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
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:
- 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 metodyexecute()
, - delegujemy zadanie znalezienia kolejności startowej do innego obiektu, aby zyskać więcej swobody i móc skupić się tutaj na sednie problemu,
- 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,
- 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,
- 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.
zobacz inne wpisy w temacie
Komentarze (0)