growth hacks

Czysta architektura w Pythonie. Jak stworzyć testowalny i elastyczny kod

Gdyby zapytać kilku programistów, czym charakteryzuje się idealny projekt, to wśród odpowiedzi z pewnością znalazłyby się: czytelny kod źródłowy, łatwość wprowadzania zmian, wysokie pokrycie testami, kod prosty w ogarnięciu i odnalezieniu tego, czego się poszukuje oraz mały dług techniczny (z ang. technical debt). Praca przy takim projekcie byłaby czystą przyjemnością. Niestety oprogramowanie tej klasy jest rzadko spotykane. Jak to zmienić?


Sebastian Buczyński. Senior Python Developer w STX Next. Programista “na sterydach”, ze smykałką do poszukiwania potencjalnych punktów zapalnych tak w oprogramowaniu, jak i całych procesach. Doświadczenie zbierał pracując parę ostatnich lat nad rozbudowaną platformą teleinformatyczną (ostatnio jako lider techniczny), a także zahaczając po drodze kilka startupów tak tajnych, że strach o nich mówić.


 

 

Programowanie do najłatwiejszych zajęć nie należy, toteż stworzenie takiego dzieła jest nie lada wyzwaniem. Odpowiednią drogę wyznacza inżynieria oprogramowania – dziedzina poświęcona praktycznym aspektom pisania programów. Niestety niezbyt popularna w świecie Pythona, o czym można się przekonać czytając agendy konferencji. Dużo większą wagę przywiązuje się do bibliotek rozwiązujących nasze problemy i frameworków, niż do zasad i wzorców, które za nimi stoją.

Niniejszy artykuł prezentuje jedno ze zdobyczy inżynierii oprogramowania – czystą architekturę. Koncept został opisany w 2012 roku na blogu firmy 8th Light, a jego autorem jest Robert C. Martin, znany jako Uncle Bob.

Obserwacją stojącą u podstaw czystej architektury jest, że najważniejszym powodem powstania projektu są pewne wymagania biznesowe, które jako zespoły IT mamy zaspokoić. Skoro to wymagania są kluczowe, to chcemy po pierwsze umieścić je w bardzo widocznym, odseparowanym od szczegółów implementacyjnych miejscu i uczynić je możliwie niezależnymi od narzędzi, których używamy. W skrócie mają być:

  • niezależne od frameworka,
  • łatwo testowalne,
  • niezależne od UI, rodzaju API, sposobu wywołania logiki,
  • niezależne od bazy danych,
  • niezależne od usług zewnętrznych.

Uzyskanie takiego efektu jest możliwe kosztem wprowadzania dodatkowych abstrakcji i zastosowania paru ciekawych technik jaka Dependency Injection.

Projekt przykładowy

Aby należycie zilustrować zagadnienie, posłużymy się przykładem projektu serwisu aukcyjnego. Wśród wymagań klienta znalazły się między innymi takie historyjki użytkownika (scenariusze):

  • Jako licytujący chciałbym złożyć ofertę, aby móc wygrać aukcję.
  • Jako licytujący chciałbym otrzymać powiadomienie e-mailowe, kiedy moja oferta jest najwyższą spośród złożonych.
  • Jako administrator chciałbym móc wycofać wybraną ofertę z aukcji.

Klasyczne podejście z Django

Rozważmy realizację tego projektu z Django. Jest to najpopularniejszy framework webowy w świecie Pythona, który dzięki paczce dobrodziejstw pozwala w błyskawicznym tempie tworzyć aplikacje. W praktyce w paczce z Django dostajemy pewne klocki (ang. building blocks), przy użyciu których komponujemy nasz projekt. W przypadku Django są to m.in:

Obrazek 1. Najważniejsze klocki w Django

Jeżeli przyjrzymy się temu obrazkowi, to zauważymy, że w zasadzie centralnym klockiem, od którego wszystko zależy są modele. Model odwzorowywany jest na tabelę w bazie danych, gdzie będziemy przechowywać nasze obiekty. Pisząc z Django zaczniemy więc dokładnie od zdefiniowania modeli poprzez przejście przez wymagania i znalezienie rzeczowników:

  • Jako licytujący chciałbym złożyć ofertę, aby móc wygrać aukcję.
  • Jako licytujący chciałbym otrzymać powiadomienie e-mailowe, kiedy moja oferta jest najwyższą spośród złożonych.
  • Jako administrator chciałbym móc wycofać wybraną ofertę z aukcji.

Namierzone słowa, pogrubione w powyższej liście, stanowią doskonałych kandydatów na modele. Skupmy się na dwóch najistotniejszych:

class Auction(models.Model):

            title = models.CharField(...)

            initial_price = models.DecimalField(...)

            current_price = models.DecimalField(...)



class Bid(models.Model):

          amount = models.DecimalField(...)

          bidder = models.ForeignKey(...)

          auction = models.ForeignKey(Auction, on_delete=PROTECT)

Notka: już na tym etapie, zanim zaczęliśmy modelować w jakikolwiek sposób scenariusze użytkownika, zdecydowaliśmy o strukturze bazy danych!

Po zdefiniowaniu modeli wystarczą dwie komendy do wygenerowania i wykonania migracji, aby nasze obiekty mogły lądować w bazie danych. Skupmy się teraz na trzeciej historyjce użytkownika:

  • Jako administrator chciałbym móc wycofać wybraną ofertę z aukcji.

Jedną z wielkich konkurencyjnych przewag Django jest automatycznie generowany panel administratora. Aby mieć gotowy widok z pojedynczą aukcją i listą jej ofert z możliwością ich kasowania przy użyciu checkboksów, wystarczy nam 6 (sześć!) linii kodu. Gdyby nie konieczność zmiany aktualnej ceny aukcji i potencjalnie wyłonienie nowego zwycięzcy(ów), to bylibyśmy urządzeni takim rozwiązaniem. Na szczęście możemy nadal dość łatwo wstrzyknąć własną logikę w odpowiednie miejsce w panelu administratora:

def save_related(self, request, form, formsets, *args, **kwargs):

           ids_of_deleted_bids = self._get_ids_of_deleted_bids(formsets)  # 1

           bids_to_withdraw = Bid.objects.filter(pk__in=ids_of_deleted_bids)  # 2



           auction = form.instance  # 3

           old_winners = set(auction.winners)

           auction.withdraw_bids(bids_to_withdraw)  # 4

           new_winners = set(auction.winners)



           self._notify_winners(new_winners - old_winners)   # 5



           super().save_related(request, _form, formsets, *args, **kwargs)

1. Z danych formularza wyciągamy IDki wybranych ofert do skasowania.

2. Wyciągamy modele ofert z bazy.

3. Model aukcji jest już załadowany i dostępny dzięki Django.

4. Na modelu aukcji wywołujemy logikę wycofywania ofert, tak by aukcja odpowiednio zaktualizowała sobie bieżącą cenę i ewentualnie listę zwycięzców.

5. Powiadamiamy ewentualnie nowych zwycięzców.

Brawo, zrealizowaliśmy historyjkę użytkownika istotną dla biznesu. Przy okazji powstało rozwiązanie mocno splątane z frameworkiem, którego nie sposób przetestować bez bazy danych, do tego zagrzebane gdzieś w czeluściach panelu administratora. Można lepiej.

Czysta architektura w zastosowaniu

Use case – pierwszy building block

W nowym podejściu, rzeczą od której zaczynamy nie będą encje biznesowe (w Django po prostu modele), ale całe procesy — historyjki użytkownika. Kod, który będzie je realizować nazywamy use case albo interactor — zależnie od upodobań. Nazwa będzie jednoznacznie identyfikować, co to za scenariusz i w idealnym świecie odpowiadać 1:1 historyjkom użytkownika w systemie.

class WithdrawingBidUseCase:

   def withdraw_bids(self, auction_id, bids_ids):

    auction = Auction.objects.get(pk=auction_id)

    bids_to_withdraw = Bid.objects.filter(pk__in=bids_ids)



    old_winners = set(auction.winners)

    auction.withdraw_bids(bids_to_withdraw)

    new_winners = set(auction.winners)



    self._notify_winners(new_winners - old_winners)

      auction.save()

Przenosimy całą logikę wynikającą z wymagań biznesowych z kodu panelu administratora do specjalnie wydzielonej klasy. Zwróćmy uwagę, że klasa WithdrawingBidUseCase może zostać użyta z dowolnego kontekstu, nie tylko panelu administratora — wymaga tylko podania id aukcji i listy id ofert do wycofania. Zadaniem use case’a jest orkiestracja całego biznesowego scenariusza.

Use case’y tworzą tak zwaną warstwę aplikacji — zespół klas, które reprezentują poszczególne przypadki użycia do wywołania w różnych kontekstach, przez różnych użytkowników. Ich wydzielenie to pierwszy krok ku lepszemu.

Interface – drugi building block

Jest to dopiero początek naszej drogi ku całkowitemu uniezależnieniu się od frameworka i lepszej reprezentacji wymagań biznesowych w kodzie. Obecnie jesteśmy bardzo mocno splątani z Djangowym ORMem:

class WithdrawingBidUseCase:

   def withdraw_bids(self, auction_id, bids_ids):

    auction = Auction.objects.get(pk=auction_id)  # ←- wyciągamy aukcję

    bids_to_withdraw = Bid.objects.filter(pk__in=bids_ids)  # ←- wyciągamy oferty



    old_winners = set(auction.winners)

    auction.withdraw_bids(bids_to_withdraw)

    new_winners = set(auction.winners)



    self._notify_winners(new_winners - old_winners)

      auction.save()  # ←- zapisujemy aukcję

Aby zerwać tę niechcianą więź użyjemy techniki bardzo powszechnej w inżynierii oprogramowania — wprowadzimy warstwę abstrakcji. Patrząc na nasze użycie ORMa w przypadku aukcji, potrzebujemy czegoś, co pozwala na pobieranie aukcji po ID i zapis jej. Abstrakcję, która to reprezentuje można zmaterializować w postaci klasy z metodami abstrakcyjnymi:

class AuctionsRepo(metaclass=ABCMeta):

   @abstractmethod

   def get(self, auction_id):

       pass



   @abstractmethod

   def save(self, auction):

       pass

Takie abstrakcje, które uniezależniają scenariusze biznesowe od świata zewnętrznego (w tym przypadku baza danych) nazywamy Interface’ami. Oczywiście nadal potrzebujemy klasy, która dziedziczy po AuctionsRepo i implementuje te dwie metody. Na razie przyjmijmy, że może to być ordynarnie prosta implementacja:

class DjangoAuctionsRepo(AuctionsRepo):

   def get(self, auction_id):

       return Auction.objects.get(pk=auction_id)



   def save(self, auction):

       auction.save()

Ten building block nazywamy interface adapter’em. Cała sztuczka z tą podmianką polega na tym, żeby nasz kod realizujący scenariusz biznesowy (use case) nie wiedział o tym, że używa akurat DjangoAuctionsRepo. Po prostu nie mówimy mu o tym w żaden sposób.

class WithdrawingBidUseCase:

             def __init__(self, auctions_repo: AuctionsRepo):

             self._auctions_repo = auctions_repo



             def withdraw_bids(self, auction_id, bids_ids):

             auction = self._auctions_repo.get(auction_id)



              ...  # pominięte dla czytelności



              self._auctions_repo.save(auction)

 


actual_auctions_repo = DjangoAuctionsRepo()

use_case = WithdrawingBidUseCase(actual_auctions_repo)

use_case.withdraw_bids(...)

W ten sposób kod samego scenariusza stał się niezależny od bazy danych. Można teraz z łatwością napisać dla niego prawdziwe testy jednostkowe przekazując jako argument mock klasy AuctionsRepo lub prostą implementację, która trzyma obiekty w pamięci.

Dygresja: takie przekazywanie zależności do use case’ów przy każdym ich tworzeniu byłoby nużące i podatne na błędy. Dependency injection to remedium — pozwala nam skonfigurować w jednym miejscu jakich prawdziwych implementacji chcemy używać w miejsce wybranych klas abstrakcyjnych. Jako ćwiczenie dla czytelnika pozostawiam zapoznanie się z jedną z dwóch bibliotek pythonowych: Inject, injector.

Entity – building block #0

Może być jeszcze piękniej – jeżeli tylko pozbędziemy się z kodu biznesowego modeli Django. Zastąpimy je bardziej biznesowymi encjami. Encje od modeli odróżniają trzy rzeczy:

  • nie wiedzą nic o bazie danych, w ogóle prawie nic nie wiedzą o świecie wokół,
  • nie są workami na dane — nie można zmieniać ich pól bezpośrednio; zawsze trzeba to zrobić za pośrednictwem ich metody,
  • mogą reprezentować grafy obiektów — aukcje i oferty to idealny przykład. Nie ma sensu rozpatrywać ofert w oderwaniu od aukcji, więc używajmy ich zawsze razem.
class Auction:

   def __init__(self, id: int, initial_price: Decimal, bids: List[Bid]):

       self.id = id

       self.initial_price = initial_price

       self.bids = bids



   def withdraw_bids(self, bids_ids: List[int]):

       ...



   def make_a_bid(self, bid: Bid):

       ...



   @property

   def winners(self):

       ...

Zaletą braku zależności jest to, że piszemy tutaj czystego Pythona i możemy bardzo łatwo przetestować takie klasy. Odcięcie się od modeli nieco skomplikuje kod Interface Adapter’a, który teraz wygląda sensownie:

class DjangoAuctionsRepo(AuctionsRepo):

   def get(self, auction_id: int) -> Auction:

       auction_model = AuctionModel.objects.prefetch_related(

           'bids'

       ).get(pk=auction_id)



       bids = [

           self._bid_from_model(bid_model)

           for bid_model in auction_model.bids.all()

       ]



       return Auction(

           auction_model.id,

           auction_model.initial_price,

           bids

       )

Koniec końców, kod use case’a będzie wyglądał następująco:

class WithdrawingBidUseCase:

   def __init__(self, auctions_repo: AuctionsRepo):

       self._auctions_repo = auctions_repo



   def withdraw_bids(self, auction_id, bids_ids):

       auction = self._auctions_repo.get(auction_id)



       old_winners = set(auction.winners)

       auction.withdraw_bids(bids_ids)

       new_winners = set(auction.winners)

       self._notify_winners(new_winners - old_winners)



       self._auctions_repo.save(auction)

Dlaczego to działa – zasada zależności

Pisanie kodu używając klocków czystej architektury to nie wszystko. Jest to podejście używające ściśle określonych warstw – w podstawowej wersji czterech:

Obrazek 2. Podstawowe warstwy w czystej architekturze

Złota zasada pozwalająca utrzymać porządek to jednostronne zależności. Na diagramie obrazują to poziome strzałki. Domena (czyli esencja biznesowego aspektu projektu) nie wie o niczym. Aplikacja wie wszystko o Domenie, ale nie ma pojęcia jak jest uruchamiana (Świat zewnętrzny) ani jakiej bazy danych używa (Infrastruktura).

W praktyce oznacza to, że nie wolno importować niczego z wyższej warstwy w niższej. W konsekwencji jakiekolwiek zmiany w wyższej warstwie nie powodują konieczności dotykania czegokolwiek, co leży niżej. Z drugiej strony, jeżeli wprowadzimy duże zmiany w Domenie, to musimy się liczyć z tym, że wszystkie warstwy wyżej będą się musiały do tej zmiany dostosować.

Największa wartość projektu jest skupiona na warstwach Domeny i Aplikacji. Dzięki ostrożnemu usuwaniu zależności jesteśmy w stanie zapewnić tutaj nawet 100% pokrycie testami oraz całkowitą niezależność od frameworka i całego świata zewnętrznego.

Granice

Zasada zależności nie tylko oznacza brak możliwości importowania rzeczy z wyższych warstw, ale także zakaz przekazywania obiektów używanych wyżej do niższych warstw. W praktyce oznacza to, że musimy przepakować dane ze świata zewnętrznego do formatu wygodnego dla Aplikacji. Takie podejście wyznacza ostrą granicę między Aplikacją, a resztą projektu.

