一.  概述

阅读本文之前,你应该了解过SpringBoot的国际化实现与原理,在这里简单介绍下:

1. 国际化

国际化(internationalization),又称为i18n(因为这个单词从i到n有18个英文字母,因此命名)。对于某些应用系统而言,它需要发布到不同的国家地区,因此需要特殊的做法来支持,也即是国际化。通过国际化的方式,实现界面信息,各种提示信息等内容根据不同国家地区灵活展示的效果。比如在中国,系统以简体中文进行展示,在美国则以美式英文进行展示。如果使用传统的硬编码方式,是无法做到国际化支持的。

所以通俗来讲,国际化就是为每种语言配置一套单独的资源文件,保存在项目中,由系统根据客户端需要选择合适的资源文件。

2. SpringBoot对国际化的支持

SpringBoot默认提供了国际化的支持,它通过自动配置类MessageSourceAutoConfiguration实现。

org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration

这个类注册了MessageSource,用来获取国际化配置。

@Bean
	public MessageSource messageSource() {
		MessageSourceProperties properties = messageSourceProperties();
		ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
		if (StringUtils.hasText(properties.getBasename())) {
			messageSource.setBasenames(StringUtils.commaDelimitedListToStringArray(
					StringUtils.trimAllWhitespace(properties.getBasename())));
		}
		if (properties.getEncoding() != null) {
			messageSource.setDefaultEncoding(properties.getEncoding().name());
		}
		messageSource.setFallbackToSystemLocale(properties.isFallbackToSystemLocale());
		Duration cacheDuration = properties.getCacheDuration();
		if (cacheDuration != null) {
			messageSource.setCacheMillis(cacheDuration.toMillis());
		}
		messageSource.setAlwaysUseMessageFormat(properties.isAlwaysUseMessageFormat());
		messageSource.setUseCodeAsDefaultMessage(properties.isUseCodeAsDefaultMessage());
		return messageSource;
	}

在SpringBoot代码中实现原生国际化配置仅需要以下三步:

<1> 指定国际化资源路径

通过application.properties指定:

spring.messages.basename=classpath:i18n/messages

其中,i18n表示resources路径上的一个文件夹,messages就是这个文件夹下的资源文件名,例如:messages.properties、messages_zh_CN.properties、messages_en_US.properties 等。

<2> 注入国际化Resolver对象

通过指定LocaleResolver对象,实现国际化策略。

@Bean
    public LocaleResolver localeResolver() {
        SessionLocaleResolver sessionLocaleResolver = new SessionLocaleResolver();
        sessionLocaleResolver.setDefaultLocale(Locale.CHINA);
        return sessionLocaleResolver;
    }

<3> 使用

在resources目录下建立i18n文件夹,文件夹中建立:messages.properties、messages_zh_CN.properties、messages_en_US.properties 三个文件,添加需要的配置即可。

同时,在需要使用的Bean中注入MessageSource对象,通过getMessage方法使用

String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;

3. SpringBoot 国际化存在的问题与不足

通过以上三个步骤,我们实现了SpringBoot的原生国际化。但是在使用过程中,我们也发现了一些不足,这主要有:

<1> 配置是存储在jar包中的,不够灵活。

<2> 绝大部分公司系统都做了微服务化,应用太多,希望有一个地方可以统一管理配置。

<3> 希望配置读取是高效的,及时的。

<4> 国际化的工作,前后端都不应该过多参与,尽量在框架层面解决,而不是应用层传输国际化参数来解决。

4. SpringBoot 国际化增强的目标

在实际工作中,我们应该且有必要对国际化做进一步的增强,让它更能满足要求。基于上述的问题,我们做了一些改进,最终达到的效果如下:

1. 配置中心存储应用的国际化配置,配置支持动态刷新,实时生效。

2. 实现高效的配置读取。

3. 简化前后端的工作量。

因此,本文的后半部分将介绍如何通过Nacos实现SpringBoot国际化的增强。

二.  实施过程

1. 总体设计

根据需要,我们会将应用服务的配置存储到配置中心,每次客户端需要获取配置时,应用服务再到配置中心去拉取对应的配置,并最终返回。方案如下:

spring nacos 配置 解密 nacos配置springboot_spring nacos 配置 解密

但是,这种方案存在的问题是,每次获取配置时,应用服务都需要去配置中心中获取配置,每次都需要发送HTTP请求,这在性能上是低效。因此,需要做出改进,改进方案如下:

spring nacos 配置 解密 nacos配置springboot_java_02

如上图所示,与最初的方案相比,每次获取配置时,都会先从服务的本地缓存中拿,如果没有,再从配置中心中获取。但是,配置如何实现实时生效呢?这需要再对上述方案进行优化,最终优化方案如下:

<1> 应用服务启动时,从配置中心拉取配置,并存储配置到本地缓存。

<2> 客户端获取配置中,应用服务直接从本地缓存中获取。

<3> 客户端订阅应用服务的配置更新事件。当配置中心有配置更新时,主动推送配置到应用服务,应用服务重新更新本地缓存。

<4> 国际化配置近乎实时生效。

2. 实施步骤

<1> Nacos配置

新增"提示语"的命名空间。

spring nacos 配置 解密 nacos配置springboot_Source_03

在Nacos上新增应用的国际化配置,命名空间选择"提示语",Data ID为:

user-message.properties,user-message_zh_CN.properties,user-message_en_US.properties。

spring nacos 配置 解密 nacos配置springboot_spring_04

里面的配置内容为:

user-message.properties:test=测试

user-message_zh_CN.properties:test=测试

user-message_en_US.properties:test=test

<2> 新增国际化配置

在application.yaml中新增以下国际化配置:

spring:  
  messages:
    baseFolder: i18n/
    basename: ${spring.application.name}-message
    encoding: UTF-8
    cacheMillis: 10000

其中,各字段含义如下:

spring.messages.baseFolder:指定国际化配置存放的本地路径(在程序的当前路径下的路径)
spring.messages.basename:国际化配置名称
spring.messages.encoding:编码格式
spring.messages.cacheMillis:国际化配置刷新的时间间隔

<3> 代码改造

1~ 国际化解析器

新增自定义国际化解析器DefaultLocaleResolver,用于解析请求头的国际化信息。代码如下:

import org.apache.commons.lang3.StringUtils;
import org.springframework.web.servlet.LocaleResolver;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Locale;

/**
 * 自定义国际化解析器
 * @author zz
 * @date 2020/2/28 15:37
 **/
public class DefaultLocaleResolver implements LocaleResolver {

    @Override
    public Locale resolveLocale(HttpServletRequest request) {
        String lang = request.getHeader(LANG);
        Locale locale = Locale.getDefault();
        if (StringUtils.isNotBlank(lang)){
            String[] language = lang.split("_");
            locale = new Locale(language[0], language[1]);

            HttpSession session = request.getSession();
            session.setAttribute(LANG_SESSION, locale);
        }else{
            HttpSession session = request.getSession();
            Locale localeInSession = (Locale) session.getAttribute(LANG_SESSION);
            if (localeInSession != null){
                locale = localeInSession;
            }
        }
        return locale;
    }

    @Override
    public void setLocale(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Locale locale) {
    }

    /**
     * 请求header字段
     */
    private static final String LANG = "lang";

    /**
     * session
     */
    private static final String LANG_SESSION = "lang_session";

}

2~ 新增国际化配置

读取配置文件中的国际化配置,代码如下:

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

/**
 * 国际化配置
 * @author zz
 * @date 2020/2/28 15:38
 **/
@Data
@RefreshScope
@Component
@ConfigurationProperties(prefix = "spring.messages")
public class MessageConfig {

    /**
     * 国际化文件目录
     */
    private String baseFolder;

    /**
     * 国际化文件名称
     */
    private String basename;

    /**
     * 国际化编码
     */
    private String encoding;

    /**
     * 缓存刷新时间
     */
    private long cacheMillis;

}

