Sari la conținutul principal

Laborator 5 - server web (continuare)

Plecand de la laboratorul precedent vom detalia structura proiectului de la baza de date, la servicii si la controllere. Este necesar sa intelegem ce face fiercare componenta si cum anume trebuie sa implementam logica.

Accesul la baza de date

In primul rand trebuie sa intelegem ca toate componentele software pe care le vom implementa vor fi declarate in builder-ul de aplicatie pentru dependency injection. Baza de date reprezentata de contextul TicketingDatabaseContext va fi declarata putin diferit fata de alte tipuri de servicii ca in felul urmator:

Program.cs
builder.Services.AddDbContext<TicketingDatabaseContext>(o => o.UseSqlite("Datasource=../Ticketing.db;"));

La adaugare contextul este configurat cu o functie lambda (o functie declarata inline) prin care specificam ce tip de baza de date se foloseste, in acest caz SQLite, si la ce adresa trebuie sa se conecteze, aici la fisierul mentionat acolo. Dupa implementarea serviciilor noi putem folosi acest context unde este nevoie adaugandu-l intr-un constructor.

In cazul din UserService singurul constructor este declarat in declaratia clasei si se numeste un constuctor primar.

UserService.cs
public class UserService(TicketingDatabaseContext databaseContext) : IUserService
{
// Implementare
}

Cu obiectul de tip TicketingDatabaseContext putem face operatii peste baza de date. Pentru a lucra cu o entitate legata la baza de date cum este cea de User pe care am generat-o trebuie sa folosim metoda .Set<User>() paramaterizata cu acest tip. Aceasta metoda returneaza o colectie care ne abstractizeaza lista de randuri din baza de date.

In exemplul de adaugare a unei entitati cream mai intai o entitate noua si o adaugam la context prin metoda .AddAsync. Dupa adaugare, aceste modificari inca nu se vor reflecta pe baza de date, contextul doar le tine evidenta pana cand se apeleaza metoda .SaveChangesAsync(), abia atunci toate modificarile pe baza de date se trimit si datele sunt persistate printr-o tranzactie. Acest lucru are ca rol optimizarea unor operatii peste baza de date, in loc sa se faca mai multe tranzactii pe baza de date se aduna mai multe modificari si se efectueaza o singura tranzactie mai mare.

UserService.cs
    public async Task AddUser(UserAddRecord user)
{
// La adaugare mapam datele din obiect pe entitatea din baza de date si il atasam la context
await databaseContext.Set<User>().AddAsync(new User()
{
FirstName = user.FirstName,
LastName = user.LastName,
Email = user.Email
});

// Dupa atasarea acestua salvam modificarile in baza de date, altfel nu vor fi luate in considerare.
await databaseContext.SaveChangesAsync();
}

Pentru extragerea datelor din baza de date ne folosim de metoada .Set<T>() si apoi efectual operatii clasice peste acea colectie returnata. Aceste operatii se numesc LINQ si reflecta operatii din baza de date. In exemplul dat, metodele de .Select si .Where fac exact acelasi lucru ca operatiile din SQL, anume .Select va proiecta datele din tabela pe un obiect, anume UserRecord, iar .Where va filtra rezultatele care satisfac conditia data.

De mentionat ca operatiile LINQ folosesc functii lambda care se declara pe loc facand mult mai usor efectuarea acestor operatii. La final, dupa efectuarea operatiilor LINQ se apeleaza functii specifice, .FirstAsync() si .ToListAsync() care extrag un element sau null daca nu exista respectiv o lista de elemente.

UserService.cs
    public async Task<UserRecord> GetUser(int userId)
{
return await databaseContext.Set<User>()
.Where(e => e.Id == userId) // Cautam dupa ID elementul
.Select(e => new UserRecord // Facem o proiectie si mapam elementul la un obiect de tranfer
{
Id = e.Id,
Email = e.Email,
FirstName = e.FirstName,
LastName = e.LastName
}).FirstAsync(); // La final extragem un singur element
}

public async Task<List<UserRecord>> GetUsers()
{
return await databaseContext.Set<User>()
.Select(e => new UserRecord // Facem o proiectie si mapam elementul la un obiect de tranfer
{
Id = e.Id,
Email = e.Email,
FirstName = e.FirstName,
LastName = e.LastName
}).ToListAsync(); // La final extragem o lista de elemente
}

Pentru actualizarea unei entitati logica este similara ca la adaugare doar ca se extrage mai intai intrarea din baza de date si apoi este modificata cu datele noi. La final trebuie apelat .SaveChangesAsync() pentru a aplica modificarile pe baza de date.

