《Spring Boot 入门》11.3 拦截器与过滤器

每个接口都要记日志、查登录,写进 Controller 太啰嗦。本节对比 Filter(Servlet 规范)与 HandlerInterceptor(Spring MVC)的执行时机,用真实日志证明 Filter → Interceptor → Controller 的顺序,演示两种 Filter 注册方式与拦截器路径匹配,并完整实现请求 ID 贯穿与登录校验。

本节目标:分清 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。

维度FilterHandlerInterceptor
规范归属Servlet 规范Spring MVC
管理方Servlet 容器DispatcherServlet
作用范围所有请求(含非 Spring 路径、静态资源)仅进入 DispatcherServlet 的请求
能否拿到 handler否能(HandlerMethod)
典型用途编码、请求 ID、MDC、CORS、压缩、全局日志登录校验、权限、参数预处理、ThreadLocal 上下文
注册方式@Component+@Order 或 FilterRegistrationBeanWebMvcConfigurer#addInterceptors

11.3.2 执行时机对比

把两套机制放进同一条请求链路,执行顺序如下(单拦截器情形):

顺序组件方法说明
1FilterdoFilter 前置进入 DispatcherServlet 之前
2DispatcherServletdoDispatch找到 handler 与拦截器链
3InterceptorpreHandle返回 false 立即短路
4Controller业务方法真正的接口逻辑
5InterceptorpostHandle逆序执行
6InterceptorafterCompletion逆序执行,即使抛异常也会调用
7FilterdoFilter 后置回到 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 返回 truepreHandle 返回 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 数据源与连接配置 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计