Modular Monolith PHP - Wprowadzenie post
2020-06-30
Od bardzo dawna zbierałem się do poruszenia tego tematu. W tym wpisie postaram się odnieść do podstaw, wrzucę trochę materiałów do poczytania/pooglądania tak aby przygotować bazę pod kolejne wpisy. Jeżeli pod koniec czytania będziesz czuć, że czegoś jeszcze brakuje i nie do końca wiesz czy byłbyś w stanie zaimplementować modularny monolit, nie przejmuj się. To bardzo obszerne zagadnienie, z wieloma szczegółami które wychodzą dopiero w trakcie implementacji lub w czasie migracji do mikroserwisów.
Polecam w ogóle zacząć temat od oglądnięcia tej prezentacji:
Jeżeli po oglądnięciu dalej będziesz miał pytania, szczególnie o aspekty związane z PHP, czytaj dalej.
4 etapy ewolucji
Można więc już na wstępie powiedzieć, że modularny monolit to nic innego jak etap w ewolucji systemu. Etap, który może być równie dobrze ostatnim etapem, który można pominąć lub do którego można nigdy nie dojść. W wielkim skrócie powiedziałbym, że istnieją 4 etapy ewolucji:
- Proof of Concept
- Monolith
- Modular Monolith
- Microservices
To nie jest tak, że Modularny Monolit i Mikroserwisy wzajemnie się wykluczają. W zasadzie jeżeli lepiej się nad tym zastanowić to obydwie architektury mają bardzo podobne założenia. Główna i największa różnica z mojego punktu widzenia to sposób deploymentu.
Modularny Monolit na produkcję leci w ramach jednego procesu, tzn deployowany jest cały system i wszystkie zmiany jakie w nim zaszły niezależnie od modułu. Mikroserwisy z kolei pozwalają na deploy tylko modyfikowanych modułów/serwisów. Brzmi bardzo dobrze ale ma też swoją cenę o której opowiem dalej.
Czym jednak różni się modularny monolit o monolitu? Bardzo dobre pytanie z bardzo prostą odpowiedzią. Monolit to po prostu jeden moduł, modularny monolit to natomiast monolit złożony z wielu modułów. Proste, nie? No nie.
Czym więc jest moduł?
No i tutaj zaczynają się schody, samo słowo jak zresztą wiele innych bez odpowiedniego kontekstu może oznaczać bardzo wiele rzeczy. Na potrzeby tego wpisu przyjmijmy, że mówiąc moduł myślimy o części systemu, części posiadającej własną architekturę, mogącej posiadać własną bazę danych, własne API (interfejs) oraz pewne granice. Granice nieprzekraczalne dla innych modułów. Można więc pokusić się o stwierdzenie że granice modułu mogą być jednakowe z granicami Bounded Contextu i wcale nie będzie to dalekie od prawdy. Bardzo możliwe że znajdą się przypadki w których w ramach jednego bounded contextu potrzeba zaimplementować kilka modułów, nie jest to jednak typowe, przynajmniej ja do tej pory nie trafiłem na coś co wymagałoby takiego podejścia.
Jak więc wyznaczyć granice modułu? W tym celu bardzo dobrze sprawdzi się Event Storming (przyklejanie karteczek, nie mylić z event sourcingiem). W książce “Introducing EventStorming”, Alberto Brandolini opisał techniki prowadzenia sesji Event Stormingu, bardzo polecam przeczytać i chociaż raz spróbować. Ta książka zawiera podstawową i bardzo obszerną wiedzę przedstawioną w przystępny sposób nie będąc przy okazji wielkim tomiszczem.
Swoboda architektury
Dla mnie, jedną z największych zalet modularnego monolitu jest swoboda architektury, możliwość projektowania modułów w ramach jednego systemu w dowolny sposób. Najłatwiej przedstawić to na przykładzie.
Załóżmy że budujemy bardzo skomplikowany system finansowy w którym zespół zdecydował się pójść w stronę Event Sourcingu oraz CQRS’a. Przyjmijmy, że była to bardzo dobra decyzja, pieniądze lubią zdarzenia i przejrzyste zmiany stanu. Poza modułem finansów biznes potrzebuje również modułu analitycznego, który będzie magazynem danych, danych niezbyt dokładnych, zsynchronizowanych z zewnętrznych źródeł, nie posiadających w sumie dokładnego schematu. Danych które biznes chciałby móc korygować w bardzo łatwy sposób, czy nawet wprowadzać z palca, jak tabelki w excelu. Przy takich systemach CQRS średnio się sprawdzi, Event Sourcing jeszcze mniej. Tutaj potrzeba bulk upsertów, potrzeba w zasadzie CRUD’a. Bez odpowiedniej separacji modułów, architektura części finansowej będzie narzucać części analitycznej pewne rozwiązania a być może nawet i w drugą stronę co byłoby zdecydowanie gorsze.
Sama separacja nie jest jakoś szczególnie trudna, można ją osiągnąć dbając tylko o izolację namespace’ów, bardzo pomocne w tym celu będą narzędzia typu Deptrac. Jest on w stanie na podstawie zdefiniowanych reguł powiedzieć czy gdzieś przypadkiem nie przekroczyliśmy granic namespace’ów. Możemy zdefiniować regułę, że namespace reprezentujący domenę modułu finansowego nie może wykorzystywać niczego z namespace’a modułu analitycznego. To, plus dyscyplina w kodzie polegająca na “nie robieniu niejawnych zależności pomiędzy modułami” w zasadzie jest wszystkim czego potrzebujemy. Pierwszy problem pojawia się w momencie kiedy jeden moduł wymaga danych z drugiego modułu a jest to w zasadzie nieuniknione.
Po co budować w systemie moduł analityczny, który nie miałby być wykorzystany przez moduł finansowy? Gdyby tak miało być to lepiej w ogóle nie budować tego jako moduł tylko osobny system.
Komunikacja między modułowa
Moduły mogą komunikować się pomiędzy sobą w sposób synchroniczny i asynchroniczny. Obydwa sposoby mają swoje wady i zalety, poruszę ten temat w kolejnym wpisie.
Załóżmy, że moduł finansowy obliczając coś w trakcie realizowania procesów finansowych potrzebuje czegoś z modułu analitycznego. Jakiejś porcji danych. Aby po nie sięgnąć moduł finansowych w ramach swojego namespece’a powinien zdefiniować interfejs reprezentujący moduł analityczny, do którego odwoła się w trakcie realizacji procesu. Ten prosty zabieg w zasadzie gwarantuje izolację oraz niezależność modułów. Pisząc testy jednostkowe dla modułu finansowego możemy śmiało mockować ten interfejs ponieważ nie jest istotne skąd pochodzą dane, istotne jest co to za dane. O izolacji testów między modułami również powstanie osobny wpis. Tworząc już właściwą implementację czy testy funkcjonalne, pod interfejs wystarczy podłożyć coś co ja nazywam implementacją binarną (nazwa trochę mało precyzyjna w kontekście PHP). Polega to na użyciu modułu analitycznego jako bezpośredniej implementacji interfejsu tego modułu w module finansowym. Na początku wspominałem, że modularny monolit to jeden z etapów rozwoju systemu. To bardzo ważne aby mieć to w tyle głowy projektując komunikację między modułowa.
Wszystkie punkty styku między modułami robimy w taki sposób jakbyśmy jutro mieli rozdzielić moduły na 2 niezależne serwisy.
Interfejs modułu analitycznego jest z poziomu tego założenia niezbędny. W przypadku podziału, niezależnie od tego jaki sposób komunikacji wybierzmy. Synchroniczny polegający na bezpośrednim requeście do api modułu analitycznego. Asynchroniczny, polegający na czytaniu z własnej projekcji danych analitycznych budowanych na podstawie asynchronicznych zdarzeń. Ten interfejs gwarantuje niezmienność i niezależność modułu finansowego. Jest on na ten moment jedynym punktem styku pomiędzy modułami, reprezentuje też zależność.
Moduł finansowy zależny jest od modułu analitycznego, moduł analityczny natomiast nie jest zależny od modułu finansowego.
Fasada Modułu
Wszelkie diagramy obrazujące modularny monolit wyglądają mniej więcej w ten sam sposób, kwadraciki reprezentujące moduły, zagregowane przez większy kwadrat reprezentujący monolit/system.
Reprezentacja ta jest dobra, problem jednak rodzi się kiedy poszczególne kawałki systemu są dość mocno niezależne, zebrane do kupy tylko w trakcie inicjalizacji jednak dalej dostępne niezależnie jako poszczególne elementy. Service Bus, Event Stream, jakiś generator Projekcji, Entity Manager. Te wszystkie elementy zebrane razem można już śmiało nazywać bazą modułu, czy nawet całego systemu (jeżeli mówimy o prostym monolicie). Zebranie ich razem jest bardzo istotne w kontekście modularnego monolitu. Do tego celu idealnie nadaje się wzorzec fasady, który ukrywa częściowo szczegóły związane z architekturą (implementacji) danego modułu tworząc jednocześnie dla niego przejrzysty i czytelny interfejs. Mając w systemie kilka modułów, wystarczy przeglądnąć ich fasady żeby zrozumieć co z grubsza robi dany moduł.
Fasady w implementacjach
Wspominałem wcześniej o implementacji binarnej dla interfejsu reprezentującego moduł. Teraz wiedząc już, że każdy moduł zbudowany jest za fasadą o wiele łatwiej jest sobie taką implementację wyobrazić, ponieważ polega ona na bezpośrednim wykorzystaniu fasady w celu dostępu do danych. Gdyby jednak dany moduł trzeba było wydzielić i zaimplementować jako sługę komunikującą się po http, implementację binarną dość łatwo można zamienić na implementację wykorzystującą protokół sieciowy.
Bardzo istotne w komunikacji między modułowej jest jednak zachowanie barier.Załóżmy, że moduł analityczny systemu posiada jakiś swój model danych, z których potrzebuje skorzystać moduł finansowy. Ten model reprezentowany jest oczywiście przez obiekty dostępne poprzez fasadę.
Nie wystarczy aby implementacja interfejsu reprezentującego moduł analityczny zwróciła te obiekty po odczytaniu ich z fasady. Implementacja musi je przepakować na obiekty ściśle skrojone pod moduł finansowy, będące zdefiniowane w ramach jego namespace’a.
Dlaczego? Aby uniknàć bardzo niebezpiecznego couplingu między modułami. Nie chodzi nawet o sytuację, w której decydujemy się rozdzielić moduły (moduły komunikujące się synchronicznie generalnie i tak ciężko się rozdziela). Przepakowując obiekty ograniczamy ilość miejsc w których należy będzie dokonać zmian w przypadku gdy z jakiegokolwiek powodu model modułu analitycznego ulegnie zmianie. Zachowujemy przy tym czystość barier a narzędzia pokroju Deptraca mogą robić swoją robotę bez hackowania miejsc w których ktoś jednak używa modelu z modułu analitycznego w module finansowym. Jedyny punkt styku to implementacja, która i tak powinna znajdować się w miejscu, którego reguły deptraca mogą być mniej ścisłe. W końcu moduł finansowy zależy od modułu analitycznego więc nie da się uniknąć reguły pozwalającej wykorzystać moduł analityczny przez finansowy w jakimś miejscu. Co jest w gruncie rzeczy dobre, czyni zależności jawnymi. Patrząc na reguły deptraca od razu widać, które moduły zależą od których i czy przypadkiem nie wkradło się gdzieś circular dependency mogące świadczyć o tym, że moduł trochę za bardzo spuchł.
Podsumowanie
Modularny monolit to jeden z etapów rozwoju oprogramowania, które podstawowe założenia nie są bardzo odmienne od założeń mikroserwisów. W budowaniu modularnego monolity bardzo pomocny jest wzorzec fasady, który pozwala organizować kod i zachowywać bariery. Istnieją narzędzia typu deptrac, które razem z odpowiednią dyscypliną ułatwią izolację modułów.
Polecam również oznaczyć fasadę jako final
, dlaczego? O tym w kolejnym wpisie, w którym
postaram się opisać temat izolacji modułów na poziomie testów.
Sporo informacji na temat modularnego monolit znajdziesz też w slajdach z prezentacji, które są dostępne jako przypięty post na moim twitterze @norbert_tech