將產生的大型 PDF 以 HTTP 回應串流傳送
你在控制器內產生一份大型 PDF,並希望把這些位元組回傳給用戶端,而不必在回應緩衝區中再保留一份完整副本。每個 framework 整合都隨附串流版本的 PdfResponse 工廠(factory),也就是 streamInline() 與 streamDownload()。兩者都會回傳 framework 的 StreamedResponse,其回呼會以固定的 64 KB 分塊把 PDF 內容寫給用戶端。
在選擇這條路徑之前,請先務實確認它的記憶體模型。引擎會先在記憶體中建構完整文件。串流回呼會呼叫 getPdfData(),將整份 PDF 實體化為單一字串,再以 64 KB 的切片逐段走訪該字串。你省下的尖峰記憶體是那 第二 份副本,也就是緩衝式的 Illuminate\Http\Response 或 Symfony\Component\HttpFoundation\Response 在 framework 量測 Content-Length 時所持有的那一份。串流版本不會量測長度,因此會省略 Content-Length。它絕不會同時持有回應內容與文件字串。這 並非 真正的逐步增量串流:NextPDF 沒有增量寫入器介面,因此文件會在第一個位元組抵達 socket 之前就完整實體化。
先確認先決條件,避免任務進行到一半才被意外卡住:
- 已安裝 NextPDF 核心,且其中一個 framework 整合已安裝並被探索到,也就是
nextpdf/laravel或nextpdf/symfony。 - 你已經知道如何在你的 framework 中把請求路由到控制器。
- 你已讀過 從控制器回傳一份產生的 PDF,該文件涵蓋本範例所依據的緩衝式
inline()與download()工廠。
本操作指南聚焦於 Laravel 與 Symfony 共用的 StreamedResponse 模式。CodeIgniter 4 隨附相同的 streamInline() / streamDownload() 方法名稱,但它們會把位元組包進 CodeIgniter\HTTP\DownloadResponse,而非以回呼驅動的 StreamedResponse。邊界情況一節記錄了這項差異。
安裝與你的 framework 相符的整合。請執行下列其中一項。
composer require nextpdf/laravelcomposer require nextpdf/symfonyLaravel 安裝後請發布組態。
php artisan vendor:publish --tag=nextpdf-configSymfony 會透過 Flex 自動註冊此套件。繼續之前,請先在你的 framework 安裝頁面確認它已被探索到。
概念總覽
標題為「概念總覽」的區段緩衝式回應工廠,也就是 PdfResponse::download() 或 PdfResponse::inline(),會呼叫 getPdfData(),把回傳的字串存放在 Response 物件中,並把 Content-Length 設定為 strlen() 的值。接著 framework 會在整個回應生命週期內持有那個字串。對於大型文件,這表示文件字串與回應內容字串會同時存在於記憶體中。
串流工廠的行為則不同。PdfResponse::streamDownload() 與 PdfResponse::streamInline() 會回傳一個由回呼建構的 StreamedResponse。framework 只有在準備送出回應內容時,才會呼叫該回呼。在回呼內,整合會呼叫一次 getPdfData(),把回傳的字串切成 64 KB 分塊,並對每個分塊執行 echo,接著呼叫 flush()。它不會保留第二份持久的回應內容副本,也不會送出任何 Content-Length 標頭。
有兩項事實會影響本頁的每個決策:
- 建構是急切的,傳輸則是分塊的。
getPdfData()在NextPDF\Core\Document上會呼叫寫入器,並把整份 PDF 當作單一字串回傳。這 64 KB 分塊只決定已建構好的位元組如何離開程序。尖峰記憶體受限於一份完成文件的大小,而非一個小型串流視窗。 - 沒有
Content-Length。 串流版本若不先在回呼內建構內容,就無從得知內容長度,因此會省略該標頭。用戶端進度條、Range請求,或對長度敏感的 proxy 都看不到大小。當已知長度比省下回應副本更重要時,請選擇緩衝式的download()/inline()。
透過 framework 慣用的 resolve(解析)流程取得文件:
- Laravel:從容器解析
NextPDF\Contracts\DocumentFactoryInterface並呼叫create()。它會回傳全新的NextPDF\Core\Document,也就是串流工廠所接受的具體型別。 - Symfony:注入
NextPDF\Symfony\Service\PdfFactory並呼叫create()。它會回傳全新的NextPDF\Core\Document,並套用已設定的預設值。
API 介面
標題為「API 介面」的區段| 關注點 | Laravel | Symfony |
|---|---|---|
| 全新文件 | app(DocumentFactoryInterface::class)->create() | PdfFactory::create() |
| 串流式 inline | PdfResponse::streamInline($doc, $name) | PdfResponse::streamInline($doc, $name) |
| 串流式下載 | PdfResponse::streamDownload($doc, $name) | PdfResponse::streamDownload($doc, $name) |
| 回傳型別 | Symfony\Component\HttpFoundation\StreamedResponse | Symfony\Component\HttpFoundation\StreamedResponse |
| 回呼內的建構呼叫 | NextPDF\Core\Document::getPdfData() | NextPDF\Core\Document::getPdfData() |
| 分塊大小 | 64 KB(決定性的 str_split) | 64 KB(決定性的 substr 迴圈) |
Laravel 的 PdfResponse 位於 NextPDF\Laravel\Http\PdfResponse;Symfony 的則位於 NextPDF\Symfony\Http\PdfResponse。它們的串流工廠都回傳同一種 Symfony\Component\HttpFoundation\StreamedResponse 型別。兩者都會套用相同且固定的 Open Web Application Security Project(OWASP)回應強化標頭組合(X-Content-Type-Options: nosniff、X-Frame-Options: DENY、Content-Security-Policy: default-src 'none'、X-Robots-Tag: noindex, nofollow、Referrer-Policy: no-referrer),並且都會清理下載檔名。你不需要自行加上這些標頭。
兩個工廠都呼叫同一個底層核心介面,NextPDF\Core\Document::getPdfData(): string,它會建構並回傳整份 PDF 二進位內容。其對應方法 save(string $path): void 則透過原子寫入器把相同的位元組寫到磁碟。本範例使用 getPdfData(),因為目標是 HTTP socket,而不是檔案。
程式碼範例 — 快速上手
標題為「程式碼範例 — 快速上手」的區段以下是每個 framework 中最精簡的串流式下載動作。文件相關呼叫都使用同一個核心介面;只有控制器結構不同。串流工廠會把一個回呼交給 framework,因此你的動作會立即回傳。當 framework 送出回應時,才會建構並送出內容。
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use NextPDF\Contracts\DocumentFactoryInterface;use NextPDF\Laravel\Http\PdfResponse;use Symfony\Component\HttpFoundation\StreamedResponse;
final class ReportController extends Controller{ public function annualReport(): StreamedResponse { $document = app(DocumentFactoryInterface::class)->create(); $document->addPage(); $document->cell(0, 10, 'Annual report', newLine: true);
return PdfResponse::streamDownload($document, 'annual-report.pdf'); }}<?php
declare(strict_types=1);
namespace App\Controller;
use NextPDF\Symfony\Http\PdfResponse;use NextPDF\Symfony\Service\PdfFactory;use Symfony\Component\HttpFoundation\StreamedResponse;use Symfony\Component\Routing\Attribute\Route;
final class ReportController{ #[Route('/report', name: 'report_pdf')] public function annualReport(PdfFactory $pdf): StreamedResponse { $document = $pdf->create(); $document->addPage(); $document->cell(0, 10, 'Annual report', newLine: true);
return PdfResponse::streamDownload($document, 'annual-report.pdf'); }}若要在瀏覽器分頁中預覽而不是強制下載,請以 streamInline(...) 取代 streamDownload(...)。Content-Disposition 會變成 inline,其餘所有標頭都維持不變。
程式碼範例 — 正式環境
標題為「程式碼範例 — 正式環境」的區段正式環境的動作會注入相依項、驗證路由輸入、捕捉建構可能拋出的最具體例外、在不洩漏追蹤的前提下記錄失敗類別,並回傳已定義的 HTTP 錯誤。以下範例使用 Laravel 的建構子注入。Symfony 的對應寫法形貌相同,只是 PdfFactory 以逐動作注入。
getPdfData() 會在串流回呼內執行,因此其拋出的例外會在 framework 已開始送出標頭 之後 才浮現。為了讓錯誤處理仍然有意義,請在你把回應交回去 之前 先建構文件(也就是可能失敗的那一步),並在那裡捕捉建構失敗。如此一來,回呼內只會進行已建構位元組的分塊傳輸。
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Response;use NextPDF\Contracts\DocumentFactoryInterface;use NextPDF\Core\Document;use NextPDF\Exception\NextPdfException;use NextPDF\Laravel\Http\PdfResponse;use Psr\Log\LoggerInterface;use Symfony\Component\HttpFoundation\StreamedResponse;
final class StatementController extends Controller{ private const int MAX_STATEMENT_ID = 9_999_999;
public function __construct( private readonly DocumentFactoryInterface $documents, private readonly LoggerInterface $logger, ) {}
public function show(int $statementId): StreamedResponse|Response { // Validate input at the boundary before any build work runs. if ($statementId < 1 || $statementId > self::MAX_STATEMENT_ID) { return new Response('Invalid statement identifier.', 422); }
try { // Build the whole document up front. getPdfData(), invoked inside // the streamed callback, materializes the full PDF in memory, so // do the failure-prone build here, where the catch can still set a // clean HTTP status before any byte is sent. $document = $this->buildStatement($statementId); $document->getPdfData(); } catch (NextPdfException $exception) { // Log the exception class, never the message or a stack trace, so // internal detail does not leak into the log sink. $this->logger->error('Statement PDF build failed', [ 'statement_id' => $statementId, 'exception' => $exception::class, ]);
return new Response('Could not generate the statement PDF.', 500); }
// The build succeeded. The streamed factory rebuilds the bytes inside // its callback and flushes them to the client in 64 KB chunks. return PdfResponse::streamDownload( $document, "statement-{$statementId}.pdf", ); }
private function buildStatement(int $statementId): Document { $document = $this->documents->create(); $document->addPage(); $document->cell(0, 10, "Statement #{$statementId}", newLine: true);
return $document; }}若要用單一處理器處理任何建構失敗,請捕捉 NextPDF\Exception\NextPdfException,這是每個 NextPDF 例外都繼承的抽象基底類別。若要針對特定成因做出反應,請先捕捉 getPdfData() 可能拋出的具體子型別:當內容無法符合頁面幾何時的 NextPDF\Exception\PageLayoutException、當串流壓縮失敗時的 NextPDF\Exception\CompressionException,以及針對無效輸出組態的 NextPDF\Exception\InvalidConfigException。絕不要撰寫空的 catch 區塊。這裡的每個分支都會記錄失敗類別,並回傳已定義的狀態。
逐動作解析一份全新文件,可讓工廠在測試中保持可替換。不要在單一長時間執行的 worker 程序中,把同一個控制器實例重複用於兩份不相關的文件,否則過時的內容狀態會殘留並被沿用。
邊界情況與陷阱
標題為「邊界情況與陷阱」的區段- 在「先驗證再串流」模式中,文件會被建構兩次。 正式環境範例會先呼叫一次
getPdfData()以驗證建構,接著工廠又會在回呼內再呼叫一次。這就是把失敗點移到標頭之前所付出的代價。當某份文件的兩次建構成本太高時,請略過預先建構的探測,並接受回呼內的建構失敗會截斷已經開始送出的回應。 - 沒有
Content-Length。 串流版本會省略該標頭。下載進度條與Range請求都將無法運作。當需要已知長度時,請改用緩衝式的download()/inline()。 - 緩衝式 proxy 會抵銷這項好處。 若反向 proxy 或 PHP 輸出緩衝區在轉送之前先擷取整個內容,就會再次持有完整 PDF,抹去你省下的那份副本。請將 proxy 設定為串流轉送
application/pdf回應,或在該路徑上改用緩衝式回應。 - CodeIgniter 4 並非以回呼串流。 CodeIgniter 整合隨附相同的
streamInline()/streamDownload()方法名稱,但它們會回傳持有完整內容的CodeIgniter\HTTP\DownloadResponse,而非以回呼驅動的StreamedResponse。本頁的 StreamedResponse 模式僅適用於 Laravel 與 Symfony。 - 回傳之後不要再寫入內容。 串流回呼掌握輸出的所有權。不要自行使用
echo或寫入回應內容;也就是說,在你把StreamedResponse交回 framework 之後不要這麼做。 - 已簽署文件會快速失敗。 對已設定要做高階 PAdES 簽章的文件呼叫
getPdfData(),會拋出NextPDF\Exception\NotImplementedException,而不會輸出未簽署的檔案。請透過文件中所記載的簽署路徑串流已簽署的輸出,而非透過本範例。
串流所界定的是回應副本的上限,而不是文件建構。尖峰記憶體大約是一份完成 PDF 的大小,因為 getPdfData() 會在送出第一個分塊之前就實體化整份文件。對於真正大型或多頁的文件,主宰請求預算的是建構本身,而不是傳輸。請以佇列工作(queued job)把產生作業移出請求執行緒。請參閱 在佇列工作中產生 PDF。
那 64 KB 的分塊大小在兩個整合中都是固定且具決定性的。它只決定傳輸粒度,並不改變送出的總位元組數或尖峰記憶體。當限制條件是那份可省下的回應副本、且不需要進度條時,請選擇串流版本。對於小型、對延遲敏感,且可受惠於已知 Content-Length 的回應,請選擇緩衝版本。
安全性注意事項
標題為「安全性注意事項」的區段- 先驗證輸入再建構。 正式環境動作會在任何建構工作執行之前,以一個
422拒絕超出範圍的識別碼。絕不要將未經驗證的輸入插入建構內容或檔名中。 - 檔名清理已為你完成。 兩個串流工廠都會清理檔名,並加上 OWASP 回應強化標頭組合。請傳入一個你能掌控的值,並讓工廠的清理作為第二道防線。不要自行手動編碼檔名。
- 為並行記憶體設限。 由於每個請求都會在記憶體中實體化整份 PDF,大量並行流量會使尖峰記憶體成倍增加。請對驅動建構的輸入施加大小與速率限制,以緩解記憶體耗盡型阻斷服務攻擊。
- 記錄失敗類別,而非訊息。 catch 區塊會記錄
$exception::class與一個關聯識別碼,絕不記錄例外訊息或堆疊追蹤。在記錄端輸出原始追蹤本身就是資訊洩漏。 - 沒有空的 catch。 本頁的每個 catch 分支都會記錄並回傳已定義的錯誤回應。
符合性
標題為「符合性」的區段本指南未提出任何規範性標準主張。所示的每個類別、方法與標頭,都是所述整合已驗證的公開介面:NextPDF\Core\Document::getPdfData()、NextPDF\Laravel\Http\PdfResponse 與 NextPDF\Symfony\Http\PdfResponse 串流工廠,以及 Symfony\Component\HttpFoundation\StreamedResponse 回傳型別。工廠所套用的 OWASP 回應強化標頭語意及其引用出處,都記載於「另請參閱」所連結的各整合「安全與維運」頁面。本 cookbook 頁面僅重述用法,並將規範性引用出處留給那些頁面。
另請參閱
標題為「另請參閱」的區段- 從控制器回傳一份產生的 PDF:對應的緩衝式
inline()與download()寫法。 - 在佇列工作中產生 PDF:把建構移出請求執行緒。
- Laravel 正式環境用法:採用 DI 接線的控制器、OWASP 標頭組合,以及容器繫結契約。
- Symfony 正式環境用法:串流回呼、64 KB 分塊輸出器,以及建構器定位器。