Yii3
Yii3 рассчитан на работу в процессе, который не завершается: в его DI-контейнере есть StateResetter, раннер отдаёт свой контейнер через публичное API, а собрать приложение один раз и сбрасывать состояние запроса после каждого ответа — это то, как фреймворк устроен изначально. Официальный раннер для RoadRunner, yiisoft/yii-runner-roadrunner, сделан так же. Эта страница описывает скрипт резидентного воркера, вариант с новым раннером на каждый запрос и то, что было проверено в маршрутизации, сессиях, загрузке файлов и обработке ошибок.
Проверено на
- PHP 8.5.8 — NTS, embed SAPI.
- Rapira 0.6.0.
- Шаблон yiisoft/app 1.4 с yii-runner-http 3.2.1 (router-fastroute 4.x).
Оба скрипта воркера с этой страницы прогнаны на этом стеке и прошли весь набор проверок: маршрутизация, сгенерированные URL, отправка форм и JSON, сессии, загрузка файлов, обработка ошибок и 200 запросов подряд.
Yii3 и режим воркера
Резидентному воркеру нужны две вещи из публичного API.
ApplicationRunner::getContainer() возвращает контейнер, на котором работает приложение, так что ни наследоваться, ни лезть в приватное состояние не нужно. Yiisoft\Di\StateResetter — обычный сервис в этом контейнере: компоненты регистрируют в нём свои колбэки сброса, и один вызов reset() возвращает их в исходное состояние; это собственный ответ фреймворка на сервис, который хранит состояние запроса.
Свой сервис, который хранит состояние запроса, тоже должен зарегистрировать колбэк: добавьте в его DI-описание ключ 'reset' => function (): void { … } — ровно так же, как объявляют свои yiisoft/session и yiisoft/router. Замыкание привязано к экземпляру, поэтому оно может восстановить приватное состояние, не пересобирая объект. Что между запросами сбрасывает сама Rapira, а что оставляет как есть, описано в обзоре фреймворков и в разделе Режим воркера.
Резидентная схема сводится к трём шагам: собрать раннер один раз, вызывать его на каждый запрос и после этого сбрасывать состояние контейнера.
Что понадобится
- Установленная Rapira — см. Установку.
- Приложение на Yii3: либо свежий проект из
yiisoft/app, либо тот, что у вас уже есть.
На стороне PHP ставить нечего: скрипт воркера ниже — единственный новый файл в проекте, и лежит он в корне проекта, рядом с composer.json, потому что rootPath раннера — это как раз корень проекта. Ещё на машине нужен обычный PHP CLI — через него запускается Composer. Rapira поставляет PHP библиотекой (libphp), а не командой php, поэтому эти шаги выполняются на вашем системном PHP, который Rapira не использует и не трогает.
Резидентный воркер
Это рекомендуемый вариант. Сохраните его как worker.php в корне проекта:
<?php
declare(strict_types=1);
use App\Environment;
use Rapira\Plugin\Http\HttpHandlerConfig;
use Yiisoft\Di\StateResetter;
use Yiisoft\Yii\Runner\Http\HttpApplicationRunner;
use function Rapira\create_plugin_handler;
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/bootstrap.php';
$runner = new HttpApplicationRunner(
rootPath: __DIR__,
debug: Environment::appDebug(),
checkEvents: Environment::appDebug(),
environment: Environment::appEnv(),
);
$container = $runner->getContainer();
$http = create_plugin_handler(new HttpHandlerConfig());
$handler = static function () use ($runner, $container): void {
try {
$runner->run();
} finally {
// The worker keeps serving after an escaped error; the reset has to
// run on that path too, or state leaks into the next request.
$container->get(StateResetter::class)->reset();
}
};
while ($http->handleRequest($handler)) {
gc_collect_cycles();
}Разберём по частям.
src/bootstrap.php — это собственный бутстрап шаблона. Он подключает автозагрузчик Composer, читает .env, если тот есть, и вызывает Environment::prepare() — ровно то же самое делает public/index.php, прежде чем взяться за раннер. Строка с vendor/autoload.php выше него избыточна — require_once превращает второй вызов в пустую операцию, — но с ней скрипт читается как самостоятельная точка входа.
Раннер создаётся один раз, с теми же аргументами, что и в public/index.php. rootPath, debug, checkEvents и environment берутся из App\Environment точь-в-точь так, как их передаёт фронт-контроллер, поэтому воркер поднимает то же самое приложение, что и веб-точка входа. Шаблонный public/index.php передаёт ещё один аргумент — temporaryErrorHandler с логгером через StreamTarget, — а при включённом APP_C3 подключает c3.php. В проверенном воркере нет ни того, ни другого. Временный обработчик ошибок ловит только то, что случилось во время сборки конфигурации и контейнера; без него раннер откатывается на ErrorHandler с NullLogger (HttpApplicationRunner::createTemporaryErrorHandler()) — так что передайте его и здесь, если хотите видеть в логах сбои сборки контейнера.
getContainer() — часть публичного API, поэтому контейнер, который вы забираете себе, и есть контейнер приложения: тот самый, которым раннер будет пользоваться на каждом запросе. StateResetter достаётся из него уже внутри обработчика.
На каждый запрос — run(), затем reset(). run() — тот же вызов, что делает фронт-контроллер, а reset() проходит по зарегистрированным в контейнере колбэкам сброса и возвращает сервисы с состоянием в исходное положение до того, как придёт следующий запрос.
run() на каждом вызове заново прогоняет всю свою последовательность. Каждый вызов регистрирует обработчик ошибок, выполняет runBootstrap(), выполняет checkEvents() и только потом обрабатывает запрос; раннер по замыслу допускает повторные вызовы, и на 200 вызовах подряд этот повтор оказался безвредным. Проверка событий делает работу, только когда поднят её флаг, а шаблон привязывает этот флаг к Environment::appDebug(), так что с выключенной отладкой она на каждом вызове ничего не делает.
Резидентный раннер читает каждый запрос заново. run() не запоминает запрос в момент создания объекта. На каждом вызове он достаёт из контейнера RequestFactory и собирает новый PSR-7-объект ServerRequest из $_SERVER, $_GET, $_POST, $_COOKIE, $_FILES и php://input, а Rapira заново наполняет эти суперглобальные переменные перед каждой итерацией цикла (этот контракт разбирает Режим воркера).
Память не растёт. За 200 запросов подряд занятая воркером память сколько-нибудь заметно не выросла: приложение собирается один раз, сброс стоит дёшево, и убирать за загрузкой на каждом запросе просто нечего.
Вариант попроще: новый раннер на каждый запрос
Чтобы вообще обойтись без резидентного состояния, создавайте раннер внутри обработчика. Тогда всё, что построит приложение, принадлежит одному запросу:
<?php
declare(strict_types=1);
use App\Environment;
use Rapira\Plugin\Http\HttpHandlerConfig;
use Yiisoft\Yii\Runner\Http\HttpApplicationRunner;
use function Rapira\create_plugin_handler;
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/bootstrap.php';
$http = create_plugin_handler(new HttpHandlerConfig());
$handler = static function (): void {
// A fresh runner per request; constructor arguments mirror public/index.php.
$runner = new HttpApplicationRunner(
rootPath: __DIR__,
debug: Environment::appDebug(),
checkEvents: Environment::appDebug(),
environment: Environment::appEnv(),
);
$runner->run();
};
while ($http->handleRequest($handler)) {
gc_collect_cycles();
}Контейнер каждый раз собирается заново, поэтому подвижных частей меньше, сброс негде сделать неправильно и состояние контейнера не переходит из одного запроса в следующий; а вот static-свойства, глобальные переменные и всё, что настроил бутстрап, остаются в памяти при любом воркере, и сбрасывать их должен ваш собственный код. Этот вариант тоже прошёл весь набор проверок.
Контейнер поднимается на каждый запрос, а значит, время подъёма тратится каждый раз и каждый раз производится целый контейнер мусора. Память воркера растёт, пока эти контейнеры копятся и PHP не освободит их разом, — обычная картина для загрузки на каждом запросе, а не утечка. Поставьте рядом с этой схемой pool.max_requests, чтобы воркер время от времени завершался и заменялся новым: как ведёт себя память в разных случаях, разбирает обзор фреймворков, а сам ключ описан в Конфигурации.
Автозагрузчик и бутстрап шаблона всё равно остаются резидентными, а цикл запросов по-прежнему живёт в скрипте воркера, так что это всё равно воркер — просто он выбрасывает приложение между запросами, — а не классический режим.
Берите резидентный раннер, если нет причин поступить иначе: это собственная схема фреймворка для долгоживущего процесса, память не растёт, а весь сброс — один вызов. Раннер на каждый запрос берите, когда в загрузке есть требования к порядку, о которых не хочется думать: код, который обязан отработать до сборки контейнера, или подготовка на каждый запрос, которую колбэк StateResetter откатить не сможет. Переход с одной схемы на другую меняет только скрипт воркера.
Как это запустить
rapira serve worker.phpРежим воркера включён по умолчанию, поэтому флаг не нужен. Остальные флаги собраны в разделе Командная строка.
Для продакшена перенесите настройки в rapira.toml:
[http]
listen = "127.0.0.1:8000"
[pool]
entrypoint = "/srv/app/worker.php"
processes = 8
max_requests = 500
request_terminate_timeout_secs = 30
[log]
level = "info"
format = "json"Каждый ключ вместе со значением по умолчанию и допустимыми границами описан на странице Конфигурация, а юнит systemd и обратный прокси перед сервером описаны в разделе Запуск в продакшене.
Что проверено
Обе схемы прогнали через один и тот же набор проверок на шаблоне yiisoft/app. Результаты:
Маршрутизация работает без переопределений в $_SERVER. Rapira кладёт в SCRIPT_NAME имя файла входного скрипта — /worker.php, а не /index.php, — и FastRoute всё равно попадал во вложенные пути со строкой запроса. Корень / отдавал домашнюю страницу шаблона, а неизвестный путь — родной 404 фреймворка. Переопределять SCRIPT_NAME, REQUEST_URI или DOCUMENT_ROOT нигде не пришлось.
Сгенерированные URL чистые. UrlGeneratorInterface::generate() выдавал обычные пути приложения — имя файла скрипта воркера в них не просачивается.
Сессии привязаны к запросу и корректно изолированы. Клиент, который хранит куки, видел, как его счётчик идёт 1, 2 от запроса к запросу, а новый клиент, обратившийся сразу после к тому же эндпоинту, получил свежую сессию и снова начал с 1. В резидентной схеме, где контейнер переживает запрос, всё точно так же.
Формы, JSON-тела и загруженные файлы доходят целиком. Поля $_POST, JSON из php://input, multipart-загрузка с временным файлом, который читается прямо во время запроса, — всё это лежит в PSR-7-объекте ServerRequest, который yii-runner-http собирает из суперглобальных переменных.
Брошенное исключение превращается в 500, а воркер продолжает обслуживать запросы. Исключение из действия ловит ErrorCatcher и рисует страницу ошибки ровно так же, как в любом другом окружении; исключение попадает в лог, а следующий же запрос тот самый процесс воркера обслуживает в обычном режиме. В Rapira непойманное исключение — сбой одного запроса, а не всего воркера; из-за чего воркер завершается, а из-за чего нет, разбирает Режим воркера.
CSRF
В шаблоне приложения CsrfTokenMiddleware стоит в цепочке middleware по умолчанию, а токен лежит в сессии — том самом состоянии, которое набор проверок как раз затрагивал: своё на каждый запрос и изолированное для каждого клиента. Цикл воркера в работу с токеном никак не вмешивается, поэтому POST требует здесь токен ровно так же, как и везде. Если после переезда на воркер POST-запросы стали отклоняться, проверьте сначала токен; лечится это обычным способом — отрисовать токен в форму и отправить обратно, — а не правкой скрипта воркера.
Классический режим как запасной вариант
Yii3 работает и обычным фронт-контроллером:
rapira serve --classic public/index.phpТот же код, никакого скрипта воркера, чистое состояние на каждый запрос. Подробности см. в разделе Классический режим.
Скрипт воркера — дополнительная точка входа, а не замена фронт-контроллеру, поэтому public/index.php стоит оставить: именно его запускает классический режим, и он по-прежнему удобен для локальной работы со встроенным сервером PHP.
В шаблонном public/index.php есть ветка PHP_SAPI === 'cli-server', которая отдаёт статику и переписывает SCRIPT_NAME. Она написана для встроенного сервера разработки PHP и под Rapira никогда не срабатывает, потому что PHP_SAPI здесь — rapira (на PHP 8.4 — fastcgi, см. Установку), так что её можно оставить как есть.