مقاله فنی

ایمیل‌کردن PDFها از طریق CDO در Delphi: تله‌های apartment-threading

PDFlibPas، کتابخانه‌ی توسعه‌دهنده‌ی PDF از losLab برای Delphi و C++Builder، یک PDF تولیدشده را از طریق یک فراخوانی API تخت، SendDocumentByMail، به‌عنوان یک پیوست ایمیل ارسال می‌کند. روی ویندوز حمل‌ونقل پیش‌فرض از CDO (Collaboration Data Objects) استفاده می‌کند، کامپوننت پستی COM که درون سیستم‌عامل ساخته شده، و جزئیاتی که واقعاً jobهای دسته‌ای چندرشته‌ای را می‌شکند، مقداردهی اولیه‌ی apartment در COM است، نه SMTP

سناریوی پشت این API غیرچشمگیر و بسیار رایج است: یک سرویس یک دسته از PDFهای صورت‌حساب پایان-ماه را رندر می‌کند، یکی به‌ازای هر مشتری، و باید هرکدام را بدون یک انسان در حلقه ایمیل کند. آن job را روی یک thread pool برای توان‌گذری فشار دهید، و کسری از ارسال‌ها با یک خطای COM شروع به شکست‌خوردن می‌کنند که هرگز وقتی همان کد روی یک رشته‌ی تکی اجرا می‌شود بازتولید نمی‌شود. هیچ چیزی با سرور SMTP، PDF، یا پیوست اشتباه نیست. مسئله چیزی است که CoInitializeEx روی یک رشته‌ای که CDO انتظارش را نداشت برمی‌گرداند، و PDFlibPas عمداً نوشته شده تا آن حالت را مدیریت کند نه به‌طور تصادفی

SendDocumentByMail واقعاً درون PDFlibPas چه کاری انجام می‌دهد

SendDocumentByMail یک orchestrator نازک است، نه یک کلاینت پستی به‌خودی‌خود. TPDFlib.SendDocumentByMail سند فعلاً بارشده را در PDF موقت خودش ذخیره می‌کند، تنظیمات SMTP و متن پیام را در یک رکورد TPDFlibMailRequest بسته‌بندی می‌کند، آن رکورد را به هر چیزی که IPDFlibMailProvider را پیاده‌سازی می‌کند می‌سپارد، و فایل موقت را دوباره حذف می‌کند به‌محض اینکه provider برمی‌گردد. اینترفیس provider خودِ کلاینت پستی واقعی است، و PDFlibPas دقیقاً یک پیاده‌سازی توکار عرضه می‌کند: یک provider مبتنی‌بر-CDO که فقط روی ویندوز کامپایل می‌شود. SendDocumentByMail را بدون تخصیص ویژگی MailProvider ابتدا فراخوانی کنید، و PDFlibPas به‌طور خودکار به آن پیش‌فرض فرومی‌گردد. مقدار بازگشتی عمداً در سراسر آن باریک می‌ماند: ۱ برای پذیرفته‌شده، ۰ برای هر چیز دیگری، چه آن یک فیلد الزامی گمشده باشد، یک شکست نوشتن فایل موقت، یا provider که پیام را رد می‌کند، با دلیل واقعی فقط از 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 برمی‌گرداند، و آیا آن یک شکست است؟

S_FALSE از CoInitializeEx یک شکست نیست، و کدی که آن را به‌عنوان یکی در نظر می‌گیرد شکست‌هایی را روی رشته‌هایی گزارش می‌دهد که واقعاً چیزی اشتباه نرفته. CoInitializeEx اولین‌باری که یک رشته با موفقیت COM را مقداردهی می‌کند S_OK برمی‌گرداند، و S_FALSE را برمی‌گرداند وقتی آن رشته از پیش COM را با یک مدل همزمانی سازگار مقداردهی کرده بود، در هر دو حالت همان شمارش مرجع به‌ازای هر رشته را افزایش می‌دهد، پس هر دو نتیجه به یک فراخوانی CoUninitialize متناظر پیش از خروج رشته یا حرکت به کار بی‌ربط نیاز دارند. خودِ TPDFlib دقیقاً از این الگو پیروی می‌کند: ساختن یک نمونه‌ی TPDFlib از پیش CoInitialize را فرا می‌خواند و ثبت می‌کند آیا یک CoUninitialize متناظر بدهکار است، با استفاده از همان بررسی یکسان S_OK-یا-S_FALSE. تا زمانی که SendDocumentByMail به provider CDOاش می‌رسد و آن provider دوباره CoInitializeEx را فرا می‌خواند، COM بنابراین از پیش در حالت معمول روی رشته مقداردهی شده، پس provider تقریباً همیشه S_FALSE را به‌جای S_OK مشاهده می‌کند. در نظرگرفتن 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 درخواست معناشناسی apartment تک‌رشته‌ای (STA) می‌کند. یک رشته مدل apartment‌اش را یک‌بار انتخاب می‌کند، و هیچ چیزی نمی‌تواند آن مدل را برای بقیه‌ی عمر رشته تغییر دهد؛ تلاش‌مجدد CoInitializeEx با پرچم‌های متفاوت عدم‌تطابق را رفع نمی‌کند، و فراخوانی CoUninitialize ابتدا یک apartment را که کد دیگری روی آن رشته ممکن است همچنان به آن بستگی داشته باشد از بین می‌برد. PDFlibPas RPC_E_CHANGED_MODE را به‌عنوان یک شرط برای کار‌کردن باهاش در نظر می‌گیرد نه یک خطا برای گزارش‌دادن: از CoUninitialize جفت‌شده رد می‌شود، چون فراخوانی هرگز واقعاً یک ارجاع برای آزادکردن به‌دست نیاورد، و اجازه می‌دهد ارسال روی apartment موجود ادامه یابد

RPC_E_CHANGED_MODE تقریباً منحصراً روی رشته‌های دوباره‌استفاده‌شده نمایان می‌شود: یک worker از thread-pool، یک رشته‌ی IIS یا میزبان-سرویس، یا هر رشته‌ای که کد قبلی مثل ADO یا WMI از پیش CoInitializeEx را با COINIT_MULTITHREADED پیش از اینکه کد پستی به آن نزدیک شود فرا خوانده. یک رشته‌ی کاملاً تازه که هیچ کاری جز فراخوانی SendDocumentByMail انجام نمی‌دهد به این مسیر برنمی‌خورد. یک رشته‌ی worker که هزاران بار در روز توسط یک زمان‌بند دسته‌ای بازیافت می‌شود، و با کار مبتنی‌بر-COM دیگری به اشتراک گذاشته می‌شود، قطعاً برخواهد خورد، و این کار را متناوباً انجام می‌دهد، که دقیقاً همان الگویی است که مردم را ابتدا به‌سراغ سرور SMTP و بعد مدل رشته‌بندی می‌فرستد

نگه‌داشتن یک پیوست پستی خارج از دایرکتوری اشتباه

PDFlibPas هر پیوست خروجی را درون یک دایرکتوری تازه می‌نویسد که نام‌گذاری‌شده پس از یک GUID که در هر فراخوانی SendDocumentByMail تولید می‌کند، به‌طور مشخص پس ارسال‌های همزمان هرگز نتوانند روی همان نام فایل برخورد کنند و پس یک نام پیوست نتواند از آن دایرکتوری بیرون برود. نامی که به‌عنوان پیوست پاس داده شده به‌عنوان یک مسیر مورد اعتماد نیست: از طریق PLSanitizeAttachmentName عبور می‌کند، که هر مؤلفه‌ی دایرکتوری را می‌لخت‌کند، رشته‌ی خالی و نام‌های خاص . و .. را رد می‌کند، و هر نویسه‌ای را که ویندوز غیرقانونی در یک نام فایل در نظر می‌گیرد، همراه با هر نویسه‌ی کنترلی، با یک زیرخط جایگزین می‌کند. ..\quarter:report.pdf را به آن بدهید، بخشی traversal دایرکتوری و بخشی دونقطه‌ی غیرقانونی، و آنچه به دیسک می‌رسد quarter_report.pdf است: هر چیزی تا آخرین جداکننده‌ی مسیر دور ریخته می‌شود، و دونقطه به یک زیرخط تبدیل می‌شود چون نمی‌تواند در یک نام فایل ویندوز ظاهر شود

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 را بدون سؤال بیشتری فرا می‌خواند، و روی یک پوشه‌ی موقت مشترک، دو ارسال همزمان هم می‌توانستند بی‌سروصدا پیوست یکدیگر را زیر همان نام پیش از تمام‌شدن هرکدام تحویل بازنویسی کنند. ضدعفونی‌کردن نام حالت traversal را می‌بندد، و دایرکتوری GUIDای به‌ازای هر فراخوانی حالت برخورد را می‌بندد، و هیچ‌کدام به‌تنهایی کافی نمی‌بود

تطبیق طول عمر COM با طول عمر رشته در یک استخر worker

قابل‌اعتمادترین رفع اشکال برای شکست‌های apartment-threading در یک ایمیل‌فرست دسته‌ای این است که با هر فراخوانی SendDocumentByMail به‌عنوان طول عمر COM ایزوله‌ی خودش رفتار نکنید، و به‌جای آن COM را یک‌بار به‌ازای هر رشته‌ی worker، برای عمر آن رشته مقداردهی کنید. یک workerای که CoInitializeEx(nil, COINIT_APARTMENTTHREADED) را وقتی شروع می‌شود فرا می‌خواند، آن apartment را برای هر فراخوانی SendDocumentByMailای که انجام می‌دهد نگه می‌دارد، و دقیقاً یک‌بار CoUninitialize را وقتی خارج می‌شود فرا می‌خواند، هرگز RPC_E_CHANGED_MODE را از ارسال‌های پستی خودش نمی‌بیند، چون هیچ چیز دیگری روی آن رشته فرصت مقداردهی COM را در یک حالت متضاد اول نمی‌گیرد. هر فراخوانی تکی SendDocumentByMail همچنان جفت CoInitializeEx و CoUninitialize خودش را داخلاً زیر این الگو اجرا می‌کند، و آن بی‌ضرر است: با apartment از پیش برقرارشده توسط رشته‌ی worker، هر یک از آن فراخوانی‌های داخلی حالا S_FALSE می‌بیند، همان شمارش مرجع را افزایش و کاهش می‌دهد، و apartment COM خودِ رشته‌ی worker را دست‌نخورده رها می‌کند

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 است که ارزش دارد از روز اول در لاگ‌گیری ساخته شود، چون مقدار بازگشتی ۱-یا-۰ به‌تنهایی نمی‌گوید آیا یک ارسال شکست‌خورده یک مسئله‌ی مقداردهی COM بود، یک رد احراز‌هویت SMTP، یا یک پیوست گمشده. ویژگی MailProvider چیزی است که کل مسیر را بدون یک صندوق پستی واقعی قابل‌تست می‌کند: یک پیاده‌سازی IPDFlibMailProvider که درخواست‌ها را به‌جای ارسالشان ثبت می‌کند به آن تخصیص دهید، یک job دسته‌ای را در برابر آن provider جعلی در یک خط لوله‌ی CI اجرا کنید، و همان محل‌های فراخوانی SendDocumentByMail بدون تغییر همچنان کار می‌کنند به‌محض اینکه MailProvider تنظیم‌نشده رها شود و PDFlibPas در تولید به حمل‌ونقل توکار CDO فرومی‌گردد

یک job دسته‌ای که صورت‌حساب‌ها را ایمیل می‌کند به‌ندرت در ارسال متوقف می‌شود: همان خط لوله اغلب نیاز دارد PDF را پیش از خروج اعتبارسنجی و امضا کند، که جداگانه در مقاله‌ی کارگاه مطابقت و امضا پوشش داده شده، چون preflight و تأیید امضا یک دغدغه‌ی متفاوت از تحویل پستی هستند حتی وقتی هر دو پشت‌سرهم اجرا می‌شوند. وقتی اسنادی که ایمیل می‌شوند خودشان خروجی یک job ادغام یا تقسیم بزرگ باشند به‌جای یک PDF تازه‌ساخته‌شده‌ی تکی، راهنمای دسترسی-مستقیم PDFهای بزرگ آن گام تولید را پوشش می‌دهد. SendDocumentByMail و مدل provider پستی توصیف‌شده در اینجا بخشی از PDFlibPas استاندارد، کتابخانه‌ی توسعه‌دهنده‌ی PDF برای Delphi و C++Builder هستند، و صفحه‌ی محصول مرجع کامل API را در کنار یک دانلود آزمایشی حمل می‌کند