优雅的国际化实现

本文项目已发布到github,后续学习项目也会添加到此工程下,欢迎fork点赞。
https://github.com/wangyuheng/spring-boot-sample

国际化

简单来说,国际化就是让应用(app、web)适应不同的语言和地区的需要,比如根据地区选择页面展示语言。

i18n=internationalization,首末字符i和n,18为中间的字符数

原理

基于传入语言or地区标识进行判断,输出不同内容。伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
func hello(var lang) {

if (lang == "にほんご") {
return "おはよう";
} else if (lang == "English") {
return "hello";
} else {
return “你好”
}

}

原理简单,但是如何优雅的实现?spring是否已经提供了现成的轮子?答案是肯定的。基于原理可以认为,实现国际化主要分为2部分

  1. 输入语言or地区标识
  2. 输出不同语言or地区的内容文案

输出

通过MessageSource实现不同语言输出。

在spring初始化refresh过程中,会初始化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
39
40
41
42
43
44
45
public abstract class AbstractApplicationContext extends DefaultResourceLoader
implements ConfigurableApplicationContext, DisposableBean {
...
@Override
public void refresh() throws BeansException, IllegalStateException {
synchronized (this.startupShutdownMonitor) {
// Prepare this context for refreshing.
prepareRefresh();

// Tell the subclass to refresh the internal bean factory.
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();

// Prepare the bean factory for use in this context.
prepareBeanFactory(beanFactory);

try {
// Allows post-processing of the bean factory in context subclasses.
postProcessBeanFactory(beanFactory);

// Invoke factory processors registered as beans in the context.
invokeBeanFactoryPostProcessors(beanFactory);

// Register bean processors that intercept bean creation.
registerBeanPostProcessors(beanFactory);

// Initialize message source for this context.
initMessageSource();

// Initialize event multicaster for this context.
initApplicationEventMulticaster();

// Initialize other special beans in specific context subclasses.
onRefresh();

// Check for listener beans and register them.
registerListeners();

// Instantiate all remaining (non-lazy-init) singletons.
finishBeanFactoryInitialization(beanFactory);

// Last step: publish corresponding event.
finishRefresh();
}
...
}

而springboot会初始化ResourceBundleMessageSource实例作为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
@Configuration
@ConditionalOnMissingBean(value = MessageSource.class, search = SearchStrategy.CURRENT)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@Conditional(ResourceBundleCondition.class)
@EnableConfigurationProperties
@ConfigurationProperties(prefix = "spring.messages")
public class MessageSourceAutoConfiguration {
...
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
if (StringUtils.hasText(this.basename)) {
messageSource.setBasenames(StringUtils.commaDelimitedListToStringArray(
StringUtils.trimAllWhitespace(this.basename)));
}
if (this.encoding != null) {
messageSource.setDefaultEncoding(this.encoding.name());
}
messageSource.setFallbackToSystemLocale(this.fallbackToSystemLocale);
messageSource.setCacheSeconds(this.cacheSeconds);
messageSource.setAlwaysUseMessageFormat(this.alwaysUseMessageFormat);
return messageSource;
}
...
}

MessageSource内部则通过basename以及Locale定位到具体Resource Bundle文件,并基于code**(properties key)**读取对应的显示文本。

其中涉及到的三个概念

  1. basename标识
  2. Locale
  3. Resource Bundle

basename

用于指定Resource Bundle文件位置,可以通过配置文件配置, 默认为messages

1
spring.messages.basename=messages

Locale

Locale对象代表具体的地理,政治或文化地区,用来指定语言及地区。构造函数如下

1
2
3
4
5
Locale(String language)

Locale(String language, String country)

Locale(String language, String country, String variant)

variant 变体值,用于指示变化的任意值Locale。
同时, Locale类内置了众多常用国家地区的常量实例,如

1
2
3
4
5
6
7
static public final Locale ENGLISH = createConstant("en", "");

static public final Locale CHINESE = createConstant("zh", "");

static public final Locale SIMPLIFIED_CHINESE = createConstant("zh", "CN");

static public final Locale TRADITIONAL_CHINESE = createConstant("zh", "TW");

Resource Bundle

ResourceBundle类和Properties类似,都可以读取程序内的文件,不过ResourceBundle更强大,提供了诸如缓存、Locale区分一类的操作。

所以MessageSource其实是对ResourceBundle的一种封装增加,优化了使用,并且托管与spring容器生命周期。这时就有一个很重要的选择:

如果通过静态类封装了restful的接口返回,可以自己扩展ResourceBundle类,而不是将MessageSource的spring实例放置在静态类中。

而idea中提供了Resource Bundle资源束的支持,方便用户添加管理国际化文案。

i18n
i18n_0

输入

看完输出的形式,可以知道,我们只需要确认Locale就可以实现国际化。所以我们再找一下Locale的轮子。

accept-language

servlet自带轮子,基于http协议,即通过header中的accept-language报文头,实现Locale的自动识别。

代码见org.apache.catalina.connector.Request

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
public class Request implements org.apache.catalina.servlet4preview.http.HttpServletRequest {
@Override
public Locale getLocale() {

if (!localesParsed) {
parseLocales();
}

if (locales.size() > 0) {
return locales.get(0);
}

return defaultLocale;
}
protected void parseLocales() {

localesParsed = true;

// Store the accumulated languages that have been requested in
// a local collection, sorted by the quality value (so we can
// add Locales in descending order). The values will be ArrayLists
// containing the corresponding Locales to be added
TreeMap<Double, ArrayList<Locale>> locales = new TreeMap<>();

Enumeration<String> values = getHeaders("accept-language");

while (values.hasMoreElements()) {
String value = values.nextElement();
parseLocalesHeader(value, locales);
}

// Process the quality values in highest->lowest order (due to
// negating the Double value when creating the key)
for (ArrayList<Locale> list : locales.values()) {
for (Locale locale : list) {
addLocale(locale);
}
}
}

}

spring提供的轮子

  1. LocaleResolver 实现次接口,用于自定义解析规则
  2. RequestContextUtils 基于request获取Locale,优先使用自定义LocaleResolver
  3. LocaleContextHolder通过ThreadLocal持有Locale对象,在FrameworkServlet支持servlet.service时执行初始化。
  4. LocaleChangeInterceptor通过Configuration配置Locale切换规则

综上,只要请求方在header中增加了accept-language报文头,即可在代码中通过LocaleContextHolder获取Locale对象。

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
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;

@SpringBootApplication
@RestController
public class I18nApplication {

public static void main(String[] args) {
SpringApplication.run(I18nApplication.class, args);
}

@Autowired
private MessageSource messageSource;

@GetMapping("hello")
public Object hello(HttpServletRequest request) {
return messageSource.getMessage("10000", new Object[]{}, LocaleContextHolder.getLocale());
}

}

lang

除了accept-language外,常见在url中增加了国家及地区参数,如: https://twitter.com/?lang=zh

通过lang配置国际化,需要通过LocaleChangeInterceptor进行配置,此配置的优先级高于accept-language

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Configuration
public class I18nConfig extends WebMvcConfigurerAdapter {

@Bean
public LocaleResolver localeResolver() {
SessionLocaleResolver sessionLocaleResolver = new SessionLocaleResolver();
return sessionLocaleResolver;
}

@Bean
public LocaleChangeInterceptor localeChangeInterceptor() {
LocaleChangeInterceptor localeChangeInterceptor = new LocaleChangeInterceptor();
localeChangeInterceptor.setParamName("lang");
return localeChangeInterceptor;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(localeChangeInterceptor());
}
}

Test Case

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.context.embedded.LocalServerPort;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.Locale;
import java.util.ResourceBundle;

import static org.junit.Assert.assertEquals;

@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment=SpringBootTest.WebEnvironment.RANDOM_PORT)
public class I18nApiTest {

@LocalServerPort
private int port;

private TestRestTemplate restTemplate = new TestRestTemplate();

@Test
public void should_return_message_by_different_accept_language() throws Exception {
HttpHeaders headers = new HttpHeaders();
headers.add("accept-language", "en");
HttpEntity entity = new HttpEntity(headers);
ResponseEntity<String> resultEn = restTemplate.exchange("http://localhost:"+port+"/hello", HttpMethod.GET, entity, String.class);
assertEquals("hello", resultEn.getBody());

headers.remove("accept-language");
headers.add("accept-language", "zh");
entity = new HttpEntity(headers);
ResponseEntity<String> resultCh = restTemplate.exchange("http://localhost:"+port+"/hello", HttpMethod.GET, entity, String.class);
assertEquals("你好", resultCh.getBody());
}

@Test
public void should_return_zh_message_by_accept_language_zh_locale(){
LocaleContextHolder.setLocale(Locale.CHINA);
assertEquals("你好", ResourceBundle.getBundle("messages", LocaleContextHolder.getLocale()).getString("10000"));
}

@Test
public void should_return_zh_message_by_different_lang(){
String lang = "en";
ResponseEntity<String> resultEn = restTemplate.getForEntity("http://localhost:"+port+"/hello?lang="+lang, String.class);
assertEquals("hello", resultEn.getBody());
lang = "zh";
ResponseEntity<String> resultCh = restTemplate.getForEntity("http://localhost:"+port+"/hello?lang="+lang, String.class);
assertEquals("你好", resultCh.getBody());
}

@Test
public void should_return_by_lang_when_set_lang_and_accept_language(){
String lang = "zh";
HttpHeaders headers = new HttpHeaders();
headers.add("accept-language", "en");
HttpEntity entity = new HttpEntity(headers);
ResponseEntity<String> resultEn = restTemplate.exchange("http://localhost:"+port+"/hello?lang="+lang, HttpMethod.GET, entity, String.class);
assertEquals("你好", resultEn.getBody());
}

}

LanguageTagCompliant

Locale的命名规则为 lang-country, 如: zh-CN,有时会看到zh_CH这种写法,这是另一种规范,可以在CookieLocaleResolver了解规范定义

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Parse the given locale value coming from an incoming cookie.
* <p>The default implementation calls {@link StringUtils#parseLocaleString(String)}
* or JDK 7's {@link Locale#forLanguageTag(String)}, depending on the
* {@link #setLanguageTagCompliant "languageTagCompliant"} configuration property.
* @param locale the locale value to parse
* @return the corresponding {@code Locale} instance
* @since 4.3
*/
@UsesJava7
protected Locale parseLocaleValue(String locale) {
return (isLanguageTagCompliant() ? Locale.forLanguageTag(locale) : StringUtils.parseLocaleString(locale));
}

需要在中LocaleChangeInterceptor开启兼容模式

1
localeChangeInterceptor.setLanguageTagCompliant(true);

但是为了符合规范,不推荐zh_CH这种写法。

倾向

按照《Http参数格式约定》文中所述,通用&非业务参数,一般会选择放到header中,所以比较倾向于accept-language这种定义方法。