ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java图书管理系统实战:从分层设计到事务与状态机

Java图书管理系统实战:从分层设计到事务与状态机

简介:一套围绕图书管理场景的Java实践项目,面向初学Java或正在做课程设计的开发者。代码覆盖图书信息增删改、用户注册登录、借阅归还、多条件查询和借阅统计等模块;实现中会看到Swing/JavaFX图形界面、ArrayList/HashMap集合框架、JDBC与SQLite/MySQL持久化的配合,并按MVC思路分层,便于理解界面、业务与数据访问如何协作。压缩包共38个文件,约47KB,包含14个java源码、16个class编译文件,以及txt数据文件、工程配置和PNG界面预览图,结构简洁,可导入IDE运行或逐步调试,通过类名和包结构也能快速定位各功能模块。已有1525人学习下载。通过分析源码和目录组织,能直观掌握面向对象设计、GUI事件处理、数据库增删改查等关键实践,还可延伸学习管理员权限、借阅统计等管理功能的设计思路,是一份很实用的课设参考和练手项目。

1. Java写的图书管理系统:不只是课程设计,更是练分层和事务的试验场

很多人看到“Java写的图书管理系统”会直接归类为毕业设计或期末作业,觉得无非是几个按钮加一张表。但真正动手的人会发现,这个系统的复杂度卡在一个很微妙的位置:比增删改查难一点,比企业级应用简单很多,恰好能把 JDBC、事务、状态流转、界面线程这些 Java 基本功串起来。某高校的实训课里,A同学用 Swing 加 MySQL 写了第一版,功能全通,结果两个人同时借同一本书时把库存扣成了负数,才发现问题不在 SQL 语法,而在业务约束。

这篇笔记不讲花哨框架,就讲一个能真正跑起来、能扛住基础并发、能拿来当后续项目地基的实现方案:技术选型怎么定,三张表怎么建,借书还书怎么保证数据一致,以及五个新手必踩的坑。适合正在做课程设计的学生,也适合想快速搭一套内部图书管理工具、但不想上 Spring Boot 全家桶的开发者。读完后你能照着代码一步步复现,也能搞清楚每个参数为什么这么设。

2. 技术选型与系统分层:为什么这套组合最适合图书管理

2.1 界面层选 Swing 还是 JavaFX:三种方案的取舍

图书管理系统的界面层是第一个要做的决定。常见做法有三种:Swing、JavaFX、B/S 架构用网页。很多人一上来就选 Swing,理由是教程多、资料全,这个理由在 2024 年依然成立,但要分清场景。

如果系统是给图书馆或公司内部用,部署环境是 Windows 老机器,Swing 是最稳的选择。它对硬件要求极低,JDK 自带,不需要额外装运行时,打包成 JAR 双击就能跑。JavaFX 在视觉上确实现代很多,支持 CSS 样式,但需要单独引入依赖,而且在高分屏下的布局适配反而不如 Swing 的经典布局管理器可控。

某公司内部做过一次真实对比:用 JavaFX 写发现界面调试时间占了总工时三成,而 Swing 加上简单的 GridBagLayout 两天就能搞定所有表单。如果是个人项目或课程设计,优先 Swing;如果未来确定要上触摸屏或需要复杂交互动效,才考虑 JavaFX。至于网页版,技术上可行,但那就意味着要引入 Tomcat、Servlet 或 Spring Boot,复杂度会翻倍,不适合“Java写的图书管理系统”这个定位。

2.2 数据库选 MySQL 还是 SQLite:按部署场景取舍

数据库选择直接决定系统的分发方式和并发能力。社区里很多教程默认用 MySQL,因为它是独立服务,更接近生产环境,但代价是用户必须单独安装数据库并配置账号。SQLite 则是一个文件即一个库,零安装,随 JAR 一起分发,这对小型图书室或个人项目极为友好。

从并发角度说,图书管理系统的真实并发量极低。一个高校院系的图书室,同时在线操作的管理员通常不超过五个,峰值操作频率也就每秒几次。SQLite 在这种量级下完全能胜任。但要注意,SQLite 在 Windows 上需要加载对应的 JDBC 驱动包,且对写入锁的处理是整库锁,高并发写入时会报 database is locked。

我的建议是这样:做课程设计或毕业设计,选 MySQL,因为面试时被问到的概率高,简历上也更好写;做给某公司内部工具、需要拷贝给同事直接运行,选 SQLite 更省心。两种在代码层差异很小,换驱动和连接字符串即可,后面会给出具体的 JDBC 配置对比。

2.3 分层边界:UI、Service、DAO 各自管什么

不夸张地说,图书管理系统是练分层最好的项目。它足够小,能完整看到三层各自的职责,又不会像大项目那样让人迷失在包的海洋里。常见的分层是界面层(UI)、业务层(Service)、数据访问层(DAO)。

界面层只负责两件事:收集用户输入,展示结果。它不应该出现任何 SQL 语句,也不应该直接操作结果集。业务层是核心,负责承载借书、还书、逾期计算这些规则,并把规则拆成对 DAO 的多次调用。数据访问层只做最朴素的 CRUD,把所有 JDBC 细节关在门内,业务层拿到的是对象或集合,而不是 ResultSet。

这个分层的价值在排错时体现得最明显。某次模拟项目X在还书功能上报错,某开发者直接看 Service 层的代码,十分钟定位到是逾期天数计算少了 1 天;而隔壁用了“大泥球写法的”同学,所有逻辑全写在按钮事件里,找问题找了半天。分层不增加代码量,增加的是代码的可读性和可替换性。

3. 从建库到核心功能:可直接抄的三张表与借还逻辑

3.1 三张核心表的 DDL 与字段设计要点

图书管理系统再怎么花哨,核心就是读者、图书、借阅这三张表。建表语句是整个系统的地基,字段设计影响后续所有查询和统计,值得花时间逐列斟酌。下面是可直接复用的 DDL,以 MySQL 为例:

CREATE TABLE t_reader ( reader_id INT AUTO_INCREMENT PRIMARY KEY COMMENT '读者ID', reader_name VARCHAR(50) NOT NULL COMMENT '读者姓名', phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='读者表'; CREATE TABLE t_book ( book_id INT AUTO_INCREMENT PRIMARY KEY COMMENT '图书ID', book_name VARCHAR(200) NOT NULL COMMENT '书名', author VARCHAR(100) DEFAULT NULL COMMENT '作者', publisher VARCHAR(100) DEFAULT NULL COMMENT '出版社', total_copies INT NOT NULL DEFAULT 1 COMMENT '总册数', available_copies INT NOT NULL DEFAULT 1 COMMENT '当前可借册数', location VARCHAR(50) DEFAULT NULL COMMENT '馆藏位置', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '入库时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图书表'; CREATE TABLE t_borrow ( borrow_id INT AUTO_INCREMENT PRIMARY KEY COMMENT '借阅记录ID', reader_id INT NOT NULL COMMENT '读者ID', book_id INT NOT NULL COMMENT '图书ID', borrow_date DATE NOT NULL COMMENT '借出日期', due_date DATE NOT NULL COMMENT '应还日期', return_date DATE DEFAULT NULL COMMENT '实际归还日期', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0借出中 1已归还 2逾期', INDEX idx_reader (reader_id), INDEX idx_book (book_id), INDEX idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='借阅表';

几个字段设计上的要点:total_copies 和 available_copies 分开存,不要用 total_copies 减当前借出数量来算可借数,那样每次借还都得多一条查询。available_copies 是一个独立的物理字段,配合后面的原子更新语句,能天然防住并发扣减。status 字段用 TINYINT 存数字,不要用 VARCHAR,数字便于索引、比较和后续扩展。

关于建立索引,很多人会在三张小表上忽略索引,觉得数据量小无所谓。但 t_borrow 表会随着时间不断增长,按读者查借阅记录、按状态筛逾期记录会越来越频繁,idx_reader、idx_book、idx_status 这三个索引就是为这些高频查询准备的。字段注释在代码评审时是加分项,坚持写,能省掉后来者大量猜测时间。

3.2 DAO 层代码:从 JDBC 连接到预编译查询

分层之后先写 DAO,它是系统的血管。下面这段代码是图书表 DAO 的核心方法,覆盖了连接管理、预编译查询、结果集映射三个关键点:

public class BookDao { private static final String SELECT_BY_ID = "SELECT book_id, book_name, author, publisher, " + "total_copies, available_copies, location " + "FROM t_book WHERE book_id = ?"; public Book findById(int bookId) { Book book = null; // try-with-resources 能确保 Connection、PreparedStatement、ResultSet 全部自动关闭 try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(SELECT_BY_ID)) { ps.setInt(1, bookId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { book = new Book(); book.setBookId(rs.getInt("book_id")); book.setBookName(rs.getString("book_name")); book.setAuthor(rs.getString("author")); book.setPublisher(rs.getString("publisher")); book.setTotalCopies(rs.getInt("total_copies")); book.setAvailableCopies(rs.getInt("available_copies")); book.setLocation(rs.getString("location")); } } } catch (SQLException e) { throw new RuntimeException("查询图书失败", e); } return book; } }

这里有三个容易被忽视的参数细节。第一,占位符 ? 的下标从 1 开始,不是 0,写错会直接抛 SQLException,而且报错信息不会告诉你是下标问题,只会显示无效的列索引。第二,字符串拼接查询有条件时,一定用 PreparedStatement 占位符,不要用 "WHERE id = " + bookId 这种写法,一是防 SQL 注入,二是让 MySQL 能复用执行计划。第三,结果集映射时用列名而不是列下标,列名可读性更强,表结构加列时也不容易错。

DBUtil.getConnection() 的实现大家都会写,但要注意连接参数的配置。MySQL 的连接 URL 至少要带上 useSSL=false 和 characterEncoding=utf8,前者避免本地开发时 SSL 握手报错,后者是中文不乱码的前提。SQLite 则不需要这些参数,驱动类名和 URL 分别为 org.sqlite.JDBC 和 jdbc:sqlite:library.db。

3.3 借书与还书:状态机与事务边界

借书是整个系统里最容易写错的地方。最简单粗暴的错误写法是这样:先 SELECT available_copies,判断大于 0,再 UPDATE 减 1。这写法在单用户测试时永远正确,但实际上 SELECT 和 UPDATE 之间有时间差,两个人同时操作时两个请求都可能读到可借册数大于 0,然后都把库存减掉,最终出现超借。

正确的借书流程要用“条件更新”代替“先查后改”,把判断和扣减合并为一条原子语句:

@Transactional public void borrowBook(int readerId, int bookId, LocalDate borrowDate) { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 关键:条件更新,库存不足时影响行数为0 String deductSql = "UPDATE t_book SET available_copies = available_copies - 1 " + "WHERE book_id = ? AND available_copies > 0"; try (PreparedStatement ps = conn.prepareStatement(deductSql)) { ps.setInt(1, bookId); if (ps.executeUpdate() == 0) { throw new IllegalStateException("图书库存不足或图书不存在"); } } // 插入借阅记录,默认借期30天 LocalDate dueDate = borrowDate.plusDays(30); String insertSql = "INSERT INTO t_borrow (reader_id, book_id, borrow_date, due_date, status) " + "VALUES (?, ?, ?, ?, 0)"; try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, readerId); ps.setInt(2, bookId); ps.setDate(3, Date.valueOf(borrowDate)); ps.setDate(4, Date.valueOf(dueDate)); ps.executeUpdate(); } conn.commit(); } catch (Exception e) { conn.rollback(); throw new RuntimeException("借书失败", e); } finally { conn.setAutoCommit(true); conn.close(); } }

代码里的灵魂在 UPDATE 语句的 AND available_copies > 0 条件。这条语句让数据库在单行层面保证:只有可借册数大于 0 时才会执行扣减,而且扣减是原子性的。executeUpdate 返回 0 表示没有满足条件的行,直接判定为库存不足。这个模式叫乐观锁的简化版,比 SELECT FOR UPDATE 更轻量,在低并发场景下完全够用。

还书流程是逆操作,但一定要带上状态条件。归还时 UPDATE t_borrow SET return_date = ?, status = ? WHERE borrow_id = ? AND status = 0,如果影响行数为 0,说明这条记录已经归还过,直接提示重复归还。归还后要把图书的可借册数加回去,这两步同样放在一个事务里。如果不加事务,万一更新借阅记录成功、更新库存失败,系统就永久丢失一本书的库存。

3.4 在 Swing 里接上业务逻辑:一个可复现的最小界面

有了 Service 层,Swing 界面就只剩事件绑定和结果展示。下面是一个借书按钮的核心事件代码,它演示了按钮点击后如何调用业务层并处理异常:

btnBorrow.addActionListener(e -> { int bookId = Integer.parseInt(txtBookId.getText().trim()); int readerId = Integer.parseInt(txtReaderId.getText().trim()); // 不要直接在按钮线程里跑数据库操作,否则界面会卡顿 SwingWorker<Void, Void> worker = new SwingWorker<>() { @Override protected Void doInBackground() throws Exception { borrowService.borrowBook(readerId, bookId, LocalDate.now()); return null; } @Override protected void done() { try { get(); JOptionPane.showMessageDialog(frame, "借书成功"); refreshBookTable(); } catch (Exception ex) { Throwable cause = ex.getCause(); if (cause instanceof IllegalStateException) { JOptionPane.showMessageDialog(frame, cause.getMessage()); } else { JOptionPane.showMessageDialog(frame, "系统异常,请联系管理员"); } } } }; worker.execute(); });

SwingWorker 的使用值得单独强调。Swing 的界面刷新必须在事件分发线程(EDT)上执行,但数据库查询和网络请求这种耗时操作放在 EDT 上会直接卡死界面,表现为窗口拖不动、按钮点了没反应。把耗时操作放在 doInBackground 里,把结果回传放在 done 里,是 Swing 开发的必要习惯。代码里对用户输入先用 trim() 再去空格再解析,能顺手挡掉一批手误输入。

提示:如果你的界面只是给管理员自己用、无法接受多写一个 SwingWorker,也要至少在耗时操作前后切换光标为等待状态,否则用户会以为程序崩溃了,这是一个体验底线。桌面上运行的方式决定了用户没有浏览器那种“卡了等一会儿”的耐心。

4. 五个必踩的坑与排查路线

4.1 中文乱码:从连接参数到显示字体逐个排查

现象:图书管理系统里明明存的是《活着》,界面上却显示成“活瀔。数据库里手工插入中文正常,程序查出来的全是乱码。

原因:MySQL 连接 URL 没有指定 characterEncoding=utf8,驱动就用系统默认编码和数据库交互,中文字符在传输过程中变成乱码。另一个常见原因是建表时未指定 DEFAULT CHARSET=utf8mb4,表实际是 latin1,中文存进去就已经失真。

解决:第一步检查建表语句,确认表字符集是 utf8mb4;第二步在 JDBC URL 上追加参数,形如 jdbc:mysql://localhost:3306/library?useSSL=false&characterEncoding=utf8。两步都做完后重启程序。如果还乱,检查代码编译环境的文件编码,IDEA 里统一设置 Project Encoding 为 UTF-8。

4.2 同一本书被借两次:库存成负数

现象:某公司内部测试时,两个人几乎同时点借同一本书,最后 available_copies 变成了 -1,借阅表里多了两条记录。

原因:在业务层里“先查数量再判断再更新”,查询和更新之间的窗口期让两个请求都认为有库存可用。数据库没有在更新语句层面加条件约束。

解决:按上文 3.3 的条件更新写法,在 UPDATE 的 WHERE 里带 AND available_copies > 0。这个坑的修复成本很低,但如果不修,后续所有统计都会跟着错。

4.3 还书逾期计算错一天

现象:读者 2024-09-01 借书,借期 30 天,应该 10-01 到期。管理员 10-01 当天看到的状态是“正常”,10-02 才显示逾期。

原因:计算逾期时用了 LocalDate.now().isAfter(dueDate) 而不是 !now.isBefore(dueDate)。isAfter 是严格大于,到期当天不算逾期,但业务规则通常是到期日当天未还就算逾期。

解决:判断条件改为if (!today.isBefore(dueDate)),即今天不是 dueDate 之前,就标记逾期。同时要把 overdue 状态的标记逻辑放在每次查询列表时统一刷新,不能只在还书时计算,否则长期未还的书永远不会被标记。

4.4 界面卡死:慢查询直接跑在事件线程

现象:点击“查询全部借阅记录”按钮后,窗口变成白屏,鼠标转圈,十秒后才恢复,期间点击任何按钮都没反应。

原因:按钮的 ActionListener 里直接调用了 DAO 查询,结果集映射和数据组装都在 EDT 上执行。借阅表数据量到了几万条以后,全表扫描加对象封装耗时飙升。

解决:所有数据库操作放进 SwingWorker 的 doInBackground。如果不想用 SwingWorker,退一步至少加一个 SwingUtilities.invokeLater 的壳来分步加载。另外检查查询 SQL 有没有走索引,比如按状态查询时是否命中 idx_status。

4.5 数据库连接没有关闭

现象:程序跑了一天以后,数据库操作越来越慢,最后报 Too many connections,MySQL 拒绝新连接。

原因:部分 DAO 方法用了手动 Connection,但异常分支里没有调用 close()。MySQL 默认最大连接数只有 151,连接池没有回收,泄漏到一定量就彻底瘫痪。

解决:所有 DAO 方法统一改用 try-with-resources,如 3.2 里演示的那样。如果用了连接池(如 HikariCP),需要检查 maximumPoolSize 是否设置为 10 以内,并设置 connectionTimeout 和 idleTimeout,避免空闲连接长期占用数据库资源。

5. 进阶:把借阅记录的状态流转固化成可验证的模型

做到这一步,系统已经能稳定运行,但借书、还书、逾期这些状态在代码里还是散落的 if/else。给系统加一个显式的状态机,是这个阶段最值得投入的升级。它能把业务规则集中管理,让所有状态变化在一个类里可见、可测、可追溯。

public class BorrowStateMachine { public static final int BORROWED = 0; public static final int RETURNED = 1; public static final int OVERDUE = 2; public static int handleStatus(int currentStatus, LocalDate dueDate, LocalDate returnDate, boolean isNowBeforeDueDate) { if (currentStatus == RETURNED) { return RETURNED; // 已归还的状态不允许再流转 } if (currentStatus == BORROWED && returnDate != null) { return RETURNED; } if (currentStatus == OVERDUE) { return OVERDUE; // 逾期后允许归还,归还时再单独处理 } if (!isNowBeforeDueDate) { return OVERDUE; } return BORROWED; } }

这个状态机的关键在于把“状态的下一步是什么”统一收口。借出中的记录,如果当前时间已经越过 dueDate 就转逾期;归还动作在任何状态下都可以转已归还。有了这个类,Service 层就不需要到处写零散的时间判断,所有状态变化都通过 handleStatus 计算。更重要的是,它能被单元测试完整覆盖。

对应的 JUnit 测试用例至少要有三条:到期当天未还转为 2(逾期)、正常归还转 1、已归还后再调用不改变状态。写好测试后再重构 Service 层,把原来二三十行的状态判断压缩成三行调用,回归时跑一遍测试,改动风险一目了然。这就是让一个“课程设计”开始靠拢工程实践的临界点。

我自己的习惯是,每写一个大一点的功能,都先问自己一句:如果这个功能的核心规则被改了五次,代码能不能跟得上?图书管理系统的借还规则绝对是整个系统里变动最频繁的部分,比如借期从 30 天改成 45 天、逾期罚款新增按小时计算,状态机模型能让这些改动都落在一个文件里。这套思路同样适用于任何带有生命周期状态的业务系统。希望这篇笔记能让你少踩几个坑,把基本功练扎实,也把这套代码变成你后续做更大项目时的起点。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进