简介:这是一套面向Java Web开发初学者与课程设计实践者的学生成绩管理系统进阶版完整工程,基于主流企业级技术栈实现,覆盖用户管理、课程维护、成绩录入与统计分析等核心教务场景。资源采用Spring + SpringMVC + MyBatis整合架构,前端依托LayUI构建响应式界面,Freemarker作为模板引擎实现动态页面渲染,MySQL承载数据存储,具备良好的分层结构与可扩展性。压缩包共524个文件,包含57个Java业务类(如StudentController、ScoreServiceImpl等)、109个XML配置与映射文件、62个JS交互脚本、40个HTML视图页及150个LayUI配套GIF图标资源,整体体积3.82MB,结构清晰、模块职责分明。目前已有870人学习下载,提供开箱即用的完整源码、数据库SQL脚本、Excel导入导出工具类及标准化RESTful接口设计,助开发者快速掌握SSM全栈开发流程与真实项目工程规范。
1. 项目缘起:从“玩具”到“工具”的蜕变
几年前,我接手了一个给学校做的学生成绩管理系统。最初的版本,怎么说呢,就是个典型的“课程设计”水平——一个Student类,一个Score类,用ArrayList存数据,所有逻辑都写在main方法里,增删改查全靠Scanner在控制台敲命令。功能是实现了,但代码结构一团乱麻,数据一关程序就丢,更别提什么并发安全和用户体验了。后来,随着项目需求的迭代(比如要支持Web端、要对接教务系统、要生成复杂的统计报表),我不得不对那个最初的“玩具”进行了一次彻头彻尾的重构,最终形成了一个结构清晰、易于维护和扩展的“工具级”系统。
今天,我想分享的正是这个“进阶版”学生成绩管理系统的全部Java源代码以及背后的设计思考。这不仅仅是一堆可以运行的代码,更是一套如何将一个小型课程设计项目,通过合理的架构设计、设计模式应用和工程化实践,升级为一个具备实用价值的小型系统的完整范例。无论你是正在学习Java、苦于课程设计没有思路的学生,还是刚入行、想了解一个真实小项目该如何搭建的初级开发者,相信这份代码和其中的“踩坑”经验都能给你带来实实在在的启发。
2. 架构演进:从面条代码到分层架构
最初的版本,所有代码都堆在一起,我们称之为“面条式代码”。这种代码的维护成本极高。进阶版的核心改进,首先就体现在架构上。
2.1 为什么选择分层架构?
分层架构(Layered Architecture)是一种最经典、也最易于理解和实施的架构模式。它将系统按职责横向切分,每一层只与相邻的上下两层通信,上层依赖下层提供的服务,下层对上层无感知。这样做的好处非常明显:
- 解耦与高内聚:数据访问逻辑、业务逻辑、展示逻辑被分离到不同的层中,修改某一层(比如将数据库从MySQL换成PostgreSQL)不会波及其他层。
- 易于分工协作:后端开发可以专注于
Service和DAO层,前端或客户端开发可以基于定义好的接口进行,并行开发效率高。 - 可测试性增强:可以对每一层进行独立的单元测试。例如,测试业务逻辑层时,可以用Mock对象模拟数据访问层,无需启动真实的数据库。
在我们的学生成绩管理系统中,我采用了经典的四层架构:
- 表现层 (Presentation Layer):负责接收用户请求、展示数据。在进阶版中,我们提供了两种实现:基于Swing的图形界面(GUI)和基于Spring Boot的RESTful API(为可能的Web前端或移动端预留接口)。
- 业务逻辑层 (Service Layer):系统的核心,包含所有的业务规则和流程。例如,“计算学生平均分”、“判断成绩是否有效(0-100分)”、“生成成绩单”等逻辑都在这里。
- 数据访问层 (Data Access Layer, DAO):封装所有对数据库(或其他持久化存储)的操作,如增、删、改、查。业务层通过调用DAO接口来存取数据,而不需要关心数据具体存在哪里、怎么存的。
- 领域模型层 (Domain Model Layer):也可以叫实体层(Entity Layer)。它包含系统核心的业务对象,如
Student(学生)、Course(课程)、Score(成绩)。这些是贯穿所有层的“血液”。
2.2 核心领域模型设计
领域模型是系统的基石,设计得好,后续开发会顺畅很多。我们系统中有三个核心实体:
1. Student(学生)
public class Student { private String studentId; // 学号,唯一标识 private String name; private String gender; private LocalDate birthDate; // 使用Java 8的日期API private String className; // 班级 // 省略Getter/Setter和构造方法 }注意:
studentId(学号)被设计为业务主键,它本身具有业务含义(如202301001)。同时,在数据库表中我们通常还会有一个无业务意义的自增id作为物理主键,用于建立外键关联,性能更好。这是一个常见的实践。
2. Course(课程)
public class Course { private String courseId; // 课程号 private String courseName; private Double credit; // 学分 private Integer courseHour; // 学时 // 省略Getter/Setter }3. Score(成绩)这是关联Student和Course的实体,是多对多关系的中间表。
public class Score { private Long id; // 物理主键 private String studentId; // 关联学生学号 private String courseId; // 关联课程号 private Double score; // 成绩 private LocalDateTime examDate; // 考试时间 // 省略Getter/Setter }心得:在
Score中存储studentId和courseId,而不是Student和Course对象的引用,这是一种“弱关联”设计。这保证了Score对象的独立性,即使学生或课程信息被软删除或修改,成绩记录依然存在且可追溯。在需要完整信息时,通过DAO层进行关联查询。
2.3 数据访问层的抽象:DAO模式与接口编程
数据访问层是变化可能最多的一层。今天我们用MySQL,明天可能换H2做测试,后天可能接入Redis做缓存。如何应对这种变化?答案是:接口与实现分离。
我们为每个实体定义一个DAO接口,规定它必须提供哪些数据操作方法。
public interface StudentDao { Student findById(String studentId); List<Student> findAll(); boolean save(Student student); boolean update(Student student); boolean delete(String studentId); List<Student> findByName(String name); // 按姓名模糊查询 }然后,提供不同的实现类。例如,StudentDaoJdbcImpl使用原生JDBC操作MySQL,StudentDaoJpaImpl使用JPA(Hibernate)框架。在业务逻辑层,我们只依赖StudentDao这个接口。
public class StudentServiceImpl { private StudentDao studentDao; // 依赖接口,而非具体实现 // 通过构造器或Setter注入具体的Dao实现 public StudentServiceImpl(StudentDao dao) { this.studentDao = dao; } public void addStudent(Student student) { // 业务校验 if (studentDao.findById(student.getStudentId()) != null) { throw new RuntimeException("学号已存在!"); } // 调用DAO保存 studentDao.save(student); } }这种做法的好处是,更换数据库或ORM框架时,业务逻辑层的代码一行都不用改,只需要提供一个新的DAO实现并在组装程序时“注入”进去即可。这是“依赖倒置”原则(DIP)和“开闭原则”(OCP)的典型应用。
3. 关键技术实现与“踩坑”实录
有了好的架构,接下来就是填充每一层的具体实现。这里我挑几个容易出问题、又有代表性的技术点展开。
3.1 数据库连接管理:从DriverManager到连接池
初学JDBC,我们习惯这样写:
Connection conn = DriverManager.getConnection(url, user, password); // ... 执行SQL conn.close();这在单线程、低频访问的演示程序中没问题。但在任何严肃的应用中,这都是灾难。频繁创建和关闭物理连接是极其昂贵的操作,会导致性能瓶颈。
解决方案:使用数据库连接池。连接池在启动时就创建一定数量的连接放在“池”里,程序使用时从池中借用,用完后归还,避免了重复创建的开销。进阶版中,我选择了HikariCP,它是目前公认性能最好的JDBC连接池之一。
集成HikariCP的“坑”与技巧:
- 依赖配置:需要在
pom.xml(Maven)或build.gradle中正确引入HikariCP的依赖。 - 配置参数:
maximumPoolSize(最大连接数)不是越大越好,通常建议是CPU核心数 * 2 + 有效磁盘数。设置过大反而会增加上下文切换和锁竞争的开销。connectionTimeout(获取连接超时时间)和idleTimeout(连接空闲超时)也需要根据实际场景调整。 - 资源关闭:务必在
finally块或使用try-with-resources语句确保Connection,Statement,ResultSet被正确关闭,否则连接不会归还到池中,导致连接泄漏。// 推荐做法:try-with-resources try (Connection conn = dataSource.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { // ... 设置参数,执行查询 try (ResultSet rs = pstmt.executeQuery()) { // ... 处理结果集 } } catch (SQLException e) { // 处理异常 }
3.2 事务管理:确保数据一致性
一个业务操作可能涉及多次数据库写操作。例如,“录入一门新课的成绩”这个业务,至少需要两步:1. 检查课程是否存在;2. 插入成绩记录。如果第二步失败了,整个操作应该回滚,否则就会留下脏数据(一门不存在的课程的成绩记录)。
在只有JDBC的情况下,我们需要手动管理事务:
Connection conn = null; try { conn = dataSource.getConnection(); conn.setAutoCommit(false); // 1. 开启事务 // 2. 执行多个DAO操作 courseDao.insert(course); scoreDao.batchInsert(scores); conn.commit(); // 3. 提交事务 } catch (SQLException e) { if (conn != null) { try { conn.rollback(); // 4. 回滚事务 } catch (SQLException ex) { // 记录日志 } } throw e; } finally { // 5. 恢复自动提交并关闭连接 if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { // 记录日志 } } }手动管理非常繁琐且容易出错,比如忘了setAutoCommit(false),或者异常处理不完整导致连接未正确关闭。
进阶方案:声明式事务。在Spring框架中,我们可以使用@Transactional注解,轻松地将一个方法标记为事务性的。框架会为我们自动处理事务的开启、提交、回滚和资源清理。这是进阶版Spring Boot API模块采用的方式,极大地简化了代码,降低了出错概率。
@Service public class ScoreServiceImpl implements ScoreService { @Autowired private CourseDao courseDao; @Autowired private ScoreDao scoreDao; @Transactional(rollbackFor = Exception.class) // 声明此方法需要事务 public void enterScoresForNewCourse(Course course, List<Score> scores) { // 业务校验... courseDao.save(course); scoreDao.batchSave(scores); // 如果任何一步抛出异常,所有数据库操作都会回滚 } }3.3 业务逻辑层的复杂计算与报表生成
业务逻辑层是体现系统价值的地方。除了简单的CRUD,更复杂的是各种计算和报表。
例如,计算学生的加权平均分(GPA):
public Double calculateWeightedGPA(String studentId) { List<ScoreDetail> details = scoreDao.findScoresWithCourseByStudent(studentId); if (details.isEmpty()) { return 0.0; } double totalWeightedScore = 0.0; double totalCredits = 0.0; for (ScoreDetail detail : details) { Double score = detail.getScore(); Double credit = detail.getCourseCredit(); if (score != null && credit != null) { // 将百分制成绩转换为绩点(例如:90-100 -> 4.0, 80-89 -> 3.0, 以此类推) double gradePoint = convertScoreToGradePoint(score); totalWeightedScore += gradePoint * credit; totalCredits += credit; } } if (totalCredits == 0) { return 0.0; } return totalWeightedScore / totalCredits; } private double convertScoreToGradePoint(double score) { // 实现具体的转换规则 if (score >= 90) return 4.0; else if (score >= 80) return 3.0; else if (score >= 70) return 2.0; else if (score >= 60) return 1.0; else return 0.0; }注意:这里的
ScoreDetail可能是一个DTO(Data Transfer Object),它包含了Score的成绩值和关联的Course的学分信息,由DAO层通过关联查询一次性返回,避免了在循环中频繁查询数据库(N+1查询问题)。
报表生成(如班级成绩单)的优化:生成报表往往涉及大量数据的聚合查询。绝对要避免在Java内存中进行大量数据的循环过滤和计算。应该把计算压力转移到数据库。
原始的、低效的做法:
// 错误示范:将所有成绩取到内存,再用Java过滤 List<Score> allScores = scoreDao.findAll(); Map<String, List<Score>> classScoresMap = new HashMap<>(); for (Score s : allScores) { // 1. 根据studentId查学生信息拿到班级 Student stu = studentDao.findById(s.getStudentId()); // 2. 按班级分组... }这个过程会产生巨量的数据库查询(N+1问题),性能极差。
正确的做法:在DAO层编写复杂的SQL(或使用JPA的Criteria API)进行关联查询和聚合。
// 在ScoreDao接口中增加方法 public interface ScoreDao { // 查询指定班级、指定课程的所有学生成绩,并包含学生姓名 List<ClassScoreReport> findClassScores(String className, String courseId); }对应的SQL可能类似于:
SELECT s.name, sc.score, c.course_name FROM student s JOIN score sc ON s.student_id = sc.student_id JOIN course c ON sc.course_id = c.course_id WHERE s.class_name = ? AND sc.course_id = ? ORDER BY sc.score DESC;这样,数据库一次查询就返回了所有需要的数据,在Java层只需要做简单的格式转换和输出即可。
4. 前端展示:Swing GUI的设计与优化
为了提供一个可独立运行的桌面应用,我使用Java Swing实现了图形界面。虽然Swing现在看来有些“古老”,但它对于理解桌面应用的事件驱动模型和MVC模式非常有帮助。
4.1 采用MVC模式组织Swing代码
在Swing中,很容易把所有代码都写在JFrame或JPanel的监听器里,导致界面和逻辑再次耦合。我们应用前面提到的分层思想,在表现层内部也做一次MVC分离:
- Model(模型):就是我们的领域对象(
Student,Course等)和业务逻辑层(Service)。 - View(视图):Swing的各个组件(
JFrame,JTable,JTextField等),负责展示数据和接收用户输入。 - Controller(控制器):通常是实现了
ActionListener,MouseListener等的类,它负责响应用户在View上的操作,调用Model(Service层)处理业务,然后更新View。
例如,学生列表界面:
// View: StudentListView (一个JPanel) public class StudentListView extends JPanel { private JTable studentTable; private StudentTableModel tableModel; // 自定义的TableModel private JButton addButton, deleteButton; private StudentController controller; // 持有控制器引用 public StudentListView(StudentController controller) { this.controller = controller; initUI(); bindEvents(); } private void initUI() { /* 初始化表格、按钮等组件 */ } private void bindEvents() { addButton.addActionListener(e -> controller.handleAddStudent()); deleteButton.addActionListener(e -> { int row = studentTable.getSelectedRow(); if (row >= 0) { String studentId = (String) tableModel.getValueAt(row, 0); controller.handleDeleteStudent(studentId); } }); } public void refreshTable(List<Student> students) { tableModel.setData(students); } } // Controller: StudentController public class StudentController { private StudentService studentService; private StudentListView view; public StudentController(StudentService service, StudentListView view) { this.studentService = service; this.view = view; } public void handleAddStudent() { // 1. 弹出对话框收集学生信息(这可以是一个新的View) StudentDialog dialog = new StudentDialog(); dialog.setVisible(true); if (dialog.isConfirmed()) { Student newStudent = dialog.getStudent(); try { // 2. 调用Service层 studentService.addStudent(newStudent); // 3. 操作成功,刷新视图 loadAndDisplayStudents(); JOptionPane.showMessageDialog(view, "添加成功!"); } catch (Exception e) { JOptionPane.showMessageDialog(view, "添加失败: " + e.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); } } } public void handleDeleteStudent(String studentId) { // 确认对话框 int confirm = JOptionPane.showConfirmDialog(view, "确定删除学号为 " + studentId + " 的学生吗?", "确认删除", JOptionPane.YES_NO_OPTION); if (confirm == JOptionPane.YES_OPTION) { try { studentService.deleteStudent(studentId); loadAndDisplayStudents(); JOptionPane.showMessageDialog(view, "删除成功!"); } catch (Exception e) { JOptionPane.showMessageDialog(view, "删除失败: " + e.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); } } } private void loadAndDisplayStudents() { List<Student> students = studentService.getAllStudents(); view.refreshTable(students); } }通过这种方式,视图只关心显示和事件触发,控制器负责协调,业务逻辑完全交给Service层,代码清晰,职责分明。
4.2 使用JTable与自定义TableModel
Swing的JTable默认使用DefaultTableModel,它要求数据是Vector或Object[][],这和我们面向对象的领域模型不匹配。更好的做法是自定义AbstractTableModel。
public class StudentTableModel extends AbstractTableModel { private List<Student> studentList; private final String[] columnNames = {"学号", "姓名", "性别", "出生日期", "班级"}; public void setData(List<Student> students) { this.studentList = new ArrayList<>(students); // 防御性拷贝 fireTableDataChanged(); // 通知表格数据已更新,重绘 } @Override public int getRowCount() { return studentList == null ? 0 : studentList.size(); } @Override public int getColumnCount() { return columnNames.length; } @Override public Object getValueAt(int rowIndex, int columnIndex) { Student student = studentList.get(rowIndex); switch (columnIndex) { case 0: return student.getStudentId(); case 1: return student.getName(); case 2: return student.getGender(); case 3: return student.getBirthDate(); // 直接返回LocalDate,渲染器会处理 case 4: return student.getClassName(); default: return null; } } @Override public String getColumnName(int column) { return columnNames[column]; } // 如果需要编辑单元格,可以重写setValueAt和isCellEditable方法 }使用自定义TableModel后,JTable就能直接绑定我们的Student对象列表,数据更新时只需调用setData并触发fireTableDataChanged,非常方便。
4.3 多线程与SwingWorker:避免界面卡死
在Swing中,所有UI组件的创建、更新都必须在事件分发线程(Event Dispatch Thread, EDT)上进行。如果你在按钮的监听器里执行一个耗时的数据库查询或网络请求,EDT就会被阻塞,整个界面就会“卡死”,用户无法进行任何操作。
解决方案:使用SwingWorker。SwingWorker是Swing提供的专门用于在后台执行耗时任务,并在完成后安全更新UI的类。
// 在Controller中 public void handleLoadData() { // 禁用按钮,显示加载中提示 view.setLoadButtonEnabled(false); view.showLoadingIndicator(true); SwingWorker<List<Student>, Void> worker = new SwingWorker<>() { @Override protected List<Student> doInBackground() throws Exception { // 在后台线程执行耗时操作 return studentService.getAllStudents(); // 假设这个操作很慢 } @Override protected void done() { // 在EDT线程执行,可以安全更新UI view.showLoadingIndicator(false); view.setLoadButtonEnabled(true); try { List<Student> result = get(); // 获取doInBackground的返回结果 view.refreshTable(result); } catch (InterruptedException | ExecutionException e) { JOptionPane.showMessageDialog(view, "加载数据失败: " + e.getCause().getMessage(), "错误", JOptionPane.ERROR_MESSAGE); } } }; worker.execute(); // 启动后台任务 }这个模式非常重要,它能显著提升桌面应用的响应性和用户体验。
5. 工程化与部署考量
一个可用的系统,除了核心功能,还需要考虑很多工程化细节。
5.1 配置文件管理
不要把数据库连接字符串、用户名密码等硬编码在Java文件里!使用配置文件(如.properties或.yml)。
# config.properties db.url=jdbc:mysql://localhost:3306/student_management?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai db.username=root db.password=your_secure_password db.pool.maximumPoolSize=10在代码中通过java.util.Properties或ResourceBundle读取。在Spring Boot中,则可以使用application.yml和@Value注解或@ConfigurationProperties。
5.2 日志记录
使用System.out.println调试和记录日志是极不专业的。应该使用成熟的日志框架,如SLF4J + Logback。
- 引入依赖。
- 配置
logback.xml,可以灵活控制日志级别(DEBUG, INFO, WARN, ERROR)、输出格式、输出目的地(控制台、文件)。 - 在代码中使用
LoggerFactory获取Logger实例。
import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class StudentServiceImpl { private static final Logger LOGGER = LoggerFactory.getLogger(StudentServiceImpl.class); public void addStudent(Student student) { LOGGER.info("开始添加学生,学号:{}", student.getStudentId()); try { // ... 业务逻辑 LOGGER.info("学生[{}]添加成功。", student.getStudentId()); } catch (Exception e) { LOGGER.error("添加学生[{}]时发生异常:", student.getStudentId(), e); throw e; } } }良好的日志是线上问题排查的生命线。
5.3 异常处理策略
不要生吞异常(catch了什么都不做),也不要只打印栈轨迹。应该定义清晰的业务异常体系,并在合适的层面处理。
- DAO层:捕获
SQLException,将其转换为自定义的DataAccessException(一个运行时异常)并向上抛出。这样业务层就不需要处理检查型异常。 - Service层:处理业务规则校验,校验不通过时抛出自定义的
BusinessException,如StudentNotFoundException,DuplicateStudentIdException。 - 表现层(Controller或GUI事件处理器):捕获所有异常,根据异常类型,友好地提示用户(例如,“学号已存在,请检查”),并记录错误日志。
5.4 打包与分发
对于Swing桌面应用,最终我们需要将它打包成一个可执行的JAR文件。可以使用Maven的maven-assembly-plugin或maven-shade-plugin来创建包含所有依赖的“胖JAR”(Uber JAR)。
<!-- 在pom.xml中配置 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.2.4</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <!-- 指定主类 --> <mainClass>com.yourcompany.studentmgmt.MainApp</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin>执行mvn clean package后,会在target目录下生成一个*-shaded.jar文件,用户只需安装JRE,即可通过java -jar your-app-shaded.jar命令运行。
对于Spring Boot的Web API部分,打包就更简单了,它内置的插件会直接生成可执行的JAR或WAR文件。
6. 从“实现”到“设计”的思考
完成这个项目后,我最大的体会是,编程不仅仅是让代码跑起来,更是让代码“活”得久、改得动。进阶版与最初版的区别,本质上是从“面向过程实现功能”到“面向对象设计系统”的思维转变。
- 单一职责原则:每个类、每个方法只做一件事。
StudentDao只负责学生数据存取,StudentService只负责学生相关业务逻辑。 - 开闭原则:对扩展开放,对修改关闭。通过DAO接口,我们很容易扩展出
StudentDaoFileImpl(文件存储)而不修改业务层。 - 依赖倒置:高层模块(Service)不依赖低层模块(DAO实现),二者都依赖抽象(DAO接口)。
- 关注点分离:将数据持久化、业务逻辑、用户界面这些不同的“关注点”分离到不同的层中。
这些原则不是教条,而是无数前辈在应对软件复杂度时总结出的最佳实践。在项目开始时多花一点时间思考架构,在后续的维护和扩展中会节省十倍、百倍的时间。这个学生成绩管理系统进阶版的源代码,就是我尝试将这些理念付诸实践的一次完整记录。
本文还有配套的精品资源,点击获取