从源码MessageSource的三个实现出发实战spring·i18n国际化
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
达成国际化转换呢?只需要稍稍改造就能完成。
- 全局异常处理类中注入
messageSource
- 业务异常处理方法新增
Locale
参数,他是国际化转换的路由因子。 - 使用
messageSource
的getMessage
做国际化翻译,其中我们也可以把参数都带进来,这样就能做到参数化的国际化翻译。 - 最后就是吐出去,给亲爱的用户了。
@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国际化的更多相关文章
- 【原】FMDB源码阅读(三)
[原]FMDB源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 FMDB比较优秀的地方就在于对多线程的处理.所以这一篇主要是研究FMDB的多线程处理的实现.而 ...
- 【原】AFNetworking源码阅读(三)
[原]AFNetworking源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 上一篇的话,主要是讲了如何通过构建一个request来生成一个data tas ...
- 【原】SDWebImage源码阅读(三)
[原]SDWebImage源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1.SDWebImageDownloader中的downloadImageWithURL 我们 ...
- Cwinux源码解析(三)
我在我的 薛途的博客 上发表了新的文章,欢迎各位批评指正. Cwinux源码解析(三)
- 一个普通的 Zepto 源码分析(三) - event 模块
一个普通的 Zepto 源码分析(三) - event 模块 普通的路人,普通地瞧.分析时使用的是目前最新 1.2.0 版本. Zepto 可以由许多模块组成,默认包含的模块有 zepto 核心模块, ...
- vueJs 源码解析 (三) 具体代码
vueJs 源码解析 (三) 具体代码 在之前的文章中提到了 vuejs 源码中的 架构部分,以及 谈论到了 vue 源码三要素 vm.compiler.watcher 这三要素,那么今天我们就从这三 ...
- Android源码浅析(三)——Android AOSP 5.1.1源码的同步sync和编译make,搭建Samba服务器进行更便捷的烧录刷机
Android源码浅析(三)--Android AOSP 5.1.1源码的同步sync和编译make,搭建Samba服务器进行更便捷的烧录刷机 最近比较忙,而且又要维护自己的博客,视频和公众号,也就没 ...
- Koa源码分析(三) -- middleware机制的实现
Abstract 本系列是关于Koa框架的文章,目前关注版本是Koa v1.主要分为以下几个方面: Koa源码分析(一) -- generator Koa源码分析(二) -- co的实现 Koa源码分 ...
- Netty 源码 ChannelHandler(三)概述
Netty 源码 ChannelHandler(三)概述 Netty 系列目录(https://www.cnblogs.com/binarylei/p/10117436.html) 一.Channel ...
- Netty 源码 NioEventLoop(三)执行流程
Netty 源码 NioEventLoop(三)执行流程 Netty 系列目录(https://www.cnblogs.com/binarylei/p/10117436.html) 上文提到在启动 N ...
随机推荐
- GitHub上的一个Latex模板
代码下载:GitHub的项目地址或者在LATEX项目报告模板下载. 编译环境:Latex的编译器,如Ctex软件. 把源码clone或者下载到本地后,根据他的说明 如何开始 使用report.tex开 ...
- SQL Server登录初次提示状态码233,再次登录提示状态码18456
解决方案: 1.使用windows方式登录数据库,修改安全性属性为SQL Server 和Windows身份验证模式 2.打开SQL Server配置管理器,启动MSSQLSERVER协议 3.修改s ...
- python之路47 django路由层配置 虚拟环境
可视化界面之数据增删改查 针对数据对象主键字段的获取可以使用更加方便的obj.pk获取 在模型类中定义双下str方法可以在数据对象被执行打印操作的时候方便的查看 ''' form表单中能够触发提交动作 ...
- SICTF2023 web_wp
兔年大吉 源码如下 <?php highlight_file(__FILE__); error_reporting(0); class Happy{ private $cmd; private ...
- 将IoTdb注册为Windows服务
昨天写的文章<Windows Server上部署IoTdb 集群>,Windows下的InfluxDB是控制台程序,打开窗口后,很容易被别人给关掉,因此考虑做成Windows服务,nssm ...
- Java基础学习笔记-数据类型、数制
数据类型,跟JS感觉差异不是很大,但是有个String不是很一样的样子 数据类型分为 基本数据类型和复合数据类型 基本数据类型分为下面三种 数值类型 1.整数类型:byte,short,int,lon ...
- 双层拖拽事件,用鼠标画矩形,拖动右下角可以再次改变矩形大小,方案一 有BUG
<template> <div class="mycanvas-container"> <vue-drag-resize :isActive = 't ...
- Angular在用户登录后设置授权请求头headers.append('Authorization', 'token');
方案1. 使用Angular http import {Injectable} from '@angular/core'; import {Http, Headers} from '@angular ...
- Java 入门与进阶P-7.1+P-7.2
函数的定义 函数的定义 习惯把函数也叫成方法,都是一个意思:函数是具备特定功能的一段代码块,解决了重复性代码的问题. 为什么要定函数呢? 目的是为了提高程序的复用性和可读性. 函数的格式 修饰符返回值 ...
- 数学工具类Math-练习
数学工具类Math 概述 java.lang.Math 类包含用于执行基本数学运算的方法,如初等指数.对数.平方根和三角函数.类似这样的工具 类,其所有方法均为静态方法,并且不会创建对象,调用起来非常 ...