[译]在 Spring 异步返回 REST 风格的结果

这是一篇国外的文章,主要讲的是Spring异步返回结果的功能,如果你的项目中某些API需要处理耗时的任务,那么这篇文章绝对适合你。本人能力有限,翻译略渣,读者有能力可以自行阅读原文Asynchronous REST results in Spring

大多数Spring Boot REST教程都是从基础开始的,它们本该这样。但是当你生产环境中开始使用Spring Boot时,你很快就会发现,如果按照例子依葫芦画瓢,可能会导致你遇到性能问题。在这篇文章中,我将向你展示如果使用Spring中内置的异步功能。
这篇博客附有示例代码

介绍

在本例中,我将演示从Spring Boot REST服务返回JSON的四种不同用法。该示例是一个时间服务,对它调用会返回JSON格式的结果,该结果为当前的时间并且使用ISO 8601格式化。所有的调用都通过浏览器以GET方式请求。

运行这个应用

重点 > 该例子使用Lombok来生成Get、Set方法以及构造函数,为了使代码可以在你的IDE中运行,你需要安装Lombok插件并且开启注释的预处理功能。
让我们开始运行我们的应用。你可以通过启动AsyncApplication这个类来运行该应用,或者使用Maven来构建该应用,使用' java -jar target/spring-async-1.0-SNAPSHOT.jar'命令来启动编译出来的jar包。启动应用之后应该会出现如下几行日志:

18:37:38.653 [main] Tomcat started on port(s): 8080 (http)
18:37:38.659 [main] Started AsyncApplication in 3.09 seconds (JVM running for 3.661)

你现在可以使用如下的路由进行访问:
http://localhost:8080/time/basic
如果你看到的是有关它无法启动的消息,那是因为该端口已被占用,你可以使用另外一个端口,使用如下命令:

java -jar target/spring-async-1.0-SNAPSHOT.jar --server.port=9000

基础性的实现方式

假设你的应用正在运行;如果你使用GET方式请求http://localhost:8080/time/basic你会看到类似下面的回复:

{
    "time": "2016-10-05T13:30:58.766"
}

现在我们开看看控制台,我们应该会看到类似下面的两行日志:

18:39:54.241 [http-nio-8080-exec-1] Basic time request
18:39:54.241 [http-nio-8080-exec-1] Creating TimeResponse

该日志只会显示时间、线程名称和消息。正如你可以看到的,这两行日志都是由同一个线程创建的;http-nio-8080-exec-1.
那么这些日志来自哪里?让我们看看controller,看该请求在什么地方被吹。该请求的映射如下:

@RequestMapping(value = "/basic", method = RequestMethod.GET)
public TimeResponse timeBasic() {
    log.info("Basic time request");
    return now();
}

正如你看到的,它在timeBasic()方法中记录该次调用的日志并且返回了一个TimeResponse数据传输的对象(DTO),该对象在controller类的静态函数中被创建:

private static TimeResponse now() {
    log.info("Creating TimeResponse");
    return new TimeResponse(LocalDateTime
            .now()
            .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME));
}

该函数是"CreatingTimeResponpse"信息被记录的地方。四种不同的控制器路由使用相同的函数来记录TimeResponse DTO的创建。

ResponseEntity的实现方式

Spring REST API中常见的方法是返回ResponseEntity包装后的结果。这样可以更容易地替代返回的结果,例如“未找到HTTP响应”.除了这个包装器,实现起来非常相似:

@RequestMapping(value = "/re", method = RequestMethod.GET)
public ResponseEntity<?> timeResponseEntity() {
    log.info("Response entity request");
    return ResponseEntity.ok(now());
}

如果你调用 http://localhost:8080/time/re,我们会看见类似请求的日志:

18:52:36.025 [http-nio-8080-exec-4] Response entity request
18:52:36.025 [http-nio-8080-exec-4] Creating TimeResponse

该TimeResponse仍然在同一个worker中被创建,就是调用控制器中函数的worker。
当我们请求/time/basic或者/time/re的时候会发生什么,Spring MVC根据这些路由将这些请求转发给我我们SimpleController类中的timeBasic和timeResponseEntity方法。为了做这个转发它会使用线程池中的某个线程。因为性能的原因,Spring不会为每个请求创建一个新的线程,取而代之的是会使用线程池中的worker线程(名字为'http-nio-8080-exec-#'),这些线程用来处理请求。默认情况下该线程池拥有十个worker,它们可以同时处理十个请求。
这对于那些简单的、短暂的请求是极好的,像我之前创建的那些。如果这些请求因为等待外部连接或者长时间的的数据库请求而阻塞很长时间会发生什么呢?我们会很容易的阻塞掉所有的worker线程,这回造成新的请求被拒绝,无论这些请求中是否有不耗时的请求。所以我们应该有一个机制,该机制能使我们不阻塞我们的线程。

Callable的实现方式

一个很容易的解决方式就是讲我们的响应包裹在Callable中。Spring会自动感知当它接收到一个回调时,它应该被认为是一个耗时的调用,并且应该方式在不同的线程中执行。让我们来看看controller:

@RequestMapping(value = "/callable", method = RequestMethod.GET)
public Callable<ResponseEntity<?>> timeCallable() {
    log.info("Callable time request");
    return () -> ResponseEntity.ok(now());
}

对的。它真的非常简单。当我们对http://localhost:8080/time/callable发起一个GET请求时,我们会看到有些有趣的事情:

19:04:24.508 [http-nio-8080-exec-10] Callable time request
19:04:24.514 [MvcAsync1] Creating TimeResponse

该请求被某个worker接受,但是这项工作被一个名为MvcAsync1的线程完成。这是SpringMVC为我们完成的。当它从controller接收到一个Callable的时候,它调出一个新的线程去处理它。默认的它可以调出你需要的线程数。所以如果我们在短时间内点击多次,这个数据会依次增加。

19:07:09.443 [MvcAsync2] Creating TimeResponse
19:07:10.123 [MvcAsync3] Creating TimeResponse
19:07:10.773 [MvcAsync4] Creating TimeResponse

重点:你仍然需要弄明白什么样的线程模式适合你的用力。创建线程是代价相对较高的,并且没有一个最大值你的应用服务器会耗尽内存和缓存。
可以通过WebMvcConfigurer bean配置次行为以及名称。

DeferredResult的实现方式

所以我们现在可以更加容易的处理长时间运行的结果。但是Callable接口只允许我们返回一个结果。我们不能告诉执行者我们已经完成任务了。对于简单的结果这样很好,但是某些场景下我们需要更多的控制权。这就是DeferredResult类引进的地方。DeferredResult是一个Future,允许我们发出任务完成的信号。让我们看一下controller中的方法:

@RequestMapping(value = "/deferred", method = RequestMethod.GET)
public DeferredResult<ResponseEntity<?>> timeDeferred() {
    log.info("Deferred time request");
    DeferredResult<ResponseEntity<?>> result = new DeferredResult<>();

    new Thread(() -> {
        result.setResult(ResponseEntity.ok(now()));
    }, "MyThread-" + counter.incrementAndGet()).start();

    return result;
}

它很像Callable的版本,我们将结果包裹在类中。但我们做的确实不一样的。Callable只是简单的返回,DefferedResult需要通过设置结果来完成。
在上面的例子中,我调用了我自己的线程('MyThread-'),Spring处理Callables也是同样的方式。我将runnable传递给thread,thread通过调用setResult方法创建一个新的ResponseEntity,并且返回DeferedResult。
当我们以GET的方式请求http://localhost:8080/time/deferred,我们会看到如下的日志:

19:18:39.563 [http-nio-8080-exec-3] Deferred time request
19:18:39.565 [MyThread-1] Creating TimeResponse

然后我们会看到该项工作在线程中被处理,该线程不同于接受请求的那个线程。

测试异步的controllers

有一点要记住的是当你通过MockMvc使用异步调用测试controllers的时候,异步方便需要你稍微改变一下测试。如何测试已经在SimpleControllerIntegrationTest中展示。一个普通的调用(像/time/basic和/time/re),已经通过MockMvc被测试,如下:

private void testSync(String route) throws Exception {
    mockMvc.perform(get(route))
            .andExpect(status().is2xxSuccessful())
            .andExpect(jsonPath("$.time").isString());
}

这个异步调用不会有效,因为它们首先需要被分配然后被处理,所以我们需要等待。幸运的是,通过几行代码,可以为我们处理这些调用:

private void testAsync(String route) throws Exception {
    MvcResult resultActions = mockMvc.perform(get(route))
            .andExpect(request().asyncStarted())
            .andReturn();

    mockMvc.perform(asyncDispatch(resultActions))
            .andExpect(status().is2xxSuccessful())
            .andExpect(jsonPath("$.time").isString());
}

该调用和结果的检查通过两个步骤被分开。首先我们创建一个请求并且使MockMvc进行阻塞,知道该请求被分配。一些更加官方的例子可以在这里找到。

一个真实的例子

如果你检出的源码,你可能会注意到我们有另外一个controller。这是一个基于现实生活的例子,在这里我使用DeferredResults将许多不同REST API的结果聚合成一个结果。它使用OkHttp的异步功能,可以并发的处理许多请求。
许多请求的示例如下:

POST localhost:8080/aggregate

{"urls": [
    "https://api.ipify.org?format=json",
    "http://ip-api.com/json",
    "https://jsonplaceholder.typicode.com/posts/1"
]}

底层的服务调用这些API,并且将它们合并为一个单一的JSON:

{
  "responses": [
    {
      "body": {
        "ip": "123.456.123.456"
      },
      "status": 200,
      "duration": 1799
    },
    {
      "body": {
        "as": "ANONYMIZED"
      },
      "status": 200,
      "duration": 123
    },
    {
      "body": {
        "userId": 1,
        "id": 1,
        "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
        "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
      },
      "status": 200,
      "duration": 1911
    }
  ],
  "duration": 2149
}

我的配置真的很棒,处理的时间取决于最慢的API,这表名异步调用拥有按顺序调用的好处。
它也有一个集成测试,该测试使用WireMock创建服务存根,然后并行的调用它们。

总结

Spring让来自我们controller中的耗时任务确实变得好处理。我们可以返回一个几乎没有影响的Callable当我们希望spring处理线程时,或者当我们需要完全控制时,我们可以使用DeferredResults。
我希望你能享受这边文章就像我享受写作一样。很轻松的与这里示例玩耍,如果你有评论或者问题请让我知道!

发表评论

电子邮件地址不会被公开。 必填项已用*标注