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 в контексте мультитенантных приложений.
ссылка на оригинал статьи https://habr.com/ru/articles/918306/
Добавить комментарий