Перейти к содержимому

Безопасная отрисовка PDF в долгоживущих воркерах

Долгоживущий PHP-воркер (PHP: Hypertext Preprocessor; RoadRunner, Swoole, Laravel Octane) держит один процесс активным для множества запросов. Если при каждом запросе разбирать одни и те же шрифты и декодировать одни и те же изображения, вы напрасно тратите процессорное время и увеличиваете объём резидентной памяти. NextPDF избегает этих затрат, разделяя два жизненных цикла:

  • Жизненный цикл процесса, совместное использование: FontRegistry и ImageRegistry хранят разобранные таблицы шрифтов и декодированные кеши изображений. Создавайте эти реестры один раз при запуске воркера.
  • Жизненный цикл запроса, одноразовое использование: объект Document, который возвращает DocumentFactory::create(). Создайте его, запишите и дайте выйти из области видимости. После этого сборщик мусора PHP сможет освободить весь граф объектов.

Этот рецепт показывает последовательность запуска воркера, тело обработки одного запроса и сброс в каждом цикле, который удерживает пиковое потребление памяти на постоянном уровне.

Окно терминала
composer require nextpdf/core:^3

Шаблон воркера не требует дополнительных расширений, а среда выполнения воркера (RoadRunner / Swoole / Octane) необязательна. Тот же шаблон фабрики можно использовать в цикле for в интерфейсе командной строки (CLI) — именно это проверяет испытательный стенд.

В коде воркера начните с DocumentFactory. Создайте его один раз с общими FontRegistry и ImageRegistry:

  • FontRegistry::warmup() разбирает предоставленные вами файлы шрифтов и кеширует разобранные таблицы. FontRegistry::lock() замораживает реестр, чтобы код обработки запроса не мог изменить общий набор шрифтов. isLocked() сообщает текущее состояние. После блокировки реестр можно безопасно использовать совместно в нескольких параллельных корутинах.
  • Создайте ImageRegistry с бюджетом maxCacheBytes. При превышении бюджета он вытесняет записи, которые использовались давнее всего. Изображение, превышающее бюджет, обходит кеш, вместо того чтобы перегружать его.
  • ImageRegistry::reset() вытесняет все кешированные изображения, при этом реестр остаётся готовым к использованию. Следующий запрос заново заполняет его по мере необходимости. Вызывайте этот метод с заданной периодичностью (каждые N запросов или когда memoryUsage() пересекает порог), чтобы вернуть пиковую отметку к базовому уровню.

Каждый документ, который создаёт фабрика, является независимым файлом Portable Document Format (PDF). ISO 32000-2 §7.5.5 определяет, что трейлер файла, который ни разу не обновлялся, не содержит записи Prev, и каждый запрос воркера создаёт именно такой файл первого поколения. Поэтому запросы не используют совместно состояние документа, хотя и разделяют кеши шрифтов и изображений. Тег BaseFont подмножества шрифта (ISO 32000-2 §9.6.4) остаётся неизменным между запросами, поскольку разобранный шрифт хранится в общем реестре.

Этот рецепт использует поверхность API, сгенерированную из PHPDoc для NextPDF\Core\DocumentFactory, NextPDF\Typography\FontRegistry, NextPDF\Graphics\ImageRegistry и NextPDF\Support\MemoryReport. Ключевые члены: DocumentFactory::create(), FontRegistry::warmup() / lock() / isLocked() / memoryUsage(), ImageRegistry::reset() / memoryUsage(), а также MemoryReport::$currentBytes / $peakBytes / $entryCount / utilizationPercent().

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot (run ONCE, before the request loop) ---------------------
$fonts = new FontRegistry();
$fonts->lock(); // freeze the shared font set
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
// --- Per request ---------------------------------------------------------
$doc = $factory->create();
$doc->setTitle('Worker output');
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, 'Generated in a shared-registry worker', newLine: true);
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
// $doc leaves scope here → GC reclaims the whole document tree.

Полный пример соблюдает требования испытательного стенда к выходному каналу. Он показывает последовательность запуска, ограниченный цикл запросов, reset() в каждом цикле и проверку пиковой отметки памяти. Именно этот сценарий стенд проверки воспроизводимости запускает дважды.

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot: shared, process-lifetime registries --------------------
$fonts = new FontRegistry();
$fonts->lock(); // share-safe once locked
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
$resetEvery = 4; // reset cadence in requests
$peakAfterReset = 0;
// --- Simulated request loop ---------------------------------------------
for ($request = 1; $request <= 12; $request++) {
$doc = $factory->create();
$doc->setTitle("Worker Request #{$request}");
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, "Worker Request #{$request}", newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->cell(0, 8, 'Shared FontRegistry / ImageRegistry across requests.', newLine: true);
// The harness captures the LAST request's PDF via the side channel.
if ($request === 12) {
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
} else {
$doc->getPdfData(); // force render, then drop
}
unset($doc); // explicit end-of-request
// Bound the cache high-water mark on a fixed cadence.
if ($request % $resetEvery === 0) {
$images->reset();
\gc_collect_cycles();
$report = $images->memoryUsage();
$peakAfterReset = \max($peakAfterReset, $report->currentBytes);
}
}
$final = $images->memoryUsage();
fwrite(STDERR, \sprintf(
"fonts.locked=%s images.entries=%d images.current=%dB peak_after_reset=%dB\n",
$fonts->isLocked() ? 'yes' : 'no',
$final->entryCount,
$final->currentBytes,
$peakAfterReset,
));

