微服务国际化实战:数据库 + Redis + Caffeine 三层架构

在微服务场景下,传统基于 messages.properties 的 classpath 国际化有两个明显短板:改文案要发版,以及多实例、多服务之间词条难以统一

本文介绍一套已在生产形态项目中验证的方案:以 MySQL 为唯一数据源Redis Hash 跨服务共享词典Caffeine 做进程内加速,并通过 Redis Pub/Sub 在词条变更后失效各实例本地缓存。文中给出可直接复制使用的 建表 SQL、初始化数据、以及全部核心 Java 代码,不依赖任何外部仓库。


一、设计目标

目标实现方式
运行时改文案,无需重启词条表 + 管理 API,写库后同步 Redis 并广播
多微服务共享同一份词典Redis Hash:i18n:dict:{locale}
低延迟读Caffeine 本地缓存 + 启动预热
与 Spring 生态兼容自定义 @Primary MessageSource,排除 Boot 默认配置
统一 API 提示语businessCode 即 i18n key,message 为已翻译文案
枚举/下拉可国际化BaseEnum + TranslatorSpi + EnumUtils

整体架构

模块划分建议:

  • common 模块ResultBusinessExceptionBaseEnumI18nCacheKeys 等,不依赖 Redis。
  • web-starter 模块:语言解析、词条查找、Caffeine、Pub/Sub 监听、MessageSource 自动配置。
  • base 服务:词条 CRUD、唯一写入 Redis 的职责、启动时 DB → Redis 全量同步。
  • 业务服务:只引入 web-starter,只读 Redis。

二、数据库设计

2.1 词条表

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
CREATE TABLE sys_i18n_message (
    id            BIGINT       NOT NULL PRIMARY KEY COMMENT '主键',
    module_name   VARCHAR(64)  NOT NULL COMMENT '归属模块,如 global、order',
    message_code  VARCHAR(64)  NOT NULL COMMENT '词条编码,同时作为业务码/i18n key',
    locale        VARCHAR(16)  NOT NULL COMMENT '语言,如 zh_CN、en_US',
    message_value VARCHAR(512) NOT NULL COMMENT '翻译正文,支持 MessageFormat 占位符 {0}',
    remark        VARCHAR(256)          COMMENT '备注',
    create_time   DATETIME     NOT NULL,
    update_time   DATETIME     NOT NULL,
    create_by     VARCHAR(64),
    update_by     VARCHAR(64),
    is_deleted    TINYINT      NOT NULL DEFAULT 0,
    UNIQUE KEY uk_code_locale (message_code, locale)
) COMMENT '系统国际化词条表';

2.2 语言表(供前端切换展示)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
CREATE TABLE sys_i18n_locale (
    id          BIGINT       NOT NULL PRIMARY KEY,
    locale      VARCHAR(16)  NOT NULL COMMENT '语言代码,如 zh_CN',
    locale_name VARCHAR(64)  NOT NULL COMMENT '展示名,如 简体中文',
    sort_order  INT          NOT NULL DEFAULT 0,
    create_time DATETIME     NOT NULL,
    update_time DATETIME     NOT NULL,
    create_by   VARCHAR(64),
    update_by   VARCHAR(64),
    is_deleted  TINYINT      NOT NULL DEFAULT 0,
    UNIQUE KEY uk_locale (locale)
) COMMENT '系统支持的语言列表';

2.3 初始化词条示例

message_code 与 API 的 businessCode 使用同一套编码。建议:99xxxx 全局系统码,10xxxx 基础模块,各业务模块自行分段。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
INSERT INTO sys_i18n_message
    (id, module_name, message_code, locale, message_value, remark, create_time, update_time, create_by, update_by, is_deleted)
VALUES
-- 990000 操作成功
(1, 'global', '990000', 'zh_CN', '操作成功', 'SUCCESS', NOW(), NOW(), 'system', 'system', 0),
(2, 'global', '990000', 'en_US', 'Operation successful', 'SUCCESS', NOW(), NOW(), 'system', 'system', 0),
-- 990001 操作失败
(3, 'global', '990001', 'zh_CN', '操作失败', 'FAIL', NOW(), NOW(), 'system', 'system', 0),
(4, 'global', '990001', 'en_US', 'Operation failed', 'FAIL', NOW(), NOW(), 'system', 'system', 0),
-- 990002 参数校验失败(带占位符)
(5, 'global', '990002', 'zh_CN', '参数校验失败:{0}', 'PARAM_ERROR', NOW(), NOW(), 'system', 'system', 0),
(6, 'global', '990002', 'en_US', 'Parameter validation failed: {0}', 'PARAM_ERROR', NOW(), NOW(), 'system', 'system', 0),
-- 100001 词条不存在(base 模块)
(7, 'base', '100001', 'zh_CN', '国际化词条不存在', 'MESSAGE_NOT_FOUND', NOW(), NOW(), 'system', 'system', 0),
(8, 'base', '100001', 'en_US', 'I18n message not found', 'MESSAGE_NOT_FOUND', NOW(), NOW(), 'system', 'system', 0);

