本节目标:把前 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 章派生查询 |
用例写清楚了,业务规则才有落点。本项目只有四条硬规则,它们决定了后面几乎所有设计:
- 库存不能为负:借书前必须校验
availableCopies > 0,否则拒绝(409)。 - 同一本书不可重复借:一个会员对同一本书,若已有未归还记录,则拒绝再次借阅(409)。
- 有在借记录的图书不可删除:避免外键悬挂(409)。
- 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 : N | loan.book_id | @ManyToOne + 反向 @OneToMany |
| 一位会员 ↔ 多条借阅记录 | 1 : N | loan.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/books | BookCreateRequest | ApiResponse<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} | BookUpdateRequest | ApiResponse<BookResponse> | 200 |
| DELETE | /api/books/{id} | — | ApiResponse<Void> | 200 |
| POST | /api/loans | LoanCreateRequest | ApiResponse<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)
);
| 表 | 字段 | 类型 | 约束 | 索引 |
|---|---|---|---|---|
book | isbn | VARCHAR(20) | NOT NULL, UNIQUE | uk_book_isbn |
book | title | VARCHAR(200) | NOT NULL | 可选,模糊查询用 |
book | available_copies | INTEGER | NOT NULL, CHECK | — |
member | VARCHAR(160) | NOT NULL, UNIQUE | uk_member_email | |
loan | book_id | BIGINT | NOT NULL, FK | idx_loan_book |
loan | member_id | BIGINT | NOT NULL, FK | idx_loan_member |
loan | status | VARCHAR(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 状态 | 含义 | 触发点 |
|---|---|---|---|
| 200 | 200 / 201 | 成功 | 正常返回 |
| 40001 | 400 | 参数校验失败 | @Valid 不通过 |
| 40401 | 404 | 图书不存在 | BookNotFoundException |
| 40402 | 404 | 借阅记录不存在 | LoanNotFoundException |
| 40901 | 409 | 库存不足 | 借书时 availableCopies == 0 |
| 40902 | 409 | 重复借阅 | 同一会员已有在借记录 |
| 40903 | 409 | 有在借记录,不可删除 | 删除图书时 |
| 40904 | 409 | ISBN 重复 | 新增/修改图书时 |
| 50000 | 500 | 服务器内部错误 | 未捕获异常兜底 |
为什么业务码要和 HTTP 状态码分开? 因为 HTTP 状态码只有几十个,语义粗;而业务错误可能上百种,且前端往往要根据业务码决定提示文案与后续动作。两者各司其职:HTTP 状态码给通用客户端、代理、监控用,业务码给前端精确分支用。
18.1.7 技术选型清单
下面每一项都标注了它在本书哪一章学过。这不是炫技清单,而是「为什么这本书按这个顺序讲」的答案——每一项选型都能在前面找到出处。
| 技术点 | 选型 | 章节 | 选它的理由 |
|---|---|---|---|
| Web 框架 | spring-boot-starter-webmvc | 3、8 | 4.x 新名,提供 MVC 与内嵌 Tomcat |
| 参数校验 | spring-boot-starter-validation | 9 | 声明式校验,与统一异常配合 |
| 持久化 | spring-boot-starter-data-jpa(Hibernate 7.2) | 12、13 | 派生查询 + JPQL,够用且不引额外工具 |
| 数据库 | PostgreSQL | 12 | 本地与生产一致,避免 H2 与生产方言差异 |
| 迁移 | spring-boot-starter-flyway(Flyway 12.4) | 15 | 4.x 需要独立 starter |
| 事务 | @Transactional | 14 | 借还的原子性由它保证 |
| 日志 | Logback + JSON 结构化 | 16 | 便于后续接入采集 |
| 测试 | spring-boot-starter-test + Testcontainers 2.0 | 17 | 切片测试 + 真实数据库集成测试 |
| 统一响应 | @RestControllerAdvice | 10 | 一处处理所有异常 |
| 配置 | application-{dev,test,prod}.yml | 6 | 环境隔离 |
有一项要在 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 实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。