Spring系列-7 国际化

背景:

为了提高软件的通用性(应对不同的语言环境)、扩大软件的业务受众范围,软件被要求具备处理国际化的能力。Java和Spring为此分别提供了不同层次的解决方案:Java在java.util包中提供了支持国际化能力的API与工具类,Spring基于此进行封装并提供了容器级别的接口。
本文作为Spring系列文章的第四篇,内容包含JDK、Spring、Spring Boot国际化相关的API的使用和背后原理;其中,基于Spring框架介绍国际化的使用和原理是本文的重点内容,该部分会伴随着Spring源码进行。

1.国际化

Java定义了Locale类用于表示国际化类型,包含国家和地区两种信息:

static public final Locale SIMPLIFIED_CHINESE = createConstant("zh", "CN");
static public final Locale CHINA = SIMPLIFIED_CHINESE;
static public final Locale US = createConstant("en", "US");
...

其中,Locale.CHINA表示简体中文(对应zh_CN);Locale.US表示美式英语(对应en_US).
根据语言和地区得到国际化对象后,可将用户信息翻译为该对象代表的国际化,案例如下:
(1) 国际化货币

@Test
public void testCurrencyInstance() {
    LOGGER.info("[CN] result is {}", NumberFormat.getCurrencyInstance(Locale.CHINA).format(100.00));
    LOGGER.info("[US] result is {}", NumberFormat.getCurrencyInstance(Locale.US).format(100.00));
    LOGGER.info("[default] result is {}", NumberFormat.getCurrencyInstance().format(100.00));
}

得到如下结果:
在这里插入图片描述

(2) 国际化日期

@Test
public void testDateInstance() {
    Date date = new Date();
    LOGGER.info("[CN] result is {}", DateFormat.getDateInstance(DateFormat.DEFAULT, Locale.CHINA).format(date));
    LOGGER.info("[US] result is {}", DateFormat.getDateInstance(DateFormat.DEFAULT, Locale.US).format(date));
    LOGGER.info("[default] result is {}", DateFormat.getDateInstance(DateFormat.DEFAULT).format(date));
}

得到如下结果:
在这里插入图片描述(3) 国际化时间

@Test
public void testTimeInstance() {
    Date date = new Date();
    LOGGER.info("[CN] result is {}", DateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.CHINA).format(date));
    LOGGER.info("[US] result is {}", DateFormat.getTimeInstance(DateFormat.DEFAULT, Locale.US).format(date));
    LOGGER.info("[default] result is {}", DateFormat.getTimeInstance(DateFormat.DEFAULT).format(date));
}

得到如下结果:
在这里插入图片描述
分析:
当不指定语言类型时,系统会使用默认的国际化类型,因此3个测试用例的[default]内容同[CN]。默认的国际化类型信息来自平台本身:

@Test
public void testDefaultLocal() {
    String language = AccessController.doPrivileged(new GetPropertyAction("user.language", "en"));
    LOGGER.info("Default language is {}.", language);

    String country = AccessController.doPrivileged(new GetPropertyAction("user.country", ""));
    LOGGER.info("Default country is {}.", country);

    String region = AccessController.doPrivileged(new GetPropertyAction("user.region"));
    LOGGER.info("Default region is {}.", region);
}

得到如下结果:
在这里插入图片描述

国际化涉及到时间、货币、数字和字符串等,本文后续内容围绕后者进行。

2.使用方式

2.1 JDK中的国际化

在资源路径下准备国际化文件

(1) 准备国际化文件:
国际化资源文件的命名需要遵循命名规范:资源名_语言_国家/地区.properties,如:messages_en_US.properties表示美式英语,messages_zh_CN.properties表示简体中文。
另外,语言和国家/地区可以缺省—🥷—用于表示默认的资源文件,如message.properties;当某个本地化类型在系统中找不到对应的资源文件时,就使用这个默认项。

#resources/i18n文件夹下
#messages.properties文件
10000=您好,北京
10001=您好,南京
10002=您好,上海

#messages_en_US.properties文件
10000=Hello, Beijing
10001=Hello, Nanjing

#messages_zh_CN.properties文件
10000=你好,北京
10001=你好,南京

资源文件对文件内容有严格的要求:只能包含ASCII字符。所以必须将非ASCII字符的内容转换为Unicode代码的表示方式。

(2) 使用如下命令进行转换:

// native2ascii是JDK提供的命令
 native2ascii -encoding utf-8 ./i18n/messages.properties ./i18n/messages.properties
 native2ascii -encoding utf-8 ./i18n/messages_zh_CN.properties ./i18n/messages_zh_CN.properties 

得到结果如下:

#resources/i18n文件夹下
#messages.properties文件
10000=\u60A8\u597D\uFF0C\u5317\u4EAC
10001=\u60A8\u597D\uFF0C\u5357\u4EAC
10002=\u60A8\u597D\uFF0C\u4E0A\u6D77

#messages_en_US.properties文件
10000=Hello, Beijing
10001=Hello, Nanjing

#messages_zh_CN.properties文件
10000=\u4F60\u597D\uFF0C\u5317\u4EAC
10001=\u4F60\u597D\uFF0C\u5357\u4EAC

(3) IDEA配置Transparent native-to-sacii conversion:
阅读与修改Unicode代码存在一定难度,通过对IDEA进行配置,开发人员可以友好地操作资源文件:

#resources/i18n文件夹下
#messages.properties文件
10000=您好,北京
10001=您好,南京
10002=您好,上海

#messages_en_US.properties文件
10000=Hello, Beijing
10001=Hello, Nanjing

#messages_zh_CN.properties文件
10000=你好,北京
10001=你好,南京

配置方式:Preferences/Settings -> Editor -> File Encodings -> Transparent native-to-sacii conversion

获取国际化资源
@Slf4j
@Slf4j
public class JavaI18nTest {
    @Test
    public void testJavaI18n() {
        ResourceBundle cnBundle = ResourceBundle.getBundle("i18n/messages", Locale.CHINA);
        LOGGER.info("[CHINA] 10000.msg is {}.", cnBundle.getString("10000"));
        LOGGER.info("[CHINA] 10001.msg is {}.", cnBundle.getString("10001"));
        LOGGER.info("[CHINA] 10002.msg is {}.", cnBundle.getString("10002"));
        ResourceBundle usBundle = ResourceBundle.getBundle("i18n/messages", Locale.US);
        LOGGER.info("[US] 10000.msg is {}.", usBundle.getString("10000"));
        LOGGER.info("[US] 10001.msg is {}.", usBundle.getString("10001"));
        LOGGER.info("[US] 10002.msg is {}.", usBundle.getString("10002"));
    }
}

运行后得到如下结果:
在这里插入图片描述
从结果可知:红框标记的输出项的国际化信息来源于默认的资源文件messages.properties.

2.1.Spring项目

Spring对JDK的国际化API进行了封装和增强,提高了容器级别的接口MessageSource,该接口位于context模块,共提供三种获取国际化信息的接口,如下所示:

package org.springframework.context;

import java.util.Locale;
import org.springframework.lang.Nullable;

public interface MessageSource {
	// defaultMessage为默认值,获取失败时-返回该对象
	@Nullable
	String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
	
	// 常用
	String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;

	// 使用传入的resolvable对象来解析国际化
	String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}

其中第二种较为常用,code表示待解析的字符串信息,args为占位符参数(来自JDK的API),Locale为国际化对象,返回的字符串为本地化后的结果。

MessageSource接口的继承关系如下:
在这里插入图片描述
如上图所示,Spring共提供了四种实现类:DelegatingMessageSource, StaticMessageSource, ResourceBundleMessageSource, ReloadableResourceBundleMessageSource.
常见为后两种: ResourceBundleMessageSource依赖于JDK提供的API,即依赖ResourceBundle.getBundle(basename, locale, classLoader, control)方法实现国际化;ReloadableResourceBundleMessageSource提供了定时更新国际化的能力而无需重启机器; 本文以ResourceBundleMessageSource为例介绍如何在Spring项目中使用国际化(ReloadableResourceBundleMessageSource使用与之类似)。

以ResourceBundleMessageSource为例介绍:

配置国际化资源:
使用与2.1中相同的国际化资源.

配置Bean:

<bean id="myResource" class="org.springframework.context.support.ResourceBundleMessageSource">
    <property name="basename" value="i18n/messages"/>
    <property name="defaultEncoding" value="UTF-8"/>
</bean>

当使用ReloadableResourceBundleMessageSource时,需要同时配置更新时间:

<bean id="myResource" class="org.springframework.context.support.ReloadableResourceBundleMessageSource">
    <property name="basename" value="i18n/messages"/>
    <property name="defaultEncoding" value="UTF-8"/>
    <property name="cacheSeconds" value="5"/>
</bean>

