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 klasieWriter
, 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 interfejsIUserInteraction
– 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 interfejsIUserInteraction
, utworzy obiekt klasyConsoleInteraction
.- Klasę
App
jako singleton. Jak widzisz – nie jest wymagany interfejs żeby zarejestrować klasę. W przypadku klasyApp
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:
- Jaki chcesz typ?
App
? OK, sprawdźmy go - Typ
App
ma konstruktor z parametrami. Jaki jest pierwszy parametr?IUserInteraction
- OK, muszę stworzyć teraz obiekt
IUserInteraction
. Co go implementuje? Z konfiguracji wynika, żeConsoleInteraction
. - Jaki konstruktor ma
ConsoleInteraction
? Domyślny. No to tworzymyConsoleInteraction
- 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 🙂