Praktyczne wprowadzenie do Daggera 2

Praktyczne wprowadzenie do Daggera 2

Wstrzykiwanie zależności z biblioteką Dagger 2 na przykładzie tworzenia modułowej aplikacji.

sobota, 29 lipca 2017

Informatyka

Od dawna do wstrzykiwania zależności w swoich projektach korzystam z biblioteki Google Guice, jednak równocześnie z zainteresowaniem śledziłem projekt Dagger (w wersji drugiej, również autorstwa Google'a). Zaciekawił mnie sposób jego działania - cały graf obiektów wyliczany jest w momencie kompilacji, a następnie na jego bazie tworzony jest kawałek kodu w Javie, który inicjalizuje wszystkie obiekty w odpowiedniej kolejności. My natomiast dostajemy niewielką klasę, z której pobieramy pierwszy obiekt. Dlaczego uważam tę koncepcję za ciekawą? Ponieważ sama idea wstrzykiwania zależności jest bajecznie prosta i może być bez problemu stosowana ręcznie. Jeśli wszystkie zależności klasy wrzucasz do niej przez konstruktor, nie stosujesz singletonów i statycznych pól, to już stosujesz wstrzykiwanie zależności. Automatyczne kontenery powstały, aby przyspieszyć pisanie kodu w momencie, gdy liczba klas idzie w dziesiątki i setki, jednak przeważnie analiza zależności jest tam wykonywana przy starcie aplikacji. Dagger to powrót do korzeni - po prostu generuje nam cały kod potrzeby do zainicjalizowania obiektów, który i tak musielibyśmy napisać. Podejście to ma kilka zalet:

  • już w momencie kompilacji wiemy czy graf zależności jest poprawny,
  • proste debugowanie - nie ma tu żadnej refleksji, przed oczami mamy zwykły kawałek kodu źródłowego, który robi new MojaKlasa(argumenty)

Zastanawiałem się, jak generacja kodu sprawdzi się przy tworzeniu modułowych aplikacji, gdzie aplikacja może sama wybrać sobie interesujące ją moduły spośród dostępnego zbioru. Na przeszkodzie stała dokumentacja Daggera, która moim zdaniem jest lekko nieczytelna - wprowadza trochę pojęć, tylko tak jakby bez podstawowej informacji: po co i dlaczego mam właściwie tego użyć? Skończyło się na tym, że spróbowałem zbudować sobie przykładową aplikację podobną do tych, które już wcześniej robiłem w Guice. Wiedziałem, co chcę uzyskać i dlatego mogłem się skupić na tym, aby metodą prób i błędów odkryć, jak narzędzia oferowane przez Daggera wykorzystać do osiągnięcia mojego celu. Ten wpis ma za zadanie wypełnić tę lukę i przedstawić podstawy Daggera właśnie z perspektywy pisania normalnej aplikacji.

Kluczowe pojęcia

Dagger wprowadza kilka pojęć, których znaczenie musimy zrozumieć, aby móc z niego skorzystać. Naszym celem jest automatyczne stworzenie wszystkich potrzebnych obiektów w naszej aplikacji.

  • Zależność - jeśli obiekt A posiada referencję do obiektu B, to B jest zależnością A,
  • Interfejs i implementacja - Dagger promuje programowanie zorientowane na interfejsy. Gdy definiujemy zależności, używamy interfejsów, a nie konkretnych klas. Możemy mieć kilka implementacji tego samego interfejsu, ale o tym, której z nich użyjemy, zdecydujemy później.
  • Moduł - specjalna klasa z metodami fabrykującymi, które mówią, jak zbudować implementacje poszczególnych interfejsów,
  • Komponent - specjalny interfejs reprezentujący kompletny graf obiektów, w którym wszystkie zależności są spełnione. Znajduje się w nim metoda do pobrania obiektu-korzenia. Wygenerowaniem implementacji tego interfejsu zajmuje się Dagger.

Innymi słowy, w naszej aplikacji potrzebujemy trochę modułów oraz przynajmniej jeden komponent, który zbiera je w całość i pozwala pobrać referencję do głównego obiektu.

Z perspektywy aplikacji...

Powyżej zapoznaliśmy się ze słownikiem pojęć Daggera. Teraz popatrzmy, jakim słownikiem pojęć będziemy operować w odniesieniu do modułowej aplikacji.

  • Moduł - kawałek kodu, który udostępnia jakąś funkcjonalność innym modułom oraz punkty rozszerzania, w które mogą się wpiąć inne moduły,
  • Punkt rozszerzania - jakieś miejsce w module A, w które moduł B może wpiąć się ze swoją funkcjonalnością,
  • Framework - zbiór luźno powiązanych modułów,
  • Aplikacja - końcowy program, który można uruchomić.

Jeśli chodzi o punkty rozszerzania, to są nimi zwykłe interfejsy, które mają jedną cechę szczególną: jeśli interfejs jest w module A, to klasa, która go implementuje, musi znajdować się w jakimś innym module. Oczekujemy tutaj, że w programie będzie dokładnie jedna implementacja - nie może jej brakować, ani nie może ich być więcej. Czasami jednak chcielibyśmy, aby w jakieś miejsce mogła się wpiąć dowolna liczba modułów dostarczających dowolnie dużo implementacji. Będziemy mieć wtedy do czynienia z wtyczkami i na szczęście da się je zaimplementować w Daggerze.

Zbudujmy przykładową aplikację

Po tym nieco długim wstępie przejdźmy od razu do praktyki. Kompletny kod do wpisu znajduje się na Githubie pod adresem github.com/zyxist/dagger-example-app - tutaj będę pokazywał tylko wybrane fragmenty. Chcemy zrobić framework z czterema modułami oraz korzystającą z niego aplikację. Głównym modułem będzie framework-core, który będzie zawierał kawałek kodu odpowiedzialny za wystartowanie aplikacji oraz jakieś przykładowe serwisy. Oto, jak wygląda jeden z nich:

public interface CoreService {
   public int getRandomNumber();
   public String doSomethingInteresting();
}

Zatem serwis reprezentowany jest przez interfejs, który mówi nam, co można z nim robić. Potrzebne nam będą także interfejsy reprezentujące jakieś punkty rozszerzania:

public interface FirstExtensionPoint {
    public String doSomething();
}

public interface SecondExtensionPoint {
    public String doSomething();
}

public interface SamplePlugin {
    public String sayHello();
}

Jak widać, w praktyce nie jest to nic strasznego. Po prostu chodzi o to, że implementacja CoreService znajduje się w tym samym module, co interfejs, a implementacje trzech pozostałych - gdzie indziej. A jak wygląda taka implementacja? Spójrzmy:

public class CoreServiceImpl implements CoreService {
   private final SecondExtensionPoint extension;
   private final Set<SamplePlugin> plugins;

   @Inject
   public CoreServiceImpl(SecondExtensionPoint ext, Set<SamplePlugin> plugins) {
      this.extension = Objects.requireNonNull(ext);
      this.plugins = Objects.requireNonNull(plugins);
   }

   @Override
   public int getRandomNumber() {
      // wygenerowane przez losowy rzut kostka
      return 4;
   }

   @Override
   public String doSomethingInteresting() {
      StringBuilder builder = new StringBuilder("(");
      for (SamplePlugin plugin: plugins) {
         builder.append(plugin.sayHello()).append(", ");
      }
      builder.append(")");
      return "foo " + extension.doSomething() + builder.toString();
   }
}

W Daggerze każda zarządzana klasa potrzebuje jawnie zadeklarowanego konstruktora z adnotacją @Inject (nawet jeśli jest on pusty i nic nie robi). Tak właśnie informujemy Daggera, że musi się taką klasą zająć, zaś argumenty konstruktora opisują, jakie zależności są potrzebne. Ich dostarczenie to już zadanie Daggera. Zwróćmy uwagę na to, w jaki sposób w kodzie możemy uruchomić wtyczki. Zamiast pojedynczego obiektu, żądamy od Daggera dostarczenia zbioru obiektów: Set<SamplePlugin> (w szczególności może on być pusty). Następnie umieszczamy gdzieś pętlę, która przejdzie przez wszystkie wtyczki i odpali na nich metodę.

Aby powiązać interfejsy z implementacjami, Dagger potrzebuje specjalnej klasy reprezentującej moduł:

@Module
public abstract class FrameworkCoreModule {
    @Binds
    @Singleton
    public abstract Bootstrap bindsBootstrap(BootstrapImpl impl);

    @Binds
    @Singleton
    public abstract CoreService bindsCoreService(CoreServiceImpl impl);

    @Binds
    @Singleton
    public abstract CapitalizationService bindsCapitalizationService(CapitalizationServiceImpl impl);
}

Na module musi być powieszona adnotacja @Module. Wewnątrz mamy kilka metod abstrakcyjnych oznaczonych adnotacjami @Binds. Zauważmy - zwracanym typem jest interfejs, a argumentem - implementacja tegoż interfejsu. W ten sposób wiążemy interfejsy z implementacjami. Dodatkowa adnotacja @Singleton informuje, że w aplikacji powinna istnieć tylko jedna instancja danej klasy. W przeciwnym wypadku Dagger będzie tworzył nowy obiekt za każdym razem, gdy będzie on potrzebny.

Zwróćmy uwagę, że w tak napisanym module nie musimy przejmować się tym, od czego zależą poszczególne implementacje. Dagger patrzy tutaj na konstruktor oznaczony adnotacją @Inject i na tej podstawie jest w stanie wygenerować dla nas odpowiedni kod. Jeśli w naszym konstruktorze pojawi się dodatkowy argument, wystarczy że przebudujemy projekt. Na tym etapie nie ma znaczenia czy zależność dostarcza ten sam czy inne moduły.

W porządku, a jak dodać nową implementację interfejsu SamplePlugin? Zauważmy, że CoreServiceImpl przyjmuje zbiór obiektów tego typu, a my powiedzieliśmy sobie wyżej, że wtyczki mogą być dostarczane przez wiele różnych modułów. Skorzystamy tutaj z mechanizmu multibindings pozwalającego na podpięcie do interfejsu dowolnej liczby implementacji. Oto przykład modułu z framework-first-extension:

@Module
public abstract class FirstExtensionModule {
    @Binds
    public abstract FirstExtensionPoint bindsFirstExtension(FirstExtensionPointImpl impl);

    @Provides @IntoSet
    public static SamplePlugin providesPlugin() {
        return new SamplePluginImpl();
    }
}

Kiedy chcemy mieć większą kontrolę nad tym, jak skonstruować dany obiekt, musimy stworzyć metodę oznaczoną adnotacją @Provides. Wtedy to my musimy wywołać odpowiedni konstruktor i wstawić do niego odpowiednie zależności. Metoda z adnotacją @Provides może przyjmować argumenty - Dagger wtedy przekaże nam w nich wymagane zależności, których będziemy mogli użyć. Metody z @Provides przydają się również wtedy, gdy chcemy skonstruować zbiór implementacji. Wieszamy wtedy na nich dodatkową metodę @IntoSet - dzięki temu pozostałe moduły mogą też podrzucić swoje implementacje, a Dagger ładnie zapakuje nam je wszystkie w zbiór, którego będzie można później użyć jako zależności.

Należy tu zwrócić uwagę na jedną istotną rzecz - jeśli nasza klasa modułu posiada już metody z adnotacjami @Binds, wtedy metoda @Provides musi być metodą statyczną. Kiedy w module nie ma metod abstrakcyjnych, słowo kluczowe static można opuścić.

Po stronie aplikacji

Aplikacja to miejsce, gdzie wybieramy, które moduły nas interesują i decydujemy o tym, do czego chcemy ich użyć. W aplikacji znajduje się przede wszystkim metoda main() - w naszym przypadku o tym, jak przebiega uruchomienie, decyduje framework, a konkretniej implementacja interfejsu Bootstrap. Zadaniem aplikacji jest jedynie dostarczenie kawałka kodu, który zostanie wykonany po wystartowaniu wszystkich serwisów (interfejs ApplicationHook) oraz... utworzenie grafu obiektów. W tym miejscu do gry wkraczają komponenty Daggera.

Jak wspomnieliśmy wcześniej, zadaniem komponentu jest zebranie grupy modułów i udostępnienie referencji do głównych obiektów - w naszym przypadku będzie to jedynie referencja do obiektu Bootstrap. Omówienie zaczniemy od początku, czyli od main():

public class Application {
    public static void main(String args[]) {
        DaggerApplicationComponent.create()
            .bootstrap()
            .execute();
    }
}

W moich aplikacjach metody main() z założenia są proste - inicjalizuję w nich jedynie kontener wstrzykiwania zależności, tworzę główny obiekt i przekazuję sterowanie dalej. Nie inaczej jest w tym przypadku:

  1. utwórz komponent aplikacji,
  2. pobierz referencję do obiektu Bootstrap, co powoduje też utworzenie wszystkich innych obiektów,
  3. odpal metodę execute() na pobranym obiekcie, w której wykonuje się właściwy kodu.

Klasa komponentu została wygenerowana przez Daggera na bazie interfejsu, który wygląda tak:

@Singleton
@Component(
    modules = { ApplicationModule.class, FrameworkCoreModule.class, FirstExtensionModule.class, ThirdExtensionModule.class }
)
public interface ApplicationComponent {
    Bootstrap bootstrap();
}

Najważniejszą częścią jest adnotacja @Component, w której określamy, jakich modułów chcemy użyć. Wybrany przez nas zestaw modułów musi być kompletny, tzn. nie może w nim być żadnej niespełnionej zależności - w przeciwnym wypadku dostaniemy błąd kompilacji. Interfejs musi posiadać przynajmniej jedną metodę, która udostępni jakiś główny obiekt. Możemy ich utworzyć więcej, ale u nas nie ma takiej potrzeby. Zwróćmy uwagę, że jeżeli któryś z użytych modułów zadeklarował, że jedna z implementacji jest singletonem, to na komponencie także musimy powiesić adnotację @Singleton.

Spróbujmy uruchomić aplikację. Oto wynik działania:

Executing first extension point: work done by framework-first-extension
Executing application: My application - Dagger Hi universe!
foo work done by FRAMEWORK-THIRD-EXTENSION(plugin from framework-third-extension, plugin from framework-first-extension, );
random number: 4

Przepraszam za nieczytelność, ale modułowe sklejanie stringów to wyższa szkoła jazdy i temat na oddzielny wpis :). Starałem się, by kod pochodzący z różnych modułów frameworka przedstawiał się i faktycznie, jeśli prześledzimy sobie przebieg działania w debuggerze, zauważymy, że wszystkie klasy dostały potrzebne zależności i że poszczególne moduły współpracują ze sobą (np. implementacja punktu rozszerzania SecondExtensionPoint korzysta z serwisu udostępnianego przez framework-core do zamiany małych liter na duże w swojej nazwie).

