Artykuł techniczny

Ponowne użycie instancji THotPDF dla wielu dokumentów w Delphi

Błąd brzmi Please load the document before using BeginDoc i prawie zawsze pojawia się za drugim razem. Pierwszy dokument zostaje zapisany bez problemu. Następnie ta sama instancja THotPDF ma za zadanie rozpocząć drugi dokument, BeginDoc zgłasza wyjątek, a komunikat mówi o wczytaniu dokumentu, czyli czymś odwrotnym do tego, co próbuje zrobić kod. Ta rozbieżność między objawem a komunikatem to właśnie to, co czyni ten błąd tak uporczywym. Prawdziwym tematem jest cykl życia komponentu, i kiedy to zrozumiemy, błąd przestaje być tajemniczy

THotPDF document lifecycle showing Create, BeginDoc, EndDoc, and Free per output file
Jedna instancja THotPDF mapuje się na jeden dokument: Create, BeginDoc, draw, EndDoc, Free.

Instancja THotPDF to jeden dokument, a nie fabryka dokumentów

Kuszącym modelem myślowym jest postrzeganie THotPDF jako obiektu usługowego, który uruchamiasz raz i karmisz go dokumentami, podobnie jak utrzymujesz otwarte połączenie z bazą danych i wykonujesz w nim zapytanie za zapytaniem. Tak nie jest. Instancja modeluje proces tworzenia pojedynczego dokumentu, a jej wewnętrzna maszyna stanów zakłada jednorazowe przejście ścieżki: od pustego stanu, przez otwarty dokument, aż do zapisanego pliku. BeginDoc otwiera tę ścieżkę i oznacza instancję jako przetwarzającą dokument. EndDoc serializuje wszystko do pliku wskazanego w FileName i kończy pracę. Ponowne wywołanie BeginDoc na tej samej ukończonej instancji prosi ją o ponowne wejście w stan, z którego nigdy prawidłowo nie wyszła, a aktywowany strażnik jest właśnie tym, którego komunikat akurat wspomina o ładowaniu, ponieważ wewnątrz warunki "gotowy do rozpoczęcia" i "ma wczytany dokument" sprawdzane są jednocześnie

Komunikat jest więc mylący, ale strażnik wykonuje swoją pracę. Nie pozwala ci rozpocząć nowego dokumentu za pomocą komponentu, który wciąż „myśli”, że jest w trakcie przetwarzania poprzedniego. Rozwiązaniem nie jest pokonanie strażnika, ale zaprzestanie używania zużytej instancji

Cykl życia we właściwej kolejności

Każdy dokument, który HotPDF tworzy od podstaw, przechodzi przez te same cztery etapy i ich kolejność nie podlega negocjacjom. Create alokuje pamięć na komponent. BeginDoc otwiera dokument i ustala jego strukturę, więc wszystko, co wpływa na cały plik (rozmiar strony, kompresja, szyfrowanie, nazwa pliku wyjściowego), musi być ustawione między Create i BeginDoc. Potem wykonujesz rysowanie. Następnie EndDoc zapisuje bajty na dysku. Free zwalnia instancję. Wywołania poleceń rysowania wykonane przed BeginDoc nie mają strony, na której można je umieścić; właściwości obejmujące cały dokument przypisane po nim są ignorowane bez żadnego komunikatu

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;

Traktuj to jako jednostkę pracy. Jedno Create, jedno BeginDoc, jedno EndDoc, jedno Free, to jeden plik na dysku. Z chwilą, gdy chcesz wygenerować drugi plik, zaczynasz nową jednostkę pracy, co z kolei wiąże się z utworzeniem nowej instancji

Co powinno oznaczać "ponowne użycie": nowa instancja na każdy plik

Wersja, która ulega awarii, stara się oszczędzać zasoby przy alokacji pamięci: instancjonuje komponent tylko raz, uruchamia proces w pętli dla partii danych i wewnątrz niej wywołuje funkcje BeginDoc i EndDoc. Druga iteracja zgłasza błąd. Wersja poprawiona traktuje każdy plik wyjściowy jako niezależny obiekt o krótkim cyklu życia, a biorąc pod uwagę to, że koszt alokacji przy instancjonowaniu komponentu jest niewielki w porównaniu z pracą wymaganą do zaplanowania struktury i serializacji PDF, zachowywanie istniejącej instancji nie daje absolutnie żadnych oszczędności

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;

Obecność instrukcji try/finally wewnątrz pętli to coś, czego warto bronić podczas code review. Jeśli z powodu BeginDoc lub jakiegokolwiek polecenia rysowania zostanie zgłoszony błąd w trakcie tworzenia jednego z dokumentów, instancja przypisana do tej iteracji zostanie pomyślnie zwolniona jeszcze przed rozpoczęciem kolejnej. Dzięki temu błędny rekord nie pozostawi po sobie na wpół zbudowanego komponentu, który mógłby zatruć całą resztę procesu wykonawczego. Jeśli dla samej zasady zechcesz przeprowadzić "optymalizację" i wyciągniesz instrukcję Create przed samą pętlę, wrócisz do pierwotnego błędu, który będzie teraz przybierał formę procesu wsadowego (batch)

Modyfikacja istniejącego pliku to inny punkt wejścia

Istnieje drugie, w pełni zasadne podejście do koncepcji "ponownego użycia": nie potrzebujesz tworzyć pustego dokumentu, ale zamiast tego chcesz otworzyć istniejący dokument PDF, a następnie nałożyć na niego modyfikacje. Ta konkretna ścieżka w ogóle nie wykorzystuje instrukcji BeginDoc i to właśnie z tego powodu komunikat błędu wyraźnie wskazuje na kwestię ładowania danych. Najpierw ładujesz określony plik, potem nanosisz zmiany, po czym możesz zapisać dokument z wykorzystaniem dowolnej nazwy

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;

Polecenie LoadFromFile odsyła użytkownika do liczby stron w dokumencie. Wynik oznaczony wartością zero (lub mniejszą) to jednoznaczny sygnał na niepowodzenie w trakcie próby załadowania, dlatego też tę czynność zawsze warto poprzedzić sprawdzeniem wartości dla parametru CurrentPage. Bardzo duże znaczenie ma tutaj wykorzystanie par w trakcie pracy: dokument otwarty przy wykorzystaniu instrukcji LoadFromFile zostanie bez kłopotu zachowany poleceniem SaveLoadedDocument, jednak w tym celu nie użyjesz już wspomnianego zestawienia typu BeginDoc/EndDoc, dedykowanego do stosowania w tworzonych przez siebie dokumentach. Wprowadzenie zamieszania przez przemieszanie tych par to jeden z najczęstszych powodów prowadzących do oszukiwania samej logiki maszyny, a w konsekwencji i samych awarii początkowych. Oddziel więc myślowo dwa zasadnicze tryby postępowania podczas modelowania i wdróż własny wzorzec: BeginDoc ... EndDoc – służy do tworzenia, a LoadFromFile ... SaveLoadedDocument – służy do edytowania

Problem z blokadą pliku jest realny, a rozwiązaniem nie jest wymuszanie zamknięcia przeglądarek PDF

Błąd związany z ponownym użyciem (reuse error) często występuje w parze z innym problemem i oba zlewają się ze sobą, ponieważ pojawiają się w tym samym przepływie pracy związanym z regeneracją pliku. Użytkownik otwiera nowo wygenerowany PDF, pozostawia go otwartego w programie Acrobat lub Foxit, a następnie wyzwala ponowną kompilację (rebuild). EndDoc próbuje nadpisać tę samą ścieżkę, system operacyjny odmawia dostępu, ponieważ przeglądarka podtrzymuje współdzielenie pliku (read share), co blokuje procesy zapisujące, a ty otrzymujesz komunikat o odmowie dostępu. To jest autentyczny problem z blokowaniem plików przez Windows, a nie problem ze stanem komponentu, i zasługuje na solidne rozwiązanie, a nie powierzchowne obejście (workaround)

