《Spring Boot 实战》5.1 资源建模与路由

把「图书借阅管理服务」的动作式接口重写成资源式接口:讲清资源命名的判据、URL 层级与扁平化的取舍、HTTP 方法与状态码的语义,以及批量操作、自定义动作和统一错误响应该在什么条件下破例,并给出可直接复用的 Controller 与 ProblemDetail 代码。

本节目标:把「图书借阅管理服务」的动作式接口重写成资源式接口,掌握 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 + 空对象掩盖
状态冲突(书已借出)4095.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 分页、过滤与排序 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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