news 2026/9/24 17:55:16

Spring Boot 统一返回体 + 全局异常处理:让接口不再吐 500 堆栈

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 统一返回体 + 全局异常处理:让接口不再吐 500 堆栈

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 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)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/listsuccess,data 里有 6 条正常路径
localhost:8080/book/list?uid=-1uid 不能为负数精确命中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这种错也能给出准确的中文提示,不用再拿"系统繁忙"糊弄。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 17:54:12

DeepSeek LeetCode 103. 二叉树的锯齿形层序遍历 Golang实现

LeetCode 103. 二叉树的锯齿形层序遍历 Golang 实现 思路 与普通层序遍历一致&#xff0c;用队列逐层处理。区别在于需要交替改变每层的填充方向&#xff1a;维护布尔变量 leftToRight 表示当前层方向。每层预分配 level : make([]int, size)&#xff1a;从左到右&#x…

作者头像 李华
网站建设 2026/9/24 17:54:00

系统分析师之信息化基础知识

知识点&#xff1a;信息工程方法信息工程是面向企业计算机信息系统建设&#xff0c;以数据为中心的开发方法。信息工程方法认为&#xff0c;与企业的信息系统密切相关的三要素是企业的各种信息、企业的业务过程和企业采用的信息技术。信息工程自上而下地将整个信息系统的开发过…

作者头像 李华
网站建设 2026/9/24 17:52:37

TCP协议栈管理、文件符表映射机制与TCP资源关闭流程介绍

文章目录 一、进程与内核 1.用户态的Java程序进程 2.内核态的操作系统内核 2.1操作系统 2.1.1内核 二、文件描述符与表引用 1.文件描述符 2.文件描述符表 3.引用比例 三、TCP资源管理与连接维护 1.TCP协议栈 1.1TCB 1.1.1端点 1.1.1.1TCP连接状态 四、Socket引用…

作者头像 李华
网站建设 2026/9/24 17:51:44

【n8n】AI驱动的短视频全自动生成应用

短视频内容的生产速度和创意质量已成为社交平台竞争的核心。借助AI与自动化技术,短视频的生成方式正从依赖人工转向全流程自动化生产模式。 该工作流以AI模型与自动化节点协作实现从创意到成品的短视频生成,涵盖文案构思、画面渲染、视频合成及语音配音等环节,适合批量化、…

作者头像 李华
网站建设 2026/9/24 17:50:39

【C#桌面客户端系列学习-1】打包exe,并根据版本号实现自动更新

目录 一、前言 二、主要用到的技术 1、AutoUpdater.NET 2、Fody.Costura 三、安装相关程序包 1、安装Autoupdater.NET.Official 2、安装Fody.Costura 四、配置版本号 五、准备更新用的XML文件 六、代码中调用 七、制作并上传更新包 八、测试是否成功 九、注意事项 …

作者头像 李华