Backend-разработка

Валидация и трансформация данных в Go: построение надёжного слоя ввода для REST API без фреймворков

Ruslan Ismailov Опубликовано 14 мин чтения
В

Введение: почему валидация — это не просто проверка полей

Валидация входящих данных — одна из тех задач, которую разработчики недооценивают до первого серьёзного инцидента. Кажется, что это просто: проверить, что email не пустой и содержит @. Но на практике плохо спроектированный слой валидации разрушает архитектуру: бизнес-логика смешивается с парсингом HTTP, доменные объекты принимают невалидные состояния, а ошибки клиенту возвращаются в разных форматах в зависимости от того, какой разработчик писал конкретный хендлер.

В экосистеме Go принято строить REST API без тяжёлых фреймворков — и это правильно. Стандартная библиотека net/http даёт всё необходимое. Но именно в этом контексте архитектурные решения по валидации приходится принимать самостоятельно. В этой статье мы разберём, как построить надёжный, тестируемый и поддерживаемый слой валидации и трансформации данных для REST API на Go.

Обзор подходов к валидации в Go

Ручная валидация

Самый простой подход — писать проверки вручную в теле хендлера или метода. Плюсы: полный контроль, нет зависимостей, легко читать. Минусы: дублирование кода, сложно собрать все ошибки за один проход, правила рассыпаются по кодовой базе.

Теги структур (struct tags)

Подход, популярный в мире Go: валидационные правила записываются в теги полей структуры. Удобно для простых случаев, но теги быстро становятся нечитаемыми, не поддерживают сложную условную логику и привязывают правила валидации к структуре данных, нарушая separation of concerns.

Библиотека go-playground/validator

Наиболее популярное решение в экосистеме. Поддерживает теги, кастомные валидаторы, локализацию. Плюсы: богатый набор встроенных правил, активное сообщество. Минусы: отражение (reflection) под капотом влияет на производительность, API бывает неочевидным, а кастомные правила через RegisterValidation сложно тестировать изолированно. Для highload-сервисов стоит взвешивать этот компромисс.

В этой статье мы реализуем собственный лёгкий слой валидации, при необходимости показывая, где его можно заменить или дополнить go-playground/validator.

Проектирование слоя валидации: где живут правила

Ключевой архитектурный вопрос: где должна жить валидация? Ответ зависит от типа проверки:

  • Синтаксическая валидация (формат email, длина строки, обязательные поля) — уровень хендлера или middleware. Это «входные ворота» — сюда не должны попадать структурно некорректные данные.
  • Семантическая валидация (уникальность email в базе, бизнес-ограничения) — уровень usecase/сервиса. Здесь есть доступ к репозиториям и бизнес-контексту.
  • Инварианты доменной модели (например, сумма заказа не может быть отрицательной) — уровень домена, в конструкторах или фабричных методах.

Смешивание этих уровней — главная причина, по которой валидация ломает архитектуру. Хендлер не должен знать о бизнес-правилах, а доменный объект не должен парсить HTTP-запросы.

Реализация кастомного валидатора

Определим интерфейс и базовые примитивы. Идея: валидатор собирает все ошибки за один проход и возвращает их списком, а не останавливается на первой.

// validation/validator.go
package validation

import (
	"fmt"
	"net/mail"
	"regexp"
	"strings"
)

// FieldError описывает ошибку конкретного поля.
type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
	Code    string `json:"code"`
}

func (e FieldError) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

// ValidationErrors — список ошибок валидации.
type ValidationErrors []FieldError

func (ve ValidationErrors) Error() string {
	msgs := make([]string, len(ve))
	for i, e := range ve {
		msgs[i] = e.Error()
	}
	return strings.Join(msgs, "; ")
}

// Rule — функция-правило: принимает значение поля, возвращает ошибку или nil.
type Rule func(field, value string) *FieldError

// FieldValidator накапливает правила для одного поля.
type FieldValidator struct {
	field string
	value string
	rules []Rule
}

// Validator — корневой объект, содержит наборы правил для всех полей.
type Validator struct {
	fields []*FieldValidator
}

// Field начинает цепочку правил для поля.
func (v *Validator) Field(field, value string) *FieldValidator {
	fv := &FieldValidator{field: field, value: value}
	v.fields = append(v.fields, fv)
	return fv
}

// Required проверяет, что поле не пустое.
func (fv *FieldValidator) Required() *FieldValidator {
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		if strings.TrimSpace(value) == "" {
			return &FieldError{Field: field, Message: "обязательное поле", Code: "required"}
		}
		return nil
	})
	return fv
}

// MinLen проверяет минимальную длину строки.
func (fv *FieldValidator) MinLen(min int) *FieldValidator {
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		if len([]rune(value)) < min {
			return &FieldError{
				Field:   field,
				Message: fmt.Sprintf("минимальная длина: %d символов", min),
				Code:    "min_length",
			}
		}
		return nil
	})
	return fv
}

// MaxLen проверяет максимальную длину строки.
func (fv *FieldValidator) MaxLen(max int) *FieldValidator {
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		if len([]rune(value)) > max {
			return &FieldError{
				Field:   field,
				Message: fmt.Sprintf("максимальная длина: %d символов", max),
				Code:    "max_length",
			}
		}
		return nil
	})
	return fv
}

// Email проверяет формат email-адреса.
func (fv *FieldValidator) Email() *FieldValidator {
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		_, err := mail.ParseAddress(value)
		if err != nil {
			return &FieldError{Field: field, Message: "некорректный email", Code: "invalid_email"}
		}
		return nil
	})
	return fv
}

// Matches проверяет соответствие регулярному выражению.
func (fv *FieldValidator) Matches(pattern, code, message string) *FieldValidator {
	re := regexp.MustCompile(pattern)
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		if !re.MatchString(value) {
			return &FieldError{Field: field, Message: message, Code: code}
		}
		return nil
	})
	return fv
}

// Validate запускает все правила и возвращает список ошибок.
func (v *Validator) Validate() ValidationErrors {
	var errs ValidationErrors
	for _, fv := range v.fields {
		for _, rule := range fv.rules {
			if err := rule(fv.field, fv.value); err != nil {
				errs = append(errs, *err)
				// Продолжаем — собираем все ошибки поля
			}
		}
	}
	if len(errs) == 0 {
		return nil
	}
	return errs
}

Такой дизайн даёт fluent API, легко читается и тестируется. Каждое правило — чистая функция без побочных эффектов.

Трансформация данных: DTO-паттерн и маппинг в доменные объекты

DTO (Data Transfer Object) — структура, которая описывает форму входящего запроса. Она не является доменным объектом и не содержит бизнес-логики. После валидации DTO трансформируется в доменную модель.

// dto/user.go
package dto

import (
	"strings"
	"time"

	"myapp/domain"
	"myapp/validation"
)

// CreateUserRequest — DTO входящего запроса на создание пользователя.
type CreateUserRequest struct {
	Name     string `json:"name"`
	Email    string `json:"email"`
	Password string `json:"password"`
	Role     string `json:"role"`
}

// Validate выполняет синтаксическую валидацию DTO.
func (r *CreateUserRequest) Validate() validation.ValidationErrors {
	v := &validation.Validator{}
	v.Field("name", r.Name).Required().MinLen(2).MaxLen(100)
	v.Field("email", r.Email).Required().Email()
	v.Field("password", r.Password).Required().MinLen(8).MaxLen(72)
	v.Field("role", r.Role).Required().Matches(
		`^(admin|user|moderator)$`,
		"invalid_role",
		"допустимые роли: admin, user, moderator",
	)
	return v.Validate()
}

// ToDomain трансформирует DTO в доменный объект.
// Вызывается только после успешной валидации.
func (r *CreateUserRequest) ToDomain() domain.CreateUserParams {
	return domain.CreateUserParams{
		Name:      strings.TrimSpace(r.Name),
		Email:     strings.ToLower(strings.TrimSpace(r.Email)),
		Password:  r.Password, // хеширование — на уровне usecase
		Role:      domain.Role(r.Role),
		CreatedAt: time.Now().UTC(),
	}
}

Важный принцип: метод ToDomain() вызывается только после успешной валидации. Он может делать нормализацию данных — strings.TrimSpace, приведение к нижнему регистру, конвертацию типов — но не бизнес-логику.

Обработка и возврат ошибок валидации: RFC 7807 Problem Details

RFC 7807 определяет стандартный формат ответа об ошибке для HTTP API. Использование этого стандарта делает API предсказуемым для клиентов и совместимым с OpenAPI-документацией.

// apierror/problem.go
package apierror

import (
	"encoding/json"
	"net/http"

	"myapp/validation"
)

// ProblemDetail соответствует RFC 7807.
type ProblemDetail struct {
	Type     string       `json:"type"`
	Title    string       `json:"title"`
	Status   int          `json:"status"`
	Detail   string       `json:"detail,omitempty"`
	Instance string       `json:"instance,omitempty"`
	Errors   []FieldError `json:"errors,omitempty"`
}

// FieldError — поле в ответе об ошибке.
type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
	Code    string `json:"code"`
}

// WriteValidationError записывает ответ с ошибками валидации.
func WriteValidationError(w http.ResponseWriter, r *http.Request, errs validation.ValidationErrors) {
	fields := make([]FieldError, len(errs))
	for i, e := range errs {
		fields[i] = FieldError{Field: e.Field, Message: e.Message, Code: e.Code}
	}

	problem := ProblemDetail{
		Type:     "https://api.example.com/errors/validation",
		Title:    "Validation Error",
		Status:   http.StatusUnprocessableEntity,
		Detail:   "Переданные данные содержат ошибки.",
		Instance: r.RequestURI,
		Errors:   fields,
	}

	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(http.StatusUnprocessableEntity)
	json.NewEncoder(w).Encode(problem)
}

// WriteInternalError записывает ответ о внутренней ошибке.
func WriteInternalError(w http.ResponseWriter, r *http.Request) {
	problem := ProblemDetail{
		Type:     "https://api.example.com/errors/internal",
		Title:    "Internal Server Error",
		Status:   http.StatusInternalServerError,
		Instance: r.RequestURI,
	}
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(http.StatusInternalServerError)
	json.NewEncoder(w).Encode(problem)
}

Пример ответа клиенту при ошибках валидации:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation Error",
  "status": 422,
  "detail": "Переданные данные содержат ошибки.",
  "instance": "/api/v1/users",
  "errors": [
    {"field": "email", "message": "некорректный email", "code": "invalid_email"},
    {"field": "password", "message": "минимальная длина: 8 символов", "code": "min_length"}
  ]
}

Локализация сообщений об ошибках

Если API обслуживает несколько локалей, сообщения об ошибках должны возвращаться на языке клиента. Простой подход — маппинг кодов ошибок на локализованные строки.

// validation/i18n.go
package validation

type Locale string

const (
	LocaleRU Locale = "ru"
	LocaleEN Locale = "en"
)

// messages содержит переводы по коду ошибки и локали.
var messages = map[Locale]map[string]string{
	LocaleRU: {
		"required":     "обязательное поле",
		"min_length":   "значение слишком короткое",
		"max_length":   "значение слишком длинное",
		"invalid_email": "некорректный адрес электронной почты",
	},
	LocaleEN: {
		"required":     "field is required",
		"min_length":   "value is too short",
		"max_length":   "value is too long",
		"invalid_email": "invalid email address",
	},
}

// Translate возвращает локализованное сообщение по коду ошибки.
func Translate(locale Locale, code string) string {
	if msgs, ok := messages[locale]; ok {
		if msg, ok := msgs[code]; ok {
			return msg
		}
	}
	// Фолбэк на английский
	if msg, ok := messages[LocaleEN][code]; ok {
		return msg
	}
	return code
}

Локаль определяется из заголовка Accept-Language и передаётся в хендлер через контекст запроса.

Тестирование слоя валидации

Один из главных плюсов описанного подхода — тестируемость. Валидаторы — чистые функции без зависимостей. Используем table-driven tests:

// validation/validator_test.go
package validation_test

import (
	"testing"

	"myapp/validation"
)

func TestEmailValidation(t *testing.T) {
	tests := []struct {
		name    string
		email   string
		wantErr bool
		wantCode string
	}{
		{name: "valid email", email: "user@example.com", wantErr: false},
		{name: "empty email", email: "", wantErr: true, wantCode: "required"},
		{name: "no at sign", email: "notanemail", wantErr: true, wantCode: "invalid_email"},
		{name: "no domain", email: "user@", wantErr: true, wantCode: "invalid_email"},
		{name: "with spaces", email: "user @example.com", wantErr: true, wantCode: "invalid_email"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			v := &validation.Validator{}
			v.Field("email", tt.email).Required().Email()
			errs := v.Validate()

			if tt.wantErr && len(errs) == 0 {
				t.Errorf("ожидалась ошибка, но валидация прошла успешно")
			}
			if !tt.wantErr && len(errs) > 0 {
				t.Errorf("не ожидалась ошибка, но получено: %v", errs)
			}
			if tt.wantErr && len(errs) > 0 && tt.wantCode != "" {
				found := false
				for _, e := range errs {
					if e.Code == tt.wantCode {
						found = true
					}
				}
				if !found {
					t.Errorf("ожидался код %q, но в ошибках его нет: %v", tt.wantCode, errs)
				}
			}
		})
	}
}

// TestCreateUserRequestValidate тестирует DTO целиком.
func TestCreateUserRequestValidate(t *testing.T) {
	tests := []struct {
		name       string
		input      dto.CreateUserRequest
		wantFields []string // поля, в которых ожидаются ошибки
	}{
		{
			name: "valid input",
			input: dto.CreateUserRequest{
				Name: "Ivan Petrov", Email: "ivan@example.com",
				Password: "securePass1", Role: "user",
			},
			wantFields: nil,
		},
		{
			name: "missing name and invalid role",
			input: dto.CreateUserRequest{
				Name: "", Email: "ivan@example.com",
				Password: "securePass1", Role: "superadmin",
			},
			wantFields: []string{"name", "role"},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			errs := tt.input.Validate()
			errFields := map[string]bool{}
			for _, e := range errs {
				errFields[e.Field] = true
			}
			for _, f := range tt.wantFields {
				if !errFields[f] {
					t.Errorf("ожидалась ошибка в поле %q, но её нет", f)
				}
			}
			if len(tt.wantFields) == 0 && len(errs) > 0 {
				t.Errorf("не ожидались ошибки, но получено: %v", errs)
			}
		})
	}
}

Интеграция с middleware: автоматическая валидация на уровне роутера

Чтобы не дублировать код парсинга и валидации в каждом хендлере, вынесем это в middleware. Используем дженерики (Go 1.18+) для типобезопасного декодирования.

// middleware/validate.go
package middleware

import (
	"context"
	"encoding/json"
	"net/http"

	"myapp/apierror"
	"myapp/validation"
)

// Validatable — интерфейс для DTO с методом валидации.
type Validatable interface {
	Validate() validation.ValidationErrors
}

// contextKey — тип для ключей контекста, чтобы избежать коллизий.
type contextKey[T any] struct{}

// DecodeAndValidate — middleware-фабрика для декодирования и валидации тела запроса.
// Использует дженерики: T должен реализовывать Validatable.
func DecodeAndValidate[T Validatable](next func(http.ResponseWriter, *http.Request, T)) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var dto T

		// Ограничиваем размер тела запроса (защита от DoS)
		r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB

		decoder := json.NewDecoder(r.Body)
		decoder.DisallowUnknownFields() // строгий режим

		if err := decoder.Decode(&dto); err != nil {
			apierror.WriteBadRequestError(w, r, "некорректный JSON")
			return
		}

		if errs := dto.Validate(); errs != nil {
			apierror.WriteValidationError(w, r, errs)
			return
		}

		next(w, r, dto)
	}
}

Пример использования в хендлере:

// handler/user.go
package handler

import (
	"net/http"

	"myapp/dto"
	"myapp/middleware"
	"myapp/usecase"
)

type UserHandler struct {
	userUC usecase.UserUseCase
}

func (h *UserHandler) Register(mux *http.ServeMux) {
	// middleware.DecodeAndValidate берёт на себя декодирование и валидацию
	mux.HandleFunc("POST /api/v1/users",
		middleware.DecodeAndValidate(h.createUser),
	)
}

func (h *UserHandler) createUser(w http.ResponseWriter, r *http.Request, req dto.CreateUserRequest) {
	// Здесь req уже валиден — можно безопасно трансформировать
	params := req.ToDomain()

	user, err := h.userUC.CreateUser(r.Context(), params)
	if err != nil {
		// Обработка бизнес-ошибок
		return
	}

	w.WriteHeader(http.StatusCreated)
	// ... сериализация ответа
	_ = user
}

Такой подход полностью убирает boilerplate из хендлеров. Хендлер получает уже валидный DTO и занимается только своей задачей — оркестрацией бизнес-логики.

Заключение: чек-лист хорошего слоя валидации

Подводя итог, сформулируем ключевые признаки качественного слоя валидации в REST API на Go:

  • Separation of concerns: синтаксическая валидация — в хендлере/middleware, семантическая — в usecase, инварианты — в домене.
  • Все ошибки за один проход: клиент должен получить полный список проблем, а не исправлять их по одной.
  • DTO отделён от доменного объекта: трансформация происходит явно через метод ToDomain() только после валидации.
  • Стандартизированный формат ошибок: RFC 7807 делает API предсказуемым для всех потребителей.
  • Локализация через коды ошибок: не хардкодьте строки в правилах — используйте коды и переводите их отдельно.
  • 100% покрытие валидаторов тестами: каждое правило тестируется изолированно с table-driven подходом.
  • Middleware убирает boilerplate: хендлер не занимается парсингом и валидацией — только логикой.
  • Ограничение размера тела запроса: http.MaxBytesReader обязателен как минимальная защита.
  • Минимальные зависимости: стандартная библиотека покрывает большинство потребностей; go-playground/validator добавляйте только при реальной необходимости.

Построенный слой валидации легко масштабируется: добавление нового правила — это одна функция типа Rule, добавление нового DTO — реализация интерфейса Validatable. Архитектура остаётся чистой, тесты — быстрыми, а API — предсказуемым для клиентов.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →