同一个需求——"给所有请求加个 traceId"——用 Filter 和 HandlerInterceptor 都能实现,效果却不同:前者能包装请求体,后者能读到 HandlerMethod 上的注解;前者在 DispatcherServlet 之外,后者在它之内。
选错地方代码能跑,但会缺东西:拿不到方法参数、拿不到注解、清理不执行、异步请求里跑两遍。
1. 两道关卡在链上的位置
一次请求进 Tomcat 之后的完整顺序:
| 阶段 | 组件 | 能做什么 |
|---|---|---|
| 1 | FilterChain 中的各 Filter |
包装/替换 ServletRequest,改编码、改 header、短路响应 |
| 3 | HandlerMapping |
选 handler,确定 HandlerExecutionChain |
| 4 | Interceptor.preHandle |
拿 HandlerMethod 判权限、埋 traceId |
| 5 | HandlerAdapter + 参数解析 + 消息转换 |
真正调用 controller 方法 |
| 6 | Interceptor.postHandle → 视图渲染 / 写响应 |
序列化 JSON |
| 8 | Interceptor.afterCompletion |
无论成功失败都要做的清理 |
| 9 | Filter 的 doFilter 后半段 |
记录总耗时、还原 MDC |
两个关键事实:Filter 在 DispatcherServlet 之外,所以它看不到"哪个方法被调用";Interceptor 由 HandlerMapping 装配,所以它能拿到 HandlerMethod——方法、类、注解、参数类型全在手上。
| 维度 | Filter |
HandlerInterceptor |
|---|---|---|
| 归属 | Servlet(jakarta.servlet) |
Spring MVC |
| 注册方式 | FilterRegistrationBean、@Bean Filter、web.xml |
WebMvcConfigurer.addInterceptors |
| 顺序控制 | @Order / Ordered / 注册顺序 |
注册顺序(registry.addInterceptor 的调用次序) |
| 能否拿到 handler | 否 | 是,HandlerMethod |
| 改写请求/响应体 | 能,包装 HttpServletRequestWrapper |
不能,只能读与设 attribute |
| 能否作用于静态资源 | 能,在 MVC 之前 | 取决于 HandlerMapping |
异常能否被 @ControllerAdvice 处理 |
不能(在 MVC 之外) | 能,交给 HandlerExceptionResolver |
| 典型职责 | 编码、CORS、请求包装、压缩、审计 | 鉴权、注解解析、日志、权限校验 |
2. Filter:三个方法与放行语义
@Order(Ordered.HIGHEST_PRECEDENCE)
@Component
public class TraceFilter implements Filter {
@Override public void init(FilterConfig cfg) { } // 容器启动时一次
@Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
long t0 = System.nanoTime();
try {
chain.doFilter(req, res); // 放行:交给下一个 Filter 或 Servlet
} finally {
log.info("cost={}ms", (System.nanoTime() - t0) / 1_000_000);
}
}
@Override public void destroy() { }
}
chain.doFilter 是唯一放行入口。忘了调用,请求静默回空响应、状态码 200——最容易被误判为"接口返回空"。
| 写法 | 结果 |
|---|---|
调 chain.doFilter 且不写响应 |
正常放行 |
不调 chain.doFilter,自己写完响应 |
请求被拦截,后续 Filter 与 Servlet 都不执行 |
不调 chain.doFilter,也不写响应 |
客户端收到空 200,链路静默中断 |
调两次 chain.doFilter |
下游被执行两次,常见于转发场景 |
顺序有两种来源:
// 方式一:@Bean 注册,order 显式指定
@Bean
public FilterRegistrationBean<TraceFilter> traceFilter() {
FilterRegistrationBean<TraceFilter> bean = new FilterRegistrationBean<>(new TraceFilter());
bean.addUrlPatterns("/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
return bean;
}
另一种是让 Filter 被组件扫描(@Component + @Order),顺序缺省为 LOWEST_PRECEDENCE。同一个 Filter 不要两处都注册,否则它会进链两次,@Order 也救不了。
2.1 为什么需要 OncePerRequestFilter
Filter 默认是"每次进入 FilterChain 就执行"。而 forward、include、以及容器的 error dispatch 都会再次进入同一条链,于是同一个请求里 Filter 跑了多遍——埋点翻倍、MDC 被清两次。
public class IdempotentFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
chain.doFilter(req, res); // 同一请求内保证只进来一次
}
@Override protected boolean shouldNotFilterAsyncDispatch() { return false; } // 需要参与异步派发时
}
它的实现是往 request attribute 里写一个标记位,第二次进来直接放行。注意 shouldNotFilterAsyncDispatch() 默认返回 true,即默认不参与异步派发;如果清理逻辑必须在异步线程结束时执行,要显式改回 false。
3. HandlerInterceptor:三个方法的时机
public class AuthInterceptor implements HandlerInterceptor {
@Override public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
if (!(handler instanceof HandlerMethod hm)) return true; // 静态资源等放行
RequireRole anno = hm.getMethodAnnotation(RequireRole.class);
if (anno == null) return true;
if (!hasRole(req, anno.value())) {
res.setStatus(403);
return false; // 中断,后续不再执行
}
return true;
}
@Override public void postHandle(HttpServletRequest req, HttpServletResponse res,
Object handler, ModelAndView mv) { }
@Override public void afterCompletion(HttpServletRequest req, HttpServletResponse res,
Object handler, Exception ex) {
MDC.clear(); // 成功、失败都走这里
}
}
| 方法 | 时机 | 异常时是否执行 | 返回 false 的后果 |
|---|---|---|---|
preHandle |
handler 调用前 | — | 中断请求,后续 preHandle/postHandle 不执行 |
postHandle |
handler 返回后、渲染前 | 不执行(有异常时跳过) | — |
afterCompletion |
请求完成后 | 执行,ex 非空 |
— |
两个细节:preHandle 返回 false 时,已通过的拦截器仍会收到 afterCompletion;postHandle 在 handler 抛异常时被跳过,所以"必须执行"的清理只能写在 afterCompletion。
注册靠 WebMvcConfigurer,顺序即执行顺序:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new TraceInterceptor()).addPathPatterns("/**").order(1);
registry.addInterceptor(new AuthInterceptor()).addPathPatterns("/api/**")
.excludePathPatterns("/api/public/**").order(2);
}
}
order 不写时按注册顺序;excludePathPatterns 的匹配是 Ant 风格,/api/** 不会自动排除 /api/public/**,必须显式写。
4. 异常与 @ControllerAdvice 的关系
| 抛出位置 | 谁来处理 | 能否命中 @ExceptionHandler |
|---|---|---|
Filter.doFilter |
容器 / ErrorPageFilter |
不能,此时还没进 MVC |
Interceptor.preHandle |
DispatcherServlet.processHandlerException |
能,走 HandlerExceptionResolver 链 |
| controller 方法 | 同上 | 能 |
HttpMessageConverter 序列化 |
同上(部分场景) | 能 |
@RestControllerAdvice
public class GlobalHandler {
@ExceptionHandler(ForbiddenException.class)
public ResponseEntity<String> forbidden(ForbiddenException e) {
return ResponseEntity.status(403).body(e.getMessage());
}
}
结论:想在拦截器里抛业务异常并用全局 advice 统一成 JSON,是可行的;想在 Filter 里这么做,不会生效——它已经在 MVC 之外,需要自己写响应体。
5. 分工建议
| 需求 | 放哪 | 原因 |
|---|---|---|
| 字符编码、请求体解密、gzip | Filter |
需要在 Servlet 之前包装流 |
| CORS 头(统一出口) | Filter 或 Spring CORS 配置,只选一处 |
两处都配会产生重复响应头 |
| 请求体多次读取 | Filter + ContentCachingRequestWrapper |
流只能读一次,必须在最外层缓存 |
| 登录态校验、角色判断 | Interceptor |
需要 HandlerMethod 与注解 |
| 全局异常转 JSON | @RestControllerAdvice |
MVC 内统一处理 |
一句话原则:要动"字节"(流、编码、header)放 Filter,要动"语义"(哪个方法、什么注解、什么角色)放 Interceptor。
6. 常见坑
| 坑 | 现象 | 原因 | 处理 |
|---|---|---|---|
preHandle 写完响应但没 return false |
响应体被拼两遍,或 IllegalStateException |
写了响应仍继续放行 | 写完必须 return false |
| 两处都配 CORS | 报 Multiple values for Access-Control-Allow-Origin |
CorsFilter 与 addCorsMappings 各加一次 |
只留一处 |
OPTIONS 被鉴权拦截 |
预检 403,实际请求根本没发出 | 鉴权拦截器拦了预检 | 放行 OPTIONS,或在 Filter 层先处理 CORS |
异步请求 afterCompletion 触发两次 |
MDC 清理时机混乱 |
异步派发导致拦截器二次执行 | 实现 AsyncHandlerInterceptor,在 afterConcurrentHandlingStarted 里处理 |
Filter 里读不到 HandlerMethod |
强转失败或拿到 null | 位置在 MVC 之外 | 换 Interceptor |
OncePerRequestFilter 没生效 |
转发场景仍执行两次 | 不是 OncePerRequestFilter 子类 |
继承它,别直接实现 Filter |
7. 边界:什么时候不用这两者
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 只想给 controller 方法加统一行为 | @Aspect 切面 |
能拿到方法参数与返回值 |
| 只想改响应体 | ResponseBodyAdvice |
在消息转换层,比包装 response 干净 |
| 需要按注解做权限 | @PreAuthorize(方法级安全) |
现成机制,不必手写拦截器 |
| 需要跨服务传递上下文 | 网关或客户端拦截器 | 客户端侧才是正确位置 |
8. 自测
- 同一个请求里
Filter与HandlerInterceptor谁先执行?afterCompletion与Filter的后半段谁先? preHandle返回false时,已经通过的拦截器还会收到afterCompletion吗?- 为什么
Filter里抛的异常无法被@ControllerAdvice转成 JSON? OncePerRequestFilter解决的是什么重复执行问题?异步派发默认参与吗?- 想读请求体三次,该用
Filter还是Interceptor?为什么? - CORS 同时在
CorsFilter和addCorsMappings配了会发生什么?