ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring MVC拦截器深度解析:核心原理、注册配置与实战避坑指南

Spring MVC拦截器深度解析:核心原理、注册配置与实战避坑指南

起步:为什么我建议先把拦截器这层搞明白

很多朋友在写 Spring Web MVC 项目时,登录校验、日志记录、接口耗时统计这类横切逻辑,第一反应就是直接往 Controller 里塞,一个方法贴一段重复代码,写到后面自己都嫌烦。我在早期做项目时也干过这种事,后来实在被改需求折磨得够呛,才开始老老实实研究拦截器(HandlerInterceptor)。现在回头看,这个东西几乎是每个 Web 项目的刚需,只是很多初学者把它当成了“会配会用就行”的边角料,结果一旦碰到跨拦截器共享数据、异步请求丢失链路信息、静态资源被拦这类问题,就彻底懵了。

这篇内容就围绕 Spring Web MVC 拦截器展开,从接口的核心方法设计讲起,到配置注册的细节、常见场景的落地写法,再到我实际踩过的一些坑。无论是刚接触 Spring MVC 的新手,还是已经在项目里用过但没时间深挖的开发者,应该都能从这里找到对自己有用的东西。

1. 先搞清楚它到底拦截了什么

1.1 拦截器在请求处理链路中的位置

Spring Web MVC 的请求处理,并不是“浏览器发过来,Controller 直接接住”这么简单。DispatcherServlet 接收到请求后,会先经历一个路由查找过程,这个过程中 HandlerMapping 会帮我们找到能处理该请求的 Handler(也就是 Controller 方法),同时也会把与该请求匹配的拦截器链组装起来。

拦截器的作用就在这之后、Controller 方法执行之前和之后生效。也就是说,它处在“DispatcherServlet 找到目标方法”和“目标方法真正运行”之间的这段空隙里,这也就决定了它很适合干那些“跟具体业务无关,但每个请求都要做”的事情。

用一个生活化的类比来讲,拦截器就像进商场前的安检通道。商场确定你要去哪个店铺(HandlerMapping 找到目标处理器),但进门之前必须过闸机,闸机检查你的包、确认你的身份,没问题才放你进去购物(执行 Controller),你买完东西出门时,闸机再确认你没有偷拿商品(清理资源或记录结果)。

1.2 拦截器和过滤器不是一回事

很多初学者会把拦截器跟 Servlet 里的 Filter 搞混。这两者最大的区别在于作用层级:

  • Filter 是 Servlet 规范里的东西,作用在 Servlet 容器层面,它可以拦截所有进入应用的请求,甚至包括静态资源的请求。
  • 拦截器是 Spring MVC 框架层面的机制,它只拦截 Spring MVC 的路由处理过程,所以它天然能拿到 HandlerMethod 信息——也就是你具体将要调用哪个类的哪个方法。

换句话说,Filter 不认识“Controller 方法”这个概念,它只知道“有请求来了”。拦截器则能精确知道你这次请求要调用的是哪个业务方法,这让它在做权限校验、方法级别日志这类事情上要聪明得多。

再随手举个例子:你想统计某个接口的调用次数,如果用 Filter,你得自己去解析 URL 或者用自定义注解标记,然后自己根据路径匹配去找对应的 Controller;但用拦截器,你直接在 preHandle 里拿到 handler,检查它是不是 HandlerMethod,是的话直接就能反射出类名和方法名,甚至连方法上的注解都能直接读出来——这感觉就像一个是站在楼下看楼层亮灯,一个是直接进门看你在哪个工位干活。

2. 接口三大方法的设计逻辑

2.1 preHandle:决定放不放行

preHandle 是拦截器里最重要的方法,它在 HandlerExecutionChain 决定执行目标 Handler 之前被调用。返回值是 boolean 类型:返回 true 表示继续执行后续拦截器和目标方法;返回 false 表示中断整个请求链,DispatcherServlet 会认为当前请求已经被处理完毕,直接返回。

这个方法最适合干的事情包括:登录状态校验、接口权限判断、请求参数合法性粗校验、埋点计时开始。

因为它的位置足够靠前,很多事情在这里拦截掉成本最低。比如你判断用户没有登录,在 preHandle 里直接往 response 写一个 JSON 响应然后 return false,Controller 根本不会执行,数据库也不会被无效请求打到,这对系统稳定性很有价值。

2.2 postHandle:执行后、出结果前

postHandle 在 Controller 方法执行完毕之后被调用,但它有一个重要的前提:只有当 preHandle 返回 true 时,它才可能被调用。而且它执行的时间点是视图渲染之前,这意味着如果你用的是前后端分离模式、Controller 直接返回 JSON 数据的场景,postHandle 往往没有太大用处,因为并没有“视图”需要你去额外处理。

这个方法的典型应用场景:往 ModelAndView 里补充一些公共数据,比如页面的公共导航、用户信息、版本号之类的。对现在的纯后端接口服务来说,这个方法在大多数情况下的存在感不强,但你得知道有它,因为一旦你后续改造项目、开始接入服务端渲染页面时,它可能就派上用场了。

2.3 afterCompletion:不管成败,最后都走这里

afterCompletion 是三个方法中收尾性质最强的。只要是 preHandle 有过返回 true 的请求,即使后续 Controller 抛了异常、postHandle 没执行,afterCompletion 也一定会被执行。唯一跳过的例外情况是 preHandle 自己返回了 false 或者抛了异常,那么整个拦截器链的后续方法都不会执行。

所以日志记录、资源清理、性能耗时统计的截止操作,放在这个方法里最稳妥。我在实际项目里,请求耗时的结束时间戳就是在这里记的,这样哪怕业务方法出错了也能准确反映出接口的整体响应时间。

3. 注册配置:一半的坑都在这里

3.1 通过 WebMvcConfigurer 注册的典型姿势

现在无论是 Spring Boot 还是 Spring MVC 的 Java 配置方式,主流做法都是实现 WebMvcConfigurer 接口,重写 addInterceptors 方法:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/api/**") .excludePathPatterns("/api/login", "/api/register"); } }

这里有几个细节值得注意:

  • addInterceptor 可以连续调用多次,注册多个拦截器,它们会按照注册顺序依次执行。
  • addPathPatterns 用的是 Spring 的路径匹配模式,/api/**表示匹配 /api 下的所有层级路径。
  • excludePathPatterns 在需要排除登录、注册等公开接口时特别实用,不用在代码里写一堆 if 条件自己判断。

3.2 路径匹配规则我踩过的边界

Spring 的 Ant 风格路径匹配里,*和**容易让人混淆。我自己最初就吃过亏,写了/api/*以为能匹配/api/user/list,结果实际只匹配了一级路径。简单总结一下:

  • *匹配路径中的一个片段,比如/api/*能匹配/api/user,但匹配不了/api/user/list。
  • **匹配多层路径,/api/**能匹配/api/user,也能匹配/api/user/list/detail。
  • 还有更精细的花括号表达式/api/{id},但它主要用于 @PathVariable 类似的模式,拦截器配置里一般用得不多。

如果你在配置拦截器时发现某些接口没有被拦截到,第一反应就该检查路径匹配模式写得对不对。这是整个拦截器使用过程中出现频率最高的问题之一。

3.3 Spring Boot 环境下的顺序和注册时机

Spring Boot 的自动配置会把用户定义的拦截器跟框架自带的一些处理器合并到一起。如果你的项目启用了 Spring Security,拦截器的执行时机是在 Security 过滤器链之后,也就是说如果 Security 在过滤器层面就把请求拒掉了,你的拦截器根本不会有机会执行。

另外,如果项目里同时存在多个 WebMvcConfigurer,执行顺序受 @Order 注解控制。这个顺序决定了拦截器的注册顺序,进而影响 preHandle 的执行顺序。通常登录校验类的拦截器应该排在最前面,因为如果连登录都没通过,后面的日志拦截器去统计参数其实没有太大意义——当然,这也要看具体需求,有些团队就想记录所有请求,哪怕是被拒绝的请求也要留下痕迹。

4. 从 0 到 1 搭一个可用的登录拦截器

4.1 实现 HandlerInterceptor 接口

直接新建一个类实现 HandlerInterceptor 接口,重写三个方法:

@Component public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } // 非控制器方法直接放行(比如静态资源,理论上不会走到这里) if (!(handler instanceof HandlerMethod)) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isBlank()) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录或登录已过期\"}"); return false; } // 校验 token 的逻辑,解析用户信息并放入 request 上下文 UserInfo user = tokenService.parseToken(token); if (user == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"无效的登录凭证\"}"); return false; } request.setAttribute("currentUser", user); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 这里可以做请求完成后的操作,比如清理 ThreadLocal、记录访问日志 } }

这里面有一个细节很关键:判断 handler 是不是 HandlerMethod。因为拦截器不仅能拦截到 Controller 方法,如果项目中配置了资源处理器(ResourceHttpRequestHandler),它们也会经过拦截器链。如果不做这个判断,后面强转 HandlerMethod 就会报 ClassCastException。

4.2 注册到拦截器链并配置放行路径

@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/login", "/api/register", "/api/public/**", "/error" ); } }

特别提醒一下,/error路径最好放行。因为如果拦截器里 response 写出异常或者后续出现 404 之类的问题,Spring Boot 会转发到 /error 路径,如果这个路径也被拦截器拦了,有可能出现递归处理或者无法正常输出错误信息的情况。

4.3 Controller 中如何拿到当前用户

既然拦截器把用户信息放到了 request 的 attribute 里,Controller 就可以直接取出来:

@GetMapping("/user/info") public Result<UserInfo> getUserInfo(HttpServletRequest request) { UserInfo user = (UserInfo) request.getAttribute("currentUser"); return Result.success(user); }

如果你觉得每次从 request 里取值太啰嗦,也可以借助一个 ContextHolder 类,配合拦截器里设置的数据来做线程级共享。但这里我要泼一盆冷水:用 ThreadLocal 存用户信息一定要在 afterCompletion 里移除,否则线程池复用线程时会把你上一次请求的用户信息带到下一次请求里。这个 bug 非常隐蔽,排查起来也特别折腾,定位到了又会觉得异常低级。

4.3.1 一个安全的 ThreadLocal 写法
public class UserContext { private static final ThreadLocal<UserInfo> HOLDER = new ThreadLocal<>(); public static void set(UserInfo user) { HOLDER.set(user); } public static UserInfo get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }

拦截器里 preHandle 身份验证通过后调用UserContext.set(user),afterCompletion 里调用UserContext.clear(),这样就能保证一次请求一个线程,用完后清掉,不被线程池复用污染。

5. 日志拦截器和性能监控拦截器的组合实践

5.1 访问日志应该记录哪些字段

访问日志拦截器是我每个项目都会配的基础组件。记录的内容一般包括:请求路径、查询参数、处理耗时、HTTP 状态码、请求来源 IP、用户标识(如果已经解析出来了)。这些数据在排查线上问题时几乎是第一手线索。

@Component public class AccessLogInterceptor implements HandlerInterceptor { private static final Logger log = LoggerFactory.getLogger(AccessLogInterceptor.class); private static final String ATTR_START_TIME = AccessLogInterceptor.class.getName() + ".startTime"; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod) { request.setAttribute(ATTR_START_TIME, System.currentTimeMillis()); } return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { Long startTime = (Long) request.getAttribute(ATTR_START_TIME); if (startTime == null) { return; } long cost = System.currentTimeMillis() - startTime; HandlerMethod handlerMethod = (HandlerMethod) handler; log.info("[access log] uri={}, method={}, handler={}#{}, status={}, userId={}, cost={}ms", request.getRequestURI(), request.getMethod(), handlerMethod.getBeanType().getSimpleName(), handlerMethod.getMethod().getName(), response.getStatus(), request.getAttribute("currentUserId"), cost); } }

注意这里我用了一个字符串 key 来存起始时间戳。为什么不直接用成员变量?因为拦截器默认是单例的,多个请求并发时会互相覆盖,用成员变量存这种请求级数据是线程不安全的。把时间戳放在 request attribute 里,一个请求一份,互不干扰,这个思路也是我在多个并发问题中总结出来的。

5.2 多个拦截器的执行顺序验证

假设我们注册了 AuthInterceptor 和 AccessLogInterceptor,注册顺序不一样,行为也会不同:

  • 先注册 Auth,再注册 AccessLog:请求进来先过登录校验,校验通过后 AccessLog 才开始计时,能准确记录“已登录用户的接口耗时”。
  • 先注册 AccessLog,再注册 Auth:所有请求都会先被记录(包括未登录请求),AccessLog 的 preHandle 先执行、afterCompletion 后执行,所以记录的是包含 Auth 拒绝在内的总体耗时。

没有哪个方案绝对正确,得看你的业务目标。我自己后端的日志上需要把“未登录请求被拒绝”也作为一条记录,方便观察恶意扫描或者活动期流量,所以我在日志项目里会刻意把 AccessLogInterceptor 放在 AuthInterceptor 之前。而在常规业务项目里,我更习惯于 Auth 优先,避免大量非业务请求把日志刷得很脏。

这里再补充一个容易踩的坑:如果你在 AccessLogInterceptor 的 preHandle 里也想拿用户信息,而它注册在 AuthInterceptor 前面,那这个时刻用户信息还没被解析出来,你能拿到的只有 null。遇到这种情况,要么调整顺序,要么把用户信息解析的判断拆得更细。

5.3 耗时统计要小心时间偏差

System.currentTimeMillis() 是墙上时钟时间,受系统时间调整影响。在线上的偶发问题排查时,更推荐用 System.nanoTime() 来做耗时计算,因为它计算的是时间间隔,不受系统时间跳变影响。

request.setAttribute(ATTR_START_TIME_NANO, System.nanoTime()); // ... long costNanos = System.nanoTime() - (long) request.getAttribute(ATTR_START_TIME_NANO); long costMs = costNanos / 1_000_000;

6. 拦静态资源那些事

6.1 静态资源默认不经过业务拦截器吗

这里有个常见误区。Spring Boot 的静态资源路径(比如/static/**)默认是通过 ResourceHttpRequestHandler 处理的,理论上会走HandlerInterceptor的拦截链。但很多人发现自己的拦截器似乎“没有拦住静态资源”,原因往往是路径匹配没对上,或者压根没有把静态资源路径包含进拦截范围。

如果你想把静态资源从拦截器中排除,标准的做法是显式声明:

registry.addInterceptor(new AuthInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/static/**", "/public/**", "/favicon.ico");

如果反过来,你想连静态资源访问也记录日志,那可以直接让拦截器的路径匹配包括/static/**,这样每次加载 js、css、图片都会在 access log 里留痕。但对高流量站点来说这会让日志量剧增,磁盘膨胀得很快,建议只在排查特定问题时临时打开,或者对静态资源只记录耗时超过阈值的那部分。

6.2 前后端分离项目里拦截器的误区

现在很多项目都是前后端分离,静态资源打包后部署在 Nginx 或 CDN,后端应用其实根本接不到静态资源请求。这种情况下,后端拦截器想拦也拦不到,也不需要拦。你只需要聚焦在/api/这个路由前缀上就行,这能让拦截器的配置逻辑更清晰。

但如果项目处于“单体应用 + 服务端渲染”的状态,静态资源由应用自己托管,那就得仔细处理排除列表,不然会出现页面能打开但样式和脚本 404 的情况。我自己遇到过好几次这种问题,排查了大半天才发现是拦截器把.css和.js的文件请求给重定向到登录页了,浏览器控制台刷了一堆 401,页面看起来跟“裸奔”一样。

7. 高级场景:在拦截器里读取请求体和响应体

7.1 读 Body 的阻塞和解决方案

有时候做接口参数审计,想在拦截器阶段就把 request 里的 JSON body 读出来存日志。教条地说,HttpServletRequest 的输入流只能被读取一次,如果你在拦截器里读了一次 input stream,后面 Controller 里的 @RequestBody 反序列化就会拿到空内容,接口直接报参数缺失错误。

解决办法是使用 ContentCachingRequestWrapper,在进入拦截器链路之前就把请求包装一下,缓存读取过的内容。Spring 框架自带的org.springframework.web.util.ContentCachingRequestWrapper可以帮我们干这件事,但必须在 Filter 层包装,不能在拦截器里包装后再传给下游,因为拦截器执行时 DispatcherServlet 已经确定了 Handler,你在这里包装 request 已经晚了。

一个典型的配置:

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

然后在拦截器的 afterCompletion 里通过((ContentCachingRequestWrapper) request).getContentAsByteArray()获取请求体内容。注意这里的坑:ContentCachingRequestWrapper 只有在输入流被读过之后才会缓存内容,如果你在 Controller 执行前就尝试读取,拿到的是空数组。所以获取内容的最佳时机是在 afterCompletion,这时候 @RequestBody 已经完成了读取,缓存里才有数据。

7.2 响应体的读取也有类似问题

响应体同理,用 ContentCachingResponseWrapper 包一层,在拦截器里把响应内容读出来记录日志。但同样要注意:包装之后的响应,只有在 Controller 写完并完成 flush 之后才有内容,所以只能在 afterCompletion 里取。

这类方案在“接口报文留痕”的需求下很管用,但是对性能有一点影响,毕竟多了一次内容缓存和对象拷贝。它主要适用在金额交易、合同签署等强审计场景,千万别在流量大的通用查询接口上无脑加。

8. 异步请求与拦截器的一个隐蔽坑

8.1 异步处理时 preHandle 提前返回了

Spring MVC 从 3.2 开始支持异步处理,使用 Callable 或 DeferredResult 时,Controller 的入口返回后,业务逻辑会在另一个线程池里继续执行。这意味着什么?拦截器的 afterCompletion 并不是在业务逻辑真正执行完之后调用的,而是可能在 Controller 将异步任务提交给线程池之后就立刻被调用。

如果你在拦截器里做了“请求耗时统计”,在异步场景下统计到的时间会严重偏短,因为它只计算了提交任务的时间,而不是业务真正执行完的时间。

应对办法是让拦截器实现 AsyncHandlerInterceptor 接口,Spring 5 之后更推荐直接实现 HandlerInterceptor 里新加的afterConcurrentHandlingStarted方法:

@Override public void afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 并发处理开始,当前线程将不会被阻塞,这个回调发生在异步处理启动时 // 这里一般可以重新计时,或者暂时标记状态 }

而真正的异步执行完成后,容器会再次走一遍 afterCompletion 流程。这里面的时间点和回调顺序比较绕,我在文档里研究了好几轮才完全理清楚。总的一句话:异步接口的性能统计不要简单依赖拦截器第一次 afterCompletion,要做专门处理,比如在异步线程内部自己记录执行时间。

8.2 用户身份在异步线程传递的问题

异步线程里如果也想用 UserContext.get() 拿到当前登录用户,默认是拿不到的,因为 ThreadLocal 是线程隔离的,异步线程池里的线程不等于接收请求的那个线程。解决方案要么在提交异步任务前把 UserContext 里的数据快照出来,作为参数传给异步逻辑;要么使用 TransmittableThreadLocal 这类专门解决线程池上下文传递的组件。我们项目里早期没注意,异步导出报表的功能偶发性出现“用户不存在”的报错,排查后发现就是这个原因。

9. 实战问题排查表

把我在工作中遇到和帮别人排查过的问题整理成了一张速查表,希望能省掉你翻源码的时间。

现象可能原因排查步骤 / 解决方案
拦截器不生效,接口直接执行没注册 / 注册了但路径没匹配上检查 WebMvcConfigurer 是否被扫描到;检查 @Configuration 和包路径;确认路径模式是/**还是/*
登录接口也被拦截了excludePathPatterns 漏配了看注册代码里的排除列表是否包含该路径;确认路径写法是否匹配实际 URL
静态资源全被重定向拦截了静态资源路径在排除列表里加/static/**/public/**/favicon.ico等路径
同一个用户请求却拿到别人的数据ThreadLocal 没有在 afterCompletion 里 remove检查 UserContext.clear() 是否被调用;在拦截器里做一次兜底清理
Controller 里 @RequestBody 反序列化为 null拦截器里提前读了输入流使用 ContentCachingRequestWrapper 或不要在拦截器里读 body
异步接口耗时统计明显偏短拦截器的 afterCompletion 在异步线程里执行时机提前使用异步回调机制单独记录,或实现 AsyncHandlerInterceptor
拦截器抛异常导致接口 500,但日志看不到异常在拦截器中被吞掉或没有记录检查 preHandle 里异常处理分支,确认 afterCompletion 的 ex 参数是否有值并输出
多个拦截器顺序乱了注册顺序或 @Order 设置不对检查代码里 registry.addInterceptor 的调用顺序,查看各 WebMvcConfigurer 的 @Order 值

