Встроенных тегов 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.
Что почитать дальше
- Валидация в Go — встроенные теги
validator, разбор тела и первая проверка. - Где валидировать: обработчик, сценарий или домен — выбор правильного слоя.
- Единый обработчик ошибок в Go — как
apperr.Invalidстановится ответом400. - Ошибки REST API в Go: RFC 9457 — формат
violationsв теле ответа.