搞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的执行顺序是有固定规律的。假设一个请求到达服务器整个过程是这样的请求先进入Servlet容器容器根据URL决定哪些Filter匹配按配置顺序逐个执行Filter的doFilter方法。如果Filter调用chain.doFilter请求才会继续往后传最终到达DispatcherServlet。DispatcherServlet解析请求通过HandlerMapping找到对应的Controller方法此时Interceptor开始介入。先执行所有匹配到的Interceptor的preHandle方法顺序执行直到某个preHandle返回false或全部通过。全部通过后Controller方法执行。Controller返回后执行Interceptor的postHandle方法这里注意执行顺序是逆序的。当请求完全处理完毕视图渲染结束后执行afterCompletion方法顺序同样是逆序。响应最终经过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而不是真实客户端IPX-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配置Filterfilter filter-nameaccessLogFilter/filter-name filter-classcom.example.filter.AccessLogFilter/filter-class /filter filter-mapping filter-nameaccessLogFilter/filter-name url-pattern/*/url-pattern /filter-mappingSpring Boot时代推荐使用FilterRegistrationBean注册好处是能精确控制顺序。不管是加WebFilter还是Order注解实际都不如FilterRegistrationBean直接指定setOrder值可靠尤其是在多个Filter叠加的时候。Configuration public class FilterConfig { Bean public FilterRegistrationBeanAccessLogFilter accessLogFilter() { FilterRegistrationBeanAccessLogFilter registrationBean new FilterRegistrationBean(); registrationBean.setFilter(new AccessLogFilter()); registrationBean.addUrlPatterns(/*); registrationBean.setOrder(1); return registrationBean; } Bean public FilterRegistrationBeanLoginFilter loginFilter() { FilterRegistrationBeanLoginFilter 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容器中获取到的BeanConfiguration 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 FilterRegistrationBeanAccessLogFilter accessLogFilterRegistration() { FilterRegistrationBeanAccessLogFilter 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 FilterRegistrationBeanCharacterEncodingFilter encodingFilterRegistration() { FilterRegistrationBeanCharacterEncodingFilter 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接口定义三个默认方法preHandleController方法执行前调用。返回true继续执行返回false则中断请求后续postHandle和afterCompletion都不会执行视图也不会渲染。postHandleController方法执行后、视图渲染前调用。可以在此处修改ModelAndView但要注意Controller中如果用了ResponseBody或RestControllerModelAndView为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最常见的用途。相比FilterInterceptor可以获取到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;charsetUTF-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。如果项目中有一个全局登录InterceptorpreHandle里要求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生命周期HttpSessionListenersessionCreatedSession创建时触发sessionDestroyedSession销毁或过期时触发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-classcom.example.listener.OnlineCountListener/listener-class /listenerSpring Boot时代有两种方式。第一种是类上直接标注WebListener注解然后启动类加ServletComponentScan扫描第二种是使用Bean方式注册Configuration public class ListenerConfig { Bean public ServletListenerRegistrationBeanServletContextListener 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、ServletComponentScanFilter执行了两次FilterWebFilter和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里各打一行日志连断点都不用在本地折腾日志输出顺序一出来链路问题基本当场就能定位。
