ข้ามไปยังเนื้อหา

สตรีม PDF ขนาดใหญ่ที่สร้างแล้วเป็นการตอบกลับ HTTP

คุณสร้าง PDF ขนาดใหญ่ในคอนโทรลเลอร์และต้องการคืนค่าไบต์โดยไม่เก็บสำเนาเต็มอีกชุดไว้ในบัฟเฟอร์การตอบกลับ การผสานรวมแต่ละเฟรมเวิร์กมีแฟกทอรี PdfResponse แบบสตรีม ได้แก่ streamInline() และ streamDownload() เมท็อดแต่ละตัวคืนค่า StreamedResponse ของเฟรมเวิร์กพร้อมคอลแบ็กที่เขียนเนื้อหา PDF ไปยังไคลเอนต์เป็นชิ้นขนาดคงที่ 64 KB

อ่านโมเดลหน่วยความจำก่อนเลือกเส้นทางนี้ เอนจินจะสร้างเอกสารทั้งฉบับในหน่วยความจำก่อน คอลแบ็กแบบสตรีมเรียก getPdfData() ซึ่งสร้าง PDF ทั้งฉบับขึ้นเป็นสตริงเดียว จากนั้นจึงไล่ผ่านสตริงนั้นเป็นสไลซ์ขนาด 64 KB คุณประหยัดต้นทุนสูงสุดของสำเนาที่สองที่ Illuminate\Http\Response หรือ Symfony\Component\HttpFoundation\Response แบบบัฟเฟอร์ต้องถือไว้ขณะที่เฟรมเวิร์กวัด Content-Length รูปแบบสตรีมไม่ได้วัดความยาว จึงละเว้น Content-Length และไม่ถือเนื้อหาการตอบกลับกับสตริงเอกสารไว้พร้อมกัน วิธีนี้ไม่ใช่การสตรีมแบบเพิ่มขึ้นทีละส่วนอย่างแท้จริง: NextPDF ไม่มี API สำหรับการเขียนแบบเพิ่มขึ้นทีละส่วน เอกสารจึงถูกสร้างทั้งฉบับก่อนที่ไบต์แรกจะไปถึงซ็อกเก็ต

ก่อนเริ่มต้น ตรวจสอบให้แน่ใจว่าส่วนประกอบเหล่านี้พร้อมแล้ว:

  • ติดตั้งคอร์ของ NextPDF แล้ว และติดตั้งการผสานรวมเฟรมเวิร์กหนึ่งรายการที่ระบบตรวจพบแล้ว ได้แก่ nextpdf/laravel หรือ nextpdf/symfony
  • คุณทราบวิธีกำหนดเส้นทางคำขอไปยังคอนโทรลเลอร์ในเฟรมเวิร์กของคุณแล้ว
  • คุณได้อ่าน คืนค่า PDF ที่สร้างขึ้นจากคอนโทรลเลอร์ แล้ว ซึ่งครอบคลุมแฟกทอรีแบบบัฟเฟอร์ inline() และ download() ที่สูตรนี้ต่อยอดมา

คู่มือนี้เน้นรูปแบบ StreamedResponse ที่ Laravel และ Symfony ใช้ร่วมกัน CodeIgniter 4 มีชื่อเมท็อด streamInline() / streamDownload() เดียวกัน แต่ห่อหุ้มไบต์ไว้ใน CodeIgniter\HTTP\DownloadResponse แทน StreamedResponse ที่ขับเคลื่อนด้วยคอลแบ็ก หัวข้อกรณีขอบและข้อควรระวังครอบคลุมความแตกต่างนี้

ติดตั้งการผสานรวมสำหรับเฟรมเวิร์กของคุณ โดยรันคำสั่งใดคำสั่งหนึ่งต่อไปนี้

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

สำหรับ Laravel ให้เผยแพร่การกำหนดค่าหลังการติดตั้ง

Terminal window
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 KB แล้ว echo แต่ละชิ้นตามด้วย flush() โดยไม่เก็บสำเนาเนื้อหาแบบถาวรชุดที่สองไว้ และไม่ปล่อยส่วนหัว Content-Length ออกมา

