PDFlibPas 是面向 Delphi 和 C++Builder 的 losLab PDF 开发库,通过一次扁平 API 调用即可将生成的 PDF 作为电子邮件附件发送,调用名称为 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 没有失败;将它视为失败的代码会在线程实际没有出错时报告失败。线程首次成功初始化 COM 时,CoInitializeEx 返回 S_OK;当线程已经使用兼容的并发模型初始化 COM 时,它会返回 S_FALSE,但两种结果都会增加同一个线程级引用计数,因此在线程退出或转而处理无关工作前,两种结果都需要匹配的 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 现在通过 COINIT_APARTMENTTHREADED 请求单线程套间(STA)语义。线程只能选择一次套间模型,该模型在整个生命周期内都不能更改;用不同标志重试 CoInitializeEx 无法修复不匹配,而先调用 CoUninitialize 会拆除线程上其他代码可能仍依赖的套间。PDFlibPas 将 RPC_E_CHANGED_MODE 当作需要配合处理的条件,而不是要报告的错误:由于调用实际上没有取得待释放的引用,它会跳过成对的 CoUninitialize,并让发送继续使用现有套间
RPC_E_CHANGED_MODE 几乎只会出现在被重复使用的线程上:线程池工作线程、IIS 或服务宿主线程,或者其他代码(例如 ADO 或 WMI)已经在邮件代码执行前使用 COINIT_MULTITHREADED 调用过 CoInitializeEx 的线程。一个全新的线程如果只调用 SendDocumentByMail,不会走到这条路径。批处理调度器每天重复回收数千次、并与其他基于 COM 的工作共享的工作线程则一定可能遇到它,而且通常是间歇性出现,这正是人们先检查 SMTP 服务器、后检查线程模型的原因
避免邮件附件进入错误目录
PDFlibPas 会在每次调用 SendDocumentByMail 时生成一个以 GUID 命名的新目录,并将每个待发送附件写入其中,专门用来确保并发发送永远不会争用同一个文件名,也确保附件名称无法走出该目录。传入的附件名称不会被当作路径信任:它会经过 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 流水线中使用这个伪提供程序运行批处理任务;当生产环境中让 MailProvider 保持未设置并由 PDFlibPas 回退到内置 CDO 传输时,同样的 SendDocumentByMail 调用位置仍可不加修改地继续工作
发送对账单的批处理任务通常不止于发送:同一流水线经常还需要在发送前验证并签署 PDF,这部分由合规与签名工作台文章单独介绍,因为预检和签名验证与邮件投递是不同的关注点,即使两者前后紧接着运行也是如此。当要发送的文档并非刚生成的单个 PDF,而是大型合并或拆分任务的输出时,大型 PDF 直接访问指南介绍生成步骤。此处介绍的 SendDocumentByMail 和邮件提供程序模型属于面向 Delphi 和 C++Builder 的标准 PDFlibPas PDF 开发库,产品页还提供完整 API 参考和试用版下载