Techninis straipsnis

PDF siuntimas el. paštu per CDO su Delphi: gijos

PDF Library for Delphi, losLab PDF kūrėjų biblioteka, skirta Delphi ir C++Builder, sugeneruotą PDF prideda prie el. laiško vienu paprastu API iškvietimu, SendDocumentByMail. Windows sistemoje numatytasis transportas naudoja CDO (Collaboration Data Objects), operacinėje sistemoje integruotą COM pašto komponentą, o daugiagijų paketinių užduočių gedimus iš tiesų sukelia COM buto inicializavimas, ne SMTP

Šios API scenarijus nėra įspūdingas, tačiau itin dažnas: tarnyba sugeneruoja mėnesio pabaigos ataskaitų PDF paketą, po vieną kiekvienam klientui, ir turi išsiųsti kiekvieną laišką be žmogaus įsikišimo. Perkėlus šią užduotį į gijų telkinį, kad padidėtų našumas, dalis siuntimų pradeda strigti dėl COM klaidos, kuri niekada nepasikartoja tam pačiam kodui veikiant vienoje gijoje. SMTP serveris, PDF ar priedas nėra sugedę. Problema yra tai, ką CoInitializeEx grąžina gijoje, kurios CDO nesitikėjo, o PDF Library for Delphi sąmoningai sukurtas šiam atvejui tvarkyti, o ne palikti jį atsitiktinumui

Ką PDF Library for Delphi viduje iš tiesų daro SendDocumentByMail

SendDocumentByMail yra plonas orkestratorius, o ne savarankiškas pašto klientas. TPDFlib.SendDocumentByMail šiuo metu įkeltą dokumentą išsaugo atskirame laikinajame PDF faile, supakuoja SMTP nustatymus ir laiško tekstą į TPDFlibMailRequest įrašą, perduoda šį įrašą tam, kas įgyvendina IPDFlibMailProvider, ir tiekėjui grįžus vėl ištrina laikinąjį failą. Tiekėjo sąsaja yra tikrasis pašto klientas, o PDF Library for Delphi pateikia lygiai vieną integruotą realizaciją: CDO pagrįstą tiekėją, kompiliuojamą tik sistemoje Windows. Iškvietus SendDocumentByMail prieš tai nepriskyrus MailProvider savybės, PDF Library for Delphi automatiškai grįžta prie šios numatytosios realizacijos. Grąžinama reikšmė visur sąmoningai siaura: 1 reiškia, kad priimta, o 0 – visa kita, nesvarbu, ar trūksta privalomo lauko, nepavyko įrašyti laikinojo failo, ar tiekėjas atmetė laišką, o tikroji priežastis vėliau pasiekiama tik per GetLastMailError

SendDocumentByMail procesų grandinės diagrama: dokumentas išsaugomas laikinajame PDF su GUID pavadinimu ir supakuojamas į TPDFlibMailRequest įrašą, IPDFlibMailProvider nukreipia į integruotą CDO transportą arba įterptąjį testavimo dublį, o kvietėjas gauna vieną arba nulį, o GetLastMailError neša priežastį
Integruotas CDO transportas atsako į tą pačią siaurą sąsają, kurią įgyvendina CI pakaitalas
var
  PDF: TPDFlib;
  Sent: Integer;
begin
  PDF := TPDFlib.Create;              // naujas egzempliorius jau turi vieną tuščią dokumentą
  try
    PDF.SetPageDimensions(612, 792);  // US Letter, in points
    PDF.NewPage;
    // ... nubrėžkite ataskaitą: šriftai, tekstas, sumos ...
    Sent := PDF.SendDocumentByMail(
      'smtp.example.com', 0, 1,                 // prievadas 0 su SSL 1 grįžta į 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');                    // priedo rodomas pavadinimas
    if Sent <> 1 then
      Writeln('Send failed: ', PDF.GetLastMailError);
  finally
    PDF.Free;
  end;
end;

Kodėl CoInitializeEx grąžina S_FALSE ir ar tai klaida

S_FALSE, grąžinama iš CoInitializeEx, nėra klaida, o kodas, laikantis ją klaida, praneša apie gedimus gijose, kuriose iš tikrųjų nieko blogo nenutiko. CoInitializeEx pirmą kartą sėkmingai inicializavus COM gijoje grąžina S_OK, o grąžina S_FALSE, kai toje gijoje COM jau buvo inicializuotas naudojant suderinamą lygiagretumo modelį, abiem atvejais padidindamas tą patį gijos nuorodų skaitiklį, todėl prieš gijos pabaigą ar pereinant prie nesusijusio darbo abiem rezultatams reikia atitinkamo CoUninitialize iškvietimo. Pats TPDFlib laikosi būtent šio šablono: sukūrus TPDFlib egzempliorių jau iškviečiamas CoInitialize ir įrašoma, ar reikės atitinkamo CoUninitialize, naudojant tą patį S_OK arba S_FALSE tikrinimą. Kai SendDocumentByMail pasiekia CDO tiekėją ir šis tiekėjas dar kartą iškviečia CoInitializeEx, įprastu atveju COM gijoje jau būna inicializuotas, todėl tiekėjas beveik visada mato S_FALSE, o ne S_OK. Šioje bibliotekoje S_FALSE laikymas kuo nors kitu nei sėkme nėra retas kraštinis atvejis, tai yra įprastas kelias

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;

Kodėl CoInitializeEx grąžina RPC_E_CHANGED_MODE

RPC_E_CHANGED_MODE reiškia, kad dabartinėje gijoje COM anksčiau buvo inicializuotas naudojant kitokį lygiagretumo modelį nei tas, kurio prašo šis iškvietimas, dažniausiai todėl, kad gija anksčiau tapo daugiagije (MTA), o CDO dabar prašo vienos gijos buto (STA) semantikos per COINIT_APARTMENTTHREADED. Gija savo buto modelį pasirenka vieną kartą, ir likusį gijos gyvavimo laiką jo pakeisti neįmanoma; pakartotinis CoInitializeEx iškvietimas su kitomis vėliavėlėmis neatitikimo neišsprendžia, o pirmiau iškvietus CoUninitialize būtų nutrauktas butas, nuo kurio kitas tos gijos kodas dar gali priklausyti. PDF Library for Delphi traktuoja RPC_E_CHANGED_MODE kaip sąlygą, su kuria reikia dirbti, o ne kaip praneštiną klaidą: praleidžiamas porinis CoUninitialize, nes iškvietimas iš tikrųjų neįgijo atlaisvintinos nuorodos, ir siuntimas tęsiamas esamame bute

RPC_E_CHANGED_MODE beveik išimtinai pasirodo pakartotinai naudojamose gijose: gijų telkinio darbuotojo, IIS ar tarnybos pagrindinės gijos arba bet kurios gijos, kurioje ankstesnis kodas, pavyzdžiui, ADO ar WMI, dar prieš pasiekiant pašto kodą iškvietė CoInitializeEx su COINIT_MULTITHREADED. Visiškai nauja gija, kuri tik iškviečia SendDocumentByMail, šio kelio nepasieks. Darbuotojo gija, paketų planuoklio perdirbama tūkstančius kartų per dieną ir bendrinama su kitu COM pagrįstu darbu, tikrai jį pasieks, be to, protarpiais, todėl žmonės pirmiausia ima tikrinti SMTP serverį, o gijų modelį – tik vėliau

PDF Library for Delphi sprendimų diagrama apie CoInitializeEx rezultatus darbinėje gijoje: S_OK sukuria apartamentą, S_FALSE tik padidina suderinamą nuorodų skaitiklį ir yra dažniausias kelias, RPC_E_CHANGED_MODE palieka gyvą ankstesnį MTA apartamentą, o bet kuri kita klaida nutraukia siuntimą
S_FALSE laikymas nesėkme atmeta įprastąjį kelią, kuriuo eina jau inicijuotos gijos

Kaip neleisti pašto priedui patekti į netinkamą katalogą

PDF Library for Delphi kiekvieną siunčiamą priedą įrašo į naują katalogą, pavadintą pagal GUID, kurį sugeneruoja kiekvieno SendDocumentByMail iškvietimo metu, būtent tam, kad lygiagretūs siuntimai niekada nesusidurtų dėl to paties failo pavadinimo ir kad priedo pavadinimas negalėtų išeiti iš šio katalogo. Kaip kelias perduodamas priedo pavadinimas nelaikomas patikimu: jis apdorojamas per PLSanitizeAttachmentName, kuri pašalina bet kokį katalogo komponentą, atmeta tuščią eilutę ir specialius pavadinimus . bei .., o kiekvieną simbolį, kurį Windows laiko neleistinu failo pavadinime, taip pat bet kurį valdymo simbolį, pakeičia pabraukimo brūkšniu. Perdavus ..\quarter:report.pdf, kuriame yra ir katalogų perėjimas, ir neleistinas dvitaškis, diske pasiekia quarter_report.pdf: viskas iki paskutinio kelio skirtuko pašalinama, o dvitaškis tampa pabraukimo brūkšniu, nes Windows failo pavadinime jis negali būti naudojamas

function PLSanitizeAttachmentName(const FileName: WideString): WideString;
var
  I, P: Integer;
begin
  P := LastDelimiter('/\', string(FileName));
  Result := Copy(FileName, P + 1, MaxInt);       // pašalinkite bet kokią katalogo dalį
  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;

Atskiras kiekvieno iškvietimo katalogas nėra vien tvarkos palaikymas. Išsiuntus laišką, SendDocumentByMail finally bloke ištrina laikinąjį failą ir pašalina jo katalogą, naudodamas tą patį kelią, į kurį rašė, todėl nesanitizuotas priedo pavadinimas ne tik nukreiptų įrašymą kitur. Tas pats nesanitizuotas kelias vėliau pasiektų valymo veiksmą, kuris be papildomų klausimų iškviečia DeleteFile, o bendrame laikinųjų failų kataloge du lygiagretūs siuntimai dar galėtų tyliai perrašyti vienas kito priedą tuo pačiu pavadinimu, kol nė vienas pristatymas nebaigtas. Pavadinimo sanitizavimas uždaro katalogų perėjimo atvejį, o kiekvieno iškvietimo GUID katalogas – susidūrimo atvejį, ir vieno iš jų nebūtų pakakę

COM gyvavimo trukmės sutapatinimas su gijos trukme darbuotojų telkinyje

Patikimiausias buto gijų klaidų sprendimas paketinio pašto siuntimo programoje – nebetraktuoti kiekvieno SendDocumentByMail iškvietimo kaip atskiro izoliuoto COM gyvavimo ciklo, o inicializuoti COM vieną kartą kiekvienai darbuotojo gijai ir palikti jį veikti visą tos gijos gyvavimo laiką. Darbuotojas, paleidimo metu iškviečiantis CoInitializeEx(nil, COINIT_APARTMENTTHREADED), išlaikantis tą butą visiems savo SendDocumentByMail iškvietimams ir baigdamas darbą tiksliai vieną kartą iškviečiantis CoUninitialize, savo pašto siuntimuose niekada nepamatys RPC_E_CHANGED_MODE, nes niekas kitas toje gijoje pirmiau negaus progos inicializuoti COM nesuderinamu režimu. Pagal šį šabloną kiekvienas atskiras SendDocumentByMail iškvietimas vis tiek viduje vykdo savo CoInitializeEx ir CoUninitialize porą, ir tai nekenkia: darbuotojo gijai jau nustačius butą, kiekvienas vidinis iškvietimas dabar mato S_FALSE, padidina ir sumažina tą patį nuorodų skaitiklį bei palieka paties darbuotojo gijos COM butą nepakeistą

PDF Library for Delphi: valymo diagrama, paverčianti kenksmingą priedo pavadinimą ..\quarter:report.pdf į quarter_report.pdf naujame GUID pavadinimo kataloge, kuriame lygiagretūs pašto siuntimai negali susidurti
Kelio dalių pašalinimas ir neteisėtų simbolių pakeitimas derinamas su atskiru GUID aplanku kiekvienam kvietimui
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;

Gedimų diagnozavimas ir testavimas be tikros pašto dėžutės

GetLastMailError yra kita šios API pusė, kurią verta nuo pat pradžių įtraukti į žurnalų rašymą, nes vien grąžinama 1 arba 0 reikšmė nepasako, ar nepavykusį siuntimą sukėlė COM inicializavimo problema, SMTP autentifikavimo atmetimas, ar trūkstamas priedas. MailProvider savybė leidžia visą kelią išbandyti be tikros pašto dėžutės: priskirkite jai IPDFlibMailProvider realizaciją, kuri užrašo užklausas jų nesiųsdama, paleiskite paketinę užduotį su šiuo netikru tiekėju CI konvejeryje, ir tie patys SendDocumentByMail iškvietimo taškai veiks nepakeisti, kai gamyboje MailProvider liks nenustatyta ir PDF Library for Delphi grįš prie integruoto CDO transporto

Paketinė užduotis, siunčianti ataskaitas el. paštu, retai baigiasi vien siuntimu: tam pačiam konvejeriui dažnai reikia prieš išsiunčiant patikrinti ir pasirašyti PDF, o tai atskirai aprašyta atitikties ir pasirašymo darbo vietos straipsnyje, nes patikra ir parašo tikrinimas yra kitas rūpestis nei laiško pristatymas, net jei abu veiksmai vykdomi vienas po kito. Kai siunčiami dokumentai yra didelės suliejimo ar skaidymo užduoties rezultatas, o ne vienas ką tik sukurtas PDF, tą generavimo etapą aprašo didelio PDF tiesioginės prieigos vadovas. Čia aprašyti SendDocumentByMail ir pašto tiekėjo modelis yra standartinės PDF Library for Delphi PDF kūrėjų bibliotekos, skirtos Delphi ir C++Builder, dalis, o produkto puslapyje kartu su bandomąja versija pateikiama visa API nuoroda