在 Golang 中如何做国际化?

国际化是一个大问题,具体到我现在从事的开发工作而言,大体上会分为以下几个步骤:

  • 获取待翻译字符串
  • 翻译字符串
  • 应用已翻译字符串
  • 使用已翻译字符串

目前并不存在 Golang 的国际化最佳实践,大家都需要自己去摸索,而本文将会结合我在 qsctl 中的实践介绍 Golang 中如何做国际化,希望对读者们有所助益,少走一些弯路。首先我会介绍每个步骤需要完成的事情,然后介绍常见的 i18n 框架是如何做的,最后介绍我在 qsctl 中的做法。


步骤介绍

获取待翻译字符串

翻译的第一步是获取待翻译字符串,社区比较常见的有两种做法。

第一种是事先定义好需要翻译的字符串,通过配置文件或者 DB 等方式存储;第二种是通过某种方式从源码中获取。

这种方式的弊端很明显:开发流程不顺畅——想要加入一个字符串,需要先修改配置文件,更好一点的方法是通过某种方式从源码中获取,将翻译和开发解耦。

翻译字符串

第二步是翻译字符串。这个部分在开发上需要做的工作并不多,只需要保证以一个确定的格式存储并读取正确即可,比如 YAML,JSON 或者 PO 文件等。

通常可以使用一些 SaaS 化的服务来辅助这一工作:crowdinoneskylocalizejsphrasetransifexsmartling 等都是可选择的项,作为开发者,尤其需要注意的是这个服务是否支持与 Github 或者 Gitlab 集成,并支持 CI 自动构建等。

应用已翻译字符串

翻译完毕之后需要应用到程序中,根据之前的技术决策不同,翻译后的字符串可能是以配置文件的形式被读取,或者是编译成二进制(比如 gettext),或者直接生成为代码等。

使用已翻译字符串

最后一步但总是被忽略的一步是使用已翻译字符串:用户究竟是什么语言?Web 应用可以根据用户传递的 Accept-Language 来确定,但是命令行应用就需要根据不同的系统来做判断了。很多框架并不关心这一问题,他们只提供了接口来使用指定的语言,gosexy/gettext 稍微好一些,会通过 LANGUAGE 来获取语言。

现有的实现

接下来我们简单的看看目前的各个 i18n 库都是怎么做的。

qor/i18n

在第一步上,它使用的是预定义方式,支持通过数据库或者本地存储来获取。

db, _ := gorm.Open("mysql", "user:password@/dbname?charset=utf8&parseTime=True&loc=Local")

I18n := i18n.New(
    database.New(&db), // load translations from the database
    yaml.New(filepath.Join(config.Root, "config/locales")), // load translations from the YAML files in directory `config/locales`
)

YAML 文件形如:

en-US:
  demo:
    hello: "Hello, world"

通过一个简短的 key 来标识不同的待翻译字符串,用起来是这种感觉:

I18n.T("en-US", "demo.greeting") // Not exist at first
I18n.T("en-US", "demo.hello") // Exists in the yml file

loctools/go-l10n

go-l10n 的做法与 qor/i18n 类似,只不过它的待翻译字符串是在源码中声明的:

locpool.Resources["en"] = map[string]string{
    // Page title
    "Hello": "Hello!",

    // {N} is the number of messages
    "YouHaveNMessages": "You have {N} {N_PLURAL:message|messages}",
}

它还设计一套特定的语法来支持复数等场景,用起来形如:

package main

import (
    "github.com/iafan/Plurr/go/plurr"
    "github.com/iafan/go-l10n/loc"
)

// Create a global localization pool which will be populated
// by resource files; use English as a default (fallback) language
var locpool = loc.NewPool("en")

func main() {
  // Get Russian localization context
  lc := locpool.GetContext("ru")

  // Get translation by key name:
  name := lc.Tr("Hello")

  // get translation by key name, then format it using Plurr:
  hello := lc.Format("YouHaveNMessages", plurr.Params{"N": 5})

  ...
}

gosexy/gettext

gosexy/gettext 的做法有些不太一样,它选择了提取所有的 gettext 函数调用中的字符串并生成 PO 文件:

fmt.Println(gettext.Gettext("Hello, world!"))

这里的 Hello, world 就会被作为 PO 文件中的 msgid 存储下来。

由于是 gettext 的 binding,它也继承了 gettext text domain 的概念,用起来稍微有些复杂:

package main

import (
	"fmt"

	"github.com/gosexy/gettext"
)

func main() {
	textDomain := "default"

	gettext.BindTextdomain(textDomain, "path/to/domains")
	gettext.Textdomain(textDomain)

	gettext.SetLocale(gettext.LcAll, "es_MX.utf8")

	fmt.Println(gettext.Gettext("Hello, world!"))
}

好处是它能使用已有一套基于 gettext 的完整生态链,包括 POEditor 之类的都能使用,各大 SaaS 平台也都有支持。

K8s 做国际化的时候就是使用了这套方案,参见:https://github.com/kubernetes/kubernetes/tree/master/translations

nicksnyder/go-i18n

这个库目前看来是 Star 数量最多的,也是运用最广泛的。它的设计同样是会提取所有特定类型的调用来生成待翻译字符串:

i18n.Message{
    ID: "PersonCats",
    One: "{{.Name}} has {{.Count}} cat.",
    Other: "{{.Name}} has {{.Count}} cats.",
}

会被提取成:

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

除此之外,它提供了一系列的工具来提取和合并,翻译一个新的语言需要做如下的事情:

  • 创建一个空的语言配置,比如 translate.es.toml

  • 将待翻译的字符串填充进去:goi18n merge active.en.toml translate.es.toml

    # translate.es.toml
    [HelloPerson]
    hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5"
    other = "Hello {{.Name}}"
    
  • 翻译完毕后重命名为 active.es.toml

    # active.es.toml
    [HelloPerson]
    hash = "sha1-5b49bfdad81fedaeefb224b0ffc2acc58b09cff5"
    other = "Hola {{.Name}}"
    
  • 在代码中载入

    bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
    bundle.LoadMessageFile("active.es.toml")
    

实际用起来的感觉是这样的:

import 
	"fmt"

	"github.com/nicksnyder/go-i18n/v2/i18n"


func main() {
	bundle := i18n.NewBundle(language.English)
    bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
	bundle.LoadMessageFile("es.toml")
    localizer := i18n.NewLocalizer(bundle, lang, accept)
    helloPersonMessage := &i18n.Message{
        ID:    "HelloPerson",
        Other: "Hello {{.Name}}!",
	}

    fmt.Println(localizer.MustLocalize(&i18n.LocalizeConfig{
        DefaultMessage: helloPersonMessage,
        TemplateData:   map[string]string{"Name": "Nick"},
    }))
}

抽象最多,功能最强,应该算是目前最好的 go-i18n 库了。

更好的方案

理想很丰满

刚才分析了 i18n 需要的各个步骤,也看了社区的一些实现,是时候想想理想中的 i18n 流程的模样了。我认为一个好的 i18n 流程应当将翻译工作和代码开发解耦,业务人员在实现逻辑的时候不需要考虑当前的语言环境,也不需要考虑这个字符串是否被翻译过,调用习惯最好与标准库接近(比如 fmt),而翻译人员在进行翻译的时候,则需要屏蔽所有的代码细节,不需要考虑这个字符串会被如何调用,不需要有任何的开发背景。那么问题来了,有没有这样一个游戏一个库呢?

现实很骨感

没有,但是我们有一个很接近的,https://golang.org/x/text : a repository of text-related packages related to internationalization (i18n) and localization (l10n)

text 包中提供的 message 库主要专注于我们上文提到的步骤三,以接近于 fmt 的接口来输出已翻译的字符串,比如:

message.SetString(language.Dutch, "You have chosen to play %m.", "U heeft ervoor gekozen om %m te spelen.")
message.SetString(language.Dutch, "basketball", "basketbal")
message.SetString(language.Dutch, "hockey", "ijshockey")
message.SetString(language.Dutch, "soccer", "voetbal")
message.SetString(language.BritishEnglish, "soccer", "football")

for _, sport := range []string{"soccer", "basketball", "hockey"} {
    for _, lang := range []string{"en", "en-GB", "nl"} {
        p := message.NewPrinter(language.Make(lang))
        fmt.Printf("%-6s %s\n", lang, p.Sprintf("You have chosen to play %m.", sport))
    }
    fmt.Println()
}

所以我们只需要想办法处理其他步骤即可。

实现总有坑

首先,提取待翻译字符串。

当初在实现的时候我忽略了 message 包提供的 gotext 工具,它支持从源码中提取所有使用 message.Printer 输出的字符串,没必要再自己重新造轮子了。

我当时的做法是创建了一个新的包叫做 i18n,在内部创建并初始化一个全局的 message.Printer ,并把 message.Printer 的方法导出为包的方法,然后在 AST 中提取所有的调用。一个比较粗糙的实现是这样的,之后应该会改成直接用 gotext

ast.Inspect(f, func(n ast.Node) bool {
    call, ok := n.(*ast.CallExpr)
    if !ok {
        return true
    }
    fn, ok := call.Fun.(*ast.SelectorExpr)
    if !ok {
        return true
    }
    pack, ok := fn.X.(*ast.Ident)
    if !ok {
        return true
    }
    if pack.Name != "i18n" {
        return true
    }
    if len(call.Args) == 0 {
        return true
    }
    str, ok := call.Args[0].(*ast.BasicLit)
    if !ok {
        return true
    }

    // Keep this for later debug usage.
    // log.Printf("%v", str.Value)
    data[str.Value] = str.Value
    return true
})

然后翻译服务使用了 crowdin,它支持与 Github 的集成,同时还为开源项目提供了慷慨的支持。

最后在应用的时候我遇到了不少的问题。

第一个问题是,Go 目前没有一个好的检测运行环境语言的库,以 Linux 为例,根据用户的发行版不同,设置语言的方式也千差万别,只是检查 LANG 或者 LANGUAGE 是不够的 ,为此我开发了 go-locale

import (
    "github.com/Xuanwo/go-locale"
)

func main() {
	tag, err := locale.Detect()
    if err != nil {
        log.Fatal(err)
    }
    // Have fun with language.Tag!
}

只需要一个简单的调用就能获得当前系统环境的对应 Language Tag。目前只支持 Linux,内部采用检查所有的 LC_*LANGLANGUAGE 环境变量,调用 locale 等多种方式来判断,后续还会支持 Windows 和 Mac 等常用操作系统。

第二个问题是 language 库的提供的 Language Match 实现很是坑爹:按照 BCP 47 的规范,zh_CN 应当被 zh_Hans 代替,但是现实是 zh_CN 已经被广泛应用于各种地方,比如 Linux 下的 locale 就是 zh_CN.UTF-8,然而使用 zh_CN 创建的 language.Tag 是既匹配不到 zh_Hans 也匹配不到 zh 的。

我也没有太好的方案,目前的做法是从 language 库的内部实现中 copy 了一个 Matcher 的实现:

func newMatcher(t []language.Tag) *matcher {
	tags := &matcher{make(map[language.Tag]int)}
	for i, tag := range t {
		ct, err := language.All.Canonicalize(tag)
		if err != nil {
			ct = tag
		}
		tags.index[ct] = i
	}
	return tags
}

type matcher struct {
	index map[language.Tag]int
}

func (m matcher) Match(want ...language.Tag) (language.Tag, int, language.Confidence) {
	for _, t := range want {
		ct, err := language.All.Canonicalize(t)
		if err != nil {
			ct = t
		}
		conf := language.Exact
		for {
			if index, ok := m.index[ct]; ok {
				return ct, index, conf
			}
			if ct == language.Und {
				break
			}
			ct = ct.Parent()
			conf = language.High
		}
	}
	return language.Und, 0, language.No
}

保证这个 Matcher 内使用的 Tag 都进行了规范化,而且总是返回我们支持的语言之一或者直接返回不支持,而不是 Tag Compose 之后的结果。

这样我们就能够按照语言来进行初始化了:

// Init will init i18n support via input language.
func Init(lang language.Tag) {
	tag, _, _ := supported.Match(lang)
	switch tag {
	case language.AmericanEnglish, language.English:
		initEnUS(lang)
	case language.SimplifiedChinese, language.Chinese:
		initZhCN(lang)
	default:
		initEnUS(lang)
	}
}

总算搞定了

上述工作做完之后,在 qsctl 想输出一个国际化字符串非常容易,只需要像使用 fmt 一样使用 i18n 库即可:

i18n.Printf("File <%s> copied to <%s>.\n", t.GetSourcePath(), t.GetDestinationPath())
  • 不需要手动载入,因为所有的字符串都已经事先生成好并在 i18n 库初始化的时候导入了
  • 不需要关心这个字符串是否被翻译以及会不会翻译,只要专注于自己的逻辑即可

在 qsctl 中 i18n 流程如下:

  • 使用 i18n 库提供的 SprintfPrintf 等函数来输出需要国际化的字符串
  • make generate 会将这些字符串都提取到 translations/en_US 目录下,以 JSON 文件的形式存储
  • 翻译人员通过 crowdin 进行翻译,crowdin 会自动创建翻译后的目录 (形如 translations/zh_CN) ,并提交 PR
  • PR Merge 之后再次运行 make generate 会将 translations 目录下的不同语言的 JSON 文件生成为对应的语言初始化函数,在 i18ninit 函数中会根据检测到的语言类型进行初始化

最后的成果:

QingStor 旗下第一款支持国际化的命令行工具诞生啦!

总结

所以在 Golang 中该如何做国际化呢?我有以下几点小小的建议:

  • 多看看 message 库,避免重复造轮子
  • 全流程自动化,不要手工维护翻译文件
  • 挑选一个合适的翻译服务

当然国际化不仅仅是将字符串本地化,其中还有货币,时间,数字本地化等内容,由于没有实践经验,我就不赘述了。

欢迎大家在评论区交流~

参考资料

  • qor/i18n: I18n is a golang implementation, provides internationalization support for your application, with different backends support
  • loctools/go-l10n: Lightweight yet powerful continuous localization solution for Go, based on Serge and Plurr.
  • gosexy/gettext: Go bindings for GNU gettext, an internationalization and localization library for writing multilingual systems.
  • nicksnyder/go-i18n: go-i18n is a Go package and a command that helps you translate Go programs into multiple languages.
  • message: Package message implements formatted I/O for localized strings with functions analogous to the fmt’s print functions. It is a drop-in replacement for fmt.