从源码MessageSource的三个实现出发实战spring·i18n国际化

时间:2022-07-09 00:42:29


1.前言

互联网业务出海,将已有的业务​​Copy to Global​​,并且开始对各个国家精细化,本土化的运营。对于开发人员来说,国际化很重要,在实际项目中所要承担的职责是按照客户指定的语言让服务端返回相应语言的内容。本文基于spring的国际化支持,实现国际化的开箱即用,静态文件配置刷新生效以及全局异常国际化处理。

2.spring·i18n

​ApplicationContext​​接口继承了​​MessageSource​​接口,因此对外提供了​​internationalization(i18n)​​国际化的能力。如下就是常用的国际化中消息转换的三个方法:

public interface MessageSource {
//通过code检索对应Locale的消息,如果找不到就使用defaultMessage作为默认值
@Nullable
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
//通过code检索对应Locale的消息,如果找不到会抛出异常,NoSuchMessageException
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
//和上面的方法其实本质是一样的,只是通过resolvable去包装了code,argument,defaultMessage。
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}

在spring初始化之后,如果能在容器中找到​​messageSource​​的bean,会使用它进行消息解析转换。如果找不到,spring自己会实例化一个​​DelegatingMessageSource​​,不过这个对象中所有的方法都是空实现,还是需要有具体的实现去做事情。

而​​MessageSource​​接口有三个主要的实现类:

​ResourceBundleMessageSource​​,​​ReloadableResourceBundleMessageSource​​,​​StaticMessageSource​​。

3.StaticMessageSource

3.1 简单使用

​StaticMessageSource​​,静态内存消息源,使用的比较少,他主要通过编码的形式添加国际化映射对。可以在项目启动时,手动注入一个​​StaticMessageSource​​。

@Bean
StaticMessageSource messageSource(){
StaticMessageSource messageSource = new StaticMessageSource();
messageSource.addMessage("test1",Locale.CHINESE,"{0} 开始测试");
messageSource.addMessage("test1",Locale.ENGLISH,"{0} start");
return messageSource;
}
3.2 AOP动态化从DB中加载国际化配置

自定义一个​​MineStaticMessageSource​​,借助​​StaticMessageSource​​的可编码能力,可以简单实现从数据库中加载所有的配置信息,并且注入到国际化配置中生效。如下:

项目启动时就从DB中获取所有的国际化配置信息,组装好后全部注入到​​MineStaticMessageSource​​中。

@Component
public class MineStaticMessageSource extends StaticMessageSource implements InitializingBean {

@Autowired
private StaticMessageService staticMessageService;
@Override
public void afterPropertiesSet() throws Exception {
List<StaticMessageDTO> staticMessages= staticMessageService.all();
for (StaticMessageDTO staticMessage : staticMessages) {
addMessage(staticMessage.getCode(),staticMessage.getLocale(),staticMessage.getMessage());
}
}
}

如何实现数据库更改并动态感知刷新呢?那就要在数据库中配置修改时能感知到,并且通知到自定义的这个消息对象去重新初始化国际化配置。有如下方案经供参考:

  • 通过AOP切面,拦截所有修改(增删改)国际化配置的方法,在数据入库成功之后,通过spring自带的事件机制进行通知,可以使用@AfterReturning环绕。并针对不同的code进行重新组装数据。
  • 实现上弯弯绕绕的,需要做很多编码实现。而且需要考虑事务问题,异常问题。所有的数据都在StaticMessageSource的国际化map中,实际上我们并不能去删除一个国际化配置,使用以下的addMessage增改配置是没有问题的。
private final Map<String, Map<Locale, MessageHolder>> messageMap = new HashMap<>();

public void addMessage(String code, Locale locale, String msg) {
Assert.notNull(code, "Code must not be null");
Assert.notNull(locale, "Locale must not be null");
Assert.notNull(msg, "Message must not be null");
this.messageMap.computeIfAbsent(code, key -> new HashMap<>(4)).put(locale, new MessageHolder(msg, locale));
if (logger.isDebugEnabled()) {
logger.debug("Added message [" + msg + "] for code [" + code + "] and Locale [" + locale + "]");
}
}

4.ResourceBundleMessageSource

​ResourceBundleMessageSource​​,资源包消息源。通过在项目的classpath中定义多个​​filename.properties​​,然后在创建​​ResourceBundleMessageSource​​时将定义的文件名都注入到其中的​​basenameSet​​属性中。项目启动就可以把文件中的配置读取翻译展示。

4.1 简单使用

创建​​ResourceBundleMessageSource​​并注入到spring容器中,

@Bean
ResourceBundleMessageSource messageSource(){
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasenames("test-i18n");
return messageSource;
}

创建​​test-i18n.properties​​文件:

test.message=hello,world!

测试成功:

@RequestMapping("/get")
public String get(String code, HttpServletRequest request) {
return messageSource.getMessage(code,null,request.getLocale());
}

//返回值 hello,world!
4.2 源码解析·热加载静态文件

​ResourceBundleMessageSource​​对于消息的解析处理时,对于​​Basenames​​中的多个文件会依次创建对应的​​ResourceBundle​​,并根据code返回对应的message。做一个实验,项目启动之后,对配置的静态文件中的配置热修改,再请求一次,值会发生变化吗?

不会。因为​​ResourceBundleMessageSource​​中有缓存机制,对于前文说的创建的​​ResourceBundle​​会根据​​Basename​​进行缓存,系统启动之后,就缓存了所有的​​ResourceBundle​​。缓存结构是:​​Basename​​中包含​​<Locate,ResourceBundle>​​。

那么,如何实现动态加载修改过的静态文件呢?从源码中我们可以看到:

private final Map<String, Map<Locale, ResourceBundle>> cachedResourceBundles =
new ConcurrentHashMap<>();

if (getCacheMillis() >= 0) {
// Fresh ResourceBundle.getBundle call in order to let ResourceBundle
// do its native caching, at the expense of more extensive lookup steps.
return doGetBundle(basename, locale);
}
else {
// Cache forever: prefer locale cache over repeated getBundle calls.
Map<Locale, ResourceBundle> localeMap = this.cachedResourceBundles.get(basename);
}

有个属​​cacheMillis​​性控制了是否会走缓存,当​​cacheMillis​​大于0时,每次都不会走缓存,重新生成​​ResourceBundle​​,那么最基本的优化点就是如下了。

@Bean
ResourceBundleMessageSource messageSource(){
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasenames("test-i18n");
messageSource.setCacheSeconds(10);
return messageSource;
}

实现的效果是每次进入缓存判断分支时都会不走缓存,重新生成​​ResourceBundle​​,也就实现了动态加载静态文件的效果。

4.3 不同语言的国际化配置

以上只是通过​​ResourceBundle​​读取了​​properties​​文件,并解析message返回。实际项目使用中会根据各个国家,各个语言版本进行单独的配置,做到对外输出的国际化。比如,目前公司业务分布在中国,日本,菲律宾,一套后端服务要做到返回数据的国际化,就需要按照一定的格式去配置。命名规范:​​自定义名_语言代码_国别代码.properties​​。比如:

test-i18n_zh_CN.properties
test-i18n_ja_JP.properties
test-i18n_en_PH.properties

值得注意的是:设置正确的编码,banseName为前缀。​​test-i18n.properties​​为基类配置,在代码中实际上是​​ResourceBundle​​的父类,如果某个国家语言配置中不存在某个code,在父类中存在,那么也是可以正常获取值的。

@Bean
ResourceBundleMessageSource messageSource(){
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasenames("test-i18n");
messageSource.setCacheMillis(1000L);
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}

5.ReloadableResourceBundleMessageSource

再聊聊​​ReloadableResourceBundleMessageSource​​,相比于上文的​​ResourceBundleMessageSource​​,有以下变化:

  • 加载资源的方式不同:​​ResourceBundleMessageSource​​通过 JDK 提供的 ResourceBundle 加载资源文件;​​ReloadableResourceBundleMessageSource​​通过 ​​PropertiesPersister​​ 加载资源,支持 ​​xml​​、​​properties​​ 两个格式,优先加载 properties 格式的文件。如果同时存在 properties 和 xml 的文件,会只加载 properties 的内容;
  • 静态文件的热加载方式发生了变化,​​cacheMillis​​参数作用发生了变化。
