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

Маршрутизация

Маршрут в Winter — это атрибут на методе контроллера. Нет файлов маршрутов, нет таблиц регистрации: вы вешаете #[GetMapping] на метод, а сканер сам находит его при старте и связывает с URL. Код контроллера и есть карта маршрутов — что видите, то и обслуживается.

Атрибуты Route\AnnotationОбнаружение автоматическоеПросмотр call mapping show

Что такое маршрутизация и зачем

Маршрутизация — это сопоставление входящего запроса (HTTP-метод + путь URL) с кодом, который его обработает. Приходит GET /api/posts/42 — фреймворку нужно понять, какой именно метод какого класса вызвать.

Проблема. Связь «URL → обработчик» нужно где-то описать и поддерживать. Если хранить её отдельно от кода (в файле маршрутов или конфиге), появляется два источника правды: добавили метод в контроллере — не забудь дописать строку в routes.php. Со временем список маршрутов и код расходятся.

Решение. Winter описывает маршрут там же, где обработчик — атрибутом на методе. Один источник правды: метод и его URL всегда рядом. Об этом и раздел.

Как это работает

В большинстве фреймворков маршруты живут отдельно от кода: в файле routes.php или в конфиге. Приходится держать в голове связь «URL → контроллер» и обновлять её в двух местах. Winter идёт от обратного: маршрут описывается там же, где и обработчик.

Механика простая:

  1. Контроллер наследует стереотип Controller.
  2. Публичный метод помечается атрибутом HTTP-метода (#[GetMapping], #[PostMapping]…).
  3. При старте сканер обходит все PSR-4-корни проекта, читает эти атрибуты через рефлексию и собирает таблицу маршрутов.
  4. В продакшене таблица кешируется, чтобы не сканировать на каждый запрос.

Никакой ручной регистрации: создали метод с атрибутом — маршрут появился.

main/PingController.php
<?php

namespace Main;

use Flytachi\Winter\K2\Http\Response\ResponseEntity;
use Flytachi\Winter\K2\Route\Annotation\GetMapping;
use Flytachi\Winter\K2\Stereotype\Controller;

class PingController extends Controller
{
  #[GetMapping('ping')]
  public function ping(): ResponseEntity
  {
      return ResponseEntity::ok('pong');
  }
}

Положите этот файл в любое место под PSR-4-корнем (main/ по умолчанию) — и GET /ping уже отвечает pong. Каталог значения не имеет: сканер находит контроллер по атрибутам, а не по расположению.

HTTP-методы

Каждому HTTP-глаголу — свой атрибут. Все принимают один аргумент — путь (по умолчанию пустой) и живут в пространстве Flytachi\Winter\K2\Route\Annotation.

Атрибут HTTP-метод Назначение
#[GetMapping] GET Чтение ресурса
#[PostMapping] POST Создание
#[PutMapping] PUT Полное обновление
#[PatchMapping] PATCH Частичное обновление
#[DeleteMapping] DELETE Удаление
#[RequestMapping] префикс / любой метод Префикс на классе или catch-all на методе

Путь в атрибуте задаётся без ведущего слэша — он подставляется автоматически:

php
#[GetMapping]           // GET /            (пустой путь)
#[GetMapping('health')] // GET /health
#[PostMapping('users')] // POST /users

CRUD-шаблон

Разберём типичный ресурс целиком — от скаффолда до готового контроллера. Это эталонная форма, к которой сводится большинство API-эндпоинтов.

Скаффолд

Сгенерируем контроллер, сервис и репозиторий одной командой. Флаги комбинируются, а суффиксы (Controller, Service, Repository) генератор добавляет сам:

bash
php call make -csr .Post
# → main/PostController.php
# → main/PostService.php
# → main/PostRepository.php

Контроллер

Приведём PostController к полному набору CRUD-операций. Обратите внимание, как каждый метод отражает свой HTTP-глагол и возвращает подходящий код ответа:

main/PostController.php
<?php

namespace Main;

use Flytachi\Winter\DI\Attribute\Autowired;
use Flytachi\Winter\K2\Http\Request\Annotation\PathVariable;
use Flytachi\Winter\K2\Http\Request\Annotation\RequestJson;
use Flytachi\Winter\K2\Http\Request\Annotation\RequestQuery;
use Flytachi\Winter\K2\Http\Request\Validation\Valid;
use Flytachi\Winter\K2\Http\Response\ResponseEntity;
use Flytachi\Winter\K2\Route\Annotation\DeleteMapping;
use Flytachi\Winter\K2\Route\Annotation\GetMapping;
use Flytachi\Winter\K2\Route\Annotation\PostMapping;
use Flytachi\Winter\K2\Route\Annotation\PutMapping;
use Flytachi\Winter\K2\Route\Annotation\RequestMapping;
use Flytachi\Winter\K2\Stereotype\Controller;

#[RequestMapping('api/posts')]                 // общий префикс → /api/posts
class PostController extends Controller
{
  #[Autowired] private PostService $service; // зависимость через контейнер

  #[GetMapping]                              // GET /api/posts
  public function index(#[RequestQuery] int $page = 1): ResponseEntity
  {
      return ResponseEntity::ok($this->service->paginate($page));
  }

  #[GetMapping('{id:\d+}')]                   // GET /api/posts/{id}
  public function get(#[PathVariable] int $id): ResponseEntity
  {
      return ResponseEntity::ok($this->service->find($id));
  }

  #[PostMapping]                             // POST /api/posts → 201
  public function create(#[RequestJson, Valid] PostRequest $req): ResponseEntity
  {
      return ResponseEntity::created($this->service->create($req));
  }

  #[PutMapping('{id:\d+}')]                   // PUT /api/posts/{id}
  public function update(
      #[PathVariable] int $id,
      #[RequestJson, Valid] PostRequest $req,
  ): ResponseEntity {
      return ResponseEntity::ok($this->service->update($id, $req));
  }

  #[DeleteMapping('{id:\d+}')]                // DELETE /api/posts/{id} → 204
  public function delete(#[PathVariable] int $id): ResponseEntity
  {
      $this->service->delete($id);
      return ResponseEntity::noContent();
  }
}

Что здесь работает вместе с маршрутизацией:

  • #[Autowired] PostService — контроллер не создаёт сервис руками, его внедряет контейнер. См. Внедрение зависимостей.
  • #[RequestJson, Valid] PostRequest — тело запроса разбирается в объект и валидируется до входа в метод. См. Запросы и ответы.
  • Коды ответаResponseEntity::ok() (200), created() (201), noContent() (204). Полный список — на странице ответов.

Маршрутизация — это только вход

Контроллер остаётся тонким: принял запрос, делегировал сервису, вернул ответ. Бизнес-логика живёт в Service, доступ к данным — в Repository. Такое разделение задаёт стереотипы Winter (см. Ключевые понятия).

Префикс маршрутов

#[RequestMapping('prefix')] на классе задаёт общий префикс для всех методов контроллера. Путь метода дописывается к нему через слэш:

php
#[RequestMapping('api/v1/orders')]   // префикс класса
class OrderController extends Controller
{
  #[GetMapping]              // GET  /api/v1/orders
  #[GetMapping('{id}')]      // GET  /api/v1/orders/{id}
  #[PostMapping]             // POST /api/v1/orders
}

Префикс удобно использовать для версии API (api/v1/...) или логического раздела (admin/...), не повторяя базовый путь в каждом методе. Меняете версию — правите одну строку.

Параметры пути

Динамические сегменты объявляются в фигурных скобках — {name}. Значение привязывается к аргументу метода атрибутом #[PathVariable] и приводится к типу аргумента:

php
#[GetMapping('users/{id}/posts/{slug}')]
public function post(
  #[PathVariable] int $id,       // "42" → int 42
  #[PathVariable] string $slug,  // "hello" → string
): ResponseEntity {
  // /users/42/posts/hello  →  $id = 42, $slug = "hello"
}

Число сегментов не ограничено, порядок аргументов с #[PathVariable] соответствует порядку {...} в пути (связь по имени).

Ограничение регулярным выражением

К сегменту можно добавить regex через двоеточие — {name:pattern}. Маршрут сработает, только если сегмент подходит под шаблон; иначе роутер вернёт 404, не заходя в контроллер:

php
#[GetMapping('{id:\d+}')]         // только цифры:      /42      ✓   /abc  ✗
#[GetMapping('{slug:[a-z-]+}')]   // строчный slug:     /hello   ✓   /Hi   ✗
#[GetMapping('{code:[A-Z]{3}}')]  // ровно 3 заглавные: /USD     ✓   /us   ✗
Приём Что даёт
Без regex — {id} Совпадает с любым непустым сегментом
С regex — {id:\d+} Отсекает несовпадения на уровне маршрута (404 до контроллера)
Приведение типа #[PathVariable] int $id конвертирует строку в нужный тип

Regex или валидация?

Regex в пути — это грубый фильтр «подходит / не подходит» (результат — 404). Для содержательной проверки входных данных с понятными сообщениями об ошибке используйте валидацию параметров — см. Запросы и ответы.

Несколько путей на один метод

Атрибуты методов повторяемы — один обработчик может отвечать сразу на несколько путей. Удобно для синонимов и обратной совместимости:

php
#[GetMapping('search')]
#[GetMapping('filter')]
public function search(#[RequestQuery] string $q = ''): ResponseEntity
{
  // обслуживает и GET /search, и GET /filter
}

Можно комбинировать и разные глаголы на одном методе, если логика общая.

#[RequestMapping] на методе

На классе #[RequestMapping] — это префикс. На методе без указания глагола он матчит любой HTTP-метод для данного пути. Пригодится для вебхуков и универсальных обработчиков, где важен путь, а не метод:

php
#[RequestMapping('webhook')]
public function webhook(HttpRequest $request): ResponseEntity
{
  // GET, POST, PUT, DELETE… — всё придёт сюда
  return ResponseEntity::ok();
}

Обнаружение и просмотр маршрутов

Таблица маршрутов собирается автоматически при старте. Поведение зависит от режима (DEBUG в .env):

Режим Что делает роутер
DEBUG=true (разработка) Сканирует проект на каждый запрос — новый маршрут виден сразу, без пересборки
DEBUG=false (прод), кеш есть Читает готовую таблицу из mapping.php — без рефлексии
DEBUG=false, кеша нет Сканирует один раз и пишет кеш для следующего старта

Посмотреть все зарегистрированные маршруты — их методы, пути и обработчики:

bash
php call mapping show

Кеш маршрутов в проде

Перед деплоем соберите кеш заранее: php call mapping build. В рантайме роутер прочитает готовую таблицу и не потратит время на скан и рефлексию. Подробнее — в CLI → mapping.

Тот же сканирующий проход попутно собирает не только маршруты: обработчики исключений #[AdviceException], маршруты плагинов и эндпоинты health-актуатора — всё обнаруживается за один обход проекта.

Ответы 404 и 405

  • Путь не найден404 Not Found.
  • Путь есть, но метод не тот405 Method Not Allowed. В ответ добавляется заголовок Allow со списком методов, зарегистрированных для этого пути — клиент сразу видит, что доступно.

Ручная регистрация

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

php
$router->get('/ping', fn($req, $res, $p) => ResponseEntity::ok('pong'));
$router->post('/users', [UserController::class, 'store']);
$router->put('/users/{id}', [UserController::class, 'update']);

// Полная форма add() — с middleware для конкретного маршрута:
$router->add('GET', '/admin/stats', [AdminController::class, 'stats'], [
  ['class' => AuthMiddleware::class, 'args' => []],
]);

Дубликаты маршрутов

Регистрация одной и той же пары «метод + путь» дважды сразу бросает RuntimeException — конфликт маршрутов проявляется на старте, а не в проде.


Под капотом

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

Конвейер обработки запроса

Каждый запрос проходит через Router::handle() — фиксированный конвейер. Порядок шагов важен: например, CORS-заголовки пишутся до диспетчеризации, поэтому попадают и в ответы 404/500.

text
 1. Header::init()            — снимок заголовков + origin (scheme/host/port)
2. Locale::initFromRequest() — определение локали (Accept-Language / cookie)
3. Swoole-контекст           — фиксация времени старта, метода, URI в корутине
4. Проверка статики          — короткий путь для существующих файлов (GET, Swoole)
5. Глобальный CORS           — заголовки до диспетчеризации (покрывают 404/500)
6. OPTIONS preflight         — ответ 204 до вызова обработчика
7. Диспетчеризация           — O(1) по статике → чанкованный regex по динамике
8. Пер-маршрутный CORS       — #[CrossOrigin] переопределяет глобальный
9. Middleware before()       — в порядке объявления
10. Метод контроллера         — ReflectionCache + ParameterResolver
11. Middleware after()        — в обратном порядке
12. Сериализация ответа       — Sendable::send() / ResponseEntity::ok()->send()
13. Обработка ошибок          — ExceptionWrapper: Throwable → HTTP-ответ

Любая ошибка — включая прерывание в middleware и провал валидации — перехватывается на шаге 13 и превращается в корректный HTTP-ответ.

Две стратегии диспетчеризации

Диспетчер делит маршруты на две группы и обрабатывает их по-разному:

Тип маршрута Пример Как ищется
Статический (без {...}) /api/posts Поиск по хеш-карте за O(1) на метод
Динамический (с {...}) /api/posts/{id} Компиляция в чанкованные regex-группы

Статические маршруты не участвуют в regex-переборе вовсе — поэтому «плоские» эндпоинты быстры независимо от общего числа маршрутов.

Результат сопоставления

Диспетчер возвращает RouteResult с одним из трёх статусов:

Статус Значение
FOUND Обработчик найден; извлечённые параметры пути доступны
METHOD_NOT_ALLOWED Путь совпал, метод — нет; заполнен allowedMethods (→ заголовок Allow)
NOT_FOUND Совпадений по пути нет (→ 404)

Кеш маршрутов

При DEBUG=false таблица сериализуется в файл mapping.php. Замыкания-обработчики (например, у health-актуатора) в кеш не попадают — они не сериализуемы и перерегистрируются из текущей конфигурации при загрузке. После записи кеша OPcache инвалидируется автоматически, чтобы рантайм подхватил свежий файл.

Дальше