Praktykowanie zasad SOLID nie wymaga tajemnej wiedzy. Da się je stosować do projektów każdej wielkości. Nie generują narzutu i nie skutkują tym, że liczba klas w projekcie będzie rosła w tempie wykładniczym.
Konkretny przypadek biznesowy i niewymierne korzyści płynące z zasad SOLID. Za kilka akapitów zobaczysz je na własne oczy.
Jeśli jeszcze tego nie zrobiłeś, to zajrzyj na chwilę do artykułu “Community Edition SOLID w służbie czystej architektury”. Dowiesz się dlaczego w ogóle ten SOLID to jest tak ważny.
Pogromcy mitów
Dawno dawno temu, w odległej galaktyce…. wydawało mi się, że zasady SOLID to takie terminy, o których wolno rozmawiać tylko starszym programistom i architektom. Tym mądrzejszym, którzy swoje myśli przelewają na diagramy UML, a nad tymi UML-ami odprawiają magiczne zaklęcia “solid-solid-solid-solid” 🧙♂️.
Drugim mitem było to, że SOLID aplikuje się wyłącznie do zaawansowanych architektur wielkich projektów.
Niemądry ja wtedy – żadne magiczne sztuczki nie są potrzebne.
Zasady są łatwe do zrozumienia, co możesz sam stwierdzić po lekturze poprzedniej części – Community Edition SOLID. Za chwilę przekonasz się o tym, jak z powodzeniem je stosować.
Był sobie startup
Pewien czas temu Tomek, uruchomił startup Mozartify. Wymyślił w nim nowatorskie podejście do sposobu rozliczania, ale o tym w innym poście. Tomek pozyskał fundusze od inwestorów, zbudował i wystawił publicznie prototyp. Użytkownicy zaczęli się rejestrować, baza rosła i rosła aż…
Za dobrze nie jest dobrze
Biznes spotyka pierwsze problemy. Nastąpił niebywały wzrost liczby użytkowników usługi. Relacyjna baza danych ledwo dawała radę. Pojawiły się problemy wiszących zapytań, nieudanych anulowanych transakcji. Wskaźnik nowych rejestracji spadł. Inwestor zaczął zadawać niewygodne pytania.
Wyzwanie skali i rozwiązanie
Z pomocą przyszedł Janek (tech lead, rzemieślnik oprogramowania) wraz ze swoim zespołem. Zdecydował, że to odpowiedni moment na wymianę silnika bazodanowego na NoSQL, który posiada wbudowane równoległe wykonywanie obliczeń, a tym samym daje się skalować. Wybór padł na ElasticSearch i zastosowanie go w operacjach odczytu (odciążenie mechanizmu kontroli wersji z transakcyjnym relacyjnym PostgreSQL).
SOLID-ne skalowanie
Prototyp Mozartify operował na dokładnie jednej klasie (PostgreSqlStorage) odpowiedzialnej za operacje na bazie danych, konkretnie na użytym dialekcie PostgreSQL.
Single Responsibility Principle (SRP)
Klasa PostgreSqlStorage powstała zgodnie z założeniem SRP. Tylko jedna kategoria wymagań biznesowych miała wpływ na zmianę kodu wewnątrz tej klasy – sposób zapisu lub odczytu danych.
To bardzo dobre podejście w początkowej fazie projektu. Zamiast drobnicować kod na wątpliwie potrzebne klasy, trzymamy wszystkie operacje jako jedną całość.
Klasa PostgreSqlStorage pozostała by w pierwotnej formie, gdyby nie wyzwanie skali.
Interface Segregation Principle (ISP)
Janek zauważył, że klasa PostgreSqlStorage jest tzw. niejawnym interfejsem. Niejawny interfejs to skrót implementacyjny. Zamiast tworzyć abstrakcję i ją implementować, po prostu piszesz konkretną implementację. Działa do czasu, aż faktycznie potrzebujesz abstrakcji. A wtedy…
Jeden raz interfejs. Zamawiam
Pierwszą operacją jaką wykonał Janek, była ekstrakcja faktycznego interfejsu Storage.
Co doprowadziło do zależności:
Jeden interfejs to za mało
Ale to nie koniec. Kolejnym krokiem był podział interfejsu na abstrakcje opisujące operacje odczytu i zapisu(ReadOpStorage, WriteOpStorage).
Powstanie ReadOpStorage, WriteOpStorage to właśnie segregacja interfejsu. Kiedy pojawi się nowe kosmiczne rozwiązanie do jeszcze szybszych operacji odczytu, wystarczy że dostarczymy implementację zgodną z interfejsem ReadOpStorage. Nic już nie wymusza implementacji nadmiarowych operacji zapisu.
Tym jednym strategicznym posunięciem Janek zapewnił zgodność kodu z dwiema kolejnymi zasadami.
Open Close Principle (OCP)
Wydzielenie interfejsu Storage otworzyło drogę do nieograniczonej rozbudowy. Pojawi się nowa baza danych? Nie ma sprawy, napiszesz niezbędną implementację. To właśnie Open, czyli możliwość elastycznego modyfikowania zachowania klasy i miejsc, w których została użyta.
Close polega na tym, że nie ma potrzeby dotykania kodu już istniejącego. To z kolei minimalizuje ryzyko błędów i niepowodzenia w projekcie (zanotować: ważny argument do rozmów z Project Managementem 🤓).
Janek to bystry obserwator. Zauważył, że po zaaplikowaniu OCP, może w końcu pokryć Mozartify testami.
Spójrz na kod 2 klas, które mu to umożliwiły: RamStorage i FileStorage. Dopisał je i wykorzystał je do napisania testów jednostkowych i behawioralnych. Persystencja stanu między żądaniami, nie potrzebuje już mockowania i interakcji z silnikiem bazy danych 💪.
A tutaj nagroda za ciężką pracę. Efekt uruchomienia narzędzia behat do testów behawioralnych. 100% zadowolenia😀.
Liskov Substitution Principle (LSP)
Wspomniałem, że zastosowanie ISP otworzyło drogę do zgodności z dwiema zasadami. Zasada podstawień jest właśnie tą drugą.
Czy na pewno LSP to taka oczywista zasada?
Kiedyś myślałem, że LSP to prosta i automatycznie stosowana zasada OOP. Piszesz interfejs (abstrakcję) strzelasz na jej podstawie konkretną implementację i ogień 🔥. Definiujesz abstrakcje jako typy przyjmowane i zwracane przez metody i gotowe!
No właśnie nie. LSP naprawdę łatwo naruszyć. Janek wyjaśnił to swojemu zespołowi na podstawie interfejsu Storage. Popatrz na poniższy kod.
Metoda createNewTenant z pustym ciałem = nic się nie dzieje. Metoda addPackage = wyjątek rzucony użytkownikowi prosto w twarz. Pisząc klasę UseCaseImpl zdecydowanie nie spodziewasz się takiego zachowania.
Dziedziczenie i polimorfizm
Teoria dziedziczenia zezwala na zmniejszanie restrykcji, ale w żadnym wypadku ich zwiększanie. Jeśli abstrakcja ma metodę prywatną createNewTenant, to klasa dziedzicząca z powodzeniem może rozszerzyć wachlarz tego co oferuje swoim klientom i zmienić modyfikator dostępu na public (zmniejszyć restrykcje). Mówiąc prościej: jeśli coś miałeś, to nie możesz nagle przestać tego mieć, ale możesz mieć lepiej i więcej.
Nie wolno kłamać!
Co dzieje się w metodzie createNewTenant? Nic. Zawiera jedynie definicję i komentarz /* do nothing*/ (instrukcję pass w Python). Miałem coś mieć, a jednak nie mam. Kod kłamczuszek! Takie przypadki raczej nie przejdą przez code review. Przynajmniej nie powinny.
Te niedobre wyjątki
A metoda addPackage i wyjątek, co tam jest źle? – spytał Janka jeden z developerów. Odpowiedź znów odnosiła się do teorii dziedziczenia miałem – nie mam.
Na wyjątek patrz jak na restrykcję. Czy widzisz ją w abstrakcji Storage? Nie, brak adnotacji @throws. Zatem implementując metodę UseCaseImpl::execute nie przyjdzie ci do głowy, aby dodać blok obsługi wyjątków try-catch. W realnym świecie wyjątek wystąpi wskutek bardziej wyrafinowanego kodu niż na przykładzie, ale wystąpi. Zobaczysz go w logu aplikacji, może w Sentry, a może jako palący ticket od użytkownika w bugtrackerze.
Dodaj koniecznie sprawdzanie obsługi wyjątków w kontekście LSP na check-listę code review.
Dependency Inversion Principle (DIP)
Na koniec zasada odwrócenia zależności. Czyli kompilacja tego, co Jankowi udało się zbudować, od momentu kiedy pospieszył z pomocą Tomkowi.
Podstawy dotyczące zasady DIP i ideę jej przyświecającą znajdziesz w artykule: “Community Edition SOLID w służbie czystej architektury”.
Solidny rysunek
Popatrz na rozwiązanie dostarczone przez zespół Janka. Co widzisz?
Przepływ sterowania w aplikacji
Konkretny przypadek użycia (UseCaseImpl) wykonuje operacje na bazie danych komunikując się z fasadą (StorageFacade). Dlaczego z fasadą? UseCaseImpl to fragment domeny aplikacji. Chce jedynie wykonać operację biznesową. Nie interesuje go czy pod spodem będzie operacja zapisu czy odczytu. To rola fasady.
Z kolei fasada (StorageFacade) nie martwi się czy korzysta z ElasticSearchStorage czy PostgreSqlStorage. Działa w oparciu o to, co opisują abstrakcje czyli: ReadOpStorage i WriteOpStorage.
Dalej jest granica – gruba niebieska linia, poniżej której znajdują się konkretne szczegóły implementacyjne.
Zależności w kodzie źródłowym
Klasy ElasticSearchStorage i PostgreSqlStorage to wyspecjalizowany kod, obsługujący konkretne silniki bazodanowe. Jakie zależności je opisują? Interfejsy. Obydwie klasy obiecują spełnić kontrakty zdefiniowane w tych interfejsach.
Zwróć uwagę na zwroty strzałek wskazujących na interfejsy. Groty strzałek dotyczących zależności (zamknięte, puste w środku) są skierowane przeciwnie niż te, które określają przepływy sterowania.
Właśnie poznałeś tajemną genezę powstania nazwy dla zasady DIP. Zależności klas są odwrócone względem sterowania. I dokładnie o to chodzi w DIP.
Janek doskonale wiedział, dlaczego dokonał odwrócenia sterowania.
Czysty zysk
Kod zorganizowany zgodnie z zasadą DIP jest niezależny od konkretnych implementacji. Operuje na abstrakcjach. Jest otwarty na nowe sterowniki.
Ta otwartość pozwala na wymienność. Potrzebujesz MySQL? Nie ma sprawy. Zaimplementuj jedynie odpowiedni interfejs i podmień konfigurację kontenerów dependency injection frameworka.
Wymienność otwiera drogę do testowalności, bez uprawiania kaskaderki na mockach i zaślepkach. Piszesz prosty RamStorage albo FileStorage i masz zabawki do testowania unitów lub zachowań (BDD). Ty decydujesz! 🍾
Podsumowanie
W SOLID nie chodzi o to, aby położyć zasady na biurku i wpasować swój kod w jego ramy. SOLID należy traktować jako checklistę i rozpatrywać wszystkie 5 zasad w całości.
SOLID to podstawy nawigacji w kodzie obiektowym. Dzięki nim będziesz mógł:
- poruszać się w kodzie prawdziwie obiektowym, jak po książce z rozdziałami i podrozdziałami (SRP), z opcją pójścia na skróty przez indeksy i skorowidze (ISP)
- rozbudowywać istniejący kod, nie psując go (OCP)
- nazywać, dzielić kod na klasy, interfejsy, abstrakcje i tworzyć zdrowe zależności (ISP, DIP)
- kodować defensywne i chronić swoje dzieła przed nie-SOLID-nymi osobnikami, ale pozostawić kod otwarty na rozbudowę (OCP).
Funkcja trackback/Funkcja pingback