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.