Articol tehnic

Trimiterea PDF-urilor prin e-mail cu CDO în Delphi: capcanele de threading pe apartament

PDF Library for Delphi, biblioteca de dezvoltare PDF losLab pentru Delphi și C++Builder, trimite un PDF generat ca atașament de e-mail printr-un singur apel API plat, SendDocumentByMail. Pe Windows, transportul implicit folosește CDO (Collaboration Data Objects), componenta de mail COM încorporată în sistemul de operare, iar detaliul care efectiv rupe joburile de lot multi-thread este inițializarea de apartament COM, nu SMTP

Scenariul din spatele acestui API este neglamuros și extrem de comun: un serviciu randează un lot de PDF-uri de extras de cont de sfârșit de lună, unul per client, și trebuie să trimită fiecare prin poștă fără o persoană în buclă. Împingeți acel job pe un pool de thread-uri pentru debit, iar o fracțiune din trimiteri încep să eșueze cu o eroare COM care nu se reproduce niciodată când același cod rulează pe un singur thread. Nimic nu este greșit cu serverul SMTP, cu PDF-ul, sau cu atașamentul. Problema este ce returnează CoInitializeEx pe un thread pe care CDO nu îl aștepta, iar PDF Library for Delphi este scris pentru a gestiona acel caz deliberat, nu accidental

Ce face de fapt SendDocumentByMail în interiorul PDF Library for Delphi

SendDocumentByMail este un orchestrator subțire, nu un client de mail de sine stătător. TPDFlib.SendDocumentByMail salvează documentul încărcat curent în propriul său PDF temporar, împachetează setările SMTP și textul mesajului într-o înregistrare TPDFlibMailRequest, predă acea înregistrare la orice implementează IPDFlibMailProvider, și șterge din nou fișierul temporar odată ce furnizorul revine. Interfața de furnizor este clientul de mail efectiv, iar PDF Library for Delphi livrează exact o implementare încorporată: un furnizor bazat pe CDO care se compilează doar pe Windows. Apelați SendDocumentByMail fără a atribui mai întâi proprietatea MailProvider, iar PDF Library for Delphi revine automat la acel implicit. Valoarea de retur rămâne deliberat restrânsă pe tot parcursul: 1 pentru acceptat, 0 pentru orice altceva, fie că este un câmp obligatoriu lipsă, un eșec de scriere a fișierului temporar, sau furnizorul care respinge mesajul, cu motivul efectiv disponibil doar din GetLastMailError ulterior

Diagramă a conductei SendDocumentByMail: documentul este salvat ca PDF temporar cu nume GUID și ambalat într-o înregistrare TPDFlibMailRequest, un IPDFlibMailProvider distribuie către transportul CDO integrat sau către un substitut de test injectat, iar apelantul primește unu sau zero, cu GetLastMailError purtând motivul
Transportul CDO încorporat răspunde aceleiași interfețe restrânse pe care o implementează și un stub pentru CI
var
  PDF: TPDFlib;
  Sent: Integer;
begin
  PDF := TPDFlib.Create;              // o instanță nouă conține deja un document gol
  try
    PDF.SetPageDimensions(612, 792);  // US Letter, in points
    PDF.NewPage;
    // ... desenați situația: fonturi, text, totaluri ...
    Sent := PDF.SendDocumentByMail(
      'smtp.example.com', 0, 1,                 // portul 0 cu SSL 1 revine la 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');                    // numele de afișare al atașamentului
    if Sent <> 1 then
      Writeln('Send failed: ', PDF.GetLastMailError);
  finally
    PDF.Free;
  end;
end;

De ce returnează CoInitializeEx S_FALSE, și este asta un eșec?

S_FALSE de la CoInitializeEx nu este un eșec, iar codul care îl tratează ca unul raportează eșecuri pe thread-uri unde nimic nu a mers efectiv greșit. CoInitializeEx returnează S_OK prima dată când un thread inițializează cu succes COM, și returnează S_FALSE atunci când acel thread avea deja COM inițializat cu un model de concurență compatibil, incrementând același contor de referință per-thread în ambele cazuri, așa că ambele rezultate au nevoie de un apel CoUninitialize corespunzător înainte ca thread-ul să iasă sau să treacă la muncă fără legătură. TPDFlib însuși urmează exact acest tipar: construirea unei instanțe TPDFlib deja apelează CoInitialize și înregistrează dacă un CoUninitialize corespunzător este datorat, folosind verificarea identică S_OK-sau-S_FALSE. Până când SendDocumentByMail ajunge la furnizorul său CDO, iar acel furnizor apelează din nou CoInitializeEx, COM este deci deja inițializat pe thread în cazul obișnuit, așa că furnizorul aproape întotdeauna observă S_FALSE, nu S_OK. Tratarea S_FALSE ca orice altceva decât succes nu este un caz limită rar în această bibliotecă; este calea comună

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;

De ce returnează CoInitializeEx RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE înseamnă că thread-ul curent a inițializat COM mai devreme sub un model de concurență diferit de cel pe care îl cere acest apel, de obicei pentru că thread-ul a devenit anterior multi-threaded (MTA), iar CDO acum cere semantica de apartament single-threaded (STA) prin COINIT_APARTMENTTHREADED. Un thread își alege modelul de apartament o singură dată, iar nimic nu poate schimba acel model pentru restul vieții thread-ului; reîncercarea CoInitializeEx cu steaguri diferite nu rezolvă nepotrivirea, iar apelarea CoUninitialize mai întâi ar demonta un apartament de care alt cod de pe acel thread s-ar putea încă baza. PDF Library for Delphi tratează RPC_E_CHANGED_MODE ca o condiție cu care se lucrează, nu ca o eroare de raportat: sare peste CoUninitialize-ul pereche, întrucât apelul nu a obținut niciodată efectiv o referință de eliberat, și lasă trimiterea să continue pe apartamentul existent

RPC_E_CHANGED_MODE apare aproape exclusiv pe thread-uri reutilizate: un worker de pool de thread-uri, un thread de gazdă IIS sau serviciu, sau orice thread unde un cod anterior precum ADO sau WMI a apelat deja CoInitializeEx cu COINIT_MULTITHREADED înainte ca codul de mail să ajungă undeva aproape de el. Un thread complet nou care nu face nimic altceva decât să apeleze SendDocumentByMail nu va lovi această cale. Un thread worker reciclat de mii de ori pe zi de un planificator de lot, și partajat cu alte munci bazate pe COM, absolut va lovi, și o va face intermitent, ceea ce este exact tiparul care trimite oamenii să se uite mai întâi la serverul SMTP și abia al doilea la modelul de threading

Diagramă decizională PDF Library for Delphi a rezultatelor CoInitializeEx pe un fir de lucru: S_OK creează apartamentul, S_FALSE doar incrementează un contor de referințe compatibil și este calea comună, RPC_E_CHANGED_MODE menține în viață un apartament MTA anterior, iar orice altă eroare abandonează trimiterea
Tratarea S_FALSE ca eșec respinge calea obișnuită pe care o urmează firele deja inițializate

Păstrarea unui atașament de mail în afara directorului greșit

PDF Library for Delphi scrie fiecare atașament ieșitor într-un director proaspăt numit după un GUID pe care îl generează la fiecare apel SendDocumentByMail, specific pentru ca trimiterile concurente să nu se poată niciodată ciocni pe același nume de fișier și pentru ca un nume de atașament să nu poată ieși din acel director. Numele transmis ca atașament nu este de încredere ca o cale: trece prin PLSanitizeAttachmentName, care elimină orice componentă de director, respinge șirul gol și numele speciale . și .., și înlocuiește fiecare caracter pe care Windows îl tratează ca ilegal într-un nume de fișier, împreună cu orice caracter de control, cu o subliniere. Alimentați-l cu ..\quarter:report.pdf, parte traversare de director și parte două puncte ilegale, iar ce ajunge pe disc este quarter_report.pdf: tot ce e până la ultimul separator de cale este aruncat, iar cele două puncte devin o subliniere pentru că nu poate apărea într-un nume de fișier Windows

function PLSanitizeAttachmentName(const FileName: WideString): WideString;
var
  I, P: Integer;
begin
  P := LastDelimiter('/\', string(FileName));
  Result := Copy(FileName, P + 1, MaxInt);       // eliminați orice parte de director
  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;

Un director dedicat per apel nu este doar ordine. SendDocumentByMail șterge fișierul temporar și își elimină directorul într-un bloc finally după ce mesajul este trimis, folosind exact aceeași cale în care a scris, așa că un nume de atașament care ar ajunge la acel cod nesanificat nu ar face doar să greșească locul scrierii. Aceeași cale nesanificată ar ajunge apoi la un pas de curățare care apelează DeleteFile fără a mai pune întrebări, iar pe un folder temp partajat, două trimiteri concurente ar putea de asemenea să se suprascrie silențios reciproc atașamentul sub același nume înainte ca oricare livrare să se termine. Sanificarea numelui închide cazul de traversare, iar directorul GUID per apel închide cazul de coliziune, și niciunul singur nu ar fi fost suficient

Potrivirea duratei de viață COM cu durata de viață a thread-ului într-un pool worker

Cea mai fiabilă soluție pentru eșecurile de threading pe apartament într-un mailer de lot este să încetați să tratați fiecare apel SendDocumentByMail ca propria sa durată de viață COM izolată, și în schimb să inițializați COM o dată per thread worker, pentru toată durata de viață a acelui thread. Un worker care apelează CoInitializeEx(nil, COINIT_APARTMENTTHREADED) când pornește, păstrează acel apartament pentru fiecare apel SendDocumentByMail pe care îl face, și apelează CoUninitialize exact o dată când iese nu va vedea niciodată RPC_E_CHANGED_MODE din propriile sale trimiteri de mail, pentru că nimic altceva pe acel thread nu are șansa de a inițializa COM într-un mod conflictual mai întâi. Fiecare apel individual SendDocumentByMail tot rulează propria sa pereche CoInitializeEx și CoUninitialize intern sub acest tipar, iar asta este inofensiv: cu apartamentul deja stabilit de thread-ul worker, fiecare din acele apeluri interne acum vede S_FALSE, incrementează și decrementează același contor de referință, și lasă neatins propriul apartament COM al thread-ului worker

PDF Library for Delphi: diagramă de igienizare care transformă numele de atașament ostil ..\quarter:report.pdf în quarter_report.pdf într-un director nou cu nume GUID pe care trimiterile de poștă concurente nu pot intra în coliziune
Eliminarea componentelor de cale și înlocuirea caracterelor ilegale se combină cu un dosar GUID per apel
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;

Diagnosticarea eșecurilor și testarea fără o cutie poștală vie

GetLastMailError este cealaltă jumătate a acestui API care merită construită în logging din prima zi, pentru că valoarea de retur 1-sau-0 singură nu spune dacă o trimitere eșuată a fost o problemă de inițializare COM, o respingere de autentificare SMTP, sau un atașament lipsă. Proprietatea MailProvider este ceea ce face întreaga cale testabilă fără o cutie poștală reală: atribuiți-i o implementare IPDFlibMailProvider care înregistrează cereri în loc să le trimită, rulați un job de lot față de acel furnizor fals într-o conductă CI, iar aceleași locuri de apel SendDocumentByMail continuă să funcționeze neschimbate odată ce MailProvider este lăsat nesetat, iar PDF Library for Delphi revine la transportul CDO încorporat în producție

Un job de lot care trimite prin e-mail extrase de cont rareori se oprește la trimitere: aceeași conductă adesea are nevoie să valideze și să semneze PDF-ul înainte de a pleca, ceea ce este acoperit separat în articolul despre banca de lucru de conformitate și semnare, întrucât preflight-ul și verificarea semnăturii sunt o preocupare diferită de livrarea prin mail chiar și atunci când ambele rulează una după alta. Când documentele trimise prin mail sunt ele însele ieșirea unui job mare de îmbinare sau divizare, în loc de un singur PDF proaspăt construit, ghidul de acces direct la PDF-uri mari acoperă acel pas de generare. SendDocumentByMail și modelul de furnizor de mail descris aici fac parte din biblioteca de dezvoltare PDF PDF Library for Delphi standard pentru Delphi și C++Builder, iar pagina de produs conține referința completă a API-ului alături de o descărcare de probă