本节目标:把 18.1 的领域模型接上内存仓储和命令行,让
library能真实完成「录入、列出、借出、归还、搜索」;同时理解仓储接口为什么值得单独定义。
适用版本:JDK 25 LTS(Temurin 25.0.5);代码只用标准库。
领域模型之后,缺的是存储和入口
18.1 定义了 Book、Member、Loan 和 LibraryService,但服务层的构造器需要一个 LibraryRepository。这个接口当时没有实现,程序还不能启动。
这一节补两块:
- 存储:把书、会员、借阅记录放进内存,并保证「同一本书只有一条活动借阅」。
- 入口:从标准输入读命令,调用服务层,把结果打印到终端。
两块都围绕一个原则:服务层不知道数据存在哪里,也不知道命令从哪里来。 这样第 18.3 节加 HTTP 入口时,服务层一行都不用改。
仓储接口:把存储挡在服务后面
LibraryRepository 只描述「需要什么操作」,不描述「怎么存」:
package com.plume.library.repository;
import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;
import com.plume.library.domain.Member;
import java.util.List;
import java.util.Optional;
public interface LibraryRepository {
void saveBook(Book book);
Optional<Book> findBook(String isbn);
List<Book> findAllBooks();
void saveMember(Member member);
Optional<Member> findMember(String id);
void saveLoan(Loan loan);
Optional<Loan> findActiveLoan(String isbn);
List<Loan> findLoansByMember(String memberId);
List<Loan> findAllLoans();
}
接口里没有 close()、flush()、connect(),也没有 SQL 或文件路径。它只表达领域需要的能力:保存、按 id 查、查全部、查活动借阅。
findBook 返回 Optional<Book>,把「可能不存在」写进类型,而不是返回 null 让调用方猜。findActiveLoan 同理:没有活动借阅是正常状态,不是异常。
第 16 章写测试时,可以用一个 FakeLibraryRepository 实现这个接口,不碰真实存储;第 18.3 节的 HTTP 服务也可以复用同一个接口。接口的价值不在于「以后可能换数据库」,而在于让上层不被具体存储绑住。
内存实现:两张表和一个历史
内存版用四个字段:
package com.plume.library.repository;
import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;
import com.plume.library.domain.Member;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
public final class InMemoryLibraryRepository implements LibraryRepository {
private final Map<String, Book> books = new TreeMap<>();
private final Map<String, Member> members = new HashMap<>();
private final Map<String, Loan> activeLoans = new HashMap<>();
private final List<Loan> loanHistory = new ArrayList<>();
books 用 TreeMap,按 ISBN 的字典序排列。这样 list 的输出顺序稳定,不依赖插入顺序,也不会因为 HashMap 的哈希分布在不同 JVM 上变化。members 用 HashMap,因为查询只按 id,不需要顺序。
activeLoans 的 key 是 ISBN,value 是当前活动借阅。一本书最多一条活动记录这条规则,在这个结构里是天然成立的:put 会覆盖同 key 的旧值。loanHistory 保存所有借阅记录,包括已归还的,用于按会员查询。
保存借阅记录时,活动记录和历史记录要一起维护:
@Override
public synchronized void saveLoan(Loan loan) {
if (loan.isActive()) {
activeLoans.put(loan.isbn(), loan);
loanHistory.add(loan);
return;
}
Loan previous = activeLoans.remove(loan.isbn());
if (previous != null) {
loanHistory.remove(previous);
}
loanHistory.add(loan);
}
@Override
public synchronized Optional<Loan> findActiveLoan(String isbn) {
return Optional.ofNullable(activeLoans.get(isbn));
}
@Override
public synchronized List<Loan> findLoansByMember(String memberId) {
return loanHistory.stream()
.filter(loan -> loan.memberId().equals(memberId))
.toList();
}
@Override
public synchronized List<Loan> findAllLoans() {
return List.copyOf(loanHistory);
}
}
归还时,loan.isActive() 为 false。代码先把它从 activeLoans 移除,再在历史里用「关闭后的记录」替换「借出时的记录」。如果不替换,历史里会同时出现「借出中」和「已归还」两条,按会员查询时就会重复。
所有方法都加了 synchronized。第 18.3 节的 HTTP 服务会用固定线程池处理请求,多个请求可能同时读写仓储。TreeMap、HashMap、ArrayList 都不是线程安全的,方法级同步是最小成本的保护。
findAllBooks 和 findAllLoans 返回 List.copyOf(...),给出不可变快照。调用方无法通过返回的列表修改仓储内部结构,这比返回内部 ArrayList 更安全。
装配:LibraryApp 的两种入口
LibraryApp 负责把实现类和接口接起来:
package com.plume.library;
import com.plume.library.cli.CommandParser;
import com.plume.library.http.LibraryHttpServer;
import com.plume.library.repository.InMemoryLibraryRepository;
import com.plume.library.service.LibraryService;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
public final class LibraryApp {
private LibraryApp() {
}
public static void main(String[] args) throws IOException {
LibraryService service = new LibraryService(new InMemoryLibraryRepository());
if (args.length > 0 && "serve".equals(args[0])) {
int port = args.length > 1 ? Integer.parseInt(args[1]) : 8080;
LibraryHttpServer server = new LibraryHttpServer(service, port);
server.start();
Runtime.getRuntime().addShutdownHook(new Thread(server::stop));
System.out.println("libraryd listening on http://localhost:" + server.port());
return;
}
CommandParser parser = new CommandParser(service, System.out, LocalDate::now);
parser.run(new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)));
}
}
main 只做两件事:装配、分发。不带参数时进 CLI;第一个参数是 serve 时启动 HTTP 服务。LocalDate::now 作为时钟传给 CommandParser,第 16 章讲的可测试性在这里落地:测试可以传一个固定日期,不必真的等到逾期。
InputStreamReader 显式指定 StandardCharsets.UTF_8,不依赖平台默认编码。第 14 章讲过,编码问题在跨平台时最容易出现,入口处写清楚成本最低。
CLI 协议:六个命令
命令协议保持小而明确:
| 命令 | 参数 | 作用 |
|---|---|---|
add | <isbn>|<title>|<author>|<year> | 新增图书 |
list | 无 | 列出全部图书与借阅状态 |
borrow | <isbn> <memberId> | 借书 |
return | <isbn> | 还书并计算罚金 |
search | <keyword> | 按 ISBN、书名或作者搜索 |
quit | 无 | 退出 |
add 用竖线分隔,而不是空格,因为书名和作者里经常有空格。borrow 只有两个短参数,用空格更自然。两种分隔符并存不是随意设计:参数里可能含空格的命令,用不会出现在参数里的分隔符。
CommandParser 的主循环和分发逻辑:
public void run(BufferedReader in) throws IOException {
out.println("library 0.1.0 (type help for commands)");
String line;
while (true) {
out.print("> ");
out.flush();
line = in.readLine();
if (line == null || !execute(line)) {
break;
}
}
}
public boolean execute(String line) {
String trimmed = line.trim();
if (trimmed.isEmpty()) {
return true;
}
int space = trimmed.indexOf(' ');
String command = space < 0 ? trimmed : trimmed.substring(0, space);
String rest = space < 0 ? "" : trimmed.substring(space + 1).trim();
try {
return switch (command) {
case "add" -> add(rest);
case "list" -> list();
case "borrow" -> borrow(rest);
case "return" -> returnBook(rest);
case "search" -> search(rest);
case "help" -> help();
case "quit" -> quit();
default -> {
out.println("unknown command: " + command + " (type help)");
yield true;
}
};
} catch (LibraryException | IllegalArgumentException e) {
out.println("error: " + e.getMessage());
return true;
}
}
execute 返回 boolean:true 表示继续,false 表示退出。主循环读到 quit 时拿到 false,跳出 while。把「是否继续」做成返回值,比在解析器里直接 System.exit(0) 更好测试。
catch (LibraryException | IllegalArgumentException e) 捕获两类错误:业务规则错误和参数解析错误。两者都打印一行 error: ...,然后继续读下一条命令。程序不会因为一次输错就退出。
三个写命令的实现:
private boolean add(String rest) {
String[] parts = rest.split("\\|", -1);
if (parts.length != 4) {
out.println("usage: add <isbn>|<title>|<author>|<year>");
return true;
}
Book book = new Book(
parts[0].trim(),
parts[1].trim(),
parts[2].trim(),
Integer.parseInt(parts[3].trim()));
service.addBook(book);
out.println("added: " + book.isbn());
return true;
}
private boolean borrow(String rest) {
String[] parts = rest.split("\\s+");
if (parts.length != 2) {
out.println("usage: borrow <isbn> <memberId>");
return true;
}
LibraryService.LoanReceipt receipt = service.borrow(parts[0], parts[1], clock.get());
out.printf("borrowed: %s -> %s, due %s%n",
receipt.isbn(), receipt.memberId(), receipt.dueOn());
return true;
}
private boolean returnBook(String rest) {
if (rest.isEmpty()) {
out.println("usage: return <isbn>");
return true;
}
LibraryService.ReturnReceipt receipt = service.returnBook(rest, clock.get());
out.printf("returned: %s, overdue %d day(s), fine %s%n",
receipt.isbn(), receipt.overdueDays(), formatCents(receipt.fineCents()));
return true;
}
split("\\|", -1) 的 -1 保留末尾空字段。如果用户输入 add isbn|title||2018,不带 -1 时作者字段会被丢掉,parts.length 变成 3,错误信息就变成「参数个数不对」,而不是「作者为空」。保留空字段能让 Book 的构造器给出更准确的错误。
formatCents 只用整数运算:
static String formatCents(long cents) {
return "¥%d.%02d".formatted(cents / 100, cents % 100);
}
0 输出 ¥0.00,50 输出 ¥0.50,125 输出 ¥1.25。不经过 double,不会有精度问题。
跑起来
构建并启动:
mvn -q clean package -DskipTests
java -jar target/library.jar
本机终端会话(输入与输出混排):
library 0.1.0 (type help for commands)
> add 9787111407010|Effective Java|Joshua Bloch|2018
added: 9787111407010
> add 9787111544937|Java 编程思想|Bruce Eckel|2007
added: 9787111544937
> list
9787111407010 | Effective Java | Joshua Bloch | 2018 | available
9787111544937 | Java 编程思想 | Bruce Eckel | 2007 | available
> borrow 9787111407010 m-001
borrowed: 9787111407010 -> m-001, due 2026-11-10
> borrow 9787111407010 m-002
error: 图书已借出: 9787111407010
> return 9787111407010
returned: 9787111407010, overdue 0 day(s), fine ¥0.00
> quit
bye
这一段覆盖了主要路径:新增、列出、借出、重复借出被拒、归还、退出。到期日是借出日 2026-10-27 加 14 天,得到 2026-11-10;当天归还没有逾期,罚金为 0。
逾期路径可以在测试里用固定时钟验证,也可以在真实运行中等到期后再还。CommandParser 的 clock 是 Supplier<LocalDate>,测试传入 () -> LocalDate.of(2026, 11, 20),就能立刻验证 10 天逾期、500 分罚金。
数据在进程退出后去哪了
答案是什么都不剩。InMemoryLibraryRepository 的四个字段都在堆上,main 结束、JVM 退出,数据随之消失。这不是 bug,而是卷一的明确边界:先把领域规则、服务和接口写对,持久化留到有稳定模型之后。
第 14 章的 Files、Path、BufferedReader 已经提供了读写文件的能力。真要持久化,可以新增一个 CsvLibraryRepository implements LibraryRepository,在 saveBook 时追加一行、在启动时读回。服务层和 CLI 不需要改,因为它们只依赖接口。
但持久化会带来新问题:并发写、文件损坏、字段转义、版本迁移。这些问题值得单独一章,而不是塞进「完整项目」的最后一节。卷二的 libraryd 会用数据库和容器化部署来处理它们。
常见错误与边界
「add 用空格分隔。」 输入 add 9787111407010 Effective Java 2018 会被拆成 4 段,但第一段是 ISBN,第二段是 Effective,第三段是 Java,第四段是 2018,书名只剩一个单词。用竖线分隔,或者给整个书名加引号由 shell 处理,二选一,不要混用。
「return 一本没借出的书。」 服务层找不到活动借阅,抛 LOAN_NOT_FOUND,CLI 打印 error: 没有找到在借记录: ...。这是正常错误路径,不是崩溃。
「用 System.exit(0) 退出。」 在命令行工具里看似可行,但测试无法拦截,HTTP 入口也不该被 CLI 的退出逻辑影响。execute 返回 false 让主循环自然结束,更可控。
「仓储返回内部集合。」 直接返回 books.values() 或 loanHistory,调用方可能在不经意间修改仓储。List.copyOf 给出快照,代价是一次浅拷贝,换来边界清晰。
小结
LibraryRepository用接口隔离存储,服务层只依赖能力,不依赖实现。- 内存实现用
TreeMap保证书单顺序,用activeLoans保证一书一借,用loanHistory保存历史。 saveLoan在归还时用关闭记录替换活动记录,避免历史里出现重复。- 仓储方法加
synchronized,为第 18.3 节的 HTTP 线程池做准备。 LibraryApp只做装配和入口分发,serve参数留给 HTTP 模式。- CLI 六个命令中,
add用竖线分隔以容纳空格,其余用空格;execute返回布尔值控制循环。 - 内存数据在进程退出后消失,持久化是卷二的扩展,不是本节的遗漏。
CLI 能跑之后,剩下最后一块:让同一个 LibraryService 通过 HTTP 对外提供服务。下一节用 JDK 自带的 com.sun.net.httpserver 暴露 GET /books、POST /books、POST /loans、POST /returns。
阅读导航:上一节:18.1 需求与领域模型:借阅、归还、查询 · 下一节:18.3 用 com.sun.net.httpserver 暴露 REST 接口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。