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, перш ніж він піде, що розглянуто окремо в статті про верстак відповідності та підписування, оскільки preflight та перевірка підпису — інше питання, ніж доставка пошти, навіть коли обидва виконуються один за одним. Коли документи, що надсилаються поштою, самі є виводом великого завдання об'єднання чи розділення, а не одного щойно побудованого PDF, посібник із прямого доступу до великих PDF охоплює цей крок генерації. SendDocumentByMail та модель поштового провайдера, описана тут, — частина стандартної бібліотеки розробника PDF PDFlibPas для Delphi та C++Builder, і сторінка продукту містить повний довідник API поряд із пробним завантаженням