التوافق الخلفي لواجهات DLL وCOM ── جدول قرار لتحديد أيّ تغيير يكسر جهة الاستدعاء

· آخر تحديث: · · COM, DLL, .NET, C#, C++, التوافق الخلفي, ترقيم الإصدارات, تقنيات قديمة, إعادة استخدام الأصول القائمة, جدول القرار

«هل يكفي استبدال DLL لهذا الإصلاح؟ أم يجب إعادة بناء (rebuild) جهة الاستدعاء أيضاً؟» ── عندما تصون DLL مشتركاً أو مكوّن COM تستدعيه عدّة تطبيقات، ستجد نفسك تجيب عن هذا السؤال في كلّ إصدار. وإذا أخطأتَ في الإجابة، فقد يتوقّف ملفّ EXE قديم يعمل لدى العميل عن الإقلاع، أو الأسوأ من ذلك: يستمرّ في الإقلاع لكن تتغيّر نتائج الحساب بصمت.

والمزعج هو أنّ هذا القرار غالباً ما يُتَّخذ بحسّ من نوع «يبدو خطيراً نوعاً ما». لكنّ الواقع أنّ تحديد أيّ تغيير يكسر التوافق يمكن الحكم عليه بشكل شبه آلي. فـDLL الأصلي (native) له قواعد للتصدير (export) واتفاقية الاستدعاء، ولـCOM قاعدة صارمة منصوص عليها هي «الواجهة ثابتة (immutable)»1، ولـ.NET قائمة بقواعد تغيير التوافق تستخدمها Microsoft نفسها في تطوير مكتبات .NET2.

تناولنا في مدونتنا أساسيّات COM في مقال «ما هو COM / ActiveX / OCX»، وفلسفة تصميمه في مقال «ما هو COM - لماذا يظلّ تصميم COM في Windows جميلاً حتى اليوم». في هذا المقال، نرتّب لكلّ من DLL وCOM وتجميعات (assemblies) .NET سؤال «أيّ تغيير يكسر جهة الاستدعاء؟» على هيئة جدول قرار، ونصل إلى الإجراء المتّبع عندما يكون الكسر أمراً لا مفرّ منه.

1. الخلاصة أوّلاً

  • للتوافق ثلاث طبقات: التوافق البايناري (يعمل بدون إعادة بناء)، والتوافق المصدري (يعمل بعد إعادة البناء)، والتوافق السلوكي (لا يتغيّر السلوك). فـ«عدم الحاجة لإعادة البناء» لا يعني «آمن»، بل يجب الحكم مع مراعاة التوافق السلوكي أيضاً.3
  • الخطّ الأساسي لـDLL الأصلي هو: «إضافة تصدير (export) آمنة، وتغيير أو حذف تصدير قائم مدمِّر (breaking)». فتوقيع الدالّة، واتفاقية الاستدعاء، وتخطيط البنية (struct layout) هي بحدّ ذاتها العقد البايناري.
  • واجهة COM تصبح ثابتة (immutable) بعد نشرها. وإضافة طريقة (method) أو حذفها أو إعادة ترتيبها بعد النشر مخالفة للمواصفة، ويُضاف أيّ تغيير على هيئة واجهة جديدة بـIID جديد (IFoo → IFoo2).41
  • عملاء VB6/VBA يثبّتون الموضع على vtable عبر الربط المسبق (early binding)، لذا هم جهة الاستدعاء الأكثر عرضة للانكسار عند تغيير تخطيط الواجهة.
  • في .NET، نُشِر «ما الذي يُعدّ تغييراً مدمِّراً في الواجهة العامة» على هيئة قواعد تغيير التوافق من Microsoft، وهي لا تصنّف حذف الطرق أو تغيير التوقيعات فقط على أنّها مدمِّرة، بل تصنّف حتى إضافة virtual أو تغيير اسم معامل (parameter) على أنّها تغيير مدمِّر.2
  • الترقيم الدلالي للإصدارات (Semantic Versioning) هو اتفاقية «ارفع رقم الإصدار الرئيسي عند تغيير مدمِّر»، لكنّه لا يعمل إلا بعد إعلان تعريف واضح لما يُعدّ تغييراً مدمِّراً.5 ويمكن استخدام جدول القرار في هذا المقال كذلك التعريف.
  • عندما يكون كسر التوافق أمراً لا مفرّ منه، اتّبع الترتيب: توفير القديم والجديد معاً ← فترة إهمال (deprecation) ← جرد جهات الاستدعاء ← الإلغاء. والمبدأ هو عدم الاستبدال المفاجئ.

2. الطبقات الثلاث للتوافق ── من يتضرّر، ومتى

ما يُطلَق عليه بكلمة واحدة «التوافق الخلفي» ينقسم في الواقع إلى ثلاث طبقات. وحتى في وثائق .NET الرسمية، تُصنَّف التغييرات المدمِّرة من زاوية التوافق المصدري والتوافق البايناري والتوافق السلوكي.3

الطبقة المعنى ما يحدث عند الانكسار من يتضرّر بشكل رئيسي
التوافق البايناري يعمل مع DLL الجديد دون إعادة بناء جهة الاستدعاء عدم العثور على نقطة الدخول (entry point) عند الإقلاع، أو MissingMethodException وقت التشغيل، أو انهيار (crash) ملفّ EXE قديم يعمل لدى العميل، أو تطبيق شركة أخرى لا يمكن إعادة بنائه
التوافق المصدري يعمل جهة الاستدعاء بعد إعادة البناء خطأ ترجمة (compile) عند البناء التالي فريق آخر داخل الشركة، أو مطوّر يملك الشيفرة المصدرية
التوافق السلوكي لا يتغيّر السلوك كمواصفة تتغيّر النتيجة أو التوقيت أو نوع الاستثناء دون ظهور خطأ المستخدم النهائي (وكلّ من يحقّق في الأعطال)

والمهمّ في هذه الطبقات الثلاث هو أنّ الطبقة الخارجية قد تنكسر حتى لو ظلّت الطبقة الداخلية سليمة. فمثلاً، تعديل يغيّر معنى قيمة الإرجاع لدالّة قائمة يحافظ على التوافق البايناري والتوافق المصدري معاً، لكنّه يكسر التوافق السلوكي فقط. وبما أنّ هذا النوع من التغييرات لا يُصدر خطأ ربط (link error) ولا خطأ ترجمة (compile error)، فهو الصفّ الأسهل إغفالاً في جدول القرار.

وبالمقابل، إذا كانت جميع جهات الاستدعاء تملك الشيفرة المصدرية ويمكن إعادة بنائها معاً في آنٍ واحد (كنظام داخلي في مستودع واحد)، فما ينبغي الحفاظ عليه هو التوافق المصدري والتوافق السلوكي فقط، ويمكن استبعاد التوافق البايناري من المتطلّبات. و«هل توجد بين جهات استدعاء DLL الخاصّ بك ملفّات ثنائية لا يمكن إعادة بنائها؟» هو أوّل تفرّع عند قراءة جدول القرار.

3. جدول قرار التوافق لـDLL الأصلي (C/C++)

يتحدَّد توافق DLL الأصلي بجدول التصدير (export table) واتفاقية الاستدعاء وتخطيط الذاكرة. أمّا كيفيّة البحث عن DLL وتحميله فقد شرحناها في «آلية حلّ أسماء DLL في Windows»، لكن يمكن الحكم على التوافق بعد نجاح التحميل عبر الجدول التالي.

التغيير التوافق البايناري ملاحظات
إضافة دالّة مُصدَّرة (export) لا ينكسر أأمن وسيلة للتوسّع. لكن إن كنتَ تعتمد على الأرقام التسلسلية (ordinals) الضمنية لملفّ .def، فقد يُعاد ترقيم الأرقام التسلسلية القائمة حسب موضع الإضافة؛ فإذا كان هناك عملاء يربطون بالرقم التسلسلي، ثبّت الأرقام التسلسلية القائمة صراحةً وأضِف الجديد في النهاية
حذف أو إعادة تسمية دالّة مُصدَّرة ينكسر يفشل حلّ الاستيراد (import resolution)، ويظهر خطأ عند التحميل أو عبر GetProcAddress
تغيير توقيع دالّة قائمة (إضافة/حذف معامل، تغيير النوع، تغيير نوع قيمة الإرجاع) ينكسر يتعارض تمرير المكدّس (stack) والسجلّات (registers). وينطبق الأمر نفسه على قيمة الإرجاع: فتغييرها من عدد صحيح (RAX) إلى فاصلة عائمة (XMM0) يجعل جهة الاستدعاء تقرأ بيانات عشوائية وفق الـABI القديم. قد لا يظهر خطأ بل يحدث تصرّف غير متوقّع (runaway)
تغيير اتفاقية الاستدعاء (__cdecl__stdcall) ينكسر (32 بت) في x86 تتبادل مسؤولية تنظيف المكدّس، ما يؤدّي إلى تلف المكدّس (stack corruption). أمّا x64 فله اتفاقية استدعاء واحدة تُتجاهَل فيها هذه التحديدات عملياً، لذا هذا الصفّ يخصّ DLL بمعمارية 32 بت فقط
تغيير الرقم التسلسلي للتصدير (ordinal) ينكسر بشرط تستدعي جهة الاستدعاء التي تربط بالرقم التسلسلي دالّةً مختلفة. لا تأثير إن كان الربط بالاسم فقط
إضافة عضو (member) إلى بنية (struct) تحجزها جهة الاستدعاء ينكسر تستمرّ جهة الاستدعاء القديمة في حجز حجم أصغر وتمريره (يمكن تخفيف الأثر بعُرف cbSize الموضَّح لاحقاً)
تغيير حزم (packing) أو محاذاة (alignment) بنية عامة (#pragma pack، /Zp، تغيير سلسلة أدوات البناء) ينكسر يتغيّر الإزاحة (offset) للأعضاء الحاليين والحجم الكلي حتى دون لمس أيّ عضو. ولا يُنقذ cbSize من انزياح المواضع، لذا ثبّت الحزم (packing) صراحةً في الترويسة (header) العامة
تغيير داخلي لبنية يحجزها ويحرّرها DLL نفسه فقط لا ينكسر إذا كان التصميم يُظهر مؤشّراً (handle) فقط للخارج، يمكن تغيير الداخل بحرّية
تغيير معنى قيمة الإرجاع أو رمز الخطأ لا ينكسر (لكن التوافق السلوكي ينكسر) ينجح الربط لكنّ السلوك يتغيّر – النمط الأصعب اكتشافاً
إضافة عضو بيانات (data member) أو دالّة افتراضية (virtual) عند تصدير صنف (class) C++ مباشرةً ينكسر يتغيّر حجم الكائن وتخطيط vtable. إضافة دالّة عضو غير افتراضية فقط لا تغيّر التخطيط ولا تكسر العملاء الحاليين مباشرةً، لكنّ تصدير صنف C++ مباشرةً أصلاً لا يتمتّع بتوافق بين المترجمات (compilers)، وكون هذا القرار يُفرَض في كلّ مرّة دليل على هشاشة الـABI

مبدأ التصميم المستخلَص من هذا الجدول لم يتغيّر منذ زمن طويل: اجعل الحدّ الفاصل مقتصراً على C ABI (دوال extern "C" وبُنى بسيطة)، ونفِّذ التوسّع بإضافة دوال. وينطبق الأمر نفسه عند إنشاء DLL أصلي من C#؛ فسطح التصدير الذي تناولناه في «كيفية استدعاء DLL أصلي من C# Native AOT عبر C/C++» يُدار وفق هذا الجدول نفسه.

3.1 عُرف cbSize ── حكمة Win32 لجعل البنى قابلة للتوسّع

الحلّ الكلاسيكي لمشكلة «إضافة عضو إلى بنية أمر مدمِّر» هو عُرف Win32 المتمثّل في وضع حقل حجم في بداية البنية. تضع جهة الاستدعاء في cbSize حجم البنية الذي كانت تعرفه وقت الترجمة (compile time) وتمرّره، ويرى DLL هذا الحجم فيحدّد «أيّ جيل من البنية تعرفه جهة الاستدعاء هذه».

typedef struct KS_CONFIG {
    DWORD cbSize;      // تضبط جهة الاستدعاء sizeof(KS_CONFIG)
    DWORD dwMode;
    DWORD dwTimeout;
    // يجب دائماً إضافة الأعضاء المستقبلية في النهاية
} KS_CONFIG;

// جانب DLL: تمييز الجيل عبر cbSize، والتصرّف بقيمة افتراضية لجهات الاستدعاء القديمة
if (pConfig->cbSize >= FIELD_OFFSET(KS_CONFIG, dwTimeout) + sizeof(DWORD)) {
    timeout = pConfig->dwTimeout;   // جهة استدعاء جديدة
} else {
    timeout = DEFAULT_TIMEOUT;      // جهة استدعاء قديمة
}

بالفعل، تُدار أجيال بنية NOTIFYICONDATA في Windows API بهذه الطريقة تحديداً، وموثَّق رسمياً أنّ القيمة المضبوطة في cbSize تسمح بالحفاظ على التوافق مع Shell32.dll القديم.6 وإذا أدرجتَ cbSize منذ الإصدار الأوّل في البُنى العامة لـDLL الخاصّ بك، فسينتقل التوسّع لاحقاً من «تغيير مدمِّر» إلى «الجانب الآمن من جدول القرار». لكن يجب أن تكون إضافة الأعضاء دائماً في النهاية، ويظلّ تغيير نوع أو ترتيب الأعضاء الحاليين ممنوعاً. وهناك نقطة أخرى: تزداد مسؤولية DLL في البُنى المستخدَمة للإخراج (output)؛ إذ يجب أن يقتصر الكتابة والتهيئة (initialization) دائماً على نطاق cbSize المستلَم. فكتابة حجم sizeof الجديد دون قيد يتجاوز المخزن المؤقّت (buffer) الصغير الذي حجزته جهة استدعاء قديمة، فيتسبّب DLL نفسه بالكسر الذي كان يفترض بهذا العُرف منعه.

4. القاعدة الصارمة لواجهات COM ── ممنوع التغيير بعد النشر

COM تقنية قدّمت أوضح إجابة على هذه المشكلة. وفق مواصفة COM، تخضع الواجهة للقواعد التالية.

  • تملك الواجهة IID فريداً (معرّف الواجهة).1
  • الواجهة ثابتة (immutable). فبمجرّد إنشائها ونشرها، لا يجوز تغيير أيّ جزء من تعريفها.1
  • إضافة طريقة (method) أو حذفها أو تغيير دلالتها (semantics) لا يعني «إصداراً جديداً من الواجهة القديمة»، بل يعني إنشاء واجهة جديدة بـIID مختلف.4

وسبب هذه الصرامة هو أنّ جوهر واجهة COM هو تخطيط بايناري يتمثّل بـvtable (جدول مؤشّرات الدوال). ويثبّت عملاء C++ وVB6 وقت الترجمة موضعاً من نوع «الفتحة الثالثة هي GetName». وإذا أُدرِجت طريقة بعد النشر، يستدعي العميل القديم طريقة مختلفة دون أيّ خطأ. لهذا السبب بالذات، ألغت COM عملية «التغيير» نفسها من المواصفة، ووفّرت بدلاً منها إجراء التوسّع التالي.

// v1: منشورة بالفعل. لا يجوز تغييرها إطلاقاً بعد الآن
[object, uuid(1111....)]
interface ICalc : IUnknown {
    HRESULT Add([in] long a, [in] long b, [out, retval] long* result);
};

// v2: واجهة جديدة بـIID جديد. ترث ICalc وتوسّعها
[object, uuid(2222....)]
interface ICalc2 : ICalc {
    HRESULT AddChecked([in] long a, [in] long b, [out, retval] long* result);
};

يطبّق صنف التنفيذ (coclass) كلاً من ICalc وICalc2 معاً؛ فيستمرّ العميل القديم في استخدام ICalc كما كان، ويطلب العميل الجديد ICalc2 عبر QueryInterface. وحتى في النظرية الرسمية لترقيم إصدارات RPC/COM، رُتِّب الأمر على النحو التالي: «واجهة جديدة ترث الواجهة القديمة تعادل ترقية إصدار ثانوية (minor)، أمّا تغيير الطرق أو الأنواع القائمة فيتطلّب واجهة جديدة تماماً لا ترث (ما يعادل ترقية إصدار رئيسية/major)».7 وما يجعل هذه الطريقة ناجحة هو أنّ QueryInterface يتيح لجهة الاستدعاء التحقّق بأمان وقت التشغيل من حالة الدعم. وقد تعمّقنا في الجمال التصميمي لهذه الآلية في «ما هو COM».

4.1 توزيع الأدوار بين CLSID وProgID وIID

عند التفكير في ترقيم إصدارات COM، يجب التمييز بين أدوار ثلاثة أنواع من المعرّفات.8

  • IID هو معرّف الواجهة (العقد). وإذا تغيّر العقد، يصبح IID جديداً بالضرورة.
  • CLSID هو معرّف صنف التنفيذ (implementation class). ويمكن استبدال التنفيذ بحرّية مع الإبقاء على نفس CLSID، طالما يُحافَظ على عقد الواجهة المنشورة.
  • ProgID هو اسم بديل قابل للقراءة البشرية (KomuraSoft.Calc.1) يُستخدَم للبحث عن المقابل لـCLSID عبر الـregistry. وهناك عُرف يجمع بين ProgID مرقَّم بإصدار وProgID غير مرتبط بإصدار (KomuraSoft.Calc) يشير دائماً إلى أحدث نسخة، ويُربَط هذا الأخير بأحدث نسخة عبر CurVer.8

بعبارة أخرى، «ترقية إصدار التنفيذ» موضوع يخصّ عالم CLSID وProgID، أمّا «تغيير العقد» فيخصّ عالم IID، ولا يجوز الخلط بينهما. أمّا الخيار المتاح إذا أردتَ تجنّب التسجيل في الـregistry أصلاً، فتناولناه في «ما هو Reg-Free COM».

4.2 لماذا عملاء VB6/VBA معرّضون للانكسار بشكل خاص

عند استخدام مكوّن COM من VB6 أو VBA عبر إعداد المرجع (reference) أي الربط المسبق (early binding)، يُقرَأ ملفّ مكتبة الأنواع (type library) وقت الترجمة (compile time) لحلّ الاستدعاءات. والربط المسبق هو الشكل الموصى به لأنّه يفعّل IntelliSense وفحص الأنواع، ويكون التنفيذ أسرع9، لكن ثمنه هو الارتباط القوي بتخطيط مكتبة الأنواع. فبالطبع إذا تغيّر vtable الواجهة تظهر المشكلة، وحتى مجرّد تغيير تعريف في مكتبة الأنواع دون تغيير vtable يظهر بشكل «انكسر المرجع عند فتح المشروع» أو «ظهور خطأ 430/438 وقت التشغيل».

لهذا السبب، يجب في المكوّنات التي تستدعيها VB6/VBA/ماكرو Excel أن تُطبَّق القاعدة الصارمة بثبات الواجهة بأقصى درجة من الصرامة. ولمكتبة الأنواع أيضاً رقم إصدار (major.minor)، فترفعه عند زيادة العقد وتديره. وقد شرحنا توليد مكتبة الأنواع عند النشر من جانب .NET إلى VBA بشكل مُصنَّف بنوع (typed) في «استدعاء DLL في .NET 8 من VBA بشكل مُصنَّف بنوع ── dscom وTLB». أمّا عملاء الربط المتأخّر (late binding) الذين يستخدمون CreateObject فقط، فهم أكثر مقاومة لتغيير التخطيط لأنّهم يحلّون بالاسم، لكنّهم يتأثّرون بالقدر نفسه بتغيير معنى الطرق (التوافق السلوكي).

5. توافق تجميعات .NET ── الحكم آلياً عبر القواعد الرسمية

في .NET، نُشِرت «قواعد التغيير من أجل التوافق» التي تستخدمها Microsoft نفسها في تطوير مكتبات .NET، وصُنِّفت التغييرات إلى مسموح (✔️) وممنوع (❌) ويحتاج تقديراً (❓).2 وبما أنّ الوثائق تنصّ صراحةً على إمكانية اعتمادها كمعيار حكم لمكتبات الشركة نفسها، ننقل هنا أهمّ الصفوف.

التغيير في الواجهة العامة (public API) الحكم ملاحظات
إضافة طريقة أو نوع أو عضو ✔️ آمن من حيث المبدأ لكن احترس من الإضافات التي تغيّر حلّ التحميل الزائد (overload resolution) القائم. إضافة حقل نسخة (instance field) إلى struct عامة استثناء، لأنّ الحجم والتخطيط يتغيّران فيكسران التشغيل البيني (interop) والمستخدمين عبر unsafe
حذف أو إعادة تسمية نوع أو عضو عام (public) ❌ مدمِّر ينكسر وقت التشغيل بخطأ مثل MissingMethodException
تغيير التوقيع (إضافة/حذف/ترتيب/نوع المعامل، أو نوع قيمة الإرجاع) ❌ مدمِّر يكسر التوافق البايناري والمصدري معاً
تغيير اسم المعامل (parameter) ❌ مدمِّر يكسر الوسائط المسمّاة (named arguments) في C# والربط المتأخّر في VB. سهل الإغفال
إضافة virtual إلى عضو ❌ مدمِّر فخّ نموذجي يبدو «آمناً لأنّه إضافة». قد يحدث تعارض في IL الاستدعاء (call/callvirt)
حذف virtual، أو تحويل عضو افتراضي إلى abstract ❌ مدمِّر تنكسر عمليات التجاوز (override) في الأصناف المشتقّة
إضافة عضو مجرّد (abstract) إلى نوع عام غير sealed ❌ مدمِّر لا يملك الصنف المشتقّ القائم تنفيذاً له
جعل النوع sealed ❌ مدمِّر يصبح الصنف المشتقّ القائم غير قابل للترجمة (compile)
إضافة عضو إلى واجهة (interface) ❓ يحتاج تقديراً يمكن تخفيفه بإرفاق تنفيذ افتراضي (Default Interface Member/DIM)، لكن بشروط تتعلّق باللغة وبيئة التشغيل
تغيير قيمة ثابت (constant) أو عنصر تعداد (enum)، أو إعادة تسمية/حذف عنصر تعداد ❌ مدمِّر تُضمَّن القيمة داخل جهة الاستدعاء وقت الترجمة
تغيير الرمز لرمي استثناء أكثر اشتقاقاً (derived) ✔️ مسموح لأنّ عبارات catch القائمة تظلّ تعمل
رمي نوع جديد من الاستثناء في مسار شيفرة قائم ❌ مدمِّر يجوز رميه فقط عند قيم معامل جديدة

الأمر ليس بسيطاً كقاعدة «ثبات الواجهة» في COM، لكنّ الفلسفة واحدة: الواجهة العامة عقد، والإضافة إلى العقد مسموحة، لكنّ تغيير العقد القائم غير مسموح. وكون تغييرات تبدو «آمنة للوهلة الأولى» مثل الطرق الافتراضية (virtual) أو أسماء المعاملات مصنَّفة على الجانب المدمِّر هو بالضبط سبب وجوب الحكم بالجدول لا بالحدس.

5.1 الاسم القوي وأرقام الإصدار الثلاثة

لتجميعات .NET عدّة أرقام إصدار، ولكلّ منها دور مختلف.10

  • AssemblyVersion: رقم الإصدار الوحيد الذي تستخدمه بيئة التشغيل للتعرّف على التجميعة وتحميلها. في التجميعات ذات الاسم القوي (strong name)، يشترط CLR الخاص بـ.NET Framework تطابقاً دقيقاً، ما يستلزم إعادة توجيه الربط (binding redirect) لدى جهة الاستدعاء في كلّ مرّة يُرفَع فيها (بينما يقبل .NET/.NET Core تلقائياً الإصدارات الأعلى). وتقترح الإرشادات الرسمية عكس رقم الإصدار الرئيسي فقط في AssemblyVersion لتقليل إعادة التوجيهات.
  • FileVersion (AssemblyFileVersion): يظهر فقط في خصائص مستكشف الملفات، ولا يؤثّر في سلوك بيئة التشغيل. ويُوصى به كموضع لتسجيل رقم بناء CI.
  • InformationalVersion: نصّ حرّ موجَّه للبشر، يُسجَّل فيه رقم إصدار الحزمة بصيغة semver أو hash الالتزام (commit) من المصدر.

أي أنّ الشكل العملي السهل الاستخدام هو بنية من ثلاث طبقات: «إعلان التوافق يتمّ عبر إصدار الحزمة/المنتج (semver)، وAssemblyVersion يحمل الرقم الرئيسي فقط، وFileVersion يتتبّع البناء».

6. طريقة ترقيم الإصدارات ── semver لا يعمل إلا بوجود «تعريف»

يمكن تلخيص جوهر الترقيم الدلالي للإصدارات (semver) في ثلاثة أسطر: ارفع MAJOR عند إجراء تغيير غير متوافق، وMINOR عند إضافة ميزة متوافقة خلفياً، وPATCH عند إصلاح خلل متوافق خلفياً.5

وما يُغفَل غالباً هو أنّ أوّل اشتراط في مواصفة semver هو أنّ «البرمجيات التي تستخدم semver يجب أن تعلن واجهتها العامة (public API)».5 فبدون إعلان ما هي الواجهة العامة، لا يوجد معيار للحكم على «التغيير غير المتوافق»، ويصبح قرار رفع الرقم الرئيسي متروكاً لمزاج المسؤول. ومعظم الأماكن التي لا يعمل فيها semver بفعالية لا تُغفل طريقة الترقيم بحدّ ذاتها، بل تُغفل هذا الإعلان.

والتشغيل الواقعي لملفّات DLL الموزَّعة داخلياً يأخذ الشكل التالي.

  1. إعلان نطاق الواجهة العامة ── بالنسبة لـDLL الأصلي: الدوال المُصدَّرة والترويسات (headers) العامة؛ ولـCOM: IDL/مكتبة الأنواع؛ ولـ.NET: الأنواع والأعضاء العامة (public). ودوّن بوضوح أنّ «ما عدا ذلك تنفيذ داخلي قابل للتغيير دون إشعار».
  2. اعتماد تعريف للتغيير المدمِّر ── ضَع في المستودع (repository) جدولَي القرار في الفصلين 3 و5 من هذا المقال، وقواعد التغيير في .NET2، بوصفها «تعريف الشركة الخاص».
  3. أتمتة الحكم ── في .NET يمكن الفحص الآلي للتوافق البايناري مع الإصدار السابق عبر أداتَي Package Validation / ApiCompat.11 وهذا يزيل عبارة «ربّما لا بأس» من المراجعة.
  4. إضافة خانة توافق في ملاحظات الإصدار ── دوّن في كلّ مرّة إحدى القيم الثلاث: «لا حاجة لإعادة البناء / يُنصَح بإعادة البناء / يحتوي تغييراً مدمِّراً». وهذا نظام يقدّم الإجابة عن سؤال «هل يكفي الاستبدال؟» في مستند قبل أن يُطرَح.

7. الإجراء عند عدم إمكانية تجنّب كسر التوافق

عندما يصبح تغيير صُنِّف في جدول القرار على أنّه «مدمِّر» ضرورياً رغم كلّ شيء، يُنفَّذ عبر التوفير المتوازي (parallel provision) لا الاستبدال المباشر.

  1. توفير القديم والجديد معاً ── في COM، أضِف IFoo2 وأبقِ على IFoo (الفصل 4). وفي DLL الأصلي، أضِف دالّة جديدة (FooEx) أو أبقِ DLL جديداً باسم مختلف يتعايش مع القديم. وفي .NET، أصدِر حزمة جديدة برقم إصدار رئيسي أعلى، واستمرّ في الإصدار الرئيسي القديم بإصلاحات الأخطاء فقط.
  2. تحديد فترة إهمال (deprecation) ── في .NET يمكن إصدار تحذير وقت الترجمة عبر السمة (attribute) [Obsolete]. وفي الأصلي/COM، أعلِن ذلك في تعليقات الترويسة وملاحظات الإصدار، مع تحديد تاريخ الإلغاء. والنقطة الجوهرية هي تحديد تاريخ لا القول بأنّه «سيُحذف يوماً ما».
  3. جرد جهات الاستدعاء ── ضَع قائمة بـ«من لا يزال يستدعي الواجهة القديمة» عبر البحث في الشيفرة المصدرية الداخلية، وسجلّات توزيع المثبِّت (installer)، وحالة المرجع في الـregistry في حال COM. وإذا وُجِدت ملفّات ثنائية لا يمكن إعادة بناؤها (أداة موظّف سابق، أو تطبيق شركة أخرى)، مدِّد عمر الواجهة القديمة بقدر ذلك أو ابنِ جسراً عبر مُغلِّف (wrapper).
  4. حذف الواجهة القديمة ── احذفها بعد التأكّد من الجرد أنّ عدد جهات الاستدعاء صفر، وارفع رقم الإصدار الرئيسي.

هذا الإجراء مكلف. ولهذا السبب بالتحديد، فإنّ تصميم واجهة صغيرة مع مراعاة جدول القرار منذ لحظة النشر الأولى (فما لم يُنشَر، لا ينشأ التزام بالتوافق) هو أكبر إجراء وقائي للتوافق.

8. الخلاصة

  • فكِّر بالتوافق على ثلاث طبقات: البايناري، والمصدري، والسلوكي. فقد ينكسر التوافق السلوكي حتى دون الحاجة لإعادة البناء.3
  • بالنسبة لـDLL الأصلي: «الإضافة آمنة، وتغيير التصدير أو التوقيع أو تخطيط البنية القائمة مدمِّر». اجعل البُنى تحمل cbSize لإتاحة مجال للتوسّع.6
  • واجهة COM ثابتة بعد نشرها. أضِف التغيير كواجهة جديدة بـIID جديد (IFoo2)، واجعل التمييز عبر QueryInterface.147 وطبّق ذلك بأقصى صرامة إن وُجِد عملاء VB6/VBA بربط مسبق.
  • يمكن الحكم آلياً على .NET عبر قواعد تغيير التوافق الرسمية. انتبه إلى أنّ «تغييرات تبدو آمنة» مثل إضافة virtual، وتغيير أسماء المعاملات، وجعل النوع sealed، مصنَّفة كتغييرات مدمِّرة.2
  • البنية الواقعية من ثلاث طبقات هي: AssemblyVersion يحمل الرقم الرئيسي فقط، وFileVersion يتتبّع البناء، وsemver يعلن التوافق.10
  • لا يعمل semver إلا بعد إعلان تعريف الواجهة العامة والتغيير المدمِّر.5 اعتمِد جدول القرار كتعريف، وافحص آلياً عبر Package Validation ونحوها.11
  • عند كسر التوافق، اتّبع: التوفير المتوازي ← فترة الإهمال ← الجرد ← الحذف. وعدم الاستبدال المفاجئ هو ما يحمي ملفّات EXE القديمة لدى العميل.

مقالات ذات صلة

مجالات الاستشارة ذات الصلة

تتعامل شركة Komura Soft LLC مع تصميم توافق DLL ومكوّنات COM ومكتبات .NET التي تستدعيها أنظمة أخرى، وجرد الواجهة العامة وتنظيم سياسة ترقيم الإصدارات، وتصميم وتنفيذ التوسّع الذي لا يكسر العملاء الحاليين (أسلوب IFoo2 والتوفير المتوازي).

المراجع

  1. Microsoft Learn، Interface Design Rules. حول وجوب أن تملك الواجهة التي ينفّذها كائن COM معرّف IID فريداً، ووجوب عدم تغيير أيّ جزء من تعريفها (كونها ثابتة) بعد إنشائها ونشرها.  2 3 4 5

  2. Microsoft Learn، Change rules for compatibility (.NET). حول تصنيف تغييرات واجهة .NET إلى مسموح وممنوع ويحتاج تقديراً، وأنّ حذف أو إعادة تسمية نوع أو عضو public، وتغيير التوقيع، وتغيير اسم المعامل، وإضافة/حذف virtual، وجعل النوع sealed، وتغيير قيمة ثابت أو عنصر تعداد كلّها ممنوعة (مدمِّرة)، وأنّ إضافة عضو إلى واجهة (interface) تحتاج تقديراً، وأنّه يمكن لمطوّري المكتبات استخدامها كمعيار تقييم لمكتباتهم.  2 3 4 5

  3. Microsoft Learn، Breaking changes (.NET library guidance). حول تصنيف التغييرات المدمِّرة إلى مدمِّرة للمصدر، ومدمِّرة للسلوك، ومدمِّرة للبايناري، وأنّ التجميعة المُترجَمة مقابل إصدار قديم تفشل وقت التشغيل بخطأ مثل MissingMethodException في حالة الكسر البايناري.  2 3

  4. Microsoft Learn، Interface Pointers and Interfaces. حول أنّ واجهة COM ثابتة، وأنّ إضافة طريقة أو حذفها أو تغيير دلالتها يعني إنشاء واجهة جديدة لا إصداراً جديداً من الواجهة القديمة، وأنّ IID يحدّد العقد بشكل فريد.  2 3

  5. semver.org، Semantic Versioning 2.0.0. حول رفع MAJOR عند تغيير API غير متوافق، وMINOR عند إضافة ميزة متوافقة خلفياً، وPATCH عند إصلاح خلل متوافق خلفياً، ووجوب أن تعلن البرمجيات المستخدِمة لـsemver واجهتها العامة، ووجوب رفع MAJOR دائماً عند أيّ تغيير غير متوافق خلفياً في الواجهة العامة.  2 3 4

  6. Microsoft Learn، NOTIFYICONDATAW structure (shellapi.h). حول ضبط حجم البنية في عضو cbSize، وتوسّع البنية عبر الأجيال، وأنّ ضبط القيمة المناسبة في cbSize يتيح الاستخدام مع الحفاظ على التوافق مع إصدارات أقدم من Shell32.dll.  2

  7. Microsoft Learn، The Versioning Theory for RPC and COM. حول أنّ إنشاء واجهة جديدة هو الأسلوب الأمثل لتوسيع الوظائف في COM، وأنّ الواجهة الجديدة التي ترث الواجهة القديمة تعادل ترقية إصدار ثانوية بينما يتطلّب تغيير الطرق أو الأنواع القائمة واجهة جديدة تماماً لا ترث، وأنّه يمكن التحقّق من حالة الدعم عبر QueryInterface.  2

  8. Microsoft Learn، COM Registry Keys. حول أنّ CLSID هو GUID يحدّد صنف COM، وأنّ ProgID سلسلة قابلة للقراءة البشرية تربط بـCLSID لكن دون ضمان تفرّدها (uniqueness)، وأنّ ProgID غير المرتبط بإصدار يُربَط بأحدث إصدار من الصنف عبر CurVer، وأنّ مفتاح Interface يسجّل IID.  2

  9. Microsoft Learn، OLE programmatic identifiers, late binding, and early binding (Project). حول أنّ الربط المسبق عبر إعداد المرجع موصى به في VBA، وأنّ الربط المتأخّر (CreateObject/ProgID) لا يُظهر الأعضاء أثناء كتابة الشيفرة وأداؤه وقت التشغيل أضعف، وأنّ الربط المسبق يتطلّب إعداد مرجع لمكتبة الكائنات (object library) المستهدَفة. 

  10. Microsoft Learn، Versioning (.NET library guidance). حول أنّ AssemblyVersion يُستخدَم في تحميل بيئة التشغيل، وأنّ .NET Framework يشترط تطابقاً دقيقاً للتجميعات ذات الاسم القوي، وأنّه يُقترَح تضمين الرقم الرئيسي فقط في AssemblyVersion، وأنّ FileVersion للعرض في Windows فقط ولا يؤثّر في سلوك التشغيل، وأنّ InformationalVersion يُستخدَم لتسجيل معلومات إصدار إضافية، وأنّه يُوصى باستخدام semver 2.0.0 لإصدار حزم NuGet.  2

  11. Microsoft Learn، NuGet package compatibility rules. حول وجوب تجنّب التغييرات المدمِّرة للتوافق البايناري، وإمكانية الكشف الآلي عن التوافق مع إصدار أساس (baseline) عبر أداتَي Package Validation وApiCompat، ووجوب عدم خفض AssemblyVersion بين الإصدارات.  2

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

إذا اكتفيتُ بإضافة دالّة إلى DLL، فهل يستغني ذلك عن إعادة بناء جهة الاستدعاء؟
من حيث المبدأ، إذا اكتفيتَ بإضافة دالّة مُصدَّرة (export)، يستمرّ عمل جهة الاستدعاء القائمة دون تغيير. فما دمتَ لم تغيّر اسم الدالّة الحالية أو توقيعها (signature) أو اتفاقية الاستدعاء (calling convention) أو الرقم التسلسلي للتصدير (export ordinal)، يبقى حلّ الاستيراد (import resolution) يعمل كما كان. لكن إذا أضفتَ عضواً (member) إلى بنية (struct) تحجزها جهة الاستدعاء وتمرّرها، أو غيّرتَ معنى قيمة الإرجاع أو رمز الخطأ لدالّة قائمة، فقد يستمرّ التشغيل دون إعادة بناء (rebuild) لكنّ التوافق السلوكيّ (behavioral compatibility) ينكسر. القاعدة الأساسية هي: «إضافة دالّة آمنة، وتغيير توقيع دالّة قائمة مدمِّر (breaking)».
لماذا لا يجوز إضافة طريقة (method) إلى واجهة COM لاحقاً؟
لأنّ القاعدة في مواصفة COM هي أنّ واجهة COM بعد نشرها تصبح غير قابلة للتغيير (immutable). فالواجهة هي عقد بشأن تخطيط بايناري (binary layout) يتمثّل في vtable (مصفوفة من مؤشّرات الدوال)، وإدراج طريقة (method) أو حذفها أو إعادة ترتيبها يجعل الملفّات الثنائيّة (binaries) القديمة تستدعي طريقةً مختلفة عند الموضع الذي ثُبِّت وقت الترجمة (compile time). الإضافة في النهاية لا تغيّر مواضع الفتحات (slots) الحالية، لكنّها تخلق مشكلة أخرى: قد يمسك عميل جديد بمكوّن قديم على أنّه التنفيذ «الذي يفترَض أن يكون قد أُضيف إليه»، فيستدعي فتحة غير موجودة أصلاً؛ لذلك تظلّ الإضافة على نفس IID غير مسموحة. عندما تريد زيادة الوظائف، تُضيف واجهةً جديدة بـIID جديد (IFoo2) وتُبقي على IFoo كما هي. وتستطيع جهة الاستدعاء عبر QueryInterface أن تحدّد بأمان أيّاً من القديم والجديد مدعوم.
كيف يُستخدَم كلّ من AssemblyVersion وFileVersion وInformationalVersion في .NET؟
AssemblyVersion هو رقم الإصدار الوحيد الذي تستخدمه بيئة التشغيل (runtime) للتعرّف على التجميعة (assembly) وتحميلها. وفي التجميعات ذات الاسم القوي (strong name)، يشترط .NET Framework تطابقاً دقيقاً (strict match)، ما يعني أنّ رفع هذا الرقم يستلزم في كلّ مرّة إعادة توجيه الربط (binding redirect). لهذا تقترح الإرشادات الرسمية أن يُعكَس فيه رقم الإصدار الرئيسي (major) فقط. أمّا FileVersion فلا يظهر إلا في خصائص مستكشف الملفات (Explorer) ولا يؤثّر في سلوك بيئة التشغيل، ويناسب تسجيل رقم بناء CI مثلاً. وInformationalVersion هو نصّ حرّ موجَّه للبشر، يُسجَّل فيه رقم إصدار بصيغة semver أو hash الالتزام (commit).
هل يحلّ اعتماد الترقيم الدلالي للإصدارات (semver) مشكلات التوافق؟
لا يُحلّ semver وحده هذه المشكلة. فـsemver اتفاقية تقول «ارفع رقم الإصدار الرئيسي (major) إذا أجريت تغييراً غير متوافق خلفياً»، لكنّه يفترض مسبقاً اشتراط إعلان «ما هي الواجهة العامة (public API)، وما الذي يُعدّ تغييراً مدمِّراً (breaking change)». وبدون هذا التعريف، فإنّ مجرّد وضع رقم إصدار يجعل الحكم يتفاوت من شخص لآخر ولا يعمل فعلياً. بالنسبة لـDLL أصلية (native)، يمكن اعتماد معيار مثل جدول القرار في هذا المقال، وبالنسبة لـ.NET يمكن اعتماد قواعد التغيير الخاصّة بالتوافق من Microsoft، بوصفها «تعريف الشركة الخاص للتغيير المدمِّر»، ودمجها في إجراءات الإصدار (release) -- عندها فقط يصبح semver ذا معنى.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

العودة إلى المدونة