我之前在维护一个老项目时遇到过这样一件事:新来的同事给某个接口同时加了权限拦截器、日志切面,又在外部补了一个Filter做请求签名校验。结果上线后,日志顺序完全对不上,签名校验也被绕了半套。当时几个人对着调用链猜了很久,最后扒代码才发现,大家其实对Controller调用前后的执行顺序理解不统一——有人以为Filter最先,有人以为是Aspect先跑,还有人以为拦截器挡在切面外面。
这个问题的根源在于Spring MVC和Servlet容器是两个不同层次的东西。搞清楚"Controller的Aspect、Filter和拦截器的执行顺序"不只能让你日志好看,更影响权限、事务、链路追踪这些核心功能的正确性。这篇博客我会用一次完整可复现的测试,把Filter、拦截器、Aspect的顺序彻底讲透,并把踩过的坑一并整理出来。适合正在用Spring Boot做Web开发、被调用链搞晕的读者。
1. 先理清三者的层级关系
1.1 它们根本不在同一个容器里
很多人把Filter、拦截器、Aspect混在一起,以为都是"请求进来后要执行的一段代码",然后纠结谁先谁后。实际上,它们分别由不同的容器管理,目标也不一样。
- Filter是Servlet规范里的东西,运行在Servlet容器内部,由Servlet容器(比如Tomcat、Jetty)管理。它几乎能在HTTP请求进入任何Servlet之前执行,天然可以作用于静态资源、Servlet、以及Spring MVC的DispatcherServlet。
- 拦截器(HandlerInterceptor)是Spring MVC框架提供的能力,它只作用于Spring MVC处理流程中,也就是DispatcherServlet分配请求、调用HandlerAdapter处理Controller方法的前后。它依赖Spring容器,可以方便地注入各种Bean。
- Aspect(AOP切面)则是Spring AOP基于代理机制实现的横切能力。它不关心你是不是HTTP请求,它关心的是"某个方法调用"——比如Service方法、Controller方法、甚至是任意被Spring管理的Bean方法。它和请求链路本身没有直接关系,只是可以切入Controller方法。
理解这个层级,顺序就清晰了一半。从代码执行路径来看,一个大致的嵌套关系是:Servlet容器 -> Filter -> DispatcherServlet -> 拦截器 -> AOP代理 -> Controller方法。
1.2 一次请求的完整"穿行"过程
当外部请求进来时,Tomcat先接收到HTTP请求,把它包装成HttpServletRequest交给Filter链。每个Filter调用doFilter方法,如果通过就调用chain.doFilter放行,请求进入DispatcherServlet。DispatcherServlet找到匹配的HandlerMapping,定位到某个Controller方法,然后通过适配器调用。
但注意,这个"调用Controller方法"可不是直接调用你写的方法。如果目标Bean被AOP代理了,那么调用会先走到代理对象,代理的执行链中包含Aspect的通知逻辑,然后才真正进入Controller方法。而在进入Controller之前,拦截器的preHandle会在HandlerAdapter里执行,postHandle在Controller方法返回、视图渲染之前执行,afterCompletion在整个请求处理完成后执行。
所以默认情况下,执行顺序可以简化成:
Filter -> Interceptor.preHandle -> Aspect.@Around前 -> Controller方法 -> Aspect.@Around后 -> Interceptor.postHandle -> Interceptor.afterCompletion -> Filter后续这个顺序不是谁规定的,而是由Tomcat、Spring MVC、Spring AOP三层嵌套自然推导出来的。后面我会用实际日志验证。
2. 执行顺序的核心规则与逻辑
2.1 默认顺序的逐步解析
先看最典型的正向流程。我写了一个示例项目,把Filter、拦截器、Aspect、Controller都配置好,并在每一步打印日志。为了直观,我用符号标注执行时间点。
首先是Filter:
@Component public class DemoFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("[Filter] 进入 doFilter 前"); chain.doFilter(request, response); System.out.println("[Filter] 离开 doFilter 后"); } }然后是拦截器,通过WebMvcConfigurer注册:
public class DemoInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { System.out.println("[Interceptor] preHandle"); return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { System.out.println("[Interceptor] postHandle"); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { System.out.println("[Interceptor] afterCompletion"); } }注册方式:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new DemoInterceptor()).addPathPatterns("/**"); } }注意我这里把拦截器注册到/**路径,让它能覆盖全部请求。
然后是Aspect,切入Controller层:
@Aspect @Component public class DemoAspect { @Around("execution(* com.example.controller..*.*(..))") public Object around(ProceedingJoinPoint joinPoint) throws Throwable { System.out.println("[Aspect] @Around 前"); Object result = joinPoint.proceed(); System.out.println("[Aspect] @Around 后"); return result; } }Controller很简单:
@RestController @RequestMapping("/demo") public class DemoController { @GetMapping("/hello") public String hello() { System.out.println("[Controller] 方法执行中"); return "hello"; } }启动项目后,请求/demo/hello。控制台输出顺序如下:
[Filter] 进入 doFilter 前 [Interceptor] preHandle [Aspect] @Around 前 [Controller] 方法执行中 [Aspect] @Around 后 [Interceptor] postHandle [Interceptor] afterCompletion [Filter] 离开 doFilter 后这验证了默认顺序。这个顺序非常关键:一旦你在Filter里做了校验,校验通过再放行,拦截器和Aspect才有机会执行。而Aspect的@Around包裹了Controller方法本身,所以它的"前"在Controller方法之前,它的"后"在Controller方法之后,但理论上在拦截器的postHandle之前。这里有一个细节值得注意——@Around的"后"逻辑其实是在Controller方法返回之后、HandlerAdapter继续处理之前执行的,而拦截器的postHandle也是在Controller返回后执行。我的实际测试表明,Aspect的@Around后的日志会先于postHandle打印,原因在于AOP代理是包裹方法调用的最内层,postHandle需要在DispatcherServlet的调用栈中等待代理方法完全返回后才执行。
2.2 前置条件变化时顺序会怎么变
理解了默认顺序还不够,因为一旦某个环节阻断或异常,整个链条会发生变化。
Filter放行与否:Filter中如果调用chain.doFilter,后续才能执行;如果Filter直接返回响应而没有放行,那么后续所有拦截器、Aspect、Controller都不会执行。
Interceptor.preHandle返回false:如果preHandle返回false,Spring MVC会停止请求处理。拦截器链中尚未执行的拦截器会被跳过,Controller和Aspect也不会执行。但注意已经执行的拦截器的afterCompletion会按照逆序触发吗?答案是:只有已经被调用过preHandle且返回true的拦截器,在后续异常或完成时,其afterCompletion才会被调用。所以如果第一个拦截器返回false,它自己的afterCompletion也不会被调用。这个细节经常被忽略。
异常发生:如果Controller方法抛出异常,那么@Around中proceed()后面的代码不会执行(除非你在aspect中捕获异常),而是向上传播。postHandle不会执行,但afterCompletion会执行,而且Aspect可以捕获异常后改变返回值。需要注意的是,如果Aspect没有捕获异常,异常会一路传到DispatcherServlet,再传到Filter,如果Filter没有catch,最终Tomcat会返回错误页。日志顺序中@Around 后不打印,但afterCompletion仍会打印。
2.3 为什么Aspect会比拦截器更接近方法
这里要提一个很多人问的点:既然拦截器位于DispatcherServlet和Controller之间,而AOP代理也在Controller之前,为什么不是拦截器先执行?实际上在链路中,拦截器的preHandle确实先于Aspect执行。但为什么Aspect的"后"会先于拦截器的"postHandle"?
这就要从Spring MVC处理流程看起。HandlerAdapter调用目标Handler时,如果Handler本身被CGLIB或JDK动态代理包装,那么调用的是代理方法。代理方法内部,切面逻辑包裹了真实Controller方法。也就是说,proceed()之后的代码其实还在代理方法内部,只有整个代理方法返回后,控制权才回到HandlerAdapter,然后才执行postHandle。因此Aspect的"后"一定在拦截器postHandle之前。如果调换顺序,那就违背了AOP代理的基本原理。
人话版:你可以把请求处理想象成洋葱,Filter是最外层,拦截器稍内,Aspect再内,Controller是芯。剥洋葱的时候从外到里,剥完里层往外退的时候,Aspect先出来,再到拦截器,最后到Filter。
3. 实操验证的完整配置与注意点
3.1 从零搭建可复现的最小工程
为了确保我们讨论的不是纸上谈兵,我建议你直接搭一个最小的Spring Boot项目来实验。不需要复杂的业务逻辑,只需引入spring-boot-starter-web和spring-boot-starter-aop。
pom.xml关键依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>主类不需要特殊配置,用Spring Boot默认的@SpringBootApplication即可。但要注意启用AspectJ支持。在Spring Boot中,只要classpath存在spring-boot-starter-aop,AOP自动配置就会生效,不需要额外@EnableAspectJAutoProxy。不过你如果自己维持非Spring Boot环境,就需要手动添加这个注解。
3.2 Filter、拦截器和切面的配置细节
Filter的注册方式。我在前面用了@Component注解,这种方式很直接,但缺点是Filter对所有URL生效,且执行顺序不可控。更推荐用FilterRegistrationBean显式注册,可以指定urlPatterns和order。Spring Boot中,@Component注册的Filter会被自动适配,doFilter里不要忘记调用chain.doFilter。多个Filter会按@Order注解或FilterRegistrationBean.setOrder决定顺序。拦截器的执行顺序则和注册顺序相关:registry.addInterceptor先添加的先执行preHandle,但afterCompletion逆序执行。所以不要只依赖直觉,最好通过日志验证。
Aspect的切入点。我上面用execution(* com.example.controller..*.*(..))来匹配Controller包下所有方法。在Spring AOP中,Controller方法也可以被切入,因为DispatcherServlet调用的是一个Spring Bean,而Spring Bean默认被AOP代理。但要注意,如果Controller的方法不是public,或者类没被Spring管理,切面就不生效。还有,如果用@Aspect在@Controller类上,可能会产生代理问题,但一般不推荐这么写,因为横切关注点应独立。
拦截器不生效的典型原因是注册了但没addPathPatterns,或者路径匹配不符合预期。Spring Boot 2后,自定义拦截器必须通过WebMvcConfigurer.addInterceptors注册,只用@Component给拦截器加注解是没有用的,它不会自动进入请求处理链。
3.3 日志中每个阶段的前后时机
运行测试时,还可以通过增加线程睡眠来观察时序。你可以在@Around前打印当前时间戳,在postHandle打印时间戳,会发现两者几乎是紧邻的,如果Controller方法执行很快,时间差极小。但如果你在@Around的proceed前做大量计算,拦截器的preHandle已执行完成,所以Controller的响应时间包含AOP前的耗时。这也是为什么在一些监控系统里,如果你把耗时统计放在Aspect中,会漏掉拦截器和Filter的耗时——它们不是同一层,别混在一起统计。
我实际项目里就把接口耗时统计放在Filter中,这样能覆盖到整个HTTP请求;而Aspect只统计业务方法自身耗时。两者各有侧重。
3.4 异步请求对顺序的影响
现代项目使用Callable或DeferredResult处理异步请求时,执行顺序会发生变化。在异步场景下,preHandle和postHandle可能出现异常:Spring MVC的拦截器可以被configureAsyncSupport配置,异步请求开始后,afterCompletion会在异步线程完成时被调用,而不是请求线程中。这种情况下,Aspect如果切入Controller方法,可能在一个线程中执行,而Filter后续逻辑在另一个线程才继续,日志顺序可能不再是简单的线性,甚至会让人怀疑过滤器是否被绕过。但这不是顺序规则失效,而是异步切换线程导致的。处理异步时,最好把上下文传递(比如TraceId)放在Filter中,并使用request.getAttribute方式传递到不同线程。
3.5 测试环境的Provider与坑
有些环境里Spring Boot会对Feign、RestTemplate等调用也进行AOP代理。如果你写的切面表达式是execution(* com.example.controller..*.*(..)),那只会切入Controller包,不会有影响。但若切面表达式写成execution(* com.example..*.*(..))这种,你可能会发现拦截器只执行一次,但Aspect却不只执行一次,因为可能内部Bean自调用也会触发切面。不过Controller类之间的自调用通常不会,因为它们是本类调用。但Service中this.method()自调用是绕过代理的,这是一个经典坑。如果你在Controller中调用了别的Controller方法(通过注入),该Bean的代理依然生效,切面会再次切入。
4. 执行顺序中的常见问题与排查技巧
4.1 为什么拦截器preHandle里注入的Service是null
这其实不是执行顺序问题,而是初始化时机问题。Spring MVC拦截器默认被Spring容器管理,如果你在拦截器里用@Autowired注入Service,通常是可以生效的。但如果拦截器是在WebMvcConfigurer中用new创建,而不是通过Spring管理,那么@Autowired必然为null。我自己就踩过这个坑:在注册拦截器时写了registry.addInterceptor(new AuthInterceptor()),而在AuthInterceptor里用@Autowired注入了RedisService,结果运行时空指针。解决方案是,让拦截器本身也是一个Spring Bean:
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private RedisService redisService; }注册时直接注入:
@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor); } }这样Spring会帮你完成依赖注入。
4.2 Filter和拦截器都做了鉴权,为什么Aspect还能先执行
有一种常见场景是:你想在Filter做鉴权,失败则不再执行后续逻辑。但如果在Filter中鉴权通过后直接放行,而拦截器里又定义了权限校验,结果Aspect反而先于拦截器的权限校验执行了长耗时逻辑,这显然不是我们想要的。这时候要检查拦截器是否真的配置了路径,preHandle返回是否为true。如果preHandle返回true并且Aspect先执行,那说明拦截器其实没匹配到该路径,或路径规则写错了。
我建议在这种情况下,把通用的、代价高的校验尽量提前到Filter层,而把业务相关、较弱敏感的校验放拦截器。过滤器可以利用HttpServletRequest直接判断,但缺点是无法获取Controller方法上的注解信息。如果你希望根据方法上某个自定义注解决定是否放行,那就必须在拦截器里做,因为拦截器的handler参数可以解析出Method,而Filter做不到。
4.3 如何精确控制执行顺序
如果你真的需要调整顺序,需要理解不同层的控制方式。
- 多个Filter之间:使用
FilterRegistrationBean或@Order控制先后,order数值越小越先执行。在Filter链中,先后会影响其他Filter的执行顺序。 - 多个拦截器之间:按
addInterceptor调用的顺序执行preHandle;postHandle和afterCompletion则逆序执行,这一点和Filter链的“先进后出”略有不同。拦截器的顺序可以借助@Order在拦截器Bean上声明,但实际仍以注册顺序为准。 - 多个Aspect之间:使用
@Order注解或实现Ordered接口控制,数值越小越先执行。当多个切面包裹同一个方法时,先执行的Aspect的@Around前会先打印,proceed内部继续执行下一个Aspect,最后一个Aspect再调用真实方法;返回时,顺序完全反转。
下面这张表可以帮你快速记忆:
| 环节 | 注册方式 | 控制顺序的机制 | 作用范围 |
|---|---|---|---|
| Filter | FilterRegistrationBean / @Component | @Order / setOrder | Servlet请求,含所有资源 |
| 拦截器 | WebMvcConfigurer.addInterceptors | 注册顺序 | Spring MVC处理器 |
| Aspect | @Component/@Aspect | @Order / Ordered接口 | Spring Bean方法 |
4.4 对返回值的修改会带来什么连锁反应
Filter、拦截器、Aspect中都可以对响应做调整。比如Aspect可以修改Controller的返回值:如果你的Controller返回一个对象,Aspect可以把它改写成另一个对象。但这种修改对拦截器的postHandle来说是透明的,因为它拿到的ModelAndView已经是经过视图解析的形式,不一定能看到修改后的对象。而Filter拿到的是HttpServletResponse,更不利于修改响应体。所以如果你要做全局响应包装,一般选Filter/Aspect都有自己的做法,但要注意顺序带来的重复包装问题。
我在一个项目中想在Filter里统一给返回体加签名,结果发现Filter设置响应头后,拦截器里又改了一遍,导致签名不一致。后来规范了各层职责:Filter只做协议层处理,拦截器处理会话,切面处理业务增强。这样顺序问题才不会变成逻辑Bug。
4.5 排查执行顺序的三板斧
光靠理论容易翻车,实际排查时我习惯用三板斧。
第一板斧:加日志,在每个环节打印同一请求的标识(比如UUID)。可以在Filter中往request设置traceId,拦截器、Aspect、Controller中都能通过RequestContextHolder取同一个值。日志输出后,一眼就能看出顺序。如果发现日志缺失,就知道哪一层没执行,或执行顺序被异步干扰了。
第二板斧:断点调试。在Filter的doFilter、拦截器的preHandle、Aspect的@Around、Controller方法中各打断点。用Debug模式跑一次,观察方法调用栈的变化。这个方法最直观,能看清代理是如何嵌套的。
第三板斧:使用Spring Actuator的Beans端点查看代理情况。如果某个Controller被代理成了DemoController$$EnhancerBySpringCGLIB,说明AOP已经生效;如果显示是原始类,说明AOP没生效。排查时很有用。
5. 我能给出的几条实战建议
先给结论,再讲原因。在大多数常规的Spring Boot Web项目中,我建议按这样分配职责:
- Filter只干三件事:字符编码、跨域处理、全局请求日志/打点。除非你非常清楚后果,否则不要在Filter里写太重的业务逻辑。因为Filter层拿不到Spring MVC的Handler,很多业务对象还没有注入,容易写出隐性Bug。
- 拦截器干权限校验、登录态校验、请求频次控制、公共参数校验。这些操作往往需要读取注解或方法信息,拦截器是最合适的位置。用
preHandle做校验,不通过就返回false并直接写响应。注意preHandle要尽早、尽量快,避免阻塞后续处理。 - Aspect干事务、缓存、日志埋点、数据权限、审计。AOP的方法级粒度最适合这类横切逻辑。但如果切面范围过大,注意避免自调用导致代理失效。
执行顺序本身不是一句“Filter最先”就能涵盖的。Aspect切入的是方法调用,而拦截器切入的是处理器执行,这两者在微秒级的时间差上也有区别。理解底层模型比死记顺序重要得多。
在你实际设计链路时,可以把这份顺序当成默认配置,但在以下几种情况要特别留意:
- 设置了拦截器,但方法经过AOP代理后,异常抛出时
@AfterThrowing会先捕获,还是afterCompletion先捕获?实测中,如果Aspect内部没有catch,异常先经过Aspect的异常通知逻辑(如果有),再传回HandlerInterceptor的afterCompletion。所以如果你想记录异常,两者都能捕获,但捕获顺序不同,最终日志会出现重复记录。建议只选一层做异常监控,避免重复。 - 使用Spring Security时,它内部使用的是Filter链,这个过滤器链位于我们自定义Filter之后还是之前?Spring Security的过滤器链默认在Servlet容器中靠后位置,所以如果自定义Filter在最外层顺序靠前,可能先于Security过滤器执行。这个顺序会影响匿名身份获取。我通常把自定义Filter的
order设置在SecurityProperties.DEFAULT_FILTER_ORDER之后,或者干脆不在Filter里依赖用户身份,统一在拦截器中取Spring Security的上下文。 - 若你在
@Around中开启了一个事务,然后调用proceed()返回后立即提交事务,而拦截器afterCompletion里要读取刚更新的数据,可能读不到,因为事务已经提交,但存在数据库主从延迟。这种跨层的一致性问题,比执行顺序本身更加致命。
最后分享一个小经验:当你发现请求处理顺序不符合预期时,不要先急着改代码,先借助RequestContextHolder和日志把完整链路打印出来。确认每一次调用的线程名、耗时、TraceId,再判断是哪一层被跳过、哪一层被重复调用。顺序问题多数不是“规则不对”,而是某个配置没生效。快速定位的方法有两个,一是看启动日志里是否确认注册了拦截器(AOP切面没有明确的启动日志),二是用调试模式看HandlerMapping里的interceptor列表。
只要把这三层关系理清,后续加功能就不慌。而且当你需要引入微服务、网关时,你会发现那个执行顺序同样适用:网关相当于Filter层,服务间调用时没有拦截器,但AOP依然有效。理解一次,受用很久。