Před týdnem jsem na blogu zadal programátorskou hádanku, která se skládala z několika otázek. Na mail mi přišlo pár řešení (ano, správně jste poznali, že e-mail byl zakódován v PowerShellu), většina z nich byla správně.
1. Co je špatně na následujícím LINQ dotazu? Jak by se to dalo opravit?
var categoryName = dc.Products.Single(p => p.ProductID == 1).Category.CategoryName;
Console.WriteLine(categoryName);
Tato první otázka byla jednodušší. V zásadě ten dotaz fungoval a vracel to, co se od něj čekalo. Jest zvláštností světa programátorského, že i věc, která funguje, může být úplně blbě. Co je zde za problém?
dc.Products.Single(p => p.ProductID == 1)
Tento výraz vrací objekt typu Product. Ten má vlastnosti reprezentující jednotlivé sloupce tabulky Products v databázi a dále vlastnosti, které reprezentují cizí klíče, v tomto případě například vlastnost Category, která ukazuje na kategorii, do níž je produkt pomocí cizího klíče v databázi přiřazen. A v tom je právě ta potíž.
LINQ dotaz se sestavuje inkrementálně jako jakýsi strom výrazu a do SQL se převádí až ve chvíli, kdy chceme výsledek. Pokud napíšu var p1 = dc.Products.Where(p => p.CategoryID == 3) a pak var p2 = p1.Where(p => p.Visible), pořád se nic nevyhodnocuje. Where jen manipuluje s výrazem, žádné SQL se v tuto chvíli ještě nespouští.
SQL totiž přijde na řadu až ve chvíli, kdy chceme výsledek. Jak se to pozná? Celá tahle LINQ šaráda je možná díky rozhraní IQueryable, které dědí z rozhraní IEnumerable (a to by měl znát každý). Rozhraní IEnumerable deklaruje metodu GetEnumerator, jež vrací speciální objekt, který umožňuje procházet kolekci záznamů. A právě ve chvíli, kdy se zavolá GetEnumerator, se dotaz vyhodnotí, protože v tuto chvíli už nutně potřebujeme výsledky. C# rozhraní IEnumerable podporuje na úrovni syntaxe a foreach cyklus je jen syntaktické zpříjemnění, které zavolá GetEnumerator a pro každý vrácený výsledek provede nějakou akci. Jakmile tedy výraz strčíme do foreach cyklu nebo ho začneme procházet, vygeneruje se SQL a vyhodnotí se.
Copak ale dělá metoda Single? Projde vrácené výsledky a zkontroluje, že dané podmínce vyhovuje právě jeden záznam. No jo, ale na to musí dotaz spustit a spočítat vrácené řádky, že je skutečně jenom jeden. Pokud tedy v databázi bude produkt s ID rovným jedné, metoda uspěje (ID je primární klíč, takže více záznamů to určitě nevrátí) a vrátí objekt typu Product.
No jo, ale my na produktu chceme číst kategorii a to je problém. LINQ standardně nenačítá i navázané záznamy. Respektive načítá, ale jen pokud o nich ví už ve chvíli, kdy provádí SQL dotaz. Jenomže ten už udělal při vyhodnocování metody Single. Teď, aby vrátil kategorii, musí udělat druhý dotaz do databáze a to je ta chyba.
Funguje to, ale na dva dotazy. Přitom tento triviální příklad lze řešit dotazem jedním. Nejhorší je, když takováhle konstrukce je v nějakém cyklu, to pak počty dotazů do databáze rostou. Pokud aplikaci ladíte na vývojářském počítači, kde máte i SQL Server, nevšimnete si toho tak snadno. Avšak v okamžiku, kdy aplikaci a databázi dáte na různé stroje (i když třeba hned vedle sebe), rázem jde výkon do kytek a na každém dotazu záleží, protože po síti to holt není tak rychlé.
Co tedy dělat? Máme dvě možnosti, přičemž obě dvě ve chvíli, kdy se konstruuje první dotaz, říkají, že bude potřeba i vlastnost Category a že se tedy mají natáhnout příslušné záznamy i z tabulky Categories.
První možností je použít třídu System.Data.Linq.DataLoadOptions, kterou říkáme něco ve smyslu “vždycky, když vytahuješ z databáze produkt, natáhni automaticky i jeho kategorii”. S tímhletím opatrně, zvláště pokud používáte instanci datového kontextu opakovaně a pro různé dotazy. Pokud si nedáte pozor a budete “preventivně natahovat všechno”, můžete tím dramaticky zvýšit objem přenášených dat mezi databází a výkonu tím opět spíše ublížíte. Vhodné a správné použití ale výkonu rozhodně pomůže, vše se natáhne na jeden dotaz. Jak na to?
var dlo = new DataLoadOptions();
dlo.LoadWith<Product>(p => p.Category);
dc.LoadOptions = dlo;
var categoryName = dc.Products.Single(p => p.ProductID == 1).Category.CategoryName;
Console.WriteLine(categoryName);
Metodou LoadWith řekneme, že k entitě typu Product chceme natáhnout i vlastnost Category. Tuto metodu můžeme zavolat samozřejmě vícekrát, ale je třeba natahovat jen to, co použijeme, abychom nepřenášeli zbytečně moc dat. Tato metoda je trochu zdlouhavější, ale funguje. Pokud se podíváte třeba aplikací SQL Server Profiler, jak vypadá vygenerovaný dotaz, zjistíte, že je vše tak, jak má být – vygeneruje se jeden dotaz, který k tabulce Products provede JOIN s tabulkou Categories. Na výsledku dostaneme entity Product s již naplněnou vlastností Category.
Druhou možností, která se mi líbí daleko více, je použití funkce Select. Select dělá tzv. projekci – z jedné kolekce hodnot (typicky entit reprezentující řádky tabulky) udělá druhou kolekci stejné velikosti, kde každý prvek této druhé kolekce je příslušný prvek kolekce první prohnaný nějakou funkcí. My si například z kolekce produktů vyzobneme jen to, co nás zajímá, totiž jejich kategorie. Funkce tedy jednoduše dostane produkt a vrátí hodnotu jeho vlastnosti Category. Metoda Single, která zajistí, že dostaneme jen jeden výsledek (pokud ne, vyhodí výjimku), musí být až za Selectem.
var categoryName = dc.Products.Where(p => p.ProductID == 1).Select(p => p.Category).Single().CategoryName;
Console.WriteLine(categoryName);
Vezmou se tedy všechny produkty, které mají ID rovno 1 (což je jen jeden, ale to nás v zásadě zatím nezajímá), a řekneme, že pro každý vrácený produkt chceme na výsledku dostat jen jeho kategorii. Až poté se ujistíme, že takový produkt (resp. taková kategorie) je jenom jedna. V okamžiku vyhodnocování dotazu ale LINQ to SQL ví, že budeme potřebovat kategorie vrácených produktů, a tak je natáhne spolu s těmi produkty. Vygeneruje prakticky to samé, jako předchozí možnost, ale je to na jeden řádek.
Select je mocný díky syntaktické fíčuře anonymních objektů, třeba .Select(p => new { Product = p, Category = p.Category, Supplier = p.Supplier }) vrátí pro každý produkt kolekci objektů s vlastnostmi Product, Category a Supplier obsahujícími samotný produkt, jeho kategorii a dodavatele. A opět, vzhledem k tomu, že dotaz se ještě nevyhodnocuje, cizí klíče může generátor SQL vzít v úvahu a natáhnout je efektivně na jeden dotaz.
Obecná rada tedy zní: Dávejte pozor na funkce a konstrukce, které vyhodnocují dotaz (Single, First, Last, foreach, ToArray atd.; je jich docela dost, stačí se zamyslet, jestli výsledky dotazu potřebují, nebo jestli se dají převést do SQL). Na všechny navázané tabulky, které budete při práci s výsledkem potřebovat, “sáhněte” (třeba Selectem) ještě před první metodou, která dotaz vyhodnocuje. LINQ bude vědět, že tyto navázané entity budete chtít, a do dotazu doplní JOIN, aby se to natáhnulo všechno najednou. Anebo použijte DataLoadOptions, kde to řeknete explicitně.
Na počtu dotazů opravdu záleží, nedávno jsem se vztekal nad jednou webovou aplikací, která na jeden HTTP request udělala asi 300 dotazů do databáze a popovídala si s ní asi 10MB dat. Na lokálu si vývojář ničeho nevšimnul, ale v okamžiku, kdy se databáze dala na jiný stroj, aplikace byla šíleně pomalá. No není divu.
2. Jaká překvapení nám může přichystat tato metoda, která používá Entity Framework? Stane se to i v LINQ to SQL? Má dané chování nějaké opodstatnění?
public static string GetData(int? value)
{
using (var dc = new NorthwindEntities())
{
return dc.Test.First(t => t.Value == value).Data;
}
}
Tato otázka byla formulována dost vágně. Na druhou stranu, ptal jsem se na překvapení. To, že když záznam s daným ID v databázi není, dostaneme po čumáku výjimkou, je snad jasné, to opravdu nepovažuji za překvapení. Rozhodně ale překvapením je, když záznam v databázi je, ale metoda vyhodí výjimku, že tam není.
Jazyk SQL je poměrně stará záležitost a je postaven na jakési matematické teorii. Na fakultě nám vysvětlovali, jak je to strašně kůl, že se to chová takhle, a že je v tom nějaký relační kalkul či co, mě to ale, ať už se na to dívám z té či oné stránky, přijde naprosto hloupé a zvrhlé. Problém je v hodnotě NULL. V SQL, na rozdíl třeba od C#, to neznamená nic, ale nevím. Jakýkoliv výraz, kde je jako jeden z argumentů NULL, má výsledek NULL. Kolik je 5 + NULL? 5 + nevím? No přece NULL! Kolik je MAX(NULL, 2, –7)? Nevím! Místo NULL může být třeba 9, ale taky –5. To by ale dávalo různé výsledky. Takže jak je to opravdu nevíme. To je ještě celkem logické.
Co vrátí SELECT * FROM Test WHERE Value = NULL? Tak počkat, co to je za blbost? Vrať mi záznam, který má nevímjakou hodnotu? Musíme se na to dívat trochu jinak – vrať mi všechny záznamy, kde výraz Value = NULL má hodnotu True. Ale to já přece u žádného řádku nevím, může se to rovnat, ale nemusí. Nevím! Porovnání cokoliv = NULL dá jako výsledek opět NULL. I když tedy existuje řádek v tabulce Test, kde ve sloupci Value je NULL, nebude vrácen, protože podmínka WHERE nebude True. Bude prostě NULL. Dokonce ani NULL se nerovná NULL!
Úchylné, že? V životě jsem neviděl případ, kde by se takovéhle chování k něčemu rozumnému využilo a kdy by si člověk řekl – no super, tohle nemusím nějak speciálně ošetřovat, protože když se ptám na NULL, nechci vrátit nic. Vždycky se akorát každý vztekal, že se to chová takhle, a ne tak, jak bychom čekali.
Vtip je v tom, že jak LINQ to SQL, tak i Entity Framework zachovává tuto pakonvenci (což je zkratka ze slov pakárna a konvence). Do SQL se vygeneruje Value = NULL a ne Value IS NULL. Z hlediska databázového je to správně, z hledicka selského rozumu a opovrhování výmysly akademického prostředí by mi bylo bližší, kdyby NULL se rovnalo NULL, jak je na to člověk zvyklý ze C#. Proč mít v každém jazyce tyto fundamentální koncepce jinak? Akorát to dělá zmatky. Očekával bych, že když jsem v C#, bude se to chovat C#-ovsky. Ale ono ne.
Dávejte tedy pozor, ať už píšete dotazy přímo v SQL, nebo s pomocí LINQ to SQL či Entity Frameworku. Jakmile porovnáváte s NULL, musíte explicitně tento stav ošetřit, protože výsledek jakékoliv operace s NULL je opět NULL – nevím. Porovnání NULL = NULL je prostě NULL a žádné True, aby bylo jasno. Pokud chcete porovnávat normálně, musíte pro NULL použít v SQL operátor IS, anebo LINQ dotaz zkomplikovat a znepřehlednit tu podmínku nějak takto:
return dc.Test.First(t => (t.Value == value) || (value == null && t.Value == null)).Data;
I přes tyto záludnosti nám LINQ to SQL i Entity Framework dovedou velmi usnadnit práci s databází. Je třeba je ale dobře znát, abychom se pak nedivili, proč na nás občas bafne takovéhle překvapení, anebo proč je aplikace ukrutně pomalá. Prvních případů zase tolik není, na druhé je vhodné používat SQL Server Profiler a sledovat, jestli na nějakou operaci negenerujeme zbytečně stovky dotazů. Velkou měrou výkon aplikace zlepší též cacheování natažených hodnot na vhodných místech.
V řešeních ještě mnozí zmiňovali, že by bylo třeba ošetřit situace, kdy se ptáme na záznam, který v databázi není. Metody First, Single atd. v takovém případě vyhazují výjimku. Máme ještě metody FirstOrDefault apod., které v takovém případě vrací null, či výchozí hodnotu datového typu (ptáme-li se na int, vrací 0).
Záleží samozřejmě na situaci. Pokud volám metodu a příslušný záznam tam není, jsou dvě možnosti – buď to je chyba aplikace (metodu bych s tímhle parametrem volat neměl, ale odněkud ji tak volám), anebo se s tím počítá a metoda na to má reagovat. V prvním případě je většinou vhodné použít First či Single a je zbytečné vyhazovat vlastní výjimku, protože pokud záznam není, dostaneme ji tak jako tak a ze stack trace již poznáme, o co jde. Neměli bychom nechávat nahoru propadávat třeba NullReferenceException, protože z ní nepoznáme, co že mělo hodnotu null, ale u známého Sequence contains no elements je jasné, odkud vítr vane.
Pokud se s absencí záznamu má počítat, vždy je výkonově efektivnější použít FirstOrDefault atp., podmínkou zkontrolovat, jestli výsledek má hodnotu nebo ne a podle toho se zachovat (třeba vrátit null nebo něco podobného). Rozhodně je to lepší než nechat Single vyhodit výjimku, tu chytat, a pak z metody vrátit null. Ale záleží na konkrétní situaci.
Vyhodnocení soutěže
Vzhledem k tomu, že mi nedávno kurýr Fedexu dovezl balíček z Microsoftu, který obsahoval 3 vouchery na Visual Studio 2010 Ultimate s MSDN, aby mi zbytečně neležely na stole, výherce této soutěže obdrží jeden z nich.
Z došlých odpovědí měl nejkvalitněji zodpovězené otázky Michael Grafnetter. Gratuluji.