Ostatni wpis przybliżył nam ideę stojącą za eksperymentalnym projektem Springa Modulith. Dowiedzieliśmy się w nim w jaki sposób Modulith pilnuje modularności naszego monolitu. Natomiast dzisiaj chciałbym abyśmy wykorzystali go do zmniejszania couplingu pomiędzy stworzonymi modułami. Wcześniej wywoływaliśmy funkcje biznesowe modułów w sposób bezpośredni, poprzez metody. Teraz spróbujemy zmienić to podejście. Wykorzystamy do tego eventy.

Klasyczne podejście z wywołaniem metod

Tak dla przypomnienia. Najprostszym rozwiązaniem jest po prostu skorzystanie z bezpośredniego wywołania metody dostępnej w danym komponencie. Wystarczy dodać go do zależności klasy i wykorzystać jedną z dostępnych opcji jego API. Ma to wiele zalet. Główną z nich jest to, że mając rozpiętą transakcję bazodanową wykonane operacje zapiszą się w całości albo wcale. Nie musimy martwić się o spójność końcową (eventual consistency), ponieważ ona zawsze będzie zachowana.

Bezpośrednie wywołanie metody pomiędzy komponentami w jednej aplikacji
Bezpośrednie wywołanie metody pomiędzy komponentami w jednej aplikacji

Natomiast gdybyśmy chcieli rozproszyć jeden z modułów jako osobny serwis to wtedy ta zaleta przestaje obowiązywać. Wyobraźmy sobie następującą sytuację. Posiadamy proces biznesowy, który musi dokonać zmiany w dwóch rozproszonych modułach. Załóżmy, że jeden z nich polega na wyniku otrzymanym od drugiego modułu. W chwili, gdy uzyska niezbędne informacje to dopiero wtedy wykona zapis. Co się jednak stanie w sytuacji, gdy w tym momencie zostanie rzucony wyjątek? Nasz proces finalnie nie będzie spójny.

Bezpośrednie wywołanie metody pomiędzy komponentami w dwóch aplikacjach
Bezpośrednie wywołanie metody pomiędzy komponentami w dwóch aplikacjach

Należałoby więc ponownie uruchomić cały proces. I tu pojawia się kolejna rzecz, o której trzeba pomyśleć. Musimy liczyć na, że zewnętrzny serwis będzie idempotentny. W skrócie, nie może dojść do ponownego zapisu tej samej informacji. Jednak zatrzymajmy się w tym miejscu z dalszym wywodem. Widać, że zachowanie spójności w takim systemie okazuje się być poważnym wyzwaniem.

Przejdźmy zatem do podsumowania działania aplikacji będącej na jednym serwerze. Najważniejsze z podejścia bezpośredniego wywołania pomiędzy modułami jest to, że zostanie zachowana wcześniej wspomniana transakcyjna spójność danych oraz łatwość implementacji. Jednak wiąże się to z kosztem w postaci couplingu. Sprawia on, że moduł jest trudniejszy w testowaniu, ponieważ trzeba zestawić kolejne, niezbędne instancje klas. Dodatkowo jeśli jedna z funkcjonalności nie jest krytyczna, a druga tak, to błąd w tej mniej ważnej spowoduje wycofanie rezultatu całego procesu.

Przejście na eventy

Rozwiązaniem tego problemu może być skorzystanie z komunikacji asynchronicznej przy pomocy eventów. Nie da się ukryć. To podejście jest o wiele trudniejsze od wyżej wspomnianego. Nie możemy śledzić w sposób bezpośredni tego co się dzieje z naszą aplikacją np. poprzez wykorzystanie debuggera. Musimy porzucić dotychczasowe myślenie. Teraz nas interesuje tylko to co wysyła nasza aplikacja (jakie eventy) oraz na co powinna reagować.

Gdzieś słyszałem określenie, że taka forma komunikacji pomiędzy modułami powinna być domyślna. Przekładając to na rzeczywisty przypadek. Jeśli zlecamy komuś wykonanie danej czynności to nie stoimi nad nim i nie czekamy aż skończy. Delegujemy zadanie po to, aby można było zrobić ich więcej w tym samym czasie. Tak samo powinno być z naszymi aplikacjami.

Podejście tworzenia modułów w oparciu o zdarzenia
Podejście tworzenia modułów w oparciu o zdarzenia

