ContentCachingRequestWrapper/Con

2022-05-25  本文已影响0人  鱼蛮子9527

在一个 Spring MVC 项目中,如果想要打印一个 HTTP 请求的输入参数、输出结果,你会想到什么方法呢?HandlerInterceptor?AOP?都可以实现,但也都有缺陷,由于是在 DispatchServlet 中特定位置做的扩展点,所以自然会受到 DispatchServlet 中处理逻辑的限制。例如,如果接口定义的 RequestMethod 与传入的不一致,还没有走到扩展点就已经抛出错误了,自然也无法输出请求信息。最好的方式应该是使用 Filter 拦截最原始的请求,进行信息输出,但是大家应该都知道 Request,Response 中的数据流是“一次性”的。打印了 Request Body 里面的信息,在真正要使用的时候就变成空了,这就要求我们需要将流写回去,或者能够支持多次读取。

Servlet Api 中提供了 HttpServletRequestWrapper、HttpServletResponseWrapper 可以帮助我们去实现重复读取数据流的功能,在 Spring 中进一步提供了 ContentCachingRequestWrapper、ContentCachingResponseWrapper 来简化我们的工作,从名字也能看出来是用来对 Request、Response 中的“内容”进行缓存。实际上这两个类也主要是干这个工作的,但其中有些小的细节需要注意下。

请求处理过程

对一个特别常见的前后端分离应用来说,经常是前端利用 Ajax 发送一个 JSON 请求体(Content-Type=application/json),后端服务处理后返回一个 JSON 内容(Content-Type=application/json)。基本处理过程如上图所示,当 Servlet 容器接收到一个请求,首先从 HttpServletRequest 的输入流中读取数据,进行业务处理,之后将处理结果写入到 HttpServletResponse 的输出流中。当 Request、Response 分别是 ContentCachingRequestWrapper、ContentCachingResponseWrapper 时候,让我们分析下处理过程。

ContentCachingRequestWrapper

在看 ContentCachingRequestWrapper 类之前,可以先看下它的父类 HttpServletRequestWrapper ,代码很简单,典型的装饰者模式应用,所有的方法实现基本都是透传传入的 HttpServletRequest 对象的实现,这也是这为啥叫做 XXXWrapper 的原因。

HttpServletRequest wrapper that caches all content read from 
the input stream and reader, 
and allows this content to be retrieved via a byte array.

首先看下 ContentCachingRequestWrapper 的注释,翻译过来就是:这是一个 HttpServletRequest Wrapper 类,将缓存所有通过 input stream 及 reader 读取过的内容,并且允许通过一个字节数组来恢复这些内容。

public ContentCachingRequestWrapper(HttpServletRequest request) {
    super(request);
    int contentLength = request.getContentLength();
    this.cachedContent = new ByteArrayOutputStream(contentLength >= 0 ? contentLength : 1024);
    this.contentCacheLimit = null;
}

上面是 ContentCachingRequestWrapper 的构造函数,我们可以看到主要是调用了 super 方法,并且初始化了一个类型为 ByteArrayOutputStream 的对象,这个对象就是用来缓存被读取的内容。

public ServletInputStream getInputStream() throws IOException {
    if (this.inputStream == null) {
        this.inputStream = new ContentCachingInputStream(getRequest().getInputStream());
    }
    return this.inputStream;
}

然后看下 getInputStream() 方法,可以看到是初始化了一个 ContentCachingInputStream 包装了原始的 InputStream。

public int read() throws IOException {
    int ch = this.is.read();
    if (ch != -1 && !this.overflow) {
        if (contentCacheLimit != null && cachedContent.size() == contentCacheLimit) {
            this.overflow = true;
            handleContentOverflow(contentCacheLimit);
        }
        else {
            cachedContent.write(ch);
        }
    }
    return ch;
}

ContentCachingInputStream 中的几个 read() 方法处理过程都比较类似,我们看最简单无参 read() 方法,代码其实也很简单,就是从原始的 HttpServletRequest 输入流中读取一个字节的数据,同时将这个数据写入到 cachedContent 对象,也就是构造函数中创建了用来做缓存的对象。

