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)
Самый производительный и предсказуемый способ — написание валидации вручную. Он требует больше кода, но не использует рефлексию, дает полный контроль над сообщениями об ошибках и легко покрывается тестами.
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. Он позволяет описывать правила прямо в тегах структуры:
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
Фронтенд обычно ожидает не просто одну ошибку, а полный список проблемных полей. Для этого агрегируем ошибки в структуру ответа:
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-обработчик
Правильный порядок действий в обработчике: декодирование -> валидация -> бизнес-логика.
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-устойчивость
Валидация — это не только проверка бизнес-правил, но и защита от атак:
- Limit Payload Size: Всегда ограничивайте
r.Bodyчерезhttp.MaxBytesReader. JSON-декодер без лимита может выделить гигабайты памяти. - Regex ReDoS: Избегайте сложных регулярных выражений с вложенными квантификаторами (
(a+)+). Они работают за экспоненциальное время и могут вызвать DOS. - Типы vs Строки: Не храните числа, даты или UUID в строковых полях без приведения. Ошибка парсинга должна происходить на этапе декодирования, а не валидации.
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
Итог
- Валидация должна быть детерминированной и быстрой.
- Ручная валидация предпочтительна для hot-path и критичных к задержкам сервисов.
- Библиотеки на основе рефлексии удобны, но требуют понимания их стоимости и правильной настройки кэширования.
- Всегда агрегируйте ошибки для удобства клиентов и логируйте их для мониторинга.
- Комбинируйте валидацию с ограничениями на размер тела запроса и безопасным парсингом.
Следующая статья: 8. JSON API best practices