SpringBoot 实战:国际化组件 MessageSource 与 Nacos 组合实现动态配置能力

你好,我是看山。

前面介绍了 Spring 的 MessageSource 组件的用法、执行逻辑和源码,本文我们将根据前面的知识,实现自己的动态刷新的国际化组件。

现在大家都用的是微服务,为了高可用,每个服务部署时最少两个实例。

  • 如果使用ResourceBundleMessageSource实现国际化,每次修改配置文件,都需要重启服务。
  • 如果使用ReloadableResourceBundleMessageSource,我们可以借助多个服务挂在同一个磁盘或同一个卷读取同一个配置文件,借助远程工具,修改文件实现动态配置内容的修改。但是这就涉及到文本编辑,不能很好的实现审计记录和文件管控。

所以,我们要实现一个适用于微服务架构、方便修改、具备审计功能的动态的国际化配置组件。本文选择 Nacos 实现,Nacos 有配置中心的能力,适合在微服务架构中使用,同时也具备方便修改和审计能力,只要我们实现从 Nacos 加载国际化配置的能力,就可以轻松实现目标。

几个关键点

Spring 提供的默认实现中,ReloadableResourceBundleMessageSource实现了动态刷新的能力,只不过是从文件读取内容,我们可以借助ReloadableResourceBundleMessageSource的逻辑实现,只是将其改为从 Nacos 读取内容。

这个实现,通常可以有两种方案(假设我们新实现的类命名为NacosBundleMessageSource):

  1. 继承ReloadableResourceBundleMessageSource:重写读取配置的方法,然后通过 Spring 注入新的方法。这种方式有优点和缺点:
    1. 优点 1:我们可以保持与ReloadableResourceBundleMessageSource相似的结构和执行逻辑,当 Spring 进行升级的时候,我们直接通过继承获取了能力;
    2. 优点 2:我们只需要覆盖几个关键方法,需要重写的方法比较少;
    3. 缺点 1:当我们期望在系统中引入多种类型的 MessageSource 组件时,就不能简单的通过类型加载了。比如applicationContext.getBean(ReloadableResourceBundleMessageSource.class)会找到ReloadableResourceBundleMessageSourceNacosBundleMessageSource两个 Bean,Spring 容器就不知道该返回哪个了;
    4. 缺点 2:虽然继承能够少写代码,但是一旦 Spring 修改了执行逻辑,我们的NacosBundleMessageSource就可能需要重写。
  2. 模仿ReloadableResourceBundleMessageSource:完全实现自己的一个动态加载类。与第一种的优缺点正好相反:
    1. 优点 1:完全不同的类,Bean 对应的 class 类型不同,applicationContext.getBean可以通过 class 类型获取;
    2. 优点 2:与ReloadableResourceBundleMessageSource的类定义没有关系,除非 Spring 修改底层逻辑,否则不会因为ReloadableResourceBundleMessageSource的变动出现不兼容的情况;
    3. 缺点 1:当 Spring 对ReloadableResourceBundleMessageSource进行升级,提出更加优化的写法,我们就需要重写NacosBundleMessageSource了;
    4. 缺点 2:既然是仿写,很多方法都是与ReloadableResourceBundleMessageSource完全相同的重复代码。

考虑到两种方案的优缺点,结合业务中的逻辑,最终选择方案二。

文件名

我们要实现的国际化组件,在 Spring 的实现中,使用的是Locale表示指定的区域,在这个类中,定义了三个不同的维度languagecountryvariant,翻译过来是语言国家变种,结合BaseLocale的定义,可以将country看做是语言大类。

根据前面的介绍,我们的国际化配置文件定义格式是根据Locale格式定义的。比如,basename 是 messages,Locale 是 de_AT_oo 的话,对应的配置文件可以是“messages_de_AT_OO”、“messages_de_AT”、“messages_de”。

所以我们需要用到递归的方式获取文件名:

protected List<String> calculateFilenamesForLocale(String basename, Locale locale) {
    List<String> result = new ArrayList<>(3);
    String language = locale.getLanguage();
    String country = locale.getCountry();
    String variant = locale.getVariant();
    StringBuilder temp = new StringBuilder(basename);

    temp.append('_');
    if (language.length() > 0) {
        temp.append(language);
        result.add(0, temp.toString());
    }

    temp.append('_');
    if (country.length() > 0) {
        temp.append(country);
        result.add(0, temp.toString());
    }

    if (variant.length() > 0 && (language.length() > 0 || country.length() > 0)) {
        temp.append('_').append(variant);
        result.add(0, temp.toString());
    }

    return result;
}

