Kontener zależności
Kontener zależności czasem ciężko zrozumieć bez podania przykładu. W tym artykule przedstawię naiwną implementację kontenera i wyjaśnię jego zasadę działania. Następnie omówię jak wykorzystać kontener zależności w .NET.
Na początku potrzebujemy klas z zależnościami:
Stworzymy dwie proste klasy gdzie Welcomer ma zależność do IWriter.
public interface IWelcomer
{
void SayHelloTo(string name);
}
public class Welcomer : IWelcomer
{
private IWriter writer;
public Welcomer(IWriter writer)
{
this.writer = writer;
}
public void SayHelloTo(string name)
{
writer.Write($"Hello {name}!");
}
}
public interface IWriter
{
void Write(string s);
}
public class ConsoleWriter : IWriter
{
public void Write(string s)
{
Console.WriteLine(s);
}
}
Normalnie mając takie dwie klasy ich użycie wyglądało by następująco:
var writer = new ConsoleWriter();
var welcomer = new Welcomer(writer);
welcomer.SayHelloTo("test");
W tym przypadku nie wydaje się to problematyczne. Niestety rzeczywistość nie jest taka prosta i nasze drzewo zależności może być naprawdę rozpięte. Inicjalizacja takich obiektów w dużych projektach była by koszmarem. Dodatkowo powodowała by dużo problemów z referencjami i cyklem życia obiektów bo za każdym razem gdzie wykorzystywalibyśmy taką klasę musielibyśmy pilnować jej cyklu życia.
Żeby wyeliminować ten problem z pomocą przychodzą nam kontenery zależności. Rejestrują one poszczególne klasy i zwracają już konkretny obiekt z odpowiednimi (z odpowiednim cyklem życia) zależnościami.
Naiwny kontener:
Poniżej przedstawiam jak taki kontener mógłby wyglądać. Ta implementacja nie nadaje się do wykorzystania produkcyjnego. Jest to tylko demo/makieta, żeby łatwiej zrozumieć mechanizm kontenerów. Przyjrzyjmy się tej implementacji:
public class SimpleDiContainer
{
private readonly Dictionary<Type, Type> types = new Dictionary<Type, Type>();
public void Register<TInterface, TImplementation>() where TImplementation : TInterface
{
types[typeof(TInterface)] = typeof(TImplementation);
}
public TInterface Create<TInterface>()
{
return (TInterface)Create(typeof(TInterface));
}
private object Create(Type type)
{
// szukamy domoślnego konstruktora używając refleksji
var concreteType = types[type];
var defaultConstructor = concreteType.GetConstructors()[0];
// pobieramy parametry domyslnego konstruktora
var defaultParams = defaultConstructor.GetParameters();
// inicjalizujemy wszystkie parametry używając rekurencji
var parameters = defaultParams.Select(param => Create(param.ParameterType)).ToArray();
// wywołujemy konstruktor z odopowiednimi parametrami
return defaultConstructor.Invoke(parameters);
}
}
Przyjrzyjmy się zasadzie działania.
Punktem wejścia jest metoda Register<,>(), która jako pierwszy parametr przyjmuje ona interfejs, a jako drugi typ który implementuje ten interface. Następnie dodaje go do prywatnego słownika. W ten sposób możemy zarejestrować wiele interfejsów i ich implementacji.
Kolejnym etapem jest stworzenie obiektu o podanym kontrakcie (interfejsie), wykorzystujemy do tego metodę Create<>(). Podajemy w niej jakiego interfejsu oczekujemy od zwróconego obiektu i do znalezienia go używamy prywatnej metody create:
Wewnątrz niej szukamy we wcześniej wspomnianym słowniku konkretnego interfejsu. Następnie za pomocą refleksji pobieramy wszystkie konstruktory ale bierzemy tylko pierwszy. Mając już konstruktor – pobieramy jego parametry i z pomocą refleksji inicjalizujemy je używając rekurencji.
Użycie naiwnego kontenera:
// inicjalizujemy konetner
var simpleDI = new SimpleDiContainer();
// rejestrujemy typy
simpleDI.Register<IWelcomer, Welcomer>();
simpleDI.Register<IWriter, ConsoleWriter>();
// tworzmy
var welcomer = simpleDI.Create<IWelcomer>();
// używamy
welcomer.SayHelloTo("adam");
Jak widać na powyższym przypadku nie musimy wiedzieć w jaki sposób inicjalizować konkretne klasy – wystarczy nam wiedza, że są potrzebne do inicjalizacji innych typów. Z pomocą metody create dostajemy obiekt który implementuje konkretny interfejs i jest gotowy do użycia.
Prawdziwy kontener:
Aktualnie najczęściej używanym kontenerem jest ten wbudowany w framework .NET. Zobaczmy jak użyć go w praktyce:
// inicjalizujemy kontener
var container = new ServiceCollection();
// rejestrujemy zależności
container.AddTransient<IWelcomer, Welcomer>();
container.AddTransient<IWriter, ConsoleWriter>();
// budujemy service provider
var serviceProvider = container.BuildServiceProvider();
// użycie
var welcomer = serviceProvider.GetRequiredService<IWelcomer>();
Jak widać użycie jest bardzo podobne do naszej naiwnej implementacji. Z dwiema różnicami:
BuildServiceProvider() – metoda która buduje nasz service provider z którego to bezpośrednio korzystamy, żeby pobrać obiekty implementujące konkretne interfejsy.
AddTransient() – metoda która rejestruje w kontenerze interfejs i typ, który go implementuje.
Cykl życia obiektu:
Mamy 3 rodzaje cykli życia obiektów:
- Transient
- Scoped
- Singleton
Transient:
Obiekty zarejestrowane jako transient są tworzone za każdym razem gdy są zażądane z kontenera zależności. Ten cykl życia najlepiej sprawdzi się kiedy mamy lekkie, bezstanowe obiekty.
Scoped:
Obiekty zarejestrowane jako scoped są tworzone jednokrotnie na dany zakres (scope). Najlepiej sprawdzi się przy obiektach, których inicjalizacja jest kosztowna (np. DbContext). Każde żądanie o obiekt w konkretnym zakresie (scope) zwróci ten sam obiekt. Zobaczmy przykład:
container.AddScoped<IWelcomer, Welcomer>();
container.AddScoped<IWriter, ConsoleWriter>();
// budujemy service provider
var serviceProvider = container.BuildServiceProvider();
// przykład użycia scope
using (var scope = serviceProvider.CreateScope())
{
var welcomer1 = scope.ServiceProvider.GetRequiredService<IWelcomer>();
var h1 = welcomer1.GetHashCode();
var welcomer2 = scope.ServiceProvider.GetRequiredService<IWelcomer>();
var h2 = welcomer2.GetHashCode(); // h1 == h2
}
Singleton:
Obiekty zarejestrowane jako singleton są tworzone jednokrotnie na czas życia aplikacji. Kiedy pierwszy raz zapytamy o obiekt zarejestrowany jako singleton stworzy się pierwszy raz, a każdorazowe zapytanie o ten sam typ zwróci ten sam obiekt. Tego typu rejestracji powinniśmy używać jak najmniej ze względu na stanową naturę singletonów.
Podsumowanie
Jak widać kontenery zależności potrafią bardzo ułatwić programowanie i zarządzanie zależnościami w aplikacji. Mam nadzieję, że przybliżyłem wystarczająco temat kontenerów zależności. Myślę, że z ta wiedza wystarczy, żeby tworzyć proste aplikacje z użyciem kontenerów zależności.