Основы веб-разработки

Middleware

Middleware — это код, который выполняется до и после контроллера: аутентификация, проверка прав, логирование, тайминги. В Winter middleware — это класс, который одновременно является PHP-атрибутом, поэтому применяется декларативно: #[AuthMiddleware] над контроллером или методом.

База Http\Stereotype\MiddlewareХуки before() / after()Применение атрибут на классе или методе

Что такое middleware и зачем

Middleware (промежуточный слой) — код, который оборачивает обработку запроса: выполняется до контроллера и после него.

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

Решение — вынести обёртку в отдельный переиспользуемый класс и навешивать его декларативно там, где нужно. В Winter такой класс одновременно является PHP-атрибутом, поэтому подключение выглядит как одна строка над контроллером: #[AuthMiddleware].

Объявление middleware

Заготовку создаёт генератор — суффикс Middleware он добавляет сам:

bash
php call make -m .Auth   # → main/AuthMiddleware.php

Полное объявление выглядит так. Здесь важна каждая строка — разберём их ниже:

main/AuthMiddleware.php
<?php

namespace Main;

use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class AuthMiddleware extends Middleware
{
  #[Autowired] private TokenService $tokens;      // зависимости — как в контроллере

  public function __construct(private string $role = 'user') {}

  public function before(HttpRequest $request, HttpResponse $response): void
  {
      // проверка до контроллера
  }

  public function after(mixed $result): mixed
  {
      return $result;                             // преобразование ответа
  }
}

Из чего это собрано:

Элемент Обязателен Что даёт
extends Middleware да Стереотип, по которому коллектор узнаёт middleware
#[\Attribute(...)] да Без него PHP не даст повесить класс атрибутом
TARGET_CLASS | TARGET_METHOD да Куда разрешено вешать — на контроллер, на метод или на оба
Конструктор нет Принимает аргументы, переданные атрибуту
#[Autowired]-свойства нет Экземпляр строит контейнер, инъекция работает
before() / after() нет Переопределяйте только нужный — второй остаётся no-op’ом

Забытая строка `#[\Attribute]`

Самая частая ошибка при написании middleware вручную: класс наследует стереотип, но не объявлен атрибутом. PHP тогда откажется применять его над контроллером — ошибка возникнет при разборе атрибутов, а не при вызове. Генератор эту строку ставит сам.

Методы

Стереотип даёт два хука, и оба уже реализованы пустыми — переопределяйте тот, который нужен.

before() — до контроллера

php
public function before(HttpRequest $request, HttpResponse $response): void

Выполняется перед вызовом обработчика. Сюда идут проверки токена, прав и лимитов. Возвращаемого значения нет: middleware либо пропускает запрос дальше, либо прерывает его исключением.

Прервать запрос — бросить MiddlewareException (по умолчанию 401) или любое ResponseException. Контроллер не вызовется, а исключение превратится в HTTP-ответ:

php
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\Kernel\Http\Header;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;

public function before(HttpRequest $request, HttpResponse $response): void
{
  $token = Header::getBearerToken();

  if ($token === null) {
      throw new MiddlewareException('Token missing');                // 401
  }
  if (!$this->tokens->isValid($token)) {
      MiddlewareException::throw('Forbidden', HttpCode::FORBIDDEN);   // 403
  }
}

К ответу об ошибке можно добавить заголовок:

php
throw new MiddlewareException('Rate limited', HttpCode::TOO_MANY_REQUESTS)
  ->withHeader('Retry-After', '60');

Прерывать — исключением, не `$response->end()`

У $response есть метод end(), но вызывать его из before() не следует: запрос на этом не останавливается, управление всё равно уйдёт в контроллер, и ответ попытается уйти клиенту дважды. Единственный способ прервать обработку — бросить исключение. Записать заголовок через $response->header() при этом можно свободно — он попадёт в итоговый ответ.

after() — после контроллера

php
public function after(mixed $result): mixed

Получает то, что вернул обработчик, до сериализации в HTTP-ответ — то есть ResponseEntity, массив или строку, ровно как их вернул метод. Что вернёте вы, то и пойдёт дальше по цепочке.

php
public function after(mixed $result): mixed
{
  // единый конверт для всех ответов контроллера
  if ($result instanceof ResponseEntity) {
      return $result->body([
          'data' => $result->getBody(),
          'meta' => ['duration' => $this->elapsed()],
      ]);
  }

  return $result;
}

`after()` — это не `finally`

Если обработчик бросил исключение, after() не выполнится: управление сразу уходит в обработку ошибок. То же и при прерывании в before() — у middleware, отработавших до него, after() не вызовется. Поэтому освобождение ресурсов, закрытие транзакции или финализацию метрики на after() вешать нельзя — для этого есть finally внутри самого обработчика.

Настройки

Всё, чем управляется подключение middleware, задаётся в двух местах: флагами в #[\Attribute(...)] самого класса и тем, как атрибут применён.

Куда вешается

На контроллер целиком, на отдельный метод или и туда, и туда — тогда middleware складываются:

php
#[AuthMiddleware]                 // на весь контроллер
#[RequestMapping('admin')]
class AdminController extends Controller
{
  #[GetMapping('info')]
  public function info(): ResponseEntity { /* только Auth */ }

  #[RateLimitMiddleware]        // добавляется к этому методу
  #[GetMapping('stats')]
  public function stats(): ResponseEntity { /* Auth + RateLimit */ }
}

Middleware класса всегда выполняется раньше middleware метода — независимо от того, в каком порядке они написаны. Внутри каждой группы порядок объявления сохраняется: before() идут сверху вниз, after() — в обратном порядке, как вложенные обёртки:

text
AuthMiddleware::before()        ← сначала класс
RateLimitMiddleware::before() ← затем метод
  метод контроллера
RateLimitMiddleware::after()
AuthMiddleware::after()         ← и обратно наружу

Аргументы

Всё, что передано атрибуту, уходит в конструктор middleware. Так один класс обслуживает разные правила:

php
#[RoleMiddleware('admin')]
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }

Повторное применение

По умолчанию один и тот же middleware вешается на цель один раз. Чтобы применить его дважды с разными аргументами, добавьте классу флаг IS_REPEATABLE:

php
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class RoleMiddleware extends Middleware { /* ... */ }

// теперь можно так:
#[RoleMiddleware('admin')]
#[RoleMiddleware('audit')]
#[GetMapping('stats')]
public function stats(): ResponseEntity { /* ... */ }

Чего middleware не умеет

Три ограничения, о которые чаще всего спотыкаются:

Ограничение Что это значит
Не наследуется Middleware, повешенный на базовый контроллер, не переходит на наследников — атрибут читается только с самого класса
Нет глобальной регистрации Повесить middleware «на всё приложение» одной строкой нельзя; его указывают на каждом контроллере
Не работает на 404 и 405 Middleware привязан к обработчику маршрута, поэтому при несовпадении пути ни один before() не вызовется

Из последнего следует практическое: сквозное логирование или сбор метрик по всем запросам на middleware не построить — такие вещи живут уровнем выше.

Типовые middleware

Три самостоятельных образца на частые задачи. Каждый — готовый файл: копируете, переименовываете под проект, вешаете атрибутом.

Логирование и тайминг

Простейший случай, задействует оба хука. before() запоминает момент старта, after() считает длительность:

main/TimingMiddleware.php
<?php

namespace Main;

use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
use Psr\Log\LoggerInterface;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class TimingMiddleware extends Middleware
{
  #[Autowired] private LoggerInterface $logger;

  private float $startedAt;

  public function before(HttpRequest $request, HttpResponse $response): void
  {
      $this->startedAt = microtime(true);

      $this->logger->info('request', [
          'method' => $request->getMethod(),
          'uri'    => $request->getUri(),
          'ip'     => $request->getClientIp(),
      ]);

      $response->header('X-Request-Id', bin2hex(random_bytes(8)));
  }

  public function after(mixed $result): mixed
  {
      $ms = round((microtime(true) - $this->startedAt) * 1000, 1);
      $this->logger->info('response', ['duration_ms' => $ms]);

      return $result;
  }
}

Состояние в свойстве ($startedAt) здесь безопасно: экземпляр middleware строится заново на каждый запрос, как и контроллер.

Только на маршрутах, которые нашлись

Такой middleware не увидит запросы, для которых маршрут не совпал: на 404 и 405 before() не вызывается. Для учёта совсем всех запросов нужен уровень выше.

Единый конверт ответа

Пример на один after(): привести ответы контроллера к общему формату, не трогая сами обработчики.

main/EnvelopeMiddleware.php
<?php

namespace Main;

use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class EnvelopeMiddleware extends Middleware
{
  public function after(mixed $result): mixed
  {
      if ($result instanceof ResponseEntity) {
          return $result->body([
              'success' => true,
              'data'    => $result->getBody(),
          ]);
      }

      return ['success' => true, 'data' => $result];
  }
}

На ответы об ошибках это не распространится — при исключении after() не выполняется. Единый формат ошибок задаётся отдельно, см. Обработку ошибок.

Готовое из коробки

Один middleware ядро поставляет само — Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware. Он берёт часовой пояс клиента из запроса и включает его на время обработки, откатываясь к TIME_ZONE из .env, если клиент ничего не прислал:

php
use Flytachi\Winter\Kernel\Http\Middleware\ClientTimezoneMiddleware;

#[ClientTimezoneMiddleware]
#[RequestMapping('reports')]
class ReportController extends Controller { /* ... */ }

Внутри обработчика читайте пояс через Timezone::current() — это значение изолировано по корутинам, поэтому у параллельных запросов оно своё. Подробнее — на странице Локализация.

Паттерн: аутентификация и права

Дальше — не отдельный образец, а разбор приёма целиком, по шагам. Он решает задачу, которая встаёт почти в каждом проекте: определить, кто прислал запрос, и дать эту информацию всему, что запрос обрабатывает, не протаскивая её аргументами.

Что получится в итоге. Контроллер перестаёт знать про заголовки и токены — он просто спрашивает «кто пришёл» и получает пользователя:

php
return ResponseEntity::created($this->service->create($this->auth->user()));

Из чего это собрано — три обязательные части и одна по желанию:

Часть Роль
Контекст запроса Объект, живущий один запрос; хранит, кто пришёл
Middleware аутентификации Опознаёт клиента и записывает результат в контекст
Контроллеры и сервисы Читают контекст, не зная, как он наполнен
Middleware прав (опционально) Проверяет, можно ли конкретное действие

Это шаблон, а не готовое решение

В примере опознание сделано через JWT, а права — через список строк. И то, и другое — сменные детали. Токен можно заменить сессией, cookie или ключом API, права — ролями или политиками: меняется содержимое шагов 2 и 4, а сам приём остаётся тем же. Важно понять именно расстановку ролей: пишет в контекст только middleware, читают — все остальные.

Шаг 1. Контекст запроса

Начинаем с объекта, куда будет складываться результат опознания. Это обычный класс — важен только атрибут #[Request] над ним.

main/AuthContext.php
<?php

namespace Main;

use Flytachi\Winter\DI\Attribute\Request;

#[Request]
class AuthContext
{
  private ?User $user = null;

  public function setUser(User $user): void { $this->user = $user; }

  public function user(): User
  {
      return $this->user ?? throw new \LogicException('No authenticated user');
  }
}

Почему #[Request]. Этот атрибут говорит контейнеру: один экземпляр на запрос. Все, кто попросит AuthContext в рамках одного запроса — middleware, контроллер, сервис — получат тот же самый объект, а параллельные запросы получат каждый свой (в Swoole изоляция идёт по корутинам).

Без этого атрибута класс считается transient: каждому потребителю достанется собственный новый экземпляр. Middleware запишет пользователя в свой, контроллер прочитает из своего — и увидит null. Ошибки при этом не будет никакой, просто пустой контекст:

text
#[Request]        middleware и контроллер → один объект → user-42
без атрибута      middleware и контроллер → разные объекты → null

Почему user() бросает исключение, а не возвращает null. Если до контекста дошли, значит middleware отработал и пользователь там есть. Пустой контекст в этот момент — не «неавторизованный запрос», а ошибка в подключении middleware, и лучше узнать о ней сразу.

Шаг 2. Middleware, который наполняет контекст

Теперь тот, кто опознаёт клиента. Его единственная задача — разобрать входные данные и положить результат в контекст; дальше он в обработке не участвует.

main/JwtMiddleware.php
<?php

namespace Main;

use Flytachi\Jwt\Entity\PublicKey;
use Flytachi\Jwt\JWT;
use Flytachi\Jwt\JWTException;
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Header;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;

#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)]
class JwtMiddleware extends Middleware
{
  #[Autowired] private AuthContext $auth;
  #[Autowired] private UserRepository $users;

  public function before(HttpRequest $request, HttpResponse $response): void
  {
      $token = Header::getBearerToken();

      if ($token === null) {
          throw new MiddlewareException('Authorization token is missing');   // 401
      }

      try {
          $payload = JWT::decode($token, [new PublicKey(env('JWT_SECRET'), 'HS256')]);
      } catch (JWTException $e) {
          throw new MiddlewareException($e->getMessage(), HttpCode::UNAUTHORIZED);
      }

      $this->auth->setUser($this->users->find($payload->getClaim('sub')));
  }
}

Что здесь происходит по порядку: достаём токен из заголовка Authorization: Bearer → проверяем → загружаем пользователя → кладём его в контекст. Последняя строка и есть смысл всего шага.

Обратите внимание на два решения:

  • Отказ — исключением. MiddlewareException по умолчанию даёт 401. До контроллера управление не дойдёт, а исключение станет HTTP-ответом само.
  • Контекст получен через #[Autowired]. Middleware строит контейнер, поэтому инъекция работает так же, как в контроллере.

JWT здесь взаимозаменяем

Пакет jwt ставится отдельно (composer require flytachi/jwt), а JWT::decode() разом проверяет подпись, алгоритм и сроки — потому одного catch и хватает. Но для паттерна важно не это: замените тело before() на проверку сессии или ключа API, оставив последнюю строку — и всё остальное продолжит работать без единой правки.

Шаг 3. Контроллер читает контекст

Подключаем middleware к контроллеру и запрашиваем контекст тем же #[Autowired]:

main/PostController.php
#[JwtMiddleware]                       // весь контроллер под аутентификацией
#[RequestMapping('api/posts')]
class PostController extends Controller
{
  #[Autowired] private AuthContext $auth;
  #[Autowired] private PostService $service;

  #[PostMapping]
  public function create(#[RequestJson, Valid] PostRequest $req): ResponseEntity
  {
      return ResponseEntity::created(
          $this->service->create($req, $this->auth->user())
      );
  }
}

Контроллер не разбирает заголовки, не знает про токены и не проверяет их сам — к моменту вызова метода middleware уже отработал. Тот же AuthContext можно попросить и в сервисе: объект один на весь запрос, протаскивать его аргументами через слои не нужно.

Шаг 4. Проверка прав

Аутентификация отвечает на вопрос «кто это», авторизация — «а можно ли ему». Второе удобно делать отдельным middleware, потому что право у каждого эндпоинта своё. Нужное передаётся аргументом атрибута.

Сначала научим контекст отвечать на вопрос о правах:

main/AuthContext.php
public function can(string $permission): bool
{
  return in_array($permission, $this->user?->permissions ?? [], true);
}

Затем middleware, который этим пользуется:

main/RequirePermission.php
<?php

namespace Main;

use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
use Flytachi\Winter\Kernel\Http\Contracts\HttpResponse;
use Flytachi\Winter\Kernel\Http\Middleware\MiddlewareException;
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;

#[\Attribute(
  \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE
)]
class RequirePermission extends Middleware
{
  #[Autowired] private AuthContext $auth;

  public function __construct(private string $permission) {}

  public function before(HttpRequest $request, HttpResponse $response): void
  {
      if (!$this->auth->can($this->permission)) {
          MiddlewareException::throw(
              "Permission '{$this->permission}' required",
              HttpCode::FORBIDDEN,                                            // 403
          );
      }
  }
}

Применение — по одному праву на строку, флаг IS_REPEATABLE разрешает повторы:

php
#[JwtMiddleware]                       // на классе: кто пришёл
#[RequestMapping('api/posts')]
class PostController extends Controller
{
  #[RequirePermission('post.edit')]  // на методе: что ему можно
  #[RequirePermission('post.publish')]
  #[PutMapping('{id:\d+}/publish')]
  public function publish(#[PathVariable] int $id): ResponseEntity { /* ... */ }
}

Почему это работает именно в таком порядке. RequirePermission читает пользователя из контекста, а значит обязан выполниться после JwtMiddleware. Так и происходит: middleware класса всегда идут раньше middleware метода. Поэтому аутентификацию вешают на класс, а проверки прав — на методы.

Список строк — самый простой вариант; заменить его ролями, политиками или запросом к базе можно, переписав только can() и тело before(). Точка подключения к эндпоинтам от этого не меняется.

Правила, на которых держится паттерн

Четыре условия — если нарушить любое, приём перестанет работать, причём чаще всего молча:

Правило Что будет иначе
Контекст помечен #[Request] Каждый получит свой экземпляр, контроллер прочитает null
Пишет в контекст только middleware Записи из разных мест — и уже не понять, кто положил значение
Отказ — через исключение $response->end() обработку не прерывает, контроллер всё равно выполнится
Аутентификация на классе, права на методах Проверка прав отработает раньше опознания и увидит пустой контекст

Дальше