CodeGym /Kursy /Spring Core /Listenery: @Order i condition

Listenery: @Order i condition

Spring Core
Poziom 18, Lekcja 2
Dostępny

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.

1
Zadanie
Spring Core, poziom 18, lekcja 2
Niedostępne
Wyraźna kolejność trzech listenerów
Wyraźna kolejność trzech listenerów
1
Zadanie
Spring Core, poziom 18, lekcja 2
Niedostępne
Filtracja listenerów według kanału przez condition
Filtracja listenerów według kanału przez condition
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION