spring 拦截器怎么配置?附 3 种使用场景+实战写法

编程狮(w3cschool.cn) 2026-08-31 17:29:39 浏览数 (60)
反馈

Spring 拦截器配置分两步:写一个类实现 HandlerInterceptor,再在一个标注 @Configuration 的配置类里实现 WebMvcConfigurer,重写 addInterceptors 方法把拦截器注册进去,用 addPathPatterns 指定要拦的路径、excludePathPatterns 指定要放行的路径。三步缺一不可,漏掉注册这一步是新手最常犯的错。

很多人知道拦截器能做登录校验,却搞不清三个回调方法分别在什么时候执行、preHandle 返回 false 之后请求去了哪里,更分不清它和过滤器到底有什么区别。今天这篇文章,编程狮就把拦截器的执行时机讲透,再给出登录态校验、接口耗时统计、防重复提交三个真实场景的完整代码,最后给你一张拦截器与过滤器的选型对照表。

Spring 拦截器执行时机示意图

一、拦截器是什么,在请求的哪个环节生效

拦截器是 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/**");
    }
}

addPathPatternsexcludePathPatterns 都支持 ** 通配,拦截规则是"先匹配包含、再剔除排除",所以登录接口这种必须放行的路径,一定要写进排除列表。

一个项目里可以同时注册多个,形成一条链。它们按注册顺序依次执行,任何一个否决都会让后续环节停下,所以"先校验后统计"这类有先后依赖的组合,注册顺序不能乱。

把它注册进来之后,一个请求的完整链路是这样走的:请求进来,先过 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 容器、越与业务无关的,越往前放。

踩坑清单:

  1. 忘了排除登录接口。 拦截 /** 却不排除 /login,用户永远登不进去。
  2. 用成员变量存请求状态。 它默认是单例,必须走 request.setAttribute()
  3. postHandle 里改 JSON 响应。 响应已经写出去,改不动,要用 ResponseBodyAdvice
  4. 多个组件的顺序靠 order() 注册时调用 .order(1),数字越小越早执行 preHandle
  5. Spring Boot 3 的包名变了。 javax.servlet 全部改成了 jakarta.servlet,照抄老代码会编译失败。

在 Spring Boot 里注册拦截器的写法完全一致,只是配置类通常放在自动扫描能扫到的包下。想确认自动配置替你做了哪些默认注册,SpringBoot 教程 的 Web 章节讲得比较细。

Spring 拦截器三个方法的执行时机流程图

总结

拦截器就三步:实现 HandlerInterceptor 写业务逻辑,实现 WebMvcConfigureraddInterceptors 里注册,用 addPathPatternsexcludePathPatterns 划定作用范围。 三个回调中 preHandle 最重要,它返回 false 请求即中断,记得自己写入响应;afterCompletion 适合做耗时统计和资源清理。

  • 它在 Spring MVC 层生效,过滤器在 Servlet 容器层生效,后者范围更大、执行更早;
  • 它默认是单例,请求状态只能存在 request 里;
  • 登录校验一定要把登录接口自身排除掉;
  • 单机内存防重提交在多实例下失效,正式环境换 Redis。

延伸学习

  1. Spring 框架配套课程 —— 从 MVC 请求流程讲到拦截器的完整配置。
  2. Spring Boot 拦截器与过滤器笔记 —— 两者执行顺序与典型坑位的对照整理。
  3. 在线运行 Java 代码工具 —— 不用配环境,直接编译运行本文的示例代码。

常见问题

Q:拦截器配好了却不生效,最常见的原因是什么?

A:按顺序排查三件事。第一,配置类上有没有 @Configuration,且它所在的包是否被组件扫描覆盖到;第二,addPathPatterns 写的路径和实际请求路径是否匹配,/api/** 拦不住 /api 本身;第三,是不是有另一个配置类也实现了 WebMvcConfigurer 并覆盖掉了你的注册。

Q:preHandle 返回 false 之后,前端到底会收到什么?

A:取决于你在返回 false 之前写了什么。什么都不写,前端收到的是一个默认状态码的空响应,在浏览器里往往表现为"请求成功但没数据",很难定位。正确做法是像第二节那样显式设置状态码和响应体,让前端能明确区分"没登录"和"服务器出错"。

Q:多个拦截器同时存在时,执行顺序是怎样的?

A:preHandle 按注册顺序正序执行,postHandleafterCompletion 按反序执行,整体像夹心饼干。想精确控制顺序,注册时调用 .order(数字),数字越小优先级越高。另外要注意,只要有一个 preHandle 返回 false,后续的都不再执行,但已经执行过的那些的 afterCompletion 仍会被调用

Q:preHandle 里的 handler 参数有什么用?

A:它是即将执行的那个处理方法的信息封装。如果请求映射到了 Controller 的方法,handler 的实际类型是 HandlerMethod,可以强转后拿到方法对象、方法上的注解、所属类的 Class,从而实现"按注解决定要不要拦截"的权限控制。请求如果指向静态资源,它则是另一种类型,这时通常直接放行。

0 人点赞