Aszinkron kérések és DTO-k (példa WebAPI alkalmazás)¶
Az alábbi jegyzet a kiszolgáló oldali aszinkronitást és a DTO-k (Data Transfer Object-ek) használatát mutatja be egy példán keresztül. Az alkalmazás egy ASP.NET Core WebApi kiszolgáló Entity Framework adateléréssel, amely egy webshop kosár kezelését valósítja meg.
Szerző
Az alábbi jegyzet Zergi Máté munkája.
Aszinkronitás¶
A webes alkalmazásaink legnagyobb része valamilyen módon kommunikál adatbázissal. Erről a kommunikációról nem feltételezhetjük, hogy:
- elérhető az adatbázis szerver,
- gyors a kapcsolat a kliens és a kiszolgáló között,
- az adatokat az adatbázis szerver gyorsan állítja elő.
Ezért fel kell készülni arra, hogy a kiszolgálás során kért adatokra várni kell. Erre kínál megoldást az aszinkronitás, amely hatékonyan használja ki a kiszolgáló erőforrásait - például nem foglal a web kiszolgálón erőforrást, amíg az az adatbázisra vár.
Aszinkronitás és párhuzamosság
Az aszinkronitás nem azonos a párhuzamossággal. Egy webes kiszolgáló a bejövő kéréseket párhuzamosan szolgálja ki. Az aszinkronitás egy kérés kiszolgálása során alkalmazott módszer, amely az I/O műveletek (pl. adatbázis elérés, fájl elérés, hálózati kommunikáció) során hatékonyan kezeli a kiszolgáló szálait.
Az alkalmazás adatbázis modellje¶
Az alkalmazásunk a tárgy minta adatbázisához hasonló, de egyszerűbb adatbázissal dolgozik, az ER diagramja itt látható:
Az egyszerűség kedvéért a kosarakban a UserID nem idegen kulcsként szerepel egy Users táblára, hanem egy statikus, 1-es ID-jű felhasználóval dolgozunk. A valóságban több felhasználó van, így a UserID idegen kulcs lenne.
A Products tábla reprezentál termékeket, a Manufacturers tábla gyártókat (ez gyorsíthatja a gyártónkként szűrést), az OrderItems pedig kosárban levő termékeket.
Kiszolgáló alkalmazás felépítése¶
ASP.NET Core WebApi és Entity Framework segítségével szeretnénk a fenti adatbázis adatait REST-kompatibilis szolgáltatáson keresztül elérhetővé tenni. Az adatbázis modellezéséhez, valamint a WebAPI kiszolgáláshoz alapvetően három (esetleg négy) feladatot kell elvégezni:
- Adatbázis táblát/táblákat modellező osztály/osztályok létrehozása,
- Adatbázis kontextus létrehozása,
- Data Transfer Object létrehozása, ha szükség van az adatok átalakítására a kliens számára,
- WebAPI Kontroller létrehozása
Menjünk végig ezeken a lépéseken!
Adatbázist modellező osztályok létrehozása¶
Az adatbázist modellező osztályokat ASP.NET Core platformon egy Models mappába szokás szervezni, és azon belül az általuk reprezentált tábla nevével létrehozni őket.
A Products táblát reprezentáló C# osztály:
namespace WebshopApi.Models
{
public class Product
{
public string Name { get; set; }
public int ManufacturerID { get; set; }
public int Price { get; set; }
public int ID { get; set; }
}
}
A Manufacturers táblát reprezentáló C# osztály:
namespace WebshopApi.Models
{
public class Manufacturer
{
public string Name { get; set; }
public int ID { get; set; }
}
}
Az OrderItems táblát reprezentáló C# osztály:
namespace WebshopApi.Models
{
public class OrderItem
{
public int ID { get; set; }
public int ProductID { get; set; }
public int CartID { get; set; }
public int Pieces { get; set; }
}
}
A Carts táblát reprezentáló C# osztály:
namespace WebshopApi.Models
{
public class Cart
{
public int ID { get; set; }
public int UserID { get; set; }
}
}
Megfigyelhető, hogy ezek az osztályok csak adatok tárolására alkalmasak, amiket az egyes C# property-ken keresztül tudunk majd elérni.
Adatbázis kontextus létrehozása¶
Miután létrehoztuk a táblákat modellező osztályokat, el tudjuk készíteni az adatbázis egészét modellező DbContext osztályt. Ehhez a saját osztályunkat le kell származtatni az Entity Framework Core DbContext osztályából.
namespace WebshopApi.Models
{
public class WebshopContext : DbContext
{
public WebshopContext(DbContextOptions<WebshopContext> options)
: base(options)
{
}
public DbSet<Product> Products { get; set; }
public DbSet<Manufacturer> Manufacturers { get; set; }
public DbSet<Cart> Carts { get; set; }
public DbSet<OrderItem> OrderItems { get; set; }
}
}
Az adatbázisban szereplő egyes táblákat DbSet-ek definiálásával tudjuk elérni. A DbSet-eknek meg kell adni, hogy milyen entitásokat tárolnak (pl. a DbSet<Products>
egy Products
entitásokat tároló DbSet
), valamint a nevüket.
A konstruktorban szereplő DbContextOptions
-t a kontextus konfigurálására lehet használni. Erre egy példa, az alkalmazás Startup
osztályában (lásd REST API & ASP.NET Web API gyakorlat) a következő konfiguráció:
public class Startup
{
// ...
// Ezt a metódust a runtime hívja meg. Ezt a metódust használjuk servicek DI konténerhez való hozzáadásához
public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<WebshopContext>(opt =>
opt.UseSqlServer(@"Data Source=(localdb)\mssqllocaldb;Initial Catalog=Webshop;Integrated Security=True"));
// ...
}
}
Data Transfer Object létrehozása¶
Az előző két lépés eredményeként rendelkezésünkre áll az adatbázis és tábláinak leképzése. Következő lépésként gondoljunk bele abba, hogyan néz ki egy webshopban a kosár: egy kosárban egyszerre több termék jelenik meg. Tehát míg az OrderItem
osztályunk egy megrendelt terméket tud reprezentálni, a teljes kosarat ilyen elemek listája jelenti. Ez a termék lista egy ún. Data Transfer Object használatával írható le: ez egy olyan osztály, ami a kliens számára használható adatot gyűjt össze az adatbázisból.
Definíció: Data Transfer Object
Egy olyan objektum, ami adatot szállít futó alkalmazások (itt: kliens és szerver) között.
DTO-k használatával nem csak kényelmesebb lesz az adatok küldése, hanem gyorsítjuk is az alkalmazásunkat:
- Csak olyan adatot küldünk a kliensnek, amire annak szüksége van.
- Továbbá, a DTO-ba összegyűjthetjük az összetartozó adatokat és egyben küldjük el a kliensnek.
Gondoljuk végig, hogyan érdemes összegyűjteni az adatokat, ha a kliens a kosárban levő összes terméket, azok darabszámát, valamint az összes termék darabszámát szeretnénk megjeleníteni?
-
Az
OrderItem
-ben a kliens szempontjából felesleges adat aCartID
, valamint azID
. Ezeket kiszűrve és a többi property-t meghagyva már más osztállyal dolgozunk, mint azOrderItem
, viszont még mindig csak egy kosár cikket reprezentálunk. Az így keletkezett osztály neve legyenCartItem
. -
Vegyük észre, hogy a
CartItem
-ben tároltProduct
termékreprezentációra ugyanez a gondolatmenet érvényes, azzal az eltéréssel, hogy aProduct
osztályt bővíteni kell a Manufacturer nevével, aManufacturerID
helyett (hiszen a felhasználói felületen ezt a nevet akarjuk megjeleníteni). Tehát hozzunk létre egy újProduct
osztályt is, és tároljuk el ezt aCartItem
-ben! -
Ilyen
CartItem
objektumokat gyűjtsünk össze egy listába, valamint számoljuk, hogy hány termék van összesen a kosárban. Legyen ez egyUserCart
osztály. IlyenUserCart
példányokat küldünk majd a kliensnek.
A DTO-kat érdemes külön szervezni az adatbázis entitásoktól. Szervezzük őket egy DTOs mappába! A fenti megoldással továbbmenve, nézzük meg, hogyan fognak kinézni ezek az osztályok!
CartItem
osztály, ami kiszűri az OrderItem
-ből a kliens számára felesleges adatokat, valamint egy kliens oldali, kosárban levő árucikket reprezentál:
namespace WebshopApi.DTOs
{
public class CartItem
{
public Product Product { get; set; } // Olyan product, amiben már nem ManufacturerID szerepel
public int Amount { get; set; } // A rendelt mennyiség
}
}
És a hozzá tartozó Product osztály:
namespace WebshopApi.DTOs
{
public class Product
{
public string ProductName { get; set; } // A termék neve, pl AB123 Full HD TV
public string Manufacturer { get; set; } // A termék gyártójának !!neve!!, pl BMETV
public int Price { get; set; } // A termék ára
public int ID { get; set; } // A termék azonosítója
}
}
Miért szerepel itt az ID?
Jogosan merülhet fel a kérdés, hogy itt miért szerepel az ID
. Végiggondolva, egy kosárelem áll egy termékből, valamint abból, hogy hány darab van az adott termékből. Megjelenítésnél ezért a kosár elemet a termék azonosítja, hiszen a termék azonosítója alapján tudjuk megjeleníteni a termékhez tartozó esetleges információkat. Ezt a problémát meg lehetne oldani úgy is, hogy a kosár elemnek van azonosítója, és a terméknek nincs, esetleg mindkettőnek lehet, ebben a példában a termék azonosítóját használjuk.
A UserCart
osztály, ami összegyűjti az egyes rendelt cikkeket, és számolja, hogy hány terméket rendeltünk összesen:
namespace WebshopApi.DTOs
{
public class UserCart
{
public List <CartItem> CartPieces { get; set; }
public int NumberOfItems { get; set; }
}
}
Azzal, hogy a CartItem
-eket listában tároljuk, könnyen kezelhetővé válnak, mind szerver, mind kliens oldalon, hiszen a kifele menő JSON objektumban a lista majd egy tömbként fog szerepelni, amin könnyen végig lehet iterálni.
Ezt a UserCart
objektumot küldjük a kliens felé a kontrollerből, miután összegyűjtöttük a hozzá tartozó cikkeket és összeszámoltuk, hogy összesen hány termék van a kosárban.
Controller osztály létrehozása¶
A kontroller osztályunkat érdemes egy Controllers mappába szervezni, majd a REST API & ASP.NET Web API gyakorlaton alapján hozzunk létre egy Controller osztályt. Ez tartalmazni fog egy WebshopContext
-et, valamint a HTTP kérések kiszolgálásához létrehozott végpontokat.
Itt szembesülünk először az aszinkronitással. Nézzük egy példán keresztül:
A GET kérés, ami lekérdezi az összes kosarat:
[HttpGet]
public async Task<ActionResult<IEnumerable<Cart>>> GetCarts()
{
var carts = await _context.Carts.ToListAsync();
return carts;
}
Figyeljük meg a függvény deklarációjában az async
kulcsszót és a Task
típust, valamint a függvény törzsében a "párját", az await
-et. Ezekre async-await-ként szoktunk hivatkozni. Értelmezzük tehát:
- A függvény
Cart
példányok listáját,IEnumerable<Cart>
ad vissza, - Amelyet a WebAPI kontrollernek megfelelően egy
ActionResult
-ba csomagolunk, - És az egészet még egy
Task
-ba is tesszük. Ez az aszinkronitás miatt van.
A fentiek mindegyike más miatt kell, de így, együtt adják a teljes megoldást. Nézzük ebből is az aszinkronitást, azaz a Task
típust és az await
kulcsszót. Ezzel a definícióval egy ún. "promise"-t adunk vissza (más nyelvekben szokták így hívni), amely egy jövőben elvégzendő feladat eredményét (fogja) tartalmazni.
Miért jó ez? Azért, mert a kontroller metódus elvégzése így lesz aszinkron. Amikor a rendszer egy await
utasításhoz ér, a szál, ami eddig a feldolgozást végezte, abbahagyja ennek a kérésnek a kiszolgálását, és egy másik kérés végrehajtásával folytatja a munkát. Miért is? Mert az await
"mögötti" feladatról tudjuk, hogy időigényes: az adatbázisra és a hálózatra várunk. Feleslegesen várakoztatnánk a kiszolgáló szálat, ha az itt "megállna" és bevárná az eredményt. Ehelyett a feladatot kiadjuk egy háttér rendszernek (az operációs rendszer és a .NET aszinkron I/O alrendszerének - ebbe azonban nem megyünk bele), és arról kérünk értesítést, amikor az itt várt végeredmény elkészült. Amint ez megtörténik, a korábban felfüggesztett kérés kiszolgálása folytatódik tovább.
Másként megfogalmazva a mi alkalmazásunk kiszolgáló szálai mindig aktívan munkát fognak végezni, nem várakoznak. A várakozás helyett más feladatok végrehajtására lesznek képesek. Ez összességében azt jelenti, hogy kevesebb operációs rendszer szálat veszünk igénybe és ezzel több kérést tudunk kiszolgálni. Ettől az alkalmazásunk hatékonyabb lesz.
Az előbb bemutatott függvényt szintaktikailag tovább egyszerűsíthetjük ha elhagyjuk a lokális változót és közvetlenül visszaadjuk a Task
eredményt. Funkcionálisan az alábbi implementáció megegyezik a fentivel, azonban a magyarázatot a fenti részletesebb kiírás jobban szemlélteti.
[HttpGet]
public Task<ActionResult<IEnumerable<Carts>>> GetCarts()
{
return _context.Carts.ToListAsync(); // nincs await, és a deklarációban sincs async
}
A ***Async függvények
A tárgyban eddig látott, lekérdezéseket kiértékelő függvényeknek (ToList
, First
, All
, Find
, stb...) mind van ...Async
párja, és hasonlóan kell őket használni a szinkron párjukhoz. Ezen függvények az alapjai az aszinkron működének.
A működés részteleibe ennél tovább nem megyünk. Annyit jegyezzünk meg, hogy ahhoz, hogy a kontrollerünk aszinkron legyen, kell, hogy legyen "alatta" (itt: az Entity Framework-ben) támogatás az aszinkronitásra.
Nézzünk egy bonyolultabb példát: egy kosár tartalmának összegyűjtése, majd elküldése, a kosár rekord megkeresése FindAsync
segítségével:
[HttpGet("{id}")]
public async Task<ActionResult<UserCart>> GetCart(int id)
{
// aszinkron kérés az id által azonosított kosár megtalálására
var cartRecord = await _context.Carts.FindAsync(id);
if (cartRecord == null)
return NotFound();
// lekérdezés felépítése
var productsquery =
from p1 in _context.Products
join m1 in _context.Manufacturers on p1.ManufacturerID equals m1.ID
select new Product(p1.ID, m1.Name, p1.Name, p1.Price); // felépítjük a DTO Product-okat
// aszinkron kiértékelés
var products = await productsquery.ToListAsync().ConfigureAwait(false);
// aszinkron kérés az order itemsekre
var orderitemsquery = from oi in _context.OrderItems
where oi.CartID == cartRecord.ID
select oi;
var orderitems = await orderitemsquery.ToListAsync().ConfigureAwait(false);
// a továbbiakban szinkron a művelet, mert már memóriában van a két eredmény
// Keressük meg azokat a termékeket, amik a kosárban vannak,
// joinoljuk az OrderItems rekordokkal, majd hozzunk létre a kettőből egy CartItem DTO objektumot
var cartitems = products.Join(orderitems, p => p.ID, oi => oi.ProductID,
(p, v) => new CartItem(p, v.Pieces)).ToList();
// UserCart DTO előállítása
return new UserCart()
{
CartPieces = cartitems,
NumberOfItems = cartitems.Count()
}
}
Figyeljük meg, hogy minden aszinkron műveletnél await
-et kell használni! Viszont amikor már végeztünk azokkal a műveletekkel, amiknél az adatbázishoz kell fordulni, már használhatunk szinkron műveleteket!
A ConfigureAwait
metódus
A ConfigureAwait(false)
további teljesítmény optimalizálásra ad lehetőséget. Ezzel a hívással azt jelezzük, hogy az await
-elt eredmény megérkezése után bármely szál folyathatja a további munkát, nem szükséges ugyanazon szálnak folytatnia a kiszolgálást, amely kezdte. Kiszolgáló oldali alkalmazások során ez általában a helyes viselkedés, de nem minden aszinkron világra igaz ez (például UI szálak használata esetén számít). Erről részletesebben lásd: https://devblogs.microsoft.com/dotnet/configureawait-faq/.
Végül nézzünk egy példát a FirstOrDefaultAsync
-re, valamint egy kliens felől érkező POST kérésre, amiben a kliens által küldött három azonosító alapján megkeressük a megfelelő adatbázis rekordokat, és frissítjük őket (darabszám növelés/csökkentés):
// Modell osztály a kérés argumentumainak kezelésére
namespace WebshopApi.Models
{
public class PostCartItemArgs
{
public int CartId { get; set; }
public int ProductId { get; set; }
public int Amount { get; set; }
}
}
//////////////////////////////////////////////////////////////////////////////
// Controller osztályban levő POST kérés
[HttpPost]
public async Task<IActionResult> PostCartItem([FromBody] PostCartItemArgs data)
{
// kosár megkeresése az ID alapján
var cart = await _context.Carts.FindAsync(data.CartId).ConfigureAwait(false);
if (cart == null)
return NotFound();
// keressük meg azokat az order itemeket, amik ebbe a kosárba tartoznak, és ezt a terméket tárolják
var orderitemquery = from oi in _context.OrderItems
where (oi.CartID == data.Id && oi.ProductID == data.ProductId)
select oi;
// FirstOrDefault, hogy ha nem talál, az orderitem értéke null legyen
var orderitem = await orderitemquery.FirstOrDefaultAsync().ConfigureAwait(false);
if (orderitem == null)
{
// Ha eddig nem szerepelt a rendelt termékek között, új OrderItems létrehozása
_context.OrderItems.Add(new OrderItems { CartID = data.Id, Amount = data.Amount, ProductID = data.ProductId });
}
else
{
// Ha már van ilyen termék a rendelésben
orderitem.Amount += data.Amount;
// Ha 0-ra csökkent a termékből rendelt darabszám, akkor töröljük a rekordot
if (orderitem.Amount == 0)
_context.OrderItems.Remove(orderitem);
}
await _context.SaveChangesAsync(); // ez is await-elt, hiszen megint az adatbázishoz fordulunk
return NoContent();
}
Ki várja meg a Task
eredményét, ha az csak a jövőben fog elkészülni?
Minden async
függvényt valahol await
-elni kell. Jelen esetben a kontroller metódusunkat az ASP.NET Core keretrendszer fogja meghívni, és az fogja "megvárni" az eredményt a JSON sorosítás előtt.
Teljes példakód¶
A teljes példakód (pár apró eltéréssel, valamint egy másik kontrollerrel) megtalálható a https://github.com/mzergi/WebshopApi/ repository-ban.