Как лень довела меня до написания кодогенератора
- среда, 9 сентября 2026 г. в 00:00:07
Лень — двигатель прогресса. Я очень ленив, поэтому прогресс должен был выйти впечатляющим. Спойлер: он и вышел, но с ленью что-то пошло не так.
Проект, в который я пришёл, был большой и почтенный, а валидация в нём жила внутри хендлеров. Выглядело это примерно так — и, подозреваю, вы такое видели:
func (h *handler) CreateInvite(w http.ResponseWriter, r *http.Request) { var req InviteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, 400, "bad payload") return } req.OrgID = chi.URLParam(r, "orgId") req.Token = r.Header.Get("Authorization") if ttl := r.URL.Query().Get("ttl"); ttl != "" { n, err := strconv.Atoi(ttl) if err != nil { writeError(w, 400, "ttl must be a number") return } req.TTL = n } if req.OrgID == "" { writeError(w, 422, "orgId is required") return } if len(req.Name) > 20 { writeError(w, 422, "name too long") return } // ...и только потом, строк через двадцать, начинается дело }
Умножьте на количество эндпоинтов. Проблема даже не в объёме — объём можно перетерпеть, — а в том, что этот код расползается. В одном хендлере ttl по умолчанию семь, в другом ноль. Здесь ошибка отдаётся полем error, там — полем message. Где-то забыли проверить имя, и никто этого не замечает, пока не приходит приглашение без имени.
А ещё len(req.Name) > 20 — это байты, а не символы. «Пётр Кузнецов» — тринадцать символов и двадцать пять байт, так что в лимит он не влезает. Узнаёт он об этом раньше, чем автор кода.
Я предложил go-playground/validator. Библиотека прекрасная, живёт десять лет, теги описывают ровно то, что нужно, и писать больше ничего не надо. Идеальное решение для ленивого человека.
И тут выяснилось, что рефлексия нас замедлит. Прозвучало даже число — двадцать миллисекунд.
Мы сделали бенчмарки. Порядок оказался такой: единицы микросекунд на структуру — примерно столько же, сколько занимает json.Unmarshal того же тела, который в хендлере происходит всё равно, и на три порядка меньше, чем поход в базу двумя строками ниже. Двадцати миллисекунд там не было и близко.
Лично я и тогда считал это мелочью, и сейчас считаю. Но команда решила иначе.
Раз тегам не быть, я посмотрел на ozzo-validation — там правила описываются кодом, а не тегами, и на вид это компактно:
func (i Invite) Validate() error { return validation.ValidateStruct(&i, validation.Field(&i.OrgID, validation.Required), validation.Field(&i.Name, validation.Required, validation.Length(0, 20)), validation.Field(&i.Email, validation.Required, is.Email), ) }
Читается приятно, ссылка на поле — обычное выражение, так что переименование поймает компилятор. Но мне не подошло.
Во-первых, это по-прежнему ручная работа: Validate() пишется на каждую структуру, просто короче, чем цепочка if. Забыть строчку так же легко, и никто об этом не скажет.
Во-вторых, рефлексия никуда не делась — validation.Field(&i.OrgID, …) находит поле по указателю через reflect. То есть исходное возражение оставалось в силе, только теперь без единственного плюса тегов.
И главное: ни то, ни другое не касается биндинга. Половина листинга из начала статьи — chi.URLParam, r.Header.Get, strconv.Atoi — остаётся на месте при любом выборе.
Третий очевидный вариант — пойти от спецификации: oapi-codegen или ogen сгенерируют по OpenAPI-документу и типы, и разбор, и проверки по схеме. Задачу они закрывают целиком, и для проекта, который начинается с нуля, это хороший выбор.
Но у нас проект уже был, и предложение звучало как «перепишите HTTP-слой и сделайте источником истины yaml-файл». Это не «добавить валидацию», это смена того, откуда в проекте берётся правда, — совсем другой разговор с командой, которая только что зарубила рефлексию. Плюс, признаюсь, я и сам предпочитаю code first: мне спокойнее, когда истина в коде, который проверяет компилятор, а спецификация из него выводится, а не наоборот.
Поэтому я пошёл другим путём: если работа происходит в рантайме, пусть она происходит не в рантайме. Тогда предмет спора исчезает вместе с рантаймом, и обсуждать становится нечего.
Так началась история, в которой ленивый человек написал кодогенератор. Логика безупречна, я проверял.
Всё, что делал код из первого листинга, в структуре уже описано — просто не формально:
//go:generate whisk $GOFILE package model type Invite struct { OrgID string `scan:"path=orgId" validate:"required" json:"org_id"` Token string `scan:"header=Authorization" validate:"required" json:"token"` Email string `validate:"required,email" json:"email"` Name string `validate:"required,max=20" json:"name"` Tags []string `scan:"query=tag,repeated" validate:"unique" json:"tags"` TTL int `scan:"query=ttl" validate:"gte=1,lte=30" json:"ttl"` }
Своих тегов у генератора два: scan говорит, откуда взять поле, validate — каким оно обязано быть. Поле без scan приезжает из тела запроса, как и раньше. Привычный json тоже читается, но не для биндинга — из него берутся имена для путей в ошибках, о чём ниже.
Отдельно про слайсы, потому что списки клиенты передают двумя разными способами и угадывать тут нельзя.
Если написать Sort []string с тегом scan:"query=sort", поле прочитает одно значение и порежет его по запятым: ?sort=name,createdAt превратится в []string{"name", "createdAt"}. Пробелы вокруг элементов срезаются, пустые куски пропускаются — так что ?sort=name, ,createdAt, не даст вам срез с пустыми строками внутри.
А модификатор ,repeated, как у Tags в листинге выше, — это про повторяющийся параметр: ?tag=go&tag=codegen. Там генератор берёт все значения, а не первое.
Какой из двух способов правильный, решаете вы, а не библиотека: как ваш клиент присылает — так и объявляете.
Про типы полей стоит сказать отдельно, потому что из query приезжает строка, а положить надо то, что объявлено. Базовые типы разбираются через strconv, именованные над ними — тоже (type Page int разбирается как int и кладётся как Page), а всё, что умеет разбирать себя само, разбирает себя само:
type Request struct { ID uuid.UUID `scan:"path=id"` Parent *uuid.UUID `scan:"query=parent"` Ids []uuid.UUID `scan:"query=id"` }
Тут ничего специального про uuid нет: тип объявляет UnmarshalText([]byte) error, и генератор отдаёт ему сырое значение. Указатель аллоцируется перед вызовом, а если параметра нет — остаётся nil. То есть «необязательное поле» выражается типом, а не соглашением между вами и коллегой.
Причём текстовый декодер важнее приведения. Если у вашего type Email string есть UnmarshalText, значение пойдёт через него: он там, чтобы что-то нормализовать, и обойти его молча было бы свинством.
А json.Unmarshaler для таких полей не смотрится, и это решение. Query-параметр — голая строка, а не JSON-документ: тип с одним UnmarshalJSON потребовал бы ?id="abc" и падал на ?id=abc. В теле запроса он, конечно, работает, там json.Unmarshal уважает оба интерфейса.
Вообще, этот кусок появился, пока я писал статью. До неё uuid.UUID из пути не биндился вообще — и никто этого не замечал, потому что у нас в моделях везде стоял string с validate:"uuid". Работало, ошибку про неверный id отдавало, вопросов не вызывало. Пришлось сесть писать листинг для раздела «чего здесь нет» — и стало неловко за формулировку.
И раз уж об этом. Переход с string + validate:"uuid" на uuid.UUID меняет класс ошибки. Сам whisk про коды ответов ничего не знает — он возвращает либо *validate.ValidationError, либо ошибку, обёрнутую в scan.ErrPayload. «asdf» в строковом поле — валидная строка, не прошедшая правило, то есть первое. В поле типа uuid.UUID она не влезает вовсе: падает Parse, и это второе.
Что из класса станет ответом, решает тот, кто рендерит. HTTP-рендерер в gorib отдаёт валидационные ошибки как 422 с type: validation_failed и списком путей, а ErrPayload — как 400. Консьюмер очереди на том же коде отдал бы что-то совсем другое, потому что там статусов нет.
Оба варианта защитимы, и я не знаю, какой правильнее. Неприятно другое: класс меняется молча, от правки типа поля, — а клиент, который ветвится по коду или по type, это заметит. Так что менять надо вместе с тем, кто рендерит, или не менять вовсе.
Дальше go generate кладёт рядом файл с двумя методами:
func (s *Invite) Parse(src scan.Source) error func (s *Invite) Validate() error
Тут возникает законный вопрос: у меня двадцать списочных ручек, и в каждой page с count и сортировкой. Копировать теги двадцать раз?
Нет. Структура с тегами — обычный тип, и её можно вложить:
type Paginated struct { Page int64 `scan:"query=page"` Count int64 `scan:"query=count"` Sort []string `scan:"query=sort[],repeated"` } type ListUsers struct { Paginated Paginated `scan:"embed"` Status string `scan:"query=status"` }
scan:"embed" означает «у этого поля свой Parse, вызови его». Сгенерированный Parse родителя делегирует — s.Paginated.Parse(src) — вместо того чтобы дублировать разбор полей. Проверки складываются так же: Validate() родителя зовёт вложенный.
И вот что оказалось важнее, чем экономия строк. Миксин — обычный Go-тип, значит у него может быть свой хук:
// Клиент считает страницы с единицы, репозиторий с нуля. Один раз здесь, а не в // двадцати хендлерах, где про это можно забыть. func (p *Paginated) parse(scan.Source) error { if p.Page > 0 { p.Page-- } return nil }
То есть вложенный тип уносит с собой не только объявление полей, но и всю свою возню: приведение нумерации, значения по умолчанию, декодирование чего-нибудь хитрого из строки. Снаружи он выглядит как поле, а внутри знает про себя всё.
У нас так живут пагинация с сортировкой и фильтр по датам. Каждый — тип со своими тегами, своим хуком и своими методами; списочные модели их просто объявляют.
Одна тонкость, о которую я спотыкался сам: поле должно быть именованным, а не анонимным Paginated без имени. Анонимное встраивание тоже работает, но сгенерированный Parse вложенного типа поднимается в набор методов родителя — и снаружи появляется метод, которого там быть не должно. whisk на это ругается предупреждением и продолжает, но лучше не надо.
Это самая скучная часть статьи и самая важная. Кодогенератор, вывод которого нельзя прочитать, — это рефлексия, только хуже. Поэтому вот Parse целиком, как он лежит в репозитории:
func (s *Invite) Parse(src scan.Source) error { if body := src.Body(); len(body) > 0 { if err := json.Unmarshal(body, s); err != nil { return failure.Wrap("%w: %w", scan.ErrPayload, err) } } if v := src.Lookup("path", "orgId"); v != "" { s.OrgID = v } if v := src.Lookup("header", "Authorization"); v != "" { s.Token = v } if vs := src.LookupAll("query", "tag"); len(vs) > 0 { s.Tags = make([]string, 0, len(vs)) for _, v := range vs { v = strings.TrimSpace(v) s.Tags = append(s.Tags, v) } } if v := src.Lookup("query", "ttl"); v != "" { parsed, err := strconv.ParseInt(v, 10, 64) if err != nil { return failure.Wrap("%w: TTL: %w", scan.ErrPayload, err) } s.TTL = int(parsed) } return nil }
Никакого reflect, никаких таблиц соответствия, никаких тегов в рантайме. Прямой доступ к полям — ровно то, что написал бы человек, если бы ему хватило терпения писать это для каждой структуры одинаково.
Validate устроен так же. Кусочек:
if s.Name == "" { errs = append(errs, &validate.ValidationError{ Segments: validate.Path{validate.NewFieldSegment("go", "Name", "json", "name")}, Tag: "required", Kind: validate.KindStr, }) } if s.Name != "" { if utf8.RuneCountInString(s.Name) > 20 { errs = append(errs, &validate.ValidationError{ Segments: validate.Path{validate.NewFieldSegment("go", "Name", "json", "name")}, Tag: "max", Value: "20", Kind: validate.KindStr, }) } }
В этих строчках спрятаны пять решений, до которых я дошёл не сразу.
Ошибки собираются в срез и отдаются через errors.Join, а не по первой найденной. Клиент получает весь список сразу и правит форму один раз, а не играет с сервером в «угадай следующее поле».
Второй блок обёрнут в if s.Name != "". Провалившийся required гасит остальные проверки того же поля, иначе на пустой строке приезжает букет из «обязательное» и «слишком длинное» — формально верный и совершенно бесполезный.
И utf8.RuneCountInString вместо len. Тот самый Пётр.
Последнее из видимого: Kind — не строка, а константа из whisk/validate. Вид значения уезжает клиенту в теле ошибки, чтобы он мог сам подобрать формулировку: «слишком длинный» для строки и «слишком много элементов» для списка это разные фразы про один и тот же тег max. Раз это часть контракта, словарь живёт в одном месте, а не двумя копиями — в эмиттере и в пакете.
Пятое в этом отрывке не видно, потому что касается вложенного. Если поле — структура или слайс структур, генератор сам обходит элементы и зовёт их Validate(), подставляя индекс в путь ошибки. Писать dive не нужно:
Items []Item `validate:"required,min=1"` // Item.Validate() вызовется сам Codes []string `validate:"dive,min=2,max=10"` // а здесь dive обязателен
Разница в том, что для структуры очевидно, что делать с элементом — позвать его проверку. А для слайса строк правила надо назвать, иначе непонятно, что проверять.
В рантайм-валидаторах забытый dive означает молча непроверенный вложенный объект — из тех дефектов, которые живут годами, потому что тест на них никто не пишет. Здесь забывать нечего, и вызов прямой: тип известен при генерации, так что ни рефлексии, ни интерфейсной обёртки в этом месте нет.
С него я начал, им и закончу. Вот он целиком:
func (h *handler) CreateInvite(w http.ResponseWriter, r *http.Request) { req, err := scan.Into[model.Invite](server.From(r)) if err != nil { writeError(w, err) return } // req заполнен и проверен — дальше сразу дело }
server.From(r) оборачивает запрос в тот самый Source, а scan.Into вызывает сначала Parse, потом Validate и возвращает первую ошибку. Двадцать строк разбора и проверок из первого листинга сжались в две — и, что важнее, они одинаковые во всех хендлерах, потому что их никто не пишет.
Ошибку при этом можно отдавать одним обработчиком на все ручки: «не разобрали» и «разобрали, но не годится» — разные типы, и отличить их снаружи нетрудно. Об этом чуть ниже.
Строчку server.From тоже можно спрятать — http-сервер умеет отдавать в хендлер уже готовый *model.Invite. Но это уже разговор про сервер, а статья про теги.
Вернёмся к сигнатуре сгенерированного метода:
func (s *Invite) Parse(src scan.Source) error
Он принимает не *http.Request, а вот такой интерфейс:
type Source interface { Lookup(scope, key string) string LookupAll(scope, key string) []string Body() []byte Context() context.Context }
Сначала это было чисто гигиеническое решение — не хотелось тащить net/http в пакет с моделями. Но вместе с ним получилось кое-что интереснее: scope в теге, то есть path или query, для генератора — непрозрачная строка. Он не знает, что она означает. Он выписывает src.Lookup("path", "orgId") дословно и оставляет толкование адаптеру.
А адаптер — это те самые четыре метода, и ничего больше. Вот три из них у адаптера RabbitMQ, как они лежат в репозитории (Context() там ровно то, чем выглядит):
func (s *deliverySource) Body() []byte { return s.d.Body } func (s *deliverySource) Lookup(scope, key string) string { switch scope { case "header": return s.Header(key) case "routing-key": return s.d.RoutingKey case "exchange": return s.d.Exchange } return "" } func (s *deliverySource) LookupAll(scope, key string) []string { if v := s.Lookup(scope, key); v != "" { return []string{v} } return nil }
Вот и весь «плагин на транспорт»: один switch, в котором вы решаете, что у вас означает routing-key. HTTP-адаптер устроен так же, только вместо этих трёх случаев у него path, query, header, cookie и form. Для Kafka понадобился бы topic. А для теста источником становятся просто байты:
req, err := scan.Into[model.Invite](scan.FromBytes(payload))
В результате модели событий из RabbitMQ у меня описаны теми же тегами, что модели HTTP-запросов, и отдельного слоя разбора сообщений в проекте просто нет. Я к этому не стремился, оно выпало из отказа от *http.Request.
Такое случается, когда убираешь из типа лишнее знание. Не всегда, но приятно, когда случается.
ValidationError несёт не строку, а структуру: путь к полю, имя правила, его аргумент, вид значения и, если валидатор его вернул, исходную ошибку.
Путь при этом — не строка, а срез сегментов, и в текст он превращается тем, кто его отдаёт:
p.Format(validate.GoFormatter) // Order.Items.0.Name p.Format(validate.JSONFormatter) // order.items.0.name
Вот зачем в структуре из начала статьи стояли json-теги. Вернитесь на минуту к сгенерированному Validate — такая строка есть в каждой ошибке, вот она для OrgID:
Segments: validate.Path{validate.NewFieldSegment("go", "OrgID", "json", "org_id")},
Сегмент несёт оба имени сразу: и Go-шное, и то, под которым поле знает клиент. Поэтому один и тот же путь печатается двумя способами, а выбирает не генератор, а тот, кто отдаёт ошибку. Сменив формат тела на XML, вы получите в сегментах имена из xml-тегов — и формат путей поедет за форматом данных сам.
Мелочь на вид, и она решает. Клиент вашего API живёт в мире snake_case, который он же и прислал, а не в мире ваших Go-полей. Путь в ошибке должен совпадать с тем, что клиент видел, иначе ошибка технически верна и практически бесполезна. Выбор представления происходит в момент отдачи, а не в момент проверки, — и поэтому одну и ту же ошибку можно отдать по-разному в API и в логе.
Готовый рендерер превращает валидационные ошибки в 422 со списком путь/правило/значение, а «не смогли разобрать» — в 400. Два разных класса проблем разъезжаются по разным кодам сами, без единого if в хендлере.
У всякого декларативного валидатора есть соблазн дорасти до языка программирования. Сначала required, потом required_if, потом eqfield, потом excluded_unless_all_of, и через год у вас в тегах вторая программа, которую нельзя ни отладить, ни прочитать.
Я решил, что теги остаются структурным языком про одно поле. Всё, что требует второго поля, состояния или похода в базу, живёт в обычном методе на Go:
func (o Order) validate() error { if o.Status == "cancelled" && o.CancelledAt.IsZero() { return &validate.ValidationError{ Segments: validate.Path{validate.NewFieldSegment("go", "CancelledAt")}, Tag: "required_when_cancelled", } } return nil }
Метод с маленькой буквы, генератор находит его сам и вызывает после своих проверок; ошибки попадают в ту же цепочку. Симметрично для биндинга есть parse(src) — он делает то, что тегом не выразить: нескалярные конверсии, производные поля, значения по умолчанию.
Но validate() — это про одну конкретную структуру. А если правило нужно всему проекту?
Обычно в этом месте появляется реестр. У go-playground/validator это v.RegisterValidation("passcode", fn), и вызвать его надо где-то на старте — значит в init() или в main. Дальше начинается знакомое: в одном бинарнике зарегистрировали, в другом забыли; в тестах не зарегистрировали вообще; кто-то опечатался в имени, и правило просто молча не работает, потому что тег validate:"pascode" для рантайма — просто неизвестная строка.
Я не хотел ни реестра, ни init(). Поэтому своё правило добавляется функцией:
// pkg/validate/rules.go package validate func IsPasscode(value string, args ...string) bool { return len(value) == 6 }
И это всё. Никакой регистрации, никакого вызова на старте, ни строчки в main. Генератор при следующем go generate найдёт в договорном каталоге функцию Is + имя тега и подставит в сгенерированный код прямой вызов. Ищется имя функции, а не имя файла: rules.go может называться как удобно пакету.
Откуда берётся сам каталог — вопрос законный, и ответ на него лежит в проекте. Путь переносится флагом на конкретной строке go:generate, а на весь проект записывается в whisk.yaml в корне модуля:
format: json # json | xml | <custom, see scan.extractor> scan: extractor: pkg/scan # dir holding func Unmarshal<Format>([]byte, any) error validate: storage: pkg/validate # dir holding func Is<Tag>(value, args ...string) bool
Файл пишет whisk init, и пишет значениями, а не закомментированными подсказками: смысл ровно в том, чтобы пути было видно. Приоритет — флаг, потом файл, потом дефолт, так что переопределение на одной строке go:generate продолжает работать. Неизвестный ключ — ошибка: конфиг, который молча означает что-то другое, хуже отсутствующего.
Из соглашения выпадает несколько приятных следствий.
Опечатка не доезжает до рантайма. Тег validate:"pascode", для которого нет ни встроенного правила, ни функции, останавливает генерацию — и сообщение называет всё, что нужно:
whisk: plugin validate: Auth.Code: unknown validator tag "pascode": not a built-in, and no func IsPascode in pkg/validate (found there: IsPasscode); point -validate.storage or whisk.yaml's validate.storage at the right directory
Поле, искомая функция, каталог, где искали, и что там лежит вместо неё. Не паника на первом запросе и не тихо пропущенная проверка.
Имя, совпавшее со встроенным, выигрывает. Хотите свой email, потому что у вашего бизнеса особое мнение о том, что такое адрес? Объявите IsEmail — и ваш победит. Это весь механизм переопределения: форкать генератор не нужно, наследоваться не от чего, конфигурировать нечего. Это же единственное место, где по модели не видно, какая проверка сработает, — поэтому генератор говорит вслух:
whisk: warning: checks.IsEmail overrides the built-in "email" validator
Правило может вернуть подробность. Кроме bool поддерживается (bool, error), и эта ошибка попадает в ValidationError.Err — то есть до потребителя, который сможет её развернуть и показать, что именно не так.
Та же схема работает и для форматов тела. json и xml встроены, а всё остальное — функция в каталоге:
// pkg/scan/formats.go package scan import "github.com/BurntSushi/toml" func UnmarshalToml(b []byte, v any) error { return toml.Unmarshal(b, v) }
После whisk -format=toml генератор будет звать вашу функцию — и заодно переключится на теги toml:"..." при построении путей в ошибках, чтобы клиенту приезжали имена из его формата, а не из чужого. Одно соглашение, применённое дважды: это уже не приём, а правило проекта.
Цену всё равно назову, потому что она есть. Соглашение о каталоге — это неявность, та самая, за которую я выше ругал рефлексию: pkg/validate не просто каталог, и об этом надо откуда-то узнать. Вопрос в том, где это «откуда-то» находится. У реестра — в голове, при каждой новой сборке и каждом новом тесте. Здесь — в двух местах, и оба в репозитории: whisk.yaml, который читается и ревьюится как любой другой конфиг, и сообщение об ошибке, которое приходит ровно в тот момент, когда соглашение понадобилось. Обмен я считаю выгодным, но это обмен: проект, не написавший whisk.yaml, живёт на дефолтах — просто теперь у него есть чем их назвать.
Теперь про то, за что обязательно достанется.
Не всякий тип доедет из строки сам. Структура без текстового декодера и слайс указателей ([]*uuid.UUID) получают в сгенерированном файле пометку // TODO whisk: и разбираются руками в хуке parse(src). Терпимо, но ручная работа, и её тем больше, чем причудливее типы в запросах.
Порядок применения зафиксирован: тело разбирается всегда, поля из query, заголовков и пути переопределяют то, что пришло из тела, а хук идёт последним. Удобно, пока совпадает с вашими ожиданиями, и не настраивается, если не совпало.
И да, это ещё один диалект тегов. Синтаксис я взял сознательным подмножеством go-playground/validator, чтобы правила переносились как есть, — но формально это отдельная реализация, и совпадение проверяют мои тесты, а не чужие.
Опечатка в scan не ловится, и это граница обещания, которое я дам ниже. Оно про тег validate: там генератор знает весь словарь правил, потому что владеет им. А scan называет источник — и структура при этом ни к какому транспорту не привязана, в том и смысл: одна и та же модель читается из HTTP, из очереди и из голых байтов. Сверять не с чем, и scan:"heder=Authorization" сгенерируется молча: Lookup вернёт пустую строку, поле останется нулевым.
С ключом и вовсе ничего не сделать: scan:"header=Autorization" даёт ровно то же, списка верных ключей нет ни у генератора, ни у транспорта, ни у кого. Со scope теоретически можно — привязать структуру к источнику, — но дёшево это не выходит. Объявлять пришлось бы каждый источник, из которого модель читается, а полной защиты всё равно не выйдет. Поэтому вместо привязки — required: превращает промах в 422 на первом же запросе, то есть ловит в первом тесте, — но это проверка на каждый запрос вместо одной на сборке, а на законно необязательном поле её нет вовсе.
И про исходное возражение, из-за которого всё началось. Я уходил от спора, а не шёл за производительностью: мне нужно было, чтобы обсуждать стало нечего, и рантайм я убрал именно для этого. Получил при этом больше, чем собирался — опечатка в validate останавливает генерацию, а не тихо пропадает вместе с проверкой. Это пригодилось гораздо чаще, чем ускорение.
Пока я всё это писал, до меня доходило постепенно: я ведь делал биндер с валидатором, а получил кое-что другое — формальное описание того, из чего состоит запрос. Раньше это знание было размазано по хендлерам и существовало только в виде исполняемого кода. Теперь оно лежит в одном месте, объявлением, и прочитать его может кто угодно.
Первым этим воспользовался не я, а генератор документации. По тем же тегам плюс реестру маршрутов он выводит спецификацию OpenAPI: email превращается в format, oneof — в enum, min, max и сравнения — в границы длины или диапазона. А правило, которое OpenAPI выразить не умеет, не выбрасывается — уезжает в x-constraints и остаётся видимым тому, кто читает документ.
Тут важно не обмануть ожиданий, поэтому скажу сразу: whisk документацию не генерирует и про OpenAPI не знает вообще. Спецификацию делает отдельный проект, читающий те же теги. Про него и про то, как документация перестаёт отставать от кода, — в следующей статье.
Если снять с этой истории мою реализацию, остаётся один приём: сделайте декларацию контрактом, и число инструментов, которые могут её прочитать, перестанет быть равным одному.
Код: gitlab.com/gorib/whisk. Структура Invite и оба сгенерированных листинга — из testdata/cases/basic в том же репозитории, так что вывод генератора можно не принимать на слово.