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) 内部 时,到底发生了什么:
- 请求分发 :请求进入
DispatcherServlet,路由到具体的Controller。 - 业务执行 :Controller 执行完毕,返回一个 Java 对象(例如
CommonResult)。 - 序列化 (Serialization) :Spring MVC 调用
HttpMessageConverter(如 Jackson),将 Java 对象转换为 JSON 字节数组。 - 写入 Socket (Critical Step) :
- Jackson 调用
response.getOutputStream().write(bytes)。 - Tomcat 容器将这些字节写入 TCP 发送缓冲区。
- 数据通过网络发送给浏览器。
- 流的状态:一旦数据写入,ServletOutputStream 指针前移,数据"流"走了。
- Jackson 调用
当代码执行完上述所有步骤,从 chain.doFilter 返回,进入"阶段 C"时:
- 物理层面:数据已经不在服务器内存中了,它已经变成了网线上的电信号。
- 对象层面 :
response对象此时处于 "Committed"(已提交)状态。它完成了发送任务,内部持有的 OutputStream 已经完成了写入使命。 - API 层面 :
HttpServletResponse是设计为 Write-Only(只写) 的。标准 API 不支持"回读"刚才发出去的内容。
结论:在 Filter 的后置阶段,你手里只有一个"已发送完毕"的空壳句柄,无法获取业务数据。
3. 传统解决方案:Wrapper 模式(昂贵方案)
为了解决"流不可读"的问题,最传统的做法是使用装饰器模式,即 ContentCachingResponseWrapper。
- 原理 :在 Filter 链的最外层,"偷换"掉原始的
response对象。重写getOutputStream()方法,实现双写(Teeing):一份数据写真正的网络流,另一份数据拷贝到本地内存(ByteArrayOutputStream)。 - 弊端:
- 内存翻倍:每个请求的响应体都在内存中完整拷贝了一份。如果是高并发或大文件下载场景,GC 压力巨大。
- 性能损耗 :由于 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),不是字节流。
核心思路(零拷贝传递):
- 在
ResponseBodyAdvice中拦截结果对象。 - 将该对象的引用(Reference) 塞入
HttpServletRequest的属性(Attribute)中。 - 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());
}
}
}