Entwurfsmuster
Kurz gesagt: Ein Entwurfsmuster ist eine bewährte Lösung für ein Problem, das in vielen Programmen wiederkehrt, zum Beispiel Strategy, Observer, Factory Method, Singleton und Adapter.
Problem
Die Funktion, die mit jeder Aktion wächst
Der Preis im Shop hängt von der Aktion ab:
function finalPrice(price, promo) { if (promo === "student") { return price * 0.9; } else if (promo === "black friday") { return Math.max(price - 20, 0); } else if (promo === "stammkunde") { return price * 0.95; } return price;}console.log(`${finalPrice(120, "student").toFixed(2)} €`);console.log(`${finalPrice(120, "black friday").toFixed(2)} €`);console.log(`${finalPrice(120, "keine").toFixed(2)} €`);108.00 €100.00 €120.00 €else if in dieser Funktion, und oft in zwei weiteren: in der für die Rechnung und in der für die Bestätigungs-E-Mail. Die Funktion kennt alle Aktionen auf einmal, und jede Änderung fasst sie an. Dieses Problem ist so häufig, dass seine Lösung einen Namen hat.Die Idee
Entwurfsmuster
Die Muster teilen sich in drei Gruppen:
- Erzeugungsmuster: wie Objekte entstehen. Zum Beispiel Singleton (nur ein Objekt der Klasse) und Factory Method (welche Klasse erzeugt wird, entscheidet jemand anderes).
- Strukturmuster: wie Klassen und Objekte zu größeren zusammengesetzt werden. Zum Beispiel Adapter (verbindet unverträgliche Interfaces) und Composite (ein Baum aus Teilen, mit dem man wie mit einem Ganzen arbeitet).
- Verhaltensmuster: wie Objekte die Arbeit aufteilen und miteinander reden. Zum Beispiel Strategy (eine austauschbare Regel) und Observer (Abonnenten für ein Ereignis).
Warum es sich lohnt, sie zu kennen:
- du erfindest keine Lösung neu, die andere schon erprobt haben;
- der Code ändert sich mit weniger Risiko, weil der Teil, der sich ändert, abgetrennt ist;
- die Namen sind eine gemeinsame Sprache. „Setz hier einen Adapter ein“ sagt einer Kollegin eine ganze Struktur in einem Wort.
Genauer, für Neugierige
if die Arbeit erledigt, brauchst du kein Muster.Sieh es dir an
Strategy: eine austauschbare Regel
class Checkout { constructor(discount) { this.discount = discount; }
total(price) { return Math.max(this.discount(price), 0); }}const checkout = new Checkout((price) => price);console.log(`Ohne Rabatt: ${checkout.total(120).toFixed(2)} €`);
checkout.discount = (price) => price * 0.9;console.log(`Student: ${checkout.total(120).toFixed(2)} €`);
checkout.discount = (price) => price - 20;console.log(`Black Friday: ${checkout.total(120).toFixed(2)} €`);Ohne Rabatt: 120.00 €Student: 108.00 €Black Friday: 100.00 €Checkout ändert sich nicht. Die Regel lässt sich auch wechseln, während das Programm läuft: Du speicherst eine neue Funktion in checkout.discount.Das ist Strategy: eine Familie von Regeln, jede mit denselben Parametern und demselben Ergebnis, sodass sie austauschbar sind. Die Vergleichsfunktion, die du an sort übergibst, ist auch eine Strategie: sort weiß, wie man sortiert, und du sagst, wonach.Genauer, für Neugierige
In den Büchern heißt die Klasse, die die Strategie benutzt, „Kontext“ (hier Checkout). Hat die Strategie eigene Daten, etwa den Prozentsatz, ist sie eine Funktion, die eine Funktion macht. Die zurückgegebene Pfeilfunktion merkt sich percent, auch nachdem percentDiscount fertig ist:
function percentDiscount(percent) { return (price) => price * (1 - percent / 100);}const checkout = new Checkout(percentDiscount(10));console.log(`Student: ${checkout.total(120).toFixed(2)} €`);Student: 108.00 €Sieh es dir an
Observer: Abonnenten für ein Ereignis
Wenn sich der Bestand eines Produkts ändert, muss die Website die neue Anzahl zeigen, und das Lager muss nachbestellen, wenn nur noch wenige Stück da sind. Morgen kommt vielleicht noch eine E-Mail an die Kunden dazu. Das Produkt soll von keinem von ihnen wissen.
class Product { constructor(name, quantity) { this.name = name; this.quantity = quantity; this.listeners = []; }
subscribe(listener) { this.listeners.push(listener); }
setQuantity(quantity) { if (quantity === this.quantity) { return; } this.quantity = quantity; for (const listener of this.listeners) { listener(this.name, quantity); } }}const phone = new Product("Telefon", 0);phone.subscribe((name, qty) => console.log(`Website: ${name}, Bestand ${qty} Stk.`));phone.subscribe((name, qty) => { if (qty < 3) { console.log(`Lager: Nachbestellung ${name}`); }});
phone.setQuantity(10);phone.setQuantity(2);Website: Telefon, Bestand 10 Stk.Website: Telefon, Bestand 2 Stk.Lager: Nachbestellung Telefon- Wer Bescheid wissen will, abonniert mit
subscribeund übergibt eine Funktion. - Bei einer Änderung geht das Produkt die Liste durch und ruft jede Funktion mit dem Namen und der Anzahl auf. Wer die Abonnenten sind und was sie tun, geht es nichts an.
- Hat sich die Anzahl nicht geändert, wird niemand gestört.
const phone = new Product("Telefon", 0);phone.subscribe((name, qty) => console.log(`Website: ${name}, Bestand ${qty} Stk.`));phone.subscribe((name, qty) => { if (qty < 3) { console.log(`Lager: Nachbestellung ${name}`); }});
phone.setQuantity(10);phone.setQuantity(2);Schritt für Schritt, in Worten
- Telefon mit Bestand 0 und einer leeren Liste listeners.
- Die Website abonniert: die Pfeilfunktion kommt in die Liste.
- Das Lager auch. Das Produkt weiß nicht, wer sie sind, nur dass es sie mit einem Namen und einer Anzahl aufrufen kann.
- Der Bestand ändert sich auf 10. Das Produkt geht die Liste durch und ruft jeden Abonnenten in der Reihenfolge des Abonnierens auf. Die Website zeigt die neue Anzahl.
- Das Lager prüft 10 < 3: nein, also tut es nichts.
- Der Bestand ändert sich auf 2. Das Produkt geht die Liste durch und ruft jeden Abonnenten in der Reihenfolge des Abonnierens auf. Die Website zeigt die neue Anzahl.
- Das Lager prüft 2 < 3: ja, also bestellt es nach.
Das ist Observer (Beobachter). Das Produkt ist das beobachtete Objekt (das Subject), und die Listener sind die Observer. Buttons in grafischen Programmen funktionieren genau so: „Beim Klick ruf das hier auf“.
Genauer, für Neugierige
- Du brauchst auch ein
unsubscribe, das den Listener aus der Liste entfernt. Du entfernst dieselbe Funktion, die du abonniert hast, also hältst du sie in einer Variable. Ein Abonnent, den niemand entfernt, bleibt im Speicher, solange das Produkt lebt.
const phone = new Product("Telefon", 5);const site = (name, qty) => console.log(`Website: ${qty} Stk.`);phone.subscribe(site);phone.setQuantity(4);phone.listeners = phone.listeners.filter((l) => l !== site);phone.setQuantity(3);console.log("Die Website hört nicht mehr zu");Website: 4 Stk.Die Website hört nicht mehr zu- In der alten Vorlesung heißt die beobachtete Klasse
Subject, mit den MethodenattachundnotifyAllObservers, und der Observer ist eine abstrakte Klasse mit einer Methodeupdate(). Die Idee ist dieselbe, und mit Funktionen brauchst du keine Klasse für jeden Observer. - Die Listener werden nacheinander aufgerufen, innerhalb der Methode, die die Anzahl geändert hat. Ist einer von ihnen langsam, warten alle.
Probier es aus
Wer hört es?
Product ist das aus der Karte davor.
Was gibt das Programm aus?
const coffee = new Product("Kaffee", 5);coffee.subscribe((name, qty) => console.log(`A: ${qty}`));coffee.setQuantity(4);coffee.setQuantity(4);coffee.subscribe((name, qty) => console.log(`B: ${qty}`));coffee.setQuantity(0);setQuantity(4) benachrichtigt A. Das zweite ist keine Änderung, und niemand hört etwas. B abonniert danach und erfährt nichts von früheren Änderungen. Bei 0 hören es beide, in der Reihenfolge, in der sie abonniert haben.Sieh es dir an
Factory Method: welche Klasse erzeugt wird
new EmailNotification() schreiben, jede mit ihrem eigenen if. JavaScript hat keine Interfaces: Eine Benachrichtigung ist jedes Objekt mit einer Methode send.// Eine Benachrichtigung ist jedes Objekt mit einer Methode send(to, text).class EmailNotification { send(to, text) { console.log(`E-Mail an ${to}: ${text}`); }}
class SmsNotification { send(to, text) { console.log(`SMS an ${to}: ${text}`); }}function createNotification(channel) { switch (channel) { case "email": return new EmailNotification(); case "sms": return new SmsNotification(); default: throw new Error(`Unbekannter Kanal: ${channel}`); }}const customers = [ ["email", "maria@example.com"], ["sms", "+49 151 2345678"], ["taube", "Hauptstraße 5"],];for (const [channel, to] of customers) { try { const n = createNotification(channel); n.send(to, "Deine Bestellung wurde versandt."); } catch (e) { console.log(e.message); }}E-Mail an maria@example.com: Deine Bestellung wurde versandt.SMS an +49 151 2345678: Deine Bestellung wurde versandt.Unbekannter Kanal: taubesend kann. Welche Klasse genau dahintersteht, weiß nur die Fabrik. Ein neuer Kanal, zum Beispiel Push, ist eine neue Klasse und ein neuer case im switch. Das Senden wird nicht angefasst.Genauer, für Neugierige
- Das ist die häufigste Form in der Praxis. Ihr genauerer Name ist „einfache Fabrik“ (simple factory). Die Factory Method aus dem GoF-Buch ist etwas anderes: Die Basisklasse hat eine Methode, die das Objekt erzeugt, und die Unterklassen entscheiden, welches. JavaScript hat kein
abstract, darum wirft die Methode in der Basisklasse einen Fehler: Schreibt die Unterklasse sie nicht, zeigt sich der Fehler beim ersten Aufruf:
class Shop { createNotification() { throw new Error("createNotification() muss in der Unterklasse geschrieben werden"); }
confirmOrder(to) { this.createNotification().send(to, "Die Bestellung ist eingegangen."); }}
class OnlineShop extends Shop { createNotification() { return new EmailNotification(); }}
class PhoneShop extends Shop { createNotification() { return new SmsNotification(); }}new OnlineShop().confirmOrder("maria@example.com");new PhoneShop().confirmOrder("+49 151 2345678");E-Mail an maria@example.com: Die Bestellung ist eingegangen.SMS an +49 151 2345678: Die Bestellung ist eingegangen.confirmOrder ist einmal geschrieben, in Shop, und weiß nicht, welche Benachrichtigung es sendet.
- Erreicht die Fabrik kein
return, gibt sieundefinedzurück. Dann kommt der Fehler erst beisend, weit weg von der eigentlichen Ursache:
function createNotification(channel) { if (channel === "email") { return new EmailNotification(); } if (channel === "sms") { return new SmsNotification(); }}
const n = createNotification("taube");try { n.send("Hauptstraße 5", "Deine Bestellung wurde versandt.");} catch (e) { console.log(`${e.name}: ${e.message}`);}TypeError: Cannot read properties of undefined (reading 'send')Eine Exception mit einer klaren Meldung in der Fabrik ist besser.
Sieh es dir an
Singleton: nur ein Objekt
Die Einstellungen der Anwendung, etwa die Währung, werden einmal gelesen. Macht sich jeder Teil des Programms sein eigenes Einstellungsobjekt, werden sie viele Male gelesen und können auseinanderlaufen.
class AppConfig { static instance = null;
constructor() { if (AppConfig.instance !== null) { return AppConfig.instance; } console.log("Einstellungen werden gelesen..."); this.currency = "EUR"; AppConfig.instance = this; }}const a = new AppConfig();const b = new AppConfig();console.log(a === b);console.log(b.currency);Einstellungen werden gelesen...trueEUR- JavaScript hat keinen
privateKonstruktor: Jeder kannnew AppConfig()schreiben. Darum steckt die Absicherung im Konstruktor selbst. - Das einzige Objekt steht im statischen Feld
instance. Gibt es schon ein Objekt, gibt der Konstruktor es mitreturnzurück. Wenn ein Konstruktor ein Objekt zurückgibt, liefertnewdieses, nicht das neue. - „Einstellungen werden gelesen...“ erscheint einmal, und
a === bisttrue: Es gibt ein Objekt.
Das ist Singleton.
Genauer, für Neugierige
- Der einfachste Singleton in JavaScript ist ein normales Objekt. Es gibt keine Klasse, aus der man ein zweites machen könnte, und
Object.freezeschützt seine Felder:
const settings = Object.freeze({ currency: "EUR",});console.log(settings.currency);console.log(Object.isFrozen(settings));EURtrue- In JavaScript macht man genau das häufiger: Das Objekt wird einmal erzeugt, in einem Modul, und die anderen Teile holen es mit
import. - Ein Singleton ist eine globale Variable für das ganze Programm mit einem schönen Namen. Jeder kann ihn von überall benutzen, darum ist er schwer zu testen und schwer zu ersetzen. Oft ist es besser, das Objekt einmal am Anfang des Programms zu erzeugen und es denen, die es benutzen, als Parameter zu übergeben.
Probier es aus
Finde den Fehler
Bestellnummern müssen eindeutig sein, darum ist der Zähler ein Singleton. Das Programm hat aber die Nummer 1001 zweimal vergeben:
const numbers = new OrderNumbers();console.log(numbers.nextNumber());console.log(numbers.nextNumber());console.log(new OrderNumbers().nextNumber());100110021001In welcher Zeile der Klasse steckt der Fehler?
Wähle die Zeile mit dem Fehler.
this.instance = this; speichert das Objekt in einem Feld des Objekts selbst, nicht der Klasse. OrderNumbers.instance bleibt null, die Prüfung in Zeile 5 ist nie wahr, und jedes new OrderNumbers() macht einen neuen Zähler ab 1001. Die Korrektur ist OrderNumbers.instance = this;.Sieh es dir an
Adapter: verbindet unverträgliche Interfaces
Der Shop bezahlt über ein Interface mit einem Betrag in Euro und einer Antwort „ja oder nein“. Die Bank gibt dir eine alte Bibliothek, die Cent will und einen Fehlercode zurückgibt. Die Bibliothek gehört nicht dir, und du kannst sie nicht ändern.
// Die alte Bibliothek der Bank. Du kannst sie nicht ändern.class LegacyBankClient { // Der Betrag ist in Cent. Gibt bei Erfolg 0 zurück, sonst einen Fehlercode. charge(cents, reference) { console.log(`Bank: ${cents} Cent für ${reference}`); return cents <= 50000 ? 0 : 51; }}Du schreibst eine Klasse, die so aussieht, wie der Shop es erwartet, und darin die Bank aufruft:
// Das erwartet der Shop: ein Objekt mit einer Methode pay(orderId, amountEur), die true oder false zurückgibt.class BankAdapter { constructor(bank) { this.bank = bank; }
pay(orderId, amountEur) { const cents = Math.round(amountEur * 100); return this.bank.charge(cents, `ORDER-${orderId}`) === 0; }}const gateway = new BankAdapter(new LegacyBankClient());console.log(gateway.pay("1001", 49.9));console.log(gateway.pay("1002", 1299));Bank: 4990 Cent für ORDER-1001trueBank: 129900 Cent für ORDER-1002falsetrue auf dem Rückweg. Der Shop weiß nicht, dass eine alte Bibliothek dahintersteht: Für ihn zählt nur, dass das Objekt pay hat. Wechselst du morgen die Bank, schreibst du einen neuen Adapter, und der Shop wird nicht angefasst.Das ist Adapter: wie ein Reisestecker-Adapter, der weder den Stecker noch die Steckdose verändert.
Genauer, für Neugierige
Das klassische Beispiel in den Büchern, und in der alten Vorlesung, ist ein Mediaplayer. AudioPlayer kann nur mp3 abspielen, und eine vorhandene Klasse AdvancedMediaPlayer hat die Methoden playVlc und playMp4. MediaAdapter implementiert das Interface des Players und leitet an die richtige Methode weiter. Die Struktur ist dieselbe wie hier.
Übung
Muster im Shop
Löse die Aufgaben in deinem Editor.
1. Rabatte als Strategien. Drei Funktionen, die eine Pfeilfunktion zurückgeben: none(), percent(p) und fixed(amount) (nie unter 0 €). Eine Klasse Cart mit einem Array von Preisen und einem austauschbaren Rabatt im Feld discount. Füge Kaffeebohnen (18,50 €), Honig (12 €) und Tee (4,20 €) hinzu und gib die Summe aus: ohne Rabatt, mit −10 %, mit −5 € und mit −50 €.
Ohne Rabatt: 34.70 €-10%: 31.23 €-5 €: 29.70 €-50 €: 0.00 €2. „Benachrichtige mich, wenn es wieder da ist“. Das Produkt hält eine Liste von Kunden, die warten. Wenn der Bestand von 0 auf mehr als 0 geht, benachrichtigt es alle und leert die Liste, denn die Benachrichtigung kommt nur einmal. Die Konsole hat 0 Stück auf Lager, Maria und Jonas abonnieren, dann wird der Bestand 5, 0 und 3.
E-Mail an Maria: Konsole ist verfügbar (5 Stk.)SMS an Jonas: Konsole ist verfügbarEnde3. Eine Fabrik für Benachrichtigungen. Füge PushNotification hinzu und eine Fabrik für die drei Kanäle, der Groß- und Kleinschreibung egal ist. Lies eine Anzahl von Zeilen und dann Zeilen „Kanal;Adresse“. Schick an jede Adresse „Deine Reservierung ist bestätigt.“, und gib für einen unbekannten Kanal die Meldung der Exception aus.
4Email;maria@example.comsms;+49 151 2345678push;Lauras Handyfax;030 123456E-Mail an maria@example.com: Deine Reservierung ist bestätigt.SMS an +49 151 2345678: Deine Reservierung ist bestätigt.Push an Lauras Handy: Deine Reservierung ist bestätigt.Unbekannter Kanal: faxOldThermometer liefert die Temperatur in Fahrenheit mit readFahrenheit(). Das neue System arbeitet mit Sensoren: Objekten mit einer Methode celsius(). Schreib ThermometerAdapter (°C = (°F − 32) × 5 / 9). Leg in ein Array von Sensoren zwei alte Thermometer (71,6 °F und 39,2 °F) über den Adapter und einen neuen Sensor als Objekt { celsius: () => 21.5 }.Büro: 22.0 °CLager: 4.0 °CServerraum: 21.5 °CWeitere Aufgaben
- Lieferung als Strategie. Mach Funktionen für den Lieferpreis zur Packstation, an die Haustür und ab dem Laden, und setz die gewählte zusammen mit dem Rabatt in die Kasse.
- Ein Log als Singleton. Eine Klasse
Logger, deren Konstruktor das einzige Objekt zurückgibt, und eine Methodelog(text), die die Zeilen nummeriert. Prüf, dass die Nummern weiterlaufen, wenn du sie aus zwei verschiedenen Funktionen aufrufst. - Ein Preis-Observer. Füge dem Produkt Abonnenten für Preisänderungen hinzu und schick eine Benachrichtigung nur, wenn der Preis um mindestens 10 % fällt.
Test
Teste dich
Die Idee
Weiter: die Muster aus der Nähe
Hier hast du fünf Muster kurz gesehen, mit Lambdas. Im Kurs „Softwaretechnik“ gibt es ein ganzes Modul über Muster: Jedes beginnt mit Code, der wehtut, hat eine Animation, welches Objekt welches aufruft, und sagt, wann du es nicht brauchst. Dort sind auch Builder, Decorator, Composite, Command, State, Template Method, Iterator und Repository.
Das Modul ist hier: Entwurfsmuster.Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Arrays: push, splice und sort · Klassen und Daten in Klassen · Pfeilfunktionen · Daten, switch und Destrukturierung
- Geht weiter in: Warum Muster · Strategy · Observer · Factory Method und Simple Factory
- Aufgaben zum Thema: Modernes JavaScript