ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringMVC拦截器源码解析与SSM整合实战:从HandlerInterceptor到权限校验

SpringMVC拦截器源码解析与SSM整合实战:从HandlerInterceptor到权限校验 1. 从一段“烂代码”说起为什么要折腾拦截器源码早些年还在用Servlet写Filter的时候我一度觉得拦截器是个可有可无的东西——无非就是在请求前后打印几行日志、设置一下编码格式。直到有一次被线上故障按在地上摩擦某个老系统里的登录校验散落在几十个Controller方法里结果漏了一个数据被非授权用户扒了个底朝天。从那以后我彻底明白了统一的请求拦截不是锦上添花而是保命符。SpringMVC的拦截器HandlerInterceptor就是干这个的。它和Filter看起来像但本质上不是一个物种。Filter是Servlet规范里的东西作用于Servlet容器层管不到Controller方法级别的细节而HandlerInterceptor是SpringMVC框架自己的组件它可以在请求进入Controller之前、执行完方法之后、以及整个请求渲染完成之后分别插入自定义逻辑。更重要的是它可以直接拿到HandlerMethod也就是说你能在拦截器里精确地知道这次请求到底要调用哪个类的哪个方法、方法上带了哪些注解这就给了我们做权限校验、接口幂等、操作审计提供了极大的发挥空间。这套机制并不复杂但要把拦截器用得顺手光会配一个 mvc:interceptors 标签是不够的。尤其是做SSMSpring SpringMVC MyBatis整合的时候拦截器经常和参数解析器、全局异常处理器、MyBatis的插件机制混淆在一起配置错一层整个链路都会崩。这篇博文我就从源码层面把SpringMVC拦截器的核心逻辑拆开再走一遍SSM整合里的完整落地流程把那些文档里写不明白的坑都给你填上。先说清楚这篇适合谁看你已经会用SpringMVC写接口但没认真研究过请求从容器到Controller之间究竟发生了什么或者你正在做SSM整合想搞明白拦截器、过滤器、MyBatis插件这三者的分工和配置方式。不需要你读过多深的源码但至少要知道Spring容器、SpringMVC容器是两套东西否则后面讨论拦截器初始化和加载时机时会晕。2. SpringMVC拦截器的核心设计三个方法、一条链路整个拦截器机制的源头就一个接口HandlerInterceptor。源码很简单但每个方法背后的调用时机才是真正要命的地方。2.1 preHandle、postHandle、afterCompletion各管哪一段看一眼HandlerInterceptor的源码三个默认方法一目了然public interface HandlerInterceptor { default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { return true; } default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception { } default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { } }preHandle在HandlerAdapter调用Controller方法之前执行。返回值是boolean返回true代表放行返回false则直接短路后面的拦截器和Controller都不会执行。这是拦截器思路的核心操作点权限校验、接口限流、请求合法性校验都放在这里。postHandle在Controller方法正常执行完成之后、视图渲染之前执行。此时ModelAndView已经构造完成如果Controller返回的是视图名拦截器还能在这里修改Model数据、调整视图。不过绝大多数前后端分离项目里Controller返回的是ResponseBody的JSON数据postHandle就没有太多可操作空间了。afterCompletion则是整个请求处理流程真正收尾的地方无论Controller执行是否抛出异常它都会触发前提是preHandle返回了true且当前拦截器已经执行过。这里适合释放资源、记录日志、清理ThreadLocal。特别要注意如果preHandle返回falseafterCompletion是不会执行的——很多新手在这里踩坑以为afterCompletion对应的是Servlet里Filter的finally逻辑其实不是。举一个实际场景你在preHandle里往ThreadLocal里塞了用户信息Controller和Service里从ThreadLocal取值。正常情况下afterCompletion里清理ThreadLocal没问题但一旦某个拦截器preHandle返回false后面的拦截器连preHandle都不会执行更别提afterCompletion清理了。所以这个场景下建议在拦截器自己内部用try/finally包裹或者干脆在最早的拦截器里统一处理。2.2 多个拦截器的执行顺序正序执行、逆序回退有了三个方法多个拦截器组合时的执行顺序就值得画张表理清。假设配置了两个拦截器A和B请求到达时的执行流是这样的拦截器/阶段执行顺序A.preHandle先执行B.preHandle后执行Controller方法最后执行B.postHandle先执行逆序A.postHandle后执行逆序B.afterCompletion先执行逆序A.afterCompletion后执行逆序这个顺序本质上是栈式调用链式调用前依次压栈回退时依次弹栈。设计成这个顺序是有讲究的——后执行的拦截器更靠近Controller它产生的数据通常是先被Controller消费所以回退时后执行的要先销毁保证资源释放的顺序和创建顺序相反跟数据库事务嵌套的释放逻辑一模一样。实际配置中容易出问题的情况有几种。常见的一类是A、B两个拦截器都做权限校验A判断用户是否登录B判断用户是否有某个角色权限。因为A先执行如果A校验不通过直接返回falseB根本不会执行。这个顺序正好符合预期但如果配置时候把B写在前边就会出现权限判断先于登录判断未登录用户会先收到角色权限错误的提示这就会让前端调试和用户理解都产生困扰。2.3 HandlerInterceptor与HandlerExecutionChain的关联拦截器并不是直接挂在DispatcherServlet上的它被封装在HandlerExecutionChain里。这个类的名字很直白处理器执行链它同时持有HandlerMethod和拦截器列表。DispatcherServlet在doDispatch方法里做的核心事情就是取出这条链然后遍历执行链里的拦截器。看一下SpringMVC请求处理的主干流程就清楚了// DispatcherServlet.doDispatch 关键部分简化 HandlerExecutionChain mappedHandler getHandler(processedRequest); if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } try { mv ha.handle(processedRequest, response, mappedHandler.getHandler()); } finally { mappedHandler.applyPostHandle(processedRequest, response, mv); // 源码中是try-catch内执行 } // 实际上源码的写法是 try { ... } catch ... { } finally { triggerAfterCompletion }applyPreHandle的逻辑是顺序遍历拦截器一旦某个preHandle返回false会触发一个triggerAfterCompletion方法该方法会逆序调用已经执行过preHandle的拦截器的afterCompletion方法。这个细节特别重要虽然afterCompletion不处理preHandle返回false的当前拦截器但是之前已经放行过的拦截器的afterCompletion一定会被触发用来释放资源。这里有个非常经典的坑你配置了一个日志拦截器记录请求耗时preHandle里记录开始时间afterCompletion里计算耗时并打印日志。如果第二个拦截器的preHandle返回false日志拦截器的afterCompletion依然会执行耗时会正常打印但日志里打印的Controller路径是null——因为请求根本没到达Controller。排查这类问题时看到日志里handler为null或者uri为null第一反应就该想到是后续拦截器短路了。3. 拦截器在SpringMVC中的注册与初始化流程源码层面看懂拦截器怎么执行还不够还得知道拦截器是怎么被加载进HandlerMapping里的。这里要区分两件事一是SpringMVC容器如何识别配置的拦截器二是拦截器如何与URL映射关联。这两件事分属不同的配置阶段搞混了就会出现“拦截器明明配置了却不生效”的诡异现象。3.1 配置方式的演进从XML到JavaConfig老项目里绝大多数是XML配置SpringMVC的拦截器声明通常挂在 mvc:interceptors 标签下mvc:interceptors mvc:interceptor mvc:mapping path/api/**/ mvc:exclude-mapping path/api/login/ ref beanloginInterceptor/ /mvc:interceptor /mvc:interceptors这个XML配置最终会被Spring解析成MvcNamespaceHandler再交由InterceptorRegistry处理。SpringMVC的配置类中WebMvcConfigurationSupport提供了addInterceptors方法子类重写这个方法通过InterceptorRegistry添加拦截器效果等价。如果项目用的是SSM的纯XML风格我建议至少了解这条解析链路 mvc:interceptors 标签其实是在给RequestMappingHandlerMapping注入interceptors属性。RequestMappingHandlerMapping是HandlerMapping的实现类负责将请求URL映射到具体的HandlerMethod它在初始化时会把拦截器列表封装到HandlerExecutionChain中然后缓存起来。也就是说拦截器的注册是在HandlerMapping的初始化阶段完成的而不是每次请求都临时注册的。这个初始化时机解释了为什么修改了拦截器配置必须重启容器才能生效——因为HandlerMapping在启动时就已经把拦截器列表固化在内存缓存里了。3.2 拦截器生效的URL匹配规则URL匹配是个大坑。SpringMVC拦截器的路径匹配用的是AntPathMatcher的规则简单来说? 匹配单个字符匹配零个或多个字符但不匹配路径分隔符** 匹配任意层级路径包括路径分隔符注意mapping path/api/**和path/api/的区别。前者能匹配到/api/user/detail后者匹配不到因为*不包含路径分隔符。实际开发中拦截器不生效的案例里一半以上都是路径匹配符写错了。我还遇到过一个更隐蔽的问题按前缀匹配配置了/api/开发环境联调时正常上线后有个请求路径是/api/v2//user路径中间多了一层空白段就绕过拦截器了。当然这个例子比较极端常规场景下注意路径规范化就好。真正要提醒的是配置exclude-mapping的时候一定要和mapping用同一套规则不要出现mapping用/exclude用/api/*这种前后不一致的写法。3.3 HandlerInterceptor与WebMvcConfigurerAdapter的恩怨网上搜拦截器配置教程经常看到老代码里继承WebMvcConfigurerAdapter新代码里实现WebMvcConfigurer接口。如果你在做SSM整合且项目里还用WebMvcConfigurerAdapter要留意Spring的版本。Spring 5.0之后WebMvcConfigurerAdapter已经被标记为DeprecatedSpring Boot 2.0以上干脆直接移除了。但纯SSM项目用的还是Spring 5.xWebMvcConfigurerAdapter依然能跑只是编译时会报个警告。这里最值得注意的不是用哪个类而是WebMvcConfigurer的加载顺序。在SpringMVC中只有容器里注册了WebMvcConfigurer类型的Bean拦截器才会通过WebMvcConfigurationSupport回调addInterceptors方法被加载。如果项目里同时启用了 mvc:annotation-driven 和自定义了WebMvcConfigurer要注意两者不能互相覆盖。具体到SSM整合中如果你在spring-mvc.xml里既配了 mvc:interceptors 又配了JavaConfig形式的拦截器注册后者的优先级会覆盖前者这类问题特别难排查因为两个配置都“看似没问题”。4. SSM整合实战从零搭建一个带拦截器的权限校验体系理论讲够了进入实操。这一节我会把SSM整合中拦截器相关的完整配置过程走一遍包括SpringMVC配置、MyBatis整合、数据库表设计、以及一个可以做登录校验和操作日志的拦截器实现。为了适配不同基础的朋友我会把关键步骤写得细一点。4.1 项目结构和依赖清单假设我们的项目用Maven管理Java 8Tomcat以war包方式部署。先看pom.xml里核心依赖的长相properties spring.version5.2.22.RELEASE/spring.version mybatis.version3.5.10/mybatis.version /properties dependencies !-- Spring核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency !-- MyBatis整合 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.26/version /dependency /dependenciesweb.xml里配置DispatcherServlet和ContextLoaderListener这是SSM整合的骨架。ContextLoaderListener负责加载Spring根容器DispatcherServlet负责加载SpringMVC子容器。拦截器属于Web层必须注册在DispatcherServlet加载的spring-mvc.xml里不能注册到根容器里——这是SSM整合时最容易忽略的原则。根容器管Service、Mapper子容器管Controller、拦截器、视图解析器。4.2 拦截器实现一个既能登录校验又能记日志的通用拦截器这里写一个实用的拦截器。功能两点第一检查登录状态未登录直接返回401第二记录耗时和操作日志为后续做审计准备。实际项目里我一般把这两个功能拆成两个拦截器但这里为了演示一个完整流程先合成一个。public class AuthInterceptor implements HandlerInterceptor { private static final Logger logger LoggerFactory.getLogger(AuthInterceptor.class); // 这个Map在实际项目里换成Redis或者数据库配置 private SetString excludeUrls new HashSet(); public void setExcludeUrls(SetString excludeUrls) { this.excludeUrls excludeUrls; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri request.getRequestURI(); if (excludeUrls.contains(uri)) { return true; } HttpSession session request.getSession(false); if (session null || session.getAttribute(loginUser) null) { // 记录被拦截请求 logger.warn([AUTH] 未授权访问拦截: uri{}, ip{}, uri, getClientIp(request)); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } // 将用户信息写入ThreadLocal便于Service层直接获取 AuthContextHolder.set((LoginUser) session.getAttribute(loginUser)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { AuthContextHolder.clear(); } private String getClientIp(HttpServletRequest request) { String ip request.getHeader(X-Forwarded-For); if (ip null || ip.isEmpty()) { ip request.getRemoteAddr(); } else { ip ip.split(,)[0].trim(); } return ip; } }这里要说明几个关键决策。第一用session配合ThreadLocal做登录态传递。SSM项目通常还是服务端渲染或者手机App对接登录态放在session里最简单可靠。ThreadLocal用于在同一个请求线程里传递用户对象省得每次都要从session里取、然后一层层往Service传参。但ThreadLocal有个很大的隐患——线程池环境下线程复用如果不清理下一个请求可能会读到上一个请求的用户数据。所以我把它设计成在afterCompletion中清理而不是等垃圾回收。第二排除登录接口本身。一个系统总得有个入口让用户登录如果登录接口也被拦截器拦了那就死锁了。所以设计了excludeUrls这个集合代码里简洁判断。第三未登录状态返回JSON而非重定向。早期很多项目习惯重定向到login.html但前后端分离甚至手机端调接口的场景下重定向是没法用的。所以我选择设置response状态码和JSON内容前端根据状态码做跳转。4.3 spring-mvc.xml里拦截器的注册姿势在SSM整合中spring-mvc.xml里拦截器的注册有两种主流方式。方式一纯XML路径使用 mvc:interceptors 标签方式二给 mvc:annotation-driven 配一个WebMvcConfigurer实现类。这里给出方式一的完整XMLmvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/api/login/ mvc:exclude-mapping path/api/register/ mvc:exclude-mapping path/static/**/ bean classcom.example.interceptor.AuthInterceptor property nameexcludeUrls set value/api/login/value value/api/register/value value/static/**/value /set /property /bean /mvc:interceptor /mvc:interceptors这里有个不少人容易忽略的细节mvc:exclude-mapping path/static/**只对拦截器生效不会对DispatcherServlet的静态资源映射生效。如果项目把静态资源也走DispatcherServlet比如配置了 mvc:default-servlet-handler/ 那静态资源请求一样会经过拦截器链。虽然有exclude-mapping排除但更好的做法是给静态资源单独配置一个专门的servlet映射减少无谓的拦截开销。方式二对应的JavaConfig样式如下适合那些已经在用Java配置类的团队Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/api/login, /api/register, /static/**); } Bean public AuthInterceptor authInterceptor() { return new AuthInterceptor(); } }两种方式的区别不在功能而在维护成本。JavaConfig方式变量可在同一个类里引入其它依赖团队成员做权限逻辑扩展时不用去解析xml里乱七八糟的bean引用。XML的好处是配合老项目的统一管理风格。我的建议是新项目一律JavaConfig老项目如果已经在XML里配了好几处类似标签就保持统一风格维护起来别搞两套。4.4 与MyBatis整合拦截器别和MyBatis插件搞混SSM整合过的人都知道MyBatis也有拦截器的概念——MyBatis插件Interceptor。它拦截的是Executor、StatementHandler、ParameterHandler、ResultSetHandler这四个核心对象用于实现分页、SQL改写、性能监控等功能。这里的核心区别在于对比项SpringMVC拦截器MyBatis插件作用层Web请求层Controller前后数据访问层SQL执行前后执行对象HandlerExecutionChainMyBatis四大对象典型用途登录校验、日志、编码分页、SQL优化、数据权限控制配置位置spring-mvc.xmlmybatis-config.xml或持久层配置类实战中有个非常容易出事的点想用MyBatis插件实现数据权限比如自动给查询条件加上dept_id ?却错误地把它注册到了SpringMVC的拦截器配置里导致整个应用启动报错或者插件完全没效果。我见过某个项目因为这种配置错位排查了整整一天。正确姿势是MyBatis插件在mybatis-config.xml里声明configuration plugins plugin interceptorcom.example.plugin.PageInterceptor property namedialect valuemysql/ /plugin /plugins /configuration或者在Spring配置里通过ConfigurationCustomizer实现。这个在SSM中用mybatis-spring整合时可以借助org.mybatis.spring.boot.autoconfigure.ConfigurationCustomizer机制SpringBoot风格或者在SqlSessionFactoryBean里设置configLocation指向mybatis-config.xml。我建议在SSM整合的项目中把spring-mvc.xml、mybatis-config.xml、spring-root.xml三者的职责在项目文档里明确写出来——很多团队往往是三个人开发三个配置文件你加个拦截器到mybatis他加个插件到springmvc最后线上运行起来谁都不知道实际生效的是哪个。4.5 基于注解的细粒度拦截说说HandlerMethod的用法如果所有Controller方法都用同一个URL前缀保护简单配置path就能满足。但权限系统往往需要更细的控制同一个Controller里方法A需要管理员角色方法B只需要登录即可。这时候光靠URL匹配是不够的可以在preHandle里判断handler参数。preHandle方法的handler参数实际类型通常是HandlerMethod。我们可以通过HandlerMethod拿到类级注解和方法级注解从而做自定义权限判断。看个示例Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; // 判断类上或方法上是否有RequireRole注解 RequireRole requireRole handlerMethod.getMethodAnnotation(RequireRole.class); if (requireRole null) { requireRole handlerMethod.getBeanType().getAnnotation(RequireRole.class); } if (requireRole null) { return true; } LoginUser loginUser AuthContextHolder.get(); String[] roles requireRole.value(); if (loginUser null || !Arrays.asList(roles).contains(loginUser.getRole())) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\msg\:\权限不足\}); return false; } return true; }这是生产环境里用得非常多的模式URL匹配负责粗粒度哪些路径需要拦截注解负责细粒度这个方法需要什么角色。它比单独用URL配置要优雅得多也方便团队在代码review时直接看到权限设计。注意一个前提handler只有落在SpringMVC的处理器映射里才会是HandlerMethod如果项目自定义了HandlerMapping返回非HandlerMethod处理器就会走进if (!(handler instanceof HandlerMethod)) return true;这个分支。大多数SSM项目不需要纠结这个。5. 日常开发中拦截器的坑与排查思路这一节我把我这几年踩过的坑和排查思路做一个系统整理。每一条后面都附上判断依据方便你遇到问题立刻对号入座。5.1 拦截器不生效的四大原因第一个原因拦截器注册到了Spring根容器而非SpringMVC子容器。表现是启动不报错但是请求完全没有经过拦截器。排查方法很简单在preHandle第一行打印一行日志如果完全没有说明DispatcherServlet根本没用上这个拦截器。打开项目的spring-mvc.xml确认 mvc:interceptors 标签存在。还有一种情况是项目同时存在applicationContext.xml和spring-mvc.xml而你在applicationContext.xml里创建了RequestMappingHandlerMapping的Bean这会导致SpringMVC的默认HandlerMapping被覆盖拦截器链表也就丢了。第二个原因路径匹配符写错。这个前面提到过常见错误是用了/api/*匹配多级路径实际只匹配到一层。建议在拦截器里临时打印所有请求路径逐个比对。第三个原因 mvc:annotation-driven 和 mvc:interceptors 的声明顺序或层级问题。如果 mvc:interceptors 标签写在了 mvc:default-servlet-handler/ 前面有些版本的Spring解析顺序会有点怪虽然理论上不该有影响但建议把 mvc:interceptors 放在 mvc:annotation-driven 之后两个标签直接平级。第四个原因多个配置类或者多份spring-mvc.xml同时加载且都没有合并拦截器列表导致后加载的那份覆盖了前一份。这个问题在拆分模块的SSM项目里经常出现。建议排查启动日志中RequestMappingHandlerMapping的注册顺序。5.2 拦截器内获取不到注入的Service有很多人在拦截器里通过Autowired注入Service结果运行时报空指针。原因是拦截器如果是在 mvc:interceptors 标签里用 声明的它也是由Spring容器管理的理论上可以注入。但如果你的拦截器是在 mvc:interceptors 里直接new出来的比如bean classcom.example.interceptor.AuthInterceptor/这个bean本身也是Spring管理的能注入。真正容易出问题的是要么拦截器没有被Spring扫描要么用了构造方法new导致脱离了容器。还有一种隐蔽情况拦截器通过XML注册但是Service的Bean声明在Spring根容器中而SpringMVC子容器可以访问父容器的BeanSpringMVC里的Bean不能反过来被根容器访问但子容器访问父容器是允许的所以这个不太会成为问题源头。真正要检查的是spring-mvc.xml扫描的包是否包含拦截器所在包。我建议的做法是所有拦截器的依赖注入都不要依赖扫描而是统一在XML或者JavaConfig里显式声明Bean这样出问题时最容易定位。5.3 拦截器与ModelAttribute、参数解析器的执行顺序RequestResponseBodyMethodProcessor、ModelAttributeMethodProcessor这些参数解析器会在preHandle执行完并放行之后才被调用。也就是说参数校验时机在拦截器之后。存在一个经典问题如果请求体是JSON需要在拦截器里读取request.getInputStream()做签名校验那么在拦截器里读完之后后面的Controller方法又去读RequestBody会发现请求体已经空了。这是因为请求体流是一次性的。解决办法通常是包装HttpServletRequest对象把读过的body缓存起出来。SpringMVC官方也有ContentCachingRequestWrapper但要注意它的作用范围。如果项目确实需要在拦截器层校验请求体内容建议自己创建一个包装类继承HttpServletRequestWrapper在构造时把body读出来缓存到字节数组里然后重写getInputStream和getReader方法重新构建ServletInputStream。这个坑在App端接口签名校验的场合非常常见。我自己写过很多次这个包装器核心就是下面这一层public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private byte[] cachedBody; public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { super(request); try (ServletInputStream inputStream request.getInputStream(); ByteArrayOutputStream buffer new ByteArrayOutputStream()) { byte[] bytes new byte[1024]; int len; while ((len inputStream.read(bytes)) ! -1) { buffer.write(bytes, 0, len); } cachedBody buffer.toByteArray(); } } Override public ServletInputStream getInputStream() { return new CachedServletInputStream(cachedBody); } // 重写getReader略 }使用这个包装类时要注意过滤器先包装拦截器后读。如果你在Filter层就包装了拦截器里就能安全读取。但Filter的执行层级高于拦截器所以这个包装类在Filter里创建才有意义。5.4 异常情况下afterCompletion的执行策略前面提到过afterCompletion在preHandle返回true时才执行。更深一层的问题是如果Controller执行抛出异常并且DispatcherServlet直接进入了HandlerExceptionResolver来处理异常那么postHandle会怎么处理源码的逻辑是在doDispatch中如果处理器执行抛异常异常会被捕捉然后走processDispatchResult。此时如果启动了拦截器且异常已被异常解析器处理postHandle不会再执行因为异常发生时还没到postHandle那一步但afterCompletion一定会执行且异常对象ex会传递过来。因此afterCompletion是记录异常日志的绝佳位置。不过要注意异常的传播链路还受到ExceptionHandler的影响。如果Controller里用ExceptionHandler处理了异常并且正常返回了ModelAndView此时框架会认为异常已恢复但是否执行postHandle取决于异常抛出的阶段。如果异常发生在方法调用阶段Marchall“跳过postHandle”的开关是打开的。这份源码细节比较绕建议把你项目里异常日志的埋点放在afterCompletion里而不是postHandle里。5.5 拦截器和CORS跨域的冲突做前后端分离时拦截器经常会拦到OPTIONS预检请求。浏览器在发真实请求前会先发一个OPTIONS请求用来探测服务器允不允许跨域。如果拦截器在preHandle里看到OPTIONS请求就认为是非法请求直接返回真实请求就会被浏览器拦在门外。正确的处理方式是在拦截器里对OPTIONS请求放行if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; }同时还要确保CORS响应头已经正确设置。这个顺序特别重要如果CORS的响应头是通过另一个Filter设置的Filter会先于拦截器执行如果你在拦截器里直接返回false像上面说的response已经写入了状态码Filter即使设置了头也没意义因为浏览器拿到的是401。我遇到过项目里模仿网上代码在拦截器里直接设置Access-Control-Allow-Origin:*结果和全局的CORS配置冲突产生了重复头。建议统一通过CorsFilter或CrossOrigin处理跨域拦截器只做鉴权越界的事情尽量少干。6. 一个完整的SSM拦截器配置示例可直接照抄这里给出一个我实测可用的spring-mvc.xml完整示例里面包含登录校验拦截器、日志拦截器、静态资源放行以及搭配的CORS配置。你可以根据自己的项目改注包名和表名。?xml version1.0 encodingUTF-8? beans xmlnshttp://www.springframework.org/schema/beans xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xmlns:contexthttp://www.springframework.org/schema/context xmlns:mvchttp://www.springframework.org/schema/mvc xsi:schemaLocation http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context https://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/mvc https://www.springframework.org/schema/mvc/spring-mvc.xsd !-- 扫描Controller包 -- context:component-scan base-packagecom.example.controller context:include-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan mvc:annotation-driven/ mvc:default-servlet-handler/ !-- 拦截器配置 -- mvc:interceptors !-- 登录校验 -- mvc:interceptor mvc:mapping path/api/**/ mvc:exclude-mapping path/api/login/ mvc:exclude-mapping path/api/register/ mvc:exclude-mapping path/static/**/ bean classcom.example.interceptor.AuthInterceptor/ /mvc:interceptor !-- 耗时日志 -- mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/static/**/ bean classcom.example.interceptor.CostLogInterceptor/ /mvc:interceptor /mvc:interceptors !-- 视图解析器按需保留 -- bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean /beans注意这里有两个拦截器都拦截了/api/**。CostLogInterceptor记录所有请求耗时AuthInterceptor只做登录校验。它们的执行顺序是配置顺序先CostLogInterceptor后AuthInterceptor。所以接口耗时日志里会包含AuthInterceptor的校验耗时——如果你希望日志更纯粹可以把CostLogInterceptor放到AuthInterceptor之后但那样耗时就会漏掉AuthInterceptor的时间。这个取舍看你的监控粒度需求。更常见的情况是为了统计真实的业务耗时把CostLogInterceptor放在最外层AuthInterceptor放在内层业务Controller耗时就会包含在内层拦截器的耗时里。具体怎么选要看你统计口径是“接口总耗时”还是“业务代码耗时”。7. 结合源码深入一点拦截器在SpringMVC中的完整生命周期这段算是进阶内容。前面讲了配置和实现这里沿着源码把请求处理链路完整串一遍帮你建立整体视角以后遇到诡异的拦截器问题就不慌了。7.1 DispatcherServlet的doDispatch流程当请求到达DispatcherServlet时doDispatch方法会执行以下核心步骤从HandlerMapping列表中找到能够匹配请求的HandlerExecutionChain。执行链上所有拦截器的preHandle方法如果某个返回false直接return不继续处理。找到合适的HandlerAdapter比如RequestMappingHandlerAdapter调用HandlerAdapter的handle方法反射执行Controller方法。在handle方法内部完成后调用拦截器的postHandle方法。处理视图解析或消息转换。在finally或异常处理逻辑里调用afterCompletion方法。用伪代码描述就是try { mappedHandler getHandler(processedRequest); if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } mv ha.handle(processedRequest, response, mappedHandler.getHandler()); mappedHandler.applyPostHandle(processedRequest, response, mv); } catch (Exception ex) { triggerAfterCompletion(processedRequest, response, mappedHandler, ex); } finally { // 清理资源 }这里的getHandler方法会遍历容器中所有HandlerMapping。SSM项目里最常见的HandlerMapping是RequestMappingHandlerMapping它负责解析Controller和RequestMapping注解。此外还有SimpleUrlHandlerMapping用于静态资源处理等。也就是说同一个请求可能匹配到不同HandlerMapping返回不同的执行链而拦截器列表是分别注册在这些HandlerMapping上的。如果你想精确控制仅在业务接口上拦截而静态资源不拦截要么用 mvc:mapping 的路径限制要么在拦截器实现里判断handler类型是否为HandlerMethod。7.2 InterceptorRegistry与MappedInterceptor的作用有个容易被忽略的类叫MappedInterceptor。它持有了一个HandlerInterceptor实例和一套路径匹配规则。在HandlerMapping初始化时InterceptingHandlerExecutionChain会遍历所有MappedInterceptor根据请求的URL去判断是否需要执行当前拦截器。想想这个设计有什么用它意味着拦截器在真正执行前其实已经被“注册期匹配”过滤过一遍。如果请求路径不匹配某个MappedInterceptor的mapping规则这个拦截器连preHandle都不会被调用。这一点和Filter不同Filter只要配置在了web.xml或FilterRegistrationBean里所有请求都会先经过它。这也解释了为什么很多时候你写了拦截器日志但某些请求路径下没有日志——不是拦截器没生效而是它压根没被匹配到。建议排查时先在拦截器里通过日志打印handler与请求URI确认是不是路径匹配字节的问题。7.3 拦截器与Filter的配合使用一个完整的SSM项目里Filter和拦截器通常合作处理不同层次的问题。我见过最典型的配合方式是这样的Filter层设置字符编码、包装request、实现CORS全局配置。拦截器层登录鉴权、权限校验、日志记录、接口幂等性控制。参数解析器/注解RequestBody解析、参数级校验。MyBatis插件SQL级数据处理、分页。框架选择上Filter更高层拦截器在SpringMVC内。理论上前者是跨SpringMVC的哪怕不是在SpringMVC环境下也能用但实际SSM项目里凡是需要访问HandlerMethod的都必须在拦截器层解决Filter做不到。这里给一个实用建议如果你在Filter里做登录解析然后把用户信息通过request.setAttribute传给拦截器要留意两个组件之间的执行顺序和数据传递方式。看似能共享request对象其实Filter在拦截器之前只要传递的key一致确实可行。这个模式适合那种登录态从Cookie或Header里解析的场景可以避免在拦截器里直接依赖session。8. 进阶玩法拦截器配合ThreadLocal实现多租户数据隔离最后分享一个我在实际项目中用得很顺手的玩法通过拦截器ThreadLocal实现多租户数据隔离不用改动Service层任何代码。这个模式特别适合SaaS类系统或者那种一个应用服务N个内部项目的场景。思路是这样用户在登录时登录接口返回一个租户ID或者项目编号。后续每次请求的Header里携带该ID比如X-Tenant-Id。拦截器在preHandle里把这个值取出来放入ThreadLocal。MyBatis的SQL执行前通过一个MyBatis插件自动从ThreadLocal里读取租户ID并拼接到SQL条件里。这套方案的好处是Service层不用每个方法都手写tenantId条件统一由插件处理减少遗漏。拦截器管获取、插件管使用职责分离。新的业务接口天然继承数据隔离不需要额外开发。实现要点有两个一是ThreadLocal必须在一次请求结束后清理否则线程池复用会串租户数据二是MyBatis插件里要判断当前Sql类型只有select/update/delete才追加租户条件避免把租户ID拼到insert里。MyBatis插件的核心代码大致是这样Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class TenantLineInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler (StatementHandler) invocation.getTarget(); String sql statementHandler.getBoundSql().getSql().toLowerCase(); Long tenantId TenantContext.get(); if (tenantId ! null !sql.contains(insert)) { // 通过反射修改BoundSql的sql字段拼接租户条件 // 这里省略具体改写SQL的工程实现 } return invocation.proceed(); } }具体SQL改写逻辑要考虑各种边界子查询、JOIN、已带租户条件等不建议直接字符串拼接最好用JSqlParser解析。但这个架构思想值得借鉴高层的拦截器只做上下文传递底层插件统一消费业务代码不需要感知多租户的存在思考起来非常舒服。注意在这个模式中拦截器和MyBatis插件的生命周期是不同的。ThreadLocal里的值在请求线程执行完请求后由afterCompletion清理而MyBatis插件是在同一线程的SQL执行期间读取只要清理时机不早于最后一个SQL执行就不会有问题。所以如果Service里开启了异步线程执行SQL异步线程里是拿不到ThreadLocal的这又是一个需要额外处理的场景。9. 这些经验都是实测过的实在话SpringMVC拦截器这块内容你网上随便一搜能找到一堆复制粘贴的入门教程但真正到了项目里能把拦截器、Filter、MyBatis插件三者边界理清的人并不多。这篇博文里提到的每一个坑比如请求体被提前读取、OPTIONS请求被误拦、拦截器不生效却找不到原因、ThreadLocal串数据我都是一行行日志排查出来的。每个方案都经历了线上环境的验证。如果你是刚接触SSM的新手我建议你别急着直接复制最后的XML先打开源码找到HandlerExecutionChain这个类看看applyPreHandle和applyPostHandle到底做了什么。把这个类看懂了拦截器的执行顺序、异常处理、资源释放这些概念就通了。源码并不可怕它只是把文档里说不清的逻辑写得比文档更精确。如果你是老手希望你至少记住一点拦截器里尽量不要直接修改response的输出流这会和后期的HandlerMethod返回值处理产生奇怪的相互影响。宁可自己再包一层返回结构也不要把跨属性逻辑写在拦截器层。最后补充一个实际的排障小技巧。如果你实在分不清一个请求是Filter拦截了、拦截器拦截了还是MyBatis插件拦截了最简单的办法是在三层里各打一行日志并加上请求唯一ID。比如UUID存在request里Filter打印一次preHandle打印一次插件打印一次。观察日志顺序三个组件的先后快慢一目了然。这比看任何源码注释都直观。我用这招排查过好几次跨层Bug屡试不爽。
返回列表