SpringBoot(10)Locale国际化

在项目中,很多时候需要国际化的支持,这篇文章要介绍一下 JDK 国际化相关的类,以及 SpringBoot 项目中多语言国际化的使用。

Locale

什么是国际化?国际化也称作本地化,简单的说就是根据本地不同的“语言”和“国家/地区”环境,前端页面、接口返回的信息或者错误提示等也要切换成对应的语言。在Java中表示国际化的类是java.util.Locale,它包含了两层含义,一是“语言”,二是“国家/地区”,比如同样是中文,既有中国大陆地区的中文(zh_CN),又有中国台湾(zh_TW)和中国香港地区的中文(zh_HK)。如果有了“语言”和“国家/地区”两个参数,我们可以创建一个确定的Locale对象,此外也有其他的创建方式,下面给出几个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//1.带有语言和国家/地区信息的本地化对象  
Locale locale1 = new Locale("zh","CN");

//2.只有语言信息的本地化对象
Locale locale2 = new Locale("zh");

//3.等同于Locale("zh","CN")
Locale locale3 = Locale.CHINA;

//4.等同于Locale("zh")
Locale locale4 = Locale.CHINESE;

//5.获取本地系统默认的本地化对象
Locale locale 5= Locale.getDefault();

用户既可以同时指定语言和国家/地区参数定义一个本地化对象①,也可以仅通过语言参数定义一个泛本地化对象②。Locale类中通过静态常量定义了一些常用的本地化对象,③和④处就直接通过引用常量返回本地化对象。此外,用户还可以获取系统默认的本地化对象,如⑤所示。

JDK的国际化工具类

JDK的java.text包中提供了几个支持国际化的格式化操作工具类:NumberFormatDateFormatMessageFormatNumberFormat可以按本地化的方式对货币金额进行格式化操作,DateFormat则可以按本地化的方式对日期进行格式化操作,MessageFormat则在NumberFormatDateFormat的基础上提供了强大的占位符字符串的格式化功能,它支持时间、货币、数字以及对象属性的格式化操作。下面是一个格式化功能的演示实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void testMessageFormat() {
//带占位符的字符串
String pattern1 = "{0},你好!你于{1}在工商银行存入{2} 元。";
String pattern2 = "At {1,time,short} On {1,date,long},{0} paid {2,number, currency}.";

//用于动态替换占位符的参数
Object[] params = {"John", new GregorianCalendar().getTime(), 1000};

//使用默认本地化对象格式化信息
String msg1 = MessageFormat.format(pattern1, params);

//使用指定的本地化对象格式化信息
MessageFormat mf = new MessageFormat(pattern2, Locale.US);
String msg2 = mf.format(params);
System.out.println(msg1);
System.out.println(msg2);
}

输出结果:

1
2
John,你好!你于22-1-21 下午5:26在工商银行存入1,000 元。
At 5:26 PM On January 21, 2022,John paid $1,000.00.

国际化资源文件

通常项目如果需要本地化,是通过对不同本地化类型分别提供对应的资源文件,并以规范的方式进行命名。命名规范如下:

1
<资源名>_<语言代码>_<国家/地区代码>.properties

其中,语言代码和国家/地区代码都是可选的。<资源名>.properties是默认的本地化资源文件,<资源名>_<语言代码>_<国家/地区代码>.properties则是具体到某个Locale的资源文件,在系统中首先是精准匹配,如果没有找到对应的资源文件,会使用默认的本地化资源文件。

假设资源名为resource,语言为英文,国家为美国,则与其对应的本地化资源文件命名为resource_en_US.properties。本地化信息在资源文件以属性名/值的方式表示:

1
2
3
greeting.common=How are you!
greeting.morning = Good morning!
greeting.afternoon = Good Afternoon!

对应语言为中文,国家/地区为中国大陆的本地化资源文件则命名为resource_zh_CN.properties,资源文件内容如下:

1
2
3
greeting.common=\u60a8\u597d\uff01
greeting.morning=\u65e9\u4e0a\u597d\uff01
greeting.afternoon=\u4e0b\u5348\u597d\uff01

这三个文件我们放在项目的resources/i18n目录下。

ResourceBundle

如果应用程序中拥有大量的本地化资源文件,通过传统的File操作资源文件太过笨拙。Java为我们提供了用于加载本地化资源文件的方便类java.util.ResourceBundle
ResourceBundle为加载及访问资源文件提供了便捷的操作,对于上面的两个资源文件,ResourceBundle的使用示例如下:

1
2
3
4
5
6
7
8
9

@Test
public void testResourceBundle() {
ResourceBundle rb1 = ResourceBundle.getBundle("i18n/resource", Locale.US);
ResourceBundle rb2 = ResourceBundle.getBundle("i18n/resource", Locale.CHINA);
System.out.println("us:"+rb1.getString("greeting.common"));
System.out.println("cn:"+rb2.getString("greeting.common"));

}

输出:

1
2
us:How are you!
cn:您好!

LocaleResolver

前面讲的都是Java JDK自带的和国际化相关的类,对于Spring来说,它由LocaleResolver这个组件来处理LocaleLocaleResolver是一个接口,包含有两个方法。

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
public interface LocaleResolver {

/**
* 根据request请求获取locale
*
* Resolve the current locale via the given request.
* Can return a default locale as fallback in any case.
* @param request the request to resolve the locale for
* @return the current locale (never {@code null})
*/
Locale resolveLocale(HttpServletRequest request);

/**
* 设置request或者response中的lacale
*
* Set the current locale to the given one.
* @param request the request to be used for locale modification
* @param response the response to be used for locale modification
* @param locale the new locale, or {@code null} to clear the locale
* @throws UnsupportedOperationException if the LocaleResolver
* implementation does not support dynamic changing of the locale
*/
void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale);

}

我们知道SpringMVC中客户端发起的http请求都会进入到DispatcherServlet中并最终由doDispatch(request, response)方法处理。DispatcherServlet中就持有一个LocaleResolver组件,用来专门处理国际化问题。此外,DispatcherServlet中还有一个initLocaleResolver(ApplicationContext context)用来对LocaleResolver变量赋值和初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Initialize the LocaleResolver used by this class.
* <p>If no bean is defined with the given name in the BeanFactory for this namespace,
* we default to AcceptHeaderLocaleResolver.
*/
private void initLocaleResolver(ApplicationContext context) {
try {
this.localeResolver = context.getBean(LOCALE_RESOLVER_BEAN_NAME, LocaleResolver.class);
if (logger.isTraceEnabled()) {
logger.trace("Detected " + this.localeResolver);
}
else if (logger.isDebugEnabled()) {
logger.debug("Detected " + this.localeResolver.getClass().getSimpleName());
}
}
catch (NoSuchBeanDefinitionException ex) {
// We need to use the default.
this.localeResolver = getDefaultStrategy(context, LocaleResolver.class);
if (logger.isTraceEnabled()) {
logger.trace("No LocaleResolver '" + LOCALE_RESOLVER_BEAN_NAME +
"': using default [" + this.localeResolver.getClass().getSimpleName() + "]");
}
}
}

可以看到,这个方法会从Spring容器中获取一个LocaleResolver对象。LocaleResolver这个接口是有好几个实现类的,那么默认获取到的LocaleResolver对象具体是什么类型呢?这就需要看到WebMvcAutoConfiguration这个类,它是一个跟 SpringMVC 功能(也就是spirng的web功能)相关的自动配置类。这个类中就自动配置了LocaleResolver,具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "spring.mvc", name = "locale")
public LocaleResolver localeResolver() {
if (this.mvcProperties
.getLocaleResolver() == WebMvcProperties.LocaleResolver.FIXED) {
return new FixedLocaleResolver(this.mvcProperties.getLocale());
}
AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver();
localeResolver.setDefaultLocale(this.mvcProperties.getLocale());
return localeResolver;
}

上面代码中看到通过spring.mvc.locale可以配置localeResolver的属性值,如果什么都不配置,默认使用的是AcceptHeaderLocaleResolver对象。

当然,我们也可以自己实现一个LocaleResolver并加入到容器中,Spring就不会自动配置LocaleResolver对象了,而是会使用我们的自定义LocaleResolver来处理国际化。代码示例:

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
public class MyLocaleResolver implements LocaleResolver {

private static final String DEFAULT_LANG = "en_US";

@Override
public Locale resolveLocale(HttpServletRequest request) {
String lang = request.getHeader("Accept-Language");
if (lang == null || lang.length() == 0) {
lang = request.getParameter("lang");
}
lang = LangUtils.parse(lang);
if (lang == null || lang.length() == 0) {
lang = DEFAULT_LANG;
}
String[] language = lang.split("_");
if (language.length == 2) {
return new Locale(language[0], language[1]);
} else {
return new Locale(language[0]);
}
}

@Override
public void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale) {

}
}

注入ioc容器中,交由spring来管理:

1
2
3
4
@Bean
public LocaleResolver localeResolver(){
return new MyLocaleResolver();
}

LocaleContextHolder

上面讲到了Spring是利用LocaleResolver组件解析出一个Locale实例,那么在项目中怎么获取到这个实例呢?Spring还提供了一个类LocaleContextHolder,通过LocaleContextHoldergetLocale()方法可以很方便地得到这个Locale实例。

1
Locale locale = LocaleContextHolder.getLocale();

查看LocaleContextHolder中的源码,可以知道它利用了ThreadLocal线程副本的功能,将一个LocaleContext对象保存在线程副本中。

1
2
3
4
5
private static final ThreadLocal<LocaleContext> localeContextHolder =
new NamedThreadLocal<>("LocaleContext");

private static final ThreadLocal<LocaleContext> inheritableLocaleContextHolder =
new NamedInheritableThreadLocal<>("LocaleContext");

LocaleContext是一个接口,它只有一个getLocale()方法,该方法就是返回一个Locale实例。

1
2
3
4
5
6
7
8
9
10
11
public interface LocaleContext {

/**
* Return the current Locale, which can be fixed or determined dynamically,
* depending on the implementation strategy.
* @return the current Locale, or {@code null} if no specific Locale associated
*/
@Nullable
Locale getLocale();

}

LocaleContext是什么时候放入线程副本中的呢?
我们知道请求进入到Servlet之后,会调用service方法,HttpServlet则根据GET、POST等不同请求方式去调用doGetdoPost等方法,DispatcherServlet继承自FrameworkServletFrameworkServlet里面重写了这些doGetdoPost方法,并且最终都会调用的它的processRequest方法,在这个processRequest方法中有会调用doService方法(这是个抽象方法),DispatcherServlet实现了doService方法,并且最终调用doDispatch方法。
现在再来看FrameworkServlet中的processRequest这个方法源码。

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
protected final void processRequest(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {

long startTime = System.currentTimeMillis();
Throwable failureCause = null;

LocaleContext previousLocaleContext = LocaleContextHolder.getLocaleContext();
LocaleContext localeContext = buildLocaleContext(request);

RequestAttributes previousAttributes = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes requestAttributes = buildRequestAttributes(request, response, previousAttributes);

WebAsyncManager asyncManager = WebAsyncUtils.getAsyncManager(request);
asyncManager.registerCallableInterceptor(FrameworkServlet.class.getName(), new RequestBindingInterceptor());

initContextHolders(request, localeContext, requestAttributes);

try {
doService(request, response);
}
catch (ServletException | IOException ex) {
failureCause = ex;
throw ex;
}
catch (Throwable ex) {
failureCause = ex;
throw new NestedServletException("Request processing failed", ex);
}

finally {
resetContextHolders(request, previousLocaleContext, previousAttributes);
if (requestAttributes != null) {
requestAttributes.requestCompleted();
}
logResult(request, response, failureCause, asyncManager);
publishRequestHandledEvent(request, response, startTime, failureCause);
}
}

在这个方法中首先会调用buildLocaleContext(request)方法得到一个LocaleContext对象,然后在initContextHolders(request, localeContext, requestAttributes)方法里把这个LocaleContext对象对象放入线程本地中。DispatcherServlet中重写了buildLocaleContext(request),看看它是怎么做的?

1
2
3
4
5
6
7
8
9
10
@Override
protected LocaleContext buildLocaleContext(final HttpServletRequest request) {
LocaleResolver lr = this.localeResolver;
if (lr instanceof LocaleContextResolver) {
return ((LocaleContextResolver) lr).resolveLocaleContext(request);
}
else {
return () -> (lr != null ? lr.resolveLocale(request) : request.getLocale());
}
}

DispatcherServlet里会拿到它持有的LocaleResolver组件,也就是从Spring容器中获取到的LocaleResolver实例,用它来去创建一个LocaleContext实现类对象(匿名内部类的方式)。至此LocaleContextHolder的原理和来龙去脉也就清晰了。

MessageSource

MessageSource是Spring中定义的获取本地化信息的接口,并且还有参数的信息替换功能,参数替换跟前面介绍的MessageFormat用法类似。

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
public interface MessageSource {
/**
* 解析code对应的信息进行返回,如果对应的code不能被解析则返回默认信息defaultMessage。
*
* @param code 需要进行解析的code,对应资源文件中的一个属性名
* @param args 需要用来替换code对应的信息中包含参数的内容,如:{0},{1,date},{2,time}
* @param defaultMessage 当对应code对应的信息不存在时需要返回的默认值
* @param locale 对应的Locale
* @return
*/
String getMessage(String code, Object[] args, String defaultMessage, Locale locale);

/**
* 解析code对应的信息进行返回,如果对应的code不能被解析则抛出异常NoSuchMessageException
*
* @param code 需要进行解析的code,对应资源文件中的一个属性名
* @param args 需要用来替换code对应的信息中包含参数的内容,如:{0},{1,date},{2,time}
* @param locale 对应的Locale
* @return
* @throws NoSuchMessageException 如果对应的code不能被解析则抛出该异常
*/
String getMessage(String code, Object[] args, Locale locale) throws NoSuchMessageException;

/**
* 通过传递的MessageSourceResolvable对应来解析对应的信息
*
* @param resolvable
* @param locale 对应的Locale
* @return
* @throws NoSuchMessageException 如不能解析则抛出该异常
*/
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}

MessageSource有很多实现类,那么SpringBoot会使用哪个呢?这就要看MessageSourceAutoConfiguration这个自动配置类,这个类会向容器中注入一个MessageSource实例。

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
@Configuration
@ConditionalOnMissingBean(value = MessageSource.class, search = SearchStrategy.CURRENT)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@Conditional(ResourceBundleCondition.class)
@EnableConfigurationProperties
public class MessageSourceAutoConfiguration {

private static final Resource[] NO_RESOURCES = {};

@Bean
@ConfigurationProperties(prefix = "spring.messages")
public MessageSourceProperties messageSourceProperties() {
return new MessageSourceProperties();
}

@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;
}


//省略。。。
}

可以看到向Spring容器中注入的是一个ResourceBundleMessageSource对象,ResourceBundleMessageSource它是基于JDK的ResourceBundle,并且实现了MessageSource接口。
从上面还能看到和国际化资源文件配置相关的属性类MessageSourceProperties,这些配置的前缀都是spring.messages,再查看MessageSourceProperties得知可以配置资源文件的basename、encoding、cacheDuration等。

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
public class MessageSourceProperties {

/**
* Comma-separated list of basenames (essentially a fully-qualified classpath
* location), each following the ResourceBundle convention with relaxed support for
* slash based locations. If it doesn't contain a package qualifier (such as
* "org.mypackage"), it will be resolved from the classpath root.
*/
private String basename = "messages";

/**
* Message bundles encoding.
*/
private Charset encoding = StandardCharsets.UTF_8;

/**
* Loaded resource bundle files cache duration. When not set, bundles are cached
* forever. If a duration suffix is not specified, seconds will be used.
*/
@DurationUnit(ChronoUnit.SECONDS)
private Duration cacheDuration;

/**
* Whether to fall back to the system Locale if no files for a specific Locale have
* been found. if this is turned off, the only fallback will be the default file (e.g.
* "messages.properties" for basename "messages").
*/
private boolean fallbackToSystemLocale = true;

/**
* Whether to always apply the MessageFormat rules, parsing even messages without
* arguments.
*/
private boolean alwaysUseMessageFormat = false;

/**
* Whether to use the message code as the default message instead of throwing a
* "NoSuchMessageException". Recommended during development only.
*/
private boolean useCodeAsDefaultMessage = false;


//省略
}

从上面的basename = "messages"可知,SpringBoot 默认国际化资源文件为:classpath:messages.properties。如果项目中的资源文件不是默认文件名,则我们可以通过spring.messages.basename指定。比如在本文示例项目中配置如下,表示默认使用classpath下i18n目录下的resource.properties文件。

1
2
3
spring:
messages:
basename: i18n.resource

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RunWith(SpringRunner.class)
@SpringBootTest(classes = I18nBootstrap.class)
@Slf4j
public class I18nTest {

@Autowired
private MessageSource messageSource;

@Test
public void getMessage() {
String usMsg = messageSource.getMessage("greeting.morning", null, Locale.US);
String cnMsg = messageSource.getMessage("greeting.morning", null, Locale.CHINA);
log.info("us:{}", usMsg);
log.info("cn:{}", cnMsg);
}

}

输出:

1
2
2022-01-23 22:44:50.980  INFO 16728 --- [           main] c.l.springboot.i18n.test.I18nTest        : us:Good morning!
2022-01-23 22:44:50.989 INFO 16728 --- [ main] c.l.springboot.i18n.test.I18nTest : cn:早上好!

工具类

根据上面的分析,我们就可以自己封装一个简单的工具类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class MessageUtil {

@Autowired
private MessageSource messageSource;

public String get(String code) {
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(code, null, locale);
}

public String get(String code, String[] args) {
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(code, args, locale);
}

public String get(String code, String[] args, String defaultMessage) {
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(code, args, defaultMessage, locale);
}

}

------ 本文完 ------