Завалявся у мене вільний переклад статті від 2004-го року – про виключення в PHP, дивлюся на неї, а вона ще нічого так – актуальна, так що вирішив її причесати, оновити та опублікувати

У цій статті мова піде про обробку помилок допомогою винятків – про те як створити те, як зустріти створене, і що з ним потім робити.

Вбудований в PHP клас Exception містить наступні методи:

  • __construct() – конструктор класу, в якості параметрів приймає текст повідомлення про помилку, код помилки і попереднє виключення (це для ланцюжка викликів)
  • getMessage() – повертає текст помилки переданої в конструктор
  • getPrevious() – попереднє виключення з ланцюжка
  • getCode() – код помилки, переданої в конструктор
  • getFile() – шлях до файлу, де виникло виключення
  • getLine() – номер рядка, де виникло виключення
  • getTrace() – масив з послідовністю кроків, що призвели до виключення
  • getTraceAsString() – теж саме, тільки у вигляді рядка
  • __toString() – “магічний” метод – поверне текстовий опис виключення
  • __clone() – метод зроблений “приватним”, т. к. виключення не можна клонувати

Як ви можете помітити, клас Exception дуже схожий за своєю структурою на Pear_Error (flashback для тих хто його пам’ятає).

Якщо в скрипті виникне помилка, ми можемо створити свій власний екземпляр об’єкта Exception з описом того, що сталося (у якості параметра конструктор приймає довільний текст і код помилки):

$exception = new Exception(“Could not open file”);

Ключове слово throw

Після створення екземпляра об’єкта Exception, ми можемо повернути його так само, як робили з об’єктом Pear_Error, але робити цього не варто — використовуйте ключове слово throw:

throw new Exception(“Some error message”, 42);

throw перериває виконання методу, і робить відповідний об’єкт Exception доступним в контексті коду який його викликав. Ось як приклад метод getCommandObject() переписаний з використанням винятків:

Метод getCommandObject() про який йде мова, ви можете знайти в першій частині статті – “Винятковий” код, але і без неї код самодостатня для розуміння. Якщо ж виникнуть труднощі, то весь код доступний в моєму репозиторії на GitHub

cmdDir}/{$cmd}.php”;
if (!file_exists($path)) {
throw new \Exception(“Cannot find $path”);
}
require_once $path;
if (!class_exists($cmd)) {
throw new \Exception(“Class `$cmd` does not exist”);
}
$command = new $cmd();
if (!$command instanceof \AbstractCommand) {
throw new \Exception(“`$cmd` is not a Command”);
}
return $command;
}
}

Якщо запустимо даний код з невірним ім’ям команди, то отримаємо помилку наступного виду:

Fatal error: Uncaught exception ‘Exception’ with message ‘Cannot find Command/Unrealcommand.php’ in /home/xyz/BasicException.php:10
Stack trace:
#0 /home/xyz/BasicException.php(26): CommandManager->getCommandObject(‘Unrealcommand’)
#1 {main} thrown in /home/xyz/BasicException.php on line 10

Як бачите, якщо просто кинути виняток, то це викличе фатальну помилку, яку в обов’язковому порядку потрібно обробляти, інакше код не буде працювати.

Термін кидати виняток – сталий і зустрічається досить часто

Конструкція try-catch

Для обробки винятків слід використовувати конструкцію try-catch. Весь код, який може викликати виняток, слід обернути в блок try, обробку винятків укладають в блок catch (повинен бути як мінімум один такий блок). Ось як буде виглядати try-catch для виклику методу getCommandObject():

getCommandObject(‘unrealcommand’);
$cmd->execute();
} catch (Exception $e) {
print $e->getMessage();
exit();
}

Як бачите, об’єкт Exception стає доступна в блоці catch, прям як аргументи при оголошенні функцій. І так, тепер вам, у разі помилки, не потрібно нікуди лізти – все є всередині об’єкта Exception, і немає потреби ніде зберігати якісь статуси про трапився помилку.

