基于若依-cloud的国际化方案(redis版)
前提
最近项目中需要进行国际化, 而项目使用的框架是若依-cloud这个框架, 网上一堆微服务的国际化方案, 好像都不太理想
然后自己深入去了解了一下国际化到底是怎么个事, 自己整个了一个比较合适的国际化方案
国际化介绍以及方案
这里说一下什么是国际化, 本质上就是, 根据用户选择的语言, 返回不同的信息
在 java 中 有个 叫 java.util.Locale
的类, 里面包含了绝大部分国家的语言类型
比如 Locale.SIMPLIFIED_CHINESE
是简体中文 Locale.ENGLISH
是英文 等
一般让前端在请求头中, 添加 { "Accept-Language": "zh" }
来标识, 用户使用的语言
然后我们添加拦截器, 将这个值取出来, 但是这一部 springboot
已经帮我们做了(默认配置)
所以一般的单体springboot项目中, 直接在配置一下国际化资源文件即可
@Configuration
public class I18nConfig implements WebMvcConfigurer {
@Bean
public MessageSource messageSource() {
// 多语言文件地址
Locale.setDefault(Locale.CHINA);
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
//设置国际化文件存储路径 resources目录下 可以设置多个
messageSource.addBasenames("i18n/common/messages","i18n/system/messages","i18n/device/messages");
//设置根据key如果没有获取到对应的文本信息,则返回key作为信息
messageSource.setUseCodeAsDefaultMessage(true);
//设置字符编码
messageSource.setDefaultEncoding(StandardCharsets.UTF_8.toString());
return messageSource;
}
}
然后在对应的目录文件(/i18n/common/
)下定义国际化资源文件
美式英语 messages_en_US.properties
user.login.username=User name
user.login.password=Password
user.login.code=Security code
user.login.remember=Remember me
user.login.submit=Sign In
中文简体 messages_zh_CN.properties
user.login.username=用户名
user.login.password=密码
user.login.code=验证码
user.login.remember=记住我
user.login.submit=登录
然后使用
先定一个 MessageUtils
工具类
public class MessageUtils
{
/**
* 根据消息键和参数 获取消息 委托给spring messageSource
*
* @param code 消息键
* @param args 参数
* @return 获取国际化翻译值
*/
public static String message(String code, Object... args)
{
MessageSource messageSource = SpringUtils.getBean(MessageSource.class);
return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
}
}
然后直接这样使用就行
MessageUtils.message("user.login.username")
MessageUtils.message("user.login.password")
或者直接参考若依官方文档:
这是单体项目的, 总得来说还是挺简单的
但是微服务的话, 也可以这样做, 但是就是每个服务都要写一遍, 维护起来非常的不方便
当然也可以把它抽出来, 写成一个公共模块, 然后不同模块的国际化资源放在不同的目录下
像这样messageSource.addBasenames("i18n/common/messages","i18n/system/messages","i18n/device/messages");
这样也是可以的, 但是还有问题, 就是每次修改都要重新加载, 这就有点麻烦
既然麻烦了, 那就麻烦到底, 我就想, 能不能将这些资源文件放在随时可以编辑的地方,
找了一圈, 看到有说放nacos的, 但是nacos那个编辑界面有点难用
所以我干脆放数据库
直接建表如下
CREATE TABLE `sys_i18n_message` (
`id` bigint NOT NULL AUTO_INCREMENT,
`code` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`locale` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL,
`message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL,
`module` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci ROW_FORMAT = Dynamic;
利用若依本身的代码生成, 很快就可以搞定一个 国际化资源管理
模块, 这样管理起来比nacos 好多了
接下来只需要把 这些资源信息加载到我们的服务中去用即可,
为了方便各个模块的调用, 这些数据应该加载到 redis
里面, 我们的服务再从 redis
里加载数据即可
这个各个模块就不用引入数据库相关的了
像这样 在国际化资源管理模块 启动的时候 将所有数据加到到 redis
@PostConstruct
@Override
public void refreshCache(){
// 更新 缓存
List<SysI18nMessage> sysI18nMessageList = messagesMapper.selectList();
redisService.setCacheObject("message_source_key",sysI18nMessageList);
}
然后接下来就是配置 redis
版的国际化资源
通过观察我们可以知道, 前面单体项目 配置一下国际化资源文件
的时候, 注入了 一个 bean
其实就是 MessageSource
这个类
我们看一下这个类
public interface MessageSource {
@Nullable
String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale);
String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException;
String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException;
}
只有三个方法, 而我们使用的主要是第一和第二个 messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
理论上, 我们只需要写一个类实现它这三个接口, 就OK了
不过我们还是找一下spring是怎么写的吧, 只要我们找到这个实现类, 然后仿照它的这个实现类, 只将数据来源从配置文件改成我们的 redis
即可
在前面注入 MessageSource
的时候我们 new
了一个 ResourceBundleMessageSource
所以它的实现类就是这个
他有一个父类 AbstractMessageSource
, 上面三个方法就是在这个父类里面实现的
且看 他是怎么实现的
public final String getMessage(String code, @Nullable Object[] args, @Nullable String defaultMessage, Locale locale) {
String msg = this.getMessageInternal(code, args, locale); // --1
if (msg != null) {
return msg;
} else {
return defaultMessage == null ? this.getDefaultMessage(code) : this.renderDefaultMessage(defaultMessage, args, locale);
}
}
public final String getMessage(String code, @Nullable Object[] args, Locale locale) throws NoSuchMessageException {
String msg = this.getMessageInternal(code, args, locale);// --2
if (msg != null) {
return msg;
} else {
String fallback = this.getDefaultMessage(code);
if (fallback != null) {
return fallback;
} else {
throw new NoSuchMessageException(code, locale);
}
}
}
public final String getMessage(MessageSourceResolvable resolvable, Locale locale) throws NoSuchMessageException {
String[] codes = resolvable.getCodes();
if (codes != null) {
String[] var4 = codes;
int var5 = codes.length;
for(int var6 = 0; var6 < var5; ++var6) {
String code = var4[var6];
String message = this.getMessageInternal(code, resolvable.getArguments(), locale); // --3
if (message != null) {
return message;
}
}
}
关键就是 这一句 this.getMessageInternal(code, args, locale)
那再看看 getMessageInternal
是怎么写的
@Nullable
protected String getMessageInternal(@Nullable String code, @Nullable Object[] args, @Nullable Locale locale) {
if (code == null) {
return null;
} else {
if (locale == null) {
locale = Locale.getDefault();
}
Object[] argsToUse = args;
if (!this.isAlwaysUseMessageFormat() && ObjectUtils.isEmpty(args)) {
String message = this.resolveCodeWithoutArguments(code, locale); // --1
if (message != null) {
return message;
}
} else {
argsToUse = this.resolveArguments(args, locale); // --2
MessageFormat messageFormat = this.resolveCode(code, locale);// --3
if (messageFormat != null) {
synchronized(messageFormat) {
return messageFormat.format(argsToUse);
}
}
}
Properties commonMessages = this.getCommonMessages();
if (commonMessages != null) {
String commonMessage = commonMessages.getProperty(code);
if (commonMessage != null) {
return this.formatMessage(commonMessage, args, locale);
}
}
return this.getMessageFromParent(code, argsToUse, locale);
}
}
通过调用链得知1,2,3 处, 最终也是调用 this.resolveCode(code, locale)
而 在这里类里面 只声明了这个方法 并未实现
protected abstract MessageFormat resolveCode(String code, Locale locale);
从他的名称MessageFormat
个人猜测就是一个消息格式化的工具 类似 将 username{0}.err
, 中的参数{0}
代进去
要搞懂这个方法是干嘛的, 就要回头看 ResourceBundleMessageSource
我们先看看他又是怎么写的
@Nullable
protected MessageFormat resolveCode(String code, Locale locale) {
Set<String> basenames = this.getBasenameSet(); // --1 和前面的 setBasename 对应上了, 就是资源文件的 路径
Iterator var4 = basenames.iterator();
while(var4.hasNext()) {
String basename = (String)var4.next();
ResourceBundle bundle = this.getResourceBundle(basename, locale);// --2 根据路径 获取到对应资源
if (bundle != null) {
MessageFormat messageFormat = this.getMessageFormat(bundle, code, locale); // --3 从这个资源解析得到 messageFormat
if (messageFormat != null) {
return messageFormat;
}
}
}
return null;
}
接下来 就是 看看 getMessageFormat
方法了
@Nullable
protected MessageFormat getMessageFormat(ResourceBundle bundle, String code, Locale locale) throws MissingResourceException {
Map<String, Map<Locale, MessageFormat>> codeMap = (Map)this.cachedBundleMessageFormats.get(bundle);
Map<Locale, MessageFormat> localeMap = null;
if (codeMap != null) {
localeMap = (Map)codeMap.get(code);
if (localeMap != null) {
MessageFormat result = (MessageFormat)localeMap.get(locale);
if (result != null) {
return result;
}
}
}
可以看到 他从一个 map
获取到的 这个map
长这样
private final Map<ResourceBundle, Map<String, Map<Locale, MessageFormat>>> cachedBundleMessageFormats = new ConcurrentHashMap();
这样看不太明显, 转成 json
格式就容易了
{
"resources1": {
"user.login.username": {
"en_US": "MessageFormatObj1",
"zh_CN": "MessageFormatObj2"
},
"user.login.password": {
"en_US": "MessageFormatObj3",
"zh_CN": "MessageFormatObj4"
}
//...
},
//resources2 ...
}
研究了大半天 终于明白了
也就是说 我们要从 redis
中读到的数据 例如:
[
{
"id": 1,
"code": "test",
"locale": "en_US",
"message": "a test",
"module": "common"
},
{
"id": 2,
"code": "test",
"locale": "zh_CN",
"message": "这是一个测试",
"module": "common"
}
//...
]
转成上面那个样子
OK 那我们也自己动手, 写一个这样的实现类, 直接继承 AbstractMessageSource
这样只需要实现一个方法即可
实现逻辑也很简单, 主要方法有两个
一个是 从字符串转成 Locale
, 好在它本身提供静态方法
Locale locale = Locale.forLanguageTag("zh_CN");
一个是构造 MessageFormat
也很简单 直接 new MessageFormat(msg, locale)
即可
ok 以下是完整的代码 RedisMessageSource.java
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.support.AbstractResourceBasedMessageSource;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import java.text.MessageFormat;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* describe:
*
* @author hengzi
* @date 2023-12-14 10:19:17
*/
@Slf4j
@Component
public class RedisMessageSource extends AbstractResourceBasedMessageSource {
private static final String MESSAGE_SOURCE_KEY = MessageUtils.MESSAGE_SOURCE_KEY;
private static final String MESSAGE_SOURCE_REFRESH_KEY = MessageUtils.MESSAGE_SOURCE_REFRESH_KEY;
private volatile long refreshTimestamp = 0;
@Autowired
private RedisService redisService;
private final static ConcurrentMap<String, Map<Locale, MessageFormat>> cachedMessageFormats = new ConcurrentHashMap<>(500);
private final static ConcurrentMap<String, Locale> LANGUAGE_LOCALE_MAP = new ConcurrentHashMap<>();
private final static ConcurrentMap<Locale, Locale> LOCALE_LOCALE_MAP = new ConcurrentHashMap<>();
static {
LOCALE_LOCALE_MAP.put(Locale.SIMPLIFIED_CHINESE, Locale.CHINESE);
LOCALE_LOCALE_MAP.put(Locale.TRADITIONAL_CHINESE, Locale.CHINESE);
LOCALE_LOCALE_MAP.put(Locale.CHINESE, Locale.CHINESE);
LOCALE_LOCALE_MAP.put(Locale.ENGLISH, Locale.ENGLISH);
LOCALE_LOCALE_MAP.put(Locale.UK, Locale.CHINESE);
LOCALE_LOCALE_MAP.put(Locale.US, Locale.CHINESE);
LOCALE_LOCALE_MAP.put(Locale.CANADA, Locale.CHINESE);
}
// 检查是否需要更新
public void checkRefresh() {
long currentTimeMillis = System.currentTimeMillis();
if ((refreshTimestamp + getCacheMillis()) > currentTimeMillis) {
return;
}
// 刷新更新时间
refreshTimestamp = currentTimeMillis;
// 从redis 获取 看看是否需要更新
Integer cacheObject = redisService.getCacheObject(MESSAGE_SOURCE_REFRESH_KEY);
if (cacheObject == null || cacheObject == 0) {
// 不需要
return;
}
forceRefresh();
}
public void forceRefresh() {
synchronized (RedisMessageSource.class) {
cachedMessageFormats.clear();
List<Object> cacheList = redisService.getCacheObject(MESSAGE_SOURCE_KEY);
if (cacheList == null || cacheList.isEmpty()) {
return;
}
for (Object obj : cacheList) {
try {
I18nMessageEntity item = parseObj(obj);
Locale locale = LANGUAGE_LOCALE_MAP.get(item.getLocale());
if (locale == null) {
locale = Locale.forLanguageTag(item.getLocale());
LANGUAGE_LOCALE_MAP.put(item.getLocale(), locale);
}
MessageFormat messageFormat = createMessageFormat(item.getMessage(), locale);
Map<Locale, MessageFormat> localeMessageFormatMap = cachedMessageFormats.get(item.getCode());
if (localeMessageFormatMap == null) {
localeMessageFormatMap = new ConcurrentHashMap<>();
localeMessageFormatMap.put(locale, messageFormat);
cachedMessageFormats.put(item.getCode(), localeMessageFormatMap);
} else {
localeMessageFormatMap.put(locale, messageFormat);
}
} catch (Exception e) {
log.error("获取{}的国际化信息出错: {}",obj, e.getMessage(), e);
}
}
}
}
public static I18nMessageEntity parseObj(Object object) {
if (object instanceof I18nMessageEntity) {
return (I18nMessageEntity) object;
}
if (object instanceof JSONObject) {
return ((JSONObject) object).toJavaObject(I18nMessageEntity.class);
}
return null;
}
@Nullable
@Override
protected MessageFormat resolveCode(String code, Locale locale) {
checkRefresh();
Map<Locale, MessageFormat> messageFormatMap = cachedMessageFormats.get(code);
if (messageFormatMap == null ) {
return createMessageFormat(code, locale);
}
Locale theLocale = LOCALE_LOCALE_MAP.getOrDefault(locale,locale);
MessageFormat messageFormat = messageFormatMap.get(theLocale);
if(messageFormat==null){
// 那就返回英文吧
messageFormat = messageFormatMap.get(Locale.ENGLISH);
if(messageFormat==null){
return createMessageFormat(code, locale);
}
}
return messageFormat;
}
}
实体类 I18nMessageEntity.java
package com.mdm.common.locale.domain;
import java.io.Serializable;
/**
* describe:
*
* @author hengzi
* @date 2023-12-14 10:41:41
*/
public class I18nMessageEntity implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String code;
private String locale;
private String message;
// 模块
private String module;
// getter and setter
}
配置类 I18nConfig
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
/**
* 国际化配置
*/
@Configuration
public class I18nConfig implements WebMvcConfigurer {
@Autowired
private RedisMessageSource redisMessageSource;
@Bean("messageSource")
public MessageSource messageSource(){
// 二十分钟检查更新一次
Locale.setDefault(Locale.CHINESE);
redisMessageSource.setCacheSeconds(20*60);
return redisMessageSource;
}
}
使用工具类 MessageUtils
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
/**
* 获取i18n资源文件
*
* @author hengzi
*/
@Slf4j
@Component
public class MessageUtils {
public static final String MESSAGE_SOURCE_KEY = "message_source_key";
public static final String MESSAGE_SOURCE_REFRESH_KEY = "message_source_refresh_key";
private static MessageSource messageSource;
@Autowired
public MessageUtils(MessageSource messageSource) {
MessageUtils.messageSource = messageSource;
}
/**
* 根据消息键和参数 获取消息 委托给spring messageSource
*
* @param code 消息键
* @param args 参数
* @return 获取国际化翻译值
*/
public static String message(String code, Object... args) {
try {
return messageSource.getMessage(code, args, LocaleContextHolder.getLocale());
} catch (Exception e) {
log.error("获取国际化信息异常:{}", code, e);
return code;
}
}
}
以上~