本节目标:设计一个 ApiResponse 包装体,用 ResponseBodyAdvice 自动包装成功响应,避开泛型与 String 转换器的坑,并判断统一响应到底该不该用。
适用版本:Spring Boot 4.1.x(Java 21)
10.3 统一响应结构
10.2 解决了「异常集中处理」,但成功响应和失败响应还是两副面孔:成功时控制器直接返回 Book 对象,失败时返回 ErrorBody。客户端要写两套解析逻辑。本节把两者统一成同一个外形,并认真讨论它的代价。
10.3.1 设计 ApiResponse
先定义一个包装体。用 record 最简洁,字段固定为 code / message / data / timestamp:
package com.example.library.web.dto;
public record ApiResponse<T>(int code, String message, T data, long timestamp) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(0, "ok", data, System.currentTimeMillis());
}
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, message, null, System.currentTimeMillis());
}
}
四个字段的分工:
| 字段 | 作用 | 说明 |
|---|---|---|
code | 业务码 | 0 表示成功,非 0 表示各类错误 |
message | 可读消息 | 面向开发者或直接展示给用户 |
data | 业务数据 | 成功时为资源,失败时为 null |
timestamp | 服务端时间 | 便于排查与时序对齐 |
10.3.2 用 ResponseBodyAdvice 自动包装成功响应
如果每个控制器方法都手写 ApiResponse.success(...),那和 10.1 的重复问题没区别。正确做法是让框架在序列化之前自动包一层,用 ResponseBodyAdvice:
package com.example.library.web.advice;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
import tools.jackson.databind.json.JsonMapper;
@RestControllerAdvice
public class GlobalResponseAdvice implements ResponseBodyAdvice<Object> {
private final JsonMapper jsonMapper;
public GlobalResponseAdvice(JsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
}
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
// 已经是 ApiResponse 的、以及 ResponseEntity 包装的,不再二次包装
Class<?> type = returnType.getParameterType();
return !ApiResponse.class.isAssignableFrom(type)
&& !ResponseEntity.class.isAssignableFrom(type);
}
@Override
public Object beforeBodyWrite(Object body, MethodParameter returnType,
MediaType selectedContentType,
Class<? extends HttpMessageConverter<?>> selectedConverterType,
ServerHttpRequest request, ServerHttpResponse response) {
if (body instanceof ApiResponse<?>) {
return body;
}
if (body instanceof String text) {
// String 会被 StringHttpMessageConverter 处理,必须手动序列化
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
try {
return jsonMapper.writeValueAsString(ApiResponse.success(text));
} catch (Exception e) {
throw new IllegalStateException("响应序列化失败", e);
}
}
return ApiResponse.success(body);
}
}
要点:
supports决定「哪些响应需要包装」。把ApiResponse与ResponseEntity排除掉,避免重复包装。beforeBodyWrite在消息转换器写出之前被调用,是插入包装的唯一时机。- 注入的是 Jackson 3 的
JsonMapper(Spring Boot 4.x 自动配置),不是 Jackson 2 的ObjectMapper。
10.3.3 泛型擦除的坑与解法
统一响应体最容易翻车的地方有两处,都源于「运行时拿不到泛型信息」。
坑一:String 返回值触发 ClassCastException。 当控制器方法声明返回 String 时,Spring 为它选的是 StringHttpMessageConverter。如果你在 beforeBodyWrite 里把 String 换成 ApiResponse 对象,转换器却仍按 String 处理,就会抛 ClassCastException。解法就是上面代码里的 body instanceof String 分支:手动把 ApiResponse 序列化成 JSON 字符串再返回,并显式把 Content-Type 设为 application/json。
坑二:客户端反序列化丢失类型。 运行时 ApiResponse<T> 的 T 已被擦除,服务端序列化没问题,但客户端若直接用 ApiResponse.class 反序列化,data 会变成 LinkedHashMap 而不是目标类型。Java 客户端需要用 TypeReference 保留泛型:
ApiResponse<BookResponse> resp = jsonMapper.readValue(
json,
jsonMapper.getTypeFactory().constructParametricType(ApiResponse.class, BookResponse.class));
这是统一响应的固有成本——服务端省事,客户端多一层泛型声明。
10.3.4 统一响应体的代价
统一响应体不是「最佳实践」的同义词,它有明确的代价,值得在采用前想清楚。
| 代价 | 具体表现 |
|---|---|
| 与 HTTP 状态码语义重复 | 响应体里的 code 与 HTTP 状态码表达同一件事,两处可能不一致 |
| 对第三方客户端不友好 | 公开 API 的调用方期望直接拿到资源,多一层信封增加适配成本 |
| 破坏部分框架约定 | OpenAPI/代码生成、部分 HTTP 客户端按原始 body 建模,信封会打乱映射 |
| 例外端点增多 | 文件下载、流式响应、204 No Content 不能包装,需逐个放行 |
「该用」与「不该用」的判断依据:
| 场景 | 建议 | 原因 |
|---|---|---|
| 公司内部前后端分离项目 | 该用 | 前后端可约定统一解析,省去大量样板 |
| 面向 App/小程序的私有 API | 该用 | 客户端完全可控,信封便于统一处理错误提示 |
| 对外开放的公共 API | 不该用 | 第三方期望标准 HTTP 语义,信封是额外负担 |
| 文件下载 / 图片 / 流式接口 | 不该用 | 二进制或流不能套 JSON 信封 |
| 以 HTTP 状态码为主的 REST 服务 | 不该用 | 信封的 code 与状态码职责重叠 |
一个务实的折中:保留正确的 HTTP 状态码,同时在 body 里带业务码。HTTP 状态码交给网关、监控、浏览器理解;业务码交给客户端业务逻辑。两者各司其职,而不是二选一。
10.3.5 错误码设计
错误码要在项目起步时定好,中途改代价极大。两条基本约定:
- 业务码与 HTTP 码分开。业务码是应用层的稳定契约,HTTP 码是协议层的通用语义,二者可以并存但不能互相冒充。
- 按码段划分领域。让「看到码就知道归属」。
| 码段 | 归属 | 示例 |
|---|---|---|
0 | 成功 | 0 |
10xxx | 通用/参数 | 10000 参数错误,10001 未认证,10002 无权限 |
20xxx | 用户与权限 | 20001 用户不存在,20002 密码错误 |
30xxx | 图书业务 | 30001 图书不存在,30002 ISBN 重复 |
50xxx | 服务端 | 50000 内部错误,50001 依赖服务超时 |
用枚举集中管理,避免字符串散落各处:
public enum ErrorCode {
PARAM_INVALID(10000, "参数错误"),
BOOK_NOT_FOUND(30001, "图书不存在"),
ISBN_DUPLICATE(30002, "ISBN 已存在"),
INTERNAL_ERROR(50000, "服务器内部错误");
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int code() {
return code;
}
public String message() {
return message;
}
}
10.3.6 把校验失败纳入统一结构
10.2 的校验处理器返回的是 ErrorBody。现在换成 ApiResponse,并把 9 章的字段错误暴露出来——这正是 9.3 结尾埋下的伏笔:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> fields = new LinkedHashMap<>();
for (FieldError fe : ex.getBindingResult().getFieldErrors()) {
fields.putIfAbsent(fe.getField(), fe.getDefaultMessage());
}
ApiResponse<Map<String, String>> body =
new ApiResponse<>(ErrorCode.PARAM_INVALID.code(),
ErrorCode.PARAM_INVALID.message(), fields, System.currentTimeMillis());
return ResponseEntity.badRequest().body(body);
}
客户端拿到的 data 是「字段名 → 错误消息」的映射,可以直接高亮到表单对应输入框。
10.3.7 例外处理:文件下载与 204
自动包装必须放过两类响应,否则会坏功能:
204 No Content:本就没有响应体,包一层信封反而产生 body,破坏语义。- 文件下载 / 二进制:返回
Resource、byte[]、InputStreamResource时,body 是二进制流,不能当 JSON 包装。
在 supports 或 beforeBodyWrite 里按返回类型与 Content-Type 放行:
@Override
public boolean supports(MethodParameter returnType,
Class<? extends HttpMessageConverter<?>> converterType) {
Class<?> type = returnType.getParameterType();
if (Resource.class.isAssignableFrom(type)
|| byte[].class.equals(type)
|| ResponseEntity.class.isAssignableFrom(type)
|| ApiResponse.class.isAssignableFrom(type)) {
return false;
}
return true;
}
Resource 覆盖了 FileSystemResource、ClassPathResource、InputStreamResource 等常见下载返回类型。返回 ResponseEntity 的接口也一律放行——它通常已经自行控制了状态码与 body,不该再被包装。
10.3.8 完整实现与 curl 实测
把本节所有内容落成一套可用实现。包装体与错误码:
public record ApiResponse<T>(int code, String message, T data, long timestamp) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(0, "ok", data, System.currentTimeMillis());
}
}
GlobalResponseAdvice 用 10.3.2 的实现,GlobalExceptionHandler 同时处理业务异常与校验异常:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(BookNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(ErrorCode.BOOK_NOT_FOUND.code(),
ErrorCode.BOOK_NOT_FOUND.message()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleAny(Exception ex) {
log.error("unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(ErrorCode.INTERNAL_ERROR.code(),
ErrorCode.INTERNAL_ERROR.message()));
}
}
成功响应实测:
curl -s http://localhost:8080/api/books/1
{
"code": 0,
"message": "ok",
"data": {
"id": 1,
"title": "Effective Java",
"isbn": "978-0134685991"
},
"timestamp": 1789000000000
}
校验失败实测(title 为空):
curl -s -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"","isbn":"978-0134685991","price":68}'
{
"code": 10000,
"message": "参数错误",
"data": {
"title": "书名不能为空"
},
"timestamp": 1789000000123
}
注意 HTTP 状态码仍是 400——状态码与业务码各管一段,这正是 10.3.4 建议的折中方案。
小结
ApiResponse<T>用code / message / data / timestamp统一成功与失败的外形,成功用code=0。- 用
ResponseBodyAdvice在序列化前自动包装成功响应,避免在每个控制器里手写包装。 - 泛型擦除有两个坑:返回
String时要手动序列化并改Content-Type,客户端反序列化要保留泛型(TypeReference)。 - 统一响应有代价:与 HTTP 状态码语义重复、对第三方不友好、需要为文件下载与 204 放行。内部 API 适合用,公共 API 与二进制/流式接口不适合。
- 错误码按码段划分领域,业务码与 HTTP 码并存而非互相替代。
- 把 9 章的校验失败纳入统一结构,
data返回「字段 → 消息」映射,前端可直接高亮表单。 - 自动包装必须放行
Resource、byte[]、ResponseEntity与204,否则会破坏下载与无体响应。
至此,图书接口的错误处理与响应结构已经完整:异常集中处理、状态码语义正确、成功与失败同一外形、校验细节可读。下一章我们回到 Web 层的其它基础能力——静态资源、CORS 与拦截器。
阅读导航:上一节:10.2 @ControllerAdvice 全局处理 · 下一节:11.1 静态资源与 WebJars 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。