Artikel Teknis

Mengirim PDF lewat Email via CDO di Delphi: Jebakan Apartment-Threading

PDFlibPas, losLab PDF Developer Library untuk Delphi dan C++Builder, mengirim sebuah PDF yang dihasilkan sebagai lampiran email lewat satu pemanggilan API datar, SendDocumentByMail. Di Windows transport default menggunakan CDO (Collaboration Data Objects), komponen mail COM yang dibangun ke dalam sistem operasi, dan detail yang sebenarnya merusak job batch multi-threaded adalah inisialisasi apartment COM, bukan SMTP

Skenario di balik API ini kurang glamor dan sangat umum: sebuah service merender sebuah batch PDF statement akhir-bulan, satu per pelanggan, dan harus mengirimkan setiap satunya lewat email tanpa seorang manusia dalam loop tersebut. Dorong job itu ke sebuah thread pool untuk throughput, dan sebagian pengiriman mulai gagal dengan sebuah error COM yang tidak pernah tereproduksi ketika kode yang sama berjalan pada satu thread tunggal. Tidak ada yang salah dengan server SMTP, PDF, atau lampirannya. Masalahnya adalah apa yang dikembalikan CoInitializeEx pada sebuah thread yang tidak diharapkan CDO, dan PDFlibPas ditulis untuk menangani kasus itu dengan sengaja alih-alih secara kebetulan

Apa sebenarnya yang dilakukan SendDocumentByMail di dalam PDFlibPas

SendDocumentByMail adalah sebuah orkestrator tipis, bukan sebuah klien mail dalam haknya sendiri. TPDFlib.SendDocumentByMail menyimpan dokumen yang sedang dimuat ke sebuah PDF sementaranya sendiri, mengemas pengaturan SMTP dan teks pesan ke dalam sebuah record TPDFlibMailRequest, menyerahkan record itu ke apa pun yang mengimplementasikan IPDFlibMailProvider, dan menghapus file sementara itu lagi begitu provider tersebut kembali. Interface provider adalah klien mail sesungguhnya, dan PDFlibPas mengirimkan persis satu implementasi bawaan: sebuah provider berbasis-CDO yang hanya dikompilasi di Windows. Panggil SendDocumentByMail tanpa menetapkan properti MailProvider lebih dulu, dan PDFlibPas jatuh kembali ke default itu secara otomatis. Nilai kembali tetap sengaja sempit sepanjang jalan: 1 untuk diterima, 0 untuk apa pun lainnya, baik itu sebuah field wajib yang hilang, sebuah kegagalan penulisan file-sementara, atau provider menolak pesan itu, dengan alasan sesungguhnya hanya tersedia dari GetLastMailError setelahnya

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;

Mengapa CoInitializeEx mengembalikan S_FALSE, dan apakah itu sebuah kegagalan?

S_FALSE dari CoInitializeEx bukan sebuah kegagalan, dan kode yang memperlakukannya sebagai satu melaporkan kegagalan pada thread di mana sebenarnya tidak ada yang salah. CoInitializeEx mengembalikan S_OK kali pertama sebuah thread berhasil menginisialisasi COM, dan ia mengembalikan S_FALSE ketika thread itu sudah memiliki COM terinisialisasi dengan sebuah model concurrency yang kompatibel, menaikkan reference count per-thread yang sama dalam kedua kasus, sehingga kedua hasil itu membutuhkan sebuah pemanggilan CoUninitialize yang cocok sebelum thread tersebut keluar atau berpindah ke pekerjaan tak-berkaitan. TPDFlib sendiri mengikuti pola persis ini: membuat sebuah instance TPDFlib sudah memanggil CoInitialize dan mencatat apakah sebuah CoUninitialize yang cocok terutang, menggunakan pemeriksaan S_OK-atau-S_FALSE yang identik. Pada saat SendDocumentByMail mencapai provider CDO-nya dan provider itu memanggil CoInitializeEx lagi, COM karenanya sudah terinisialisasi pada thread itu dalam kasus biasa, sehingga provider hampir selalu mengamati S_FALSE alih-alih S_OK. Memperlakukan S_FALSE sebagai apa pun selain sukses bukan sebuah kasus tepi langka dalam library ini; itu adalah jalur umum

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;

Mengapa CoInitializeEx mengembalikan RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE berarti thread saat ini menginisialisasi COM lebih awal di bawah model concurrency berbeda dari yang diminta pemanggilan ini, biasanya karena thread itu sebelumnya menjadi multi-threaded (MTA) dan CDO sekarang meminta semantik single-threaded apartment (STA) lewat COINIT_APARTMENTTHREADED. Sebuah thread memilih model apartment-nya sekali, dan tidak ada apa pun yang bisa mengubah model itu untuk sisa umur thread tersebut; mencoba ulang CoInitializeEx dengan flag berbeda tidak memperbaiki ketidakcocokan itu, dan memanggil CoUninitialize lebih dulu akan merobohkan sebuah apartment yang mungkin masih bergantung padanya kode lain pada thread itu. PDFlibPas memperlakukan RPC_E_CHANGED_MODE sebagai sebuah kondisi untuk dikerjakan alih-alih sebuah error untuk dilaporkan: ia melewati CoUninitialize yang berpasangan, karena pemanggilan itu tidak pernah benar-benar memperoleh sebuah referensi untuk dilepaskan, dan membiarkan pengiriman berlanjut pada apartment yang sudah ada

RPC_E_CHANGED_MODE muncul hampir secara eksklusif pada thread yang digunakan ulang: sebuah worker thread-pool, sebuah thread IIS atau service-host, atau thread mana pun di mana kode lebih awal seperti ADO atau WMI sudah memanggil CoInitializeEx dengan COINIT_MULTITHREADED sebelum kode mail sampai ke mana pun di dekatnya. Sebuah thread yang benar-benar baru yang tidak melakukan apa pun kecuali memanggil SendDocumentByMail tidak akan menabrak jalur ini. Sebuah worker thread yang didaur ulang ribuan kali sehari oleh sebuah scheduler batch, dan dibagi dengan pekerjaan berbasis-COM lainnya, benar-benar akan, dan itu akan terjadi secara intermiten, yang persis merupakan pola yang mengirim orang-orang melihat server SMTP lebih dulu dan model threading kedua

Menjaga sebuah lampiran mail keluar dari direktori yang salah

PDFlibPas menulis setiap lampiran keluar ke dalam sebuah direktori baru yang dinamai berdasarkan sebuah GUID yang dihasilkannya pada setiap pemanggilan SendDocumentByMail, secara khusus agar pengiriman konkuren tidak pernah bisa bertabrakan pada nama file yang sama dan agar sebuah nama lampiran tidak bisa keluar dari direktori itu. Nama yang diserahkan sebagai lampiran tidak dipercaya sebagai sebuah path: ia melalui PLSanitizeAttachmentName, yang menghapus komponen direktori apa pun, menolak string kosong dan nama khusus . dan .., dan mengganti setiap karakter yang dianggap Windows ilegal dalam sebuah nama file, beserta karakter kontrol apa pun, dengan sebuah underscore. Berikan padanya ..\quarter:report.pdf, sebagian traversal direktori dan sebagian titik dua ilegal, dan apa yang sampai ke disk adalah quarter_report.pdf: segala sesuatu hingga separator path terakhir dibuang, dan titik dua itu menjadi sebuah underscore karena tidak bisa muncul dalam sebuah nama 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;

Sebuah direktori khusus per-pemanggilan bukan sekadar kerapian. SendDocumentByMail menghapus file sementara dan menghilangkan direktorinya dalam sebuah blok finally setelah pesan itu terkirim, menggunakan persis path yang ditulisnya, sehingga sebuah nama lampiran yang mencapai kode itu tanpa disanitasi tidak hanya akan salah menempatkan penulisan tersebut. Path tanpa-sanitasi yang sama itu kemudian akan mencapai sebuah langkah pembersihan yang memanggil DeleteFile tanpa bertanya lebih lanjut, dan pada sebuah folder temp bersama, dua pengiriman konkuren juga bisa diam-diam menimpa lampiran satu sama lain di bawah nama yang sama sebelum salah satu pengiriman selesai. Mensanitasi nama itu menutup kasus traversal, dan direktori GUID per-pemanggilan menutup kasus tabrakan, dan tak satu pun dari keduanya sendirian akan cukup

Mencocokkan umur COM dengan umur thread dalam sebuah worker pool

Perbaikan paling andal untuk kegagalan apartment-threading dalam sebuah mailer batch adalah berhenti memperlakukan setiap pemanggilan SendDocumentByMail sebagai umur COM-nya sendiri yang terisolasi, dan sebagai gantinya menginisialisasi COM sekali per worker thread, sepanjang umur thread itu. Sebuah worker yang memanggil CoInitializeEx(nil, COINIT_APARTMENTTHREADED) ketika dimulai, mempertahankan apartment itu untuk setiap pemanggilan SendDocumentByMail yang dibuatnya, dan memanggil CoUninitialize tepat satu kali ketika keluar tidak akan pernah melihat RPC_E_CHANGED_MODE dari pengiriman mail-nya sendiri, karena tidak ada apa pun lain pada thread itu yang mendapat kesempatan menginisialisasi COM dalam mode yang bertentangan lebih dulu. Setiap pemanggilan SendDocumentByMail individual tetap menjalankan pasangan CoInitializeEx dan CoUninitialize-nya sendiri secara internal di bawah pola ini, dan itu tidak berbahaya: dengan apartment yang sudah ditetapkan worker thread, setiap satu dari pemanggilan internal itu sekarang melihat S_FALSE, menaikkan dan menurunkan reference count yang sama, dan meninggalkan apartment COM milik worker thread itu sendiri tidak tersentuh

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;

Mendiagnosis kegagalan dan menguji tanpa sebuah mailbox hidup

GetLastMailError adalah separuh lain dari API ini yang layak dibangun ke dalam logging sejak hari pertama, karena nilai kembali 1-atau-0 saja tidak mengatakan apakah sebuah pengiriman yang gagal adalah sebuah masalah inisialisasi COM, sebuah penolakan autentikasi SMTP, atau sebuah lampiran yang hilang. Properti MailProvider adalah yang membuat seluruh jalur itu bisa diuji tanpa sebuah mailbox sungguhan: tetapkan padanya sebuah implementasi IPDFlibMailProvider yang mencatat permintaan alih-alih mengirimkannya, jalankan sebuah job batch terhadap provider palsu itu dalam sebuah pipeline CI, dan titik pemanggilan SendDocumentByMail yang sama tetap bekerja tak berubah begitu MailProvider dibiarkan tidak diatur dan PDFlibPas jatuh kembali ke transport CDO bawaan dalam produksi

Sebuah job batch yang mengirim statement lewat email jarang berhenti pada pengiriman: pipeline yang sama seringkali perlu memvalidasi dan menandatangani PDF tersebut sebelum keluar, yang dibahas terpisah di artikel workbench kepatuhan dan signing, karena preflight dan verifikasi signature adalah kepedulian berbeda dari pengiriman mail bahkan ketika keduanya berjalan berurutan. Ketika dokumen yang dikirim lewat email itu sendiri adalah output dari sebuah job merge atau split besar alih-alih sebuah PDF yang baru saja dibangun tunggal, panduan direct-access PDF besar membahas langkah pembuatan itu. SendDocumentByMail dan model mail provider yang dijelaskan di sini adalah bagian dari PDFlibPas PDF Developer Library standar untuk Delphi dan C++Builder, dan halaman produk membawa referensi API lengkap berdampingan dengan sebuah unduhan trial