> Spring 系列 · 第 5 篇
> 适用读者 :学过 Spring MVC 基础、写过 Controller,但还不清楚「登录校验写在哪」「耗时统计怎么统一做」的 Java 开发者。
> 技术环境 :Java 17+ · Spring Boot 3.4.x(兼容 3.x)· Spring MVC
>示例项目:spring-interceptor-demo(./spring-interceptor-demo/)(登录校验 + 耗时统计 + 访问日志)
提示:写完文章后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
- [- 一、为什么需要拦截器(#一为什么需要拦截器)
- 二、核心概念:拦截器到底是什么(#二核心概念拦截器到底是什么)
- 三、三个关键方法:preHandle / postHandle / afterCompletion(#三三个关键方法prehandle--posthandle--aftercompletion)
- 四、请求处理流程与 UML(#四请求处理流程与-uml)
- 五、拦截器 vs Filter vs AOP(#五拦截器-vs-filter-vs-aop)
- 六、完整代码示例(#六完整代码示例)
- 七、常见统一功能一览(#七常见统一功能一览)
- 八、注意事项与常见误区(#八注意事项与常见误区)
- 九、实例项目 walkthrough(#九实例项目-walkthrough)
- 总结(#总结)](#一为什么需要拦截器) - 二、核心概念:拦截器到底是什么 - 三、三个关键方法:preHandle / postHandle / afterCompletion - 四、请求处理流程与 UML - 五、拦截器 vs Filter vs AOP - 六、完整代码示例 - 七、常见统一功能一览 - 八、注意事项与常见误区 - 九、实例项目 walkthrough - 总结)
前言:这行代码你敢复制到第 30 个 Controller 吗?
先来看一段你大概率写过的代码。
java
@PostMapping("/api/orders")
public ApiResponse<OrderVO> createOrder(@RequestBody OrderDTO dto) {
// 1. 取登录用户
UserInfo user = (UserInfo) request.getSession().getAttribute("loginUser");
if (user == null) {
return ApiResponse.fail(401, "未登录");
}
// 2. 打日志
log.info("创建订单 userId={}, amount={}", user.getId(), dto.getAmount());
// 3. 记耗时
long start = System.currentTimeMillis();
OrderVO vo = orderService.create(dto, user);
log.info("创建订单耗时 {} ms", System.currentTimeMillis() - start);
return ApiResponse.success(vo);
}
单看一个方法,没问题,甚至挺规范。但当项目有30 个 Controller、60 个接口 时,这三件事就要复制 60 遍:
-
有人把 `getSession()` 写成 `getSession(true)`,Session 无限增长;
-
有人在 `return` 之前忘了打耗时日志,日志对不齐;
-
有人做权限判断时忘了判断某个方法,白名单漏了一个接口;
-
需求变了------「所有写操作都要记录操作人」,你要改60 个文件,漏改一个就是线上事故。
更糟的是,这些代码会让新人迷惑 :明明是同一个「查当前用户」的动作,为什么这里用 `Session`,那里用 `Token`?这段校验到底算不算业务逻辑?它该写在 Service 里吗?------因为它没有明确的位置。
这正是「横切关注点」的问题
这类「每个接口都要做、又跟具体业务无关」的逻辑,软件上叫**横切关注点(cross-cutting concern)**:

Spring MVC 为这类需求提供的官方答案,就是拦截器(HandlerInterceptor) :在请求到达 Controller 之前、之中、之后,集中处理这些逻辑,Controller 只专心写业务。
它不解决业务问题,但解决「这些逻辑该放哪」的问题------这正是它存在的意义。
本文你将学到什么
1.搞清它的位置 ------拦截器到底跑在请求生命周期的哪一段,和 Filter、AOP 有什么区别;
- 吃透三个方法 ------`preHandle` / `postHandle` / `afterCompletion` 各自在什么时候被调用、返回值意味着什么;
3.写得出完整代码 ------登录校验、耗时统计、访问日志三个拦截器,外加 `WebMvcConfigurer` 注册;
- 避开常见坑 ------执行顺序、线程安全、路径匹配、异常处理;
5.知道什么时候不该用------拦截器适合轻量 Web 层逻辑,完整的安全体系请交给 Spring Security。
本文所有代码都来自可运行的示例项目 spring-interceptor-demo(./spring-interceptor-demo/),你可以边读边跑。
提示:以下是本篇文章正文内容,下面案例可供参考
一、为什么需要拦截器
前面用代码展示了【复制粘贴】的代价,我们换个更直观的场景。
想象你开了一家连锁奶茶店。每家店都要三件事:
1.顾客进门,先看看有没有会员卡(登录校验)
2.店员记录【几点几分来了谁】(访问日志)
3.做完一杯茶,看看花了多久(耗时统计)
如果每个店员、每个窗口各自写一套逻辑,很快就会出现:A店忘了校验、B店日志格式不一样、C店计时不准。
Web应用一模一样。你有几十个Controller,每个接口都可能需要检查登录、打印日志、统计耗时、做权限判断。
如果把这段逻辑复制粘贴到每个Controller里,就是灾难------代码重复、改一处漏十处、新人根本不知道改写在哪。
那怎么办?把逻辑抽出来变成公共方法`checkLogin()`、`logAccess()` 不就行了?
能解决一半,但解决不了另一半:

真正彻底的解法,是让这段逻辑自动对所有请求生效------开发者想忘都忘不了。
拦截器(Interceptor) 就是Spring MVC提供的【统一检查站】:在请求到达Controller**之前、之中、之后,**其中处理这些横切逻辑,Controller只专心写业务。
**一句话:**拦截器 = 放在Controller门口的保安 + 计时员 + 登记员,所有请求过一遍,不用每个房间单独雇人。
本章要点 :拦截器解决的是**【放哪】**的问题------它明确告诉你,登录校验、耗时统计这类逻辑应该写在拦截器里,而不是Controller里。
二、核心概念:拦截器到底是什么
2.1 它工作在哪个位置?
一次HTTP请求进入Spring Boot应用,大致经过这些环节:

关键点:
-
拦截器在 DispatcherServlet 内部 ,已经知道「这次请求要交给哪个 Controller 处理」(`handler` 对象)。
-
它比 Filter更靠近业务 ,比 AOP 更贴近 Web 请求 。
-
它只能拦截 Spring MVC 管理的请求(通常是 `@Controller` / `@RestController`),静态资源、非 MVC 路径不一定走拦截器(取决于配置)。
2.2 核心接口:Handler Interceptor
Spring 规定:你要写拦截器,就实现 `org.springframework.web.servlet.HandlerInterceptor` 接口。


三、三个关键方法:preHandle/postHandle/after Completion
这是面试喝实战里最高频的三个方法。用大白话 +准确术语一起讲。
3.1 preHandle------【进Controller 之前】
干什么:请求刚到,Controller还没执行。
返回值:

典型用途:登录校验、权限检查、在request上塞公共属性。
java
public Boolean preHandle(HttpServletRequest request, HttpServletResponse response,Object handler)throws Exception{
// 目的:没登陆就直接拦在门外
HttpSession session = request.getSession();
if (session == null){
writeJson(response,ApiResponse.fail(401,"未登录"));
request fales;//关键:return false = 到处为止
}
return true;
}
> 容易误解 :`return false` 之后,`postHandle`不会 被调用,但 `afterCompletion` 仍会被调用(见下文)。
3.2 postHandle ------ 「Controller 执行完,视图还没渲染完」
干什么 :Controller 方法已经跑完,返回值(或 `ModelAndView`)已经有了,但如果是传统 JSP/Thymeleaf 页面,视图渲染还没结束。
典型用途:往 Model 里补数据、记录「Controller 已正常返回」的日志。
注意(REST API 场景):
-
如果你用的是 `@RestController` + JSON 直接写响应,`postHandle` 里改 `ModelAndView`往往没意义。
-
如果 Controller 抛异常 ,`postHandle`不会被调用。
3.3 afterCompletion ------ 「整个请求收尾,类似 finally」
干什么 :视图渲染完毕(或 REST 响应已提交),做最终清理。
参数 `ex`:如果处理过程中抛了异常,`ex` 不为 null;否则为 null。
典型用途:清理 ThreadLocal、统计耗时、记录「无论成功失败都需要的日志」。
java
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) throws Exception {
Long start = (Long) request.getAttribute(START_TIME);
if (start == null){
return;
}
Long cost = System.currentTimeMillis()- start;
log.info("{} {} -> [] ({} ms)",
request.getMethod(),request.getRequestURL(),response.getStatus(),cost);
}
> 类比:`preHandle` 是进考场前检查准考证;`postHandle` 是交卷后老师先看一眼;`afterCompletion` 是铃响后无论及不及格都要收卷。
3.4 三个方法的调用顺序(单个拦截器)

四、请求处理与UML
4.1 多个拦截器时的执行顺序
假设注册了三个拦截器:`Timing`->`AccessLog`->`Login`,路径`/orders`:

口诀:
-
preHandle :按注册顺序 正序 执行(1 → 2 → 3)
-
postHandle / afterCompletion :按注册顺序倒序 执行(3 → 2 → 1).
这和 Filter 链类似,但 Filter 在更外层。
4.2 一次成功请求的对象图(简化)

为什么要用 `request.setAttribute` 存开始时间?
因为拦截器 Bean 默认是单例 ,会被所有线程共享。如果把 `startTime` 写成拦截器的成员变量,高并发下 A 用户的计时会串到 B 用户------典型的线程安全问题。正确做法:数据跟请求走,存在 `HttpServletRequest` 或 `ThreadLocal`(用完必须 `remove`)。
五、拦截器 vs Filter vs AOP
三者都能做「统一处理」,但位置和能力不同。不要混为一谈。

选择建议

>提醒 :拦截器适轻量级、Web 层 的统一逻辑。完整的认证授权、OAuth2、JWT 刷新、细粒度 RBAC,请用 Spring Security,不要把拦截器写成「山寨版安全框架」。
六、完整代码示例
以下代码来自示例项目 spring-interceptor-demo(./spring-interceptor-demo/),已实测可编译运行。
6.1 登录拦截器
目的:所有 `/*` 请求(除白名单外)必须已登录。
java
package com.example.springinterceptordemo.interceptor;
import com.example.springinterceptordemo.common.ApiResponse;
import com.example.springinterceptordemo.common.SessionConstants;
import com.example.springinterceptordemo.model.UserInfo;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import tools.jackson.databind.ObjectMapper;
@Component
public class LoginInterceptor implements HandlerInterceptor {
/** Controller 里读取当前用户的 key,抽成常量避免手写字符串不一致 */
public static final String CURRENT_USER = "currentUser";
private final ObjectMapper objectMapper;
public LoginInterceptor(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
// BUG修复1:放行 CORS 预检请求,否则前端跨域时 OPTIONS 直接被 401 拦掉
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// BUG修复2:静态资源 / 错误转发 / 非 Controller 方法直接放行,
// 否则 favicon、/error、静态文件也会被要求登录
if (!(handler instanceof HandlerMethod)) {
return true;
}
HttpSession session = request.getSession(false);
if (session == null) {
writeJson(response, HttpServletResponse.SC_UNAUTHORIZED, ApiResponse.fail(401, "未登录,请先登录"));
return false;
}
// BUG修复3:原来是强制转型,session 被污染时会抛 ClassCastException 变成 500;
// 改为 instanceof 保护,类型不对就当未登录处理
Object attr = session.getAttribute(SessionConstants.LOGIN_USER);
if (!(attr instanceof UserInfo userInfo)) {
writeJson(response, HttpServletResponse.SC_UNAUTHORIZED, ApiResponse.fail(401, "登录已过期,请重新登录"));
return false;
}
request.setAttribute(CURRENT_USER, userInfo);
return true;
}
private void writeJson(HttpServletResponse response, int httpStatus, ApiResponse<?> body) throws Exception {
response.setStatus(httpStatus);
// BUG修复4:原来是 "application/json;charset = UTF-8"(等号两边多了空格),
// 是非法 Content-Type,某些容器/浏览器会解析失败;同时补上 characterEncoding
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
objectMapper.writeValue(response.getWriter(), body);
}
}
为什么用 `getSession(false)`?
`false` 表示「没有 Session 就别新建」。登录校验场景下,没 Session 就是没登录,不应该凭空创建一个。
为什么在拦截器写 JSON 而不是抛异常?
`preHandle` 返回 `false` 时流程已中断,走不到 `@ControllerAdvice`。直接在拦截器写响应是最直接的做法。
6.2 耗时拦截器
目的:记录每个 API 花了多少毫秒。
java
package com.example.springinterceptordemo.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
@Slf4j
public class TimingInterceptor implements HandlerInterceptor {
private static final String START_TIME = "requestStartTime";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
request.setAttribute(START_TIME, System.currentTimeMillis());
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
@Nullable Exception ex) throws Exception {
Object attr = request.getAttribute(START_TIME);
if (!(attr instanceof Long start)) {
return;
}
long cost = System.currentTimeMillis() - start;
// BUG修复:原来占位符只有3个却传了4个参数,cost被丢弃,状态码被错位打印;[] 改为 {}
// BUG修复:getRequestURL() 会带上域名,日志太长,改用 getRequestURI()
log.info("{} {} -> {} ({} ms)", request.getMethod(), request.getRequestURI(), response.getStatus(), cost);
if (ex != null) {
log.info("请求异常: {} {}", request.getRequestURI(), ex.getMessage());
}
}
}
为什么用 afterCompletion 而不是 postHandle?
耗时统计要覆盖「Controller 抛异常」的情况;`postHandle` 在异常时不执行,`afterCompletion` 更像 `finally`。
6.3 注册拦截器(Spring Boot方式)
实现 `WebMvcConfigurer`,重写 `addInterceptors`:
java
package com.example.springinterceptordemo.config;
import com.example.springinterceptordemo.interceptor.AccessLogInterceptor;
import com.example.springinterceptordemo.interceptor.LoginInterceptor;
import com.example.springinterceptordemo.interceptor.TimingInterceptor;
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 WebMvcConfig implements WebMvcConfigurer {
private final LoginInterceptor loginInterceptor;
private final TimingInterceptor timingInterceptor;
private final AccessLogInterceptor accessLogInterceptor;
public WebMvcConfig(LoginInterceptor loginInterceptor, TimingInterceptor timingInterceptor,
AccessLogInterceptor accessLogInterceptor) {
this.loginInterceptor = loginInterceptor;
this.timingInterceptor = timingInterceptor;
this.accessLogInterceptor = accessLogInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// BUG修复1:原来注释"注册顺序=执行顺序"只说对一半:
// preHandle 按注册顺序执行,postHandle/afterCompletion 按注册逆序执行。
// 下面顺序保证:preHandle: timing -> accessLog -> login;
// afterCompletion: login -> accessLog -> timing,timing 在最外层才能统计完整耗时(含401)。
registry.addInterceptor(timingInterceptor)
.addPathPatterns("/**");
registry.addInterceptor(accessLogInterceptor)
.addPathPatterns("/**");
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
// BUG修复2:放行范围补齐
// - /public 本体:PublicController 的 @GetMapping 无路径,实际暴露的是 /public 而非 /public/xxx,
// 只写 /public/** 在部分版本匹配不到,导致健康检查被 401 拦截
// - /error、/favicon.ico:登录态失效时的错误转发、浏览器小图标不应要求登录
// - /actuator/**:健康检查端点不应要求登录(按需保留)
.excludePathPatterns(
"/auth/login", "/auth/login/**",
"/public", "/public/**",
"/error", "/favicon.ico",
"/actuator/**",
"/css/**", "/js/**", "/images/**", "/static/**", "/webjars/**");
}
}

> Spring Boot 2.x / 3.x 均适用此写法。老式 XML 配置 `<mvc:interceptors>` 现已少见,知道即可。
七、常见统一功能一览

八、注意事项与常见误区
8.1 执行顺序
多个拦截器时,注册顺序就是 preHandle 顺序。如果有「先计时、再鉴权」的需求,把 `TimingInterceptor` 放在前面注册。
8.2 不要不业务逻辑堆进拦截器
拦截器应薄 且通用:
-
「有没有登录」(正确)
-
「根据用户等级计算订单折扣」------ 这是业务,放 Service。(错误)
8.3 线程安全

8.4路径匹配陷阱
-
`/**` 能匹配 `/orders`,但不一定 匹配你期望的所有变体;测试时应用真实 URL 验证。
-
静态页面 `.html` 若不走 DispatcherServlet,可能绕过拦截器;SPA 前端路由更是客户端行为,后端拦截器管不到。
8.5 异常与响应
-
`preHandle` 返回 `false`:`postHandle` 跳过,`afterCompletion` 执行。
-
Controller 抛异常:`postHandle` 跳过,`afterCompletion` 执行且 `ex != null`;最终 JSON 错误格式通常由 `@ControllerAdvice` 处理------但前提是请求能走到 Controller。
8.6 和 Spring Security 的关系
引入 Spring Security 后,它自带 `Filter` 链,执行顺序在拦截器之前 。很多项目会逐步迁移到 Security,而不是长期维护一套手写登录拦截器。学习拦截器仍然重要------它帮你理解 Web 请求生命周期;生产环境则按复杂度选型。
九、实例项目 walkthrough
9.1 项目结构

验证
bash
# 1. 公开接口 ------ 无需登录,但仍走耗时拦截器
curl http://localhost:8080/public/health
# 2. 受保护接口 ------ 未登录,LoginInterceptor 返回 401
curl http://localhost:8080/order/listOrders
# 3. 登录 ------ 白名单路径,不校验登录
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d "{\"username\":\"admin\",\"password\":\"123456\"}" \
-c cookies.txt
# 4. 带 Session 访问 ------ 通过校验,Controller 返回订单列表
curl http://localhost:8080/orders/listOrders -b cookies.txt
总结
