Observer
Kurz gesagt: Observer lässt ein Objekt seine Abonnenten benachrichtigen, wenn etwas passiert, ohne zu wissen, wer sie sind; in Node.js ist das ein EventEmitter.
Problem
Die Zahlung, die alle anruft
Ein Kunde bezahlt die Bestellung #1042 über 80 €. Danach muss ein Beleg per E-Mail und SMS rausgehen, das Lager muss die Ware reservieren, und die Buchhaltung muss eine Rechnung ausstellen. Die Klasse für die Zahlungen macht das alles selbst:
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); }}Jeder Dienst ist eine kleine Klasse, die ausgibt, was sie getan hat:
const payments = new Payments();payments.markPaid({ id: 1042, total: 80 });E-Mail: Beleg für #1042SMS: Bestellung #1042 ist bezahltLager: reserviere #1042Rechnung: 80.00 € für #1042Das funktioniert. Aber das Marketing will Treuepunkte bei jeder Zahlung. Das ist ein Feld mehr und eine Zeile mehr in markPaid. Morgen kommt eine Push-Benachrichtigung, übermorgen eine Statistik. Der Code, den jede Zahlung durchläuft, wird für Dinge angefasst, die mit der Zahlung nichts zu tun haben.
Schlimmer noch: Heute antwortet das SMS-Gateway nicht:
class SmsService { notifyPaid(order) { throw new Error("Das SMS-Gateway antwortet nicht"); }}const payments = new Payments();try { payments.markPaid({ id: 1042, total: 80 });} catch (e) { console.log(`Zahlung fehlgeschlagen: ${e.message}`);}E-Mail: Beleg für #1042Zahlung fehlgeschlagen: Das SMS-Gateway antwortet nichtDas Geld ist abgebucht, und die Seite sagt dem Kunden, die Zahlung sei fehlgeschlagen. Das Lager hat nichts reserviert, es gibt keine Rechnung. Ein Dienst, den niemand für wichtig hält, hat die ganze Zahlung gestoppt.
Die Idee
Observer: Wer es wissen will, abonniert
Die Zahlungen sollen nicht wissen, wen sie interessieren. Sie sollen nur wissen, dass jemand erfahren will, wann eine Bestellung bezahlt ist.
Also sind die Zahlungen einEventEmitter aus dem eingebauten Modul node:events: ein Objekt, das Listener nach Ereignisnamen hält. Jeder Listener ist eine Funktion, die die Bestellung nimmt. Wer es wissen will, hört von außen mit on auf das Ereignis "paid". Bei einer Zahlung sendet das Objekt das Ereignis mit emit, und es ruft jeden Listener mit der Bestellung auf.Das ist das Observer-Muster. Es hat drei Rollen:
- das Subjekt ist dort, wo etwas passiert. Hier sind es die Zahlungen. In Büchern heißt es auch Publisher;
- die Abonnenten (Observer) wollen es erfahren. Das sind E-Mail, SMS, das Lager;
- die Benachrichtigung ist der Gang durch die Liste: Jeder Abonnent bekommt, was passiert ist, hier die bezahlte Bestellung.
Ein neuer Abonnent ist neuer Code außerhalb. Das Subjekt wird nicht angefasst. Ein Abonnent kann sich auch abmelden, und dann hört er nichts mehr.
Sieh es dir an
Die Abonnenten erfahren es von selbst
EventEmitter. emit("paid", order) ruft jeden Listener von "paid" auf:import { EventEmitter } from "node:events";
class Payments extends EventEmitter { markPaid(order) { this.emit("paid", order); }}const email = (order) => console.log(`E-Mail: Beleg für #${order.id}`);const sms = (order) => console.log(`SMS: Bestellung #${order.id} ist bezahlt`);const warehouse = (order) => console.log(`Lager: reserviere #${order.id}`);const loyalty = (order) => console.log(`Punkte: +${Math.floor(order.total)} für #${order.id}`);Drei Abonnenten, eine Zahlung. Dann kommen die Treuepunkte, und der Kunde schaltet die SMS ab:
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 });E-Mail: Beleg für #1042SMS: Bestellung #1042 ist bezahltLager: reserviere #1042E-Mail: Beleg für #1043Lager: reserviere #1043Punkte: +25 für #1043const 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 });Schritt für Schritt, in Worten
- Noch hört niemand auf das Ereignis "paid".
- email hört mit on zu: Es kommt zu den Listenern von "paid".
- sms und warehouse auch. Payments weiß nicht, wer sie sind, nur dass sie eine Bestellung annehmen.
- Bestellung #1042 ist bezahlt. Die Benachrichtigung geht an jeden Abonnenten, in der Reihenfolge der Anmeldung.
- Einer nach dem anderen, im selben Aufruf: Beleg, SMS, Reservierung. Jeder Abonnent macht seine Arbeit.
- Die Treuepunkte kommen: loyalty abonniert von außen. In Payments ändert sich keine einzige Zeile.
- Der Kunde schaltet die SMS ab: sms meldet sich ab und verlässt die Liste.
- Bestellung #1043: Die Benachrichtigung erreicht email, warehouse und loyalty. sms steht nicht mehr auf der Liste und erfährt nichts.
Payments ist von Anfang bis Ende derselbe Code. Die Punkte sind aufgetaucht, ohne dass sich eine einzige Zeile geändert hat, und die SMS hat aufgehört, ohne dass die Zahlungen es bemerkt haben.
Genauer, für Neugierige
EventEmitterhat auchonce: einen Listener, der sich nach dem ersten Ereignis selbst entfernt. Im Browser ist dieselbe IdeeEventTarget: Ein Button hataddEventListener("click", ...), und ein Klick ruft jeden Listener auf.- Du entfernst dieselbe Funktion, die du angehängt hast, also steht sie in einer Variablen (
sms).offmit einer neuen Pfeilfunktion, auch mit demselben Code, entfernt nichts. - Die Listener werden nacheinander im selben Aufruf von
markPaidaufgerufen. Ein langsames SMS-Gateway bremst auch die Zahlung. Große Shops schicken das Ereignis an eine Warteschlange (einen Message Broker), und die Dienste holen es dort ab, wenn sie bereit sind.
Sieh es dir an
Ein Abonnent scheitert, die anderen nicht
Observer hat die Verbindung zwischen der Zahlung und der SMS entfernt, aber nicht den Fehler. Der SMS-Abonnent wirft:
const sms = (order) => { throw new Error("Das SMS-Gateway antwortet nicht");};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(`Zahlung fehlgeschlagen: ${e.message}`);}E-Mail: Beleg für #1042Zahlung fehlgeschlagen: Das SMS-Gateway antwortet nichtDie Abonnenten werden in einer Schleife aufgerufen, und die Exception der SMS verlässt die Schleife. Das Lager reserviert weiter nichts, und der Fehler erreicht weiter die Zahlung. Das Subjekt muss jeden Aufruf einzeln schützen:
markPaid(order) { for (const listener of this.listeners("paid")) { try { listener(order); } catch (e) { console.log(`Ein Abonnent ist fehlgeschlagen: ${e.message}`); } }}Mit demselben Programm:
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(`Zahlung fehlgeschlagen: ${e.message}`);}E-Mail: Beleg für #1042Ein Abonnent ist fehlgeschlagen: Das SMS-Gateway antwortet nichtLager: reserviere #1042Die Zahlung ging durch, das Lager hat reserviert, und der Fehler ist festgehalten. In einem echten Shop geht diese Zeile ins Log, und die SMS wird später erneut versucht.
emit ruft die Listener nacheinander auf und stoppt bei der ersten Exception. this.listeners("paid") gibt dir eine Kopie der Liste der Listener, sodass du den Fehler jedes einzelnen abfangen kannst.Probier es aus
Was kommt heraus?
Die Abonnenten sind die aus der Karte „Die Abonnenten erfahren es von selbst“. Was gibt das Programm aus?
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 });Probier es aus
Finde den Fehler
E-Mail: Beleg für #1042In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
"paid", und das Lager hört auf "payed". EventEmitter prüft keine Namen: Ein Ereignis, das niemand sendet, kommt einfach nie. Die Lösung ist "paid". Damit es nicht wieder passiert, lebt der Name an einer Stelle, zum Beispiel const PAID = "paid", oder die Zahlungen haben eine Methode onPaid(listener), die den Namen selbst schreibt.Genauer, für Neugierige
Ein Listener, der sich während der Benachrichtigung selbst entfernt, ist mit EventEmitter sicher: emit kopiert die Liste, bevor es die Listener aufruft. once macht genau das:
const payments = new Payments();payments.once("paid", (order) => console.log(`Willkommensgutschein: #${order.id}`));payments.on("paid", email);payments.markPaid({ id: 1042, total: 80 });payments.markPaid({ id: 1043, total: 25 });Willkommensgutschein: #1042E-Mail: Beleg für #1042E-Mail: Beleg für #1043Mit einem eigenen Array von Funktionen und for...of überspringt dasselbe den Listener nach dem entfernten, und in Java wirft es ConcurrentModificationException.
Die Idee
Wann du es nicht brauchst
Observer hat seinen Preis. Der Code sagt nicht, wer auf die Zahlung reagiert: Um das herauszufinden, suchst du jede Stelle, die anmeldet. Ein Abonnent, den niemand abmeldet, lebt im Speicher, solange das Subjekt lebt. Es lohnt sich nicht, wenn:
- es eine einzige Reaktion gibt und sie immer dieselbe ist. Ein Aufruf ist klarer als eine Liste mit einem Abonnenten;
- der Schritt Teil der Handlung selbst ist. Ist die Zahlung ohne Rechnung ungültig, ist die Rechnung kein Abonnent: Sie wird direkt aufgerufen, und ihr Scheitern stoppt die Zahlung;
- die Reihenfolge zählt und die Schritte voneinander abhängen (erst das Lager, dann die Zahlung, dann die Rechnung). Das ist eine Abfolge, kein Ereignis. Wie man sie an einer Stelle bündelt, steht in der Lektion „Adapter und Facade“.
Es lohnt sich, wenn die Reaktionen viele sind, wachsen und das Subjekt nicht von ihnen abhängen darf: Benachrichtigungen, Punkte, Statistik, Logs.
Übung
Die Rechnung als Abonnent
Löse die Aufgabe in deinem Editor.
Rechnung. Mach die Rechnung zum Abonnenten: eine Funktion invoice, das den Betrag mit zwei Nachkommastellen ausgibt. Melde E-Mail, das Lager und die Rechnung an und bezahle die Bestellung #1042 über 80 €. Die Zahlungen werden nicht angefasst.
E-Mail: Beleg für #1042Lager: reserviere #1042Rechnung: 80.00 € für #1042Weitere Aufgaben
- Ein Geschenk über 100 €. Ein Abonnent, der „Geschenk: kostenlose Lieferung für #…“ nur für Bestellungen über 100 € ausgibt. Bezahle eine über 80 € und eine über 120 €.
- Der Gutschein, der sich selbst abmeldet. Schreibe den Gutschein-Abonnenten aus „Finde den Fehler“ und Zahlungen, die damit umgehen. Mach es mit
once, dann mit einem eigenen Array, und sieh, welcher Listener übersprungen wird. - Zwei Ereignisse. Füge auch Erstattungen hinzu. Das Ereignis
"refunded"und eine MethodemarkRefunded. E-Mail hört auf beide, das Lager nur auf die Zahlung.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Pfeilfunktionen · Daten, switch und Destrukturierung · Entwurfsmuster · Strategy
- Geht weiter in: Der Shop: welches Muster wohin · Ereignisse und Warteschlangen · Transactional Outbox
- Aufgaben zum Thema: Entwurfsmuster