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

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

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

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

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

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

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

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

Первый маршрут

Чтобы у приложения появился эндпоинт, нужны три вещи:

  1. Класс наследует стереотип Controller.
  2. Публичный метод помечен атрибутом HTTP-метода — #[GetMapping], #[PostMapping] и так далее.
  3. Файл лежит внутри каталогов, которые фреймворк сканирует (о них — ниже).

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

main/PingController.php
<?php

namespace Main;

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

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

Запускаем приложение и проверяем:

bash
php call run
# слушает 0.0.0.0:8000 — переопределяется флагами --host и --port

curl http://localhost:8000/ping
# pong

Где фреймворк ищет контроллеры

Обход начинается с корня проекта и захватывает каталог src/ каждого подключённого плагина. Конкретное расположение файла роли не играет — контроллер находят по атрибутам, а не по пути.

Три каталога из обхода исключены, и об этом стоит помнить:

Каталог Почему исключён
vendor/ Чужой код; маршруты оттуда приходят только через плагины
storage/ Сгенерированный код: кеш контейнера, прокси #[Async]
resources/ Шаблоны представлений — файлы .php, но не классы приложения

Контроллер, положенный в resources/, найден не будет.

Зависимости внутри контроллера

Controller::__construct() объявлен final и без аргументов — переопределить конструктор нельзя, поэтому конструкторная инъекция в контроллере недоступна. Зависимости внедряются в свойства атрибутом #[Autowired]:

php
class PostController extends Controller
{
  #[Autowired] private PostService $service;
}

Экземпляр контроллера создаёт контейнер, поэтому к моменту вызова метода свойство уже заполнено. Подробности — на странице Внедрение зависимостей.

Новый маршрут не появился?

Карта маршрутов собирается один раз при старте сервера, а не на каждый запрос. После обычного php call run добавленный контроллер требует перезапуска. В разработке запускайте php call run dev — сторож следит за .php-файлами и перезапускает приложение сам при любом изменении.

HTTP-методы

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

Атрибут HTTP-метод Назначение
#[GetMapping] GET Чтение ресурса
#[PostMapping] POST Создание
#[PutMapping] PUT Полное обновление
#[PatchMapping] PATCH Частичное обновление
#[DeleteMapping] DELETE Удаление
#[RequestMapping] На классе — префикс. На методе — сразу пять глаголов: GET, POST, PUT, PATCH, DELETE

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

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

OPTIONS регистрировать не нужно: preflight-запросы фреймворк обрабатывает сам, до поиска маршрута — см. CORS.

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

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

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

Именно пять, а не «любой метод»: нестандартные глаголы так не зарегистрируются. Какой глагол пришёл на самом деле, обработчик узнаёт у запроса — $request->getMethod().

HEAD и OPTIONS в этот список не входят, но обрабатываются и без него: OPTIONS перехватывается как preflight, а HEAD подставляется к обработчику GET автоматически.

Дополнительные атрибуты маршрута

Помимо глагола и пути на маршрут вешаются ещё два атрибута. Оба ставятся и на класс (тогда действуют на все его методы), и на отдельный метод — метод перекрывает класс.

Атрибут Что задаёт
#[CrossOrigin] CORS-политику этого маршрута вместо глобальной — см. CORS
#[Timeout] Дедлайн маршрута в секундах вместо глобального

#[CrossOrigin] пригождается, когда одному эндпоинту нужна политика строже или мягче общей: например, весь сервис открыт основному фронтенду, а сводка по метрикам — только админке, и с кэшем preflight на час.

php
#[CrossOrigin(origins: ['https://app.example.com'], credentials: true)]
class UserController extends Controller
{
  #[GetMapping('me')]                       // политика класса
  public function me(): ResponseEntity { /* ... */ }

  #[GetMapping('stats')]
  #[CrossOrigin(origins: ['https://admin.example.com'], maxAge: 3600)]
  public function stats(): ResponseEntity { /* ... */ }   // своя политика
}

Важно: атрибут заменяет глобальную политику целиком, а не дополняет её. Что означает каждый параметр и как настроить политику по умолчанию — на странице CORS.

#[Timeout] нужен там, где обычного лимита не хватает: глобально запрос живёт 30 секунд, а выгрузка отчёта — десять минут.

php
#[Timeout(120)]                  // весь контроллер: две минуты
class ReportController extends Controller
{
  #[GetMapping('export')]
  #[Timeout(600)]              // ...а этой выгрузке — десять
  public function export(): ResponseEntity { /* ... */ }

  #[GetMapping('ping')]
  #[Timeout(0)]                // ...а этот не ограничен вовсе
  public function ping(): ResponseEntity { /* ... */ }
}

По истечении дедлайна клиент получает 504 Gateway Timeout.

Что дедлайн может прервать

Прерывается запрос, который чего-то ждёт — базы, внешнего HTTP, файла, sleep(). Блоки finally и defer при этом отрабатывают, так что транзакции закрываются, а соединения возвращаются в пул. Запрос, который жжёт процессор в цикле без ввода-вывода, не прерывается: пока он не отпустит поток, в воркере не выполняется вообще ничего, включая сам сторож.

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

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

main/OrderController.php
#[RequestMapping('api/v1/orders')]
class OrderController extends Controller
{
  #[GetMapping]                                     // GET    /api/v1/orders
  public function index(): ResponseEntity { /* ... */ }

  #[GetMapping('{id}')]                             // GET    /api/v1/orders/{id}
  public function show(#[PathVariable] int $id): ResponseEntity { /* ... */ }

  #[PostMapping]                                    // POST   /api/v1/orders
  public function create(): ResponseEntity { /* ... */ }
}

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

Метод с пустым путём отвечает ровно по префиксу — отдельного «корневого» маршрута объявлять не нужно. Лишние слэши по краям нормализуются, так что #[RequestMapping('/api/v1/')] и #[GetMapping('/ping')] вместе дают /api/v1/ping, а не /api/v1//ping.

Общий базовый контроллер

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

php
abstract class CrudController extends Controller
{
  #[GetMapping('list')]
  public function list(): ResponseEntity { /* ... */ }
}

#[RequestMapping('api/posts')]
class PostController extends CrudController {}   // GET /api/posts/list

#[RequestMapping('api/users')]
class UserController extends CrudController {}   // GET /api/users/list

Сам базовый класс маршрутов не даёт: абстрактные классы, интерфейсы и трейты сканер пропускает.

У наследников должны быть разные префиксы

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

RuntimeException: Ambiguous handler methods mapped for [GET] '/list'

Конфликт маршрутов виден при старте, а не в проде.

Префикс плагина

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

text
префикс плагина  +  префикс класса  +  путь метода
 /billing            orders             {id}
→  GET /billing/orders/{id}

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

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

Динамические сегменты объявляются в фигурных скобках — {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"
}

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

Если имя аргумента отличается от имени в пути — передайте его в атрибут явно:

php
#[GetMapping('posts/{id}')]
public function show(#[PathVariable('id')] int $postId): ResponseEntity { /* ... */ }

Типы аргументов

Сегмент URL — всегда строка, но в метод она приходит уже приведённой к объявленному типу. Если привести не получается, до тела контроллера дело не доходит: клиент получает 400 Bad Request с внятным текстом.

Тип аргумента Что принимает При несоответствии
int, float Числовой сегмент Path variable 'id' must be an integer, got 'abc'
bool true/false, 1/0, yes/no Сообщение со списком допустимых форм
string Любой сегмент
backed enum Значение из перечисления must be one of [draft, published], got 'x'
DateTimeImmutable Разбираемую дату Сообщение о формате

Enum в пути особенно удобен — проверка допустимых значений достаётся бесплатно:

php
enum Status: string { case Draft = 'draft'; case Published = 'published'; }

#[GetMapping('posts/status/{status}')]
public function byStatus(#[PathVariable] Status $status): ResponseEntity { /* ... */ }
// /posts/status/draft   → Status::Draft
// /posts/status/hz      → 400: must be one of [draft, published], got 'hz'

Аргумент с #[PathVariable] обязателен. Чтобы сделать его необязательным, дайте значение по умолчанию или объявите тип nullable — иначе при отсутствии сегмента будет 400 с текстом Path variable 'id' is missing.

Атрибут можно не писать

Если имя аргумента совпадает с именем сегмента, #[PathVariable] необязателен — резолвер найдёт значение по имени. Но с атрибутом код читается однозначно, поэтому в примерах он оставлен.

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

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

php
#[GetMapping('{id:\d+}')]         // только цифры:   /42      ✓   /abc     ✗
#[GetMapping('{slug:[a-z-]+}')]   // строчный slug:  /hello   ✓   /Hi      ✗
#[GetMapping('files/{path:.+}')]  // остаток пути:   /a/b/c   ✓ (слэши внутри)
Приём Что даёт
Без regex — {id} Совпадает с любым непустым сегментом, кроме /
С regex — {id:\d+} Отсекает несовпадения на уровне маршрута (404 до контроллера)
{path:.+} Захватывает и слэши — так делается «остаток пути»
Приведение типа #[PathVariable] int $id конвертирует строку в нужный тип

Что в шаблоне писать нельзя

Шаблон подставляется в общий regex маршрута, и две конструкции его ломают:

  • Фигурные скобки — квантификаторы {3}, {2,4}. Закрывающая скобка обрывает сегмент, и маршрут молча перестаёт совпадать: вместо ошибки вы получаете 404 на всех запросах. Вместо {code:[A-Z]{3}} пишите {code:[A-Z][A-Z][A-Z]}.
  • Захватывающие группы(cat|dog). Лишняя группа сдвигает нумерацию параметров и роняет диспетчер на запросе. Используйте незахватывающие — {kind:(?:cat|dog)}.

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

Regex в пути — это грубый фильтр «подходит / не подходит», и результат у него один: 404, как будто такого адреса нет. Для содержательной проверки с понятным сообщением об ошибке вешайте на тот же аргумент ограничения — они срабатывают на #[PathVariable] автоматически, #[Valid] для этого не нужен:

#[PathVariable, Min(1)] int $id

Тогда вместо 404 клиент получит 400 с описанием, что именно не так. См. Запросы и ответы.

CRUD-шаблон

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

Скаффолд

Заготовку контроллера создаёт генератор. Суффикс Controller он добавляет сам, так что имя ресурса пишется коротко:

bash
php call make -c .Post
# → main/PostController.php

Внутри — рабочий метод-заглушка, чтобы приложение поднялось сразу:

main/PostController.php
<?php

namespace Main;

use Flytachi\Winter\Kernel\Http\Response\ResponseEntity;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;
use Flytachi\Winter\Kernel\Http\Stereotype\Controller;

class PostController extends Controller
{
  #[RequestMapping('post')]
  public function hello(): ResponseEntity
  {
      return ResponseEntity::ok("hello");
  }
}

Заглушку принято сразу заменять: #[RequestMapping] на методе занимает пять глаголов на одном пути, а нужен обычно один конкретный.

Куда именно ляжет файл

По умолчанию — в корневой каталог приложения (main/). Но если в проекте уже есть каталог Controllers/, Controller/, Rests/ или Rest/, генератор положит файл туда и подставит соответствующий неймспейс. Остальные флаги (-s сервис, -r репозиторий и другие) — на странице CLI → make.

Контроллер

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

main/PostController.php
<?php

namespace Main;

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

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

  #[GetMapping]                              // GET /api/posts
  public function index(#[RequestParam] 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 — контроллер не создаёт сервис руками, его внедряет контейнер. См. Внедрение зависимостей.
  • #[RequestParam] int $page = 1 — одиночный параметр строки запроса (?page=2), необязательный благодаря значению по умолчанию.
  • #[RequestJson, Valid] PostRequest — тело запроса разбирается в объект (PostRequest — обычный класс с полями) и валидируется до входа в метод. См. Запросы и ответы.
  • {id:\d+} — числовое ограничение отсекает /api/posts/abc ещё до контроллера.
  • Коды ответаResponseEntity::ok() (200), created() (201), noContent() (204). Полный список — на странице ответов.

Проверка результата

Убедиться, что все пять маршрутов зарегистрировались, можно не запуская сервер:

bash
php call mapping show api/posts

|   GET     /api/posts Main\PostController::index
|   POST    /api/posts Main\PostController::create
|   GET     /api/posts/{id:\d+} Main\PostController::get
|   PUT     /api/posts/{id:\d+} Main\PostController::update
|   DELETE  /api/posts/{id:\d+} Main\PostController::delete

Аргумент — фрагмент URL для фильтра; без него команда покажет все маршруты приложения.

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

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

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

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

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

Глаголы тоже можно смешивать — атрибуты на методе не обязаны быть одного типа:

php
#[GetMapping('search')]
#[PostMapping('search')]
public function search(): ResponseEntity
{
  // GET /search и POST /search — один обработчик
}

Каждый атрибут даёт отдельную запись в таблице маршрутов, и все они подчиняются общим правилам: наследуют префикс класса, попадают в call mapping show отдельными строками. Обработчик при этом один, и по параметрам он не может отличить, каким путём его вызвали, — если это важно, спросите у запроса $request->getUri().

Пара «глагол + путь» должна быть уникальной

Два одинаковых атрибута на одном методе — не «на всякий случай», а конфликт: приложение упадёт при старте с RuntimeException: Ambiguous handler methods mapped for [GET] '/search'. Повторять можно только разные пути или разные глаголы.

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

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

Из этого следуют две вещи, важные на практике:

  • На запросе сканирования нет. Рефлексия, обход файлов, чтение атрибутов — всё это стоимость запуска, а не стоимость запроса. Поиск по готовой таблице устроен так, что путь без параметров находится за одно обращение к хеш-карте независимо от общего числа маршрутов; перебором проверяются только пути с {...}.
  • Изменения в коде подхватываются только рестартом. Добавили контроллер или поправили путь — сервер надо перезапустить. Режим DEBUG на это не влияет: таблица собирается одинаково и в разработке, и в проде.

Поэтому в разработке приложение запускают через php call run dev — сторож следит за .php-файлами и перезапускает процесс сам, так что новый маршрут появляется сразу после сохранения файла.

Перед деплоем ничего собирать не нужно

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

Ускорить старт можно другим: call di build готовит список классов проекта, и сканер маршрутов берёт его же вместо повторного обхода файлов — см. CLI → di.

Просмотр маршрутов

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

bash
php call mapping show              # все маршруты приложения
php call mapping show api/posts    # только те, где путь содержит 'api/posts'

Команда не поднимает сервер, поэтому годится и для быстрой проверки после правки, и для диагностики: если маршрута нет в выводе — значит, контроллер не попал в скан (проверьте, что файл лежит не в resources/ и класс не абстрактный).

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

Ответы 404 и 405

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

Дальше