Salta ai contenuti

Trasmettere in streaming un PDF generato di grandi dimensioni come risposta HTTP

Si genera un PDF di grandi dimensioni all’interno di un controller e si vogliono restituire i byte senza conservare una seconda copia completa nel buffer della risposta. Ogni integrazione framework fornisce una variante in streaming della propria factory PdfResponse: streamInline() e streamDownload(). Entrambe restituiscono uno StreamedResponse del framework, il cui callback scrive il corpo del PDF verso il client in blocchi fissi da 64 KB.

Valutare con realismo il modello di memoria prima di scegliere questo percorso. Il motore costruisce prima l’intero documento in memoria. Il callback in streaming chiama getPdfData(), che materializza l’intero PDF come un’unica stringa, quindi la percorre in porzioni da 64 KB. Il picco risparmiato è la seconda copia che una risposta bufferizzata Illuminate\Http\Response o Symfony\Component\HttpFoundation\Response conserverebbe mentre il framework misura Content-Length. La variante in streaming non misura la lunghezza, quindi omette Content-Length. Non mantiene mai contemporaneamente il corpo della risposta e la stringa del documento. Non è vero streaming incrementale: NextPDF non espone una superficie di scrittura incrementale, quindi il documento viene realizzato per intero prima che il primo byte raggiunga il socket.

Prerequisiti, dichiarati fin dall’inizio per evitare sorprese a metà attività:

  • Il core NextPDF è installato e un’integrazione framework, nextpdf/laravel o nextpdf/symfony, è installata e rilevata.
  • Si sa già come instradare una richiesta a un controller nel proprio framework.
  • È stato letto Restituire un PDF generato da un controller, che illustra le factory bufferizzate inline() e download() su cui questa ricetta si basa.

Questa guida pratica si concentra sul pattern StreamedResponse condiviso da Laravel e Symfony. CodeIgniter 4 fornisce gli stessi nomi di metodo streamInline() / streamDownload(), ma incapsula i byte in un CodeIgniter\HTTP\DownloadResponse anziché in uno StreamedResponse guidato da callback. La sezione Casi limite registra questa differenza.

Installare l’integrazione corrispondente al proprio framework eseguendo uno dei comandi seguenti.

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

Per Laravel, pubblicare la configurazione dopo l’installazione.

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

Symfony registra automaticamente il bundle tramite Flex. Prima di continuare, confermare il rilevamento nella pagina di installazione del proprio framework.

Una factory di risposta bufferizzata, PdfResponse::download() o PdfResponse::inline(), chiama getPdfData(), memorizza la stringa restituita in un oggetto Response e imposta Content-Length a partire da strlen(). Il framework conserva quindi quella stringa per l’intera durata della risposta. Per un documento di grandi dimensioni, questo significa che la stringa del documento e la stringa del corpo della risposta risiedono in memoria contemporaneamente.

La factory in streaming ha una forma diversa. PdfResponse::streamDownload() e PdfResponse::streamInline() restituiscono uno StreamedResponse costruito con un callback. Il framework invoca tale callback solo quando è pronto a inviare il corpo. All’interno del callback, l’integrazione chiama getPdfData() una sola volta, suddivide la stringa restituita in blocchi da 64 KB ed esegue echo di ciascun blocco, seguito da un flush(). Non viene conservata alcuna seconda copia persistente del corpo e non viene emessa alcuna intestazione Content-Length.

Due fatti condizionano ogni decisione in questa pagina:

  • La costruzione è eager, il trasferimento è a blocchi. getPdfData() su NextPDF\Core\Document chiama il writer e restituisce l’intero PDF come un’unica stringa. La suddivisione in blocchi da 64 KB governa solo il modo in cui i byte già costruiti escono dal processo. Il picco di memoria è limitato dalla dimensione di un singolo documento completato, non da una piccola finestra di streaming.
  • Nessun Content-Length. La variante in streaming non può conoscere la lunghezza del corpo senza costruirlo all’interno del callback, quindi omette l’intestazione. Una barra di avanzamento del client, una richiesta Range o un proxy sensibile alla lunghezza non vedranno alcuna dimensione. Scegliere i metodi bufferizzati download() / inline() quando una lunghezza nota conta più della copia della risposta risparmiata.

Ottenere il documento attraverso il percorso di risoluzione idiomatico del framework:

  • Laravel: risolvere NextPDF\Contracts\DocumentFactoryInterface dal container e chiamare create(). Il risultato è un nuovo NextPDF\Core\Document, il tipo concreto accettato dalle factory in streaming.
  • Symfony: iniettare NextPDF\Symfony\Service\PdfFactory e chiamare create(). Il risultato è un nuovo NextPDF\Core\Document con i valori predefiniti configurati già applicati.
