用Go一步一步教你国际化和本地化[译]

本文翻译自A Step-by-Step Guide to Go Internationalization (i18n) & Localization (l10n)

Alt 用Go一步一步教你国际化和本地化
Go作为一门静态编译的语言,由于其简单、高性能以及特别适合云服务的开发等特性获得了很多的关注。Go有一个用来处理国际化(i18n)和本地化(l10n)的库,诸如:字符编码,文本转换,特定地区的文本处理,它有很强大的功能,但是文档化却很少。这篇文章通过创建一个本地化的Go应用来帮助我们理解该库。

我们所指的包是golang.org/x/text,如果你使用得当,它将在你全球化应用时提供很多方面的帮助。它附带了一组抽象的集合,让我们更容易的使用可翻译的信息,格式化,负数规则,Unicode等等。

这篇文章将由两部分组成。第一部分概述golang.org/x/text包以及其在格式化和本地化方面提供的公共函数。由于Go特别擅长于搭建微服务架构,所以在第二部分,我们将通过构建一个本地化的微服务来帮助理解GO对i18n和l10n支持的总体情况。

在该教程中,我使用的Go版本是1.10,同时该教程编写的代码都放在Gihub上。

让我们开始咯!!

包概述

Go程序里面的信息大部分是通过fmt或者是模板包进行输出的。golang.org/x/text包由多个级别的子包组成,它们提供了大量的类似fmt风格的公共函数来格式化字符串。下面让我们看看如何使用它们。

Messages和Catalogs

Messages就是用来传递给用户的某些形式的内容。每个Messge都由一个key定义,一个key对应很多中形式的Message。你可以像下面那样创建一个Message Printer:

1
2
p := message.NewPrinter(language.BritishEnglish)
p.Printf("There are %v flowers in our garden.", 1500)

在你调用NewPrinter函数的时候,你需要提供一个语言标签(Language Tag)。当你想要指定一种语言的时候,你就需要用到语言标签(Language Tag)。有很多中方法能让你创建标签,如下:

  • 使用预定于标签。如:
    1
    language.Greek, language.BrazilianPortuguese

预定义标签列表请看这里

  • 使用字符串。如:

    1
    language.Make("el"), language.Parse("en-UK")
  • 通过组合类型:Tag, Base, Script, Region, Variant, []Variant, Extension, []Extension或者error。如:

    1
    2
    3
    4
    ja, _ := language.ParseBase("ja")
    jp, _ := language.ParseRegion("JP")
    jpLngTag, _ := language.Compose(ja, jp)
    fmt.Println(jpLngTag) // prints ja-JP

如果指定了非法的语言标签(Language Tag),将得到一个用来表示未定义标签的Und对象。

1
fmt.Println(language.Compose(language.ParseRegion(AL))) // prints Und-AL

想了解更多的语言API,请看移步这里
回到我们刚才的Message,我们指定了一个不同的语言并且将格式化后的字符串打印出来。语言库将很好的为你格式化任何需要本地化的变体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main

import (
"golang.org/x/text/message"
"golang.org/x/text/language"
)

func main() {
p := message.NewPrinter(language.BritishEnglish)
p.Printf("There are %v flowers in our garden.\n", 1500)

p = message.NewPrinter(language.Greek)
p.Printf("There are %v flowers in our garden.\n", 1500)
}

运行该程序,你将会得到下面的输出:

1
2
3
-> go run main.go
There are **1,500** flowers in our garden.
There are **1.500** flowers in our garden.

现在为了打印出被翻译的Messages,我们需要将它们加入到Message catalog当中去,这样Printer就能正确的找到对应语言标签(language tag)。
一个Catalog就是一组翻译好并格式化好的字符串的集合。你可以把它想象成是每种语言的翻译字典的目录索引。为了能够使用catalogs,我们需要用翻译好的文字来填充它们。
实际上,翻译将被自动从一个翻译提供的数据源注入(In practice, translations will be automatically injected from a translator-supplied data source)。让我们看看怎么手动的实现它:

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
27
28
package main

import (
"golang.org/x/text/message"
"golang.org/x/text/language"
"fmt"
)

func init() {
message.SetString(language.Greek, "%s went to %s.", "%s πήγε στήν %s.")
message.SetString(language.AmericanEnglish, "%s went to %s.", "%s is in %s.")
message.SetString(language.Greek, "%s has been stolen.", "%s κλάπηκε.")
message.SetString(language.AmericanEnglish, "%s has been stolen.", "%s has been stolen.")
message.SetString(language.Greek, "How are you?", "Πώς είστε?.")
}

func main() {
p := message.NewPrinter(language.Greek)
p.Printf("%s went to %s.", "Ο Πέτρος", "Αγγλία")
fmt.Println()
p.Printf("%s has been stolen.", "Η πέτρα")
fmt.Println()

p = message.NewPrinter(language.AmericanEnglish)
p.Printf("%s went to %s.", "Peter", "England")
fmt.Println()
p.Printf("%s has been stolen.", "The Gem")
}

执行以上程序,你将得到下面的输入:

1
2
3
4
5
-> go run main.go
Ο Πέτρος πήγε στήν Αγγλία.
Η πέτρα κλάπηκε.
Peter is in England.
The Gem has been stolen.%

警示:传入SetString方法的参数是大小写和换行敏感的。这意味着你不能用Println函数并且不能在输出语句的末尾加上\n

1
2
3
4
p := message.NewPrinter(language.Greek)

p.Printf("%s went to %s.\n", "Ο Πέτρος", "Αγγλία") // will print Ο Πέτρος went to Αγγλία.
p.Println("How are you?") // will print How are you?

通常你不会创建catalogs,而是让这个库帮你处理。你还可以使用catalog.Builder函数去构建。

复数的处理

有些时候,当你需要基于复数值去添加多个字符串翻译,你需要在翻译的catalogs里调用指定的配置。子包golang.org/x/text/feature/plural 暴露一个叫SelectF的函数用于在一个文本中定义多种语言的复数形式。
下面我给出一些这个函数的一些典型用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func init() {
message.Set(language.Greek, "You have %d. problem",
plural.Selectf(1, "%d", "=1", "Έχεις ένα πρόβλημα", "=2", "Έχεις %[1]d πρόβληματα", "other", "Έχεις πολλά πρόβληματα",))
message.Set(language.Greek, "You have %d days remaining", plural.Selectf(1, "%d", "one", "Έχεις μία μέρα ελεύθερη", "other", "Έχεις %[1]d μέρες ελεύθερες",))
}

func main() {
p := message.NewPrinter(language.Greek)
p.Printf("You have %d. problem", 1)
fmt.Println()
p.Printf("You have %d. problem", 2)
fmt.Println()
p.Printf("You have %d. problem", 5)
fmt.Println()
p.Printf("You have %d days remaining", 1)
fmt.Println()
p.Printf("You have %d days remaining", 10)
fmt.Println()
}

运行该程序,你会得到一下输出:

1
2
3
4
5
6
go run main.go
Έχεις ένα πρόβλημα
Έχεις 2 πρόβληματα
Έχεις πολλά πρόβληματα
Έχεις μία μέρα ελεύθερη
Έχεις 10 μέρες ελεύθερες

在这个函数中提供的例子可以做一些如: zero,one,two,few,many等的变化,同时它还支持>x或者<x的比较

Message中的字符串插入

在一些情况下,你想要处理一些Message的更进一步的可能的变体, 你可以指派一些能够处理指定语言特征的占位符。比如,上一个例子我们使用的复数可以写成下面那样:

1
2
3
4
5
6
7
8
9
10
11
12
13
func init() {
message.Set(language.Greek, "You are %d minute(s) late.",
catalog.Var("minutes", plural.Selectf(1, "%d", "one", "λεπτό", "other", "λεπτά")),
catalog.String("Αργήσατε %[1]d ${minutes}."))
}

func main() {
p := message.NewPrinter(language.Greek)
p.Printf("You are %d minute(s) late.", 1) // prints Αργήσατε 1 λεπτό
fmt.Println()
p.Printf("You are %d minute(s) late.", 10)// prints Αργήσατε 10 λεπτά
fmt.Println()
}

catalog.Var指定了一个特定的标签minutes作为第一个字符串参数,这样它就可以根据参数%d的值来替换一个更为贴切的翻译。

Formatting Currency

golang.org/x/text/currency包用来处理货币的格式化规则。
这个包有一些有用的函数可以用来打印本地化指定的关于货币总额的字符串。这里有一些关于如何格式化的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"golang.org/x/text/message"
"golang.org/x/text/language"
"fmt"
"golang.org/x/text/currency"
)

