Java 專案中使用 Resilience4j 框架實現非同步超時處理

信碼由韁發表於2021-12-01

到目前為止,在本系列中,我們已經瞭解了 Resilience4j 及其 [Retry](
https://icodewalker.com/blog/...) 和 [RateLimiter](
https://icodewalker.com/blog/...) 模組。在本文中,我們將通過 TimeLimiter 繼續探索 Resilience4j。我們將瞭解它解決了什麼問題,何時以及如何使用它,並檢視一些示例。

程式碼示例

本文附有 [GitHub 上](
https://github.com/thombergs/...)的工作程式碼示例。

什麼是 Resilience4j?

請參閱上一篇文章中的描述,快速瞭解 [Resilience4j 的一般工作原理](
https://icodewalker.com/blog/...)。

什麼是限時?

對我們願意等待操作完成的時間設定限制稱為時間限制。如果操作沒有在我們指定的時間內完成,我們希望通過超時錯誤收到通知。

有時,這也稱為“設定最後期限”。

我們這樣做的一個主要原因是確保我們不會讓使用者或客戶無限期地等待。不提供任何反饋的緩慢服務可能會讓使用者感到沮喪。

我們對操作設定時間限制的另一個原因是確保我們不會無限期地佔用伺服器資源。我們在使用 Spring 的 @Transactional 註解時指定的 timeout 值就是一個例子——在這種情況下,我們不想長時間佔用資料庫資源。

什麼時候使用 Resilience4j TimeLimiter?

Resilience4j 的 TimeLimiter 可用於設定使用 CompleteableFutures 實現的非同步操作的時間限制(超時)。

Java 8 中引入的 CompletableFuture 類使非同步、非阻塞程式設計變得更容易。可以在不同的執行緒上執行慢速方法,釋放當前執行緒來處理其他任務。 我們可以提供一個當 slowMethod() 返回時執行的回撥:

int slowMethod() {
  // time-consuming computation or remote operation
return 42;
}

CompletableFuture.supplyAsync(this::slowMethod)
.thenAccept(System.out::println);

這裡的 slowMethod() 可以是一些計算或遠端操作。通常,我們希望在進行這樣的非同步呼叫時設定時間限制。我們不想無限期地等待 slowMethod() 返回。例如,如果 slowMethod() 花費的時間超過一秒,我們可能想要返回先前計算的、快取的值,甚至可能會出錯。

在 Java 8 的 CompletableFuture 中,沒有簡單的方法來設定非同步操作的時間限制。CompletableFuture 實現了 Future 介面,Future 有一個過載的 get() 方法來指定我們可以等待多長時間:

CompletableFuture<Integer> completableFuture = CompletableFuture
  .supplyAsync(this::slowMethod);
Integer result = completableFuture.get(3000, TimeUnit.MILLISECONDS);
System.out.println(result);

但是這裡有一個問題—— get() 方法是一個阻塞呼叫。所以它首先違背了使用 CompletableFuture 的目的,即釋放當前執行緒。

這是 Resilience4j 的 TimeLimiter 解決的問題——它讓我們在非同步操作上設定時間限制,同時保留在 Java 8 中使用 CompletableFuture 時非阻塞的好處。

CompletableFuture 的這種限制已在 Java 9 中得到解決。我們可以在 Java 9 及更高版本中使用 CompletableFuture 上的 orTimeout()completeOnTimeout() 等方法直接設定時間限制。然而,憑藉 Resilience4J指標事件,與普通的 Java 9 解決方案相比,它仍然提供了附加值。

Resilience4j TimeLimiter 概念

TimeLimiter支援 FutureCompletableFuture。但是將它與 Future 一起使用相當於 Future.get(long timeout, TimeUnit unit)。因此,我們將在本文的其餘部分關注 CompletableFuture

與其他 Resilience4j 模組一樣,TimeLimiter 的工作方式是使用所需的功能裝飾我們的程式碼 - 如果在這種情況下操作未在指定的 timeoutDuration 內完成,則返回 TimeoutException

我們為 TimeLimiter 提供 timeoutDurationScheduledExecutorService 和非同步操作本身,表示為 CompletionStageSupplier。它返回一個 CompletionStage 的裝飾 Supplier

在內部,它使用排程器來排程一個超時任務——通過丟擲一個 TimeoutException 來完成 CompletableFuture 的任務。如果操作先完成,TimeLimiter 取消內部超時任務。

除了 timeoutDuration 之外,還有另一個與 TimeLimiter 關聯的配置 cancelRunningFuture。此配置僅適用於 Future 而不適用於 CompletableFuture。當超時發生時,它會在丟擲 TimeoutException 之前取消正在執行的 Future

使用 Resilience4j TimeLimiter 模組

TimeLimiterRegistryTimeLimiterConfigTimeLimiterresilience4j-timelimiter 的主要抽象。

TimeLimiterRegistry 是用於建立和管理 TimeLimiter 物件的工廠。

TimeLimiterConfig 封裝了 timeoutDurationcancelRunningFuture 配置。每個 TimeLimiter 物件都與一個 TimeLimiterConfig 相關聯。

TimeLimiter 提供輔助方法來為 FutureCompletableFuture Suppliers 建立或執行裝飾器。

讓我們看看如何使用 TimeLimiter 模組中可用的各種功能。我們將使用與本系列前幾篇文章相同的示例。假設我們正在為一家航空公司建立一個網站,以允許其客戶搜尋和預訂航班。我們的服務與 FlightSearchService 類封裝的遠端服務對話。

第一步是建立一個 TimeLimiterConfig

TimeLimiterConfig config = TimeLimiterConfig.ofDefaults();

這將建立一個 TimeLimiterConfig,其預設值為 timeoutDuration (1000ms) 和 cancelRunningFuture (true)。

假設我們想將超時值設定為 2s 而不是預設值:

TimeLimiterConfig config = TimeLimiterConfig.custom()
  .timeoutDuration(Duration.ofSeconds(2))
  .build();

然後我們建立一個 TimeLimiter

TimeLimiterRegistry registry = TimeLimiterRegistry.of(config);

TimeLimiter limiter = registry.timeLimiter("flightSearch");

我們想要非同步呼叫
FlightSearchService.searchFlights(),它返回一個 List<Flight>。讓我們將其表示為 Supplier<CompletionStage<List<Flight>>>

Supplier<List<Flight>> flightSupplier = () -> service.searchFlights(request);
Supplier<CompletionStage<List<Flight>>> origCompletionStageSupplier =
() -> CompletableFuture.supplyAsync(flightSupplier);

然後我們可以使用 TimeLimiter 裝飾 Supplier

ScheduledExecutorService scheduler =
  Executors.newSingleThreadScheduledExecutor();
Supplier<CompletionStage<List<Flight>>> decoratedCompletionStageSupplier =  
  limiter.decorateCompletionStage(scheduler, origCompletionStageSupplier);

最後,讓我們呼叫裝飾的非同步操作:

decoratedCompletionStageSupplier.get().whenComplete((result, ex) -> {
  if (ex != null) {
    System.out.println(ex.getMessage());
  }
  if (result != null) {
    System.out.println(result);
  }
});

以下是成功飛行搜尋的示例輸出,其耗時少於我們指定的 2 秒 timeoutDuration

Searching for flights; current time = 19:25:09 783; current thread = ForkJoinPool.commonPool-worker-3

Flight search successful

[Flight{flightNumber='XY 765', flightDate='08/30/2020', from='NYC', to='LAX'}, Flight{flightNumber='XY 746', flightDate='08/30/2020', from='NYC', to='LAX'}] on thread ForkJoinPool.commonPool-worker-3

這是超時的航班搜尋的示例輸出:

Exception java.util.concurrent.TimeoutException: TimeLimiter 'flightSearch' recorded a timeout exception on thread pool-1-thread-1 at 19:38:16 963

Searching for flights; current time = 19:38:18 448; current thread = ForkJoinPool.commonPool-worker-3

Flight search successful at 19:38:18 461

上面的時間戳和執行緒名稱表明,即使非同步操作稍後在另一個執行緒上完成,呼叫執行緒也會收到 TimeoutException。

如果我們想建立一個裝飾器並在程式碼庫的不同位置重用它,我們將使用decorateCompletionStage()。如果我們想建立它並立即執行 Supplier<CompletionStage>,我們可以使用 executeCompletionStage() 例項方法代替:

CompletionStage<List<Flight>> decoratedCompletionStage =  
  limiter.executeCompletionStage(scheduler, origCompletionStageSupplier);

TimeLimiter 事件

TimeLimiter 有一個 EventPublisher,它生成 TimeLimiterOnSuccessEventTimeLimiterOnErrorEventTimeLimiterOnTimeoutEvent 型別的事件。我們可以監聽這些事件並記錄它們,例如:

TimeLimiter limiter = registry.timeLimiter("flightSearch");

limiter.getEventPublisher().onSuccess(e -> System.out.println(e.toString()));

limiter.getEventPublisher().onError(e -> System.out.println(e.toString()));

limiter.getEventPublisher().onTimeout(e -> System.out.println(e.toString()));

示例輸出顯示了記錄的內容:

2020-08-07T11:31:48.181944: TimeLimiter 'flightSearch' recorded a successful call.

... other lines omitted ...

2020-08-07T11:31:48.582263: TimeLimiter 'flightSearch' recorded a timeout exception.

TimeLimiter 指標

TimeLimiter 跟蹤成功、失敗和超時的呼叫次數。

首先,我們像往常一樣建立 TimeLimiterConfigTimeLimiterRegistryTimeLimiter。然後,我們建立一個 MeterRegistry 並將 TimeLimiterRegistry 繫結到它:

MeterRegistry meterRegistry = new SimpleMeterRegistry();
TaggedTimeLimiterMetrics.ofTimeLimiterRegistry(registry)
  .bindTo(meterRegistry);

執行幾次限時操作後,我們顯示捕獲的指標:

Consumer<Meter> meterConsumer = meter -> {
  String desc = meter.getId().getDescription();
  String metricName = meter.getId().getName();
  String metricKind = meter.getId().getTag("kind");
  Double metricValue =
    StreamSupport.stream(meter.measure().spliterator(), false)
    .filter(m -> m.getStatistic().name().equals("COUNT"))
    .findFirst()
    .map(Measurement::getValue)
    .orElse(0.0);
  System.out.println(desc + " - " +
                     metricName +
                     "(" + metricKind + ")" +
                     ": " + metricValue);
};
meterRegistry.forEachMeter(meterConsumer);

這是一些示例輸出:

The number of timed out calls - resilience4j.timelimiter.calls(timeout): 6.0

The number of successful calls - resilience4j.timelimiter.calls(successful): 4.0

The number of failed calls - resilience4j.timelimiter.calls(failed): 0.0

在實際應用中,我們會定期將資料匯出到監控系統並在儀表板上進行分析。

實施時間限制時的陷阱和良好實踐

通常,我們處理兩種操作 - 查詢(或讀取)和命令(或寫入)。對查詢進行時間限制是安全的,因為我們知道它們不會改變系統的狀態。我們看到的 searchFlights() 操作是查詢操作的一個例子。

命令通常會改變系統的狀態。bookFlights() 操作將是命令的一個示例。在對命令進行時間限制時,我們必須記住,當我們超時時,該命令很可能仍在執行。例如,bookFlights() 呼叫上的 TimeoutException 並不一定意味著命令失敗。

在這種情況下,我們需要管理使用者體驗——也許在超時時,我們可以通知使用者操作花費的時間比我們預期的要長。然後我們可以查詢上游以檢查操作的狀態並稍後通知使用者。

結論

在本文中,我們學習瞭如何使用 Resilience4j 的 TimeLimiter 模組為非同步、非阻塞操作設定時間限制。我們通過一些實際示例瞭解了何時使用它以及如何配置它。

您可以使用 [GitHub 上](
https://github.com/thombergs/...)的程式碼演示一個完整的應用程式來說明這些想法。


本文譯自:
https://reflectoring.io/time-...

相關文章