5.1 简单使用

创建​​ReloadableResourceBundleMessageSource​​并注入到spring容器中,

@Bean
ReloadableResourceBundleMessageSource messageSource(){
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasenames("classpath:test-i18n");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}

创建​​test-i18n_zh_CN.properties​​文件:

test.message=你好,世界!

测试成功:

@RequestMapping("/get")
public String get(String code, HttpServletRequest request) {
return messageSource.getMessage(code,null,request.getLocale());
}

//返回值 你好,世界!
5.2 源码解析·不一样的缓存参数

首先我们看一下缓存部分的代码:

if (getCacheMillis() < 0) {
PropertiesHolder propHolder = getMergedProperties(locale);
String result = propHolder.getProperty(code);
if (result != null) {
return result;
}
}
else {
for (String basename : getBasenameSet()) {
List<String> filenames = calculateAllFilenames(basename, locale);
for (String filename : filenames) {
PropertiesHolder propHolder = getProperties(filename);
String result = propHolder.getProperty(code);
if (result != null) {
return result;
}
}
}
}

对于​​ReloadableResourceBundleMessageSource​​,设置​​messageSource.setCacheSeconds(10);​​的效果和前文说的​​ResourceBundleMessageSource​​的缓存控制条件相同,只有设置为<0时​​[默认值为-1]​​,才会进入缓存流程,而大于0则走向了文件加载&有条件刷新的流程。

@Bean
ReloadableResourceBundleMessageSource messageSource(){
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
messageSource.setBasenames("classpath:test-i18n");
messageSource.setDefaultEncoding("UTF-8");
return messageSource;
}

不设置的话默认值为-1。并且有意思的是,因为是采用​​PropertiesPersister​​进行文件的解析,所以缓存的数据源就的国际化配置文件中的​​key-value​​键值对,根据​​locale​​去读取所有的文件名,并将所有的​​key-value​​键值对全部都缓存到内存中的​​properties​​。同时使用locale进行路由不同的​​PropertiesHolder​​。

后续每次获取message的时候,都会从这个大​​properties[merged properties]​​中尝试获取,找得到就返回,找不到就抛异常。

//缓存各个语言的mergedHolder
PropertiesHolder mergedHolder = this.cachedMergedProperties.get(locale);
//根据配置去读取所有的文件名
List<String> filenames = calculateAllFilenames(basenames[i], locale);
//从缓存的properties中读取code对应的配置
String result = propHolder.getProperty(code);
if (result != null) {
return result;
}

​cacheMillis​​参数和前文​​ResourceBundleMessageSource​​不同点:

  • ​ResourceBundleMessageSource​​中​​cacheMillis​​只做了一件事,就是粗粒度地控制了是否走缓存流程,并且对于本地静态文件的刷新是每一次都会刷新。
  • 而​​ReloadableResourceBundleMessageSource​​中​​cacheMillis​​多了另一个职责-超时刷新静态文件,当不走缓存流程时,会通过比对​​上次刷新时间和[当前时间-cacheMillis]​​的大小去选择是否重新刷新本地的静态文件配置到内存中。
5.3 源码解析·双重缓存·刷新的奥义

从上文可知,设置​​messageSource.setCacheSeconds(10);​

控制缓存时间为10s,​​ReloadableResourceBundleMessageSource​​便具备了超时刷新的能力。

以下,​​originalTimestamp​​是上次properties刷新的时间戳,​​getCacheMillis()​​获取的是​​cacheMillis​​,目前我们的配置是10s,以下代码的判断很清晰了,如果刷新时间是在​​【当前时间减去缓存控制时间】​​之后,那么就直接使用原来的propHolder,不做刷新操作。

if (propHolder != null) {
originalTimestamp = propHolder.getRefreshTimestamp();
if (originalTimestamp == -1 || originalTimestamp > System.currentTimeMillis() - getCacheMillis()) {
// Up to date
return propHolder;
}
}

对于不走缓存流程的分支,其中这里也有一个缓存。这里的缓存是根据所有的国际化配置文件名作为key的缓存,而之前是通过​​Locate​​作为key进行缓存,这是最大的区别。这样做的好处就是,可以做到按文件进行刷新。

PropertiesHolder propHolder = this.cachedProperties.get(filename);

源码阅读中,一些小的技术细节也值得我们去品味,比如,对于每一个文件持有对象​​propHolder​​内部都有一个​​ReentrantLock​​,在多线程环境下,也能保证只有一个线程去进行文件读写刷新。这就保证了费时的操作可以尽可能地由单线程完成。

private final ReentrantLock refreshLock = new ReentrantLock();

propHolder.refreshLock.lock();

try {
PropertiesHolder existingHolder = this.cachedProperties.get(filename);
if (existingHolder != null && existingHolder.getRefreshTimestamp() > originalTimestamp) {
return existingHolder;
}
return refreshProperties(filename, propHolder);
}
finally {
propHolder.refreshLock.unlock();
}

对于需要刷新的key,调用​​refreshProperties(filename, propHolder);​​完成刷新,刷新操作很简单,从类路径下读取对应文件名的静态文件,并装载到内存中的properties中。同时设置文件最后的更新时间​​lastModified​​到propHolder中。

Properties props = loadProperties(resource, filename);
propHolder = new PropertiesHolder(props, fileTimestamp);

并且可以看到,设置本次刷新的时间戳,重新创建新的propHolder,并设置到缓存结构​​cachedProperties​​中去,完成本次的刷新。

propHolder.setRefreshTimestamp(refreshTimestamp);
this.cachedProperties.put(filename, propHolder);

以上,完成的效果就是:对于国际化的配置,当获取​​message​​时,如果本地静态文件修改之后,只要超过10秒就会刷新重新加载最新的配置信息到缓存中。

6.全局异常处理的国际化配置

业务对外跑出的异常,是国际化转换最重要的出口处。对于全局异常处理的方案老生常谈了。只需要使用几个注解就可以胜任。

@Slf4j
@ControllerAdvice
public class RestExceptionHandler {

@ExceptionHandler(value = BaseBizException.class)
public CommonResult<Object> handle(BaseBizException e) {
log.error("bizException", e);
return CommonResult.buildError(e.getErrorCode(), e.getErrorMsg());
}
}

那么如何结合以上我们的i18n的​​messageSource​​达成国际化转换呢?只需要稍稍改造就能完成。

  1. 全局异常处理类中注入​​messageSource​
  2. 业务异常处理方法新增​​Locale​​参数,他是国际化转换的路由因子。
  3. 使用​​messageSource​​的​​getMessage​​做国际化翻译,其中我们也可以把参数都带进来,这样就能做到参数化的国际化翻译。
  4. 最后就是吐出去,给亲爱的用户了。
@Autowired
MessageSource messageSource;

@ExceptionHandler(value = BaseBizException.class)
public CommonResult<Object> handleAccessDeniedException(BaseBizException e, HttpServletRequest request,
Locale locale) {
log.error("bizException", e);
String errorMessage = messageSource.getMessage(e.getMessage(), e.getArgs(), locale);
return CommonResult.buildError(e.getErrorCode(),errorMessage);
}

7.后续的思考

通过本地国际化语言静态文件可以实现多个语言的配置,并且配合缓存和文件刷新机制也能做到系统运行中的热更新。但是,现实中,我们很多服务都做了微服务部署,一个系统有多个实例。那么这种文件的形式就有了挑战。要么一个个去改服务器上的文件,要么就是通过一些统一挂载盘的形式去实现文件统一修改,但这些都不是最优解,还容易出错。再看看*们,现在有了​​nacos​​,有了​​apollo​​,这些配置中心都具有远程配置,中心化存储,可监听(实时更新)的能力,我们可以考虑结合这些*去改造spring的i18n实现。