统一 API 响应示例:

1
2
3
4
5
6
{
  "code": 200,
  "businessCode": "990000",
  "message": "操作成功",
  "data": null
}

2.4 Redis 结构约定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
public final class CommonConstant {

    /** Redis Hash 顶层 Key 前缀,完整格式: i18n:dict:{locale} */
    public static final String I18N_REDIS_KEY_PREFIX = "i18n:dict:";

    /** 通知各实例刷新本地 Caffeine 的频道 */
    public static final String I18N_REDIS_REFRESH_CHANNEL = "i18n:refresh";

    /** 广播载荷:清空全部本地缓存 */
    public static final String I18N_REFRESH_ALL = "ALL";

    private CommonConstant() {}
}
  • Hash Key:i18n:dict:zh_CN
  • Hash Field:message_code
  • Hash Value:message_value
  • 刷新载荷:ALLzh_CN:990000locale:messageCode

三、公共层代码(common 模块)

3.1 缓存 Key 工具

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.common.utils;

import com.example.common.constants.CommonConstant;
import org.apache.commons.lang3.StringUtils;

import java.util.Locale;

public final class I18nCacheKeys {

    private I18nCacheKeys() {}

    /** 将 Locale 规范为与数据库一致的语言键,如 zh_CN */
    public static String localeKey(Locale locale) {
        if (locale == null) {
            return "zh_CN";
        }
        String language = locale.getLanguage();
        String country = locale.getCountry();
        if (!StringUtils.isNotBlank(language)) {
            return "zh_CN";
        }
        if (StringUtils.isNotBlank(country)) {
            return language + "_" + country;
        }
        return language;
    }

    public static String redisHashKey(String locale) {
        return CommonConstant.I18N_REDIS_KEY_PREFIX + locale;
    }

    public static String localCacheKey(String locale, String messageCode) {
        return locale + ":" + messageCode;
    }

    public static String localeFromRedisHashKey(String redisHashKey) {
        return redisHashKey.substring(CommonConstant.I18N_REDIS_KEY_PREFIX.length());
    }

    public static String refreshPayload(String locale, String messageCode) {
        return locale + ":" + messageCode;
    }
}

3.2 翻译器 SPI(common 只定义接口,web 层实现)

1
2
3
4
5
6
7
8
package com.example.common.translator;

public interface TranslatorSpi {

    String translate(String key, String defaultMessage);

    String translate(String key, String defaultMessage, Object... args);
}

3.3 枚举国际化契约

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.common.constants.base;

import com.example.common.utils.EnumUtils;

public interface BaseEnum<T> {

    T getValue();

    /** 不需要国际化的枚举可保持默认 null */
    default String getI18nKey() {
        return null;
    }

    String getDefaultDescription();

    default String getDescription() {
        return EnumUtils.getDescription(getI18nKey(), getDefaultDescription());
    }

    default String getDescription(Object... args) {
        return EnumUtils.getDescription(getI18nKey(), getDefaultDescription(), args);
    }
}

3.4 全局响应码枚举

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.common.constants;

import com.example.common.constants.base.BaseEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum ResultCodeEnum implements BaseEnum<Integer> {

    SUCCESS(200, "990000", "操作成功"),
    FAIL(400, "990001", "操作失败"),
    PARAM_ERROR(400, "990002", "参数校验失败:{0}"),
    UNAUTHORIZED(401, "990003", "未经授权,请先登录"),
    SYSTEM_ERROR(500, "999999", "系统异常");

    private final Integer value;
    private final String i18nKey;
    private final String defaultDescription;
}

3.5 统一响应体

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.common.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serial;
import java.io.Serializable;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> implements Serializable {

    @Serial
    private static final long serialVersionUID = -8386029242456310140L;

    /** HTTP 语义状态码,如 200、400、500 */
    private Integer code;

    /** 业务码,同时作为国际化词条 key */
    private String businessCode;

    /** 已翻译的提示信息 */
    private String message;

    private T data;
}

3.6 业务异常

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.common.exception;

import lombok.Getter;

@Getter
public class BusinessException extends RuntimeException {

    /** 业务码,对应国际化 key */
    private final String businessCode;

    /** MessageFormat 占位符参数 */
    private final Object[] args;

    public BusinessException(String businessCode) {
        this.businessCode = businessCode;
        this.args = null;
    }

    public BusinessException(String businessCode, Object... args) {
        this.businessCode = businessCode;
        this.args = args;
    }
}

3.7 业务码常量(示例)

1
2
3
4
5
6
7
8
9
package com.example.base.constants;

public final class BaseBusinessCode {

    private BaseBusinessCode() {}

    public static final String MESSAGE_NOT_FOUND = "100001";
    public static final String MESSAGE_DUPLICATE = "100002";
}

3.8 下拉选项与枚举工具

1
2
3
package com.example.common.model;

public record ConstantOption(Object value, String label) {}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.example.common.utils;

import com.example.common.constants.base.BaseEnum;
import com.example.common.model.ConstantOption;
import com.example.common.translator.TranslatorSpi;

import java.util.Arrays;
import java.util.List;

public class EnumUtils {

    private static TranslatorSpi translator;

    public static void registerTranslator(TranslatorSpi spi) {
        translator = spi;
    }

    public static <E extends Enum<E> & BaseEnum<?>> List<ConstantOption> toOptions(Class<E> enumClass) {
        return Arrays.stream(enumClass.getEnumConstants()).map(e -> {
            String label = e.getDefaultDescription();
            if (translator != null && e.getI18nKey() != null) {
                label = translator.translate(e.getI18nKey(), e.getDefaultDescription());
            }
            return new ConstantOption(e.getValue(), label);
        }).toList();
    }

    public static String getDescription(String i18nKey, String description) {
        if (i18nKey == null || i18nKey.isEmpty() || translator == null) {
            return description;
        }
        return translator.translate(i18nKey, description);
    }

    public static String getDescription(String i18nKey, String description, Object... args) {
        if (i18nKey == null || i18nKey.isEmpty() || translator == null) {
            return description;
        }
        return translator.translate(i18nKey, description, args);
    }
}

枚举定义示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Getter
@AllArgsConstructor
public enum DataStatusEnum implements BaseEnum<Integer> {

    VALID(1, "common.data_status.valid", "有效"),
    INVALID(0, "common.data_status.invalid", "无效");

    private final Integer value;
    private final String i18nKey;
    private final String defaultDescription;
}

四、读路径代码(web-starter 模块)

4.1 排除 Boot 默认 MessageSource

application.properties

1
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration

4.2 语言解析器

按优先级:lang 查询参数 → X-Language 请求头 → 默认 zh_CN。无状态 API,不使用 Session。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.web.translator;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.LocaleResolver;

import java.util.Locale;

public class TpLocaleResolver implements LocaleResolver {

    @Override
    public Locale resolveLocale(HttpServletRequest request) {
        String language = request.getParameter("lang");
        if (!StringUtils.hasText(language)) {
            language = request.getHeader("X-Language");
        }
        if (!StringUtils.hasText(language)) {
            return Locale.CHINA;
        }
        try {
            if (language.contains(",")) {
                language = language.split(",")[0];
            }
            language = language.replace("-", "_");
            String[] split = language.split("_");
            if (split.length == 2) {
                return new Locale(split[0], split[1]);
            } else if (split.length == 1) {
                return new Locale(split[0]);
            }
        } catch (Exception ignored) {
            return Locale.CHINA;
        }
        return Locale.CHINA;
    }

    @Override
    public void setLocale(HttpServletRequest request, HttpServletResponse response, Locale locale) {
        // 无状态 REST,无需回写 Session
    }
}

4.3 Caffeine 本地缓存配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.web.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.concurrent.TimeUnit;

@Configuration
public class CaffeineAutoConfig {

    @Bean("i18nLocalCache")
    public Cache<String, String> i18nLocalCache() {
        return Caffeine.newBuilder()
                .maximumSize(5000)
                .expireAfterWrite(2, TimeUnit.HOURS)
                .build();
    }
}

4.4 Redis + Caffeine 词条查找

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package com.example.web.translator;

import com.github.benmanes.caffeine.cache.Cache;
import com.example.common.utils.I18nCacheKeys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.Locale;
import java.util.Map;

@Slf4j
@Component
@RequiredArgsConstructor
public class I18nRedisMessageLookup {

    private final StringRedisTemplate redisTemplate;
    private final Cache<String, String> i18nLocalCache;

    public String lookup(String messageCode, Locale locale) {
        if (!StringUtils.hasText(messageCode)) {
            return null;
        }
        String localeStr = I18nCacheKeys.localeKey(locale);
        String localCacheKey = I18nCacheKeys.localCacheKey(localeStr, messageCode);

        String cached = i18nLocalCache.getIfPresent(localCacheKey);
        if (StringUtils.hasText(cached)) {
            return cached;
        }

        String redisHashKey = I18nCacheKeys.redisHashKey(localeStr);
        String message = readFromRedisHash(redisHashKey, messageCode);
        if (StringUtils.hasText(message)) {
            i18nLocalCache.put(localCacheKey, message);
            return message;
        }
        return null;
    }

    public long countLocaleMessages(String locale) {
        return redisTemplate.opsForHash().size(I18nCacheKeys.redisHashKey(locale));
    }

    private String readFromRedisHash(String redisHashKey, String messageCode) {
        Object direct = redisTemplate.opsForHash().get(redisHashKey, messageCode);
        if (direct != null && StringUtils.hasText(direct.toString())) {
            return direct.toString();
        }
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(redisHashKey);
        if (entries.isEmpty()) {
            return null;
        }
        Object matched = entries.get(messageCode);
        if (matched == null) {
            for (Map.Entry<Object, Object> entry : entries.entrySet()) {
                if (messageCode.equals(String.valueOf(entry.getKey()))) {
                    matched = entry.getValue();
                    break;
                }
            }
        }
        return matched != null ? String.valueOf(matched) : null;
    }
}

4.5 对接 Spring MessageSource

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.web.translator;

import org.springframework.context.support.AbstractMessageSource;

import java.text.MessageFormat;
import java.util.Locale;

public class RedisCaffeineMessageSource extends AbstractMessageSource {

    private final I18nRedisMessageLookup messageLookup;

    public RedisCaffeineMessageSource(I18nRedisMessageLookup messageLookup) {
        this.messageLookup = messageLookup;
    }

    @Override
    protected MessageFormat resolveCode(String code, Locale locale) {
        String message = messageLookup.lookup(code, locale);
        if (message != null) {
            return new MessageFormat(message, locale);
        }
        return null;
    }

    @Override
    protected String resolveCodeWithoutArguments(String code, Locale locale) {
        return messageLookup.lookup(code, locale);
    }
}

4.6 静态翻译工具 I18nUtils

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.example.web.utils;

import com.example.web.translator.I18nRedisMessageLookup;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.text.MessageFormat;
import java.util.Locale;

@Slf4j
@Component
public class I18nUtils {

    private static I18nRedisMessageLookup messageLookup;

    @Autowired
    public void bindMessageLookup(I18nRedisMessageLookup messageLookup) {
        I18nUtils.messageLookup = messageLookup;
    }

    public static String getMessage(String key) {
        return resolveMessage(key, null, null);
    }

    public static String getMessage(String key, String defaultMessage) {
        return resolveMessage(key, null, defaultMessage);
    }

    public static String getMessage(String key, String defaultMessage, Object... args) {
        return resolveMessage(key, args, defaultMessage);
    }

    private static String resolveMessage(String key, Object[] args, String defaultMessage) {
        if (!StringUtils.hasText(key)) {
            return fallback(key, defaultMessage);
        }
        if (messageLookup == null) {
            log.warn("I18nRedisMessageLookup 未注入,无法翻译 key={}", key);
            return fallback(key, defaultMessage);
        }
        try {
            Locale locale = LocaleContextHolder.getLocale();
            String template = messageLookup.lookup(key, locale);
            if (!StringUtils.hasText(template)) {
                return fallback(key, defaultMessage);
            }
            if (args == null || args.length == 0) {
                return template;
            }
            return new MessageFormat(template, locale).format(args);
        } catch (Exception e) {
            log.warn("翻译 key={} 失败,locale={}", key, LocaleContextHolder.getLocale(), e);
            return fallback(key, defaultMessage);
        }
    }

    private static String fallback(String key, String defaultMessage) {
        return StringUtils.hasText(defaultMessage) ? defaultMessage : key;
    }
}

4.7 TranslatorSpi 实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package com.example.web.translator;

import com.example.common.translator.TranslatorSpi;
import com.example.web.utils.I18nUtils;
import org.springframework.stereotype.Component;

@Component
public class I18nTranslator implements TranslatorSpi {

    @Override
    public String translate(String key, String defaultMessage) {
        return I18nUtils.getMessage(key, defaultMessage);
    }

    @Override
    public String translate(String key, String defaultMessage, Object... args) {
        return I18nUtils.getMessage(key, defaultMessage, args);
    }
}

4.8 自动配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.web.config;

import com.example.common.translator.TranslatorSpi;
import com.example.common.utils.EnumUtils;
import com.example.web.translator.I18nRedisMessageLookup;
import com.example.web.translator.RedisCaffeineMessageSource;
import com.example.web.translator.TpLocaleResolver;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.LocaleResolver;

@Configuration
@ConditionalOnClass(StringRedisTemplate.class)
@AutoConfigureBefore({WebMvcAutoConfiguration.class, MessageSourceAutoConfiguration.class})
@AutoConfigureAfter(RedisAutoConfiguration.class)
public class I18nAutoConfiguration {

    @Bean("messageSource")
    @Primary
    @ConditionalOnBean({StringRedisTemplate.class, I18nRedisMessageLookup.class})
    @ConditionalOnMissingBean(name = "messageSource")
    public RedisCaffeineMessageSource messageSource(I18nRedisMessageLookup messageLookup) {
        RedisCaffeineMessageSource messageSource = new RedisCaffeineMessageSource(messageLookup);
        messageSource.setUseCodeAsDefaultMessage(false);
        return messageSource;
    }

    @Bean
    @ConditionalOnBean(name = "messageSource")
    public InitializingBean i18nTranslatorRegistrar(ObjectProvider<TranslatorSpi> translatorSpiProvider) {
        return () -> translatorSpiProvider.ifAvailable(EnumUtils::registerTranslator);
    }

    @Bean("localeResolver")
    @ConditionalOnMissingBean(name = "localeResolver")
    public LocaleResolver localeResolver() {
        return new TpLocaleResolver();
    }
}

4.9 本地缓存失效(Pub/Sub 消费端)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.web.translator;

import com.github.benmanes.caffeine.cache.Cache;
import com.example.common.constants.CommonConstant;
import com.example.common.utils.I18nCacheKeys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;

@Slf4j
@RequiredArgsConstructor
public class I18nLocalCacheSupport {

    private final Cache<String, String> i18nLocalCache;

    public void put(String locale, String messageCode, String messageValue) {
        if (!StringUtils.hasText(locale) || !StringUtils.hasText(messageCode)) {
            return;
        }
        i18nLocalCache.put(I18nCacheKeys.localCacheKey(locale, messageCode), messageValue);
    }

    public void evict(String locale, String messageCode) {
        if (!StringUtils.hasText(locale) || !StringUtils.hasText(messageCode)) {
            return;
        }
        i18nLocalCache.invalidate(I18nCacheKeys.localCacheKey(locale, messageCode));
    }

    public void evictAll() {
        i18nLocalCache.invalidateAll();
        log.info("已清空全部国际化本地 Caffeine 缓存");
    }

    public void handleRefreshPayload(String payload) {
        if (!StringUtils.hasText(payload)) {
            return;
        }
        if (CommonConstant.I18N_REFRESH_ALL.equals(payload)) {
            evictAll();
            return;
        }
        int separatorIndex = payload.indexOf(':');
        if (separatorIndex <= 0 || separatorIndex >= payload.length() - 1) {
            log.warn("无法解析的国际化缓存刷新载荷: {}", payload);
            return;
        }
        String locale = payload.substring(0, separatorIndex);
        String messageCode = payload.substring(separatorIndex + 1);
        evict(locale, messageCode);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.web.translator;

import com.example.common.constants.CommonConstant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;

@Slf4j
@RequiredArgsConstructor
public class I18nCacheRefreshListener implements MessageListener {

    private final I18nLocalCacheSupport i18nLocalCacheSupport;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String payload = new String(message.getBody());
        log.info("收到国际化缓存刷新通知 [{}]: {}", CommonConstant.I18N_REDIS_REFRESH_CHANNEL, payload);
        i18nLocalCacheSupport.handleRefreshPayload(payload);
    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.web.config;

import com.github.benmanes.caffeine.cache.Cache;
import com.example.common.constants.CommonConstant;
import com.example.web.translator.I18nCacheRefreshListener;
import com.example.web.translator.I18nLocalCacheSupport;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Configuration
@ConditionalOnClass(StringRedisTemplate.class)
@ConditionalOnBean(RedisConnectionFactory.class)
public class I18nRedisListenerConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public I18nLocalCacheSupport i18nLocalCacheSupport(Cache<String, String> i18nLocalCache) {
        return new I18nLocalCacheSupport(i18nLocalCache);
    }

    @Bean
    @ConditionalOnMissingBean
    public I18nCacheRefreshListener i18nCacheRefreshListener(I18nLocalCacheSupport support) {
        return new I18nCacheRefreshListener(support);
    }

    @Bean
    @ConditionalOnMissingBean(name = "i18nRedisMessageListenerContainer")
    public RedisMessageListenerContainer i18nRedisMessageListenerContainer(
            RedisConnectionFactory connectionFactory,
            I18nCacheRefreshListener listener) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listener, new ChannelTopic(CommonConstant.I18N_REDIS_REFRESH_CHANNEL));
        return container;
    }
}

4.10 启动预热(可选,失败不阻断启动)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package com.example.web.translator;

import com.github.benmanes.caffeine.cache.Cache;
import com.example.common.constants.CommonConstant;
import com.example.common.utils.I18nCacheKeys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.Map;

@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class I18nCachePreloader implements ApplicationRunner {

    private final StringRedisTemplate redisTemplate;
    private final Cache<String, String> i18nLocalCache;
    private final I18nRedisMessageLookup messageLookup;

    public I18nCachePreloader(StringRedisTemplate redisTemplate,
                              Cache<String, String> i18nLocalCache,
                              I18nRedisMessageLookup messageLookup) {
        this.redisTemplate = redisTemplate;
        this.i18nLocalCache = i18nLocalCache;
        this.messageLookup = messageLookup;
    }

    @Override
    public void run(ApplicationArguments args) {
        log.info(">>> 开始国际化本地缓存预热...");
        long zhCnCount = messageLookup.countLocaleMessages("zh_CN");
        log.info(">>> Redis i18n:dict:zh_CN 词条数: {}", zhCnCount);

        long start = System.currentTimeMillis();
        int total = 0;
        try {
            ScanOptions options = ScanOptions.scanOptions()
                    .match(CommonConstant.I18N_REDIS_KEY_PREFIX + "*")
                    .count(100)
                    .build();

            Iterable<byte[]> keys = redisTemplate.execute(connection -> {
                Cursor<byte[]> cursor = connection.keyCommands().scan(options);
                return () -> cursor;
            }, true);

            if (keys != null) {
                for (byte[] keyBytes : keys) {
                    String redisKey = new String(keyBytes);
                    String localeStr = I18nCacheKeys.localeFromRedisHashKey(redisKey);
                    Map<Object, Object> dict = redisTemplate.opsForHash().entries(redisKey);
                    for (Map.Entry<Object, Object> entry : dict.entrySet()) {
                        String code = String.valueOf(entry.getKey());
                        String message = String.valueOf(entry.getValue());
                        i18nLocalCache.put(I18nCacheKeys.localCacheKey(localeStr, code), message);
                        total++;
                    }
                    log.info(" - 加载语言包 [{}], 词条数: {}", localeStr, dict.size());
                }
            }
        } catch (Exception e) {
            log.error(">>> 预热失败,降级为懒加载", e);
        } finally {
            log.info(">>> 预热结束,耗时 {} ms,共 {} 条", System.currentTimeMillis() - start, total);
        }
    }
}

五、写路径代码(base 服务)

5.1 Redis 写入仓储(仅 base 服务持有)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.base.cache;

import com.example.common.constants.CommonConstant;
import com.example.common.utils.I18nCacheKeys;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.util.Map;

@Repository
@RequiredArgsConstructor
public class I18nRedisCacheRepository {

    private final StringRedisTemplate redisTemplate;

    public void putMessage(String locale, String messageCode, String messageValue) {
        if (!StringUtils.hasText(locale) || !StringUtils.hasText(messageCode)) {
            return;
        }
        redisTemplate.opsForHash().put(I18nCacheKeys.redisHashKey(locale), messageCode, messageValue);
    }

    public void removeMessage(String locale, String messageCode) {
        if (!StringUtils.hasText(locale) || !StringUtils.hasText(messageCode)) {
            return;
        }
        redisTemplate.opsForHash().delete(I18nCacheKeys.redisHashKey(locale), messageCode);
    }

    public void replaceLocaleDict(String locale, Map<String, String> dict) {
        String redisHashKey = I18nCacheKeys.redisHashKey(locale);
        redisTemplate.delete(redisHashKey);
        if (!CollectionUtils.isEmpty(dict)) {
            redisTemplate.opsForHash().putAll(redisHashKey, dict);
        }
    }

    public void publishRefresh(String payload) {
        redisTemplate.convertAndSend(CommonConstant.I18N_REDIS_REFRESH_CHANNEL, payload);
    }

    public void publishRefreshAll() {
        publishRefresh(CommonConstant.I18N_REFRESH_ALL);
    }

    public void publishRefreshEntry(String locale, String messageCode) {
        publishRefresh(I18nCacheKeys.refreshPayload(locale, messageCode));
    }
}

5.2 词条变更后同步 Redis

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
private void syncOneToRedis(SysI18nMessagePO po) {
    i18nRedisCacheRepository.putMessage(po.getLocale(), po.getMessageCode(), po.getMessageValue());
    i18nRedisCacheRepository.publishRefreshEntry(po.getLocale(), po.getMessageCode());
}

@Transactional(readOnly = true)
public int syncAllToRedis() {
    List<SysI18nMessagePO> messages = messageMapper.selectList(new LambdaQueryWrapper<>());
    Map<String, Map<String, String>> localeDictMap = new LinkedHashMap<>();
    for (SysI18nMessagePO message : messages) {
        localeDictMap
                .computeIfAbsent(message.getLocale(), k -> new LinkedHashMap<>())
                .put(message.getMessageCode(), message.getMessageValue());
    }
    for (Map.Entry<String, Map<String, String>> entry : localeDictMap.entrySet()) {
        i18nRedisCacheRepository.replaceLocaleDict(entry.getKey(), entry.getValue());
    }
    i18nRedisCacheRepository.publishRefreshAll();
    return messages.size();
}

5.3 启动时全量同步

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.base.job;

import com.example.base.service.SysI18nMessageAppService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@RequiredArgsConstructor
public class I18nCacheSyncRunner implements ApplicationRunner {

    private final SysI18nMessageAppService messageAppService;

    @Override
    public void run(ApplicationArguments args) {
        try {
            int count = messageAppService.syncAllToRedis();
            log.info(">>> 启动国际化缓存同步完成,词条数: {}", count);
        } catch (Exception e) {
            log.error(">>> 启动国际化缓存同步失败", e);
        }
    }
}

六、业务层使用方式

6.1 ResultUtils(统一 API 出口)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.web.utils;

import com.example.common.constants.ResultCodeEnum;
import com.example.common.model.Result;

public class ResultUtils {

    private ResultUtils() {}

    private static <T> Result<T> build(Integer code, String businessCode, String message, T data) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setBusinessCode(businessCode);
        result.setMessage(message);
        result.setData(data);
        return result;
    }

    private static String successMessage() {
        return I18nUtils.getMessage(
                ResultCodeEnum.SUCCESS.getI18nKey(),
                ResultCodeEnum.SUCCESS.getDefaultDescription());
    }

    public static <T> Result<T> success(T data) {
        return build(ResultCodeEnum.SUCCESS.getValue(),
                ResultCodeEnum.SUCCESS.getI18nKey(), successMessage(), data);
    }

    public static <T> Result<T> fail(String businessCode) {
        String message = I18nUtils.getMessage(businessCode, ResultCodeEnum.FAIL.getDefaultDescription());
        return build(ResultCodeEnum.FAIL.getValue(), businessCode, message, null);
    }

    public static <T> Result<T> failArgs(String businessCode, Object... args) {
        String message = I18nUtils.getMessage(businessCode, ResultCodeEnum.FAIL.getDefaultDescription(), args);
        return build(ResultCodeEnum.FAIL.getValue(), businessCode, message, null);
    }

    public static <T> Result<T> fail(Integer code, String businessCode) {
        String message = I18nUtils.getMessage(businessCode, ResultCodeEnum.FAIL.getDefaultDescription());
        return build(code, businessCode, message, null);
    }
}