Punktem styku są use case’y, a raczej ich argumenty. Bardzo wygodnym podejściem jest tworzenie use case’ów przyjmujących zawsze tylko jeden argument — strukturę danych. Taki obiekt można łatwo skonstruować (a więc i spreparować w testach!) i z niego korzystać. W Pythonie zasadniczo można wykorzystać trzy podejścia: użyć namedtuples, znakomitej biblioteki attrs lub dodanych w Pythonie 3.7 dataclasses. W praktyce wszystkie trzy podejścia ułatwiają stworzenie klasy, która przyjmuje argumenty określonego typu.

class WithdrawingBidRequest(typing.NamedTuple):

       auction_id: int

       bids_ids: typing.List[int]

Sygnatura metody use case’a zmieni się by przyjmować tylko taki obiekt:

class WithdrawingBidUseCase:

      def withdraw_bids(self, request: WithdrawingBidRequest):

      ...

Pola klasy WithdrawingBidRequest są typami niezależnymi od świata zewnętrznego (nie zawierają referencji do ORMa, klas z infrastruktury itd.), a więc granica została wytyczona poprawnie. Uwagę może zwrócić nazewnictwo argumentu – request. Analogicznie wartość zwracaną z use case’a można nazwać response i również posługiwać się strukturami danych konstruowanymi na jeden z trzech wymienionych wyżej sposobów.

Bezpieczeństwo typów dzięki value objects

Granica aplikacji to miejsce szczególne. Wiemy już, że przekazywane tam dane podlegają potrzebują specjalnej uwagi. Ogromną pomocą jest pewność co do tego, że otrzymujemy dokładnie ten typ danych, który określiliśmy adnotacją. Python jest językiem dynamicznie typowanym, więc aby taką gwarancję uzyskać musimy ręcznie sprawdzić, czy na pewno dostajemy to, czego oczekujemy.

assert isinstance(request.auction_id, int)
assert all(isinstance(bid_id, int) for bid_id in request.bids_ids)

W tym momencie programiści języków statycznie typowanych uśmiechnęli się złośliwie pod nosem widząc heroiczne próby rozwiązania problemu, który u nich nie występuje. Muszę powiedzieć, że nie uważam by statyczne typowanie było jedyną słuszną drogą, a jedynie, że sprawdzenie typu w tym konkretnym miejscu ma ogromny sens.

Wracając do Pythona — sprawdzenie typów można sobie ułatwić rozmaitymi bibliotekami, jednak nawet wtedy wcale nie rozwiążemy całego problemu (tak, w językach statycznie typowanych też nie)! Co z tego, że argument jest ciągiem znaków, skoro potrzebujemy akurat takiego, który oznacza poprawny adres e-mail lub IP? Dodatkowo nie chcemy śmiecić walidacją na każdym kroku w warstwie aplikacji i domeny. Zamiast więc przekazywać instancje str, użyjmy tak zwanych value objects.

class IpAddress:

   def __init__(self, raw_ip_address: str):

       # walidacja

       self._value = raw_ip_address



   @property

   def value(self):

       return self._value

Value objects charakteryzują się kilkoma rzeczami. Po pierwsze są niemutowalne, co oznacza brak możliwości zmiany ich wartości po skonstruowaniu. Po drugie, jeżeli stworzymy dwie instancje inicjalizując je taką samą wartością, to nie powinny się od siebie niczym różnić.

Przykładami value objects z biblioteki standardowej Pythona jest Decimal lub datetime.timedelta.

Czy czysta architektura ma jakieś wady?

Oczywiście!

Nieidiomatyczne użycie frameworka

Używając czystej architektury trzeba pogodzić się z faktem, że wiele rzeczy trzeba będzie zrobić inaczej niż to przewidzieli autorzy frameworka i tutoriali do niego. O ile w przypadku Flaska i Pyramida nie jest to duży problem ze względu na ich elastyczność, tak z Django trzeba się kopać na każdym kroku.

Pisanie większej ilości kodu

Dodatkowe abstrakcje mają swoją cenę. W skrajnych przypadkach kod jest kilka (2-3) razy dłuższy. Na szczęście są adnotacje typów, dzięki którym w odpowiednim IDE kod pisze się w dużej mierze sam.

Dużo kopiowania danych między obiektami

Docelowa implementacja repozytorium aukcji zawierała kod kopiujący dane z modeli Django do encji. Jest to tylko jeden z przykładów konieczności robienia takiej transformacji, jeżeli chcemy mieć poprawnie odseparowane warstwy.

Walidacja

Nie istnieje jeden dobry sposób zrobienia walidacji w czystej architekturze, która mogłaby być używana na wszystkich warstwach bez nadmiernego komplikowania kodu. Zwykle można się zadowolić walidacją umieszczoną na wejściu do use case’ów lub widokach. Pomóc mogą biblioteki takie jak marshmallow czy colander.

Ryzyko overengineeringu

Chociaż czysta architektura świetnie nadaje się do skomplikowanych projektów, to w prostszych przypadkach narzut z nią związany może zwyczajnie okazać się zbyt duży. Czasem pójście drogą najmniejszego oporu z tandemem Django + Rest Framework może okazać się najlepszą opcją. Z drugiej strony złożoność w stale rozwijanych projektach informatycznych rośnie wykładniczo w czasie, więc nawet jeżeli początkowo czysta architektura wyda się przekombinowanym podejściem, to na pewnym etapie ta inwestycja się zwróci.

Wiele scenariuszy testowych do sprawdzenia

Gdy akcje użytkownika mogą mieć wiele różnych następstw i zależy nam na sprawdzeniu ich maksymalnej ilości, wtedy z pewnością docenimy odseparowanie use case’ów i możliwość ich jednostkowego przetestowania w każdej sytuacji.

Potrzeba opóźnienia podjęcia decyzji

Nie wszystkie decyzje projektowe mogą zapaść przed rozpoczęciem pracy. Czasami zawieranie umów z partnerami trwa miesiącami, innym razem nie wiadomo jak dany problem ugryźć, a jeszcze w innym przypadku zmieniamy dostawcę, gdyż konkurencja ma prowizję niższą o pół punkta procentowego.

Zamiast wiązać się z konkretną implementacją na śmierć i życie, można zapewnić sobie elastyczność w każdym momencie projektu dzięki interface adapterom.

Skomplikowana domena projektu

Złożoność może być przypadkowa — gdy coś zostało źle zamodelowane lub esencjonalna — gdy coś w swej naturze składa się z wielu elementów i zależności między nimi. W obu sytuacjach obciążenie poznawcze jest bardzo duże. W pierwszym przypadku wystarczy poprawić model, w drugim niestety trzeba z nim żyć. W tym trudzie ulżyć nam może czysta architektura, która jest bardzo wygodną notacją zapisu. W nierównej walce ze złożonością pomóc mogą też inne podejścia stosowane razem, szczególnie Domain Driven Design.

Podsumowanie

Wymagania biznesowe są tym, co faktycznie kształtuje projekty. Potraktowanie ich z należytą uwagą pozytywnie odbije się na oprogramowaniu które wytwarzamy, a także pozwoli nam stać się lepszymi programistami.


najwięcej ofert html

Odnośniki

Przykładowy projekt w Pythonie — przykład z artykułu

Implementing Clean Architecture — zapowiedź książki o implementowaniu Czystej Architektury w praktyce

Czysta Architektura (nawet z Django!) — nagranie z wystąpienia z PyWaw #72

The Clean Architecture — pierwszy artykuł Roberta C. Martina

Clean Architecture: A Craftsman’s Guide to Software Structure and Design — książka Roberta C. Martina zawierająca kilka rozdziałów o Czystej Architekturze

Clean Architecture Python (web) apps — prezentacja Przemka Lewandowskiego

Software architecture chronicles — seria blog postów poświęconych architekturze

Boundaries — prezentacja Gary’ego Bernhardta

Implementing the Clean Architecture — przykładowy projekt w PHP i jego historia

Przykładowy projekt w C#


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

Podobne artykuły

[wpdevart_facebook_comment curent_url="https://geek.justjoin.it/czysta-architektura-pythonie-stworzyc-testowalny-elastyczny-kod/" order_type="social" width="100%" count_of_comments="8" ]