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

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

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

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

Итоговый проект: регистрационная форма

Итоговый validator проект собирает конвейер регистрации пользователя целиком: от JSON-запроса до сохранённого аккаунта, из кирпичей, разобранных за двадцать четыре урока. Нового здесь мало, и это честно: финал проверяет умение складывать знакомое в работающую границу API, а не знание свежих функций. Форма регистрации получает DTO с тегами, Normalize чистит почту и ник, один экземпляр валидатора исполняет правила, русский словарь переводит FieldError в человеческие тексты, единый JSON отвечает 400 по полям, сервис отсекает занятый email доменной ошибкой 409, а табличные тесты держат правила под замком. Сборка умещается в три куска: DTO с тегами и Normalize, маппер ValidationErrors в ответ и табличный тест. Каждое решение помечено уроком-источником, так что непонятный шов открывается заново за минуту, а весь конвейер переносится в свой проект почти без правок.

Содержание урока · 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 с тегами validate16 — границы APISignupRequest: контекст регистрации в полях и тегах
Нормализация15Normalize: TrimSpace, регистр почты; пароль не трогаем
Валидация14 — один экземплярvalidate.Struct на пакетном валидаторе-зависимости
Сообщения06русский словарь по Tag() и Field(), default-страховка
Ответ 40024единый JSON: срез «поле — текст», битое тело отдельно
Сервис23email занят — доменная ошибка и 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

GO
package 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

GO
type 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. Что отвечает хендлер?

Табличный тест конвейера#

Тесты правил из девятнадцатого урока переносятся в проект один к одному: срез кейсов с именем, кандидатом и ожидаемой парой поле-тег, ветка успеха с пустой парой. Сверяем Field() и Tag(), а не текст Error(): формулировки меняет каталог, имена правил — нет. Кейсы накрывают конвейер целиком: валидная форма с пустой парой; короткий пароль — min; несовпадающие пароли — eqfield; пустое подтверждение — required, потому что цепочка остановилась раньше eqfield; мусорный email — email; пустой ник — required; семнадцать лет — gte. Тест работает на том же пакетном экземпляре, что и хендлер: RegisterTagNameFunc уже настроен, поэтому Field() возвращает json-имена, и таблица ожиданий говорит языком фронта. Запускается всё командой go test в терминале — песочница сайта показывает интерактивы, но каркас testing не исполнит.

TestSignupRules: пять правил одной таблицей

GO
func 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-именах: экземпляр общий с хендлером, и тест проверяет тот же контракт, что получают клиенты.

Чек-лист сборки конвейера#

  • SignupRequest в транспортном слое: теги контекста регистрации, не домена
  • порядок на границе: декод, Normalize, validate.Struct, ответ
  • Normalize до проверок: трим и регистр email, трим ника, пароль не трогаем
  • eqfield=Password на подтверждении; required раньше eqfield даёт внятный текст на пустом
  • экземпляр валидатора один, RegisterTagNameFunc настроен при создании: Field() говорит json-именами
  • 400 — срез «поле — текст» из каталога; битое тело — отдельная ветка без полей
  • занятый email — ErrEmailTaken из сервиса и 409; уникальный индекс — последний рубеж
  • табличный тест на каждый тег: Field() и Tag() вместо текста ошибки

Шаги обработки запроса регистрации

От JSON-тела до сохранённого аккаунта — расставьте шаги конвейера.

Строки

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

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

Соберите конвейер signup

Четыре пропуска: чистка, исполнение тегов, код и сборка ответа.

func (h *SignupHandler) Signup(w http.ResponseWriter, r *http.Request) { var in SignupRequest if err := json.NewDecoder(r.Body).Decode(&in); err != nil { writeError(w, http.StatusBadRequest, "битое тело") return } in.() if err := h.validate.(&in); err != nil { var verrs validator.ValidationErrors if errors.As(err, &verrs) { writeJSON(w, http., map[string]any{"errors": (verrs)}) return } writeError(w, http.StatusBadRequest, "проверьте поля") return } // дальше сервис: доменная проверка и сохранение }

Карточки: проект целиком

Термин

eqfield

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

Сравнивает поле с другим полем той же структуры (eqfield=Password). Чужого поля нет или тип не совпал — правило провалено. Подтверждение пароля выражается только так.

1 / 6

Курс закрыт, и конвейер этого урока — его сумма: 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Validator · Итоговый проект: регистрационная форма)

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

Почему подтверждение пароля нельзя проверить одним тегом на самом пароле?

eqfield сравнивает два поля одной структуры, а второго поля в заявке нет: подтверждение — отдельное значение, которое присылает клиент. Нечего сравнивать на поле Password, и хранить пароль дважды ради проверки — путь к рассинхрону. Поэтому ConfirmPassword — самостоятельное поле с required и eqfield=Password, а другие подтверждения — почта письмом и согласие на обработку — остаются шагами процесса, не тегами.

Когда отвечать 400, а когда 409?

400 — когда заявку нельзя принять по форме: ValidationErrors по полям с русским текстом из каталога. 409 — когда форма корректна, но конфликтует с состоянием: почта уже занята. Разделение дешевле, чем кажется: клиент смотрит на код и выбирает сценарий — подсветить поля или предложить другую почту, а единый JSON-конверт делает оба ответа разборчивыми одним парсером.

Чем конвейер в Gin или Echo отличается от этого проекта?

Порядком вызовов, не смыслом: в Gin ShouldBindJSON совмещает декод и проверку, и конвейер из пятнадцатого урока разлепляет их — правила в validate-тегах, Normalize между шагами; в Echo и chi шаги и так пишутся руками. Сервис, словарь сообщений, 400 и 409 не меняются вовсе: валидатор — обычная библиотека, а фреймворк — только транспорт вокруг неё.

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

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

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

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

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

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

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