Skip to content

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
<?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
<?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 откатить не сможет. Переход с одной схемы на другую меняет только скрипт воркера.

Как это запустить

bash
rapira serve worker.php

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

Для продакшена перенесите настройки в rapira.toml:

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 работает и обычным фронт-контроллером:

bash
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, см. Установку), так что её можно оставить как есть.