Zum Inhalt springen

5. Entwurfsmuster III - Struktur und Architektur

Zu Zen-Modus wechseln

Strukturmuster beschäftigen sich damit, wie Objekte zusammengesetzt werden - wie man fremde Bausteine passend macht, wie man Verhalten schichtweise ergänzt, wie man ein ganzes System in Zuständigkeiten gliedert. Dieses Kapitel behandelt zwei Muster im Kleinen (Adapter, Decorator) und ein Muster im Großen (MVC), und es schließt mit der wichtigsten Fähigkeit im Umgang mit Mustern: sich dagegen zu entscheiden.

Problem: Sie brauchen eine Funktion, und es gibt eine Bibliothek dafür - aber deren Schnittstelle passt nicht zu der, die Ihr Code erwartet. Ihre Anwendung erwartet überall einen MediaStore mit save(name, bytes), die eingekaufte Cloud-Bibliothek bietet aber putObject({ key, sizeBytes }). Beide tun dasselbe, sprechen aber verschiedene Sprachen.

Lösung: Ein Adapter sitzt dazwischen und übersetzt. Er implementiert die Schnittstelle, die Ihr Code erwartet, und leitet die Aufrufe passend an die fremde Bibliothek weiter.

classDiagram
  class MediaStore {
    <<interface>>
    +save(name, bytes) void
    +list() string[]
  }
  class CloudBucketAdapter
  class CloudBucketSdk {
    +putObject(request) void
    +listKeys() string[]
  }
  MediaStore <|.. CloudBucketAdapter
  CloudBucketAdapter --> CloudBucketSdk : uebersetzt Aufrufe
// what our application expects everywhere
interface MediaStore {
save(name: string, bytes: number): void;
list(): string[];
}
// the foreign library, with an interface we cannot change
class CloudBucketSdk {
putObject(request: { key: string; sizeBytes: number }): void { /* ... */ }
listKeys(): string[] { /* ... */ return []; }
}
// the adapter makes the foreign interface fit ours
class CloudBucketAdapter implements MediaStore {
constructor(private sdk: CloudBucketSdk) {}
save(name: string, bytes: number): void {
this.sdk.putObject({ key: name, sizeBytes: bytes }); // translate
}
list(): string[] {
return this.sdk.listKeys();
}
}

Der Nutzen ist doppelt. Erstens bleibt Ihr Code von der fremden Bibliothek unberührt - er kennt nur MediaStore. Zweitens ist die Bibliothek austauschbar: Ein Wechsel des Anbieters bedeutet einen neuen Adapter, sonst nichts. Das ist dasselbe Prinzip wie ein Repository-Interface hinter verschiedenen Datenquellen - der Adapter ist die allgemeine Form davon.

Problem: Sie wollen ein Objekt um Verhalten ergänzen, und zwar in frei kombinierbaren Schichten. Eine Benachrichtigung soll gespeichert werden; manchmal zusätzlich per E-Mail, manchmal zusätzlich per Chat, manchmal beides. Löst man das über Vererbung, braucht man für jede Kombination eine Unterklasse (EmailNotifier, SlackNotifier, EmailAndSlackNotifier, …) - die Zahl explodiert.

Lösung: Jede Ergänzung wird ein Objekt, das ein anderes desselben Interfaces umhüllt und sein Verhalten vor oder nach dem Weiterreichen erweitert. Man stapelt Hüllen, statt Unterklassen zu bilden.

classDiagram
  class Notifier {
    <<interface>>
    +send(message) string[]
  }
  class BaseNotifier
  class EmailNotifier
  class SlackNotifier
  Notifier <|.. BaseNotifier
  Notifier <|.. EmailNotifier
  Notifier <|.. SlackNotifier
  EmailNotifier o--> Notifier : umhuellt
  SlackNotifier o--> Notifier : umhuellt
interface Notifier {
send(message: string): string[];
}
class BaseNotifier implements Notifier {
send(message: string): string[] {
return [`stored: ${message}`];
}
}
class EmailNotifier implements Notifier {
constructor(private wrapped: Notifier) {}
send(message: string): string[] {
return [...this.wrapped.send(message), `email: ${message}`]; // add one layer
}
}
class SlackNotifier implements Notifier {
constructor(private wrapped: Notifier) {}
send(message: string): string[] {
return [...this.wrapped.send(message), `slack: ${message}`];
}
}

Die Schichten werden zur Laufzeit zusammengesteckt, in beliebiger Zahl und Reihenfolge:

let notifier: Notifier = new BaseNotifier();
notifier = new EmailNotifier(notifier);
notifier = new SlackNotifier(notifier);
notifier.send("render done");
// ["stored: render done", "email: render done", "slack: render done"]

