Spring WebMvc高级-Servlet与WebMvc的异步请求

从 0 开始深入学习 Spring 专栏目录总览

WebMvc 的高级部分只有一章哈,主要是一些过于高级的东西我们用不到,讲到的东西基本都不怎么高级 ~

不过这个知识点还蛮高级的,异步请求这个东西想必有不少小伙伴都没用过,甚至没有接触过。这样咱先从 Servlet 的规范开始,讲解一下如何编写和处理异步请求。

1. Servlet3.0的异步请求支持【熟悉】

在 Servlet 3.0 规范发布之前,所有的 Servlet 请求采用同步阻塞式进行处理,也就是从请求进到 Web 容器,到响应回客户端,这之间的过程。是一个线程从头到尾阻塞运行。这种处理方式在一些特殊的场景下会出现很强的弊端:如果请求对应访问的数据库性能不好,或者 SQL 性能太差,会导致请求等待的时间太长,又因为此时线程一直是阻塞的,无法抽身出来供其他的请求使用,因此会导致 Web 容器中的线程池逐渐被耗尽,无法及时释放回收,造成系统的性能太差,甚至服务器崩溃等问题。

Servlet 3.0 规范的提出解决了这一问题,它提出了异步请求的概念。借助 HttpServletRequest ,可以从中拿到一个 AsyncContext ,使用这个 AsyncContext ,就可以实现异步请求处理。

下面我们来演示一下这种原生的写法。

本节源码位置:spring-04-web 工程 com.linkedbear.spring.c_async 。

1.1 编写新的Servlet

我们先来写一个最基本的 Servlet ,这里面我们为了模拟连接数据库,SQL 性能太差引起的线程阻塞,就在 doGet 方法中给线程休眠 5 秒吧:

@WebServlet(urlPatterns = "/async")
public class AsyncServlet extends HttpServlet {
    
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("AsyncServlet doGet ......");
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException ignore) { }
    }
}

写完之后,我们先来部署工程进 Tomcat ,启动测试一下。

浏览器访问 http://localhost:8080/spring-web/async ,发现浏览器确实转了 5 秒,然后响应了 success 的文本。

Spring WebMvc高级-Servlet与WebMvc的异步请求

1.2 使Servlet支持异步

接下来,我们来改造这个 Servlet ,让它能支持异步请求。

首先,我们要给 @WebServlet 注解上,添加一个属性声明:asyncSupported = true ,代表让 Servlet 支持异步请求;然后,我们要在 Servlet 的请求处理中,开启异步请求处理,这个方法会返回一个异步上下文,也就是刚才上面提到的那个 AsyncContext :

@WebServlet(urlPatterns = "/async", asyncSupported = true)
public class AsyncServlet extends HttpServlet {
    
    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("AsyncServlet doGet ......");
        AsyncContext asyncContext = req.startAsync();
        
        try {
            TimeUnit.SECONDS.sleep(5);
        } catch (InterruptedException ignore) { }
        resp.getWriter().println("success");
    }
}

接下来就是重点了,AsyncContext 中有一个 start 方法,这里面需要传入一个 Runnable 的匿名内部类实现。

诶,说到这里是不是一下子就明白了,让这个 start 方法去执行业务逻辑,然后外层的 doGet 方法就顺势执行完毕了,里面耗时的操作对应的线程,跟 doGet 方法执行的线程肯定不是同一个,这样问题也就解决了!

好,那既然是这样,我们就把耗时的逻辑放到 start 方法中:

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("AsyncServlet doGet ......");
        PrintWriter writer = resp.getWriter();
        AsyncContext asyncContext = req.startAsync();
        asyncContext.start(() -> {
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException ignore) { }
            writer.println("success");
        });
    }

但是光这样写还不行,如果现在就重启 Tomcat 并访问,会发现浏览器一直在转圈,也没有 success 的响应。这是因为,异步请求需要我们手动通知异步逻辑执行完成,即在 start 中的逻辑最后,添加上 asyncContext.complete(); 的代码即可。

为了方便观察一会执行的情况,我们在代码中再添加一些控制台打印,以及处理的线程打印:

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("AsyncServlet doGet ......" + Thread.currentThread().getName());
        PrintWriter writer = resp.getWriter();
        
        AsyncContext asyncContext = req.startAsync();
        asyncContext.start(() -> {
            System.out.println("AsyncServlet asyncContext ......" + Thread.currentThread().getName());
            
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException ignore) { }
            asyncContext.complete();
            
            writer.println("success");
            System.out.println("AsyncServlet asyncContext end ......" + Thread.currentThread().getName());
        });
        
        System.out.println("AsyncServlet doGet end ......" + Thread.currentThread().getName());
    }

这样就算搞定了,重启 Tomcat ,浏览器重新访问 /async 路径,发现 5 秒后成功收到了 Tomcat 响应的 success ,并且后端 Tomcat 也打印了控制台输出:

AsyncServlet doGet ......http-nio-8080-exec-1
AsyncServlet doGet end ......http-nio-8080-exec-1
AsyncServlet asyncContext ......http-nio-8080-exec-3
AsyncServlet asyncContext end ......http-nio-8080-exec-3

注意观察,当浏览器访问 /async 的时候,前面 3 行都是立即打印的,只有最后一行 AsyncServlet asyncContext end 是过了 5 秒后才打印的:

Spring WebMvc高级-Servlet与WebMvc的异步请求

这也进一步说明了,异步请求会有额外的线程来处理,能提高整体线程的使用效率。

2. SpringWebMvc的异步请求支持【熟悉】

说完了 Servlet 3.0 规范中的异步请求支持,下面我们来说 SpringWebMvc 对异步请求的支持。

对于 SpringWebMvc 而言,自打 4.0 版本之后,它就已经是天然支持异步请求的了,不需要编写任何配置,也不需要折腾这那的开启,只需要在编写 Handler 的时候调整一下返回值即可。下面咱来演示两种异步请求支持的方法。

本节源码位置:spring-04-webmvc 工程 com.linkedbear.spring.c_async 。

2.1 基于Callable的异步请求支持

Callable ,这是 jdk 原生的带返回值的 “Runnable” ,它也经常出现在多线程并发编程中。SpringWebMvc 规定了,如果返回值是 Callable 类型,则认定该 Handler 采用异步请求支持。下面我们可以来编写一下代码。

2.1.1 代码快速编写

按照之前学过的方式,编写一个 AsyncController ,并声明 @RestController 注解(不要产生疑惑,我只是不想写 @ResponseBody 注解),之后编写一个与上面逻辑相似的方法:

@RestController
public class AsyncController {
    
    @GetMapping("/async")
    public Callable<String> async() {
        return () -> {
            TimeUnit.SECONDS.sleep(5);
            return "AsyncController async ......";
        };
    }
}

可以发现,除了返回值不一样之外,其余的好像并没有什么两样吧!

接下来还要改一个地方,因为之前我们在刚开始学习 SpringWebMvc 的时候,包扫描的范围太小了,所以我们要改一下 b_anno.config.WebMvcConfiguration 的包扫描路径:

@ComponentScan(value = {"com.linkedbear.spring.b_anno.controller", "com.linkedbear.spring.c_async"})

修改完之后就可以开始测试啦。在 IDE 中部署好工程,之后启动 Tomcat ,浏览器访问 http://localhost:8080/spring-webmvc/async ,可以发现浏览器也是阻塞了 5 秒后收到了响应的字符串 AsyncController async ...... ,说明编写成功。

Spring WebMvc高级-Servlet与WebMvc的异步请求

2.1.2 线程的细节

跟上面一样,我们来打印一下外层和内层的执行线程:

    @GetMapping("/async")
    public Callable<String> async() {
        System.out.println("AsyncController async ......" + Thread.currentThread().getName());
        return () -> {
            System.out.println("AsyncController Callable ......" + Thread.currentThread().getName());
            TimeUnit.SECONDS.sleep(5);
            return "AsyncController async ......";
        };
    }

重启 Tomcat ,重新访问,发现控制台打印了好多:

AsyncController async ......http-nio-8080-exec-6
!!!
An Executor is required to handle java.util.concurrent.Callable return values.
Please, configure a TaskExecutor in the MVC config under "async support".
The SimpleAsyncTaskExecutor currently in use is not suitable under load.
-------------------------------
Request URI: '/spring-webmvc/async'
!!!
AsyncController Callable ......MvcAsync1

可以发现,两个线程也是不一致的。内部 Callable 执行的线程,是一个叫 MvcAsync1 的家伙,它咋起的名咱甭管,知道俩线程不一样就 OK 了。

2.1.3 异步执行的细节

上面的打印中,除了发现两个线程不一样之外,更重要的是中间 WebMvc 框架给出的警告:

An Executor is required to handle java.util.concurrent.Callable return values. Please, configure a TaskExecutor in the MVC config under “async support”. The SimpleAsyncTaskExecutor currently in use is not suitable under load.

需要执行程序来处理 Callable 的返回值。请在 “异步支持” 下的 MVC 配置中注册一个 TaskExecutor 。当前使用的 SimpleAsyncTaskExecutor 不适用于高负载的环境。

