优雅的国际化实现

本贴最后更新于 2300 天前,其中的信息可能已经时移世改

本文项目已发布到 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 这种定义方法。

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3190 引用 • 8214 回帖
  • Spring

    Spring 是一个开源框架,是于 2003 年兴起的一个轻量级的 Java 开发框架,由 Rod Johnson 在其著作《Expert One-On-One J2EE Development and Design》中阐述的部分理念和原型衍生而来。它是为了解决企业应用开发的复杂性而创建的。框架的主要优势之一就是其分层架构,分层架构允许使用者选择使用哪一个组件,同时为 JavaEE 应用程序开发提供集成的框架。

    943 引用 • 1460 回帖 • 1 关注
  • 国际化

    i18n(其来源是英文单词 internationalization 的首末字符 i 和 n,18 为中间的字符数)是“国际化”的简称。对程序来说,国际化是指在不修改代码的情况下,能根据不同语言及地区显示相应的界面。

    8 引用 • 26 回帖 • 1 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...