一个鉴权需求:校验 token、记录耗时、给响应加统一头。三个需求分给了 Filter 与 Interceptor 两个机制,写完发现"耗时日志比鉴权先执行"、"统一头没加在错误响应上"。原因不是代码错,而是两个机制的生效顺序与作用范围不同,而没人把它们画在一张图上。
Filter 属于 Servlet 容器,Interceptor 属于框架。还有一层容易忽略:Filter 的生效范围是整个 Web 容器(静态资源、错误页都在内),而 Interceptor 只管 MVC 的 Handler。所以"给所有响应加头"只能靠 Filter,"只给 API 加"用 Interceptor 更精确。
前者在框架之外,后者在框架之内——这个位置差别决定了它们能拿到什么、能改什么、什么时候执行。
1. 两者的位置与能力
| 维度 | Filter | Interceptor |
|---|---|---|
| 归属 | Servlet 容器 | Spring MVC |
| 拿到 Handler | 否 | 是(HandlerMethod) |
| 拿到 Controller 参数 | 否 | 否(参数在 HandlerAdapter 里解析) |
| 能否改响应体 | 需包装(ContentCachingResponseWrapper) |
否(只能改 ModelAndView) |
| 异常传播 | 容器层,可能看不到框架异常 | 走 HandlerExceptionResolver |
第三项(Controller 参数)两者都拿不到,因为参数在 HandlerAdapter 里解析——这一步发生在 preHandle 之后。所以想在"进入方法前"看到解析好的参数,只能用 AOP 环绕 Controller 方法,而不是拦截器。
第二项是关键差异:Interceptor 知道"这次请求会打到哪个方法",所以"按接口做权限"只能在 Interceptor 里做;Filter 看不到 Handler。
第四项常被误解:想统一改响应体(如加密、脱敏)需要 Filter + 包装器,Interceptor 的 postHandle 拿不到已经写出的响应。
2. 执行顺序的完整链条
| 阶段 | 执行体 |
|---|---|
| 1 | Filter.doFilter(前置) |
| 2 | DispatcherServlet 分发 |
| 3 | HandlerInterceptor.preHandle |
| 4 | HandlerAdapter 解析参数、调用 Controller |
| 5 | Interceptor.postHandle |
| 6 | 渲染视图 / 写响应体 |
| 7 | Interceptor.afterCompletion |
| 8 | Filter(后置) |
注意第 4 步在 preHandle 之后:参数解析发生在 preHandle 之后,所以"在 Interceptor 里做参数校验"必须用 AOP 或 @Valid,不能用 preHandle。
这也解释了"耗时统计总是偏短":如果只用 preHandle 打点、afterCompletion 收尾,异常请求会被计入;而如果用 postHandle 收尾,异常请求就完全不计——于是 P99 看起来比真实更好。
第 7 步在异常情况下也会执行(若 preHandle 返回 true),而 postHandle 在 Controller 抛异常时不会执行——这是"统一头没加上"的最常见原因。
// Filter:容器层,先于一切
@Component
public class TraceFilter extends OncePerRequestFilter {
@Override protected void doFilterInternal(req, res, chain) {
long t0 = System.nanoTime();
try { chain.doFilter(req, res); }
finally { log.info("cost={}ms path={}", (System.nanoTime()-t0)/1_000_000, req.getRequestURI()); }
// finally 保证异常路径也记录耗时
}
}
// Interceptor:框架层,能看到 Handler
public class AuthInterceptor implements HandlerInterceptor {
public boolean preHandle(req, res, handler) {
if (handler instanceof HandlerMethod hm) { // 只有这里能拿到方法
if (hm.hasMethodAnnotation(RequireRole.class) && !hasRole(req)) { res.setStatus(403); return false; }
}
return true;
}
}
3. 三个典型需求的归属
| 需求 | 应放在 |
|---|---|
| 统一耗时日志(含异常路径) | Filter(用 finally) |
| 按接口做权限 | Interceptor(要 HandlerMethod) |
| 统一响应头(含错误响应) | Filter(写响应前) |
| 参数校验 | @Valid / AOP |
| 事务 | AOP(@Transactional) |
第二项(按接口做权限)还有一个能力边界:Interceptor 能读到方法上的注解,所以"用注解声明权限"是最自然的设计;而 Filter 只能读 URI,于是权限规则会退化成路径字符串匹配。
第三项要特别注意:错误响应往往由 @ControllerAdvice 生成,此时 Interceptor 已经退出,所以只有 Filter 能在"响应写出前"加头。
// 统一响应头:Filter 在 chain 之后、响应提交前加
@Override protected void doFilterInternal(req, res, chain) throws ... {
res.setHeader("X-Trace-Id", currentTraceId()); // 前置加,最稳
chain.doFilter(req, res);
// 后置改头可能已提交,所以关键头要在前置加
}
4. 多 Filter 的顺序怎么定
| 手段 | 说明 |
|---|---|
@Order / Ordered |
数值小的先执行 |
FilterRegistrationBean.setOrder |
显式注册顺序 |
| 注册顺序(无注解时) | 不稳定,别依赖 |
第三项是隐患:同一容器里多个 Filter 没有显式顺序时,执行顺序取决于注册顺序,而注册顺序在不同版本/不同部署方式下可能变化。
异步请求还有一个特例:AsyncHandlerInterceptor 的 afterConcurrentHandlingStarted 在异步开始时调用,而 postHandle 与 afterCompletion 在异步完成后才调用。所以在异步接口上,"请求结束"与"响应写完"不是同一个时刻。
// 异步接口的耗时统计:要覆盖到异步完成
public class AsyncTraceInterceptor extends AsyncHandlerInterceptor {
@Override public void afterConcurrentHandlingStarted(req, res, handler) {
// 此时请求线程已释放,Servlet 容器认为请求"暂时结束"
}
@Override public void afterCompletion(req, res, handler, ex) {
// 异步完成后才到这里,耗时统计应在这里收尾
}
}
所以规则很简单:只要顺序重要,就必须显式声明。尤其是"鉴权 Filter 必须在业务 Filter 之前"这类安全相关顺序。
// 显式顺序:数值越小越靠前
@Bean FilterRegistrationBean<AuthFilter> auth() {
var r = new FilterRegistrationBean<>(new AuthFilter());
r.setOrder(1); // 鉴权最前
return r;
}
@Bean FilterRegistrationBean<TraceFilter> trace() {
var r = new FilterRegistrationBean<>(new TraceFilter());
r.setOrder(10); // 追踪次之
return r;
}
5. 异常路径的行为差异
| 机制 | 异常时是否执行收尾 |
|---|---|
Filter 的 finally |
是 |
Interceptor.postHandle |
否 |
Interceptor.afterCompletion |
是(preHandle 返回 true 时) |
@ControllerAdvice |
是(处理异常本身) |
第二项与第三项的差别是排障关键:想在异常时也做清理(如释放 ThreadLocal),只能放 afterCompletion 或 Filter 的 finally。
还有个"响应已提交"的陷阱:一旦 response.getWriter() 写过内容并 flush,头就再也改不了。所以"统一加头"必须在写响应之前,而这一位置通常只有 Filter 的前置阶段。
第四项的顺序也要知道:@ControllerAdvice 处理异常发生在 postHandle 之前、afterCompletion 之前,所以"在 advice 里读的上下文"必须在 Filter 里就准备好。
调试顺序最快的手段是"打印一条带序号的行":在每一处入口与出口都打一行同格式日志,跑一次请求就能看到真实顺序。
// 一次请求的真实顺序(探针日志)
[t=0] filter.auth.pre
[t=1] filter.trace.pre
[t=2] interceptor.preHandle
[t=3] controller.enter
[t=4] controller.exit
[t=5] interceptor.postHandle
[t=6] filter.trace.post
[t=7] filter.auth.post
// 若日志顺序与此不同 → 顺序声明有问题;若缺某一行 → 该阶段未执行
6. 排错:五种"顺序/范围不对"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 耗时日志缺异常请求 | 只记在 chain 之后 | 用 finally |
| 权限对某些接口不生效 | 用 Filter 拿不到 Handler | 改 Interceptor |
| 错误响应缺统一头 | 头在 postHandle 加 | 改 Filter 前置加 |
| 参数校验拿不到参数 | 在 preHandle 里取 | 用 @Valid/AOP |
| 某些 Filter 时而先时而后 | 无显式顺序 | 加 setOrder |
第二种很常见:用 Filter 做"按接口"的权限,只能拿到 URI 字符串,于是要做路径匹配,而路径匹配在参数化路由(/order/{id})上极易错。
7. 边界:什么情况只用一个就够
| 场景 | 为什么 | 做法 |
|---|---|---|
| 只有全局预处理(如编码) | 无 Handler 需求 | 只用 Filter |
| 只有 Controller 层逻辑 | 无容器需求 | 只用 Interceptor |
| 网关层已处理 | 应用内无需重复 | 都不加 |
| 一次性小服务 | 复杂度不值 | 选一个简单的 |
最后一条与"测试"有关的边界:Filter 与 Interceptor 的顺序问题几乎无法被单元测试覆盖,因为它依赖容器的装配。可行的验证是集成测试里断言"某个头存在""某个顺序的日志顺序正确",或者用一个明确的探针接口。
判断口径一句话:这段逻辑需不需要知道"这次请求打到哪个方法"。需要,用 Interceptor;只需要"请求/响应"这一层,用 Filter。
8. 自测
- Filter 与 Interceptor 的位置差别决定了哪些能力差异?为什么"按接口做权限"只能在 Interceptor 里?
- 完整执行链条里,参数解析发生在哪一步之后?这对"在 preHandle 里做参数校验"意味着什么?
- 为什么
postHandle在异常时不会执行?"统一头没加上"该怎么修? - 多 Filter 不显式声明顺序会有什么隐患?为什么安全相关顺序必须显式?
- 想在异常时也做清理,只能放在哪两个位置?
- 判断"用 Filter 还是 Interceptor"的那个问题是什么?