Использование переменных в переводах
В предыдущей части мы реализовали в нашем приложении базовый функционал перевода сообщений. Теперь давайте сделаем что-то более сложное — разберемся, как работать с переводами, содержащими переменные.
Чтобы продемонстрировать как работать с переменными, мы добавим в 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
}
// Определяем переменную для хранения количества книг. В реальном приложении
// она, вероятно, будет получена путем выполнения запроса к базе данных или
// чего-то подобного.
var totalBookCount = 1_252_794
p := message.NewPrinter(lang)
p.Fprintf(w, "Welcome!\n")
// Чтобы включить в HTTP-ответ новое сообщение с количеством книг в виде
// интерполированного целочисленного значения, мы используем функцию 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".
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"
, где мы видим имя переменной (с заглавной буквы) из нашего кода, которое используется в качестве параметра-заполнителя. Вы должны учитывать это в процессе написания кода и стараться использовать вразумительные и достаточно информативные имена переменных, которые будут легко поняты вашими переводчиками. Массив 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
}
// Установим общее количество книг равным 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" | Все остальные случаи (по сути как default в операторе ветвления Go) |
Примечание: Если вы решите посмотреть документацию по golang.org/x/text/feature/plural (это то, что gotext
использует под капотом при создании каталога сообщений), вы можете обратить внимание, что в нем также упоминаются правила "zero", "one", "two", "few" и "many". Однако эти правила поддерживаются не для всех возможных целевых языков, и из-за этого вы можете получить сообщение об ошибке типа gotext: generation failed: error: plural: form "many" not supported for language "de-DE
", если вы будете их использовать в нашем приложении. Похоже, что самый безопасный вариант работы — придерживаться трех правил, приведенных в таблице выше. Кроме того, важно учитывать, что диапазон допустимых значений для x в этих правилах "=x" и "<x" составляет от 0 до 32767. Попытка использовать что-то за пределами этого диапазона приведет к ошибке. По этому поводу уже существует открытый ишью.
Давайте завершим работу над этим, обновив файлы messages.gotext.json
для локалей de-DE и fr-CH , включив в них соответствующие вариации во множественном числе, как показано ниже:
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) {
...
// Восстановим общее количество книг.
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 (
// Мы импортируем internal/translations в целях запуска его функции init().
// Очень важно сделать это именно здесь, чтобы каталог сообщений по умолчанию
// обновлялся для наших переводов *до* инициализации инстансов message.Printer
// ниже.
_ "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
}
// Инициализируем слайс, который содержит инициализированные типы Localizer
// для каждой из наших поддерживаемых локалей.
var locales = []Localizer{
{
// Германия
ID: "de-de",
printer: message.NewPrinter(language.MustParse("de-DE")),
},
{
// Швейцария (франкоговорящая часть)
ID: "fr-ch",
printer: message.NewPrinter(language.MustParse("fr-CH")),
},
{
// Великобритания
ID: "en-gb",
printer: message.NewPrinter(language.MustParse("en-GB")),
},
}
// Функция Get() принимает ID локали и возвращает соответствующий ей локализатор.
// Если ID локали не поддерживается, то вторым return возвращается false.
func Get(id string) (Localizer, bool) {
for _, locale := range locales {
if id == locale.ID {
return locale, true
}
}
return Localizer{}, false
}
// Мы также добавляем метод Translate() к типу Localizer. Это действует
// как обертка вокруг Функции Sprintf() неэкспортированного message.Printer
// и возвращает соответствующий перевод для данного сообщения и аргументов.
func (l Localizer) Translate(key message.Reference, args ...interface{}) string {
return l.printer.Sprintf(key, args...)
}
Примечание: Обратите внимание, что здесь во время запуска мы инициализируем по одному message.Printer
для каждой локали, которые будут параллельно использоваться хендлерами нашего веб-приложения. Хотя в документации по 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" // Новый импорт
"net/http"
"bookstore.example.com/internal/localizer" // New import
)
func handleHome(w http.ResponseWriter, r *http.Request) {
// Инициализируем новый локализатор на основе ID локали в URL.
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
не извлекает сообщение в файлы 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, несмотря на то, что это отличный и очень популярный роутер. Это связано с тем, что использование динамической локали в качестве первой части URL может привести к конфликтам с другими маршрутами приложений, для которых не требуется префикс с локалью, например /static/css/main.css
или /admin/login
. Пакет httprouter не допускает конфликтующих маршрутов, что затрудняет его использование в данном сценарии. Если вы хотите использовать httprouter или хотите избежать конфликтующих маршрутов в своем приложении, вы можете вместо этого передавать локаль в качестве параметра строки запроса, например /category/travel?locale=gb
.
Если вам понравилась эта статья, вы также можете посмотреть список рекомендуемых туториалов или ознакомиться с моими книгами Let's Go и Let's Go Further, которые научат вас всему, что вам нужно знать о том, как создавать профессиональные готовые к продакшену веб-приложения и API с помощью Go.
Приглашаем всех желающих на открытое занятие «Примитивы синхронизации в Go», на котором мы:
— Поговорим про группу ожидания (sync.WaitGroup)
— Затронем гарантированно одноразовое выполнение (sync.Once)
— Обсудим "простой" мьютекс (sync.Mutex) и детектор гонок (race detector).
После занятия вы сможете пользоваться частью механизмов синхронизации в Go и бороться с «гонками» в Go. Регистрация здесь.