ข้อเท็จจริงสองประการกำหนดทุกการตัดสินใจในหน้านี้:

  • การสร้างเป็นแบบ eager การถ่ายโอนเป็นแบบแบ่งชิ้น getPdfData() บน NextPDF\Core\Document เรียกตัวเขียนและคืนค่า PDF ทั้งฉบับเป็นสตริงเดียว การแบ่งชิ้นขนาด 64 KB ควบคุมเฉพาะวิธีที่ไบต์ซึ่งสร้างไว้แล้วออกจากโปรเซสเท่านั้น หน่วยความจำสูงสุดถูกจำกัดด้วยขนาดของเอกสารที่เสร็จสมบูรณ์หนึ่งฉบับ ไม่ใช่ด้วยหน้าต่างการสตรีมขนาดเล็ก
  • ไม่มี Content-Length รูปแบบสตรีมไม่สามารถทราบความยาวของเนื้อหาได้หากไม่สร้างเนื้อหาภายในคอลแบ็ก จึงละเว้นส่วนหัวนี้ แถบความคืบหน้าของไคลเอนต์ คำขอ Range หรือพร็อกซีที่ต้องพึ่งพาความยาวจะไม่เห็นขนาด เลือกใช้ download() / inline() แบบบัฟเฟอร์เมื่อความยาวที่ทราบมีความสำคัญมากกว่าการประหยัดสำเนาการตอบกลับ

รับเอกสารผ่านวิธี resolve ตามรูปแบบของแต่ละเฟรมเวิร์ก:

  • Laravel: resolve 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_split แบบกำหนดได้แน่นอน)64 KB (ลูป substr แบบกำหนดได้แน่นอน)

คลาส PdfResponse ของ Laravel อยู่ที่ 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) และทั้งสองทำความสะอาดชื่อไฟล์ดาวน์โหลด คุณไม่ต้องเพิ่มส่วนหัวเหล่านั้นด้วยตนเอง

แฟกทอรีทั้งสองเรียก API คอร์พื้นฐานเดียวกันคือ NextPDF\Core\Document::getPdfData(): string ซึ่งสร้างและคืนค่าไบนารี PDF ทั้งฉบับ เมท็อดคู่กันคือ save(string $path): void จะเขียนไบต์ชุดเดียวกันลงดิสก์ผ่านตัวเขียนแบบ atomic สูตรนี้ใช้ getPdfData() เพราะเป้าหมายคือซ็อกเก็ต HTTP ไม่ใช่ไฟล์

นี่คือแอ็กชันดาวน์โหลดแบบสตรีมขั้นต่ำในแต่ละเฟรมเวิร์ก การสร้างเอกสารใช้ API คอร์เดียวกัน มีเพียงโครงคอนโทรลเลอร์เท่านั้นที่แตกต่างกัน แฟกทอรีแบบสตรีมส่งคอลแบ็กให้เฟรมเวิร์ก แอ็กชันของคุณจึงคืนค่าทันที เนื้อหาจะถูกสร้างและฟลัชเมื่อเฟรมเวิร์กส่งการตอบกลับ

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 และส่วนหัวอื่น ๆ ทุกตัวยังคงเหมือนเดิม

แอ็กชันระดับโปรดักชันจะฉีดดีเพนเดนซีของตัวเอง ตรวจสอบความถูกต้องของอินพุตจากเส้นทาง จับเอกซ์เซปชันที่เฉพาะเจาะจงที่สุดที่อาจเกิดขึ้นระหว่างการสร้าง บันทึกคลาสของความล้มเหลวโดยไม่ให้ trace รั่วไหล และคืนค่าข้อผิดพลาด 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 ที่ว่างเปล่าเด็ดขาด แต่ละสาขาในที่นี้จะบันทึกคลาสของความล้มเหลวและคืนค่าสถานะที่กำหนดไว้

การสร้างเอกสารฉบับใหม่ต่อหนึ่งแอ็กชันช่วยให้สลับแฟกทอรีในการทดสอบได้ อย่านำคอนโทรลเลอร์อินสแตนซ์เดียวมาใช้ซ้ำสำหรับเอกสารสองฉบับที่ไม่เกี่ยวข้องกันภายในโปรเซสเวิร์กเกอร์ที่ทำงานต่อเนื่องยาวนานเดียวกัน เพราะสถานะเนื้อหาที่ค้างอยู่จะติดตามมาด้วย

  • เอกสารถูกสร้างสองครั้งในรูปแบบ validate-then-stream ตัวอย่างระดับโปรดักชันเรียก 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 KB คงที่และกำหนดได้แน่นอนในการผสานรวมทั้งสอง ค่านี้ควบคุมเฉพาะความละเอียดของการถ่ายโอนเท่านั้น และไม่เปลี่ยนจำนวนไบต์ทั้งหมดที่ส่งหรือหน่วยความจำสูงสุด เลือกใช้รูปแบบสตรีมเมื่อสำเนาการตอบกลับที่ประหยัดไว้คือข้อจำกัด และไม่จำเป็นต้องมีแถบความคืบหน้า เลือกใช้รูปแบบบัฟเฟอร์สำหรับการตอบกลับขนาดเล็กที่อ่อนไหวต่อความหน่วงซึ่งได้ประโยชน์จาก Content-Length ที่ทราบ

  • ตรวจสอบความถูกต้องของอินพุตก่อนการสร้าง แอ็กชันระดับโปรดักชันปฏิเสธตัวระบุที่อยู่นอกช่วงด้วย 422 ก่อนที่งานสร้างใด ๆ จะทำงาน อย่าแทรกอินพุตที่ยังไม่ตรวจสอบความถูกต้องลงในการสร้างหรือชื่อไฟล์เด็ดขาด
  • มีการทำความสะอาดชื่อไฟล์ให้คุณ แฟกทอรีแบบสตรีมทั้งสองทำความสะอาดชื่อไฟล์และเพิ่มชุดส่วนหัวเสริมความแข็งแกร่งการตอบกลับของ OWASP ส่งค่าที่คุณควบคุม และให้แฟกทอรีทำความสะอาดอีกชั้นหนึ่ง อย่าเข้ารหัสชื่อไฟล์ด้วยมือ
  • จำกัดหน่วยความจำที่ใช้พร้อมกัน เพราะ PDF ทั้งฉบับถูกสร้างขึ้นในหน่วยความจำต่อหนึ่งคำขอ ปริมาณคำขอพร้อมกันที่สูงจะทวีคูณหน่วยความจำสูงสุด กำหนดขีดจำกัดด้านขนาดและอัตรากับอินพุตที่ขับเคลื่อนการสร้าง เพื่อลดความเสี่ยงการปฏิเสธบริการจากการใช้หน่วยความจำจนหมด
  • บันทึกคลาสของความล้มเหลว ไม่ใช่ข้อความ บล็อก catch บันทึก $exception::class และตัวระบุสหสัมพันธ์ ไม่ใช่ข้อความเอกซ์เซปชันหรือ stack trace เด็ดขาด raw trace ใน log sink ถือเป็นการรั่วไหลของข้อมูล
  • ไม่มี catch ที่ว่างเปล่า ทุกสาขา catch ในหน้านี้บันทึกและคืนค่าการตอบกลับข้อผิดพลาดที่กำหนดไว้

คู่มือนี้ไม่อ้างมาตรฐานเชิงบรรทัดฐานใด ๆ ทุกคลาส เมท็อด และส่วนหัวที่แสดงเป็น API สาธารณะที่ได้รับการยืนยันแล้วของการผสานรวมที่ระบุชื่อ: NextPDF\Core\Document::getPdfData() แฟกทอรีแบบสตรีม NextPDF\Laravel\Http\PdfResponse และ NextPDF\Symfony\Http\PdfResponse และชนิดที่คืนค่า Symfony\Component\HttpFoundation\StreamedResponse ความหมายของส่วนหัวเสริมความแข็งแกร่งการตอบกลับของ OWASP ที่แฟกทอรีใช้มีเอกสารกำกับพร้อมการอ้างอิงอยู่ในหน้า security-and-operations ของการผสานรวมแต่ละรายการที่ลิงก์ไว้ใต้หัวข้อ See also หน้า cookbook นี้กล่าวถึงการใช้งานซ้ำและส่งต่อการอ้างอิงเชิงบรรทัดฐานไปยังหน้าเหล่านั้น