clean architecture we frontendzie

Wyjdź poza ramy dokumentacji. Clean Architecture w aplikacji frontendowej

Kiedyś to było, a teraz jest wszystkiego więcej. Szczególnie we frontendzie. To już od wielu lat nie są czasy wstawiania jQuery i scripts.js na dole <body>. Strony internetowe (ich warstwa kliencka), to często duże, skomplikowane, naładowane różnoraką logiką aplikacje. Nie ma sensu się tu rozpisywać, kto uprawia ten sport dłużej niż rok, to wie, o co chodzi.

Szczególnie przytłoczony człowiek może być w przypadku projektów o dużej skali, klasy enterprise, trwających rok lub dłużej. 

Łukasz Rozbicki. Application Architect w Livespace – Sell Smarter. Pisze te HTMLe od kilkunastu lat. Przeważnie przy większych projektach. Widział rzeczy, którym inni developerzy nie daliby wiary, np. agencyjne projekty na 4 miesiące robione w dwa tygodnie. Walczył w startupie. Oglądał i pracował nad aplikacjami enterprise blisko klientów korporacyjnych. Wszystkie te chwile doprowadziły do chwili obecnej. 


Jak żyć? Co robić?

Na pewno dużą pomocą są różnorakie frameworki, Angulary, Reacty, Vue i inne. Pomijając już wszelkie shitstormy na grupach i forach o  tym, które narzędzie jest lepsze, to koniec końców każde z nich spełnia swoje zadanie, czyli pozwala na zbudowanie aplikacji internetowej.

Tylko jest tu jeden problem. Frameworki się zmieniają, jedne odchodzą, przychodzą inne, czasem w ramach jednej nazwy mamy breaking changes (na szczęście rzadko!), coś nowego staje modne. A projekt trzeba utrzymywać! Pół biedy, jak to jakiś jednorazowy strzał realizowany przez software house – robimy, oddajemy, fakturka się zgadza, bierzemy kolejny temat.

Ale co w przypadku, gdy mówimy o firmie produktowej albo o długoletnich kontraktach uwzględniających utrzymanie? Jak poradzić sobie ze zmieniającymi się trendami? Jak być niezależnym od szczegółów takich, jak framework, baza danych itp? Jak, odcinając się od samej technologii, poradzić sobie i przygotować się na zmiany w samym produkcie, nad którym pracujemy? 

Sam tytuł artykułu odpowiada trochę na to pytania. W skrócie – użyjmy odpowiedniej architektury! 

I to ma za zadanie przedstawić ten artykuł. Pierwotnie chciałem po prostu popisać dużo teorii na temat inżynierii oprogramowania, Clean Architecture itp, jednak takich materiałów jest sporo. Nawet nie będę ich linkować tutaj. Kto ma ochotę, to na pewno sobie znajdzie odpowiednie źródła wiedzy. 

Skupiłem się zatem na stronie praktycznej i opisałem przykładową implementację Clean Architecture w aplikacji frontendowej.

Inżynieria, proszę państwa!

Jednak zanim do tego dojdziemy, to trochę teorii. Framework to narzędzie, a narzędzia się wymienia. Są jednak ogólne zasady, idee, wzorce… Właśnie, wzorce! Znane i lubiane (ale rzadko używane, przynajmniej na froncie) design patterns. Uniwersalne rozwiązania problemów, które często napotkać można podczas pisania kodu. Nie są zależne od języka programowania, platformy, często nawet też paradygmatu.

(Troszeczkę już tu odnosimy się do kwestii odcięcia się od detali technicznych, o których wspomniałem wyżej.)

Bazując na wzorcach projektowych jakieś 20 lat temu pan Robert C. Martin ukuł termin SOLID. Nie będę tu szczegółowo opisywał, co znaczy każda literka, ale tak z kronikarskiego obowiązku:

  • S – Single responsibility principle – zasada pojedynczej odpowiedzialności.
  • O – Open-closed principle – otwartość na rozszerzanie, zamknięcie na zmiany.
  • L – Liskov substitution principle – zasada podstawienia autorstwa pani Liskov.
  • I – Interface segregation principle – zasada segregacji interfejsów.
  • D – Dependency inversion principle – zasada odwrócenia zależności.

Co nam daje SOLID? Reguły i wskazówki dotyczące tego, jak pisać kod, jak tworzyć nasze aplikacje, jak nie wykopać pod samym soba dołka z wbitymi na dnie kolcami. To tak w skrócie. Zachęcam do zgłębienia tematu SOLIDa i poszczególnych jego literek już samodzielnie.

A co nam daje SOLID w kontekście tego artykułu?

Z tych 5 elementów powstał Kapitan… eee… Clean Architecture

clean architecture we frontendzie

Uncle Bob (Robert C Martin) na podstawie SOLIDa i swoich doświadczeń na polu tworzenia różnorakich aplikacji wyszedł z ideą Clean Architecture. Poniżej wstawiam diagram, który przewija się wszędzie, gdzie tylko mowa i tej koncepcji (więc i tutaj nie może go zabraknąć).

Clean Architecture jest architekturą warstwową, ale stawiającą domenę za główny punkt programu. Jak to w życiu, Inżynieria jest w służbie biznesu. Stąd ten biznes musi być centralną warstwą. To reguły tego biznesu kierują aplikacją.

Tutaj ważne zaznaczenie – “reguły” to nie “logika”. Logika biznesowa (jej implementacje) rozłożona jest po wszystkich warstwach aplikacji. Warto też zwrócić uwagę na kierunek zależności (solidne D). Idzie on od zewnątrz do wewnątrz, czyli warstwa wewnętrzna nie zależy w żaden sposób od warstwy zewnętrznej.

Po więcej informacji na temat Cleana, jego różnych interpretacji i podejść, zapraszam do internetu. Mają tam wiele wartościowych materiałów. Do tego warto zapoznać się z książką pana Martina pod tytułem “Clean Architecture”.

Clean na żywo i po mojemu

Jak wspomniałem, jest wiele implementacji Cleana. Na GitHubie można znaleźć wiele repozytoriów z kodem chociażby .netowym, kotlinowym. Z reguły stoi za tymi implementacjami wspólna idea:

  • warstwa domeny z use case’ami reprezentującymi reguły biznesowe, 
  • warstwa danych i źródeł, 
  • warstwa UI.

O co dokładnie chodzi z tymi warstwami?

Domena (domain)

Główna, centralna warstwa aplikacji. Tutaj znajdują się:

  • use case’y – odpowiadają regułom biznesowym. To proste konstrukcje, które same w sobie nie zawierają implementacji logiki (czy jak klikasz “zamów” online, to wczuwasz się w to, jak Twoje zamówienie będzie procesowane?). Use case ma za zadania rozpocząć wykonywanie jakiejś operacji z punktu widzenia potrzeb biznesowych. Np. pobranie listy użytkowników, czy zapis nowych danych profilu użytkownika.
  • modele – encje biznesowe. Mogą to być klasy, mogą to być nawet definicje interface’ów TS. Ważne by odpowiadały potrzebom i założeniom biznesowym. 
  • interfejsy repozytoriów – use case’y muszą w jakiś sposób mieć dostęp do danych. Jako, że zgodnie z zasadą odwrócenia zależności warstwa domeny nie zależy od żadnej innej (a to inne zależą od niej), to ta warstwa sama definiuje wygląd repozytoriów. Wygląd! Nie ich szczegóły implementacyjne. Same repozytoria to po prostu użyty repository pattern. Niezależna od faktycznego źródła danych logika dostarczająca lub wysyłająca dane z i do źródeł. 

Dane (data)

Warstwa ta odpowiada za dostęp do danych, ich dopasowywanie do modeli domenowych. Konkretnie znajduje się tu:

  • repozytoria – a konkretnie implementacje interfejsów zadeklarowanych w warstwie domain. Mogą operować na wielu źródłach danych.
  • źródła danych – niskopoziomowa warstwa transportu. To tu w praktyce odbywa się komunikacja z naszym RESTem, GraphQLem czy jakimkolwiek innym źródłem danych (np. też systemem plików).
  • modele odpowiedzi – każde źródło danych, każdy endpoint, każdy zestaw parametrów (np. w przypadku CQRS – każda komenda czy query) zwraca surówkę, dane w jakimś formacie, często z góry narzuconym. Dobrze jest te kształty danych mieć opisane. Po co?
  • mappery – a no po to. Na kształty odpowiedzi czasem możemy nawet nie mieć wpływu. A mamy przecież jakieś modele domenowe, jakieś już ustalone nazwy pól, typy itp itd. Dlatego też naturalne jest tworzenie mapperów do tłumaczenia jednej postaci danych na drugą. Albo nawet do łączenia kilku surowych odpowiedzi w jeden model domenowy.

UI

Jest to warstwa prezentacji danych. Może to być właściwa aplikacja stworzona w Reakcie, Angularze czy Vue, ale też np. jakaś tymczasowa zaślepka konsolowa. A może nawet apka React Native? Obojętne. 

Zadaniem tej warstwy jest ogarnianie wyświetlania danych w odpowiedniej formie i dbanie o logikę UIową. W tej warstwie nie powinno się trzymać jakiejkolwiek logiki manipulowania danymi domenowymi. Dane w formie gotowej do użycia powinny być już dostarczone poprzez wywołanie odpowiedniego use case’a. Ta warstwa sama w sobie może też być zbudowana w oparciu o jakieś zasady, architekturę. Ba, nawet powinna!

Czy jeszcze coś?

TAK! IoC. Inversion of Control. Swego rodzaju otoczka na całą naszą aplikację będącą implementacją reguły dependency inversion. W praktyce mogą to być kontenery wstrzykujące faktyczne implementacje tam, gdzie są one wymagane jako zależności.

Może też odpowiadać za faktyczny bootstrap aplikacji.

Poróbmy coś w końcu

Teoria teorią, można jej wiele poczytać wszędzie. Ciekawe może być natomiast przejście praktyczne przez tworzenie aplikacji opartej na Czystej Architekturze.

Oczywiście to, co tu zaprezentuję to tylko przykład i to prosty. W “prawdziwym życiu” Clean Architecture pokazuje swoje zalety, rozwija skrzydła i tym podobne przy aplikacjach klasy enterprise. Do małych projekcików może być po prostu overkillem. Do rzeczy. 

Zróbmy na szybko aplikację do wyświetlania informacji o browarach. Wykorzystam do tego znane i lubiane Punk API. Umówmy się, że rzeczy typu boilerplate, IoC i inne poboczne, po prostu są przygotowane (finalny kod do zobaczenia na GH, link na końcu artykułu).

Jako boilerplate użyję CRA, do opędzenia IoC – inversify. Kod będę pisał w TS (ale wybaczcie czasem skróty). Jakkolwiek SOLID i wszystkie te inżynierskie historie pasują do OOP, kod, który będę prezentował jest bardziej “funkcjonalny” niż “obiektywny”. To dla zmniejszenia boilerplate’u, po nic innego w sumie.

W ogóle będę czasem szedł na skróty. Będę o tym informował, ale proszę, weźcie to pod uwagę.

Przyjmijmy zatem takie proste założenia, potrzeby biznesowe, jeśli wolicie:

  1. Jako koneser dobrego piwa potrzebuję widzieć listę piw i przeglądać ją strona po stronie.
  2. Jako koneser dobrego piwa potrzebuję zobaczyć szczegóły wybranego browaru.

Przygotujmy najpierw trochę infrastruktury

Żeby móc sobie spokojnie pracować, potrzebujemy trochę rzeczy zrobionych najpierw. Przydałaby się jakaś struktura katalogów. Najlepiej odzwierciedlająca warstwy.

├── data

│   └── sources

├── domain

├── index.ts

└── ui

Plik index.ts będzie entrypointem naszej aplikacji, ale do tego dojdziemy za chwilę.

Teraz potrzebujemy jeszcze kontenera IoC. Tutaj jednak pójdę na wspomniany wcześniej skrót i nie będę szczegółowo opisywał co i jak. Wszystko to można wyciągnąć z dokumentacji biblioteki inversify.js.

Finalnie wyglądać to będzie tak:

├── ioc

│   ├── app-container.ts

