Интернационализация (i18n)¶
Как локализуем user-facing контент в backend-сервисах: где хранятся
переводы, как выбирается язык, что локализуем и что нет. Reference —
эта страница. Template-injection и безопасность рендеринга —
security.md.
Содержание¶
- Поддерживаемые языки
- Где локализация
- 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 идёт по цепочке:
Пример: запросили 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.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")
}
Добавление нового ключа¶
- Добавь ключ во все три файла
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 строки¶
Нарушение — ни тестируется, ни переводится, ни поддерживается. Все
user-facing строки — через i18n.Render.
fmt.Sprintf вместо template¶
Кроме того, что захардкожено, fmt.Sprintf не даёт HTML-escape для
email'ов → уязвимость. Всегда template.
Конкатенация локализованных фрагментов¶
Грамматика разных языков не позволяет просто склеивать. В казахском
суффикс склеивается с именем, в русском — ещё падежи. Один ключ на
полный шаблон — "Привет, {{.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 — 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.