《Spring Boot 入门》18.1 需求与设计

用一个「图书借阅管理服务」把前 17 章串成完整项目:梳理增删改查、借阅归还、分页查询的用例,设计实体关系、Controller/Service/Repository/DTO 分层与依赖方向,给出接口清单、数据库表结构、Flyway 迁移脚本、错误码与统一响应结构,并列出技术选型与对应章节。

本节目标:把前 17 章散落的知识点收敛到一个「图书借阅管理服务」上,完成需求梳理、领域建模、分层设计、接口清单、表结构与错误码约定,形成一份可以直接照着写代码的设计文档。
适用版本:Spring Boot 4.1.x(Java 21)

18.1 需求与设计

到第 17 章为止,你已经分别学过控制器、校验、JPA、事务、Flyway、日志与测试,但每一节都只在「一个能演示概念的最小例子」上打转。真实的项目不是这样长出来的:先有需求,再有设计,最后才是逐层实现。本节先把需求与设计钉死,18.2 照着实现,18.3 打包运行。整个过程围绕同一个领域——图书借阅管理,它从第 12 章起就贯穿全书,这里只是把它做完整。

18.1.1 需求梳理

一个「最小但完整」的图书借阅服务,功能收敛到三组用例。不要贪多,能跑通闭环比堆功能更重要。

编号用例说明涉及的章节知识
UC-1新增图书录入书名、作者、ISBN、分类、总册数8 章路由、9 章校验、12 章持久化
UC-2查询单本图书按 ID 取详情8 章路径变量、10 章 404
UC-3修改图书改书名、分类、总册数9 章分组校验、14 章事务
UC-4删除图书无在借记录时才能删10 章业务异常、14 章事务
UC-5分页查询图书关键字模糊匹配 + 分页排序13 章分页与排序
UC-6借书库存减一,生成借阅记录14 章事务边界
UC-7还书库存加一,回填归还时间14 章事务边界
UC-8查询借阅记录按会员分页查历史13 章派生查询

用例写清楚了,业务规则才有落点。本项目只有四条硬规则,它们决定了后面几乎所有设计:

  1. 库存不能为负:借书前必须校验 availableCopies > 0,否则拒绝(409)。
  2. 同一本书不可重复借:一个会员对同一本书,若已有未归还记录,则拒绝再次借阅(409)。
  3. 有在借记录的图书不可删除:避免外键悬挂(409)。
  4. ISBN 唯一:重复录入直接拒绝(409)。

18.1.2 领域模型设计

领域模型只保留三个实体。刻意不引入「出版社」「作者」等独立表,是为了让关联关系停在「够用」的复杂度上——你已经在第 13 章练过 @ManyToOne,这里用一次即可。

实体中文名关键属性说明
Book图书id、isbn、title、author、category、totalCopies、availableCopies一本书一行,库存是「总册数 - 在借数」
Member会员id、name、email、status借书主体,status 取 ACTIVE / SUSPENDED
Loan借阅记录id、book、member、borrowedAt、dueAt、returnedAt、status一次借还一条记录

关系如下(用文字描述,避免图):

关系基数外键位置映射注解
一本图书 ↔ 多条借阅记录1 : Nloan.book_id@ManyToOne + 反向 @OneToMany
一位会员 ↔ 多条借阅记录1 : Nloan.member_id@ManyToOne + 反向 @OneToMany

为什么库存要冗余成 availableCopies 列,而不是每次 count 借阅记录? 因为借书是一个高频且需要立刻判断的操作,用一列整数承载「当前可借数」,配合数据库行锁或乐观锁即可保证一致性;如果每次都去 count(loan where status='BORROWED'),在并发下既慢又容易读脏。这是「用一点冗余换确定性」的经典取舍,代价是每一次借还都必须同步维护这一列,绝不允许出现两条更新路径。

18.1.3 分层设计

分层不是仪式感,它的唯一目的是约束依赖方向。本项目的依赖是单向的:

HTTP 请求
   │
   ▼
Controller  ──依赖──▶  Service  ──依赖──▶  Repository  ──▶  数据库
   │                     │
   │                     └──依赖──▶  Entity
   └──依赖──▶  DTO(入参 / 出参)

各层职责与「绝对不要做的事」:

层职责绝对不要做
Controller接收 HTTP、参数绑定与校验、调用 Service、包装统一响应、决定状态码写业务规则、直接调用 Repository、拼 SQL
Service业务规则、事务边界、编排多个 Repository、抛出业务异常直接操作 HttpServletRequest、返回 ResponseEntity
Repository数据访问、派生查询、@Query写业务判断、跨聚合编排
DTO定义接口契约(入参与出参)、承载校验注解与实体互相继承、把实体直接当 DTO 用
Entity映射表结构、承载持久化状态直接暴露给 Controller、承载 HTTP 语义

依赖方向一句话:Controller 认识 Service,Service 认识 Repository,反过来一律不认识。DTO 是「跨边界的数据形状」,Controller 与 Service 之间传 DTO,Service 与 Repository 之间传实体。这条边界一旦守住,改表结构就不会波及接口,改接口也不会波及 SQL。

18.1.4 接口清单

接口先行,是让前后端能并行、让测试能提前写的基础。本项目全部走 /api 前缀,响应体统一为 ApiResponse<T>(见 18.1.6)。

方法路径请求体成功响应状态码
POST/api/booksBookCreateRequestApiResponse<BookResponse>201
GET/api/books—(query:keyword、page、size、sort)ApiResponse<PageResponse<BookResponse>>200
GET/api/books/{id}—ApiResponse<BookResponse>200
PUT/api/books/{id}BookUpdateRequestApiResponse<BookResponse>200
DELETE/api/books/{id}—ApiResponse<Void>200
POST/api/loansLoanCreateRequestApiResponse<LoanResponse>201
POST/api/loans/{id}/return—ApiResponse<LoanResponse>200
GET/api/loans—(query:memberId、page、size)ApiResponse<PageResponse<LoanResponse>>200

几个约定值得先记下,它们会在 18.2 逐条兑现:

  • 创建用 201,其余用 200,删除也返回 200 而不是 204,因为响应体里还要带 ApiResponse 外壳。
  • 分页统一包一层 PageResponse,不要直接暴露 Spring Data 的 Page 序列化结果(它的 JSON 结构不稳定,且耦合了框架类型)。
  • 借还都用 POST,return 是动作而非资源创建,但用 /loans/{id}/return 表达「对这条记录执行归还」在语义上是清晰的。

契约落到 JSON 上长这样。新增图书的请求体:

{
  "isbn": "978-7-111-12345-6",
  "title": "深入理解计算机系统",
  "author": "Randal E. Bryant",
  "category": "计算机",
  "totalCopies": 3
}

成功的响应(注意 availableCopies 初始等于 totalCopies):

{
  "code": 200,
  "message": "OK",
  "data": {
    "id": 1,
    "isbn": "978-7-111-12345-6",
    "title": "深入理解计算机系统",
    "author": "Randal E. Bryant",
    "category": "计算机",
    "totalCopies": 3,
    "availableCopies": 3
  }
}

分页响应的 data 形状固定为四项,前端可无条件依赖:

{
  "code": 200,
  "message": "OK",
  "data": {
    "items": [],
    "page": 0,
    "size": 10,
    "totalElements": 0
  }
}

18.1.5 数据库表设计与 Flyway 迁移

三张表对应三个实体。所有表都带自增主键、创建/更新时间,金额与数量用能精确表示的整数类型。

-- book 表
CREATE TABLE book (
    id               BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    isbn             VARCHAR(20)  NOT NULL,
    title            VARCHAR(200) NOT NULL,
    author           VARCHAR(120) NOT NULL,
    category         VARCHAR(60)  NOT NULL,
    total_copies     INTEGER      NOT NULL,
    available_copies INTEGER      NOT NULL,
    created_at       TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at       TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT uk_book_isbn UNIQUE (isbn),
    CONSTRAINT ck_book_copies CHECK (available_copies >= 0 AND available_copies <= total_copies)
);
表字段类型约束索引
bookisbnVARCHAR(20)NOT NULL, UNIQUEuk_book_isbn
booktitleVARCHAR(200)NOT NULL可选,模糊查询用
bookavailable_copiesINTEGERNOT NULL, CHECK—
memberemailVARCHAR(160)NOT NULL, UNIQUEuk_member_email
loanbook_idBIGINTNOT NULL, FKidx_loan_book
loanmember_idBIGINTNOT NULL, FKidx_loan_member
loanstatusVARCHAR(20)NOT NULL组合索引

loan 表加一个组合索引 idx_loan_member_status(member_id, status),因为「某会员当前在借」这个查询在借书校验里被频繁调用。索引不是越多越好——每一个索引都会拖慢写入,只为真实存在的查询路径建。

迁移脚本按 Flyway 的命名规范(第 15 章)组织:

src/main/resources/db/migration/
├── V1__create_schema.sql      # 建三张表
├── V2__create_indexes.sql     # 组合索引与外键
└── V3__seed_books.sql         # 少量种子数据,便于本地验证

