V minulé části tohoto seriálu jsme si vytvořili základní infrastrukturu naší aplikace – nastavili jsme membership providera, vytvořili přihlašovací stránku, nastavili oprávnění pro stránku Default.aspx. Dnes napíšeme metody, které z databáze vytáhnou informace o dané koncertní síni, vytáhnou seznam a stav obsazenosti sedadel a provedou rezervaci sedadla pro daného uživatele.
Práce s daty
Odbočka: Častým nešvarem vývojářů bývá, že se snaží vše namatlat do codebehindu stránky. Bohužel ne vše, co funguje, je taky správně a vhodně vyřešeno. Aby byl kód v budoucnu udržovatelný, je velmi dobré dodržovat dva principy – DRY a SRP (ono je jich ve skutečnosti víc, ale tyto nám zatím stačí).
DRY je zkratka z “Don’t Repeat Yourself” (neopakuj se), což v praxi znamená vyhnout se opakujícímu se kódu - pokud to samé dělám v aplikaci víckrát, měl bych to umístit do nějaké metody. V případě, že bude potřeba v tomto kódu udělat změnu, nebudu to muset dohledávat na padesáti místech.
SRP je zkratka “Single Responsibility Principle” (princip zodpovědnosti za jednu věc), který v kostce říká, že každá třída by měla dělat jen jednu věc, která by měla být poměrně jasně vymezena. Naše stránka je taky jen třída a rozhodně by neměla dělat všechno (tahat data z databáze a zároveň vytvářet řádky a buňky tabulky).
Minimálně tahání dat z databáze by bylo vhodné do samostatné třídy, protože tohle nejsme schopni udělat deklarativně. Stránka má data prezentovat, čili se bude starat o generování buněk tabulky a řešení, co se stane, když na ně někdo klikne. Ale samotné získávání dat a změny stavu sedadel oddělíme.
Protože jsem si již odvykl psát SQL dotazy ručně a na všechno používám ORM (nástroj, který zpřístupní obsah databáziepomocí objektů), použijeme pro práci s daty LINQ to SQL. LINQ to SQL je technologie, která se objevila s příchodem .NET Frameworku 3.5. Velmi podobnou (trochu robustnější) technologií je Entity Framework, pokud se naučíte jednu z nich, tu druhou zvládnete hravě.
Pokud jste LINQ to SQL ještě nikdy před tím neviděli, nezoufejte, je to snadné. Do projektu přidejte novou položku typu LINQ to SQL Classes a pojmenujte ji Opera.
V designeru, který se objeví, přidáme tabulky z naší databáze – v okně Server Explorer se připojte k databázi aplikace, rozbalte složku Tables a naše tabulky přetáhněte do většího bílého prostoru:
Po uložení nám tento designer vygeneruje několik tříd. První třídou je tzv. datový kontext, bude se jmenovat OperaDataContext (protože náš soubor se jmenuje Opera.DBML). Tento kontext obsahuje kolekce Halls, Seats a Users, které reprezentují tabulky v naší databázi, a pár dalších věcí, které nás ale nemusí tolik zajímat.
Dále se vygeneruje třída Hall s vlastnostmi HallId, SeatRows a SeatColumns, která navíc ještě bude obsahovat kolekci Seats, protože mezi tabulkami Halls a Seats v databázi máme vazbu 1:N. Obdobně se vygeneruje třída Seat, která bude mít vlastnosti odpovídající jednotlivým sloupcům této tabulky, a dále ještě vlastnost Hall typu Hall (objekt koncertního sálu z tabulky Halls) a vlastnost User typu User, která reprezentuje přiřazeného uživatele, je-li jaký.
Jak tyto třídy vypadají můžete vidět v souboru Opera.designer.cs, ale nic v něm neměňte, protože Visual Studio vám tento soubor při nejbližší příležitosti přepíše.
Pointa celého tohoto řešení je, že místo toho, abych ručně vytvářel SqlConnection a SqlCommand, psal ručně SELECT dotaz a procházel vrácené řádky přes SqlDataReader, vytvořím instanci třídy OperaDataContext a sáhnu si do kolekce Seats, což mi vrátí rovnou kolekci objektů typu Seat. A protože to má v názvu LINQ, znamená to, že nad touto kolekcí můžeme dělat dotazy, takže pokud v tabulce bude sedadel pět milionů (extra super mega cool velká opera), nebudeme je muset načítat všechny, ale sáhneme si jen na záznamy, která nás zajímají.
V tomto článku budu z LINQu používat jen základy, které pochopí každý, pokud vás zajímá, jak dělat pokročilejší dotazy, navštivte stránku 101 LINQ Samples.
Třída pro práci s databází
Nyní si napíšeme vlastní třídu SeatManager, která bude interně používat LINQ to SQL třídy. Od aplikace budeme potřebovat tři věci – vytáhnout detail koncertní síně, vytáhnout seznam všech sedadel a jejich stav, a zarezervovat sedadlo konkrétnímu uživateli.
Základní prototyp této třídy by mohl vypadat nějak takto:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Data.Linq;
/// <summary>
/// Třída, která se stará o zamlouvání sedadel a zjišťování jejich stavu
/// </summary>
public class SeatManager
{
/// <summary>
/// Vrací instanci datového kontextu
/// </summary>
private OperaDataContext CreateDataContext()
{
return new OperaDataContext();
}
/// <summary>
/// Vrátí objekt reprezentující koncertní sál s daným ID
/// </summary>
public Hall GetHall(int hallId)
{
}
/// <summary>
/// Vrátí seznam sedadel a velikost mřížky
/// </summary>
public IEnumerable<Seat> GetSeats(int hallId)
{
}
/// <summary>
/// Zkusí rezervovat dané sedadlo pro daného uživatele a vrátí, zda-li se to podařilo
/// </summary>
public bool TryReserveSeat(int seatId, int userId)
{
}
}
Třída obsahuje jednu metodu, která jen vytvoří nový datový kontext a vrátí jej. Možná vám to může připadat jako zbytečnost, ale ze zkušenosti tuto factory metodu doporučuji – nikdy nevíte, kdy budete potřebovat každé instanci datovém kontextu něco nastavit (například parametrizovat connection string atd.), a hledat v aplikaci každé new OperaDataContext je otravné.
Dále zde máme tři metody – GetHall, GetSeats a TryReserveSeat. Z komentáře je jasné, co dělají, první vrací objekz Hall s daným HallId, druhá vrací kolekci sedadel v dané hale (opět podle daného HallId), a třetí zkusí zarezervovat sedadlo pro daného uživatele.
Dotazování nad daty v LINQ to SQL
Kód první metody může vypadat například takto:
/// <summary>
/// Vrátí objekt reprezentující koncertní sál s daným ID
/// </summary>
public Hall GetHall(int hallId)
{
using (var dc = CreateDataContext())
{
// vytáhnout sál s daným ID
return dc.Halls.Single(h => h.HallId == hallId);
}
}
Datový kontext vždy používejte pokud možno s konstrukcí using, která zajistí, že jakmile ji opustíte (jakkoliv, ať už výjimkou, klauzulí return, break nebo třeba goto), na kontextu se zavolá Dispose, čímž se uzavře příslušné spojení do databáze. Nechávat spojení otevřená není dobrý nápad, ona se sice za čas uvolní sama (až Garbage Collector usoudí, že je ten správný čas), ale do té doby by mohla dojít a aplikace by házela výjimky.
Uvnitř using bloku máme jen jeden řádek. To, co je za klauzulí return, je jedna z forem LINQ dotazu – dc.Halls je kolekce všech koncertních sálů v databázi. Na této kolekci voláme metodu Single, která z ní vybere právě jeden záznam (vrací výsledek typu Hall). To, co je uvnitř jako parametr, totiž h => h.HallId == hallId, je tzv. lambda funkce.
Ano, teď se možná chytáte za hlavu – takže Single je funkce, která dostává jako parametr funkci? Přesně tak. LINQ je koncept, který se inspiroval tzv. funkcionálním programováním, kde je toto běžné. Ve funkcionálních jazycích de facto neexistují proměnné, vše je funkce (to, co v ”normálních jazycích” považujeme za proměnnou, je ve funkcionálním jazyce funkce bez parametrů, která vrací pořád tu samou hodnotu).
Ta lambda funkce, o které byla řeč, je jen zkrácený zápis následujícího kódu. Schválně jsem žlutě označil to, co je pro deklaraci této funkce podstatné:
bool MojeFunkce(Hall h)
{
return h.HallId == hallId;
}
A co že to dělá? Metodu Single spouštíme na kolekci objektů typu Hall. Single má za úkol objekty vyfiltrovat a vrátit ten jediný, který vyhovuje dané podmínce (všimněte si, že naše funkce vrátí true jen pokud je ID testované haly, která je v proměnné h). Naše funkce vrátí true jen tehdy, pokud je hodnota v proměnné hallId rovna HallId objektu v proměnné h. Je to prostě jen v C# zapsaná podmínka WHERE HallId = @p0, kde místo p0 se dosadí hodnota z proměnné hallId.
Obdobně bychom mohli vyfiltrovat haly podle toho, jestli mají více než 4 řady sedadel, podmínka by vypadala h => h.SeatRows > 4. Nebo haly, které mají víc než 500 sedadel – h => h.SeatRows * h.SeatColumns > 500. Proměnná h není nikde deklarována – její typ se uhodne podle kolekce, na které metodu Single spouštíme – bude typu Hall. Jméno proměnné h si zvolíme sami tak, že jej napíšeme před šipku =>, a samotná proměnná h platí jen ve výrazu za šipkou. V lambda funkcích ale můžeme používat proměnné z metody, uvnitř které lambda funkce je, tomuto principu se říká closure, to je třeba případ proměnné hallId.
Jinak lambda funkci obecně můžeme třeba přiřadit do proměnné, nebo ji zavolat (stačí za ni napsat otevřenou závorku a předat jí parametry, tak jako normální funkci).
var mojeFunkce = (int a, int b) => a + b;
var dvacetjedna = mojeFunkce(15, 6);
Uvnitř metody Single si můžete představit foreach cyklus, které projde všechny položky kolekce, na níž ji spouštíme, pro každou zavolá naši funkci, a pokud ta funkce vrátí true, tak je to hledaný prvek – metoda Single navíc zkontroluje, že je jen jeden, a na konci jej vrátí (výsledek je tedy typu Hall).
Takhle by to fungovalo, pokud by dc.Halls byla obyčejná kolekce. Ale ona je to chytrá kolekce, která implementuje rozhraní IQueryable, díky kterému zjistí, že se na ní zavolala metoda Single s nějakou podmínkou. Tu podmínku takovou složitou magií přeloží do SQL a vyplivne dotaz SELECT * FROM Halls WHERE HallId = @p0, přičemž do parametru p0 dosadí hodnotu, kterou máme v proměnné hallId. Díky této složité magii druhý řádek v naší ukázce kódu spustí dotaz proti databázi a do proměnné hall uloží objekt, který bude reprezentovat daný řádek z databáze.
Pokud na IQueryable kolekci voláme metody, kolekce si jen “pamatuje, co se na ní volalo”, ve skutečnosti si staví jakýsi strom dotazu (tzv. expression tree). Jakmile na ni zavoláme foreach cyklus, tak celý tento strom převede do SQL, spustí, a vrátí výsledky.
Kód metody GetSeats bude vypadat takto:
/// <summary>
/// Vrátí seznam sedadel a velikost mřížky
/// </summary>
public IEnumerable<Seat> GetSeats(int hallId)
{
using (var dc = CreateDataContext())
{
// vrátit sedadla v sále a natáhnout rovnou i jejich uživatele
var dlo = new DataLoadOptions();
dlo.LoadWith<Seat>(s => s.User);
dc.LoadOptions = dlo;
// zavolat ToList, aby se dotaz provedl
return dc.Seats.Where(s => s.HallId == hallId).ToList();
}
}
První tři řádky uvnitř bloku s DataLoadOptions prozatím ignorujme a podívejme se na poslední řádek return dc.Seats.Where(s => s.HallId == hallId).ToList().
Where je velmi podobné jako Single, ale jeho výsledkem není jeden objekt, ale opět kolekce (počítá s tím, že podmínku splňuje více objektů). A není to ledajaká kolekce, je to opět IQueryable, takže na tento dotaz můžeme řetězit další operace, jako třeba další Where, OrderBy atd.
Už víme, že IQueryable kolekce si jen pamatují, co jsme s nimi dělali, a samotný dotaz spustí až ve chvíli, kdy na ní zavoláme foreach. To musíme udělat předtím, než zrušíme datový kontext, takže na té kolekci zavoláme metodu ToList. Metoda ToList uvnitř foreach cyklus obsahuje, takže v okamžiku, kdy ji na kolekci zavoláme, dotaz se vyhodnotí a my dostaneme klasickou kolekci List<Seat>, která obsahuje vrácené výsledky.
V našem případě se na databázi spustí něco jako SELECT * FROM Seats WHERE HallId = @p0, kde p0 bude hodnota vlastnosti HallId našeho objektu. Kdybychom ale dotaz psali ručně, vidíme, že budeme ještě potřebovat uživatelské jméno toho, kdo si dané místo zaregistroval. Tímto dotazem ale dostaneme jen jejich UserId. Pokud bychom chtěli i uživatelské jméno a dotaz jsme si psali sami, přidali bychom JOIN na tabulku Users, takže by dotaz byl SELECT * FROM Seats LEFT JOIN Users ON Seats.UserId = Users.UserId WHERE HallId = @p0.
A to právě zařizují ty tři řádky, které jsme ignorovali – vlastnost LoadOptions datového kontextu určuje instrukce pro přednačítání tabulek. Metodou LoadWith jsme řekli, že vždycky, když natáhneme objekt typu Hall, budeme k němu chtít načíst i jeho vlastnost User, tedy vazbu na tabulku Users.
Pokud toto nastavení použijeme, do seznamu se nám vrátí kolekce objektů Seat, jejichž vlastnost User bude naplněná příslušným objektem User, anebo v ní bude null, pokud sedadlo zabrané není.
Pokud bychom toto přednačtení neudělali a sáhli na vlastnost User, LINQ to SQL by neměl jinou možnost než pro ni spustit separátní SQL dotaz a co to může udělat je vidět například v tomto článku. V praxi by to vypadalo, že bychom nespustili 2 SQL dotazy, ale 26 dotazů – jeden na halu a jeden na seznam sedadel (ty děláme vždycky), a pak pro každé sedadlo něco jako SELECT * FROM Users WHERE UserId = @p0. Pomocí výše uvedených 3 řádků se zeptáme na halu a následně na její sedadla a zároveň jejich uživatele, což bude daleko rychlejší.
Navíc pokud bychom na vlastnost User sáhli až ve chvíli, kdy je datový kontext zrušen (mimo using blok), dostali bychom po čumáku výjimkou, protože příslušné databázové spojení by bylo již uzavřené.
Při psaní LINQ dotazů si tedy musíme pořádně rozmyslet, které všechny navázané záznamy budeme potřebovat, a natáhnout je, například pomocí DataLoadOptions.
Rezervace místa
Kód druhé metody bude mít za úkol najít sedadlo s daným ID, zkontrolovat, jestli již není zabrané, a pokud není, zabrat jej (nastavit UserId na předanou hodnotu a uložit změnu).
Vnitřek metody TryReserveSeat bude vypadat takto:
/// <summary>
/// Zkusí rezervovat dané sedadlo pro daného uživatele a vrátí, zda-li se to podařilo
/// </summary>
public bool TryReserveSeat(int seatId, string username)
{
using (var dc = CreateDataContext())
{
// vytáhnout sedadlo s daným ID
Seat seat = dc.Seats.Single(s => s.SeatId == seatId);
// pokud je již zabrané, vrátit false
if (seat.UserId != null)
return false;
// zjistit ID uživatele a zabrat sedadlo
var user = dc.Users.Single(u => u.UserName == username);
seat.UserId = user.UserId;
// uložit změny
try
{
dc.SubmitChanges();
}
catch (ChangeConflictException)
{
// změnu provedl v tu samou chvíli někdo jiný
return false;
}
// povedlo se
return true;
}
}
Na prvním řádku uvnitř bloku získáme stejně jako v předchozím případě sedadlo s daným ID. Podíváme se na jeho vlastnost UserId (pozor, vlastnost User by spustila SQL dotaz do tabulky Users, nenastavili jsme přednačítání, protože ho nepotřebujeme). Protože UserId v databázi je typu INT, ale může obsahovat NULL, v C# je reprezentována typem int?, což je zkratka za Nullable<int>. Do tohoto typu můžeme uložit buď null (které je jinak určeno pouze pro referenční typy), nebo číslo.
Pokud tam není null, tak sedadlo již má někdo jiný. V opačném případě do UserId nastavíme ID, které jsme dostali jako parametr. Zatím se ale nic neděje, žádný SQL dotaz se nespouští – datový kontext si jen poznamená, že tenhle objekt se změnil. Až ve chvíli, kdy si zavoláme SubmitChanges, všechny změněné objekty daného kontextu se uloží do databáze (vygenerují se UPDATE příkazy). Vše se provede v transakci, takže buď se všechono povede, nebo databáze vůbec nebude změněna, o to se stará databázový server.
Tady nastává ještě jedno úskalí – vzhledem k tomu, že webové prostředí je silně konkurenční, dva uživatelé mohou rezervovat to samé místo v tu samou chvíli nebo velmi krátce po sobě. Vzhledem k tomu, že každý HTTP požadavek se ve webové aplikaci zpracovává v separátním vlákně, může se tato metoda provádět zároveň víckrát. Obě dvě vlákna by si tedy mohla z databáze vytáhnout sedadlo, které je neobsazené, a naráz jej změnit.
Existují dva přístupy, jak se s tím vypořádat. První řešení je pštrosí způsob – strčíme hlavu do písku a neřešíme to, poslední zarezervovaný vyhrává. Druhé řešení (tzv. optimistic concurrency), které používá LINQ to SQL, vyhodí výjimku, pokud se objekt změnil a již neodpovídá tomu, co jsme načetli. My tuto výjimku zachytíme (jedná se o ChangeConflictException) a řekneme, že sedadlo jsme nezarezervovali. To je řešení, které funguje a je dobré se jej držet. Někdy stačí pštrosí způsob, ale u rezervace míst je dobré zajistit, že když se mi nezobrazila chyba, sedadlo je moje.
To by tedy bylo krátké intro do LINQu, určitě o něm v nejbližší době vyjde jeden nebo více článků, jelikož se jedná o technologii, která není zdaleka tak používaná, jak by býti mohla.
Pro tento díl je to vše, příště si ukážeme, jak ve stránce s použitím naší třídy vygenerovat tabulku sedadel, jak buňku tabulky rozšířit o událost Click a jak to celé dát dohromady.