最近,我第一次使用Go的 golang.org/x/text
包。我发现,生活在golang.org/x/text
下的包和工具真的很有效,而且设计得很好,尽管要弄清楚如何把它放在一个真正的应用中,是一个有点难度的挑战。
注意:如果你还没有意识到,生活在golang.org/x
下的软件包是官方 Go 项目的一部分,但在主 Go 标准库树之外。它们的标准比标准库包要宽松,这意味着它们不受 Go 兼容性承诺的约束(即它们的 API 可能会改变),而且文档也不一定完整。
在本教程中,我想解释如何使用golang.org/x/text
包来管理应用程序中的翻译。具体而言:
- 如何使用
golang.org/x/text/language
和golang.org/x/text/message
包来打印 Go 代码中的翻译信息。 - 如何使用
gotext
工具从你的代码中自动提取要翻译的信息到JSON文件。 - 如何使用
gotext
来解析翻译好的JSON文件,并创建一个包含翻译好的消息的目录。 - 如何管理消息中的变量并提供翻译的复数版本。
我们将建立什么
为了帮助我们了解情况,我们将为一个假想的在线书店创建一个简单的预发布网站。我们将慢慢开始,一步一步地建立代码。
我们的应用程序将只有一个主页,我们将根据URL路径开始处的地区标识符对页面内容进行本地化。我们将设置我们的应用程序以支持三个不同的地区:英国、德国和瑞士的法语地区。
URL | 本地化为 |
---|---|
localhost:4018/en-gb | 联合王国 |
本地主机:4018/de-de | 德国 |
本地主机:4018/fr-ch | 瑞士(讲法语的国家) |
我们将遵循一个共同的惯例,使用BCP 47语言标签作为我们的URL中的地区标识符。为了本教程的目的,将事情大大简化,BCP 47语言标签的格式通常为{language}-{region}
。语言部分是ISO 639-1代码,地区是ISO_3166-1的双字母国家代码。传统的做法是大写地区(如en-GB
),但BCP 47标签在技术上是不分大小写的,我们在URL中使用全小写版本也是可以的。
构建一个网络应用程序的脚手架
如果你想跟随应用程序的构建,继续运行以下命令来设置一个新的项目目录:
$ mkdir bookstore
$ cd bookstore
$ go mod init bookstore.example.com
go: creating new go.mod: module bookstore.example.com
在这一点上,你应该在项目目录的根部有一个go.mod
文件,模块路径为bookstore.example.com
。
接下来创建一个新的cmd/www
目录来存放书店网络应用程序的代码,并像这样添加main.go
和handlers.go
文件:
$ mkdir -p cmd/www
$ touch cmd/www/main.go cmd/www/handlers.go
你的项目目录现在应该是这样的:
.
├── cmd
│ └── www
│ ├── handlers.go
│ └── main.go
└── go.mod
让我们从cmd/www/main.go
文件开始,添加代码来声明我们的应用路由,并启动一个HTTP服务器。
因为我们的应用程序的URL路径将总是使用一个(动态的)locale作为前缀--比如/en-gb/bestsellers
或/fr-ch/bestsellers
--如果我们的应用程序使用一个支持URL路径段动态值的第三方路由器,那就最简单了。我将使用 pat
,但也可以自由地使用另一种方式,如 chi
或 gorilla/mux
如果你愿意的话,也可以使用另一种方式,如
注意:如果你不确定在你的项目中使用哪种路由器,你可能想看看我的Go路由器比较博文。
好了,打开main.go
文件,添加以下代码。
File: cmd/www/main.go
package main
import (
"log"
"net/http"
"github.com/bmizerany/pat"
)
func main() {
// Initialize a router and add the path and handler for the homepage.
mux := pat.New()
mux.Get("/:locale", http.HandlerFunc(handleHome))
// Start the HTTP server using the router.
log.Print("starting server on :4018...")
err := http.ListenAndServe(":4018", mux)
log.Fatal(err)
}
然后在cmd/www/handlers.go
文件中,添加一个handleHome()
函数,从URL路径中提取locale标识符,并在HTTP响应中进行回显。
File: cmd/www/handlers.go
package main
import (
"fmt"
"net/http"
)
func handleHome(w http.ResponseWriter, r *http.Request) {
// Extract the locale from the URL path. This line of code is likely to
// be different for you if you are using an alternative router.
locale := r.URL.Query().Get(":locale")
// If the locale matches one of our supported values, echo the locale
// in the response. Otherwise send a 404 Not Found response.
switch locale {
case "en-gb", "de-de", "fr-ch":
fmt.Fprintf(w, "The locale is %s\n", locale)
default:
http.NotFound(w, r)
}
}
一旦完成,运行go mod tidy
,整理你的go.mod
文件并下载任何必要的依赖,然后运行网络应用:
$ go mod tidy
go: finding module for package github.com/bmizerany/pat
go: found github.com/bmizerany/pat in github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f
$ go run ./cmd/www/
2021/08/21 21:22:57 starting server on :4018...
如果你用curl向应用程序提出一些请求,你应该发现适当的locale会像这样回馈给你:
$ curl localhost:4018/en-gb
The locale is en-gb
$ curl localhost:4018/de-de
The locale is de-de
$ curl localhost:4018/fr-ch
The locale is fr-ch
$ curl localhost:4018/da-DK
404 page not found
提取和翻译文本内容
现在我们已经为我们的网络应用程序奠定了基础,让我们进入本教程的核心部分,更新handleHome()
函数,以便它渲染一个"Welcome!"
,并为特定的语言环境翻译。
在这个项目中,我们将使用英式英语(en-GB
)作为我们应用程序的默认 "源 "或 "基本 "语言,但我们希望为其他地区渲染德语和法语的欢迎信息的翻译版本。
要做到这一点,我们需要导入 golang.org/x/text/language
和 golang.org/x/text/message
包,并更新我们的handleHome()
函数来做以下两件事:
- 构建一个
language.Tag
来确定我们要将信息翻译成的目标语言。language
包包含了一些预定义的标签,用于常见的语言变体,但我发现,使用language.MustParse()
函数来创建一个标签。这让你可以为任何有效的BCP 47值创建一个language.Tag
,如language.MustParse("fr-CH")
。 - 一旦你有了一个语言标签,你就可以使用
message.NewPrinter()
函数来创建一个message.Printer
实例,打印出该特定语言的信息。
如果你正在关注,请继续更新你的cmd/www/handlers.go
文件,以包含以下代码:
File: cmd/www/handlers.go
package main
import (
"net/http"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func handleHome(w http.ResponseWriter, r *http.Request) {
locale := r.URL.Query().Get(":locale")
// Declare variable to hold the target language tag.
var lang language.Tag
// Use language.MustParse() to assign the appropriate language tag
// for the locale.
switch locale {
case "en-gb":
lang = language.MustParse("en-GB")
case "de-de":
lang = language.MustParse("de-DE")
case "fr-ch":
lang = language.MustParse("fr-CH")
default:
http.NotFound(w, r)
return
}
// Initialize a message.Printer which uses the target language.
p := message.NewPrinter(lang)
// Print the welcome message translated into the target language.
p.Fprintf(w, "Welcome!\n")
}
同样,运行go mod tidy
,下载必要的依赖性...
$ go mod tidy
go: finding module for package golang.org/x/text/message
go: finding module for package golang.org/x/text/language
go: downloading golang.org/x/text v0.3.7
go: found golang.org/x/text/language in golang.org/x/text v0.3.7
go: found golang.org/x/text/message in golang.org/x/text v0.3.7
然后运行该应用程序:
$ go run ./cmd/www/
2021/08/21 21:33:52 starting server on :4018...
当你向任何一个支持的URL提出请求时,你现在应该看到这样的(未翻译的)欢迎信息:
$ curl localhost:4018/en-gb
Welcome!
$ curl localhost:4018/de-de
Welcome!
$ curl localhost:4018/fr-ch
Welcome!
因此,在所有情况下,我们看到的是我们的en-GB
源语言中的"Welcome!"
消息。这是因为我们仍然需要向Go的message
包提供我们想要使用的实际翻译。没有实际的翻译,它就会退回到用源语言显示信息。
有很多方法可以为Go的message
包提供翻译,但对于大多数非琐碎的应用程序来说,使用一些自动工具来帮助你管理这项任务可能是明智的。幸运的是,Go提供了 gotext
工具来协助完成这项工作。
注意:我们使用的gotext
工具是来自golang.org/x/text/cmd/gotext
。它不应该与github.com/leonelquinteros/gotext
包混淆(它被设计为与 GNU gettext 工具和 PO/MO 文件一起工作)。
如果你正在跟随,请使用go install
,在你的机器上安装gotext
可执行文件:
$ go install golang.org/x/text/cmd/gotext@latest
一切顺利的话,该工具应该被安装到你的系统路径上的$GOBIN
目录,你可以像这样运行它:
$ which gotext
/home/alex/go/bin/gotext
$ 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
Use "gotext help [command]" for more information about a command.
Additional help topics:
Use "gotext help [topic]" for more information about that topic.
我真的很喜欢gotext
这个工具--它的功能非常好--但是在我们继续之前,有几件重要的事情要指出来。
第一件事是,go text
被设计为与go generate
一起工作,而不是作为一个独立的命令行工具。你可以把它作为一个独立的工具来运行,但是会发生一些奇怪的事情,如果你按照它的设计方式来使用它,就会顺利得多。
另一件事是,文档和帮助功能基本上是不存在的。关于如何使用它的最好的指导是软件库中的例子,可能还有你现在正在读的这篇文章。有一个关于缺乏帮助功能的公开问题,希望这在将来会有所改善。
在本教程中,我们将把所有与翻译有关的代码存储在一个新的internal/translations
包中。我们可以把我们的Web应用程序的所有翻译代码放在cmd/www
,但根据我(有限的)经验,我发现使用一个单独的internal/translations
包更好。它有助于分离关注点,也使得在同一项目中的不同应用中重复使用相同的翻译成为可能。我想是的。
如果你跟在后面,继续创建那个新的目录和一个translations.go
文件,就像这样:
$ mkdir -p internal/translations
$ touch internal/translations/translations.go
在这一点上,你的项目结构应该看起来像这样:
.
├── cmd
│ └── www
│ ├── handlers.go
│ └── main.go
├── go.mod
├── go.sum
└── internal
└── translations
└── translations.go
接下来,让我们打开internal/translations/translations.go
文件,并添加一个go generate
命令,该命令使用gotext
,从我们的应用程序中提取要翻译的信息。
package translations
//go:generate gotext -srclang=en-GB update -out=catalog.go -lang=en-GB,de-DE,fr-CH bookstore.example.com/cmd/www
在这个命令中,有很多事情要做,所以让我们快速分解一下:
-srclang
标志包含我们在应用程序中使用的源语言(或 "基础")的BCP 47标签。在我们的例子中,源语言是en-GB
。update
是我们要执行的 函数。除了 ,还有 、 和 函数,但在网络应用程序的翻译工作流程中,你实际需要的只有 。gotext
update
extract
rewrite
generate
update
-out
标志包含你希望消息目录输出到的路径。这个路径应该是相对于包含go generate
命令的文件。在我们的例子中,我们把这个值设置为catalog.go
,这意味着消息目录将被输出到一个新的internal/translations/catalog.go
文件。我们将更多地讨论消息目录,并很快解释它们是什么。-lang
标志包含一个以逗号分隔的BCP 47标签的列表,你想为其创建翻译。你不需要在这里包括源语言,但(正如我们将在本文后面演示的那样)它对处理文本内容的复数化有帮助。- 最后,我们有你想要创建翻译的软件包的全限定模块路径(在这里是
bookstore.example.com/cmd/www
)。如果有必要,你可以列出多个软件包,用一个空白字符分开。
当我们执行这个go generate
命令时,gotext
将浏览cmd/www
应用程序的代码,寻找所有对message.Printer
† 的调用。然后,它提取相关的消息字符串,并将其输出到一些JSON文件中进行翻译。
重要:需要注意的是,当gotext
浏览你的代码时,它实际上只寻找对message.Printer.Printf()
、Fprintf()
和Sprintf()
的调用--基本上是以f
结尾的三个方法。它忽略了所有其他方法,如Sprint()
或Println()
。你可以在 gotext
的实现中看到这种行为。
好的,让我们把这个付诸行动,在我们的translations.go
文件上调用go generate
。反过来,这将执行我们包含在该文件顶部的gotext
命令:
$ go generate ./internal/translations/translations.go
de-DE: Missing entry for "Welcome!".
fr-CH: Missing entry for "Welcome!".
酷,这看起来像我们正在取得进展。我们已经得到一些有用的反馈,表明我们的"Welcome!"
信息缺少必要的德语和法语翻译。
如果你看一下你的项目的目录结构,它现在应该是这样的:
.
├── cmd
│ └── www
│ ├── handlers.go
│ └── main.go
├── go.mod
├── go.sum
└── internal
└── translations
├── catalog.go
├── locales
│ ├── de-DE
│ │ └── out.gotext.json
│ ├── en-GB
│ │ └── out.gotext.json
│ └── fr-CH
│ └── out.gotext.json
└── translations.go
我们可以看到,go generate
命令已经为我们自动生成了一个internal/translations/catalog.go
文件(我们将在一分钟内查看),以及一个locales
文件夹,其中包含我们每种目标语言的out.gotext.json
文件。
让我们看一下internal/translations/locales/de-DE/out.gotext.json
文件:
File: internal/translations/locales/de-DE/out.gotext.json
{
"language": "de-DE",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": ""
}
]
}
在这个JSON文件中,相关的BCP 47language
标签被定义在文件的顶部,然后是一个需要翻译的messages
的JSON数组。message
值是源语言中需要翻译的文本,而(目前为空)translation
值是我们应该输入适当的德语翻译的地方。
需要强调的是,你不要在原地编辑这个文件。相反,添加翻译的工作流程是这样的:
- 你生成包含需要翻译的信息的
out.gotext.json
文件(我们刚刚已经完成)。 - 你把这些文件发送给译员,译员编辑JSON以包括必要的翻译。然后他们将更新的文件发回给你。
- 然后,你将这些更新的文件以
messages.gotext.json
,保存在相应语言的文件夹中。
为了演示,让我们快速模拟这个工作流程,把out.gotext.json
文件复制到messages.gotext.json
文件,并更新它们以包括翻译后的信息,像这样:
$ cp internal/translations/locales/de-DE/out.gotext.json internal/translations/locales/de-DE/messages.gotext.json
$ cp internal/translations/locales/fr-CH/out.gotext.json internal/translations/locales/fr-CH/messages.gotext.json
File: internal/translations/locales/de-DE/messages.gotext.json
{
"language": "de-DE",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Willkommen!"
}
]
}
File: internal/translations/locales/fr-CH/messages.gotext.json
{
"language": "fr-CH",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Bienvenue !"
}
]
}
如果你喜欢,你也可以看一下我们的en-GB
源语言的out.gotext.json
文件。你会看到,消息的translation
值已经为我们自动填充了:
File: internal/translations/locales/en-GB/messages.gotext.json
{
"language": "en-GB",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Welcome!",
"translatorComment": "Copied from source.",
"fuzzy": true
}
]
}
下一步是再次运行我们的go generate
命令。这一次,它应该在没有任何关于丢失翻译的警告信息的情况下执行:
$ go generate ./internal/translations/translations.go
现在是看一下internal/translations/catalog.go
文件的好时机,这个文件是由gotext update
命令为我们自动生成的。这个文件包含一个消息目录,它是--非常粗略的说--每个目标语言的消息及其相关翻译的映射。
让我们快速浏览一下该文件的内容:
File: internal/translations/catalog.go
// Code generated by running "go generate" in golang.org/x/text. DO NOT EDIT.
package translations
import (
"golang.org/x/text/language"
"golang.org/x/text/message"
"golang.org/x/text/message/catalog"
)
type dictionary struct {
index []uint32
data string
}
func (d *dictionary) Lookup(key string) (data string, ok bool) {
p, ok := messageKeyToIndex[key]
if !ok {
return "", false
}
start, end := d.index[p], d.index[p+1]
if start == end {
return "", false
}
return d.data[start:end], true
}
func init() {
dict := map[string]catalog.Dictionary{
"de_DE": &dictionary{index: de_DEIndex, data: de_DEData},
"en_GB": &dictionary{index: en_GBIndex, data: en_GBData},
"fr_CH": &dictionary{index: fr_CHIndex, data: fr_CHData},
}
fallback := language.MustParse("en-GB")
cat, err := catalog.NewFromMap(dict, catalog.Fallback(fallback))
if err != nil {
panic(err)
}
message.DefaultCatalog = cat
}
var messageKeyToIndex = map[string]int{
"Welcome!\n": 0,
}
var de_DEIndex = []uint32{ // 2 elements
0x00000000, 0x00000011,
} // Size: 32 bytes
const de_DEData string = "\x04\x00\x01\n\f\x02Willkommen!"
var en_GBIndex = []uint32{ // 2 elements
0x00000000, 0x0000000e,
} // Size: 32 bytes
const en_GBData string = "\x04\x00\x01\n\t\x02Welcome!"
var fr_CHIndex = []uint32{ // 2 elements
0x00000000, 0x00000010,
} // Size: 32 bytes
const fr_CHData string = "\x04\x00\x01\n\v\x02Bienvenue !"
// Total table size 143 bytes (0KiB); checksum: 385F6E56
我不想在这里纠缠细节,因为我们可以把这个文件当作一个 "黑盒子 "来使用,而且--正如文件顶部的注释所警告的那样--我们不应该直接对它做任何修改。
但最重要的是,这个文件包含一个init()
函数,当它被调用时,会初始化一个包含我们所有翻译和映射的新消息目录。然后,它将这个目录设置为默认的消息目录,将其分配给 message.DefaultCatalog
全局变量。
当我们调用message.Printer
函数之一时,打印机将从默认的信息目录中查找相关的翻译,以便打印。这真的很好,因为这意味着我们所有的翻译在运行时都存储在内存中,任何查找都是非常快速和有效的。
因此,如果我们退后一步,我们可以看到,我们与go generate
一起使用的gotext update
命令实际上做了两件事。第一,它浏览我们的cmd/www
应用程序中的代码,并提取必要的字符串翻译成out.gotext.json
文件;第二,它还解析任何messages.gotext.json
文件(如果存在),并相应地更新消息目录。
让它工作的最后一步是在我们的cmd/www/handlers.go
文件中导入internal/translations
包。这将确保internal/translations/translations.go
中的init()
函数被调用,并且默认的消息目录被更新为包含我们翻译的目录。因为我们实际上不会直接引用internal/translations
包中的任何东西,我们需要将导入路径别名为空白标识符_
以防止 Go 编译器抱怨。
现在就去做吧:
File: cmd/www/handlers.go
package main
import (
"net/http"
// Import the internal/translations package, so that its init()
// function is called.
_ "bookstore.example.com/internal/translations"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
func handleHome(w http.ResponseWriter, r *http.Request) {
locale := r.URL.Query().Get(":locale")
var lang language.Tag
switch locale {
case "en-gb":
lang = language.MustParse("en-GB")
case "de-de":
lang = language.MustParse("de-DE")
case "fr-ch":
lang = language.MustParse("fr-CH")
default:
http.NotFound(w, r)
return
}
p := message.NewPrinter(lang)
p.Fprintf(w, "Welcome!\n")
}
好了,让我们来试试这个当你重新启动应用程序并尝试提出一些请求时,你现在应该看到"Welcome!"
消息被翻译成适当的语言。
$ curl localhost:4018/en-GB
Welcome!
$ curl localhost:4018/de-de
Willkommen!
$ curl localhost:4018/fr-ch
Bienvenue !
在翻译中使用变量
现在我们已经在我们的应用程序中得到了基本的翻译工作,让我们继续做一些更高级的事情,看看如何管理带有内插变量的翻译。
为了演示,我们将更新HTTP响应,从我们的handleHome()
函数,以包括一个"{N} books available"
行,其中{N}
是一个整数,包含我们想象中的书店的书籍数量:
File: cmd/www/handlers.go
package main
...
func handleHome(w http.ResponseWriter, r *http.Request) {
locale := r.URL.Query().Get(":locale")
var lang language.Tag
switch locale {
case "en-gb":
lang = language.MustParse("en-GB")
case "de-de":
lang = language.MustParse("de-DE")
case "fr-ch":
lang = language.MustParse("fr-CH")
default:
http.NotFound(w, r)
return
}
// Define a variable to hold the number of books. In a real application
// this would probably be retrieved by making a database query or
// something similar.
var totalBookCount = 1_252_794
p := message.NewPrinter(lang)
p.Fprintf(w, "Welcome!\n")
// Use the Fprintf() function to include the new message in the HTTP
// response, with the book count as in interpolated integer value.
p.Fprintf(w, "%d books available\n", totalBookCount)
}
保存这些变化,然后使用go generate
,输出一些新的out.gotext.json
文件。你应该看到新的缺失翻译的警告信息,就像这样:
$ go generate ./internal/translations/translations.go
de-DE: Missing entry for "{TotalBookCount} books available".
fr-CH: Missing entry for "{TotalBookCount} books available".
让我们看一下de-DE/out.gotext.json
文件。
File: internal/translations/locales/de-DE/out.gotext.json
{
"language": "de-DE",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Willkommen!"
},
{
"id": "{TotalBookCount} books available",
"message": "{TotalBookCount} books available",
"translation": "",
"placeholders": [
{
"id": "TotalBookCount",
"string": "%[1]d",
"type": "int",
"underlyingType": "int",
"argNum": 1,
"expr": "totalBookCount"
}
]
}
]
}
这里要指出的第一件事是,我们的"Welcome!"
信息的翻译已经在工作流程中持续存在,并且已经存在于out.gotext.json
文件中。这显然是非常重要的,因为这意味着当我们把文件发送给翻译时,他们不需要再次提供翻译。
第二件事是,现在有一个关于我们的新消息的条目。我们可以看到,它的形式是"{TotalBookCount} books available"
,Go代码中的*(大写的)变量名*被用作占位参数。在编写代码时,你应该记住这一点,尽量使用合理的、描述性的变量名,这样对你的译者来说才有意义。placeholders
数组还提供了关于每个占位符值的额外信息,最有用的部分可能是type
值(在这种情况下,它告诉翻译人员TotalBookCount
值是一个整数)。
因此,下一步是将这些新的out.gotext.json
文件发送给翻译人员进行翻译。同样,我们将在这里模拟,把它们复制到messages.gotext.json
文件中,并像这样添加翻译。
$ cp internal/translations/locales/de-DE/out.gotext.json internal/translations/locales/de-DE/messages.gotext.json
$ cp internal/translations/locales/fr-CH/out.gotext.json internal/translations/locales/fr-CH/messages.gotext.json
File: internal/translations/locales/de-DE/messages.gotext.json
{
"language": "de-DE",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Willkommen!"
},
{
"id": "{TotalBookCount} books available",
"message": "{TotalBookCount} books available",
"translation": "{TotalBookCount} Bücher erhältlich",
"placeholders": [
{
"id": "TotalBookCount",
"string": "%[1]d",
"type": "int",
"underlyingType": "int",
"argNum": 1,
"expr": "totalBookCount"
}
]
}
]
}
File: internal/translations/locales/fr-CH/messages.gotext.json
{
"language": "fr-CH",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Bienvenue !"
},
{
"id": "{TotalBookCount} books available",
"message": "{TotalBookCount} books available",
"translation": "{TotalBookCount} livres disponibles",
"placeholders": [
{
"id": "TotalBookCount",
"string": "%[1]d",
"type": "int",
"underlyingType": "int",
"argNum": 1,
"expr": "totalBookCount"
}
]
}
]
}
确保两个messages.gotext.json
,然后运行go generate
,更新我们的消息目录。这应该在没有任何警告的情况下运行。
$ go generate ./internal/translations/translations.go
当你重新启动cmd/www
,并再次发出一些HTTP请求时,你现在应该看到新的翻译的消息,就像这样。
$ curl localhost:4018/en-GB
Welcome!
1,252,794 books available
$ curl localhost:4018/de-de
Willkommen!
1.252.794 Bücher erhältlich
$ curl localhost:4018/fr-ch
Bienvenue !
1 252 794 livres disponibles
现在,这真的很酷。由于我们的message.Printer
,它也足够聪明,为每种语言输出具有正确数字格式的内插整数值。我们在这里可以看到,我们的en-GB
地区设置使用","
字符作为千位分隔符,而de-DE
使用"."
,fr-CH
使用空格" "
。对小数的分隔符也做了类似的处理。
处理复数化的问题
这样做很好,但如果我们的书店里只有一本书,会发生什么呢?让我们更新一下handleHome()
函数,使totalBookCount
的值为1。
File: cmd/www/handlers.go
package main
...
func handleHome(w http.ResponseWriter, r *http.Request) {
locale := r.URL.Query().Get(":locale")
var lang language.Tag
switch locale {
case "en-gb":
lang = language.MustParse("en-GB")
case "de-de":
lang = language.MustParse("de-DE")
case "fr-ch":
lang = language.MustParse("fr-CH")
default:
http.NotFound(w, r)
return
}
// Set the total book count to 1.
var totalBookCount = 1
p := message.NewPrinter(lang)
p.Fprintf(w, "Welcome!\n")
p.Fprintf(w, "%d books available\n", totalBookCount)
}
(我知道这个例子有点勉强,但它有助于说明Go的复数化功能,不需要太多额外的代码,所以请忍受我!)
你可能可以想象当我们重新启动应用程序并向localhost:4018/en-gb
,会发生什么:
$ curl localhost:4018/en-gb
Welcome!
1 books available
没错,我们看到的消息是"1 books available"
,这不是正确的英语,因为有复数名词books
。如果这个消息是1 book available
,或者--甚至更好--One book available
,那就更好了。
令人高兴的是,我们有可能根据我们的messages.gotext.json
文件中的插值变量的值来指定替代的翻译。
让我们首先为我们的en-GB
地区设置进行演示。如果你在跟读,把en-GB/out.gotext.json
文件复制到en-GB/messages.gotext.json
。
$ cp internal/translations/locales/en-GB/out.gotext.json internal/translations/locales/en-GB/messages.gotext.json
然后像这样更新它。
File: internal/translations/locales/en-GB/messages.gotext.json
{
"language": "en-GB",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Welcome!",
"translatorComment": "Copied from source.",
"fuzzy": true
},
{
"id": "{TotalBookCount} books available",
"message": "{TotalBookCount} books available",
"translation": {
"select": {
"feature": "plural",
"arg": "TotalBookCount",
"cases": {
"=1": {
"msg": "One book available"
},
"other": {
"msg": "{TotalBookCount} books available"
}
}
}
},
"placeholders": [
{
"id": "TotalBookCount",
"string": "%[1]d",
"type": "int",
"underlyingType": "int",
"argNum": 1,
"expr": "totalBookCount"
}
]
}
]
}
现在,translation
值不是一个简单的字符串,而是将其设置为一个JSON对象,指示消息目录根据TotalBookCount
占位符的值使用不同的翻译。这里的关键部分是cases
值,它包含对占位符的不同值使用的翻译。支持的案例规则是:
案例 | 说明 |
---|---|
"=x" | 其中x 是一个整数,等于占位符的值 |
"<x" | 其中x 是一个比占位符的值大的整数 |
"other" | 所有其他情况(有点像Go中的default switch 语句)。 |
注意:如果你看一下关于 golang.org/x/text/feature/plural
包(这是gotext
在幕后生成消息目录时使用的),你会看到它也提到了 "零"、"一"、"二"、"几 "和 "多 "的情况规则。然而,这些规则并不支持所有可能的目标语言,如果你试图使用它们,你可能会得到一个错误,如gotext: generation failed: error: plural: form "many" not supported for language "de-DE"
。坚持使用上表中的三个大小写规则似乎更安全。此外,需要注意的是,"=x"
和"<x"
案例规则中x
的允许值范围是 0 到 32767。试图使用这个范围之外的东西将导致错误。这里有一个关于这些行为的公开问题。
让我们通过更新我们的de-DE
和fr-CH
语言的messages.gotext.json
文件来完成这个工作,包括适当的复数化变化,像这样。
File: internal/translations/locales/de-DE/messages.gotext.json
{
"language": "de-DE",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Willkommen!"
},
{
"id": "{TotalBookCount} books available",
"message": "{TotalBookCount} books available",
"translation": {
"select": {
"feature": "plural",
"arg": "TotalBookCount",
"cases": {
"=1": {
"msg": "Ein Buch erhältlich"
},
"other": {
"msg": "{TotalBookCount} Bücher erhältlich"
}
}
}
},
"placeholders": [
{
"id": "TotalBookCount",
"string": "%[1]d",
"type": "int",
"underlyingType": "int",
"argNum": 1,
"expr": "totalBookCount"
}
]
}
]
}
File: internal/translations/locales/fr-CH/messages.gotext.json
{
"language": "fr-CH",
"messages": [
{
"id": "Welcome!",
"message": "Welcome!",
"translation": "Bienvenue !"
},
{
"id": "{TotalBookCount} books available",
"message": "{TotalBookCount} books available",
"translation": {
"select": {
"feature": "plural",
"arg": "TotalBookCount",
"cases": {
"=1": {
"msg": "Un livre disponible"
},
"other": {
"msg": "{TotalBookCount} livres disponibles"
}
}
}
},
"placeholders": [
{
"id": "TotalBookCount",
"string": "%[1]d",
"type": "int",
"underlyingType": "int",
"argNum": 1,
"expr": "totalBookCount"
}
]
}
]
}
一旦这些文件被保存,再次使用go generate
,更新消息目录:
$ go generate ./internal/translations/translations.go
而如果你重新启动网络应用程序并进行一些HTTP请求,你现在应该看到1本书的相应消息:
$ curl localhost:4018/en-GB
Welcome!
One book available
$ curl localhost:4018/de-de
Willkommen!
Ein Buch erhältlich
$ curl localhost:4018/fr-ch
Bienvenue !
Un livre disponible
如果你愿意,你可以把totalBookCount
这个变量恢复到一个更大的数字...
File: cmd/www/handlers.go
package main
...
func handleHome(w http.ResponseWriter, r *http.Request) {
...
// Revert the total book count.
var totalBookCount = 1_252_794
p := message.NewPrinter(lang)
p.Fprintf(w, "Welcome!\n")
p.Fprintf(w, "%d books available\n", totalBookCount)
}
而当你重新启动应用程序并提出另一个请求时,你应该看到我们的消息的"other"
版本:
$ curl localhost:4018/de-de
Willkommen!
1.252.794 Bücher erhältlich
创建一个本地化的抽象概念
在本文的最后一部分,我们将创建一个新的internal/localizer
包,它抽象了我们处理语言、打印机和翻译的所有代码。
如果你正在跟读,请继续创建一个新的internal/localizer
目录,其中包含一个localizer.go
文件:
$ mkdir -p internal/localizer
$ touch internal/localizer/localizer.go
在这一点上,你的项目结构应该看起来像这样:
.
├── cmd
│ └── www
│ ├── handlers.go
│ └── main.go
├── go.mod
├── go.sum
└── internal
├── localizer
│ └── localizer.go
└── translations
├── catalog.go
├── locales
│ ├── de-DE
│ │ ├── messages.gotext.json
│ │ └── out.gotext.json
│ ├── en-GB
│ │ ├── messages.gotext.json
│ │ └── out.gotext.json
│ └── fr-CH
│ ├── messages.gotext.json
│ └── out.gotext.json
└── translations.go
然后在新的localizer.go
文件中添加以下代码:
File: internal/localizer/localizer.go
package localizer
import (
// Import the internal/translations so that it's init() function
// is run. It's really important that we do this here so that the
// default message catalog is updated to use our translations
// *before* we initialize the message.Printer instances below.
_ "bookstore.example.com/internal/translations"
"golang.org/x/text/language"
"golang.org/x/text/message"
)
// Define a Localizer type which stores the relevant locale ID (as used
// in our URLs) and a (deliberately unexported) message.Printer instance
// for the locale.
type Localizer struct {
ID string
printer *message.Printer
}
// Initialize a slice which holds the initialized Localizer types for
// each of our supported locales.
var locales = []Localizer{
{
// Germany
ID: "de-de",
printer: message.NewPrinter(language.MustParse("de-DE")),
},
{
// Switzerland (French speaking)
ID: "fr-ch",
printer: message.NewPrinter(language.MustParse("fr-CH")),
},
{
// United Kingdom
ID: "en-gb",
printer: message.NewPrinter(language.MustParse("en-GB")),
},
}
// The Get() function accepts a locale ID and returns the corresponding
// Localizer for that locale. If the locale ID is not supported then
// this returns `false` as the second return value.
func Get(id string) (Localizer, bool) {
for _, locale := range locales {
if id == locale.ID {
return locale, true
}
}
return Localizer{}, false
}
// We also add a Translate() method to the Localizer type. This acts
// as a wrapper around the unexported message.Printer's Sprintf()
// function and returns the appropriate translation for the given
// message and arguments.
func (l Localizer) Translate(key message.Reference, args ...interface{}) string {
return l.printer.Sprintf(key, args...)
}
注意:在这里注意到,我们在启动时为每个locale初始化一个message.Printer
,这些将被我们的web应用处理程序同时使用。尽管golang.org/x/text/message
文档中没有说message.Printer
可以安全地并发使用,但我向Marcel van Lohuizen(golang.org/x/text
包的主要开发者)核实,他确认message.Printer
是为了并发使用的,并且是并发安全的(只要对任何写入目标的访问是同步的)。
接下来,让我们更新cmd/www/handlers.go
文件,以使用我们新的Localizer
类型,同时--我们也让我们的handleHome()
函数渲染一个额外的"Launching soon!"
消息。
File: cmd/www/handlers.go
package main
import (
"fmt" // New import
"net/http"
"bookstore.example.com/internal/localizer" // New import
)
func handleHome(w http.ResponseWriter, r *http.Request) {
// Initialize a new Localizer based on the locale ID in the URL.
l, ok := localizer.Get(r.URL.Query().Get(":locale"))
if !ok {
http.NotFound(w, r)
return
}
var totalBookCount = 1_252_794
// Update these to use the new Translate() method.
fmt.Fprintln(w, l.Translate("Welcome!"))
fmt.Fprintln(w, l.Translate("%d books available", totalBookCount))
// Add an additional "Launching soon!" message.
fmt.Fprintln(w, l.Translate("Launching soon!"))
}
值得指出的是,我们在这里使用的Translate()
方法并不只是一些语法上的糖。你可能还记得早些时候,我写了下面的警告:
需要注意的是,当
gotext
读取你的代码时,它实际上只寻找对message.Printer.Printf()
、Fprintf()
和Sprintf()
的调用--基本上是以f
结尾的三个方法。它忽略了所有其他方法,如Sprint()
或Println()
。
通过让我们所有的翻译通过Translate()
方法--它在幕后使用Sprintf()
--我们避免了这样的情况:你不小心使用了像Sprint()
或Println()
这样的方法,而gotext
并没有将信息提取到out.gotext.json
文件中。
让我们试试这个,再次运行go generate
:
$ go generate ./internal/translations/translations.go
de-DE: Missing entry for "Launching soon!".
fr-CH: Missing entry for "Launching soon!".
所以这真的很聪明。我们可以看到,gotext
已经很聪明地走遍了我们的整个代码库,并识别出哪些字符串需要被翻译,即使我们把message.Printer.Sprintf()
的调用抽象为不同包中的一个辅助函数。这真是太棒了,也是我非常欣赏gotext
这个工具的地方之一。
如果你正在跟随,请继续下去,将out.gotext.json
文件复制到message.gotext.json
文件,并为新的"Launching soon!"
信息添加必要的翻译。然后记得再次运行go generate
,并重新启动网络应用。
当你现在再做一些HTTP请求时,你的响应应该与此类似:
$ curl localhost:4018/en-gb
Welcome!
1,252,794 books available
Launching soon!
$ curl localhost:4018/de-de
Willkommen!
1.252.794 Bücher erhältlich
Bald verfügbar!
$ curl localhost:4018/fr-ch
Bienvenue !
1 252 794 livres disponibles
Bientôt disponible !
其他信息
冲突的路由
在这篇文章的开头,我特意没有推荐使用httprouter,尽管它是一个优秀的、流行的路由器。这是因为,使用动态locale作为URL路径的第一部分,很可能导致与其他不需要locale前缀的应用路由冲突,比如/static/css/main.css
或/admin/login
。httprouter
包不允许冲突的路由,这使得在这种情况下使用它很尴尬。如果你确实想使用httprouter
,或者想在你的应用程序中避免冲突的路由,你可以把locale作为一个查询字符串参数来传递,比如/category/travel?locale=gb
。