1. 概述
Resilience4j 是一個輕量級的容錯性庫,為 Web 應用程序提供多種容錯性和穩定性模式。
在本教程中,我們將學習如何使用該庫與一個簡單的 Spring Boot 應用程序一起使用。
2. 設置
本節將重點介紹為我們的 Spring Boot 項目設置關鍵方面。
2.1. Maven 依賴
首先,我們需要添加 <em ><a href="https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-web">spring-boot-starter-web</a></em> 依賴,以啓動一個簡單的 Web 應用程序:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>接下來,我們需要添加 resilience4j-spring-boot2 和 spring-boot-starter-aop 依賴項,以便在我們的 Spring Boot 應用程序中使用 Resilience-4j 庫中的註解功能。
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>此外,我們還需要添加 spring-boot-starter-actuator 依賴項,以便通過一組暴露的端點來監控應用程序的當前狀態:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>最後,我們將添加 wiremock-jre8 依賴項,它將幫助我們在使用 Mock HTTP 服務器測試我們的 REST API:
<dependency>
<groupId>com.github.tomakehurst</groupId>
<artifactId>wiremock-jre8</artifactId>
<scope>test</scope>
</dependency>2.2. RestController 和外部 API 調用者
在使用 Resilience4j 庫的不同功能的同時,我們的 Web 應用程序需要與外部 API 進行交互。因此,我們來添加一個使用 RestTemplate 的 Bean,以幫助我們進行 API 調用:
@Bean
public RestTemplate restTemplate() {
return new RestTemplateBuilder().rootUri("http://localhost:9090")
.build();
}然後,我們將定義 ExternalAPICaller 類為 Component,並使用 restTemplate bean 作為成員:
@Component
public class ExternalAPICaller {
private final RestTemplate restTemplate;
@Autowired
public ExternalAPICaller(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
}接下來,我們將定義 ResilientAppController 類,該類暴露 REST API 端點,並在內部使用 ExternalAPICaller Bean 調用外部 API:
@RestController
@RequestMapping("/api/")
public class ResilientAppController {
private final ExternalAPICaller externalAPICaller;
}2.3. Actuator 端點
我們可以通過 Spring Boot Actuator 暴露健康端點,從而實時瞭解應用程序的精確狀態。
因此,讓我們將配置添加到 application.properties 文件中,並啓用這些端點:
management.endpoints.web.exposure.include=*
management.endpoint.health.show-details=always
management.health.circuitbreakers.enabled=true
management.health.ratelimiters.enabled=true此外,在需要時,我們將會在相同的 application.properties 文件中添加特定功能的配置。
2.4. 單元測試
我們的 Web 應用程序將在實際場景中調用一個外部服務。但是,我們可以通過使用 WireMockExtension 類啓動外部服務來 模擬該運行服務的存在。
因此,讓我們將 EXTERNAL_SERVICE 定義為 ResilientAppControllerUnitTest 類中的一個靜態成員:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ResilientAppControllerUnitTest {
@RegisterExtension
static WireMockExtension EXTERNAL_SERVICE = WireMockExtension.newInstance()
.options(WireMockConfiguration.wireMockConfig()
.port(9090))
.build();然後,我們將添加一個 TestRestTemplate 實例來調用 API:
@Autowired
private TestRestTemplate restTemplate;2.5. 異常處理器
Resilience4j 庫將根據上下文中的容錯模式,通過拋出異常來保護服務資源。然而,這些異常應轉換為具有意義的狀態碼的 HTTP 響應,供客户端使用。
因此,我們將 定義 ApiExceptionHandler 類來存儲不同異常的處理程序:
@ControllerAdvice
public class ApiExceptionHandler {
}我們將在探索不同的容錯模式時,向該類添加處理程序。
3. 電路斷路器
電路斷路器模式通過在部分或完全停機期間限制上游服務調用下游服務來保護下游服務。
我們首先暴露 /api/circuit-breaker 端點並添加 @CircuitBreaker 註解:
@GetMapping("/circuit-breaker")
@CircuitBreaker(name = "CircuitBreakerService")
public String circuitBreakerApi() {
return externalAPICaller.callApi();
}根據要求,我們還需要為 ExternalAPICaller 類定義 callApi() 方法,用於調用外部端點 /api/external:
public String callApi() {
return restTemplate.getForObject("/api/external", String.class);
}接下來,我們將會在 application.properties文件中添加電路斷路器的配置:
resilience4j.circuitbreaker.instances.CircuitBreakerService.failure-rate-threshold=50
resilience4j.circuitbreaker.instances.CircuitBreakerService.minimum-number-of-calls=5
resilience4j.circuitbreaker.instances.CircuitBreakerService.automatic-transition-from-open-to-half-open-enabled=true
resilience4j.circuitbreaker.instances.CircuitBreakerService.wait-duration-in-open-state=5s
resilience4j.circuitbreaker.instances.CircuitBreakerService.permitted-number-of-calls-in-half-open-state=3
resilience4j.circuitbreaker.instances.CircuitBreakerService.sliding-window-size=10
resilience4j.circuitbreaker.instances.CircuitBreakerService.sliding-window-type=count_based基本上,配置將允許50%的失敗調用進入關閉狀態,之後它將打開斷路器並開始拒絕帶有CallNotPermittedException的請求。因此,建議在ApiExceptionHandler類中添加一個處理此異常的處理程序:
@ExceptionHandler({CallNotPermittedException.class})
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
public void handleCallNotPermittedException() {
}最後,我們將通過模擬下游服務故障場景來測試 /api/circuit-breaker API 端點。使用 EXTERNAL_SERVICE。
@Test
public void testCircuitBreaker() {
EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
.willReturn(serverError()));
IntStream.rangeClosed(1, 5)
.forEach(i -> {
ResponseEntity response = restTemplate.getForEntity("/api/circuit-breaker", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
});
IntStream.rangeClosed(1, 5)
.forEach(i -> {
ResponseEntity response = restTemplate.getForEntity("/api/circuit-breaker", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
});
EXTERNAL_SERVICE.verify(5, getRequestedFor(urlEqualTo("/api/external")));
}我們可以看到,前五次調用失敗,原因是下游服務不可用。之後,電路斷路器切換到打開狀態,後續五次嘗試均被拒絕,並使用 503 HTTP 狀態碼,而沒有實際調用底層的 API。
4. 重新嘗試
重試模式通過從瞬態問題中恢復,為系統提供彈性。
讓我們先添加 /api/retry API 端點,並使用 @Retry 註解:
@GetMapping("/retry")
@Retry(name = "retryApi", fallbackMethod = "fallbackAfterRetry")
public String retryApi() {
return externalAPICaller.callApi();
}當然,以下是翻譯後的內容:
可選地,我們還可以提供在所有重試嘗試失敗時使用的備用機制。在這種情況下,我們提供了 fallbackAfterRetry 作為備用方法:
public String fallbackAfterRetry(Exception ex) {
return "all retries have exhausted";
}接下來,我們將更新 application.properties 文件以添加控制重試行為的配置:
resilience4j.retry.instances.retryApi.max-attempts=3
resilience4j.retry.instances.retryApi.wait-duration=1s
resilience4j.retry.metrics.legacy.enabled=true
resilience4j.retry.metrics.enabled=true如上所示,我們計劃最多重試三次,每次延遲 1s
最後,我們將測試 /api/retry API 端點的重試行為:
@Test
public void testRetry() {
EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
.willReturn(ok()));
ResponseEntity<String> response1 = restTemplate.getForEntity("/api/retry", String.class);
EXTERNAL_SERVICE.verify(1, getRequestedFor(urlEqualTo("/api/external")));
EXTERNAL_SERVICE.resetRequests();
EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
.willReturn(serverError()));
ResponseEntity<String> response2 = restTemplate.getForEntity("/api/retry", String.class);
Assert.assertEquals(response2.getBody(), "all retries have exhausted");
EXTERNAL_SERVICE.verify(3, getRequestedFor(urlEqualTo("/api/external")));
}我們可以看到,在第一個場景中,沒有問題,因此一次嘗試就足夠了。另一方面,當出現問題時,有三次嘗試,之後API通過回退機制響應。
5. 時間限制器
我們可以使用時間限制器模式來為向外部系統發出的異步調用設置閾值超時值。
下面添加 /api/time-limiter API 端點,該端點內部調用一個慢速 API:
@GetMapping("/time-limiter")
@TimeLimiter(name = "timeLimiterApi")
public CompletableFuture<String> timeLimiterApi() {
return CompletableFuture.supplyAsync(externalAPICaller::callApiWithDelay);
}然後,我們將通過在 callApiWithDelay()方法中添加睡眠時間來模擬外部API調用的延遲:
public String callApiWithDelay() {
String result = restTemplate.getForObject("/api/external", String.class);
try {
Thread.sleep(5000);
} catch (InterruptedException ignore) {
}
return result;
}接下來,我們需要在 application.properties文件中提供 timeLimiterApi的配置:
resilience4j.timelimiter.metrics.enabled=true
resilience4j.timelimiter.instances.timeLimiterApi.timeout-duration=2s
resilience4j.timelimiter.instances.timeLimiterApi.cancel-running-future=true我們能看到閾值設置為 2 秒。之後,Resilience4j 庫內部會使用 TimeoutException 異步操作取消。因此,我們將 在 ApiExceptionHandler 類中添加一個處理該異常的處理程序,以返回帶有 408 HTTP 狀態碼的 API 響應:
@ExceptionHandler({TimeoutException.class})
@ResponseStatus(HttpStatus.REQUEST_TIMEOUT)
public void handleTimeoutException() {
}最後,我們將驗證配置好的時間限制器模式對於 api/time-limiter</em/> API 端點的配置:
@Test
public void testTimeLimiter() {
EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external").willReturn(ok()));
ResponseEntity<String> response = restTemplate.getForEntity("/api/time-limiter", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.REQUEST_TIMEOUT);
EXTERNAL_SERVICE.verify(1, getRequestedFor(urlEqualTo("/api/external")));
}正如預期的那樣,由於下游 API 調用設置為超過五秒完成,因此我們觀察到該 API 調用超時。
6. 隔板(Bulkhead)
隔板模式限制了對外部服務的併發最大調用次數。
我們首先添加帶有 /api/bulkhead API 端點以及 @Bulkhead 註解:
@GetMapping("/bulkhead")
@Bulkhead(name="bulkheadApi")
public String bulkheadApi() {
return externalAPICaller.callApi();
}接下來,我們將通過在 application.properties文件中定義配置來控制 bulkhead 功能:
resilience4j.bulkhead.metrics.enabled=true
resilience4j.bulkhead.instances.bulkheadApi.max-concurrent-calls=3
resilience4j.bulkhead.instances.bulkheadApi.max-wait-duration=1通過此項,我們希望限制同時進行的調用數量為三個,因此每個線程最多等待 1ms,如果 bulkhead 已滿。之後,請求將被用 BulkheadFullException 異常拒絕。我們還將添加異常處理程序,以便返回有意義的 HTTP 狀態碼給客户端:
@ExceptionHandler({ BulkheadFullException.class })
@ResponseStatus(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED)
public void handleBulkheadFullException() {
}最後,我們將通過並行調用五個請求來測試 bulkhead 行為:
@Test
void testBulkhead() throws Exception {
EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
.willReturn(ok()));
Map<Integer, Integer> responseStatusCount = new ConcurrentHashMap<>();
ExecutorService executorService = Executors.newFixedThreadPool(5);
CountDownLatch latch = new CountDownLatch(5);
IntStream.rangeClosed(1, 5)
.forEach(i -> executorService.execute(() -> {
ResponseEntity response = restTemplate.getForEntity("/api/bulkhead", String.class);
int statusCode = response.getStatusCodeValue();
responseStatusCount.merge(statusCode, 1, Integer::sum);
latch.countDown();
}));
latch.await();
executorService.shutdown();
assertEquals(2, responseStatusCount.keySet().size());
LOGGER.info("Response statuses: " + responseStatusCount.keySet());
assertTrue(responseStatusCount.containsKey(BANDWIDTH_LIMIT_EXCEEDED.value()));
assertTrue(responseStatusCount.containsKey(OK.value()));
EXTERNAL_SERVICE.verify(3, getRequestedFor(urlEqualTo("/api/external")));
}我們可以看到,只有三個請求成功,其餘請求則被拒絕,並返回 BANDWIDTH_LIMIT_EXCEEDED HTTP 狀態碼。
7. 速率限制器
速率限制器模式限制對資源的請求速率。
我們首先添加帶有 /api/rate-limiter API 端點和 @RateLimiter 註解:
@GetMapping("/rate-limiter")
@RateLimiter(name = "rateLimiterApi")
public String rateLimitApi() {
return externalAPICaller.callApi();
}接下來,我們將定義限速器在 application.properties文件中配置:
resilience4j.ratelimiter.metrics.enabled=true
resilience4j.ratelimiter.instances.rateLimiterApi.register-health-indicator=true
resilience4j.ratelimiter.instances.rateLimiterApi.limit-for-period=5
resilience4j.ratelimiter.instances.rateLimiterApi.limit-refresh-period=60s
resilience4j.ratelimiter.instances.rateLimiterApi.timeout-duration=0s
resilience4j.ratelimiter.instances.rateLimiterApi.allow-health-indicator-to-fail=true
resilience4j.ratelimiter.instances.rateLimiterApi.subscribe-for-events=true
resilience4j.ratelimiter.instances.rateLimiterApi.event-consumer-buffer-size=50通過這種配置,我們希望限制API調用速率為每分鐘5個請求,且不進行等待。當達到允許的速率閾值後,請求將被拒絕,並拋出RequestNotPermitted異常。因此,我們將定義一個處理程序在ApiExceptionHandler類中,將其轉換為有意義的HTTP狀態響應代碼:
@ExceptionHandler({ RequestNotPermitted.class })
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public void handleRequestNotPermitted() {
}最後,我們將使用 50 個請求測試我們的限速 API 端點:
@Test
public void testRatelimiter() {
EXTERNAL_SERVICE.stubFor(WireMock.get("/api/external")
.willReturn(ok()));
Map<Integer, Integer> responseStatusCount = new ConcurrentHashMap<>();
IntStream.rangeClosed(1, 50)
.parallel()
.forEach(i -> {
ResponseEntity<String> response = restTemplate.getForEntity("/api/rate-limiter", String.class);
int statusCode = response.getStatusCodeValue();
responseStatusCount.put(statusCode, responseStatusCount.getOrDefault(statusCode, 0) + 1);
});
assertEquals(2, responseStatusCount.keySet().size());
assertTrue(responseStatusCount.containsKey(TOO_MANY_REQUESTS.value()));
assertTrue(responseStatusCount.containsKey(OK.value()));
EXTERNAL_SERVICE.verify(5, getRequestedFor(urlEqualTo("/api/external")));
}正如預期的那樣,僅有五個請求成功,而其餘所有請求均以 TOO_MANY_REQUESTS HTTP 狀態碼失敗。
8. Actuator 端點
我們配置了應用程序支持 Actuator 端點,用於監控目的。通過這些端點,我們可以利用一種或多種配置的容錯模式來確定應用程序隨時間的變化情況。
首先,我們可以通過向 /actuator 端點發送 GET 請求來查找所有暴露的端點:
http://localhost:8080/actuator/
{
"_links" : {
"self" : {...},
"bulkheads" : {...},
"circuitbreakers" : {...},
"ratelimiters" : {...},
...
}
}我們能看到一個包含諸如 隔板、斷路器、請求限制器 等字段的 JSON 響應。每個字段都提供特定信息,具體取決於其與容錯模式的關聯。
接下來,我們將查看與重試模式相關的字段:
"retries": {
"href": "http://localhost:8080/actuator/retries",
"templated": false
},
"retryevents": {
"href": "http://localhost:8080/actuator/retryevents",
"templated": false
},
"retryevents-name": {
"href": "http://localhost:8080/actuator/retryevents/{name}",
"templated": true
},
"retryevents-name-eventType": {
"href": "http://localhost:8080/actuator/retryevents/{name}/{eventType}",
"templated": true
}接下來,我們可以檢查應用程序以查看重試實例的列表:
http://localhost:8080/actuator/retries
{
"retries" : [ "retryApi" ]
}正如預期的那樣,我們可以看到 retryApi 實例在配置的重試實例列表中。
最後,我們將通過瀏覽器向 /api/retry API 端點發出 GET 請求,並通過 /actuator/retryevents 端點觀察重試事件:
{
"retryEvents": [
{
"retryName": "retryApi",
"type": "RETRY",
"creationTime": "2022-10-16T10:46:31.950822+05:30[Asia/Kolkata]",
"errorMessage": "...",
"numberOfAttempts": 1
},
{
"retryName": "retryApi",
"type": "RETRY",
"creationTime": "2022-10-16T10:46:32.965661+05:30[Asia/Kolkata]",
"errorMessage": "...",
"numberOfAttempts": 2
},
{
"retryName": "retryApi",
"type": "ERROR",
"creationTime": "2022-10-16T10:46:33.978801+05:30[Asia/Kolkata]",
"errorMessage": "...",
"numberOfAttempts": 3
}
]
}由於下游服務不可用,我們可以看到三次重試嘗試,每次嘗試之間間隔時間為 1s。 就像我們配置的那樣。
9. 結論
在本文中,我們學習瞭如何在 Sprint Boot 應用程序中使用 Resilience4j 庫。此外,我們重點關注了多種容錯模式,例如斷路器、速率限制器、時間限制器、隔離艙和重試。