Zanim przekleję z internetu definicję odwrócenia zależności, najpierw poznamy kilka innych terminów, a na koniec wszystko stanie się jasne.
Kierunek działania programu
W angielskich źródłach będzie mowa o flow of control / control flow. W polskich źródłach będzie to też kierunek przepływu sterowania lub bardziej po ludzku, kierunek działania programu / kolejność wykonywania instrukcji.
Póki co przykład. Mało wyszukany lecz nie to jest najistotniejsze w tym przypadku.
Prowadzimy sklep internetowy. Generujemy raport ze sprzedaży z ostatniego miesiąca. Kierunek działania programu, czyli kolejność wywoływania linijek kodu aplikacji byłaby taka:
Interfejs użytkownika (np.: kontroler) -> Logika aplikacyjna (np.: serwis aplikacyjny) -> Data Access (np.: JDBC ) -> baza danych
Mamy do czynienia z prostym przepływem. Wchodzimy do naszego panelu administracyjnego przez przeglądarkę, klikamy przycisk Generuj raport za styczeń, request HTTP trafia do naszego kontrolera. Kontroler deleguje dane requesta do logiki aplikacyjnej, gdzie następuje wywołanie klasy z implementacją metody uderzającą do bazy danych.
Na marginesie, w takim przypadku powinniśmy zastosować architekturę dwuwarstwową (UI->DataAccess, czyli bezpośrednie uderzenie z UI do komponentu Data Access), a nie trzywarstwowa (mam na myśli tę dodatkową, tu: zbędną, środkową warstwę domenowo-aplikacyjną) czy jeszcze jakąś bardziej wyszukaną, ponieważ żadnej logiki biznesowej tutaj nie mamy, więc w naszym wypadku nie mamy powodu, aby komplikować architekturę i tworzyć niepotrzebną złożoność. Ale my się uczymy, więc aby zobrazować DIP przyda nam się ta warstwa.
Moduł wysokiego poziomu VS moduł niskiego poziomu
Co ma wspólnego mój manager projektu, absolwent SGH na kierunku zarządzanie projektami, z bazą danych, którą wykorzystujemy w naszym oprogramowaniu. Niewiele. Ile razy w ciągu roku z jego ust padają słowa baza danych, SQL Server, index, widok? Policzyć można na palcach jednej ręki. Czy interesuje go, czy używamy Hibernate’a czy JdbcTemplate? Pewnie zaczęłoby mu się ulewać gdybym zaczął mu o tym opowiadać. Czy interesuje go w jaki sposób nasza aplikacja wysyła e-maile i SMSy? Nie, grunt że wysyła. Managera, w skrócie, interesuje czy oprogramowanie dostarcza wartość – czy zarabia $$.
Hmm… nasuwa się w związku z tym pytanie. Skoro manager nie wypytuje mnie codziennie o bazę danych, sposób wysyłania maili oraz SMSów, to czy oznacza to, że jest to mało istotne? A może chciałby pytać, tylko nie wie jak zagadać? Programistę interesuje to jak działają jego linijki kodu, które np.: utrwalają encję w bazie. Jego to kręci jak mało co i dziwi się, że ktoś może mieć inaczej. Managera za to bardzo by nie kręciła informacja, że zespół musi coś grzebnąć w klasach realizujących wygrzaną już logikę biznesową, bo na przykład podmieniliśmy bibliotekę do wysyłania SMSów lub e-maili. Jego gorycz byłaby uzasadniona. Czujesz to? Tak na chłopski rozum. To tak, jakby wymiana endoprotezy biodra u pacjenta wymagała jednocześnie z jakichś względów otwarcia klatki piersiowej. Może się wiele wydarzyć, c’nie?
Zderzamy się więc z dwoma, różnymi punktami widzenia. Ten moduł, który jest bliższy managerowi jest modułem wysokiego poziomu niż ten (niskiego poziomu), który odpowiada za sięgnięcie do bazy danych lub wysłanie e-maila. Moduł wysokiego poziomu realizuje funkcje wraz z ich regułami, które są ważne dla właściciela aplikacji, ponieważ oprogramowanie dostarcza mu wartość, wspiera jego biznes, tzn., zarabia $$. Gdybyśmy patrzyli na aplikację z lotu ptaka (czyli w miarę z wysoka) to prędzej dostrzeżemy to co aplikacja faktycznie robi, a nie jej bebechy, które siedzą w jej wnętrzu i nie są dostępne gołym okiem dla użytkownika.
Zależności
Czym są w zasadzie zależności? Tu nie ma na szczęście żadnej tajemnicy i ukrytych znaczeń.
class ReportController {
ReportService reportService = new ReportService();
void generate() {
reportService.generate();
}
}
ReportController zależy od ReportService. Dlaczego? Jeśli kod klasy ReportService się zmieni, np.: zniknie konstruktor bezparametrowy, a pojawi się inny, z parametrem lub metoda generate() zmieni nazwę to klasa ReportController przestanie się kompilować. Jest więc zależna od zmian w klasie ReportService.
class ReportService {
SQLReportRepository reportRepository = new SQLReportRepository();
void generate() {
reportRepository.findBy(...);
}
}
Klasa ReportService jest zależna od klasy SQLReportRepository. Zmiana w implementacji SQLReportRepository może wymagać zmian w klasie ReportService.
Wracając na chwilę do kierunku wykonania programu można zauważyć, że w architekturze warstwowej, kolejność wykonania kodu aplikacji pokrywa się z kierunkiem zależności. Tzn., UI (kontroler) wywołuje serwis aplikacyjny i jednocześnie zależy od niego. Następnie serwis aplikacyjny wywołuje repozytorium i jednocześnie od niego zależy. Klasy te są ze sobą ściśle powiązane. Dokonanie zmian w zasadzie gdziekolwiek może spowodować propagację zmiany w górę. Zmiana w klasie SQLReportRepository może powodować zmianę w ReportService, a nawet jeszcze wyżej – w ReportController. Kiepsko to brzmi. Chcielibyśmy dokonywać zmian w izolacji. Chcielibyśmy, aby nasze klasy były jak najbardziej od siebie niezależne.
Pamiętamy jednak, żeby nie popadać w parnoję i tam, gdzie logika biznesowa nie istnieje lub jest znikoma, architektura dwu/trzywarstwowa w zupełności wystarczy i nie jest przestępstwem. 🙂
Definicja
Definicja nie jest na szczęście super skomplikowana i niesie ze sobą praktyczne rady.
- Moduły wysokiego poziomu nie powinny zależeć od modułów niskopoziomowych. O modułach oraz o ich abstrakcyjnej naturze i nie tylko przeczytasz tu.
W przypadku naszej prostej aplikacji, która generuje raport, nasz kod wysokopoziomowy (ReportService) zależy od kodu niskiego poziomu – SQLReportRepository. Łamiemy tym samym opisaną wyżej regułę. Co zrobić, aby temu zaradzić? Nic trudnego. Co leży w kręgu zainteresowań managera? Jakim językiem on się posługuje? Od managera moglibyśmy usłyszeć: hej, wygeneruj mi raport za luty. Wiemy więc, że na wysokim poziomie, na managerskim (!) poziomie taka czynność jest ważna. Skoro manager używa takiego stwierdzenia, tzn., że musi ono przynosić $$. Co w takim wypadku robimy. Identyfikujemy abstrakcję – wyłuskujemy istotne informacje, którą niewątpliwie jest właśnie generowanie raportu za jakiś okres. Pisałem nieco więcej o tym tu. Czyli to wysokopoziomowe, managerskie, biznesowe słownictwo narzuca nam to, co aplikacja ma robić. W związku z tym w naszej aplikacji utworzymy moduł (np.: javowy pakiet) o nazwie np.: reporting, który będzie zawierał logikę biznesową. Klasę ReportService uniezależnimy od kodu niskopoziomowego (SQLReportRepository) w poniższy sposób:
a) tworzymy w module logiki biznesowej (pakiet reporting) interfejs.
interface ReportRepository {
Report generateBy(MyPeriod period);
}
Jest to nasza abstrakcja, o której pisałem chwilę wcześniej. Nasza klasa ReportService będzie teraz wyglądała tak:
class ReportService {
private final ReportRepository reportRepository;
public(final ReportRepository reportRepository) {
this.reportRepository = reportRepository;
}
public Report generateBy(Date from, Date to) {
return reportRepository.generateBy(period);
}
}
Zniknęła zależność od klasy SQLReportRepository, która zawierała implementację uderzającą do bazy danych. Pozbywamy się z kodu domenowego jakichkolwiek użyć klas z zewnętrznych modułow. Nasz pakiet reporting jest całkowicie niezależny od świata zewnętrznego. Teraz jesteśmy uzależnieni w domenie tylko od interfejsu ReportRepository, który jest plikiem znajdującym się w tym samym pakiecie co ReportService. Jest więc na tym samym poziomie abstrakcji (pakiet/moduł to też abstrakcja!). Zauważ, że teraz za pomocą tego interfejsu to pakiet reporting narzuca słownictwo i swoje potrzeby, nie mówiąc jednak przy tym słowa o technikaliach. Tzn., po nazwie interfejsu oraz po nazwie metody nie jesteśmy w stanie stwierdzić czym jest źródło danych. Czy jest to baza danych, pamięć, czy może jakiś inny system.
Dochodzimy tu do kolejnej składowej definicji:
2) Abstrakcja nie powinna zależeć od detali.
Nazewnictwo naszej abstrakcji (tu: naszego interfejsu), jak powyżej wspomniałem, nie mówi nic o sposobie implementacji. Czyli już na poziomie nazewnictwa możemy mówić o respektowaniu tej reguły. Nie używamy w nazwie interfejsu członu SQL, NoSQL, JPA, Spring / cokolwiek niskopoziomowego.
Dodatkowo nasza klasa MyPeriod stanowi poziom abstrakcji. Dzięki czemu naszego interfejsu nie obchodzi implementacja klasy MyPeriod (możemy zmienić bebechy tej klasy (np.: używać dowolnych klas/bibliotek pozwalających na operacje na datach) i owa zmiana nie będzie wymagać zmian w interfejsie). Ponownie udaje nam się być zgodnymi z powyższą regułą.
Ot co. Zbliżamy się do końca. Ostatnia sprawa.
Struktura modułów (pakietów) bez odwrócenia kierunku zależności.
Ułożyłem pakiety alfabetycznie. Kierunek wykonania programu jest zgodny z kolejnością pakietów (patrząc od góry). Tzn., controller wywołuje ReportService, który następnie wywołuje SQLReportRepository. Kierunek wywołań (kierunek wykonania programu) jest zgodny z kierunkiem zależności.
Nieco inaczej będzie to wyglądać gdy zastosujemy odwrócenie kontroli.
ReportController jest punktem wejścia do programu. Taki punkt zawsze będzie w Twojej aplikacji. Raz będzie to controller, może być to również funkcja main. Możesz o tym przeczytać trochę tutaj. Anyway. ReportController, nie ma wyjścia będzie zależał od modułu reporting wywołując z niego ReportService (w zasadzie w kontrolerze również moglibyśmy uzależnić się od abstrakcji i od jakiejś fabryki która zwraca implementację serwisu, ale darowałem to sobie, żeby niepotrzebnie nie komplikować). Na tę chwilę kierunek zależności i kierunek wywołania programu pokrywa się z poprzednim przykładem. Nieco inaczej dzieje się krok dalej. ReportService nie wywołuje bezpośrednio ReportRepositoryImpl. Pozwalamy natomiast wstrzyknąć do ReportService dowolną implementację interfejsu ReportRepository.
To moduł reporting, jako ten super ważny, narzuca swoje potrzeby. Poniżej owa potrzeba:
A tę potrzebę realizuje już moduł storage:
Podsumowując, nasz ReportService nie woła bezpośrednio modułu storage. Odwróciliśmy zależność. To moduł storage zależy teraz od modułu reporting (implementuje przecież ReportRepository). W module reporting nie będzie żadnego importu, który dotyczy jakiegokolwiek modułu zewnętrznego. To moduły zewnętrzne w swoim kodzie będą miały importy do modułu biznesowego. Zależności idą do środka.
Podsumowanie
Moduł domenowy dostarcza wartość. Wspiera biznes w zarabianiu $$. Z reguły, wygrzana logika biznesowa nie jest często modyfikowana. Jest stabilnym bytem. Jest trudna i ryzykowna. Zatem zawsze lepiej uzależniać się od czegoś, co jest rzadko zmieniane. Uchroni nas to przed ewentualną propagacją zmian. I to właśnie możemy osiągnąć przy pomocy abstrakcji oraz polimorfizmu.
Obrazkowe przykłady w postaci kodu dostępne są tu: https://github.com/wprostychslowach/samples/tree/master/wprostychslowach/dependencyinversion