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实现。

从源码MessageSource的三个实现出发实战spring·i18n国际化的更多相关文章

  1. 【原】FMDB源码阅读(三)

    [原]FMDB源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 FMDB比较优秀的地方就在于对多线程的处理.所以这一篇主要是研究FMDB的多线程处理的实现.而 ...

  2. 【原】AFNetworking源码阅读(三)

    [原]AFNetworking源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 上一篇的话,主要是讲了如何通过构建一个request来生成一个data tas ...

  3. 【原】SDWebImage源码阅读(三)

    [原]SDWebImage源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1.SDWebImageDownloader中的downloadImageWithURL 我们 ...

  4. Cwinux源码解析(三)

    我在我的 薛途的博客 上发表了新的文章,欢迎各位批评指正. Cwinux源码解析(三)

  5. 一个普通的 Zepto 源码分析(三) - event 模块

    一个普通的 Zepto 源码分析(三) - event 模块 普通的路人,普通地瞧.分析时使用的是目前最新 1.2.0 版本. Zepto 可以由许多模块组成,默认包含的模块有 zepto 核心模块, ...

  6. vueJs 源码解析 (三) 具体代码

    vueJs 源码解析 (三) 具体代码 在之前的文章中提到了 vuejs 源码中的 架构部分,以及 谈论到了 vue 源码三要素 vm.compiler.watcher 这三要素,那么今天我们就从这三 ...

  7. Android源码浅析(三)——Android AOSP 5.1.1源码的同步sync和编译make,搭建Samba服务器进行更便捷的烧录刷机

    Android源码浅析(三)--Android AOSP 5.1.1源码的同步sync和编译make,搭建Samba服务器进行更便捷的烧录刷机 最近比较忙,而且又要维护自己的博客,视频和公众号,也就没 ...

  8. Koa源码分析(三) -- middleware机制的实现

    Abstract 本系列是关于Koa框架的文章,目前关注版本是Koa v1.主要分为以下几个方面: Koa源码分析(一) -- generator Koa源码分析(二) -- co的实现 Koa源码分 ...

  9. Netty 源码 ChannelHandler(三)概述

    Netty 源码 ChannelHandler(三)概述 Netty 系列目录(https://www.cnblogs.com/binarylei/p/10117436.html) 一.Channel ...

  10. Netty 源码 NioEventLoop(三)执行流程

    Netty 源码 NioEventLoop(三)执行流程 Netty 系列目录(https://www.cnblogs.com/binarylei/p/10117436.html) 上文提到在启动 N ...

随机推荐

  1. 新项目决定用 JDK 17了

    大家好,我是风筝,公众号「古时的风筝」,专注于 Java技术 及周边生态. 文章会收录在 JavaNewBee 中,更有 Java 后端知识图谱,从小白到大牛要走的路都在里面. 最近在调研 JDK 1 ...

  2. .NET周报【12月第3期 2022-12-23】

    由于众所周知的原因,大佬们纷纷加入羊群,笔者也未能幸免,体验下来这绝对不是普通感冒的症状,身体不适,熬了几天,所以本周更新比较晚:另外精力有限,对于国际板块只有链接没有简介,十分抱歉! 在这个艰难时刻 ...

  3. IdentityServer4 - v4.x .Net中的实践应用

    认证授权服务的创建 以下内容以密码授权方式为例. 创建模拟访问DB各数据源类 为模拟测试准备的数据源. /// 假设的用户模型 public class TestUser { public strin ...

  4. Kubernetes的垂直和水平扩缩容的性能评估

    Kubernetes的垂直和水平扩缩容的性能评估 译自:Performance evaluation of the autoscaling strategies vertical and horizo ...

  5. [编程基础] Python装饰器入门总结

    Python装饰器教程展示了如何在Python中使用装饰器基本功能. 文章目录 1 使用教程 1.1 Python装饰器简单示例 1.2 带@符号的Python装饰器 1.3 用参数修饰函数 1.4 ...

  6. JS加载层

    花了些时间封装了一个JS类,内置9种图标样式,全局主要样式可自定义. 转载请附上本文链接! 全局配置 { "msg": "文字,默认: "loading...& ...

  7. Git Rebase和Merge的用法

    title: Git Rebase和Merge的用法 categories: 后端 tags: - Git Rebase和Merge是什么? merge和rebase的作用都是合并两个分支,其区别在于 ...

  8. C语言写的 史上最公平的投票系统

    #include<stdio.h> #include<string.h> #define MMM 4 struct student { char name[10]; int c ...

  9. 避免用Apache Beanutils进行属性的copy。why?让我们一起一探究竟

    在实际的项目开发中,对象间赋值普遍存在,随着双十一.秒杀等电商过程愈加复杂,数据量也在不断攀升,效率问题,浮出水面. 问:如果是你来写对象间赋值的代码,你会怎么做? 答:想都不用想,直接代码走起来,g ...

  10. 都用过@Autowired,但你知道它是怎么实现的吗

    前言 在使用Spring开发的时候,配置的方式主要有两种,一种是xml的方式,另外一种是 java config的方式.在使用的过程中java config,我们难免会与注解进行各种打交道,其中,我们 ...