Spring的HandlerInterceptor

结城 SpringBoot 8 次阅读 3899 字 发布于 24 天前 预计阅读时间: 18 分钟


HandlerInterceptor(处理器拦截器),核心作用是在请求到达具体的 Controller(控制器)方法之前或之后,加入自定义的业务逻辑。

例如我们想实现自定的前置处理流程,就可以实现处理器拦截器进行相应的代码实现,例如解析JWT鉴权,黑名单用户请求拦截等。

1.拦截器工作流程与方法

HandlerInterceptor 接口提供了三个核心方法,它们分别在请求生命周期的不同阶段被触发:

客户端请求 ──> preHandle ──(true)──> Controller 方法 ──> postHandle ──> 视图渲染 ──> afterCompletion
                │
             (false)
                └──> 请求被拦截,直接返回

preHandle(前置处理)

触发时机​:在请求到达 Controller 方法之前执行。

返回值:返回一个 boolean 值。

true:放行,请求继续向下传递,去调用下一个拦截器或目标 Controller。

false:拦截,请求终止,后续的拦截器和 Controller 都不会被执行。通常需要在返回 false 前通过 response 打印错误信息或跳转页面。

需要注意的是,如果请求通过,必须要明确返回true,如果返回false,则会熔断请求,后续的后置处理和收尾处理都不会执行,这一点需要注意。

如果需要确保收尾操作执行,可以抛异常,但不能明确返回false

典型场景:登录检查、权限验证、接口防刷、IP 黑名单校验。

我们可以在此处书写自定义的前置处理功能,例如解析TOKEN,因为拦截器的特性所有的请求只要命中拦截器路径都会被前置处理捕获执行,可以确保每次鉴权都会命中。

postHandle(后置处理)

触发时机​:在 Controller 方法成功执行​完毕之后​,但在视图渲染之前执行。

注意点:如果 Controller 抛出了异常,或者使用了 @ResponseBody / @RestController(直接返回 JSON 数据,不经过视图渲染),该方法的某些操作(如修改 ModelAndView)可能不会起作用,只有完成处理无论是否抛出异常都会触发。

典型场景:动态向页面传递公共参数、记录统计数据。

绝大多数场景都不需要后置处理,此处忽略,不过多介绍,仅供了解。

afterCompletion(完成处理)

触发时机​:在​整个请求结束(视图渲染完成后,或者请求处理发生异常后)执行。

注意点:无论是否发生异常,只要对应的 preHandle 返回了 true,该方法就一定会执行。

典型场景:性能监控(计算请求耗时)、清理资源(例如释放 ThreadLocal 中的用户信息,防止内存泄漏)。

清理ThreadLocal这一点非常重要,如果你的项目中使用了ThreadLocal,必须要进行处理。

如果你的应用程序中使用了ThreadLocal,但是没有在此处进行线程ThreadLocal处理,那么会导致ThreadLocal之间的临时变量泄露导致问题。

SringBoot自带的Tomcat默认会启用线程池,例如你在使用线程A的时候向ThreadLocal中放了ID=1,但是由于你未进行清理,线程B进来的时候复用了线程池的线程A,而你未进行清理就会导致线程获取变量的时候直接能获取到ID=1,导致程序出现BUG

如果你在程序中使用了ThreadLocal,务必需要在此进行处理,确保每次线程调用完成后都能够删除掉ThreadLocal内存储的内容,确保内存安全,线程之间数据不会互相污染。

2.代码实现

在 Spring Boot 中使用拦截器,通常需要两步:定义拦截器 和 ​注册拦截器

2.1编写自定义拦截器

实现 HandlerInterceptor 接口,并重写你需要的方法。

这个示例重写了所有三个方法,包括拦截器开始,结束,释放收尾。

开始是线程进入时便会开始拦截处理,这个时候还没有处理业务代码,拦截器最先生效。

结束的时候,代表所有的Controller方法执行完毕,也就是你的所有核心业务代码已经执行完成,准备执行最后的拦截器收尾。

释放收尾函数代表所有的代码执行完毕,调用最后的收尾释放工作,当这个方法执行完毕后,此拦截器所有的业务代码执行完成。

只要拦截器返回了true,最后执行释放的收尾函数必定会执行,代码是否出现抛出异常的报错都会生效。

这个机制确保了每次调用之后,无论是否失败都能够完成后置收尾操作,在这里就保证线程安全。

如果拦截器返回了false,Spring会认为拦截器都没有进入便被直接拒绝,所以不会执行收尾函数。

如果需要确保收尾操作执行,可以抛异常,但不能明确返回false

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

public class MyCustomInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        System.out.println(">>> [preHandle] 拦截器生效,正在检查请求..." + request.getRequestURI());

        // 示例:简单的 Token 校验
        String token = request.getHeader("Authorization");
        if (token == null || token.isEmpty()) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("Unauthorized: Missing Token");
            return false; // 拦截请求
        }
        return true; // 放行
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        System.out.println(">>> [postHandle] Controller 方法执行完毕");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        System.out.println(">>> [afterCompletion] 请求完全结束,清理资源");
    }
}

下面是一个实际开发过程中使用的代码。

实际开发过程中,使用的代码可能只重写部分接口,并非完全使用,例如这个代码就没有拦截器结束的函数,只有开始和最后释放的代码重写。

在进入的时候进行JWT的TOKEN校验,然后将TOKEN写入到线程独立的存储空间中,同时保证结束后清空确保线程安全不会串数据。

这里使用的方式是抛异常,确保了收尾操作一定会执行。

@Component
@Slf4j
public class JwtTokenUserInterceptor implements HandlerInterceptor {

    private final JwtProperties jwtProperties;

    @Autowired
    public JwtTokenUserInterceptor(JwtProperties jwtProperties) {
        this.jwtProperties = jwtProperties;
    }

    // 自定义拦截器
    // 校验用户JWT令牌,在业务处理器处理请求之前被调用
    @Override
    public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) {
        //判断当前拦截到的是Controller的方法还是其他资源
        if (!(handler instanceof HandlerMethod)) {
            //当前拦截到的不是动态方法,直接放行
            return true;
        }

        //1、从请求头中获取令牌
        String token = request.getHeader(jwtProperties.getUserTokenName());

        //2、校验令牌
        try {
            log.info("用户端JWT校验: {}", token);
            Claims claims = JwtUtil.parseJwt(jwtProperties.getUserSecretKey(), token);
            Long userId = Long.valueOf(claims.get(JwtClaimsConstant.USER_ID).toString());
            log.info("当前用户uid: {}", userId);
            BaseContext.setCurrentId(userId);
            //3、通过,放行
            return true;
        } catch (Exception ex) {
           // 4、不通过,响应401状态码
            log.warn("用户端JWT校验失败: {}", ex.getMessage());
            throw new UserNotLoginException(MessageEnum.USER_NOT_LOGIN.getMessage());
        }
    }

    // 此处用于清除ThreadLocal
    // afterCompletion是Spring拦截器的回调方法,在请求处理完成后一定会被调用(即使发生异常)
    @Override
    public void afterCompletion(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler, Exception ex) {
        //清空ThreadLocal
        BaseContext.removeCurrentId();
    }
}

2.2将拦截器注册到配置中

将拦截器注册到配置中,当检测到命中指定请求后,拦截器便会生效。

也可以指定放行特定的路径,放行特定的路径后,拦截器不会在此路径上生效。

实现 WebMvcConfigurer 接口,指定拦截的 URL 规则,这是一个示例。

/**表示通配符,包含所有的路径。

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 MyCustomInterceptor())
                .addPathPatterns("/**")             // 拦截所有请求
                .excludePathPatterns("/login", "/register", "/static/**"); // 放行特定路径
    }
}

实际实现的代码可以是这样,配置了跨域,消息转换器和自定义拦截器结合。

@Configuration
@Slf4j
@RequiredArgsConstructor
public class WebMvcConfiguration implements WebMvcConfigurer {

    private final JwtTokenUserInterceptor jwtTokenUserInterceptor;

    /**
     * 配置跨域请求
     */
    @Override
    public void addCorsMappings(@NotNull CorsRegistry registry) {
        log.info("跨域配置初始化...");
        registry.addMapping("/**")  // 允许所有路径
                .allowedOriginPatterns("http://localhost:5173", // 允许本地IDE开发环境
                .allowedMethods("GET", "POST", "PUT", "DELETE")  // 允许的HTTP方法
                .allowedHeaders("*")  // 允许所有请求头
                .allowCredentials(true)  // 允许携带凭证(如Cookie、Authorization等)
                .maxAge(3600);  // 预检请求的缓存时间(秒)
        log.info("跨域配置初始化完成");
    }

    /**
     * 注册自定义拦截器
     */
    @Override
    public void addInterceptors(@NotNull InterceptorRegistry registry) {
        log.info("自定义拦截器初始化...");
        registry.addInterceptor(jwtTokenUserInterceptor)
                .addPathPatterns("/api/**")
                .addPathPatterns("/user/**")
                .addPathPatterns("/system/**")
                .excludePathPatterns("/robots.txt")
                .excludePathPatterns("/sitemap.xml")
                .excludePathPatterns("/favicon.ico")
                .excludePathPatterns("/status")
                .excludePathPatterns("/");
    }

    @Override
    public void extendMessageConverters(@NotNull List<HttpMessageConverter<?>> converters) {
        log.info("消息转换器初始化...");
        //创建消息转换器对象
        MappingJackson2HttpMessageConverter messageConverter = new MappingJackson2HttpMessageConverter();
        //设置对象转换器,底层使用Jackson将Java对象转为json
        messageConverter.setObjectMapper(new JacksonObjectMapper());
        //将上面的消息转换器对象追加到mvc框架的转换器集合中
        converters.add(1, messageConverter);
    }
}

3.Interceptor vs Filter

列表对比一下,常见拦截器与过滤器这两个组件使用,它们的核心区别如下:

特性Filter (过滤器)Interceptor (拦截器)
出身与依赖属于 Servlet 规范,依赖于外部 Servlet 容器(如 Tomcat)。属于Spring MVC 框架,由 Spring 容器管理。
在架构中的位置在 DispatcherServlet 之前执行,颗粒度较粗。在 DispatcherServlet 之后,Controller 之前执行。
Spring Bean 注入默认不支持直接 @Autowired 注入 Spring 组件(需要特殊配置)。原生支持 @Autowired 注入。
核心能力可以通过 FilterChain 甚至修改/替换核心的 request 和 response。可以获取到当前请求对应的 Controller 方法对象 (HandlerMethod),从而进行方法级别的权限精细化控制。

简单总结一下。

如果是跟系统环境、原生协议相关的(如跨域设置 CORS、字符编码 Filter、全局敏感词过滤),用 Filter;

如果是跟业务逻辑、权限权限、Spring 内部组件紧密相关的,用 Interceptor。

4.拦截器链

当项目中存在多个拦截器时,Spring MVC 会将它们组织成一个拦截器链(Interceptor Chain)。

配置多个拦截器其实非常简单,只需要在配置类中按顺序多次调用 addInterceptor 即可。

配置方式类似于SpringAOP,对拦截器配置order属性指定相应的执行顺序,显式指定调用顺序。

4.1.代码配置方式

只需要在实现 WebMvcConfigurer 的配置类中,按照希望的前置顺序依次注册它们:

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 1. 注册日志拦截器(最外层,负责记录所有请求日志)
        registry.addInterceptor(new LogInterceptor())
                .addPathPatterns("/**")
                .order(1); // 显式指定顺序,数字越小越先执行

        // 2. 注册登录身份验证拦截器
        registry.addInterceptor(new AuthInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/login", "/register")
                .order(2);

        // 3. 注册接口限流/防刷拦截器(最内层)
        registry.addInterceptor(new RateLimitInterceptor())
                .addPathPatterns("/api/**")
                .order(3);
    }
}

对于执行顺序的指定,存在默认顺序和显式指定的方式决定

  • 默认顺序:如果你不写 order()​,Spring 会严格按照你在代码中 addInterceptor 的先后编写顺序来决定谁先执行。遵循流式调用逻辑,代码从上到下依次执行。
  • 显式顺序:推荐使用 order(int),数字越小,优先级越高(即 preHandle 越先执行)。

4.2.多拦截器的洋葱模型

多个拦截器的执行逻辑类似于洋葱模型,遵循先进后出原则,不存在层级跨越问题。

就像洋葱一样,最外层的一层皮剥开后才能见到内层的东西,同理,执行完毕后,最内层的也需要逐渐向外才能够到达最外层。

假设我们有拦截器 A(order=1)和拦截器 B(order=2)。

在全部放行,即拦截器都返回true的情况下,执行顺序如下:

[请求进来] ──> A.preHandle ──> B.preHandle ──> Controller 方法
                                                    │
[请求出去] <── A.afterCompletion <── B.afterCompletion <── B.postHandle <── A.postHandle
  • preHandle:正序执行(A -> B)。先配置的先关卡拦截,因为是洋葱最外层。
  • postHandle:逆序执行(B -> A)。Controller 执行完后,后进来的拦截器先处理,此时已经是洋葱最内层,开始由内向外执行。
  • afterCompletion:逆序执行(B -> A)。整个请求结束清理资源时,同样是后进来的先清理,收尾任务可以认为是最后的洋葱皮,当需要结束的时候才会执行。

4.3.拦截器熔断机制

拦截器具备熔断机制,当一个拦截器不通过后,之后的所有拦截器都会失效,并且会调用收尾方法,释放之前拦截器占用的所有私有资源。

只要有一个拦截器的 preHandle​ 返回了 false​,后续的 Controller​ 和所有其他拦截器的 preHandle / postHandle 都会被终止。

但是,在此之前已经返回了 true​ 的拦截器的 afterCompletion 依然会被触发。

洋葱模型是逐渐向内穿透的,当遇到一层无法穿透后,便会中止执行,而Controller​包裹在最内层,故只要有一个拦截器返回false​,之后的所有Controller​,以及所有的preHandle / postHandle都不会执行。而之前通过的拦截器由于是正常执行完毕,所有会执行收尾方法。

4.4.拦截器链场景分析

假设我们有拦截器 A 和拦截器 B,现在都实现重写了拦截器的所有方法。

假设执行顺序是 A.preHandle -> B.preHandle:

  • 情况一:A 返回了 false
  • 请求在 A 处直接被截断。
  • B 拦截器的所有方法和 Controller 完全不会被触发。
  • A 自己的 postHandle​ 和 afterCompletion 也不会执行。
  • 情况二:A 返回了 true,B 返回了 false
  • A.preHandle 执行(返回 true)。
  • B.preHandle 执行(返回 false,请求在这里被截断)。
  • 按照洋葱层次执行顺序,Controller​、 B.postHandle​、 A.postHandle​、 B.afterCompletion都不会执行。
  • 唯一会被额外触发的是 A.afterCompletion​。因为 A 在前面放行了,它必须清理自己可能在 ThreadLocal 里注册的资源,所以必须要执行收尾操作。

总结概括一下。

通过的拦截器,收尾方法必定执行,是否执行完成方法看后续拦截器状态。

不通过被截断的拦截器,所有方法都不会执行。

5.补充说明

尽量避免全局 /​ 避免拦截所有路径。比如限流拦截器,只拦截 /api/​ 即可,静态资源(如 /static/)如果也走拦截器,会额外消耗系统性能。

使用特定路径过滤避免全局拦截 / 可缓解拦截器压力。

在上面的拦截器链例子中,我们在 AuthInterceptor​ 的 preHandle​ 中往 ThreadLocal​ 存了用户信息,务必在 afterCompletion​ 中调用 ThreadLocal.remove()

由于熔断机制,即使后面的拦截器挂了,已通过拦截器的 afterCompletion 也会执行,确保了内存安全,线程之间数据不会异常互通。

给时光以生命,给岁月以文明
最后更新于 2026-06-15