新增:《解决无法重复读取请求体和响应体的问题》

This commit is contained in:
2022-09-26 19:00:53 +08:00
parent 898ab8f6fb
commit 3fc44375d8
3 changed files with 184 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ date: 2022/08/31 22:39
categories:
- Bug万象集
tags:
- Java
- "Spring Boot"
- "Spring Cloud"
- "Open Feign"

View File

@@ -0,0 +1,182 @@
---
title: 解决无法重复读取请求体和响应体的问题
author: 查尔斯
date: 2022/09/23 20:55
categories:
- Bug万象集
tags:
- Java
- "Spring Boot"
- 过滤器
---
# 解决无法重复读取请求体和响应体的问题
## 项目场景
**C** 这两天实现了一个操作日志功能,需求是要记录指定操作的请求 URL请求方式、请求头、请求体、响应码、响应头、响应体、请求耗时、操作人、操作IP、操作地址等信息。
考虑了几种方案,结合以前的经验,排掉了 AOP综合评估后这次采用的是 Spring 拦截器的方式来记录,大体的实现流程是:
1. 提供一个 `@Log` 注解
2. 在需要记录操作日志的接口类及方法上添加 `@Log` 注解,指定好资源名称和操作类型(具体为什么要在类和方法上都加,是考虑复用操作的资源名称)
3. 提供一个拦截器,在拦截器中判断当前 Handler 是否存在 `@Log` 注解
4. 存在该注解,就在 `preHandle()` 中开始计时,在 `afterCompletion()` 中结束计时并获取请求和响应信息
5. 将请求和响应信息异步存储到数据库中
## 问题描述
流程很简单,但是在获取 requestBody请求体和 responseBody响应体时出了些问题。如果我在 `preHandle()` 中获取了请求体信息,那么对应 Handler 就无法获取了,反之如果我是在 `afterCompletion` 中获取请求体信息,那么就获取不到了。而对于响应体,在我获取完之后,向前端响应就没内容了。
## 原因分析
之所以如此,是由于请求体和响应体分别对应的是 InputStream 和 OutputStream由于流的特性使用完之后就无法再被使用了。
```java
/**
* Retrieves the body of the request as binary data using a {@link ServletInputStream}. Either this method or
* {@link #getReader} may be called to read the body, not both.
*
* @return a {@link ServletInputStream} object containing the body of the request
*
* @exception IllegalStateException if the {@link #getReader} method has already been called for this request
*
* @exception IOException if an input or output exception occurred
*/
public ServletInputStream getInputStream() throws IOException;
```
```java
/**
* Returns a {@link ServletOutputStream} suitable for writing binary data in the response. The servlet container
* does not encode the binary data.
*
* <p>
* Calling flush() on the ServletOutputStream commits the response.
*
* Either this method or {@link #getWriter} may be called to write the body, not both, except when {@link #reset}
* has been called.
*
* @return a {@link ServletOutputStream} for writing binary data
*
* @exception IllegalStateException if the <code>getWriter</code> method has been called on this response
*
* @exception IOException if an input or output exception occurred
*
* @see #getWriter
* @see #reset
*/
public ServletOutputStream getOutputStream() throws IOException;
```
想要解决的话就要想办法把这信息使用完再“塞回去”,直接“塞回去”是不可能的。
## 解决方案
为了解决这个问题Servlet 提供了两个类 HttpServletRequestWrapper、HttpServletResponseWrapper我们可以继承它们来实现请求体和响应体内容的缓存达到重复读取请求体和响应体的目的。
不过既然我们在使用 Spring 框架,贴心的 Spring 也提供了两个实现类ContentCachingRequestWrapper、ContentCachingResponseWrapper这样我们就无需再自行定义相应 Wrapper 直接使用它们就可以解决这个问题了。
下面是在过滤器中对请求对象和响应对象进行包装处理的代码段:
```java
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;
import org.springframework.web.util.WebUtils;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Objects;
/**
* 缓存请求体和响应体过滤器
*
* <p>
* 由于 requestBody 和 responseBody 分别对应的是 InputStream 和 OutputStream由于流的特性读取完之后就无法再被使用了。
* 所以,需要额外缓存一次流信息。
* </p>
*
* @author Charles7c
* @since 2022/9/22 16:33
*/
@Component
public class ContentCachingWrapperFilter extends OncePerRequestFilter implements Ordered {
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 10;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 包装流,可重复读取
if (!(request instanceof ContentCachingRequestWrapper)) {
request = new ContentCachingRequestWrapper(request);
}
if (!(response instanceof ContentCachingResponseWrapper)) {
response = new ContentCachingResponseWrapper(response);
}
filterChain.doFilter(request, response);
updateResponse(response);
}
/**
* 更新响应(不操作这一步,会导致接口响应空白)
*
* @param response 响应对象
* @throws IOException /
*/
private void updateResponse(HttpServletResponse response) throws IOException {
ContentCachingResponseWrapper responseWrapper = WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
Objects.requireNonNull(responseWrapper).copyBodyToResponse();
}
}
```
下面是使用缓存对象来获取请求体或响应体的代码段,在你需要的地方使用就可以了:
```java
import org.apache.commons.io.IOUtils;
// --------------------------------------------
/**
* 获取请求体
*
* @param request 请求对象
* @return 请求体
*/
private String getRequestBody(HttpServletRequest request) {
String requestBody = "";
ContentCachingRequestWrapper wrapper = WebUtils.getNativeRequest(request, ContentCachingRequestWrapper.class);
if (wrapper != null) {
requestBody = IOUtils.toString(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8.toString());
}
return requestBody;
}
/**
* 获取响应体
*
* @param response 响应对象
* @return 响应体
*/
private String getResponseBody(HttpServletResponse response) {
String responseBody = "";
ContentCachingResponseWrapper wrapper = WebUtils.getNativeResponse(response, ContentCachingResponseWrapper.class);
if (wrapper != null) {
responseBody = IOUtils.toString(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8.toString());
}
return responseBody;
}
```