Содержание урока · 10 разделов
Списки — самый недопроверенный тип данных в API. Теги статей, позиции заказа, атрибуты товара: форма сверху выглядит валидной, а внутри среза живут пустая строка, дубль и элемент на пятьсот символов. Полиморфизм min и max из третьего урока здесь работает наполовину: на срезе они считают элементы, а не символы — «минимум 1» не спасёт от пустых строк внутри элементов. Для содержимого контейнера валидатор требует отдельного приказа, и этот приказ — dive: встретив его в теге, движок переключается с контейнера на элементы и прогоняет оставшиеся правила по каждому. Разделитель один, а последствий два: всё до него описывает контейнер, всё после — элементы. Это простое правило держит весь урок.
validator dive: правила до и после разделителя#
Соберём список тегов статьи: Title сверху и Tags []string — от нуля до десяти тегов, каждый от одного до шестнадцати символов. Тег поля собирается так: omitempty разрешает пустой список, max=10 ограничивает количество элементов, dive переключает режим, и дальше min=1,max=16 уже про каждый элемент. Валидация слайса go читается как предложение: контейнер — максимум десять элементов, внутри каждого — от одного до шестнадцати символов. Ключевая разница с третьим уроком в том, куда смотрит правило: min=1 до dive требует хотя бы один элемент в срезе, min=1 после dive требует непустую строку в каждом элементе — одинаковая запись, два разных договора. Правила после dive исполняются для каждого элемента слева направо до первого провала, ровно как на плоских полях; элемент не проходит одно правило — его проверка завершена, следующие не выполняются. Контейнер при этом проверяется один раз, до цикла. Массивы живут по тем же правилам: [4]string под dive проверит каждый из четырёх элементов, а len=4 до dive потребует ровно четыре. И последняя деталь грамматики: dive может стоять и последним правилом — тогда он просто включает проверку элементов, а правила к ним приезжают из тегов самих структур, как в срезе объектов из пятого блока. Так тегом из одной строки описывается конвейер, который ручным кодом занял бы вложенный цикл с ветками на каждый случай.
Список тегов статьи: контейнер и элементы
GOtype ArticleForm struct {
Title string `validate:"required,min=5,max=120"`
Tags []string `validate:"omitempty,max=10,dive,min=1,max=16"`
}
// Tags: ["go", "", "очень длинный тег из многих слов"]
// Namespace() Field() Tag() Param()
// ArticleForm.Tags[1] Tags[1] min 1
// ArticleForm.Tags[2] Tags[2] max 16max=10 стоит до dive — это правило контейнера, элементов три, проходит. После dive правило min=1 ловит пустую строку во втором элементе, max=16 — длинный тег в третьем. Ошибки приезжают отдельными записями с индексами в Namespace(), поэтому фронт подсвечивает конкретные строки списка, а не весь блок разом.
Контейнер против элемента#
| Правило | До dive (контейнер) | После dive (элемент) |
|---|---|---|
| min=N | элементов ≥ N | длина элемента ≥ N рун |
| max=N | элементов ≤ N | длина элемента ≤ N рун |
| required | срез не nil — пустой, но инициализированный пройдёт | элемент — не нулевое значение |
| unique | нет дублей среди элементов | места нет: это правило контейнера |
| len=N | ровно N элементов | ровно N рун в элементе |
| email, oneof и другие | — | формат каждого элемента |
Проверка: куда смотрит min
Tags []string с тегом max=10,dive,min=1. Срез из двенадцати непустых коротких элементов. Что вернёт валидатор?
валидация слайса go: ошибки по индексам#
Элементы независимы, как поля плоской структуры: один провалился — проверка остальных продолжается, и каждая ошибка становится отдельной записью ValidationErrors. В поле Field() такой записи появляется индекс: Tags[1], Tags[2] — это обещание пятого урока сбылось. Именно индексы превращают технический список в человеческий ответ: каталог сообщений из шестого урока строит «тег №2 пустой» по Field(), и фронт подсвечивает конкретную строку списка, а не блок целиком. Отсюда два практических вывода. Первый: не склеивайте ошибки элементов в одну строку на бэкенде — держите по записи на элемент, формату Details из двадцать четвёртого урока это подходит идеально. Второй: ожидайте несколько записей по одному полю — срез из трёх пустых элементов даст три записи с разными индексами, и тесты должны это покрывать. Сообщение при этом собирается из готовых деталей: Field() дал «Tags[1]», каталог по Tag() вернул «пусто нельзя, минимум 1 символ» — и пользователь видит «Тег 2: пусто нельзя», не зная ничего о срезах и нумерации с нуля. Отдельная тонкость порядка: внутри одного элемента работает правило «первый провалившийся тег завершает проверку», поэтому длинный пустой элемент вернёт min, а не max — как у Password в пятом уроке. И последнее: индексы в Namespace() — материал для ретрая и логов, но не для ответа клиенту как есть: пользователь думает строками формы, а не номерами массива.
Сколько записей и с какими индексами
Прочитайте код и выберите, что он выведет:
type Playlist struct {
Names []string `validate:"max=5,dive,min=2"`
}
p := Playlist{Names: []string{"go", "x"}}
err := validate.Struct(&p)
// что лежит в ValidationErrors?unique: список без дублей#
Второй список-специфический тег — unique: он требует, чтобы элементы не повторялись. Правило уровня контейнера, поэтому ставится до dive, вместе с max и len: validate:"omitempty,unique,dive,min=1,max=16". Сценарии — теги статьи, список адресатов, набор купонов: дубль здесь не ошибка формата, а логическая ерунда, которую не поймает ни один тег формата из четвёртого урока. Механика у unique простая: валидатор собирает элементы в служебную карту и ловит повтор — значит, элементы должны быть сравнимыми: строки, числа, структуры без срезов и карт внутри. Срез несравнимого типа под unique приведёт к панике в рантайме при первой же проверке — тот же жанр, что битый тег из первого урока, и лечится тем же лекарством: тестом с реальным значением на старте. Для срезов структур, где «уникальность» означает «нет двух позиций с одним артикулом», у unique есть параметр: unique=SKU сравнивает элементы по полю SKU и ловит повтор (с версии v10.12), а на отдельном поле той же механикой работает сравнение с соседом — «дата конца не равна дате начала». Ошибётесь в имени поля — паника «Bad field name» на первой проверке, и это тоже ловит тест на старте. Уникальность по нескольким полям сразу — по-прежнему территория StructLevel из одиннадцатого урока. И заметьте разделение труда: unique следит за взаимностью элементов, dive — за качеством каждого по отдельности; одно другому не заменяет, и в честных формах оба правила живут рядом. Комбинация с oneof внутри дива закрывает и качество, и состав: omitempty,unique,dive,oneof=go rust python — список без дублей, где каждый элемент из перечня. Одна строка тега заменяет три ветки кода, и ошибки по-прежнему едут с индексами.
Команда хотела «в списке нет пустых строк» и написала min=1 перед dive. Правило прилипло к контейнеру и стало требовать хотя бы один элемент: пустой срез проваливается («минимум 1 элемент» у пользователя, который просто не добавил теги), а срез из пустых строк проходит насквозь — ровно наоборот от задуманного. Симптом неявный: тесты с заполненными списками зелёные, прод ломается на пустой форме. Лечится чтением тега вслух: всё до dive — про контейнер, всё после — про элементы. Проверяйте пятёркой значений из третьего урока: пустой срез, один элемент, пустая строка внутри, дубль, длинный элемент.
Карты: dive, keys и endkeys#
С картами dive работает по той же схеме с одной надстройкой: после dive правила применяются к значениям, а для ключей есть собственный сэндвич — между тегами keys и endkeys пишутся правила ключа, после endkeys — правила значения. Атрибуты товара: Attrs map[string]string, ключ — имя атрибута от одного до двадцати символов, значение — до двухсот. Один тег описывает обе стороны каждой пары, и ошибки различают, что именно сломалось: путь в Namespace() содержит сам ключ карты, поэтому из ответа видно, какая пара виновата. Пустая карта под dive не проверяет ничего — итераций ноль, это не ошибка; обязательность наличия пар описывается required или min=1 до dive, по правилам контейнера. Для структур в значениях действует то же правило, что и для срезов структур: dive включает рекурсию седьмого урока, и путь до ошибки проходит сквозь ключ карты. Практический совет: не описывайте в ключах сложные форматы — ключ карты чаще всего едет в JSON-объект, где его формат договорён с фронтом; тегу оставьте длину и oneof на перечень допустимых имён атрибутов. И держите сэндвич в голове как целое: keys пишут в паре с endkeys, чтобы границы правил ключа и значения в теге оставались читаемыми — вольная перестановка превращает тег в загадку для следующего ревью.
Атрибуты товара: ключи и значения в одном теге
GOtype MetaForm struct {
Attrs map[string]string `validate:"omitempty,dive,keys,min=1,max=20,endkeys,max=200"`
}
// Attrs: {"цвет": "красный", "": "потерянный ключ"}
// ключ "" → провал keys-правила min=1: путь содержит сам ключ
// значения → max=200 после endkeys, по каждой пареПравила между keys и endkeys — про ключ: min=1 отвергает пустое имя атрибута, max=20 ограничивает его длину. После endkeys идут правила значения: max=200 на каждую пару. omitempty разрешает карту без атрибутов. В путях ошибок фигурирует сам ключ карты, поэтому виновника видно без угадываний по индексам.
Проверка: чьё правило между keys и endkeys
В теге карты написано dive,keys,min=1,endkeys,max=200. К чему применяется min=1?
Срез структур: dive внутрь полей#
Самый частый в реальных API случай — срез не строк, а структур: Positions []Item в заказе, Addresses []Address в профиле. Здесь dive включается в рекурсию из прошлого урока: правило после dive применяется к каждому элементу, элемент — структура, и валидатор спускается в её поля по их тегам. Пути ошибок собирают всё сразу: Order.Positions[0].Sku, Order.Positions[2].Qty — индекс элемента плюс маршрут внутри него, и фронт подсвечивает конкретную ячейку конкретной строки. Для указателей в срезе работает договор прошлой недели: []*Address с dive пропустит nil-элементы без спуска, а required на элементе потребует ненулевой указатель в каждой позиции. Условные теги из следующего урока тоже работают внутри элементов: требование «у позиции со скидкой обязателен код причины» выражается тегом на поле структуры, и dive доставит его до каждой строки. Единственное, чего dive не делает, — сравнивает элементы между собой: «сумма позиций равна итогу» и «артикулы не повторяются» живут за пределами тегов, в StructLevel из одиннадцатого урока. На практике здесь любят гранулярные Details: ошибка «позиция 3: количество больше остатка на складе» читается оператором быстрее, чем общее «проверьте позиции», — формат ошибок API рассчитан именно на такой по-элементный список.
Правила dive одним списком#
- всё до dive — правила контейнера, всё после — правила элементов
- мин/max до dive считают элементы, после — руны строки или поля структуры
- элементы независимы: три плохих элемента — три записи с индексами в Field()
- внутри элемента работает «первый провалившийся тег завершает проверку»
- unique — правило контейнера, ставится до dive; элементы обязаны быть сравнимыми
- для карты: правила ключа — между keys и endkeys, значения — после
- пустой контейнер под dive не проверяет ничего; обязательность — required или min до dive
- для срезов структур dive включает рекурсию прошлых уроков, пути получают индексы
Поэлементная проверка через reflect заметно дороже плоской структуры, и на горячих путях я не ставлю dive с тяжёлыми правилами на срезы в тысячи элементов — импорт каталога, пакетная загрузка, вебхуки с большим телом. Там честнее ручной проход обычными if с накоплением ошибок: быстрее, и ошибки собираются по своей логике. Условие, при котором dive прав везде: элементов десятки, источник — пользователь, а требование «подсветить каждую строку формы» важнее наносекунд. Цена отражения и способы её измерить — отдельная тема урока про производительность.
Соберите теги списка и карты
Четыре пропуска: разделитель, запрет дублей и сэндвич для ключей карты.
Соберите путь списка через валидатор
Клиент отправил статью с тремя тегами, один из них пустой. Разложите события по порядку.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: контейнеры и элементы
Девятый урок — условные теги: required_without, excluded_with: зависимости полей внутри формы и внутри элементов среза, который мы сегодня научились доставать dive-ом. Полиморфизм длин и границ, на котором построен весь урок, разобран в третьем уроке, рекурсия для срезов структур — в седьмом. Код формы статьи соберите локально: песочница сайта исполняет только чистый Go без внешних модулей, поэтому валидатор — программа вашего терминала, а интерактивы закрепляют уровни правил без запуска.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- dive — разделитель: до него правила контейнера, после — правила каждого элемента.
- min и max до dive считают элементы, после — руны строк; одна запись, два разных договора.
- Элементы независимы: три плохих элемента дают три записи FieldError с индексами вроде Tags[1].
- unique — правило контейнера против дублей, ставится до dive; элементы должны быть сравнимыми.
- Для карт: правила ключей — между keys и endkeys, значения — после; в пути ошибки виден сам ключ.
- Для срезов структур dive включает рекурсию, и пути получают индексы: Positions[0].Sku.
- На больших срезах горячего пути dive с тяжёлыми правилами заменяют ручным проходом — цена отражения.
Домашнее задание
Возьмите форму статьи и доведите список до контракта: Tags []string с omitempty,max=10,unique,dive,min=1,max=16. Прогоните локально пять случаев: пустой срез, один тег, пустая строка внутри, два одинаковых тега, тег на двадцать символов. Запишите, какие записи легли в ValidationErrors и что показал Field() для каждой. Потом переставьте min=1 перед dive и найдите случай, при котором поведение ломается ровно наоборот от задуманного — вы только что воспроизвели главную граблю урока.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему пустой срез проходит валидацию с dive?
Потому что dive — цикл по элементам, а в пустом срезе итераций ноль: нечего проверять. Обязательность выражается правилами контейнера: min=1 до dive требует хотя бы один элемент, а required отфильтрует только nil — пустой, но инициализированный срез он пропускает, что после декодирования JSON означает «поле было, но с пустым массивом». Если пустой список легитимен, оставьте omitempty — тогда отсутствие и пустота пропускаются честно.
Как проверить, что элементы среза принадлежат перечню значений?
Поставьте oneof после dive: правила формата применяются к каждому элементу. oneof=go rust python после dive отвергнет элемент вне перечня с индексом в Field(). Перечень с пробелами внутри тега и запятыми между правилами — грамматика из третьего урока работает и здесь.
unique провалился — можно ли сказать, какие элементы дублируются?
Нет: unique — проверка уровня контейнера, и запись в ValidationErrors одна, без индексов виновников. Если продукт требует назвать дубли, проверяйте пересечения сами и складывайте результат в ответ — или заворачивайте в StructLevel-правило одиннадцатого урока, где доступен весь срез.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon