Антон Шевчук // Web-разработчик

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

Целевая аудитория

Данная статья предназначена для опытных PHP разработчиков которые хотят узнать больше о поддержке исключений в PHP 5, а также – что не менее важно – узнать о других подходах стандартизованной обработки ошибок. Вы должны быть знакомы с основами ООП (в том числе со страшным словом полиморфизм).

Вступление

Большинство технических статей забивают на описание обработки ошибок. И это понятно, ведь условия для проверки ошибок не способствуют пониманию и без того сложного кода. Эта статья должна исправить такое положение дел. Тут Вы в избытке встретите код для обработки и управления ошибками.


С PHP 5 появились исключения – новый механизм для управления ошибками в объектной среде. Как Вы сможете увидеть далее, исключения имеют несколько важных преимуществ над традиционными технологиями управления ошибками.

Управление ошибками до PHP 5

До появления PHP5 управление ошибками можно было разделить на два типа:

  1. Функция или метод возвращает флаг ошибки, и возможно выставляется свойство или глобальная переменная, которая проверяется в дальнейшем
  2. Генерируется «warning» или «fatal error» используя функции trigger_error() или die() (т.н. Errors at Script Level).

Errors at Script Level

Начнем с самого простого: Вы можете использовать функцию die() для завершения выполнения скрипта, когда нет необходимости продолжать выполнение (к примеру когда не удалось создать соединение с БД). Вы часто можете видеть die() в примерах и в скриптах “на скорую руку”. Приведенный ниже пример описывает класс, который пытается загрузить файл класса с директории:

// PHP 4 
require_once('cmd_php4/Command.php'); 
class CommandManager { 
    var $cmdDir = "cmd_php4"; 

    function getCommandObject($cmd) { 
        $path = "{$this->cmdDir}/{$cmd}.php"; 
        if (!file_exists($path)) { 
            die("Cannot find $path\n"); 
        } 
        require_once $path; 

        if (!class_exists($cmd)) { 
            die("class $cmd does not exist"); 
        } 

        $ret = new $cmd(); 
        if (!is_a($ret, 'Command')) { 
            die("$cmd is not a Command"); 
        } 
        return $ret; 
    } 
}

Это упрощенный пример того, что зовется шаблоном проектирования Command. Разработчик может сохранить класс в определенной директории (’cmd_php4’ в данном примере), и т.к. файл имеет тоже имя, что и содержащийся в нём класс, a этот класс является потомком базового класса (Command), наш метод должен будет создать объект типа Command по переданному в него строковому параметру. Базовый класс Command определяет метод execute(), таким образом мы знаем, что во всём, что вернет нам метод getCommandObject(), будет реализован execute().

Взглянем на класс Command, который находится в файле ‘cmd_php4/Command.php’:

// PHP 4 
class Command { 
    function execute() { 
        die("Command::execute() is an abstract method"); 
    } 
}

Как Вы можете видеть, Command представляет собой абстрактный класс для PHP 4. Заглядывая немного наперед, посмотрите на версию этого класса для PHP 5 (объявленному в файле command/Command.php):

// PHP 5 
abstract class Command { 
    abstract function execute(); 
}

Структура, подобная этой, обеспечивает гибкость при расширении системы. Вы можете в любой момент добавить новый Command-based класс без необходимости каких либо глобальных изменений. Но есть несколько требований:

  1. в директории Команды должен быть файл
  2. должен существовать класс
  3. класс должен наследовать класс Command

Если хоть одно из этих условий не выполнено, скрипт брутально прервет своё выполнение. Это безопасный код, но он не достаточно гибкий. Скрипт выполняется единственным образом и только если всё хорошо. Код отвечает только за нахождение нужного класса и создание соответствующей команды. Код не знает ни о каких других действиях скрипта-клиента по отлову ошибки, да и не должен знать. Если мы дадим этому методу слишком много информации о контексте своего выполнения, то в последствии нам будет сложно повторно его использовать.

Несмотря на то, что использование die() исключает возможность включения ненужной логики в метод getCommandObject(), это всё-таки привносит страшные error’ы в приложение в целом.

А чего мы вообще решили, что при неопределённой команде выполнение скрипта должно быть прекращено? Логичнее использовать команду по-умолчанию или же процесс определения команды может быть выполнен заново.

А можно сделать скрипт ещё более гибким, если мы будем генерировать сообщения для пользователей вместо банального die();

// PHP 4 
require_once('cmd_php4/Command.php'); 
class CommandManager { 
    var $cmdDir = "cmd_php4"; 
 
    function getCommandObject($cmd) { 
        $path = "{$this->cmdDir}/{$cmd}.php"; 
        if (!file_exists($path)) { 
            trigger_error("Cannot find $path", E_USER_ERROR); 
        } 
        require_once $path; 
 
        if (!class_exists($cmd)) { 
            trigger_error("class $cmd does not exist", E_USER_ERROR); 
        } 
 
        $ret = new $cmd(); 
        if (!is_a($ret, 'Command')) { 
            trigger_error("$cmd is not a Command", E_USER_ERROR); 
        } 
        return $ret; 
    } 
}

Если при возникновении ошибки вы используете функцию trigger_error() вместо die(), вы предоставляете клиентскому скрипту возможность для удобного управления ошибками. Функция trigger_error() принимает в качестве параметров строку сообщения о ошибке и одну из следующих констант (подробнее…):

  • E_USER_ERROR – Критическая ошибка
  • E_USER_WARNING – Не критическая ошибка
  • E_USER_NOTICE – Сообщения которые не являются ошибками

Вы так же можете указать свою функцию, которая будет обрабатывать ошибки при вызове trigger_error(), для этого следует воспользоваться функцией set_error_handler().

// PHP 4 
function cmdErrorHandler($errnum, $errmsg, $file, $lineno) { 
    if($errnum == E_USER_ERROR) { 
        print "error: $errmsg\n"; 
        print "file: $file\n"; 
        print "line: $lineno\n"; 
        exit(); 
    } 
} 
 
$handler = set_error_handler('cmdErrorHandler'); 
$mgr = new CommandManager(); 
$cmd = $mgr->getCommandObject('realcommand'); 
$cmd->execute();

Как Вы можете видеть, функция set_error_handler() принимает в качестве параметра имя функции. При возникновении ошибки будет вызвана указанная функция со следующими параметрами:

  1. флаг ошибки
  2. строка сообщения
  3. имя файла
  4. номер строки где произошла ошибка

Для управления ошибками Вы так же можете назначить метод какого-либо класса, для этого следует в функцию set_error_handler() передать массив, в котором первым элементом будет ссылка на объект, а вторым – имя метода:

set_error_handler(array(&$aErrorHandlerClass, 'cmdErrorHandler'));

Хотя с помощью еррор-хендлеров можно делать всякие полезности типа логирования ошибок, вывода дебаг-информации и т.п., они всё-равно являются топорным методом обработки ошибок.

Дело в том, что вы ограничены в тех действиях, которые вы можете предпринять при появлении ошибки. В методе перехватывающем E_USER_ERROR вы, например, можете переопределить обычное поведение и отказаться от остановки процесса и не вызывать exit() или die(). Но если вы так поступите, то надо понимать, что выполнение приложения продолжится с того места, где случилась ошибка. А это может вызвать хитровыдуманные ошибке в коде, который расчитан на то, что при ошибке скрипт обрывается.

Returning Error Flags

Описанный способ конечно юзабельный, но сыроват. Обычно, для обеспечения большей гибкости, функция или метод возвращает клиенту код ошибки. Это даёт возможность клиенту обрабатывать возникшую ошибку на свой лад.

Мы изменим предыдущий пример для возврата кода ошибки при возникновении оной. (используем “false” в качестве кода ошибки).

// PHP 4 
require_once('cmd_php4/Command.php'); 
class CommandManager { 
    var $cmdDir = "cmd_php4"; 
 
    function getCommandObject($cmd) { 
        $path = "{$this->cmdDir}/{$cmd}.php"; 
        if (!file_exists($path)) { 
            return false; 
        } 
        require_once $path; 
 
        if (!class_exists($cmd)) { 
            return false; 
        } 
 
        $ret = new $cmd(); 
        if (!is_a($ret, 'Command')) { 
            return false; 
        } 
        return $ret; 
    } 
}

При таком раскладе, Вы можете реагировать на возникновение ошибки, в зависимости от ваших потребностей. Вот пример “умирания” скрипта в случае ошибки:

// PHP 4 
$mgr = new CommandManager(); 
$cmd = $mgr->getCommandObject('realcommand'); 
if (is_bool($cmd)) { 
    die("error getting command\n"); 
} else { 
    $cmd->execute(); 
} 

или вот пример логирования ошибки на сервере:

// PHP 4 
$mgr = new CommandManager(); 
$cmd = $mgr->getCommandObject('realcommand'); 
if(is_bool($cmd)) { 
    error_log("error getting command\n", 0); 
    } 
else { 
    $cmd->execute(); 
} 

Когда используешь код ошибки наподобие “false” (или -1, или 0) есть одна проблемка – это недостаток информации о возникшей ошибке. Вы можете конечно добавить некую переменную, которая будет содержать в себе информацию о возникшей ошибке, и в случае возврата кода ошибки Вы сможете запросить её:

// PHP 4 
require_once('cmd_php4/Command.php'); 
class CommandManager { 
    var $cmdDir = "cmd_php4"; 
    var $error_str = ""; 
 
    function setError($method, $msg) { 
        $this->error_str  = 
        get_class($this)."::{$method}(): $msg"; 
    } 
 
    function error() { 
        return $this->error_str; 
    } 
 
    function getCommandObject($cmd) { 
        $path = "{$this->cmdDir}/{$cmd}.php"; 
        if (!file_exists($path)) { 
            $this->setError(__FUNCTION__, "Cannot find $path\n"); 
            return false; 
        } 
        require_once $path; 
 
        if (!class_exists($cmd)) { 
            $this->setError(__FUNCTION__, "class $cmd does not exist"); 
            return false; 
        } 
 
        $ret = new $cmd(); 
        if (!is_a($ret, 'Command')) { 
            $this->setError(__FUNCTION__, "$cmd is not a Command"); 
            return false; 
        } 
        return $ret; 
    } 
} 

Этот простой механизм позволяет использовать метод setError() для логирования информации об ошибке. Клиент, в случае возврата кода ошибки, может запросить эту информацию используя метод error(). Возьмите этот функционал и вставьте в базовый класс, от которого Вы будете наследовать все Ваши объекты. Если Вы этого не сделаете, клиентскому скрипту придётся работать с несколькими вариантами логирования ошибок, а это не есть хорошо. Я видел проекты в которых были методы getErrorStr(), getError(), и error() в разных классах, ох как это раздражает.

Но не всегда легко сделать архитектуру в которой все классы будут наследоваться от базового. Что же нам делать, если необходимо наследовать класс от какого-нить opensource класса? Конечно вы можете реализовать интерфейс, но как известно интерфейсы появились в PHP с 5-ой версии, и, как Вы сможете увидеть в дальнейшем, PHP 5 предоставляет намного лучшее решение для обработки ошибок, чем все перечисленные.

Вы можете увидеть другой подход к управлению ошибками в PEAR пакетах. Когда происходит ошибка PEAR пакет возвращает объект типа Pear_Error (или потомка данного класса). Клиент проверяет вернувшееся значение используя статический метод PEAR::isError(). Если произошла ошибка, вернувшийся к клиенту объект Pear_Error содержит всю необходимую информацию:

  • PEAR::getMessage() – сообщение о ошибке
  • PEAR::getType() – подтип Pear_Error
  • PEAR::getUserInfo() – дополнительная информация о ошибке или её контексте
  • PEAR::getCode() – код ошибки (может быть любым)

Мы изменили метод getCommandObject(), теперь он вернет Pear_Error в случае ошибки:

// PHP 4 
require_once("PEAR.php"); 
require_once('cmd_php4/Command.php'); 
 
class CommandManager { 
    var $cmdDir = "cmd_php4"; 
 
    function getCommandObject($cmd) { 
        $path = "{$this->cmdDir}/{$cmd}.php"; 
        if (!file_exists($path)) { 
            return PEAR::RaiseError("Cannot find $path"); 
        } 
        require_once $path; 
 
        if (!class_exists($cmd)) { 
            return 
            PEAR::RaiseError("class $cmd does not exist"); 
        } 
 
        $ret = new $cmd(); 
        if (!is_a($ret, 'Command')) { 
            return 
            PEAR::RaiseError("$cmd is not a Command"); 
        } 
        return $ret; 
    } 
}

Используя Pear_Error мы убиваем двух зайцев – мы знаем, что возникла ошибка, и знаем всё о этой ошибке.

// PHP 4 
$mgr = new CommandManager(); 
$cmd = $mgr->getCommandObject('realcommand'); 
if (PEAR::isError($cmd)) { 
    print $cmd->getMessage()."\n"; 
    exit; 
} 
$cmd->execute(); 

Если мы возвращаем код ошибки это конечно позволяет нам достичь гибкости при работе с объектами, но это смотрится ужасно.

PHP4 не позволяет указать тип возвращаемого значения, а на практике удобно быть уверенным в том, что тебе возвращают. Так вот метод getCommandObject() вернет нам либо объект типа Command, либо объект типа Pear_Error. Если вы намереваетесь работать с этим методом, то Вам прийдется проверять тип возвращаемой переменной при каждом вызове метода. Таким образом “правильный” код превратиться в сущий хаос, т.к. обрастет кучей проверок.

Рассмотрим пример клиентского кода для работы с PEAR::DB без проверки на ошибки:

// PHP 4 
require_once("DB.php"); 
$db = "errors.db"; 
unlink($db); 
$dsn = "sqlite://./$db"; 
$db = DB::connect($dsn); 
$create_result = $db->query("CREATE TABLE records(name varchar(255))"); 
$insert_result = $db->query("INSERT INTO records values('OK Computer')"); 
$query_result = $db->query("SELECT * FROM records"); 
$row = $query_result->fetchRow(DB_FETCHMODE_ASSOC); 
print $row['name']."\n"; 
$drop_result = $db->query("drop TABLE records"); 
$db->disconnect(); 

Данный код выглядит читабельным. Мы открываем БД, создаём таблицу, добавляем запись, выбираем запись, и уничтожаем таблицу. А теперь посмотрим как будет выглядеть этот же код, только приведенный к рабочему виду:

// PHP 4 
require_once("DB.php"); 
$db = "errors.db"; 
unlink($db); 
$dsn = "sqlite://./$db"; 
 
$db = DB::connect($dsn); 
if (DB::isError($db)) { 
    die ($db->getMessage()); 
} 
 
$create_result = $db->query("CREATE TABLE records (name varchar(255))"); 
if (DB::isError($create_result)) { 
    die ($create_result->getMessage()); 
} 
 
$insert_result = $db->query("INSERT INTO records values('OK Computer')"); 
if (DB::isError($insert_result)) { 
    die ($insert_result->getMessage()); 
} 
 
$query_result = $db->query("SELECT * FROM records"); 
if (DB::isError($query_result)) { 
    die ($query_result->getMessage()); 
} 
 
$row = $query_result->fetchRow(DB_FETCHMODE_ASSOC); 
print $row['name']."\n"; 
 
$drop_result = $db->query("drop TABLE records"); 
if (DB::isError($drop_result)) { 
    die ($drop_result->getMessage()); 
} 
 
$db->disconnect();

По правде говоря, это немного параноидальный примерчик, но зато он замечательно иллюстрирует какой хаос появляется если проверять на возникновение ошибок таким образом.

Пришли время сделать вывод о том, что же нам надо от механизма управления ошибками:

  • Чтобы предоставлял механизм делегирования полномочия по управлению ошибками в клиентский класс, которому лучше знать, как реагировать на ошибку.
  • Чтобы предоставлял детальную информацию о возникшей проблеме.
  • Чтобы давал возможность обрабатывать несколько различных ошибочных ситуаций в одном месте, тем самым отделяя “правильный воркфлоу” от операций по обработки ошибок и восстановления после них
  • Чтобы не влезал ногами в возвращаемые значения.

Механизм обработки исключения в PHP5 получает зачёт по всем выдвинутым требованиям. О них в следующей части.

Перевод статьи с Zend Developer Zone: Exceptional Code – PART 1.
Вторая часть доступна пока только на английском языке – Exceptional Code – PART 2

Переводили: Антон Шевчук и Сергей Мовчан.

© Антон Шевчук 2007-2017