Tekninen artikkeli

THotPDF-instanssin uudelleenkäyttö eri asiakirjoissa Delphissä

Virheilmoituksessa lukee Please load the document before using BeginDoc, ja se ilmaantuu lähes aina toisella kerralla. Ensimmäinen asiakirja kirjoittuu hienosti. Sitten samaa THotPDF-instanssia pyydetään aloittamaan toinen, BeginDoc nostaa poikkeuksen, ja viesti osoittaa asiakirjan lataamiseen, mikä on päinvastoin kuin mitä koodi yrittää tehdä. Epäsuhta oireen ja viestin välillä tekee tästä ongelmasta sitkeän. Todellinen aihe on komponentin elinkaari, ja kun se ymmärretään, virhe lakkaa olemasta mystinen

THotPDF-dokumentin elinkaari, joka näyttää Create-, BeginDoc-, EndDoc- ja Free-kutsut kutakin tulostustiedostoa kohti
Yksi THotPDF-instanssi yhdistyy yhteen asiakirjaan: Create, BeginDoc, piirrä, EndDoc, Free.

THotPDF-instanssi on yksi asiakirja, ei asiakirjatehdas

Houkutteleva mentaalimalli on, että THotPDF on palveluobjekti, jonka käynnistät kerran ja jolle syötät asiakirjoja, samalla tavalla kuin voisit pitää tietokantayhteyden auki ja suorittaa kyselyn toisensa perään sen kautta. Se ei ole sitä. Instanssi mallintaa yhtä rakennettavaa asiakirjaa, ja sen sisäinen tilakone (state machine) olettaa kulkevansa polun kerran: tyhjästä, avoimen asiakirjan kautta, tallennettuun tiedostoon. BeginDoc avaa tuon polun ja merkitsee instanssilla olevan keskeneräisen asiakirjan. EndDoc sarjallistaa (serializes) kaiken FileName-tiedostoon ja sulkee sen. BeginDoc-kutsun toistaminen samalle valmiille instanssille pyytää sitä palaamaan tilaan, josta se ei koskaan poistunut siististi, ja laukeava suojaus on se, jonka viesti sattuu mainitsemaan lataamisen, koska sisäisesti "valmis aloittamaan" ja "ladattu asiakirja" -ehdot tarkistetaan yhdessä

Joten viesti on harhaanjohtava, mutta suojaus tekee tehtävänsä. Se kieltäytyy antamasta sinun aloittaa uutta asiakirjaa sellaisen komponentin päälle, joka yhä uskoo olevansa keskellä asiakirjaa. Ratkaisu ei ole suojauksen kiertäminen. Se on kulutetun instanssin uudelleenkäytön lopettaminen

Elinkaari järjestyksessä, jossa sen on tapahduttava

Jokainen HotPDF:n tyhjästä kirjoittama asiakirja noudattaa samoja neljää vaihetta, ja järjestyksestä ei voi neuvotella. Create varaa komponentin muistiin. BeginDoc avaa asiakirjan ja lyö lukkoon rakenteelliset valinnat, joten kaikki mikä vaikuttaa koko tiedostoon (sivukoko, pakkaus, salaus, tulostustiedoston nimi) on asetettava Create- ja BeginDoc-kutsujen välissä. Sitten piirrät. Sitten EndDoc kirjoittaa tavut levylle. Free vapauttaa instanssin. Piirtokutsuilla, jotka on sijoitettu ennen BeginDoc-kutsua, ei ole sivua jolle laskeutua; sen jälkeen määritetyt koko asiakirjan ominaisuudet ohitetaan ilman valituksia

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'invoice.pdf';
    Pdf.BeginDoc;                        // avaa asiakirjan
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 760, 0, 'Invoice 2026-042');
    Pdf.EndDoc;                          // kirjoittaa invoice.pdf-tiedoston, sulkee sen
  finally
    Pdf.Free;                            // yksi instanssi, yksi asiakirja
  end;
end;

Lue tämä työn yksikkönä. Yksi Create, yksi BeginDoc, yksi EndDoc, yksi Free, yksi tiedosto levyllä. Heti kun haluat toisen tiedoston, aloitat uuden työn yksikön, mikä tarkoittaa uutta instanssia

Mitä "uudelleenkäytön" tulisi tarkoittaa: tuore instanssi joka tiedostolle

Rikkoutuva versio yrittää olla säästäväinen varaamisessa (allocation): rakenna komponentti kerran, kierrä erää (batch) silmukassa, kutsu BeginDoc ja EndDoc silmukan sisällä. Toinen iteraatio kaatuu. Toimiva versio käsittelee jokaista tulostusta omana lyhytikäisenä objektinaan, ja komponentin luomisen varaamiskustannus on vähäinen verrattuna PDF:n asettelun ja sarjallistamisen työhön, joten instanssin hamstraamisella ei saavuteta säästöjä

procedure WriteBatch(const Names: TArray<string>);
var
  I: Integer;
  Pdf: THotPDF;
begin
  for I := 0 to High(Names) do
  begin
    Pdf := THotPDF.Create(nil);         // uusi instanssi jokaisella kierroksella
    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;

Silmukan sisällä oleva try/finally on se osa, jota kannattaa puolustaa koodikatselmoinnissa. Jos BeginDoc tai jokin piirtokutsu nostaa poikkeuksen kesken yhden asiakirjan, kyseisen iteraation instanssi silti vapautetaan ennen seuraavan alkua, jolloin yksi huono tietue ei jätä puoliksi rakennettua komponenttia jumiin ja myrkytä loppua ajoa. Jos vedät Create-kutsun silmukan yläpuolelle "optimoimiseksi", olet palannut alkuperäiseen bugiin, joka on nyt pukeutunut eräajosilmukkaan

Olemassa olevan tiedoston muokkaaminen on eri aloituspiste

Sanoille "uudelleenkäyttö" on toinenkin tulkinta, joka on täysin oikeutettu: et halua tyhjää asiakirjaa, vaan haluat avata jo olemassa olevan PDF:n ja muuttaa sitä. Se polku ei kulje BeginDoc-kutsun kautta ollenkaan, ja juuri siksi virheilmoitus mainitsee lataamisen. Lataat tiedoston, muokkaat sitä ja tallennat haluamallasi nimellä

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 palauttaa sivumäärän, ja nolla tai sitä pienempi arvo tarkoittaa latauksen epäonnistumista, joten se kannattaa tarkistaa ennen kuin kosket CurrentPage-ominaisuuteen. Parinmuodostus on tärkeää: LoadFromFile-funktiolla avaamasi asiakirja tallennetaan SaveLoadedDocument-funktiolla, ei BeginDoc/EndDoc-parilla, joka kuuluu asiakirjoille, jotka luot tyhjästä. Näiden kahden sekoittaminen on yleisin tapa sekoittaa sama tilakone, joka tuotti alkuperäisen virheen. Pidä nämä kaksi kulkua mielessäsi erillään: BeginDoc ... EndDoc luo, LoadFromFile ... SaveLoadedDocument muokkaa

Tiedoston lukitusongelma on todellinen, eikä vastaus ole katseluikkunoiden tappaminen

Uudelleenkäyttövirhe kulkee usein toisen valituksen kanssa, ja nämä kaksi kietoutuvat toisiinsa, koska ne nousevat esiin samassa "generoi tiedosto uudelleen" -työnkulussa. Käyttäjä avaa juuri tuottamasi PDF:n, jättää sen auki Acrobatiin tai Foxitiin, ja laukaisee sitten uudelleenkoonnin. EndDoc yrittää kirjoittaa samaan polkuun, käyttöjärjestelmä kieltäytyy, koska katseluohjelmalla on lukujako (read share), joka estää kirjoittajat, ja saat access-denied -virheen (pääsy evätty). Tämä on aidosti Windowsin tiedostonlukitusongelma eikä komponentin tilaongelma, ja se ansaitsee todellisen vastauksen kiertotavan sijaan

Kiertotapa, joka kiertää verkossa, jossa luetellaan ylimmän tason ikkunat ja lähetetään WM_CLOSE-viesti mille tahansa, jonka otsikko näyttää PDF-katseluohjelmalta, on väärä vaisto. Se ulottuu prosessirajojen yli sulkemaan ikkunoita, joita ohjelmasi ei omista, se arvaa katseluohjelmat otsikkotekstin perusteella, ja se voi heittää pois käyttäjän tallentamattomat huomautukset kysymättä. Pidä koko lähestymistapaa huonona käytäntönä (smell). Luotettava ratkaisu on olla koskaan kirjoittamatta polkuun, jota toinen prosessi saattaa pitää auki. Sarjallista väliaikaistiedostoon samassa hakemistossa ja vaihda se paikoilleen atomisella uudelleennimeämisellä, kun EndDoc onnistuu. Jos katseluohjelmalla on yhä vanha tiedosto auki, uudelleennimeäminen joko onnistuu siististi tai epäonnistuu äänekkäästi, ja tuot esiin selkeän viestin lukon kanssa taistelemisen sijaan

uses
  System.SysUtils, System.IOUtils;

procedure WritePdfAtomically(const FinalPath: string);
var
  Pdf: THotPDF;
  TempPath: string;
begin
  // Väliaikaistiedosto SAMASSA hakemistossa kuin kohde: yhden
  // NTFS-taltion sisällä tapahtuva uudelleennimeäminen vaihtaa nimen atomisesti,
  // kun taas siirto taltioiden välillä heikkenee "kopioi ja poista" -toiminnoksi ja menettää takuun
  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;                    // väliaikaistiedosto on tässä valmis levyllä
    finally
      Pdf.Free;
    end;

    // Vaihda paikoilleen. TFile.Move kieltäytyy ylikirjoittamasta, joten tyhjennä vanhentunut
    // kohde ensin; jos katseluohjelma pitää yhä vanhaa tiedostoa auki, poisto
    // epäonnistuu äänekkäästi ennen kuin hyviin tavuihin kosketaan
    if TFile.Exists(FinalPath) then
      TFile.Delete(FinalPath);
    TFile.Move(TempPath, FinalPath); // tai: RenameFile(TempPath, FinalPath)
  except
    if TFile.Exists(TempPath) then
      TFile.Delete(TempPath);        // älä koskaan jätä puoliksi kirjoitettua väliaikaistiedostoa orvoksi
    raise;
  end;
end;

Kaksi rehellistä alaviitettä tuohon koodiin. TFile.Move ja klassinen RenameFile yhdistyvät samaan Windowsin uudelleennimeämiseen, joka on atominen vain silloin, kun lähde ja kohde sijaitsevat samalla taltiolla (volume), ja juuri siksi väliaikaistiedosto menee kohdehakemistoon TPath.GetTempPath-hakemiston sijaan. Ja poista-sitten-siirrä -pari ei itsessään ole yksi atominen vaihe: on olemassa lyhyt ikkuna, jolloin kumpaakaan tiedostoa ei ole olemassa. Raportin uudelleen generoivalle työpöytäsovellukselle tuo ikkuna on merkityksetön; lukijat, jotka tarvitsevat vahvemman sopimuksen samalla taltiolla, voivat kutsua Win32:n ReplaceFile- tai MoveFileEx-funktiota parametrilla MOVEFILE_REPLACE_EXISTING suoraan, mikä tiivistää vaihdon yhdeksi kutsuksi

Suuren volyymin palvelimelle, joka generoi asiakirjoja jatkuvasti, puhtaampi kurinalaisuus on kirjoittaa jokainen tuloste yksilöllisellä nimellä (aikaleima tai työn tunniste), jotta kaksi ajoa ei koskaan kilpaile samasta polusta, ja antaa erillisen säilytyskäytännön (retention policy) siivota vanhat tiedostot. Kuvio on yksi rivi nimeämiskurinalaisuutta pyyntöä kohti

// Yksi tulostuspolku pyyntöä kohti: kaksi samanaikaista työtä ei voi koskaan kilpailla
// samasta nimestä, joten ei uudelleennimeämistanssia eikä menetettävää lukkoa
OutName := Format('statement-%s-%s.pdf',
  [CustomerId, TGUID.NewGuid.ToString.Trim(['{', '}'])]);
Pdf.FileName := TPath.Combine(OutputDir, OutName);

Pyynnön tunniste (request id) tai työn tunniste (job id) toimii aivan yhtä hyvin kuin GUID, kun ympäröivä kehys (framework) jo ojentaa sellaisen sinulle, ja se tekee tiedostonimestä ilmaiseksi jäljitettävän takaisin lokiriville. Kummassakin tapauksessa periaate on sama: suunnittele niin, että kirjoittamasi tiedosto on yksin sinun sillä hetkellä, kun kirjoitat sitä. Lukko ei katoa siksi, että pakotit ikkunan kiinni, vaan siksi, että mikään muu ei kosketa tavuja

Korjauksen muoto

Pura molemmat ongelmat takaisin juuriinsa, ja molemmissa on kyse rajojen kunnioittamisesta. Tilakonevirhe haluaa sinun kunnioittavan instanssirajaa: yksi THotPDF, yksi asiakirja, päästä sitten irti ja tee uusi. Tiedoston lukitusvirhe haluaa sinun kunnioittavan tiedostorajaa: kirjoita sinne missä mikään muu ei lue, ja siirrä sitten tulos paikoilleen. Kumpikaan ei edellytä kirjaston paikkaamista tai työpöydän skriptaamista. Molemmat ratkeavat käsittelemällä jokaista asiakirjaa itsenäisenä työn yksikkönä, luotuna tuoreeltaan, kirjoitettuna puhtaasti ja vapautettuna, mikä on sama kuvio, joka tekee lopusta komponentista ennustettavan

Tässä esitetyt BeginDoc-, EndDoc-, LoadFromFile- ja SaveLoadedDocument-kutsut ovat osa HotPDF-komponenttia Delphille ja C++Builderille