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

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

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

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

Вложенные структуры: Validate рекурсией

validator nested-структуры проверяет сам, без единого дополнительного тега: встретив поле-структуру, Struct спускается внутрь и собирает ошибки со всего дерева, а Namespace() показывает полный путь до сломанного поля. В этом уроке — адрес внутри профиля: указатели против значений, required у вложенного объекта, structonly, json-имена в пути и мост к dive для срезов структур.

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

До сих пор формы были плоскими: 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 из второго урока: дерево разбирается один раз за жизнь процесса, повторные вызовы работают по готовому плану.

Профиль с адресом: одна проверка на всё дерево

GO
type 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:"-"пропускаетсяпропускается

Три вложенных объекта, три договора

GO
type 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, и разыменование после проверки безопасно.

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 — темы следующего урока

Соберите договоры вложенности

Три пропуска: опциональный объект, обязательный объект и поле, которое проверено выше по потоку.

type CheckoutForm struct { Delivery *Address `validate:""` // объекта может не быть Invoice *Address `validate:""` // без объекта заказ не оформить Legacy Address `validate:""` // поля проверены на другой границе }

Соберите путь ошибки по дереву

Клиент отправил профиль с пустым City и коротким ZipCode. Разложите события валидации по порядку.

Строки

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

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

Карточки: дерево и его правила

Термин

рекурсия валидатора

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

Поле-структуру валидатор проверяет сам, без тегов: спускается в поля и применяет их теги на любой глубине. Стоимость разбора дерева кэшируется по reflect.Type.

1 / 6

Восьмой урок — срезы и карты: тег 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 на структуру-значение и объясните себе, почему исчезла возможность сказать «объекта может не быть».

Личные заметкиПусто
Хранятся только в вашем браузере (Validator · Вложенные структуры: Validate рекурсией)

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

Как проверить «хотя бы один объект из среза заполнен правильно»?

Тегами уровня контейнера это не выражается: dive проверит каждый элемент, а правило «хотя бы один валиден» — это бизнес-условие. Путь — StructLevel-проверка на одиннадцатом уроке или своя функция-валидатор на десятом, которые читают весь срез и решают по своей логике. Держите её вне тегов, чтобы не имитировать перечисления строками.

Валидатор зациклится, если структуры ссылаются друг на друга?

Циклы в структурах — признак сломанной модели данных, и валидатор не обязан их спасать: бесконечный спуск по ссылкам переполнит стек раньше, чем вернёт ответ. Разрывайте циклы указателями на корневые справочники или описывайте ссылку идентификатором, а не копией структуры — заодно упростите и сериализацию.

Почему после omitempty на *Address заказ прошёл без адреса доставки?

Потому что так вы и написали: nil-указатель пропускается целиком, поля не проверяются. omitempty честен — «объекта может не быть». Если логика требует объект, ставьте required; если объект нужен только при некоторых условиях — условные теги девятого урока или отдельные структуры под каждый вариант формы.

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

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

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

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

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

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

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