Java 基础体系 · 第 49/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 国际化:Locale、ResourceBundle、日期数字和消息格式

国际化(internationalization,常写作 i18n)是让程序能够根据用户的语言、地区、文字系统、数字习惯、日期习惯和货币习惯展示内容,而不必为每一种语言复制一套程序逻辑。Java 标准库把这项能力拆成几个相互配合、但职责不同的组件:

  • Locale:描述“面向谁、采用哪种区域规则”;
  • ResourceBundle:根据 Locale 选择本地化资源;
  • java.timeDateTimeFormatter:格式化日期、时间和时区;
  • NumberFormat:格式化数字、百分比、货币和紧凑数字;
  • MessageFormat:把本地化文本与参数组合起来。

它们共同解决的是展示问题,不是把任意字符串“翻译”成另一种语言。日期和数字的本质值仍应以类型化数据保存,语言文本也应与程序逻辑分离。


一、先区分语言、地区、时区和编码

国际化中最容易出现的错误,是把几个不同概念混成一个“语言设置”。

1. Locale 不等于时区

Locale 主要描述:

  • 语言,例如 zhenfr
  • 地区,例如 CNTWUS
  • 文字系统,例如 HansHant
  • 可选的变体和扩展。

时区描述的是某个时间点如何转换为本地民用时间,例如:

  • Asia/Shanghai
  • America/New_York
  • Europe/Paris

同一个 Locale.US 的用户可能身处纽约、洛杉矶或东京。反过来,身处东京的用户可能选择英文界面。

因此,展示一个时间通常至少需要两个信息:

Locale  ->  月份名称、星期名称、日期顺序、数字符号
ZoneId  ->  Instant 转换成哪个当地时间

不能用 Locale 推断时区,也不能用时区推断语言。

2. Locale 不等于字符编码

字符编码决定字节和字符如何转换,例如 UTF-8;Locale 决定语言和区域规则。例如:

UTF-8 + Locale.CHINA

表示“用 UTF-8 传输或存储字符,并按照中国地区的语言和格式规则展示”。UTF-8 本身不会决定日期写成什么样,也不会把英文翻译成中文。

Java 源文件、类路径资源、HTTP 响应和数据库连接各自都有编码边界。ResourceBundle.properties 资源在现代 Java 中默认按 UTF-8 读取,但这不意味着所有外部输入输出都会自动采用 UTF-8。


二、Locale:区域规则的描述值

1. Locale 的组成

以下几个 Locale 的含义不同:

Locale zh = Locale.forLanguageTag("zh");
Locale zhCn = Locale.forLanguageTag("zh-CN");
Locale zhTw = Locale.forLanguageTag("zh-TW");
Locale zhHans = Locale.forLanguageTag("zh-Hans");
Locale zhHant = Locale.forLanguageTag("zh-Hant");
  • zh 只有语言,未指定地区或文字;
  • zh-CN 指中文和中国地区;
  • zh-TW 指中文和中国台湾地区;
  • zh-Hans 指简体中文文字系统;
  • zh-Hant 指繁体中文文字系统。

地区和文字系统并不完全等价。zh-CN 通常会得到简体中文习惯,但“地区”和“文字系统”在模型上是两个独立字段。

可以检查各部分:

Locale locale = Locale.forLanguageTag("zh-Hant-TW");

System.out.println(locale.getLanguage());    // zh
System.out.println(locale.getScript());      // Hant
System.out.println(locale.getCountry());     // TW
System.out.println(locale.toLanguageTag());  // zh-Hant-TW

对于来自 HTTP Accept-Language、浏览器设置或用户配置的语言标签,优先使用:

Locale locale = Locale.forLanguageTag("en-GB");

而不是手工拆分字符串后调用构造器。旧式构造器仍然可用:

Locale locale = new Locale("en", "GB");

但它不能完整表达 BCP 47 语言标签中的脚本和扩展。需要逐步构建复杂标签时,可以使用 Locale.Builder

Locale locale = new Locale.Builder()
        .setLanguage("zh")
        .setScript("Hans")
        .setRegion("CN")
        .build();

2. Locale.ROOT 与默认 Locale

Locale.ROOT 是一个不带语言和地区的中性 Locale。它适合表示协议、机器数据和与用户语言无关的规则,例如:

String normalized = input.toLowerCase(Locale.ROOT);

不能用默认 Locale 做机器协议的大小写转换。土耳其语就是典型反例:

"TITLE".toLowerCase(Locale.ENGLISH); // "title"
"TITLE".toLowerCase(new Locale("tr")); // 可能得到 "tıtle",首字母是无点小写 ı

默认 Locale 是进程或线程环境中的隐含状态。可以查询和设置:

Locale defaultLocale = Locale.getDefault();

Locale.setDefault(Locale.US);
Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
Locale.setDefault(Locale.Category.DISPLAY, Locale.CHINA);

Java 将默认 Locale 分成两个类别:

  • FORMAT:日期、数字、货币等格式化;
  • DISPLAY:显示语言名称、国家名称等元数据。

例如:

Locale formatLocale = Locale.getDefault(Locale.Category.FORMAT);
Locale displayLocale = Locale.getDefault(Locale.Category.DISPLAY);

Locale.setDefault 改变全局默认状态,会影响未显式传入 Locale 的后续操作。服务器应用不应把它当作每个请求的用户语言开关,否则一个请求可能改变另一个请求看到的格式。

3. Locale 是值对象,但不是“翻译结果”

Locale 本身是不可变值对象。它只回答“采用什么区域规则”,不包含:

  • 文案资源;
  • 用户时区;
  • 用户偏好的货币余额;
  • 翻译服务;
  • 某个具体字符串的翻译结果。

完整的格式化输入通常可以抽象为:

展示结果 = Format(数据值, Locale, ZoneId, 资源)

其中:

  • 数据值LocalDateInstantBigDecimal 等类型;
  • Locale 提供语言和区域规则;
  • ZoneId 负责时间点到当地时间的转换;
  • 资源 提供固定文案和消息模板。

三、ResourceBundle:按 Locale 查找本地化资源

1. 资源文件的组织方式

假设基础名称为:

i18n.Messages

资源文件可以组织为:

src/
└── main/
    ├── java/
    │   └── demo/Main.java
    └── resources/
        └── i18n/
            ├── Messages.properties
            ├── Messages_zh.properties
            ├── Messages_zh_CN.properties
            └── Messages_en_GB.properties

基础名称使用 Java 包名和类名,不带 .properties

ResourceBundle bundle =
        ResourceBundle.getBundle("i18n.Messages", Locale.forLanguageTag("zh-CN"));

资源内容示例:

# Messages.properties
welcome=Welcome, {0}!
itemCount={0,number} items
balance=Balance: {0,number,currency}
# Messages_zh.properties
welcome=你好,{0}!
itemCount=共 {0,number} 项
balance=余额:{0,number,currency}
# Messages_zh_CN.properties
welcome=你好,{0}!
itemCount=共 {0,number} 项
balance=余额:{0,number,currency}
# Messages_en_GB.properties
welcome=Hello, {0}!
itemCount={0,number} items
balance=Balance: {0,number,currency}

在 Java 9 及之后,基于 .propertiesPropertyResourceBundle 默认使用 UTF-8 读取资源,因此上面的中文文件可以直接保存为 UTF-8。实际项目仍应检查构建工具、编辑器和打包过程,避免资源在进入 JAR 前被转换成错误编码。

2. 候选资源与回退

调用:

ResourceBundle.getBundle("i18n.Messages", Locale.forLanguageTag("zh-CN"));

时,Java 会根据请求 Locale 生成候选资源,并尝试查找更具体到更通用的版本。典型路径是:

Messages_zh_CN.properties
Messages_zh.properties
Messages.properties

因此:

  • Messages_zh_CN.properties 可以覆盖中国地区特有的文案;
  • Messages_zh.properties 可以作为所有中文地区的通用资源;
  • Messages.properties 是最终的根资源。

这个过程的核心不是“把 zh-CN 字符串拼到文件名上”,而是由 ResourceBundle 的候选 Locale 和父级 Bundle 规则共同完成。

如果请求 en-GB,但只有 Messages.properties,则程序可以使用根资源。如果配置了默认 Locale,资源查找还可能经过默认 Locale 的回退路径。需要严格控制回退时,可以使用 ResourceBundle.Control 的自定义策略;但模块化应用和命名模块对自定义加载策略有额外限制,不能简单假设所有类路径时代码都能原样迁移。

3. 缺失键和缺失 Bundle 是两类故障

以下代码:

String text = bundle.getString("welcome");

可能出现两种不同问题:

  1. 没有任何候选 Bundle 可加载,抛出 MissingResourceException
  2. Bundle 已加载,但没有 welcome 键,读取该键时抛出 MissingResourceException

这两者的修复路径不同:

  • Bundle 缺失:检查基础名称、包路径、构建产物和类路径;
  • 键缺失:检查资源文件中的键、拼写和回退资源是否完整。

可以在启动阶段验证必需键:

static void requireKeys(ResourceBundle bundle, String... keys) {
    for (String key : keys) {
        if (!bundle.containsKey(key)) {
            throw new IllegalStateException(
                    "Missing i18n key: " + key + " in " + bundle.getBaseBundleName());
        }
    }
}

生产系统不应把资源键缺失静默变成空字符串。空字符串会让错误延迟到用户界面,诊断成本更高。

4. ResourceBundle 的缓存和生命周期

ResourceBundle 会缓存加载结果。缓存键与基础名称、Locale、类加载器等信息有关,目的是避免每次请求都读取资源文件。

这意味着:

  • 修改磁盘上的 .properties 文件,不一定会立即影响已经运行的进程;
  • 热部署或插件卸载时,类加载器和 Bundle 缓存需要一并考虑;
  • 测试中需要重新加载时,可以调用:
ResourceBundle.clearCache();

或者清理指定类加载器的缓存。

ResourceBundle 的缓存行为不等于“资源文件永远不变”。具体重新加载时机还受缓存策略和 ResourceBundle.Control 影响。默认资源适合随应用版本发布,而不适合直接当作实时配置中心。

5. ResourceBundle、Formatter 和 MessageFormat 的关系

它们的职责链可以表示为:

flowchart LR
    A[用户 Locale] --> B[ResourceBundle]
    B --> C[消息模板]
    A --> D[NumberFormat / DateTimeFormatter]
    E[类型化参数] --> D
    C --> F[MessageFormat]
    D --> F
    E --> F
    F --> G[最终展示文本]

关键路径是:

  1. Locale 选择资源 Bundle;
  2. Bundle 返回带占位符的消息模板;
  3. MessageFormat 解析模板;
  4. 参数中的数字或 java.util.Date 使用消息的 Locale 格式化;
  5. 日期时间若使用 java.time,通常先由 DateTimeFormatter 格式化,再作为字符串传给消息模板。

四、日期和时间:值、时区、格式必须分开

1. java.time 类型表达不同语义

Java 8 引入的 java.time 是现代日期时间 API,Java 25 继续使用这一模型。常见类型的语义不同:

类型 表示
LocalDate 没有时间和时区的日期
LocalTime 没有日期和时区的时间
LocalDateTime 没有时区的日期时间
Instant UTC 时间线上的一个瞬时点
ZonedDateTime 某个时区中的日期时间
OffsetDateTime 带固定 UTC 偏移量的日期时间

例如:

Instant instant = Instant.parse("2025-09-01T12:00:00Z");

ZonedDateTime shanghai =
        instant.atZone(ZoneId.of("Asia/Shanghai"));

ZonedDateTime newYork =
        instant.atZone(ZoneId.of("America/New_York"));

两个结果代表同一个瞬时点,但当地日期和时间可能不同。若直接拿 Instant 做“当地日期”展示,必须先指定 ZoneId;若只有 LocalDateTime,则它本身无法说明对应哪个瞬时点。

2. 本地化日期格式

DateTimeFormatter 可以按 Locale 选择本地化日期样式:

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;

LocalDate date = LocalDate.of(2025, 9, 1);

DateTimeFormatter formatter = DateTimeFormatter
        .ofLocalizedDate(FormatStyle.LONG)
        .withLocale(Locale.CHINA);

System.out.println(formatter.format(date));

FormatStyle 有:

  • FULL
  • LONG
  • MEDIUM
  • SHORT

具体输出由 JDK 的 Locale 数据决定。不能把某个 JDK 版本、操作系统或 Locale Provider 下的样式字符串当作跨版本永久保证。比如 FULL 通常包含星期名称,但具体标点和顺序属于区域格式数据的一部分。

日期和 Locale 是在格式化时结合的:

DateTimeFormatter formatter = DateTimeFormatter
        .ofLocalizedDateTime(FormatStyle.FULL)
        .withLocale(Locale.US);

String text = formatter.format(
        instant.atZone(ZoneId.of("America/New_York")));

这里的格式器需要能够访问日期、时间和时区信息,因此传入 ZonedDateTime 比传入 LocalDate 更合适。

3. 固定协议格式与用户展示格式不能混用

用户展示:

DateTimeFormatter display =
        DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG)
                .withLocale(Locale.GERMANY);

机器协议:

DateTimeFormatter protocol =
        DateTimeFormatter.ISO_LOCAL_DATE;

String wireValue = protocol.format(LocalDate.of(2025, 9, 1));
// 2025-09-01

协议格式需要稳定、可解析、与用户 Locale 无关;用户展示需要根据 Locale 改变。把 09/01/2025 直接写入数据库或接口,会产生“1 月 9 日还是 9 月 1 日”的歧义。

4. Calendar 和 DateFormat 的历史边界

旧 API 仍然存在:

DateFormat format =
        DateFormat.getDateInstance(DateFormat.LONG, Locale.US);
String text = format.format(new Date());

DateFormat 是可变对象,通常不是线程安全的。Calendar 也有复杂的可变状态和旧式字段语义。新代码一般应使用:

  • Instant 表示时间线瞬时点;
  • LocalDate 表示业务日期;
  • ZonedDateTime 表示带时区的日期时间;
  • DateTimeFormatter 负责格式化。

java.util.Datejava.time 仍可互操作:

Date legacy = Date.from(instant);
Instant modern = legacy.toInstant();

MessageFormat 的日期参数主要基于 java.util.Date 和相关旧式格式类型,因此当消息模板直接格式化日期时,经常需要把 Instant 转成 Date,或者先使用 DateTimeFormatter 生成字符串。


五、数字、百分比和货币格式

1. NumberFormat 的职责

NumberFormat 根据 Locale 格式化数字:

import java.math.BigDecimal;
import java.text.NumberFormat;
import java.util.Locale;

BigDecimal amount = new BigDecimal("1234567.89");

NumberFormat number =
        NumberFormat.getNumberInstance(Locale.GERMANY);

NumberFormat currency =
        NumberFormat.getCurrencyInstance(Locale.US);

NumberFormat percent =
        NumberFormat.getPercentInstance(Locale.CHINA);

System.out.println(number.format(amount));
System.out.println(currency.format(amount));
System.out.println(percent.format(0.256));

典型结果可能类似:

1.234.567,89
$1,234,567.89
26%

但货币符号、空格、负号、分组符号和小数位都由 Locale 及货币规则决定,实际输出应以当前 JDK 的区域数据为准。

百分比有一个重要的数值语义:

format(0.256) -> 26%

百分比格式通常把数值乘以 100 后再显示。因此传入 26 往往得到 2,600%,不能把已经乘过 100 的展示值再次传入。

2. 货币 Locale 不一定等于业务货币

NumberFormat currency =
        NumberFormat.getCurrencyInstance(Locale.CHINA);

通常会使用中国地区关联的人民币货币,但这表达的是“按中国地区的货币展示规则格式化”,不一定表达订单的真实结算货币。

如果订单明确是 EUR,应显式设置:

NumberFormat currency =
        NumberFormat.getCurrencyInstance(Locale.US);
currency.setCurrency(Currency.getInstance("EUR"));

System.out.println(currency.format(new BigDecimal("1234.50")));

这里有两个独立决定:

  • Currency:金额属于哪种货币;
  • Locale:货币符号、位置、分组和小数展示习惯如何表示。

不能仅根据用户 Locale 推断数据库金额的货币。

3. 精度和类型选择

double 是二进制浮点数,不适合作为财务金额的精确存储类型:

System.out.println(0.1 + 0.2);
// 不是精确的十进制 0.3

金额计算通常使用 BigDecimal,并从字符串构造:

BigDecimal amount = new BigDecimal("0.10");

格式化不会修复前面的计算误差。NumberFormat 只负责把已有数值转换成展示文本;舍入规则应在业务计算或明确的展示边界中定义。

4. DecimalFormat 的区域化和可变性

可以基于 Locale 创建更具体的数字格式:

NumberFormat format =
        NumberFormat.getNumberInstance(Locale.US);

format.setMinimumFractionDigits(2);
format.setMaximumFractionDigits(2);

System.out.println(format.format(new BigDecimal("12.5")));
// 12.50

DecimalFormatNumberFormat 通常是可变的、非线程安全的。不要把同一个实例无保护地放入单例服务中并发使用。可以:

  • 每次创建;
  • 使用 ThreadLocal
  • 在不共享实例的作用域内使用;
  • 或改用不可变、线程安全的 DateTimeFormatter 处理日期。

5. 紧凑数字格式

Java 还提供紧凑数字格式:

NumberFormat compact =
        NumberFormat.getCompactNumberInstance(
                Locale.US,
                NumberFormat.Style.SHORT);

System.out.println(compact.format(1_200_000));
// 1.2M(具体细节依 Locale 数据而定)

紧凑格式适合用户界面中的“1.2M”一类展示,不适合账单、审计日志或需要精确阅读的业务数据,因为它可能丢失部分数量信息。


六、MessageFormat:把模板和参数组合起来

1. 基本模式

MessageFormat 使用带编号的参数:

String pattern = "Hello, {0}! You have {1,number} messages.";

MessageFormat messageFormat =
        new MessageFormat(pattern, Locale.US);

String result = messageFormat.format(
        new Object[] {"Alice", 1234});

System.out.println(result);

输出可能是:

Hello, Alice! You have 1,234 messages.

模式中的 {0} 表示第一个参数,{1,number} 表示第二个参数按数字格式化。参数编号可以重复:

welcome=用户 {0} 于 {1} 登录,当前余额为 {2,number,currency}。

对应调用:

MessageFormat mf = new MessageFormat(
        bundle.getString("welcome"),
        Locale.CHINA);

String text = mf.format(new Object[] {
        "张三",
        "2025-09-01",
        new BigDecimal("1234.50")
});

2. 日期参数的类型边界

经典 MessageFormatdatetime 子格式主要面向 java.util.Date

Date date = Date.from(
        Instant.parse("2025-09-01T12:00:00Z"));

MessageFormat mf = new MessageFormat(
        "Time: {0,date,full} {0,time,short}",
        Locale.US);

System.out.println(mf.format(new Object[] {date}));

如果直接传入 LocalDateInstant,不能假设 MessageFormat 会自动理解其时区和格式语义。更清晰的做法是先使用 java.time 确定时区并格式化:

Instant instant = Instant.parse("2025-09-01T12:00:00Z");

String dateText = DateTimeFormatter
        .ofLocalizedDate(FormatStyle.LONG)
        .withLocale(Locale.CHINA)
        .format(instant.atZone(ZoneId.of("Asia/Shanghai")));

MessageFormat mf = new MessageFormat(
        "会议日期:{0}",
        Locale.CHINA);

String result = mf.format(new Object[] {dateText});

这里的时序很明确:

  1. Instant 是时间线上的点;
  2. ZoneId 把它转换成上海当地日期;
  3. DateTimeFormatter 按中文 Locale 生成日期文本;
  4. MessageFormat 只负责把文本插入消息。

3. 撇号是 MessageFormat 的语法字符

MessageFormat 使用撇号转义大括号和其他模式字符。要输出一个字面撇号,通常需要写成两个连续撇号:

String pattern = "用户 ''{0}'' 已创建";
MessageFormat mf = new MessageFormat(pattern, Locale.CHINA);

System.out.println(mf.format(new Object[] {"alice"}));
// 用户 'alice' 已创建

如果翻译人员把普通英文撇号直接放入包含占位符的模板,可能导致参数不再被解析,表现为:

  • 原样显示 {0}
  • 参数消失;
  • IllegalArgumentException
  • 文本在某些语言下被截断或含义改变。

因此本地化资源应像代码一样进行模板校验,而不是只检查是否存在对应键。

4. MessageFormat 不是完整的复数规则引擎

传统 MessageFormat 支持:

  • 普通参数;
  • number
  • date
  • time
  • choice

例如:

String pattern = "{0,choice,0#没有文件|1#有一个文件|1<有多个文件}";
MessageFormat mf = new MessageFormat(pattern, Locale.CHINA);

System.out.println(mf.format(new Object[] {2}));
// 有多个文件

choice 是基于数值区间的简单选择,不等于完整的语言复数系统。英语的 one/other 已经比简单区间更复杂,阿拉伯语、俄语等语言的复数类别更加丰富。不要把下面这种模式当作所有语言的通用方案:

