资讯动态

Spring Web MVC拦截器全解析:从原理到实战

发布时间:2026/10/14 10:44:47 来源:尧图企业网站定制
做后端这几年Web 项目基本都会碰到同一类问题请求在进业务方法之前总有一些“横切”逻辑要先跑——登录用户是否有效、权限够不够、接口耗时要不要记、重复提交要不要拦。如果这些判断散落在每个 Controller 的方法里代码会越写越重复改一处权限规则还得全项目搜索替换。Spring Web MVC 拦截器 就是这套问题的标准解法它把请求处理的前置检查、后置处理和最终清理都收拢到一个统一入口里既不会入侵业务代码又能让你在真实请求进出的关键节点上做文章。这篇文章我会从“为什么需要它”讲起再把接口方法、注册配置、执行顺序、实操案例和常见坑完整串一遍。无论你是刚接触 Spring Boot 的新人还是写了两三年业务接口但一直没系统整理过拦截器知识的老手都能直接照着落地。1. 为什么横切逻辑需要一个统一收口从业务场景理解拦截器1.1 没有拦截器时Controller 层会变成什么样假设你现在维护一个订单查询接口第一版代码非常朴素Controller 里直接查库返回。上线之后产品说“必须登录才能看订单”于是你在方法第一行加了一段从 Session 里取用户的逻辑。第二个接口出来了复制粘贴同样的代码第三个接口是导出功能除了校验登录还要校验角色。慢慢地每个方法头部都堆着三四段相似代码方法一多改起来就非常痛苦。更麻烦的情况是你发现登录校验逻辑需要从 Session 改成 Redis 里的 token于是全局替换七八十个方法里的重复片段漏改一个接口就是安全事故。这种痛点本质上不是因为“某段代码难写”而是因为你在用面向过程的方式处理面向横切的关注点。登录、权限、日志、限流、防重复提交这些逻辑并不属于任何一个具体业务方法它们横跨几乎所有业务方法需要被放在一个独立的、可统一修改的位置。1.2 拦截器和过滤器、AOP 切面到底差在哪很多初学者会把过滤器、拦截器、AOP 切面混为一谈因为它们的名字都带“拦截”的味道。我在下面这张表里把三者从容器位置、能拿到的东西、适合做的事做了个对比理清边界之后再选型会非常清晰。维度Filter 过滤器HandlerInterceptor 拦截器AOP 切面所处环境Servlet 容器Spring MVC 容器Spring 容器能拿到的信息ServletRequest / ServletResponse / FilterChainHttpServletRequest / HttpServletResponse / Handler 方法对象方法参数 / 返回值 / 异常对象 / JoinPoint作用范围所有进入 Servlet 的请求由 DispatcherServlet 路由到具体 Handler 的请求被 Spring 管理的 bean 方法调用典型用途编码统一、跨域、粗粒度鉴权登录校验、权限控制、耗时日志、接口防刷事务控制、方法级权限、细粒度性能监控能否拿到目标方法注解不能直接拿到能拿到 HandlerMethod可读取方法注解能通过反射读取方法注解用一个生活类比来解释过滤器就像小区门口的人脸识别门禁只要进小区就先机器扫一遍它不关心你去几号楼几单元拦截器就像单元楼下的呼叫系统你到了具体单元楼按下门牌号它确认你有权限进入这层楼AOP 切面更像是电梯里的刷卡按钮到了具体楼层之后针对某一个业务动作做精细控制。所以如果你的逻辑需要拿到HandlerMethod比如方法上的RequiresPermission注解过滤器做不到必须用拦截器如果只想在 Controller 层做粗粒度控制拦截器也比 AOP 更贴合 Web 语义因为你能直接拿到请求 URL、请求参数和响应对象。1.3 什么场景应该优先选择拦截器我在选型时通常先按下面三个问题拍板这个逻辑是和 HTTP 请求生命周期绑定的吗比如“请求进来了没有”“请求走完了没有”那优先考虑拦截器。需要读取 URL、Header、Session 或请求体吗是的拦截器直接给HttpServletRequest处理起来最顺。需要影响 Controller 是否执行吗比如“没登录就直接返回 401”拦截器的preHandle返回false就能直接中断请求不需要业务代码感知。如果逻辑是对某个 Service 方法的守护比如“保存订单前校验库存”那更适合 AOP 或业务层校验因为拦截器只工作在 Web 层拿不到 Service 调用链路内部的上下文。简单说拦截器是“请求进出的保安”不是“业务方法内部的审计员”。2. 从接口方法到注册生效拦截器落地全链路2.1 HandlerInterceptor 接口的三个回调方法在 Spring Web MVC 里拦截器只需要实现org.springframework.web.servlet.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在 Controller 方法调用之前执行。返回值是 boolean返回true放行返回false直接短路后续所有拦截器和业务方法。postHandle在 Controller 方法调用之后、视图渲染之前执行。但要注意如果 Controller 返回的是ResponseBody或RestController响应体已经在 HandlerAdapter 内部写完了ModelAndView基本是空的你能干预的空间很有限。afterCompletion在请求处理完成之后执行无论前面流程是正常结束还是抛出异常都会回调。适合做资源清理、耗时统计、日志兜底。最后一个参数Object handler多数情况下是HandlerMethod类型的实例你可以通过它拿到 Controller 类和方法信息也能读取方法上的自定义注解。这一点是过滤器给不了的核心能力。2.2 在 Spring Boot 中注册拦截器的推荐姿势拦截器写好后需要通过WebMvcConfigurer注册进 MVC 组件链。最常见的是新建一个配置类重写addInterceptors方法Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/api/**) .excludePathPatterns(/api/login, /api/public/**); registry.addInterceptor(new CostLogInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/static/**, /favicon.ico); } }这里有几个细节值得展开addInterceptor的返回值是InterceptorRegistration你可以链式调用addPathPatterns和excludePathPatterns不配置路径时默认对所有请求生效。注册顺序就是链式执行顺序的基础。后面我会专门演示多个拦截器时的真实执行轨迹。如果你把拦截器类用Component注解托管给 Spring也可以在配置类里Autowired注入再拿给registry.addInterceptor(...)注意不要既注入又 new否则可能出现同一逻辑执行两次的坑。这种 Java 配置是 Spring Boot 项目里的主流方式。早期 Spring 还支持在 XML 里配置mvc:interceptors现在新项目基本都不用了。2.3 addPathPatterns 与 excludePathPatterns 的路径规则路径匹配规则是由AntPathMatcher或 Spring 5.3 之后的PathPatternParser处理的在写拦截器时只需要掌握最常用的三种模式*匹配任意单层路径比如/api/user/*能匹配/api/user/list但不能匹配/api/user/list/detail。**匹配任意多层路径比如/api/**能匹配/api/user、/api/user/list、/api/user/list/detail。?匹配单个字符用得少知道有这个能力即可。我常见的习惯是对内部接口用/api/**作为总入口再用excludePathPatterns逐个放行登录注册、验证码、公开接口。有一个容易踩的点是/api/*不能匹配/api/v2/orders这种带多级目录的请求如果没意识到这个问题大概率会导致接口没被拦截登录校验直接放行。还要注意匹配范围是应用 context path 之后的路径不带 query string。也就是说请求GET /api/orders?page1匹配时只看/api/orders。2.4 多个拦截器的执行顺序先进先出与后进先出当多个拦截器同时注册时很多人会想当然认为“谁先注册谁先执行”。这个说法一半对一半错。我用两个拦截器 A、B 举例注册顺序是 A 在前、B 在后完整执行轨迹是这样的A.preHandle执行返回true。B.preHandle执行返回true。Controller 方法执行。B.postHandle执行。A.postHandle执行。B.afterCompletion执行。A.afterCompletion执行。也就是说preHandle阶段按注册顺序执行看起来像排队进门postHandle和afterCompletion阶段则是逆序执行有点像把栈里的元素弹出来。底层原因是这些拦截器被存进了一个InterceptorRegistry维护的有序列表执行时通过HandlerExecutionChain按索引正序遍历preHandle再逆序遍历postHandle和afterCompletion。理解了这一点你就能精准设计一些依赖顺序的拦截器。比如一定要让“参数解析”和“用户上下文注入”先执行后面的“权限校验”才能拿到用户 ID那就要保证前者注册在前。3. 三个回调方法的执行时机决定了你能做什么3.1 preHandle业务方法执行前的那道闸门preHandle是整个拦截器体系里最有“控制力”的方法因为你可以通过返回值决定请求是否继续。很多权限系统就是靠它实现的。它有个细节值得强调一旦preHandle返回falseSpring MVC 会立刻中断处理链postHandle不会执行但已经执行过的拦截器的afterCompletion依然会被触发。这个设计意图很明确即使你没权限继续走进业务方法系统也给你一个“收尾”机会。比如统计日志拦截器已经记录了开始时间虽然请求被另外一个权限拦截器拦住了你仍然能在afterCompletion里把这一段也计入日志统计信息才不会缺漏。实际开发中还要注意在preHandle里不要随便调用request.getInputStream()或request.getReader()去读请求体因为在某些框架组合下读取后不重置会让 Controller 拿不到 body。如果你只是做登录校验读 Header 和 Session 就足够了。3.2 postHandle视图渲染前唯一一次补救机会postHandle看起来是“Controller 返回后、渲染前”但它在真实场景里很尴尬。以RestController为例消息转换器已经写好 JSON 响应了postHandle拿到的ModelAndView为null你在这时修改任何 model 都不会影响返回给前端的 JSON。如果你遇到的需求是“在响应里统一加一个 header”或“被拦截器提前写入了一些占位数据”可以做但直接操作HttpServletResponse时要小心一旦响应已经被提交response.isCommitted()返回 true设置 Header 可能不生效并抛异常。对于把页面渲染成 View比如 JSP、Thymeleaf的场景postHandle才有更大价值你可以往ModelAndView里塞公共数据比如当前登录用户名、菜单列表、页面标题避免每个 Controller 重复准备数据。3.3 afterCompletion无论成败都收尾的通知点afterCompletion是三个方法里稳定性最高的一个它通常用来做三件事释放资源比如清理ThreadLocal、关闭 IO 流、归还连接。统计耗时因为它是最后才执行的能计算整个请求链路的总耗时。记录异常兜底日志它的最后一个参数Exception ex会带出请求处理过程中产生的异常。要注意的是afterCompletion里不能保证业务数据已经完整写入响应如果业务代码里返回了错误 JSON而业务方法本身没抛异常ex参数就是null。所以基于日志做监控时别只依赖ex判断成败要结合响应状态码或响应内容。3.4 异步请求下的拦截器行为差异如果你的接口用到Callable或DeferredResult这类异步 Servlet 特性拦截器的执行次数和时机会有变化。一个典型的异步请求首次请求分发时会执行preHandle开启异步后 Spring 会调用AsyncHandlerInterceptor提供的afterConcurrentHandlingStarted方法这时preHandle、postHandle、afterCompletion并不会在整个异步过程结束后立刻执行而是要等异步结果出来后再次分发再走一遍拦截器链路。简而言之异步接口里拦截器可能执行两遍以上。如果你的拦截器里用了ThreadLocal存开始时间异步线程间数据串流就是一个需要注意的问题。不要想当然地认为每个请求只有一组preHandle/afterCompletion排查问题时看到日志里同一条路径出现两次拦截器记录先想想是不是异步请求场景。4. 三个可以直接抄的实战拦截器实现4.1 登录会话校验让无状态接口先过一遍门卫我先写一个最常用的登录校验拦截器。假设项目里用户登录成功后拿到一个 token之后的请求带X-Auth-Token头拦截器负责校验 token 并注入用户上下文再放行到业务层public class AuthInterceptor implements HandlerInterceptor { private final TokenService tokenService; public AuthInterceptor(TokenService tokenService) { this.tokenService tokenService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (isPublicPath(request.getRequestURI())) { return true; } String token request.getHeader(X-Auth-Token); if (token null || !tokenService.validate(token)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding(UTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } request.setAttribute(userId, tokenService.getUserId(token)); return true; } private boolean isPublicPath(String uri) { return uri.startsWith(/api/login) || uri.startsWith(/api/public/); } }这段代码的几个关键点校验失败时不仅返回 401还直接把 JSON 响应体写进去了。这样可以避免跳默认错误页或返回一个 HTML 页面让前端解析失败。通过request.setAttribute(userId, ...)把用户信息塞进请求上下文Controller 直接用RequestAttribute(userId)拿业务层不需要关心 token 解析逻辑。isPublicPath是我用来兜底的手段和注册时的excludePathPatterns职责略有重叠。注册时已经排除过的路径根本走不到这个拦截器这里再判断一次是双保险防止配置被人误删后接口直接裸奔。4.2 接口耗时日志用 ThreadLocal 记录并在 finally 中清理耗时统计是最容易写但最容易泄漏内存的拦截器。很多人一开始这样写在preHandle里Map.put(methodName, startTime)在afterCompletion里取值算差但完全没想着清理。在多线程复用场景下Tomcat 的工作线程处理完请求后并不会消亡ThreadLocal里的旧值会在下一个请求中残留导致统计数字莫名其妙变大甚至互相串数据。我推荐这种写法重点在最后强制清理public class CostLogInterceptor implements HandlerInterceptor { private static final Logger logger LoggerFactory.getLogger(CostLogInterceptor.class); private static final ThreadLocalLong COST_HOLDER new ThreadLocal(); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { COST_HOLDER.set(System.nanoTime()); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { try { Long start COST_HOLDER.get(); if (start null) { return; } long costMs TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); logger.info(请求 {} {} 耗时 {}ms, request.getMethod(), request.getRequestURI(), costMs); } finally { COST_HOLDER.remove(); } } }这里我用System.nanoTime()而不是System.currentTimeMillis()因为耗时统计关心的是间隔不是墙上时间nanoTime更高精度且不受系统改时间影响。另一个小细节是finally块里必须remove()不要只set(null)否则可能造成弱引用问题。在日志输出里我还建议带上X-Request-Id这样前端报障时能通过请求 ID 直接定位到一整条调用链。如果你已经在preHandle里生成了请求 ID这里顺手一起打出来。4.3 防重复提交先占坑、后释放的设计防重复提交是一个很常见的业务需求但实现起来有个并发问题如果在preHandle里“先查询有没有记录再插入记录”两个并发请求同时通过查询判断会把重复请求也放进去。解决方案是使用ConcurrentHashMap.putIfAbsent这类原子操作先把“坑”占住谁后到谁退出。public class DuplicateSubmitInterceptor implements HandlerInterceptor { private final ConcurrentHashMapString, Long submittingMap new ConcurrentHashMap(); private final long barrierMs; public DuplicateSubmitInterceptor(long barrierMs) { this.barrierMs barrierMs; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String userKey String.valueOf(request.getAttribute(userId)); String key userKey : request.getRequestURI(); long now System.currentTimeMillis(); Long previous submittingMap.putIfAbsent(key, now); if (previous ! null) { if (now - previous barrierMs) { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write({\code\:429,\msg\:\操作过于频繁请稍后再试\}); return false; } submittingMap.replace(key, previous, now); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { String userKey String.valueOf(request.getAttribute(userId)); String key userKey : request.getRequestURI(); Long stampedTime submittingMap.get(key); if (stampedTime ! null System.currentTimeMillis() - stampedTime barrierMs) { submittingMap.remove(key, stampedTime); } } }这套设计我用“占坑”来理解putIfAbsent成功的人获得放行权后到的请求看到坑里已有人就直接被拒绝。超过防抖时间窗口后replace或remove会重新开放坑位避免同一个用户永远无法再次提交。需要说明的是ConcurrentHashMap是进程内方案适合单节点应用。如果服务是集群部署多个节点各自维护submittingMap防御会失效。跨节点场景应该换成 Redis 的SETNX或 Lua 脚本原子操作。这里给的是单机快速落地版本适用于内部管理系统这类并发入口压力不高的场景。4.4 注册顺序与优先级安排上面三个拦截器都写好后注册顺序我并不建议随意排。实际项目里我建议按“从最外层到最内层”的语义注册CostLogInterceptor最外层任何请求都需要记录。AuthInterceptor第二层需要校验登录。DuplicateSubmitInterceptor第三层依赖userId已经注入才能做用户维度防重复提交。对应配置如下Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { CostLogInterceptor costLogInterceptor new CostLogInterceptor(); AuthInterceptor authInterceptor new AuthInterceptor(new TokenService()); DuplicateSubmitInterceptor duplicateSubmitInterceptor new DuplicateSubmitInterceptor(3000); registry.addInterceptor(costLogInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/static/**, /favicon.ico); registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/login, /api/public/**); registry.addInterceptor(duplicateSubmitInterceptor) .addPathPatterns(/api/order/**, /api/pay/**); } }这样在真实请求进来时即便一个用户没登录他也能留下耗时日志但因为他被AuthInterceptor挡住根本不会走到防重复提交。反过来如果先注册防重复提交再注册登录校验一个未登录用户可能先被当作“重复提交”拦截返回的 429 语义就完全错了。5. 拦截器排障实录五类高频坑与我的处理经验5.1 preHandle 抛异常后 afterCompletion 没有回调有一次我排查一个“连接数泄漏”的问题数据库连接池的活跃连接数只增不减。最后定位到某个拦截器里查了数据库preHandle中抛出了异常按我的预期afterCompletion应该负责释放连接资源。实际上当preHandle内部直接抛异常时并不会走return false那条正常中断路径afterCompletion可能压根不会被触发。原因是异常是在applyPreHandle阶段抛出来的处理链直接跳到异常分支进入了统一的异常处理流程。这个坑的教训是如果你在preHandle里占用了资源要么用try/finally自行保证释放要么在出现任何异常时手动调用afterCompletion逻辑不能指望框架兜底。更稳妥的做法是拦截器里尽量只做轻量校验和上下文注入把重活、可能抛异常的查询放到位次更靠前但异常边界更清晰的地方。5.2 postHandle 对着 RestController 改 ModelAndView 无效还有一次我以为在postHandle里给ModelAndView加一个version字段能让所有接口的响应多一个全局版本号结果前端反馈完全没变化。原因就是RestController的响应在 HandlerAdapter 内部已经通过消息转换器写出去了postHandle再改任何一个全局 map 也不会反映到已经生成的 JSON 里。这种情况的通用解法是使用ResponseBodyAdvice在消息转换器真正写出之前拦截响应体统一包装结构。而拦截器的postHandle更适合处理视角相关的资源比如页面渲染前的公共 model 变量。如果要用postHandle给响应加 header也请先判断response.isCommitted()响应一旦提交就不要再动它否则可能抛出IllegalStateException。5.3 静态资源也进了拦截器排除路径记得加Spring Boot 项目的静态资源默认会通过/**匹配进入拦截器。很多人在开发时用浏览器直接访问图片或 JS 文件发现报 401就是因为登录校验拦截器把所有静态资源也拦下来了。我的经验是注册拦截器时把静态资源路径统一起始规则写在excludePathPatterns里比如.excludePathPatterns(/static/**, /public/**, /favicon.ico, /error)这里的/error也要排除否则一旦出现未处理异常Spring Boot 会转发到/error页面而这个转发也会再次经过拦截器很可能导致异常响应变成 401干扰真正的问题排查。我把这条写在第一优先级开发环境遇到的“怪问题”十有八九是这个转发被拦截器“吃掉”了。5.4 同一个拦截器被重复注册日志和校验都翻倍如果你用Component标注了拦截器类同时在配置类里Autowired注入它并注册这没有问题。但如果你注入后又new了一个新实例或者在两个配置类里都注册了同一类型的拦截器就会出现日志重复、校验逻辑执行两次的现象。表面上看起来无非是多做一遍校验但如果校验里有写入操作、有数据库查询对性能的影响就不是“一点”了。排查这类问题有个简单方法在preHandle里临时打一行logger.info(interceptor invoked)启动应用收到一个请求数一数日志出现几次。超过一次就顺着注册来源查把重复的注册删掉。生产环境的拦截器日志如果突然翻倍第一反应就应该是模块配置重复加载。5.5 拦截器里不要做重活一次查询拖慢所有接口拦截器是全局共享的这意味着它的耗时会被均匀放大到所有被拦截的接口上。有一种很典型的性能劣化登录校验拦截器里每次请求都查一次用户表本来查询只要 30ms接口平均耗时可能还好一旦数据量增大查询变慢到 200ms所有接口的响应都会跟着变慢而且很难通过接口本身的火焰图定位因为耗时发生在业务方法之前。我的建议是拦截器里最多做 token 解析、Redis 点查、内存缓存判断不要做全表扫描、不要调外部服务、不要把需要几秒的鉴权逻辑写进去。用户信息这类数据应该在校验成功后塞进请求上下文再用一次ThreadLocal或请求属性做缓存后续业务逻辑需要时直接拿避免多次解析同一份 token。如果你也在维护一个接口较多的后端服务我建议先把“耗时日志拦截器”和“登录校验拦截器”这两款跑通把请求 ID、用户 ID、耗时打点这些基础底座打扎实再考虑扩展限流、防重复提交这类偏业务的控制逻辑。拦截器最理想的状态就是轻、快、不显眼让业务代码自己做它该做的事让横切逻辑安安静静地守在外面。等这套机制成熟后你会发现新增一个接口时不再需要粘贴任何权限判断安全性和可维护性都提升一个台阶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