3~ 注册国际化解析器,配置消息资源管理器

新增Spring配置,注册自定义国际化解析器DefaultLocaleResolver,同时注册ReloadableResourceBundleMessageSource用于实时获取国际化配置。

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Primary;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.util.ResourceUtils;
import org.springframework.web.servlet.LocaleResolver;
import java.io.File;

/**
 * Spring配置
 * @author zz
 * @date 2020/2/28 15:50
 **/
@Slf4j
@Configuration
public class SpringConfig {

    @Bean
    public LocaleResolver localeResolver(){
        return new DefaultLocaleResolver();
    }

    @Primary
    @Bean(name = "messageSource")
    @DependsOn(value = "messageConfig")
    public ReloadableResourceBundleMessageSource messageSource() {
        String path = ResourceUtils.FILE_URL_PREFIX + System.getProperty("user.dir") + File.separator + messageConfig.getBaseFolder() + File.separator + messageConfig.getBasename();
        log.info("国际化配置内容:{}", messageConfig);
        log.info("国际化配置路径:{}", path);
        ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
        messageSource.setBasename(path);
        messageSource.setDefaultEncoding(messageConfig.getEncoding());
        messageSource.setCacheMillis(messageConfig.getCacheMillis());
        return messageSource;
    }

    /**
     * 应用名称
     */
    @Value("${spring.application.name}")
    private String applicationName;

    @Autowired
    private MessageConfig messageConfig;

}

注:这里简单介绍下ReloadableResourceBundleMessageSource。ReloadableResourceBundleMessageSource是AbstractResourceBasedMessageSource的两个实现类之一,它提供强大的定时刷新配置文件的功能,支持应用在不重启的情况下重载配置文件,保证应用的长期稳定运行。所以,我们通过它来实现国际化信息的动态更新。

4~ 新增Nacos配置管理器

新增Nacos配置管理器,该类主要做两个工作:配置拉取,配置更新。

import com.alibaba.nacos.api.NacosFactory;
import com.alibaba.nacos.api.PropertyKeyConst;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.config.listener.Listener;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.stereotype.Component;
import java.io.File;
import java.io.IOException;
import java.util.Locale;
import java.util.Properties;
import java.util.concurrent.Executor;

/**
 * Nacos配置管理器
 * @author zz
 * @date 2020/2/28 16:30
 **/
@Slf4j
@Component
public class NacosConfig {


    @Autowired
    public void init() {
        serverAddr = applicationContext.getEnvironment().getProperty("spring.cloud.nacos.config.server-addr");
        dNamespace = applicationContext.getEnvironment().getProperty("spring.cloud.nacos.config.dNamespace");
        if (StringUtils.isEmpty(dNamespace)) {
            dNamespace = DEFAULT_NAMESPACE;
        }
        initTip(null);
        initTip(Locale.CHINA);
        initTip(Locale.US);
        log.info("初始化系统参数成功!应用名称:{},Nacos地址:{},提示语命名空间:{}", applicationName, serverAddr, dNamespace);
    }

    private void initTip(Locale locale) {
        String content = null;
        String dataId = null;
        ConfigService configService = null;
        try {
            if (locale == null) {
                dataId = messageConfig.getBasename() + ".properties";
            } else {
                dataId = messageConfig.getBasename() + "_" + locale.getLanguage() + "_" + locale.getCountry() + ".properties";
            }
            Properties properties = new Properties();
            properties.put(PropertyKeyConst.SERVER_ADDR, serverAddr);
            properties.put(PropertyKeyConst.NAMESPACE, dNamespace);
            configService = NacosFactory.createConfigService(properties);
            content = configService.getConfig(dataId, DEFAULT_GROUP, 5000);
            if (StringUtils.isEmpty(content)) {
                log.warn("配置内容为空,跳过初始化!dataId:{}", dataId);
                return;
            }
            log.info("初始化国际化配置!配置内容:{}", content);
            saveAsFileWriter(dataId, content);
            setListener(configService, dataId, locale);
        } catch (Exception e) {
            log.error("初始化国际化配置异常!异常信息:{}", e);
        }
    }

