7. Валидация входных данных

В сетевом программировании действует непреложный закон: никогда не доверяйте входящим данным. Сеть — это враждебная среда, где каждый байт, пришедший из TCP-сокета, может быть поврежден, сформирован злоумышленником или отправлен клиентом со старой версией мобильного приложения.

В хорошей программе граница системы — это крепостная стена. Все проверки должны происходить непосредственно на входе, на уровне транспортных DTO (Data Transfer Objects), до того как данные проникнут во внутренние слои доменной логики и базы данных. В этой лекции мы разберем философию валидации в Go, сопоставим компиляторный и рефлексивный подходы, и научимся отдавать клиентам кристально ясные сообщения об ошибках.

flowchart TD
    RawBody["Входящий сырой HTTP Request Body"] --> SizeGuard["http.MaxBytesReader\n(Ограничение размера тела, напр. 1MB)"]
    SizeGuard --> Unmarshal["json.NewDecoder.DisallowUnknownFields()\n(Синтаксический парсинг в DTO)"]

    Unmarshal -->|"Синтаксический сбой (Syntax error, type mismatch)"| Err400["400 Bad Request\n(Невалидный JSON)"]

    Unmarshal --> ValidChoice{"Выбор подхода к валидации"}

    ValidChoice -->|"Идиоматичный (Метод Validate)"| Manual["Ручная проверка полей (DTO.Validate)\n(0 аллокаций, CPU inlining, O(1))"]
    ValidChoice -->|"Декларативный (Теги структуры)"| Tags["validator/v10 (Struct Tags)\n(Рефлексия, sync.Map кэш тегов, dive)"]

    Manual --> CheckErrs{"Есть ошибки валидации?"}
    Tags --> CheckErrs

    CheckErrs -->|"Да (Ошибки полей)"| FormatErr["Сборка ErrorResponse { field, message }\n422 Unprocessable Entity / 400 Bad Request"]
    CheckErrs -->|"Нет (Данные валидны)"| Domain["Передача в Service / Domain Layer\n(Гарантия целостности данных)"]

    classDef proc fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef err fill:#450a0a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
    classDef succ fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    class RawBody,SizeGuard,Unmarshal,ValidChoice,Manual,Tags proc;
    class Err400,FormatErr err;
    class Domain succ;
  

Философия Fail-Fast и защита периметра

Валидация входных данных — это первый рубеж обороны любого HTTP-сервиса. Принцип Fail-Fast требует от нас как можно раньше отклонять некорректные запросы, чтобы не тратить CPU, память и соединения БД на обработку мусора. В Go нет встроенной «серебряной пули» для валидации, но есть четкие идиомы, которые позволяют выстроить надежный и производительный конвейер проверки данных.

Если запрос содержит отрицательный возраст или пустой email, бессмысленно открывать транзакцию к PostgreSQL или отправлять запрос в Redis. Отклонение на периметре сберегает ресурсы процессора для обслуживания реальных пользователей.


Подходы к валидации в Go

В экосистеме Go сформировались два ключевых подхода: явная ручная валидация через методы структур и декларативная проверка на основе тегов структуры.

Ручная валидация (Idiomatic Go)

Самый производительный и предсказуемый способ — написание валидации вручную. Он требует больше кода, но не использует рефлексию, дает полный контроль над сообщениями об ошибках и легко покрывается тестами.

Go
package domain

import (
    "errors"
    "fmt"
    "regexp"
)

var ErrInvalidInput = errors.New("invalid input")

type CreateUserRequest struct {
    Email    string `json:"email"`
    Password string `json:"password"`
    Age      int    `json:"age"`
}

func (r *CreateUserRequest) Validate() error {
    var errs []error

    if r.Email == "" {
        errs = append(errs, errors.New("email is required"))
    } else if !isValidEmail(r.Email) {
        errs = append(errs, errors.New("invalid email format"))
    }

    if len(r.Password) < 8 {
        errs = append(errs, errors.New("password must be at least 8 characters"))
    }

    if r.Age < 18 || r.Age > 120 {
        errs = append(errs, errors.New("age must be between 18 and 120"))
    }

    if len(errs) > 0 {
        return fmt.Errorf("%w: %v", ErrInvalidInput, errs)
    }
    return nil
}

var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)

func isValidEmail(email string) bool {
    return emailRegex.MatchString(email)
}

Тег-базированная валидация

Для сложных проектов часто используют github.com/go-playground/validator/v10. Он позволяет описывать правила прямо в тегах структуры:

Go
import "github.com/go-playground/validator/v10"

type CreateUserRequest struct {
    Email    string `json:"email" validate:"required,email"`
    Password string `json:"password" validate:"required,min=8,max=64"`
    Age      int    `json:"age" validate:"required,gte=18,lte=120"`
}

var validate = validator.New()

func (r *CreateUserRequest) Validate() error {
    if err := validate.Struct(r); err != nil {
        // Преобразование ошибок валидации в читаемый вид
        var ve validator.ValidationErrors
        if errors.As(err, &ve) {
            return formatValidationErrors(ve)
        }
        return err
    }
    return nil
}

func formatValidationErrors(ve validator.ValidationErrors) error {
    msgs := make([]string, 0, len(ve))
    for _, e := range ve {
        msgs = append(msgs, fmt.Sprintf("%s: %s failed on %s", e.Field(), e.Value(), e.Tag()))
    }
    return fmt.Errorf("validation failed: %v", msgs)
}

Агрегация ошибок и маппинг в HTTP

Фронтенд обычно ожидает не просто одну ошибку, а полный список проблемных полей. Для этого агрегируем ошибки в структуру ответа:

Go
type ValidationError struct {
    Field   string `json:"field"`
    Message string `json:"message"`
}

type ErrorResponse struct {
    Error   string            `json:"error"`
    Details []ValidationError `json:"details,omitempty"`
}

func writeValidationErrors(w http.ResponseWriter, err error) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusBadRequest)

    var details []ValidationError
    var ve validator.ValidationErrors

    if errors.As(err, &ve) {
        details = make([]ValidationError, 0, len(ve))
        for _, e := range ve {
            details = append(details, ValidationError{
                Field:   e.Field(),
                Message: translateTag(e.Tag()),
            })
        }
    } else {
        details = []ValidationError{{Field: "body", Message: err.Error()}}
    }

    json.NewEncoder(w).Encode(ErrorResponse{
        Error:   "validation_failed",
        Details: details,
    })
}

func translateTag(tag string) string {
    tags := map[string]string{
        "required": "поле обязательно",
        "email":    "неверный формат email",
        "min":      "значение меньше допустимого",
        "gte":      "значение меньше минимального",
    }
    if msg, ok := tags[tag]; ok {
        return msg
    }
    return "неверное значение"
}

Интеграция в HTTP-обработчик

Правильный порядок действий в обработчике: декодирование -> валидация -> бизнес-логика.

Go
func createUserHandler(w http.ResponseWriter, r *http.Request) {
    // 1. Декодирование
    var req CreateUserRequest
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields() // Защита от лишних полей
    if err := decoder.Decode(&req); err != nil {
        http.Error(w, "invalid json payload", http.StatusBadRequest)
        return
    }

    // 2. Валидация
    if err := req.Validate(); err != nil {
        writeValidationErrors(w, err)
        return
    }

    // 3. Бизнес-логика
    user, err := userService.Create(r.Context(), &req)
    if err != nil {
        http.Error(w, "failed to create user", http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(user)
}

Security и DOS-устойчивость

Валидация — это не только проверка бизнес-правил, но и защита от атак:

  1. Limit Payload Size: Всегда ограничивайте r.Body через http.MaxBytesReader. JSON-декодер без лимита может выделить гигабайты памяти.
  2. Regex ReDoS: Избегайте сложных регулярных выражений с вложенными квантификаторами ((a+)+). Они работают за экспоненциальное время и могут вызвать DOS.
  3. Типы vs Строки: Не храните числа, даты или UUID в строковых полях без приведения. Ошибка парсинга должна происходить на этапе декодирования, а не валидации.
Go
func maxBodySize(next http.Handler, limit int64) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        r.Body = http.MaxBytesReader(w, r.Body, limit)
        next.ServeHTTP(w, r)
    })
}

// Использование
mux.Handle("/api/users", maxBodySize(createUserHandler, 1<<20)) // 1MB

Итог

  1. Валидация должна быть детерминированной и быстрой.
  2. Ручная валидация предпочтительна для hot-path и критичных к задержкам сервисов.
  3. Библиотеки на основе рефлексии удобны, но требуют понимания их стоимости и правильной настройки кэширования.
  4. Всегда агрегируйте ошибки для удобства клиентов и логируйте их для мониторинга.
  5. Комбинируйте валидацию с ограничениями на размер тела запроса и безопасным парсингом.

Следующая статья: 8. JSON API best practices