A .NET nagyon sokáig nem rendelkezett de facto IoC megoldással, így ez projektenként eléggé eltért. Mondhatni hitvallás kérdése volt, hogy melyik IoC megoldást használta egy projekt. Ez azonban a .NET Core kiadásával megváltozott. A Microsoft is felismerte, hogy a modern ASP.NET-nek szüksége van egy IoC megoldásra. Így született meg a Microsoft.Extensions.DependencyInjection. Ez nem része a .NET-nek, hanem egy kiegészítése. Alapvetően az ASP.NET-hez tervezték, de természetesen bármilyen .NET alkalmazásban használhatjuk.
ASP.NET projektek esetén ez alapértelmezetten telepített NuGet csomag lesz. Ezt nem csak .NET, hanem .NET Framework esetén is használhatjuk. A konténer két fő komponensből áll: az IServiceCollection és IServiceProvider interfészekből és implementációjukból.
Az IServiceCollection szerepe az IoC felépítésében van. Ennek a metódusaival regisztráljuk be a konténerünkbe a típusokat. Az IServiceProvider interfész pedig a beregisztrált típusok feloldását valósítja meg.
A szétválasztás oka, hogy a regisztrációt tipikusan az alkalmazás indulásakor végezzük el és biztonsági szempontból sem lenne szerencsés például, ha menet közben további típusokat tudnánk hozzáadni, vagy mondjuk implementációkat cserélnénk ki.
Regisztráció
A regisztrációért a korábban említett IServiceCollection interfész felelős. A típus és a hozzájuk tartozó extension metódusokkal háromféle életciklusban regisztrálhatunk típusokat.
A Singleton módon bejegyzett típusok életciklusa meg fog egyezni az alkalmazás életciklusával, a Transient módon regisztrált típusokból minden egyes feloldás során egy új példány fog keletkezni.
A Scoped típusú regisztrációk kifejezetten webes környezethez lettek kitalálva és ASP.NET esetén az ilyen módon regisztrált típusokból minden egyes HTTP kéréshez tartozóan egy példány jön létre. A Scoped regisztráció kifejezetten IDisposable interfészt implementáló típusok esetén hasznos. A szkópon belül ugyanis az interfész első feloldása hoz létre csak példányt, a további feloldásokkor a már létrehozott példány kerül továbbadásra és a szkópból kilépve az IDisposable interfészt implementáló típusokon automatikusan meg fog hívódni a Dispose metódus.
Regisztráló metódusokból léteznek az Add prefixummal ellátottak és a TryAdd prefixáltak. A kettő működésében az a fő eltérés, hogy az Add prefixummal rendelkezők megengedik, hogy többször beregisztráljuk az adott interfészhez tartozó típust, vagy példányt. Tehát ha van két osztályom, amelyek implementálják az ILog interfészt és Add prefixált metódussal regisztrálom őket, akkor mindkettő bekerül a konténerbe, míg ha TryAdd prefixáltat használok, akkor csak az első példány adódik hozzá a kollekcióhoz.
A regisztráció történhet generikus módon, vagy Type információ segítségével is. Ha a típusunk létrehozásához szükséges konstruktor olyan típusokra is függene valamiért, amelyek nem találhatóak meg az IoC konténerben, akkor megadhatunk egy lambda metódust is, amely a típus létrehozásakor fog lefutni, egyfajta Factory metódusként.
Regisztráló metódusok:
IServiceCollection Add/*LifeCycle*/<TService>();
IServiceCollection Add/*LifeCycle*/<TService,TImplementation>();
IServiceCollection Add/*LifeCycle*/<TService>(Func<IServiceProvider,TService> implementationFactory);
IServiceCollection Add/*LifeCycle*/(Type service);
IServiceCollection Add/*LifeCycle*/(Type service, Type implementationType);
IServiceCollection Add/*LifeCycle*/(Type service, Func<IServiceProvider,object> implementationFactory);
void TryAdd/*LifeCycle*/<TService>();
void TryAdd/*LifeCycle*/<TService,TImplementation>();
void TryAdd/*LifeCycle*/<TService>(Func<IServiceProvider,TService> implementationFactory);
void TryAdd/*LifeCycle*/(Type service);
void TryAdd/*LifeCycle*/(Type service, Type implementationType);
void TryAdd/*LifeCycle*/(Type service, Func<IServiceProvider,object> implementationFactory);
A /*LifeCycle*/ helyére Scoped, Transient, Singleton helyettesíthető. Ha regisztrációt szeretnénk eltávolítani, akkor a RemoveAll metódust tudjuk használni:
IServiceCollection RemoveAll<T>();
IServiceCollection RemoveAll<T>(Type serviceType);
ASP.NET alkalmazások esetén az IServiceCollection példánya már adott a főprogramban. Saját programunk esetén a ServiceCollection típus példányosításával kapjuk meg ezt.
Feloldás
Mielőtt típusokat tudnánk feloldani, először meg kell hívnunk a BuildServiceProvider() metódust, ami létrehoz egy IServiceProvider példányt, amivel a típusokat fel tudjuk oldani. A BuildServiceProvider() szerepe a korábban említett regisztráció feloldás-szétválasztás mellett a függőségi fa építése. Ez a Singleton regisztrációk esetén érdekes, mivel ha B típusunk létrehozása függ A-tól, akkor ugye A-t kell először létrehoznunk. Ha a létrehozás a regisztráció pillanatában történne, akkor A-t kellene hamarább regisztrálnunk, hogy a B típus regisztrálásakor már meglegyen a függőség.
Ez igencsak kellemetlen tudna lenni, mivel így csak részben egyszerűsödne az életünk. A korábban bemutatott IoC megoldás a szemléltetés miatt ezt a kellemetlen viselkedést implementálta az egyszerűség kedvéért. A Microsoft.Extensions.DependencyInjection a Singleton regisztrációval rendelkező típusokat azok első feloldásukkor hozza létre.
A kollekció felépítése után az IServiceProvider interfészt felhasználva tudunk típusokat kikérni.
object? GetService (Type serviceType);
T? GetService<T>();
T GetRequiredService<T>();
IEnumerable<T> GetServices<T>();
Feloldáshoz az alábbi metódusokat tudjuk felhasználni:
A GetSetvice<T> metódus null értéket ad vissza, ha T típus regisztrációja nem létezik, a GetRequiredService<T> pedig InvalidOperationException kivételt dob, ha a típus nem feloldható. Ha pedig T típus összes regisztrációja érdekel bennünket, akkor a GetServices segítségével megkapjuk őket egy IEnumerable kollekcióban.
Ha több példányt is regisztráltunk T típusból és nem a GetServices metódussal kérjük ki az összeset, hanem a GetService vagy GetRequiredService segítségével egyet, akkor mindig az utolsónak regisztráltat kapjuk meg.
Ha olyan típust regisztrálunk a konténerbe, ami a konténerben tárolt T típus több implementációjára függ és a típusunk konstruktorában egy IEnumerable<T> paraméteren keresztül hivatkozunk ezekre, akkor ebben az esetben belsőleg egy GetServices hívás fog történni és ugyanúgy megkapjuk az összes példányt.
Szkópok létrehozása
Saját szkópot a ServiceProvider CreateScope() metódusával tudunk létrehozni. Ez egy IServiceScope típusú objektumot ad vissza, ami implementálja az IDisposable interfészt. Ezért a létrehozott szkópot egy using blokkon belül érdemes használni. A szkópon belül fontos, hogy a létrejött IServiceScope objektum ServiceProvider tulajdonságán keresztül kérjünk ki példányokat az IoC konténerből. Ha ezt megkerülve kérünk ki példányokat, akkor azok nem lesznek részei a szkópnak és a szkóp using blokkjából kilépés nem fogja a Dispose() metódusokat végighívni a kikért példányokon.
Az alábbi példa a szkópok használatát mutatja be:
using Microsoft.Extensions.DependencyInjection;
using System;
internal interface ITest : IDisposable
{
}
internal class TestImplementation : ITest
{
public void Dispose()
{
Console.WriteLine("A dispose meg lett hívva");
}
}
internal class Program
{
private static void Main(string[] args)
{
IServiceCollection serviceCollection = new ServiceCollection();
serviceCollection.AddScoped<ITest, TestImplementation>();
IServiceProvider serviceProvider = serviceCollection.BuildServiceProvider();
using (IServiceScope scope = serviceProvider.CreateScope())
{
ITest test1 = scope.ServiceProvider.GetService<ITest>() ?? throw new InvalidOperationException();
ITest test2 = scope.ServiceProvider.GetService<ITest>() ?? throw new InvalidOperationException();
Console.WriteLine("ReferenceEquals test1, test2: {0}", test1 == test2);
}
}
}
A program kimenete:
ReferenceEquals test1, test2: True
A dispose meg lett hívva
Buktatók
Service Locator
Dependency Injection és Inversion során általánosan elmondható, hogy kerülendő a Service Locator minta, ami abból áll, hogy a DI konténer interfészét vagy példányát adjuk tovább függőségnek további osztályoknak a tényleges függőségek helyett:
public class Component
{
public Component(IServiceProvider dependencies)
{
//függőségek feloldása
}
}
Ez leginkább azért kerülendő, mert így nagymértékben rontjuk a tesztelhetőséget, illetve a Dependency Inversion elvét is sérti. Azért vezetünk be DI konténert, hogy a típusoknak ne kelljen tudniuk arról, hogy a függőségeik milyen módon hozhatóak létre. A DI konténerre való függéssel azonban pontosan tudniuk kell, hogy a DI konténeren keresztül érhetőek el.
Captive Dependency
Tételezzük fel, hogy van egy ilyen osztálydefiníciónk, amit regisztrálni szeretnénk a konténerbe:
public class Foo
{
public Foo(Bar bar)
{
}
}
A konténerbe pedig az alábbi hívásokkal regisztráljuk:
services.AddSingleton<Foo>();
services.AddTransient<Bar>();
Ebben az esetben Bar a regisztrációja alapján tranziensnek kellene, hogy legyen, de egy példánya a Foo regisztrációja miatt Singleton lesz, ami okozhat problémát. Az ilyen hibák általában hibás konfigurációból adódnak. Ezek kivédhetőek, ha a BuildServiceProvider() metódus bool paraméteres változatát hívjuk meg, ami InvalidOperationException kivételt fog dobni, ha a függőségi fa építésekor ellentmondást talál a regisztációk életciklusában.
Tranziens IDisposable regisztrációk
A ServiceProvider implementálja az IDisposable interfészt, hogy a szkópolt és singleton regisztrációval rendelkező IDisposable implementációkon el tudja végezni az erőforrások felszabadítását a megfelelő helyen. Amennyiben tranziens módon regisztrálunk egy IDisposable interfészt implementáló típust, akkor nekünk kell gondoskodnunk annak a meghívásáról, mert a konténer automatán ezeket csak akkor hívná meg, ha magán a konténeren hívnánk a Dispose metódust.
Ezért a tranziens regisztrácójú IDisposable típusok esetén a programozó felelőssége a Dispose megfelelő időben történő meghívása. Ennek az elmulasztása memória szivárgás hibákat eredményez, amelyek felderítése nem triviális.