
Зачем нужна валидация
Валидация входных данных — критически важная часть любого приложения. Без неё ваше приложение подвержено:
-
паникам и ошибкам из-за неожиданных 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.
ссылка на оригинал статьи https://habr.com/ru/articles/927460/
Добавить комментарий