如何使用golang.org/x/text 包来管理应用程序中的翻译(超详细指南)

312 阅读16分钟

最近,我第一次使用Go的 golang.org/x/text包。我发现,生活在golang.org/x/text 下的包和工具真的很有效,而且设计得很好,尽管要弄清楚如何把它放在一个真正的应用中,是一个有点难度的挑战。

注意:如果你还没有意识到,生活在golang.org/x 下的软件包是官方 Go 项目的一部分,但在主 Go 标准库树之外。它们的标准比标准库包要宽松,这意味着它们不受 Go 兼容性承诺的约束(即它们的 API 可能会改变),而且文档也不一定完整。

在本教程中,我想解释如何使用golang.org/x/text 包来管理应用程序中的翻译。具体而言:

  • 如何使用golang.org/x/text/languagegolang.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.gohandlers.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,但也可以自由地使用另一种方式,如 chigorilla/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/languagegolang.org/x/text/message包,并更新我们的handleHome() 函数来做以下两件事:

  1. 构建一个 language.Tag来确定我们要将信息翻译成的目标语言。language 包包含了一些预定义的标签,用于常见的语言变体,但我发现,使用 language.MustParse()函数来创建一个标签。这让你可以为任何有效的BCP 47值创建一个language.Tag ,如language.MustParse("fr-CH")
  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

    // 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 值是我们应该输入适当的德语翻译的地方。

需要强调的是,你不要在原地编辑这个文件。相反,添加翻译的工作流程是这样的:

  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/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-DEfr-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/loginhttprouter 包不允许冲突的路由,这使得在这种情况下使用它很尴尬。如果你确实想使用httprouter ,或者想在你的应用程序中避免冲突的路由,你可以把locale作为一个查询字符串参数来传递,比如/category/travel?locale=gb