Wstrzykiwanie zależności – Dependency Injection

Wstrzykiwanie zależności – Dependency Injection

Wstęp

Ten artykuł opisuje czym jest dependency injection. A także jak z tego korzystać w .NET i po co. Jeśli wiesz, znasz, stosujesz, to raczej niczego nowego się tutaj nie dowiesz 🙂 On jest kierowany głównie do młodych programistów lub programistów nie znających tych mechanizmów.

O co chodzi we wstrzykiwaniu zależności?

Przede wszystkim musimy zdefiniować sobie zależność. O zależności mówimy wtedy, kiedy jedna klasa zależy od drugiej. Weźmy sobie przykładową klasę Writer, która umie wypisywać komunikaty i klasę Worker, która wykonuje jakąś operację i posługuje się klasą Writer.

class Writer
{
    public void Write(string message)
    {
        Console.WriteLine(message);
    }
}

class Worker
{
    Writer writer = new Writer();

    public void Foo()
    {
        writer.Write("Rozpoczynam pracę...");
    }
}

Jak widzisz, klasa Worker zależy od klasy Writer.

Co więcej, klasa Worker samodzielnie tworzy i używa obiekt klasy Writer. Takie coś nazywamy silnym związaniem (tight coupling). I chociaż tight coupling to pojęcie szersze, to jednak dobrze jest prezentowany przez ten przykład. Dwie klasy są mocno ze sobą związane.

Takie zakodowane na sztywno zależności (silne związania) są złe dla aplikacji i powinieneś ich unikać. Dlaczego?

  • jeśli chciałbyś aby komunikaty były wpisywane do pliku, a nie na konsolę, musiałbyś zmienić klasę Writer lub utworzyć nową i zmienić klasę Worker (sprzeczność z zasadą OpenClose).
  • jeśli klasa Writer miałaby inne zależności, te zależności musiałby także zostać utworzone (lub w jakiś sposób przekazane) przez klasę Worker. Lub utworzone w klasie Writer, co jeszcze bardziej zacieśnia kod. Ponadto daje nam już zbyt dużo odpowiedzialności (możliwa sprzeczność z zasadą Single Responsibility) i zdecydowanie zaciemnia obraz.
  • taka implementacja nie nadaje się do testów jednostkowych

Te wszystkie problemy można rozwiązać stosując wstrzykiwanie zależności…

Siostro! Zastrzyk!

OK, teraz wyobraźmy sobie jak lepiej mogłaby wyglądać klasa Worker:

class Worker
{
    Writer writer;
    public Worker(Writer writer)
    {
        this.writer = writer;
    }

    public void Foo()
    {
        writer.Write("Rozpoczynam pracę...");
    }
}

Spójrz, co się stało. Wstrzyknęliśmy obiekt klasy Writer do Worker za pomocą konstruktora. Obiekt Writer w tym momencie jest już poprawnie stworzony (ma utworzone swoje wszystkie zależności) i można go używać. Klasa Worker nie musi tworzyć już tego obiektu i nie daj Boże innych jego zależności.

Po prostu Worker używa Writer. A skąd go ma? To w zasadzie nie jest istotne. Czy Ciebie interesuje kto zrobił Ci przelew na konto? Czy ważne, że masz te pieniądze? 😉

Jednak cały czas nie rozwiązaliśmy jednego problemu. Silnego związania. Klasa Worker cały czas jest silnie związana z Writer. A gdyby tak posłużyć się interfejsem?

interface IWriter
{
    void Write(string message);
}
class Writer: IWriter
{
    public void Write(string message)
    {
        Console.WriteLine(message);
    }
}

class Worker
{
    IWriter writer;
    public Worker(IWriter writer)
    {
        this.writer = writer;
    }

    public void Foo()
    {
        writer.Write("Rozpoczynam pracę...");
    }
}

Na początku zdefiniowaliśmy sobie interfejs IWriter – z jedną metodą. Potem utworzyliśmy klasę Writer implementującą ten interfejs i na koniec do klasy Worker wstrzyknęliśmy interfejs.

W .NET można wstrzykiwać zależności przez konstruktor (najczęściej używane), właściwość (częściej używane w Blazor, gdzie to jest jedyna możliwość w komponencie będącym widokiem), a nawet przez parametr (stosowane raczej w kontrolerze webowej aplikacji)

To nam rozwiązuje ostatni problem. Dlaczego? Bo możemy sobie teraz rozszerzyć naszą aplikację, pisząc nieco inną implementację klasy Writer:

class FileWriter : IWriter
{
    public void Write(string message)
    {
        File.AppendText(message);
    }
}

Teraz klasa Worker może dostać obiekt Writer lub FileWriter. Nie ma już silnego związania z klasą Writer. Otrzymaliśmy luźne powiązanie (loose coupling). Daje to też możliwość napisania oszukanej klasy (Fake), którą można wykorzystać później w testach automatycznych:

class FakeWriter : IWriter
{
    public void Write(string message)
    {
        //żadnego ciała albo Debug.WriteLine
    }
}

Powyższy przykład pokazuje również wzorzec projektowy „Strategia”. Wzorzec ten jest poniekąd jednym z przykładów wstrzykiwania zależności.

Kontenery IoC

Zostaje jeszcze pytanie, jak tworzyć obiekty jak np. Writer? NAJPROSTSZYM przykładem Dependency Injection jest po prostu:

Worker worker = new Worker(new Writer());

To jest NAJPROSTSZY przykład, najbardziej banalny i całkowicie bezużyteczny w prawdziwym świecie (chociaż czasem nie da się inaczej). Co więcej, powoduje dużo problemów. Załóżmy, że masz taki kod rozsiany po całej aplikacji i nagle konstruktor klasy Writer potrzebuje jeszcze jednego obiektu… Musisz to zmieniać w wielu miejscach. Zupełna strata czasu.

Na szczęście powstało coś takiego jak kontenery DI.

Czym jest kontener DI

To specjalny kontener (pomyśl o tym jak o klasie Dictionary<Type, object> na mocnych sterydach), który konfigurujesz podczas inicjalizowania aplikacji. Np. w metodzie Main. Możesz spotkać się też z określeniem „kontener IoC” – IoC to „Inversion of Control” – wzorzec projektowy, którego jedną z implementacji jest wstrzykiwanie zależności.

Konfigurując taki kontener, rejestrujesz w nim klasy, interfejsy, długości życia, a także sposoby w jakie konkretne obiekty mają zostać tworzone. Kontenery dają też możliwość rejestrowania własnych metod do tworzenia obiektów. Na końcu to właśnie kontener tworzy dla Ciebie w pełni działający obiekt.

W C# mamy do dyspozycji różne kontenery IoC. Najbardziej znane to chyba Autofac i Microsoft.Extensions.DependencyInjection. Autofac był wcześniej, natomiast w .NetCore przyszły mechanizmy z Microsoftu. Jako, że Autofac jest dużo starszy, PRAWDOPODOBNIE ma więcej możliwości, ale Microsoftowy odpowiednik jest wystarczający. Moim zdaniem jest też prostszy w użyciu i dlatego to nim się zajmiemy.

Długość życia serwisu

W związku z tym, że mechanizm DI musi widzieć kiedy tworzyć i zwalniać obiekty, podczas konfiguracji podajemy długość życia. Czyli mówimy kontenerowi jak długo obiekt powinien żyć, czy też kiedy go tworzyć. To może wyglądać strasznie, ale w rzeczywistości jest bardzo proste.

Niezależnie od tego, czy używasz Autofaca, Microsoft Dependency Injection, czy jeszcze innego mechanizmu, długości życia będą analogiczne:

Transient

Obiekt zarejestrowany jako transient będzie tworzony za każdym razem, gdy będzie potrzebny. To znaczy, że każda klasa, do której wstrzykujesz obiekt transient, będzie miała własną niepowtarzalną instancję, np:

class Worker
{
    IWriter writer;
    public Worker(IWriter writer)
    {
        this.writer = writer;
    }
}

class Manager
{
    IWriter writer;
    public Manager(IWriter writer)
    {
        this.writer = writer;
    }
}

Jeśli klasa Writer zostanie zarejestrowana jako transient, to instancje Writera w Worker i Manager zawsze będą różne. Po prostu klasa Writer zostanie utworzona na nowo przy każdym takim wstrzyknięciu.

Można by to przyrównać do tego kodu:

Writer w1 = new Writer();
Worker worker = new Worker(w1);

Writer w2 = new Writer();
Manager manager = new Manager(w2);

Scoped

W przypadku aplikacji desktopowych i mobilnych nie różni się to od singleton niczym (chyba że sam tworzysz scope). W przypadku aplikacji webowych, powstanie tylko jedna instancja takiego obiektu na żądanie http. Tzn.:

class Worker
{
    IWriter writer;
    public Worker(IWriter writer)
    {
        this.writer = writer;
    }
}

class Manager
{
    IWriter writer;
    public Manager(IWriter writer)
    {
        this.writer = writer;
    }
}

Jeśli teraz Worker zostanie zarejestrowany jako scoped i jesteśmy w obrębie jednego żądania HTTP, wtedy w Worker i Manager będziemy mieli tę samą instancję klasy Writer. Po prostu obiekt Writer zostanie utworzony raz i wstrzyknięty do wszystkich innych obiektów w ramach jednego żądania. Obiekt umiera, gdy żądanie się kończy i nie jest już dłużej używany.

Można by to zademonstrować takim kodem:

Response ManageRequest()
{
    Writer scopedWriter = new Writer();

    Worker worker = new Worker(scopedWriter);
    Manager manager = new Manager(scopedWriter);

    //tutaj praca na żądaniu..., a na koniec

    worker = null;
    manager = null;
    scopedWriter = null;
}

Singleton

Zostanie utworzona TYLKO JEDNA instancja takiej klasy w całej aplikacji. I ta jedna instancja będzie przekazywana innym obiektom przez cały okres działania aplikacji. Obiekt umiera wraz z aplikacją.

Można to porównać do takiego kodu:

if (mainWriter == null)
    mainWriter = new Writer();

Worker worker = new Worker(mainWriter);
Manager manager = new Manager(mainWriter);

DI od Microsoftu

Jeśli tworzysz aplikację webową, to masz już to w standardzie. Natomiast aplikacja konsolowa, WPF, czy WinForms wymaga, żebyś zainstalował paczkę NuGet: Microsoft.Extensions.DependencyInjection

Następnie musisz dodać do usings:

using Microsoft.Extensions.DependencyInjection;

Kontenery

Zwróć teraz uwagę na dwie klasy: ServiceCollection i ServiceProvider. W klasie ServiceCollection dodajemy wszystkie nasze serwisy – konfigurujemy kontener DI. Klasa ServiceProvider tworzy i zwraca nam konkretny obiekt, który potrzebujemy w danej chwili.

Weźmy teraz prostą aplikację konsolową:

class MainClass
{
    static void Main()
    {
        Console.Write("Podaj imię: ");
        string name = Console.ReadLine();

        Console.WriteLine($"Cześć {name}!");
        Console.ReadKey();
    }
}

Spróbujmy ją przerobić tak, żeby używała Dependency Injection. Standardowe podejście jest takie:

  • w metodzie main konfigurujemy dependency injection
  • pobieramy obiekt jakiejś głównej klasy (np. w WPF/WinForms byłoby to najpewniej MainForm, czy też MainWindow)
  • wywołujemy metodę z tej klasy

Ponieważ pracujemy na konsoli, musimy stworzyć główną klasę aplikacji. Klasa Main w tym wypadku służy tylko do konfiguracji:

class App
{
    public void Run()
    {

    }
}

W metodzie Main utworzymy instancję klasy App, następnie wywołamy metodę Run, która zrobi dokładnie to samo, co Main po staremu. Moglibyśmy tutaj użyć klasy Console, ale żeby pobawić się dependency injection, zrobimy to inaczej.

Używaj abstrakcji

Zamiast posługiwać się bezpośrednio klasą Console, utworzymy interfejs i zaimplementujemy go:

interface IUserInteraction
{
    void Print(string message);
    string Read();
}

class ConsoleInteraction : IUserInteraction
{
    public void Print(string message)
    {
        Console.Write(message);
    }

    public string Read()
    {
        return Console.ReadLine();
    }
}

Konfiguracja

Teraz w metodzie Main skonfigurujemy nasze DI. Aby to zrobić, najpierw trzeba utworzyć obiekt klasy ServiceCollection (pamiętaj, że w aplikacjach webowych masz już to dostępne):

class MainClass
{
    static void Main()
    {
        var serviceCollection = new ServiceCollection();
        serviceCollection.AddTransient<IUserInteraction, ConsoleInteraction>();
        serviceCollection.AddSingleton<App>();

        using(var serviceProvider = serviceCollection.BuildServiceProvider())
        {
            var app = serviceProvider.GetService<App>();
            app.Run();
        }
    }
}

W linijce 5 tworzymy ServiceCollection, a następnie rejestrujemy dwa serwisy:

  • ConsoleInteraction jako interfejs IUserInteraction – pamiętaj, że w Microsoft DI najpierw podajesz interfejs, a później klasę, która ten interfejs implementuje (jeśli zrobisz na odwrót, aplikacja się nie skompiluje). Teraz mechanizm DI wszędzie tam, gdzie zobaczy interfejs IUserInteraction, utworzy obiekt klasy ConsoleInteraction.
  • Klasę App jako singleton. Jak widzisz – nie jest wymagany interfejs żeby zarejestrować klasę. W przypadku klasy App interfejs nie ma większego sensu, bo to główna klasa aplikacji. Ale często interfejs ma sens (głównie tam, gdzie jest zależnością dla innej klasy), więc pamiętaj o tym.

Jeśli nie wiesz czym jest singleton to po prostu obiekt który jest utworzony raz w całej aplikacji i żyje przez cały cykl jej życia. Możesz się też spotkać z określeniem, że singleton jest antywzorcem, ale tu chodzi o inną sytuację – ręczne klasyczne tworzenie singletona. W naszym przypadku rejestrujemy klasę jako singleton w kontenerze DI i wszystko jest w porządku.

Teraz tak. Dlaczego klasę ConsoleInteraction zarejestrowaliśmy jako transient? W przypadku tej aplikacji nie ma to żadnego znaczenia, bo i tak użyjemy jej tylko w jednym miejscu. Chciałem po prostu pokazać, że tak to się robi. Zazwyczaj klasy logujące będziesz rejestrował jako singletony.

A dlaczego klasa App jest jako singleton? No przypomnij sobie czym jest singleton – jedna instancja klasy, która żyje przez cały czas życia aplikacji. Czyli idealny zakres dla klasy, która reprezentuje całą aplikację.

Pobieranie serwisów

W linijce 9 tworzymy ServiceProvider ze skonfigurowanego ServiceCollection. Od tego momentu ServiceProvider będzie dostarczał nam obiekty, których potrzebujemy. Nie można już niczego zmienić w service collection (oczywiście nikt Ci nie broni, żeby mieć kilka ServiceCollection i Providerów, ale jakoś nie widzę w tym sensu).

Na końcu używamy ServiceProvider, żeby otrzymać obiekt klasy App. Dosłownie –

– Ej Ty! ServiceProvider, dej mnie no w pełni działający obiekt klasy App!

To może nie jest niczym wyglądającym super. Po prostu nie musiałeś tworzyć obiektu przez new, tylko za pomocą ServiceProvidera. Ale pamiętasz IUserInteraction? Teraz dodajmy go do klasy App:

class App
{
    readonly IUserInteraction ui;
    public App(IUserInteraction ui)
    {
        this.ui = ui;
    }
    public void Run()
    {
        ui.Print("Podaj imię: ");
        string name = ui.Read();

        ui.Print($"Cześć {name}!");
        Console.ReadKey();
    }
}

W metodzie Main – już nic więcej nie musisz robić. Otrzymasz znowu w pełni działający obiekt klasy App! Klasa implementująca IUserInteraction zostanie automagicznie utworzona i wstrzyknięta do App.

ServiceProvider i zwalnianie zasobów

Pewnie chodzi Ci po głowie pytanie – co z klasami IDisposable? I czy są one zwalniane?

Zasadniczo tak. Spójrz, jak został utworzony ServiceProvider:

using (var serviceProvider = serviceCollection.BuildServiceProvider())
{
    var app = serviceProvider.GetRequiredService<App>();
    app.Run();
}

Teraz wszystkie obiekty zostaną zwolnione po zwolnieniu ServiceProvidera. Natomiast ServiceProvider może też utworzyć tzw. zakres:

using (var serviceProvider = serviceCollection.BuildServiceProvider())
{
    using (var scope = serviceProvider.CreateScope())
    {
        var app = scope.ServiceProvider.GetRequiredService<App>();
        app.Run();
    }                
}

W aplikacji możemy mieć wiele takich zakresów. Np. w aplikacji webowej taki zakres jest tworzony na całe żądanie HTTP. Obiekty zarejestrowanie jako scope i transient zostaną usunięcie po wyjściu z takiego zakresu, chyba że są zależnościami dla innych obiektów. Jeśli implementują interfejs IDisposable lub IAsyncDisposable, odpowiednie metody Dispose też zostaną automatycznie wywołane. Singletony zostaną zwolnione po zakończeniu życia ServiceProvidera.

Ale uwaga! Jeśli zarejestrowałeś singleton, który jest zależny od innych klas i np. taką zależność zarejestrowałeś jako transient (lub scoped), to zwróć uwagę na to, że te zależności zostaną zwolnione dopiero po śmierci singletona. Przecież singleton na nich polega, dlatego też musi mieć dostęp do nich przez cały czas. W niektórych bibliotekach (np. boost::di dla C++) musisz rejestrować zależności dla singletona jako singleton. W .NET tak nie jest (przynajmniej nie w chwili pisania artykułu), ale miej świadomość długości życia.

Więcej na ten temat w kolejnym artykule, opisujących typowo Microsoft Dependency Injection

Skąd ten ServiceProvider jest taki mądry?

W baaaardzo dużym skrócie można by opisać jego działanie tak:

  1. Jaki chcesz typ? App? OK, sprawdźmy go
  2. Typ App ma konstruktor z parametrami. Jaki jest pierwszy parametr? IUserInteraction
  3. OK, muszę stworzyć teraz obiekt IUserInteraction. Co go implementuje? Z konfiguracji wynika, że ConsoleInteraction.
  4. Jaki konstruktor ma ConsoleInteraction? Domyślny. No to tworzymy ConsoleInteraction
  5. Skoro mam utworzone ConsoleInteraction, mogę teraz utworzyć App

ServiceProvider przeleci przez wszystkie zależności (i zależności tych zależności) i utworzy je (jeśli musi) w odpowiedniej kolejności. Na koniec zwróci Ci w pełni działający obiekt, o który prosiłeś.

Możesz teraz uruchomić aplikację i zobaczyć jak działa. Przedebuguj ją sobie linijka po linijce i sprawdź kiedy wywołują się poszczególne konstruktory.

Wydajność aplikacji

Oczywiście taki mechanizm musi wpływać na wydajność aplikacji. Jednak w dzisiejszych czasach nie ma się tym co przejmować. Raczej nie powinno być to zauważalne. Używaj tego i będzie git 🙂 Pamiętaj, żeby nie optymalizować programu jeśli faktycznie nie musisz. Jeśli jest to problem, pomyśl czy klas, które są tworzone najdłużej nie zarejestrować jako singletony.


To na tyle, jeśli chodzi o wstrzykiwanie zależności. W innym artykule opiszę niedługo co jeszcze bardziej zaawansowanego można osiągnąć tym mechanizmem. Więc koniecznie zapisz się do newslettera, żeby nie pominąć go.

Jeśli masz pytania lub znalazłeś błąd w artykule, daj znać w komentarzu. Jeśli spodobał Ci się, udostępnij go 🙂

Podziel się artykułem na: