Spring Boot :如何高性能地在 Filter 中获取响应体(Response Body)

1. 背景与痛点

在构建企业级 Web 应用时,全链路操作日志(Access Log) 是核心基础设施。我们需要记录每一个请求的完整生命周期,通常包括:

  • 请求人与请求时间
  • 请求 URL 与参数
  • 执行耗时
  • 响应结果(状态码、错误信息、业务数据)

开发者通常选择使用 Servlet Filter 来实现这一功能,因为它位于请求处理的最外层,能覆盖所有接口。然而,在 Filter 中获取 响应体(Response Body) 却是一个著名的技术难题,如果不理解底层原理,很容易写出 Bug 或造成严重的性能损耗。

2. 核心困难:基于 Filter 执行链的深度剖析

要理解为什么获取响应体这么难,我们需要从 代码执行栈(Call Stack)IO 流(IO Stream) 的角度,对 Filter 的执行过程进行微观拆解。

2.1 标准 Filter 的执行结构

以下是一个典型的日志 Filter 代码。请重点关注 chain.doFilter(request, response) 这行代码,它是整个流程的分水岭。

java 复制代码
@Component
@Order(1)
public class AccessLogFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {

        // ==========================================
        // A. 前置阶段 (Pre-processing)
        // ==========================================
        // 时机:请求刚到达 Tomcat,还没进入 Spring MVC
        long startTime = System.currentTimeMillis();

        // ==========================================
        // B. 执行链 (The Chain Execution) ------ 核心分水岭
        // ==========================================
        // 这是一个【同步阻塞】方法。
        // 控制权从这里移交给 DispatcherServlet,进而进入 Controller。
        // 所有的业务逻辑、SQL查询、JSON序列化、网络发送都在这行代码内部完成。
        chain.doFilter(request, response); 

        // ==========================================
        // C. 后置阶段 (Post-processing)
        // ==========================================
        // 时机:Controller 方法已返回,数据已发给客户端,代码执行流回到此处。
        
        // 【核心困难发生的时刻】
        // 此时,开发者通常希望读取 response 中的 JSON 数据来记录日志
        logResponseDetail(response, startTime); 
    }

    private void logResponseDetail(ServletResponse response, long startTime) {
        HttpServletResponse httpResp = (HttpServletResponse) response;
        
        // ❌ 错误尝试 1:Servlet API 根本没有 getBody() 方法
        // String body = httpResp.getBody(); 
        
        // ❌ 错误尝试 2:试图获取流来读取
        // httpResp.getOutputStream(); // 这是输出流,只能写不能读
        // 且由于数据在阶段 B 已经写完并刷新,流已经关闭或不可逆。
        
        System.out.println("耗时:" + (System.currentTimeMillis() - startTime));
    }
}

2.2 为什么在"阶段 C"读不到数据?

很多开发者认为 response 对象像一个装满数据的盒子,随时可以打开看。但在 Servlet 容器中,response 本质上是对 TCP 网络流 的封装。

让我们详细拆解当代码执行到 阶段 B (chain.doFilter) 内部 时,到底发生了什么:

  1. 请求分发 :请求进入 DispatcherServlet,路由到具体的 Controller
  2. 业务执行 :Controller 执行完毕,返回一个 Java 对象(例如 CommonResult)。
  3. 序列化 (Serialization) :Spring MVC 调用 HttpMessageConverter (如 Jackson),将 Java 对象转换为 JSON 字节数组
  4. 写入 Socket (Critical Step)
    • Jackson 调用 response.getOutputStream().write(bytes)
    • Tomcat 容器将这些字节写入 TCP 发送缓冲区。
    • 数据通过网络发送给浏览器。
    • 流的状态:一旦数据写入,ServletOutputStream 指针前移,数据"流"走了。

当代码执行完上述所有步骤,从 chain.doFilter 返回,进入"阶段 C"时:

  • 物理层面:数据已经不在服务器内存中了,它已经变成了网线上的电信号。
  • 对象层面response 对象此时处于 "Committed"(已提交)状态。它完成了发送任务,内部持有的 OutputStream 已经完成了写入使命。
  • API 层面HttpServletResponse 是设计为 Write-Only(只写) 的。标准 API 不支持"回读"刚才发出去的内容。

结论:在 Filter 的后置阶段,你手里只有一个"已发送完毕"的空壳句柄,无法获取业务数据。


3. 传统解决方案:Wrapper 模式(昂贵方案)

为了解决"流不可读"的问题,最传统的做法是使用装饰器模式,即 ContentCachingResponseWrapper

  • 原理 :在 Filter 链的最外层,"偷换"掉原始的 response 对象。重写 getOutputStream() 方法,实现双写(Teeing):一份数据写真正的网络流,另一份数据拷贝到本地内存(ByteArrayOutputStream)。
  • 弊端
  1. 内存翻倍:每个请求的响应体都在内存中完整拷贝了一份。如果是高并发或大文件下载场景,GC 压力巨大。
  2. 性能损耗 :由于 Filter 拿到的是字节数组,记录日志时通常需要进行二次反序列化(Bytes -> String -> JSON Object),消耗额外的 CPU。

4. 高性能解决方案:ResponseBodyAdvice + Request Attribute

为了避免 IO层面的拷贝,我们可以利用 Spring MVC 的切面机制,在数据 被写入 IO 流之前 截获它。

4.1 方案原理:拦截在"阶段 B"内部

我们不等到"阶段 C"再去亡羊补牢,而是深入到"阶段 B"的内部,利用 ResponseBodyAdvice 接口。

它的 beforeBodyWrite 方法执行时机非常巧妙:

  • 时机:Controller 方法刚刚返回,但 Jackson 还没开始写 Socket。
  • 状态 :此时数据还是 Java 原生对象 (如 CommonResult),不是字节流。

核心思路(零拷贝传递):

  1. ResponseBodyAdvice 中拦截结果对象。
  2. 将该对象的引用(Reference) 塞入 HttpServletRequest 的属性(Attribute)中。
  3. Filter 在后置阶段,直接从 Request 中取出该对象。

4.2 代码实现

第一步:定义拦截器(数据搬运工)

创建一个实现了 ResponseBodyAdvice 的类,用于"窃听"并备份结果。

java 复制代码
@ControllerAdvice
public class GlobalResponseBodyHandler implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        // 优化:只拦截返回类型为标准通用结果(CommonResult)的接口
        // 避免干扰 Swagger、文件下载等接口
        if (returnType.getMethod() == null) {
            return false;
        }
        return returnType.getMethod().getReturnType() == CommonResult.class;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType,
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType,
                                  ServerHttpRequest request, ServerHttpResponse response) {
        // 【核心动作】
        // 仅仅是把对象的引用放到了 Request 作用域中
        // 开销极低(Map.put 操作),没有流的读写,没有序列化
        if (request instanceof ServletServerHttpRequest) {
            HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
            servletRequest.setAttribute("COMMON_RESULT_ATTRIBUTE", body);
        }
        
        // 原封不动返回 body,让 Spring MVC 继续它的序列化工作
        return body;
    }
}
第二步:在 Filter 中读取结果

此时,Filter 的后置逻辑变得非常简单且高效。

java 复制代码
@Component
public class AccessLogFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        
        // B. 执行链
        chain.doFilter(request, response); 
        
        // C. 后置阶段
        // 直接从 Request 域中获取 Java 对象,避开了 Response 流不可读的问题
        Object resultObject = request.getAttribute("COMMON_RESULT_ATTRIBUTE");
        
        if (resultObject instanceof CommonResult) {
            CommonResult<?> result = (CommonResult<?>) resultObject;
            // 直接读取字段,无需反序列化,无需 IO 操作
            log.info("业务结果码:{},提示信息:{}", result.getCode(), result.getMsg());
        }
    }
}
相关推荐
曹牧3 小时前
文档格式:OFD
java
豆角焖肉3 小时前
Maven进阶与搭建私服
java·maven
大不点wow4 小时前
Java序列化与反序列化:让对象走出JVM
java·开发语言·jvm
止语Lab4 小时前
好的 DX 不等于少写代码——三种语言的摩擦力设计课
后端
吃饱了得干活4 小时前
别再手动解析 LLM 输出了!LangChain 四种结构化输出方案对比
后端·python·langchain
噢,我明白了4 小时前
Java中日期和字符串的处理
java·开发语言·日期
程序员天天困4 小时前
Arthas trace 命令怎么用?一行定位最慢那行代码
jvm·后端
Huiturn4 小时前
GPT 5.6 连续编码 10 小时,纯 Python 啃下 Word 二进制格式——doc2docx 实现拆解
后端
dkbnull4 小时前
Spring Boot请求处理组件对比详解
java·spring boot
jun_bai4 小时前
Orthanc服务器使用java上传dicom影像文件
java·运维·服务器