1. 概要

このチュートリアルでは、 SpringRESTAPIのリクエストタイムアウトを実装するためのいくつかの可能な方法を探ります。

それぞれの長所と短所について説明します。 リクエストのタイムアウトは、ユーザーエクスペリエンスの低下を防ぐのに役立ちます。特に、リソースに時間がかかりすぎる場合にデフォルトで使用できる代替手段がある場合に役立ちます。 このデザインパターンはサーキットブレーカーパターンと呼ばれますが、ここでは詳しく説明しません。

2. @Transactionalタイムアウト

データベース呼び出しにリクエストタイムアウトを実装する1つの方法は、Springの@Transactionalアノテーションを利用することです。 設定できるtimeoutプロパティがあります。 このプロパティのデフォルト値は-1です。これは、タイムアウトがまったくないことと同じです。 タイムアウト値の外部構成では、代わりに別のプロパティ timeoutStringを使用する必要があります。

たとえば、このタイムアウトを30に設定するとします。 注釈付きメソッドの実行時間がこの秒数を超えると、例外がスローされます。 これは、実行時間の長いデータベースクエリをロールバックする場合に役立つことがあります。

これが実際に動作することを確認するために、完了するのに時間がかかりすぎてタイムアウトが発生する外部サービスを表す非常に単純なJPAリポジトリーレイヤーを作成してみましょう。 このJpaRepository拡張機能には、時間のかかるメソッドが含まれています。

public interface BookRepository extends JpaRepository<Book, String> {

    default int wasteTime() {
        Stopwatch watch = Stopwatch.createStarted();

        // delay for 2 seconds
        while (watch.elapsed(SECONDS) < 2) {
          int i = Integer.MIN_VALUE;
          while (i < Integer.MAX_VALUE) {
              i++;
          }
        }
    }
}

タイムアウトが1秒のトランザクション内でwasteTime()メソッドを呼び出すと、メソッドの実行が完了する前にタイムアウトが経過します。

@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が管理するトランザクションを備えたデータベースがあることに依存しています。 また、アノテーションはそれを必要とする各メソッドまたはクラスに存在する必要があるため、プロジェクトにグローバルに適用することはできません。 また、1秒未満の精度も許可されません。 最後に、タイムアウトに達してもリクエストが短くなることはないため、リクエスト元のエンティティは引き続き全時間待機する必要があります。

他のいくつかのオプションを考えてみましょう。

3. Resilience4j TimeLimiter

Resilience4jは、主にリモート通信のフォールトトレランスの管理専用のライブラリです。そのTimeLimiterモジュールがここで関心を持っています。

まず、プロジェクトにresilience4j-timelimiter依存関係を含める必要があります。

<dependency>
    <groupId>io.github.resilience4j</groupId>
    <artifactId>resilience4j-timelimiter</artifactId>
    <version>1.6.1</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ソリューションに比べていくつかの利点があります。 つまり、1秒未満の精度と、タイムアウト応答の即時通知をサポートします。 ただし、タイムアウトが必要なすべてのエンドポイントに手動で含める必要があり、長いラッピングコードが必要であり、生成されるエラーは依然として一般的な500HTTPエラーです。 また、それは返す必要があります呼び出し可能生の代わりに弦。

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によって構成が自動的に実装されることがわかります。 タイムアウトに達するとすぐに応答が返され、一般的な500ではなくより説明的な503 HTTPエラーが返されます。また、プロジェクト内のすべてのエンドポイントは、このタイムアウト構成を自動的に継承します。

もう少し細かくタイムアウトを定義できるようにする別のオプションを考えてみましょう。

5. WebClientタイムアウト

エンドポイント全体のタイムアウトを設定するのではなく、おそらく単一の外部呼び出しのタイムアウトを設定したいだけです。 WebClientはSpringのリアクティブWebクライアントであり、次のことができます。応答タイムアウトを構成します。

Springの古いRestTemplateオブジェクトでタイムアウトを構成することもできます。 ただし、ほとんどの開発者は現在、RestTemplateよりもWebClientを好みます。

WebClientを使用するには、最初にSpringのWebFlux依存関係をプロジェクトに追加する必要があります。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
    <version>2.4.2</version>
</dependency>

250ミリ秒の応答タイムアウトを持つWebClientを定義しましょう。これを使用して、ベースURLでローカルホストを介して自分自身を呼び出すことができます。

@Bean
public WebClient webClient() {
    return WebClient.builder()
      .baseUrl("http://localhost:8080")
      .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create().responseTimeout(Duration.ofMillis(250))
      ))
      .build();
}

明らかに、このタイムアウト値を外部で簡単に構成できます。 また、ベースURLを外部で構成したり、他のいくつかのオプションのプロパティを構成したりすることもできます。

これで、 WebClient をコントローラーに挿入し、それを使用して、1秒のタイムアウトがある独自の /transactionalエンドポイントを呼び出すことができます。 WebClient を250ミリ秒でタイムアウトするように構成したため、1秒よりもはるかに速く失敗するはずです。

新しいエンドポイントは次のとおりです。

@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();
}

このエンドポイントを呼び出した後、500HTTPエラー応答の形式でWebClientのタイムアウトを受信していることがわかります。 ログをチェックして、ダウンストリームの@Transactionalタイムアウトを確認することもできます。 ただし、もちろん、ローカルホストではなく外部サービスを呼び出した場合、そのタイムアウトはリモートで出力されます。

異なるバックエンドサービスに対して異なる要求タイムアウトを構成することが必要な場合があり、このソリューションで可能です。 また、WebClientによって返されるMonoまたはFlux応答パブリッシャーには、一般的なタイムアウトエラー応答を処理するためのエラー処理メソッドが多数含まれています。

6. 結論

この記事では、リクエストのタイムアウトを実装するためのいくつかの異なるソリューションについて説明しました。 どれを使用するかを決定する際に考慮すべきいくつかの要因があります。

データベースリクエストにタイムアウトを設定する場合は、Springの@Transactionalメソッドとそのtimeoutプロパティを使用することをお勧めします。 より広いサーキットブレーカーパターンと統合しようとしている場合は、Resilience4jのTimeLimiterを使用するのが理にかなっています。 Spring MVC request-timeout プロパティを使用すると、すべてのリクエストのグローバルタイムアウトを設定できますが、 WebClient を使用すると、リソースごとにより詳細なタイムアウトを簡単に定義できます。

これらすべてのソリューションの実用的な例として、コードは準備ができており、GitHubで箱から出してすぐに実行できます