将生成的大型 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/symfony对于 Laravel,安装后请发布配置。
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 已开始发送头 之后 才浮现。为了让错误处理保持有意义,请在你把响应返回给 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 分块输出器,以及构建器定位器。