Observer ist leicht zu schreiben und leicht falsch zu betreiben. Diese vier Punkte kosten in der Praxis am meisten Zeit â jeder davon in etwa in dieser Reihenfolge der HĂ€ufigkeit.
1. Der Abonnent, der nie stirbt
Das ist das bekannteste Problem, und es sieht harmlos aus:
public sealed class BestandsAnzeige
{
public BestandsAnzeige(Lager lager)
{
lager.MindestbestandUnterschritten += Aktualisieren; // nur angemeldet
}
private void Aktualisieren(object? sender, BestandArgs e) { /* ... */ }
}Mit dem += hĂ€lt das Lager eine Referenz auf die Anzeige â nicht umgekehrt. Solange das Lager lebt, kann der Garbage Collector die Anzeige nicht einsammeln, auch wenn sie sonst niemand mehr braucht.
Bei einem langlebigen Sender (Singleton, statische Klasse, ein Fenster ĂŒber der ganzen Sitzung) und kurzlebigen EmpfĂ€ngern ist das ein Leck, das linear mit der Nutzung wĂ€chst. Die Handler laufen ausserdem weiter und arbeiten auf Objekten, die lĂ€ngst geschlossen sind.
Die Lösung ist unspektakulÀr: Was sich anmeldet, meldet sich wieder ab.
public sealed class BestandsAnzeige : IDisposable
{
private readonly Lager _lager;
public BestandsAnzeige(Lager lager)
{
_lager = lager;
_lager.MindestbestandUnterschritten += Aktualisieren;
}
public void Dispose() => _lager.MindestbestandUnterschritten -= Aktualisieren;
private void Aktualisieren(object? sender, BestandArgs e) { /* ... */ }
}`-=` braucht dasselbe Delegate
-= entfernt einen Handler nur, wenn er dem angemeldeten gleich ist. Eine Methodengruppe wie Aktualisieren funktioniert. Ein Lambda nicht â lager.X -= (s, e) => ... erzeugt ein neues Delegate und entfernt nichts. Wer abmelden will, muss das Lambda vorher in einer Variablen festhalten.
2. Ein Handler wirft â und die ĂŒbrigen laufen nicht mehr
Invoke ruft alle Abonnenten nacheinander in Anmeldereihenfolge auf. Wirft der zweite von vier eine Exception, laufen der dritte und vierte nie, und die Ausnahme landet beim Auslöser â also im Lager, das damit nichts zu tun hat.
Das ist genau der Fehler, den wir in Lektion 1 loswerden wollten: Er ist nur umgezogen.
protected void Melde(BestandArgs e)
{
foreach (var handler in
MindestbestandUnterschritten?.GetInvocationList()
.Cast<EventHandler<BestandArgs>>() ?? [])
{
try
{
handler(this, e);
}
catch (Exception ex)
{
_logger.LogError(ex, "Abonnent {Handler} ist gescheitert", handler.Method.Name);
}
}
}Das lohnt sich ĂŒberall dort, wo die Abonnenten voneinander unabhĂ€ngig sind â und das sind sie beim Observer-Pattern per Definition. Bei einem internen Event mit zwei Handlern aus derselben Klasse wĂ€re es dagegen ĂŒbertrieben.
3. async void im Handler
Ein EventHandler gibt void zurĂŒck. Wer in einem Handler auf etwas warten muss, landet fast automatisch hier:
lager.MindestbestandUnterschritten += async (_, e) =>
{
await _nachbestellung.BeauftrageAsync(e.Artikel); // async void
};Was daran schiefgeht
Der Aufrufer kehrt beim ersten await zurĂŒck und weiss nicht, dass noch etwas lĂ€uft. Eine Exception nach dem await landet in keinem catch, sondern auf dem Thread-Pool â und beendet in .NET den Prozess. Es gibt auch keinen Weg, auf den Abschluss zu warten.
Zwei brauchbare Auswege:
- Nur einreihen. Der Handler legt einen Auftrag in eine Queue (
Channel<T>, eine Tabelle, ein Message-Broker) und kehrt sofort zurĂŒck. Ein Hintergrunddienst arbeitet ihn ab. Das ist die Variante, die in Serveranwendungen fast immer richtig ist. - Einen eigenen Delegate-Typ verwenden, der
TaskzurĂŒckgibt, und beim Auslösen alle Handlerawaiten. Dann ist das Ereignis aber keineventmehr, sondern eine Liste, die du selbst fĂŒhrst.
4. Niemand sieht mehr, was passiert
Die letzte Falle ist keine technische. Bei einem direkten Aufruf kannst du «Gehe zu Definition» drĂŒcken. Bei einem Event steht da ?.Invoke â und wer darauf reagiert, entscheidet sich an einer ganz anderen Stelle, oft in einer Konfigurationsdatei oder einem Registrierungsblock.
Das ist der Preis der Entkopplung, und er ist real. Zwei Gegenmittel:
- Ereignisse in der Vergangenheitsform benennen:
MindestbestandUnterschritten,BestellungAbgeschlossen,ZahlungEingegangen. Ein Name wieNachbestellungAusloesenverrĂ€t, dass es in Wahrheit ein Auftrag ist â und AuftrĂ€ge ruft man direkt auf. - Die Anmeldungen zusammenhalten, an einer Stelle im Startcode, statt sie ĂŒber die Klassen zu verteilen.
Die Kurzfassung
| Falle | Woran du sie merkst | Gegenmittel |
|---|---|---|
| Leck durch Anmeldung | Speicher wÀchst, alte Objekte reagieren noch | -= in Dispose, Methodengruppe statt Lambda |
| Ein Handler wirft | SpÀtere Abonnenten laufen nicht | Handler einzeln aufrufen und fangen |
async void | Verschluckte Fehler, ProzessabbrĂŒche | Einreihen und im Hintergrund abarbeiten |
| Unsichtbarer Fluss | Niemand findet den Auslöser | Vergangenheitsform, Anmeldungen zentral |