注意:这里写法有问题(举例可忽略),实际beanName需要设置为messageSource,原因在章节3原理部分介绍。

读取国际化资源:

@Slf4j
public class SpringDemoApplication {
    public static void main(String[] args) {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring-context.xml");
        MessageSource messageSource = (MessageSource) context.getBean("myResource");

        LOGGER.info("[zh-CN] 10000.msg is {}.", messageSource.getMessage("10000", null, Locale.CHINA));
        LOGGER.info("[en-US] 10000.msg is {}.", messageSource.getMessage("10000", null, Locale.US));
    }
}

得到结果:
在这里插入图片描述

另外,对于Spring提供的ResourceBundleMessageSource类,项目可以选择将其作为Bean对象(如上),也可以不作为Bean对象,如下所示:

@Slf4j
public class I18nTest {
    @Test
    public void testI18n() {
        MessageSource messageSource = buildMessageSource();
        LOGGER.info("[zh-CN] 10000.msg is {}.", messageSource.getMessage("10000", null, Locale.CHINA));
        LOGGER.info("[en-US] 10000.msg is {}.", messageSource.getMessage("10000", null, Locale.US));
    }

    private MessageSource buildMessageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasenames("i18n/messages");
        messageSource.setDefaultEncoding(Charset.forName("UTF-8").name());
        messageSource.setFallbackToSystemLocale(true);
        messageSource.setCacheSeconds(-1);
        messageSource.setAlwaysUseMessageFormat(false);
        messageSource.setUseCodeAsDefaultMessage(true);
        return messageSource;
    }
}

得到运行结果:
在这里插入图片描述

2.2 SpringBoot项目

配置国际化资源:
使用与2.1中相同的国际化资源.

在application.yml中进行参数配置:

spring:
  messages:
    basename: i18n/messages
    encoding: UTF-8
    fallback-to-system-locale: false

使用国际化资源:

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class I18nTest {
    @Autowired
    private MessageSource messageSource;

    @Test
    public void testI18n() {
        LOGGER.info("[zh-CN] 10000.msg is {}.", messageSource.getMessage("10000",null, Locale.CHINA));
        LOGGER.info("[en-US] 10000.msg is {}.", messageSource.getMessage("10000",null, Locale.US));
    }
}

得到运行结果如下:
在这里插入图片描述

3.原理

3.1 Spring项目国际化原理

在启动Spring容器时—执行refresh()的步骤中存在initMessageSource()方法:

@Override
public void refresh() throws BeansException, IllegalStateException {
	//...
	registerBeanPostProcessors(beanFactory);
	
	// Initialize message source for this context.
	initMessageSource();
	
	//...
	
	// Instantiate all remaining (non-lazy-init) singletons.
	finishBeanFactoryInitialization(beanFactory);
	// ...
}

进入initMessageSource()内部:

// 简化后的代码
protected void initMessageSource() {
	ConfigurableListableBeanFactory beanFactory = getBeanFactory();
	if (beanFactory.containsLocalBean("messageSource")) {
		this.messageSource = beanFactory.getBean("messageSource", MessageSource.class);
		// Make MessageSource aware of parent MessageSource.
		if (this.parent != null && this.messageSource instanceof HierarchicalMessageSource) {
			HierarchicalMessageSource hms = (HierarchicalMessageSource) this.messageSource;
			if (hms.getParentMessageSource() == null) {
				// Only set parent context as parent MessageSource if no parent MessageSource
				// registered already.
				hms.setParentMessageSource(getInternalParentMessageSource());
			}
		}
	} else {
		// Use empty MessageSource to be able to accept getMessage calls.
		DelegatingMessageSource dms = new DelegatingMessageSource();
		dms.setParentMessageSource(getInternalParentMessageSource());
		this.messageSource = dms;
		beanFactory.registerSingleton("messageSource", this.messageSource);
	}
}

代码只有两个主分支,逻辑比较清晰:
如果BeanFactory中存在beanName为"messageSource"的BeanDefinition: 实例化该BeanDefinition并将Bean对象设置给AbstractApplicationContext的messageSource属性;同时如果该Spring容器存在父容器且该国际化对象为HierarchicalMessageSource类型(具备父子结构), 则一并设置国际化对象的父子关系。
不存在"messageSource": 实例化一个DelegatingMessageSource类型的对象,并注册到IOC容器中,同时赋值给AbstractApplicationContext的messageSource属性。如注释// Use empty MessageSource to be able to accept getMessage calls :DelegatingMessageSource无实际功能,是为了让调用不会抛空指针,如下所示:

// DelegatingMessageSource:
@Override
public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
	if (this.parentMessageSource != null) {
		return this.parentMessageSource.getMessage(code, args, locale);
	}
	else {
		throw new NoSuchMessageException(code, locale);
	}
}

个人见解:
Spring容器在AbstractApplicationContext中引入了messageSource属性,并在启动容器的过程中将"messageSource"对应的Bean对象设置给messageSource属性;这就明示着要将国际化对象作为一个容器级别的全局组件对外提供,因此Spring项目中如果设置了国际化对象,必须将beanName设置为"messageSource".

在2.1章节中介绍了MessageSource的类继承关系图,仅展示了真正实现国际化功能的子类,为包含代理类如ApplicationContext系列。如下所示是AbstractApplicationContext中对MessageSource中接口的实现:

@Override
public String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) {
	return getMessageSource().getMessage(code, args, defaultMessage, locale);
}

@Override
public String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
	return getMessageSource().getMessage(code, args, locale);
}

@Override
public String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException {
	return getMessageSource().getMessage(resolvable, locale);
}

private MessageSource getMessageSource() throws IllegalStateException {
	if (this.messageSource == null) {
		throw new IllegalStateException("MessageSource not initialized - call 'refresh' before accessing messages via the context: " + this);
	}
	return this.messageSource;
}

即完全将国际化功能委托给了messageSource属性。
因此,在Spring项目中可以直接使用Spring容器对象或者messageSource对应的Bean对象来获取国际化信息。
在2.1中提到了ResourceBundleMessageSource依赖于JDK的API,这里稍作探究:

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class I18nTest {
    @Autowired
    private MessageSource messageSource;

    @Test
    public void testI18n() {
        LOGGER.info("[zh-CN] 10000.msg is {}.", messageSource.getMessage("10000",null, Locale.CHINA));
        LOGGER.info("[en-US] 10000.msg is {}.", messageSource.getMessage("10000",null, Locale.US));
    }
}

Step1: 跟进getMessage方法:

@Override
public final String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
	// 主干流程
	String msg = getMessageInternal(code, args, locale);
	if (msg != null) {
		return msg;
	}
	// 从支1:返回code本身作为国际化结果
	String fallback = getDefaultMessage(code);
	if (fallback != null) {
		return fallback;
	}
	// 从支2: 抛出异常
	throw new NoSuchMessageException(code, locale);
}

先调用getMessageInternal(code, args, locale)获取国际化信息,不为空则直接返回;
否则进入从支1,调用getDefaultMessage(code)逻辑,如果配置项useCodeAsDefaultMessage属性为true(默认为false)则将code本身作为国际化信息返回,否则getDefaultMessage(code)得到null;进入从支2—抛出异常。
Step2: 跟进getMessageInternal(code, args, locale)方法:

@Nullable
protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
	if (!isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
		String message = resolveCodeWithoutArguments(code, locale);
		if (message != null) {
			return message;
		}
	} else {
		argsToUse = resolveArguments(args, locale);
		MessageFormat messageFormat = resolveCode(code, locale);
		if (messageFormat != null) {
			synchronized (messageFormat) {
				return messageFormat.format(argsToUse);
			}
		}
	}

	// Check locale-independent common messages for the given message code.
	Properties commonMessages = getCommonMessages();
	if (commonMessages != null) {
		String commonMessage = commonMessages.getProperty(code);
		if (commonMessage != null) {
			return formatMessage(commonMessage, args, locale);
		}
	}
	// Not found -> check parent, if any.
	return getMessageFromParent(code, argsToUse, locale);
}

整体流程比较清晰:
(1) 优先从国际化资源中获取,获取失败时再从配置项commonMessages对象中获取—🥶—获取失败再从其父对象中获取;
(2) 从国际化资源中获取时,如果需要处理占位符则先处理占位符再处理国际化:

argsToUse = resolveArguments(args, locale);
MessageFormat messageFormat = resolveCode(code, locale);
if (messageFormat != null) {
	synchronized (messageFormat) {
		return messageFormat.format(argsToUse);
	}
}

进入resolveCode方法内部:

protected MessageFormat resolveCode(String code, Locale locale) {
	Set<String> basenames = getBasenameSet();
	for (String basename : basenames) {
	// ⚠️获取ResourceBundle对象
		ResourceBundle bundle = getResourceBundle(basename, locale);
		if (bundle != null) {
			MessageFormat messageFormat = getMessageFormat(bundle, code, locale);
			if (messageFormat != null) {
				return messageFormat;
			}
		}
	}
	return null;
}

