cswork / 系统设计

第 46 章 / 共 57 章 · 约 9 分钟

LLD 案例:图书馆管理系统

本页目录
  1. 需求澄清
  2. 核心类
  3. 建模的关键:Book 和 BookCopy 分开
  4. 用到的设计模式
  5. 核心代码
  6. 并发
  7. 扩展讨论
  8. 章节测验

图书馆管理系统考的是建模:哪些是“书”,哪些是“书架上那一本”,借阅和预约分别挂在谁身上。模型一旦理顺,借书、还书、预约排队、罚款这些流程写起来都很直接。

需求澄清

功能需求

  1. 馆藏目录:一种书(ISBN、书名、作者)可以有多本实体副本,每本有自己的条码。
  2. 读者借书:同时最多借 5 本,借期 14 天;有未缴罚款超过 10 元的不能借。
  3. 还书:超期按天罚款,每天 0.5 元,单本封顶 20 元。
  4. 预约:所有副本都借出时可以预约;同一本书的预约按先来后到排队。有副本归还时,留给队首读者 3 天,过期就轮到下一位。
  5. 按书名、作者、ISBN 检索。

非功能需求

  • 同一本副本不能同时借给两个人。
  • 预约公平:严格按先来后到。

核心类

类职责
Book目录信息:ISBN、书名、作者
BookCopy实体副本:条码、状态(在架、借出、预约架、遗失),在预约架上时留给谁、留到哪天
Member读者:当前借阅、未缴罚款
Loan一次借阅:哪本副本、谁借的、应还日期、实际归还日期
Hold一次预约:哪种书、谁预约的、什么时候预约的
LibraryService借书、还书、预约的业务流程
FinePolicy罚款规则(策略)
图书馆类图:书目、副本、借阅与预约
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
classDiagram
  accTitle: 图书馆类图:书目、副本、借阅与预约
  class Book {
    -String isbn
    -String title
    -List authors
  }
  class BookCopy {
    -String barcode
    -CopyStatus status
    -Member heldFor
    -LocalDate holdUntil
  }
  class Member {
    -String id
    -long unpaidFines
    +activeLoans() int
  }
  class Loan {
    -LocalDate dueDate
    -LocalDate returnedOn
  }
  class Hold {
    -LocalDateTime placedAt
  }
  class LibraryService {
    +checkout(Member m, BookCopy c) Loan
    +returnCopy(BookCopy c)
    +placeHold(Member m, Book b)
  }
  class FinePolicy {
    <<interface>>
    +fine(LocalDate due, LocalDate returned) long
  }
  Book "1" *-- "many" BookCopy
  Loan --> BookCopy
  Loan --> Member
  Hold --> Book
  Hold --> Member
  LibraryService ..> FinePolicy
  LibraryService ..> Notifier : hold ready

建模的关键:Book 和 BookCopy 分开

这一步做对了,后面的问题都好回答:

  • 书名、作者属于 Book,所有副本共享;状态、条码、应还日期属于某一本实体副本。
  • 借阅挂在副本上:读者借走的是“条码 0042 的那一本”。
  • 预约挂在书上:读者要的是“任意一本《算法导论》”,哪本先还回来就给他哪本。如果预约挂在副本上,一本迟迟不还,读者就会一直等,而另一本早就回来了。

Loan 是独立的类,而不是 BookCopy 上的几个字段:一本书会被借很多次,借阅历史、逾期统计都需要它。

用到的设计模式

  • 策略模式:FinePolicy 把罚款规则抽出来,学生和教师、普通书和期刊可以用不同的规则。
  • 观察者模式:预约的书到了,通过 Notifier 发邮件或短信;业务流程不关心具体怎么通知。
  • 服务层:LibraryService 负责编排流程,实体类只管自己的数据和不变量。

核心代码

