Wróć do strony głównej
Angular

Microfrontendy w Angularze: Przyszłość skalowalnych aplikacji front-endowych

Architektura mikrofrontendów jest już od dłuższego czasu dość dobrze znana. To bliźniacze podejście do doskonale znanej architektury mikroserwisów, stosowanej często po stronie backendowej. 

Nasze aplikacje w większości składają się z przeróżnych modułów biznesowych. Tworząc sklep musimy zadbać o stronę startową, stronę produktów, ich szczegóły, koszyk, panele klienta i sprzedawcy. W podejściu mikrofrontendów nasz sklep składałby się z 6 osobnych mniejszych aplikacji. Każda z nich mogłaby być rozwijana przez osobny team i wdrażana niezależnie od siebie. Następnie każda z tych “małych” cząsteczek aplikacji, nazywana również “remote”, składana jest w całość w tak zwanego shella lub hosta. Z tego podejścia najczęściej korzystają duzi gracze na rynku posiadający ogromne aplikacje. Dodatkowo, każdy z mikrofrontendów może stanowić dla klienta osobny produkt, który może sprzedać. Daje to większą swobodę w działaniu. 

Schemat poniżej idealnie przedstawia jak mogłaby wyglądać struktura teamów przy pracy nad takim projektem. Jak możecie zauważyć, każdy team posiada własną aplikację frontendową. 

Prawdziwą rewolucję w tym temacie wprowadziło Module Federation, o którym swego czasu było bardzo głośno w społeczności Angulara. Module Federation znacząco uprościło tworzenie aplikacji w architekturze mikrofrontendów i zoptymalizowało ładowanie zależności. Jednakże niosło to również pewne ograniczenia, to znaczy – Webpack. Module Federation wymaga użycia Webpacka. Obecnie na pierwszy plan wysuwają się nowoczesne bundlery takie jak esbuild, który zadebiutował w Angular i daje niesamowite przyśpieszenie budowania aplikacji, lub vite. Na ratunek przychodzi nam Native Federation, technologia “prawie” taka sama, lecz w tym przypadku ‘prawie’ robi wielką różnicę. Native Federation jest framework i build agnostic czyli działa niezależnie od używanego frameworka i buildera. W tym wypadku łączenie aplikacji napisanych w Angular, React lub Vue staje się niezwykle proste. 

Modularny monolit vs mikrofrontendy

Niektórzy na pewno zadadzą mądre pytanie – ale na co to komu? 

Obecnie spora część aplikacji wykorzystuje architekturę nrwl/nx, która daje możliwość tworzenia wielu aplikacji w jednym repozytorium. W dodatku możemy tworzyć biblioteki, które umożliwiają podzielenie aplikacji na konteksty biznesowe oraz –  chyba największy plus modularnego monolitu – możliwość wykorzystania istniejącego już kodu z innej aplikacji. Wydawałoby się, że jest to architektura idealna. Otóż nx nie wyklucza wykorzystania mikrofrontendów. W dodatku oferuje wygodny schematics, który całą konfigurację stworzy za nas.

Jednak mikrofrontendy wykorzystywane są w trochę innych sytuacjach. Tak jak wspominałem na początku artykułu, mikrofrontendy używane są przy budowie dużych aplikacji. W przypadku tworzenia bardzo złożonych systemów separacja kodu może być zaletą, w takim przypadku deweloper skupiony jest na swojej części aplikacji, nie musi się martwić, że zmiana komponentu nieoczekiwanie negatywnie wpłynie na działanie innej części aplikacji. Z takiego rozwiązania korzysta na przykład Allegro.

Kiedy warto rozważyć mikrofrontendy?

Dobrze, ale w jakich przypadkach dobrze jest rozważyć użycie mikrofrontendów? Przede wszystkim w aplikacjach z wieloma różnymi kontekstami biznesowymi. Spora część tworzonych przez nas aplikacji jest po prostu za mała, aby wykorzystać w nich mikrofrontendy, no bo po co rozbijać projekt na tak małe kawałki? Jednak jeśli tworzymy platformę, która będzie posiadała bardzo dużo różnych kontekstów biznesowych, i które dodatkowo mogą być wartością jako osobne aplikacje, z pewnością warto takie podejście rozważyć. Zwłaszcza jeśli daje to dodatkowe możliwości biznesowe. Podejście mikrofrontendów będzie bardzo pomocne w sytuacji korzystania z różnych technologii używanych podczas tworzenia aplikacji. Jeśli z jakiegoś powodu zdecydujemy się na napisanie modułu naszej aplikacji na przykład w React, dołączenie takiego modułu nie będzie stanowić problemu. Problem będzie stanowić jednak co innego – zarządzanie stanem i wymiana informacji pomiędzy modułami – ale o tym opowiemy zaraz. W mojej opinii całkiem dobrym pomysłem na wykorzystanie mikrofrontendu jest też budowa systemu ui, który jest utrzymywany przez osobny team.

Wymiana danych, zarządzanie stanem

Większość aplikacji webowych wymaga jakiejś formy zarządzania stanem i komunikacji między poszczególnymi częściami aplikacji. Najprostszym przykładem jest przechowywanie danych na temat obecnie zalogowanego użytkownika. W przypadku rozwijania aplikacji w architekturze modularnego monolitu nie stanowi to żadnego problemu. 

W data-access’ie auth przechowywujemy tokeny i dane użytkownika. Selektory udostępniają dane, których potrzebują komponenty lub interceptor. Używając mikrofrontendów nie jest to już takie oczywiste. Aby rozprawić się z tym problemem możemy stworzyć osobną bibliotekę publikowaną w prywatnym rejestrze, która będzie przechowywać i udostępniać dane do poszczególnych mikrofrontendów.

Wróćmy do przykładu ze sklepem internetowym. Bardzo dobrym przykładem na komunikację pomiędzy mikrofrontendami jest dodawanie produktu do koszyka. Jeśli założymy że koszyk jest osobnym mikrofrontendem (co akurat jest dość częstą praktyką) to po dodaniu produktu do koszyka z poziomu szczegółów produktu, który jest również osobny mikrofrontendem, należałoby zaktualizować liczbę elementów wyświetlanych przez koszk. Komunikacja w takim przypadku może odbywać się za pomocą eventów.

Oczywiście dla zapewnienia typowania dobrze jest zrobić w takiej bibliotece metody, które będą te eventy wysyłać, pilnować typów i udostępniać metody do nasłuchiwania na eventy. Bardzo podstawowy przykład możecie zobaczyć poniżej.

Jak na pewno zauważyliście, jest to rozwiązanie Angularowe. Problem pojawi się jeśli w naszej aplikacji używamy różnych technologi do tworzenia mikrofrontendów. Jeśli jeden mikrofrontend używa Reacta to nie będzie w stanie z takiego rozwiązanie korzystać. W takiej sytuacji powinniśmy zapewnić “plain-javascript’owe” rozwiązanie.

Dlaczego nie warto?

Jakie ryzyka niesie za sobą to podejście?

Pierwsze z czym jako developer możemy się spotkać w pracy z mikrofrontendami to dość dużo w porównaniu do np. nrwl/nx prób wejścia. Przede wszystkim jesteśmy zależni od współdzielonych zależności. 

Na przykład UI system, który stworzony jest w Angularze 15 może nie współpracować zbyt dobrze z aplikacją pisaną w Angularze 17. W takim przypadku jesteśmy uzależnieni od innego teamu co powoduje – można powiedzieć – konflikt interesów, ponieważ jeśli potrzebujemy nowych feature’ów z najnowszych wersji Angulara i mamy przestrzeń, aby robić regularne updaty, to niekoniecznie chcielibyśmy czekać, aż inny team zrobi to samo. 

Zarządzanie stanem i wymiana danych pomiędzy wspólnymi częściami aplikacji również może przysporzyć sporo kłopotów. Choć komunikacja z wykorzystaniem eventów jest dość prosta, to z czasem rozwoju projektu może być po prostu skomplikowana i trudna do debugowania. Do komunikacji pomiędzy aplikacjami mogą być przydatne również kolejki,

które nie są dostępne domyślnie. Oczywiście można stworzyć własną implementację kolejek ale to zajmuje czas. 

Dodatkowo nasza wspólna biblioteka shared nie może rosnąć w nieskończoność. Jeśli chodzi o bibliotekę shared to jest również podatna na wiele zmian w obrębie różnych teamów, a to może powodować niecelowe afektowanie naszej aplikacji przez kogoś z “zewnątrz”, co może powodować nieoczekiwane bugi. Idealny przykład tego ilustruje poniższe zdjęcie. Konieczne jest określenie granic współdzielonych zasobów. Nie zawsze chcemy żeby każdy team mógł zmienić nasze dane lub nadpisać je swoimi. Wymaga to dużego narzutu organizacyjnego i sporej dyscypliny.

Aplikacje mikrofrontendowe wymagają więcej uwagi ze strony devopsów. CI/CD, wszystkie pipeliny trzeba skonfigurować w każdym microfrontendzie, co pochłania czas. Podobna sytuacja jest z deploymentami. Choć końcowo po poprawnym skonfigurowaniu deploymentu mikofrontendów nasz czas deploymentu skraca się, to poprawna konfiguracja wymaga czasu i jest dość złożona. Narzut infrastrukturalny związany z serwerami różnież jest w przypadku mikrofrontendów znacząco wyższy. Jeśli chodzi o proces budowania aplikacji – finalny rozmiar aplikacji mikrofrontendowych jest znacznie wyższy niż monolitu. 

Dodatkowym negatywnym czynnikiem podczas tworzenia aplikacji bazującej na mikrofrontendach jest wymiana wiedzy pomiędzy teamami. W rozproszonych teamach nie jest to proste. Wiele osób może nie wiedzieć o problemach i rozwiązaniach używanych w innych mikrofrontendach. To powoduje niespójność kodu w obrębie całego systemu. 

Jako programiści dużą uwagę przywiązujemy do wygody naszej pracy i Developer Experience. W podejściu mikrofrontendów DX jest niestety dość zaburzony. Debugowanie aplikacji może sprawiać problemy związane z wymianą danych pomiędzy różnymi aplikacjiami jak i również wymuszone jest na nas uruchomienie wielu aplikacji w jednym czasie. 

Demo

Spróbujmy teraz stworzyć prostą aplikację z wykorzystaniem Native Federation, która będzie zawierać zaledwie jeden mikrofrontend, jednak będzie to wystarczające, żeby zobaczyć ładowanie zależności i tworzenie routingu.

Zacznijmy od stworzenia naszego projektu – niech będzie to początkowo zwykły angularowy projekt, który nie posiada żadnych aplikacji. Możemy to osiągnąć dodając atrybut —no-create-application po ng new: 

Następnie wygenerujmy dwie aplikacje. Jedna będzie nazywać się shell, a druga users.

ng generate application shell

ng generate application users

Kolejnym krokiem będzie instalacja schematicsa, który stworzy konfigurację Native Federation za nas. Schematics stworzony jest przez znanego w angularowym comunity Manfreda Steyera. Paczkę do instalacji możemy znaleźć pod tym linkiem

<span style="font-weight: 400;">npm i @angular-architects/native-federation

W tym momencie nadszedł czas na dodanie konfiguracji naszych aplikacji. Schematics od Angular Architects załatwia ten temat za nas. Nic prostszego, wystarczy tylko wykonać te dwa polecenia: 

Jak pewnie zauważyliście, po wykonaniu powyższych poleceń schematics utworzył nowy plik w każdej z aplikacji – federation.config.js. Wygląda on tak: 

W obiekcie shared możemy zdefiniować, które zależności mikrofrontend chce współdzielić z innymi. Na przykład jeśli używamy w naszym mikrofrontendzie Ngrx’a, możemy go współdzielić z innym mikrofrontendem co zoptymalizuje nam ładowanie zależności po przejściu do innej części aplikacji. Natomiast tablica skip definiuje, które zależności nie mogą być współdzielone.

Jeśli chodzi o właściwości singleton, strictVersion i requiredVersion. 

Opcja singleton ustawiona na true spowodouje że wersja zależności będzie ładowana tylko przy raz, przy starcie naszej aplikacji.

StrictVersion wymusza użycie tylko jednej wersji jakiejś zależności. Na przykład – jeśli w jednym mikrofrontendzie używamy angular material w wersji 17.0.0 a w drugim 17.1.0 to przy ustawieniu strictVersion na true aplikacja nie będzie działać poprawnie. W takim ustawieniu wersje muszą się zgadzać. Jeśli zmienimy wartość tego parametru na false, aplikacje jedynie powiadomi nas w konsoli, że używamy różnych wersji zależności a native federation samo ustali której wersji używać. 

Parametr requiredVersion wyznacza zakres wersji, które mogą być używane w danym mikrofrontendzie. Może to być zakres wersji na przykład Angular 16.1 do Angular 17.1, może być to również wartość auto. W takim przypadku Native Federation samo dostosuje najwyższą kompatybilną wersję zależności.

Za definiowanie mikrofrontendów w shellu odpowiada plik federation.manifest.json

Natomiast konfiguracja naszego remote’a wygląda następująco:

Plik wygląda niemalże identycznie jak w przypadku hosta. Dodatkowo posiada pola name, które określa nazwę mikrofrontendu i exposes – obiekt określający które, komponenty/moduły exportuje mikrofrontend. 

Dobrze, konfigurację mamy już za sobą. W takim razie jak stworzyć routing. Sprawa tutaj jest bardzo prosta. 

