Interacțiunea cu baza de date
Pe lângă logica aplicației, trebuie să existe persistența datelor asupra cărora se efectuează logica efectivă. În acest sens, majoritatea aplicațiilor folosesc baze de date. Pentru a simplifica interacțiunea programelor cu baza de date, au fost implementate ORM-uri (Object-Relational Mapping). Acestea sunt framework-uri care realizează o corespondență între tabelele și tipurile de date din baza de date cu obiectele, numite entități, și tipurile declarate în codul aplicației.
ORM-urile expun în general o interfață generică care poate fi folosită pentru mai multe baze de date (cum ar fi PostgreSQL, MariaDB sau SQL Server), utilizând același cod, chiar dacă pot exista particularizări pentru fiecare. Aceste implementări specifice se găsesc în diverse biblioteci disponibile pe NuGet, pentru .NET ORM-ul folosit se numește EntityFramework. Interfața generică este expusă printr-un context de bază de date. Contextul nu este altceva decât un client pentru baza de date, care serializează/deserializează cereri și obiecte în comunicarea cu baza de date și servește și ca cache pentru entități. În EntityFramework, contextul va fi o clasă derivată din DbContext.
Vă recomandăm să utilizați baze de date SQL; majoritatea aplicațiilor nu vor avea nevoie de baze de date NoSQL, iar bazele de date tradiționale vor satisface cel mai probabil nevoile voastre. Alegerea bazei de date trebuie să fie una informată și adaptată nevoilor proiectului. Nu adoptați tehnologii doar pentru că sunt la modă sau doar pentru că le cunoașteți.
Definirea schemei bazei de date
Majoritatea logicii aplicației va fi dictată de schema de date. Pentru a începe dezvoltarea unei aplicații, prima etapă constă în definirea schemei bazei de date și a obiectivelor pe care doriți să le realizați cu ea. Dacă aceste aspecte sunt bine definite, implementarea logicii peste date va fi mult mai ușoară și va necesita mai puține modificări asupra aplicației.
Mai jos aveți un exemplu de corespondență a unor entități cu tabele din baza de date. Observați aici că entitățile reprezentate prin clase normale pot moșteni clase abstracte pentru a evita codul duplicat. Fiecare entitate va reprezenta o tabelă în baza de date, iar legăturile între entități, reprezentate prin proprietăți ce conțin tipul altor entități, se numesc proprietăți de navigare (navigation properties). Prin intermediul acestor proprietăți se vor realiza legăturile de cheie străină (foreign key).
public abstract class BaseEntity
{
public Guid Id { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public void UpdateTime() => UpdatedAt = DateTime.UtcNow;
}
public class User : BaseEntity
{
public string Name { get; set; } = default!;
public string Email { get; set; } = default!;
public string Password { get; set; } = default!;
public UserRoleEnum Role { get; set; } = default!;
public ICollection<UserFile> UserFiles { get; set; } = default!;
}
public class UserFile : BaseEntity
{
public string Path { get; set; } = default!;
public string Name { get; set; } = default!;
public string? Description { get; set; }
public Guid UserId { get; set; }
public User User { get; set; } = default!;
}
Pentru fiecare entitate, se creează și o clasă de configurare pentru ca ORM-ul să cunoască diverse detalii privind crearea tabelelor, cum ar fi ce proprietăți corespund cheilor primare, unice sau de referință. Observați cum sunt definite legăturile între tabele prin proprietățile de navigare. Puteți accesa mai multe informații despre EntityFramework și cum să-l utilizați aici.
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.Property(e => e.Id) // Aici se specifică care proprietate este configurată.
.IsRequired(); // Aici se specifică dacă proprietatea este obligatorie, ceea ce înseamnă că nu poate fi nulă în baza de date.
builder.HasKey(x => x.Id); // Aici se specifică că proprietatea Id este cheia primară.
builder.Property(e => e.Name)
.HasMaxLength(255) // Aici se specifică lungimea maximă pentru tipul varchar în baza de date.
.IsRequired();
builder.Property(e => e.Email)
.HasMaxLength(255)
.IsRequired();
builder.HasAlternateKey(e => e.Email); // Aici se specifică că proprietatea Email este o cheie unică.
builder.Property(e => e.Password)
.HasMaxLength(255)
.IsRequired();
builder.Property(e => e.Role)
.HasMaxLength(255)
.IsRequired();
builder.Property(e => e.CreatedAt)
.IsRequired();
builder.Property(e => e.UpdatedAt)
.IsRequired();
}
}
public class UserFileConfiguration : IEntityTypeConfiguration<UserFile>
{
public void Configure(EntityTypeBuilder<UserFile> builder)
{
builder.Property(e => e.Id)
.IsRequired();
builder.HasKey(x => x.Id);
builder.Property(e => e.Path)
.HasMaxLength(255)
.IsRequired();
builder.Property(e => e.Name)
.HasMaxLength(255)
.IsRequired();
builder.Property(e => e.Description)
.HasMaxLength(4095)
.IsRequired(false); // Aici se specifică că această coloană poate fi nulă în baza de date.
builder.Property(e => e.CreatedAt)
.IsRequired();
builder.Property(e => e.UpdatedAt)
.IsRequired();
builder.HasOne(e => e.User) // Aici se specifică o relație de unu-la-mulți.
.WithMany(e => e.UserFiles) // Aici se furnizează maparea inversă pentru relația de unu-la-mulți.
.HasForeignKey(e => e.UserId) // Aici este specificată coloana cheii străine.
.HasPrincipalKey(e => e.Id) // Aici se specifică cheia referențiată în tabela referențiată.
.IsRequired()
.OnDelete(DeleteBehavior.Cascade); // Aici se specifică comportamentul de ștergere atunci când entitatea referențiată este eliminată.
}
}
Migrări
Când implementați o schemă de bază de date, aceasta poate suferi diverse modificări în timpul dezvoltării și maturizării aplicației. Din acest motiv, modificările la schema de bază de date ar trebui să fie efectuate incremental, adică orice schimbare se aplică peste modificările anterioare. De aceea, există conceptul de migrare. O migrare este o transformare, adesea reversibilă, a schemei bazei de date care să reflecte schimbările din cod. În Entity Framework, puteți utiliza migrări instalând dotnet-ef:
dotnet tool install --global dotnet-ef --version 8.*
După ce entitățile au fost create și configurate corespunzător (consultați configurările pentru entități în proiectul laboratorului), puteți rula comanda pentru generarea migrărilor având baza de date deschisă:
dotnet ef migrations add <nume_migrare> --context <nume_clasa_context> --project <proiect_cu_migrarile> --startup-project <proiect_cu_startup>
Exemplu:
dotnet ef migrations add InitialCreate --context WebAppDatabaseContext --project .\MobyLabWebProgramming.Infrastructure --startup-project .\MobyLabWebProgramming.Backend
În codul din laborator, migrările create vor fi aplicate automat la prima cerere făcută către baza de date. Alternativ, puteți rula comanda:
dotnet ef database update
Pentru mai multe informații despre migrări și uneltele în linia de comandă, puteți consulta documentația pentru utilitarul dotnet-ef.
Întotdeauna creați migrări în dezvoltarea proiectelor și nu neglijați importanța lor. Acest lucru vă ajută din două puncte de vedere: automatizați procesul de modificare a bazei de date și puteți urmări modificările de-a lungul istoricului aplicației pentru a detecta eventuale erori care pot apărea datorită unei modificări.
Aplicarea unei migrări poate eșua dacă constrângerile pe coloane sunt încălcate. De exemplu, dacă este pusă condiția de not null pe o coloană existentă și în baza de date există înregistrări cu NULL pe acea coloană, migrarea va eșua.
Trebuie să fiți conștienți că anumite modificări asupra bazei de date sunt ireversibile, cum ar fi ștergerea unor tabele sau coloane. Înainte de a aplica o migrare, faceți un backup la baza de date.
Citirea de date
Ca o particularitate pentru EntityFramework, acesta nu se folosește de cereri SQL scrise de programator, ci se pot specifica funcțional prin LINQ (Language Integrated Query) pentru accesul la date. Framework-ul abstractizează prin interfață funcțională cererile, iar acestea sunt traduse în cereri specifice pentru fiecare tip de bază de date. Mai jos este un exemplu de cum se traduce codul din LINQ în SQL pentru Postgres:
var search = "Dan Geros";
await DbContext.Set<UserFile>()
.Where(e => EF.Functions.Like(e.Name, $"%{search}%"))
.OrderByDescending(e => e.CreatedAt)
.Select(e => new UserFileDTO
{
Id = e.Id,
Name = e.Name,
Description = e.Description,
CreatedAt = e.CreatedAt,
UpdatedAt = e.UpdatedAt,
User = new()
{
Id = e.User.Id,
Email = e.User.Email,
Name = e.User.Name,
Role = e.User.Role
}
}).ToListAsync();
Acest cod se traduce în următorul SQL, funcțiile lambda descrise în operațiile anterioare vor fi traduse textual în acest SQL prin mecanismele de reflexie din C#:
select uf."Id", uf."Name", uf."Description", uf."CreatedAt", uf."UpdatedAt", u."Id", u."Email", u."Name", u."Role" from "UserFile" uf
left join "User" u on u."Id" = uf."UserId"
where uf."Name" like '%Dan Geros%'
order by uf."CreatedAt" desc
În principiu, acest lucru este posibil deoarece tabelele din baza de date nu sunt altceva decât colecții de intrări și se pot aplica aceleași operații funcționale ca în programarea funcțională. De altfel, operațiile funcționale în LINQ au fost inspirate din operațiile analoge din bazele de date. Această corespondență cu SQL este unu la unu, de exemplu, proiecția/select corespunde la .Select, filtrarea/where corespunde la .Where, iar sortarea/order la .OrderBy. O introducere în LINQ o puteți găsi aici.
Deși aceste operații se pot folosi direct cu contextul bazei de date, se pot implementa componente de tip repository care fie să interacționeze cu ORM-ul. Un repository se poate implementa pentru entități specifice, cum ar fi entitatea pentru utilizatori, sau să fie generic, iar cererile să fie grupate în design pattern-ul de specificatii. O specificație în contextul de design pattern este un obiect care conține cererea către baza de date pentru a fi refolosită în mai multe părți ale codului. Puteți vedea în codul din laborator cum sunt implementate specificațiile și repository-ul generic. Dacă alegeți să lucrați cu specificații, folosiți același pachet ca în proiectul din laborator.
Un lucru foarte important de știut aici este că entitățile odată extrase din baza de date sunt legate implicit la contextul bazei de date și sunt urmărite de framework, aceste entități sunt numite urmărite (tracked), și nu vor fi consumate de garbage collector decât după ce contextul bazei de date este consumat mai întâi.
Este nerecomandat să fie expuse entitățile bazei de date direct către exteriorul aplicației. De aceea, cel mai bine este ca entitățile să fie transformate/mapate în DTO-uri (Data Transfer Objects), adică obiecte simple care doar transferă informațiile din entități și care pot fi consumate de garbage collector independent de contextul bazei de date. De asemenea, nu toate informațiile din entitate pot fi necesare sau se doresc a fi expuse în afara serviciilor și este mai bine să fie folosite DTO-uri pentru securitatea aplicației.
Modificarea datelor
Pe lângă operațiile de citire a datelor există, bineînțeles, și operații de modificare a datelor din baza de date. Adăugarea, modificarea și ștergerea datelor se fac prin setul expus de contextul bazei de date, în felul următor.
var user = new User
{
Email = "admin@default.com",
Name = "Admin",
Role = UserRoleEnum.Admin,
Password = PasswordUtils.HashPassword("default")
}
dbContext.Set<T>().Add(user); // Adăugăm entitatea la context, dar nu o trimitem imediat către baza de date; este doar marcată pentru inserare.
dbContext.SaveChanges(); // Abia acum, la apelul acestei metode, cererea de inserare este trimisă către baza de date, iar contextul urmărește modificările făcute asupra entității.
user.Name = "NewAdmin";
dbContext.SaveChanges(); // După efectuarea modificărilor asupra unei entități monitorizate, la salvarea contextului, cererile de actualizare sunt trimise către baza de date.
dbContext.Remove(user); // Odată legată la context, o entitate poate fi ștearsă dintr-un set. La fel, cererea de ștergere nu este trimisă imediat.
dbContext.SaveChanges(); // La salvarea contextului, entitățile eliminate din setul contextului sunt șterse din baza de date prin cereri de ștergere.
Trebuie reținut că modificările asupra setului de date se fac mereu la .SaveChanges() sau .SaveChangesAsync(), acest lucru ajută ca mai multe cereri să poată fi grupate și trimise într-o singură cerere pentru a optimiza scrierile pe baza de date. Pentru a obține entitățile legate de context fără a le insera mai întâi, le extrageți prin operații de LINQ.
Deși puteți face orice operație posibilă pe baza de date prin context, puteți folosi codul din laborator pentru repository și specificații pentru a avea câteva abstractizări care să reducă codul duplicat.
De reținut, nu se pot urmări mai multe entități de către context cu aceeași cheie primară sau unică. Dacă se încearcă legarea unei entități la context când există o altă entitate cu aceeași cheie, contextul va întoarce o eroare.
Aveți grijă la modificările pe entități. Dacă nu intenționați să modificați entitățile în baza de date, nu ar trebui să fie modificate nici în cod, deoarece la apelul metodei .SaveChanges() sau .SaveChangesAsync() ulterioare, chiar dacă nu se face în interiorul aceleiași funcții, se vor transmite modificările către baza de date și vor apărea modificări nedorite asupra datelor. Dacă doriți să modificați date extrase din baza de date, cel mai bine faceți o proiecție pe DTO-uri și lucrați cu acestea.
Sarcini de laborator
Descărcați codul din laborator de pe Gitlab și urmați următoarele tipuri de clase:
- Entități
- Configurări de entități
- Specificații
- Repository
Creați prima migrare numită "InitialCreate" cu comanda dotnet-ef
și rulați proiectul cu baza de date pornită. Conectați-vă la baza de date și urmăriți schema bazei de date.
Încercați să adăugați propriile entități și creați noi migrări. Puteți acum să vă creați schema bazei de date pentru proiect.