1. 概述
在本教程中,我們將探討幾種實現 Spring REST API 請求超時的方法。
然後,我們將討論每種方法的優缺點。請求超時對於防止糟糕的用户體驗非常有用,尤其是在我們能夠提供默認選項以應對資源耗時過長的情況下。這種設計模式稱為斷路器模式,但我們在此處將不進行詳細説明。
2. > 時間限制
通過利用 Spring 的 > 註解,我們可以實現數據庫調用上的請求時間限制。它具有 > 屬性,我們可以設置其值。該屬性的默認值為 -1,等效於沒有設置任何時間限制。要配置時間限制值,必須使用不同的屬性,>,而不是它。
例如,假設我們將時間限制設置為 30。如果標註方法的執行時間超過該數字秒數,將會拋出異常。這對於回滾長時間運行的數據庫查詢可能很有用。
要查看此操作的效果,我們將編寫一個非常簡單的 JPA 存儲庫層,它將代表一個花費過多的外部服務,並導致時間限制發生。此 > 擴展包含一個耗時的方法:
public interface BookRepository extends JpaRepository<Book, String> {
default int wasteTime() {
Stopwatch watch = Stopwatch.createStarted();
// 延遲 2 秒
while (watch.elapsed(SECONDS) < 2) {
int i = Integer.MIN_VALUE;
while (i < Integer.MAX_VALUE) {
i++;
}
}
}
}
如果我們在帶有 1 秒時間限制的事務中調用我們的 > 方法,則在方法執行完畢之前,時間限制將到期:
@GetMapping("/author/transactional")
@Transactional(timeout = 1)
public String getWithTransactionTimeout(@RequestParam String title) {
bookRepository.wasteTime();
return bookRepository.findById(title)
.map(Book::getAuthor)
.orElse("No book found for this title.");
}
調用此端點會導致 500 HTTP 錯誤,我們可以將其轉換為更有意義的響應。它也需要很少設置來實施。
但是,這種時間限制解決方案存在一些缺點:
- 依賴於具有 Spring 事務管理的數據庫
- 不可在全球範圍內應用於項目,因為該註解必須存在於每個方法或類中
- 在達到時間限制時不會中斷請求,因此實體仍必須等待整個時間進行處理
讓我們考慮一些替代方案。
3. Resilience4j TimeLimiter
Resilience4j 是一個主要用於管理遠程通信故障容錯的庫。它的 TimeLimiter 模塊是我們感興趣的部分。
首先,我們需要在項目中添加 resilience4j-timelimiter 依賴項:
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-timelimiter</artifactId>
<version>2.1.0</version>
</dependency>
接下來,我們將定義一個具有 500 毫秒超時時間的簡單 TimeLimiter:
private TimeLimiter ourTimeLimiter = TimeLimiter.of(TimeLimiterConfig.custom()
.timeoutDuration(Duration.ofMillis(500)).build());
我們可以輕鬆地外部配置它。
我們可以使用我們的 TimeLimiter 來包裝與我們的 @Transactional 示例中使用的相同邏輯:
@GetMapping("/author/resilience4j")
public Callable<String> getWithResilience4jTimeLimiter(@RequestParam String title) {
return TimeLimiter.decorateFutureSupplier(ourTimeLimiter, () ->
CompletableFuture.supplyAsync(() -> {
bookRepository.wasteTime();
return bookRepository.findById(title)
.map(Book::getAuthor)
.orElse("No book found for this title.");
}));
}
TimeLimiter 相比 @Transactional 解決方案,提供了多個優勢。 尤其是,它支持亞秒級精度和立即通知超時響應。 但是,我們仍然需要在所有需要超時的端點中手動包含它。 它還要求進行一些冗長的包裝代碼,並且產生的錯誤仍然是一個通用的 500 HTTP 錯誤。 此外,它要求返回一個 Callable<String> 而不是一個原始 String.
TimeLimiter 只是 Resilience4j 功能的一個子集,並且與斷路器模式很好地配合使用。
4. Spring MVC request-timeout
Spring 提供了名為 spring.mvc.async.request-timeout 的屬性。此屬性允許我們以毫秒精度定義請求超時時間。
讓我們將屬性設置為 750 毫秒超時時間:
spring.mvc.async.request-timeout=750
此屬性是全局的,並且可以外部配置,但與 TimeLimiter 解決方案一樣,它僅適用於返回 Callable 類型的端點。 讓我們定義一個類似於 TimeLimiter 示例,但無需將邏輯包裝在 Futures 中,也不需要提供 TimeLimiter:
@GetMapping("/author/mvc-request-timeout")
public Callable<String> getWithMvcRequestTimeout(@RequestParam String title) {
return () -> {
bookRepository.wasteTime();
return bookRepository.findById(title)
.map(Book::getAuthor)
.orElse("No book found for this title.");
};
}
我們可以看到代碼更簡潔,並且 Spring 在我們定義應用程序屬性時會自動實現配置。 一旦超時時間到達,響應將立即返回,並且甚至會返回更描述性的 503 HTTP 錯誤,而不是泛型 500 錯誤。 我們的項目中的所有端點都會自動繼承此超時配置。
現在,讓我們考慮另一種允許我們使用更精細粒度定義超時時間的選項。
5. Configuring the Timeout in the HTTP Client
Rather than setting a timeout for an entire endpoint, we may want to simply have a timeout for a single external call. WebClient is Spring’s reactive web client that allows us to configure a response timeout.
Needless to say, all popular HTTP client libraries allow configuring custom timeouts for outgoing requests. For example, Spring’s older RestTemplate and WebClient’s non-reactive equivalent – the RestClient – both support this feature.
5.1. WebClient Timeout
To use WebClient, we must first add Spring’s WebFlux dependency to our project:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
<version>2.4.2</version>
</dependency>
Let’s define a WebClient with a response timeout of 250 milliseconds that we can use to call ourselves via localhost in its base URL:
@Bean
public WebClient webClient() {
return WebClient.builder()
.baseUrl("http://localhost:8080")
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create().responseTimeout(Duration.ofMillis(250))
))
.build();
}
Clearly, we can easily configure this timeout value externally. We can also configure the base URL externally, as well as several other optional properties.
Now, we can inject our WebClient into our controller, and use it to call our own /transactional endpoint, which still has a timeout of 1 second. Since we configured our WebClient to timeout in 250 milliseconds, we should see it fail much faster than 1 second.
Here is our new endpoint:
@GetMapping("/author/webclient")
public String getWithWebClient(@RequestParam String title) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/author/transactional")
.queryParam("title", title)
.build())
.retrieve()
.bodyToMono(String.class)
.block();
}
After calling this endpoint, we can see that we do receive the WebClient’s timeout in the form of a 500 HTTP error response. We can also check the logs to see the downstream @Transactional timeout, but its timeout will be printed remotely if we call an external service instead of localhost.
Configuring different request timeouts for different backend services may be necessary, and is possible with this solution. Also, the Mono or Flux response that publishers returned by WebClient contains plenty of error handling methods for handling the generic timeout error response.
5.2. RestClient Timeout
Spring’s RestClient was introduced in Spring Framework 6 and Spring Boot 3 as a simpler, non-reactive alternative to WebClient. It offers a straightforward, synchronous approach while still providing a modern and fluent API design.
Firstly, let’s add the spring-boot-starter-web dependency if we don’t have it already:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
Similarly to the previous example, we’ll start by creating a RestClient bean using the builder pattern:
@Bean
public RestClient restClient() {
return RestClient.builder()
.baseUrl("http://localhost:" + serverPort)
.requestFactory(customRequestFactory())
.build();
}
ClientHttpRequestFactory customRequestFactory() {
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofMillis(200))
.withReadTimeout(Duration.ofMillis(200));
return ClientHttpRequestFactories.get(settings);
}
As we can see, we can configure a default timeout value by defining a ClientHtttpRequestFactory inside the RestClient builder.
We can now use the HTTP client to make a request, and it will throw an exception if no response is received within the set threshold of 200 milliseconds:
@GetMapping("/author/restclient")
public String getWithRestClient(@RequestParam String title) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/author/transactional")
.queryParam("title", title)
.build())
.retrieve()
.body(String.class);
}
As we can see, the syntax is very similar to the reactive approach, allowing us to easily switch between the two without many code changes.
6. 結論
在本文中,我們探討了多種實現請求超時解決方案。 在選擇哪一種方案時,需要考慮多個因素。
如果我們想為數據庫請求設置超時,我們可能希望使用 Spring 的 @Transactional 方法及其 timeout 屬性。 如果我們嘗試與更廣泛的熔斷器模式集成,使用 Resilience4j 的 TimeLimiter 會很有意義。 使用 Spring MVC 的 request-timeout 屬性是為所有請求設置全局超時,但我們也可以輕鬆地在諸如 WebClient 和 RestClient 這樣的 HTTP 客户端中定義更精細的資源級別的超時。