简介:一份面向Java初学者的图书馆管理系统实训项目完整资源,适合计算机专业学生完成课程设计或实训任务时参考。内容围绕图书信息管理、读者信息管理、借阅归还、图书查询与统计分析等核心模块展开,覆盖数据库ER图设计、JDBC连接与增删改查、Swing界面布局、MVC分层架构、异常处理与权限校验等关键知识点;从这些源码和设计文件中,可以还原图书表、读者表、借阅记录表的关系,以及借阅超期、可借状态等业务判断逻辑。压缩包共1988个文件,大小约6MB,其中以XMI建模文件、Class字节码、Java源文件、XML配置和Eclipse工程相关文件为主,大量XMI设计模型有助于理解类图结构和数据库建模,Java源码与编译产物并存也便于导入开发环境后对照调试。就技能层级而言,适合已经掌握Java语法、希望综合运用面向对象与数据库知识的读者,也适合作为课程设计的起点模板。已有229人学习下载,适合需要完整实训代码和设计文档对照的Java学习者。
1. 图书馆管理系统:Java 实训任务里最该较真的 CRUD 项目
不少人在做图书馆管理系统(java实训任务)时,一开始觉得它就是个普通的增删改查练习:图书表、读者表、借阅表,敲完几个 JSP 或 Swing 界面就算交差。但真正被导师挑出毛病的地方,往往不在功能缺没缺,而在事务边界、SQL 注入防护、日期处理和级联删除这些“看不见”的位置。这个系统能很好地检验你对 JDBC 封装、分层思想、数据库约束和基础设计模式的理解程度,而且是 Java 后端求职简历里出现频率极高的项目类型。如果你正在找一份既能过实训考核、又能写进项目经验的代码包,这篇笔记会把拆解过程和落地细节按完整流程讲清楚。
适合的人群很明确:刚学完 JavaSE 想做第一个综合项目的人,或者被实训任务卡住需要参考完整代码结构的人。下文从表结构设计讲到 CRUD 与借还流程,再讲到事务和连接池改造,每一步都会给出参数说明与坑点记录,照着走基本不会偏。
2. 先理清需求和选型:分层架构、数据库表与依赖取舍
2.1 为何实训里的管理系统都爱用三层架构
图书馆管理系统在实训任务里被反复布置,是因为它业务边界清晰:图书管理、读者管理、借阅归还、逾期统计,每块都能独立测试。而三层架构是这类 CRUD 项目最稳的骨架,因为它把界面、业务规则和数据访问拆开,改界面不碰 SQL,调 SQL 不动页面。
常见的包结构如下:
src ├── com.library.entity // 实体类:Book, Reader, BorrowRecord ├── com.library.dao // 数据访问层:BookDao, ReaderDao, BorrowDao ├── com.library.service // 业务层:BookService, BorrowService ├── com.library.view // 界面层:Swing 或 Servlet/JSP └── com.library.util // 工具类:DBUtil, StringUtil我在拆解模拟项目 X 时发现,很多初学者把 SQL 直接写在界面类里,导师一问“借阅表要加一个字段,要改几个文件”,当场就答不上来。分层改造后用 service 包住事务,用 dao 统一管理 SQL,加字段只需要动实体类和对应的 dao 映射,界面层几乎不改,这个思路在实训练习和后续做外包项目时同样适用。
2.2 表结构设计:五张表和一条外键原则
图书管理系统最少需要四张表:图书表、读者表、借阅表和管理员表。多数实训要求里还会加一个“分类表”,便于做图书分类统计。表结构直接影响后面写 SQL 的复杂度,所以先设计好再动手。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| book | id, book_name, author, publisher, category_id, stock, remaining | category_id 关联分类表 |
| category | id, category_name | 一个分类对应多本图书 |
| reader | id, reader_no, name, phone, max_borrow_num | 每人可借上限 |
| borrow_record | id, book_id, reader_id, borrow_date, due_date, return_date, status | status 标记借出/已还/逾期 |
| admin | id, username, password | 登录用,密码存哈希值 |
这里最容易翻车的是在 borrow_record 里直接存 book_name 而不是 book_id。虽然查询时确实少了一次 join,但一旦书名改版,所有历史借阅记录的关联就断了。我一般会坚持只存外键 id,查询时用 JOIN 取名称,这个习惯在实训代码里也会成为评分亮点。
2.3 依赖选型:JDBC 直连还是 MyBatis 封装
实训场景里通常不让你上框架,JDBC 是默认要求。但 JDBC 写起来繁琐,所以项目包里一般会自带 DBUtil 工具类和 DAO 基类来做通用查询。如果你拿到的是这样的代码包,先看它有没有帮你把连接关闭、预编译、结果集映射这些重复操作封装掉。
下面是一个典型 JDBC 工具类的核心方法,很多实训项目在此基础上扩展:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/library_db?useSSL=false&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 按逆序关闭资源,避免连接泄漏 if (rs != null) { try { rs.close(); } catch (SQLException e) {} } if (ps != null) { try { ps.close(); } catch (SQLException e) {} } if (conn != null) { try { conn.close(); } catch (SQLException e) {} } } }URL 里有两个参数特别关键:useSSL=false 是为了避免本机测试时 SSL 握手报错,characterEncoding=utf8 是为了防止中文写入数据库变成问号。如果你用的 MySQL 8.x,驱动类名必须是 com.mysql.cj.jdbc.Driver,旧版的 com.mysql.jdbc.Driver 在新驱动里已经移除了,这也是拿到代码包后第一个要检查的位置。
DAO 基类里的通用查询方法通常会封装一个 RowMapper 接口,用 Java 8 的 lambda 简化结果集到实体的映射。如果你看到这样的设计,说明代码作者对 JDBC 的抽象有一定实战经验,这种写法在实训报告里也很加分。
3. 图书 CRUD 与借还书的完整实现:参数、SQL 与代码落地
3.1 图书新增和更新:PreparedStatement 传参的正确姿势
图书模块是系统的门面,新增和编辑功能一般放在一起,共用同一个表单。先看一个标准的 BookDao 新增方法,注意参数处理和返回值设计:
public int insertBook(Book book) { String sql = "INSERT INTO book (book_name, author, publisher, category_id, stock, remaining) VALUES (?, ?, ?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, book.getBookName()); ps.setString(2, book.getAuthor()); ps.setString(3, book.getPublisher()); ps.setInt(4, book.getCategoryId()); ps.setInt(5, book.getStock()); ps.setInt(6, book.getStock()); // 新增时剩余量与库存一致 ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) { return keys.getInt(1); } } return 0; } catch (SQLException e) { e.printStackTrace(); return -1; } }新增时让 remaining 等于 stock 是个很容易被忽略的业务规则。如果两者分开填,就会出现“库存 10 本但可借 0 本”的逻辑错误。PreparedStatement 里加了 RETURN_GENERATED_KEYS 参数,这样新增后可以立刻拿到主键,用于跳转到详情页或继续录入借阅记录。如果你只需要返回影响行数,去掉那个参数、直接走 executeUpdate 就可以。
更新方法逻辑类似,只是 SQL 变成 UPDATE,并且要额外 set 一个条件 id。这里有一个常见误区:有些代码会把 remaining 也放进更新表单里让用户去改,这是不对的,剩余量应该由借还业务自动维护,手工改会导致数据错乱。
3.2 图书搜索与列表分页:LIKE 查询的参数化处理
搜索功能在实训里通常支持按书名、作者两个字段模糊查询。模糊查询的 SQL 写法和参数拼接顺序很容易踩坑,尤其要注意 % 符号的位置:
public List<Book> searchBooks(String keyword, int pageStart, int pageSize) { String sql = "SELECT * FROM book WHERE book_name LIKE ? OR author LIKE ? LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String pattern = "%" + keyword + "%"; ps.setString(1, pattern); ps.setString(2, pattern); ps.setInt(3, pageStart); ps.setInt(4, pageSize); try (ResultSet rs = ps.executeQuery()) { List<Book> list = new ArrayList<>(); while (rs.next()) { list.add(mapBook(rs)); } return list; } } catch (SQLException e) { e.printStackTrace(); return Collections.emptyList(); } }LIMIT ?, ? 是从第几行开始取、取多少行的意思,不是“第几页”。假设每页 10 条,第二页的 pageStart 应该是 10 而不是 2,这个换算经常有人在 Service 层写错。我一般会在 Service 层统一做计算:int pageStart = (currentPage - 1) * pageSize;,这样 DAO 层只管 SQL 执行逻辑。
LIKE 查询参数化后,完全不用字符串拼接,也就杜绝了 SQL 注入风险。有些旧代码喜欢写WHERE book_name LIKE '%" + keyword + "%'",实训时导师可能不会深究,但代码审计或面试时这是致命伤。
3.3 借书与还书:事务控制的边界在哪
借书操作涉及两件异步更新:往 borrow_record 表插入一条记录,同时把 book 表的 remaining 减一。这两个操作如果分开执行,中间任何一步失败都会造成数据不一致。
public boolean borrowBook(int bookId, int readerId) { String insertRecord = "INSERT INTO borrow_record (book_id, reader_id, borrow_date, due_date, status) VALUES (?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 'BORROWED')"; String updateBook = "UPDATE book SET remaining = remaining - 1 WHERE id = ? AND remaining > 0"; Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement(insertRecord); PreparedStatement ps2 = conn.prepareStatement(updateBook)) { ps1.setInt(1, bookId); ps1.setInt(2, readerId); ps1.executeUpdate(); ps2.setInt(1, bookId); int rows = ps2.executeUpdate(); if (rows == 0) { conn.rollback(); return false; } conn.commit(); return true; } } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { try { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException e) { e.printStackTrace(); } } }事务控制的精髓在conn.setAutoCommit(false)之后,两条 SQL 共享同一个连接和同一个事务。UPDATE 语句里的AND remaining > 0是一个防御性条件,作用是在库存为 0 时返回影响行数 0,然后主动回滚。如果没有这个条件,库存变成负数也不会报错,数据就直接坏了。
还书操作是反向流程:更新 borrow_record 的 return_date 和 status,再把 book 表的 remaining 加一。同样需要事务控制。借还逻辑里日期处理可以直接用 MySQL 的 CURDATE() 和 DATE_ADD(),这样不用在 Java 端处理日期格式化,也避免了 SimpleDateFormat 线程安全的问题。你如果拿到代码包,重点检查里面是不是也用这两个函数,如果看到 Java 端自己拼日期字符串,那就要留个心眼。
3.4 读者借阅历史与逾期计算
读者模块除了基本 CRUD,还有一个被实训项目隐藏的特殊功能——借阅历史。通常的做法是拉取当前读者的借阅记录后,在 Java 内存里计算是否有逾期。
实际项目中逾期判断一般交给 SQL 查出来,比如以下查询:
SELECT br.id, b.book_name, br.borrow_date, br.due_date, br.return_date, CASE WHEN br.return_date IS NULL AND br.due_date < CURDATE() THEN '已逾期' WHEN br.return_date IS NOT NULL AND br.return_date > br.due_date THEN '逾期归还' ELSE '正常' END AS status_text FROM borrow_record br JOIN book b ON br.book_id = b.id WHERE br.reader_id = ? ORDER BY br.borrow_date DESC;用 CASE WHEN 语句把状态直接计算出来,展示层只需要渲染字符串,不需要写复杂的判断逻辑。CASE WHEN 里的判断顺序是有讲究的:先判断空值逾期,再判断归还后逾期,最后才是正常,顺序反了会得到错误的结果。这里的“已逾期”和“逾期归还”是两个状态,前者表示书还在读者手里但超过应还日期,后者表示虽然还了但还晚了,业务上扣款规则完全不同。
读者表里有个字段 max_borrow_num,借书前需要校验当前已借数量是否达到上限。校验的做法是查 borrow_record 中该 reader_id 且 return_date IS NULL 的记录数,这个查询可以用 GROUP BY 做也可以用简单 COUNT。这里要注意,已还的记录不能算进在借数量,否则读者永远只能借一本书。
4. 避坑与排查:实训中常见的六个翻车现场与解法
4.1 现象:运行报 ClassNotFoundException,驱动加载失败
原因:MySQL 驱动 JAR 包没放进 WEB-INF/lib 或项目构建路径里。这几乎是新手最低级但频率最高的错误。
解决:如果用 Maven 项目,在 pom.xml 里确认 mysql-connector-java 依赖已加入;如果是普通 Dynamic Web Project,把驱动 JAR 拷贝到 WEB-INF/lib 目录后重新发布。某些开发工具还需要在 Project Structure 里把 JAR 添加到 Libraries,光拷到目录不刷新索引也不行。
4.2 现象:控制台输出中文是乱码,数据库里存进的是问号
原因:连接 URL 没有指定 characterEncoding=utf8,或者页面、数据库、连接串三层编码不一致。MySQL 数据库本身的字符集如果是 latin1,存中文也会出问题。
解决:连接串加上?useUnicode=true&characterEncoding=utf8,同时检查 MySQL 建库时的字符集。我会在代码里用SHOW CREATE DATABASE library_db查看默认字符集,如果不是 utf8mb4,把库表和连接串全部统一调整。前端页面也要在<head>里加<meta charset="UTF-8">,三层缺一层都会乱码。
4.3 现象:借书明明成功但库存没减
原因:执行 UPDATE book SET remaining = remaining - 1 时,连接是自动提交模式且执行了多次同一事务,或者 SQL 里没写AND remaining > 0,导致最后库存变成负数,界面就不显示了。
解决:借还操作必须把多条 SQL 放在同一个事务里执行,要么全成功、要么全回滚。同时给 UPDATE 语句加库存条件,如果影响行数为 0,说明库存不足,代码要主动回滚并给用户提示。检查库存为负的方法很简单:SELECT * FROM book WHERE remaining < 0,有查出来就是事务没做好。
4.4 现象:外键约束报错,删不掉有借阅记录的图书
原因:book 表的主键被 borrow_record 表引用,直接 DELETE FROM book WHERE id=? 时数据库拒绝执行。
解决:删除前先检查 borrow_record 是否有未归还的记录,有的话提示“该书存在未归还记录,不可删除”。如果实训只要求逻辑删除,给 book 表加一个 is_deleted 字段,删除时执行 UPDATE 而不是 DELETE,这样借阅历史里的 join 查询还能正常显示书名,是最稳妥的做法。
4.5 现象:分页查询时页码越点越乱,数据重复或缺失
原因:LIMIT 的起始位置被错误地传成了页码本身,第二页执行的是 LIMIT 2,10,“跳过两行”后的数据顺序就乱了。
解决:在 Service 层统一做换算,参数传入 currentPage 和 pageSize,内部计算start = (currentPage - 1) * pageSize。另外注意 ORDER BY 不要漏掉,不排序时 LIMIT 的结果顺序是不确定的,容易出现翻页后数据跳动。
4.6 现象:导出 Excel 或打印报表时,日期格式变成一串数字
原因:Java 里 Date 类型直接 toString() 输出的是英文格式或时间戳,没有做格式化处理。有些代码用 SimpleDateFormat 但没指定时区,导出的时间差了 8 小时。
解决:统一用一个日期工具类格式化显示。常见写法是new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date),并且设置时区TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"))。实训项目里日期多数只有年月日,用 LocalDate 更轻便,避免处理时分秒。
5. 高分改造三板斧:分页组件、事务优化与连接池接入
最后一个环节分享几个能让实训评分往上走的改造点,全部在现有代码基础上改就好,不需要推倒重来。
第一板斧:把分页逻辑封装成 PageResult 对象。
现有代码里分页计算通常散落在 Servlet 和 Service 层,改造后用一个泛型类统一装数据:
public class PageResult<T> { private List<T> list; private int totalCount; private int currentPage; private int pageSize; private int totalPages; public PageResult(List<T> list, int totalCount, int currentPage, int pageSize) { this.list = list; this.totalCount = totalCount; this.currentPage = currentPage; this.pageSize = pageSize; this.totalPages = (int) Math.ceil((double) totalCount / pageSize); } }这个类的价值在于把“总记录数、总页数、当前页、每页条数”打包成一个对象传给前端,JSP 里用 EL 表达式直接读取,避免在页面里重复写除法取余逻辑。Service 层查完数据后,再执行一条SELECT COUNT(*) FROM book WHERE 条件就能构造 PageResult。注意 COUNT 查询条件和列表查询条件必须完全一致,否则总页数和实际页数对不上,这个坑我在模拟项目 X 里踩过多次。
第二板斧:给“借还事务”增加统一的事务管理器。
现在的借书方法里写了一套事务控制,还书方法里又写了一遍,代码重复严重。可以抽出一个简单的 TransactionTemplate,用接口回调的方式把事务控制统一收口:
public interface TransactionCallback<T> { T doInTransaction(Connection conn) throws SQLException; } public class TransactionTemplate { public <T> T execute(TransactionCallback<T> callback) throws SQLException { Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); T result = callback.doInTransaction(conn); conn.commit(); return result; } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn, null, null); } } }改造后,借书方法只需要写 Lambda 表达式描述“在该事务里做什么”,事务打开和回滚交给模板类统一处理。这个写法是 Spring 事务的一个简化版雏形,你在实训报告的“设计亮点”部分写一笔,通常会被导师多留意几眼。
第三板斧:引入简单的数据库连接池。
JDBC 直连每次查询都要建立物理连接,实训中数据量小体现不出问题,但答辩时如果被问“你这个系统能支撑多少并发”,直接答不上来。可以在不引入框架的情况下用 HikariCP 的最小配置替代 DBUtil 的 DriverManager:
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/library_db?useSSL=false"); config.setUsername("root"); config.setPassword("123456"); config.setMaximumPoolSize(10); config.setConnectionTimeout(3000); HikariDataSource ds = new HikariDataSource(config);把 DataSource 注入 DBUtil,之后所有 getConnection 都从池里获取,性能和稳定性立马上一个台阶。HikariCP 是生产环境最常见的连接池,修改量只有 DBUtil 一个类,其他代码完全兼容。唯一要注意的是引入 JAR 包时确认版本和 MySQL 驱动兼容,HikariCP 最新版需要 JDK 8 以上,如果你实训机器用的是老 JDK 6,就别折腾这个了,保持直连就行。
做完这三板斧,这份图书馆管理系统代码就从“能跑”变成了“能讲”:事务有边界、查询有分页、连接有池化、状态有 CASE WHEN 兜底。从那以后我每次搭这类管理系统,都会强制走一遍同样的检查:事务包没包住全部写操作,LIKE 查询有没有参数化,分页起始位置有没有换算对。量产项目里这些问题会让线上事故一夜之间爆出来,实训时把血泪踩完,总比上线后翻车好。希望这些拆解对你有用。
本文还有配套的精品资源,点击获取