Wszyscy znają, albo mówią, że znają zasady SOLID, natomiast z GRASP jest już nieco gorzej. Cały GRASP jest zbyt obszerny, ale skrótowo można go opisać jako 9 zasad dobrego projektowania klas i relacji między nimi. Dziś chciałbym skupić się na parze niski coupling i wysoka kohezja, które ostatnio na konferencjach stały się dość popularnymi buzzwordami.
Ja bym chciał pójść nieco dalej i stwierdzić, że coupling i kohezja odnoszą się nie tylko do klas, ale również do funkcji, czy modułów w ujęciu funkcyjnym, lub agregatów w ujęciu DDD, aż do popularnych teraz mikroserwisów. Czyli każdy komponent w systemie powinien charakteryzować się wysoką kohezją. Ogólnie gorąco zachęcam wszystkich do prostego ćwiczenia, które polega na aplikowaniu znanych zasad dobrego programowania do szerszych kontekstów.
Coupling vs kohezja
Kohezja to sposób określenia jak bardzo poszczególne elementy danej klasy do siebie pasują, czyli jak bardzo dana klasa jest spójna. Tu zasada jest prosta, bo im większą kohezją charakteryzuje się dany komponent tym większa szansa, że jest to poprawnie zaprojektowana system, ale szerzej opiszę to później, jak poznamy już coupling, modularyzację, enkapsujację i SRP jako pierwszą zasadę z akromnimu SOLID.
Coupling z kolei najprościej można określić jako miarę powiązań między tymi komponentami, oraz siłę tych relacji. Z coupling jest trochę trudniej, bo ten nie jest aż taki zły i o ile dążymy do niskiego, to nie możemy z niego zrezygnować w całości i w sumie musi występować. Jeśli byśmy mieli system zbudowany z modułów, które w ogóle się ze sobą nie komunikują, to nie przyniosłyby one żadnej wartości. Dlatego te powiązania powinny być przede wszystkim przemyślane, a nie jako wynik przypadku.
Ogólnie temat modularyzacji, enkapsulacji i kohezji jest bardzo ciekawy i myślę, że powinienem opisać je szerzej w innych wpisach. A w tym wpisie zajmiemy się dokładnie samym couplingiem. Myślę, że będzie to dobry punkt wyjścia do dalszych wpisów.
No dobra, wiemy z grubsza czym jest coupling. A teraz podejdźmy do tego od strony praktycznej. Zdarzyło Ci się przeglądać kod w którym wszystko rozmawiało ze wszystkim? Kod, w którym ilość zależności była tak duża, że debugując prostą ścieżkę biznesową przeszedłeś przez ponad połowę systemu? Kod, w którym jedna prosta zmiana powodowała zmianę w kilku innych miejscach? Albo ostatecznie kod, w którym dodanie checkboxa wiązała się z przebudową tylu fragmentów, że szacowane było na pół sprintu? Takie właśnie systemy charakteryzuje się bardzo wysokim couplingiem.
Rodzaje couplingu
Jak już wspomniałem coupling sam w sobie nie jest zły i jest kilka jest jego kilka rodzajów. Poznajmy zatem krótko 6 rodzajów couplingu uszeregowanych od najmniej pożądanego, do takiego, który jest jak najbardziej pożądany.
- Content Coupling - To najgorszy rodzaj couplingu, w którym moduł sięga do wnętrzności implementacyjnych innego modułu. Jest najgorszy z wielu powodów, ale dwa najważniejsze są takie, że zmiana w jednym miejscu często wymaga zmianę w innych miejscach oraz fakt, że wprowadzając zmianę możemy zepsuć inny, logicznie niezwiązany element
- Common Coupling - Ten rodzaj couplingu opiera się o jeden wspólny, współdzielony, często globalny stan. Tutaj już nie sięgamy bezpośrednio do wnętrzności innych modułów, ale dalej zmiana w jednym module może spowodować niespodziewane zachowanie w innym
- External Coupling - Ten rodzaj couplingu najczęściej występuje jeśli odwołujemy się do elementów z zewnętrznych systemów, czy bibliotek. Tu zachęcam do zapoznania się z ACL (Anti-Corruption layer), czyli zamknięciu zewnętrznego, niestabilnego konceptu w jednym module, który dla reszty systemu będzie stabilny
- Control Coupling - Tutaj mamy sytuację w której Ty korzystając z innego komponentu jawnie przekazujesz jakiś parametr, który pozwala Ci sterować oczekiwanym przez Ciebie zachowaniem. Ten rodzaj couplingu akurat akceptuję w przypadku bibliotek, jak np. parametr reversed, żeby posortować listę w odwrotnej kolejności. Natomiast w przypadku własnych modułów często jest to niepożądana cecha, bo musisz wiedzieć jaki to parametr i co robi. Lepiej wtedy napisać dwie funkcje, lub metody.
- Stamp Coupling - to już jest bardziej pożądany coupling i polega na tym, że jeden moduł przekazuje drugiemu pewną strukturę, ale ten korzysta tylko z części pól. Jest już znacznie lepiej, ale problem jest taki, że często możemy skończyć z przekazywaniem całego obiektu mimo iż potrzebny jest na przykład tylko identyfikator. To jest niebezpieczne dlatego, że zamiast zastanowić się co jest potrzebne do konkretnej akcji, dodaje się kolejne pole do struktury i to zalążek do powstania wielkiej kuli błota.
- Data Coupling - idealny stan w którym przekazujemy tylko te dane, których potrzebujemy i nic więcej. Tak naprawdę zrozumiałem, że jest to optymalny rodzaj couplingu po nabraniu doświadczenia w programowaniu funkcyjnym, gdzie funkcja otrzymuje konkretne wejście i na tej podstawie zwraca konkretny wynik
Mocny coupling vs luźny coupling
Prawdopodobnie i tak nikt nie zapamięta tych rodzajów couplingu. I to jest w porządku, bo zamiast znać te rodzaje lepiej zapamiętać różnicę między mocnym ( tight ) i luźnym ( loose ) couplingiem. Mocny coupling, to taki w którym moduły znają swoją wewnętrzną implementację, z kolei luźny to ten w którym moduły rozmawiają ze sobą za pomocą ustalonego interfejsu. Łatwo więc zauważyć, że content coupling jest sztandarowym przykładem mocnego couplingu. Czy to wszystko nie brzmi jakoś znajomo? Jeśli nie, to zachęcam wrócić do początku wiedzy o programowaniu obiektowym, w którym hermetyzacja i enkapsulacja to swoisty święty gral. Tak więc tak proste zasady jak ukrywanie szczegółów implementacyjnych i udostępnienie API dla komponentów pozwoli na zbudowanie systemu o luźnym couplingu.
Coupling semantyczny
To jest coś czego nauczyłem się oglądając wystąpienia Sławka Sobótki oraz z kursu DNA. O ile wcześniej wspomniane rodzaje couplingu można jakoś zmierzyć, to tego już nie zmierzymy tak łatwo. Ale wyjaśnimy o co tutaj chodzi.
Weźmy na przykład prosty system, w którym użytkownik ma jakieś statusy. Statusy te wiążą się z konkretnymi profitami, bądź karami. Na przykład klient typu VIP dostaje 10% rabatu przy wszystkich zakupach, a klient który notorycznie spóźnia się z płatnościami ma limit zakupów do 1000 złotych.
Dobrze przeprowadzona analiza pozwoliłaby dojść do podziału na sensowne moduły i przyjmijmy, że po takiej analizie wyłuskaliśm moduł użytkownika, statusu, katalogu, produktu oraz płatności. Ciekawy jest moduł statusu, bo mamy tu ważną decyzję do podjęcia. Mianowicie, który moduł powinien zależeć od którego. Można podejść tak, że to moduł płatności wysyła do modułu statusu "zostań VIP". Można też podejść odwrotnie, bo moduł płatności wysyła informację o kolejnej zaległości i system statusów potrafi odczytać tę wiadomość oraz przetłumaczyć to na status. Jak wiemy nie powinniśmy robić systemu w którym oba systemy patrzą na siebie wzajemnie.
Pozornie zadanie jest bardzo proste, ale jakby się zastanowić to musimy podjąć decyzję, w którym module powinna być informacja o tym drugim.
Które podejście jest lepsze? Nie ma jednej odpowiedzi, tak samo jak nie ma jednej dobrej architektury. Dlaczego płatność, która często jest generyczna ma rozumieć czym jest status, skoro to zupełnie niezwiązany koncept? Ale odwrotnie również brzmi to absurdalnie, bo status to ostatnie miejsce w którym szukałbym informacji o płatności.
Coupling semantyczny w mikroserwisach
O ile w monolicie cieknąca logika nie kopnie nas szybko, to w mikroserwisach jest już znacznie gorzej. Tutaj granice są bardziej sztywne i komunikacja częściej przebiega asynchronicznie. Weźmy tu na tapet system do hazardu online, który ma między innymi mikroserwis do reputacji, która ma jakiś wskaźnik punktowy, moduł płatności odpowiedzialny za rozliczanie użytkownika i moduł rozgrywki, który odpowiada za obstawianie zakładów. Zastanówmy się jak będzie lepiej. To wszystkie moduły powinny wysyłać zdarzenie zmień reputację, czy może odwrotnie i każdy mikroserwis wysyła swoje standardowe zdarzenie, a mikroserwis reputacji nasłuchuje i tłumaczy te zdarzenia u siebie. Wydaje mi się, że ta druga opcja jest lepsza, bo reputacja może zależeć od bardzo wielu czynników i podejrzewam, że algorytm wyliczania reputacji może się często zmieniać. Jeśli ten algorytm ma się wiele razy zmieniać, to lepiej żeby był zamknięty w jednym miejscu, a nie rozsiany po całym systemie. Dlatego właśnie minimalizujemy logikę po stronie innych serwisów, wiążąc się z tym, że mikroserwis do reputacji ma wiedzę, a więc coupling semantyczny do wielu usług dookoła. I to jest jak najbardziej OK.
A jeśli limit debetowy w systemie płatności zależy od reputacji? Zastanówmy się co lepsze, komenda "zmień limit" z reputacji do płatności, czy zdarzenie "zmieniła się reputacja", na czym nasłuchuje płatność i zmienia limit. A no znowu to zależy. Podobnie jak wcześniej musimy wiedzieć czy to płatność będzie potrafiła przetłumaczyć konkretny status na limit, czy rozszerzamy reputację o wiedzę jakie limity wiążą się z danym statusem.
Podejrzewam, że jeśli więcej mikroserwisów nasłuchiwałoby na zdarzenie zmiany statusu, to w płatności zrobiłbym właśnie przez nasłuchiwanie.
Podsumowując coupling semantyczny, to taki, który nie dotyczy bezpośrednio kodu, a bardziej ogólnych konceptów. Czyli, że jeden moduł rozumie coś, co nie jest z nim bezpośrednio związane, albo rozmawia językiem który zdecydowanie wykracza poza jego zakres. Jeśli chcemy żeby system dostarczał jakąś wartość, to komunikacja między tymi modułami musi wystąpić! Naszym zadaniem jest zadbanie o to, żeby kierunek zależności był przemyślany, a nie wynikał z przypadku.
Miary couplingu
Jeśli mówimy o jawnym couplingu, który wynika bezpośrednio z kodu, to możemy go zmierzyć. Warto tu wspomnieć, że mamy coupling wejściowy i couplingu wyjściowy. Stosunek couplingu wyjściowego do sumy couplingów wyjściowego i wejściowego, to miara niestabilności.
instability = efferent_coupling / (efferent_coupling + afferent_coupling)
Widać zatem, że miara niestabilności waha się od 0, dla komponentów które nie mają żadnych wyjściowych połączeń, do 1 dla fragmentów systemu, które mają tylko wyjściowe połączenia i żadnego wejściowego.
I teraz na przykładzie warstwa prezentacji będzie miała wysoki wskaźnik couplingu bo korzysta z modułów warstwy niższej, natomiast żaden inny moduł nie wykorzystuje bezpośrednio tej warstwy. Z kolei warstwa persystencji powinna mieć wartość zerową, bo sama nie korzysta z innych modułów ale sama jest często wykorzystywana.
Możemy mieć też dwie inne sytuacje. Jeśli mamy 0 po stronie couplingu wejściowym i 0 po stronie couplingu wyjściowym, to dany komponent najprawdopodobniej nie jest przez nic używany i może zostać usunięty. Jeśli natomiast po obu stronach mamy wartości różne od zera, to jest to sygnał do przeanalizowania danego fragmentu. Oczywiście nie oznacza to, że od razu moduł jest błędny, bo w klasycznej architekturze warstwowej kolejne warstwy zależą od siebie i jest to naturalne.
Stabilność, a coupling
Wiemy już czym jest coupling, potrafimy go zmierzyć i powinniśmy zwracać uwagę na kierunek zależności. Ale czy są jakieś wskaźniki, albo złote zasady, które pomogą określić jak te zależności powinny wyglądać.
Pierwszą kwestią tutaj może być określenie co się częściej zmienia. Wróćmy do wcześniejszego przykładu i zastanówmy się w jaki sposób ktoś zostaje VIP. Jeśli ktoś może zostać VIPem z wielu różnych przyczyn, to warto, żeby taka logika była zamknięta w module statusów.
A może nowe statusy dochodzą bardzo często? Wtedy również sensowne jest żeby to moduł od statusów znał te wszystkie koncepty.
Z kolei inną miarą może być próba znalezienia wymagania, które podważa nasze pierwotne rozwiązanie. Niech będzie to sytuacja, w której obecnie robimy system tylko na Polskę, natomiast w przyszłości będzie planowana rozbudowa na całą europę. Być może wtedy w każdym kraju będzie inny sposób płatności, a statusy będą wyliczane wg specjalnego wzoru, który można zdefiniować za pomocą jakiejś prostej funkcji matematycznej, dostarczonej od klienta. Wtedy lepiej żeby płatność jako niestabilny koncept patrzył na statusy, które są stabilne i których nie chcemy zmieniać.
Stabilność jest tutaj bardzo ważna. Bo naiwne podejście suchymi liczbami może wprowadzić nas w błąd. Lepiej żeby moduł zależał od pięciu modułów, które nie zmieniły się od lat, czy tylko od dwóch, ale takich, które zmieniają się średnio co kilka miesięcy? A no zdecydowanie lepiej zależeć od modułów, które charakteryzują się dużą stabilnością.
Coupling w prawdziwym świecie
Zobaczmy alegorie couplingu do rzeczywistych przykładów, żeby lepiej zrozumieć ten koncept.
Pierwszym przykładem mogą być wagony pociągu. Każdy wagon jest połączony z kolejnym bardzo mocno, a mimo to dodawanie nowych, lub usuwanie istniejących wagonów można zrobić bardzo szybko.
Kolejnym i moim ulubionym jest rakieta kosmiczna. Widzieliście kiedyś jak kolejne części rakiety odczepiają się od niej w kolejnych fazach lotu? To rewelacyjny przykład w którym rakieta jako całość ma dołożone kolejne silniki, które po spełnieniu swojej roli odczepiają się, żeby zmniejszyć masę rakiety.
Dalej nie musimy sięgać daleko, bo zapewne każdy z Was jeździ samochodem. Samochód jako całość jest jednym produktem, jednak prawie każdy komponent można wymienić niezależnie. Wymieniając koła, nie musisz interesować się sprzęgłem i odwrotnie. Niestety widząc swój kod sprzed kilku lat, to żeby odnieść to do samochodów, to żeby wymienić żarówkę prawdopodobnie musiałbym wymienić silnik, bo " biznes kazał na szybko zrobić gaszenie świateł przy zgaszeniu pojazdu ".
Coupling w praktyce
Poznaliśmy teorię, więc przejdźmy trochę do praktyki.
Sprzedaż i płatność
class Sales:
def __init__(self, payment):
self.payment = payment
# ----
class Payment:
def __init__(self, sales):
self.sales = sales
Płatność zależy od sprzedaży, czy sprzedaż zależy od płatności? Co jest bardziej stabilne? Jeśli od dawna sprzedajemy te same produkty, ale eksperymentujemy z nowymi sposobami płatności, że lepiej wybrać tę drugą opcję w której to płatność patrzy na sprzedaż. Jeśli z kolei nie kombinujemy i płatność mamy zamkniętą w stabilnym pudełku ale sprzedajemy produkty fizyczne i przewidujemy, że w przyszłości być może sprzedawane będą również na przykład kursy online, to wtedy lepszym rozwiązaniem będzie pierwsza opcja.
Prawo Demeter
Bardzo ciekawa zasada, którą w poprzedniej pracy nazywaliśmy zasadą pojedynczej kropki. Chodziło o to, żeby zminimalizować wiedzę o modułach z którymi się komunikujemy. Zobaczmy przykład kodu, który łamie tę zasadę
class Role:
def __init__(self, permissions):
self.permissions = permissions
class User:
def __init__(self, role):
self.role = role
class UserView:
def __init__(self, user):
self.user = user
def has_permission(self, permission):
return permission in self.user.role.permissions
Dopiszmy do tego test
import unittest
class UserViewTest(unittest.TestCase):
def test_return_true_if_user_has_permission(self):
role = Role(["role.create", "role.show"])
user = User(role)
view = UserView(user)
self.assertTrue(view.has_permission("role.create"))
def test_return_false_if_user_has_not_permission(self):
role = Role(["role.create", "role.show"])
user = User(role)
view = UserView(user)
self.assertFalse(view.has_permission("role.delete"))
if __name__ == '__main__':
unittest.main()
~
Co tu jest nie tak? A no zobaczmy, jak teraz wyglądają zależności.
- Rola nie zależy od niczego
- Użytkownik zależy od roli
- Widok zależy od użytkownika i od roli
I właśnie ta funkcja has_permission w widoku łamie zasadę, bo odnosi się do wnętrzności użytkownika. Sięga bowiem po rolę i uprawnienia tej roli. Czyli najprościej mówiąc prosty widok jest bardzo mocno związany z dwoma konceptami. Spróbujmy zminimalizować tę zależność zgodnie z prawem Demeter, czyli chowając szczegóły implementacyjne.
class Role:
def __init__(self, permissions):
self.permissions = permissions
class User:
def __init__(self, role):
self.role = role
def permissions(self): # dodałem nową metodę, zamykająca logikę pobrania uprawnień
return self.role.permissions
class UserView:
def __init__(self, user):
self.user = user
def has_permission(self, permission):
return permission in self.user.permissions() # odwołanie do uprawnień użytkownika zamiast do uprawnień z roli
Napisany wcześniej test przeszedł, a więc wiemy, że kod dalej działa. Część osób może zarzucić, że tylko zaciemniliśmy kod. Ale przeanalizujmy to. Co może się zmienić.
- Sposób przechowywania uprawnień w roli może się zmienić - zmianę mamy zamkniętą w jednym miejscu
- Rezygnujemy z konceptu roli i użytkownik ma uprawnienia ma nadawane w inny sposób - zmianę mamy tylko w użytkowniku
- Dochodzi nowy widok (np widok pdf), który również musi sprawdzać uprawnienia - coupling tylko do użytkownika
- Użytkownik dostaje listę własnych uprawnień, które rozszerzają uprawnienia z roli - widoku nie musimy ruszać, bo cała logika jest już zamknięta
Wszystkie wymienione zmiany zamknięte są w jednym miejscu. Zresztą zasymuluj ostatnie dwa przypadki.
Dochodzi nowy widok, który inaczej reprezentuje dane
class PdfUserView:
def __init__(self, user):
self.user = user
def has_permission(self, permission):
return permission in self.user.permissions()
# pozostałe metody nas nie interesują
Teraz dochodzi mechanizm zezwalający użytkownikowi na rozszerzenie uprawnień
class User:
def __init__(self, role, permissions=None):
self.role = role
self.custom_permissions = permissions or []
def permissions(self):
return self.role.permissions + self.custom_permissions
Dzięki temu, że sposób reprezentacji uprawnień jest zamknięty u użytkownika, to zmiana i nawet wywrócenie sposobu ich przetwarzania do góry nogami dalej jest zamknięta w jednym miejscu i dopóki oba widoki operują na API jakim jest metoda permissions nic nie powinno się zepsuć.
Ukrywanie wewnętrznej struktury
A teraz zobaczmy przykład w którym jeden moduł rozumie w jaki sposób przechowywane są dane zależnego modułu. Niech będzie to kurs online, który składa się z kilku odcinków. Długość kursu, to suma długości odcinków.
class Episode:
def __init__(self, name, length):
self.length = length
self.name = name
class Course:
def __init__(self, episodes=None):
self.episodes = episodes or []
def length(self):
return sum([episode.length for episode in self.episodes])
pattern_maching = Episode("Elixir Course - Pattern Matching", 100)
guards = Episode("Elixir Course - Guards in practice", 200)
course = Course([pattern_maching, guards])
print(course.length())
A co jeśli zmieniamy sposób prowadzenia szkoleń i kurs to nie jest lista odcinków, tylko całodniowe wydarzenie i czas trwania to na przykład czas od otwarcia, do zamknięcia budynku w którymi dany kurs czy szkolenie się odbywa?
Podsumowanie
Jak widać temat couplingu jest tematem bardzo szerokim, ale jednocześnie stosunkowo prostym. Na pewnym poziomie wszystkie dobre praktyki programowania zaczynają się przenikać i można zauważyć, że jedna wynika z drugiej. Tak samo jest też tutaj, bo dobra modularyzacja, enkapsulacja logiki biznesowej i trzymanie się spójnego interfejsu to wprost definicja prawa demeter. Z kolei kod zbudowany zgodnie z pierwszą zasadą SOLID, czyli SRP cechuje się wysoką kohezją, a ta z kolei prowadzi do niskiego couplingu. Oczywiście na początku wszystko wydaje się zawiłe, jednak gwarantuje, że wystarczy się przyłożyć, żeby zacząć dostrzegać te fakty.
Tak więc podsumowując, coupling nie jest taki zły jak go malują. Występować będzie zawsze i naszym zadaniem jako programistów czy architektów jest dbanie o to, żeby był luźny i przemyślany. Dzięki temu kod będzie czytelniejszy i łatwiejszy w utrzymaniu.
Jeśli spodobał Ci się temat couplingu i chcesz dalej zagłębić się w tematy modularyzacji, ACL, kohezji i innych podobnych tematów koniecznie zostaw informację w komentarzu. A może masz jakieś własne przemyślenia i z czymś się nie zgadzasz? Również zostaw taką informację. Jestem pewien, że może z tego wyniknąć ciekawa dyskusja.