本节目标:分清 Filter 与 HandlerInterceptor 的定位与执行时机,掌握各自的注册方式与路径匹配,理解 preHandle 返回 false 后的行为与异步请求下的差异,并完成一个「请求 ID 贯穿 + 登录校验」的可用实现。
适用版本:Spring Boot 4.1.x(Java 21)
11.3 拦截器与过滤器
到这里,图书服务有了静态页面、配好了 CORS,接口本身也能正常响应。但还有一类需求,它和具体接口无关,却几乎每个接口都要:
- 记日志:每个请求的方法、路径、耗时、响应状态都要打出来。
- 埋 traceId:给每个请求分配一个 ID,写进所有日志行,出问题时能一把捞出全链路。
- 校验登录:除了登录接口,其余接口都要检查令牌,没登录直接返回 401。
把这些逻辑抄进每个 Controller 方法里显然荒谬。我们需要一个「请求进入业务代码之前、响应返回之后」的统一切入点。Spring 体系里有两套机制干这件事:Filter 和 HandlerInterceptor。它们的名字常被混着叫「拦截器」,但工作在不同的层次,理解这个差异是本节的核心。
11.3.1 两者分属两个世界
- Filter 是 Servlet 规范的一部分,由 Servlet 容器(Tomcat)管理,处在
DispatcherServlet之外。它只知道「请求」和「响应」,不知道 Spring MVC 的 handler、Controller、方法参数。 - HandlerInterceptor 是 Spring MVC的一部分,由
DispatcherServlet调用,处在DispatcherServlet之内。它能拿到即将执行的HandlerMethod,知道要调用哪个类的哪个方法。
一句话记忆:Filter 在门口,Interceptor 在屋里。请求要先穿过 Filter,才能进到 DispatcherServlet,再由 DispatcherServlet 依次调用 Interceptor,最后才到 Controller。
| 维度 | Filter | HandlerInterceptor |
|---|---|---|
| 规范归属 | Servlet 规范 | Spring MVC |
| 管理方 | Servlet 容器 | DispatcherServlet |
| 作用范围 | 所有请求(含非 Spring 路径、静态资源) | 仅进入 DispatcherServlet 的请求 |
| 能否拿到 handler | 否 | 能(HandlerMethod) |
| 典型用途 | 编码、请求 ID、MDC、CORS、压缩、全局日志 | 登录校验、权限、参数预处理、ThreadLocal 上下文 |
| 注册方式 | @Component+@Order 或 FilterRegistrationBean | WebMvcConfigurer#addInterceptors |
11.3.2 执行时机对比
把两套机制放进同一条请求链路,执行顺序如下(单拦截器情形):
| 顺序 | 组件 | 方法 | 说明 |
|---|---|---|---|
| 1 | Filter | doFilter 前置 | 进入 DispatcherServlet 之前 |
| 2 | DispatcherServlet | doDispatch | 找到 handler 与拦截器链 |
| 3 | Interceptor | preHandle | 返回 false 立即短路 |
| 4 | Controller | 业务方法 | 真正的接口逻辑 |
| 5 | Interceptor | postHandle | 逆序执行 |
| 6 | Interceptor | afterCompletion | 逆序执行,即使抛异常也会调用 |
| 7 | Filter | doFilter 后置 | 回到 Filter,继续 chain 之后的代码 |
几个必须记住的细节:
- 多个拦截器时,
preHandle按注册顺序执行,postHandle与afterCompletion按注册顺序的逆序执行(像栈一样先进后出)。 afterCompletion在请求抛出异常时也会执行,是清理资源的正确位置;postHandle在抛异常时不会执行。- Filter 的
doFilter是一个包裹结构:chain.doFilter()前后即「前置/后置」,后置代码靠try/finally在异常时也能执行。
11.3.3 执行顺序实测
空口无凭,用一个最小实验验证:注册一个 Filter、一个 Interceptor,再在 Controller 方法里打一行日志(三者的完整代码就是 11.3.6 与 11.3.7 的简化版)。访问 /api/books,实测日志顺序如下:
2026-10-09T15:43:10.001+08:00 INFO 43496 --- [nio-8080-exec-1] c.e.library.filter.TraceFilter : [filter] before /api/books
2026-10-09T15:43:10.002+08:00 INFO 43496 --- [nio-8080-exec-1] c.e.l.interceptor.TraceInterceptor : [interceptor] preHandle /api/books
2026-10-09T15:43:10.003+08:00 INFO 43496 --- [nio-8080-exec-1] c.e.library.web.BookController : [controller] list
2026-10-09T15:43:10.005+08:00 INFO 43496 --- [nio-8080-exec-1] c.e.l.interceptor.TraceInterceptor : [interceptor] postHandle /api/books
2026-10-09T15:43:10.006+08:00 INFO 43496 --- [nio-8080-exec-1] c.e.l.interceptor.TraceInterceptor : [interceptor] afterCompletion /api/books
2026-10-09T15:43:10.007+08:00 INFO 43496 --- [nio-8080-exec-1] c.e.library.filter.TraceFilter : [filter] after /api/books
顺序与 11.3.2 的表格完全一致:Filter → Interceptor → Controller → Interceptor → Filter。
11.3.4 注册 Filter 的两种方式
方式一:@Component + @Order——给 Filter 类标上 @Component 与 @Order(Ordered.HIGHEST_PRECEDENCE),Spring Boot 会自动把它注册到 Servlet 容器,@Order 决定多个 Filter 之间的先后。优点是最省事;缺点是无法细粒度控制 URL 模式,默认对所有路径生效。
方式二:FilterRegistrationBean
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<TraceFilter> traceFilterRegistration(TraceFilter filter) {
FilterRegistrationBean<TraceFilter> registration = new FilterRegistrationBean<>(filter);
registration.addUrlPatterns("/*");
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
registration.setName("traceFilter");
return registration;
}
}
FilterRegistrationBean 能精确指定 urlPatterns、order、initParameters、dispatcherTypes。
重要坑:
TraceFilter若同时标了@Component又用FilterRegistrationBean注册,它会被注册两次,日志打两遍、MDC 设两次。两种方式只能选一种。用FilterRegistrationBean时,把 Filter 类上的@Component去掉,只在@Bean方法里接收它即可。
另外,继承 OncePerRequestFilter 而不是直接实现 Filter,可以保证「一次请求只执行一次」——它默认对 ASYNC 分发不再执行(shouldNotFilterAsyncDispatch() 返回 true),避免异步请求下重复埋点。
11.3.5 注册 Interceptor
Interceptor 通过 WebMvcConfigurer#addInterceptors 注册,并用 addPathPatterns / excludePathPatterns 精确圈定范围:
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
public WebConfig(AuthInterceptor authInterceptor) {
this.authInterceptor = authInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**") // 只拦 API
.excludePathPatterns( // 排除登录与公开接口
"/api/auth/login",
"/api/auth/register",
"/api/public/**");
}
}
路径匹配规则:
addPathPatterns是「白名单」:不写就默认/**,拦全部;excludePathPatterns优先于它,排除的路径不会被拦。- 模式用 Ant 风格:
/api/**匹配/api下的任意层级,/api/*只匹配一层;addPathPatterns("/api/**")只圈 API,静态资源天然不受影响。
11.3.6 用 Filter 做请求日志与 MDC 埋点
请求 ID 的最佳位置是 Filter,因为它最早介入,能覆盖包括静态资源、404 在内的所有请求。配合 SLF4J 的 MDC,可以让每行日志自动带上这个 ID。
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestIdFilter extends OncePerRequestFilter {
public static final String MDC_KEY = "requestId";
public static final String HEADER = "X-Request-Id";
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String requestId = request.getHeader(HEADER);
if (requestId == null || requestId.isBlank()) {
requestId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
MDC.put(MDC_KEY, requestId);
response.setHeader(HEADER, requestId); // 回写,方便前端/网关串联
long start = System.currentTimeMillis();
try {
chain.doFilter(request, response);
} finally {
long cost = System.currentTimeMillis() - start;
log.info("{} {} -> {} ({} ms)",
request.getMethod(), request.getRequestURI(),
response.getStatus(), cost);
MDC.remove(MDC_KEY); // 必须清理,线程池复用否则会串号
}
}
}
配套的日志格式里加上 %X{requestId}(例如把 logging.pattern.console 配成 %d{HH:mm:ss.SSS} [%X{requestId}] %-5level %logger{36} - %msg%n)。之后所有日志行都会自动带上 [8f2a1c9d4e7b3a05] 这样的 ID,排查问题时按 ID 过滤即可捞出整条链路。
MDC 的两个坑:一是必须在
finally里MDC.remove,因为 Tomcat 线程是复用的,不清理会让下一个请求串上上一个的 ID;二是 MDC 基于 ThreadLocal,异步线程里拿不到,异步场景需要在切线程时手动传递(属于进阶内容)。
11.3.7 用 Interceptor 做登录校验
登录校验适合放在 Interceptor,因为需要知道「这次请求映射到了哪个方法」,以便放行某些特殊方法。核心是 preHandle:
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
@Component
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 IOException {
// 1. 预检请求直接放行,否则浏览器预检会被 401 拦住
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
return true;
}
// 2. 非 Controller 方法(如静态资源)放行
if (!(handler instanceof HandlerMethod)) {
return true;
}
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
return reject(response, "缺少访问令牌");
}
Long userId = tokenService.verify(header.substring(7));
if (userId == null) {
return reject(response, "令牌无效或已过期");
}
UserContext.set(userId); // 供 Controller 读取当前用户
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
UserContext.clear(); // 与 MDC 同理,ThreadLocal 必须清理
}
private boolean reject(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":401,\"message\":\"" + message + "\"}");
return false;
}
}
配套的 UserContext 用 ThreadLocal 保存当前用户:
public final class UserContext {
private static final ThreadLocal<Long> CURRENT = new ThreadLocal<>();
private UserContext() {
}
public static void set(Long userId) { CURRENT.set(userId); }
public static Long get() { return CURRENT.get(); }
public static void clear() { CURRENT.remove(); }
}
Controller 里就能直接调用 UserContext.get() 拿到当前用户 id,无需再解析令牌。
11.3.8 preHandle 返回 false 之后发生了什么
这是最容易想当然的一环。看 DispatcherServlet 的真实逻辑:preHandle 返回 false 时,框架会调用 triggerAfterCompletion,从当前拦截器往前(含)依次执行已通过 preHandle 的那些拦截器的 afterCompletion,然后直接结束请求。
归纳成表:
| 阶段 | preHandle 返回 true | preHandle 返回 false |
|---|---|---|
| 后续拦截器 | 继续执行 | 不执行 |
| Controller | 执行 | 不执行 |
postHandle | 执行 | 不执行 |
afterCompletion | 执行 | 只对已通过 preHandle 的拦截器执行 |
两个直接推论:
- 想在「拒绝请求」时清理资源,不能依赖
postHandle,要么在返回false前手动清理,要么放到afterCompletion(但注意afterCompletion只对已通过的拦截器调用)。 preHandle返回false时必须自己写好响应(状态码 + body),否则浏览器收到一个空白的 200 或 401,前端无从判断。
11.3.9 异步请求下的差异
当 Controller 返回 Callable、DeferredResult 或 CompletableFuture 时,请求进入异步模式,拦截器的行为发生变化:
- 初次分发:
preHandle执行 → handler 启动异步 → 不调用postHandle和afterCompletion,改为调用AsyncHandlerInterceptor#afterConcurrentHandlingStarted(常用于清理线程绑定属性),随后释放容器线程。 - 异步结果就绪后,容器发起异步分发,请求再次进入
DispatcherServlet,此时会再次调用preHandle,然后才是postHandle和afterCompletion。
也就是说:异步请求下 preHandle 会执行两次(一次 REQUEST 分发、一次 ASYNC 分发)。可以通过判断 request.getDispatcherType() 是 REQUEST 还是 ASYNC 来区分。
实践建议:需要感知异步生命周期时实现 AsyncHandlerInterceptor 而非 HandlerInterceptor;把「只该执行一次」的逻辑放进 OncePerRequestFilter(默认不处理 ASYNC 分发)而不是 preHandle;异步请求超时或网络出错时容器不会发起异步分发,postHandle / afterCompletion 都不会执行。
11.3.10 完整实现:请求 ID 贯穿 + 登录校验
把本节两块拼起来:RequestIdFilter(11.3.6)用 @Order(Ordered.HIGHEST_PRECEDENCE) 保证最先执行,负责生成 ID、写 MDC、回写响应头、记录耗时;AuthInterceptor(11.3.7)负责登录校验与 UserContext 维护,在 WebConfig 里用 11.3.5 的方式注册到 /api/** 并排除 /api/auth/**;日志格式里加入 %X{requestId}。此后一次请求的日志形如:
15:43:10.002 [8f2a1c9d4e7b3a05] INFO c.e.l.interceptor.AuthInterceptor - preHandle /api/books
15:43:10.003 [8f2a1c9d4e7b3a05] INFO c.e.library.web.BookController - 查询图书列表 userId=42
15:43:10.007 [8f2a1c9d4e7b3a05] INFO c.e.library.filter.RequestIdFilter - GET /api/books -> 200 (6 ms)
整条链路共用一个 ID:Filter 负责横切日志与埋点,Interceptor 负责业务前置校验,职责清晰、互不打架。
11.3.11 常见坑速查
| 坑 | 现象 | 解决 |
|---|---|---|
Filter 既 @Component 又 FilterRegistrationBean | 日志打两遍 | 只保留一种注册方式 |
excludePathPatterns 写漏登录接口 | 登录接口自己返回 401 | 把 /api/auth/** 排除 |
拦截器没放行 OPTIONS | 浏览器预检失败、跨域报错 | preHandle 里对 OPTIONS 返回 true |
preHandle 返回 false 但没写响应 | 前端收到空白响应 | 手动设置状态码与 body |
ThreadLocal / MDC 未清理 | 线程复用导致数据串号 | 在 finally 或 afterCompletion 清理 |
异步接口里 preHandle 执行两次 | 计数翻倍、日志重复 | 用 getDispatcherType() 区分,或改用 Filter 埋点 |
小结
- Filter 属于 Servlet 规范、在 DispatcherServlet 之外;Interceptor 属于 Spring MVC、在 DispatcherServlet 之内,前者拿不到 handler,后者能拿到
HandlerMethod。 - 执行顺序固定为 Filter → Interceptor#preHandle → Controller → postHandle → afterCompletion → Filter;多拦截器时
postHandle与afterCompletion逆序执行。 - Filter 两种注册方式(
@Component+@Order、FilterRegistrationBean)只能选一种,否则重复注册;OncePerRequestFilter默认不处理 ASYNC 分发。 - Interceptor 用
WebMvcConfigurer#addInterceptors注册,addPathPatterns圈范围、excludePathPatterns排例外。 - Filter 适合请求日志与 MDC 埋点(最早介入、覆盖全量请求),Interceptor 适合登录校验与权限(能识别 handler、可精确排除路径)。
preHandle返回false会短路:Controller、postHandle都不执行,只对已通过的拦截器调用afterCompletion,且必须自己写好拒绝响应。- 异步请求下
preHandle会执行两次,postHandle/afterCompletion在异步分发时才调用;超时或出错时二者都不会执行。
至此,Web 层的外围能力——静态资源、跨域、请求拦截——已经齐备。从下一章开始,我们进入数据访问层,先配置数据源与连接池,把图书真正持久化到数据库里。
阅读导航:上一节:11.2 CORS 跨域 · 下一节:12.1 数据源与连接配置 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。