Serwisy w świecie Guice'a

Serwisy w świecie Guice'a

Krótki poradnik, jak zaimplementować serwisy, które można startować i zatrzymywać, korzystając z Google Guice.

wtorek, 2 sierpnia 2016

Informatyka

Używam Guice'a od kilku lat. Jakiś czas temu natknąłem się na problem zaimplementowania w jednej z aplikacji API serwisów, które można startować i zatrzymywać. Nie chciałem i nie mogłem zaprogramować na sztywno ich sekwencji startowej, ponieważ aplikacja była złożona z modułów, które można było dowolnie komponować i po prostu nie dało się przewidzieć, co będziemy musieli odpalić. Guice posiada mechanizm providerów, ale nie są one tutaj właściwym rozwiązaniem z dwóch powodów:

  • nie można za ich pomocą zrealizować zamykania aplikacji i zakończenia działania serwisu,
  • twórcy rekomendują, aby w providerach nie używać żadnego I/O.

Zrozumienie sensu drugiego z tych ograniczeń zajęło mi trochę czasu, gdy poznawałem Guice'a, ale obecnie mam bardzo prostą odpowiedź: Zasada Jednej Odpowiedzialności. Guice zajmuje się tworzeniem i wsadzaniem jednych obiektów w inne. W ogóle nie obchodzi go ich inicjalizacja, startowanie (co to w ogóle takiego?). Providerów powinniśmy używać zatem wyłącznie do wstrzykiwania obiektów tam, gdzie Guice sobie z tym nie radzi. Proste, deterministyczne providery to klucz do tego, by nasz projekt był utrzymywalny i byśmy sobie po prostu nie strzelili w stopę. Dzięki temu ograniczeniu udało mi się tego błędu uniknąć, a swoje rozważania skierowałem w zupełnie innym kierunku.

To nie takie trudne

Pomyślałem, "w porządku, odpalenie sekwencji metod start(), a później sekwencji stop() w odwróconej kolejności nie może być aż tak trudne". Zaiste, nie było takie. Jedyna trudność polegała na znalezieniu odpowiedniego sposobu, jak obsłużyć możliwe scenariusze. Oto, co chciałem mieć:

  • uruchomienie sekwencji metod start() podczas startu aplikacji,
  • uruchomienie sekwencji metod stop() podczas zamykania,
  • eleganckie zatrzymanie już uruchomionych serwisów, gdy któryś z nich wysypie się podczas startu,
  • wsparcie dla aplikacji złożonych z nieznanej liczby modułów:
    • definiowanie zależności,
    • znalezienie właściwego porządku wykonania.

Pierwszym krokiem było odpowiedzenie sobie na pytanie, czym tak naprawdę dla mnie miał być serwis. Moja interpretacja brzmiała następująco: miała to być klasa, która posiada dwie metody do uruchamiania i zatrzymywania czegoś w aplikacji:

public interface StartableService {
   default public void start() throws Exception {
   }

   default public void stop() throws Exception {
   }
}

Proste, prawda? Teraz zbudujmy wokół tego trochę mechaniki.

Uruchamiarka serwisów

Uruchamiarka serwisów to klasa, która akceptuje zbiór różnych serwisów i uruchamia je. Aby okreslić kolejność odpalania, skorzystamy z pomocy oddzielnej klasy, ServiceComposer, którą omówimy później. Na razie wystarczy nam wiedza, że przyjmuje ona zbiór serwisów i "magicznie" wypluwa ich listę ułożoną zgodnie z kolejnością startu.

public class ServiceRunnerImpl implements ServiceRunner {
   private static final Logger log = LoggerFactory.getLogger(ServiceRunnerImpl.class);
   private final ServiceComposer serviceComposer;
   private final Set<StartableService> services;

   @Inject
   public ServiceRunnerImpl(ServiceComposer composer, @Assisted Set<StartableService> services) {
      this.serviceComposer = Objects.requireNonNull(composer);
      this.services = Objects.requireNonNull(services);
   }

   // 1
   @Override
   public void execute(Runnable serviceAwareCode) {
      // 2
      List<StartableService> orderedServices = serviceComposer.compose(services);
      List<StartableService> stopOrderedServices = Lists.reverse(orderedServices);
      Set<StartableService> correctlyStarted = new HashSet<>();

      // 3
      try {
         if (startServices(orderedServices, correctlyStarted)) {
            serviceAwareCode.run();
         }
      } finally {
         stopServices(stopOrderedServices, correctlyStarted);
      }
   }

   private boolean startServices(List<StartableService> services, Set<StartableService> correctlyStarted) {
      // 4
      for (StartableService svc: services) {
         String name = svc.getClass().getSimpleName();
         try {
            log.info("Starting service: " + name);
            svc.start();
            log.info("Started service: " + name);
            correctlyStarted.add(svc);
         } catch (Exception exception) {
            log.error("Service " + name + " failed during startup.", exception);
            return false;
         }
      }
      return true;
   }

   private void stopServices(List<StartableService> services, Set<StartableService> correctlyStarted) {
      // 5
      for (StartableService svc: services) {
         if (correctlyStarted.contains(svc)) {
            String name = svc.getClass().getSimpleName();
            try {
               log.info("Stopping service: " + name);
               svc.stop();
               log.info("Stopped service: " + name);
            } catch (Exception exception) {
               log.error("Service " + name + " failed during shutdown.", exception);
            }
         }
      }
   }
}