│   ├── beer-module.ts

│   ├── common

│   │   └── helpers.ts

│   └── initialize-application.ts

Kilka rzeczy po drodze jeszcze zrobimy, ale już możemy przejść do realizacji naszych user stories.

Jako koneser dobrego piwa potrzebuję widzieć listę piw, sortować ją i filtrować

Zaczynamy od core’a, czyli od domeny. Dla przypomnienia, warstwa domeny składa się z definicji modeli, interfejsów repozytoriów i use case’ów. 

Co powinien zawierać? Przyjmijmy, że na liście chcemy wyświetlać obrazek przedstawiający piwo, jego nazwę i jakiś slogan promocyjny. W tym przykładzie użyję interfejsu typescriptowego, ale równie dobrze mogłaby to być legitna klasa, czy też jakiś builder. Ale interfejs tutaj w zupełności wystarczy. 

Zatem nasz model może wyglądać tak:

Zauważcie, że w ogóle tu nie myślimy o API! Skupiamy się tylko na naszych potrzebach i odwzorowaniu biznesu. Dobra, mamy model, więcej póki co nie potrzebujemy. Co dalej? 

Zróbmy sobie repozytorium

A właściwie interfejs.

Jak możecie zauważyć, pojawiło się tu coś takiego jak BeerListRequestObject. Request objecty są reprezentacją parametrów żądania danych. W tym wypadku nasz request object dotyczy listy piw i wygląda bardzo prosto:

Zauważcie, że w ogóle tu nie myślimy o API! [2]. Sam wygląd repozytorium jest dość prosty. Ot, obiekt z jakimiś metodami, gdzie metoda zwraca asynchronicznie dane konkretnego typu.

Co ważne, repozytorium zawsze zwraca dane w modelu domenowym! Nie może być inaczej, bo wtedy złamana by została zasada zależności. A póki co nasza warstwa domeny nie zależy od niczego, co byłoby zdefiniowane poza nią.

Pora na use case’y

Nasz pierwszy use case ma odpowiadać za realizację reguły biznesowej mówiącej o potrzebie wyświetlenia listy piw.

Kod takiego use case’a wygląda tak:

Dzieją się tu rzeczy. Rzeczy, które warto trochę opisać. Przede wszystkim mamy tu fabrykę zwracającą faktyczny use case. Jest to podyktowane tym, że nie chcemy odnosić się do implementacji repozytorium bezpośrednio w kodzie use case’a, nie chcemy mieć repo użytego przez jakiś import. Chcemy użyć naszego dependency injection i naszego mechanizmu kontenera IoC (za pomocą Inversify).

Skoczmy zatem na sekundę do odpowiedniego miejsca w IoC.

Nie wchodząc w zbytnie szczegóły tego kodu (zapraszam do analizy na GitHuba), póki co wystarczy powiedzieć, że parametry naszej fabryki opisują wymagane zależności, a tablica będąca drugim parametrem injectDependencies odpowiada właśnie tym parametrom i wskazuje na odpowiednie implementacje za pomocą identyfikatorów – symboli. Każda implementacja ma swój symbol.

Ale co z tym repozytorium? No, żeby móc je wpiąć w IoC trzeba je mieć zaimplementowane…

Pora na warstwę danych

Warstwa danych to miejsce, gdzie powinna siedzieć cała logika związana z przygotowaniem danych do prezentacji użytkownikowi. Tutaj właśnie odbywają się faktyczne requesty do np. RESTa. Tutaj są implementacje repozytoriów, źródeł, mappery… Ale po kolei.

Interfejs mamy, więc sama implementacja nie powinna być skomplikowana. To, co chcemy osiągnąć, to (w tym wypadku) proste pobranie danych ze źródła. Nie interesuje nas w tym wypadku wciąż, jakie to źródło jest. 

I jakkolwiek może wydawać się, że repozytorium wydaje się nadmiarowe, to pamiętajcie proszę, że operujemy to na dość prostackim przykładzie. W dużych projektach, gdzie dane latają w każdą stronę, endpointów jest mnóstwo, może się okazać, że żeby przygotować odpowiedź dla use case’a (odpowiadającą modelowi domenowemu), trzeba puknąć do kilku endpointów, albo wręcz kilku źródeł danych. Wtedy to repository jest tym miejscem, gdzie to wszystko ma być zebrane do kupy i uformowane w strawną formę.

Coś takiego na chwilę obecną. Tyle, że czegoś tu nie ma i ten Promise.resolve([]) to tak sobie wygląda jednak.

Jedna rzecz jest tu nieuwzględniona.

Faktyczne odniesienie do źródła danych

Zróbmy coś z tym definiując interfejs takiego źródła. Do samych źródeł można podejść dwojako. Można je tworzyć per rodzaj źródła, np. RESTSource, GraphQLSource, FileSystemSource, a można też je tworzyć per obszar, np. UserSource, BeerSource

Użyję drugiego podejścia, ale nie znaczy to, że jest to jedyna słuszna droga. Po prostu w ten sposób można łatwo podmieniać rodzaje źródeł tylko dla konkretnego repozytorium. Co ma niebanalne znaczenie np przy testach albo przy mockach backendu.

Pominę tu wygląd BeerResponseItem, gdyż użyłem tu generatora online na podstawie zwrotki z Punk API…

W każdym razie interface źródła jest też dość prosty i trzyma się schematu podobnego, jak w przypadku wcześniejszych tematów.

Zaktualizowane repozytorium

Teraz nasze repozytorium wygląda już tak:

Coś się zmieniło. Poza dodaniem async/await, nie mamy już bezpośredniego zwracania rzeczy pobranych ze źródła. Nie możemy tak zrobić, bo nasz model domenowy wygląda ciut inaczej niż surowa zwrotka… 

Tu wchodzą mappery

Z doświadczenia w pracy z różnymi zespołami mogę powiedzieć, że to jest często najbardziej kontrowersyjna rzecz. Niby taka prosta i mała, ale jednak. Trochę rozumiem developerów, którzy mają z tym początkowy problem, jednak wiem, jak bardzo potrzebne są mappery.

Mappery mogą być prostymi funkcjami, jak tu w naszym przykładzie:

Można też tu użyć auto-mapperów, mechanizmów bardziej zaawansowanych i robiących więcej “magii”. Wszystko w zależności od potrzeb. Koniec końców chodzi o to, by logikę zamieniającą jeden kształt danych na drugi, mieć w oddzielnym miejscu.

Faktyczna implementacja faktycznego źródła!

Mamy repo, mamy typy zwrotki z API, mamy mappery, możemy spokojnie już napisać samo źródło. Tu też trochę pójdę na #skróty i niczego fancy tu nie będzie. Żadnej obsługi błędów czy coś. Ot, przykład…

Znowu mamy fabrykę, tym razem zwracającą obiekt źródła. W samym kodzie źródła nic nadzwyczajnego. Zwykły GET pod wskazany endpoint, odpakowanie JSONa i zwrócenie go.

Iiiii…. to (prawie) tyle

Jeśli chodzi o tzw. “core” aplikacji. Mamy definicję reguły biznesowej i logikę do jej realizowania.

Widzicie dokąd to zmierza? Mamy już aplikację w stanie takim, że od biedy można już jej używać po jakimś CLI czy devtoolsach (sprawdźcie commit 3da922593298ef4e5e093441c7f1ebc8072aa57d).

A pomyślmy też chwilę o testach. Tak, nie pisałem tego kodu trzymając się TDD. Po prawdzie nie pisałem żadnych testów. Bo to przykład, nie prawdziwy projekt. Ale jeśli to miałby być prawdziwy projekt, to pomijając już proste unity (bo każdą z warstw możemy sobie jednostkowo otestować bardzo szybko, przygotowując sobie jedynie mocki zależności), to zauważcie (słowo-klucz tego artykułu!), jak łatwo zrobić teraz…

Test integracyjny logiki naszej aplikacji

Wystarczy otestować use case! Tak! I co ciekawe, to nie będzie jakiś turbo skomplikowany test. Ot, sprawdź, czy po odpaleniu execute’a przyjdą odpowiednie dane. Mając kontrolowane środowisko uruchomieniowe do testów integracyjnych.

Nie wchodzę tu już w tematykę oddzielnych kontenerów IoC pod testy, bo to otwiera jeszcze inne możliwości i zachęcam Was do pokombinowania we własnym zakresie.

Proszę, tu taki szybki przykład testu, który weryfikuje partyzancko (#skróty) poprawność działania use case’a GetBeersUseCase.

Oczywiście w produkcyjnym kodzie, w poważnych projektach, nigdy nie piszcie tak testów! Swoją drogą sprawdźcie coverage po odpaleniu takiego testu.

clean architecture we frontendzie

A co z UI?

A co ma być? Róbcie jak chcecie, nie ma to znaczenia. Lubisz Reacta? Śmiało! Wolisz Vue? Go for it! Serio, przy takiej architekturze sama libka czy framework do budowania widoków jest kwestią preferencji, ale nie wpływającą nijak na ogólny kształt aplikacji. Dlatego też celowo nie wchodziłem tu głębiej w tę kwestię.

Efekt finalny

Podsumowując trochę sekcję kodu, mamy aplikację, która realizuje nasze potrzeby, nie pisząc linijki kodu opartego na Reakcie, Angularze czy innym frameworku frontendowym!  Dzięki temu, że nasza aplikacja nie jest uwiązana do niczego poza samymi design patternami i ogólną koncepcją architektoniczną, możemy ten kod utrzymywać przez lata, wymieniając sobie w razie potrzeby sam UI na cokolwiek będziemy chcemy.

Co można z tym zrobić dalej?

Uooo, kochani… Bardzo dużo. Co się właściwie chce. Przede wszystkim zachęcam do pobawienia się kodem z udostępnionego repo GitHubowego i np. dorobienia we własnym zakresie feature’a wyświetlania szczegółów piwa.

A poza tym? Sky is the limit! A tak serio, to po prostu kwestia indywidualnych potrzeb projektowych. Można np. rozbudować warstwę data o jakiś magazyn danych (Redux, MobX, coś własnego, nie ważne). Można popracować nad inną formą komunikacją między poszczególnymi warstwami, np. użyć rxów po to, by móc uwzględniać asynchroniczną komunikację z backendem typu polling, pushe, sockety (w zasadzie by być przygotowanym na każdy rodzaj źródeł danych).

Ogólnie można robić co się chce, byle by zachować warstwowy podział i zasadę odwróconej zależności. I żeby domena była w środku. Pamiętajcie, eksperymenty są fajne!

Na zakończenie

Zdaję sobie sprawę, że temat przedstawiłem pobieżnie. Nie mówię już o samej teorii, bo tu po prostu nie chciałem pisać po raz setny czegoś, co już ktoś napisał gdzieś indziej. Sam kod jest przykładowy i raczej brakuje mu trochę rzeczy (jak np. wspomniany chwilę temu store czy jakaś wewnętrzna szyna danych), by być gotowym na faktyczne duże projekty.

Chodzi mi tu jednak o samo zaprezentowanie tego, że można (a nawet fajnie jest, a czasem nawet trzeba) wyjść poza ramy frameworkowej dokumentacji, blog postów ewangelistów tego czy innego frameworka itp., i spojrzeć na naszą pracę, jak inżynier, inżynier oprogramowania. A nie jak developer Reacta, developer Angulara czy coś w tym stylu.

Nie ograniczajmy się. Nie stwarzajmy sami sobie problemów pod pretekstem pozornej wygody na początku pracy nad projektem. Odstawmy detale technologiczne na bok, skupiajmy się na potrzebach użytkowników, potrzebach biznesu. Niech to będzie wyznacznikiem tego, jak czy w czym robimy nasze aplikacje. A jak już technologia, to niech inżynieria będzie na pierwszym miejscu.

Aha, repozytorium z wyżej klepanym kodem znajdziecie tu github.com/lukaszrozbicki/. Jest tu cała aplikacja, gotowa do odpalenia i rozwoju.


Zdjęcie główne artykułu pochodzi z unsplash.com.

Zapraszamy do dyskusji

Patronujemy

 
 
More Stories
10 years challenge to niegroźna zabawa, prawda?