Работа с исключениями в PHP
Сегодня мы с вами разберем такую тему как исключения в 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. Это такой встроенный в 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? Мы тогда писали в контроллере статей что-то типа такого:
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.
Здесь мы вывели сообщение об ошибке – вот и всё! Теперь, если нам в приложении потребуется где-то вывести сообщение о несуществующей странице – мы просто бросим исключение с нужным типом.
На этом всё, за домашку =)
Текущая версия проекта на гитхабе.
Комментарии