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