资深 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 通知,直接目标调用不会;所以事务、方法权限等依赖代理的机制,不能由类上有注解就判断一定生效。