handbookНаписать

patterns/behavioral/chain-of-responsibility/practice/README.md · практика · 4 языка · 51 тест

Практика: middleware входящего запроса (Request middleware) ​

Лимит запросов, аутентификация и валидация как звенья перед обработчиком: каждое может ответить само и остановить цепочку, а от порядка звеньев зависит, кого и от чего они защищают.

Базовый пример: Цепочка обязанностей. Там звено только решает «моё или дальше», а запрос по дороге не меняется. Здесь звенья дополняют запрос для следующих, отвечают вместо обработчика, а перестановка двух звеньев меняет защиту сервиса.

Задача ​

Сервис заказов принимает POST /orders от мобильного приложения. Сначала обработчик всё делал сам: проверял токен, разбирал JSON, считал запросы с одного адреса. Потом появились ещё десять обработчиков, и в каждом те же пятнадцать строк в начале — где-то с лимитом, где-то без, где-то токен проверялся после разбора тела. Аудит безопасности нашёл эндпоинт, где перебор токенов не ограничивался ничем.

Цепочка выносит каждую заботу в отдельное звено с одной формой: «запрос и остаток цепочки -> ответ». Звенья собираются один раз перед обработчиками. И тут всплывает вопрос, которого не было в учебном примере: звенья не независимы. Обработчику нужен пользователь от аутентификации и заказ от валидации, а лимит до аутентификации и после неё защищает от разного.

Что урезано ​

  • Сети нет: запрос и ответ — простые структуры, без заголовков, путей и методов. Токен лежит прямо в запросе, адрес клиента — строка.
  • Токены — словарь «токен -> пользователь» в конструкторе. В проекте это JWT или запрос в хранилище сессий; звено от этого не меняет форму.
  • Лимит — фиксированное окно в памяти одного процесса, часы внедряются функцией. В проекте счётчики живут в Redis и окно скользящее.
  • Обработчик один и ничего не сохраняет: возвращает строку с заказом.

Решение ​

Контракт: Request и Response, обработчик Handler (запрос -> ответ) и звено Middleware, которое получает запрос и остаток цепочки как next. Звено либо возвращает ответ само (цепочка остановлена), либо зовёт next — возможно, с дополненной копией запроса.

Звенья:

  • RateLimit пускает не больше 2 запросов с одного адреса за 60 секунд, остальным — 429.
  • Authenticate находит пользователя по токену и кладёт его в запрос; нет токена или он чужой — 401.
  • ValidateOrder разбирает JSON стандартной библиотекой, проверяет item и qty и кладёт в запрос готовый Order; иначе — 400 с причиной.

Обработчик CreateOrder стоит в конце и полагается на звенья перед ним: берёт из запроса пользователя и заказ. Если их нет, цепочку собрали неправильно — он отвечает 500 сразу, а не делает вид, что всё в порядке.

Сборка (Pipeline в PHP, Chain в Go, pipeline в Python и TypeScript) ставит звенья перед обработчиком: первое в списке видит запрос первым.

Структура ​

links

endpoint

next

Request

+client: string

+token: string

+body: string

+user: string

+order: Order

Response

+status: int

+body: string

«interface»

Handler

+handle(request) : Response

«interface»

Middleware

+process(request, next) : Response

RateLimit

Authenticate

ValidateOrder

CreateOrder

Pipeline

-endpoint: Handler

-links: Middleware[]

+handle(request) : Response

Контракт нарочно маленький: звену не нужно знать ни о других звеньях, ни об обработчике, только о next. Запрос неизменяемый (в Go передаётся по значению): звено дополняет копию, и звенья перед ним не видят её изменений.

Порядок звеньев ​

Сборка — единственное место, где решается порядок, и он здесь не косметика:

  • Лимит до аутентификации считает все запросы с адреса, в том числе с неверным токеном. Перебор токенов останавливается на третьей попытке. Цена — настоящий пользователь за тем же адресом (офис, NAT) получит 429, пока окно не сменится.
  • Лимит после аутентификации видит только запросы с верным токеном. Пользователь не страдает от чужого перебора, но перебор токенов не ограничен ничем: каждая попытка получает честный 401.
  • Валидация до аутентификации разбирает тела анонимных запросов и рассказывает им, какой формат ждёт сервис. Аутентификация первой отвечает анониму 401, не тратя время на тело.
  • Обработчик без аутентификации не получает пользователя и отвечает 500: зависимость звеньев друг от друга видна сразу, а не в продакшене.

На практике лимитов часто два: грубый по адресу до аутентификации (против перебора) и точный по пользователю после неё (тарифы и квоты). Порядок здесь: limit, auth, validate.

Как это собирается ​

Звенья создаются один раз при старте сервиса, там же, где регистрируются маршруты. Часы и словарь токенов приходят из окружения, RateLimit - один объект на весь сервис. Маршрут POST /orders получает полную цепочку limit, auth, validate перед CreateOrder, а POST /orders/check (проверка заказа без создания) - только limit, validate перед своим обработчиком. Звенья общие, цепочки разные.

Сервер ищет цепочку по маршруту и отдаёт ей запрос. В примере три запроса с одного адреса: первые два проходят лимит и отвечают 201 и 400, третий получает 429 ещё до проверки токена, потому что лимитер общий и считает запросы по адресу на всех маршрутах. Неизвестный маршрут цепочки не находит, и 404 отвечает сам сервер.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Звенья одинаковы по смыслу во всех языках: тексты ответов, коды, лимит 2 за 60 секунд. Каждое звено — одна проверка и один выход наружу; общего базового класса нет, потому что передача дальше — это просто вызов next. Звено с настройками — фабрика или класс с конструктором, звено без настроек (ValidateOrder) — само является звеном.

ЯзыкОсобенность
PHPИнтерфейсы в форме PSR-15: Middleware::process($request, Handler $next); Pipeline сам Handler, остаток цепочки — новый Pipeline
Gotype Middleware func(next Handler) Handler, как middleware net/http; запрос по значению, лимит под мьютексом
PythonЗвено — функция (request, call_next), как в Starlette; копия запроса — dataclasses.replace
TypeScriptЗвено — функция (request, next) в духе Express; копия запроса — spread, сборка — reduceRight

Запуск: make test T=patterns/behavioral/chain-of-responsibility/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках и работают на фейковом окружении: ручные часы, один адрес клиента 10.0.0.1, единственный верный токен t-alice.

  • Каждое звено останавливает цепочку своим ответом: без токена и с чужим токеном — 401, битый JSON, нет item, qty = 0 — 400 с причиной. Верный запрос доходит до обработчика: 201 и order for alice: 2 x book.
  • Лимит: три запроса подряд — 201, 201, 429; через 60 секунд окно сменилось — снова 201. Часы двигает тест, без ожидания.
  • Порядок звеньев: одна таблица, те же звенья, разные ответы. Три чужих токена и потом верный: лимит до аутентификации — 401, 401, 429, 429, после — 401, 401, 401, 201. Аноним с битым телом: валидация первой — 400, аутентификация первой — 401.
  • Обработчик без аутентификации и валидации перед ним отвечает 500.

Где граница ​

  • Звеньев больше пяти-шести и нужны разные наборы для разных маршрутов — это уже работа роутера фреймворка (группы маршрутов, middleware на маршрут), свою сборку лучше не растить.
  • Скрытая зависимость «обработчику нужен пользователь от аутентификации» держится на порядке и проверке в рантайме. Если таких зависимостей много, их выражают типами: аутентификация возвращает AuthenticatedRequest, и обработчик без неё просто не соберётся.
  • Лимит в памяти процесса работает, пока процесс один. При нескольких репликах счётчик выносится в общее хранилище, звено остаётся тем же.
  • Если звено ничего не решает, а только добавляет поведение вокруг (журнал, метрики, тайминг), это скорее декоратор: форма та же, но цепочку он не останавливает.
  • Если проверка одна и маршрут один, звено избыточно: три строки в начале обработчика понятнее.

Отличия между языками ​

  • PHP — форма PSR-15 (MiddlewareInterface::process($request, $handler)), на которой построены Slim, Mezzio и многие другие. Pipeline сам реализует Handler: остаток цепочки — новый Pipeline без первого звена, поэтому звено получает обычный Handler и не знает, звено за ним или обработчик. Запрос — readonly класс с методами with*, как в PSR-7.
  • Go — канонический middleware net/http: func(next Handler) Handler. Звено без настроек (ValidateOrder) само имеет тип Middleware, звено с настройками — фабрика, возвращающая Middleware. Запрос передаётся по значению, поэтому r.User = user меняет только копию. Сервер зовёт обработчик из многих горутин — счётчики лимита под мьютексом.
  • Python — звено с сигнатурой (request, call_next), как BaseHTTPMiddleware в Starlette. Неизменяемый запрос — frozen dataclass, копия с новым полем — dataclasses.replace. Привязка звена к остатку цепочки вынесена в _bind, а не в лямбду внутри цикла: иначе все лямбды захватят последнее значение переменной.
  • TypeScript — в духе Express (req, res, next), но синхронно и с возвратом ответа вместо записи в res. Неизменяемость — readonly поля и spread { ...request, user }. JSON.parse возвращает any, поэтому тело сначала приводится к unknown и проверяется по полям. В Koa и Hono звенья асинхронные (await next()), форма та же.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS