Filter、Interceptor、AOP 执行顺序与实战

bee2026-10-0883 分钟0 次阅读
三个都能「拦截请求」,到底该用哪个?从 Tomcat 的 Filter 到 Spring MVC 的 HandlerInterceptor,再到方法级 AOP,用一张执行时序图和统一登录拦截实战讲清边界。
1 / 146
小节
〇、30 秒看懂:这就是机场的四道关卡
2 / 146

先别管 Filter、Interceptor、AOP 这些名字。你只需要记住一件事:一个 HTTP 请求就像一名要登机的乘客,从航站楼门口一路走到登机口,中间有几道完全不同的关卡,每道关卡能看到的消息量天差地别。

3 / 146
类比

值机柜台 = Filter(过滤器)。它在 Servlet 容器(Tomcat,真正接住网络连接的服务器)这一层,只查你的身份证和行李——它认识「人」,却压根不知道你要坐哪班飞机、去哪个登机口。安检口 = Interceptor(拦截器),属于 Spring MVC,它手里有航班表:能看到你最终要调用「哪个类的哪个方法」(这个东西叫 HandlerMethod),所以它能说「这班机你不许上」。登机口复核 = AOP(面向切面编程——把「每个方法都要做的杂事」抽出来统一织进方法调用前后的技术),它只盯着「登机」这一个动作本身,管不着你在航站楼里怎么走的。

4 / 146
类比

那 afterCompletion(请求收尾时必调的回调)是什么?是离场时才结账。前面每一段流程都可能出岔子——延误、改签、被拒载——但收银员不管你这场戏怎么演的,只管在你离开时把账单结掉、把桌子收干净。所以「统计耗时」「清理 ThreadLocal(绑在线程上的私有储物柜)」永远写在 afterCompletion,而不是写在只有顺利登机才有人喊的 postHandle。

5 / 146
架构图
图 3 · 一次请求穿过三道关卡
图 3 · 一次请求穿过三道关卡
6 / 146

学完这一篇,你要能脱口而出地回答三个问题:

7 / 146
  • 控制器抛异常时,postHandle 还执行吗?(不执行,直接跳到异常解析,再走 afterCompletion)
  • 为什么登录校验放拦截器比放 Filter 省事?(拦截器能拿到 HandlerMethod,能按注解和白名单精确放行)
  • 想在拦截器里读一眼 @RequestBody 的请求体,为什么控制器随后拿到空对象?(输入流是一次性的,读一次就到底)
8 / 146
小节
一、三个都能「拦截」,到底差在哪
9 / 146

「登录校验写在 Filter 还是 Interceptor?」「统计接口耗时用拦截器还是 AOP?」——几乎每个 Spring 新人都会在同一个路口犯迷糊:三种机制似乎都能在业务代码执行前「拦一下」,于是随手挑一个,直到某天发现「拦截器里拿不到方法名」或「切面拦不住静态资源」才回头补课。

10 / 146

先把身份说清楚,后面的选择就水到渠成:

11 / 146
对照表
机制所属规范 / 容器作用粒度能拿到什么典型场景
FilterServlet 规范(Tomcat)一次 HTTP 请求 / 响应ServletRequest / ServletResponse;不知道要调哪个控制器方法跨域 CORS、字符编码、请求日志、Gzip 压缩、XSS 过滤
InterceptorSpring MVCHandler 级别(进入控制器前后)HandlerMethod(哪个 Bean 的哪个方法)、ModelAndView登录校验、权限判断、接口审计、本地化
AOPSpring 容器(方法级)任意 Spring Bean 的任意方法方法签名、参数、返回值、抛出的异常事务、缓存、接口耗时统计、幂等、限流
12 / 146
说明

三者最大的分水岭是「知不知道业务方法」。Filter 活在 Servlet 世界里,看到的只有 HttpServletRequest,根本不知道后面要调用哪个 Controller;Interceptor 由 Spring MVC 掌管,能拿到 HandlerMethod;AOP 更靠里,直接围着某个 Bean 的方法转。想让逻辑「离业务更近」,往 Interceptor / AOP 走;想让逻辑「离容器更近、对所有请求一视同仁」,才用 Filter。

13 / 146
小节
二、执行时序全景:一张图看清谁先谁后
14 / 146
架构图
图 1 · 一次请求经过的拦截层
图 1 · 一次请求经过的拦截层
15 / 146

把一次正常请求拉直,顺序是这样的(这段顺序值得背下来):

16 / 146
代码对照
代码text
请求进入  └─ Filter 链(doFilter 中 chain.doFilter 之前的代码,正序)       └─ DispatcherServlet.doDispatch            └─ Interceptor.preHandle(正序)                 └─ Controller 方法 ──(方法内部被 AOP @Around 环绕:前置 → 目标 → 后置)                      └─ Interceptor.postHandle(逆序,仅成功时执行)                           └─ 视图渲染 / 响应写出  └─ Interceptor.afterCompletion(逆序,finally 语义,一定执行)       └─ Filter 链(chain.doFilter 之后的代码,逆序)            └─ 响应回到客户端
解读
  • 前置顺序 = 正序,后置顺序 = 逆序:谁先进去,谁后出来,像套娃一样一层层包回去
  • postHandle 只有控制器成功返回时才执行,它拿着 ModelAndView,所以能改视图;RESTful 场景一般用不上
  • afterCompletion 是 finally 语义:只要它前面的 preHandle 返回过 true,无论成功失败都会执行,最适合清理 ThreadLocal、关闭资源
17 / 146
原理动画
动图 · 拦截器的三个钩子
动图 · 拦截器的三个钩子
18 / 146

那如果控制器抛异常呢?异常线是这样的:Filter 前置 → preHandle → Controller 抛异常 → 跳过 postHandle → afterCompletion(逆序,异常作为参数传入)→ 异常上交 HandlerExceptionResolver 处理 → 补写响应 → Filter 后置。记住一句:postHandle 是「成功专属」,afterCompletion 是「收尾专属」,两者一个可能被跳过、一个必然执行。

19 / 146

上面这条线只能一次性看完。下面这张图把它拆成可以一格一格点的九站——学习顺序时最有效的不是背,而是搞清楚「每站在等谁」:

20 / 146
交互图解
流程一次请求的九站:谁正序、谁逆序1 / 9
按 ①→⑨ 点,再倒着点一遍:正序发生在 ①②③,逆序发生在 ⑥⑦⑧⑨,这是全部差异的来源
→
→
→
→
→
→
→
→
① Filter 前段(正序)
容器层。Tomcat 按 FilterRegistrationBean 的 order 依次调用 doFilter,chain.doFilter 之前的代码全部在这里跑完。它比 Spring MVC 早,所以连 DispatcherServlet 都还没接管。
全部看懂了背顺序不如背一件事:前置三站正序、后置四站逆序,而 ⑥ 是唯一可能被跳过的那一站。
21 / 146
小节
三、Filter 实战:跨域与请求日志
22 / 146

Filter 适合做「与业务无关、且对所有请求生效」的脏活。两个最经典的例子——跨域和访问日志。

23 / 146

跨域过滤器必须最早执行,否则预检请求(OPTIONS)会被后面的逻辑拦掉:

24 / 146
代码对照
代码java
@Component@Order(Ordered.HIGHEST_PRECEDENCE)                      // 跨域过滤器必须排在最前public class CorsFilter extends OncePerRequestFilter {  // 保证一次请求只跑一次    @Override    protected void doFilterInternal(HttpServletRequest request,                                    HttpServletResponse response,                                    FilterChain chain) throws ServletException, IOException {        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));        response.setHeader("Access-Control-Allow-Credentials", "true");        response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");        response.setHeader("Access-Control-Allow-Headers", "*");        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {            response.setStatus(HttpServletResponse.SC_OK);   // 预检请求直接结束,不进业务            return;        }        chain.doFilter(request, response);                    // 放行给下一个过滤器    }}
解读
  • 继承 OncePerRequestFilter 而非直接实现 Filter:转发(forward)/ include 时不会重复执行
  • @Order(HIGHEST_PRECEDENCE) 保证它在所有过滤器中排最前——跨域头必须最先写
  • chain.doFilter(request, response) 是分界线:它之前是「前置」,它之后是「后置」
  • OPTIONS 预检请求没有业务体,可直接返回 200,不必往下走
25 / 146

请求耗时日志,正好演示「后置代码」:

26 / 146
代码对照
代码java
@Component@Order(1)public class AccessLogFilter extends OncePerRequestFilter {    private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class);    @Override    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)            throws ServletException, IOException {        long start = System.currentTimeMillis();        try {            chain.doFilter(req, res);                        // 前置:就当「开始」        } finally {            long cost = System.currentTimeMillis() - start;  // 后置:写在 finally 里            log.info("{} {} status={} cost={}ms", req.getMethod(),                     req.getRequestURI(), res.getStatus(), cost);        }    }}
解读
  • 计时的「开始」写在 chain.doFilter 前,「结束」写在它之后,这正是 Filter 的前置 / 后置模型
  • 放在 finally 里,即使业务抛异常也能记录到耗时和状态码
  • 注意此时 res.getStatus() 已经是最终状态码——因为后置代码在响应提交前执行
27 / 146
小节
3.1 两种注册方式:FilterRegistrationBean 还是 @WebFilter
28 / 146

@Component 的 Filter 会被 Spring Boot 自动注册,但顺序不好控。要精确控制顺序,用 FilterRegistrationBean:

29 / 146
java
@Configurationpublic class FilterConfig {    @Bean    public FilterRegistrationBean<AccessLogFilter> accessLog() {        FilterRegistrationBean<AccessLogFilter> reg = new FilterRegistrationBean<>(new AccessLogFilter());        reg.addUrlPatterns("/*");        // 拦截所有路径        reg.setOrder(1);                 // 数字越小越先执行        reg.setName("accessLogFilter");        return reg;    }}
30 / 146

另一种写法是注解式,但必须让启动类扫描它:

31 / 146
java
@WebFilter(urlPatterns = "/*", filterName = "accessLogFilter")public class AccessLogFilter extends OncePerRequestFilter { /* ... */ }// 然后在启动类上补一句,否则 @WebFilter 完全不生效@ServletComponentScan@SpringBootApplicationpublic class DemoApplication { }
32 / 146
对照表
注册方式顺序控制依赖注入适用场景
FilterRegistrationBeansetOrder 精确可控天然支持需要顺序、需要注入其他 Bean(推荐)
@WebFilter + @ServletComponentScan靠 @Order,容易混乱需借助容器回调简单场景、原生 Servlet 习惯
@Component 直接实现靠 @Order支持全局默认,但顺序不直观
33 / 146
提示

@WebFilter 最经典的坑是忘了配 @ServletComponentScan。注解写着、类也在,但过滤器就是不执行——因为它不经过 Spring 的组件扫描,必须显式开启 Servlet 组件扫描才注册。

34 / 146
小节
四、HandlerInterceptor 实战:统一登录校验
35 / 146

登录校验是 Interceptor 的主场:它知道「这次要调哪个方法」,还能被 excludePathPatterns 精确放行。

36 / 146
代码对照
代码java
@Componentpublic class LoginInterceptor implements HandlerInterceptor {    private final TokenService tokenService;         // 依赖注入,注意它是单例    public LoginInterceptor(TokenService tokenService) {        this.tokenService = tokenService;    }    @Override    public boolean preHandle(HttpServletRequest request,                             HttpServletResponse response,                             Object handler) throws Exception {        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {            return true;                             // 放行跨域预检        }        String token = request.getHeader("Authorization");        LoginUser user = tokenService.parse(token);  // 解析失败 / 过期返回 null        if (user == null) {            throw new UnauthorizedException("请先登录");   // 抛异常,交给全局异常处理        }        UserContext.set(user);                       // 存进 ThreadLocal 供后续业务取用        return true;                                 // 返回 true 才继续往下走    }    @Override    public void postHandle(HttpServletRequest request, HttpServletResponse response,                           Object handler, ModelAndView mv) {        // 仅控制器成功返回时执行;RESTful 项目通常无事可做    }    @Override    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,                                Object handler, Exception ex) {        UserContext.clear();                         // ★ 一定执行:清理 ThreadLocal,防止线程复用串数据    }}
解读
  • preHandle 返回 true 放行、返回 false 中断,这是它唯一要记住的语义
  • throw new UnauthorizedException(...) 而不是返回 false:让全局 @RestControllerAdvice 返回结构统一的错误体(见第五节对比)
  • afterCompletion 里的 Exception ex 参数:为 null 表示正常结束,非空表示控制器抛了异常——它是判断「这次请求到底成没成」的最后机会
  • afterCompletion 永远用于清理:ThreadLocal、计数器、请求级缓存,一个都不能漏
37 / 146

注册拦截器并配置黑白名单:

38 / 146
代码对照
代码java
@Configurationpublic class WebMvcConfig implements WebMvcConfigurer {    private final LoginInterceptor loginInterceptor;    public WebMvcConfig(LoginInterceptor loginInterceptor) {        this.loginInterceptor = loginInterceptor;    }    @Override    public void addInterceptors(InterceptorRegistry registry) {        registry.addInterceptor(loginInterceptor)                .addPathPatterns("/api/**")              // 只拦业务接口                .excludePathPatterns(                    // 白名单,按顺序匹配、命中即放行                        "/api/auth/**",                  // 登录、注册                        "/actuator/**",                  // 健康检查                        "/error",                        // 错误页                        "/favicon.ico"                );    }}
解读
  • addPathPatterns / excludePathPatterns 用的是 Ant 风格路径,/** 匹配多级、/* 只匹配一级
  • 注册顺序 = 执行顺序(preHandle 正序);后置钩子则逆序执行
  • 拦截器是单例 Bean,可以正常依赖注入 TokenService,但绝不能把请求数据存进成员变量(见第九节)
39 / 146
小节
五、preHandle 返回 false 还是抛异常
40 / 146

这是本节最该记住的对照表——它决定了客户端到底能不能拿到一个「像样的错误」:

41 / 146
对照表
维度preHandle 返回 falsepreHandle 抛异常
是否调用控制器否否
响应行为需自己写好响应,否则客户端拿到空响应 / 含糊的 200冒泡到 DispatcherServlet,交给 HandlerExceptionResolver
是否走 postHandle否否
是否走 afterCompletion当前这个拦截器不会(只有已放行的靠前拦截器会走)已放行的拦截器都会走,异常作为参数传入
错误响应是否统一需手写,容易和全局异常体不一致复用 @RestControllerAdvice,契约天然一致
适用场景已经自行完成响应(重定向、直接写 JSON / HTML)需要标准错误体、想复用全局异常处理
42 / 146
坑

preHandle 返回 false 时,当前拦截器的 afterCompletion 不会执行。因为 DispatcherServlet 在 applyPreHandle 阶段就中断了,只会对那些「已经返回过 true」的拦截器触发 afterCompletion。如果你在 preHandle 里放了 ThreadLocal,又在 afterCompletion 里清理,一旦某次返回 false,就会出现内存残留——一定要想清楚在哪一步清理。

43 / 146
要点

能给出标准错误时优先抛异常,返回 false 只留给「已经自己写好了响应」的场景。 这与第 22 篇 DispatcherServlet 的结论一脉相承:拦截器的语义是「守门」,守门失败要给出明确信号,而不是把客户端晾在空响应前。

44 / 146

两条路在源码里只差一行,但结果差一个数量级。开调试台逐行走一遍,把「谁被跳过」看得清清楚楚——请特别注意第 2 步与第 4 步:

45 / 146
单步调试台
单步台doDispatch 里的分岔:return false 与 throw 差在哪1 / 7
七步走完两条路。第 2 步是短路点,第 4 步是「异常时 postHandle 为什么不会补跑」的答案
被调试的代码
1boolean interest = mappedHandler.applyPreHandle(request, response);
2if (!interest) { return; } // 短路:后面的一切都不发生
3mv = ha.handle(request, response, mappedHandler.getHandler);
4mappedHandler.applyPostHandle(request, response, mv);
5} catch (Exception ex) { dispatchException = ex; }
6processDispatchResult(request, response, mappedHandler, mv, dispatchException);
7mappedHandler.triggerAfterCompletion(request, response, ex);
此刻的变量
拦截器数2
当前调用AuthInterceptor.preHandle
已放行计数0
调用栈
1DispatcherServlet.doDispatch
2HandlerExecutionChain.applyPreHandle
1applyPreHandle 内部是一个正序 for 循环,逐个调用拦截器并计数。这个计数器决定第 7 步谁能收到收尾回调——它是本节最容易被忽略的细节。
46 / 146
原理动画
动图 · preHandle 返回 false 的那一瞬
动图 · preHandle 返回 false 的那一瞬
47 / 146

动图第 ⑤ 步是最贵的一格:返回 false 的那个拦截器自己收不到 afterCompletion。埋点、ThreadLocal 清理、审计落库如果写在它的 afterCompletion 里,被它拒掉的请求就全都漏记——这也是第十八节第二档那个「调换注册顺序」练习要你亲眼看到的现象。

48 / 146
小节
六、AOP 的边界:为什么它拦不到 Filter / Interceptor
49 / 146

很多人第一次用 AOP 时都想过:「既然切面能环绕方法,那让它拦住整个请求行不行?」答案是不行,原因藏在「代理」二字里。

50 / 146
  • AOP 的本质是Spring 容器为你管理的 Bean 创建代理对象,方法调用必须经过容器注入的代理,切面才能生效
  • Filter 由 Servlet 容器(Tomcat)直接 new 并调用,根本没经过 Spring 代理,切面无从下手
  • Interceptor 由 Spring MVC 直接调用注册的那个对象,preHandle 也不是「通过代理调用」的 bean 方法,所以切面同样拦不到
  • 更根本的是时机:AOP 环绕的是「方法调用」,请求必须已经进入方法体才会触发。而 preHandle 的意义恰恰是「在方法被调用之前」拒绝请求——AOP 做不到这件事,等它介入时业务方法已经在执行了
51 / 146

AOP 真正擅长的位置是「方法内部」:事务、缓存、耗时、幂等、限流。它也有自己的边界,用之前必须知道:

52 / 146
  • 只对Spring 容器管理的 Bean 生效,自己 new 出来的对象没有代理
  • 同类内部自调用(this.otherMethod())不走代理,切面失效——要用注入自身或 AopContext.currentProxy()
  • private / final 方法 CGLIB 覆盖不了,切面同样失效
53 / 146
说明

一句话归纳三者的边界——Filter 管「进不进得来」,Interceptor 管「要不要放行到这个 Handler」,AOP 管「这个方法调用前后做什么」。 三者是接力关系,不是替代关系。

54 / 146
小节
七、顺序控制:三套机制各自怎么排序
55 / 146

三种机制都能排序,但排法各不相同,混在一起很容易搞乱。与其背表,不如玩一局闯关:左边是你写的排序代码,右边点它真实的生效规则——配错会当场告诉你为什么。

56 / 146
配对闯关
闯关排序写法 ↔ 它到底怎么生效已配对 0/6 · 配错 0
六组都是硬映射。第 3 组是全站最容易答错的一格:拦截器压根不看数字
先点左边一个
57 / 146
要点

三套规则里,「数字越小越先」是一致的,但Interceptor 不看数字、只看注册顺序——这是最容易踩的差异。想调整拦截器顺序,只能调整 addInterceptor 的调用次序,给它加 @Order 是没用的。

58 / 146
小节
八、实战组合:让 traceId 贯穿整条链路
59 / 146

真实的链路追踪需要「一次请求一个 traceId」,从最外层一直带到日志里。这时三者要配合:Filter 负责生成并绑定,Interceptor / AOP / 业务只管复用。

60 / 146
代码对照
代码java
@Component@Order(Ordered.HIGHEST_PRECEDENCE)public class TraceIdFilter extends OncePerRequestFilter {    @Override    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)            throws ServletException, IOException {        String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))                .orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));        MDC.put("traceId", traceId);                 // 绑定到当前线程,日志自动带上        res.setHeader("X-Trace-Id", traceId);        // 回写给客户端,便于对账        try {            chain.doFilter(req, res);        } finally {            MDC.remove("traceId");                   // ★ 线程池会复用线程,必须清理        }    }}
解读
  • traceId 的生成放在 Filter:它最早执行,能确保后续所有环节都有值
  • 通过 MDC(SLF4J 的映射诊断上下文)把 traceId 绑到当前线程,日志框架就能自动输出
  • 回到 finally 里 remove:Tomcat 线程池会复用线程,不清理会把上一个请求的 traceId 带到下一个请求
61 / 146

日志格式里引用 %X{traceId} 即可自动打印:

62 / 146
代码对照
代码xml
<!-- logback-spring.xml --><pattern>%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
解读
  • %X{traceId} 会去 MDC 里取当前线程的 traceId
  • 拦截器、切面、业务代码里直接 MDC.get("traceId") 就能拿到,无需层层传参
  • 这正是「Filter 生成 → MDC 共享 → 各处复用」的经典组合
63 / 146

这套「一处生成、全程复用、收尾清理」的接力值得单独走一遍,因为新手最常在这里翻车的是只做了前两步、忘了最后一步——线上日志里 traceId 串号,十次有八次是 finally 那行 remove 被省掉了:

64 / 146
原理动画
动图 · 一个 traceId 的接力赛
动图 · 一个 traceId 的接力赛
65 / 146
坑

MDC 基于 ThreadLocal,异步线程(@Async、线程池、CompletableFuture)里取不到父线程的 traceId。跨线程传递需要手动包装任务(提交前 MDC.getCopyOfContextMap(),执行前 setContextMap),否则异步日志里的 traceId 会缺失或串号。

66 / 146
小节
九、两个必踩的坑
67 / 146
坑

拦截器是单例 Bean,禁止在成员变量里存请求私有状态。 LoginInterceptor 全应用只有一个实例,被所有并发请求共享。如果你写了 private LoginUser currentUser; 并在 preHandle 里赋值,A 请求的用户信息会被 B 请求覆盖,导致越权——这类 bug 偶发且极难复现。正确做法是存进 ThreadLocal(用完即清)或 request.setAttribute,永远不要放进成员变量。

68 / 146
坑

拦截器只对「进入 DispatcherServlet 的请求」生效,静态资源常常"漏网"。 当静态资源交给容器的默认 Servlet 处理时,它们根本不经过 DispatcherServlet,拦截器自然对其不生效;而当静态资源由 Spring MVC 的 ResourceHttpRequestHandler 处理时,/ 的拦截器又会把它一并拦下,导致 JS / CSS 请求被登录校验挡掉。结论:一定要用 excludePathPatterns 显式排除静态资源目录(如 /static/、/favicon.ico),别指望「默认不生效」。

69 / 146
小节
十、动手体验:拦截器在哪一环
70 / 146

下面这个演示把 DispatcherServlet 的分派流程做成了可切换路径。切换 /users/42、/users、/nope,重点观察「参数解析」与「控制器执行」的先后——preHandle 恰恰发生在这两者之前,这就是拦截器能「提前拒绝请求」的时机。

71 / 146
内核实验
TeaVM请求链路实测:拦截器在哪一环未启动
观察参数解析与执行的先后,理解拦截器前置发生的时机
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
72 / 146
小节
十一、决策:登录校验到底放哪一层
73 / 146
决策
决策一个前后端分离的后端,需要统一做登录校验,并希望未登录时返回结构一致的 JSON 错误体。校验该放在哪?
74 / 146
小节
十二、上手实验二:把五种争议现场逐个点开看
75 / 146

前面十节都是「讲」。这一节全部换成「跑」——下面这个内核演示把本章最容易吵起来的五件事做成了可切换的参数,建议按 order → pre → ex → cors → scope 的顺序各点一遍,每格都对应一类真实工单。

76 / 146
架构图
图 4 · Filter / Interceptor / AOP 能力边界
图 4 · Filter / Interceptor / AOP 能力边界
77 / 146

同一个需求放错层,就会直接失去它的能力——这张表就是上图的逐条展开:

78 / 146
对照表
你想做的事只有这一层做得到另外两层为什么不行
给所有响应加跨域头 / 压缩 / traceIdFilter拦截器在 MVC 层内部,切面更是既看不见静态资源也看不见容器自带的 Filter
按方法上的 @LoginRequired 决定放不放行InterceptorFilter 拿不到 HandlerMethod,只能靠 URL 字符串匹配;切面生效时方法已经被调用了
统计某个 Service 调用耗时 / 做幂等AOPFilter 看不见 Service Bean,拦截器只包住控制器,@Service 内部调用从它眼皮底下溜走
在进入控制器之前直接给出 401Interceptor(preHandle)切面是「环绕调用」,方法早已在栈上;Filter 虽然也能中断,但没有方法级白名单
无论请求怎么结束都要清理 ThreadLocalInterceptor(afterCompletion)切面要自己捕获异常才走得完;Filter 的 finally 能用,但拿不到「这次是哪个 handler」
79 / 146
内核实验
TeaVM过滤器与拦截器 · 五种争议现场未启动
依次切 order(完整顺序)/ pre(返回 false)/ ex(异常时还走 postHandle 吗)/ cors(跨域预检)/ scope(能力边界),重点看 pre 和 ex 两格
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
80 / 146

五个参数分别要看到什么,先给你划重点:

81 / 146
  • order:完整执行线 —— 容器 Filter 前段(正序)→ DispatcherServlet → preHandle(正序)→ 参数解析与校验 → Controller → postHandle(逆序)→ 异常解析 → afterCompletion(逆序)→ Filter 后段(逆序)。注意 afterCompletion 比响应体真正写出更早
  • pre:preHandle 一返回 false,applyPreHandle 立刻中断——后面的拦截器、参数解析、Controller、postHandle 全部跳过,而且只有「已经进去过」的拦截器会收到 afterCompletion。这就是埋点数据莫名变少的头号原因
  • ex:Controller 抛异常时 applyPostHandle 整个被跳过,哪怕异常最终被 @RestControllerAdvice 优雅地转成 400,postHandle 也不会补跑;而 afterCompletion 一定执行并带着原始异常
  • cors:三种 CORS 配置位置(Filter / addCorsMappings / @CrossOrigin)谁能活下来。预检请求不带凭证,所以鉴权逻辑排在 CORS 前面就会把合法的 OPTIONS 判成 401
  • scope:三道关卡各自「看得见什么」的硬边界,正好对应上一张对照图
82 / 146
小节
十三、上手实验三:Spring Security 的过滤器链长什么样
83 / 146

第八节的 traceId 组合里,如果项目引入了 Spring Security,你会发现「自己写的拦截器」和「Security 的过滤器」在抢同一份工作。先看清它的链条结构:Security 的整条链本身就是十几个 Filter,由一个名叫 springSecurityFilterChain 的 DelegatingFilterProxy(把容器级 Filter 桥接到 Spring Bean 的代理)接手,再交给容器里的 FilterChainProxy 挑选具体那条链。

84 / 146
类比

这相当于机场里有两条并行的安检通道。你的 LoginInterceptor 是航空公司自己在登机口加的一次复核;Security 的链是机场统一安检——所有旅客都得走完,permitAll(允许匿名访问)也只是「最后一位工作人员对你点头」,前面那道金属门照样要走。所以给公开接口加 permitAll 省不掉耗时,只省掉「是否要求登录」。

85 / 146
内核实验
TeaVMSecurity 过滤器链 · 从进门到判权限未启动
依次试 chain(过滤器顺序全景)/ anon(匿名身份怎么补)/ deny(401 与 403 的区别)/ login(表单登录全流程)/ csrf(CSRF 拦截)
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
86 / 146

看完这条链,你要带走三个结论:

87 / 146
  • 认证在前、授权在最后:链尾的 AuthorizationFilter(旧版叫 FilterSecurityInterceptor)才是唯一决定「放不放行」的那一位
  • 401 与 403 的分岔只看「你是谁」:完全没身份 → AuthenticationEntryPoint 给 401;有身份但权限不够 → AccessDeniedHandler 给 403
  • 需要自定义鉴权时,优先扩这条链,而不是再叠一个自己的 LoginInterceptor——否则同一个请求会被两套体系各判一次,出错时极难定位是谁拦的
88 / 146
小节
十四、上手实验四:请求到底死在哪一站
89 / 146

第十三节讲「谁拦的」,这一节讲「什么时候拦的」。同一个 URL,处理器找到了却没人能执行、参数填不上、异常没人接管,报出来的东西完全不同。先用分派链路确认拦截器所处的位置:

90 / 146
内核实验
TeaVM请求链路实测:拦截器在哪一环未启动
观察参数解析与执行的先后,理解拦截器前置发生的时机
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
91 / 146

再把 /users/42、/users、/nope 三条路径分别和 preHandle 的行为对照一遍:

92 / 146
对照表
请求分派链路上发生的事拦截器视角
/users/42命中 @GetMapping("/users/{id}") → 参数解析 "42" → Long → 反射调用 → 写 JSONpreHandle 拿到的是 HandlerMethod,能读到方法上的注解
/users命中列表方法,无需路径变量同上,但 HandlerMethod 是另一个方法——按注解放行才成立
/nopegetHandler 返回 null → noHandlerFound → 404拦截器压根没机会执行:没有 handler,就没有拦截器链
93 / 146

然后看异常落点。preHandle 抛出的异常、参数解析失败的异常、Controller 抛出的异常,会走同一条解析器链,但命中的解析器和最终状态码不同:

94 / 146
内核实验
TeaVM异常解析器链现场:谁把你的异常接住了未启动
依次试 valid(校验失败 400)/ convert(类型转换失败)/ handler(@ExceptionHandler)/ status(@ControllerAdvice 兜底)/ none(没人接管 → 500)
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
95 / 146
  • 要点:想让未登录返回结构统一的 JSON,就必须让异常进入这条链——这正是第五节推荐「抛异常优于返回 false」的原因:return false 的请求根本不会走到这里
  • 最后一个参数 none 是新手最陌生的一格:三个解析器都返回 null 时,异常继续往外抛,由 Tomcat 的错误页机制兜底,客户端拿到一张 Whitelabel 页而不是你的错误体
96 / 146

拦截器写对了、状态码给对了,最后还可能栽在返回值形态上:同一句 return "redirect:/login",在 @Controller 里是视图名、在 @RestController 里是 JSON 字符串。四档都点一遍,特别是 missing——它就是「未登录时浏览器跳到一张白页」的真身:

97 / 146
内核实验
TeaVM@Controller 还是 @RestController:拦截器返回的那句字符串被当成什么未启动
按 view → json → missing → string 顺序点:看清 preHandle 里写重定向 / 写 JSON / 什么都不写,三种落在响应上的形态差别
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
98 / 146
小节
14.1 内核控制台:把顺序自己敲一遍
99 / 146

实验是「看内核跑」,控制台是「你指挥内核跑」。下面这台是真容器,回显全部由浏览器里的 Java 内核算出来——先用 beans 确认拦截器与 Filter 都被注册了,再用 lab 复现上面每一个现场:

100 / 146
内核控制台
101 / 146

beans 里没有你的 LoginInterceptor,通常不是没写 @Component,而是注册方法没被调用——WebMvcConfigurer 不在启动类的包下时,addInterceptors 会被静默跳过。第 3、4 条是第五节那条分岔的可敲版:同一个 handler,order 给你完整九站,pre 给你「从这里开始全部消失」。

102 / 146
小节
十五、常见报错速查
103 / 146

下表四列固定为「报错原文片段 / 真实原因 / 30 秒自救 / 深挖看第几篇」。报错原文请整段复制去搜索,不要意译——搜索引擎认全类名和整句英文,不认你的中文描述。

104 / 146
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
接口返回 HTTP 200 但响应体空白,前端什么提示都拿不到preHandle 直接 return false,doDispatch 就此中断:既不执行 Controller,也不触发 HandlerExceptionResolver,响应头保持默认 200改成抛异常交给 @RestControllerAdvice;确实要返回 false,就自己在返回前 response.getWriter().write(...) 并 flush本篇第五节
浏览器控制台:Access to fetch at '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.响应里缺 Access-Control-Allow-Origin 头:CORS 配置写在了拦截器 / @CrossOrigin 上,而请求在更早的鉴权过滤器就被拒了,压根没走到加头的地方把 CORS 放到最靠前的 Filter(Ordered.HIGHEST_PRECEDENCE),并确认 OPTIONS 预检被提前回掉;顺手排除「拦截器把预检挡了」的可能本篇第十二节 cors 格
启动直接失败:IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*"allowCredentials=true(要带 cookie)的同时来源写了通配 ——规范禁止这样组合,因为 Access-Control-Allow-Origin 不能既回显 又携带凭证把来源显式列出来,或改用 allowedOriginPatterns("*");生产环境永远不要为了省事把两者同时留空本篇第十五节末
preHandle 里想判断「这个方法有没有 @NeedLogin 注解」,结果强转报错或永远是 nullhandler 参数不是永远是 HandlerMethod:静态资源命中 ResourceHttpRequestHandler、错误页命中 ParameterizableViewController 时它是别的类型先 if (!(handler instanceof HandlerMethod hm)) return true; 放行非方法处理器,再读注解本篇第九节
过滤器里打日志读了请求体,随后控制器的 @RequestBody 变成 null 或直接报 HttpMessageNotReadableException: Required request body is missing(或 Stream closed)请求体是一次性的字节流:request.getInputStream() 被读到底就没了,Spring 再来反序列化时无米下锅用 ContentCachingRequestWrapper 或自定义 HttpServletRequestWrapper 把字节缓存成可重复读;只在 Filter 层做,别在拦截器里读本篇第十二节 scope 格
加了 @Component 的 Filter「死活不执行」用的是 @WebFilter 却没有 @ServletComponentScan;或者 Filter 注册了但 urlPatterns 与实际路径不匹配换 FilterRegistrationBean 显式 addUrlPatterns("/*"),并在其 doFilter 第一行打日志确认它真的被调到本篇 3.1 节
明明写了拦截器,登录页 / CSS / JS 一起被挡成 401拦截器配的是 /**,把静态资源和错误页一并纳入管辖;而 /error 被拦会让真实异常显示成空白页excludePathPatterns("/static/", "/favicon.ico", "/error", "/actuator/") 显式排除本篇第九节
异步接口(@Async、CompletableFuture)的日志里没有 traceId,或串成了别人的MDC 基于 ThreadLocal,线程池复用线程;异步任务不在原线程上执行,自然看不到父线程的上下文提交任务前 MDC.getCopyOfContextMap()、执行前 setContextMap(...),或用 TaskDecorator 统一包装本篇第八节
105 / 146
说明

上面第二、三行是两种完全不同的失败——第一行是「运行期浏览器拒绝」,第三行是「启动期 Spring 直接拒绝」。排查时先看应用能不能启动,能启动再去翻浏览器;顺序反了会浪费时间。

106 / 146

表里第四行那种「上线没事、一打开首页就 500」的现场最值得练读栈。栈很长,但凶手只有一行:

107 / 146
报错急救
报错急救ClassCastException: ResourceHttpRequestHandler → HandlerMethod
拦截器强转 handler 的报错现场

登录拦截器上线后接口全绿,直到有人用浏览器打开首页——页面变 500,日志里刷出长长一段栈,而你的代码一行都没改过。

java.lang.ClassCastException: class org.springframework.web.servlet.resource.ResourceHttpRequestHandler cannot be cast to class org.springframework.web.method.HandlerMethod
at com.example.web.LoginInterceptor.preHandle(LoginInterceptor.java:31)
at org.springframework.web.servlet.HandlerExecutionChain.applyPreHandle(HandlerExecutionChain.java:59)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1060)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
108 / 146
提示

搜报错时只搜冒号后第一段原文(例如 cannot be cast to),命中率远高于搜整句。

109 / 146
小节
15.1 配置生成器:排查期该开的开关一次勾全
110 / 146

上面这些失败里,有一半是「看不见现场」造成的:静态资源被拦、请求体读空、404 与拦截器的关系。勾几下把排查期要看的都拿到,比在代码里加十行日志快得多:

111 / 146
生成器
生成器把拦截层排查期该开的配置一次配全application.yml2 / 4
先勾「服务器」与「日志」,看 error.* 与 org.springframework.web 的级别怎么把「哪个 handler 命中了」暴露出来;再勾「上传」看 multipart 那几行——它正是 Filter 读请求体最容易翻车的接口;最后勾「环境 profile」,让这些都只在 dev 生效
产物
server:
  port: 8080
  servlet:
    encoding: { charset: UTF-8, enabled: true, force: true }
  compression: { enabled: true, min-response-size: 2048 }

spring:
  application:
    name: demo-service

logging:
  level:
    root: INFO
    com.example.demoservice: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: DEBUG   # 打 SQL 与参数
  file:
    name: logs/app.log
  logback:
    rollingpolicy: { max-file-size: 50MB, max-history: 14 }
勾了这些,代价与理由在这里
serverserver.port 被命令行 --server.port=8081 覆盖,也吃 SERVER_PORT 环境变量。
logging级别可按包精细控制;logging.level.root=DEBUG 会把三方库全打爆,别在生产这么干。
112 / 146

勾完对一句:logging.level.org.springframework.web=debug 会把命中的 handler 类型打印出来,第四节和第十五节讲的静态资源、错误页那两种 handler 就是在这里现形的——生产环境记得收回去。

113 / 146
小节
十六、随堂自测
114 / 146
随堂自测
随堂自测Controller 方法里抛了一个异常,而这个异常最终被你的 @RestControllerAdvice 优雅地处理成了 400。请问拦截器的 postHandle 执行了吗?
先自己选一个,选中立刻告诉你对不对
115 / 146
随堂自测
随堂自测你要实现「凡是标了 @NeedLogin 注解的方法必须登录,其他一律放行」。放在哪一层最省力?
先自己选一个,选中立刻告诉你对不对
116 / 146
小节
十七、沙盘:这段逻辑到底该放哪一层
117 / 146

这个沙盘不做计算,做的是岗位匹配。左边选一层,右边选需求,输出会告诉你三层各自最关心的三件事能不能满足:能否拿到 HandlerMethod(哪个类的哪个方法)、能否拿到响应体(写出前的内容)、事务是否已经开启。

118 / 146
沙盘
沙盘这段逻辑该放哪一层
运行结果
HandlerMethod ✓ 响应体 ✓(需自己写) 事务 ✗
if (!(handler instanceof HandlerMethod hm)) return true; // 静态资源先放行
#能读注解、能按 excludePathPatterns 精确放行,这是它的主场
首选。记得:要给标准错误就抛异常,返回 false 得自己写好响应体
119 / 146
提示

玩这个沙盘的正确顺序是——先问「这段逻辑需要看见什么」,再挑层,而不是「反正 Filter 最早,就写 Filter」。三层的关系是接力:Filter 管进不进得来,拦截器管放不放行到这个 handler,AOP 管这次方法调用前后做什么。

120 / 146
小节
十八、动手练习
121 / 146
小节
第一档 · 照做
122 / 146

目标:一次跑通「Filter + 两个 Interceptor + 一个 Advice」,亲眼看到顺序、短路和异常路径三种形态。

123 / 146
java
package com.example.lab.chain;import jakarta.servlet.*;import jakarta.servlet.http.*;import org.slf4j.*;import org.springframework.context.annotation.*;import org.springframework.core.Ordered;import org.springframework.stereotype.*;import org.springframework.web.filter.OncePerRequestFilter;import org.springframework.web.servlet.*;import org.springframework.web.servlet.config.annotation.*;import java.io.IOException;import java.util.UUID;@Component@Order(Ordered.HIGHEST_PRECEDENCE)class TimingFilter extends OncePerRequestFilter {        // OncePerRequestFilter:保证一次请求只跑一遍    private static final Logger log = LoggerFactory.getLogger(TimingFilter.class);    @Override    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)            throws ServletException, IOException {        long start = System.currentTimeMillis();        req.setAttribute("traceId", UUID.randomUUID().toString().substring(0, 8));        try {            chain.doFilter(req, res);                     // ← 这一行之后才轮到 DispatcherServlet        } finally {            log.info("[FILTER] {} {} status={} cost={}ms trace={}", req.getMethod(),                    req.getRequestURI(), res.getStatus(), System.currentTimeMillis() - start,                    req.getAttribute("traceId"));        }    }}@Componentclass AuthInterceptor implements HandlerInterceptor {     // 排在埋点拦截器之前注册    @Override    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler)            throws Exception {        if (!(handler instanceof HandlerMethod hm)) {            return true;                                  // 静态资源 / 错误页不是方法处理器,先放行        }        if (hm.hasMethodAnnotation(PublicApi.class)) {            return true;                                  // 有 HandlerMethod 才能这样按注解放行        }        if (req.getHeader("Authorization") == null) {            throw new IllegalStateException("NOT_LOGIN"); // 抛异常 → 走统一错误体;返回 false → 空 200        }        return true;    }}@Componentclass MetricsInterceptor implements HandlerInterceptor {    private static final Logger log = LoggerFactory.getLogger(MetricsInterceptor.class);    @Override    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {        req.setAttribute("t0", System.currentTimeMillis());        return true;    }    @Override    public void postHandle(HttpServletRequest req, HttpServletResponse res,                           Object handler, ModelAndView mv) {        log.info("[METRICS] postHandle ran for {}", handler);   // 异常请求永远看不到这一行    }    @Override    public void afterCompletion(HttpServletRequest req, HttpServletResponse res,                                Object handler, Exception ex) {        long cost = System.currentTimeMillis() - (long) req.getAttribute("t0");        log.info("[METRICS] {} cost={}ms ex={}", handler, cost, ex == null ? "none" : ex.getClass().getSimpleName());    }}@RestController@RequestMapping("/api")class LabController {    @GetMapping("/public") @PublicApi    public String open() { return "ok"; }    @GetMapping("/me")    public String me(@RequestHeader("Authorization") String token) { return "hello " + token; }    @GetMapping("/boom") @PublicApi    public String boom() { throw new RuntimeException("boom"); }}
124 / 146
java
@Configurationclass MvcConfig implements WebMvcConfigurer {    private final AuthInterceptor auth; private final MetricsInterceptor metrics;    MvcConfig(AuthInterceptor a, MetricsInterceptor m) { this.auth = a; this.metrics = m; }    @Override    public void addInterceptors(InterceptorRegistry registry) {        registry.addInterceptor(auth).addPathPatterns("/api/**")          // 第 1 个注册 = 第 1 个 preHandle                .excludePathPatterns("/api/public", "/static/**", "/error");        registry.addInterceptor(metrics).addPathPatterns("/api/**");      // 第 2 个注册    }}
125 / 146

启动后用 curl 逐条验证(每条都换一种链路形态):

126 / 146
bash
# 1) 公开接口:白名单命中,两道拦截器都放行curl -i localhost:8080/api/public# 2) 未带 Authorization:AuthInterceptor 抛异常 → 看 postHandle 有没有出现curl -i localhost:8080/api/me# 3) 正常带 tokencurl -i -H "Authorization: abc123" localhost:8080/api/me# 4) 控制器自己抛异常curl -i localhost:8080/api/boom
127 / 146

预期日志顺序(第 3 条):

128 / 146
text
[METRICS] postHandle ran for ...   # 只有成功请求才有这行[METRICS] ...LabController#me... cost=4ms ex=none[FILTER] GET /api/me status=200 cost=9ms trace=8f3c1a02
129 / 146

第 2、4 条预期:完全没有 postHandle ran 这一行,但 [METRICS] ... ex=... 和 [FILTER] 两行照样打出来——这就是「postHandle 成功专属、afterCompletion 必到、Filter 包在最外层」的实证。

130 / 146
小节
第二档 · 变体
131 / 146

三个改动,每个只需一两行,重点是观察报错原文与响应体的变化:

132 / 146
  1. 把 AuthInterceptor.preHandle 里的 throw 换成 return false,再跑第 2 条 curl → 你会观察到状态码仍是 200、响应体空白,且 [METRICS] 那一行也不打了(因为第 1 个拦截器短路后,第 2 个连 preHandle 都没执行)。这就是第十七节沙盘 interceptor|auth 那格说的坑
  2. 把两个 addInterceptor 的注册顺序调换(metrics 先、auth 先注册的变成 metrics),再跑第 2 条 → 你会观察到 [METRICS] ... ex=IllegalStateException: NOT_LOGIN 出现了:埋点拦截器因为「先进过」所以收到了收尾回调。结论:埋点拦截器要排在鉴权之前,否则被拒的请求全部漏记
  3. 在 TimingFilter 的 chain.doFilter 之前加一行 req.getInputStream().readAllBytes(),然后 POST 一个带 @RequestBody 的接口 → 你会观察到 HttpMessageNotReadableException: Required request body is missing,改成 ContentCachingRequestWrapper 包裹后立即恢复
133 / 146
小节
第三档 · 造一个
134 / 146

做一个「接口守门员 + 记账员」组合:所有写操作(POST / PUT / DELETE)必须登录并携带 X-Request-Id,同时给出一份「谁被拦在哪一层」的诊断报告。

135 / 146

要求:

136 / 146
  • 一个 CorsFilter:HIGHEST_PRECEDENCE,对 OPTIONS 直接回 200 且不进业务;带 cookie 场景用 allowedOriginPatterns 而不是 *
  • 一个 AuditInterceptor:preHandle 里判断 handler instanceof HandlerMethod,读取类上的 @RequestMapping 前缀 + 方法名写进 MDC;afterCompletion 打印耗时并把 ex != null 的请求单独标记
  • 一个 RepeatableBodyFilter:用 HttpServletRequestWrapper 缓存请求体,使同一个 JSON 既能被过滤器读、又能被 @RequestBody 读
  • 一个 @RestControllerAdvice:把 IllegalStateException("NOT_LOGIN") 转成 {"code":40100,"msg":"请先登录"} 且 HTTP 仍为 401
137 / 146

验收清单:

138 / 146
  • [ ] curl -i -X OPTIONS -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: POST" localhost:8080/api/orders 返回 200 且响应头里有 Access-Control-Allow-Origin(证明 CORS 在鉴权之前)
  • [ ] 不带 Authorization 打 /api/orders 得到 401 + 统一 JSON 错误体,不是空 200、也不是 Whitelabel 页
  • [ ] 日志里能看到「类名#方法名」(证明拦截器拿到了 HandlerMethod,把它挪进 CorsFilter 立刻做不到)
  • [ ] POST 一个合法 JSON,控制器能正常拿到对象,同时过滤器日志里也打印出了原始 body(证明 wrapper 生效)
  • [ ] 故意让控制器抛异常,确认审计日志仍然记录了该请求(证明记账挂在 afterCompletion 而非 postHandle)
139 / 146
小节
十九、要点自查
140 / 146
自检

不看上文,能否按顺序背出一次请求经过的全部环节,并指出哪两步是「逆序」发生的?(Filter 前段正序 → DispatcherServlet → preHandle 正序 → 参数解析与校验 → Controller(内含 AOP 环绕)→ postHandle 逆序 → 异常解析 → afterCompletion 逆序 → 消息转换器写出 → Filter 后段逆序)

141 / 146
自检

preHandle 返回 false 之后,哪些钩子还会执行、哪些不会?(后续拦截器的 preHandle、参数解析、Controller、postHandle 全部跳过;只有已经返回过 true 的靠前拦截器收到 afterCompletion;当前这个返回 false 的拦截器自己也不会收到)

142 / 146
自检

为什么统计耗时写在 afterCompletion 而不是 postHandle?(postHandle 在异常时被整个跳过,正好丢掉你最想看的失败样本;afterCompletion 是 finally 语义,一定执行还带着原始异常)

143 / 146
自检

拦截器里 handler 参数的三种可能类型是什么,为什么必须先做 instanceof HandlerMethod 判断?(HandlerMethod、ResourceHttpRequestHandler(静态资源)、ParameterizableViewController(错误页)等;不判断就强转会在访问 /favicon.ico 或 /error 时抛 ClassCastException)

144 / 146
自检

为什么 @EnableWebMvc 一加上页面就白了、静态资源 404 了?(它关掉了 Spring Boot 的 MVC 自动配置,包括默认静态资源映射;自定义配置请实现 WebMvcConfigurer 而不要写 @EnableWebMvc)

145 / 146
口诀

值机柜台不认识航班,安检口才认识——要「哪个方法」找拦截器,要「原始字节」找 Filter,要「方法前后」找 AOP;postHandle 只给成功者发糖,afterCompletion 才是离场必结账的那一位。

146 / 146
总结

本篇要记住的是一条顺序线 + 四条认知。顺序线是 Filter 前置 → DispatcherServlet → Interceptor.preHandle → Controller(内含 AOP 环绕)→ 后置逆序:postHandle → 视图渲染 → afterCompletion → Filter 后置 → 客户端。四条认知:① 前置正序、后置逆序;② postHandle 只在成功时执行,afterCompletion 一定执行;③ AOP 拦不住 Filter / Interceptor,也无法「阻止请求」,因为它发生在方法调用之后;④ 三者是接力而非替代——Filter 管进不进得来,Interceptor 管放不放行,AOP 管方法前后做什么。 把这条线和这四条刻进脑子,再遇到「拦截该放哪」的问题,你会毫不犹豫。