Műszaki cikk

PDF-ek emailezése CDO-n keresztül Delphiben: apartment-szálazási buktatók

A PDFlibPas, a losLab PDF Fejlesztői Könyvtára Delphihez és C++Builderhez, egy generált PDF-et email-mellékletként küld el egyetlen egyszerű API-hívással, a SendDocumentByMail-lel. Windowson az alapértelmezett szállítás a CDO-t (Collaboration Data Objects) használja, az operációs rendszerbe épített COM levelező-komponenst, és a részlet, amely ténylegesen elrontja a több szálas kötegelt feladatokat, a COM apartment-inicializáció, nem az SMTP

Az e mögött az API mögött álló forgatókönyv nem túl fényes, és rendkívül gyakori: egy szolgáltatás egy köteg hónapvégi kimutatás-PDF-et rendel, egyet ügyfelenként, és mindegyiket ki kell postáznia egy ember bevonása nélkül. Told azt a feladatot egy szálkészletre az áteresztőképesség kedvéért, és a küldések egy törtrésze elkezd elbukni egy COM-hibával, amely soha nem reprodukálódik, amikor ugyanaz a kód egyetlen szálon fut. Semmi nincs rosszul az SMTP-szerverrel, a PDF-fel, vagy a melléklettel. A probléma az, amit a CoInitializeEx ad vissza egy szálon, amit a CDO nem várt, és a PDFlibPas úgy van megírva, hogy szándékosan kezelje ezt az esetet, ne véletlenül

Mit csinál ténylegesen a SendDocumentByMail a PDFlibPas-on belül

A SendDocumentByMail egy vékony orkesztrátor, nem egy levelező-kliens önmagában. A TPDFlib.SendDocumentByMail elmenti az aktuálisan betöltött dokumentumot a saját ideiglenes PDF-jébe, becsomagolja az SMTP-beállításokat és üzenetszöveget egy TPDFlibMailRequest rekordba, átadja azt a rekordot bárminek, ami megvalósítja az IPDFlibMailProvider-t, és ismét törli az ideiglenes fájlt, amint a szolgáltató visszatér. A szolgáltató-interfész a tényleges levelező-kliens, és a PDFlibPas pontosan egy beépített megvalósítást szállít: egy CDO-alapú szolgáltatót, amely csak Windowson fordul. Hívd meg a SendDocumentByMail-t anélkül hogy előbb hozzárendelnéd a MailProvider tulajdonságot, és a PDFlibPas automatikusan visszaesik arra az alapértelmezettre. A visszatérési érték szándékosan szűk marad mindvégig: 1 az elfogadotthoz, 0 bármi máshoz, akár egy hiányzó kötelező mező, egy ideiglenesfájl-írási hiba, vagy a szolgáltató üzenetelutasítása, a tényleges okkal csak utólag a GetLastMailError-ból elérhetően

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;

Miért ad vissza a CoInitializeEx S_FALSE-t, és ez bukás?

Az S_FALSE a CoInitializeEx-től nem bukás, és a kód, amely úgy kezeli, mint egyet, bukásokat jelent olyan szálakon, ahol ténylegesen semmi nem romlott el. A CoInitializeEx S_OK-t ad vissza, amikor egy szál először sikeresen inicializálja a COM-ot, és S_FALSE-t ad vissza, amikor annak a szálnak már inicializálva volt a COM-ja egy kompatibilis konkurencia-modellel, mindkét esetben ugyanazt a szálankénti referenciaszámlálót növelve, így mindkét eredménynek egy megfelelő CoUninitialize hívásra van szüksége, mielőtt a szál kilépne vagy nem kapcsolódó munkára térne át. Maga a TPDFlib pontosan ezt a mintát követi: egy TPDFlib-példány konstruálása már meghívja a CoInitialize-t, és rögzíti, tartozik-e egy megfelelő CoUninitialize, ugyanazt az S_OK-vagy-S_FALSE ellenőrzést használva. Mire a SendDocumentByMail eléri a CDO-szolgáltatóját, és az a szolgáltató ismét meghívja a CoInitializeEx-et, a COM tehát már inicializálva van a szálon a szokásos esetben, így a szolgáltató szinte mindig S_FALSE-t figyel meg, nem S_OK-t. Az S_FALSE kezelése bármi másnak, mint sikernek, nem ritka szélsőeset ebben a könyvtárban; ez a gyakori útvonal

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;

Miért ad vissza a CoInitializeEx RPC_E_CHANGED_MODE-ot?

Az RPC_E_CHANGED_MODE azt jelenti, hogy az aktuális szál korábban inicializálta a COM-ot egy másik konkurencia-modell alatt, mint amit ez a hívás kér, jellemzően azért, mert a szál korábban több szálas (MTA) lett, és a CDO most egy szálas apartment (STA) szemantikát kér a COINIT_APARTMENTTHREADED-en keresztül. Egy szál egyszer választja meg saját apartment-modelljét, és semmi nem tudja megváltoztatni azt a modellt a szál élettartamának hátralévő részére; a CoInitializeEx újrapróbálása más jelzőkkel nem javítja meg az eltérést, és egy CoUninitialize hívás előbb lebontana egy apartmentet, amitől az adott szálon lévő más kód még mindig függhet. A PDFlibPas úgy kezeli az RPC_E_CHANGED_MODE-ot, mint egy feltételt, amivel dolgozni kell, nem mint egy jelentendő hibát: kihagyja a párosított CoUninitialize-ot, mivel a hívás ténylegesen soha nem szerzett referenciát a felszabadításhoz, és hagyja, hogy a küldés folytatódjon a meglévő apartmenten

Az RPC_E_CHANGED_MODE szinte kizárólag újrahasznosított szálakon jelenik meg: egy szálkészlet-munkás, egy IIS- vagy szolgáltatás-hoszt szál, vagy bármely szál, ahol korábbi kód, mint az ADO vagy a WMI, már meghívta a CoInitializeEx-et COINIT_MULTITHREADED-del, mielőtt a levelező-kód egyáltalán a közelébe került volna. Egy vadonatúj szál, amely semmit nem tesz, csak meghívja a SendDocumentByMail-t, nem fog ebbe az útvonalba ütközni. Egy munkásszál, amelyet naponta ezerszer újrahasznosít egy kötegelt ütemező, és amit más COM-alapú munkával osztanak meg, biztosan igen, és időszakosan fogja ezt tenni, ami pontosan az a minta, amely miatt az emberek először az SMTP-szervert nézik, a szálazási modellt csak másodszor

Egy levélmelléklet távol tartása a rossz könyvtártól

A PDFlibPas minden kimenő mellékletet egy friss könyvtárba ír, amelyet egy, minden SendDocumentByMail híváskor generált GUID nevez el, kifejezetten azért, hogy az egyidejű küldések soha ne ütközzenek ugyanazon fájlnéven, és hogy egy mellékletnév ne tudjon kisétálni abból a könyvtárból. A mellékletként átadott név nem megbízható útvonalként: átmegy a PLSanitizeAttachmentName-en, amely lecsupaszít bármely könyvtár-komponenst, elutasítja az üres sztringet és a speciális . és .. neveket, és lecserél minden karaktert, amit a Windows illegálisnak kezel egy fájlnévben, minden vezérlőkarakterrel együtt, aláhúzásjelre. Etesd be neki a ..\quarter:report.pdf-et, részben útvonal-bejárás, részben illegális kettőspont, és ami eléri a lemezt, a quarter_report.pdf: minden az utolsó útvonal-elválasztóig eldobásra kerül, és a kettőspont aláhúzásjellé válik, mert nem jelenhet meg egy Windows fájlnévben

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;

Egy dedikált, hívásonkénti könyvtár nem csupán rendezettség. A SendDocumentByMail törli az ideiglenes fájlt, és eltávolítja a könyvtárát egy finally blokkban, miután az üzenet elküldve, ugyanazt az útvonalat használva, amire írt, így egy mellékletnév, amely szennyezetlenül érte volna el azt a kódot, nem csupán helytelenül helyezné el az írást. Ugyanaz a szennyezetlen útvonal ezután elérne egy takarítási lépést, amely meghívja a DeleteFile-t további kérdések nélkül, és egy megosztott temp mappán két egyidejű küldés is csendben felülírhatná egymás mellékletét ugyanazon a néven, mielőtt bármelyik kézbesítés befejeződne. A név szennyezésmentesítése lezárja a bejárás-esetet, és a hívásonkénti GUID-könyvtár lezárja az ütközés-esetet, és egyik sem lett volna elég önmagában

A COM-élettartam egyeztetése a szál-élettartammal egy munkáskészletben

A legmegbízhatóbb javítás az apartment-szálazási hibákra egy kötegelt levelezőben az, hogy megszűnünk minden SendDocumentByMail hívást saját, elszigetelt COM-élettartamként kezelni, és ehelyett a COM-ot munkásszálanként egyszer inicializáljuk, annak a szálnak az élettartamára. Egy munkás, amely meghívja a CoInitializeEx(nil, COINIT_APARTMENTTHREADED)-et indításkor, megtartja azt az apartmentet minden SendDocumentByMail híváshoz, amit tesz, és pontosan egyszer hívja meg a CoUninitialize-t, amikor kilép, soha nem fog RPC_E_CHANGED_MODE-ot látni saját levélküldéseiből, mert semmi más azon a szálon nem kap esélyt arra, hogy előbb ütköző módban inicializálja a COM-ot. Minden egyes SendDocumentByMail hívás még mindig futtatja saját CoInitializeEx és CoUninitialize párját belsőleg e minta alatt, és ez ártalmatlan: az apartmenttel, amelyet a munkásszál már felállított, ezen belső hívások mindegyike most S_FALSE-t lát, növeli és csökkenti ugyanazt a referenciaszámlálót, és érintetlenül hagyja a munkásszál saját COM-apartmentjét

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;

Hibák diagnosztizálása és tesztelés élő postafiók nélkül

A GetLastMailError ennek az API-nak a másik fele, amit érdemes a naplózásba építeni az első naptól, mert az 1-vagy-0 visszatérési érték önmagában nem mondja meg, hogy egy elbukott küldés egy COM-inicializálási probléma, egy SMTP-hitelesítési elutasítás, vagy egy hiányzó melléklet volt-e. A MailProvider tulajdonság az, ami tesztelhetővé teszi a teljes útvonalat valódi postafiók nélkül: rendelj hozzá egy IPDFlibMailProvider megvalósítást, amely rögzíti a kéréseket, ahelyett hogy elküldené őket, futtass egy kötegelt feladatot azzal a hamis szolgáltatóval szemben egy CI-csővezetékben, és ugyanazok a SendDocumentByMail híváshelyek változatlanul tovább működnek, amint a MailProvider-t nem állítják be, és a PDFlibPas visszaesik a beépített CDO-szállításra termelésben

Egy kötegelt feladat, amely kimutatásokat emailez, ritkán áll meg a küldésnél: ugyanaz a csővezeték gyakran igényli a PDF érvényesítését és aláírását, mielőtt kimenne, amit külön tárgyal a megfelelőségi és aláírási munkapadról szóló cikk, mivel a preflight és az aláírás-ellenőrzés más aggodalom, mint a levélkézbesítés, még akkor is, ha mindkettő egymás után fut. Amikor az emailezett dokumentumok maguk egy nagy egyesítési vagy felosztási feladat kimenetei, nem egy egyetlen, frissen épített PDF, a nagy PDF direkt-hozzáférési útmutató tárgyalja azt a generálási lépést. A SendDocumentByMail és az itt leírt levelezőszolgáltató-modell a Delphihez és C++Builderhez készült szabványos PDFlibPas PDF Fejlesztői Könyvtár részei, és a termékoldal hordozza a teljes API-referenciát egy próbaverzió letöltése mellett