عقد الإطار - استكشاف أنماط حقن التبعية

نشرت: 2022-03-11

يبدو أن الآراء التقليدية حول انعكاس التحكم (IoC) ترسم خطاً متشدداً بين نهجين مختلفين: محدد موقع الخدمة وأنماط حقن التبعية (DI).

يتضمن كل مشروع أعرفه تقريبًا إطار عمل DI. ينجذب الناس إليهم لأنهم يروجون للاقتران السائب بين العملاء وتبعياتهم (عادةً من خلال حقن المُنشئ) مع رمز معياري ضئيل أو معدوم. على الرغم من أن هذا يعد أمرًا رائعًا للتطوير السريع ، إلا أن بعض الأشخاص يجدون أنه يمكن أن يجعل من الصعب تتبع التعليمات البرمجية وتصحيحها. عادة ما يتحقق "السحر وراء الكواليس" من خلال التفكير ، والذي يمكن أن يجلب مجموعة كاملة من المشاكل الجديدة.

في هذه المقالة ، سوف نستكشف نمطًا بديلًا مناسبًا تمامًا لـ Java 8+ و Kotlin codeebases. يحتفظ بمعظم مزايا إطار عمل DI بينما يكون مباشرًا مثل محدد موقع الخدمة ، دون الحاجة إلى أدوات خارجية.

تحفيز

  • تجنب التبعيات الخارجية
  • تجنب الانعكاس
  • تعزيز حقن المنشئ
  • تقليل سلوك وقت التشغيل

مثال

في المثال التالي ، سنضع نموذجًا لتطبيق التلفزيون ، حيث يمكن استخدام مصادر مختلفة للحصول على المحتوى. نحتاج إلى إنشاء جهاز يمكنه استقبال الإشارات من مصادر مختلفة (على سبيل المثال ، الأرضية ، والكابلات ، والأقمار الصناعية ، وما إلى ذلك). سنبني التسلسل الهرمي للفئات التالية:

التسلسل الهرمي لفئة جهاز تلفزيون يقوم بتنفيذ مصدر إشارة عشوائي

لنبدأ الآن بتطبيق DI التقليدي ، حيث يقوم إطار عمل مثل Spring بتوصيل كل شيء بالنسبة لنا:

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

نلاحظ بعض الأشياء:

  • تعبر فئة التليفزيون عن الاعتماد على TvSource. سوف يرى إطار عمل خارجي هذا ويحقن مثيلًا لتنفيذ ملموس (أرضي أو كبل).
  • يسمح نمط حقن المُنشئ باختبار سهل لأنه يمكنك بسهولة إنشاء مثيلات تلفزيون بتطبيقات بديلة.

لقد بدأنا بداية جيدة ، لكننا ندرك أن إحضار إطار عمل DI لهذا قد يكون نوعًا من المبالغة. أبلغ بعض المطورين عن مشكلات في تصحيح أخطاء مشكلات الإنشاء (عمليات تتبع المكدس الطويلة ، والاعتماديات التي لا يمكن تعقبها). أعرب عميلنا أيضًا عن أن أوقات التصنيع أطول قليلاً من المتوقع ، ويظهر ملف التعريف لدينا تباطؤًا في المكالمات العاكسة.

سيكون البديل هو تطبيق نمط محدد الخدمة. إنه واضح ومباشر ولا يستخدم انعكاسًا وقد يكون كافيًا لقاعدة الشفرة الصغيرة الخاصة بنا. بديل آخر هو ترك الفئات وشأنها وكتابة رمز موقع التبعية حولها.

بعد تقييم العديد من البدائل ، اخترنا تنفيذها كتسلسل هرمي لواجهات الموفر. سيكون لكل تبعية موفر مرتبط سيكون مسؤولاً حصريًا عن تحديد موقع تبعيات فئة وإنشاء مثيل محقون. سنجعل الموفر أيضًا واجهة داخلية لسهولة الاستخدام. سنسميها Mixin Injection لأن كل مزود مختلط مع مزودين آخرين لتحديد تبعياته.

