PHP Exceptions

“Исключительный” код – Часть 2

Завалялся у меня вольный перевод статьи от 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.

8 thoughts on ““Исключительный” код – Часть 2”

  1. Еще про set_exception_handler можно рассказать и как удобно через обработчик в 1 месте все ошибки логировать и обрабатывать при этом не исключая возможность локальной обработки через try-catch.

    Так как статья совсем свежая, то можно и про php7 Throwable объекты Error рассказать.

    1. Статья как раз уже пахнет нафталином :)
      А вот об обработке ошибок ещё будет статья в ближайшее время ;)

      1. Я имел про дату публикации на блоге. В старых статьях как раз ничего и не будет про Throwable. А в свеженькой стоит показать.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.