Springboot基于Nacos的动态国际化
背景
公司项目需求将国际化配置放入Nacos配置中心,以实现动态修改更新国际化配置而避免后端服务发版
面向搜索引擎
现成的实现
基于文件下载的nacos国际化
这个是将nacos的配置通过http接口读取到内存,再写到磁盘上,通过ReloadableResourceBundleMessageSource类定时检查资源文件是否更新来重新读取国际化配置。此方法直接利用Spring框架已经提供的轮子来实现功能,但是感觉缺点也很明显:
- 已经通过http调用读取配置数据到了内存,还要经过IO操作落到磁盘上,再读取磁盘上的文件到内存更新国际化配置数据,为何不直接修改?
- 项目部署时会打包,更新配置信息落到磁盘还要修改jar包
- 如果将国际化配置文件放到项目外,作为外部资源文件,那么我们为何还需要nacos,直接更新了就上传新的配置文件覆盖原来的就行了,且现在都容器化部署,外部文件极为不便
因此,此方案被放弃
Spring国际化的实现
Spring的国际化基于MessageSource接口,此接口为最顶层的接口
其主要有两个实现类:
- ResourceBundleMessageSource
- ReloadableResourceBundleMessageSource
ResourceBundleMessageSource是Springboot国际化自动装配类MessageSourceAutoConfiguration的默认的实现类,其就是读取国际化properties文件的配置缓存国际化数据。
ReloadableResourceBundleMessageSource是可重加载国际化配置文件的实现,其中对于国际化配置有两级缓存,分别是文件名-国际化配置数据缓存、时区-对应的配置缓存。
思路
由于这两种实现都依赖于读取文件,与我们读取nacos配置并动态更新的需求不符合,所以只能实现自己的MessageSource接口实现类了,又因为读取nacos配置是网络请求,肯定不能每次国际化翻译都进行请求,则本地缓存、增量更新尤为重要。因此我们可以基本结构沿用ReloadableResourceBundleMessageSource的实现,只修改生成Properties对象的方法,换成请求nacos服务获取配置信息来组装,这样还可直接沿用其缓存机制。
读取配置
无缓存时请求nacos获取配置信息,组装为Properties对象返回
有缓存时直接读取本地缓存(沿用ReloadableResourceBundleMessageSource的实现)
// 成员变量,在创建对象时设置进来
private NacosConfigManager nacosConfigManager;
private String nacosGroup;
// 查询配置数据,组装为Properties对象
protected Properties loadProperties(String fileName) throws IOException, NacosException {
// 从nacos中读取配置
Properties props = newProperties();
// nacos查询配置信息是使用文件名,而沿用ReloadableResourceBundleMessageSource获取的fileName仅为前缀,故此处拼接了一个后缀
String config = nacosConfigManager.getConfigService().getConfig(fileName + ".properties", nacosGroup, 5000);
ByteArrayInputStream inputStream = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8));
this.propertiesPersister.load(props, inputStream);
return props;
}
增量更新
由于本地缓存是放入ConcurrentMap,所以我们更新缓存就直接操作对应的Map,替换或清空对应key的value即可
// Cache to hold already loaded properties per filename
private final ConcurrentMap<String, NacosBundleMessageSource.PropertiesHolder> cachedProperties = new ConcurrentHashMap<>();
// Cache to hold already loaded properties per filename
private final ConcurrentMap<Locale, NacosBundleMessageSource.PropertiesHolder> cachedMergedProperties = new ConcurrentHashMap<>();
/**
* 强制刷新
*
* @param fileName 文件名
* @param config 变更后的配置内容
*/
public void forceRefresh(String fileName, String config) throws IOException {
/*
此处只是简单的实现功能,对于this.cachedMergedProperties仅仅简单粗暴的全部清空,细化的话可以考虑仅清空对应时区的数据
*/
synchronized (this) {
Properties props = newProperties();
ByteArrayInputStream inputStream = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8));
this.propertiesPersister.load(props, inputStream);
// 此参数好像是ReloadableResourceBundleMessageSource用来判断是否重新读取资源配置信息,我们通过主动读取nacos和等待nacos通知变化来更新数据,所以可以不用ReloadableResourceBundleMessageSource的重新读取功能
long fileTimestamp = -1;
NacosBundleMessageSource.PropertiesHolder propHolder = new NacosBundleMessageSource.PropertiesHolder(props, fileTimestamp);
this.cachedProperties.put(fileName, propHolder);
this.cachedMergedProperties.clear();
}
}
i18n相关配置
将我们自己实现的NacosBundleMessageSource放入容器,注意对象名必须为messageSourcee,使用此名字则Springboot国际化自动装配类才不会自动装载国际化实现类
@Configuration(proxyBeanMethods = false)
// 缺少名为messageSourcee的bean时才加载
@ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, search = SearchStrategy.CURRENT)
@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
@Conditional(ResourceBundleCondition.class)
@EnableConfigurationProperties
// 国际化自动装配类
public class MessageSourceAutoConfiguration {...}
// 常量
public abstract class AbstractApplicationContext extends DefaultResourceLoader
implements ConfigurableApplicationContext {
/**
* Name of the MessageSource bean in the factory.
* If none is supplied, message resolution is delegated to the parent.
* @see MessageSource
*/
public static final String MESSAGE_SOURCE_BEAN_NAME = "messageSource";
// ...
}
@Configuration
public class I18Config {
@Value("${spring.messages.basename}")
private String baseName;
@Value("${spring.messages.encoding}")
private String encoding;
@Value("${spring.cloud.nacos.discovery.group}")
private String group;
@Resource
private NacosConfigManager nacosConfigManager;
@Bean("messageSource")
public NacosBundleMessageSource messageSource() {
NacosBundleMessageSource messageSource = new NacosBundleMessageSource();
messageSource.setBasenames("web_message");
messageSource.setDefaultEncoding(encoding);
messageSource.setNacosGroup(group);
messageSource.setNacosConfigManager(nacosConfigManager);
messageSource.setCacheSeconds(10);
return messageSource;
}
/**
* @return LocaleResolver
*/
@Bean
public LocaleResolver localeResolver() {
return new LocaleResolver() {
@Override
public Locale resolveLocale(HttpServletRequest request) {
// 通过请求头的lang参数解析locale
String temp = request.getParameter("lang");
if (!StringUtils.isEmpty(temp)) {
String[] split = temp.split("_");
// 构造器要用对,不然时区对象在MessageSource实现类中获取的fileName将与传入的配置信息不匹配导致问题
Locale locale = new Locale(split[0], split[1]);
log.info("locale:" + locale);
return locale;
} else {
return Locale.getDefault();
}
}
@Override
public void setLocale(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Locale locale) {
}
};
}
}
Nacos相关配置
对nacos中配置的国际化信息配置文件,主要做两件事:
- 遍历每个文件名,初始化查询并缓存国际化信息(代码里未做)
- 遍历每个文件名,设置对应的监听器监听nacos的配置变更
@Slf4j
@Component
@RequiredArgsConstructor
public class NacosConfig {
@Value("${spring.cloud.nacos.discovery.group}")
private String group;
private final NacosConfigManager nacosConfigManager;
private final NacosBundleMessageSource messageSource;
@PostConstruct
public void init() throws Exception {
// 基本国际化文件名、各个时区的国际化文件名
List<String> fileNameList = new ArrayList<>();
fileNameList.add("web_message");
String[] langs = {"zh_CN", "en_US"};
for (String lang : langs) {
fileNameList.add("web_message" + "_" + lang);
}
// TODO 初始化本地国际化配置信息
// 遍历添加nacos监听器,监听对应配置变化
for (String fileName : fileNameList) {
// nacos查询配置是以文件名来查询
String dataId = fileName + ".properties";
nacosConfigManager.getConfigService().addListener(dataId, group, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
try {
// 更新对应配置信息,可以带入对应时区信息,做到仅更新对应时区的信息,避免全量的开销
messageSource.forceRefresh(fileName, configInfo);
} catch (Exception e) {
log.error("国际化配置监听异常", e);
}
}
@Override
public Executor getExecutor() {
return null;
}
});
}
log.info("国际化初始配置结束");
}
}
NacosBundleMessageSource代码
@Getter
@Setter
public class NacosBundleMessageSource extends AbstractResourceBasedMessageSource {
private boolean concurrentRefresh = true;
private NacosConfigManager nacosConfigManager;
private String nacosGroup;
private PropertiesPersister propertiesPersister = new DefaultPropertiesPersister();
// Cache to hold filename lists per Locale
private final ConcurrentMap<String, Map<Locale, List<String>>> cachedFilenames = new ConcurrentHashMap<>();
// Cache to hold already loaded properties per filename
private final ConcurrentMap<String, NacosBundleMessageSource.PropertiesHolder> cachedProperties = new ConcurrentHashMap<>();
// Cache to hold already loaded properties per filename
private final ConcurrentMap<Locale, NacosBundleMessageSource.PropertiesHolder> cachedMergedProperties = new ConcurrentHashMap<>();
@Override
protected String resolveCodeWithoutArguments(String code, Locale locale) {
if (getCacheMillis() < 0) {
NacosBundleMessageSource.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) {
NacosBundleMessageSource.PropertiesHolder propHolder = getProperties(filename);
String result = propHolder.getProperty(code);
if (result != null) {
return result;
}
}
}
}
return null;
}
@Override
@Nullable
protected MessageFormat resolveCode(String code, Locale locale) {
if (getCacheMillis() < 0) {
NacosBundleMessageSource.PropertiesHolder propHolder = getMergedProperties(locale);
MessageFormat result = propHolder.getMessageFormat(code, locale);
if (result != null) {
return result;
}
} else {
for (String basename : getBasenameSet()) {
List<String> filenames = calculateAllFilenames(basename, locale);
for (String filename : filenames) {
NacosBundleMessageSource.PropertiesHolder propHolder = getProperties(filename);
MessageFormat result = propHolder.getMessageFormat(code, locale);
if (result != null) {
return result;
}
}
}
}
return null;
}
/**
* 强制刷新
*
* @param fileName 文件名
* @param config 变更后的配置内容
*/
public void forceRefresh(String fileName, String config) throws IOException {
synchronized (this) {
Properties props = newProperties();
ByteArrayInputStream inputStream = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8));
this.propertiesPersister.load(props, inputStream);
long fileTimestamp = -1;
NacosBundleMessageSource.PropertiesHolder propHolder = new NacosBundleMessageSource.PropertiesHolder(props, fileTimestamp);
this.cachedProperties.put(fileName, propHolder);
this.cachedMergedProperties.clear();
}
}
protected NacosBundleMessageSource.PropertiesHolder getMergedProperties(Locale locale) {
NacosBundleMessageSource.PropertiesHolder mergedHolder = this.cachedMergedProperties.get(locale);
if (mergedHolder != null) {
return mergedHolder;
}
Properties mergedProps = newProperties();
long latestTimestamp = -1;
String[] basenames = StringUtils.toStringArray(getBasenameSet());
for (int i = basenames.length - 1; i >= 0; i--) {
List<String> filenames = calculateAllFilenames(basenames[i], locale);
for (int j = filenames.size() - 1; j >= 0; j--) {
String filename = filenames.get(j);
NacosBundleMessageSource.PropertiesHolder propHolder = getProperties(filename);
if (propHolder.getProperties() != null) {
mergedProps.putAll(propHolder.getProperties());
if (propHolder.getFileTimestamp() > latestTimestamp) {
latestTimestamp = propHolder.getFileTimestamp();
}
}
}
}
mergedHolder = new NacosBundleMessageSource.PropertiesHolder(mergedProps, latestTimestamp);
NacosBundleMessageSource.PropertiesHolder existing = this.cachedMergedProperties.putIfAbsent(locale, mergedHolder);
if (existing != null) {
mergedHolder = existing;
}
return mergedHolder;
}
protected List<String> calculateAllFilenames(String basename, Locale locale) {
Map<Locale, List<String>> localeMap = this.cachedFilenames.get(basename);
if (localeMap != null) {
List<String> filenames = localeMap.get(locale);
if (filenames != null) {
return filenames;
}
}
// Filenames for given Locale
List<String> filenames = new ArrayList<>(7);
filenames.addAll(calculateFilenamesForLocale(basename, locale));
// Filenames for default Locale, if any
Locale defaultLocale = getDefaultLocale();
if (defaultLocale != null && !defaultLocale.equals(locale)) {
List<String> fallbackFilenames = calculateFilenamesForLocale(basename, defaultLocale);
for (String fallbackFilename : fallbackFilenames) {
if (!filenames.contains(fallbackFilename)) {
// Entry for fallback locale that isn't already in filenames list.
filenames.add(fallbackFilename);
}
}
}
// Filename for default bundle file
filenames.add(basename);
if (localeMap == null) {
localeMap = new ConcurrentHashMap<>();
Map<Locale, List<String>> existing = this.cachedFilenames.putIfAbsent(basename, localeMap);
if (existing != null) {
localeMap = existing;
}
}
localeMap.put(locale, filenames);
return filenames;
}
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;
}
protected NacosBundleMessageSource.PropertiesHolder getProperties(String filename) {
NacosBundleMessageSource.PropertiesHolder propHolder = this.cachedProperties.get(filename);
long originalTimestamp = -2;
if (propHolder != null) {
originalTimestamp = propHolder.getRefreshTimestamp();
if (originalTimestamp == -1 || originalTimestamp > System.currentTimeMillis() - getCacheMillis()) {
// Up to date
return propHolder;
}
} else {
propHolder = new NacosBundleMessageSource.PropertiesHolder();
NacosBundleMessageSource.PropertiesHolder existingHolder = this.cachedProperties.putIfAbsent(filename, propHolder);
if (existingHolder != null) {
propHolder = existingHolder;
}
}
// At this point, we need to refresh...
if (this.concurrentRefresh && propHolder.getRefreshTimestamp() >= 0) {
// A populated but stale holder -> could keep using it.
if (!propHolder.refreshLock.tryLock()) {
// Getting refreshed by another thread already ->
// let's return the existing properties for the time being.
return propHolder;
}
} else {
propHolder.refreshLock.lock();
}
try {
NacosBundleMessageSource.PropertiesHolder existingHolder = this.cachedProperties.get(filename);
if (existingHolder != null && existingHolder.getRefreshTimestamp() > originalTimestamp) {
return existingHolder;
}
return refreshProperties(filename, propHolder);
} finally {
propHolder.refreshLock.unlock();
}
}
protected NacosBundleMessageSource.PropertiesHolder refreshProperties(String filename, @Nullable NacosBundleMessageSource.PropertiesHolder propHolder) {
long refreshTimestamp = (getCacheMillis() < 0 ? -1 : System.currentTimeMillis());
long fileTimestamp = -1;
try {
Properties props = loadProperties(filename);
propHolder = new NacosBundleMessageSource.PropertiesHolder(props, fileTimestamp);
} catch (IOException | NacosException ex) {
if (logger.isWarnEnabled()) {
logger.warn("Could not get properties form nacos ", ex);
}
// Empty holder representing "not valid".
propHolder = new NacosBundleMessageSource.PropertiesHolder();
}
propHolder.setRefreshTimestamp(refreshTimestamp);
this.cachedProperties.put(filename, propHolder);
return propHolder;
}
protected Properties loadProperties(String fileName) throws IOException, NacosException {
Properties props = newProperties();
String config = nacosConfigManager.getConfigService().getConfig(fileName + ".properties", nacosGroup, 5000);
ByteArrayInputStream inputStream = new ByteArrayInputStream(config.getBytes(StandardCharsets.UTF_8));
this.propertiesPersister.load(props, inputStream);
return props;
}
protected Properties newProperties() {
return new Properties();
}
protected class PropertiesHolder {
@Nullable
private final Properties properties;
private final long fileTimestamp;
private volatile long refreshTimestamp = -2;
private final ReentrantLock refreshLock = new ReentrantLock();
/**
* Cache to hold already generated MessageFormats per message code.
*/
private final ConcurrentMap<String, Map<Locale, MessageFormat>> cachedMessageFormats =
new ConcurrentHashMap<>();
public PropertiesHolder() {
this.properties = null;
this.fileTimestamp = -1;
}
public PropertiesHolder(Properties properties, long fileTimestamp) {
this.properties = properties;
this.fileTimestamp = fileTimestamp;
}
@Nullable
public Properties getProperties() {
return this.properties;
}
public long getFileTimestamp() {
return this.fileTimestamp;
}
public void setRefreshTimestamp(long refreshTimestamp) {
this.refreshTimestamp = refreshTimestamp;
}
public long getRefreshTimestamp() {
return this.refreshTimestamp;
}
@Nullable
public String getProperty(String code) {
if (this.properties == null) {
return null;
}
return this.properties.getProperty(code);
}
@Nullable
public MessageFormat getMessageFormat(String code, Locale locale) {
if (this.properties == null) {
return null;
}
Map<Locale, MessageFormat> localeMap = this.cachedMessageFormats.get(code);
if (localeMap != null) {
MessageFormat result = localeMap.get(locale);
if (result != null) {
return result;
}
}
String msg = this.properties.getProperty(code);
if (msg != null) {
if (localeMap == null) {
localeMap = new ConcurrentHashMap<>();
Map<Locale, MessageFormat> existing = this.cachedMessageFormats.putIfAbsent(code, localeMap);
if (existing != null) {
localeMap = existing;
}
}
MessageFormat result = createMessageFormat(msg, locale);
localeMap.put(locale, result);
return result;
}
return null;
}
}
}