Der Shop: welches Muster wohin
Kurz gesagt: Eine Übung zu Entwurfsmustern: Ein Shop mit Bezahlung, Benachrichtigungen und Speicherung in einer Methode wird mit Strategy, Observer und Repository neu geordnet, und seine Ausgabe bleibt gleich.
Problem
Ein Shop in einer Methode
Hier ist ein kleiner Onlineshop. Die Bestellmethode berechnet den Betrag aus der Zahlungsart, speichert die Bestellung, schickt eine E-Mail und schreibt ins Lager:
class Shop{ private readonly List<string> orders = new();
public void PlaceOrder(string id, string customer, decimal amount, string method) { decimal total; if (method == "Karte") { total = amount; } else if (method == "Nachnahme") { total = amount + 2.50m; } else { throw new ArgumentException("Unbekannte Zahlungsart: " + method); } orders.Add($"{id};{customer};{total}"); Console.WriteLine($"E-Mail an {customer}: Bestellung {id}, {total:F2} €"); Console.WriteLine("Lager: bereite vor " + id); }
public int Count => orders.Count;}var shop = new Shop();shop.PlaceOrder("A-1", "ana@example.com", 80, "Karte");shop.PlaceOrder("A-2", "ivan@example.com", 45, "Nachnahme");Console.WriteLine("Bestellungen: " + shop.Count);E-Mail an ana@example.com: Bestellung A-1, 80.00 €Lager: bereite vor A-1E-Mail an ivan@example.com: Bestellung A-2, 47.50 €Lager: bereite vor A-2Bestellungen: 2Es funktioniert. Aber nächsten Monat kommen drei Änderungen: Überweisung mit 2 % Rabatt, eine SMS nach jeder Bestellung und eine Datenbank statt einer Liste. Alle drei stecken in derselben Methode. Jede fasst sie an, und einen Test für den Betrag, ohne dass eine E-Mail rausgeht, kann man nicht schreiben.
In dieser Lektion gibt es kein neues Muster. Sie ist eine Übung: Du erkennst, welches der Muster im Modul jedes dieser Dinge ordnet und welche hier nicht nötig sind.
Die Idee
Das Symptom nennt das Muster
Du suchst dir ein Muster nicht aus einer Liste aus. Du suchst es nach dem, was wehtut:
| Symptom im Code | Muster |
|---|---|
eine Kette von if auf eine Art, die mit jeder neuen Variante wächst |
Strategy, oder Factory, wenn die Kette auswählt, was erzeugt wird |
| nach einem Ereignis ruft der Code selbst alle an, die es erfahren müssen | Observer |
| die Speicherung sitzt in den Regeln, und ein Test ohne Datenbank ist unmöglich | Repository und das Hineinreichen von außen |
ein switch auf den Zustandstext in jeder Methode |
State |
| ein Konstruktor mit vielen optionalen Parametern | Builder |
| eine fremde Bibliothek mit sperriger Schnittstelle | Adapter |
| fünf Teilsysteme, die in genauer Reihenfolge aufgerufen werden müssen | Facade |
| Verhalten „um“ eine Methode: Logging, Cache, Aufschläge auf den Preis | Decorator |
| die letzte Aktion rückgängig machen | Command |
| ein Baum, in dem ein Teil und das Ganze gleich benutzt werden | Composite |
Wenn du das Symptom nicht findest, brauchst du höchstwahrscheinlich kein Muster.
Probier es aus
Erkenne es
Sieh dir den Shop von der ersten Karte noch einmal an.
Sieh es dir an
Eine mögliche Art, ihn zu ordnen
Drei der Symptome stecken im Shop, also sind es auch drei Muster.
Die Zahlungsarten sind Strategies. Jede ist eine Funktion, also ist die TabelleDictionary<string, Func<decimal, decimal>>, ohne Interface (siehe die Lektion zu Strategy):Die Bestellung ist ein Record. Die Speicherung steht hinter einem Interface, mit einer Implementierung für den Speicher:record Order(string Id, string Customer, decimal Total);
interface IOrderRepository{ void Save(Order order); int Count { get; }}
class InMemoryOrders : IOrderRepository{ private readonly List<Order> orders = new();
public void Save(Order order) => orders.Add(order);
public int Count => orders.Count;}Placed, das er auslöst, wenn eine Bestellung aufgegeben wird:class Shop{ private readonly IOrderRepository orders; private readonly Dictionary<string, Func<decimal, decimal>> methods;
public event Action<Order>? Placed;
public Shop(IOrderRepository orders, Dictionary<string, Func<decimal, decimal>> methods) { this.orders = orders; this.methods = methods; }
public void PlaceOrder(string id, string customer, decimal amount, string method) { if (!methods.TryGetValue(method, out var payment)) { throw new ArgumentException("Unbekannte Zahlungsart: " + method); } var order = new Order(id, customer, payment(amount)); orders.Save(order); Placed?.Invoke(order); }
public int Count => orders.Count;}Welche Speicherung, welche Arten und welche Abonnenten, wird an einer Stelle entschieden, dort, wo der Shop zusammengesetzt wird:
var methods = new Dictionary<string, Func<decimal, decimal>>{ ["Karte"] = amount => amount, ["Nachnahme"] = amount => amount + 2.50m,};var shop = new Shop(new InMemoryOrders(), methods);shop.Placed += o => Console.WriteLine($"E-Mail an {o.Customer}: Bestellung {o.Id}, {o.Total:F2} €");shop.Placed += o => Console.WriteLine("Lager: bereite vor " + o.Id);
shop.PlaceOrder("A-1", "ana@example.com", 80, "Karte");shop.PlaceOrder("A-2", "ivan@example.com", 45, "Nachnahme");Console.WriteLine("Bestellungen: " + shop.Count);E-Mail an ana@example.com: Bestellung A-1, 80.00 €Lager: bereite vor A-1E-Mail an ivan@example.com: Bestellung A-2, 47.50 €Lager: bereite vor A-2Bestellungen: 2Die Ausgabe ist dieselbe, Zeile für Zeile. Das Ordnen hat das Verhalten nicht geändert, und das prüfst du als Erstes. Hier laufen beide Programme bei jeder Prüfung der Seite und geben dieselbe Ausgabe.
Sieh es dir an
Die drei Änderungen jetzt
Überweisung ist eine neue Zeile in der Tabelle. SMS ist ein neuer Abonnent. Die Datenbank wird eine neue Implementierung von IOrderRepository. Keine einzige Zeile der Shop-Klasse ändert sich:
var methods = new Dictionary<string, Func<decimal, decimal>>{ ["Karte"] = amount => amount, ["Nachnahme"] = amount => amount + 2.50m, ["Überweisung"] = amount => amount * 0.98m,};var shop = new Shop(new InMemoryOrders(), methods);shop.Placed += o => Console.WriteLine("SMS an " + o.Customer + ": " + o.Id);
shop.PlaceOrder("A-3", "ana@example.com", 100, "Überweisung");SMS an ana@example.com: A-3Jetzt ist auch ein Test für den Betrag leicht: Du reichst eine Speicherung im Speicher hinein und abonnierst die E-Mail nicht.
Die Idee
Wo du aufhörst
Das Modul hat mehr als zehn Muster, und drei werden hier benutzt. Die anderen sind nicht vergessen, sie helfen hier nur nicht:
- Builder: Die Bestellung hat drei Pflichtfelder. Der Konstruktor (Record) reicht;
- Singleton: Nichts muss das Einzige sein. Die Speicherung wird von außen hineingereicht, also können der Test und der Shop jeweils ihre eigene haben;
- Factory: Die Zahlungsarten sind Funktionen in einer Tabelle, und die Tabelle ist schon der Ort, an dem die Wahl getroffen wird;
- State: Die Bestellung hat noch keine Zustände. Wenn „bezahlt“ und „versandt“ kommen, kommt es auch (siehe die Aufgaben).
Du hörst auf, wenn die Änderung, die ansteht, leicht wird. Mehr Muster heißt nicht besserer Code.
Übung
Ordne den Shop weiter
Löse die Aufgaben in deinem Editor, ausgehend vom geordneten Shop. Führe nach jedem Schritt das Programm von der Karte „Eine mögliche Art, ihn zu ordnen“ aus: Seine Ausgabe muss gleich bleiben.
1. Die Zustände der Bestellung. Die Bestellung geht neu → bezahlt → versandt und kann storniert werden, aber nicht nach dem Versand. Mach es mit State (oder mit einem Enum und einer Übergangstabelle, wenn die Zustände kein eigenes Verhalten haben) und zeige den Fehler beim Stornieren einer versandten Bestellung.
2. Ein Rabatt über 100 €. Eine Bestellung über 100 € bekommt 5 % Rabatt, egal wie bezahlt wird. Überlege, wohin er gehört: in eine Strategy, in einen Decorator um die Strategy oder in den Shop. Warum?
3. Eine Speicherung, die sich erinnert. Mach eine zweite Speicherung, die die Bestellungen in eine Datei schreibt (eine pro Zeile), und reiche sie dem Shop statt der im Speicher. Die Shop-Klasse ändert sich nicht.
4. Ein fremder Kurierdienst. Das Lager wird jetzt über die Bibliothek eines Kurierdienstes mit der Methode createShipment(reference, weightGrams) aufgerufen. Der Lager-Abonnent wird ein Adapter dazu.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Decorator · Command · State · Repository
- Aufgaben zum Thema: Entwurfsmuster