本文项目已发布到 github,后续学习项目也会添加到此工程下,欢迎 fork 点赞。
https://github.com/wangyuheng/spring-boot-sample
国际化
简单来说,国际化就是让应用(app、web)适应不同的语言和地区的需要,比如根据地区选择页面展示语言。
i18n=internationalization,首末字符 i 和 n,18 为中间的字符数
原理
基于传入语言 or 地区标识进行判断,输出不同内容。伪代码如下:
func hello(var lang) {
if (lang == "にほんご") {
return "おはよう";
} else if (lang == "English") {
return "hello";
} else {
return “你好”
}
}
原理简单,但是如何优雅的实现?spring 是否已经提供了现成的轮子?答案是肯定的。基于原理可以认为,实现国际化主要分为 2 部分
- 输入语言 or 地区标识
- 输出不同语言 or 地区的内容文案
输出
通过 MessageSource 实现不同语言输出。
在 spring 初始化 refresh 过程中,会初始化 MessageSource。
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 的默认实现
@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)**读取对应的显示文本。
其中涉及到的三个概念
- basename 标识
- Locale
- Resource Bundle
basename
用于指定 Resource Bundle 文件位置,可以通过配置文件配置, 默认为 messages
spring.messages.basename=messages
Locale
Locale 对象代表具体的地理,政治或文化地区,用来指定语言及地区。构造函数如下
Locale(String language)
Locale(String language, String country)
Locale(String language, String country, String variant)
variant 变体值,用于指示变化的任意值 Locale。
同时, Locale 类内置了众多常用国家地区的常量实例,如
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 资源束的支持,方便用户添加管理国际化文案。
输入
看完输出的形式,可以知道,我们只需要确认 Locale 就可以实现国际化。所以我们再找一下 Locale 的轮子。
accept-language
servlet 自带轮子,基于 http 协议,即通过 header 中的 accept-language 报文头,实现 Locale 的自动识别。
代码见 org.apache.catalina.connector.Request
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 提供的轮子
- LocaleResolver 实现次接口,用于自定义解析规则
- RequestContextUtils 基于 request 获取 Locale,优先使用自定义 LocaleResolver
- LocaleContextHolder 通过 ThreadLocal 持有 Locale 对象,在 FrameworkServlet 支持 servlet.service 时执行初始化。
- LocaleChangeInterceptor 通过 Configuration 配置 Locale 切换规则
综上,只要请求方在 header 中增加了 accept-language 报文头,即可在代码中通过 LocaleContextHolder 获取 Locale 对象。
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
@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
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 了解规范定义
/**
* 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 开启兼容模式
localeChangeInterceptor.setLanguageTagCompliant(true);
但是为了符合规范,不推荐 zh_CH 这种写法。
倾向
按照《Http 参数格式约定》文中所述,通用&非业务参数,一般会选择放到 header 中,所以比较倾向于 accept-language 这种定义方法。
欢迎来到这里!
我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。
注册 关于