NotORM 2

Školení, která pořádám

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:

  1. Využít spojování tabulek: To je tradiční způsob, který dovoluje počet dotazů podstatně zredukovat. Má ale dva hlavní problémy:
    • Dopředu musím říct, které tabulky budu potřebovat, což jde proti požadavku na jednoduché API.
    • Stejná data se přenáší opakovaně (v tomto příkladě například jméno autora a název skupiny). To sice obvykle není velký problém, v některých případech by ale objem přenášených dat mohl nekontrolovaně narůst.
  2. Sdružit stejné typy dotazů do jednoho: Počet dotazů bude konstantní, nezávislý na velikosti procházených množin. Každá data (kromě primárních klíčů) se navíc přenesou právě jednou. Výsledek možná nebude optimální, každopádně ale máme jistotu, že nám nikam „neustřelí“ – ani kvůli počtu kladených dotazů (jako u naivního řešení), ani kvůli přenášeným objemům dat (jako u spojování tabulek). Zásadní výhodou je také to, že dopředu nemusíme vědět, z jakých tabulek budeme data potřebovat a dotáhneme si je až v případě potřeby.

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í.

Více dotazů najednou

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:

  1. V prvním kole položíme všechny dotazy, které na ničem nezávisí.
  2. V druhém kole položíme všechny dotazy, které závisí na výsledcích prvních dotazů.
  3. A tak dále.

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í.

NotORM 2

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…

Nette Latte

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.

Má to vůbec smysl?

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 smysqli_query(" = ") x 8
0.00199 smysqli_query(" IN ()")
0.00256 smysqli_query(" UNION ALL ")
0.00237 smysqli_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ů.

Jakub Vrána, Seznámení s oblastí, 22.2.2012, diskuse: 24 (nové: 0)

Diskuse

Filip Procházka (@HosipLan):

S kamarádem jsme se divili, co to je za šílenosti s těmi anonymními funkcemi, ale s Latte to vypadá velice použitelně.

OT: Jakube, oprav prosím svůj highlighter, někdo by se mohl divit, kde se v Nette vzala třída "NetteLatteCompileException", díky :)

ikona Jakub Vrána OpenID:

OT: Díky za upozornění, opravil jsem to.

Tharos:

Ahoj Jakube,

za sebe bych měl do NotORM 2 jednu věc k zamyšlení. Po nějaké době denní práce s NotORMem jsem se myslím poměrně sžil s jeho API a jediné, co mi na něm aktuálně vadí, je špatná čitelnost hlavní myšlenky u složitějších dotazů. Trpí zkrátka stejným neduhem, jako samotné SQL. Když někde vidím následující zápis:

<?php
$pages
= $NotORM->page('id', $NotORM->page()->select('MAX(id) id')->group('parent, url');
?>

přemýšlím, co se tam vůbec získává. Oproti zápisu:

<?php
$pages
= $pagesfacade->findHeadRevisions();
?>

který je na první pohled jasný. Jedním řešením je mít podobné fasády, ale zase pro jednoduché dotazy je to zbytečné a mohlo by to vést k tomu, že k tabulce page by se jednou přistupovalo přímo přes NotORM a jindy přes nějakou fasádu, což by ani nebylo konzistentní.

Napadlo mě tedy, zda nezavést nějaký systém „pojmenovaných dotazů“? Zkrátka aby se nějaká sekvence fluent volání mohla pojmenovat a kdykoliv snadno vyvolat přes instanci NotORMu.

Jsem přesvědčen, že by to velmi zčitelnilo kód využívající NotORM. Hlavně pak u košatějších databázových návrhů, kde se každý druhý dotaz děje přes více tabulek.

ikona Jakub Vrána OpenID:

„Pojmenované dotazy“ už existují, ve většině databází se jim říká pohledy. Sám jsem je s NotORM občas používal.

Jinak tato logika podle mě jednoznačně patří do Modelu.

Opravdový odborník :-):

