《Spring Boot 入门》10.3 统一响应结构

设计一个 ApiResponse<T> 包装体,用 ResponseBodyAdvice 自动包装成功响应并避开泛型擦除与 String 转换器的坑,同时讨论统一响应体的代价——与 HTTP 状态码语义重复、对第三方客户端不友好、文件下载与 204 的例外,给出该用与不该用的判断依据,并输出一套完整实现与 curl 实测响应。

本节目标:设计一个 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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计