
说实话Spring MVC里有两类东西最容易被初学者搞混一个是 Filter另一个就是 Interceptor。这两个都能在请求进入 Controller 之前拦截请求但拦截器的地位又比 Filter 更“深入腹地”它直接嵌在 Spring MVC 的 HandlerExecutionChain 里能在请求处理的不同阶段做不同的事。很多人学 Spring MVC 拦截器背了一堆概念真正写起来却不知道 preHandle、postHandle 这三个方法到底什么时候被调用也不知道为什么登录校验写在拦截器里有时会失效。这篇文章我就以实际项目开发者的角度把 Spring MVC 拦截器的原理、配置、常见坑一次性讲透适合刚做完 Spring 基础项目、正在上手 Web 项目权限和日志功能的开发者看。1. 先搞清楚拦截器是谁它和 Filter 有什么区别1.1 一个请求从入门到出站的完整路线要理解 Spring MVC 拦截器必须先建立整个请求链路的全局观。一个 HTTP 请求到达 Tomcat 之后会先被 Servlet 容器接管然后按照 web.xml 里配置的 Filter 顺序依次通过过滤器链接着才会进入 DispatcherServlet——这是整个 Spring MVC 的前端控制器。DispatcherServlet 拿到请求之后会通过 HandlerMapping 找到对应的 Handler 和 HandlerInterceptor 组合最终才真正调用我们写的 Controller 方法。很多时候网上讲拦截器都会画几条线代表执行顺序但我觉得最直观的理解是把请求链路想象成工厂里的流水线。Filter 相当于工厂门口的安全检查每一个进厂的人都要先过这道安检它工作在 Servlet 容器层面跟 Spring 容器根本不认识。而拦截器相当于车间内部各个工位之间的质检员它不是每个人都能见到的只有被 DispatcherServlet 分拣出来的请求才会经过它。这样理解你就会知道为什么 static 资源、jsp 文件这些不经过 DispatcherServlet 的东西拦截器通常管不着但 Filter 能管到。从代码层面讲HandlerExecutionChain是连接两者的关键类。它内部持有两个 List一个是被匹配上的 HandlerInterceptor 列表另一个才是真正的 Handler一般来说是 HandlerMethod也就是 Controller 里某个方法。Spring 在调用 Controller 方法之前会先按顺序执行所有 Interceptor 的 preHandle进入 Controller 方法之后执行流程再反过来走 postHandle 和 afterCompletion。1.2 为什么有了 Filter 还不够既然 Filter 能在请求进入 DispatcherServlet 之前统一处理编码、登录校验、防XSS这些东西那为什么还需要拦截器答案其实很简单Filter 拿不到 Spring MVC 的上下文也拿不到 HandlerMethod 的信息。在实际开发中我们经常要做“这个用户有没有权限访问某个具体接口”的校验单纯靠 URL 前缀匹配是远远不够的。不同的 Controller 方法可能有不同的角色注解比如 RequiresRole(admin)这种信息只存在于 HandlerMethod 上。Filter 看不到这个注解但拦截器可以。interceptor 的 preHandle 方法签名非常友好三个参数里有一个就是 Object handler通常可以强转成 HandlerMethod然后获取目标 Controller 类的 Class、方法上的注解、方法名甚至可以拿到方法参数。这意味着你可以在拦截器里实现非常精细的权限判断——不是按 URL 一刀切而是按方法级粒度精准控制。所以 Filter 和拦截器不是二选一的关系而是各司其职协同使用。2. 深入拆解 HandlerInterceptor 三个方法的执行时机2.1 preHandle、postHandle 与 afterCompletion 各自的使命HandlerInterceptor 接口定义了我们最熟悉的三个方法preHandle、postHandle、afterCompletion。这三个方法之所以重要是因为它们覆盖了请求处理的生命周期而且执行时机差异巨大。preHandle在 Controller 方法执行之前调用。返回 true 表示放行继续执行后续拦截器和处理器返回 false 表示直接终止请求后面所有的拦截器和 Controller 都不会执行。这是做登录校验、权限控制、接口幂等校验、请求参数预处理的绝佳位置。postHandle在 Controller 方法执行完之后、视图渲染之前调用。这里有个非常关键的细节它只能拿到 ModelAndView但拿不到异步处理的结果。在这个阶段可以修改 ModelAndView 的模型数据往里面填充公共的视图变量比如当前用户信息、站点配置也可以改变视图名称做跳转。afterCompletion在整个请求处理流程完成后调用也就是视图渲染结束之后。这里是释放资源、记录最终耗时、清理 ThreadLocal 的最佳位置。注意这个方法是无论如何都会执行的即使 Controller 抛了异常也会走这里只要 preHandle 返回了 true。用生活化的场景来类比preHandle 就像是餐厅门口的服务员先看看你有没有预约postHandle 像是厨房出菜之后帮你把菜摆盘的服务员afterCompletion 则是你吃完离场之后收拾桌子的保洁员。三个阶段各管一摊千万不要把日志记录放在 postHandle 里因为那会儿请求还没结束你拿不到最终的执行结果。2.2 执行顺序与第一个常见的坑倒序执行如果项目里注册了多个拦截器它们的执行顺序是有讲究的。假设我们注册了 A、B、C 三个拦截器那么请求到达时 preHandle 的执行顺序是 A - B - C但 postHandle 和 afterCompletion 的执行顺序却是反过来的C - B - A。这个规则和 Filter 链的执行顺序一样由栈式调用结构决定前一个拦截器的 preHandle 通过之后才会进入下一个拦截器的 preHandle等到内部处理完毕往外弹出时自然就是反着的顺序。这个“倒序”特性在实际开发中非常有用但同时也是一个坑点。比如你想在日志拦截器的 afterCompletion 里记录整个请求的总耗时而日志拦截器恰好注册在第一个位置那么它的 afterCompletion 会是最后一个执行的。如果你只想记录 Controller 执行阶段的耗时就要注意对应的方法位置。我在项目中就遇到过同事把耗时日志写在 postHandle 里结果发现 Controller 返回后视图还没渲染耗时数据总是偏小后来改成 afterCompletion 才拿到接近真实的整体耗时。还有一点需要特别强调如果某个拦截器的 preHandle 返回了 false请求会被直接终止这个拦截器之后的拦截器方法都不会执行但这个拦截器之前的拦截器的 afterCompletion 还是会执行。为什么因为前面的拦截器的 preHandle 已经返回 trueSpring 认为它已经“进入”了请求生命周期就必须确保它的资源能被及时释放。但注意返回 false 的那个拦截器自己的 afterCompletion 不会被调用这是一件容易被忽略的边界问题。3. 注册拦截器的两种姿势和路径匹配细节3.1 传统 XML 注册和现代 Java Config 写法拦截器不是写了类就能用的必须先注册到 Spring MVC 配置里。老项目里通常会看到 xml 方式大致长这样mvc:interceptors mvc:interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/css/**/ bean classcom.example.interceptor.AuthInterceptor/ /mvc:interceptor /mvc:interceptors但是现在的 Spring Boot 项目基本都用 Java Config 了。我推荐在实现 WebMvcConfigurer 的配置类里重写 addInterceptors 方法代码简洁还能和 Spring Boot 的自动配置完美兼容Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /register, /css/**, /js/**, /error) .order(1); registry.addInterceptor(new LogInterceptor()) .addPathPatterns(/api/**) .order(2); } }注意这里的 chain 是怎么形成的registry.addInterceptor() 每调用一次就注册一个拦截器。order() 方法可以显式指定顺序不指定的话按注册顺序执行。order 的值越小优先级越高这一点和 Spring 的 Order 注解语义一致。如果你不写 order默认按照注册顺序先 add 的先执行。3.2 路径匹配规则不是所有的 / 都代表一切Spring MVC 拦截器的路径匹配使用的是 Ant 风格路径模式最常见的几个符号是?匹配任意单个字符*匹配任意数量的字符但不包含路径分隔符 /**匹配任意数量的字符包括路径分隔符所以/user/*能匹配/user/list、/user/detail但匹配不了/user/profile/info而/user/**能匹配/user/list也能匹配/user/profile/info甚至能匹配/user这个路径。这里有一个很隐蔽的“坑”/user/**的匹配范围比大多数人想象的要大得多如果你在项目里写了addPathPatterns(/api/**)但没有把/api/public/**加进 excludePathPatterns那么你的公共接口一样会被拦截。我实际做项目时经常会写一个配置类专门维护所有公开接口的路径保证 excludePathPatterns 的列表永远和 Controller 里的公开接口同步更新。还有一个从 Spring 5.3 开始被新的 PathPattern 解析器替代的细节新版 Spring 里默认使用的是 PathPatternParser它的匹配规则和 AntPathMatcher 非常接近但解析性能更好。一般来说你不需要刻意关心底层实现只要注意 URL 里不要出现奇怪的占位符写法即可。4. 实战从登录校验到权限控制拦截器的标准写法4.1 写一个真正能用的 AuthInterceptor相信大部分读者接触拦截器都是从“登录校验”开始的。我见过太多同学直接在 Controller 里每个方法写一遍“判断 session 里有没有 user没有就 redirect”这在项目早期可能还能撑一撑一旦接口多起来就极其痛苦重复代码多、容易漏掉校验、后期很难统一加白名单。拦截器的意义就在于把这种“横切逻辑”抽出来。下面是一个我认为“够用且稳健”的登录校验拦截器写法代码里融合了我踩坑之后总结的几个关键细节public class AuthInterceptor implements HandlerInterceptor { private static final String LOGIN_URI /login; private static final String USER_KEY loginUser; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 只拦截需要处理的方法放行静态资源之外的预检请求 if (!(handler instanceof HandlerMethod)) { return true; } HttpSession session request.getSession(false); Object user session ! null ? session.getAttribute(USER_KEY) : null; if (user ! null) { // 可选把用户对象重新放进 request 域方便后续代码直接使用 request.setAttribute(currentUser, user); return true; } // 判断请求是否来自 AJAX String requestedWith request.getHeader(X-Requested-With); if (XMLHttpRequest.equals(requestedWith)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); } else { // 注意这个路径重定向时尽量带上下文路径否则部署在子路径下会出问题 String contextPath request.getContextPath(); response.sendRedirect(contextPath LOGIN_URI ?redirectUrl request.getRequestURI()); } return false; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 这里可以清理 ThreadLocal 或者记录访问日志但注意 ex 可能不为 null } }几个值得注意的点第一我先判断 handler 是不是 HandlerMethod不是就直接放行。这样是为了保证放行默认错误页和其他非 Controller 处理器的请求否则拦截器可能把某些静态资源或者预检请求误伤。第二AJAX 请求不能直接重定向否则前端拿到的不是 JSON 而是重定向后的 HTML 内容会导致前端框架报各种怪异的错误。我用 X-Requested-With 头来判断虽然有些跨域请求可能不带这个头但绝大多数基于 JQuery 和 Axios 的项目都默认带上它。第三sendRedirect 的地址要拼 contextPath在项目部署到 Tomcat 子路径比如 /myapp时如果不拼上重定向会跳到错误的路径。4.2 假设抛异常了怎么办让拦截器处理异常而不是撒手不管Controller 里抛出的异常默认情况下会一路冒泡到容器。如果你只在 Spring Boot 里配置了全局异常处理器 ControllerAdvice拦截器的 preHandle 里抛出的异常其实是能到全局异常处理器的这一点很多人都不知道。但 postHandle 里抛出的异常就不能被 ExceptionHandler 捕获了因为它已经处于视图渲染之前的阶段异常处理机制的处理顺序在它之前。所以我的建议是拦截器里尽量只做“判断”和“最简单的前置处理”复杂逻辑一律挪到真正的业务代码或全局异常处理器里去。比如你在 preHandle 里做参数签名校验如果校验失败直接抛出 IllegalArgumentException然后让全局异常处理器统一处理返回 JSON这样比直接在拦截器里写 response.getWriter() 更符合单一职责。坏消息是异常写的太散排查问题时你会发现同一个异常有好几个来源好消息是只要在拦截器里用 throw 抛出Spring Boot 的异常处理链路会帮你收拾残局。RequestMapping(/api/order) public String orderApi(RequestParam(timestamp) long timestamp, RequestParam(sign) String sign) { // 实际上真正的签名校验应该抽到拦截器里 return ok; }写到这里顺便提醒一句拦截器里的参数校验虽然省事但不要忘了校验耗时。我在最开始的版本里用 RSA 做签名验证把验签逻辑写进 preHandle上线后发现高并发下接口吞吐量掉了四分之一后来加了一层本地缓存才缓解。记住preHandle 是同步执行的它多花一毫秒用户就多等一毫秒。5. 拦截器干了什么活日志记录、性能监控和本地线程清理5.1 一个通用的日志拦截器是怎么设计的很多项目会在 Controller 里面打日志每个方法前一行 Logger.info方法后一行 Logger.info这样做不是不行但非常容易遗漏而且不同人的日志格式五花八门排查问题非常痛苦。一个合理的方案是写一个 LogInterceptor统一记录请求 URL、IP、parameter、处理耗时。这里有个不太容易被注意到的细节HttpServletRequest 的 inputStream 只能被读取一次如果你在拦截器里为了打日志去读请求体后面 Controller 用 RequestBody 接收就会拿不到内容报 “Required request body is missing” 错误。所以除非你用 Spring 的 ContentCachingRequestWrapper 包装了请求否则别在拦截器里读 body。我通常只在拦截器里记录 request.getParameterMap()如果必须记录 body我建议配合 AOP 在 Controller 层处理而不是拦截器。性能监控也是一样用 System.currentTimeMillis() 在 preHandle 里记录开始时间然后在 afterCompletion 里计算差值。要注意的是afterCompletion 执行的时机有一个很大的特点它发生在客户端响应已经提交之后。所以如果你想在响应里附加一个“服务端处理耗时”的自定义响应头必须在 postHandle 里做因为此时响应头还没提交等到 afterCompletion 你再 setHeader 是无效的IDE 不一定会报错但浏览器端看不到这个 header。这个坑我项目中真实踩到过排查了半天才发现是在错误的生命周期里设置了响应头。5.2 ThreadLocal 的正确清理姿势很多项目在拦截器里会用 ThreadLocal 存储当前请求的用户信息这样 Controller 和 Service 层随手就能拿到避免每个方法都传一遍用户参数。但 ThreadLocal 最大的风险是内存泄漏尤其是在线程池环境下线程被复用如果不清理上一个用户的数据可能被下一个请求读到这是非常严重的线上事故。标准的安全清理方式就是在 afterCompletion 里调用 ThreadLocal 的 remove 方法。注意不是 set(null) 就完事remove 才是真正把当前线程的变量清掉。我见到有些老代码只在 finally 里 set(null)虽然大多数情况也能用但最严谨的做法是Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); }高频并发下务必要记得这个清理动作。哪怕当前拦截器看起来没有使用 ThreadLocal如果项目里有任何 Filter 或拦截器往 ThreadLocal 里塞了东西都要统一在这个出口清理。安全永远优先于便利。6. 静态资源被拦截、拦截器不生效这些排查实录最值得收藏6.1 常见问题速查表我是一个喜欢把问题和解决方案做成表格的人因为排查问题时鼠标一翻就能找到答案。下面这份表是我在带项目时经常给新同学发的关于 Spring MVC 拦截器的常见问题排查基本覆盖了新手和老手都会遇到的典型情况。现象根本原因解决方案静态资源(css/js/图片)被拦截器拦住页面样式丢失拦截器路径配置了/**且没有排除静态资源目录在 excludePathPatterns 中排除/static/**、/public/**、/resources/**或实际静态资源前缀拦截器完全不生效项目里添加了 EnableWebMvc 注解导致 Spring Boot 自动配置失效去掉 EnableWebMvc或用 WebMvcConfigurer 明确配置避免覆盖默认配置preHandle 返回 false 之后afterCompletion 没执行这是 Spring 的设计返回 false 的拦截器自身的 afterCompletion 不会调用如果你有资源要释放请在 preHandle 返回 false 之前手动处理拦截器里拿不到 RequestBody 的 JSON 内容request 流只能读一次拦截器读取后 Controller 拿不到不要直接在拦截器里读取 inputStream用 ContentCachingRequestWrapper 或 Spring 的 RequestBodyAdvice 处理多个拦截器执行顺序不符合预期没有显式指定 order或使用了过时的 HandlerInterceptorAdapter用 registry.addInterceptor().order(n) 显式排序实现 HandlerInterceptor 接口而不是继承旧适配类Spring Boot 高版本下拦截器注册了但请求没进来容器路径与 PathPattern 匹配歧义检查请求路径是否使用了上下文路径更新拦截器的 addPathPatterns 为更精确的匹配异步请求时拦截器只调用了 preHandleSpring MVC 对异步请求默认只执行 preHandle若需支持异步实现 AsyncHandlerInterceptor在 afterConcurrentHandlingStarted 中处理开始逻辑请求抛异常后 postHandle 不执行Controller 抛异常时不会进入 postHandle把异常处理的补录逻辑放到 afterCompletion 中或在 ControllerAdvice 里处理6.2 两个独家排查案例说一个我印象很深的案例。之前有个服务升级 Spring Boot 版本后发现某个接口的拦截器不生效了。一样的代码一样的配置本地正常测试环境却失效。排查了好久最后发现是因为这个接口所在的模块被放入了一个 context-path 前缀的应用里导致注册的 path 和实际访问路径不一致。之后我养成了一个习惯排查拦截器问题第一件事看 path 到底匹配不匹配直接用 postman 打日志验证而不是盯着配置发呆不是配置错了就是路径没对上。另一个案例更有意思。同事写了一个 MonitoringInterceptor想统计所有 Controller 方法的执行时间。但奇怪的是测试数据显示大部分接口耗时都在 1ms 以下这显然不对。后来发现他把 Controller 方法里的 RequestBody 和参数校验逻辑都写到了 preHandle 之前导致统计出来的仅仅是从进入 Controller 到方法返回的时间而忽略了整个请求链路。我当时给出的建议是如果你真的想统计“用户感觉上的耗时”应该在 Filter 那层做因为那里才是请求进入应用的最早入口如果你只关心 Controller 的业务耗时可以在拦截器里做但要把结果除以几次取平均值同时注意只看相对趋势不要过分纠结绝对值。7. 从拦截器到 AOP什么时候用拦截器什么时候用切面说到 Spring MVC 拦截器就绕不开 AOP。很多功能拦截器能做AOP 也能做比如日志记录、权限校验、性能监控那该怎么选我的经验很简单拦截器更关注“请求进入了哪个 Controller 方法”AOP 更关注“方法被调用时的参数和返回值”。如果你的需求和 HTTP 层强相关——比如要拿到请求 URL、要重定向、要设置响应状态码、要判断是否是 AJAX——那就用拦截器因为它的方法签名直接给了 HttpServletRequest 和 HttpServletResponse想干什么都很顺手。如果你的需求更偏向方法内部逻辑——比如要拦截 Service 层的方法、要读取方法返回值做缓存、要做方法的幂等控制——那就用 AOP因为拦截器根本拦不到 Service 层它只作用于 Controller 层。有一个最常见的误区是拦截器只能拦 Controller 层的请求它拦不了 Service 的方法调用。所以我在项目里经常是“双管齐下”拦截器负责登录态和权限粗筛AOP 负责具体的业务切面比如打点、字段加密、分布式锁。两者各有各的舞台不要试图用一个替代另一个。还有一个容易被忽略的场景在微服务架构里Spring Cloud Gateway 这类网关层往往也提供了 Filter 机制它能做流量控制、路由转发、白名单校验。但网关层面拿不到业务的数据因为它离业务代码更远。所以说最终你的权限拦截点一定不能只放在网关还得下沉到每个服务里用拦截器或 AOP 做一次二次校验。这也是我最近在项目中反复跟团队强调的一点服务之间的互相调用绝对不能信任内网必须在服务端自己再做一次校验而拦截器正是实现这个能力的高性价比选择。8. 几个独家建议关于拦截器设计的最后补充专栏写到这里核心的东西都讲得差不多了。最后分享几个平时文档里很难看到的“过来人”建议都是我在实际项目里一点点总结出来的。第一拦截器的注册顺序和拦截器的执行顺序要画图挂在团队 Wiki 上。我遇到过很多次线上事故不是因为拦截器写错而是因为新增了一个拦截器后执行顺序被打乱导致某些原本应该执行的校验被提前拦截了。多拦截器项目里一定要明确“哪个在最外层、哪个在最内层”并且写好注释。你要是问我推荐最简单可靠的排列方式我会说登录校验 - 签名校验 - 权限校验 - 日志记录顺序从上到下逻辑上越基础越前置。第二拦截器里别做重活尤其不要发起远程调用。假设登录校验拦截器里要调用户中心服务验证 token这个调用如果超时整个接口都会被拖垮。哪怕只超时 200ms在高并发下也会攒出一大批线程阻塞最后导致线程池满。如果你必须调远程建议做一层本地缓存且在调用时设置足够短的超时时间。我见过最夸张的线上事故就是拦截器里调第三方接口对方慢了 3 秒整个站点全挂。拦截器不是不能做这些事而是要做就得想清楚降级方案。第三在 Spring Boot 中一定要避免使用过时的 HandlerInterceptorAdapter。Java 8 之前很多项目继承这个适配器类现在直接实现 HandlerInterceptor 接口就够了因为接口里的方法都有 default 实现你不需要每个方法都重写。我每次做 code review 看到有人还在用 HandlerInterceptorAdapter都会建议改掉不是为了装新而是旧类在未来的 Spring 版本里随时可能被删除没必要留这个隐患。第四如果你的项目用了 Spring Security注意拦截器和 Security 的过滤器链不要重复配置登录校验。Spring Security 有自己的一套授权机制如果你既在 Security 里配置了权限又在业务拦截器里写了一遍容易造成权限逻辑双份管理、规则不一致。我推荐的做法是Spring Security 管理框架级的认证与授权业务拦截器专注业务侧的非安全需求比如参数预处理、日志、埋点、用户上下文注入。别人问起来为什么要拦你就说是“为了配合业务”不要试图用拦截器去实现 Spring Security 已经做得很好的事情。其实我把 Spring MVC 拦截器聊到现在算下来更像是在分享一套“如何组织请求处理流程”的思维方式。拦截器本身并不复杂复杂的是你如何利用好那个横切点在合适的地方做合适的事。希望这篇文章能让你对 Spring MVC 拦截器有更清晰的把握——既知道怎么配、怎么写也知道它和 Filter、AOP 的边界在哪里。我在最初学拦截器的时候也踩过不少坑所以特别理解那种配置了半天就是不生效的绝望感。如果这篇文章能帮你少走一段弯路那就最有价值了。