CodeGym /Kursy /Spring Core /Gdzie przydają się zdarzenia w scenariuszach

Gdzie przydają się zdarzenia w scenariuszach

Spring Core
Poziom 17, Lekcja 0
Dostępny

1. Serwis scenariusza: dyrygent i kombajn

Jeśli spojrzeć na ContextFlow od strony architektury, nasze OrderPlacementService i OrderCancellationService to nie „serwisy w ogóle", lecz serwisy scenariuszy. Dyrygują procesem: zbierają dane, zmieniają stan, wywołują potrzebne zależności. I to normalne. Problem zaczyna się, gdy dyrygent nagle postanawia sam grać na wszystkich instrumentach orkiestry — i jeszcze sprzedawać bilety przy wejściu.

W prawdziwej aplikacji serwis scenariusza prawie zawsze startuje „czysto": robi jedną-dwie rzeczy, wygląda schludnie i nawet napawa optymizmem. Ale potem pojawia się audyt (i jest „absolutnie konieczny"), następnie powiadomienia („a zróbmy od razu SMS i email"), potem statystyki („potrzebujemy raport na dashboard"), a potem kolejny efekt uboczny („no to szybko, po prostu wywołaj metodę"). I już serwis scenariusza wie za dużo i zależy od połowy aplikacji.

Zobaczmy typowy obrazek „przed zdarzeniami" — gdy uczciwie wywołujemy wszystko bezpośrednio.

Wyobraźmy sobie, że scenariusz tworzenia zamówienia wygląda tak (uproszczone, bez szczegółów domeny):

public void placeOrder(CreateOrderCommand cmd) {
    // 1) Tworzymy model dziedziny zamówienia z wejściowej komendy
    Order order = orderFactory.create(cmd);

    // 2) Zapisujemy zamówienie — to część rdzenia scenariusza (bez tego faktu „zamówienie utworzone" nie ma)
    orderStore.save(order);

    // 3) Dalej idą reakcje na już zaistniały fakt (nie rdzeń scenariusza)
    auditService.recordCreated(order.getId());
    notificationDispatchService.sendCreated(order.getId());
}

Z punktu widzenia wyniku biznesowego zrobiliśmy „wszystko prawidłowo": zamówienie zapisane, audyt zapisany, powiadomienie wysłane. Ale architektonicznie tu jest już ukryty problem: OrderPlacementService wie, że po utworzeniu zamówienia obowiązkowo trzeba wywołać audyt i powiadomienia, czyli jest bezpośrednio powiązany z konkretnymi serwisami i ich kontraktami. Każde nowe „po utworzeniu zamówienia jeszcze trzeba…" zmusi do modyfikacji właśnie tego serwisu.

2. Główny scenariusz i reakcje

Żeby zrozumieć, gdzie zdarzenie jest odpowiednie, najpierw trzeba uzgodnić terminy. Inaczej będziemy się spierać w stylu „a audyt — to ważne czy poboczne?" i tak czy owak zrobimy jak w tasku, ale z poczuciem winy. W tym wykładzie interesuje nas prosta i bardzo praktyczna granica: co jest rdzeniem scenariusza, a co jest reakcją na już zaistniały fakt.

Pod „głównym scenariuszem" rozumiemy kroki, bez których fakt biznesowy nie uznaje się za zaistniały. Dla tworzenia zamówienia wygląda to zwykle tak: zebraliśmy zamówienie, przypisaliśmy mu id, obliczyliśmy kwotę, zapisaliśmy. Można się spierać o kolejność i szczegóły, ale sens jeden: jeśli zamówienie nie jest zapisane i nie przeszło do oczekiwanego stanu, za wcześnie mówić „zamówienie utworzone".

Pod „akcją poboczną" rozumiemy reakcję na fakt, który już zaistniał. Na przykład „zamówienie utworzone" — fakt, a „zapisz audyt", „powiadom klienta", „zwiększ licznik statystyk" — reakcje. I tu ważny niuans: akcja poboczna niekoniecznie jest „nieważna". Audyt może być krytycznie ważny biznesowo, ale wciąż może być reakcją na fakt utworzenia zamówienia, a nie częścią mechaniki „utwórz zamówienie jako obiekt".

Żeby było łatwiej to trzymać w głowie, warto raz zobaczyć porównanie w tabeli.

Pytanie Główny scenariusz Akcja poboczna (reakcja)
O czym to? „Sprawić, żeby fakt zaistniał" „Zareagować na fakt"
Kto powinien o tym wiedzieć? Serwis scenariusza (dyrygent) Dowolne osobne „reaktory": audyt, powiadomienia, statystyki
Co będzie, jeśli tego nie zrobić? Fakt się nie wydarzył Fakt się wydarzył, ale system nie zareagował w pełni
Co zazwyczaj dzieje się przy rozszerzeniu wymagań? Rzadko się zmienia, bo rdzeń jest stabilniejszy Rośnie cały czas („jeszcze jedno powiadomienie", „jeszcze jeden raport")

W ContextFlow naturalne fakty, wokół których wszystko się kręci, to „zamówienie utworzone" i „zamówienie anulowane". Właśnie takie fakty to najlepsi kandydaci na zdarzenia. Nie „zaczęliśmy tworzyć zamówienie", nie „ustawiliśmy status NEW", nie „obliczyliśmy kwotę" (choć w złożonych domenach i tak bywa), lecz właśnie zrozumiałe rzeczy, które brzmią jak ukończone twierdzenia biznesowe.

3. Bezpośrednie wywołania zwiększają powiązania

Wróćmy teraz do naszego „kombajnu" i sformułujmy, co w nim zaczyna boleć. Ważne: to nie „estetyka" i nie „czysta architektura dla lajków". Ból tu jest praktyczny: takie serwisy są trudne do zmiany, trudne do testowania i trudne do rozszerzania bez przypadkowych efektów ubocznych. A jeszcze rosną zależność za zależnością, jak drzewo zależności rośnie nowe gałęzie — i pewnego dnia nie możesz prześledzić, gdzie tam pień.

Zobaczmy bardziej realistyczny fragment: serwis scenariusza wie o magazynie zamówień, o cenach, o audycie, o powiadomieniach, i (w pewnym momencie) o statystykach.

public void placeOrder(CreateOrderCommand cmd) {
    // Rdzeń scenariusza: złóż zamówienie, oblicz kwotę, zapisz
    Order order = orderFactory.create(cmd);
    Money total = pricingService.calculateTotal(order);

    // Aktualizujemy model zamówienia obliczoną kwotą
    order = order.withTotal(total);

    // Zapis — część definicji faktu „zamówienie utworzone"
    orderStore.save(order);

    // Reakcje na fakt: nie zmieniają samego wyniku „zamówienie utworzone", a uzupełniają system
    auditService.recordCreated(order.getId());
    notificationDispatchService.sendCreated(order.getId());
    statisticsService.onOrderCreated(order.getId());
}

Na poziomie „działa" — świetnie. Ale na poziomie „architektura" mamy trzy typowe konsekwencje.

Pierwsza konsekwencja — efekt kuli śnieżnej. Każda nowa reakcja („a zróbmy też zapis do pliku", „a zróbmy też do raportu", „a zróbmy też zlokalizowany komunikat") prowadzi do nowej zależności i nowego wywołania w tej metodzie. I w pewnym momencie metoda zamienia się w serial: 12 sezonów, 300 odcinków, a ty nie pamiętasz, kto komu jest bratem.

Druga konsekwencja — sztywne powiązania: serwis scenariusza zależy od konkretnych reakcji. Nawet jeśli audyt i powiadomienia są ładnymi serwisami, sam fakt „kto reaguje" jest zasztywniony w scenariuszu. Serwis scenariusza staje się miejscem, gdzie „schodzą się wszystkie kable".

Trzecia konsekwencja — trudność testowania. Żeby przetestować „tworzenie zamówienia zapisuje zamówienie", musisz albo podnosić mnóstwo zależności, albo mockować audyt, powiadomienia i statystyki, choć test w ogóle nie jest o tym. W efekcie testy stają się kruche: zmieniłeś reakcję — zepsuły się testy tworzenia zamówień.

4. Bezpośrednie wywołanie: uczciwe i proste

W tym momencie u nowicjuszy często rodzi się pokusa: „O, to wszystko róbmy przez zdarzenia!" I to świetny sposób, żeby zamienić jeden problem na inny, tylko bardziej modny. Bezpośrednie wywołanie to nie zło, lecz normalny inżynierski instrument. Jest uczciwe, przejrzyste i dobrze się czyta, gdy zależność faktycznie uczestniczy w głównym wyniku scenariusza.

Bezpośrednie wywołanie jest szczególnie zasadne, gdy serwis scenariusza nie może zakończyć pracy bez wyniku zależności. Na przykład bez OrderStore zamówienie nie zostanie zapisane. Bez generowania id zamówienie nie stanie się prawidłowym obiektem. Bez obliczenia ceny nie będziesz mógł utworzyć końcowej kwoty. To wszystko elementy rdzenia scenariusza — nie chce się ich chować za zdarzeniami, bo wtedy sam scenariusz stałby się „niejawny".

W ContextFlow rdzeń scenariusza tworzenia zamówienia może wyglądać mniej więcej tak:

public Order placeOrder(CreateOrderCommand cmd) {
    // Stworzenie obiektu dziedziny — część scenariusza
    Order order = orderFactory.create(cmd);

    // Zapis — część definicji faktu „zamówienie utworzone"
    orderStore.save(order);

    // Zwracamy wynik scenariusza do wywołującego kodu
    return order;
}

Tu można się spierać, gdzie obliczać cenę i kiedy przypisywać id, ale kluczowy moment jest prosty: orderStore.save(order) to nie reakcja, to sedno scenariusza. Jeśli „zapis zamówienia" wyniesiesz do reakcji na zdarzenie, scenariusz zamieni się w „niby stworzyliśmy zamówienie, ale naprawdę jeszcze nie, dopóki ktoś tam gdzieś czegoś nie zrobi". I to już architektoniczny mrok, który wygląda pięknie tylko w prezentacji.

W efekcie przydatna reguła brzmi nudno, ale działa: jeśli krok jest częścią definicji faktu („zamówienie utworzone"), niech zostaje bezpośrednim wywołaniem. Jeśli krok jest reakcją na fakt („po utworzeniu zamówienia zrób jeszcze vot to"), to kandydat do rozsprzęgnięcia.

5. Zdarzenie: jeden fakt i reakcje

Teraz przechodzimy do miejsca, dla którego w ogóle planuje się zdarzenia. Są przydatne tam, gdzie jeden fakt powinien wywołać kilka reakcji, a serwis scenariusza nie powinien wiedzieć ani o liście tych reakcji, ani o ich kolejności, ani ile ich w ogóle jest. Serwis scenariusza ma zrobić swoje i ogłosić: „Fakt zaistniał". A dalej niech system reaguje.

Po ludzku przypomina to ogłoszenie w biurze: „Koledzy, zamówienie zostało utworzone". Ktoś usłyszał i poszedł zapisać audyt. Ktoś usłyszał i poszedł wysłać powiadomienie. Ktoś usłyszał i zaktualizował statystyki. Przy tym osoba, która stworzyła zamówienie, nie biega po gabinetach z krzykiem „Piotr, zapisz audyt! Kasia, wyślij SMS! Marek, policz statystyki!". Zrobiła działanie i zakomunikowała fakt.

Jeśli zostajemy w świecie bezpośrednich wywołań, to „ogłoszenie" zazwyczaj wygląda jak łańcuch wywołań:

// Rdzeń scenariusza
orderStore.save(order);

// Reakcje (akcje poboczne) — ich lista i kolejność zaszyta bezpośrednio w scenariuszu
auditService.recordCreated(order.getId());
notificationDispatchService.sendCreated(order.getId());
statisticsService.onOrderCreated(order.getId());

Na razie nie wchodzimy w API Spring i trzymamy tylko samą ideę:

// Rdzeń scenariusza: zamówienie musi być tutaj zapisane, wprost i jawnie
orderStore.save(order);

// Ogłaszamy fakt „zamówienie utworzone" raz (dalej będą reakcje)
orderEvents.orderCreated(order.getId());

A dalej orderEvents to nie „serwis, który wszystko robi", lecz właśnie mechanizm dostarczania faktu do tych, którzy są zapisani do reagowania.

Mini-pomost: korzyść ze zdarzeń bez Spring API

Żeby zobaczyć, czego dokładnie chcemy, można na chwilę wyobrazić sobie „półzdarzeniowy" wariant, który nie wymaga żadnej magii: po prostu wstrzykujemy listę reakcji (umiemy to z czasów tematów o wstrzykiwaniu kolekcji).

// Spring może wstrzyknąć listę wszystkich beanów implementujących OrderCreatedReaction
private final List<OrderCreatedReaction> reactions;

public void placeOrder(CreateOrderCommand cmd) {
    // Rdzeń scenariusza
    Order order = orderFactory.create(cmd);
    orderStore.save(order);

    // Reakcje: serwis scenariusza nie wie, ile ich jest i jakie dokładnie to klasy
    for (OrderCreatedReaction r : reactions) {
        // Przekazujemy na zewnątrz tylko „fakt" (minimalne dane potrzebne do reakcji)
        r.react(order.getId());
    }
}

To jeszcze nie są zdarzenia Spring, ale już widać, do czego celujemy: OrderPlacementService nie wie już, jakie dokładnie reakcje istnieją. Wie tylko, że po utworzeniu zamówienia trzeba przekazać fakt na zewnątrz, a dalej tę dekuplację może przejąć standardowy mechanizm zdarzeń kontenera.

Granice zdarzeń aplikacji

Bardzo ważne jest teraz utrzymanie granic, bo inaczej słowo „events" natychmiast wywołuje w głowie obraz brokerów, kolejek, mikroserwisów i wiecznej konsystentności. W naszym kursie chodzi o zdarzenia aplikacji wewnątrz jednego ApplicationContext, czyli wewnątrz jednego procesu, jednej aplikacji. To nie system rozproszony. To sposób zorganizowania komunikacji między beanami tak, żeby scenariusz nie zależał od szczegółów reakcji pobocznych.

Dlatego nie należy oczekiwać od zdarzeń aplikacji tego, czego nie obiecały. Nie zamieniają aplikacji automatycznie w asynchroniczną. Nie dają gwarancji dostarczenia „jak kolejka". Nie robią „event sourcing" (i nie trzeba, chcielibyśmy najpierw stworzyć zamówienie). Po prostu pozwalają powiedzieć: „wewnątrz aplikacji zaistniał fakt" i dają innym częściom aplikacji możliwość zareagowania, bez zaszycia tych reakcji w scenariuszu.

W praktyce zmienia to odczucie związane z kodem. Serwis scenariusza staje się krótszy, ma mniej zależności, a dodanie nowej reakcji to „dodaj nowy handler", a nie „zaglądaj do scenariusza i ostrożnie wstawiaj kolejne wywołanie, żeby czegoś nie zepsuć". I właśnie to — realna inżynierska korzyść, a nie „zrobiliśmy modną architekturę".

Jak nie zrobić event soup

Zdarzenia mają drugą stronę: bardzo łatwo je pokochać za mocno. To jak przyprawy: szczypta polepsza danie, pół paczki zamienia wszystko w ostre nieporozumienie. Główne niebezpieczeństwo modelu zdarzeniowego wewnątrz aplikacji — utrata przejrzystości: akcja nie dzieje się „tu i teraz", lecz „gdzieś tam", i nowicjusz otwiera projekt, widzi jedno placeOrder(), i nie rozumie, dlaczego przy tworzeniu zamówienia tworzony jest też plik raportu i drukowane komunikaty do konsoli.

Żeby tak się nie stało, przydatna jest prosta dyscyplina. Zdarzenie to znaczący fakt, a nie „każde kichnięcie wewnątrz metody". Jeśli zaczniesz publikować „OrderValidatedEvent", „OrderPriceCalculatedEvent", „OrderSavedEvent", „OrderAuditPlannedEvent" i „OrderNotificationPreparedEvent", dostaniesz system, gdzie główna logika biznesowa rozlazła się na dziesiątek handlerów, a złożenie łańcucha w głowie jest niemożliwe bez debuggera i mocnej kawy.

Jeszcze jeden częsty błąd — robienie ze zdarzenia komendy. Gdy zdarzenie nazywa się jak rozkaz („SendEmailNowEvent"), to prawie zawsze sygnał, że wymieszałeś „fakt" i „działanie". Dobre zdarzenie — w czasie przeszłym: „zamówienie zostało utworzone", „zamówienie zostało anulowane". Złe zdarzenie — w trybie rozkazującym: „wyślij", „zapisz", „oblicz".

I ostatnie: zdarzenia nie są usprawiedliwieniem dla ukrywania ważnych kroków scenariusza. Jeśli krok krytycznie definiuje wynik („zamówienie musi zostać zapisane"), nie wynoś go do zdarzenia tylko dlatego, że „tak modniej". Niech scenariusz pozostaje uczciwy i czytelny, a zdarzenia obsługują reakcje.

6. Typowe błędy: wywołanie vs zdarzenie

Błąd nr 1: próba robienia zdarzenia z każdego kroku metody.
Nowicjuszowi wydaje się, że „zdarzenia = architektoniczna piękność", i zaczyna publikować zdarzenia na każdą mikrokomendę: „stworzono obiekt", „obliczono cenę", „zapisano". W efekcie główny scenariusz przestaje być scenariuszem i zamienia się w zestaw sygnałów. Na ten kurs trzymamy prostej idei: publikujemy zdarzenia tylko na poziomie zrozumiałych faktów biznesowych, na przykład „zamówienie utworzone" i „zamówienie anulowane".

Błąd nr 2: przenoszenie do zdarzenia kroku, bez którego scenariusz nie może być uznany za zakończony.
Czasem próbuje się „odciążyć" OrderPlacementService i jedzie się za daleko: zapis zamówienia zaczyna być „gdzieś w handlerze zdarzenia". Wtedy wywołanie placeOrder() nie gwarantuje już, że zamówienie jest naprawdę zapisane. Powstaje architektoniczne bagno: metoda się zakończyła, ale fakt jeszcze nie zaistniał. Jeśli krok definiuje sam fakt, niech zostaje bezpośrednim wywołaniem.

Błąd nr 3: odbieranie zdarzenia jako zewnętrznej kolejki komunikatów.
Po słowach „events" u wielu włącza się tryb „myślenia Kafką": jakby publikacja zdarzenia oznaczała „wysłaliśmy w tło". W naszym kursie to wewnętrzny mechanizm jednej aplikacji, a nie rozproszony transport. Będziemy jeszcze mówić o zachowaniu w runtime, ale już teraz ważne jest, żeby nie budować oczekiwań, które nie dotyczą zdarzeń aplikacji.

Błąd nr 4: zdarzenie nazywa się jak komenda, a nie jak fakt.
Nazwy w stylu SendEmailEvent brzmią jak „zrób działanie", a nie „zaistniał fakt". W efekcie handlery zaczynają zależeć od komendy, a publisher faktycznie nimi steruje przez zdarzenie. Prawidłowiej nazywać zdarzenia „w czasie przeszłym": OrderCreated..., OrderCancelled.... Wtedy zdarzenie opisuje rzeczywistość, a nie wydaje rozkaz.

Błąd nr 5: serwis scenariusza zaczyna „wiedzieć" o listenerach.
Jeśli już zdecydowałeś się na zdarzenia, ale wciąż zostawiłeś w OrderPlacementService bezpośrednie wywołania „na wszelki wypadek", to dekuplacja nie działa. Masz hybrydę: część reakcji schowana w listenerach, część — w serwisie, i nowy deweloper będzie ich szukał po całym projekcie. Albo reakcja jest częścią rdzenia (wtedy bezpośrednie wywołanie), albo reakcja jest subskrybentem (wtedy zdarzenie). Mieszać można tylko świadomie i rzadko.

1
Zadanie
Spring Core, poziom 17, lekcja 0
Niedostępne
Bezpośrednie wywołania w serwisie aktywacji subskrypcji
Bezpośrednie wywołania w serwisie aktywacji subskrypcji
1
Zadanie
Spring Core, poziom 17, lekcja 0
Niedostępne
Jeden fakt biznesowy i kilka reakcji przez listę strategii
Jeden fakt biznesowy i kilka reakcji przez listę strategii
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION