Techninis straipsnis

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

PDFlibPas, 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 PDFlibPas sąmoningai sukurtas šiam atvejui tvarkyti, o ne palikti jį atsitiktinumui

Ką PDFlibPas 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 PDFlibPas pateikia lygiai vieną integruotą realizaciją: CDO pagrįstą tiekėją, kompiliuojamą tik sistemoje Windows. Iškvietus SendDocumentByMail prieš tai nepriskyrus MailProvider savybės, PDFlibPas 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

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;

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

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

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

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ą

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 PDFlibPas 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 PDFlibPas PDF kūrėjų bibliotekos, skirtos Delphi ir C++Builder, dalis, o produkto puslapyje kartu su bandomąja versija pateikiama visa API nuoroda