Obejście polegające na wyliczaniu okien najwyższego poziomu i wysyłaniu WM_CLOSE do wszystkiego, czego tytuł wygląda jak przeglądarka PDF, jest błędem. Przekracza to granice procesów, aby zamknąć okna, których twój program nie posiada, zgaduje co jest przeglądarką po tytule i może usunąć niezapisane adnotacje użytkownika bez pytania. Traktuj to całe podejście jako niepożądane (code smell). Niezawodnym rozwiązaniem jest, aby nigdy nie zapisywać w ścieżce, którą inny proces może blokować. Dokonaj serializacji do pliku tymczasowego w tym samym katalogu, a następnie zamień go w docelowe miejsce za pomocą atomowej zmiany nazwy (atomic rename), gdy EndDoc zakończy się sukcesem. Jeśli przeglądarka nadal ma otwarty stary plik, zmiana nazwy albo powiedzie się czysto, albo głośno zawiedzie, i będziesz mógł wyświetlić wyraźny komunikat, zamiast walczyć z blokadą

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // Temp file in the SAME directory as the target: a rename inside one
  // NTFS volume swaps the name atomically, while a cross-volume move
  // degrades to copy-plus-delete and loses that guarantee
  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;                    // the temp file is complete on disk here
    finally
      Pdf.Free;
    end;

    // Swap into place. TFile.Move refuses to overwrite, so clear a stale
    // target first; if a viewer still holds the old file, the delete is
    // what fails, loudly, before the good bytes are touched
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // or: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // never strand a half-written temp file
    raise;
  end;
end;

Dwa szczere przypisy do tego kodu. TFile.Move i klasyczna funkcja RenameFile mapują do tej samej operacji zmiany nazwy w systemie Windows, która jest atomowa tylko wtedy, gdy źródło i cel znajdują się na tym samym woluminie, i to właśnie dlatego plik tymczasowy trafia do katalogu docelowego, a nie do TPath.GetTempPath. A sama para operacji usuń-i-przenieś nie jest jednym atomowym krokiem: istnieje krótkie okno czasowe, w którym żaden z plików nie istnieje. W przypadku aplikacji komputerowej regenerującej raport to okno jest bez znaczenia; czytelnicy, którzy potrzebują silniejszego kontraktu na tym samym woluminie, mogą wywołać bezpośrednio funkcje Win32 ReplaceFile lub MoveFileEx z flagą MOVEFILE_REPLACE_EXISTING, co sprowadza zamianę do jednego wywołania

W przypadku serwera o dużej przepustowości, który stale regeneruje dokumenty, czystszym podejściem jest zapisywanie każdego wyniku pod unikalną nazwą (znacznik czasu lub identyfikator zadania), tak aby dwa uruchomienia nigdy nie rywalizowały o jedną ścieżkę, a następnie pozwolenie oddzielnej polityce przechowywania na sprzątanie starych plików. Ten wzorzec to w zasadzie jedna linijka dyscypliny w nazewnictwie dla każdego żądania:

// One output path per request: two concurrent jobs can never contend
// for the same name, so no rename dance and no lock to lose
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Identyfikator żądania lub identyfikator zadania działa równie dobrze jak GUID, gdy otaczający framework już ci go udostępnia, i sprawia, że nazwę pliku można za darmo powiązać ze wpisem w dzienniku logów. W każdym przypadku zasada jest ta sama: projektuj tak, aby plik, który zapisujesz, był tylko i wyłącznie twój w momencie jego zapisywania. Blokada znika nie dlatego, że wymusiłeś zamknięcie okna, ale dlatego, że nic innego nie dotyka tych bajtów

Kształt rozwiązania

Zdejmując z tych dwóch problemów kolejne warstwy aż do samych korzeni, okazuje się, że oba dotyczą poszanowania granic. Błąd maszyny stanów wymaga szacunku dla granic instancji: jedno THotPDF, jeden dokument, potem należy go zwolnić i utworzyć kolejny. Błąd blokady pliku zmusza do szanowania granic pliku: zapisuj tam, gdzie nikt inny nie czyta, a dopiero potem przenieś wynik na właściwe miejsce. Żaden z nich nie wymaga łatania biblioteki ani stosowania skryptów na pulpicie. Oba wynikają z traktowania każdego dokumentu jako niezależnej jednostki pracy, tworzonej od nowa, czysto zapisywanej i zwalnianej, co jest dokładnie tym samym wzorcem, który sprawia, że cała reszta komponentu jest przewidywalna

Wywołania metod BeginDoc, EndDoc, LoadFromFile oraz SaveLoadedDocument pokazane w tym przykładzie, należą do komponentu HotPDF dla środowisk programistycznych Delphi i C++Builder