Pull to refresh
1025.59
OTUS
Цифровые навыки от ведущих экспертов

i18n в Go: работа с переводами — Часть 1

Reading time14 min
Views5.9K
Original author: alexedwards.net

Недавно мне впервые довелось создавать полностью интернационализированное (i18n) и локализированное (L10n) веб-приложение, в котором я задействоал набор пакетов Go golang.org/x/text. Я обнаружил, что пакеты и инструменты, собранные в golang.org/x/text, невероятно полезны и очень хорошо спроектированы.  Однако мне было довольно сложно понять, как объединить все это вместе в реальном приложении.

В этом руководстве я постараюсь объяснить, как вы можете использовать golang.org/x/text для работы с переводами в вашем приложении. В частности:

  • Как использовать golang.org/x/text/language и golang.org/x/text/message для вывода переведенных сообщений из вашего Go-кода.

  • Как использовать gotext для автоматического извлечения сообщений для перевода из вашего кода в JSON-файлы.

  • Как использовать gotext для синтаксического анализа переведенных JSON-файлов и создания каталога, содержащего переведенные сообщения.

  • Как работать с переменными в сообщениях и предоставлять варианты перевода для множественного числа (во второй части).

Примечание: На случай, если вы еще не в курсе, пакеты, находящиеся в golang.org/x, являются частью официального Go Project, но не включены в основное дерево стандартной библиотеки Go. Они соответствуют не таким строгим стандартам, как пакеты стандартной библиотеки, а это означает, что на них не распространяется гарантия обратной совместимости Go (т. е. их API могут измениться), а документация не всегда может быть полной.

Над чем мы будем работать

Чтобы лучше погрузиться в контекст, мы создадим простенький макет сайта для абстрактного книжного интернет-магазина. Мы начнем практически с нуля и будем постепенно шаг за шагом добавлять код.

Наше приложение будет состоять всего из одной страницы, а локализовать ее содержимое мы будем на основе идентификатора локали в начале URL. Мы реализуем в нашем приложении поддержку трех разных регионов: Великобритании, Германии и франкоговорящей части Швейцарии.

URL

Локализация для

localhost:4018/en-gb

Великобритания

localhost:4018/de-de

Германия

localhost:4018/fr-ch

Швейцария (франкоговорящая часть)

В рамках общепринятого соглашения мы будем использовать в качестве идентификаторов локали в наших URL языковые теги BCP 47. Языковые теги BCP 47 обычно имеют формат {language}-{region}, что значительно упрощает это руководство. Языковая часть (language) — это код ISO 639-1, а регион (region) — двухбуквенный код страны из ISO_3166-1. Обычно регион пишется заглавными буквами (например, en-GB), но технически теги BCP 47 нечувствительны к регистру, и в наших URL мы можем писать все строчными буквы.

Скаффолдинг веб-приложения

Мы начнем работу над приложением с создания новой папки под наш проект. Нам нужно будет выполнить следующие команды:

$ mkdir bookstore
$ cd bookstore
$ go mod init bookstore.example.com
go: creating new go.mod: module bookstore.example.com

Теперь в корневой папке проекта расположен файл go.mod с путем к модулю bookstore.example.com.

Затем мы создадим новую папку cmd/www для хранения кода веб-приложения книжного магазина и добавим туда файлы main.go и handlers.go следующим образом:

$ mkdir -p cmd/www
$ touch cmd/www/main.go  cmd/www/handlers.go

Структура папок нашего проекта теперь должна выглядеть вот так:

.
├── cmd
│   └── www
│       ├── handlers.go
│       └── main.go
└── go.mod

Теперь мы добавим код для объявления маршрутов нашего приложения и запуска HTTP-сервера в файл cmd/www/main.go.

Поскольку URL-адреса нашего приложения всегда будут использовать (динамическую) локаль в качестве префикса — например, /en-gb/bestsellers или /fr-ch/bestsellers — для нас будет проще всего, если наше приложение будет использовать сторонний роутер, который поддерживает динамические значения в сегментах URL. Здесь я буду использовать pat, но вы можете выбрать любую удобную для вас альтернативу, например chi или gorilla/mux, если вам так угодно.