Запам’ятайте, при виникненні виключення в блоці try, виконання коду буде припинено місці виклику throw, а далі буде виконуватися код у відповідному блоці catch, якщо ж Exception не буде спійманий, то це призведе до фатальної помилки.

Термін ловити виняток – сталий і використовується повсякденно

Обробка декількох помилок

Коли ви працюєте з винятками, немає різниці – ви викликаєте метод або створюєте об’єкт – все можна обернути в конструкцію try-catch. Давайте додамо в конструктор класу CommandManager перевірку на існування директорії з файлами:

cmdDir)) {
throw new \Exception(“Is not directory `$this->cmdDir`”);
}
}
public function getCommandObject($cmd)
{
$path = __DIR__ . DIRECTORY_SEPARATOR . “{$this->cmdDir}/{$cmd}.php”;
if (!file_exists($path)) {
throw new \Exception(“Cannot find $path”);
}
require_once $path;
if (!class_exists($cmd)) {
throw new \Exception(“Class `$cmd` does not exist”);
}
$command = new $cmd();
if (!$command instanceof \AbstractCommand) {
throw new \Exception(“`$cmd` is not a Command”);
}
return $command;
}
}

Тепер виходить є два різних місця, де можуть виникнути помилки, але при цьому в обробник помилок зміни вносити не потрібно, все буде працювати:

  • Якщо конструктор класу CommandManager кине виняток, то виконання блоку try завершиться, і буде виконаний блок catch, а Exception буде містити помилку “Is not directory“
  • Якщо виняток виникне в методі getCommandObject(), то Exception, буде містити одну з трьох можливих помилок (див. рядки 20, 26 і 32)

Це дозволяє писати легко читається код без надлишкових конструкцій для обробки помилок:

getCommandObject(‘realcommand’); // і тут теж
// … // ще який-небудь код
$cmd->execute();
} catch (\Exception $e) {
// обробляємо виниклу помилку
print $e->getMessage();
exit();
}

Але з цим кодом існує одна проблема – як розрізняти типи помилок? Ось наприклад, якщо потрібно по різному повідомляти про помилки з директорією і про проблеми з ім’ям класу?
Для цієї мети добре підійде другий цілочисельний параметр, який ми передаємо в конструктор класу Exception:

cmdDir)) {
throw new \Exception(“Is not directory `$this->cmdDir`”, self::CMDMAN_GENERAL_ERROR);
}
}
public function getCommandObject($cmd)
{
$path = __DIR__ . DIRECTORY_SEPARATOR . “{$this->cmdDir}/{$cmd}.php”;
if (!file_exists($path)) {
throw new \Exception(“Cannot find $path”, self::CMDMAN_ILLEGALCLASS_ERROR);
}
require_once $path;
if (!class_exists($cmd)) {
throw new \Exception(“Class `$cmd` does not exist”, self::CMDMAN_ILLEGALCLASS_ERROR);
}
$command = new $cmd();
if (!$command instanceof \AbstractCommand) {
throw new \Exception(“`$cmd` is not a Command”, self::CMDMAN_ILLEGALCLASS_ERROR);
}
return $command;
}
}

Використовуючи константи CMDMAN_ILLEGALCLASS_ERROR або CMDMAN_GENERAL_ERROR при створенні Exception, ми даємо можливість клієнтського коду розрізняти типи помилок, і відповідно можемо по-різному на них реагувати:

getCommandObject(‘realcommand’);
$cmd->execute();
} catch (\Exception $e) {
if ($e->getCode() == CommandManager::CMDMAN_GENERAL_ERROR) {
// no way of recovering
die($e->getMessage());
} elseif ($e->getCode() == CommandManager::CMDMAN_ILLEGALCLASS_ERROR) {
error_log($e->getMessage());
print “attempting recovery\n”;
// perhaps attempt to invoke a default command?
}
}

Спосіб робочий, але не дуже красивий, для подібної задачі краще використовувати класи нащадки Exception

Виключення та спадкування

Є як мінімум дві причини створювати свої класи наслідуючи Exception:

  • Для додавання специфічного функціоналу за зберігання і обробки помилок
  • Для колірної диференціації штанів відмінності між типами помилок

Давайте зупинимося на другому моменті, і спробуємо це впровадити на прикладі класу CommandManager. Виділимо два типи помилок:

  • Загальні помилки – на випадок, якщо немає директорії з файлами
  • Помилки виконання команд

Визначимо два класи для кожного типу помилок, і окремо спільного предка для них; для порядку все це виділимо в окремий namespace і відповідну директорію Exception:

// файл Education\Exception\EducationException.php
namespace Education\Exception;
class EducationException extends \Exception
{
}
// файл Education\Exception\CommandManagerException.php
namespace Education\Exception;
class CommandManagerException extends EducationException
{
}
// файл Education\Exception\IllegalCommandException.php
namespace Education\Exception;
class IllegalCommandException extends EducationException
{
}

Для іменування винятків слід вибирати правильні імена, які дозволять зрозуміти суть сталася помилки, наприклад, для кореневого виключення пакета використовуйте його ім’я + суфикс Exception, наприклад: EducationException, ApplicationException і т. д.

Використовувати будемо наступним чином:

cmdDir)) {
throw new CommandManagerException(“Is not directory `$this->cmdDir`”);
}
}
public function getCommandObject($cmd)
{
$path = __DIR__ . DIRECTORY_SEPARATOR . “{$this->cmdDir}/{$cmd}.php”;
if (!file_exists($path)) {
throw new IllegalCommandException(“Cannot find $path”);
}
require_once $path;
if (!class_exists($cmd)) {
throw new IllegalCommandException(“Class `$cmd` does not exist”);
}
$command = new $cmd();
if (!$command instanceof \AbstractCommand) {
throw new IllegalCommandException(“`$cmd` is not a Command”);
}
return $command;
}
}

Коли наш клас не зможе знайти директорію, то він викине виняток CommandManagerException. Якщо ж виникнуть проблеми на етапі створення об’єкта Command, то метод getCommandObject() викине виняток IllegalCommandException. Зауважте, виключення IllegalCommandException може виникнуть у трьох різних випадках, щоб їх розрізняти можете скористатися кодом помилки, як це було описано в попередньому прикладі.

Тепер настала черга клієнтського коду – додамо ще три блоку catch для відлову нових помилок:

getCommandObject(‘realcommand’);
$cmd->execute();
} catch (CommandManagerException $e) {
die($e->getMessage());
} catch (IllegalCommandException $e) {
error_log($e->getMessage());
print “Attempting recovery\n”;
} catch (EducationException $e) {
print “Package exception\n”;
die($e->getMessage());
} catch (\Exception $e) {
print “Unexpected exception\n”;
die($e->getMessage());
}

Якщо об’єкт CommandManager кидає виняток CommandManagerException, то буде виконано код відповідного блоку catch. Слід враховувати що конструкція працює як switch і аргумент кожного блоку catch працює як умова входження в цей блок, тому необхідно вибудовувати блоки catch від приватних типів помилок до загальних помилок. Якщо вибудувати все в зворотному порядку, то перший блок catch буде виконуватися завжди при виникненні виключення в коді. Це відбувається тому, що всі винятки є нащадками \Exception, і буде виконана умова входження в даний блок:

getCommandObject(‘realcommand’);
$cmd->execute();
} catch (\Exception $e) {
print “Unexpected exception\n”;
die($e->getMessage());
} catch (EducationException $e) {
// …
} catch (CommandManagerException $e) {
// …
} catch (IllegalCommandException $e) {
// …
}

Намалюємо ієрархію винятків, вона допоможе вам в організації правильної обробки помилок:

\Exception
`– \Education\Exception\EducationException
|– \Education\Exception\CommandManagerException
`– \Education\Exception\IllegalCommandException

Якщо ви в своєму коді написали блоки catch для кожного типу виникаючих помилок, то було б непогано написати ще один для лову виключення типу \Exception (як це зроблено вище) – таким чином ви будете відловлювати і обробляти абсолютно всі виникаючі виключення, хоча ніхто вам не забороняє прокидати помилки далі, якщо ви не можете їх обробити на даному логічному рівні.

Прокид помилок

Так-так, таке трапляється – помилка виникла, ми її зловили, та тільки виявилася вона нам не по зубах, і ми повинні передати її далі. У цьому разі помилку потрібно прокинути далі, повторно викликавши throw. Давайте наведу приклад простого класу для даного сценарію:

request = $request_array)) {
$this->request = $_REQUEST;
}
}
public function getCommandString()
{
if ($this->command) {
return $this->command;
} else {
if (isset($this->request[‘cmd’])) {
$this->command = $this->request[‘cmd’];
return $this->command;
} else {
throw new EducationException(“Request parameter `cmd` not found”);
}
}
}
public function runCommand()
{
$command = $this->getCommandString();
try {
$manager = new CommandManager();
$cmd = $manager->getCommandObject($command);
$cmd->execute();
} catch (IllegalCommandException $e) {
error_log($e->getMessage());
if ($command != $this->default) {
$this->command = $this->default;
$this->runCommand();
} else {
throw $e;
}
} catch (\Exception $e) {
throw $e;
}
}
}
$helper = new RequestHelper(array(‘cmd’=>’realcommand’));
$helper->runCommand();

Як бачите, код для роботи з класом CommandManager був обгорнутий в клас RequestHelper, це клас, який відповідає за роботу з даними, що вводяться користувачем. Конструктор опціонально приймає масив, який використовується для налагодження і тестування класу, якщо ж його не передавати, то дані будуть підтягнуті з суперглобальной змінної $_REQUEST.

Алгоритм роботи наведеного класу наступний:

  • Сигналом до виконання, наявність елемента cmd у запиті
  • Метод getCommandString() перевіряє властивість $command:
    • якщо значення присутня, то воно буде використано для виклику команди
    • якщо там порожньо, то властивості буде присвоєно значення по ключу cmd властивості $request
    • якщо cmd в $request не виявилося, то буде кинуто виняток EducationException

Таким чином, командний рядок “за замовчуванням” можна легко замінити в класі RequestHelper.

Що у нас виходить – клас RequestHelper працює з об’єктами типу AbstractCommand, і відповідно обробку винятків типу IllegalCommandException логічно було б покласти на його плечі, а все інше – виключення інших типів – прокинути далі, за це відповідає наступний код:

try {
// …
} catch (IllegalCommandException $e) {
// …
} catch (\Exception $e) {
throw $e;
}

Якщо ж буде спійманий IllegalCommandException, то в першу чергу буде здійснена спроба запустити команду “за замовчуванням”,
для цього властивості $command буде присвоєно значення з $default, і далі запуск методу runCommand().
Якщо $command і $default рівні, то виняток буде проброшено вище викликав код:

try {
// …
} catch (IllegalCommandException $e) {
if ($command != $this->default) {
// …
} else {
throw $e;
}
} catch (\Exception $e) {
throw $e;
}

За фактом, Zend Engine автоматично прокидає всі винятки, які не були спіймані, так що можна обійтися без останнього блоку catch – поведінка системи не зміниться. З іншого боку, ви можете організувати ланцюжок винятків, щоб спростити налагодження програми:

use Education\Exception\EducationException;
use Education\Exception\IllegalCommandException;
try {
// …
} catch (IllegalCommandException $e) {
if ($command != $this->default) {
// …
} else {
throw $e;
}
} catch (\Exception $e) {
throw new EducationException(“Package error”, 0, $e);
}

Врахуйте, наведені у статті приклади мають багато спрощень, щоб не навантажувати приклади кодом. Наприклад, в методі getCommandObject() можливо поява “fatal error”, у разі якщо конструктор підключається класу буде приватним.

Коли потрібні подробиці

Як вже було сказано раніше, об’єкт Exception вже містить у собі корисну інформацію для налагодження коду, ось приклад як її отримати:

getMessage();
echo “code: “. $e->getCode() .”
\n”;
echo “file: “. $e->getFile() .”
\n”;
echo “line: “. $e->getLine() .”
\n”;
echo “;
echo $e->getTraceAsString();
echo “;
}

Можна створення і роботу з класом RequestHelper укласти ще один клас Front (щоб ще більше заплутати ситуацію, але ООП такий і є):

namespace Education;
class Front
{
public static function main()
{
try {
$helper = new RequestHelper(array(‘cmd’ => null));
$helper->runCommand();
} catch (\Exception $e) {
echo “

“. get_class($e) .”

\n”;
echo “

“. $e->getMessage() .”, code “. $e->getCode() .”

\n\n”;
echo “file: “. $e->getFile() .”
\n”;
echo “line: “. $e->getLine() .”
\n”;
echo “;
echo $e->getTraceAsString();
echo “;
die;
}
}
}

Викликаємо статичний метод Front::main(), якщо виникне виключення, то ми побачимо наступний текст (а воно виникне, т. к. в якості команди переданий null :):

Education\Exception\EducationException

Request parameter `cmd` not found, code 0

file: /home/dev/www/education/exception/Education/RequestHelper.php
line: 46
#0 /home/dev/www/education/exception/Education/RequestHelper.php(58): Education\RequestHelper->getCommandString()
#1 /home/dev/www/education/exception/Education/Front.php(19): Education\RequestHelper->runCommand()
#2 /home/dev/www/education/exception/front.php(17): Education\Front::main()
#3 {main}

Як бачите, методи getFile() і getLine() повертають інформацію про те, де саме виникло це виключення. Метод getStackAsString() повертає повну інформацію про всієї послідовності викликів призвели до виключення. Цю ж інформацію можна отримати у вигляді двовимірного масиву з використанням методу getTrace():

array (size=2)
0 =>
array (size=6)
‘file’ => string ‘/www/exception/Education/RequestHelper.php’ (length=61)
‘line’ => int 58
‘function’ => string ‘getCommandString’ (length=16)
‘class’ => string ‘Education\RequestHelper’ (length=23)
‘type’ => string ‘->’ (length=2)
‘args’ => array ()
1 =>
array (size=6)
‘file’ => string ‘/www/exception/index.php’ (length=43)
‘line’ => int 22
‘function’ => string ‘runCommand’ (length=10)
‘class’ => string ‘Education\RequestHelper’ (length=23)
‘type’ => string ‘->’ (length=2)
‘args’ => array ()

Кожен елемент масиву верхнього рівня – це один виклик на шляху від нашого виключення до вхідних точці (т. е. масив йде в зворотному порядку від порядку викликів), кожен елемент являє собою масив, з наступними атрибутами:

  • file – ім’я файлу, де був зроблений виклик
  • line – номер рядка у файлі
  • function ім’я функції або методу
  • class – ім’я класу
  • type – тип – небудь “::” для статичного виклику, або “->” для динамічного виклику методу
  • args – список аргументів

У висновку про винятки

У винятків є ряд незаперечних переваг, перед старим підходом:

  • Згрупування винятків у блоки catch дозволяє відокремити обробку помилок від самої логіки програми, що робить код легко читаним і його зручно надалі підтримувати
  • Виключення передаються від місця появи на верхні рівні, що дає більше можливостей для обробки помилок. Як би це дивно не звучало, але найчастіше обробляти помилку краще викликала в коді, а не там де помилка з’явилася
  • Механізм throw-catch дозволяє не писати зайвий код для перевірки значень як це було в епоху Pear_Error

Переклад і адаптація статті з Zend Developer Zone: Exceptional Code – PART 2. Переклад першої частини так само доступний на моєму блозі – “Винятковий” код. Частина 1.