Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 24 из 25 ~20 мин +20 XP

Единый формат ошибок API

Формат ошибок API — контракт, по которому клиент разбирает неудачу: один и тот же JSON на всех маршрутах, без сюрпризов от конкретного хендлера. Когда каждый хендлер пишет 400 по-своему, клиент заводит три парсера, тесты ломаются от перестановки полей, а логи перестают грепаться. Материал у валидатора готов: ValidationErrors — срез FieldError, где Field() даёт имя поля, Tag() — тег правила, Param() — параметр вроде восьмёрки из min=8, а Namespace() — путь до вложенного поля. Урок собирает из этих деталей конверт: код и сообщение наверху, детали по полям ниже, словарь тегов переводит правила в человекочитаемые сообщения, а Gin и Echo получают одну функцию сборки на весь проект. Формат — часть публичного API наравне с маршрутами и кодами ответов: его меняют реже любых текстов, документируют один раз и проверяют тестами как контракт. Отдельно — семантика статусов: где 400, когда уместен 422 и почему единообразие важнее выбора.

Содержание урока · 10 разделов

Клиент, получивший ошибку, решает две задачи: показать человеку, что поправить, и объяснить машине, что произошло. Для первой нужен человекочитаемый текст по каждому полю, для второй — стабильные коды, на которые не влияет переформулировка сообщений. Единый формат закрывает обе: наверху 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

GO
type 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.

Словарь: тег и параметр в сообщение

GO
var 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 — имя поля. Функция чистая: без фреймворка и глобального состояния, тест — таблица кейсов по тегам.

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[].fieldField() или Namespace()какое поле подсветить в форме
details[].ruleTag()машинное правило: логи и аналитика
details[].paramParam()граница правила: «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 тестируются как контракт: поле, тег, статус в таблице кейсов

Соберите разбор FieldError

Три пропуска: методы FieldError для поля, правила и параметра.

for _, fe := range errs { out.Details = append(out.Details, FieldDetail{ Field: fe.(), // имя поля клиента Rule: fe.(), // машинное имя правила Param: fe.(), // «8» у min=8 Message: humanMessage(fe), }) }

Путь ValidationErrors до JSON

От ошибки валидатора до ответа клиенту.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Проверка: Field против Namespace

В форме срез адресов: не прошли email у Users[0].Email и Users[1].Email. Что положить в details[].field, чтобы поля различались?

Карточки: единый формат ошибок

Термин

конверт ошибок

нажмите, чтобы перевернуть
Определение

error с машинным кодом и общим сообщением, ниже details по полям: field, rule, param, message. Один формат на все маршруты API.

1 / 6

Единый конверт — последний кирпич перед итоговым проектом: данные разобраны по 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, и зафиксируйте конвенцию рядом с описанием формата. Пятое: напишите тест контракта — поле, тег, статус — на двух разных маршрутах. Шестое: проверьте, что логи содержат тег и поле, а не только текст сообщения, — иначе аудит инцидентов сведётся к перечитыванию переписки поддержки.

Личные заметкиПусто
Хранятся только в вашем браузере (Validator · Единый формат ошибок API)

Вопросы и ответы по уроку

Почему field — это Field(), а не имя, вырезанное из Error()?

Error() склеивает всё в одну строку и зависит от нормализации и RegisterTagNameFunc — парсить её хрупко. FieldError отдаёт Field() и Namespace() отдельно, как и Tag(); конверт собирается из методов, а не из парсинга текста, поэтому формат не ломается от смены формулировок и версии библиотеки. Плюс парсинг строк — лишняя ветка в клиенте: чем меньше её код, тем меньше ревью.

Что класть в details, если ошибку вернул не валидатор, а домен?

Тот же конверт, другой источник деталей: у доменной ошибки есть своё поле и код — ErrInsufficientFunds маппится в detail с полем суммы, правилом insufficient_funds и статусом 409 или 422. Формат один, фабрики деталей две: валидаторная и доменная. Клиент разбирает их одинаково, а тест контракта покрывает обе фабрики одной таблицей кейсов.

Нужно ли класть русские сообщения в сам валидатор через RegisterTagNameFunc?

RegisterTagNameFunc меняет имена полей в ошибках — удобно для текста, но тогда логи и разбор видят переименованные поля, и контракт усложняется. Надёжнее держать поля машинными, а локализацию в словаре по Tag(): тег остаётся контрактом, текст — отображением поверх него. Если проект всё же переименовывает поля, для машинного имени остаётся StructNamespace(): он несёт фактические имена полей без подстановок.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Asynq25 уроков~11 чпрактика25 уроков уже доступны

Asynq в Go: очередь фоновых задач на Redis

Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.

  • Строить клиент и воркер asynq и подключать Redis
  • Отправлять отложенные, периодические и уникальные задачи
  • Настраивать ретраи, таймауты и приоритеты очередей
  • Мониторить задачи через Inspector и asynqmon