Każdy z nas, deweloperów, na pewno miał styczność z pojęciem modularnego monolitu. Jeśli nie w praktyce to chociaż w teorii. Więc w skrócie, dzięki modularnemu monolitowi wszystko w naszej aplikacji powinno znajdować się na swoim miejscu, w odrębnych modułach. Dodatkowo, skoro to monolit, to nie musimy martwić się żadnymi problemami powodowanymi przez rozproszenie. Jak wiemy, gdy posiadamy rozproszony monolit to jedyną rozsądną decyzją jest jego połączenie. Z tego powodu warto trzymać się monolitu, a najlepiej modularnego, jak najdłużej.

Oczywiście utrzymanie modularności w projekcie nie jest łatwym zadaniem. Wystarczy jeden zły ruch i do naszej aplikacji może wkraść się bałagan. A potem już jest krótka droga do przejścia na Big Ball Of Mud. Powstaje więc pytanie, jak się przed tym bronić? Przyznam się, że nie znam ogólnej odpowiedzi na to pytanie, bo jest ono zbyt złożone. Wiem natomiast co może nam ułatwić organizację naszego kodu.

Czytając jeden z blog postów na temat Springa natrafiłem na tytułową inicjatywę o nazwie Modulith. Zacząłem drążyć do czego to służy i jak może pomóc moim projektom. Po krótkim researchu uważam, że idea stojąca za Modulith jest naprawdę warta uwagi. Z tego powodu powstał ten wpis, w którym chciałbym podzielić się moimi spostrzeżeniami.

Sama koncepcja została zainicjowana pod nazwą Moduliths w 2017. Natomiast od połowy 2022 roku Spring ją wchłonął i zaczął rozwijać pod swoimi skrzydłami. Jest to już dosyć spory kawałek historii, którą można się teraz pobawić. Przejdźmy zatem przez podstawy tej biblioteki i sprawdźmy jej działanie na prostej aplikacji.

UWAGA DISCLAIMER: Modulith jest na ten moment w fazie experimental. W tym wpisie posługiwałem się wersją 0.3.0.

Czym jest moduł?

Tak dla formalności, zdefiniujemy sobie czym jest moduł. W skrócie można powiedzieć, że jest to jednostka odpowiedzialna za dostarczenie spójnych ze sobą funkcjonalności. W idealnym świecie taki moduł moglibyśmy przenieść do innej aplikacji i, po szybkiej konfiguracji, od razu z niego korzystać. A co się składa na taki moduł?

  • wystawione API dla innych modułów w postaci interfejsów oraz eventów
  • wewnętrzna implementacja, która nie powinna być widoczna na zewnątrz
  • odwołania do innych modułów w postaci użytych interfejsów, event listenerów czy też konfiguracji w postaci propertiesów

No właśnie… warto zwrócić uwagę na drugi punkt. W Javie można napotkać pewną, związaną z tym, niedogodność. Często nie chcemy mieć napakowanych wiele klas w jednym pakiecie tylko wolelibyśmy je mieć pogrupowane w wielu mniejszych. Kiedy tak zrobimy to nie możemy skorzystać z dobrodziejstwa jakim jest dostępność package-private. Musimy zrobić niektóre klasy dostępowe jako public w naszych podpakietach. Warto zajrzeć w tym temacie do prezentacji Kuby Nabrdalika.

I tutaj do gry wchodzi nasza silna wola. Musimy ciągle się pilnować, aby inne moduły nie korzystały z czegoś co inny moduł schował w swoich podpakietach. Łatwo jest więc doprowadzić do bałaganu zwłaszcza w sytuacji, gdy nasz zespół jest duży i nie posiada wspólnej wizji. Gdyby tak istniało narzędzie, które pomogłoby nam rozwiązać ten problem…

Rozwiązanie problemu w Modulith

Teraz cały na biało wchodzi Modulith. Aby przekonać się jak on nam może pomóc z powyżej opisanym scenariuszem to przejdźmy do zdefiniowania przypadku. Załóżmy, że mamy następującą sytuację.

Inny moduł "grzebie" w naszy wewnątrznych pakietach

Istnieją dwa pakiety reprezentujące moduły: firstmodule oraz secondmodule. W pierwszym z nich dla wygody wynieśliśmy część klas do pakietu firstmodule.internal. Zielone kółko oznacza, że dana klasa jest publiczna. Z tego powodu klasa FirstA może korzystać z dobrodziejstw klasy FirstInternalA.

W pewnym momencie niezbędne było dodanie drugiego modułu secondmodule, który potrzebuje informacji od firstmodule. W trakcie dewelopmentu wyszło, że klasa SecondA musi współpracować z klasą FirstA i to zachowanie jest jak najbardziej prawidłowe. Natomiast kolejny deweloper dostarczając nową funkcjonalność poszedł “na łatwiznę”. Skorzystał z danych dostarczanych przez FirstInternalA w klasie SecondB. Taka sytuacja nie powinna mieć miejsca. Takiego podejścia chcielibyśmy uniknąć. Oczywiście można by to wyłapać na etapie Code Review, ale jak wiemy, to nie zawsze jest takie łatwe. Zawsze można coś przeoczyć.

Warto więc w tym momencie skorzystać z Modulith. Twórcy tej biblioteki wpadli na pomysł, aby do swojego rozwiązania wykorzystać pliki package-info. Wystarczy stworzyć jeden z nich w wybranym pakiecie i w środku umieścić następującą adnotację @ApplicationModule. Posiada ona atrybut o nazwie allowedDependencies, któremu wskazujemy do jakich innych pakietów ten pakiet ma mieć dostęp.

1
2
3
4
@org.springframework.modulith.ApplicationModule(
  allowedDependencies = "firstmodule"
)
package pl.csanecki.modulith.secondmodule;

Nie da się niestety odwrócić zależności, czyli żeby to dany pakiet wybierał kto ma do niego dostęp. Kto wie, być może przyjdzie taka możliwość w późniejszych wersjach i na pewno znajdzie zastosowanie. Powstaje natomiast jedno, bardzo ważne pytanie. W jaki sposób tak zdefiniowane ograniczenia są egzekwowane?

Egzekucja restrykcji

Niestety, nie jesteśmy w tym rozwiązaniu pilnowani na poziomie kompilacji. Radośnie kodując możemy i tak bez żadnych problemów dostać się do bebechów innych pakietów. Dopiero podczas fazy weryfikacji, poprzez testy, dowiemy się o przekroczeniu restrykcji modułowej. Nie jest to zbyt wygodne, ale być może w pewnym momencie zmusi nas do przemyślenia naszego rozwiązania pod kątem architektury aplikacji.

Jak więc wygląda ta egzekucja? Należy utworzyć odpowiedni test z wywołaniem poniższego kawałka kodu i tyle.

1
ApplicationModules.of(Application.class).verify();

Po uruchomieniu, jeśli nasz kod będzie miał strukturę z powyższego obrazka, dostaniemy następujące ostrzeżenie w logach, a test nie przejdzie.

org.springframework.modulith.core.Violations: - Module 'secondmodule' depends on module 'firstmodule' via pl.csanecki.modulith.secondmodule.SecondB -> pl.csanecki.modulith.firstmodule.internal.FirstInternalA. Allowed targets: firstmodule.
- Module 'secondmodule' depends on module 'firstmodule' via pl.csanecki.modulith.secondmodule.SecondB -> pl.csanecki.modulith.firstmodule.internal.FirstInternalA. Allowed targets: firstmodule.
- Module 'secondmodule' depends on module 'firstmodule' via pl.csanecki.modulith.secondmodule.SecondB -> pl.csanecki.modulith.firstmodule.internal.FirstInternalA. Allowed targets: firstmodule.

Zaglądając do wspomnianej klasy secondmodulith.SecondB zobaczymy w dwóch miejscach użycie zabronionego FirstInternalA, jako pole oraz parametr konstruktora.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package pl.csanecki.modulith.secondmodule;

import org.springframework.stereotype.Component;
import pl.csanecki.modulith.firstmodule.internal.FirstInternalA;

@Component
public class SecondB {

  private final FirstInternalA firstInternalA;

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

}

Zostaliśmy złapani na gorącym uczynku. Natomiast jedna rzecz może nam tutaj doskwierać. W tych logach nie wyczytamy informacji z jakiego powodu zostały naruszone ograniczenia. Z tego powodu zrobiłem pewien eksperyment. Wyrzuciłem wszystkie package-info z projektu. Chciałem zobaczyć jak domyślnie zachowa się Modulith. Uruchomiłem jeszcze raz test i… zaświecił się na czerwono. Wychodzi na to, że Modulith prowadzi już nas od samego początku w kierunku modularności. Co ciekawsze komunikaty o przekroczeniu ograniczeń są teraz o wiele przyjemniejsze.

org.springframework.modulith.core.Violations: - Module 'secondmodule' depends on non-exposed type pl.csanecki.modulith.firstmodule.internal.FirstInternalA within module 'firstmodule'!
FirstInternalA declares parameter FirstInternalA(FirstInternalA) in (SecondB.java:0)
- Module 'secondmodule' depends on non-exposed type pl.csanecki.modulith.firstmodule.internal.FirstInternalA within module 'firstmodule'!
Field <pl.csanecki.modulith.secondmodule.SecondB.firstInternalA> has type <pl.csanecki.modulith.firstmodule.internal.FirstInternalA> in (SecondB.java:0)
- Module 'secondmodule' depends on non-exposed type pl.csanecki.modulith.firstmodule.internal.FirstInternalA within module 'firstmodule'!
Constructor <pl.csanecki.modulith.secondmodule.SecondB.<init>(pl.csanecki.modulith.firstmodule.internal.FirstInternalA)> has parameter of type <pl.csanecki.modulith.firstmodule.internal.FirstInternalA> in (SecondB.java:0)

Już na pierwszy rzut oka widać, że wykorzystaliśmy niedozwoloną klasę jako pole oraz parametr konstruktora. Mam więc nadzieję, że te komunikaty zostaną ujednolicone w późniejszych wersjach. Może nie przeszkadza to aż tak bardzo, ale na pewno jest wygodniejsze dla dewelopera. Ważne jest, że najistotniejsza wiadomość do nas trafiła. Nie powinniśmy grzebać w wewnętrznych pakietach innego modułu.

Testowanie integracyjne

Następną rzeczą wartą uwagi jest sposób w jaki Modulith pozwala nam na przeprowadzenie testów integracyjnych. Zamiast podnosić cały kontekst Springa, aby przetestować dany moduł, możliwe jest powstanie tylko jednego z wybranych. Oczywiście dalej warto testować aplikację w całości poprzez testy akceptacyjne. Jednak podejście z pojedynczym modułem ma również swoje zalety np. w postaci szybkości testów. Sprawdźmy więc jak to działa w praktyce. Załóżmy, że poprawiliśmy naszą aplikację i nie ma ona już niedozwolonych zależności.

Aplikacja z prawidłowo rozdzielonymi modułami
Oczywiście na start należy dodać odpowiednią zależności do projektu.

1
2
3
4
5
6
<dependency>
  <groupId>org.springframework.experimental</groupId>
  <artifactId>spring-modulith-test</artifactId>
  <version>0.3.0</version>
  <scope>test</scope>
</dependency>

Napiszmy teraz test. Wykorzystamy w tym celu adnotację @ApplicationModuleTest. Tworzymy klasę, dodajemy @Autowired do testowanego komponentu oraz pustą metodę testową. Uruchamiamy i… test nie przechodzi. Dlaczego? Tak jak zakładaliśmy, kontekst Springa podniósł nam tylko komponenty z modułu secondmodule.

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

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

@ApplicationModuleTest
class SecondAIntTest {

  @Autowired
  SecondA secondA;

  @Test
  void test() {

  }

}
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'secondA' defined in file [D:\\git\\blog-devcezz\\modulith-demo\\target\\classes\\pl\\csanecki\\modulith\\secondmodule\\SecondA.class]: Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'pl.csanecki.modulith.firstmodule.FirstA' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

Klasa SecondA nie otrzymała niezbędnych zależności. Tak się stało dlatego, bo @ApplicationModuleTest domyślnie uruchamia testy w trybie STANDALONE (wstaje kontekst tylko wybranego modułu). Gdybyśmy chcieli, aby ten test zadziałał to mamy dwie możliwości.

Wyjścia z tej sytuacji

Pierwszą z nich jest użycie trybu DIRECT_DEPENDENCIES. W ten sposób postawimy kontekst testowanego modułu oraz wszystkich innych, od których on zależy. Jest to ciekawa opcja do wyboru. Coś pomiędzy postawieniem całej aplikacji a jednego modułu.

Drugą możliwością jest wykorzystanie domyślnego trybu, STANDALONE, i zamockowanie zależnych komponentów. Wyglądałoby to w następujący sposób.

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

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.modulith.test.ApplicationModuleTest;
import pl.csanecki.modulith.firstmodule.FirstA;

@ApplicationModuleTest
class SecondAIntTest {

  @MockBean
  FirstA firstA;

  @Autowired
  SecondA secondA;

  @Test
  void test() {

  }

}

W tym momencie test przejdzie, a my możemy zajrzeć do logów, gdzie znajdują się bardzo ciekawe informacje na temat naszego modułu.

.   ____          _            __ _ _
 /\\\\ / ___'_ __ _ _(_)_ __  __ _ \\ \\ \\ \\
( ( )\\___ | '_ | '_| | '_ \\/ _` | \\ \\ \\ \\
 \\\\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.0.2)

.. com.tngtech.archunit.core.PluginLoader   : Detected Java version 17
... ustomizerFactory$ModuleContextCustomizer : Bootstrapping @org.springframework.modulith.test.ApplicationModuleTest for Secondmodule in mode STANDALONE (class pl.csanecki.modulith.ModulithDemoApplication)…
.. ustomizerFactory$ModuleContextCustomizer : 
.. ustomizerFactory$ModuleContextCustomizer : # Secondmodule
.. ustomizerFactory$ModuleContextCustomizer : > Logical name: secondmodule
.. ustomizerFactory$ModuleContextCustomizer : > Base package: pl.csanecki.modulith.secondmodule
.. ustomizerFactory$ModuleContextCustomizer : > Direct module dependencies: firstmodule
.. ustomizerFactory$ModuleContextCustomizer : > Spring beans:
.. ustomizerFactory$ModuleContextCustomizer :   + ….SecondA
.. ustomizerFactory$ModuleContextCustomizer :   + ….SecondB

Widzimy w jakim trybie dany test się uruchomił i dla jakiego modułu. Widnieje też informacja o tym od jakich modułów zależy nasz moduł oraz jakie beany zostały zarejestrowane w kontenerze Springa.

Inne informacje w logach

Rozpatrzmy na koniec jeszcze dwie opcje. Spróbujmy zarejestrować bean, który będzie miał dostęp package-private. Nazwiemy go SecondInternalC. Gdy uruchomimy test w konsoli zauważymy następujące dane o kontenerze.

.. ustomizerFactory$ModuleContextCustomizer : > Spring beans:
.. ustomizerFactory$ModuleContextCustomizer :   + ….SecondA
.. ustomizerFactory$ModuleContextCustomizer :   + ….SecondB
.. ustomizerFactory$ModuleContextCustomizer :   o ….SecondInternalC

Klasy public są oznaczone przez ‘+’, natomiast te package-private jako ‘o’. W ten sposób mamy wgląd na to co udostępniamy na zewnątrz naszego modułu. To samo możemy uzyskać, gdy korzystamy z IntelliJ. Nie trzeba wtedy uruchamiać żadnych testów.

Widoczność klas w IntelliJ

Jednak czasami zdarza się, że niektórych klas nie muszą być rejestrowane jako beany. Co wtedy pokaże nam Modulith?

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
package pl.csanecki.modulith.secondmodule;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import pl.csanecki.modulith.firstmodule.FirstA;

@Configuration
public class SecondConfig {

  private final FirstA firstA;

  SecondConfig(FirstA firstA) {
    this.firstA = firstA;
  }

  @Bean
  SecondA secondA() {
    return new SecondA(firstA);
  }

  @Bean
  SecondB secondB() {
    SecondInternalC secondInternalC = new SecondInternalC();
    return new SecondB(secondInternalC);
  }
}
.. ustomizerFactory$ModuleContextCustomizer : > Spring beans:
.. ustomizerFactory$ModuleContextCustomizer :   + ….SecondA
.. ustomizerFactory$ModuleContextCustomizer :   + ….SecondB
.. ustomizerFactory$ModuleContextCustomizer :   + ….SecondConfig

Spring nic nie wie o istnieniu SecondInternalC, czyli tak jak sobie tego zażyczyliśmy.

Podsumowanie

To by było na tyle w tym wpisie na temat Modulith. W kolejnych artykułach chciałbym poruszyć jeszcze kwestię komunikowania się modułów przez eventy oraz w jaki sposób Modulith pozwala nam na automatyczne tworzenie dokumentacji kodu. Jeszcze raz chciałbym przestrzec o tym, że Modulith w Spring jest w fazie experimental, więc nie ma co ryzykować z wykorzystaniem go w celach produkcyjnych. Natomiast według mnie warto zwrócić uwagę na dalszy rozwój Modulith jeśli chcemy lepiej zarządzać naszą modularną aplikacją i być może, łatwiej wyodrębnić mikroserwisy. Dajcie znać co myślicie o tym projekcie.

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