    private void setListener(ConfigService configService, String dataId, Locale locale) throws com.alibaba.nacos.api.exception.NacosException {
        configService.addListener(dataId, DEFAULT_GROUP, new Listener() {
            @Override
            public void receiveConfigInfo(String configInfo) {
                log.info("接收到新的国际化配置!配置内容:{}", configInfo);
                try {
                    initTip(locale);
                } catch (Exception e) {
                    log.error("初始化国际化配置异常!异常信息:{}", e);
                }
            }

            @Override
            public Executor getExecutor() {
                return null;
            }
        });
    }

    private void saveAsFileWriter(String fileName, String content) {
        String path = System.getProperty("user.dir") + File.separator + messageConfig.getBaseFolder();
        try {
            fileName = path + File.separator + fileName;
            File file = new File(fileName);
            FileUtils.writeStringToFile(file, content);
            log.info("国际化配置已更新!本地文件路径:{}", fileName);
        } catch (IOException e) {
            log.error("初始化国际化配置异常!本地文件路径:{}异常信息:{}", fileName, e);
        }
    }

    /**
     * 应用名称
     */
    @Value("${spring.application.name}")
    private String applicationName;
    /**
     * 命名空间
     */
    private String dNamespace;
    /**
     * 服务器地址
     */
    private String serverAddr;

    @Autowired
    private MessageConfig messageConfig;

    @Autowired
    private ConfigurableApplicationContext applicationContext;

    private static final String DEFAULT_GROUP = "DEFAULT_GROUP";

    private static final String DEFAULT_NAMESPACE = "515667c5-0450-4d1f-b14f-6f243079b6fb";

}

在该类中,主要做了几个事情:

第一,应用启动时,通过init方法初始化配置国际化配置。

第二,在init方法中,会拉取Nacos服务端配置并写入到本地缓存,同时注册一个监听器,实时监听配置的变化并及时更新本地配缓存。

最后,读取的命名空间通过spring.cloud.nacos.config.dNamespace指定,如果没有取到,则取默认值。

5~ 新增国际化配置获取工具类

通过注入MessageSource,通过getMessage方法获取对应的国际化配置。

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
import java.util.Locale;

/**
 * 国际化配置获取工具类
 * @author zz
 * @date 2020/2/28 17:00
 **/
@Slf4j
@Component
public class PropertiesTools {

    public String getProperties(String name) {
        try {
            Locale locale = LocaleContextHolder.getLocale();
            return messageSource.getMessage(name, null, locale);
        } catch (NoSuchMessageException e) {
            log.error("获取配置异常!异常信息:{}", e);
        }
        return null;
    }

    @Autowired
    private MessageSource messageSource;

}

6~ 使用

通过注入PropertiesTools,调用getProperties获取国际化配置。

import com.demo.util.PropertiesTools;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * 样例
 * @author zz
 * @date 2020/2/28 17:30
 **/
@RestController
public class DemoController {

    /**
     * 获取国际化配置
     * @param name 配置名称
     * @return String
     */
    @PostMapping(value = "/getProperties")
    public String getProperties (String name) {
        return propertiesTools.getProperties(name);
    }

    @Autowired
    private PropertiesTools propertiesTools;

}

<3> 验证

利用postman进行验证,结果如下:

1~ 简体中文版:

spring nacos 配置 解密 nacos配置springboot_java_05

2~ 美式英文版:

spring nacos 配置 解密 nacos配置springboot_spring nacos 配置 解密_06

三. 总结

经过上述改造,我们实现了:通过Nacos增强了SpringBoot的国际化。最后,我们做个简单的总结。

1. 配置文件统一通过 Nacos 配置中心管理,实时更新,实时生效。

2. 读取配置文件时,会读取本地缓存文件,提高效率。更新配置后,服务端会推送配置到客户端,客户端更新本地的配置文件。

3. 配置文件其实不是实时生效,这取决于spring.messages.cacheMillis(刷新间隔)。