Sari la conținutul principal

Injectarea de dependente

Unul dintre principalele concepte care stau la baza dezvoltării aplicațiilor moderne este Injectarea de Dependente (Dependency Injection). Pentru o descriere detaliată și informații despre implementarea sa în C#, consultați aici.

Pentru a pune în context programarea web, unul dintre motivele pentru care limbaje precum Java și C# sunt populare în dezvoltarea aplicațiilor este suportul pentru reflexie la runtime. Cu alte cuvinte, programul poate face introspecție asupra propriului cod la runtime și poate, de exemplu, să creeze instanțe de obiecte fără a fi programat explicit în acest sens.

Acest aspect a facilitat implementarea dependency injection în aceste limbaje, care constă în instantierea componentelor la runtime, de la cele mai simple la cele mai complexe. Instanțele acestor componente sunt apoi furnizate ca parametri pentru instantierea altor componente.

În exemplul de mai jos, puteți observa cum este declarată o componentă. Parametrii furnizați la constructor sunt injectați de către framework atunci când această componentă este solicitată. Notați că parametrii sunt de obicei interfete. De obicei, se injectează interfete, nu implementări concrete. Acest lucru se datorează faptului că pentru o interfață pot exista mai multe implementări, care pot fi schimbate în funcție de necesități. De exemplu, în scopuri de testare, implementarea de producție poate fi înlocuită cu o implementare de test pentru interceptarea apelurilor de metode ale serviciului respectiv.

public class InitializerWorker : BackgroundService
{
private readonly ILogger<InitializerWorker> _logger;
private readonly IServiceProvider _serviceProvider;

public InitializerWorker(ILogger<InitializerWorker> logger, IServiceProvider serviceProvider)
{
_logger = logger; // Aici este injectată instanța loggerului.
_serviceProvider = serviceProvider; // Aici este injectat furnizorul de servicii pentru a solicita alte componente la runtime la cerere.
}

protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
try
{
await using var scope = _serviceProvider.CreateAsyncScope(); // Aici se creează un nou scope, util pentru obținerea de instanțe noi pe durata cererii. instances.
var userService = scope.ServiceProvider.GetService<IUserService>(); // Aici se solicită o instanță pentru un serviciu,
// poate eșua dacă crearea componentei aruncă o excepție.

if (userService == null) // Dacă nu a fost declarată o implementare pentru acea interfață poate sa nu returneze un serviciu.
{
_logger.LogInformation("Couldn't create the user service!");

return;
}

var count = await userService.GetUserCount(cancellationToken);

if (count.Result == 0)
{
_logger.LogInformation("No user found, adding default user!");

await userService.AddUser(new()
{
Email = "admin@default.com",
Name = "Admin",
Role = UserRoleEnum.Admin,
Password = PasswordUtils.HashPassword("default")
}, cancellationToken: cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred while initializing database!");
}
}
}

Orice componentă care va fi utilizată în aplicația de backend va fi instanțiată prin intermediul DI și trebuie declarată într-un obiect de tip WebApplicationBuilder. Declararea se va realiza în modul următor, specificând și durata de viață a componentei:

builder.Services
.AddTransient<IUserService, UserService>()
.AddTransient<ILoginService, LoginService>()
.AddTransient<IFileRepository, FileRepository>()
.AddScoped<IUserFileService, UserFileService>()
.AddSingleton<IMailService, MailService>();

Există 3 tipuri de durate de viață pentru instanțele componentelor, și anume:

  • Singleton – Pe durata întregului program, este instanțiată doar o singură instanță a acelei componente. De fiecare dată când este solicitată componenta, aceeași instanță este returnată. Un exemplu ar fi ILogger, care este instantiat o singură dată pentru fiecare parametru generic.
  • Transient – De fiecare dată când este solicitată o componentă, este returnată o nouă instanță. Exemple de componente transiente includ controalele; la fiecare cerere HTTP, este creată o nouă instanță de controlor pentru tratarea cererii.
  • Scoped – Instanțele returnate sunt unice pentru fiecare scop (scope), in cadrul aceleiași cereri pentru o instanță se va injecta aceiași referință a unui obiect cu acest lifetime. Un exemplu ar fi contextul de bază de date.

De menționat că fiecare serviciu injectat este o interfață căreia, la momentul declarării pentru DI, i se asociază o implementare.

Servicii

De obicei, logica aplicației se implementează în componente specializate numite servicii. Motivul este să segregăm componentele pe funcționalități. O componentă cu multe responsabilități va fi dificil de întreținut și de înțeles pentru alți dezvoltatori. Prin urmare, este crucial ca responsabilitățile fiecărui serviciu să fie bine definite și coezive. De exemplu, logica specifică pentru utilizatori (adaugare/modificare/ștergere) poate compune un serviciu distinct separat de altele, cum ar fi unul responsabil de accesul la fișiere.

Serviciile sunt declarate ca implementări ale unor interfețe pentru a expune doar ceea ce este necesar către alte componente care utilizează aceste servicii și pentru a abstractiza o parte din logica aplicației. Nu există o regulă strictă privind aspectul interfeței sau implementării; este la latitudinea dezvoltatorului cum trebuie să fie implementate specificațiile, dar trebuie să existe o coerență în implementare, iar logica din servicii ar trebui segregată pe funcționalități bine definite. Dacă este nevoie de un serviciu care să gestioneze date despre utilizatori, trebuie să existe un serviciu pentru utilizatori și să nu îndeplinească alte acțiuni. Similar, un serviciu pentru notificări trebuie să se ocupe doar de notificări, iar alte servicii pot cel mult folosi interfața expusă de acel serviciu pentru a reutiliza funcționalitățile acestuia.

După cum am menționat, serviciile sunt instantiate prin DI și au un lifetime definit pentru acesta. În majoritatea cazurilor, veți folosi obiecte tranziente sau cu scop. Datorită facilităților de DI, orice serviciu declarat poate fi folosit în orice altă componentă doar declarându-l ca parametru în constructor. Acest lucru va ajuta, deoarece, odată ce ați implementat o logică undeva, o puteți apela la nevoie în orice altă parte a codului doar prin serviciile injectate. În anumite situații, cum ar fi pentru servicii în fundal (background services), puteți folosi un obiect de tip IServiceProvider injectat ca orice alt serviciu pentru a cere o instanță, așa cum am arătat în exemplul anterior, dar acest lucru este un caz mai excepțional.

Bonus - Client de e-mail

În cadrul codului pentru laborator, aveți și un serviciu de mail care poate fi configurat în fișierul appsettings.json. Dacă vreți să-l testați ca să-l folosiți, puteți să vă faceți cont pe MailTrap și să configurați credențialele în fișierul appsettings.json. Corpul e-mail-ului poate fi formatat ca HTML ca să aibă un aspect mai plăcut; încercați să vă faceți propriile șabloane de mail personalizate. Acest serviciu este un exemplu simplu pentru a urmări cum este injectat, unde este folosit și cum se poate personaliza pentru propriile proiecte.