Перейти к содержанию

Интернационализация (i18n)

Как локализуем user-facing контент в backend-сервисах: где хранятся переводы, как выбирается язык, что локализуем и что нет. Reference — эта страница. Template-injection и безопасность рендеринга — security.md.

Содержание

Поддерживаемые языки

Код Язык Роль
kk Казахский основной
ru Русский основной + default fallback
en Английский резервный

Новые языки добавляются через convention-PR: обсуждение → добавление папки → бэкфилл переводов → включение в CI-проверку полноты.

Любой user-facing контент должен быть во всех трёх языках одновременно. Ключ, который есть в ru, но отсутствует в kk — это баг (CI его поймает).

Где локализация

Переводы живут в сервис-репо, в пакете internal/i18n/:

internal/i18n/
├── i18n.go                  — loader, рендер, fallback
├── translations/
│   ├── kk.yaml
│   ├── ru.yaml
│   └── en.yaml
└── i18n_test.go             — coverage / snapshot tests

Формат — YAML с вложенной структурой по домену:

# internal/i18n/translations/ru.yaml
notification:
  review_replied:
    title: "На ваш отзыв ответили"
    body: "Пользователь {{.AuthorName}} ответил на ваш отзыв о {{.PlaceName}}"
  photo_approved:
    title: "Фотография одобрена"
    body: "Ваша фотография места {{.PlaceName}} прошла модерацию"

auth:
  email_verified:
    title: "Email подтверждён"
    body: "Ваш email {{.Email}} успешно подтверждён"

Вложенность помогает группировать по домену (notification, auth, moderation). Глубина — до трёх уровней, дальше становится нечитаемо.

Ключи — snake_case, английский. Содержимое значений — на соответствующем языке.

Fallback-chain

Если ключа нет в запрошенном языке, loader идёт по цепочке:

<lang> → ru → en

Пример: запросили notification.photo_approved.title на kk:

  1. Искать в kk.yaml — нет.
  2. Искать в ru.yaml — есть → вернуть.

Если и в ru нет — en. Если и там нет — loader возвращает сам ключ (notification.photo_approved.title) и логирует WARN. Это signal-of- last-resort: в норме такого быть не должно (CI блокирует неполные ключи).

ru как промежуточный fallback выбран, потому что исторически переводы появляются там первыми; en — технический базис (есть всегда, контент — обычно короткая английская фраза).

Выбор языка

Notification

Язык берётся из профиля пользователя, поле preferences.language (строка kk/ru/en). Default при создании пользователя — ru. Handler при построении сообщения:

lang := user.Preferences.Language
if lang == "" {
    lang = "ru"
}
msg := i18n.Render("notification.review_replied", lang, data)

HTTP API (error messages)

Для messages, которые видит пользователь в UI, есть два пути:

  • Frontend делает lookup сам. Backend отдаёт error_code (review_not_found, invalid_rating), frontend по своим справочникам показывает локализованный текст. Это основной путь.
  • Backend форматирует текст сам по Accept-Language. Опциональный путь — используется, если frontend не может (например, сторонний интегратор запрашивает API напрямую). Парсим header Accept-Language: kk, ru;q=0.9, en;q=0.8 → выбираем наиболее подходящий из поддерживаемых → рендерим. Если header не пришёл — ru.

Internal logs

Всегда en. Логи читают инженеры, не пользователи. Локализация логов усложняет grep'ы и создаёт нагрузку на loader при каждом log.Info(...). Плюс — ../conventions/logging.md предписывает логи на английском.

Где используем

  • Notification templates (push/email/sms/in-app) — каждый template в kk/ru/en. Основной use-case i18n.
  • Эмотивные ошибки клиенту (например «Отзыв не найден», «У вас уже есть отзыв на это место») — если frontend ожидает через Accept-Language. Обычно — лучше отдавать error_code и frontend сам делает lookup.
  • Бизнес-контент (категории мест, пресеты фильтров, системные сообщения) — вне scope backend'а. Обычно такое живёт в content management (база данных или отдельный контент-репо), owners сами решают формат в UI.

НЕ локализуем

  • Логи. Всегда en (см. выше).
  • Метрики и label'ы. http_requests_total{endpoint="/v1/reviews"} — никаких русских слов.
  • Sentinel-ошибки в Go. ErrUserNotFound, ErrReviewNotFound — это имена типов Go, не текст для пользователя.
  • API response error_code. Это machine-readable slug: user_not_found, rating_out_of_range. Всегда английский snake_case. Локализация этого поля — антипаттерн, см. ниже.
  • Имена event'ов, topic'ов, header'ов. Всё протокольное — en.

Template syntax

Используем стандартный Go text/template или html/template. Для email — обязательно html/template (автоэкранирование HTML-injection). Для push/sms — text/template достаточно.

Рендеринг через helper:

msg := t.Render("notification.review_replied", "ru", map[string]any{
    "AuthorName": "Алексей",
    "PlaceName":  "Kafe Lounge",
})

Возвращаемый result — структура с Title и Body (для многочастевых шаблонов), либо просто string (для single-value ключей).

Template-значения:

notification:
  review_replied:
    title: "На ваш отзыв ответили"
    body: "Пользователь {{.AuthorName}} ответил на ваш отзыв о {{.PlaceName}}"

Правила:

  • Именованные поля в шаблоне, не порядковые ({{.AuthorName}}, не {{.0}}). Порядок аргументов меняется при переводе (в казахском подлежащее может быть в конце), именованные поля — независимы.
  • Не хранить HTML-фрагменты в переводах. Всё форматирование (<b>, <a>) — в самом html/template, в YAML только текст.
  • Plural forms через внешний helper. «5 отзывов» / «1 отзыв» — это plural(count, "отзыв", "отзыва", "отзывов") на русском, отдельная функция, не шаблон. Для казахского plural'ы проще ({{.Count}} пікір), но помощник всё равно нужен, чтобы не дублировать правила в каждом шаблоне.

Loader

Загрузка YAML — через //go:embed, чтобы файлы попадали в binary и не зависели от файловой системы в рантайме:

package i18n

import (
    "embed"
    "fmt"

    "gopkg.in/yaml.v3"
)

//go:embed translations/*.yaml
var translationsFS embed.FS

type Translator struct {
    dict map[string]map[string]any // lang -> nested map
}

func New() (*Translator, error) {
    t := &Translator{dict: map[string]map[string]any{}}
    entries, err := translationsFS.ReadDir("translations")
    if err != nil {
        return nil, err
    }
    for _, e := range entries {
        data, err := translationsFS.ReadFile("translations/" + e.Name())
        if err != nil {
            return nil, fmt.Errorf("read %s: %w", e.Name(), err)
        }
        var m map[string]any
        if err := yaml.Unmarshal(data, &m); err != nil {
            return nil, fmt.Errorf("parse %s: %w", e.Name(), err)
        }
        lang := strings.TrimSuffix(e.Name(), ".yaml")
        t.dict[lang] = m
    }
    return t, nil
}

func (t *Translator) Render(key, lang string, data map[string]any) string {
    tpl := t.lookup(key, lang) // с fallback-chain
    return renderTemplate(tpl, data)
}

В main.go loader инстанциируется один раз и передаётся service'ам как зависимость (см. dependency-injection.md).

Тесты

Три уровня проверок:

Coverage: каждый ключ во всех языках

Скрипт (или unit-тест) пробегается по всем ключам в en.yaml и проверяет, что ровно те же ключи есть в ru.yaml и kk.yaml:

func TestTranslations_CompleteCoverage(t *testing.T) {
    en := loadKeys("translations/en.yaml")
    for _, lang := range []string{"ru", "kk"} {
        other := loadKeys("translations/" + lang + ".yaml")
        for k := range en {
            if _, ok := other[k]; !ok {
                t.Errorf("missing key %q in %s", k, lang)
            }
        }
    }
}

CI падает — PR не мержится, пока не заполнены все языки.

Rendering: snapshot-тест

Для каждого template × language:

func TestRender_ReviewReplied(t *testing.T) {
    tr, _ := i18n.New()
    data := map[string]any{"AuthorName": "Алексей", "PlaceName": "Kafe Lounge"}
    for _, lang := range []string{"kk", "ru", "en"} {
        got := tr.Render("notification.review_replied", lang, data)
        // сравнить с snapshot в testdata/review_replied_<lang>.txt
        approveSnapshot(t, "review_replied_"+lang, got)
    }
}

Snapshot'ы коммитятся, diff виден в PR — перевод менялся или нет.

Template safety

HTML-значения в data не должны ломать рендеринг. Для email'ов с html/template — автоэкранирование поверяет сам рендер, но негативный тест тоже нужен:

got := tr.Render("notification.review_replied", "ru", map[string]any{
    "AuthorName": `<script>alert(1)</script>`,
    "PlaceName":  "X",
})
if strings.Contains(got, "<script>") {
    t.Fatal("html injection not escaped")
}

Добавление нового ключа

  1. Добавь ключ во все три файла kk.yaml / ru.yaml / en.yaml.
  2. Обнови snapshot-тесты (если включены).
  3. Запусти go test ./internal/i18n/... — проверка coverage и snapshot'ов должна пройти.
  4. PR. CI дополнительно упадёт, если ключ не везде.

Если автор PR не знает казахского — он может оставить в kk временно русский текст с комментарием # TODO: translate to kk и приложить тикет к PR. Но CI не даст смержить: нужно найти переводчика (команда ведёт список) или использовать заглушку-placeholder.

Изменение ключа

  • Rename (ключ notification.review_repliednotification. review_answered) — breaking:
  • Добавь новый ключ параллельно со старым.
  • Выкати в прод, убедись что всё работает.
  • Перевёдь все места в коде на новый ключ.
  • Выкати снова.
  • Удали старый ключ.

  • Изменение текста ключа (перевод стал точнее) — non-breaking. Просто правь значение, PR. Если snapshot-тест есть — обновить snapshot.

  • Добавление переменной в template (было {{.AuthorName}}, стало {{.AuthorName}} ({{.AuthorRole}})) — breaking для вызывающего кода: он должен передать новый AuthorRole в data. Сначала выкати код с новым полем, потом — изменение YAML.

Anti-patterns

Hardcoded строки

// Плохо
subject := "Пользователь " + author + " ответил на отзыв"

Нарушение — ни тестируется, ни переводится, ни поддерживается. Все user-facing строки — через i18n.Render.

fmt.Sprintf вместо template

// Плохо
text := fmt.Sprintf("Привет, %s!", name)

Кроме того, что захардкожено, fmt.Sprintf не даёт HTML-escape для email'ов → уязвимость. Всегда template.

Конкатенация локализованных фрагментов

// Плохо
msg := tr.Get("prefix", lang) + " " + name + " " + tr.Get("suffix", lang)

Грамматика разных языков не позволяет просто склеивать. В казахском суффикс склеивается с именем, в русском — ещё падежи. Один ключ на полный шаблон — "Привет, {{.Name}}!".

Один файл translations.yaml без language split

# Плохо
notification:
  review_replied:
    title:
      ru: "На ваш отзыв ответили"
      kk: "Сіздің пікіріңізге жауап берді"
      en: "Your review was replied"

Проблемы: - Огромный файл, сложно навигировать. - Merge-конфликты в каждом PR, затрагивающем i18n. - Coverage-проверка усложняется (нужно для каждого ключа проходить три вложения).

Правильно — separate-file по языку, одна иерархия ключей в каждом.

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

// Плохо
{"error_code": "Пользователь не найден"}

error_code — machine-readable slug (user_not_found). Human-readable часть — в отдельном поле message или на стороне клиента по справочнику. Смешивание ломает API-contract: клиенту невозможно надёжно парсить code, если он меняется от языка.

Один Translator на несколько сервисов (shared i18n service)

Отдельный микросервис «i18n», в который остальные ходят за переводами — лишняя network-dependency, лишний RPS, лишний single-point-of- failure. Перевод — это статические данные, которые можно embed'ить в binary. Никакого отдельного сервиса не нужно.

Связанные разделы

  • security.md — template-injection, html/template для email, экранирование.
  • testing.md — snapshot-тесты, coverage-тесты.
  • logging.md — логи не локализуем, всегда en.
  • dependency-injection.md — как Translator wire'ится в main.go.
  • ../glossary.md — i18n, fallback-chain, Accept-Language.