Содержание урока · 10 разделов
- Единый формат ошибок API: конверт из одной функции
- Field(), Tag(), Param(): материал ValidationErrors
- Словарь тегов: сообщения без дублей
- Gin и Echo: одна сборка, две доставки
- Что у клиента — откуда
- 400 против 422: семантика по конвенции
- Дисциплина формата одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Клиент, получивший ошибку, решает две задачи: показать человеку, что поправить, и объяснить машине, что произошло. Для первой нужен человекочитаемый текст по каждому полю, для второй — стабильные коды, на которые не влияет переформулировка сообщений. Единый формат закрывает обе: наверху error с машинным кодом и общим сообщением, ниже details — массив по полям, где поле, правило и параметр разложены отдельно от текста. Разложенность — главная идея: текст локализуется и меняется, а поле и тег — контракт. Валидатор этот контракт уже отдал: ValidationErrors это срез FieldError, и всё, что нужно конверту, лежит в методах Field(), Tag() и Param(). Задача проекта — не потерять материал: не склеить всё в одну строку Error(), не оставить общее «проверьте поля» и не выдумывать поля заново в каждом хендлере. Одна функция сборки решает это раз и навсегда. Предсказуемость — фича: клиент, один раз прочитавший формат, разбирает вторую форму без чтения документации, а дежурный грепает логи по тегу, не открывая код хендлера. Хуже всего середина: формат вроде есть, но два маршрута от него отступают — и именно на них ловятся редкие клиенты, о которых никто не вспомнил при выпуске. Аудит быстрый: curl по пяти маршрутам вашего API — если ответы различаются формой или регистром полей, формат ещё не существует, существует догадка.
Единый формат ошибок API: конверт из одной функции#
Формат описывается один раз как DTO: наверху error с машинным кодом и общим сообщением, ниже details — срез по полям: field, rule, param, message. Собирает конверт одна функция BuildValidationResponse, принимающая validator.ValidationErrors и возвращающая DTO; она тестируется отдельно от фреймворка — табличный каркас из девятнадцатого урока ложится на неё напрямую. Хендлеры больше не пишут JSON руками: получили ValidationErrors — вызвали функцию, отдали результат фреймворку. Выигрыш виден на втором маршруте: форма профиля и форма заказа возвращают конверт одинаковой формы, и фронтенд пишет разбор один. Запасной путь тоже один: незнакомый тег получает нейтральный текст, но поле и правило в деталях всё равно точны — формат не разваливается от опечатки в словаре. Имя функции — не украшение: BuildValidationResponse читается в хендлере как команда, и на ревью достаточно взгляда — ответ собран централизованно. Возвращаемый тип — не map и не интерфейс, а описанный DTO: поля задокументированы компилятором, а не комментарием, и правка формата ломает компиляцию там, где она нужна. Ёмкость под len(errs) и готовый срез details — мелочь, которую приятно оставить в тесте: пустой ValidationErrors сериализуется пустым массивом, и клиентский цикл не падает на null.
BuildValidationResponse: ValidationErrors в DTO
GOtype FieldDetail struct {
Field string `json:"field"`
Rule string `json:"rule"`
Param string `json:"param,omitempty"`
Message string `json:"message"`
}
type ErrorDTO struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Details []FieldDetail `json:"details"`
}
func BuildValidationResponse(errs validator.ValidationErrors) ErrorDTO {
out := ErrorDTO{Details: make([]FieldDetail, 0, len(errs))}
out.Error.Code = "VALIDATION_FAILED"
out.Error.Message = "проверьте поля формы"
for _, fe := range errs {
out.Details = append(out.Details, FieldDetail{
Field: fe.Field(),
Rule: fe.Tag(),
Param: fe.Param(),
Message: humanMessage(fe),
})
}
return out
}Функция — единственное место, где ValidationErrors превращаются в JSON: код и общее сообщение наверху, срез деталей по полям ниже. Field() даёт имя поля, Tag() — правило, Param() — параметр тега, текст берёт словарь humanMessage. Ёмкость среза готовится под len(errs), а details сериализуется массивом, а не null.
Проверка: что в details[].rule
Форма не прошла тег min=8 на поле Password. Что лежит в details[].rule этого элемента?
Field(), Tag(), Param(): материал ValidationErrors#
Три метода FieldError закрывают весь разбор. Field() возвращает имя поля — для плоских структур этого достаточно; для вложенных и срезов с dive честнее Namespace(): он отдаёт полный путь вида Users[0].Email, тогда как Field() оставит только Email, и два разных адресата неразличимы. Это стык пятого урока про разбор ValidationErrors — там каркас, здесь договорённость: если в API есть вложенные DTO, кладите в field Namespace(), если плоские — Field(), но везде одинаково. Tag() — имя правила: eqfield, oneof, datetime; это ключ для словаря сообщений и для логов. Param() — параметр: у min=8 это 8, у eqfield — имя второго поля, у oneof — перечисление вариантов; у тегов без параметра — пустая строка. Параметр приходит строкой, и «5» из min=5 кладётся в details как есть. У пары Tag() и ActualTag() разница проявляется на алиасах: Tag() вернёт имя алиаса, ActualTag() — реальный тег внутри него; для словаря и логов обычно нужен Tag(), согласованный с тем, что клиент видит в details. Три метода плюс Namespace() — весь бюджет разбора: больше валидатор ничего не обязан сообщать, остальное — забота словаря и конвенции проекта.
min=8 против пароля из трёх символов
Прочитайте код и выберите, что он выведет:
type PasswordForm struct {
Password string `validate:"required,min=8"`
}
// клиент прислал: {"password": "abc"}
err := v.Struct(PasswordForm{Password: "abc"})
// fmt.Println(fe.Field(), fe.Tag(), fe.Param())Словарь тегов: сообщения без дублей#
Детали дают клиенту машину, а человеку нужен текст: «минимум 8 символов», «адрес почты некорректен». Словарь тег → шаблон — тот же двухуровневый каталог из шестого урока, свёрнутый до одной функции: по Tag() достали шаблон, подставили Param() или имя поля, вернули строку. Незнакомый тег получает запасной текст: формат не ломается, ведь поле и правило в деталях уже точны. Разделение обязанностей простое и полезное на ревью: теги — в логи для разработчика, сообщения — клиенту. Лог пишет поле, тег и параметр — по ним дежурный находит правило; клиент получает текст без тегов вообще. Дубли этого правила распознаются мгновенно: если в каком-то хендлере текст ошибки склеен руками, а не из словаря, — формат уже пошёл вразнос. Шаблоны держите рядом с кодом сборки: словарь — часть кодовой базы и меняется пулл-реквестом с тестом. Если понадобится второй язык — это второй словарь и тот же разбор: детали field, rule, param общие для любой локали, меняется только message.
Словарь: тег и параметр в сообщение
GOvar templates = map[string]string{
"required": "поле %s обязательно для заполнения",
"min": "минимум %s символов",
"max": "максимум %s символов",
"email": "некорректный адрес почты",
}
func humanMessage(fe validator.FieldError) string {
t, ok := templates[fe.Tag()]
if !ok {
return "поле заполнено неверно" // запасной текст
}
p := fe.Param()
if p == "" {
p = fe.Field() // шаблонам без параметра идёт имя поля
}
return fmt.Sprintf(t, p)
}Словарь — карта по Tag(): незнакомое правило получает запасной текст, и конверт не разваливается при добавлении тега. Параметр тега подставляется в шаблон: у min это граница, у required — имя поля. Функция чистая: без фреймворка и глобального состояния, тест — таблица кейсов по тегам.
Форма заказа возвращала 400 с массивом details, форма профиля — строку error с перечислением полей через запятую: писали разные авторы в разные недели. Клиентский разбор профиля молча падал, поддержка получала билеты «ошибка пустая». Если хендлер собирает ответ руками — вынесите сборку в BuildValidationResponse и запретите рукопись на ревью; если поле вложенное и field различает два Email — кладите Namespace(), а не Field(); если сомневаетесь в статусе — вернитесь к конвенции проекта, единообразие важнее идеального номера.
Gin и Echo: одна сборка, две доставки#
Функция сборки независима от фреймворка: она про данные, а доставка — слой ниже. В Gin хендлер отвечает AbortWithStatusJSON с кодом 400 и DTO: ответ уходит, цепочка прерывается, и после этого места код работает с валидными данными. В Echo два равных пути: вернуть ошибку из хендлера и разобрать её в своём HTTPErrorHandler или сразу написать c.JSON(400, dto); оба ведут к одному конверту, если errorHandler знает DTO. Стык тринадцатого урока про хуки Echo: там настройка, здесь — дисциплина одного конверта на все маршруты. Порядок проверки в хендлере тоже стоит зафиксировать: сначала InvalidValidationError — это ошибка кода, 500 и лог; потом ValidationErrors — 400 и конверт. Перепутанная ветка отправляет опечатку пользователя в алерты дежурного, а реальный баг кода — в молчаливый 400. Доставка повторяется в каждом хендлере — и ровно поэтому собирается в одну строчку: ответ уже готов, хендлер только отдаёт его фреймворку. Таблица ниже показывает, что именно видит клиент и откуда приходит каждое поле, — на ревью удобно сверять реальный ответ по ней.
Что у клиента — откуда#
| Поле в JSON | Откуда | Зачем клиенту |
|---|---|---|
| error.code | константа проекта | стабильный машинный код для ветвления |
| error.message | константа проекта | общий текст без деталей |
| details[].field | Field() или Namespace() | какое поле подсветить в форме |
| details[].rule | Tag() | машинное правило: логи и аналитика |
| details[].param | Param() | граница правила: «8» у min=8 |
| details[].message | словарь тег → шаблон | человекочитаемый текст на языке клиента |
400 против 422: семантика по конвенции#
Статус — часть формата. 400 говорит «запрос некорректен» и честно описывает ветку валидатора: теги не прошли, details заполнены по ValidationErrors. 422 — «запрос понят, но не принят по смыслу»: форма корректна, а правило домена — возраст, лимит, промокод — не сложилось; материал для details тот же, источник — доменные ошибки. С разметкой правил по слоям связан предыдущий урок: она подсказывает, какой статус отдавать. Выбор между 400 и 422 менее важен, чем его постоянство: клиент пишет разбор один раз, тесты сверяют код один раз. Итоговый проект курса собирает конвейер целиком: конверт, статусы, 409 на занятую почту — и тестирует ответы как контракт API. Слова споров здесь короткие: 400 закрывает синтаксис и формат, 422 — семантические проблемы — оба варианта легальны, и спор о выборе дешевле закрыть конвенцией, чем выиграть раз за разом в пулл-реквестах. В конвенции удобно записать и исключения: что 409 используется только для конфликтов состояния, а ошибки аутентификации вообще мимо этого конверта — у них свой код и свой разбор.
Дисциплина формата одним списком#
- одна функция BuildValidationResponse на весь проект: ValidationErrors → DTO
- details[].field — Field() для плоских структур, Namespace() для вложенных и dive
- details[].rule — Tag(): машинный контракт, текстом не заменяется
- details[].param — Param(): параметр тега строкой, «8» у min=8
- словарь тег → шаблон: незнакомый тег получает запасной текст
- теги и поля — в логи разработчику, сообщения — клиенту
- 400 — формат, 422 и 409 — смысл; конвенция фиксируется один раз
- ответы API тестируются как контракт: поле, тег, статус в таблице кейсов
HTTP-слой в песочнице не поднимется, но сборка конверта — чистая функция: её механика прогоняется в интерактивах урока. Если нужен полный цикл с Gin и Echo — запускайте локально: curl по вашим маршрутам покажет конверт быстрее, чем чтение, а тест контракта закрепит его в CI.
Соберите разбор FieldError
Три пропуска: методы FieldError для поля, правила и параметра.
Путь ValidationErrors до JSON
От ошибки валидатора до ответа клиенту.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Проверка: Field против Namespace
В форме срез адресов: не прошли email у Users[0].Email и Users[1].Email. Что положить в details[].field, чтобы поля различались?
Карточки: единый формат ошибок
Единый конверт — последний кирпич перед итоговым проектом: данные разобраны по Field(), Tag() и Param(), сообщения берутся из словаря, статус отделяет формат от смысла, а Gin и Echo доставляют один и тот же JSON. Проверьте свой проект на три симптома: хендлер, собирающий ошибку руками; field, не различающий два поля среза; правило на ревью, сверяющееся с текстом, а не с тегом. Каждый симптом чинится одной функцией и одним словарём. Отдельный аудит — логи: если в них нет тега и поля, дежурный восстанавливает картину по тексту сообщения, который меняется без предупреждения. Следующий урок собирает курс в итоговый проект: регистрационная форма от DTO и тегов до тестов и конверта 400 — всё, что разобрано за двадцать четыре урока, на одном экране.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Единый формат: error с кодом и сообщением, ниже details по полям — контракт клиента.
- ValidationErrors — готовый материал: Field(), Tag(), Param() без выдумывания.
- Для вложенных и dive — Namespace(): Field() различит не всё.
- BuildValidationResponse — одна функция сборки, тестируется отдельно от фреймворка.
- Словарь тег → шаблон; незнакомый тег получает запасной текст, формат не разваливается.
- Теги и поля — в логи разработчику, сообщения — клиенту; аудит — по тегам, не по тексту.
- 400 — формат, 422 и 409 — смысл; единообразие важнее выбора номера.
Домашнее задание
Соберите конверт в своём проекте. Первое: опишите DTO с error.code, error.message и details по полям и функцию BuildValidationResponse, принимающую validator.ValidationErrors. Второе: заведите словарь тег → шаблон с подстановкой Param() и запасным текстом; проверьте его табличным тестом. Третье: пройдите по хендлерам и уберите ручную сборку ошибок — Gin получает AbortWithStatusJSON(400, dto), Echo — errorHandler с тем же DTO. Четвёртое: решите, где у проекта 400, а где 422 или 409, и зафиксируйте конвенцию рядом с описанием формата. Пятое: напишите тест контракта — поле, тег, статус — на двух разных маршрутах. Шестое: проверьте, что логи содержат тег и поле, а не только текст сообщения, — иначе аудит инцидентов сведётся к перечитыванию переписки поддержки.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему field — это Field(), а не имя, вырезанное из Error()?
Error() склеивает всё в одну строку и зависит от нормализации и RegisterTagNameFunc — парсить её хрупко. FieldError отдаёт Field() и Namespace() отдельно, как и Tag(); конверт собирается из методов, а не из парсинга текста, поэтому формат не ломается от смены формулировок и версии библиотеки. Плюс парсинг строк — лишняя ветка в клиенте: чем меньше её код, тем меньше ревью.
Что класть в details, если ошибку вернул не валидатор, а домен?
Тот же конверт, другой источник деталей: у доменной ошибки есть своё поле и код — ErrInsufficientFunds маппится в detail с полем суммы, правилом insufficient_funds и статусом 409 или 422. Формат один, фабрики деталей две: валидаторная и доменная. Клиент разбирает их одинаково, а тест контракта покрывает обе фабрики одной таблицей кейсов.
Нужно ли класть русские сообщения в сам валидатор через RegisterTagNameFunc?
RegisterTagNameFunc меняет имена полей в ошибках — удобно для текста, но тогда логи и разбор видят переименованные поля, и контракт усложняется. Надёжнее держать поля машинными, а локализацию в словаре по Tag(): тег остаётся контрактом, текст — отображением поверх него. Если проект всё же переименовывает поля, для машинного имени остаётся StructNamespace(): он несёт фактические имена полей без подстановок.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon