Использование переменных в переводах

В предыдущей части мы реализовали в нашем приложении базовый функционал перевода сообщений. Теперь давайте сделаем что-то более сложное — разберемся, как работать с переводами, содержащими переменные.

Чтобы продемонстрировать как работать с переменными, мы добавим в 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. Регистрация здесь.

Комментарии (0)