跳转到内容

将生成的大型 PDF 作为 HTTP 响应流式返回

你在控制器中生成一份大型 PDF,并希望把这些字节返回给客户端,同时避免在响应缓冲区里再保留一份完整副本。每个 framework 集成都提供流式版本的 PdfResponse 工厂(factory),即 streamInline()streamDownload()。两者都会返回 framework 的 StreamedResponse,其回调会按固定的 64 KB 分块把 PDF 内容发送给客户端。

选择这条路径之前,需要先明确它的内存模型。引擎会先在内存中构建出完整文档。流式回调会调用 getPdfData(),将整份 PDF 实体化为单个字符串,再按 64 KB 切片遍历该字符串。你节省的峰值内存是那 第二 份副本,也就是缓冲式的 Illuminate\Http\ResponseSymfony\Component\HttpFoundation\Response 在 framework 测量 Content-Length 时持有的副本。流式版本不会测量长度,因此会省略 Content-Length。它绝不会同时持有响应内容和文档字符串。这 并非 真正的逐步增量流式输出:NextPDF 没有增量写入器接口,因此文档会在第一个字节抵达 socket 之前就完整实体化。

先明确这些前提条件,避免你做到一半才意外卡住:

  • 已安装 NextPDF 核心,并且其中一个 framework 集成已安装且已被自动发现,即 nextpdf/laravelnextpdf/symfony
  • 你已经知道如何在自己的 framework 中将请求路由到控制器。
  • 你已阅读 从控制器返回生成的 PDF,该文档涵盖了本示例所基于的缓冲式 inline()download() 工厂。

本操作指南聚焦于 Laravel 与 Symfony 共享的 StreamedResponse 模式。CodeIgniter 4 也提供相同的 streamInline() / streamDownload() 方法名称,但它们会把字节封装进 CodeIgniter\HTTP\DownloadResponse,而不是以回调驱动的 StreamedResponse。边界情况一节记录了这一差异。

安装与你的 framework 匹配的集成。执行下列命令之一。

Terminal window
composer require nextpdf/laravel
Terminal window
composer require nextpdf/symfony

对于 Laravel,安装后请发布配置。

Terminal window
php artisan vendor:publish --tag=nextpdf-config

Symfony 会通过 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,并应用已配置的默认值。
关注点LaravelSymfony
新建文档app(DocumentFactoryInterface::class)->create()PdfFactory::create()
流式 inlinePdfResponse::streamInline($doc, $name)PdfResponse::streamInline($doc, $name)
流式下载PdfResponse::streamDownload($doc, $name)PdfResponse::streamDownload($doc, $name)
返回类型Symfony\Component\HttpFoundation\StreamedResponseSymfony\Component\HttpFoundation\StreamedResponse
回调内的构建调用NextPDF\Core\Document::getPdfData()NextPDF\Core\Document::getPdfData()
分块大小64 KB(确定性的 str_split64 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: nosniffX-Frame-Options: DENYContent-Security-Policy: default-src 'none'X-Robots-Tag: noindex, nofollowReferrer-Policy: no-referrer),并且都会清理下载文件名。你无需自行添加这些头。

两个工厂都会调用同一个底层核心接口,NextPDF\Core\Document::getPdfData(): string,它会构建并返回整份 PDF 二进制内容。其姐妹方法 save(string $path): void 则通过原子写入器把相同的字节写到磁盘。本示例使用 getPdfData(),因为目标是 HTTP socket,而不是磁盘文件。

以下是每个 framework 中最精简的流式下载处理方法。与文档相关的调用都使用同一个核心接口;只有控制器脚手架不同。流式工厂会把一个回调交给 framework,因此你的处理方法会立即返回。等 framework 发送响应时,内容才会被构建并输出。

Laravel: app/Http/Controllers/ReportController.php
<?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');
}
}
Symfony: src/Controller/ReportController.php
<?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 之前 就构建好文档(也就是可能失败的那一步),并在那里捕获构建失败。这样一来,回调内只会发生已构建字节的分块传输。

Laravel: app/Http/Controllers/StatementController.php
<?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\PdfResponseNextPDF\Symfony\Http\PdfResponse 流式工厂,以及 Symfony\Component\HttpFoundation\StreamedResponse 返回类型。工厂应用的 OWASP 响应强化头语义及其引用出处,都记录在「另请参阅」下链接的各集成「安全与运维」页面中。本 cookbook 页面仅重述用法,并将规范性引用出处留给那些页面。