加载方式

既然是从 Nacos 读取配置,那配置文件的内容就需要通过 Nacos 获取。这里使用了NacosConfigManager获取:

protected Properties loadProperties(String filename) throws IOException, NacosException {
    final Properties props = newProperties();
    final String dataId = filename + NacosConstants.PROPERTIES_SUFFIX;
    logger.info("Loading properties for " + dataId);
    final String config = nacosConfigManager.getConfigService().getConfig(dataId, nacosGroup, 5000);
    if (StringUtils.hasText(config)) {
        logger.info("No properties found for " + dataId);
        throw new NoSuchFileException(dataId);
    }

    try (Reader reader = new StringReader(config)) {
        this.propertiesPersister.load(props, reader);
        logger.info("Loaded properties for " + dataId);
    }
    return props;
}

方法传入的filename就是从上一节中获取的文件名。当然,文件名是我们计算出来的,可能不存在,此处直接抛出NoSuchFileException,由上层逻辑捕捉处理:

protected PropertiesHolder refreshProperties(String filename, @Nullable PropertiesHolder propHolder) {
    long refreshTimestamp = (getCacheMillis() < 0 ? -1 : System.currentTimeMillis());

    try {
        Properties props = loadProperties(filename);
        propHolder = new PropertiesHolder(props, -1);
    } catch (NacosException ex) {
        if (logger.isWarnEnabled()) {
            logger.warn("Could not get properties form nacos ", ex);
        }
        // Empty holder representing "not valid".
        propHolder = new PropertiesHolder();
    } catch (IOException ex) {
        if (logger.isInfoEnabled()) {
            logger.info("Could not get properties form nacos, the message is " + ex.getMessage());
        }
        // Empty holder representing "not valid".
        propHolder = new PropertiesHolder();
    }

    propHolder.setRefreshTimestamp(refreshTimestamp);
    this.cachedProperties.put(filename, propHolder);
    logger.info("Refreshed properties for " + filename);
    return propHolder;
}

我们可以看到,从 Nacos 中读取配置逻辑的上层会捕捉Exception,然后创建一个空的配置管理器。此处会有两个异常:

  • NacosException:读取 Nacos 失败抛出的异常,包括 Nacos 链接不正确或者是读取失败等;
  • IOException:没有指定文件名的配置时会抛出 IOException。

动态刷新

前一节讲了加载 Nacos 配置文件的方式,本节说一下怎么实现 Nacos 配置文件的监听,动态刷新配置内容。

我们先介绍一下实现 Nacos 监听的几个类:

  • NacosRefreshHistory:nacos 配置文件刷新的历史记录,会保存 Nacos 配置文件的刷新历史,最多存储 20 个;
  • ConfigService:一般使用的时候是 NacosConfigService 类,这个类是使用NacosConfigManager#getConfigService通过反射创建的,用于存储 Nacos 监听器,实现监听逻辑;
  • Listener:这个就是具体的监听器了,在我们的例子中,使用的是AbstractSharedListener的匿名子类,实现动态刷新的逻辑就在这个类里。

首先,我们当前组件是 MessageSource,用于实现国际化的组件。这个组件是在整个应用就绪后再加载就行,所以,我们监听 Spring 的ApplicationReadyEvent事件即可。

@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
    // many Spring context
    if (this.ready.compareAndSet(false, true)) {
        for (Locale defaultLocale : this.nacosBundleMessageSource.defaultLocales()) {
            this.nacosBundleMessageSource.getMergedProperties(defaultLocale);
        }
        this.registerNacosListenersForApplications();
    }
}

然后是我们需要对所有可能的配置文件进行监听,就需要用到前文的calculateFilenamesForLocale方法,计算所有可能出现的名字。

private void registerNacosListenersForApplications() {
    if (!isRefreshEnabled()) {
        return;
    }

    this.nacosBundleMessageSource.getBasenameSet().stream()
            .map(basename -> this.nacosBundleMessageSource.defaultLocales().stream()
                    .map(locale -> this.nacosBundleMessageSource.calculateAllFilenames(basename, locale))
                    .flatMap(List::stream)
                    .collect(Collectors.toList())
            )
            .flatMap(List::stream)
            .forEach(x -> registerNacosListener(nacosBundleMessageSource.getNacosGroup(), x + NacosConstants.PROPERTIES_SUFFIX));
}

最后,我们需要向 Nacos 的运行管理器中注册文件监听,实现动态刷新的能力。

private void registerNacosListener(final String groupKey, final String dataKey) {
    final String key = NacosPropertySourceRepository.getMapKey(dataKey, groupKey);
    final Listener listener = listenerMap.computeIfAbsent(key, lst -> new AbstractSharedListener() {
        @Override
        public void innerReceive(String dataId, String group, String configInfo) {
            refreshCountIncrement();
            nacosRefreshHistory.addRefreshRecord(dataId, group, configInfo);
            try {
                nacosBundleMessageSource.forceRefresh(dataId, configInfo);
                if (log.isDebugEnabled()) {
                    log.debug("Refresh Nacos config group={},dataId={},configInfo={}", group, dataId, configInfo);
                }
            } catch (IOException e) {
                log.warn("Nacos refresh failed, dataId: {}, group: {}, configInfo: {}",
                        dataId, group, configInfo, e);
            }
        }
    });
    try {
        configService.addListener(dataKey, groupKey, listener);
    } catch (NacosException e) {
        log.warn("register fail for nacos listener ,dataId=[{}],group=[{}]", dataKey, groupKey, e);
    }
}

至此,我们获取了国际化组件 MessageSource 与 Nacos 组合实现动态配置能力。本文中的实例已经传到 GitHub,关注公众号「看山的小屋」,回复spring获取源码。

发散一下

接下来,我们再重头想一下,我们最初想要实现一个适用于微服务架构、方便修改、具备审计功能的动态的国际化配置组件。选择 Nacos 作为例子,是因为 Nacos 本身实现了动态监听的能力,可以快速复刻ReloadableResourceBundleMessageSource的能力。那 Nacos 具备什么能力呢?

  1. 中心化存储:因为我们想在微服务架构中使用,就不能采用与独立服务耦合的方式,需要所有服务可以读取的方式。比如数据库(包括关系型数据库、非关系型数据库等)、分布式缓存、远程磁盘(或者 Docker 的卷)等;
  2. 可监听:任何配置的修改能够被服务感知,想要做到这个,就是在配置发生修改时,发送一个修改时间,通知监听服务配置被修改。可以采用被动和主动两种方式:
    1. 被动方式:这个比较简单,只要将组件中的缓存逻辑删除就可以了,每次查询配置都直接存储器中读取,但是这又与性能相悖,一般不采用这个方案;
    2. 主动方式:通过发送事件或者消息的方式,比如采用 CQRS 模式,发生修改时,发送一条消息,各个微服务监听这个消息,重新加载配置;
  3. 可审计:这个能力简单说就是要记录每次修改的时间、人物、动作等信息,这个功能是常用功能,这里就不赘述了。

只要具备上面三个特性,我们可以通过各种组合实现,比如:

  1. 可挂在磁盘
  2. Git+Hook
  3. MySQL+Redis+MQ
  4. ……

文末总结

本文从实践角度出发,实现了一个适用于微服务架构、方便修改、具备审计功能的动态的国际化配置组件。文中的实例已经传到 GitHub,关注公众号「看山的小屋」,回复spring获取源码。如果是你,你会采用哪种方案呢?欢迎一起讨论。

青山不改,绿水长流,我们下次见。

推荐阅读


你好,我是看山。游于码界,戏享人生。如果文章对您有帮助,请点赞、收藏、关注。我还整理了一些精品学习资料,关注公众号「看山的小屋」,回复“资料”即可获得。

个人主页:https://www.howardliu.cn
个人博文:SpringBoot 实战:国际化组件 MessageSource 与 Nacos 组合实现动态配置能力
CSDN 主页:https://kanshan.blog.csdn.net/
CSDN 博文:SpringBoot 实战:国际化组件 MessageSource 与 Nacos 组合实现动态配置能力

👇🏻欢迎关注我的公众号「看山的小屋」,领取精选资料👇🏻

公众号:看山的小屋