golang

Как лень довела меня до написания кодогенератора

  • среда, 9 сентября 2026 г. в 00:00:07
https://habr.com/ru/articles/1077792/

Лень — двигатель прогресса. Я очень ленив, поэтому прогресс должен был выйти впечатляющим. Спойлер: он и вышел, но с ленью что-то пошло не так.

Сколько раз можно написать «поле обязательно»

Проект, в который я пришёл, был большой и почтенный, а валидация в нём жила внутри хендлеров. Выглядело это примерно так — и, подозреваю, вы такое видели:

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 в том же репозитории, так что вывод генератора можно не принимать на слово.