سلسلة net. للمحترفين حقن التبعية Dependency Injection في dot NET


رضوى العربي

يعني حَقْن التَبَعيّات (Dependency Injection) بالأساس عملية كتابة الأنواع بطريقة تَمنَع تلك الأنواع من التَحكُم بتَبعيّاتها، هي فقط تُعلِّن عن اعتمادها على تَبَعيّات مُعينة، وفي المقابل تُوفَّر لها تلك التبعيّات فيما يُعرَف بعملية الحَقْن (Injection).

في المثال التالي، تَقتصِر مسؤولية النوع Greeter على عرض رسالة تحيّة. للقيام بذلك، يَعتمِد النوع على تَبَعيّتان (dependencies)، تَمنَحُه الأولى نص رسالة التحية المطلوب إخراجها، بينما تُوفِّر التَبَعيّة الأُخرى الوسيلة المُستخدَمة لإخراج التحية. تَصِف الواجهتين IGreetingProvider و IGreetingWriter هاتين التَبَعيّتين على الترتيب، وتُحقَن (inject) التَبَعيّتين بالنوع.

public class Greeter
{
    private readonly IGreetingProvider _greetingProvider;
    private readonly IGreetingWriter _greetingWriter;

    public Greeter(IGreetingProvider greetingProvider, IGreetingWriter greetingWriter)
    {
        _greetingProvider = greetingProvider;
        _greetingWriter = greetingWriter;
    }

    public void Greet()
    {
        var greeting = _greetingProvider.GetGreeting();
        _greetingWriter.WriteGreeting(greeting);
    }
}

public interface IGreetingProvider
{
    string GetGreeting();
}

public interface IGreetingWriter
{
    void WriteGreeting(string greeting);
}

على الرغم من اعتماد النوع Greeting على كُلًا من الواجهتين IGreetingProvider و IGreetingWriter، فهو في نفس الوقت غيْر مسؤول عن تَنشئة نُسخ من أيّ منهما، وإنما يُفْترَض أن يَستقبِلهما من خلال باني الكائنات (constructor) خاصته. وبالتالي لابد لأي شيفرة تُنشِئ نسخة من هذا النوع أن تُوفِّر هاتين التَبَعيّتين فيما يُعرَف باسم حَقْن (injecting) التَبعيّات.

في المثال السابق، يُمكن تسميتها أيضًا باسم حَقْن باني الكائنات (constructor injection)؛ نظرًا لأن التَبعيّات تُوفَّر من خلال باني الكائنات.

بعضًا من الأعراف (conventions) الأكثر شيوعًا:

  • يُخزِّن باني الكائنات (constructor) الخاص بنوع ما تَبعيّاته (dependencies) بعد استقبالها في حُقول خاصة (private fields). تُصبِح تلك التَبعيّات مُتوفِّرة لكل التوابع غير الساكنة (non-static) بهذا النوع بُمجرد تنشئة نُسخة منه.
  • تَكون الحُقول الخاصة (private fields) للقراءة فقط، ولا يُمكِن تَغْيير قيمتها بعد ضَبْطِها بواسطة باني الكائنات (constructor)، مما يَعكِس أنه ليس من المَنوط -بل وليس بالإمكان- تَغْيير قيم تلك الحُقول خارج باني الكائنات، كما يَضمَن تَوفُّر التَبَعيّات طوال فترة حياة (lifetime) الكائن.
  • تَكون التَبَعيّات عبارة عن واجهات. ليس هذا ضروريًا وإن كان شائعًا؛ لأنه يُسهِّل من استبدال تَّنْفيذ (implementation) تبعيّة معينة بتَّنْفيذ آخر. بالإضافة إلى أنه يَسمَح باستخدَام نُسخ مزيفة (mocks) من الواجهة لأغراض اختبار الوَحْدَات (unit testing).

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

في المثال بالأعلى، يَعتمِد النوع Greeter على تَبَعيّتان من الواجهتين IGreetingProvider و IGreetingWriter.

قد يُعيد التَّنْفيذ (implementation) الفعلّي للواجهة IGreetingProvider السِلسِلة النصية من خلال اِستدعاء API أو من خلال قاعدة بيانات. بينما قد يَعرِض التَّنْفيذ الفعلّي للواجهة IGreetingWriter النص على الطرفية (console).

لمّا كان النوع Greeter يَستخدِم حَقْن التَبَعيّة (dependency Injection) لتوفير تَبعيّاته، فمن السهل كتابة اختبار وِحْدَة (unit test) يَحقِن نُسخ مزيفة (mocks) من تلك الواجهات، كالتالي:

public class TestGreetingProvider : IGreetingProvider
{
    public const string TestGreeting = "Hello!";
    public string GetGreeting()
    {
        return TestGreeting;
    }
}

public class TestGreetingWriter : List<string>, IGreetingWriter
{
    public void WriteGreeting(string greeting)
    {
        Add(greeting);
    }
}

[TestClass]
public class GreeterTests
{
    [TestMethod]
    public void Greeter_WritesGreeting()
    {
        var greetingProvider = new TestGreetingProvider();
        var greetingWriter = new TestGreetingWriter();
        var greeter = new Greeter(greetingProvider, greetingWriter);
        greeter.Greet();
        Assert.AreEqual(greetingWriter[0], TestGreetingProvider.TestGreeting);
    }
}

ملحوظة: عادةً ما يُستخدَم اطار عمل مثل Moq لتَنشِئة النُسخ المزيفة (mocks)، لكن في المثال بالأعلى، تم كتابة التَّنْفيذات (implementations) المُزيَّفة للتبسيط.

يَتحقق اختبار الوِحْدَة (unit test) بالأعلى مما إذا كان النوع Greeter يَتسلَم نصوص الرسائل ثم يُخرِجها للطباعة بشكل صحيح، مما يعني أن الطريقة التي تَعمَل بها التَبعيّات IGreetingProvider و IGreetingWriter ليست ذات صلة هنا؛ فكل ما يُختبَر هو فقط طريقة تَفاعُل (interact) هذا النوع مع تَبَعيّاته. تَسمَح كتابة النوع بأسلوب حَقْن التَبَعيّة (dependency injection) بحَقْن تَبَعيّات مُزيَّفة دون تعقيد، وهو ما يُسهِل من كتابة اختبار الوَحْدَات (unit testing).

لماذا نحتاج حاوي الخدمات (IoC Containers)؟

كما ذكرنا بالأعلى فحَقْن التَبَعيّات هو أسلوب كتابة للأنواع، وهو بذلك يختلف عن اِستخدَام إطار عمل حَقْن التبعيّات (يُعرَف عادة باسم حَاوِي الخدمات DI container / IoC container) مثل Windsor وAutofac وSimpleInjector وNinject وUnity وغيرها.

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

في المثال التالي، نُنشِئ نُسخة من نوع CustomerService والتي تَمتلك تَبعيّات وتلك التَبعيّات بدورها تَمتلك تَبعيّات أُخرى:

public CustomerData GetCustomerData(string customerNumber)
{
    var customerApiEndpoint = ConfigurationManager.AppSettings["customerApi:customerApiEndpoint"];
    var logFilePath = ConfigurationManager.AppSettings["logwriter:logFilePath"];
    var authConnectionString =
        ConfigurationManager.ConnectionStrings["authorization"].ConnectionString;
    using(var logWriter = new LogWriter(logFilePath ))
    {
        using(var customerApiClient = new CustomerApiClient(customerApiEndpoint))
        {
            var customerService = new CustomerService(
                new SqlAuthorizationRepository(authorizationConnectionString, logWriter),
                new CustomerDataRepository(customerApiClient, logWriter),
                logWriter
            );

            // All this just to create an instance of CustomerService!
            return customerService.GetCustomerData(string customerNumber);
        }
    }
}

رُبما تتساءل لماذا لا نُضمِّن شيفرة البناء بالأعلى بداخل دالة منفصلة، بحيث يَقتصِر دَورِها على إعادة كائن من النوع CustomerService؟

دَعنا نَتفِق أنه ليس من المُفترَض لأي نوع معرفة ما إذا كانت تَبعيّاته تُنفِّذ الواجهة IDisposable أم لا، بل لا يُفترَض لتلك الأنواع التخلص من تلك التَبعيّات في حالة كانت كذلك. وإنما جُل التَعامُل مع تلك التَبعيّات هو اِستخدَامها. ولذلك، إذا كان لدينا دالة GetCustomerService تُعيد كائنًا مُكتمِل البناء من النوع CustomerService، فإننا نَفتَح الباب أمام وجود كائنات يُحتمَل امتلاكها لتَبعيّات تُنفِّذ الواجهة IDisposable دون وجود طريقة للولوج الى تلك التَبعيّات للتخلص منها.

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

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

هنا يأتي دور حَاوِيات حَقْن التَبعيّات حيث تَسمَح تلك الحَاوِيات بتخصيص النوع أو القيمة التي يجب اِستخدَامها لاِستيفاء تَبعيّة معينة. تُعرَف هذه العملية باسم تَسجِيل التبعيّات (registering dependencies) أو ضَبْط الحَاوِي (configuring the container). فمثلًا:

var container = new WindsorContainer();
container.Register(
    Component.For<CustomerService>(),

    Component.For<ILogWriter, LogWriter>()
    .DependsOn(Dependency.OnAppSettingsValue("logFilePath", "logWriter:logFilePath")),

    Component.For<IAuthorizationRepository, SqlAuthorizationRepository>()
    .DependsOn(Dependency.OnValue(
        connectionString, 
        ConfigurationManager
        .ConnectionStrings["authorization"].ConnectionString
    )),

    Component.For<ICustomerDataProvider, CustomerApiClient>()
    .DependsOn(Dependency.OnAppSettingsValue(
        "apiEndpoint", 
        "customerApi:customerApiEndpoint"
    ))
);

جُل ما تَقوم به الشيفرة بالأعلى هو تَبلّيغ الحَاوِي بالآتي:

  • لاستيفاء طلب تَبعيّة من الواجهة ILogWriter، اِنشِئ نُسخة من النوع LogWriter، واستخدِم القيمة الثابتة logWriter:logFilePath الموجودة بملف الإعدادات AppSettings لتوفير السِلسِلة النصية المطلوبة.
  • لاستيفاء طلب تَبعيّة من الواجهة IAuthorizationRepository، اِنشِئ نُسخة من النوع SqlAuthorizationRepository، واستخدِم القيمة المذكورة بالأعلى من قسم ConnectionStrings لتوفير نص سِلسِلة الاتصال بقاعدة البيانات (connection string).
  • لاستيفاء طلب تَبعيّة من الواجهة ICustomerDataProvider، اِنشِئ نُسخة من النوع CustomerApiClient، واستخدِم القيمة الفلانية الموجودة بملف الإعدادات AppSettings لتوفير السِلسِلة النصية المطلوبة.

الآن، عندما نحتاج تَبعيّة معينة، -وهو ما يُعرَف باسم استيفاء (resolving) التبعيّة-، فمن المُمارسات السيئة أن تَستَوفِي التَبَعيّة مُباشرة من الحَاوِي، كالتالي:

var customerService = container.Resolve<CustomerService>();
var data = customerService.GetCustomerData(customerNumber);
container.Release(customerService);

يَعتمد النوع CustomerService على التَبعيّتان IAuthorizationRepository وICustomerDataProvider. ولمّا كان الحَاوِي يَعلم الأنواع التي ينبغي اِنشاؤها لاستيفاء تلك التَبعيّات، بالإضافة إلى مَعرِفته بالأنواع التي ينبغي إنشاؤها لاستيفاء تَبعيّات تلك التَبعيّات، فإنه يُنشِئ جميع الأنواع المطلوبة إلى النقطة التي يُصبِح فيها قادرًا على إعادة نُسخة من النوع الأساسي المَطلوب CustomerService.

إذا وَجَد الحَاوِي نفسه مُضطرًا لاستيفاء تَبعيّة لم يتم تسجيلها مثل IDoesSomethingElse، فإنه سيُبلِّغ عن اعتراض واضح يُعلِمنا بعدم تَوفُّر المعلومات المطلوبة لاستيفاء تلك الواجهة.

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

ماذا عن الأنواع المُنفِّذة للواجهة IDisposable؟ في الواقع، استدعينا container.Release(customerService)‎ لهذا الغرض. تقوم غالبية الحَاوِيات بالتخلُص (Dispose) من نُسخ التَبعيّات التي تحتاج لذلك.

قد يبدو تسجيل التَبعيّات -كما رأينا بالأعلى- عملًا كثيرًا للقيام به. لكنه حقًا سيُؤْتي ثماره خاصة عند العمل على تطبيق يََحتاج لأنواع كثيرة تَعتمِد بدورها على تَبعيّات أُخرى كثيرة، وأنه لو اضطررنا لكتابة نفس تلك الأنواع بدون اِستخدَام حَقْن التبعيّات، فسيُصبِح من الصعب جدًا التَعامُل مع هذا التطبيق واختباره.

بهذا نكون ألقينا نظرة خاطفة على أسباب اِستخدَام حَاوِيات حَقْن التبعيّات. مع ذلك فإن ضَبْط تطبيق معين بحيث يَستخدِم احدى تلك الحَاوِيات ليس مجرد موضوع واحد، وإنما عدة مواضيع؛ حيث ستختلف التَعلِيمات والأمثلة من حَاوِي لآخر، ولكنه إجراء غير مُعقَّد على الإطلاق.

ترجمة -وبتصرف- للفصل Dependency Injection من كتاب ‎.NET Framework Notes for Professionals





تفاعل الأعضاء


لا توجد أيّة تعليقات بعد



يجب أن تكون عضوًا لدينا لتتمكّن من التعليق

انشاء حساب جديد

يستغرق التسجيل بضع ثوان فقط


سجّل حسابًا جديدًا

تسجيل الدخول

تملك حسابا مسجّلا بالفعل؟


سجّل دخولك الآن