Trasmettere in streaming un PDF generato di grandi dimensioni come risposta HTTP
In sintesi
Sezione intitolata “In sintesi”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/laravelonextpdf/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()edownload()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.
Installazione
Sezione intitolata “Installazione”Installare l’integrazione corrispondente al proprio framework eseguendo uno dei comandi seguenti.
composer require nextpdf/laravelcomposer require nextpdf/symfonyPer Laravel, pubblicare la configurazione dopo l’installazione.
php artisan vendor:publish --tag=nextpdf-configSymfony registra automaticamente il bundle tramite Flex. Prima di continuare, confermare il rilevamento nella pagina di installazione del proprio framework.
Panoramica concettuale
Sezione intitolata “Panoramica concettuale”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()suNextPDF\Core\Documentchiama 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 richiestaRangeo un proxy sensibile alla lunghezza non vedranno alcuna dimensione. Scegliere i metodi bufferizzatidownload()/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\DocumentFactoryInterfacedal container e chiamarecreate(). Il risultato è un nuovoNextPDF\Core\Document, il tipo concreto accettato dalle factory in streaming. - Symfony: iniettare
NextPDF\Symfony\Service\PdfFactorye chiamarecreate(). Il risultato è un nuovoNextPDF\Core\Documentcon i valori predefiniti configurati già applicati.
Superficie API
Sezione intitolata “Superficie API”| Aspetto | Laravel | Symfony |
|---|---|---|
| Documento nuovo | app(DocumentFactoryInterface::class)->create() | PdfFactory::create() |
| Inline in streaming | PdfResponse::streamInline($doc, $name) | PdfResponse::streamInline($doc, $name) |
| Download in streaming | PdfResponse::streamDownload($doc, $name) | PdfResponse::streamDownload($doc, $name) |
| Tipo restituito | Symfony\Component\HttpFoundation\StreamedResponse | Symfony\Component\HttpFoundation\StreamedResponse |
| Chiamata di costruzione all’interno del callback | NextPDF\Core\Document::getPdfData() | NextPDF\Core\Document::getPdfData() |
| Dimensione del blocco | 64 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.
Esempio di codice — Avvio rapido
Sezione intitolata “Esempio di codice — Avvio rapido”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.
<?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'); }}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.
Esempio di codice — Produzione
Sezione intitolata “Esempio di codice — Produzione”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.
<?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.
Casi limite e insidie
Sezione intitolata “Casi limite e insidie”- 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 richiesteRangenon funzioneranno. Usare i metodi bufferizzatidownload()/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 unCodeIgniter\HTTP\DownloadResponseche conserva l’intero corpo, non unoStreamedResponseguidato 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
echoné scrivere manualmente nel corpo della risposta dopo aver restituito loStreamedResponseal framework. - I documenti firmati falliscono rapidamente. Chiamare
getPdfData()su un documento configurato per una firma PAdES di alto livello sollevaNextPDF\Exception\NotImplementedExceptionanziché emettere un file non firmato. Trasmettere in streaming l’output firmato attraverso il percorso di firma documentato, non attraverso questa ricetta.
Prestazioni
Sezione intitolata “Prestazioni”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.
Note sulla sicurezza
Sezione intitolata “Note sulla sicurezza”- Convalidare l’input prima della costruzione. L’azione di produzione rifiuta un identificatore fuori intervallo con un
422prima 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::classe 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.
Conformità
Sezione intitolata “Conformità”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.
Vedere anche
Sezione intitolata “Vedere anche”- Restituire un PDF generato da un controller: le controparti bufferizzate
inline()edownload(). - Generare un PDF in un job in coda: spostare la costruzione fuori dal thread della richiesta.
- Uso in produzione con Laravel: controller con DI cablata, insieme di intestazioni OWASP e contratto di binding del container.
- Uso in produzione con Symfony: il callback in streaming, l’emettitore di blocchi da 64 KB e il locator del builder.