RESTful API 设计规范与统一响应封装
REST 被神化得太久了。它不是「用 JSON 传数据的 HTTP 接口」——那叫 HTTP API,不叫 REST。Roy Fielding 在论文里给出的六条约束,本质是在回答一个问题:如何让系统在规模变大时依然可演化。我们只须关心其中能在业务接口里落地的那几条。
| 约束 | 白话解释 | 我们实际能做到的部分 |
|---|---|---|
| 客户端-服务端分离 | 前端只管展示,后端只管数据,互不依赖内部实现 | 前后端通过 API 契约解耦 |
| 无状态 | 每个请求自带全部信息,服务端不依赖上一次请求的上下文 | 认证用 Token 而非服务端会话 |
| 可缓存 | 响应要能标明自己能不能被缓存 | 正确使用 Cache-Control、ETag |
| 统一接口 | 用统一的资源标识、表述、自描述消息与超媒体 | 名词化 URL + 标准方法 + 标准状态码 |
| 分层系统 | 中间可以有网关、代理、负载均衡 | 网关统一鉴权与限流 |
| 按需代码(可选) | 服务端可下发可执行代码 | 基本不用,忽略即可 |
真正有实操价值的只有「无状态」和「统一接口」。前者决定了你能不能水平扩展,后者决定了别人能不能不看文档就猜出你的接口怎么调。剩下四条是架构级理想,业务项目做到七分就够。
URL 是资源的名字,不是函数的调用命令。这两者的差别,账面上只是一两个词,体验上却是「看名字就懂」和「必须翻文档」的距离。
| 好例子 | 反例 | 为什么 |
|---|---|---|
GET /users/42 | GET /getUserById?id=42 | 资源用名词,动作交给方法 |
GET /users | GET /userList | 集合用复数,语义统一 |
POST /users | POST /createUser | 创建集合中的新成员 |
PUT /users/42 | POST /updateUser | 更新是方法语义,不必写进路径 |
DELETE /users/42 | GET /deleteUser?id=42 | 删除绝不能用 GET(会被预取、缓存、爬虫误触发) |
GET /users/42/orders | GET /getOrdersByUser?uid=42 | 从属资源用层级表达 |
GET /users?status=ACTIVE&page=1 | GET /activeUsersPage1 | 过滤、分页、排序走查询串 |
三条铁律:用名词复数、用层级表达归属、把过滤/分页/排序统统塞进查询串。路径里出现动词(get / create / update / delete)就是没理解资源导向。
把敏感操作做成 GET /users/42/delete 是最危险的一种。GET 是「安全方法」,浏览器、代理、爬虫都认为它可以被随意重复——预取会删数据,这不是假设,是真实事故。
规范里那条「统一前缀」在代码里其实有两个写法:把 /api/v1 写死在每个 @RequestMapping 上,或者交给 server.servlet.context-path。这两条路选错,网关路由和健康检查会一起踩坑。把对外 API 真正要配的几行勾出来——server 出前缀与端口、logging 出访问日志与慢请求、actuator 出探活端点、profile 让 dev 与 prod 用不同前缀:
server:
port: 8080
servlet:
encoding: { charset: UTF-8, enabled: true, force: true }
compression: { enabled: true, min-response-size: 2048 }
spring:
application:
name: demo-service
management:
server:
port: 9090 # 管理端口与业务端口隔离
endpoints:
web:
exposure:
include: health,info,metrics,prometheus # 白名单,绝不写 *
endpoint:
health:
show-details: when_authorized
group:
liveness: { include: ping }
readiness: { include: db,redis,diskSpace }
方法不是装饰,它自带语义契约:
| 方法 | 语义 | 幂等 | 安全 | 典型状态码 |
|---|---|---|---|---|
| GET | 读取资源 | 是 | 是 | 200 / 404 |
| POST | 创建资源 / 触发处理 | 否 | 否 | 201 / 400 |
| PUT | 整体替换资源 | 是 | 否 | 200 / 204 |
| PATCH | 局部更新资源 | 通常视实现 | 否 | 200 / 204 |
| DELETE | 删除资源 | 是 | 否 | 204 / 404 |
幂等的含义是「同样的请求执行一次和执行 N 次,对资源的影响相同」。这条性质直接决定了重试策略:GET / PUT / DELETE 可以放心重试,POST 不能——重试一次就可能多下一单、多扣一次款。

「一切皆 200,错误码藏在 body 里」是很多团队的历史包袱。它能让前端只判断一个字段,却让监控、网关、缓存、日志全部失去判断依据。
状态码这一层要背的东西不多,但挨得很近的那几个一定会用错——401 与 403、404 与 409、400 与 422。表格读三遍不如点一局:先点状态码,再点它对应的场景,配错当场解释差在哪。

对比两派做法:
- 「一切皆 200」:
{"code":50001,"message":"用户不存在","data":null}。前端好写,但 CDN 会缓存 200 的错误响应,网关无法按状态码统计错误率,运维看监控全是绿的 - 正确用状态码:404 +
{"code":50001,"message":"用户不存在"}。既让基础设施看懂,也用code保留业务细分
- 纯 HTTP 状态码派:成功就 200 加裸数据,失败就是 4xx/5xx 加问题详情。最「标准」,但对前端不友好——判定点分散在状态码和错误结构两处
- 信封派(Envelope):所有响应都套一个
{ code, message, data }。前端判定集中、业务错误可细分,代价是与纯 REST 的「表示即资源」理念有出入
我的推荐是融合:HTTP 状态码表达「请求处理结果」这一层,信封里的 code 表达「业务细分结果」这一层。两者各司其职,谁也不替代谁。
package com.example.common.api;import com.fasterxml.jackson.annotation.JsonInclude;import lombok.Getter;import java.io.Serializable;/** * 统一响应体 * code 与 HTTP 状态码解耦:HTTP 说明「传输/处理」层面,code 说明「业务」层面 */@Getter@JsonInclude(JsonInclude.Include.NON_NULL)public class Result<T> implements Serializable { /** 业务码:0 表示成功,其余见 ErrorCode 枚举 */ private final int code; private final String message; private final T data; private Result(int code, String message, T data) { this.code = code; this.message = message; this.data = data; } public static <T> Result<T> ok(T data) { return new Result<>(0, "success", data); } public static <T> Result<T> ok() { return new Result<>(0, "success", null); } public static <T> Result<T> fail(int code, String message) { return new Result<>(code, message, null); } public static <T> Result<T> fail(ErrorCode errorCode) { return new Result<>(errorCode.getCode(), errorCode.getMessage(), null); }}手写 Result.ok(...) 有个隐患:总有人忘记包,接口格式就不一致。用 ResponseBodyAdvice 可以在序列化之前自动包装,同时排除文件下载等特殊返回。
package com.example.common.api;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;/** * 自动把控制器返回值包装进 Result。 * 只处理标注了 @WrapResult 的接口,避免误伤文件下载 / 第三方回调。 */@RestControllerAdvicepublic class ResultWrapAdvice implements ResponseBodyAdvice<Object> { @Override public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) { // 已经是 Result 就不再包;标注了 @SkipWrap 的一律跳过 return returnType.getMethodAnnotation(SkipWrap.class) == null && !Result.class.isAssignableFrom(returnType.getParameterType()); } @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> converterType, ServerHttpRequest request, ServerHttpResponse response) { // 字符串返回值要单独处理,否则与 StringHttpMessageConverter 冲突 if (body instanceof String) { return body; // 交由调用方自行返回 Result } return Result.ok(body); }}配套一个标记注解:
@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface SkipWrap {}警告:全局包装一旦启用,所有 String 返回值都会踩坑——Spring 用 StringHttpMessageConverter 处理字符串,包成 Result 后再序列化会与它冲突,轻则类型转换异常,重则返回一坨转义 JSON。要么在 beforeBodyWrite 里对 String 特判,要么干脆让控制器返回对象而非字符串。
这条链上每一格都有明确的接手人,点一遍比读代码快。第 ⑤ 格就是上面那条警告的案发现场:
那个 String 分岔可以在内核里点出来。同一个 return,注解一换,出口就换了——missing 那一格还是白标 404 的现场:
异常不是错误日志的附属品,而是业务错误码的载体。一套清晰的体系应该有两层:一个错误码枚举,一个业务异常。
package com.example.common.exception;import lombok.Getter;@Getterpublic enum ErrorCode { SUCCESS(0, "success"), INVALID_PARAM(40000, "参数不合法"), USER_NOT_FOUND(40401, "用户不存在"), USERNAME_TAKEN(40901, "用户名已被占用"), ORDER_STATE_CONFLICT(40902, "订单状态不允许该操作"), PERMISSION_DENIED(40300, "没有操作权限"), UNAUTHORIZED(40100, "登录已失效,请重新登录"), SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"); private final int code; private final String message; ErrorCode(int code, String message) { this.code = code; this.message = message; }}package com.example.common.exception;import lombok.Getter;/** 业务异常:可安全展示给用户的错误,禁止把 5xx 堆栈暴露出去 */@Getterpublic class BizException extends RuntimeException { private final ErrorCode errorCode; public BizException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } public BizException(ErrorCode errorCode, String message) { super(message); this.errorCode = errorCode; } // 省略堆栈,业务异常不需要栈信息,省下大量开销 @Override public synchronized Throwable fillInStackTrace() { return this; }}业务异常与 HTTP 状态码的映射关系,是这套体系的黏合剂:
| 业务异常 | 业务码 | HTTP 状态码 |
|---|---|---|
BizException(INVALID_PARAM) | 40000 | 400 |
BizException(UNAUTHORIZED) | 40100 | 401 |
BizException(PERMISSION_DENIED) | 40300 | 403 |
BizException(USER_NOT_FOUND) | 40401 | 404 |
BizException(USERNAME_TAKEN) | 40901 | 409 |
BizException(ORDER_STATE_CONFLICT) | 40902 | 409 / 422 |
未捕获的 Exception | 50000 | 500 |
这张映射表就是整套体系的黏合剂:一次业务失败从抛出到落地要走七步,其中第 ④⑤ 步分别决定 body 里的 code 和状态线上的结论。动图里那两条读者(网关与页面)各读各的字段,谁也不能替谁:

fillInStackTrace() 被覆写这件事值得单独讲一句。业务异常是控制流而不是事故,一条「用户名已占用」的堆栈除了证明你 new 过它之外没有任何信息量,而采集栈帧的成本在热点接口上是实打实的 CPU。把它省掉,日志里改用 code 与关键业务 ID 定位——这才是把异常当返回值用的正确姿势。
接口一旦对外开放,就要考虑「旧客户端还在用」的现实。三种主流策略各有取舍:
| 策略 | 形式 | 优点 | 缺点 |
|---|---|---|---|
| URL 版本 | /api/v1/users | 直观、易调试、网关好路由 | URL 会变,不够「纯粹」 |
| Header 版本 | X-API-Version: 1 | URL 稳定 | 不便调试,浏览器地址栏看不出来 |
| 媒体类型版本 | Accept: application/vnd.demo.v1+json | 最「RESTful」 | 冗长、工具链支持差 |
绝大多数团队应该选 URL 版本。它直观、可分享、可在浏览器直接打开、网关按路径前缀就能路由。媒体类型版本理论最优,实操体验最差——除非你在维护一个必须极致遵守 REST 规范的公共 API。
分页参数要有统一约定,否则每个接口各写一套,前端苦不堪言。推荐:
- 请求:
?page=1&size=20&sort=createdAt,desc - 响应:返回总数与当前页数据
package com.example.common.api;import java.util.List;/** 分页结果:total 供前端算页数,list 是当前页数据 */public record PageResult<T>(long total, int page, int size, List<T> list) { public static <T> PageResult<T> of(long total, int page, int size, List<T> list) { return new PageResult<>(total, page, size, list); }}@GetMapping("/users")public PageResult<UserVO> page( @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "createdAt,desc") String sort) { return userService.page(page, size, sort);}参数名固定为 page / size / sort,sort 用 字段,方向 的形式,全站一致——前端只写一次分页组件就能复用到所有列表。
约定管住了参数名,管不住参数值。size 能写多大,是一条真实的容量红线:它由 spring.data.web.pageable.max-page-size 兜着,而且超限的后果是「静默裁剪」,不是报错。把这个数拖一遍,看契约在多大的调用量上会开始变形:
- Boot 给 Pageable 的默认上限正是 2000,超了会被裁剪而不是抛异常
- 调用方要 5000 实得 2000,只有比对 total 与 list 长度时才会发现
- 两千行实体加上 ORM 的一级缓存,一次请求吃掉几十 MB 堆并不罕见
- 内部系统够用,公网 API 建议再降一档,别把默认值当契约
- PUT 与 PATCH 混用:PUT 是整体替换(不传的字段会被置空),PATCH 是局部更新。团队只用一个时必须写进规约,否则「更新用户」到底是清空还是保留,全凭开发者心情
- DELETE 返回 200 还是 204:两者都对。204 更符合语义(无内容),200 更方便前端统一解析。关键是全站统一,别一个接口 204 一个接口 200
- 分页从 0 还是 1 开始:Spring Data 的
Pageable默认从 0 开始,前端组件大多从 1 开始。这是个纯「团队约定」问题,没有对错,但必须在项目规范里写死
前十节是规范条文,这一节把它们压成一个画面。

REST 的核心只有一句话——你喊的是「谁」,你要做的是什么。找小张,正确说法是「幸福路 42 号」(门牌号 = URL)+「拜访」(动作 = HTTP 方法);而不是发明一个词叫「拜访幸福路42号的小张」。前者的好处是:所有「幸福路 42 号」的动作共用同一个地址(GET/PUT/DELETE /users/42),快递员(网关)、门卫(缓存)、物业(监控)都能凭地址和动作判断该不该放行、要不要重投、这次算成功还是失败。而「拜访幸福路42号的小张」这种说法,每个新动作都要造一个新地名,别人只能翻你的地图(文档)才看得懂。URL 说「操作什么」,方法说「怎么操作」,状态码说「结果如何」——这就是全部。

统一返回体像医院给每张单据用的同一套抬头。不管化验单、收费票据还是出院小结,纸张右上角永远印着同三样东西:科室编号(code)、一句话说明(message)、正文数据(data)。前台(前端)只要认这个抬头,就能用一套代码处理所有单据;而信封上贴的那枚邮票(HTTP 状态码)是给邮递员(网关、CDN、监控)看的——两套信息给两拨读者看,所以不能互相省略。这也是第五节推荐「状态码 + code 融合派」的真正理由。
学完这一篇你要能回答:
- 「
POST /createUser错在哪?」(动词进了门牌号,且集合创建本该是POST /users) - 「为什么删除绝不能用 GET?」(GET 被默认为可重复的安全方法,预取和爬虫会替你删数据)
- 「状态码 404 和 body 里的 code 40401 是什么关系?」(前者给基础设施看,后者给业务分支看)
REST 不是背出来的,是「看一眼请求长什么样、再看一眼响应长什么样」。下面四个演示分别对应四个断面,建议按顺序点。
断面一:内容协商——同一段代码,为什么有时 JSON 有时 406。
fail 那一格值得多看两遍:它给出的是 HttpMediaTypeNotAcceptableException,也就是第十三节速查表的第一行。注意 406 是客户端的 Accept 造成的,不是你返回值写错了——很多新手第一反应去改控制器,方向就反了。
断面二:错误怎么变成规范的错误响应。
none 那一格就是第十节说的「一切皆 200 派」最怕看到的形态:没人接管 → 500 + Whitelabel 页,网关统计到的错误率突然翻倍,而你只想知道「用户名已存在」。
断面三:资源化 URL 到底是怎么被匹配的。
把 /users/42(单个资源)和 /users(集合)放在一条链上看,你会更直观地理解第二节那条铁律:层级表达归属,查询串表达筛选。
断面四:整条 API 链路——包括失败与慢。
前面三个断面都是局部。这一个把过滤器、分派、参数校验、业务层、数据库全串起来,五个场景刚好是本篇五节的合订本:
最后这张动图解释第五、六节那套壳子到底在流程的哪一步被套上——理解了这一步,第六节那个 String 警告就不是死记硬背的规则了:

四个断面点完,换成命令行自己敲。这台控制台连着浏览器里的同一个内核,每一行回显都是算出来的——先 boot,再用 lab 把本篇的五种结局挨个敲出来:
lab conv fail 与 lab err handler 是两种「都返回 JSON 的错误」,但一个是调用方的 Accept 造成的 406,一个是你的 @RestControllerAdvice 产出的 4xx。第四节那句「状态码给基础设施看,code 给业务看」,在这两条命令的回显里最能看出区别。
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
406 Not Acceptable + org.springframework.web.HttpMediaTypeNotAcceptableException: No acceptable representation | 客户端 Accept 里要求的类型,没有任何转换器能产出(例如只接受 application/xml 而项目没引 Jackson XML) | 先用 curl -H "Accept: application/json" 验证接口本身是否活着;确认是调用方的头太挑 | #22 第七节 |
415 Unsupported Media Type + HttpMediaTypeNotSupportedException: Content-Type 'application/x-www-form-urlencoded;charset=UTF-8' is not supported | @RequestBody 收到的媒体类型没人能读 | 补 -H "Content-Type: application/json";或去掉 @RequestBody 改用 @ModelAttribute | #23 第六节 |
JSON parse error: Cannot deserialize value of type java.util.Date from String "2024-05-01 10:00:00" | Jackson 默认只认 ISO-8601(2024-05-01T10:00:00.000+08:00)或时间戳 | 字段上加 @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),或全局配 spring.jackson.date-format | 本节末 |
java.time.format.DateTimeParseException(序列化 LocalDateTime 时报 InvalidDefinitionException: Java 8 date/time type ... not supported by default) | 没有注册 jackson-datatype-jsr310 模块 | Boot 的 spring-boot-starter-web 已自带;手工 new ObjectMapper() 时才会漏 | 本节末 |
Unrecognized field "user_name" (class UserCreateDTO), not marked as ignorable (3 known properties: ...) | 前端字段名与 DTO 不一致,且严格模式被打开了 | 对齐命名或用 @JsonProperty("user_name");放宽见下一段 | #23 第十一节 |
Required request body is missing(HttpMessageNotReadableException) | 声明了 @RequestBody 却没发体;也常见于拦截器/过滤器先把流读过了一遍 | 用 ContentCachingRequestWrapper 包一层让请求体可重复读 | #23 第八节 |
加了 ResponseBodyAdvice 之后,返回 String 的接口报 ClassCastException: Result cannot be cast to java.lang.String | 壳子被交给 StringHttpMessageConverter 处理,而它只吃字符串 | 在 beforeBodyWrite 里对 body instanceof String 特判,或让该接口返回对象 | 本篇第六节 |
| 文件下载接口拿到一堆转义 JSON | 全局套壳误伤了二进制/文本响应 | 加 @SkipWrap 之类的标记注解并在 supports 里排除 | 本篇第六节 |
NoResourceFoundException / 静态资源 404,接口却正常 | 版本升级后资源处理器行为变化(Boot 3.2+ 不再默认把未知路径交给静态资源处理) | 显式配置 spring.web.resources.add-mappings=true 并核对路径 | #22 第十五节 |
CORS 报 Response to preflight request doesn't pass access control check | OPTIONS 预检被登录拦截器挡在门外 | 用 CorsFilter / addCorsMappings 放行 OPTIONS,别用 MVC 拦截器管跨域 | #26 |
表格里第一行的 406 是新手最容易查错方向的:它长得像服务端的错,其实是调用方的请求头。下面是真堆栈,先别看解析——点出你认为的凶手行:
联调时前端说所有接口都返回 406。你把同一个 URL 粘到地址栏,页面正常出数据,一度以为是网关在中间动了什么手脚。
Jackson 的日期格式一旦全局放开,就变成了「隐式契约」。推荐做法是把格式写进 DTO:@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8"),或者干脆在 API 层统一用 ISO-8601 字符串(前端 new Date() 直接能解析)。spring.jackson.serialization.write-dates-as-timestamps=false 是最省事的开关,但它只对 JSR-310 类型生效,老的 java.util.Date 仍走 spring.jackson.date-format。
「统一返回体 / 状态码 / 分页 / 版本」这四件事各自都有取舍。选两组组合,输出告诉你这套契约在真实运维里的样子。
GET /api/v1/users?page=1&size=20 → 200{"code":0,"data":{"total":137,"list":[...]}}网关错误率面板:真实#推荐给大多数内部/中小规模对外 API
目标:搭出最小可用的「资源化 API + 统一返回体 + 全局异常」三件套,并用 curl 验证四种状态码。
package com.example.lab.rest;import org.springframework.http.HttpStatus;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;import java.time.LocalDate;import java.util.List;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;@RestController@RequestMapping("/api/v1/books") // 资源化 URL + URL 版本public class BookLabController { public record Book(Long id, String title, LocalDate published) {} public record CreateForm(@jakarta.validation.constraints.NotBlank String title, LocalDate published) {} public record PageResult<T>(long total, int page, int size, List<T> list) {} private static final Map<Long, Book> STORE = new ConcurrentHashMap<>(); private static final AtomicLong SEQ = new AtomicLong(); static { // 造一点种子数据 SEQ.set(2); STORE.put(1L, new Book(1L, "Domain-Driven Design", LocalDate.of(2003, 8, 20))); STORE.put(2L, new Book(2L, "Spring in Action", LocalDate.of(2020, 5, 1))); } // 集合:分页 + 过滤统统走查询串 → 200 @GetMapping public Map<String, Object> list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size) { var list = STORE.values().stream().sorted((a, b) -> a.id().compareTo(b.id())) .skip((long) (page - 1) * size).limit(size).toList(); return Map.of("code", 0, "message", "success", "data", new PageResult<>(STORE.size(), page, size, list)); } // 单个资源:存在 → 200,不存在 → 真 404(不是 200 + code) @GetMapping("/{id}") public ResponseEntity<Map<String, Object>> get(@PathVariable Long id) { Book book = STORE.get(id); if (book == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("code", 40401, "message", "图书不存在")); } return ResponseEntity.ok(Map.of("code", 0, "message", "success", "data", book)); } // 创建 → 201 + Location @PostMapping public ResponseEntity<Map<String, Object>> create(@jakarta.validation.Valid @RequestBody CreateForm form) { Long id = SEQ.incrementAndGet(); Book saved = new Book(id, form.title(), form.published() == null ? LocalDate.now() : form.published()); STORE.put(id, saved); return ResponseEntity.created(java.net.URI.create("/api/v1/books/" + id)) .body(Map.of("code", 0, "message", "success", "data", saved)); } // 删除 → 204,无响应体 @DeleteMapping("/{id}") public ResponseEntity<Void> delete(@PathVariable Long id) { return STORE.remove(id) == null ? ResponseEntity.status(HttpStatus.NOT_FOUND).build() : ResponseEntity.noContent().build(); }}五条 curl 与预期响应:
# 1) 列表 → 200curl -i "http://localhost:8080/api/v1/books?page=1&size=20"# 2) 单个资源 → 200curl -i http://localhost:8080/api/v1/books/1# 3) 资源不存在 → 404(关键:状态码真的是 404)curl -i http://localhost:8080/api/v1/books/999# 4) 创建 → 201 + Location 头curl -i -X POST http://localhost:8080/api/v1/books \ -H "Content-Type: application/json" -d '{"title":"Refactoring","published":"2018-11-19"}'# 5) 缺少必填字段 → 400(@Valid 触发 MethodArgumentNotValidException)curl -i -X POST http://localhost:8080/api/v1/books \ -H "Content-Type: application/json" -d '{"title":""}'第 2 条预期响应:
{ "code": 0, "message": "success", "data": { "id": 1, "title": "Domain-Driven Design", "published": "2003-08-20" }}第 3 条预期:状态行 HTTP/1.1 404,body 为 {"code":40401,"message":"图书不存在"}。第 4 条预期:状态行 HTTP/1.1 201,并且响应头里出现 Location: /api/v1/books/3。第 5 条预期:HTTP/1.1 400——如果你在这里拿到了 200,说明忘了在第 4 个参数前写 @Valid。
- 把第 3 条的状态码改成
HttpStatus.OK(body 不动)→ 你观察到curl -i显示 200,然后加一个curl -sI -H "Cache-Control: no-cache" .../books/999也看不出错。这就是第十四节第二题:现在你能用一条命令复现「监控面板全绿」 - 把
list方法的返回改为裸PageResult<Book>(PageResult见第九节),并配一个ResponseBodyAdvice自动套壳 → 你观察到响应结构完全不变,但控制器里已经没有Map.of("code", 0, ...)。再试@GetMapping("/ping") public String ping()→ 你观察到ClassCastException或一坨转义 JSON,正好复现第六节那条警告 - 把
Book的published换成java.util.Date并保持第 1 条请求 → 你观察到响应里变成毫秒时间戳(例如"published": 1061308800000)。改成@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")后重新请求 → 你观察到回到"2003-08-20"。两种写法都合法,但必须全站一致,否则前端要写两套解析 - 把 URL 版本
/api/v1去掉,改成X-API-Version: 1请求头匹配(@RequestMapping(value="/books", headers="X-API-Version=1"))→ 你观察到浏览器直接打开localhost:8080/books立刻 404,因为地址栏没法带头。这就是第八节那句「实操体验最差」的具体形状
做一个「可对外的图书 API」,把本篇所有规范落成一份可交付的契约。
要求:
- 资源化 URL:
/api/v1/books、/api/v1/books/{id}、/api/v1/books/{id}/reviews(从属资源用层级) - 完整方法语义:
GET(200)、POST(201 + Location)、PUT(整体替换,200)、PATCH(局部更新,200)、DELETE(204) - 统一返回体
Result<T>+ErrorCode+BizException+@RestControllerAdvice,业务码与 HTTP 状态码按第七节的映射表走 - 分页固定
page/size/sort三参数,返回total;size超过 100 时返回 400 而不是慢慢查 - 幂等保护:
PUT/DELETE对同一 id 重复调用两次,第二次必须是可预期的(200/204 或 404),绝不能 500
验收清单:
- [ ]
curl -i -X DELETE .../books/1连打两次:第一次 204、第二次 404,两次都不是 500 - [ ]
curl -i -X POST .../books -H "Content-Type: application/json" -d '{"title":"x"}'得到 201 且有Location头 - [ ] 制造一个
BizException(USERNAME_TAKEN)式的冲突,确认返回 409 且 body 里有code - [ ]
curl -i -H "Accept: application/xml" .../books得到 406,且日志里的异常全名是HttpMediaTypeNotAcceptableException - [ ] 所有列表响应的
data结构完全一致(同一套PageResult),前端只写一次分页组件即可复用 - [ ] 你能用一句话说清「为什么状态码和
code不能合并成一个」
不看上文,能否说出 GET/POST/PUT/PATCH/DELETE 各自的幂等性与典型状态码?(GET 安全幂等 200/404;POST 非幂等 201/400;PUT 幂等 200/204;PATCH 视实现;DELETE 幂等 204/404)
404、406、415 分别由谁负责?(404 = 压根没匹配上映射或资源不存在;406 = 客户端 Accept 太挑;415 = 客户端 Content-Type 没人能读)
Result<T> 里的 code 与 HTTP 状态码为什么要同时存在?(读者不同:状态码给网关/CDN/监控,code 给业务分支)
全局套壳为什么必须对 String 返回值特判?(StringHttpMessageConverter 只接受字符串,塞给它一个 Result 会类型冲突)
门牌号说「是谁」,方法说「干什么」,状态码说「成没成」,code 说「为什么」;动词永远不进 URL。
RESTful 不是玄学,它只要求你把三件事做对——URL 用名词表达资源、方法表达操作、状态码表达结果。在此之上,一套 Result<T> 统一响应体、一个 ErrorCode + BizException 异常体系、一个版本策略,就构成了能长期演进的企业级 API 骨架。