这个意思蛮好理解的,因为默认情况下我们压根就没有配置 TaskExecutor ,所以 WebMvc 内部用了一个 SimpleAsyncTaskExecutor 来临时顶名干活。而这个 SimpleAsyncTaskExecutor 在底层是每次都会 new 一个全新的线程,这种操作当然是不合适的,取而代之的应该是线程池,所以这也就是为什么 SpringWebMvc 给我们警告,让我们注册 TaskExecutor 。

那解决方案也很简单,SpringFramework 给我们提供了一个 ThreadPoolTaskExecutor ,它就是基于线程池的 TaskExecutor ,向 IOC 容器中注册一个,就可以消除上面的警告了。

以上就是基于 Callable 的异步请求支持,可以发现这种情景还是比较简单的。但在实际开发中,可能还会遇到更复杂的场景,这就需要下面的 DeferredResult 出马了。

2.2 基于DeferredResult的异步请求支持

这个 DeferredResult 比较冷门,可能很多用过几年 SpringWebMvc 的,也未必接触过,不过它应对异步请求处理还是很好用的。要用它的话,基本都是复杂场景了,这里我们先来举一个栗子。

2.2.1 复杂场景构想

比方说我们现在有两个系统 A 和 B ,它们中间使用消息中间件进行交互。每次客户端(浏览器)请求到 A 系统时,A 系统会把这次请求的信息暂存到一个特殊的位置,然后给 B 系统发新的请求,等到 B 响应到数据之后,A 系统会把刚才暂存的请求信息拿出来,并把 B 响应的数据放进去,最后响应给客户端。这个设计听起来很复杂,配一张图就好理解多了:

Spring WebMvc高级-Servlet与WebMvc的异步请求

注意这个暂存区不是存 HttpServletRequest 的哈,是一些我们自己封装的东西。

要实现这种场景的构想,就需要 DeferredResult 上场了。下面我们来演示一下。

2.2.2 代码编写

我们还是在 AsyncController 中,再来写一个新的方法:

    @GetMapping("/deferred")
    public DeferredResult<String> deferred() {
        // 5000L表示5秒没有传值,则认定超时
        DeferredResult<String> deferredResult = new DeferredResult<>(5000L);
        
        return deferredResult;
    }

只要返回值是 DeferredResult ,SpringWebMvc 就不会立即响应结果,而是等待数据填充。当 DeferredResult 的 setResult 方法被调用时,才会触发响应处理,客户端也才能收到响应结果。

所以下一步是将这个 DeferredResult 暂存了,咱这里就不写那么多复杂逻辑了,主要是让各位看明白 DeferredResult 的使用,所以我们直接存到 AsyncController 的成员变量中吧:

    private DeferredResult<String> deferredResult = null;
    
    @GetMapping("/deferred")
    public DeferredResult<String> deferred() {
        DeferredResult<String> deferredResult = new DeferredResult<>(5000L);
        this.deferredResult = deferredResult;
        return deferredResult;
    }

接下来,这样写完之后还不行,我们再来写一个新的方法,来触发 deferredResult 的 setResult 方法:

    @GetMapping("/addData")
    public void addData() {
        if (this.deferredResult != null) {
            this.deferredResult.setResult("AsyncController deferredResult setResult");
            this.deferredResult = null;
        }
    }

这里面逻辑简单一写就好,涉及到的并发等因素我们就不考虑了哈,只是为了快速演示用。

2.2.3 测试运行

好,接下来重启 Tomcat ,浏览器访问 http://localhost:8080/spring-webmvc/deferred ,如果只是访问的话,由于 DeferredResult 中没有数据,所以 5 秒之后会响应 503 的错误:

Spring WebMvc高级-Servlet与WebMvc的异步请求

如果在请求过程中同时访问 http://localhost:8080/spring-webmvc/addData ,则 DeferredResult 中有了数据,自然前面的请求也就可以得到响应了:

Spring WebMvc高级-Servlet与WebMvc的异步请求

这就是 DeferredResult 的使用,还算挺简单吧。只不过相较于 Callable 来说,DeferredResult 在编码使用中更自由,但同时也需要我们自己来控制响应,两者各有利弊,小伙伴在处理时最好根据实际情况来设计使用。

免责声明:
1.本站所有内容由本站原创、网络转载、消息撰写、网友投稿等几部分组成。
2.本站原创文字内容若未经特别声明,则遵循协议CC3.0共享协议,转载请务必注明原文链接。
3.本站部分来源于网络转载的文章信息是出于传递更多信息之目的,不意味着赞同其观点。
4.本站所有源码与软件均为原作者提供,仅供学习和研究使用。
5.如您对本网站的相关版权有任何异议,或者认为侵犯了您的合法权益,请及时通知我们处理。
火焰兔 » Spring WebMvc高级-Servlet与WebMvc的异步请求