《Spring Boot 入门》8.3 RESTful 设计约定

本节把「图书管理服务」从随手写的接口改造成符合 REST 约定的一整套 API:资源如何命名、HTTP 方法与状态码怎样搭配、为什么幂等性决定重试策略、分页过滤排序如何与 Spring Data 的 Pageable 对齐、统一响应包装体值不值得用,以及 API 版本化的三种方式与 4.x 内建支持。

本节目标:把 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 里只出现名词,动词交给方法。

不推荐推荐原因
/getBooksGET /books动作由方法表达
/books/delete/42DELETE /books/42删除是方法,不是路径
/book/books集合用复数
/books/42/author/name/books/42/author层级不超过两层,末级是资源
/books?action=createPOST /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),重试多少次结果都一样,可以放心重试。

因此两条实践建议:

  1. 能设计成幂等就设计成幂等。比如让客户端生成资源 ID 并用 PUT 创建,而不是服务端分配 ID 的 POST。
  2. 无法幂等的 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 + 资源 JSON200 + {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.0URL 稳定浏览器里不好调试
内容协商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 注解 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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