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

Контроллеры

Контроллер принимает HTTP-запрос и возвращает ответ. В Winter это класс, наследующий стереотип Controller; методы с атрибутами маршрутов — обработчики. Сам класс намеренно «пустой»: зависимости приходят через контейнер, а всё поведение задаётся атрибутами.

База Http\Stereotype\ControllerСоздаётся контейнером (DI)Возвращает Sendable или данные

Что такое контроллер и зачем

Контроллер — это граница между HTTP и вашим приложением: точка, куда приходит запрос и откуда уходит ответ.

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

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

В Winter Controller — это стереотип: базовый класс с чёткой ролью «вход HTTP». Он намеренно минимален — по сути маркер, по которому сканер находит обработчики, а контейнер знает, как построить экземпляр:

php
namespace Flytachi\Winter\Kernel\Http\Stereotype;

abstract class Controller implements ControllerInterface
{
  final public function __construct() {}
}

Одна деталь этого класса определяет почти всё, как вы будете с ним работать: конструктор объявлен final и без аргументов. Отсюда два следствия:

  • Конструктор не переопределить. Конструкторной инъекции у контроллера нет — зависимости приходят в свойства через #[Autowired].
  • new UserController() вы не пишете. Экземпляр создаёт фреймворк на каждый запрос, попутно разрешая зависимости.

Создание

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

bash
php call make -c .User   # → main/UserController.php

Каркас минимален: класс, стереотип и один метод-обработчик.

main/UserController.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;
use Flytachi\Winter\Kernel\Route\Annotation\RequestMapping;

#[RequestMapping('users')]
class UserController extends Controller
{
  #[GetMapping]
  public function index(): ResponseEntity
  {
      return ResponseEntity::ok([]);
  }
}

Чтобы фреймворк подхватил класс как контроллер, он должен отвечать трём условиям:

Условие Почему
Наследует Controller По нему сканер отличает обработчики от прочих классов
Не абстрактный Абстрактные классы, интерфейсы и трейты сканер пропускает
Лежит в сканируемом каталоге Каталоги resources/, storage/ и vendor/ исключены

Обработчиками становятся публичные методы с атрибутами маршрутов. Как эти атрибуты устроены — глаголы, префиксы, параметры пути — на странице Маршрутизация; здесь дальше речь о самом классе.

Зависимости

Контроллеру почти всегда нужны сервисы и репозитории. Их не создают через new — их внедряет контейнер. Конструктор для этого недоступен, поэтому способ один: типизированное свойство с атрибутом #[Autowired].

main/UserController.php
use Flytachi\Winter\DI\Attribute\Autowired;

#[RequestMapping('users')]
class UserController extends Controller
{
  #[Autowired] private UserService $service;
  #[Autowired] private LoggerInterface $logger;

  #[GetMapping('{id}')]
  public function get(#[PathVariable] int $id): ResponseEntity
  {
      $this->logger->info('fetch user', ['id' => $id]);
      return ResponseEntity::ok($this->service->find($id));
  }
}

Свойство можно объявлять private — контейнер заполняет его до того, как управление дойдёт до вашего метода. Тип свойства и есть то, что будет внедрено.

Логгер по типу

#[Autowired] LoggerInterface $logger даёт логгер, автоматически именованный по классу-потребителю — писать getLogger(self::class) не нужно, имя канала подставит контейнер. Механика — в доках logger.

Подробно о ручных привязках, #[Lazy] и настройке контейнера — на странице Внедрение зависимостей.

Время жизни контроллера

Контроллер создаётся заново на каждый запрос. Экземпляр не переиспользуется и не разделяется между запросами, поэтому свойства безопасно наполнять данными текущего запроса — соседний запрос их не увидит.

Это поведение по умолчанию: класс без атрибута области видимости считается transient, то есть строится заново при каждом обращении к контейнеру.

Области видимости

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

Атрибут Сколько экземпляров Когда уместно
нет (по умолчанию) Новый на каждое внедрение Всё, что хранит состояние
#[Singleton] Один на процесс-воркер Классы без состояния: фабрики, пулы, клиенты
#[Request] Один на запрос (в Swoole — на корутину) Контекст запроса: авторизация, unit of work
#[Transient] Новый на каждое внедрение — явно Когда хочется сказать это в коде

Не вешайте `#[Singleton]` на контроллер

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

Обратите внимание и на обратную сторону: сервис без #[Singleton] тоже строится заново на каждый запрос. Если он открывает соединение или держит прогретый кеш, это стоит денег — пометьте его явно.

Конфликт областей ловится при старте

Комбинация «#[Singleton], внутри которого лежит #[Request]-зависимость» — скрытая ошибка: request-объект первого запроса замёрз бы в синглтоне на всё время жизни воркера. Winter обходит граф зависимостей при загрузке и падает с ScopeConflictException, показывая цепочку A → $prop: B, а не даёт приложению подняться с такой связкой.

Возврат ответа

Что метод вернул, то фреймворк и отправит. Есть два пути: вернуть готовый объект ответа или просто данные.

Просто данные

Любое не-null значение оборачивается в ответ 200 автоматически. Для быстрых эндпоинтов этого достаточно:

php
#[GetMapping('ping')]
public function ping(): string
{
  return 'pong';                       // 200, text/plain
}

#[GetMapping]
public function index(): array
{
  return $this->service->all();        // 200, application/json
}

Тип возвращаемого значения решает, каким будет Content-Type:

Что вернули Content-Type
Массив или объект Согласуется с заголовком Accept; по умолчанию application/json
Строка, число, bool Всегда text/plain; charset=utf-8
null (или void) Ответ не отправляется — см. предупреждение ниже

Объект, у которого есть метод toArray(), перед сериализацией через него и проходит — сущности и DTO отдаются как есть, вручную разворачивать не нужно.

`null` — это не пустой ответ

Обработчик, вернувший null или объявленный как void, не отправляет ничего: фреймворку нечего сериализовать, и клиент остаётся без ответа. Если тело действительно не нужно, возвращайте ResponseEntity::noContent() — это корректный 204.

Готовый объект ответа

Когда нужен конкретный код, заголовки или особый формат — возвращайте объект, реализующий Sendable. Их четыре:

Класс Для чего
ResponseEntity Данные и коды состояния — основной тип для API
ResponseView Серверный HTML из шаблонов
ResponseFile Файлы и выгрузки, собранные в памяти
ResponseStreamFile Отдача файла с диска потоком, без загрузки в память

ResponseEntity — данные и коды

Фабрика задаёт HTTP-код, аргумент становится телом:

php
return ResponseEntity::ok($data);            // 200
return ResponseEntity::created($data);       // 201
return ResponseEntity::accepted($data);      // 202
return ResponseEntity::noContent();          // 204
return ResponseEntity::badRequest($err);     // 400
return ResponseEntity::notFound();           // 404
return ResponseEntity::status(HttpCode::IM_A_TEAPOT)->body($data);  // любой код

// Заголовок — builder-стилем, вызовы можно цеплять:
return ResponseEntity::ok($data)->header('X-Total-Count', (string) $total);

Есть ещё unauthorized(), forbidden(), conflict(), unprocessable() и internalError() — полный список на странице ответов.

Ошибку удобнее бросить, чем вернуть

Возвращать ResponseEntity::notFound() из глубины метода — значит тащить проверку через все ветки. Вместо этого бросьте исключение: фреймворк сам превратит его в ответ с нужным кодом. См. Обработку ошибок.

ResponseView — HTML

Для серверного рендеринга шаблонов. view() рендерит ресурс сам по себе, render() оборачивает его в макет — внутри макета готовый HTML выводится вызовом wrContent():

php
// Ресурс внутри макета: resources/views/layouts/main.php + resources/views/users/index.php
return ResponseView::render('layouts/main', 'users/index', ['users' => $users]);

// Только ресурс, без макета — например, фрагмент для htmx:
return ResponseView::view('users/row', ['user' => $user]);

Третий аргумент — данные, доступные в шаблоне как переменные. Подробнее — на странице Представления.

ResponseFile — файлы и выгрузки

Собирает файл в памяти и отдаёт клиенту. Имя файла обязательно во всех фабриках, кроме file():

php
return ResponseFile::csv($rows, 'report.csv');        // CSV-выгрузка
return ResponseFile::json($payload, 'export.json');   // JSON-файл
return ResponseFile::xml($data, 'feed.xml');          // XML
return ResponseFile::txt($text, 'notes.txt');         // текст
return ResponseFile::binary($bytes, 'image.png');     // произвольные байты
return ResponseFile::file('/abs/path/report.pdf');    // файл с диска

По умолчанию csv и binary отдаются как вложение (диалог «сохранить»), а json, xml, txt и file — как содержимое для просмотра. Переключает это аргумент $isAttachment.

ResponseStreamFile — большие файлы

Для видео, архивов и всего, что не стоит целиком поднимать в память. Файл уходит потоком, а ответ ведёт себя как полноценный файловый сервер: поддерживает HTTP Range (докачка и перемотка), условные запросы и валидаторы ETag / Last-Modified.

php
return ResponseStreamFile::open('/abs/path/video.mp4');

// Скачивание вместо просмотра, с кешированием на час:
return ResponseStreamFile::open($path)->attachment()->maxAge(3600);

// Отдать целиком, запретив докачку по частям:
return ResponseStreamFile::open($path)->acceptRanges(false);

Разница с ResponseFile::file() — в памяти: та читает файл целиком, эта отдаёт его напрямую, поэтому размер файла не упирается в лимит памяти воркера.

Тонкий контроллер

Контроллер — это вход, а не место для бизнес-логики. Держите его тонким: принял запрос, делегировал сервису, вернул ответ. Логика — в Service, доступ к данным — в Repository:

php
#[PostMapping]
public function create(#[RequestJson, Valid] CreateUserRequest $req): ResponseEntity
{
  // никакой логики здесь — только оркестрация
  $user = $this->service->register($req);
  return ResponseEntity::created($user);
}

Обратите внимание, чего в этом методе нет: разбора тела запроса, проверки полей, ручного формирования JSON. Тело уже разобрано в объект и провалидировано до входа в метод, ответ соберётся из возвращённого значения. Контроллеру остаётся один содержательный вызов.

Почему так

Тонкие контроллеры проще тестировать и переиспользовать: одну и ту же логику сервиса можно вызвать из контроллера, консольной команды или фоновой задачи. Разделение Controller → Service → Repository — базовая модель Winter (см. Ключевые понятия).

Middleware на контроллере

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

php
#[AuthMiddleware]                 // на весь контроллер
#[RequestMapping('admin')]
class AdminController extends Controller
{
  #[AuditMiddleware]            // ...и дополнительно только на этот метод
  #[GetMapping('stats')]
  public function stats(): ResponseEntity { /* ... */ }
}

Класс middleware — это ещё и атрибут

Чтобы класс можно было повесить атрибутом, он должен не только наследовать стереотип Middleware, но и сам быть объявлен атрибутом. Без строки #[Attribute] PHP откажется его применять:

main/AuthMiddleware.php
<?php

namespace Main;

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
{
  public function before(HttpRequest $request, HttpResponse $response): void
  {
      // проверка перед обработчиком
  }
}

Генератор делает это за вас — php call make -m .Auth создаёт заготовку уже с нужной строкой.

Порядок выполнения

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

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

Прервать запрос можно прямо из before() — достаточно бросить исключение: до обработчика дело не дойдёт, а исключение превратится в HTTP-ответ обычным путём.

Middleware с параметрами

Аргументы, переданные атрибуту, попадают в конструктор middleware. Так один класс обслуживает разные правила без дублирования:

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

Сам middleware создаёт контейнер, поэтому в нём работает и #[Autowired] — зависимости внедряются так же, как в контроллере.

Как писать before() / after() подробно, что можно вернуть из after() и какие middleware есть из коробки — на странице Middleware.

Дальше