通过本文我们将:
- 掌握拦截器的使用及其原理
- 学习统一数据返回格式和统一异常处理的操作
- 了解一些 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 项目中,我们很快会遇到三类 "重复且分散" 的问题:
- 每个接口都要手写登录校验
- 每个 Controller 都要手动包装返回结果
- 每个方法里都要 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 方法。它的主干流程是:
- 通过
HandlerMapping找到当前请求对应的HandlerExecutionChain(包含 Handler 和拦截器链); - 通过
HandlerAdapter适配并准备执行 Handler; - 执行
applyPreHandle,依次调用拦截器的preHandle; - 执行 Controller 方法;
- 执行拦截器的
postHandle; - 处理视图或响应,最后触发
afterCompletion。
@ControllerAdvice 的两个分支分别挂在这条主干的特定环节上:
- ResponseBodyAdvice 分支 :
RequestMappingHandlerAdapter在启动时会扫描所有@ControllerAdviceBean,把实现了ResponseBodyAdvice的 Bean 收集到requestResponseBodyAdvice列表中。当 Controller 返回值需要写出时,AbstractMessageConverterMethodProcessor.writeWithMessageConverters会遍历这些 Advice,调用beforeBodyWrite,这正是自动包装的切入点。 - ExceptionHandler 分支 :
DispatcherServlet初始化时会创建ExceptionHandlerExceptionResolver,它同样扫描@ControllerAdviceBean,并通过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 中抽离,用框架的统一入口集中处理。
- 拦截器 :在
preHandle中完成登录校验,用excludePathPatterns精确控制放行范围;注意异步请求下postHandle/afterCompletion的调用时机差异。 - 统一数据返回 :
@ControllerAdvice+ResponseBodyAdvice在响应写出前自动包装,但必须单独处理String类型,否则会因StringHttpMessageConverter的类型期望而抛出ClassCastException。 - 统一异常处理 :
@ControllerAdvice+@ExceptionHandler分层捕获,精确异常优先、Exception兜底;生产环境对外隐藏堆栈,对内记录完整日志。 - 前端配合 :
401走error回调跳登录,正常响应统一从result.data取业务数据。
三项能力串在一起,后端的接口从此有了稳定的 "外形",前端的解析逻辑也只需要写一次。这就是 SpringBoot 统一功能处理真正的价值所在。