Odborný článok

Odosielanie PDF cez CDO v Delphi: úskalia apartmentového vláknenia

PDFlibPas, vývojárska PDF knižnica spoločnosti losLab pre Delphi a C++Builder, odosiela vygenerované PDF ako prílohu e-mailu jediným plochým volaním API, SendDocumentByMail. V systéme Windows predvolený prenos používa CDO (Collaboration Data Objects), poštovú COM komponentu zabudovanú priamo do operačného systému, a detail, ktorý skutočne rozbíja viacvláknové dávkové úlohy, je inicializácia apartmentu COM, nie SMTP

Scenár za týmto API je nenápadný a mimoriadne bežný: služba vykreslí dávku mesačných výpisov vo formáte PDF, jeden na zákazníka, a musí každý z nich odoslať mailom bez zásahu človeka. Presuňte túto úlohu do fondu vlákien kvôli priepustnosti a časť odosielaní začne zlyhávať s chybou COM, ktorá sa nikdy nezopakuje, keď ten istý kód beží na jednom vlákne. Nič nie je zlé so serverom SMTP, s PDF ani s prílohou. Problémom je to, čo vráti CoInitializeEx na vlákne, ktoré CDO neočakávalo, a PDFlibPas je napísaný tak, aby tento prípad ošetril zámerne, nie náhodou

Čo SendDocumentByMail vnútri PDFlibPas skutočne robí

SendDocumentByMail je tenký orchestrátor, nie poštový klient sám osebe. TPDFlib.SendDocumentByMail uloží aktuálne načítaný dokument do vlastného dočasného súboru PDF, zabalí nastavenia SMTP a text správy do záznamu TPDFlibMailRequest, odovzdá tento záznam čomukoľvek, čo implementuje IPDFlibMailProvider, a po návrate poskytovateľa dočasný súbor opäť zmaže. Rozhranie poskytovateľa je skutočný poštový klient a PDFlibPas dodáva presne jednu vstavanú implementáciu: poskytovateľa založeného na CDO, ktorý sa kompiluje iba na Windows. Zavolajte SendDocumentByMail bez toho, aby ste najprv priradili vlastnosť MailProvider, a PDFlibPas sa automaticky vráti k tomuto predvolenému poskytovateľovi. Návratová hodnota zostáva zámerne úzka: 1 pre prijaté, 0 pre čokoľvek iné, či už ide o chýbajúce povinné pole, chybu zápisu dočasného súboru, alebo o odmietnutie správy poskytovateľom, s presnejším dôvodom dostupným až následne z GetLastMailError

var
  PDF: TPDFlib;
  Sent: Integer;
begin
  PDF := TPDFlib.Create;              // a new instance already holds one blank document
  try
    PDF.SetPageDimensions(612, 792);  // US Letter, in points
    PDF.NewPage;
    // ... draw the statement: fonts, text, totals ...
    Sent := PDF.SendDocumentByMail(
      'smtp.example.com', 0, 1,                 // port 0 with SSL 1 falls back to 465
      'billing@example.com', 'app-password',    // SMTP auth
      'billing@example.com', 'customer@example.com', '', '',
      'Your statement is ready',
      'Please find the attached PDF statement.',
      'statement-4471.pdf');                    // attachment display name
    if Sent <> 1 then
      Writeln('Send failed: ', PDF.GetLastMailError);
  finally
    PDF.Free;
  end;
end;

Prečo CoInitializeEx vracia S_FALSE a je to vôbec zlyhanie?

S_FALSE z CoInitializeEx nie je zlyhanie, a kód, ktorý ho ako zlyhanie ošetruje, hlási chyby na vláknach, kde sa v skutočnosti nič nepokazilo. CoInitializeEx vráti S_OK pri prvej úspešnej inicializácii COM na danom vlákne a vráti S_FALSE, keď malo toto vlákno COM už inicializované s kompatibilným modelom súbežnosti, pričom oba prípady zvýšia ten istý počítadlo referencií na dané vlákno, takže obe výsledky si vyžadujú zodpovedajúce volanie CoUninitialize pred ukončením vlákna alebo pred prechodom na nesúvisiacu prácu. Samotná TPDFlib nasleduje presne tento vzor: zostrojenie inštancie TPDFlib už volá CoInitialize a zaznamenáva, či je za sebou dlžné zodpovedajúce CoUninitialize, pomocou identickej kontroly S_OK alebo S_FALSE. V čase, keď sa SendDocumentByMail dostane k svojmu poskytovateľovi CDO a ten znovu zavolá CoInitializeEx, je teda COM na danom vlákne v bežnom prípade už inicializované, takže poskytovateľ takmer vždy pozoruje S_FALSE, nie S_OK. Zaobchádzať s S_FALSE ako s čímkoľvek iným než úspechom nie je v tejto knižnici zriedkavý okrajový prípad – je to bežná cesta

InitResult := CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
NeedUninitialize := (InitResult = S_OK) or (InitResult = S_FALSE);
if Failed(InitResult) and (InitResult <> RPC_E_CHANGED_MODE) then
begin
  ErrorText := 'COM initialization failed';
  Exit;
end;
try
  // ... create CDO.Message, CDO.Configuration, send ...
finally
  if NeedUninitialize then
    CoUninitialize;
end;

Prečo CoInitializeEx vracia RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE znamená, že aktuálne vlákno inicializovalo COM už skôr pod iným modelom súbežnosti, než aký toto volanie požaduje – typicky preto, že vlákno bolo predtým prepnuté do viacvláknového apartmentu (MTA) a CDO teraz žiada sémantiku jednovláknového apartmentu (STA) prostredníctvom COINIT_APARTMENTTHREADED. Vlákno si svoj model apartmentu vyberá raz, a nič to nemôže po zvyšok jeho života zmeniť; opakovanie CoInitializeEx s inými príznakmi nesúlad nevyrieši, a volanie CoUninitialize najprv by zrušilo apartment, na ktorom môže stále závisieť iný kód bežiaci na tomto vlákne. PDFlibPas považuje RPC_E_CHANGED_MODE za stav, s ktorým treba pracovať, nie za chybu, ktorú treba hlásiť: preskočí párové CoUninitialize, keďže volanie v skutočnosti nikdy nezískalo referenciu na uvoľnenie, a nechá odosielanie pokračovať v existujúcom apartmente

RPC_E_CHANGED_MODE sa objavuje takmer výlučne na opätovne použitých vláknach: pracovné vlákno fondu vlákien, vlákno IIS alebo hostiteľa služby, alebo akékoľvek vlákno, kde skorší kód, ako ADO alebo WMI, už zavolal CoInitializeEx s COINIT_MULTITHREADED skôr, než sa k nemu poštový kód vôbec dostal. Celkom nové vlákno, ktoré nerobí nič iné než volanie SendDocumentByMail, na túto cestu nenarazí. Pracovné vlákno recyklované tisíckrát denne dávkovým plánovačom, zdieľané aj s inou prácou založenou na COM, na ňu absolútne narazí, a to prerušovane, čo je presne ten vzor, ktorý ľudí najprv privedie k skúmaniu servera SMTP a až potom k modelu vláknenia

Ako udržať prílohu mimo nesprávneho adresára

PDFlibPas zapisuje každú odchádzajúcu prílohu do čerstvého adresára pomenovaného podľa GUID, ktoré generuje pri každom volaní SendDocumentByMail, konkrétne preto, aby sa súbežné odosielania nikdy nezrazili na tom istom názve súboru a aby názov prílohy nemohol vyjsť mimo tento adresár. S názvom odovzdaným ako príloha sa nezaobchádza ako s dôveryhodnou cestou: prechádza cez PLSanitizeAttachmentName, ktorá odstráni akúkoľvek zložku adresára, odmietne prázdny reťazec a špeciálne názvy . a .., a nahradí každý znak, ktorý Windows považuje za nedovolený v názve súboru, spolu s akýmkoľvek riadiacim znakom, podčiarkovníkom. Podsuňte jej ..\quarter:report.pdf, čiastočne prechod adresárom a čiastočne nedovolenú dvojbodku, a na disk sa dostane quarter_report.pdf: všetko až po posledný oddeľovač cesty sa zahodí a dvojbodka sa zmení na podčiarkovník, pretože v názve súboru Windows nemôže byť

function PLSanitizeAttachmentName(const FileName: WideString): WideString;
var
  I, P: Integer;
begin
  P := LastDelimiter('/\', string(FileName));
  Result := Copy(FileName, P + 1, MaxInt);       // strip any directory part
  if (Result = '') or (Result = '.') or (Result = '..') then
    Result := 'document.pdf';
  for I := 1 to Length(Result) do
    if (Ord(Result[I]) < 32) or (Pos(Result[I], WideString('<>:"/\|?*')) > 0) then
      Result[I] := '_';
end;

Vyhradený adresár na jedno volanie nie je len otázkou poriadku. SendDocumentByMail po odoslaní správy v bloku finally zmaže dočasný súbor a odstráni jeho adresár, pomocou tej istej cesty, do ktorej zapisovala, takže názov prílohy, ktorý by sa k tomuto kódu dostal neošetrený, by nespôsobil len nesprávne umiestnenie zápisu. Rovnaká neošetrená cesta by potom dorazila do upratovacieho kroku, ktorý zavolá DeleteFile bez ďalších otázok, a v zdieľanom dočasnom priečinku by si dve súbežné odosielania mohli aj potichu navzájom prepísať prílohu pod tým istým názvom skôr, než by ktorékoľvek doručenie doběhlo. Ošetrenie názvu uzatvára prípad prechodu adresárom a adresár na jedno volanie s GUID uzatvára prípad kolízie, a ani jedno samo osebe by nestačilo

Zosúladenie životnosti COM so životnosťou vlákna vo fonde pracovníkov

Najspoľahlivejšia oprava zlyhaní apartmentového vláknenia v dávkovom odosielaní pošty je prestať zaobchádzať s každým volaním SendDocumentByMail ako s vlastnou izolovanou životnosťou COM, a namiesto toho inicializovať COM raz na pracovné vlákno, na celú jeho životnosť. Pracovník, ktorý pri štarte zavolá CoInitializeEx(nil, COINIT_APARTMENTTHREADED), ponechá si tento apartment pre každé volanie SendDocumentByMail, ktoré vykoná, a pri ukončení zavolá CoUninitialize presne raz, nikdy nenarazí na RPC_E_CHANGED_MODE z vlastného odosielania pošty, pretože nič iné na danom vlákne nedostane príležitosť inicializovať COM najprv v konfliktnom režime. Každé jednotlivé volanie SendDocumentByMail pod týmto vzorom stále interne spúšťa vlastnú dvojicu CoInitializeEx a CoUninitialize, a to je neškodné: s apartmentom už založeným pracovným vláknom teraz každé z týchto interných volaní vidí S_FALSE, zvýši a zníži ten istý počítadlo referencií a ponechá vlastný apartment COM pracovného vlákna nedotknutý

type
  TMailWorker = class(TThread)
  protected
    procedure Execute; override;
  end;

procedure TMailWorker.Execute;
var
  PDF: TPDFlib;
  Job: TStatementJob;
begin
  CoInitializeEx(nil, COINIT_APARTMENTTHREADED);
  try
    while not Terminated do
    begin
      if not TryGetNextJob(Job) then
        Break;
      PDF := TPDFlib.Create;
      try
        BuildStatement(PDF, Job);
        if PDF.SendDocumentByMail(Job.Host, 0, 1, Job.User, Job.Pass,
             Job.From, Job.Recipient, '', '', Job.Subject, Job.Body,
             Job.AttachmentName) <> 1 then
          LogFailure(Job, PDF.GetLastMailError);
      finally
        PDF.Free;
      end;
    end;
  finally
    CoUninitialize;
  end;
end;

Diagnostika zlyhaní a testovanie bez živej poštovej schránky

GetLastMailError je druhá polovica tohto API, ktorú sa oplatí zapracovať do logovania hneď od začiatku, pretože samotná návratová hodnota 1 alebo 0 nehovorí, či bolo zlyhané odosielanie problémom inicializácie COM, odmietnutím autentifikácie SMTP, alebo chýbajúcou prílohou. Vlastnosť MailProvider je to, čo robí celú cestu testovateľnou bez skutočnej poštovej schránky: priraďte jej implementáciu IPDFlibMailProvider, ktorá žiadosti zaznamenáva namiesto ich odosielania, spustite dávkovú úlohu proti tomuto falošnému poskytovateľovi v CI pipeline, a tie isté volania SendDocumentByMail naďalej fungujú nezmenené, akonáhle MailProvider ponecháte nenastavené a PDFlibPas sa v produkcii vráti k vstavanému prenosu CDO

Dávková úloha, ktorá odosiela výpisy mailom, sa málokedy zastaví pri samotnom odoslaní: ten istý pipeline často potrebuje pred odoslaním overiť a podpísať PDF, čo je pokryté samostatne v článku o pracovisku pre súlad a podpisovanie, keďže preflight kontrola a overenie podpisu sú iná záležitosť než doručenie pošty, aj keď obe bežia hneď za sebou. Keď sú dokumenty odosielané mailom samy osebe výstupom veľkého zlučovania alebo delenia namiesto jedného čerstvo vytvoreného PDF, tento krok generovania pokrýva sprievodca priamym prístupom pre veľké PDF. SendDocumentByMail a tu opísaný model poskytovateľa pošty sú súčasťou štandardnej PDFlibPas, vývojárskej PDF knižnice pre Delphi a C++Builder, a produktová stránka obsahuje úplnú referenciu API spolu so skúšobným stiahnutím