Resilience4j i Spring Boot a odporność na awarie

Filip Raszka

Używanie prawidłowych wzorców odporności na uszkodzenia dla poszczególnych przypadków jest bardzo ważne. Dlaczego? Znacznie zwiększa naszą kontrolę nad błędami zewnętrznymi i pojemności. W efekcie aplikacja jest bardziej przewidywalna. Jak zaimplementować je przy użyciu biblioteki Resilience4j i wypróbować we własnej usłudze Spring Boot?

Wstęp

Wzorce odporności na uszkodzenia (ang. Fault Tolerance Patterns) pomagają określić, jak powinna się zachowywać aplikacja, gdy nie może spełnić odebranego żądania – czy to z powodu wyczerpania zasobów własnych, czy błędów pochodzących z wywołań zewnętrznych. Przykład?

Wyobraź sobie, że usługa zewnętrzna działa bardzo wolno i niekonsekwentnie, zwracając błędy po długich okresach oczekiwania. Nie najlepszą strategią będzie ciągłe wysyłanie żądań do serwisu, nie pozwalając mu się zregenerować i blokując własne zasoby. Lepiej byłoby wykryć taki scenariusz i zmienić zachowanie aplikacji tak, aby automatycznie przełączała się w tryb awaryjny bez wywoływania zewnętrznego serwisu. Dopiero po pewnym czasie należałoby spróbować wrócić do pierwotnego zachowania. Dokładnie to robi wzorzec wyłącznika (ang. Circuit Breaker).

Najważniejsze wzorce odporności

Istnieje wiele wzorców, które możemy zastosować do naszych metod, zabezpieczając je przed różnymi stanami błędów, z których każdy ma zastosowanie w nieco innym scenariuszu. Stosowanie odpowiednich wzorców do naszych usług nie tylko pomaga nam lepiej zarządzać zasobami, ostatecznie skracając czas życia rzeczywistego problemu. Sprawia też, że nasza aplikacja jest bardziej responsywna i spójna.

Do najważniejszych wzorców odporności na uszkodzenia należą:

  • Wyłącznik (ang. Circuit Breaker) – aplikacja śledzi, jak często chroniona usługa kończy się niepowodzeniem. Jeśli awarie przekroczą skonfigurowany próg, obwód zostanie otwarty, a wszystkie kolejne wywołania natychmiast zakończą się niepowodzeniem, dając czas na uzdrowienie się wadliwej usługi. Po pewnym czasie obwód staje się w połowie otwarty i umożliwia przejście niektórych połączeń, testując usługę. Jeśli reaguje poprawnie, obwód jest zamykany, a zachowanie aplikacji wraca do normy. W przeciwnym razie obwód zostanie ponownie w pełni otwarty.
  • Ponowienie (ang. Retry) – jeśli chroniona usługa ulegnie awarii, aplikacja czeka przez skonfigurowany czas, po czym próbuje ponownie, powtarzając proces tyle razy, ile zostało określone podczas konfiguracji.
  • Przegroda (ang. Bulkhead) – tylko pewna liczba otwartych wywołań do chronionej usługi jest możliwa w tym samym czasie, reszta jest odrzucana.
  • Ogranicznik liczby żądań (ang. Rate Limiter) – tylko określony poziom ruchu jest akceptowany do chronionej usługi. Jeśli ruch jest zbyt duży (w zależności od konfiguracji), kolejne wywołania są odrzucane.

Odporność na awarie dzięki zastosowaniu Resilience4j

Resilience4j to „lekka” biblioteka zawierająca mechanizmy odporności na uszkodzenia, którą możesz z łatwością dodać i używać w ramach naszego projektu Spring Boot. Zapewnia implementację różnych wzorców odporności na uszkodzenia i stabilności. Jest też oparta na adnotacjach. Możesz dodać adnotację np. @CircuitBreaker do metody lub klasy (co oznacza wszystkie metody publiczne) i podać dla niej konkretną konfigurację w naszych plikach profilu yaml.

Możesz również podać metodę rezerwową dla metody z adnotacjami, która zostanie wywołana w przypadku jakiegokolwiek błędu – zamiast propagować wyjątek. Resilience4j to następca popularnej, ale obecnie przestarzałej biblioteki Netflix Hystrix. Dobrze współpracuje ze Spring Boot 3 i rozszerza funkcjonalności poprzednika. Tym samym zapewnia większą konfigurowalność (np. znacznie rozszerza ją dla stanu półotwartego Circuit Breaker, podczas gdy Hystrix wykonałby tylko jedno wywołanie w tym stanie).

Inicjalizacja projektu

Pora na skonfigurowanie wałsnego projektu Spring Boot 3 w celu przetestowania funkcjonalności Resilience4j. Stosując się do poniższych instrukcji, zdefiniujesz prosty serwer wywołujący usługę zewnętrzną, zabezpieczysz wywołania zewnętrzne za pomocą Resilience4j i przetestujesz scenariusze błędów za pomocą testów integracyjnych Spring Boot z wykorzystaniem narzędzia WireMock.

Usługi REST

Dodaj trójwarstwową strukturę:

  • TaskController odbiera żądanie i przekazuje je do TaskService;
  • TaskService wykonuje logikę biznesową i wywołuje TaskConnector;
  • TaskConnector wywołuje zewnętrzny interfejs API (jest to metoda łącznika, którą opatrzysz adnotacjami Resilience4j).

Przetestujesz wzorce Circuit Breaker, Retry, Bulkhead i Rate Limiter. W swoich testach, dla zwięzłości, będziesz bezpośrednio wywoływać metody Service, dzięki czemu nie musisz powielać wszystkich metod w kontrolerze.

  • TaskController
@Slf4j
@RestController
@RequiredArgsConstructor
public class TaskController {

    private final TaskService taskService;

    @GetMapping("/task/{id}")
    public String getTaskDetails(@PathVariable("id") Integer id) {
        return taskService.getTaskDetails(id);
    }
  • TaskService
@Slf4j
@Service
@RequiredArgsConstructor
public class TaskService {

    private final TaskConnector taskConnector;

    public String getTaskDetailsCircuitBreaker(Integer id) {
        log.info("Getting task {} details using Circuit Breaker pattern", id);
        // some other business logic here
        return taskConnector.getTaskDetailsCircuitBreaker(id);
    }

    public String getTaskDetailsRetry(Integer id) {
        log.info("Getting task {} details using Retry pattern", id);
        // some other business logic here
        return taskConnector.getTaskDetailsRetry(id);
    }

    public String getTaskDetailsBulkhead(Integer id) {
        log.info("Getting task {} details using Bulkhead pattern", id);
        // some other business logic here
        return taskConnector.getTaskDetailsBulkhead(id);
    }

    public String getTaskDetailsRatelimiter(Integer id) {
        log.info("Getting task {} details using Ratelimiter pattern", id);
        // some other business logic here
        return taskConnector.getTaskDetailsRatelimiter(id);
    }
}
  • TaskConnector
@Slf4j
@Service
@RequiredArgsConstructor
public class TaskConnector {

    private final RestTemplate restTemplate;

    // TODO: add resilience4j annotation
    public String getTaskDetailsCircuitBreaker(Integer id) {
        return restTemplate.getForObject("/task/{id}", String.class, id);
    }

    // TODO: add resilience4j annotation
    public String getTaskDetailsRetry(Integer id) {
        return restTemplate.getForObject("/task/{id}", String.class, id);
    }

    // TODO: add resilience4j annotation
    public String getTaskDetailsBulkhead(Integer id) {
        return restTemplate.getForObject("/task/{id}", String.class, id);
    }

    // TODO: add resilience4j annotation
    public String getTaskDetailsRatelimiter(Integer id) {
        return restTemplate.getForObject("/task/{id}", String.class, id);
    }
}

Pamiętaj, aby zdefiniować również komponent @Bean RestTemplate używany przez Twój TaskConnector. Jak to zrobić? Spójrz:

@Configuration
public class RestConfiguration {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplateBuilder().rootUri("http://localhost:8081")
                .build();
    }
}

W celach testowych konfigurujemy naszą aplikację tak, aby wywoływała usługi zewnętrzne pod następującym adresem: localhost:8081.

WireMock

Aby wykorzystać w swoim projekcie Wiremock, a więc narzędzie do mockowania zewnętrznych usług w testach, musisz dodać zależności:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
    <version>4.0.3</version>
    <scope>test</scope>
</dependency>

Teraz możesz skonfigurować swoje testy Spring Boot. Zrób to w następujący sposób:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 8081)
class Resilience4jApplicationTests {

    private static final String SERVER_ERROR_NAME = "org.springframework.web.client.HttpServerErrorException$InternalServerError";
    private static final String CIRCUIT_BREAKER_ERROR_NAME = "io.github.resilience4j.circuitbreaker.CallNotPermittedException";
    private static final String BULKHEAD_ERROR_NAME = "io.github.resilience4j.bulkhead.BulkheadFullException";
    private static final String RATELIMITER_ERROR_NAME = "io.github.resilience4j.ratelimiter.RequestNotPermitted";

    @Autowired
    private TaskService taskService;
    @SpyBean
    private RestTemplate restTemplate;

    @BeforeEach
    void initWireMock() {
        stubFor(get(urlEqualTo("/task/1")).willReturn(aResponse().withBody("Task 1 details")));
        stubFor(get(urlEqualTo("/task/2")).willReturn(serverError().withBody("Task 2 details failed")));
    }
    // [...]
}
  • Określasz, że podczas testów integracyjnych Twoja aplikacja powinna działać na losowym porcie.
  • Definiujesz stałe: nazwę wyjątku, który wyrzucisz w zewnętrznej usłudze WireMock stub oraz nazwy wyjątków, które z wyjątkiem Resilience4j masz wyrzucić, gdy odrzuci wywołanie metody chronionej.
  • Wstrzykujesz zależność TaskService.
  • Przygotowujesz Mockito Spy dla RestTemplate którego używa Twój TaskConnector.
  • Za pomocą WireMock określasz, że wywołania zewnętrzne z parametrem ścieżki o wartości „1” powinny zawsze kończyć się powodzeniem, a te o wartości „2” – zawsze powinny kończyć się niepowodzeniem.

Dodawanie Resilience4j do projektu Spring Boot

Teraz dodaj Resilience4j do Twojego projektu. Aby było to możliwe, musisz pamiętać o poniższych zależnościach:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-spring-boot3</artifactId>
    <version>2.1.0</version>
</dependency>

Gotowe? Teraz możesz dodać adnotacje Resilience4j do Twojego TaskConnector! W dalszych sekcjach skonfigurujesz i przetestujesz wzorce odporności na uszkodzenia opisane we wcześniejszej sekcji.

Wyłącznik (ang. Circuit Breaker)

Dodaj adnotację @CircuitBreaker do metody w TaskConnector. Zrób to w ten sposób:

@CircuitBreaker(name = "CircuitBreakerService", fallbackMethod = "getTaskDetailsFallback")
    public String getTaskDetailsCircuitBreaker(Integer id) {
        return restTemplate.getForObject("/task/{id}", String.class, id);
    }
  • Określasz nazwę swojego wyłącznika w adnotacji – pozwoli Ci to zidentyfikować go w pliku konfiguracyjnym.
  • Określasz metodę w przypadku awarii – zostanie ona wywołana, kiedy oryginalna metoda „wyrzuci” wyjątek.

Część określająca metodę awaryjną/rezerwową nie jest obowiązkowa. Gdy nie jest ona zastosowana, wyjątek jest po prostu nadal propagowany. W Twoim przypadku metoda rezerwowa zwróci komunikat o błędzie zawierający nazwę zgłoszonego wyjątku. W ten sposób sprawdzisz, czy metoda awaryjna została wywołana, jak do tego doszło i czy oryginalny wyjątek jest poprawny. Sygnatura metody rezerwowej musi być taka sama, jak sygnatura metody z oryginalnej z adnotacjami – z dodatkiem argumentu Throwable, który zawiera wyjątek powodujący awarię.

    public String getTaskDetailsFallback(Integer id, Throwable error) {
        return "Default fallback with error " + error.getClass().getName();
    }

Jak określić konfigurację Twojego wzorca wyłącznika w pliku yaml? Możesz zrobić to w ten sposób:

resilience4j:
  circuitbreaker:
    instances:
      CircuitBreakerService:
        failure-rate-threshold: 50
        minimum-number-of-calls: 5
        automatic-transition-from-open-to-half-open-enabled: true
        wait-duration-in-open-state: 15s
        permitted-number-of-calls-in-half-open-state: 3
        sliding-window-type: count_based
        sliding-window-size: 10

Jak widać, użyliśmy identyfikatora z adnotacji. Dzięki konfiguracjom możesz dostosować zachowanie wyłącznika. Oto najważniejsze z nich:

  • failure-rate-threshold – procent progu awarii (jeśli wskaźnik awaryjności jest równy lub wyższy, obwód jest otwierany);
  • minimum-number-of-calls – minimalna liczba połączeń wymaganych do obliczenia poziomu błędu;
  • automatic-transition-from-open-to-half-open-enabled – to, czy przejście z otwartego do półotwartego powinno być automatyczne, zależy tylko od czasu, jaki upłynął;
  • wait-duration-in-open-state – jak długo czekać przed przejściem z otwartego do półotwartego;
  • permitted-number-of-calls-in-half-open-state – na ile wywołań testowych należy zezwolić w stanie półotwartym;
  • sliding-window-type – definiuje typ okna przesuwnego, a więc – czy śledzić ostatnie błędy na podstawie licznika, czy też czasu;
  • sliding-window-size – rozmiar okna przesuwnego (liczba wywołań, jeśli typ jest count_based lub sekund, jeśli jest time_based).

Pełną listę możliwych konfiguracji znajdziesz na stronie dokumentacji wzorca wyłącznika.

Testowanie wzorca wyłącznika

Dodaj testy do Resilience4jApplicationTests. Spójrz, jak o zrobić:

@Test
    void testCircuitBreaker() {
        IntStream.rangeClosed(1, 5).forEach(i -> {
            String details = taskService.getTaskDetailsCircuitBreaker(2);
            assertThat(details).isEqualTo("Default fallback with error " + SERVER_ERROR_NAME);
        });
        IntStream.rangeClosed(1, 5).forEach(i -> {
            String details = taskService.getTaskDetailsCircuitBreaker(2);
            assertThat(details).isEqualTo("Default fallback with error " + CIRCUIT_BREAKER_ERROR_NAME);
        });
        Mockito.verify(restTemplate, Mockito.times(5)).getForObject("/task/{id}", String.class, 2);
    }
  • Pierwsze pięć zapytań powinno zakończyć się niepowodzeniem – z wyjątkiem błędu serwera, ponieważ minimalna liczba została skonfigurowana na pięć.
  • Następne pięć zapytań powinno zakończyć się niepowodzeniem – z wyjątkiem Resilience4j CallNotPermittedException, ponieważ obliczona liczba błędów dla okna jest równa lub wyższa niż skonfigurowane 50%.
  • Rzeczywisty restTemplate powinien być wywoływany tylko pięć, a nie dziesięć, ponieważ obwód powinien zostać otwarty.

Gdy wszystko będzie w porządku, zobaczysz, że testy przeszły pozytywnie. Powinno to wyglądać tak:

Ponowienie (ang. Retry)

Dodaj adnotację @Retry do metody TaskConnector. Spójrz, jak to zrobić:

    @Retry(name = "RetryService", fallbackMethod = "getTaskDetailsFallback")
    public String getTaskDetailsRetry(Integer id) {
        return restTemplate.getForObject("/task/{id}", String.class, id);
    }

Ponownie użyj identyfikatora z adnotacji dla konfiguracji:

  retry:
    instances:
      RetryService:
        max-attempts: 3
        wait-duration: 1s
  • max-attempts – maksymalna liczba prób;
  • wait-duration – jak długo czekać przed każdą próbą.

Pełną listę możliwych konfiguracji znajdziesz w dokumentacji wzorca ponawiania.

Testowanie wzorca ponawiania

Dodaj testy do Resilience4jApplicationTests. Jak? Tak:

 @Test
    public void testRetry() {
        String result1 = taskService.getTaskDetailsRetry(1);
        assertThat(result1).isEqualTo("Task 1 details");
        Mockito.verify(restTemplate, Mockito.times(1)).getForObject("/task/{id}", String.class, 1);

        String result2 = taskService.getTaskDetailsRetry(2);
        assertThat(result2).isEqualTo("Default fallback with error " + SERVER_ERROR_NAME);
        Mockito.verify(restTemplate, Mockito.times(3)).getForObject("/task/{id}", String.class, 2);
    }
  • Zapytanie dla identyfikatora „1” powinno zakończyć się powodzeniem, więc powinna być tylko jedna próba.
  • Wywołania dla identyfikatora „2” powinny zakończyć się niepowodzeniem, więc powinny być trzy próby (ostatecznie zwracając błąd przez metodę awaryjną).

Jeśli wszystko jest właściwie przygotowane, zobaczysz, że testy przeszły pozytywnie:

Przegroda (ang. Bulkhead)

Dodaj adnotację @Bulkhead do metody TaskConnector:

    @Bulkhead(name = "BulkheadService", fallbackMethod = "getTaskDetailsFallback")
    public String getTaskDetailsBulkhead(Integer id) {
        return restTemplate.getForObject("/task/{id}", String.class, id);
    }

Ponownie użyj identyfikatora z adnotacji dla konfiguracji. Jak to zrobić? Spójrz:

  bulkhead:
    instances:
      BulkheadService:
        max-concurrent-calls: 3
        max-wait-duration: 1
  • max-concurrent-call – maksymalna liczba jednoczesnych połączeń do zaakceptowania;
  • max-wait-duration – jak długo kolejne połączenia mogą być w stanie oczekującym (w ms), zanim zostaną odrzucone.

Pełną listę możliwych konfiguracji znajdziesz na stronie dokumentacji wzorca przegrody.

Testowanie wzorca przegrody

Dodaj test do Resilience4jApplicationTests. Wykorzystaj poniższą metodę:

  @Test
    public void testBulkhead() throws Exception {
        ExecutorService executorService = Executors.newFixedThreadPool(5);
        CountDownLatch latch = new CountDownLatch(5);
        AtomicInteger successCounter = new AtomicInteger(0);
        AtomicInteger failCounter = new AtomicInteger(0);

        IntStream.rangeClosed(1, 5)
                .forEach(i -> executorService.execute(() -> {
                    String result = taskService.getTaskDetailsBulkhead(1);
                    if (result.equals("Default fallback with error " + BULKHEAD_ERROR_NAME)) {
                        failCounter.incrementAndGet();
                    } else if (result.equals("Task 1 details")) {
                        successCounter.incrementAndGet();
                    }
                    latch.countDown();
                }));
        latch.await();
        executorService.shutdown();

        assertThat(successCounter.get()).isEqualTo(3);
        assertThat(failCounter.get()).isEqualTo(2);
        Mockito.verify(restTemplate, Mockito.times(3)).getForObject("/task/{id}", String.class, 1);
    }
  • Wołasz metodę z adnotacją przegrody w pięciu osobnych wątkach.
  • Tylko pierwsze trzy wywołania powinny zwrócić pozytywną odpowiedź, ostatnie dwa powinny zostać odrzucone i zwrócić błąd z metody awaryjnej.
  • Ostatecznie restTemplate powinien zostać wywołany dokładnie trzy razy.

Gotowe? Teraz zobaczysz, że testy przeszły pozytywnie:

Ogranicznik liczby żądań (ang. RateLimiter)

Dodaj adnotację @RateLimiter do metody TaskConnector. Zobacz, jak to zrobić:

  @RateLimiter(name = "RateLimiterService", fallbackMethod = "getTaskDetailsFallback")
    public String getTaskDetailsRatelimiter(Integer id) {
        return restTemplate.getForObject("/task/{id}", String.class, id);
    }

Ponownie użyj identyfikatora z adnotacji dla konfiguracji:

  ratelimiter:
    instances:
      RateLimiterService:
        limit-for-period: 5
        limit-refresh-period: 60s
        timeout-duration: 0s
  • limit-for-period – limit połączeń dozwolonych w jednym okresie (po każdym okresie limit jest resetowany);
  • limit-refresh-period – okres odświeżenia limitu;
  • timeout-duration – domyślny czas oczekiwania dla wątku, gdy limit zostanie wyczerpany w bieżącym okresie, zanim wywołanie zostanie odrzucone.

Pełną listę możliwych konfiguracji znajdziesz w dokumentacji wzorca ogranicznika liczby żądań.

Testowanie wzorca ogranicznika liczby żądań

Dodaj test do Resilience4jApplicationTests:

 @Test
    public void testRateLimiter() {
        AtomicInteger successCounter = new AtomicInteger(0);
        AtomicInteger failCounter = new AtomicInteger(0);

        IntStream.rangeClosed(1, 10)
                .parallel()
                .forEach(i -> {
                    String result = taskService.getTaskDetailsRatelimiter(1);
                    if (result.equals("Default fallback with error " + RATELIMITER_ERROR_NAME)) {
                        failCounter.incrementAndGet();
                    } else if (result.equals("Task 1 details")) {
                        successCounter.incrementAndGet();
                    }
                });

        assertThat(successCounter.get()).isEqualTo(5);
        assertThat(failCounter.get()).isEqualTo(5);
        Mockito.verify(restTemplate, Mockito.times(5)).getForObject("/task/{id}", String.class, 1);
    }
  • Wywołujesz metodę RateLimiter jednocześnie dziesięć razy.
  • Tylko pierwsze pięć wywołań powinno zwrócić pozytywną odpowiedź, kolejne przekroczą limit, więc zostaną odrzucone.
  • Ostatecznie restTemplate powinien zostać wywołany dokładnie pięć razy.

Jeśli wszystko jest w porządku, zobaczysz, że testy przeszły pozytywnie:

Podsumowanie

Wiesz już, jak ważne są wzorce odporności na uszkodzenia i jak je zaimplementować przy użyciu biblioteki Resilience4j. Używanie prawidłowych wzorców dla poszczególnych przypadków znacznie zwiększa Twoją kontrolę nad błędami zewnętrznymi i pojemności. W efekcie aplikacja jest bardziej przewidywalna. Może warto spróbować tego w swojej usłudze Spring Boot?

Odnośniki

Poznaj mageek of j‑labs i daj się zadziwić, jak może wyglądać praca z j‑People!

Skontaktuj się z nami