Weblog o elegantním programování v PHP pro mírně pokročilé
Představte si aplikaci podle návrhového vzoru MVC nebo podobného, napsanou třeba v Nette, ve které chceme někde v postranním menu zobrazit třeba výběr článků. Pokud se tento výběr nepodaří načíst, neměl by zamezit zobrazení hlavního obsahu stránky. Model může vypadat třeba takhle:
<?php class Article { static function findBests() { return dibi::fetchAll("SELECT * FROM [article] WHERE [best]"); } } ?>
Prezenter:
<?php class ArticlePresenter { function renderDetail($id) { // tady bude načtení hlavního obsahu stránky - detailu článku // kromě toho načteme i výběr článků pro postranní menu $this->template->bestArticles = Article::findBests(); } } ?>
Šablona postranního menu:
{if $bestArticles}
<h3><a href="{link Article:bests}">Výběr článků</a></h3>
<ul>
{foreach $bestArticles as $article}
<li><a href="{link Article:detail $article->id}">{$article->title}</a></li>
{/foreach}
</ul>
{/if}
Zdrojový kód je k dispozici na GitHubu.
Jak je vidět, model používá Dibi, které v případě chyby vyvolá DibiException. A mě zajímá, jak s touto výjimkou naložíte. Ošetříte ji už v modelu, v prezenteru, nebo ji snad necháte probublat až na nejvyšší úroveň? A pokud ji nezachytíte už v modelu, použijete dokumentační komentář @throws? A nebude divné, že se prezenter prostřednictvím typu výjimky dozví o implementaci modelu? Nebo ji třeba v modelu přeložíte na jinou výjimku? A jak vlastně bude vypadat blok catch?
Všimněte si také toho, že v šabloně je odkaz na Article:bests – to bude stránka, kde je hlavním obsahem právě seznam nejlepších článků a kde je tedy žádoucí, aby skončila pětistovkou, pokud se seznam nepodaří načíst. A aby vypsala informaci o prázdném seznamu, pokud žádné články jako výběr ještě označené nebudou. Přijde mi logické, aby tato stránka vevnitř volala stejnou metodu modelu Article::getBests.
Ptám se proto, že jsem stejné otázky v osobním rozhovoru položil hlavnímu vývojáři významného českého frameworku a jeho odpovědi se mi zrovna dvakrát nelíbily. A pokud vám možnost výskytu chyby při čtení dat z databáze přijde nepravděpodobná, tak si představte, že se ptáte nějaké nespolehlivé služby (třeba Twitteru). Pokud byste v tom případě své řešení upravili, tak si ještě odpovězte na otázku, jakou chyba musí mít pravděpodobnost, aby stálo za to s ní nezacházet jako s fatální.
Typická webová aplikace si načte nějaká data z databáze a zobrazí je rozmístěné na stránce. Občas do databáze i něco uloží. Tyto části jsou na sobě většinou nezávislé a jedna část by neměla ovlivnit druhou. Někdy to samozřejmě neplatí (pokud se nepodaří načíst detail produktu, nemá smysl načítat jeho obrázky), takový případ bývá ale většinou jen jeden na stránce. Z tohoto důvodu je nesmírně důležité, aby aplikace neskončila nezachycenou výjimkou (případně výjimkou zachycenou až na nejvyšší úrovni) nebo obecně fatální chybou – nezobrazení stránky je u webové aplikace většinou kritický problém. Většinu chyb je potřeba ošetřovat lokálně, blízko místa jejich vzniku.
Co když se třeba nepodaří uložit objednávku zákazníka do databáze, je to důvod pro fatální chybu? Není, protože pořád se ji může ještě podařit odeslat na e-mail obchodníka a tím o ni nepřijít. I tato operace, která je pro zpracování operace kritická, by si tedy chybu měla ošetřit lokálně.
V PHP se používají dva mechanismy ošetření chyb: návratová hodnota (spojená většinou s vygenerováním varování) nebo výjimky. Výjimky se perfektně hodí pro ošetření chyb v posloupnosti operací a pokud chceme chybu lokalizovat (omezit jen na několik operací, v krajním případě na jednu), musíme napsat kód navíc (jeden blok try–catch na každou lokalitu). Návratová hodnota je zase výborná pro zpracování lokálních chyb a pokud je chceme rozšířit na více operací, musíme napsat kód navíc (jeden if na každou možnou chybu). Moje zkušenost je taková, že tento kód navíc se mnoha programátorům nechce psát, čehož důsledkem je to, že buď v aplikaci může docházet k neošetřeným výjimkám nebo se z posloupnosti operací provede jen jejich nevhodná část.
To se ostatně pokouší demonstrovat David Grudl, i když podle mě trochu nešikovně. První tři příklady jsou zcela umělé: místo copy + unlink se dá použít rename, místo USE testdata + DELETE FROM orders bych použil DELETE FROM testdata.orders a místo ftp_chdir($ftp, "test") + ftp_delete($ftp, "database.sdb") prosté ftp_delete($ftp, "test/database.sdb"). Jistě by se daly najít příklady, kde druhá funkce závisí na doběhnutí první a přitom nepoužívá její výsledek, zdaleka ale nejsou běžné. Další tři příklady zase vůbec nesouvisí s tím, jakým způsobem se chyby reportují, ale s tím, že se s nimi vůbec nepočítá – při použití výjimek by byl výsledek úplně stejný. Článek se také obouvá do příkladů v dokumentaci funkce fread, tyto příklady kupodivu ale pracují správně a konzistentně – když v kterékoliv volané funkci dojde k chybě, tak bude mít proměnná $contents hodnotu false. Samozřejmě se vygenerují (a zalogují) nějaké chyby navíc, to ale není zásadní problém. Za zásadní problém naopak považuji nerespektování lokálnosti chyby, což způsobí nezobrazení celé stránky, které David schvaluje.
Pojďme se podívat na nějaký konkrétní příklad. Budeme chtít získat výsledek databázového dotazu a uložit ho do proměnné. Tuto proměnnou pak budeme chtít třeba v šabloně projít, neměla by ale ovlivnit ostatní operace, které na stránce provádíme. Tuto jednoduchou úlohu lze v PHP vyřešit překvapivě velkým množstvím způsobů a to i v případě, že se omezíme pouze na extenzi MySQLi. Kromě rozhodnutí, zda budeme chyby sami ošetřovat, si můžeme vybrat způsob hlášení chyb (funkcí mysqli_report) a to, zda budeme používat funkce nebo metody. Ukázky předpokládají nastavení konfigurace vhodné pro produkční prostředí, tedy error_reporting alespoň E_ERROR | E_WARNING, display_errors vypnuto, log_errors zapnuto. Chybový log je vhodné sledovat třeba pomocí jeho pravidelného posílání e-mailem.
Ukazuje se, že vcelku naivní přístup může být prakticky optimální, a naopak pokus o důkladné ošetření může mít nepříjemné následky:
<?php // dozvíme se, že došlo k chybě, ale ne příčinu mysqli_report(MYSQLI_REPORT_OFF); $result = mysqli_query($mysqli, $query); $data = mysqli_fetch_all($result); // může skončit fatální chybou bez zjištění příčiny mysqli_report(MYSQLI_REPORT_OFF); $result = $mysqli->query($query); $data = $result->fetch_all(); // nedozvíme se, že došlo k chybě mysqli_report(MYSQLI_REPORT_OFF); $result = mysqli_query($mysqli, $query); if ($result) { $data = mysqli_fetch_all($result); } // zjistíme příčinu chyby a jednu chybu navíc mysqli_report(MYSQLI_REPORT_ERROR); $result = mysqli_query($mysqli, $query); $data = mysqli_fetch_all($result); // zjistíme příčinu chyby mysqli_report(MYSQLI_REPORT_ERROR); $result = mysqli_query($mysqli, $query); if ($result) { $data = mysqli_fetch_all($result); } // může skončit nezachycenou výjimkou mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); $result = $mysqli->query($query); $data = $result->fetch_all(); // nedozvíme se, že došlo k chybě mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); try { $result = $mysqli->query($query); $data = $result->fetch_all(); } catch (mysqli_sql_exception $e) { } // zjistíme příčinu chyby mysqli_report(MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT); try { $result = $mysqli->query($query); $data = $result->fetch_all(); } catch (mysqli_sql_exception $e) { error_log($e); } ?>
Ještě stručné shrnutí:
| Reportování chyb | Zápis kódu | Výsledek |
|---|---|---|
| vypnuto | funkce | chyba bez vysvětlení |
| vypnuto | metody | fatální chyba |
| vypnuto | obsluha chyb | zatajení chyby |
| varování | funkce | vysvětlení, chyba navíc |
| varování | metody | fatální chyba |
| varování | obsluha chyb | vysvětlení |
| výjimky | funkce | neošetřená výjimka |
| výjimky | metody | neošetřená výjimka |
| výjimky | obsluha chyb | vysvětlení nebo zatajení chyby |
Za nejdůležitější považuji, aby lokální chyba nezpůsobila pád celé aplikace. V druhé řadě bychom se měli dozvědět o vzniku chyby, ideálně i o její příčině.
Jsem toho názoru, že lokálních chyb bývá ve webových aplikacích většina, opravdu globálních chyb poměrně málo (a dají se ošetřit snadno pomocí výjimek i pomocí návratové hodnoty) a celkem těžko se v reálných webových aplikacích hledají posloupnosti operací, kde by vadilo, že se zavolá B, i když se nepovedlo A. Proto jsem napsal, že výjimky nepovažuji obecně za nejlepší možný způsob ošetřování chyb a že způsob práce s chybami v PHP považuji za rozumný. Pro správné ošetření chyby je totiž nutné ji jednak ošetřit, ale také respektovat její kontext a zbytečně ji neeskalovat.
NotORM se výborně hodí pro pohodlné sestavování dotazů pokládaných do databáze, obzvlášť pokud potřebujeme pracovat se vztahy mezi tabulkami. Pokud je ale databáze navržena nešikovně nebo je příliš komplexní, tak by se hodilo nad nízkoúrovňovými funkcemi NotORM vytvořit ještě jednu vrstvu abstrakce.
Vezměme si třeba takové vícejazyčné záznamy. Pokud překlady uchováváme v samostatné tabulce, můžeme je pomocí NotORM získat následujícím kódem:
<?php // zjištění názvu a ceny produktu v dané skupině foreach ($notORM->product("group_id", $group) as $product) { foreach ($product->product_translation("language_id", $lang) as $product_translation) { $product_translation["name"]; } $product["price"]; } ?>
Kód je sice mnohem jednodušší, než při použití nízkoúrovňových funkcí, pořád je ale dost krkolomný. A to ani nezohledňuje případ, že by překlad neexistoval. Představa, že bych musel tento kód psát při každém získání přeloženého textu, mě zrovna neláká.
NotORM proto nyní (zatím pouze ve vývojové verzi) umožňuje určit třídu, ze které se budou vytvářet vracené řádky a která může chování upravovat. Třeba takhle:
<?php class NotORM_Row_Lang extends NotORM_Row { static $lang = "cs"; function offsetExists($key) { if (!array_key_exists($key, $this->row)) { $table = $this->result->table . "_translation"; // pokud by překlad neexistoval, vrátíme anglickou verzi foreach ($this->$table("language_id", array(self::$lang, "en"))->order("language_id = 'en'")->limit(1) as $row) { foreach ($row as $key => $val) { $this->row[$key] = $val; } } } return parent::offsetExists($key); } function offsetGet($key) { $this->offsetExists($key); return parent::offsetGet($key); } } ?>
Pro zjištění názvu a ceny výrobku pak lze psát stejný kód, jako kdyby žádné překlady vůbec neexistovaly:
<?php // zjištění názvu a ceny produktu v dané skupině foreach ($notORM->product("group_id", $group) as $product) { $product["name"]; $product["price"]; } ?>
Stejně jako bez rozšíření se položí pouze konstantní počet dotazů (v tomto případě dva).
Vracená třída se dá v současné době určit pouze globálně pro objekt třídy NotORM, nicméně zvažuji, že by šla určit i pro každý výsledek zvlášť. Sám bych pro to ale asi neměl využití.
Moje dvě přednášky o NotORM a Adminer Editoru byly přijaty a zařazeny do programu konference OSI Days. Jde o největší asijskou open–source konferenci, zaměřuje se především na PHP a související technologie.
Na konferencích už jsem přednášel, dokonce i v angličtině, přesto už mám teď trochu trému. Kromě mých přednášek je na programu i řada dalších zajímavých vystoupení, takže bych vás na konferenci rád pozval. Indie je samozřejmě poněkud daleko (letenka stojí kolem 12 000 Kč), proto chci účast na konferenci spojit s asi dvoutýdenním výletem po Indii. Kdo by chtěl, může se připojit. Vstup na konferenci stojí 50 dolarů, jako přednášející vám mohu zajistit 50% slevu. Já si zrovna vyřizují vízum a času už není příliš nazbyt. Konference končí dva dny před WebExpem, takže se dá stihnout oboje.
Když jsem začínal s PHP, tak se mi líbilo, že se všechno používá v podobě funkce, dokonce i příkaz pro vložení jiného souboru. Pak jsem se ale dozvěděl, že to tak úplně není a závorky jsem psát přestal. Platí to pro příkazy echo, include, return, exit a jejich varianty.
Závorky u nich napsat samozřejmě lze, ale mají stejný význam jako v matematickém výrazu. Je to totéž, jako kdybyste napsali $a = ($b + 5). Jiný význam závorek se navíc může vymstít třeba v momentě, kdy se dozvíme, že echo může mít více parametrů (což je paměťově šetrnější, než jejich zřetězení), include může mít návratovou hodnotu nebo return může být použito ve funkci vracející referenci:
<?php // funguje echo $a, $b; if ((include "a.php") !== false) { } function &getSession($key) { return $_SESSION[$key]; } // nefunguje echo($a, $b); if (include("a.php") !== false) { } function &getSession($key) { return($_SESSION[$key]); } ?>
Vyplatí se tedy respektovat syntaxi PHP a závorky zbytečně nepsat tam, kde mají jiný význam, než to na první pohled vypadá. Já třeba z důvodu přehlednosti píšu závorky při vracení výsledku porovnání – return (volaniFunkce() == 5), jiný význam závorky ale naznačuji mezerou.
Závorky se nemusí psát ani při vytváření nového objektu bez předání parametrů konstruktoru (např. new stdClass).
Starší články naleznete v archivu.