Controller 示例:

1
2
3
4
5
6
7
8
@GetMapping("/users/{id}")
public Result<UserVO> getUser(@PathVariable Long id) {
    UserVO user = userService.findById(id);
    if (user == null) {
        return ResultUtils.fail("200001");  // 业务码即 i18n key
    }
    return ResultUtils.success(user);
}

6.2 全局异常处理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package com.example.web.handler;

import com.example.common.constants.ResultCodeEnum;
import com.example.common.exception.BusinessException;
import com.example.common.model.Result;
import com.example.web.utils.ResultUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.stream.Collectors;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.OK)
    public Result<Void> handleBusinessException(BusinessException e) {
        return ResultUtils.failArgs(e.getBusinessCode(), e.getArgs());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining("; "));
        return ResultUtils.failArgs(ResultCodeEnum.PARAM_ERROR.getI18nKey(), message);
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常", e);
        return ResultUtils.fail(
                ResultCodeEnum.SYSTEM_ERROR.getValue(),
                ResultCodeEnum.SYSTEM_ERROR.getI18nKey());
    }
}

业务代码只需抛码:

1
2
3
4
if (duplicate) {
    throw new BusinessException(BaseBusinessCode.MESSAGE_DUPLICATE);
}
throw new BusinessException("100005", userName);

6.3 客户端如何切换语言

1
GET /api/users/1?lang=en_US

或:

1
2
GET /api/users/1
X-Language: en_US

七、管理 API 约定

方法路径说明
GET/api/v1/i18n/messages分页查询词条
POST/api/v1/i18n/messages新增 → 写 Redis → 发布刷新
PUT/api/v1/i18n/messages/{id}更新 → 同步 Redis
DELETE/api/v1/i18n/messages/{id}删除 → 从 Redis 移除
POST/api/v1/i18n/messages/sync全量 DB → Redis,广播 ALL
GET/api/v1/i18n/locales可选语言列表

八、端到端时序

一次 API 调用的读路径:

  1. TpLocaleResolver 解析语言,写入 LocaleContextHolder
  2. ResultUtils.fail("100001") 调用 I18nUtils.getMessage
  3. I18nRedisMessageLookup:Caffeine → Redis Hash
  4. MessageFormat 填充占位符,写入 Result.message

一次后台改词的写路径:

  1. 更新 MySQL
  2. putMessage 写入 i18n:dict:zh_CN
  3. publishRefreshEntry("zh_CN", "100001")
  4. 各实例 I18nCacheRefreshListener 失效对应 Caffeine 键
  5. 下次请求从 Redis 重新加载

九、上线与运维清单

  1. sys_i18n_locale 增加语言(前端展示用)
  2. 为每个 message_code 插入各 localemessage_value
  3. 调用 POST .../messages/sync 或重启 base 服务触发全量同步
  4. 确认所有微服务 spring.data.redis.database 一致
  5. 查看启动日志:i18n:dict:zh_CN 词条数应大于 0
  6. X-Language: en_US 验证 message 字段

十、注意事项

  1. Redis 是运行时强依赖:DB 有词但 Redis 未同步时,接口会走 defaultMessage 兜底。
  2. database 索引必须一致:写入与读取若落在不同 logical database,会表现为「永远中文」。
  3. Locale 规范化:查找时使用 zh_CN 形式,不要把 zh_CN_#Hans 直接当 Hash 后缀。
  4. 占位符:统一 {0}{1},与 MessageFormat 一致。
  5. Caffeine TTL(2 小时) 仅作兜底;正常靠 Pub/Sub 失效。
  6. 编码规划:提前划分业务码段,避免多团队冲突。

小结

这套方案把国际化从「静态 properties 文件」升级为「可运营的动态词典」:MySQL 负责真相来源,Redis 负责跨服务分发,Caffeine 负责单机性能,Pub/Sub 负责近实时一致性;业务侧通过 businessCodeBusinessExceptionBaseEnumResultUtils 统一出口,只关心「返回什么码」,由基础设施按请求语言渲染「说什么话」。

文中代码包名统一写作 com.example.*,落地时替换为你的工程包名即可;依赖方面需要 Spring Boot Web、Spring Data Redis、Caffeine、Lombok(可选)。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计