《Java 编程入门》18.1 需求与领域模型:借阅、归还、查询

把前 17 章的能力收束成一个完整项目:先划清 library 的需求边界,再用 Book、Member、Loan 三个 record 和 LibraryService 的借阅、归还方法,把「一本书同时只能借给一个人、借期 14 天、逾期按天计费」写成可执行的领域规则。

本节目标:把「图书借阅系统」从一句话需求拆成可执行的规则,用 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,本节不再重复定义。服务层只负责抛错,入口层决定它对应什么状态码:

ErrorCodeHTTP 状态场景
BOOK_NOT_FOUND404ISBN 不存在
BOOK_ALREADY_BORROWED409图书已有活动借阅
LOAN_NOT_FOUND404没有可归还的借阅
INVALID_COMMAND400请求字段缺失或格式错误
DUPLICATE_ISBN400新增时 ISBN 已存在
INTERNAL500未预期的内部错误

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 交互 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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