Привет, Хабр!
Если вы когда‑нибудь пытались настроить бизнес‑логику в своём проекте так, чтобы она не выглядела как свалка if-else
и работала хорошо, то этот материал для вас. Сегодня мы разберём один из самых приятных паттернов — Chain of Responsibility, или «Цепочка обязанностей».
Вместо кучи условий, которые как минимум трудно читать, а как максимум невозможно поддерживать, мы строим гибкую архитектуру. Каждый этап обработки запроса становится отдельным модулем. А благодаря возможности менять порядок этих модулей или добавлять новые, система легко масштабируется.
Используйте Chain of Responsibility, когда:
Логика обработки запроса должна быть модульной.
Нужно динамически менять последовательность обработки.
Вы хотите облегчить добавление новых обработчиков.
Сразу перейдем к коду
Реализация паттерна на примере магазина котиков
Архитектура магазина котиков
Вот как будет выглядеть наш процесс:
Проверка наличия товара.
Проверка возраста покупателя.
Проверка оплаты.
Упаковка заказа.
Каждое из этих действий — это отдельный обработчик в цепочке.
Интерфейс обработчика
Начнём с базового интерфейса, который будут реализовывать наши обработчики.
<?php
interface HandlerInterface
{
public function setNext(HandlerInterface $handler): HandlerInterface;
public function handle(array $request): ?array;
}
abstract class AbstractHandler implements HandlerInterface
{
private ?HandlerInterface $nextHandler = null;
public function setNext(HandlerInterface $handler): HandlerInterface
{
$this->nextHandler = $handler;
return $handler;
}
public function handle(array $request): ?array
{
if ($this->nextHandler) {
return $this->nextHandler->handle($request);
}
return $request;
}
}
Интерфейс HandlerInterface
определяет контракт для всех обработчиков, а базовый класс AbstractHandler
реализует передачу запроса следующему обработчику.
Обработчики
Теперь создадим обработчики для проверки заказа. Начнем с проверки наличия заказа:
<?php
class StockHandler extends AbstractHandler
{
public function handle(array $request): ?array
{
if ($request['stock'] <= 0) {
throw new RuntimeException('Товара нет в наличии.');
}
error_log("Товар в наличии: {$request['stock']} единиц.");
return parent::handle($request);
}
}
Теперь реализуем проверку возраста покупателя:
<?php
class AgeVerificationHandler extends AbstractHandler
{
public function handle(array $request): ?array
{
if ($request['age'] < 18) {
throw new RuntimeException('Покупатель слишком молод.');
}
error_log("Возраст покупателя ({$request['age']}) прошёл проверку.");
return parent::handle($request);
}
}
Проверка оплаты:
<?php
class PaymentHandler extends AbstractHandler
{
public function handle(array $request): ?array
{
if (empty($request['payment']) || !$request['payment']) {
throw new RuntimeException('Оплата не прошла.');
}
error_log("Оплата успешно завершена: {$request['payment_id']}.");
return parent::handle($request);
}
}
Упаковка и подготовка к доставке:
<?php
class PackagingHandler extends AbstractHandler
{
public function handle(array $request): ?array
{
error_log("Товар упакован и готов к доставке.");
$request['status'] = 'ready_for_delivery';
return parent::handle($request);
}
}
Сборка цепочки
Теперь объединим все обработчики в цепочку.
<?php
$request = [
'stock' => 5,
'age' => 25,
'payment' => true,
'payment_id' => 'PAY12345',
];
$stockHandler = new StockHandler();
$ageHandler = new AgeVerificationHandler();
$paymentHandler = new PaymentHandler();
$packagingHandler = new PackagingHandler();
$stockHandler->setNext($ageHandler)
->setNext($paymentHandler)
->setNext($packagingHandler);
try {
$result = $stockHandler->handle($request);
echo "Заказ успешно обработан: " . json_encode($result, JSON_PRETTY_PRINT);
} catch (RuntimeException $e) {
error_log("Ошибка обработки заказа: " . $e->getMessage());
echo "Ошибка: " . $e->getMessage();
}
Если что‑то идёт не так, выбрасываем RuntimeException
, а все важные этапы логируются через error_log
(или можно заменить на тот же Monolog).
Не забываем покрыть код тестами:
<?php
use PHPUnit\Framework\TestCase;
class ChainTest extends TestCase
{
public function testStockHandlerFailsWhenOutOfStock()
{
$handler = new StockHandler();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Товара нет в наличии.');
$handler->handle(['stock' => 0]);
}
public function testChainProcessesRequestSuccessfully()
{
$request = [
'stock' => 5,
'age' => 25,
'payment' => true,
'payment_id' => 'PAY12345',
];
$stockHandler = new StockHandler();
$ageHandler = new AgeVerificationHandler();
$paymentHandler = new PaymentHandler();
$packagingHandler = new PackagingHandler();
$stockHandler->setNext($ageHandler)
->setNext($paymentHandler)
->setNext($packagingHandler);
$result = $stockHandler->handle($request);
$this->assertEquals('ready_for_delivery', $result['status']);
}
}
Что ещё можно улучшить?
Динамическая конфигурация цепочки.
Например, настраивать последовательность обработчиков через тот же JSON или YAML.Производительность.
Для больших цепочек можно добавить кэширование результатов, чтобы не проходить одни и те же проверки повторно.Логирование.
Подключаем Monolog для более подробного логирования.
Как вы заметили, паттерн упрощает сложные процессы, разбивая их на независимые шаги. А какое применение паттерну находили вы? Делитесь в комментариях!
Всем PHP-разработчикам рекомендую посетить открытый урок «Вебсокеты на PHP, или как написать свой чат», который пройдет 22 января в Otus. Записаться можно по ссылке.
Комментарии (4)
Ascard
15.01.2025 10:33Ни одного котика в статье не обнаружено. Только очередной магазин. Предлагаю заголовок изменить на "...на примере магазина котиков". Хотя какая связь между котиками, магазином, и проверкой возраста всё ещё не ясно. Я надеюсь, у вас котики это не 18+ товар? Хотя котики это вообще не товар.
MihaOo
15.01.2025 10:33Что бы я добавил:
Финализировал бы методы в
AbstractHandler
Финализировал бы всех потомков
AbstractHandler
Естественно объявил бы тип для
$request
Добавил бы
Director
класс который собирал бы цепочку что бы не тянуть все эти зависимости в нужный класс тем самым захламляя конструктор. Но тут конечно по обстоятельствамТак же объявил
abstract HandlerException extends Exception
и его потомков для каждого хэндлера и, возможно, для каждого отдельного исключительного случая. Очень удобно, если класс исключения берёт на себя ответственность за создание сообщения, это разгружает бизнес логику.
ingrain
У вас эти ifы спрятаны за setNext, если верно понимаю. И тогда в чём принципиальная разница, непонятно. Можно же этот маленький элемент логики записать ифами, и будет то же самое. А то, что вы продемонстрировали пример декомпозиции, — тоже типичный шаблон как будто из учебника
MihaOo
Как мне кажется, в таком подходе плюсом является тестируемость.
Если будет один метод и в нём много условных операторов, придётся тестировать много комбинаций входных данных для одного этого метода. Так очень легко просмотреть какой-то "edge case". Тут классы достаточно простые, написать тесты для них - легчайшее занятие.