背景:
为了提高软件的通用性(应对不同的语言环境)、扩大软件的业务受众范围,软件被要求具备处理国际化的能力。Java和Spring为此分别提供了不同层次的解决方案:Java在java.util包中提供了支持国际化能力的API与工具类,Spring基于此进行封装并提供了容器级别的接口。
本文作为Spring系列文章的第四篇,内容包含JDK、Spring、Spring Boot国际化相关的API的使用和背后原理;其中,基于Spring框架介绍国际化的使用和原理是本文的重点内容,该部分会伴随着Spring源码进行。
1.国际化
Java定义了Locale类用于表示国际化类型,包含国家和地区两种信息:
static public final Locale SIMPLIFIED_CHINESE = createConstant("zh", "CN");
static public final Locale CHINA = SIMPLIFIED_CHINESE;
static public final Locale US = createConstant("en", "US");
...
其中,Locale.CHINA表示简体中文(对应zh_CN);Locale.US表示美式英语(对应en_US).
根据语言和地区得到国际化对象后,可将用户信息翻译为该对象代表的国际化,案例如下:
(1) 国际化货币
@Test
public void testCurrencyInstance() {
LOGGER.info("[CN] result is {}", NumberFormat.getCurrencyInstance(Locale.CHINA).format(100.00));
LOGGER.info("[US] result is {}", NumberFormat.getCurrencyInstance(Locale.US).format(100.00));
LOGGER.info("[default] result is {}", NumberFormat.getCurrencyInstance().format(100.00));
}
得到如下结果:
(2) 国际化日期
@Test
public void testDateInstance() {
Date date = new Date();
LOGGER.info("[CN] result is {}", DateFormat.getDateInstance(DateFormat.DEFAULT, Locale.CHINA).format(date));
LOGGER.info("[US] result is {}", DateFormat.getDateInstance(DateFormat.DEFAULT, Locale.US).format(date));
LOGGER.info("[default] result is {}", DateFormat.getDateInstance(DateFormat.DEFAULT).format(date));
}
得到如下结果:
(3) 国际化时间
@Test
public void testTimeInstance() {
Date date = new Date();
LOGGER.info("[CN] result is {}", DateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.CHINA).format(date));
LOGGER.info("[US] result is {}", DateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.US).format(date));
LOGGER.info("[default] result is {}", DateFormat.getTimeInstance(DateFormat.DEFAULT).format(date));
}
得到如下结果:
分析:
当不指定语言类型时,系统会使用默认的国际化类型,因此3个测试用例的[default]内容同[CN]。默认的国际化类型信息来自平台本身:
@Test
public void testDefaultLocal() {
String language = AccessController.doPrivileged(new GetPropertyAction("user.language", "en"));
LOGGER.info("Default language is {}.", language);
String country = AccessController.doPrivileged(new GetPropertyAction("user.country", ""));
LOGGER.info("Default country is {}.", country);
String region = AccessController.doPrivileged(new GetPropertyAction("user.region"));
LOGGER.info("Default region is {}.", region);
}
得到如下结果:
国际化涉及到时间、货币、数字和字符串等,本文后续内容围绕后者进行。
2.使用方式
2.1 JDK中的国际化
在资源路径下准备国际化文件
(1) 准备国际化文件:
国际化资源文件的命名需要遵循命名规范:资源名_语言_国家/地区.properties
,如:messages_en_US.properties表示美式英语,messages_zh_CN.properties表示简体中文。
另外,语言和国家/地区可以缺省—🥷—用于表示默认的资源文件,如message.properties;当某个本地化类型在系统中找不到对应的资源文件时,就使用这个默认项。
#resources/i18n文件夹下
#messages.properties文件
10000=您好,北京
10001=您好,南京
10002=您好,上海
#messages_en_US.properties文件
10000=Hello, Beijing
10001=Hello, Nanjing
#messages_zh_CN.properties文件
10000=你好,北京
10001=你好,南京
资源文件对文件内容有严格的要求:只能包含ASCII字符。所以必须将非ASCII字符的内容转换为Unicode代码的表示方式。
(2) 使用如下命令进行转换:
// native2ascii是JDK提供的命令
native2ascii -encoding utf-8 ./i18n/messages.properties ./i18n/messages.properties
native2ascii -encoding utf-8 ./i18n/messages_zh_CN.properties ./i18n/messages_zh_CN.properties
得到结果如下:
#resources/i18n文件夹下
#messages.properties文件
10000=\u60A8\u597D\uFF0C\u5317\u4EAC
10001=\u60A8\u597D\uFF0C\u5357\u4EAC
10002=\u60A8\u597D\uFF0C\u4E0A\u6D77
#messages_en_US.properties文件
10000=Hello, Beijing
10001=Hello, Nanjing
#messages_zh_CN.properties文件
10000=\u4F60\u597D\uFF0C\u5317\u4EAC
10001=\u4F60\u597D\uFF0C\u5357\u4EAC
(3) IDEA配置Transparent native-to-sacii conversion:
阅读与修改Unicode代码存在一定难度,通过对IDEA进行配置,开发人员可以友好地操作资源文件:
#resources/i18n文件夹下
#messages.properties文件
10000=您好,北京
10001=您好,南京
10002=您好,上海
#messages_en_US.properties文件
10000=Hello, Beijing
10001=Hello, Nanjing
#messages_zh_CN.properties文件
10000=你好,北京
10001=你好,南京
配置方式:Preferences/Settings -> Editor -> File Encodings -> Transparent native-to-sacii conversion
获取国际化资源
@Slf4j
@Slf4j
public class JavaI18nTest {
@Test
public void testJavaI18n() {
ResourceBundle cnBundle = ResourceBundle.getBundle("i18n/messages", Locale.CHINA);
LOGGER.info("[CHINA] 10000.msg is {}.", cnBundle.getString("10000"));
LOGGER.info("[CHINA] 10001.msg is {}.", cnBundle.getString("10001"));
LOGGER.info("[CHINA] 10002.msg is {}.", cnBundle.getString("10002"));
ResourceBundle usBundle = ResourceBundle.getBundle("i18n/messages", Locale.US);
LOGGER.info("[US] 10000.msg is {}.", usBundle.getString("10000"));
LOGGER.info("[US] 10001.msg is {}.", usBundle.getString("10001"));
LOGGER.info("[US] 10002.msg is {}.", usBundle.getString("10002"));
}
}
运行后得到如下结果:
从结果可知:红框标记的输出项的国际化信息来源于默认的资源文件messages.properties.
2.1.Spring项目
Spring对JDK的国际化API进行了封装和增强,提高了容器级别的接口MessageSource,该接口位于context模块,共提供三种获取国际化信息的接口,如下所示:
package org.springframework.context;
import java.util.Locale;
import org.springframework.lang.Nullable;
public interface MessageSource {
// defaultMessage为默认值,获取失败时-返回该对象
@Nullable
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
// 常用
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
// 使用传入的resolvable对象来解析国际化
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}
其中第二种较为常用,code表示待解析的字符串信息,args为占位符参数(来自JDK的API),Locale为国际化对象,返回的字符串为本地化后的结果。
MessageSource接口的继承关系如下:
如上图所示,Spring共提供了四种实现类:DelegatingMessageSource, StaticMessageSource, ResourceBundleMessageSource, ReloadableResourceBundleMessageSource.
常见为后两种: ResourceBundleMessageSource依赖于JDK提供的API,即依赖ResourceBundle.getBundle(basename, locale, classLoader, control)
方法实现国际化;ReloadableResourceBundleMessageSource提供了定时更新国际化的能力而无需重启机器; 本文以ResourceBundleMessageSource为例介绍如何在Spring项目中使用国际化(ReloadableResourceBundleMessageSource使用与之类似)。
以ResourceBundleMessageSource为例介绍:
配置国际化资源:
使用与2.1中相同的国际化资源.
配置Bean:
<bean id="myResource" class="org.springframework.context.support.ResourceBundleMessageSource">
<property name="basename" value="i18n/messages"/>
<property name="defaultEncoding" value="UTF-8"/>
</bean>
当使用ReloadableResourceBundleMessageSource时,需要同时配置更新时间:
<bean id="myResource" class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
<property name="basename" value="i18n/messages"/>
<property name="defaultEncoding" value="UTF-8"/>
<property name="cacheSeconds" value="5"/>
</bean>
注意:这里写法有问题(举例可忽略),实际beanName需要设置为messageSource,原因在章节3原理部分介绍。
读取国际化资源:
@Slf4j
public class SpringDemoApplication {
public static void main(String[] args) {
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring-context.xml");
MessageSource messageSource = (MessageSource) context.getBean("myResource");
LOGGER.info("[zh-CN] 10000.msg is {}.", messageSource.getMessage("10000", null, Locale.CHINA));
LOGGER.info("[en-US] 10000.msg is {}.", messageSource.getMessage("10000", null, Locale.US));
}
}
得到结果:
另外,对于Spring提供的ResourceBundleMessageSource类,项目可以选择将其作为Bean对象(如上),也可以不作为Bean对象,如下所示:
@Slf4j
public class I18nTest {
@Test
public void testI18n() {
MessageSource messageSource = buildMessageSource();
LOGGER.info("[zh-CN] 10000.msg is {}.", messageSource.getMessage("10000", null, Locale.CHINA));
LOGGER.info("[en-US] 10000.msg is {}.", messageSource.getMessage("10000", null, Locale.US));
}
private MessageSource buildMessageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasenames("i18n/messages");
messageSource.setDefaultEncoding(Charset.forName("UTF-8").name());
messageSource.setFallbackToSystemLocale(true);
messageSource.setCacheSeconds(-1);
messageSource.setAlwaysUseMessageFormat(false);
messageSource.setUseCodeAsDefaultMessage(true);
return messageSource;
}
}
得到运行结果:
2.2 SpringBoot项目
配置国际化资源:
使用与2.1中相同的国际化资源.
在application.yml中进行参数配置:
spring:
messages:
basename: i18n/messages
encoding: UTF-8
fallback-to-system-locale: false
使用国际化资源:
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class I18nTest {
@Autowired
private MessageSource messageSource;
@Test
public void testI18n() {
LOGGER.info("[zh-CN] 10000.msg is {}.", messageSource.getMessage("10000",null, Locale.CHINA));
LOGGER.info("[en-US] 10000.msg is {}.", messageSource.getMessage("10000",null, Locale.US));
}
}
得到运行结果如下:
3.原理
3.1 Spring项目国际化原理
在启动Spring容器时—执行refresh()的步骤中存在initMessageSource()
方法:
@Override
public void refresh() throws BeansException, IllegalStateException {
//...
registerBeanPostProcessors(beanFactory);
// Initialize message source for this context.
initMessageSource();
//...
// Instantiate all remaining (non-lazy-init) singletons.
finishBeanFactoryInitialization(beanFactory);
// ...
}
进入initMessageSource()内部:
// 简化后的代码
protected void initMessageSource() {
ConfigurableListableBeanFactory beanFactory = getBeanFactory();
if (beanFactory.containsLocalBean("messageSource")) {
this.messageSource = beanFactory.getBean("messageSource", MessageSource.class);
// Make MessageSource aware of parent MessageSource.
if (this.parent != null && this.messageSource instanceof HierarchicalMessageSource) {
HierarchicalMessageSource hms = (HierarchicalMessageSource) this.messageSource;
if (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("messageSource", this.messageSource);
}
}
代码只有两个主分支,逻辑比较清晰:
如果BeanFactory中存在beanName为"messageSource"的BeanDefinition: 实例化该BeanDefinition并将Bean对象设置给AbstractApplicationContext的messageSource属性;同时如果该Spring容器存在父容器且该国际化对象为HierarchicalMessageSource类型(具备父子结构), 则一并设置国际化对象的父子关系。
不存在"messageSource": 实例化一个DelegatingMessageSource类型的对象,并注册到IOC容器中,同时赋值给AbstractApplicationContext的messageSource属性。如注释// Use empty MessageSource to be able to accept getMessage calls
:DelegatingMessageSource无实际功能,是为了让调用不会抛空指针,如下所示:
// DelegatingMessageSource:
@Override
public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
if (this.parentMessageSource != null) {
return this.parentMessageSource.getMessage(code, args, locale);
}
else {
throw new NoSuchMessageException(code, locale);
}
}
个人见解:
Spring容器在AbstractApplicationContext中引入了messageSource属性,并在启动容器的过程中将"messageSource"对应的Bean对象设置给messageSource属性;这就明示着要将国际化对象作为一个容器级别的全局组件对外提供,因此Spring项目中如果设置了国际化对象,必须将beanName设置为"messageSource".
在2.1章节中介绍了MessageSource的类继承关系图,仅展示了真正实现国际化功能的子类,为包含代理类如ApplicationContext系列。如下所示是AbstractApplicationContext中对MessageSource中接口的实现:
@Override
public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) {
return getMessageSource().getMessage(code, args, defaultMessage, locale);
}
@Override
public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
return getMessageSource().getMessage(code, args, locale);
}
@Override
public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException {
return getMessageSource().getMessage(resolvable, locale);
}
private MessageSource getMessageSource() throws IllegalStateException {
if (this.messageSource == null) {
throw new IllegalStateException("MessageSource not initialized - call 'refresh' before accessing messages via the context: " + this);
}
return this.messageSource;
}
即完全将国际化功能委托给了messageSource属性。
因此,在Spring项目中可以直接使用Spring容器对象或者messageSource对应的Bean对象来获取国际化信息。
在2.1中提到了ResourceBundleMessageSource依赖于JDK的API,这里稍作探究:
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class I18nTest {
@Autowired
private MessageSource messageSource;
@Test
public void testI18n() {
LOGGER.info("[zh-CN] 10000.msg is {}.", messageSource.getMessage("10000",null, Locale.CHINA));
LOGGER.info("[en-US] 10000.msg is {}.", messageSource.getMessage("10000",null, Locale.US));
}
}
Step1: 跟进getMessage方法:
@Override
public final String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
// 主干流程
String msg = getMessageInternal(code, args, locale);
if (msg != null) {
return msg;
}
// 从支1:返回code本身作为国际化结果
String fallback = getDefaultMessage(code);
if (fallback != null) {
return fallback;
}
// 从支2: 抛出异常
throw new NoSuchMessageException(code, locale);
}
先调用getMessageInternal(code, args, locale)
获取国际化信息,不为空则直接返回;
否则进入从支1,调用getDefaultMessage(code)
逻辑,如果配置项useCodeAsDefaultMessage属性为true(默认为false)则将code本身作为国际化信息返回,否则getDefaultMessage(code)
得到null;进入从支2—抛出异常。
Step2: 跟进getMessageInternal(code, args, locale)方法:
@Nullable
protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
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);
}
}
}
// Check locale-independent common messages for the given message code.
Properties commonMessages = getCommonMessages();
if (commonMessages != null) {
String commonMessage = commonMessages.getProperty(code);
if (commonMessage != null) {
return formatMessage(commonMessage, args, locale);
}
}
// Not found -> check parent, if any.
return getMessageFromParent(code, argsToUse, locale);
}
整体流程比较清晰:
(1) 优先从国际化资源中获取,获取失败时再从配置项commonMessages对象中获取—🥶—获取失败再从其父对象中获取;
(2) 从国际化资源中获取时,如果需要处理占位符则先处理占位符再处理国际化:
argsToUse = resolveArguments(args, locale);
MessageFormat messageFormat = resolveCode(code, locale);
if (messageFormat != null) {
synchronized (messageFormat) {
return messageFormat.format(argsToUse);
}
}
进入resolveCode方法内部:
protected MessageFormat resolveCode(String code, Locale locale) {
Set<String> basenames = getBasenameSet();
for (String basename : basenames) {
// ⚠️获取ResourceBundle对象
ResourceBundle bundle = getResourceBundle(basename, locale);
if (bundle != null) {
MessageFormat messageFormat = getMessageFormat(bundle, code, locale);
if (messageFormat != null) {
return messageFormat;
}
}
}
return null;
}
(3) 不需要处理占位符:直接调用resolveCodeWithoutArguments(code, locale)
获取;
进入resolveCodeWithoutArguments内部:
protected String resolveCodeWithoutArguments(String code, Locale locale) {
Set<String> basenames = getBasenameSet();
for (String basename : basenames) {
// ⚠️获取ResourceBundle对象
ResourceBundle bundle = getResourceBundle(basename, locale);
if (bundle != null) {
String result = getStringOrNull(bundle, code);
if (result != null) {
return result;
}
}
}
return null;
}
Step3: 跟进getResourceBundle(basename, locale);
step2中的(2)和(3)最终都会调用getResourceBundle(basename, locale)
方法获取ResourceBundle对象,然后从ResourceBundle对象中获取国际化信息(根据code从ResourceBundle对象的属性中解析,逻辑较为简单);
剩余的调用链依次为:ResourceBundle bundle = doGetBundle(basename, locale);
-> ResourceBundle.getBundle(basename, locale, classLoader, control);
, 而后者为JDK提供的API。
JDK中的国际化API实现方式不是本文关心的内容,因此不再继续往下进行;感兴趣的读者可以继续往下扒一下JDK是如何加载资源文件并构造ResourceBundle对象的流程(有问题可在留言区讨论)。
3.2 SpringBoot项目国际化原理
在Spring项目中手动配置了ResourceBundleMessageSource类型的Bean对象,而SpringBoot可以直接从IOC中获取而不需要配置;这是因为Spring Boot通过自动装配机制向IOC中默认注入了一个ResourceBundleMessageSource类型的Bean对象。
自动装配的逻辑不在本文介绍,将在介绍完Spring Bean/AOP 后补上,感兴趣读者可订阅Spring系列专题。
本章节认为读者对SpringBoot的自动装配已有基本了解。
spring.factories文件中引入MessageSourceAutoConfiguration类:
在[spring-boot-autoconfigure]模块的spring.factories文件的
org.springframework.boot.autoconfigure.EnableAutoConfiguration
属性中存在org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration
类型:
MessageSourceAutoConfiguration注入条件:
MessageSourceAutoConfiguration类上存在@ConditionalOnMissingBean注解,如下所示:
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = "messageSource", search = SearchStrategy.CURRENT)
public class MessageSourceAutoConfiguration {
//...
}
表示:当有beanName为messageSource时,该类的装配过程不会进行;因此用户可自定义MessageSource,如ReloadableResourceBundleMessageSource:
配置属性MessageSourceProperties:
在MessageSourceAutoConfiguration中定义了MessageSourceProperties的注入逻辑:
@Bean
@ConfigurationProperties(prefix = "spring.messages")
public MessageSourceProperties messageSourceProperties() {
return new MessageSourceProperties();
}
@ConfigurationProperties(prefix = "spring.messages")
表示读取配置文件中spring.messages表示的信息并注入到MessageSourceProperties对象中;
对应2.2章节中的配置:
在application.yml中进行参数配置:
spring:
messages:
basename: i18n/messages
encoding: UTF-8
fallback-to-system-locale: false
上述属性对应MessageSourceProperties配置类中的属性:
public class MessageSourceProperties {
// 默认为resources资源路径下的"messages"
private String basename = "messages";
// 编码格式,一般设置为utf-8(默认是 UTF-8)
private Charset encoding;
// 指定资源、默认资源文件找不到时是否从当前系统语言对应配置文件中查找
// 默认为true: 进行查找;false时-抛出异常
private boolean fallbackToSystemLocale;
// 无论是有有注入参数都使用MessageFormat.format函数对结果进行格式化(默认是 false)
// 注入参数值调用MessageSource接口时传入的args参数),
private boolean alwaysUseMessageFormat;
// 根据code获取不到国际化信息是,是否直接返回code(默认是 false)
private boolean useCodeAsDefaultMessage;
}
MessageSourceProperties本质上是一个临时的内存数据—🐭—为了读取作准备(实现配置文件 -> 内存),实例化ResourceBundleMessageSource对象时从中读取配置信息.
注入MessageSource:
在MessageSourceAutoConfiguration中定义了MessageSource的注入逻辑:
@Bean
public MessageSource messageSource(MessageSourceProperties properties) {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
if (StringUtils.hasText(properties.getBasename())) {
messageSource.setBasenames(StringUtils
.commaDelimitedListToStringArray(StringUtils.trimAllWhitespace(properties.getBasename())));
}
if (properties.getEncoding() != null) {
messageSource.setDefaultEncoding(properties.getEncoding().name());
}
messageSource.setFallbackToSystemLocale(properties.isFallbackToSystemLocale());
Duration cacheDuration = properties.getCacheDuration();
if (cacheDuration != null) {
messageSource.setCacheMillis(cacheDuration.toMillis());
}
messageSource.setAlwaysUseMessageFormat(properties.isAlwaysUseMessageFormat());
messageSource.setUseCodeAsDefaultMessage(properties.isUseCodeAsDefaultMessage());
return messageSource;
}
逻辑较为简单,直接从MessageSourceProperties中读取国际化配置信息构造ResourceBundleMessageSource对象,并注入到IOC容器中。