บทความเทคนิค

ใช้อินสแตนซ์ THotPDF ซ้ำข้ามเอกสารใน Delphi

ข้อความผิดพลาดเขียนว่า Please load the document before using BeginDoc และมันมักโผล่มาในรอบที่สองเสมอ เอกสารแรกเขียนออกมาได้ดี จากนั้นอินสแตนซ์ THotPDF ตัวเดิมถูกสั่งให้เริ่มเอกสารที่สอง BeginDoc ก็โยนข้อผิดพลาด และข้อความนั้นชี้ไปที่การโหลดเอกสาร ซึ่งตรงข้ามกับสิ่งที่โค้ดกำลังพยายามทำ ความไม่เข้ากันระหว่างอาการกับข้อความนี่เองที่ทำให้ปัญหานี้ติดตา เรื่องจริงที่กำลังพูดถึงคือวงจรชีวิตของคอมโพเนนต์ และเมื่อเข้าใจจุดนั้นแล้ว ข้อผิดพลาดนี้ก็เลิกเป็นเรื่องลึกลับ

วงจรชีวิตเอกสารของ THotPDF ที่แสดง Create, BeginDoc, EndDoc และ Free ต่อไฟล์ผลลัพธ์หนึ่งไฟล์
อินสแตนซ์ THotPDF หนึ่งตัวจับคู่กับเอกสารหนึ่งฉบับ: Create, BeginDoc, วาด, EndDoc, Free

อินสแตนซ์ THotPDF คือเอกสารหนึ่งฉบับ ไม่ใช่โรงงานผลิตเอกสาร

แบบจำลองความคิดที่ล่อใจคือมอง THotPDF เป็นออบเจกต์บริการที่คุณเปิดขึ้นครั้งเดียวแล้วป้อนเอกสารเข้าไปเรื่อย ๆ ทำนองเดียวกับที่คุณเปิดการเชื่อมต่อฐานข้อมูลค้างไว้แล้วยิงคิวรีทีละคำสั่งผ่านมัน แต่มันไม่ใช่แบบนั้น อินสแตนซ์หนึ่งตัวจำลองเอกสารหนึ่งฉบับที่กำลังถูกสร้าง และเครื่องสถานะภายในของมันตั้งอยู่บนสมมติฐานว่ามันเดินเส้นทางนี้เพียงรอบเดียว: จากว่างเปล่า ผ่านเอกสารที่เปิดอยู่ ไปสู่ไฟล์ที่บันทึกแล้ว BeginDoc เปิดเส้นทางนั้นและทำเครื่องหมายว่าอินสแตนซ์มีเอกสารที่ทำค้างอยู่ EndDoc เขียนทุกอย่างลง FileName แล้วปิดงาน การเรียก BeginDoc อีกครั้งบนอินสแตนซ์เดิมที่จบงานไปแล้ว คือการขอให้มันกลับเข้าสถานะที่มันไม่เคยออกมาอย่างสะอาด และตัวป้องกันที่ทำงานคือตัวที่มีข้อความบังเอิญพูดถึงการโหลด เพราะภายในนั้นเงื่อนไข "พร้อมเริ่ม" กับ "มีเอกสารที่โหลดแล้ว" ถูกตรวจร่วมกัน

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

วงจรชีวิต ตามลำดับที่มันต้องเกิดขึ้น

ทุกเอกสารที่ HotPDF เขียนขึ้นใหม่จากศูนย์เดินตามจังหวะสี่ขั้นเดียวกัน และลำดับนี้ต่อรองไม่ได้ Create จองคอมโพเนนต์ BeginDoc เปิดเอกสารและตรึงการเลือกเชิงโครงสร้าง ดังนั้นทุกอย่างที่มีผลกับทั้งไฟล์ (ขนาดหน้า การบีบอัด การเข้ารหัส ชื่อไฟล์ผลลัพธ์) ต้องถูกตั้งค่าระหว่าง Create กับ BeginDoc จากนั้นคุณจึงวาด แล้ว EndDoc จะเขียนไบต์ลงดิสก์ ส่วน Free คืนอินสแตนซ์ การเรียกวาดที่วางไว้ก่อน BeginDoc ไม่มีหน้ากระดาษให้ลง ส่วนคุณสมบัติระดับทั้งเอกสารที่กำหนดหลังจากนั้นจะถูกเมินเฉยโดยไม่มีเสียงบ่น

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // เปิดเอกสาร
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // เขียน invoice.pdf แล้วปิดงาน
  finally
    Pdf.Free;                            // หนึ่งอินสแตนซ์ หนึ่งเอกสาร
  end;
end;

ให้อ่านโค้ดนั้นเป็นหนึ่งหน่วยงาน หนึ่ง Create หนึ่ง BeginDoc หนึ่ง EndDoc หนึ่ง Free และไฟล์หนึ่งไฟล์บนดิสก์ วินาทีที่คุณต้องการไฟล์ที่สอง คุณกำลังเริ่มหน่วยงานใหม่ ซึ่งแปลว่าต้องมีอินสแตนซ์ใหม่

ความหมายที่ควรเป็นของคำว่า "ใช้ซ้ำ": อินสแตนซ์ใหม่ต่อหนึ่งไฟล์

เวอร์ชันที่พังพยายามประหยัดการจองหน่วยความจำ: สร้างคอมโพเนนต์ครั้งเดียว วนลูปทั้งชุดงาน แล้วเรียก BeginDoc กับ EndDoc ภายในลูป รอบที่สองจะโยนข้อผิดพลาด ส่วนเวอร์ชันที่ใช้ได้จะปฏิบัติต่อผลลัพธ์แต่ละชิ้นเป็นออบเจกต์อายุสั้นของตัวเอง และต้นทุนการจองหน่วยความจำเพื่อสร้างคอมโพเนนต์นั้นเล็กจิ๋วเมื่อเทียบกับงานจัดวางและเขียน PDF ออกมา จึงไม่มีอะไรให้ประหยัดจากการหวงอินสแตนซ์ไว้

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // อินสแตนซ์ใหม่ในทุกรอบ
    try
      Pdf.FileName := Names[I] + '.pdf';
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 12);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Statement for ' + Names[I]);
      Pdf.EndDoc;
    finally
      Pdf.Free;
    end;
  end;
end;

try/finally ที่วางอยู่ภายในลูปคือส่วนที่ควรปกป้องเวลาถูกรีวิว ถ้า BeginDoc หรือการเรียกวาดใด ๆ โยนข้อผิดพลาดกลางคันในเอกสารหนึ่งฉบับ อินสแตนซ์ของรอบนั้นก็ยังถูกคืนก่อนรอบถัดไปจะเริ่ม เรกคอร์ดเสียหนึ่งตัวจึงไม่ทิ้งคอมโพเนนต์ที่สร้างค้างไว้ไปวางยาการรันที่เหลือ ถ้าดึง Create ออกไปไว้เหนือลูปเพื่อ "ปรับให้เร็วขึ้น" คุณก็กลับไปอยู่กับบั๊กเดิม เพียงแต่คราวนี้มันสวมชุดลูปประมวลผลเป็นชุด

การแก้ไขไฟล์ที่มีอยู่แล้วคือจุดเข้าคนละจุด

