Jak je to s těmi chybami?

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

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.

Příklad

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í chybZápis kóduVýsledek
vypnutofunkcechyba bez vysvětlení
vypnutometodyfatální chyba
vypnutoobsluha chybzatajení chyby
varovánífunkcevysvětlení, chyba navíc
varovánímetodyfatální chyba
varováníobsluha chybvysvětlení
výjimkyfunkceneošetřená výjimka
výjimkymetodyneošetřená výjimka
výjimkyobsluha chybvysvě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ě.

Závěr

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.

Jakub Vrána, Dobře míněné rady, 6.8.2010, diskuse: 8 (nové: 0)

Diskuse

ikona v6ak:

No, co se týče lokalizace místa chyby, tak u výjimek částečně pomůže stacktrace (člověk se dozví, na hledání pomocí kódu to není). U chyb (varování, notice, ...)
Priority máme asi trošku jinde. Aplikace by neměla říkat, že funguje, když nastane chyba. Uvažme uložení do databáze. To ten naivní způsob neřeší. Nastane chyba a dozvíme se, že data byla úspěšně uložena. To je špatně.
Že lokální chyba znefunkční aplikaci sice není příjemné, ale je to IMHO méně závažné než lhát o úspěchu. Můžeme také zvážit, jak často lokální chyby nastávají. Já nemám pocit, že by nastávaly nějak extrémně často. Ale nepopírám, že možnost zotavení se z lokální chyby je určitě výhoda.
Jinak je škoda, že se nediskutovalo o mém nápadu na anotaci pro nekritické komponenty v Nette.
Otázka řešení chyb alternativním způsobem samozřejmě závisí na požadavcích, u e-shopu se to možná vyplatí, jinde to může být zbytečné. Navíc lze tyto údaje často vyčíst ze stacktrace (u výjimek nativně, u chyb nutno dopsat přes vlastní handler).

optik:

Ad nekritické komponenty v nette - my si to řešíme vlastním catchnutím výjímky a převodem přes trigger_error na message s textovými informacemi o výjimce. Funguje to dobře, podpora  ve frameworku není moc potřeba. Pokud by byla, tak určitě jen volitelně, implicitně vypnutá.

ikona v6ak:

No stejně u mého nápadu by to vyžadovalo označit tu komponentu za nekritickou.

ikona David Grudl:

Víš, že to napíšu, tak ať to máme z krku ;-)

Ty příklady demonstrují fakt, jak programátor předpokládá, že posloupnost příkazů proběhne ve správném pořadí a kompletně. Pokud jeden z nich selže, nemá smysl, aby probíhaly další. Takže to nejsou příklady dvou funkcí jdoucích po sobě, ale funkce, která selže + následujících funkcí (ač je uvedena jen jedna). Tedy pokud například selže změna databáze, adresáře atd, následující příkazy páchají škodu.

(mimochodem ani to copy + unlink se nedá nahradit za rename v příkladu, který uvádím)

ikona Jakub Vrána OpenID:

Jak jsem psal, tyhle případy samozřejmě existovat mohou, ale podle mých zkušeností jich je řádově míň, než kdy se může stát, že lokální chyba shodí celou aplikaci. Tebou zvolené příklady jsou podle mě nešikovné a nezdá se mi, že by pocházely z reálné aplikace. Ošetřit v nich chybu je samozřejmě nutné, ať už pomocí návratové hodnoty (jeden if) nebo pomocí výjimek (jeden try–catch).

Dokonce i na Windows funguje rename() z disku na disk, nahradit to tedy jde.

Honza:

http://poll.pollcode.com/Hm7p

ikona Ondřej Mirtes:

Nevím, jestli svým příspěvkem nebudu o pár článků a vašich (Jakuba a Davida) úvah nazpět, ale rád se k tomuto problému také vyjádřím.

Pokud volám funkci/metodu, co má něco *vykonat* (např. touch($file)), je přirozené, aby v případě neúspěchu vyvolala výjimku. Nikdy totiž neví, z jakého kontextu je volaná a jestli je jí vykonávaná operace kritická pro aktuální kontext či nikoli. Toto rozhodnutí je na vrstvě, která tu funkci volá. Chování výjimek je předvídatelné a intuitivní - pokud jeden řádek programu selže, další se už nevykonávají a výjimka probublá výše. Obvykle je to to, co chceme - pokud na jednom řádku vytvářím nový soubor, asi s ním chci na dalších řádcích pracovat. Pokud nemám nějaký špagetový kód, tak ta výjimka nemůže ani udělat paseku.

Pokud na vyšší vrstvě volám metodu, která uvnitř obsahuje logiku se založením nového souboru a nějakou prací s ním, jsem obeznámen s tím, jakou výjimku ta metoda může vyvolat, můžu se na vyšší vrstvě rozhodnout, jestli má smysl pokračovat v posloupnosti dalších příkazů, pokud ten se založením a zapsáním něčeho do souboru vyvolá chybu. Zde je už místo na try-catch blok, pokud v daném místě dává smysl pokračovat v dalších příkazech po tom selhání.

Určitě za celým mechanismem výjimek nevidím ten důsledek, že bych měl každý samostatně vykonávaný řádek kódu obalovat samostatným try-catch blokem, jak to možná vidí Jakub, aby mu poskytly to samé "pohodlí", co návratové kódy. Posloupnost příkazů v jedné samostatné metodě spolu obvykle souvisí a výjimky jsou pro to to pravé. A pokud na vyšší vrstvě mám posloupnost příkazů, které jsou na sobě do jisté míry nezávislé a chci pokračovat ve výkonu kódu i v případě chyby nějakého předchozího příkazu, použiju try-catch bloky a nijak mě to nebolí.

Návratové kódy jsou podle mě úplně mimo mísu. Funkce má něco vracet pouze v případě, že se to od ní očekává. V chybových konstantách je chaos a svádí to k jejich ignorování. Proč bych měl kontrolovat návratovou hodnotu funkce, co má pouze něco provést a ať přemýšlím jakkoli usilovně, nenapadá mě nic, co by mohla vracet? V případě, že se od ní očekává nějaká akce, chci se dozvědět, že nebyla správně provedená bez jakéhokoli dalšího úsilí (kterým bývá if blok s kontrolou návratové hodnoty).

Pokud toto chování není zabudovanými funkcemi jazyka zajištěno (anebo nějak zhůvěřile, viz představované mysqli_report), znamená to nutnost napsat si vlastní vrstvu s intuitivním API a tudíž i vyhazováním výjimek.

ikona Jakub Vrána OpenID:

Načtení dat v akci webové aplikace je ukázka typické posloupnosti operací v jedné metodě, které spolu nesouvisí. Kritická operace (na které jsou ostatní závislé) je často jen jedna (načtení textu článku, základních informací o produktu, …), ostatní jsou nepovinné a často na sobě nezávislé. Nejde ale o jediný případ, vezměme si třeba takové zpracování objednávky:

<?php
$order
->save();
$order->send();
?>

Když první metoda vyvolá výjimku, druhá metoda se taky nezavolá, čehož důsledkem je, že jsme o objednávku přišli. Když by prošla aspoň druhá metoda, tak se nám objednávka alespoň pošle. Potřebovali bychom, aby se tyto operace provedly nezávisle na sobě (OR). Výjimky jsou vhodné pro závislé operace (AND).

U chyby tedy musím respektovat především její dosah. Pokud má chyba malý dosah (třeba jen jeden příkaz a nic dalšího by ovlivnit neměla), tak jsou výjimky zoufale nepohodlné a návratová hodnota spolu s chybovou zprávou PHP nám chybu zpracuje zadarmo. Pokud je naopak příkazů víc, mohou být pohodlnější výjimky.

Omezení rozsahu chyby v případě výjimek je totiž podle mě prakticky stejně důležité jako jeho rozšíření v případě návratové hodnoty. Jinak totiž stránka skončí fatální chybou, což je u webových aplikací typicky ten největší problém. Podle mé zkušenosti je ale fatálních chyb (u kterých chci, aby skutečně vedly k nezobrazení celé stránky) podstatně méně než těch, které naopak nic dalšího ovlivnit nemají.

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