Если ты изучаешь ООП, то наверняка слышал про исключения. В мануале PHP описаны конструкции try
/catch
/throw
и finally
(доступна только в PHP 5.5 и выше), но не объясняется, как их использовать и зачем они придуманы.
А придуманы они были как удобный способ обработки ошибок.
Для примера представим, что мы пишем программу для вывода списка пользователей из файла на экран. Допустим, код выглядит как-то так:
$file = './users.csv';
// Загружаем список пользователей из файла в массив
$users = loadUsersFromFile($file);
// Выводим
foreach ($users as $user) {
echo "{$user['name']} набрал {$user['score']} очков\n";
}
Все ли тут верно? Не все. Мы забыли сделать обработку ошибок. Файла может не существовать, к нему может не быть доступа, данные в нем могут быть в неверном формате. Хорошая программа, разумеется, должна обрабатывать такие ситуации и выводить соответствующее сообщение.
Самый плохой (но простой) вариант — поместить код обработки и вывода ошибки в функцию loadUsersFromFile()
:
/**
* Загружает список пользователей из файла и возвращает
* массив с ними. При ошибке выводит сообщение и завершает
* программу.
*/
function loadUsersFromFile(string $file): array {
// Файла не существует — ошибка
if (!file_exists($file)) {
die("Ошибка: файл $file не существует\n");
}
....
}
Этот вариант плохой, так как тот, кто вызывает функцию loadUsersFromFile
, не может никак повлиять на обработку ошибок. Может быть, он хочет при ошибке вывести другое сообщение или хочет попробовать поискать файл в другом месте. Но наша функция просто завершает программу, не оставляя никакого выбора.
Правильнее будет разделить ответственность: функция лишь возвращает информацию об ошибке, а тот, кто её вызвал - решает, что с ней делать.
Для этого мы можем переделать функцию, чтобы она возвращала массив из 2 элементов: если данные загружены, то элемент success
содержит true
, а элемент users
содержит массив пользователей. Если же произошла ошибка, то в success
будет находиться false
, а в элементе error
- текст ошибки.
/**
* Загружает данные пользователей из файла. Возвращает
* массив с ключами:
*
* - success - true при успехе, false при ошибке
* - error - присуствует в случае ошибки, содержит текст ошибки
* - users - присутствует в случае успеха, содержит массив с данными
*/
function loadUsersFromFile(string $file): array {
// Файла не существует — ошибка
if (!file_exists($file)) {
return [
'success' => false,
'error' => "файл $file не существует"
];
}
.... загружаем информацию о пользователях ....
return [
'success' => true,
'users' => $users
];
}
Конечно, мы должны поменять и код, который вызывает функцию:
....
// Загружаем список пользователей в массив
$result = loadUsersFromFile($file);
// можно еще писать if (!$result['success'])
if ($result['success'] === false) {
// Выводим текст ошибки
die("Не удалось вывести список пользователей из-за ошибки: {$result['error']}\n");
}
$users = $result['users'];
...
Код стал гораздо лучше. Теперь можно обрабатывать ошибки так, как хочется. Но за это нам пришлось заплатить увеличением объема кода: мы должны после каждого вызова функции писать if
и проверять, успешно ли она выполнилась. Когда функций много, и каждая может вернуть ошибку, код начинает наполовину состоять из таких проверок.
В качестве альтернативного подхода были придуманы исключения. Если функция по какой-то причине не может выполнить свою работу, то она создает объект исключения, содержащий подробности ошибки и выбрасывает его командой throw
:
if (!file_exists($file)) {
throw new Exception("Ошибка: файл $file не существует");
}
Исключение — это объект любого класса, который реализует встроенный в PHP интерфейс Throwable (реализует - значит, содержит описанные в интерфейсе методы). Предполагается, что для каждого типа ошибки создаются свои классы исключений, что позволяет потом их различать.
До PHP7 класс исключения надо было унаследовать от встроенного в PHP класса Exception, а в новой версии такого требования больше нет. Однако, напрямую реализовать интерфейс Throwable
все равно нельзя, и придется создавать свои исключения на основе стандартного класса. Также, в PHP есть другие классы исключений, которые могут пригодиться: http://php.net/manual/ru/spl.exceptions.php
Объект исключения хранит в себе подробности ошибки. В простейшем случае это текст ошибки, но можно делать дополнительные поля и методы. Например, исключение FileReadException, описывающее ошибку чтения из файла, может содержать поле с именем файла. Объект исключения обычно неизменен: все значения полей задаются конструктором в момент создания.
В сообщении стоит указать все подробности ошибки. Например, при ошибке чтения файла полезно указать имя файла. Это упростит отладку кода.
Исключение выбрасывается в случае возникновения нештатной ситуации, когда функция обнаруживает, что не способна выполнить свою задачу.
Вот пример класса-исключения. Он унаследован от встроенного в PHP класса:
/**
* Ошибка при чтении файла конфигурации
*/
class ConfigException extends \Exception
{
// Ничего не добавляем
}
Исключение по умолчанию (если оно не перехватывается) выходит из всех вызовов функций до самого верха и завершает программу, выводя сообщение об ошибке (но если в конфигурации PHP стоит display_errors = off, то сообщение не выводится). Таким образом, если ты не перехватываешь исключения, то все равно увидишь причину ошибки (а если у тебя установлено расширение xdebug
, то еще и стектрейс — цепочку вызовов функций, внутри которых оно произошло). Код становится проще:
// Если тут произойдет исключение, оно само завершит программу
// потому у нас нет необходимости проверять результат
$users = loadUsersFromFile($file);
Таким образом, каждая функция либо возвращает результат, либо выбрасывает исключение.
Вот пример, показывающий работу исключений:
function a()
{
echo "В начале a\n";
b();
echo "В конце a\n";
}
function b()
{
echo "В начале b\n";
throw new Exception("Ошибка в функции b()");
echo "В конце b\n";
}
// Функция a() вызывает b(), которая выбрасывает исключение. Исключение выходит
// из функции b() наверх в функцию a(), выходит из нее и, оказавшись на верхнем
// уровне, завершает программу сообщением об ошибке.
a();
echo "В конце программы\n";
Если запустить эту программу (код на ideone), то она выведет:
В начале a
В начале b
PHP Fatal error: Uncaught Exception: Ошибка в функции b() in /home/XMoMDv/prog.php:13
В данном примере кода исключение мгновенно выйдет из всех вложенных вызовов функций и завершит программу. И это правильно, так как незачем выполнять программу, где есть ошибки. Это называется принципом fail fast (статья на Хабре).
Исключения имеют и недостатки в сравнении с явным возвратом ошибок. Например, они могут быть выброшены при выполнении любой функции, и сложнее становится угадать, как будет выполняться код.
Если в программе возникло исключение, и мы его не перехватили, то оно завершает программу. Если программа была запущена в командной строке, то PHP выведет содержащийся в исключении текст ошибки в консоль вместе с стектрейсом (распечаткой стека) - списком вызовов функций, которые привели к исключению.
Если же PHP используется в веб-сервере, то его поведение зависит от настройки display_errors. Если она равна 1 или on
, то при исключении в браузер будет выведен текст исключения и распечатка стека. Это полезно на машине разработчика, но недопустимо использовать на реальных серверах - чтобы злоумышленник не мог извлечь какую-то информацию об устройстве приложения. На них ставится настройка display_errors = 0 (или off
) и при исключении показывается просто пустая страница. Также, в обоих случаях текст исключения записывается в лог (журнал) ошибок веб-сервера. Журнал помогает разработчику увидеть, какие ошибки происходили на сайте за последнее время.
Белая страница - это не очень красиво, и ее могут проиндексировать поисковые роботы. Потому обычно в веб-приложениях ловят все исключения на верхнем уровне и показывают «страницу ошибки», а для роботов добавляют специальный HTTP-код ответа, говорящий о проблеме. Ниже будет описано, как это сделать.
Если мы хотим как-то обработать ошибку, то мы можем "ловить" выброшенные исключения. Например, при ошибке скачивании файла по сети можно сделать паузу и повторить попытку.
Перехватывать исключения можно двумя способами: неструктурно и структурно. Неструктурно — это когда мы задаем обработчик исключений в начале программы:
set_exception_handler(function (Throwable $exception) {
// Функция будет вызвана, если исключение не будет
// поймано и завершит программу.
//
// Она может записать исключение в журнал и вывести
// страницу ошибки.
error_log($exception->__toString());
header("HTTP/1.0 503 Temporary unavailable");
header("Content-type: text/plain; charset=utf-8");
echo "Извините, на сайте произошла ошибка.\n";
echo "Попробуйте перезагрузить страницу.\n";
});
Этот способ ловит неперехваченные исключения любых видов во всей программе, его можно использовать для того, чтобы сделать свою страницу ошибки. После выполнения обработчика программа будет завершена в любом случае, продолжить ее выполнение невозможно.
Обрати внимание, мы указали в функции-обработчике, что аргумент $exception
реализует интерфейс Throwable
. До PHP7 надо было указывать в тайп-хинте базовый класс исключений - Exception
. Чтобы код работал в обоих версиях PHP, придется отказаться от тайп-хинта.
Структурная обработка исключений - это отлов исключений определенных типов в указанном месте кода. Она реализуется с помощью конструкции try
/catch
:
try {
// В try пишется код, в котором мы хотим перехватывать исключения
$users = loadUsersFromFile(...);
....
} catch (LoadUsersException $e) {
// В catch мы указываем, исключения каких классов хотим ловить.
// В данном случае мы ловим исключения класса LoadUsersException и его
// наследников, то есть только те, которые выбрасывает наша функция
// Блоков catch может быть несколько, для разных классов
die("Ошибочка: {$e->getMessage()}\n");
}
В этом примере мы ловим исключения класса LoadUsersException
или его наследника, возникшее внутри блока try
. Остальные исключения не перехватываются.
В PHP5.5 добавлен блок finally
. Команды из этого блока будут выполнены после любого из блоков (try
или catch
) — в случае, если исключения не произойдет и в случае если оно произойдет. Это можно использовать для освобождения каких-то ресурсов. Например, если код внутри try
открыл файл на чтение, мы можем поставить команду закрытия в finally
и она будет выполнена в любом случае.
Если в catch
указать Exception
(или Throwable
для PHP7), то он будет ловить все типы исключений. Это, как правило, плохая идея, так как мы хотим обрабатывать только определенные типы ошибок и не знаем, что делать с другими. Чтобы перехватывать только нужные нам исключения, надо сделать свой класс на основе встроенного в PHP Exception
:
/** Ошибка загрузки данных о пользователях */
class LoadUsersException extends Exception { }
При ошибке мы создаем объект и выкидываем его с помощью throw
throw new LoadUsersException("Файл $file не существует");
Такое исключение можно будет поймать с помощью catch
:
catch (LoadUsersException $e) {
.... обрабатываем ошибку ...
}
Мы можем рассматривать исключения как часть интерфейса функции (часть правил работы с этой функцией). Есть аргументы, которые мы даем на вход, есть результат, который она возвращает, и есть исключения, которые она может выкинуть при ошибке. И использование исключений позволяет пользователю функции (тому кто ее вызвал) решить, что делать в случае ошибки.
Мы не обязаны всегда использовать исключения. Можно использовать и явный возврат ошибок, если это удобнее.
По умолчанию многие встроенные в PHP функции не выбрасывают исключения при ошибке, а используют «классическую» систему обработки ошибок. При ошибке эти функции не выбрасывают исключение, а возвращают определенное значение - например, false
, и также генерируют сообщение (есть несколько типов сообщений - notice, warning, error, они перечислены в мануале), которое может записываться в лог ошибок и выводиться на экран (или не записываться и не выводиться, в зависимости от настроек PHP). Эта «классическая» система обработки ошибок частично описана в мануале PHP. Что делать с сообщениями об ошибках, определяют настройки вроде display_errors
, log_errors
и error_reporting
в php.ini.
Ну например, функция чтения файла в память file_get_contents
использует «классическую» систему, и ошибка чтения файла или его отсутствие не вызывает выброс исключения и завершение программы, а просто приводит к тому, что функция вернет false
. Потому ты обязан ставить if
после каждого вызова:
// Если файла нет, функция просто вернет false, и программа продолжит выполняться
$content = file_get_contents('file.txt');
if ($content === false) {
... обрабатываем ситуацию, когда из-за ошибки не удалось прочесть файл...
... или файла не существует ...
}
Многие разработчики забывают или ленятся проверять результат выполнения функции, и пишут некорректные программы, которые даже в случае ошибки продолжают выполнение. Это опасно, так как программа, в которой возникла ошибка, скорее всего будет дальше работать неправильно и выведет неточную информацию. Допустим, программа берет из базы сумму денег на счету пользователя, что-то меняет в ней и сохраняет ее обратно в базу. Если мы опечатаемся в имени переменной, может получиться так, что в базу мы запишем ноль. Хотя PHP и выведет предупреждение о несуществующей переменной, он не прерывает выполнения программы в этом случае.
Для этой проблемы есть решение. PHP позволяет установить функцию-обработчик ошибок (она вызывается при любой ошибке PHP, например обращении к несуществующей переменной или невозможности чтения файла), и в нем выкидывать исключение. Таким образом, любая ошибка или предупреждение приведут к выбросу исключения. Все это делается в несколько строчек с помощью встроенного в PHP класса ErrorException
(мануал):
set_error_handler(function ($errno, $errstr, $errfile, $errline ) {
// Не выбрасываем исключение если ошибка подавлена с
// помощью оператора @
if (!error_reporting()) {
return;
}
throw new ErrorException($errstr, $errno, 0, $errfile, $errline);
});
Этот код превращает любые «классические» ошибки и предупреждения PHP в исключения. Некоторые современные фреймворки (Slim, Symfony, Laravel) включают в себя такой код.
По умолчанию функции из расширения для работы с базами данных PDO при ошибках не вырасывают исключения и не генерируют сообщения, а просто явно возращают значение вроде false
. Чтобы функции PDO выбрасывали исключения (например при попытке выполнить неправильно написанный SQL запрос), надо установить соответствующий параметр (рекомендуется). Без него ты будешь должен после вызова каждой функции проверять результат с помощью if
:
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
Мануал: http://php.net/manual/ru/pdo.error-handling.php
По умолчанию библиотека mysqli при ошибках не выбрасывает исключений и не генерирует сообщений, а просто возвращает false
. Таким образом, после каждого действия ты должен проверять результат с помощью if
, и сам делать вывод текста ошибки, что видно в примерах кода в мануале: http://php.net/manual/ru/mysqli.query.php
К счастью, ее можно переключить на выброс исключений при ошибках. В мануале описано поле report
, которое задает опции обработки ошибок:
$driver->report_mode = MYSQLI_REPORT_STRICT | MYSQLI_REPORT_ERROR;
Не стоит ловить все исключения без разбора:
catch (Exception $e)
Лучше создать свой класс исключений и ловить только его объекты.
Нельзя скрывать информацию об ошибках. В 99% такой код, игнорирующий исключение, неправильный:
catch (Exception $e) {
echo "Произошла ошибка";
die();
}
Из-за такого кода информация об ошибках не будет записываться в журнал, и разработчик о ней не узнает.
Не надо располагать try
/catch
и throw
на одном уровне, внутри одной и той же функции — в этом случае код может быть проще, если использовать обычные конструкции вроде if
. Обычно throw
и catch
располагают в разных функциях:
try {
...
throw new Exception(...);
...
} catch (Exception $e) {
...
}
По умолчанию при непойманном исключении PHP завершает скрипт. Если опция display_errors
в php.ini равна 1, то PHP выводит подробности об исключении, а если она равна 0, то в браузере отображается просто белая страница. Также, PHP записывает информацию об исключении в лог ошибок сервера.
Очевидно что оба варианта не годятся для использования на продакшен («боевом») сервере: обычные пользователи не должны видеть непонятные надписи на английском, и не должны смотреть на пустую страницу и гадать, в чем дело. А хакеры не должны видеть подробности об устройстве твоего приложения. Более того, PHP не выдает при ошибке HTTP код 500, который говорит роботам (вроде Гугла или Яндекса) что на странице ошибка и индексировать ее не надо. Разработчики PHP выбрали неудачное поведение по умолчанию.
Потому на боевом сервере надо ставить display_errors
в 0, а в приложении делать свою страницу ошибки.
Что будет, если просто не ловить исключение:
- информация пишется в лог (ок)
- на компьютере разработчика выводятся подробности (ок)
- пользователь видит белую страницу или подробности ошибки (плохо)
- отдается HTTP код 200 (плохо)
Как надо обрабатывать исключения:
- записать информацию в журнал ошибок, например, функцией error_log
- показать пользователю заглушку ("сайт временно недоступен, вот контакты администратора")
- на заглушке выставить HTTP код ответа 503 для роботов
- на компьютере разработчика (при display_errors = 1) можно показать подробности и стектрейс
Для реализации страницы ошибки можно либо обернуть в try/catch
код в FrontController, либо установить свой обработчик исключений через set_exception_handler
. Не забудь записать информацию в лог с помощью error_log($e->__toString())
- иначе ты не узнаешь об ошибках которые происходят у пользователей твоего приложения.
Разумеется, страницу ошибки надо протестировать - выкинуть в коде исключение и убедиться, что страница показывается, HTTP код выдается, а исключение пишется в лог ошибок.
Если ты используешь фреймворк, возможно, в нем все это уже реализовано. Современные фреймворки, такие как Slim, Yii, Symfony, выводят заглушку при непойманном исключении. Старые - не выводят.
Иногда мы ловим исключение, создаем на его основе новое исключение и выбрасываем дальше. Например, функция чтения конфига обнаруживает ошибку чтения файла FileException, и создает исключение чтения конфига ConfigException. В такой ситуации в новом исключении можно сохранить ссылку на предыдущее с помощью аргумента previous, который есть в конструкторе стандартного класса \Exception.
Механизм исключений существует и в других языках в таком же виде: Java, C++, Ruby, Javascript и многих других. Статья в вики:
https://ru.wikipedia.org/wiki/%D0%9E%D0%B1%D1%80%D0%B0%D0%B1%D0%BE%D1%82%D0%BA%D0%B0_%D0%B8%D1%81%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%BD%D0%B8%D0%B9 (написано не очень понятно)
Также, про исключения можно почитать в книгах:
- Мэтт Зандстра «PHP: Объекты, шаблоны, методики программирования»
- Джордж Шлосснейгл «Профессиональное программирование на PHP»