PDFlibPas, PDF библиотеката за разработка на losLab за Delphi и C++Builder, изпраща генериран PDF като прикачен файл чрез едно опростено API извикване, SendDocumentByMail. В Windows транспортът по подразбиране използва CDO (Collaboration Data Objects), пощенския COM компонент, вграден в операционната система, а истинската причина, поради която пакетните задачи с много нишки се провалят, е инициализирането на COM апартамента, а не SMTP
Сценарият зад този API е прозаичен и изключително често срещан: услуга генерира пакет PDF извлечения в края на месеца, по едно за всеки клиент, и трябва да изпрати всяко от тях без човешка намеса. Когато задачата бъде прехвърлена към пул от нишки за по-висока производителност, част от изпращанията започват да се провалят с COM грешка, която никога не се проявява, когато същият код работи в една нишка. Няма проблем със SMTP сървъра, PDF файла или прикачения файл. Проблемът е какво връща CoInitializeEx в нишка, за която CDO не е очаквал текущото състояние, а PDFlibPas е написан така, че умишлено да обработва този случай
Какво всъщност прави SendDocumentByMail в PDFlibPas
SendDocumentByMail е тънък оркестратор, а не самостоятелен пощенски клиент. TPDFlib.SendDocumentByMail записва текущо заредения документ във временен PDF файл, пакетира SMTP настройките и текста на съобщението в запис TPDFlibMailRequest, предава този запис на реализацията на IPDFlibMailProvider и изтрива временния файл, след като доставчикът върне резултат. Интерфейсът на доставчика е същинският пощенски клиент, а PDFlibPas включва точно една вградена реализация: доставчик на основата на CDO, който се компилира само в Windows. Ако извикате SendDocumentByMail, без първо да зададете свойството MailProvider, PDFlibPas автоматично използва този доставчик по подразбиране. Връщаната стойност умишлено остава ограничена: 1 за прието съобщение и 0 за всичко останало, независимо дали причината е липсващо задължително поле, грешка при запис на временния файл или отказ на доставчика, докато действителната причина е достъпна след това само чрез 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;
Защо CoInitializeEx връща S_FALSE и това грешка ли е?
S_FALSE от CoInitializeEx не е грешка, а кодът, който го третира като такава, отчита проблеми в нишки, в които всъщност всичко е наред. CoInitializeEx връща S_OK, когато нишката успешно инициализира COM за първи път, и връща S_FALSE, когато COM вече е бил инициализиран в тази нишка със съвместим модел на конкурентност, като и в двата случая увеличава същия брояч на препратки за нишката, затова преди нишката да приключи или да премине към несвързана работа и двата резултата изискват съответстващо извикване на CoUninitialize. Самият TPDFlib следва точно този модел: създаването на екземпляр TPDFlib вече извиква CoInitialize и записва дали се дължи съответстващо CoUninitialize, като използва същата проверка за S_OK или S_FALSE. Когато SendDocumentByMail достигне своя CDO доставчик и той отново извика CoInitializeEx, COM обикновено вече е инициализиран в нишката, затова доставчикът почти винаги вижда S_FALSE, а не S_OK. Третирането на S_FALSE като нещо различно от успех не е рядък краен случай в тази библиотека, а обичайният път
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;
Защо CoInitializeEx връща RPC_E_CHANGED_MODE?
RPC_E_CHANGED_MODE означава, че текущата нишка е инициализирала COM по-рано с различен модел на конкурентност от заявения при това извикване, обикновено защото нишката вече е работила с много нишки (MTA), а CDO сега изисква семантика на еднонишков апартамент (STA) чрез COINIT_APARTMENTTHREADED. Нишката избира модела на своя апартамент веднъж и нищо не може да промени този модел до края на живота ѝ; повторното извикване на CoInitializeEx с други флагове не поправя несъответствието, а първоначалното извикване на CoUninitialize би разрушило апартамент, от който друг код в същата нишка може още да зависи. PDFlibPas третира RPC_E_CHANGED_MODE като условие, с което трябва да работи, а не като грешка за докладване: пропуска съответстващото CoUninitialize, тъй като извикването реално не е придобило препратка за освобождаване, и позволява изпращането да продължи в съществуващия апартамент
RPC_E_CHANGED_MODE се появява почти изключително в повторно използвани нишки: работник от пул, нишка на IIS или хост на услуга, или всяка нишка, в която по-ранен код като ADO или WMI вече е извикал CoInitializeEx с COINIT_MULTITHREADED, преди пощенският код изобщо да стигне до нея. Чисто нова нишка, която прави само извикване на SendDocumentByMail, няма да попадне в този път. Работна нишка, рециклирана хиляди пъти дневно от пакетен планировчик и споделена с друга COM-базирана работа, със сигурност ще попадне в него, и то периодично, което точно кара разработчиците първо да проверяват SMTP сървъра, а едва след това модела на нишките
Как да не попадне прикаченият файл в грешна директория
PDFlibPas записва всеки изходящ прикачен файл в нова директория, именувана с GUID, който генерира при всяко извикване на SendDocumentByMail, така че едновременните изпращания никога да не се сблъскат заради едно и също име и името на прикачения файл да не може да излезе извън тази директория. Подаденото име не се приема като надежден път: то преминава през PLSanitizeAttachmentName, което премахва всяка директория, отхвърля празния низ и специалните имена . и .., и заменя с долна черта всеки знак, който Windows счита за недопустим във файлово име, както и всеки управляващ знак. Ако подадете ..\quarter:report.pdf, което съчетава обход на директория и недопустимо двоеточие, на диска достига quarter_report.pdf: всичко до последния разделител на пътя се изхвърля, а двоеточието става долна черта, защото не може да присъства във файлово име на 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;
Отделната директория за всяко извикване не е просто подреденост. SendDocumentByMail изтрива временния файл и премахва директорията му в блок finally, след като съобщението бъде изпратено, като използва точно същия път, в който е записал файла, затова име на прикачен файл, достигнало до този код без почистване, не би довело само до неправилен запис. Същият непочистен път би стигнал и до стъпката за почистване, която извиква DeleteFile без допълнителна проверка, а в обща временна директория две едновременни изпращания биха могли и незабелязано да презапишат прикачените файлове с едно и също име, преди което и да е доставяне да приключи. Почистването на името затваря случая с обхода на директории, а GUID директорията за всяко извикване затваря случая със сблъсъка, като нито една от двете мерки сама по себе си не би била достатъчна
Съгласуване на живота на COM с живота на нишката в пул от работници
Най-надеждното решение за грешки при апартаментното нишково изпълнение в пакетна пощенска услуга е всяко извикване на SendDocumentByMail да не се третира като отделен COM живот, а COM да се инициализира веднъж за всяка работна нишка и да остане инициализиран през целия живот на тази нишка. Работник, който извиква CoInitializeEx(nil, COINIT_APARTMENTTHREADED) при стартиране, запазва този апартамент за всяко свое извикване на SendDocumentByMail и извиква CoUninitialize точно веднъж при приключване, никога няма да види RPC_E_CHANGED_MODE от собствените си изпращания, защото нищо друго в тази нишка няма да успее първо да инициализира COM в конфликтен режим. Всяко отделно извикване на SendDocumentByMail все пак изпълнява вътрешно собствена двойка CoInitializeEx и CoUninitialize при този модел, но това е безопасно: понеже апартаментът вече е установен от работната нишка, всяко от тези вътрешни извиквания вижда S_FALSE, увеличава и намалява същия брояч на препратки и оставя COM апартамента на работната нишка непокътнат
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;
Диагностика на грешки и тестване без реална пощенска кутия
GetLastMailError е другата половина на този API, която си струва да включите в журналите още от първия ден, защото самата стойност 1 или 0 не показва дали неуспешното изпращане е причинено от инициализирането на COM, отказана SMTP автентикация или липсващ прикачен файл. Свойството MailProvider прави целия път тестируем без реална пощенска кутия: задайте му реализация на IPDFlibMailProvider, която записва заявките, вместо да ги изпраща, изпълнете пакетна задача срещу този фиктивен доставчик в CI конвейер и същите места на извикване на SendDocumentByMail ще продължат да работят без промяна, когато в продукция MailProvider остане незададено и PDFlibPas премине към вградения CDO транспорт
Пакетната задача за изпращане на извлечения рядко приключва само с изпращането: същият конвейер често трябва да провери и подпише PDF файла, преди той да бъде изпратен, което е разгледано отделно в статията за проверка на съответствието и подписване, тъй като предварителната проверка и удостоверяването на подписа са различна задача от доставянето на пощата, дори когато се изпълняват последователно. Когато изпращаните документи са резултат от голяма операция за сливане или разделяне, а не един току-що създаден PDF файл, ръководството за директен достъп до големи PDF файлове обхваща стъпката за генериране. SendDocumentByMail и описаният тук модел на пощенския доставчик са част от стандартната PDF библиотека за разработка PDFlibPas за Delphi и C++Builder, а продуктовата страница съдържа пълната API справка и пробна версия чрез PDFlibPas