简介:面向Java初学者与大学低年级学生,资源是一份完整可运行的商品管理系统项目,以Java为核心、通过JDBC操作MySQL数据库、使用Swing构建图形界面,涵盖商品增删改查、数据展示等典型功能,有助于理解分层结构下的Java数据库开发全流程。压缩包共18个文件,以10个java源文件为主,按DAO、Service、Controller、UI进行分层组织;同时包含6个xml配置文件(含Maven或IDE工程配置),并附有gitignore与iml工程描述文件,整体仅16KB,结构简洁、便于快速阅读源码与导入工程。该项目已有1951人学习浏览,适合作为课程设计参考或入门练习素材。通过学习可获得数据库连接工具类、SQL操作封装、Swing界面事件处理等关键代码,并能结合源码理解JDBC加载驱动、建立连接、处理结果集以及Swing布局与监听器的配合方式,是一份能直接运行并二次改造的实用项目。
1. 一个 Java 课程设计题目,凭什么值得你照着做一遍
商品管理系统是 Java 课程设计和期末项目中出现频率最高的题目之一,这个「使用 java,jdbc 连接数据库和 Java swing 图形化界面完成一个商品管理系统」基本把 Java 后端访问数据库和桌面 GUI 两条主线都占了。你用纯 JDBC 写数据访问层,用 Swing 写界面层,中间不夹带任何框架,一套代码跑下来,JDBC 的增删改查、PreparedStatement 防注入、Swing 的事件模型、表格数据绑定这些 Java 基础面试题里的核心考点全都能落在实处。适合两类人:一是准备 Java 课程设计、需要能答辩能演示的在校生,二是想补 Java 基础、之前只写过 Servlet 和 MyBatis 但没碰过原生 JDBC 的转行者。这个项目最大的价值不是「做出一个系统」,而是让你把 Java 基础里最容易囫囵吞枣的几个环节亲手打通。
2. 为什么是 JDBC + Swing:先把技术选型和项目结构立住
2.1 Swing 不是过时技术,是课程设计和桌面端的最稳选择
很多人一听到 Swing 就觉得是老古董,实际在课程设计、毕业设计和部分企业内部小工具场景里,Swing 依然是存活率最高的桌面方案。JavaFX 虽然界面更现代,但它的包结构复杂、FXML 和 Controller 分离的写法对刚做完 Java 基础学习的人来说陡峭不少;Electron 要装 Node.js 全家桶,一个课设的重量级完全不对等。Swing 是 JDK 自带的,javax.swing 包开箱即用,不需要额外引入任何依赖,这对「拿到题目就得在一周内交出可运行代码」的场景是决定性的优势。
Swing 的学习曲线也平缓:JFrame 是窗口,JPanel 是容器,JTable 显示数据,JButton 绑定 ActionListener 处理点击事件。它的 MVC 设计虽然老派但非常清晰——JTable 的 TableModel 就是典型的 Model 层,业务逻辑完全不需要堆在界面类里。你把这个项目做完,再去看网上那些用 JavaFX 写的管理系统,会发现界面渲染和事件响应的思路完全相通,迁移成本很低。
2.2 JDBC 是数据库访问的「原厂件」,绕过它等于没学 Java 数据库编程
JDBC 是 Java 规范里访问数据库的基础接口,MyBatis、Hibernate、Spring Data JPA 这些框架底层全是 JDBC。你在课设里用纯 JDBC 写一遍连接、查询、更新、事务提交,后面再用任何 ORM 框架时遇到诡异问题——比如连接池爆了、事务没提交、驱动版本不兼容——你才能根据 JDBC 的报错栈信息快速定位到是连接层还是 SQL 层出了问题。
这个项目里 JDBC 承担三件事:获取数据库连接、执行 SQL、处理结果集。连接用 DriverManager 从配置文件里读取 URL、用户名、密码来创建;执行 SQL 分成 Statement 和 PreparedStatement 两种,后者用占位符传参,既能避免字符串拼接引发的 SQL 注入,又能让数据库预编译 SQL 提升重复执行效率;结果集 ResultSet 通过 next() 方法逐行遍历,每行按列名或列索引取值,转成商品对象放回 List。这套流程就是 MyBatis 框架里 SqlSession 执行 Mapper 语句的底层原形,你在这里把它走通,面试时被问到「MyBatis 一次查询做了什么」就能答出实质内容。
2.3 三层结构:把界面、业务、数据分开,课设答辩才有东西讲
商品管理系统这类题目,最容易写成的样子是把所有代码塞进一个 JFrame 类里:界面上直接写 SQL、直接操作 ResultSet、直接弹 JOptionPane 提示。这种写法能跑,但答辩时老师问一句「如果要把 MySQL 换成 SQL Server,你改哪里」就答不上来。更严重的是,这种代码后期改一个字段要翻几百行,调一次 bug 要动整个界面逻辑。
我一般会把项目拆成三个包(package),对应三层职责。entity 包放商品实体类,字段和数据库表的列一一对应;dao 包放数据访问类,每个方法对应一条 SQL 操作,只负责「给一个商品对象,插入数据库」或「从数据库查所有商品返回 List」;ui 包放 Swing 界面类,只负责接收用户输入、调用 dao 方法、把结果显示在表格里。业务规则比如「库存不能为负数」可以放在 dao 层做校验,也可以单独拆 service 包,课设规模下放在 dao 层够用,不要过度设计。
这个结构的好处是每一层都能独立测试:先写 entity,再用一个 main 方法单独测 dao 的增删改查,确认数据没问题,最后才碰界面。出 bug 时定位也快——界面没反应查事件监听是否绑定,SQL 报错只查 dao 层,数据对不上查实体类字段映射。答辩时你把三层包结构一摆,老师自然知道你理解了分层思想,而不是只会拖控件。
3. 从建表到写通第一个 DAO:JDBC 连接与商品增删改查的最小实现
3.1 MySQL 建表:字段别贪多,够课设演示就行
数据库设计这一步最容易翻车,不是表建得不够全,而是字段类型和长度选得不对。商品管理系统最核心的表就一张,商品表 goods,字段不要超过六个:id、name、price、stock、description、create_time。id 用 INT 自增主键,name 用 VARCHAR(50) 并加唯一索引防重复录入,price 用 DECIMAL(10,2) 而不是 DOUBLE——浮点数计算价格在 Java 侧用 BigDecimal 接收才不会出现精度丢失,stock 用 INT,description 用 VARCHAR(200),create_time 用 DATETIME 默认值 CURRENT_TIMESTAMP。
建表脚本可以直接写进项目根目录的 sql 文件夹里,课设报告里也能作为附录展示。我习惯把建表语句写成可重复执行的格式,先 DROP TABLE IF EXISTS 再 CREATE TABLE,这样反复调试时不会因为表已存在而中断。数据库名用 goods_manager,字符集必须指定 utf8mb4,否则 Java 侧传入的中文商品名在 MySQL 8.x 默认字符集下可能变成乱码。
DROP TABLE IF EXISTS `goods`; CREATE TABLE `goods` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '商品ID', `name` VARCHAR(50) NOT NULL COMMENT '商品名称', `price` DECIMAL(10,2) NOT NULL COMMENT '单价', `stock` INT NOT NULL DEFAULT 0 COMMENT '库存数量', `description` VARCHAR(200) DEFAULT NULL COMMENT '商品描述', `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_name` (`name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';这里把 name 设成唯一索引,实际课设里可能遇到「同一商品名不同规格」的需求,那你把 name 改成 VARCHAR(100) 并去掉唯一索引即可,但演示阶段建议保留,因为这是答辩时讲「数据完整性约束」的现成素材。create_time 用 DEFAULT CURRENT_TIMESTAMP 可以省掉 Java 侧手动塞时间的代码,插入时少一个参数的坑。
3.2 JDBC 连接参数:url、user、password 别写死在代码里
连接数据库最常见的错误写法是把 URL、用户名、密码直接写死在 DAO 类里。这么做代码确实能跑,但换一台电脑运行、改数据库账号就得重新编译,而且代码里裸奔数据库密码在课设查重和答辩演示时都很尴尬。正确做法是放在 src 目录下的 db.properties 配置文件里,用 Properties 类读取。
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/goods_manager?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=123456注意 url 里的三个参数:useSSL=false 是避免 MySQL 8.x 默认开启 SSL 时报证书警告;serverTimezone=Asia/Shanghai 是解决 JDBC 驱动连接 MySQL 8.x 报时区错误(The server time zone value 'Öйú±ê׼ʱ¼ä' 之类的乱码错误)的关键;characterEncoding=utf8 保证中文参数传输不出乱码。driver 写 com.mysql.cj.jdbc.Driver 是 MySQL 8.x 的驱动类,如果你用的是 mysql-connector-java 5.1.x,则要改成 com.mysql.jdbc.Driver,这个差异是 JDBC 面试题里高频考点。
写一个 DBUtil 工具类负责加载配置、注册驱动、获取连接。类里用静态代码块在类加载时一次性注册驱动,用 getConnection() 方法返回新连接。课设项目不需要连接池,用 DriverManager 每次获取连接即可,但用完必须关,这部分写在 DAO 层的 finally 块里。
import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.util.Properties; public class DBUtil { private static String url; private static String username; private static String password; static { try (InputStream in = DBUtil.class.getClassLoader().getResourceAsStream("db.properties")) { Properties props = new Properties(); props.load(in); Class.forName(props.getProperty("jdbc.driver")); url = props.getProperty("jdbc.url"); username = props.getProperty("jdbc.username"); password = props.getProperty("jdbc.password"); } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置加载失败:" + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } }getResourceAsStream 从 classpath 加载配置文件,这样 DBUtil 在任意目录下运行都能找到 db.properties,而不依赖当前工作目录。Class.forName 这行在 MySQL 8.x 驱动下其实可以省略,因为 SPI 机制会自动注册驱动,但保留它是兼容老版本驱动的稳妥写法。静态块里抛 ExceptionInInitializerError 是故意为之——如果配置加载失败,类加载直接失败,后续任何数据库操作都会立刻报错,避免把配置错误延迟到运行时才暴露。
3.3 商品 DAO:PreparedStatement 的增删改查,四条 SQL 写完核心
DAO 层是这个项目的心脏。我习惯先定义一个包含全部数据访问方法的接口,再用实现类去写 JDBC 代码,虽然课设规模下接口加实现有点啰嗦,但答辩时能多讲一层「面向接口编程」的设计思想,比甩一个全是静态方法的类体面得多。
import java.util.List; public interface GoodsDao { int insert(Goods goods); int update(Goods goods); int deleteById(Integer id); Goods findById(Integer id); List<Goods> findAll(); List<Goods> findByNameLike(String keyword); }实现类里最核心的是 prepareStatement 的写法。以插入为例,SQL 用占位符,参数从传入的 Goods 实体对象取值,executeUpdate() 返回受影响的行数,等于 1 表示插入成功。注意 PreparedStatement 的下标从 1 开始,不是 0;setBigDecimal 对应数据库的 DECIMAL 字段,价格必须传 BigDecimal,否则类型不匹配报错。
import java.math.BigDecimal; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.List; public class GoodsDaoImpl implements GoodsDao { @Override public int insert(Goods goods) { String sql = "INSERT INTO goods (name, price, stock, description) VALUES (?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, goods.getName()); ps.setBigDecimal(2, goods.getPrice()); ps.setInt(3, goods.getStock()); ps.setString(4, goods.getDescription()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("插入商品失败:" + e.getMessage(), e); } } }try-with-resources 写法是 JDK 7 引入的,Connection 和 PreparedStatement 都会在 try 块结束时自动关闭,比手动在 finally 里写 close() 少三层嵌套,也避免忘记关闭连接导致数据库连接数耗尽。这是 java 基础面试题里「try-with-resources 和传统 try-finally 的区别」的原型代码。
查询方法里需要把 ResultSet 转成 List 的关键操作我用 findAll 来说明。ResultSet 的 next() 方法每调用一次指针下移一行,没有更多行时返回 false。取出字段时,getInt("id") 和 getBigDecimal("price") 这类按列名取值的方式可读性好,也避免因表结构调整后列索引变化导致的隐性 bug。
@Override public List<Goods> findAll() { String sql = "SELECT id, name, price, stock, description, create_time FROM goods ORDER BY id"; List<Goods> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Goods g = new Goods(); g.setId(rs.getInt("id")); g.setName(rs.getString("name")); g.setPrice(rs.getBigDecimal("price")); g.setStock(rs.getInt("stock")); g.setDescription(rs.getString("description")); g.setCreateTime(rs.getTimestamp("create_time")); list.add(g); } } catch (SQLException e) { throw new RuntimeException("查询商品列表失败:" + e.getMessage(), e); } return list; }executeQuery() 和 executeUpdate() 的区别在这里体现出来:查询用 executeQuery,返回 ResultSet;增删改用 executeUpdate,返回 int。很多人初学 JDBC 时把两者混用,查询用 executeUpdate 拿不到结果集,插入用 executeQuery 返回不了行数,都属于对 JDBC 接口规范不熟悉的表现。
模糊查询的写法也是 JDBC 面试题常客。SQL 里写 name LIKE ?,Java 侧调用 setString 时传入 "%" + keyword + "%",注意百分号里的空格不能多,写成 "% " + keyword 会导致匹配失败。
@Override public List<Goods> findByNameLike(String keyword) { String sql = "SELECT id, name, price, stock, description, create_time FROM goods WHERE name LIKE ? ORDER BY id"; List<Goods> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Goods g = new Goods(); g.setId(rs.getInt("id")); g.setName(rs.getString("name")); g.setPrice(rs.getBigDecimal("price")); g.setStock(rs.getInt("stock")); g.setDescription(rs.getString("description")); g.setCreateTime(rs.getTimestamp("create_time")); list.add(g); } } } catch (SQLException e) { throw new RuntimeException("搜索商品失败:" + e.getMessage(), e); } return list; }3.4 数据访问层的验证:用一个 main 方法先跑通,再进入界面开发
写 Swing 界面之前必须先验证 DAO 层没有问题。这个步骤省不得,否则后续界面开发时数据库报错和界面代码混在一起,排错难度直接翻倍。我在 dao 包里放一个 DaoTest 类,里面写 main 方法依次调用 insert、findAll、update、delete、findByNameLike,观察控制台输出。
import java.math.BigDecimal; public class DaoTest { public static void main(String[] args) { GoodsDao dao = new GoodsDaoImpl(); Goods g = new Goods(); g.setName("农夫山泉 550ml"); g.setPrice(new BigDecimal("2.00")); g.setStock(100); g.setDescription("饮用水"); int rows = dao.insert(g); System.out.println("插入结果:" + rows); List<Goods> list = dao.findAll(); for (Goods item : list) { System.out.println(item.getId() + " " + item.getName() + " " + item.getPrice() + " " + item.getStock()); } } }跑完这个 main 方法,你会在控制台看到插入成功和查询回来的数据,说明驱动版本、连接参数、SQL 语法、实体类映射都没问题。此时再进入 Swing 开发,后续如果界面有 bug,就能明确断定是界面层的问题,不用再怀疑数据库连通性。这一步也是我多年写 Java 项目的习惯:每一层开发完必须独立验证,绝不带着未经验证的底层代码向上堆界面。
4. Swing 图形化界面:从空窗口到商品表格增删改查的完整实现
4.1 主窗口布局:JFrame + JPanel,把界面拆成功能区
Swing 界面开发最忌讳的就是把整界面写成一个类,然后塞几百行代码。正确的拆法是把主窗口拆成三个区域:顶部是搜索和操作按钮区,中间是商品表格展示区,底部是状态栏或提示区。JFrame 默认布局是 BorderLayout,正好对应这种上中下结构:NORTH 放操作面板,CENTER 放表格滚动面板,SOUTH 放状态标签。
主窗口类 MainFrame 继承 JFrame,构造函数里设置窗口标题、大小、关闭行为,然后调 initUI() 初始化各区域组件。商品管理系统的窗口标题就叫「商品管理系统」,尺寸设 900x600,居中显示。关闭行为用 EXIT_ON_CLOSE 保证点关闭按钮时整个程序退出。
import javax.swing.*; import java.awt.*; public class MainFrame extends JFrame { private GoodsTableModel tableModel; private JTable table; private JTextField searchField; public MainFrame() { setTitle("商品管理系统"); setSize(900, 600); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); initUI(); } private void initUI() { JPanel topPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 10, 10)); searchField = new JTextField(15); JButton searchBtn = new JButton("搜索"); JButton addBtn = new JButton("新增"); JButton updateBtn = new JButton("修改"); JButton deleteBtn = new JButton("删除"); JButton refreshBtn = new JButton("刷新"); topPanel.add(new JLabel("商品名称:")); topPanel.add(searchField); topPanel.add(searchBtn); topPanel.add(addBtn); topPanel.add(updateBtn); topPanel.add(deleteBtn); topPanel.add(refreshBtn); tableModel = new GoodsTableModel(dao.findAll()); table = new JTable(tableModel); JScrollPane scrollPane = new JScrollPane(table); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setRowHeight(28); add(topPanel, BorderLayout.NORTH); add(scrollPane, BorderLayout.CENTER); } }FlowLayout 让按钮从左到右排列,遇到窗口拉伸时不会挤压变形。表格放在 JScrollPane 里,否则表头不可见且数据量大时滚动不了。SINGLE_SELECTION 限制只能选中一行,修改和删除操作都基于当前选中行,避免多选时逻辑复杂。这里有个 Swing 关键点:add(topPanel, BorderLayout.NORTH) 的 add 方法在 JFrame 层面等同于 setContentPane 后再加组件,JDK 5 之后直接往 JFrame 上 add 组件是合法写法,不需要手动调 getContentPane()。
4.2 表格模型:用 DefaultTableModel 还是自定义 TableModel
商品表格是界面的核心,数据显示和编辑是否顺手取决于 TableModel 怎么写。最省事的方式是用 JTable 自带的 DefaultTableModel,addRow 方法往里塞 Object 数组即可。但 DefaultTableModel 的列类型默认都是 Object,价格列无法右对齐,库存列无法排序,而且表格里的值要转成字符串再存,取出来还要自己转型,代码写得很脏。
我一般会继承 AbstractTableModel 写一个 GoodsTableModel,持有 List 数据源,getValueAt 方法里根据列索引从 Goods 对象取对应字段。这样表格和实体类之间直接映射,列数据的类型也是真实的,后续如果要给价格列加右对齐渲染器,直接在 TableModel 的 getColumnClass 里返回 BigDecimal.class 即可。
import javax.swing.table.AbstractTableModel; import java.math.BigDecimal; import java.util.List; public class GoodsTableModel extends AbstractTableModel { private final String[] columns = {"ID", "商品名称", "单价", "库存", "描述", "创建时间"}; private List<Goods> data; public GoodsTableModel(List<Goods> data) { this.data = data; } public void setData(List<Goods> data) { this.data = data; fireTableDataChanged(); } public Goods getRow(int rowIndex) { return data.get(rowIndex); } @Override public int getRowCount() { return data == null ? 0 : data.size(); } @Override public int getColumnCount() { return columns.length; } @Override public String getColumnName(int column) { return columns[column]; } @Override public Object getValueAt(int rowIndex, int columnIndex) { Goods g = data.get(rowIndex); switch (columnIndex) { case 0: return g.getId(); case 1: return g.getName(); case 2: return g.getPrice(); case 3: return g.getStock(); case 4: return g.getDescription(); case 5: return g.getCreateTime(); default: return ""; } } }fireTableDataChanged() 是表格模型的关键方法。每次数据更新后必须调用,JTable 才会重绘刷新界面。这个方法是 AbstractTableModel 内部的观察者机制,它通知所有注册的 TableModelListener 数据已变,JTable 收到通知后重新调用 getValueAt 获取数据。如果你改了 data 引用却不调用 fireTableDataChanged,表格界面不会报错,但显示的还是旧数据,这是 Swing 开发里最容易踩的隐藏坑。
4.3 事件监听:新增、修改、删除的按钮逻辑与数据刷新
按钮的 ActionListener 是 Swing 事件驱动的核心。每个按钮注册一个 ActionListener,actionPerformed 方法里写业务逻辑。新增按钮的逻辑是:弹出一个对话框收集商品信息,用户点确定后把数据封装成 Goods 对象,调用 dao.insert(),成功后刷新表格。
searchBtn.addActionListener(e -> { String keyword = searchField.getText().trim(); List<Goods> list = dao.findByNameLike(keyword); tableModel.setData(list); }); refreshBtn.addActionListener(e -> { List<Goods> list = dao.findAll(); tableModel.setData(list); });搜索和刷新是最简单的两个操作。搜索框拿到的文本先去空格,空字符串时调用 findByNameLike 会返回 LIKE '%%' 的结果,等价于全表查询。刷新按钮主动调 findAll 并把新列表塞进 TableModel,实现数据同步。这里我把 dao 声明为 MainFrame 的成员变量,在构造函数里初始化 GoodsDaoImpl,而不是每个方法里 new 一个——道理很简单,一个 DAO 实例贯穿整个程序生命周期,避免无谓的对象创建开销,也让代码结构更清晰。
新增和删除按钮的完整逻辑我写在一起说明。新增需要弹出输入对话框,Swing 提供 JOptionPane 的 showInputDialog 可以逐字段收集,但商品有四个字段,逐个弹窗体验很差。常见做法是用 JDialog 自建一个商品表单弹窗,放四个 JLabel 和四个 JTextField,点确定后获取数据。这里给出表单弹窗的简化写法,使用 JOptionPane 的自定义面板参数。
addBtn.addActionListener(e -> { JTextField nameField = new JTextField(); JTextField priceField = new JTextField(); JTextField stockField = new JTextField(); JTextField descField = new JTextField(); JPanel formPanel = new JPanel(new GridLayout(4, 2, 5, 5)); formPanel.add(new JLabel("商品名称:")); formPanel.add(nameField); formPanel.add(new JLabel("单价:")); formPanel.add(priceField); formPanel.add(new JLabel("库存:")); formPanel.add(stockField); formPanel.add(new JLabel("描述:")); formPanel.add(descField); int result = JOptionPane.showConfirmDialog(this, formPanel, "新增商品", JOptionPane.OK_CANCEL_OPTION); if (result == JOptionPane.OK_OPTION) { try { Goods g = new Goods(); g.setName(nameField.getText().trim()); g.setPrice(new BigDecimal(priceField.getText().trim())); g.setStock(Integer.parseInt(stockField.getText().trim())); g.setDescription(descField.getText().trim()); dao.insert(g); tableModel.setData(dao.findAll()); JOptionPane.showMessageDialog(this, "新增成功", "提示", JOptionPane.INFORMATION_MESSAGE); } catch (NumberFormatException ex) { JOptionPane.showMessageDialog(this, "单价和库存必须是数字", "输入错误", JOptionPane.ERROR_MESSAGE); } catch (RuntimeException ex) { JOptionPane.showMessageDialog(this, "新增失败:" + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); } } });GridLayout(4, 2, 5, 5) 生成 4 行 2 列的网格,标签和输入框一一对齐,间距 5 像素。showConfirmDialog 返回 OK_CANCEL_OPTION 时表示用户点了确定,此时从各 JTextField 取文本并解析。注意 NumberFormatException 和 RuntimeException 分开捕获,前者是用户输入格式不对,后者是数据库操作失败,提示信息区分开便于定位。新增成功后重新调 dao.findAll() 刷新表格,而不是手动往 tableModel 的 data 里追加一行——虽然效率低一点,但保证表格数据和数据库完全一致,在课设场景下正确性优先于性能。
删除按钮的逻辑是取表格当前选中行,从 TableModel 拿到对应 Goods 对象,调 dao.deleteById(id) 后刷新。删除前必须弹确认框,这是所有管理系统的基本操作规范,防止误删。
deleteBtn.addActionListener(e -> { int selectedRow = table.getSelectedRow(); if (selectedRow < 0) { JOptionPane.showMessageDialog(this, "请先选择要删除的商品", "提示", JOptionPane.WARNING_MESSAGE); return; } Goods g = tableModel.getRow(selectedRow); int confirm = JOptionPane.showConfirmDialog(this, "确定删除商品【" + g.getName() + "】吗?", "删除确认", JOptionPane.YES_NO_OPTION); if (confirm == JOptionPane.YES_OPTION) { dao.deleteById(g.getId()); tableModel.setData(dao.findAll()); } });getSelectedRow 返回选中行的索引,没有选中时返回 -1,所以必须判断。这里用 tableModel.getRow(selectedRow) 取数据而不是直接从 JTable 拿值,原因在于 TableModel 里存的是 Goods 对象,取出来直接能用 g.getId() 和 g.getName(),不需要再从表格单元格的字符串转换回去。删除确认框用 YES_NO_OPTION,和新增表单的 OK_CANCEL_OPTION 区分开,各自语义清晰。
修改按钮的复用性最强。选中一行后弹出表单,表单各输入框先填上当前行的值,用户改动后点确定调 dao.update()。Swing 里实现方式是把新增的表单弹窗逻辑抽取成一个方法,传入一个 Goods 对象用于回显,没传入则是新增模式,传入了则做更新模式。
updateBtn.addActionListener(e -> { int selectedRow = table.getSelectedRow(); if (selectedRow < 0) { JOptionPane.showMessageDialog(this, "请先选择要修改的商品", "提示", JOptionPane.WARNING_MESSAGE); return; } Goods g = tableModel.getRow(selectedRow); JTextField nameField = new JTextField(g.getName()); JTextField priceField = new JTextField(g.getPrice().toString()); JTextField stockField = new JTextField(String.valueOf(g.getStock())); JTextField descField = new JTextField(g.getDescription()); JPanel formPanel = new JPanel(new GridLayout(4, 2, 5, 5)); formPanel.add(new JLabel("商品名称:")); formPanel.add(nameField); formPanel.add(new JLabel("单价:")); formPanel.add(priceField); formPanel.add(new JLabel("库存:")); formPanel.add(stockField); formPanel.add(new JLabel("描述:")); formPanel.add(descField); int result = JOptionPane.showConfirmDialog(this, formPanel, "修改商品", JOptionPane.OK_CANCEL_OPTION); if (result == JOptionPane.OK_OPTION) { g.setName(nameField.getText().trim()); g.setPrice(new BigDecimal(priceField.getText().trim())); g.setStock(Integer.parseInt(stockField.getText().trim())); g.setDescription(descField.getText().trim()); dao.update(g); tableModel.setData(dao.findAll()); } });4.4 启动入口:一个 main 方法启动整个程序
项目里放一个 Main 类作为启动入口,只做一件事:在 Swing 事件调度线程上创建主窗口。Swing 组件不是线程安全的,必须在事件调度线程(EDT)上创建和操作,这是 Java Swing 面试题里几乎必问的原则。初学者在 main 里直接 new MainFrame() 也能跑,但在某些 Linux 环境下会出现界面渲染异常,正确姿势是调用 SwingUtilities.invokeLater。
import javax.swing.SwingUtilities; public class Main { public static void main(String[] args) { SwingUtilities.invokeLater(() -> { MainFrame frame = new MainFrame(); frame.setVisible(true); }); } }invokeLater 把创建窗口的任务压入事件调度线程的任务队列,保证窗口初始化在 EDT 上串行执行。这行代码的含义值得在答辩时说清楚:Swing 采用的是单线程模型,所有组件创建、重绘、事件处理都在 EDT 上完成,开发者自己开的子线程如果要更新界面,同样必须通过 invokeLater 切回 EDT,否则会出现界面卡顿、闪烁甚至崩溃。
5. 避坑实录:JDBC 与 Swing 联调时常见的六个翻车现场
5.1 MySQL 8.x 时区报错的玄学:the server time zone value 乱码
现象:运行 DAO 测试时控制台报错,错误信息里出现 The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized or represents more than one time zone,数据库完全连不上。
原因:MySQL 8.x 的时区默认是系统时区,JDBC 驱动连接时拿到的是中文时区名的乱码表示,驱动解析失败。这不是连接配置写错了,是 JDBC 驱动要求显式指定 serverTimezone 参数。
解决:在 jdbc.url 末尾追加 serverTimezone=Asia/Shanghai,并确保 MySQL 服务端时区与之匹配。如果还报错,在 MySQL 命令行执行 SET GLOBAL time_zone = '+08:00' 后再试。这个坑在课程设计高峰期特别常见,因为大部分人的 MySQL 装完没改过时区配置。
5.2 ClassNotFoundException: com.mysql.cj.jdbc.Driver 不发愁,先看 jar 包位置
现象:启动程序直接报 ClassNotFoundException,驱动类找不到。
原因:mysql-connector-java 的 jar 包没有正确引入到 classpath。很多人以为把 jar 文件拷贝进项目根目录就算引入了,实际上 IDEA 里必须右键 jar 包选 Add as Library,或者通过 Maven 添加依赖。另一种原因是 MySQL 驱动版本太老,5.1.x 的驱动类名是 com.mysql.jdbc.Driver,你却在配置里写成 com.mysql.cj.jdbc.Driver。
解决:确认 jar 包已经出现在 External Libraries 里;检查驱动类名和 jar 包版本对应关系。我一般把项目改成 Maven 结构,在 pom.xml 里引 mysql-connector-java,让 idea 自动下载依赖,比手动拷贝 jar 包少踩很多路径问题。IDEA 下载 Maven 依赖失败时可以检查 network 配置或改为本机手工装包到本地仓库,这又是另一类常见问题了。
5.3 中文乱码的根源:URL 参数 + 表字符集 + 控制台编码三层叠加
现象:插入中文商品名后查出来是「???」,或者控制台输出乱码,但数据库中存的汉字正常。
原因:乱码问题有三层来源。第一层是连接 URL 没加 characterEncoding=utf8,Java 侧传的中文按默认编码转成字节流,MySQL 按 latin1 存储导致乱码;第二层是表创建时没指定 utf8mb4,用了数据库默认字符集;第三层是 IDEA 控制台输出编码不是 UTF-8,数据显示正常但打印出来乱码,这种是假乱码。
解决:URL 加 characterEncoding=utf8;创建表时明确 CHARSET=utf8mb4;IDEA 里打开 Help -> Edit Custom VM Options 添加 -Dfile.encoding=UTF-8 并重启。排查时先查数据库里存的到底对不对,用 Navicat 看表数据判断是存储层乱码还是展示层乱码,别一上来就改代码。
5.4 Swing 界面刷新不及时:改完数据表格纹丝不动
现象:新增商品后数据库里有新数据,但 JTable 界面没显示,必须点一下刷新按钮才能看到。
原因:JTable 的显示数据来自 TableModel,你调 dao.insert() 只改了数据库,TableModel 持有的 List 还是旧数据,而且没有通知 JTable 数据已变。这不算 JDBC 问题,是 Swing 的 Model-View 机制没搞明白。
解决:新增、修改、删除操作后必须重新调 findAll() 获取最新数据,然后调 tableModel.setData() 方法。setData 里调用了 fireTableDataChanged(),JTable 收到事件后重新渲染。这里要特别注意顺序:先更新数据库,再刷新表格,两个步骤缺一不可。有些初学者先刷新表格再操作数据库,界面永远显示旧数据,就是这个顺序反了。
5.5 商品重名插入报错:唯一索引的 Duplicate entry 异常没有兜底
现象:插入一个已存在的商品名,程序抛异常,界面卡住或者直接退出。
原因:建表时 name 字段有唯一索引 uk_name,数据库层面拒绝了重复数据,但 Java 代码没有捕获这个 SQLException,异常一路抛到事件监听线程,Swing 的单线程模型下 EDT 异常会导致界面无响应甚至程序崩溃。
解决:在 DAO 层的 insert 方法里捕获 SQLException,判断错误码或错误信息是否包含 Duplicate,转为更友好的提示。或者在 UI 层捕获 RuntimeException 后弹窗提示「商品名称已存在」。我更推荐在 UI 层统一处理,因为 DAO 层保持抛出异常的语义,UI 层根据场景决定怎么展示。
5.6 JDBC 连接不关闭导致数据库连接数耗尽
现象:程序运行一段时间后,任何数据库操作都卡住,控制台报 Connection is not available, request timed out 之类的错误,重启程序又恢复正常。
原因:因为用了 try-with-resources 而不再手动关连接时,错误地写成了只在成功路径上关闭;或者某些代码路径下 ps.executeUpdate() 抛异常,导致连接未释放。重复执行多次后 MySQL 的连接数被占满,后续所有请求等待连接超时。
解决:确保所有 JDBC 操作都使用 try-with-resources 关闭 Connection、PreparedStatement、ResultSet。如果项目里有手动获取连接的旧代码,检查 finally 块中是否把 conn.close() 放在了正确位置。最好的验证方式是连续新增删除一百条数据,然后运行 SHOW STATUS LIKE 'Threads_connected' 看连接数是否持续增长。
6. 进阶一步:连接池替换、事务处理和入库参数防注入验证
项目跑通后,再花半天时间做三件进阶改造,课设答辩的价值能提高一档。第一件事是引入数据库连接池。DriverManager 每次 getConnection 都走 TCP 握手和数据库认证,高并发场景下开销不小。可以换成 HikariCP,在 pom.xml 引入依赖并写一个连接池工具类,用它替换 DBUtil 里的 DriverManager。核心改动是把配置文件里的密码字段从明文改为可加密存储,用 jasypt 或者自写一个 base64 解码工具类处理。这一改动和热词搜索里「mysql的数据库连接池」「数据库同步工具」这类运维话题也能串起来讲,说明理解了连接池对数据库稳定性的价值。
第二件事是验证 PreparedStatement 的防注入能力。在搜索框输入 1' OR '1'='1 这类注入串,JDBC 会把它当作文本直接传给 LIKE 匹配,不会拼进 SQL 语法里。你可以在 DAO 里加一个打印实际 SQL 的日志开关,用 log4j 或直接 System.out 输出预编译参数,让答辩老师看到参数是安全绑定的。这一条直接对应 jdbc增删改查 和 java面试题 里的高频考点——SQL 注入的防护原理不能只背概念,得有亲手验证过的例子。
第三件事是把新增和修改操作放进事务里。MySQL 默认 autocommit,每个 SQL 立即提交。如果一次业务操作要同时更新商品表和库存流水表,中途第二条 SQL 失败会出现数据不一致。我在 dao 层加一个批量入库方法,用 conn.setAutoCommit(false) 手动控制,两条 SQL 都成功才 commit,任何一条失败则 rollback。这个写法和热词里「java怎么保证数据一致性」的面试考点完全对应,把事务的 ACID 属性从理论变成代码。
public void batchInsertWithTransaction(List<Goods> goodsList) { String sql = "INSERT INTO goods (name, price, stock, description) VALUES (?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement(sql)) { for (Goods g : goodsList) { ps.setString(1, g.getName()); ps.setBigDecimal(2, g.getPrice()); ps.setInt(3, g.getStock()); ps.setString(4, g.getDescription()); ps.addBatch(); } ps.executeBatch(); conn.commit(); } catch (SQLException e) { conn.rollback(); throw new RuntimeException("批量插入失败,已回滚:" + e.getMessage(), e); } finally { conn.setAutoCommit(true); } } catch (SQLException e) { throw new RuntimeException("事务操作失败:" + e.getMessage(), e); } }addBatch 和 executeBatch 是 JDBC 的批量操作接口,一次网络往返提交多条 SQL,性能和一致性都比循环单条插入好。commit 和 rollback 的调用位置必须精确:commit 在批量执行成功后,rollback 只在捕获 SQLException 时触发。finally 里把 autoCommit 恢复 true 是防止连接归还连接池时残留事务状态,虽然这里用的是 DriverManager 直连,但写成规范对后面接连接池有好处。
做完这三件事,你的项目就从「能跑的课设」升级成「能讲出设计深度的课设」:连接池讲性能优化,事务讲数据一致性,PreparedStatement 讲安全编码。答辩时老师问「你这个项目哪里有亮点」,你至少有三个角度可以展开。这比临时抱佛脚背 java面试八股文要实在得多,因为每句话背后都是你亲手跑过的代码——我至今写的每个项目都会保留这套自查流程,先单层验证、再联调、再上事务和连接池,顺序反了就会陷入界面和数据库互相甩锅的泥潭。希望帮到你。
本文还有配套的精品资源,点击获取