Mojí hlavní motivací při vytváření NotORM bylo nabídnout jednoduché API, které pokud možno nedovolí vytváření neefektivních dotazů (při správně navržené databázi). Často jsem totiž vídal postup, kdy se v každém průchodu cyklem kladly pořád dokola ty stejné dotazy, jen s různými parametry. Výsledek tak mohl vypadat třeba takhle (možná poznáte titulní stránku tohoto blogu):
SELECT * FROM clanek LIMIT 5;
SELECT * FROM autor WHERE id = 11;
SELECT * FROM skupina WHERE id = 21;
SELECT COUNT(*) FROM komentar WHERE clanek_id = 1;
SELECT * FROM autor WHERE id = 11;
SELECT * FROM skupina WHERE id = 22;
SELECT COUNT(*) FROM komentar WHERE clanek_id = 2;
SELECT * FROM autor WHERE id = 11;
SELECT * FROM skupina WHERE id = 23;
SELECT COUNT(*) FROM komentar WHERE clanek_id = 3;
SELECT * FROM autor WHERE id = 11;
SELECT * FROM skupina WHERE id = 22;
SELECT COUNT(*) FROM komentar WHERE clanek_id = 4;
SELECT * FROM autor WHERE id = 11;
SELECT * FROM skupina WHERE id = 21;
SELECT COUNT(*) FROM komentar WHERE clanek_id = 5;
SELECT * FROM skoleni;
SELECT * FROM skoleni_termin WHERE skoleni = 31;
SELECT SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id = 51;
SELECT * FROM skoleni_termin WHERE skoleni = 32;
SELECT SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id = 52;
SELECT * FROM skoleni_termin WHERE skoleni = 33;
SELECT SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id = 53;
SELECT * FROM skoleni_termin WHERE skoleni = 34;
SELECT SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id = 54;
SELECT * FROM skoleni_termin WHERE skoleni = 35;
SELECT SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id = 55;
SELECT * FROM skoleni_termin WHERE skoleni = 36;
SELECT SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id = 56;
SELECT SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id = 57;
SELECT COUNT(*) FROM clanek;
SELECT * FROM prace_platnost WHERE NOW() BETWEEN platne_od AND platne_do;
SELECT * FROM prace WHERE id = 41;
SELECT * FROM prace WHERE id = 42;
SELECT * FROM skupina;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 21;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 22;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 23;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 24;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 25;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 26;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 27;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 28;
SELECT COUNT(*) FROM clanek WHERE skupina_id = 29;
SELECT * FROM clanek WHERE vyber = 1;
Na to, jak je stránka jednoduchá, se položilo příšerných 45 dotazů. Řešit se to dá dvěma způsoby:
V NotORM jsem proto zvolil druhý způsob, jehož výsledkem jsou tyto dotazy:
SELECT id, autor_id, skupina_id, nadpis, clanek FROM clanek LIMIT 5;
SELECT id, jmeno FROM autor WHERE id IN (11);
SELECT id, nazev FROM skupina WHERE id IN (21, 22, 23);
SELECT clanek_id, COUNT(*) FROM komentar WHERE clanek_id IN (1, 2, 3, 4, 5) GROUP BY clanek_id;
SELECT id, nazev FROM skoleni;
SELECT skoleni_id, datum FROM skoleni_termin WHERE skoleni_id IN (31, 32, 33, 34, 35);
SELECT skoleni_termin_id, SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id IN (51, 52, 53, 54, 55, 56) GROUP BY skoleni_termin_id;
SELECT COUNT(*) FROM clanek;
SELECT id, prace_id FROM prace_platnost WHERE NOW() BETWEEN platne_od AND platne_do;
SELECT id, popis, url FROM prace WHERE id IN (41, 42);
SELECT id, nazev FROM skupina;
SELECT skupina_id, COUNT(*) FROM clanek WHERE skupina_id IN (21, 22, 23, 24, 25, 26, 27) GROUP BY skupina_id;
SELECT id, nadpis FROM clanek WHERE vyber = 1;
Počet dotazů jsme zredukovali na 13, sloučením vztahů 1:1 (v tomto případě to jsou dotazy s GROUP BY) bychom počet dotazů mohli za cenu mírného zesložitění kódu zredukovat na 10.
Při postupu využívajícím spojování tabulek bychom se dostali nejlépe na 6 dotazů, níž už ale ne, protože těch 6 skupin (v kódu oddělených prázdným řádkem) spolu nijak nesouvisí.
Jak to dále vylepšit? Stačí vyjít z pozorování, že pro rychlost často není hlavní počet pokládaných dotazů, ale počet round-tripů. Obvykle to jde ruku v ruce, ale při využití funkce mysqli_multi_query nebo v PDO to platit nemusí – během každé komunikace lze položit dotazů několik. Takže to, co nás dosud brzdilo (počet nezávislých dotazů), můžeme využít ve svůj prospěch:
Počet dotazů tedy zůstane stejný, ale počet komunikací s databází se zredukuje na maximální počet na sobě závislých dotazů (v příkladech znázorněný odsazením):
SELECT id, autor_id, skupina_id, nadpis, clanek FROM clanek LIMIT 5; SELECT id, nazev FROM skoleni; SELECT COUNT(*) FROM clanek; SELECT id, prace_id FROM prace_platnost WHERE NOW() BETWEEN platne_od AND platne_do; SELECT id, nazev FROM skupina; SELECT id, nadpis FROM clanek WHERE vyber = 1;
SELECT id, jmeno FROM autor WHERE id IN (11); SELECT id, nazev FROM skupina WHERE id IN (21, 22, 23); SELECT clanek_id, COUNT(*) FROM komentar WHERE clanek_id IN (1, 2, 3, 4, 5) GROUP BY clanek_id; SELECT skoleni_id, datum FROM skoleni_termin WHERE skoleni_id IN (31, 32, 33, 34, 35, 36); SELECT id, popis, url FROM prace WHERE id IN (41, 42); SELECT skupina_id, COUNT(*) FROM clanek WHERE skupina_id IN (21, 22, 23, 24, 25, 26, 27) GROUP BY skupina_id;
SELECT skoleni_termin_id, SUM(pocet) FROM skoleni_prihlaska WHERE skoleni_termin_id IN (51, 52, 53, 54, 55, 56) GROUP BY skoleni_termin_id;
Skončili jsme na třech komunikacích s databází, při využití spojování tabulek bychom se mohli snadno dostat i na dvojku, za cenu opakovaného přenášení stejných dat dokonce i na jedinou komunikaci s databází.
Jak bude vypadat API, které něco takového umožní? Potřebujeme zaregistrovat dotaz, který se provede později spolu s ostatními a po jeho provedení se spustí nějaká obsluha (která si může zaregistrovat další dotazy). Inspirovat se lze u node.js. (Používám databázi ze srovnání NotORM s Doctrine 2.)
<?php NotORM::then($db->article[174], function ($article) { echo "<h1>" . htmlspecialchars($article["title"]) . "</h1>\n"; $article->article_tag()->then(function ($article_tags) { // zkratka za NotORM::then($article->article_tag(), ...) echo "<ul>\n"; foreach ($article_tags as $article_tag) { NotORM::then($article_tag->tag, function ($tag) { echo "<li>" . htmlspecialchars($tag["name"]) . "</li>\n"; }); } echo "</ul>\n"; }); }); ?>
NotORM 2 vyvíjím v branchi then.
Psát anonymní funkce je otrava. A to ani nemluvím o PHP starším než 5.3, kde by psát kód v tomto API byla ještě větší otrava. O něco by to vylepšily anonymní bloky, ty ale v oficiálním PHP nejsou. Skoro by to chtělo tento kód generovat…
Prostředí, které nám dovoluje generovat kód, je vždy po ruce a je snadno rozšiřitelné, jsou v Nette šablony. S nimi se callbacků můžeme úplně zbavit a kód psát skoro stejně jako u sekvenčního zpracování:
{* $article dostaneme *}
<h1>{$article['title']}</h1>
<ul n:inner-foraside="$article->article_tag() as $article_tag">
<li n:with="$article_tag->tag as $tag">{$tag['name']}</li>
</ul>
<p n:with="$article->category as $category">{$category['name']}</p>
Definice maker je jednoduchá:
<?php $latte = new LatteFilter; $set = new MacroSet($latte->compiler); $set->addMacro('with', function ($node, $writer) { $then = "function("; if (preg_match('~(.*\S)(\s+)as(\s+)(.+)~s', $node->args, $match)) { $then = "$match[1],$match[2]$then$match[4]"; } elseif ($node->args) { throw new CompileException("Missing ' as ' in {with} macro."); } return "NotORM::then($then) {"; }, "});"); $set->addMacro('foraside', function ($node, $writer) { if (!preg_match('~(.*\S)(\s+)as(\s+)(.+)~s', $node->args, $match)) { throw new CompileException("Missing ' as ' in {foraside} macro."); } return "$match[1]->thenForeach(function ($match[4]) {"; }, "});"); ?>
Celá myšlenka jde zobecnit na všechno, co se vyplatí provádět v dávce – zásadní přínos to bude mít také např. u Memcache.
Udělal jsem si laboratorní experiment, který by mohl ukázat, že celá rošáda s multidotazy nemá smysl, protože jejich režie je příliš vysoká. Cílem bylo získat osm řádek ze stejné tabulky na vzdáleném serveru (tedy ne localhost) podle indexu. Záměrně jsem vybral takto jednoduchou úlohu, abych mohl porovnat čas multidotazů s jednoduššími dotazy, které by v obecném případě (dotazy do různých tabulek) ani nešly použít. Výsledky mě mile překvapily:
| 0.01414 s | mysqli_query(" = ") x 8 |
|---|---|
| 0.00199 s | mysqli_query(" IN ()") |
| 0.00256 s | mysqli_query(" UNION ALL ") |
| 0.00237 s | mysqli_multi_query("; ") |
Multidotazy se zpracovaly rychleji než UNION a skoro stejně rychle jako jeden dotaz využívající IN, především ale řádově rychleji než osm jednotlivých dotazů.