通过本文我们将:
- 掌握拦截器的使用及其原理
- 学习统一数据返回格式和统一异常处理的操作
- 了解一些 Spring 的源码
文章目录
- 1. 拦截器:把登录校验从每个接口里抽出来
-
- 1.1 为什么需要拦截器
- 1.2 快速入门:实现 HandlerInterceptor
- 1.3 注册拦截器与路径控制
- 1.4 实战:用拦截器完成登录校验
- 2. 统一数据返回格式:让所有接口说同一种“语言”
-
- 2.1 手动包装的痛点
- 2.2 @ControllerAdvice + ResponseBodyAdvice
- 2.3 为什么返回 String 时会报 ClassCastException
- 2.4 一个值得知道的权衡
- 3. 统一异常处理:不让异常“裸奔”到前端
-
- 3.1 为什么需要全局异常处理
- 3.2 快速入门:@ControllerAdvice + @ExceptionHandler
- 3.3 分层捕获:精确异常优先,Exception 兜底
- 4. 源码脉络:DispatcherServlet 与 @ControllerAdvice 的协作
- 5. 前端 Ajax 配合改造
- 总结
在真实的 Web 项目中,我们很快会遇到三类 “重复且分散” 的问题:
SpringBoot 提供了一套统一的机制,让我们把这些横切逻辑从业务代码中抽离出来。
本文按照拦截器 → 统一数据返回 → 统一异常处理 → 源码脉络 → 前端配合的顺序,带你从零理解并落地这三项能力。
1. 拦截器:把登录校验从每个接口里抽出来
1.1 为什么需要拦截器
在没有拦截器之前,实现强制登录最常见的写法是在每个接口方法里都检查一遍 Session:
@RequestMapping("/getListByPage")
public Result getListByPage(PageRequest pageRequest, HttpSession session) {
if (session.getAttribute(SESSION_USER_KEY) == null) {
return Result.unlogin();
}
// 真正的业务逻辑……
}
问题很明显:接口有多少个,这段重复代码就要写多少遍;将来登录规则一变,所有接口都得跟着改。拦截器正是为了解决这类 “每个请求都要做同一件事” 的场景而设计的。它在请求进入 Controller 之前、Controller 执行之后、请求完全结束之后,提供了三个可插入自定义逻辑的时机。
后端正常的调用顺序:
用户
↓ 调用
Controller控制层
↓
Service服务层
↓
Mapper数据持久层
↓ 调用
DataBase数据库
加入拦截器之后的调用顺序:
用户
↓ 调用
Interceptor拦截器 → false:拦截
↓ true:放行
Controller控制层
↓
Service服务层
↓
Mapper数据持久层
↓ 调用
DataBase数据库
添加拦截器后,在执行 Controller 的方法之前请求会先被拦截器拦截住并执行 preHandle 方法,这个方法需要返回一个布尔类型的值:如果返回 true,就表示放行本次请求,继续访问 Controller 中的方法;如果返回 false,则不会放行,Controller 中的方法也不会执行。
1.2 快速入门:实现 HandlerInterceptor
自定义拦截器只需实现 HandlerInterceptor 接口,重写三个方法:
@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 不会执行,它后面的拦截器也不会执行。这是清理资源、记录最终耗时最合适的位置。
正常请求下三个方法的执行顺序是:
preHandle
↓
Controller 方法
↓
postHandle
↓
afterCompletion
如果把 preHandle 的返回值改成 false,日志会停在 preHandle,请求不会再进入 Controller,响应也不会正常渲染。
- 一个容易被忽略的细节:异步请求。:
- 当 Controller 返回 Callable 或使用 DeferredResult 时,Servlet 主线程会先退出,此时 postHandle 和 afterCompletion 不会在本次线程中执行。等异步处理完成、请求重新 dispatch 回来时,框架会再次调用 preHandle 和 postHandle。如果你的拦截器依赖 postHandle 做收尾工作,在异步场景下需要改用 AsyncHandlerInterceptor 的 afterConcurrentHandlingStarted,或者确保逻辑放在 afterCompletion 中。
1.3 注册拦截器与路径控制
定义好拦截器后,需要通过 WebMvcConfigurer 的 addInterceptors 把它注册进去,并指定拦截范围和排除范围:
@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,所有接口就自动拥有了登录保护:
@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 的返回值统一加工:
@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 内部逻辑决定。
要让 supports 返回 true 真正起到 “统一包装”的效果,下面三件事得同时成立:
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface ControllerAdvice {
/**
* basePackages 属性的别名。
* 允许更简洁的注释声明——例如,@ControllerAdvice(“org.my.pkg”) 等效于 @ControllerAdvices(basePackages=“org.my.pkg”)
* @since 4.0
*/
@AliasFor("basePackages")
String[] value() default {};
@AliasFor("value")
String[] basePackages() default {};
Class<?>[] assignableTypes() default {};
Class<? extends Annotation>[] annotations() default {};
}
/**
* 用于在HTTP请求和响应之间进行转换的策略接口。
*
* @author Arjen Poutsma
* @author Juergen Hoeller
* @author Rossen Stoyanchev
* @since 3.0
* @param <T> 转换后的对象类型
*/
public interface HttpMessageConverter<T> {
/**
* 指示此转换器是否可以读取给定的类。
* @param clazz 测试可读性的类
* @param mediaType 要读取的媒体类型(如果未指定,可以是null);通常是报头中 Content-Type 属性的值。
* @return 如果可读,则为true;否则为false
*/
boolean canRead(Class<?> clazz, @Nullable MediaType mediaType);
/**
* 指明给定的类是否可以由此转换器编写。
* @param clazz 测试可写性的类
* @param mediaType 要写入的媒体类型(如果未指定,则可以为null);通常是报头中 Accept 属性的值。
* @return 如果可写,则为true;否则为false
*/
boolean canWrite(Class<?> clazz, @Nullable MediaType mediaType);
/**
* 返回此转换器支持的媒体类型列表。该列表可能不适用于所有可能的目标元素类型,因此通常应通过canWrite(clazz, null)方法来保护对此方法的调用。该列表可能还会排除仅特定类支持的MIME类型。或者,使用getSupportedMediaTypes(Class)获取更精确的列表。
* @return 支持的媒体类型列表
*/
List<MediaType> getSupportedMediaTypes();
/**
* 返回此转换器针对给定类支持的媒体类型列表。如果转换器不支持给定的类,或者仅支持其中的一部分媒体类型,则该列表可能与getSupportedMediaTypes()返回的列表不同。
* @param clazz 指定的类
* @return 给定类支持的媒体类型列表
* @since 5.3.4
*/
default List<MediaType> getSupportedMediaTypes(Class<?> clazz) {
return (canRead(clazz, null) || canWrite(clazz, null) ?
getSupportedMediaTypes() : Collections.emptyList());
}
/**
* 从给定的输入消息中读取给定类型的对象,并返回该对象。
* @param clazz 要返回的对象类型。此类型必须之前已传递给此接口的canRead方法,且该方法必须返回true。
* @param inputMessage 要读取的HTTP输入消息
* @return 转换后的对象
* @throws IOException 在发生I/O错误的情况下
* @throws HttpMessageNotReadableException 以防出现转换错误
*/
T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException;
/**
* 将给定对象写入给定的输出消息中。
* @param t 要写入输出消息的对象。此对象的类型必须之前已传递给此接口的canWrite方法,且该方法必须返回true。
* @param contentType 写入时要使用的内容类型。可以为 null,表示必须使用转换器的默认内容类型。如果非 null,则此媒体类型必须之前已传递给此接口的 canWrite 方法,且该方法必须返回 true。
* @param outputMessage 要写的信息
* @throws IOException 在发生I/O错误的情况下
* @throws HttpMessageNotWritableException 以防出现转换错误
*/
void write(T t, @Nullable MediaType contentType, HttpOutputMessage outputMessage)
throws IOException, HttpMessageNotWritableException;
}
/**
* 允许在执行@ResponseBody或 ResponseEntity控制器方法之后,
* 但在使用HttpMessageConverter写入响应体之前,自定义响应。
* 实现类可以直接注册到 RequestMappingHandlerAdapter和 ExceptionHandlerExceptionResolver中,
* 或者更常见的是使用 @ControllerAdvice进行注解,在这种情况下,它们将被两者自动检测到。
* @author Rossen Stoyanchev
* @since 4.1
* @param <T> 响应体类型
*/
public interface ResponseBodyAdvice<T> {
/**
* 此组件是否支持给定的Controller方法返回类型和所选的 HttpMessageConverter类型。
* @param returnType 返回的类型
* @param converterType 所选的 HttpMessageConverter类型
* @return 如果应该调用beforeBodyWrite,则为true;否则为false
*/
boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
/**
* 在选择了HttpMessageConverter之后,且在其write方法被调用之前调用。
* @param body 要被写入的响应体
* @param returnType Controller方法的返回类型
* @param selectedContentType 通过内容协商选择的内容类型
* @param selectedConverterType 选择写入响应的转换器类型
* @param request 当前请求
* @param response 当前响应
* @return 被传入的实体或一个修改后的(可能是新的)实例
*/
@Nullable
T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response);
}
2.3 为什么返回 String 时会报 ClassCastException
如果你直接让 beforeBodyWrite 无差别地 return Result.success(body),访问一个返回 String 的接口时,会遇到:
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
@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,因此精确匹配的方法优先执行。
基于这个规则,推荐的组织方式是:
@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 方法。它的主干流程是:
@ControllerAdvice 的两个分支分别挂在这条主干的特定环节上:
这两个分支的触发时机不同:ResponseBodyAdvice 在 正常返回路径 上生效,@ExceptionHandler 在 异常路径 上生效。如果你的统一异常处理返回的 Result 又被 ResponseBodyAdvice 包装了一层,就会产生嵌套。这也是为什么 beforeBodyWrite 中需要判断 body instanceof Result 并直接返回——正常路径和异常路径的返回值最终汇入同一个写出环节,必须避免重复包装。
5. 前端 Ajax 配合改造
后端统一之后,前端也需要做相应调整。核心变化只有两点:响应体结构变了,以及未登录时 HTTP 状态码是 401。
对于 401,Ajax 的 error 回调统一处理:
error: function (xhr) {
if (xhr.status === 401) {
alert("登录已失效,请重新登录");
location.href = "login.html";
}
}
对于 成功响应,前端不再直接从顶层字段读取业务数据,而是先判断 result.status,再从 result.data 中取数据:
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 中抽离,用框架的统一入口集中处理。
三项能力串在一起,后端的接口从此有了稳定的 “外形”,前端的解析逻辑也只需要写一次。这就是 SpringBoot 统一功能处理真正的价值所在。
网硕互联帮助中心



评论前必须登录!
注册