Teknik Makale

Delphi'de CDO ile PDF E-postalama: Apartman-İş Parçacığı Tuzakları

Delphi ve C++Builder için losLab PDF Geliştirici Kütüphanesi olan PDFlibPas, üretilen bir PDF'i tek düz bir API çağrısıyla, SendDocumentByMail, bir e-posta eki olarak gönderir. Windows'ta varsayılan taşıma, işletim sistemine yerleşik COM posta bileşeni olan CDO'yu (Collaboration Data Objects) kullanır ve çok iş parçacıklı toplu işleri gerçekten bozan detay, SMTP değil COM apartman başlatmasıdır

Bu API'nin arkasındaki senaryo sıradan ve son derece yaygındır: bir servis, müşteri başına bir tane olmak üzere bir ay sonu hesap özeti PDF'i toplusu render eder ve döngüde bir insan olmadan her birini postalamak zorundadır. Bu işi verim için bir iş parçacığı havuzuna itin ve gönderimlerin bir kısmı, aynı kod tek bir iş parçacığında çalıştığında hiç yeniden üretilmeyen bir COM hatasıyla başarısız olmaya başlar. SMTP sunucusunda, PDF'te veya ekte hiçbir sorun yoktur. Sorun, CoInitializeEx'in CDO'nun beklemediği bir iş parçacığında ne döndürdüğüdür ve PDFlibPas bu durumu kazayla değil kasıtlı olarak ele alacak şekilde yazılmıştır

SendDocumentByMail PDFlibPas içinde gerçekte ne yapar?

SendDocumentByMail, kendi başına bir posta istemcisi değil, ince bir orkestratördür. TPDFlib.SendDocumentByMail, o anda yüklü belgeyi kendi geçici PDF'ine kaydeder, SMTP ayarlarını ve mesaj metnini bir TPDFlibMailRequest kaydına paketler, o kaydı IPDFlibMailProvider'ı uygulayan her ne varsa ona verir ve sağlayıcı döndükten sonra geçici dosyayı tekrar siler. Sağlayıcı arayüzü gerçek posta istemcisidir ve PDFlibPas tam olarak bir yerleşik uygulama gönderir: yalnızca Windows'ta derlenen CDO tabanlı bir sağlayıcı. Önce MailProvider özelliğini atamadan SendDocumentByMail'i çağırın ve PDFlibPas otomatik olarak o varsayılana düşer. Dönüş değeri boyunca kasıtlı olarak dardır: kabul için 1, başka her şey için 0, ister eksik gerekli bir alan, ister geçici-dosya yazma hatası, ister sağlayıcının mesajı reddetmesi olsun; gerçek neden yalnızca sonradan GetLastMailError'dan alınabilir

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 neden S_FALSE döndürür ve bu bir başarısızlık mıdır?

CoInitializeEx'ten gelen S_FALSE bir başarısızlık değildir ve onu bir başarısızlıkmış gibi ele alan kod, gerçekte hiçbir şeyin yanlış gitmediği iş parçacıklarında başarısızlıklar bildirir. CoInitializeEx, bir iş parçacığı COM'u ilk kez başarıyla başlattığında S_OK döndürür ve o iş parçacığında COM zaten uyumlu bir eşzamanlılık modeliyle başlatılmışsa S_FALSE döndürür; her iki durumda da aynı iş parçacığı başına referans sayısını artırır; bu yüzden her iki sonuç da, iş parçacığı çıkmadan veya ilgisiz bir işe geçmeden önce eşleşen bir CoUninitialize çağrısına ihtiyaç duyar. TPDFlib'in kendisi tam olarak bu örüntüyü izler: bir TPDFlib örneği kurmak zaten CoInitialize'ı çağırır ve aynı S_OK-veya-S_FALSE kontrolünü kullanarak eşleşen bir CoUninitialize'ın borçlu olup olmadığını kaydeder. SendDocumentByMail CDO sağlayıcısına ulaştığında ve o sağlayıcı CoInitializeEx'i tekrar çağırdığında, bu yüzden COM sıradan durumda iş parçacığında zaten başlatılmıştır; bu yüzden sağlayıcı neredeyse her zaman S_OK yerine S_FALSE gözlemler. S_FALSE'ı başarı dışında bir şey olarak ele almak bu kütüphanede nadir bir uç durum değildir; yaygın yoldur

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 neden RPC_E_CHANGED_MODE döndürür?