(3) 不需要处理占位符:直接调用resolveCodeWithoutArguments(code, locale)获取;
进入resolveCodeWithoutArguments内部:

protected String resolveCodeWithoutArguments(String code, Locale locale) {
	Set<String> basenames = getBasenameSet();
	for (String basename : basenames) {
		// ⚠️获取ResourceBundle对象
		ResourceBundle bundle = getResourceBundle(basename, locale);
		if (bundle != null) {
			String result = getStringOrNull(bundle, code);
			if (result != null) {
				return result;
			}
		}
	}
	return null;
}

Step3: 跟进getResourceBundle(basename, locale);
step2中的(2)和(3)最终都会调用getResourceBundle(basename, locale)方法获取ResourceBundle对象,然后从ResourceBundle对象中获取国际化信息(根据code从ResourceBundle对象的属性中解析,逻辑较为简单);
剩余的调用链依次为:ResourceBundle bundle = doGetBundle(basename, locale); -> ResourceBundle.getBundle(basename, locale, classLoader, control);, 而后者为JDK提供的API。
JDK中的国际化API实现方式不是本文关心的内容,因此不再继续往下进行;感兴趣的读者可以继续往下扒一下JDK是如何加载资源文件并构造ResourceBundle对象的流程(有问题可在留言区讨论)。

3.2 SpringBoot项目国际化原理

在Spring项目中手动配置了ResourceBundleMessageSource类型的Bean对象,而SpringBoot可以直接从IOC中获取而不需要配置;这是因为Spring Boot通过自动装配机制向IOC中默认注入了一个ResourceBundleMessageSource类型的Bean对象。

自动装配的逻辑不在本文介绍,将在介绍完Spring Bean/AOP 后补上,感兴趣读者可订阅Spring系列专题。
本章节认为读者对SpringBoot的自动装配已有基本了解。

spring.factories文件中引入MessageSourceAutoConfiguration类:

在[spring-boot-autoconfigure]模块的spring.factories文件的
org.springframework.boot.autoconfigure.EnableAutoConfiguration属性中存在org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration类型:
在这里插入图片描述

MessageSourceAutoConfiguration注入条件:

MessageSourceAutoConfiguration类上存在@ConditionalOnMissingBean注解,如下所示:

@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(name = "messageSource", search = SearchStrategy.CURRENT)
public class MessageSourceAutoConfiguration {
//...
}

表示:当有beanName为messageSource时,该类的装配过程不会进行;因此用户可自定义MessageSource,如ReloadableResourceBundleMessageSource:
在这里插入图片描述

配置属性MessageSourceProperties:

在MessageSourceAutoConfiguration中定义了MessageSourceProperties的注入逻辑:

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

@ConfigurationProperties(prefix = "spring.messages")表示读取配置文件中spring.messages表示的信息并注入到MessageSourceProperties对象中;
对应2.2章节中的配置:
在application.yml中进行参数配置:

spring:
  messages:
    basename: i18n/messages
    encoding: UTF-8
    fallback-to-system-locale: false

上述属性对应MessageSourceProperties配置类中的属性:

public class MessageSourceProperties {
	// 默认为resources资源路径下的"messages"
    private String basename = "messages";
    
    // 编码格式,一般设置为utf-8(默认是 UTF-8)
    private Charset encoding;
    
    // 指定资源、默认资源文件找不到时是否从当前系统语言对应配置文件中查找
    // 默认为true: 进行查找;false时-抛出异常
    private boolean fallbackToSystemLocale;
    
    // 无论是有有注入参数都使用MessageFormat.format函数对结果进行格式化(默认是 false)
    // 注入参数值调用MessageSource接口时传入的args参数),
    private boolean alwaysUseMessageFormat;
    
    // 根据code获取不到国际化信息是,是否直接返回code(默认是 false)
    private boolean useCodeAsDefaultMessage;
}

MessageSourceProperties本质上是一个临时的内存数据—🐭—为了读取作准备(实现配置文件 -> 内存),实例化ResourceBundleMessageSource对象时从中读取配置信息.

注入MessageSource:

在MessageSourceAutoConfiguration中定义了MessageSource的注入逻辑:

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

逻辑较为简单,直接从MessageSourceProperties中读取国际化配置信息构造ResourceBundleMessageSource对象,并注入到IOC容器中。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值