《Java 编程入门》18.2 内存仓储与 CLI 交互

给 library 接上存储和交互:用 LibraryRepository 接口隔离存储,用 TreeMap 与活动借阅表实现内存仓储,再让 CommandParser 解析 add、list、borrow、return、search、quit 六个命令,最后用真实终端会话跑通借阅与归还。

本节目标:把 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 接口 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发