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 המלא לצד הורדת ניסיון