Blog

02 czerwca 2016 Wojciech Zawistowski

Elastyczna struktura projektu w Angularze 1.5 (architektura “fraktalna”) [“NG 1.5 z placu boju” 4/7]

Elastyczna struktura projektu w Angularze 1.5 (architektura “fraktalna”) [“NG 1.5 z placu boju” 4/7]

Ten post to 4-ta część serii “Angular 1.5 z placu boju”, prezentującej architekturę opartej na komponentach, “gotowej na NG 2” aplikacji w Angularze 1.5, nad którą pracujemy.

Wprowadzona w Angularze 1.5 metoda module.component() może wydawać się kosmetycznym dodatkiem, jednak podejście, które ta metoda promuje (a które wykorzystujemy “do oporu”) owocuje zupełnie innym typem architektury niż opisywana w większości “klasycznych” tutoriali do Angulara, mam więc nadzieję, że uda Ci się na bazie naszych doświadczeń paru rzeczy dowiedzieć.

Spis Treści:

W poprzednich dwóch artykułach opisywałem koncepcyjną strukturę aplikacji w Angularze 1.5: jak podzielić aplikację na komponenty oraz jak komponenty się ze sobą komunikują. Teraz nadszedł czas na omówienie fizycznej organizacji aplikacji opartej na komponentach: struktury plików i katalogów.

Spojrzenie z góry: struktura aplikacji

istnieją dwa główne sposoby organizacji aplikacji:

  • podział według warstw technicznych, z katalogami najwyższego poziomu takimi jak components, services albo filters
  • podział według funkcjonalności (czasami określany jako pod architecure), z katalogami najwyższego poziomu takimi jak todos, tags albo users

struktura aplikacji zorganizowana według funkcjonalności ma kilka zalet:

  • aplikację łatwiej zrozumieć
  • łatwiej jest odszukać różne rzeczy
  • łatwiej jest podzielić aplikację (np. na oddzielne serwisy)

Zobacz też doskonały post Boba Martina, który nazywa to “krzyczącą architekturą” (screaming architecture) i, w typowym dla siebie stylu, w niezwykle celny sposób podsumowuje zalety takiej architektury.

My także jesteśmy zwolennikami podziału aplikacji według funkcjonalności i w ten właśnie sposób porządkujemy naszą aplikację.

moduły Angulara świetnie przystają do funkcjonalności najwyższego poziomu

W naszej aplikacji, katalogi najwyższego poziomu odpowiadają naszym głównym obszarom funkcjonalnym. Każdy taki katalog jest oddzielnym, autonomicznym modułem Angulara:

  • jawnie określa wszystkie swoje zależności (takie jak np. ngRoute, ngAnimate czy ngMessages)
  • definiuje w pełni swoją konfigurację (swoje własne bloki angular.module().config() i angular.module().run())
  • zawiera swoją własną, wewnętrzną konfigurację routingu

Celem jest osiągnięcie takiej samowystarczalności modułów, żeby dało się minimalnym nakładem pracy wydzielić moduł do niezależnej aplikacji.

wewnątrz modułu stosujemy podział według warstw technicznych

Moduł zawiera następujące katalogi:

Oraz następujące pliki najwyższego poziomu:

  • plik index.config.js, zawierający statyczną konfigurację modułu (uruchamianą poprzez angular.module().config())
  • plik index.run.js, zawierający runtime-ową konfigurację modułu (uruchamianą poprzez angular.module().run())
  • oddzielny plik index.routes.js, zawierający routing modułu (technicznie rzecz biorąc, routing jest częścią statycznej konfiguracji modułu, uruchamianej poprzez angular.module().config(), jednak jest on na tyle koncepcyjnie niezależną częścią konfiguracji, że umieszczenie go w osobnym pliku jest uzasadnione)
  • główny plik index.module.js, określający zależności modułu oraz deklarujący komponenty, serwisy, filtry i stałe

Oczywiście nie wszystkie moduły muszą zawierać wszystkie wymienione pliki i katalogi. Powyższa lista to maksymalny zestaw, ale jak najbardziej możliwe są moduły nie zawierające np. filtrów (i w efekcie katalogu filters) albo np. nie potrzebujące statycznej ani runtime-owej konfiguracji (i w efekcie nie zawierające plików index.config.js ani index.run.js).

pojedynczy moduł nadrzędny (app) agreguje wszystkie moduły najwyższego poziomu

Nadrzędny katalog app zawiera takie same 4 pliki jak w przypadku modułów (index.config.js, index.run.js, index.routes.js oraz index.module.js). Nie zawierają one jednak własnej logiki, działają jedynie jako agregatory:

  • jedynymi zależnościami zdefiniowanymi w pliku index.module.js na poziomie aplikacji są inne moduły; nie definiuje on nigdy swoich własnych zewnętrznych zależności
  • jedyną route-ą, zdefiniowaną w pliku index.routes.js na poziomie aplikacji, jest route-a domyślna (.otherwise({ redirectTo: '/some_route' })); wszystkie pozostałe route-y są zdefiniowane wewnątrz modułów
  • zazwyczaj na poziomie aplikacji nie jest w ogóle potrzebny plik index.run.js, a plik index.config.js na poziomie aplikacji zawiera jedynie konfigurację Angulara, taką jak debugEnabled, nie zawiera natomiast żadnej konfiguracji biznesowej

Ponadto, katalog app nie zawiera żadnych pod-katalogów poza modułami (nie ma własnych komponentów, serwisów, config-u itp.).

kompletna struktura aplikacji wygląda jak poniżej:

[app]
    [todos]
        [components]
        [config]
        [enums]
        [filters]
        [services]
        [spec_utils]
        [utils]     
        index.config.js
        index.module.js
        index.routes.js
        index.run.js
    [tags]
        ...
    [users]
        ...
    [auth]
        ...
    [shared]
        ...
    index.config.js
    index.module.js
    index.routes.js
    index.run.js

specjalny moduł shared (i inne nie-funkcjonalne moduły)

W powyższym przykładzie kompletnej struktury aplikacji mogłeś oprócz modułów funkcjonalnych takich jak todos albo tags zauważyć również moduł shared. W tym specjalnym, nie-funkcjonalnym module, przechowujemy kod reużywalny pomiędzy kilkoma modułami (Komponenty Atomowe, współdzielone helpery dla testów, niektóre uniwersalne serwisy i filtry itp.).

Katalog shared jest najbardziej typowym przykładem modułu nie-funkcjonalnego, obecnym w praktycznie każdej aplikacji. Aplikacje mogą mieć jednak także inne tego typu moduły. Na przykład w naszej aplikacji mamy też moduł env, w którym trzymamy konfigurację dla różnych środowisk na które deploy-ujemy naszą aplikację oraz logikę wykrywającą aktualne środowisko.

Co istotne, wszystkie moduły, zarówno funkcjonalne jak i nie-funkcjonalne, mają taką samą strukturę katalogów i plików i z czysto technicznego punktu widzenia są identyczne.

architektura “fraktalna”

Ponieważ wszystkie moduły mają zunifikowaną strukturę, są samowystarczalne i zaprojektowane do tego, by można je poziom wyżej agregować, otwiera się przed nami ciekawa opcja: możemy powielić taką samą strukturę także wewnątrz modułu, tak jak poniżej:

[app]
    [module_with_submodules]
        [sub_module_1]
        [sub_module_2]
        ...
        [sub_module_n]
        [shared]
        index.config.js
        index.module.js
        index.routes.js
        index.run.js        
    [module_without_submodules]
        [components]
        [config]
        [enums]
        [filters]
        [services]
        [spec_utils]
        [utils]     
        index.config.js
        index.module.js
        index.routes.js
        index.run.js
    [shared]
    index.config.js
    index.module.js
    index.routes.js
    index.run.js

Powyższy przykład pokazuje jedynie pojedynczy poziom sub-modułów, możemy jednak mieć tyle poziomów, ile tylko chcemy (sub-moduł może zawierać sub-sub-moduły itd.). Nazywamy to “architekturą fraktalną”. Taka architektura jest bardzo elastyczna i pozwala podzielić Twoją aplikację w taki sposób, w jaki tylko zechcesz.

Nie powinieneś jednak tego potencjału nadużywać. Naszym zdaniem najlepiej utrzymywać strukturę katalogów jak najbardziej płaską, wprowadzając sub-moduły jedynie gdy jakiś moduł staje się naprawdę ciężki do utrzymania (a nawet wtedy uważamy, że powinieneś w pierwszej kolejności rozważyć podział modułu na dwa lub więcej modułów najwyższego poziomu). Sądzimy też, że mimo iż jest możliwe by moduł zawierał równocześnie zarówno sub-moduły jak i niskopoziomowe elementy takie jak komponenty, moduł staje się łatwiejszy w utrzymaniu jeśli zawiera albo komponenty ALBO sub-moduły, nigdy jedne i drugie na raz.

Spojrzenie z dołu: struktura komponentu

W pierwszej części artykułu omówiłem jak podzielić aplikację na moduły. Moduły to jednak nie wszystko. Zawierają on także elementy niższego poziomu: komponenty, serwisy i filtry. Te elementy niższego poziomu są zazwyczaj rozbite na kilka plików. Jaki jest najlepszy sposób uporządkowania tych plików?

komponenty także powinny być samowystarczalne (tak jak moduły)

Istnieją dwa sposoby organizacji niskopoziomowych plików:

  • można je umieścić w katalogach podzielonych względem typu plików (templates, styles, specs itd.)
  • można trzymać wszystkie pliki związane z danym komponentem w katalogu tego komponentu

Preferujemy drugą opcję: trzymanie wszystkich części składowych komponentu razem, w jednym katalogu. (Oczywiście odnosi się to także do serwisów, filtrów itd.)

Fragment takiej struktury może wyglądać jak poniżej:

[app]
    [todos]
        [components]
            [todo_list]
                todo_list.component.js
                todo_list.html
                todo_list.scss          
                todo_list.spec.js
            [add_todo_form]
                ...
        [services]
            [todos_notifications]
                todos_notifications.service.js
                todos_notifications.spec.js
                notification_formatter.js
        ...

Zwróć uwagę, że wszystkie powiązane pliki są trzymane razem – nie tylko standardowe elementy komponentu albo serwisu, ale także wszystkie pliki pomocnicze, takie jak np. notification_formatter.js (“czysta” funkcja pomocnicza, służąca do generowania odpowiednio sformatowanych komunikatów powiadomień).

trzymanie wszystkiego razem ma dwie istotne zalety:

  • łatwiej jest pracować nad komponentem (łatwiej jest wyszukiwać i nawigować po wszystkich powiązanych z komponentem plikach, mniej jest skakania pomiędzy katalogami)
  • łatwiej jest przenieść komponent do innego modułu, uwspólnić go albo wydzielić do innej aplikacji

izolowanie komponentów także na poziomie CSS-a

Warto nadmienić, że idea samowystarczalnych komponentów może sięgać dalej niż tylko struktura plików i katalogów. Podobną izolację można (i powinno się) osiągnąć także na poziomie CSS-a, poprzez namespacing wszystkich klas CSS per komponent.

Jedną z popularnych metodyk, które to umożliwiają – i którą wykorzystujemy w naszym projekcie – jest metodyka BEM (Block Element Modifier). Komponentyzacja CSS-a to jednak szeroki temat, nie będę się więc w niego bardziej w tym poście zagłębiał.

Wnioski

Przedstawiona w tym artykule struktura projektu jest elastyczna, czysta i łatwo przeszukiwalna. Po kilku miesiącach rozwoju aplikacji w takiej architekturze, możemy z przekonaniem stwierdzić, że jest to właściwa droga i z całą pewnością wykorzystamy podobną architekturę w naszym kolejnym projekcie.

Jednakże, nauczyliśmy się też, że w pracy z “fraktalną” architekturą niezbędna jest właściwa równowaga. Możliwe jest zarówno wypracowanie zbyt płaskiej struktury (co czyni ją trudną do przeglądania) jak i struktury zbyt głębokiej (co czyni ją ciężką do refaktoryzacji).

Co Ty o tym sądzisz?

W jaki sposób Ty organizujesz swoje Angularowe (lub inne) aplikacje? Co sądzisz o strukturze opisanej powyżej? Podziel się swoją opinią w komentarzach poniżej!

Zobacz na blogu

09.09.2022
Marcin Jahn
It’s Not Just HTTP It’s Not Just HTTP

In today’s world of cloud-based solutions, distributed systems, and microservices-based architectures, network communication is a...

23.08.2022
Adam Mrowiec
Konferencja IPC 2022 Berlin Konferencja IPC 2022 Berlin

Pandemia wreszcie się kończy, dlatego w tym roku postanowiliśmy wrócić do naszych wyjazdów na konferencje....