请求进门先安检:Spring 拦截器与统一功能实战指南

> Spring 系列 · 第 5 篇

> 适用读者 :学过 Spring MVC 基础、写过 Controller,但还不清楚「登录校验写在哪」「耗时统计怎么统一做」的 Java 开发者。

> 技术环境 :Java 17+ · Spring Boot 3.4.x(兼容 3.x)· Spring MVC

>示例项目:spring-interceptor-demo(./spring-interceptor-demo/)(登录校验 + 耗时统计 + 访问日志)


提示:写完文章后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录


前言:这行代码你敢复制到第 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 有什么区别;

  1. 吃透三个方法 ------`preHandle` / `postHandle` / `afterCompletion` 各自在什么时候被调用、返回值意味着什么;

3.写得出完整代码 ------登录校验、耗时统计、访问日志三个拦截器,外加 `WebMvcConfigurer` 注册;

  1. 避开常见坑 ------执行顺序、线程安全、路径匹配、异常处理;

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

总结

相关推荐
学长毕设6 小时前
基于SpringBoot的烘焙商品商品系统-附源码
java·spring boot·后端
起个名字费劲死了7 小时前
Halcon学习一:骰子识别
前端·python·学习
付威20237 小时前
Durable Execution 怎样设计:从运行记录到中断恢复
后端
এ慕ོ冬℘゜7 小时前
JS 事件循环、宏任务微任务:单线程底层原理 + 执行顺序
开发语言·javascript·ecmascript
其实防守也摸鱼7 小时前
Chrome 扩展安全攻防实录:从攻击面到防御实践
开发语言·前端·网络·chrome·安全·web安全·php
web打印社区7 小时前
Lodop排障总纲-常见报错一张表
开发语言·前端·javascript·chrome·websocket
金銀銅鐵7 小时前
[Java] 用纯 Swing 做一个《論語·學而》阅读器
java·ai编程·交互设计
艾伦_耶格宇8 小时前
【kubernetes - 1.28版本】-11 K8S核心组件
java·容器·kubernetes