func main() {
p := message.NewPrinter(language.English)
p.Printf("%d", currency.Symbol(currency.USD.Amount(0.1)))
fmt.Println()
p.Printf("%d", currency.NarrowSymbol(currency.JPY.Amount(1.6)))
fmt.Println()
p.Printf("%d", currency.ISO.Kind(currency.Cash)(currency.EUR.Amount(12.255)))
fmt.Println()
}

执行的结果是:

1
2
3
4
➜ go run main.go
US$ 0.10
¥ 2
EUR 12.26

Loading Messages

当你使用翻译时,通常你需要先加载翻译,这样应用才可以使用它们。你可以认为这是一些静态的资源。在你的有用中,有几个可选的方式用来部署。

手动设置翻译字符串

最简单的组织翻译的方式是将它们打包进应用的二进制包里。你必须创建一个词目的数组用于在初始化的时候被加载进默认的catalog里。然后在你的应用中,你只需要通过NewPrinter函数选择本地化就可以了。
请看下面的例子:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package main

import (
"golang.org/x/text/language"
"golang.org/x/text/feature/plural"
"golang.org/x/text/message"
"golang.org/x/text/message/catalog"
)

type entry struct {
tag, key string
msg interface{}
}

var entries = [...]entry{
{"en", "Hello World", "Hello World"},
{"el", "Hello World", "Για Σου Κόσμε"},
{"en", "%d task(s) remaining!", plural.Selectf(1, "%d",
"=1", "One task remaining!",
"=2", "Two tasks remaining!",
"other", "[1]d tasks remaining!",
)},
{"el", "%d task(s) remaining!", plural.Selectf(1, "%d",
"=1", "Μία εργασία έμεινε!",
"=2", "Μια-δυο εργασίες έμειναν!",
"other", "[1]d εργασίες έμειναν!",
)},
}

func init() {
for _, e := range entries {
tag := language.MustParse(e.tag)
switch msg := e.msg.(type) {
case string:
message.SetString(tag, e.key, msg)
case catalog.Message:
message.Set(tag, e.key, msg)
case []catalog.Message:
message.Set(tag, e.key, msg...)
}
}
}

func main() {
p := message.NewPrinter(language.Greek)
p.Printf("Hello World")
p.Println()
p.Printf("%d task(s) remaining!", 2)
p.Println()

p = message.NewPrinter(language.English)
p.Printf("Hello World")
p.Println()
p.Printf("%d task(s) remaining!", 2)
}

运行起来将得到一下输出:

1
2
3
4
5
$ go run examples/static/main.go         
Για Σου Κόσμε
Μια-δυο εργασίες έμειναν!
Hello World
Two tasks remaining!%

事实上,虽然这种方法很容易实现,但却不够具有可扩展性,它只适合一些需要少量翻译的小项目。这种方式你必须手动设置翻译的字符串,并且它很难自动化。基于另外一些原因,我强烈推荐使用自动化加载消息的方式,这在下面我会介绍一些细节。

自动化加载Message

一般来说,大部分的具有本地化功能的框架都有分组好的多语言自动化加载的文件。你可以对这些文件分开翻译,然后将它们合并到你的项目中。
为了提供帮助,golang的作者提供了一个叫gotext的CLI的助手工具,它可以用来管理Go源码里面的文本。

先让我们确定你拥有最新的版本:

1
$ go get -u golang.org/x/text/cmd/gotext

直接运行gotext你会看到一些参数说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
➜ gotext
gotext is a tool for managing text in Go source code.

Usage:

gotext command [arguments]

The commands are:

update merge translations and generate catalog
extract extracts strings to be translated from code
rewrite rewrites fmt functions to use a message Printer
generate generates code to insert translated messages

出于本文的目的,我们使用update标志,它将执行多个步骤用于提取文件中的翻译key并为加载进入catalogs更新代码,以便更好的使用。

创建一个main.go文件并添加一个Printf函数的调用,同时确保你的备注里有go:generate命令:

1
$ touch main.go

File:main.go

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

//go:generate gotext -srclang=en update -out=catalog/catalog.go -lang=en,el

import (
"golang.org/x/text/language"
"golang.org/x/text/message"
)

