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
в контексте мультитенантных приложений.
Комментарии (5)
FanatPHP
14.06.2025 09:46автору надоело отлавливать тонну кастомных исключений между слоями приложения
...и поэтому он решил написать тонну неубираемого кода по обработке ошибок.
Удобство исключений в том, что в 90% случаев их ловить не обязательно. То есть в коде нет вообще никакой обработки ошибок, ни с try, ни без. А
конструкция по типу
try { .. } catch (Exception $e) { ..$e->getMessage() }
-- это очевидный говнокод, с которым надо бороться не переменой синтаксиса (чтобы теперь на каждый чих писать не
try
, аif ($result->error)
), а выпиливанием бессмысленных try...catch из кода.Просто удивительно, как автор не замечает этой иронии. Это напоминает мне историю с logicless шаблонами: придумываем несуществующую проблему, потом придумываем гениальное решение для неё, получаем код хуже исходного, и ничтоже сумняшеся пишем победную статью, в которой заявляем полное превосходство своего метода над существующим.
mainbotan Автор
14.06.2025 09:46Да нет, напротив, далеко не гениальное решение, не заменяющее (и не пытающееся это делать там где не надо) способ обработки ошибок, предусмотренный самим языком.
По задумке использование такого объекта уместно при составлении логической цепочки по типу: здесь данные взял + проверил, здесь их сохранил + проверил результат сохранения... Если ваш контроллер, юз кейс, да как его не назовите выполняет все эти операции, и вы чётко понимаете, когда какая из них заканчивается провалом - да, конечно, создание нового объекта ответа избыточно.
Но если всё резделено на отдельные репозитории, всё переиспользуется по сотне раз в разных местах приложения, хочеца-не хочеца нужно будет вводить что-то подобное "единому стандарту ответов" для передачи данных между слоями.
Это не просто переход с try на if, это переход на явное выполнение цепочки запросов (как это изначально и было сказано в статье).
В слоях где сосредоточена главная часть приложения и важна последовательность выполнения операций - это более чем удобно.
FanatPHP
14.06.2025 09:46В какой-то мере я с вами согласен. Но вот не надо было пол-статьи писать про то, какая бяка эти исключения. Потому что решение выглядит не составлением логической цепочки, а борьбой с try..catch.
Плюс, если вы "не против" "предусмотренной" обработки ошибок, то надо было четко описать их сосуществование. А сейчас статья выглядит как "давайте сделаем из пыхи голанг в плане обработки ошибок", а "предусмотренная" обработка сводится к всё тому же try-catch вокруг любого вызова, который может выбросить исключение.
mainbotan Автор
14.06.2025 09:46Согласен, мог больше внимания уделить объяснению удобства составления цепочек с помощью такого контракта, не отказываясь полностью от исключений, просто выбрасывая их при фатальных ошибках.
Мог сказать про централизованный обработчик ошибок где-то в бутстрапе, убирающий необходимость try...catch-ить там где не надо.
И свести всё к тому, что с какой стороны не посмотреть, явная обработка ошибок (построенная не на if..else, а на методах по типу .then, .map, .catch и т.д.) лучше для восприятия логической цепочки людьми.
Суть в том, чтобы сделать ошибки частью возвращаемого типа, по аналогии с растом, хаскеллом или десятком других языков. Но в php всё держится на исключениях, там где-то внизу выбросим, потом наверху всё равно всё поймается, а в контроллере мы будем расчитывать на идеальный случай, когда все операции вернули то, что надо...
Вариант конечно живой, но есть и альтернатива.
Veym
Я считаю, что данная статья помогает нам развивать навыки для IT, но то, что сказал Иисус, можно немного поспорить. В данный момент, сейчас идет век IT технологий и то что, сейчас идут большинство сил на IT технологии. В данный момент, человечество разрабатывает новые технологии, программы и тд. И сейчас, обычный человек не сможет прожить время без телефона хотя бы 10-15 дней, потому что, мы уже привыкли к гаджетам и у нас там хранится наша база данных или проще "наши данные", по типу банковские карты и тд. Чтобы не погубить мир IT, мы должны развиваться и продвигать наше IT, для совершенствования!