开头先说明一下:这篇内容是我“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(唯一业务编号)、title、author、price、stock。注意这里有一个很容易被忽略的设计问题:既然 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 类的字段包括:recordId、bookId、bookTitle(冗余存储书名)、borrower、borrowTime、returnTime。在借阅记录里冗余存储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.javamodel包放实体类;dao包负责与数据集合直接打交道,增删改查都在这里;service包负责业务逻辑,比如借书时检查库存、还书时补库存;MenuService负责打印菜单、接收用户输入并调用 service。
分层最大的优点体现在“借书”这个场景中。借书业务包括两步操作:从 BookDao 中找到对应图书并扣减库存;向 BorrowRecordDao 中新增一条记录。如果没有分层,你会在菜单的 switch-case 里直接写这两段逻辑,代码会越长越难维护。分层之后,菜单调borrowService.borrow(isbn, borrower),内部方法里统一处理库存判断和记录新增,逻辑集中且清晰。第11天做这个练习时,不必追求过度设计,但“实体类 / 数据访问 / 业务逻辑 / 交互界面”这四层的大方向,值得通过这个小项目建立起来。
3. 实操过程与核心环节实现
3.1 环境准备与项目初始化
我使用的工具版本如下,供参考:
- JDK 17
- 纯文本编辑器 + 命令行编译运行,没有使用 IDE 自动补全(这一步为了保证自己手写代码的熟练度)
- Ubuntu 终端环境
初始化时我犯了一个小错误:刚开始直接在默认包里创建了 Main.java,后来发现类多了之后非常乱,于是重新建立了model、dao、service三个包,并把文件移动到对应目录。这件事提醒我,项目开工前五分钟先规划包结构,比写代码重要得多。
命令行编译时我使用了:
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,我写了五个方法:addBook、findById、findByIsbn、searchByTitle、updateBook、deleteById。
这里以“修改图书”为例,展示具体的实现逻辑。
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 控制台交互与输入校验的坑
控制台程序看起来简单,但“用户输入”这块能让你栽不少跟头。最典型的问题就是nextInt与nextLine混合使用。
假设你这样做:
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.parseDouble或Integer.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方法修改集合时,迭代器检测到修改次数不一致,立刻抛出异常,防止后续行为不可预测。
解决方案有三种,我按推荐顺序列出:
| 方案 | 思路 | 适用场景 |
|---|---|---|
| 方案一 | 使用Iterator的remove() | 遍历时需要删除时最推荐 |
| 方案二 | 反向遍历 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.deleteById和BorrowService.checkActiveRecord两个方法,菜单分支只保留三行调用。代码量没有明显减少,但每一行都变得有目的、有归属,修改起来信心更足。
这种“职责划分”的意识在后续学习 Spring 的分层架构、MyBatis 的 Mapper 接口时非常有用。你要记住:第11天的练习项目虽然简单,但它是一座桥,把之前学过的语法点连接成一条能走通的路。
5.2 下一步可以怎么继续扩展
这个练习做完后,我给它列出了三个后续扩展方向,作为后面几天的学习计划:
- 引入文件存储:把 Book 和 BorrowRecord 写成 CSV 文件,程序启动时读取,退出时保存。这会涉及
FileReader、BufferedReader、FileWriter的练习,同时你会体会到“序列化”的意义。 - 增加日期时间处理:借书时记录时间,还书时计算借阅天数,逾期则计算罚款。这会用到
LocalDate和ChronoUnit。 - 把控制台交互改成简易 Web 接口:用最原生的 Java 网络编程写一个 HTTP 服务器,把 CRUD 暴露成 RESTful API。这一步跨度较大,但能帮助你理解前后端分离中后端的作用。
5.3 给后来者的一些实操建议
如果你也正处在第十一天左右的学习阶段,我在这次练习后总结了几条可以直接照用的建议:
第一,先花二十分钟画一张功能清单,再动手写代码。没有清单的练习,大概率写着写着就跑偏,最后变成“加了一个功能又发现另一个 bug”的无限循环。
第二,不要复制网上的完整代码。哪怕你的实现更笨拙、代码更长,但只要是亲手敲出来的,收获都比复制大十倍。遇到不会的 API,先猜一下方法名,再看文档或源码,再不行才搜索具体用法。
第三,第11天练习一定要打开 DEBUG 模式,或者使用断点调试。你在 IDE 里加上断点,运行到“借书”逻辑时停下来,一帧一帧地看 bookDao 里的集合在每一步发生了什么变化。相信我,这个过程对理解“引用传递”“对象状态修改”非常有帮助,图像化地刻在脑子里之后,很多困惑自动就解开了。
第四,保留这个练习项目,不要删。后面学到 IO 流时,你可以在这个基础上直接把内存存储换成文件存储;学到 JDBC 时,可以再换成数据库存储。不同阶段回头改同一个项目,最能直观看到自己的成长曲线。