func main() {
p := message.NewPrinter(language.Greek)
p.Printf("Hello world!")
p.Println()

p.Printf("Hello", "world!")
p.Println()

person := "Alex"
place := "Utah"

p.Printf("Hello ", person, " in ", place, "!")
p.Println()

// Greet everyone.
p.Printf("Hello world!")
p.Println()

city := "Munich"
p.Printf("Hello %s!", city)
p.Println()

// Person visiting a place.
p.Printf("%s is visiting %s!",
person,
place)
p.Println()

// Double arguments.
miles := 1.2345
p.Printf("%.2[1]f miles traveled (%[1]f)", miles)
}

执行下面的命令:

1
2
$ mkdir catalog
$ go generate

然后在import包含catalog.go文件:
File:main.go

1
2
3
4
5
6
7
8
9
10
11
package main

//go:generate gotext -srclang=en update -out=catalog/catalog.go -lang=en,el

import (
"golang.org/x/text/language"
"golang.org/x/text/message"
_ "golang.org/x/text/message/catalog"
)

...

现在你可以在你的项目结构中看到生成的一些文件:

1
2
3
4
5
6
7
8
9
10
$ tree .
.
├── catalog
│ └── catalog.go
├── locales
│ ├── el
│ │ └── out.gotext.json
│ └── en
│ └── out.gotext.json
├── main.go

locales文件夹包含了这个库支持的,格式化好的翻译的Message。通常你需要自己创建一个名为messages.gotext.json的文件,并在里面翻译Greek语言。

1
$ touch locales/el/messages.gotext.json

File:locales/el/messages.gotext.json

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
{
"language": "el",
"messages": [
{
"id": "Hello world!",
"message": "Hello world!",
"translation": "Γιά σου Κόσμε!"
},
{
"id": "Hello",
"message": "Hello",
"translation": "Γιά σας %[1]v",
"placeholders": [
{
"id": "World",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "\"world!\""
}
]
},
{
"id": "Hello {City}!",
"message": "Hello {City}!",
"translation": "Γιά σου %[1]s",
"placeholders": [
{
"id": "City",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "city"
}
]
},
{
"id": "{Person} is visiting {Place}!",
"message": "{Person} is visiting {Place}!",
"translation": "Ο %[1]s επισκέπτεται την %[2]s",
"placeholders": [
{
"id": "Person",
"string": "%[1]s",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "person"
},
{
"id": "Place",
"string": "%[2]s",
"type": "string",
"underlyingType": "string",
"argNum": 2,
"expr": "place"
}
]
},
{
"id": "{Miles} miles traveled ({Miles_1})",
"message": "{Miles} miles traveled ({Miles_1})",
"translation": "%.2[1]f μίλια ταξίδεψε %[1]f",
"placeholders": [
{
"id": "Miles",
"string": "%.2[1]f",
"type": "float64",
"underlyingType": "float64",
"argNum": 1,
"expr": "miles"
},
{
"id": "Miles_1",
"string": "%[1]f",
"type": "float64",
"underlyingType": "float64",
"argNum": 1,
"expr": "miles"
}
]
}
]
}

现在执行go generate和接下去执行该程序,看看发生了什么:

1
2
3
4
5
6
7
8
9
$ go generate
$ go run main.go
Γιά σου Κόσμε!
Γιά σας world!

Γιά σου Κόσμε!
Γιά σου Munich
Ο Alex επισκέπτεται την Utah
1,23 μίλια ταξίδεψε 1,234500%

如果你感兴趣,你可以使用rewrite参数去搜索源码里的fmt引用并用p.Print函数替换。举个例子,让我们看看下面的代码:

1
2
3
4
5
6
func main() {
p := message.NewPrinter(language.German)
fmt.Println("Hello world")
fmt.Printf("Hello world!")
p.Printf("Hello world!\n")
}

你可以使用下面的命令:

1
gotext rewrite -out main.go

然后看看main.go的内容:

1
2
3
4
5
6
func main() {
p := message.NewPrinter(language.German)
p.Printf("Hello world\n")
p.Printf("Hello world!")
p.Printf("Hello world!\n")
}

微服务例子

这是文章的第二部分,我们将利用学到的关于golang.org/x/text包的知识进行实践。我们将创建一个简单的HTTP服务,它接收一个用户的语言参数,然后服务会尝试去匹配该参数对应的语言文件,然后响应一个合适的本地化的翻译。
首先,确定你安装了所有的依赖:

1
2
3
$ go get -u github.com/golang/dep/cmd/dep
$ dep init
$ touch main.go

File:main.go

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
27
28
29
30
31
32
33
package main

import (
"html"
"log"
"net/http"
"fmt"
"flag"
"time"
)

const (
httpPort = "8090"
)

func PrintMessage(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s", html.EscapeString(r.Host))
}

func main() {
var port string
flag.StringVar(&port, "port", httpPort, "http port")
flag.Parse()

server := &http.Server{
Addr: ":" + port,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: http.HandlerFunc(PrintMessage)}

log.Fatal(server.ListenAndServe())
}

这个http服务还没有处理翻译。我们使用p.Fprintf函数替换fmt.Fprintf

1
2
3
4
func PrintMessage(w http.ResponseWriter, r *http.Request) {
p := message.NewPrinter(language.English)
p.Fprintf(w,"Hello, %v", html.EscapeString(r.Host))
}

添加下面这句注释,然后运行go generate命令

1
//go:generate gotext -srclang=en update -out=catalog/catalog.go -lang=en,el

1
2
3
$ dep ensure -update
$ go generate
el: Missing entry for "Hello, {Host}".

拷贝缺失的文件:

1
$ cp locales/el/out.gotext.json locales/el/messages.gotext.json

File:locales/el/messages.gotext.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"language": "el",
"messages": [
{
"id": "Hello, {Host}",
"message": "Hello, {Host}",
"translation": "Γιά σου %[1]v",
"placeholders": [
{
"id": "Host",
"string": "%[1]v",
"type": "string",
"underlyingType": "string",
"argNum": 1,
"expr": "html.EscapeString(r.Host)"
}
]
}
]
}

再次运行go generate并增加catalog包到main.go

1
$ go generate

File:main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"html"
"log"
"net/http"
"flag"
"time"
"golang.org/x/text/message"
"golang.org/x/text/language"

_ "go-internationalization/catalog"
)

...

现在,为了确定当用户从API请求一个资源时,我们需要切换哪些语言,我们需要添加一个Matcher对象,当提供一个语言标记列表时,它将从我们所支持的本地化中匹配最合适的。

gotext工具中的message.DefaultCatalog创建一个Matcher对象用于提供支持的本地化列表:
File:main.go

1
var matcher = language.NewMatcher(message.DefaultCatalog.Languages())

你的函数通过请求的参数匹配争取的语言:
File:main.go

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main

import (
"html"
"log"
"net/http"
"flag"
"time"
"context"
"golang.org/x/text/message"
"golang.org/x/text/language"

_ "go-internationalization/catalog"
)

//go:generate gotext -srclang=en update -out=catalog/catalog.go -lang=en,el

var matcher = language.NewMatcher(message.DefaultCatalog.Languages())

type contextKey int

const (
httpPort = "8090"
messagePrinterKey contextKey = 1
)

func withMessagePrinter(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
lang, ok := r.URL.Query()["lang"]

if !ok || len(lang) < 1 {
lang = append(lang, language.English.String())
}
tag,_, _ := matcher.Match(language.MustParse(lang[0]))
p := message.NewPrinter(tag)
ctx := context.WithValue(context.Background(), messagePrinterKey, p)

next.ServeHTTP(w, r.WithContext(ctx))
}
}
...

这里我只是简单支持从url的参数中获取参数,你还可以从cookie或者Accept-Language的header里解析。
现在你只需要用withMessagePrinter包装你的处理函数PrintMessage并且从context中获得printer对象。
File:main.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
...
func PrintMessage(w http.ResponseWriter, r *http.Request) {
p := r.Context().Value(messagePrinterKey).(*message.Printer)
p.Fprintf(w,"Hello, %v", html.EscapeString(r.Host))
}

func main() {
var port string
flag.StringVar(&port, "port", httpPort, "http port")
flag.Parse()

server := &http.Server{
Addr: ":" + port,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
MaxHeaderBytes: 1 << 16,
Handler: http.HandlerFunc(withMessagePrinter(PrintMessage))}

log.Fatal(server.ListenAndServe())
}

启动服务,发起一些请求看看本地化翻译是怎样的:

1
$ go run main.go

Alt 截图
Alt 截图
世界属于你的了。。。。年轻人!!