Wprowadzenie
Monolit
Obecnie najpopularniejszymi wzorcami architektonicznymi dla struktury aplikacji są monolit i mikroserwisy. W przypadku monolitu nasz system stanowi jedna duża aplikacja. Jezeli przeczytacie dokumentację Symfony czy Laravela, to pokazują one właśnie jak tworzyć aplikację opartą o monolit. Nie ma najczęściej jasno wydzielonych granic pomiędzy funkcjonalnościami. Zależności wewnątrz aplikacji są używane swobodnie w ramach wzorca MVC. Kontroler może używać dowolnych serwisów. Serwisy mogą zależeć od dowolnych innych serwisów. Takie podejście sprawdza się w mniejszych projektach. Pozwala szybko tworzyć prototypy, nie narzuca zbyt wielu reguł których programista musi pilnować. Niestety, wraz z rozwojem projektu jego utrzymanie może stawać się coraz trudniejsze. Zależności porozrzucane po całym projekcie utrudniają układanie w głowie i śledzenie przepływów danych w aplikacji. Dowożenie nowych funkcjonalności staje się coraz trudniejsze. Skalowanie oznacza tworzenie nowych instancji całej aplikacji, nawet jeżeli tylko jej niewielki fragment wymaga większych zasobów.
Mikroserwisy
Z drugiej strony mamy podejście oparte o mikroserwisy. W tym przypadku system składa się z wielu niezależnych, mniejszych aplikacji. Serwisy komunikują się ze sobą poprzez zdefiniowane api, lub asynchronicznie przy użyciu kolejki, np. RabbitMQ. Podejście mikroserwisowe posiada wiele zalet. Każda aplikacja jest zdecydowanie mniejsza, ma jasną odpowiedzialność. Pozwala to na łatwiejszy rozwój i utrzymanie kodu. W przypadku testowania możemy ją traktować jak zamkniętą skrzynkę. Posiada jasno zdefiniowane wejście i wyjście w postaci udostępnionego API. Nad aplikacją może pracować wiele zespołów opiekujących się konkretnymi serwisami bez wchodzenia sobie w drogę. Każdy mikroserwis możemy oddzielnie skalować wedle potrzeb. Oczywiście stosowanie mikroserwisów stawia przed programistą również wiele wyzwań. Komunikacja przez sieć czy kolejkę jest zawodna. Serwisy muszę wiedzieć jak się odnaleźć. Musimy uzgadniać api pomiędzy serwisami.
Modularny monolit
Modularny monolit jest podejściem które stara się połączyć zalety obydwu rozwiązań. Koncepcja zakłada stworzenie jasno odseparowanych modułów w ramach aplikacji monolitycznej. Wyznaczenie granic możemy osiągnąć poprzez określenie api modułu z którego inne moduły będą mogły korzystać. Jednocześnie wykorzystując inny moduł należy ukryć jego implementację za interfejsem. Daje nam To wiele korzyści normalnie związanych z korzystaniem z mikroserwisów. Mamy jasną odpowiedzialność modułów, brak przeciekania zależności pomiędzy modułami czy możliwość niezależnej pracy nad nimi. Dużą zaletą takiej budowy aplikacji jest możliwość znacznie prostszego przejścia na pełną architekturę mikroserwisową, niż przy tradycyjnym monolicie. Jednocześnie unikamy problemów normalnie związanych z komunikacją między serwisami.
Jak można zgadnąć podejście to niesie ze sobą również pewne wady. Wymaga od nas wprowadzenia dodatkowych abstrakcji, a co za tym idzie złożoności w projekcie. Wymaga również pewnego poziomu duplikacji struktur i danych w projekcie aby zapewnić niezależność między modułami i brak wzajemnych zależności.
Budowa modułów
Jak odkrywać moduły?
To chyba jedno z najtrudniejszych pytań związanych z podejściem modularnym. Jak podzielić nasz kod na funkcjonalne kawałki, które będą spójne logicznie i posiadać akurat taki zakres funkcjonalności aby mogły samodzielnie funkcjonować. I niestety nie ma, a na pewno ja nie znam łatwej odpowiedzi na to pytanie. Ne pewno trzeba się przygotować na to, że zwłaszcza na początku granice naszych modułów będą płynne i będziemy je zmieniać. Jedne moduły będą wchłaniać inne gdy zauważymy że są ze sobą mocno powiązane i praktycznie przy każdej operacji muszą ze sobą gadać. Kiedy indziej będziemy dzielić moduły na mniejsze gdy zauważymy że ląduje w nich zbyt wiele funkcjonalności i zaczyna nam się z nich rodzić tradycyjny monolit. Generalnie każdy moduł powinniśmy traktować właśnie jako mały monolit, ale o jasno zdefiniowanej odpowiedzialności i rozmiarach pozwalających na łatwe utrzymanie i wprowadzanie zmian. Przypomina to bardzo sposób w jaki myślmy o klasach w projektowaniu obiektowym. Moduły znajdują się jeden poziom abstrakcji wyżej.
Szukając granic modułów można również sięgnąć do pojęcia kontekstów ograniczonych (bounded context) z DDD. Ogólnie rzecz ujmując kontekst ograniczony określa pewien fragment domeny biznesowej w której wszystkie pojęcia są jasno zdefiniowane i każdy rozumie je tak samo. W innych kontekstach te same nazwy pojęć mogą oznaczać odmienne modele dostosowane do kontekstu w którym funkcjonują. Na przykład w kontekście katalogu sklepu internetowego sklepu obuwniczego wszystkie rozmiary jednego modelu buta będą prezentowane jak warianty jednego produktu, natomiast w kontekście złożonego zamówienia każdy rozmiar będziemy traktowali jako odmienny produkt. Zachęcam do pogłębienia swojej wiedzy w tym temacie, myślę że wnosi ona dużo wartości i pogłębia nasze myślenie o funkcjonowaniu biznesu.
Struktura projektu Symfony
Na potrzeby wpisu posłużę się popularnym przykładem domeny ecommerce. Standardowo w Symfony, w katalogu src możemy znaleźć podział na kontrolery, encje i różnego rodzaju serwisy:
- src
- Controller
- Entity
- Repository
- Form
- …
W przypadku podziału na moduły, w katalogu src umieścimy katalogi reprezentujące nasze moduły a w nich strukturę odpowiadającą standardowemu układowi. Tak jakbyśmy chcieli każdy moduł traktować jako pełną oddzielną aplikację. Zdefiniujmy dwa moduły reprezentujące katalog produktów oraz koszyk w naszej aplikacji:
- src
- ProductModule
- Controller
- Entity
- Repository
- …
- CartModule
- Controller
- Entity
- Repository
- …
- ProductModule
Dodatkowo w każdym module powinna znaleźć się klasa opisująca api modułu, oraz katalog informujący o zależnościach modułu, z definicjami interfejsów i ich implementacjami. Na przykładzie klasa Api to oczywiście ProductModuleApi, oraz widzimy również zdefiniowaną jedną zależność dla modułu Product – CartInterface, która posłuży nam później do zaprezentowania komunikacji między modułami:
- ProductModule
- Controller
- Entity
- Repository
- Dependency
- CartInterface.php
- ProductModuleApi.php
Z powyższej struktury możemy wywnioskować że nasz moduł będzie potrzebował do działania implementacji interfejsu Cart reprezentującego koszyk aby pozwolić na dodawanie produktów do koszyka.
Konfiguracja Symfony
Jeżeli chodzi o konfigurację to głównie musimy wskazać gdzie doctrine ma szukać mapowania encji jeżeli korzystamy z annotacji:
doctrine:
mappings:
ProductModule:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/ProductModule/Entity'
prefix: 'App\ProductModule\Entity'
alias: ProductModule
CartModule:
is_bundle: false
type: annotation
dir: '%kernel.project_dir%/src/CartModule/Entity'
prefix: 'App\CartModule\Entity'
alias: CartModule
Oraz zaimportować kotrolery w serwisach, jeżeli nie rozszerzamy klasy bazowej kontrolera:
services:
# controllers are imported separately to make sure services can be injected
# as action arguments even if you don't extend any base controller class
App\Controller\:
resource: '../src/Controller/'
tags: [ 'controller.service_arguments' ]
App\ProductModule\Controller\:
resource: '../src/ProductModule/Controller/'
tags: [ 'controller.service_arguments' ]
Wykorzystanie modułów, oraz komunikacja między modułami
Zależy nam na tym aby oddzielić moduły wyraźną granicą oraz ograniczyć zależności między nimi do minimum. Powoduje to że komunikacja między modułami wymaga od nas trochę pracy i zdefiniowania odpowiednich abstrakcji oraz dodatkowego narzutu w kodzie i na początku może wydawać się nieintuicyjna. Dostajemy w zamian wyraźne korzyści w postaci ułatwionego modyfikowania kodu. Dzięki enkapsulacji modułów enkapsulujemy również zmiany ograniczając ich 'rozlewanie’ się po kodzie, co jest wartością wręcz nie do przecenienia.
Komunikacja
Jak już wcześniej pisałem, moduł powinien definiować swoje publiczne api w klasie 'module’, podobnie jak klasa definiuje swoje publiczne api za pomocą metod publicznych. Chcemy żeby jedyną zależnością jaką inne moduły mogły posiadać do naszego modułu jest właśnie klasa 'module’. Wszelkie pozostałe szczegóły powinny zostać przed nimi ukryte. Inne moduły będą wykorzystywać zachowania udostępnione w klasie 'module’ do realizacji swoich celów. Nie powinny jednak robić tego bezpośrednio, ale zgodnie z zasadą odwrócenia zależności (dependency inversion principle, D w SOLID), ukryć tą zależność za interfejsem. Dzięki temu jedyna zależność pomiędzy modułami będzie występować w implementacji tego interfejsu.
Przyjmijmy, że moduł Product potrzebuje możliwości dodawania produktów do koszyka. Zdefiniujemy więc zależność która będzie określała potrzebne zachowanie:
namespace App\ProductModule\Dependency
interface CartConnectorInterface
{
public function addItemToCart($productId, $quantity, $price): void;
}
Następnie tworzymy implementację tego interfejsu:
namespace App\ProductModule\Dependency
class CartConnector implements CartConnectorInterface
{
public function __construct(private CartModule $cartModule)
{
}
public function addItemToCart($productId, $quantity, $price): void
{
$this->cartModule->addToCart($productId, $quantity, $price);
}
}
Jak widać, dzięki temu modułu produkt nie interesuje w jaki sposób realizowana jest operacja dodawania produktu do koszyka, musi jedynie dostarczyć wymagane dane.
Podobnie w przypadku zwracania danych z modułu nie powinniśmy zwracać ich w postaci klas z modułu, a przepakować je np. do tablicy i w takiej postaci zwrócić. Moduł korzystający z tej funkcji powinien następnie utworzyć na tej podstawie obiekty klas które są dla niego zrozumiałe.
Może się pojawić również pytanie, co w przypadku gdy formularz służący np do tworzenia produktu mamy w innym module (np. w module admin). Gdy chcemy skorzystać z komponentu formularzy Symfony mamy problem, ponieważ nie chcemy tworzyć jawnej zależności do encji produkt w module admin. Aby rozwiązać ten problem możemy utworzyć obiekt typu DTO reprezentujący produkt w module admin i z nim spiąć formularz. Następnie normalnie wywołać operację z konektora na podstawie danych z obiektu DTO.
Baza danych
W przypadku budowania aplikacji o koncepcję modularnego monolitu pojawi się również kwestia tego jak w takim wypadku powinna być modelowana baza danych. Czy pomiędzy tabelami z różnych modułów powinny istnieć relacje? Wydaje mi się, że nie, tworzyłoby to silne sprzężenie wewnątrz modułów. Dopuszczalne jest natomiast moim zdaniem tworzenie zapytań obejmujących tabele z różnych modułów, ale bez jawnie zdefiniowanych połączeń na poziomie bazy danych. Inną opcją jest postępowanie podobne jak w przypadku pełnej architektury mikroserwisowej i duplikowanie potrzebnych do zapytań danych między serwisami. Minusem w tym wypadku jest na pewno duplikacja danych i konieczność ich synchronizacji. Po stronie plusów znajdzie się wydajność takiego rozwiązania.
Co dalej?
Na koniec parę linków które mogą pomóc zgłębić temat:
https://zawarstwaabstrakcji.pl/20200630-modular-monolith-wprowadzenie/ – więcej o koncepcji modularnego monolitu.
https://ebookpoint.pl/ksiazki/czysta-architektura-struktura-i-design-oprogramowania-przewodnik-dla-profesjonalistow-robert-c-martin,czarch.htm – kultowa pozycja wujka Boba. Nie dotyczy dokładnie koncepcji modularnego monolitu, ale można się z niej dużo dowiedzieć o sensie wyznaczania granic w projekcie i ukrywania implementacji za interfejsami.
https://bettersoftwaredesign.pl/episodes/8 – więcej o kontekstach ograniczonych.
[…] Mój poprzedni wpis dotyczący tematu architektury systemu poświęcony modularnym monolitom: http://adamzajac.info/2021/08/06/modularny-monolit-w-symfony/ […]