คำว่า "ใช้ซ้ำ" ยังมีความหมายที่สองซึ่งชอบธรรมเต็มที่: คุณไม่ได้ต้องการเอกสารเปล่า แต่ต้องการเปิด PDF ที่มีอยู่แล้วมาแก้ไข เส้นทางนั้นไม่ผ่าน BeginDoc เลย ซึ่งเป็นเหตุผลตรง ๆ ที่ข้อความผิดพลาดพูดถึงการโหลด คุณโหลดไฟล์ แก้ไขมัน แล้วบันทึกภายใต้ชื่อใดก็ได้ที่คุณเลือก

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('contract.pdf');
    if PageCount > 0 then
    begin
      Pdf.CurrentPage.SetFont('Arial', [fsBold], 10);
      Pdf.CurrentPage.TextOut(40, 30, 0, 'REVIEWED');
      Pdf.SaveLoadedDocument('contract-reviewed.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

LoadFromFile คืนจำนวนหน้า และค่าที่เป็นศูนย์หรือต่ำกว่าแปลว่าการโหลดล้มเหลว จึงคุ้มที่จะตรวจก่อนแตะ CurrentPage การจับคู่มีความหมาย: เอกสารที่คุณเปิดด้วย LoadFromFile ต้องบันทึกด้วย SaveLoadedDocument ไม่ใช่ด้วยคู่ BeginDoc/EndDoc ซึ่งเป็นของเอกสารที่คุณแต่งขึ้นจากศูนย์ การผสมสองอย่างนี้คือวิธีที่พบบ่อยที่สุดในการทำให้เครื่องสถานะตัวเดียวกับที่ก่อข้อผิดพลาดตอนต้นสับสน ให้แยกสองสายงานนี้ออกจากกันในหัว: BeginDoc ... EndDoc คือการสร้าง ส่วน LoadFromFile ... SaveLoadedDocument คือการแก้ไข

ปัญหาไฟล์ถูกล็อกมีจริง และคำตอบไม่ใช่การไล่ปิดหน้าต่างโปรแกรมอ่าน

ข้อผิดพลาดเรื่องการใช้ซ้ำมักเดินทางมาพร้อมคำบ่นอีกข้อ และทั้งสองพันกันเพราะมันโผล่ในสายงานสร้างไฟล์ใหม่เหมือนกัน ผู้ใช้เปิด PDF ที่คุณเพิ่งสร้าง ทิ้งไว้ค้างใน Acrobat หรือ Foxit แล้วสั่งสร้างใหม่ EndDoc พยายามเขียนทับพาธเดิม ระบบปฏิบัติการปฏิเสธเพราะโปรแกรมอ่านถือสิทธิ์แชร์แบบอ่านที่กันฝั่งเขียนไว้ คุณจึงได้ความล้มเหลวแบบปฏิเสธการเข้าถึง ข้อนี้เป็นปัญหาการล็อกไฟล์ของ Windows จริง ๆ ไม่ใช่ปัญหาสถานะของคอมโพเนนต์ และมันสมควรได้คำตอบจริง ไม่ใช่ทางเลี่ยง

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

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // ไฟล์ชั่วคราวอยู่ในไดเรกทอรีเดียวกันกับปลายทาง: การเปลี่ยนชื่อภายใน
  // โวลุม NTFS เดียวกันสลับชื่อแบบอะตอมมิก ขณะที่การย้ายข้ามโวลุม
  // จะลดชั้นเป็นคัดลอกแล้วลบ และเสียการรับประกันนั้นไป
  TempPath := TPath.Combine(TPath.GetDirectoryName(FinalPath),
    TGUID.NewGuid.ToString + '.pdf.tmp');
  try
    Pdf := THotPDF.Create(nil);
    try
      Pdf.FileName := TempPath;
      Pdf.BeginDoc;
      Pdf.CurrentPage.SetFont('Arial', [], 11);
      Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
      Pdf.EndDoc;                    // ถึงตรงนี้ไฟล์ชั่วคราวบนดิสก์สมบูรณ์แล้ว
    finally
      Pdf.Free;
    end;

    // สลับเข้าที่ TFile.Move ไม่ยอมเขียนทับ จึงต้องล้างไฟล์ปลายทางเก่า
    // ก่อน ถ้ายังมีโปรแกรมอ่านถือไฟล์เก่าอยู่ ตัวที่ล้มเหลวคือการลบ
    // และล้มอย่างเสียงดัง ก่อนที่ไบต์ชุดดีจะถูกแตะ
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // หรือ: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // อย่าทิ้งไฟล์ชั่วคราวที่เขียนค้างไว้
    raise;
  end;
end;

ขอเชิงอรรถตรงไปตรงมาสองข้อกับโค้ดนั้น TFile.Move และ RenameFile แบบดั้งเดิมต่างแมปไปที่การเปลี่ยนชื่อของ Windows ตัวเดียวกัน ซึ่งเป็นอะตอมมิกเฉพาะเมื่อต้นทางกับปลายทางอยู่บนโวลุมเดียวกัน และนั่นคือเหตุผลตรง ๆ ที่ไฟล์ชั่วคราวไปอยู่ในไดเรกทอรีปลายทาง แทนที่จะเป็น TPath.GetTempPath อีกข้อคือคู่ลบแล้วย้ายไม่ได้เป็นขั้นตอนอะตอมมิกขั้นเดียวในตัวมันเอง: มีช่องเวลาสั้น ๆ ที่ไม่มีไฟล์ทั้งสองอยู่เลย สำหรับแอปเดสก์ท็อปที่สร้างรายงานใหม่ ช่องเวลานั้นไม่มีนัยสำคัญ ส่วนผู้อ่านที่ต้องการสัญญาที่แข็งกว่านั้นบนโวลุมเดียวกัน เรียก ReplaceFile หรือ MoveFileEx ของ Win32 พร้อม MOVEFILE_REPLACE_EXISTING ได้โดยตรง ซึ่งยุบการสลับทั้งหมดเหลือการเรียกครั้งเดียว

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

// หนึ่งพาธผลลัพธ์ต่อหนึ่งคำขอ: งานสองงานที่รันพร้อมกันไม่มีวันแย่ง
// ชื่อเดียวกัน จึงไม่ต้องเต้นรำเปลี่ยนชื่อ และไม่มีตัวล็อกให้เสีย
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

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

รูปทรงของทางแก้

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

การเรียก BeginDoc, EndDoc, LoadFromFile และ SaveLoadedDocument ที่แสดงไว้ที่นี่เป็นส่วนหนึ่งของ HotPDF Delphi Component สำหรับ Delphi และ C++Builder