Bài viết kỹ thuật

Gửi email PDF qua CDO trong Delphi: cạm bẫy apartment-threading

PDFlibPas, thư viện phát triển PDF của losLab dành cho Delphi và C++Builder, gửi một PDF đã tạo như một file đính kèm email thông qua một lệnh gọi API phẳng duy nhất, SendDocumentByMail. Trên Windows, phương thức vận chuyển mặc định dùng CDO (Collaboration Data Objects), component mail COM tích hợp sẵn trong hệ điều hành, và chi tiết thực sự làm hỏng các job hàng loạt đa luồng là việc khởi tạo apartment COM, không phải SMTP

Kịch bản đứng sau API này không hào nhoáng và cực kỳ phổ biến: một dịch vụ render một lô PDF sao kê cuối tháng, một cho mỗi khách hàng, và phải gửi mail từng cái mà không cần một người trong vòng lặp. Đẩy job đó vào một thread pool để tăng thông lượng, và một phần các lượt gửi bắt đầu thất bại với một lỗi COM không bao giờ tái tạo được khi cùng code chạy trên một luồng đơn. Không có gì sai với server SMTP, PDF, hay file đính kèm. Vấn đề là những gì CoInitializeEx trả về trên một luồng mà CDO không mong đợi, và PDFlibPas được viết để xử lý trường hợp đó có chủ đích chứ không phải tình cờ

SendDocumentByMail thực sự làm gì bên trong PDFlibPas

SendDocumentByMail là một bộ điều phối mỏng, không phải một mail client tự thân. TPDFlib.SendDocumentByMail lưu tài liệu đang tải vào một file PDF tạm riêng của nó, đóng gói các thiết lập SMTP và văn bản thông điệp vào một bản ghi TPDFlibMailRequest, đưa bản ghi đó cho bất cứ thứ gì triển khai IPDFlibMailProvider, và xóa file tạm lần nữa một khi provider trả về. Interface provider là mail client thực sự, và PDFlibPas gửi kèm chính xác một triển khai tích hợp sẵn: một provider dựa trên CDO chỉ biên dịch trên Windows. Gọi SendDocumentByMail mà không gán thuộc tính MailProvider trước, và PDFlibPas tự động rơi về mặc định đó. Giá trị trả về giữ có chủ đích hẹp xuyên suốt: 1 cho chấp nhận, 0 cho bất cứ điều gì khác, dù đó là một trường bắt buộc bị thiếu, một lỗi ghi file tạm, hay provider từ chối thông điệp, với lý do thực sự chỉ có sẵn từ GetLastMailError sau đó

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;

Vì sao CoInitializeEx trả về S_FALSE, và đó có phải một lỗi không?

S_FALSE từ CoInitializeEx không phải một lỗi, và code coi nó như một lỗi báo cáo lỗi trên các luồng mà thực ra không có gì sai cả. CoInitializeEx trả về S_OK lần đầu tiên một luồng khởi tạo COM thành công, và nó trả về S_FALSE khi luồng đó đã có COM được khởi tạo với một mô hình đồng thời tương thích, tăng cùng bộ đếm tham chiếu theo-từng-luồng trong cả hai trường hợp, nên cả hai kết quả đều cần một lệnh gọi CoUninitialize khớp trước khi luồng thoát hoặc chuyển sang công việc không liên quan. Bản thân TPDFlib theo đúng khuôn mẫu này: việc dựng một instance TPDFlib đã gọi CoInitialize và ghi lại liệu một lệnh gọi CoUninitialize khớp có đang nợ hay không, dùng đúng kiểm tra S_OK-hoặc-S_FALSE giống hệt. Đến lúc SendDocumentByMail đến được provider CDO của nó và provider đó gọi CoInitializeEx lần nữa, COM do đó đã được khởi tạo trên luồng trong trường hợp thông thường, nên provider hầu như luôn quan sát S_FALSE thay vì S_OK. Việc coi S_FALSE là bất cứ điều gì khác ngoài thành công không phải một trường hợp biên hiếm trong thư viện này; nó là đường đi phổ biến

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;

Vì sao CoInitializeEx trả về RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE nghĩa là luồng hiện tại đã khởi tạo COM trước đó dưới một mô hình đồng thời khác với mô hình lệnh gọi này đang yêu cầu, thường vì luồng trước đó đã trở thành đa luồng (MTA) và CDO giờ yêu cầu ngữ nghĩa single-threaded apartment (STA) thông qua COINIT_APARTMENTTHREADED. Một luồng chọn mô hình apartment của nó đúng một lần, và không gì có thể thay đổi mô hình đó cho phần còn lại của vòng đời luồng; thử lại CoInitializeEx với các cờ khác không sửa được sự không khớp đó, và gọi CoUninitialize trước sẽ phá dỡ một apartment mà code khác trên luồng đó có thể vẫn phụ thuộc vào. PDFlibPas coi RPC_E_CHANGED_MODE là một điều kiện để làm việc cùng thay vì một lỗi để báo cáo: nó bỏ qua CoUninitialize khớp, vì lệnh gọi đó chưa bao giờ thực sự lấy được một tham chiếu để giải phóng, và để lượt gửi tiếp tục trên apartment hiện có

RPC_E_CHANGED_MODE gần như chỉ xuất hiện trên các luồng được tái sử dụng: một worker thread pool, một luồng IIS hay service-host, hay bất kỳ luồng nào mà code trước đó như ADO hay WMI đã gọi CoInitializeEx với COINIT_MULTITHREADED trước khi code mail đến gần nó. Một luồng hoàn toàn mới không làm gì ngoài gọi SendDocumentByMail sẽ không gặp đường này. Một worker thread được tái chế hàng nghìn lần mỗi ngày bởi một bộ lập lịch hàng loạt, và được chia sẻ với công việc dựa trên COM khác, chắc chắn sẽ gặp, và nó sẽ làm vậy một cách ngắt quãng, chính xác là khuôn mẫu khiến người ta nhìn vào server SMTP trước và mô hình luồng sau

Giữ một file đính kèm mail tránh xa sai thư mục

PDFlibPas viết mỗi file đính kèm gửi đi vào một thư mục mới được đặt tên theo một GUID nó sinh ra ở mỗi lệnh gọi SendDocumentByMail, cụ thể để các lượt gửi đồng thời không bao giờ va chạm trên cùng một tên file và để một tên đính kèm không thể thoát ra khỏi thư mục đó. Tên được truyền vào như file đính kèm không được tin tưởng như một đường dẫn: nó đi qua PLSanitizeAttachmentName, hàm loại bỏ bất kỳ thành phần thư mục nào, từ chối chuỗi rỗng và các tên đặc biệt ..., và thay mỗi ký tự Windows coi là bất hợp pháp trong một tên file, cùng với bất kỳ ký tự điều khiển nào, bằng một dấu gạch dưới. Đưa cho nó ..\quarter:report.pdf, một phần duyệt thư mục và một phần dấu hai chấm bất hợp pháp, và những gì đến được đĩa là quarter_report.pdf: mọi thứ cho đến dấu phân cách đường dẫn cuối cùng bị loại bỏ, và dấu hai chấm trở thành một dấu gạch dưới vì nó không thể xuất hiện trong một tên file 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;

Một thư mục chuyên dụng cho mỗi lệnh gọi không chỉ là sự gọn gàng. SendDocumentByMail xóa file tạm và loại bỏ thư mục của nó trong một khối finally sau khi thông điệp được gửi, dùng chính đường dẫn nó đã viết vào, nên một tên đính kèm đến được code đó chưa được làm sạch sẽ không chỉ đặt sai vị trí lượt ghi. Cùng đường dẫn chưa làm sạch đó sau đó sẽ đến được một bước dọn dẹp gọi DeleteFile mà không hỏi thêm câu hỏi nào, và trên một thư mục temp dùng chung, hai lượt gửi đồng thời cũng có thể âm thầm ghi đè file đính kèm của nhau dưới cùng một tên trước khi một trong hai lượt gửi hoàn tất. Việc làm sạch tên đóng lại trường hợp duyệt thư mục, và thư mục GUID theo-từng-lệnh-gọi đóng lại trường hợp va chạm, và không cái nào một mình sẽ đủ

Khớp vòng đời COM với vòng đời luồng trong một worker pool

Cách sửa đáng tin cậy nhất cho các lỗi apartment-threading trong một mailer hàng loạt là ngừng coi mỗi lệnh gọi SendDocumentByMail như vòng đời COM cô lập của riêng nó, và thay vào đó khởi tạo COM một lần cho mỗi worker thread, suốt vòng đời của luồng đó. Một worker gọi CoInitializeEx(nil, COINIT_APARTMENTTHREADED) khi nó bắt đầu, giữ apartment đó cho mỗi lệnh gọi SendDocumentByMail nó thực hiện, và gọi CoUninitialize đúng một lần khi nó thoát sẽ không bao giờ thấy RPC_E_CHANGED_MODE từ các lượt gửi mail của chính nó, vì không gì khác trên luồng đó có cơ hội khởi tạo COM ở một chế độ xung đột trước. Mỗi lệnh gọi SendDocumentByMail riêng lẻ vẫn tự chạy cặp CoInitializeExCoUninitialize nội bộ dưới khuôn mẫu này, và điều đó vô hại: với apartment đã được worker thread thiết lập sẵn, mỗi lệnh gọi nội bộ đó giờ thấy S_FALSE, tăng và giảm cùng bộ đếm tham chiếu, và để nguyên apartment COM riêng của worker thread

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;

Chẩn đoán lỗi và test không cần một hộp thư sống

GetLastMailError là nửa còn lại của API này đáng xây dựng vào logging ngay từ ngày đầu, vì giá trị trả về 1-hoặc-0 riêng nó không nói lên liệu một lượt gửi thất bại là một vấn đề khởi tạo COM, một lượt từ chối xác thực SMTP, hay một file đính kèm bị thiếu. Thuộc tính MailProvider là thứ khiến toàn bộ đường này có thể test được mà không cần một hộp thư thật: gán cho nó một triển khai IPDFlibMailProvider ghi lại các yêu cầu thay vì gửi chúng, chạy một job hàng loạt trên provider giả đó trong một pipeline CI, và các điểm gọi SendDocumentByMail giống hệt tiếp tục hoạt động không đổi một khi MailProvider được để chưa đặt và PDFlibPas rơi về phương thức vận chuyển CDO tích hợp sẵn trong sản xuất

Một job hàng loạt gửi email sao kê hiếm khi dừng lại ở việc gửi: cùng pipeline thường cần xác thực và ký PDF trước khi nó đi ra, được nói đến riêng trong bài viết về xưởng tuân thủ chuẩn và ký, vì preflight và xác minh chữ ký là một mối quan tâm khác với việc gửi mail ngay cả khi cả hai chạy nối tiếp nhau. Khi các tài liệu đang được gửi mail tự thân là đầu ra của một job gộp hay tách lớn thay vì một PDF vừa tạo đơn lẻ, hướng dẫn direct-access PDF lớn nói đến bước sinh đó. SendDocumentByMail và mô hình mail provider được mô tả ở đây là một phần của thư viện phát triển PDF PDFlibPas tiêu chuẩn dành cho Delphi và C++Builder, và trang sản phẩm mang đầy đủ tài liệu tham khảo API cùng một bản tải thử