1. Wiele listenerów: kolejność wywołania
Gdy tylko side effects przeniosły się z serwisu use-case do listenerów, problem się przesunął. Teraz nie wystarczy tylko zdecydować, co w ogóle wynosić do zdarzenia: na jedno i to samo OrderCreatedEvent może odpowiadać kilka handlerów, a bez jawnych reguł ten fragment szybko staje się nieprzewidywalny.
Gdy projekt jest mały, łatwo wierzyć, że „no listenerów jest dwa, jakoś tam". Ale gdy tylko pojawia się audyt, powiadomienia i statystyki (a to prawie każdy realny scenariusz biznesowy, nawet w dydaktycznym ContextFlow), jedno zdarzenie zaczyna rozlatywać się na kilka reakcji. I jeśli nie ustawiasz reguł, w zespole pojawia się ulubiona gra: „zgadnij, kto wykona się pierwszy".
Problem nie w tym, że Spring jest „zły" i nie potrafi kolejności. Problem w tym, że bez jawnych reguł kolejność nie musi być oczywista. Może zależeć od tego, jak kontener rejestrował beany, jak zmieniałeś pakiety, jak zmieniałeś nazwy klas, a nawet od tego, jak refaktoryzowałeś konfigurację. Czasem „niby działa", ale to dokładnie ten przypadek, gdy stabilność to przypadek, a przypadek — zły fundament.
Wyobraźmy sobie znajomy przepływ w ContextFlow. Serwis opublikował OrderCreatedEvent, a dalej chcemy trzy reakcje: zapisać audyt, zaktualizować statystyki, wysłać powiadomienie. Schematycznie wygląda to tak:
flowchart TD
A["OrderPlacementService.publishEvent(OrderCreatedEvent)"] --> B[AuditOrderEventsListener]
A --> C[StatisticsOrderEventsListener]
A --> D[NotificationOrderEventsListener]
Jeśli reakcje są niezależne, w zasadzie jest ci obojętne, kto z nich pierwszy. Ale gdy tylko pojawia się choć jeden „delikatny" moment — na przykład chcesz, żeby audyt zapisał się przed wysłaniem powiadomienia (żeby w logach było widać, że w ogóle zaczęliśmy przetwarzanie), kolejność staje się narzędziem przewidywalności.
2. @Order: kolejność wywołania listenerów
@Order to mała adnotacja z dużym efektem psychologicznym. Pozwala powiedzieć Springowi: „jeśli wywołujesz kilka handlerów jednego zdarzenia, proszę, zrób to w takiej kolejności". Ważne właśnie słowo „względna": @Order(1) nie znaczy „pierwszy we wszechświecie", lecz „wcześniej niż @Order(2)".
W Springu reguła jest prosta i zapamiętuje się prawie jak „na wychowaniu fizycznym": mniejsza liczba biegnie pierwsza. Czyli @Order(1) wykonuje się przed @Order(10). Jeśli @Order nie ma — kolejność nie powinna być uważana za stabilną.
Oto minimalny przykład dla ContextFlow: audyt chcemy wykonywać jako pierwszy, żeby „utrwalał fakt reakcji" przed innymi efektami.
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // rejestrujemy listener jako Spring-bean
public class AuditOrderEventsListener {
@Order(1) // im mniejsza liczba — tym wcześniej zostanie wywołany listener
@EventListener // zapisujemy się na zdarzenie OrderCreatedEvent
public void onCreated(OrderCreatedEvent event) {
// audyt: utrwalamy fakt przetwarzania przed wszelkimi zewnętrznymi efektami
System.out.println("AUDIT: created " + event.orderId()); // AUDIT: created O-101
}
}
Dla statystyk dajemy następny priorytet. Niech będzie drugi: audyt już się zapisał, teraz można aktualizować liczniki.
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // listener ze statystykami
public class StatisticsOrderEventsListener {
@Order(2) // zostanie wywołany po @Order(1)
@EventListener // słuchamy tego samego zdarzenia
public void onCreated(OrderCreatedEvent event) {
// tu mogłyby być aktualizowane metryki/liczniki
System.out.println("STATS: +1 created"); // STATS: +1 created
}
}
I powiadomienia — trzecie. Logika nie jest „jedynie słuszna", ale często wygodna: audyt i statystyki — wewnętrzna kuchnia, powiadomienie — zewnętrzny szum. Niech zewnętrzny szum idzie po wewnętrznym utrwaleniu.
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component // listener dla „zewnętrznego efektu" (powiadomienia)
public class NotificationOrderEventsListener {
@Order(3) // wysyłamy powiadomienia po audycie i statystykach
@EventListener
public void onCreated(OrderCreatedEvent event) {
// zazwyczaj tu będzie integracja: email/sms/kolejka itp.
System.out.println("NOTIFY: " + event.channel()); // NOTIFY: EMAIL
}
}
Żeby to nie wyglądało jak „magiczne liczby", wygodnie trzymać w głowie małą tabelę sensów. Nie prawo, a wygoda czytania:
| @Order | Co to znaczy w głowie | Typowy sens w zdarzeniach |
|---|---|---|
| 1 | „najpierw utrwalić" | audyt, krytyczna diagnostyka |
| 2 | „zaktualizować wewnętrzne agregaty" | statystyki, liczniki, cache |
| 3 | „wykonać zewnętrzne efekty" | powiadomienia, wysyłki, wyjście |
Teraz ważny metodyczny moment. @Order to nie mechanizm budowania procesu biznesowego. Jeśli zaczniesz myśleć „a zróbmy 12 kroków scenariusza przez 12 listenerów i ułóżmy je przez @Order", to gratulacje: właśnie wymyśliłeś „ukryty silnik workflow", który jest niewygodny do czytania, trudny do testowania i prawie niemożliwy do wyjaśnienia nowicjuszowi (łącznie z tobą za trzy miesiące).
3. condition w @EventListener: filtrowanie zdarzeń
Czasem problem nie leży w kolejności, lecz w tym, że listenerów jest za dużo i część z nich powinna się wyzwalać tylko w określonych okolicznościach. Najprostszy przykład z ContextFlow — kanał powiadomienia. Jeśli zdarzenie zawiera channel = NotificationChannel.EMAIL, chcemy wysłać e-mail, a jeśli NotificationChannel.SMS — SMS. I chce się, żeby było to widoczne bezpośrednio z deklaracji listenera, a nie ukryte w if wewnątrz metody.
Do tego @EventListener ma parametr condition. To wyrażenie SpEL (Spring Expression Language), które jest obliczane przed wywołaniem metody. Jeśli warunek jest fałszywy, metoda po prostu nie jest wywoływana. W sensie to jak „deklaratywny if", tylko w adnotacji.
Mamy channel jako NotificationChannel, więc w warunku wygodniej porównywać name() enuma. Sam obiekt zdarzenia przez to nie zamienia się z powrotem w worek stringowy.
Przykład: osobny listener, który reaguje na tworzenie zamówienia tylko jeśli kanał to EMAIL.
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // osobny handler tylko dla kanału email
public class EmailOrderEventsListener {
@EventListener(condition = "#event.channel.name() == 'EMAIL'") // filtr SpEL przed wywołaniem metody
public void onCreated(OrderCreatedEvent event) {
// tu trafiamy tylko jeśli channel == EMAIL
System.out.println("EMAIL: " + event.orderId()); // EMAIL: O-101
}
}
Zwróć uwagę na „zmienną" #event. W condition Spring daje dostęp do obiektu zdarzenia, i możemy czytać jego pola, jakbyśmy pisali zwykły kod. Jeśli twoja metoda przyjmuje parametr OrderCreatedEvent event, to wyrażenie #event.channel wygląda prawie jak Java — i to dobry znak: warunek powinien być krótki i czytelny.
Można też zrobić warunek na zaprzeczenie. Na przykład zdecydowałeś, że kanał „konsolowy" to rzecz deweloperska, i do statystyk takich zdarzeń nie liczymy (bo inaczej uruchomienia deweloperskie psują liczby).
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // statystyki tylko dla „bojowych" zdarzeń
public class ProductionLikeStatisticsListener {
@EventListener(condition = "#event.channel.name() != 'CONSOLE'") // odrzucamy kanał dev/lokalny
public void onCreated(OrderCreatedEvent event) {
// ważne: ten kod nie wykona się dla channel == CONSOLE
System.out.println("STATS: counted " + event.orderId()); // STATS: counted O-101
}
}
A warunek może być też nie tylko „równe/nierówne". Na przykład czasem wygodnie jest filtrować zdarzenia demo/testowe po formacie id. To nie najlepszy projekt dla produkcji, ale w projekcie dydaktycznym może pomóc zrobić zachowanie naocznym.
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // audyt tylko dla zamówień demo
public class DemoOnlyAuditListener {
@EventListener(condition = "#event.orderId.startsWith('DEMO-')") // filtr po prefiksie id
public void onCreated(OrderCreatedEvent event) {
// trafiamy tu tylko jeśli orderId zaczyna się od "DEMO-"
System.out.println("DEMO AUDIT " + event.orderId()); // DEMO AUDIT DEMO-1
}
}
Tu ważne ograniczenie: jeśli czujesz, że warunek w condition staje się dłuższy niż kilka prostych sprawdzeń, to prawie zawsze sygnał, że próbujesz wpychać logikę biznesową „w string". Lepiej wrócić do normalnego kodu Java: albo zrobić różne typy zdarzeń, albo wynieść decyzję do serwisu, albo przegrupować listenery.
4. Praktyka: @Order + condition
Staranna dyscyplina zamiast „event soup"
Gdy łączysz @Order i condition, bardzo łatwo dostać dwie skrajności. Pierwsza — „nie ma reguł, wszystko jest przypadkowe". Druga — „mamy 28 listenerów, każdy z condition na 4 linie, a kolejność jak rozkład pociągów". Potrzebujemy złotego środka: dokładnie tyle mechaniki, żeby przepływ był przewidywalny i czytelny.
Wyobraźmy sobie praktyczny i „żywy" wariant dla ContextFlow. Audyt powinien dziać się zawsze i jako pierwszy. Statystyki — zawsze i jako drugi. Powiadomienia — trzecie, przy czym powiadomienia są różne: e-mail i SMS.
Od razu zaznaczę: poniższy podział na Email/Sms listeners — to gałąź demonstracyjna, żeby condition było widoczne jak najwyraźniej. W głównym baseline ContextFlow na ten dzień powiadomienia wciąż wygodnie trzymać w jednym NotificationOrderEventsListener, który wewnątrz deleguje do NotificationDispatchService.
Oto jak może to wyglądać przy łączeniu:
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class SmsOrderEventsListener {
@Order(3)
@EventListener(condition = "#event.channel.name() == 'SMS'")
public void onCreated(OrderCreatedEvent event) {
System.out.println("SMS: " + event.orderId()); // SMS: O-101
}
}
I symetryczny handler dla e-mail (ta sama kolejność, to samo zdarzenie, ale inny warunek):
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Component
public class EmailOrderEventsListener {
@Order(3)
@EventListener(condition = "#event.channel.name() == 'EMAIL'")
public void onCreated(OrderCreatedEvent event) {
System.out.println("EMAIL: " + event.orderId()); // EMAIL: O-101
}
}
Tu jest subtelność, którą warto omówić. Jeśli oba listenery mają ten sam @Order(3), to kolejność między nimi może nie być ustalona. Ale ponieważ warunki są wzajemnie wykluczające się (kanał nie może być jednocześnie EMAIL i SMS), kolejność między nimi nam nie jest potrzebna. Najważniejsze — że idą po audycie i statystykach.
Wychodzi dość czytelny obraz: najpierw utrwalamy, potem liczymy, potem powiadamiamy. Przy tym „gałąź powiadomień" jest wybierana naturalnie przez warunek, nie zamieniając głównego serwisu use-case w „dyspozytora całego świata".
Mini-inkrement w ContextFlow
Bardzo chce się, żeby @Order odbierany był jako „no tak, działa, po prostu wierz". Ale nowicjuszowi (i szczerze mówiąc nie tylko nowicjuszowi) przydatne jest raz zobaczyć to w wyjściu konsolowego uruchomienia. Zrobimy mały obserwowalny przykład: serwis publikuje zdarzenie, a handlery drukują linijki.
Niech będzie takie wywołanie w scenariuszu (uproszczone, bez szczegółów domeny):
import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class DemoOrderPublisher {
private final ApplicationEventPublisher publisher;
public DemoOrderPublisher(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void publishCreated(String orderId, NotificationChannel channel) {
publisher.publishEvent(new OrderCreatedEvent(orderId, channel));
}
}
I gdzieś w ScenarioRunner (lub tymczasowo w demo-scenariuszu) wywołujesz:
demoOrderPublisher.publishCreated("O-101", NotificationChannel.EMAIL);
Jeśli masz listenery z poprzednich przykładów z @Order(1), @Order(2) i @Order(3), wyjście będzie iść w tej kolejności. Na przykład w najprostszym wariancie:
AUDIT: created O-101
STATS: +1 created
EMAIL: O-101
To ważne odczucie: nie „masz nadzieję na kolejność", lecz ją deklarujesz. I gdy za tydzień ktoś doda nowy listener, będzie musiał albo też jawnie oznaczyć kolejność, albo zaakceptować, że jego handler nie ma wymagań co do pozycji w łańcuchu.
5. Typowe błędy przy pracy z @Order i condition
Błąd nr 1: poleganie na „przypadkowej kolejności", bo „i tak działa na moim komputerze".
Jest to szczególnie podstępne, gdy projekt jest jeszcze mały: wydaje się, że audyt zawsze drukuje się pierwszy, bo „tak wyszło". Ale po refaktoryzacji pakietów, dodaniu nowego beana lub przebudowie konfiguracji kolejność może się zmienić. Jeśli kolejność jest naprawdę ważna — trzeba ją zadeklarować przez @Order.
Błąd nr 2: pomylenie kierunku kolejności (myślenie, że 10 jest przed 1).
Ten błąd pojawia się częściej niż chce się przyznać. W Springu mniejsza liczba to wyższy priorytet. Jeśli masz @Order(10) na audycie i @Order(1) na powiadomieniu, to powiadomienie będzie przed audytem, i długo będziesz szukać „dlaczego logi wyglądają dziwnie", choć odpowiedź jest w jednej cyfrze.
Błąd nr 3: próba budowania głównego procesu biznesowego przez @Order.
Gdy scenariusz „stworzyć zamówienie" rozlewa się na dziesiątek listenerów, powiązanie między krokami staje się niejawne. Tak, może to działać, ale czytanie jest trudne: główny serwis use-case zamienia się w „publikatora zdarzenia", a logika jest rozrzucona po handlerach. @Order powinien pomagać porządkować kilka niezależnych side effects, a nie zastępować normalny projekt scenariusza.
Błąd nr 4: zamienianie condition w pełnoprawny program w SpEL.
Krótki warunek w stylu #event.channel.name() == 'EMAIL' czyta się normalnie. A wyrażenie na pół ekranu, które oblicza coś sprytnego — to prawie zawsze „zły zapach". W takiej sytuacji lepiej przenieść logikę do kodu Java: albo zrobić osobny listener i osobny typ zdarzenia, albo wynieść decyzję do serwisu, a condition zostawić jako prosty filtr.
Błąd nr 5: zapomnienie, że condition wykonuje się przed wywołaniem metody, i „przypadkowy null" może zepsuć wyrażenie.
Jeśli piszesz #event.channel.name().startsWith('E'), a channel nagle okazuje się null, to wyrażenie może paść jeszcze przed twoim kodem. Dlatego albo trzymaj dane zdarzenia prawidłowe i niepuste, albo pisz warunki tak, żeby były odporne (np. najpierw sprawdzaj null), ale nie zamieniaj tego w spaghetti.
GO TO FULL VERSION