PDFlibPas 是適用於 Delphi 與 C++Builder 的 losLab PDF 開發者函式庫,透過一次扁平的 API 呼叫 SendDocumentByMail,將產生的 PDF 作為電子郵件附件寄出。在 Windows 上,預設傳輸使用 CDO(Collaboration Data Objects),也就是內建於作業系統的 COM 郵件元件,而真正會讓多執行緒批次工作中斷的細節是 COM apartment 初始化,而不是 SMTP
這個 API 背後的情境既不起眼又極其常見:服務會產生一批月底對帳單 PDF,每位客戶一份,並且必須在無人介入的情況下逐一寄出。為了提高吞吐量而將工作推送到執行緒集區後,其中一小部分寄送開始出現 COM 錯誤,但同一段程式碼在單一執行緒上執行時卻無法重現。SMTP 伺服器、PDF 或附件都沒有問題。問題在於 CoInitializeEx 在 CDO 未預期的執行緒上回傳了什麼,而 PDFlibPas 是刻意處理這種情況,不是偶然避開它
SendDocumentByMail 在 PDFlibPas 內部實際做了什麼
SendDocumentByMail 是薄型協調器,本身不是郵件用戶端。TPDFlib.SendDocumentByMail 會將目前載入的文件儲存為專用的暫存 PDF,把 SMTP 設定與訊息文字封裝至 TPDFlibMailRequest 記錄,將該記錄交給任何實作 IPDFlibMailProvider 的元件,並在提供者回傳後刪除暫存檔。提供者介面才是真正的郵件用戶端,而 PDFlibPas 正好附帶一個內建實作:以 CDO 為基礎、只能在 Windows 上編譯的提供者。在未先指派 MailProvider 屬性的情況下呼叫 SendDocumentByMail,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 會在執行緒首次成功初始化 COM 時回傳 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 要求單執行緒 apartment(STA)語意。執行緒只能選定一次 apartment 模型,該執行緒存續期間都不能變更;以不同旗標重試 CoInitializeEx 無法修正不匹配,而先呼叫 CoUninitialize 會拆除該執行緒上其他程式碼可能仍依賴的 apartment。PDFlibPas 將 RPC_E_CHANGED_MODE 視為可處理的狀況,而不是要回報的錯誤:它跳過配對的 CoUninitialize,因為該呼叫實際上沒有取得要釋放的參考,並讓寄送工作在現有 apartment 上繼續
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 存留期與執行緒存留期
批次郵件程式處理 apartment 執行緒失敗時,最可靠的修正方式是停止將每次 SendDocumentByMail 呼叫視為獨立的 COM 存留期,改為每個工作執行緒初始化一次,並持續到該執行緒結束。工作執行緒啟動時呼叫 CoInitializeEx(nil, COINIT_APARTMENTTHREADED),在它執行的每次 SendDocumentByMail 呼叫中保留該 apartment,並在結束時恰好呼叫一次 CoUninitialize,如此自身的寄送就永遠不會看到 RPC_E_CHANGED_MODE,因為執行緒上的其他程式碼沒有機會先以衝突模式初始化 COM。在這個模式下,每次個別的 SendDocumentByMail 呼叫仍會在內部執行自己的 CoInitializeEx 與 CoUninitialize 配對,而這沒有問題:工作執行緒已建立 apartment 後,這些內部呼叫都會看到 S_FALSE,遞增並遞減相同的參考計數,同時不影響工作執行緒自己的 COM apartment
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 參考與試用版下載