资讯动态

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

发布时间:2026/9/9 20:30:28 来源:尧图企业网站定制
开头先说明一下这篇内容是我“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)一行代码搞定换成数组你得写一个循环再手动把后面的元素往前挪一个位置。练习阶段用集合就是要熟悉这些现成工具知道每个方法的时间复杂度等到需要自己实现数据结构时才能理解底层到底发生了什么。图书存储我选了ArrayListBook借阅记录选了ArrayListBorrowRecord。有人会用 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 作为唯一标识最直观的方案是MapString, Bookkey 是 ISBNvalue 是 Book 对象。查询时map.get(isbn)的时间复杂度是 O(1)非常快。但问题是模糊查询时就麻烦了你拿一个不完整的书名想把所有包含该关键字的书都找出来Map 做不到直接从 key 出发需要遍历全部 value 再逐个过滤。我最终选择ListBook作为主存储原因是功能列表里“按书名模糊查询”的使用频率远高于“按 ISBN 精确查询”。List 从头到尾遍历一遍时间复杂度 O(n)在几百本图书的规模下完全不是问题。借阅记录同理我使用ListBorrowRecord因为需要按列表展示所有未还记录List 天然有序方便按时间排序。需要说明的是这不是一个非此即彼的判断题而是典型的“根据业务选择数据结构”的案例。第11天的练习核心目标不是追求极致的性能而是理解不同集合的特性和取舍。如果你做的时候想练 Map也可以换成MapString, 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 ListBook searchByTitle(String keyword) { ListBook 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 里写的代码逻辑也不错但读到第三遍时发现全是面条代码。重构后抽成了BorrowServicepublic 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 时可以再换成数据库存储。不同阶段回头改同一个项目最能直观看到自己的成长曲线。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价