Injectarea de dependente
Pentru aplicatii moderne s-au creat diferite modalitati prin care sa se simplifice implementarea de la zero a unei aplicatii. Chiar daca exista multe biblioteci care sa faciliteze scrierea aplicatilor pentru diferite cazuri de utilizare se pune problema constructiei si controlului aplicatiei. In programarea procedurala atunci cand se scrie cod acesta apeleaza alt cod care se poate afla in biblioteci sau este expus de catre un framework dictand atat fluxul de control al programului cat si constructia acestuia. Aceasta abordare este problematica deoarece i se impune programului ca fluxul de control a diferitelor componente sa fie dat de implementare si nu de o abstractizare care sa faca codul mai refolosibil si mai usor de urmarit.
Exista aici cateva concepte de discutat aici. Sa detaliem ce ne dorim, vrem ca programul scris de noi sa fie modular cu module usor refolosibile, astfel vrem sa dam controlul programului framework-ului iar acesta prin abstractizarile pe care le furnizam sa apeleze codul scris de noi. Acest design pattern se numeste Inversion of Control (IoC). Atat pentru programarea interfetelor grafice cat si pentru dezvoltarea serverelor web se foloseste aceasta tehnica si exista gata implementate framework-uri care sa faciliteze acest lucru.
IoC este des intalnit in conjunctie cu alt concept, principiul inversare a dependentelor (dependency inversion principle). Acest principiu afirma ca daca avem module la nivel inalt, acestea nu trebuie sa stie de detalii ale modulelor de nivel mai jos, ambele trebuie sa depinda de abstractizari, nu de detalii de implementare si de aici provine inversarea dependentelor, implementarile depind de abstractizari, nu invers. Adica in loc ca o implementare generica (abstracta) a unui program sa depinda de detaliile de implementare ale componentelor sale, componentele depind de abstractizare. Ca exemplu, in loc ca noi sa definim clasa A, la un nivel superior, care depinde de clasa B, aflat la nivel inferior, pe ambele le abstractizam prin interfete iar pentru detaliile de implemetare unde clasa A necesita interactiune cu clasa B aceasta se realizeaza printr-o interfata, nu implementare. Un exemplu unde aceasta abordare este buna este cand se folosesc teste, in loc sa folosim anumite implementari pentru componente, de exemplu apeluri catre un serviciu web, putem sa facem o componenta mock care sa substitie implementarea actuala avand aceiasi abstractizare (interfata).
Aceste concepte sunt necesare de stiut pentru a ajunge la unul din cele mai des folosite concepte, anume injectarea de dependente (dependency injection sau DI). Conceptul in sine este simplu, atunci cand avem logica incapsulata intr-o clasa aceasta poate avea nevoie de referinte la alte clase, adica are dependente, pentru a face diferite actiuni. Dependentele pot aparea ca parametri in constructor sau proprietati la care se pot asigna valori. In loc sa instantiem fiecare parametru de constructor sau fiecare proprietate a clasei in mod explicit folosim un framework care implementeaza DI si noi doar declaram de ce componente este nevoie sa fie instantiate la runtime iar acesta se ocupa de rezolvarea recursiva a fiecarei dependente atunci cand se cere o componenta.
Practic, cu DI se face IoC prin faptul ca controlul instantierii si apelarea componentelor este la framework dar mai este nevoie si de inversarea dependentelor pentru ca in loc sa injectam implementari direct. Fiecare dependenta in principiu ar trebui sa fie o interfata care este implementata de o clasa concreta pentru a ascunde detaliile de implementare a unei componente fata de alta. Ca exemplu, daca avem o clasa Service si o clasa Repository iar Service are nevoie de datele furnizate de clasa Repository, cream interfetele IService si IRepository care vor fi implementate de acestea si in constructorul clasei Service adaugam un parametru de tip IRepository.
In anumite framework-uri cum este in Java Spring se permite DI si prin proprietati dar nu se recomanda. In C# se evita cu totul acest lucru si se face doar prin constructor. Motivul fiind ca la construtor stim ca acea clasa nu a fost initializata inainte si nu exista distanta intre initializarea clasei si popularea ei cu dependente ca sa se evite posibile erori. Daca clasa ar fi mai intai initializata iar apoi populata cu dependente ar exista o distanta intre aceste doua evenimente care tin de framework si e necunoscuta programatorului. Astfel se pot strecura erori necunoscand aceasta distanta. De asemenesea, nu se pot face dependente ciclice, daca pentru o interfata A implementarea cere recursiv la un moment data aceiasi interfata A atunci implementarea nu poate fi construita pentru ca s-ar face recursivitate la infinit si ar crapa programul din cauza epuizarii stivei.
La DI se mai pune problema cat timp pot fi folosite instantele componentelor, adica care este lifetime-ul. In general exista trei lifetime-uri cand ne referim la cat de mult pot trai aceste componente la executia unei cereri de instantiere.
- Transient - la fiecare cerere se instantiaza din nou clasa inclusiv la rezolvarea fiecarei dependente dintr-o cerere. Acest lifetime se foloseste in general pentru obiecte pentru care costul de instantiere este mic sau sau care trebuie distruse dupa fiecare folosire.
- Scoped - la cerere fiecare rezolvare a acestei dependente este aceiasi referinta, se pot crea alte referinte scoped daca se creaza un nou scope. Acest lifetime se foloseste in anumite situatii, in general cand este vorba de sesiuni de comunicatie cu procese aflate la distanta cum sunt bazele de date.
- Singleton - instanta se creaza la prima cerere unde se rezolva aceasta dependenta iar acea referinta va fi folosita oriunde este nevoie de o instanta de acel tip. Daca obiectul instantiat nu tine stare este bine de folosit aceasta clasa pentru a evita costul instantierii si pentru ca nu este o problema folosirea concurenta a acelei clase.
Desi se pot folosi in orice combinatie de lifetime-uri, instante ce sunt singleton ar trebui sa aiba dependente singleton, scoped ar trebui sa aiba dependente singleton sau scoped iar cele transient pot avea orice tip de dependente ca sa se pastreze consistent lifetime-ul pentru fiecare dependenta.
Trebuie mentionat ca instantierea componentelor se face intr-un obiect host care face toata instantierea si tine evidenta dependentelor. Pentru a evidentia cum functioneaza DI ilustram in acest exemplu o implementare de DI minimala. De retinut ca in practica se folosesc implementari standard mai eficiente si bine testate.
// Folosim acest enum pentru a specifica lifetime-uri pentru componente.
public enum LifetimeEnum
{
Transient,
Scoped,
Singleton
}
// Declaram o interfata pentru scope care sa fie si disposable.
public interface IServiceProvider : IDisposable
{
// Metoda aceasta este pentru a injecta prin atribute o componenta din Assembly.
public ServiceProvider AddInjected();
// Aceste metode sunt pentru a inregistra componentele cu diferite lifetime-uri, o sa fie declarate ca interfete cu implementari.
public ServiceProvider AddTransient<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface;
public ServiceProvider AddScoped<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface;
public ServiceProvider AddSingleton<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface;
// Cand este nevoie folosim aceasta metoda ca sa cream un nou scope pentru o cerere.
public IServiceScope CreateScope();
// Aceste metode sunt pentru a crea o instanta pentru o componenta.
public object GetService(Type type);
public T GetService<T>() where T : class;
}
// Declaram o interfata pentru scope care sa fie si disposable.
public interface IServiceScope : IDisposable
{
public object GetService(Type type);
public T GetService<T>() where T : class;
}
/* Pentru a folosi atribute trebuie sa mostenim Attribute si trebuie sa
* adaugam un atribut pentru a indica unde poate fi folosit acest atribut,
* In acest caz este folosit pe o clasa.
*/
[AttributeUsage(AttributeTargets.Class)]
public class InjectableAttribute : Attribute
{
public LifetimeEnum Lifetime { get; }
public InjectableAttribute(LifetimeEnum lifetime) => Lifetime = lifetime;
}
// Implementam un IServiceProvider pentru dependency injection.
public sealed class ServiceProvider : IServiceProvider
{
// Campul acesta este folosit pentru a evita recursivitatea multipla la Dispose().
private bool _isDisposed;
// Tinem evidenta la componentele inregistrate, avem un tip ca cheie si un tuplu cu implementarea si lifetime.
private readonly Dictionary<Type, (Type, LifetimeEnum)> _services = new();
// Aici tinem evidenta la instantele singleton.
private readonly Dictionary<Type, object> _singletons = new();
public ServiceProvider()
{
// Adaugam si provider=ul implicit ca singleton ca sa fie create componente la cerere.
_services.Add(typeof(IServiceProvider), (typeof(ServiceProvider), LifetimeEnum.Singleton));
_singletons.Add(typeof(IServiceProvider), this);
}
// Cu aceasta metoda inregistram o interfata cu o implementare cu un lifetime.
public ServiceProvider Add<TInterface, TImplementation>(LifetimeEnum lifetime) where TInterface : class where TImplementation : class, TInterface
{
// Cu typeof putem crea un Type care e o structura care incapsuleaza toate informatiile despre un tip, este un exemplu de reflexie.
var interfaceType = typeof(TInterface);
var implementationType = typeof(TImplementation);
// Putem verifica daca un tip este o interfata.
if (!interfaceType.IsInterface)
{
throw new ArgumentException($"{interfaceType.Name} is not a interface!");
}
// Sau este o clasa.
if (!implementationType.IsClass)
{
throw new ArgumentException($"{implementationType.Name} is not a concrete class!");
}
_services.Add(interfaceType, (implementationType, lifetime));
// Ca sa folosim method-chaing pentru un buider returnam this.
return this;
}
public ServiceProvider AddTransient<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface =>
Add<TInterface, TImplementation>(LifetimeEnum.Transient);
public ServiceProvider AddScoped<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface =>
Add<TInterface, TImplementation>(LifetimeEnum.Scoped);
public ServiceProvider AddSingleton<TInterface, TImplementation>() where TInterface : class where TImplementation : class, TInterface =>
Add<TInterface, TImplementation>(LifetimeEnum.Singleton);
// Putem sa cautam si in assembly pentru a inrefistra tipuri.
public ServiceProvider AddInjected()
{
// Aici extragem toate tipurile existente din Assembly-ul care se executa.
foreach (var type in Assembly.GetExecutingAssembly().GetTypes())
{
// Pentru atribute non-standard create de noi putem sa extragem lista de atribute care sunt de tipul dat.
var attribute = type.GetCustomAttributes(typeof(InjectableAttribute)).FirstOrDefault();
if (attribute is InjectableAttribute injectableAttribute)
{
// Extragem atributul si adaugam tipul cu lifetime-ul din atribut.
_services.Add(type, (type, injectableAttribute.Lifetime));
}
}
return this;
}
// Cand traversam lantul de dependente trebuie sa tinem evicenta tipurilor traversate si a obiectelor scoped.
private object GetService(Type type, IReadOnlySet<Type> traversedTypes, IDictionary<Type, object> scopedObjects)
{
// Daca cerem un tip pe care l-am traversat deja inseamna ca avem o ciclicitate si trebuie sa aruncam exceptie.
if (traversedTypes.Contains(type))
{
throw new ArgumentException($"Cyclic dependency detected, cannot construct type {traversedTypes.FirstOrDefault()}!");
}
// Daca tipul nu este inregistrat trebuie iarasi sa aruncam exceptie.
if (!_services.ContainsKey(type))
{
throw new ArgumentException($"Cannot construct type {traversedTypes.FirstOrDefault()}, {type.Name} was not registered!");
}
var (serviceType, lifetime) = _services[type];
// Daca am gasit tipul si este singleton il returnam daca l-am initializat inainte.
if (lifetime == LifetimeEnum.Singleton)
{
if (_singletons.TryGetValue(type, out var value))
{
return value;
}
}
// Daca am gasit tipul si este scoped verificam lista pentru instantele scoped ca sa-l returnam direct daca exista.
if (lifetime == LifetimeEnum.Scoped)
{
if (scopedObjects.TryGetValue(type, out var value))
{
return value;
}
}
// Pentru fiecare tip pentru implementare extragem contructorii pentru a invoca unul.
var constructors = serviceType.GetConstructors();
// Daca sunt mai mult de un constructor nu stim pe care sa-l apelam si aruncam eroare.
if (constructors.Length > 1)
{
throw new ArgumentException($"{type.Name} was has more than one constructor!");
}
// Pentru contructorul gasit extragem tipurile parametrilor si recursiv cream instantele ca sa apelam constructorul
var constructorParameters = constructors[0].GetParameters()
// traversam din nou crearea componentelor cu tipul curent traversat.
.Select(e => GetService(e.ParameterType, traversedTypes.Concat( new List<Type> { type }).ToHashSet(), scopedObjects))
.ToArray();
// Dupa ce avem parametri contructorului invocam constructorul prin reflectie si cream instanta.
var service = constructors[0].Invoke(constructorParameters);
// Conform lifetime-ului putem instanta in colectia coresunzatoare.
switch (lifetime)
{
// Pentru Singleton instanta trebuie sa fie unica si tinuta in provider.
case LifetimeEnum.Singleton:
_singletons.Add(type, service);
break;
// La Scoped instanta este unica la cererea prin GetService si o adaugam in lista trimisa recursiv entru rezolvare.
case LifetimeEnum.Scoped:
scopedObjects.Add(type, service);
break;
// Pentru Transient se creaza din nou instanta si nu tinem undeva intanta creata.
case LifetimeEnum.Transient:
default:
break;
}
return service;
}
// Daca avem obiecte IDisposible pentru instante scoped le cream un scope ca sa fie distruse cu acesta.
public object GetService(Type type)
{
using var scope = CreateScope();
return scope.GetService(type);
}
public T GetService<T>() where T : class => (T) GetService(typeof(T));
// Cand cream un scope il cream legat la provider-ul curent.
public IServiceScope CreateScope() => new ServiceScope(this);
// La dipose daca avem alte obiecte IDisposable trebuie sa le facem Dispose.
public void Dispose()
{
// Ca sa evitam recursivitate infinita verificam daca este in curs de Dispose.
if (_isDisposed)
{
return;
}
_isDisposed = true;
// Pentru toate instantele singleton daca sunt IDisposable le gasim si le facem Dispose automat.
foreach (var (_, instance) in _singletons)
{
if (instance is IDisposable disposable)
{
disposable.Dispose();
}
}
}
/* Ca sa nu expunem implementareea pentru IScope facem o clasa imbricata (nested) private
* care are acces la toate compurile si metodele private a clasei care o contine dar care
* se afla intr-un context static fata acea clasa.
*/
private sealed class ServiceScope : IServiceScope
{
// Trebuie pasat un ServiceProvider pentru a avea acces la compurile si metodele private ale acestuia.
private readonly ServiceProvider _serviceProvider;
// La fel ca la provider tinem cont de obiectele scoped.
private readonly Dictionary<Type, object> _scoped = new();
public ServiceScope(ServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
// Apelam cu obiectele urmarile functia de a construi componenta.
public object GetService(Type type) => _serviceProvider.GetService(type, new HashSet<Type>(), _scoped);
public T GetService<T>() where T : class => (T) GetService(typeof(T));
// La fel facem Dispose la obiectele scoped pentru a le recicla cum trebuie.
public void Dispose()
{
foreach (var (_, instance) in _scoped)
{
if (instance is IDisposable disposable)
{
disposable.Dispose();
}
}
}
}
}
/* Putem folosi atributul nou pe clasa cu infromatii trimise in constructor.
* Unele componente se pot injecta si fara o interfata ca aici dar in general
* se prefera sa fie o interfata injectata in alte componente si nu o
* implementare specifica.
*/
[Injectable(LifetimeEnum.Transient)]
public class InjectedService
{
private readonly ISingletonService _singletonService;
private readonly IScopedService _scopedService;
private readonly ITransientService _transientService;
private readonly IServiceProvider _serviceProvider;
private readonly ISecondTransientService _secondTransientService;
// Toate aceste interfete vor fi injectate de ServiceProvider prin constructor.
public InjectedService(ISingletonService singletonService, IScopedService scopedService,
ITransientService transientService, IServiceProvider serviceProvider, ISecondTransientService secondTransientService)
{
_singletonService = singletonService;
_scopedService = scopedService;
_transientService = transientService;
_serviceProvider = serviceProvider;
_secondTransientService = secondTransientService;
}
public void Log()
{
// ..
}
}
using IServiceProvider serviceProvider = new ServiceProvider();
// Asa putem inregistra prin method-chianing serviciile. Putem injecta interfata cu implementare.
serviceProvider.AddSingleton<ISingletonService, SingletonService>()
.AddScoped<IScopedService, ScopedService>()
.AddTransient<ITransientService, TransientService>()
.AddTransient<ISecondTransientService, SecondTransientService>()
// Sau sa injectam cautand atribute pe tipurile date.
.AddInjected();
// Aici aceasta clasa injectata prin atribut este instantiata la cerere.
var injectedService = serviceProvider.GetService<InjectedService>();