10. 再补几个容易被忽略的小技巧

10.1 在拦截器里给请求打标

如果你的统一异常处理器里需要区分“这个请求是不是已经被拦截器标记过”,可以在拦截器里加一个 attribute 标记。比如在 AuthInterceptor 通过后设置request.setAttribute("authenticated", true),在全局异常处理器或 Filter 里就能根据这个标记决定返回 401 还是 500。这种隐式控制状态的方式在复杂的链路中很实用。

10.2 拦截器里返回 JSON 响应的细节

很多人在 preHandle 里直接response.getWriter().write(json)然后 return false,但偶尔会遇到“响应内容没有生效”的问题,原因可能是:

  • response.getWriter() 和 getOutputStream() 不能同时使用。
  • 返回前最好把 response 状态码设置正确。
  • 如果没有设置 Content-Type,某些客户端对响应体的解析可能会乱码。

推荐统一写法:

response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"未经授权\"}");

10.3 用 HandlerMethod 读方法级注解做精细权限

既然到了拦截器这一层能拿到 HandlerMethod,就可以在 preHandle 里读取方法上的自定义注解,实现非常灵活的操作级权限控制。

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

Controller 方法上加@Permission("order:export"),拦截器里这样读取并校验权限:

HandlerMethod handlerMethod = (HandlerMethod) handler; Permission permission = handlerMethod.getMethodAnnotation(Permission.class); if (permission != null) { boolean hasPerm = permissionService.hasPermission(currentUser.getId(), permission.value()); if (!hasPerm) { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.getWriter().write("{\"code\":403,\"message\":\"无权限操作\"}"); return false; } }

这种做法把权限控制从 Controller 里面抽出来,业务方法不需要写一大堆权限判断代码,只声明“需要什么权限”就够了。是我比较推荐的一种风格,比在 Controller 里到处调用checkPermission(...)干净得多。

写在最后,聊聊我自己的体会

拦截器在 Spring Web MVC 里看起来只是一个简单的接口,但真正往深处挖,它串联起了路径匹配、请求上下文、异常处理、异步回调、线程安全等一堆知识点。刚做项目的那几年,我总觉得它太简单,不值得花时间深挖,后来遇到 ThreadLocal 数据串了、异步任务日志时间不对这类问题才明白,越是基础的组件,越值得把细节啃透。

如果你正准备在自己的项目里引入拦截器,我建议从最基础的访问日志开始练手,把一个拦截器完整跑通后,再去叠加登录校验、权限控制、接口耗时统计这些能力。等一个项目里的三四个拦截器协同工作不出问题时,你对整个 Spring MVC 请求生命周期的理解也就扎实了一大截。之后再看框架源码里 HandlerExecutionChain 的调用逻辑,会顺畅很多——本质上,拦截器链的实现逻辑并不复杂,就是按顺序拿一个 list 循环执行。别怕它,也别小看它,掌握好了它就是你在 Web 层做横切控制的利器。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进