Перейти к содержимому

Потоковая передача большого сгенерированного PDF как HTTP-ответа

Вы генерируете большой PDF внутри контроллера и хотите вернуть байты, не сохраняя вторую полную копию в буфере ответа. Каждая интеграция с фреймворком включает потоковые варианты фабрики PdfResponse: streamInline() и streamDownload(). Оба метода возвращают StreamedResponse фреймворка с обратным вызовом, который записывает тело PDF клиенту фиксированными блоками по 64 КБ.

Прежде чем выбрать этот путь, разберитесь с моделью памяти. Движок сначала полностью строит документ в памяти. Потоковый обратный вызов обращается к getPdfData(), который материализует весь PDF как одну строку, а затем проходит по ней срезами по 64 КБ. Так вы экономите пиковые накладные расходы на вторую копию, которую буферизованный Illuminate\Http\Response или Symfony\Component\HttpFoundation\Response хранил бы, пока фреймворк вычисляет Content-Length. Потоковый вариант не вычисляет длину, поэтому опускает Content-Length. Он никогда не хранит тело ответа и строку документа одновременно. Это не настоящая инкрементная потоковая передача: у NextPDF нет интерфейса инкрементной записи, поэтому документ полностью материализуется до того, как первый байт достигнет сокета.

Прежде чем начать, убедитесь, что выполнены все условия:

  • Установлено ядро NextPDF и подключена одна интеграция с фреймворком — nextpdf/laravel или nextpdf/symfony.
  • Вы уже знаете, как маршрутизировать запрос к контроллеру в своём фреймворке.
  • Вы прочитали статью Возврат сгенерированного PDF из контроллера, где описаны буферизованные фабрики inline() и download(), на которых строится этот рецепт.

Это практическое руководство сосредоточено на шаблоне StreamedResponse, общем для Laravel и Symfony. CodeIgniter 4 поставляется с теми же именами методов streamInline() / streamDownload(), но оборачивает байты в CodeIgniter\HTTP\DownloadResponse, а не в управляемый обратным вызовом StreamedResponse. Раздел «Граничные случаи» описывает это различие.

Установите интеграцию для своего фреймворка. Выполните одну из следующих команд.

Окно терминала
composer require nextpdf/laravel
Окно терминала
composer require nextpdf/symfony

В Laravel после установки опубликуйте конфигурацию.

Окно терминала
php artisan vendor:publish --tag=nextpdf-config

Symfony регистрирует пакет через Flex. Прежде чем продолжить, проверьте обнаружение на странице установки своего фреймворка.

Буферизованная фабрика ответов, PdfResponse::download() или PdfResponse::inline(), вызывает getPdfData(), сохраняет возвращённую строку в объекте Response и устанавливает Content-Length по strlen(). Затем фреймворк хранит эту строку на протяжении всего времени жизни ответа. Для большого документа строка документа и строка тела ответа одновременно находятся в памяти.

Потоковая фабрика устроена иначе. PdfResponse::streamDownload() и PdfResponse::streamInline() возвращают StreamedResponse, построенный с обратным вызовом. Фреймворк вызывает этот обратный вызов только тогда, когда готов отправить тело. Внутри обратного вызова интеграция один раз обращается к getPdfData(), нарезает возвращённую строку на блоки по 64 КБ и выводит каждый блок через echo, а затем вызывает flush(). Она не сохраняет вторую постоянную копию тела и не отправляет заголовок Content-Length.

Все решения на этой странице опираются на два факта:

  • Сборка выполняется заранее, передача — по частям. getPdfData() у NextPDF\Core\Document вызывает модуль записи и возвращает весь PDF как одну строку. Нарезка на блоки по 64 КБ управляет только тем, как уже построенные байты выходят из процесса. Пиковая память ограничена размером одного готового документа, а не небольшим окном потоковой передачи.
  • Нет Content-Length. Потоковый вариант не может узнать длину тела, не построив его внутри обратного вызова, поэтому он опускает заголовок. Индикатор выполнения на стороне клиента, запрос Range или чувствительный к длине прокси не увидят размер. Выбирайте буферизованные download() / inline(), когда известная длина важнее экономии копии ответа.

Получайте документ через идиоматический для фреймворка способ разрешения зависимостей:

  • 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 КБ (детерминированный str_split)64 КБ (детерминированный цикл 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-сокет, а не файл.

Ниже приведено минимальное действие потоковой загрузки для каждого фреймворка. Вызовы документа используют одну и ту же поверхность ядра; различается только обвязка контроллера. Потоковая фабрика передаёт фреймворку обратный вызов, поэтому ваше действие возвращается сразу. Тело строится и сбрасывается, когда фреймворк отправляет ответ.

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');
}
}

Чтобы предварительно просмотреть PDF во вкладке браузера, а не принудительно загрузить его, вызовите streamInline(...) вместо streamDownload(...). Content-Disposition становится inline, а все остальные заголовки остаются прежними.

Продакшен-действие внедряет свои зависимости, проверяет входной путь, перехватывает наиболее конкретное исключение, которое может вызвать сборка, регистрирует класс сбоя без утечки трассировки и возвращает определённую ошибку Hypertext Transfer Protocol (HTTP). В примере ниже используется внедрение через конструктор в Laravel. Эквивалент для Symfony устроен так же, только PdfFactory внедряется в каждое действие.

getPdfData() выполняется внутри потокового обратного вызова, поэтому вызванное им исключение проявится после того, как фреймворк начнёт отправлять заголовки. Чтобы обработка ошибок оставалась полезной, постройте документ (шаг, который может завершиться сбоем) до того, как вернёте ответ, и перехватите сбой сборки именно там. Тогда внутри обратного вызова останется только передача уже построенных байтов по частям.

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. Каждая ветвь здесь регистрирует класс сбоя и возвращает определённый статус.

Разрешение свежего документа для каждого действия сохраняет фабрику заменяемой в тестах. Не используйте один и тот же экземпляр контроллера для двух несвязанных документов внутри долгоживущего рабочего процесса: устаревшее состояние содержимого перенесётся дальше.

  • Документ строится дважды в шаблоне «проверить, затем передать потоком». Продакшен-пример один раз обращается к getPdfData(), чтобы проверить сборку, а затем фабрика вызывает его снова внутри обратного вызова. Это цена переноса точки сбоя до отправки заголовков. Когда двойная сборка слишком затратна для конкретного документа, пропустите предварительную проверочную сборку и примите, что сбой сборки внутри обратного вызова оборвёт уже начатый ответ.
  • Нет Content-Length. Потоковый вариант опускает заголовок. Индикаторы выполнения загрузки и запросы Range работать не будут. Используйте буферизованные download() / inline(), когда требуется известная длина.
  • Буферизующий прокси сводит выгоду на нет. Обратный прокси или буфер вывода PHP, который захватывает всё тело перед пересылкой, снова хранит весь PDF и съедает сэкономленную копию. Настройте прокси на потоковую передачу ответов application/pdf или используйте на этом пути буферизованный ответ.
  • CodeIgniter 4 не использует потоковую передачу через обратный вызов. Интеграция с CodeIgniter поставляется с теми же именами методов streamInline() / streamDownload(), но они возвращают CodeIgniter\HTTP\DownloadResponse, который хранит всё тело, а не управляемый обратным вызовом StreamedResponse. Шаблон StreamedResponse с этой страницы применим только к Laravel и Symfony.
  • Не записывайте в тело после возврата. Потоковый обратный вызов владеет выводом. Не используйте echo и не записывайте в тело ответа самостоятельно после того, как вернули StreamedResponse фреймворку.
  • Подписанные документы сразу завершаются сбоем. Вызов getPdfData() для документа, настроенного на высокоуровневую подпись PAdES, вызывает NextPDF\Exception\NotImplementedException вместо того, чтобы выдать неподписанный файл. Передавайте подписанный вывод через документированный путь подписания, а не через этот рецепт.

Потоковая передача ограничивает копию ответа, а не сборку документа. Пиковая память примерно равна размеру одного готового PDF, потому что getPdfData() материализует документ целиком до отправки первого блока. Для действительно большого или многостраничного документа в бюджете запроса доминирует сама сборка, а не передача. Перенесите генерацию из потока запроса в задачу очереди. См. статью Генерация PDF в задаче очереди.

Размер блока 64 КБ фиксирован и детерминирован в обеих интеграциях. Он управляет только гранулярностью передачи и не меняет ни общее количество отправленных байтов, ни пиковую память. Выбирайте потоковый вариант, когда ограничением является сэкономленная копия ответа, а индикатор выполнения не нужен. Выбирайте буферизованный вариант для небольших, чувствительных к задержке ответов, которым полезна известная 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 повторно излагает использование и оставляет нормативные цитаты тем страницам.