Работа с исключениями в PHP

02.09.2018 в 17:38
10070
+547

Сегодня мы с вами разберем такую тему как исключения в PHP. Но прежде чем перейти к деталям, давайте дадим простое понятие термину «Исключительная ситуация». Исключительная ситуация в программе – это ситуация, при которой дальнейшее выполнение кода не имеет смысла. Например, новости на страничке /post/add могут добавлять только администраторы, но при этом это пытается сделать неавторизованный пользователь. Здесь имеет смысл проверить в самом начале права пользователя и если их недостаточно, обработать эту исключительную ситуацию. Или вот ещё один пример: наше приложение работает с базой данных, но при подключении к серверу MySQL обнаруживается, что такой базы данных на сервере нет. При такой ситуации тоже не имеет смысла продолжать выполнение скрипта – это исключительная ситуация.

В PHP для работы с такими исключительными ситуациями есть специальный механизм - исключения. Исключение – это такой объект специального класса. Этот класс является встроенным в PHP и называется Exception. Создание исключения выглядит следующим образом:

<?php

$exception = new Exception();

В качестве аргументов в конструктор можно передать сообщение и код ошибки:

$exception = new Exception('Сообщение об ошибке', 123);

При этом класс Exception это немного нестандартный класс – объекты этого класса можно «бросать». Для этого используется оператор throw. Делается это вот так:

<?php

$exception = new Exception('Сообщение об ошибке', 123);

throw $exception;

Если мы сейчас запустим этот скрипт, то увидим следующее:
Непойманное стандартное исключение

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

Для того, чтобы исключение поймать, используется специальная конструкция try-catch:

<?php

try {
    throw new Exception('Сообщение об ошибке', 123);    
} catch (Exception $e) {
    echo 'Было поймано исключение: ' . $e->getMessage() . '. Код: ' . $e->getCode();
}

Результат будет следующим:
Обработанное стандартное исключение

В чем же смысл такой сложной конструкции? Дело в том, что исключение будет подниматься по стеку вызовов выше и выше, до тех пор, пока оно не будет поймано. Вот что это значит:
Пусть у нас есть 3 функции: func1, func2 и func3. func1 вызывает внутри себя func2, а func2 вызывает func3.

function func1()
{
    // какой-то код
    func2();
}

function func2()
{
    // какой-то код
    func3();
}

function func3()
{
    // код, в котором возможна исключительная ситуация
    throw new Exception('Ошибка при подключении к БД');
}

Для того, чтобы обработать это исключение уровнем выше, достаточно написать блок try-catch на уровне func2, обернув вызов func3 внутри секции try:

<?php

function func1()
{
    // какой-то код
    func2();
}

function func2()
{
    try {
        // какой-то код
        func3();
    } catch (Exception $e) {
        echo 'Было поймано исключение: ' . $e->getMessage();
    }
}

function func3()
{
    // код, в котором возможна исключительная ситуация
    throw new Exception('Ошибка при подключении к БД');
}

func1();

Исключение, пойманное на другом уровне

А можем и вовсе поймать его и в func1:

<?php

function func1()
{
    try {
        // какой-то код
        func2();
    } catch (Exception $e) {
        echo 'Было поймано исключение: ' . $e->getMessage();
    }
}

function func2()
{
    // какой-то код
    func3();
}

function func3()
{
    // код, в котором возможна исключительная ситуация
    throw new Exception('Ошибка при подключении к БД');
}

func1();

Результат будет таким же:
То же исключение

В этом случае исключение будет брошено внутри func3, поднимется на уровень func2, там его никто не поймает, и оно пойдет на еще уровень выше, в место, где была вызвана func2 – внутри func1. И вот здесь-то оно и будет поймано и обработано. После того как исключение обработано, будет выполнен код, который идет после блока try-catch.

<?php

function func1()
{
    try {
        // какой-то код
        func2();
    } catch (Exception $e) {
        echo 'Было поймано исключение: ' . $e->getMessage();
    }

    echo 'А теперь выполнится этот код';
}

function func2()
{
    // какой-то код
    func3();
}

function func3()
{
    // код, в котором возможна исключительная ситуация
    throw new Exception('Ошибка при подключении к БД');

    echo 'Этот код не выполнится, так как идет после места, где было брошено исключение';
}

func1();

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

Наследование классов-исключений

От класса Exception можно наследоваться. Таким образом, мы можем создавать свои классы исключений для разных ситуаций. Например, для ошибок при работе с базой данных мы можем создать класс DbException, а для ошибок при работе с файлами – FileSystemException.

Блок try-catch позволяет обрабатывать разные типы исключений, это выглядит так:

try {
    // тут какой-то код
} catch (DbException $e) {
    // обработка исключений, связанных с базой данных
} catch (FileSystemException $e) {
    // обработка исключений, связанных с файловой системой
}

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

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

src/settings.php

<?php

return [
    'db' => [
        'host' => 'localhost',
        'dbname' => 'my_project2',
        'user' => 'root',
        'password' => '',
    ]
];

PDOException не поймано

Как видим, возникла ошибка – непойманное исключение типа PDOException. Это такой встроенный в PHP класс для исключений при работе с базой данных через PDO.

Давайте теперь создадим собственный класс исключений специально для базы данных. Назовём его DbException.

src/MyProject/Exceptions/DbException.php

<?php

namespace MyProject\Exceptions;

class DbException extends \Exception
{
}

Теперь создадим «ловушки» для стандартных исключениий класса PDOException, и будем заменять их своими исключениями. Вот так:

src/MyProject/Services/Db.php

private function __construct()
{
    $dbOptions = (require __DIR__ . '/../../settings.php')['db'];

    try {
        $this->pdo = new \PDO(
            'mysql:host=' . $dbOptions['host'] . ';dbname=' . $dbOptions['dbname'],
            $dbOptions['user'],
            $dbOptions['password']
        );
        $this->pdo->exec('SET NAMES UTF8');
    } catch (\PDOException $e) {
        throw new DbException('Ошибка при подключении к базе данных: ' . $e->getMessage());
    }
}

Снова перезагрузим страничку и увидим уже наше сообщение об ошибке.

Давайте теперь обработаем наше исключение. Для этого нам требуется поймать исключение уже с типом DbException. Так как ошибка при работе с базой данных – это критичная ошибка, которая наверняка не позволит выполняться программе дальше, нам стоит ловить её на самом низком уровне нашего приложения – во фронт-контроллере. Обернем в блок try-catch код фронт-контроллера.

www/index.php

try {
    spl_autoload_register(function (string $className) {
        require_once __DIR__ . '/../src/' . $className . '.php';
    });

    $route = $_GET['route'] ?? '';
    $routes = require __DIR__ . '/../src/routes.php';

    $isRouteFound = false;
    foreach ($routes as $pattern => $controllerAndAction) {
        preg_match($pattern, $route, $matches);
        if (!empty($matches)) {
            $isRouteFound = true;
            break;
        }
    }

    if (!$isRouteFound) {
        echo 'Страница не найдена!';
        return;
    }

    unset($matches[0]);

    $controllerName = $controllerAndAction[0];
    $actionName = $controllerAndAction[1];

    $controller = new $controllerName();
    $controller->$actionName(...$matches);
} catch (\MyProject\Exceptions\DbException $e) {
    echo $e->getMessage();
}

Если мы теперь обновим страничку, то увидим уже только наше сообщение, без каких-либо необработанных исключений. Мы поймали исключение и просто вывели текст ошибки через echo.
А теперь давайте во фронт-контроллере научимся выводить ошибки, используя наш компонент View. То есть мы будем выводить ошибки через шаблоны. Это позволит избежать дублирования кода и всегда использовать один механизм для вывода.

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

templates/errors/500.php

<h1>Хьюстон, у нас проблема!</h1>
<?= $error ?>

А теперь просто обработаем исключение во фронт-контроллере по-новому.

www/index.php

...
    $controller = new $controllerName();
    $controller->$actionName(...$matches);
} catch (\MyProject\Exceptions\DbException $e) {
    $view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
    $view->renderHtml('500.php', ['error' => $e->getMessage()], 500);
}

Перезагружаем страничку.
Обработанная ошибка работы с БД

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

Исключение не брошено - всё работает

А теперь давайте посмотрим на вот этот кусок кода во фронт-контроллере:

if (!$isRouteFound) {
    echo 'Страница не найдена!';
    return;
}

Этот код работает, когда не найден нужный роутинг. Это ведь тоже исключительная ситуация! Давайте сделаем исключение для случаев, когда страничка не найдена.

src/MyProject/Exceptions/NotFoundException.php

<?php

namespace MyProject\Exceptions;

class NotFoundException extends \Exception
{
}

И начнём бросать его в случае, когда роут не найден. Для этого заменяем код

www/index.php

if (!$isRouteFound) {
    echo 'Страница не найдена!';
    return;
}

на

if (!$isRouteFound) {
    throw new \MyProject\Exceptions\NotFoundException();
}

И проверяем на несуществующем роуте, например:
Исключение о несуществующей страничке

Видим ошибку о непойманном исключении, так давайте же его поймаем! Для этого добавляем еще один блок catch.

www/index.php

<?php

try {
    spl_autoload_register(function (string $className) {
        require_once __DIR__ . '/../src/' . $className . '.php';
    });

    $route = $_GET['route'] ?? '';
    $routes = require __DIR__ . '/../src/routes.php';

    $isRouteFound = false;
    foreach ($routes as $pattern => $controllerAndAction) {
        preg_match($pattern, $route, $matches);
        if (!empty($matches)) {
            $isRouteFound = true;
            break;
        }
    }

    if (!$isRouteFound) {
        throw new \MyProject\Exceptions\NotFoundException();
    }

    unset($matches[0]);

    $controllerName = $controllerAndAction[0];
    $actionName = $controllerAndAction[1];

    $controller = new $controllerName();
    $controller->$actionName(...$matches);
} catch (\MyProject\Exceptions\DbException $e) {
    $view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
    $view->renderHtml('500.php', ['error' => $e->getMessage()], 500);
} catch (\MyProject\Exceptions\NotFoundException $e) {
    $view = new \MyProject\View\View(__DIR__ . '/../templates/errors');
    $view->renderHtml('404.php', ['error' => $e->getMessage()], 404);
}

Пробуем снова зайти по несуществующему адресу:
Корректная 404 страничка

Теперь мы видим ошибку из шаблона, и при этом сервер вернул код 404 – то, что нужно!

А теперь – внимание. Помните, мы уже использовали шаблон ошибки 404? Мы тогда писали в контроллере статей что-то типа такого:

src/MyProject/Controllers/ArticlesController.php

if ($article === null) {
    $this->view->renderHtml('errors/404.php', [], 404);
    return;
}

А теперь мы можем просто кинуть там исключение, вот так:

if ($article === null) {
    throw new NotFoundException();
}

И оно всплывет через все слои нашей программы до фронт-контроллера, где будет успешно поймано!

Давайте перепишем контроллер статей:

src/MyProject/Controllers/ArticlesController.php

<?php

namespace MyProject\Controllers;

use MyProject\Exceptions\NotFoundException;
use MyProject\Models\Articles\Article;
use MyProject\Models\Users\User;
use MyProject\View\View;

class ArticlesController
{
    /** @var View */
    private $view;

    public function __construct()
    {
        $this->view = new View(__DIR__ . '/../../../templates');
    }

    public function view(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            throw new NotFoundException();
        }

        $this->view->renderHtml('articles/view.php', [
            'article' => $article
        ]);
    }

    public function edit(int $articleId)
    {
        $article = Article::getById($articleId);

        if ($article === null) {
            throw new NotFoundException();
        }

        $article->setName('Новое название статьи');
        $article->setText('Новый текст статьи');

        $article->save();
    }

    public function add(): void
    {
        $author = User::getById(1);

        $article = new Article();
        $article->setAuthor($author);
        $article->setName('Новое название статьи');
        $article->setText('Новый текст статьи');

        $article->save();

        var_dump($article);
    }
}

А теперь попробуем зайти на адрес с несуществующей статьёй: http://myproject.loc/articles/123
Ненайденная страничка статьи

Видим всё ту же ошибку! Давайте теперь посмотрим, что произошло. Поставим breakpoint в отладчике на место, где бросается исключение и обновим страничку.
Бросаем исключение и наблюдаем

Видите, слева внизу есть стек вызовов. Если переключиться на предыдущий уровень (index.php), мы увидим, где был вызван экшн контроллера:
Смотрим место где было брошено исключение

Это произошло в файле index.php, внутри блока try-catch. Так как в самом экшене брошенное исключение никто не ловит, оно всплывет на уровень, где был вызван экшен – в файл index.php. Если мы сейчас нажмем F8, то увидим, что исключение было успешно поймано и мы попали в нужный нам блок catch.

Обработка исключения

Здесь мы вывели сообщение об ошибке – вот и всё! Теперь, если нам в приложении потребуется где-то вывести сообщение о несуществующей странице – мы просто бросим исключение с нужным типом.

На этом всё, за домашку =)

Текущая версия проекта на гитхабе.

loader
02.09.2018 в 17:38
10070
+547
Домашнее задание

Самостоятельно прочитайте в официальной документации о новом типе ошибок в PHP 7 – Error. Их тоже можно бросать, однако на данном этапе мы их использовать не будем и пока ограничимся только типом Exception. Также изучите самостоятельно интерфейс Throwable. Попробуйте реализовать этот интерфейс в каком-нибудь классе.

Комментарии
Этот урок набрал набрал достаточно большое количество комментариев и дальнейшее его комментирование отключено. Если вы хотели убедиться в правильности выполнения ДЗ или у вас возник вопрос по уроку, посмотрите ранее добавленные комментарии, кликнув по кнопке ниже. Скорее всего вы найдете там то, что искали. Если это не помогло - задайте вопрос в чате в телеграме - https://t.me/php_zone
Логические задачи с собеседований