SpringMVC拦截器一文讲透:原理、执行顺序与实战坑位
2026/9/8 3:50:01 网站建设 项目流程

你的项目里的所有Controller开头,是不是都在重复做同一件小事:判断用户有没有登录、有没有权限、把请求参数打个日志、算一下这个接口到底耗时多少毫秒。如果答案是肯定的,说明你的代码已经在你耳边喊——“给我上一个SpringMVC拦截器”。SpringMVC框架学到这个阶段,其实是一个分水岭:前半段你在学怎么把接口“写出来”,后半段你在学怎么把接口“管起来”。这篇“SpringMVC的学习(二)”会完整拆解SpringMVC拦截器的原理、配置、执行顺序、实战案例和坑位,读完可以直接拿到项目里去用。

拦截器这个东西,不接触它之前,你觉得它挺神秘;搞懂了以后,你会发现它就是SpringMVC给开发者留的一个“钩子”,让你能在请求到达Controller前后插一段逻辑。它也不是什么高深莫测的黑魔法,它依赖的就是一套清晰到近乎透明的调用链。下面我从一次请求的完整生命周期讲起。

1. 从“每个接口都写一遍判断”说起:拦截器到底解决什么问题

1.1 没有拦截器时,你的代码长什么样

很多习惯了边写业务边“打补丁”的团队,登录校验的代码大概率是这样的:

