国际化是一个大问题,具体到我现在从事的开发工作而言,大体上会分为以下几个步骤:
- 获取待翻译字符串
- 翻译字符串
- 应用已翻译字符串
- 使用已翻译字符串
目前并不存在 Golang 的国际化最佳实践,大家都需要自己去摸索,而本文将会结合我在 qsctl 中的实践介绍 Golang 中如何做国际化,希望对读者们有所助益,少走一些弯路。首先我会介绍每个步骤需要完成的事情,然后介绍常见的 i18n 框架是如何做的,最后介绍我在 qsctl 中的做法。
步骤介绍
获取待翻译字符串
翻译的第一步是获取待翻译字符串,社区比较常见的有两种做法。
第一种是事先定义好需要翻译的字符串,通过配置文件或者 DB 等方式存储;第二种是通过某种方式从源码中获取。
这种方式的弊端很明显:开发流程不顺畅——想要加入一个字符串,需要先修改配置文件,更好一点的方法是通过某种方式从源码中获取,将翻译和开发解耦。
翻译字符串
第二步是翻译字符串。这个部分在开发上需要做的工作并不多,只需要保证以一个确定的格式存储并读取正确即可,比如 YAML,JSON 或者 PO 文件等。
通常可以使用一些 SaaS 化的服务来辅助这一工作:crowdin,onesky,localizejs,phrase,transifex,smartling 等都是可选择的项,作为开发者,尤其需要注意的是这个服务是否支持与 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_*
,LANG
,LANGUAGE
环境变量,调用 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
库提供的Sprintf
和Printf
等函数来输出需要国际化的字符串 make generate
会将这些字符串都提取到translations/en_US
目录下,以 JSON 文件的形式存储- 翻译人员通过 crowdin 进行翻译,crowdin 会自动创建翻译后的目录 (形如
translations/zh_CN
) ,并提交 PR - PR Merge 之后再次运行
make generate
会将translations
目录下的不同语言的 JSON 文件生成为对应的语言初始化函数,在i18n
的init
函数中会根据检测到的语言类型进行初始化
最后的成果:
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.