为什么表结构不交给 Hibernate 自动生成? 因为 ddl-auto=update 在生产上是灾难:它不会删除列、不会改类型、也无法回滚,且每次启动都可能产生意外 DDL。用 Flyway 显式管理迁移,表结构的每一次变化都有一条可审计、可重放的脚本——这正是第 15 章反复强调的。

18.1.6 错误码与统一响应结构

这是第 10 章的收口。所有响应(成功与失败)共用同一外形:

{
  "code": 200,
  "message": "OK",
  "data": { }
}

code 是业务码而非 HTTP 状态码,两者可以不同(例如「库存不足」用 HTTP 409 + 业务码 40901)。约定如下:

业务码HTTP 状态含义触发点
200200 / 201成功正常返回
40001400参数校验失败@Valid 不通过
40401404图书不存在BookNotFoundException
40402404借阅记录不存在LoanNotFoundException
40901409库存不足借书时 availableCopies == 0
40902409重复借阅同一会员已有在借记录
40903409有在借记录,不可删除删除图书时
40904409ISBN 重复新增/修改图书时
50000500服务器内部错误未捕获异常兜底

为什么业务码要和 HTTP 状态码分开? 因为 HTTP 状态码只有几十个,语义粗;而业务错误可能上百种,且前端往往要根据业务码决定提示文案与后续动作。两者各司其职:HTTP 状态码给通用客户端、代理、监控用,业务码给前端精确分支用。

18.1.7 技术选型清单

下面每一项都标注了它在本书哪一章学过。这不是炫技清单,而是「为什么这本书按这个顺序讲」的答案——每一项选型都能在前面找到出处。

技术点选型章节选它的理由
Web 框架spring-boot-starter-webmvc3、84.x 新名,提供 MVC 与内嵌 Tomcat
参数校验spring-boot-starter-validation9声明式校验,与统一异常配合
持久化spring-boot-starter-data-jpa(Hibernate 7.2)12、13派生查询 + JPQL,够用且不引额外工具
数据库PostgreSQL12本地与生产一致,避免 H2 与生产方言差异
迁移spring-boot-starter-flyway(Flyway 12.4)154.x 需要独立 starter
事务@Transactional14借还的原子性由它保证
日志Logback + JSON 结构化16便于后续接入采集
测试spring-boot-starter-test + Testcontainers 2.017切片测试 + 真实数据库集成测试
统一响应@RestControllerAdvice10一处处理所有异常
配置application-{dev,test,prod}.yml6环境隔离

有一项要在 4.x 特别注意:Flyway 从 4.0 起必须显式引入 spring-boot-starter-flyway,只加 flyway-core 依赖不再触发自动配置。这是 4.x 模块化重构的直接后果,写 pom.xml 时最容易踩。

18.1.8 关键设计决策记录

设计文档里最容易被省略、却最有用的是「为什么这么定」。把几个绕不开的取舍记下来,将来有人质疑时不必重新论证。

决策选择放弃的方案原因
库存表示availableCopies 冗余列每次 count 借阅记录借书需即时判断,列 + 锁比聚合查询确定
分页返回自定义 PageResponse直接序列化 Page避免暴露框架类型,JSON 形状稳定
删除策略有在借记录则拒绝(软失败)级联删除借阅记录借阅历史是审计数据,不能随书消失
表结构Flyway 迁移ddl-auto=update生产不可依赖自动 DDL,需可审计可回滚
错误表达业务码 + HTTP 状态码只用 HTTP 状态码业务错误种类远多于 HTTP 状态码
实体暴露DTO 与实体分离直接返回实体改表不波及接口,避免过度暴露字段

这张表本身就是一种交付物:18.2 的每一处实现选择,都能回到这里找到依据。

小结

  • 一个可交付的服务从需求开始:8 条用例、4 条硬规则,决定了后续所有设计。
  • 领域模型只有 Book、Member、Loan 三个实体;库存冗余成列是「用确定性换一点冗余」的取舍,代价是必须同步维护。
  • 分层的唯一目的是约束依赖方向:Controller → Service → Repository 单向依赖,DTO 跨边界传数据、实体不出 Service。
  • 接口清单先行:8 个端点,创建 201、其余 200,分页统一包 PageResponse。
  • 表结构交给 Flyway 显式管理,ddl-auto 只用于本地;组合索引只为真实查询路径建。
  • 业务码与 HTTP 状态码分离:前者给前端做精确分支,后者给通用客户端与监控。
  • 技术选型每一项都能在本书前面找到出处——这正是全书顺序设计的用意。

设计定了,接下来就是照着它把代码写出来。18.2 会逐层实现这三个实体、八个接口与一套全局异常处理,并指出实现中最容易走样的地方。

阅读导航:上一节:17.3 集成测试 · 下一节:18.2 实现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》17.3 集成测试