Spring 拦截器配置分两步:写一个类实现 HandlerInterceptor,再在一个标注 @Configuration 的配置类里实现 WebMvcConfigurer,重写 addInterceptors 方法把拦截器注册进去,用 addPathPatterns 指定要拦的路径、excludePathPatterns 指定要放行的路径。三步缺一不可,漏掉注册这一步是新手最常犯的错。
很多人知道拦截器能做登录校验,却搞不清三个回调方法分别在什么时候执行、preHandle 返回 false 之后请求去了哪里,更分不清它和过滤器到底有什么区别。今天这篇文章,编程狮就把拦截器的执行时机讲透,再给出登录态校验、接口耗时统计、防重复提交三个真实场景的完整代码,最后给你一张拦截器与过滤器的选型对照表。

一、拦截器是什么,在请求的哪个环节生效
拦截器是 Spring MVC 提供的一套"横切"机制:请求到达 Controller 之前、Controller 执行之后、整个请求结束之后,这三个时间点各给你一次插入代码的机会。登录校验、接口耗时统计、防重复提交这类"几乎每个接口都要做、又跟业务无关"的事情,最适合放在这里。
三个回调方法的分工可以用一句话记住:preHandle 决定放不放行,postHandle 能改模型数据,afterCompletion 只负责收尾。
1.1 一个最小可运行示例
先写拦截器本体,实现 HandlerInterceptor 并重写三个方法:
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
public class HelloInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
System.out.println("1. preHandle:Controller 执行之前");
return true; // 返回 false 请求到此为止,Controller 不会执行
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
System.out.println("2. postHandle:Controller 执行之后,视图渲染之前");
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
System.out.println("3. afterCompletion:请求彻底结束,用于清理资源");
}
}
再把它注册进容器,这一步才是很多人漏掉的:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HelloInterceptor())
.addPathPatterns("/api/**") // 只拦 /api 开头
.excludePathPatterns("/api/login", "/api/public/**");
}
}
addPathPatterns 和 excludePathPatterns 都支持 ** 通配,拦截规则是"先匹配包含、再剔除排除",所以登录接口这种必须放行的路径,一定要写进排除列表。
一个项目里可以同时注册多个,形成一条链。它们按注册顺序依次执行,任何一个否决都会让后续环节停下,所以"先校验后统计"这类有先后依赖的组合,注册顺序不能乱。
把它注册进来之后,一个请求的完整链路是这样走的:请求进来,先过 Servlet 过滤器,然后才进 Spring MVC 的 DispatcherServlet,接着依次执行各个拦截器的 preHandle,全部放行才轮到 Controller;Controller 返回后倒序执行 postHandle,视图渲染完毕再倒序执行 afterCompletion。想把这一层的前后关系理清,Spring 教程 的 MVC 章节有完整的流程图。
1.2 常见误解
误解一:拦截器和过滤器是一回事。 不是。过滤器是 Servlet 规范的东西,能拦包括静态资源在内的所有请求;拦截器是 Spring MVC 的东西,只拦映射到了 Controller 处理方法的请求。详见第五节。
误解二:preHandle 返回 false 就是抛异常。 它只是安静地中断,浏览器收到的响应完全由你自己写——不写就是空响应体,前端往往表现为"接口没反应",排查起来很费劲。
误解三:postHandle 里还能改响应结果。 在返回 JSON 的项目里,Controller 执行完时响应内容基本已经写进输出流了,postHandle 改不了。想动响应体要用 ResponseBodyAdvice。
误解四:写好了就自动生效。 必须实现 WebMvcConfigurer 注册,否则这个类从头到尾都不会被调用。
二、场景一:登录态校验
这是它最经典的用法:在 preHandle 里检查会话,没登录就直接返回 401 并中断。
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.web.servlet.HandlerInterceptor;
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
HttpSession session = request.getSession(false); // 没有就返回 null,不新建
if (session != null && session.getAttribute("user") != null) {
return true; // 已登录,放行
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"msg\":\"请先登录\"}");
return false; // 中断,Controller 不执行
}
}
注册时务必把登录接口本身排除掉,否则用户连登录请求都被拦,形成一个永远登不进去的死循环:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.addPathPatterns("/**")
.excludePathPatterns("/login", "/captcha", "/error", "/static/**");
}
}
两个细节值得留意:getSession(false) 表示"有就拿、没有别新建",避免为每次未登录访问白白创建会话;return false 之前一定要自己写入响应状态码和提示信息,否则用户看到的是一个空白响应。
不这么做,同样的判断就只能写进每一个 Controller 方法里。那份重复的代价不只是代码量,更是"总有新同事忘记加"的风险——漏掉的那一个接口,往往就是被刷的那一个。
三、场景二:接口耗时统计
想统计每个接口的处理耗时,思路是在 preHandle 里记下开始时间,afterCompletion 里算差值并打印日志。
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.servlet.HandlerInterceptor;
public class CostInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(CostInterceptor.class);
private static final String START_KEY = "cost_start_time";
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
request.setAttribute(START_KEY, System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
Object start = request.getAttribute(START_KEY);
if (start == null) {
return;
}
long cost = System.currentTimeMillis() - (Long) start;
String uri = request.getRequestURI();
log.info("请求 {} 耗时 {} ms,响应状态 {}", uri, cost, response.getStatus());
if (cost > 1000) {
log.warn("慢请求告警:{} 耗时 {} ms", uri, cost);
}
}
}
这里有个必须记住的并发陷阱:开始时间一定要存在 request 里,绝不能存成成员变量。它在容器里是单例的,所有请求共用同一个实例,用成员变量存时间戳,多线程并发时数据会互相覆盖,统计出来的耗时全是错的。同理,任何与单次请求相关的状态都应该走 request.setAttribute()。
计时点选在 afterCompletion 而不是 postHandle,也是刻意的:postHandle 之后还有响应写出、视图渲染这些步骤,在那里停表会漏掉一截真实耗时,测出来的数字偏小。另外 afterCompletion 的第四个参数 ex 就是 Controller 抛出的异常,可以在这里统一记录异常日志——即使请求中途出错,这个回调也一定会被调用。
HandlerInterceptor 的方法签名、WebMvcConfigurer 的常用回调记不全很正常,随手查 Spring 速查手册 比翻源码快。
四、场景三:防重复提交
用户手抖连点两次提交按钮,或者网络卡顿后重试,都会产生重复订单。拦截器可以按"用户 + 接口"做一层时间窗拦截。
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;
public class RepeatSubmitInterceptor implements HandlerInterceptor {
private static final long MIN_INTERVAL = 3000; // 3 秒内不允许重复提交
private final Map<String, Long> lastSubmit = new ConcurrentHashMap<String, Long>();
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (!"POST".equalsIgnoreCase(request.getMethod())) {
return true; // 只拦 POST
}
String key = request.getSession(true).getId() + ":" + request.getRequestURI();
long now = System.currentTimeMillis();
Long last = lastSubmit.get(key);
if (last != null && now - last < MIN_INTERVAL) {
response.setStatus(429);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":429,\"msg\":\"操作太频繁,请稍后再试\"}");
return false;
}
lastSubmit.put(key, now);
return true;
}
}
这段代码只在 POST 请求上生效,用"会话 ID + 请求路径"拼成唯一的键,三秒内的第二次提交直接返回 429。它拦的是时间窗内的重复点击,简单有效。
但有两点要清楚:第一,ConcurrentHashMap 是单机内存方案,服务部署多个实例时各实例各记一份,拦截会失效,正式环境应当换成 Redis 并设置过期时间;第二,它防不了并发极高的瞬时双提交,涉及金额的接口还得靠数据库唯一索引或分布式锁兜底。
三秒这个阈值不是硬标准,得看业务:普通表单提交给三秒足够挡住手抖连点,导出报表这类本来就慢的操作要放宽到几十秒,或者干脆按"同一份参数只处理一次"来重新设计。判断键里带上会话 ID,是为了让不同用户之间互不影响。
五、怎么选:拦截器与过滤器的区别及踩坑清单
| 对比项 | 过滤器 Filter | 拦截器 Interceptor |
|---|---|---|
| 所属规范 | Servlet 规范,任何 Java Web 项目都能用 | Spring MVC 提供,依赖 Spring 容器 |
| 拦截范围 | 所有请求,含静态资源 | 只拦映射到了处理方法的请求 |
| 执行位置 | 在 DispatcherServlet 之前,更靠外 | 在 DispatcherServlet 内部 |
| 能否拿到 Controller 信息 | 不能,只认 URL | 能,handler 可转成 HandlerMethod 拿注解 |
| 注入 Spring Bean | 麻烦,需要额外桥接 | 直接 @Autowired |
| 典型用途 | 字符编码、跨域、压缩、全局日志 | 登录校验、权限、耗时统计、防重提交 |
一句话决策:需要 Spring 的 Bean 或者想读 Controller 上的注解,用拦截器;要处理静态资源、编码、跨域这类与业务无关的底层事情,用过滤器。
两者也不是非此即彼。实际项目里常见的组合是:字符编码、跨域、请求包装放在过滤器里,因为它执行更早、覆盖面更广;登录态、权限、业务日志放在后者里,因为它能直接拿到 Spring 容器中的 Bean 和 Controller 方法上的注解。判断标准只有一条——越贴近 Servlet 容器、越与业务无关的,越往前放。
踩坑清单:
- 忘了排除登录接口。 拦截
/**却不排除/login,用户永远登不进去。 - 用成员变量存请求状态。 它默认是单例,必须走
request.setAttribute()。 - 在
postHandle里改 JSON 响应。 响应已经写出去,改不动,要用ResponseBodyAdvice。 - 多个组件的顺序靠
order()。 注册时调用.order(1),数字越小越早执行preHandle。 - Spring Boot 3 的包名变了。
javax.servlet全部改成了jakarta.servlet,照抄老代码会编译失败。
在 Spring Boot 里注册拦截器的写法完全一致,只是配置类通常放在自动扫描能扫到的包下。想确认自动配置替你做了哪些默认注册,SpringBoot 教程 的 Web 章节讲得比较细。

总结
拦截器就三步:实现 HandlerInterceptor 写业务逻辑,实现 WebMvcConfigurer 在 addInterceptors 里注册,用 addPathPatterns 与 excludePathPatterns 划定作用范围。 三个回调中 preHandle 最重要,它返回 false 请求即中断,记得自己写入响应;afterCompletion 适合做耗时统计和资源清理。
- 它在 Spring MVC 层生效,过滤器在 Servlet 容器层生效,后者范围更大、执行更早;
- 它默认是单例,请求状态只能存在
request里; - 登录校验一定要把登录接口自身排除掉;
- 单机内存防重提交在多实例下失效,正式环境换 Redis。
延伸学习
- Spring 框架配套课程 —— 从 MVC 请求流程讲到拦截器的完整配置。
- Spring Boot 拦截器与过滤器笔记 —— 两者执行顺序与典型坑位的对照整理。
- 在线运行 Java 代码工具 —— 不用配环境,直接编译运行本文的示例代码。
常见问题
Q:拦截器配好了却不生效,最常见的原因是什么?
A:按顺序排查三件事。第一,配置类上有没有 @Configuration,且它所在的包是否被组件扫描覆盖到;第二,addPathPatterns 写的路径和实际请求路径是否匹配,/api/** 拦不住 /api 本身;第三,是不是有另一个配置类也实现了 WebMvcConfigurer 并覆盖掉了你的注册。
Q:preHandle 返回 false 之后,前端到底会收到什么?
A:取决于你在返回 false 之前写了什么。什么都不写,前端收到的是一个默认状态码的空响应,在浏览器里往往表现为"请求成功但没数据",很难定位。正确做法是像第二节那样显式设置状态码和响应体,让前端能明确区分"没登录"和"服务器出错"。
Q:多个拦截器同时存在时,执行顺序是怎样的?
A:preHandle 按注册顺序正序执行,postHandle 和 afterCompletion 按反序执行,整体像夹心饼干。想精确控制顺序,注册时调用 .order(数字),数字越小优先级越高。另外要注意,只要有一个 preHandle 返回 false,后续的都不再执行,但已经执行过的那些的 afterCompletion 仍会被调用。
Q:preHandle 里的 handler 参数有什么用?
A:它是即将执行的那个处理方法的信息封装。如果请求映射到了 Controller 的方法,handler 的实际类型是 HandlerMethod,可以强转后拿到方法对象、方法上的注解、所属类的 Class,从而实现"按注解决定要不要拦截"的权限控制。请求如果指向静态资源,它则是另一种类型,这时通常直接放行。

免费 AI IDE