Możemy także zamiast modułu ThirdExtensionModule użyć SecondExtensionModule - wynik działania będzie inny:

Executing first extension point: work done by framework-first-extension
Executing application: My application - Dagger Hi universe!
foo work done by framework-second-extension(plugin from framework-first-extension, );
random number: 4

Podane moduły nie mogą być użyte jednocześnie, ponieważ każdy z nich dostarcza własną implementację punktu rozszerzania SecondExtensionPoint, a w aplikacji może być tylko jedna. ThirdExtensionModule dostarcza również dodatkową wtyczkę implementującą SamplePlugin, której nie ma w drugim module. I faktycznie - przy pierwszym uruchomieniu na wyjściu ukazały się komunikaty wygenerowane przez dwie wtyczki, a przy drugim - tylko jeden. Jest to jednocześnie ostateczny dowód, że Dagger spełnił nasze oczekiwania i może służyć do budowy modułowych aplikacji z punktami rozszerzania.

Modułowa aplikacja - czy warto?

Tworzenie modułowych aplikacji to świetna zabawa, do której wcale nie potrzeba nam kombajnów w rodzaju OSGi. Moduły to po prostu pewien styl programowania, który sprawia, że nasza aplikacja jest luźno powiązana, a to pociąga za sobą jej łatwą rozbudowę i późniejsze utrzymanie. Większość aplikacji tak naprawdę nie potrzebuje bajerów w rodzaju dynamicznego ładowania modułów. W zupełności wystarczy ich określenie w momencie kompilacji i już samo to daje nam bardzo dużo zalet. Spójrzmy jeszcze raz na napisany kod. Wprowadzone na początku pojęcia mogły brzmieć groźnie, a w praktyce okazało się, że mieliśmy do czynienia z czystą, szkolną Javą: implementowaliśmy sobie interfejsy i przekazywaliśmy argumenty do konstruktorów. Nasze moduły są dzięki temu praktycznie niezależne od technologii. Owszem, mamy tam parę adnotacji, ale same w sobie one nic nie robią. Możemy wyrzucić Daggera i zbudować wszystkie obiekty ręcznie, a nasza aplikacja będzie działać dokładnie tak samo.

W modułowych aplikacjach musimy pamiętać o tym, aby robić dużo punktów rozszerzania i... robić ich dużo. Zauważyłem, że ludzie z jakiegoś dziwnego powodu bardzo się tego boją i wymyślają niestworzone historie, byleby jakoś się usprawiedliwić, dlaczego stworzyli kolejną kobylastą klasę. Wyobraźmy sobie w ramach ćwiczenia, że w naszej aplikacji jest jakiś marshaller, klasa, która potrafi przetłumaczyć pewne obiekty np. na XML. Tego typu klasy bardzo uwielbiają rosnąć do ogromnych rozmiarów. Zaczyna się od tego, że marshaller musi rozpoznawać dwa rodzaje obiektów i odpowiednio je konwertować. W kodzie pojawia się instrukcja if ... else .... Za jakiś czas dochodzi trzeci obiekt i w kodzie pojawia się elseif. Później czwarty, piąty, szósty i tak dalej. I spróbuj sobie potem do czegoś takiego napisać testy automatyczne.

Moglibyśmy uniknąć problemu, gdyby marshaller od początku definiował tylko ogólny przebieg konwersji i nie zajmował się tym, ile rodzajów obiektów jest wspieranych. Zamiast ifa, mielibyśmy interfejs, który należałoby zaimplementować, aby dodać obsługę nowego typu:

public class Marshaller {
   private final Set<MarshallerPlugin> plugins;

   @Inject
   public Marshaller(Set<MarshallerPlugin> plugins) {
      this.plugins = Objects.requireNonNull(plugins);
   }

   public void convert(Object something) {
      this.prepareSomething();
      for (MarshallerPlugin plugin: plugins) {
         if (plugin.accepts(something)) {
            plugin.convert(something);
         }
      }
      this.finalizeSomething();
   }
}

Teraz dodanie obsługi nowego rodzaju obiektów nie nastręcza już żadnych trudności. Co więcej, każdy fragment marshallera możemy przetestować całkowicie niezależnie, a poszczególne elementy nie puchną wraz z rozbudową aplikacji o nowe funkcjonalności.

Argument nie będę robił punktu rozszerzania, bo nie wiem czy pojawi się trzecia implementacja jest kiepski z jeszcze jednego powodu. Wyobraź sobie, że w aplikacji masz już kilkanaście serwisów. Pojawia się nowa funkcjonalność do zaimplementowania. Jeśli we wszystkich serwisach są punkty rozszerzania, jest spora szansa, że jej implementacja sprowadzi się po prostu do napisania nowych wtyczek i wygrywasz. Jeśli natomiast punktów rozszerzania nie ma wcale, "bo nie wiadomo czy będą potrzebne" - to jest prawie pewne, że przegrasz, ponieważ będziesz musiał rozgrzebać istniejący i działający kod, a później spędzić dodatkowy czas na sprawdzeniu czy niczego nie popsułeś. Co więcej, Twoje serwisy zaczynają puchnąć, bo skoro nie było w nich do tej pory punktów rozszerzania, to raczej nie będzie Ci się chciało refaktorować kodu, by je teraz dodać. Moje doświadczenie mówi, że przy takim podejściu w bibliotece zawsze jest za mało punktów rozszerzania :). Skoro nie wiemy, co przyniesie przyszłość, twórzmy kod tak, aby był jak najbardziej na tę przyszłość odporny, tym bardziej, że JVM znakomicie radzi sobie z optymalizacją wywołań metod interfejsów, które mają tylko jedną lub dwie implementacje.

Zatem twórz punkty rozszerzania.

Wskazówki i porady

Na koniec chciałbym podać kilka dodatkowych wskazówek i porad. Zacznijmy od udzielenia sobie odpowiedzi na pytanie: kiedy używać Daggera, a kiedy potrzebne jest bardziej zaawansowane rozwiązanie. Dagger sprawdzi się wszędzie tam, gdzie lista modułów może być określona w momencie kompilacji. Zasadniczo z taką sytuacją mamy do czynienia w mikroserwisach, które założenia nie są zbyt duże. Dynamiczne komponowanie uzyskuje się w ich przypadku na zupełnie innym poziomie - po prostu usuwamy jeden mikroserwis i w jego miejsce wstawiamy inny.

Dagger nie sprawdzi się, jeśli listę modułów musimy budować dynamicznie, w momencie startu aplikacji. Wtedy musimy skorzystać z bardziej zaawansowanych rozwiązań:

  • lista modułów konstruowana jest raz i później się nie zmienia - wtedy odpowiednim rozwiązaniem będzie Google Guice,
  • lista modułów może się zmieniać w trakcie działania aplikacji - wtedy musimy skorzystać z rozwiązań takich, jak OSGi.

Warto też zwrócić uwagę na sposób, w jaki wstrzykujemy nasze zależności, bowiem kontenery udostępniają nam tutaj wiele rozwiązań: wstrzykiwanie przez konstruktor, wstrzykiwanie przez settery, wstrzykiwanie bezpośrednio do pól... Po latach tworzenia różnych aplikacji i przetestowaniu w boju różnych podejść mogę z czystym sumieniem powiedzieć, że jedynym słusznym jest wstrzykiwanie przez konstruktor. Dzięki temu każda nasza klasa może być nie tylko bezproblemowo użyta z innym kontenerem wstrzykiwania zależności, ale i używana całkowicie niezależnie. Pierwszym miejscem, w którym się to nam przyda, są testy automatyczne, gdzie raczej nie będziemy chcieli korzystać z kontenerów (również sprawdziłem to w praktyce ).

Ostatnia porada dotyczy tego, ile zależności powinna mieć klasa. Dla mnie maksymalną dopuszczalną liczbą są 4 zależności, czyli 4 argumenty konstruktora. Kiedy jest ich więcej, jest to wymowny sygnał, że nasza klasa próbuje robić zbyt dużo rzeczy. Liczbę 4 wspiera też literatura - polecam tutaj ciekawą książkę na ten temat Building Maintainable Software: Ten Guidelines For Future-proof Code autorstwa prof. Joosta Vissera, szefa departamentu badań w firmie Software Improvement Group zajmującej się audytami i badaniami jakości kodu.

Podsumowanie

Zaprezentowana aplikacja prezentuje wszystkie główne techniki, jakie mogą się nam przydać przy tworzeniu modułowych aplikacji z użyciem wstrzykiwania zależności, wraz z ich realizacjami w Daggerze 2. W praktyce okazuje się, że są one wystarczające do budowy nawet dużych aplikacji - tych konkretnych technik używam wielokrotnie w każdej aplikacji, zaś sytuacje, kiedy musiałem użyć czegoś ekstra, mogę policzyć na palcach jednej ręki. Mam nadzieję, że zarówno wpis, jak i zaprezentowana aplikacja, pomogą Wam nie tylko lepiej zrozumieć Daggera, ale i szybko wystartować z prawdziwym projektem.

Odnośniki:

-- Tomasz Jędrzejewski

Autor zdjęcia nagłówkowego: Ben Stassen, CC-BY-2.0

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, nie będzie publikowany.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.