<? phpukraine PHP-ПЛАТФОРМА
⌕Пошук по платформі
ПОВЕДІНКОВИЙ ПАТЕРН

Ланцюжок обовʼязківChain of Responsibility

Запит проходить ланцюжком обробників. Кожен вирішує сам: обробити його чи передати наступному, а відправник не знає, хто зрештою візьметься.

Laravel Symfony складність 2 з 3 6 хв читання
ГЛІФ
01 · ПРОБЛЕМА

Повернення коштів, де кожне правило в одному if

Інтернет-магазин повертає гроші за товар. Дрібні суми бот повертає одразу, середні підтверджує оператор, великі розглядає менеджер, а після 14 днів заявку відхиляють. Усі правила живуть в одному методі, і кожне нове вставляють у середину ланцюга if.

src/Refunds/RefundService.php
1final class RefundService
2{
3 public function decide(Refund $refund): Decision
4 {
5 if ($refund->daysSincePurchase > 14) {
1Порядок перевірок важливий, але видно його лише тому, хто дочитав метод до кінця.
6 return Decision::reject('минуло більше 14 днів');
7 }
8
9 if ($refund->amount <= 500_00 && ! $refund->customer->isSuspicious()) {
2Правило для бота змішане з перевіркою клієнта. Щоб змінити поріг, треба перечитати й решту гілок.
10 return $this->bot->refund($refund);
11 } elseif ($refund->amount <= 5_000_00) {
12 return $this->support->assignOperator($refund);
13 } elseif ($refund->customer->isVip()) {
3VIP-правило додали пізніше, і воно спрацьовує лише для сум понад 5 000 грн. Чи так задумано, вже ніхто не памʼятає.
14 return $this->support->assignSeniorOperator($refund);
15 }
16
17 return $this->managers->review($refund);
18 }
19}
02 · ІДЕЯ РІШЕННЯ

Кожне правило окремою ланкою

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
Controller«interface»RefundHandlerhandle(Refund)TooLateAutoRefundOperatorостаннійManager
Controller«interface»RefundHandlerhandle(Refund)TooLateAutoRefundOperatorостаннійManager
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • Controller використовує TooLate.
  • TooLate реалізує RefundHandler.
  • AutoRefund реалізує RefundHandler.
  • Operator реалізує RefundHandler.
  • Manager реалізує RefundHandler.
  • Дані йдуть від TooLate до AutoRefund.
  • Дані йдуть від AutoRefund до Operator.
  • Дані йдуть від Operator до Manager.
03 · КОД

Запустіть і подивіться

PHP 8.5
1final class RefundService
2{
3 public function decide(Refund $refund): Decision
4 {
5 if ($refund->daysSincePurchase > 14) {
6 return Decision::reject('минуло більше 14 днів');
7 }
8
9 if ($refund->amount <= 500_00 && ! $refund->customer->isSuspicious()) {
10 return $this->bot->refund($refund);
11 } elseif ($refund->amount <= 5_000_00) {
12 return $this->support->assignOperator($refund);
13 } elseif ($refund->customer->isVip()) {
14 return $this->support->assignSeniorOperator($refund);
15 }
16
17 return $this->managers->review($refund);
18 }
19}
1<?php
2
3final readonly class Refund
4{
5 public function __construct(public string $order, public int $amount, public int $days) {}
6}
7
81abstract class RefundHandler
9{
10 private ?RefundHandler $next = null;
11
12 public function then(RefundHandler $next): RefundHandler
13 {
14 $this->next = $next;
15
16 return $next;
17 }
18
19 public function handle(Refund $refund): string
20 {
21 return $this->next?->handle($refund) ?? 'ніхто не взявся';
22 }
23}
24
252final class TooLate extends RefundHandler
26{
27 public function handle(Refund $refund): string
28 {
29 return $refund->days > 14 ? 'відмова: минуло більше 14 днів' : parent::handle($refund);
30 }
31}
32
33final class AutoRefund extends RefundHandler
34{
35 public function handle(Refund $refund): string
36 {
37 return $refund->amount <= 500 ? 'бот повернув гроші одразу' : parent::handle($refund);
38 }
39}
40
41final class Operator extends RefundHandler
42{
43 public function handle(Refund $refund): string
44 {
45 return $refund->amount <= 5000 ? 'оператор підтвердить сьогодні' : parent::handle($refund);
46 }
47}
48
493final class Manager extends RefundHandler
50{
51 public function handle(Refund $refund): string
52 {
53 return 'менеджер розгляне вручну';
54 }
55}
56
574$chain = new TooLate();
58$chain->then(new AutoRefund())->then(new Operator())->then(new Manager());
59
60$refunds = [
61 new Refund('UA-1042', amount: 320, days: 3),
62 new Refund('UA-1043', amount: 2400, days: 10),
63 new Refund('UA-1044', amount: 18000, days: 5),
64 new Refund('UA-1045', amount: 320, days: 30),
65];
66
67foreach ($refunds as $refund) {
68 printf("%s, %6d грн: %s\n", $refund->order, $refund->amount, $chain->handle($refund));
69}
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 8–23

    Базовий клас тримає посилання на наступну ланку. Якщо ланка не взялася, handle() передає запит далі.

  2. 2 рядки 25–47

    Кожне правило в окремому класі: або вирішує саме, або викликає parent::handle().

  3. 3 рядки 49–55

    Остання ланка бере все, що лишилося. Без неї заявка могла б пройти ланцюжок і ніхто її не взяв би.

  4. 4 рядки 57–58

    Порядок ланок задають в одному місці. Новий рівень вставляється сюди, сусідні класи не змінюються.

  5. Наведіть на пояснення, щоб підсвітити рядки.Торкніться пояснення, щоб підсвітити рядки.
04 · У ВАШОМУ ФРЕЙМВОРКУ

Ви вже цим користуєтесь

LARAVEL

Middleware

Кожен middleware отримує запит і $next. Він може відповісти сам, наприклад 403, і тоді далі запит не піде, або передати його наступному викликом $next($request).

final class EnsureCompanyIsVerified
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->company?->verified) {
            abort(403);
        }
 
        return $next($request);
    }
}

Ви вже користуєтесь цим, коли додаєте middleware до маршруту чи групи, а контролер про нього не знає.

SYMFONY

Messenger middleware

Повідомлення в Messenger проходить стек middleware на шині. Кожен отримує конверт і сам вирішує, чи передати його далі через $stack->next().

final class AuditMiddleware implements MiddlewareInterface
{
    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        $this->logger->info($envelope->getMessage()::class);
 
        return $stack->next()->handle($envelope, $stack);
    }
}

Ви вже користуєтесь цим, коли додаєте свій middleware у список middleware шини в messenger.yaml.

✓ ЯК ЦЕ ТЕСТУВАТИ

Кожну ланку тестуйте окремо з фейковою наступною, а зібраний ланцюжок одним тестом з набором даних: сума на вході, хто взявся на виході.

it('routes a refund to the right level', function (int $amount, string $who) {
    expect(refundChain()->handle(new Refund('UA-1', $amount, days: 3)))
        ->toContain($who);
})->with([
    [320, 'бот'],
    [2400, 'оператор'],
    [18000, 'менеджер'],
]);
05 · КОЛИ НЕ ТРЕБА

Дві умови не потребують ланцюжка

Якщо правил два й нових не передбачається, match читається легше за базовий клас і чотири нащадки. А коли мають спрацювати всі кроки, а не перший, що взявся, вам потрібен конвеєр.

$who = match (true) {
    $refund->amount <= 500 => 'бот',
    default => 'оператор',
};
06 · СУСІДИ

Схожі на вигляд, різні за змістом

07 · ПЕРЕВІР СЕБЕ

Питання зі співбесід

Усі питання з архітектури →

Запит дійде до кінця ланцюжка без результату. Тому в кінці ставлять ланку, яка бере все, що лишилося, або базовий клас повертає зрозумілу відмову.

Так, з однією відмінністю. Middleware може зупинити запит, як класичний обробник, але найчастіше передає його далі й може змінити відповідь, коли $next($request) поверне її назад.

Ланка, яка стоїть раніше, перехоплює запит першою. Якщо AutoRefund поставити перед TooLate, бот поверне гроші й за старе замовлення. Порядок задають в одному місці: там, де збирають ланцюжок, а не всередині ланок.