使用 Servlet3.1 WriteListener进行异步响应输出以及踩过的坑

在标准的spring mvc项目中,当对象数据已经产生时,不管是输出json,还是view,最终均是将相应的数据直接写入response的outputStream中。这里的输出是同步式调用的,即意味着只有当整个数据完整地写到输出流中时,整个业务线程才会被认为执行结束。

这就意味着在传统的基于请求的慢速攻击之外,还有一种基于响应的慢速攻击,其思路即是客户端强制让整个接收变慢,而服务端因为缓存区不足以存储完整的响应数据,而被动地阻塞调用端,进而阻塞业务线程。

在Servlet 3.0版本中,提出了业务中的异步处理,即 通过 HttpServletRequest#startAsync 来开启异步处理。等业务处理完成之后,才通过 AsyncContext#complete 来结束处理。在中间,可以仍然通过 HttpServletResponse#getOutputStream 来输出数据. 这里的异步处理仅仅是将容器的处理线程解放出来,即当业务处理需要长时间或者需要故意挂起请求(如LongPull)时,才有相应的意义。在最终输出数据时,这里仍然是阻塞的,只不过是阻塞业务线程(而非容器线程).造成的后果即是缓慢地IO操作造成线程阻塞。

随着 NIO 的出现,所有Web容器均使用了Nio来处理请求,即当整个请求已经完整读取完毕之后,才转交由具体的业务线程来处理,即在这里的io读取均为非阻塞读取,由很少数的线程就可以处理上百(千)个请求的网络请求. 在 Servlet 3.1 版本中, 规范中给出了 异步响应的概念,即可以通过响应式来达到异步非阻塞输出数据的效果. 从调用端来看,调用write之后即刻返回,并不会阻塞业务线程。后续未刷新到缓冲区的数据将在后续通过标准Nio流程由web容器的IO线程负责处理.

一个参考使用例子如下所示(参考于 https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/HTML5andServlet31/HTML5andServlet%203.1.html):

//开启异步
val context = request.startAsync();
val response = context.getResponse();
val servletOut = response.getOutputStream();

//定义响应监听器,以可写时输出相应的数据
val writeListener = new WriteListenerImpl(context, servletOut, actionQueue);
servletOut.setWriteListener(writeListener);

//一个参考的监听器实现
public class WriteListenerImpl implements WriteListener {
    private ServletOutputStream output = null;
    private Queue queue = null;
    private AsyncContext context = null;

    private boolean flag = false;

    @Override
    public void onWritePossible() throws IOException {
        if (flag) {
            return;
        }

        //这里仅当output.isReady() 时才会调用write方法,否则会报 IllegalStateException
        while (queue.peek() != null && output.isReady()) {
            val action = queue.poll();
            doWrite(action);
        }
    }

    private void doWrite(Action action) throws IOException {
        //已完成标记位
        if (action == Action.COMPLETE) {
            flag = true;
        }

        action.run(context, servletOut);
    }
}

而在标准的Spring Mvc项目中,由于相应的数据输出均由不同的View或MessageConverter处理了,因此需要根据项目内实际使用的范围进行调整,以保证满足异步响应的条件。即仅在 ServletOutputStream#isReady, 才可以进行输出. 以及在发生错误时,通过正常地 complete 以结束响应,避免响应一直未结束而超时.

这里聊一下在实际过程中具体碰到的坑,也算是编码规范吧

1 控制异步响应输出范围

这里的意思为,仅在确定要调用write时才开启异步,并且准备好相应逻辑,然后写入消费队列,最后写入 COMPLETE 标记. 如果将这几个步骤分散在各个地方,则会产生各种奇怪的后果.

比如笔者最初的作法

  1. 使用Filter开启异步响应并准备好消费队列
  2. 各种view或messageConverter写入消费队列
  3. filter finally语句中检查complete标记并补充COMPLETE标记

以上的逻辑在业务处理成功时不会有问题,但在以下这几种情况下就会出问题了

  1. 当业务中出错时(或其它filter出错时),即产生了5xx错误,某些情况会通过response.sendError 来处理。进而进一步调用web容器的默认错误页,从而绕过第2步,出现Illegal异常
  2. 业务中出现response.sendStatus(302)时, 因为本身没有io处理,会导致 writeListener 不会按预期工作,即出现COMPLETE一直不会触发,而导致响应超时
  3. 在第2步中,complete和sendRedirect之间的冲突. 当complete由其它线程触发而commit response时,response#sendRedirect 则会报Illegal异常. 这是多个线程相互影响产生的.

以上2个问题,在笔者使用的Undertow容器时,不断地出现以上的问题。包括 undertow 自身在处理各种与 AsyncContext 相关的代码时,也会有奇怪的设定.最终的解决方案,即是将异步响应的逻辑控制在很小的范围,即只有在需要进行响应输出内容时,才开启相应的异步化响应。

2 内部禁用异步化标记

考虑一种场景,在缓存的场景中,需要缓存没有命中,则需要先调用业务逻辑,获取数据之后,再写入缓存. 如果使用 ContentCachingResponseWrapper 来替换原生的的response,下层业务如果不能感知到此变化,仍按默认流程异步化输出,则此wrapper最终就不能拿到相应的数据,不能满足正确的缓存需求.

因此,在这个场景中,需要给到一个hit,下层在处理输出响应时,在此hit下,则仍按原来默认的逻辑直接write至response中,以达到使用 byteArray content接收下层业务数据的目的.

这里参考的作法如下

//设置1个标记位,下层
public static void setRequestAsyncNFlag(HttpServletRequest request) {
    request.setAttribute(FLAG, Boolean.FALSE);
}

//通过读取此标记来判断是否支持异步
public static boolean currentRequestAsync(HttpServletRequest request) {
    return !Objects.equals(Boolean.FALSE, request.getAttribute(FLAG));
}

3 HttpMessageConverter中单次写入

在spring mvc中提供的HttpMessageConverter,其本质上就是通过 outputMessage#getBody 拿到response的outputStream,然后不停地输出。默认情况下,不同的messageConverter并不是只调用一次out.write,而是在处理数据的过程中不停地调用write来写入数据. 如典型的 MappingJackson2HttpMessageConverter,即是通过将 outputStream 传递至objectMapper,然后在内部通过遍列数据结构进行write操作。

这里的问题在于,异步化的outputStream仅仅在 ServletOutputStream#isReady 时才可以调用write操作,否则会报Illegal异常。同时,在一次write调用之后,其ready状态根据缓冲区情况很可能变为unReady,因此不能在一次ready之后,反复地调用write操作。

解决此问题的一种作法是在write之后,通过模拟的ByteArrayOutputStream来处理数据,再通过获取byte[]来一次性在isReady状态下写入. 这种作法的缺点在于,存在两次(或多次)数据copy操作,对内存和额外的压力.

转载请标明出处:i flym
本文地址:https://www.iflym.com/index.php/code/202012030001.html

相关文章:

作者: flym

I am flym,the master of the site:)

发表评论

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