Bài viết kỹ thuật

Tái sử dụng một Thực thể THotPDF qua Nhiều Tài liệu trong Delphi

Thông báo lỗi ghi là Please load the document before using BeginDoc, và nó hầu như luôn xuất hiện ở lần thứ hai. Tài liệu đầu tiên ghi tốt. Sau đó, cùng một thực thể THotPDF đó được yêu cầu bắt đầu một tài liệu thứ hai, BeginDoc đưa ra ngoại lệ (raises), và thông báo lại chỉ ra việc tải một tài liệu, điều này hoàn toàn trái ngược với những gì mã đang cố gắng làm. Sự không khớp giữa triệu chứng và thông báo là lý do khiến lỗi này trở nên dai dẳng. Chủ đề thực sự là vòng đời của thành phần (component lifecycle), và một khi điều đó đã rõ ràng thì lỗi không còn là một điều bí ẩn nữa

Vòng đời của tài liệu THotPDF hiển thị Create, BeginDoc, EndDoc, và Free cho mỗi tệp đầu ra
Một thực thể THotPDF ánh xạ tới một tài liệu: Create, BeginDoc, vẽ, EndDoc, Free.

Một thực thể THotPDF là một tài liệu, không phải là một nhà máy sản xuất tài liệu

Mô hình tinh thần (mental model) hấp dẫn là coi THotPDF như một đối tượng dịch vụ (service object) mà bạn khởi tạo một lần và truyền tài liệu vào đó, giống như cách bạn giữ một kết nối cơ sở dữ liệu mở và chạy hết truy vấn này đến truy vấn khác qua nó. Nhưng không phải như vậy. Một thực thể lập mô hình (models) cho một tài liệu đơn lẻ đang được xây dựng, và cỗ máy trạng thái bên trong của nó mang theo giả định rằng nó chỉ đi qua con đường đó một lần: từ trạng thái trống (empty), qua một tài liệu đang mở (open document), đến một tệp đã lưu (saved file). BeginDoc mở ra con đường đó và đánh dấu thực thể là đang có một tài liệu dang dở (in progress). EndDoc tuần tự hóa mọi thứ (serializes) ra FileName và đóng nó lại. Việc gọi lại BeginDoc một lần nữa trên cùng một thực thể đã hoàn thành là yêu cầu nó nhập lại vào một trạng thái mà nó chưa bao giờ thoát ra một cách sạch sẽ, và đoạn mã bảo vệ (guard) bị kích hoạt chính là cái có thông báo ngẫu nhiên đề cập đến việc tải (loading), bởi vì trong nội bộ, các điều kiện "sẵn sàng để bắt đầu" (ready to begin) và "đã tải một tài liệu" (has a loaded document) được kiểm tra cùng nhau

Vì vậy, thông báo này có thể gây hiểu nhầm, nhưng đoạn mã bảo vệ vẫn đang làm đúng công việc của mình. Nó đang từ chối để bạn bắt đầu một tài liệu mới hoàn toàn (fresh document) trên một thành phần vẫn tin rằng nó đang ở giữa chừng một tài liệu (mid-document). Cách sửa lỗi không phải là đánh bại đoạn mã bảo vệ. Mà là ngừng việc tái sử dụng một thực thể đã dùng xong (spent instance)

Vòng đời, theo đúng trật tự bắt buộc phải xảy ra

Mỗi tài liệu HotPDF được viết lại từ đầu đều tuân theo cùng bốn nhịp độ (beats), và thứ tự này không thể thương lượng. Create cấp phát thành phần (allocates). BeginDoc mở tài liệu và cố định các lựa chọn về cấu trúc, vì vậy bất cứ thứ gì ảnh hưởng đến toàn bộ tệp (kích thước trang, độ nén, mã hóa, tên tệp đầu ra) đều phải được thiết lập giữa CreateBeginDoc. Sau đó bạn vẽ. Rồi EndDoc ghi các byte xuống đĩa. Free giải phóng thực thể. Các lệnh gọi vẽ đặt trước BeginDoc sẽ không có trang nào để hiển thị lên; các thuộc tính cấp toàn tài liệu gán sau đó sẽ bị phớt lờ mà không có lời phàn nàn nào

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // mở tài liệu
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // viết invoice.pdf, và đóng nó lại
  finally
    Pdf.Free;                            // một thực thể, một tài liệu
  end;
end;

Hãy xem đó như là đơn vị công việc (unit of work). Một Create, một BeginDoc, một EndDoc, một Free, một tệp tin trên đĩa. Khoảnh khắc bạn muốn một tệp thứ hai, bạn đang bắt đầu một đơn vị công việc mới, điều đó có nghĩa là cần một thực thể mới

Thế nào mới nên gọi là "tái sử dụng": một thực thể mới tinh (fresh instance) cho mỗi tệp

Phiên bản hay bị hỏng cố gắng tiết kiệm cấp phát bộ nhớ (frugal with allocation): xây dựng thành phần một lần, lặp qua một lô (batch), gọi BeginDocEndDoc bên trong vòng lặp. Vòng lặp thứ hai văng lỗi (throws). Phiên bản hoạt động đúng cách xử lý mỗi đầu ra như một đối tượng có tuổi thọ ngắn (short-lived object) của riêng nó, và chi phí cấp phát bộ nhớ (allocation cost) để tạo một thành phần là không đáng kể (trivial) khi đặt cạnh khối lượng công việc bố cục và tuần tự hóa (serializing) một tệp PDF, vì vậy chẳng có gì đáng để tiết kiệm bằng cách tích trữ khư khư lấy (hoarding) thực thể đó

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // thực thể mới cho mỗi lượt
    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;

Khối try/finally nằm bên trong vòng lặp là phần đáng được bảo vệ trong quá trình duyệt mã (review). Nếu BeginDoc hoặc bất kỳ lệnh gọi vẽ nào gây ra ngoại lệ (raises) khi mới vẽ được nửa chừng của một tài liệu, thực thể của lần lặp đó vẫn được giải phóng trước khi bắt đầu lần lặp tiếp theo, như vậy một bản ghi dữ liệu hỏng sẽ không để lại một thành phần bị kẹt xây dựng dở dang (strand a half-built component) và đầu độc phần còn lại của tiến trình chạy. Việc lôi Create ra phía trên vòng lặp để "tối ưu hóa" (optimize) sẽ đưa bạn trở lại lỗi ban đầu, giờ đây lại đội lốt một vòng lặp lô (batch loop)

Sửa đổi một tệp đã có là một điểm truy cập khác

Có một cách hiểu thứ hai về "tái sử dụng" (reuse) hoàn toàn hợp lệ (legitimate): bạn không muốn một tài liệu trống, bạn muốn mở một tệp PDF đã tồn tại và thay đổi nó. Con đường đó hoàn toàn không đi qua BeginDoc, đó chính xác là lý do tại sao thông báo lỗi lại gọi tên việc tải tài liệu (loading). Bạn tải tệp, chỉnh sửa nó, và lưu dưới bất kỳ tên nào bạn chọn

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 trả về số lượng trang, và giá trị 0 hoặc nhỏ hơn có nghĩa là quá trình tải thất bại, vì vậy nó đáng để kiểm tra trước khi bạn chạm vào CurrentPage. Sự kết hợp (pairing) rất quan trọng: một tài liệu bạn đã mở bằng LoadFromFile phải được lưu bằng SaveLoadedDocument, chứ không phải với cặp BeginDoc/EndDoc, cặp này thuộc về các tài liệu bạn tự tạo từ con số không (author from nothing). Trộn lẫn hai luồng này là cách phổ biến nhất gây nhầm lẫn cho cùng cỗ máy trạng thái (state machine) đã tạo ra lỗi ban đầu. Hãy phân định tách biệt hai luồng này trong đầu: BeginDoc ... EndDoc để tạo mới, LoadFromFile ... SaveLoadedDocument để chỉnh sửa

Vấn đề khóa tệp là có thật, và câu trả lời không phải là tiêu diệt cửa sổ trình xem

Lỗi tái sử dụng thường đi kèm với một lời phàn nàn thứ hai, và cả hai bị rối vào nhau vì chúng nổi lên trong cùng một luồng công việc tạo lại tệp (regenerate-the-file workflow). Một người dùng mở tệp PDF bạn vừa sản xuất, để nó mở trong Acrobat hoặc Foxit, sau đó kích hoạt thao tác tái tạo lại (rebuild). EndDoc cố gắng ghi đè lên cùng một đường dẫn đó, hệ điều hành từ chối vì trình xem PDF (viewer) đang giữ quyền chia sẻ đọc (read share) ngăn chặn các trình ghi (writers), và bạn nhận được thông báo lỗi từ chối truy cập (access-denied failure). Lỗi này thực sự là một vấn đề khóa tệp (file-locking issue) của Windows hơn là vấn đề về trạng thái thành phần (component-state issue), và nó xứng đáng nhận một câu trả lời đích đáng thay vì chỉ là một giải pháp tình thế (workaround)

Giải pháp tình thế vẫn hay truyền tay nhau, đó là liệt kê (enumerating) các cửa sổ lớp trên cùng (top-level windows) và đăng tải (posting) lệnh WM_CLOSE đến bất cứ thứ gì có tên tiêu đề (title) trông giống như một trình xem PDF, đó là một bản năng sai lầm (wrong instinct). Nó vươn tay vượt qua ranh giới tiến trình (process boundaries) để đóng các cửa sổ mà chương trình của bạn không sở hữu, nó phỏng đoán các trình xem dựa trên văn bản tiêu đề, và nó có thể vứt bỏ các chú thích (annotations) chưa được lưu của người dùng mà không thèm hỏi ý kiến. Hãy coi toàn bộ cách tiếp cận đó như là một dấu hiệu mã hỏng (code smell). Cách sửa chữa đáng tin cậy là không bao giờ ghi đè vào một đường dẫn mà tiến trình khác có thể đang nắm giữ. Tuần tự hóa vào một tệp tạm (temporary file) trong cùng một thư mục, sau đó tráo đổi (swap) nó vào đúng vị trí với một thao tác đổi tên nguyên tử (atomic rename) khi EndDoc thành công. Nếu một trình xem vẫn đang mở tệp cũ, thao tác đổi tên hoặc thành công một cách gọn gàng, hoặc vỡ lở ầm ĩ (fails loudly), và bạn đưa ra được một thông báo rõ ràng thay vì phải vật lộn với cái lệnh khóa tệp

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // Tệp tạm nằm CÙNG thư mục với đích đến: một thao tác đổi tên bên trong 
  // một ổ đĩa phân vùng NTFS hoán đổi tên hoàn toàn nguyên tử, trong khi thao tác 
  // chuyển tệp xuyên qua các phân vùng ổ đĩa sẽ suy biến thành tác vụ copy-kèm-xóa 
  // và tự đánh mất đặc ân đảm bảo đó
  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;                    // tệp tạm đã được tạo trọn vẹn trên đĩa tại đây
    finally
      Pdf.Free;
    end;

    // Tráo tệp vào đúng vị trí. TFile.Move từ chối ghi đè, vì vậy cần phải xóa cái 
    // đích cũ trước; nếu một trình xem vẫn còn giữ tệp cũ, lệnh xóa (delete)
    // sẽ là điểm vỡ lở, ầm ĩ (loudly), trước cả khi mảng byte mới tinh tươm bị chạm tới
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // hoặc: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // không bao giờ để tệp tạm bị viết dở dang bơ vơ
    raise;
  end;
end;

Hai chú thích thành thực (honest footnotes) về đoạn mã trên. TFile.MoveRenameFile cổ điển đều ánh xạ tới cùng một thao tác đổi tên của Windows, thao tác này chỉ có tính nguyên tử (atomic) khi tệp nguồn và đích nằm trên cùng một phân vùng (volume), và đó chính xác là lý do tại sao tệp tạm (temp file) lại đi vào trong thư mục đích thay vì TPath.GetTempPath. Và cặp thao tác xóa-rồi-chuyển (delete-then-move) bản thân nó không phải là một bước nguyên tử (atomic step) nguyên bản đơn nhất: có một khoảng thời gian trống ngắn ngủi (brief window) mà ở đó cả hai tệp đều không tồn tại. Đối với một ứng dụng máy tính để bàn đang tái tạo lại một báo cáo, khoảng trống đó chẳng có ý nghĩa gì (irrelevant); những người đọc cần một thỏa thuận mạnh mẽ hơn (stronger contract) trên cùng một phân vùng có thể gọi trực tiếp API Win32 là ReplaceFile hoặc MoveFileEx với tùy chọn MOVEFILE_REPLACE_EXISTING, phương án này thu gọn thao tác tráo đổi (swap) vào trong duy nhất một lời gọi hàm duy nhất

Đối với một máy chủ khối lượng lớn liên tục tái tạo các tài liệu, một kỷ luật (discipline) làm sạch hiệu quả hơn là viết mỗi đầu ra dưới một tên tệp duy nhất (ví dụ: dấu thời gian hoặc id công việc (job id)) để hai đợt chạy không bao giờ tranh giành nhau (contend) chung một đường dẫn, và phó thác cho một chính sách lưu giữ (retention policy) tách biệt quét dọn các tệp cũ. Mẫu khuôn chuẩn là mỗi dòng quy tắc định danh chỉ dành cho mỗi một lượt truy xuất yêu cầu (request)

// Mỗi đường dẫn đầu ra dành cho một request: hai luồng xử lý chạy đồng thời 
// không bao giờ tranh giành một cái tên, nhờ thế khỏi cần múa may đổi tên hay sợ khóa file
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Một id truy xuất (request id) hoặc job id cũng có tác dụng tốt y chang cái mã GUID khi mà cơ sở nền tảng framework bao quanh vốn dĩ đã cung cấp sẵn (hands you) cho bạn một mã, và nó khiến cho tên tệp tin truy ngược (traceable) lại tới dòng log một cách hoàn toàn miễn phí (for free). Dù bằng cách nào, nguyên tắc đều như nhau: hãy thiết kế sao cho tệp mà bạn đang viết thuộc về riêng bạn một mình ngay lúc bạn ghi đè vào nó. Lỗi khóa tệp biến mất không phải vì bạn đã cưỡng ép (forced) một cửa sổ đóng lại, mà là vì không có cái gì khác đang đụng chạm vào các byte dữ liệu

Định dạng cho bộ vá lỗi

Gọt trần (Strip) hai vấn đề này về tận rễ của chúng và thực chất cả hai đều xoay quanh chuyện tôn trọng các ranh giới (respecting boundaries). Lỗi cỗ máy trạng thái (state-machine error) muốn bạn tôn vinh (honor) ranh giới thực thể (instance boundary): một THotPDF, một tài liệu, sau đó giải thoát nó và tạo mới một cái khác. Lỗi khóa tệp (file-lock error) muốn bạn tôn trọng ranh giới tệp tin (file boundary): viết vào chỗ không có cái gì khác đang đọc (nothing else is reading), sau đó di chuyển kết quả vào đúng vị trí. Chẳng có điều nào yêu cầu việc vá lỗi (patching) thư viện hay lập kịch bản (scripting) trên màn hình máy tính để bàn (desktop) cả. Cả hai trường hợp đều được giải quyết khi xử lý (treating) từng tài liệu như một đơn vị công việc độc lập tự thân, được tạo mới tinh tươm, ghi trọn vẹn dứt điểm sạch sẽ, và được giải phóng (released), vốn cũng chính là mẫu khuôn chung (pattern) khiến cho phần còn lại của thành phần trở nên dễ đoán (predictable)

Các lệnh BeginDoc, EndDoc, LoadFromFile, và SaveLoadedDocument được hiển thị tại đây là một phần của Thành phần HotPDF dành cho Delphi và C++Builder