تفاصيل سبب استقراري على هذا الهيكل موضحة بالتفصيل في التفاصيل والأسباب المنطقية ، ولكن هنا النسخة القصيرة:

  • إنه يفصل سلوك موقع التبعية.
  • تمديد الواجهات لا يقع في مشكلة الماس.
  • الواجهات لها تطبيقات افتراضية.
  • التبعيات المفقودة تمنع التجميع (نقاط المكافأة!).

يوضح الرسم البياني التالي كيف تتفاعل التبعيات والمزودون ، والتنفيذ موضح أدناه. نضيف أيضًا طريقة رئيسية لتوضيح كيف يمكننا تكوين تبعياتنا وإنشاء كائن تلفزيون. يمكن أيضًا العثور على نسخة أطول من هذا المثال على GitHub هذا.

التفاعلات بين مقدمي الخدمة والتبعيات

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

بعض الملاحظات حول هذا المثال:

  • يعتمد فصل التليفزيون على TvSource ، لكنه لا يعرف أي تطبيق.
  • يقوم TV.Provider بتوسيع TvSource.Provider لأنه يحتاج إلى طريقة tvSource () لإنشاء TvSource ، ويمكنه استخدامه حتى إذا لم يتم تنفيذه هناك.
  • يمكن استخدام مصادر الأرض والكابلات بالتبادل بواسطة التلفزيون.
  • توفر واجهات Terrestrial.Provider و Cable.Provider تطبيقات TvSource ملموسة.
  • الطريقة الرئيسية لها تنفيذ ملموس MainContext of TV.Provider يتم استخدامه للحصول على مثيل TV.
  • يتطلب البرنامج تنفيذ TvSource.Provider في وقت التجميع لإنشاء مثيل تلفزيون ، لذلك نقوم بتضمين Cable.Provider كمثال.

التفاصيل والمبررات

لقد رأينا النمط أثناء العمل وبعض الأسباب الكامنة وراءه. قد لا تكون مقتنعًا بأنه يجب عليك استخدامه الآن ، وستكون على حق ؛ انها ليست بالضبط رصاصة فضية. أنا شخصياً أعتقد أنه يتفوق على نمط محدد الخدمة في معظم الجوانب. ومع ذلك ، عند مقارنتها بأطر عمل DI ، يتعين على المرء تقييم ما إذا كانت المزايا تفوق النفقات العامة لإضافة رمز معياري.

يقوم الموفرون بتوسيع مقدمي الخدمات الآخرين لتحديد تبعياتهم

عندما يقوم موفر بتوسيع موفر آخر ، يتم ربط التبعيات معًا. يوفر هذا الأساس الأساسي للتحقق الثابت الذي يمنع إنشاء سياقات غير صالحة.

تتمثل إحدى نقاط الألم الرئيسية في نمط محدد موقع الخدمة في أنك تحتاج إلى استدعاء طريقة GetService<T>() عامة من شأنها أن تحل بطريقة ما التبعية الخاصة بك. في وقت الترجمة ، ليس لديك أي ضمانات بأنه سيتم تسجيل التبعية في محدد المواقع ، وقد يفشل برنامجك في وقت التشغيل.

لا يعالج نمط DI هذا أيضًا. عادةً ما يتم حل التبعية من خلال الانعكاس بواسطة أداة خارجية مخفية في الغالب عن المستخدم ، والتي تفشل أيضًا في وقت التشغيل إذا لم يتم استيفاء التبعيات. توفر أدوات مثل CDI الخاص بـ IntelliJ (متوفر فقط في الإصدار المدفوع) مستوى معينًا من التحقق الثابت ، ولكن يبدو أن Dagger فقط مع معالج التعليق التوضيحي الخاص به يعالج هذه المشكلة عن طريق التصميم.

تحافظ الفئات على الحقن النموذجي للمنشئ لنموذج DI

هذا ليس مطلوبًا ولكنه مطلوب بالتأكيد من قبل مجتمع المطورين. من ناحية ، يمكنك فقط إلقاء نظرة على المُنشئ ، ورؤية تبعيات الفصل على الفور. من ناحية أخرى ، فإنه يتيح نوع اختبار الوحدة الذي يلتزم به كثير من الناس ، وذلك من خلال بناء الموضوع قيد الاختبار باستخدام نماذج من تبعياته.

هذا لا يعني أن الأنماط الأخرى غير مدعومة. في الواقع ، قد يجد المرء أن Mixin Injection يبسط إنشاء رسوم بيانية معقدة للتبعية للاختبار لأنك تحتاج فقط إلى تنفيذ فئة سياق توسع مزود الموضوع الخاص بك. يعد MainContext أعلاه مثالًا رائعًا حيث تحتوي جميع الواجهات على تطبيقات افتراضية ، لذلك يمكن أن يكون لها تنفيذ فارغ. لا يتطلب استبدال تبعية سوى تجاوز طريقة الموفر الخاصة بها.

لنلقِ نظرة على الاختبار التالي لفصل التلفزيون. يحتاج إلى إنشاء مثيل لجهاز تلفزيون ، ولكن بدلاً من استدعاء مُنشئ الفئة ، فإنه يستخدم واجهة TV.Provider. لا يحتوي TvSource.Provider على تطبيق افتراضي ، لذلك نحتاج إلى كتابته بأنفسنا.

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

الآن دعنا نضيف تبعية أخرى إلى فئة التلفزيون. تعمل تبعية CathodeRayTube على جعل الصورة تظهر على شاشة التلفزيون. يتم فصله عن تطبيق التلفزيون لأننا قد نرغب في التبديل إلى LCD أو LED في المستقبل.

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

إذا قمت بذلك ، فستلاحظ أن الاختبار الذي كتبناه للتو لا يزال يجمع ويمر كما هو متوقع. أضفنا تبعية جديدة إلى التلفزيون ، لكننا قدمنا ​​أيضًا تطبيقًا افتراضيًا. هذا يعني أنه لا يتعين علينا الاستهزاء به إذا أردنا فقط استخدام التنفيذ الحقيقي ، ويمكن لاختباراتنا إنشاء كائنات معقدة بأي مستوى من الدقة الوهمية نريده.

يكون هذا مفيدًا عندما تريد السخرية من شيء محدد في تسلسل هرمي للفئات المعقدة (على سبيل المثال ، طبقة الوصول إلى قاعدة البيانات فقط). يتيح هذا النمط بسهولة إعداد نوع الاختبارات الاجتماعية التي تُفضل أحيانًا على الاختبارات الانفرادية.

بغض النظر عن تفضيلاتك ، يمكنك أن تثق في أنه يمكنك اللجوء إلى أي شكل من أشكال الاختبارات التي تناسب احتياجاتك بشكل أفضل في كل موقف.

تجنب التبعيات الخارجية

كما ترى ، لا توجد مراجع أو إشارات للمكونات الخارجية. هذا هو المفتاح للعديد من المشاريع التي لديها قيود الحجم أو حتى الأمان. كما أنه يساعد في قابلية التشغيل البيني لأن أطر العمل لا يتعين عليها الالتزام بإطار عمل DI معين. في Java ، كانت هناك جهود مثل JSR-330 Dependency Injection لمعيار Java الذي يخفف من مشكلات التوافق.

تجنب التفكير

لا تعتمد تطبيقات محدد موقع الخدمة عادةً على الانعكاس ، لكن تطبيقات DI تفعل ذلك (مع استثناء ملحوظ لـ Dagger 2). هذا له عيوب رئيسية تتمثل في إبطاء بدء تشغيل التطبيق لأن إطار العمل يحتاج إلى فحص الوحدات النمطية الخاصة بك ، وحل الرسم البياني للتبعية ، وبناء كائناتك بشكل انعكاسي ، وما إلى ذلك.

يتطلب منك Mixin Injection كتابة الكود لإنشاء مثيل لخدماتك ، على غرار خطوة التسجيل في نمط محدد الخدمة. يؤدي هذا العمل الإضافي الصغير إلى إزالة المكالمات العاكسة تمامًا ، مما يجعل شفرتك أسرع ومباشرة.

هناك مشروعان لفتا انتباهي مؤخرًا واستفادا من تجنب التفكير هما Graal's Substrate VM و Kotlin / Native. كلاهما يُترجم إلى كود بايت أصلي ، وهذا يتطلب من المترجم أن يعرف مسبقًا أي مكالمات عاكسة ستقوم بها. في حالة Graal ، يتم تحديده في ملف JSON يصعب كتابته ، ولا يمكن التحقق منه بشكل ثابت ، ولا يمكن إعادة بنائه بسهولة باستخدام أدواتك المفضلة. يعد استخدام Mixin Injection لتجنب الانعكاس في المقام الأول طريقة رائعة للحصول على فوائد التجميع الأصلي.

تقليل سلوك وقت التشغيل

من خلال تنفيذ وتوسيع الواجهات المطلوبة ، يمكنك إنشاء مخطط التبعية قطعة واحدة في كل مرة. يجلس كل موفر بجانب التنفيذ الملموس ، والذي يجلب النظام والمنطق لبرنامجك. سيكون هذا النوع من الطبقات مألوفًا إذا كنت قد استخدمت نمط Mixin أو نمط الكيك من قبل.

في هذه المرحلة ، قد يكون من المفيد التحدث عن فئة MainContext. إنه جذر مخطط التبعية ويعرف الصورة الكبيرة. تتضمن هذه الفئة جميع واجهات الموفر وهي مفتاح لتمكين عمليات التحقق الثابتة. إذا عدنا إلى المثال وأزلنا Cable.Provider من قائمة الأدوات الخاصة به ، فسنرى هذا بوضوح:

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

ما حدث هنا هو أن التطبيق لم يحدد TvSource الملموس لاستخدامه ، واكتشف المترجم الخطأ. مع محدد موقع الخدمة و DI القائم على الانعكاس ، كان من الممكن أن يمر هذا الخطأ دون أن يلاحظه أحد حتى تعطل البرنامج في وقت التشغيل - حتى لو اجتازت جميع اختبارات الوحدة! أعتقد أن هذه الفوائد وغيرها التي أظهرناها تفوق الجانب السلبي لكتابة النموذج المعياري اللازم لجعل النمط يعمل.

اقبض على التبعيات الدائرية

دعنا نعود إلى مثال CathodeRayTube ونضيف تبعية دائرية. لنفترض أننا نريد أن يتم حقنه بمثيل تلفزيوني ، لذلك قمنا بتوسيع TV.

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

لا يسمح المترجم بالوراثة الدورية ولا يمكننا تحديد هذا النوع من العلاقات. تفشل معظم أطر العمل في وقت التشغيل عند حدوث ذلك ، ويميل المطورون إلى العمل على حلها لمجرد تشغيل البرنامج. على الرغم من أن هذا النمط المضاد يمكن العثور عليه في العالم الحقيقي ، إلا أنه عادة ما يكون علامة على التصميم السيئ. عندما تفشل الشفرة في التجميع ، يجب تشجيعنا على البحث عن حلول أفضل قبل فوات الأوان للتغيير.

الحفاظ على البساطة في بناء الكائن

إحدى الحجج المؤيدة لـ SL على DI هي أنه مباشر وأسهل في التصحيح. يتضح من الأمثلة أن إنشاء تبعية سيكون مجرد سلسلة من استدعاءات أسلوب الموفر. يعد تتبع مصدر التبعية أمرًا بسيطًا مثل الانتقال إلى استدعاء الأسلوب ومعرفة أين ينتهي بك الأمر. التصحيح أبسط من كلا الخيارين لأنه يمكنك التنقل بالضبط حيث يتم إنشاء مثيل التبعيات ، مباشرة من الموفر.

عمر الخدمة

ربما لاحظ القارئ اليقظ أن هذا التنفيذ لا يعالج مشكلة عمر الخدمة. ستعمل جميع استدعاءات طرق الموفر على إنشاء كائنات جديدة ، مما يجعل هذا أقرب إلى نطاق Spring's Prototype.

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

خاتمة

سواء كنت معتادًا على أطر عمل حقن التبعية أو كتابة محددات مواقع الخدمة الخاصة بك ، فقد ترغب في استكشاف هذا البديل. ضع في اعتبارك استخدام نمط mixin الذي رأيناه للتو ومعرفة ما إذا كان بإمكانك جعل الكود الخاص بك أكثر أمانًا وأسهل في التفكير.

الموضوعات ذات الصلة: JS Best Practices: إنشاء روبوت للخلاف باستخدام TypeScript وحقن التبعية