Wyjaśnienie:

  1. zazwyczaj będziemy chcieli wykonać jakiś kawałek kodu, gdy już odpalimy wszystkie serwisy. Możemy to zrobić poprzez przekazanie implementacji Runnable do wnętrza metody execute(),
  2. delegujemy zadanie znalezienia kolejności startowej do innego obiektu, aby zyskać więcej swobody i móc skupić się tutaj na sednie problemu,
  3. ten kawałek kodu gwarantuje nam, że odpalimy naszą akcję tylko wtedy, gdy wszystkie serwisy prawidłowo wystartują oraz że zawsze spróbujemy zamknąć je na końcu,
  4. każdy serwis, gdy wystartuje, zapamiętywany jest w zbiorze już odpalonych serwisów. Każdy wyjątek przerywa sekwencję startową i powoduje eleganckie zamknięcie aplikacji,
  5. zatrzymywanie jest bardzo podobne. Różnica polega jedynie na tym, że zatrzymujemy tylko te serwisy, które prawidłowo wystartowały oraz że błędy nie przerwą pętli.

Nie użyłem tutaj API strujemi z dwóch powodów. Po pierwsze, nie robię tu żadnych transformacji. Po drugie, nie oczekuję, by w mojej aplikacji były setki tysięcy serwisów - strumienie dodają duży narzut, który zwraca się tylko wtedy, gdy używamy ich do większych kolekcji i złożonych transformacji.

Komponowanie serwisów

Aby określić kolejność startu, zdecydowałem się wykorzystać adnotacje. W mojej aplikacji zakładam, że implementacja każdego serwisu schowana jest za interfejsem, który definiuje jego publiczne API. Tam, gdzie aplikacja potrzebuje skorzystać z serwisu, korzysta tak naprawdę z interfejsu, natomiast Guice podrzuca jego implementację, której użytkownik nie musi znać. Interfejsy możemy też wykorzystać do powiedzenia, że serwis A potrzebuje do pracy działającego serwisu B, co przekłada się na to, że B powinien wystartować wcześniej.

Zacznijmy zatem od dwóch adnotacji: @RequiresServices oraz @ProvidesService`:

@Retention(RUNTIME)
@Target(TYPE)
public @interface RequiresServices {
   Class<?>[] value();
}

@Retention(RUNTIME)
@Target(TYPE)
public @interface ProvidesService {
    Class<?> value();
}

Spójrzmy na nie i wyobraźmy sobie prosty serwis oznaczony nimi:

public interface JoeService {
   public void doSomething();
}
@RequiresServices({FooService.class, BarService.class})
@ProvidesService(JoeService.class)
public class JoeServiceImpl implements JoeService, StartableService {
   @Inject
   public JoeServiceImpl(FooService foo, BarService bar) {
      // ...
   }

   // start(), stop() ida tutaj
}

Gdy już mamy kilka serwisów, te adnotacje utworzą nam graf skierowany. Nasz problem znalezienia sekwencji startowej redukuje się do zaimplementowania algorytmu sortowania topologicznego, a takich algorytmów jest dostępnych kilka. W mojej implementacji użyłem algorytmu Kahna, którego pseudokod można znaleźć na Wikipedii, zaś mój kod - na Githubie.

Guice zbiera wszystko razem

Główna implementacja jest niezależna od jakiegokolwiek systemu wstrzykiwania zależności i może być używana jako samodzielne rozwiązanie. Jednak Guice i inne kontenery zależności znacznie upraszczają dodawanie nowych serwisów. Możemy pobrać sobie zbiór implementacji interfejsu StartableService, korzystając z rozszerzenia guice-multibindings:

public class CoreModule extends AbstractModule {
   protected void configure() {
      bind(BarServiceImpl.class).in(Singleton.class);
      bind(BarService.class).to(Key.get(BarServiceImpl.class));

      Multibinder<StartableService> serviceBinder = Multibinder.newSetBinder(binder, StartableService.class);
      serviceBinder.addBinding().to(Key.get(BarServiceImpl.class));
   }
}

Ponieważ jest tu dużo zagmatwanego kodu, polecam opakować go w jakąś statyczną metodę, która pozwoli prosto rejestrować nowe serwisy w innych modułach. Teraz jesteśmy gotowi do stworzenia uruchamiarki:

public class Bootstrap {
   private final ServiceRunner runner;

   @Inject
   public Bootstrap(ServiceRunnerFactory factory, Set<StartableService> services) {
      runner = factory.create(services);
   }

   public void execute() {
      runner.execute(() -> System.out.println("Hi, universe!"));
   }
}

Podsumowanie

Zaproponowane rozwiązanie jest bardzo proste, a jednocześnie eleganckie i wystarczająco potężne dla potrzeb wielu aplikacji. Składa się z dwóch klas, trzech interfejsów i dwóch adnotacji. W zasadzie zamiast Guice'a można pokusić się o użycie dowolnego innego kontenera. Sam od dłuższego czasu mam ochotę na wypróbowanie Daggera.

Przykładowy kod można znaleźć w moim repozytorium-piaskownicy na Githubie, gdzie testuję sobie od czasu do czasu różne koncepcje. Opisane klasy są w com.zyxist.dirtyplayground.core.svc. Mam nadzieję, że znajdę niebawem trochę czasu na przeniesienie je do dedykowanego repozytorium.

Tomasz Jędrzejewski

Programista Javy, lider techniczny. W wolnych chwilach podróżuje, realizując od kilku lat projekty długodystansowych wypraw pieszych.

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

zobacz inne wpisy w temacie

Informatyka

poprzedni wpis Bariery w LMAX Disruptor następny wpis Wielokrotne haszowanie

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, anonimizowany po zatwierdzeniu komentarza.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

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

Klikając "Wyślij komentarz" wyrażasz zgodę na przetwarzanie podanych w nim danych osobowych do celów moderacji i publikacji komentarza, zgodnie z polityką prywatności: polityka prywatności