Spring Boot中Filter、Interceptor、Listener的职责划分与实战
1. 请求从浏览器到你的Controller到底被谁“盘”了几遍先抛一个我面试很爱问的场景用户请求一个带权限校验的页面从浏览器发出请求到后端方法执行完返回中间到底经过了几道“关卡”很多写了两年CURD的同学都会愣一下然后说“不是直接进Controller吗”实际上在Spring Boot项目里一个普通请求从进来到返回通常会依次经过Servlet容器级过滤链、Spring MVC分发器、拦截器链、参数解析、方法执行、视图渲染这几个环节。其中最先介入、最容易被忽略、也最容易被用错的就是标题里那三个组件Filter、Interceptor、Listener。今天这篇不是把三个组件翻来覆去背定义也不是再贴一遍Servlet规范文档而是从“它们各自在什么位置、能干什么、不能干什么、怎么配合、踩过什么坑”这个实战维度来讲。适合三类人刚学完JavaWeb课程但还分不清三兄弟区别的初学者项目中已经把Filter和Interceptor混用却说不清理由的开发者以及即将面试想把这个知识点讲出差异化的候选人。听懂这篇文章的核心你只需要记住一句话Filter是容器级的守门员Interceptor是Spring MVC级的执法者Listener是生命周期与事件的观察哨。后面的所有内容都是围绕这句话展开。需要说明的是文中涉及的实操步骤和部分选型建议是基于我在实际项目中的常见做法给出的补充方案并非唯一标准。每个项目的基础设施不一样但排查思路和设计原则是通用的。2. 三大组件的生命周期与执行时机谁先谁后谁管谁2.1 一个请求被“过三关”的完整时序为了把问题讲清楚我画了一条请求的必经之路用文字描述浏览器发起请求 - Servlet容器Tomcat接管 - FilterChain开始执行 - DispatcherServlet接收请求 - HandlerInterceptor.preHandle执行 - 反射调用Controller方法 - HandlerInterceptor.postHandle执行 - 视图/响应渲染 - HandlerInterceptor.afterCompletion执行 - 响应返回FilterChain - 回到浏览器这个链路里有一个非常重要的细节Filter是在Servlet规范层面定义的由Servlet容器Tomcat/jetty/undertow负责调度而Interceptor是在Spring MVC框架层面实现的由DispatcherServlet负责调度。也就是说在请求的实际流转中Filter一定先于Interceptor执行也晚于Interceptor结束。这正是很多开发者第一次混乱的地方为什么我在Filter里设置的request参数到了Interceptor里读不到因为Filter和Interceptor虽然都叫“层”但你设置参数时如果用的是request.setAttribute()它确实能在Interceptor里读到如果用的是request.setParameter()实际上servlet规范不允许或者自定义包装器没传递给下一次请求那Interceptor里自然拿不到。2.2 三大组件各自的生命周期Filter生命周期项目启动时容器创建Filter实例并调用init()每次请求匹配到URL后调用doFilter()容器关闭时调用destroy()。Interceptor生命周期Spring容器管理preHandle()在Controller方法执行前调用postHandle()在Controller方法执行后、视图渲染前调用afterCompletion()在请求完全结束后调用。Listener生命周期根据监听类型不同而不同ServletContextListener在Web应用启动和关闭时触发HttpSessionListener在会话创建和销毁时触发ServletRequestListener在每个请求进入和销毁时触发。三者一个很显著的区别是Filter是单实例多线程同一个Filter实例要处理所有请求不能在doFilter里持有请求级状态Interceptor本身也是单例但它的三个回调方法天然按请求上下文组织适合保存本次请求的上下文数据Listener则更像是“旁观者”只做事件记录和回调不参与请求链路的流转。2.3 执行顺序的三个决定性因素第一个因素是容器还是框架Filter由容器执行必然在外面Interceptor由框架执行必然在里面。第二个因素是注册顺序同为Filter时注册顺序决定链式执行顺序同为Interceptor时注册顺序决定preHandle的执行顺序但postHandle和afterCompletion的执行顺序会反过来。第三个因素是URL匹配规则Filter匹配的URL模式和Interceptor匹配的路径模式是完全两套配置互不干扰。举个例子在一个项目中注册了两个FilterfirstFilter和secondFilter配置的URLPattern都是/*。请求进来时执行顺序是firstFilter - secondFilter - 后续链路响应阶段则是secondFilter的放行后逻辑 - firstFilter的放行后逻辑。执行顺序和注册顺序在入站方向一致在出站方向相反这是FilterChain链式调用的天然特性。Interceptor稍微特殊一些SpringMVC的Interceptor注册顺序在preHandle阶段是按注册顺序执行在postHandle和afterCompletion阶段是逆序执行。比如我先注册了日志拦截器再注册了权限拦截器那么preHandle先走日志再走权限但postHandle先走权限再走日志。这个细节在拦截器里做“响应结果变更”时非常容易出bug我在后面专门讲。3. Filter过滤器容器级守门员别把它当成万能中间件3.1 Filter的核心能力边界Filter能做的主要是这几类事情对请求做编码统一的字符编码设置、对响应做压缩处理、跨域请求的CORS头注入、XSS/参数清洗、登录态的统一前置校验配合Session/Cookie、请求耗时统计、灰度发布的路由切换等。它的特点是“看不到Controller长什么样”。在doFilter方法里你拿到的只有ServletRequest、ServletResponse和FilterChain。你无法直接知道这个请求会被映射到哪个类哪个方法也无法在Filter里对一个Controller方法的参数做精确操作。这决定了Filter适合做“与具体业务无关”的通用横切逻辑而不适合做“需要针对特定接口定制”的权限控制。3.2 三种注册方式的差异与坑点在Spring Boot还没普及的时代Filter的标准注册方式是web.xml里配置filter和filter-mapping。现在主流是三种方式并存在Filter类上加Component或者WebFilter注解。这是最省事的方式但坑也最多。加了Component的Filter会被Spring容器托管并且默认对所有URL生效/*你如果想限制URL会非常尴尬。WebFilter是Servlet 3.0的注解在Spring Boot里需要配合ServletComponentScan才能生效。使用FilterRegistrationBean注册。这是我在生产环境最推荐的方式因为它能显式指定filter实例、URL模式、顺序order、初始化参数而且不会误伤Spring Boot自动装配的其他Filter比如Spring Security的Filter、HiddenHttpMethodFilter等。例如Configuration public class FilterConfig { Bean public FilterRegistrationBeanMyFilter myFilterRegistration() { FilterRegistrationBeanMyFilter registration new FilterRegistrationBean(); registration.setFilter(new MyFilter()); registration.addUrlPatterns(/api/*, /admin/*); registration.setOrder(1); return registration; } }在web.xml中配置。这种场景现在基本只出现在老项目迁移和非Spring Boot项目中纯Servlet项目仍然适用。3.3 一个容易踩的事件Component导致的Filter对所有接口生效我有一次在一个支付项目里排查问题团队新加了一个Filter只希望拦截/pay/notify/*回调地址但测试发现在处理其他业务请求时这个Filter的耗时统计逻辑也被触发了。单看代码没什么问题因为Filter自己判断了路径不匹配就放行。但问题出在耗时统计是异步写入的这个重复触发导致日志量暴涨。根因就是Component注册让Filter对所有URL生效了虽然Filter内部有不匹配放行的逻辑但统计代码在放行前就被执行了。这个案例的教训是如果Filter内部有副作用的逻辑打点、计数、链路埋点最好用FilterRegistrationBean精确控制URL模式而不是依赖Filter内部做路径判断。3.4 doFilter里最容易被误解的三个点第一chain.doFilter()调用之后不是结束你的Filter可能还需要在响应阶段做处理。如果你想让Filter对响应头注入一个标记必须在chain.doFilter()返回之后并且只能修改被包装后的Response对象。第二如果你想在Filter里读取请求体直接从request.getInputStream()读的话读完之后Controller就再也拿不到参数了必须使用HttpServletRequestWrapper做缓存。第三Filter里抛出的异常不会走RestControllerAdvice的全局异常处理因为异常发生在进入Spring MVC框架之前容器直接接管的只能在外层容器配置错误页或者靠Filter自己的try-catch处理。4. Interceptor拦截器Spring MVC内部的执法者精确到“方法级”4.1 Interceptor的三个回调方法分别该干什么preHandle()返回true则继续执行后续拦截器和Controller方法返回false则中断请求。这里要注意返回false之后后续的Interceptor和Controller都不会执行但已经执行过的前序Interceptor的afterCompletion()也不会执行。这是很多刚接触拦截器的人理解偏差的地方。postHandle()是在Controller方法正常返回后调用此时ModelAndView还没渲染。如果你用ResponseBody返回JSON这个阶段拿到的ModelAndView基本是null所以对JSON接口而言postHandle里能做的事情非常有限。afterCompletion()是在请求完全结束、视图渲染完成之后调用通常用来释放资源、清理ThreadLocal、记录最终耗时或者调用外部日志系统。注意无论Controller方法是否抛出异常只要preHandle返回了trueafterCompletion就一定会执行并且可以通过Exception ex参数拿到异常信息。4.2 注册拦截器与路径匹配的实操配置在Spring Boot中注册一个自定义InterceptorConfiguration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/login, /api/register, /api/public/**); } }这里的addPathPatterns使用的是Spring的AntPathMatcher匹配规则/**代表任意多级路径/*代表一级路径。一个常见的坑是使用/*时/api/order/1这种多级路径不会被匹配到导致拦截器失效。所以如果要对所有子路径生效务必使用/**。另一个常见坑是在Spring Boot 2.6及以上版本如果项目里配置了自定义路径匹配规则如PathMatchConfigureraddInterceptors里的路径匹配默认会遵循自定义策略有时候你以为拦截了/api/**但实际因为路径后缀匹配策略变化导致拦截器不生效。这种情况排查时先去掉自定义路径匹配试试。4.3 一个反直觉的执行顺序问题前面提到过多个Interceptor的postHandle和afterCompletion是逆序执行的这个特性在“在拦截器里给返回体做变更”这类需求上非常令人头疼。举个例子我配置了拦截器A做日志收集拦截器B做响应加密。希望执行顺序是请求进来先A再B响应返回先B加密再A记录。从preHandle的注册顺序来看A先注册order1B后注册order2preHandle按A-B执行但响应阶段postHandle按B-A执行所以B的postHandle先执行加密然后A的postHandle再记录日志符合预期。但如果你的意图是“请求和响应都保持A在外层、B在内层”那就要调整注册顺序了。实际项目中我建议的原则是不要在拦截器里依赖postHandle和afterCompletion的逆序特性而是把需要最早做响应的逻辑放在order值最小的拦截器里或者干脆把响应处理逻辑拆到Filter层去做降低理解成本。4.4 拦截器与过滤器的职责划分实战建议在真实项目中我的建议是编码处理、跨域头、响应压缩、静态资源缓存这类的”HTTP协议层“操作放在Filter。登录态校验、权限校验、权限菜单加载、接口幂等Key校验、公众号菜单之类的“业务执行前校验”放在Interceptor。请求日志、耗时统计、用户行为打点这类横切逻辑Filter和Interceptor都可以但如果涉及拿到Controller方法上的自定义注解比如记录操作日志就必须用Interceptor因为只有Interceptor能拿到HandlerMethod。这个划分的核心逻辑依据是你能不能在Filter里拿到HandlerMethod不能。所以凡是和“某个具体Controller方法”强相关的能力都必须用Interceptor或AOP实现Filter无能为力。5. Listener监听器监听整个Web容器的”观察哨“远比你想的能打5.1 三类Listener各管哪一段Servlet规范里监听器主要分为三类ServletContextListener监听整个Web应用的启动和关闭。它是项目启动预处理的绝佳位置。HttpSessionListener监听Session的创建和销毁常用于实现在线人数统计、Session超时清理。ServletRequestListener监听每个请求的创建和销毁可以用来在请求进入时初始化上下文在请求结束时自动清理ThreadLocal。举个实际的例子我想在请求开始时往ThreadLocal里放一个traceId在请求结束时清除它防止线程池复用时数据串线。用ServletRequestListener实现是最优雅的方案WebListener public class TraceIdListener implements ServletRequestListener { Override public void requestInitialized(ServletRequestEvent sre) { TraceIdHolder.set(generateTraceId()); } Override public void requestDestroyed(ServletRequestEvent sre) { TraceIdHolder.clear(); } }对比Filter里做同样的事Listener的优点是它天然在每个请求创建和销毁时触发不会因为忘记调用chain.doFilter()导致后续逻辑不执行也不需要在finally里手工清理。5.2 在Spring Boot中使用Listener的正确姿势Spring Boot项目中使用Listener和Filter类似也有几种注册方式在Listener类上标注WebListener然后在启动类上加ServletComponentScan扫描。使用ServletListenerRegistrationBean注册推荐这种方式因为能精确控制生效范围。如果是ApplicationContext层面的EventListener则使用ApplicationListener或EventListener注解这种是Spring事件机制和Servlet规范中的Listener不是一回事不要混用。一个容易踩的坑是在WebListener标注的ServletContextListener中通过Autowired注入的Spring Bean可能为null。原因在于Servlet容器创建Listener实例的时机早于Spring容器完全初始化完成Spring容器还没准备好依赖自然没法注入。解决方式有两种第一种是改写成ServletListenerRegistrationBean注册一个由Spring管理的Bean实例这样Spring容器会负责创建和注入第二种是直接用WebApplicationContextUtils.getWebApplicationContext(sce.getServletContext())手动获取Spring上下文再从上下文里拿Bean。5.3 监听器里那些“你以为监听但不生效”的边界场景有次项目里遇到一个诡异问题监听器统计的“在线用户数”一直不准Session销毁了但数量不减。排查后发现有多个节点部署Session从A节点转移到了B节点A节点上的Session已经销毁但B节点上对应Session的创建事件和销毁事件不在同一节点产生导致统计数飘忽不定。这个例子说明Listener有一个天然的边界只要是分布式环境直接在每个节点统计Session总数就是不可靠的需要把统计数据汇总到一个共享存储里如Redis或者干脆用业务数据的最后活跃时间统计分析而不是依赖Session生命周期。另外需要注意ServletRequestListener只监听Servlet容器层面的请求Spring MVC内部通过DispatcherServlet转发的请求如forward在Servlet层面可能对应多个ServletRequest生命周期如果你在Listener里绑定某种“每个HTTP请求一份”的数据要考虑到forward和include导致的事件重复触发。Spring内部对forward有自己的处理机制一般建议不要在Listener里基于“每个用户请求”做太重的事务性操作。6. 那些看起来和JavaWeb监听器有关的“报错”其实是个误会在开发过程中我经常看到同学们在群里贴报错比如“listener refused the connection with the following error: ora-12514”或者前端控制台报“uncaught (in promise) error: a listener indicated an asynchronous response b”然后问我“这个JavaWeb监听器是不是写错了”这里统一澄清一下这两个报错都和Servlet监听器无关。ORA-12514: TNS:listener does not currently know of service requested in connector是Oracle数据库的网络监听服务没有识别到请求的服务名属于数据库连接配置问题而浏览器的a listener indicated an asynchronous response b是Promise事件监听器的异步响应错误属于前端JavaScript的监听器问题。这种“同名不同物”的混淆在实际开发中非常常见。搜索“listener”相关问题时Google/百度会混着给出Servlet监听器、Oracle监听器、前端事件监听器、Netty监听器等完全不同领域的内容。我的建议是搜索时加上“JavaWeb”“Servlet”“ServletContextListener”这类限定词避免被不相关内容淹没。还有个更实际的问题是有同学在写前端代码时监听了某个事件控制台报listener indicated an asynchronous response这其实是浏览器在异步事件监听器里返回Promise但处理出错这时候应该去检查前端的事件监听回调函数而不是跑到JavaWeb项目里找HttpSessionListener。这类问题本质上考验的是你是否能分清“监听器”这个词在不同技术栈里的指代而不是技术本身有多难。7. 三组件协同编排一个带完整链路的请求级权限控制实例7.1 需求场景假设现在要做一个接口平台所有/api/下的请求需要统计耗时并记录日志其中/api/admin/下的请求需要校验管理员权限如果用户Session过期要返回JSON格式的提示而不是302跳转登录页。同时项目启动时要初始化一份接口权限白名单在运行时请求结束时要清理ThreadLocal中的用户信息。7.2 组件分工与代码骨架我用一个Filter做全局请求耗时与跨域处理用一个Interceptor做管理员权限校验用一个Listener做启动初始化和ThreadLocal清理。第一步定义FilterComponent public class AccessLogFilter extends OncePerRequestFilter { private static final Logger log LoggerFactory.getLogger(AccessLogFilter.class); Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { long start System.currentTimeMillis(); try { chain.doFilter(request, response); } finally { long cost System.currentTimeMillis() - start; log.info(path{}, cost{}ms, request.getRequestURI(), cost); } } }注意这里用了Spring提供的OncePerRequestFilter它保证一个请求无论内部怎么forwardFilter都只执行一次。这是很多项目里从一开始就该用而没用导致重复日志的经典问题。第二步定义Interceptor做管理员权限校验public class AdminAuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } // 如果接口标识了AdminOnly注解才校验 HandlerMethod handlerMethod (HandlerMethod) handler; AdminOnly adminOnly handlerMethod.getMethodAnnotation(AdminOnly.class); if (adminOnly null) { return true; } // 校验当前用户的角色 Object currentUser request.getSession().getAttribute(currentUser); if (!admin.equals(((User) currentUser).getRole())) { response.setStatus(HttpStatus.FORBIDDEN.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\msg\:\无权限访问\}); return false; } return true; } }这里有个细节值得注意返回false之后如果后续还有其他Interceptor它们的preHandle不会被调用而且当前Interceptor的afterCompletion也不会调用。所以“返回false”的路径上所有需要释放的资源都要在本次调用里处理干净别指望afterCompletion帮你兜底。第三步定义一个ServletContextListener在启动时加载白名单并定义一个ServletRequestListener清理ThreadLocal两个Listener通过WebListener注册WebListener public class AppInitListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { // 模拟从数据库加载权限白名单 SetString whitelist loadWhitelistFromDb(); sce.getServletContext().setAttribute(admin_whitelist, whitelist); // 如果是在Spring Boot容器中也可以在这里用ApplicationContext提前预热线程池等资源 } Override public void contextDestroyed(ServletContextEvent sce) { // 应用关闭前的资源释放 } } WebListener public class UserContextListener implements ServletRequestListener { Override public void requestInitialized(ServletRequestEvent sre) { UserContextHolder.set(new UserContext()); } Override public void requestDestroyed(ServletRequestEvent sre) { UserContextHolder.clear(); } }7.3 这个分工为什么合理这个场景的核心诉求可以拆解为四类通用横切逻辑耗时、跨域放在Filter因为不关心具体接口也不需要拿到HandlerMethod。业务权限校验管理员识别、注解驱动放在Interceptor因为要拿到HandlerMethod上的AdminOnly注解。启动初始化放在ServletContextListener因为这正好是应用启动回调。请求级别上下文清理放在ServletRequestListener保证一次请求进来创建、结束时销毁避免线程池复用导致ThreadLocal数据残留。如果把这个场景全部用Filter实现你会发现在Filter里判断Controller方法上是哪个体彩通的注解非常别扭还得手动解析HandlerMethod完全绕远路。如果全部用Interceptor实现又会发现日志耗时的统计范围不包含某些过滤器链里的处理环节也不包含异常状态下afterCompletion之外的代码路径。7.4 实际运行时会踩到的两个顺序性坑第一个坑Filter和Listener虽然都标注WebListener或Component但它们在Spring Boot中的装配顺序可能不一致。Filter的order是相对Filter之间的顺序Listener和Filter之间没有绝对的顺序约定但它们的事件触发时机天然是互不干扰的Listener的requestInitialized发生在请求对象创建时而Filter的doFilter发生在请求进入FilterChain时两者都早于Interceptor但先后顺序在不同容器实现下可能有细微差异。官方文档不建议在Filter里依赖requestInitialized的执行结果因为那属于Servlet容器内部实现细节。第二个坑Interceptor的preHandle抛异常时会直接走afterCompletion并且带着异常参数但Filter的finally块也会执行。也就是说Filter的日志统计最终仍然能记录到耗时但Interceptor的afterCompletion拿到的可能是异常状态。如果你想把异常请求和正常请求的日志区分开建议在Interceptor的afterCompletion里判断exception参数而不是靠Filter里try-catch区分。8. 写在最后一个用了三年之后的组件分工体会我把三大组件重新梳理一遍后发现很多同学纠结的“到底用Filter还是Interceptor”本质是在问“哪个更高级”。坦白讲没有更高级的说法只有位置和定位的差异。Filter在Servlet容器里对框架无感任何JavaWeb框架都能用Interceptor在Spring MVC里能拿到方法信息能和Spring生态无缝整合Listener是事件驱动思维的产物适合做生命周期管理和上下文清理。从我个人经验来看一个项目里如果团队成员对这三个组件的边界理解不一致代码最终一定会膨胀成一个巨大的Filter“上帝类”里面塞满了解析URL、判断权限、压缩响应、打印日志的逻辑。后续维护的人看到这样一个Filter普遍的心态是不敢动、不敢拆最后只能在Filter外再包一层Interceptor来弥补Filter的过度设计。这种情况我见了不止一次。给你们一个最落地的建议新项目起步时在代码规范的文档里明文写上三条规则——写Filter的类禁止出现HandlerMethod、Autowired Controller相关代码禁止在Filter里执行业务逻辑。写Interceptor的类禁止使用request.getSession()做非权限类的业务数据读取禁止在preHandle里返回false后忘记清理当前请求上下文。写Listener的类禁止在里面启动异步线程执行耗时任务会导致应用关闭时线程未回收。如果在代码评审时坚持住这三条三组件就会各司其职请求链路清晰排查问题也不会发生“改一个Filter结果所有接口超时”的悲剧。这比背下来一百个定义实用得多。