ASP.NET Core webszolgáltatások I.-II.¶
Kiinduló projektek beüzemelése¶
Klónozzuk le a publikus kiinduló projektet a GitHub-ról az alábbi paranccsal:
git clone https://github.com/bmeviauav23/WebApiLab-kiindulo.git
A kiinduló solution két .NET 8 osztálykönyvtárat foglal magába, melyek egy N-rétegű architektúra egy-egy rétegét valósítják meg:
- WebApiLab.Dal: lényegében az Entity Framework gyakorlatok anyagát tartalmazza, ez az adatelérési rétegünk.
- entitásdefiníciók
- kontext, modellkonfigurációval, kezdeti adatokkal
- connection string kezelés és SQL naplózás a korábbi gyakorlatok alapján
- migráció (még) nincs
- WebApiLab.Bll: ezt szánjuk az üzleti logikai rétegnek. Fő feladata, hogy a DAL-ra építve végrehajtsa az
Interfaces
mappában definiált műveleteket.- Interfaces - ez a BLL réteg specifikációja
- Services - ide kerülnek majd az üzleti logikát, ill. az interfészeket megvalósító osztály(ok)
- Dtos - csak később lesz szerepük, egyelőre nincsenek használva
- Exceptions - saját kivétel osztály, egyelőre nincs használva
Adjunk hozzá a solution-höz egy új C# nyelvű web API projektet (ASP.NET Core Web API, nem pedig Web App), a neve legyen WebApiLab.Api
.
A következő dialógusablakban válasszuk ki a .NET 8 opciót. Az extrák közül ne kérjük ezeket: HTTPS, Docker, authentikáció. Viszont hagyjuk bepipálva a Controller és az OpenAPI támogatást. A generált projektből törölhetjük a minta API fájljait, azaz a Weather
kezdetű fájlokat a projekt gyökeréből és a Controllers
mappából.
Adjuk hozzá függőségként:
- a BLL projektet (menu:projekten jobbklikk[Dependencies > Add Project Reference…])
- a Microsoft.EntityFrameworkCore.Tools NuGet csomagot. Válasszunk olyan verziót, ami egyezik a DAL projekt Entity Framework Core függőségének verziójával.
Warning
Olyan csomagoknál, ahol a verziószámozás követi az alap keretrendszer verziószámozását, törekedjünk arra, hogy a csomagok verziói konzisztensek legyenek egymással és a keretrendszer verziójával is - akkor is, ha egyébként a függőségi szabályok engednék a verziók keverését. Ha a projektünk például .NET 8-os keretrendszert használ, akkor az Entity Framework Core és egyéb extra ASP.NET Core csomagok közül is olyan verziót válasszunk, ahol legalább a főverzió egyezik, tehát valamilyen 8.x verziót. Ez nem azt jelenti, hogy az inkonzisztens verziók mindig hibát eredményeznek, inkább a projekt általában stabilabb, ha a főverziók közötti váltást egyszerre, külön migrációs folyamat (példa) keretében végezzük.
Az EF bekötése az ASP.NET Core DI, naplózó, konfiguráló rendszereibe¶
A kontext konfigurálása az EF gyakorlat során - mivel ott egy sima konzol alkalmazást írtunk - a kontext OnConfiguring
függvényében történt.
Mivel az ASP.NET Core projekt beépítetten DI konténert is ad a számunkra, érdemes a kontextet a DI rendszerbe regisztrálni, hogy a projekten belül a modulok/osztályok függőségként tudják használni.
A regisztrálás a legfelső szintű kódban történik (lásd ASP.NET Core bevezető gyakorlatot).
A kontext regisztrálása a legfelső szintű kódban a DI konténerbe:
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
Az EF naplózást az ASP.NET Core naplózó rendszere végzi, amit a kiinduló builder már inicializál, így ezzel kapcsolatban nincs teendőnk.
Viszont egy új kontext konstruktorra lesz szükségünk, ami DbContextOptions<AppDbContext>
-et vár.
A kontext OnConfiguring
-jára pedig nincs szükség, úgyhogy töröljük ki, helyére tegyük az új konstruktort:
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
}
Az Entity Framework gyakorlat alapján hozzunk létre egy új LocalDB adatbázist egy választott névvel, pl. neptun kód, northwind, stb. Az SQL Server Object Explorer-ből a connection string-et lopjuk el. (menu:nyissuk le az adatbáziskapcsolatot[jobbklikk az adatbázison > Properties > a Properties ablakból a Connection String értéke]).
Az appsettings.Development.json-ba vegyük fel a connection string-et és a generált SQL megfigyeléséhez a Microsoft kategóriájú naplók minimum szintjét csökkentsük Information-re.
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Information",
}
}, //vessző bekerült
"ConnectionStrings": {
"DefaultConnection": "<connection string>"
}
}
Escapelt karakterek
Kukac (@
) ilyenkor nem kell a connection string elé, mert ez JSON.
A connection string különleges karaktereit a beillesztés után a VS alapesetben automatikusan escape-eli. Ha az automatikus escape-elés mégsem történik meg, manuálisan kell ezt megtennünk, különben A network-related or instance-specific error occurred while establishing a connection to SQL Server hibát kaphatunk.
Az adatbáziskapcsolatot azért kellhet lenyitni, hogy az SQL Server Object Explorer csatlakozzon is az új adatbázishoz, ezután tudjuk megszerezni a connection stringet.
Adatbázis inicializálása Code-First migrációval¶
Fordítsuk a teljes solution-t, állítsuk be indítandó (startup) projektnek az új Web API projektet (menu:jobbklikk a projekten[Set as Startup Project]). A Package Manager Console-t nyissuk meg, és állítsuk be Default Project-ként a DAL projektet.
.NET 8-ban a migrációk csak akkor működnek rendesen, ha az API projekten kikapcsoljuk az InvariantGlobalization beállítást. Ezt a WebApiLab.Api.csproj fájlban tehetjük meg:
<InvariantGlobalization>false</InvariantGlobalization>
Készíttessük el a migrációt és futtassuk is le:
Add-Migration Init
Update-Database
Projektek a migrációhoz
Fontos, hogy a fenti parancs két projektet ismerjen: azt, amelyikben a kontext van, ill. a kontextet használó futtatható projektet. A VS Package Manager Console-jában futtatva alapértelmezésben az előbbit a Default Project értéke adja meg, utóbbit az indítandó projekt. Továbbá ezeket a projekteket meg lehet adni paraméterként is.
Migráció során lefut a Program.cs is?
Igen, itt mutatkozik meg, hogy a migráció lényegében egy teljes alkalmazásindítást jelent a Program
osztályon keresztül: inicializálódik a DI konténer, a konfigurációs objektum stb.
Ellenőrizzük az SQL Server Object Explorer-ben, hogy rendben lefutott-e a migráció, létrejöttek-e az adatbázis objektumok, feltöltődtek-e a táblák.
EF entitások használata az API felületen¶
Bár architektúra szempontból nem a legszebb, a BLL réteget gyakorlatilag mellőzve közvetlenül is használhatjuk az EF entitásokat a kontrollerek megvalósításánál. Ehhez használhatjuk a Visual Studio Entity Framework-ös Controller sablonjait, amit most csak azért használunk, hogy gyorsan legyen egy működő API felületünk.
Adjunk hozzá egy új Controllert a Controllers mappába (menu:Add[Controller > bal fában Common > API > jobb oldalon API Controller with read/write actions]) EFProductController
néven.
Válasszuk ki az AppDbContext
-t, és a Product
entitást. Az új kontrollerben már láthatjuk is a scaffoldolt CRUD műveleteket.
névterek
Figyeljünk rá, hogy ne a Dtos
névtérből adjuk meg a DTO típust a tényleges entitástípus helyett.
Generálás hibára fut
A generálás során Unable to create an object of type AppDbContext
. hibát kaphatunk. A hiba a kódgeneráló eszközben keresendő, a kapcsolódó GitHub issue-ban találunk egy lehetséges megoldást is a problémára, ami elő van készítve a kiinduló projektben is.
A legenerálódó kontroller már használható is. Állítsuk át a zöld nyíl mellett az indítási konfigurációt a projektnevesre, hogy ne IIS Express induljon és így lássuk a konzolon a naplót. Indítsuk a projektet és próbáljuk például lekérni az összes terméket az api/efproduct címről vagy a Swagger felületről.
Böngésző választása debugoláshoz
Érdemes a zöld nyíl melletti lenyíló menüben olyan böngészőt megadni (Chrome, Firefox), ami értelmes formában meg tudja jeleníteni a nyers JSON adatokat, ha nem Swagger felületről tesztelünk.
Alapértelmezett URL útvonal
Az alapértelmezésben megnyitandó URL útvonalat a projekt tulajdonságok között adhatjuk meg: menu:zöld nyíl melletti legördülő menü[\<Projektnév> Debug Properties]. Ide egy a gyökércímhez képesti relatív útvonalrészt kell beírni. (pl. api/efproduct)
Figyeljük meg, hogy a controller a konstruktorban igényli meg a DI-tól az EF kontextet, amit a szokásos módon osztályváltozóban tárol el.
Köztes réteg alkalmazása¶
A rétegezett architektúra elveit követve gyakori eljárás, hogy a kontroller nem éri el közvetlenül az EF kontextet, hanem csak egy extra rétegen keresztül. A kontroller projekt így függetleníthető az EF modelltől.
Ehhez a megoldáshoz készítsünk külön kontroller változatot. A Controllers mappába hozzunk létre egy kontrollert (menu:Add[Controller > bal fában Common > API > jobb oldalon API Controller with read/write actions]) ProductsController
néven.
A BLL projekt Services mappájába hozzunk létre egy új osztályt ProductService
néven.
Az új osztály kontroller számára nyújtandó funkcióit az IProductService
adja meg.
Implementáljuk ezt az interfészt, a kiinduló implementációt generáltassuk a Visual Studio-val.
Konstruktorban várja a függőségként a kontextet.
A kontext segítségével implementáljuk normálisan a GetProducts
függvényt.
Eager Loading* használatával az egyes termékekhez a kapcsolódó kategóriát és megrendeléseket is adjuk vissza.
public class ProductService : IProductService
{
private readonly AppDbContext _context;
public ProductService(AppDbContext context)
{
_context = context;
}
public List<Product> GetProducts()
{
var products = _context.Products
.Include(p => p.Category)
.Include(p => p.ProductOrders)
.ThenInclude(po => po.Order)
.ToList();
return products;
}
/*Többi függvény generált implementációja*/
}
Injektáljunk IProductService
-t a ProductsController
-be.
private readonly IProductService _productService;
public ProductsController(IProductService productService)
{
_productService = productService;
}
Adjuk meg a DI alrendszernek, hogy hogyan kell egy IProductService
típusú függőséget létrehozni. A legfelső szintű kódba:
builder.Services.AddTransient<IProductService, ProductService>();
A függőséginjektálás úgy működik, hogy a kontrollereket is a központi DI komponens példányosítja, és ilyenkor megvizsgálja a konstruktor paramétereket. Ha a konténerben talál alkalmas beregisztrált osztályt, akkor azt létrehozza és átadja a konstruktornak. Ezt hívjuk konstruktor injektálásnak. Ha a létrehozandó függőségnek is vannak konstruktor paraméterei, akkor azokat is megpróbálja feloldani, így rekurzívan a teljes függőségi objektum hierarchiát le tudja kezelni (ha abban nincs irányított kör). Ezt hívjuk autowiring-nek.
A regisztráció során több lehetőségünk is van. Egyrészt nem kötelező interfészt megadni egy osztály beregisztrálásához, az osztályt önmagában is be lehet regisztrálni, ilyenkor a konstruktorban is osztályként kell elkérni a függőségeket.
Háromféle példányosítási stratégiával regisztrálhatjuk be az osztályainkat:
- Transient: minden egyes injektálás során új példány jön létre
- Scoped: HTTP kérésenként egy példány kerül létrehozásra és a kérésen belül mindenkinek ez lesz injektálva
- Singleton: mindenkinek ugyanaz az egy példány kerül átadásra kéréstől függetlenül
Írjunk új Get()
változatot az eredeti helyett a ProductsController
-be az IProductService
függőséget felhasználva:
[HttpGet]
public IEnumerable<Product> Get()
{
return _productService.GetProducts();
}
Próbáljuk ki (api/products
).
Hibát kapunk, mert a ProductService
lekérdező függvénye eager loading-gal (Include
) navigációs property-ket is kitölt, így könnyen hivatkozási kör jön létre, amit a JSON sorosító alapértelmezésben kivétellel jutalmaz.
A sorosítást a keretrendszer végzi, a kontrollerfüggvény visszatérési értékét sorosítja a HTTP tartalomegyeztetési szabályok szerint.
Böngésző kliens esetén alapesetben a JSON formátum lesz a befutó. Persze a sorosítás ennél közvetlenebbül is konfigurálható, ha szükséges.
A kontrollerek által használt JSON sorosítót konfigurálhatjuk a legfelső szintű kódban, például beállíthatjuk, hogy ha egy objektumot már korábban sorosított, akkor csak hivatkozzon rá és ne sorosítsa újra.
builder.Services.AddControllers() //; törölve
.AddJsonOptions(o => o.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve);
Így már sikerülni fog a sorosítás, egy elég furcsa JSON-t láthatunk, ahol az első elem egy nagyobb objektumgráfot leíró rész, a többi elem pedig csak hivatkozás. Ennek a megoldásnak a hátránya, hogy a kliensoldali sorosítónak is támogatnia kell ezt a sorosítási logikát, a JSON-on belüli kereszthivatkozások kezelését. Emiatt kommentezzük is ki ezt a beállítást, keressünk más megoldást.
DTO osztályok¶
Láthattuk, hogy az entitástípusok közvetlen sorosítása gyakran nehézségekbe ütközik. A modell kifejezetten az EF számára lett megalkotva, illetve hogy a lekérdező műveleteket minél kényelmesebben végezhessük. A kliensoldal számára érdemes külön modellt megalkotni, egy ún. DTO (Data Transfer Object) modellt, ami a kliensoldal igényeit veszi figyelembe: pontosan annyi adatot és olyan szerkezetben tartalmaz, amire a kliensnek szüksége van.
A BLL projektben jelenleg egy nagyon egyszerű DTO modell található a Dtos mappában:
- rekord típusok alkotják a modellt
- nincs benne minden navigációs property, pl.
Category.Products
- nincs benne a kapcsolótáblát reprezentáló entitás
- a termékből közvetlenül elérhetők a megrendelések
A különféle modellek közötti leképezésnél jól jönnek az ún. object mapper-ek, melyek segítenek elkerülni a leképezésnél nagyon gyakori repetitív kódokat, mint amilyen az x.Prop = y.Prop
jellegű propertyérték-másolgatás.
Adjuk hozzá az API és a BLL projekthez az AutoMapper csomagot.
Tranzitív nuget referenciák
Alap esetben elég lenne csak a BLL projekthez felvenni a nuget csomagot, mivel az API projekt hivatkozik a BLL-re, az az ott behivatkozott csomagokat is láthatja (mint ahogy az EF-et is hivatkoztuk fentebbi rétegekből). Az AutoMapper-t viszont explicit be kell hivatkoznunk az API projektbe is, mert a BLL-ben behivatkozott, class library-val kompatibilis csomag nem tartalmazza az ASP.NET Core-hez szükséges konfigurációs függvényeket.
A leképezési konfigurációkat profilokba szervezve adhatjuk meg. Adjunk hozzá a BLL projekthez egy új osztályt WebApiProfile
néven a Dtos mappába.
Az AutoMapper konvenció alapon működik, tehát a DTO-entitás párokon kívül nem kell megadni például egyesével a property- vagy konstruktorparaméter-leképezéseket, ha a nevek alapján a leképezés kikövetkeztethető.
Külön konfigurálásra csak a nem-triviális esetekben van szükség.
using AutoMapper;
namespace WebApiLab.Bll.Dtos;
public class WebApiProfile : Profile
{
public WebApiProfile()
{
CreateMap<Dal.Entities.Product, Product>().ReverseMap();
CreateMap<Dal.Entities.Order, Order>().ReverseMap();
CreateMap<Dal.Entities.Category, Category>().ReverseMap();
}
}
A DI konténerhez adjuk hozzá és konfiguráljuk a leképezési szolgáltatást.
builder.Services.AddAutoMapper(typeof(WebApiProfile));
Típusparaméter
Az AutoMapper az AddAutoMapper
paramétereként megadott típust definiáló szerelvényben fogja a profilt keresni. A konkrét típusnak nincs más jelentősége, nem kell feltétlenül profilnak lenni.
Injektáéjuk be a leképzőt reprezentáló IMapper
típusú objektumot a ProductService
-be.
private readonly AppDbContext _context;
private readonly IMapper _mapper;
public ProductService(AppDbContext context, IMapper mapper)
{
_context = context;
_mapper = mapper;
}
A ProductsController
-ben, az IProductService
-ben és a ProductService
-ben az entitásokra mutató névteret cseréljük ki a DTO-kra mutatóra:
//using WebApiLab.Dal.Entities;
using WebApiLab.Bll.Dtos;
Írjuk át a lekérdezést a ProductService
-ben a leképzőt alkalmazva:
public List<Product> GetProducts()
{
var products = _context.Products
.ProjectTo<Product>(_mapper.ConfigurationProvider)
.ToList();
return products;
}
Hogy ne zavarjanak be a Swaggernek az EFProductController
-ben használt entitás osztályok, töröljük ki a Controllers mappából az EFProductController
-t!
Próbáljuk ismét meghívni böngészőből, figyeljük meg a naplóban, hogy milyen SQL lekérdezés fut le.
Modell rétegek
A többrétegű architektúránál elméletben minden rétegnek külön objektummodellje kellene, hogy legyen DAL: EF entitások, BLL: domain objektumok, Kontroller: DTO-k, viszont ha a domain objektumok nem visznek plusz funkciót a rendszerbe, akkor el szoktuk hagyni.
A DTO leképezést más rétegben is végezhetnénk. Egyes megközelítések szerint a kontroller réteg feladata lenne, azonban, ha az EF lekérdezésekkel összevonva végezzük a leképezést, akkor kiaknázhatjuk a query result shaping előnyeit, azaz csak azt kérdezzük le az adatbázisból, amire a leképezésnek szüksége van. Az AutoMapper ProjectTo
függvénye ráadásul mindezt el is intézi helyettünk a leképezési konfiguráció alapján.
A ProjectTo
metódust felfoghatjuk a továbbiakban egy LINQ-s Select()
operátornak, annyi különbséggel, hogy az AutoMapper generálja azt az Expression
-t, ami alapján előáll majd az eredmény.
ProjectTo
A ProjectTo
speciálisan IQueryable
-en működik.
Ha csak simán memóriabeli objektumok között szeretnénk leképezni, akkor az IMapper
Map<>
függvényét hívjuk.
A memóriabeli leképezésnek hátránya, hogy EF szinten gondoskodnunk kell róla, hogy Include
hívásokkal a leképezéshez szükséges kapcsolódó entitásokat is lekérdezzük.
A ProjectTo
ezt is elintézi helyettünk.
BLL funkciók implementációja¶
Egy elem lekérdezése¶
Valósítsunk meg további interfész által előírt funkciókat a ProductService
osztályban:
public Product GetProduct(int productId)
{
return _context.Products
.ProjectTo<Product>(_mapper.ConfigurationProvider)
.SingleOrDefault(p => p.Id == productId)
?? throw new EntityNotFoundException("Nem található a termék", productId);
}
Szintén a ProjectTo
-t használva, de most a SingleOrDefault
LINQ operátorral kérdezzük le az egyetlen elemet, aminek az Id
mezője egyezik a paraméterben kapott productId
-val.
Ha nem találjuk meg az elemet, akkor egy saját kivételt dobunk, ami majd a későbbiekben lekezelünk.
SingleOrDefault vagy FirstOrDefault
Ha biztosak vagyunk benne, hogy csak egy elemet találhatunk, akkor a SingleOrDefault
használata javasolt, mert ha több elemet talál, akkor kivételt dob.
Ha több elemet is várható egy lekérdezés eredményeként, de biztosak vagyunk benne a követelményeink alapján, hogy az első elem az, amit keresünk, akkor a FirstOrDefault
használata javasolt.
Beszúrás¶
Ez hasonló az EF gyakorlaton látottakhoz, csak itt nem kell legyártanunk az új Product
példányt, paraméterként kapjuk és memóriában leképezzük az enititásra.
A SaveChanges
hívás után a kulcs értéke már ki lesz töltve (adatbázis osztja ki a kulcsot).
public Product InsertProduct(Product newProduct)
{
var efProduct = _mapper.Map<Dal.Entities.Product>(newProduct);
_context.Products.Add(efProduct);
_context.SaveChanges();
return GetProduct(efProduct.Id);
}
Módosítás¶
Módosításhoz lekérdezzük az adott elemet, majd a Map
függvénnyel a DTO-ból az entitásba mappeljük az új adatokat.
Mentés után pedig visszaadjuk a módosított elemet.
public Product UpdateProduct(int productId, Product updatedProduct)
{
var efProduct = _context.Products.SingleOrDefault(p => p.Id == productId)
?? throw new EntityNotFoundException("Nem található a termék", productId);
_mapper.Map(updatedProduct, efProduct);
_context.SaveChanges();
return GetProduct(efProduct.Id);
}
Alternatív módosítás
Alternatíva, hogy a Map
helyett a Attach
függvényt használjuk, amivel az EF kontextusba visszatöltjük az entitást, majd a Entry
függvénnyel jelöljük módosítottként.
Ilyenkor spórolunk egy lekérdezést, de a SaveChanges hibával térhet vissza ha nem létező elemet próbálunk módosítani.
Törlés¶
Hasonlóan az előzőekhez, csak itt a Remove
függvényt hívjuk meg a kontextuson.
public void DeleteProduct(int productId)
{
var efProduct = _context.Products.SingleOrDefault(p => p.Id == productId)
?? throw new EntityNotFoundException("Nem található a termék", productId);
_context.Products.Remove(efProduct);
_context.SaveChanges();
}
Törlés lekérdezés nélkül
Egy trükkel elkerülhetjük, hogy le kelljen kérdezni a törlendő terméket. Az azonosító alapján előállítunk memóriában egy példányt a megfelelő kulccsal, majd Remove
függvénnyel hozzáadjuk a kontexthez. A Remove
törlendőnek jelöli a példányt, de itt is hibaágakra kell készülni, ha nem létező elemet próbálunk törölni.
public void DeleteProduct(int productId)
{
_context.Products.Remove(new Dal.Entities.Product(null!) { Id = productId });
_context.SaveChanges();
}
REST konvenciók alkalmazása¶
A REST megközelítés nem csak átviteli közegnek tekinti a HTTP-t, hanem a protokoll részeit felhasználja, hogy kiegészítő információkat vigyen át. Emiatt előnyös lenne, ha nagyobb ellenőrzésünk lenne a HTTP válasz felett - szerencsére az ASP.NET Core biztosítja ehhez a megfelelő API-kat.
GET - 200 OK¶
Egyik legegyszerűbb ilyen irányelv, hogy a lekérdezések eredményeként, ha megtaláltuk és visszaadtuk a kért adatokat, akkor 200 (OK) HTTP válaszkódot adjunk.
HTTP és REST irányelvek
A HTTP kérést érintő irányelvekről egy jó összefoglaló elérhető itt.
Az eddig megírt Get()
függvényünk most is 200 (OK)-ot ad, ezt le is ellenőrizhetjük a böngészőnk hálózati monitorozó eszközében.
HTTP monitorozás
A HTTP kommunikáció megfigyelésére használhatjuk a böngészők beépített eszközeit, mint amilyen a Firefox Developer Tools, illetve Chrome DevTools. Általában az F12 billentyűvel aktiválhatók. Emellett, ha egy teljesértékű HTTP kliensre van szükségünk, amivel például könnyen tudunk nem csak GET kéréseket küldeni, akkor a Postman és a Fiddler Classic külön telepítendő eszközök ajánlhatók. A Fiddler mint proxy megoldás egy Windows gépen folyó HTTP kommunikáció megfigyelésére is alkalmas.
Első körben a két lekérdező függvényt írjuk át úgy, hogy a HTTP válaszkódokat explicit megadjuk.
A jelenlegi ajánlás ehhez az ActionResult<>
használata. Elég T
-t visszaadnunk a függvényben, automatikusan ActionResult<T>
típussá konvertálódik.
[HttpGet]
public ActionResult<IEnumerable<Product>> Get()
{
return _productService.GetProducts();
}
Írjuk meg ugyanígy a másik Get
függvényt is:
[HttpGet("{id}")]
public ActionResult<Product> Get(int id)
{
return _productService.GetProduct(id);
}
Próbáljuk ki mindkét kontroller függvényt (api/products, api/products/1), ellenőrizzük a státuszkódokat is.
Ami fura, hogy még mindig nem állítottunk explicit státuszkódokat.
A logikánk most még nagyon egyszerű, csak a hibamentes ágat kezeltük, így eddig az ActionResult
alapértelmezései megoldották, hogy 200 (OK)-ot kapjunk.
POST - 201 Created¶
Most viszont következzen egy létrehozó művelet:
[HttpPost]
public ActionResult<Product> Post([FromBody] Product product)
{
var created = _productService.InsertProduct(product);
return CreatedAtAction(nameof(Get), new { id = created.Id }, created);
}
Itt már látszik az ActionResult
haszna.
A konvenciónak megfelelően 201-es kódot akarunk visszaadni.
Ehhez a ControllerBase
ősosztály biztosít segédfüggvényt.
A segédfüggvény olyan ActionResult
leszármazottat ad vissza, ami 201-es kódot szolgáltat a kliensnek.
Másik konvenció, hogy a Location HTTP fejlécben legyen egy URL az új termék lekérdező műveletének meghívásához.
Ezt az URL-t rakjuk össze a CreatedAtAction
paraméterei révén.
Gyakori, hogy a lefele irányú kommunikáció során (kliens felé) bővebb adathalmaz kerül leküldésre, mint amit egy létrehozáskor vagy módosításkor várunk.
Esetünkben is az Orders
és a Category
propertyk létrehozáskor feleslegesek.
Erre a célra jobb egy külön DTO-t létrehozni, ami csak a megfelelő adatokat tartalmazza.
Most ideiglenesen tegyük nullozhatóvá ezt a két propertyt.
public Category? Category { get; init; } //? módosító bekerült
public List<Order>? Orders { get; init; } //? módosító bekerült
Próbáljuk ki a műveletet Swagger felületről. Egy Product
-ot kell felküldenünk. Erre egy példa érték:
{
"Name" : "Pálinka",
"UnitPrice" : 4000,
"ShipmentRegion" : 1,
"CategoryId" : 1
}
Content-Type
Ha Fiddlerből vagy Postmanből tesztelünk, ne felejtsük el a Content-Type fejlécet application/json-re állítani!
Figyeljük meg a kapott választ. A válaszból másoljuk ki a Location fejlécből az URL-t és hívjuk meg böngészőből.
Fiddler Classic példa POST hívásra:
PUT - 200 OK¶
A módosítás a konvenció szerint 200 (OK) választ ad, mert a kliens a módosított erőforrást kapja vissza.
[HttpPut("{id}")]
public ActionResult<Product> Put(int id, [FromBody] Product product)
{
return _productService.UpdateProduct(id, product);
}
PUT és PATCH
PUT mellett a módosításhoz használatos a PATCH is. A PUT konvenció szerint teljes, míg a PATCH részleges felülírásnál használatos. PATCH esetén általában valamilyen patch formátumú adatot küld a kliens, pl. RFC 6902 - JSON Patch. A JSON Patch formátumot jelenleg csak a JSON korábbi sorosító (Newtonsoft.Json) támogatja.
204 No Content
Módosítás esetében a konvenció megengedi, hogy üres törzsű (body) választ adjunk, ilyenkor a válaszkód 204 (No Content).
DELETE - 204 No Content¶
A törlő műveleteknél a konvenció megengedi, hogy üres törzsű (body) választ adjunk, ilyenkor a válaszkód 204 (No Content).
Ilyesfajta válasz előállításához is van segédfüggvény, illetve elég csak az ActionResult
típust megadni visszatérési típusnak:
[HttpDelete("{id}")]
public ActionResult Delete(int id)
{
_productService.DeleteProduct(id);
return NoContent();
}
Próbáljuk kitörölni az újonnan felvett terméket Swaggerből/Fiddler-ből/Postman-ből (DELETE igés kérés az api/products/<új id>
címre, üres törzzsel).
Sikerülnie kell, mert még nincs rá idegen kulcs hivatkozás.
Hibakezelés¶
Eddig főleg csak a hibamentes ágakat (happy path) néztük. A REST konvenciók rendelkeznek arról is, hogy bizonyos hibahelyezetekben milyen HTTP választ illik adni, például ha a kérésben hivatkozott azonosító nem létezik - 404-es hiba a bevett eljárás. Státuszkódok szempontjából a korábban idézett oldal ad segítséget, a válasz törzsében a hibaüzenet szerkezete tekintetében az RFC 7807 ad iránymutatást az ún. Problem Details típusú válaszok bevezetésével. Az ASP.NET Core támogatja a Problem Details válaszokat, és általában automatikusan ilyen válaszokat küld.
400 Bad Request¶
Kezdjük a kliens által küldött nem helyes adatokkal. Ez a hibakód nem összekeverendő a 415-tel, ahol az adat formátuma nem megfelelő (XML vagy JSON): ezt általában nem kell kézzel lekezeljük, mivel ezt az ASP.NET Core megteszi helyettünk. 400-zal olyan hibákat szoktunk lekezelni, ahol a küldött adat formátuma megfelelő, de valamilyen saját validációs logikának nem felel meg a kapott objektum, pl.: egységár nem lehet negatív stb.
Itt használjuk fel a .NET ún. Data Annotation attribútumait, amiket a DTO-kon érvényesíthetünk, és az ASP.NET Core figyelembe vesz a művelet végrehajtása során.
Vegyünk fel a Product
DTO osztályban néhány megkötést attribútumok formájában.
[Required(ErrorMessage = "Product name is required.", AllowEmptyStrings = false)]
public string Name { get; init; } = null!;
[Range(1, int.MaxValue, ErrorMessage = "Unit price must be higher than 0.")]
public int UnitPrice { get; init; }
Próbáljuk ki egy POST /api/Products művelet meghívásával. Paraméterként kiindulhatunk a felület által adott minta JSON-ból, csak töröljük ki a navigációs property-ket és sértsük meg valamelyik (vagy mindkét) fenti szabályt. Egy példa törzs:
{
"Name" : "",
"UnitPrice" : 0,
"ShipmentRegion" : 1,
"CategoryId" : 1
}
A válasz 400-as kód és valami hasonló, RFC 7807-nek megfelelő törzs lesz:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "|2f35d378-4420cbafb80aec04.",
"errors": {
"Name": [
"Product name is required."
],
"UnitPrice": [
"Unit price must be higher than 0."
]
}
}
Összetettebb validáció
Az egyszerűbb eseteknél a Data Annotation attribútumok elegendőek, de ha összetettebb validációra van szükség, akkor érdemes a FluentValidation csomagot használni.
404 Not Found - kontroller szinten¶
Konvenció szerint 404-es hibát kellene adnunk, ha a keresett azonosítóval nem található erőforrás - esetünkben termék.
Jelenleg a ProductService
EntityNotFoundException
-t dob, és amennyiben Development módban futtatjuk az alkalmazást, a cifra hibaoldal jelenik meg, amit a DeveloperExceptionPage middleware generál.
Ha kivesszük a middleware-t (vagy nem Development módban indítjuk, de ekkor gondoskodnunk kell connection string-ről, ami eddig csak a Development konfigurációban volt beállítva), akkor 500-as hibát kapunk vissza.
Exception Shielding
A kezeletlen kivételek általában 500-as hibakód formájában kerülnek vissza a kliensre, mindenfajta egyéb információ nélkül (üres oldalként jelenik meg). Ez a jobbik eset, ahhoz képest, ha a teljes kivételszöveg és stack trace is visszakerülne. Az átlagos felhasználók nem tudják értelmezni, viszont a támadó szándékúaknak értékes információt jelenthet, így ajánlott elkerülni, hogy a kivétel ilyen módon kijusson. Ez az elkerülés az úgynevezett exception shielding technika, és az ASP.NET Core alapértelmezetten alkalmazza.
Legegyszerűbb módszer a kontroller műveletben érvényesíteni a konvenciót egy try-catch blokkal:
[HttpGet("{id}")]
public ActionResult<Product> Get(int id)
{
try
{
return _productService.GetProduct(id);
}
catch (EntityNotFoundException)
{
return NotFound();
}
}
null érték
Alternatív megoldás, hogy a ProductService
egy null
értékkel jelezné, hogy nincs találat.
Ezesetben a fenti kódban a null
értékre kellene vizsgálni, pl. if
szerkezettel.
A későbbiekben látjuk majd, hogy a kivételeket egyszerűbb központi helyen kezelni.
Próbáljuk ki, hogy 404-es státuszkódot és annak megfelelő problem details-t kapunk-e, ha egy nem létező termékazonosítóval hívjuk a fenti műveletet.
Ha saját problem details-t szeretnénk a 404-es kód mellé, akkor kézzel összerakhatjuk és visszaküldhetjük.
catch (EntityNotFoundException)
{
ProblemDetails details= new ProblemDetails
{
Title = "Invalid ID",
Status = StatusCodes.Status404NotFound,
Detail = $"No product with ID {id}"
};
return NotFound(details);
}
Így is próbáljuk ki. Az általunk megadott üzenetet kell visszakapjuk.
404 Not Found - központi hibakezeléssel¶
A rendhagyó válaszok előállításánál előnyös lehet, ha az alacsonyabb rétegekből specifikus kivételeket dobunk, mert ezeket egy központi helyen szisztematikusan átalakíthatjuk konvenciónak megfelelő HTTP válaszokká.
Ezt az ASP.NET Core 8 óta beépítetten meg tudjuk tenni. Erre a célra több kiterjesztési pontja is van a keretrendszernek 1 2, de nekünk most elég a legmagasabb szinten a ProblemDetails
választ testreszabni.
Az AddProblemDetails
konfigurációjában a saját kivételtípusunkat képezzük le 404-es hibakódra és a válasz tartalmát módosítsuk.
builder.Services.AddProblemDetails(options =>
options.CustomizeProblemDetails = context =>
{
if (context.HttpContext.Features.Get<IExceptionHandlerFeature>()?.Error is EntityNotFoundException ex)
{
context.HttpContext.Response.StatusCode = StatusCodes.Status404NotFound;
context.ProblemDetails.Title = "Invalid ID";
context.ProblemDetails.Status = StatusCodes.Status404NotFound;
context.ProblemDetails.Detail = $"No product with ID {ex.Id}";
}
}
);
A middleware pipeline-ba az alábbi beépített middleware-eket kell felvenni a csővezeték elejére:
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
Térjünk vissza a korábbi, nem kivétel-elkapós változatra:
[HttpGet("{id}")]
public ActionResult<Product> Get(int id)
{
return _productService.GetProduct(id);
}
Próbáljuk ki: hasonlóan kell működjön, mint a kontroller szintű változat, de ez általánosabb, bármely műveletből EntityNotFoundException
érkezik, azt kezeli, nem kell minden műveletben megírni a kezelő logikát.
500 Internal Server Error
Beépítetten a fenti megoldás minden egyéb kezeletlen hibára 500-as hibakódot ad vissza, és egy általános ProblemDetails tartalommal tér vissza, ami nem tartalmazza a kivétel szövegét és stack trace-jét.
Az exception shielding elv miatt csak olyan kivételeknél alkalmazzuk, ahol a felhasználók számára hasznos, de nem technikai jellegű információt tartalmaz a kivétel szövege.
Delete idempotens működése
Jelenleg a delete műveletünk hibával tér vissza másodjára, ha 2x egymás után meghívnánk azonos azonosítóval.
Egy másik megközelítés szerint a DELETE műveletnek idempotensnek kellene lennie, tehát egymás után többször végrehajtva is sikeres eredményt kell kapjunk. Ez azt is jelenti, hogy 404-es hiba helyet 204 No Content státuszkódot kell küldenünk akkor is, ha nem található adott ID-val entitás. Ezt a jelenlegi kódban egyszerűen implementálhatjuk, hogy nem dobunk kivételt a megfelelő ágban.
Próbáljuk ki, hogy az egy termék lekérdezésénél, a módosításnál és a törlésnél is a rossz azonosító egységesen működik-e: 404-es hibát ad vissza, a Problem Details-ben a kivétel szövegével.
Aszinkron műveletek¶
Aszinkron műveletek alkalmazásával hatékonyságjavulást érhetünk el: nem feltétlenül az egyes műveleteink lesznek gyorsabbak, hanem időegység alatt több műveletet tudunk kiszolgálni.
Ennek oka, hogy az await
-nél (például egy adatbázis művelet elküldésekor) a várakozási idejére történő kiugrásnál, ha vissza tudunk ugrálni egészen az ASP.NET engine szintjéig, akkor a végrehajtó környezet a kiszolgáló szálat a várakozás idejére más kérés kiszolgálására felhasználhatja.
Aszinkronitás végigvezetése a kódban
Ökölszabály, hogy ha elköteleztük magunkat az aszinkronitás mellett, akkor ha megoldható, az aszinkronitást vezessük végig a kontrollertől az adatbázis művelet végrehajtásáig minden rétegben.
Ha egy API-nak van TAP jellegű változata, akkor azt részesítsük előnyben (pl. SaveChanges
helyett SaveChangesAsync
).
Ha aszinkronból szinkronba váltunk, csökkentjük a hatékonyságot, rosszabb esetben deadlock-ot is előidézhetünk.
Vezessük végig az aszinkronitást egy művelet teljes végrehajtásán:
public Task<Product> UpdateProductAsync(int productId, Product updatedProduct);
public async Task<Product> UpdateProductAsync(int productId, Product updatedProduct)
{
var efProduct = await _context.Products.SingleOrDefaultAsync(p => p.Id == productId)
?? throw new EntityNotFoundException("Nem található a termék", productId);
_mapper.Map(updatedProduct, efProduct);
await _context.SaveChangesAsync();
return await GetProductAsync(efProduct.Id);
}
[HttpPut("{id}")]
public async Task<ActionResult<Product>> Put(int id, [FromBody] Product product)
{
return await _productService.UpdateProductAsync(id, product);
}
Async végződés és kontroller műveletek
Az Async végződés alkalmazása kontroller műveletek nevében jelenleg nem ajánlott, mert könnyen hibákba futhatunk.
Próbáljuk ki, hogy továbbra is működik a módosított művelet.
Végállapot¶
A végállapot elérhető a kapcsolódó GitHub repo megoldas ágán is.