🎣 Организация исключений

10.06.2022

За свою практику у меня образовалось несколько правил формирования исключений для проекта в контексте WEB API. Это не новшество и некоторые из них уже существовали (вот и вот) или были сформированы, но резюмирую ключевые моменты со своими дополнениями.

По ходу дела эта статья будет дополняться/редактироваться, это не окончательная ее версия.

Отделение встроенных исключений от новых

Создадим свой базовый класс исключений, для того чтобы отделять свои исключения от встроенных, так как имена могут совпадать, и обработка может значительно отличаться:

•••
php
namespace MyProject\Exceptions; class BaseException extends \Exception { }

Теперь мы можем создать свое исключение InvalidArgumentException и выбрасывать его в случае когда самостоятельно производим валидацию переданных аргументов:

•••
php
namespace MyProject\Exceptions; class InvalidConfigException extends BaseException { }

При этом четко отделяется встроенное исключение \InvalidArgumentException от MyProject\Exceptions\InvalidArgumentException:

•••
php
use MyProject\Exceptions\InvalidArgumentException; try { $model = $db->dispense('mymodel', $_POST)); } catch (InvalidArgumentException $e) { ... } catch (\InvalidArgumentException $e) { ... }

ВСЕГДА перехватывать исключения

Код ниже ниже производит перехват и дальнейший проброс исключения:

•••
php
try { $model = $db->load('mymodel', $id)) } catch (DataBaseException $e) { throw $e; }
Бесполезный код, но это только с точки зрения автора кода, который держит весь контекст в своей памяти.

Что будет по прошествии времени или когда новый разработчик увидит код выше? Определенно будет понятно что операция заключенная в try/catch может выбросить исключение.

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

•••
php
try { $model = $db->load('mymodel', $id)) } catch (DataBaseException $e) { throw new InternalServerErrorException($e->getMessage(), $e); }

В этом случае всякий код подверженный исключениям будет обернут в try/catch и обновить логику не составит труда, по крайней мере будет легче выявить такой код.

Соответствие слою асбтракции

Рассмотрим код работающий на уровне слоя данных с использованием PDO:

•••
php
$stmt = $pdo->prepare('INSERT ...'); try { $stmt->execute(); } catch (\PDOException $e) { if ($e->getCode() == 23000) { // такая запись в БД уже существует throw new DuplicateEntryException($e->getMessage(), $e->getCode(), $e); } elseif ($e->getCode() == 42000) { // ошибка синтаксиса SQL throw new SQLException($e->getMessage(), $e->getCode(), $e); } // ... }

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

А теперь посмотрим на пользовательский код использующий интерфейс слоя данных из примера выше:

•••
php
try { $model = $db->dispense('mymodel', $data); $db->save($model); } catch (DuplicateEntryException $e) { // такие данные уже записаны, выбрасываем исключение в контексте http (409) throw new ConflictException($e->getMessage()); } catch (SQLException | ErrorClassDataModelException $e) { // ошибка реализации выбрасываем исключение в контексте http (500) throw new InternalServerErrorException($e->getMessage(), $e); }

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

Файловая структура

На повседневной практике я вывел такую файловую структуру исключений:

Файловая структура исключений
Файловая структура исключений

Из этой структур видно:

  • каждый контекст возможных исключений помещен в свою директорию
  • каждый контекст имеет свой базовый класс исключений, который наследуется от базового исключения для проекта в целом (первый наследник Exception)
  • каждый класс исключения имеет постфикс Exception

ВСЕГДА документировать выбрасываемые исключения

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

Но что если описаны не все возможные исключения или указано что-то обобщенное типа Exception, а внутри может быть на самом деле несколько типов?

Либо менять библиотеку, либо погружаться в исходный код.

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

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

Послесловие

Перечисленные здесь правила позволяют быстро удобно и понятно организовывать выбрасывать и обрабатывать исключительные ситуации.