No exceptions культ — Rift Miniframework

от автора

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 в контексте мультитенантных приложений.

Github: https://github.com/mainbotan/Rift


ссылка на оригинал статьи https://habr.com/ru/articles/918306/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *