← назад к разделу

Встроенных тегов go-playground/validator — required, min, email, uuid — хватает для простых случаев. Когда логика сложнее, пишут свои правила. Разберём, как это сделать, как проверить согласованность нескольких полей и как превратить теги в понятные клиенту тексты: сам validator текстов не даёт вовсе.

Три типичных правила показывают всю развилку сразу: одно станет тегом на поле, второе — проверкой на уровне структуры, а третьему в валидаторе вообще не место.

Обязательно

Зачем нужны свои правила

Встроенные теги проверяют одно поле по простому критерию. Они не умеют:

  • сверять значение с базой («такой логин уже занят»);
  • проверять бизнес-правило, специфичное для домена («скидка не может быть больше цены»);
  • давать одно имя составному правилу, которое иначе копируется тегом regexp по десяти структурам.

Первое — не для валидатора вообще. Остальные два — свои правила.

Как устроено своё правило

Телефон должен начинаться с +7 и содержать одиннадцать цифр. Тег с регулярным выражением это умеет, но выражение придётся копировать в каждую структуру с телефоном, а сообщение — писать заново. Своё правило убирает копирование: правило и сообщение живут в одном месте.

var phoneRe = regexp.MustCompile(`^\+7\d{10}$`)

func phone(fl validator.FieldLevel) bool {
    return phoneRe.MatchString(fl.Field().String())
}

func New() *validator.Validate {
    v := validator.New(validator.WithRequiredStructEnabled())
    if err := v.RegisterValidation("phone", phone); err != nil {
        panic(err)
    }
    return v
}
type CreateUserRequest struct {
    Name  string `json:"name"  validate:"required"`
    Phone string `json:"phone" validate:"required,phone"`
}

Три вещи, которые здесь важны. Функция правила получает FieldLevel и отвечает только «да» или «нет»: ни текста, ни кода ошибки у неё нет — текст появится позже из перевода по имени тега. Регулярное выражение компилируется один раз на пакет, а не внутри функции: правило вызывается на каждое поле каждого запроса. И пустое значение — не забота правила: phone на пустой строке вернёт false, но за «обязательно» отвечает required, а необязательный телефон пишут как omitempty,phone, и тогда на пустое значение правило не вызывается.

Короткая формула: тег описывает контракт, функция — реализует его.

Межполевая валидация

Проверить, что два поля согласованы между собой, нельзя на уровне одного поля. Для самого частого случая есть встроенный тег:

type ChangePasswordRequest struct {
    Password        string `json:"password"        validate:"required,min=12"`
    ConfirmPassword string `json:"confirmPassword" validate:"required,eqfield=Password"`
}

eqfield=Password сравнивает с полем структуры по имени поля в Go, не по JSON-имени. Ошибка при этом привязана к ConfirmPassword — ровно к тому полю, которое клиенту надо подсветить.

Когда правило сложнее равенства — «дата окончания после начала, но не дальше чем на год», «скидка не больше цены» — пишут проверку на уровне структуры:

type DiscountRequest struct {
    Price    int64 `json:"price"    validate:"required,gt=0"`
    Discount int64 `json:"discount" validate:"gte=0"`
}

func discountNotAbovePrice(sl validator.StructLevel) {
    req := sl.Current().Interface().(DiscountRequest)
    if req.Discount > req.Price {
        sl.ReportError(req.Discount, "discount", "Discount", "lte_price", "")
    }
}

v.RegisterStructValidation(discountNotAbovePrice, DiscountRequest{})

ReportError делает то, что в других стеках делают отдельным вызовом «привязать к полю»: первым аргументом идёт значение, затем имя поля для клиента (его вернёт fe.Field()), затем имя поля в Go, затем имя правила, по которому потом найдётся перевод. Ошибка встанет рядом с полем discount, а не у объекта целиком.

Порядок: почему видно две ошибки сразу

Проверка структуры выполняется после полевых, но независимо от их результата. Отсюда картина, которая выглядит как дефект: пользователь прислал пустую цену и пустую скидку и получил «цена обязательна» и «скидка не больше цены» одновременно. Вторая бессмысленна: сравнивать было нечего.

Лечится устойчивостью к пустоте внутри самой проверки: если одно из сравниваемых полей пустое, выходим молча и оставляем работу полевым правилам.

func discountNotAbovePrice(sl validator.StructLevel) {
    req := sl.Current().Interface().(DiscountRequest)
    if req.Price == 0 {
        return
    }
    if req.Discount > req.Price {
        sl.ReportError(req.Discount, "discount", "Discount", "lte_price", "")
    }
}

Групп и последовательностей, как в Bean Validation, у validator нет, и для двух-трёх проверок они и не нужны. Если этапов много и порядок важен, проверяют в два вызова: сначала v.StructPartial по полям, и только при успехе — структуру целиком.

Сообщения об ошибках

Главное отличие от Java-стека: validator возвращает не тексты, а список нарушений с тегами. v.Struct(req) даёт validator.ValidationErrors — срез FieldError, у каждого есть поле, тег и параметр. Текста «неверный формат телефона» там нет; его надо получить переводом.

Для этого в комплекте идёт universal-translator и готовые переводы встроенных тегов на русский:

import (
    "github.com/go-playground/locales/ru"
    ut "github.com/go-playground/universal-translator"
    "github.com/go-playground/validator/v10"
    rutr "github.com/go-playground/validator/v10/translations/ru"
)

func NewWithMessages() (*validator.Validate, ut.Translator) {
    v := validator.New(validator.WithRequiredStructEnabled())
    loc := ru.New()
    uni := ut.New(loc, loc)
    trans, _ := uni.GetTranslator("ru")
    if err := rutr.RegisterDefaultTranslations(v, trans); err != nil {
        panic(err)
    }
    return v, trans
}

После этого у required, min, email есть русские тексты. Для своего правила перевод регистрируют отдельно — это и есть аналог ключа в файле сообщений:

err := v.RegisterTranslation("phone", trans,
    func(ut ut.Translator) error {
        return ut.Add("phone", "{0} должен начинаться с +7 и содержать 11 цифр", true)
    },
    func(ut ut.Translator, fe validator.FieldError) string {
        msg, _ := ut.T("phone", fe.Field())
        return msg
    })

{0} — имя поля, оно подставляется из fe.Field(). У правил с параметром (min=12) в шаблоне есть {1}, и туда кладут fe.Param().

Перевод нужен и правилу из ReportError: тег lte_price библиотеке неизвестен, и без RegisterTranslation("lte_price", …) вместо текста клиент получит служебную строку Key: 'DiscountRequest.Discount' Error: Field validation … failed on the 'lte_price' tag. Правило: каждый тег, который вы завели сами, получает перевод в той же функции, где регистрируется.

Имена полей из JSON, а не из Go

По умолчанию fe.Field() вернёт ConfirmPassword — имя поля в структуре. Клиент такого поля не знает, у него confirmPassword. Валидатору один раз говорят, откуда брать имя:

v.RegisterTagNameFunc(func(fld reflect.StructField) string {
    name, _, _ := strings.Cut(fld.Tag.Get("json"), ",")
    if name == "-" {
        return ""
    }
    return name
})

После этого и fe.Field(), и {0} в переводах говорят на языке контракта.

Во что нарушение превращается в ответе

Статья про сообщения без ответа с сообщением неполна. Вот что получает клиент, когда сработали и полевое, и межполевое правило:

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Проверка не пройдена",
  "status": 400,
  "code": "VALIDATION_FAILED",
  "violations": [
    { "field": "phone",           "message": "phone должен начинаться с +7 и содержать 11 цифр", "rule": "phone" },
    { "field": "confirmPassword", "message": "confirmPassword должен быть равен Password",        "rule": "eqfield" }
  ]
}

Сборка списка — одна функция на приложение, а не на обработчик:

func violations(err error, trans ut.Translator) ([]apperr.Violation, bool) {
    var verrs validator.ValidationErrors
    if !errors.As(err, &verrs) {
        return nil, false
    }
    out := make([]apperr.Violation, 0, len(verrs))
    for _, fe := range verrs {
        out = append(out, apperr.Violation{Field: fe.Field(), Message: fe.Translate(trans), Rule: fe.Tag()})
    }
    return out, true
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) error {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        return apperr.Invalid(nil)
    }
    if err := h.v.Struct(req); err != nil {
        vs, _ := violations(err, h.trans)
        return apperr.Invalid(vs)
    }
    return h.users.Create(r.Context(), req)
}

Два момента, которые решают за вас удобство клиента. У межполевого нарушения тоже есть поле — eqfield и ReportError дают его сами. И имя правила (rule) отдают рядом с сообщением: клиент может по нему подобрать свой текст, не разбирая человеческую строку. Как apperr.Invalid превращается в ответ 400 с типом application/problem+json — в статье про единый обработчик ошибок.

Какой язык выберется

Переводчик выбирается на запрос, а не на процесс: один пользователь просит русский, другой английский. universal-translator умеет держать несколько локалей и находить подходящую по заголовку:

func (h *Handler) translator(r *http.Request) ut.Translator {
    langs := strings.Split(r.Header.Get("Accept-Language"), ",")
    for i := range langs {
        langs[i], _, _ = strings.Cut(strings.TrimSpace(langs[i]), ";")
    }
    trans, _ := h.uni.FindTranslator(langs...)
    return trans
}

uni := ut.New(ru.New(), ru.New(), en.New()) — первая локаль запасная, остальные доступные; FindTranslator перебирает языки из заголовка и возвращает первый найденный, иначе запасной. Для каждой локали регистрируют и встроенные переводы, и свои: без RegisterTranslation("phone", enTrans, …) английский клиент получит пустую строку вместо сообщения.

