Маршрутизация
Маршрут в Winter — это атрибут на методе контроллера. Нет файлов
маршрутов, нет таблиц регистрации: вы вешаете #[GetMapping] на метод, а сканер
сам находит его при старте и связывает с URL. Код контроллера и есть карта
маршрутов — что видите, то и обслуживается.
Что такое маршрутизация и зачем
Маршрутизация — это сопоставление входящего запроса (HTTP-метод + путь URL)
с кодом, который его обработает. Приходит GET /api/posts/42 — фреймворку нужно
понять, какой именно метод какого класса вызвать.
Проблема. Связь «URL → обработчик» нужно где-то описать и поддерживать. Если
хранить её отдельно от кода (в файле маршрутов или конфиге), появляется два
источника правды: добавили метод в контроллере — не забудь дописать строку в
routes.php. Со временем список маршрутов и код расходятся.
Решение. Winter описывает маршрут там же, где обработчик — атрибутом на методе. Один источник правды: метод и его URL всегда рядом. Об этом и раздел.
Как это работает
В большинстве фреймворков маршруты живут отдельно от кода: в файле routes.php
или в конфиге. Приходится держать в голове связь «URL → контроллер» и обновлять
её в двух местах. Winter идёт от обратного: маршрут описывается там же, где и
обработчик.
Механика простая:
- Контроллер наследует стереотип
Controller. - Публичный метод помечается атрибутом HTTP-метода (
#[GetMapping],#[PostMapping]…). - При старте сканер обходит все PSR-4-корни проекта, читает эти атрибуты через рефлексию и собирает таблицу маршрутов.
- В продакшене таблица кешируется, чтобы не сканировать на каждый запрос.
Никакой ручной регистрации: создали метод с атрибутом — маршрут появился.
<?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 на методе |
Путь в атрибуте задаётся без ведущего слэша — он подставляется автоматически:
#[GetMapping] // GET / (пустой путь)
#[GetMapping('health')] // GET /health
#[PostMapping('users')] // POST /usersCRUD-шаблон
Разберём типичный ресурс целиком — от скаффолда до готового контроллера. Это эталонная форма, к которой сводится большинство API-эндпоинтов.
Скаффолд
Сгенерируем контроллер, сервис и репозиторий одной командой. Флаги
комбинируются, а суффиксы (Controller, Service, Repository) генератор
добавляет сам:
php call make -csr .Post
# → main/PostController.php
# → main/PostService.php
# → main/PostRepository.phpКонтроллер
Приведём PostController к полному набору CRUD-операций. Обратите внимание, как
каждый метод отражает свой HTTP-глагол и возвращает подходящий код ответа:
<?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')] на классе задаёт общий префикс для всех методов
контроллера. Путь метода дописывается к нему через слэш:
#[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] и приводится к типу
аргумента:
#[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,
не заходя в контроллер:
#[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). Для содержательной проверки входных данных с понятными сообщениями об ошибке используйте валидацию параметров — см. Запросы и ответы.
Несколько путей на один метод
Атрибуты методов повторяемы — один обработчик может отвечать сразу на несколько путей. Удобно для синонимов и обратной совместимости:
#[GetMapping('search')]
#[GetMapping('filter')]
public function search(#[RequestQuery] string $q = ''): ResponseEntity
{
// обслуживает и GET /search, и GET /filter
}Можно комбинировать и разные глаголы на одном методе, если логика общая.
#[RequestMapping] на методе
На классе #[RequestMapping] — это префикс. На методе без указания глагола он
матчит любой HTTP-метод для данного пути. Пригодится для вебхуков и
универсальных обработчиков, где важен путь, а не метод:
#[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, кеша нет |
Сканирует один раз и пишет кеш для следующего старта |
Посмотреть все зарегистрированные маршруты — их методы, пути и обработчики:
php call mapping showКеш маршрутов в проде
Перед деплоем соберите кеш заранее: php call mapping build. В рантайме роутер
прочитает готовую таблицу и не потратит время на скан и рефлексию. Подробнее —
в CLI → mapping.
Тот же сканирующий проход попутно собирает не только маршруты: обработчики
исключений #[AdviceException], маршруты плагинов и эндпоинты health-актуатора —
всё обнаруживается за один обход проекта.
Ответы 404 и 405
- Путь не найден →
404 Not Found. - Путь есть, но метод не тот →
405 Method Not Allowed. В ответ добавляется заголовокAllowсо списком методов, зарегистрированных для этого пути — клиент сразу видит, что доступно.
Ручная регистрация
Атрибуты — предпочтительный и основной способ. Но когда маршруты строятся
динамически (например, из конфигурации или в интеграционном слое), их можно
регистрировать императивно через объект Router:
$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.
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
инвалидируется автоматически, чтобы рантайм подхватил свежий файл.
Дальше
- Контроллеры — стереотип
Controllerи структура обработчика - Запросы и ответы — привязка параметров,
#[PathVariable], валидация - CORS — глобальная политика и
#[CrossOrigin]на маршрутах - Внедрение зависимостей — как
#[Autowired]наполняет контроллер