Skip to content

Latest commit

 

History

History
executable file
·
378 lines (268 loc) · 33.2 KB

exceptions.md

File metadata and controls

executable file
·
378 lines (268 loc) · 33.2 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(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 выведет содержащийся в исключении текст ошибки в консоль вместе с стектрейсом (распечаткой стека) - списком вызовов функций, которые привели к исключению.

Если же 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

По умолчанию функции из расширения для работы с базами данных PDO при ошибках не вырасывают исключения и не генерируют сообщения, а просто явно возращают значение вроде false. Чтобы функции 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

К счастью, ее можно переключить на выброс исключений при ошибках. В мануале описано поле 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»