Moduły w systemie są od siebie zależne. Żaden z nich nie działa w pełnej izolacji. Zależności te mogą mieć różny charakter. Celem nie jest całkowita eliminacja tych zależności. Celem jest odpowiednie zarządzenie nimi. Wybór konkretnego stylu integracji i redukcja zależności tam gdzie nie są one wymagane. Tam gdzie jest to możliwe. adekwatna organizacja powiązań pozwoli kontrolować tę całą plątaninę i uniknąć nieprzemyślanego bałaganu. Niekoniecznie chodzi o to, żeby przenieść zależność w inne miejsce, czy co gorsza ją ukryć. Naturalnie, moduły są łatwiejsze w okiełznaniu, kiedy mają wyższy stopień autonomii. Ale poziom powiązania (coupling) nie będzie jedynym czynnikiem sterującym decyzją podczas wyboru stylu komunikacji międzymodułowej. Warto pomyśleć też o złożoności danego rozwiązania. Jak proste, albo jak trudne, jest wdrożenie i utrzymanie konkretnej opcji. W tym materiale będę się głównie odnosił do tych dwóch uniwersalnych: powiązania i złożoności. Po staropolsku coupling i complexity. W Waszych projektach być może trzeba będzie wziąć pod uwagę jeszcze kilka innych motywatorów.
Istnieją trzy sposoby komunikacji między modułami w monolicie:
- Współdzielenie danych (Shared data).
- Bezpośrednie wywołanie (Direct call).
- Powiadamianie – komunikacja sterowana zdarzeniami (Messaging/Event-driven).
Nie da się jasno powiedzieć, który z nich jest najlepszy. Każdy ma swoje wady i zalety. Jak zwykle, wszystko jest kontekstowe. W tym materiale spróbuję pokazać Wam z czym to się je. Skupię się na tym: czym są te wzorce, kiedy i jak ich używać oraz z jakimi konsekwencjami się to wiąże.
Celowo nie chcę porównywać modularnego monolitu do mikroserwisów. Bo to nie jest o tym. Pozwólcie tylko, iż podzielę się dwoma przemyśleniami.
Jeżeli nie potraficie zrobić dobrze modularnego monolitu to nie zrobicie dobrze mikroserwisów.
Łatwiej uczyć się modularyzacji na monolicie, bo znacznie więcej wybacza i prościej jest wycofać się z błędnych decyzji.
Wszystkie 3 style pokażę w oparciu o przykład okrojonego monolitu z dwoma modułami: rekrutacja i pracownicy. Na razie nie wchodzą one ze sobą w żadną interakcję. Każdy z nich ma proste operacje typu: stwórz, pobierz, edytuj czy usuń.
Współdzielenie danych
Ten wzorzec, jak sama nazwa wskazuje, polega to na współdzieleniu danych. Bardzo odkrywcze. Wiele modułów może mieć dostęp do tych samych danych. Istnieje transakcyjne, pojedyncze źródło prawdy, więc dane są zawsze spójne. Z jednej strony prawie każdy moduł wymaga źródła danych, więc wydaje się iż infrastrukturalnie wszystko jest załatwione. Z drugiej strony wymusza to użycie tego konkretnego źródła danych dla wszystkich modułu. W literaturze spotkacie też nazwę Shared Database, choć myślę iż jest ona trochę niefortunna i może wprowadzić w błąd. Można przecież współdzielić bazę danych, ale mieć podział logiczny na poziomie schematu. Wtedy wcale to nie jest ten rodzaj komunikacji. Nie uważam, iż każdy moduł powinien mieć osobną bazę danych. Wystarczającym rozwiązaniem może okazać się podział schematu, gdzie każdy moduł powinien mieć własne tabele lub kolekcje (w zależności od typu źródła).
Rozwiązanie to jest dość proste w implementacji, a próg wejścia jest niski. jeżeli chodzi o powiązanie to jest ono ogromne. Występuje podział na poziomie logicznym, ale brakuje podziału na poziomie danych. Baza jest spłatana. Wszystko rozmawia ze wszystkim. Co więcej, couplingu nie widać pierwszy rzut oka. Wiadomym jest, iż moduł może współdzielić stan, ale żeby sprawdzić co dokładnie jest wykorzystywane trzeba się mocniej wgryźć. Istnieje najgorszy rodzaj powiązania, czyli ukryte powiązania. Każda zmiana w strukturze danych powinna być traktowana jako potencjalnie niekompatybilna wstecznie. Nie ma też łatwego sposobu wersjonowania. Brakuje jasno zdefiniowanych granic przez co trudniej jest wyłapać ewentualne błędy w modularyzacji.
Wszystko to blokuje szybki rozwój i negatywnie wpływa na utrzymanie w długim terminie. Zmiana w jednym module może rozlać się na pozostałe moduły. Brakuje stabilnego interfejsu. Analogicznie, kiedy robicie integracje z zewnętrznymi systemami, zamiast dawać bezpośredni dostęp do bazy danych, raczej wystawiacie stabilne API za którym można ukryć szczegóły implementacyjne. Tak zwana enkapsulacja. Wspólny model dla wielu konceptów prowadzi też do tego, iż model staje się nieczytelny i posiada wiele nadmiarowych danych. Pod kątem skalowania nie jest to najlepsze rozwiązanie.
Jeżeli chodzi o plusy to system posiada spójne dane, których nie trzeba synchronizować i duplikować. Współdzielenie danych jest szybkie, zarówno jeżeli chodzi o implementację jak i wydajność. Nie wymaga żadnych dodatkowych mechanizmów.
Wracając do przykładu. Widzicie na obrazku, iż jest jedna tabela reprezentująca użytkownika, która ma kilka kolumn. W rzeczywistości miałaby ich pewnie więcej, ale wiadomo, iż dla czytelności, musiałem to okroić. Jest więc identyfikator, imię, nazwisko, adres e-mail pracownika, ale też adres e-mail z rekrutacji. On zwykle będzie inny, jako iż w trakcie rekrutacji komunikacja odbywa się przy użyciu prywatnego adresu, a po zatrudnieniu pracownik otrzymuje adres firmowy. Jest też ocena, którą kandydat uzyskał na etapie rekrutacji.
I to jest przykład raczej z tych, jak tego nie robić. Teoretycznie dwie niezwiązane ze sobą operacje mogą powodować konflikty lub, co gorsza, nadpisywanie danych. o ile już mowa o współdzieleniu danych to i tak warto zastosować odpowiedni podział. Wcześniejszy model użytkownika można podzielić na dwa bardziej specjalistyczne: kandydat i pracownik. W tym przykładzie akurat jest tak, iż moduł rekrutacji coś zapisuje w module pracownika, ale z niego nie czyta. Za to moduł pracownika wręcz odwrotnie. Czyta, ale nie zapisuje. Oczywiście jest to pewne założenie, a sama relacja między modułami mogłaby wyglądać inaczej.
W momencie, gdy kandydat zostaje przyjęty do pracy staje się pracownikiem, więc leci operacja tworzenia pracownika. Za to moduł do zarządzania pracownikami, w jakimś procesie, potrzebuje pobrać jego ocenę nadaną podczas rekrutacji. To z kolei jest powód pojawienia się kolumny applicant_id, która jest referencją. o ile mowa o relacyjnej bazie danych i stylu komunikacji opartym o współdzielone dane to może to być bazodanowa relacja. Wtedy chociaż z poziomu bazy widać, co od czego zależy.
Bezpośrednie wywołanie
To rozwiązanie wydaje się bardzo intuicyjne. Zresztą, podobnie jak pierwszy styl, ma też swoje mocne miejsce w monolitach, których nie śmiałbym nazwać modułowymi, Bliżej im do wielkiej kuli błota. W tym rozwiązaniu nie chodzi jednak o to, by bezrefleksyjnie wstrzykiwać obojętnie jaki kod jednego modułu do innych modułów. Chodzi raczej o wystawienie stabilnego interfejsu, który ukrywa szczegóły implementacyjne. Pokazuje na zewnątrz możliwie jak najmniej informacji. Dzieli się tylko tym, co jest niezbędne. W przypadku monolitu będzie to taka fasada modułu.
W tym wzorcu cała ta operacja zamyka się w ramach jednego procesu w pamięci. Ma to swoje plusy. zwykle jest to więc rozwiązanie synchroniczne. Nic nie stoi na przeszkodzie, żeby API modułu przyjęło żądanie i obsłużyło je później w ramach innego procesu. Ten styl komunikacji gwarantuje większą kontrolę nad danymi. Osobne, dedykowane źródła danych są w stanie zapewnić izolację danych. Nie ma więc relacji do tabel z innych modułów. Powinno się też unikać transakcji zawierających dane z więcej niż jednego schematu danych. Rozproszona transakcyjność bywa trudna. Może czasem warto rozważyć jedną wielką transakcję obejmującą swoim zakresem operacje z kilku modułów. Naturalnie, trzeba wiedzieć jakie to ma konsekwencje i używać tego świadomie. Powoduje to znacznie większy coupling.
Ten rodzaj integracji w najbardziej klarowny sposób definiuje zależności między modułami. Może choćby być kontrolowany przez narzędzia do statycznej analizy kodu. Samo rozwiązanie jest dość łatwe w implementacji i utrzymaniu. Direct Call jest łatwy w rozwijaniu, a każda zmiana w kodzie jest bezpośrednio widoczna w innych modułach. Na etapie statycznej analizy kodu wyłapane zostaną ewentualne błędy związane z nowym kontraktem. Dodatkowo, chyba najprościej się je debuguje. Nie ma tutaj też mowy o żadnym pośredniku jak baza danych czy broker wiadomości.
Jeżeli chodzi o wady to na pewno kwestia skalowalności może tutaj kuleć. Łatwość konfiguracji może też prowadzić do pokusy, iż zaraz wszystko od wszystkiego będzie zależało. I skończy się jak zwykle. Co więcej, błąd w jednym module może powodować iż cała operacja uda się tylko połowicznie lub zakończy się błędem.
Wracając do przykładu. Widać iż dwie tabele w bazie danych nie są już ze sobą powiązane. przez cały czas istnieje referencja w postaci applicant_id, ale nie jest to już bazodanowy klucz obcy. Nie ma bazodanowej relacji. Każdy moduł pisze i czyta tylko w ramach swoich własnych tabel. Dla uproszczenia może to być wspólna baza danych z oddzielnymi schematami. Ale mogą to być też osobne bazy danych. Można zacząć od wspólnej bazy danych, a o ile faktycznie przestrzega się podziału to wyniesienie konkretnego modułu do osobnej bazy w późniejszym etapie będzie trywialne.
Jeżeli chodzi o komunikacje to ma ona miejsce w samym kodzie. Moduły rozmawiają przez bezpośrednie wywołanie dostępnych metod. W tym przykładzie byłyby to: komenda „stwórz pracownika” i zapytanie „pobierz ocenę rekrutacyjną dla kandydata”. zwykle wszystko dzieje się w ramach jednego procesu – synchronicznie. Nic nie stoi na przeszkodzie, żeby żądania przyjąć i zrealizować je w tle. Wtedy oczywiście trzeba wziąć na klatę wszystkie konsekwencje przetwarzania asynchronicznego.
Napisałem, iż to kontrakt powinien być stabilny i udostępniać tylko te funkcje, które są niezbędne. Dopracowując ten diagram, powinien on wyglądać tak jak poniżej. Moduł wystawia swoją fasadę nad którą ma pełną kontrolę. Nie ma możliwości wywoływania metod modułu z innego poziomu.
Komunikacja dzięki zdarzeń
To metoda, która odwraca zależność między modułami. Moduł publikuje zdarzenie, iż coś zadziało się w jego obrębie. Pozostałe moduły decydują, czy są zainteresowane tą wiadomością i wchodzą z nią w interakcję bądź nie. Do momentu, gdy odbywa się to w ramach jednego procesu w pamięci to zmienia się tylko kierunek couplingu. Jego poziom jest mniej więcej taki sam jak wcześniej. o ile któryś moduł spowoduje błąd to wpłynie to na logikę z innych modułów i cały proces może zakończyć się niepowodzeniem.
Messaging jest dużo bardziej skomplikowany, niż dwa poprzednie style. Trudno jest zrozumieć zależności między modułami, a kaskadowe przetwarzanie zdarzeń może utrudniać debugowanie. o ile chodzi o synchroniczną komunikacje zdarzeniami to przez cały czas istnieje opcja gwarancji transakcyjności. Przetwarzanie synchroniczne kończy się reakcją modułów na zdarzenie jeden po drugim, a te przecież mogą generować kolejne zdarzenia. W pewnym momencie może się okazać, iż w jednym procesie dzieje się już tak dużo, iż trwa on zbyt długo lub brakuje pamięci. W komunikacji zdarzeniami dochodzi jeszcze kwestia sekwencyjności niektórych procesów. Trzeba się upewnić, iż kolejność zostanie zachowana lub jej ewentualny brak zostanie obsłużony. Podejście oparte o zdarzenia ma też duży plus o ile chodzi o testowalność. Dużo łatwiej testuje się kod, który ma wysoki stopień autonomii.
W momencie wprowadzenia asynchroniczności sytuacja się komplikuje i dochodzą nowe problemy, ale bardzo dużo można zyskać. Monolit komunikujący się zdarzeniami staje się bardziej skalowalnym rozwiązaniem. Tyle iż dochodzi spójność końcowa… Obietnica, iż dane prędzej czy później będą spójne. Ale nie ma gwarancji, iż są spójne w tym momencie. Można za to liczyć na zrównoleglanie pracy, a to przekłada się na lepszą skalowalność. Wprowadzenie szyny zdarzeń wymaga dodatkowej infrastruktury, która trzeba utrzymywać.
Przechodząc do przykładu. Zamiast poleceń wykonywanych bezpośrednio, teraz to moduł sygnalizuje, iż kandydat został przyjęty. Na tej podstawie, moduł odpowiedzialny za zarządzanie pracownikami, który jest zainteresowany tym zdarzeniem, tworzy pracownika. Zmienia się więc kierunek zależności. Tym samym, akurat dla tej konkretnej sytuacji, udało się wyeliminować dwukierunkową zależność. Teraz już tylko moduł Employee zależy od modułu Recruitment. Reakcja na zdarzenie odbywa się w pamięci, więc powiązanie też jest przez cały czas silne.
Można je rozluźnić wprowadzając brokera wiadomości. Wtedy sprzężenie jest dużo mniejsze, ale znowu rozwiązanie to jest dużo bardziej skomplikowane. I wymaga kilku dodatkowych mechanizmów. Można pomyśleć o hybrydzie: część robić w pamięci, a część przez brokera. Generalnie to co widzicie na tym diagramie już bardzo mocno przypomina architekturę aplikacji rozproszonej. Tyle iż w monolicie zapytanie następuje poprzez wywołanie w pamięci, a nie po sieci. Dodatkowo modularny monolit jest pojedynczą jednostką wdrożeniową. Wyciągnięcie takiego modułu na zewnątrz, chociażby w celu skalowania, nie powinno być bardzo skomplikowane.
Style komunikacji międzymodułowej – który wybrać?
Podsumowując, w tym materiale przedstawiłem Wam 3 style komunikacji międzymodułowej w modularnym monolicie. W praktyce są one ze sobą łączone.
Polecam szczególną uwagę zwrócić na dwukierunkowe relacje. Mogą sugerować, iż zostały błędnie wyznaczone granice. Możliwe, iż dwa moduły powinny zostać złączone w jeden lub brakuje jeszcze jednego modułu pomiędzy nimi. Naturalnie, nie zawsze jest tak, iż dwukierunkowa relacja jest błędem.
Integracja przez współdzielone dane, mimo iż wydaje się najmniej rozsądnym podejściem, dla wielu projektów może być wybawieniem. Podział logiczny na poziomie kodu też może wnieść wartość, a może być pierwszym krokiem. Poza tym trzeba pamiętać, iż nie każdy pracuje na zielonym poletku i często trzeba myśleć o rozwiązaniach dających się wpasować w brązowe projekty. o ile już decydujecie się na to rozwiązanie to rekomenduję coupling tylko na poziomie odczytów. Wtedy oczywiście nie wystarczy tylko i wyłącznie ten wzorzec. Potrzebne będzie też użycie innego z dwóch pozostałych. Wyobraźcie sobie, iż musicie zrobić listę, która zawiera dane z kilku modułów. Dodatkowo: sortowanie, filtry i paginację. Nie ma się co oszukiwać, można by było to wszystko opierdzielić jednym zapytaniem. Co prawdopodobnie byłoby najszybsze i najbardziej wydajne. Zbieranie tego ze wszystkich modułów może być bardzo trudne albo wręcz niewykonalne. Oczywiście, możliwe iż da się inaczej zaprezentować ten widok. Albo problemy wynikają z błędnego podziału modułów. Dla osób, które mają większe doświadczenie i rozumieją wszystkie wymienione wzorce, sugeruję wystrzegać się integracji poprzez współdzielone dane.
Kuszące jest robienie wszystkiego dzięki zdarzeń, ale tej drogi też nie rekomenduję. Wyobraźcie sobie moduł powiadomień, który reaguje na zdarzenia ze wszystkich innych modułów i wysyła odpowiednie powiadomienia. Moduł ten jest pospinany z każdym innym. Każda zmiana powoduje, iż trzeba też dokonać zmiany w module powiadomień. Aby temu zapobiec, wystarczy wystawić stabilny interfejs fasadowy z metodą „wyślij powiadomienie”. Taki moduł nie wymaga żadnych zmian związanych z modyfikacjami w innych modułach. To one wiedzą jakie powiadomienia chcą wysłać. Moduł powiadomień przyjmuje żądanie, robi swoją robotę i ewentualnie informuje, czy operacja się udała.
Finalnie, dobrze jest więc znać wszystkie trzy style komunikacji międzymodułowej w modularnym monolicie, a ja zalecam sięgnięcie po połączenie dwóch: Bezpośrednie wywołanie i Zdarzenia.
Wpis Modularny monolit – 3 style komunikacji między modułami pojawił się pierwszy raz pod Koddlo.