基于若依-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;
        }
    }
}

以上~