מאמר טכני

שליחת PDF במייל דרך CDO ב-Delphi: מכשולי apartment-threading

PDFlibPas, ספריית מפתחי ה-PDF של losLab עבור Delphi ו-C++Builder, שולחת PDF שנוצר כצירוף דוא"ל דרך קריאת API שטוחה אחת, ‏SendDocumentByMail. ב-Windows התחבורה בברירת המחדל משתמשת ב-CDO (‏Collaboration Data Objects), רכיב הדוא"ל מסוג COM המובנה במערכת ההפעלה, והפרט שבפועל שובר עבודות-אצווה רב-תהליכוניות הוא אתחול-apartment של COM, לא SMTP

התרחיש מאחורי ה-API הזה לא-זוהר ונפוץ ביותר: שירות מעבד אצוות דוחות-סוף-חודש, אחד לכל לקוח, וחייב לשלוח כל אחד בדוא"ל בלי אדם בלולאה. דחוף את העבודה הזו למאגר-תהליכונים למען תפוקה, וחלק מהמשלוחים מתחילים להיכשל עם שגיאת COM שאף פעם לא משתחזרת כשאותו קוד רץ על תהליכון בודד. שום דבר לא שגוי בשרת ה-SMTP, ה-PDF, או הצירוף. הבעיה היא מה ש-CoInitializeEx מחזירה על תהליכון ש-CDO לא ציפה לו, ו-PDFlibPas כתובה כדי לטפל במקרה ההוא במכוון ולא בטעות

מה SendDocumentByMail בפועל עושה בתוך PDFlibPas

SendDocumentByMail היא מתאם דק, לא לקוח-דואר בזכות עצמו. ‏TPDFlib.SendDocumentByMail שומרת את המסמך הטעון כרגע לקובץ PDF זמני משלה, אורזת את הגדרות ה-SMTP וטקסט ההודעה לתוך רשומת TPDFlibMailRequest, מוסרת את הרשומה ההיא לכל מה שמיישם IPDFlibMailProvider, ומוחקת את הקובץ הזמני שוב ברגע שהספק חוזר. ממשק הספק הוא לקוח-הדואר בפועל, ו-PDFlibPas שולחת בדיוק מימוש מובנה אחד: ספק מבוסס-CDO שמתקמפל רק ב-Windows. קרא ל-SendDocumentByMail בלי להקצות את מאפיין ה-MailProvider קודם, ו-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, וזה כשל?

S_FALSE מ-CoInitializeEx אינו כשל, וקוד שמתייחס אליו ככזה מדווח כשלים על תהליכונים שבהם שום דבר בפועל לא השתבש. ‏CoInitializeEx מחזירה S_OK בפעם הראשונה שתהליכון מאתחל בהצלחה את COM, והיא מחזירה S_FALSE כאשר לתהליכון ההוא כבר היה COM מאותחל עם מודל concurrency תואם, מגדילה את אותה ספירת-הפניה לתהליכון בכל מקרה, כך ששני התוצאות זקוקות לקריאת CoUninitialize מתאימה לפני שהתהליכון יוצא או עובר לעבודה בלתי-קשורה. ‏TPDFlib עצמה עוקבת אחר התבנית המדויקת הזו: בניית מופע TPDFlib כבר קוראת ל-CoInitialize ורושמת אם CoUninitialize מתאימה חייבת, באמצעות אותה בדיקת S_OK-או-S_FALSE זהה. עד שה-SendDocumentByMail מגיעה לספק ה-CDO שלה והספק ההוא קורא ל-CoInitializeEx שוב, COM לכן כבר מאותחל בתהליכון במקרה הרגיל, כך שהספק כמעט תמיד מבחין ב-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 קודם תחת מודל concurrency שונה מזה שהקריאה הזו מבקשת, בדרך כלל משום שהתהליכון קודם עבר לרב-תהליכוני (MTA) ו-CDO עכשיו מבקשת סמנטיקת דירה-חד-תהליכונית (STA) דרך COINIT_APARTMENTTHREADED. תהליכון בוחר את מודל-הדירה שלו פעם אחת, ושום דבר לא יכול לשנות את המודל ההוא לשארית חיי התהליכון; ניסיון-חוזר של CoInitializeEx עם דגלים שונים לא מתקן את אי-ההתאמה, וקריאה ל-CoUninitialize קודם הייתה מפרקת דירה שקוד אחר בתהליכון ההוא אולי עדיין תלוי בה. PDFlibPas מתייחסת ל-RPC_E_CHANGED_MODE כתנאי לעבוד איתו ולא שגיאה לדווח: היא מדלגת על ה-CoUninitialize המתואם, שכן הקריאה אף פעם בפועל לא רכשה הפניה לשחרר, ונותנת למשלוח להמשיך על הדירה הקיימת

RPC_E_CHANGED_MODE מופיע כמעט אך ורק בתהליכונים שעברו מיחזור: worker של מאגר-תהליכונים, תהליכון IIS או מארח-שירות, או כל תהליכון שבו קוד מוקדם יותר כמו ADO או WMI כבר קרא ל-CoInitializeEx עם COINIT_MULTITHREADED לפני שקוד הדואר הגיע לאזור ההוא בכלל. תהליכון חדש-לגמרי שלא עושה כלום מלבד קריאה ל-SendDocumentByMail לא יפגע בנתיב הזה. תהליכון-worker שממוחזר אלפי פעמים ביום על ידי מתזמן-אצווה, ומשותף עם עבודת-COM אחרת, בהחלט כן יפגע, והוא יעשה זאת לסירוגין, שזו בדיוק התבנית ששולחת אנשים להסתכל על שרת ה-SMTP קודם ומודל-התהליכונים שני

שמירת צירוף דוא"ל מחוץ לספרייה הלא-נכונה

PDFlibPas כותבת כל צירוף יוצא לתוך ספרייה טרייה בעלת-שם על שם GUID שהיא מייצרת בכל קריאת SendDocumentByMail, ספציפית כך שמשלוחים בו-זמניים אף פעם לא יכולים להתנגש על אותו שם קובץ וכך ששם צירוף לא יכול לצאת מהספרייה ההיא. השם שנמסר כצירוף לא נחשב מהימן כנתיב: הוא עובר דרך 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 בלי לשאול עוד שאלות, ובתיקיית temp משותפת, שני משלוחים בו-זמניים גם יכלו לכתוב בשקט מעל הצירוף אחד של השני תחת אותו שם לפני ששני המסירות מסתיימות. טיהור השם סוגר את מקרה המעבר, וספריית ה-GUID לכל-קריאה סוגרת את מקרה ההתנגשות, ואף אחת מהן לבדה לא הייתה מספיקה

התאמת חיי-COM לחיי-תהליכון במאגר-worker

התיקון האמין ביותר לכשלי apartment-threading בדוור-אצווה הוא להפסיק להתייחס לכל קריאת SendDocumentByMail כחיי-COM מבודדים משלה, ובמקום זאת לאתחל COM פעם אחת לכל תהליכון-worker, למשך חיי התהליכון ההוא. ‏worker שקורא ל-CoInitializeEx(nil, COINIT_APARTMENTTHREADED) כשהוא מתחיל, שומר על הדירה ההיא עבור כל קריאת SendDocumentByMail שהוא עושה, וקורא ל-CoUninitialize בדיוק פעם אחת כשהוא יוצא, לעולם לא יראה RPC_E_CHANGED_MODE ממשלוחי הדואר שלו עצמו, משום ששום דבר אחר בתהליכון ההוא לא מקבל את ההזדמנות לאתחל COM במצב מתנגש קודם. כל קריאת SendDocumentByMail בודדת עדיין מריצה את זוג ה-CoInitializeEx וה-CoUninitialize שלה עצמה פנימית תחת התבנית הזו, וזה בלתי-מזיק: עם הדירה כבר מבוססת על ידי תהליכון ה-worker, כל אחת מהקריאות הפנימיות ההן עכשיו רואה S_FALSE, מגדילה ומקטינה את אותה ספירת-הפניה, ומשאירה את דירת ה-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 הזה ששווה לבנות לתוך רישום-יומן מהיום הראשון, משום שערך ההחזרה 1-או-0 לבדו לא אומר אם משלוח שנכשל היה בעיית אתחול-COM, דחיית אימות-SMTP, או צירוף חסר. מאפיין ה-MailProvider הוא מה שהופך את הנתיב כולו לניתן-לבדיקה בלי תיבת-דואר אמיתית: הקצה לו מימוש IPDFlibMailProvider שרושם בקשות במקום לשלוח אותן, הרץ עבודת אצווה מול הספק המזויף ההוא בצינור CI, ואותם אתרי-קריאה SendDocumentByMail ממשיכים לעבוד ללא שינוי ברגע ש-MailProvider נשאר בלתי-מוגדר ו-PDFlibPas נופלת בחזרה לתחבורת ה-CDO המובנית בייצור

עבודת אצווה ששולחת דוחות בדוא"ל לעיתים רחוקות נעצרת בשליחה: אותו צינור לעיתים קרובות זקוק לאמת ולחתום את ה-PDF לפני שהוא יוצא, מכוסה בנפרד במאמר שולחן העבודה לתאימות וחתימה, שכן preflight ואימות-חתימה הם עניין שונה מהעברת דואר אפילו כששניהם רצים גב-אל-גב. כאשר המסמכים שנשלחים בדוא"ל הם עצמם הפלט של עבודת מיזוג או פיצול גדולה במקום PDF יחיד שנבנה טרי, מדריך ה-direct-access ל-PDF גדול מכסה את שלב-היצירה ההוא. ‏SendDocumentByMail ומודל-ספק-הדואר המתואר כאן הם חלק מספריית מפתחי ה-PDF PDFlibPas הסטנדרטית עבור Delphi ו-C++Builder, ודף המוצר נושא את מסמך ה-API המלא לצד הורדת ניסיון