Содержание урока · 10 разделов
- Сообщения валидации go: каталог вместо веток if
- Шаблоны сообщений: от тега к тексту
- Уточнение по полю: когда общего текста мало
- Согласование числительных: 1 символ, 2 символа, 5 символов
- json-имена полей: RegisterTagNameFunc
- validator перевод: universalTranslator и готовые словари
- Правила каталога сообщений одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
После пятого урока у вас на руках всё сырьё: Field() даёт имя поля, Tag() — споткнувшийся тег, Param() — его параметр. Осталось превратить сырьё в текст, который пользователь прочтёт без гугла. Разница между «тупым» и человечным ответом видна на одном поле: «Password: проверка min не прошла» против «Пароль — от 8 символов». Информация та же, цена для поддержки разная: первый вариант генерирует тикеты «что мне делать?», второй — сам себя объясняет. В этом уроке мы не будем трогать валидатор — все механики уже разобраны; речь о слое над ним, где живут русские тексты. Задача слоя простая: по каждой записи FieldError выдать строку, а правила этой выдачи — один раз и в одном месте, потому что сообщения, как и валидация, расползаются по кодовой базе мгновенно. Кладите слой рядом с валидатором, но отдельно от хендлеров: пакет messages с парой словарей и одной функцией собирается за полчаса и переезжает из проекта в проект почти без изменений — приёмы урока не зависят ни от фреймворка, ни от набора тегов, поэтому написанное здесь однажды работает и в Gin, и в Echo, и в фоновых задачах без HTTP вовсе.
Сообщения валидации go: каталог вместо веток if#
Главная ошибка стартовой версии любого API — писать тексты прямо в хендлере: switch по тегам, рядом с разбором ValidationErrors. На одном эндпоинте это выглядит невинно, а на четвёртом выясняется, что «обязательное поле» в регистрации, «укажите поле» в профиле и «поле не заполнено» в подписке — три разных текста для одного и того же required. Сообщения валидации go собираются в каталог — обычный package рядом с валидатором, где ключом служит Tag(), а значением — готовый русский текст. Хендлер умеет только одно: перебрать записи, позвать функцию-сообщалку и сложить строки в Details ответа. Двухуровневая структура каталога вытекает из самой FieldError: первый уровень — тексты по тегам, общие для всех полей проекта; второй — уточнения по конкретным полям, потому что «обязательное поле» одинаково честно и для пароля, и для города, а вот «минимум 8 символов» для пароля и «от 3 до 32 символов» для имени — разные разговоры. Порядок проверки уровней фиксированный: сначала уточнение по полю, потом общий текст по тегу — иначе уточнения никогда не сработают. И обязательная страховка на оба уровня: ветка default, которая отдаёт техническую строку с ActualTag(), а не пустоту. Незнакомый тег в каталоге — не повод молчать, а повод узнать о дыре в каталоге из ответа API или лога.
Двухуровневый каталог: уточнение сильнее общего
GOvar tagTexts = map[string]string{
"required": "обязательное поле",
"email": "укажите адрес почты",
"oneof": "допустимые значения: %s",
}
var fieldTexts = map[string]string{
"Phone": "телефон в формате +7XXXXXXXXXX",
"Promo": "промокод — от 4 до 16 символов",
}
func humanMessage(e validator.FieldError) string {
if t, ok := fieldTexts[e.Field()]; ok {
return t // уточнение по полю сильнее общего текста
}
t, ok := tagTexts[e.Tag()]
if !ok {
return e.Field() + ": проверка " + e.ActualTag() + " не прошла"
}
return fmt.Sprintf(t, e.Param())
}Словарь tagTexts обслуживает все поля проекта, fieldTexts — отдельные поля, которые говорят по-своему. Порядок важен: уточнение по Field() проверяется первым, иначе общий текст по Tag() перехватит запрос раньше. Для oneof параметр с перечнем подставляется в шаблон через Sprintf, для required лишний аргумент безвреден. Ветка default с ActualTag() — та же страховка, что и в мини-каталоге прошлого урока.
Шаблоны сообщений: от тега к тексту#
| Тег | Шаблон текста | Что подставляется |
|---|---|---|
| required | обязательное поле | ничего |
| min / max | минимум N символов | Param() — граница |
| укажите адрес почты | ничего | |
| oneof | допустимые значения: %s | Param() — перечень |
| gte / lte | значение от N до M | Param() — граница |
| default | проверка X не прошла | ActualTag() — имя тега |
Уточнение по полю: когда общего текста мало#
Общие тексты по тегам — это основа, но в каждом проекте есть поля, которые заслуживают персонального разговора. Телефон: общий текст min=10 скажет «минимум 10 символов», а пользователь не понимает, в каком формате хочет номер сервис; уточнение fieldTexts["Phone"] говорит «телефон в формате +7XXXXXXXXXX» — и поддержка получает на один тикет меньше. Промокод: min=4 одинаково справедлив для кода и для фамилии, но «промокод — от 4 до 16 символов» снимает вопрос «а что сюда вообще писать». Дата рождения: требование формата лучше сформулировать один раз на поле, чем надеяться, что общий текст datetime догадается. Правило отбора простое: если формулировка общего текста не отвечает на вопрос «что мне ввести», полю положено уточнение. Второе правило — дисциплина объёма: уточнений должно быть на порядок меньше, чем полей, иначе второй уровень разрастается в параллельный каталог, который некому поддерживать. И третье — уточнение по полю выигрывает у уточнения по типу: если хочется для числовых полей говорить «минимум N», а для строковых «минимум N символов», посмотрите на Kind() и Type() из пятого урока — каталог может ветвиться и по ним, без второго словаря.
Проверка: чей текст сильнее
В каталоге есть общий текст для required и уточнение для поля Phone. Поле Phone провалило required. Что вернёт humanMessage?
Согласование числительных: 1 символ, 2 символа, 5 символов#
Русский язык добавляет валидатору проблему, которой нет у английских туториалов: числительные склоняются. Шаблон «минимум N символов» на N=1 выдаёт «минимум 1 символов» — и это ровно тот уровень небрежности, который пользователи замечают мгновенно. Форм три: один символ, два символа, пять символов, и выбор зависит от двух последних цифр. Функция на десяток строк закрывает вопрос навсегда — и переиспользуется во всех шаблонах с числом: символы, элементы, байты, минуты. Есть и честный обходной путь, который выбирают небольшие проекты: обезличенные формулировки вроде «от N символов» и «до N символов» не требуют согласования вовсе. Компромисс рабочий, но от него страдают короткие значения: «от 1 символов» — правдоподобная цена такой экономии. И отдельная ловушка внутри этой темы: min и max на числовом поле говорят не про символы, а про значение — полиморфизм тегов из третьего урока. Один и тот же тег min=1 на строке и на числе требует разных текстов, и каталог, который не различает типы, рано или поздно напишет «минимум 1 символ» про количество детей. Различайте поля по Kind() из пятого урока или разведите уточнения по fieldTexts.
Числительные: три формы на десять строк
GOfunc pluralRu(n int, one, few, many string) string {
m10, m100 := n%10, n%100
switch {
case m10 == 1 && m100 != 11:
return one // 1 символ
case m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14):
return few // 2 символа
default:
return many // 5, 11, 20 символов
}
}
// ветка каталога для min:
case "min":
n, _ := strconv.Atoi(e.Param())
return fmt.Sprintf("минимум %d %s", n,
pluralRu(n, "символ", "символа", "символов"))Три формы русской числительной выбираются по последней цифре и её паре: 1 — единственная, 2-4 — малая, остальное — множественная, с исключением 11-14. Функция принимает формы словами, поэтому работает и для «элементов», и для «минут». Atoi на Param() делает проверку поля-конкретики: параметр тега приходит строкой, а число в шаблон едут уже согласованное.
fieldTexts ключуется по Field() — имени поля структуры. Рефакторинг переименовывает Phone в Contact: компиляция проходит (ключи словаря — строки), рантайм не падает, тесты зелёные — а уточнение перестаёт находиться, и пользователи тихо получают общий текст по тегу. Симптом появляется через недели, когда поддержка спросит, почему формат телефона больше не подсказывается. Страховка — тест, который прогоняет пустую форму и сверяет: для каждого поля из fieldTexts ответ пришёл уточнением, а не общим текстом.
json-имена полей: RegisterTagNameFunc#
Пятый урок оставил шрам: Field() возвращает имя поля структуры, а фронтенд знает json-имя. Пароль в ошибке — «Password», в инпуте — «password», и подсветка на snake_case-фронтах молча не срабатывает. Валидатор решает это штатно: метод RegisterTagNameFunc на экземпляре принимает функцию, которая по reflect.StructField возвращает имя поля — и валидатор дальше использует это имя в Field() и Namespace(). Стандартный вариант — отдавать json-тег. Тут две грабли, на которых спотыкаются все: json-тег с опциями вида "phone,omitempty" вернётся целиком, вместе с опцией, — имя поля получится «phone,omitempty», поэтому тег режут через strings.SplitN по запятой; а поле с json:"-" вернёт минус — имя поля «-» в ошибках не годится, и на минус возвращают пустую строку, чтобы валидатор откатился на имя из структуры. После регистрации маппинг на фронте работает один к одному: Field() говорит json-именами, каталог уточнений по полям ключуется теми же json-именами, а Namespace() строит путь из них — что оживёт в следующем уроке на вложенных структурах. Регистрация — один раз на старте, рядом с созданием экземпляра из второго урока; переключать словарь имён на живом валидаторе нельзя.
RegisterTagNameFunc: осторожно с опциями
GOvalidate := validator.New()
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return "" // json:"-" — вернём имя поля структуры
}
return name
})
// было: Field() → "Phone", Namespace() → "SignupForm.Phone"
// стало: Field() → "phone", Namespace() → "SignupForm.phone"SplitN режет json-тег по первой запятой: «phone» остаётся из «phone,omitempty». Пустая строка на минусе возвращает валидатору право назвать поле по-структурному — без этого поля, скрытые из JSON, получили бы имя «-». После регистрации все имена — Field(), Namespace(), имена в ошибках StructLevel — живут по json-меткам, и фронтенд подсвечивает инпуты без маппинга.
Проверка: что вернёт Field()
Поле Phone string с json:"phone" провалило min=10. RegisterTagNameFunc по json-тегу зарегистрирован. Что вернёт e.Field()?
Что получит пользователь
Прочитайте код и выберите, что он выведет:
type SignupForm struct {
Promo string `validate:"omitempty,min=4"`
Email string `validate:"required,email"`
}
// fieldTexts: {"Email": "адрес почты целиком, с доменом"}
// tagTexts: required → "обязательное поле", email → "укажите адрес почты"
// клиент прислал: Promo "", Email "не-почта"
// что вернёт humanMessage по записям ValidationErrors?validator перевод: universalTranslator и готовые словари#
Когда API говорит на нескольких языках, словарь на одном языке перестаёт быть решением. Для этого сценария у экосистемы есть тяжёлая артиллерия: пакет universal-translator с менеджером переводов и механизм RegisterTranslation на валидаторе — каждый тег регистрируется с шаблоном и функцией подстановки параметров, а в рантайме сообщения достаются по локали запроса. В валидаторе v10 есть готовые наборы переводов для двух десятков языков, включая русский, — подпакеты translations, где тексты уже написаны сообществом. Прежде чем бежать туда, посчитайте цену: регистрация переводов — отдельный слой инициализации, шаблоны с плейсхолдерами чужого формата, ревью текстов, локали в контексте запроса. Для моноязычного сервиса на русском свой словарь из map — дешевле, прозрачнее и честнее: вы контролируете тон, числительные и уточнения по полям, которых в готовых наборах нет. Мой рабочий ориентир: один язык — свой каталог; два и больше — universalTranslator с готовыми наборами и собственными поверх. И в любом случае тексты — не место для «да» и «нет»: клиенту едут строки без эмоций, но с конкретикой, потому что каждое сообщение — интерфейс к полю формы. Подробный конверт ошибок с кодами и Details соберём в двадцать четвёртом уроке.
Правила каталога сообщений одним списком#
- один каталог на проект, отдельный пакет: хендлеры не пишут тексты вообще
- первый уровень — тексты по Tag(), второй — уточнения по Field() (json-имена после RegisterTagNameFunc)
- уточнение по полю проверяется раньше общего текста — иначе оно никогда не сработает
- ветка default с ActualTag() обязательна: незнакомый тег — техническая строка, а не пустота
- числительные согласуются: 1 символ, 2 символа, 5 символов — или обезличенное «от N»
- переименование поля не ломает каталог молча — тест прогоняет пустую форму и сверяет ветки
- Value() и внутренние имена структур наружу не едут — только Field, Tag, Param и текст
Соблазн автогенерации красив: шаблон на каждый тег, отражение по Kind() для «символов против значений», подстановки — и каталог «пишется сам». Я от этого отказался после продукта, где каждое второе поле требовало человеческой формулировки: автогенерация одинаково бездушна везде и сопротивляется уточнениям, а уточнения и есть человечность. Компромисс, который работает: шаблоны для скучного большинства (required, min, max, email) и ручные тексты для полей, где формулировка экономит тикеты поддержки. Условие правоты автогенерации одно: в интерфейсе нет русского UX или тексты — черновик до появления переводчика.
Соберите функцию-сообщалку
Восстановите тип ошибки, два ключа каталога и страховку в default — четыре пропуска.
Соберите путь текста до клиента
Форма сломалась на трёх полях. Разложите путь от валидации до ответа 400.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: слой сообщений
Седьмой урок — вложенные структуры: адрес внутри профиля, рекурсивная проверка дерева и Namespace() из нескольких сегментов, на который сейчас опираются уточнения каталога. Каталог в двенадцатом уроке переедет в Gin вместе с тегами binding, а единый конверт ошибок API с кодами — тема двадцать четвёртого. Каталог из этого урока соберите локально: песочница сайта исполняет только чистый Go без внешних модулей, поэтому валидатор и его сообщения живут в вашем терминале, а интерактивы закрепляют механику без запуска.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Сообщения собираются в отдельный пакет-каталог: хендлеры только вызывают функцию-сообщалку, тексты не живут в коде эндпоинтов.
- Два уровня каталога: общий текст по Tag() и уточнение по Field(); уточнение проверяется первым.
- Ветка default с ActualTag() обязательна: незнакомый тег отвечает технической строкой, а не молчанием.
- Русские числительные склоняются: функция на три формы или обезличенное «от N символов».
- RegisterTagNameFunc переименовывает поля в ошибках по json-тегу; SplitN режет опции, минус откатывается на имя структуры.
- Для мультиязычных API — universalTranslator и RegisterTranslation; для одного языка дешевле свой словарь.
- Переименование поля убивает уточнение молча — тест на пустую форму сверяет ветки каталога.
Домашнее задание
Возьмите mini-каталог из пятого урока и доведите до двухуровневого: tagTexts для required, min, max, email, oneof и fieldTexts минимум для двух полей вашей формы. Добавьте pluralRu и ветку min с согласованием числительных. Напишите тест, который прогоняет пустую форму и проверяет: каждое поле из fieldTexts отвечает уточнением. Потом переименуйте одно поле в структуре и убедитесь, что тест покраснел — вы только что защитили каталог от молчаливой смерти.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему бы не править err.Error() регуляками по ходу?
Простыня Key: '...' — это Error() каждой записи, склеенный переводами строк; парсить его регуляками значит зависеть от формата чужой строки, который меняется между мажорными версиями. Каталог по Tag() и Field() опирается на методы, а не на формат текста, и переживает обновления библиотеки.
Где ключиться уточнения — по Field() или по Namespace()?
На плоских структурах одинаково. На вложенных Field() возвращает только последний сегмент, и два поля City в разных структурах съедят уточнение друг друга — там ключуют по Namespace(). Условие простое: как только появились вложенные структуры, переходите на полный путь, об этом следующий урок.
Стоит ли хранить тексты в YAML или базе?
YAML — да, если каталог большой или редактируется без релиза: ключи те же, загрузка — на старте. База — обычно нет: сообщения меняются вместе с правилами в коде, а запрос в базу на каждое сообщение в hot path — цена неоправданная. Если правила в базе, кэшируйте словарь в памяти и обновляйте по сигналу.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon