Hold the Framework — badanie wzorców wstrzykiwania zależności

Opublikowany: 2022-03-11

Tradycyjne poglądy na odwrócenie kontroli (IoC) wydają się wyznaczać twardą granicę między dwoma różnymi podejściami: wzorcami lokalizatora usług i wstrzykiwania zależności (DI).

Praktycznie każdy projekt jaki znam zawiera framework DI. Ludzi do nich przyciągają, ponieważ promują luźne sprzężenie między klientami i ich zależnościami (zwykle poprzez wstrzyknięcie konstruktora) przy minimalnym lub żadnym kodzie wzorcowym. Chociaż jest to świetne do szybkiego programowania, niektórzy uważają, że może to utrudnić śledzenie i debugowanie kodu. „Magia za kulisami” zwykle osiągana jest poprzez refleksję, która może przynieść cały szereg nowych problemów.

W tym artykule zbadamy alternatywny wzorzec, który jest dobrze dopasowany do baz kodu Java 8+ i Kotlin. Zachowuje większość zalet architektury DI, będąc jednocześnie tak prostym, jak lokalizator usług, bez konieczności używania zewnętrznych narzędzi.

Motywacja

  • Unikaj zależności zewnętrznych
  • Unikaj refleksji
  • Promuj wstrzyknięcie konstruktora
  • Zminimalizuj zachowanie w czasie wykonywania

Przykład

W poniższym przykładzie zamodelujemy implementację telewizyjną, w której do uzyskania treści można wykorzystać różne źródła. Musimy skonstruować urządzenie, które może odbierać sygnały z różnych źródeł (np. naziemne, kablowe, satelitarne itp.). Zbudujemy następującą hierarchię klas:

Hierarchia klas urządzenia telewizyjnego, które implementuje dowolne źródło sygnału

Teraz zacznijmy od tradycyjnej implementacji DI, takiej, w której framework, taki jak Spring, łączy wszystko dla nas:

 public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } } public interface TvSource { void tuneChannel(int channel); } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } }

Zauważamy kilka rzeczy:

  • Klasa TV wyraża zależność od TvSource. Zewnętrzna struktura zobaczy to i wprowadzi instancję konkretnej implementacji (naziemnej lub kablowej).
  • Wzorzec wstrzykiwania konstruktora umożliwia łatwe testowanie, ponieważ można łatwo budować instancje TV z alternatywnymi implementacjami.

Mamy dobry początek, ale zdajemy sobie sprawę, że wprowadzenie do tego frameworka DI może być trochę przesadą. Niektórzy programiści zgłaszali problemy z debugowaniem problemów konstrukcyjnych (długie ślady stosu, niemożliwe do wyśledzenia zależności). Nasz klient stwierdził również, że czasy produkcji są nieco dłuższe niż oczekiwano, a nasz profiler pokazuje spowolnienia w wywołaniach refleksyjnych.

Alternatywą byłoby zastosowanie wzorca Service Locator. Jest to proste, nie wykorzystuje refleksji i może wystarczyć dla naszej małej bazy kodu. Inną alternatywą jest pozostawienie klas w spokoju i napisanie wokół nich kodu lokalizacji zależności.

Po przeanalizowaniu wielu alternatyw decydujemy się na wdrożenie go jako hierarchii interfejsów dostawców. Każda zależność będzie miała skojarzonego dostawcę, który będzie ponosił wyłączną odpowiedzialność za lokalizowanie zależności klasy i konstruowanie wstrzykniętego wystąpienia. Uczynimy również dostawcę wewnętrznym interfejsem ułatwiającym użytkowanie. Nazwiemy to Mixin Injection, ponieważ każdy dostawca jest mieszany z innymi dostawcami, aby zlokalizować swoje zależności.

Szczegóły, dlaczego zdecydowałem się na tę strukturę, zostały omówione w Szczegóły i uzasadnienie, ale oto krótka wersja:

  • Segreguje zachowanie lokalizacji zależności.
  • Rozszerzanie interfejsów nie wchodzi w problem diamentu.
  • Interfejsy mają domyślne implementacje.
  • Brakujące zależności uniemożliwiają kompilację (punkty bonusowe!).

Poniższy diagram pokazuje, w jaki sposób zależności i dostawcy współdziałają, a implementacja jest zilustrowana poniżej. Dodajemy również główną metodę, aby zademonstrować, jak możemy skomponować nasze zależności i skonstruować obiekt TV. Dłuższą wersję tego przykładu można również znaleźć w tym serwisie GitHub.

Interakcje między dostawcami a zależnościami

 public interface TvSource { void tuneChannel(int channel); interface Provider { TvSource tvSource(); } } public class TV { private final TvSource source; public TV(TvSource source) { this.source = source; } public void turnOn() { System.out.println("Turning on the TV"); this.source.tuneChannel(42); } interface Provider extends TvSource.Provider { default TV tv() { return new TV(tvSource()); } } } public class Terrestrial implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Adjusting dish frequency to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Terrestrial(); } } } public class Cable implements TvSource { @Override public void tuneChannel(int channel) { System.out.printf("Changing digital signal to channel %d\n", channel); } interface Provider extends TvSource.Provider { @Override default TvSource tvSource() { return new Cable(); } } } // Here compose the code above to instantiate a TV with a Cable TvSource public class Main { public static void main(String[] args) { new MainContext().tv().turnOn(); } static class MainContext implements TV.Provider, Cable.Provider { } }

Kilka uwag na temat tego przykładu:

  • Klasa TV zależy od TvSource, ale nie zna żadnej implementacji.
  • TV.Provider rozszerza TvSource.Provider, ponieważ potrzebuje metody tvSource() do zbudowania TvSource i może jej używać, nawet jeśli nie jest tam zaimplementowana.
  • Źródła telewizji naziemnej i kablowej mogą być używane zamiennie przez telewizor.
  • Interfejsy Terrestrial.Provider i Cable.Provider zapewniają konkretne implementacje TvSource.
  • Główna metoda ma konkretną implementację MainContext z TV.Provider, która służy do pobierania instancji TV.
  • Program wymaga implementacji TvSource.Provider w czasie kompilacji, aby utworzyć instancję telewizora, dlatego jako przykład podajemy Cable.Provider.

Szczegóły i uzasadnienie

Widzieliśmy wzór w działaniu i niektóre z jego rozumowań. Możesz nie być przekonany, że powinieneś go teraz używać, i miałbyś rację; to nie jest dokładnie srebrna kula. Osobiście uważam, że w większości aspektów jest lepszy od wzorca lokalizatora usług. Jednak w porównaniu do frameworków DI, należy ocenić, czy korzyści przeważają nad dodawaniem standardowego kodu.

Dostawcy rozszerzają możliwości innych dostawców na lokalizowanie ich zależności

Kiedy dostawca rozszerza inny, zależności są ze sobą powiązane. Zapewnia to podstawową podstawę walidacji statycznej, która zapobiega tworzeniu nieprawidłowych kontekstów.

Jednym z głównych problemów wzorca lokalizatora usług jest to, że musisz wywołać ogólną GetService<T>() , która w jakiś sposób rozwiąże twoją zależność. W czasie kompilacji nie masz gwarancji, że zależność zostanie kiedykolwiek zarejestrowana w lokalizatorze, a Twój program może zawieść w czasie wykonywania.

Wzorzec DI również tego nie rozwiązuje. Rozwiązywanie zależności jest zwykle wykonywane przez odbicie przez narzędzie zewnętrzne, które jest w większości ukryte przed użytkownikiem, co również kończy się niepowodzeniem w czasie wykonywania, jeśli zależności nie są spełnione. Narzędzia takie jak CDI IntelliJ (dostępne tylko w wersji płatnej) zapewniają pewien poziom weryfikacji statycznej, ale tylko Dagger z preprocesorem adnotacji wydaje się rozwiązywać ten problem od samego początku.

Klasy zachowują typowe wstrzykiwanie konstruktora wzorca DI

Nie jest to wymagane, ale zdecydowanie pożądane przez społeczność programistów. Z jednej strony wystarczy spojrzeć na konstruktor i od razu zobaczyć zależności klasy. Z drugiej strony umożliwia to rodzaj testowania jednostkowego, do którego przywiązuje się wiele osób, czyli konstruowanie testowanego przedmiotu z makietami jego zależności.

Nie oznacza to, że inne wzorce nie są obsługiwane. W rzeczywistości może się nawet okazać, że Mixin Injection upraszcza konstruowanie złożonych grafów zależności do testowania, ponieważ wystarczy zaimplementować klasę kontekstu, która rozszerza dostawcę twojego podmiotu. Powyższy MainContext jest doskonałym przykładem, w którym wszystkie interfejsy mają domyślne implementacje, więc może mieć pustą implementację. Zastąpienie zależności wymaga tylko zastąpienia jej metody dostawcy.

Przyjrzyjmy się następującemu testowi dla klasy telewizyjnej. Musi utworzyć instancję telewizora, ale zamiast wywoływać konstruktor klasy, używa interfejsu TV.Provider. TvSource.Provider nie ma domyślnej implementacji, więc musimy go napisać sami.

 public class TVTest { @Test public void testWithProvider() { TvSource source = Mockito.mock(TvSource.class); TV.Provider provider = () -> source; // lambdas FTW provider.tv().turnOn(); Mockito.verify(source, times(1)).tuneChannel(42); } }

Teraz dodajmy kolejną zależność do klasy TV. Zależność CathodeRayTube działa magicznie, aby obraz pojawił się na ekranie telewizora. Jest oddzielony od implementacji telewizora, ponieważ w przyszłości możemy chcieć przejść na LCD lub LED.

 public class TV { public TV(TvSource source, CathodeRayTube cathodeRayTube) { ... } public interface Provider extends TvSource.Provider, CathodeRayTube.Provider { default TV tv() { return new TV(tvSource(), cathodeRayTube()); } } } public class CathodeRayTube { public void beam() { System.out.println("Beaming electrons to produce the TV image"); } public interface Provider { default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Jeśli to zrobisz, zauważysz, że test, który właśnie napisaliśmy, nadal się kompiluje i przechodzi zgodnie z oczekiwaniami. Dodaliśmy nową zależność do telewizora, ale zapewniliśmy również domyślną implementację. Oznacza to, że nie musimy go maskować, jeśli chcemy tylko użyć rzeczywistej implementacji, a nasze testy mogą tworzyć złożone obiekty o dowolnym poziomie pozorowanej granulacji.

Jest to przydatne, gdy chcesz zakpić coś konkretnego w złożonej hierarchii klas (np. tylko warstwę dostępu do bazy danych). Wzorzec umożliwia łatwe skonfigurowanie rodzaju testów towarzyskich, które czasami są preferowane od testów samotnych.

Niezależnie od preferencji możesz mieć pewność, że w każdej sytuacji możesz skorzystać z dowolnej formy testowania, która lepiej odpowiada Twoim potrzebom.

Unikaj zależności zewnętrznych

Jak widać, nie ma żadnych odniesień ani wzmianek o komponentach zewnętrznych. Jest to kluczowe dla wielu projektów, które mają ograniczenia dotyczące rozmiaru lub nawet bezpieczeństwa. Pomaga również w interoperacyjności, ponieważ frameworki nie muszą angażować się w określone ramy DI. W Javie podjęto wysiłki, takie jak JSR-330 Dependency Injection for Java Standard, które łagodzą problemy ze zgodnością.

Unikaj odbicia

Implementacje lokalizatora usług zwykle nie opierają się na odbiciu, ale implementacje DI tak (z godnym uwagi wyjątkiem Dagger 2). Ma to główne wady polegające na spowolnieniu uruchamiania aplikacji, ponieważ framework musi przeskanować moduły, rozwiązać wykres zależności, refleksyjnie konstruować obiekty itp.

Mixin Injection wymaga napisania kodu w celu utworzenia wystąpienia usług, podobnie jak w kroku rejestracji we wzorcu lokalizatora usług. Ta niewielka dodatkowa praca całkowicie usuwa odblaskowe wywołania, dzięki czemu Twój kod jest szybszy i prosty.

Dwa projekty, które ostatnio przykuły moją uwagę i czerpią korzyści z unikania refleksji, to Substrate VM firmy Graal i Kotlin/Native. Oba kompilują się do natywnego kodu bajtowego, a to wymaga, aby kompilator znał z wyprzedzeniem wszelkie refleksyjne wywołania, które wykonasz. W przypadku Graala jest to określone w pliku JSON, który jest trudny do napisania, nie można go statycznie sprawdzić, nie da się łatwo zrefaktoryzować przy użyciu ulubionych narzędzi. Używanie Mixin Injection, aby przede wszystkim uniknąć refleksji, to świetny sposób na czerpanie korzyści z natywnej kompilacji.

Minimalizuj zachowanie w czasie wykonywania

Implementując i rozszerzając wymagane interfejsy, konstruujesz wykres zależności po jednym kawałku. Każdy dostawca znajduje się obok konkretnej implementacji, która wprowadza porządek i logikę do Twojego programu. Ten rodzaj warstw będzie znany, jeśli wcześniej używałeś wzoru Mixin lub wzoru Cake.

W tym miejscu może warto porozmawiać o klasie MainContext. Jest to podstawa wykresu zależności i zna ogólny obraz. Ta klasa obejmuje wszystkie interfejsy dostawcy i jest kluczem do włączenia kontroli statycznych. Jeśli wrócimy do przykładu i usuniemy Cable.Provider z jego listy narzędzi, zobaczymy to wyraźnie:

 static class MainContext implements TV.Provider { } // ^^^ // MainContext is not abstract and does not override abstract method tvSource() in TvSource.Provider

To, co się tutaj wydarzyło, polega na tym, że aplikacja nie określiła konkretnego źródła TvSource do użycia, a kompilator wykrył błąd. Dzięki lokalizatorowi usług i DI opartemu na odbiciach ten błąd mógł pozostać niezauważony, dopóki program nie uległ awarii w czasie wykonywania — nawet jeśli wszystkie testy jednostkowe zakończyły się pomyślnie! Wierzę, że te i inne korzyści, które pokazaliśmy, przeważają nad wadami napisania szablonu potrzebnego do działania wzorca.

Złap zależności kołowe

Wróćmy do przykładu CathodeRayTube i dodajmy zależność kołową. Powiedzmy, że chcemy, aby został wstrzyknięty do instancji TV, więc rozszerzamy TV.Provider:

 public class CathodeRayTube { public interface Provider extends TV.Provider { // ^^^ // cyclic inheritance involving CathodeRayTube.Provider default CathodeRayTube cathodeRayTube() { return new CathodeRayTube(); } } }

Kompilator nie pozwala na cykliczne dziedziczenie i nie jesteśmy w stanie zdefiniować tego rodzaju relacji. Większość frameworków zawodzi w czasie wykonywania, kiedy to się dzieje, a programiści mają tendencję do obejścia tego tylko po to, aby program działał. Mimo że ten antywzór można znaleźć w prawdziwym świecie, zwykle jest to oznaka złego projektu. Gdy kod się nie kompiluje, powinniśmy być zachęcani do szukania lepszych rozwiązań, zanim będzie za późno na zmiany.

Zachowaj prostotę w budowie obiektów

Jednym z argumentów przemawiających za SL nad DI jest to, że jest prosty i łatwiejszy do debugowania. Z przykładów jasno wynika, że ​​tworzenie wystąpienia zależności będzie tylko łańcuchem wywołań metod dostawcy. Śledzenie źródła zależności jest tak proste, jak wejście do wywołania metody i sprawdzenie, gdzie się znajdujesz. Debugowanie jest prostsze niż obie alternatywy, ponieważ możesz nawigować dokładnie tam, gdzie występują zależności, bezpośrednio od dostawcy.

Żywotność usługi

Uważny czytelnik mógł zauważyć, że ta implementacja nie rozwiązuje problemu okresu istnienia usługi. Wszystkie wywołania metod dostawcy będą tworzyć instancje nowych obiektów, czyniąc to podobnym do zakresu Spring Prototype.

Te i inne rozważania są nieco poza zakresem tego artykułu, ponieważ chciałem jedynie przedstawić istotę wzoru bez rozpraszania szczegółów. Pełne wykorzystanie i wdrożenie w produkcie wymagałoby jednak uwzględnienia pełnego rozwiązania z dożywotnią obsługą.

Wniosek

Niezależnie od tego, czy jesteś przyzwyczajony do struktur wstrzykiwania zależności, czy pisania własnych lokalizatorów usług, możesz chcieć zbadać tę alternatywę. Rozważ użycie wzorca mixin, który właśnie widzieliśmy, i sprawdź, czy możesz uczynić swój kod bezpieczniejszym i łatwiejszym do zrozumienia.

Powiązane: Najlepsze praktyki JS: Zbuduj bota Discord za pomocą TypeScript i Dependency Injection