STDOUT остаётся свободным для стенда; сообщения о ходе выполнения направляются в STDERR. PDF записывается только в NEXTPDF_COOKBOOK_OUTPUT; он никогда не выводится.

  • Блокируйте перед совместным использованием. Вызывайте FontRegistry::lock() при запуске. Реестр, который всё ещё можно изменять, когда к нему обращаются две корутины, создаёт гонку данных. Используйте isLocked() как проверку в health check.
  • reset() — это не unset(). ImageRegistry::reset() вытесняет кешированные двоичные данные и оставляет реестр пригодным к использованию, поэтому это правильный периодический вызов. Если уничтожать и пересоздавать реестр для каждого запроса, вы теряете преимущество общего кеша.
  • Обход кеша для слишком больших изображений. Изображение, превышающее maxCacheBytes, декодируется при каждом использовании и никогда не кешируется, поэтому оно не может вытеснить рабочий набор. Это сделано намеренно. Подбирайте бюджет под обычные изображения, а не под редкое крупное.
  • Документ должен выйти из области видимости. Если вы удерживаете Document в статическом свойстве, долгоживущей привязке контейнера или замыкании, захваченном воркером, весь граф объектов остаётся в памяти, и сборка для каждого запроса не работает. Вызов unset() или выход из области видимости обязателен.
  • Размещение gc_collect_cycles(). Сборщик циклов PHP ничего не знает о границах запросов. Вызывайте его после цикла сброса, а не при каждом запросе. Это ограничивает пиковую отметку, не добавляя затраты на сборку в горячий путь.
  • Оговорка о детерминированности. Временные метки документа и /ID в трейлере генерируются заново при каждом сохранении (ISO 32000-2 §14.3). Поэтому захваченный PDF сравнивается по семантическому профилю (структурное абстрактное синтаксическое дерево (AST) плюс метаданные, но не нестабильные байты). См. раздел “Соответствие”.
  • Общий реестр превращает повторный разбор шрифтов и декодирование изображений в однократные затраты при запуске. Тогда работа для каждого запроса сводится к компоновке макета и сериализации.
  • Пиковая резидентная память ограничена значением maxCacheBytes плюс рабочий набор одного обрабатываемого документа. reset() в каждом цикле возвращает кеш к базовому уровню, поэтому долгоживущий воркер не показывает восходящего пилообразного тренда.
  • Фронтматтер performance_budget (wall_ms: 4000, peak_mb: 192) ограничивает прогон стенда для цикла из 12 запросов. Стенд проверяет соблюдение этого бюджета; это не гарантия для произвольных документов.
  • Этот рецепт обеспечивает покрытие “memory/GC” из списка пробелов §4.3 для #31. Сопутствующий examples/14-worker-factory.php существует, а tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php добавляет недостающую проверку memory/GC (пик не растёт между циклами после сброса).
  • Шаблон воркера обрабатывает один документ на запрос и совместно использует только кеши разобранных шрифтов и декодированных изображений. Содержимое документа не пересекает границу запроса. Запрос не может прочитать данные документа другого запроса через общие реестры.
  • Недоверенные входные данные по-прежнему проходят через обычные входные границы NextPDF, и шаблон воркера не ослабляет проверку. Считайте HyperText Markup Language (HTML) и входные ресурсы каждого запроса недоверенными, как и в процессе, который обрабатывает один запрос.
УтверждениеСпецификацияПунктreference_id (идентификатор ссылки)
Дата изменения документа генерируется заново при каждом сохранении, поэтому вывод на каждый запрос не является байт-стабильным.ISO 32000-2§14.3
Каждый документ воркера — это файл, который ни разу не обновлялся (без Prev в трейлере); запросы не используют совместно состояние документа.ISO 32000-2§7.5.5
Префикс тега подмножества шрифта остаётся неизменным между запросами, поскольку разобранный шрифт хранится в общем реестре.ISO 32000-2§9.6.4

Поскольку /ID в трейлере и дата изменения генерируются заново при каждом сохранении, этот рецепт проверяется по семантическому профилю воспроизводимости (равенство структурного абстрактного синтаксического дерева (AST) плюс сравнение только метаданных). Утверждать побитовую или структурную идентичность для вывода воркера было бы неточно.