Artykuł techniczny

Wysyłanie PDF-ów przez CDO w Delphi: pułapki wątkowania apartamentowego

PDFlibPas, biblioteka deweloperska PDF losLab dla Delphi i C++Buildera, wysyła wygenerowany PDF jako załącznik e-mail przez jedno płaskie wywołanie API, SendDocumentByMail. Na Windows domyślny transport używa CDO (Collaboration Data Objects), komponentu pocztowego COM wbudowanego w system operacyjny, a szczegół, który faktycznie psuje wielowątkowe zadania wsadowe, to inicjalizacja apartamentu COM, nie SMTP

Scenariusz stojący za tym API jest przyziemny i niezwykle powszechny: usługa renderuje partię PDF-ów wyciągów na koniec miesiąca, po jednym na klienta, i musi wysłać każdy pocztą bez udziału człowieka w pętli. Zepchnij to zadanie na pulę wątków dla przepustowości, a ułamek wysyłek zaczyna zawodzić z błędem COM, który nigdy się nie powtarza, gdy ten sam kod działa na jednym wątku. Nic nie jest nie tak z serwerem SMTP, PDF-em ani załącznikiem. Problem tkwi w tym, co zwraca CoInitializeEx na wątku, którego CDO się nie spodziewało, a PDFlibPas jest napisany, aby obsłużyć ten przypadek celowo, nie przez przypadek

Co faktycznie robi SendDocumentByMail wewnątrz PDFlibPas

SendDocumentByMail to cienki orkiestrator, nie klient pocztowy sam w sobie. TPDFlib.SendDocumentByMail zapisuje aktualnie wczytany dokument do własnego pliku tymczasowego PDF, pakuje ustawienia SMTP i tekst wiadomości w rekord TPDFlibMailRequest, przekazuje ten rekord dowolnej implementacji IPDFlibMailProvider, i usuwa plik tymczasowy ponownie, gdy tylko dostawca zwróci sterowanie. Interfejs dostawcy to faktyczny klient pocztowy, a PDFlibPas dostarcza dokładnie jedną wbudowaną implementację: dostawcę opartego na CDO, który kompiluje się wyłącznie na Windows. Wywołaj SendDocumentByMail bez wcześniejszego przypisania właściwości MailProvider, a PDFlibPas automatycznie wraca do tego domyślnego. Wartość zwracana pozostaje celowo wąska przez cały czas: 1 dla zaakceptowanego, 0 dla wszystkiego innego, czy to brakujące wymagane pole, awaria zapisu pliku tymczasowego, czy odrzucenie wiadomości przez dostawcę, z faktycznym powodem dostępnym dopiero potem z 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;

Dlaczego CoInitializeEx zwraca S_FALSE, i czy to awaria?

S_FALSE z CoInitializeEx nie jest awarią, a kod, który traktuje to jako awarię, zgłasza niepowodzenia na wątkach, na których naprawdę nic złego się nie stało. CoInitializeEx zwraca S_OK za pierwszym razem, gdy wątek pomyślnie inicjalizuje COM, i zwraca S_FALSE, gdy ten wątek już miał zainicjalizowany COM ze zgodnym modelem współbieżności, zwiększając ten sam licznik odwołań na wątek w obu przypadkach, więc oba wyniki wymagają pasującego wywołania CoUninitialize, zanim wątek się zakończy lub przejdzie do niepowiązanej pracy. Sam TPDFlib podąża za dokładnie tym wzorcem: konstrukcja instancji TPDFlib już wywołuje CoInitialize i zapisuje, czy należy się pasujące CoUninitialize, używając identycznego sprawdzenia S_OK-lub-S_FALSE. Zanim SendDocumentByMail dotrze do swojego dostawcy CDO, a ten dostawca ponownie wywoła CoInitializeEx, COM jest więc w zwykłym przypadku już zainicjalizowany na wątku, więc dostawca niemal zawsze obserwuje S_FALSE, a nie S_OK. Traktowanie S_FALSE jako czegokolwiek innego niż sukces nie jest rzadkim przypadkiem brzegowym w tej bibliotece; to zwykła ścieżka

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;

Dlaczego CoInitializeEx zwraca RPC_E_CHANGED_MODE?

RPC_E_CHANGED_MODE oznacza, że bieżący wątek zainicjalizował COM wcześniej pod innym modelem współbieżności niż ten, o który prosi to wywołanie, zwykle ponieważ wątek wcześniej stał się wielowątkowy (MTA), a CDO teraz prosi o semantykę jednowątkowego apartamentu (STA) przez COINIT_APARTMENTTHREADED. Wątek wybiera swój model apartamentu raz, i nic nie może zmienić tego modelu do końca życia wątku; ponowienie CoInitializeEx z innymi flagami nie naprawia niedopasowania, a wywołanie najpierw CoUninitialize zburzyłoby apartament, od którego może wciąż zależeć inny kod na tym wątku. PDFlibPas traktuje RPC_E_CHANGED_MODE jako warunek, z którym trzeba pracować, a nie błąd do zgłoszenia: pomija sparowane CoUninitialize, ponieważ wywołanie nigdy faktycznie nie pozyskało odwołania do zwolnienia, i pozwala wysyłce kontynuować na istniejącym apartamencie

RPC_E_CHANGED_MODE pojawia się niemal wyłącznie na wątkach ponownie wykorzystywanych: worker puli wątków, wątek IIS lub hosta usługi, lub dowolny wątek, na którym wcześniejszy kod, taki jak ADO czy WMI, już wywołał CoInitializeEx z COINIT_MULTITHREADED, zanim kod pocztowy w ogóle się do niego zbliżył. Zupełnie nowy wątek, który nie robi nic poza wywołaniem SendDocumentByMail, nie trafi na tę ścieżkę. Wątek roboczy poddawany recyklingowi tysiące razy dziennie przez harmonogram zadań wsadowych, dzielony z inną pracą opartą na COM, absolutnie trafi, i zrobi to sporadycznie, co jest dokładnie tym wzorcem, który wysyła ludzi najpierw sprawdzić serwer SMTP, a model wątkowania dopiero po nim

Trzymanie załącznika pocztowego z dala od niewłaściwego katalogu

PDFlibPas zapisuje każdy wychodzący załącznik do świeżego katalogu nazwanego od GUID-a, który generuje przy każdym wywołaniu SendDocumentByMail, konkretnie po to, aby równoczesne wysyłki nigdy nie kolidowały o tę samą nazwę pliku, i aby nazwa załącznika nie mogła wyjść poza ten katalog. Nazwa przekazana jako załącznik nie jest traktowana jako zaufana ścieżka: przechodzi przez PLSanitizeAttachmentName, które usuwa dowolny komponent katalogu, odrzuca pusty łańcuch oraz specjalne nazwy . i .., i zastępuje każdy znak, który Windows traktuje jako niedozwolony w nazwie pliku, wraz z dowolnym znakiem kontrolnym, podkreśleniem. Podaj jej ..\quarter:report.pdf, częściowo przejście przez katalogi, częściowo niedozwolony dwukropek, a to, co dociera na dysk, to quarter_report.pdf: wszystko aż do ostatniego separatora ścieżki jest odrzucane, a dwukropek staje się podkreśleniem, ponieważ nie może pojawić się w nazwie pliku 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;

Dedykowany katalog na wywołanie to nie tylko schludność. SendDocumentByMail usuwa plik tymczasowy i usuwa jego katalog w bloku finally po wysłaniu wiadomości, używając dokładnie tej samej ścieżki, do której zapisywał, więc nazwa załącznika, która dotarłaby do tego kodu bez oczyszczenia, nie tylko przeniosłaby zapis w złe miejsce. Ta sama nieoczyszczona ścieżka trafiłaby wtedy do kroku sprzątania, który wywołuje DeleteFile bez zadawania dalszych pytań, a na współdzielonym folderze tymczasowym dwie równoczesne wysyłki mogłyby też po cichu nadpisać wzajemnie swoje załączniki pod tą samą nazwą, zanim którakolwiek dostawa się zakończy. Oczyszczanie nazwy zamyka przypadek przejścia przez katalogi, a katalog GUID na wywołanie zamyka przypadek kolizji, i żaden z nich osobno nie byłby wystarczający

Dopasowanie czasu życia COM do czasu życia wątku w puli roboczej

Najbardziej niezawodną poprawką awarii wątkowania apartamentowego w mailerze wsadowym jest przestanie traktować każde wywołanie SendDocumentByMail jako swój własny izolowany czas życia COM, a zamiast tego zainicjalizować COM raz na wątek roboczy, na czas życia tego wątku. Worker, który wywołuje CoInitializeEx(nil, COINIT_APARTMENTTHREADED) przy starcie, zachowuje ten apartament przez każde wywołanie SendDocumentByMail, jakie wykonuje, i wywołuje CoUninitialize dokładnie raz, gdy się kończy, nigdy nie zobaczy RPC_E_CHANGED_MODE z własnych wysyłek pocztowych, ponieważ nic innego na tym wątku nie dostaje szansy najpierw zainicjalizować COM w konfliktowym trybie. Każde pojedyncze wywołanie SendDocumentByMail wciąż uruchamia własną wewnętrzną parę CoInitializeEx i CoUninitialize pod tym wzorcem, i to jest nieszkodliwe: z apartamentem już ustanowionym przez wątek roboczy, każde z tych wewnętrznych wywołań widzi teraz S_FALSE, zwiększa i zmniejsza ten sam licznik odwołań, i pozostawia własny apartament COM wątku roboczego nietknięty

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;

Diagnozowanie awarii i testowanie bez żywej skrzynki pocztowej

GetLastMailError to druga połowa tego API, warta wbudowania w logowanie od pierwszego dnia, ponieważ sama wartość zwracana 1-lub-0 nie mówi, czy nieudana wysyłka to problem z inicjalizacją COM, odrzucenie uwierzytelnienia SMTP, czy brakujący załącznik. Właściwość MailProvider jest tym, co czyni całą ścieżkę testowalną bez prawdziwej skrzynki pocztowej: przypisz jej implementację IPDFlibMailProvider, która zapisuje żądania zamiast je wysyłać, uruchom zadanie wsadowe wobec tego udawanego dostawcy w potoku CI, a te same miejsca wywołania SendDocumentByMail nadal działają bez zmian, gdy tylko MailProvider zostanie pozostawiona nieustawiona, a PDFlibPas wraca do wbudowanego transportu CDO w produkcji

Zadanie wsadowe, które wysyła wyciągi pocztą, rzadko kończy się na wysyłce: ten sam potok często musi zwalidować i podpisać PDF, zanim wyjdzie, co jest omówione osobno w artykule o warsztacie zgodności i podpisywania, ponieważ preflight i weryfikacja podpisu to inna kwestia niż dostarczenie poczty, nawet gdy oba działają jedno po drugim. Gdy dokumenty wysyłane pocztą są same w sobie wynikiem dużego zadania scalania lub dzielenia, a nie pojedynczego świeżo zbudowanego PDF, przewodnik po bezpośrednim dostępie do dużych PDF-ów omawia ten krok generowania. SendDocumentByMail oraz opisany tutaj model dostawcy poczty są częścią standardowej biblioteki deweloperskiej PDF PDFlibPas dla Delphi i C++Buildera, a strona produktu zawiera pełne odniesienie do API obok wersji próbnej do pobrania