Вне запроса — в обработчике сообщения из очереди, в фоновой задаче — заголовка нет, и переводчик берут запасной. Поэтому в машинных контурах на локализованные сообщения не опираются: там читают rule и field.

Валидатор — один на процесс

validator.New разбирает теги структур при первом обращении и кэширует их; создавать валидатор на каждый запрос — выбрасывать кэш и тратить время на отражение заново. Один экземпляр безопасен для одновременного использования из горутин. Его создают при старте и передают в обработчики полем, как любую зависимость, вместе с переводчиком.

Зависимости для своих правил передают замыканием: правило coupon, которому нужен формат из настроек, получает его при регистрации, а не читает из глобальной переменной:

func couponRule(rules CouponFormatRules) validator.Func {
    return func(fl validator.FieldLevel) bool {
        return rules.Matches(fl.Field().String())
    }
}

v.RegisterValidation("coupon", couponRule(cfg.Coupons))

Оговорка к следующему разделу: то, что зависимости технически доступны, не означает, что туда стоит передавать репозиторий.

Когда лучше проверить в сервисе

Своё правило — правильный инструмент, если правило:

  • чисто синтаксическое (формат, диапазон, структура данных);
  • переиспользуется в нескольких местах;
  • не требует обращения к базе или внешнему сервису.

Если проверка требует запроса к базе («логин уже занят», «категория существует»), её место в сценарии. Передать пул в замыкание правила технически можно, и именно поэтому стоит сказать, почему так не делают.

Время ответа. Правило вызывается на каждое поле при каждом запросе. Проверка «такой логин уже есть» — запрос в базу; десять таких полей — десять запросов, и они последовательные. Измеренная картина «было 40 мс, стало 900 мс на девяносто пятом процентиле» — типичный результат.

Гарантии всё равно нет. Между проверкой и вставкой проходит время, и за него параллельный запрос успевает занять тот же логин. Защищает только ограничение UNIQUE в базе; валидатор даёт лишь красивое сообщение.

И статус другой. «Логин занят» — не ошибка формы, а конфликт состояния: правильный ответ 409, а не 400. Валидатор физически не может отдать 409: все его нарушения собираются в один ответ о неверном запросе. Значит, проверка уникальности живёт в сценарии, а её результат — apperr.Conflict, который обработчик превращает в 409. Разбор уровней — в статье про где валидировать.

Правило: в валидаторе — только то, что проверяется по самому значению; всё, что требует состояния системы, — в сценарии с 409 и ограничением в базе.

Дополнительно: при первом чтении можно пропустить

Глубже: required и нулевые значениярасширенное

required в validator означает «не нулевое значение типа»: для строки — не пустая, для числа — не ноль, для указателя — не nil. Отсюда две ловушки. Поле Quantity int с required отвергнет честный ноль; если ноль допустим, поле делают указателем *int — тогда required проверяет только присутствие, а gte=0 — значение. И структура с required без WithRequiredStructEnabled() проверялась старыми версиями не так, как ожидают: флаг включает честное поведение, и в новом коде его ставят всегда.

Для срезов есть dive: validate:"required,min=1,dive" проверит сам срез и затем каждый элемент по его тегам; без dive элементы не проверяются вовсе, и это самая частая причина «валидация есть, а мусор прошёл».

Коротко

  • Своё правило — функция validator.Func плюс RegisterValidation; правило отвечает только «да/нет», регулярное выражение компилируется один раз, пустоту отдают required и omitempty.
  • Межполевая проверка: для равенства — встроенный eqfield, для сложного — RegisterStructValidation с ReportError, который сам привязывает ошибку к полю.
  • Проверка структуры идёт после полевых, но независимо: лишнее сообщение убирают устойчивостью к пустоте.
  • validator не даёт текстов: тексты — из universal-translator, встроенные — RegisterDefaultTranslations, свои — RegisterTranslation по имени тега; {0} — поле, {1} — параметр.
  • Имена полей для клиента берут из тега json через RegisterTagNameFunc, иначе в ответе будут имена полей Go.
  • Список нарушений собирается одной функцией из ValidationErrors в apperr.Invalid с полем, сообщением и именем правила; ответ 400 отдаёт единый обработчик.
  • Переводчик выбирают на запрос по Accept-Language через FindTranslator; вне запроса — запасной язык, и машинные контуры читают rule, а не текст.
  • Валидатор один на процесс, безопасен для горутин, зависимости правил — замыканием; из правил не ходят в базу: это последовательные запросы, гарантии нет, а «занято» — это 409.
  • required — это «не нулевое значение»: честный ноль требует указателя, а элементы срезов проверяются только с dive.

Что почитать дальше