@GetMapping("/user/info") public Result userInfo(HttpSession session) { Object user = session.getAttribute("loginUser"); if (user == null) { return Result.error(401, "未登录"); } // 真正的业务逻辑 return Result.success(...); } @GetMapping("/order/list") public Result orderList(HttpSession session) { Object user = session.getAttribute("loginUser"); if (user == null) { return Result.error(401, "未登录"); } // 真正的业务逻辑 return Result.success(...); }

当你有十个接口时,这还勉强能忍。当你有五十个接口时,这段if (user == null)就会像牛皮癣一样贴在每个方法里。更头疼的是,如果校验规则变了,比如不仅要判断登录,还要判断用户状态是否被冻结、访问IP是否在白名单里,你得把所有Controller翻一遍。

这种代码的内在问题,不叫“复用性差”,而叫“关注点混杂”。登录校验、日志记录、权限判断、性能统计这类逻辑,和“查用户信息”“查订单列表”这样的核心业务逻辑没有任何关系,却硬生生挤在同一个方法里。而软件开发领域对付这种问题,早就有了一套成熟的思路,就是“拦截器模式/过滤器模式”。

1.2 拦截器背后的设计思想:横切关注点

你可以把每个请求想象成一趟从浏览器出发的火车,目的地是Controller里的某个方法。如果一路上每过一个站都要人工检查车票,乘务员就会累死。最合理的做法是在中间设几个检查点,所有火车进站时自动检查,符合条件就放行,不符合就当场拦下。

这里的“自动检查点”,在SpringMVC里就是HandlerInterceptor。它能把“登录检查”“日志记录”“耗时统计”这些和业务无关的公共逻辑抽取出来,一次性注册到某些路径下,让框架自动帮你调用。这个思想,和后端领域常说的AOP(面向切面编程)是一脉相承的,区别在于拦截器是SpringMVC框架基于Servlet规范提供的一种更具体的实现,使用门槛更低,几乎不需要动态代理的知识。

拦截器的应用场景,主要集中在下面这几类:

  • 登录状态校验、会话有效性检查
  • 接口权限控制、角色判断
  • 访问日志记录、请求参数审计
  • 接口耗时统计、性能监控
  • 统一设置响应头、处理跨域
  • 黑名单/白名单过滤、限流控制

换句话说,拦截器是你在学习SpringMVC框架时,最先接触也最容易直接落地的一个“治理接口”的手段。它不像AOP那样需要理解切点表达式和通知类型,你只需要实现三个方法,就能控制请求的进和出。

2. 一次请求进入SpringMVC后,是谁调用了你的拦截器

这一节必须把DispatcherServlet和HandlerExecutionChain讲透,否则你会一直有一种“拦截器好像被神秘力量触发”的错觉。事实上,触发逻辑非常朴素。

2.1 DispatcherServlet是唯一的总调度入口

SpringMVC框架延续了前端控制器模式:所有请求先到DispatcherServlet,再由它分发到具体的Handler(也就是Controller方法)。DispatcherServlet内部会做下面这些事:

  1. 接收Request对象,并做multipart封装处理。
  2. 调用HandlerMapping,根据URL找到能处理这个请求的Handler。
  3. HandlerMapping不光返回Handler,还会返回一组“配套的拦截器”。
  4. 把Handler和拦截器一起组装成一个HandlerExecutionChain对象。
  5. 顺着这个执行链,依次触发拦截器和Controller。

这里第3步和第4步是理解拦截器的关键。Handler就好比你在演唱会门口拿到的门票,拦截器则是一路上的安检口。HandlerMapping在发门票的时候,就已经顺手在门票上盖了“前方需要过几道安检”的章。所以拦截器并不是在请求到达后临时去数据库里查出来的,而是处理器的映射阶段就已经静态匹配好的。

2.2 HandlerExecutionChain:Handler + 拦截器序列

HandlerExecutionChain从名字也能看出来,它把“执行处理器”和“一系列拦截器”包装成了链条结构。DispatcherServlet拿到这个链条后,执行顺序非常固定:

  1. 按顺序执行所有拦截器的preHandle方法,如果某个返回false,请求在这里立即终止。
  2. 调用Handler(Controller方法)本身。
  3. 如果Controller正常执行完,倒序执行所有拦截器的postHandle方法。
  4. 渲染视图(如果存在ModelAndView)。
  5. 不管成功还是抛异常,都会倒序执行所有拦截器的afterCompletion方法。

我把这个过程画成文字时序,你感受一下:

客户端请求 -> DispatcherServlet -> HandlerMapping 找到 Handler + Interceptors -> Interceptor1.preHandle -> Interceptor2.preHandle -> Controller 方法执行 -> Interceptor2.postHandle -> Interceptor1.postHandle -> 视图渲染(或者响应JSON) -> Interceptor2.afterCompletion -> Interceptor1.afterCompletion -> 响应回到客户端

有没有注意到postHandleafterCompletion的顺序是倒序的?这其实就是栈式调用:先进后出。第一个进入的拦截器,最后才退出。这样可以保证资源的释放顺序和资源的申请顺序对称,比如第一个拦截器打开了数据库连接,最后一个关闭它,整个链路才安全。

2.3 拦截器的三个方法凭什么让你控制整个链路

HandlerInterceptor接口定义了三个方法,我建议你不要只把它们当成“三个回调”,而是理解成三个时机:

  • preHandle:在Controller执行之前调用。返回值决定请求是否继续。
  • postHandle:在Controller执行完成后、视图渲染之前调用。此时你可以拿到ModelAndView,修改返回给前端的数据。
  • afterCompletion:在整个请求处理完成之后调用,哪怕是Controller抛了异常,这个方法也会执行,非常适合做资源清理和日志收尾。

很多初学者会把注意力全放在preHandle上,这是不够的。真正让拦截器强大的是它同时覆盖了业务执行前后两个阶段,并且异常发生时也能兜底。比如你想统计接口耗时,就必须在preHandle里记录开始时间,在afterCompletion里计算差值。前者拿不到耗时结果,后者拿不到开始时间,两者配合才是完整的性能监控。

为了让你对这三个时机有更强的体感,我用一个自定义的日志拦截器来演示:

public class AccessLogInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(AccessLogInterceptor.class); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { request.setAttribute("startTime", System.currentTimeMillis()); log.info("请求开始: {}, 来自IP: {}", request.getRequestURI(), request.getRemoteAddr()); return true; } @Override public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) { log.info("请求处理完成,准备渲染视图或返回响应"); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { long startTime = (long) request.getAttribute("startTime"); long cost = System.currentTimeMillis() - startTime; log.info("请求结束: {},耗时: {}ms,异常: {}", request.getRequestURI(), cost, ex == null ? "无" : ex.getMessage()); } }

这里用request.setAttribute("startTime", ...)把开始时间放在request域里,因为preHandleafterCompletion虽然方法不同,但拿到的是同一个HttpServletRequest对象,数据能天然传递。这个小trick在拦截器内部传递上下文时非常实用。

3. preHandle返回false之后的那些微妙行为

我刚学拦截器时,最大的误区就是以为preHandle返回false之后,整个请求就“什么都没有了”。实际上不是这样,返回false只是把Controller短路掉,但你仍然可以对Response对象做写入操作。DispatcherServlet看到preHandle返回false后,会直接跳过Controller、跳过所有postHandle,然后只对“已经执行过preHandle”的拦截器执行afterCompletion

这段话有点绕,我用一个简单模型解释:想象电梯上行,一层层停靠,每层都有人按下按钮。如果电梯在5楼发现异常停住了,那么不会继续上行到6楼,但在5楼已经亮过灯的楼层,系统不会再让它们亮一次。放到拦截器里就是:

  • 第2个拦截器preHandle返回false,则第3个及之后的拦截器完全不会执行;
  • 第2个拦截器的afterCompletion不会执行,因为它的preHandle没有成功结束;
  • 第1个拦截器的afterCompletion会执行,因为它的preHandle已经正常返回true。

也就是说,afterCompletion只保证对“已经成功通过了preHandle”的拦截器触发。这个规则的合理之处在于,你不能要求一个“没来得及进入”的拦截器做清理工作,否则它清理的可能是别人还没有创建的资源,反而出问题。

3.1 一个多拦截器顺序对比表

为了让你对执行顺序一目了然,我用一张表展示“两个拦截器A和B”在不同情况下的调用结果:

场景执行顺序
A和B都放行A.pre -> B.pre -> Controller -> B.post -> A.post -> B.after -> A.after
A放行,B的pre返回falseA.pre -> B.pre -> A.after
A的pre返回falseA.pre -> A.after
Controller异常A.pre -> B.pre -> Controller异常 -> B.after -> A.after(postHandle不执行)
视图渲染异常A.pre -> B.pre -> Controller -> B.post -> A.post -> 渲染异常 -> B.after -> A.after

注意我这里的“A.after在前还是B.after在前”很容易记错,你只要记住afterCompletion是倒序执行即可。上面表格里我默认A先注册,A是链路上的第一个拦截器。

3.2 返回false后,不要忘记自己输出响应

因为preHandle返回false后,整个调用链就断了,DispatcherServlet不会帮你做任何页面跳转,也不会渲染默认视图。此时你能不能给用户一个友好提示,完全取决于拦截器自己有没有往HttpServletResponse里写东西。

最常见的登录拦截器写法是这样的:

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { return true; } // 判断是不是ajax请求 if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或会话已过期\"}"); } else { response.sendRedirect("/login"); } return false; }

这种写法非常经典:页面访问直接重定向到登录页,接口访问返回JSON。它说明拦截器不只是“放行/不放行”,它和Controller一样掌握着完整的请求和响应对象,可以自主决定“被挡下来之后怎么反馈给客户端”。很多刚写权限拦截器的人容易犯的错就是只返回false,结果前端收到一片空白、一个302都没有。问题出在缺少对这个机制的完整理解。

4. 拦截器怎么注册:WebMvcConfigurer与路径表达式里的学问

光有拦截器类还不够,你还需要通过配置把它“挂到”DispatcherServlet的处理链上。Spring Boot集成SpringMVC后,最标准的做法是实现WebMvcConfigurer接口。

4.1 一段完整的注册代码

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AccessLogInterceptor()) .addPathPatterns("/**") .excludePathPatterns( "/login", "/register", "/css/**", "/js/**", "/images/**", "/favicon.ico", "/error" ); } }

这里有好几个细节需要展开说:

第一,/**/*的区别。/*只匹配当前路径一层的URL,比如/user能匹配,/user/info就匹配不到。/**是匹配所有层级路径。如果你想把所有业务接口都纳入拦截,必须用/**。很多初学者在这里写了个/*,结果/user/info没被拦截,排查半天找不到原因,其实是路径匹配的问题。

第二,excludePathPatterns的作用是“排除”。拦截器里的排除逻辑非常重要,因为登录接口本身就不需要“已登录”的校验,静态资源也不需要。最容易被遗漏的排除项是/error,如果你不排除Spring Boot的错误跳转路径,一旦业务接口出了异常,请求被转发到/error时再次经过拦截器,可能导致你看到大量“接口异常”日志,甚至干扰对真实异常的排查。

4.2 拦截器不生效的两大罪魁祸首

我帮人排查“为什么拦截器没生效”这种情况特别多,绝大多数是下面两个原因:

第一个原因,WebConfig类上的@Configuration注解丢了,或者这个配置类所在包不在Spring Boot的扫描路径下。Spring Boot默认只扫描启动类所在包及其子包。如果你的配置类放在了别的包,恭喜你,它根本没被加载,拦截器自然不会生效。

第二个原因,有人在拦截器里注入了Service或者Mapper,但创建拦截器对象时用了new而不是从Spring容器里获取:

@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; // 正确做法 @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) // 用容器里的bean .addPathPatterns("/**"); // 错误做法:registry.addInterceptor(new LoginInterceptor()) // 这样new出来的对象不在Spring容器里,任何依赖注入都拿不到 } }

如果拦截器里不需要任何Spring组件,new没问题。但它一旦需要查询数据库或调用Redis,你就要让它成为Spring bean并通过@Autowired注入到配置类中。这个坑的实际表现是:拦截器确实执行了,但一执行就报NullPointerException,因为loginService是空的。

4.3 一个完整的基于注解的权限拦截器设计

路径表达式只能做“粗粒度”的拦截,但真实业务里经常需要“这个接口登录了就能访问,那个接口必须管理员权限”。一个常见的设计思路是:自定义一个@RequirePermission注解,配合拦截器判断每个Handler上有没有这个注解,有就做权限校验,没有就放行。

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequirePermission { String value(); }

注册时仍然拦截所有接口,但在preHandle里通过方法检查真正的权限逻辑:

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; // 非Controller方法,比如静态资源处理器,放行 } HandlerMethod handlerMethod = (HandlerMethod) handler; RequirePermission permission = handlerMethod.getMethodAnnotation(RequirePermission.class); if (permission == null) { return true; // 没有权限注解,跳过校验 } // 从request里解析当前用户,校验是否拥有 permission.value() 这个权限 // 没有则写403响应,返回false }

这里有个隐藏知识点:handler参数到底是个什么对象。当请求映射到Controller方法时,handlerHandlerMethod类型,你可以通过它拿到方法上的注解、类上的注解、方法参数等丰富信息。但当请求是访问静态资源时,handlerResourceHttpRequestHandler,不是HandlerMethod。所以判断类型之后再强转,是为了避免类型转换异常。

4.4 多个拦截器的顺序怎么控制

项目里很少只用一个拦截器,比如“日志拦截器”和“权限拦截器”往往同时存在。多个拦截器的执行顺序,默认取决于addInterceptor的调用顺序。如果你需要精确控制,可以实现Ordered接口,或者注册时用带有order的重载方法。

registry.addInterceptor(authInterceptor).order(1); registry.addInterceptor(logInterceptor).order(2);

order数值越小,优先级越高,越先执行preHandle,也就越靠在外层。一般建议把日志、耗时统计这类“什么请求都要记录”的拦截器放在最外层,把权限校验放在内层。这样即使权限校验失败,日志也能先记录下来。

5. 过滤器(Filter)与拦截器的边界:看到这个问题就该知道两种技术选型

几乎每个面试官在问SpringMVC拦截器时,都会追加一问:拦截器和过滤器有什么区别?这不只是面试题,在实际架构设计里,选错工具会很别扭。

5.1 四张图级别的区别对比

我把核心差异整理成一个表格:

对比维度Filter(过滤器)HandlerInterceptor(拦截器)
所属规范Servlet规范SpringMVC框架
生效范围所有经过Servlet容器的请求只对DispatcherServlet映射的请求生效
是否依赖Spring不依赖,原生Servlet接口依赖Spring容器,可以注入任意Bean
能否拿到Controller方法信息不能,只能拿到URL、Request等能,handlerHandlerMethod,可读方法注解
执行时机在DispatcherServlet之前在HandlerMapping定位之后
是否可以操作ModelAndView不能可以,在postHandle
是否有业务回退钩子类似chain.doFilter后的代码afterCompletion,异常时也触发

Filter的执行时机比拦截器更靠前,请求在Servlet容器里会先穿过过滤器链,才到达DispatcherServlet。DispatcherServlet再通过HandlerMapping匹配Controller和拦截器。用一句话概括:Filter管的是“请求进不进SpringMVC”,拦截器管的是“请求怎么进入Controller、出来之后怎么处理结果”。

5.2 实际项目中怎么分配各自的任务

基于这两种组件的特性,我的习惯是:

  • Filter负责和“传输层”强相关的事情:请求编码、响应压缩、CORS跨域、XSS过滤、读取并缓存请求体。
  • Interceptor负责和“业务上下文”强相关的事情:登录校验、权限判断、功能开关、审计日志、接口耗时统计。

举个具体例子:如果我要统计一次请求的入站开始时间,放在Filter里其实更准确,因为Filter是第一个拿到请求的组件;但如果我要统计的是Controller方法的执行时长,那就必须放在拦截器里,因为Filter覆盖的范围包括静态资源和其他Servlet,统计出来的就不纯了。

5.3 请求体被拦截器读空的问题必须用Filter解决

有一个非常经典的问题:POST请求的Body是流式的,只能读一次。如果你在拦截器里执行了request.getInputStream()request.getReader(),Controller里的@RequestBody就会拿到空值。这是SpringMVC初学者最容易踩到的隐形大坑。

要想既读请求体做日志,又让Controller正常拿到参数,正确的思路是使用Servlet规范里的ContentCachingRequestWrapper。这个包装类会把读过的内容缓存下来,后续再次读取时从缓存返回。但因为这个包装需要在请求进入DispatcherServlet之前就生效,所以它应该发生在Filter层:

public class RequestCachingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (request instanceof HttpServletRequest) { HttpServletRequest httpReq = (HttpServletRequest) request; ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(httpReq); chain.doFilter(wrappedRequest, response); } else { chain.doFilter(request, response); } } }

这样Filter把包装好的request传下去,后面的拦截器和Controller每次读取的都是缓存版本,互不干扰。如果你需要在拦截器里打印请求体,可以调用wrappedRequest.getContentAsByteArray(),但注意要在afterCompletion里读,因为preHandle阶段body可能还没被Controller消费完,缓存是空的。这种细节只有真正踩过坑才会注意。

6. 实战拆解:一个完整的登录鉴权拦截器是这样迭代出来的

光讲概念和API还不够,我带你从头到尾把登录鉴权拦截器的代码演进过程走一遍,你就能明白这玩意在实际项目中怎么落地。

6.1 第一版:没有任何拦截器,Controller里全是重复校验

这是最原始的状态,前面的例子已经展示过了:每个接口开头都要从session里拿用户,判断是否为空。这个版本的缺点是改规则困难,代码冗余严重,而且容易漏写——只要有一个接口忘了加判断,就出现安全漏洞。真实的线上事故往往就是这么来的:新同学写了一个新接口,抄了代码模板但忘了复制那段login判断。

6.2 第二版:正规的HandlerInterceptor + 排除路径

我定义一个LoginInterceptor,所有需要用户登录的接口都走它,而登录、注册、验证码这些接口通过排除路径放行。上面已经有代码了,这里不重复。这个版本的优点是校验逻辑集中统一,缺点是想对某个接口做更细粒度控制时,只能靠路径区分,比如“管理员接口”和“普通用户接口”必须放在不同的路径前缀下才能分别控制。

6.3 第三版:注解驱动,想校验哪个方法就标注哪个方法

我在实际项目里最喜欢的就是注解驱动。还是用前面那个@RequirePermission思路,登录后还必须校验角色权限。在这版设计里,拦截器不再直接判死“所有接口都要登录”,而是先看目标方法有没有权限注解:

  • 方法上有@RequirePermission("admin"):必须要求当前用户是管理员。
  • 方法上有@RequirePermission("user:query"):要求当前用户拥有对应权限点。
  • 方法上没有任何权限注解:放行。

这样权限配置就完全跟着方法走,迁移性非常强,新接口要不要权限控制,只需要看方法上面有没有注解,一眼就能扫出来。而且权限校验的逻辑也沉淀在了一个拦截器里,后续拓展特别方便。

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; RequiresLogin login = handlerMethod.getMethodAnnotation(RequiresLogin.class); if (login == null) { login = handlerMethod.getBeanType().getAnnotation(RequiresLogin.class); } if (login == null || !login.required()) { return true; } // 校验session/login token LoginUser user = (LoginUser) request.getAttribute("loginUser"); if (user == null) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"请先登录\"}"); return false; } return true; }

这里多了一个小技巧:先看方法注解,再看类注解。如果控制器类级别标注了@RequiresLogin,就意味着类下所有接口默认都要登录,个别方法如果不需要,可以在方法上强制关闭。这就是“类默认开启 + 方法白名单”的组合设计,比纯路径配置灵活得多。

6.4 关于token登录态的一点提示

现在的项目大多是前后端分离架构,不再依赖session,而是用JWT或自定义token。这种情况下,拦截器的写法也相应变化:从Header里取token,解析出用户信息,放入request的Attribute里,方便后续业务代码直接调用。

String token = request.getHeader("Authorization"); if (!StringUtils.hasText(token)) { response.setStatus(401); return false; } LoginUser user = userService.parseToken(token); if (user == null) { response.setStatus(401); return false; } request.setAttribute("loginUser", user); return true;

有一个细节值得说:拦截器解析完用户后,不要用ThreadLocal直接存用户对象,强烈建议通过request.setAttribute往下传。原因很简单:SpringMVC在请求结束后并没有保证ThreadLocal被清理,如果线程被复用,上一个请求的用户数据可能泄露给下一个请求,这是安全级别很高的问题。而且request.setAttribute是Servlet规范自带的上下文传递机制,天然跟随请求生命周期,不需要手动清理,更稳妥。

7. 我在实际项目里被拦截器绊过的5个跟头

最后一个环节,我想把这些年在线下项目和帮朋友排查问题中积累的“拦截器事故”分享出来。这些坑不是从文档里看来的,全是真实环境里用生产事故换来的经验。

7.1 坑一:OPTIONS请求被权限拦截器挡住,前端跨域直接失败

前后端分离项目里,如果前端和后端域名不同,浏览器在发送真正的POST请求前会先发一个OPTIONS预检请求。如果你在拦截器里对所有/**都做登录验证,这个OPTIONS请求会因为没有携带token而被401拦截。前端看到的结果是“跨域失败”,但真正的问题在后端拦截器。

解决办法有两个:要么在排除路径里加上OPTIONS方法对应的判断,要么干脆在拦截器里针对OPTIONS直接放行:

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

这样预检请求不会触发真实的业务处理,放行是安全的。

7.2 坑二:静态资源也被拦截,页面样式全部丢失

Spring Boot默认把/static/public/resources下的文件映射到静态资源处理器。如果你在路径配置里只写了addPathPatterns("/**"),没有排除静态目录,拦截器也会作用到CSS、JS、图片上。这些资源根本不需要登录,一旦被拦截器挡下来,你的页面就会变成一个“没有灵魂的HTML骨架”。

每次写拦截器配置前,我都会把所有静态资源路径和健康检查路径全部列成清单,放进excludePathPatterns。不要嫌麻烦,这个清单能省掉大量“样式怎么丢了”的无谓排查。

7.3 坑三:拦截器里拿到了用户,Controller里却拿不到

这个问题常见于新人对model和request的作用域理解不清。你如果在拦截器里往request里set了用户对象,想通过@SessionAttribute@ModelAttribute去Controller里拿,容易踩坑。安全可靠的做法是用@RequestAttribute,或者直接在Controller方法参数里显式声明,再通过ServletRequestAttributes工具类获取。

@GetMapping("/user/info") public Result userInfo(@RequestAttribute("loginUser") LoginUser user) { return Result.success(user.getUsername()); }

@RequestAttribute是专门用来取request.setAttribute设置的数据的,语义清晰,不会和session、model混在一起。

7.4 坑四:日志里出现两次相同请求,以为是拦截器重复执行

有一次我在拦截器里加了访问日志,结果发现一个请求被打印了两遍。排查之后发现,PATCH、PUT请求在这套老项目里会先被转发到一个处理hidden method的Filter,这个Filter内部又做了一次forward。拦截器默认也会拦截forward请求,所以同一个请求在转发前后各进了一次拦截器。

解决办法是判断请求的DispatcherType,或者用request.getAttribute("javax.servlet.forward.request_uri")来判断是否为转发请求。在Spring Boot里可以利用registry.addInterceptor(...).addPathPatterns(...)配合excludePathPatterns,或者干脆把日志记录类的逻辑放到Filter层,因为Filter天然支持DispatcherType的精确控制。

7.5 坑五:拦截器里使用@Value读配置总是为null

这个坑和前面提到的“用new创建拦截器”类似。如果@Value@Autowired没有生效,大概率是拦截器对象不是由Spring容器管理的。还有一个容易忽略的情况:你在某个配置类里通过new创建了一个拦截器,然后又把这个拦截器声明为@Component,导致容器里有两个不同的实例,配置类接收的是new出来的那个,依赖全部为空。

我的建议是:拦截器类的生命周期管理,要么完全走Spring容器,要么完全通过new手动注入所需参数,不要混用。如果你用@Component容器管理,就在配置类里@Autowired注入;如果你喜欢纯Java写法,就在配置类里手动传入所需依赖,二选一,思路越简单越不容易出错。

8. 关于拦截器的几条个人经验

做了多年开发,我觉得SpringMVC拦截器是那种“一看就会,一写就错”的组件。很多东西原理很简单,但各种边界情况非常考验经验。如果要我给正在学这个框架的人提几条实在建议,我会挑这几点。

第一,拦截器里写代码要时刻记得它是“并发”的。同一个拦截器实例会被多个线程同时调用,所以千万不要在拦截器里用没有线程安全保护的成员变量存请求级数据。计时开始时间可以放进request attribute或ThreadLocal,但用ThreadLocal时一定要在afterCompletion里清掉,避免线程复用污染,这是血泪教训。

第二,拦截器不是万能的应急工具。有些业务逻辑适合放在Service层的AOP切面里,比如事务控制、更细粒度的权限切点;有些适合放在独立的过滤器里,比如请求体缓存、编码处理。拦截器最强的地方是它能结合URL和HandlerMethod做灵活的路径级、方法级控制,但它终究是“HTTP请求层面”的组件,不应承载过重的业务逻辑。

第三,写拦截器时永远要问自己三个问题:处理不了的请求能不能返回一个明确的响应?异常发生了afterCompletion会不会正常兜底?多个拦截器并存时顺序是否和业务预期吻合?如果你每次写拦截器都把这几个问题在脑子里过一遍,项目里因为拦截器引发的事故至少能减少八成。

这就是我在实践里对SpringMVC拦截器的全部沉淀。下一次学习SpringMVC时,我建议你把过滤器、拦截器、控制器增强(@ControllerAdvice)这三样东西放在一起对比学习,你会发现它们虽然都是“在请求处理过程中插入逻辑”,但各自服务的层次完全不同。理解了层次,你才算真正把这个框架用到位了。

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

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

立即咨询