Wygląda to całkiem przyjaźnie, prawda? Na pierwszy rzut oka nie różni się zanacząco od zwykłego lazy loadingu. Jedyna różnica to </span><span style="font-weight: 400;">loadRemoteModule.

Jak wygląda ładowanie zależności i mikrofrontendu w konsoli?

Tak wyglądają requesty w zakładce network w konsoli. W środkowej części możemy zauważyć, że ładują się takie zależności jak angular-platform-browers, angular-core, rxjs itd.

Nasza druga aplikacja używa tych samych zależności więc chcielibyśmy żeby były one współdzielone. Dzieje się to automatycznie. Po przejściu do mikrofrontendu users w konsoli widzimy tylko załadowany komponent i zależności, których nie używa shell czyli na przykład inne czcionki.

Podsumowywując. Podejście mikrofrontendów nie należy do najprostszych. Z całą pewnością mogę powiedzieć, że próg wejścia jest dużo wyższy niż w przypadku modularnego monolitu. Zastosowania tych dwóch podejść są jednak różne. Mikrofrontendy stosuje się do bardzo rozproszonych systemów. NativeFederation z pewnością ułatwia nam pracę z mikrofrontendami, załatwia sporo rzeczy za nas np. optymalizację ładowania zależności. Mimo wszystko sama świadomość czym są, do czego służą mikrofrontendy zwiększy naszą świadomość o tworzeniu oprogramowania a być może pomoże w przyszłości.

O autorze

Mateusz Basiński

Angular Developer w House of Angular. Fifa Team Leader, w wolnym czasie Angular Developer. Ciekawy nowych rozwiązań i technologii.

Zapisz się do naszego newslettera. Bądź na bieżąco z najnowszymi trendami, poradami, meetupami i stań się częścią społeczności Angulara w Polsce. Rynek pracy docenia członków społeczności.

3 komentarzy

  1. Wojciech

    Fajny artykuł 🙂

    Takie “share’owane” eventy można by stworzyć jako oddzielną bibliotekę, wrzucaną na wewnętrznego NPMa, skąd wszystkie aplikacje mogłyby “zaciągać” informacje o nich. Każda zmiana = podbicie wersji 🙂

    Dodałbym tylko, że eventy powinny być wyrażane w formie przeszłej – są informacją o czymś, co już się zadziało. Zatem nie “AddProductEvent”, tylko “ProductAdded” itd. 😉

    • Dzięki 🙂 Zgadzam się z obydwoma punktami. Osobna biblioteka na pewno by się sprawdziła 🙂 Niestety podnoszenie wersji po każdej zmianie może być dość uciążliwe podczas dewelopmentu. Zarządzanie komunikacją i wyminą danych pomiędzy mikro frontendami to okropnie szeroki temat. Może nawet na kolejny artykuł 🙂

  2. Mirek

    Przepraszam, że trochę włożę kij w mrowisko, ale mam trochę na pieńku z ideą “microfrontendową”.

    Jak to ktoś kiedyś powiedział w teorii nie ma różnicy między teorią a praktyką… A w praktyce jest 😉

    W praktyce projekt fronendowy ciężko traktować na równi z microserwisem backendowym

    Microserwis backendowy skupia się na udostępnieniu konkretnej funkcji. Nie ma w założeniu rozwiązywać całych problemów biznesowych.
    Frontend/UI w założeniu to coś co umożliwia użytkownikowi osiągnąć konkretną wartość biznesową w łatwy sposób z wykorzystaniem UI.

    A co za tym idzie apka najczęściej spina kilka microserwisów, nawiguje pomiędzy wieloma funkconalnościami, często mixując wiele rzeczy naraz, które od siebie są zależne.
    Problemy z nawigacją czy komunikowanie się niezależnych “komponentów” między sobą jest trudne do osiągnięcia w microfrontendach a jeszcze trudniejsze w utrzymaniu.

    Osobiście wzoruję się na idei Google Workspace od Google.
    Dzielimy więc naszego monolita frontendowego na wiele możliwie małych aplikacji działających i hostowanych niezależnie (mogą być pisane w różnych technologiach)
    Elementy “globalne” np. top menu, logo, profil można załatwić osobno hostowanymi webcomponentami, dzięki czemu nie mamy problemu z ciągłym budowaniem wszystkich aplikacji, jeżeli zmieni się menu.

    Zero problemów, zero złożoności.
    A to co jest najfajniejsze to zero ograniczeń w tworzeniu nowych wersji appek z wykorzystaniem nowych technologii.
    Czy to nie jest idea microserwisów? 🙂

Leave a Reply

Your email address will not be published. Required fields are marked *