Nie komplikuj swojego kodu. Dependency Injection w iOS

geek.justjoin.it 8 miesięcy temu

Dependency Injection, DI, wstrzykiwanie zależności – wszystkie te określenia na pewno zna, lub chociaż słyszał o nich niemal każdy adept programowania, bez względu na technologię, stopień wtajemniczenia czy światopogląd. Przez ostatnie lata powstało tyle artykułów, opracowań czy rozważań na temat DI, iż chyba zostało już na jego temat napisane niemal wszystko i nie ma potrzeby pisać ani mówić nic więcej. A jednak…

Z jakiegoś powodu znalazłeś lub znalazłaś się w tym miejscu w sieci i czytasz te słowa. Coś tutaj chyba jednak poszło nie tak. Czy zatem rzeczywiście temat jest wyczerpany? Wydaje mi się, iż przez ostatnie lata szczególnie w moim małym świecie – języka Swift i aplikacji na systemy iOS i MacOS – DI to problem wiecznie żywy i dyskutowany, a w mojej opinii również wciąż nierozwiązany.

1. Powtórka z inżynierki – czym jest wstrzykiwanie zależności?

Dependency Injection (dalej DI) to żaden nowy koncept ani też coś, o czym najpewniej nie słyszałaś/łeś. Nie jest to też coś skomplikowanego na poziomie koncepcyjnym. Wstrzykiwanie zależności nie jest również rozwiązaniem dedykowanym wyłącznie ułatwieniu pisania testów automatycznych, co słyszałem w swoim zawodowym życiu już nazbyt wiele razy, by tego przytyku w tymi miejscu uniknąć.

Bardzo spodobał mi się komentarz James’a Shore’a, który w swoim artykule z 2006 (sic!) napisał:
„Dependency Injection” is a 25-dollar term for a 5-cent concept.

Dla porządku zatem przytoczę definicję ($25) a potem w krótkich słowach wyjaśnię ($0.05) to pojęcie. Nieomylna Królowa Wszechwiedzy – Wikipedia mówi nam tak:

Wstrzykiwanie zależności (ang. Dependency Injection, DI) – wzorzec projektowy i wzorzec architektury systemu polegający na usuwaniu bezpośrednich zależności pomiędzy komponentami na rzecz architektury typu plug-in. Polega na przekazywaniu gotowych, utworzonych instancji obiektów udostępniających swoje metody i adekwatności obiektom, które z nich korzystają (np. jako parametry konstruktora). Stanowi alternatywę do podejścia, gdzie obiekty tworzą instancję obiektów, z których korzystają np. we własnym konstruktorze.

Rzeczywiście, brzmi to groźnie, ale jak się nad tym zastanowić, to jest to bardzo prosty koncept. Zamiast pozwalać obiektom na samowolne tworzenie instancji innych obiektów, których potrzebują w toku istnienia do wykonania konkretnych zadań, chcą one przyjąć już usankcjonowane obiekty. Kropka. Zakładając najłatwiejsze podejście do DI, czyli przekazanie parametrów wejściowych w konstruktorze (tzw. odwrócenie zależności), w najpiękniejszym języku programowania świata, czyli w Objective-C, będzie to wyglądało następująco:

I obiecuję, iż to już ostatni listing w tym języku, ale użyty nieprzypadkowo, bo prowadzi nas idealnie do kolejnego przystanku na tej trasie.

2. Czy koncept wstrzykiwania zależności w iOS jest czymś nowym?

Czy DI w świecie inżynierii aplikacji na system iOS to coś zupełnie nowego? Odpowiedź wydaje się być oczywista – skoro coś tak egzotycznego, jak Objective-C pozwalało na zastosowanie różnych podejść do pracy z zależnościami, to pewnie wraz z rozwojem tego konceptu w innych technologiach programiści iOS (dla uproszczenia będę używał iOS, ale chodzi o ekosystem Apple) też go gwałtownie zaadaptowali. Otóż temat jest nieco bardziej skomplikowany.

Obserwując rozwój całego ekosystemu programistycznego od ponad 12 lat, jak i zresztą samych programistów na platformie, zobaczyłem wiele ciekawych prawidłowości. App Store u swych początków spowodował ogromne zainteresowanie ludzi spoza branży IT, aby spróbować swoich sił i zacząć „zabawę” w pisanie aplikacji mobilnych na system iOS, być może przy tym budując swój biznes. Początki App Store to była prawdziwa gorączka złota i każdy chciał się na tę falę załapać. Wówczas często najlepszym źródłem wiedzy była dokumentacja techniczna. Tak – ludzie kiedyś korzystali głównie z dokumentacji technicznych.

Apple natomiast nigdy nie promował ani nie narzucał żadnych wzorców programistycznych. I mówimy tutaj o najprostszych wzorcach, jak fabryki czy repozytoria. Można powiedzieć, iż UIKit czy AppKit (natywne frameworki do budowania aplikacji odpowiednio na iOS i MacOS) były tworzone z ideą implementacji wzorca MVC (Model-View-Controller), ale nie przeszkadzało to firmie prezentować przykładów całej aplikacji napisanej w jednym, biednym pliku AppDelegate. Tysiące linii zamkniętych w jednym pliku, ale skoro kod pisała jedna czy dwie osoby, to po co marnować czas na złożone koncepty czy wzorce projektowe?

Co więcej – inaczej niż Java czy .Net, iOS stosunkowo niedawno stał się poważną platformą z perspektywy dużych korporacji, które pragnąc mieć swoją aplikację w App Store, zaczęły powiększać swoje składy deweloperskie o programistów z tej platformy. W konsekwencji minęły lata nim pewne praktyki i wzorce typowe dla kodu korporacyjnego zaczęły wchodzić na „jabłkowe” salony. I taka też jest prawda – to specjaliści z innych światów przynieśli do Objective-C wzorce takie, jak bohater tego artykułu.

Ważne jest zrozumienie powyższej lekcji historii, bo ułatwia ona zrozumienie tego, dlaczego iOS, i aplikacje mobilne w ogóle, są dość specyficznym podwórkiem w świecie wytwarzania oprogramowania. Od małych, prostych, nieskalowalnych, tworzonych przez jedno-dwuosobowe zespoły programów przeszliśmy transformację po dzisiejsze zmodularyzowane, skomplikowane projekty tworzone przez dwudziesto-trzyciestoosobowe składy. I tak jak w innych technologiach, tak i tutaj nie mogło się obyć bez problemów.

3. DI u zarania Swifta – małe aplikacje

W czasach pojawienia się języka Swift (2014) przez cały czas dominowało raczej luźne podejście do adaptacji wzorców projektowych, ale zespoły posiadające doświadczenia produkcyjne i wiele rozwiązanych problemów – zaczynały powoli coś adaptować i opisywać. DI nie jest tym samym, co proste odwrócenie kontroli (Inversion of control), gdyż zakłada ono pewien ustrukturyzowany system, który odpowiada za tworzenie i wstrzykiwanie gotowych instancji do innych obiektów w dół drzewiastej hierarchii aniżeli tylko prostą zmianę miejsca ich inicjalizacji. Aplikacje mobilne przez lata były monolitami (i technicznie rzecz biorąc przez cały czas nimi są) i najprostszym stosowanym rodzajem DI było po prostu trzymanie instancji obiektów globalnych jako singletony.

Nieco bardziej wyrafinowane rozwiązanie spotyka się do dzisiaj w przypadku aplikacji, które nie są podzielone na moduły, szczególnie przez wzgląd na łatwość implementacji. Zakłada ono, iż tworzymy kontener z zależnościami, który to kontener jest dostępny z poziomu całego systemu:

class DependencyContainer { static let current = DependencyContainer() let dependency: Dependecy! }

Przy starcie aplikacji możemy najpierw utworzyć wszystkie wymagane zależności, a następnie wykorzystać je jako gotowe obiekty tam, gdzie tego potrzebujemy. Problemów z takim podejściem jest sporo, zaczynając od problemu z wartościami domyślnymi (jeśli chcemy unikać wartości opcjonalnych), przez problemy z testowalnością (w izolacji), aż po bardzo silne związanie (ang. coupling). Co więcej – tego typu struktury mają silną tendencję do ciągłego „puchnięcia” i rozrastania się wprost proporcjonalnie do rozwoju aplikacji. Widziałem już projekty zawierające po 200-300 zależności w ramach jednego kontenera dostępnego z dowolnego miejsca.

Najprostszym rozwiązaniem problemu mogą być oczywiście mniejsze kontenery z jasną linią podziału odpowiedzialności, np. jeden kontener per ekran. W bardzo wczesnych wersjach języka Swift programiści szeroko stosowali wzorzec fabryki do wstrzykiwania zależności w momencie, kiedy było to wymagane:

o już zalążek czegoś na kształt dynamicznego budowania zależności. Nie jest źle.

Niestety to tylko pozornie rozwiązuje niektóre problemy, w innych miejscach tworząc nowe. Wiele kontenerów to wiele miejsc do utrzymywania oraz trudność w odnalezieniu interesującej nas zmiennej. Z plusów – przykładowo możemy założyć, iż zależność nie istnieje i rzucić wyjątek nim obiekt zostanie zainicjalizowany, zamiast próbować odpakować nieistniejącą zależność i narazić się na crash. W rzeczywistości w projektach o małej złożoności i w czasach przed wprowadzeniem przez Apple biblioteki SwiftUI, takie podejście wydawało się wystarczające, o ile panowaliśmy nad liczbą tych przechowywanych obiektów i ich stanem. Zatem jest to też pierwszy krok w procesie ewolucji ekosystemu i podejścia do tworzenia aplikacji. W kolejnych etapach rozwoju programiści i architekci będą zmagać się ze skalowaniem aplikacji i będą musieli zastanowić się nad podejściem do DI w czasach wszechobecnej modularyzacji.

4. Co z DI, kiedy projekt rośnie? (Monolit na dużą skalę)

Kiedy projekt zaczyna rosnąć i rozwijać się, w którymś momencie pojawia się potrzeba modularyzacji. To ostatnio bardzo popularny w świecie aplikacji mobilnych termin oznaczający rozbicie projektu z monolitu na mniejsze, odseparowane od siebie moduły. To podejście zyskało na znaczeniu dopiero w ostatnich latach, gdyż wcześniej środowisko Xcode nie pozwalało na zbytnie szaleństwo w tym zakresie. Aplikacje mobilne zawsze powstawały jako monolity i tak naprawdę do dzisiaj technicznie nimi są. Inaczej niż mikrousługi w architekturze serwerowej – moduły w ramach jednego projektu przez cały czas łączy jeden wspólny plik projektu, repozytorium (choć nie koniecznie musi) i – co ważne – wspólne zależności.

Z perspektywy innych technologii czy mitycznego biznesu aplikacja mobilna to jeden spójny komponent, rysowany na wszystkich schematach architektury jako jeden malutki węzełek na końcu skomplikowanego łańcucha usług. Kolejną różnicą jest fakt, iż projekt uruchamia się jednocześnie choćby jeżeli aktualnie pracujemy wyłącznie w ramach naszego modułu. Możemy oczywiście symulować zachowanie reszty aplikacji, ale do tego potrzeba nam – no właśnie – dobrze zaprojektowanego wstrzykiwania zależności.

Zatrzymajmy się w tym miejscu na chwilę i spróbujmy odpowiedzieć sobie na pytanie – dlaczego dependency injection w iOS jest przez cały czas zagadnieniem, który generuje problemy?

5. Prawdziwa moc DI, ale też największa słabość – duży projekt i modularyzacja

Ze wszystkich znanych mi cytatów z popkultury ten wydaje mi się tak oklepany, jak adekwatny, więc jeszcze raz użyjmy wujka Bena do upiększenia naszych rozważań:

„Wielka siła to wielka odpowiedzialność” – Wujek Ben ze Spider-Mana

Modularyzacja to w terminologii aplikacji mobilnych proces prowadzący do rozbicia jednego dużego modułu, który zawiera cały kod aplikacji z wyłączeniem zewnętrznych zależności, jak dynamiczne biblioteki, czy frameworki. Taki proces jest dość skomplikowany szczególnie przy już istniejących, dużych aplikacjach, których nie chcemy przepisywać od zera. A to dlatego, iż aby projekt zmodularyzować, to trzeba go rozmontować, postawić linię podziału we właściwym miejscu jednocześnie zapewniając, iż wszystko będzie działało po staremu. A jak wiadomo średniowieczna prawda programowania mówi wyraźnie:

„Jak działa, to nie ruszać” – Jakiś Programista, gdzieś kiedyś w internecie.

Zazwyczaj jest tak, iż projekt dzieli się na tzw. moduły biznesowe oraz systemowe. Te pierwsze zawierają najczęściej kod realizujący jakiś konkretny proces biznesowy. Będzie to więc ekran lub kilka ekranów, służących przeprowadzeniu użytkownika przez jakiś proces – płatności, onboarding, czy czat. Moduły systemowe to takie, które mają wspierać moduły biznesowe. To przykładowo takie zawierające globalne komponenty interfejsu użytkownika, klasy pomocnicze, providery czy repozytoria. Zatem w momencie rozbicia, poszczególne moduły stają się takimi małymi mikrousługami, które muszą się jakoś między sobą porozumiewać, tworząc drzewo wzajemnych zależności.

No właśnie! Zależności!

Przebywając tą przydługawą historię rozwoju podejścia do programowania na platformie iOS doszliśmy do momentu, w którym DI nie jest już fajnym patternem, a staje się podstawą funkcjonowania całej aplikacji! Bez dobrego wstrzykiwania i zarządzania zależnościami, modularyzacja aplikacji staje się praktycznie niemożliwa. Na tym etapie pojawiają się podstawowe pytania: gdzie postawić linię podziału, co powinno być zależnością, a co nie?

Małe kontenery jako singletony

Pierwszym przykładem rozwiązania problemu zależności w przypadku zmodularyzowanego projektu, może być po prostu granulacja zaprezentowanego wcześniej podejścia. Każdy moduł aplikacji posiadać będzie swój kontener z zależnościami. Będzie on singletonem, a wszystkie potrzebne zależności będą mu wstrzykiwane gdzieś w okolicach momentu uruchamiania aplikacji, logowania użytkownika – to zależy od momentu, w którym dane są już dostępne. Takie podejście oczywiście nie rozwiązuje problemów z etapu bycia monolitem, no może poza tym, iż granulacja powoduje zmniejszenie się ilości zależności w jednym miejscu. Pozornie.

Nadal jednak potrzebujemy zgromadzić wszystkie zależności w module głównym, a następnie przekazać referencje do nich w dół hierarchii. Co więcej, do jednego obiektu referencje mogą być trzymane w kilku różnych, mniejszych kontenerach, a zatem zmiana stanu danego obiektu w jednym miejscu może skutkować trudnymi do wykrycia efektami ubocznymi w innych miejscach. Innym problemem jest to, iż inicjalizując w ten sposób każdy kontener, musimy przekazać referencję do stworzonej zależności, a przecież choćby nie wiemy, czy użytkownik w ogóle uruchomi dany fragment kodu.

Marnotrawstwo!

Wzorzec fabryki

Wspomniałem już nieco wcześniej, iż w środowisku wokół aplikacji iOS dość gwałtownie powszechne stało się używanie wzorca fabryki. Jak się okazuje, nie było to wspomnienie przypadkowe. Nie ma przypadków.

Fabryka to również pośrednio sposób wstrzykiwania zależności do odpowiednich modułów czy komponentów. Tworząc np. widok, możemy w jednym miejscu przekazać potrzebne zależności, a następnie na ich podstawie zbudować odpowiedni obiekt. I faktycznie odpowiada to założeniom DI – zależności są tworzone poza obiektem, przychodzą z zewnątrz i – co jest uzyskiem względem poprzedniego podejścia – unikamy wartości opcjonalnych! Podejście to dobrze rozwiązuje też problem tzw. laziness, to znaczy obiekty są przekazywane tylko tam, gdzie będą niezbędne.

Jak jednak możecie się domyślać – nie rozwiązuje to wszystkich problemów. Po pierwsze – nie mamy żadnej kontroli nad cyklem życia zależności w kontekście globalnym. Po drugie nie jest to przyjazne podejście w kontekście pisania testów. Zamknięte i tworzące wszystko fabryki utrudniają nam szczególnie sytuację, gdy chcemy testować kod w różnych konfiguracjach, np. część zależności jest prawdziwa, część jest tylko atrapami. I po trzecie wreszcie, nie mamy żadnej metody weryfikacji zapętleń zależności, czyli sytuacji, w której jeden komponent zależy od innego, a ten inny pośrednio od pierwszego. Nieskończona pętla zależności to jedna z ostatnich pułapek, w jakie chcemy trafić.

Wzorzec fabryki będzie więc dobrym rozwiązaniem w przypadku stosowania wewnętrznego DI dla mniejszych komponentów, natomiast im większy projekt, tym trudniej jest takie podejście dobrze utrzymać. Szukamy dalej…

SwiftUI – lekarstwo na całe zło (zależności)

I wtedy znienacka podczas WWDC19 pojawiło się objawienie – SwiftUI, a wraz z nim nowe podejście do przekazywania zależności. SwiftUI poza deklaratywnym sposobem zapisu warstwy prezentacji i potrzebą nadużywania tabletek na ból głowy przy próbie poprawnego użycia GeometryReadera, wprowadził dwie adekwatności (property wrapper) służące wstrzykiwaniu zależności w widokach – @Environment oraz @EnvironmentObject.

To przyczyniło się do mocnego rozwoju DI w świecie iOS i przesunęło sposób myślenia o zależnościach jako singletonach na kontenery opakowujące całe typy obiektów i kaskadowym ich przekazywaniu w dół hierarchii. Do tej pory mieliśmy coś, co imitowało środowisko aplikacji w postaci worka na pojedyncze obiekty. @Environment pozwolił zaś wstrzyknąć zależność do prawdziwego, niewidocznego dla dewelopera środowiska danego modułu. Co więcej pojawiła się możliwość nadpisywania zależności inną wartością od danego miejsca w dół hierarchii widoków. To tak jakby środowisko było przekazywane w dół z założeniem, iż jest ono wyizolowane im dalej je przekazujemy.

To była zupełna zmiana w porównaniu do podejścia z wstrzykiwaniem zależności przez konstruktor w czasach biblioteki UIKit. Czy zatem teraz mamy już wszystko rozwiązane i pora na CSa? Wydaje się, iż nie ma już tutaj nic więcej do dodania.

Niestety, nie jest znowu tak różowo.

Co prawda środowiska SwiftUI możemy używać do wstrzykiwania zależności, ale należy pamiętać, iż jest to ograniczone do korzystania z komponentów widoków. To znaczy wstrzykujemy te wszystkie zależności do komponentów SwiftUI. Może się z tego zrobić niezły bałagan. Dodatkowo należy pamiętać, iż nie jest to wyizolowane środowisko. Apple trzyma tam również swoje zależności, z których deweloper mógłby chcieć skorzystać, przykładowo dobrać się do aktualnie używanego schematu kolorystycznego systemu czy domyślnej wielkość fontu. Chcąc uzyskać dostęp do własnego środowiska, które będzie wpisywało się w wybraną przez nas architekturę aplikacji trzeba to środowisko zbudować. Pisząc jednak własne rozwiązanie, należy mieć na uwadzę kilka bardzo ważnych zmiennych.

Po pierwsze – chcemy mieć możliwość wczytywania zależności tylko, kiedy jest to potrzebne (tzw. laziness). Chcemy też móc podmieniać sobie środowisko do testów automatycznych czy do pracy z widokami w Swift Previews. Czyli jednak do testów. Autor hipokryta.

I najważniejsze – chcemy unikać zapętleń zależności – czyli sytuacji, w których jedna zależność jest wciągana przez inną, a ta inna przez tę pierwszą. To dość dużo warunków, a jak dorzucimy do tego potrzebę, by nasze środowisko było bezpieczne w świecie wielowątkowym, to już robi się skomplikowanie. Na szczęście na pewno ktoś już to wymyślił. Prawda?

Kontener + Factory

Wszystko, o czym wspomniałem w tym artykule dotychczas teraz będzie miało swój payoff. Dlaczego nie można połączyć ze sobą całego wspomnianego wcześniej dobra: singletonów, środowisk i wzorca fabryki – w jedno duże narzędzie do zarządzania zależnościami?

A zatem w najprostszy możliwy sposób tłumacząc – mamy nasz globalnie trzymany kontener na zależności, który będziemy nazywali środowiskiem. Do niego wstrzykiwać będziemy tak naprawdę nie gotowe obiekty, a fabryki tworzące te obiekty. Możemy choćby zrobić rozróżnienie – czy chcemy za każdym razem tworzyć nowy obiekt, czy tylko zwracać zawsze referencję do tej samej instancji.

Sprytne, nie? Tym podejściem rozwiązujemy naprawdę sporo problemów, o których pisałem już wcześniej. Ale znowu – co to by był za artykuł o złym DI, gdybym nie widział tu problemów.

Zależności jako graf

Otóż wyobraźmy sobie taką sytuację, iż dany obiekt potrzebuje pobrać sobie dwie zależności z kontenera. Tym razem użyję bardziej konkretnego przykładu, żeby lepiej unaocznić problem:

Klasa Customer chce użyć dwóch zależności skrytych pod dwoma różnymi protokołami. Co się jednak okazuje po zajrzeniu do kontenera? Że w zasadzie jest to dokładnie ten sam obiekt:

Z perspektywy obiektu Customer jest przezroczyste, co kryje się jako implementacja danego protokołu, ale tak naprawdę mamy tutaj zgrzyt, ponieważ faktycznie tworzymy przy pomocy fabryki dwa oddzielne obiekty dokładnie tej samej klasy. W takiej sytuacji możemy zastosować nieco bardziej wyrafinowane podejście i użyć struktury grafu. Bez zagłębiania się w naukę o grafach (może następnym razem) zakładając, iż korzeniem naszego grafu jest obiekt Customer, dopiero po jego stworzeniu będziemy mogli przekazać zależności. Oba protokoły będą wstrzykiwane jako referencję do tego samego obiektu, dzięki temu unikniemy tworzenia dwóch oddzielnych instancji. Graf ma to do siebie, iż musi się gdzieś zaczynać, a zatem po zniszczeniu obiektu Customer, obiekt pełniący rolę dwóch zależności znika z pamięci. Win – win.

To oczywiście dość duże uproszczenie w implementacji, ale zasada jest myślę dość jasna. Dokładnie taką drogą poszli twórcy biblioteki Factory powstałej w 2022 r. Plusem biblioteki jest to, iż nie jest ona ociężała, zawierając zaledwie kilkaset linii kodu. Oczywiście można takie rozwiązanie napisać na potrzeby zespołu samodzielnie, mając pełną kontrolę np. nad sposobem przechowywania zależności w środowisku uruchomieniowym. Factory robi to w globalnie dostępnym kontenerze, który przechowuje dane w słowniku, a obiekty są serializowane z użyciem refleksji.

I tutaj myślałem sobie, iż przy dobrze zaimplementowanym w ten sposób podejściu trudno byłoby się jeszcze do czegoś przyczepić. Oczywiście rośnie nam tutaj już znacznie próg wejścia do projektu z tak zaawansowanym podejściem do DI, ale przez cały czas nie jest to coś, z czego nie skorzystałby mocny junior. Na szczęście z pomocą przyszli mi inżynierowie z Grammarly.

Zależności generowane w trakcie kompilacji

Wszystkie przykłady czy podejścia omówione dotychczas bazowały na schemacie drzewa. Przekazywanie zależności odbywało się hierarchicznie – z modułu głównego aplikacji w dół do modułów zależnych. Jedyna różnica była przy okazji podejścia z grafami, ale raczej nie jest to przypadek częsty. Okazuje się, iż przy wszystkich omówionych już podejściach praktycznie zawsze spotykamy się z trzema problemami, które dla większości małych i średnich projektów problemami najprawdopodobniej nie są. Chodzi o ilość kodu, jeden wspólny kontener na wszystko oraz zwracane z kontenera dane opcjonalne o określonych z góry typach.

Zawsze gdy moduł aplikacji odpytuje o daną zależność, będzie mógł ją pobrać jako typ opcjonalny. Dla wyjaśnienia zmienne opcjonalne to po prostu dane opakowane w enumerację z dwa opcjami: none oraz some(Value). To dlatego, iż nie jesteśmy stanie zapewnić, iż zależność będzie istniała w danym momencie, bo są one tworzone na różnych etapach działania programu. Zatem, aby zapewnić bezpieczeństwo typów naszej aplikacji powinniśmy za każdym razem odpakowywać naszą zależność, czyli sprawdzać jej stan lub ewentualnie sprawdzać w trakcie działania programu czy aby na pewno dany moduł ma do niej dostęp.

To niestety ma duży minus – jest to krok manualny. Kod sprawdzający trzeba dodawać przy każdej nowej zależności, której chcemy używać. Oczywiście przy pierwszych możliwych testach manualnych to od razu wyjdzie, bo aplikacja się kolokwialnie pisząc skraszuje, ale nie jest to najlepsza metoda zapewniająca bezpieczeństwo w naszym kodzie. Poza tym odpakowywanie zmiennych za każdym razem jest uciążliwe i zmniejsza czytelność kodu. Im mniej ifologii stosowanej, tym lepiej.

Drugi problem to używanie zależności do monitorowania stanu różnych, odległych od siebie części aplikacji. To zagadnienie, przed którym stanęli wspomniani deweloperzy z aplikacji Grammarly próbując rozwiązać je w swojej aplikacji na MacOS. W odróżnieniu od aplikacji jedno-ekranowych (mobilnych), aplikacje okienkowe mierzą się z jeszcze jednym wyzwaniem – wiele okien programu może dzielić ze sobą stan i przekazywać jego zmiany. Mamy zupełnie inną hierarchię widoków, bo wszystko zaczyna być bardziej płaskie. jeżeli zatem jedna część aplikacji chce zmienić coś w innej, musi przedzierać się przez niezliczone warstwy w górę hierarchii, by potem zejść w dół w innym miejscu. Wiadomość o zmianie stanu jest niepotrzebnie rozpowszechniana w każdym miejscu, w którym wstrzyknięto daną zależność. Takie problemy można rozwiązać na wiele sposobów z pominięciem DI (np. przez Delegaty), ale dlaczego by nie spróbować upiec wszystkich pieczeni na jednym ogniu?

Do tej pory zależności trzymaliśmy jako kod statyczny dostępny w ramach jednego kontenera, zapisywane pod spodem do jakiegoś rodzaju słownika (w innych językach znane pod nazwą hash-mapy). Co gdyby zmienić sposób zapisywania z tak ustrukturyzowanego drzewa lub grafu na tzw. koszyki? o ile nasze zależności znajdują się w przysłowiowym koszyku zależności, a nie są przekazywane w dół hierarchii, to faktycznie trafiają one bezpośrednio do modułów, które tego potrzebują – bez względu na hierarchię.

Co więcej – dostęp w tym samym czasie do elementów grafu w dwóch różnych miejscach to wspólne miejsce synchronizacji danych. Elementy systemu są asynchronicznie zarządzane, ale synchronicznie aktualizują stan we wszystkich instancjach aplikacji, a zmiany stanu nie wyciekają na zewnątrz. W płaskiej strukturze działamy na tych samych elementach i na tym samym poziomie, zachowując zdrową izolację od reszty aplikacji. Takie współdzielenie zależności jest dużo bardziej wyrafinowane i dużo trudniejsze w implementacji, ale jak zawsze zamiast wymyślać koło na nowo można skorzystać z istniejących rozwiązań.

Z problemem najwyraźniej mierzyli się również programiści Ubera, gdyż to właśnie oni stworzyli i podzielili się ze społecznością biblioteką Needle, która nie tylko tworzy sieć zależności na bazie właśnie koszyków, ale też rozwiązuje inny wspomniany przeze mnie problem związany z ilością kodu. Otóż generuje ona cały boilerplate na bazie skryptów w trakcie kompilacji! To oznacza, iż nie dość iż nie musimy pisać kodu wstrzykującego i rozpakowującego zależność, ale też to, iż poprawność implementacji jest sprawdzana już na etapie kompilacji (if it builds, it works).

To może wydawać się na pierwszy rzut oka nieintuicyjne, bo z poziomu programisty wszystko wygląda jak struktura drzewa. Tworzymy sobie zależność per komponent i przekazujemy go dalej:

W rzeczywistości zależność staje się dostępna tylko w tych miejscach, w których faktycznie tego potrzebujemy, a dzięki podejściu z pozoru podobnego do drzewiastego łatwiej jest nam tymi zależnościami zarządzać.

Czy zatem podejście stworzone na zasadzie koszyków współdzielonych jest idealne i teraz już naprawdę pora na CSa? Niestety, gdyby rozwiązanie to było idealne, to na początku tego artykułu napisałbym to od razu i całość miałaby cztery zdania wyjaśnienia. Podejście to również będzie problematyczne z wielu powodów. Przede wszystkim jego skomplikowanie rośnie w miarę schodzenia coraz niżej i głębiej. Próg wejścia do tej technologii dla nowych osób jest stosunkowo wysoki, zatem do kosztów utrzymania należy doliczyć czas wdrożenia nowych kontrybutorów do projektu. Wreszcie – wiadomo jak to z tym generowanym kodem jest – częściej skrypty generujące przysparzają nam więcej bólu głowy niż ten nieszczęsny GeometryReader w SwiftUI.

Na tym etapie moje rozważania na temat dependency injection i pomysłów rozwiązania dobiegają końca. Czas na podsumowanie i wnioski, jakie możemy wyciągnąć z tej podróży.

6. Podsumowanie i wnioski

W tym miejscu wypadałoby odpowiedzieć na pytanie postawione we wstępie – „Dlaczego ciągle mamy problem z Dependency Injection?”. Wydaje mi się, iż trudność w zarządzaniu zależnościami nie polega na trudności samego konceptu. Jak już ustaliliśmy wcześniej – wstrzykiwanie zależności samo w sobie nie jest konceptem skomplikowanym, ale podobnie jaki inne tego typu koncepty daje jedynie wysokopoziomowe wytyczne, wyznacza pewien kierunek, nie dbając nazbyt o implementacyjne problemy. A tych, jak się okazuje, jest całkiem sporo, a sprowadzają się tak naprawdę do jednego, ale jakże ważnego założenia – pisanie skomplikowanych i skalowalnych aplikacji jest trudne.

Brzmi to może jak truizm, ale bynajmniej nie używam go dla uwydatnienia powagi zawodu programisty. Tak naprawdę już etap odpowiedniego wyboru podejścia przed rozpoczęciem pisania kodu jest najważniejszy dla późniejszego sukcesu. Szkopuł w tym, iż programiści zbyt wiele problemów chcą rozwiązać dzięki DI, a przez to nazbyt komplikują ten koncept, a przy tym samą implementację. Jak wiele jest podejść do rozwiązania problemu w ogóle zarządzania zależnościami mam nadzieję pokazałem w tym artykule. Przy ciągłych zmianach w technologii, zmian paradygmatów, jak pojawienie się SwiftUI czy Swift Concurrency, nie da się przewidzieć tego, jak dane nowe rozwiązanie wpłynie na nasze obecne podejście.

Dlaczego zatem nie sięgnąć po gotowce stosowane w innych językach programowania czy technologiach, jak Java, Python czy jeden z tryliarda JSowych frameworków? Będę to powtarzał jak mantrę do znudzenia, aż moja misja wpojenia tego osobom spoza świata aplikacji mobilnych przyniesie wreszcie akceptację takiego stanu rzeczy – aplikacje mobilne mają swoją niepowtarzalną nigdzie indziej specyfikę! Ich architektura, sposób testowania, komunikacja poszczególnych komponentów, planowanie interakcji z użytkownikiem, modularyzacja – to coś, czego nie uświadczą inżynierowie mikrousług, aplikacji internetowych czy choćby dużych monolitów. Specyfika ta powoduje, iż musimy szukać własnych, skrojonych na miarę rozwiązań, nie zaś na siłę adaptować choćby najlepsze gotowce.

Czy zatem lepiej jest tworzyć własne rozwiązania, czy korzystać z gotowych bibliotek? To oczywiście zależy od wielu czynników. Gdybym miał w prosty sposób nakreślić jakiś drogowskaz, to pewnie poszedłbym następującym tokiem rozumowania i skupiłbym się na obecnym stanie projektu i planach na dalszą ewolucję:

  • PoC oraz wczesny etap projektu – najlepiej jest stosować prosty kontener typu Singleton lub choćby zalecałbym używanie mechanizmów natywnych wbudowanych w SwiftUI. Im prościej i szybciej – tym lepiej.
  • Wczesna faza większego projektu, MVP lub modularyzacja projektu – to już czas na trochę bardziej wyrafinowane metody, jak drzewo zależności i konteneryzacja, ale przez cały czas z założeniem „nie komplikować bez potrzebny”.
  • Duże, zmodularyzowane projekty – To już czas na rozważenie poważnych i skomplikowanych konceptów, wypracowanie czegoś samodzielnie w zespole ekspertów, skrojone na miarę potrzeb lub wykorzystanie biblioteki (np. Factory lub Needle).

Ale nie jest to jakiś święty kodeks. Wszystko powinno się dopasowywać do swoich potrzeb i potrzeb zespołu pod warunkiem, iż potrafimy jak najlepiej przewidywać potencjalne problemy, które mogą pojawiać się w przyszłości.

I najważniejsze – nie komplikować kodu dla samego komplikowania. Wasze rozwiązania mają być proste i zrozumiałe, a przede wszystkim rozwiązywać wasze i tylko wasze problemy. To technologia ma służyć wam, a nie wy jej. Powodzenia!

Źródła:

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

Idź do oryginalnego materiału