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. docker部署项目

    @ 目录 前言 一.下载安装docker: 1.前提工作 1.1 查看linux版本 1.2 yum包更新到最新 1.3 安装工具包 1.4 设置yum源并更新yum包索引 2.安装docker 2. ...

  2. ffmpeg库安装及入门指南(Windows篇)- 2022年底钜献

    最近项目需要,使用了 ffmpeg 做摄像头视频采集和串流.这几天有点时间,打算把相关的一些知识记录分享一下. 在撰写本文时,我又在另外一台电脑上把 ffmpeg 重新安装了一遍,所以绝对真实靠谱!如 ...

  3. 《HTTP权威指南》– 10.安全HTTP

    HTTPS的概念 HTTPS 是最流行的HTTP安全模式,由网景公司首创,所有主流浏览器和服务器都支持此协议.HTTPS方案 的URL以 https:// 开头,使用 HTTPS 时,所有的HTTP请 ...

  4. 网易云VIP音乐NCM文件转MP3,C语言版本。

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

  5. PowerDotNet平台化软件架构设计与实现系列(14):平台建设指南

    软件开发中常见的几种不同服务模型包括SaaS(软件即服务).LaaS(许可即服务).PaaS(平台即服务).CaaS(容器即服务).IaaS(基础设施即服务)和FaaS(功能即服务). 很多人认为Ia ...

  6. JavaScript:七大基础数据类型:数值number及其表示范围

    数值number类型,用来表示任何类型的数字:整数或者浮点数都可以: 实际上,JS中的数值,是一个64位的浮点数,这与Java中的double类型的浮点数是一致的: 但是它有表示的范围,在范围内,JS ...

  7. 未授权访问漏洞之Redis漏洞复现

    前言 未授权访问漏洞简写是SSRF(Server-Side Request Forgery:服务器端请求伪造),是一种服务器端提供了可以从其他服务器获取资源和数据的功能,但没有对目标地址进行过滤和限制 ...

  8. Spring+Quartz+Dom4j实现一个小项目

    目录 1.项目背景 2.技术介绍 3.实现代码 4.程序演示 5.打成jar包 1.项目背景 最近在工作中碰到了一个问题,一个叫aura的系统每天都会接收到许多xml,其中有些xml会包含错误信息,这 ...

  9. [WPF]颜色主题功能

    效果 点击选择皮肤颜色 代码 public enum Themes { Blue, Gray, Orange } /// <summary> /// 主题颜色管理类 /// </su ...

  10. python paramiko通过远程操作linux

    python-paramiko通过远程操作linux 1. python-paramiko通过远程操作linux python3 远程操作linux 使用第三方paramiko库,对于实现运维自动部署 ...