Pull to refresh

No exceptions культ — Rift Miniframework

Level of difficultyEasy
Reading time7 min
Views1.2K

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

Tags:
Hubs:
-1
Comments5

Articles