Filter、Interceptor 与请求上下文:横切逻辑应落在哪一层
Filter 看见 Servlet 请求与响应,Interceptor 看见 MVC handler;两者既不是上下级替代关系,也不能依靠 ThreadLocal 自动跨越异步线程。入口规范化、trace 建立、handler 元数据检查与完成清理若放错层,正常请求可能工作,ERROR 或 ASYNC 分派却会重复执行或丢失上下文。
Filter 和 Interceptor 不要混用
Filter 和 Interceptor 都能拦请求,但位置不同。
Filter:Servlet 规范,DispatcherServlet 之前,适合 traceId、编码、CORS、请求包装、低层安全过滤
Interceptor:Spring MVC,Controller 前后,适合登录态、业务权限、接口日志、灰度标识、HandlerMethod 级判断三者的边界可以按请求位置判断:
这张图适合做设计评审。traceId、请求体缓存、底层 CORS 这类必须在 MVC 之前完成的事情放 Filter;依赖 HandlerMethod 的接口日志、轻量业务上下文放 Interceptor;参数校验、业务异常和内置 MVC 异常统一交给 @RestControllerAdvice。如果异常发生在 Filter、网关或 Spring Security 过滤链前段,不能指望普通 ControllerAdvice 兜住,要在对应层单独处理错误响应和 traceId。
用 Filter 生成 traceId
package com.example.order.web;
import java.io.IOException;
import java.util.UUID;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.web.filter.OncePerRequestFilter;
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID = "traceId";
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String traceId = request.getHeader("X-Request-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID, traceId);
response.setHeader("X-Request-Id", traceId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID);
}
}
}注册:
package com.example.order.web;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class WebFilterConfig {
@Bean
FilterRegistrationBean<TraceIdFilter> traceIdFilter() {
FilterRegistrationBean<TraceIdFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new TraceIdFilter());
registration.addUrlPatterns("/*");
registration.setOrder(1);
return registration;
}
}验证:
curl -i http://127.0.0.1:8080/api/v1/orders/1001 -H 'X-Request-Id: demo-001'应该看到响应头:
X-Request-Id: demo-001用 Interceptor 做接口日志
package com.example.order.web;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
public class ApiAccessLogInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ApiAccessLogInterceptor.class);
private static final String START_TIME = "startTime";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request.setAttribute(START_TIME, System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(
HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex
) {
Object start = request.getAttribute(START_TIME);
long cost = start instanceof Long startTime ? System.currentTimeMillis() - startTime : -1;
String handlerName = handler instanceof HandlerMethod method
? method.getBeanType().getSimpleName() + "#" + method.getMethod().getName()
: String.valueOf(handler);
log.info("api access method={} uri={} status={} costMs={} handler={} traceId={}",
request.getMethod(),
request.getRequestURI(),
response.getStatus(),
cost,
handlerName,
MDC.get("traceId"));
}
}注册:
package com.example.order.web;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new ApiAccessLogInterceptor())
.addPathPatterns("/api/**")
.excludePathPatterns("/actuator/**");
}
}注意不要随便加 @EnableWebMvc。在 Spring Boot 项目里,通常实现 WebMvcConfigurer 就能扩展 MVC 配置;加了 @EnableWebMvc 会接管大量 Boot 自动配置,可能让消息转换器、静态资源、默认错误处理出现变化。
Filter 和 Interceptor 的选型:
| 需求 | 建议 |
|---|---|
| traceId、请求包装、读取原始请求、低层 CORS | Filter |
| 统计 Controller 耗时、读取 HandlerMethod、业务权限 | Interceptor |
| 登录认证、授权、CSRF、密码策略 | Spring Security,不要自己在 Interceptor 里造完整安全体系 |
| API 限流、灰度、网关鉴权 | 优先网关或基础设施,应用内只做兜底 |
生产化建议
接口命名:
路径用名词,不用动词堆满路径
版本放路径或官方版本机制里,不要藏在业务参数里
分页参数统一 page / size 或 cursor
时间字段统一 ISO-8601,明确时区
金额用 BigDecimal,不要用 double响应治理:
成功响应结构稳定
错误响应结构稳定
错误码有归属和文档
HTTP 状态码不伪装
traceId 必须出现在响应头和日志里安全边界:
Filter 可以做 traceId 和底层包装
Interceptor 可以做轻量业务上下文
认证授权优先交给 Spring Security 或网关
Controller 不直接信任 userId、tenantId 这类敏感参数性能边界:
上传大小要限制
请求体大小要限制
分页 size 要设上限
外部调用要有超时
批量接口要有数量上限日志边界:
不要打印完整请求体里的敏感信息
不要在高 QPS 接口打印大 JSON
错误日志要带 traceId、接口、错误码
慢接口要记录耗时和关键依赖耗时用可运行模型固定边界
两个模型不依赖 Spring 运行时,而是把策略选择和状态迁移压缩成 Java 17 程序。它们不是框架源码替身;价值在于先固定不变量,再回到真实应用观察哪个扩展点破坏了不变量。
javac --release 17 -Xlint:all -Werror examples/backend-development/spring-mvc/filter-interceptor-context/FilterInterceptorOrderDemo.java examples/backend-development/spring-mvc/filter-interceptor-context/AsyncContextPropagationDemo.java
java -cp examples/backend-development/spring-mvc/filter-interceptor-context FilterInterceptorOrderDemo
java -cp examples/backend-development/spring-mvc/filter-interceptor-context AsyncContextPropagationDemoevents=[filter.before, interceptor.preHandle, controller, interceptor.postHandle, interceptor.afterCompletion, filter.after]
requestThreadCleared=true worker.traceId=t-9 worker.tenant=north第一条输出验证正常路径,第二条刻意暴露分支或失败路径。把这两条输出放进持续集成,能防止重构只保持“接口能返回”,却改变匹配优先级、清理时机或错误契约。
状态图强调“选择先于执行、提交限制补救、所有分支最终清理”。真实框架包含更多策略对象,但任何自定义扩展都不应打破这三个约束。
把故障定位到第一个发生偏差的阶段
生产观测要控制基数。handler 模板可以作为指标标签,原始 URL、用户 ID、游标和异常消息不能直接成为标签。细节进入带采样和脱敏的日志或 trace;指标只回答哪一阶段、哪类结果、耗时分布是否偏离基线。
建立可回归的完成标准
当一个问题出现时,先判断它属于 Servlet 入口、MVC 策略选择、业务 handler 还是响应输出,再选择证据和修复点。这样扩展 Spring MVC 时增加的是明确策略,而不是更多互相覆盖的全局钩子。
