Java Swing+JDBC教务系统实战:从MVC架构到数据库连接详解
1. 项目缘起与核心价值为什么从零构建一个带界面的教务系统如果你是一名计算机专业的学生或者刚入行的Java开发者可能都面临过一个经典又有点头疼的课程设计或练手项目做一个教务管理系统。网上的源码一搜一大堆但要么是纯控制台的要么是用了你还没学过的Spring Boot框架要么就是界面丑得让人没有继续下去的欲望。直接拿来用吧感觉什么都没学到自己从头写吧又不知道从何下手尤其是如何把数据库里的数据和那个看得见摸得着的图形界面GUI给连起来。这就是我当年踩过的坑。所以我决定抛开那些复杂的框架就用最基础的Java SE技术栈——Swing做界面JDBC连数据库——来完整地走一遍这个流程。这个项目的价值远不止是完成一个“作业”。它是一次对Java桌面应用开发核心脉络的梳理如何设计一个结构清晰、易于维护的MVC架构如何高效、安全地进行数据库操作如何让粗糙的Swing组件变得美观且好用当你亲手把一个个按钮、一张张表格和后台的数据库表关联起来看到数据能增删改查、界面能实时响应时你对程序的理解会从“知识点”层面深入到“系统”层面。这对于巩固Java基础、理解数据库应用、乃至为后续学习更复杂的Web开发都打下了极其坚实的实践基础。2. 技术选型与项目架构设计为什么是Swing JDBC面对一个项目选型是第一步。为什么在这个时代我还要推荐看似“过时”的Swing和JDBC呢这恰恰是项目的教学和实践意义所在。2.1 界面层Swing的坚守与美化是的JavaFX更现代Web前端更炫酷。但Swin g是Java标准库的一部分无需任何额外依赖环境搭建零成本。更重要的是Swing的组件模型如JFrame,JPanel,JTable,JButton非常经典理解了它你就能触类旁通地理解几乎所有GUI编程的基本思想事件驱动、布局管理、组件渲染。直接使用原生Swing确实简陋但我们可以通过一些技巧大幅提升体验使用JTable展示数据这是教务系统的核心用于显示学生列表、课程表、成绩单。我们需要自定义TableModel来绑定数据库查询结果。采用BorderLayout、GridBagLayout等高级布局管理器告别绝对定位实现界面的自适应和整洁。引入FlatLaf等第三方外观库一行代码就能让界面拥有现代化的扁平化设计彻底摆脱“复古”感。这是让项目“拿得出手”的关键一步。2.2 数据层JDBC的纯粹与封装MyBatis、Hibernate等ORM框架能极大简化数据库操作但在这个阶段直接使用JDBC能让你把SQL语句、连接池、事务控制这些底层概念吃得透透的。我们的目标是写一个健壮、可复用的数据访问层DAO。连接池绝不每次操作都新建连接。我们使用像HikariCP这样轻量高效的连接池它是高性能应用的标配。DAO模式为每个实体如StudentCourse创建对应的DAO接口和实现类。所有SQL都封装在这里业务逻辑层无需关心SQL细节。PreparedStatement必须使用它来执行SQL这是防止SQL注入攻击的生命线同时也能提升性能。事务管理对于如“学生选课”需要同时更新学生选课表和课程余量这样的操作必须手动控制事务确保数据一致性。2.3 架构模式MVC的清晰边界这是本项目在代码组织上的灵魂。我们严格采用MVCModel-View-Controller模式让代码各司其职Model模型包含实体类POJO和数据访问层DAO。实体类就是学生、课程等对象的JavaBeanDAO负责所有数据库交互。View视图就是我们的Swing界面。它只负责展示数据和接收用户输入不应该出现任何SQL语句或业务逻辑。Controller控制器作为View和Model的桥梁。它监听View上的按钮点击、菜单选择等事件然后调用相应的ModelDAO进行业务处理最后将结果更新回View。这样的分离使得后期维护、功能扩展比如想把Swing界面换成JavaFX变得非常清晰和容易。3. 数据库设计与核心表结构解析任何系统的基石都是数据库。一个设计良好的表结构能让后续的开发事半功倍。我们设计一个精简但核心的教务模型主要包含以下四张表3.1 学生表 (student)CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY COMMENT 学号主键, name VARCHAR(50) NOT NULL COMMENT 姓名, gender CHAR(1) COMMENT 性别M/F, birth_date DATE COMMENT 出生日期, enrollment_date DATE NOT NULL COMMENT 入学日期, class_name VARCHAR(100) COMMENT 班级名称, phone VARCHAR(20) COMMENT 联系电话 ) COMMENT 学生信息表;注意student_id学号通常作为主键且为字符型因为可能包含字母。日期字段使用DATE类型便于进行日期相关的查询和计算。3.2 课程表 (course)CREATE TABLE course ( course_id VARCHAR(20) PRIMARY KEY COMMENT 课程号主键, course_name VARCHAR(100) NOT NULL COMMENT 课程名称, teacher VARCHAR(50) COMMENT 授课教师, credit DECIMAL(3,1) NOT NULL COMMENT 学分, total_hours INT COMMENT 总学时, capacity INT NOT NULL COMMENT 课程容量, selected INT DEFAULT 0 COMMENT 已选人数 ) COMMENT 课程信息表;注意这里引入了capacity容量和selected已选人数字段这是实现“选课”功能的关键。每次选课需检查selected capacity并在成功后对selected加1。3.3 成绩表 (score)CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 自增主键, student_id VARCHAR(20) NOT NULL COMMENT 学号, course_id VARCHAR(20) NOT NULL COMMENT 课程号, regular_score DECIMAL(5,2) COMMENT 平时成绩, final_score DECIMAL(5,2) COMMENT 期末成绩, total_score DECIMAL(5,2) GENERATED ALWAYS AS (COALESCE(regular_score*0.3, 0) COALESCE(final_score*0.7, 0)) STORED COMMENT 总评成绩平时30%期末70%, FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE, UNIQUE KEY uk_student_course (student_id, course_id) ) COMMENT 学生成绩表;核心技巧这里使用了计算列GENERATED ALWAYS AS来自动计算总评成绩业务逻辑在数据库层面就得到了保证应用代码只需读写regular_score和final_score即可。ON DELETE CASCADE外键约束确保了当学生或课程被删除时其成绩记录自动清理保证了数据完整性。3.4 用户表 (user)CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL COMMENT 用户名, password VARCHAR(255) NOT NULL COMMENT 加密后的密码, role ENUM(admin, teacher, student) NOT NULL COMMENT 角色 ) COMMENT 系统用户表;安全警告密码字段绝不允许明文存储即使是练习项目也要养成好习惯。我们应在Java代码中使用如BCrypt的强哈希算法进行加密后存储。role字段用于实现简单的权限控制。4. 核心模块实现详解从DAO到Swing界面联动有了清晰的设计我们就可以开始编码了。我们以“学生信息管理”模块为例串联起整个流程。4.1 实体类与基础DAO封装首先创建与学生表对应的实体类Student.java这是一个简单的JavaBean。然后我们编写一个基础的BaseDAO类封装获取连接、释放资源等通用操作所有具体DAO都继承它。// BaseDAO.java - 封装通用JDBC操作 public abstract class BaseDAO { // 从Hikari连接池获取连接 protected Connection getConnection() throws SQLException { return DataSourceConfig.getDataSource().getConnection(); } // 通用的关闭资源方法 protected void closeResources(Connection conn, PreparedStatement pstmt, ResultSet rs) { // 关闭顺序ResultSet - PreparedStatement - Connection (归还连接池) try { if (rs ! null) rs.close(); } catch (SQLException e) { e.printStackTrace();} try { if (pstmt ! null) pstmt.close(); } catch (SQLException e) { e.printStackTrace();} try { if (conn ! null) conn.close(); } // Hikari连接池的close()方法是归还连接 } // 一个通用的更新方法用于INSERT, UPDATE, DELETE protected int executeUpdate(String sql, Object... params) { Connection conn null; PreparedStatement pstmt null; try { conn getConnection(); pstmt conn.prepareStatement(sql); // 设置参数 for (int i 0; i params.length; i) { pstmt.setObject(i 1, params[i]); } return pstmt.executeUpdate(); } catch (SQLException e) { e.printStackTrace(); return -1; // 或抛出自定义异常 } finally { closeResources(conn, pstmt, null); } } }4.2 学生DAO实现接着创建StudentDAO.java接口及其实现类StudentDAOImpl.java继承BaseDAO实现具体的增删改查。// StudentDAO.java public interface StudentDAO { int insert(Student student); int deleteById(String studentId); int update(Student student); Student findById(String studentId); ListStudent findAll(); ListStudent findByName(String name); } // StudentDAOImpl.java public class StudentDAOImpl extends BaseDAO implements StudentDAO { Override public int insert(Student student) { String sql INSERT INTO student(student_id, name, gender, birth_date, enrollment_date, class_name, phone) VALUES(?,?,?,?,?,?,?); return executeUpdate(sql, student.getStudentId(), student.getName(), student.getGender(), student.getBirthDate(), // 这里需要处理java.util.Date到java.sql.Date的转换 student.getEnrollmentDate(), student.getClassName(), student.getPhone()); } Override public ListStudent findAll() { String sql SELECT * FROM student ORDER BY student_id; ListStudent list new ArrayList(); Connection conn null; PreparedStatement pstmt null; ResultSet rs null; try { conn getConnection(); pstmt conn.prepareStatement(sql); rs pstmt.executeQuery(); while (rs.next()) { Student student new Student(); // 从ResultSet中取出数据填充student对象... student.setStudentId(rs.getString(student_id)); student.setName(rs.getString(name)); // ... 设置其他字段 list.add(student); } } catch (SQLException e) { e.printStackTrace(); } finally { closeResources(conn, pstmt, rs); } return list; } // 其他方法类似实现... }4.3 自定义TableModel连接JTable与数据库这是Swing显示数据库数据的核心。我们需要创建一个自定义的StudentTableModel继承AbstractTableModel。public class StudentTableModel extends AbstractTableModel { private final String[] columnNames {学号, 姓名, 性别, 出生日期, 入学日期, 班级, 电话}; private ListStudent data; public StudentTableModel() { this.data new ArrayList(); } // 从数据库加载数据并刷新表格 public void loadData() { StudentDAO dao new StudentDAOImpl(); this.data dao.findAll(); fireTableDataChanged(); // 通知JTable数据已更新必须调用 } Override public int getRowCount() { return data.size(); } Override public int getColumnCount() { return columnNames.length; } Override public String getColumnName(int column) { return columnNames[column]; } Override public Object getValueAt(int rowIndex, int columnIndex) { Student student data.get(rowIndex); switch (columnIndex) { case 0: return student.getStudentId(); case 1: return student.getName(); case 2: return student.getGender(); case 3: return student.getBirthDate(); // 注意日期格式化显示 case 4: return student.getEnrollmentDate(); case 5: return student.getClassName(); case 6: return student.getPhone(); default: return null; } } }4.4 视图与控制器的构建StudentPanel现在我们创建一个StudentPanel继承JPanel作为学生管理的主界面。它包含一个JTable使用我们的StudentTableModel、几个按钮新增、编辑、删除、查询和输入框。public class StudentPanel extends JPanel { private StudentTableModel tableModel; private JTable studentTable; private JButton btnAdd, btnEdit, btnDelete, btnRefresh, btnQuery; private JTextField txtQueryName; public StudentPanel() { initUI(); initController(); loadTableData(); } private void initUI() { setLayout(new BorderLayout()); // 创建表格 tableModel new StudentTableModel(); studentTable new JTable(tableModel); studentTable.setRowHeight(25); studentTable.getTableHeader().setFont(new Font(微软雅黑, Font.BOLD, 14)); JScrollPane scrollPane new JScrollPane(studentTable); add(scrollPane, BorderLayout.CENTER); // 创建工具栏 JPanel toolBar new JPanel(new FlowLayout(FlowLayout.LEFT)); btnAdd new JButton(新增); btnEdit new JButton(编辑); btnDelete new JButton(删除); btnRefresh new JButton(刷新); toolBar.add(btnAdd); toolBar.add(btnEdit); toolBar.add(btnDelete); toolBar.add(btnRefresh); // 创建查询面板 JPanel queryPanel new JPanel(new FlowLayout(FlowLayout.LEFT)); queryPanel.add(new JLabel(姓名查询:)); txtQueryName new JTextField(15); btnQuery new JButton(查询); queryPanel.add(txtQueryName); queryPanel.add(btnQuery); JPanel northPanel new JPanel(new BorderLayout()); northPanel.add(toolBar, BorderLayout.WEST); northPanel.add(queryPanel, BorderLayout.EAST); add(northPanel, BorderLayout.NORTH); } private void initController() { // 刷新按钮事件 btnRefresh.addActionListener(e - loadTableData()); // 新增按钮事件弹出一个自定义的对话框StudentDialog来输入信息 btnAdd.addActionListener(e - { StudentDialog dialog new StudentDialog((Frame) SwingUtilities.getWindowAncestor(this), 新增学生, null); dialog.setVisible(true); if (dialog.isConfirmed()) { Student newStudent dialog.getStudent(); StudentDAO dao new StudentDAOImpl(); if (dao.insert(newStudent) 0) { JOptionPane.showMessageDialog(this, 新增成功); loadTableData(); // 刷新表格 } else { JOptionPane.showMessageDialog(this, 新增失败, 错误, JOptionPane.ERROR_MESSAGE); } } }); // 编辑按钮事件获取表格选中行弹出对话框编辑 btnEdit.addActionListener(e - { int selectedRow studentTable.getSelectedRow(); if (selectedRow -1) { JOptionPane.showMessageDialog(this, 请先选择一条记录进行编辑); return; } String studentId (String) tableModel.getValueAt(selectedRow, 0); StudentDAO dao new StudentDAOImpl(); Student studentToEdit dao.findById(studentId); if (studentToEdit ! null) { StudentDialog dialog new StudentDialog((Frame) SwingUtilities.getWindowAncestor(this), 编辑学生, studentToEdit); dialog.setVisible(true); if (dialog.isConfirmed()) { Student updatedStudent dialog.getStudent(); if (dao.update(updatedStudent) 0) { JOptionPane.showMessageDialog(this, 更新成功); loadTableData(); } } } }); // 删除按钮事件 btnDelete.addActionListener(e - { int selectedRow studentTable.getSelectedRow(); if (selectedRow -1) { JOptionPane.showMessageDialog(this, 请先选择一条记录进行删除); return; } String studentId (String) tableModel.getValueAt(selectedRow, 0); int confirm JOptionPane.showConfirmDialog(this, 确定要删除学号为 studentId 的学生吗, 确认删除, JOptionPane.YES_NO_OPTION); if (confirm JOptionPane.YES_OPTION) { StudentDAO dao new StudentDAOImpl(); if (dao.deleteById(studentId) 0) { JOptionPane.showMessageDialog(this, 删除成功); loadTableData(); } } }); // 查询按钮事件 btnQuery.addActionListener(e - { String name txtQueryName.getText().trim(); StudentDAO dao new StudentDAOImpl(); ListStudent result dao.findByName(name); tableModel.setData(result); // 需要在StudentTableModel中实现一个setData方法 tableModel.fireTableDataChanged(); }); } private void loadTableData() { tableModel.loadData(); // 调用TableModel的loadData方法它会从数据库加载并自动刷新UI } }在这个StudentPanel中initUI方法纯粹构建界面ViewinitController方法则扮演了Controller的角色。它监听按钮事件调用StudentDAOModel进行业务操作并根据操作结果更新StudentTableModelView的数据源最终驱动界面刷新。这就是一个完整的MVC在Swing中的实现。5. 关键功能实现与避坑指南5.1 选课功能的并发控制与事务处理选课是教务系统的核心也是并发问题的典型场景。假设学生A和学生B同时选择最后一门课如果不加控制可能导致超选。我们需要在数据库层面和代码层面双重保障。数据库层面在course表中我们通过capacity和selected字段进行控制。选课SQL应该是一个原子操作UPDATE course SET selected selected 1 WHERE course_id ? AND selected capacity;这条SQL语句在执行时会加锁如果更新行数为0说明课程已满选课失败。代码层面事务一次成功的选课需要做两件事1. 更新课程已选人数2. 在score表中插入一条初始记录成绩为空。这两步必须在一个事务中完成。public boolean selectCourse(String studentId, String courseId) { Connection conn null; PreparedStatement pstmtUpdateCourse null; PreparedStatement pstmtInsertScore null; try { conn getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 尝试更新课程人数 String sqlUpdate UPDATE course SET selected selected 1 WHERE course_id ? AND selected capacity; pstmtUpdateCourse conn.prepareStatement(sqlUpdate); pstmtUpdateCourse.setString(1, courseId); int rowsAffected pstmtUpdateCourse.executeUpdate(); if (rowsAffected 0) { conn.rollback(); // 课程已满回滚 return false; } // 2. 插入选课记录 String sqlInsert INSERT INTO score(student_id, course_id) VALUES(?, ?); pstmtInsertScore conn.prepareStatement(sqlInsert); pstmtInsertScore.setString(1, studentId); pstmtInsertScore.setString(2, courseId); pstmtInsertScore.executeUpdate(); conn.commit(); // 提交事务 return true; } catch (SQLException e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { // 关闭资源注意关闭顺序和异常处理 closeResources(null, pstmtUpdateCourse, null); closeResources(null, pstmtInsertScore, null); try { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException e) { e.printStackTrace(); } } }5.2 密码安全存储绝对不要在数据库中存储明文密码。我们使用BCrypt算法它是目前公认最安全的密码哈希算法之一。// 添加依赖 (如Maven: org.mindrot:jbcrypt:0.4) import org.mindrot.jbcrypt.BCrypt; public class PasswordUtil { // 生成加密后的密码 public static String hashPassword(String plainPassword) { return BCrypt.hashpw(plainPassword, BCrypt.gensalt()); } // 验证密码 public static boolean checkPassword(String plainPassword, String hashedPassword) { return BCrypt.checkpw(plainPassword, hashedPassword); } } // 在用户注册或修改密码时 String hashedPwd PasswordUtil.hashPassword(rawPassword); // 将hashedPwd存入数据库 // 在用户登录时 User user userDao.findByUsername(username); if (user ! null PasswordUtil.checkPassword(inputPassword, user.getPassword())) { // 登录成功 }5.3 使用FlatLaf美化界面原生Swing界面确实缺乏吸引力。使用FlatLaf库可以瞬间让界面现代化。// 在主程序启动时main方法的第一行设置 import com.formdev.flatlaf.FlatIntelliJLaf; // ... public static void main(String[] args) { // 设置现代化外观 try { UIManager.setLookAndFeel(new FlatIntelliJLaf()); } catch (Exception ex) { System.err.println(Failed to initialize LaF); } // 启动你的主窗口 SwingUtilities.invokeLater(() - { new MainFrame().setVisible(true); }); }5.4 常见问题与调试技巧界面卡顿或无响应所有耗时的数据库操作如loadData()绝对不能在Swing的事件分发线程EDT中直接执行。必须使用SwingWorker。btnRefresh.addActionListener(e - { new SwingWorkerListStudent, Void() { Override protected ListStudent doInBackground() throws Exception { // 在后台线程执行耗时操作 StudentDAO dao new StudentDAOImpl(); return dao.findAll(); } Override protected void done() { try { ListStudent result get(); tableModel.setData(result); tableModel.fireTableDataChanged(); } catch (Exception ex) { ex.printStackTrace(); JOptionPane.showMessageDialog(StudentPanel.this, 加载数据失败, 错误, JOptionPane.ERROR_MESSAGE); } } }.execute(); });中文乱码问题确保数据库连接URL指定了字符集如jdbc:mysql://localhost:3306/edu_db?useUnicodetruecharacterEncodingUTF-8。同时检查你的IDE、文件编码和数据库表的字符集是否统一为UTF-8。日期处理Java中的java.util.Date与数据库中的java.sql.Date需要转换。可以使用java.time包Java 8的LocalDate并通过Date.valueOf(LocalDate)和resultSet.getDate(...).toLocalDate()进行转换这是更现代和推荐的做法。6. 项目打包与部署生成可独立运行的JAR开发完成后我们需要将项目打包成一个可执行的JAR文件方便分发和运行。6.1 使用Maven进行打包如果你的项目使用Maven管理在pom.xml中配置maven-assembly-plugin或maven-shade-plugin来创建包含所有依赖的“胖JAR”。build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.2.4/version executions execution phasepackage/phase goals goalshade/goal /goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer !-- 指定主类即包含main方法的那个类 -- mainClasscom.yourcompany.edusys.MainApp/mainClass /transformer /transformers /configuration /execution /executions /plugin /plugins /build然后运行mvn clean package在target目录下会生成一个*-shaded.jar文件。6.2 处理外部配置文件数据库连接信息如URL、用户名、密码不应该硬编码在代码里。应该放在一个外部的配置文件如config.properties中。# config.properties db.urljdbc:mysql://localhost:3306/edu_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/Shanghai db.usernameroot db.passwordyour_password db.pool.size10在代码中读取Properties props new Properties(); try (InputStream input MainApp.class.getClassLoader().getResourceAsStream(config.properties)) { props.load(input); String url props.getProperty(db.url); // ... 初始化连接池 } catch (IOException e) { // 处理异常或使用默认值 }6.3 最终运行确保目标机器上安装了合适版本的Java运行时环境JRE。然后通过命令行运行java -jar your-education-system-shaded.jar如果一切配置正确你的教务管理系统窗口就会弹出来。你可以将这个JAR文件和配置文件一起打包分发给最终用户。走完这一整套流程你收获的不仅仅是一个“能跑”的教务管理系统。你深入实践了Java桌面应用从设计、编码、调试到打包部署的全过程理解了MVC架构如何落地掌握了JDBC编程的最佳实践并学会了如何让Swing界面变得美观实用。这些经验是任何书本或视频教程都无法替代的。当你下次再面对一个更复杂的项目时你会发现很多难题的解决思路早已在这次实践中埋下了种子。