Architektura warstwowa

Czym jest architektura warstwowa?

Architektura warstwowa jest jednym z najbardziej znanych i najczęściej wybieranych stylów architektonicznych. Polega ona na podzieleniu aplikacji na horyzontalne warstwy. Każda warstwa jest zależna od jednej lub kilku warstw znajdujących się poniżej (w zależności od tego czy dana warstwa jest otwarta czy zamknięta), ale nie jest zależna od warstwy znajdującej się wyżej. Najczęściej każda z warstw wystawia interfejs przez który kontaktują się z nią warstwy znajdujące się wyżej. Dzięki temu warstwy wyższe nie są zależne od implementacji, a jedynie od abstrakcji, która jest bardziej stabilna (rzadziej się zmienia).

Najczęściej spotykanym typem architektury warstwowej jest architektura trójwarstwowa. Warstwa pierwsza jest warstwą prezentacji w której znajdują się klasy odpowiedzialne za odbieranie zapytań i wysyłanie odpowiedzi lub za renderowanie interfejsu użytkownika. W warstwie drugiej znajdują się klasy odpowiedzialne za logikę biznesową. Natomiast warstwa trzecia odpowiada za zapis naszych danych. Znajdują się w niej repozytoria, mappery oraz kod odpowiedzialny za komunikacje z bazą danych.

Powyżej widzicie idealny przykład architektury warstwowej. Natomiast w rzeczywistości taka architektura wygląda mniej więcej tak:

Powodem tego jest to, że jeżeli pogrupujemy małe moduły w tego typu warstwy to każdy moduł z warstwy wyższej może się dostać do każdego modułu z warstw położonych niżej. Przez to moduł z warstwy pierwszej może np. zależeć od trzech modułów z warstwy drugiej. Prowadzi to do ogromnej liczby zależności co przekłada się na gorszą czytelność oraz testowalność i negatywnie wpływa na utrzymywalność kodu. Chcąc przetestować jakiś moduł zamiast jednego mocka będziemy musieli zrobić cztery. Tego typu podział jest z reguły powodowany tym, że mamy źle wydzielone granice modułów lub nie mamy ich w ogóle.

Jak temu zaradzić?

Po pierwsze powinniśmy podzielić nasz system na autonomiczne moduły i z naszego monolitu zrobić modularny monolit. Dzięki temu będziemy mogli zastosować architekturę warstwową per moduł, a nie system. Wtedy nasza architektura będzie wyglądać mniej więcej w taki sposób. Co więcej dzięki takiemu rozwiązaniu nasze moduły mogą posiadać różną ilość warstw.

Warstwy otwarte vs zamknięte

Warstwy zamknięte

Architektura warstwowa może posiadać warstwy zarówno otwarte jak i zamknięte. Warstwa zamknięta oznacza, że zapytanie nie może być przekazane w dół z pominięciem tej warstwy. Zawsze musi przez nią przejść. Odwołując się do naszego przykładu oznacza to, że warstwa pierwsza nie może się bezpośrednio komunikować z warstwą trzecią, ponieważ warstwa druga jest zamknięta. Dlaczego to jest problemem? Ponieważ jeśli nie mamy żadnej logiki biznesowej w naszej funkcjonalności to jedynym zadaniem warstwy drugiej będzie przekazanie zapytania do warstwy trzeciej. Przykładowo mamy funkcjonalność, która jedyne co robi to wyświetla dane z bazy danych. Nie ma w niej żadnej logiki biznesowej. Mimo to, będziemy musieli przejść przez warstwę drugą bo wymaga od nas tego konwencja. Przez co niepotrzebnie komplikujemy funkcjonalności. Plusem tego rozwiązania jest to, że dokładnie wiemy w jaki sposób nasze warstwy są zależne od siebie.

Warstwy otwarte

Przeciwieństwem warstw zamkniętych są warstwy otwarte. W tej konwencji bezproblemowo warstwa pierwsza może komunikować się z warstwą trzecią z pominięciem warstwy drugiej jeśli warstwa druga jest warstwą otwartą. To rozwiązanie na pierwszy rzut oka może się wydawać idealne jednak w cale takie nie jest. Minusem tego rozwiązania jest to, że w każdym miejscu droga naszych zależności będzie wyglądać nieco inaczej. Raz nasza warstwa będzie zależeć od warstwy drugiej, a innym razem bezpośrednio od warstwy trzeciej albo od obu na raz. Bez analizy szczegółów implementacyjnych nie jesteśmy w stanie tego określić. Przez to nasz kod staje się mniej czytelny oraz trudniejszy w testowaniu. Im więcej warstw będziemy mieli tym bardziej ten problem się nasili.

Preferowane podejście

Według mnie preferowanym podejściem jest używanie warstw zamkniętych. Dlaczego? Ponieważ dzięki nim mamy sztywno określone zależności przez co wiemy, że dana warstwa jest zależna tylko od warstwy znajdującej się niżej. Czyli co? Jeśli chcemy po prostu wyświetlić dane z bazy użytkownikowi to jesteśmy zmuszeni do przejścia przez warstwę logiki biznesowej, która w tym wypadku nic nie robi? Nie do końca. Tak jak Ci wcześniej wspomniałem architekturę powinno się stosować per moduł, a nie system. Dzięki temu jeśli zauważymy taką sytuacje w której fajnie by było użyć warstwy otwartej warto zastanowić się nad wydzieleniem takiej funkcjonalności do osobnego modułu odpowiedzialnego wyłącznie za odczyt (pomoże nam w tym wzorzec CQRS). W naszym nowym module zamiast robić trzy warstwy (prezentacji, logiki biznesowej, persystencji), możemy zrobić tylko dwie (prezentacji i persystencji). Dzięki temu nasza warstwa zawsze będzie zależeć od warstwy znajdującej się pod nią, a my nie będziemy musieli przepuszczać ruchu przez warstwę, która nic nie robi.

Spójność

Pewnie większość z Was zadała sobie teraz pytanie „A co ze spójnością?”. Większość osób powie, że w naszym oprogramowaniu powinniśmy zachować spójność, a przecież wprowadzanie różnej ilości warstw w poszczególnych modułach nie jest spójne. We wszystkim trzeba być pragmatycznym i znaleźć punkt równowagi pomiędzy spójnością a homogenicznością. Z jednej strony nie chcemy systemu, w którym każdy moduł jest w innym stylu architektonicznym lub w przypadku mikroserwisów w innej technologi bądź języku, ponieważ taki system jest trudny w utrzymaniu. Natomiast z drugiej strony nie chcemy systemu, który wszędzie będzie wyglądał identycznie. Dlaczego? Ponieważ różne problemy wymagają różnych rozwiązań i czasami ta sztywność systemu wywołana przez zgubne dążenie do spójności może nas znacząco ograniczać. Prowadzi to do tego, że implementacja danego rozwiązania może być trudna i czasochłonna tylko dlatego, że staramy się być spójni.

Moduły współdzielone

Częstym problemem przy tego typu architekturze są moduły współdzielone czyli moduły z których korzystamy w kilku miejscach (w różnych warstwach). Co zrobić z takimi rzeczami? Często nazywamy taki moduł common lub utils i trochę się go wstydzimy, ponieważ chcemy to rozwiązać jakoś inaczej, ale nie wiemy jak. Ale czy powinniśmy się go wstydzić? Moim zdaniem nie! Taki moduł jest narzędziem z którego korzystamy podobnie jak z wbudowanych obiektów czy metod danego języka bo przecież nie zastanawiamy się w której warstwie powinien się znajdować obiekt String czy Number. Zamiast bezmyślnie kopiować funkcje z jednej warstwy do drugiej i naruszać zasadę DRY możemy je wydzielić przez co znacznie uprościmy nasz kod. Ważne jest natomiast to aby w takim module rzeczywiście znajdował się kod, który jest generyczny oraz narzędziowy (pomagający nam w rozwiązywaniu powszechnych problemów), a nie stanowiący esencje logiki biznesowej, która powinna być przypisana do konkretnej warstwy.

Warstwy logiczne vs warstwy fizyczne

Zapewne nie raz zdarzyło Ci się słyszeć określenia takie jak tier oraz layer w odniesieniu do architektury warstwowej. W języku polskim tier oznacz warstwę fizyczną, natomiast layer warstwę logiczną. Zanim przejdziemy dalej warto zaznajomić się z tymi pojęciami. Niektórzy ludzie używają tych określeń na przemiennie natomiast jest między nimi zasadnicza różnica.

Warstwy logiczne (layers)

Kiedy dzielimy logikę aplikacyjną warstwy logiczne służą nam do organizowania funkcjonalności i komponentów. Tak jak w przypadku naszej przykładowej architektury podzieliliśmy naszą logikę na trzy warstwy. Warstwę prezentacji, logiki biznesowej oraz warstwę persystencji. Jest to przykład architektury trójwarstwowej. Natomiast jeśli dzielimy naszą aplikacje na więcej niż trzy warstwy, taką architekturę nazwiemy multi-layer architecture. Poszczególne warstwy nie koniecznie muszą być ulokowane na osobnych serwerach. Możemy mieć wiele warstw na tej samej maszynie.

Warstwy fizyczne (tiers)

Warstwy fizyczne (tiers) natomiast odnoszą się do architektury wdrożeniowej. Mówiąc o nich mamy na myśli osobne lokalizacje w których jest rozmieszczony nasz kod. Każda warstwa fizyczna musi znajdować się na osobnej maszynie. Odnosząc się do naszej przykładowej architektury, fizyczną warstwę prezentacji mogła by stanowić na przykład aplikacja webowa lub mobilna uruchamiana na telefonie czy w przeglądarce. Fizyczną warstwę logiki biznesowej stanowią natomiast serwery aplikacyjne, a warstwa fizyczna persystencji to po prostu baza danych. Kiedy aplikacja posiada wiele warstw fizycznych, wtedy nazywamy ją multi-tier architecture.

Należy jednak pamiętać, że niektórzy ludzie używają tych dwóch terminów zamiennie. Podczas rozmów w których różnica między tymi dwoma konceptami jest istotna, staraj się używać precyzyjnych określeń. Jeśli masz wątpliwości czy Twój rozmówca ma na myśli warstwy logiczne czy fizyczne po prostu zapytaj, aby uniknąć nieporozumień.

Ilość warstw

Ile warstw powinna mieć idealna architektura? Czy zawsze powinniśmy używać trzech warstw? Takie pytania często padają w odniesieniu do architektury warstwowej. Aby na nie odpowiedzieć musimy zadać kolejne pytanie. Co tak naprawdę dają nam warstwy i po co my je w ogóle robimy?

Zmniejszenie złożoności

Pierwszą rzeczą jaką daje nam podział na warstwy jest zmniejszenie złożoności. Wyobraźmy sobie sytuacje w której musimy napisać oprogramowanie do sterowania robotem w fabryce składającej samochody. Taki robot udostępnia nam jakiś sterownik, czyli API za pomocą którego możemy się z nim komunikować. Powiedzmy, że posiada on jedną metodę move, która przyjmuje trzy parametry x, y oraz z. Te parametry określają położenie naszego ramienia. Na metodzie move możemy zbudować warstwę w której określimy podstawowe ruchy, takie jak weź silnik z taśmy, weź koło, zamontuj silnik itd. Spójrzmy jak by to mogło wyglądać.

interface Driver {
    move(x: number, y: number, z: number): void;
}

class BasicMoves {
    private driver: Driver

    constructor(driver: Driver) {
        this.driver = driver;
    }

    public getEngine(): void {
        this.driver.move(10, 2, 30);
        this.driver.move(20, 40, 20);
        this.driver.move(30, 52, 15);
    }

    public getWheel(): void {
        this.driver.move(20, 32, 10);
        this.driver.move(10, 20, 50);
        this.driver.move(30, 52, 15);
    }

    public mountWheel(): void {
        this.driver.move(10, 20, 50);
        this.driver.move(30, 52, 15);
    }

    public mountEngine(): void {
        this.driver.move(20, 40, 20);
        this.driver.move(30, 52, 15);
    }

    // ...
}

Mając naszą nowo utworzoną warstwę na jej podstawie możemy stworzyć kolejną, która również zmniejszy nam poziom złożoności. Zamiast ciągłego wywoływania metod get i mount w celu zamontowania części możemy stworzyć metodę, w której weźmiemy i od razu zamontujemy daną część. Spójrzmy na przykład.

class RobotController {
    private basicMoves: BasicMoves;

    constructor(basicMoves: BasicMoves) {
        this.basicMoves = basicMoves;
    }

    public mountWheels(): void {
        for (let i = 0; i < 4; i ++) {
            this.basicMoves.getWheel();
            this.basicMoves.mountWheel();
        }
    }

    public mountEngine(): void {
        this.basicMoves.getEngine();
        this.basicMoves.mountEngine();
    }

    // ...
}

Na podstawie tej warstwy możemy stworzyć kolejną warstwę, w której za pomocą jednej metody będziemy mogli stworzyć gotowy samochód:

class CarFactory {
    private robotController: RobotController;

    constructor(robotController: RobotController) {
        this.robotController = robotController;
    }

    public makeSportCar(): void {
        this.robotController.mountEngine();
        this.robotController.mountWheels();
        // ...
    }

    public makeNormalCar(): void {
        this.robotController.mountEngine();
        this.robotController.mountWheels();
        // ...
    }
    
    // ...
}

Jak możesz zauważyć im więcej warstw posiadamy tym bardziej zmniejszamy poziom złożoności dzięki czemu użycie naszego kodu staje się o wiele prostsze.

Separacja odpowiedzialności

Kolejnym powodem dla którego stosujemy podział na warstwy jest chęć separacji odpowiedzialności. Wyobraźmy sobie sytuacje w której mamy stworzyć prosty CRUD. W nim nie ma żadnej logiki, a tym bardziej złożoności więc nie mamy czego zmniejszać, ponieważ w każdej warstwie będziemy mieć cztery metody create, update, delete oraz get. W tym wypadku warstwy stosujemy, aby podzielić naszą odpowiedzialność. Każda warstwa powinna spełniać pierwszą zasadę SOLID, jaką jest Single Responsibility Principle. Co nam to daje? Jeśli w przyszłości będziemy chcieli zmienić format zwracanych danych z JSON’a na XML’a to zmian będziemy musieli dokonać tylko w naszej warstwie prezentacji. Podobnie sytuacja będzie wyglądać w przypadku zmiany bazy danych. Zmian będziemy musieli dokonać tylko w warstwie persystencji pod warunkiem, że nie naruszymy naszego API przez które komunikuje się warstwa logiki biznesowej.

// Prezentacja
class Controller {
  constructor(
    private readonly service: Service,
  ) {}

  public create(...): void { ... }
  public delete(...): void { ... }
  public update(...): void { ... }
  public get(...): string { ... }
}

// Warstwa "logiki biznesowej"
interface Service {
  create(...): void;
  delete(...): void;
  update(...): void;
  get(...): string;
}

class ServiceImpl implements Service {
  constructor(
    private readonly repository: Repository,
  ) {}

  public create(...): void { ... }
  public delete(...): void { ... }
  public update(...): void { ... }
  public get(...): string { ... }
}

interface Repository {
  create(...): void;
  delete(...): void;
  update(...): void;
  get(...): string;
}

class RepositoryImpl implements Repository {
  public create(...): void { ... }
  public delete(...): void { ... }
  public update(...): void { ... }
  public get(...): string { ... }
}

Przykład architektury trójwarstwowej

W celu łatwiejszego zrozumienia architektury warstwowej przygotowałem dla Was prosty przykład, który możecie znaleźć pod tym linkiem: https://github.com/2048bits/1024bits-blog-resources/tree/artykul-architektura-warstwowa

Jest to przykład architektury trójwarstwowej, której diagram znajduje się poniżej.

Mamy tutaj aplikacje w której znajduje się jeden moduł o nazwie element. Jest w nim zastosowana architektura trójwarstwowa. W tym przypadku jest ona wykorzystywana do podziału odpowiedzialności. Znajdują sie tutaj trzy warstwy:

  • presentation – odpowiada za obsługę zapytań i odpowiedzi
  • business-logic – znajduje się w niej logika biznesowa
  • persistence – odpowiada za zapis oraz odczyt danych

Każda z warstw zawiera swoją abstrakcje, przez co warstwy, które z niej korzystają nie wiedzą nic na temat szczegółów implementacyjnych tej warstwy. Ułatwia to testowanie oraz podmiane szczegółów implementacyjnych w poszczególnych warstwach.

Zalety architektury warstwowej

Powszechnie znana

Istnieje wiele zalet korzystania z architektury warstwowej. Pierwszą zaletą jest to, że architektura warstwowa jest najbardziej popularna pośród developerów. Jest również jedną z prostszych architektur. Sprawia to, że rozwój oprogramowania jest prostszy i szybszy, ponieważ każdy developer rozumie jej założenia, a jeśli nie to w bardzo łatwy sposób można je wytłumaczyć.

Zmniejsza złożoność oraz umożliwia podział odpowiedzialności

Kolejną zaletą jest to, że wzorzec ten pomaga nam zmniejszyć złożoność oraz podzielić odpowiedzialność pomiędzy poszczególne warstwy. Każda z warstw jest niezależna, dzięki czemu można ją łatwo zrozumieć bez zagłębiania się w inne warstwy. Ale jak to niezależna? Przecież wcześniej pisałem, że warstwa jest zależna od warstw lub warstwy znajdującej się poniżej. Tak to prawda, ale dzięki abstrakcji nie jesteśmy zmuszani do poznawania szczegółów implementacyjnych. Dzięki czemu użycie warstwy znajdującej się poniżej jest banalnie proste. Ułatwia to również podmianę implementacji bez konieczności wprowadzania zmian w innych warstwach.

Ułatwia testowanie

Podział modułów na warstwy oraz używanie abstrakcji takich jak interfejsy oraz klasy abstrakcyjne do komunikacji pomiędzy warstwami umożliwia nam odizolowanie poszczególnych warstw podczas testów. Na przykład możemy testować warstwę logiki biznesowej bez warstwy prezentacji i persystencji. Dzieje się tak ponieważ warstwa logiki biznesowej nie jest zależna od warstwy prezentacji, a jako że bazujemy na abstrakcji to w łatwy sposób możemy za mockować lub za stubbować warstwę persystencji.

Ułatwia podział zadań

Podział odpowiedzialności ułatwia również rozdzielenie zadań pomiędzy zespoły lub członków zespołu. Każda z warstw jest w bardzo dużym stopniu niezależna od innych dzięki czemu osoba pracująca nad warstwą prezentacji, nie wpłynie na kod osoby pracującej nad warstwą logiki biznesowej. W drugą stronę zadziała to tak samo. Jeśli osoba dokonująca zmian w warstwie logiki biznesowej, nie zmieni kontraktu czyli abstrakcji, od której zależy warstwa prezentacji to również nie wpłynie na kod znajdujący się w tej warstwie. Ponadto każda z warstw z reguły wymaga innych umiejętności. Na przykład jeśli w warstwie prezentacji generujemy UI to świetnie się tam sprawdzi UI lub frontend developer. Natomiast warstwę logiki biznesowej oraz persystencji możemy zostawić backend developerowi. Dzięki takiemu podziałowi prace nad projektem idą zdecydowanie szybciej, ponieważ żadna z osób nie musi zagłębiać się w kod i technologie, które jej nie dotyczą.

Umożliwia ponowne wykorzystanie warstw

Podział na warstwy umożliwia ich ponowne wykorzystanie. Na przykład jeśli stworzyliśmy aplikację webową, a nasz CEO poprosił nas o stworzenie wersji mobilnej to zmiany, których będziemy musieli dokonać dotkną jedynie warstwę prezentacji. Warstwę logiki biznesowej oraz persystencji będziemy mogli ponownie wykorzystać, ponieważ będą działać dokładnie tak samo.

