Содержание урока · 10 разделов
Проект живёт в трёх слоях, и роли из урока про DTO здесь не меняются. Хендлер принимает запрос: декодирует тело, зовёт Normalize, исполняет validate.Struct и собирает ответ — HTTP-код и JSON. Сервис решает бизнес: проверяет, свободен ли email, отображает заявку в доменного пользователя и отвечает за сохранение. Репозиторий говорит с хранилищем, и в уроке он честная заглушка: интерфейс UserStore с Exists и Create, без SQL и драйвера — в реальном проекте за ним стоят база и уникальный индекс. Валидатор приезжает в хендлер зависимостью: экземпляр создан один раз на приложение, RegisterTagNameFunc настроен рядом с созданием, и в тесты идёт тот же экземпляр без хаков. Порядок разбора: сначала конвейер целиком и таблица кирпичей, затем DTO поле за полем, словарь сообщений с единым ответом 400, бизнес-правило с 409, и в конце — табличный тест, который держит все правила под замком.
validator проект: конвейер из кирпичей#
Семь шагов конвейера регистрации — семь уроков курса, и таблица ниже — карта проекта. Декод тела в SignupRequest закрывает семнадцатый урок: битый JSON отделяется до валидации и отвечает без разбора полей. Дальше Normalize, validate.Struct на одном экземпляре, каталог русских сообщений и BuildValidationResponse с ответом 400 по полям. Шестой шаг — сервис: теги не знают, занята ли почта, это вопрос к хранилищу, и отказ звучит иначе — 409, а не 400. Седьмой — табличный тест: на каждый тег своя строка таблицы. Заметьте, чего в конвейере нет: подтверждения почты и согласия на обработку данных. Это не теги, а состояния аккаунта и отдельные шаги процесса — двадцать второй урок раскладывает их по закону, валидатору здесь ловить нечего. Формат заявки и жизнь заявки после ошибки — две разные истории, и конвейер честно держит их по разные стороны ответа.
Шаг конвейера — урок проекта#
| Шаг конвейера | Урок | Что делает в проекте |
|---|---|---|
| DTO с тегами validate | 16 — границы API | SignupRequest: контекст регистрации в полях и тегах |
| Нормализация | 15 | Normalize: TrimSpace, регистр почты; пароль не трогаем |
| Валидация | 14 — один экземпляр | validate.Struct на пакетном валидаторе-зависимости |
| Сообщения | 06 | русский словарь по Tag() и Field(), default-страховка |
| Ответ 400 | 24 | единый JSON: срез «поле — текст», битое тело отдельно |
| Сервис | 23 | email занят — доменная ошибка и 409, не 400 |
| Тесты | 19 | табличные кейсы: Field() и Tag() вместо текста |
DTO: теги, Normalize и один экземпляр#
SignupRequest — request-DTO по правилам урока про границы API: поля одной операции, теги validate, Normalize методом. Email получает required и email: сначала обязательность, потом формат — порядок в цепочке тегов становится порядком сообщений. Никнейм: min=2 и max=32, границы длины в рунах. Пароль: min=8; подтверждение — required и eqfield=Password. Здесь честная механика: eqfield сравнивает поле с другим полем той же структуры — параметр тега это имя соседнего поля, а не значение; других вариантов у сравнения паролей нет. Если второе поле не найдено или его тип не совпадает с вашим, правило провалено. Возраст — required и gte=18, и это граница тега, о которой договоримся вслух: gte=18 проверяет самозаявленное число из формы; если проект хранит дату рождения и хочет «18 лет на сегодня», тегу нужен сегодняшний день, которого в DTO нет, — правило смысла уезжает в сервис, о чём говорил двадцать третий урок. Normalize чистит email тримом и нижним регистром, ник — тримом; пароль и подтверждение не трогаем — секреты не канонизируем. Экземпляр валидатора создан один раз и приходит в хендлер зависимостью; рядом с созданием — RegisterTagNameFunc, чтобы Field() говорил json-именами, и каталог сообщений сразу знал имена фронта.
SignupRequest: теги и Normalize
GOpackage httpapi
import "strings"
type SignupRequest struct {
Email string `json:"email" validate:"required,email"`
Nickname string `json:"nickname" validate:"required,min=2,max=32"`
Password string `json:"password" validate:"required,min=8"`
ConfirmPassword string `json:"confirm_password" validate:"required,eqfield=Password"`
Age int `json:"age" validate:"required,gte=18"`
}
func (in *SignupRequest) Normalize() {
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
in.Nickname = strings.TrimSpace(in.Nickname)
// Password и ConfirmPassword не чистим: секреты не канонизируем
}Пять полей — пять договоров: почта обязательна и по формату, ник в пределах двух и тридцати двух символов, пароль от восьми, подтверждение совпадает с Password через eqfield, возраст от восемнадцати. Normalize живёт методом на DTO и вызывается до validate.Struct: трим и нижний регистр для email, трим для ника, секретные поля не тронуты. Порядок required,eqfield даёт внятное сообщение на пустом подтверждении.
Проверка: тег для подтверждения пароля
Какой тег заставит валидатор сверить пароль с подтверждением?
Пустое подтверждение при заполненном пароле
Прочитайте код и выберите, что он выведет:
in := SignupRequest{
Email: "ada@example.com",
Nickname: "ada",
Password: "qwerty12",
// ConfirmPassword пуст
Age: 30,
}
err := validate.Struct(&in)Русский словарь и единый ответ 400#
Сырьё ошибок даёт пятый урок: Field(), Tag(), Param(). Шестой собирает из него каталог: общие тексты по тегам, уточнения по полям, ветка default с ActualTag() на незнакомый тег. В проекте каталог пополняется одной строкой: eqfield заслуживает человеческого текста, и уточнение по полю ConfirmPassword говорит «пароли не совпадают» вместо технического «проверка eqfield не прошла». Для min продолжает работать согласование числительных: минимум 8 символов. Дальше конверт ответа: BuildValidationResponse перебирает ValidationErrors и складывает пары «поле — текст» в срез, а хендлер отдаёт его с кодом 400. Единый формат — тема двадцать четвёртого урока, и здесь берём минимальный честный вид: срез объектов, чтобы два сообщения об одном поле не затирали друг друга в ключах карты, плюс отдельная ветка для битого тела. Русские тексты живут в пакете сообщений рядом с валидатором, как собирает урок про русские сообщения, а хендлер отдаёт один и тот же JSON и для 400, и для остальных ошибок API, как учит урок про единый формат.
BuildValidationResponse: ValidationErrors в JSON
GOtype fieldIssue struct {
Field string `json:"field"`
Message string `json:"message"`
}
func BuildValidationResponse(verrs validator.ValidationErrors) []fieldIssue {
out := make([]fieldIssue, 0, len(verrs))
for _, e := range verrs {
out = append(out, fieldIssue{
Field: e.Field(), // json-имя: RegisterTagNameFunc настроен при создании
Message: humanMessage(e), // каталог шестого урока: уточнение, затем тег
})
}
return out
}
func writeValidation(w http.ResponseWriter, verrs validator.ValidationErrors) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"errors": BuildValidationResponse(verrs)})
}Срез вместо карты — сознательный выбор: на одном поле стоит цепочка правил, и карта по имени поля потеряла бы второе сообщение. Field() отдаёт json-имя благодаря RegisterTagNameFunc, humanMessage достаётся проекту из шестого урока вместе с уточнением для ConfirmPassword. writeValidation фиксирует 400 и один конверт для всех полевых ошибок; битое тело декодера уходит отдельной веткой хендлера.
Бизнес-правило: email уже занят#
Теги говорят «форма заполнена правильно», но не «эта почта свободна». За ответ отвечает сервис: UserStore — интерфейс-заглушка с Exists(email) bool и Create(u) error; за ним в реальном проекте база с уникальным индексом. Занятая почта — доменная ошибка ErrEmailTaken, и хендлер переводит её в 409 Conflict, а не в 400: ValidationErrors говорят «исправьте поля заявки», доменный отказ говорит «заявка корректна, но конфликтует с состоянием» — прямой стык с двадцать третьим уроком. Клиент на 409 предлагает взять другую почту, а не подсвечивает поле как форматное. Честная деталь напоследок: между Exists и Create почту может занять параллельная регистрация, и никакая проверка в сервисе этого не закроет — последний рубеж всегда в хранилище, уникальный индекс отвечает ошибкой вставки, которую сервис снова переводит в ErrEmailTaken и 409. Проверка в сервисе — не замена индексу, а быстрый путь к внятному сообщению для большинства запросов.
Проверка: какой код за какой отказ
Теги прошли, сервис вернул ErrEmailTaken. Что отвечает хендлер?
Форму переносили со старого фронта, и поле подтверждения приезжало под именем password2, а в DTO стоял json-тег confirm_password. Декодер клал значение мимо: ConfirmPassword оставалось пустым, и пользователь с двумя заполненными строками получал «обязательное поле». Час разбора свёлся к контракту имён: если json-тег расходится с тем, что шлёт клиент, то значение не доезжает, поле остаётся пустым, цепочка тегов останавливается на required — до eqfield дело не доходит, и «пароли не совпадают» вы не прочитаете. Если поле подозрительно пустое при заполненной форме, сравните json-теги с телом запроса первыми, а не правила валидации.
Табличный тест конвейера#
Тесты правил из девятнадцатого урока переносятся в проект один к одному: срез кейсов с именем, кандидатом и ожидаемой парой поле-тег, ветка успеха с пустой парой. Сверяем Field() и Tag(), а не текст Error(): формулировки меняет каталог, имена правил — нет. Кейсы накрывают конвейер целиком: валидная форма с пустой парой; короткий пароль — min; несовпадающие пароли — eqfield; пустое подтверждение — required, потому что цепочка остановилась раньше eqfield; мусорный email — email; пустой ник — required; семнадцать лет — gte. Тест работает на том же пакетном экземпляре, что и хендлер: RegisterTagNameFunc уже настроен, поэтому Field() возвращает json-имена, и таблица ожиданий говорит языком фронта. Запускается всё командой go test в терминале — песочница сайта показывает интерактивы, но каркас testing не исполнит.
TestSignupRules: пять правил одной таблицей
GOfunc TestSignupRules(t *testing.T) {
tests := []struct {
name string
in SignupRequest
wantField string
wantTag string
}{
{"валидная форма", SignupRequest{
Email: "ada@example.com", Nickname: "ada", Age: 30,
Password: "qwerty12", ConfirmPassword: "qwerty12",
}, "", ""},
{"короткий пароль", SignupRequest{
Email: "ada@example.com", Nickname: "ada", Age: 30,
Password: "qwerty1", ConfirmPassword: "qwerty1",
}, "password", "min"},
{"пароли не совпадают", SignupRequest{
Email: "ada@example.com", Nickname: "ada", Age: 30,
Password: "qwerty12", ConfirmPassword: "qwerty1213",
}, "confirm_password", "eqfield"},
{"пустое подтверждение", SignupRequest{
Email: "ada@example.com", Nickname: "ada", Age: 30,
Password: "qwerty12",
}, "confirm_password", "required"},
{"мусорный email", SignupRequest{
Email: "ada-example.com", Nickname: "ada", Age: 30,
Password: "qwerty12", ConfirmPassword: "qwerty12",
}, "email", "email"},
{"пустой ник", SignupRequest{
Email: "ada@example.com", Age: 30,
Password: "qwerty12", ConfirmPassword: "qwerty12",
}, "nickname", "required"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := validate.Struct(&tc.in)
if tc.wantTag == "" {
if err != nil {
t.Fatalf("ожидали успех, получили %v", err)
}
return
}
var verrs validator.ValidationErrors
if !errors.As(err, &verrs) {
t.Fatalf("тип ошибки не ValidationErrors: %v", err)
}
if got := verrs[0].Field(); got != tc.wantField {
t.Fatalf("поле: %s, ожидали %s", got, tc.wantField)
}
if got := verrs[0].Tag(); got != tc.wantTag {
t.Fatalf("тег: %s, ожидали %s", got, tc.wantTag)
}
})
}
}Каркас из девятнадцатого урока: имя кейса, кандидат, ожидаемая пара и одна ветка на успех и провал. Валидная форма закрывает eqfield и gte разом — все пять правил на честных данных. Пара кейсов подтверждения показывает порядок цепочки: пустое поле даёт required, заполненное чужое — eqfield. wantField в json-именах: экземпляр общий с хендлером, и тест проверяет тот же контракт, что получают клиенты.
Конвейер урока — три файла с внешним модулем validator, HTTP-хендлером и тестами: песочница сайта такое не собирает, она рассчитана на чистый Go без go.mod. Интерактивы проверяют порядок шагов и имена тегов, а живой прогон локален: go mod init, go get github.com/go-playground/validator/v10, go test — и таблица кейсов покраснеет на первом сломанном правиле.
Чек-лист сборки конвейера#
- SignupRequest в транспортном слое: теги контекста регистрации, не домена
- порядок на границе: декод, Normalize, validate.Struct, ответ
- Normalize до проверок: трим и регистр email, трим ника, пароль не трогаем
- eqfield=Password на подтверждении; required раньше eqfield даёт внятный текст на пустом
- экземпляр валидатора один, RegisterTagNameFunc настроен при создании: Field() говорит json-именами
- 400 — срез «поле — текст» из каталога; битое тело — отдельная ветка без полей
- занятый email — ErrEmailTaken из сервиса и 409; уникальный индекс — последний рубеж
- табличный тест на каждый тег: Field() и Tag() вместо текста ошибки
Шаги обработки запроса регистрации
От JSON-тела до сохранённого аккаунта — расставьте шаги конвейера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Соберите конвейер signup
Четыре пропуска: чистка, исполнение тегов, код и сборка ответа.
Карточки: проект целиком
Курс закрыт, и конвейер этого урока — его сумма: DTO с тегами на границе, нормализация до проверок, один экземпляр с json-именами, русский каталог сообщений, единый ответ 400, доменный 409 из сервиса и табличные тесты на каждое правило. Перенесите файлы в свой проект и доращивайте в стороны, которые курс сознательно не тронул: подтверждение почты письмом и согласие на обработку — отдельные шаги, а не теги; возраст по дате рождения и лимиты — сервис; фреймворки из двенадцатого и тринадцатого уроков возьмут на себя транспорт, но порядок шагов оставят тот же. Шпаргалки по тегам собраны на странице курса — держите её рядом, когда добавляете новое правило. Последний совет финала: любое правило входит в проект одинаково — тег на DTO, строка в каталоге сообщений, строка в таблице тестов. Три места, три минуты, и правило проверяемо так же, как исполняется.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Итоговый конвейер регистрации: декод, Normalize, validate.Struct, 400 по полям, сервис с 409, сохранение.
- SignupRequest — request-DTO: email, ник min=2/max=32, пароль min=8, подтверждение eqfield=Password, возраст gte=18.
- eqfield сравнивает поля одной структуры: подтверждение пароля выражается только так.
- Normalize чистит email тримом и регистром, ник — тримом; пароль не нормализуем.
- Один экземпляр валидатора — зависимость хендлера; RegisterTagNameFunc даёт Field() json-имена.
- BuildValidationResponse собирает срез «поле — текст» из каталога; битое тело отвечает отдельно.
- Занятый email — доменная ошибка: 409 вместо 400; уникальный индекс — последний рубеж.
- Табличные тесты сверяют Field() и Tag(): контракт правил переживёт любые формулировки.
Домашнее задание
Соберите конвейер целиком: SignupRequest с пятью тегами, Normalize, пакетный экземпляр с RegisterTagNameFunc, каталог сообщений с уточнением для подтверждения пароля, BuildValidationResponse и заглушку UserStore на карте занятых почт. Прогоните табличный тест из урока и добавьте свой кейс на возраст: семнадцать лет обязаны дать gte на age. Затем сломайте контракт дважды: уберите eqfield с подтверждения и убедитесь, что кейс «пароли не совпадают» покраснел; отправьте подтверждение под именем password2 и посмотрите, что вернётся required по confirm_password. Дорисуйте ветку 409: два запроса подряд с одной почтой — первый создаёт аккаунт, второй получает 409 с тем же конвертом ошибки, что и 400.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему подтверждение пароля нельзя проверить одним тегом на самом пароле?
eqfield сравнивает два поля одной структуры, а второго поля в заявке нет: подтверждение — отдельное значение, которое присылает клиент. Нечего сравнивать на поле Password, и хранить пароль дважды ради проверки — путь к рассинхрону. Поэтому ConfirmPassword — самостоятельное поле с required и eqfield=Password, а другие подтверждения — почта письмом и согласие на обработку — остаются шагами процесса, не тегами.
Когда отвечать 400, а когда 409?
400 — когда заявку нельзя принять по форме: ValidationErrors по полям с русским текстом из каталога. 409 — когда форма корректна, но конфликтует с состоянием: почта уже занята. Разделение дешевле, чем кажется: клиент смотрит на код и выбирает сценарий — подсветить поля или предложить другую почту, а единый JSON-конверт делает оба ответа разборчивыми одним парсером.
Чем конвейер в Gin или Echo отличается от этого проекта?
Порядком вызовов, не смыслом: в Gin ShouldBindJSON совмещает декод и проверку, и конвейер из пятнадцатого урока разлепляет их — правила в validate-тегах, Normalize между шагами; в Echo и chi шаги и так пишутся руками. Сервис, словарь сообщений, 400 и 409 не меняются вовсе: валидатор — обычная библиотека, а фреймворк — только транспорт вокруг неё.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon