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

10.06.2022 / development
Рассмотрим некоторые правила организации исключений в проекте WEB API на PHP, затронем вопросы распределения, иерархии и файловой структуры
php

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

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

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

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

namespace MyProject\Exceptions;

class BaseException extends \Exception
{
}

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

namespace MyProject\Exceptions;

class InvalidConfigException extends BaseException
{
}

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

use MyProject\Exceptions\InvalidArgumentException;

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

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

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

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

Бесполезный код, но это только с точки зрения автора кода, который держит весь контекст в своей памяти.

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

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

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

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

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

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

$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);
    }
    // ...
}

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

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

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, а внутри может быть на самом деле несколько типов?

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

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

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

Послесловие

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