Безопасная отрисовка 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
Заголовок раздела «Поверхность API»Этот рецепт использует поверхность 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) плюс сравнение только метаданных). Утверждать побитовую или структурную идентичность для вывода воркера было бы неточно.