Permalink
Find file
15571f1 Dec 14, 2016
executable file 307 lines (211 sloc) 26.3 KB

Как использовать исключения в PHP

Если ты изучаешь ООП, ты наверняка натыкался на исключения. В мануале 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($file) {
    // Файла не существует — ошибка
    if (!file_exists($file)) {
        die("Ошибка: файл $file не существует\n");
    }

    ....
}

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

Что же, давай улучшим код и переделаем функцию, чтобы она возвращала массив из 2 элементов: если все ок, то элемент success содержит true, а элемент result содержит массив пользователей. Если же произошла ошибка, то в success будет находиться false, а в элементе error текст ошибки.

function loadUsersFromFile($file) {
    // Файла не существует — ошибка
    if (!file_exists($file)) {
        return [
            'success'   =>  false,
            'error'     =>  "файл $file не существует"
        ];
    }

    .... загружаем информацию о пользователях ....

    return [
        'success'   =>  true,
        'result'    =>  $users
    ];
}

Конечно, мы должны поменять и код, который вызывает функцию:

....
// Загружаем список пользователей в массив
$loadResult = loadUsersFromFile($file); 

// можно еще писать if (!$loadResult['success'])
if ($loadResult['success'] === false) {  
    // Выводим текст ошибки
    die("Не удалось вывести список пользователей из-за ошибки: {$loadResult['error']}\n");
}

$users = $loadResult['result'];
...

Теперь код гораздо лучше. Тот, кто вызывает функцию, может обрабатывать ошибки так, как ему хочется. Но за это нам пришлось заплатить усложнением кода: теперь мы должны после каждого вызова писать if и проверять, успешно ли выполнилась функция. Когда функций много, и каждая может вернуть ошибку, код начинает наполовину состоять из таких проверок.

Выбрасываем исключение

В качестве решения проблемы были придуманы исключения. Если в функции произошла какая-то ошибка и она не может выполнить свою работу, то она выбрасывает исключение командой throw:

if (!file_exists($file)) {
    throw new Exception("Ошибка: файл $file не существует");
}

Исключение — это объект встроенного в PHP класса Exception (мануал по Exception) или его наследника. Объект исключения содержит подробности о причинах ошибки. Также, в PHP есть еще другие классы исключений, которые ты можешь использовать: http://php.net/manual/ru/spl.exceptions.php

В PHP7 определение расширено: исключения не обязаны наследоваться от Exception, это может быть любой класс, реализующий интерфейс Throwable, а так как сам Exception его реализует, то его наследники по-прежнему будут работать.

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

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

Исключение по умолчанию (если оно не перехватывается) выходит из всех вызовов функций до самого верха и завершает программу, выводя сообщение об ошибке. Таким образом, если ты не перехватываешь исключения, то все равно увидишь причину ошибки (а если у тебя установлено расширение xdebug то еще и стектрейс — цепочку вызовов функций, внутри которых оно произошло). И тебе больше не надо писать if:

// Если тут произойдет исключение, оно само завершит программу
// потому у нас нет необходимости проверять результат
$users = loadUsersFromFile($file);

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

Вот простой пример:

function a() 
{
    b();
}

function b()
{
    throw new Exception("Some error happened");
}

// Функция a() вызывает b() которая выбрасывает исключение. Исключение выходит
// из функции b() наверх в функцию a(), выходит из нее и, оказавшись на верхнем 
// уровне, завершает программу
a();

// Эта строчка никогда не будет выполнена
echo "Survived\n"; 

В данном примере кода исключение мгновенно выйдет из всех вложенных вызовов функций и завершит программу. Это хорошо, так как если произошла какая-то неисправимая ошибка, выполнять программу дальше нельзя. Это называется принципом fail fast (статья на Хабре).

Ловим исключения

Исключения можно "ловить". Это полезно в нескольких случаях. Иногда мы можем как-то отреагировать на неудачу: например, при ошибке скачивании файла по сети можно сделать паузу и повторить попытку. Для этого нам надо перехватить выброшенное функцией исключение.

Также, исключения обычно ловят на верхнем уровне программы в веб-приложениях для того, чтобы сделать свою страницу, информирующую об ошибке (так как PHP при непойманном исключении завершает программу, и пользователь видит пустую белую страницу в браузере, что плохо). В приложениях, запускаемых в командной строке, а не в браузере, обычно это не требуется, так как они не ориентированы на "обычных" пользователей.

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

set_exception_handler(function (Exception $exception) {
    // Функция будет вызвана при возникновении исключения        
});

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

Обрати внимание, мы указали в функции-обработчике, что аргумент $exception относится к классу Exception или его наследнику. Так как в PHP7 появились исключения, не являющиеся наследниками Exception, то для него мы должны указать Throwable (который не будет работать в PHP5). Чтобы код работал в обоих версиях PHP, придется отказаться от тайп-хинта.

Структурная обработка исключений - это когда мы ловим только исключения определенных типов в определенном месте кода. Она реализуется с помощью try/catch:

try {
    // В try пишется код, в котором мы хотим перехватывать исключения
    $users = loadUsersFromFile(...);
    ....
} catch (LoadUsersException $e) {
    // В catch мы указываем, исключения каких классов хотим ловить.
    // В данном случае мы ловим исключения класса LoadUsersException и его 
    // наследников, то есть только те, которые выбрасывает наша функция
    // Блоков catch может быть несколько, для разных классов

    die("Ошибочка: {$e->getMessage()}\n");
}

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

В PHP5.5 и выше добавлен блок finally. Команды из этого блока будут выполнены после любого из блоков (try или catch) — в случае если исключения не произойдет и в случае если оно произойдет.

Если в catch указать Exception (или Throwable для PHP7), то он будет ловить все типы исключений. Это, как правило, плохая идея, так как мы хотим обрабатывать только определенные, "наши", типы ошибок и не знаем что делать с другими. Чтобы перехватывать только нужные нам исключения, надо сделать свой класс на основе встроенного в PHP Exception:

class LoadUsersException extends Exception { }

Выкидывать его в throw

throw new LoadUsersException("Файл $file не существует");

И ловить в catch только наши исключения:

catch (LoadUsersException $e) {
    .... обрабатываем ошибку ...
}

Мы можем рассматривать исключения как часть интерфейса функции (часть правил работы с этой функцией). Есть аргументы, которые мы даем на вход, есть результат, который она возвращает, и есть исключения которые она может выкинуть при ошибке. И использование исключений позволяет пользователю функции (тому кто ее вызвал) решить что делать в случае ошибки.

Поддержка исключений везде

Эта система работала бы идеально, если бы стандартные функции PHP выкидывали исключения при ошибках. Но увы, по историческим причинам эти функции просто генерируют сообщение об ошибке и возвращают false. Ну например, функция чтения файла в память file_get_contents поступает именно так, и ошибка чтения файла или его отсутствие не завершает программу. Потому ты обязан ставить if после каждого вызова:

// Если файла нет, функция просто вернет false, и программа продолжит выполняться
$content = file_get_contents('file.txt');
if ($content === false) {
    ... не удалось прочесть файл ...
}

Многие разработчики забывают или ленятся проверять результат выполнения функции, и пишут некорректные программы, которые даже в случае ошибки пытаются продолжать выполнение. Это опасно, так как программа, в которой возникла ошибка, скорее всего будет дальше работать неправильно и выведет неточную информацию. Например, программа берет из базы сумму денег на счету пользователя, что-то меняет в ней и сохраняет ее обратно в базу. Если мы опечатаемся в имени переменной, может получиться так, что в базу мы запишем ноль. Хотя 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) включают в себя такой код.

Исключения и PDO

Чтобы расширение для работы с базами данных PDO использовало исключения (например при попытке выполнить неправильно написанный SQL запрос), надо установить соответствующий параметр (рекомендуется). Без него ты будешь должен после вызова каждой функции проверять результат с помощью if:

$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

Мануал: http://php.net/manual/ru/pdo.error-handling.php

Исключения и mysqli

Библиотека mysqli при ошибках не выбрасывает исключений и не генерирует предупреждений, а просто возвращает false (то есть молчит как партизан). Таким образом, после каждого действия ты должен проверять результат с помощью if, что видно в примерах кода в мануале: http://php.net/manual/ru/mysqli.query.php

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

Чтобы не писать ифы во всей программе, ты можешь сделать класс-обертку над mysqli. Или просто использовать PDO.

Так делать не надо

Не стоит ловить все исключения без разбора:

catch (Exception $e)

Лучше создать свой класс исключений и ловить только его.

Не надо скрывать информацию об ошибках. В 99% этот код, игнорирующий исключение, неправильный:

catch (Exception $e) {
    // ничего не делаем
}

Не надо располагать try/catch и throw на одном уровне — в этом случае проще написать if:

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 (плохо)

Как надо обрабатывать исключения:

  • записать информацию в лог
  • показать пользователю заглушку ("сайт временно недоступен, вот контакты администратора")
  • на заглушке выставить HTTP код ответа 503 для роботов
  • на компьютере разработчика (при display_errors = 1) можно показать подробности и стектрейс

Для реализации страницы ошибки можно либо сделать try/catch на уровне FrontController, либо установить свой обработчик исключений через set_exception_handler. Не забудь записать информацию в лог с помощью error_log($e->__toString()) - иначе ты не узнаешь об ошибках которые происходят у пользователей твоего приложения.

Разумеется, страницу ошибки надо протестировать - выкинуть в коде исключение и убедиться, что страница показывается, HTTP код выдается, а исключение пишется в лог ошибок.

Если ты используешь фреймворк, возможно, в нем все это уже реализовано. Современные фреймворки, такие как Slim, Yii 2, Symfony 2, выводят заглушку при непойманном исключении. Старые - не выводят.

Ссылки

Механизм исключений существует и в других языках в таком же виде: 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»