Модуль 9. Middleware и валидация

Цели модуля

После модуля студент сможет:

  • Понимать middleware-паттерн и его роль в HTTP-обработке
  • Реализовывать Request ID middleware для корреляции запросов
  • Писать структурированное логирование запросов
  • Защищать сервис от panic через Recovery middleware
  • Ограничивать время обработки через Timeout middleware
  • Валидировать запросы с go-playground/validator
  • Компонировать middleware в правильном порядке
  • Маскировать PII в логах

9.1 Middleware-паттерн

Что такое middleware

Middleware - функция которая оборачивает http.Handler и выполняет код до и/или после обработки запроса.

Сигнатура middleware:

text
func(http.Handler) http.Handler

Middleware принимает следующий handler в цепочке и возвращает новый handler с дополнительным поведением.

Базовая структура

go
func MyMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Код ДО обработки запроса
        // ...

        next.ServeHTTP(w, r)  // вызываем следующий handler

        // Код ПОСЛЕ обработки запроса
        // ...
    })
}

Пример: простой middleware

go
func HelloWorld(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Println("before handler")
        next.ServeHTTP(w, r)
        fmt.Println("after handler")
    })
}

Применение middleware

go
// Оборачиваем один handler
handler := HelloWorld(http.HandlerFunc(myHandler))

// Оборачиваем mux
mux := http.NewServeMux()
// ... регистрация роутов
wrappedMux := HelloWorld(mux)

// Цепочка middleware
handler = MiddlewareA(MiddlewareB(MiddlewareC(handler)))

Порядок выполнения

Middleware выполняется в порядке добавления (внешний -> внутренний):

go
handler = Logging(handler)      // 3. выполняется последним
handler = RequestID(handler)    // 2. выполняется вторым
handler = Recovery(handler)     // 1. выполняется первым

// Порядок выполнения запроса:
// Recovery -> RequestID -> Logging -> Handler

Для запроса: Recovery выполняется первым, потом RequestID, потом Logging. Для ответа: порядок обратный.


9.2 Request ID middleware

Зачем нужен Request ID

Request ID - уникальный идентификатор запроса. Используется для:

  • Корреляции логов одного запроса
  • Трассировки запроса через сервисы
  • Отладки проблем в production
  • Связи с клиентом (клиент может передать свой ID)

Реализация

go
package middleware

import (
    "context"
    "net/http"
    "github.com/google/uuid"
)

type contextKey string

const RequestIDKey contextKey = "request_id"

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Получаем ID из заголовка или генерируем новый
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }

        // Добавляем в context
        ctx := context.WithValue(r.Context(), RequestIDKey, id)

        // Возвращаем в ответе
        w.Header().Set("X-Request-ID", id)

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Извлечение request ID из context
func GetRequestID(ctx context.Context) string {
    if id, ok := ctx.Value(RequestIDKey).(string); ok {
        return id
    }
    return ""
}

Использование

go
func (h *Handler) GetRepo(w http.ResponseWriter, r *http.Request) {
    requestID := middleware.GetRequestID(r.Context())
    h.logger.Info("getting repo", "request_id", requestID)
    // ...
}

9.3 Logging middleware

Зачем нужно логирование запросов

Логирование запросов даёт видимость:

  • Какие запросы приходят
  • Сколько времени занимает обработка
  • Какие ошибки возникают
  • Какой клиент отправил запрос

Реализация

go
package middleware

import (
    "log/slog"
    "net/http"
    "time"
)

func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()

            // Оборачиваем ResponseWriter для перехвата статуса
            wrapped := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}

            next.ServeHTTP(wrapped, r)

            duration := time.Since(start)

            logger.Info("request completed",
                "method", r.Method,
                "path", r.URL.Path,
                "status", wrapped.statusCode,
                "duration", duration.String(),
                "request_id", GetRequestID(r.Context()),
                "remote_addr", r.RemoteAddr,
            )
        })
    }
}

// statusRecorder перехватывает WriteHeader для логирования статуса
type statusRecorder struct {
    http.ResponseWriter
    statusCode int
}

func (r *statusRecorder) WriteHeader(code int) {
    r.statusCode = code
    r.ResponseWriter.WriteHeader(code)
}

Что логировать

ПолеЗачем
methodHTTP метод запроса
pathПуть запроса
statusКод ответа
durationВремя обработки
request_idКорреляция с другими логами
remote_addrАдрес клиента

Что НЕ логировать

  • Тело запроса (может содержать PII)
  • Заголовки с токенами (Authorization, Cookie)
  • Query параметры с секретами
  • Пароли, email, телефоны

9.4 Recovery middleware

Зачем нужен Recovery

Panic в хэндлере убьёт весь процесс. Recovery middleware:

  • Ловит panic
  • Логирует stack trace
  • Возвращает 500 клиенту
  • Процесс продолжает работать

Реализация

go
package middleware

import (
    "log/slog"
    "net/http"
    "runtime/debug"
)

func Recovery(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if rec := recover(); rec != nil {
                    logger.Error("panic recovered",
                        "panic", rec,
                        "stack", string(debug.Stack()),
                        "request_id", GetRequestID(r.Context()),
                        "path", r.URL.Path,
                    )

                    http.Error(w, "internal server error", http.StatusInternalServerError)
                }
            }()

            next.ServeHTTP(w, r)
        })
    }
}

Важно

Recovery должен быть ПЕРВЫМ в цепочке middleware, чтобы поймать panic из любого middleware или хэндлера.

go
handler = Recovery(handler)  // первый!
handler = RequestID(handler)
handler = Logging(handler)

9.5 Timeout middleware

Зачем нужен Timeout

Долгие запросы могут:

  • Занять connection pool
  • Потребить ресурсы сервера
  • Привести к таймауту на клиенте

Timeout middleware ограничивает время обработки.

Реализация

go
package middleware

import (
    "context"
    "net/http"
    "time"
)

func Timeout(d time.Duration) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()

            r = r.WithContext(ctx)

            done := make(chan struct{})
            go func() {
                next.ServeHTTP(w, r)
                close(done)
            }()

            select {
            case <-done:
                // обработчик завершился успешно
            case <-ctx.Done():
                // таймаут
                http.Error(w, "request timeout", http.StatusGatewayTimeout)
            }
        })
    }
}

Альтернатива: http.TimeoutHandler

Стандартная библиотека предоставляет готовый TimeoutHandler:

go
handler = http.TimeoutHandler(handler, 30*time.Second, "request timeout")

Ограничение: http.TimeoutHandler буферизует ответ, что может быть проблемой для больших ответов или streaming.

Использование

go
handler = Timeout(30 * time.Second)(handler)

ВАЖНО: Timeout middleware должен проверять ctx.Done() в хэндлерах. Хэндлеры должны использовать r.Context() для операций.


9.6 Валидация запросов

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

Валидация защищает от:

  • Некорректных данных
  • SQL injection (частично)
  • Непредвиденного поведения
  • Ошибок в бизнес-логике

go-playground/validator

Стандартная библиотека валидации в Go.

Установка:

bash
go get github.com/go-playground/validator/v10

Теги валидации

go
type CreateRepoRequest struct {
    Name      string `json:"name" validate:"required,min=3,max=100"`
    OwnerID   string `json:"owner_id" validate:"required,uuid"`
    IsPrivate bool   `json:"is_private"`
    Tags      []string `json:"tags" validate:"omitempty,max=10,dive,max=50"`
}
ТегНазначение
requiredПоле обязательно
min, maxМинимальная/максимальная длина
emailФормат email
uuidФормат UUID
urlФормат URL
omitemptyПропустить если пусто
diveВалидировать элементы массива
oneofОдно из значений

Использование validator

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

var validate *validator.Validate

func init() {
    validate = validator.New()
}

func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) {
    var req CreateRepoRequest
    if err := decodeJSON(r, &req); err != nil {
        h.writeError(w, http.StatusBadRequest, "invalid request body")
        return
    }

    // Валидация
    if err := validate.Struct(req); err != nil {
        validationErrors := formatValidationErrors(err)
        h.writeJSON(w, http.StatusBadRequest, ErrorResponse{
            Code:    "VALIDATION_ERROR",
            Message: "validation failed",
            Errors:  validationErrors,
        })
        return
    }

    // Обработка валидного запроса
}

Форматирование ошибок валидации

go
func formatValidationErrors(err error) []string {
    var errors []string

    var validationErrors validator.ValidationErrors
    if errors.As(err, &validationErrors) {
        for _, e := range validationErrors {
            errors = append(errors, fmt.Sprintf(
                "field '%s' failed validation: %s",
                e.Field(),
                e.Tag(),
            ))
        }
    }

    return errors
}

ErrorResponse с деталями

go
type ErrorResponse struct {
    Code    string   `json:"code"`
    Message string   `json:"message"`
    Errors  []string `json:"errors,omitempty"`
}

// Пример ответа
{
    "code": "VALIDATION_ERROR",
    "message": "validation failed",
    "errors": [
        "field 'Name' failed validation: required",
        "field 'OwnerID' failed validation: uuid"
    ]
}

Валидация на уровне usecase

Валидация тегов - только формат. Бизнес-правила в usecase:

go
func (uc *CreateRepoUseCase) Execute(ctx context.Context, req CreateRepoRequest) (*models.Repo, error) {
    // Валидация формата (в handler)
    // Валидация бизнес-правил (здесь)

    if req.Name == "admin" {
        return nil, &ValidationError{
            Field:   "name",
            Message: "name 'admin' is reserved",
        }
    }

    // Проверка существования владельца
    user, err := uc.userService.GetUser(ctx, req.OwnerID)
    if err != nil {
        return nil, fmt.Errorf("get user: %w", err)
    }
    if user == nil {
        return nil, &ValidationError{
            Field:   "owner_id",
            Message: "user not found",
        }
    }

    // Создание репо
    repo := req.ToDomain()
    if err := uc.repoService.Create(ctx, repo); err != nil {
        return nil, fmt.Errorf("create repo: %w", err)
    }

    return repo, nil
}

9.7 Композиция middleware

Функция для композиции

go
func Chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
    // Применяем в обратном порядке чтобы первый middleware был внешним
    for i := len(middlewares) - 1; i >= 0; i-- {
        handler = middlewares[i](handler)
    }
    return handler
}

Использование

go
handler := Chain(
    mux,
    middleware.Recovery(logger),
    middleware.RequestID,
    middleware.Logging(logger),
    middleware.Timeout(30*time.Second),
)

Правильный порядок middleware

text
1. Recovery       - ловит panic из всех остальных
2. RequestID      - добавляет ID для корреляции
3. Logging        - логирует запрос (видит request ID)
4. Timeout        - ограничивает время обработки
5. Auth           - проверяет аутентификацию
6. RateLimit      - ограничивает частоту запросов
7. Handler        - бизнес-логика

Пример из шаблона

go
// internal/app/server.go
func BuildRouter(h *v1.Handler, logger *slog.Logger) http.Handler {
    mux := http.NewServeMux()
    v1.RegisterRoutes(mux, h)

    // Применяем middleware в правильном порядке
    handler := middleware.Recovery(logger)(mux)
    handler = middleware.RequestID(handler)
    handler = middleware.Logging(logger)(handler)
    handler = middleware.Timeout(30*time.Second)(handler)

    return handler
}

PII masking

Что такое PII

PII (Personally Identifiable Information) - персональные данные:

  • Email
  • Телефон
  • ФИО
  • Адрес
  • Паспортные данные

Зачем маскировать

Логи могут быть доступны широкому кругу лиц. Утечка логов = утечка PII. Маскирование защищает данные.

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

go
package middleware

import (
    "regexp"
)

var (
    emailRegex = regexp.MustCompile(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`)
    phoneRegex = regexp.MustCompile(`\+?[0-9]{10,15}`)
)

func MaskPII(s string) string {
    s = emailRegex.ReplaceAllString(s, "***@***.***")
    s = phoneRegex.ReplaceAllString(s, "***")
    return s
}

Использование в логировании

go
logger.Info("user action",
    "email", MaskPII(user.Email),
    "action", "login",
)

// Результат: email=***@***.***

Привязка к шаблону проекта

Где middleware в шаблоне

text
internal/handlers/http/middleware/
├── request_id.go     # Request ID middleware
├── logging.go        # Логирование запросов
├── recovery.go       # Перехват panic
├── timeout.go        # Таймаут запросов
├── pii.go            # Маскирование PII
└── prometheus.go     # Метрики запросов

Что изучаем на примере шаблона

  1. Каждый middleware в отдельном файле
  2. Middleware принимают logger как зависимость
  3. Request ID передаётся через context
  4. statusRecorder для перехвата статуса
  5. Правильный порядок применения

Задание: изучите internal/handlers/http/middleware/

  • Какие middleware реализованы?
  • В каком порядке они применяются?
  • Как передаётся request ID?
  • Как логируются запросы?

💱 Компромиссы

  • Кастомный Timeout vs http.TimeoutHandler: свой middleware не буферизует ответ и дружит со стримингом, но после отмены context горутина хендлера продолжает работать; готовый http.TimeoutHandler проще, но буферизует весь ответ — плохо для больших ответов и streaming.
  • Валидация тегами vs бизнес-правила в usecase: теги min=3, uuid декларативны и проверяют формат бесплатно, но не видят контекста; правила вроде «name 'admin' зарезервирован» требуют похода в БД и живут в usecase.
  • Клиентский X-Request-ID vs свой uuid: доверие к заголовку клиента связывает логи всех сервисов в одну трассу, но кривой или злой клиент может подсунуть мусор в логи; генерация uuid.NewString() безопасна, но трасса обрывается на входе в сервис.
  • Recovery vs падение пода: recover() возвращает клиенту 500 и держит процесс живым, но превращает баг в тихую ошибку, которую видно только по stack trace в логах; без recovery panic роняет весь под — отказ каскадом, зато проблема видна сразу.
  • Полные логи vs маскирование PII: точный email в логе ускоряет поиск инцидента, но утечка логов становится утечкой персональных данных; MaskPII защищает пользователя, но искать по исходным значениям уже нельзя.

Практическое задание

Задание 1: Request ID middleware

Реализуйте middleware который добавляет request ID:

go
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" {
            id = uuid.NewString()
        }

        ctx := context.WithValue(r.Context(), RequestIDKey, id)
        w.Header().Set("X-Request-ID", id)

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Задание 2: Logging middleware

Реализуйте логирование запросов:

go
func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            wrapped := &statusRecorder{ResponseWriter: w, statusCode: 200}

            next.ServeHTTP(wrapped, r)

            logger.Info("request completed",
                "method", r.Method,
                "path", r.URL.Path,
                "status", wrapped.statusCode,
                "duration", time.Since(start).String(),
                "request_id", GetRequestID(r.Context()),
            )
        })
    }
}

Задание 3: Recovery middleware

Реализуйте перехват panic:

go
func Recovery(logger *slog.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if rec := recover(); rec != nil {
                    logger.Error("panic recovered",
                        "panic", rec,
                        "stack", string(debug.Stack()),
                    )
                    http.Error(w, "internal server error", 500)
                }
            }()
            next.ServeHTTP(w, r)
        })
    }
}

Задание 4: Timeout middleware

Реализуйте ограничение времени обработки:

go
func Timeout(d time.Duration) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), d)
            defer cancel()

            r = r.WithContext(ctx)

            done := make(chan struct{})
            go func() {
                next.ServeHTTP(w, r)
                close(done)
            }()

            select {
            case <-done:
            case <-ctx.Done():
                http.Error(w, "request timeout", 504)
            }
        })
    }
}

Задание 5: Валидация запросов

Реализуйте валидацию CreateRepoRequest:

go
type CreateRepoRequest struct {
    Name      string `json:"name" validate:"required,min=3,max=100"`
    OwnerID   string `json:"owner_id" validate:"required,uuid"`
    IsPrivate bool   `json:"is_private"`
}

func (h *Handler) CreateRepo(w http.ResponseWriter, r *http.Request) {
    var req CreateRepoRequest
    if err := decodeJSON(r, &req); err != nil {
        h.writeError(w, 400, "invalid request body")
        return
    }

    if err := validate.Struct(req); err != nil {
        h.writeValidationError(w, err)
        return
    }

    // обработка

}

Задание 6: Композиция middleware

Соберите цепочку middleware:

go
func BuildRouter(h *Handler, logger *slog.Logger) http.Handler {
    mux := http.NewServeMux()
    RegisterRoutes(mux, h)

    handler := Recovery(logger)(mux)
    handler = RequestID(handler)
    handler = Logging(logger)(handler)
    handler = Timeout(30*time.Second)(handler)

    return handler
}

Задание 7: Тесты для middleware

Напишите тесты:

go
func TestRequestID(t *testing.T) {
    handler := RequestID(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := GetRequestID(r.Context())
        assert.NotEmpty(t, id)
    }))

    req := httptest.NewRequest("GET", "/", nil)
    w := httptest.NewRecorder()
    handler.ServeHTTP(w, req)

    assert.NotEmpty(t, w.Header().Get("X-Request-ID"))

}

Quiz "Проверь себя"

1. Что такое PII и зачем его маскировать в логах?
2. Как правильно логировать запросы без утечки PII? — выберите все подходящие
3. Как timeout middleware отменяет долгие запросы?
4. Зачем нужен recovery middleware?
5. В каком порядке применять middleware?
6. Почему recovery middleware должен быть первым в цепочке?
7. Что произойдёт, если middleware не вызовет next.ServeHTTP?
8. Чем валидация тегов отличается от бизнес-валидации?
9. Как middleware добавляет значение в context запроса?

✅ Итоги модуля

Студент должен:

  • Понимать middleware-паттерн
  • Реализовывать Request ID, Logging, Recovery, Timeout middleware
  • Валидировать запросы с go-playground/validator
  • Компонировать middleware в правильном порядке
  • Маскировать PII в логах
  • Тестировать middleware с httptest
  • Ответить на все вопросы Quiz

🔗 Мостик к Модулю 10

Следующий модуль: Модуль 10. Работа с PostgreSQL: основы - database/sql vs pgx, connection pooling, SQL-запросы, prepared statements, SQL injection prevention. Привязка к internal/adapters/postgres/ шаблона.