Builder
Kurz gesagt: Builder baut ein komplexes Objekt Schritt für Schritt zusammen, mit einem Namen für jedes Feld, und prüft es in build(); in JavaScript tut ein Options-Objekt oft dasselbe.
Problem
Neun Parameter und zwei vertauschte Wörter
Der Shop schickt eine E-Mail, wenn eine Bestellung versandt wird. Die E-Mail hat neun Felder: an wen sie geht, die Adresse für Antworten, den Betreff, den Text, eine Kopie, eine Blindkopie, einen Anhang, ob sie wichtig ist und ob das Öffnen verfolgt wird.
class Email { constructor(to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens) { this.to = to; this.replyTo = replyTo; this.subject = subject; this.body = body; this.cc = cc; this.bcc = bcc; this.attachment = attachment; this.highPriority = highPriority; this.trackOpens = trackOpens; }
send() { console.log(`An: ${this.to}`); if (this.replyTo) { console.log(`Antwort an: ${this.replyTo}`); } console.log(`Betreff: ${this.subject}`); if (this.attachment) { console.log(`Anhang: ${this.attachment}`); } if (this.highPriority) { console.log("Hohe Priorität"); } }}So wird die Bestätigung für Bestellung 1042 verschickt:
const email = new Email("support@shop.example", "maria@example.com", "Bestellung 1042 wurde versandt", "Dein Paket ist unterwegs.", null, null, "invoice-1042.pdf", true, false);email.send();An: support@shop.exampleAntwort an: maria@example.comBetreff: Bestellung 1042 wurde versandtAnhang: invoice-1042.pdfHohe PrioritätDie E-Mail ging an den Support des Shops, und Maria hat nichts bekommen. Die ersten beiden Argumente sind vertauscht. Beide sind Text, also kann die Sprache es nicht erkennen. Und welches ist am Ende true und welches false? Um das herauszufinden, zählst du Positionen im Konstruktor. Zwei null halten die Plätze von Feldern frei, die diese E-Mail nicht hat.
Jedes neue Feld, sagen wir die Sprache der E-Mail, ändert den Konstruktor und jede Stelle, die ihn aufruft.
Die Idee
Builder: über Namen, in Schritten, mit Prüfung
Das Objekt entsteht nicht durch einen Aufruf mit neun Positionen, sondern in Schritten, jeder mit einem Namen: .to(…), .subject(…). Die Schritte gehen an ein eigenes Objekt, den Builder. Er sammelt die Werte, und am Ende build():
- prüft die Pflichtfelder (hier: Empfänger, Betreff und Text) und wirft eine Exception, wenn eins fehlt;
- erzeugt ein unveränderliches Objekt: Danach kann sich die E-Mail nicht mehr ändern.
Das ist das Muster Builder. Die Rollen:
- das Produkt:
Email, unveränderlich; - der Builder:
EmailBuilder, mit einer Methode für jedes Feld. Die Methode gibt den Builder selbst zurück, sodass sich die Aufrufe in einer Kette aneinanderreihen; build(): die einzige Stelle, an der das Produkt geprüft und erzeugt wird.
Sieh es dir an
Die Kette und build()
class Email { constructor({ to, replyTo = null, subject, body, cc = null, bcc = null, attachment = null, highPriority = false, trackOpens = false, }) { Object.assign(this, { to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens }); Object.freeze(this); }
send() { console.log(`An: ${this.to}`); if (this.replyTo) { console.log(`Antwort an: ${this.replyTo}`); } console.log(`Betreff: ${this.subject}`); if (this.attachment) { console.log(`Anhang: ${this.attachment}`); } if (this.highPriority) { console.log("Hohe Priorität"); } }}
class EmailBuilder { fields = {};
to(value) { this.fields.to = value; return this; }
replyTo(value) { this.fields.replyTo = value; return this; }
subject(value) { this.fields.subject = value; return this; }
body(value) { this.fields.body = value; return this; }
cc(value) { this.fields.cc = value; return this; }
bcc(value) { this.fields.bcc = value; return this; }
attachment(value) { this.fields.attachment = value; return this; }
highPriority() { this.fields.highPriority = true; return this; }
trackOpens() { this.fields.trackOpens = true; return this; }
build() { if (this.fields.to === undefined) { throw new Error("Empfänger fehlt"); } if (this.fields.subject === undefined) { throw new Error("Betreff fehlt"); } if (this.fields.body === undefined) { throw new Error("Text fehlt"); } return new Email(this.fields); }}Ein Builder kostet Code: Jedes Feld wird sowohl in der E-Mail als auch in der Methode des Builders geschrieben. Deshalb baut man ihn nicht für jede Klasse.
Email nimmt ein Options-Objekt und destrukturiert es über Namen, mit Standardwerten. Object.freeze(this) verbietet Änderungen nach der Erzeugung.const email = new EmailBuilder() .to("maria@example.com") .subject("Bestellung 1042 wurde versandt") .body("Dein Paket ist unterwegs.") .attachment("invoice-1042.pdf") .highPriority() .build();email.send();
const draft = new EmailBuilder() .to("peter@example.com") .body("Dein Paket ist unterwegs.");try { draft.build();} catch (e) { console.log(e.message);}An: maria@example.comBetreff: Bestellung 1042 wurde versandtAnhang: invoice-1042.pdfHohe PrioritätBetreff fehltconst email = new EmailBuilder() .to("maria@example.com") .subject("Bestellung 1042 wurde versandt") .body("Dein Paket ist unterwegs.") .attachment("invoice-1042.pdf") .highPriority() .build();email.send();
const draft = new EmailBuilder() .to("peter@example.com") .body("Dein Paket ist unterwegs.");try { draft.build();} catch (e) { console.log(e.message);}Schritt für Schritt, in Worten
- new EmailBuilder(): ein leerer Bauplan mit neun Feldern. Die drei Pflichtfelder (to, subject, body) sind markiert.
- .to(…) füllt ein Feld und gibt denselben Builder zurück, also geht die Kette weiter.
- Jeder Aufruf hat einen Namen: Ein Wert kann nicht im falschen Feld landen, wie es bei den Positionen im Konstruktor passierte.
- .body(…): Alle drei Pflichtfelder sind gefüllt.
- Optionale Felder schreibst du nur, wenn du sie brauchst. highPriority() hat kein Argument: kein true mehr an achter Stelle.
- build() prüft die Pflichtfelder und stanzt eine Email aus. Ihre Felder sind Object.freeze: Das Objekt ändert sich nicht mehr.
- Die E-Mail geht an Maria, mit der Rechnung, mit hoher Priorität.
- Ein neuer Builder, ein neuer leerer Bauplan. Hier hat jemand den Betreff vergessen.
- draft.build() stößt auf das leere Feld subject und bricht mit einer Exception ab. Eine E-Mail ohne Betreff entsteht gar nicht erst.
- Der Fehler sagt, was fehlt. Es wurde nichts gesendet.
Die E-Mail ohne Betreff erreicht nie die Post: build() stoppt sie schon beim Zusammenbau.
Sieh es dir an
Ohne Builder: was die Sprache bietet
class Email { constructor({ to, replyTo = null, subject, body, cc = null, bcc = null, attachment = null, highPriority = false, trackOpens = false, }) { if (!to || !subject || !body) { throw new Error("Empfänger, Betreff oder Text fehlt"); } Object.assign(this, { to, replyTo, subject, body, cc, bcc, attachment, highPriority, trackOpens }); Object.freeze(this); }}const email = new Email({ to: "maria@example.com", subject: "Bestellung 1042 wurde versandt", body: "Dein Paket ist unterwegs.", attachment: "invoice-1042.pdf", highPriority: true,});console.log(email.to, "/", email.subject, "/", email.highPriority);maria@example.com / Bestellung 1042 wurde versandt / trueObject.freeze stoppt Änderungen. In einem Modul (einer Datei mit import) oder mit "use strict" wirft die Zuweisung einen Fehler, in einem einfachen Skript tut sie stillschweigend nichts:email.to = "peter@example.com";TypeError: Cannot assign to read only property 'to' of object '#<Email>'Wann sich ein Builder trotzdem lohnt:
- die E-Mail wird in Schritten an verschiedenen Stellen des Codes zusammengesetzt: Der Anhang kommt nur dazu, wenn es eine Rechnung gibt, die Kopie nur bei Firmen;
- es gibt eine Prüfung über mehrere Felder: Zum Beispiel darf die Blindkopie nicht die Adresse des Empfängers sein;
- es gibt viele Felder, die meisten optional, und der Zusammenbau muss von einer Stelle zur anderen weitergereicht werden, bevor er fertig ist.
Probier es aus
Sag die Ausgabe voraus
Ein Builder, zwei Aufrufe von build(). Was gibt das Programm aus?
const builder = new EmailBuilder() .to("maria@example.com") .subject("Bestellung 1042 wurde versandt") .body("Dein Paket ist unterwegs.");const first = builder.build();builder.highPriority();const second = builder.build();console.log(first.highPriority, second.highPriority);Probier es aus
Finde den Fehler
Der Shop schickt die E-Mails für versandte Bestellungen in einer Schleife. Nur die erste Bestellung hat eine Rechnung:
const orders = [ { email: "maria@example.com", id: 1042, invoice: "invoice-1042.pdf" }, { email: "peter@example.com", id: 1043, invoice: null },];const builder = new EmailBuilder().replyTo("support@shop.example");for (const order of orders) { builder .to(order.email) .subject(`Bestellung ${order.id} wurde versandt`) .body("Dein Paket ist unterwegs."); if (order.invoice) { builder.attachment(order.invoice); } builder.build().send();}An: maria@example.comAntwort an: support@shop.exampleBetreff: Bestellung 1042 wurde versandtAnhang: invoice-1042.pdfAn: peter@example.comAntwort an: support@shop.exampleBetreff: Bestellung 1043 wurde versandtAnhang: invoice-1042.pdfPeter hat die Rechnung von Maria bekommen. In welcher Zeile steckt der Fehler?
Wähle die Zeile mit dem Fehler.
Der Builder wird einmal vor der Schleife erzeugt. Er merkt sich alles, was man ihm gesagt hat, und der Anhang der ersten E-Mail bleibt für die zweite, weil ihn niemand gelöscht hat. Ein Builder ist für einen Zusammenbau gedacht: Erzeuge in der Schleife für jede E-Mail einen neuen. Hier gibt der Fehler personenbezogene Daten preis: die Rechnung eines anderen, mit Namen und Adresse.
Die Idee
Wann du ihn nicht brauchst
Builder verdoppelt den Code der Klasse. Er lohnt sich nicht, wenn:
- es zwei oder drei Felder gibt und alle Pflicht sind: Ein einfacher Konstruktor ist klarer;
- ein Options-Objekt dasselbe ohne zweite Klasse tut;
- das Objekt nichts zu prüfen hat und nicht in Schritten zusammengesetzt wird.
Er lohnt sich, wenn es viele Felder gibt und die meisten optional sind, wenn es eine Prüfung über mehrere gibt oder wenn das Objekt in Schritten an verschiedenen Stellen des Codes zusammengesetzt wird.
Übung
Ein Paket für den Kurier
Löse die Aufgabe in deinem Editor.
Ein Builder für ein Paket. Der Kurier will ein Paket mit Empfänger, Telefon, Adresse und Gewicht (Pflicht) und optional: zerbrechlich und Nachnahme. Schreibe Shipment und einen Builder dafür. build() prüft, dass die Pflichtfelder gesetzt sind und dass das Gewicht über 0 und bis 30 kg liegt. Erzeuge ein Paket für Maria (2,5 kg, zerbrechlich, Nachnahme 42,90 €), gib es aus und probiere ein Paket mit 45 kg:
Paket für Maria, 0888 123 456Adresse: Gartenstraße 5, HamburgGewicht: 2.5 kg, zerbrechlichNachnahme: 42.90 €Das Gewicht muss über 0 und bis 30 kg liegenWeitere Aufgaben
- Die Schleife reparieren. Repariere das Programm aus „Finde den Fehler“ so, dass jede E-Mail nur ihre eigene Rechnung trägt. Die Antwortadresse muss für alle E-Mails bleiben.
- Eine Kopie mit einer Änderung. Füge eine Methode hinzu, die aus einer fertigen E-Mail einen Builder mit denselben Werten macht (in Java heißt sie oft
toBuilder()). Schicke dieselbe E-Mail an einen anderen Empfänger. - Der Weg der Sprache. Schreibe
Shipmentwie in der Karte „Ohne Builder“ und vergleiche die beiden Versionen. Welche ist kürzer, und was verlierst du?
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Klassen und Daten in Klassen · Daten, switch und Destrukturierung · Entwurfsmuster · Factory Method und Simple Factory
- Geht weiter in: Der Shop: welches Muster wohin
- Aufgaben zum Thema: Entwurfsmuster