go-i18n/v2/i18n是目前最稳的落地选择,因其支持按请求动态切换语言、热重载、cldr规则及严格资源校验;而golang.org/x/text/message不支持运行时切换,仅适用于编译期格式化。

go-i18n/v2/i18n 是目前最稳的落地选择,别碰 golang.org/x/text/message 做动态语言切换——它不支持运行时改语言,硬上只会输出错语言还查不出原因。
Accept-Language 解析必须用 language.ParseAcceptLanguage + matcher.Match
浏览器发来的 Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8 不是简单逗号分隔字符串,q 值决定优先级,且可能含非法标签(如 xx-YY)或空格。直接 strings.Split(..., ",")[0] 会掉坑里。
正确做法:
- 用
language.ParseAcceptLanguage(r.Header.Get("Accept-Language"))解析,返回已排序、过滤后的[]language.Tag - 预定义白名单:
supported := []language.Tag{language.Chinese, language.English} - 初始化一次 matcher:
matcher := language.NewMatcher(supported) - 匹配请求 tag:
matched, _ := matcher.Match(acceptTags)—— 返回归一化后的 tag,比如zh-Hans,不是zh-CN - 这个
matched才能安全传给bundle.NewLocalizer,否则加载active.zh-CN.json会静默失败
JSON 文件名和结构必须严格符合 BCP 47 规范
go-i18n/v2 对文件名和 JSON 结构极其敏感,错一个字符就静默失败(返回空字符串或原始 key,不 panic,极难排查)。
关键约束:
- 文件名必须是
active.zh-Hans.json(不是zh.json、zh_Hans.json、zh-hans.json;大小写、连字符、顺序都必须合规) - 路径要对:若用
os.DirFS("./locales"),文件必须在./locales/active.zh-Hans.json - JSON 内容外层必须是对象,每个 key 对应 message ID,value 必须是
{"description": "...", "translation": "..."} - 不能简写成
{"login.title": "登录"}—— 缺description字段也会静默失败 - 只加载
active.*.json,inactive.*.json被忽略
Bundle 全局单例,Localizer 每请求新建
*i18n.Bundle 是线程安全的,负责管理所有语言资源,应该全局初始化一次;而 *i18n.Localizer 是轻量、非线程安全、按需绑定语言的实例,必须每个 HTTP 请求生成一个。
典型错误:
- 在中间件里复用同一个
*i18n.Localizer实例 → 并发请求语言偏好互相覆盖 - 每次请求都调用
bundle.LoadMessageFile(...)→ I/O 和解析开销巨大,且可能触发重复注册 - 在
init()中加载资源 → 微服务无法根据配置动态切换根路径(如 Docker 镜像中路径不同)
推荐姿势:
- 启动时用
bundle.LoadMessageFileFS(localeFS, "active.en.json")加载全部支持语言文件 - 中间件中解析出
matched后,调用i18n.NewLocalizer(bundle, matched.String())创建新实例 - 把 localizer 或封装好的
T函数注入到 request context 或框架上下文(如 Gin 的c.Set("T", tFunc))
微服务间语言上下文必须透传
单体应用只需处理 HTTP header,但微服务架构下,语言偏好必须跨服务传递,否则下游服务默认 fallback 到英文,用户看到混合语言界面。
透传方式取决于通信协议:
- HTTP 服务间:统一加
X-Request-Language: zh-Hansheader - gRPC 服务间:通过
metadata.MD注入键值对,如metadata.Pairs("lang", "zh-Hans") - 消息队列(如 Kafka):将语言标识作为消息 payload 的字段,或额外 headers
- 所有下游服务必须在入口处重新走一遍 matcher 匹配逻辑,不能直接信任上游传来的 lang 字符串
最容易被忽略的是:matcher 白名单必须在每个服务独立维护并保持一致,否则同一 zh-CN 请求在不同服务里可能匹配出 zh-Hans 和 zh,导致翻译不一致。











