1. Synchroniczne zdarzenia: zwykłe wywołanie
Kolejność listenerów i ich condition są przydatne tylko jeśli rozumiesz, jak w ogóle wykonuje się publikacja zdarzenia. Inaczej bardzo łatwo zdecydować, że handlery żyją „gdzieś obok", a publishEvent() po prostu wysyła wiadomość do abstrakcyjnej wszechświat.
Jeśli kiedykolwiek pisałeś notifications.send(...), intuicyjnie rozumiesz: metoda się wywołała, wewnątrz coś się stało, może rzucił się wyjątek, i sterowanie wróciło z powrotem. Ze zdarzeniami w Springu domyślnie ta sama logika, tylko „warstwą pośrednią" jest kontener: wywołujesz publisher.publishEvent(event), i dalej Spring w tym samym wątku wywołuje wszystkie pasujące listenery.
To jest ważne, bo gdy tylko słyszymy słowo „event", mózg zaczyna rysować obraz Kafki/RabbitMQ, kolejek, workerów w tle i „no tam gdzieś się kiedyś przetworzy". W naszym kursie i w naszym ContextFlow — nie. To zdarzenia wewnątrz jednego procesu, i domyślnie wykonują się synchronicznie: dopóki handlery się nie zakończą, publishEvent() nie „puści" twojego kodu dalej.
Ta wiedza od razu sprawia, że zachowanie jest przewidywalne. Jeśli po publishEvent() piszesz System.out.println("done"), to „done" pojawi się po zapisaniu audytu, wysłaniu powiadomienia i zaktualizowaniu statystyk (jeśli zrobiłeś to przez listenery). I jeśli jakiś listener padnie — dowiesz się o tym nie „w logach kiedyś potem", lecz od razu, jak o każdym błędzie w zwykłej metodzie.
2. Synchroniczny call flow w ContextFlow
Nieco „przyziemmy" model na nasz projekt. Wyobraź sobie, że OrderPlacementService stworzył zamówienie, zapisał je i publikuje OrderCreatedEvent. W głowie chce się uważać, że zdarzenie — to „powiadomienie dla pozostałych". W praktyce zaś to część tego samego wywołania, tego samego stosu, tego samego wątku wykonania. I jeśli handlery robią coś ciężkiego, poczujesz to jako opóźnienie w placeOrder() — a nie jak „równoległą pracę".
Żeby to zobaczyć, przydatne jest zrobienie małego diagnostycznego eksperymentu: wydrukować wiadomość „przed publikacją" i „po publikacji", a w listenerach dodać swoje println. Wtedy kolejność linii w konsoli staje się twoim „zdjęciem RTG" synchroniczności.
Schemat synchronicznej publikacji
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) (ten sam wątek)"
A-->>P: return
P->>N: "onOrderCreated(event) (ten sam wątek)"
N-->>P: return
P->>T: "onOrderCreated(event) (ten sam wątek)"
T-->>P: return
P-->>S: "publishEvent() wrócił"
Tak, wygląda jak zwykły łańcuch wywołań. Bo domyślnie nim właśnie jest.
Poniżej fragment metody wewnątrz OrderPlacementService. Zwróć uwagę: after publish pojawi się dopiero po zakończeniu listenerów (w modelu synchronicznym).
import org.springframework.context.ApplicationEventPublisher;
public void place(String orderId, NotificationChannel channel) {
// Diagnostyczny marker: widać, że jesteśmy jeszcze PRZED publikacją zdarzenia
System.out.println("before publish");
// Publikacja zdarzenia: domyślnie Spring wywoła listenery synchronicznie (w tym samym wątku)
publisher.publishEvent(new OrderCreatedEvent(orderId, channel));
// Ten wydruk pojawi się dopiero PO zakończeniu wszystkich listenerów
System.out.println("after publish");
}
Teraz dodajmy dwa proste listenery (w prawdziwym projekcie będą wywoływały serwisy audytu/powiadomień, ale do zrozumienia teraz wystarczą printy).
import org.springframework.context.event.EventListener;
@EventListener
public void audit(OrderCreatedEvent event) {
// Tu zazwyczaj robi się zapis do audytu, ale do eksperymentu wystarczy konsola
System.out.println("AUDIT " + event.orderId()); // AUDIT 42
}
import org.springframework.context.event.EventListener;
@EventListener
public void stats(OrderCreatedEvent event) {
// Tu mogłyby być aktualizowane statystyki, ale teraz ważna jest tylko kolejność wywołania
System.out.println("STATS " + event.orderId()); // STATS 42
}
I oczekiwane wyjście (uproszczone):
before publish
AUDIT 42
STATS 42
after publish
To nie magia i nie „przypadkowa kolejność". To bezpośrednia konsekwencja modelu synchronicznego: publishEvent() wrócił dopiero po wykonaniu handlerów.
3. Wyjątki w listenerach
Gdy handlery są synchroniczne, pojawia się bardzo „dorosłe" pytanie: a jeśli listener padł? Kto zobaczy błąd? Czy wykona się następny listener? I co z pierwotnym use-case — „zakończył się pomyślnie" czy też padł?
Domyślnie (w naszym podstawowym modelu kursu) wszystko jest maksymalnie uczciwe: jeśli listener rzucił RuntimeException, ten błąd propaguje się w górę stosu tak samo, jak gdybyś po prostu wywołał metodę, która padła. W efekcie publishEvent() też „padnie" i twój OrderPlacementService dowie się, że coś poszło nie tak. Co więcej, jeśli jeden listener rzucił wyjątek, pozostałe handlery, które „stały za nim", najprawdopodobniej nie wykonają się — bo łańcuch wywołań się przerwie.
Brzmi straszno? W rzeczywistości jest to bardzo przydatne jako teaching default: od razu widzisz problem, od razu możesz go zdiagnozować, a zachowanie nie zamienia się w „gdzieś coś nie zadziałało, ale nie wiemy gdzie".
Zróbmy „zepsuty" handler — czysto jako laboratoryjny wirus w probówce.
import org.springframework.context.event.EventListener;
@EventListener
public void explode(OrderCreatedEvent event) {
// Symulujemy awarię w handlerze (np. padło repozytorium audytu)
throw new IllegalStateException("Audit storage is not available");
}
Teraz, jeśli explode() wywoła się podczas publishEvent(), twój place() też to odczuje jako błąd.
Poniżej jak wygląda to w wywołującym kodzie.
try {
// Publikujemy zdarzenie: w modelu synchronicznym wyjątek z listenera trafi bezpośrednio tu
publisher.publishEvent(new OrderCreatedEvent("42", NotificationChannel.EMAIL));
// Ta linijka wykona się tylko jeśli żaden listener nie padł
System.out.println("place() success"); // place() success
} catch (RuntimeException ex) {
// Tu trafimy, jeśli jakiś listener rzucił RuntimeException
System.out.println("place() failed: " + ex.getMessage());
// place() failed: Audit storage is not available
}
Ważny sens tu nie jest nawet w try/catch. Sens w tym, że model synchroniczny sprawia, że błędy są obserwowalne tam, gdzie publikujesz zdarzenie. To właśnie „cena" zdarzeń: zyskałeś dekuplację zależności, ale wciąż żyjesz w jednym procesie i w jednym call flow.
W ContextFlow podpowiada to dyscyplinę projektowania. Jeśli audyt i powiadomienia dla twojego scenariusza są obowiązkową częścią „pomyślnego tworzenia zamówienia", to synchroniczność jest nawet przydatna: jeśli powiadomienie nie zostało wysłane, uczciwie uważasz scenariusz za problematyczny. Jeśli zaś statystyki — „byłoby fajnie mieć", to zmuszanie całego scenariusza do padania z powodu statystyk może być dziwne. I właśnie tu pojawia się pokusa asynchroniczności — ale najpierw rozumiemy, co tracimy, zanim radośnie naciśniemy przycisk „przyspieszyć".
4. Synchroniczność jako teaching default
Synchroniczne listenery są często nazywane „nudnymi", bo nie dają odczucia równoległości i „prawdziwego świata event-driven". Ale właśnie ta „nuda" sprawia, że są idealną dydaktyczną i inżynierską bazą: przepływ wykonania czyta się bezpośrednio z kodu i z logu. Jeśli placeOrder() opublikował zdarzenie i zawiesił się — znaczy zawiesił się jakiś listener, a nie „gdzieś tam w tle coś się nie przetworzyło".
Dla naszego ContextFlow (non-web, napędzana konsolą aplikacja) synchroniczność jest też praktycznie wygodna. W aplikacjach konsolowych często jest logika „uruchomiliśmy scenariusz — wydrukowaliśmy wynik — zakończyliśmy aplikację". Jeśli handler jest asynchroniczny, łatwo dostać sytuację, gdy program już zamyka kontekst, a zadania w tle jeszcze nie zdążyły zakończyć pracy. W efekcie część efektów ubocznych może zginąć, lub dostajesz „dziwne" błędy przy zakończeniu. Przy synchroniczności takich niespodzianek jest mniej: jeśli publishEvent() wrócił, znaczy wszystko się wykonało.
Poza tym synchroniczność dobrze przyjaźni się z naszymi narzędziami przewidywalności z poprzedniego wykładu. @Order w modelu synchronicznym naprawdę oznacza kolejność wywołania handlerów w jednym wątku. Daje to stabilny log i reprodukowalne zachowanie. W modelu asynchronicznym @Order zaczyna znaczyć znacznie mniej, bo kolejność „zadania wystartowały" jeszcze nie gwarantuje kolejności „zakończyły się", a czasem ważny jest właśnie wynik.
I jeszcze jedna praktyczna zaleta: w modelu synchronicznym wyjątki nie „chowają się". Jeśli listener padł — zobaczysz to bezpośrednio w scenariuszu i będziesz mógł zdecydować, jak z tym żyć. W modelu asynchronicznym błąd może uciec do osobnego wątku, a scenariusz będzie wyglądał „pomyślnie", choć część reakcji faktycznie nie zadziałała. Czasem to normalne, ale jako teaching default to prawie gwarantowane źródło zdezorientowania.
5. Preview: co zmienia się w async listenerach
Teraz — obiecany preview. Od razu zaznaczamy: nie robimy asynchroniczności „głównym stylem" i nie konfigurujemy jej szczegółowo. Chodzi nam o coś innego: zrozumienie, co zmienia się w modelu, gdy handler przestaje wykonywać się w tym samym wywołaniu.
W modelu synchronicznym mamy jeden wątek, jeden stos wywołań, jedną linię zdarzeń: „serwis opublikował — handlery zadziałały — serwis kontynuował". W modelu asynchronicznym publishEvent() uruchamia przetwarzanie „z boku", a wywołujący kod idzie dalej, jakby handlerów nie było. To przypomina „postawiliśmy zadanie w kolejce i nie czekamy na wynik".
Stąd od razu wyłaniają się trzy duże zmiany: kolejność, błędy i zakończenie aplikacji.
Porównanie sync vs async
| Pytanie | Synchroniczny listener (domyślnie) | Asynchroniczny listener (preview) |
|---|---|---|
| Czy publikacja czeka na zakończenie handlerów? | Tak | Nie |
| Czy błąd listenera jest widoczny dla wywołującego kodu? | Tak, zazwyczaj bezpośrednio | Zazwyczaj nie, błąd żyje w tle |
| Czy kolejność zakończenia jest przewidywalna? | Tak | Nie, zakończenie zadań może być w różnej kolejności |
| Czy jest ryzyko niezakończenia przed shutdown? | Praktycznie nie | Tak, jeśli aplikacja szybko się zamknie |
Schemat: asynchroniczny 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() wrócił od razu"
Note over S: serwis kontynuuje pracę
E->>T: "onOrderCreated(event) (w innym wątku)"
T-->>E: "return/exception"
Zwróć uwagę: „exception" na końcu diagramu nie propaguje się już z powrotem do S. Czyli z perspektywy OrderPlacementService „wszystko ok", a z perspektywy listenera „wszystko padło". Właśnie dlatego async to nie kosmetyka, lecz zmiana modelu.
6. @Async i @EventListener: forma i reguły
W Springu jednym ze sposobów, żeby powiedzieć „wykonuj handler nie w tym samym wątku" — oznaczyć metodę @Async. Na potrzeby dzisiejszego wykładu ważne jest właśnie to: zobaczyć formę deklaracji i zrozumieć konsekwencje. Konfiguracji asynchronicznego wykonania (włączanie wsparcia, wybór executora, polityka obsługi błędów) celowo nie omawiamy szczegółowo, bo inaczej wykład zamieniłby się w mini-kurs wielowątkowości i infrastruktury.
Poniżej typowa forma „asynchronicznego listenera". Taki handler dobrze nadaje się do drugorzędnych reakcji jak statystyki, gdy jesteś gotowy zaakceptować, że statystyki mogą zaktualizować się nieco później, a w razie błędu — nie niszczyć głównego scenariusza.
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
@Async // Prosimy Springa o wykonanie metody w innym wątku (przez executor), a nie w call flow publishEvent()
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
// Do demonstracji drukujemy do konsoli: może się to zdarzyć JUŻ PO powrocie publishEvent()
System.out.println("STATS async " + event.orderId()); // STATS async 42
}
Ważne proponować dwa niuanse, które ratują początkujących od „magicznych oczekiwań".
Po pierwsze, @Async sam w sobie — to marker zamiaru. Żeby naprawdę zadziałał, w aplikacji musi być włączone wsparcie dla asynchronicznego wykonania i musi istnieć executor, który będzie wykonywał zadania. W „czystym Springu" to osobna konfiguracja infrastruktury, i teraz jej nie rozwijamy, bo celem wykładu jest model zachowania, a nie konfig.
Po drugie, nawet gdy async jest włączone, w aplikacji konsolowej trzeba pamiętać o cyklu życia: jeśli publikujesz zdarzenie i od razu zamykasz aplikację, „tło" może po prostu nie zdążyć wykonać pracy. W aplikacji webowej to częściej się wygładza, bo proces żyje długo, a w ContextFlow scenariusz może być krótki. Dlatego jeśli robisz reakcję naprawdę ważną, ale jednocześnie asynchroniczną, potrzebujesz bardziej poważnego modelu zakończenia aplikacji i oczekiwania na zadania w tle — a to już zupełnie inny temat.
W efekcie reguła zdrowego rozsądku dla naszego kursu brzmi tak: synchroniczność — to bazowy model, bo jest przewidywalny i obserwowalny. Asynchroniczność — to świadomy wybór, gdy jesteś gotowy „puścić" handler i zaakceptować nowe ryzyko: inną kolejność, inne błędy i inne gwarancje zakończenia.
7. Typowe błędy przy pracy ze zdarzeniami
Nawet jeśli wszystko prawidłowo zrozumiałeś konceptualnie, w prawdziwym kodzie błędy najczęściej rodzą się nie z powodu „nie znać adnotacji", lecz z powodu błędnych oczekiwań co do zachowania programu. Ze zdarzeniami i asynchronicznością jest to szczególnie widoczne: wydaje się, że zmieniłeś jeden mały szczegół, a w rzeczywistości zmieniłeś model wykonania i obserwowalności. Poniżej najczęstsze grabie, na które stąpają nowicjusze.
Błąd nr 1: myślenie, że @EventListener automatycznie sprawia, że przetwarzanie jest asynchroniczne.
Adnotacja @EventListener zmienia tylko formę deklaracji listenera: zamiast interfejsu piszesz metodę. Domyślnie wykonanie pozostaje synchroniczne. Jeśli czekasz „fire-and-forget", a dostajesz opóźnienie w publishEvent(), to nie „Spring jest powolny" — po prostu miałeś błędne oczekiwania co do modelu.
Błąd nr 2: uważanie, że asynchroniczność to „przyspieszenie bez konsekwencji".
W modelu synchronicznym błędy listenera uczciwie padają z powrotem do publishEvent(). W modelu asynchronicznym błąd często przestaje być widoczny dla wywołującego kodu, a kolejność wykonania staje się mniej przewidywalna. To nie dobre ani złe — to inny model. Ale jeśli nie uwzględniasz tego w logice scenariusza, dostaniesz „cicho się zepsuło".
Błąd nr 3: przenoszenie krytycznego kroku scenariusza do listenera (zwłaszcza asynchronicznego).
Jeśli bez kroku scenariusz nie można uznać za wykonany (np. „zamówienie musi zostać zapisane"), to powinno to pozostać bezpośrednim wywołaniem w serwisie use-case. Gdy krytyczny krok ucieka do listenera, tracisz jasność i kontrolę: scenariusz „zakończył się", ale może faktycznie jeszcze się nie zakończył, zwłaszcza jeśli listener jest asynchroniczny.
Błąd nr 4: oczekiwanie, że przy padnięciu jednego listenera pozostałe i tak zadziałają.
W bazowym synchronicznym modelu, który używamy, wyjątek w listenerze może przerwać przetwarzanie i zatrzymać wykonanie kolejnych handlerów. To logiczne: łańcuch wywołań jest przerywany. Jeśli ważne jest dla ciebie, żeby „pozostałe i tak zadziałały", to znaczy, że chcesz innej polityki obsługi błędów — a więc trzeba ją jawnie zaprojektować i skonfigurować, a nie mieć nadzieję, że „jakoś samo".
Błąd nr 5: włączyć async i zapomnieć, że aplikacja konsolowa może zakończyć się przed handlerami.
W ContextFlow łatwo zrobić scenariusz „stworzono zamówienie — wydrukowano do konsoli — zamknięto kontekst". Jeśli handler stał się asynchroniczny, jego praca może nie zdążyć wykonać się przed zamknięciem. W efekcie „niby wszystko się uruchamiało", ale część side effects czasem ginie. Dla nowicjusza wygląda to jak „losowy bug", a w rzeczywistości to naturalne zachowanie modelu.
GO TO FULL VERSION