从MessageSource源码出发实战spring·i18n国际化的三种改造方案

本文涉及的产品
全局流量管理 GTM,标准版 1个月
云解析 DNS,旗舰版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
简介: 从源码去看MessageSource的几个实现类的源码出发,基于spring的国际化支持,实现国际化的开箱即用,静态文件配置刷新生效以及全局异常国际化处理。

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

目录
相关文章
|
1月前
|
监控 Java 应用服务中间件
高级java面试---spring.factories文件的解析源码API机制
【11月更文挑战第20天】Spring Boot是一个用于快速构建基于Spring框架的应用程序的开源框架。它通过自动配置、起步依赖和内嵌服务器等特性,极大地简化了Spring应用的开发和部署过程。本文将深入探讨Spring Boot的背景历史、业务场景、功能点以及底层原理,并通过Java代码手写模拟Spring Boot的启动过程,特别是spring.factories文件的解析源码API机制。
77 2
|
1月前
|
数据采集 监控 前端开发
二级公立医院绩效考核系统源码,B/S架构,前后端分别基于Spring Boot和Avue框架
医院绩效管理系统通过与HIS系统的无缝对接,实现数据网络化采集、评价结果透明化管理及奖金分配自动化生成。系统涵盖科室和个人绩效考核、医疗质量考核、数据采集、绩效工资核算、收支核算、工作量统计、单项奖惩等功能,提升绩效评估的全面性、准确性和公正性。技术栈采用B/S架构,前后端分别基于Spring Boot和Avue框架。
|
23天前
|
存储 缓存 Java
Spring面试必问:手写Spring IoC 循环依赖底层源码剖析
在Spring框架中,IoC(Inversion of Control,控制反转)是一个核心概念,它允许容器管理对象的生命周期和依赖关系。然而,在实际应用中,我们可能会遇到对象间的循环依赖问题。本文将深入探讨Spring如何解决IoC中的循环依赖问题,并通过手写源码的方式,让你对其底层原理有一个全新的认识。
44 2
|
1月前
|
缓存 Java Spring
实战指南:四种调整 Spring Bean 初始化顺序的方案
本文探讨了如何调整 Spring Boot 中 Bean 的初始化顺序,以满足业务需求。文章通过四种方案进行了详细分析: 1. **方案一 (@Order)**:通过 `@Order` 注解设置 Bean 的初始化顺序,但发现 `@PostConstruct` 会影响顺序。 2. **方案二 (SmartInitializingSingleton)**:在所有单例 Bean 初始化后执行额外的初始化工作,但无法精确控制特定 Bean 的顺序。 3. **方案三 (@DependsOn)**:通过 `@DependsOn` 注解指定 Bean 之间的依赖关系,成功实现顺序控制,但耦合性较高。
实战指南:四种调整 Spring Bean 初始化顺序的方案
|
25天前
|
缓存 NoSQL Java
Spring Boot中的分布式缓存方案
Spring Boot提供了简便的方式来集成和使用分布式缓存。通过Redis和Memcached等缓存方案,可以显著提升应用的性能和扩展性。合理配置和优化缓存策略,可以有效避免常见的缓存问题,保证系统的稳定性和高效运行。
43 3
|
1月前
|
前端开发 Java 开发者
Spring生态学习路径与源码深度探讨
【11月更文挑战第13天】Spring框架作为Java企业级开发中的核心框架,其丰富的生态系统和强大的功能吸引了无数开发者的关注。学习Spring生态不仅仅是掌握Spring Framework本身,更需要深入理解其周边组件和工具,以及源码的底层实现逻辑。本文将从Spring生态的学习路径入手,详细探讨如何系统地学习Spring,并深入解析各个重点的底层实现逻辑。
67 9
|
2月前
|
自然语言处理 Java API
Spring Boot 接入大模型实战:通义千问赋能智能应用快速构建
【10月更文挑战第23天】在人工智能(AI)技术飞速发展的今天,大模型如通义千问(阿里云推出的生成式对话引擎)等已成为推动智能应用创新的重要力量。然而,对于许多开发者而言,如何高效、便捷地接入这些大模型并构建出功能丰富的智能应用仍是一个挑战。
284 6
|
2月前
|
缓存 NoSQL Java
Spring Boot与Redis:整合与实战
【10月更文挑战第15天】本文介绍了如何在Spring Boot项目中整合Redis,通过一个电商商品推荐系统的案例,详细展示了从添加依赖、配置连接信息到创建配置类的具体步骤。实战部分演示了如何利用Redis缓存提高系统响应速度,减少数据库访问压力,从而提升用户体验。
154 2
|
2月前
|
Java Spring
Spring底层架构源码解析(三)
Spring底层架构源码解析(三)
169 5
|
2月前
|
XML Java 数据格式
Spring底层架构源码解析(二)
Spring底层架构源码解析(二)