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接口有三个主要的实现类:

ResourceBundleMessageSourceReloadableResourceBundleMessageSourceStaticMessageSource

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 加载资源,支持 xmlproperties 两个格式,优先加载 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不同点:

  • ResourceBundleMessageSourcecacheMillis只做了一件事,就是粗粒度地控制了是否走缓存流程,并且对于本地静态文件的刷新是每一次都会刷新。
  • ReloadableResourceBundleMessageSourcecacheMillis多了另一个职责-超时刷新静态文件,当不走缓存流程时,会通过比对上次刷新时间和[当前时间-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. 使用messageSourcegetMessage做国际化翻译,其中我们也可以把参数都带进来,这样就能做到参数化的国际化翻译。
  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. 网易云VIP音乐NCM文件转MP3,C语言版本。

    前言 网易云的Vip音乐下载下来,格式不是mp3/flac这种通用的音乐格式,而是经过加密的ncm文件.只有用网易云的音乐App才能够打开.于是想到可不可以把.ncm文件转换成mp3或者flac文件, ...

  2. cmd命令行ssh连接Linux服务器

    打开cmd工具 使用命令ssh连接服务器 ssh 用户名@ip地址 (不需要指定端口号,默认端口就是22) 输入密码即可

  3. 90%的Java开发人员都会犯的5个错误

    前言 作为一名java开发程序员,不知道大家有没有遇到过一些匪夷所思的bug.这些错误通常需要您几个小时才能解决.当你找到它们的时候,你可能会默默地骂自己是个傻瓜.是的,这些可笑的bug基本上都是你忽 ...

  4. 分享自己亲测过的Visualstudio 2019中开发Typescript时,设置自动编译生成js,无需手工运行命令生成的方法。

    步骤1)右键web项目,添加 tsconfig.json文件. 步骤2)确保配置如下,编译版本可自行设置,这里主要关注编译目标目录和自动编译设置: { "compileOnSave" ...

  5. flutter系列之:flutter中listview的高级用法

    目录 简介 ListView的常规用法 创建不同类型的items 总结 简介 一般情况下,我们使用Listview的方式是构建要展示的item,然后将这些item传入ListView的构造函数即可,通 ...

  6. P8340 [AHOI2022] 山河重整

    \(20pts\) 给 \(O(2^n)\) 枚举,\(60pts\) 是 \(O(n^2)\),先看看怎么做.计数题无非容斥和 \(dp\),不妨从 \(dp\) 入手.多项式复杂度的做法意味着无法 ...

  7. word取消保护

    没有 文档的保护密码 可尝试用此方式,亲测有效 Excel.PPT 应该也可以,没试过 1.新建空白文档 2.插入.对象 3.点击[对象]右边的箭头,选择被加密的文件. 建议两个选项都试一下,我的第二 ...

  8. 迁移学习(MixMatch)《MixMatch: A Holistic Approach to Semi-Supervised Learning》

    论文信息 论文标题:MixMatch: A Holistic Approach to Semi-Supervised Learning论文作者:David Berthelot, Nicholas Ca ...

  9. ClickHouse ORM 3.x 发布啦

    经过1年断断续续的迭代,ClickHouse ORM 3.x 发布啦-说说它的故事吧. 回顾下2022 说来惭愧上次写博客都是22年4月份了,今年行情不好团队急剧收缩,工作几乎全扑在接手存量业务和主业 ...

  10. 【Basic Knowledge】Self-Attention Generative Adversarial Networks

    Note   这是一篇将Self-Attention应用到GAN中的paper,Self-Attention模块是卷积模块的补充,能够有助于建模跨图像区域的长范围.多层次依赖关系.文中主要提到4点: ...