Примечание: Если вы не уверены, какой роутер следует задействовать в своем проекте, вы можете почитать мой обзор самых популярных Go-роутеров.

Отлично, откроем main.go и добавим следующий код:

File: cmd/www/main.go
package main

import (
    "log"
    "net/http"

    "github.com/bmizerany/pat"
)

func main() {
    // Инициализируем роутер, добавляем путь и хендлер для домашней страницы.
    mux := pat.New()
    mux.Get("/:locale", http.HandlerFunc(handleHome))

    // Запускаем HTTP-сервер с помощью роутера.  
    log.Print("starting server on :4018...")
    err := http.ListenAndServe(":4018", mux)
    log.Fatal(err)
}

Затем в файл cmd/www/handlers.go добавляем функцию handleHome(), которая извлекает идентификатор локали из 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 Not Found.
    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, мы обнаружим, что соответствующая локаль возвращается к нам следующим образом:

$ curl localhost:4018/en-gb
The locale is en-gb

$ curl localhost:4018/de-de
The locale is de-de

$ curl localhost:4018/fr-ch
The locale is fr-ch

$ curl localhost:4018/da-DK
404 page not found

Извлечение и перевод текстового содержимого

Теперь, когда мы заложили основу для нашего веб-приложения, давайте перейдем к сути этого руководства и сделаем так, чтобы функция handleHome() отображала сообщение "Welcome!", переведенное в соответствии с полученной локалью.

В рамках этого проекта в качестве “исходного” или “базового” языка по умолчанию в нашем приложении мы будем использовать британский английский (en-GB), но мы также хотим отображать переведенную версию приветственного сообщения на немецком и французском языках для других локализаций.

Для этого нам потребуется импортировать golang.org/x/text/language и golang.org/x/text/message и обновить нашу функцию handleHome(), чтобы она делала следующее:

  1. Создавала language.Tag, идентифицирующий целевой язык, на который мы хотим перевести сообщение. Пакет language содержит несколько предопределенных тегов для распространенных языковых вариантов, но я считаю, что нам будет проще использовать функцию language.MustParse() для создания тега. Она позволяет создать language.Tag для любого допустимого значения BCP 47, например, 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")

    // Объявляем переменную для хранения тега языка перевода.
    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 "fr-ch":
        lang = language.MustParse("fr-CH")
    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/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!

В каждом из случаев мы видим сообщение "Welcome!" на нашем базовом языке (en-GB). Это потому, что нам все еще нужно предоставить пакету Go message фактические переводы, которые мы хотим использовать. При отсутствии фактических переводов он будет отображать сообщения на исходном языке.

Есть несколько способов предоставить пакету Go message переводы, но для большинства нетривиальных приложений, вероятнее всего, наилучшим  решением будет использовать какое-нибудь автоматизированное решение, которое поможет вам справиться с этой задачей. К счастью, Go предоставляет gotext, который мы и будем использовать.

Примечание: Инструмент gotext, который мы будем использовать для нашего сайта, взят с golang.org/x/text/cmd/gotext. Не путайте его с github.com/leonelquinteros/gotext (который предназначен для работы с утилитами gettext GNU и PO/MO файлами).

Чтобы установить исполняемый файл gotext на наш компьютер, мы воспользуемся go install:

$ 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 — его функциональность просто превосходна — но есть пара важных моментов, на которые следует обратить внимание, прежде чем мы продолжим.

Во-первых, gotext предназначен для работы в сочетании с go generate, а не как отдельный инструмент командной строки. Вы можете запускать его как отдельный инструмент, но вместе с этим будут происходить странные вещи. Если вы будете использовать его так, как было задумано, то работа с ним не будет вызывать никаких проблем.

Еще один момент заключается в том, что документация и справочная информация для него практически отсутствуют. Лучшим руководством по его использованию являются примеры в репозитории и, вполне возможно, эта статья, которую вы сейчас читаете. Существует открытый ишью об отсутствии какой-либо справки, и я очень надеюсь, что в будущем дела с этим будут обстоять лучше.

