Exceptions -> OperationOutcome
В мире php-ходящих есть мнение, что первое, что сказал Иисус Христос, придя в этот мир: "исключения - зло".
Причина, по которой появилась эта статья статья, проста и банальна: автору надоело отлавливать тонну кастомных исключений между слоями приложения.
Исключения в php — мощный и гибкий способ отлавливать непредвиденные события, произошедшие при выполнении операции. И самое главное здесь то, что исключения предусмотрены на уровне самого языка.
Конструкция по типу try { .. } catch (Exception $e) { ..$e->getMessage() }
знакома каждому 5 человеку в мире и воспринимается как неотъемлемая часть любой логики на php.
Взгляните на исходники mature-фреймворков вроде Symfony, Laravel и десятков других. Вы без труда обнаружите отдельную директорию Exceptions с тонной кастомных исключений, предусмотренных самими фреймворками.
Любопытный читатель задаст вопрос и что в этом такого?
Ничего, кроме того, что из чёткой цепочки обработки запросов ваш код быстро превращается в коллекцию try catch
на каждой 3 строке.
Это не кажется проблемой до того момента, как дело не дойдёт до разделения приложения на отдельные слои во благо SOLID. Представьте, что в вашей команде >1 человека и все они работают над разными слоями, которые должны между собой взаимодействовать. В подобных ситуациях все участники должны документировать все созданные методы, а так же возвращаемые исключения. И да, это хорошо, но зачастую документация исключений становится невыносимой. Таким образом ваша работа обрастает ненужным слоем прокидывания исключений, которые к слову нужно ещё и создать.
OperationOutcome
OperationOutcome
объект, он же DTO, он же "контракт", он же спаситель моей жопы - ключевой компонент ядра минифреймворка Rift для апи шлюзов с уклоном в мультитенантность.
На его основе реализован весь минифреймворк, который представляет из себя весьма занятный эксперимент, являющийся следствием работы над несколькими разными по сути, но очень схожими в реализации проектами, объединяющий в себе то лучшее, что я вынес из попыток сделать легаси шлак не легаси шлаком.
OperationOutcome
не что иное как объект, создаваемый каждый раз когда какое-то звено вашего приложения пытается сообщить окружающему миру о результате своей работы. Использование стандартизированного объекта ответа в сотню раз облегчает восприятие логической цепочки выполнения запросов и позволяет не "ломать" единый поток её выполнения.
Аналогичная концепция передачи объекта в качестве результата выполнения операции реализована azjezz/psl
. Знающий читатель увидит в OperationOutcome
влияние промисов из фп с их методами .then
и .map
Итак, как это работает.
Rift предлагает организовывать приложения, придерживаясь единого контракта, убивающего путаницу при работе с сырыми исключениям. Rift\Core\Contracts
- здесь описывается объект OperationOutcome
, вспомогательный класс-обёртка Operation
, позволяющий создавать новый объект с помощью методов success
и error
, а так же OperationOutcomeTrait
, содержащий HTTP статус-коды (которые вы легко можете заменить на свои кастомные).
Объект OperationOutcome
содержит 4 основных переменных, полностью описывающих возможные исходы выполнения операции:
code (int)
- статус-код операции, по умолчанию используются http коды;
result (mixed)
- результат выполнения операции, любая полезная нагрузка. Если операция занимается генерацией целочисленного числа - кладите его сюда, инициализацией объекта - сюда же, формированием массива - добро пожаловать;
error (string)
- описание ошибки при наличии;
meta (array)
- мета-данные операции. Метрики, дебаг информация лежит тут.
Приведём пример возвращения методом результата операции:
use Rift\Core\Contracts\Operation;
use Rift\Core\Contracts\OperationOutcome;
class SomeService extends Operation {
public static function execute(): OperationOutcome {
// логика получения $result
return self::success($result);
}
}
Использование вспомогательного класса Operation
и его метода success
/ error
через наследование может осуждаться, поэтому альтернативный вариант будет выглядеть так:
use Rift\Core\Contracts\Operation;
use Rift\Core\Contracts\OperationOutcome;
class SomeService {
public static function execute(): OperationOutcome {
// логика получения $result
return Operation::success($result);
}
}
В любом случае результатом вызова статического метода execute
будет объект OperationOutcome
:
object(Rift\Core\Contracts\OperationOutcome)#29 (4) {
["code"]=>
int(200)
["result"]=>
string(18) "operation's result"
["error"]=>
NULL
["meta"]=>
array(2) {
["metrics"]=>
array(0) {
}
["debug"]=>
array(0) {
}
}
}
Более сложные вариации инициализации объекта OperationOutcome
Существует слишком много вариантов инициализации объекта ответа, включающих в себя как простые сценарии, по типу success($result)
, так и более сложные, с использованием кастомных метрик, отладочной информации и подобных необязательных полей.
Здесь вы найдёте несколько простых (сложные цепочки вынесены отдельно) примеров инициализации OperationOutcome
.
Во всех этих случаях вызванная операция возвращает стандартизированный объект ответа, готовый к обработке, и неважно имеет он такой вид:
object(Rift\Core\Contracts\OperationOutcome)#49 (4) {
["code"]=>
int(404)
["result"]=>
NULL
["error"]=>
string(14) "User not found"
["meta"]=>
array(1) {
["debug"]=>
array(3) {
["searched_id"]=>
int(999)
["available_ids"]=>
array(3) {
[0]=>
int(1)
[1]=>
int(2)
[2]=>
int(3)
}
["trace"]=>
array(1) {
[0]=>
array(5) {
["file"]=>
string(21) "/app/public/index.php"
["line"]=>
int(74)
["function"]=>
string(14) "errorWithDebug"
["class"]=>
string(24) "App\Examples\SomeService"
["type"]=>
string(2) "::"
}
}
}
}
}
или такой:
object(Rift\Core\Contracts\OperationOutcome)#23 (4) {
["code"]=>
int(200)
["result"]=>
array(2) {
["user_id"]=>
int(123)
["name"]=>
string(8) "Huila"
}
["error"]=>
NULL
["meta"]=>
array(2) {
["metrics"]=>
array(3) {
["execution_time_ms"]=>
float(45.2)
["memory_usage_mb"]=>
float(12.7)
["database_queries"]=>
int(3)
}
["debug"]=>
array(0) {
}
}
}
Согласитесь, это напоминает исключения, но с одной большой разницей...
Обработка OperationOutcome
Представим, что в вашем приложении есть слой типа UseCase
/ Controller
, который должен обратиться к ряду репозиториев, обработать их ответы и выдать результат в случае успеха. Если бы мы были педофилами Symfony-like разработчиками, мы бы использовали конструкцию try-catch
, проверяя каждый раз корректность запроса к репозиторию и выбрасывая исключение в случае неудачи.
Но мы не доверяем исключениям, а полагаемся на OperationOutcome
, который будет возвращать каждый метод наших репозиториев.
OperationOutcome
из коробки содержит несколько методов, облегчающих составление логической цепочки запросов:
->isSuccess()
: bool - проверка объекта на успех / провал. Работает с использованием поля code. По умолчанию успешные коды ответа: 200 Operation::HTTP_OK
, 201 Operation::HTTP_CREATED
. Если проверяемый объект ответа имеет один из этих статусов, метод isSuccess()
вернёт true
;
->then(callable $callback)
- выполняет коллбэк, если результат успешный (аналог then/flatMap);
->map(callable $callback)
- трансформирует результат, если успех (аналог map);
->catch(callable $errorHandler)
- обрабатывает ошибку, если она есть (аналог catch);
->tap(callable $callback)
- выполняет сайд-эффект без изменения результата (аналог tap);
->ensure(callable $predicate, string $errorMessage, int $errorCode = 400)
- проверяет условие, иначе возвращает ошибку (аналог filter/assert);
->merge(OperationOutcome $other, callable $merger)
- комбинирует два OperationOutcome (аналог zip);
так же из коробки предоставляются готовые методы для работы с метриками и дебаг информацией:
->withMetric(string $key, mixed $value)
- пушит метрику в meta['metrics'] объекта;
->addDebugData(string $key, mixed $value)
- пушит дебаг информацию в meta['debug'] объекта.
Здесь собраны демонстрационные примеры использования всех вышеуказанных методов.
Вот как может выглядеть цепочка запросов в вашем UseCase
:
class SomeUseCase {
public static function demoChain(): OperationOutcome
{
return Operation::success(['id' => 1, 'name' => ' alice '])
->withMetric('start_time', microtime(true))
->map(function($user) {
$user['name'] = trim($user['name']);
return $user;
})
->ensure(
fn($user) => !empty($user['name']),
'Name cannot be empty',
400
)
->map(function($user) {
$user['name'] = ucfirst($user['name']);
return $user;
})
->then(function($user) {
return self::fetchUserStats($user['id'])
->map(function($stats) use ($user) {
return array_merge($user, ['stats' => $stats]);
});
})
->addDebugData('ahuenno', 'yes')
->withMetric('end_time', microtime(true));
}
private static function fetchUserStats(int $userId): OperationOutcome
{
// Имитация получения статистики
if ($userId === 1) {
return Operation::success([
'logins' => 42,
'last_login' => '2023-01-01'
]);
}
return Operation::error(404, 'Stats not found');
}
}
результатом вызова метода demoChain
будет элегантный OperationOutcome
:
object(Rift\Core\Contracts\OperationOutcome)#41 (4) {
["code"]=>
int(200)
["result"]=>
array(3) {
["id"]=>
int(1)
["name"]=>
string(5) "Alice"
["stats"]=>
array(2) {
["logins"]=>
int(42)
["last_login"]=>
string(10) "2023-01-01"
}
}
["error"]=>
NULL
["meta"]=>
array(2) {
["metrics"]=>
array(1) {
["end_time"]=>
float(1749400258.696166)
}
["debug"]=>
array(1) {
["ahuenno"]=>
string(3) "yes"
}
}
}
Представление OperationOutcome: ->toJson()
OperationOutcome
настолько универсален, что может использоваться для API ответов (особенно хорошо, если ваше приложение разделено на несколько серверов и общается по одному стандарту).
->toJson(?callable $transformer = null, int $flags)
- метод для сериализации OperationOutcome
. Вы можете задать кастомную схему ответа и установить необходимые флаги для преобразования во всеми любимый json
.
Приведём пример:
$resultQueryJson = $resultQuery->toJson(fn($outcome) => [
'ok' => $outcome->isSuccess(),
'code' => $outcome->code,
'payload' => $outcome->result ?? $outcome->error,
'_meta' => $outcome->meta
]);
в результате мы получим трансформированный OperationOutcome
:
string(134) "{
"ok": false,
"code": 404,
"payload": "Path not found",
"_meta": {
"metrics": [],
"debug": []
}
}"
Послесловие
Как бы ни хотелось отказаться от исключений в пользу единого контракта, есть ситуации, когда без них не обойтись. Фатальные ошибки при работе с базой данных никуда не делись, и их всё так же нужно отлавливать и передавать на уровень выше. OperationOutcome
— это лишь ещё один слой абстракции, который нужен для интуитивно понятного восприятия цепочки запросов и стандартизации ответов каждого звена. Он не подвержен статическому анализу (в отличие от @throws
в PHPStan/Psalm). Он может быть избыточным для многих проектов.
Но он совмещает в себе природу функциональщины и единый стандарт ответа и может выступать отличным связующим звеном между слоями сложных проектов, где важна контрактность и последовательность выполнения цепочки запросов.
Rift
Rift - минифреймворк, строящийся вокруг идеи OperationOutcome
в контексте мультитенантных приложений.