Spring Web 请求封装 + 拦截器统计耗时

Spring Web 请求封装 + 拦截器统计耗时

一、整体目的与背景

在 Java Servlet 规范中,HttpServletRequest 的输入流(InputStream)设计为 只能被顺序读取一次。如果在 Spring MVC 之前的组件(如 FilterInterceptor)中读取了请求体用于日志记录或参数校验,那么后续的 Controller 将无法再次读取到请求体数据,导致业务层获取请求参数失败(如 @RequestBody 失效)。

解决方案核心思路

通过 装饰器模式,在请求进入 Spring MVC 核心流程前,用一个可重复读取的包装类替换原始请求对象:

  1. Filter (Repeater): 在请求进入 Spring MVC 前,拦截并判断是否为需要重复读取的请求(如 JSON 请求)。
  2. Request Wrapper (RepeatedlyRequestWrapper): 将原始 InputStream 的内容 一次性完整读取到内存中的 byte[] 数组,并重写 getInputStream()getReader() 方法,使其每次调用都基于内存数据创建一个新的可读流。
  3. 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;
    }
}
<think>嗯,用户这次的核心问题是询问HTTP请求拦截器的概念。看来用户对网络请求有一定了解,可能需要一个形象化的解释和结构化的功能梳理。 从用户提供的多个引用来看,ta应该接触过不同技术栈的拦截器实现(Flutter/Dio、Axios、Spring),这暗示用户可能有全栈开发背景。引用中提到的401/403状态码处理和InputStream重用问题,说明用户可能在实际开发中遇到过相关痛点。 需要特别注意用户引用了老王和小王的Dio封装故事,这个生活化案例值得借鉴。我决定采用“请求生命周期”的比喻来解释,这样既能覆盖所有引用中的技术点(请求头修改、异常处理、权限验证),又能避免过于抽象。 技术上要强调三个要点:一是拦截器的可插拔特性(呼应引用中的自定义拦截器),二是数据流不可逆问题(针对引用4的InputStream痛点),三是分层拦截思想(引用1提到的多种应用场景)。最后用“流水线质检员”的类比收尾,这样用户下次看到拦截器代码时能立刻联想到这个画面。 用户似乎更关注概念而非具体实现,所以响应中避免展示代码片段,但保留了缓存、Cookie管理等典型场景说明。最后补充的拦截器层级问题,其实是预判用户后续可能遇到的困惑——为什么前端拦截器修改headers后端收不到?这个点值得提前铺垫。 </think>HTTP请求中的拦截器(Interceptor)是一种**在请求发送前或响应返回后统一处理逻辑的中间件机制**。它像流水线上的“质检员”和“加工员”,在请求生命周期中拦截数据流,实现通用功能的解耦和复用。以下是其核心要点: --- ### **一、拦截器的工作原理** 1. **请求拦截** 在请求发送到服务器前拦截,可修改请求配置(如添加头信息、重写URL)。 **典型场景**: - 自动添加身份令牌(如`Authorization: Bearer xxx`)[^1][^2] - 加密敏感参数 - 压缩请求体 2. **响应拦截** 在接收到响应后、业务代码处理前拦截,可预处理响应数据或错误。 **典型场景**: - 统一处理错误状态码(如401跳转登录页,403提示无权限)[^1][^3] - 格式化响应数据结构 - 缓存接口数据(如使用`dio-http-cache`)[^1] 3. **错误拦截** 捕获请求过程中的异常(如网络断开、超时),避免代码冗余。 **示例**: ```javascript // Axios错误拦截 axios.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { router.push('/login'); // 跳转登录页 } return Promise.reject(error); } ); ``` [^3] --- ### **二、拦截器的核心价值** 1. **功能解耦** 将鉴权、日志、缓存等横切关注点与业务逻辑分离,提升代码可维护性。 2. **全局控制** 统一处理所有请求/响应,避免重复代码(如每个API调用都写一遍错误处理)。 3. **灵活扩展** 支持自定义拦截器(如引用接口调用生成Postman文档)[^1]。 --- ### **三、不同技术栈的实现差异** | **场景** | **前端(Axios/Dio)** | **后端(Spring)** | |------------------------|---------------------------------------------|------------------------------------------| | **触发时机** | 客户端发出请求前/收到响应后 | 请求到达Controller前/响应返回客户端前 | | **参数访问** | 直接修改请求配置对象(`config`) | 需从`HttpServletRequest`读取原始流[^4] | | **关键问题** | 异步处理(Promise) | 输入流不可重用(需封装`HttpServletRequestWrapper`)[^4] | > 💡 **后端拦截器注意事项**: > 由于HTTP请求的输入流(`InputStream`)只能读取一次,后端需通过过滤器(`Filter`)封装请求对象,保存流数据供后续多次读取[^4]。 --- ### **四、典型应用场景** 1. **权限管理** 请求拦截:自动注入`token`;响应拦截:处理401/403状态码[^1]。 2. **接口缓存** 响应拦截:存储特定接口数据,后续请求直接返回缓存(如`dio-http-cache`)[^1]。 3. **自动化流程** - 请求日志上报(如记录请求耗时) - 自动管理`Cookie`(如`cookie_manager`)[^1] - 接口文档生成(如`postman_dio`转换请求为Postman格式)[^1] --- ### **总结** 拦截器的本质是**HTTP请求生命周期的钩子函数**,通过分层处理实现“一次编写,全局生效”。前端拦截器侧重便捷性(如修改`headers`),后端拦截器需关注底层流处理(如请求体重用)。合理使用可显著提升开发效率,减少冗余代码[^1][^3][^4]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值