В рамках этого руководства мы будем хранить весь код, относящийся к переводам, в новом пакете internal/translations. Мы могли бы хранить весь код нашего веб-приложения, связанный с переводами, в 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 для извлечения сообщений на перевод из нашего приложения.

File: internal/translations/translations.go
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 в его реализации здесь.

Что ж, давайте вызовем функцию go generate для нашего файла translations.go. В свою очередь, это выполнит команду 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": ""
        }
    ]
}

Соответствующий BCP 47 тег language определяется в верхней части этого JSON-файла, за которым следует массив messages (сообщения, требующие перевода). Значение 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 !"
        }
    ]
}

Если хотите, вы также можете взглянуть на out.gotext.json для нашего исходного языка en-GB. Вы увидите, что значение 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
// Код, сгенерированный запуском "go generate" в golang.org/x/text. НЕ РЕДАКТИРОВАТЬ.

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,
} // Размер: 32 байта

const de_DEData string = "\x04\x00\x01\n\f\x02Willkommen!"

var en_GBIndex = []uint32{ // 2 elements
    0x00000000, 0x0000000e,
} // Размер: 32 байта

const en_GBData string = "\x04\x00\x01\n\t\x02Welcome!"

var fr_CHIndex = []uint32{ // 2 elements
    0x00000000, 0x00000010,
} // Размер: 32 байта

const fr_CHData string = "\x04\x00\x01\n\v\x02Bienvenue !"

// Общий размер таблицы 143 байта (0 КБ); контрольная сумма: 385F6E56

Я не хочу здесь подробно останавливаться на деталях, потому что можно использовать этот файл как нечто вроде “черного ящика”, и — как предупреждает комментарий в начале файла — нам не следует вносить в него никаких изменений напрямую.

Но самое важное, на что следует обратить внимание, это то, что этот файл содержит функцию init(), которая при вызове инициализирует новый каталог сообщений, содержащий все наши переводы и сопоставления. Затем она устанавливает его в качестве каталога сообщений по умолчанию, присваивая его глобальной переменной message.DefaultCatalog.

Когда мы вызываем одну из функций message.Printer, принтер будет искать для вывода соответствующий перевод в каталоге сообщений по умолчанию. Это очень хорошо, потому что это означает, что все наши переводы сохраняются в памяти во время выполнения, и любой поиск выполняется очень быстро и эффективно.

Теперь, если мы сделаем шаг назад, мы увидим, что команда gotext update, которую мы используем с go generate, на самом деле делает две вещи. Во-первых  — она просматривает код в нашем cmd/www и извлекает необходимые строки для перевода в out.gotext.json; и во-вторых — она также анализирует любые messages.gotext.json (если они есть) и соответствующим образом обновляет каталог сообщений.

Последний шаг на этом этапе — импорт internal/translations в наш cmd/www/handlers.go. Это гарантирует, что будет вызвана функция init() в файле internal/translations/catalog.go и каталог сообщений по умолчанию обновится, чтобы он содержал наши переводы. Поскольку на мы не будем напрямую ссылаться на что-либо в пакете internal/translations, нам нужно указать в качестве пути импорта пустой идентификатор _, чтобы компилятор Go нам на это не жаловался.

Сделаем это прямо сейчас:

File: 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 "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 !

Завтра состоится открытое занятие по теме «Горутины и каналы», на котором попробуем начать работу с горутинами:
— Узнаем, что такое горутины и как их запускать.
— Сравним буферизированные и небуферизованные каналы.
— Поговорим про использование каналов для передачи данных и синхронизации.
— А также затронем оператор select и таймеры в Go.
После занятия вы сможете реализовать передачу данных между горутинами с помощью канала. Регистрация доступна по ссылке.

Также приглашаем на открытый урок «Примитивы синхронизации в Go», после которого вы сможете пользоваться частью механизмов синхронизации в Go и бороться с «гонками» в Go. Регистрация для всех желающих здесь.

Tags:
Hubs:
Total votes 13: ↑10 and ↓3+7
Comments3

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS