
SpringMVC拦截器这个东西几乎每个做Java Web开发的人都用过但很多人停留在“会用”的阶段照着配置写一遍登录校验能跑就行。一旦遇到拦截器不生效、执行顺序错乱、postHandle不执行这类问题就只能在搜索引擎里来回折腾越查越迷糊。我用过相当长一段时间的SSM框架组合后来深入读了DispatcherServlet和HandlerExecutionChain的相关源码又把拦截器从源码到配置再到实战整个串了一遍才感觉自己真正掌握了它。这篇文章我就把这个过程完整写出来结合SpringMVC源码解析和SSM整合实践把拦截器的底层原理、注册方式、踩坑排查都讲透。1. 先从整体上看拦截器在SpringMVC里的位置1.1 一个HTTP请求到底经过了哪些环节先建立全局认知。一个请求进入Tomcat之后会先经过Servlet容器层面的Filter过滤器链然后进入DispatcherServlet。DispatcherServlet拿到请求后不会自己直接去找Controller而是委托给HandlerMapping由它来决定这个请求究竟对应哪个Handler处理器。这个Handler通常就是我们的Controller方法但它往往不是单独存在的而是被包装成一个HandlerExecutionChain处理器执行链。protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception { // HandlerMapping的具体实现RequestMappingHandlerMapping / SimpleUrlHandlerMapping等 }关键就在这里HandlerExecutionChain里面除了handler本体还可以挂载一组HandlerInterceptor拦截器。也就是说SpringMVC的拦截器并不是独立于请求链之外的东西它本身就是执行链的一部分。DispatcherServlet在调用Controller方法前后会依次回调拦截器的方法。一次完整的请求生命周期可以简化为DispatcherServlet通过HandlerMapping拿到HandlerExecutionChain依次执行链上的每个拦截器的preHandle方法preHandle全部通过后HandlerAdapter真正调用Controller方法Controller返回ModelAndView之后执行拦截器的postHandle方法视图渲染和收尾阶段执行拦截器的afterCompletion方法整个机制从设计上就非常清晰拦截器借助HandlerExecutionChain实现了解耦SpringMVC的核心只负责流程编排具体的业务逻辑可以交给各个拦截器去增强。1.2 拦截器和Filter的区别为什么会让人混淆很多初学者会把Filter和拦截器混为一谈因为它俩确实长得像。但从技术实现和职责边界来看区别非常明显。Filter工作在Servlet容器层面只有url-pattern没有Handler的概念它根本不关心这个请求最终会被哪个Controller处理拦截器工作在SpringMVC框架层面它能够拿到HandlerMethod也就是可以直接知道请求要调用哪个Controller的哪个方法还能拿到方法的注解、参数、返回值描述等信息。举个例子如果要做方法级别的权限控制比如某个Controller方法上面标注了RequiresPermissionFilter是无能为力的因为Filter看不到方法信息拦截器却可以轻松做到判断handler是否为HandlerMethod然后读取注解做鉴权。这就是为什么在Spring体系里细粒度的Web控制几乎都交给拦截器来完成。2. 源码解析HandlerExecutionChain如何组装和执行拦截器2.1 getHandlerExecutionChain拦截器是怎么挂到请求链上的要理解拦截器的本质直接看AbstractHandlerMapping源码。在HandlerMapping确定了handler之后有一个非常重要的方法getHandlerExecutionChain它专门负责把一个普通handler包装成执行链并把所有适配的拦截器装配进去。protected HandlerExecutionChain getHandlerExecutionChain(Object handler) { HandlerExecutionChain chain (handler instanceof HandlerExecutionChain ? (HandlerExecutionChain) handler : new HandlerExecutionChain(handler)); chain.addInterceptors(getAdaptedInterceptors()); return chain; }表面上看这段逻辑极其简单创建一个链把拦截器加进去。但实际情况还要再往深处看getAdaptedInterceptors内部会遍历容器中所有实现了MappedInterceptor接口的拦截器并且用atternMatcher处理映射关系。也就是说拦截器的pathPatterns配置例如addPathPatterns(/**)、excludePathPatterns(/login)并不是SpringMVC核心流程处理的而是在HandlerMapping构建执行链时就做了配置。对于不匹配当前请求路径的拦截器根本不会加到链里面。这直接引出一个很重要的认知拦截器的路径排除是非常高效的。如果请求路径不匹配拦截器的映射路径它连preHandle都不会被回调而不是像某些人理解的那样“所有拦截器都会执行preHandle只不过在内部判断路径”。源码层面的设计省去了大量无谓调用。2.2 DispatcherServlet中的回调顺序preHandle、postHandle、afterCompletion现在把镜头切换到DispatcherServlet核心的doDispatch方法。HandlerExecutionChain的applyPreHandle方法会按照注册顺序从头到尾执行preHandle但后面的postHandle、afterCompletion则是从尾到头倒序执行。这是个非常经典的设计类似于递归回溯每个拦截器负责它的前置逻辑处理完控制器的调用和视图渲染之后按反方向做后置清理。boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception { for (int i 0; i this.interceptors.length; i) { HandlerInterceptor interceptor this.interceptors[i]; if (!interceptor.preHandle(request, response, this.handler)) { triggerAfterCompletion(request, response, null); return false; } this.interceptorIndex i; } return true; }这里面有个容易被忽略的细节就是interceptorIndex。它记录的是“最后一个成功执行preHandle且返回true的拦截器下标”。一旦某个拦截器返回false触发短路triggerAfterCompletion只会倒序调用链上已经标记成功的那些拦截器的afterCompletion而跳过导致中断的这个拦截器它的afterCompletion不会执行。这意味着在一个通用型拦截器中如果preHandle返回false那么这个拦截器自己的afterCompletion也不会被回调资源清理逻辑不能放在这里必须放在preHandle内部自己保证。postHandle的执行逻辑同样倒序void applyPostHandle(HttpServletRequest request, HttpServletResponse response, ModelAndView mv) throws Exception { for (int i this.interceptors.length - 1; i 0; i--) { HandlerInterceptor interceptor this.interceptors[i]; interceptor.postHandle(request, response, this.handler, mv); } }2.3 深入理解短路机制与afterCompletion的兜底作用SpringMVC处理请求有一段典型的try-catch-finally结构afterCompletion就放在finally阶段。这也是为什么即便Controller抛出异常afterCompletion依然会被执行。而postHandle不同它是在Controller正常返回后立即调用的如果Controller抛异常postHandle会直接跳过因为没有可用的ModelAndView。正是这个机制决定了我们在选型时的策略需要做请求日志、资源清理、ThreadLocal清理的必须放在afterCompletion需要往ModelAndView里注入公共数据的放在postHandle需要做前置校验、权限判断、参数预处理并可直接拦截请求的放在preHandle我在实际项目里见过一个统计接口耗时的拦截器把结束时间和耗时计算写在了postHandle里结果接口一抛异常耗时统计永远缺失。改成afterCompletion之后才彻底解决。这种坑完全可以从源码机制上预判——Controller抛异常时根本不会进入postHandle。如果不看源码你只会觉得“postHandle有时候执行有时候不执行很诡异”。看完源码你就会明白这不是框架的随机行为而是由异常处理链路决定的。3. SSM整合实践拦截器的注册方式与配置要点3.1 SSM项目的基础结构回顾先简单铺垫一下SSM的工程结构后端要分三层Spring负责Service和事务SpringMVC负责Controller层MyBatis负责数据持久化。在传统XML配置模式下web.xml会分别加载applicationContext.xml和spring-mvc.xml并配置DispatcherServlet的映射路径。在这种结构下拦截器通常属于SpringMVC这一层的组件因为它直接服务于Handler调用链。所以拦截器的bean定义、注册配置都放在spring-mvc.xml里。Service层的Spring容器理论上不需要感知拦截器的存在。一个常规的工程包路径大致如下com.example ├── controller ├── service ├── dao ├── interceptor └── config3.2 XML方式注册拦截器标签加映射路径传统SSM项目最常用的注册方式是mvc命名空间的interceptors标签。下面是一份可以直接使用的配置mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/register/ mvc:exclude-mapping path/assets/**/ bean classcom.example.interceptor.LoginInterceptor/ /mvc:interceptor mvc:interceptor mvc:mapping path/**/ bean classcom.example.interceptor.AccessLogInterceptor/ /mvc:interceptor /mvc:interceptors对于拦截器bean如果没有注入依赖的需求可以不指定id使用直接bean定义即可。但如果拦截器里需要注入Service我习惯给它配一个id避免和其他同名bean冲突bean idloginInterceptor classcom.example.interceptor.LoginInterceptor/ mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ bean refloginInterceptor/ /mvc:interceptor /mvc:interceptors3.3 JavaConfig方式注册拦截器适配Spring 4到Spring 5如果你用的是Spring 5.x或Spring Boot风格下的SSM混合配置不再使用spring-mvc.xml而是通过实现WebMvcConfigurer接口来配置MVC组件。早期的WebMvcConfigurerAdapter已经被标记废弃现在直接继承WebMvcConfigurer即可。Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private LoginInterceptor loginInterceptor; Autowired private AccessLogInterceptor accessLogInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(/login, /register, /assets/**); registry.addInterceptor(accessLogInterceptor) .addPathPatterns(/**); } }比起XML方式JavaConfig的写法更直观尤其对于拦截器数量较多的项目可以清晰看到每个拦截器的路径规则。需要特别注意的是顺序。addInterceptors的注册顺序就是各拦截器在HandlerExecutionChain中的排列顺序preHandle按注册顺序执行而非按配置类的写法顺序。3.4 拦截器放在哪一层才能正常注入Service这里是一个SSM整合中非常常见的坑拦截器虽然是SpringMVC的组件但它如果要注入Service而这些Service又定义在Spring父容器中那它必须被SpringMVC子容器所能扫描到。具体来说spring-mvc.xml里的注解扫描通常只扫描controller包context:component-scan base-packagecom.example.controller/如果你把拦截器包定义在这个扫描路径之外那它就无法被实例化你声明的接口会注入失败最典型的表现就是启动报错或运行时空指针。解决办法有两种一是把interceptor包也加入spring-mvc.xml的组件扫描路径二是像前面那样在spring-mvc.xml中显式定义拦截器的bean并让它引用Spring容器中的Service。显式定义的方式可控性更强我个人更推荐。4. 核心实战基于SSM实现登录认证拦截器和请求日志拦截器4.1 需求细览与模块划分现在做一个有实际业务背景的案例。假设我们有一个SSM架构的后台管理系统需要满足两个需求一是未登录用户不能访问除登录页之外的任何页面二是所有请求都要记录访问日志包括请求地址、方法、参数、耗时、处理结果状态。对于需求一我打算实现一个LoginInterceptor。对于需求二实现一个AccessLogInterceptor。两者都注册到/**路径下登录拦截器排除登录、注册和静态资源。实际项目中日志拦截器通常放在登录拦截器之前注册这样它可以先记录请求开始时间然后登录拦截器决定是否放行。如果登录拦截器直接拦截了请求日志拦截器的afterCompletion依然会执行因为整个链路的finally机制保证了这一点。接口层定义一个常量类存放Session的key和路径常量避免一词反复改动。public class WebConstants { public static final String SESSION_USER session_user; public static final String LOGIN_PATH /login; public static final String REGISTER_PATH /register; public static final String ASSET_PREFIX /assets; }4.2 登录认证拦截器的完整实现首先要说明一点登录拦截器内部的逻辑要区分普通页面请求和AJAX异步请求。普通请求可以直接重定向到登录页但AJAX请求如果收到302重定向通常不会自动跟随前端会拿到一个异常的状态码导致难以排查。所以我对AJAX请求统一返回401状态码由前端框架统一处理跳转。代码实现如下Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod false) { return true; } HttpSession session request.getSession(false); Object user session null ? null : session.getAttribute(WebConstants.SESSION_USER); if (user null) { if (isAjaxRequest(request)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); } else { response.sendRedirect(request.getContextPath() WebConstants.LOGIN_PATH); } return false; } UserContext.setUser((SysUser) user); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } private boolean isAjaxRequest(HttpServletRequest request) { String requestedWith request.getHeader(X-Requested-With); return XMLHttpRequest.equals(requestedWith); } }这里引入了一个ThreadLocal工具类UserContext。它的作用是避免Controller方法每一层都要手动从Session取用户信息拦截器放行前把用户对象放进ThreadLocal请求结束时再清理Controller和Service可以直接从UserContext取当前用户。public class UserContext { private static final ThreadLocalSysUser USER_HOLDER new ThreadLocal(); public static void setUser(SysUser user) { USER_HOLDER.set(user); } public static SysUser getUser() { return USER_HOLDER.get(); } public static void clear() { USER_HOLDER.remove(); } }注意ThreadLocal必须放到afterCompletion里清理否则容器线程池复用线程时上一个请求的用户信息会残留到下一个请求中导致数据错乱。这块是线上事故高发区我见过不止一次因为漏清理ThreadLocal导致的诡异Bug。4.3 请求耗时统计日志拦截器AccessLogInterceptor的核心思路比较简单preHandle记录开始时间afterCompletion计算耗时。但要注意如果请求在preHandle阶段就被拦截返回false这个拦截器自己的afterCompletion依然会执行吗答案是会。原因在前文源码部分已经解释过在触发短路时它会调用已经成功执行preHandle且返回true的拦截器的afterCompletion而这个拦截器本身是第一个且已经成功返回true所以它的afterCompletion会被回调能够完成日志记录。Component public class AccessLogInterceptor implements HandlerInterceptor { private static final Logger log LoggerFactory.getLogger(AccessLogInterceptor.class); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { long startTime System.currentTimeMillis(); request.setAttribute(_start_time, startTime); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { Long startTime (Long) request.getAttribute(_start_time); if (startTime null) { return; } long cost System.currentTimeMillis() - startTime; String uri request.getRequestURI(); String method request.getMethod(); String handlerInfo handler instanceof HandlerMethod ? ((HandlerMethod) handler).getMethod().toGenericString() : handler.getClass().getName(); int status response.getStatus(); log.info(请求: {} {} | 处理器: {} | 状态: {} | 耗时: {}ms, method, uri, handlerInfo, status, cost); } }4.4 整合后的完整链路演示项目里增加一个简单的登录控制器和测试控制器。Controller public class LoginController { PostMapping(value /login) ResponseBody public Result login(String username, String password, HttpSession session) { // 实际项目中会走Service校验密码这里简化为固定账号 if (admin.equals(username) admin123.equals(password)) { SysUser user new SysUser(1L, admin); session.setAttribute(WebConstants.SESSION_USER, user); return Result.success(); } return Result.error(用户名或密码错误); } GetMapping(value /logout) public String logout(HttpSession session) { session.invalidate(); return redirect:/login; } }Controller RequestMapping(/user) public class UserController { GetMapping(/list) ResponseBody public ListSysUser list() { return UserContext.currentUser() null ? Collections.emptyList() : userService.listAll(); } }实际完整执行流程可能是这样的就拿GET /user/list接口来说AccessLogInterceptor的preHandle记录开始时间LoginInterceptor的preHandle检查Session没有用户则返回false此时URL重定向到/login请求处理结束AccessLogInterceptor的afterCompletion依然执行记录一条未登录被拦截的日志用户通过登录接口认证后再次访问LoginInterceptor放行并把用户放到ThreadLocalController返回JSON数据DispatcherServlet渲染响应postHandle执行afterCompletion执行日志拦截器输出当前请求的完整信息LoginInterceptor清理ThreadLocal上面这个链路展示了一个非常重要的点拦截器的执行链并不因为某一次拦截而中断日志统计。只要你的日志拦截器注册在登录拦截器之前它就能记录被拦截的请求。如果想让日志拦截器记录“被拦截原因”可以在请求域中放一个属性比如request.setAttribute(intercept_msg, 未登录)日志拦截器在afterCompletion中读取并输出这样日志链路就完整了。4.5 集成之后如何快速验证拦截器是否生效我习惯用三种方式验证SSM项目的拦截器配置是否正常第一种是看日志。AccessLogInterceptor启动后访问任意业务接口如果日志输出请求信息和耗时说明拦截器已经成功进入执行链。第二种是直接测试登录拦截。未登录状态下访问受保护接口观察浏览器是否跳转到登录页或者AJAX请求是否返回401。第三种是临时加断点调试。在LoginInterceptor的preHandle第一行加断点启动Debug模式访问一个匹配的接口看断点是否命中。如果不命中检查路径映射或spring-mvc.xml有没有被正确加载。这个方法在集成之初排查问题效率极高。5. 拦截器不生效、执行顺序错乱等典型问题排查5.1 拦截器不生效HandlerMapping压根没覆盖到拦截器不生效的原因排在首位的就是路径映射没有匹配。最常见的情况是DispatcherServlet的url-pattern配置成了/*而拦截器注册的路径是/**一个Servlet路径一个SpringMVC路径两者经常被搞混。同时在SSM项目中.action、.do后缀的Servlet映射非常普遍比如servlet-mapping servlet-namedispatcherServlet/servlet-name url-pattern*.do/url-pattern /servlet-mapping这时候如果拦截器的addPathPatterns配置的是/api/**那以.do结尾的请求根本不会被DispatcherServlet接管拦截器自然也不会执行。所以排查的第一步永远先确认请求是否确实进入了DispatcherServlet可以在DispatcherServlet的doService方法上临时打日志或加断点来验证。5.2 拦截器不生效Spring容器中没有被加载第二种高发原因是spring-mvc.xml本身没加载或者mvc:interceptors标签被放在applicationContext.xml中。这里有非常重要的一点SpringMVC的拦截器只能在DispatcherServlet对应的MVC容器配置中生效。如果放在Spring父容器的applicationContext.xml里即使配置不报错也无法被加到HandlerExecutionChain。另外对于JavaConfig方式如果你的SpringMVC配置类没有被EnableWebMvc扫描或者没被显式引入同样会静默失效。Spring Boot环境下通常没问题但在传统SSM里DispatcherServlet的init-param contextConfigLocation必须指向包含WebMvcConfigurer配置的XML或类。5.3 执行顺序不对注册顺序和你想的不一样当系统里同时存在多个拦截器时顺序问题非常容易引起困惑。一个比较典型的场景是先在代码里注册了接口权限拦截器又注册了耗时日志拦截器结果发现日志输出总是在权限判断之后才拿到数据导致那些被权限拦截器拒绝的请求没有被记录。排查顺序问题时最好记住两条原则preHandle的正序执行顺序严格按照注册顺序postHandle和afterCompletion是倒序执行如果要在同一个方法里控制多个拦截器的协作顺序比如日志拦截器必须最先记录、最后输出那它必须注册在第一个位置。权限拦截器返回false后只有日志拦截器的afterCompletion能得到执行机会。5.4 postHandle响应头设置不生效可能是视图渲染顺序的问题postHandle执行时机是在Controller方法调用之后、视图渲染之前。它的官方用途是可以修改ModelAndView或者往响应中添加额外的Header。但很多人会遇到拦截器里设置的响应头不生效尤其在使用ResponseBody的情况下。原因在于ResponseBody接口在HandlerAdapter内部就已经完成JSON序列化并写入了响应体后续的请求处理链中视图解析这步基本不会发生。postHandle虽然会被执行但对响应头内容的修改实际上是有条件的某些写法可能会因为响应已经提交committed而失效。所以如果你要给API接口统一增加响应头更可靠的方式是使用ResponseBodyAdvice而不是重度依赖拦截器的没把握。5.5 拦截器内部异常导致请求链路紊乱拦截器内的异常处理也要留意。preHandle抛异常DispatcherServlet会直接进入triggerAfterCompletion此时前面成功执行的拦截器的afterCompletion依然会被执行。但如果afterCompletion再次抛出异常则可能造成原始异常被覆盖对线上问题的定位非常不友好。因此在afterCompletion里我一定会对代码做异常兜底Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { try { // 日志记录、清理资源 } catch (Exception ignore) { // 不影响主流程 } }5.6 异步请求环境下的拦截器注意事项如果你做的是SSM 异步Servlet的长轮询或流式接口拦截器在异步分发时会有不同的行为。Spring 5.3之前HandlerInterceptor需要实现AsyncHandlerInterceptor接口才能适配异步请求Spring 5.3之后HandlerInterceptor里新增了afterConcurrentHandlingStarted钩子方法。在异步请求场景DispatcherServlet第一次调用时会执行preHandle然后进入异步处理此时afterCompletion不会立刻触发而是要等异步线程计算出结果并完成后续分发后才会执行postHandle和afterCompletion。如果你不了解这个机制在异步接口里会发现拦截器的afterCompletion迟迟不执行甚至怀疑框架bug。实际这是异步机制的设计意图——标准拦截器不拦截异步调用中的后续阶段。如果需要完整记录异步请求的耗时可以考虑用Filter或者自定义CallableProcessingInterceptor。6. 关于代码设计的一些补充想法在SSM项目里很多团队对拦截器的使用比较粗放把所有逻辑都塞进一个“大而全”的拦截器里导致这个类越写越长最后没人敢动。我自己的习惯是一个拦截器只干一件事。认证拦截器只管认证日志拦截器只管日志接口幂等校验就单独写一个幂等拦截器。每个拦截器通过注册顺序协作而不是在一个类里堆砌一堆if判断。有意识地分解拦截器职责最终的收益非常明显排查问题时可以直接定位到是哪个拦截器出的问题新增一个切面功能时不会影响已有逻辑在单元测试中也可以针对单个拦截器单独编写测试用例。这一点和二八法则很类似拦截器链的灵活程度关键不在于框架能做什么而在于你怎么设计它们之间的关系。另外拦截器和AOP的使用边界也值得提一下。虽然都是横切逻辑但拦截器能拿到HTTP请求响应对象适合做Web层控制AOP更偏方法级别的增强适合处理Service层的事务、权限校验等。在SSM实践中Controller层优先用拦截器Service层优先用Spring AOP这是相对合理的一套组合拳。这样做的好处是Web层逻辑不会侵入到Service层事务边界的清晰程度也能得到保障。