和小伙伴们仔细梳理一下 Spring 国际化吧!从用法到源码!

国际化(Internationalization,简称 I18N)是指在 Java 应用程序中实现国际化的技术和方法。Java 提供了一套强大的国际化支持,使开发人员能够编写适应不同语言、地区和文化的应用程序。

Java 国际化的主要目标是使应用程序能够在不同语言环境下运行,并提供相应的本地化体验。以下是 Java 国际化的一些关键概念和组件:

  1. ResourceBundle:ResourceBundle 是 Java 国际化的核心组件之一,用于存储本地化的文本和其他资源。它根据当前的 Locale(区域设置)加载相应的资源文件,以提供与用户语言和地区相匹配的内容。
  2. Locale:Locale 表示特定的语言和地区。Java 中的 Locale 对象包含了语言、国家/地区和可选的变体信息。通过使用 Locale,可以确定应用程序应该使用哪种语言和地区的本地化资源。
  3. MessageFormat:MessageFormat 是 Java 提供的一种格式化消息的工具类。它允许开发人员根据不同的语言和地区,将占位符替换为相应的值,并进行灵活的消息格式化。
  4. DateFormat 和 NumberFormat:Java 提供了 DateFormat 和 NumberFormat 类,用于在不同的语言和地区格式化日期、时间和数字。这些类可以根据 Locale 的不同,自动适应不同的语言和地区的格式规则。
  5. Properties 文件:Properties 文件是一种常见的配置文件格式,用于存储键值对。在 Java 国际化中,可以使用 Properties 文件来存储本地化文本和其他资源的键值对。

通过使用 Java 国际化的技术和组件,开发人员可以轻松地为 Java 应用程序提供多语言支持。应用程序可以根据用户的 Locale 加载相应的资源,并根据不同的语言和地区提供本地化的用户界面、日期时间格式、数字格式等。这样,应用程序就能够更好地适应全球用户的需求,提供更好的用户体验。

1. Java 国际化

经过前面的介绍,小伙伴们已经了解到,Java 本身实际上已经提供了一整套的国际化方案,Spring 中当然也有国际化,Spring 中的国际化实际上就是对 Java 国际化的二次封装。

所以我们先来了解下 Java 中的国际化怎么玩。

1.1 基本用法

首先我们需要定义自己的资源文件,资源文件命名方式是:

  • 资源名_语言名称_国家/地区名称.properties

其中 _语言名称_国家/地区名称 可以省略,如果省略的话,这个文件将作为默认的资源文件。

现在假设我在 resources 目录下创建如下三个资源文件:

三个资源文件的内容分别如下。

content.properties:

1
hello=默认内容

content_en_US.properties:

1
hello=hello world

content_zh_CN.properties:

1
hello=你好世界!

接下来我们看下 Java 代码如何加载。

1
2
3
4
5
Locale localeEn = new Locale("en", "US");
Locale localeZh = new Locale("zh", "CN");
ResourceBundle res = ResourceBundle.getBundle("content", localeZh);
String hello = res.getString("hello");
System.out.println("hello = " + hello);

首先我们先来定义 Locale 对象,这个 Locale 对象相当于定义本地环境,说明自己当前的语言环境和地区信息,然后调用 ResourceBundle.getBundle 方法去加载配置文件,该方法第一个参数就是资源的名称,第二个参数则是当前的环境,加载完成之后,就可以从 res 变量中提取出来数据了。而且这个提取是根据当前系统环境提取的。

在上面的案例中,如果配置的 locale 实际上并不存在,那么就会读取 content.properties 文件中的内容(相当于这就是默认的配置)。

1.2 Format

Java 中的国际化还提供了一些 Format 对象,用来格式化传入的资源。

Format 主要有三类,分别是:

  1. MessageFormat:这个是字符串格式化,可以在资源中配置一些占位符,在提取的时候再将这些占位符进行填充。
  2. DateFormat:这个是日期的格式化。
  3. NumberFormat:这个是数字的格式化。

不过这三个完全可以单独当成工具类来使用,并非总是要结合 I18N 一起来用,实际上我们在日常的开发中,就会经常使用 DateFormat 的子类 SimpleDateFormat。

这里我把三个分别举个例子给大家演示下。

MessageFormat

对于这种,我们在定义资源的时候,可以使用占位符,例如下面这样:

1
2
hello=你好世界!
name=你好 {0},欢迎来到 {1}

那么这里 {0}{1} 就是占位符,将来读取到这个字符串之后,可以给占位符的位置填充数据。

1
2
3
4
5
6
7
Locale localeEn = new Locale("en", "US");
Locale localeZh = new Locale("zh", "CN");
ResourceBundle res = ResourceBundle.getBundle("content", localeZh);
MessageFormat format = new MessageFormat(res.getString("name"));
Object[] arguments = new Object[]{"javaboy", "Spring源码学习课程"};
String s = format.format(arguments);
System.out.println("s = " + s);

那么最终打印结果如下:

DateFormat

这个是根据当前环境信息对日期进行格式化,中文的就格式化为中文日期,英文就格式化为英文日期:

1
2
3
4
5
Date date = new Date();
DateFormat df = DateFormat.getDateInstance(DateFormat.LONG, new Locale("zh", "CN"));
DateFormat df2 = DateFormat.getDateInstance(DateFormat.LONG, new Locale("en", "US"));
System.out.println(df.format(date));
System.out.println(df2.format(date));

参数 LONG 表示演示完整的日期信息。

执行结果如下:

NumberFormat

数字格式化这块比较典型的就是关于货币的格式化了,我们来看个例子:

1
2
3
4
5
6
7
Locale localeEn = new Locale("en", "US");
Locale localeZh = new Locale("zh", "CN");
NumberFormat formatZh = NumberFormat.getCurrencyInstance(localeZh);
NumberFormat formatEn = NumberFormat.getCurrencyInstance(localeEn);
double num = 199.99;
System.out.println("formatZh.format(num) = " + formatZh.format(num));
System.out.println("formatEn.format(num) = " + formatEn.format(num));

根据不同的 Locale 来获取不同的货币格式化实例。

最终打印结果如下:

Java 中提供的国际化,差不多就这么玩!

2. Spring 国际化

Spring 的国际化,实际上就是在 Java 国际化的基础之上做了一些封装,提供了一些新的能力。

2.1 实践

先来一个简单的案例来看看 Spring 中的国际化怎么使用。

首先我们的资源文件跟前面第一小节的一致,不再赘述。

Spring 中需要我们首先提供一个 MessageSource 实例,常用的 MessageSource 实例是 ReloadableResourceBundleMessageSource,这是一个具备自动刷新能力的 MessageSource,即,用户修改了配置文件之后,在项目不重启的情况下,新的配置就能生效。

配置方式很简答,我们只需要将这个 Bean 注册到 Spring 容器中:

1
2
3
4
5
6
@Bean
ReloadableResourceBundleMessageSource messageSource() {
ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
source.setBasenames("content");
return source;
}

这个 Bean 在注册的时候,有一个固定要求:beanName 必须是 messageSource。为什么是这样,等松哥一会分析源码的时候大家就看明白了。为 bean 设置 basename,也就是配置文件的基础名称。

接下来我们就可以使用了:

1
2
3
4
5
6
7
8
AnnotationConfigApplicationContext ctx = new AnnotationConfigApplicationContext(JavaConfig.class);
MessageSource source = ctx.getBean(MessageSource.class);
Locale localeZh = new Locale("zh", "CN");
String hello = source.getMessage("hello", null, localeZh);
System.out.println("hello = " + hello);
Object[] params = new Object[]{"javaboy","world"};
String name = source.getMessage("name", params, localeZh);
System.out.println("name = " + name);

当然,一般在应用中,我们会对获取资源文件内容的方法进行封装再用。

封装类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Component
public class MessageUtils implements MessageSourceAware {
private static MessageSource messageSource;
private static Locale currentLocale = new Locale("zh","CN");

public static Locale getCurrentLocale() {
return currentLocale;
}

public static void setCurrentLocale(Locale currentLocale) {
MessageUtils.currentLocale = currentLocale;
}

public static String getMessage(String key) {
return messageSource.getMessage(key, null, key, currentLocale);
}

public static String getMessage(String key, Locale locale) {
return messageSource.getMessage(key, null, key, locale == null ? currentLocale : locale);
}

public static String getMessage(String key, String defaultMessage) {
return messageSource.getMessage(key, null, defaultMessage == null ? key : defaultMessage, currentLocale);
}

public static String getMessage(String key, String defaultMessage, Locale locale) {
return messageSource.getMessage(key, null, defaultMessage == null ? key : defaultMessage, locale == null ? currentLocale : locale);
}

public static String getMessage(String key, Object[] placeHolders) {
return messageSource.getMessage(key, placeHolders, key, currentLocale);
}

public static String getMessage(String key, Object[] placeHolders, String defaultMessage) {
return messageSource.getMessage(key, placeHolders, defaultMessage == null ? key : defaultMessage, currentLocale);
}

public static String getMessage(String key, Object[] placeHolders, Locale locale) {
return messageSource.getMessage(key, placeHolders, key, locale == null ? currentLocale : locale);
}

public static String getMessage(String key, Object[] placeHolders, String defaultMessage, Locale locale) {
return messageSource.getMessage(key, placeHolders, defaultMessage == null ? key : defaultMessage, locale == null ? currentLocale : locale);
}

@Override
public void setMessageSource(MessageSource messageSource) {
this.messageSource = messageSource;
}

}

这个工具类实现了 MessageSourceAware 接口,这样就可以拿到 messageSource 对象,然后将 getMessage 方法进行封装。

用法其实并不难。

2.2 原理分析

再来看原理分析。

首先,在之前的分析中,小伙伴们知道,Spring 容器在初始化的时候,都会调用到 AbstractApplicationContext#refresh 方法,这个方法内部又调用了 initMessageSource 方法,没错,这个方法就是用来初始化 MessageSource 的,我们来看下这个方法的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected void initMessageSource() {
ConfigurableListableBeanFactory beanFactory = getBeanFactory();
if (beanFactory.containsLocalBean(MESSAGE_SOURCE_BEAN_NAME)) {
this.messageSource = beanFactory.getBean(MESSAGE_SOURCE_BEAN_NAME, MessageSource.class);
// Make MessageSource aware of parent MessageSource.
if (this.parent != null && this.messageSource instanceof HierarchicalMessageSource hms &&
hms.getParentMessageSource() == null) {
// Only set parent context as parent MessageSource if no parent MessageSource
// registered already.
hms.setParentMessageSource(getInternalParentMessageSource());
}
}
else {
// Use empty MessageSource to be able to accept getMessage calls.
DelegatingMessageSource dms = new DelegatingMessageSource();
dms.setParentMessageSource(getInternalParentMessageSource());
this.messageSource = dms;
beanFactory.registerSingleton(MESSAGE_SOURCE_BEAN_NAME, this.messageSource);
}
}

这个方法首先判断容器中是否存在一个名为 messageSource 的 Bean(MESSAGE_SOURCE_BEAN_NAME 常量的实际值就是 messageSource),如果存在,则检查当前容器是否存在 parent,如果存在 parent 容器,那么 parent 容器可能也会有一个 messageSource 对象,就把 parent 容器的 messageSource 对象设置给当前的 messageSource 作为 parentMessageSource。如果当前容器中不存在一个名为 messageSource 的 bean,那么系统就会自动创建一个 DelegatingMessageSource 对象并注册到 Spring 容器中。

从前面的介绍中大家就明白了为什么我们向 Spring 容器中注册 ReloadableResourceBundleMessageSource 的时候,beanName 必须是 messageSource,如果 beanName 不是 messageSource,那么 Spring 容器就会自动创建另外一个 MessageSource 对象了,这就导致最终在获取资源的时候出错。

好啦,这是 MessageSource Bean 加载的方式。加载完成之后,这个 Bean 将来会被初始化,然后我们在需要的时候,调用这个 Bean 中的 getMessage 方法去获取资源,现在我们就去分析 getMessage 方法。

松哥这里的分析就以 ReloadableResourceBundleMessageSource 来展开,因为在整个 MessageSource 体系中,ReloadableResourceBundleMessageSource 是相对比较复杂的一个了,把这个搞懂了,剩下的几个其实都很好懂了。

这个 getMessage 方法实际上是在 ReloadableResourceBundleMessageSource 的父类 AbstractMessageSource 中,换句话说,不同类型的 MessageSource 调用的 getMessage 方法是同一个:

1
2
3
4
5
6
7
8
9
10
11
@Override
public final String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) {
String msg = getMessageInternal(code, args, locale);
if (msg != null) {
return msg;
}
if (defaultMessage == null) {
return getDefaultMessage(code);
}
return renderDefaultMessage(defaultMessage, args, locale);
}

这个方法分两步,首先调用 getMessageInternal 尝试去解析出来 key 对应的 value,如果没有找到合适的 value,那么就会使用默认值。

我们先来看 getMessageInternal 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Nullable
protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
if (code == null) {
return null;
}
if (locale == null) {
locale = Locale.getDefault();
}
Object[] argsToUse = args;
if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
String message = resolveCodeWithoutArguments(code, locale);
if (message != null) {
return message;
}
}
else {
argsToUse = resolveArguments(args, locale);
MessageFormat messageFormat = resolveCode(code, locale);
if (messageFormat != null) {
synchronized (messageFormat) {
return messageFormat.format(argsToUse);
}
}
}
Properties commonMessages = getCommonMessages();
if (commonMessages != null) {
String commonMessage = commonMessages.getProperty(code);
if (commonMessage != null) {
return formatMessage(commonMessage, args, locale);
}
}
return getMessageFromParent(code, argsToUse, locale);
}

这个方法的逻辑还是比较简单的,如果传入的 code 为空就直接返回 null,如果传入的 locale 为空,则获取一个默认的 locale,这个默认的 locale 是根据当前操作系统的信息获取到的一个环境。

接下来,如果不想使用 MessageFormat 并且也没有传入 MessageFormat 所需要的参数,那么就调用 resolveCodeWithoutArguments 方法去解析获取到 Message 对象。如果是需要用到 MessageFormat 对象,那么就调用 resolveCode 方法先去获取到一个 MessageFormat,然后格式化数据并返回。

如果前面两个都没能返回,那么就获取到一个公共的资源,然后尝试去解析 code,如果公共资源也还是没能解析到,那么就去 parent 中尝试解析。

这里涉及到的几个方法,我们分别来看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
if (getCacheMillis() < 0) {
PropertiesHolder propHolder = getMergedProperties(locale);
String result = propHolder.getProperty(code);
if (result != null) {
return result;
}
}
else {
for (String basename : getBasenameSet()) {
List<String> filenames = calculateAllFilenames(basename, locale);
for (String filename : filenames) {
PropertiesHolder propHolder = getProperties(filename);
String result = propHolder.getProperty(code);
if (result != null) {
return result;
}
}
}
}
return null;
}

resolveCodeWithoutArguments 方法在 ReloadableResourceBundleMessageSource 类中被重写过了,所以这里我们直接看重写后的方法。

首先会去判断缓存时间是否小于 0,小于 0 表示不缓存,那么就去现场加载数据,否则就从缓存中读取数据。如果是现场加载数据的话,那么就是根据传入的 locale 对象调用 getMergedProperties 方法,获取到 PropertiesHolder 对象,这个对象中封装了读取到的资源文件以及资源文件的时间戳(通过这个时间戳可以判断资源文件是否被修改过)。

getMergedProperties 方法的源码我这里就不贴出来了,就大概和大家说一下大致的流程:首先会根据传入的 basename 和 locale 定位出文件名,会定义出来多种文件名,例如传入的 basename 是 content,locale 是 zh_CN,那么最终生成的可能文件名有五种,如下:

  • content_zh_CN
  • content_zh
  • content_en_CN
  • content_en
  • content

这五种,前两个是根据传入的参数生成的,接下来两个是根据当前系统信息生成的文件名,最后一个则是默认的文件名,接下来就会根据这五个不同的文件名尝试去加载配置文件的,加载配置文件的时候是倒着来的,就是先去查找 content.properties 文件,找到了,就把找到的数据存入到一个 Properties 中,然后继续找上一个,上一个文件要是存在,则将之也存入到 properties 配置文件中,这样,如果有重复的 key,后者就会覆盖掉前者,换言之,上面这个文件名列表中,第一个文件名的优先级是最高的,因为它里边的 key 如果跟前面的 key 重复了,会覆盖掉前面的 key。

这就是 getMergedProperties 方法的大致逻辑。最后就从这个方法的返回值中,找到我们需要的数据返回。

这是不缓存的情况,如果缓存的话,那么就去缓存中读取数据并返回。

大家看去缓存中读取数据的时候,首先也是调用 calculateAllFilenames 方法获取到所有可能的文件名(获取到的结果就是上面列出来的),然后根据文件名去获取数据,这次获取是顺序获取的,即先去查找 content_zh_CN 这个文件,存在的话就直接返回了,这也显示了上面的列表中,从上往下优先级依次降低。然后遍历文件名,调用 getProperties 方法获取对应的 properties 文件,这个获取的过程中,会去检查文件的时间戳,检查资源文件是否被修改过,如果被修改过就重新读取,否则就使用之前已经读取到的缓存数据。

以上就是 resolveCodeWithoutArguments 方法的大概逻辑。

resolveCode 方法的逻辑实际上和 resolveCodeWithoutArguments 类似,唯一的区别在于,resolveCodeWithoutArguments 方法中,存储数据的 Properties 实际上就是我们的资源文件,而在 resolveCode 方法中,存储数据的是一个双层 Map,外层 Map key 是 code,即传入的资源的 key,value 则是一个 Map,里边这个 Map 的 key 是 locale 对象,value 则是一个 MessageFormat 对象,查找的时候根据用户传入的 code 先找到一个 Map,然后再根据用户传入的 locale 找到 MessageFormat,然后返回。其他逻辑基本上都是一致的了。

3.附录

搜刮了一个语言简称表,分享给各位小伙伴:

语言 简称
简体中文(中国) zh_CN
繁体中文(中国台湾) zh_TW
繁体中文(中国香港) zh_HK
英语(中国香港) en_HK
英语(美国) en_US
英语(英国) en_GB
英语(全球) en_WW
英语(加拿大) en_CA
英语(澳大利亚) en_AU
英语(爱尔兰) en_IE
英语(芬兰) en_FI
芬兰语(芬兰) fi_FI
英语(丹麦) en_DK
丹麦语(丹麦) da_DK
英语(以色列) en_IL
希伯来语(以色列) he_IL
英语(南非) en_ZA
英语(印度) en_IN
英语(挪威) en_NO
英语(新加坡) en_SG
英语(新西兰) en_NZ
英语(印度尼西亚) en_ID
英语(菲律宾) en_PH
英语(泰国) en_TH
英语(马来西亚) en_MY
英语(阿拉伯) en_XA
韩文(韩国) ko_KR
日语(日本) ja_JP
荷兰语(荷兰) nl_NL
荷兰语(比利时) nl_BE
葡萄牙语(葡萄牙) pt_PT
葡萄牙语(巴西) pt_BR
法语(法国) fr_FR
法语(卢森堡) fr_LU
法语(瑞士) fr_CH
法语(比利时) fr_BE
法语(加拿大) fr_CA
西班牙语(拉丁美洲) es_LA
西班牙语(西班牙) es_ES
西班牙语(阿根廷) es_AR
西班牙语(美国) es_US
西班牙语(墨西哥) es_MX
西班牙语(哥伦比亚) es_CO
西班牙语(波多黎各) es_PR
德语(德国) de_DE
德语(奥地利) de_AT
德语(瑞士) de_CH
俄语(俄罗斯) ru_RU
意大利语(意大利) it_IT
希腊语(希腊) el_GR
挪威语(挪威) no_NO
匈牙利语(匈牙利) hu_HU
土耳其语(土耳其) tr_TR
捷克语(捷克共和国) cs_CZ
斯洛文尼亚语 sl_SL
波兰语(波兰) pl_PL
瑞典语(瑞典) sv_SE
西班牙语(智利) es_CL
# Spring

喜欢这篇文章吗?扫码关注公众号【江南一点雨】【江南一点雨】专注于 SPRING BOOT+微服务以及前后端分离技术,每天推送原创技术干货,关注后回复 JAVA,领取松哥为你精心准备的 JAVA 干货!

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×