{0,choice,1#file|1<files}

如果应用需要复杂复数、性别、嵌套选择或 ICU MessageFormat 语法,应使用明确支持这些规则的国际化库,并确认其版本、资源格式和部署方式。Java SE 的经典 MessageFormat 本身不能自动把一个整数正确翻译成所有语言的复数类别。


七、一个可运行的端到端示例

下面的示例展示完整数据流:

  • 根据 Locale 载入资源;
  • 使用 NumberFormat 格式化金额;
  • 使用 DateTimeFormatter 格式化 Instant
  • 使用 MessageFormat 组合最终消息;
  • 显式处理缺失资源。

1. 项目文件

src/
├── Main.java
└── i18n/
    ├── Messages.properties
    ├── Messages_zh_CN.properties
    └── Messages_en_US.properties

src/i18n/Messages.properties

welcome=Welcome, {0}!
summary=Balance: {0,number,currency}; date: {1}

src/i18n/Messages_zh_CN.properties

welcome=你好,{0}!
summary=余额:{0,number,currency};日期:{1}

src/i18n/Messages_en_US.properties

welcome=Hello, {0}!
summary=Balance: {0,number,currency}; date: {1}

src/Main.java

import java.math.BigDecimal;
import java.text.MessageFormat;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.Locale;
import java.util.ResourceBundle;

public class Main {
    public static void main(String[] args) {
        Locale locale = Locale.forLanguageTag(
                args.length == 0 ? "zh-CN" : args[0]);

        ZoneId zone = ZoneId.of(
                args.length < 2 ? "Asia/Shanghai" : args[1]);

        ResourceBundle bundle;
        try {
            bundle = ResourceBundle.getBundle("i18n.Messages", locale);
        } catch (java.util.MissingResourceException e) {
            throw new IllegalStateException(
                    "Cannot load i18n.Messages for " + locale, e);
        }

        requireKeys(bundle, "welcome", "summary");

        String welcome = new MessageFormat(
                bundle.getString("welcome"), locale)
                .format(new Object[] {"Alice"});

        BigDecimal balance = new BigDecimal("1234567.89");

        String dateText = DateTimeFormatter
                .ofLocalizedDate(FormatStyle.LONG)
                .withLocale(locale)
                .format(Instant.parse("2025-09-01T12:00:00Z")
                        .atZone(zone));

        String summary = new MessageFormat(
                bundle.getString("summary"), locale)
                .format(new Object[] {balance, dateText});

        System.out.println(welcome);
        System.out.println(summary);
    }

    private static void requireKeys(
            ResourceBundle bundle, String... keys) {
        for (String key : keys) {
            if (!bundle.containsKey(key)) {
                throw new IllegalStateException(
                        "Missing resource key: " + key);
            }
        }
    }
}

2. 编译和运行

在项目根目录执行:

javac -d out src/Main.java
cp -R src/i18n out/
java -cp out Main zh-CN Asia/Shanghai

可能得到:

你好,Alice!
余额:¥1,234,567.89;日期:2025年9月1日

执行:

java -cp out Main en-US America/New_York

可能得到:

Hello, Alice!
Balance: $1,234,567.89; date: September 1, 2025

日期的具体标点、货币符号和空格可能因 Java 版本使用的 Locale 数据而不同,但以下因果关系是稳定的:

  • zh-CN 选择中文资源;
  • en-US 选择英文资源;
  • Asia/ShanghaiAmerica/New_York 影响同一 Instant 对应的当地日期;
  • MessageFormat 使用传入的 Locale 格式化 {0,number,currency}
  • 日期文本已经在 DateTimeFormatter 阶段完成本地化。

如果把资源目录漏复制到 out/i18n,运行时会出现 MissingResourceException。如果只漏掉 summary 键,则 getString("summary") 处失败,而 welcome 仍然可以正常显示。


八、线程安全、缓存和并发边界

这些 API 的并发属性不同,不能因为它们都属于标准库就混用。

通常可以共享的对象

  • Locale:不可变;
  • DateTimeFormatter:不可变且线程安全;
  • ZoneId:不可变,适合共享;
  • ResourceBundle:资源 Bundle 通常作为只读对象使用,加载和缓存由标准库管理。

不应无保护共享的对象

  • NumberFormat
  • DecimalFormat
  • MessageFormat
  • 旧式 DateFormat
  • Calendar

例如,不要这样写:

class FormatterHolder {
    static final NumberFormat FORMAT =
            NumberFormat.getCurrencyInstance(Locale.US);
}

然后让所有请求并发调用 FORMAT.format(...)。同一个实例内部可能保存可变的格式状态,结果可能错误或出现并发问题。

较简单的做法是按调用创建:

String formatMoney(BigDecimal value, Locale locale) {
    return NumberFormat
            .getCurrencyInstance(locale)
            .format(value);
}

如果性能分析证明创建成本需要优化,应在明确的线程隔离策略下使用 ThreadLocal 或对象池,并测试清理、Locale 切换和请求生命周期。不要在没有测量的情况下通过共享可变格式器换取所谓的性能。


九、常见误解与失败表现

1. “把默认 Locale 设置成用户 Locale 就完成国际化了”

错误原因是默认 Locale 是全局隐含状态。并发服务器中,请求 A 设置中文,可能影响请求 B 的英文格式化。

正确做法是让 Locale 显式流经调用链:

String render(Invoice invoice, Locale locale, ZoneId zone) {
    // 使用 locale 和 zone 创建或选择格式器
}

默认 Locale 更适合命令行程序、桌面程序或应用启动时的默认值,不适合表示每个请求的独立用户偏好。

2. “语言代码就是 Locale”

enen-USen-GB 可能使用不同的日期顺序、货币和数字规则。只保存语言而丢弃地区,会让程序无法表达区域差异。

3. “日期格式化只需要一个 Locale”

对于 Instant,还需要 ZoneId。如果只传 Locale,程序无法知道 UTC 时间点要转换成哪个当地日期。

反例:

Instant instant = ...;
// 直接把 Instant 当成用户当地日期,没有 ZoneId

这会在跨时区用户或夏令时切换时产生日期错误。

4. “数字字符串可以参与业务计算”

以下代码丢失了类型语义:

String price = "1,234.50";

逗号、小数点和货币符号都可能随 Locale 改变。数据库和业务层应保存 BigDecimal 等类型,只有在输出边界才调用 NumberFormat

5. “资源文件有了,所有消息自然都会正确”

资源文件只提供文本,不会自动检查:

  • 占位符数量是否一致;
  • 参数编号是否正确;
  • {0,number} 是否真的传入数字;
  • {0,date} 是否传入可识别的日期类型;
  • 撇号是否破坏模板;
  • 翻译是否改变了业务含义。

应在构建或测试阶段解析每个 Locale 的模板,并用代表性参数执行一次格式化。

6. “把用户输入拼进翻译模板最灵活”

直接拼接存在两个问题:

"用户 " + name + " 有 " + count + " 个项目"

第一,语序无法适应所有语言;第二,参数可能带来 HTML、日志注入或界面转义问题。

更适合的方式是:

itemCount=用户 {0} 有 {1,number} 个项目

但最终输出进入 HTML、JSON、日志或 SQL 时,仍需在对应边界进行转义。MessageFormat 不是 HTML 转义器,也不是安全模板引擎。


十、生产诊断与取舍

1. 资源加载失败的排查顺序

出现 MissingResourceException 时,可以按以下路径检查:

  1. 基础名称是否写成 i18n.Messages,而不是文件名;
  2. 资源是否位于类路径或模块资源中;
  3. 资源文件是否被打包进 JAR;
  4. Locale 的语言、地区和脚本是否符合命名约定;
  5. 是否存在根资源 Messages.properties
  6. 键是否拼写一致;
  7. 是否因为模块边界或自定义类加载器导致资源不可见;
  8. 文件编码是否正确。

可以查看 JAR 内容:

jar tf app.jar | grep 'i18n/Messages'

预期至少能看到类似:

i18n/Messages.properties
i18n/Messages_zh_CN.properties

如果资源存在但中文变成乱码,应检查资源实际编码和构建过程,而不是先修改 Locale。

2. Locale 数据不是完全静态的业务契约

Java 的区域格式数据来自 JDK 支持的 Locale 数据提供者。不同 JDK 版本、Locale Provider 配置或数据更新可能导致:

  • 货币符号变化;
  • 窄空格和普通空格变化;
  • 日期标点变化;
  • 月份、星期名称变化;
  • 某些地区的默认货币或格式规则更新。

因此测试本地化输出时,应区分:

  • 必须稳定的协议字符串;
  • 允许随 Locale 数据变化的用户展示文本。

对用户展示文本做精确字符串断言时,要固定 Java 版本和 Locale 数据环境,或者测试结构性性质,例如包含年份、货币值和关键参数,而不是把所有标点都写死。

3. 资源回退是可用性机制,不是翻译质量保证

拥有 Messages.properties 可以避免资源完全缺失,但如果根资源是英文,中文用户可能看到未翻译的英文。工程上可以选择:

  • 根资源作为英文;
  • 每种受支持语言都必须提供完整资源;
  • 启动时扫描资源键集合并报告缺失;
  • 对缺失翻译使用显式监控,而不是静默回退。

回退路径应是可解释的,否则用户看到混合语言时很难知道实际使用的是哪个 Bundle。调试时记录请求 Locale、实际加载的资源层级和缺失键,但不要把敏感业务参数直接写入日志。


十一、一个清晰的设计边界

一个典型请求的国际化数据流可以整理为:

请求语言偏好
    ↓
解析并规范化 Locale
    ↓
选择 ResourceBundle
    ↓
取得消息模板
    ↓
业务层提供类型化参数
    ├── Instant + ZoneId → ZonedDateTime
    │                    → DateTimeFormatter → 日期文本
    └── BigDecimal + Locale → NumberFormat → 数字文本
    ↓
MessageFormat 组合模板与参数
    ↓
根据输出环境进行 HTML/JSON/日志等专门转义

其中每个组件都不应越权:

  • Locale 不负责时区;
  • ZoneId 不负责翻译;
  • ResourceBundle 不负责数字计算;
  • DateTimeFormatter 不负责消息语序;
  • NumberFormat 不负责货币业务逻辑;
  • MessageFormat 不负责 HTML 安全;
  • 默认 Locale 不应替代请求级 Locale。

掌握这些边界后,国际化不再是给字符串加几个语言后缀,而是把“类型化数据、区域规则、语言资源和最终展示”按明确的数据流连接起来。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。