UserService.cs
    public async Task UpdateUser(UserUpdateRecord user)
{
// Extragem din baza de date elementul care va fi actualizat
var entry = await databaseContext.Set<User>().Where(e => e.Id == user.Id).FirstOrDefaultAsync();

if (entry == null)
{
return;
}

// Actualizam campurile
entry.FirstName = user.FirstName;
entry.LastName = user.LastName;
entry.Email = user.Email;

// (Optional) actualizam si in context entitate
databaseContext.Set<User>().Update(entry);

// In final trimitem modificarile catre baza de date
await databaseContext.SaveChangesAsync();
}

Pentru stergere se face la fel ca la actualizare, se extrage din baza de date entitatea si apoi se sterge din context cu metoda .Remove si aplicand modificarile cu .SaveChangesAsync(). Pentru stergere si actualizare e bine sa fie si verificate informatiile din date pentru ca depinzand de logica aplicatiei trebuie sa fie refuzate anumite actiuni cand este necesar.

UserService.cs
    public async Task DeleteUser(int userId)
{
// Cautam elementul dupa ID in baza de date
var entry = await databaseContext.Set<User>().Where(e => e.Id == userId).FirstOrDefaultAsync();

if (entry == null)
{
return;
}

// Il stergem din context
databaseContext.Set<User>().Remove(entry);

// Si trimitem modificarile catre baza de date
await databaseContext.SaveChangesAsync();
}

Implementarea serviciilor

Cand vorbim de servicii vorbim de componente software care vor incapsula majoritatea logicii aplicatiei. Aceste vor arata oricum este necesar pentru a implementa functionalitatile aplicatiei. Partea grea la a implementa serviciile este proiectarea acestora conform unor specificatii pe care le doriti, altfel doar trebuie facut o interfata care sa fie implementata de o clasa concreta.

Pentru serviciul nostru avem interfata IUserService implementata de UserService si va fi injectata in restul aplicatiei cu lifetime scoped. In majoritate cazurilor acest lifetime va fi folosit. Alte servicii pot refolosi servicii existente prin injectarea interfetelor acestora prin constructor.

Folosirea functiilor asincrone

In general, cand implementam servere, pentru ca necesita un numar mare de accesari concurente, vom folosi metode asincrone.

O metoda asincrona este prefixata cu cuvantul cheie async, orice metoda care returneaza o clasa Task (parametrizata sau nu) poate avea acest cuvant cheie.

Ce face acest cuvant cheie este sa se poata astepta sa se termine un obiect Task sa se execute folosind cuvantul cheie await. Nu se poate folosi cuvantul cheie await decat intr-o functie declarata async. De asemenea, intr-o functie async se poate retuna nimic daca signatura functiei returneaza Task sau poate returna obiectul cu care e parametrizat clasa Task de la iesirea functiei ca sa se simplifice scrierea acestor functii.

Avantajul de a folosi functii asincrone este ca acestea se pot executa in mod concurent mai usor pentru ca se ocupa aplicatia pentru a programa mai eficient executia claselor Task.

Program.cs
builder.Services.AddScoped<IUserService, UserService>();

Definirea API-ului prin controllere

Ca sa avem acces la functionalitatea aplicatiei va trebui sa implementam controllere. Trebuie sa intelegem structura unui controller ca sa putem implementa rutele care vor fi apelate cu date de catre client.

In aplicatii moderne veti intalni ce numin @OP (Attribute-Oriented Programming). Aceasta paradigma de programare se foloseste de atribute care in C# sunt obiectele care decoreaza clase, metode sau parametri de metode puse intre paranteze patrate.

Controller-ul va reprezenta o colectie de metode care vor fi asociate cu cate o ruta iar aplicatia va apela aceste metode in mod automat cand va gasi o potrivire cu o ruta. Orice controller va trebui sa mosteneasca clasa ControllerBase altfel nu va putea fi folosit.

Aceasta clasa trebuie sa fie decorata cu atributele ApiController ca sa deserializeze anumite cereri corect. Atributul Route va adauga un prefix pentru rutele declarate pe metode. In cazul de aici prefixul rutei foloseste o interpolare pentru cu numele controllerului si numele unei metode pentru ca vor fi inlocuite sirurile [controller] respectiv [action], astfel pentru o metoda AddUser prefixul rutei va fi /User/AddUser.

UserController.cs
// Clasa trebuiwesa aiba aceste atribute pentru a putea fi apelata la cererea clientilor
[ApiController]
// Aici specificam care este prefixul rutei, in acest caz va fi numele controller-ului urmat de numele metodei
[Route("[controller]/[action]")]
public class UserController(IUserService userService) : ControllerBase
{
//...
}

