Laborator 4 - server web
In cadrul acestui laborator vom implementa un server web care sa poata fi extins pentru diverse proiecte. Serverul va implementa un API (Application Programmable Interface) Rest (REstful State Transfer), acest lucru inseamna ca datele care se schimba intre server si clienti sunt in forma de JSON iar apelurile catre rutele serverului sunt practic apeluri la distanta ale functiilor de pe server.
Pentru o introducere mai detaliata in programarea web va vom referi la acest curs, pentru acest laborator va fi suficient doar ce prezentam aici.
Cerinte prealabile
Pentru a incepe dezvoltarea serverului mai intai vom avea nevoie de o baza de date. Pentru moment vom folosi o baza de date SQLite pe care o puteti crea cu acest program.
SQLite este o baza de date foarte rudimentara care este continuta intr-un singur fisier si este o solutie buna (dar foarte limitata) pentru a face teste. Aceasta baza de date de obicei se foloseste pentru sisteme incorporate.
Vom pleca de la baza de date urmatoare cu un singur tabel pentru a dezvolta functionalitati peste aceasta.
Baza de date va fi folosita pentru a mentine starea aplicatiei, adica se vor depozita in aceasta date venite de la clientii serverului si care se pot interoga. Serverul nu doar ca va putea intermedia acest transfer de date dar cu baza de date poate mentine o evidenta a logicii aplicatiei cu persitenta starii acesteia.
Structura proiectului
Pentru a initializa proiectul se creaza un proiect nou in .NET 8 de tip Rest API.
Veti observa ca proiectul este populat cu cateva fisiere sursa. Momentan avem nevoie sa vedem ce face proiectul nou. Porniti proiectul dar asigurati-va ca se porneste cu configurarea pentru HTTP.
Dupa rulare se deschide o pagina in browser, aceasta este intefata de Swagger sau cum se numeste mai nou de OpenAPI Specification. Este o pagina unde veti vedea descris tot API-ul expus de catre server si tipurile de date interschimbate cu acesta. De asemenea, puteti testa cu acesta interfata cererile catre server.
Trebuie mentionat ca atunci cand vom actualiza API-ul se va actualiza si aceasta interfata se va actualiza si puteti testa noile rute adaugate.
Ca sa implementam logica noua va trebui sa ne organizam codul. Pentru asta vom avea in interiorul proiectului urmatoareale foldere:
- Controllers - aici vor fi clasele de tip controller care vor expune API-ul.
- Services - vom tine aici componentele de tip serviciu care for implementa majoritatea logicii aplicatiei.
- Abstractions - aici punem interfetele pentru servicii pe care le vom referentia unde este nevoie in interiorul aplicatiei iar implementarile se vor folosi de aceste abstractizari.
- Implementations - implementarile de servicii vor implementa interfetele mentionate anterior si vor implementa logica aplicatiei dar nu vor fi referentiate in restul proiectului.
- DataTransferObjects - va fi necesar sa definim obiecte vor fi necesare pentru implementarea logii aplicatiei si a muta date dintr-o parte in alta, aceste obiecte sunt DTO-uri (DataTransferObjects).
- Database - vom tine aici obiectele legate la accesul catre baza de date.
- Models - aici vom tine modelele care abstractizeaza tabelele din baza de date.
Initializarea bazei de date
Pentru acest exemplu vom avea o abordare database-first, adica am creat baza de date si vom crea codul necesar pentru a o interoga.
Mai intai va trebui sa adaugam urmatoarele pachete de Nuget in proiect:
- Microsoft.EntityFrameworkCore
- Microsoft.EntityFrameworkCore.Abstractions
- Microsoft.EntityFrameworkCore.Design
- Microsoft.EntityFrameworkCore.Sqlite
Aceste pachete ne vor furniza primitive pentru a interactiona cu baza de date dar si posibilitatea de a ne genera codul pe baza bazei de date. Ca sa facem acest lucru instalati utilitarul in linie de comanda folosind comanda urmatoare:
dotnet tool install --global dotnet-ef --version 8.*
Este posibil sa fie nevoie sa se ruleze dupa instalare urmatoarea comanda de Windows pentru a seta variabila de mediu PATH ca comanda sa poata fi folosita:
setx PATH "%PATH%;C:\Users\<user>\.dotnet\tools"
Si de asemenea sa se instaleze daca nu exista, runtime-ul de .NET 8.
Dupa instalare nu va mai trebui sa o rulati, apoi rulati acesta comanda ajustand numele proiectului si a sursei bazei de date daca este nevoie:
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 gasi dupa rularea cu succes a comenzii clase nou generate in folderul pentru baza de date. Veti gasi o clasa care modsteneste DbContext si o clasa reprezentand tabela creata anterior in baza de date.
Daca vreti sa modificati baza de date faceti acest lucru si rulati din nou comanda anterioara pentru a actualiza codul generat. Nu uitati sa va salvati pe git codul cand faceti modificari majore.
Trebuie sa mentionam ca clasele care mostenesc DbContext vor servi ca clienti pentru baza de date si vom interactiona prin acestea cu baza de date.
Obiecte de transfer
Acum ca avem o baza de la care sa plecam putem adauga logica peste baza de date dar nu pana nu definim niste tipuri de obiecte cu care sa fie folosite pentru apelul logicii. Vom definii urmatoarele obiecte:
namespace MobyLab.Ticketing.DataTransferObjects;
public class UserRecord
{
public int Id { get; set; }
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public string Email { get; set; } = null!;
}
namespace MobyLab.Ticketing.DataTransferObjects;
public class UserAddRecord
{
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public string Email { get; set; } = null!;
}
namespace MobyLab.Ticketing.DataTransferObjects;
public class UserUpdateRecord
{
public int Id { get; set; }
public string FirstName { get; set; } = null!;
public string LastName { get; set; } = null!;
public string Email { get; set; } = null!;
}
Fiecare obiect va avea un rol bine stabilit asa cum sugereaza numele, fiecare va fi folosit pentru extragerea, adaugarea respectiv actualizarea datelor.
Implementarea serviciilor
Peste baza de date putem acum implementa logica. Mai intai ne definim un serviciu care sa faca operatii de adaugare, citire, actualizare si stergere de date. Serviciile sunt componente ce au ca rol implementarea logicii aplicatiei si pot arata oricum aveti nevoie dar fiecare serviciu trebuie sa aiba un rol bine stabilit.
namespace MobyLab.Ticketing.Services.Abstractions;
public interface IUserService
{
public Task AddUser(UserAddRecord user); // Adaugare date
public Task UpdateUser(UserUpdateRecord user); // Modificare date
public Task DeleteUser(int userId); // Stergere dupa ID din baza de date
public Task<UserRecord> GetUser(int userId); // Extragere cu ID din baza de date
public Task<List<UserRecord>> GetUsers(); // Extragere a tuturor datelor din baza de date
}
Interfata o implementam intr-o clasa dupa cum urmeaza, urmariti si comentariile de cod:
namespace MobyLab.Ticketing.Services.Implementations;
// Implementam interfata si avem un constructor primar care foloseste contextul de baza de date care va fi injectat
public class UserService(TicketingDatabaseContext databaseContext) : IUserService
{
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();
}
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();
}
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();
}
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
}
}
Implementarea unui controller
Dupa ce avem serviciul creat putem sa-l expunem intr-un controller pentru a fi apelat prin API-ul nostru. Un controller este o componenta ce va trata cererile HTTP venite de la client, va deserializa cererea in obiectele furnizate medotelor sale si va apela aceste metode pentru a trimite rapunsul serializat catre clienti.
In .NET un controller trebuie sa mosteneasca ControllerBase si sa fie adnotat cu atributele corespunzatoare ca in exemplul de mai jos. Atributele sunt obiecte ce decoreaza clase, metode sau parametri de metode pentru a adauga functionalitati noi acestora. In acest caz sunt folosite pentru a indica cum sa fie apelate metodele la cererea clientilor si cum sa fie extrase datele din cerere.
namespace MobyLab.Ticketing.Controllers;
// Clasa trebuie sa 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
{
// 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 PUT
[HttpPut]
public async Task<IActionResult> UpdateUser([FromBody] UserUpdateRecord user)
{
await userService.UpdateUser(user);
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));
}
// E acelasi lucru ca metoda anterioara doar cu alt tip de date la iesire si fara alti parametri
[HttpGet]
public async Task<ActionResult<UserRecord>> GetUsers()
{
return Ok(await userService.GetUsers());
}
// Aici avem decorat cu un atribut ce indica ca se foloseste o cerere de tip DELETE
[HttpDelete("{userId:int}")]
public async Task<ActionResult<UserRecord>> DeleteUser([FromRoute] int userId)
{
await userService.DeleteUser(userId);
return NoContent();
}
}
Adaugarea in dependency injection
Ca aplicatia sa functioneze trebuie sa adaugati in builder-ul de aplicatie serviciile si contextul de baza de date ce trebuie injectate. De altfel, proiectul este deja initiat in functia Main cu un builder de aplicatie care poate fi extins pentru a cuprinde mai multe componente gestinate de dependency injection.
builder.Services
.AddDbContext<TicketingDatabaseContext>(o => o.UseSqlite("Datasource=../Ticketing.db;")) // Adaugam in DI baza de date SQLite cu sursa sa
.AddScoped<IUserService, UserService>(); // Adaugam serviciul in DI ca sa poata fi folosit in componentele controller
Practic aplicatia este reprezentata de obiectul produs de un IWebApplicationBuilder, aceasta va porni serverul web cu tot ce are nevoie cum ar fi serializarea si deserializarea de date sau destionarea conectiunilor clientilor.
La final puteti porni aplicatia din noi si testa din Swagger apelurile catre server vazand si cum se modifica baza de date.