Spring Boot 统一返回结果怎么写
最后更新:2026-10-08
适用场景:Spring Boot 3.x、REST 接口、统一 JSON 返回结构、ResponseEntity、业务码与 HTTP 状态码
前端接一个新接口,最怕每个接口的返回格式都不一样:查用户时直接返回对象,查列表时返回数组,出错时又返回一段字符串。前端不得不为每个接口单独判断,后端排查问题也很难对齐。
统一返回结果能解决这个问题,但不能只写一个 Result<T> 类就算完成。状态码、找不到数据、参数错误和异常响应也得有明确约定。下面用上一篇文章里的用户查询接口,写一套够用、容易维护的返回方式。
一、先看结论
普通业务 JSON 接口可以约定三个字段:
{
"code": "OK",
"message": "成功",
"data": {
"id": 1,
"username": "zhangsan"
}
}
这三个字段的职责很简单:
| 字段 | 用途 |
|---|---|
code | 给前端稳定判断的业务码,例如 OK、USER_NOT_FOUND |
message | 给调用方看的简短说明 |
data | 实际业务数据,错误时可以为 null |
同时保留 HTTP 状态码的含义:查询成功用 200,新增成功可以用 201,资源不存在用 404,参数不合法用 400。不要把所有请求都返回 HTTP 200,再要求前端只看 JSON 里的 code。
本文采用 ResponseEntity<ApiResponse<T>>。ApiResponse<T> 负责 JSON 结构,ResponseEntity 负责 HTTP 状态和响应头。这与 Spring MVC 的 ResponseEntity 设计一致。
二、先想清楚是否需要统一包装
如果项目只有少量内部接口,前端也能直接按 HTTP 状态处理,返回 User 或 ResponseEntity<User> 就足够了,不必为了“统一”增加一层包装。
当项目有多个业务模块、多个前端调用方,或者已经约定业务码时,固定结构更有价值。比如用户不存在、库存不足、订单状态不允许操作,都能用稳定的 code 处理,而不是让前端解析中文 message。
统一结构通常只用于业务 JSON 接口。下面这些响应要单独处理:
- 文件下载和图片响应:保留二进制内容与
Content-Type、Content-Disposition。 - 流式响应和 SSE:保留流格式。
- 健康检查、第三方回调:遵守其既定协议。
204 No Content:按 HTTP 语义不返回响应体。
已有接口如果正在被外部系统调用,修改返回结构属于接口契约变更。先确认调用方,再安排版本或迁移方式。
三、定义 ApiResponse
示例以 Java 17 和 Spring Boot 3.x 为基准,使用 Java record。创建 src/main/java/com/example/demo/web/ApiResponse.java:
package com.example.demo.web;
public record ApiResponse<T>(String code, String message, T data) {
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>("OK", "成功", data);
}
public static <T> ApiResponse<T> error(String code, String message) {
return new ApiResponse<>(code, message, null);
}
}
这里没有再放一个 success 布尔值。项目已经有 HTTP 状态码和业务码,第三个成功标志容易与前两者出现矛盾。例如 HTTP 是 500、code 是 OK、success 又是 false,调用方不知道以哪个为准。
如果你的项目使用 Java 8 或 Java 11,把 record 换成普通类,保留字段、构造方法和 getter 即可;示例的接口设计不用改。
业务码可以先用字符串。它比 0、10001 更容易读,也便于日志检索。等项目确实出现大量重复编码,再集中成常量或枚举,不必一开始就设计庞大的错误码体系。
四、把用户查询接口改成统一返回
假设上一篇 Spring Boot 整合 MyBatis 中已有 User 和 UserService,其中 findById 在查不到用户时返回 null,findAll 返回列表。
创建或修改 UserController:
package com.example.demo.controller;
import com.example.demo.domain.User;
import com.example.demo.service.UserService;
import com.example.demo.web.ApiResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<User>> findById(@PathVariable Long id) {
User user = userService.findById(id);
if (user == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.<User>error(
"USER_NOT_FOUND", "用户不存在"));
}
return ResponseEntity.ok(ApiResponse.ok(user));
}
@GetMapping
public ResponseEntity<ApiResponse<List<User>>> findAll() {
List<User> users = userService.findAll();
return ResponseEntity.ok(ApiResponse.ok(users));
}
}
这里的 ApiResponse.<User>error(...) 是显式指定泛型类型。这样返回值与 ResponseEntity<ApiResponse<User>> 一致,读代码时也能看出该接口正常情况下返回什么数据。
查询到用户
请求:
GET /users/1
HTTP 状态为 200,响应体类似:
{
"code": "OK",
"message": "成功",
"data": {
"id": 1,
"username": "zhangsan",
"email": "[email protected]",
"status": 1,
"createdAt": "2026-10-08T09:30:00"
}
}
用户不存在
请求:
GET /users/9999
HTTP 状态为 404,响应体是:
{
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"data": null
}
这样前端既可以统一处理 404,也可以对 USER_NOT_FOUND 做更细的业务提示。
列表为空
如果没有用户,列表接口应该返回 200 和空数组:
{
"code": "OK",
"message": "成功",
"data": []
}
空列表是一次成功查询,不应伪装成 404。data: [] 也比 data: null 更方便前端直接遍历。
五、业务码和 HTTP 状态码怎么配合
可以先定一个小范围约定:
| 场景 | HTTP 状态 | 业务码示例 | data |
|---|---|---|---|
| 查询成功 | 200 | OK | 对象或数组 |
| 新增成功 | 201 | OK | 新建资源 |
| 参数不合法 | 400 | INVALID_ARGUMENT | null 或字段错误列表 |
| 资源不存在 | 404 | USER_NOT_FOUND | null |
| 状态冲突 | 409 | ORDER_STATE_CONFLICT | null |
| 服务器故障 | 500 | INTERNAL_ERROR | null |
这里的业务码只是示例,项目可以按领域命名。关键是稳定、可区分,不要把异常类名直接当作对外业务码。
为什么错误不能都返回 200
HTTP 状态码不只是给浏览器看的。网关、监控、缓存、重试逻辑和 API 客户端都会读取它。
如果数据库连接失败时仍返回 200,监控可能把失败当成功,前端的通用错误处理也不会生效。JSON 里的业务码适合表达更细的业务含义,不能替代 HTTP 状态。
为什么业务码不要直接等于 HTTP 状态
同样是 404,可能是用户不存在,也可能是订单不存在。前端需要区分时,USER_NOT_FOUND 和 ORDER_NOT_FOUND 比两个 404 更具体。
同样是 409,可以区分“用户名已被占用”和“订单状态不允许取消”。HTTP 状态描述错误类别,业务码描述具体业务情况。
六、新增和删除接口怎么返回
新增资源成功后,可以返回 HTTP 201 Created 和新资源内容。假设 userService.create(request) 返回包含 ID 的 User:
User created = userService.create(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.ok(created));
上面是 Controller 方法里的返回片段,create 的参数与存储逻辑需要按实际项目实现。若前端需要新资源地址,还可以设置 Location 响应头。
删除成功且不需要任何响应体时,可以直接返回 204 No Content:
return ResponseEntity.noContent().build();
这时不要再返回 ApiResponse.ok(null)。204 本身表示响应体为空。如果团队要求删除接口也返回固定 JSON,可以使用 200,并在文档中写清楚约定。
七、分页结果放在哪里
分页接口要把列表和分页信息一起放进 data,不要让 data 有时是数组、有时又突然变成一个带 total 的对象而不说明。
例如:
{
"code": "OK",
"message": "成功",
"data": {
"items": [
{ "id": 1, "username": "zhangsan" },
{ "id": 2, "username": "lisi" }
],
"page": 1,
"size": 10,
"total": 26
}
}
前端可以稳定读取 data.items 和 data.total。普通列表接口继续返回数组也可以,但最好在接口文档里区分“列表”和“分页列表”。
如果使用 MyBatis 分页插件,插件负责生成分页查询;ApiResponse<T> 只负责 HTTP 响应结构。不要把分页插件的内部对象直接作为对外接口格式。
八、统一返回类不能自动处理所有异常
写了 ApiResponse<T>,只会影响你显式返回它的接口。以下错误仍可能走 Spring Boot 默认错误响应:
@RequestBody JSON 格式错误
参数校验失败
请求路径不存在
业务代码抛出异常
数据库或其他依赖故障
所以实际项目通常会配合 @RestControllerAdvice 和 @ExceptionHandler,把需要对外暴露的异常映射成约定格式。Spring MVC 文档中,@RestControllerAdvice 负责让异常处理方法对多个 Controller 生效,并把结果写入响应体。
不过不要为了凑“统一”把所有异常都捕获成 HTTP 200。也不要把 exception.getMessage() 原样返回给用户;数据库、文件路径和内部实现细节可能混在里面。
对于已经使用 Spring 的 ProblemDetail(RFC 9457)错误格式的项目,也可以保留它。Spring Framework 本身支持从异常处理方法返回 ProblemDetail,无需强迫错误响应和成功响应使用同一个外层结构。前后端约定一致即可。
九、怎么验证返回结果
启动项目后,用命令行同时看响应头和响应体:
curl -i http://localhost:8080/users/1
curl -i http://localhost:8080/users/9999
curl -i http://localhost:8080/users
Windows PowerShell 中建议明确调用系统自带的 curl.exe:
curl.exe -i http://localhost:8080/users/1
curl.exe -i http://localhost:8080/users/9999
重点检查四件事:
1. 查到数据时,HTTP 是否为 200,data 是否为对象
2. 查不到用户时,HTTP 是否为 404,code 是否为 USER_NOT_FOUND
3. 列表为空时,data 是否为 []
4. Content-Type 是否为 application/json
如果只看浏览器中的 JSON,而不看 HTTP 状态,很容易漏掉“错误也返回 200”的问题。
常见坑
1. 只有统一返回类,没有统一约定
一个接口 code 用数字,另一个用字符串;有的接口 data 是数组,有的是分页对象却没有说明。前端仍然要写很多特殊判断。
至少先固定字段含义、成功码、错误码命名和常见 HTTP 状态,再推广到其他接口。
2. 把找不到一条数据与列表为空混为一谈
GET /users/9999 找不到用户,可以返回 404。GET /users 没有记录,是一次成功查询,返回 200 和 [] 更自然。
3. 所有错误都塞进 message
前端不应该靠比较“用户不存在”“未找到用户”这类中文文案做判断。文案可以调整,业务码应保持稳定。
4. 对所有返回值强行自动包装
用 ResponseBodyAdvice 可以集中包装响应,但要处理字符串、文件、流式输出、已经包装过的对象和框架错误响应。小项目先在 Controller 显式返回 ApiResponse<T>,更容易看清接口行为。
5. 把内部异常原文放进响应
异常信息适合记录在服务端日志中。对客户端返回稳定的业务码和有限的说明,避免暴露 SQL、连接信息或代码路径。
6. 新旧接口突然切换格式
对外接口的 JSON 结构变动会影响调用方。已有消费者的项目应先约定迁移方案,再修改响应格式。
排查清单
[ ] 业务 JSON 接口是否使用一致的 code、message、data 字段
[ ] HTTP 状态是否与实际结果一致
[ ] 查单条资源不存在时是否返回 404
[ ] 空列表是否返回 []
[ ] 新增和删除接口是否遵守 201、204 等约定
[ ] 前端是否根据稳定业务码判断具体错误
[ ] 参数错误和异常是否有明确的响应处理方式
[ ] 文件、流式和第三方协议接口是否保留原有格式
[ ] 现有调用方是否知道返回结构变更
常见问题 FAQ
1. 统一返回结果一定要有 code、message、data 吗?
不一定。这是常见的业务接口约定,不是 Spring Boot 的强制格式。若项目只靠 HTTP 状态就能表达需求,直接返回资源也可以。
2. success 字段有必要加吗?
通常没有必要。HTTP 状态和业务码已经表达结果,额外的布尔字段容易产生不一致。团队已有固定契约时按契约执行。
3. 业务码用 int 还是 String?
两者都可以。字符串如 USER_NOT_FOUND 可读性更好;数字适合已有完整编码规范的团队。无论选哪种,发布后不要随意改动同一错误的编码。
4. 返回 404 时 data 应该是什么?
上面的约定使用 null。如果项目采用 ProblemDetail,错误体会是另一种结构。选一种契约并保持稳定。
5. List 为空时应该返回 404 吗?
通常返回 200 和空数组。集合资源存在,只是当前没有符合条件的元素。
6. 统一返回类能处理参数校验异常吗?
不能自动处理。校验失败通常需要异常处理逻辑把错误转换为约定响应;这部分适合在全局异常处理里实现。
7. 可以只返回 HTTP 200,用 code 区分错误吗?
技术上可以,但会让通用 HTTP 客户端、监控和网关失去状态信息。业务 API 更建议同时返回正确的 HTTP 状态和稳定业务码。
最后总结
Spring Boot 统一返回结果,先把契约定清楚,再写工具类。对普通业务 JSON 接口,ApiResponse<T> 可以统一 code、message、data;ResponseEntity 负责真正的 HTTP 状态。
从单条查询和列表查询开始最容易验证:查到数据返回 200,单条不存在返回 404,列表为空返回 200 加空数组。异常、文件下载和流式接口按各自的响应方式处理。
相关文章
参考资料
Spring Framework 6.2:ResponseEntity
Spring Framework 6.2:Controller Advice
Spring Framework 6.2:Error Responses
更新记录
2026-10-08:创建文章,增加 ApiResponse、ResponseEntity、业务码与 HTTP 状态码、空列表和异常边界示例。
Spring Boot 统一返回结果怎么写
https://java.li/archives/spring-boot-response-result
评论