写在前面
国内系统做国际化常常掉进一个误区——以为前端搞个 i18n 文件就完事了。但当业务真要支持多语言(出海、跨境电商、海外 SaaS)时,你会发现:
- 错误消息从后端来——前端 i18n 没用
- 数据字典(订单状态、产品类型)从 DB 来——前端写死翻译会脱节
- 菜单和按钮文本从配置中心来——前端 i18n 不能覆盖
- 营销内容/邮件模板/短信模板都要多语言
真正的国际化是『后端数据 + 前端展示』的协同。本文围绕数据字典和菜单这两个最难的场景,给出生产级方案。
一、为什么数据字典国际化最难
普通业务表的国际化简单——加个 _en 字段就完事。但数据字典特殊:
- 字典本身可配置——产品/运营会随时新增字典项
- 多语言字段动态——今天支持中英,明天加日韩西
- 菜单层级深——一级菜单/二级菜单都要翻译
- 跨业务共享——同一个字典被多个模块用
这种场景下"加 _en 字段"会导致:
1
| status_zh, status_en, status_ja, status_ko, status_es, ...
|
字段无限增长——根本没法管。
二、方案 1:i18n 副表
最常见的做法——主表存核心数据,副表存翻译:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| CREATE TABLE dict_item (
id BIGINT PRIMARY KEY,
type VARCHAR(50), -- order_status / product_type ...
code VARCHAR(50), -- PAID / SHIPPED ...
sort INT,
enabled BOOLEAN,
UNIQUE KEY uk_type_code (type, code)
);
CREATE TABLE dict_item_i18n (
id BIGINT PRIMARY KEY,
dict_item_id BIGINT,
locale VARCHAR(10), -- zh-CN / en-US / ja-JP / ...
label VARCHAR(255), -- 翻译后的名称
description VARCHAR(500), -- 翻译后的描述
UNIQUE KEY uk_item_locale (dict_item_id, locale)
);
|
查询时 JOIN:
1
2
3
4
5
| SELECT i.code, t.label
FROM dict_item i
LEFT JOIN dict_item_i18n t ON t.dict_item_id = i.id AND t.locale = ?
WHERE i.type = 'order_status' AND i.enabled = TRUE
ORDER BY i.sort;
|
优点
- 加新语言只增数据不改 schema
- 字典项本身和翻译解耦
- 易于配后台管理
缺点
- 每次查都 JOIN——大量字典查询有性能开销
- 缺少某语言时 fallback 要自己处理
Fallback 策略
请求 locale = ja-JP,但只有 zh-CN 和 en-US 翻译——怎么办?两种策略:
- 回退到默认语言(通常是
en-US)——返回英文 - 回退到 code 本身——比如返回 “PAID”
1
2
3
4
5
6
7
8
| SELECT i.code,
COALESCE(
t.label,
(SELECT label FROM dict_item_i18n WHERE dict_item_id = i.id AND locale = 'en-US'),
i.code
) AS label
FROM dict_item i
LEFT JOIN dict_item_i18n t ON t.dict_item_id = i.id AND t.locale = ?
|
三、方案 2:JSON 字段
主表用 JSON 字段存所有语言:
1
2
3
4
5
6
7
| CREATE TABLE dict_item (
id BIGINT PRIMARY KEY,
type VARCHAR(50),
code VARCHAR(50),
labels JSON, -- {"zh-CN": "已付款", "en-US": "Paid", "ja-JP": "支払済み"}
sort INT
);
|
查询:
1
2
| SELECT code, JSON_EXTRACT(labels, '$."zh-CN"') AS label
FROM dict_item WHERE type = 'order_status';
|
优点
- 不用 JOIN,单表查询快
- schema 极简
- 取数据时一次拿到所有语言
缺点
- JSON 字段查询/索引能力差
- 后台编辑更新某个语言要 JSON 操作
- 字典量大时 JSON 体积膨胀
适合字典量不大、改动不频繁的场景。
四、方案 3:消息文件 + DB 关联
把翻译完全放在配置文件(properties / yaml)里:
1
2
3
4
5
| # messages_zh_CN.properties
dict.order_status.PAID=已付款
dict.order_status.SHIPPED=已发货
dict.product_type.SAAS=SaaS 服务
|
1
2
3
| # messages_en_US.properties
dict.order_status.PAID=Paid
dict.order_status.SHIPPED=Shipped
|
DB 只存 code,业务里用 MessageSource 翻译:
1
2
3
4
5
6
7
8
| @Autowired private MessageSource messageSource;
public String getLabel(String type, String code, Locale locale) {
return messageSource.getMessage(
"dict." + type + "." + code,
null, code, locale
);
}
|
优点
- 走标准 Spring i18n——成熟可靠
- 文件易于版本管理
- 性能极好(启动时全加载到内存)
缺点
- 不能在后台动态改翻译——必须发版
- 配置文件长了不好维护
- 多语言文件容易忘改
适合翻译相对稳定的场景——但是少数。多数业务希望运营能自己改翻译,这种方案不行。
五、推荐:副表 + 多级缓存
对一个生产级国际化系统,我推荐这套架构:
flowchart LR
Client[前端] --> Cache1[CDN 缓存]
Cache1 --> API[API 网关]
API --> AppCache[应用本地缓存
Caffeine]
AppCache --> RedisCache[(Redis)]
RedisCache --> DB[(主表 + i18n 副表)]
Admin[管理后台] -.改翻译.-> DB
Admin -.触发刷新.-> RedisCache
RedisCache -.广播失效.-> AppCache设计要点:
- DB 用副表方案(方案 1)
- Redis 缓存全量字典——几 MB 数据,启动加载
- 应用本地 Caffeine 缓存——避免每次查 Redis
- 管理后台改翻译时主动失效缓存——所有实例同步刷新(用 Redis pub/sub 或 MQ)
- 前端 Service Worker / CDN 缓存按 locale 区分
六、菜单的国际化
菜单是另一个棘手场景——菜单结构是动态的,每个用户看到的菜单可能不同:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| CREATE TABLE menu (
id BIGINT PRIMARY KEY,
code VARCHAR(100) UNIQUE,
parent_code VARCHAR(100),
icon VARCHAR(100),
path VARCHAR(255),
sort INT,
permission VARCHAR(100),
enabled BOOLEAN
);
CREATE TABLE menu_i18n (
menu_id BIGINT,
locale VARCHAR(10),
title VARCHAR(255),
PRIMARY KEY (menu_id, locale)
);
|
查询:
1
2
3
4
5
6
7
| SELECT m.id, m.code, m.parent_code, m.icon, m.path, m.sort,
COALESCE(t.title, t_default.title, m.code) AS title
FROM menu m
LEFT JOIN menu_i18n t ON t.menu_id = m.id AND t.locale = ?
LEFT JOIN menu_i18n t_default ON t_default.menu_id = m.id AND t_default.locale = 'en-US'
WHERE m.enabled = TRUE
ORDER BY m.sort;
|
返回前端时已经是带翻译的菜单树——前端只需渲染。
七、错误消息的国际化
后端错误也要多语言——但错误信息往往动态:
1
| throw new BusinessException("user_not_found", "User " + userId + " not found");
|
不能写死翻译——要用模板:
1
2
3
| # messages_zh_CN.properties
error.user_not_found=用户 {0} 不存在
error.insufficient_balance=余额不足,需要 {0} 但只有 {1}
|
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
| public class I18nException extends RuntimeException {
private final String code;
private final Object[] args;
public I18nException(String code, Object... args) {
super(code);
this.code = code;
this.args = args;
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@Autowired private MessageSource messageSource;
@ExceptionHandler(I18nException.class)
public Result handle(I18nException e, Locale locale) {
String msg = messageSource.getMessage(
"error." + e.getCode(),
e.getArgs(),
"Unknown error",
locale
);
return Result.fail(e.getCode(), msg);
}
}
|
业务里:
1
| throw new I18nException("user_not_found", userId);
|
八、Locale 的传递
前端怎么告诉后端"我要中文"?三种方式:
1
| Accept-Language: zh-CN, en;q=0.9
|
Spring 默认能识别。
2. URL 参数
1
| GET /api/dict/order_status?locale=zh-CN
|
3. 用户偏好
用户登录后,从用户配置里读 locale——存在 user 表或 JWT claim 里。
1
2
3
| LocaleResolver resolver = new SessionLocaleResolver();
// 或
LocaleResolver resolver = new AcceptHeaderLocaleResolver();
|
生产推荐:用户偏好优先 → URL 参数次之 → Header 兜底。
九、几个工程实践
1. 翻译流程标准化
研发 / 产品 / 翻译方的协作流程:
- 研发提交新字典项(默认中英两语)
- 翻译方在管理后台补其他语言
- 缓存自动刷新
- 上线前 review 是否所有 active 字典项都有翻译
2. 缺翻译的报警
定期跑一个脚本——任何 enabled 字典项缺少某种 locale 翻译就告警:
1
2
3
4
5
6
| SELECT i.type, i.code
FROM dict_item i
WHERE i.enabled = TRUE
AND NOT EXISTS (
SELECT 1 FROM dict_item_i18n WHERE dict_item_id = i.id AND locale = 'en-US'
);
|
3. 翻译质量保证
机器翻译只是初稿——专业翻译必须人工 review。某些行业术语机器翻得很离谱。
4. RTL 语言(阿拉伯语 / 希伯来语)
不只是文字翻译——整个 UI 要从右到左。CSS 用 dir="rtl" 切换。
5. 数字 / 日期 / 货币本地化
1
2
3
4
| NumberFormat.getCurrencyInstance(locale).format(123456.78);
// zh-CN: ¥123,456.78
// en-US: $123,456.78
// de-DE: 123.456,78 €
|
不要自己写——用 ICU / java.text 标准库。
6. 时区
不同 locale 通常配不同时区——时间字段统一存 UTC,展示时按用户 timezone 转换。
十、常见踩坑
1. 把 locale 和 currency 混淆
zh-CN 是 locale,CNY 是 currency——两个完全独立的概念。中国大陆用户也可能想看 USD 报价。
2. 字典项加了但缓存没刷新
新加 dict_item 后必须主动失效缓存——否则要等 Redis TTL 到才能看到。
3. 字典 ID 和 code 都泄漏
返回前端时——只用 code,不要把 ID 暴露。后端内部关联用 ID,前端展示用 code。
4. 移动端 / Web 缓存策略不一致
移动 App 缓存了字典,Web 没缓存——改翻译后 App 用户要等很久才看到。要么 App 也定期同步,要么强制版本检查。
5. SEO 友好
国际化网站——每种语言一个 URL 路径(/zh-cn/... 和 /en-us/...)——而不是用同一个 URL 切换语言。这样搜索引擎能正确索引。
小结
把全文压一句:
后台系统的国际化不是『前端 i18n 文件』那么简单——数据字典、菜单、错误消息这些"动态文本"需要一套完整的『DB + 缓存 + 标准协议』方案。
工程要点:
- 数据字典用 i18n 副表 + 缓存
- 菜单同样的姿势
- 错误消息用 MessageSource + 占位符
- 缓存改时主动失效
- 缺翻译要监控告警
- locale ≠ currency ≠ timezone,三者独立
把这些做对,你的系统能真正承载"出海到任意国家"——而不只是"加个英文翻译"。