Валидация запросов в Go: чек-лист рабочих приёмов
Что проверять во входящих HTTP-запросах на Go, где validator.v10 помогает, а где вредит, и почему одной структуры с тегами обычно мало.

Запрос с телом {"age": -5, "email": ""} не должен доходить до бизнес-логики. Но в реальных сервисах он туда доходит: часть полей проверили руками, часть тегами, часть забыли, и в базе оказывается пользователь с отрицательным возрастом. Разберём чек-лист валидации входящих HTTP-запросов на Go 1.22+ — что проверять, чем проверять и где очевидное решение через go-playground/validator/v10 начинает мешать. Форматы JSON API и net/http; примеры на стандартном роутере Go 1.22.
Четыре слоя, которые путают между собой
Слово «валидация» в одном обсуждении означает четыре разные задачи. Смешивать их в одном месте — главная ошибка.
- Разбор (decoding). JSON вообще распарсился? Пришло
"age": "abc"в полеint— это ещё не бизнес-ошибка, это сломанный запрос. - Синтаксическая валидация. Поле непустое, длина в диапазоне, строка похожа на email, число неотрицательно. Не требует обращения к базе.
- Семантическая валидация. Email уникален, товар есть на складе, у пользователя хватает прав. Требует данных из хранилища — это уже бизнес-логика, а не валидация запроса.
- Санитизация. Обрезать пробелы, привести email к нижнему регистру. Меняет данные, а не отвергает их.
Первые две живут в слое HTTP-хендлера. Третья — в сервисе. Санитизацию делайте до синтаксической проверки, иначе " ADMIN@X.RU " провалит проверку уникальности, хотя такой пользователь есть.
Подход 1. Ручная проверка полей
Самый прямой способ: после декодирования пройтись по полям if-ами и собрать ошибки.
package main
import (
"errors"
"strings"
)
type CreateUserRequest struct {
Email string `json:"email"`
Age int `json:"age"`
}
// ValidationErrors — карта поле -> сообщение, удобно отдавать клиенту.
type ValidationErrors map[string]string
func (r *CreateUserRequest) Validate() ValidationErrors {
errs := ValidationErrors{}
// Санитизация до проверок.
r.Email = strings.ToLower(strings.TrimSpace(r.Email))
if r.Email == "" {
errs["email"] = "обязательное поле"
} else if !strings.Contains(r.Email, "@") {
errs["email"] = "некорректный email"
}
if r.Age < 0 || r.Age > 150 {
errs["age"] = "возраст должен быть от 0 до 150"
}
if len(errs) == 0 {
return nil
}
return errs
}
var ErrInvalid = errors.New("validation failed")
Что выигрываете: полный контроль, читаемые сообщения на русском, нулевые зависимости, легко тестировать. Чем платите: на форме из двадцати полей метод Validate раздувается, и правила расползаются по разным местам.
Подход 2. go-playground/validator через теги
Библиотека github.com/go-playground/validator/v10 навешивает правила прямо на структуру. Проверка required, email, min, max, oneof — тегом.
package main
import (
"github.com/go-playground/validator/v10"
)
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"gte=0,lte=150"`
Role string `json:"role" validate:"oneof=admin user guest"`
}
// validate создаётся один раз на приложение — он потокобезопасен
// и кеширует разбор тегов структуры.
var validate = validator.New(validator.WithRequiredStructEnabled())
func (r *CreateUserRequest) Validate() error {
return validate.Struct(r)
}
Точный список встроенных тегов и их поведение смотрите в документации пакета — набор пополняется между версиями, и полагаться на память тут не стоит.
Что выигрываете: двадцать правил умещаются в теги, меньше однотипного кода. Чем платите: ошибки приходят типом validator.ValidationErrors с англоязычными техническими сообщениями (Key: 'CreateUserRequest.Email' Error:Field validation for 'Email' failed on the 'email' tag). Отдавать это клиенту нельзя — нужен слой перевода. И теги не покрывают правила вида «поле A обязательно, только если B равно X» без кастомных функций.
Теги validator экономят строки на простых правилах и мстят на сложных. Как только появляется «зависит от другого поля», вы всё равно пишете функцию на Go — только теперь она спрятана в регистрации валидатора, а не лежит на виду.
Перевод ошибок validator в ответ клиенту
Сырой ValidationErrors нужно разложить по полям. Тип ошибки — срез validator.FieldError, у каждого элемента есть Field() и Tag().
func toClientErrors(err error) map[string]string {
var ve validator.ValidationErrors
if !errors.As(err, &ve) {
return nil
}
out := make(map[string]string, len(ve))
for _, fe := range ve {
// fe.Field() вернёт имя поля структуры, не json-тег;
// для json-имён регистрируют RegisterTagNameFunc.
switch fe.Tag() {
case "required":
out[fe.Field()] = "обязательное поле"
case "email":
out[fe.Field()] = "некорректный email"
default:
out[fe.Field()] = "недопустимое значение"
}
}
return out
}
Чтобы Field() отдавал json-имя (email), а не имя поля Go (Email), зарегистрируйте функцию через validate.RegisterTagNameFunc — точную сигнатуру возьмите из документации, она разбирает тег json.
Чем какой подход выбирать
| Критерий | Ручная проверка | validator/v10 |
|---|---|---|
| Число полей | до ~5–7 | много однотипных |
| Сообщения на русском | сразу и бесплатно | нужен слой перевода |
| Правила «поле зависит от поля» | обычный if | кастомная функция или required_if |
| Зависимости | нет | +1 пакет |
| Единый стиль на весь проект | расползается | централизован |
Практичное правило: маленький сервис с парой ручек — ручная проверка. Большой API с десятками DTO — validator плюс один общий адаптер ошибок. Смешивать в одном хендлере не надо.
Что чаще всего ломается
- Decode не проверяют на лишние поля. Клиент прислал
is_admin: true— стандартныйjson.Decoderего молча проигнорирует, но если вы вызоветеDisallowUnknownFields(), получите явную ошибку. Для форм с чувствительными флагами это стоит включать. - Нулевое значение неотличимо от «поля нет».
Age intсvalidate:"gte=0"пропустит запрос вообще без поляage: там будет0, а0 >= 0истинно. Если различие важно — используйте*intи проверяйте наnil. - Санитизацию делают после проверки. Тогда
requiredпроходит на строке из пробелов. Триммируйте до валидации. - Семантику тащат в теги. Уникальность email тегом не проверить без обращения к базе — это работа сервиса, не валидатора DTO.
- Один
validator.New()на запрос. Создавайте инстанс один раз и переиспользуйте: он кеширует разбор структур, пересоздание в хендлере убивает этот кеш.
Куда двигаться дальше
Соберите валидацию в один middleware или generic-хелпер Decode[T any], который читает тело, декодирует в T, вызывает Validate и на ошибке отдаёт 422 с картой полей. Тогда хендлеры получают уже проверенную структуру и не содержат if-ов вовсе. Отдельно посмотрите на ограничение размера тела через http.MaxBytesReader — это защита от гигантских запросов, и она к валидации полей отношения не имеет, но живёт в том же слое. Инвалидация и версионирование схем API — отдельная тема, здесь мы её не трогаем.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: go-playground/validator — репозиторий и документация, encoding/json — стандартная библиотека Go
Частые вопросы
А если у меня validator не v10, а старее?
Validate() возвращает nil, хотя поле явно невалидно — почему?
Стоит ли валидировать в middleware или в хендлере?
Как отдавать ошибки клиенту — списком или картой?
Нужно ли проверять Content-Type и размер тела?
Можно ли одной библиотекой закрыть и валидацию, и санитизацию?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.