Lift v1.3.0
Внутреннее устройство

Архитектура

Как загружается приложение Lift, как запрос проходит сквозь него и как контейнер разрешает зависимости. Никаких сюрпризов — всё явно.

Жизненный цикл запроса

С момента, когда PHP получает HTTP-запрос, до момента, когда отправлен ответ.

HTTP-запрос
PHP получает сырой запрос (FPM, RoadRunner, Swoole, FrankenPHP)
PSR-7 ServerRequest
Lift оборачивает суперглобали PHP в неизменяемый объект PSR-7
Стек глобального middleware
Каждый зарегистрированный глобальный middleware выполняется в порядке регистрации
Маршрутизатор
Статические маршруты → хеш-поиск за O(1). Динамические → скомпилированный regex
Middleware маршрута
Помаршрутный / погрупповой middleware выполняется внутри глобального стека
Разрешение обработчика
DI-контейнер автосвязывает зависимости конструктора; обработчик вызывается
Авто-приведение ответа
array → JSON 200, string → HTML 200, null → 204, Response → как есть
PSR-7 Response
Неизменяемый объект ответа передаётся обратно вверх по цепочке middleware
Отправка
Заголовки + тело записываются клиенту (или возвращаются рантайму)

Конвейер middleware

Луковичная модель PSR-15 — каждый слой оборачивает следующий. Самый внутренний слой — обработчик маршрута.

// Запрос течёт внутрь ↓, ответ течёт наружу ↑
┌─ GlobalMiddleware A ────────────────────┐
│  ┌─ GlobalMiddleware B ───────────────┐ │
│  │  ┌─ GroupMiddleware ─────────────┐ │ │
│  │  │  ┌─ RouteMiddleware ────────┐ │ │ │
│  │  │  │  ┌─ Handler ──────────┐ │ │ │ │
│  │  │  │  │   return $response  │ │ │ │ │
│  │  │  │  └────────────────────── ┘ │ │ │ │
│  │  │  └────────────────────────────┘ │ │ │
│  │  └──────────────────────────────── ┘ │ │
│  └────────────────────────────────────── ┘ │
└────────────────────────────────────────────┘
регистрация middleware
// 1. Глобальный — каждый запрос
$app->use(LoggingMiddleware::class);
$app->use(CorsMiddleware::class);

// 2. Группа — только /api/*
$app->group('/api', function ($g) {
    $g->middleware(AuthMiddleware::class);

    // 3. Маршрут — только этот эндпоинт
    $g->get('/admin', AdminHandler::class)
        ->middleware(RequireAdminMiddleware::class);
});
Контракт PSR-15: Каждый middleware реализует MiddlewareInterface::process(ServerRequestInterface, RequestHandlerInterface). Он может прервать цепочку, вернув ответ напрямую — внутренние слои тогда не вызываются. Middleware аутентификации и ограничения частоты используют это, чтобы отклонять неаутентифицированные запросы до запуска любого обработчика.

Устройство маршрутизатора

Двухуровневая диспетчеризация: хеш-карта за O(1) для статических маршрутов и просмотр скомпилированных regex по каждому маршруту для динамических.

Уровень 1 — статические маршруты

GET /, POST /auth/login — маршруты без параметров хранятся во вложенном массиве по ключу [method][path]. Разрешение это одно чтение массива: O(1), без сканирования строк.

$staticRoutes['GET']['/'] = $handler;
// поиск: isset($staticRoutes[$method][$path])
Уровень 2 — динамические маршруты

Маршруты с параметрами (/users/{id:\d+}) каждый компилирует свой regex один раз — кэшируется на объекте маршрута, не перестраивается на каждый запрос. Диспетчеризация просматривает динамические маршруты по порядку, проверяя каждый скомпилированный regex, пока один не совпадёт.

// regex на маршрут, скомпилирован и закэширован один раз
~^/users/(?<id>\d+)$~
Ограничения маршрута: Встроенные regex-ограничения ({id:\d+}) запекаются в скомпилированный regex — маршрут совпадает только когда ограничение проходит, поэтому дополнительный шаг валидации в вашем обработчике не нужен.

DI-контейнер

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

bind()

Фабрика вызывается каждый раз при вызове make(). Используйте для безсостоятельных сервисов.

$app->bind(Mailer::class, fn() => new Mailer($_ENV));
singleton()

Фабрика вызывается один раз; экземпляр кэшируется и переиспользуется при каждом последующем make().

$app->singleton(DB::class, fn() => new DB($_ENV['DSN']));
instance()

Хранит уже построенный объект напрямую. Полезно, когда вы конструируете значение до его регистрации.

$app->instance(Config::class, $config);

Алгоритм автосвязывания

Когда make(ClassName::class) вызывается без зарегистрированной привязки:

1
Проверить реестр привязок
Если существует bind/singleton/instance → использовать его. Без рефлексии.
2
Проверить кэш рефлексии
Параметры конструктора кэшируются после первого разрешения. Без повторного чтения на повторных вызовах.
3
Прочитать параметры конструктора
ReflectionClass осматривает подсказки типов. Примитивы без значений по умолчанию → ContainerException.
4
Рекурсивно разрешить зависимости
Каждый типизированный параметр разрешается через make() — всё дерево связывается автоматически.
5
Создать экземпляр
new ClassName(...$resolvedArgs) — стандартное создание PHP, без прокси-объектов.
6
Закэшировать результат рефлексии
Список параметров сохраняется; следующий make() полностью пропускает шаги 2–4.

Загрузка

Что происходит между new App() и обслуживанием первого запроса.

1 new App()

Создаёт пустой контейнер PSR-11 и пустой маршрутизатор. Ни один сервис ещё не создан — всё ленивое.

2 bind / singleton / instance

Регистрирует фабрики-замыкания в контейнере. Фабрики на этом этапе НЕ вызываются.

3 get / post / group

Регистрирует определения маршрутов в маршрутизаторе. Callable-обработчики на этом этапе НЕ вызываются.

4 run() / RoadRunner serve()

На FPM: строит PSR-7 ServerRequest из $_SERVER/$_FILES/$_POST. На персистентных воркерах: получает уже построенный запрос от рантайма.

5 Выполняется конвейер middleware

Глобальный middleware разрешается из контейнера (лениво), маршрутизатор диспетчеризует, обработчик автосвязывается и вызывается.

6 Ответ отправлен

На FPM: заголовки + тело записываются через header()/echo. На воркерах: передаётся обратно в цикл рантайма.

bootstrap.php (типичный)
<?php
require 'vendor/autoload.php';
use Lift\App;

// ① Контейнер создан, маршрутизатор создан
$app = new App();

// ② Фабрики зарегистрированы — пока ничего не создано
$app->singleton(DB::class, fn() => new DB($_ENV['DSN']));
$app->singleton(Mailer::class, fn() => new Mailer($_ENV));

// ③ Маршруты зарегистрированы — обработчики ещё не вызваны
$app->use(LoggingMiddleware::class);

$app->group('/api', function ($g) {
    $g->middleware(AuthMiddleware::class);
    $g->get('/users', [UserController::class, 'index']);
    $g->post('/users', [UserController::class, 'store']);
});

// ④ Получен первый запрос → выполняется конвейер
$app->run();

Модель памяти

Критическое различие между php-fpm и рантаймами с персистентным воркером — и что это значит для вашего кода.

php-fpm (традиционный) на запрос
// Запрос 1
загрузка → new App()new DB() → обработка → 💀 уничтожить всё
// Запрос 2
загрузка → new App()new DB() → обработка → 💀 уничтожить всё
// Запрос N — та же история
  • Полная стоимость загрузки на каждом запросе
  • Никакого общего состояния между запросами
  • Память освобождается автоматически — утечки невозможны
RoadRunner / Swoole / FrankenPHP персистентный
// Загрузка один раз
new App()new DB()✓ остаются в памяти
// Запрос 1..N — переиспользуем прогретое состояние
приём → обработка → отправка → цикл
приём → обработка → отправка → цикл
приём → обработка → отправка → цикл
  • Стоимость загрузки оплачивается один раз — не на каждый запрос
  • Соединения с БД, кэши, скомпилированные маршруты остаются прогретыми
  • Общие синглтоны сохраняются — не храните в них состояние уровня запроса

Написание кода, безопасного для воркеров

Синглтоны Lift переживают запросы. Держите их безсостоятельными или только дополняемыми.

Безопасно — безсостоятельный синглтон
class DB {
    private \PDO $pdo;
    public function __construct(string $dsn) {
        // соединение разделяется — нормально
        $this->pdo = new \PDO($dsn);
    }
    public function query(string $sql): array {
        // безсостоятельный вызов — безопасно
        return $this->pdo->query($sql)->fetchAll();
    }
}
Небезопасно — состояние запроса в синглтоне
class UserContext {
    // ⚠ переживает запросы в воркерах
    public ?User $current = null;

    public function setUser(User $u): void {
        $this->current = $u; // утекает в следующий запрос!
    }
}
// Решение: используйте атрибуты запроса:
// $request->withAttribute('user', $user)