Cand vom defini metode va trebui sa declaram pe metoda un atribut care sa ne indice ce verb de HTTP se foloseste. Aici vom folosi in general unul din cele patru verbe pentru operatiile CRUD.

  • Create - adaugam HttpPost pentru verbul POST care de obicei creaza o resursa pe server.
  • Read - folosim HttpGet pentru verbul GET care trebuie sa citeasca o resursa.
  • Update - in general se foloseste HttpPut cu verbul PUT ca sa actualizeze o resursa, partial sau integral.
  • Delete - atributul este HttpDelete cu verbul DELETE ca sa stergem o resursa.

La fel ca atributul Route acestea pot adauga la ruta mai multe siruri ca postfix.

Metoda din controller va returna un Task parametrizat cu un IActionResult care are ca implementare si tipul ActionResult parametrizat. Rezultatul va fi incapsulat intr-o metoda din ControllerBase cum sunt .Ok(), .NoContent() sau .StatusCode. Aceste metode dau un raspuns sau pot folosi un obiect ca sa returneze la iesire un ActionResult care pe langa date va contine si codul de status al raspunsului HTTP.

Parametri functiei vor fi si ei adnotati cu atribute pentru a fi extrase dintr-o locatie din cerere si sa fie deserializate. Datele pot fi extrase din:

  • Parametri de url/query sunt specificați cu FromQuery, acestia sunt parametri din URL dupa ? si delimitati cu &.
  • Parametri din ruta sunt extrase prin FromRoute dacă acestea au fost declarate în șablonul de rută dintr-un atribut care specifică ruta.
  • Pentru form-uri, campurile din form pot fi extrase prin FromForm, un caz special este când un camp este un fișier, iar acesta poate fi extras într-un obiect de tip IFromFile sau IFormfileCollection.
  • Body-ul cererii poate fi extras doar o singură dată și deserializat într-un singur obiect fie lăsând parametrul fără atribut sau cu atributul FromBody; doar metodele de POST și PUT acceptă body.

In cazul rutelor sablon din care se extrag date pentru FromRoute se foloseste un sablon intre acolade, eventual cu o constrangere de tip, parametrul care are numele respectiv va fi extras din ruta.

UserController.cs
    // Aici avem decorat cu un atribut ce indica ca se foloseste o cerere de tip POST
[HttpPost]
public async Task<IActionResult> AddUser([FromBody] UserAddRecord user) // Atributul aici indica faptul ca parametrul este extras din corpul mesajul care este de tip JSON
{
// Apelam serviciul cu datele deserializate
await userService.AddUser(user);

// Raspunsul va fi un raspuns gol cu status code 204 No Content
return NoContent();
}

// Aici avem decorat cu un atribut ce indica ca se foloseste o cerere de tip GET
// Aici ruta este una variabila care la final are un ID ce va fi extras si trimis catre apelul metodei
[HttpGet("{userId:int}")]
public async Task<ActionResult<UserRecord>> GetUser([FromRoute] int userId) // Atributul aici indica faptul ca parametrul este extras din ruta cererii
{
// Raspunsul va fi un raspuns continand datele cerute cu status code 200 Ok
return Ok(await userService.GetUser(userId));
}

Controllerele trebuie sa efectueze putine operatii, rolul lor trebuie doar sa fie de deserializat cererea si trimis raspunsul inapoi. Logica aplicatiei trebuie implementata la nivelul serviciilor care vor fi injectate in contorllere.

Date corelate

In multe aplicatii veti avea nevoie sa corelati date, in baze de date relationale acest lucru se face cu chei straine (foreign keys). Sa presupunem ca facem o noua tabela in exemplul nostru si adaugam o cheie straina catre tabela pe care deja am creat-o.

nat

Dupa ce baza de date a fost actualizata putem sa rulam din nou aceiasi comanda de scaffold si codul se va actualiza automat.

dotnet ef dbcontext scaffold --project MobyLab.Ticketing/MobyLab.Ticketing.csproj --startup-project MobyLab.Ticketing/MobyLab.Ticketing.csproj --verbose "Data Source=../Ticketing.db" Microsoft.EntityFrameworkCore.Sqlite --context TicketingDatabaseContext --context-dir Database --force --output-dir Database/Models --use-database-names

Veti observa in codul generat ca relatia noastra intre cele doua tabele s-a transformat in compuri noi din entitatile generate. Acestea se numesc proprietati de navigare (navigation properties) si ne vor ajuta sa navigam intre datele din tabele.

Daca ne dorim sa luam datele noi din baza de date o data cu alte informatii putem de exemplu sa extindem obiectul pentru utilizatori si sa luam cateva informatii de acolo:

UserRecordWithTicketCount.cs
public class UserRecordWithTicketCount : UserRecord
{
public int TicketCount { get; set; }
public List<string> TicketNames { get; set; } = [];
}

