يرسل PDFlibPas، مكتبة losLab لمطوري PDF لـDelphi وC++Builder، ملف PDF مُولَّدًا كمرفق بريد إلكتروني عبر استدعاء واجهة برمجية مسطّح واحد، SendDocumentByMail. على ويندوز يستخدم النقل الافتراضي CDO (كائنات بيانات التعاون)، مكوّن البريد القائم على COM المدمج في نظام التشغيل، والتفصيل الذي يكسر فعليًا المهام الدفعية متعددة الخيوط هو تهيئة شقة COM، لا SMTP
السيناريو خلف هذه الواجهة البرمجية مبتذل وشائع للغاية: تُرقّم خدمة دفعة من ملفات PDF لكشوف نهاية الشهر، واحد لكل عميل، ويتعين عليها إرسال كل واحد بالبريد دون شخص في الحلقة. ادفع تلك المهمة إلى مجمّع خيوط للإنتاجية، ويبدأ جزء من الإرسالات في الفشل بخطأ COM لا يتكرر أبدًا عندما تعمل الشيفرة نفسها على خيط واحد. لا شيء خاطئ في خادم SMTP، أو ملف PDF، أو المرفق. المشكلة هي ما يعيده CoInitializeEx على خيط لم يتوقعه CDO، وPDFlibPas مكتوب لمعالجة تلك الحالة عمدًا لا بالمصادفة
ما الذي تفعله SendDocumentByMail فعليًا داخل PDFlibPas
SendDocumentByMail منسّق رقيق، لا عميل بريد بحد ذاته. تحفظ TPDFlib.SendDocumentByMail المستند المحمَّل حاليًا إلى ملف PDF مؤقت خاص بها، وتحزم إعدادات SMTP ونص الرسالة في سجل TPDFlibMailRequest، وتسلّم ذلك السجل إلى أيًّا كان ما ينفّذ IPDFlibMailProvider، وتحذف الملف المؤقت مرة أخرى بمجرد عودة المزوّد. واجهة المزوّد هي عميل البريد الفعلي، ويشحن PDFlibPas تنفيذًا مدمجًا واحدًا بالضبط: مزوّد قائم على CDO لا يُترجَم إلا على ويندوز. استدعِ 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 بالفعل بنموذج تزامن متوافق، مُزيدة عداد المرجع نفسه لكل خيط في كلتا الحالتين، بحيث تحتاج كلتا النتيجتين استدعاء 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 سابقًا تحت نموذج تزامن مختلف عن ذلك الذي يطلبه هذا الاستدعاء، عادة لأن الخيط أصبح سابقًا متعدد الخيوط (MTA) ويطلب CDO الآن سمانتيك شقة أحادية الخيط (STA) عبر COINIT_APARTMENTTHREADED. يختار خيط نموذج شقته مرة واحدة، ولا شيء يمكنه تغيير ذلك النموذج لبقية عمر الخيط؛ إعادة المحاولة لـCoInitializeEx بأعلام مختلفة لا يصلح عدم التطابق، واستدعاء CoUninitialize أولًا كان سيهدم شقة قد تعتمد عليها شيفرة أخرى على ذلك الخيط لا تزال. يعامل PDFlibPas RPC_E_CHANGED_MODE كحالة يجب العمل معها لا خطأ يُبلَّغ عنه: يتخطى استدعاء CoUninitialize المقترن، بما أن الاستدعاء لم يكتسب فعليًا مرجعًا ليحرره، ويترك الإرسال يستمر على الشقة الموجودة
تظهر RPC_E_CHANGED_MODE شبه حصريًا على خيوط مُعاد استخدامها: عامل مجمّع خيوط، أو خيط IIS أو مضيف خدمة، أو أي خيط استدعت فيه شيفرة سابقة مثل ADO أو WMI بالفعل CoInitializeEx بـCOINIT_MULTITHREADED قبل أن تصل شيفرة البريد إليه على الإطلاق. لن يصطدم خيط جديد تمامًا لا يفعل شيئًا سوى استدعاء SendDocumentByMail بهذا المسار. أما خيط عامل يُعاد تدويره آلاف المرات يوميًا بواسطة جدولة دفعية، ومشترك مع عمل آخر قائم على COM، فسيصطدم به بالتأكيد، وسيفعل ذلك بشكل متقطع، وهذا بالضبط النمط الذي يرسل الناس للنظر إلى خادم SMTP أولًا ونموذج الخيوط ثانيًا
إبقاء مرفق بريد خارج المجلد الخاطئ
يكتب PDFlibPas كل مرفق صادر إلى دليل جديد مسمّى بحسب GUID يولّده في كل استدعاء لـSendDocumentByMail، تحديدًا بحيث لا يمكن للإرسالات المتزامنة أبدًا التصادم على اسم الملف نفسه ولا يمكن لاسم مرفق الخروج من ذلك الدليل. الاسم المُمرَّر كمرفق لا يُثَق به كمسار: يمر عبر PLSanitizeAttachmentName، التي تزيل أي مكوّن دليل، وترفض السلسلة الفارغة والاسمين الخاصين . و..، وتستبدل كل حرف يعامله ويندوز كغير قانوني في اسم ملف، جنبًا إلى جنب مع أي حرف تحكم، بشرطة سفلية. غذّها بـ..\quarter:report.pdf، جزء اجتياز دليل وجزء نقطتين رأسيتين غير قانونيتين، وما يصل إلى القرص هو 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 دون طرح مزيد من الأسئلة، وعلى مجلد مؤقت مشترك، كان يمكن أيضًا لإرسالين متزامنين أن يستبدلا بصمت مرفق أحدهما الآخر تحت الاسم نفسه قبل انتهاء أي من التسليمين. تعقيم الاسم يغلق حالة الاجتياز، ودليل GUID لكل استدعاء يغلق حالة التصادم، ولم يكن أي منهما وحده كافيًا
مطابقة عمر COM لعمر الخيط في مجمّع عامل
الإصلاح الأكثر موثوقية لإخفاقات خيوط الشقق في مُرسِل بريد دفعي هو التوقف عن معاملة كل استدعاء SendDocumentByMail كعمر COM معزول خاص به، وبدلًا من ذلك تهيئة COM مرة واحدة لكل خيط عامل، طوال عمر ذلك الخيط. عامل يستدعي CoInitializeEx(nil, COINIT_APARTMENTTHREADED) عند بدئه، ويحتفظ بتلك الشقة لكل استدعاء SendDocumentByMail يجريه، ويستدعي CoUninitialize مرة واحدة بالضبط عند خروجه لن يرى أبدًا RPC_E_CHANGED_MODE من إرسالات بريده الخاصة، لأن لا شيء آخر على ذلك الخيط يحصل على فرصة تهيئة COM في نمط متعارض أولًا. لا يزال كل استدعاء فردي لـ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 هي النصف الآخر من هذه الواجهة البرمجية التي تستحق بناءها في التسجيل منذ اليوم الأول، لأن قيمة الإرجاع 1-أو-0 وحدها لا تقول ما إذا كان إرسال فاشل مشكلة تهيئة COM، أو رفض مصادقة SMTP، أو مرفقًا مفقودًا. خاصية MailProvider هي ما يجعل المسار بأكمله قابلًا للاختبار دون صندوق بريد حقيقي: عيّن لها تنفيذ IPDFlibMailProvider يسجّل الطلبات بدلًا من إرسالها، وشغّل مهمة دفعية مقابل ذلك المزوّد المزيَّف في خط أنابيب تكامل مستمر، وتستمر مواقع استدعاء SendDocumentByMail نفسها في العمل دون تغيير بمجرد ترك MailProvider غير مضبوطة ويعود PDFlibPas إلى نقل CDO المدمج في الإنتاج
نادرًا ما تتوقف مهمة دفعية ترسل كشوفًا بالبريد عند الإرسال: كثيرًا ما يحتاج خط الأنابيب نفسه التحقق من ملف PDF وتوقيعه قبل إرساله، وهو مغطى بشكل منفصل في مقالة منصة التوافق والتوقيع، بما أن الفحص المسبق والتحقق من التوقيع اهتمام مختلف عن تسليم البريد حتى عندما يعمل كلاهما تباعًا. عندما تكون المستندات المُرسَلة بالبريد نفسها مخرجات مهمة دمج أو تقسيم كبيرة بدلًا من ملف PDF واحد مبني حديثًا، تغطي دليل الوصول المباشر لملفات PDF الكبيرة خطوة التوليد تلك. SendDocumentByMail ونموذج مزوّد البريد الموصوف هنا جزء من مكتبة PDFlibPas القياسية لمطوري PDF لـDelphi وC++Builder، وتحمل صفحة المنتج مرجع الواجهة البرمجية الكامل إلى جانب تنزيل تجريبي