Umożliwia wdrożenia poszczególnych warstw na różnych serwerach

Poszczególne warstwy w bardzo łatwy sposób można wdrożyć na różnych serwerach. Dzięki takiemu podziałowi zyskujemy dodatkowe benefity:

  • Zmniejszamy koszty skalowalności – Możemy niezależnie skalować poszczególne warstwy, przez co nie jesteśmy zmuszeni do skalowania całej aplikacji lub modułu, a jedynie warstwy która tego aktualnie wymaga.
  • Zwiększamy dostępność – Jeśli każda z warstw jest wdrożona na kilku instancjach to w przypadku błędu aplikacja nadal będzie działać poprawnie. Ponadto jeśli na przykład warstwa prezentacji dla aplikacji webowej będzie nie dostępna, to nie wpłynie ona na działanie aplikacji mobilnej.
  • Zwiększamy bezpieczeństwo – Możemy użyć firewalla do komunikacji pomiędzy poszczególnymi warstwami. Dzięki czemu możemy na przykład zablokować zewnętrzny dostęp do bazy danych.
  • Jeśli warstwy logiczne mogą być ponownie użyte oznacza to, że dokładnie to samo możemy zrobić z warstwami fizycznymi.

Wady architektury warstwowej

Warstwy nie są w 100% autonomiczne

Pomimo tego, że mamy separacje odpowiedzialności czasami zmiany mogą dotknąć kilku warstw jednocześnie. Na przykład dodanie nowego pola wymaga zmiany zarówno w warstwie prezentacji, logiki biznesowej jak i persystencji. Jeśli nowa zmiana zmieni nam kontrakt, czyli abstrakcje za pomocą której komunikujemy się pomiędzy warstwami to zmiany w warstwie persystencji, która powinna być tylko detalem wpłyną również na logikę biznesową, która powinna stanowić core naszej aplikacji.

Nadmierna ilość kodu

Kolejną wadą jest to, że podział aplikacji na warstwy zmusza nas do pisania większej ilości kodu. Dla każdej z warstw musimy zdefiniować abstrakcje oraz logikę, która umożliwi komunikację pomiędzy warstwami. Ponadto jeśli stworzymy zbyt wiele warstw, to może się okazać, że niepotrzebnie skomplikujemy naszą aplikację.

Problemy z podziałem na wiele warstw fizycznych

Podział na warstwy fizyczne, wprowadza wiele problemów.

  • Wolniejsze zapytania – Zapytanie musi przejść przez wszystkie warstwy co go znacząco spowalnia, szczególnie jeśli nasze serwery nie znajdują się w sieci lokalnej.
  • Konieczność obsługi błędów – Sieć nie jest nie zawodna, przez co musimy być gotowi na obsługę błędów, które spowoduje.
  • Zwiększony poziom skomplikowania – Będziemy musieli użyć dodatkowych narzędzi oraz technologii co jest kosztowne oraz czasochłonne.

Utrudniona testowalność

Pomimo tego, że korzystamy z abstrakcji oraz tego, że warstwy niższe są niezależne od warstw wyższych to istnieją inne rodzaje architektur, które są znacznie łatwiejsze w testowaniu. Na przykład w architekturze heksagonalnej w celu przetestowania logiki biznesowej nie jesteśmy zmuszani do mockowania.

Podsumowanie

Architektura warstwowa jest jednym z prostszych oraz najbardziej popularnych wzorców. Świetnie nadaje się do prostych aplikacji. Ponadto można ją łączyć z innymi wzorcami na przykład w celu zmniejszenia złożoności. Jednak należy być pragmatycznym i nie implementować jej tylko dlatego, że jest łatwa i powszechna. Należy sobie zdawać sprawę zarówno z jej zalet jak i wad, ponieważ nie jest to architektura, która rozwiąże nam wszystkie problemy.

Źródła:
Design It!
Software Architect’s Handbook