使用注意

通过上面的分析过程,我们能发现 ContentCachingRequestWrapper 只有在 HttpServletRequest 中的输入流被读取之后,才能够将数据缓存下来。这就要求,如果想要使用 ContentCachingRequestWrapper 进行请求内容的打印,只能够在业务处理后。这在日志的输出顺序上看起来有点怪,但其实并不影响整体功能的实现。 如果实在接受不了这种方式可以自己继承 HttpServletRequestWrapper,在初始化或者第一次读取输入流的时候,读取输入流中的全部内容并进行缓存,后面再获取输入流的时候都从缓存中读取。

ContentCachingRequestWrapper 中提供对数据进行重复读取的方法是 getContentAsByteArray()。

ContentCachingResponseWrapper

相应的 ContentCachingResponseWrapper 继承自 HttpServletResponseWrapper,跟上面的 ContentCachingRequestWrapper 的实现非常类似。

HttpServletResponse wrapper that caches all content 
 written to the output stream and writer, 
and allows this content to be retrieved via a byte array.

这是 ContentCachingResponseWrapper 的注释,将会缓存所有写入 output stream 和 writer 的内容,也提供了一个字节数组供写入内容的恢复。

private final FastByteArrayOutputStream content = new FastByteArrayOutputStream(1024);


/**
 * Create a new ContentCachingResponseWrapper
 for the given servlet response.
 * @param response the original servlet response
 */
public ContentCachingResponseWrapper(HttpServletResponse response) {
    super(response);
}

ContentCachingResponseWrapper 的构造函数很简单,只是调用了下 super() 方法,但是 ContentCachingResponseWrapper 有个成员变量 content 设置了默认值,也就在创建对象的时候将进行创建,而创建的类型为 FastByteArrayOutputStream 的对象就是用来缓存写入的内容。

public ServletOutputStream getOutputStream() throws IOException {
  if (this.outputStream == null) {
    this.outputStream = new ResponseServletOutputStream(getResponse().getOutputStream());
  }
  return this.outputStream;
}

ContentCachingResponseWrapper 核心重写的方法是 getOutputStream() 及 getWriter(),这两个方法虽然名字看上去差距很大,但其实底层的实现是类似的。我们主要看下 getOutputStream() 方法,这个方法中主要是新建了一个类型为 ResponseServletOutputStream 的对象,包装了原始的 HttpServletResponse#ServletOutputStream 对象。

public void write(int b) throws IOException {
    content.write(b);
}

ResponseServletOutputStream 核心重新了两个 write() 方法,我们依然看最简单的,可以看到就是将传入的字节写入到 content 对象中而已。随着返回数据的不断写入,自然 content 中就缓存了所有的返回内容。

使用注意

通过上面的分析,我们会发现,原本应该写入到 HttpServletResponse 输出流的数据,被写入到了一个类型为 FastByteArrayOutputStream 的缓存对象中。这个缓存对象能被 Servlet 容器直接读取使用吗?显然是不可以的。

于是 ContentCachingResponseWrapper 提供了 copyBodyToResponse() 方法,用于将 FastByteArrayOutputStream 中的内容写入到原始的 HttpServletResponse 输出流。所以如果使用了 ContentCachingResponseWrapper 包装过 HttpServletResponse,在使用完成之后,都需要调用下 copyBodyToResponse() 方法,来保证输出信息被真正的写到了原始的 HttpServletResponse 输出流中。需要注意的是,执行 copyBodyToResponse() 方法时将执行 this.content.reset() 方法,这将导致缓存的数据被重置,后面将无法再被读取到。

ContentCachingResponseWrapper 中提供了 getContentAsByteArray() 及 getContentInputStream() 方法来读取缓存的内容。

总结

总结下。

  1. ContentCachingRequestWrapper 中缓存的内容需要在请求处理之后才能读取到;
  2. 使用 ContentCachingResponseWrapper 包装过的 HttpServletResponse,在使用完之后需要调用下 copyBodyToResponse() 方法。
上一篇下一篇

猜你喜欢

热点阅读