AspettoLaravelSymfony
Documento nuovoapp(DocumentFactoryInterface::class)->create()PdfFactory::create()
Inline in streamingPdfResponse::streamInline($doc, $name)PdfResponse::streamInline($doc, $name)
Download in streamingPdfResponse::streamDownload($doc, $name)PdfResponse::streamDownload($doc, $name)
Tipo restituitoSymfony\Component\HttpFoundation\StreamedResponseSymfony\Component\HttpFoundation\StreamedResponse
Chiamata di costruzione all’interno del callbackNextPDF\Core\Document::getPdfData()NextPDF\Core\Document::getPdfData()
Dimensione del blocco64 KB (str_split deterministico)64 KB (ciclo substr deterministico)

Il PdfResponse di Laravel si trova in NextPDF\Laravel\Http\PdfResponse; quello di Symfony in NextPDF\Symfony\Http\PdfResponse. Le rispettive factory in streaming restituiscono entrambe lo stesso tipo Symfony\Component\HttpFoundation\StreamedResponse. Entrambe applicano lo stesso insieme fisso di intestazioni di hardening della risposta dell’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) ed entrambe sanificano il nome del file di download. Non occorre aggiungere manualmente queste intestazioni.

Entrambe le factory chiamano la stessa superficie core sottostante, NextPDF\Core\Document::getPdfData(): string, che costruisce e restituisce l’intero binario PDF. Il metodo gemello save(string $path): void scrive gli stessi byte su disco tramite un writer atomico. Questa ricetta usa getPdfData() perché la destinazione è un socket HTTP, non un file.

Ecco l’azione minima per il download in streaming in ciascun framework. Le chiamate al documento usano la stessa superficie core; cambia soltanto l’impalcatura del controller. La factory in streaming consegna al framework un callback, quindi l’azione restituisce immediatamente. Il corpo viene costruito e svuotato quando il framework invia la risposta.

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

Per visualizzare l’anteprima in una scheda del browser anziché forzare un download, chiamare streamInline(...) al posto di streamDownload(...). Il Content-Disposition diventa inline e tutte le altre intestazioni restano invariate.

Un’azione di produzione inietta le proprie dipendenze, convalida l’input del percorso, intercetta l’eccezione più specifica che la costruzione può sollevare, registra la classe di errore senza divulgare uno stack trace e restituisce un errore HTTP definito. L’esempio seguente usa l’iniezione tramite costruttore di Laravel. L’equivalente Symfony segue la stessa struttura, con PdfFactory iniettato per azione.

getPdfData() viene eseguito all’interno del callback in streaming, quindi un’eccezione sollevata da quel metodo emerge dopo che il framework ha iniziato a inviare le intestazioni. Per mantenere significativa la gestione degli errori, costruire il documento, cioè il passaggio che può fallire, prima di restituire la risposta e intercettare lì l’errore di costruzione. Solo il trasferimento a blocchi dei byte già costruiti avviene poi all’interno del callback.

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

Intercettare NextPDF\Exception\NextPdfException, la base astratta estesa da ogni eccezione NextPDF, quando si desidera un unico gestore per qualsiasi errore di costruzione. Per reagire a cause specifiche, intercettare prima i sottotipi concreti che getPdfData() può sollevare: NextPDF\Exception\PageLayoutException quando il contenuto non può adattarsi alla geometria della pagina, NextPDF\Exception\CompressionException quando la compressione del flusso fallisce e NextPDF\Exception\InvalidConfigException per una configurazione di output non valida. Non scrivere mai un blocco catch vuoto. Ogni ramo qui registra la classe di errore e restituisce uno stato definito.

Risolvere un nuovo documento per ogni azione mantiene la factory sostituibile nei test. Non riutilizzare una singola istanza di controller per due documenti non correlati all’interno di un unico processo worker a lunga esecuzione, perché lo stato di contenuto obsoleto verrebbe trasferito.

  • Nel pattern convalida-poi-streaming il documento viene costruito due volte. L’esempio di produzione chiama getPdfData() una volta per convalidare la costruzione; poi la factory lo chiama di nuovo all’interno del callback. Questo è il costo di spostare il punto di errore prima delle intestazioni. Quando una doppia costruzione è troppo onerosa per un determinato documento, omettere la verifica preliminare di costruzione e accettare che un errore di costruzione all’interno del callback tronchi una risposta già avviata.
  • Nessun Content-Length. La variante in streaming omette l’intestazione. Le barre di avanzamento del download e le richieste Range non funzioneranno. Usare i metodi bufferizzati download() / inline() quando è richiesta una lunghezza nota.
  • Un proxy di buffering annulla il vantaggio. Un reverse proxy o un buffer di output di PHP che cattura l’intero corpo prima di inoltrarlo conserva di nuovo l’intero PDF, eliminando la copia risparmiata. Configurare il proxy affinché trasmetta in streaming le risposte application/pdf, oppure usare una risposta bufferizzata su quel percorso.
  • CodeIgniter 4 non utilizza lo streaming guidato da callback. L’integrazione CodeIgniter fornisce gli stessi nomi di metodo streamInline() / streamDownload(), ma restituisce un CodeIgniter\HTTP\DownloadResponse che conserva l’intero corpo, non uno StreamedResponse guidato da callback. Il pattern StreamedResponse di questa pagina si applica solo a Laravel e Symfony.
  • Non scrivere nel corpo dopo aver restituito la risposta. Il callback in streaming è proprietario dell’output. Non eseguire echo né scrivere manualmente nel corpo della risposta dopo aver restituito lo StreamedResponse al framework.
  • I documenti firmati falliscono rapidamente. Chiamare getPdfData() su un documento configurato per una firma PAdES di alto livello solleva NextPDF\Exception\NotImplementedException anziché emettere un file non firmato. Trasmettere in streaming l’output firmato attraverso il percorso di firma documentato, non attraverso questa ricetta.

Lo streaming limita la copia della risposta, non la costruzione del documento. Il picco di memoria corrisponde all’incirca alla dimensione di un singolo PDF completato, perché getPdfData() realizza l’intero documento prima che il primo blocco venga inviato. Per un documento davvero grande o multipagina, è la costruzione stessa, non il trasferimento, a dominare il budget della richiesta. Spostare la generazione fuori dal thread della richiesta con un job in coda. Vedere Generare un PDF in un job in coda.

La dimensione del blocco da 64 KB è fissa e deterministica in entrambe le integrazioni. Governa solo la granularità del trasferimento e non modifica il totale dei byte inviati né il picco di memoria. Scegliere la variante in streaming quando il vincolo è la copia della risposta risparmiata e non è richiesta una barra di avanzamento. Scegliere la variante bufferizzata per risposte piccole e sensibili alla latenza che traggono vantaggio da un Content-Length noto.

  • Convalidare l’input prima della costruzione. L’azione di produzione rifiuta un identificatore fuori intervallo con un 422 prima che venga eseguito qualsiasi lavoro di costruzione. Non interpolare mai input non convalidato nella costruzione o nel nome del file.
  • La sanificazione del nome del file viene applicata automaticamente. Entrambe le factory in streaming sanificano il nome del file e aggiungono l’insieme di intestazioni di hardening della risposta OWASP. Passare un valore sotto il proprio controllo e lasciare che la factory lo sanifichi come secondo livello. Non codificare manualmente il nome del file.
  • Limitare la memoria concorrente. Poiché l’intero PDF viene materializzato in memoria per ogni richiesta, un traffico concorrente elevato moltiplica il picco di memoria. Applicare limiti di dimensione e di frequenza agli input che guidano una costruzione per mitigare la negazione del servizio per esaurimento della memoria.
  • Registrare la classe di errore, non il messaggio. Il blocco catch registra $exception::class e un identificatore di correlazione, mai il messaggio dell’eccezione o uno stack trace. Uno stack trace grezzo in un sink di log è una fuga di informazioni.
  • Nessun catch vuoto. Ogni ramo catch in questa pagina registra e restituisce una risposta di errore definita.

Questa guida non avanza alcuna affermazione normativa di conformità agli standard. Ogni classe, metodo e intestazione mostrati costituiscono la superficie pubblica verificata dell’integrazione indicata: NextPDF\Core\Document::getPdfData(), le factory in streaming NextPDF\Laravel\Http\PdfResponse e NextPDF\Symfony\Http\PdfResponse, e il tipo restituito Symfony\Component\HttpFoundation\StreamedResponse. La semantica delle intestazioni di hardening della risposta OWASP applicate dalle factory è documentata, con le relative citazioni, nella pagina di sicurezza e operazioni di ciascuna integrazione, collegata in Vedere anche. Questa pagina del cookbook ribadisce l’uso e rimanda le citazioni normative a quelle pagine.