DelphiおよびC++Builder向けlosLabのPDF開発者ライブラリであるPDFlibPasは、生成されたPDFを1つの単純なAPI呼び出し、SendDocumentByMailを通じてメール添付ファイルとして送信する。Windowsでは既定のトランスポートはCDO(Collaboration Data Objects)を使う。これはオペレーティングシステムに組み込まれたCOMメールコンポーネントであり、実際にマルチスレッドのバッチジョブを壊す詳細はSMTPではなくCOMアパートメントの初期化である
このAPIの背後にあるシナリオは地味だが極めて一般的である:あるサービスが月末明細PDFのバッチを顧客ごとに1枚ずつレンダリングし、人手を介さずにそれぞれをメールで送信しなければならない。そのジョブをスループットのためにスレッドプールに乗せると、送信の一部が、同じコードが単一スレッドで実行されるときには決して再現しないCOMエラーで失敗し始める。SMTPサーバー、PDF、添付ファイルのどれにも問題はない。問題は、CDOが予期していなかったスレッド上でCoInitializeExが何を返すかであり、PDFlibPasは偶然にではなく意図的にそのケースを扱うように書かれている
PDFlibPas内部でSendDocumentByMailが実際に何をするか
SendDocumentByMailは薄いオーケストレーターであり、それ自体はメールクライアントではない。TPDFlib.SendDocumentByMailは、現在読み込まれている文書を自身の一時PDFに保存し、SMTPの設定とメッセージテキストをTPDFlibMailRequestレコードにパッケージ化し、そのレコードをIPDFlibMailProviderを実装する何かに渡し、プロバイダが戻ってきたらその一時ファイルを再び削除する。プロバイダインターフェースが実際のメールクライアントであり、PDFlibPasは正確に1つの組み込み実装を出荷している:Windowsでしかコンパイルされない、CDOベースのプロバイダである。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を返すのか、そしてそれは失敗なのか
CoInitializeExからのS_FALSEは失敗ではなく、それを失敗として扱うコードは、実際には何も間違っていなかったスレッドについて失敗を報告してしまう。CoInitializeExは、あるスレッドが初めて成功裏にCOMを初期化したときS_OKを返し、そのスレッドがすでに互換性のある並行性モデルでCOMを初期化していた場合にはS_FALSEを返す。どちらの場合もスレッドごとの同じ参照カウントをインクリメントするため、どちらの結果も、そのスレッドが終了するか無関係な作業に移る前に対応するCoUninitialize呼び出しを必要とする。TPDFlib自体もまさにこのパターンに従う:TPDFlibインスタンスを構築することはすでにCoInitializeを呼んでおり、同一のS_OKかS_FALSEかのチェックを使って、対応するCoUninitializeが必要かどうかを記録する。SendDocumentByMailがそのCDOプロバイダに到達し、そのプロバイダが再びCoInitializeExを呼ぶ頃には、通常のケースではすでにそのスレッド上でCOMが初期化されているため、そのプロバイダはほとんど常にS_OKではなくS_FALSEを観測する。このライブラリでは、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を通じてシングルスレッドアパートメント(STA)のセマンティクスを求めているためである。あるスレッドはそのアパートメントモデルを一度だけ選び、そのスレッドの生涯の残りにわたってそのモデルを変えるものは何もない;異なるフラグでCoInitializeExを再試行してもその不一致は直らず、先にCoUninitializeを呼ぶと、そのスレッド上の他のコードがまだ依存しているかもしれないアパートメントを取り壊してしまう。PDFlibPasはRPC_E_CHANGED_MODEを、報告すべきエラーではなく共存すべき条件として扱う:それはペアになるCoUninitializeをスキップする、なぜならその呼び出しは実際には解放すべき参照を一切取得していなかったからだ。そして送信は既存のアパートメント上で継続する
RPC_E_CHANGED_MODEはほぼ再利用されるスレッドでしか現れない:スレッドプールのワーカー、IISやサービスホストのスレッド、あるいはメールコードがそこに近づく前にADOやWMIのような以前のコードがすでにCOINIT_MULTITHREADEDでCoInitializeExを呼んでいたスレッドである。SendDocumentByMailを呼ぶだけの真新しいスレッドはこの経路にぶつからない。バッチスケジューラによって1日に何千回もリサイクルされ、他の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を呼ぶクリーンアップステップに到達し、共有された一時フォルダでは、2つの並行する送信が、どちらの配信も終わる前に同じ名前で互いの添付ファイルを静かに上書きすることもありえただろう。名前を消毒することがトラバーサルのケースを閉じ、呼び出しごとのGUIDディレクトリが衝突のケースを閉じる。そしてどちらか一方だけでは十分ではなかっただろう
ワーカープールでCOMの寿命をスレッドの寿命に合わせる
バッチメーラーにおけるアパートメントスレッディングの失敗に対する最も信頼できる修正は、すべてのSendDocumentByMail呼び出しをそれ自身の独立したCOMの生涯として扱うことをやめ、代わりにワーカースレッドごとに一度、そのスレッドの生涯にわたってCOMを初期化することである。開始時にCoInitializeEx(nil, COINIT_APARTMENTTHREADED)を呼び、それが行うすべてのSendDocumentByMail呼び出しについてそのアパートメントを保ち、終了時に正確に一度CoUninitializeを呼ぶワーカーは、そのスレッド上で他の何も先に競合するモードでCOMを初期化する機会を得ないため、自身のメール送信からRPC_E_CHANGED_MODEを決して見ることがない。このパターンの下では、各個別のSendDocumentByMail呼び出しは内部でそれでも自身のCoInitializeExとCoUninitializeのペアを実行するが、それは無害である:ワーカースレッドによってすでにアパートメントが確立されているため、それらの内部呼び出しのすべては今やS_FALSEを見て、同じ参照カウントを増減させ、ワーカースレッド自身のCOMアパートメントを手つかずのままにする
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リファレンスが掲載されている