简介:面向刚学完JavaWeb基础、希望将MVC设计模式和动态代理模式落地实践的初学者,这套学生成绩管理系统源码包是合适的练手项目。压缩包内共271个文件,整体大小约23.41MB,既有78个jar依赖库、32个java源码和对应的class编译文件,也有22个jsp页面、20个xml配置,还附带用于初始化数据库的sql脚本和doc文档,资源类型覆盖后端逻辑、前端页面、配置与说明,目录结构清晰便于检索。系统围绕登录、课程、学生、成绩等核心模块展开,从StudentAction、ScoreAction等控制类中可以看到请求分发、业务处理和数据持久化的完整链路,对理解MVC分层与动态代理在事务控制中的应用很有帮助。目前已有6978人浏览学习,适合用作课程设计、毕业设计参考或项目实战练手,既可直接运行体验,也方便在此基础上二次开发。
1. JavaWeb 学生成绩管理系统:这份资源包里到底有什么
学生成绩管理系统是 JavaWeb 入门绕不开的经典案例。这份资源包把源码、数据库脚本、文档打包齐全,解压后能直接运行:四个 Action 控制器、三个 DaoImp 实现、Student 和 Major 实体类、Pager 分页类,MVC 分层完整。
它覆盖 JavaWeb 最核心的练手诉求:学生管理、课程管理、成绩录入查询、登录认证、分页列表。刚学完 Servlet、JSP、JDBC 的新手靠它把散装知识串成项目,准备 java 面试的人也能拿「登录校验、分页查询、DAO 封装」当项目经历讲。环境是 Java + MySQL,技术栈涉及 MVC 设计模式和动态代理,适合先拆开读懂每一层再动手改。
2. 从 MVC 到动态代理:类清单拆解与事务边界
2.1 先看类清单:Action、DaoImp、实体类各管什么
我拆项目有个习惯,先不看代码,把文件清单按职责排一遍。这份资源的类名起得很规矩,几乎不用看内容就能猜到每一层在干嘛。LoginAction、StudentAction、ScoreAction、CourseAction 是控制层入口,对应登录认证、学生管理、成绩管理、课程管理四个模块;三个 DaoImp 是数据层实现;Student、Major 是实体类;Pager 是分页工具类。
| 类名 | 所属层 | 核心职责 |
|---|---|---|
| LoginAction | 控制层 | 登录参数校验、会话写入、跳转 |
| StudentAction | 控制层 | 学生列表、新增、修改、删除 |
| ScoreAction | 控制层 | 成绩录入、条件查询 |
| CourseAction | 控制层 | 课程维护 |
| StudentDaoImp | 数据层 | student 表 CRUD |
| ScoreDaoImp | 数据层 | score 表 CRUD 与联表查询 |
| CourseDaoImp | 数据层 | course 表 CRUD |
| Student / Major | 实体层 | 表字段的 Java 映射 |
| Pager | 工具层 | 分页参数封装与起始下标计算 |
这个结构就是典型 MVC:JSP 当 View,Action 当 Controller,DaoImp 当 Model 的数据访问实现。Action 里不出现 SQL,DaoImp 里不出现 HttpSession,两层之间靠实体类传值。这样拆的好处是,后面加一个「按班级筛选成绩」的需求,改动范围基本锁死在 ScoreAction 和 ScoreDaoImp,不用把整个项目翻一遍。
对应的 java基础 知识点也全用上了:实体类是封装和构造器的练习,DaoImp 是 JDBC 和集合的练习,Action 是 Servlet API 的练习。所以我说这个项目适合当 java 面试的项目经历讲,因为每一层都能对应到一个你确实写过的具体类,而不是背出来的概念。你拿到手之后可以先打开 LoginAction 看一眼方法体,如果里面出现了 conn 和 sql 字符串,说明这个项目分层没做干净;正常情况应该是调一个 DAO 方法就完事,这本身就是检验资源质量的第一个标尺。
2.2 动态代理在项目里的落点:事务控制与日志埋点
摘要里写了动态代理模式,这是这套资源比普通增删改查项目值钱的地方。学生成绩管理系统里最容易出问题的是成绩录入:往 score 表插一条记录,可能同时要更新 student 表的统计字段或者 course 表的选课人数,两步操作必须在一个事务里,第二步失败第一步得回滚。
我在做类似项目时一般不会在每个 DaoImp 方法里手写事务开启和提交,那样代码会非常散。常见做法是抽一个事务代理,用 JDK 动态代理统一处理:代理对象拦截 DaoImp 的方法调用,方法执行前从连接池拿连接并关闭自动提交,执行成功提交,抛异常回滚。资源源码里既然标了动态代理,你可以在 src 目录下直接搜 Proxy.newProxyInstance 和 invoke 两个关键词,看它把事务边界放在了哪一层:
grep -rn "Proxy.newProxyInstance" src/ grep -rn "invoke" src/搜完你会发现动态代理的落点基本就两类:一类是事务,一类是日志。事务代理保证一个业务方法里的多条 SQL 要么全成功要么全回滚;日志代理在每个 DAO 方法前后打印参数和耗时,排查慢查询时特别好用。面试问「动态代理在你项目里怎么用的」,别回答「JDK 动态代理基于接口」这种八股,直接说「我用来给 DAO 层统一加事务,代理对象包住所有 DaoImp,进入时开启事务、异常回滚、成功提交」,这才是项目级的答法。
2.3 数据库脚本里的表关系:student、major、course、score 怎么关联
实体类里有 Student 和 Major,再加上课程和成绩,数据库脚本里大概率是四张表:major(专业)、student(学生)、course(课程)、score(成绩)。四张表是典型的「一对多 + 关联表」结构:一个专业下有多个学生,一个学生选修多门课程,每个学生每门课程有一条成绩记录。
设计上有几个点值得注意。major 单独建表而不是在学生表里直接写专业名字符串,是为了避免同一专业名在不同记录里写法不一致——「计算机科学与技术」和「计算机科学」在统计时就变成两个专业了。score 表用 student_id 加 course_id 定位一条成绩,粒度是一行代表「某个学生某门课的成绩」。这一点在导入脚本时就要想清楚:如果你后面要做「按专业统计平均分」这种报表,JOIN 路径是 score 到 student 再到 major,三层关联,索引怎么建提前心里有数。
资源脚本里的具体字段名我没法逐列背出来,但你用 Navicat 打开表结构后,按「主键、外键、唯一约束」三个维度把关系画出来,整个系统的数据流就清楚了。画完你会发现在这套表结构下,所有业务查询都逃不出学生维度、课程维度、专业维度三条线。
2.4 Pager 分页类:四个字段一个方法撑起所有列表
Pager.class 是这套系统里复用率最高的类。它一般封装四个参数:pageIndex 当前页码、pageSize 每页条数、totalCount 总记录数、totalPage 总页数,再加一个计算 SQL 起始下标的方法。
public class Pager { private int pageIndex; // 当前页码,用户看到的是从 1 开始 private int pageSize; // 每页条数,默认 10 private int totalCount; // 总记录数,来自 SELECT COUNT(*) private int totalPage; // 总页数,由 totalCount 和 pageSize 算出 public int getStartPos() { // 第一页 startPos = (1 - 1) * 10 = 0,从第 0 条开始取 return (pageIndex - 1) * pageSize; } public int getTotalPage() { // 11 条数据每页 5 条,结果是 2.2,向上取整得 3 页 return (totalCount + pageSize - 1) / pageSize; } }这里有两个易错点。第一,pageIndex 是给用户看的,从 1 开始;startPos 是给 SQL 的 LIMIT 用的,从 0 开始,两者永远差一个 pageSize,写成 pageIndex * pageSize 的话第一页就会漏掉前 10 条。第二,总页数必须向上取整,整数除法会把最后一页吞掉。资源里所有列表查询都走 Pager,改每页条数只动一个地方,这就是工具类存在的意义。
3. 把源码跑起来:MySQL 脚本导入与 Tomcat 部署的完整链路
3.1 环境准备:JDK、MySQL、Tomcat 的版本搭配
先说版本,这决定你会不会在第一步就翻车。这套资源是传统 JavaWeb 项目,Servlet + JSP + JDBC 技术栈,不是 Spring Boot,所以环境要按老规矩来:JDK 8 最稳,MySQL 5.7 或 8.0 都行,Tomcat 8.5 或 9。JDK 11 以上跑老项目偶尔会碰到 JSP 编译问题,能换回 8 就换回 8,省得把时间花在和环境较劲上。
java 环境配置 是第一个关卡。装完 JDK 要配 JAVA_HOME 和 PATH,命令行里能正常输出版本号才算就绪:
java -version mysql --version两个命令都有输出,说明基础环境到位。Tomcat 不用往系统里装,下载解压就行,后面 IDEA 里直接指定这个解压目录。
这里有个选型问题值得说一句:为什么不用 Spring Boot 一键启动?因为这个项目的定位是练手和面试复习,JDBC 原生连接、Servlet 生命周期、手动配置 Tomcat 这些恰恰是 Spring Boot 帮你隐藏掉的底层知识。把老项目跑起来的过程本身就是在复习这些点,所以我建议老老实实按传统方式部署一遍,别图省事。
3.2 导入数据库脚本:命令行与图形化两种姿势
资源包里的数据库脚本是一个 .sql 文件,建库、建表、插初始数据都在里面。导入前先确认 MySQL 服务起来了,然后命令行方式最直接:
# 方式一:登录后执行 source 命令 mysql -uroot -p你的密码 mysql> source D:/student_db.sql; # 方式二:不进入交互模式,直接重定向导入 mysql -uroot -p你的密码 < D:/student_db.sql导入完用 show tables 验证,能看到 student、course、score、major 这几张表(具体库名以脚本里的 CREATE DATABASE 语句为准)就说明执行成功。如果你平时用图形化工具,Navicat 里右键目标库 →「运行 SQL 文件」,选那个 .sql 文件同样效果,注意导入时文件编码选 UTF-8,不然初始数据里的中文会变成问号。
如果你后面改了表结构想留一份备份,IDEA 的 Database 面板里右键表 → SQL Scripts → Generate 可以导出当前库完整脚本。这比手动在原 .sql 文件上加字段靠谱,导出的就是你当前库的真实状态,不会漏约束。
3.3 改连接配置:db.properties 里的四个坑
项目里一般有一个数据库配置文件,可能叫 db.properties,也可能直接写在 JDBC 工具类里。资源带文档的话,文档会标清楚配置文件在哪。重点改四处:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/student_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=你的密码driver 这一行是 MySQL 8.0 的驱动类名,带 .cj;如果你用的是 MySQL 5.7 加 5.x 驱动,要改回 com.mysql.jdbc.Driver。url 里三个参数各管一件事:useSSL=false 关掉安全连接握手,不然控制台刷一堆警告;serverTimezone=Asia/Shanghai 解决驱动时区报错;characterEncoding=utf8 管中文写入不乱码。username 和 password 改成你本机 MySQL 的实际账号。
提示:改完配置先别启动 Tomcat,用一份最简单的 Java 代码或者 IDE 的 Database 面板测试连接,通了再进下一步。把问题隔离在配置层,比在 Tomcat 日志里猜快得多。
3.4 部署到 Tomcat:IDEA 里跑通第一个页面
打开 IDEA,用 Open 方式选择资源解压目录。老工程通常没有 pom.xml,是普通 Web 工程结构,需要手动添加 Tomcat 运行配置:Run → Edit Configurations → 左上角加号 → Tomcat Server → Local,Application server 选你解压的 Tomcat 目录。
然后切到 Deployment 页签,点加号选 Artifact,挑 war exploded 模式,Application context 改成 /student(具体值以资源文档为准,要和项目里的跳转路径对得上)。启动后浏览器访问:
http://localhost:8080/student/login.jsp能看到登录页说明部署成功。初始账号密码以文档为准,一般不复杂,登录后先点学生列表和成绩录入两个页面,这两个页面覆盖了分页查询和表单提交两条最核心的链路。跑通这两个页面,这套系统的八成功能就验证完了。
4. 登录校验与分页查询:两个核心模块的代码走读
4.1 LoginAction:登录请求的 MVC 完整链路
登录是整个系统的入口,把这条链路走一遍,MVC 在项目里长什么样就清楚了。用户在 login.jsp 输入账号密码提交,表单 POST 到 LoginAction,LoginAction 调 StudentDaoImp 查库,结果决定跳转还是留在登录页。核心逻辑是这样一段:
@WebServlet("/login") public class LoginAction extends HttpServlet { private StudentDaoImp studentDao = new StudentDaoImp(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); Student student = studentDao.findByUsernameAndPassword(username, password); if (student != null) { request.getSession().setAttribute("loginUser", student); response.sendRedirect(request.getContextPath() + "/student/list"); } else { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }逻辑分三段。第一段是字符编码,POST 表单的中文参数必须在这里先设置,否则后面全是乱码,这是老 JavaWeb 项目里最容易被忽略的一行。第二段是取参调 DAO,注意这里没有任何 SQL,查库细节被封装在 findByUsernameAndPassword 里,这就是分层的好处。第三段是分支跳转:成功就把用户对象塞进 session,用 sendRedirect 重定向,防止用户刷新页面时重复提交表单;失败用 forward 回登录页,error 属性携带提示信息。重定向和转发的区别在这个例子里非常直观,一个改变地址栏且发新请求,一个在服务端内部转交请求,java 面试题里常考这个对比。
密码字段的处理方式值得看一眼源码:常见做法是数据库里存 MD5 或加盐后的摘要,登录时把明文密码加密后再比对。资源里具体怎么存的,看 StudentDaoImp 里密码相关的语句就知道,不建议明文存。
4.2 Pager 分页:limit 起始下标到底怎么算
列表页是这套系统最典型的查询场景。页面传 pageIndex 和 pageSize,DaoImp 先查一次总数,再按起始下标查当前页数据:
public List<Student> findByPage(Pager pager) { String countSql = "SELECT COUNT(*) FROM student"; // 执行 countSql,把 totalCount 填进 pager int startPos = pager.getStartPos(); String pageSql = "SELECT * FROM student ORDER BY student_id LIMIT ?, ?"; PreparedStatement ps = conn.prepareStatement(pageSql); ps.setInt(1, startPos); // 偏移量:跳过多少条 ps.setInt(2, pager.getPageSize()); // 取多少条 ResultSet rs = ps.executeQuery(); // 封装成 List<Student> 返回 }为什么先查 count 再查数据?因为页面底部要渲染「共 X 条 / 共 Y 页」,这两个数字必须在查数据之前拿到。两条 SQL 是配套操作,任何分页都逃不掉。LIMIT 的第一个参数是偏移量不是页码,第 1 页偏移是 0,第 2 页偏移是 pageSize,这个转换逻辑在 Pager.getStartPos() 里收口,前面已经看过了。
需要特别留意的是分页查询必须带 ORDER BY。没有排序的分页,MySQL 返回顺序不保证稳定,翻到后面几页可能看到前面出现过的记录,这就是「分页翻车」。另外 count 查询在小数据量下没压力,但如果 student 表数据量上了几十万,COUNT(*) 会全表扫,这时可以考虑用一个近似值或者缓存,这是后面优化的话题,练手阶段不用较真。
4.3 ScoreDaoImp:条件查询与联表统计的实现
成绩模块比学生模块复杂,因为成绩数据要和课程表关联才能展示课程名。ScoreDaoImp 里会有一类联表查询,常见写法是这样:
public List<Score> findByStudentId(int studentId, Pager pager) { String sql = "SELECT s.score, c.course_name, c.credit " + "FROM score s " + "JOIN course c ON s.course_id = c.course_id " + "WHERE s.student_id = ? " + "ORDER BY s.id LIMIT ?, ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setInt(1, studentId); ps.setInt(2, pager.getStartPos()); ps.setInt(3, pager.getPageSize()); // 执行并把结果封装成 List<Score> }JOIN 在这里的作用是补全信息:score 表里只有 course_id,页面上要显示「Java 程序设计 90 分」,课程名在 course 表里,不联表就得在 Java 代码里循环再查一次,那种 N+1 查询在列表页是性能灾难。给表起别名 s 和 c 是联表查询的常规操作,SQL 能短不少。
条件查询的另一个常见需求是按分数段筛选,SQL 会在 WHERE 后面拼 AND s.score BETWEEN ? AND ?。这里有个实现细节值得学:用 StringBuilder 动态拼 SQL,根据页面传参决定要不要拼某个条件,最后统一 set 参数。PreparedStatement 的占位符既能防 SQL 注入,又让动态条件可控,比直接拼接字符串靠谱。如果你在资源里看到这种写法,说明作者的基本功是到位的。
5. 学生成绩管理系统避坑:五个真实翻车现场与修复方法
5.1 数据库连不上:时区、驱动版本、密码三连坑
现象:Tomcat 启动没报错,但登录页一提交就 500,控制台打印 Communications link failure 或 Access denied for user。
原因:九成是三个配置问题叠在一起。驱动类名写成 MySQL 5 时代的 com.mysql.jdbc.Driver,而本地装的是 MySQL 8.0;JDBC URL 缺 serverTimezone 参数,驱动要求显式指定时区;db.properties 里的密码和本机 MySQL root 实际密码不一致。
解决:三件套一次改齐。驱动类名带 .cj,URL 加 ?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8,密码改成实际值。改完先单独验证连接再启 Tomcat。这个坑几乎每个第一次跑别人项目的人都会踩,因为发布者用的是他的环境,你用的是你的环境,配置文件不可能天然通用。
5.2 分页翻到最后几页出现重复数据或缺失
现象:列表页第 1 页正常,翻到第 3、4 页时前面出现过的学生又冒出来了,而且最后一页的记录条数和预期不符。
原因:分页查询 SQL 没有 ORDER BY。MySQL 执行 LIMIT 时如果没有排序,返回行顺序不保证稳定,可能走索引也可能全表扫描,两次查询结果顺序不一致,翻页自然就乱。
解决:所有分页查询强制加 ORDER BY,用主键或唯一字段排序。这也是 java 面试题里「分页查询有哪些坑」的标准答案之一。从那以后我写分页 SQL,第一行永远是 SELECT 字段 FROM 表 ORDER BY 主键,条件可以后加,排序不能没有。
5.3 页面正常但中文存进数据库变成 ??
现象:添加学生时姓名填「张三」,列表页显示「??」,数据库表里也是问号。
原因:编码链路断了三个环节。Servlet 里没设 request.setCharacterEncoding("UTF-8"),表单参数按平台默认编码解析;JDBC URL 没带 characterEncoding=utf8,驱动用默认编码写库;最隐蔽的是建表时表或字段的 charset 是 latin1,连接层改了也存不进去中文。
解决:三层都设 UTF-8。Servlet 或过滤器里统一 setCharacterEncoding("UTF-8");JDBC URL 末尾加 characterEncoding=utf8;表结构层面执行 ALTER TABLE student CONVERT TO CHARACTER SET utf8mb4;。前两层好改,第三层得动表,顺手把库和表的默认字符集一起确认了。改完重启,重新插入一条数据验证。
5.4 编译通过但 Tomcat 启动报 ClassNotFoundException
现象:IDEA 里编译没有任何报错,一启动 Tomcat 就抛 ClassNotFoundException: com.mysql.jdbc.Driver,或者某个 Action 类找不到。
原因:老 Web 工程通病。mysql-connector-java.jar 只在 IDEA 的 Libraries 里被引用,Artifacts 打包时没带进 WEB-INF/lib;或者 jar 依赖被标记成 Provided,运行时根本不参与部署。
解决:打开 File → Project Structure → Artifacts,看 Output Layout 里 WEB-INF/lib 下有没有这个 jar,没有就右键依赖 → Put into Output Root。手动部署的话直接解压 war 包看 WEB-INF/lib 目录,缺哪个 jar 就放哪个。判断标准很简单:运行时报什么类找不到,就把包含这个类的 jar 部署上去,别在类路径上瞎猜。
5.5 改了源码重启还是旧页面在跑
现象:改了 LoginAction 的跳转逻辑,重启 Tomcat,页面行为和改之前一模一样,怀疑自己改错了文件。
原因:Tomcat 的 work 目录缓存了旧的编译产物,IDEA 的增量部署只更新了部分文件;再加上浏览器缓存了静态页面,一叠加就显得改了个寂寞。
解决:IDEA 里 Build → Rebuild Project 强制全量编译,把 Tomcat 的 work/Catalina 目录整个删掉再启动,浏览器开无痕窗验证。这套组合拳能解决九成的「改了不生效」,剩下的那一成基本都是部署的 war 包路径和源码头不对。
6. 二次开发技巧:把 Pager 升级成通用分页组件
先说说为什么要动 Pager。现在这个类只负责分页参数,不携带数据,每个列表 Action 都要自己准备一个 List 或 List 放进 request,再单独放一个 Pager,页面取数据时要两样都拿。改成带泛型的 PageResult ,让数据和分页参数一次带走,复用性直接上一个台阶。
public class PageResult<T> { private List<T> list; // 当前页数据 private int pageIndex; // 当前页码 private int pageSize; // 每页条数 private int totalCount; // 总记录数 public int getTotalPage() { return (totalCount + pageSize - 1) / pageSize; } public int getStartPos() { return (pageIndex - 1) * pageSize; } }改造思路:原来的 Pager 保留,作为入参对象;新增 PageResult 作为返回对象,DaoImp 把查到的 List 塞进去,连同分页参数一起返回。Action 里只需要 setAttribute("result", pageResult),JSP 里循环遍历 result.list 渲染表格,页码条用 result 里的分页参数渲染。泛型的好处是 Student、Score、Course 都能套用,不用为每个实体写一个分页返回类。
改完怎么验证?写一个 main 方法或者 JUnit 用例,测三个边界值:totalCount=10、pageSize=5 时总页数是 2;totalCount=11 时总页数是 3,覆盖向上取整分支;pageIndex=1 时 startPos=0,覆盖第一页偏移。这三个值过了,分页逻辑基本稳了。
这个改法是我在一次课程设计里被逼出来的——那时候三个列表页各写各的分页代码,改一处要同步改三处,改完第一处忘了第二处,页面直接越界。从那以后我每次写列表页,都强制先问一句「分页结果能不能复用」,再动手写 DAO。这套资源给的不只是能跑通的源码,更是一个值得反复折腾的底座,希望帮到你。
本文还有配套的精品资源,点击获取