如果你写接口时发现每个 Controller 方法的第一行几乎都是同一件事——从请求头里取 token、校验登录态、不通过就返回 401——那你需要的很可能就是 Spring MVC 的拦截器。我接手过一个老项目,五十多个接口,登录校验的代码几乎原样贴了五十多份,后来换鉴权方案,全组人改了整整两天,还漏了两处。从那次之后,我再写接口鉴权,第一反应永远是拦截器:把横切逻辑集中到一个类里,Controller 干净得像刚重构过。这篇文章围绕 Spring MVC / Spring Boot 的拦截器展开,从 HandlerInterceptor 三个核心方法的执行时机讲到它和 Filter 的边界,再用一个 Token 统一校验的实战把代码落地,最后整理了六个我真正踩过的坑。适合正在做接口鉴权、想清理 Controller 重复代码的 Java 后端开发者。
1. 拦截器到底帮我们做了什么:从一段真实接口代码说起
1.1 没有拦截器时,接口层被重复代码淹没
很多项目不是一上来就想到用拦截器的,而是被重复代码逼出来的。早期的 Controller 里经常看到这种写法:
@RestController @RequestMapping("/api/order") public class OrderController { @GetMapping("/list") public Result list(HttpServletRequest request) { String userId = checkToken(request); if (userId == null) { return Result.fail(401, "未登录"); } return orderService.list(userId); } @GetMapping("/detail") public Result detail(HttpServletRequest request, @RequestParam Long id) { String userId = checkToken(request); if (userId == null) { return Result.fail(401, "未登录"); } return orderService.detail(userId, id); } }这里的checkToken方法往往还散落在各个 Controller 的私有方法里,或者放在一个"工具类"里被到处 import。接口少的时候没什么感觉,接口一旦上了量,问题就来了:
- 每个新接口都要记得写这段校验,新人一忘,接口就裸奔了。
- 校验逻辑要改的时候,全项目搜
checkToken,五十个调用点就得改五十处。 - 更麻烦的是,如果校验失败时需要额外记录日志、统计登录失败次数、写审计信息,你需要在每一个接口里都塞一份,代码膨胀得飞快。
我见过一个极端例子:项目里有的接口校验了 token,有的没校验;校验的接口里返回错误码还不一样,前端对接时被坑得骂人。这就是"每个方法自己管横切逻辑"的必然结局——只要有一次遗漏,行为就不一致了。
1.2 拦截器把横切逻辑请出了 Controller
拦截器做的事情其实很简单:在请求进入 Controller 之前,把"这个请求有没有资格进来"这件事统一判断掉;在请求出去之后,把"这次调用留下了什么痕迹"统一处理掉。Controller 只需要关心业务参数和返回值,不再需要关心请求头、token 或者用户身份。
上面那段代码用拦截器重构之后,Controller 会变成这样:
@GetMapping("/list") public Result list() { String userId = UserContext.getUserId(); return orderService.list(userId); } @GetMapping("/detail") public Result detail(@RequestParam Long id) { String userId = UserContext.getUserId(); return orderService.detail(userId, id); }登录校验、token 解析、用户身份注入,全部从每个方法里抽离出去了。后期就算把 token 从 Redis 换成 JWT,或者从 Header 里挪个位置,改动都只落在拦截器这一个类里,而不是散落在几十个 Controller 中。
这也是为什么我一直把拦截器理解为"Spring MVC 给 Web 层提供的 AOP 抓手"。它解决的核心问题不是某个具体功能,而是把"横切关注点"从业务代码中剥离出来,让接口层只做接口层该做的事。
2. HandlerInterceptor三个核心方法的执行时机与底层逻辑
2.1 preHandle、postHandle、afterCompletion各司其职
Spring MVC 的拦截器接口是HandlerInterceptor,Spring 5.2 之后三个方法都有 default 实现,已经不需要强制实现全部方法了:
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是进入 Controller 前的最后一个关卡。返回值是boolean:返回true表示放行;返回false表示请求到此为止,Spring MVC 不会再调用后续的拦截器,也不会再调用 Controller。绝大多数鉴权逻辑都写在这里,因为在这里返回 false 是最干净、成本最低的中断方式。postHandle在 Controller 方法执行完、视图渲染之前执行,理论上适合往 ModelAndView 里补公共数据,但在前后端分离的项目里,@ResponseBody接口走到这里时响应体其实已经写完了,这个方法的用武之地少了很多,具体原因我后面有一节专门讲。afterCompletion则是在整个请求处理结束之后执行,不管请求是正常返回还是抛了异常,只要拦截器链已经走到过 preHandle 就会走到这里,所以非常适合做资源清理、耗时统计和最终日志采样。
2.2 多拦截器场景:顺序、中断与回调的真实行为
一个项目里通常不止一个拦截器,比如登录拦截器和权限拦截器。多个拦截器会组成一条链,执行顺序并不像大多数人想的那样"从头到尾线性执行":
preHandle按照注册顺序执行,A 先执行,B 后执行。postHandle按照注册顺序的逆序执行,B 先执行,A 后执行。afterCompletion同样按逆序执行。
如果中间的某个preHandle返回false,情况的细节很多人说不清楚。实际行为是:返回false的那个拦截器自身的afterCompletion不会执行,但它前面所有已经返回true的拦截器的afterCompletion会逆序执行一遍。
这个点值得展开说一下。Spring 内部的HandlerExecutionChain维护着一个interceptorIndex,只有某个拦截器的preHandle返回true之后,索引才会更新到它。一旦某个拦截器返回false,它会立刻调用triggerAfterCompletion,而triggerAfterCompletion是从interceptorIndex(也就是上一个成功放行的拦截器)开始逆序回调的。所以如果你在返回false之前已经创建了临时文件、开了数据库连接,指望afterCompletion帮你兜底是不现实的,必须在return false之前自己把资源清掉。
3. 拦截器和Filter到底差在哪:请求链路里的位置决定一切
3.1 一条请求从进入到Controller要经过几道门
很多初学者会把 Spring MVC 拦截器和 Servlet 的 Filter 混为一谈,但两者根本不在同一个层面。一个请求从客户端到 Controller,要经过两层关卡:
第一层是 Servlet Filter。它由 Servlet 容器管理,在请求到达 DispatcherServlet 之前就执行,是一串独立的过滤器链,可以对ServletRequest和ServletResponse做最底层的处理。第二层才是拦截器。它属于 Spring MVC 框架内部的组件,在请求已经进入 DispatcherServlet、并且已经匹配到具体 Handler 之后才执行。
用个生活化的比喻:Filter 是小区大门口的保安,对所有进出的人和车做通行检查;Interceptor 是写字楼里的前台,还要确认你具体去哪个公司、找哪位。保安不认识楼里的业务,前台却知道哪层是哪个部门。
3.2 按场景选型:谁更适合接口鉴权、日志和性能监控
Filter 和 Interceptor 最核心的差异,可以整理成一张表:
| 维度 | Filter | Interceptor |
|---|---|---|
| 技术归属 | Servlet 规范 | Spring MVC 框架 |
| 执行位置 | DispatcherServlet 之前 | DispatcherServlet 之后、Handler 之前 |
| 获取的对象 | ServletRequest / ServletResponse | HttpServletRequest / HttpServletResponse + Handler |
| 访问 Spring Bean | 需要手动获取 WebApplicationContext,比较绕 | 直接注入 |
| 对静态资源的影响 | 全部请求都会经过 | 按路径匹配,可能拦不到静态资源 |
| 中断粒度 | 对整个请求 | 可以精确到某个 HandlerMethod |
选型建议其实很直接:需要在请求到达 Spring 模型之前做字符编码、跨域处理、请求体加解密、IP 黑名单的,用 Filter;需要访问 Spring 容器里的业务 Bean、需要判断具体是哪个 Controller 方法、需要把用户信息放入上下文供业务使用的,用 Interceptor。
我自己在项目里的切分标准是:Filter 处理"物理层面"的事,Interceptor 处理"业务层面"的事。token 校验属于业务语义,当然用拦截器;而 CORS 这种不管你是 Spring MVC 还是普通 Servlet 都要处理的,用 Filter。
4. Spring Boot注册拦截器:配置细节与路径匹配规则
4.1 WebMvcConfigurer的正确打开方式
在 Spring Boot 里注册拦截器,最标准的方式是实现WebMvcConfigurer,重写addInterceptors方法:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private TokenInterceptor tokenInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tokenInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/**", "/api/common/**"); } }这里有一个特别值得注意的坑:类上只需要@Configuration就够了,不要顺手加@EnableWebMvc。一旦加了@EnableWebMvc,Spring Boot 的WebMvcAutoConfiguration自动配置会被关掉,静态资源映射和默认的消息转换器可能全部失效,你会收获一堆莫名其妙的 404 和乱码问题。我见过不止一个同事在这个地方翻车,排查了半天最后发现是自己多写了一个注解。
4.2 通配符与排除路径:addPathPatterns和excludePathPatterns的坑
路径匹配是注册拦截器最容易出错的部分。Spring MVC 支持三个通配符:
*:匹配一个路径段,比如/api/*只能匹配/api/order,匹配不了/api/order/detail。**:匹配多级路径,/api/**可以匹配/api/order和/api/order/detail/list。?:匹配单个字符,用得很少。
推荐的写法是拦截范围放宽一点,排除范围写细一点:
registry.addInterceptor(tokenInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/api/auth/captcha", "/api/common/health", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**", "/error" );这段配置的背景是拦截器默认会拦截掉匹配路径下的所有请求,包括 Swagger 文档、Knife4j 页面和 WebJars 静态资源。如果这些不排除,联调时你会发现 API 文档页面全部报 401,排查半天最后发现是拦截器把文档接口也当成了业务接口。排除路径还有一个容易踩的细节:excludePathPatterns是精确匹配加通配符匹配,/api/auth/login不会自动放行/api/auth/login/这样的带斜杠变体,如果前端请求路径偶尔带尾部斜杠,也会被拦截住。
4.3 多个拦截器的注册顺序会对执行顺序产生什么影响
当项目里同时存在登录拦截器和权限拦截器时,先后顺序决定了功能是否正确。比如登录拦截器负责把用户身份写入 ThreadLocal,权限拦截器需要读取这个身份判断是否有操作权限,那登录拦截器必须排在权限拦截器之前。
registry.addInterceptor(loginInterceptor).addPathPatterns("/api/**").order(1); registry.addInterceptor(authInterceptor).addPathPatterns("/api/**").order(2);order()的数字越小优先级越高,preHandle按这个顺序执行。实测下来,我建议在把多拦截器接入系统的时候就把 order 显式写清楚,不要依赖默认的注册顺序。默认情况下 Spring 会按注册顺序排序,但一旦后续有人在中间插入了一个新拦截器,顺序就可能乱掉,而类名和注册顺序通常看不出来问题,排查时非常痛苦。
5. 实战:用拦截器统一校验Token,把Controller的重复代码删干净
5.1 方案选型:Redis缓存Token还是JWT
在做 token 校验之前,先想清楚用哪种 token 方案。这两种方案各有鲜明的优缺点:
| 维度 | Redis 缓存 Token | JWT |
|---|---|---|
| 服务端状态 | 有状态,需要存 Redis | 无状态,签名自校验 |
| 主动失效 | 很容易,删掉 Redis key 即可 | 很难,需要维护黑名单 |
| 携带业务信息 | 按 token 查 Redis 才能拿到 | 负载中可以携带 userId 等 |
| 性能 | 多一次 Redis IO | 本地解析,快 |
| 复杂度 | 依赖 Redis,逻辑简单 | 依赖密钥管理和过期策略 |
我个人的建议是:如果团队已经有 Redis,且业务需要支持"踢人下线""改密码后所有 token 失效"这类操作,优先用 Redis。如果系统是纯无状态分布式的,多服务之间不想共享 Redis 或者想降低每一次校验的 IO 开销,再用 JWT。下面这段代码按 Redis 方案展开,因为它的实现路径对初学者最友好,也最容易照葫芦画瓢改成自己的场景。
5.2 完整代码:拦截器、用户上下文和注册配置
项目里需要引入spring-boot-starter-data-redis。然后是拦截器本体:
@Component public class TokenInterceptor implements HandlerInterceptor { private static final String TOKEN_PREFIX = "login:token:"; private static final String AUTH_HEADER = "Authorization"; @Autowired private StringRedisTemplate stringRedisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 先放行预检请求,前端跨域时每次都会发 OPTIONS if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 从 Header 里取 token,兼容 "Bearer xxx" 写法 String token = request.getHeader(AUTH_HEADER); if (StringUtils.hasText(token) && token.startsWith("Bearer ")) { token = token.substring(7); } if (!StringUtils.hasText(token)) { writeUnauthorized(response, "未携带登录凭证"); return false; } // 校验 token,拿到 userId String userId = stringRedisTemplate.opsForValue().get(TOKEN_PREFIX + token); if (!StringUtils.hasText(userId)) { writeUnauthorized(response, "登录态已失效,请重新登录"); return false; } // 校验通过,把用户信息放入上下文 UserContext.setUserId(userId); UserContext.setToken(token); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求收尾,清掉 ThreadLocal UserContext.clear(); } private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"" + message + "\"}"); } }用户上下文用 ThreadLocal 实现,避免在 Controller 里到处传HttpServletRequest:
public class UserContext { private static final ThreadLocal<String> USER_ID = new ThreadLocal<>(); private static final ThreadLocal<String> TOKEN = new ThreadLocal<>(); public static void setUserId(String userId) { USER_ID.set(userId); } public static String getUserId() { return USER_ID.get(); } public static void setToken(String token) { TOKEN.set(token); } public static String getToken() { return TOKEN.get(); } public static void clear() { USER_ID.remove(); TOKEN.remove(); } }注册配置和前面章节一致,把它接进 Spring Boot 即可:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private TokenInterceptor tokenInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(tokenInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/api/auth/captcha", "/api/common/health", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**" ); } }业务侧彻底删掉checkToken(request)那一大段,直接读上下文:
@GetMapping("/list") public Result list() { String userId = UserContext.getUserId(); return orderService.list(userId); }5.3 放行清单:哪些请求必须绕开拦截器
从上面的配置能看出,放行清单是个需要花心思维护的东西。我的经验是,放行请求不外乎这几类:
- 登录、注册、验证码、找回密码这类"本来就是给未登录用户用"的接口。
- 健康检查、监控探针,比如
health、metrics,这类接口往往被运维探活频繁调用,拦了会影响部署判断。 - 接口文档资源,Swagger UI、Knife4j、
api-docs、webjars都属于这一类。 - 如果前端使用了跨域预检,
OPTIONS请求要么放行,要么在拦截器里统一返回 200 空响应。
放行清单有一个隐性成本:它很容易"年久失修"。项目早期放行了/api/auth/**,后来在这个路径下新增了一个需要登录态的接口但忘记收拢规则,这个接口就会裸奔。所以我有两个习惯:一是在excludePathPatterns里列具体路径而不是放一个大前缀;二是定期检查放行清单,逐个确认"这个接口确实不需要登录吗"。
5.4 校验流程完整复盘
现在把整条链路串起来看一下。前端发起带Authorization头的请求,DispatcherServlet 匹配到/api/order/list和对应的 HandlerMethod 后,先执行TokenInterceptor.preHandle。拦截器从 Header 里拿到 token,去掉Bearer前缀,拿它去 Redis 查 userId。查到说明登录态有效,把 userId 和 token 放进UserContext,返回true放行。业务方法通过UserContext.getUserId()拿到当前操作人,完成本次业务。响应返回后,DispatcherServlet 执行到afterCompletion,调用UserContext.clear()把 ThreadLocal 里的数据清空,避免线程复用时串数据。
这条链路最舒服的地方在于:登录判断完全不需要 Controller 参与,Controller 也拿不到也无需关心用户是怎么登录的,它只需要知道自己正在为谁服务。如果你要在后续给登录接口增加双因子验证、给 token 增加刷新逻辑,改动都被圈在拦截器和它依赖的认证服务内部,业务代码一行都不用动。
6. 拦截器实战中绕不开的六个经典坑
6.1 preHandle返回false后,afterCompletion真的不执行吗
前面第 2 章已经说了,返回false的拦截器自身不会执行afterCompletion,只有它之前已经放行的拦截器才会逆序执行。这个结论背后的源码是HandlerExecutionChain.interceptorIndex的更新机制。实际开发中,如果你在preHandle里已经做了某些临时操作(打开资源、计数、写日志),并且下面某个分支要return false,请一定在返回前自己把资源清理掉,不要把希望寄托在afterCompletion上。我踩过一次:拦截器里给某个接口做了访问计数,认为afterCompletion一定能执行,结果计数前置逻辑里有参数校验失败直接return false的分支,统计数字就永远少了一部分,最后查了很久才发现。
6.2 @ResponseBody接口在postHandle里动手已经来不及了
这个坑很多人中过。@ResponseBody的响应并不是在postHandle阶段才写入HttpServletResponse的,而是在HandlerAdapter.handle()内部,由RequestResponseBodyMethodProcessor拿到 Controller 的返回值后立刻通过消息转换器写进响应流。所以当postHandle执行时,响应体其实已经在路上了。你如果想在postHandle里对 JSON 做统一包装、补公共字段,会发现要么写不进去,要么写进去之后响应已经乱七八糟。正确的做法是这类"统一响应结构"的需求交给ResponseBodyAdvice,它不是拦截器,但职责就是统一处理响应体,识别度更高,也更不容易出错。
6.3 拦截器里读了一次Body,Controller就收到了空请求
这个坑的破坏力极大。HttpServletRequest的getInputStream()和getReader()只能读一次,一旦你在拦截器里为了打日志、做签名校验把 body 读了,后续@RequestBody解析就会拿到一个空的输入流,接口参数全变成 null。项目里如果有"日志拦截器",它在preHandle里读 body 打印入参,是特别常见的故障来源。正确做法是不要直接读原始流,而是用ContentCachingRequestWrapper把请求包装一层,读取的数据会被缓存,再放回 FilterChain 给下游使用。要注意的是这个包装通常放在OncePerRequestFilter里做,而不是放在拦截器里,因为拦截器拿到的时候已经晚了,读取过程一样会消费流。我实际遇到过一起:加了个入参日志拦截器后,所有 POST 接口的请求体全部变成 null,排查了整整半天才定位到是拦截器干的好事。
6.4 把handler强转HandlerMethod,会踩到静态资源的雷
拦截器的第三个参数是Object handler,大多数情况下它是HandlerMethod,表示即将调用的 Controller 方法。但如果拦截路径覆盖了静态资源,这个 handler 可能是ResourceHttpRequestHandler,并非HandlerMethod。如果代码里不做类型判断直接强转,遇到静态资源请求就会抛ClassCastException。
我在写任何拦截器时都养成一个习惯,开头先做一层防御:
if (!(handler instanceof HandlerMethod)) { return true; }不是所有拦截器都需要关心这个。登录校验、权限校验这种业务型拦截器,遇到静态资源直接放行通常没问题;但如果是想记录每个接口的调用方法名、方法上的注解信息,强转之前务必做好判断。
6.5 ThreadLocal设置用户信息后忘记清理,线上内存泄漏的元凶
ThreadLocal 用起来很方便,但 Tomcat 的工作线程默认是复用的。一个请求处理完,线程回到线程池,如果 ThreadLocal 里的值没有 remove,下一次这个线程处理其他请求时,业务代码可能读到上一个用户的数据。而且如果 ThreadLocal 存储的是那种很大的用户对象,长期不清理,在高并发场景下会带来内存压力。
所以UserContext.clear()一定要放在afterCompletion里,而不是放在业务代码里随缘执行。还有一个细节:afterCompletion里即使ex为 null,也要执行清理逻辑,因为"请求正常结束"是最常见的情况。不要写if (ex != null) { clear(); }这种偷懒条件,那样绝大多数请求反而不清理了。
6.6 异步接口会让拦截器失效一半,AsyncHandlerInterceptor才是答案
当接口返回Callable、DeferredResult这类异步结果时,请求会经历两次 dispatch。第一次 dispatch 进入 Controller 拿到异步任务后,主线程就返回了;等异步任务真正执行完,容器再第二次 dispatch 返回结果。问题是,第二次 dispatch 并不会重新执行完整的拦截器链,旧的preHandle不会再走一遍,而postHandle和afterCompletion的执行时机也不再是你直觉上的那个时机。
Spring 为这种情况提供了AsyncHandlerInterceptor,它在HandlerInterceptor基础上多了一个afterConcurrentHandlingStarted方法,在第一次 dispatch 处理完、异步线程刚启动时立刻回调。适合在这里做上下文的转移和清理。如果你的异步业务后续还需要 userId,不要依赖 ThreadLocal,最好在启动异步线程之前把参数显式传进异步任务里。这也是我踩了异步接口拦截器"失忆"之后总结出来的经验:ThreadLocal 只适用于同步模型,异步场景请显式传参。
把上面这些坑补完,拦截器的使用其实就很稳了。写这个系列的时候我又翻了一遍自己项目里的拦截器代码,发现当初觉得"不过是一个 HandlerInterceptor 而已"的东西,真要写出高可用版本,涉及的边界条件一点不比业务代码少。等你把路径匹配、执行顺序、类型判断和生命周期都理顺了,再看项目中其他拦截器,基本一眼就能看出哪段逻辑会在什么请求下出问题。