【JavaEE】SpringBoot 统一功能处理: 拦截器、统一返回与全局异常

通过本文我们将:

  • 掌握拦截器的使用及其原理
  • 学习统一数据返回格式和统一异常处理的操作
  • 了解一些 Spring 的源码

文章目录

  • [1. 拦截器:把登录校验从每个接口里抽出来](#1. 拦截器:把登录校验从每个接口里抽出来)
    • [1.1 为什么需要拦截器](#1.1 为什么需要拦截器)
    • [1.2 快速入门:实现 HandlerInterceptor](#1.2 快速入门:实现 HandlerInterceptor)
    • [1.3 注册拦截器与路径控制](#1.3 注册拦截器与路径控制)
    • [1.4 实战:用拦截器完成登录校验](#1.4 实战:用拦截器完成登录校验)
  • [2. 统一数据返回格式:让所有接口说同一种"语言"](#2. 统一数据返回格式:让所有接口说同一种“语言”)
    • [2.1 手动包装的痛点](#2.1 手动包装的痛点)
    • [2.2 @ControllerAdvice + ResponseBodyAdvice](#2.2 @ControllerAdvice + ResponseBodyAdvice)
    • [2.3 为什么返回 String 时会报 ClassCastException](#2.3 为什么返回 String 时会报 ClassCastException)
    • [2.4 一个值得知道的权衡](#2.4 一个值得知道的权衡)
  • [3. 统一异常处理:不让异常"裸奔"到前端](#3. 统一异常处理:不让异常“裸奔”到前端)
    • [3.1 为什么需要全局异常处理](#3.1 为什么需要全局异常处理)
    • [3.2 快速入门:@ControllerAdvice + @ExceptionHandler](#3.2 快速入门:@ControllerAdvice + @ExceptionHandler)
    • [3.3 分层捕获:精确异常优先,Exception 兜底](#3.3 分层捕获:精确异常优先,Exception 兜底)
  • [4. 源码脉络:DispatcherServlet 与 @ControllerAdvice 的协作](#4. 源码脉络:DispatcherServlet 与 @ControllerAdvice 的协作)
  • [5. 前端 Ajax 配合改造](#5. 前端 Ajax 配合改造)
  • 总结

在真实的 Web 项目中,我们很快会遇到三类 "重复且分散" 的问题:

  1. 每个接口都要手写登录校验
  2. 每个 Controller 都要手动包装返回结果
  3. 每个方法里都要 try-catch 处理异常。

SpringBoot 提供了一套统一的机制,让我们把这些横切逻辑从业务代码中抽离出来。

本文按照拦截器 → 统一数据返回 → 统一异常处理 → 源码脉络 → 前端配合的顺序,带你从零理解并落地这三项能力。

1. 拦截器:把登录校验从每个接口里抽出来

1.1 为什么需要拦截器

在没有拦截器之前,实现强制登录最常见的写法是在每个接口方法里都检查一遍 Session:

java 复制代码
@RequestMapping("/getListByPage")
public Result getListByPage(PageRequest pageRequest, HttpSession session) {
    if (session.getAttribute(SESSION_USER_KEY) == null) {
        return Result.unlogin();
    }
    // 真正的业务逻辑......
}

问题很明显:接口有多少个,这段重复代码就要写多少遍;将来登录规则一变,所有接口都得跟着改。拦截器正是为了解决这类 "每个请求都要做同一件事" 的场景而设计的。它在请求进入 Controller 之前、Controller 执行之后、请求完全结束之后,提供了三个可插入自定义逻辑的时机。

1.2 快速入门:实现 HandlerInterceptor

自定义拦截器只需实现 HandlerInterceptor 接口,重写三个方法:

java 复制代码
@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {
        log.info("preHandle:目标方法执行前");
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request,
                           HttpServletResponse response,
                           Object handler,
                           ModelAndView modelAndView) throws Exception {
        log.info("postHandle:目标方法执行后");
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                HttpServletResponse response,
                                Object handler,
                                Exception ex) throws Exception {
        log.info("afterCompletion:视图渲染完毕后执行");
    }
}

三个方法的职责和返回值含义如下:

  • preHandle :在 Handler 执行前调用,返回值是布尔类型。返回 true 表示放行,继续执行后续拦截器和 Controller;返回 false 则中断整个请求,Controller 不会被执行。
  • postHandle :在 Controller 方法正常执行完毕后、视图渲染之前调用。如果 Controller 抛出了异常,这个方法不会执行。
  • afterCompletion :在整个请求处理完成后调用,主要任务是释放执行 preHandle 时申请的资源。当某个拦截器的 preHandle 返回 false 导致请求中断时,DispatcherServlet 框架会逆序调用此前已经成功执行过 preHandle 并返回 true 的拦截器的 afterCompletion;当前返回 false 的拦截器自身的 afterCompletion 不会执行,它后面的拦截器也不会执行。这是清理资源、记录最终耗时最合适的位置。

正常请求下三个方法的执行顺序是:

text 复制代码
preHandle
	 ↓
Controller 方法
	 ↓
postHandle
	 ↓
afterCompletion

如果把 preHandle 的返回值改成 false,日志会停在 preHandle,请求不会再进入 Controller,响应也不会正常渲染。

后端正常的调用顺序:

text 复制代码
   用户
	↓ 调用
Controller控制层
	↓
Service服务层
	↓
Mapper数据持久层
	↓ 调用
DataBase数据库

加入拦截器之后的调用顺序:

text 复制代码
   用户
	↓ 调用
Interceptor拦截器 → false:拦截
	↓ true:放行
Controller控制层
	↓
Service服务层
	↓
Mapper数据持久层
	↓ 调用
DataBase数据库

添加拦截器后,在执行 Controller 的方法之前请求会先被拦截器拦截住并执行 preHandle 方法,这个方法需要返回一个布尔类型的值:如果返回 true,就表示放行本次请求,继续访问 Controller 中的方法;如果返回 false,则不会放行,Controller 中的方法也不会执行。

  • 一个容易被忽略的细节:异步请求。 :
    • 当 Controller 返回 Callable 或使用 DeferredResult 时,Servlet 主线程会先退出,此时 postHandle 和 afterCompletion 不会 在本次线程中执行。等异步处理完成、请求重新 dispatch 回来时,框架会再次调用 preHandle 和 postHandle。如果你的拦截器依赖 postHandle 做收尾工作,在异步场景下需要改用 AsyncHandlerInterceptor 的 afterConcurrentHandlingStarted,或者确保逻辑放在 afterCompletion 中。

1.3 注册拦截器与路径控制

定义好拦截器后,需要通过 WebMvcConfigurer 的 addInterceptors 把它注册进去,并指定拦截范围和排除范围:

java 复制代码
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private LoginInterceptor loginInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/user/login",
                        "/**/*.js",
                        "/**/*.css",
                        "/**/*.png",
                        "/**/*.html"
                );
    }
}

路径模式中,/* 只匹配一级路径(如 /user、/book),/** 匹配任意层级路径。实际项目中最容易踩的坑是 静态资源没有被正确排除 。一旦你在 addPathPatterns("/**") 的同时没有排除 /**/*.js、/**/*.css 等,浏览器加载前端页面时会连 JS 和 CSS 都被拦截器拦住,页面直接白屏。推荐的排除列表中至少应包含 /**/*.js、/**/*.css、/**/*.png、/**/*.html,如果项目有 Swagger,还要排除 /swagger-ui/** 和 /v3/api-docs/**。

1.4 实战:用拦截器完成登录校验

把 Session 校验逻辑搬进 preHandle,所有接口就自动拥有了登录保护:

java 复制代码
@Slf4j
@Component
public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) throws Exception {
        HttpSession session = request.getSession(false);
        if (session != null
                && session.getAttribute(Constants.SESSION_USER_KEY) != null) {
            return true;
        }
        response.setStatus(401);
        return false;
    }
}

这里设置 HTTP 状态码 401 Unauthorized ,语义是 "请求需要身份认证,且当前未提供或认证失败"。前端 Ajax 的 error 回调可以通过判断 error.status === 401 统一跳转到登录页,而不需要解析响应体中的业务码。这是拦截器与前端配合的一个关键约定。

2. 统一数据返回格式:让所有接口说同一种"语言"

2.1 手动包装的痛点

在拦截器解决登录问题之后,另一个重复劳动浮出水面:每个 Controller 方法都要手动写 return Result.success(data)。一旦某个接口忘记包装,前端拿到的就是裸数据,解析逻辑立刻崩溃。理想的状态是:Controller 只管返回业务数据本身,框架自动套上统一的外壳。

2.2 @ControllerAdvice + ResponseBodyAdvice

SpringMVC 提供了 ResponseBodyAdvice 接口,允许在响应体被 HttpMessageConverter 写出之前进行拦截和修改。配合 @ControllerAdvice,就可以对所有 Controller 的返回值统一加工:

java 复制代码
@ControllerAdvice
public class ResponseAdvice implements ResponseBodyAdvice<Object> {

    private static final ObjectMapper mapper = new ObjectMapper();

    @Override
    public boolean supports(MethodParameter returnType,
                            Class converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) {
        if (body instanceof Result) {
            return body;
        }
        if (body instanceof String) {
            try {
                return mapper.writeValueAsString(Result.success(body));
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        return Result.success(body);
    }
}

supports 返回 true 表示:对于被 @ControllerAdvice 覆盖、且通过 HttpMessageConverter 写出响应体的 Controller 方法,允许调用 beforeBodyWrite 进行包装;返回 false 则跳过该 Advice。返回 true 只是"允许调用",是否真正包装还由 beforeBodyWrite 内部逻辑决定。

2.3 为什么返回 String 时会报 ClassCastException

如果你直接让 beforeBodyWrite 无差别地 return Result.success(body),访问一个返回 String 的接口时,会遇到:

text 复制代码
java.lang.ClassCastException: Result cannot be cast to java.lang.String

原因藏在 SpringMVC 的 HttpMessageConverter 链里。默认注册的转换器顺序中,StringHttpMessageConverter 排在 MappingJackson2HttpMessageConverter 之前。当原始返回类型是 String 时,Spring 会选中 StringHttpMessageConverter 来写出响应;而这个转换器在 addDefaultHeaders 中期望收到的是 String 类型。你的 beforeBodyWrite 却把 String 包装成了 Result,于是类型不匹配,抛出异常。

修复方式就是上面的代码: 在 beforeBodyWrite 中单独判断 body instanceof String,先用 Jackson 手动把 Result 序列化成 JSON 字符串再返回,这样 StringHttpMessageConverter 拿到的仍然是合法的 String。同时增加 body instanceof Result 的判断,避免对已经手动包装过的返回值进行二次包装(否则前端会收到 {"status":"SUCCESS","data":{"status":"SUCCESS","data":...}} 这样的嵌套结构)。

2.4 一个值得知道的权衡

ResponseBodyAdvice 自动包装的优点是 "零侵入":Controller 完全不需要感知 Result 的存在。但它也有代价------Controller 的方法签名中看不出来最终返回结构,对于需要文件下载、流式响应、Swagger 文档 等场景,自动包装反而会造成干扰。在中小型项目中,自动包装是高效的;在大型或对外 API 项目中,不少团队会选择手动返回 Result<T>,以获得更强的类型可见性和控制力。

3. 统一异常处理:不让异常"裸奔"到前端

3.1 为什么需要全局异常处理

没有统一异常处理时,Controller 里任何一个未捕获的异常都会让前端收到一个 500 响应,响应体可能是 Spring 默认的错误页或一长串 Java 堆栈。这不仅不友好,还可能把内部类名、SQL 片段等敏感信息暴露给外部。统一异常处理的目标是:所有异常都被转换成与正常返回结构一致的 Result 对象,前端只需按同一套逻辑解析。

3.2 快速入门:@ControllerAdvice + @ExceptionHandler

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(Exception.class)
    public Result handleException(Exception e) {
        return Result.fail(e.getMessage());
    }
}

@RestControllerAdvice 等价于 @ControllerAdvice + @ResponseBody,这意味着处理方法的返回值会自动被序列化为 JSON,适合前后端分离项目。如果你返回的是视图页面而不是 JSON,才需要使用 @ControllerAdvice 并在方法上单独加 @ResponseBody。

3.3 分层捕获:精确异常优先,Exception 兜底

一个健壮的异常处理器不会只有一个 "万能" 方法。@ExceptionHandler 的匹配规则是当前异常类型及其子类向上匹配 :如果抛出的异常同时能被多个方法处理,Spring 会选择声明类型与抛出类型最"近"的那个。例如抛出一个 NullPointerException,handler(NullPointerException e) 的匹配深度为 0,handler(Exception e) 的匹配深度为 2,因此精确匹配的方法优先执行。

基于这个规则,推荐的组织方式是:

java 复制代码
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // 业务异常:可预期,返回具体提示
    @ExceptionHandler(BusinessException.class)
    public Result handleBusiness(BusinessException e) {
        log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
        return Result.fail(e.getCode(), e.getMessage());
    }

    // 参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public Result handleValidation(MethodArgumentNotValidException e) {
        String msg = e.getBindingResult().getFieldError().getDefaultMessage();
        return Result.fail("PARAM_INVALID", msg);
    }

    // 兜底:非预期异常,隐藏细节,记录完整堆栈
    @ExceptionHandler(Exception.class)
    public Result handleUnexpected(Exception e) {
        log.error("未预期异常", e);
        return Result.fail("系统繁忙,请稍后再试");
    }
}

业务异常(如"用户不存在""余额不足")应该返回给用户可读的提示;非预期的系统异常则只记录日志,对外返回模糊的友好信息,避免信息泄露。这与前端的配合也很关键:前端只需要判断 result.status 是否成功,失败时展示 errorMessage,不需要区分背后是业务异常还是系统异常。

4. 源码脉络:DispatcherServlet 与 @ControllerAdvice 的协作

理解上面三项功能共享同一个核心入口------DispatcherServlet,有助于你在遇到 "拦截器没生效"、"异常处理没触发" 时快速定位问题。

所有请求都会先进入 DispatcherServlet 的 doDispatch 方法。它的主干流程是:

  1. 通过 HandlerMapping 找到当前请求对应的 HandlerExecutionChain(包含 Handler 和拦截器链);
  2. 通过 HandlerAdapter 适配并准备执行 Handler;
  3. 执行 applyPreHandle,依次调用拦截器的 preHandle;
  4. 执行 Controller 方法;
  5. 执行拦截器的 postHandle;
  6. 处理视图或响应,最后触发 afterCompletion。

@ControllerAdvice 的两个分支分别挂在这条主干的特定环节上:

  1. ResponseBodyAdvice 分支 :RequestMappingHandlerAdapter 在启动时会扫描所有 @ControllerAdvice Bean,把实现了 ResponseBodyAdvice 的 Bean 收集到 requestResponseBodyAdvice 列表中。当 Controller 返回值需要写出时,AbstractMessageConverterMethodProcessor.writeWithMessageConverters 会遍历这些 Advice,调用 beforeBodyWrite,这正是自动包装的切入点。
  2. ExceptionHandler 分支 :DispatcherServlet 初始化时会创建 ExceptionHandlerExceptionResolver,它同样扫描 @ControllerAdvice Bean,并通过 ExceptionHandlerMethodResolver 建立 "异常类型 → 处理方法" 的映射。当 Controller 抛出异常时,Resolver 按匹配深度排序,选出最精确的 @ExceptionHandler 方法执行。

这两个分支的触发时机不同:ResponseBodyAdvice 在 正常返回路径 上生效,@ExceptionHandler 在 异常路径 上生效。如果你的统一异常处理返回的 Result 又被 ResponseBodyAdvice 包装了一层,就会产生嵌套。这也是为什么 beforeBodyWrite 中需要判断 body instanceof Result 并直接返回------正常路径和异常路径的返回值最终汇入同一个写出环节,必须避免重复包装。

5. 前端 Ajax 配合改造

后端统一之后,前端也需要做相应调整。核心变化只有两点:响应体结构变了 ,以及未登录时 HTTP 状态码是 401。

对于 401 ,Ajax 的 error 回调统一处理:

javascript 复制代码
error: function (xhr) {
    if (xhr.status === 401) {
        alert("登录已失效,请重新登录");
        location.href = "login.html";
    }
}

对于 成功响应 ,前端不再直接从顶层字段读取业务数据,而是先判断 result.status,再从 result.data 中取数据:

javascript 复制代码
success: function (result) {
    if (result.status === "SUCCESS") {
        var bookList = result.data;  // 真正的业务数据在 data 里
        // 渲染页面......
    } else {
        alert(result.errorMessage);
    }
}

登录接口的返回值也会被统一包装,因此前端判断登录是否成功的条件从原来的 result === true 变为 result.status === "SUCCESS" && result.data === true。这一步看起来微小,却是前后端契约对齐的关键:后端所有接口无论正常还是异常,前端永远面对同一种 JSON 结构。

总结

本文覆盖的三项能力,本质上是在解决同一个问题:把与业务无关的横切关注点从 Controller 中抽离,用框架的统一入口集中处理。

  1. 拦截器 :在 preHandle 中完成登录校验,用 excludePathPatterns 精确控制放行范围;注意异步请求下 postHandle/afterCompletion 的调用时机差异。
  2. 统一数据返回 :@ControllerAdvice + ResponseBodyAdvice 在响应写出前自动包装,但必须单独处理 String 类型,否则会因 StringHttpMessageConverter 的类型期望而抛出 ClassCastException。
  3. 统一异常处理 :@ControllerAdvice + @ExceptionHandler 分层捕获,精确异常优先、Exception 兜底;生产环境对外隐藏堆栈,对内记录完整日志。
  4. 前端配合 :401 走 error 回调跳登录,正常响应统一从 result.data 取业务数据。

三项能力串在一起,后端的接口从此有了稳定的 "外形",前端的解析逻辑也只需要写一次。这就是 SpringBoot 统一功能处理真正的价值所在。


相关推荐
pan_junbiao1 小时前
Go语言中的空白标识符
开发语言·后端·golang
SL-staff1 小时前
JVS-Rules金融风控实践:四大原生能力的技术实现与可落地配置指南
java·决策树·规则引擎·函数计算·金融风控·jvs-rules·数据源抽象
Thomas.Sir2 小时前
第6课:SpringBoot4.x 核心注解全解:Component、Bean、Scope、Lazy等IOC相关注解
java·spring boot
paopaokaka_luck2 小时前
美食推荐系统(协同过滤算法推荐、AI助手、HanLP中文分词、 ECharts可视化分析、菜谱学习与话题交流)
java·spring boot·mysql·tomcat·echarts
开源推荐官2 小时前
2026 年开源商城源码选型参考:技术栈、功能与适用场景梳理
java·架构·开源
sunburn-2 小时前
数据库操作完整教程:从基础到实战
java·数据库·mysql
文丑颜不良啊2 小时前
LeetCode576~600题目解析
java·leetcode
Linux运维技术栈2 小时前
CVE-2026-91403 高危漏洞预警:Java JSSE 信任链被攻破 全场景修复方案与Linux系统级彻底清理实操
java·linux·安全·jdk·信任链
IT_陈寒2 小时前
SpringBoot性能优化完全指南
前端·人工智能·后端