
Зачем нужна валидация
Валидация входных данных — критически важная часть любого приложения. Без неё ваше приложение подвержено:
паникам и ошибкам из-за неожиданных nil или невалидных значений,
некорректной работе бизнес-логики при обработке невалидных данных,
уязвимостям безопасности (SQL-инъекции, XSS и др.),
сложностям в отладке из-за непредсказуемого поведения.
Проблемы ручной валидации
Рассмотрим типичный подход к валидации без специализированных библиотек:
type User struct {
Name string
Email string
Age int
Password string
}
func (u *User) Validate() error {
// Проверка имени
if u.Name == "" {
return errors.New("name is required")
}
if len(u.Name) < 2 || len(u.Name) > 50 {
return errors.New("name must be between 2 and 50 characters")
}
// Проверка email
if u.Email == "" {
return errors.New("email is required")
}
if !strings.Contains(u.Email, "@") {
return errors.New("invalid email format")
}
// Проверка возраста
if u.Age < 18 {
return errors.New("age must be at least 18")
}
// Проверка пароля
if len(u.Password) < 8 {
return errors.New("password must be at least 8 characters")
}
return nil
}
Основные проблемы такого подхода:
1. Дублирование кода.
// В каждой структуре повторяются похожие проверки
type Product struct { Name string }
func (p *Product) Validate() error {
if p.Name == "" { return errors.New("name is required") }
// ... и так для каждой структуры
}
2. Сложность поддержки. При изменении требований нужно обновлять валидацию во множестве мест.
3. Легко пропустить важные проверки.
type Registration struct {
Password string
PasswordConfirm string
}
// Забыли проверить, что Password == PasswordConfirm
4. Невозможность переиспользования. Одинаковые правила валидации приходится копировать между HTTP-хендлерами, gRPC-сервисами, CLI-командами и т. д.
Введение в go-playground/validator
go-playground/validator — это мощный инструмент для декларативной валидации в Go, который позволяет описывать её правила прямо в тегах структур.
Установка
go get github.com/go-playground/validator/v10
Ключевые преимущества:
Декларативный подход — правила описываются в тегах.
Переиспользование — один валидатор для всего приложения.
Расширяемость — легко добавлять кастомные правила.
Производительность — использует рефлексию только при инициализации.
Богатый набор встроенных правил — более 75 готовых валидаторов.
Основы работы с пакетом
Создание валидатора
Рекомендуется создавать один валидатор, например, при старте приложения, т. к. создание нового экземпляра — это дорогая операция. Теперь давайте рассмотрим, как будет выглядеть валидация кода выше, при помощи данной библиотеки.
import "github.com/go-playground/validator/v10"
// Создаём глобальный экземпляр валидатора
var validate *validator.Validate
func init() {
validate = validator.New()
}
type User struct {
Name string `validate:"required,min=2,max=50"`
Email string `validate:"required,email"`
Age int `validate:"required,min=18"`
Password string `validate:"required,min=8"`
}
func main() {
user := User{
Name: "John Doe",
Email: "john@example.com",
Age: 25,
Password: "securepass",
}
err := validate.Struct(user)
if err != nil {
// Обработка ошибок валидации
fmt.Println("Validation failed:", err)
}
}
Код стал гораздо компактнее, а также стал читаться гораздо проще
Синтаксис тегов валидации
Сейчас мы разберемся с синтаксисом пакета, ниже будут представлены операторы, которые используются для гибкой настройки правил.
Основные операторы:
Запятая (,) — логическое И (все правила должны выполняться).
Pipe (|) — логическое ИЛИ (хотя бы одно правило должно выполниться).
Знак равенства (=) — параметр для правила.
Пробелы — игнорируются.
Само собой, разрешается задавать несколько правил и комбинировать разные операторы в отдельном поле каждого тега:
`validate:"rule1,rule2=param,rule3=param1|param2"`
Примеры синтаксиса:
Теперь рассмотрим несколько полезных и часто встречающихся тегов на практике.
type Example struct {
Name string `validate:"required,min=2,max=50"`
Contact string `validate:"required_without=Phone,omitempty,email|e164"`
Phone string `validate:"required_without=Contact,omitempty,e164"`
}
В структуре выше представлены типичные поля для проверки, давайте кратко разберемся, как проходит процесс валидации:
Поле Name validate:"required,min=2,max=50"
:
required
— поле обязательно для заполнения, не может быть пустым,min=2
— минимальная длина строки 2 символа,max=50
— максимальная длина строки 50 символов,логика валидации — поле всегда должно быть заполнено и содержать от 2 до 50 символов.
Поле validate:"required_without=Phone,omitempty,email|e164"
:
required_without=Phone
— обязательно, если полеPhone
пустое;omitempty
— пропустить дальнейшую валидацию для пустых значений, т. к. если этого не сделать, будут возникать ошибки валидации;email
— стандартный формат email;e164
— международный формат телефона (+1234567890);email|e164
— формат email или формат телефона (достаточно пройти любую проверку);логика — если
Phone
пустой, тоContact
обязателен и должен быть в форматеemail
илиe164
.
В поле Contact
(такая же логика, как и в Phone
). Обратите внимание, что при помощи тегов валидации мы декларативно можем задать, что у нас допускается заполнить только одно из двух полей Phone
или Contact
.
ВАЖНО, если используете required_without
и дополнительную валидацию, то нужно использовать после поля omitempty
, чтобы все правильно работало, подробнее можно почитать тут про использование omitempty
с этими тегами (описание этой особенности на гитхабе).
Также приведём пример базовых тестов для проверки валидации, в тестах даны краткие комментарии по ожидаемым ошибкам:
func TestExampleValidation(t *testing.T) {
validate := validator.New()
t.Run("Valid values", func(t *testing.T) {
example := Example{
Name: "John Doe",
Phone: "john@example.com",
}
assert.NoError(t, validate.Struct(example))
})
// тесты для проверки ошибочной валидации
t.Run("Invalid name", func(t *testing.T) {
example := Example{Contact: "test@example.com"}
// тут должна быть ошибка, т.к. поле name пустое
assert.Error(t, validate.Struct(example))
example.Name = "A"
err := validate.Struct(example)
// а тут имя слишком короткое
assert.Error(t, err)
})
t.Run("Invalid contact", func(t *testing.T) {
example := Example{Name: "John"}
assert.Error(t, validate.Struct(example))
// плохой email
example.Contact = "invalid-email"
assert.Error(t, validate.Struct(example))
})
}
Основные теги валидации
Детально рассматривать все теги в рамках данной статьи мы не будем, но ниже находится код, в котором они кратко представлены. Если хотите узнать о том, как конкретно реализованы проверки для валидации, можете изучить тут.
Строковые валидации
В данном разделе указаны теги, которые позволяют упростить работу со строками, в том числе они могут использовать для проверки url, uuid и т. д.
type StringValidations struct {
Required string `validate:"required"` // Не пустая строка
AlphaOnly string `validate:"alpha"` // Только буквы
AlphaNum string `validate:"alphanum"` // Буквы и цифры
Numeric string `validate:"numeric"` // Числовая строка
// Проверки длины
MinLength string `validate:"min=5"` // Минимум 5 символов
MaxLength string `validate:"max=10"` // Максимум 10 символов
Length string `validate:"len=8"` // Ровно 8 символов
// Форматы
Email string `validate:"email"` // Email адрес
URL string `validate:"url"` // URL адрес
UUID string `validate:"uuid"` // UUID любой версии
UUID4 string `validate:"uuid4"` // UUID версии 4
// Содержимое
Contains string `validate:"contains=test"` // Содержит подстроку
Excludes string `validate:"excludes=test"` // Не содержит подстроку
StartsWith string `validate:"startswith=Hello"` // Начинается с
EndsWith string `validate:"endswith=World"` // Заканчивается на
OneOf string `validate:"oneof=red green blue"` // Одно из значений
}
Числовые валидации
Пакет позволяет также гибко работать с валидацией чисел, доступны все операторы вроде >,<,== и т. д. (код правил)
type NumericValidations struct {
// Сравнения
GreaterThan int `validate:"gt=0"` // > 0
GreaterOrEqual int `validate:"gte=18"` // >= 18
LessThan float64 `validate:"lt=100.5"` // < 100.5
LessOrEqual float64 `validate:"lte=99.99"` // <= 99.99
NotEqual int `validate:"ne=0"` // != 0
Equal int `validate:"eq=0"` // == 0
// Диапазоны
Between int `validate:"min=1,max=10"` // От 1 до 10
}
Сравнения полей
Пакет предоставляет возможности, чтобы сравнивать значения с другими полями, что на самом деле удобно для проверки дат, паролей и т. д.
type FieldComparisons struct {
Password string `validate:"required,min=8"`
PasswordConfirm string `validate:"required,eqfield=Password"` // Равно полю Password
StartDate string `validate:"required"`
EndDate string `validate:"required,gtfield=StartDate"` // Больше StartDate
Price float64 `validate:"required"`
DiscountPrice float64 `validate:"ltfield=Price"` // Меньше Price
}
Валидация коллекций
А данный раздел мы рассмотрим подробнее, т. к. ряд моментов при работе с коллекциями может быть не совсем очевидным. Кроме того, в данном разделе будут показаны более сложные кейсы, которые позволяют проводить более детальные проверки.
type TagsExample struct {
// min=1,max=5 - ограничение на кол-во элементов
// dive - указываем что будем проверять каждый элемент в слайсе
// min=2,max=20 - ограничения длины строки для элементов в слайсе
Tags []string `validate:"min=1,max=5,dive,min=2,max=20"`
}
type MapExample struct {
// required - мапа обязательна (не nil)
// min=1 - минимум 1 элемент в мапе
// dive - погружаемся для проверки ключей и значений
// keys,min=3,endkeys - проверка ключей: минимум 3 символа
// required,max=100 - проверка значений: обязательны, максимум 100 символов
Settings map[string]string `validate:"required,min=1,dive,keys,min=3,endkeys,required,max=100"`
}
Объяснение для TagsExample validate:"min=1,max=5,dive,min=2,max=20"
min=1,max=5
— применяются к слайсу, который должен содержать от 1 до 5 элементов,dive
— указывает на то, что следующие правила будут применяются к КАЖДОМУ элементу слайса,min=2,max=20
— применяется к КАЖДОМУ элементу послеdive
, каждая строка должна быть от 2 до 20 символов.
Порядок проверки:
Сначала проверяется количество элементов в слайсе (min=1,max=5).
Потом dive указывает на то, что будет проверяться каждый элемент.
Для каждого элемента проверяется его длина (min=2,max=20).
Теперь разберём то, как мы можем сделать наши тесты более гибкими. В прошлом примере мы только устанавливали факт того, что у нас возникла ошибка при валидации структуры, в примере ниже мы узнаем конкретное поле и тег, с которыми связана ошибка.
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "min", validationErrors[0].Tag())
assert.Equal(t, "Tags", validationErrors[0].Field())
Для таких проверок важно привести ошибку к типу validator.ValidationErrors, чтобы иметь возможность проверять теги, поля и т. д.
Код теста:
func TestTagsValidation(t *testing.T) {
validate := validator.New()
t.Run("Empty slice", func(t *testing.T) {
example := TagsExample{
Tags: []string{},
}
err := validate.Struct(example)
assert.Error(t, err)
// Проверяем, что ошибка именно в min для слайса
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "min", validationErrors[0].Tag())
assert.Equal(t, "Tags", validationErrors[0].Field())
})
t.Run("Too many tags", func(t *testing.T) {
example := TagsExample{
Tags: []string{"tag1", "tag2", "tag3", "tag4", "tag5", "tag6"},
}
err := validate.Struct(example)
assert.Error(t, err)
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "max", validationErrors[0].Tag())
assert.Equal(t, "Tags", validationErrors[0].Field())
})
t.Run("Tag too short", func(t *testing.T) {
example := TagsExample{
Tags: []string{"a", "valid"},
}
err := validate.Struct(example)
assert.Error(t, err)
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "min", validationErrors[0].Tag())
assert.Contains(t, validationErrors[0].Namespace(), "Tags[0]")
})
t.Run("Tag too long", func(t *testing.T) {
tooLongTag := "tooooooooooooooooooooooolooooooooooooooooooooooong"
example := TagsExample{
Tags: []string{"valid", tooLongTag},
}
err := validate.Struct(example)
assert.Error(t, err)
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "max", validationErrors[0].Tag())
assert.Contains(t, validationErrors[0].Namespace(), "Tags[1]")
})
t.Run("Multiple validation errors", func(t *testing.T) {
example := TagsExample{
// два коротких тега
Tags: []string{"x", "valid", "y"},
}
err := validate.Struct(example)
assert.Error(t, err)
validationErrors := err.(validator.ValidationErrors)
//должно быть 2 ошибки
assert.Len(t, validationErrors, 2)
for _, fieldError := range validationErrors {
assert.Equal(t, "min", fieldError.Tag())
assert.Contains(t, fieldError.Namespace(), "Tags[")
}
})
}
Объяснение логики валидации map validate:"required,min=1,dive,keys, min=3,endkeys,required,max=100"
. Логика для map может быть немного сложнее, но в целом всё можно достаточно легко проверить:
r
equired
— мапа не должна быть nil,min=1
— минимум 1 элемент в мапе,dive
— начинаем проверку пар ключ-значение,keys
— начало проверки КЛЮЧЕЙ,min=3
— каждый ключ минимум 3 символа,endkeys
— конец проверки ключей,required
— значение не должно быть пустым,max=100
— максимум 100 символов.
ПОРЯДОК ПРОВЕРКИ:
Проверка самой мапы (
required, min=1
).dive
— входим в мапу для проверки элементов.Для каждой пары ключ-значение:
a) Проверяем ключ (правила междуkeys
иendkeys
),
b) Проверяем значение (правила послеendkeys
).
Важные моменты:
keys...endkeys
— специальный синтаксис ТОЛЬКО для валидации ключей мапы.Правила после
endkeys
применяются к значениям.Порядок проверки элементов мапы не гарантирован.
Ниже представлен код тестов, где проверяются типичные ошибки для map
:
func TestMapValidation(t *testing.T) {
validate := validator.New()
t.Run("Empty map", func(t *testing.T) {
example := MapExample{
Settings: map[string]string{},
}
err := validate.Struct(example)
assert.Error(t, err)
// Проверяем, что ошибка именно в min для мапы
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "min", validationErrors[0].Tag())
assert.Equal(t, "Settings", validationErrors[0].Field())
})
t.Run("Nil map", func(t *testing.T) {
example := MapExample{
Settings: nil, // nil мапа
}
err := validate.Struct(example)
assert.Error(t, err)
// Проверяем, что ошибка в required
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "required", validationErrors[0].Tag())
assert.Equal(t, "Settings", validationErrors[0].Field())
})
t.Run("Key too short", func(t *testing.T) {
example := MapExample{
Settings: map[string]string{
// ключ всего 2 символа, нужно минимум 3
"ab": "valid value",
},
}
err := validate.Struct(example)
assert.Error(t, err)
// Проверяем ошибку валидации ключа
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "min", validationErrors[0].Tag())
// Namespace будет содержать Settings[ab] - указывает на конкретный ключ
assert.Contains(t, validationErrors[0].Namespace(), "Settings[ab]")
})
t.Run("Empty value", func(t *testing.T) {
example := MapExample{
Settings: map[string]string{
"key1": "", // пустое значение, но required требует непустое
},
}
err := validate.Struct(example)
assert.Error(t, err)
// Проверяем ошибку валидации значения
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "required", validationErrors[0].Tag())
assert.Contains(t, validationErrors[0].Namespace(), "Settings[key1]")
})
t.Run("Multiple errors", func(t *testing.T) {
example := MapExample{
Settings: map[string]string{
"k1": "valid", // ключ слишком короткий
"validKey": "", // пустое значение
"ok": "another value", // ключ слишком короткий
},
}
err := validate.Struct(example)
assert.Error(t, err)
// Должно быть 3 ошибки
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, len(validationErrors), 3)
// Собираем типы ошибок
errorTags := make(map[string]int)
for _, fieldError := range validationErrors {
errorTags[fieldError.Tag()]++
}
// Проверяем, что есть ошибки обоих типов
assert.GreaterOrEqual(t, errorTags["min"], 2) // минимум 2 ошибки min (короткие ключи)
assert.GreaterOrEqual(t, errorTags["required"], 1) // минимум 1 ошибка required (пустое значение)
})
t.Run("Valid map", func(t *testing.T) {
example := MapExample{
Settings: map[string]string{
"timeout": "30s",
"retryCount": "3",
"debug": "true",
},
}
err := validate.Struct(example)
assert.NoError(t, err) // Не должно быть ошибок
})
//Граничные случаи
t.Run("Edge cases", func(t *testing.T) {
// Минимально валидная мапа
example := MapExample{
Settings: map[string]string{
"key": "v", // ключ ровно 3 символа, значение минимальное
},
}
err := validate.Struct(example)
assert.NoError(t, err)
// Максимально длинное валидное значение (ровно 100 символов)
maxValue := "1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"
example2 := MapExample{
Settings: map[string]string{
"key": maxValue,
},
}
err2 := validate.Struct(example2)
assert.NoError(t, err2)
})
}
Кастомные валидаторы
Теперь перейдем к кастомным валидаторам, которые могут понадобиться, если стандартных возможностей библиотеки будет недостаточно.
Для создания валидатора поля нужна функция с сигнатурой func(fl validator.FieldLevel) bool
. Параметр validator.FieldLevel
предоставляет доступ к проверяемому полю и контексту валидации.
func validateStrongPassword(fl validator.FieldLevel) bool {
password := fl.Field().String()
if len(password) < 8 {
return false
}
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
hasDigit := regexp.MustCompile(`[0-9]`).MatchString(password)
return hasUpper && hasDigit
}
В отличие от стандартных валидаторов, свои мы должны отдельно зарегистрировать:
validate := validator.New()
validate.RegisterValidation("strong_password", validateStrongPassword)
Также можно создать правило, которое будет применяться ко всей структуре. Используется валидация на уровне структуры. Функция должна иметь сигнатуру func(sl validator.StructLevel):
func validateShoppingCart(sl validator.StructLevel) {
cart := sl.Current().Interface().(ShoppingCart)
// Если есть товары, должен быть указан адрес доставки
if len(cart.Items) > 0 && cart.DeliveryAddress == "" {
sl.ReportError(cart.DeliveryAddress, "DeliveryAddress", "DeliveryAddress", "address_required", "")
}
// Для заказа больше 5000 требуется подтверждение по телефону
if cart.TotalAmount > 5000 && cart.PhoneConfirmed == false {
sl.ReportError(cart.PhoneConfirmed, "PhoneConfirmed", "PhoneConfirmed", "phone_confirm_required", "")
}
}
Обратите внимание на функцию ReportError
, которая позволяет сообщить об ошибке валидации с произвольным тегом. В отличие от стандартных валидаторов, где теги определены заранее (required, min, max
), здесь мы можем использовать любую строку в качестве тега ошибки:
sl.ReportError(
cart.PhoneConfirmed, // значение поля
"PhoneConfirmed", // имя поля для namespace
"PhoneConfirmed", // имя поля в структуре
"phone_confirm_required", // произвольный тег ошибки
"" // параметр (опционально)
)
Тег "phone_confirm_required"
— это не валидационный тег из структуры, а произвольный идентификатор, который мы придумываем для описания конкретной ошибки. Регистрация валидатора структуры выглядит следующим образом:
validate := validator.New()
validate.RegisterStructValidation(validateShoppingCart, ShoppingCart{})
Тестирование кастомных валидаторов в целом ничем не отличается от обычных:
t.Run("Too short", func(t *testing.T) {
reg := Registration{
Email: "user@mail.ru",
Password: "Pass1",
}
err := validate.Struct(reg)
assert.Error(t, err)
})
t.Run("Items without address", func(t *testing.T) {
cart := ShoppingCart{
Items: []string{"Товар1", "Товар2"},
DeliveryAddress: "",
TotalAmount: 3000,
PhoneConfirmed: false,
}
err := validate.Struct(cart)
assert.Error(t, err)
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "DeliveryAddress", validationErrors[0].Field())
})
t.Run("Large amount without confirmation", func(t *testing.T) {
cart := ShoppingCart{
Items: []string{"Дорогой товар"},
DeliveryAddress: "ул. Ленина, д. 1",
TotalAmount: 6000,
PhoneConfirmed: false,
}
err := validate.Struct(cart)
assert.Error(t, err)
validationErrors := err.(validator.ValidationErrors)
assert.Equal(t, "PhoneConfirmed", validationErrors[0].Field())
})
Как и в случае с валидацией коллекций, преобразование ошибки к типу validator.ValidationErrors
позволяет получить детальную информацию о проблеме: какое поле не прошло проверку и какое правило было нарушено.
Размещение валидации отдельно от структуры
Иногда удобно хранить правила валидации отдельно от структур, например, в конфигурационных файлах или базе данных. Это позволяет изменять правила без перекомпиляции кода:
// Определение правил валидации отдельно от структуры
type User struct {
Name string
Email string
Age int
}
// создание отдельной конфигурации
rules := map[string]string{
"Name": "required,min=2,max=50",
"Email": "required,email",
"Age": "required,min=18,max=120",
}
// Регистрация правил
validate.RegisterStructValidationMapRules(rules, User{})
Заключение
Использование go-playground/validator
позволяет больше сосредоточиться на бизнес-логике и избежать однотипных проверок, что делает код более надёжным и поддерживаемым.
Автор перевода alex_name_m
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на заказ любого VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.