Pular para o conteúdo

Renderize PDFs com segurança em um worker de longa duração

Um worker PHP (PHP: Hypertext Preprocessor) de longa duração (RoadRunner, Swoole, Laravel Octane) mantém um único processo ativo por muitas requisições. Se você analisar as mesmas fontes e decodificar as mesmas imagens a cada requisição, desperdiça tempo de processador e aumenta a memória residente. O NextPDF evita esse custo separando dois ciclos de vida:

  • Ciclo de vida do processo, compartilhado: FontRegistry e ImageRegistry mantêm tabelas de fontes analisadas e caches de imagens decodificadas. Crie os registros uma única vez quando o worker for inicializado.
  • Ciclo de vida da requisição, descartável: o Document retornado por DocumentFactory::create(). Construa-o, salve-o e deixe-o sair de escopo. O coletor de lixo do PHP pode então recuperar todo o grafo de objetos.

Esta receita mostra a sequência de inicialização do worker, o corpo executado a cada requisição e o reset por ciclo que mantém o pico de memória estável.

Terminal window
composer require nextpdf/core:^3

O padrão de worker não requer nenhuma outra extensão, e um runtime de worker (RoadRunner / Swoole / Octane) é opcional. Você pode executar o mesmo padrão de fábrica em um loop for pela interface de linha de comando (CLI), que é o que o harness testa.

Em código de worker, comece com DocumentFactory. Construa-o uma única vez com um FontRegistry e um ImageRegistry compartilhados:

  • FontRegistry::warmup() analisa os arquivos de fonte que você fornece e armazena em cache as tabelas analisadas. FontRegistry::lock() congela o registro para que o código executado por requisição não possa modificar o conjunto de fontes compartilhado. isLocked() informa o estado atual. Depois de bloquear o registro, é seguro compartilhá-lo entre corrotinas concorrentes.
  • Construa o ImageRegistry com um orçamento de maxCacheBytes. Quando o orçamento é excedido, ele descarta as entradas usadas menos recentemente. Uma imagem maior que o orçamento ignora o cache em vez de sobrecarregá-lo.
  • ImageRegistry::reset() descarta todas as imagens em cache enquanto mantém o registro pronto para uso. A próxima requisição o repopula sob demanda. Chame-o em uma cadência (a cada N requisições, ou quando memoryUsage() cruza um limite) para trazer o pico máximo de volta à linha de base.

Cada documento criado pela fábrica é um arquivo Portable Document Format (PDF) independente. A ISO 32000-2 §7.5.5 define o trailer de um arquivo nunca atualizado como um trailer sem entrada Prev, e cada requisição do worker emite esse tipo de arquivo de primeira geração. Portanto, as requisições não compartilham estado de documento, mesmo que compartilhem os caches de fontes e imagens. A tag BaseFont de fonte de subconjunto (ISO 32000-2 §9.6.4) permanece estável entre requisições porque a fonte analisada reside no registro compartilhado.

Esta receita usa a superfície da API gerada a partir do PHPDoc em NextPDF\Core\DocumentFactory, NextPDF\Typography\FontRegistry, NextPDF\Graphics\ImageRegistry e NextPDF\Support\MemoryReport. Os membros principais são DocumentFactory::create(), FontRegistry::warmup() / lock() / isLocked() / memoryUsage(), ImageRegistry::reset() / memoryUsage() e 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.

O exemplo completo respeita o canal de saída do harness. Ele mostra a sequência de inicialização, um loop limitado de requisições, o reset() por ciclo e uma asserção de pico máximo de memória. Este é o script que o harness de reprodutibilidade executa duas vezes.

<?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 permanece livre para o harness; o texto de progresso vai para STDERR. O PDF é gravado somente em NEXTPDF_COOKBOOK_OUTPUT; ele nunca é exibido na saída.

  • Bloqueie antes de compartilhar. Chame FontRegistry::lock() na inicialização. Um registro que ainda é mutável quando duas corrotinas o acessam é uma condição de corrida de dados. Use isLocked() como a asserção em uma verificação de integridade.
  • reset() não é unset(). ImageRegistry::reset() descarta os dados binários em cache e mantém o registro utilizável, por isso é a chamada periódica correta. Se você destruir e reconstruir o registro a cada requisição, perde o benefício do cache compartilhado.
  • Desvio de imagem superdimensionada. Uma imagem maior que maxCacheBytes é decodificada a cada uso e nunca armazenada em cache, de modo que não pode expulsar o conjunto de trabalho. Isso é intencional. Dimensione o orçamento para as imagens comuns, não para a rara imagem grande.
  • O documento deve sair de escopo. Se você mantiver o Document em uma propriedade estática, em um binding de container de longa duração ou em uma closure capturada pelo worker, todo o grafo de objetos permanece vivo e a coleta por requisição não funciona. Uma chamada a unset() ou a saída de escopo é obrigatória.
  • Posicionamento de gc_collect_cycles(). O coletor de ciclos do PHP não conhece os limites de requisição. Chame-o após a cadência de reset, não a cada requisição. Isso limita o pico máximo sem adicionar custo de coleta ao caminho crítico.
  • Ressalva sobre determinismo. Os carimbos de data/hora do documento e o /ID do trailer são regenerados a cada gravação (ISO 32000-2 §14.3). Portanto, o PDF capturado é comparado com o perfil semântico (árvore de sintaxe abstrata (AST) estrutural mais metadados, nunca bytes voláteis). Consulte “Conformidade”.
  • O registro compartilhado transforma a análise repetida de fontes e a decodificação de imagens em um custo único de inicialização. Depois disso, o trabalho por requisição passa a ser layout e serialização.
  • O pico de memória residente é limitado por maxCacheBytes mais o conjunto de trabalho de um documento em andamento. O reset() por ciclo retorna o cache à linha de base, de modo que um worker de longa duração não apresente um padrão dente de serra com tendência de crescimento.
  • O front-matter performance_budget (wall_ms: 4000, peak_mb: 192) limita a execução do harness no loop de 12 requisições. O harness impõe esse orçamento; ele não é uma garantia para documentos arbitrários.
  • Esta receita fornece a cobertura “memory/GC” da lista de lacunas da §4.3 para a #31. O examples/14-worker-factory.php de apoio existe, e tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php adiciona a asserção de memory/GC que faltava (o pico não cresce entre ciclos depois do reset).
  • O padrão de worker processa um documento por requisição e compartilha apenas os caches de fontes analisadas e imagens decodificadas. O conteúdo do documento não cruza o limite da requisição. Uma requisição não pode ler os dados de documento de outra requisição por meio dos registros compartilhados.
  • A entrada não confiável continua passando pelos limites de entrada normais do NextPDF, e o padrão de worker não flexibiliza a validação. Trate a entrada de HyperText Markup Language (HTML) e os assets de cada requisição como não confiáveis, exatamente como faria em um processo por requisição.
DeclaraçãoEspecificaçãoCláusulareference_id
A data de modificação do documento é regenerada a cada gravação, de modo que a saída por requisição não é estável byte a byte.ISO 32000-2§14.3
Cada documento do worker é um arquivo nunca atualizado (sem Prev no trailer); as requisições não compartilham estado de documento.ISO 32000-2§7.5.5
O prefixo da tag de fonte de subconjunto é estável entre requisições porque a fonte analisada reside no registro compartilhado.ISO 32000-2§9.6.4

Como o /ID do trailer e a data de modificação são regenerados a cada gravação, esta receita é verificada com o perfil de reprodutibilidade semântico (igualdade de árvore de sintaxe abstrata (AST) estrutural mais uma comparação apenas de metadados). Uma afirmação byte a byte ou estrutural seria imprecisa para a saída do worker.