Содержание урока · 10 разделов
- RegisterValidation: своя функция становится тегом
- FieldLevel: что можно читать в функции
- Параметр тега: fl.Param() и гибкие правила
- nil-поля и третий аргумент
- RegisterAlias: композиция вместо кода
- Сообщения для своих тегов и тесты правил
- Правила своих валидаторов одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Четвёртый урок оставил обещание: форматов в валидаторе много, но бизнес-форматы бесконечны, и телефон с кодом страны туда не влез. Часть таких случаев закрывает e164, часть — не закрывает ничем: артикул по внутреннему стандарту, логин из чёрного списка, сумма, кратная номиналу. Для них у валидатора есть RegisterValidation — функция регистрируется на экземпляре и становится тегом с собственным именем. Внутри функции — обычный Go: регулярки, словари, любые проверки без сетевого IO; снаружи — обычный тег, неотличимый от required и email. Это последняя из базовых механик курса: после неё теги перестают быть чужой грамматикой и становятся вашим API.
RegisterValidation: своя функция становится тегом#
Регистрация — метод на экземпляре валидатора: RegisterValidation("phone", fn), где fn — функция типа validator.FieldLevel, возвращающая bool. С этого момента тег phone работает в любой структуре этого экземпляра как встроенный: встанет в цепочку правил, подчинится omitempty и условным тегам, попадёт в FieldError с собственным именем. Регистрировать нужно на старте, до первых проверок: реестр правил не потокобезопасен во время валидации — та же дисциплина второго урока, где один экземпляр живёт на всё приложение. Место регистрации тоже выбирайте один раз: пакет, где создаётся экземпляр, обычно рядом с конструктором — так ревьюер видит весь словарь тегов проекта на одном экране, а тесты собирают тот же набор вызовов без копипасты. Реестр ведёт себя иначе, чем подсказывает интуиция. Повторная регистрация того же имени не падает: документация прямо говорит — если тег уже существует, предыдущая функция заменяется новой. Тихая замена опаснее паники: два пакета, зарегистрировавшие один тег по-разному, дают валидатор, который работает по правилам того, кто инициализировался последним, — поэтому держите все RegisterValidation в одном месте, где конфликт виден на ревью. Жёстко защищены служебные теги: попытка зарегистрировать required, omitempty, dive, keys, endkeys или structonly падает паникой «Tag 'required' either contains restricted characters or is the same as a restricted tag needed for normal operation» — они грамматика разбора тега, и валидатор их не отдаёт. Остальные встроенные — min, max, email — формально перезаписываются без ошибки: не приглашение, а дыра, в которую легко наступить; своё поведение называйте своим именем. Имя тега выбирайте как имя функции: латиница, без пробелов и запятых — грамматика тега не оставляет места для вольностей. И держите функцию чистой: предикат, который берёт значение и отвечает bool, без изменения состояния, без времени и сети — причины разобраны ниже, а пока договорённость.
Тег phone: регулярка за десяток строк
GOvar phoneRe = regexp.MustCompile("^[+][0-9]{7,15}$")
validate := validator.New()
validate.RegisterValidation("phone", func(fl validator.FieldLevel) bool {
return phoneRe.MatchString(fl.Field().String())
})
type ContactForm struct {
Phone string `validate:"required,phone"`
}
// "+79161234567" → проходит: плюс и 11 цифр
// "89161234567" → провал: тег phone, Param пустРегулярка скомпилирована один раз на уровне пакета — компиляция в самой функции означала бы пересборку выражения на каждое поле. fl.Field() возвращает reflect.Value поля, String() читает строку. required стоит первым: nil и пустоту он ловит до функции, поэтому внутри можно не думать о нуле. Провал попадает в FieldError с Tag() равным phone — имя своё, механика чужая.
Проверка: двойная регистрация
На одном экземпляре валидатора RegisterValidation("phone", fn) вызвали второй раз с другой функцией. Что произойдёт?
FieldLevel: что можно читать в функции#
| Метод | Что отдаёт |
|---|---|
| Field() | reflect.Value поля: String(), Int(), Kind(), IsNil() |
| Param() | параметр тега строкой — всё после знака равенства |
| FieldName() | имя поля; после RegisterTagNameFunc — json-имя |
| StructFieldName() | имя поля из объявления структуры — даже после RegisterTagNameFunc |
| Parent() | родительская структура — соседние поля одним движением |
| Top() | корневая структура вызова Struct() |
| GetTag() | имя тега, по которому вызвали функцию |
Параметр тега: fl.Param() и гибкие правила#
Тег может нести параметр после знака равенства — это fl.Param() внутри функции, строка без трюков: «5» для multiple=5, перечень через пробел для списка значений. Параметр — способ сделать один тег семейством правил вместо десяти тегов на каждый случай: multiple=5 и multiple=100 обслуживаются одной функцией, а если завтра бизнес попросит кратность двадцати, тег в структуре изменится без правки кода проверки — в этом весь смысл параметризации. Механика чтения честная: Param() приходит строкой, и разбор — ваша работа — strconv.Atoi с честной обработкой ошибки. Ошибка параметра — это ошибка программиста, не данных: тег написали руками, и «5о» в нём обнаружится на первом же тесте; функция возвращает false, и провал всплывает с именем тега. Внутри функции стоит дисциплина отражения: прежде чем звать Int() или String(), проверьте Kind() — тег, повешенный не на то поле, обязан честно провалиться, а не поднять панику reflect. Для значений-перечней держите грамматику из третьего урока: пробел внутри параметра разделяет значения, запятая между правилами — конвейер. И не забывайте Parent() из таблицы: функция видит родительскую структуру, поэтому «сумма кратна номиналу, но не больше остатка» может читаться из соседнего поля — а полноценные кросс-польные контракты это мост к StructLevel из одиннадцатого урока.
Два тега: замыкание и параметр
GO// Замыкание: чёрный список загружен на старте из конфига.
var reserved = map[string]bool{"admin": true, "root": true}
validate.RegisterValidation("notreserved", func(fl validator.FieldLevel) bool {
return !reserved[fl.Field().String()]
})
// Параметр после знака равенства приходит в fl.Param().
validate.RegisterValidation("multiple", func(fl validator.FieldLevel) bool {
if fl.Field().Kind() != reflect.Int {
return false // тег повесили не на то поле
}
n, err := strconv.Atoi(fl.Param())
if err != nil || n == 0 {
return false // битый параметр — честный провал
}
return fl.Field().Int()%int64(n) == 0
})
// Username string `validate:"required,notreserved,min=3"`
// Amount int `validate:"required,multiple=5"`notreserved читает замыкание: словарь загружен на старте, функция остаётся чистым предикатом без IO. multiple разбирает Param() через strconv и дисциплину Kind: не-число и не-целое поле проваливаются явно, а не паникуют. Оба тега используются как встроенные — цепочка правил и omitempty работают без оговорок.
Соблазн зашить в тег «логин не занят» с проверкой по базе: функция же может всё. В час пик каждая регистрация ставит сетевой вызов внутрь рефлексивного цикла валидации: таймауты базы превращаются в 400, ретраи дублируют запросы, тесты требуют живой БД, а гонка на общем кэше функции ловится по ночам. Правило жёсткое: валидатор — чистые предикаты; данные для проверки грузите на старте в замыкание, а проверки живого состояния — «логин занят», «купон истёк» — оставляйте бизнес-слою после валидации. У них другой контракт ошибок и другие ретраи.
nil-поля и третий аргумент#
У собственных функций есть тихое соглашение, которое проявляется только на указателях и интерфейсах: по умолчанию валидатор НЕ вызывает вашу функцию на nil-поле. Указатель пуст — правило пропускается, поле считается пройденным, и это выглядит логично: проверять нечего. Но молчаливый пропуск — источник недоумений: тег есть, а проверки как будто нет. Если функция обязана видеть даже nil — например, чтобы различать «не указано» и «указано пусто» — у RegisterValidation есть третий вариадический аргумент: RegisterValidation("phone", fn, true) вызывает функцию и на нулевых полях, и внутри вы сами решаете, что с ними делать, через fl.Field().IsNil(). Комбинации с остальными тегами при этом обычные: required перед вашим тегом отловит nil и не пустит выполнение дальше, omitempty пропустит пустоту по договору опциональности. Практический вывод: свой тег на указателе — всегда случай из трёх, и тест должен показать, какой из них ваш: nil пропущен, nil проваливается, nil передаётся в функцию. Третий аргумент выручает там, где nil — легитимное значение с собственным смыслом: «не указано» в полях отчётности, дата без времени, опциональная ссылка на документ. Без теста разница живёт только в голове автора, а после рефакторинга живёт там неправильно.
Что вернёт проверка профиля
Прочитайте код и выберите, что он выведет:
type Profile struct {
Nickname string `validate:"omitempty,notreserved"`
Backup *string `validate:"notreserved"`
}
p := Profile{Nickname: "", Backup: nil}
err := validate.Struct(&p)
// что в err?RegisterAlias: композиция вместо кода#
Прежде чем писать функцию, спросите: а не собирается ли мой тег из существующих правил? RegisterAlias регистрирует новое имя для готовой комбинации: RegisterAlias("phone", "e164") — и тег phone означает встроенный формат E.164, без единой строки логики. Значение алиаса — готовая строка правил: с параметрами и с разделителем | для варианта «любое из», как в классическом примере с цветовыми форматами. Алиас не проверяет ничего сам — он словарь имён, и в этом его сила: правило меняется в одном месте, а теги по проекту остаются. С алиасами связана тонкость из пятого урока: Tag() вернёт ваше имя — phone, а ActualTag() — реальный тег e164; каталог сообщений ключуйте по Tag(), чтобы сообщение «телефон в формате +7...» жило под именем, которое видит ревьюер. Регистрация алиасов — на том же старте и с той же не-потокобезопасностью, что у RegisterValidation. Правило выбора простое: комбинация существующих правил — алиас; новая логика, которой нет среди встроенных тегов — функция. Если валидатору можно объяснить телефон через e164 — не пишите регулярку, форматы из четвёртого урока уже проверены сообществом.
Проверка: параметр в FieldError
Amount int с тегом multiple=5 провалил проверку. Что вернёт e.Param()?
Сообщения для своих тегов и тесты правил#
Свой тег попадает в FieldError со своим именем — и каталог из шестого урока должен его знать: ветка case "phone" в tagTexts с формулировкой «телефон в формате +7XXXXXXXXXX». Без ветки сообщение уедет в default с технической строкой — тот же признак дыры в каталоге, что и у встроенных тегов. Тесты правил — вторая половина дисциплины: тег — строка в структуре, компилятор его не проверяет, а ваша функция проверяет только то, что ей дали. Пятёрка значений из третьего урока здесь работает в полный рост: валидное значение, валидное на границе, невалидный формат, пустое, nil для указателей — и к ним стоит добавить случай «тег повешен не на тот тип», если функция проверяет Kind. Табличные тесты для правил — готовый каркас из девятнадцатого урока, а регистрация в тестах использует тот же экземпляр, что и приложение. Имена тегов и ветки каталога стоит проверять одной связкой: тест, который прогоняет все зарегистрированные теги через пустую форму, заодно проверяет и полноту каталога — две защиты одной строкой. Отдельный тест на Param() не повредит: битый параметр сегодня — это false на всех полях сразу, и лучше узнать об этом в CI, чем в тикете «всё сломалось с обновлением».
Правила своих валидаторов одним списком#
- функция — чистый предикат bool: без IO, без времени, без состояния запроса
- регистрация — на старте, до первых проверок, в одном месте пакета
- повторная регистрация молча заменяет функцию; служебные теги вроде required и dive — паника
- fl.Field() — reflect.Value: проверяйте Kind(), прежде чем звать String() и Int()
- Param() — одна строка после знака равенства; перечни — через пробел внутри
- по умолчанию nil-поле функцию не вызывает; третий аргумент true меняет контракт
- комбинация существующих правил — RegisterAlias, новая логика — функция
- каждому тегу — ветка в каталоге сообщений и тест с пятёркой значений
Признак болезни: RegisterValidation на каждое второе поле, а внутри — повторение min, max и oneof. Я считаю, свой тег оправдан только для бизнес-формата, которого нет среди встроенных: телефонные маски, артикулы, ИНН с контрольной суммой, логин вне резерва. Условие, при котором я готов писать больше: функция убирает повторяющуюся логику минимум из трёх мест и не дублирует встроенный тег. Остальное — встроенные теги и алиасы; иначе через год проект носит собственный диалект валидации, который новому человеку придётся учить вместо известной грамматики.
Соберите регистрацию тега
Четыре пропуска: конструктор, регистрация, вызов регулярки и чтение поля.
Соберите путь своего тега
Логин «admin» приходит на регистрацию. Разложите путь от старта приложения до ответа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: свои теги
Одиннадцатый урок — StructLevel: правила на всю структуру: когда «сумма позиций равна итогу» не лезет ни в один тег поля, начинается валидация целого объекта. Форматы, на которых строились алиасы, разобраны в четвёртом уроке, каталог сообщений — в шестом. Код тега phone и чёрного списка соберите локально: песочница сайта исполняет только чистый Go без внешних модулей, поэтому свои валидаторы живут в вашем терминале, а интерактивы проверяют контракт функций без запуска.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- RegisterValidation превращает функцию в тег: имя своё, механика встроенная; повторная регистрация молча заменяет функцию — держите теги в одном месте.
- FieldLevel даёт Field(), Param(), Parent() и Top(): значение, параметр, соседние поля и корень структуры.
- Параметр тега — строка в Param(): разбор strconv, дисциплина Kind, битый параметр — честный провал.
- Функция — чистый предикат: IO, время и состояние запроса остаются бизнес-слою; данные для проверки — в замыкании на старте.
- По умолчанию функция на nil-поле не вызывается; третий аргумент true заставляет её видеть и nil.
- Комбинация существующих правил — RegisterAlias без кода; новая логика — функция.
- Свой тег обязан иметь ветку в каталоге сообщений и табличный тест с пятёркой значений.
Домашнее задание
Реализуйте тег inn: функция проверяет десять цифр и контрольное число по алгоритму ИНН физлица. Зарегистрируйте на экземпляре из второго урока, добавьте ветку в каталог сообщений и табличный тест: валидный ИНН, валидный на границе контрольного числа, невалидная контрольная сумма, буквы в строке, пустая строка, nil на *string. Потом напишите RegisterAlias("phone", "e164") и объясните себе, когда ваш телефонный тег из урока честнее алиаса.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли перезаписать встроенный тег своей функцией?
Служебные теги — required, omitempty, dive, keys, endkeys, structonly — нет: RegisterValidation падает паникой «Tag ... either contains restricted characters ...». Остальные встроенные, вроде min и email, перезапишутся молча, но делать этого не стоит: код, читающий FieldError по Tag(), разъедется с фактическими правилами. Регистрируйте новое имя, а для «телефон — это e164» проще RegisterAlias с новым именем: он не пишет логики вовсе.
Почему функция не вызывается на пустой строке?
Соглашение касается nil: на nil-указателях и nil-интерфейсах функция не вызывается, а пустая строка — обычное значение, и функция её получает. Если хотите пропускать пустые строки до своей проверки, ставьте omitempty перед тегом — это обычный конвейер правил из третьего урока.
Где жить правилу «логин уже занят»?
Не в валидаторе. Это вопрос живого состояния, а не формата: он требует запроса к базе, своих ретраев и своего контракта ошибок — обычно 409 вместо 400. Валидатор проверяет форму значения, бизнес-слой — занятость; смешение даёт таймауты базы внутри рефлексивного цикла и 400 на сетевые сбои.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon