2318 字
约 7 分钟
14
S07-A16 · Filter 与 Interceptor 的顺序:谁先谁后,谁能拿到什么

一个鉴权需求:校验 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. 自测

  1. Filter 与 Interceptor 的位置差别决定了哪些能力差异?为什么"按接口做权限"只能在 Interceptor 里?
  2. 完整执行链条里,参数解析发生在哪一步之后?这对"在 preHandle 里做参数校验"意味着什么?
  3. 为什么 postHandle 在异常时不会执行?"统一头没加上"该怎么修?
  4. 多 Filter 不显式声明顺序会有什么隐患?为什么安全相关顺序必须显式?
  5. 想在异常时也做清理,只能放在哪两个位置?
  6. 判断"用 Filter 还是 Interceptor"的那个问题是什么?
S07-A16 · Filter 与 Interceptor 的顺序:谁先谁后,谁能拿到什么
https://www.clxhxhhr.top/posts/6494/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。