class LibraryService {
    private final Map<String, Deque<Hold>> holds = new HashMap<>();   // isbn -> FIFO queue
    private final Map<String, Loan> loans = new HashMap<>();          // barcode -> open loan
    private final FinePolicy fines;
    private final Notifier notifier;
    private final Clock clock;

    LibraryService(FinePolicy fines, Notifier notifier, Clock clock) {
        this.fines = fines; this.notifier = notifier; this.clock = clock;
    }

    synchronized Loan checkout(Member m, BookCopy copy) {
        if (m.activeLoans() >= 5 || m.unpaidFines() > 1_000)
            throw new IllegalStateException("Member may not borrow now");
        boolean heldForThem = copy.status() == CopyStatus.ON_HOLD_SHELF && m.equals(copy.heldFor());
        if (copy.status() != CopyStatus.AVAILABLE && !heldForThem)
            throw new IllegalStateException("Copy not available");
        copy.lend();                                                  // status = ON_LOAN, hold cleared
        Loan loan = new Loan(copy, m, today().plusDays(14));
        loans.put(copy.barcode(), loan);
        m.addLoan(loan);
        return loan;
    }

    synchronized void returnCopy(BookCopy copy) {
        Loan loan = loans.remove(copy.barcode());
        if (loan == null) throw new IllegalArgumentException("Copy is not on loan");
        loan.close(today());
        long fine = fines.fine(loan.dueDate(), today());
        if (fine > 0) loan.member().charge(fine);
        shelve(copy);
    }

    /** A free copy goes to the first hold in line, else back to the shelf. Also run for expired holds. */
    synchronized void shelve(BookCopy copy) {
        Hold next = holds.getOrDefault(copy.book().isbn(), new ArrayDeque<>()).poll();
        if (next == null) {
            copy.makeAvailable();
            return;
        }
        copy.holdFor(next.member(), today().plusDays(3));             // status = ON_HOLD_SHELF
        notifier.holdReady(next.member(), copy);
    }

    private LocalDate today() { return LocalDate.now(clock); }
}

class DailyFine implements FinePolicy {
    public long fine(LocalDate due, LocalDate returned) {
        long late = Math.max(0, ChronoUnit.DAYS.between(due, returned));
        return Math.min(late * 50, 2_000);                            // 0.50 a day, at most 20.00
    }
}

还书时有一个容易漏掉的分支:有人在排队,这本书就不能回到书架,而是进预约架,并且只留给队首那个人。checkout 里的 heldForThem 判断保证别人借不走它。

预约过期的处理复用 shelve:每天跑一次定时任务,找出预约架上过了期限的副本,对每一本调用 shelve,它会自动轮到下一位,没人排队就回到书架。

并发

上面用 synchronized 把整个服务锁住,一个图书馆的借还频率很低,这完全够用。如果数据存在数据库里,就把“检查状态再修改”写成一条条件更新:

UPDATE book_copies SET status = 'ON_LOAN'
WHERE barcode = ? AND status = 'AVAILABLE';

受影响行数为 1 才算借书成功,为 0 说明别人抢先一步。这和停车场那一章(LLD 案例 1)里“取出即占用”是同一个思路。

扩展讨论

  • 续借:没人预约时可以续借一次,应还日期顺延 14 天;有人预约就不能续借。
  • 检索:按书名或作者模糊搜索,数据量小用数据库的全文索引,大了再上搜索引擎。
  • 多个分馆:BookCopy 加上所在分馆;预约可以指定取书地点,副本在馆际之间调拨。
  • 电子书:许可证数量就是“副本数”,借期到了自动归还,不会产生罚款。

章节测验

3 道选择题
  1. 为什么预约挂在 Book 上,而借阅挂在 BookCopy 上?
  2. 一本书被归还时,它的预约队列里还有两个人。这本副本应该进入什么状态?
  3. 借还数据存在数据库里,两位馆员同时给两个读者借最后一本在架副本。怎么保证不会借出两次?

章节讨论

正在加载讨论…