第 22 集:Filter、Interceptor、AOP 都能拦截,为什么不能互换?

资深 Java 面试到 Agent 开发工程师 · 第 22 集

同样是"调用前检查,调用后清理",把代码放进 Filter、MVC 拦截器或 AOP,覆盖的调用却不一样:Filter 围住匹配的 Servlet 调用,Interceptor 围住 MVC 选中的处理器,AOP 围住经过 Spring 代理的方法调用。

位置也决定失败后谁来收尾。MVC 拦截器在 preHandle() 中放入线程上下文,接着返回 false,它自己的 afterCompletion() 不会执行;Service 从定时任务被调用,MVC 拦截器不会参与,服务代理上的 AOP 仍可以执行。

因此,选择哪一种,要看检查需要哪些信息、必须覆盖哪些入口,以及资源在哪一步释放。

一个请求经过三处拦截,谁在外面?

以同步请求 GET /orders/42 为例。Servlet 容器根据 URL、Servlet 名称、分派类型等注册信息,选出适用的 Filter。Filter 调用 chain.doFilter(),请求才继续进入后面的 Filter 或 Servlet。

如果目标 Servlet 是 Spring MVC 的 DispatcherServlet,它再找处理器。普通注解接口中,处理器描述的是某个 Controller 方法;与它匹配的 HandlerInterceptor 随执行链一起返回。所有 preHandle() 放行后,适配器才准备参数并调用方法。

Controller 再调用注入的 Service。这个对象如果是 Spring AOP 代理,且方法匹配切点,调用才会进入对应的通知链。

图中 AOP 在 Service 上,是这个例子的切点选择。AOP 也可以应用到 Controller 等其他 Spring Bean;它的位置由被代理的方法决定,并没有固定的"第三层 Web 拦截器"位置。

三者拿到的信息也不同:

机制 当前已经知道什么 拦截范围与常见用途
Servlet Filter 请求、响应、后续过滤链,通常尚无 MVC 方法信息 匹配的 Servlet 分派;请求包装、入口认证、跨请求入口的日志
MVC Interceptor 请求、响应,以及 MVC 找到的 handler 匹配的 MVC 处理器;读取 Controller 方法注解、处理器相关上下文
Spring AOP 代理调用的方法、参数、目标对象 经过代理且匹配切点的方法;事务、业务方法计时、方法级权限检查

Filter 还能把包装后的 request、response 传给后续链,例如缓存请求体的包装器。preHandle() 没有替换传入对象的同类链式参数。这是接口能力上的差别,不能靠把同一段代码换个注解补齐。

preHandle 返回 false,为什么自己的清理没执行?

MVC 拦截器有三个常用回调:preHandle() 决定是否继续,postHandle() 在处理器正常返回后执行,afterCompletion() 在本次 MVC 处理收尾时执行。后两个回调都有进入条件。

Spring 5.3.23 的 HandlerExecutionChain.applyPreHandle()保存了最后一个成功放行的拦截器位置。下面保留循环与分支,添加中文注释:

java 复制代码
for (int i = 0; i < this.interceptorList.size(); i++) {
    HandlerInterceptor interceptor = this.interceptorList.get(i);
    if (!interceptor.preHandle(request, response, this.handler)) {
        triggerAfterCompletion(request, response, null);
        return false;
    }
    this.interceptorIndex = i; // 只有返回 true,才记为已成功进入
}
return true;

interceptorIndex 初始是 -1。假设拦截器顺序是 A、B:A 返回 true 后,索引更新为 0;B 返回 false 时,索引还停在 A。因此,收尾只从 A 开始倒序执行,不包含 B。

同一个类中的 triggerAfterCompletion() 使用这个索引回退,省略日志和捕获清理异常的代码后是:

java 复制代码
for (int i = this.interceptorIndex; i >= 0; i--) {
    HandlerInterceptor interceptor = this.interceptorList.get(i);
    interceptor.afterCompletion(request, response, this.handler, ex);
}

所以,B 如果在拒绝请求之前创建了需要清理的资源,必须在拒绝路径中自己释放。B 的 preHandle() 直接抛异常也一样:索引尚未更新到 B,它自己的完成回调不会因此被补调。

这条规则也影响线程上下文。身份校验可以先完成,确认放行后再绑定上下文;绑定后的异常和拒绝分支仍要有对应清理。若上下文需要覆盖整个同步 Servlet 调用,Filter 的 try/finally 可以把设置和释放放在同一个调用栈中。下面是放在 Filter.doFilter() 内的处理结构;requestContext 是应用自己的线程上下文对象,负责绑定本次请求的信息并清理:

java 复制代码
try {
    requestContext.bind(request);
    chain.doFilter(request, response);
} finally {
    requestContext.clear(); // 正常返回、提前结束、异常退出都清理当前线程
}

清理函数要能处理"绑定只完成一部分"的情况。这里保证的是当前调用栈退出时清理当前线程,不是后台任务结束,也不是客户端已经收完响应。

异常被处理了,后置回调就都能执行吗?

不会。postHandle() 的调用位置在处理器适配器正常返回之后。DispatcherServlet.doDispatch()中的关键顺序如下,省略默认视图处理及其他分支:

java 复制代码
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
    return;
}
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
if (asyncManager.isConcurrentHandlingStarted()) {
    return;
}
// 省略默认视图名处理
mappedHandler.applyPostHandle(processedRequest, response, mv);

mappedHandler 保存处理器和拦截器链,ha 是调用处理器的适配器。如果参数转换、Controller 或其调用的业务方法在 ha.handle() 中抛异常,执行就跳出这段正常路径。即使异常随后被 @ExceptionHandler 处理,MVC 也不会回头补一次 postHandle()。

异常处理完以后,MVC 可以继续完成请求。processDispatchResult() 在已处理完异常、视图或响应之后,调用的是 triggerAfterCompletion(request, response, null)。这里的 null 表示没有继续向这个收尾路径传播的异常,不表示业务一定成功。

例如,Service 抛 IllegalStateException,Advice 把它转成 409 响应。调用情况如下:

同步请求结果 postHandle 已放行拦截器的 afterCompletion Filter 中 chain 后的普通语句
方法正常返回 200 倒序执行 倒序执行,异常参数为 null 执行
方法正常返回 422,未抛异常 仍然执行 异常参数为 null 执行
方法抛异常,由 Advice 转成 409 不执行 异常参数可以为 null MVC 正常返回时执行
异常没有被 MVC 处理,继续向外抛 不执行 收到继续传播的异常 不执行,但 finally 执行
B 的 preHandle 返回 false 不执行 只有之前返回 true 的 A 执行 MVC 正常返回时执行

HTTP 状态码、业务结果和 Java 异常是不同信息。记录请求结果时,可以结合响应状态及明确的业务结果字段;只用 afterCompletion() 的异常参数统计失败,会漏掉已被统一异常处理器转换的失败。

@ResponseBody、@RestController 还会在适配器内处理返回值并可能写出响应。因此,postHandle() 通常也不适合再改 REST 响应体。要统一转换响应内容,可以考察 MVC 的 ResponseBodyAdvice;资源释放仍放在适用的完成回调或 finally 中。

为什么 AOP 接不住所有请求异常?

Spring AOP 必须先收到一次代理方法调用。Service 代理没有被调用,服务通知就没有机会执行。请求在 Filter 中被拒绝、路由没有选中接口、JSON 参数转换失败,都可能发生在业务方法之前。

代理内也有自己的调用链。ReflectiveMethodInvocation.proceed()用 currentInterceptorIndex 记录执行到哪条方法通知。链中还有通知时,交给下一条;走到链尾后调用目标方法。源码中的链尾条件是:

java 复制代码
if (this.currentInterceptorIndex ==
        this.interceptorsAndDynamicMethodMatchers.size() - 1) {
    return invokeJoinpoint(); // 通知链结束,实际调用目标方法
}

interceptorsAndDynamicMethodMatchers 保存本次方法调用的通知及适用的动态匹配条件。普通方法通知分支执行 MethodInterceptor.invoke(this),通知可以继续调用 proceed(),也可以直接返回或抛异常,从而不进入后续通知和目标方法。

定时任务调用 Service 代理,业务方法通知也可以照常执行:这条链只需要代理、方法和参数,不需要 HTTP 请求。反过来,手动 new 一个服务对象,或目标对象内部的 this.otherMethod() 自调用,没有再次经过代理,就不会因此进入另一轮代理通知。

异常处理也遵守调用栈。Filter 在进入 DispatcherServlet 之前抛异常,通常不会进入 MVC 的 @ControllerAdvice 异常解析流程;应由 Filter 自己或容器的错误处理机制处理。服务代理上的环绕通知则只能捕获其内部 proceed() 传播出来的异常,不能向外捕获尚未进入代理时发生的错误。

登录检查、资源权限和清理,该放在哪里?

判断用户有没有登录,需要覆盖应用希望保护的请求入口。Servlet 应用通常使用 Spring Security 等安全框架的过滤器链建立身份、检查入口规则。Filter 的映射范围、放行路径和分派类型必须与这些入口对应。只有配置到的请求才会经过它。

MVC Interceptor 在处理器已经选中后工作,适合确实依赖该处理器的信息,例如读取 Controller 方法上的业务标记。它是否覆盖静态资源,要看资源是否经过 MVC 处理器及拦截器的路径配置;交给另一个 Servlet 的请求不会因此自动被它拦住。把"拦截器能读方法注解"直接等同于"覆盖全部身份认证",会漏掉这些入口差异。

资源权限还需要具体业务信息。例如"已登录"不能证明"可以读取订单 42"。在 Service 的业务规则或正确配置的方法级权限检查中,结合当前身份、订单归属和操作类型作判断,可以让 HTTP 和后台调用共用规则。后台入口也必须提供适用的身份或系统授权条件,不能仅因为经过了 AOP 就认定已获授权。

排查一次拦截失效,可以对照实际调用路径:

现象 需要核对的位置
Filter 没执行 是否注册,URL/Servlet 映射、分派类型及链顺序是否匹配
Filter 执行,MVC 拦截器没执行 请求是否进入 DispatcherServlet、是否选中 handler、路径是否排除
MVC 拦截器执行,方法通知没执行 是否真正调用被代理方法、切点是否匹配、是否手动 new 或自调用
拒绝后上下文残留 资源在哪个回调创建;拒绝或抛异常前,是否已进入完成回调名单
失败日志计数为零 是否只判断异常参数,忽略 Advice 转换后的响应与业务失败

上面的回程按同步请求说明。MVC 开启异步处理后,原 Servlet 线程可能先退出;初次分派不会照常调用 postHandle() 和 afterCompletion(),适用的异步拦截器改走 afterConcurrentHandlingStarted()。后续异步分派、超时或错误又有各自回调。原线程上的 ThreadLocal 要在原线程释放,后台线程需要独立的传递与清理;Filter 是否参与异步分派,还受注册的分派类型和实现规则影响。

面试问答

问题 1:Filter、Interceptor 和 AOP 的执行顺序固定吗?

参考回答:同步 MVC 请求中,匹配的 Servlet Filter 围住 DispatcherServlet;MVC 找到处理器后执行匹配的拦截器,再由适配器调用处理器。AOP 在匹配的代理方法被调用时执行。若切点在 Service,它位于 Controller 调用服务时;若切点在 Controller,位置也相应变化。三者各自还有链顺序,不能简单背成三个组件永远按固定位置执行。

问题 2:B 的 preHandle 返回 false,为什么 A 有 afterCompletion,B 没有?

参考回答:HandlerExecutionChain 只在一个 preHandle 成功返回 true 后更新 interceptorIndex。B 拒绝时索引仍停在 A,收尾从这个索引倒序执行。B 在拒绝前创建的资源要自己释放;B 抛异常也不会被登记为已经成功进入。

问题 3:afterCompletion 的异常参数是 null,就能记录请求成功吗?

参考回答:不能。业务异常可能已经被 MVC 异常解析器转成错误响应,收尾仍接收 null;方法正常返回错误状态也没有异常。应依据所需口径记录 HTTP 状态和业务结果,异常参数用于补充尚未处理的失败信息。

问题 4:业务权限为什么不能只依赖 Controller 拦截器?

参考回答:后台任务和其他非 HTTP 入口不会经过 MVC 拦截器。处理器路径配置也可能留下不同入口。入口认证负责建立和检查身份,具体资源权限要结合业务对象和操作判断,并确保其他入口执行相同业务规则;方法权限若通过 AOP 实现,还要确认调用经过代理并满足身份上下文要求。

自测:换一个条件,结果会怎样?

自测 1:A、B 都放行,Service 抛异常,Advice 转成 409。哪些回调会执行?

参考要点:Service 通知内的 finally 执行,正常返回后的语句不执行;A、B 的 postHandle 都跳过;MVC 成功处理该异常后,B、A 的 afterCompletion 倒序执行,异常参数可为 null;外部 Filter 的链调用可以正常返回,其 finally 也执行。

自测 2:A 的 preHandle 绑定了 ThreadLocal,然后直接返回 false,清理只写在 A 的 afterCompletion,会留下什么?

参考要点:A 没有成功返回 true,不会得到自己的完成回调,线程上下文可能遗留给线程复用后的请求。拒绝路径应自己清理,或调整生命周期;若改由 Filter 管理同步请求上下文,应将绑定和释放放进覆盖该次调用的 try/finally。

自测 3:定时任务拿到 Service 代理调用方法,与手动 new Service 后调用,差在哪里?

参考要点:两者都没有 Servlet Filter 和 MVC Interceptor。代理调用在方法匹配时进入 AOP 通知,直接目标调用不会;所以事务、方法权限等依赖代理的机制,不能由类上有注解就判断一定生效。

参考资料

相关推荐
sun03226 小时前
【JDK 动态代理】 和 【CGLIB 代理】对比
java·数据库·sql
文丑颜不良啊6 小时前
LeetCode1261~1280题目解析
java·leetcode
郭小李6 小时前
spring 中的 @EnableWebMvc注解和WebMvcConfigurer接口
java·后端·spring
卷无止境6 小时前
Skill 的安全边界在哪里:风险来源、验证思路与用户可用的防线
后端·python
香蕉大香蕉(06敬畏市场)7 小时前
高频八股 · JVM(8)
java·jvm·面试·八股·大厂实习
星核0penstarry7 小时前
企业 API 向后兼容实战:版本控制、废弃字段通知与灰度发布策略
人工智能·后端·api·接口隔离原则·版本控制·架构设计·后端架构
养军博客7 小时前
spirngSecurity 鉴权教程02
java·springsecurity
CoderYanger7 小时前
Java EE 进阶:5. Spring IoC&DI(第二弹):Spring IoC
java·开发语言·spring boot·spring·面试·职场和发展·java-ee
用户别睡觉8 小时前
自己搭的中文 PDF RAG 接了 Redis 缓存、跑了 RAGAS,以为能写“提速多少、准确率多少”,结果两个都没按预期来。
后端