一. 概述
阅读本文之前,你应该了解过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. 总体设计
根据需要,我们会将应用服务的配置存储到配置中心,每次客户端需要获取配置时,应用服务再到配置中心去拉取对应的配置,并最终返回。方案如下:
但是,这种方案存在的问题是,每次获取配置时,应用服务都需要去配置中心中获取配置,每次都需要发送HTTP请求,这在性能上是低效。因此,需要做出改进,改进方案如下:
如上图所示,与最初的方案相比,每次获取配置时,都会先从服务的本地缓存中拿,如果没有,再从配置中心中获取。但是,配置如何实现实时生效呢?这需要再对上述方案进行优化,最终优化方案如下:
<1> 应用服务启动时,从配置中心拉取配置,并存储配置到本地缓存。
<2> 客户端获取配置中,应用服务直接从本地缓存中获取。
<3> 客户端订阅应用服务的配置更新事件。当配置中心有配置更新时,主动推送配置到应用服务,应用服务重新更新本地缓存。
<4> 国际化配置近乎实时生效。
2. 实施步骤
<1> Nacos配置
新增"提示语"的命名空间。
在Nacos上新增应用的国际化配置,命名空间选择"提示语",Data ID为:
user-message.properties,user-message_zh_CN.properties,user-message_en_US.properties。
里面的配置内容为:
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~ 简体中文版:
2~ 美式英文版:
三. 总结
经过上述改造,我们实现了:通过Nacos增强了SpringBoot的国际化。最后,我们做个简单的总结。
1. 配置文件统一通过 Nacos 配置中心管理,实时更新,实时生效。
2. 读取配置文件时,会读取本地缓存文件,提高效率。更新配置后,服务端会推送配置到客户端,客户端更新本地的配置文件。
3. 配置文件其实不是实时生效,这取决于spring.messages.cacheMillis(刷新间隔)。