Spring Boot 统一返回体 + 全局异常处理:让接口不再吐 500 堆栈
环境:Spring Boot + MyBatis-Plus + Lombok + JDK 17
上一篇把三层架构搭好之后,接口能查数据了,但返回的东西不太像样:直接一个裸数组。而且参数写错的时候,浏览器上是一整页 Java 堆栈。
这篇做两件事:把返回格式统一成Result<T>,再把异常统一兜住。做完之后不管请求成功还是失败,前端拿到的都是同一个结构。
一、为什么要有统一返回体
改造前 Controller 长这样:
@GetMapping("/list")publicList<Book>list(...){returnbookService.listBooks(uid,status,keyword);}成功的时候返回[{...}, {...}]。但问题是,失败的时候返回什么?
前端要写这样的判断逻辑:先看 HTTP 状态码是不是 200,再看返回体是不是数组,再看数组里有没有数据。接口一多,每个都要写一遍。
统一成Result<T>之后,前端只需要看code:
{"code":1,"message":"success","data":[...]}Result 类
packagecom.first.book.common;importlombok.Data;// <T> 是类型参数:T 代表"这个 Result 里装的到底是什么类型"// 装书的列表就是 Result<List<Book>>,装一个用户就是 Result<SysUser>@DatapublicclassResult<T>{// 1 = 成功,0 = 失败privateIntegercode;// 给前端看的提示文字privateStringmessage;// 真正的数据。类型是 T,具体是什么由调用方决定privateTdata;// 成功时用这个:把数据装进来,code 固定 1publicstatic<T>Result<T>success(Tdata){Result<T>result=newResult<>();result.setCode(1);result.setMessage("success");result.setData(data);returnresult;}// 失败时用这个:只传一句提示,data 保持 nullpublicstatic<T>Result<T>error(Stringmessage){Result<T>result=newResult<>();result.setCode(0);result.setMessage(message);returnresult;}}Controller 改两处就行:
@GetMapping("/list")// 返回类型从 List<Book> 变成 Result<List<Book>>publicResult<List<Book>>list(@RequestParam(required=false)Integeruid,@RequestParam(required=false)Integerstatus,@RequestParam(required=false)Stringkeyword){List<Book>books=bookService.listBooks(uid,status,keyword);// 不再直接 return,而是包一层returnResult.success(books);}Service 一行都不用改。包装是 Controller 的活,Service 只管业务,不需要知道"接口要返回成什么格式"。
二、静态方法里为什么要多写一个<T>
这是我卡最久的地方。看这两行的区别:
// 类上的 T —— 实例方法可以直接用publicTgetData(){returndata;}// 静态方法 —— 必须在自己签名里重新声明 <T>publicstatic<T>Result<T>success(Tdata){...}当时盯着看了半天没想通:类上不是已经有<T>了吗,为什么还要再写一个?
关键在"这个 T 是谁的"。
类上的那个T属于实例。你写new Result<String>()的时候,T 才被定下来是 String。也就是说,它得先有个对象,才有意义。
而静态方法不用 new 就能调用。它压根不属于任何实例,所以"类上的 T"对它来说是不存在的。它只能在自己签名里声明一个自己的 T。
这两个 T 连作用域都不是一个层级的,一个是实例级,一个是方法级。
如果漏写会报这个错:
错误: 无法从静态上下文中引用非静态类型 T编译器这句话就是在说:静态方法里没有"当前实例",你说的那个 T 我找不到。
补上之后,类型推断才能工作 —— 传List<Book>进去,返回的就是Result<List<Book>>。后面单元测试里可以直接声明类型接收,不用强转。
三、为什么用静态工厂,不用 new
写成new Result()然后挨个 setter,要三行,而且很容易漏 —— 忘了setCode,返回的 code 就是 null,前端判断全乱。
用静态工厂,一行搞定,而且成功时 message 一定是 “success”、code 一定是 1,把"正确的格式"焊死在方法里。以后要改提示文案,改一处就行,不用翻所有 Controller。
data声明成T而不是Object也是一个道理。写 Object 也能编译,但调用方拿到的类型信息全丢了,取值得强转。用泛型之后,编译器会帮你检查"你装的类型和声明的对不对"。
四、异常怎么兜
上半场只解决了"正常路径返回什么"。但请求出错的时候呢?
改造前访问localhost:8080/book/list?uid=-1,浏览器上是一整页 Java 堆栈。这东西不能说完全没用(开发时能看堆栈),但绝对不能给用户看—— 里面有你项目名、类名、甚至框架版本。
1. 自定义业务异常
packagecom.first.book.exception;// 业务异常:用来表达"这个请求本身就不合法"publicclassBizExceptionextendsRuntimeException{// 只留一个构造方法:把提示语交给父类存着publicBizException(Stringmessage){super(message);}}为什么继承RuntimeException,不继承Exception?
这个点值得说一下。继承Exception是受检异常,编译器会强迫每个调用方要么try-catch,要么在自己方法签名上写throws。
结果就是:listBooks抛了,BookService接口要声明throws BizException,BookController也得处理 —— 一条调用链全被污染,每个中间层都得写一个自己根本不处理的东西。
继承RuntimeException是非受检的:随时能抛,上层不用知道,一路冒泡上去,最后被全局处理器接住。这才是我想要的效果。
super(message)那行是把提示语存进异常对象,后面用e.getMessage()取出来。
2. 全局处理器
packagecom.first.book.exception;importcom.first.book.common.Result;importlombok.extern.slf4j.Slf4j;importorg.springframework.web.bind.annotation.ExceptionHandler;importorg.springframework.web.bind.annotation.RestControllerAdvice;@Slf4j// 这个注解让下面的方法对所有 Controller 生效@RestControllerAdvicepublicclassGlobalExceptionHandler{// 第一层:专门接住 BizException(我们自己主动抛的那种)@ExceptionHandler(BizException.class)publicResult<Void>handleBizException(BizExceptione){// 业务异常是可预期的,用 warn 级别log.warn("业务异常:{}",e.getMessage());// 把抛异常时写的那句提示,原样交给前端returnResult.error(e.getMessage());}// 第二层:兜住所有没被上面接住的异常@ExceptionHandler(Exception.class)publicResult<Void>handleException(Exceptione){// 必须打日志。吞掉异常还不留痕迹,线上出问题你什么都查不到log.error("系统异常",e);// 对外只给一句模糊的话,别把内部细节暴露出去returnResult.error("系统繁忙,请稍后重试");}}返回类型写的Result<Void>,是因为失败响应里没有 data。泛型填Void表示"这里没有数据",不影响 JSON 输出 ——data字段照样是 null。
3. Service 里加个校验
光有处理器还不够,得有人真的抛出来才验证得了:
@OverridepublicList<Book>listBooks(Integeruid,Integerstatus,Stringkeyword){// 新增:业务校验if(uid!=null&&uid<0){thrownewBizException("uid 不能为负数");}QueryWrapper<Book>qw=newQueryWrapper<>();// ... 后面不变}注意这里没有 try-catch。Service 只管"发现问题就抛",至于怎么变成给前端的 JSON,那是全局处理器的活。每个业务方法都能保持干净,这是这套机制最舒服的地方。
五、两个 handler 谁先谁后
不用你操心顺序,Spring 自己按"就近原则"找最匹配的那个。
抛BizException→ 精确命中handleBizException;
抛别的(比如?uid=abc触发的MethodArgumentTypeMismatchException)→ 落到handleException兜底。
关键在于:兜底那个Exception.class一定要有。少了它,没被匹配上的异常照样会变成 500 堆栈,等于白做。
还有一点容易忽略:兜底里必须打日志。
捕获了异常却什么都不记,这叫"吞异常"。线上用户报"接口报错了",你打开日志一片干净 —— 等于自己把眼睛蒙上。
log.error("系统异常", e)的第二个参数传e,才会把完整堆栈打出来。只传字符串的话堆栈就丢了。分工要清楚:完整堆栈写进日志(给自己看),模糊提示返回前端(给用户看),两者别搞反。
六、验收
重启应用,依次访问下面三个地址。
| 请求 | 返回的 message | 走了哪条路 |
|---|---|---|
localhost:8080/book/list | success,data 里有 6 条 | 正常路径 |
localhost:8080/book/list?uid=-1 | uid 不能为负数 | 精确命中handleBizException |
localhost:8080/book/list?uid=abc | 系统繁忙,请稍后重试 | 落到handleException兜底 |
第二个是重点:message就是你在throw new BizException(...)里写的那句话,说明从 Service 抛出来、一路冒泡到被处理器接住、再转成 JSON 的这条链路真的通了。
第三个也值得看一眼。abc转不成数字,Spring 抛的是MethodArgumentTypeMismatchException,它不属于 BizException,所以被兜底接住了。提示语"系统繁忙"其实不准确(明明是参数写错了),这是我故意留的简化,等学到参数校验(Validation)时再补上精确提示。
另外注意:这三个响应的 HTTP 状态码都是 200,不是 500。因为异常已经被处理成正常返回值了。前端靠code判断成败,这是企业项目里的常见做法 —— 否则前端每调一个接口都得同时处理 HTTP 状态码和业务码,反而更乱。
七、我遇到的报错
| 报错 / 现象 | 原因 |
|---|---|
传uid=-1还是吐 500 堆栈 | GlobalExceptionHandler忘了加@RestControllerAdvice,Spring 扫不到它 |
| 返回值不是 JSON 而是页面名 | 用了@ControllerAdvice,少写了 ResponseBody 那半个,要换成@RestControllerAdvice |
找不到符号: 变量 log | @Slf4j没加,或者 IDEA 没识别 lombok(Build → Rebuild) |
找不到符号: 方法 getMessage() | BizException忘了写super(message),消息没存进去 |
| 三个请求全都返回"系统繁忙" | 兜底方法写得太宽,把BizException也吃掉了,检查第一个 handler 的注解参数 |
| 改了没生效 | 应用没重启(Spring Boot 默认不热更新) |
八、下一步
到这里,一个接口的"正常返回"和"出错返回"就都有统一格式了。三层架构负责各层职责分离,Result<T>负责正常路径,@RestControllerAdvice负责异常路径,几块拼起来才像个正经项目。
下一步打算学参数校验(Validation),到时候?uid=abc这种错也能给出准确的中文提示,不用再拿"系统繁忙"糊弄。