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

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

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

Комментарии (0)