程序员社区

Go国际化:翻译管理

本文将介绍使用golang.org/x/text包实现web应用的国际化。包括以下内容:

  • 如何使用golang.org/x/text/message和golang.org/x/text/language包翻译Go代码中需打印的信息。
  • 如何使用gotext工具自动从代码中提取需要翻译的消息到JSON文件中。
  • 如何使用gotext工具来解析翻译的JSON文件并创建一个包含翻译信息的目录。
  • 如何管理翻译中的变量并提供多元化版本。

特别提醒:golang.org/x下的包是官方Go项目的一部分,但不在主要的Go标准库中。它们的标准比标准库包更宽松,这意味着它们不受Go兼容性承诺的约束(即它们的api可能会改变),文档也不一定总是完整的。

创建应用

为了便于理解,我们以一个简单的在线书店应用为例。将一步一步地构建代码演示。这里书店应用只包含一个首页,我们将基于URL路径中包含的区域标识符来本地化页面内容。应用支持三种不同的区域:英国、德国和中国。

URL 本地化
localhost:4018/en-gb 英国
localhost:4018/de-de 德国
localhost:4018/zh-cn 中国

我们将跟随惯例使用BCP47语言标签作为URL中的本地化标识符。为了方便说明BCP47语言标签一般使用{language}-{region}格式,即语言--区域。语言部分是ISO639-1码,区域是两个字母的国家代码来源于 ISO_3166-1。传统的做法是将区域大写(如en-GB),但BCP 47标签在技术上是不区分大小写的,我们可以在url中使用全小写版本。

搭建一个web应用程序

如果您想跟随应用程序的创建,请运行以下命令来设置一个新的项目目录。

$ 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目录来存放书店web应用,然后创建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

先从main.go文件开始,添加代码来声明应用程序路由并启动HTTP服务器。因为应用程序URL路径使用(动态的)区域设置作为前缀——比如/en-gb/bestsellers或/zh-cn/bestsellers——如果应用程序使用一个支持URL路径动态值的第三方路由,是很简单的。本文使用pat(一种路由器创建库),也可以使用chi或gorilla/mux之类的替换。
下面打开main.go文件添加以下代码:

File: cmd/www/main.go
package main

import (
    "log"
    "net/http"

    "github.com/bmizerany/pat"
)

func main() {
    // 初始化一个路由实例,并为主页添加路径和处理程序。
    mux := pat.New()
    mux.Get("/:locale", http.HandlerFunc(handleHome))

    // 使用路由实例启动HTTP服务器。
    log.Println("starting server on :4018...")
    err := http.ListenAndServe(":4018", mux)
    log.Fatal(err)
}

然后在cmd/www/handlers.go文件中,添加hanldeHome()函数来提取URL中区域标识符,并将其通过HTTP响应返回。

File: cmd/www/handlers.go
package main

import (
    "fmt"
    "net/http"
)

func handleHome(w http.ResponseWriter, r *http.Request) {
    // 从URL路径提取语言区域,需根据你选择的路由实现方式修改这行代码.
    locale := r.URL.Query().Get(":locale")

    //如果所在区域语言有匹配的,在响应中回显区域设置。否则返回404
    switch locale {
    case "en-gb", "de-de", "zh-cn":
        fmt.Fprintf(w, "The locale is %s\n", locale)
    default:
        http.NotFound(w, r)
    }
}

完成以上代码后,运行go mod tidy来整理go.mod文件并下载必要的依赖包,然后执行web应用:

➜  go mod tidy
go: finding module for package github.com/bmizerany/pat
go: downloading github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f
go: found github.com/bmizerany/pat in github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f
➜   go run ./cmd/www
2021/10/07 09:57:03 starting server on :4018...

如果你使用curl向应用程序发出一些请求,你应该会发现相应的区域设置会像这样返回给你:

➜  curl localhost:4018/en-gb
The locale is en-gb
➜  curl localhost:4018/de-de
The locale is de-de
➜  curl localhost:4018/zh-cn
The locale is zh-cn

