简介一套围绕图书管理场景的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 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 入库时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 至少要带上 useSSLfalse 和 characterEncodingutf8前者避免本地开发时 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()); // 不要直接在按钮线程里跑数据库操作否则界面会卡顿 SwingWorkerVoid, 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 没有指定 characterEncodingutf8驱动就用系统默认编码和数据库交互中文字符在传输过程中变成乱码。另一个常见原因是建表时未指定 DEFAULT CHARSETutf8mb4表实际是 latin1中文存进去就已经失真。解决第一步检查建表语句确认表字符集是 utf8mb4第二步在 JDBC URL 上追加参数形如 jdbc:mysql://localhost:3306/library?useSSLfalsecharacterEncodingutf8。两步都做完后重启程序。如果还乱检查代码编译环境的文件编码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 connectionsMySQL 拒绝新连接。原因部分 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 天、逾期罚款新增按小时计算状态机模型能让这些改动都落在一个文件里。这套思路同样适用于任何带有生命周期状态的业务系统。希望这篇笔记能让你少踩几个坑把基本功练扎实也把这套代码变成你后续做更大项目时的起点。本文还有配套的精品资源点击获取