Filter、Interceptor、AOP 执行顺序与实战
先别管 Filter、Interceptor、AOP 这些名字。你只需要记住一件事:一个 HTTP 请求就像一名要登机的乘客,从航站楼门口一路走到登机口,中间有几道完全不同的关卡,每道关卡能看到的消息量天差地别。
值机柜台 = Filter(过滤器)。它在 Servlet 容器(Tomcat,真正接住网络连接的服务器)这一层,只查你的身份证和行李——它认识「人」,却压根不知道你要坐哪班飞机、去哪个登机口。安检口 = Interceptor(拦截器),属于 Spring MVC,它手里有航班表:能看到你最终要调用「哪个类的哪个方法」(这个东西叫 HandlerMethod),所以它能说「这班机你不许上」。登机口复核 = AOP(面向切面编程——把「每个方法都要做的杂事」抽出来统一织进方法调用前后的技术),它只盯着「登机」这一个动作本身,管不着你在航站楼里怎么走的。
那 afterCompletion(请求收尾时必调的回调)是什么?是离场时才结账。前面每一段流程都可能出岔子——延误、改签、被拒载——但收银员不管你这场戏怎么演的,只管在你离开时把账单结掉、把桌子收干净。所以「统计耗时」「清理 ThreadLocal(绑在线程上的私有储物柜)」永远写在 afterCompletion,而不是写在只有顺利登机才有人喊的 postHandle。

学完这一篇,你要能脱口而出地回答三个问题:
- 控制器抛异常时,
postHandle还执行吗?(不执行,直接跳到异常解析,再走afterCompletion) - 为什么登录校验放拦截器比放 Filter 省事?(拦截器能拿到
HandlerMethod,能按注解和白名单精确放行) - 想在拦截器里读一眼
@RequestBody的请求体,为什么控制器随后拿到空对象?(输入流是一次性的,读一次就到底)
「登录校验写在 Filter 还是 Interceptor?」「统计接口耗时用拦截器还是 AOP?」——几乎每个 Spring 新人都会在同一个路口犯迷糊:三种机制似乎都能在业务代码执行前「拦一下」,于是随手挑一个,直到某天发现「拦截器里拿不到方法名」或「切面拦不住静态资源」才回头补课。
先把身份说清楚,后面的选择就水到渠成:
| 机制 | 所属规范 / 容器 | 作用粒度 | 能拿到什么 | 典型场景 |
|---|---|---|---|---|
| Filter | Servlet 规范(Tomcat) | 一次 HTTP 请求 / 响应 | ServletRequest / ServletResponse;不知道要调哪个控制器方法 | 跨域 CORS、字符编码、请求日志、Gzip 压缩、XSS 过滤 |
| Interceptor | Spring MVC | Handler 级别(进入控制器前后) | HandlerMethod(哪个 Bean 的哪个方法)、ModelAndView | 登录校验、权限判断、接口审计、本地化 |
| AOP | Spring 容器(方法级) | 任意 Spring Bean 的任意方法 | 方法签名、参数、返回值、抛出的异常 | 事务、缓存、接口耗时统计、幂等、限流 |
三者最大的分水岭是「知不知道业务方法」。Filter 活在 Servlet 世界里,看到的只有 HttpServletRequest,根本不知道后面要调用哪个 Controller;Interceptor 由 Spring MVC 掌管,能拿到 HandlerMethod;AOP 更靠里,直接围着某个 Bean 的方法转。想让逻辑「离业务更近」,往 Interceptor / AOP 走;想让逻辑「离容器更近、对所有请求一视同仁」,才用 Filter。

把一次正常请求拉直,顺序是这样的(这段顺序值得背下来):
请求进入 └─ 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、关闭资源

那如果控制器抛异常呢?异常线是这样的:Filter 前置 → preHandle → Controller 抛异常 → 跳过 postHandle → afterCompletion(逆序,异常作为参数传入)→ 异常上交 HandlerExceptionResolver 处理 → 补写响应 → Filter 后置。记住一句:postHandle 是「成功专属」,afterCompletion 是「收尾专属」,两者一个可能被跳过、一个必然执行。
上面这条线只能一次性看完。下面这张图把它拆成可以一格一格点的九站——学习顺序时最有效的不是背,而是搞清楚「每站在等谁」:
Filter 适合做「与业务无关、且对所有请求生效」的脏活。两个最经典的例子——跨域和访问日志。
跨域过滤器必须最早执行,否则预检请求(OPTIONS)会被后面的逻辑拦掉:
@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,不必往下走
请求耗时日志,正好演示「后置代码」:
@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()已经是最终状态码——因为后置代码在响应提交前执行
@Component 的 Filter 会被 Spring Boot 自动注册,但顺序不好控。要精确控制顺序,用 FilterRegistrationBean:
@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; }}另一种写法是注解式,但必须让启动类扫描它:
@WebFilter(urlPatterns = "/*", filterName = "accessLogFilter")public class AccessLogFilter extends OncePerRequestFilter { /* ... */ }// 然后在启动类上补一句,否则 @WebFilter 完全不生效@ServletComponentScan@SpringBootApplicationpublic class DemoApplication { }| 注册方式 | 顺序控制 | 依赖注入 | 适用场景 |
|---|---|---|---|
FilterRegistrationBean | setOrder 精确可控 | 天然支持 | 需要顺序、需要注入其他 Bean(推荐) |
@WebFilter + @ServletComponentScan | 靠 @Order,容易混乱 | 需借助容器回调 | 简单场景、原生 Servlet 习惯 |
@Component 直接实现 | 靠 @Order | 支持 | 全局默认,但顺序不直观 |
@WebFilter 最经典的坑是忘了配 @ServletComponentScan。注解写着、类也在,但过滤器就是不执行——因为它不经过 Spring 的组件扫描,必须显式开启 Servlet 组件扫描才注册。
登录校验是 Interceptor 的主场:它知道「这次要调哪个方法」,还能被 excludePathPatterns 精确放行。
@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、计数器、请求级缓存,一个都不能漏
注册拦截器并配置黑白名单:
@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,但绝不能把请求数据存进成员变量(见第九节)
这是本节最该记住的对照表——它决定了客户端到底能不能拿到一个「像样的错误」:
| 维度 | preHandle 返回 false | preHandle 抛异常 |
|---|---|---|
| 是否调用控制器 | 否 | 否 |
| 响应行为 | 需自己写好响应,否则客户端拿到空响应 / 含糊的 200 | 冒泡到 DispatcherServlet,交给 HandlerExceptionResolver |
是否走 postHandle | 否 | 否 |
是否走 afterCompletion | 当前这个拦截器不会(只有已放行的靠前拦截器会走) | 已放行的拦截器都会走,异常作为参数传入 |
| 错误响应是否统一 | 需手写,容易和全局异常体不一致 | 复用 @RestControllerAdvice,契约天然一致 |
| 适用场景 | 已经自行完成响应(重定向、直接写 JSON / HTML) | 需要标准错误体、想复用全局异常处理 |
preHandle 返回 false 时,当前拦截器的 afterCompletion 不会执行。因为 DispatcherServlet 在 applyPreHandle 阶段就中断了,只会对那些「已经返回过 true」的拦截器触发 afterCompletion。如果你在 preHandle 里放了 ThreadLocal,又在 afterCompletion 里清理,一旦某次返回 false,就会出现内存残留——一定要想清楚在哪一步清理。
能给出标准错误时优先抛异常,返回 false 只留给「已经自己写好了响应」的场景。 这与第 22 篇 DispatcherServlet 的结论一脉相承:拦截器的语义是「守门」,守门失败要给出明确信号,而不是把客户端晾在空响应前。
两条路在源码里只差一行,但结果差一个数量级。开调试台逐行走一遍,把「谁被跳过」看得清清楚楚——请特别注意第 2 步与第 4 步:
boolean interest = mappedHandler.applyPreHandle(request, response);if (!interest) { return; } // 短路:后面的一切都不发生mv = ha.handle(request, response, mappedHandler.getHandler);mappedHandler.applyPostHandle(request, response, mv);} catch (Exception ex) { dispatchException = ex; }processDispatchResult(request, response, mappedHandler, mv, dispatchException);mappedHandler.triggerAfterCompletion(request, response, ex);| 拦截器数 | 2 |
| 当前调用 | AuthInterceptor.preHandle |
| 已放行计数 | 0 |
DispatcherServlet.doDispatchHandlerExecutionChain.applyPreHandle
动图第 ⑤ 步是最贵的一格:返回 false 的那个拦截器自己收不到 afterCompletion。埋点、ThreadLocal 清理、审计落库如果写在它的 afterCompletion 里,被它拒掉的请求就全都漏记——这也是第十八节第二档那个「调换注册顺序」练习要你亲眼看到的现象。
很多人第一次用 AOP 时都想过:「既然切面能环绕方法,那让它拦住整个请求行不行?」答案是不行,原因藏在「代理」二字里。
- AOP 的本质是Spring 容器为你管理的 Bean 创建代理对象,方法调用必须经过容器注入的代理,切面才能生效
- Filter 由 Servlet 容器(Tomcat)直接 new 并调用,根本没经过 Spring 代理,切面无从下手
- Interceptor 由 Spring MVC 直接调用注册的那个对象,
preHandle也不是「通过代理调用」的 bean 方法,所以切面同样拦不到 - 更根本的是时机:AOP 环绕的是「方法调用」,请求必须已经进入方法体才会触发。而
preHandle的意义恰恰是「在方法被调用之前」拒绝请求——AOP 做不到这件事,等它介入时业务方法已经在执行了
AOP 真正擅长的位置是「方法内部」:事务、缓存、耗时、幂等、限流。它也有自己的边界,用之前必须知道:
- 只对Spring 容器管理的 Bean 生效,自己
new出来的对象没有代理 - 同类内部自调用(
this.otherMethod())不走代理,切面失效——要用注入自身或AopContext.currentProxy() private/final方法 CGLIB 覆盖不了,切面同样失效
一句话归纳三者的边界——Filter 管「进不进得来」,Interceptor 管「要不要放行到这个 Handler」,AOP 管「这个方法调用前后做什么」。 三者是接力关系,不是替代关系。
三种机制都能排序,但排法各不相同,混在一起很容易搞乱。与其背表,不如玩一局闯关:左边是你写的排序代码,右边点它真实的生效规则——配错会当场告诉你为什么。
三套规则里,「数字越小越先」是一致的,但Interceptor 不看数字、只看注册顺序——这是最容易踩的差异。想调整拦截器顺序,只能调整 addInterceptor 的调用次序,给它加 @Order 是没用的。
真实的链路追踪需要「一次请求一个 traceId」,从最外层一直带到日志里。这时三者要配合:Filter 负责生成并绑定,Interceptor / AOP / 业务只管复用。
@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 带到下一个请求
日志格式里引用 %X{traceId} 即可自动打印:
<!-- 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 共享 → 各处复用」的经典组合
这套「一处生成、全程复用、收尾清理」的接力值得单独走一遍,因为新手最常在这里翻车的是只做了前两步、忘了最后一步——线上日志里 traceId 串号,十次有八次是 finally 那行 remove 被省掉了:

MDC 基于 ThreadLocal,异步线程(@Async、线程池、CompletableFuture)里取不到父线程的 traceId。跨线程传递需要手动包装任务(提交前 MDC.getCopyOfContextMap(),执行前 setContextMap),否则异步日志里的 traceId 会缺失或串号。
拦截器是单例 Bean,禁止在成员变量里存请求私有状态。 LoginInterceptor 全应用只有一个实例,被所有并发请求共享。如果你写了 private LoginUser currentUser; 并在 preHandle 里赋值,A 请求的用户信息会被 B 请求覆盖,导致越权——这类 bug 偶发且极难复现。正确做法是存进 ThreadLocal(用完即清)或 request.setAttribute,永远不要放进成员变量。
拦截器只对「进入 DispatcherServlet 的请求」生效,静态资源常常"漏网"。 当静态资源交给容器的默认 Servlet 处理时,它们根本不经过 DispatcherServlet,拦截器自然对其不生效;而当静态资源由 Spring MVC 的 ResourceHttpRequestHandler 处理时,/ 的拦截器又会把它一并拦下,导致 JS / CSS 请求被登录校验挡掉。结论:一定要用 excludePathPatterns 显式排除静态资源目录(如 /static/、/favicon.ico),别指望「默认不生效」。
下面这个演示把 DispatcherServlet 的分派流程做成了可切换路径。切换 /users/42、/users、/nope,重点观察「参数解析」与「控制器执行」的先后——preHandle 恰恰发生在这两者之前,这就是拦截器能「提前拒绝请求」的时机。
前面十节都是「讲」。这一节全部换成「跑」——下面这个内核演示把本章最容易吵起来的五件事做成了可切换的参数,建议按 order → pre → ex → cors → scope 的顺序各点一遍,每格都对应一类真实工单。

同一个需求放错层,就会直接失去它的能力——这张表就是上图的逐条展开:
| 你想做的事 | 只有这一层做得到 | 另外两层为什么不行 |
|---|---|---|
| 给所有响应加跨域头 / 压缩 / traceId | Filter | 拦截器在 MVC 层内部,切面更是既看不见静态资源也看不见容器自带的 Filter |
按方法上的 @LoginRequired 决定放不放行 | Interceptor | Filter 拿不到 HandlerMethod,只能靠 URL 字符串匹配;切面生效时方法已经被调用了 |
| 统计某个 Service 调用耗时 / 做幂等 | AOP | Filter 看不见 Service Bean,拦截器只包住控制器,@Service 内部调用从它眼皮底下溜走 |
| 在进入控制器之前直接给出 401 | Interceptor(preHandle) | 切面是「环绕调用」,方法早已在栈上;Filter 虽然也能中断,但没有方法级白名单 |
| 无论请求怎么结束都要清理 ThreadLocal | Interceptor(afterCompletion) | 切面要自己捕获异常才走得完;Filter 的 finally 能用,但拿不到「这次是哪个 handler」 |
五个参数分别要看到什么,先给你划重点:
- 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:三道关卡各自「看得见什么」的硬边界,正好对应上一张对照图
第八节的 traceId 组合里,如果项目引入了 Spring Security,你会发现「自己写的拦截器」和「Security 的过滤器」在抢同一份工作。先看清它的链条结构:Security 的整条链本身就是十几个 Filter,由一个名叫 springSecurityFilterChain 的 DelegatingFilterProxy(把容器级 Filter 桥接到 Spring Bean 的代理)接手,再交给容器里的 FilterChainProxy 挑选具体那条链。
这相当于机场里有两条并行的安检通道。你的 LoginInterceptor 是航空公司自己在登机口加的一次复核;Security 的链是机场统一安检——所有旅客都得走完,permitAll(允许匿名访问)也只是「最后一位工作人员对你点头」,前面那道金属门照样要走。所以给公开接口加 permitAll 省不掉耗时,只省掉「是否要求登录」。
看完这条链,你要带走三个结论:
- 认证在前、授权在最后:链尾的
AuthorizationFilter(旧版叫FilterSecurityInterceptor)才是唯一决定「放不放行」的那一位 - 401 与 403 的分岔只看「你是谁」:完全没身份 →
AuthenticationEntryPoint给 401;有身份但权限不够 →AccessDeniedHandler给 403 - 需要自定义鉴权时,优先扩这条链,而不是再叠一个自己的
LoginInterceptor——否则同一个请求会被两套体系各判一次,出错时极难定位是谁拦的
第十三节讲「谁拦的」,这一节讲「什么时候拦的」。同一个 URL,处理器找到了却没人能执行、参数填不上、异常没人接管,报出来的东西完全不同。先用分派链路确认拦截器所处的位置:
再把 /users/42、/users、/nope 三条路径分别和 preHandle 的行为对照一遍:
| 请求 | 分派链路上发生的事 | 拦截器视角 |
|---|---|---|
/users/42 | 命中 @GetMapping("/users/{id}") → 参数解析 "42" → Long → 反射调用 → 写 JSON | preHandle 拿到的是 HandlerMethod,能读到方法上的注解 |
/users | 命中列表方法,无需路径变量 | 同上,但 HandlerMethod 是另一个方法——按注解放行才成立 |
/nope | getHandler 返回 null → noHandlerFound → 404 | 拦截器压根没机会执行:没有 handler,就没有拦截器链 |
然后看异常落点。preHandle 抛出的异常、参数解析失败的异常、Controller 抛出的异常,会走同一条解析器链,但命中的解析器和最终状态码不同:
- 要点:想让未登录返回结构统一的 JSON,就必须让异常进入这条链——这正是第五节推荐「抛异常优于返回
false」的原因:return false的请求根本不会走到这里 - 最后一个参数
none是新手最陌生的一格:三个解析器都返回 null 时,异常继续往外抛,由 Tomcat 的错误页机制兜底,客户端拿到一张 Whitelabel 页而不是你的错误体
拦截器写对了、状态码给对了,最后还可能栽在返回值形态上:同一句 return "redirect:/login",在 @Controller 里是视图名、在 @RestController 里是 JSON 字符串。四档都点一遍,特别是 missing——它就是「未登录时浏览器跳到一张白页」的真身:
实验是「看内核跑」,控制台是「你指挥内核跑」。下面这台是真容器,回显全部由浏览器里的 Java 内核算出来——先用 beans 确认拦截器与 Filter 都被注册了,再用 lab 复现上面每一个现场:
beans 里没有你的 LoginInterceptor,通常不是没写 @Component,而是注册方法没被调用——WebMvcConfigurer 不在启动类的包下时,addInterceptors 会被静默跳过。第 3、4 条是第五节那条分岔的可敲版:同一个 handler,order 给你完整九站,pre 给你「从这里开始全部消失」。
下表四列固定为「报错原文片段 / 真实原因 / 30 秒自救 / 深挖看第几篇」。报错原文请整段复制去搜索,不要意译——搜索引擎认全类名和整句英文,不认你的中文描述。
| 报错原文(片段) | 真实原因 | 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 注解」,结果强转报错或永远是 null | handler 参数不是永远是 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 统一包装 | 本篇第八节 |
上面第二、三行是两种完全不同的失败——第一行是「运行期浏览器拒绝」,第三行是「启动期 Spring 直接拒绝」。排查时先看应用能不能启动,能启动再去翻浏览器;顺序反了会浪费时间。
表里第四行那种「上线没事、一打开首页就 500」的现场最值得练读栈。栈很长,但凶手只有一行:
登录拦截器上线后接口全绿,直到有人用浏览器打开首页——页面变 500,日志里刷出长长一段栈,而你的代码一行都没改过。
搜报错时只搜冒号后第一段原文(例如 cannot be cast to),命中率远高于搜整句。
上面这些失败里,有一半是「看不见现场」造成的:静态资源被拦、请求体读空、404 与拦截器的关系。勾几下把排查期要看的都拿到,比在代码里加十行日志快得多:
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 }
勾完对一句:logging.level.org.springframework.web=debug 会把命中的 handler 类型打印出来,第四节和第十五节讲的静态资源、错误页那两种 handler 就是在这里现形的——生产环境记得收回去。
这个沙盘不做计算,做的是岗位匹配。左边选一层,右边选需求,输出会告诉你三层各自最关心的三件事能不能满足:能否拿到 HandlerMethod(哪个类的哪个方法)、能否拿到响应体(写出前的内容)、事务是否已经开启。
HandlerMethod ✓ 响应体 ✓(需自己写) 事务 ✗if (!(handler instanceof HandlerMethod hm)) return true; // 静态资源先放行#能读注解、能按 excludePathPatterns 精确放行,这是它的主场
玩这个沙盘的正确顺序是——先问「这段逻辑需要看见什么」,再挑层,而不是「反正 Filter 最早,就写 Filter」。三层的关系是接力:Filter 管进不进得来,拦截器管放不放行到这个 handler,AOP 管这次方法调用前后做什么。
目标:一次跑通「Filter + 两个 Interceptor + 一个 Advice」,亲眼看到顺序、短路和异常路径三种形态。
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"); }}@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 个注册 }}启动后用 curl 逐条验证(每条都换一种链路形态):
# 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预期日志顺序(第 3 条):
[METRICS] postHandle ran for ... # 只有成功请求才有这行[METRICS] ...LabController#me... cost=4ms ex=none[FILTER] GET /api/me status=200 cost=9ms trace=8f3c1a02第 2、4 条预期:完全没有 postHandle ran 这一行,但 [METRICS] ... ex=... 和 [FILTER] 两行照样打出来——这就是「postHandle 成功专属、afterCompletion 必到、Filter 包在最外层」的实证。
三个改动,每个只需一两行,重点是观察报错原文与响应体的变化:
- 把
AuthInterceptor.preHandle里的throw换成return false,再跑第 2 条 curl → 你会观察到状态码仍是 200、响应体空白,且[METRICS]那一行也不打了(因为第 1 个拦截器短路后,第 2 个连preHandle都没执行)。这就是第十七节沙盘interceptor|auth那格说的坑 - 把两个
addInterceptor的注册顺序调换(metrics先、auth先注册的变成 metrics),再跑第 2 条 → 你会观察到[METRICS] ... ex=IllegalStateException: NOT_LOGIN出现了:埋点拦截器因为「先进过」所以收到了收尾回调。结论:埋点拦截器要排在鉴权之前,否则被拒的请求全部漏记 - 在
TimingFilter的chain.doFilter之前加一行req.getInputStream().readAllBytes(),然后 POST 一个带@RequestBody的接口 → 你会观察到HttpMessageNotReadableException: Required request body is missing,改成ContentCachingRequestWrapper包裹后立即恢复
做一个「接口守门员 + 记账员」组合:所有写操作(POST / PUT / DELETE)必须登录并携带 X-Request-Id,同时给出一份「谁被拦在哪一层」的诊断报告。
要求:
- 一个
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
验收清单:
- [ ]
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)
不看上文,能否按顺序背出一次请求经过的全部环节,并指出哪两步是「逆序」发生的?(Filter 前段正序 → DispatcherServlet → preHandle 正序 → 参数解析与校验 → Controller(内含 AOP 环绕)→ postHandle 逆序 → 异常解析 → afterCompletion 逆序 → 消息转换器写出 → Filter 后段逆序)
preHandle 返回 false 之后,哪些钩子还会执行、哪些不会?(后续拦截器的 preHandle、参数解析、Controller、postHandle 全部跳过;只有已经返回过 true 的靠前拦截器收到 afterCompletion;当前这个返回 false 的拦截器自己也不会收到)
为什么统计耗时写在 afterCompletion 而不是 postHandle?(postHandle 在异常时被整个跳过,正好丢掉你最想看的失败样本;afterCompletion 是 finally 语义,一定执行还带着原始异常)
拦截器里 handler 参数的三种可能类型是什么,为什么必须先做 instanceof HandlerMethod 判断?(HandlerMethod、ResourceHttpRequestHandler(静态资源)、ParameterizableViewController(错误页)等;不判断就强转会在访问 /favicon.ico 或 /error 时抛 ClassCastException)
为什么 @EnableWebMvc 一加上页面就白了、静态资源 404 了?(它关掉了 Spring Boot 的 MVC 自动配置,包括默认静态资源映射;自定义配置请实现 WebMvcConfigurer 而不要写 @EnableWebMvc)
值机柜台不认识航班,安检口才认识——要「哪个方法」找拦截器,要「原始字节」找 Filter,要「方法前后」找 AOP;postHandle 只给成功者发糖,afterCompletion 才是离场必结账的那一位。
本篇要记住的是一条顺序线 + 四条认知。顺序线是 Filter 前置 → DispatcherServlet → Interceptor.preHandle → Controller(内含 AOP 环绕)→ 后置逆序:postHandle → 视图渲染 → afterCompletion → Filter 后置 → 客户端。四条认知:① 前置正序、后置逆序;② postHandle 只在成功时执行,afterCompletion 一定执行;③ AOP 拦不住 Filter / Interceptor,也无法「阻止请求」,因为它发生在方法调用之后;④ 三者是接力而非替代——Filter 管进不进得来,Interceptor 管放不放行,AOP 管方法前后做什么。 把这条线和这四条刻进脑子,再遇到「拦截该放哪」的问题,你会毫不犹豫。