Spring Web 请求封装 + 拦截器统计耗时
一、整体目的与背景
在 Java Servlet 规范中,HttpServletRequest 的输入流(InputStream)设计为 只能被顺序读取一次。如果在 Spring MVC 之前的组件(如 Filter 或 Interceptor)中读取了请求体用于日志记录或参数校验,那么后续的 Controller 将无法再次读取到请求体数据,导致业务层获取请求参数失败(如 @RequestBody 失效)。
解决方案核心思路
通过 装饰器模式,在请求进入 Spring MVC 核心流程前,用一个可重复读取的包装类替换原始请求对象:
- Filter (Repeater): 在请求进入 Spring MVC 前,拦截并判断是否为需要重复读取的请求(如 JSON 请求)。
- Request Wrapper (RepeatedlyRequestWrapper): 将原始
InputStream的内容 一次性完整读取到内存中的byte[]数组,并重写getInputStream()和getReader()方法,使其每次调用都基于内存数据创建一个新的可读流。 - Interceptor (PlusWebInvokeTimeInterceptor): 利用包装器提供的重复读取能力,安全地打印请求参数(包括 JSON 请求体)并利用
ThreadLocal+StopWatch统计请求的总处理耗时。
二、可重复读取 InputStream 的 Request 包装器
1. 核心解决方案:HttpServletRequestWrapper
HttpServletRequestWrapper 是 Servlet API 提供的 装饰器 类,允许我们包裹原始 HttpServletRequest 对象,并通过 重写 特定方法来改变其行为,同时保持对原始请求属性的访问。
2. RepeatedlyRequestWrapper 代码实现
核心原理: 在构造时读取原始流并缓存到 body 数组,在重写 getInputStream() 时,基于 body 数组创建可重复的 ByteArrayInputStream。
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
// 假设 IoUtil 和 Constants 为自定义工具类
// import cn.hutool.core.io.IoUtil;
// import com.ruoyi.common.constant.Constants;
/**
* @类说明:构建可重复读取输入流 (InputStream) 的 Request 包装器。
* 由于原始的 HttpServletRequest 的 InputStream 只能读取一次,
* 该类通过将请求体数据一次性读取并缓存到内存中,实现了请求体的重复读取。
*
* @author ruoyi
*/
public class RepeatedlyRequestWrapper extends HttpServletRequestWrapper {
/**
* @字段说明:存储请求体的原始字节数据(内存缓冲区)。
* 通过构造方法将原始流的内容读取并保存于此。
*/
private final byte[] body;
/**
* @方法说明:构造方法,用于在初始化时读取并存储请求体内容。
*
* @param request 原始的 HttpServletRequest 对象。
* @param response 原始的 ServletResponse 对象(用于设置编码)。
* @throws IOException 如果读取流时发生 I/O 错误。
*/
public RepeatedlyRequestWrapper(HttpServletRequest request, ServletResponse response) throws IOException {
super(request);
// 设置请求和响应的字符编码为 UTF-8,防止中文乱码
request.setCharacterEncoding(Constants.UTF8);
response.setCharacterEncoding(Constants.UTF8);
// 核心步骤:将原始请求流中的所有字节数据一次性读取到 body 字节数组中。
// 一旦读取完成,原始流就被消费(即无法再次读取),但数据已安全存储在 body 中。
body = IoUtil.readBytes(request.getInputStream(), false);
}
/**
* @方法说明:重写获取字符输入流(Reader)的方法,确保从缓存的 body 数组读取。
*
* @return 能够从内存 body 数组读取数据的 BufferedReader。
* @throws IOException
*/
@Override
public BufferedReader getReader() throws IOException {
// 先调用重写的 getInputStream() 获取能够从 body 数组读取的流,
// 再将其包装为字符流(InputStreamReader),最后包装为高效的 BufferedReader。
return new BufferedReader(new InputStreamReader(getInputStream()));
}
/**
* @方法说明:重写获取字节输入流(InputStream)的方法,核心实现重复读取。
*
* @return 能够从 body 数组读取数据的 ServletInputStream。
* @throws IOException
*/
@Override
public ServletInputStream getInputStream() throws IOException {
// 关键:每次调用都基于 body 数组创建一个新的 ByteArrayInputStream。
// 内存流(ByteArrayInputStream)可以重复创建,从而实现重复读取。
final ByteArrayInputStream bais = new ByteArrayInputStream(body);
// 返回一个匿名内部类实现的 ServletInputStream
return new ServletInputStream() {
// 实现 read() 方法,从内存流中读取数据
@Override
public int read() throws IOException {
return bais.read();
}
// 实现 available() 方法,返回缓存数据(body)的总长度
@Override
public int available() throws IOException {
return body.length;
}
// 以下方法为 Servlet 3.1+ 异步 IO 规范要求实现的方法
// 由于是同步读取内存数据,通常返回 false 或空实现
@Override
public boolean isFinished() {
// 当前流是否已经到达末尾,通常返回 false
return false;
}
@Override
public boolean isReady() {
// 是否准备好读取数据,由于是内存数据,认为随时准备好
return true; // 应返回 true,表示内存数据随时可读
}
@Override
public void setReadListener(ReadListener readListener) {
// 异步读取监听器,此处为空实现
}
};
}
}
⚠️ 注意事项:
这种将请求体加载到内存 (body 数组) 的方式,不适用于处理超大文件上传。它最适合用于 JSON/XML/Form 等普通 API 请求,以避免 内存溢出(OOM) 风险。
三、Repeatable 过滤器:按需包装 Request
过滤器(Filter)位于 Servlet 容器层面,是包裹 Request 的最佳位置。RepeatableFilter 负责判断请求类型,并决定是否将原始 HttpServletRequest 替换为我们的 RepeatedlyRequestWrapper。
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
// 假设 StringUtils 为自定义或 Apache Commons 工具类,MediaType 为 Spring 定义
// import org.springframework.http.MediaType;
// import org.apache.commons.lang3.StringUtils;
/**
* @类说明:用于处理可重复读取请求体的过滤器(Filter)。
* 它负责判断请求类型,并在需要时将原始的 HttpServletRequest 替换为
* RepeatedlyRequestWrapper,以保证后续组件(如日志、AOP、Controller)可以重复读取请求体。
*
* @author ruoyi
*/
public class RepeatableFilter implements Filter {
/**
* @方法说明:过滤器初始化方法,通常在此处加载配置。
*
* @param filterConfig 过滤器配置对象。
* @throws ServletException
*/
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 无需特殊初始化,保持空实现
}
/**
* @方法说明:核心过滤逻辑。判断请求是否满足包装条件,并执行包装或放行。
*
* @param request 原始的 ServletRequest。
* @param response 原始的 ServletResponse。
* @param chain 过滤器链。
* @throws IOException
* @throws ServletException
*/
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 用于存储包装后的 Request 对象
ServletRequest requestWrapper = null;
// 判断是否满足包装条件:
// 1. 请求必须是 HttpServletRequest
// 2. Content-Type 必须是 application/json (或其他需要读取请求体的类型)
if (request instanceof HttpServletRequest) {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String contentType = httpServletRequest.getContentType();
// 检查 Content-Type 是否以 "application/json" 开头 (忽略大小写)
// 适用于 application/json;charset=UTF-8 等情况
if (StringUtils.startsWithIgnoreCase(contentType, MediaType.APPLICATION_JSON_VALUE)) {
// 满足条件:创建 RepeatedlyRequestWrapper 实例,进行 Request 包装。
// 包装过程中,请求体数据会被读取并缓存到内存中。
requestWrapper = new RepeatedlyRequestWrapper(httpServletRequest, response);
}
}
// 判断是否进行了 Request 包装:
if (null == requestWrapper) {
// 没有包装(不满足条件,如 GET 请求或非 JSON Content-Type):
// 将原始 request 传递给过滤器链的下一个组件。
chain.doFilter(request, response);
} else {
// 已包装(Content-Type 是 JSON):
// 将包装后的 requestWrapper 传递给过滤器链的下一个组件 (包括 Controller/Servlet)。
// 后续所有组件调用 getInputStream() 或 getReader() 时,都将获取到可重复读取的流。
chain.doFilter(requestWrapper, response);
}
}
/**
* @方法说明:过滤器销毁方法,通常用于资源释放。
*/
@Override
public void destroy() {
// 无需特殊资源释放,保持空实现
}
}
四、Web 调用时间统计拦截器
HandlerInterceptor 是 Spring MVC 专用的拦截器,工作在请求分发(DispatcherServlet)之后。我们利用它在请求开始(preHandle)时记录请求参数并启动计时,在请求结束(afterCompletion)时停止计时、计算耗时并清理资源。
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.io.IoUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
// 假设 StopWatch, JsonUtils, StringUtils 为自定义或常用工具类
// import org.springframework.util.StopWatch;
// import com.ruoyi.common.utils.JsonUtils;
// import org.apache.commons.lang3.StringUtils;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.BufferedReader;
import java.util.Map;
/**
* @类说明:Web 调用时间统计拦截器。
* 实现了 Spring 的 HandlerInterceptor 接口,用于在请求处理前后记录日志、统计调用时间,
* 并安全地打印请求参数(兼容普通参数和 JSON 请求体)。
*
* @author Lion Li
* @since 3.3.0
*/
@Slf4j
public class PlusWebInvokeTimeInterceptor implements HandlerInterceptor {
/**
* @字段说明:使用 ThreadLocal 存储 StopWatch 实例,确保每个请求线程的计时器是隔离且独立的,避免线程安全问题。
*/
private final static ThreadLocal<StopWatch> KEY_CACHE = new ThreadLocal<>();
/**
* @方法说明:请求预处理方法,在 Controller 方法执行前调用。
* 主要职责:记录请求日志、获取请求参数、初始化并启动计时器。
*
* @param request 当前的 HTTP 请求对象。
* @param response 当前的 HTTP 响应对象。
* @param handler 目标处理方法(Controller)。
* @return 始终返回 true,表示继续执行后续拦截器和目标 Controller。
* @throws Exception
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 构建请求标识:HTTP方法 + URI,例如 "GET /api/user/info"
String url = request.getMethod() + " " + request.getRequestURI();
// 打印请求参数的逻辑开始
String contentType = request.getContentType();
// 1. 判断本次请求是否为 JSON 类型 (如 Content-Type: application/json)
if (isJsonRequest(request)) {
String jsonParam = "";
// 判断 request 对象是否是我们自定义的 RepeatedlyRequestWrapper 实例
if (request instanceof RepeatedlyRequestWrapper) {
// 如果是 RepeatedlyRequestWrapper,说明请求体已被缓存,可以安全地再次读取
// 获取可重复读取的字符流
BufferedReader reader = request.getReader();
// 读取整个请求体内容
jsonParam = IoUtil.read(reader);
}
// 打印 JSON 请求日志
log.info("[PLUS]开始请求 => URL[{}],参数类型[json],参数:[{}]", url, jsonParam);
} else {
// 2. 处理非 JSON 请求(如普通 Form 表单参数、GET 请求参数)
Map<String, String[]> parameterMap = request.getParameterMap();
// 如果参数不为空
if (MapUtil.isNotEmpty(parameterMap)) {
// 将参数 Map 转换为 JSON 字符串以便于日志记录
String parameters = JsonUtils.toJsonString(parameterMap);
log.info("[PLUS]开始请求 => URL[{}],参数类型[param],参数:[{}]", url, parameters);
} else {
// 请求不包含任何参数
log.info("[PLUS]开始请求 => URL[{}],无参数", url);
}
}
// 初始化并启动计时器 StopWatch,用于统计请求处理时间
StopWatch stopWatch = new StopWatch();
// 将 StopWatch 实例绑定到当前线程
KEY_CACHE.set(stopWatch);
stopWatch.start();
// 返回 true,继续执行后续的 Controller 方法
return true;
}
/**
* @方法说明:请求处理后方法,在 Controller 方法执行后、视图渲染前调用。
* 在调用时间统计中通常不需要特殊处理。
*
* @param request 当前的 HTTP 请求对象。
* @param response 当前的 HTTP 响应对象。
* @param handler 目标处理方法。
* @param modelAndView Controller 返回的 ModelAndView (如果是非 @ResponseBody 方法)。
* @throws Exception
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 保持空实现
}
/**
* @方法说明:请求完成方法,在 DispatcherServlet 渲染视图后或发生异常时调用。
* 主要职责:停止计时器、计算耗时、记录日志并清理 ThreadLocal。
*
* @param request 当前的 HTTP 请求对象。
* @param response 当前的 HTTP 响应对象。
* @param handler 目标处理方法。
* @param ex 如果处理过程中发生异常,则为异常对象。
* @throws Exception
*/
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 从当前线程获取计时器
StopWatch stopWatch = KEY_CACHE.get();
// 确保计时器对象存在
if (ObjectUtil.isNotNull(stopWatch) && stopWatch.isRunning()) {
stopWatch.stop();
// 记录请求 URL 和总耗时
log.info("[PLUS]结束请求 => URL[{}],耗时:[{}]毫秒", request.getMethod() + " " + request.getRequestURI(), stopWatch.getTotalTimeMillis());
// 关键步骤:移除 ThreadLocal 中的变量,防止内存泄露
KEY_CACHE.remove();
}
}
/**
* @方法说明:判断本次请求的 Content-Type 是否为 application/json 类型。
*
* @param request 当前的 HTTP 请求对象。
* @return boolean 如果 Content-Type 以 application/json 开头则返回 true。
*/
private boolean isJsonRequest(HttpServletRequest request) {
String contentType = request.getContentType();
// 判断 Content-Type 是否为空
if (contentType != null) {
// 忽略大小写判断是否以 "application/json" 开头
return StringUtils.startsWithIgnoreCase(contentType, MediaType.APPLICATION_JSON_VALUE);
}
return false;
}
}
1006

被折叠的 条评论
为什么被折叠?