Statt einer Unterklasse pro Kombination gibt es einen Baustein pro Ergänzung; die Kombinationen entstehen beim Zusammenstecken.

Muster gibt es nicht nur für einzelne Klassen, sondern für den Aufbau ganzer Anwendungen. Das bekannteste ist MVC (Model-View-Controller). Es trennt drei Zuständigkeiten:

TeilZuständigkeitKennt
ModelDaten und fachliche Regelnnichts von der Darstellung
ViewDarstellung für den Benutzerdas Model (liest es), nicht die Regeln
ControllerEingaben entgegennehmen, Model und View koordinierenbeide
flowchart LR
  U[Benutzer] -->|Eingabe| C[Controller]
  C -->|aendert| M[Model]
  M -->|Daten| V[View]
  V -->|Anzeige| U

Der Kern von MVC ist derselbe wie bei einer Schichtenarchitektur: Die Darstellung ist von der Fachlogik getrennt. Das Model weiß nichts davon, wie es angezeigt wird; deshalb lässt sich dieselbe Fachlogik hinter verschiedenen Oberflächen betreiben und unabhängig von der Anzeige testen. MVC hat viele Verwandte (MVVM und andere), die sich in der Aufgabenverteilung zwischen den Teilen unterscheiden; die gemeinsame Idee bleibt die Trennung von Daten, Regeln und Darstellung.

Eine Schichtenarchitektur - Präsentation über Fachlogik über Datenzugriff, mit einbahniger Abhängigkeit nach unten - ist damit selbst ein Architekturmuster. Wer eine solche Trennung sauber umsetzt, hat ein Muster im Großen angewendet, auch ohne es so zu nennen.

Damit ist die Übersicht über die behandelten Muster vollständig. Diese Landkarte ist die Entscheidungshilfe fürs Fachgespräch: Vom Problem aus zum Muster, nicht umgekehrt.

KategorieMusterLöst das Problem …
ErzeugungSingletongenau eine Instanz, überall erreichbar
ErzeugungFactoryObjekt passend zur Eingabe erzeugen, ohne die Klasse zu nennen
VerhaltenObservermehrere reagieren auf ein Ereignis, ohne dass der Auslöser sie kennt
VerhaltenStrategyaustauschbares Verfahren zur Laufzeit wählen
VerhaltenCommandeine Aktion als Objekt (rückgängig, Warteschlange, Protokoll)
StrukturAdapterfremde Schnittstelle passend machen
StrukturDecoratorVerhalten in kombinierbaren Schichten ergänzen
ArchitekturMVC / SchichtenDarstellung, Fachlogik und Daten trennen

Ein Muster ist die Antwort auf ein Problem. Wer Muster einsetzt, um Muster einzusetzen, erzeugt das Gegenteil von guter Struktur: unnötige Klassen, Indirektion ohne Nutzen, Code, den man durch fünf Dateien verfolgen muss, um zu verstehen, dass am Ende ein String zurückgegeben wird. Dieser Überschuss hat einen Namen: Patternitis.

// Patternitis: a factory that produces a strategy for a decision with one option
const greeterFactory = new AbstractGreeterFactoryProvider();
const greeter = greeterFactory.createFactory().getStrategy("default").build();
greeter.execute("hello");
// the honest version
console.log("hello");

Die Gegenmittel sind schlicht:

  • Problem zuerst. Erst das konkrete Problem benennen, dann prüfen, ob ein bekanntes Muster genau dieses Problem löst. Nie mit dem Muster beginnen.
  • Der einfachste Weg zählt. Wenn eine Funktion genügt, ist eine Klassenhierarchie falsch. Ein Muster muss sich seinen Aufwand verdienen.
  • Ein Muster ist Kommunikation. Sein Name spart Erklärung nur, wenn er tatsächlich passt. Ein falsch benanntes Muster verwirrt mehr, als es hilft.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Erklären: Adapter und Decorator mit ihrem Problem, Diagramm und Nutzen erklären.
  • Anwenden: beide Muster in TypeScript implementieren und Decorator-Schichten zur Laufzeit zusammenstecken.
  • Erklären: MVC und die Idee der Schichtenarchitektur als Architekturmuster erklären und die Trennung von Darstellung und Fachlogik begründen.
  • Analysieren: die behandelten Muster über eine Landkarte vom Problem aus zuordnen.
  • Beurteilen: übertriebenen Mustereinsatz (Patternitis) erkennen und sich begründet gegen ein Muster entscheiden.
  • Aufgabe 08 - Muster-Landkarte