本节目标:把「图书借阅系统」从一句话需求拆成可执行的规则,用 record 建出
Book、Member、Loan,并让LibraryService成为唯一裁决借阅、归还和罚金的地方。
适用版本:JDK 25 LTS(Temurin 25.0.5);代码只用标准库。
从零散章节到一个完整程序
前 17 章里,library 一直是「按章生长的半成品」:第 7 章有 Book 的字段,第 9 章改成 record,第 13 章加了异常,第 15 章补了借期和罚金,第 16 章写了测试。每章只验证一个点,合在一起却不一定能跑。
第 18 章的任务是收口,做出一个能交付的版本。它只需要三个动作:
- 借阅:会员把一本在架图书借走。
- 归还:会员把书还回来,系统算逾期天数。
- 查询:列出全部图书、按关键词搜索、查看借阅状态。
范围也要说清楚。卷一不做的内容包括:持久化到文件或数据库、用户登录与权限、多人并发下的分布式锁、分页和排序参数、HTTP 鉴权。这些不是不重要,而是会把一个教学项目拖成半成品框架。先把规则写对,再谈基础设施。
三条业务规则,先写成句子
需求如果只写「能借能还」,实现时就会到处补条件。先把规则写成可判定的句子:
| 规则 | 具体行为 | 违反时的结果 |
|---|---|---|
| 一本书同时只能借给一个人 | 借出前检查是否有在借记录 | BOOK_ALREADY_BORROWED |
| 借期 14 天 | dueOn = borrowedOn + 14 天 | 不涉及 |
| 逾期按天计费 | 每天 50 分,归还日减到期日 | 归还回执里给出 fineCents |
| ISBN 不重复 | 新增前检查书库 | DUPLICATE_ISBN |
| 归还必须有在借记录 | 按 ISBN 找活动借阅 | LOAN_NOT_FOUND |
这里有一个卷一特有的简化:会员在首次借阅时自动建档,名字默认等于 id。 真实系统会先注册会员、校验身份;卷一先把借阅主链路跑通,避免为了一个 member 命令再扩一套 CLI。这个取舍会在本节末尾展开。
罚金用「分」而不是「元」作为单位。50 表示 0.50 元,计算全程用 long,只在显示时格式化成 ¥0.50。金额一旦用 double,0.1 + 0.2 这类问题就会在某个对账日冒出来。
领域模型:三个 record
先写最稳定的部分:数据形状。Book 只描述一本实体书,不关心它现在在谁手里:
package com.plume.library.domain;
public record Book(String isbn, String title, String author, int year) {
public Book {
if (isbn == null || isbn.isBlank()) {
throw new IllegalArgumentException("isbn 不能为空");
}
if (title == null || title.isBlank()) {
throw new IllegalArgumentException("title 不能为空");
}
if (author == null || author.isBlank()) {
throw new IllegalArgumentException("author 不能为空");
}
if (year < 0) {
throw new IllegalArgumentException("year 不能为负数: " + year);
}
}
}
紧凑构造器(compact constructor)在对象创建时做校验,不写 this.isbn = isbn,record 会自动赋值。这样 new Book("", "x", "y", 2000) 不会悄悄生成一个坏对象,而是在构造阶段就抛 IllegalArgumentException。
Member 更简单,只有 id 和名字:
package com.plume.library.domain;
public record Member(String id, String name) {
public Member {
if (id == null || id.isBlank()) {
throw new IllegalArgumentException("会员 id 不能为空");
}
if (name == null || name.isBlank()) {
name = id;
}
}
}
紧凑构造器里可以给参数重新赋值,record 会用新值初始化字段。名字为空时回退到 id,和「首次借阅自动建档」的简化保持一致。
Loan 是三者里唯一有状态的:它要记录借出日、到期日、归还日:
package com.plume.library.domain;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Objects;
public record Loan(
String isbn,
String memberId,
LocalDate borrowedOn,
LocalDate dueOn,
LocalDate returnedOn) {
public static final int LOAN_DAYS = 14;
public Loan {
Objects.requireNonNull(isbn, "isbn");
Objects.requireNonNull(memberId, "memberId");
Objects.requireNonNull(borrowedOn, "borrowedOn");
Objects.requireNonNull(dueOn, "dueOn");
if (dueOn.isBefore(borrowedOn)) {
throw new IllegalArgumentException("dueOn 不能早于 borrowedOn");
}
if (returnedOn != null && returnedOn.isBefore(borrowedOn)) {
throw new IllegalArgumentException("returnedOn 不能早于 borrowedOn");
}
}
public static Loan open(String isbn, String memberId, LocalDate today) {
return new Loan(isbn, memberId, today, today.plusDays(LOAN_DAYS), null);
}
public boolean isActive() {
return returnedOn == null;
}
public Loan close(LocalDate today) {
if (!isActive()) {
throw new IllegalStateException("借阅记录已关闭: " + isbn);
}
return new Loan(isbn, memberId, borrowedOn, dueOn, today);
}
public long overdueDays(LocalDate today) {
LocalDate end = returnedOn != null ? returnedOn : today;
return Math.max(0, ChronoUnit.DAYS.between(dueOn, end));
}
}
returnedOn 为 null 表示还在借。归还时 close 不修改原对象,而是返回一条新的 Loan:
Loan closed = loan.close(today);
这是不可变设计的直接收益:历史记录不会被后续操作改掉,仓储里存的是「每个时刻的借阅事实」,而不是一个会被覆盖的可变行。第 7 章讲的不可变,到这里变成了业务上的可审计性。
借阅与归还的状态转换
把借阅看成状态机,只有两个状态和两条边:
available --borrow--> on loan --return--> available
available 不是 Book 的字段,而是「没有活动借阅」推导出来的状态。这样做的好处是只有一份真相:借阅表里有没有 returnedOn == null 的记录。如果 Book 里再放一个 boolean borrowed,两次更新就可能不一致。
| 当前状态 | 动作 | 结果 |
|---|---|---|
| 在架 | borrow(isbn, memberId, today) | 新建 Loan,dueOn = today + 14 |
| 已借出 | borrow(...) | 抛 BOOK_ALREADY_BORROWED |
| 已借出 | return(isbn, today) | close 生成归还记录,计算罚金 |
| 在架 | return(isbn, today) | 抛 LOAN_NOT_FOUND |
逾期天数用日历日算,不用小时。图书馆的规则是「到期日当天还不算逾期,第二天开始算」,所以:
long days = Math.max(0, ChronoUnit.DAYS.between(dueOn, end));
ChronoUnit.DAYS.between(dueOn, end) 在 end 早于 dueOn 时返回负数,Math.max(0, ...) 把它压成 0。到期日当天归还,差值是 0,罚金也是 0。
服务层:规则只写一遍
Book 和 Loan 只负责自己的校验,不负责「书库里有没有这本书」「这本书是不是已经借出」。这些跨对象规则放在 LibraryService:
package com.plume.library.service;
import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;
import com.plume.library.domain.Member;
import com.plume.library.repository.LibraryRepository;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
public final class LibraryService {
public static final long FINE_PER_DAY_CENTS = 50;
private final LibraryRepository repository;
public LibraryService(LibraryRepository repository) {
this.repository = repository;
}
public record BookView(Book book, String memberId) {
public boolean available() {
return memberId == null;
}
}
public record LoanReceipt(
String isbn,
String memberId,
LocalDate borrowedOn,
LocalDate dueOn) {
}
public record ReturnReceipt(
String isbn,
String memberId,
LocalDate returnedOn,
long overdueDays,
long fineCents) {
}
}
三个嵌套 record 是服务层的输出模型:BookView 给查询用,LoanReceipt 给借阅用,ReturnReceipt 给归还用。它们不是数据库实体,只是「这一次调用的结果」,所以放在服务层而不是 domain 包。
下面是同一个类里的两个核心方法。借阅方法把所有前置条件按顺序检查:
public synchronized LoanReceipt borrow(String isbn, String memberId, LocalDate today) {
repository.findBook(isbn).orElseThrow(() -> new LibraryException(
ErrorCode.BOOK_NOT_FOUND,
"图书不存在: " + isbn));
if (repository.findActiveLoan(isbn).isPresent()) {
throw new LibraryException(
ErrorCode.BOOK_ALREADY_BORROWED,
"图书已借出: " + isbn);
}
if (repository.findMember(memberId).isEmpty()) {
repository.saveMember(new Member(memberId, memberId));
}
Loan loan = Loan.open(isbn, memberId, today);
repository.saveLoan(loan);
return new LoanReceipt(isbn, memberId, loan.borrowedOn(), loan.dueOn());
}
public synchronized ReturnReceipt returnBook(String isbn, LocalDate today) {
Loan loan = repository.findActiveLoan(isbn).orElseThrow(() -> new LibraryException(
ErrorCode.LOAN_NOT_FOUND,
"没有找到在借记录: " + isbn));
Loan closed = loan.close(today);
repository.saveLoan(closed);
long overdueDays = closed.overdueDays(today);
return new ReturnReceipt(
isbn,
closed.memberId(),
today,
overdueDays,
overdueDays * FINE_PER_DAY_CENTS);
}
borrow 的检查顺序是有意的:先确认书存在,再确认没被借走,最后才建档和写借阅记录。顺序反了会出现「书不存在但会员被创建」的副作用。synchronized 是为了第 18.3 节的 HTTP 线程池:检查和写入必须是一个原子动作,否则两个请求可能同时通过「没有活动借阅」的检查。
returnBook 只找活动借阅,找不到就抛 LOAN_NOT_FOUND;归还成功后用 closed.overdueDays(today) 算罚金。注意罚金来自「关闭后的记录」,而不是重新拿原始 loan 算,这样 returnedOn 一定参与计算。
异常沿用 13.1 节定义的 LibraryException 与 ErrorCode,本节不再重复定义。服务层只负责抛错,入口层决定它对应什么状态码:
ErrorCode | HTTP 状态 | 场景 |
|---|---|---|
BOOK_NOT_FOUND | 404 | ISBN 不存在 |
BOOK_ALREADY_BORROWED | 409 | 图书已有活动借阅 |
LOAN_NOT_FOUND | 404 | 没有可归还的借阅 |
INVALID_COMMAND | 400 | 请求字段缺失或格式错误 |
DUPLICATE_ISBN | 400 | 新增时 ISBN 已存在 |
INTERNAL | 500 | 未预期的内部错误 |
CLI 只需要打印 getMessage(),HTTP 层则把 errorCode() 映射成状态码。异常类型把「用户可见的错误」和「程序 bug」分开:前者是 LibraryException,后者让 NullPointerException、IllegalStateException 继续往上抛,不要包装成 400。
版本演进提示:第 11 章用
Result<Book>演示「把成功和失败写进返回类型」,第 13.3 节展示了Result与异常的两种风格。完整项目为了同时服务 CLI 和 HTTP,统一用LibraryException表达规则失败,并把第 11 章的registerBook命名为addBook。读到这里时以本章的接口为准;前几章的Result仍然有效,只是最终项目没有采用它作为主流程。
项目结构
第 18 章结束时的目录如下:
library/
├── pom.xml
└── src/main/java/
├── module-info.java
└── com/plume/library/
├── LibraryApp.java
├── domain/
│ ├── Book.java
│ ├── Member.java
│ └── Loan.java
├── repository/
│ ├── LibraryRepository.java
│ └── InMemoryLibraryRepository.java
├── service/
│ ├── LibraryException.java
│ └── LibraryService.java
├── cli/
│ └── CommandParser.java
└── http/
├── Json.java
└── LibraryHttpServer.java
分层的边界是:domain 不知道仓储,repository 不知道 CLI/HTTP,service 只依赖仓储接口,cli 和 http 只依赖服务。这样第 16 章的测试替身可以替换仓储,第 18.2 节的 CLI 和第 18.3 节的 HTTP 也能共用同一套服务。
边界与取舍
会员自动建档。 borrow 发现会员不存在时创建 new Member(memberId, memberId)。这让 CLI 只保留 add/list/borrow/return/search/quit 六个命令,不用再加 member。代价是无法区分「会员 id 写错」和「新会员首次借阅」。卷二接入账号体系后会去掉这个简化。
内存存储。 仓储把所有数据放在 Map 和 List 里,进程退出就清空。第 14 章的 Files 读写可以做成持久化实现,但那是卷二的扩展;卷一先把接口和内存实现写对。
金额用分。 FINE_PER_DAY_CENTS = 50,fineCents 是 long。显示时格式化为 ¥0.50,计算和存储都不出现浮点数。
日期用 LocalDate。 借期是「日历日」,不是「24 小时」。用 LocalDate 可以避开时区和夏令时,today.plusDays(14) 的可读性也更好。
常见错误与边界
「用 double 存罚金。」 0.1 + 0.2 不等于 0.3,罚金累加几次就会出现一分钱的偏差。金额一律用最小货币单位(分)或 BigDecimal,卷一选前者。
「把 Book 的借出状态存成字段。」 一旦 Book.borrowed = true 和借阅表不同步,查询就会自相矛盾。状态应由活动借阅推导,不要存两份。
「归还时直接改原 Loan。」 record 没有 setter,强行改成普通类会破坏不可变性。正确做法是 close 返回新记录,仓储用新记录替换旧记录。
「逾期天数用 Period 或手写减法学。」 ChronoUnit.DAYS.between(dueOn, end) 已经处理了跨月、跨年;手写 end.getDayOfMonth() - dueOn.getDayOfMonth() 会在跨月时出错。负数要用 Math.max(0, ...) 压平。
小结
library卷一只有借阅、归还、查询三个动作,不做持久化、鉴权、分页和分布式并发。- 三条核心规则:一本书同时只能借给一个人、借期 14 天、逾期每天 50 分。
Book、Member、Loan都是 record,构造器做校验,Loan.close返回新记录而不是修改原对象。- 「在架/已借出」由活动借阅推导,不在
Book里存重复状态。 LibraryService是唯一写业务规则的地方,borrow的检查顺序和synchronized都是有意设计。- 罚金用
long分计算,逾期天数用ChronoUnit.DAYS并压掉负数。 - 会员自动建档和内存存储是卷一的明确取舍,不是遗漏。
领域模型定下来后,接下来要给它接上存储和交互。下一节实现 LibraryRepository 的内存版本,把六个 CLI 命令串成一个真正能用的程序。
阅读导航:上一节:17.3 版本、发布与可复现构建 · 下一节:18.2 内存仓储与 CLI 交互 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。