Si ca sa populam informatiile in acest obiect trebuie sa actualizam interogarea bazei de date:

        await databaseContext.Set<User>()
.Select(e => new UserRecordWithTicketCount()
{
Id = e.Id,
FirstName = e.FirstName,
LastName = e.LastName,
Email = e.Email,
TicketCount = e.Tickets.Count(), // extragem numarul de intrari care refera
TicketTitles = e.Tickets.Select(x => x.Title).ToList() // extragem si titlurile pentru fiecare tichet
})
.ToListAsync();

Aici se efectueaza o proiectie si datele se vor popula automat in raspuns dar in cazul in care vreti luati entitatile asa cum sunt o sa observati ca .Tickets este null. Motivul este ca nu se doreste la fiecare cerere sa se incarce toate datele posibile din baza de date ci doar ce este strict necesar.

Pentru fi populat automat datele in entitati cand este nevoie se foloseste metoda .Include ca in exemplul urmator:

        await databaseContext.Set<User>()
.Include(e => e.Tickets) // dupa interogare pentru fiecare intrare din lista se va popula aceasta proprietate de navigare
.ToListAsync();

Paginare si cautare

In multe cazuri nu ne dorim sa extragem toate datele din baza de date ca o lista foarte lunga cand facem o cerere pentru mai multe valori. Nu ne dorim acest lucru pentru ca ingreunam atat serverul cat si clientul sa incarce in memorie foarte multe date care in mare parte nu vor fi folosite. Astfel, putem sa luam data paginat, adica sa impartim datele in pagini de un anumit numar de elemente si sa interogam serverul pagina cu pagina eventual cu filtrand rezultatele dupa un criteriu de cautare.

Mai intai ne definim un obiect pentru interogarea unei pagini cu dimensiunea paginii si numarul acesteia:

PaginationQueryParams.cs
public class PaginationQueryParams
{
public int Page { get; set; } // numarul paginii pe care ne dorim sa o interogam incepand de la 1
public int PageSize { get; set; } // numarul maxim de elemente din pagina
}

Pentru filtrari putem sa extindem aceasta clasa si sa adaugam alti parametri cum ar fi un sir de cautare:

SearchPaginationQueryParams.cs
public class SearchPaginationQueryParams : PaginationQueryParams
{
public string? Search { get; set; }
}

Apoi ne definim un tip polimorfic care sa contina raspunsul si informatiile despre interogarea facuta:

PaginationResponse.cs
public class PaginationResponse<T>
{
public int Page { get; set; } // pagina intergoata
public int PageSize { get; set; } // numarul maxim de elemente din pagina
public int TotalCount { get; set; } // numarul total din baza de date, va fi folosit de client sa afle cate pagini exista
public List<T> Data { get; set; } = []; // intrarile returnate din baza de date
}

In serviciu putem adauga o noua metoda pentru a trata acest tip de interogare. Obeservati cum se implementeaza cautarea dar si extragerea unei pagini cu parametri dati prin metodele .Skip si .Take.

UserService.cs
    public async Task<PaginationResponse<UserRecordWithTicketCount>> GetPagedUsers(SearchPaginationQueryParams query)
{
// acest sir va fi folosit pentru cautare
var search = !string.IsNullOrWhiteSpace(query.Search) ? $"%{query.Search}%" : "";

return new PaginationResponse<UserRecordWithTicketCount>()
{
Page = query.Page,
PageSize = query.PageSize,
TotalCount = await databaseContext.Set<User>().CountAsync(),
Data = await databaseContext.Set<User>()
// daca sirul nu este gol va fi solosit sa caute toti utilizatorii cu email-ul care contine sirul respectiv
.Where(e => search == "" || EF.Functions.Like(e.Email, search))
// ordonam intrarile ca sa fie interogarea determinista
.OrderBy(e => e.Id)
// omitem primele paginile inainte de pagina curenta de dimensiunea data.
.Skip((query.Page - 1) * query.PageSize)
// luam maxim dimensiunea paginii elemente
.Take(query.PageSize)
.Select(e => new UserRecordWithTicketCount()
{
Id = e.Id,
FirstName = e.FirstName,
LastName = e.LastName,
Email = e.Email,
TicketCount = e.Tickets.Count(),
TicketNames = e.Tickets.Select(x => x.Name).ToList()
})
.ToListAsync()
};
}

In controller doar va fi nevoie sa se apeleze serviciul avand obiectul care contine parametri de interogare pusi ca parametri de cerere.

UserController.cs
    [HttpGet]
public async Task<ActionResult<UserRecordWithTicketCount>> GetPagedUsers([FromQuery] SearchPaginationQueryParams pagination)
{
return Ok(await userService.GetPagedUsers(pagination));
}