Введение

Ключевые понятия

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

Рантайм резидентный, SwooleОписание атрибутыСборка контейнер

1. Приложение живёт, а не поднимается на каждый запрос

Это главное отличие, и из него следует почти всё остальное.

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

Как в Winter. Приложение запускается один раз и продолжает работать. Мастер-процесс загружает код и порождает воркеры; каждый воркер обслуживает запросы годами, не перезагружаясь.

text
мастер                    загружает код один раз
├── воркер 1            обслуживает запросы, живёт долго
├── воркер 2                 └── корутина ← запрос A
├── воркер 3                 └── корутина ← запрос B  (одновременно)
└── воркер 4                 └── корутина ← запрос C

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

Три следствия, которые вы почувствуете сразу:

Старт стоит дорого — один раз Скан проекта, построение контейнера, чтение конфигов происходят до первого запроса. Сам запрос уже ничего не поднимает
Состояние переживает запрос Статическое свойство, заполненное в одном запросе, увидит следующий. Иногда это то, что нужно; чаще — источник ошибки
Долгая операция не блокирует остальных Но только если она умеет уступать управление. Обычные PHP-функции этого не умеют

Что теперь может пойти не так

Данные одного пользователя, сохранённые в статическое свойство, увидит следующий — и это будет не «странность», а утечка между пользователями. Из-за корутин это случается даже в пределах одного запроса по времени: два запроса выполняются вперемежку.

Поэтому в Winter состояние либо принадлежит объекту, который создаётся на запрос, либо хранится в контексте корутины. Всё, что фреймворк держит «текущим» — язык, часовой пояс, поля логирования — устроено именно так.

2. Обнаружение вместо регистрации

В Winter нет файла со списком контроллеров, нет routes.php, нет реестра сервисов. При старте фреймворк обходит проект и находит всё сам.

text
старт → обход всех .php от корня проекта
       (кроме vendor/, storage/, resources/)
     → в каждом файле ищется объявление класса
     → найденный класс подключается и разбирается:
         контроллер?     → маршруты в таблицу
         #[Configuration]? → фабрики в контейнер
         конфигуратор?   → применить настройки
         процесс, демон? → зарегистрировать

Обход один на всё приложение, а не отдельный для маршрутов, отдельный для DI. Создали класс — он работает. Не нужно ничего дописывать в списки, и не бывает ситуации «написал контроллер, а маршрут не появился, потому что забыл зарегистрировать».

Плата за это — старт: обход читает каждый файл. Поэтому в рабочем режиме результат кешируется, и при DEBUG=false повторный старт уже не ходит по диску. В режиме разработки кеш выключен: новый класс должен подхватываться сразу.

Отсюда правило про чужие .php

Сканер не отличает класс приложения от чего угодно другого — он читает файл и подключает его, если нашёл объявление класса. Именно поэтому шаблоны живут в resources/, а генерируемый код — в storage/: оба каталога исключены из обхода. Подробнее — в Структуре проекта.

3. Атрибуты вместо конфигов

Поведение описывается рядом с кодом, к которому относится — атрибутом PHP, а не записью в отдельном файле.

main/UserController.php
#[RequestMapping('users')]                       // prefix for the whole class
class UserController extends Controller
{
  #[Autowired] private UserService $service;   // dependency

  #[GetMapping('{id}')]                        // GET /users/{id}
  public function show(#[PathVariable] int $id): ResponseEntity
  {
      return ResponseEntity::ok($this->service->find($id));
  }
}

Здесь пять решений — префикс, зависимость, метод, путь, источник параметра — и все пять видны в одном экране. В конфиг-файле их пришлось бы искать в четырёх местах, а при переименовании метода они молча разъехались бы с кодом.

Атрибуты покрывают все слои фреймворка:

Слой Атрибуты Где читать
Маршруты #[RequestMapping], #[GetMapping], #[PostMapping], #[Timeout], #[CrossOrigin] Маршрутизация
Привязка запроса #[PathVariable], #[RequestParam], #[RequestQuery], #[RequestBody], #[RequestJson], #[RequestForm], #[RequestFile], #[RequestHeader] Запросы
Валидация #[Valid] и 24 ограничения: #[NotBlank], #[Email], #[Size], #[In] Валидация
Зависимости #[Autowired], #[Inject], #[Lazy], #[Singleton], #[Request], #[Transient] Внедрение зависимостей
Конфигурация #[Configuration], #[Bean], #[Value], #[Import] Конфигурация
Состав приложения #[EnableWeb], #[EnableProcess], #[EnableDaemon], #[EnableScheduler], #[EnableAsync], #[EnableActuator] Состав приложения
Фон и расписание #[Async], #[Scheduled] Асинхронность, Планировщик
Ошибки #[AdviceException] Обработка ошибок
Схема БД #[Table], #[Id], #[Varchar], #[ForeignKey], #[Index] База данных

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

4. Стереотипы

Стереотип — базовый класс с определённой ролью. Наследуясь от него, вы говорите фреймворку, чем является ваш класс, и получаете готовое поведение.

Стереотип Роль Наследуют
Controller Принимает HTTP-запрос, возвращает ответ Контроллеры
Middleware Работает до и после контроллера Middleware
Repository Доступ к таблице; есть RepositoryCrud и RepositoryView Репозитории
Process Единица работы вне запроса: разово, в фоне, по требованию Процессы
Daemon Долгоживущий процесс с воркерами и авто-перезапуском Демоны
Cmd, CmdCustom Консольная команда Свои команды

Сервис — это обычный класс

Стереотипа Service в Winter нет, и это намеренно. Бизнес-логика не нуждается ни в каком поведении от фреймворка: обычный класс отлично внедряется, тестируется без всякого окружения и не тянет за собой базовый класс.

php call make -s .Order создаёт именно такой класс — без наследования.

5. Контейнер собирает объекты за вас

Вы не пишете new для контроллеров, сервисов и репозиториев. Объявляете, что вам нужно, — контейнер создаёт и передаёт.

php
class OrderService
{
  #[Autowired] private OrderRepository $orders;
  #[Autowired] private PaymentGateway $payments;
}

Регистрировать эти классы нигде не нужно: контейнер умеет создать любой класс, у которого разрешимы зависимости конструктора.

Область видимости — сколько живёт объект

Область Объект создаётся Когда применять
Transient — по умолчанию Каждый раз заново Почти всегда
#[Singleton] Один раз на воркер Дорогое и не хранящее данных запроса: пулы, клиенты
#[Request] Один раз на запрос То, что описывает этот запрос: текущий пользователь, контекст

По умолчанию — transient, а не singleton

Если вы приходите из Spring или Laravel, это отличие стоит запомнить: там компонент по умолчанию один на приложение, здесь — новый на каждое внедрение.

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

Фреймворк проверяет сочетания областей при старте: #[Singleton], держащий внутри #[Request]-объект, — это данные первого запроса, замёрзшие на всё время жизни воркера. Приложение не поднимется и укажет цепочку. Подробнее — в Внедрении зависимостей.

6. Одно приложение — несколько рантаймов

Winter — не только веб. Из чего состоит приложение, объявляется атрибутами на классе-точке входа:

bootstrap.php
#[EnableWeb]                                    // HTTP server
#[EnableScheduler]                              // scheduled methods
#[EnableProcess(QueueWorker::class)]            // background workers
final class Application extends WinterApplication
{
  public static function main(array $argv): never
  {
      parent::run($argv);
  }
}

Убрали #[EnableWeb] — получилось приложение без HTTP: планировщик и очередь, тот же код, те же сервисы, тот же контейнер. Отдельного «консольного ядра» и второго набора конфигов для этого не требуется.

Файл входа при этом один — call. Он же запускает сервер, он же выполняет команды.

7. Конвейер запроса

Между сокетом и вашим методом стоит цепочка шагов. Каждый можно настроить, но по умолчанию все они уже работают.

text
запрос
→ маршрут          подобрать метод по пути и HTTP-методу
→ middleware       before(): аутентификация, контекст, логирование
→ привязка         из URL, query, тела, файлов — в аргументы метода
→ валидация        #[Valid] и ограничения → 422 при провале
→ ваш метод        получает готовые, проверенные, типизированные данные
→ ответ            ResponseEntity, ResponseView, ResponseFile
→ middleware       after(): доработать ответ
ответ

Смысл конвейера — в том, что до вашего метода доходят уже разобранные и проверенные данные. В теле не встречается ни $_GET, ни json_decode, ни проверок «а пришло ли поле».

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

Работа вне запроса

Четыре механизма, и выбор между ними — частый вопрос:

Механизм Что это Когда брать
#[Async] Метод выполняется параллельно, результат забирается через Future Несколько независимых обращений внутри одного запроса
Process Отдельная единица работы; запускается разово, в фоне или по требованию Импорт, отчёт, рассылка — то, что имеет начало и конец
Daemon Долгоживущий процесс с воркерами, перезапуском и масштабированием Очередь, потребитель шины, постоянный обработчик
#[Scheduled] Метод вызывается по расписанию или интервалу Регулярное: выгрузка, очистка, синхронизация

Ответ на «что выбрать» обычно даёт длительность: секунды внутри запроса — #[Async], задача с концом — Process, работа без конца — Daemon, работа по времени — #[Scheduled]. Разбор с примерами — на странице Фоновые процессы.

Настройка — это код

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

main/WebConfig.php
final class WebConfig extends WebConfigurerAdapter
{
  public function configureCors(CorsRegistry $cors): void
  {
      $cors->addMapping('/api/**')->allowedOrigins('https://app.example.com');
  }

  public function configureServer(ServerSettings $server): void
  {
      $server->workers(8)->requestTimeout(30);
  }
}

Регистрировать класс не нужно. Так же настраиваются каналы логов (LoggingConfigurer) и собираются нестандартные объекты (#[Configuration] с методами #[Bean]). Значения, зависящие от окружения, живут в .env — см. Конфигурацию.

Собираем вместе

Что происходит с GET /users/1:

text
ДО ЗАПУСКА (один раз)
php call run
  → обход проекта: контроллеры, конфигурации, конфигураторы
  → контейнер собран, таблица маршрутов построена
  → воркеры порождены, сервер слушает порт

НА ЗАПРОС (каждый раз)
GET /users/1
  → воркер принимает, открывает корутину
  → маршрут: #[GetMapping('{id}')] → UserController::show
  → middleware before()
  → контейнер создаёт UserController, внедряет UserService
  → #[PathVariable] привязывает id = 1
  → show(1) → ResponseEntity::ok(...)
  → middleware after() → ответ клиенту
  → объекты запроса уничтожены, воркер берёт следующий

Обратите внимание на границу: всё дорогое — слева, до запуска. Справа остаётся только работа, относящаяся к конкретному запросу.

Дальше