优雅的国际化实现

本文项目已发布到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部分

  1. 输入语言or地区标识
  2. 输出不同语言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)读取对应的显示文本。

其中涉及到的三个概念

  1. basename标识
  2. Locale
  3. 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资源束的支持,方便用户添加管理国际化文案。

i18n
i18n_0

输入

看完输出的形式,可以知道,我们只需要确认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提供的轮子

  1. LocaleResolver 实现次接口,用于自定义解析规则
  2. RequestContextUtils 基于request获取Locale,优先使用自定义LocaleResolver
  3. LocaleContextHolder通过ThreadLocal持有Locale对象,在FrameworkServlet支持servlet.service时执行初始化。
  4. 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这种定义方法。

wangyuheng wechat
扫码关注我的公众号,获取更多文章推送