W tym podejściu sytuacja się komplikuje pod względem technicznym, ale upraszcza przy implementacji wymagań biznesowych. A my, programiści, jesteśmy dobrzy w rozwiązywaniu problemów technicznych. Nasza infrastruktura będzie teraz wymagała do działania brokera wiadomości. To jego odpowiedzialnością będzie obsługa odbioru i wysyłki wiadomości do odpowiednich klientów. Oczywiście, proces biznesowy będzie rozspójniony na jakiś czas (eventual consistency) i idempotentność również będzie wymagana. Mogą być niezbędne też takie mechanizmy jak np. Transactional Outbox.

Jednak te wszystkie problemy trzeba rozwiązywać dopiero wtedy, gdy zaczniemy komunikować się pomiędzy modułami przez sieć. W ramach jednej aplikacji nasze moduły mogą również wymieniać między sobą informacje poprzez eventy. Będąc na jednej maszynie i stosując zdarzenia wiele rzeczy się uprości i łatwiej będzie nam zmieniać błędne decyzje architektoniczne. Dopiero po ustabilizowaniu modułów w aplikacji będziemy mogli czuć się bezpiecznie przy wydzielaniu mikroserwisów. Jednak zanim do tego dojdzie trzeba to jakoś zaimplementować. I tutaj do gry wchodzi tytułowa biblioteka Modulith.

Sposoby obsługi zdarzeń w Spring

W sumie dokładniej to coś co znamy ze Springa. Zamiast bezpośrednio zawierać w sobie klasę, na której chcemy wywołać określoną metodę to skorzystajmy z ApplicationEventPublisher. W poprzednim artykule klasa SecondA zawierała w sobie klasę FirstA i mogła na niej wywołać dostępne metody. Teraz natomiast chcielibyśmy emitować zdarzenie, na które pierwszy moduł mógłby zareagować.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package io.csanecki.modulith.secondmodule;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.transaction.annotation.Transactional;

public class SecondA {

  private final ApplicationEventPublisher publisher;

  SecondA(ApplicationEventPublisher publisher) {
    this.publisher = publisher;
  }

  @Transactional
  public void doSomething() {
    publisher.publishEvent(new SecondModuleEvent());
  }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package io.csanecki.modulith.firstmodule;

import io.csanecki.modulith.firstmodule.internal.FirstInternalA;
import io.csanecki.modulith.secondmodule.SecondModuleEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class FirstA {

  private final FirstInternalA firstInternalA;

  FirstA(FirstInternalA firstInternalA) {
    this.firstInternalA = firstInternalA;
  }

  @EventListener
  public void handle(SecondModuleEvent event) {
    System.out.println("i've got message!");
  }
}

W ten sposób zależność nam się odwróciła. Teraz SecondA nic nie wie o FirstA, natomiast to FirstA ma zależność do jednej z klas z drugiego modułu - SecondModuleEvent. Można się zastanowić w tym momencie czy ten skutek jest dla nas do zaakceptowania.

Podczas implementacji należy oczywiście pamiętać, aby metoda listenera była publiczna 🙂. @EventListener działa na zasadzie aspektu, więc metoda nim oznaczona musi spełniać wszystkie wymagania stawiane aspektom (tak jak i @Transactional).

No właśnie, transakcja. Domyślnie zadziała nam ona na obydwu modułach. Pomimo korzystania z eventów to i tak w przypadku błędu do bazy zapisze nam się wszystko albo nic. Co jednak, gdy druga funkcjonalność nie jest kluczowa? Możemy wtedy zastosować następujące podejście.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package io.csanecki.modulith.firstmodule;

import io.csanecki.modulith.firstmodule.internal.FirstInternalA;
import io.csanecki.modulith.secondmodule.SecondModuleEvent;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class FirstA {

  private final FirstInternalA firstInternalA;

  FirstA(FirstInternalA firstInternalA) {
    this.firstInternalA = firstInternalA;
  }

  @Transactional
  @TransactionalEventListener
  public void handle(SecondModuleEvent event) {
    System.out.println("i've got message!");
  }
}

@TransactionalEventListener, jak nazwa wskazuje, ma świadomość o istnieniu transakcji. Domyślnie działa on w trybie AFTER_COMMIT. Oznacza to, że obsługa eventu SecondModuleEvent zacznie się dopiero wtedy, gdy transakcja na metodzie publikującej zdarzenie zostanie zapisana w bazie. Przy okazji korzystamy też tutaj z @Transactional, aby w pierwszym module obsłużyć zapis transakcyjny.

Niestety, gdy moduł obsługujący zdarzenie wyrzuci wyjątek to zapis w nim do bazy danych się nie odbędzie, a event zostanie trwale utracony. Za chwilę pokażę jak można rozwiązać ten problem, gdybyśmy chcieli jednak, aby wszystko się nam powiodło. Ale najpierw mała ciekawostka.

Wykorzystanie asynchroniczności Springa

W powyższym rozwiązaniu możemy skorzystać z @Async, aby drugi proces odpalił się nam asynchronicznie. Wtedy metoda wyglądałaby następująco.

1
2
3
4
5
6
@Async
@Transactional
@TransactionalEventListener
public void handle(SecondModuleEvent event) {
  System.out.println("i've got message!");
}

Całość tych trzech adnotacji Modulith schował w jednym miejscu, czyli w adnotacji @ApplicationModuleListener. Będąc drobiazgowym to jeszcze w niej @Transactional korzysta z trybu propagacji transakcji REQUIRES_NEW. Być może dla kogoś będzie to istotne, aby posiadać mniej adnotacji nad metodą. Jednak czy wtedy nie chowamy istotnych mechanizmów jeszcze głębiej?

Przejdźmy zatem do próby rozwiązania wcześniej wspomnianego problemu. Jak mieć pewność, że funkcja biznesowowa dotykająca dwóch modułów wykona się w całości, ale w sposób asynchroniczny w ramach jednej aplikacji? Dążymy tutaj do eventual consistency i potrzebujemy do tego przechowywania stanu o etapie procesu biznesowego. Spójrzmy co udostępnia nam do rozwiązania tego problemu Modulith.

Wykorzystanie rejestru zdarzeń

Działanie rejestru zdarzeń w Modulith
Działanie rejestru zdarzeń w Modulith

Do mechanizmu publikacji i obsługi zdarzeń możemy dodać rejestr zdarzeń dostarczony przez Modulith. Wtedy komponenty, które nasłuchują na nasze zdarzenie, nie obsługują ich od razu. W tym miejscu do gry wchodzi wyżej wspomniany rejestr. Dla każdego listenera tworzy on wpis o evencie w odpowiedniej tabeli w bazie danych i dopiero wtedy próbuje tą wiadomość dostarczyć do odbiorcy. Jeśli coś się nie powiedzie to wpis zostaje w bazie, a próba zostanie podjęta ponownie później. Domyślnie mechanizm ponowień odpali się po ponownym uruchomieniu aplikacji.

Technicznie działa to na takiej zasadzie, że Modulith dostarcza nam repozytorium EventPublicationRepository. Aby móc z niego skorzystać musimy dostarczyć jedną z zależności do JPA, JDBC albo MongoDB.

1
2
3
4
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

Stworzy się nam odpowiednia tabela do przechowywania eventów o nazwie EVENT_PUBLICATION. Jej schemat możemy znaleźć pod tym linkiem. Różni się on w zależności od wybranej bazy danych. Do serializacji eventów domyślnie wykorzystywany jest oczywiście Jackson - JacksonEventSerializer.

Testy integracyjne

W celu dopełnienia całej obsługi zdarzeń w tym artykule należałoby jeszcze przyjrzeć się w jaki sposób możemy przetestować nasze rozwiązanie. Wydaje się to być naprawdę proste. Jednak zanim napiszemy testy musimy dodać w Maven zależność do H2.

1
2
3
4
5
<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>test</scope>
</dependency>

Oraz klasyczne parametry w application.properties.

spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
spring.datasource.username=sa
spring.datasource.password=sa

Teraz jesteśmy gotowi, aby napisać testy. A dokładnie dwa, ponieważ Modulith daje nam dwie możliwości. Pierwszą z nich jest skorzystanie z parametru testu o nazwie PublishedEvents. Dzięki niemu dostaniemy dostęp do opublikowanych zdarzeń, które wystąpiły podczas działania testu.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package io.csanecki.modulith.secondmodule;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.PublishedEvents;

import static org.assertj.core.api.Assertions.assertThat;

@ApplicationModuleTest
class SecondModuleEventsIntTests {

  @Autowired
  private SecondA secondA;

  @Test
  void emit_event_exactly_once(PublishedEvents events) {
    secondA.doSomething();

    var secondModuleEvents = events.ofType(SecondModuleEvent.class);
    assertThat(secondModuleEvents).hasSize(1);
  }

}

Weryfikujemy więc czy zostało opublikowane konkretne zdarzenie, w zadanej ilości. Natomiast Modulith poszedł o krok dalej i udostępnił kolejną możliwość weryfikacji. Możemy skorzystać z AssertablePublishedEvents, którego API już w sobie ma możliwość tworzenia asercji.

1
2
3
4
5
6
7
@Test
void event_is_emitted_at_least_once(AssertablePublishedEvents events) {
  secondA.doSomething();

  events.assertThat()
    .contains(SecondModuleEvent.class);
}

Co prawda interfejs jest jeszcze ubogi, ale można sprawdzić jakieś podstawowe warunki. Jest to więc ciekawa opcja, ale bardziej dla tych, którzy korzystają z AssertJ w swoich testach.

Ciekawostka

Chciałbym Ci jeszcze zaprezentować w jaki sposób działa rejestr zdarzeń. Wstrzyknijmy sobie do testów wcześniej wspomniane repozytorium EventPublicationRepository. Posiada ono metodę do znalezienia zdarzeń, których nie udało się obsłużyć. Z tego powodu najpierw w listenerze musimy rzucić jakiś wyjątek, aby taka sytuacja miała miejsce.

1
2
3
4
5
@ApplicationModuleListener
public void handle(SecondModuleEvent event) {
  System.out.println("i've got message!");
  throw new IllegalStateException("something went wrong!");
}

Teraz piszemy test. Warto zwrócić uwagę, że w BootstrapMode dajemy ALL_DEPENDECIES, aby postawić wszystkie utworzone moduły.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package io.csanecki.modulith.firstmodule;

import io.csanecki.modulith.secondmodule.SecondA;
import io.csanecki.modulith.secondmodule.SecondModuleEvent;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.modulith.events.EventPublicationRepository;
import org.springframework.modulith.test.ApplicationModuleTest;
import org.springframework.modulith.test.ApplicationModuleTest.BootstrapMode;

import static org.assertj.core.api.Assertions.assertThat;

@ApplicationModuleTest(value = BootstrapMode.ALL_DEPENDENCIES)
class BetweenModulesEventsIntTests {

  @Autowired
  private SecondA secondA;

  @Autowired
  private EventPublicationRepository eventPublicationRepository;

  @Test
  void emit_event_exactly_once() {
    secondA.doSomething();

    var result = eventPublicationRepository.findIncompletePublications();
    assertThat(result).hasSize(1);
    assertThat(result.get(0).getEvent()).isInstanceOf(SecondModuleEvent.class);
  }

}

Test powinien przejść, ale tak się nie dzieje. Nie może w nim uzyskać zależności do klasy SecondA. Gdy zajrzymy do logów to moduł drugi po prostu nie wstał. Mamy tylko zależności do beanów modułu pierwszego.

2023-02-10T12:48:27.407+01:00  INFO 399480 --- [       main] ustomizerFactory$ModuleContextCustomizer : # Firstmodule
2023-02-10T12:48:27.407+01:00  INFO 399480 --- [       main] ustomizerFactory$ModuleContextCustomizer : > Logical name: firstmodule
2023-02-10T12:48:27.407+01:00  INFO 399480 --- [       main] ustomizerFactory$ModuleContextCustomizer : > Base package: io.csanecki.modulith.firstmodule
2023-02-10T12:48:27.407+01:00  INFO 399480 --- [       main] ustomizerFactory$ModuleContextCustomizer : > Direct module dependencies: none
2023-02-10T12:48:27.407+01:00  INFO 399480 --- [       main] ustomizerFactory$ModuleContextCustomizer : > Spring beans:
2023-02-10T12:48:27.407+01:00  INFO 399480 --- [       main] ustomizerFactory$ModuleContextCustomizer :   + ….FirstA
2023-02-10T12:48:27.408+01:00  INFO 399480 --- [       main] ustomizerFactory$ModuleContextCustomizer :   + ….internal.FirstInternalA
2023-02-10T12:48:27.409+01:00  INFO 399480 --- [       main] ustomizerFactory$ModuleContextCustomizer :

Żeby całość zadziałała trzeba zrobić sztuczną zależność pomiędzy modułami np. klasa FirstA niech będzie zawierała SecondB. Uruchamiamy test i teraz dopiero wszystko działa jak powinno. Wychodzi na to, że sama zależność w postaci zdarzenia testom nie wystarczy…

Podsumowanie

To by było na tyle. Moim zdaniem warto zainteresować się rozwojem Modulith nawet tylko ze względu na możliwość asynchronicznej obsługi zdarzeń bez ich gubienia (rejestr). Jeszcze jest sporo do poprawy, ale projekt wygląda obiecująco. W następnym wpisie z serii o Modulith chciałbym zaprezentować Ci sposób w jaki pomaga on nam tworzyć dokumentację. Mam nadzieję, że w tym wpisie wszystko zostało jasno przedstawione i zostawisz komentarz ze swoimi odczuciami na temat tego projektu.

Link do GitHub: https://github.com/cezarysanecki/code-from-blog