ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java Swing图书管理系统实战:从JDBC连接到事务处理的完整实现

Java Swing图书管理系统实战:从JDBC连接到事务处理的完整实现 1. 动手前的需求梳理与技术选型1.1 这个系统到底要做什么先说我做这个项目的背景。当时我需要一个能交作业、能写进简历、又真正能跑通的Java桌面应用选来选去最后还是做了图书管理系统。原因很直接图书管理系统的业务足够典型——增删改查全覆盖、有一对多的关联查询、有状态流转借书、还书、有权限区分管理员和普通用户这些正好覆盖了Java初学者最需要练手的全部知识点。很多人在网上找Java图书管理系统源代码的时候拿到的要么是纯内存版本数据一关程序就全没了要么是连数据库的版本但代码一团乱麻根本没法二次开发。我这次做的是完整连接MySQL数据库的版本意味着程序重启之后数据还在图书的库存、借阅记录都能持久化保存这才是一个真正能用的管理系统。系统需要实现的功能我列了一下管理员登录后可以维护图书信息新增图书、修改图书、删除图书、查询图书普通用户读者可以浏览图书、按关键字搜索图书、借书、还书借书时自动扣减库存还书时自动增加库存借阅记录需要保存能查谁在什么时间借了什么书登录时校验用户名密码并区分管理员和普通用户两种角色1.2 为什么选Swing MySQL而不是其他方案技术选型上我遇到过很多纠结的人尤其是有的人一上来就想用SSM、Spring Boot加Vue。我的建议是如果目的是练手而不是生产上线老老实实用Swing做界面、JDBC连MySQL就够了。原因有三点第一Swing虽然丑但它足够直观。你把一个JFrame跑起来看到窗口的那一刻正反馈非常强这对初学者的学习动力很重要。命令行版本的图书管理项目我也写过说实话写到后面自己都觉得没意思。第二JDBC是Java连接数据库的最底层方式。你把这个搞通了再去看MyBatis、Hibernate那些ORM框架会发现它们本质上都是在JDBC外面包了一层皮。SessionFactory、DataSource这些概念的底层全部是Connection、PreparedStatement、ResultSet。第三数据库选MySQL是因为它普及率高、资料多、你在网上遇到的90%报错都能搜到解决方案。SQL Server或者Oracle当然也可以但没必要给自己增加环境的折腾成本。2. 数据库设计把业务落到表结构上2.1 三张核心表和一对多关系这个项目我设计了四张表分别是admin管理员、user读者、book图书、borrow借阅记录。其实管理员和读者也可以合并成一张user表加个role字段但我为了演示更清晰的权限模型拆成了两张。借阅记录可以说是整个系统的核心。它记录了谁借了哪本书、什么时候借的、什么时候还的、现在是什么状态。这里有一对多关系一个用户可以有多条借阅记录一本书也可以出现在多条借阅记录里。所以借阅记录表本质上是一个中间表关联了用户和图书两个维度。需要注意的一个细节是不要在借阅记录表里存书名这样的冗余字段正确做法是存book_id查询的时候通过JOIN去拿书名。这样做的好处是如果书名修改了所有历史记录自动同步不会出现数据不一致。2.2 建表语句与边界考量我用MySQL 5.7环境写的建表脚本如果你用8.0也没问题语法基本兼容。下面是完整脚本CREATE DATABASE IF NOT EXISTS library_system DEFAULT CHARACTER SET utf8mb4; USE library_system; CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, book_name VARCHAR(200) NOT NULL, author VARCHAR(100), publisher VARCHAR(100), price DECIMAL(10,2), stock INT NOT NULL DEFAULT 0, total_stock INT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, return_time DATETIME, status TINYINT DEFAULT 0 COMMENT 0-借出中 1-已归还, FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (book_id) REFERENCES book(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个我在实操中觉得值得说道的点字符集一定要用utf8mb4不要用utf8。虽然你只是存中文utf8够用了但如果你往备注字段里存了一个emojiutf8就会报错。养成用utf8mb4的习惯一劳永逸。password字段我用了VARCHAR(64)因为常规MD5加密后是32位SHA-256加密后是64位。如果你后续想升级加密方式64位也够用。图书表里我同时设计了stock当前库存和total_stock总库存。这两个字段是有业务含义的stock表示现在还剩几本可借total_stock表示这本书一共采购了几本。借书时stock-1还书时stock1这个操作本身要放到事务里后面我会详细说。3. JDBC连接的完整封装从DriverManager到连接池思路3.1 配置文件与工具类很多人写JDBC的代码习惯直接把连接信息硬编码在类里Class.forName(com.mysql.cj.jdbc.Driver); Connection conn DriverManager.getConnection( jdbc:mysql://localhost:3306/library_system, root, 123456);这段代码在大学作业里很常见但我不推荐。原因有两点一是如果数据库密码改了你得改代码再重新编译二是这不符合配置与代码分离的基本习惯写下去会让类之间的耦合越来越重。我的做法是建一个db.properties文件放在src根目录下drivercom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/library_system?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 usernameroot password你的数据库密码然后用一个DBUtil工具类统一读取import java.io.InputStream; import java.sql.*; import java.util.Properties; public class DBUtil { private static String driver; private static String url; private static String username; private static String password; static { try (InputStream is DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { Properties props new Properties(); props.load(is); driver props.getProperty(driver); url props.getProperty(url); username props.getProperty(username); password props.getProperty(password); Class.forName(driver); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt ! null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } }静态代码块在类第一次被加载的时候执行正好适合做只需要一次的驱动注册和配置读取。用ClassLoader.getResourceAsStream读取配置文件意味着你把项目打成JAR包也能正常读到资源不会因为相对路径问题报空指针。3.2 连接池为什么值得引入初学者经常困惑每次操作数据库都getConnection()然后再close()频繁创建和销毁连接不浪费吗确实浪费。建立一次数据库连接需要TCP握手、认证、分配资源假设一次连接要50毫秒如果你的程序要执行1000次SQL光连接开销就是50秒这是不能忽视的。生产环境里没人直接裸用DriverManager都是用数据库连接池比如Druid、HikariCP、C3P0。连接池的思想非常像共享单车不用每个人来了就造一辆新车而是有个车棚提前停了一批车谁要用就骑走用完还回来别人可以继续用。我把DBUtil改成基于Druid连接池的版本也很简单import com.alibaba.druid.pool.DruidDataSourceFactory; public class DBUtil { private static DataSource dataSource; static { try (InputStream is DBUtil.class.getClassLoader() .getResourceAsStream(druid.properties)) { Properties props new Properties(); props.load(is); dataSource DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }对应的druid.properties配置driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/library_system?useSSLfalseserverTimezoneAsia/Shanghai usernameroot password你的数据库密码 initialSize5 maxActive10 maxWait3000把依赖的JAR包放到项目的lib目录并加入Build Path即可。初学者如果觉得连接池概念太抽象先记住一个结论连接池就是提前创建一堆连接放在池子里用的时候拿一个用完归还而不是真正关闭。等到你的项目在并发场景下需要频繁访问数据库你会主动回来研究它。3.3 增删改查的通用写法JDBC操作数据库的套路非常固定我总结成四步通过DBUtil拿到Connection用PreparedStatement预处理SQL同时填充?占位符执行executeUpdate()或executeQuery()在finally块里关闭资源以新增一本图书为例public int addBook(Book book) { String sql INSERT INTO book(book_name, author, publisher, price, stock, total_stock) VALUES(?, ?, ?, ?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, book.getBookName()); ps.setString(2, book.getAuthor()); ps.setString(3, book.getPublisher()); ps.setBigDecimal(4, book.getPrice()); ps.setInt(5, book.getStock()); ps.setInt(6, book.getTotalStock()); return ps.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return 0; } }注意这里用了try-with-resources语法Java 7开始支持它能自动关闭实现了AutoCloseable接口的资源省去了手写finally块关闭连接的麻烦。提示PreparedStatement相比Statement有两个核心优势防止SQL注入和预编译带来的性能提升。在任何涉及用户输入的SQL操作中绝对不要用拼字符串的方式一定要用PreparedStatement。4. 核心功能模块实现细节4.1 登录与权限判断登录逻辑是典型的查询判断组合。用户输入用户名和密码后程序先去admin表查如果匹配就进入管理员界面否则去user表查匹配则进入用户界面两张表都没匹配到提示用户名或密码错误。在密码处理上有一个必须养成的习惯不要明文存储密码。我用的是MD5加盐的方式。虽然MD5在安全性上已经不被推荐用于高强度加密场景但作为课程设计和技术演示它比明文存储强太多了。public static String md5(String input) { try { MessageDigest md MessageDigest.getInstance(MD5); byte[] digest md.digest(input.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : digest) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } }注册用户时存md5(password)登录时对比md5(输入的密码)和数据库存的密码是否一致。数据库里即使密码泄露别人拿到的也是一串不可逆的散列值。4.2 图书管理界面与数据库的交互图书管理模块包含增删改查四个操作我把这些操作封装在BookDAO类中DAOData Access Object模式的核心思想就是把数据库访问逻辑从界面层隔离出来。Swing界面上只调用BookDAO的方法不直接碰SQL。查询全部图书public ListBook listAllBooks() { String sql SELECT id, book_name, author, publisher, price, stock, total_stock FROM book; ListBook list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Book book new Book(); book.setId(rs.getInt(id)); book.setBookName(rs.getString(book_name)); book.setAuthor(rs.getString(author)); book.setPublisher(rs.getString(publisher)); book.setPrice(rs.getBigDecimal(price)); book.setStock(rs.getInt(stock)); book.setTotalStock(rs.getInt(total_stock)); list.add(book); } } catch (SQLException e) { e.printStackTrace(); } return list; }界面层用JTable展示结果核心代码是把ListBook转换成DefaultTableModelDefaultTableModel model new DefaultTableModel(new Object[]{ ID, 书名, 作者, 出版社, 价格, 库存, 总库存 }, 0); for (Book b : list) { model.addRow(new Object[]{ b.getId(), b.getBookName(), b.getAuthor(), b.getPublisher(), b.getPrice(), b.getStock(), b.getTotalStock() }); } bookTable.setModel(model);搜索功能用的是LIKE模糊查询public ListBook searchBooks(String keyword) { String sql SELECT * FROM book WHERE book_name LIKE ? OR author LIKE ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); ps.setString(2, % keyword %); ... } }这里有坑要提醒MySQL的LIKE默认不区分大小写对中文没有影响但如果书名里包含英文搜索时大小写不敏感可能带来惊喜或惊吓看你业务需求。4.3 借阅归还的库存一致性处理借书操作绝不是简单的往borrow表插一条记录它涉及到两张表的数据变更在borrow表插入一条记录status0表示借出中在book表更新stock stock - 1这两个操作必须要么都成功要么都失败。如果插入了借阅记录但库存没减会造成数据不一致如果减了库存但借阅记录没插进去书就凭空消失了。这就是事务的用武之地。JDBC里默认每条SQL执行完自动提交所以你必须手动关闭自动提交冲突逻辑中显式调用commit()出错则调用rollback()public boolean borrowBook(int userId, int bookId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 检查库存是否大于0 String checkSql SELECT stock FROM book WHERE id ? FOR UPDATE; int stock; try (PreparedStatement ps conn.prepareStatement(checkSql)) { ps.setInt(1, bookId); ResultSet rs ps.executeQuery(); if (rs.next()) { stock rs.getInt(stock); } else { return false; } } if (stock 0) { return false; } // 2. 插入借阅记录 String insertBorrowSql INSERT INTO borrow(user_id, book_id, status) VALUES(?, ?, 0); try (PreparedStatement ps conn.prepareStatement(insertBorrowSql)) { ps.setInt(1, userId); ps.setInt(2, bookId); ps.executeUpdate(); } // 3. 扣减库存 String updateStockSql UPDATE book SET stock stock - 1 WHERE id ?; try (PreparedStatement ps conn.prepareStatement(updateStockSql)) { ps.setInt(1, bookId); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { e.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }SELECT ... FOR UPDATE这一行是行级锁意思是把这一行数据锁住防止两个用户同时借最后一本书导致库存变负数。在单机桌面程序里你可能感觉不到它的作用但一旦系统变成Web端多用户并发访问这一行就是保证数据不出错的命根子。归还书的逻辑正好相反更新borrow表的status1和return_time同时把库存加回去。注意归还操作也需要事务因为如果不更新归还状态只加库存借阅记录就永远是借出中用户下次就没法借同一本书了。5. 实际开发中我踩过的几个坑5.1 中文乱码问题这个坑几乎每个人都躲不掉。表现是程序里显示的中文都正常但往MySQL里插入中文变成??或者从数据库读出来乱码。排查思路要按这个顺序来数据库表字符集是否是utf8mb4JDBC连接串有没有加characterEncodingutf8SQL文件本身是不是以UTF-8保存的数据库连接串和服务端字符集是否一致我最开始就是在db.properties里漏了characterEncodingutf8结果界面看起来一切正常数据一入库就乱。后来我把连接串改成urljdbc:mysql://localhost:3306/library_system?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8问题解决。另外提醒一句如果你用Navicat之类的工具直接执行SQL脚本建语句文件一定也要以UTF-8编码保存否则表结构里的中文注释照样乱。5.2 mysql-connector版本与驱动类名我遇到过不少人在第一步就卡住Class.forName(com.mysql.jdbc.Driver)抛ClassNotFoundException。原因很简单这是老版本驱动类的名字MySQL Connector/J 5.1.8之后驱动类改成了com.mysql.cj.jdbc.Driver。如果你用的是mysql-connector-java 8.x的JAR包就必须写新的类名否则JVM根本找不到这个类。还有一点容易忽略MySQL 8.x连接串里必须带serverTimezone参数否则会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。这个报错就是因为连接驱动的默认时区跟MySQL服务端时区不一致加serverTimezoneAsia/Shanghai就好。5.3 连接泄漏与测试盲区很多人写完DAO层觉得没问题结果程序跑着跑着突然变卡或者过一会儿报Too many connections。原因几乎必然是Connection被用完之后没有close。你可以用下面的SQL监控当前的连接数SHOW PROCESSLIST;如果发现很多Sleep状态的连接堆积十有八九就是代码里有getConnection()没有对应的close()。我自己的排查经验是每写一个DAO方法立刻检查是否在finally或try-with-resources里关掉了连接。不要等整个项目写完再查那时根本定位不到是哪一层的锅。6. 界面与业务逻辑分离的一点个人习惯6.1 用ActionListener解耦按钮事件图书管理系统的界面我用Swing实现窗体主要有LoginFrame、AdminMainFrame、UserMainFrame。如果所有代码都堆在按钮的ActionListener匿名内部类里几百行代码很快就会失控。我的做法是给每个操作定义一个方法例如btnAddBook.addActionListener(e - { AddBookDialog dialog new AddBookDialog(this); dialog.setVisible(true); refreshTable(); // 关闭对话框后刷新列表 });Swing的ActionListener里涉及数据库操作时CPU密集和IO密集的活儿会让界面卡住。我实测过如果借书操作耗时2秒在这2秒内窗口完全拖不动。解决思路是用SwingWorker把耗时操作丢到后台线程。进阶阶段可以研究但作为基础项目我暂时用同步调用也能接受。6.2 数据校验别只做前端在addBook的对话框里我做了两个层面的校验第一层是界面校验书名不能为空、价格必须是非负数字、库存必须非负整数。写起来简单if (bookName.trim().isEmpty()) { JOptionPane.showMessageDialog(this, 书名不能为空, 提示, JOptionPane.WARNING_MESSAGE); return; }第二层是数据库层校验BookDAO.addBook返回0表示插入失败。你不能完全相信界面的输入框万一有人绕过界面直接用代码调用DAO或者界面上字段长度跟数据库字段不一致你总得在更底层兜底。if (dao.addBook(book) 0) { JOptionPane.showMessageDialog(this, 新增成功); refreshTable(); } else { JOptionPane.showMessageDialog(this, 新增失败请检查输入); }6.3 项目跑通之后还能怎么扩展说实话图书管理系统是一个被写烂了的课程设计题目但正因为题目常见才更容易在基础之上做出差异化。我在完成基础功能后自己额外加了两块一是按分类统计图表借JFreeChart的库把所有图书按出版社统计数量画成柱状图视觉效果立刻不一样了。二是数据导出通过JFileChooser选择保存位置把图书列表导出成CSV文件。这两块都不难但能让项目从别人也有的功能变成稍微有自己的亮点。别人考核这个项目看的不是你界面多华丽而是你遇到的业务问题和解决方案。能完整讲清楚借书为什么用事务PreparedStatement为什么能防SQL注入连接池解决的是什么问题比写一万行CRUD都值钱。最后分享一个我自己排查代码时常干的事在DAO方法里临时加打印日志把每次执行的SQL和参数打在控制台上System.out.println(SQL: sql); System.out.println(param bookId bookId);跑完直接看控制台就能定位是SQL写错了还是参数传参有问题。这个方法非常土但在数据层出现问题的时候它就是定位速度最快的手段。等你把整个项目调通了再把调试用的日志删掉代码就是干干净净可以直接复用的一版了。
返回列表