项目场景
C: 这两天实现了一个操作日志功能,需求是要记录指定操作的请求 URL,请求方式、请求头、请求体、响应码、响应头、响应体、请求耗时、操作人、操作IP、操作地址等信息。
考虑了几种方案,结合以前的经验,排掉了 AOP,综合评估后这次采用的是 Spring 拦截器的方式来记录,大体的实现流程是:
- 提供一个
@Log
注解 - 在需要记录操作日志的接口类及方法上添加
@Log
注解,指定好资源名称和操作类型(具体为什么要在类和方法上都加,是考虑复用操作的资源名称) - 提供一个拦截器,在拦截器中判断当前 Handler 是否存在
@Log
注解 - 存在该注解,就在
preHandle()
中开始计时,在afterCompletion()
中结束计时并获取请求和响应信息 - 将请求和响应信息异步存储到数据库中
问题描述
流程很简单,但是在获取 requestBody(请求体)和 responseBody(响应体)时出了些问题。如果我在 preHandle()
中获取了请求体信息,那么对应 Handler 就无法获取了,反之如果我是在 afterCompletion
中获取请求体信息,那么就获取不到了。而对于响应体,在我获取完之后,向前端响应就没内容了。
原因分析
之所以如此,是由于请求体和响应体分别对应的是 InputStream 和 OutputStream,由于流的特性,使用完之后就无法再被使用了。
/**
* 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;
/**
* 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 直接使用它们就可以解决这个问题了。
下面是在过滤器中对请求对象和响应对象进行包装处理的代码段:
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();
}
}
下面是使用缓存对象来获取请求体或响应体的代码段,在你需要的地方使用就可以了:
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;
}