Содержание урока · 10 разделов
- Вложенные структуры валидация: рекурсия из коробки
- Указатель или значение: required у вложенного объекта
- Поведение поля-структуры по тегам
- structonly и "-": управляем глубиной проверки
- json-имена в Namespace: путь для фронтенда
- Срез структур: dive коротким мостом
- Правила вложенности одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
До сих пор формы были плоскими: string и int на верхнем уровне. Настоящие формы слоистые — профиль несёт адрес, заказ несёт контакт и доставку, платёж несёт карту. Интуиция подсказывает, что для вложенности нужен какой-то тег вроде deep или nested — и интуиция ошибается: валидатор проверяет дерево из коробки. Встретив поле, чей тип — структура, он спускается внутрь и проверяет её поля по их тегам, рекурсивно на любую глубину. Новая здесь не механика, а два вопроса, которые раньше не вставали: как ошибке внутри дерева объяснить, откуда она (ответ — Namespace() из пятого урока) и как сказать «сам объект обязателен» (ответ — указатель, и тут есть ловушка, на которой теряются даже опытные).
Вложенные структуры валидация: рекурсия из коробки#
Разберём механику на профиле. ProfileForm содержит Name и Address, а Address содержит City, Street и ZipCode со своими тегами. Один вызов validate.Struct(&form) проверяет всё: Name по своим правилам, затем движок видит, что Address — структура, и обходит её поля по их тегам. Вложенные структуры валидация проваливает не одним обобщённым «Address сломан», а конкретными записями по полям: City провалил required, ZipCode провалил len=6. И вот здесь оживёт Namespace(), о котором пятый урок сказал «запомните сам факт»: для City путь будет ProfileForm.Address.City — полный маршрут от корня через точки. Field() при этом возвращает только последний сегмент, «City», — этого хватает для каталога уточнений на плоских структурах и не хватает на вложенных: два поля City в разных адресах съедят уточнение друг друга, поэтому на деревьях каталог ключуют по Namespace(). Проверки каждого уровня независимы: три сломанных поля на трёх глубинах дадут три записи в ValidationErrors, порядок следует порядку полей структуры. Обход дерева предсказуем: движок идёт по полям в порядке объявления и спускается внутрь поля-структуры сразу, как до него доходит очередь — глубина раньше соседей. Записи в ValidationErrors поэтому читаются сверху вниз по дереву, и в логе сообщения про вложенные объекты не перемешиваются хаотично. Цена рекурсии — отражение — платится из того же кэша планов по reflect.Type из второго урока: дерево разбирается один раз за жизнь процесса, повторные вызовы работают по готовому плану.
Профиль с адресом: одна проверка на всё дерево
GOtype Address struct {
City string `validate:"required,min=2"`
Street string `validate:"required"`
ZipCode string `validate:"required,len=6"`
}
type ProfileForm struct {
Name string `validate:"required,min=3,max=64"`
Address Address `validate:"required"`
}
// Address{Street: "Тверская", ZipCode: "123"}:
// Namespace() Field() Tag()
// ProfileForm.Address.City City required
// ProfileForm.Address.ZipCode ZipCode lenОдин вызов Struct проверил поле верхнего уровня и весь вложенный объект: ошибки приехали по конкретным полям адреса, а не по Address целиком. required на Address здесь — иллюстрация ловушки из следующего блока: на структуре-значении он ничего не проверяет. Namespace() складывает маршрут через точки — это и есть материал для каталога уточнений и для подсветки на фронте.
Проверка: полный путь ошибки
В ProfileForm.Address поле City провалило required. Что вернёт e.Namespace()?
Указатель или значение: required у вложенного объекта#
Главный вопрос вложенности: как сказать «объект целиком обязателен». Ответ прячется в семантике required из третьего урока — поле не должно быть нулевым значением своего типа. Структура-значение нулевой не бывает: она всегда существует, пусть и заполненная нулями, поэтому required на Address Address проходит всегда и не проверяет ничего. Это не баг, а задокументированная ветка обратной совместимости: валидатор явно пропускает required на не-указательной структуре — в том числе и полностью нулевой. Опция WithRequiredStructEnabled() включает строгую семантику: required начинает проваливать нулевую структуру-значение, и именно она станет поведением по умолчанию в следующей мажорной версии — закладывайтесь на указатели уже сейчас, чтобы миграция не изменила вам договоры. Требование «объект должен прийти» выражается указателем: Address с required означает «не nil», и nil-указатель провалит проверку с честным тегом required по имени поля. Вторая половина механики — что происходит с nil без required: валидатор пропускает такой объект целиком, внутрь не спускается, ошибки по полям не собирает. Это разумно — проверять поля отсутствующего объекта нечем, — но пропуска должен хотеть именно вы, а не случайность декодирования. omitempty на указателе делает намерение явным: «объекта может не быть, а если есть — проверяй поля». Бонус связки знаком по третьему уроку: после успешной проверки Address с required указатель гарантированно не nil, и разыменование в бизнес-коде безопасно без второго if. И разбор StructPartial из второго урока здесь добирает своё: частичная проверка принимает вложенные пути вроде Address.City, потому что сравнивает перечисленные поля с теми же Namespace().
Поведение поля-структуры по тегам#
| Описание поля | Значение nil | Значение непустое |
|---|---|---|
| T — значение, без тегов | невозможно | рекурсия по полям |
| *T — указатель, без тегов | пропускается целиком | рекурсия по полям |
| *T required | ошибка по required | рекурсия по полям |
| *T omitempty | пропускается целиком | рекурсия по полям |
| T structonly | — | поля не проверяются |
| любое с validate:"-" | пропускается | пропускается |
Три вложенных объекта, три договора
GOtype CheckoutForm struct {
Contact Contact `validate:"required"` // структура-значение
Delivery *Address `validate:"omitempty"` // объект опционален
Invoice *Address `validate:"required"` // объект обязателен
}
// Delivery == nil → пропущена: поля не проверялись
// Delivery == &Address{} → рекурсия: City провалит required
// Invoice == nil → ошибка по required: заказ без счёта нельзя
// Contact без адреса? → required всегда зелёный, ошибки — по полямТри поля — три разных договора с клиентом. Contact как значение означает «контакт есть всегда», и его честность держится на тегах внутренних полей, а не на required. Delivery — опциональный объект: nil пропускается, непустой проверяется рекурсией. Invoice — обязательный: nil проваливает required, и разыменование после проверки безопасно.
Кейс из корзины: Delivery *Address с omitempty — логично, «доставка опциональна». Через релиз появляется самовывоз, и старый мобильный клиент перестаёт слать delivery вовсе: валидатор молчит, заказ принимается, а адреса доставки нет — курьеру нечего передавать. Обнаруживается на складе, а не в тестах, потому что валидация честно сделала то, о чём её попросили. Мораль: omitempty на вложенном объекте — коммит о бизнесе, а не о синтаксисе; объект, без которого логика не работает, обязан быть required, а ветки «или самовывоз» описываются условными тегами из девятого урока.
structonly и "-": управляем глубиной проверки#
Рекурсия из коробки — это удобно ровно до тех пор, пока дерево совпадает с границей проверки. В реальных системах встречаются поля-структуры, которые проверять не нужно: объект уже прошёл проверку на другой границе — пришёл из очереди после валидации продюсера или из соседнего сервиса, который гарантировал целостность; вложенная структура описывает данные, которые этот эндпоинт не трогает; либо дерево настолько большое, что повторная проверка хвоста на каждом запросе — чистая трата. Для таких полей есть тег structonly: поле остаётся под валидацией как структура, но спуск в его поля не выполняется — правила внутри игнорируются, отрабатывают только StructLevel-правила самой структуры, о которых одиннадцатый урок. Полное отключение поля даёт validate:"-": валидатор не проверяет ни поле, ни его содержимое — приём для полей, которые живут в структуре ради сериализации. Различие стоит держать в голове, потому что они решают разные задачи: structonly говорит «поле проверяется как единое целое», минус — «поле вне контракта проверки». Выбирайте structonly, когда гарантии объекта у вас всё-таки есть и вы готовы назвать, откуда, — иначе это способ замести дыру в границе доверия под ковёр.
Что вернёт проверка заказа
Прочитайте код и выберите, что он выведет:
type Order struct {
Buyer *Contact `validate:"omitempty"`
Note string `validate:"omitempty,max=100"`
}
o := Order{Buyer: nil, Note: ""}
err := validate.Struct(&o)
// что в err?json-имена в Namespace: путь для фронтенда#
Шестой урок зарегистрировал RegisterTagNameFunc по json-тегам — на плоских структурах эффект был косметическим, на деревьях он становится архитектурным: каждый сегмент пути переименовывается, и Namespace() превращается из ProfileForm.Address.City в маршрут json-имён вроде profile.address.city. Для фронтенда это готовый контракт: ответ 400 несёт путь, фронт разбивает его по точкам и подсвечивает вложенный инпут без таблиц соответствий. Отсюда же практическое правило каталога: на вложенных структурах уточнения ключуются по Namespace(), потому что Field() отдаёт только последний сегмент — City в Address и City в OfficeAddress различит только полный путь. Есть и цена, о которой честно: путь — это строка, и переименование json-тега меняет контракт с фронтом так же, как переименование поля меняло каталог уточнений в прошлом уроке; страховка та же — тест на пустую форму, который проверяет, что путь ожидаемого вида доезжает до ответа. И ещё деталь, которую удобно знать заранее: вложенность и нормализация имён не конфликтуют — сегменты собираются по тем же правилам, что и плоские поля, поэтому поведение предсказуемо на любой глубине. Пример на практике: уточнение «город — только Москва» для Address и «город отделения — для справки» для OfficeAddress записываются двумя ключами с общим префиксом и никогда не конфликтуют, потому что полный путь различает оба поля целиком; на плоской структуре такой конфликт не смог бы возникнуть, поэтому правило ключуют по Namespace вместе с первой вложенностью.
Проверка: какой ключ для уточнения
Профиль содержит Address и OfficeAddress, в обоих есть поле City с json:"city". Какому ключу в каталоге уточнений доверить текст про город?
Срез структур: dive коротким мостом#
Последний вопрос плана — что делать, когда вложенных объектов много: Addresses []Address в профиле или позиции []Item в заказе. Ответ: элементы среза валидатор сам не трогает — срез это контейнер, и по умолчанию проверяются его правила уровня контейнера вроде min и max на количество элементов. Чтобы спуститься в каждый элемент, между правилами ставится тег dive, и после него правила применяются уже к каждому элементу — а элемент здесь структура, значит, включается та же рекурсия из этого урока. Ошибки получают индексированные пути: Addresses[0].City, Addresses[1].ZipCode — фронт подсвечивает конкретную строку формы, а не весь блок. Пара тонкостей, которые обычно спрашивают сразу: пустой срез под dive не проверяет ничего — итераций ноль, и это не ошибка; nil-элемент в срезе указателей без required пропускается по правилам прошлого блока. Для длинных срезов полезно помнить, что ошибки не ограничиваются первым провалом: список из двадцати позиций с тремя сломанными вернёт три записи, и все три доедут до Details одним ответом — повторная проверка после исправления покажет следующий список, если что-то осталось. Полная механика dive — правила до и после разделителя, unique, карты с keys и endkeys, цена поэлементной проверки — отдельная тема следующего урока, здесь достаточно моста: вложенность работает и внутри элементов, просто перед ней стоит dive.
Правила вложенности одним списком#
- вложенные структуры проверяются рекурсией без дополнительных тегов — на любую глубину
- Field() — последний сегмент пути, Namespace() — полный маршрут через точки
- required на структуре-значении ничего не проверяет; «объект обязателен» — это *T с required
- nil-указатель без required пропускается целиком; omitempty делает намерение явным
- после успешной проверки *T с required разыменование безопасно
- structonly останавливает спуск в поля, validate:"-" выключает поле целиком
- на деревьях каталог уточнений ключуют по Namespace(), а не по Field()
- для срезов структур перед рекурсией стоит dive — темы следующего урока
Моё рабочее мнение: для простых форм вложенность — лишняя. Форма профиля из трёх полей не обязана нести объект Address ради двух строк — плоский DTO с City и Street коротче, ошибки читаются без разбора пути, а фронтенд подсвечивает инпуты без конвертов маршрутов. Условие, при котором я меняю мнение: данные иерархические по смыслу — адрес повторяется, позиций заказа много, вложенный объект едет дальше по системе как единое целое. Тогда вложенность отражает домен, а не вкус разработчика, и рекурсивная проверка окупается мгновенно. Хуже всего золотая середина: вложенность ради «красивого JSON» в форме, где глубина не отражает ничего.
Соберите договоры вложенности
Три пропуска: опциональный объект, обязательный объект и поле, которое проверено выше по потоку.
Соберите путь ошибки по дереву
Клиент отправил профиль с пустым City и коротким ZipCode. Разложите события валидации по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: дерево и его правила
Восьмой урок — срезы и карты: тег dive: правила до и после разделителя, unique, карты с keys и endkeys и цена поэлементной проверки. Когда вложенность растёт вместе с API, пригодится урок про DTO-границы — там решаем, что проверять на входе, а что домену; а адрес, телефон и прочие персональные данные из этого урока вернутся с юридической стороны в уроке про ПДн и 152-ФЗ. Код профиля с адресом соберите локально: песочница сайта исполняет только чистый Go, валидатор — программа вашего терминала, а интерактивы проверяют договоры nil против required без запуска.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Вложенные структуры валидатор проверяет рекурсией без дополнительных тегов — ошибки собираются со всего дерева.
- Namespace() — полный путь через точки, Field() — последний сегмент; на деревьях каталог уточнений ключуют по Namespace().
- required на структуре-значении ничего не проверяет; «объект обязателен» — это *T с required.
- nil-указатель без required пропускается целиком; omitempty делает опциональность явным договором.
- structonly останавливает спуск в поля; validate:"-" выключает поле совсем — это разные задачи.
- RegisterTagNameFunc переименовывает каждый сегмент пути: Namespace() становится маршрутом json-имён для фронта.
- Для срезов структур перед рекурсией стоит dive; ошибки получают индексы вроде Addresses[0].City.
Домашнее задание
Возьмите форму заказа из урока и доведите до полного договора: Delivery *Address с omitempty, Invoice *Address с required, Positions []Item с dive для следующего урока. Прогоните локально четыре состояния: Delivery nil, Delivery пустым объектом, Invoice nil, Invoice заполненным наполовину. Для каждого запишите, какие записи легли в ValidationErrors и какие пути показал Namespace(). Потом замените Delivery на структуру-значение и объясните себе, почему исчезла возможность сказать «объекта может не быть».
Личные заметкиПусто
Вопросы и ответы по уроку
Как проверить «хотя бы один объект из среза заполнен правильно»?
Тегами уровня контейнера это не выражается: dive проверит каждый элемент, а правило «хотя бы один валиден» — это бизнес-условие. Путь — StructLevel-проверка на одиннадцатом уроке или своя функция-валидатор на десятом, которые читают весь срез и решают по своей логике. Держите её вне тегов, чтобы не имитировать перечисления строками.
Валидатор зациклится, если структуры ссылаются друг на друга?
Циклы в структурах — признак сломанной модели данных, и валидатор не обязан их спасать: бесконечный спуск по ссылкам переполнит стек раньше, чем вернёт ответ. Разрывайте циклы указателями на корневые справочники или описывайте ссылку идентификатором, а не копией структуры — заодно упростите и сериализацию.
Почему после omitempty на *Address заказ прошёл без адреса доставки?
Потому что так вы и написали: nil-указатель пропускается целиком, поля не проверяются. omitempty честен — «объекта может не быть». Если логика требует объект, ставьте required; если объект нужен только при некоторых условиях — условные теги девятого урока или отдельные структуры под каждый вариант формы.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon