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());
        }
    }
}
相关推荐
sg_knight2 小时前
抽象工厂模式(Abstract Factory)
java·python·设计模式·抽象工厂模式·开发
春日见2 小时前
win11 分屏设置
java·开发语言·驱动开发·docker·单例模式·计算机外设
2301_780029042 小时前
支付宝sdk导入错误
java·开发语言·maven
码界奇点2 小时前
基于Spring Boot和Vue3的无头内容管理系统设计与实现
java·spring boot·后端·vue·毕业设计·源代码管理
九皇叔叔3 小时前
【03】微服务系列 之Nacos 注册中心(服务注册)
java·微服务·nacos·架构·注册中心·服务注册
木辰風3 小时前
PLSQL自定义自动替换(AutoReplace)
java·数据库·sql
heartbeat..3 小时前
Redis 中的锁:核心实现、类型与最佳实践
java·数据库·redis·缓存·并发
3 小时前
java关于内部类
java·开发语言
好好沉淀3 小时前
Java 项目中的 .idea 与 target 文件夹
java·开发语言·intellij-idea