Articol tehnic

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

PDFlibPas, 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 PDFlibPas este scris pentru a gestiona acel caz deliberat, nu accidental

Ce face de fapt SendDocumentByMail în interiorul PDFlibPas

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 PDFlibPas 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 PDFlibPas 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

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;

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. PDFlibPas 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

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

PDFlibPas 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);       // 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;

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

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 PDFlibPas 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 PDFlibPas 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ă