Перейти к содержимому
Фёдор Башинский

Цепочка обязанностей

6 min read
Обложка статьи «Цепочка обязанностей»

Цепочка обязанностей (Chain of Responsibility) — это поведенческий паттерн проектирования, который передаёт запрос по цепочке обработчиков: каждый решает, взять запрос на себя или передать дальше, а отправитель не знает заранее, кто именно из цепочки в итоге его обработает.

Проблема

Заявка на гарантийный ремонт утверждается на разных уровнях — в зависимости от суммы решение принимает мастер-приёмщик, старший мастер или начальник сервиса:

final class RepairClaim
{
    public function __construct(
        public readonly int $amount,
        private ?string $approvedBy = null,
    ) {}
 
    public function approveBy(string $role): void
    {
        $this->approvedBy = $role;
    }
}

Утверждение реализовано одним методом с последовательностью условий:

final class ApprovalService
{
    public function approve(RepairClaim $claim): void
    {
        if ($claim->amount <= 10_000) {
            $claim->approveBy('мастер-приёмщик');
        } elseif ($claim->amount <= 50_000) {
            $claim->approveBy('старший мастер');
        } else {
            $claim->approveBy('начальник сервиса');
        }
    }
}

Пока уровней три, метод ещё читается. Но появляется четвёртое требование: заявки на кузовной ремонт свыше 50 000 должен утверждать не начальник сервиса, а сразу директор дилерского центра — при этом обычный ремонт на ту же сумму по-прежнему утверждает начальник сервиса. Условие приходится усложнять проверкой типа заявки внутри той же ветки:

} else {
    if ($claim->type === 'bodywork') {
        $claim->approveBy('директор дилерского центра');
    } else {
        $claim->approveBy('начальник сервиса');
    }
}

Каждое новое правило утверждения — новый уровень, новое исключение, новый тип заявки — правится внутри одного и того же метода, рискуя сломать уже работающие ветки. ApprovalService знает про все роли утверждающих сразу, хотя каждая из них должна отвечать только за своё собственное правило.

Решение

Цепочка обязанностей превращает каждую ветку условия в отдельный объект-обработчик с общим интерфейсом. Обработчик либо берёт запрос на себя, либо передаёт его следующему звену цепочки:

interface ApprovalHandler
{
    public function setNext(ApprovalHandler $next): ApprovalHandler;
 
    public function handle(RepairClaim $claim): void;
}

Базовый обработчик хранит ссылку на следующее звено и по умолчанию просто передаёт запрос дальше — конкретным обработчикам останется переопределить только условие:

abstract class BaseApprovalHandler implements ApprovalHandler
{
    private ?ApprovalHandler $next = null;
 
    public function setNext(ApprovalHandler $next): ApprovalHandler
    {
        $this->next = $next;
 
        return $next;
    }
 
    public function handle(RepairClaim $claim): void
    {
        $this->next?->handle($claim);
    }
}

Каждый уровень утверждения — отдельный класс с одним правилом:

class ServiceAdvisorHandler extends BaseApprovalHandler
{
    private const LIMIT = 10_000;
 
    public function handle(RepairClaim $claim): void
    {
        if ($claim->amount <= self::LIMIT) {
            $claim->approveBy('мастер-приёмщик');
            return;
        }
 
        parent::handle($claim); // не наш случай — дальше по цепочке
    }
}
 
class SeniorMasterHandler extends BaseApprovalHandler
{
    private const LIMIT = 50_000;
 
    public function handle(RepairClaim $claim): void
    {
        if ($claim->amount <= self::LIMIT) {
            $claim->approveBy('старший мастер');
            return;
        }
 
        parent::handle($claim);
    }
}
 
class ServiceManagerHandler extends BaseApprovalHandler
{
    public function handle(RepairClaim $claim): void
    {
        $claim->approveBy('начальник сервиса'); // последнее звено — обрабатывает всё, что дошло досюда
    }
}

Клиент собирает звенья в цепочку и запускает её с первого:

$advisor = new ServiceAdvisorHandler();
$seniorMaster = new SeniorMasterHandler();
$manager = new ServiceManagerHandler();
 
$advisor->setNext($seniorMaster)->setNext($manager);
 
$advisor->handle($claim); // заявка пройдёт по цепочке до первого подходящего обработчика

Требование про кузовной ремонт теперь решается новым классом, а не правкой существующих:

class BodyworkDirectorHandler extends BaseApprovalHandler
{
    public function handle(RepairClaim $claim): void
    {
        if ($claim->type === 'bodywork') {
            $claim->approveBy('директор дилерского центра');
            return;
        }
 
        parent::handle($claim);
    }
}

Достаточно один раз изменить сборку цепочки, вставив новое звено перед ServiceManagerHandler, — ни один из существующих обработчиков при этом не редактируется:

$advisor->setNext($seniorMaster)->setNext($bodyworkDirector)->setNext($manager);

Из чего состоит паттерн

  • Обработчик (ApprovalHandler) — общий интерфейс со связыванием в цепочку (setNext()) и обработкой запроса (handle()).
  • Базовый обработчик (BaseApprovalHandler) — хранит ссылку на следующее звено и реализует передачу запроса дальше по умолчанию, чтобы конкретные обработчики не дублировали этот код.
  • Конкретный обработчик (ServiceAdvisorHandler, SeniorMasterHandler, ServiceManagerHandler, BodyworkDirectorHandler) — содержит одно правило: либо берёт запрос на себя, либо отдаёт его дальше по цепочке.
  • Клиент — собирает обработчики в цепочку в нужном порядке и запускает её с первого звена, не зная заранее, какое из звеньев в итоге обработает запрос.

Цепочка не гарантирует обработку

В примере выше ServiceManagerHandler — последнее звено, и оно обрабатывает запрос безусловно, без вызова parent::handle(). Это осознанное решение: цепочка гарантированно заканчивается обработкой. Но если бы каждое звено, включая последнее, вызывало parent::handle(), а $this->next в последнем звене оказался null, запрос молча дошёл бы до конца цепочки и потерялся бы — BaseApprovalHandler::handle() просто ничего не делает, если следующего звена нет. Заявка осталась бы неутверждённой без единой ошибки в логах.

Это обратная сторона гибкости паттерна: сборка цепочки — ответственность клиента, и если в неё забыли включить обработчик на случай «никто не подошёл», ошибка проявится не при компиляции, а в поведении — request тихо провалится сквозь цепочку. Поэтому последнее звено либо обрабатывает запрос безусловно, как ServiceManagerHandler, либо явно бросает исключение вместо молчаливой передачи в никуда.

Цепочка обязанностей vs Декоратор

Оба паттерна строятся из цепочки объектов с общим интерфейсом, и структурно код связывания цепочки — $a->setNext($b) против new B(new A()) — легко перепутать. Но по смыслу они устроены наоборот друг другу. Декоратор передаёт запрос всем звеньям цепочки последовательно, и каждое добавляет своё поведение поверх результата предыдущего — цепочка декораторов не решает, кто из них «главный», все выполняются всегда. Цепочка обязанностей, наоборот, ищет одно (или несколько) подходящее звено и на нём может остановиться — большинство обработчиков в примере выше вообще не видят заявку с маленькой суммой, потому что ServiceAdvisorHandler обработал её и не передал дальше.

Цепочка обязанностейДекоратор
Кто обрабатывает запросОбычно одно звено, остальные не вызываютсяВсе звенья цепочки, каждое добавляет своё поведение
Решение о продолженииКаждый обработчик сам решает, передавать ли запрос дальшеПередача дальше не обсуждается — так устроена обёртка
ЗадачаНайти обработчик, которому запрос предназначенДополнить обработку запроса, который в любом случае будет обработан

Когда применять

  • Запрос может обработать один из нескольких кандидатов, и заранее неизвестно, какой именно, — как в примере с суммой заявки на ремонт.
  • Набор обработчиков и их порядок должны собираться и меняться во время выполнения, а не быть зашитыми в один метод с условиями.
  • Отправитель запроса не должен знать ни про одного конкретного получателя — только про интерфейс, с которого начинается цепочка.

Плюсы и минусы

Цепочка обязанностей разбивает разрастающийся if/elseif на отдельные классы с одним правилом в каждом — новое правило утверждения добавляется новым обработчиком, без правки существующих, как в примере с кузовным ремонтом. Порядок и состав цепочки при этом собираются динамически, а отправитель запроса остаётся полностью развязан с конкретными обработчиками.

Минус — как показано выше, паттерн не гарантирует, что запрос вообще будет обработан: забытое последнее звено превращает необработанный запрос в тихую потерю данных, а не в ошибку. Прохождение по цепочке из многих звеньев сложнее для отладки, чем один метод с условиями, — чтобы понять, где и почему запрос был обработан, нужно пройти по всей цепочке, а не заглянуть в один список веток.

Итог

Цепочка обязанностей нужна, когда запрос может обработать любой из нескольких кандидатов, а какой именно — заранее неизвестно и может меняться со временем. Она превращает нарастающий ком условий в набор независимых обработчиков с одним правилом каждый и позволяет пересобирать цепочку без правки уже существующих звеньев — расплачиваясь за это отсутствием гарантии, что запрос вообще дойдёт до кого-то, кто готов его обработать.