提取和翻译文本内容

现在已经为我们的web应用程序打下了基础,让我们进入本文的核心部分,并更新handleHome()函数,以便它能够根据特定的区域翻译“Welcome!”消息。在这个项目中,我们将在应用程序中使用英式英语(en-GB)作为默认的“源”或“基础”语言,但需要为其他地区提供德语和中文的欢迎信息翻译版本。

为此,我们需要导入golang.org/x/text/language和golang.org/x/text/message包,并更新handleHome()函数来完成以下两件事:
1、创建一个language标签来标识我们需要翻译的目标语言。language包包含提前为通用语言变量定义的标签,但我发现使用language.MustParse()函数创建标签更容易。你可以使用这个包为BCP47中的值创建一个language.Tag,例如language.MustParse("zh-CN")。
2、 一旦有了语言标签,就可以使用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

    // 使用 language.MustParse()为区域设置分配适当的语言标签。
    switch locale {
    case "en-gb":
        lang = language.MustParse("en-GB")
    case "de-de":
        lang = language.MustParse("de-DE")
    case "zh-cn":
        lang = language.MustParse("zh-CN")
    default:
        http.NotFound(w, r)
        return
    }

    // 使用对应语言初始化一个message.Printer实例
    p := message.NewPrinter(lang)
    // 将欢迎信息翻译成目标语言。
    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/10/07 10:13:19 starting server on :4018...

当你向任何支持的url发出请求时,应该会看到这样的(未翻译的)欢迎消息:

$ curl localhost:4018/en-gb
Welcome!

$ curl localhost:4018/de-de
Welcome!

$ curl localhost:4018/zh-cn
Welcome!

因此,在所有情况下,我们都能在en-GB源语言中看到“Welcome”消息。这是因为我们需要为Go的消息包提供我们想要使用的实际翻译。如果没有实际的翻译,它将退回到用源语言显示消息。

有许多方法可以为Go的消息包提供翻译,但对于大多数重要的应用程序来说,使用一些自动化工具来帮助您管理任务是明智的。幸运的是Go提供了gotext工具来帮助解决这个问题。

如果你遵循下面的方法,请使用go install在你的机器上安装gotext可执行文件:

go install golang.org/x/text/cmd/gotext@latest

如果一切正常,该工具应该安装到系统路径的$GOBIN目录下,你可以像这样运行它:

➜  ~ which gotext
/Users/wangmingjun/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配合使用,它不是一个独立的命令行工具。你可以将它作为一个独立的工具来运行,但会发生一些奇怪的事情,如果你按照预期方式使用会更加流畅。

另一件事是,文档和帮助功能基本上不存在。关于如何使用它的最佳指导在仓库的示例中,可能还有您正在阅读的这篇文章。

本文,我们将把所有与翻译相关的代码存储在一个新的interanal/translations包中。我们可以将web应用程序的所有翻译代码保存在cmd/www下,但根据我(有限)的经验,我发现使用单独的interanal/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来从应用中提取需要翻译的消息。

File: internal/translations/translations.go
package translations

//go:generate gotext -srclang=en-GB update -out=catalog.go -lang=en-GB,de-DE,zh-CN 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应用程序的代码,并查找所有mesage.Printer的调用。然后提取相关消息字符串并将其输出到一些JSON文件中进行翻译。

注意:gotext只查找代码中messge.Printer.Printf(),Fsprintf()和Sprintf()三个基础函数。其他的例如Sprint()或Print()函数会忽略。

让我们在translations.go文件调用go generate。接下来,这将执行我们在该文件顶部包含的gotext命令。

go generate ./internal/translations/translations.go
de-DE: Missing entry for "Welcome!".
zh-CN: 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
        │   └── zh-CN
        │       └── out.gotext.json
        └── translations.go

我们可以看到go generate命令已经自动生成了一个internal/translations/catalog.go文件(我们将在后面介绍),以及一个包含每种目标语言的out.gotext.json文件的locale文件夹。
让我们看看internal/translations/locale /de-DE/out.gotext.json文件:

