拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring MVC拦截器实战:从HandlerInterceptor到Token统一校验

如果你写接口时发现每个 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 的拦截器接口是HandlerInterceptorSpring 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 最核心的差异可以整理成一张表维度FilterInterceptor技术归属Servlet 规范Spring MVC 框架执行位置DispatcherServlet 之前DispatcherServlet 之后、Handler 之前获取的对象ServletRequest / ServletResponseHttpServletRequest / 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。一旦加了EnableWebMvcSpring 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 缓存 TokenJWT服务端状态有状态需要存 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;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\ message \}); } }用户上下文用 ThreadLocal 实现避免在 Controller 里到处传HttpServletRequestpublic class UserContext { private static final ThreadLocalString USER_ID new ThreadLocal(); private static final ThreadLocalString 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 拦截器里读了一次BodyController就收到了空请求这个坑的破坏力极大。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 而已的东西真要写出高可用版本涉及的边界条件一点不比业务代码少。等你把路径匹配、执行顺序、类型判断和生命周期都理顺了再看项目中其他拦截器基本一眼就能看出哪段逻辑会在什么请求下出问题。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门