在長時間執行的 worker 中安全地產生 PDF
長時間執行的 PHP worker(RoadRunner、Swoole、Laravel Octane)會讓行程跨多個請求持續存活。在每個請求中都重新剖析相同字型、重新解碼相同影像,會浪費 CPU,並讓常駐記憶體持續成長。為了避免這種情況,NextPDF 將兩種生命週期分開:
- 行程生命週期、可共用:
FontRegistry與ImageRegistry保存已剖析的字型表與已解碼的影像快取。在 worker 啟動時建立一次即可。 - 請求生命週期、可拋棄:
Document,由DocumentFactory::create()回傳。建構它、寫出它,然後讓它離開作用域。接著垃圾回收器就會回收整個物件圖。
這份 recipe(範例)會說明 worker 的啟動序列、每個請求的主體,以及讓尖峰記憶體維持平穩的每週期 reset。
composer require nextpdf/core:^3worker 模式本身不需要任何額外的擴充套件,而 worker runtime(RoadRunner / Swoole / Octane)則是選用的。同樣的 factory(工廠)模式也能在單純的 CLI for 迴圈中執行,這正是測試載具所驗證的情境。
概念總覽
標題為「概念總覽」的區段對 worker 而言,建議的進入點是 DocumentFactory。請用共用的 FontRegistry 與 ImageRegistry 建構它一次:
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)會跨請求保持穩定,因為已剖析的字型存在於共用的登錄表中。
API 介面
標題為「API 介面」的區段這份 recipe 的 API 介面是從 NextPDF\Core\DocumentFactory、NextPDF\Typography\FontRegistry、NextPDF\Graphics\ImageRegistry 與 NextPDF\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_budgetfront-matter(wall_ms: 4000、peak_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 輸出而言,逐位元組或結構性的宣稱都會是不誠實的。