Zum Inhalt
wannacode

Observer

Kurz gesagt: Observer lässt ein Objekt seine Abonnenten benachrichtigen, wenn etwas passiert, ohne zu wissen, wer sie sind; in C# ist das ein Event.

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
{
private readonly EmailService email = new EmailService();
private readonly SmsService sms = new SmsService();
private readonly Warehouse warehouse = new Warehouse();
private readonly InvoiceService invoices = new InvoiceService();
public void MarkPaid(Order order)
{
email.SendReceipt(order);
sms.NotifyPaid(order);
warehouse.Reserve(order);
invoices.Issue(order);
}
}

Jeder Dienst ist eine kleine Klasse, die ausgibt, was sie getan hat:

var payments = new Payments();
payments.MarkPaid(new Order(1042, 80m));
Ausgabe
E-Mail: Beleg für #1042
SMS: Bestellung #1042 ist bezahlt
Lager: reserviere #1042
Rechnung: 80.00 € für #1042

Das 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
{
public void NotifyPaid(Order order)
{
throw new InvalidOperationException("Das SMS-Gateway antwortet nicht");
}
}
var payments = new Payments();
try
{
payments.MarkPaid(new Order(1042, 80m));
}
catch (InvalidOperationException e)
{
Console.WriteLine("Zahlung fehlgeschlagen: " + e.Message);
}
Ausgabe
E-Mail: Beleg für #1042
Zahlung fehlgeschlagen: Das SMS-Gateway antwortet nicht

Das 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 haben die Zahlungen ein Event (event): eine in die Sprache eingebaute Liste von Abonnenten. Jeder Abonnent hat dieselbe Form, Action<Order>: eine Funktion, die die Bestellung nimmt. Wer es wissen will, meldet sich von außen mit += an. Bei einer Zahlung löst das Objekt das Event aus, und es ruft jeden Abonnenten 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

Die Zahlungen haben das Event Paid. Paid?.Invoke(order) ruft jeden Abonnenten auf. Ohne Abonnenten ist das Event null, daher das ?.:
record Order(int Id, decimal Total);
class Payments
{
public event Action<Order> Paid;
public void MarkPaid(Order order)
{
Paid?.Invoke(order);
}
}
Jeder Abonnent ist ein Lambda vom Typ Action<Order>:
Action<Order> email = order => Console.WriteLine($"E-Mail: Beleg für #{order.Id}");
Action<Order> sms = order => Console.WriteLine($"SMS: Bestellung #{order.Id} ist bezahlt");
Action<Order> warehouse = order => Console.WriteLine($"Lager: reserviere #{order.Id}");
Action<Order> loyalty = order => Console.WriteLine($"Punkte: +{(int)order.Total} für #{order.Id}");

Drei Abonnenten, eine Zahlung. Dann kommen die Treuepunkte, und der Kunde schaltet die SMS ab:

var payments = new Payments();
payments.Paid += email;
payments.Paid += sms;
payments.Paid += warehouse;
payments.MarkPaid(new Order(1042, 80m));
payments.Paid += loyalty;
payments.Paid -= sms;
payments.MarkPaid(new Order(1043, 25m));
Ausgabe
E-Mail: Beleg für #1042
SMS: Bestellung #1042 ist bezahlt
Lager: reserviere #1042
E-Mail: Beleg für #1043
Lager: reserviere #1043
Punkte: +25 für #1043

var payments = new Payments();
payments.Paid += email;
payments.Paid += sms;
payments.Paid += warehouse;
payments.MarkPaid(new Order(1042, 80m));
payments.Paid += loyalty;
payments.Paid -= sms;
payments.MarkPaid(new Order(1043, 25m));
Schritt für Schritt, in Worten
  1. Das Ereignis Paid hat noch keinen einzigen Abonnenten.
  2. email abonniert mit +=: Es kommt in das Ereignis Paid.
  3. sms und warehouse auch. Payments weiß nicht, wer sie sind, nur dass sie eine Bestellung annehmen.
  4. Bestellung #1042 ist bezahlt. Die Benachrichtigung geht an jeden Abonnenten, in der Reihenfolge der Anmeldung.
  5. Einer nach dem anderen, im selben Aufruf: Beleg, SMS, Reservierung. Jeder Abonnent macht seine Arbeit.
  6. Die Treuepunkte kommen: loyalty abonniert von außen. In Payments ändert sich keine einzige Zeile.
  7. Der Kunde schaltet die SMS ab: sms meldet sich ab und verlässt die Liste.
  8. 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
  • Von außen kann man das Event nur abonnieren (+=) und abbestellen (-=). Nur die Klasse, der es gehört, kann es auslösen oder alle Abonnenten löschen. Bei einem einfachen Feld vom Typ Action<Order> könnte jeder payments.Paid = null schreiben.
  • Du meldest dasselbe Lambda ab, das du angemeldet hast, also steht es in einer Variablen (sms). -= mit einem neuen Lambda, auch mit demselben Code, entfernt nichts.
  • In den .NET-Bibliotheken haben Events den Typ EventHandler<T>: Der Abonnent bekommt auch, wer das Event ausgelöst hat (sender). Du siehst es in WPF und in den Web-Frameworks. Die Idee ist dieselbe.
  • Die Abonnenten werden nacheinander im selben Aufruf von MarkPaid aufgerufen. 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:

Action<Order> sms = order => throw new InvalidOperationException("Das SMS-Gateway antwortet nicht");
var payments = new Payments();
payments.Paid += email;
payments.Paid += sms;
payments.Paid += warehouse;
try
{
payments.MarkPaid(new Order(1042, 80m));
}
catch (InvalidOperationException e)
{
Console.WriteLine("Zahlung fehlgeschlagen: " + e.Message);
}
Ausgabe
E-Mail: Beleg für #1042
Zahlung fehlgeschlagen: Das SMS-Gateway antwortet nicht

Die 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:

public void MarkPaid(Order order)
{
if (Paid == null)
{
return;
}
foreach (Action<Order> listener in Paid.GetInvocationList())
{
try
{
listener(order);
}
catch (Exception e)
{
Console.WriteLine("Ein Abonnent ist fehlgeschlagen: " + e.Message);
}
}
}

Mit demselben Programm:

var payments = new Payments();
payments.Paid += email;
payments.Paid += sms;
payments.Paid += warehouse;
try
{
payments.MarkPaid(new Order(1042, 80m));
}
catch (InvalidOperationException e)
{
Console.WriteLine("Zahlung fehlgeschlagen: " + e.Message);
}
Ausgabe
E-Mail: Beleg für #1042
Ein Abonnent ist fehlgeschlagen: Das SMS-Gateway antwortet nicht
Lager: reserviere #1042

Die 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.

Paid?.Invoke(order) ruft die Abonnenten nacheinander auf und stoppt bei der ersten Exception. GetInvocationList() gibt dir die Abonnenten einzeln, 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?

var payments = new Payments();
payments.Paid += email;
payments.Paid += warehouse;
payments.Paid += email;
payments.MarkPaid(new Order(7, 10m));
payments.Paid -= warehouse;
payments.MarkPaid(new Order(8, 10m));
Antwort

Probier es aus

Finde den Fehler

Ein nächtlicher Job markiert die Bestellungen aus dem Kontoauszug als bezahlt. E-Mails oder SMS braucht es dafür nicht, also meldet sich niemand an:
var payments = new Payments();
payments.MarkPaid(new Order(1042, 80m));
Console.WriteLine("Der Kontoauszug ist verarbeitet");
Fehler
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.

In welcher Zeile steckt der Fehler?

Wähle die Zeile mit dem Fehler.

Genauer, für Neugierige

Ein Abonnent, der sich während der Benachrichtigung abmeldet, ist in C# sicher. Das Delegate des Events ändert sich nicht: -= erzeugt eine neue Liste, und die laufende Benachrichtigung geht über die alte weiter:

var payments = new Payments();
Action<Order> coupon = null;
coupon = order =>
{
Console.WriteLine($"Willkommensgutschein: #{order.Id}");
payments.Paid -= coupon;
};
payments.Paid += coupon;
payments.Paid += email;
payments.MarkPaid(new Order(1042, 80m));
payments.MarkPaid(new Order(1043, 25m));
Ausgabe
Willkommensgutschein: #1042
E-Mail: Beleg für #1042
E-Mail: Beleg für #1043

Mit einer eigenen List<Action<Order>> und foreach wirft dasselbe InvalidOperationException (Collection was modified). In Java ist es ConcurrentModificationException, und in Python wird der Abonnent nach dem entfernten ohne Fehler übersprungen.

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: ein Lambda invoice vom Typ Action<Order>, 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.

Ausgabe
E-Mail: Beleg für #1042
Lager: reserviere #1042
Rechnung: 80.00 € für #1042

Weitere 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. Prüfe, dass es mit event ohne Änderung funktioniert, und probiere dann eine eigene List<Action<Order>> und sieh dir den Fehler an.
  • Zwei Ereignisse. Füge auch Erstattungen hinzu. Ein zweites Event Refunded und eine Methode MarkRefunded. E-Mail hört auf beide, das Lager nur auf die Zahlung.

Test

Teste dich

1. Der Shop führt Treuepunkte bei jeder Zahlung ein. Was schreibst du mit Observer?
2. Der SMS-Abonnent wirft eine Exception, und die Benachrichtigung ist nicht geschützt. Was passiert?
3. Derselbe Abonnent ist zweimal angemeldet. Wie oft erfährt er von einer Zahlung?
4. Die Rechnung muss ausgestellt werden, sonst zählt die Zahlung nicht. Passt Observer hier?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·