PDFlibPas, knihovna vývojáře PDF od losLab pro Delphi a C++Builder, odešle vygenerované PDF jako přílohu e-mailu jedním plochým voláním API, SendDocumentByMail. Na Windows výchozí přenos používá CDO (Collaboration Data Objects), komponentu pošty COM vestavěnou do operačního systému, a detail, který skutečně rozbíjí vícevláknové dávkové úlohy, je inicializace bytu COM, ne SMTP
Scénář za tímto API je neokázalý a extrémně běžný: služba vykreslí dávku měsíčních výpisů PDF, jeden na zákazníka, a musí každý rozeslat e-mailem bez člověka ve smyčce. Nasaďte tuto úlohu na pool vláken kvůli propustnosti, a zlomek odeslání začne selhávat s chybou COM, která se nikdy nezreprodukuje, když stejný kód běží na jednom vlákně. Nic není špatně na serveru SMTP, PDF, ani příloze. Problém je, co CoInitializeEx vrátí na vlákně, které CDO nečekalo, a PDFlibPas je napsaný tak, aby tento případ řešil záměrně, ne náhodou
Co SendDocumentByMail vlastně dělá uvnitř PDFlibPas
SendDocumentByMail je tenký orchestrátor, ne poštovní klient sám o sobě. TPDFlib.SendDocumentByMail uloží aktuálně načtený dokument do vlastního dočasného PDF, zabalí nastavení SMTP a text zprávy do záznamu TPDFlibMailRequest, předá tento záznam čemukoli, co implementuje IPDFlibMailProvider, a poté, co se poskytovatel vrátí, dočasný soubor znovu smaže. Rozhraní poskytovatele je skutečný poštovní klient, a PDFlibPas dodává přesně jednu vestavěnou implementaci: poskytovatele založeného na CDO, který se kompiluje jen na Windows. Zavolejte SendDocumentByMail bez toho, abyste nejdřív přiřadili vlastnost MailProvider, a PDFlibPas automaticky spadne zpátky na tento výchozí. Návratová hodnota zůstává záměrně úzká po celou dobu: 1 pro přijato, 0 pro cokoli jiného, ať je to chybějící povinné pole, selhání zápisu dočasného souboru, nebo odmítnutí zprávy poskytovatelem, se skutečným důvodem dostupným až poté z 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;
Proč CoInitializeEx vrací S_FALSE, a je to selhání?
S_FALSE z CoInitializeEx není selhání, a kód, který jej tak bere, hlásí selhání na vláknech, kde se ve skutečnosti nic nepokazilo. CoInitializeEx vrátí S_OK poprvé, kdy vlákno úspěšně inicializuje COM, a vrátí S_FALSE, když toto vlákno už mělo COM inicializovaný s kompatibilním modelem souběžnosti, přičemž v obou případech zvýší stejné počítadlo referencí na vlákno, takže oba výsledky potřebují odpovídající volání CoUninitialize dřív, než vlákno skončí nebo přejde na nesouvisející práci. Sám TPDFlib se řídí přesně tímto vzorem: konstrukce instance TPDFlib už volá CoInitialize a zaznamenává, zda je dlužné odpovídající CoUninitialize, pomocí identické kontroly S_OK-nebo-S_FALSE. V okamžiku, kdy se SendDocumentByMail dostane ke svému poskytovateli CDO a tento poskytovatel znovu zavolá CoInitializeEx, je COM na vlákně proto v obyčejném případě už inicializovaný, takže poskytovatel skoro vždy pozoruje S_FALSE, ne S_OK. Brát S_FALSE jako cokoli jiného než úspěch není v této knihovně vzácný okrajový případ; je to běžná cesta
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;
Proč CoInitializeEx vrací RPC_E_CHANGED_MODE?
RPC_E_CHANGED_MODE znamená, že aktuální vlákno inicializovalo COM dřív pod jiným modelem souběžnosti, než jaký toto volání požaduje, typicky protože vlákno dřív přešlo na vícevláknové (MTA) a CDO teď žádá o sémantiku bytu jednoho vlákna (STA) přes COINIT_APARTMENTTHREADED. Vlákno si volí svůj model bytu jednou, a nic tento model nemůže změnit po zbytek života vlákna; opakování CoInitializeEx s jinými příznaky nesoulad neopraví, a zavolání CoUninitialize nejdřív by strhlo byt, na kterém jiný kód na tomto vlákně možná pořád závisí. PDFlibPas bere RPC_E_CHANGED_MODE jako podmínku, se kterou pracovat, ne chybu k nahlášení: přeskočí párové CoUninitialize, protože volání ve skutečnosti nezískalo žádnou referenci k uvolnění, a nechá odeslání pokračovat na existujícím bytu
RPC_E_CHANGED_MODE se objevuje skoro výhradně na znovupoužitých vláknech: pracovníku poolu vláken, vlákně IIS nebo host služby, nebo jakémkoli vlákně, kde dřívější kód jako ADO nebo WMI už zavolal CoInitializeEx s COINIT_MULTITHREADED, ještě než se k němu poštovní kód vůbec dostal. Zbrusu nové vlákno, které nedělá nic než volá SendDocumentByMail, na tuto cestu nenarazí. Pracovní vlákno recyklované tisíckrát denně dávkovým plánovačem, a sdílené s jinou prací založenou na COM, na to absolutně narazí, a udělá to přerušovaně, což je přesně vzor, který posílá lidi hledat nejdřív u serveru SMTP a model vláknování až na druhém místě
Udržení přílohy e-mailu mimo špatný adresář
PDFlibPas zapisuje každou odchozí přílohu do čerstvého adresáře pojmenovaného podle GUID, které vygeneruje při každém volání SendDocumentByMail, konkrétně proto, aby souběžná odeslání nikdy nemohla kolidovat na stejném jméně souboru a aby jméno přílohy nemohlo vyjít z tohoto adresáře. Jméno předané jako příloha se nebere jako důvěryhodná cesta: prochází PLSanitizeAttachmentName, které odstraní jakoukoli komponentu adresáře, odmítne prázdný řetězec a speciální jména . a .., a nahradí každý znak, který Windows berou jako nelegální ve jméně souboru, spolu s jakýmkoli řídicím znakem, podtržítkem. Podejte tomu ..\quarter:report.pdf, částečně traverzování adresáře a částečně nelegální dvojtečka, a to, co se dostane na disk, je quarter_report.pdf: vše až po poslední oddělovač cesty se zahodí, a dvojtečka se stane podtržítkem, protože se nemůže objevit ve jméně souboru 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;
Vyhrazený adresář na volání není jen úhlednost. SendDocumentByMail smaže dočasný soubor a odebere jeho adresář v bloku finally poté, co je zpráva odeslána, pomocí přesně té cesty, do které zapisoval, takže jméno přílohy, které by se do tohoto kódu dostalo nesanitizované, by nejenom umístilo zápis špatně. Stejná nesanitizovaná cesta by pak dosáhla úklidového kroku, který zavolá DeleteFile bez dalších otázek, a na sdílené složce temp by si dvě souběžná odeslání také mohla tiše přepsat vzájemně přílohu pod stejným jménem dřív, než kterékoli doručení skončí. Sanitizace jména uzavírá případ traverzování, a adresář GUID na volání uzavírá případ kolize, a ani jedno samotné by nestačilo
Sladění životnosti COM se životností vlákna v poolu pracovníků
Nejspolehlivější oprava selhání bytového vláknování v dávkovém odesílateli pošty je přestat brát každé volání SendDocumentByMail jako vlastní izolovanou životnost COM, a místo toho inicializovat COM jednou na pracovní vlákno, po celou dobu jeho života. Pracovník, který zavolá CoInitializeEx(nil, COINIT_APARTMENTTHREADED) při startu, podrží tento byt pro každé volání SendDocumentByMail, které udělá, a zavolá CoUninitialize přesně jednou při ukončení, nikdy neuvidí RPC_E_CHANGED_MODE z vlastních poštovních odeslání, protože nic jiného na tomto vlákně nedostane šanci inicializovat COM v konfliktním režimu první. Každé jednotlivé volání SendDocumentByMail stále interně spouští vlastní pár CoInitializeEx a CoUninitialize pod tímto vzorem, a to je neškodné: s bytem už zavedeným pracovním vláknem každé z těchto interních volání teď vidí S_FALSE, zvýší a sníží stejné počítadlo referencí, a ponechá vlastní byt COM pracovního vlákna nedotčený
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;
Diagnostika selhání a testování bez živé poštovní schránky
GetLastMailError je druhá polovina tohoto API, kterou se vyplatí zabudovat do logování od prvního dne, protože samotná návratová hodnota 1-nebo-0 neříká, zda bylo selhané odeslání problém inicializace COM, odmítnutí ověření SMTP, nebo chybějící příloha. Vlastnost MailProvider je to, co dělá celou cestu testovatelnou bez skutečné poštovní schránky: přiřaďte jí implementaci IPDFlibMailProvider, která zaznamenává požadavky místo jejich odesílání, spusťte dávkovou úlohu proti tomuto falešnému poskytovateli v pipeline CI, a stejná místa volání SendDocumentByMail pokračují v práci beze změny, jakmile se MailProvider ponechá nenastavené a PDFlibPas v produkci spadne zpátky na vestavěný přenos CDO
Dávková úloha, která rozesílá výpisy e-mailem, se málokdy zastaví u odeslání: stejná pipeline často potřebuje PDF ověřit a podepsat, ještě než odejde, což je popsáno samostatně v článku o pracovním nástroji pro shodu a podepisování, protože preflight a ověření podpisu je jiná záležitost než doručení pošty, i když obojí běží hned po sobě. Když jsou dokumenty odesílané e-mailem samy výstupem velké úlohy slučování nebo rozdělování místo jednoho čerstvě postaveného PDF, tento krok generování popisuje průvodce přímým přístupem k velkým PDF. SendDocumentByMail a model poskytovatele pošty popsaný zde jsou součástí standardní PDFlibPas knihovny vývojáře PDF pro Delphi a C++Builder, a stránka produktu nese úplnou referenci API spolu se zkušebním stažením