1. 登录拦截器到底在解决什么问题
做后端接口开发,绕不开一个最基础也最容易被忽视的环节:怎么判断当前发请求的人到底有没有登录。刚开始写接口的时候,很多人的做法是在每个需要登录的Controller方法里手动写一段校验逻辑,从请求头或者Session里取用户标识,取不到就返回未登录。接口少的时候还能忍,一旦接口数量上到几十个,这种写法就是灾难——重复代码满天飞,改一处校验规则要翻遍所有文件,漏掉一个接口就是安全漏洞。
登录拦截器要解决的就是这个痛点。它的核心思路是把“判断用户是否登录”这件事从业务代码里抽出来,放到一个统一的入口去处理。请求进来之后,先经过拦截器,拦截器判断这个请求需不需要登录、当前用户有没有登录,符合条件就放行到Controller,不符合条件就直接拦下来返回统一的结果。业务代码里再也不需要关心登录校验,专注写业务逻辑就行。
这篇文章适合正在学SpringBoot、已经能写出基本增删改查接口、但对“如何优雅地做权限校验”还没有头绪的开发者。我会从拦截器的整体设计思路讲起,把注册方式、路径匹配规则、Session与Token两种场景下的实现差异、以及实际开发中踩过的坑全部拆开讲清楚。看完之后你应该能直接把这套方案搬到自己项目里,并且知道每个配置项为什么这么写。
需要提前说明的是,拦截器方案主要针对单体应用或者基于Session的传统Web应用。如果你的项目是前后端完全分离、用JWT做无状态认证的架构,拦截器的角色会有所不同,这个后面会专门讲。两种场景我都会覆盖,你可以根据自己的技术栈对号入座。
2. 拦截器的整体设计与选型考量
2.1 为什么是拦截器而不是过滤器
SpringBoot里能做请求拦截的组件不止一个,最常被拿来对比的是Filter(过滤器)和Interceptor(拦截器)。很多人一开始会困惑:这俩看起来都能拦请求,到底该用哪个?
从执行时机上看,Filter是Servlet规范层面的东西,它在请求进入DispatcherServlet之前就会执行,拿到的参数是ServletRequest和ServletResponse,对Spring的容器和Bean几乎无感知。Interceptor是SpringMVC体系里的组件,它在DispatcherServlet之后、Controller之前执行,能拿到HandlerMethod,也就是能知道当前请求要进哪个类的哪个方法,还能访问Spring容器里的Bean。
这个差异决定了选型。登录校验这件事,往往需要查询数据库或者缓存来判断用户身份,也就是需要用到Spring容器里注入的Service。用Interceptor可以直接注入依赖,用Filter就得想别的办法去拿Bean,写起来别扭。另外Interceptor能精确知道目标方法,方便做更细粒度的控制,比如某个方法加了自定义注解就跳过校验。所以登录校验这个场景,Interceptor是更自然的选择。
Filter也不是没用,它更适合做那些和Spring无关的通用处理,比如请求日志、字符编码设置、跨域处理。实际项目里两者经常共存,各干各的活。
2.2 拦截器的三大核心方法
实现一个拦截器,本质就是实现HandlerInterceptor接口,这个接口有三个方法,理解它们各自的执行时机是写好拦截器的前提。
preHandle在Controller方法执行之前调用,返回值是boolean。返回true表示放行,请求继续往下走;返回false表示拦截,后续的Controller和后面的拦截器都不会执行。登录校验的核心逻辑就写在这里,这也是三个方法里唯一一个必须重点掌握的。
postHandle在Controller方法执行完之后、视图渲染之前调用。注意,如果Controller抛了异常,这个方法不会执行。它一般用来往ModelAndView里塞一些公共数据,在前后端分离的项目里基本用不上。
afterCompletion在整个请求完成之后调用,也就是视图渲染完毕之后。它的特点是不管请求成功还是抛异常都会执行,所以适合做资源清理、耗时统计这类收尾工作。它有个参数叫ex,如果请求过程中抛了异常,这个参数就是异常对象,可以用来记录错误日志。
三个方法的执行顺序可以这样记:preHandle按拦截器注册顺序正序执行,postHandle和afterCompletion按注册顺序倒序执行。这个顺序在配置多个拦截器的时候很关键,后面会细说。
2.3 拦截器与过滤器的执行链路对比
为了让你对执行顺序有个直观印象,我把一个请求从进入到返回经过的主要节点列一下:
| 阶段 | 执行组件 | 说明 |
|---|---|---|
| 1 | Filter | Servlet容器层面,最先执行 |
| 2 | DispatcherServlet | SpringMVC核心调度器 |
| 3 | Interceptor.preHandle | 拦截器前置处理,登录校验在这里 |
| 4 | Controller方法 | 真正的业务逻辑 |
| 5 | Interceptor.postHandle | 业务执行完,视图渲染前 |
| 6 | 视图渲染 | 前后端分离时通常无此步 |
| 7 | Interceptor.afterCompletion | 请求收尾,异常也会执行 |
| 8 | Filter收尾 | 回到Filter的后置逻辑 |
看清楚这条链路,你就明白为什么登录校验放preHandle最合适——它在业务逻辑之前,又能访问Spring的Bean,位置刚刚好。
3. 核心实现细节与实操要点
3.1 拦截器类的完整实现
先看一个基于Session的登录拦截器怎么写。假设登录成功后我们把用户信息存到了Session里,key叫loginUser。
public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求,否则跨域场景下OPTIONS请求会被拦 if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } HttpSession session = request.getSession(); Object loginUser = session.getAttribute("loginUser"); if (loginUser == null) { // 未登录,返回统一格式的JSON response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write("{\"code\":401,\"msg\":\"未登录或登录已过期\"}"); return false; } return true; } }这段代码有几个细节值得展开说。
第一,HttpMethod.OPTIONS.matches这个判断。在前后端分离、跨域请求的场景下,浏览器会先发一个OPTIONS预检请求,这个请求是不带Cookie和自定义Header的。如果不放行它,预检就失败,真正的请求根本发不出去。我见过不少人被这个问题卡半天,明明登录了却一直提示未登录,最后发现是OPTIONS被拦了。
第二,返回未登录信息时,一定要设置response.setStatus和Content-Type。不设状态码,前端可能拿到200以为成功了;不设Content-Type,中文可能乱码。用response.getWriter().write直接写JSON字符串是最简单的做法,也可以用Jackson的ObjectMapper把对象序列化后写出去,更规范。
第三,request.getSession()默认会创建Session。如果只是想读取已有的Session、不想创建新的,应该用request.getSession(false),返回null说明没有Session。这个区别在高并发场景下有意义,避免为未登录请求白白创建Session占用内存。
3.2 拦截器的注册与路径匹配
写完拦截器类还不算完,必须注册到SpringMVC里才会生效。在SpringBoot中通常用一个配置类实现WebMvcConfigurer接口。
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns( "/user/login", "/user/register", "/error", "/static/**", "/favicon.ico" ); } }这里的关键是路径匹配规则。addPathPatterns("/**")表示拦截所有请求,excludePathPatterns列出不需要拦截的路径。这个“全拦再排除”的思路比“只拦需要的”更安全,因为新增接口时默认就是受保护的,不会因为忘记加拦截而留下漏洞。
路径匹配用的是Ant风格的通配符,规则要记清楚:
| 通配符 | 含义 | 示例 |
|---|---|---|
? | 匹配任意单个字符 | /user/?匹配/user/a |
* | 匹配任意字符,但不跨路径层级 | /user/*匹配/user/list,不匹配/user/a/b |
** | 匹配任意字符,可跨路径层级 | /user/**匹配/user/a/b/c |
踩过的坑:/static/**这种写法能匹配/static/css/a.css,但如果你写成/static/*就只能匹配一层。静态资源通常有多层目录,一定要用**。
还有一个容易忽略的点:/error路径必须排除。SpringBoot在处理异常时会转发到/error,如果这个路径被拦截,一旦业务抛异常,用户看到的不是错误信息而是“未登录”,排查起来非常误导人。
3.3 拦截器里注入Bean的正确姿势
上面的例子里拦截器是用new LoginInterceptor()手动创建的,这种方式下拦截器里的依赖注入不会生效。如果你需要在拦截器里用RedisTemplate、UserService这些Bean,就必须把拦截器也交给Spring管理。
正确做法是给拦截器类加上@Component注解,然后在配置类里注入进来:
@Component public class LoginInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redisTemplate; @Autowired private UserService userService; // ... preHandle里就能正常使用这些Bean了 }@Configuration public class WebConfig implements WebMvcConfigurer { @Autowired private LoginInterceptor loginInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns("/**") .excludePathPatterns("/user/login", "/error"); } }注意:千万不要在拦截器里用
new的方式创建依赖对象,也不要试图用静态字段持有Bean。拦截器是单例的,成员变量要注意线程安全,不要在里面存和单个请求相关的状态数据。
4. 从Session到Token:两种认证场景的落地
4.1 基于Session的传统方案
Session方案的核心逻辑是:用户登录成功后,服务端创建Session并把用户信息存进去,同时把SessionId通过Cookie返回给浏览器。浏览器后续请求自动带上这个Cookie,服务端根据SessionId找到对应的Session,取出用户信息。
这个方案在单体应用里工作得很好,拦截器只需要从Session里取用户信息判断即可。但它有几个天然的局限。一是分布式部署时,多台服务器之间的Session不共享,用户请求打到另一台机器就变成未登录,需要额外做Session共享(比如存到Redis)。二是移动端或者前后端分离场景下,Cookie的管理不如浏览器那么自然,跨域时Cookie的携带还需要额外配置。
4.2 基于Token的无状态方案
Token方案(典型的是JWT)的思路完全不同:用户登录成功后,服务端生成一个Token返回给客户端,客户端自己保存(通常存localStorage),后续请求通过请求头(一般是Authorization)带上这个Token。服务端不保存任何会话状态,每次请求解析Token验证有效性即可。
这种方案下拦截器的写法要调整:
@Component public class TokenInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { return writeUnauthorized(response, "缺少令牌"); } // 通常Token会带个前缀,比如 "Bearer " if (token.startsWith("Bearer ")) { token = token.substring(7); } try { Claims claims = jwtUtil.parseToken(token); // 把解析出的用户信息放到请求属性里,供Controller使用 request.setAttribute("userId", claims.get("userId")); } catch (ExpiredJwtException e) { return writeUnauthorized(response, "令牌已过期"); } catch (JwtException e) { return writeUnauthorized(response, "令牌无效"); } return true; } private boolean writeUnauthorized(HttpServletResponse response, String msg) throws IOException { response.setContentType("application/json;charset=UTF-8"); response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.getWriter().write( "{\"code\":401,\"msg\":\"" + msg + "\"}"); return false; } }Token方案里有个很实用的技巧:解析出用户信息后,用request.setAttribute把它挂到请求上。这样Controller里可以直接request.getAttribute("userId")拿到当前用户,不用再解析一遍Token。更进一步的做法是封装一个ThreadLocal或者用Spring的HandlerMethodArgumentResolver,让Controller方法直接通过参数拿到当前用户对象,代码更干净。
4.3 两种方案的对比与选择
| 对比维度 | Session方案 | Token方案 |
|---|---|---|
| 状态存储 | 服务端存储 | 客户端存储 |
| 分布式支持 | 需要额外做共享 | 天然支持 |
| 跨域支持 | 需要配置Cookie | 请求头天然跨域 |
| 注销控制 | 服务端删Session即可 | 需要黑名单或短过期时间 |
| 移动端友好度 | 一般 | 好 |
| 实现复杂度 | 低 | 中 |
选择建议很直接:单体应用、用户量不大、主要面向浏览器,Session方案够用且简单。分布式、前后端分离、需要支持多端,Token方案更合适。实际项目里也有混合用法,比如用Token做认证但把Token存到Redis里以便主动注销,兼顾无状态和可控性。
5. 常见问题与排查技巧实录
5.1 拦截器不生效的排查思路
拦截器配了但没起作用,是最常见的问题。按下面这个顺序排查,基本能定位到原因。
第一,检查配置类有没有加@Configuration。没有这个注解,Spring根本不会加载这个配置类,拦截器自然不生效。这个错误很隐蔽,因为代码看起来完全正确。
第二,检查拦截器是不是被Spring管理。如果用new创建拦截器又需要在里面注入Bean,注入会失败。要么改成@Component注入,要么确认拦截器里确实不需要Bean。
第三,检查路径匹配。addPathPatterns写错了,比如想拦/api/**却写成了/api/*,导致多层路径的接口没被拦到。建议在preHandle里打一行日志,看看请求到底有没有进拦截器。
第四,检查是不是被其他拦截器或过滤器提前处理了。多个拦截器按注册顺序执行,如果前面的拦截器返回了false,后面的根本不会执行。
5.2 静态资源和错误页被误拦
这个问题的典型表现是:登录页面样式全丢了,或者接口报错时返回的是“未登录”。
原因是addPathPatterns("/**")把静态资源和/error也拦了。解决办法就是在excludePathPatterns里把它们排除掉。需要排除的常见路径包括:
/static/**、/public/**、/resources/**:静态资源目录/favicon.ico:网站图标/error:SpringBoot默认错误处理路径/swagger-ui/**、/v3/api-docs/**:接口文档相关/user/login、/user/register:登录注册接口
实操心得:每次新增一个不需要登录的接口,都要记得往
excludePathPatterns里加。我习惯在配置类里把排除路径抽成一个常量数组,加注释说明每个路径为什么排除,方便后续维护。
5.3 跨域场景下的预检请求被拦
前后端分离项目里,如果前端和后端不同源,浏览器会先发OPTIONS预检请求。这个请求不带认证信息,如果被拦截器拦下返回401,浏览器就认为跨域失败,真正的请求不会发出。
解决办法就是在preHandle最前面判断请求方法,是OPTIONS就直接放行。这个判断要放在所有校验逻辑之前,确保预检请求畅通无阻。
5.4 拦截器里抛异常导致返回格式不统一
拦截器里如果直接抛异常,比如解析Token时抛了JwtException没捕获,异常会往上抛。如果项目里配了全局异常处理器,可能能兜住;如果没配,用户看到的就是SpringBoot默认的错误页面,和接口约定的JSON格式不一致。
稳妥的做法是在拦截器里把可能抛异常的代码用try-catch包起来,统一返回约定的JSON格式。或者在全局异常处理器里专门处理这些认证相关的异常,保证返回格式一致。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 拦截器完全不执行 | 配置类缺@Configuration | 补注解 |
| 拦截器里注入的Bean为null | 拦截器用new创建 | 改为@Component注入 |
| 部分接口没被拦截 | 路径通配符写错 | 检查*和**用法 |
| 登录页样式丢失 | 静态资源被拦 | 排除静态资源路径 |
| 报错时提示未登录 | /error被拦 | 排除/error |
| 跨域请求失败 | OPTIONS被拦 | preHandle放行OPTIONS |
| 返回中文乱码 | 未设Content-Type | 设置application/json;charset=UTF-8 |
| 分布式下频繁掉登录 | Session不共享 | 改用Token或Session存Redis |
6. 多拦截器协作与执行顺序
6.1 多个拦截器的执行顺序
实际项目里往往不止一个拦截器,比如一个做登录校验,一个做接口耗时统计,一个做权限校验。它们的执行顺序由注册顺序决定。
假设按顺序注册了A、B、C三个拦截器,执行流程是这样的:
A.preHandle→B.preHandle→C.preHandle→ Controller →C.postHandle→B.postHandle→A.postHandle→C.afterCompletion→B.afterCompletion→A.afterCompletion
preHandle是正序,postHandle和afterCompletion是倒序,像一个栈的结构。理解这个顺序很重要,比如登录校验拦截器应该注册在最前面,确保未登录请求尽早被拦下,不用走后面的耗时统计逻辑。
6.2 用注解实现更细粒度的控制
有时候不是所有接口都需要登录校验,比如商品列表页游客也能看,但下单必须登录。这时候可以在方法上加自定义注解来标记。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RequireLogin { }然后在拦截器里通过handler参数判断:
@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod = (HandlerMethod) handler; RequireLogin annotation = handlerMethod.getMethodAnnotation(RequireLogin.class); // 没有注解的方法默认放行,或者反过来默认拦截 if (annotation == null) { return true; } // 有注解,执行登录校验 // ... }这种“注解驱动”的方式比纯路径匹配更灵活,尤其适合接口权限粒度不规则的场景。代价是每个需要校验的方法都要加注解,容易漏。所以更常见的做法是路径匹配为主、注解为辅,默认全拦,个别放行的用注解标记。
7. 一些实战中的经验补充
拦截器这套东西看起来简单,但真正在项目里用好,有几个经验值得分享。
关于Session超时时间,默认是30分钟,可以在配置文件里改。但要注意,如果用户一直在操作,Session应该续期。SpringBoot默认在每次请求时会更新Session的最后访问时间,所以只要用户在操作就不会过期。但如果前端是定时轮询,轮询请求也会续期,可能导致Session永远不过期,这个要结合业务判断是否合理。
关于Token的过期处理,前端拿到401后应该跳转登录页,但要注意避免多个请求同时返回401导致多次跳转。常见做法是在前端拦截器里加一个标志位,第一个401触发跳转,后续的忽略。
关于拦截器的性能,preHandle里尽量不要做重操作。如果每次请求都要查数据库验证用户,压力会很大。合理的做法是把用户信息缓存到Redis,拦截器查缓存即可。Token方案里解析JWT是纯计算,不涉及IO,性能上更有优势。
关于日志,建议在拦截器里记录关键信息,比如拦截了哪个请求、用户是谁、耗时多少。但不要记录敏感信息如完整Token、密码。日志级别用debug,生产环境按需开启,避免日志量过大。
最后说一个我踩过的坑:拦截器里用response.getWriter()写数据后,不要再调用response.getOutputStream(),两者只能用一个,混用会报错。统一用getWriter()写字符数据就够了。