第12篇 · 拦截器与统一处理:AOP思想的初步实践

前面几篇,我们把 Spring MVC 的请求处理流程从头到尾过了一遍。从 DispatcherServletdoDispatch 方法中,你可能已经注意到了这样一个细节:在 HandlerAdapter 执行 Controller 方法之前,有一段 applyPreHandle 的调用;在执行之后,又有 applyPostHandletriggerAfterCompletion 的调用。

这就是拦截器(HandlerInterceptor) 的切入点。

如果你熟悉 AOP(面向切面编程)的概念,拦截器其实就是 AOP 思想在 Web 层的具体应用。它允许你在请求处理的特定节点插入横切逻辑------比如日志记录、权限校验、性能监控等,而不用修改 Controller 的代码。

这一篇,我们把拦截器彻底讲清楚,同时也会对比它和 Filter 的区别------因为这两个东西太容易混淆了。

学习目标

  • 掌握 HandlerInterceptor 的完整生命周期(preHandlepostHandleafterCompletion
  • 理解拦截器与 Filter 的区别与联系
  • 掌握使用拦截器实现登录校验、权限控制、日志记录等场景
  • 理解拦截器的执行顺序@Order 的关系

正文

一、拦截器的"三重奏":preHandle、postHandle、afterCompletion

HandlerInterceptor 接口定义了三个方法,分别对应请求处理的不同阶段:

java 复制代码
public interface HandlerInterceptor {
    // 在 Controller 方法执行之前调用
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, 
                              Object handler) throws Exception {
        return true;
    }
    
    // 在 Controller 方法执行之后、视图渲染之前调用
    default void postHandle(HttpServletRequest request, HttpServletResponse response,
                            Object handler, ModelAndView modelAndView) throws Exception {
    }
    
    // 在视图渲染完成之后(或整个请求处理完成之后)调用
    default void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, Exception ex) throws Exception {
    }
}

这三个方法在请求处理流程中的位置,可以用一张时序图来表示:

复制代码
请求到达
    ↓
preHandle()  ← ① 请求处理之前
    ↓
(前置拦截器链执行)
    ↓
Controller 方法执行  ← ② 业务逻辑
    ↓
postHandle()  ← ③ 业务逻辑之后,视图渲染之前
    ↓
(后置拦截器链执行)
    ↓
视图渲染  ← ④ 返回 HTML / JSON
    ↓
afterCompletion()  ← ⑤ 最终清理
    ↓
响应返回

各方法的执行条件

  • preHandle :每次请求都会调用。返回值 true 表示继续执行后续流程;false 表示中断请求,后续的 postHandleafterCompletion 都不会执行。
  • postHandle :只在 preHandle 返回 true 且 Controller 方法正常执行(没有抛出未捕获的异常)的情况下调用。
  • afterCompletion :只要 preHandle 返回 true,无论 Controller 方法是否抛出异常,afterCompletion 都会执行。这使它非常适合做资源清理、日志记录等"无论成败都需要做"的事情。

Object handler 参数是什么?

preHandlepostHandle 方法中,handler 参数代表当前请求对应的处理器对象。在大多数场景下(使用 @RequestMapping 注解),这个 handlerHandlerMethod 类型------它封装了目标 Controller 类的实例和方法信息。你可以通过它获取类名、方法名、注解等信息,在日志记录和权限校验中非常有用。

二、拦截器 vs Filter:很多人用错了,但不知道错在哪

拦截器(HandlerInterceptor)和 Filter(javax.servlet.Filter)都能在请求处理流程中插入横切逻辑,但它们完全不同。

本质区别

  • Filter 是 Servlet 规范的一部分 ,由 Servlet 容器(如 Tomcat)管理,在请求到达 DispatcherServlet 之前执行。
  • 拦截器是 Spring MVC 的一部分 ,由 Spring 容器管理,在请求到达 DispatcherServlet 之后 、Controller 方法执行前后执行。

更详细的对比

维度 Filter HandlerInterceptor
规范归属 Servlet 规范 Spring MVC 规范
管理容器 Servlet 容器(Tomcat) Spring IoC 容器
执行时机 请求到达 DispatcherServlet 之前 请求到达 DispatcherServlet 之后
是否可以注入 Spring Bean 可以,但需要通过 DelegatingFilterProxy 可以直接使用 @Autowired
能访问的信息 ServletRequestServletResponse HttpServletRequestHttpServletResponseHandlerMethodModelAndView
能感知 Controller ❌ 不能 ✅ 能,可以获取目标方法信息
典型用途 字符编码、跨域、XSS 过滤、请求日志 登录校验、权限校验、日志记录(含方法信息)

一个常见的误区:"登录校验用 Filter 就行。"

实际上,登录校验通常需要知道用户要访问的是哪个 Controller 方法、是否需要特定权限。这些信息 Filter 拿不到,但拦截器可以。而且拦截器可以直接注入 Service 来查询用户信息------Filter 虽然也能做到,但需要额外配置 DelegatingFilterProxy

选型建议

  • 需要处理原始请求/响应 (字符编码、压缩、跨域),或者需要DispatcherServlet 之前 介入的,用 Filter
  • 需要感知 Controller 方法信息 、需要注入 Spring Bean ,或者需要复用 Spring 的事务、AOP 能力 的,用 拦截器

三、登录校验实战:拦截器的典型应用

在一个前后端分离的项目中,大部分接口都需要校验用户是否已登录。用拦截器来实现,思路是这样的:

  1. preHandle 中从请求头获取 Token
  2. 解析 Token,获取用户信息
  3. 如果 Token 无效或不存在,返回 401 状态码,并中断请求
  4. 如果 Token 有效,将用户信息存入 ThreadLocal 或请求属性中,供后续使用

需要注意的细节

  • 某些接口需要放行(如登录接口、注册接口、健康检查等),用 excludePathPatterns 配置排除路径
  • 如果 preHandle 返回 false,需要手动设置响应状态码和响应体,否则前端会一直等待
  • Token 解析失败时,不要抛异常(会让 postHandle 不执行),而是直接返回 false 并设置响应

Token 放哪里?

常见的做法是将 Token 放在请求头 Authorization 中,格式为 Bearer <token>。拦截器从请求头中提取 Token 后,去掉 Bearer 前缀再进行解析。

四、拦截路径配置:通配符的规则

WebMvcConfigurer 中注册拦截器时,可以指定拦截路径和排除路径:

java 复制代码
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/api/**")          // 拦截所有 /api/ 开头的请求
                .excludePathPatterns("/api/login",   // 排除登录接口
                                     "/api/register", // 排除注册接口
                                     "/static/**");   // 排除静态资源
    }
}

通配符规则(Ant 风格)

通配符 含义 示例
? 匹配任意单个字符 /api/us?r 匹配 /api/user/api/us3r
* 匹配任意零个或多个 字符(不含 / /api/* 匹配 /api/user/api/order,但不匹配 /api/user/detail
** 匹配任意零个或多个 路径段(含 / /api/** 匹配 /api/user/detail/api/order/123 等所有子路径

常见错误addPathPatterns("/api/login") 只匹配精确的 /api/login,如果请求是 /api/login/(末尾带斜杠),则不会被拦截。

五、多拦截器的执行顺序

当有多个拦截器时,执行顺序遵循 "注册顺序决定前置顺序,前置顺序决定后置逆序" 的规则。

假设注册了 A、B、C 三个拦截器:

java 复制代码
registry.addInterceptor(interceptorA).addPathPatterns("/**");
registry.addInterceptor(interceptorB).addPathPatterns("/**");
registry.addInterceptor(interceptorC).addPathPatterns("/**");

执行顺序如下:

复制代码
preHandle A → preHandle B → preHandle C → Controller → postHandle C → postHandle B → postHandle A → afterCompletion C → afterCompletion B → afterCompletion A

关键规则:

  • preHandle :按注册顺序执行。如果某个 preHandle 返回 false,后续的 preHandle 不会执行 ,但已经执行过的 preHandle 对应的 afterCompletion 依然会执行。
  • postHandle :按注册顺序的逆序 执行。只有所有 preHandle 都返回 true 时,postHandle 才会执行。
  • afterCompletion :按注册顺序的逆序 执行。只要对应的 preHandle 返回了 trueafterCompletion 就会执行。

通过 @Order 控制顺序

除了注册顺序,还可以在拦截器类上添加 @Order 注解:

java 复制代码
@Component
@Order(1)
public class LoggingInterceptor implements HandlerInterceptor { ... }

@Component
@Order(2)
public class LoginInterceptor implements HandlerInterceptor { ... }

数字越小,优先级越高,preHandle 越先执行。@Order 的优先级高于注册顺序。

代码示例

示例一:登录校验拦截器完整实现

这是一个完整的登录校验拦截器实现,支持从请求头获取 JWT Token,并自动将用户信息注入到后续处理中。

自定义注解(用于标记哪些接口不需要登录)

java 复制代码
package com.example.demo.annotation;

import java.lang.annotation.*;

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SkipLogin {
    // 标记方法或类不需要登录校验
}

登录校验拦截器

java 复制代码
package com.example.demo.interceptor;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.example.demo.annotation.SkipLogin;
import com.example.demo.common.UserContext;
import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class LoginInterceptor implements HandlerInterceptor {

    private final UserService userService;

    @Value("${jwt.secret}")
    private String jwtSecret;

    public LoginInterceptor(UserService userService) {
        this.userService = userService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {

        // 1. 检查是否需要跳过登录
        if (handler instanceof HandlerMethod handlerMethod) {
            SkipLogin skipLogin = handlerMethod.getMethodAnnotation(SkipLogin.class);
            if (skipLogin == null) {
                // 检查类上是否有 @SkipLogin
                skipLogin = handlerMethod.getBeanType().getAnnotation(SkipLogin.class);
            }
            if (skipLogin != null) {
                return true;  // 标记了 @SkipLogin,直接放行
            }
        }

        // 2. 从请求头获取 Token
        String authorization = request.getHeader("Authorization");
        if (authorization == null || !authorization.startsWith("Bearer ")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("{\"code\":401,\"message\":\"未登录,请先登录\"}");
            return false;
        }

        String token = authorization.substring(7);  // 去掉 "Bearer " 前缀

        // 3. 解析 Token
        try {
            Long userId = JWT.require(Algorithm.HMAC256(jwtSecret))
                    .build()
                    .verify(token)
                    .getClaim("userId")
                    .asLong();

            // 4. 查询用户信息
            User user = userService.findById(userId);
            if (user == null) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("{\"code\":401,\"message\":\"用户不存在\"}");
                return false;
            }

            // 5. 将用户信息存入 ThreadLocal(供 Controller 使用)
            UserContext.setCurrentUser(user);
            return true;

        } catch (JWTVerificationException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("{\"code\":401,\"message\":\"Token 无效或已过期\"}");
            return false;
        }
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) throws Exception {
        // 请求完成后清理 ThreadLocal,防止内存泄漏
        UserContext.clear();
    }
}

ThreadLocal 工具类(用于在请求线程中传递用户信息)

java 复制代码
package com.example.demo.common;

import com.example.demo.entity.User;

public class UserContext {
    private static final ThreadLocal<User> currentUser = new ThreadLocal<>();

    public static void setCurrentUser(User user) {
        currentUser.set(user);
    }

    public static User getCurrentUser() {
        return currentUser.get();
    }

    public static void clear() {
        currentUser.remove();
    }
}

注册拦截器

java 复制代码
package com.example.demo.config;

import com.example.demo.interceptor.LoginInterceptor;
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 {

    private final LoginInterceptor loginInterceptor;

    public WebConfig(LoginInterceptor loginInterceptor) {
        this.loginInterceptor = loginInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/api/**")           // 拦截所有 API 请求
                .excludePathPatterns("/api/login",    // 排除登录
                                     "/api/register",  // 排除注册
                                     "/static/**",     // 排除静态资源
                                     "/swagger-ui/**", // 排除 Swagger
                                     "/v3/api-docs/**");
    }
}

在 Controller 中使用

java 复制代码
@RestController
@RequestMapping("/api/user")
public class UserController {

    @GetMapping("/profile")
    public User getProfile() {
        // 直接从 ThreadLocal 中获取当前登录用户
        return UserContext.getCurrentUser();
    }

    @SkipLogin  // 这个方法不需要登录
    @GetMapping("/health")
    public String health() {
        return "ok";
    }
}

示例二:日志记录拦截器

这个拦截器记录了每个请求的详细信息,包括 URL、参数、耗时和响应状态。

java 复制代码
package com.example.demo.interceptor;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.util.ContentCachingRequestWrapper;
import org.springframework.web.util.ContentCachingResponseWrapper;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

@Slf4j
@Component
public class LoggingInterceptor implements HandlerInterceptor {

    private static final DateTimeFormatter FORMATTER = 
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS");
    private static final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) throws Exception {
        // 设置请求开始时间
        request.setAttribute("_startTime", System.currentTimeMillis());
        log.info("🟢 [请求开始] {} {} at {}", 
                 request.getMethod(), request.getRequestURI(), 
                 LocalDateTime.now().format(FORMATTER));
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) throws Exception {
        long startTime = (long) request.getAttribute("_startTime");
        long duration = System.currentTimeMillis() - startTime;

        String status = ex != null ? "❌ 异常" : "✅ 完成";
        log.info("🔴 [请求结束] {} {} | 状态: {} | 耗时: {}ms | 异常: {}", 
                 request.getMethod(), request.getRequestURI(),
                 response.getStatus(), duration, 
                 ex != null ? ex.getMessage() : "无");
    }
}

新手错误 vs 正确姿势

错误表象 根本原因 正确姿势
拦截器中注入的 Service 为 null,调用时抛出 NullPointerException 拦截器未交给 Spring 管理,直接 new 了实例 拦截器类添加 @Component 注解,通过 @Autowired 注入依赖,注册时注入该 Bean
配置了 excludePathPatterns,但请求仍然被拦截 路径表达式写错,如 /user/login/user/login/ 不是同一个路径 明确通配符规则,或使用 /user/login/** 匹配所有子路径
preHandle 返回 false,但前端没有收到任何响应 返回 false 后未手动设置响应状态码和响应体 在返回 false 之前,调用 response.setStatus()response.getWriter().write() 设置响应内容
@Order 注解不起作用,拦截器顺序混乱 拦截器注册顺序仍然起作用,@Order 只对同类型组件有效 @Order 需要配合注册顺序一起理解:它是在同一拦截器注册顺序基础上的微调。建议统一用注册顺序控制
postHandle 中修改响应数据,但前端收到的数据没有变化 postHandle 执行时响应数据尚未生成,但可以通过 ModelAndView 修改视图模型 如果要修改响应体,使用 ResponseBodyAdvicepostHandle 只适合修改 ModelAndView 的视图数据(适用于 JSP 等模板引擎)

疑难深度追问

Q1:拦截器中的 postHandle 在什么情况下不会执行?

两种情况:第一,preHandle 返回 false,请求被中断,后续所有 postHandle 都不会执行;第二,Controller 方法抛出异常且未被 @ExceptionHandler 捕获,异常被抛出到 DispatcherServlet 层,此时 postHandle 可能不会执行(取决于具体的异常处理机制,但 afterCompletion 仍然会执行)。另外,如果 preHandle 阶段就抛出了异常,postHandle 也不会执行。

Q2:如果要在拦截器中修改响应数据,应该怎么做?

拦截器不适合修改响应体 ,因为 postHandle 执行时 HttpServletResponseOutputStream 尚未被写入(或者说,写入发生在 ViewResolver 渲染时)。要统一修改响应体,更合适的方式是使用 ResponseBodyAdvice (我们第 11 篇讲过的),它可以在 HttpMessageConverter 序列化之前对返回值进行统一处理。

Q3:登录校验用拦截器实现和用 AOP 实现有什么区别?

拦截器是 Spring MVC 层面的组件,直接操作 HttpServletRequestHttpServletResponse,天然适合处理 Web 请求上下文(如获取 Header、设置状态码)。AOP 更通用,可以拦截任何方法,但处理请求响应时相对不太方便------AOP 切面无法直接设置 HTTP 状态码(需要结合 ResponseBodyAdvice 或手动注入 Response)。在实践中,Web 层的请求校验用拦截器,业务层的方法拦截用 AOP------两者各司其职。

思考与延伸

  1. 动手实验 :注册三个拦截器 A、B、C,在 preHandle 中分别打印日志,让 A 的 preHandle 返回 false,观察 B 和 C 的 preHandle 是否还会执行,以及 afterCompletion 的执行情况。

  2. 思考题 :拦截器中能否抛出异常?如果能,异常被抛出后谁来处理?(提示:会进入 DispatcherServlet 的异常处理流程,最终被 HandlerExceptionResolver 处理)

  3. 延伸阅读 :Spring 官方文档中关于 HandlerInterceptor 的说明,以及 DispatcherServletdoDispatch 源码中对拦截器的调用逻辑。

参考与延伸阅读

  • Spring Framework. HandlerInterceptor Interface. Spring Framework Javadoc, 6.0.x
  • Spring Framework. Web MVC Framework --- Intercepting requests. Spring Framework Documentation
  • Baeldung. Spring MVC HandlerInterceptor Example. Baeldung, 2024
  • 阿里云开发者社区. Spring MVC 拦截器与过滤器对比. 2023-05
  • 腾讯云. Spring Boot 拦截器实现登录校验详解. 2024-08