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

การใช้ THotPDF ซ้ำในเอกสารต่าง ๆ ใน Delphi

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

THotPDF document lifecycle showing Create, BeginDoc, EndDoc, and Free per output file
อินสแตนซ์ THotPDF หนึ่งตัวแมปกับเอกสารหนึ่งฉบับ: Create, BeginDoc, draw, EndDoc, Free

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

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

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

วงจรชีวิต ในลำดับที่ต้องปฏิบัติตาม

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

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // opens the document
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // writes invoice.pdf, closes it out
  finally
    Pdf.Free;                            // one instance, one document
  end;
end;

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

ความหมายที่แท้จริงของการ "ใช้ซ้ำ": อินสแตนซ์ใหม่สำหรับแต่ละไฟล์

เวอร์ชันของโค้ดที่มีปัญหาพยายามประหยัดการจองหน่วยความจำ: โดยการสร้างคอมโพเนนต์เพียงครั้งเดียว, วนลูปผ่านแบทช์งาน, เรียก BeginDoc และ EndDoc ภายในลูป การวนลูปรอบที่สองจึงโยนข้อผิดพลาดออกมา ส่วนเวอร์ชันที่ทำงานได้จริงจะมองว่าผลลัพธ์แต่ละตัวเป็นอ็อบเจ็กต์ที่มีอายุสั้นในตัวมันเอง และต้นทุนการจองหน่วยความจำเพื่อสร้างคอมโพเนนต์นั้นแทบไม่มีนัยสำคัญเลยเมื่อเทียบกับงานในการจัดวางหน้าและการเขียนข้อมูล (serialize) 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);         // new instance each pass
    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 ที่อยู่ข้างในลูปคือส่วนที่ควรได้รับการป้องกันเมื่อมีการทำ code review หาก BeginDoc หรือคำสั่งวาดใด ๆ ทำให้เกิดข้อผิดพลาดขึ้นกลางคันในเอกสารฉบับหนึ่ง อินสแตนซ์ในรอบการทำงานนั้นจะยังคงถูกคืนหน่วยความจำก่อนที่รอบต่อไปจะเริ่มขึ้น ดังนั้นเรคคอร์ดที่ผิดพลาดตัวเดียวจะไม่ทิ้งคอมโพเนนต์ที่ทำค้างไว้ให้เป็นพิษกับการทำงานในส่วนที่เหลือ หากคุณดึง Create ออกไปไว้เหนือลูปเพื่อ "ปรับจูน (optimize)" คุณก็จะกลับไปเจอบั๊กตัวเดิม ที่คราวนี้มาในรูปแบบการวนลูปทำแบทช์งาน

การแก้ไขไฟล์ที่มีอยู่แล้วเป็นช่องทางการทำงานที่ต่างออกไป

มีการตีความหมายของคำว่า "ใช้ซ้ำ" อีกแบบหนึ่งที่สมเหตุสมผลโดยสิ้นเชิง: คุณไม่ได้ต้องการเอกสารเปล่า แต่คุณต้องการเปิด 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 ซึ่งเป็นชุดคำสั่งสำหรับเอกสารที่คุณสร้างขึ้นมาใหม่จากความว่างเปล่า การนำกระบวนการทั้งสองแบบนี้มาปะปนกันคือวิธีที่พบได้บ่อยที่สุดในการทำให้ state machine ตัวเดียวกันนี้สับสนและสร้างข้อผิดพลาดแบบเดิมขึ้นมา ให้แยกกระแสการทำงานทั้งสองแบบนี้ออกจากกันในหัวอย่างชัดเจน: BeginDoc ... EndDoc ใช้สำหรับสร้าง, LoadFromFile ... SaveLoadedDocument ใช้สำหรับแก้ไข

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

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

วิธีเลี่ยงปัญหาที่มักถูกแชร์ต่อกันมา คือการแจกแจงรายการหน้าต่างระดับบนสุด (top-level windows) ทั้งหมดและส่งคำสั่ง WM_CLOSE ไปยังหน้าต่างใดก็ตามที่มีชื่อที่ดูเหมือนจะเป็นโปรแกรมดู PDF ซึ่งเป็นสัญชาตญาณที่ผิด มันข้ามขอบเขตของโปรเซสไปปิดหน้าต่างที่โปรแกรมของคุณไม่ได้เป็นเจ้าของ มันเดาว่าหน้าต่างไหนคือโปรแกรมดูเอกสารจากชื่อ title และมันอาจจะทิ้งคำอธิบายประกอบ (annotations) ที่ผู้ใช้ยังไม่ได้บันทึกไปโดยไม่ได้ถามไถ่เลยด้วยซ้ำ ให้ถือว่าแนวทางแบบนี้ทั้งหมดเป็นวิธีที่ไม่ถูกต้องและส่งกลิ่นทะแม่ง ๆ (smell) วิธีแก้ไขที่เชื่อถือได้คือการไม่เขียนข้อมูลลงไปในเส้นทางที่โปรเซสอื่นอาจกำลังถือครองอยู่ ให้เขียนข้อมูล (serialize) ลงในไฟล์ชั่วคราวในไดเรกทอรีเดียวกันก่อน จากนั้นสลับให้ไปอยู่ในตำแหน่งที่ต้องการด้วยการเปลี่ยนชื่อ (atomic rename) ทันทีที่ EndDoc ทำงานสำเร็จ หากโปรแกรมดูเอกสารยังคงเปิดไฟล์เก่าอยู่ การเปลี่ยนชื่อก็จะสำเร็จอย่างราบรื่นหรือไม่ก็ล้มเหลวอย่างชัดเจน และคุณก็สามารถแสดงข้อความที่ชัดเจนให้กับผู้ใช้งานได้ แทนที่จะต้องไปต่อสู้กับการล็อคไฟล์

สำหรับเซิร์ฟเวอร์ที่มีปริมาณงานสูง (high-volume server) ที่ทำการสร้างเอกสารอยู่ตลอดเวลา หลักการที่สะอาดกว่าคือการเขียนผลลัพธ์แต่ละไฟล์ภายใต้ชื่อที่ไม่ซ้ำกัน (อาจใช้ timestamp หรือ job id) ดังนั้นการทำงานสองครั้งจะไม่มีทางแย่งชิงเส้นทางเดียวกัน และปล่อยให้เป็นหน้าที่ของนโยบายการเก็บรักษาข้อมูล (retention policy) แบบแยกต่างหากในการตามไปลบไฟล์เก่าทิ้ง ไม่ว่าคุณจะเลือกวิธีไหนหลักการก็คือเดียวกัน: ออกแบบเพื่อให้ไฟล์ที่คุณกำลังเขียนเป็นไฟล์ของคุณเพียงผู้เดียว ณ ขณะที่คุณกำลังเขียนมัน ปัญหาการล็อคจะหายไปไม่ใช่เพราะคุณบังคับปิดหน้าต่าง แต่เป็นเพราะไม่มีกระบวนการอื่นใดที่กำลังแตะต้องข้อมูลไบต์เหล่านั้นอยู่

รูปแบบที่ถูกต้องของการแก้ไขปัญหา

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

การเรียกใช้งาน BeginDoc, EndDoc, LoadFromFile, และ SaveLoadedDocument ที่แสดงอยู่ที่นี่เป็นส่วนหนึ่งของคอมโพเนนต์ HotPDF Component สำหรับ Delphi และ C++Builder

แนวทางที่คาดเดาได้คือให้หนึ่งอินสแตนซ์รับผิดชอบเอกสารหนึ่งรอบ ตั้งแต่ Create และ BeginDoc จนถึง EndDoc แล้วเรียก Free ก่อนเริ่มเอกสารถัดไป วิธีนี้ป้องกันสถานะหน้าปัจจุบัน ฟอนต์ และออบเจกต์ภายในจากการค้างข้ามไฟล์

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