1. 概要

ロングポーリングは、サーバーアプリケーションが情報が利用可能になるまでクライアント接続を保持するために使用する方法です。これは、サーバーが情報を取得して結果を待つためにダウンストリームサービスを呼び出す必要がある場合によく使用されます。

このチュートリアルでは、SpringMVCでのロングポーリングの概念を次のように使用して説明します。 DeferredResult。 lookinから始めましょう g DeferredResultを使用した基本的な実装で、エラーとタイムアウトを処理する方法について説明します。 最後に、これらすべてをテストする方法を見ていきます。

2. DeferredResultを使用したロングポーリング

インバウンドHTTPリクエストを非同期で処理する方法としてSpringMVCでDeferredResultを使用できます。HTTPワーカースレッドを解放して他の着信リクエストを処理し、作業を別のワーカースレッドにオフロードできます。 そのため、長い計算や任意の待機時間を必要とするリクエストのサービスの可用性に役立ちます。

SpringのDeferredResultクラスに関する前回の記事では、その機能とユースケースについて詳しく説明しています。

2.1. 出版社

を使用するパブリッシングアプリケーションを作成することから、長いポーリングの例を始めましょう DeferredResult。 

最初に、 DeferredResult を利用するが、その作業を別のワーカースレッドにオフロードしないSpring @RestControllerを定義しましょう。

@RestController
@RequestMapping("/api")
public class BakeryController { 
    @GetMapping("/bake/{bakedGood}")
    public DeferredResult<String> publisher(@PathVariable String bakedGood, @RequestParam Integer bakeTime) {
        DeferredResult<String> output = new DeferredResult<>();
        try {
            Thread.sleep(bakeTime);
            output.setResult(format("Bake for %s complete and order dispatched. Enjoy!", bakedGood));
        } catch (Exception e) {
            // ...
        }
        return output;
    }
}

このコントローラーは、通常のブロッキングコントローラーが機能するのと同じように同期的に機能します。 そのため、 bakeTime が経過するまで、HTTPスレッドは完全にブロックされます。 私たちのサービスに多くのインバウンドトラフィックがある場合、これは理想的ではありません。

次に、作業をワーカースレッドにオフロードして、出力を非同期に設定しましょう。

private ExecutorService bakers = Executors.newFixedThreadPool(5);

@GetMapping("/bake/{bakedGood}")
public DeferredResult<String> publisher(@PathVariable String bakedGood, @RequestParam Integer bakeTime) {
    DeferredResult<String> output = new DeferredResult<>();
    bakers.execute(() -> {
        try {
            Thread.sleep(bakeTime);
            output.setResult(format("Bake for %s complete and order dispatched. Enjoy!", bakedGood));
        } catch (Exception e) {
            // ...
        }
    });
    return output;
}

この例では、HTTPワーカースレッドを解放して他のリクエストを処理できるようになりました。 ベイカープールのワーカースレッドが作業を行っており、完了時に結果を設定します。 ワーカーがsetResultを呼び出すと、コンテナースレッドが呼び出し元のクライアントに応答できるようになります。

私たちのコードは現在、長いポーリングに適した候補であり、従来のブロッキングコントローラーよりもインバウンドHTTPリクエストでサービスを利用できるようになります。 ただし、エラー処理やタイムアウト処理などのエッジケースにも注意する必要があります。

ワーカーによってスローされたチェック済みエラーを処理するには、DeferredResultによって提供されるsetErrorResultメソッドを使用します。

bakers.execute(() -> {
    try {
        Thread.sleep(bakeTime);
        output.setResult(format("Bake for %s complete and order dispatched. Enjoy!", bakedGood));
     } catch (Exception e) {
        output.setErrorResult("Something went wrong with your order!");
     }
});

ワーカースレッドは、スローされた例外を適切に処理できるようになりました。

長いポーリングは、非同期と同期の両方でダウンストリームシステムからの応答を処理するために実装されることが多いため、ダウンストリームシステムから応答を受信しない場合にタイムアウトを強制するメカニズムを追加する必要があります。 DeferredResult APIは、これを行うためのメカニズムを提供します。 まず、DeferredResultオブジェクトのコンストラクターにタイムアウトパラメーターを渡します。

DeferredResult<String> output = new DeferredResult<>(5000L);

次に、タイムアウトシナリオを実装しましょう。 このために、 onTimeout:を使用します

output.onTimeout(() -> output.setErrorResult("the bakery is not responding in allowed time"));

これはRunnableを入力として受け取ります—タイムアウトしきい値に達するとコンテナスレッドによって呼び出されます。タイムアウトに達すると、これをエラーとして処理し、それに応じてsetErrorResultを使用します。

2.2. サブスクライバー

パブリッシングアプリケーションをセットアップしたので、サブスクライブするクライアントアプリケーションを作成しましょう。

この長いポーリングAPIを呼び出すサービスを作成することは、標準のブロッキングREST呼び出し用のクライアントを作成することと本質的に同じであるため、かなり簡単です。 唯一の本当の違いは、長いポーリングの待機時間のためにタイムアウトメカニズムを確実に設定したいということです。 Spring MVCでは、RestTemplateまたはWebClientを使用してこれを実現できます。どちらにも、タイムアウト処理が組み込まれているためです。

まず、使用例から始めましょう RestTemplate。 のインスタンスを作成しましょう RestTemplate を使用して RestTemplateBuilder タイムアウト期間を設定できるように:

public String callBakeWithRestTemplate(RestTemplateBuilder restTemplateBuilder) {
    RestTemplate restTemplate = restTemplateBuilder
      .setConnectTimeout(Duration.ofSeconds(10))
      .setReadTimeout(Duration.ofSeconds(10))
      .build();

    try {
        return restTemplate.getForObject("/api/bake/cookie?bakeTime=1000", String.class);
    } catch (ResourceAccessException e) {
        // handle timeout
    }
}

このコードでは、長いポーリング呼び出しから ResourceAccessException をキャッチすることで、タイムアウト時にエラーを処理できます。

次に、 WebClient を使用して例を作成し、同じ結果を達成しましょう。

public String callBakeWithWebClient() {
    WebClient webClient = WebClient.create();
    try {
        return webClient.get()
          .uri("/api/bake/cookie?bakeTime=1000")
          .retrieve()
          .bodyToFlux(String.class)
          .timeout(Duration.ofSeconds(10))
          .blockFirst();
    } catch (ReadTimeoutException e) {
        // handle timeout
    }
}

Spring RESTタイムアウトの設定に関する前回の記事では、このトピックについて詳しく説明しています。

3. ロングポーリングのテスト

アプリケーションが稼働しているので、それをテストする方法について説明しましょう。 MockMvcを使用して、コントローラークラスへの呼び出しをテストすることから始めることができます:

MvcResult asyncListener = mockMvc
  .perform(MockMvcRequestBuilders.get("/api/bake/cookie?bakeTime=1000"))
  .andExpect(request().asyncStarted())
  .andReturn();

ここでは、 DeferredResult エンドポイントを呼び出して、リクエストが非同期呼び出しを開始したことを表明しています。 ここから、テストは非同期結果の完了を待機します。つまり、テストに待機ロジックを追加する必要はありません。

次に、非同期呼び出しが戻ってきたときに、それが期待する値と一致することを表明します。

String response = mockMvc
  .perform(asyncDispatch(asyncListener))
  .andReturn()
  .getResponse()
  .getContentAsString();

assertThat(response)
  .isEqualTo("Bake for cookie complete and order dispatched. Enjoy!");

asyncDispatch()を使用することにより、非同期呼び出しの応答を取得し、その値をアサートできます。

DeferredResult のタイムアウトメカニズムをテストするには、asyncListener呼び出しとresponse呼び出しの間にタイムアウトイネーブラーを追加して、テストコードを少し変更する必要があります。

((MockAsyncContext) asyncListener
  .getRequest()
  .getAsyncContext())
  .getListeners()
  .get(0)
  .onTimeout(null);

このコードは奇妙に見えるかもしれませんが、このようにonTimeoutと呼ぶ特定の理由があります。 これは、AsyncListenerに操作がタイムアウトしたことを通知するために行います。 これにより、コントローラーのonTimeoutメソッドに実装したRunnableクラスが正しく呼び出されるようになります。

4. 結論

この記事では、長いポーリングのコンテキストでDeferredResultを使用する方法について説明しました。 また、長いポーリング用にサブスクライブするクライアントを作成する方法と、それをテストする方法についても説明しました。 ソースコードは、GitHubから入手できます。