{
    "language": "de-DE",
    "messages": [
        {
            "id": "Welcome!",
            "message": "Welcome!",
            "translation": ""
        }
    ]
}

在这个JSON文件中,相关的BCP 47语言标签定义在文件的顶部,后面是需要翻译的消息JSON数组。消息值是源代码中要翻译的文本,(当前为空)翻译值是我们应该输入适当的德语翻译的地方。

需要指出的是你不需要编辑翻译文件,实际上翻译流程应该是:
1、你生成包含需要翻译的out.gotext.json文件。
2、你将这些文件发送给翻译人员,翻译人员编辑JSON添加必要的翻译信息。然后他们将更新后的文件发回给你。
3、然后将这些更新后的文件以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/zh-CN/out.gotext.json internal/translations/locales/zh-CN/messages.gotext.json

nternal/translations/locales/de-DE/messages.gotext.json

{
    "language": "de-DE",
    "messages": [
        {
            "id": "Welcome!",
            "message": "Welcome!",
            "translation": "Willkommen!"
        }
    ]
}

internal/translations/locales/zh-CN/messages.gotext.json文件

{
    "language": "zh-CN",
    "messages": [
        {
            "id": "Welcome!",
            "message": "Welcome!",
            "translation": "Bienvenue !"
        }
    ]
}

如果您愿意,还可以查看一下en-GB源语言的out.gotext.json文件。您将看到消息的翻译值已经为我们自动填充了。
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命令自动生成的。该文件包含一个消息目录,粗略的说是一个消息和翻译之间的映射关系。
我们快速看下源代码:

// 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},
        "zh_CN": &dictionary{index: zh_CNIndex, data: zh_CNData},
    }
    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 zh_CNIndex = []uint32{ // 2 elements
    0x00000000, 0x00000010,
} // Size: 32 bytes

const zh_CNData 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/translation.go中的init()函数被调用,并且使得更新后的默认消息目录能生效。因为internal/translations包并没有在代码中直接使用,因此在引入该包的时候,需要使用占位符"_"防止编译报错。
cmd/www/handlers.go代码:

package main

import (
    "net/http"

    // 引入internal/translations包,确保init()函数被调用
    _ "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 "zh-cn":
        lang = language.MustParse("zh-CN")
    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/zh-cn
欢迎光临

翻译中使用变量

现在我们已经在应用程序中完成了基本的翻译工作,让我们看一些更高级的东西,并看看如何管理在翻译中插入变量。

为了说明,我们修改handleHome函数的HTTP响应,其包含"{N} books available" 字符串,其中{N}是一个整数指的是bookstore中书的数目。
修改 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 "zh-cn":
        lang = language.MustParse("zh-CN")
    default:
        http.NotFound(w, r)
        return
    }

    // 定义一个变量来保存书的数量。 在实际应用中数量需查询数据库的到
    var totalBookCount = 1252794

    p := message.NewPrinter(lang)
    p.Fprintf(w, "Welcome!\n")

    //使用Fprintf() 函数在响应中添加书数量
    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".
zh-CN: Missing entry for "{TotalBookCount} books available".

看下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代码中的(大写的)变量名作为占位符参数。在编写代码时,您应该记住这一点,并尝试使用对翻译人员有意义的合理的描述性变量名。占位符数组还提供关于每个占位符值的附加信息,最有用的部分可能是类型值(在本例中,它告诉翻译人员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/zh-CN/out.gotext.json internal/translations/locales/zh-CN/messages.gotext.json

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"
                }
            ]
        }
    ]
}

internal/translations/locales/zh-CN/messages.gotext.json文件

{
    "language": "zh-CN",
    "messages": [
        {
            "id": "Welcome!",
            "message": "Welcome!",
            "translation": "欢迎光临"
        },
        {
            "id": "{TotalBookCount} books available",
            "message": "{TotalBookCount} books available",
            "translation": "总共{TotalBookCount}本书可阅读",
            "placeholders": [
                {
                    "id": "TotalBookCount",
                    "string": "%[1]d",
                    "type": "int",
                    "underlyingType": "int",
                    "argNum": 1,
                    "expr": "totalBookCount"
                }
            ]
        }
    ]
}

确保两个message .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/zh-cn
欢迎光临
总共1,252,794本书可阅读

效果不错,正如我们通过message.Printer翻译,其很智能地将对应的变量值也输出到翻译中而且根据语言进行格式化。我们看到在中文和英文中数字是用"," 分隔的,而在德语中使用"."分隔。

处理多元化

如果我们的书店只有一本书可读,会发生什么呢?让我们更新handleHome()函数,使totalBookCount值为1,修改 cmd/www/handlers.go文件:

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 "zh-cn":
        lang = language.MustParse("zh-CN")
    default:
        http.NotFound(w, r)
        return
    }

    // 定义一个变量来保存书的数量。 在实际应用中数量需查询数据库的到
    var totalBookCount = 1

    p := message.NewPrinter(lang)
    p.Fprintf(w, "Welcome!\n")

    //使用Fprintf() 函数在响应中添加书数量
    p.Fprintf(w, "%d books available\n", totalBookCount)
}

你可以想象,当我们重新启动应用程序并向localhost:4018/en-gb发出请求时会发生什么。

$ curl localhost:4018/en-gb
Welcome!
1 books available

很明显看到“1 books available”,这在英语语法中并不正确,因books是复数。1本书的正确显示应该是单数才合理例如“1 book available”或者“One book available”效果更好。

我们可以根据messages.gotext.json文件中变量的值指定替代翻译。
更新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"
                }
            ]
        }
    ]
}

现在,我们不再将翻译值设置为简单的字符串,而是将其设置为一个JSON对象,该对象指示消息目录根据TotalBookCount占位符的值使用不同的翻译。这里的关键部分是case值,它包含占位符的不同值所使用的翻译。支持的案例规则是:

分类 描述
"=x" x是整数等于占位符中的值|
"<x" x是整数小于占位符中的值
"other" 其他情况,类似switch中的default语句|

我们更新下其他的message.gotext.json文件:
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"
                }
            ]
        }
    ]
}

internal/translations/locales/zh-CN/messages.gotext.json文件

{
    "language": "zh-CN",
    "messages": [
        {
            "id": "Welcome!",
            "message": "Welcome!",
            "translation": "欢迎光临"
        },
        {
            "id": "{TotalBookCount} books available",
            "message": "{TotalBookCount} books available",
            "translation":  {
                "select": {
                    "feature": "plural",
                    "arg": "TotalBookCount",
                    "cases": {
                        "=1": {
                            "msg": "只有1本书可读"
                        },
                        "other": {
                            "msg": "总共{TotalBookCount}本书可读"
                        }
                    }
                }
            },
            "placeholders": [
                {
                    "id": "TotalBookCount",
                    "string": "%[1]d",
                    "type": "int",
                    "underlyingType": "int",
                    "argNum": 1,
                    "expr": "totalBookCount"
                }
            ]
        }
    ]
}

一旦这些文件被保存,再次使用go generate更新消息目录:

go generate ./internal/translations/translations.go

重启服务然后发起HTTP请求,可以看到如下信息:

➜  ~ curl localhost:4018/en-gb
Welcome!
One book available

➜  ~ curl localhost:4018/de-de
Willkommen!
Ein Buch erhältlich

➜  ~ curl localhost:4018/zh-cn
欢迎光临
只有1本书可读

可以将book的数量修改大点查看响应内容:

curl localhost:4018/en-gb
Welcome!
1,252,794 books available

结果和预期的一样。

创建本地化抽象

在本文的最后一部分,我们将创建一个新的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文件中添加如下代码:

package localizer

import (
    // 引入internal/translations包,确保 init()函数被调用
    _ "bookstore.example.com/internal/translations"

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

// 定义Localizer 类型保存相关本地化ID (和URL中使用类似)
// 创建不可导出的message.Printer实例
type Localizer struct {
    ID      string
    printer *message.Printer
}

// 初始化一个片,其中保存了我们支持的每个区域设置的初始化本地化器类型。
var locales = []Localizer{
    {
        // 德国
        ID:      "de-de",
        printer: message.NewPrinter(language.MustParse("de-DE")),
    },
    {
        // 中国
        ID:      "zh-cn",
        printer: message.NewPrinter(language.MustParse("zh-CN")),
    },
    {
        //英国
        ID:      "en-gb",
        printer: message.NewPrinter(language.MustParse("en-GB")),
    },
}

// Get() 函数接收一个本地化ID,并返回对应本地化实例
// 如果本地化ID不支持将返回空,false作为第二个参数值。
func Get(id string) (Localizer, bool) {
    for _, locale := range locales {
        if id == locale.ID {

            return locale, true
        }
    }

    return Localizer{}, false
}

// 为本地化类型增加Translate()方法,该方法对消息和参数进行包装
func (l Localizer) Translate(key message.Reference, args ...interface{}) string {
    return l.printer.Sprintf(key, args...)
}

下面我们更新cmd/www/handlers.go文件来使用新建的Localizer类型, 同时,让handleHome()函数打印一个额外的“Launching soon!”消息。

package main

import (
    "fmt" // New import
    "net/http"

    "bookstore.example.com/internal/localizer" // New import
)

func handleHome(w http.ResponseWriter, r *http.Request) {
    //基于URL中的区域设置ID初始化一个新的本地化器。
    l, ok := localizer.Get(r.URL.Query().Get(":locale"))
    if !ok {
        http.NotFound(w, r)
        return
    }

    var totalBookCount = 1_252_794

    // 使用Translate()方法.
    fmt.Fprintln(w, l.Translate("Welcome!"))
    fmt.Fprintln(w, l.Translate("%d books available", totalBookCount))

    //增加 "Launching soon!"消息.
    fmt.Fprintln(w, l.Translate("Launching soon!"))
}

需指出的是,我们在这里使用的Translate()方法不仅仅是一个语法糖。你可能还记得我之前写过的警告:

当gotext遍历你的代码时,它实际上只查找对message.Printer.Printf()、Fprintf()和Sprintf()的调用——基本上这三个方法都以f结尾。忽略所有其他方法,如Sprint()或Println()。

将所有翻译都统一使用Translate()方法完成,其实现使用Sprintf,可以避免我们自己在使用过程中误用Sprint或Println,导致gotext无法提取翻译信息到json文件。
下面我们再次执行go generate:

go generate ./internal/translations/translations.go
de-DE: Missing entry for "Launching soon!".
zh-CN: Missing entry for "Launching soon!".

我们可以看到,gotext已经足够智能,可以遍历整个代码库并确定需要翻译的字符串,甚至当我们将message.Printer.Sprintf()调用抽象到另一个包中的helper函数时也是如此。

请继续将out.gotext.json文件复制到message.gotext.json文件,并为新的“launch soon!”消息添加必要的翻译。然后记住再次运行go generate并重新启动web应用程序。

当你再次发出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/zh-cn
欢迎光临
总共1,252,794本书可读
即将启动

附加信息

路由冲突

在这篇文章的开头,我故意不推荐使用httprouter,尽管它是一个非常优秀和流行的路由器。这是因为使用动态区域作为URL路径的第一部分可能会导致与其他不需要地区前缀的应用程序路由冲突,如/static/css/main.css或/admin/login。httprouter包不允许冲突路由,这使得在这个场景中使用它很尴尬。如果您确实希望使用httprouter,或者希望避免应用程序中的路由冲突,您可以将区域设置作为查询字符串参数例如:/category/travel?locale=gb

赞(0) 打赏
未经允许不得转载:IDEA激活码 » Go国际化:翻译管理

一个分享Java & Python知识的社区