news 2026/9/9 20:30:27

Java集合实战:从零构建图书管理系统的完整复盘

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java集合实战:从零构建图书管理系统的完整复盘

开头先说明一下:这篇内容是我“day 11 练习”的完整复盘。练习内容是用 Java 集合写一个控制台版图书管理系统,重点围绕 ArrayList、HashMap、面向对象分层设计和输入校验展开。适合处于 Java 基础向面向对象过渡阶段的学习者参考,也适合想看看“第11天到底能练出什么水平”的朋友对照自己的进度。我会把需求设计、关键代码、踩坑记录和调试过程全部写出来,尽量还原当天做题时的真实状态。


1. 练习的整体设计与目标拆解

1.1 为什么第11天选这个题目

学 Java 到第十一天,刚好处于一个“什么都懂一点,但串不起来”的阶段。前些天学了变量、流程控制、数组、类和对象、继承、接口、集合框架的基本用法,但没有一个题目能把它们全部串联到一起。如果继续刷语法题,收效会很低;如果直接上 Spring Boot,跨度又太大,容易挫败。

我这次选“图书管理系统”作为第11天的练习,原因只有一个:它把“集合操作 + 对象设计 + 用户交互”三件事揉在了一起,且需求边界足够小,一天内能做完核心功能,又留有足够的扩展空间。做完之后你会发现,后面学文件读写、数据库、Web 分层时,很多概念都能在这个小项目里找到对应物。

题目的设定也很简单:做一个命令行程序,管理员可以录入图书、查询图书、修改信息、删除图书,还能登记借书和还书。信息仅保存在内存中,程序退出后数据清空。刻意不引入数据库和文件持久化,是为了把练习焦点集中在集合和对象设计上。

1.2 需求设计与功能清单

动手写代码前,我花了大约二十分钟把需求整理成功能清单。这个习惯非常重要,没有清单的编码就像没有菜谱做饭,很容易写着写着就偏了。

我最终确定的功能列表如下:

序号功能模块具体说明
1图书录入输入书名、作者、ISBN、价格、库存数量
2图书查询支持按书名模糊查询和按 ISBN 精确查询
3图书修改根据 ISBN 定位图书,修改价格与库存
4图书删除根据 ISBN 删除图书,同时校验库存状态
5借书登记输入图书 ISBN 和借阅人姓名,库存减一
6还书登记输入借阅记录编号,库存加一
7借阅记录列表查看所有借出未还的记录

这些需求看起来不难,但组合在一起后,你会发现“删除图书”和“借书登记”之间会互相牵制。比如一本正在被借出的书,是否允许删除?如果允许,那借阅记录如何显示?如果不允许,删除逻辑就要校验借阅状态。这种边界问题的取舍,正是练习的价值所在。我最终选择“有未还记录时不允许删除”,理由很简单:为了保证数据一致性,避免出现“书没了但借阅记录还在”的情况。

1.3 为什么用集合而不是数组

这个练习中一个非常关键的决策是:数据容器到底用数组还是集合。从功能上看,数组也能实现增删改查,但需要手动维护容量、移动元素,代码量会成倍增加,而且极易出现下标越界。

选择集合的原因非常明确:ArrayList 和 HashMap 封装了最常用的数据操作,让我们可以把精力放在业务逻辑上,而不是数据结构细节上。例如删除元素,list.remove(book)一行代码搞定,换成数组你得写一个循环,再手动把后面的元素往前挪一个位置。练习阶段用集合,就是要熟悉这些现成工具,知道每个方法的时间复杂度,等到需要自己实现数据结构时,才能理解底层到底发生了什么。

图书存储我选了ArrayList<Book>,借阅记录选了ArrayList<BorrowRecord>。有人会用 HashMap 以 ISBN 为 key 存书,我第11天做的时候也在两个方案之间犹豫过,后来选择了“以 List 为主、临时用 Map 加速查询”的折中方式。具体原因在后面的数据存储设计里详细说。


2. 核心细节解析与实操要点

2.1 实体类设计:Book 与 BorrowRecord

写集合项目的第一步不是写 DAO 也不是写菜单,而是把“书”和“借阅记录”抽象成类。实体类设计直接决定了后面所有代码的写法,这一层如果偷懒,后续会不断返工。

Book 类我定义了五个字段:bookId(自增主键)、isbn(唯一业务编号)、titleauthorpricestock。注意这里有一个很容易被忽略的设计问题:既然 ISBN 本身具有唯一性,为什么还要额外加一个自增bookId?我的考虑是:ISBN 虽然唯一,但它是业务编号,现实中可能出现录入错误需要修改的情况。如果拿 ISBN 当主键,一旦修改主键,所有关联的借阅记录都要跟着改。而自增bookId是内部编号,永远不变,外键关系都指向它,这样即使 ISBN 改了,数据关联也不会断裂。这个设计是练习中非常宝贵的收获。

在实现时,我做了一个小技巧:用静态变量维护自增序号

public class Book { private static int counter = 0; private int bookId; private String isbn; private String title; private String author; private double price; private int stock; public Book(String isbn, String title, String author, double price, int stock) { this.bookId = ++counter; this.isbn = isbn; this.title = title; this.author = author; this.price = price; this.stock = stock; } // getter / setter 省略 }

++counter的写法可以保证每创建一本新书,其 bookId 自动加一,且不会重复。这里有一个很微妙的小坑:静态变量的生命周期是类的生命周期,不是对象的生命周期。如果你只是创建了两本不同的书对象,counter 会正常递增;但如果你在同一个 JVM 里运行多次 main 方法,counter 每次都会从 0 重新开始。这在当前练习中是可以接受的,因为数据本来就不持久化。

BorrowRecord 类的字段包括:recordIdbookIdbookTitle(冗余存储书名)、borrowerborrowTimereturnTime。在借阅记录里冗余存储bookTitle的做法,是我后来重构时才加上的。最初我只有 bookId,查询记录时需要拿着 bookId 回到图书列表里找书名。这本身没问题,但一旦图书被删除,历史记录就查不到书名了。冗余存储虽然打破了数据库设计中的“范式”,但在业务上换来了便利。练习中体会一下这种“冗余换体验”的取舍,到了真实项目中会很常见。

2.2 数据存储选型:List 还是 Map

这个练习中值得记录的一个决策过程:图书集合到底用List还是用Map来存。

如果按照教科书的建议,图书以 ISBN 作为唯一标识,最直观的方案是Map<String, Book>,key 是 ISBN,value 是 Book 对象。查询时map.get(isbn)的时间复杂度是 O(1),非常快。但问题是,模糊查询时就麻烦了:你拿一个不完整的书名,想把所有包含该关键字的书都找出来,Map 做不到直接从 key 出发,需要遍历全部 value 再逐个过滤。

我最终选择List<Book>作为主存储,原因是功能列表里“按书名模糊查询”的使用频率远高于“按 ISBN 精确查询”。List 从头到尾遍历一遍,时间复杂度 O(n),在几百本图书的规模下完全不是问题。借阅记录同理,我使用List<BorrowRecord>,因为需要按列表展示所有未还记录,List 天然有序,方便按时间排序。

需要说明的是,这不是一个非此即彼的判断题,而是典型的“根据业务选择数据结构”的案例。第11天的练习,核心目标不是追求极致的性能,而是理解不同集合的特性和取舍。如果你做的时候想练 Map,也可以换成Map<String, Book>然后自己实现模糊查询,那也能学到东西。关键是你要知道为什么选它,而不是盲目跟从某一种写法。

2.3 分层思想:DAO、Service、Menu 的初步划分

在只写一个类就能完成所有功能的前提下,我为什么会主动拆分成三个包?这是第11天练习中我认为最有价值的进阶思考。

我的项目结构如下:

src/ ├── Main.java ├── model/ │ ├── Book.java │ └── BorrowRecord.java ├── dao/ │ ├── BookDao.java │ └── BorrowRecordDao.java └── service/ ├── BookService.java ├── BorrowService.java └── MenuService.java

model包放实体类;dao包负责与数据集合直接打交道,增删改查都在这里;service包负责业务逻辑,比如借书时检查库存、还书时补库存;MenuService负责打印菜单、接收用户输入并调用 service。

分层最大的优点体现在“借书”这个场景中。借书业务包括两步操作:从 BookDao 中找到对应图书并扣减库存;向 BorrowRecordDao 中新增一条记录。如果没有分层,你会在菜单的 switch-case 里直接写这两段逻辑,代码会越长越难维护。分层之后,菜单调borrowService.borrow(isbn, borrower),内部方法里统一处理库存判断和记录新增,逻辑集中且清晰。第11天做这个练习时,不必追求过度设计,但“实体类 / 数据访问 / 业务逻辑 / 交互界面”这四层的大方向,值得通过这个小项目建立起来。


3. 实操过程与核心环节实现

3.1 环境准备与项目初始化

我使用的工具版本如下,供参考:

  • JDK 17
  • 纯文本编辑器 + 命令行编译运行,没有使用 IDE 自动补全(这一步为了保证自己手写代码的熟练度)
  • Ubuntu 终端环境

初始化时我犯了一个小错误:刚开始直接在默认包里创建了 Main.java,后来发现类多了之后非常乱,于是重新建立了modeldaoservice三个包,并把文件移动到对应目录。这件事提醒我,项目开工前五分钟先规划包结构,比写代码重要得多。

命令行编译时我使用了:

javac -encoding UTF-8 -d out src/Main.java src/model/*.java src/dao/*.java src/service/*.java java -cp out Main

使用-encoding UTF-8非常关键,尤其是源码中包含中文,如果不指定编码,在 Linux 环境下容易出现乱码问题。

3.2 图书 CRUD 核心代码:从写死数据到动态管理

图书管理模块的BookDao,我写了五个方法:addBookfindByIdfindByIsbnsearchByTitleupdateBookdeleteById

这里以“修改图书”为例,展示具体的实现逻辑。

public boolean updateBook(String isbn, double newPrice, int newStock) { for (int i = 0; i < bookList.size(); i++) { Book book = bookList.get(i); if (book.getIsbn().equals(isbn)) { book.setPrice(newPrice); book.setStock(newStock); return true; } } return false; }

这个写法中有几个细节值得注意:使用bookList.get(i)拿到对象后,直接修改对象属性,而不需要把对象放回集合。因为 Java 中对象引用是地址,集合中保存的是对象地址,通过book引用修改属性,会直接反映到集合里的对象上。很多新手会在这里多写一句bookList.set(i, book),其实是多余的,但不影响正确性。明白“集合保存引用还是保存副本”这个问题,是理解 Java 对象机制的重要一步。

模糊查询的实现则用到了contains方法和一个临时列表:

public List<Book> searchByTitle(String keyword) { List<Book> result = new ArrayList<>(); for (Book book : bookList) { if (book.getTitle().contains(keyword)) { result.add(book); } } return result; }

注意这里我新建了一个result列表,而不是直接修改原列表。原因很简单:如果直接在遍历原列表的同时往原列表里加元素或删元素,会触发ConcurrentModificationException用一个新列表收集结果,既避免了这个异常,也保护了原始数据不被污染。

删除图书时的库存校验放在了 Service 层:

public boolean deleteBookById(int bookId) { if (borrowDao.hasActiveRecordByBookId(bookId)) { return false; } return bookDao.deleteById(bookId); }

第11天练习时,我一开始把这段校验写在了 DAO 层,但后来发现 DAO 层不应该知道 BorrowRecord 的存在。DAO 的职责是“管好我这一个集合”,而“一本被借出的书能不能删”属于业务规则,应该由 Service 来决定。这种职责边界的调整,是我当天收获最大的重构。

3.3 借书与还书的完整逻辑链路

借书是整个系统中最有业务含量的操作,因为它涉及多个对象的协作。

我最初的实现是直接在 MenuService 里写的,代码逻辑也不错,但读到第三遍时发现全是面条代码。重构后抽成了BorrowService

public boolean borrow(int bookId, String borrower) { Book book = bookDao.findById(bookId); if (book == null) { System.out.println("图书不存在,操作失败"); return false; } if (book.getStock() <= 0) { System.out.println("图书库存不足,借书失败"); return false; } book.setStock(book.getStock() - 1); BorrowRecord record = new BorrowRecord(book.getBookId(), book.getTitle(), borrower); borrowDao.add(record); System.out.println("借书成功,记录编号:" + record.getRecordId()); return true; }

这段代码有几个值得学习的点:

第一,先校验后操作。整个方法按照“对象是否存在 → 库存是否足够 → 扣库存 → 加记录”的顺序执行,任何一步失败都会提前 return,不会留下半个操作的结果。这个模式在写所有“事务型”业务时都适用。

第二,每次修改都要考虑数据一致性。扣库存和加记录这两步必须放在同一个方法里,如果分开写,万一中途抛出异常,就会出现库存扣了但没有借阅记录的脏数据。虽然当前练习没有使用数据库事务,但代码层面的“靠前校验 + 顺序执行”已经能规避大部分问题。

还书逻辑正好相反:

public boolean returnBook(int recordId) { BorrowRecord record = borrowDao.findById(recordId); if (record == null) { System.out.println("借阅记录不存在"); return false; } if (record.getReturnTime() != null) { System.out.println("该记录已归还,请勿重复操作"); return false; } record.setReturnTime(LocalDateTime.now()); Book book = bookDao.findById(record.getBookId()); if (book != null) { book.setStock(book.getStock() + 1); } return true; }

这里我用LocalDateTime而不是Date,是因为新版 JDK 里的时间 API 更清晰直观。注意“已归还的图书再次归还”这种异常操作,实践中最容易忽略。第11天练习时我专门用几组测试用例去试了重复还书、还一本不存在的书、借库存为零的书,这些都是边界条件。

3.4 控制台交互与输入校验的坑

控制台程序看起来简单,但“用户输入”这块能让你栽不少跟头。最典型的问题就是nextIntnextLine混合使用。

假设你这样做:

System.out.print("请输入ISBN:"); String isbn = scanner.nextLine(); System.out.print("请输入价格:"); double price = scanner.nextDouble();

第一次输入时,用户输入 ISBN 后按下回车,nextLine会正确读取内容。但第二次如果继续用nextInt来读一个整数,之后再用nextLine读字符串,就会发现nextLine读到了一个空字符串。原因是nextInt只读取数字,不会读取数字后面的换行符,这个换行符留在了缓冲区中,紧接着的nextLine会把换行符当成输入内容直接返回。

解决这个问题的方法有两种,我当天采用的是最稳妥的“全读字符串再转换”:

System.out.print("请输入价格:"); String priceStr = scanner.nextLine(); double price = Double.parseDouble(priceStr);

先统一用nextLine读入字符串,然后用Double.parseDoubleInteger.parseInt转换。如果转换失败,程序会抛出NumberFormatException,此时可以用 try-catch 捕获并提示用户重新输入。这种方法避免了缓冲区残留问题,也方便做格式校验。

我还做了一个.isBlank()的非空校验:

if (isbn.isBlank() || title.isBlank()) { System.out.println("输入内容不能为空,请重新输入"); continue; }

这些细节如果不做,程序也能运行,但你会觉得“哪里怪怪的”。做了之后,整个体验就会从一个“能跑通的脚本”变成一个“像样的程序”。第11天练习,就是要把这些细节一个一个抠出来。


4. 常见问题与排查技巧实录

4.1 集合遍历时 ConcurrentModificationException 的解决

练习中我踩到的第一个 RuntimeException 就是ConcurrentModificationException,当时代码如下:

for (BorrowRecord record : borrowList) { if (record.getReturnTime() == null) { borrowList.remove(record); } }

运行后直接抛异常。原因在 Java 集合的“快速失败”机制中:使用迭代器遍历时,迭代器内部记录了集合被修改的次数,当你通过集合的remove方法修改集合时,迭代器检测到修改次数不一致,立刻抛出异常,防止后续行为不可预测。

解决方案有三种,我按推荐顺序列出:

方案思路适用场景
方案一使用Iteratorremove()遍历时需要删除时最推荐
方案二反向遍历 for 循环从尾部往前删,下标不偏移
方案三先把要删的对象收集到新列表,最后统一删除删除条件复杂时更清晰

我第一次选择了方案三,代码可读性最高。后来测试性能时,在 100 万条数据下方案一更快一些,但第11天练习阶段不需要考虑这种优化,搞清楚原因最重要。

4.2 字符串比较 == 还是 equals

这个练习我故意踩了一次==的坑,为了加深印象。在校验用户输入的命令时,我写了:

if (command == "1") { ... }

结果输入 1 时,程序毫无反应。原因很简单:==比较的是两个对象的引用地址,而字符串内容相同不代表地址相同。只有使用equals才能比较内容。

Java 中字符串有一个字符串常量池的机制,直接写"1"时,编译器会把它放入常量池。但用户的输入是运行时创建的新字符串对象,它的地址和常量池中的对象地址不同,所以==永远为 false。这就是推荐比较字符串一律用equals的原因。

4.3 用户输入了非法数字导致程序崩溃

功能测试到一半,我在输入价格时打了一个字母“abc”,程序立刻抛出InputMismatchException并退出。这说明“让用户直接输入数字”的写法在真实场景中非常脆弱,一旦用户不配合,程序就崩溃。

第11天练习结束后,我把所有菜单选项的统一入口改成了字符串接收、try-catch 解析,并加了一个循环重试机制:

public int readInt(String prompt) { while (true) { try { System.out.print(prompt); String input = scanner.nextLine(); return Integer.parseInt(input.trim()); } catch (NumberFormatException e) { System.out.println("请输入有效的数字"); } } }

这个工具方法成了后面所有控制台交互程序的基础设施,建议你也封装一个放在工具类里。

4.4 “库存已经减到负数”的逻辑漏洞

在第一次完成借书功能时,我的库存校验是放在菜单里的,结果两次调用借书方法时,第一次借出了最后一本,第二次依然能借出。查来查去发现,判断库存的代码块在if中写的是stock > 0,但借阅时写的是stock > 1,边界条件不统一,导致 bug 藏得很深。

这类问题最好的排查方式是针对同一个函数设计几个典型测试用例,而不是一直用交互式输入测试。我这里用一个简单的方法验证业务逻辑:

public static void testBorrow() { BookDao bookDao = new BookDao(); bookDao.addBook(new Book("001", "Java编程思想", "Bruce Eckel", 89.0, 1)); BorrowService service = new BorrowService(bookDao, new BorrowRecordDao()); System.out.println("第一次借书:" + service.borrow(1, "张三")); System.out.println("第二次借书:" + service.borrow(1, "李四")); }

第一次应该返回 true,第二次应该返回 false。如果两次都返回 true,说明库存减一逻辑没生效,要去查setStock是否真正修改了对象。这种“写一个最小测试类验证核心方法”的习惯,是第11天练习中从小白到入门的分水岭。


5. 本次练习的收获与后续可扩展方向

5.1 从“写完代码”到“想清楚为什么这么写”

第11天练习最大的收获不是多会了几个 API,而是开始有意识地在写代码之前问自己:这个数据用什么容器存?这个校验放在哪一层?这个对象要不要拆一个类?

我举一个例子。最开始图书删除功能的实现,我直接在菜单的 case 分支里写了一大段:从 bookList 里找到 ISBN 对应的对象、从借阅列表里遍历检查记录、删除并提示成功。这段代码虽然能跑,但只有 30 行时已经显得臃肿。重构后,我把它拆成了BookDao.deleteByIdBorrowService.checkActiveRecord两个方法,菜单分支只保留三行调用。代码量没有明显减少,但每一行都变得有目的、有归属,修改起来信心更足。

这种“职责划分”的意识在后续学习 Spring 的分层架构、MyBatis 的 Mapper 接口时非常有用。你要记住:第11天的练习项目虽然简单,但它是一座桥,把之前学过的语法点连接成一条能走通的路。

5.2 下一步可以怎么继续扩展

这个练习做完后,我给它列出了三个后续扩展方向,作为后面几天的学习计划:

  • 引入文件存储:把 Book 和 BorrowRecord 写成 CSV 文件,程序启动时读取,退出时保存。这会涉及FileReaderBufferedReaderFileWriter的练习,同时你会体会到“序列化”的意义。
  • 增加日期时间处理:借书时记录时间,还书时计算借阅天数,逾期则计算罚款。这会用到LocalDateChronoUnit
  • 把控制台交互改成简易 Web 接口:用最原生的 Java 网络编程写一个 HTTP 服务器,把 CRUD 暴露成 RESTful API。这一步跨度较大,但能帮助你理解前后端分离中后端的作用。

5.3 给后来者的一些实操建议

如果你也正处在第十一天左右的学习阶段,我在这次练习后总结了几条可以直接照用的建议:

第一,先花二十分钟画一张功能清单,再动手写代码。没有清单的练习,大概率写着写着就跑偏,最后变成“加了一个功能又发现另一个 bug”的无限循环。

第二,不要复制网上的完整代码。哪怕你的实现更笨拙、代码更长,但只要是亲手敲出来的,收获都比复制大十倍。遇到不会的 API,先猜一下方法名,再看文档或源码,再不行才搜索具体用法。

第三,第11天练习一定要打开 DEBUG 模式,或者使用断点调试。你在 IDE 里加上断点,运行到“借书”逻辑时停下来,一帧一帧地看 bookDao 里的集合在每一步发生了什么变化。相信我,这个过程对理解“引用传递”“对象状态修改”非常有帮助,图像化地刻在脑子里之后,很多困惑自动就解开了。

第四,保留这个练习项目,不要删。后面学到 IO 流时,你可以在这个基础上直接把内存存储换成文件存储;学到 JDBC 时,可以再换成数据库存储。不同阶段回头改同一个项目,最能直观看到自己的成长曲线。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 20:28:27

docker compose unpause 使用指南:恢复暂停的 Compose 服务容器

docker compose unpause 使用指南&#xff1a;恢复暂停的 Compose 服务容器 【免费下载链接】compose Define and run multi-container applications with Docker 项目地址: https://gitcode.com/GitHub_Trending/compose/compose docker compose unpause 是 Docker Com…

作者头像 李华
网站建设 2026/9/9 20:26:30

彻底搞懂反向传播与自动求导:从矩阵微积分到PyTorch Autograd

先问大家一个可能被问过无数次的问题&#xff1a;训练神经网络的时候&#xff0c;那一行 loss.backward() 到底是在做什么&#xff1f;很多人的第一反应是“反向传播&#xff0c;算梯度”。但如果继续问“梯度具体是怎么沿着网络一层一层传回去的&#xff1f;”“为什么框架能…

作者头像 李华
网站建设 2026/9/9 20:24:25

0基础快速做出高质量PPT:工具推荐与实操指南

做PPT这件事&#xff0c;我真是被逼出来的。以前在学校当助教&#xff0c;每周都要帮导师做课件&#xff0c;后来自己做了教育博主&#xff0c;又得频繁出内容&#xff0c;加上一年里总要帮学生改几版答辩PPT&#xff0c;前前后后经手了不下几百套。见得多了就发现一个规律&…

作者头像 李华
网站建设 2026/9/9 20:22:48

数据清洗与异常值处理实战:一次销售数据分析上机的完整复盘

1月29日上机&#xff1a;一节让我彻底梳理实验逻辑的实操课 如果只用一个词形容1月29日这天上机&#xff0c;我会选“扎实”。不是那种按部就班把流程走一遍的踏实&#xff0c;而是整个过程里不断出现“咦&#xff0c;怎么跟预期不一样”然后逼着自己去查、去试、去改的充实感。…

作者头像 李华
网站建设 2026/9/9 20:22:23

用Python手写一个最小区块链:从哈希到工作量证明实战

1. 为什么用Python写区块链&#xff1a;先搞懂几个核心概念1.1 区块链到底是个什么东西以前跟朋友聊起区块链&#xff0c;大部分人的第一反应就是比特币、炒币、挖矿&#xff0c;后来变成NFT、Web3&#xff0c;好像这个东西离普通开发者特别远。其实剥掉那些金融外壳&#xff0…

作者头像 李华