本节目标:把「图书借阅管理服务」的动作式接口重写成资源式接口,掌握 URL 层级与扁平化的取舍、方法与状态码的语义,并定下批量操作、自定义动作与错误响应的统一约定。
适用版本:Spring Boot 4.1.x(Java 21)
5.1 资源建模与路由
第 4 章把「图书借阅管理服务」的测试策略、Testcontainers 与契约隔离理清了。而契约里那些接口长什么样,其实在写第一行 Controller 之前就定了。本节回到那个更靠前的决策。
入门卷第 18 章给出的第一版接口是能跑的,但它带着几个典型的新手形状:
GET /getBookById?id=42
POST /createLoan
POST /returnBook?loanId=7
GET /queryBooks
这些路径在浏览器地址栏里能点通,放到生产上却会持续制造摩擦:网关无法按资源维度做限流与统计,HTTP 缓存无法介入,OpenAPI 文档里每个接口都是一条孤立路径而没有共享结构,前端也没法从 URL 推断出「还有什么相关资源」。
本节把这些动作式接口改写成资源式接口,并明确什么时候该破例。
5.1.1 从动作到资源:判据是「能不能被命名」
把动作改成资源,不是把动词翻译成名词这么简单。判据只有一条:这个东西能不能被单独指认、单独获取、单独持有状态。
以「借书」为例。createLoan 是一个动作,但它的产物——一条借阅记录——是可以被指认的:它有 loanId,有借出时间、应还时间、归还时间,会被查询、被更新。所以它是资源,用 Loan 表示。而「登录」这个动作的产物是「一个 token」,token 也可以被指认,所以它同样能建模成资源(Session 或 Token)。真正无法建模成资源的,是那种没有持久状态、纯粹是计算的请求,比如「校验一段 ISBN 是否合法」——那类接口留在动作式形态里更诚实。
再看「还书」。returnBook 这个动作改变了 Loan 的状态,但它并没有产生一个新的可指认对象。这种「改变已有资源状态」的操作,正是需要讨论破例的地方,5.1.4 会专门处理。
一个实用心法:先列出领域里的名词,再看每个名词的生命周期。 借阅服务的名词清单很短:
| 名词 | 生命周期 | 是否资源 |
|---|---|---|
| Book | 入库 → 在架 / 借出 → 下架 | 是 |
| Member | 注册 → 有效 / 冻结 | 是 |
| Loan | 借出 → 归还 / 逾期 | 是 |
| 逾期费 | 归还时计算,可能豁免 | 是(Loan 的子资源或独立 Fine) |
| 搜索 | 无状态,一次请求即结束 | 否 |
5.1.2 URL 层级还是扁平:按「归属」决定
确定了资源,下一个问题是路径怎么拼。同一个查询至少有三种写法:
GET /members/42/loans # 层级:会员下的借阅
GET /loans?memberId=42 # 扁平:借阅集合加过滤
GET /loans/by-member/42 # 伪资源:把动作藏进路径
第三种是明确要避免的——by-member 不是资源,它只是把查询参数伪装成路径段,没有任何好处。
真正要权衡的是前两种。判断标准是归属关系是否唯一且稳定:
- **层级(
/members/{id}/loans)**适合「这个子资源离开父资源就没有意义」的情形。某会员的借阅记录,脱离会员这个主体后语义会变得模糊,用层级表达「归属」最自然。 - **扁平(
/loans?memberId=42)**适合「这个资源本身独立存在,父 ID 只是众多过滤条件之一」。Loan有自己的主键、有自己的状态机、会被按图书、按到期日、按状态查询,把会员写死进路径反而让其他维度的查询无处安放。
这张表是实践里最常被拿出来讨论的:
| 维度 | 层级式 /members/{id}/loans | 扁平式 /loans?memberId= |
|---|---|---|
| 语义清晰度 | 归属关系一目了然 | 需要看查询参数才知道语境 |
| 过滤扩展性 | 只能按父资源过滤 | 可叠加任意过滤维度 |
| 权限模型 | 天然按父资源做鉴权 | 需在查询层再做一次归属校验 |
| 缓存友好度 | 路径稳定,易做 CDN 缓存 | 参数组合多,缓存命中率低 |
| 分页与排序 | 与扁平式同样支持 | 与层级式同样支持 |
生产上的常见做法是两者并存,但职责分开:层级式只用于「创建子资源」和「取全部子资源」这两个语义明确的动作,其余带过滤、分页、排序的查询一律走扁平式。这样 /members/42/loans 表示「42 号会员的全部借阅」,而 /loans?memberId=42&status=OVERDUE&page=0 表示「按条件筛选的借阅集合」。
@RestController
@RequestMapping("/members/{memberId}/loans")
class MemberLoanController {
private final LoanService loanService;
MemberLoanController(LoanService loanService) {
this.loanService = loanService;
}
@GetMapping
List<LoanResponse> listByMember(@PathVariable Long memberId) {
return loanService.findByMember(memberId).stream()
.map(LoanResponse::from)
.toList();
}
}
注意这里的 listByMember 不分页——层级式子资源接口刻意保持「小集合」语义。一旦某个会员的借阅可能上千条,就应该把它移到扁平式的 /loans?memberId= 上去,用 5.2 讲的分页机制承载。
5.1.3 方法与状态码:把语义交给协议
资源定好、路径定好,动作就只剩「用哪个 HTTP 方法」。方法不是随手选的动词,每个方法都带着一组契约:
| 方法 | 语义 | 安全 | 幂等 | 借阅服务里的用法 |
|---|---|---|---|---|
| GET | 读取,不改变状态 | 是 | 是 | 查图书、查借阅 |
| POST | 创建子资源 / 触发非幂等操作 | 否 | 否 | 新建借阅、借书 |
| PUT | 整体替换已知资源 | 否 | 是 | 全量更新图书元数据 |
| PATCH | 局部更新 | 否 | 否(可做成幂等) | 改会员联系方式 |
| DELETE | 删除 | 否 | 是 | 下架图书 |
「安全」和「幂等」这两列不是学术概念,它们直接决定基础设施能做什么。安全的 GET 才能被缓存、被预取、被 CDN 处理;幂等的 PUT/DELETE 才能在超时后被客户端安全重试。 把「还书」做成 GET,等于允许任何爬虫或浏览器预取去改数据,这是真实事故的来源。
状态码同样要落到语义上,而不是一律 200:
| 场景 | 状态码 | 说明 |
|---|---|---|
| 查询成功 | 200 | 有响应体 |
| 创建成功 | 201 | 必须带 Location 头指向新资源 |
| 删除成功、无响应体 | 204 | 不要返回 null 包装 |
| 参数格式错误 | 400 | 请求本身不合法 |
| 未认证 / 无权限 | 401 / 403 | 分开处理,不要都用 403 |
| 资源不存在 | 404 | 不要用 200 + 空对象掩盖 |
| 状态冲突(书已借出) | 409 | 5.3 会展开 |
| 语义校验失败 | 422 | 格式合法但业务规则不满足 |
| 限流 | 429 | 带 Retry-After |
@RestController
@RequestMapping("/books")
class BookController {
private final BookService bookService;
BookController(BookService bookService) {
this.bookService = bookService;
}
@PostMapping
ResponseEntity<BookResponse> create(@RequestBody @Valid BookCreateRequest request) {
BookResponse created = bookService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
@DeleteMapping("/{id}")
ResponseEntity<Void> delete(@PathVariable Long id) {
bookService.delete(id);
return ResponseEntity.noContent().build();
}
}
ResponseEntity.created(location) 同时给出 201 与 Location 头,这两者必须成对出现——只给 201 不给 Location,客户端就不知道该去哪里查新资源。
5.1.4 批量操作与自定义动作:破例的三种情形
资源式设计很干净,但现实里总有三类请求塞不进去。破例是可以的,但要按固定套路破,且要能说清代价。
情形一:批量创建 / 批量删除。 逐个发 N 个 POST /books 会产生 N 次网络往返,且无法在一个事务里原子提交。做法是加一个显式的批量端点:
@PostMapping("/batch")
ResponseEntity<List<BookResponse>> createBatch(
@RequestBody @Valid @Size(max = 100) List<BookCreateRequest> requests) {
List<BookResponse> created = bookService.createAll(requests);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
代价要提前想清楚:批量接口的失败语义模糊——10 条里有 3 条失败,是整个回滚还是部分成功?主流选择是「全成功或全失败」,在单个事务里提交,任一失败返回 422 并指明失败项。 部分成功的批量接口(返回 207)会让客户端逻辑复杂一个量级,除非业务真的需要,否则不要引入。
情形二:改变状态但不产生新资源。 还书、续借、冻结会员都属于这一类。它们没有新资源可命名,用 PUT 整体替换 Loan 又要求客户端把整个对象回传,既不安全也不方便。这里的标准破例是在资源下挂一个动作子路径:
@PostMapping("/loans/{id}/return")
LoanResponse returnLoan(@PathVariable Long id) {
return loanService.returnLoan(id);
}
return 是动词,严格说违反资源式设计。但它有明确的边界条件,满足才允许这么写:
- 该动作不可被建模成状态更新,或者状态更新会带来大量客户端负担;
- 动作不是幂等的,因此必须用
POST而不是PUT(重复调用要返回 409 或直接返回同一结果,见 5.3); - 动作只作用于单个资源,
/loans/{id}/return是上限,不要再嵌套/loans/{id}/items/{itemId}/return。
情形三:本质是查询但参数是对象。 用 GET 传一个复杂的过滤对象(比如「在某个时间段内、属于某分类、且有库存的图书」)会撑爆 query string。这时可以退化成 POST /books/search,但更推荐的做法是 5.2 讲的「过滤参数 + 白名单」方案,除非过滤条件本身是自由文本或嵌套结构。
| 破例情形 | 推荐写法 | 何时不要破例 |
|---|---|---|
| 批量增删 | POST /books/batch | 数量少、可逐个调用时不要加 |
| 状态变更动作 | POST /loans/{id}/return | 能表达成 PATCH 状态字段时优先 PATCH |
| 复杂查询 | POST /books/search | 能用 query 参数 + 白名单表达时不要破例 |
5.1.5 统一错误响应:别让每个接口自己发明结构
动作式接口常伴生一个问题:错误响应各写各的,有的返回 {"error": "..."},有的返回 {"code": 1, "msg": "..."}。客户端要为每个接口写一套解析逻辑。
Spring Framework 7 提供了 ProblemDetail,对应 RFC 9457(原 RFC 7807)的 application/problem+json。开启它只需要一行配置:
spring.mvc.problemdetails.enabled=true
开启后,框架抛出的异常会自动转成标准结构。业务异常则通过 @RestControllerAdvice 补上:
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
ProblemDetail handleNotFound(BookNotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Book not found");
pd.setType(URI.create("https://plumephp.example/problems/book-not-found"));
pd.setProperty("bookId", ex.getBookId());
return pd;
}
@ExceptionHandler(BookAlreadyLoanedException.class)
ProblemDetail handleConflict(BookAlreadyLoanedException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
pd.setTitle("Book already loaned");
pd.setProperty("bookId", ex.getBookId());
return pd;
}
}
返回体是这样一个结构,字段名由规范固定,客户端可以一次写好解析逻辑:
{
"type": "https://plumephp.example/problems/book-not-found",
"title": "Book not found",
"status": 404,
"detail": "Book 42 does not exist",
"instance": "/books/42",
"bookId": 42
}
type 是给机器读的稳定标识,title 是给人读的短描述,detail 是本次请求的具体说明。自定义字段(如 bookId)通过 setProperty 平铺进对象——不要再套一层 data 或 payload,那会破坏规范的扁平结构。
5.1.6 常见坑
坑一:把 404 当成 200。 查不到资源返回 200 加一个 null 或空对象,前端就得靠判断 body 是否为空来决定逻辑。这是接口契约的漏洞,应该直接返回 404。
坑二:PUT 做成部分更新。 如果 PUT /books/42 只更新请求体里出现的字段,那它就不是「整体替换」而是 PATCH 的语义,幂等性也不成立了(同一个请求体对不同初始状态产生不同结果)。要部分更新就用 PATCH。
坑三:路径里塞动词当常态。 个别动作子路径是合理破例,但如果 /books/{id}/borrow、/books/{id}/reserve、/books/{id}/recommend 成批出现,说明资源建模没做对——这些动作大概率各自对应一个可命名的资源。
坑四:错误响应泄露内部细节。 直接把 SQLException 的堆栈或数据库表名放进 detail,既暴露结构又容易被利用。detail 只写面向调用方的话,内部细节进日志。
小结
- 资源判据只有一条:能不能被单独指认、单独获取、单独持有状态。能,就是资源;不能,才考虑动作式。
- URL 层级与扁平不是二选一:层级式只承担「创建子资源」和「取全部子资源」,其余过滤查询走扁平式。
- HTTP 方法的「安全」与「幂等」直接影响缓存、预取和重试,不能随意把
GET用在写操作上。 - 状态码要落到语义:201 必带
Location,204 无响应体,404 不要用 200 掩盖,冲突用 409。 - 批量操作、状态变更动作、复杂查询是三类允许的破例,但都有明确的边界条件与写法。
- 错误响应统一用
ProblemDetail(RFC 9457),自定义字段平铺,不额外套壳。
资源模型定了,接下来要处理「集合资源怎么返回」——分页、过滤、排序,这是任何列表接口都绕不开的三件事,也是 5.2 的主题。
阅读导航:上一节:4.3 契约测试与数据隔离 · 下一节:5.2 分页、过滤与排序 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。