本教程面向想在 Go 项目中使用 go-i18n 这个库来实现国际化(i18n)支持的开发者。覆盖从安装、消息抽取与翻译文件管理、运行时加载、Localizer 使用、复数规则、模板变量与自定义分隔符、内嵌文件(go:embed)、到进阶主题(hash、回退与调试)的完整工作流,并配有大量示例代码与 CLI 使用示例。

概览

go-i18n 将国际化拆成两条主线:

  • 运行时库(包 i18n):负责加载翻译文件、管理消息模板、语言匹配、选择复数形式以及模板渲染。
  • 工具链(命令 goi18n):负责从源码抽取消息、生成/合并供翻译文件、序列化(toml/json/yaml)等,配合 CLDR 生成的复数规则保证各语言复数行为准确。

特点:

  • 支持 CLDR 的 200+ 语言复数规则;
  • 支持 text/template 风格的命名变量模板;
  • 支持 TOML/JSON/YAML 消息文件格式(可注册自定反序列化);
  • 提供 goi18n 工具自动抽取 Go 源代码中的 Message 字面量并生成翻译工作文件。

什么是 CLDR?

CLDR 全称是 “Unicode Common Locale Data Repository”(Unicode 通用区域设置信息库)。它是由 Unicode Consortium 维护的一个权威性数据库,收集、标准化并发布全球各语言与地区有关的本地化数据(locale data)。很多国际化/本地化工具、库和平台都以 CLDR 作为数据来源来保证本地化行为的一致性与正确性,对实现准确的国际化功能(特别是复数处理、格式化、排序等)非常关键。

安装与依赖

在项目中引入 go-i18n(v2)及常用序列化工具。

示例 go.mod 依赖:

require (
    github.com/nicksnyder/go-i18n/v2 v2.x.x
    github.com/BurntSushi/toml v0.4.1 // 如果使用 toml
    gopkg.in/yaml.v3 v3.x.x           // 如果使用 YAML
    golang.org/x/text v0.x.x         // language tag / matcher
)

安装 goi18n CLI(用于提取/合并翻译文件):

go install github.com/nicksnyder/go-i18n/v2/goi18n@latest

概念与核心类型简介

  • Bundle:运行时的核心容器。包含默认语言、已加载的消息模板、plural 规则与 language.Matcher。
  • Message:消息结构(ID、描述、Hash、Left/RightDelim、Zero/One/Two/Few/Many/Other 字段等),用于在文件里定义或作为 DefaultMessage。
  • MessageTemplate:从 Message 生成的运行时模板结构(包含模板源、编译的 template 等,便于本地化渲染)。
  • Localizer:本地化器,使用一组语言偏好(例如 URL 参数、Accept-Language)来选择合适语言并渲染消息。
  • goi18n:CLI 工具,含 extract(从源代码抽取消息)、merge(为翻译生成/合并消息文件)、marshal(序列化)等功能。
  • internal/plural:基于 CLDR 的复数规则数据与 codegen。

开始:一个完整可运行示例

示例:在运行时加载翻译文件并本地化一条消息。

package main

import (
	"fmt"
	"log"

	"github.com/nicksnyder/go-i18n/v2/i18n"
	"github.com/BurntSushi/toml"
	"golang.org/x/text/language"
)

func main() {
	// 1) 创建 Bundle(应用生命周期内使用同一个)
	bundle := i18n.NewBundle(language.English)

	// 2) 指定如何反序列化 .toml 文件(或 json/yaml)
	bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)

	// 3) 加载翻译文件(例如 active.es.toml)
	// 假设 active.es.toml 中有对 MessageID "HelloPerson" 的翻译。
    // # 文件名示例: active.es.toml
    // [HelloPerson]
    // other = "Hola {{.Name}}"
	if _, err := bundle.LoadMessageFile("active.es.toml"); err != nil {
		log.Fatalf("加载翻译文件失败: %v", err)
	}

	// 4) 创建 Localizer
	//    传入 bundle,并可提供一个或多个语言偏好(可以是 language.Tag、语言字符串或 Accept-Language header)
	//    例如只指定 "es" 表示首选西班牙语;也可以写 NewLocalizer(bundle, "es", "en-US") 表示首选 es,回退 en-US。
	localizer := i18n.NewLocalizer(bundle, "es")

	// 5) 本地化一条消息
    // 常用字段说明:
    // - MessageID: 要查找的消息 ID(优先使用已加载翻译文件中的翻译)
    // - DefaultMessage: 当 MessageID 对应的翻译不存在时作为回退(可以把默认文本放在这里)
    // - TemplateData: 传入给模板的变量(模板中用 {{.Name}}、{{.Count}} 等占位)
    // - PluralCount: 如果消息包含复数化(one/other 等),传入计数值以便根据语言规则选取正确的复数形式
    //
    // 回退逻辑:
    // 1. Localizer 用 language.Matcher 根据偏好选择最合适的已加载语言(例如 "es" -> active.es.toml)。
    // 2. 在选定语言中查找 MessageID 对应的 MessageTemplate 并选择合适的 plural 变体(如果提供 PluralCount)。
    // 3. 若选定语言没有对应翻译,则按 language.Matcher 继续回退到其他已加载的语言(例如 en-US -> en),最终回退到 Bundle 的 defaultLanguage。
    // 4. 如果所有已加载语言都没有该 MessageID,且 LocalizeConfig 提供了 DefaultMessage,则使用 DefaultMessage(并在 DefaultMessage 中再做 plural/template 渲染)。
    // 5. 如果既没有翻译也没有 DefaultMessage,则 Localize 返回错误(或使用 MustLocalize 会 panic)。
    //
    // 注意:
    // - MessageID 和 DefaultMessage 可以二选一(有 ID 的话优先用 ID 去查翻译)。
    // - TemplateData 的字段名必须与模板占位符一致(Go text/template 语法)。
    // - PluralCount 必须是数值(用来触发 plural 规则),否则会按 other 处理。
    //
	//
	// 下面示例:尝试使用 MessageID "HelloPerson"(如果 active.es.toml 有翻译则使用),
	// 并传入模板数据 Name="Alice"。
	greeting, err := localizer.Localize(&i18n.LocalizeConfig{
		MessageID: "HelloPerson", // 要查询的消息 ID(优先使用已加载针对 localizer 语言的翻译)
		DefaultMessage: &i18n.Message{ // MessageID 未命中时的回退(可选)
			ID:    "HelloPerson",
			Other: "Hello {{.Name}}", // DefaultMessage 使用相同的模板语法
		},
		TemplateData: map[string]interface{}{ // 提供给模板的数据(键名与模板变量相对应)
			"Name": "Alice",
		},
		// 没有复数情况时可以省略 PluralCount
	})
	if err != nil {
		log.Fatalf("本地化失败: %v", err)
	}
	fmt.Println(greeting) // 例如: "Hola Alice"(如果 active.es.toml 中 HelloPerson 翻译为 "Hola {{.Name}}")

	// 复数示例
	// 假设消息文件包含:
	// [PersonCats]
	// one = "{{.Name}} tiene {{.Count}} gato."
	// other = "{{.Name}} tiene {{.Count}} gatos."
	//
	// 当 Count==1 时 CLDR 规则选择 "one",当 Count==2 时选择 "other"(语言差异化由 CLDR 控制)
	s, err := localizer.Localize(&i18n.LocalizeConfig{
		MessageID: "PersonCats",
		TemplateData: map[string]interface{}{
			"Name":  "Carlos",
			"Count": 2, // 这是模板中显示的变量
		},
		PluralCount: 2, // 这是用于决定复数形式的计数(必须传入以触发复数选择)
	})
	if err != nil {
		log.Fatalf("复数本地化失败: %v", err)
	}
	fmt.Println(s) // 例如: "Carlos tiene 2 gatos."

    // 如果你确定消息存在或希望在错误时立即失败,可以使用 MustLocalize。
    mustResult := localizer.MustLocalize(&i18n.LocalizeConfig{
        MessageID: "HelloPerson",
        TemplateData: map[string]interface{}{
            "Name": "Bob",
        },
    })
    fmt.Println("MustLocalize result:", mustResult)
}

消息文件格式(toml/json/yaml)

消息文件命名约定:

  • 格式部分:文件名最后一个 . 之后的部分(例如 .toml.json.yaml)。
  • 语言 tag:倒数第二个 . 之后到文件格式部分之前(例如 active.en.tomlen.toml)。

支持两种主要表示法:

1. 简洁形式(当源语言且只有 other 字段时):

# active.en.toml
[HelloPerson]
other = "Hello {{.Name}}"

或更简洁(源语言并且只有 other):

[HelloPerson]
other = "Hello {{.Name}}"
# 有时也支持直接写成:
# HelloPerson = "Hello {{.Name}}"

2. 完整对象形式(含 description、hash、复数各形态、定界符等):

[PersonCats]
description = "Number of cats a person has"
one = "{{.Name}} has {{.Count}} cat."
other = "{{.Name}} has {{.Count}} cats."

JSON 示例:

{
  "HelloPerson": {
    "other": "Hello {{.Name}}"
  }
}

注意:

  • 支持字段:id(通常由方括号 key 表示)、description、hash、leftdelim、rightdelim、zero、one、two、few、many、other。
  • 在源语言文件里,goi18n 的 marshal 会把只有 other 且没有描述、没有自定义 delimiter 的消息写成简洁字符串,以便更易读。

模板与变量、模板定界符

go-i18n 使用 Go 标准库的 text/template 风格语法(默认 {{}})来渲染模板变量。

示例:

[HelloPerson]
other = "Hello {{.Name}}, you have {{.Count}} messages."

在代码中传入模板数据:

localizer.Localize(&i18n.LocalizeConfig{
    MessageID: "HelloPerson",
    TemplateData: map[string]interface{}{
        "Name": "Bob",
        "Count": 5,
    },
})

自定义模板分隔符(如果后端模板冲突或想用别的分隔符):

  • 在 Message 里或消息文件里设置 leftdelim / rightdelim:
    [CustomDelim]
    leftdelim = "{%"
    rightdelim = "%}"
    other = "{% .Name %} has {% .Count %} items."
    
  • 当 MessageTemplate 有 LeftDelim/RightDelim 时,库在渲染时会使用这些定界符创建模板。

复数(Plural)支持

go-i18n 支持 CLDR 中的 plural 分类(zero/one/two/few/many/other),并通过 internal/plural 的规则在运行时为每个语言选择正确的形式。

示例消息(支持 plural forms):

[PersonCats]
description = "The number of cats a person has"
one = "{{.Name}} has {{.Count}} cat."
other = "{{.Name}} has {{.Count}} cats."

本地化时传入 PluralCount 来选择复数形式:

s, err := localizer.Localize(&i18n.LocalizeConfig{
    MessageID: "PersonCats",
    TemplateData: map[string]interface{}{
        "Name": "Nick",
        "Count": 2,
    },
    PluralCount: 2, // 标识复数计数,复数规则由语言决定
})
// s => "Nick has 2 cats."(假设翻译对应 English 规则)

注意:

  • PluralCount 不必是整数类型(会被识别为数值),但是通常传入整数更直观。
  • CLDR 复数规则覆盖 200+ 语言,保证在各语言下的“1 条 vs 多条”等差异被正确处理。

使用 Localizer:HTTP 示例(Accept-Language)

在 Web 应用常见场景中,你会根据用户的请求偏好(表单参数或 Accept-Language header)创建 Localizer:

func handler(w http.ResponseWriter, r *http.Request) {
    // 假设 bundle 已在程序启动时创建并加载了消息文件
    // lang 可通过 ?lang=xx 明确覆盖
    lang := r.FormValue("lang")
    accept := r.Header.Get("Accept-Language")

    // NewLocalizer 接受 bundle 和一系列语言偏好参数(字符串或 language.Tag)
    localizer := i18n.NewLocalizer(bundle, lang, accept)

    message, err := localizer.Localize(&i18n.LocalizeConfig{
        MessageID: "PersonCats",
        TemplateData: map[string]interface{}{
            "Name": "Maria",
            "Count": 1,
        },
        PluralCount: 1,
    })
    if err != nil {
        http.Error(w, "i18n error", http.StatusInternalServerError)
        return
    }
    fmt.Fprintln(w, message)
}

语言匹配策略:

  • Bundle 维护一组已加载语言 tag,并使用 golang.org/x/text/language.Matcher 进行标准化匹配(确保 Accept-Language 与已加载的翻译之间的匹配符合预期,例如 en-US 回退到 en 等)。

使用 go:embed 加载消息文件(Go 1.16+)

如果希望把消息文件打包到二进制中,使用 go:embed:

import (
    _ "embed"
    "embed"

    "github.com/nicksnyder/go-i18n/v2/i18n"
    "github.com/BurntSushi/toml"
    "golang.org/x/text/language"
)

//go:embed locales/active.*.toml
var localesFS embed.FS

func main() {
    bundle := i18n.NewBundle(language.English)
    bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)

    // LoadMessageFileFS 会读取 embed.FS 中的文件(API 在 README 有示例)
    // 例如同目录下有 locales/active.es.toml
    if _, err := bundle.LoadMessageFileFS(localesFS, "locales/active.es.toml"); err != nil {
        log.Fatal(err)
    }

    // ...
}

注意:如果你使用的是 goi18n 的 marshal 输出,确保 embed 的路径和文件命名保持与 ParseMessageFileBytes 的解析规则一致。

goi18n CLI:extract / merge 使用示例

安装 goi18n CLI:

go install github.com/nicksnyder/go-i18n/v2/goi18n@latest

约定与注意:

  • 源语言文件通常命名为 active.<tag>.<format>,例如 active.en.toml(这是常用习惯,goi18n 期望倒数第二段为语言 tag)。
  • 翻译人员工作文件通常命名为 translate.<tag>.<format>,例如 translate.es.toml
  • 支持的格式:toml / json / yaml(要在运行时注册相应的 UnmarshalFunc)。
  • 在代码中使用 i18n.Message 字面量(例如 &i18n.Message{ ID:"X", Other:"..."})或 DefaultMessage 写在 LocalizeConfig 里,便于 goi18n extract 自动抽取。
  • extract 会忽略 *_test.go 文件,并且只会抽取那些导入了 “github.com/nicksnyder/go-i18n/v2/i18n” 的源码文件里以字面量形式出现的 Message。

1. 提取源码中的消息(生成源语言 active 文件)

将会扫描 Go 源文件的 AST,提取 i18n.Message 结构字面量并生成一个源消息文件(例如 active.en.toml)。

示例:

var PersonCats = &i18n.Message{
    ID:          "PersonCats",
    Description: "The number of cats a person has",
    One:         "{{.Name}} has {{.Count}} cat.",
    Other:       "{{.Name}} has {{.Count}} cats.",
}
# 在项目根运行(默认 sourceLanguage=en, format=toml)
goi18n extract -sourceLanguage en -outdir locales -format toml ./...

extract 会扫描项目(此处为 ./...)的 .go 文件,找到 i18n.Message 字面量并合并成一个消息文件写入指定目录(-outdir)。

输出示例 locales/active.en.toml

[PersonCats]
description = "The number of cats a person has"
one = "{{.Name}} has {{.Count}} cat."
other = "{{.Name}} has {{.Count}} cats."

你在源码中新增了消息或修改了原文需要重新 extract(更新源语言 active 文件)

2. 为翻译生成占位文件(merge)

用于把 source 消息合并到 translate.xx.toml 中,并添加 hash 字段以标记源内容(便于检测源变更)。

示例:

goi18n merge locales/active.en.toml locales/translate.es.toml

生成或更新 locales/translate.es.toml

[PersonCats]
hash = "sha1-..."
one = "{{.Name}} has {{.Count}} cat."
other = "{{.Name}} has {{.Count}} cats."

翻译人员将 translate.es.toml 翻译后重命名为 active.es.toml 并放入项目中供运行时加载。

3. 常用选项示例

  • 输出格式:-format toml|json|yaml
  • 指定输出目录:-outdir locales
  • 抽取指定路径:goi18n extract ./cmd ./pkg

翻译工作流(hash 与更新)

  • goi18n 的 merge 会为每条消息生成一个 hash(sha1),基于 message description 与 source other 字段(如 merge 中的实现)。
  • 当源消息文本发生改变时,hash 会变化,merge 在下次运行时会把更新后的源推送到 translate.*.toml,并新增/更新 hash,提示翻译人员需要重新翻译。

推荐流程:

  1. add/modify 源代码的 Message 字面量 → run goi18n extract 更新 source (active.en.toml)。
  2. run goi18n merge active.en.toml translate.*.toml 更新翻译占位文件(翻译人员收到更新标识)。
  3. 翻译人员翻译后把 translate.xx.toml 重命名为 active.xx.toml,或提交到翻译仓库。
  4. 程序启动时加载 active.xx.toml

测试、本地调试与常见问题

单元测试中使用 Bundle 与 Localizer:

func TestGreeting(t *testing.T) {
    bundle := i18n.NewBundle(language.English)
    bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
    bundle.MustLoadMessageFile("testdata/active.es.toml")
    localizer := i18n.NewLocalizer(bundle, "es")
    got, err := localizer.Localize(&i18n.LocalizeConfig{
        MessageID: "HelloPerson",
        TemplateData: map[string]interface{}{"Name": "Test"},
    })
    if err != nil {
        t.Fatalf("localize error: %v", err)
    }
    if got != "Hola Test" {
        t.Errorf("unexpected: %s", got)
    }
}

常见问题:

  • “找不到翻译”:检查文件命名规则(例如 active.es.toml),以及 bundle 是否注册了对应 format 的 UnmarshalFunc。
  • “模板渲染失败”:检查 TemplateData 是否包含模板中引用的字段名、或自定义定界符是否设置正确。
  • “复数选择不对”:确认 PluralCount 是否传入,且翻译文件包含对应复数形态(one/few/other 等)。不同语言的规则在 CLDR 中差异很大。
  • “并发”问题:Bundle 在运行时用于 Localizer 可并发读取,但修改 Bundle(如动态添加消息)在并发读取时可能不安全——建议应用启动时一次性加载完所有消息。

进阶指南

1. 回退策略(Fallback)

Bundle 的 defaultLanguage 用于最后回退。Localizer 会依照 language.Matcher 选择最佳已加载 language tag;若无翻译,Localizer 可以使用 DefaultMessage 字段(LocalizeConfig)作为回退。

示例:DefaultMessage 用法:

localizer.Localize(&i18n.LocalizeConfig{
    MessageID: "MissingID",
    DefaultMessage: &i18n.Message{
        ID: "MissingID",
        Other: "Default fallback message for {{.Name}}",
    },
    TemplateData: map[string]interface{}{"Name": "Bob"},
})

2. 自定义复数规则

默认使用 internal/plural 中的 CLDR 规则。如果需要覆盖或添加自定义规则(极少见),可以在创建 Bundle 后调整 bundle.pluralRules,或提供自定义 Rules 实现(需熟悉 internal/plural 的类型签名与规则接口)。

3. 性能与并发

解析模板(parse/compile)是有成本的:把模板文本(例如 {{.Name}} has {{.Count}} cats.)解析成模板对象(*template.Template)需要做字符串解析、AST 构建、可能还要绑定 template.Funcs 等,这一步比“渲染”更重。

渲染模板(execute)也有开销(分配 buffer、格式化变量等),但通常低于反复解析。

为了性能,go-i18n 会在创建 MessageTemplate(即 NewMessageTemplate)时把模板解析/编译一次并缓存,后续 Localize 请求直接重用已编译模板,这样每次请求都只做渲染而不用重复解析。

在高并发场景下,最佳实践是:在程序启动时使用 bundle 一次性加载所有 active.*.toml,这会把消息文件解析成 Message 并构建 MessageTemplate(NewMessageTemplate),从而在加载阶段完成模板解析工作。每次处理请求时,只创建 Localizer 并渲染。

不要在每个请求内重新加载文件或重新解析模板;也不要在请求处理路径频繁修改同一个 Bundle(这在库里不是并发安全的)。如果需要动态更新翻译(热加载),使用“原子替换整个 bundle”或用合适的锁/原子包装,避免在被并发读取时修改内部状态。

常用代码片段(速查)

创建 bundle 并注册 toml:

bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)

加载消息文件(磁盘与 embed):

bundle.LoadMessageFile("locales/active.es.toml")
// or embed FS:
bundle.LoadMessageFileFS(embedFS, "locales/active.es.toml")

新增消息(程序化):

bundle.AddMessages(language.Spanish, &i18n.Message{
    ID: "SayHi",
    Other: "Hola {{.Name}}",
})

创建 Localizer 并 localize:

localizer := i18n.NewLocalizer(bundle, "es", "en-US;q=0.8")
s, err := localizer.Localize(&i18n.LocalizeConfig{
    MessageID: "PersonCats",
    TemplateData: map[string]interface{}{"Name": "Ana", "Count": 3},
    PluralCount: 3,
})

使用 DefaultMessage 回退:

s, err := localizer.Localize(&i18n.LocalizeConfig{
    MessageID: "Unknown",
    DefaultMessage: &i18n.Message{
        ID: "Unknown",
        Other: "Default Hello {{.Name}}",
    },
    TemplateData: map[string]interface{}{"Name": "Joe"},
})

调试与排错建议

  • 打开日志或在本地临时输出 bundle.LanguageTags() 查看已加载的语言 tags,确认语言是否被正确注册。
  • 如果消息未被加载,检查文件名(tag 部分)、注册的 UnmarshalFunc 是否匹配文件格式后缀。
  • 使用 localizer.Localize 返回的 error 信息进行定位(缺少 template data、模板语法错误、缺少 plural form 等)。
  • 当翻译未更新时,确认 merge 过程中 hash 是否发生变化以及翻译人员是否把 translate.xx.toml 重命名为 active.xx.toml

参考资料


也可以看看