Pojmenovaný dotaz může být třeba: „najdi uživatele podle e-mailové adresy“ nebo „najdi článek X v jazyce Y a pokud není, tak v libovolné jazyce, který je dostupný“. Kvůli tomu se pohled v databázi opravdu dělat nebude – nebyl by k ničemu a obsahoval by totéž, co tabulka – smysl těch pojmenovaných dotazů je v těch parametrech a jejich přiřazení k určitým sloupcům a vložení do WHERE podmínky.

A abychom (mj.) tyto podmínky nemuseli sestavovat pokaždé znovu a měli je definované na jednom místě, použijeme pojmenovaný dotaz – ten patří do aplikace resp. její datové vrstvy. Např. v JPA na to máme pěknou anotaci @NamedQuery

Tharos:

Pohledy se na to, co mám na mysli, bohužel moc dobře nehodí.

Jednak proto, protože se nedají parametrizovat. (Respektive dají, ale jsou to všechno otřesné hacky přes uložené procedury. A když už jsou do hry vtaženy uložené procedury, lze se obejít úplně bez pohledů a volat rovnou procedury...)

A dále také proto, protože já bych si přál, aby výsledkem volání toho „pojmenovaného dotazu“ byla instance NotORM_Result, se kterou se dá dále pracovat „NotORM like“ stylem (třeba dodatečně přistupovat k souvisejícím tabulkám). Tohle by u výsledku získaného přes pohled nešlo, anebo se pletu?

Samozřejmě tato logika patří do modelu, ale tam přece patří celý NotORM (rozuměj třída NotORM).

Mějme use case, kdy k jedné tabulce přistupuješ přes několik triviálních dotazů a pak k ní jednou potřebuješ přistoupit nějak složitěji. Jinak řečeno tak, že vyjádřeno v NotORM API z toho zápisu není na první pohled jasná hlavní myšlenka.

Pak můžeš:

a) Smířit se s tím, že tam bude jeden nečitelný dotaz a jiní programátoři budou bádat, o co se tam asi autor pokouší. Tomu se chci právě vyhnout.

b) Postavit nad tím další vrstvu (fasádu), která to jedno volání pojmenuje. Pak je ale nehezké, že z nějakého controlleru k té tabulce typicky budeš přistupovat někde přímo přes instanci NotORMu a někde přes nějakou fasádu. Není to konzistentní a u kódu v duchu DI to navíc znamená i to, že tomu controlleru budeš muset předávat instanci NotORMu a i té fasády.

c) Postavit nad tím další vrstvu, která zapouzdří všechny dotazy. To je čisté, v controlleru se bude pracovat pouze s tou fasádou, ale je to zbytečně pracné a u jednoduchých volání vyloženě kontraproduktivní.

d) Mít možnost v NotORMu pojmenovat nějakou sekvceni volání a k ní pak přistupovat přímo přes instanci NotORMu. Řeší to zmíněný problém a netrpí to žádným z výše uvedených nedostatků. Jenom to momentálně není součástí NotORM.

e) Vyřešit to nějak úplně jinak, ale nenapadá mě jak...

Filip Procházka (@HosipLan):

Co takhle využít naše milované Nette? https://gist.github.com/1922814 (je to jenom nápad, NotORM nepoužívám)

Tharos:

Což o to, já to u sebe naimplementovné mám, dá se to opravdu vyřešit na pár řádcích kódu... Ale přišlo by mi fajn, kdyby něco podobného bylo v NotORMu nativně.

bronislav klucka:

Dobry den,
Je videt ze vam pobyt v zemi vychazejiciho slunce svedci
Jen tak dale.

ikona Jakub Vrána OpenID:

Já ale nejsem v Číně!

Ugo:

Nevím kde jste, ale země vycházejícího slunce většinou bývá Japonsko ... jestli jste tam, nechcete mi sem poslat nějakou elektroniku? :D

bronislav klucka:

Pan Vrana je v Thajsku. Ale je to blizko Japonska tak se snad
neurazi.

ikona Jakub Vrána OpenID:

No jo, fakt. Ale nejsem ani v Japonsku ani v Thajsku. Asi jste si mě spletli s filmovou postavou: http://www.csfd.cz/film/278293-czech-made-man/.

bronislav klucka:

Na Nette psali ze jste v Thajsku?
Vy uz jste doma?

ikona Jakub Vrána OpenID:

Kde přesně? Rád si to přečtu a uvedu na pravou míru.

Franta:

Nechceš udělat ještě test se spojováním tabulek?

ikona Jakub Vrána OpenID:

Ne, spojování tabulek je pro mě mrtvé. Kromě ojedinělých případů, kdy je potřeba omezující podmínku nebo třídění sestavit z dat ve více tabulkách, pro to nemám žádné uplatnění. A i v těchto případech se dá často použít poddotaz.

Diskobolos:

"spojování tabulek je pro mě mrtvé" - Jakube, Jakube :-(

Franta:

BTW: ještě hlásím jednu chybu: když jsem vkládal předchozí komentář, tak mne to přesměrovalo na OpenID poskytovatele (který už mimochodem neexistuje), přestože na své stránce mám delegování zakomentované (resp. bylo, teď už jsem ho smazal úplně). tzn. jako by nebylo – neměl by se jím nikdo řídit a přeskočit to jako jakýkoli jiný HTML/XML komentář.

ikona Jakub Vrána OpenID:

Je to tak, jedná se o nedostatek v použité knihovně. Poslal jsem merge request: https://gitorious.org/lightopenid/lightop…_requests/15.

Franta:

Díky :-)

BTW: zjistil jsem to tak, že mne to přesměrovalo na stránku s nějakou šmejďárnou, tak si říkám co to, kouknu na doménu a tam openid.cz – tento poskytovatel už bohužel neexistuje a pokud jste tam někdo měli účet, už ho nemáte (tady se ukazuje, jak je delegování a nezávislost na poskytovateli důležité)

Schmutzka:

Ad spojování tabulek a Nette, hodily by se mi nějaké příklady pro vytažení dat z více tabulek (které bych jinak spojoval pomocí JOIN) a možnost jejich předzpracování (např. výpočty a jejich sumy či násobení odeslaným číslem).

Jak by se to udělalo v Nette?

Stejně tak mi chybí doporučená aplikace Notorm v Nette šablonách. Sám jsem naučený používat modelovou vrstvu a např. nevím, jestli je správné kombinovat Notorm v šabloně a Notorm v modelové vrstvě.

Tedy nějaký větší ucelený příklad o "správném" použití Notorm v Nette, bylo-li by to možné. Děkuji.

P.S.: Skvělá práce s těmi sjednocenými dotazy, jednoduchá a zároveň geniální myšlenka. Jestli bych se mohl přimluvit za časy do debug panelu. Dalším krokem by mohlo být autocachování výsledků dotazů, což by bylo pro konkurenci jistě ničivé. :)

Milsa (msx):

Má NotORM aj podporu pre zjednodušený prístup k prekladom či už v rôznych stĺpcoch alebo tabuľkách? Preklad myslím viacjazyčnosť stránky.

ikona Jakub Vrána OpenID:

Viz http://php.vrana.cz/rozsireni-pro-notorm.php.

Vložit komentář

Používejte diakritiku. Vstup se chápe jako čistý text, ale URL budou převedeny na odkazy a PHP kód uzavřený do <?php ?> bude zvýrazněn. Pokud máte dotaz, který nesouvisí s článkem, zkuste raději diskusi o PHP, zde se odpovědi pravděpodobně nedočkáte.

Jméno: URL:

avatar © 2005-2017 Jakub Vrána. Publikované texty můžete přetiskovat pouze se svolením autora. Ukázky kódu smíte používat s uvedením autora a URL tohoto webu bez dalších omezení Creative Commons. Můžeme si tykat. Skripty předpokládají nastavení: magic_quotes_gpc=Off, magic_quotes_runtime=Off, error_reporting=E_ALL & ~E_NOTICE a očekávají předchozí zavolání mysql_set_charset. Skripty by měly být funkční v PHP >= 4.3 a PHP >= 5.0.