通过本文我们将:
- 掌握拦截器的使用及其原理
- 学习统一数据返回格式和统一异常处理的操作
- 了解一些 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 项目中,我们很快会遇到三类 “重复且分散” 的问题:
- 每个接口都要手写登录校验
- 每个 Controller 都要手动包装返回结果
- 每个方法里都要 try-catch 处理异常。
SpringBoot 提供了一套统一的机制,让我们把这些横切逻辑从业务代码中抽离出来。
本文按照拦截器 → 统一数据返回 → 统一异常处理 → 源码脉络 → 前端配合的顺序,带你从零理解并落地这三项能力。
1. 拦截器:把登录校验从每个接口里抽出来
1.1 为什么需要拦截器
在没有拦截器之前,实现强制登录最常见的写法是在每个接口方法里都检查一遍 Session:
@RequestMapping("/getListByPage")publicResultgetListByPage(PageRequestpageRequest,HttpSessionsession){if(session.getAttribute(SESSION_USER_KEY)==null){returnResult.unlogin();}// 真正的业务逻辑……}问题很明显:接口有多少个,这段重复代码就要写多少遍;将来登录规则一变,所有接口都得跟着改。拦截器正是为了解决这类 “每个请求都要做同一件事” 的场景而设计的。它在请求进入 Controller 之前、Controller 执行之后、请求完全结束之后,提供了三个可插入自定义逻辑的时机。
1.2 快速入门:实现 HandlerInterceptor
自定义拦截器只需实现HandlerInterceptor接口,重写三个方法:
@Slf4j@ComponentpublicclassLoginInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{log.info("preHandle:目标方法执行前");returntrue;}@OverridepublicvoidpostHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,ModelAndViewmodelAndView)throwsException{log.info("postHandle:目标方法执行后");}@OverridepublicvoidafterCompletion(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler,Exceptionex)throwsException{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控制层 ↓ Service服务层 ↓ Mapper数据持久层 ↓ 调用 DataBase数据库加入拦截器之后的调用顺序:
用户 ↓ 调用 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把它注册进去,并指定拦截范围和排除范围:
@ConfigurationpublicclassWebConfigimplementsWebMvcConfigurer{@AutowiredprivateLoginInterceptorloginInterceptor;@OverridepublicvoidaddInterceptors(InterceptorRegistryregistry){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@ComponentpublicclassLoginInterceptorimplementsHandlerInterceptor{@OverridepublicbooleanpreHandle(HttpServletRequestrequest,HttpServletResponseresponse,Objecthandler)throwsException{HttpSessionsession=request.getSession(false);if(session!=null&&session.getAttribute(Constants.SESSION_USER_KEY)!=null){returntrue;}response.setStatus(401);returnfalse;}}这里设置 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 的返回值统一加工:
@ControllerAdvicepublicclassResponseAdviceimplementsResponseBodyAdvice<Object>{privatestaticfinalObjectMappermapper=newObjectMapper();@Overridepublicbooleansupports(MethodParameterreturnType,ClassconverterType){returntrue;}@OverridepublicObjectbeforeBodyWrite(Objectbody,MethodParameterreturnType,MediaTypeselectedContentType,ClassselectedConverterType,ServerHttpRequestrequest,ServerHttpResponseresponse){if(bodyinstanceofResult){returnbody;}if(bodyinstanceofString){try{returnmapper.writeValueAsString(Result.success(body));}catch(Exceptione){thrownewRuntimeException(e);}}returnResult.success(body);}}supports返回true表示:对于被@ControllerAdvice覆盖、且通过HttpMessageConverter写出响应体的 Controller 方法,允许调用beforeBodyWrite进行包装;返回false则跳过该 Advice。返回true只是“允许调用”,是否真正包装还由beforeBodyWrite内部逻辑决定。
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
@RestControllerAdvicepublicclassGlobalExceptionHandler{@ExceptionHandler(Exception.class)publicResulthandleException(Exceptione){returnResult.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@Slf4jpublicclassGlobalExceptionHandler{// 业务异常:可预期,返回具体提示@ExceptionHandler(BusinessException.class)publicResulthandleBusiness(BusinessExceptione){log.warn("业务异常: code={}, msg={}",e.getCode(),e.getMessage());returnResult.fail(e.getCode(),e.getMessage());}// 参数校验异常@ExceptionHandler(MethodArgumentNotValidException.class)publicResulthandleValidation(MethodArgumentNotValidExceptione){Stringmsg=e.getBindingResult().getFieldError().getDefaultMessage();returnResult.fail("PARAM_INVALID",msg);}// 兜底:非预期异常,隐藏细节,记录完整堆栈@ExceptionHandler(Exception.class)publicResulthandleUnexpected(Exceptione){log.error("未预期异常",e);returnResult.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回调统一处理:
error:function(xhr){if(xhr.status===401){alert("登录已失效,请重新登录");location.href="login.html";}}对于成功响应,前端不再直接从顶层字段读取业务数据,而是先判断result.status,再从result.data中取数据:
success:function(result){if(result.status==="SUCCESS"){varbookList=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 统一功能处理真正的价值所在。