Валидация данных в Go с go-playground/validator: полное руководство

от автора

Зачем нужна валидация

Валидация входных данных — критически важная часть любого приложения. Без неё ваше приложение подвержено:

  • паникам и ошибкам из-за неожиданных 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 символов.

Порядок проверки:

  1. Сначала проверяется количество элементов в слайсе (min=1,max=5).

  2. Потом dive указывает на то, что будет проверяться каждый элемент.

  3. Для каждого элемента проверяется его длина (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 может быть немного сложнее, но в целом всё можно достаточно легко проверить:

  • required — мапа не должна быть nil,

  • min=1 — минимум 1 элемент в мапе,

  • dive — начинаем проверку пар ключ-значение,

  • keys — начало проверки КЛЮЧЕЙ,

  • min=3 — каждый ключ минимум 3 символа,

  • endkeys — конец проверки ключей,

  • required — значение не должно быть пустым,

  • max=100 — максимум 100 символов.

ПОРЯДОК ПРОВЕРКИ:

  1. Проверка самой мапы (required, min=1).

  2. dive — входим в мапу для проверки элементов.

  3. Для каждой пары ключ-значение:
    a) Проверяем ключ (правила между keys и endkeys),
    b) Проверяем значение (правила после endkeys).

Важные моменты:

  1. keys...endkeys — специальный синтаксис ТОЛЬКО для валидации ключей мапы.

  2. Правила после endkeys применяются к значениям.

  3. Порядок проверки элементов мапы не гарантирован.

Ниже представлен код тестов, где проверяются типичные ошибки для 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *