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

СпостерігачObserver

Обʼєкт повідомляє, що сталася подія, а хто й як на неї реагує, вирішують слухачі, про яких він не знає.

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

Оформлення замовлення, яке знає про все

Після оформлення замовлення магазин надсилає лист, резервує товар на складі, пише менеджерам у Telegram і пробиває чек через Checkbox. Усе це живе в одному методі place(). Кожна нова реакція додає залежність у конструктор, а збій у будь-якій зупиняє решту.

src/Orders/OrderService.php
1final class OrderService
2{
3 public function place(Cart $cart, Customer $customer): Order
4 {
5 $order = Order::fromCart($cart, $customer);
6 $this->orders->save($order);
7
8 $this->mailer->send(new ReceiptMail($order));
1Якщо поштовий сервер не відповідає, виняток зупинить склад, Telegram і чек, хоча замовлення вже збережене.
9 $this->warehouse->reserve($order->items());
10 $this->telegram->notifyManagers("Нове замовлення {$order->number}");
11 $this->checkbox->fiscalize($order);
2Checkbox додали пізніше, і тепер кожен тест place() мокає ще й касу.
12 // $this->novaPoshta->createWaybill($order);
3Наступна вимога знову відкриває метод, який уже працює.
13
14 return $order;
15 }
16}
02 · ІДЕЯ РІШЕННЯ

Замовлення оголошує подію, слухачі реагують самі

ДІАГРАМА · УСЯ СХЕМА
Кроки: 4
newdispatch()Checkout:OrderPlacedDispatcherlisten() · dispatch()SendReceiptReserveStockNotifyTelegram
Checkout:OrderPlacedDispatcherlisten() · dispatch()SendReceiptReserveStockNotifyTelegram
Уся схема. Натисніть → або виберіть крок нижче, щоб пройти по ній частинами.
Абстракція Ключовий учасник Клієнт реалізує · - - ▷
  • Checkout створює OrderPlaced.
  • Checkout використовує Dispatcher (dispatch()).
  • Dispatcher надсилає подію, її отримує SendReceipt.
  • Dispatcher надсилає подію, її отримує ReserveStock.
  • Dispatcher надсилає подію, її отримує NotifyTelegram.
03 · КОД

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

PHP 8.5
1final class OrderService
2{
3 public function place(Cart $cart, Customer $customer): Order
4 {
5 $order = Order::fromCart($cart, $customer);
6 $this->orders->save($order);
7
8 $this->mailer->send(new ReceiptMail($order));
9 $this->warehouse->reserve($order->items());
10 $this->telegram->notifyManagers("Нове замовлення {$order->number}");
11 $this->checkbox->fiscalize($order);
12 // $this->novaPoshta->createWaybill($order);
13
14 return $order;
15 }
16}
1<?php
2
31final readonly class OrderPlaced
4{
5 public function __construct(public string $number, public string $email, public int $total) {}
6}
7
8final class EventDispatcher
9{
10 /** @var array<string, list<callable>> */
11 private array $listeners = [];
12
13 public function listen(string $event, callable $listener): void
14 {
15 $this->listeners[$event][] = $listener;
16 }
17
182 public function dispatch(object $event): void
19 {
20 foreach ($this->listeners[$event::class] ?? [] as $listener) {
21 $listener($event);
22 }
23 }
24}
25
26final class SendReceipt
27{
28 public function __invoke(OrderPlaced $event): void
29 {
30 echo "Лист на {$event->email}: замовлення {$event->number}, {$event->total} грн\n";
31 }
32}
33
34final class ReserveStock
35{
36 public function __invoke(OrderPlaced $event): void
37 {
38 echo "Склад: товар для {$event->number} зарезервовано\n";
39 }
40}
41
42final class NotifyTelegram
43{
44 public function __invoke(OrderPlaced $event): void
45 {
46 echo "Telegram менеджерам: нове замовлення {$event->number}\n";
47 }
48}
49
503final class Checkout
51{
52 public function __construct(private EventDispatcher $events) {}
53
54 public function place(string $number, string $email, int $total): void
55 {
56 echo "Замовлення {$number} збережено\n";
57 $this->events->dispatch(new OrderPlaced($number, $email, $total));
58 }
59}
60
614$events = new EventDispatcher();
62$events->listen(OrderPlaced::class, new SendReceipt());
63$events->listen(OrderPlaced::class, new ReserveStock());
64$events->listen(OrderPlaced::class, new NotifyTelegram());
65
66(new Checkout($events))->place('UA-1042', 'olena@example.com', 1850);
Перший запуск завантажить рушій PHP 8.5, приблизно 3,8 МБ Відкрити в пісочниці ↗
ВИВІД
Завантажуємо рушій PHP 8.5
Лише перший раз. Далі код запускається миттєво, рушій лишається в кеші браузера.
$ php main.php ▍
  1. 1 рядки 3–6

    Подія описує факт у минулому часі й несе лише дані: номер, email, суму.

  2. 2 рядки 18–23

    Диспетчер не знає, що роблять слухачі. Він просто викликає всіх, хто підписався на цей клас події.

  3. 3 рядки 50–59

    Checkout залежить лише від диспетчера. Про лист, склад і Telegram він не знає нічого.

  4. 4 рядки 61–64

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

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

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

LARAVEL

Events і listeners

Подія це звичайний клас, слухач це клас з методом handle(), який Laravel знаходить у app/Listeners за типом аргументу. Слухач з ShouldQueue виконується в черзі, тож повільний лист не гальмує оформлення.

final class SendReceipt implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->email)
            ->send(new ReceiptMail($event->order));
    }
}
 
event(new OrderPlaced($order));

Ви вже користуєтесь цим, коли додаєте реакцію на подію новим класом у app/Listeners, а код, що викликає event(), лишається як був.

SYMFONY

EventDispatcher і #[AsEventListener]

Сервіс отримує EventDispatcherInterface і викликає dispatch(). Слухача позначають атрибутом #[AsEventListener], а подію контейнер бере з типу аргументу __invoke().

#[AsEventListener]
final class SendReceipt
{
    public function __invoke(OrderPlaced $event): void
    {
        // лист покупцю
    }
}
 
$this->dispatcher->dispatch(new OrderPlaced($order));

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

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

Тест видавця перевіряє лише, що подію надіслано: у Laravel для цього є Event::fake(). Кожного слухача тестуйте окремо, передаючи йому готову подію.

it('announces a placed order', function () {
    Event::fake();
 
    app(Checkout::class)->place(cart(), customer());
 
    Event::assertDispatched(OrderPlaced::class);
});
05 · КОЛИ НЕ ТРЕБА

Коли від реакції залежить сама дія

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

// без оплати замовлення не існує, тому оплату викликають прямо
$this->payments->charge($order);
$this->orders->save($order);
 
event(new OrderPlaced($order));
06 · СУСІДИ

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

Спостерігач
Observer
ВИ ТУТ
ХТО ОТРИМУЄ
Усі підписані слухачі, скільки б їх не було.
ЩО ПОВЕРТАЄТЬСЯ
Нічого: видавець не чекає відповіді й не знає, хто відреагував.
Ланцюжок обовʼязків
Chain of Responsibility
ХТО ОТРИМУЄ
Обробники по черзі, доки один не візьметься.
Команда
Command
ХТО ОТРИМУЄ
Один виконавець команди.
ЩО ПОВЕРТАЄТЬСЯ
Результат дії, або нічого, якщо команду поставили в чергу.
07 · ПЕРЕВІР СЕБЕ

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

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

OrderService перестає залежати від усіх цих сервісів. Нова реакція це новий слухач, а метод оформлення й тести на нього не змінюються.

Виняток дійде до коду, який викликав event(), і слухачі після нього не виконаються. Тому повільні або ненадійні реакції роблять слухачами з ShouldQueue: їхній збій стає збоєм окремої задачі в черзі.

Model observer реагує на технічні моменти життя моделі: created, updated, deleted. Масовий update() через query builder його не викликає. Доменна подія називає бізнес-факт, наприклад OrderPlaced, і не залежить від того, як модель зберігають.