Интернационализация (i18n)
Как локализуем user-facing контент в backend-сервисах: где хранятся
переводы, как выбирается язык, что локализуем и что нет. Reference —
эта страница. Template-injection и безопасность рендеринга —
security.
Содержание
- Поддерживаемые языки
- Где локализация
- Fallback-chain
- Выбор языка
- Где используем
- НЕ локализуем
- Template syntax
- Loader
- Тесты
- Добавление нового ключа
- Изменение ключа
- Anti-patterns
- Связанные разделы
Поддерживаемые языки
| Код | Язык | Роль |
|---|---|---|
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:
- Искать в
kk.yaml— нет. - Искать в
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 напрямую). Парсим headerAccept-Language: kk, ru;q=0.9, en;q=0.8→ выбираем наиболее подходящий из поддерживаемых → рендерим. Если header не пришёл —ru.
Internal logs
Всегда en. Логи читают инженеры, не пользователи. Локализация
логов усложняет grep’ы и создаёт нагрузку на loader при каждом
log.Info(...). Плюс — ../conventions/logging предписывает
логи на английском.
Где используем
- 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).
Тесты
Три уровня проверок:
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")
}Добавление нового ключа
- Добавь ключ во все три файла
kk.yaml/ru.yaml/en.yaml. - Обнови snapshot-тесты (если включены).
- Запусти
go test ./internal/i18n/...— проверка coverage и snapshot’ов должна пройти. - PR. CI дополнительно упадёт, если ключ не везде.
Если автор PR не знает казахского — он может оставить в kk временно
русский текст с комментарием # TODO: translate to kk и приложить
тикет к PR. Но CI не даст смержить: нужно найти переводчика (команда
ведёт список) или использовать заглушку-placeholder.
Изменение ключа
-
Rename (ключ
notification.review_replied→notification. 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— template-injection,html/templateдля email, экранирование.testing— snapshot-тесты, coverage-тесты.logging— логи не локализуем, всегдаen.dependency-injection— как Translator wire’ится вmain.go.../glossary— i18n, fallback-chain, Accept-Language.