RPC_E_CHANGED_MODE, mevcut iş parçacığının COM'u daha önce, bu çağrının istediğinden farklı bir eşzamanlılık modeli altında başlattığı anlamına gelir; tipik olarak iş parçacığı daha önce çok iş parçacıklı (MTA) hale geldiği ve CDO'nun şimdi COINIT_APARTMENTTHREADED aracılığıyla tek iş parçacıklı apartman (STA) anlambilimini istediği için. Bir iş parçacığı apartman modelini bir kez seçer ve hiçbir şey iş parçacığının geri kalan ömrü için o modeli değiştiremez; farklı bayraklarla CoInitializeEx'i yeniden denemek uyuşmazlığı düzeltmez ve önce CoUninitialize'ı çağırmak, o iş parçacığındaki başka kodun hâlâ bağımlı olabileceği bir apartmanı yıkar. PDFlibPas, RPC_E_CHANGED_MODE'u bildirilecek bir hata yerine üzerinde çalışılacak bir koşul olarak ele alır: eşleşen CoUninitialize'ı atlar, çünkü çağrı gerçekte serbest bırakılacak bir referans hiç edinmemiştir ve gönderimin mevcut apartmanda devam etmesine izin verir

RPC_E_CHANGED_MODE neredeyse yalnızca yeniden kullanılan iş parçacıklarında ortaya çıkar: bir iş parçacığı havuzu işçisi, bir IIS veya servis-ana bilgisayarı iş parçacığı veya ADO veya WMI gibi daha önceki kodun, posta kodu ona hiç yaklaşmadan önce zaten COINIT_MULTITHREADED ile CoInitializeEx'i çağırdığı herhangi bir iş parçacığı. Yalnızca SendDocumentByMail'i çağıran yepyeni bir iş parçacığı bu yola çarpmayacaktır. Bir toplu iş zamanlayıcısı tarafından günde binlerce kez geri dönüştürülen ve diğer COM tabanlı işlerle paylaşılan bir işçi iş parçacığı ise kesinlikle çarpacaktır ve bunu aralıklı olarak yapacaktır ki bu, insanları önce SMTP sunucusuna, sonra iş parçacığı modeline bakmaya gönderen tam olarak bu örüntüdür

Bir posta ekini yanlış dizinden uzak tutmak

PDFlibPas, her giden eki, her SendDocumentByMail çağrısında ürettiği bir GUID'in adını taşıyan taze bir dizine yazar; özellikle eşzamanlı gönderimlerin aynı dosya adında asla çakışmaması ve bir ek adının o dizinin dışına çıkamaması için. Ek olarak geçirilen ad bir yol olarak güvenilmez: PLSanitizeAttachmentName'den geçer; bu, herhangi bir dizin bileşenini soyar, boş dizeyi ve özel . ve .. adlarını reddeder ve Windows'un bir dosya adında yasadışı olarak ele aldığı her karakteri, herhangi bir kontrol karakteriyle birlikte, bir alt çizgiyle değiştirir. Ona ..\quarter:report.pdf'yi verin, kısmen dizin geçişi ve kısmen yasadışı iki nokta üst üste, ve diske ulaşan şey quarter_report.pdf'dir: son yol ayırıcısına kadar her şey atılır ve iki nokta üst üste bir Windows dosya adında görünemeyeceğinden alt çizgi olur

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;

Çağrı başına özel bir dizin yalnızca düzenlilik değildir. SendDocumentByMail, mesaj gönderildikten sonra geçici dosyayı ve dizinini, yazdığı aynı yolu kullanarak bir finally bloğunda siler; bu yüzden temizlenmemiş olarak o koda ulaşan bir ek adı yalnızca yazmayı yanlış yere koymakla kalmazdı. Aynı temizlenmemiş yol, sonra başka soru sormadan DeleteFile'ı çağıran bir temizlik adımına ulaşırdı ve paylaşılan bir geçici klasörde, iki eşzamanlı gönderim de, teslimatlardan biri bitmeden aynı ad altında birbirinin ekini sessizce üzerine yazabilirdi. Adı temizlemek geçiş durumunu kapatır ve çağrı başına GUID dizini çakışma durumunu kapatır ve ikisinden de yalnızca biri yeterli olmazdı

Bir işçi havuzunda COM ömrünü iş parçacığı ömrüyle eşleştirmek

Toplu bir posta gönderici uygulamasında apartman-iş parçacığı hatalarının en güvenilir düzeltmesi, her SendDocumentByMail çağrısını kendi yalıtılmış COM ömrü olarak ele almayı bırakmak ve bunun yerine COM'u iş parçacığı başına bir kez, o iş parçacığının ömrü boyunca başlatmaktır. Başladığında CoInitializeEx(nil, COINIT_APARTMENTTHREADED)'i çağıran, yaptığı her SendDocumentByMail çağrısı için o apartmanı tutan ve çıktığında tam olarak bir kez CoUninitialize'ı çağıran bir işçi, kendi posta gönderimlerinden asla RPC_E_CHANGED_MODE görmeyecektir, çünkü o iş parçacığındaki başka hiçbir şey önce çakışan bir modda COM'u başlatma şansı bulamaz. Bu örüntü altında her tek SendDocumentByMail çağrısı yine dahili olarak kendi CoInitializeEx ve CoUninitialize çiftini çalıştırır ve bu zararsızdır: apartman işçi iş parçacığı tarafından zaten kurulmuşken, bu dahili çağrıların her biri artık S_FALSE görür, aynı referans sayısını artırır ve azaltır ve işçi iş parçacığının kendi COM apartmanını dokunulmamış bırakır

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;

Hataları teşhis etmek ve canlı bir posta kutusu olmadan test etmek

GetLastMailError, bu API'nin ilk günden günlüklemeye kurulmaya değer diğer yarısıdır, çünkü tek başına 1-veya-0 dönüş değeri, başarısız bir gönderimin bir COM başlatma sorunu mu, bir SMTP kimlik doğrulama reddi mi, yoksa eksik bir ek mi olduğunu söylemez. MailProvider özelliği, tüm yolu gerçek bir posta kutusu olmadan test edilebilir kılan şeydir: ona, istekleri göndermek yerine kaydeden bir IPDFlibMailProvider uygulaması atayın, bir CI boru hattında o sahte sağlayıcıya karşı toplu bir işi çalıştırın ve MailProvider ayarlanmadan bırakıldığında ve PDFlibPas üretimde yerleşik CDO taşımasına düştüğünde aynı SendDocumentByMail çağrı yerleri değişmeden çalışmaya devam eder

Hesap özetlerini e-postalayan bir toplu iş nadiren göndermekte durur: aynı boru hattı genellikle PDF'i gitmeden önce doğrulamayı ve imzalamayı gerektirir; bu, önkontrol ve imza doğrulama posta teslimatından farklı bir konu olduğundan, ikisi de arka arkaya çalışsa bile, ayrıca uyumluluk ve imzalama çalışma tezgahı makalesinde ele alınmıştır. Postalanan belgeler, tek bir yeni oluşturulmuş PDF yerine büyük bir birleştirme veya bölme işinin çıktısının kendisi olduğunda, büyük-PDF doğrudan erişim rehberi, o üretim adımını ele alır. Burada açıklanan SendDocumentByMail ve posta sağlayıcısı modeli, Delphi ve C++Builder için standart PDFlibPas PDF Geliştirici Kütüphanesi'nin bir parçasıdır ve ürün sayfası, bir deneme indirmesinin yanında tam API referansını taşır