本节目标:把 8.1、8.2 拼出来的图书接口,按 REST 约定重写一遍,并搞清每条约定背后的原因。
适用版本:Spring Boot 4.1.x(Java 21)
8.3 RESTful 设计约定
8.1 和 8.2 我们让接口「能跑」了,但写法还很随意:路径里可能带动词、状态码一律 200、分页参数各写各的。这类接口短期内能用,一旦前端、移动端、第三方同时对接,就会因为「同一个意思有五种写法」而反复返工。
REST 不是框架强制的,Spring MVC 也不会因为你「不合规范」就报错。它是一套约定:遵守它,接口可预测、可缓存、可被工具理解;不遵守,也能跑,只是协作成本会累积。本节逐条把约定落到图书服务上。
8.3.1 资源命名
REST 的核心是把一切当作资源,用 URL 定位资源,用 HTTP 方法表达动作。因此 URL 里只出现名词,动词交给方法。
| 不推荐 | 推荐 | 原因 |
|---|---|---|
/getBooks | GET /books | 动作由方法表达 |
/books/delete/42 | DELETE /books/42 | 删除是方法,不是路径 |
/book | /books | 集合用复数 |
/books/42/author/name | /books/42/author | 层级不超过两层,末级是资源 |
/books?action=create | POST /books | 不要用查询参数传动作 |
几条实践规则:
- 集合用复数名词:
/books、/authors、/orders。单数会让「取集合」和「取单条」的语义混淆。 - 层级表达从属关系:某作者的书是
/authors/{id}/books。层级别太深,超过两三层就该考虑拆成独立资源。 - 统一小写、用连字符:
/order-items比/order_items更常见,也更容易被工具处理。
8.3.2 HTTP 方法与状态码的搭配
约定里最实用的是一张「方法 × 语义 × 状态码」对照表。记住它,接口设计就有了骨架:
| 方法 | 路径 | 语义 | 成功状态码 | 备注 |
|---|---|---|---|---|
| GET | /books | 查列表 | 200 | 可缓存 |
| GET | /books/{id} | 查单条 | 200 | 不存在返回 404 |
| POST | /books | 创建 | 201 | 带 Location 头指向新资源 |
| PUT | /books/{id} | 整体替换 | 200 或 204 | 不存在时可选 201/404 |
| PATCH | /books/{id} | 局部更新 | 200 | 只改传入的字段 |
| DELETE | /books/{id} | 删除 | 204 | 不存在时 404 或幂等返回 204 |
常见错误状态码的语义:
| 状态码 | 含义 | 典型场景 |
|---|---|---|
| 400 | 请求格式错误 | JSON 解析失败、参数类型不对 |
| 401 | 未认证 | 缺少或无效凭证 |
| 403 | 已认证但无权限 | 普通用户访问管理接口 |
| 404 | 资源不存在 | ID 查不到 |
| 409 | 冲突 | ISBN 已存在、乐观锁版本冲突 |
| 415 | 媒体类型不支持 | 忘了 Content-Type: application/json |
| 422 | 语义校验失败 | 字段存在但业务规则不满足 |
400 与 422 的区别常被问到:400 是「我看不懂你的请求」,422 是「我看懂了,但它不合法」。例如 publishedYear 传了 "abc" 是 400;传了 1200(年份不合理)是 422。是否使用 422 属团队偏好,很多团队用 400 覆盖两者,也能自洽——关键是全站统一。
8.3.3 幂等性:为什么它决定重试策略
幂等(idempotent)指「同一请求执行一次和执行多次,对服务端状态的最终影响相同」。这是设计重试逻辑的前提:
| 方法 | 幂等 | 安全 | 说明 |
|---|---|---|---|
| GET | 是 | 是 | 只读,不改变状态 |
| PUT | 是 | 否 | 用完整表示覆盖,重复执行结果一样 |
| DELETE | 是 | 否 | 删一个已删除的资源,结果仍是「不存在」 |
| POST | 否 | 否 | 每次执行通常新建一条记录 |
| PATCH | 否 | 否 | 除非特意设计成幂等,否则重复执行会叠加 |
为什么这重要?假设客户端在弱网下发起 POST /books,超时了。它不知道请求是否到达服务端:
- 如果无脑重试,可能创建出两本相同的书——因为 POST 不幂等。
- 若换成
PUT /books/42(客户端自带 ID),重试多少次结果都一样,可以放心重试。
因此两条实践建议:
- 能设计成幂等就设计成幂等。比如让客户端生成资源 ID 并用 PUT 创建,而不是服务端分配 ID 的 POST。
- 无法幂等的 POST,用幂等键兜底。客户端在请求头带一个唯一的
Idempotency-Key,服务端记录该键与结果,重复请求直接返回上次结果,不再创建。
@PostMapping
public ResponseEntity<Book> create(@RequestHeader("Idempotency-Key") String key,
@RequestBody Book book) {
return idempotencyService.executeOnce(key, () ->
ResponseEntity.status(HttpStatus.CREATED).body(bookService.save(book)));
}
这段代码的重点不在实现,而在约定:只要接口声明了幂等键,客户端就可以安全重试。
8.3.4 分页、过滤、排序
列表接口几乎必然要分页。约定俗成的查询参数是 page、size、sort,这与 Spring Data 的 Pageable 完全对齐:
GET /books?page=0&size=20&sort=title,asc&category=java
| 参数 | 含义 | 示例 |
|---|---|---|
page | 页码,从 0 开始 | page=0 |
size | 每页条数 | size=20 |
sort | 排序字段与方向,可多个 | sort=title,asc |
| 其他 | 过滤条件,用资源字段名 | category=java |
注意 page 从 0 开始,这是 Spring Data 的默认口径,也是很多 API 的惯例;但有些团队从 1 开始,务必在文档里写清。Spring MVC 会在类路径存在 Spring Data 时,自动把这三个参数解析成 Pageable:
@GetMapping
public PageResponse<Book> list(@RequestParam(required = false) String category,
Pageable pageable) {
Page<Book> page = bookService.findByCategory(category, pageable);
return PageResponse.of(page);
}
把 Page 直接序列化成 JSON 会带出一堆 pageable、sort 的内部结构,不推荐直接返回。用一个稳定的 DTO 包装:
public record PageResponse<T>(
java.util.List<T> content,
int page,
int size,
long totalElements,
int totalPages) {
public static <T> PageResponse<T> of(org.springframework.data.domain.Page<T> p) {
return new PageResponse<>(p.getContent(), p.getNumber(), p.getSize(),
p.getTotalElements(), p.getTotalPages());
}
}
{
"content": [{"id": 1, "title": "Effective Java"}],
"page": 0,
"size": 20,
"totalElements": 42,
"totalPages": 3
}
8.3.5 统一响应包装体:两种风格与选型
「所有响应都包一层 {code, message, data}」是国内很常见的做法。它和「裸返回资源 + 用 HTTP 状态码表达结果」哪种更好?先看两种风格的对比:
| 维度 | 裸资源(HTTP 状态码) | 统一包装体(envelope) |
|---|---|---|
| 成功响应 | 200 + 资源 JSON | 200 + {code:0, data:…} |
| 失败响应 | 404 + 错误体 | 200 + {code:40401, msg:…} |
| 可缓存性 | 好(状态码语义清晰) | 差(失败也是 200) |
| 工具/网关识别 | 天然支持 | 需额外约定 |
| 前端错误处理 | 按状态码分支 | 解析 body 里的 code |
| 与 REST 一致性 | 高 | 低 |
统一包装体的好处是前端可以「只看一个字段」,错误码自成一派,不受 HTTP 状态码种类限制。代价是丢掉了 HTTP 的语义:监控系统、CDN、网关、重试库全都依赖状态码,一旦失败也返回 200,这些基础设施就失效了。
我的选型建议:
- 对外公开 API、多端协作:优先「裸资源 + 正确状态码」,错误用统一错误体(如 RFC 9457 Problem Details 风格)表达。
- 内部系统、前端团队强要求 code 字段:可以用包装体,但保留正确的 HTTP 状态码,即
code只做业务细分,不与 HTTP 语义冲突。
第 10 章我们会专门讲统一异常处理与统一响应体,这里先记住结论:包装与否是团队选择,但 HTTP 状态码必须真实。
8.3.6 API 版本化的三种方式
接口一旦对外发布,就再也收不回来。当字段含义要变、结构要调整,又不能破坏老客户端时,就需要版本化。三种主流方式:
| 方式 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| URL 路径 | GET /v1/books | 直观、易调试、易缓存 | URL 会变,不够「纯粹」 |
| 请求头 | X-API-Version: 1.0 | URL 稳定 | 浏览器里不好调试 |
| 内容协商 | Accept: application/vnd.book.v1+json | 最符合 HTTP 语义 | 复杂、工具支持参差 |
Spring Boot 4 把版本化做进了框架(见 8.1 节)。它支持从 header、路径段、查询参数、媒体类型参数四种位置读取版本,配置集中在 spring.mvc.apiversion.*:
spring:
mvc:
apiversion:
use:
path-segment: 0 # 版本作为第 0 段路径,如 /1.0/books
supported: "1.0, 2.0"
default: "1.0"
控制器侧用 version 属性声明,框架自动按请求版本挑选最合适的方法:
@GetMapping(path = "/api/books/{id}", version = "1.0")
public Book getV1(@PathVariable Long id) {
return bookService.findLegacy(id);
}
@GetMapping(path = "/api/books/{id}", version = "2.0")
public BookV2 getV2(@PathVariable Long id) {
return bookService.findModern(id);
}
相比自己写拦截器解析 URL 或 Header,内建版本化省掉了大量样板代码,也能与 ApiVersionDeprecationHandler 配合,在响应里提示老版本即将下线。
8.3.7 把图书接口改造一遍
把前面的约定合起来,图书服务最终长这样:
package com.example.bookstore.web;
import java.net.URI;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.example.bookstore.domain.Book;
import com.example.bookstore.service.BookService;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService service;
public BookController(BookService service) {
this.service = service;
}
@GetMapping
public PageResponse<Book> list(@RequestParam(required = false) String category,
Pageable pageable) {
Page<Book> page = service.findByCategory(category, pageable);
return PageResponse.of(page);
}
@GetMapping("/{id}")
public ResponseEntity<Book> get(@PathVariable Long id) {
return service.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Book> create(@RequestBody Book book) {
Book saved = service.create(book);
return ResponseEntity
.created(URI.create("/api/books/" + saved.id()))
.body(saved);
}
@PutMapping("/{id}")
public ResponseEntity<Book> replace(@PathVariable Long id, @RequestBody Book book) {
return service.replace(id, book)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
boolean removed = service.delete(id);
return removed
? ResponseEntity.noContent().build()
: ResponseEntity.notFound().build();
}
}
对照改造前后,差异一眼可见:
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 路径 | /getBook?id=1 | /api/books/1 |
| 创建 | GET /addBook?title=… | POST /api/books + JSON |
| 删除成功 | 200 + {"ok":true} | 204 无响应体 |
| 查不到 | 200 + {"ok":false} | 404 |
| 列表分页 | 无 | ?page=0&size=20&sort=title,asc |
| 版本 | 无 | 内建 version 属性 |
实测几条:
# 列表 + 分页排序,期望 200
curl -s "http://localhost:8080/api/books?page=0&size=20&sort=title,asc"
# → {"content":[...],"page":0,"size":20,"totalElements":42,"totalPages":3}
# 创建,期望 201 + Location
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"978-0134685991","publishedYear":2018}'
# → HTTP/1.1 201 Created / Location: /api/books/1
# 查不到,期望 404
curl -i http://localhost:8080/api/books/9999
# → HTTP/1.1 404 Not Found
# 删除,期望 204
curl -i -X DELETE http://localhost:8080/api/books/1
# → HTTP/1.1 204 No Content
到这一步,图书服务已经是一套「别人不用问文档也能猜到用法」的 API。第 9 章我们会给它的入参加上校验注解,把「字段存在但业务不合法」这类 422 场景自动化。
小结
- 资源命名用复数名词,动词交给 HTTP 方法,层级表达从属关系。
- 方法与状态码要搭配:创建 201 +
Location,删除 204,查不到 404,冲突 409,语义错误 422。 - 幂等性决定重试策略:GET/PUT/DELETE 幂等可放心重试,POST 不幂等需用
Idempotency-Key兜底。 - 分页统一用
page/size/sort,与 Spring Data 的Pageable对齐;page从 0 开始,并用稳定 DTO 包装Page。 - 统一响应包装体有利有弊,无论选哪种,HTTP 状态码都必须真实。
- 版本化三选一(URL / Header / 内容协商),4.x 用
spring.mvc.apiversion.*+version属性即可内建支持。
下一节进入第 9 章:给这些接口的入参加上 Bean Validation 注解,让非法数据在进入业务逻辑之前就被拦下。
阅读导航:上一节:8.2 请求参数与响应体 · 下一节:9.1 Bean Validation 注解 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。