跳到內容

在長時間執行的 worker 中安全地產生 PDF

長時間執行的 PHP worker(RoadRunner、Swoole、Laravel Octane)會讓行程跨多個請求持續存活。在每個請求中都重新剖析相同字型、重新解碼相同影像,會浪費 CPU,並讓常駐記憶體持續成長。為了避免這種情況,NextPDF 將兩種生命週期分開:

  • 行程生命週期、可共用: FontRegistryImageRegistry 保存已剖析的字型表與已解碼的影像快取。在 worker 啟動時建立一次即可。
  • 請求生命週期、可拋棄: Document,由 DocumentFactory::create() 回傳。建構它、寫出它,然後讓它離開作用域。接著垃圾回收器就會回收整個物件圖。

這份 recipe(範例)會說明 worker 的啟動序列、每個請求的主體,以及讓尖峰記憶體維持平穩的每週期 reset。

Terminal window
composer require nextpdf/core:^3

worker 模式本身不需要任何額外的擴充套件,而 worker runtime(RoadRunner / Swoole / Octane)則是選用的。同樣的 factory(工廠)模式也能在單純的 CLI for 迴圈中執行,這正是測試載具所驗證的情境。

對 worker 而言,建議的進入點是 DocumentFactory。請用共用的 FontRegistryImageRegistry 建構它一次:

  • FontRegistry::warmup() 會剖析你指定的字型檔,並快取剖析後的字型表。接著 FontRegistry::lock() 會凍結登錄表,讓每請求的程式碼都無法變更共用的字型集合。isLocked() 會回報目前狀態。一旦鎖定,這個登錄表就能安全地跨多個並行的共常式共用。
  • 建構 ImageRegistry 時,請設定一個 maxCacheBytes 預算。超出該預算時,它會逐出最近最少使用的項目。單一張大於預算的影像會完全略過快取,不會把快取衝垮。
  • ImageRegistry::reset() 會逐出每一張已快取的影像,同時讓登錄表保持完全可用。下一個請求會依需求重新填入快取。請依固定頻率呼叫它(每 N 個請求一次,或當 memoryUsage() 越過某個門檻時),讓記憶體高水位回到基準線。

factory 建立的每一份文件都是獨立的 PDF。ISO 32000-2 §7.5.5 將從未更新過的檔案尾隨資料定義為沒有 Prev 項目,而每一個 worker 請求所產出的正是這樣一份第一代檔案。因此,各請求之間並不共用文件狀態,即使它們確實共用字型與影像快取。子集字型的 BaseFont 標籤(ISO 32000-2 §9.6.4)會跨請求保持穩定,因為已剖析的字型存在於共用的登錄表中。

這份 recipe 的 API 介面是從 NextPDF\Core\DocumentFactoryNextPDF\Typography\FontRegistryNextPDF\Graphics\ImageRegistryNextPDF\Support\MemoryReport 上的 PHPDoc 產生的。下方用到的關鍵成員包括 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() 作為斷言。
  • reset() 不是 unset() ImageRegistry::reset() 會逐出已快取的二進位資料,但讓登錄表維持可用,所以它才是正確的週期性呼叫。在每個請求都銷毀並重建登錄表,會徹底違背共用快取的整個用意。
  • 超大影像的略過機制。 大於 maxCacheBytes 的影像會在每次使用時解碼,且絕不快取,因此它無法逐出工作集。這是刻意設計的。請依你常見的影像大小來設定預算,而非依罕見的大型影像。
  • 文件必須離開作用域。Document 留在靜態變數、長期存活的容器繫結,或被 worker 捕捉的閉包中,會讓整個物件圖持續存活,並破壞每請求的回收機制。一次 unset() 呼叫或一次作用域結束是必要的。
  • gc_collect_cycles() 的擺放位置。 PHP 的循環回收器並不知道請求邊界的存在。請在 reset 頻率之後呼叫它,而不是在每個請求都呼叫。這能讓記憶體高水位維持有界,同時不必在熱路徑上付出回收成本。
  • 決定性的注意事項。 文件時間戳記與尾隨資料的 /ID 會在每次儲存時重新產生(ISO 32000-2 §14.3)。因此擷取到的 PDF 會以語意設定檔來比對(結構化 AST 加上中繼資料,絕不比對易變的位元組)。請參閱「符合性」一節。
  • 共用登錄表會把重複的字型剖析與影像解碼,轉化為一次性的啟動成本。如此一來,每請求的工作就只剩下版面配置與序列化。
  • 尖峰常駐記憶體的上限是 maxCacheBytes 加上一份處理中文件的工作集。每週期的 reset() 會把快取拉回基準線,因此長期存活的 worker 不會呈現持續上升的鋸齒狀曲線。
  • 這個 performance_budget front-matter(wall_ms: 4000peak_mb: 192)會為這個 12 個請求迴圈的測試載具執行設定上限。測試載具會強制執行這項上限;它並非對任意文件的保證。
  • 這份 recipe 為 §4.3 缺口清單的「memory/GC」項目提供 #31 的涵蓋範圍。作為後盾的 examples/14-worker-factory.php 已存在,而新增的 tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php 補上了缺少的 memory/GC 斷言(尖峰記憶體在 reset 之後不會跨週期成長)。
  • worker 模式在每個請求只處理一份文件,且只共用已剖析的字型快取與已解碼的影像快取。沒有任何文件內容會跨越請求邊界。一個請求無法透過共用的登錄表讀取另一個請求的文件資料。
  • 未受信任的輸入仍會流經正常的 NextPDF 輸入邊界,而 worker 模式不會放寬任何驗證。請將每個請求的 HTML/資產輸入都視為未受信任,就如同你在每請求各自獨立的行程中所做的一樣。
陳述規範條款參考 ID
文件的修改日期會在每次儲存時重新產生,因此每請求輸出並非位元組穩定。ISO 32000-2§14.3
每一份 worker 文件都是從未更新過的檔案(尾隨資料中沒有 Prev);各請求之間不共用文件狀態。ISO 32000-2§7.5.5
子集字型的標籤前綴會跨請求保持穩定,因為已剖析的字型存在於共用的登錄表中。ISO 32000-2§9.6.4

由於尾隨資料的 /ID 與修改日期會在每次儲存時重新產生,這份 recipe 是以語意可重現性設定檔來驗證(結構化 AST 相等性加上僅比對中繼資料的比較)。對 worker 輸出而言,逐位元組或結構性的宣稱都會是不誠實的。