Modularyzacja: kolejny modny trend IT, czy jedyna słuszna droga?

03.09.2020 | Paweł Widera

Jak to się wszystko zaczęło?

Jeszcze 10 lat temu najpopularniejszą architekturą wybieraną przy tworzeniu aplikacji był jednolity monolit. Rozwiązanie takie pociągało za sobą wiele plusów. Cały kod w jednym miejscu, szybki czas developmentu oraz brak konieczności zmagania się z problemami systemów rozproszonych. Architektura ta ma jednak swoje ograniczenia. Rozpoczęcie procesu globalnej cyfrowej transformacji spowodowało znaczne zwiększenie ilości wytwarzanych danych, których stara architektura nie była już w stanie efektywnie przetworzyć. 

Wkrótce pojawiło się nowe podejście, które szturmem przejęło branżę. Mowa tutaj o mikroserwisach. Filozofia tego rozwiązania polega na rozłożeniu skomplikowanych monolitów w mniejsze usługi działające niezależnie od siebie. Komunikacja między nimi powinna odbywać się przy pomocy interfejsu API. Dzięki temu problem skalowania znacznie się uprościł, niemniej jednak pojawiły się nowe wyzwania związane z rozproszeniem usług. Dotyczyły one takich obszarów jak np.:

  • transakcje rozproszone,
  • zawodność sieci,
  • dodatkowe opóźnienia wynikające z przesyłania danych poprzez API,
  • bezpieczeństwo sieci,
  • wzrost złożoności infrastruktury,
  • śledzenie zapytań między systemami.

Niestety brak pełnego zrozumienia filozofii nowej architektury oraz podążanie za trendami technicznymi zamiast potrzebami biznesowymi doprowadziło do sytuacji, w której wiele firm w efekcie końcowym nie uzyskało architektury mikroserwisów lecz rozproszony monolit. Jest to sytuacja, w której system składa się z wielu mniejszych usług (czasami zdecydowanie za małych), które nie są w pełni autonomiczne, przez co wszystkie pozostają od siebie zależne.

Jako rozwiązanie tej sytuacji specjaliści z branży zaczęli zalecać, żeby przy migracji z monolitu do mikroserwisów lub tworzeniu nowej aplikacji rozproszonej wykonać najpierw krok pośredni - zbudować modularny monolit. Polega on na stworzeniu pojedynczej aplikacji tak jakby była ona systemem rozproszonym, czyli zachowując granice autonomii każdego z modułów. Nie trzeba wtedy jednak mierzyć się z problemami fizycznego rozproszenia. 

Źródło: https://twitter.com/simonbrown/status/847339104874381312

Jednym z największych wyzwań w tym procesie jest poprawne wyznaczenie granic autonomicznych modułów. W przypadku architektury modularnego monolitu znacznie łatwiej jest naprawić wynikające z tego błędy w porównaniu do architektury mikroserwisów, gdzie wymagana jest nie tylko refaktoryzacja kodu wewnątrz kilku aplikacji, ale również przebudowa infrastruktury. W celu zmniejszenia ryzyka niepowodzenia najlepiej oprzeć się o metodologię, która już dowiodła swojej efektywności. Mówimy tutaj o Domain Driven Design. Jest to obecnie bardzo popularna metodologia, dlatego w celu jej głębszego zrozumienia odsyłam do artykułów i szkoleń w Internecie jak również do książki „Implementing Domain-Driven Design”.

W naszym przypadku wyzwanie modularyzacji którego się podjęliśmy dotyczyło 10-letniego monolitu, w którym rezydują trzy różne linie biznesowe. Dodatkowo podział wewnętrzny, jaki do tej pory był stosowany zawsze był podziałem technicznym. Oznacza to, że w kodzie brak jest separacji pomiędzy funkcjonalnościami różnych domen, a o tym jak aplikacja ostatecznie się zachowa decydują instrukcje warunkowe. Naszym celem było wydzielenie trzech niezależnych usług i zmniejszenie czasu startu każdej z nich z obecnych 15 do 3 minut.

Wdrażanie architektury modularnego monolitu

Zanim rozpoczniemy proces kodowania powinniśmy określić ogólne założenia, których chcemy przestrzegać w projekcie. Mianowicie:

  • Aplikacja będzie miała wiele niezależnych modułów
    • zabronione jest bezpośrednie odwoływanie się do klas innego modułu - wszelka komunikacja pomiędzy modułami powinna odbywać się poprzez API
    • moduły powinny być uważane za kod prywatny; w przypadku udostępnienia modelu lub interfejsów niezbędnych do komunikacji powinno to się odbyć poprzez wydzieloną publiczną część kodu
    • zabroniony jest bezpośredni dostęp do bazy danych innego modułu; dane również uważane są za część prywatną. Oznacza to, że w przypadku współdzielenia bazy przez kilka modułów, każdy z nich powinien posiadać własnego użytkownika i tylko on powinien mieć dostęp do odczytu i zapisu swoich danych
    • każdy z modułów może mieć osobną/inną architekturę
    • aplikacja powinna mieć możliwość uruchomienia z wybranymi, dowolnymi modułami
  • Część wspólną należy zagregować w jednym wspólnym module
  • W celu zapobiegania niekontrolowanemu rozrostowi modułów należy ustalić limit maksymalnego czas startu pojedynczego modułu (np. 3 minuty); po przekroczeniu tego limitu powinna zostać wykonana refaktoryzacja i podział na podmoduły

Biorąc pod uwagę określone założenia wstępna architektura prezentuje się następująco:


Niestety takie ogólne podejście może wydawać się dla wielu programistów dość mgliste, dlatego najlepiej oprzeć się na przykładzie. Stworzymy więc przykładową aplikację składająca się z 2 modułów. Pierwszy odpowiadać będzie za wystawienie API przyjmującego adres e-maila klienta. Zadaniem modułu jest zapisanie tej informacji do bazy danych oraz wysłanie eventu informującego o tym zdarzeniu. Drugi moduł realizuje natomiast funkcjonalność odbierania tego eventu w celu przygotowania dla klienta maila ofertowego. Wysyłka odbywa się również poprzez wygenerowanie zdarzenia.

Kod

Źródła dla tego przykładu znajdują się na Githubie. Rysunek architektury dostosowany do tego konkretnego przypadku biznesowego wygląda zatem następująco:

Projektowanie pierwszego modularnego monolitu może nie być tak oczywiste jak w teorii, dlatego dobrze jest odnieść się do czegoś prostego, co każdy z nas zna i rozumie – do telefonu komórkowego.

Każdy telefon posiada bowiem wiele różnych aplikacji. To jego użytkownik decyduje w dowolnym momencie, jakie aplikacje mają być w nim zainstalowane, a jakie nie. Taką role pełni w prezentowanym przykładzie moduł gateway. Posiada on możliwość decydowania o tym, które z modułów biznesowych w danym momencie należy uruchomić. 

Ważne, żeby zwrócić tutaj uwagę, iż zależności do modułów biznesowych są na poziomie runtime. Moduł gateway nie powinien być od nich zależny. W przypadku modułu base robimy jednak wyjątek i nie akceptujemy uruchomienia bez niego.

Należy zadbać o to, żeby każdy z modułów biznesowych posiadał możliwość samodzielnego uruchomienia, bez pozostałych. 

Dodatkowo nie oczekujemy od modułu gateway żadnych innych dodatkowych klas ani funkcjonalności. Pełni on tylko role „launchera”. Zatem jedyna klasa, która się w nim znajduje to główna klasa SpringBoota Application.

Drugi element, o którym już wspomnieliśmy to moduł base czyli „system operacyjny” telefonu. Jest on kluczowy dla całości rozwiązania, dlatego w poprzednim kroku nie było akceptowalne uruchomienia aplikacji bez niego. 

Moduł ten zawiera wszystkie współdzielone fragmenty kodu. Mogą one odpowiadać np. za wielowątkowość, asynchroniczność, monitoring, bezpieczeństwo, komunikacje z innymi aplikacjami lub elementami infrastruktury. Może on również zawierać współdzielony kod funkcjonalności biznesowych np. algorytmy do weryfikacji pewnych danych, które powinny działać tak samo w każdym module biznesowym.

Zalecaną architekturą dla tego modułu jest architektura portów i adapterów. Dzięki temu, jeśli zdecydujemy się na zmianę np. mechanizmu wysyłki maili nie spowoduje to zmian w modułach biznesowych. Będą działać tak samo na podstawie określonego interfejsu, który ukrywa przed nimi implementację. Mamy tutaj zatem podział kodu na cześć publiczną (porty) i prywatną (adaptery).

W naszym przypadku w podmodule base-external znajdują się klasy dostępne dla wszystkich komponentów w aplikacji. Są one przedstawione w postaci klasy POJO – interfejsów i modeli. Podmoduł base-internal natomiast zawiera szczegóły implementacji dwóch elementów. Szyny eventowej do przesyłania eventów wewnętrznych aplikacji, jak również sztuczny mechanizm do wysyłania maili. To nie są fragmenty kodu, które mogą być udostępniane pozostałym modułom, jest to kod prywatny i powinien być strzeżony.

Kolejne elementy telefonu to aplikacje, które użytkownik decyduje się zainstalować w telefonie czyli moduły biznesowe. Należy pamiętać, że moduły te mogą różnić się od siebie domeną biznesową, wykorzystanymi technologiami czy architekturą. Kluczowe jest, by każdy z nich pozostał autonomiczny i nie zależny od pozostałych. Pierwszy z modułów to moduleCustomer.

W tym przypadku określamy, że granicą autonomii jest funkcjonalność obsłużenia żądania HTTP celem zapisania do bazy danych emaila klienta oraz wysłanie zdarzenia UserEvent, informującego o tym właśnie zdarzeniu przy pomocy interfejsu EventBus z modułu base-external.

Warto zwrócić uwagę, że w moduleCustomer został napisany w architekturze warstwowej. Znajduje się w nim również konfiguracja połączenia do bazy danych w pliku moduleCustomer.properties. Wyłącznie ten moduł takowej potrzebuje, dlatego nie jest to element współdzielony pomiędzy modułami. Jeśli natomiast więcej modułów potrzebowałby dostępu do bazy to każdy z nich powinien mieć swoją własną konfigurację. Należy tutaj przypomnieć, że autonomia modułów nie dotyczy tylko kodu w aplikacji, ale również danych w bazie. W takiej sytuacji każdy z modułów powinien posiadać własnego użytkownika i własny schemat bazy tak, aby zapobiec nieumyślnemu korzystaniu z danych innych modułów! Pamiętajmy, że komunikacja między modułami powinna odbywa się jawnie, poprzez API, a nie w sposób ukryty np. przez bazę danych.

Ostatnim komponentem jest moduleNotification. Jego odpowiedzialność to odbieranie zdarzenia UserEvent i zlecanie klasie biznesowej UserNotificationLogic przygotowanie treści, która zostanie wysłana mailem do klienta. Ustaloną granicą autonomii jest zatem przygotowanie treści. Funkcjonalność wysłania na zewnątrz maila powinna znajdować się poza modułem. W tym celu wykorzystywany jest ten sam interfejs EventBus z modułu base i publikowane do niego jest zdarzenie EmailEvent.

Ten moduł z kolei posiada architekturę portów i adapterów. Dzięki temu w osobnym podmodule mn-domain chroniony jest kod biznesowy (przygotowanie treści wiadomości) poprzez odseparowanie go od kodu infrastruktury mn-infra (wysyłanie emaila).

Podsumowanie

Dzięki zastosowaniu architektury modularnego monolitu można tanim kosztem przetestować jak efektownie pracowałby nasz system, gdyby był złożony z wielu niezależnych, luźno powiązanych komponentów. Tak samo jak nie decydujemy się na wdrażanie aplikacji od razu na produkcję, tak samo nie należy zaczynać budowania systemów rozproszonych od budowania infrastruktury. W pierwszej kolejności należałoby przetestować pomysł oraz wyznaczone granice autonomii i dopiero po uzyskaniu zadowalającego wyniku podjąć ewentualną decyzje o wykonaniu kroku w stronę mikroserwisów.

W naszym przypadku wielkość aplikacji i ilość miejsc, które należałoby zrefaktoryzować była dość dużym wyzwaniem i spowodowałaby zatrzymanie rozwoju nowych zmian na dłuższy okres czasu, co nie byłoby rozwiązaniem akceptowalnym przez biznes. Dlatego podjęliśmy decyzje o utworzeniu nowej modularnej struktury w obecnym rozwiązaniu i tworzenia tylko nowych funkcjonalności w tej strukturze. Oczywiście wymusiło to na nas refaktoryzację elementów bazowych, niemniej jednak koszt ten był już akceptowalny biznesowo. Pozwoliło nam to przebudować wnętrze aplikacji tak, by nowe moduły uzyskały niezależność. Dodatkowo nie musieliśmy refaktoryzować starych funkcjonalności.

Pamiętajmy, że wdrażanie modularnego monolitu w nowym zespole jest zadaniem trudnym, dlatego warto zadbać o to, aby wszystkie wymagania i rysunki architektury były przechowywane w miejscu dostępnym dla całego zespołu uczestniczącego w tworzeniu aplikacji. Nie tylko dla programistów, ale również dla analityków i testerów. Ważne jest, aby wszystkie zaangażowane osoby rozumiały filozofię tego, co chcemy zbudować. Dodatkowo pomocne jest również stworzenie tzw. Architecture Decision Log - dokumentu, w którym zawieramy wszystkie nasze decyzje architektoniczne, takie jak wykorzystane technologie, narzędzia, wybrane sposoby pracy, style kodowania i rysunki architektury. Dzięki temu, w razie wątpliwości dalszego kierunku rozwoju albo potrzeby zweryfikowania podjętych wcześniej ustaleń można skorzystać ze stworzonej dokumentacji. Jest to szczególnie przydatne w dużych, rozciągniętych w czasie projektach.