1. 概述

长轮询是服务器应用程序用来保持客户端连接直到信息可用的一种方法。 当服务器必须调用下游服务来获取信息并等待结果时,通常会使用此方法。

在本教程中,我们将使用 DeferredResult探索 Spring MVC 中长轮询的概念。 我们将首先查看使用 DeferredResult 的基本实现,然后讨论如何处理错误和超时。最后,我们将看看如何测试这一切。

2. 使用 DeferredResult 进行长轮询

我们可以在 Spring MVC 中使用 DeferredResult 作为异步处理入站 HTTP 请求的方法。 它允许释放 HTTP 工作线程来处理其他传入请求,并将工作卸载到另一个工作线程。因此,它有助于提高需要长时间计算或任意等待时间的请求的服务可用性。

我们之前关于 Spring 的 DeferredResult 类的文章更深入地介绍了它的功能和用例。

2.1.出版商

让我们通过创建一个使用 DeferredResult 的发布应用程序来开始我们的长轮询示例。

首先,我们定义一个 Spring @RestController ,它使用 DeferredResult 但不会将其工作卸载到另一个工作线程:

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

该控制器以与常规阻塞控制器相同的方式同步工作。因此,我们的 HTTP 线程完全被阻塞,直到 bakeTime 过去。如果我们的服务有大量入站流量,这并不理想。

现在让我们通过将工作卸载到工作线程来异步设置输出:

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 中,我们可以使用 RestTemplateWebClient 来实现这一点,因为两者都有内置的超时处理。

首先,让我们从使用 RestTemplate 的示例开始。 让我们使用 RestTemplateBuilder 创建一个 RestTemplate 实例,以便我们可以设置超时持续时间:

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响应 调用之间添加超时启用程序来稍微更改测试代码:

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

这段代码可能看起来很奇怪,但是我们以这种方式调用 onTimeout 是有特定原因的。我们这样做是为了让 AsyncListener 知道操作已超时。这将确保我们在控制器中为 onTimeout 方法实现的 Runnable 类被正确调用。

4。结论

在本文中,我们介绍了如何在长轮询上下文中使用 DeferredResult 。我们还讨论了如何编写用于长轮询的订阅客户端,以及如何测试它。源代码可在 GitHub 上获取。