Завалялся у меня вольный перевод статьи от 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
<?php namespace Education; class CommandManager { private $cmdDir = "Command"; 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; } }
Если запустим данный код с неверным именем команды, то получим ошибку следующего вида:
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()
:
<?php use Education\CommandManager; try { $mgr = new CommandManager(); $cmd = $mgr->getCommandObject('unrealcommand'); $cmd->execute(); } catch (Exception $e) { print $e->getMessage(); exit(); }
Как видите, объект Exception становится доступен в блоке catch
, прям как аргументы при объявлении функций. И да, теперь вам, в случае ошибки, не нужно никуда лезть – всё есть внутри объекта Exception, и нет нужды нигде хранить какие-либо статусы о случившийся ошибке.
Запомните, при возникновении исключения в блоке try
, выполнение кода будет прекращено в месте вызова throw
, а дальше будет выполняться код в соответствующем блоке catch
, если же Exception
не будет пойман, то это приведёт к фатальной ошибке.
Термин ловить исключение – устоявшийся и используется повседневно
Обработка нескольких ошибок
Когда вы работаете с исключениями, нет разницы – вызываете вы метод или создаёте объект – всё можно обернуть в конструкцию try-catch
. Давайте добавим в конструктор класса CommandManager
проверку на существования директории с файлами:
<?php namespace Education; class CommandManager { private $cmdDir = "command"; public function __construct() { if (!is_dir(__DIR__ . DIRECTORY_SEPARATOR . $this->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)
Это позволяет писать легко-читаемый код без избыточных конструкций для обработки ошибок:
<?php use Education\CommandManager; try { $mgr = new CommandManager(); // тут возможна ошибка $cmd = $mgr->getCommandObject('realcommand'); // и тут тоже // ... // ещё какой-нибудь код $cmd->execute(); } catch (\Exception $e) { // обрабатываем возникшую ошибку print $e->getMessage(); exit(); }
Но с этим кодом существует одна проблема – как различать типы ошибок? Вот например, если требуется по разному сообщать об ошибках с директорией и о проблемах с именем класса?
Для этой цели хорошо подойдёт второй целочисленный параметр, который мы передаём в конструктор класса Exception
:
<?php namespace Education; class CommandManager { private $cmdDir = "command"; const CMDMAN_GENERAL_ERROR = 1; const CMDMAN_ILLEGALCLASS_ERROR = 2; public function __construct() { if (!is_dir(__DIR__ . DIRECTORY_SEPARATOR . $this->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
, мы даём возможность клиентскому коду различать типы ошибок, и соответственно можем по разному на них реагировать:
<?php use Education\CommandManager; try { $mgr = new CommandManager(); $cmd = $mgr->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 и т.д.
Использовать будем следующим образом:
<?php namespace Education; use Education\Exception\CommandManagerException; use Education\Exception\IllegalCommandException; class CommandManager { private $cmdDir = "command"; public function __construct() { if (!is_dir(__DIR__ . DIRECTORY_SEPARATOR . $this->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
для отлова новых ошибок:
<?php use Education\CommandManager; use Education\Exception\EducationException; use Education\Exception\CommandManagerException; use Education\Exception\IllegalCommandException; try { $mgr = new CommandManager(); $cmd = $mgr->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
, и будет выполнено условие вхождения в данный блок:
<?php use Education\CommandManager; use Education\Exception\EducationException; use Education\Exception\CommandManagerException; use Education\Exception\IllegalCommandException; try { $mgr = new CommandManager(); $cmd = $mgr->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
. Давайте приведу пример простого класса для данного сценария:
<?php namespace Education; use Education\Exception\EducationException; use Education\Exception\IllegalCommandException; class RequestHelper { private $request = array(); private $default= 'DefaultCommand'; private $command; public function __construct($request_array = null) { if (!is_array($this->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();
Как видите, код для работы c классом 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
уже содержит в себе полезную информацию для отладки кода, вот пример как её получить:
<?php try { // ... } catch (\Exception $e) { echo "exception: ". get_class($e); echo "message: ". $e->getMessage(); echo "code: ". $e->getCode() ."<br/>\n"; echo "file: ". $e->getFile() ."<br/>\n"; echo "line: ". $e->getLine() ."<br/>\n"; echo '<pre>'; echo $e->getTraceAsString(); echo '</pre>'; }
Можно создание и работу с классом RequestHelper
заключить в ещё один класс Front
(чтобы ещё больше запутать происходящее, но ООП такой и есть):
namespace Education; class Front { public static function main() { try { $helper = new RequestHelper(array('cmd' => null)); $helper->runCommand(); } catch (\Exception $e) { echo "<h1>". get_class($e) ."</h1>\n"; echo "<h2>". $e->getMessage() .", code ". $e->getCode() ."</h2>\n\n"; echo "file: ". $e->getFile() ."<br />\n"; echo "line: ". $e->getLine() ."<br />\n"; echo '<pre>'; echo $e->getTraceAsString(); echo '</pre>'; die; } } }
Вызываем статический метод Front::main()
, если возникнет исключение, то мы увидим следующий текст (а оно возникнет, т.к. в качестве команды передан null
:):
<h1>Education\Exception\EducationException</h1> <h2>Request parameter `cmd` not found, code 0</h2> file: /home/dev/www/education/exception/Education/RequestHelper.php<br /> line: 46<br /> <pre> #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} </pre>
Как видите, методы 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.
Как по мне для 2016 не хватает http://php.net/manual/ru/spl.exceptions.php
Часто новеньких на собеседованиях именно об этом спрашивают.
И информации про блок
finnaly
– http://php.net/language.exceptions#language.exceptions.finallyНадо будет добавить…
Еще про set_exception_handler можно рассказать и как удобно через обработчик в 1 месте все ошибки логировать и обрабатывать при этом не исключая возможность локальной обработки через try-catch.
Так как статья совсем свежая, то можно и про php7 Throwable объекты Error рассказать.
Статья как раз уже пахнет нафталином :)
А вот об обработке ошибок ещё будет статья в ближайшее время ;)
Я имел про дату публикации на блоге. В старых статьях как раз ничего и не будет про Throwable. А в свеженькой стоит показать.