1. Synchrone Ereignisse: Ein gewöhnlicher Aufruf
Die Reihenfolge der Listener und ihre condition sind nur dann nützlich, wenn Sie verstehen, wie die Ereignisveröffentlichung überhaupt ausgeführt wird. Sonst ist es sehr leicht, anzunehmen, die Handler lebten irgendwo „daneben“, und publishEvent() sende einfach eine Nachricht in ein abstraktes Universum.
Wenn Sie jemals notifications.send(...) geschrieben haben, verstehen Sie intuitiv: Die Methode wurde aufgerufen, etwas geschah darin, möglicherweise trat eine Ausnahme auf und die Kontrolle kehrte zurück. Bei Ereignissen in Spring gilt standardmäßig dieselbe Logik, nur dass der Container als „Zwischenschicht“ fungiert: Sie rufen publisher.publishEvent(event) auf, und dann ruft Spring im selben Thread alle passenden Listener auf.
Das ist wichtig, denn sobald wir das Wort „Event“ hören, malt unser Gehirn ein Bild von Kafka/RabbitMQ, Warteschlangen, Hintergrundarbeitern und „das wird schon irgendwo verarbeitet“. In unserem Kurs und in unserem ContextFlow ist das nicht der Fall. Es sind Ereignisse innerhalb eines einzigen Prozesses, und standardmäßig werden sie synchron ausgeführt: Solange die Handler nicht abgeschlossen sind, gibt publishEvent() die Kontrolle nicht an Ihren Code zurück.
Dieses Wissen macht das Verhalten sofort vorhersagbar. Wenn Sie nach publishEvent() System.out.println("done") schreiben, erscheint „done“ nachdem das Audit geschrieben, die Benachrichtigung gesendet und die Statistik aktualisiert wurde (falls Sie dies über Listener getan haben). Und wenn ein Listener abstürzt, erfahren Sie das nicht erst „irgendwann später in den Logs“, sondern sofort, wie bei jedem Fehler in einer normalen Methode.
2. Synchroner Call Flow in ContextFlow
Betrachten wir das Modell etwas konkreter für unser Projekt. Stellen Sie sich vor, der OrderPlacementService hat eine Bestellung erstellt, gespeichert und veröffentlicht nun ein OrderCreatedEvent. Instinktiv möchte man das Ereignis als „Benachrichtigung an die anderen“ betrachten. In der Praxis ist es jedoch Teil desselben Aufrufs, desselben Stacks, desselben Ausführungsfadens. Wenn die Handler etwas Schweres tun, werden Sie das als Verzögerung in placeOrder() spüren – nicht als „parallele Arbeit“.
Um dies zu sehen, ist es hilfreich, ein kleines diagnostisches Experiment durchzuführen: Geben Sie eine Meldung „vor der Veröffentlichung“ und eine „nach der Veröffentlichung“ aus, und fügen Sie in den Listenern eigene println ein. Dann wird die Reihenfolge der Zeilen in der Konsole zu Ihrem „Röntgenbild “ der Synchronizität.
Schema der synchronen Veröffentlichung
sequenceDiagram
participant S as OrderPlacementService
participant P as ApplicationEventPublisher
participant A as AuditOrderEventsListener
participant N as NotificationOrderEventsListener
participant T as StatisticsOrderEventsListener
S->>P: "publishEvent(OrderCreatedEvent)"
P->>A: "onOrderCreated(event) (derselbe Thread)"
A-->>P: return
P->>N: "onOrderCreated(event) (derselbe Thread)"
N-->>P: return
P->>T: "onOrderCreated(event) (derselbe Thread)"
T-->>P: return
P-->>S: "publishEvent() kehrte zurück"
Ja, es sieht aus wie eine gewöhnliche Aufrufkette. Denn standardmäßig ist es genau das.
Unten sehen Sie einen Ausschnitt der Methode innerhalb des OrderPlacementService. Beachten Sie: after publish erscheint erst, nachdem die Listener ihre Arbeit beendet haben (im synchronen Modell).
import org.springframework.context.ApplicationEventPublisher;
public void place(String orderId, NotificationChannel channel) {
// Diagnostischer Marker: Wir sehen, dass wir noch VOR der Ereignisveröffentlichung sind
System.out.println("before publish");
// Ereignisveröffentlichung: Standardmäßig ruft Spring die Listener synchron auf (im selben Thread)
publisher.publishEvent(new OrderCreatedEvent(orderId, channel));
// Diese Ausgabe erscheint erst NACH Abschluss aller Listener
System.out.println("after publish");
}
Jetzt fügen wir zwei einfache Listener hinzu (im echten Projekt würden sie Audit-/Benachrichtigungsdienste aufrufen, aber für das Verständnis reichen zunächst Ausgaben).
import org.springframework.context.event.EventListener;
@EventListener
public void audit(OrderCreatedEvent event) {
// Normalerweise würde hier ein Audit-Eintrag erfolgen, aber für das Experiment reicht die Konsole
System.out.println("AUDIT " + event.orderId()); // AUDIT 42
}
import org.springframework.context.event.EventListener;
@EventListener
public void stats(OrderCreatedEvent event) {
// Hier könnte die Statistik aktualisiert werden, jetzt ist uns nur die Aufrufreihenfolge wichtig
System.out.println("STATS " + event.orderId()); // STATS 42
}
Und hier ist die erwartete Ausgabe (vereinfacht):
before publish
AUDIT 42
STATS 42
after publish
Das ist weder Magie noch eine „zufällige“ Reihenfolge. Es ist eine direkte Folge des synchronen Modells: publishEvent() kehrte erst nach Ausführung der Handler zurück.
3. Ausnahmen in Listenern
Wenn die Handler synchron sind, taucht eine sehr „erwachsene“ Frage auf: Was passiert, wenn ein Listener ausfällt? Wer sieht den Fehler? Wird der nächste Listener noch ausgeführt? Und was passiert mit dem ursprünglichen Use Case – wird er „erfolgreich beendet“ oder schlägt er ebenfalls fehl?
Standardmäßig (in unserem grundlegenden Kursmodell) ist alles maximal ehrlich: Wenn ein Listener eine RuntimeException wirft, steigt dieser Fehler den Stack hinauf, so als hätten Sie einfach eine Methode aufgerufen und diese wäre fehlgeschlagen. In der Folge wird auch publishEvent() „fallen“ und Ihr OrderPlacementService erfährt, dass etwas schiefgelaufen ist. Wenn darüber hinaus ein Listener eine Ausnahme auslöst, werden die „späteren“ Handler höchstwahrscheinlich nicht ausgeführt, da die Aufrufkette abbricht.
Klingt beängstigend? Eigentlich ist es sehr nützlich als Teaching Default: Sie sehen das Problem sofort, können es sofort diagnostizieren, und das Verhalten wird nicht zu „irgendwo hat etwas nicht funktioniert, aber wir wissen nicht wo“.
Erstellen wir einen „kaputten“ Handler – rein als Laborvirus im Reagenzglas.
import org.springframework.context.event.EventListener;
@EventListener
public void explode(OrderCreatedEvent event) {
// Simuliert einen Ausfall im Handler (z. B. das Audit-Speicher ist nicht verfügbar)
throw new IllegalStateException("Audit storage is not available");
}
Wenn nun explode() während publishEvent() aufgerufen wird, wird auch Ihr place() dies als Fehler spüren.
Unten sehen Sie, wie das im aufrufenden Code aussieht.
try {
// Ereignis veröffentlichen: Im synchronen Modell fliegt die Ausnahme des Listeners direkt hierher
publisher.publishEvent(new OrderCreatedEvent("42", NotificationChannel.EMAIL));
// Diese Zeile wird nur ausgeführt, wenn kein Listener ausfällt
System.out.println("place() success"); // place() success
} catch (RuntimeException ex) {
// Hier landen wir, wenn ein Listener eine RuntimeException geworfen hat
System.out.println("place() failed: " + ex.getMessage());
// place() failed: Audit storage is not available
}
Der wichtige Punkt hier ist nicht einmal das try/catch. Der Punkt ist, dass das synchrone Modell Fehler beobachtbar macht, und zwar dort, wo Sie das Ereignis veröffentlichen. Das ist der „Preis“ der Ereignisse: Sie entkoppeln die Abhängigkeiten, leben aber immer noch in einem Prozess und in einem einzigen Call Flow.
In ContextFlow deutet dies auf eine Entwurfsdisziplin hin. Wenn Audit und Benachrichtigungen für Ihr Szenario ein obligatorischer Teil der „erfolgreichen Bestellerstellung“ sind, ist Synchronizität sogar nützlich: Wenn die Benachrichtigung nicht gesendet wurde, betrachten Sie das Szenario ehrlich als problematisch. Wenn die Statistik jedoch nur „schön zu haben“ ist, könnte es seltsam sein, das gesamte Szenario wegen der Statistik scheitern zu lassen. Und genau hier entsteht die Verlockung der Asynchronität – aber lassen Sie uns zuerst verstehen, was wir verlieren, bevor wir freudig auf den „Beschleunigen“-Knopf drücken.
4. Synchronizität als Teaching Default
Synchrone Listener werden oft als „langweilig“ bezeichnet, weil sie kein Gefühl von Parallelität und einer „wahren Event-Driven-Welt“ vermitteln. Aber genau diese „Langeweile“ macht sie zur idealen Lehr- und Ingenieursbasis: Der Ausführungsfluss ist direkt im Code und im Log lesbar. Wenn placeOrder() ein Ereignis veröffentlicht hat und hängt, dann hängt ein Listener – und nicht „irgendwo im Hintergrund wurde etwas nicht verarbeitet“.
Für unseren ContextFlow (Nicht-Web, konsolengetriebene Anwendung) ist Synchronizität auch praktisch nützlich. Konsolenanwendungen haben oft eine Logik: „Szenario gestartet – Ergebnis ausgedruckt – Anwendung beendet“. Wenn ein Handler asynchron ist, kann leicht die Situation eintreten, dass das Programm den Kontext bereits schließt, während Hintergrundaufgaben noch nicht fertig sind. Dadurch können Nebeneffekte verloren gehen oder es treten „seltsame“ Fehler beim Beenden auf. Mit Synchronizität gibt es weniger solche Überraschungen: Wenn publishEvent() zurückgekehrt ist, ist alles ausgeführt.
Darüber hinaus harmoniert Synchronizität gut mit unseren Werkzeugen der Vorhersagbarkeit aus der letzten Vorlesung. @Order bedeutet im synchronen Modell tatsächlich die Aufrufreihenfolge der Handler in einem Thread. Das ergibt ein stabiles Log und reproduzierbares Verhalten. Im asynchronen Modell bedeutet @Order viel weniger, denn die Reihenfolge „Aufgaben gestartet“ garantiert noch nicht die Reihenfolge „sie sind beendet“, und manchmal ist genau das Ergebnis wichtig.
Und ein weiterer praktischer Vorteil: Im synchronen Modell verstecken sich Ausnahmen nicht. Wenn ein Listener ausfällt, sehen Sie das direkt im Szenario und können entscheiden, wie Sie damit umgehen. Im asynchronen Modell kann der Fehler in einen anderen Thread entweichen, und das Szenario erscheint „erfolgreich“, obwohl einige Reaktionen tatsächlich nicht ausgeführt wurden. Manchmal ist das in Ordnung, aber als Teaching Default ist es eine fast garantierte Verwirrungsquelle.
5. Preview: Was ändert sich bei async Listenern
Nun der versprochene Preview. Vorab: Wir machen Asynchronität nicht zum „Hauptstil“ und konfigurieren sie nicht im Detail. Uns geht es um etwas anderes: zu verstehen, was sich im Modell ändert, wenn der Handler nicht mehr im selben Aufruf ausgeführt wird.
Im synchronen Modell haben wir einen Thread, einen Aufrufstack, eine Ereignislinie: „Service veröffentlicht – Handler arbeiten – Service fährt fort“. Im asynchronen Modell startet publishEvent() die Verarbeitung „nebenher“, und der aufrufende Code geht weiter, als ob es die Handler gar nicht gäbe. Das ist, als ob man eine Aufgabe in eine Warteschlange stellt und nicht auf das Ergebnis wartet.
Daraus ergeben sich sofort drei große Änderungen: Reihenfolge, Fehler und Beendigung der Anwendung.
Vergleich sync vs. async
| Frage | Synchroner Listener (Standard) | Asynchroner Listener (Preview) |
|---|---|---|
| Wartet die Veröffentlichung auf den Abschluss der Handler? | Ja | Nein |
| Ist der Fehler des Listeners für den aufrufenden Code sichtbar? | Ja, in der Regel direkt | In der Regel nein, der Fehler lebt im Hintergrund |
| Ist die Reihenfolge des Abschlusses vorhersagbar? | Ja | Nein, Aufgaben können in unterschiedlicher Reihenfolge beendet werden |
| Besteht das Risiko, vor dem Herunterfahren nicht fertig zu werden? | Praktisch nicht | Ja, wenn die Anwendung schnell beendet wird |
Schema: Asynchroner Listener
sequenceDiagram
participant S as OrderPlacementService
participant P as ApplicationEventPublisher
participant E as "Executor/ThreadPool"
participant T as Async Statistics Listener
S->>P: "publishEvent(event)"
P->>E: "submit(task: listener(event))"
P-->>S: "publishEvent() sofort zurück"
Note over S: Service arbeitet weiter
E->>T: "onOrderCreated(event) (in einem anderen Thread)"
T-->>E: "return/exception"
Beachten Sie: „exception“ am Ende des Diagramms steigt nicht zurück zu S. Aus Sicht des OrderPlacementService ist also „alles in Ordnung“, aus Sicht des Listeners „alles abgestürzt“. Deshalb ist async keine Kosmetik, sondern ein Modellwechsel.
6. @Async und @EventListener: Form und Regeln
Eine Möglichkeit in Spring zu sagen „führe den Handler nicht im selben Thread aus“ ist die Markierung der Methode mit @Async. Für die heutige Vorlesung ist es wichtig, genau das zu sehen: die Form der Deklarierung und die Konsequenzen zu verstehen. Die Konfiguration der asynchronen Ausführung (Aktivierung der Unterstützung, Wahl des executors, Strategie zur Fehlerbehandlung) lassen wir bewusst im Detail außen vor, sonst würde die Vorlesung zu einem Minikurs über Nebenläufigkeit und Infrastruktur.
Unten sehen Sie die typische Form eines „asynchronen Listeners“. Ein solcher Handler eignet sich gut für nebensächliche Reaktionen wie Statistiken, wenn Sie bereit sind zu akzeptieren, dass die Statistik etwas später aktualisiert werden kann und der Hauptszenario im Fehlerfall nicht kaputt geht.
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
@Async // Bitte Spring, die Methode in einem anderen Thread auszuführen (via executor), nicht im Call Flow von publishEvent()
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
// Zur Demonstration geben wir auf der Konsole aus: Dies kann bereits NACH der Rückkehr von publishEvent() geschehen
System.out.println("STATS async " + event.orderId()); // STATS async 42
}
Wichtig sind zwei Nuancen, die Anfänger vor „magischen Erwartungen“ bewahren.
Erstens ist @Async an sich ein Marker für die Absicht. Damit es tatsächlich funktioniert, muss in der Anwendung die Unterstützung für asynchrone Ausführung aktiviert sein, und es muss einen Ausführer (executor) geben, der die Aufgaben ausführt. In „reinem Spring“ ist das eine separate Infrastruktureinstellung, und wir führen sie jetzt nicht ein, weil das Ziel der Vorlesung das Verhaltensmodell ist, nicht die Konfiguration.
Zweitens muss man in einer Konsolenanwendung selbst bei aktiviertem Async den Lebenszyklus bedenken: Wenn Sie ein Ereignis veröffentlichen und die Anwendung sofort beenden, kann der „Hintergrund-Listener“ seine Arbeit einfach nicht rechtzeitig beenden. In Webanwendungen wird dies oft dadurch gemildert, dass der Prozess lange lebt, aber in ContextFlow kann ein Szenario kurz sein. Wenn eine Reaktion wirklich wichtig ist, aber asynchron bleibt, benötigen Sie ein robusteres Modell zum Beenden der Anwendung und Warten auf Hintergrundaufgaben – das ist ein ganz anderes Thema.
Die Faustregel für unseren Kurs lautet daher: Synchronizität ist das Basismodell, weil es vorhersagbar und beobachtbar ist. Asynchronität ist eine bewusste Entscheidung, wenn Sie bereit sind, den Handler „loszulassen“ und neue Risiken zu akzeptieren: andere Reihenfolge, andere Fehler und andere Garantien für die Beendigung.
7. Typische Fehler bei der Arbeit mit Ereignissen
Selbst wenn Sie konzeptionell alles richtig verstanden haben, entstehen Fehler im echten Code meist nicht aus „Unkenntnis der Annotation“, sondern aus falschen Erwartungen an das Programmverhalten. Bei Ereignissen und Asynchronität ist dies besonders auffällig: Sie denken, Sie hätten ein kleines Detail geändert, dabei haben Sie das Ausführungs- und Beobachtbarkeitsmodell geändert. Im Folgenden die häufigsten Fallstricke, auf die Anfänger treten.
Fehler Nr. 1: Denken, @EventListener mache die Verarbeitung automatisch asynchron.
Die Annotation @EventListener ändert nur die Form der Listener-Deklaration: Statt eines Interfaces schreiben Sie eine Methode. Standardmäßig bleibt die Ausführung synchron. Wenn Sie ein „fire-and-forget“ erwarten, aber eine Verzögerung in publishEvent() erhalten, ist das nicht „Spring ist langsam“ – Sie haben das Modell falsch erwartet.
Fehler Nr. 2: Glauben, Asynchronität sei „Beschleunigung ohne Folgen“.
Im synchronen Modell fallen Fehler des Listeners ehrlich zurück zu publishEvent(). Im asynchronen Modell werden Fehler für den aufrufenden Code oft unsichtbar, und die Ausführungsreihenfolge wird weniger vorhersagbar. Das ist weder gut noch schlecht – es ist ein anderes Modell. Aber wenn Sie dies nicht in Ihrer Szenarielogik berücksichtigen, erhalten Sie ein „leises Versagen“.
Fehler Nr. 3: Verlagerung eines kritischen Schritts in einen Listener (insbesondere in einen async Listener).
Wenn ein Szenario ohne einen Schritt nicht als abgeschlossen betrachtet werden kann (z. B. „die Bestellung muss gespeichert sein“), sollte dieser Schritt ein direkter Aufruf im Use-Case-Service bleiben. Wenn ein kritischer Schritt in einen Listener abwandert, verlieren Sie Klarheit und Kontrolle: Das Szenario ist „beendet“, aber in Wirklichkeit könnte es noch nicht beendet sein, insbesondere wenn der Listener asynchron ist.
Fehler Nr. 4: Erwarten, dass beim Ausfall eines Listeners die anderen dennoch ausgeführt werden.
Im grundlegenden synchronen Modell, das wir verwenden, kann eine Ausnahme in einem Listener die Verarbeitung abbrechen und die Ausführung der nachfolgenden Handler stoppen. Das ist logisch: Die Aufrufkette wird unterbrochen. Wenn es Ihnen lebenswichtig ist, dass „die anderen trotzdem laufen“, bedeutet das, dass Sie eine andere Richtlinie zur Fehlerbehandlung möchten – die müssen Sie dann explizit entwerfen und konfigurieren, anstatt darauf zu hoffen, dass es „irgendwie klappt“.
Fehler Nr. 5: Async aktivieren und vergessen, dass eine Konsolenanwendung früher als die Handler beendet sein kann.
In ContextFlow ist es leicht, ein Szenario zu erstellen: „Bestellung erstellt – auf Konsole ausgeben – Kontext schließen“. Wenn der Handler asynchron geworden ist, kann seine Arbeit möglicherweise nicht vor dem Herunterfahren abgeschlossen werden. Das Ergebnis: „Es hat scheinbar alles gestartet“, aber einige Seiteneffekte verschwinden manchmal. Für Anfänger sieht das aus wie ein „zufälliger Bug“, obwohl es ein konsequentes Verhalten des Modells ist.
GO TO FULL VERSION