Filter、Interceptor、Listener三大组件详解:执行时机与实战避坑指南
2026/9/16 5:29:06 网站建设 项目流程

搞JavaWeb开发,无论是写单体应用还是带前端的项目,只要用Spring Boot / Spring MVC这套体系,迟早会撞上三个组件:过滤器Filter、拦截器Interceptor、监听器Listener。面试时候几乎必问,背概念简单,真到项目里用起来却是另一回事,Filter和Interceptor看起来都是"请求中间层",实际执行地方完全不同;Listener平时不起眼,但一旦要统计在线用户数、做系统启动初始化,又绕不开它。这篇就把这三个组件从原理、执行时机到实际可落地的代码案例,完整拆一遍。

这篇内容适合正在做JavaWeb项目开发、需要理清Filter/Interceptor/Listener区别的读者,也可以作为面试前的系统梳理。为了把问题讲透,我会尽量把执行机制和踩坑细节都摊开,不只是给结论,而是让你知道为什么必须这样做,代码也都能直接抄下来改改就能用。整个文章的核心思路其实就是一件事:理解每个组件在请求处理链路中的位置,位置对了,用起来自然顺手。

1. 三大组件的核心定位与执行层级差异

学这三个组件之前,首先要破除一个常见幻觉:Filter、Interceptor、Listener并不是一个层面的东西。虽然它们都能给Web应用"加逻辑",但一个处理的是Servlet容器层面的请求流向,一个处理的是Spring MVC层面的方法调用,还有一个是容器生命周期和事件机制的通知器。三者的设计目标从一开始就不一样。

1.1 三个组件各自管的是什么

先锚定一个基础概念:JavaWeb应用里,请求进入后依次经历的阶段是:客户端请求到达Servlet容器(比如Tomcat)→ 容器按URL匹配Servlet → Servlet执行后返回响应。这里的Servlet可以理解成真正处理业务逻辑的入口,Spring MVC里的DispatcherServlet本质上也是一个Servlet。

Filter过滤器就工作在这条链路的最前端,它由Servlet规范定义,只要能部署到Servlet容器的Web应用都能用。Filter的特点是"链式拦截",可以拿到HttpServletRequest和HttpServletResponse的原始引用,在请求进入Servlet之前做处理,在Servlet返回响应之后再次做处理。典型的应用场景是:编码设置、CORS跨域响应头、请求日志记录、登录状态校验、请求参数篡改等。

Interceptor拦截器则是由Spring MVC框架提供的组件,工作在DispatcherServlet把请求分发给具体Controller方法之前和之后。正因为它是Spring的组件,所以可以访问Spring容器中的Bean,也天然能拿到HandlerMethod对象,从而知道这次请求具体匹配到了哪个Controller的哪个方法。典型应用场景是:方法级权限校验、Controller接口耗时监控、通用日志记录、统一修改ModelAndView等。

Listener监听器同样来自Servlet规范,但它的机制和Filter完全不同。Filter是主动拦截请求,Listener是被动监听事件。Servlet容器在运行过程中会不断产生事件,比如Web应用启动、会话创建、Session过期、属性增删改等,Listener就是用来响应这些事件的观察者。典型应用场景是:应用启动时预加载数据、统计在线人数、Session超时清理、全局配置初始化等。

1.2 三个组件的执行时机与调用顺序

在同一个请求中,Filter、Interceptor的执行顺序是有固定规律的。假设一个请求到达服务器,整个过程是这样的:

  1. 请求先进入Servlet容器,容器根据URL决定哪些Filter匹配,按配置顺序逐个执行Filter的doFilter方法。
  2. 如果Filter调用chain.doFilter,请求才会继续往后传,最终到达DispatcherServlet。
  3. DispatcherServlet解析请求,通过HandlerMapping找到对应的Controller方法,此时Interceptor开始介入。先执行所有匹配到的Interceptor的preHandle方法,顺序执行,直到某个preHandle返回false或全部通过。
  4. 全部通过后,Controller方法执行。
  5. Controller返回后执行Interceptor的postHandle方法,这里注意执行顺序是逆序的。
  6. 当请求完全处理完毕,视图渲染结束后执行afterCompletion方法,顺序同样是逆序。
  7. 响应最终经过Filter链返回,此时每个Filter还可以在chain.doFilter后面继续做处理。

我用一个更生活化的比喻来理解:假设请求是一架飞机起飞,Filter是机场安检通道,进机场先过安检;Interceptor是登机口工作人员,执行完登机牌核验后才能上飞机;Listener则是机场广播系统,某个登机口开放或关闭时广播会收到通知并播报。安检有问题飞机根本进不了登机区域,登机口核验未通过无法登机,而广播只是被动响应,不阻止飞机起飞。

这个执行方向很关键,特别是Filter和Interceptor混用的时候,如果不知道谁先谁后,排查问题会非常痛苦。

1.3 为什么经常分不清:职责边界与混用场景

大家容易混淆Filter和Interceptor,还有一个客观原因:很多功能两个组件都能做。比如登录校验,写Filter能做,写Interceptor也能做。那到底选谁?这里我给一个比较实用的判断原则:如果功能跟Spring容器的Controller方法执行逻辑无关,只是对HTTP层做通用处理,优先用Filter。如果功能需要知道当前请求是执行哪个Controller方法,甚至需要放行某些Handler,只能用Interceptor。

另外,Interceptor拿不到Filter能拿到的原始Servlet包装对象。有个典型场景是修改请求参数,比如把前端传的加密参数做解密后重新放入Request。Filter可以通过自定义HttpServletRequestWrapper进行包装,让后续代码读取到处理后的参数,而Interceptor做不到这一点,因为请求到Interceptor时已经过了容器匹配阶段,参数已经固化。反过来,Filter无法感知HandlerMethod的存在,如果你要在方法注解上做权限标识校验,Filter是完全没法实现的,Interceptor却很轻松。

Listener就更特殊了,它根本不处理"请求应该放行还是拦截"这样的逻辑,它只做通知和响应。混用场景中,比较常见的是用Listener在应用启动时先做缓存初始化,用Filter做请求级处理,用Interceptor做方法级增强,三者各司其职。用对关键点,项目结构会非常清晰。

2. Filter 过滤器详解:基于Servlet规范的第一道门户

Filter是三个组件里最贴近HTTP底层入口的一个。它的拦截范围是所有匹配到的URL请求,无论是静态资源、Servlet还是Controller接口,只要路径匹配Filter都会执行。所以在做全局跨域、全链路日志这类事情时,Filter是首选。

2.1 Filter的核心原理:FilterChain链式调用

Filter的核心是FilterChain,也就是过滤器链。一个Web应用可以配置多个Filter,容器会按照注册顺序把它们串成一条链。设计上用了典型的责任链模式。每个Filter只做自己职责内的事,然后决定放行还是中断,从而避免一个巨无霸Filter包揽所有逻辑。

看一个最小Filter实现会更容易理解:

public class DemoFilter implements Filter { @Override public void init(FilterConfig filterConfig) throws ServletException { // 容器启动时调用一次,可以读取初始化参数 } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 请求进来时的处理逻辑,比如打印请求路径 System.out.println("请求进入DemoFilter: " + req.getRequestURI()); // 调用chain.doFilter放行,如果注释掉这行,请求就不会继续往下走 chain.doFilter(request, response); // 响应返回时的处理逻辑 System.out.println("响应返回DemoFilter"); } @Override public void destroy() { // 容器销毁时调用一次 } }

这个类里有几个关键点值得展开说。doFilter方法里,chain.doFilter(request, response)这行代码就像一把钥匙,调用它,请求才会继续沿着链路传到下一个Filter或Servlet;不调用,请求就断在这里。这正好解释了为什么很多新手写过滤器实现登录校验,session校验不通过return后,却发现页面一直空白,多半就是忘了在通过校验后调用chain.doFilter。

另外要注意ServletRequest和ServletResponse是通用接口,实际项目中通常要强转成HttpServletRequest和HttpServletResponse,才能拿到session、header、URI等信息。init方法在容器启动时执行一次,适合放配置校验;每次请求只执行doFilter。

2.2 实操:用Filter实现请求日志与登录校验

Filter最常见的落地场景就是统一日志。每个请求进入接口前,我们想知道谁在什么时间调用了什么接口,参数是什么,处理耗时多久;请求处理完后,还要记录响应状态码。用Filter在chain.doFilter前后埋点,正好能覆盖这两个阶段。

下面这段代码可以作为一个可复用的请求日志过滤器:

@Component public class AccessLogFilter implements Filter { private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; long startTime = System.currentTimeMillis(); try { chain.doFilter(request, response); } finally { long costTime = System.currentTimeMillis() - startTime; log.info("请求路径: {}, 客户端IP: {}, 耗时: {}ms", req.getRequestURI(), getClientIp(req), costTime); } } private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; } }

注意这里用了try-finally,目的是即使请求处理过程中抛异常,也能在finally里把日志打出来,耗时统计不会丢。getClientIp这个方法在处理反向代理场景时特别关键,因为直接getRemoteAddr拿到的是代理服务器的IP,而不是真实客户端IP;X-Forwarded-For才是代理转发时附加的真实来源IP。当然,这个Header是客户端可伪造的,生产环境应该由可信的Nginx或网关层覆盖写,而不是无条件信任。

再来说登录校验。Filter版登录校验和Interceptor版都可以做,Filter版的好处是路径匹配简单粗暴:凡是需要登录的URL,全部走同一个Filter,不用关心Controller怎么设计的。核心逻辑是这样的:先从session里取用户信息,如果没有且当前请求不是登录接口,就重定向到登录页或返回401;如果有就放行。

这里有个建议:如果一个项目里存在大量静态资源或者开放接口,最好用路径匹配把Filter范围限定到需要保护的路径下,避免每个请求都被全局过滤器拦一道,性能上虽然损失微乎其微,但代码逻辑上容易出问题。比如在Spring Boot中通过FilterRegistrationBean精确设置URL patterns。另一个不太推荐的做法:在Filter里写业务判断逻辑,比如判断用户角色、判断菜单权限。这些涉及业务语义的东西更适合放到Interceptor或Service层,因为Filter层拿不到HandlerMethod信息,硬写的话会逼迫你把业务规则都写成一堆if-else,后期维护很麻烦。

2.3 Filter的注册方式与执行顺序控制

JavaWeb时代用web.xml配置Filter:

<filter> <filter-name>accessLogFilter</filter-name> <filter-class>com.example.filter.AccessLogFilter</filter-class> </filter> <filter-mapping> <filter-name>accessLogFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

Spring Boot时代推荐使用FilterRegistrationBean注册,好处是能精确控制顺序。不管是加@WebFilter还是@Order注解,实际都不如FilterRegistrationBean直接指定setOrder值可靠,尤其是在多个Filter叠加的时候。

@Configuration public class FilterConfig { @Bean public FilterRegistrationBean<AccessLogFilter> accessLogFilter() { FilterRegistrationBean<AccessLogFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new AccessLogFilter()); registrationBean.addUrlPatterns("/*"); registrationBean.setOrder(1); return registrationBean; } @Bean public FilterRegistrationBean<LoginFilter> loginFilter() { FilterRegistrationBean<LoginFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new LoginFilter()); registrationBean.addUrlPatterns("/api/*"); registrationBean.setOrder(2); return registrationBean; } }

setOrder的值决定了Filter的执行顺序:数字越小,越先执行。所以这里访问日志Filter先执行,接着才执行登录Filter。如果搞反了顺序,登录校验Filter先于日志Filter执行,那未登录请求就不会有日志输出,排查问题时少掉一条重要线索。生产环境上为了防止配置错乱,我会在启动日志里把每个Filter的order和urlPattern打印出来,一目了然。这是个很小的习惯,但关键时刻能省下大量排查时间。

实际项目中还有一种情况很常见:项目引入了第三方依赖,第三方也往Web容器里注入Filter,此时我们需要调整自己和第三方Filter的顺序。用FilterRegistrationBean指定setOrder就能精确控制,这也是它比@WebFilter注解更好用的原因。

需要注意一点,给Filter注入Spring依赖的时候要谨慎处理。如果new了一个Filter实例而不是直接使用Spring IOC管理的Bean,则Filter内部的@Autowired字段会是null。上面FilterConfig中new AccessLogFilter()如果该Filter里存在@Autowired属性,一样会空指针。所以实际项目中,RegistrationBean的setFilter里传入的应该是从Spring容器中获取到的Bean:

@Configuration public class FilterConfig { private final AccessLogFilter accessLogFilter; private final LoginFilter loginFilter; public FilterConfig(AccessLogFilter accessLogFilter, LoginFilter loginFilter) { this.accessLogFilter = accessLogFilter; this.loginFilter = loginFilter; } @Bean public FilterRegistrationBean<AccessLogFilter> accessLogFilterRegistration() { FilterRegistrationBean<AccessLogFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(accessLogFilter); registrationBean.addUrlPatterns("/*"); registrationBean.setOrder(1); return registrationBean; } }

用构造器注入的方式把Spring管理的Filter Bean传给RegistrationBean,这样Filter内部依赖都能正常注入。这是个比较容易踩的细节,直接setFilter(new FilterImpl())的方式在Filter里没有任何依赖时没毛病,一旦加了Service依赖就出问题。另外,如果Filter本身标注了@Component,又再加FilterRegistrationBean手动注册,会造成重复执行问题,因为Spring Boot会把@Component的Filter和RegistrationBean中的Filter都注册进去。在Spring Boot 3.x下,@WebFilter也整合适配器也做了调整,被@WebFilter标记的Filter会被自动识别。为了避免重复,我习惯统一用FilterRegistrationBean注册,Filter类不标注@Component,这样哪里注册、注册几次,都是自己控制。

2.4 实际工作中用Filter还要注意的乱码与跨域问题

乱码问题在纯中文环境的项目中基本都会碰到。POST请求的JSON体中文乱码、GET请求参数乱码、响应中文乱码,原因大概率是字符编码设置不统一。如果不在Filter层面统一处理,每个Controller都得自己写编码转换代码。建议在项目里放一个CharacterEncodingFilter,并且设置forceEncoding为true。

@Bean public FilterRegistrationBean<CharacterEncodingFilter> encodingFilterRegistration() { FilterRegistrationBean<CharacterEncodingFilter> registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(new CharacterEncodingFilter("UTF-8", true, true)); registrationBean.addUrlPatterns("/*"); registrationBean.setOrder(0); return registrationBean; }

setOrder(0),保证它在所有业务Filter之前执行。这个CharacterEncodingFilter是Spring Web提供好的现成类,根本不需要自己造轮子。第二个参数forceRequestEncoding和第三个参数forceResponseEncoding都设置成true,会强制请求和响应都使用UTF-8,否则很可能出现请求是UTF-8、响应却是ISO-8859-1的诡异组合,导致页面中文乱码,排查半天找不出原因。

跨域CORS这里也简单说一句,因为这是过滤器最常用的场景之一。如果是Spring Boot,官方提供了CorsFilter,自己不用写代码。但如果项目里没有引入Spring Web,或者需要最基础的跨域支持,用Filter手动加响应头也完全可行。区别在于:Spring的CorsFilter会正确处理预检请求OPTIONS,自己写Filter时要注意对OPTIONS请求直接放行,并且加上合适的Access-Control-Allow-Headers、Access-Control-Allow-Methods、Access-Control-Allow-Origin等响应头。

3. Interceptor 拦截器详解:Spring MVC的关隘

Interceptor给人的感觉和Filter很像,实际定位不同。它在DispatcherServlet确定好Controller方法之后、方法执行之前介入,天然可以感知到"这次请求到底进入哪个方法",这是它最大的优势。Spring事务、参数解析、异步处理等高级特性都在它后面生效。

3.1 Interceptor三个方法的执行时机

定义拦截器最主流的方式是继承HandlerInterceptorAdapter(老项目)或者直接实现HandlerInterceptor接口(新项目)。HandlerInterceptor接口定义三个默认方法:

  • preHandle:Controller方法执行前调用。返回true继续执行,返回false则中断请求,后续postHandle和afterCompletion都不会执行,视图也不会渲染。
  • postHandle:Controller方法执行后、视图渲染前调用。可以在此处修改ModelAndView,但要注意,Controller中如果用了@ResponseBody或@RestController,ModelAndView为null,此时再修改它没有意义。
  • afterCompletion:整个请求处理完毕之后调用,适合做资源清理、异常记录、耗时统计。

三个方法执行的完整时机可以用下面这段伪代码来表示:

// 伪代码 boolean canContinue = true; for (Interceptor interceptor : interceptorList) { if (!interceptor.preHandle(request, response, handler)) { canContinue = false; break; } } if (canContinue) { try { result = controllerMethod.execute(request, response); } finally { for (Interceptor interceptor : reverse(interceptorList)) { interceptor.afterCompletion(request, response, handler, exception); } } // 视图渲染前 for (Interceptor interceptor : reverse(interceptorList)) { interceptor.postHandle(request, response, handler, modelAndView); } }

注意postHandle和afterCompletion都是倒序执行的,只有preHandle是按顺序执行。这个细节很关键:假如你在两个拦截器里分别配置了资源和逻辑,顺序没理清,可能出现资源先被释放、后一个拦截器还要用的尴尬局面。

3.2 实操:用Interceptor做登录鉴权与方法级日志

登录鉴权是Interceptor最常见的用途。相比Filter,Interceptor可以获取到HandlerMethod,所以能在代码里判断"这次请求是不是某个放行接口",还能读取方法或类上的注解,做非常精细的权限控制。举个实际用的例子,假设项目定义了一个@NoAuth注解,标记的接口不需要登录也可以访问:

@Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface NoAuth { } @Component public class LoginInterceptor implements HandlerInterceptor { private final UserService userService; public LoginInterceptor(UserService userService) { this.userService = userService; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 非Controller方法直接放行,比如静态资源映射 if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; // 方法或类上有@NoAuth注解,说明不需要登录 if (handlerMethod.hasMethodAnnotation(NoAuth.class) || handlerMethod.getBeanType().isAnnotationPresent(NoAuth.class)) { return true; } // Session中不存在用户则返回401 Object user = request.getSession().getAttribute("loginUser"); if (user == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); return false; } return true; } }

这段代码有几个设计考量。第一个考量和Filter完全不同:通过handler instanceof HandlerMethod先过滤掉非Controller请求,因为Spring MVC中的静态资源映射、错误分流等情况下handler并不是HandlerMethod对象,直接强转会抛ClassCastException。第二个考量是读取注解的方式用了hasMethodAnnotation和getBeanType()两个配合,意味着在Controller类上打@NoAuth也能生效,这样团队约定上更灵活——某个Controller整体都是白名单直接在类上注解就行。

性能监控是Interceptor另一块高价值应用场景。可以在preHandle中记录开始时间,在afterCompletion中计算耗时和异常信息。这样做的核心优势是方法和请求一一对应,日志中可以直接输出类名、方法名、请求路径,排查问题非常方便。

@Component public class PerformanceInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(PerformanceInterceptor.class); private static final String START_TIME = "startTime"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod) { request.setAttribute(START_TIME, System.currentTimeMillis()); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod = (HandlerMethod) handler; Long startTime = (Long) request.getAttribute(START_TIME); if (startTime != null) { long cost = System.currentTimeMillis() - startTime; log.info("接口: {}.{} 耗时: {}ms, 异常: {}", handlerMethod.getBeanType().getSimpleName(), handlerMethod.getMethod().getName(), cost, ex == null ? "无" : ex.getMessage()); } } } }

这里的经验:耗时统计一定放afterCompletion而不是postHandle,因为postHandle执行时视图还没有渲染,此时计算出来的耗时是Controller方法执行耗时,不含视图渲染耗时。如果项目用jsp或Thymeleaf做服务端渲染,这两者差别很大。但用@RestController时,postHandle和afterCompletion差别不大。更进一步的场景是记录异常日志:afterCompletion有个exception参数,当Controller执行过程中抛出异常时,这里能拿到异常对象,很适合做统一异常埋点。不过要注意,如果异常在@ControllerAdvice全局异常处理器里已经被处理掉了,这个参数仍会拿到原始异常,需要结合业务判断要不要记录两遍。

3.3 注册拦截器与路径匹配规则

Spring Boot里注册拦截器通过实现WebMvcConfigurer完成:

@Configuration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; private final PerformanceInterceptor performanceInterceptor; public WebConfig(LoginInterceptor loginInterceptor, PerformanceInterceptor performanceInterceptor) { this.loginInterceptor = loginInterceptor; this.performanceInterceptor = performanceInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(performanceInterceptor) .addPathPatterns("/**") .excludePathPatterns("/error", "/favicon.ico"); registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/login", "/api/register"); } }

这里要特别留意拦截器的注册顺序。addInterceptors方法中,先添加的Interceptor会先执行preHandle。所以性能监控要放在最前面,确保它能记录到登录校验的耗时。如果登录拦截器先执行,未登录请求在性能监控还没开始计时的时候就被拦截了。

路径匹配规则中,/**匹配所有路径,包括多级路径;/*只匹配一级路径。这个差别很多人会搞混。比如/api/user/list/api/*是匹配不到的,/api/**才匹配。excludePathPatterns排除的路径要写全,比如项目里登录接口是/api/user/login,如果只排除/api/login,拦截器照样会拦截登录请求,导致用户永远无法登录。

3.4 拦截器处理跨域和异步请求的一个坑

很多项目同时使用Interceptor和Spring的CORS配置,这里有个很隐蔽的坑:预检请求OPTIONS也会经过Interceptor。如果项目中有一个全局登录Interceptor,preHandle里要求session必须有用户信息,那么前端发起跨域预检请求OPTIONS时会被拦截,导致预检失败,前端报CORS错误但后端日志看不到异常。解决方法是preHandle开头识别请求方法:

if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; }

这个代码比在排除路径里一个个加**options**要简单可靠得多。另外一个坑:如果Controller方法返回类型是Callable或WebAsyncTask,或者标注了@Async,请求进入异步模式后会从Tomcat线程池中脱离。此时Spring MVC容器会暂停拦截器链的执行,直到异步结果回来后,才会继续执行postHandle和afterCompletion。如果在Interceptor中使用了ThreadLocal保存用户信息,异步线程是拿不到的,这一点在做异步接口的时候需要额外小心。

4. Listener 监听器详解:事件驱动与生命周期管理

与Filter和Interceptor一直围绕着"请求"转不同,Listener关注的对象是Web应用本身。一个Web应用从启动到销毁的整个生命周期中,会发生很多事件:应用初始化完成、应用即将销毁、创建Session、Session失效、往Session里存了数据等。Listener就是用来监听这些事件,并做出相应处理的组件。它对请求是透明的,不会拦截也不会修改请求,只负责"听到事件后干点活"。

4.1 Servlet规范中的Listener体系

Servlet规范里定义了大量Listener接口,但从实际使用频率来看,真正常见的就这么几类。下面整理一个速查表,方便理清整个体系:

监听对象监听接口触发事件
ServletContext生命周期ServletContextListenercontextInitialized:应用启动时触发;contextDestroyed:应用卸载时触发
ServletContext属性变化ServletContextAttributeListener向application域添加、删除、替换属性时触发
HttpSession生命周期HttpSessionListenersessionCreated:Session创建时触发;sessionDestroyed:Session销毁或过期时触发
HttpSession属性变化HttpSessionAttributeListener向session域添加、删除、替换属性时触发
ServletRequest生命周期ServletRequestListenerrequestInitialized:请求进入时触发;requestDestroyed:请求销毁时触发
ServletRequest属性变化ServletRequestAttributeListener向request域添加、删除、替换属性时触发
HttpSession绑定/解绑HttpSessionBindingListener对象被绑定到Session或从Session解绑时触发

其中最重要、最高频使用的就是ServletContextListener。因为它在Web应用初始化完成后触发,适合做各种初始化逻辑。很多项目用Spring Boot后觉得再也不需要Listener了,因为Spring自身启动过程就是通过ApplicationListener监听Spring容器事件完成的。但如果你需要获取标准的ServletContext对象做全局配置,或者需要在Web应用启动时做点Servlet规范层面的处理,ServletContextListener仍然是标准做法。

ServletRequestListener相对小众一点,但它有一个很巧妙的使用场景:统计每个请求的访问时间,比Filter更轻量,因为它不做链式处理,只是"响起"一下。比如在requestInitialized中绑定开始时间,在requestDestroyed中计算整个请求生命周期。

还有一个容易混淆的点:HttpSessionBindingListener和HttpSessionAttributeListener的区别。HttpSessionAttributeListener是监听Session上的属性增删改,而HttpSessionBindingListener是让对象自己感知"我被绑定到Session了"或"我从Session解绑了"。前者注册在容器上,后者是对象主动实现接口。实际项目中用HttpSessionAttributeListener做登录状态统计更常见,用HttpSessionBindingListener做过期清理的场景相对少。

4.2 实操:用Listener实现在线人数统计与Session清理

在线人数统计是一个非常好的Listener落地案例。常规做法是用户登录成功后,在Session中放入用户信息,然后由HttpSessionListener在Session创建时加一、销毁时减一。单独统计这个数字有一点误差,更准确的做法是统计"已登录的Session数",而不是全部Session数。下面是一个结合登录逻辑的实现:

@Component public class OnlineCountListener implements HttpSessionListener { private static int onlineCount = 0; @Override public void sessionCreated(HttpSessionEvent se) { // 这里只统计登录后的Session,需要在登录成功时配合设置标记 } @Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession session = se.getSession(); Object user = session.getAttribute("loginUser"); if (user != null) { synchronized (OnlineCountListener.class) { onlineCount--; } } } public static void increaseOnlineCount() { synchronized (OnlineCountListener.class) { onlineCount++; } } public static int getOnlineCount() { return onlineCount; } }

登录成功时调用OnlineCountListener.increaseOnlineCount(),Session过期或用户主动登出销毁Session时,sessionDestroyed会自动减一。用static变量存储在线人数有一个前提:这个应用是单实例部署。如果项目做了多节点负载均衡,各个节点的内存独立,统计出来的人数就是每个节点各自的在线数,而不是全站总数。这种情况要么依赖Redis统一计数,要么用数据库或消息队列做汇总,不能依赖Listener的内存变量。

国情客制的实现里,绝大多数教育项目、中小型管理系统的确就是单节点部署,所以用Listener统计在线人数仍然是个简单实用的方案。比这种"纯粹统计Session"更细一点的需求,比如记录每个在线用户最近活跃时间、维护在线用户列表,可以用HttpSessionAttributeListener。一旦往Session里存入属性loginUser就认为用户上线,往里存入lastActiveTime就更新活跃时间;Session销毁时拿到loginUser属性,如果非空,从在线用户列表Map中移除。这样就能查出当前哪些用户在线、最后活跃时间是什么时候。

更复杂的场景是"定时清理不活跃用户",这通常要结合定期任务。一个比较标准但略显繁琐的做法是:在ServletContextListener的contextInitialized中启动一个调度线程,每隔一段时间遍历Session,判断lastAccessTime距今是否超过阈值。当然,这个功能在Spring Boot里可以交给@Scheduled注解处理,因为Spring容器本身就是ServletContextListener的监听者,没必要自己再搞一套线程调度。实际项目中,我通常只在需要同时监听多个事件时才写一个综合Listener,否则单独维护一个Spring的@Component + @Scheduled反而更简单。

4.3 Listener的注册方式与常见疑惑

JavaWeb时代Listener在web.xml中注册:

<listener> <listener-class>com.example.listener.OnlineCountListener</listener-class> </listener>

Spring Boot时代有两种方式。第一种是类上直接标注@WebListener注解,然后启动类加@ServletComponentScan扫描;第二种是使用@Bean方式注册:

@Configuration public class ListenerConfig { @Bean public ServletListenerRegistrationBean<ServletContextListener> appInitListener() { return new ServletListenerRegistrationBean<>(new AppInitListener()); } }

第二种方式的好处是能明确看到注册了哪些Listener,并且可以传入Spring容器管理的依赖。在企业项目里,我更推荐用这种方式。

常见疑惑一:为什么@WebListener不生效?原因大概率是Spring Boot启动类没加@ServletComponentScan,或者加了但扫描的包路径不对。这种问题特征很隐蔽,启动时不报错,但Listener就是没有任何输出。建议先检查启动类注解,再用上面的@Bean方式替代注册。

常见疑惑二:Listener里能不能注入Service?直接new出来的Listener是不能依赖Spring注入的,因为Servlet容器实例化Listener时不会走Spring生命周期。要想在Listener里使用Spring容器中的Bean,可以换一种思路:Listener中维护一个静态的ApplicationContext引用,然后在Spring容器启动完成后把容器赋给它。简单做法:

@Component public class SpringContextHolder implements ApplicationContextAware { private static ApplicationContext context; @Override public void setApplicationContext(ApplicationContext applicationContext) { context = applicationContext; } public static Object getBean(String beanName) { return context.getBean(beanName); } }

然后在Listener里通过SpringContextHolder.getBean("serviceName")获取就行。这个做法虽然老派,但理解起来非常透彻,也解决了很多框架层面的问题。当然,Spring Boot中更契合现代实践的方式是让Listener自身也作为Spring的@Bean注册,由Spring创建和持有,这样它的依赖可以正常注入。不过这种写法不再是"容器管理的Listener",而是"Spring管理的Bean同时实现了Servlet规范接口",实际效果上差别不大,但代码模式上要清楚。

5. 高频问题排查笔记与经验速查

把这三个组件放到同一个项目中,意味着执行链路变长,一旦出问题,排查起来也比单一组件要复杂。根据过往经验,我把最常见的几类问题整理成一张速查表,再逐一展开背后的判断思路。

问题现象涉及组件主要原因排查方向
Filter不执行Filter没注册、路径匹配不对、重复注册检查FilterRegistrationBean或web.xml、@ServletComponentScan
Filter执行了两次Filter@WebFilter和FilterRegistrationBean重复注册、类上有@Component检查重复注册情况,保留一种方式
Interceptor不执行Interceptor没实现WebMvcConfigurer、路径不匹配、没写excludePathPatterns导致登录拦截了后续检查addInterceptors方法、拦截器路径、启动类是否扫描配置类
OPTIONS请求被拦截InterceptorpreHandle中未放行预检请求添加OPTIONS方法判断
Listener不生效Listener没加@ServletComponentScan、注册方式错误改用ServletListenerRegistrationBean注册
中文乱码Filter未统一编码Filter使用CharacterEncodingFilter并forceEncoding为true
在线人数统计不准确Listener未考虑多节点、未处理主动登出改用Redis统计或确保单节点部署
过滤器/拦截器中注入的Service为nullFilter / Interceptor手动new实例将组件作为Spring Bean构造器注入
afterCompletion不执行InterceptorpreHandle返回falsepreHandle返回false时afterCompletion不会执行
postHandle拿不到ModelAndViewInterceptor接口是@ResponseBody或@RestController改用afterCompletion或直接在Controller做处理

5.1 三大组件不生效的原因定位

Filter不生效最经典的原因就是注册路径没对上。Spring Boot中FilterRegistrationBean的addUrlPatterns填写"/*"能覆盖所有请求;写"/"则只匹配根路径。这个细节虽然基础,但确实遇到过好几次。另外一个原因是多个Filter执行顺序混乱导致前置Filter把请求短路了,比如登录Filter在编码Filter之前执行,请求进来时编码还没设置,中文参数全部乱码。所以协调好多个Filter的order值非常关键。

Interceptor不生效主要有两种情况。一种是没有实现WebMvcConfigurer,只是单纯在Spring容器里放了Interceptor的Bean,这种做法不会自动注册,等于是白写。另一种是路径匹配出错,addPathPatterns配置成"/api/*",实际访问的是"/api/user/list",因为匹配不上导致拦截器没有执行。排查时可以先去掉路径匹配限制,临时改成"/**",如果生效说明路径有问题;还不生效就检查项目是否有多套WebMvcConfigurer配置,或者Spring Boot的自动配置被覆盖。

Listener不生效的排查思路跟Filter类似。首先确认Web应用启动时Listener有没有被初始化,很多情况下可以在contextInitialized方法里打一条日志,启动后看日志是否是甲。如果完全没有日志,说明Listener根本没注册成功,检查@ServletComponentScan或ServletListenerRegistrationBean是否配置正确。如果日志打印了但业务逻辑没生效,通常是Listener里拿不到依赖对象,或者是监听的事件类型弄错了,比如项目实际用的是session属性变化触发,却注册了Session生命周期监听。

5.2 实际项目中最容易踩的执行顺序坑

Filter和Interceptor混用时最典型的坑是执行顺序完全不符合直觉。举一个真实场景:项目里有两个组件,一个是CORS Filter,一个是登录Interceptor。CORS Filter的作用是给响应加跨域头。登录Interceptor校验用户是否登录。正常情况下,请求先过CORS Filter,再进入Interceptor,跨域头正常加上。但如果不小心把CORS Filter的实现写成了"chain.doFilter()之前做处理,之后也做处理",然后在Interceptor中直接response.getWriter()输出错误信息,此时response已经提交,CORS Filter的after逻辑再尝试加响应头就加不上了。表现出来就是未登录时前端拿不到跨域头,浏览器直接拦截响应,前端的报错既不是401也不是CORS错误,而是网络错误。

再有一个是关于重定向的坑。Filter里做登录校验,如果用户未登录就response.sendRedirect(),这个重定向本身也会再次发起新的HTTP请求,也就是重定向后的请求会再次经过Filter。如果不小心在重定向URL上也匹配了Filter,很可能出现新请求又被拦截,最终进入循环重定向。规避方法是重定向地址要么用不需要登录的白名单,要么在Filter中判断当前请求URL与重定向目标URL一致时放行。

Interceptor执行顺序中还有一个隐蔽现象,很多人以为preHandle返回false后请求就完全断了,其实Servlet容器仍然会把请求转给视图解析器尝试渲染,只是Controller方法不会执行。如果你在preHandle里设置了response的状态码或重定向,但忘了设置响应头Content-Type,浏览器可能直接展示空白页,F12里却能看到状态码是对的。处理时务必确保返回错误信息时设置合适的ContentType和编码。

Listener和Filter联动的坑也不少。比如应用关闭时,如果Filter里使用了Spring容器中的Bean,而容器销毁顺序是先销毁Listener后销毁Filter,那么就会在应用关闭时偶发出现"找不到Bean"的异常。这个问题在多模块工程中尤其明显。对所有资源清理工作,建议都放在ServletContextListener的contextDestroyed方法中进行,而不是依赖Filter的destroy方法,因为Listener的destroy回调在容器层面优先级更高、可预测性更强。

5.3 组合使用的最佳实践模板

谈完排查,最后整理一个组合使用模板。这个模板基于一个典型的Spring Boot 3.x项目,路径规范是/api/**为后端接口、/public/**为公开资源、/admin/**为管理端接口:

@Configuration public class WebConfig implements WebMvcConfigurer { private final LoginInterceptor loginInterceptor; private final PermissionInterceptor permissionInterceptor; private final PerformanceInterceptor performanceInterceptor; public WebConfig(LoginInterceptor loginInterceptor, PermissionInterceptor permissionInterceptor, PerformanceInterceptor performanceInterceptor) { this.loginInterceptor = loginInterceptor; this.permissionInterceptor = permissionInterceptor; this.performanceInterceptor = performanceInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(performanceInterceptor) .addPathPatterns("/**") .excludePathPatterns("/error", "/favicon.ico"); registry.addInterceptor(loginInterceptor) .addPathPatterns("/api/**", "/admin/**") .excludePathPatterns("/api/login", "/api/register", "/api/public/**"); registry.addInterceptor(permissionInterceptor) .addPathPatterns("/admin/**"); } }

Filter配置继续走FilterRegistrationBean,顺序上让编码Filter第一、CORS Filter第二、访问日志Filter第三、业务Filter第四。Listener则保持ServletContextListener做启动初始化和数据预加载,HttpSessionAttributeListener做在线用户跟踪,ServletRequestListener做请求生命周期日志。

这套组合下来,整个项目的请求处理链路是:请求先进编码Filter和CORS Filter,然后由访问日志Filter记录入口,进入Spring MVC后由性能Interceptor统计耗时,登录Interceptor判断身份,权限Interceptor判断操作权限,进入Controller执行业务逻辑,返回后postHandle和afterCompletion做收尾,最后响应原路返回;整个过程中ServletRequestListener记录每次请求的开始和销毁,ServletContextListener负责全局生命周期事件。各司其职,层次分明。

我个人的实践经验是,实际项目中不要试图让一个组件包揽所有功能。Filter管好HTTP层,Interceptor管好Handler层,Listener管好生命周期和事件,三者配合就算不写什么花哨框架,整个应用的基础设施也已经相当完整和清晰了。最后再提一句:线上环境如果发现Filter或Interceptor相关的问题,第一步永远是先确认执行链路上到底有没有走到这一步,最简单的办法就是在doFilter、preHandle、contextInitialized里各打一行日志,连断点都不用在本地折腾,日志输出顺序一出来,链路问题基本当场就能定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询