Към съдържанието
wannacode

Observer

Накратко: Observer позволява на един обект да известява абонатите си, когато нещо стане, без да знае кои са те; в Node.js това е EventEmitter.

Проблем

Плащането, което вика всички

Клиентът плаща поръчка #1042 за 80 €. След това трябва да тръгне разписка по имейл и SMS, складът да резервира стоката, а счетоводството да издаде фактура. Класът за плащанията прави всичко сам:

class Payments {
constructor() {
this.email = new EmailService();
this.sms = new SmsService();
this.warehouse = new Warehouse();
this.invoices = new InvoiceService();
}
markPaid(order) {
this.email.sendReceipt(order);
this.sms.notifyPaid(order);
this.warehouse.reserve(order);
this.invoices.issue(order);
}
}

Всяка услуга е малък клас, който печата какво е направил:

const payments = new Payments();
payments.markPaid({ id: 1042, total: 80 });
Изход
Имейл: разписка за #1042
SMS: поръчка #1042 е платена
Склад: резервирай #1042
Фактура: 80.00 € за #1042

Работи. Но маркетингът иска точки за лоялност при всяко плащане. Това е още едно поле и още един ред в markPaid. Утре идва push известие, вдругиден статистика. Кодът, през който минава всяко плащане, се пипа заради неща, които нямат нищо общо с плащането.

По-лошото е друго. Днес SMS шлюзът не отговаря:

class SmsService {
notifyPaid(order) {
throw new Error("SMS шлюзът не отговаря");
}
}
const payments = new Payments();
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`Плащането се провали: ${e.message}`);
}
Изход
Имейл: разписка за #1042
Плащането се провали: SMS шлюзът не отговаря

Парите са взети, а страницата казва на клиента, че плащането се е провалило. Складът не е резервирал нищо, фактура няма. Една услуга, която никой не смята за важна, спря цялото плащане.

Идеята

Observer: който иска да знае, се абонира

Плащанията не трябва да знаят кой се интересува от тях. Трябва да знаят само, че някой иска да научи, когато поръчка е платена.

Затова плащанията са EventEmitter от вградения модул node:events: обект, който пази слушатели по име на събитие. Всеки слушател е функция, която приема поръчката. Който иска да знае, слуша събитието "paid" отвън с on. При плащане обектът пуска събитието с emit и то вика всеки слушател с поръчката.

Това е шаблонът Observer (наблюдател). Има три роли:

  • субектът е този, при когото става нещо. Тук това са плащанията. В книгите се казва още издател (publisher);
  • абонатите (наблюдателите) искат да научат. Те са имейлът, SMS-ът, складът;
  • известяването е обиколката на списъка: на всеки абонат се дава какво е станало, тук платената поръчка.

Нов абонат е нов код отвън. Субектът не се пипа. Абонатът може и да се отпише, и тогава повече не научава.

Виж го

Абонатите научават сами

Плащанията наследяват EventEmitter. emit("paid", order) вика всеки слушател на "paid":
import { EventEmitter } from "node:events";
class Payments extends EventEmitter {
markPaid(order) {
this.emit("paid", order);
}
}
Всеки слушател е стрелкова функция, която приема поръчката:
const email = (order) => console.log(`Имейл: разписка за #${order.id}`);
const sms = (order) => console.log(`SMS: поръчка #${order.id} е платена`);
const warehouse = (order) => console.log(`Склад: резервирай #${order.id}`);
const loyalty = (order) => console.log(`Точки: +${Math.floor(order.total)} за #${order.id}`);

Три абоната, плащане. После идват точките за лоялност, а клиентът изключва SMS-ите:

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
payments.markPaid({ id: 1042, total: 80 });
payments.on("paid", loyalty);
payments.off("paid", sms);
payments.markPaid({ id: 1043, total: 25 });
Изход
Имейл: разписка за #1042
SMS: поръчка #1042 е платена
Склад: резервирай #1042
Имейл: разписка за #1043
Склад: резервирай #1043
Точки: +25 за #1043

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
payments.markPaid({ id: 1042, total: 80 });
payments.on("paid", loyalty);
payments.off("paid", sms);
payments.markPaid({ id: 1043, total: 25 });
Стъпка по стъпка, с думи
  1. Още никой не слуша събитието "paid".
  2. email слуша с on: влиза в слушателите на "paid".
  3. sms и warehouse също. Payments не знае кои са, само че приемат поръчка.
  4. Поръчка #1042 е платена. Известието тръгва към всеки абонат, по реда на абонирането.
  5. Един след друг, в същото извикване: разписка, SMS, резервация. Всеки абонат прави своето.
  6. Идват точките за лоялност: loyalty се абонира отвън. В Payments не се пипа нито ред.
  7. Клиентът изключва SMS-ите: sms се отписва и излиза от списъка.
  8. Поръчка #1043: известието стига до email, warehouse и loyalty. sms вече не е в списъка и не научава нищо.

Payments е един и същ код от началото до края. Точките се появиха, без да се пипне ред в него, а SMS-ът спря, без плащанията да разберат.

По-точно, за любопитните
  • EventEmitter има още once: слушател, който се маха сам след първото събитие. В браузъра същата идея е EventTarget: бутонът има addEventListener("click", ...), и натискането вика всеки слушател.
  • Махаш същата функция, която си закачил, затова тя е в променлива (sms). off с нова стрелкова функция, дори със същия код, не маха нищо.
  • Слушателите се викат един след друг, в същото извикване на markPaid. Бавен SMS шлюз бави и плащането. Големите магазини пращат събитието в опашка (message broker), а услугите го взимат оттам, когато са готови.

Виж го

Един абонат пада, другите не трябва

Observer махна връзката между плащането и SMS-а, но не и грешката. SMS абонатът хвърля:

const sms = (order) => {
throw new Error("SMS шлюзът не отговаря");
};
const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`Плащането се провали: ${e.message}`);
}
Изход
Имейл: разписка за #1042
Плащането се провали: SMS шлюзът не отговаря

Абонатите се викат в цикъл, и изключението от SMS-а излиза от цикъла. Складът пак не резервира, а грешката пак стига до плащането. Субектът трябва да пази всяко извикване поотделно:

markPaid(order) {
for (const listener of this.listeners("paid")) {
try {
listener(order);
} catch (e) {
console.log(`Абонат се провали: ${e.message}`);
}
}
}

Със същата програма:

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", sms);
payments.on("paid", warehouse);
try {
payments.markPaid({ id: 1042, total: 80 });
} catch (e) {
console.log(`Плащането се провали: ${e.message}`);
}
Изход
Имейл: разписка за #1042
Абонат се провали: SMS шлюзът не отговаря
Склад: резервирай #1042

Плащането мина, складът резервира, а провалът е записан. В истински магазин този ред отива в лога, а SMS-ът се пробва пак по-късно.

emit вика слушателите един след друг и спира при първото изключение. this.listeners("paid") дава копие на списъка със слушатели, за да хванеш грешката на всеки.

Пробвай

Предвиди изхода

Абонатите са от картата „Абонатите научават сами“. Какво печата програмата?

const payments = new Payments();
payments.on("paid", email);
payments.on("paid", warehouse);
payments.on("paid", email);
payments.markPaid({ id: 7, total: 10 });
payments.off("paid", warehouse);
payments.markPaid({ id: 8, total: 10 });
Отговор

Пробвай

Намери грешката

Складът е абониран, но не резервира нищо. Програмата печата само:
Изход
Имейл: разписка за #1042

На кой ред е грешката?

Избери реда с грешката.

По-точно, за любопитните

Слушател, който се маха по време на известяването, е безопасен с EventEmitter: emit копира списъка, преди да вика слушателите. once прави точно това:

const payments = new Payments();
payments.once("paid", (order) => console.log(`Купон за добре дошъл: #${order.id}`));
payments.on("paid", email);
payments.markPaid({ id: 1042, total: 80 });
payments.markPaid({ id: 1043, total: 25 });
Изход
Купон за добре дошъл: #1042
Имейл: разписка за #1042
Имейл: разписка за #1043

Със собствен масив от функции и for...of същото прескача слушателя след махнатия, а в Java хвърля ConcurrentModificationException.

Идеята

Кога не ти трябва

Observer има цена. Кодът не казва кой ще реагира на плащането: за да разбереш, търсиш всички места, които абонират. Абонат, когото никой не отписва, живее в паметта, докато живее субектът. Не си струва, когато:

  • реакцията е една и винаги същата. Едно извикване е по-ясно от списък с един абонат;
  • стъпката е част от самото действие. Ако без фактура плащането е невалидно, фактурата не е абонат: вика се направо, а провалът ѝ спира плащането;
  • редът има значение и стъпките зависят една от друга (първо склад, после плащане, после фактура). Това е последователност, не събитие. Как се събира на едно място, е в урока „Adapter и Facade“.

Струва си, когато реакциите са много, растат и субектът не трябва да зависи от тях: известия, точки, статистика, логове.

Упражнение

Фактурата като абонат

Реши задачата в редактора си.

Фактура. Направи фактурата абонат: функция invoice, която печата сумата с две цифри след точката. Абонирай имейла, склада и фактурата и плати поръчка #1042 за 80 €. Плащанията не се пипат.

Изход
Имейл: разписка за #1042
Склад: резервирай #1042
Фактура: 80.00 € за #1042

Още задачи

  • Подарък над 100 €. Абонат, който печата „Подарък: безплатна доставка за #…“ само за поръчки над 100 €. Плати една за 80 € и една за 120 €.
  • Купонът, който се отписва сам. Напиши абоната за купона от „Намери грешката“ и плащания, които го понасят. Направи го с once, после със собствен масив и виж кой слушател се прескача.
  • Две събития. Добави и връщане на пари. Събитието "refunded" и метод markRefunded. Имейлът слуша и двете, складът само плащането.

Тест

Провери се

1. Магазинът добавя точки за лоялност при всяко плащане. Какво пишеш при Observer?
2. Абонатът за SMS хвърля изключение, а известяването не е защитено. Какво става?
3. Един и същ абонат е абониран два пъти. Колко пъти научава за едно плащане?
4. Фактурата трябва да е издадена, иначе плащането не се брои. Подходящ ли е тук Observer?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·