☰
JSP+Servlet成绩管理系统实战:Servlet生命周期、Session与过滤链全解析
2026/10/1 13:08:42 网站建设 项目流程

简介:一套基于JSP与Servlet的Web成绩管理系统,面向Java Web课程设计学生及入门开发者。系统按学生、教师、管理员三种角色划分,模块边界清晰;学生可查询与修改个人信息、查看成绩,教师可录入和调整学生成绩,管理员则负责学生、教师、班级、课程及用户权限的全面管理,覆盖成绩管理系统的典型业务闭环。压缩包共870个文件,约39.64MB,以Java源码、JSP页面、JS/CSS前端资源为主,并配有SQL数据库脚本、配置文件和说明文档,方便导入开发工具后直接运行或二次修改。目前已有173人学习下载。项目中整合了Bootstrap、Summernote等前端组件,页面交互完整;通过梳理各模块实现,能快速理解Servlet控制层、JSP视图层与MySQL数据层之间的协作,同时学习表单处理、数据校验、权限管理等关键知识点,适合毕业设计、课程设计或自学练手时参考使用。

1. 这套JSP+Servlet成绩管理系统,到底解决了什么问题

很多同学第一次接触Java Web开发,都会选“成绩管理系统”作为毕设或课程设计题目。这个系统听起来简单,但当你真正上手去做,会发现它几乎覆盖了Servlet生命周期的全部核心知识点:请求怎么进来、参数怎么接、Session怎么维持用户状态、JDBC怎么连数据库、JSP怎么把数据渲染成页面。那些只给个前端页面却没有任何业务逻辑的“假系统”,和基于Jsp+Servlet实现、能跑通增删改查和登录认证的真系统,中间隔着一整层Java Web知识。

这篇文章从一个一线开发者的视角,带你把这个项目从零搭起来。你需要会点Java基础,知道JDBC是什么,能装好IDEA、Maven、MySQL和Tomcat。做完你会得到一个能部署到真实服务器上的WAR包,而不是只能在本地IDE里点着玩的黑匣子。这里不涉及Spring全家桶,正因为如此,你才能看清楚Servlet容器、过滤器、监听器、JSP、Session这些底层组件,是怎么协作完成一次完整的Web请求的。

2. 项目骨架与数据库设计:成绩管理系统的表该怎么建

2.1 用户、学生、课程、成绩:四张表的关系能拆多细

成绩管理系统的数据模型,最常见的是三角关系:学生、课程、成绩。再加上一个管理员登录账号,用于保护整个后台。这套结构放在JSP+Servlet项目里,最大的好处是表少、关联清晰,而且能让你练习到一对多和复合主键这两种基础SQL设计。

我建议先建这四张表。学生表存储学号、姓名、班级、性别、入学年份;课程表存储课程编号、课程名、学分;成绩表是关键,它不单放一个成绩字段,而是把学生ID和课程ID作为联合主键,这样才能保证同一个学生对同一门课只有一条成绩记录;管理员表单独存放账号密码。

CREATE DATABASE IF NOT EXISTS score_system DEFAULT CHARSET utf8mb4; USE score_system; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role VARCHAR(20) DEFAULT 'admin' ) ENGINE=InnoDB; CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, gender CHAR(1), class_name VARCHAR(50), enroll_year VARCHAR(10) ) ENGINE=InnoDB; CREATE TABLE t_course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(50) NOT NULL, credit DECIMAL(3,1) ) ENGINE=InnoDB; CREATE TABLE t_score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), exam_date DATE, CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES t_student(id), CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES t_course(id), CONSTRAINT uk_student_course UNIQUE (student_id, course_id) ) ENGINE=InnoDB;

这里我把t_score的联合唯一约束建成了uk_student_course。这样做的直接好处是,业务层插入成绩时不需要先查一遍“这个学生这门课是不是已存在”,数据库会在约束层面把重复数据挡住。如果后期想让同一个学生可以补考多次、保留多条历史记录,再把这条唯一约束去掉,改为前端控制查询最近一次考试成绩。

密码字段建议存SHA-256摘要而非明文。你没看错,登录功能在毕设里经常被直接明文存储,但一条合格的Java Web课程设计,应该加上密码加密。数据库表设计时就把它当一条安全红线,后面写Servlet登录逻辑时才不会偷懒。

2.2 三层包结构与Servlet职责划分:跳开纯JSP页面的坑

传统JSP项目最常见的翻车,是让JSP页面直接访问数据库,把<% Connection conn = ...写在页面顶部。这样项目确实能跑,但后续扩展成本极高,页面一多,改个数据库密码得全局搜索。正确的做法是复用MVC经典分层:Model层放DAO和实体类,View层放JSP,Controller层放Servlet。

包结构我这样划分:

com.score.entity —— Student、Course、Score、User 实体类 com.score.dao —— 每个实体对应一个DAO接口和实现类 com.score.service —— 业务逻辑层,处理密码校验、成绩平均分计算 com.score.servlet —— 接收请求、调用Service、跳转或转发 com.score.filter —— 登录状态过滤器 com.score.util —— DBUtil、MD5工具类

Servlet在这套结构里只做三件事:取参数、调Service、定跳转。不要在Servlet里写一行SQL,更不要在Servlet里做复杂业务计算。曾有同学把所有代码塞进一个doPost里,六百多行,最终连自己都分不清哪个功能对应哪个方法。

2.3 从JSP页面反向推Servlet请求映射

设计Servlet映射时不要用/servlet/StudentAdd这种风格。推荐直接用伪静态风格映射:/student/add、/student/list、/score/input。这样页面地址看起来清晰,后期如果换成SpringMVC,控制器的URL基本不用大改。

每个模块遵循同一套约定:

  • list对应列表查询,走GET请求
  • add对应跳转新增页面,和提交新增,前者GET后者POST
  • update对应修改,先GET到编辑页回显,再POST提交新数据
  • delete对应删除,走POST请求

删除接口不要用GET。有些同学图省事,写一个<a href="/student/delete?id=3">就完事了。这等于把删除操作塞进浏览器地址栏爬虫也能触达的入口,传参不够安全。

3. 用Servlet处理成绩增删改查:接口设计与参数校验

3.1 写一个BaseServlet统一分发请求

四个模块如果每个都建四个Servlet,项目会平白多出一堆类。更常见的做法是写一个BaseServlet,重写service方法,通过action参数自动调用对应方法。这样控制层的代码量大幅缩小,也符合大多数JSP+Servlet课程设计的要求。

package com.score.servlet; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.Method; public class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null || action.trim().isEmpty()) { throw new ServletException("缺少 action 参数,无法分发请求"); } try { Method method = this.getClass().getDeclaredMethod(action, HttpServletRequest.class, HttpServletResponse.class); method.setAccessible(true); method.invoke(this, req, resp); } catch (Exception e) { throw new ServletException("请求处理方法 " + action + " 调用失败", e); } } }

这段代码用反射机制在service方法里把URL参数action映射为当前Servlet里的同名方法。例如请求/course?action=list,就会自动调用CourseServlet类的list(HttpServletRequest, HttpServletResponse)方法。好处是新增一个功能只需要写一个方法,不用修改web.xml和映射配置。

使用反射要注意参数顺序不能写反,HttpServletRequest必须放在第一个参数位置,否则方法调用时会报参数类型不匹配。另外反射在并发量很大时性能不如直接分支判断,但这种校内课程设计级别的QPS完全不需要考虑性能劣化的问题。

3.2 成绩录入接口:参数校验为什么不能只依赖前端

成绩录入是整个系统的核心入口。前端JSP页面会用<input type="number">限制输入范围,但后端Servlet必须再做一次完整校验。前端的限制只是提升用户操作体验,后端校验才是数据安全的底线,因为一个心怀恶意的人完全可以用Postman绕过页面直接向你的接口提交score=999或score=-1。

@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String studentIdStr = req.getParameter("studentId"); String courseIdStr = req.getParameter("courseId"); String scoreStr = req.getParameter("score"); // 防止看漏了 null 与空字符串的区分 if (studentIdStr == null || studentIdStr.trim().isEmpty() || courseIdStr == null || courseIdStr.trim().isEmpty() || scoreStr == null || scoreStr.trim().isEmpty()) { req.setAttribute("errorMsg", "学生、课程、成绩均不能为空"); req.getRequestDispatcher("/error.jsp").forward(req, resp); return; } int studentId = 0; int courseId = 0; double score = 0.0; try { studentId = Integer.parseInt(studentIdStr.trim()); courseId = Integer.parseInt(courseIdStr.trim()); score = Double.parseDouble(scoreStr.trim()); } catch (NumberFormatException e) { req.setAttribute("errorMsg", "参数格式错误,必须为数字"); req.getRequestDispatcher("/error.jsp").forward(req, resp); return; } if (score < 0 || score > 100) { req.setAttribute("errorMsg", "成绩必须在0到100分之间"); req.getRequestDispatcher("/error.jsp").forward(req, resp); return; } ScoreService scoreService = new ScoreService(); boolean result = scoreService.addScore(studentId, courseId, score); if (result) { resp.sendRedirect(req.getContextPath() + "/course?action=scoreList"); } else { req.setAttribute("errorMsg", "该学生此课程的成绩已存在,不能重复录入"); req.getRequestDispatcher("/error.jsp").forward(req, resp); } }

这段代码体现了三层校验逻辑:先判空,再转换类型,最后判断业务范围。我把数值转换单独包在try/catch里,就是为了区分“参数缺失”和“参数格式错”这两种不同的错误场景,方便页面上给用户展示不同的提示信息。

这里有个容易被忽视的点:请求成功的跳转和请求失败的跳转方式不一样。成功后我用了resp.sendRedirect做重定向,失败后用req.getRequestDispatcher().forward做请求转发。原因是重定向会让浏览器发起一次全新的请求,地址栏会变成列表页URL,用户刷新时只会重复刷新列表,不会导致成绩重复提交。而转发是服务端内部跳转,地址栏不变化,用户按F5会把刚才的POST请求再提交一遍,很容易产生重复数据。初次做Java Web项目时,很多人在这两类跳转的区别上栽过跟头。

3.3 成绩列表查询与JSP渲染:不要忘了空数据判断

成绩列表页面是整个系统最常被查看的页面。它要把成绩表和学生表、课程表联查出来,然后在JSP里用表格逐行输出。查询逻辑写在DAO层,Servlet只负责把List<Score>传给JSP。JSP拿到集合后,必须处理集合为空的情况。

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>成绩列表</title> </head> <body> <h2>成绩列表</h2> <c:if test="${empty scoreList}"> <p>当前没有成绩记录,请先录入。</p> </c:if> <c:if test="${not empty scoreList}"> <table border="1" cellpadding="6" cellspacing="0"> <tr> <th>学号</th> <th>学生姓名</th> <th>课程名</th> <th>成绩</th> <th>考试日期</th> <th>操作</th> </tr> <c:forEach items="${scoreList}" var="score"> <tr> <td>${score.studentNo}</td> <td>${score.studentName}</td> <td>${score.courseName}</td> <td>${score.score}</td> <td>${score.examDate}</td> <td> <a href="${pageContext.request.contextPath}/score?action=edit&id=${score.id}">编辑</a> <a href="javascript:void(0)" onclick="confirmDelete(${score.id})">删除</a> </td> </tr> </c:forEach> </table> </c:if> <script> function confirmDelete(scoreId) { if (confirm("确定要删除这条成绩记录吗?删除后不可恢复。")) { var form = document.createElement("form"); form.method = "post"; form.action = "<%=request.getContextPath()%>/score?action=delete"; var input = document.createElement("input"); input.type = "hidden"; input.name = "id"; input.value = scoreId; form.appendChild(input); document.body.appendChild(form); form.submit(); } } </script> </body> </html>

我用JSTL的c:forEach循环输出,而不是脚本片段式JSP里的for(int i=0; i<list.size(); i++)。原因有两个:一是JSTL能在页面上强制不写Java代码,页面维护起来清爽得多;二是${score.score}的输出自动调用toString方法,不会因为空指针导致整个页面500。

删除操作必须用POST。代码里我用JavaScript动态创建了一个表单,把删除ID包装成隐藏字段提交。这种写法虽然比直接<a href>删多一点代码,但它避免了GET请求删除数据的安全隐患,也演示了在实际开发里如何在页面上安全地提交一个POST请求。

4. 登录认证与过滤链:Session与Filter的价值所在

4.1 加密存储与登录校验:DAO层的防呆设计

登录功能是成绩管理系统区别于纯静态Web页面的关键标志。没有登录的话,任何人都可以直接访问/score/list接口看到所有成绩。一个正经的JSP+Servlet项目一定要有一个登录页面、一个登录Servlet和一个逻辑判断用户Session是否存在的过滤器。

用户表的密码密文存储,我用的方案是SHA-256加盐。加盐是指在原始密码后面拼接一个固定字符串,再计算哈希。这样做是为了防止恶意用户用彩虹表逆推出常见密码,比如123456的SHA-256哈希值在互联网上一搜就能找到。

public class MD5Util { private static final String SALT = "ScoreSystem2024"; public static String encrypt(String password) { String target = password + SALT; try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(target.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { String hex = Integer.toHexString(b & 0xff); if (hex.length() == 1) { sb.append("0"); } sb.append(hex); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 算法不可用", e); } } }

盐值不要放在代码里写死。更常见的做法是放在db.properties配置文件中,部署时按环境替换。把盐写在工具类里虽然不优雅,但对一个课程设计来说,已经能挡住绝大多数初级的直接查库破解。如果以后要做到生产级别,推荐用BCryptPasswordEncoder这类每次生成随机盐的算法。

登录Servlet拿到用户上传的明文密码后,调用MD5Util.encrypt得到密文,再与数据库里的密文字段比对。数据库里不要存password原始明文,这是底线。有些老系统甚至把密码明文直接放在前端页面的注释里展示给用户,那已经是重大安全事故了。

4.2 过滤器拦截未登录用户:每个受保护站点的护城河

登录验证只做在单个Servlet里远远不够。成绩管理系统的/course/list、/score/add、/student/update这些地址,用户手动在浏览器里输入URL就能直接访问,完全绕过了登录页面。要阻止这种未授权访问,必须给一组URL加一个过滤器。

package com.score.filter; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; public class LoginFilter implements Filter { private String excludedUrls; @Override public void init(FilterConfig filterConfig) throws ServletException { this.excludedUrls = filterConfig.getInitParameter("excludedUrls"); } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String requestURI = req.getRequestURI(); String contextPath = req.getContextPath(); String path = requestURI.substring(contextPath.length()); if (excludedUrls != null && excludedUrls.contains(path)) { chain.doFilter(request, response); return; } HttpSession session = req.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(request, response); } else { // 未登录则重定向到登录页 String loginPage = req.getContextPath() + "/login.jsp"; resp.sendRedirect(loginPage); } } @Override public void destroy() { } }

req.getSession(false)这个方法,传false表示如果当前请求里没有Session对象,就直接返回null,不会自动创建一个空Session。很多教学代码里直接写req.getSession(),如果此时用户还没有Session,容器会为他合成一个新Session,结果过滤器把新建的Session也当成无效处理,白白浪费了资源。判断登录状态时,用getSession(false)配合getAttribute才是标准姿势。

排除列表里的访问路径,至少要包括/login.jsp、/user?action=login和静态资源CSS、JS文件。如果忘记排除静态资源,用户打开登录页面时会发现浏览器报404或者样式全丢了,因为CSS请求也被拦截并重定向到登录页。

4.3 退出登录:小心Session没有真正失效

业务里容易被忽视的细节就是退出登录。最常见的写法是点击“退出”按钮后,在LoginServlet里写一行session.invalidate()。但这还不够安全,因为Session失效后,浏览器地址栏里的JSESSIONID这个Cookie还残留在客户端。正确的做法是把会话Cookie一并清除。

protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(false); if (session != null) { session.invalidate(); } Cookie[] cookies = req.getCookies(); if (cookies != null) { for (Cookie cookie : cookies) { if ("JSESSIONID".equals(cookie.getName())) { cookie.setMaxAge(0); cookie.setPath(req.getContextPath()); resp.addCookie(cookie); break; } } } resp.sendRedirect(req.getContextPath() + "/login.jsp"); }

调session.invalidate()之后,Servlet容器会把服务端的Session对象标记为失效,但客户端浏览器里存放的JSESSIONID Cookie仍然存在。Cookie的清理方式是把它的maxAge设置为0,再通过resp.addCookie重新写回浏览器,浏览器收到后会立刻把同名Cookie删掉。完整的退出登录,这两步缺一不可。

5. 成绩管理系统常见翻车点:现象、原因、解决一条条说清

5.1 JSP界面修改后浏览器一直显示旧页面

现象:在IDEA里改了student_list.jsp上的文字或样式,刷新浏览器页面没有任何变化,强制刷新也一样。

原因:Tomcat默认对JSP的修改检测有时间间隔。当项目使用Exploded方式部署时,IDEA把编译后的文件直接输出到target目录,而JSP文件在src\main\webapp目录下修改后,有时没有同步到target目录。另一个原因是浏览器缓存了旧页面。

解决:在IDEA中将Tomcat配置里的On Update Action和On Frame Deactivation都设置为Update resources,这样JSP修改后,IDE会自动同步文件到部署目录。如果还没变化,关掉Tomcat,手动删除target目录后用Maven的clean命令重启项目。

提示:修改web.xml、Filter类的init方法等容器级配置后,Update resources不会生效,必须Redeploy或重启Tomcat。

5.2 表单提交中文乱码

现象:从JSP页面往Servlet提交中文姓名后,Servlet里接收到的参数值变成一堆问号。

原因:HTML页面的charset设置是UTF-8,但Servlet端在读取请求参数前没有设置request.setCharacterEncoding("UTF-8")。Tomcat 8及以上版本对POST请求默认编码也是UTF-8,但实际项目里经常出现过滤器设置不及时的问题。

解决:在Servlet的doPost方法第一行加上req.setCharacterEncoding("UTF-8")。如果项目里已经有Filter,在doFilter里加上同样代码。另外检查JSP页面顶部的contentType是否有charset=UTF-8、数据库连接串是否带了characterEncoding=utf8、MySQL表本身是否用的utf8mb4字符集。四层编码全部统一后,乱码基本能解决。

5.3 连接数据库报ClassNotFoundException

现象:启动Tomcat后,访问/student/list页面报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。

原因:mysql-connector-java依赖没有打包到WEB-INF/lib目录下。使用IDEA的Artifacts打包WAR时,依赖库有时候没有被包含进去。另一种原因是用了旧版驱动包,但代码里写的是新版驱动类名。

解决:使用Maven管理项目时,检查pom.xml里有没有mysql-connector-java依赖。在IDEA的Project Structure - Artifacts里,把Available Elements里的依赖右键选择Put into /WEB-INF/lib。如果代码里写的是com.mysql.jdbc.Driver,把驱动包升级后改成com.mysql.cj.jdbc.Driver。

5.4 删除成绩后列表页还显示旧数据

现象:点击删除按钮,页面提示已删除,但列表页还是能看到刚被删除的那条记录。

原因:浏览器的地址栏里显示的列表地址是/score?action=list,删除逻辑里用的是req.getRequestDispatcher("/score?action=list").forward(...),而不是sendRedirect。浏览器地址栏没有变化,点击F5后重新提交了一次POST请求,于是又被当作删除操作返回了。

解决:删除成功后的跳转必须使用resp.sendRedirect到列表页地址。转发只能用于请求失败或数据校验失败,成功操作一律重定向。这样JSP页面上看到的是一条全新的GET请求,而不是上一次POST请求的回显。

5.5 部署到服务器后登录失败、页面打不开

现象:本地IDEA里一切正常,打成WAR包部署到Linux服务器后,Tomcat访问/login.jsp可以打开,但提交登录信息后一直进不去,或提示404。

原因:项目Context Path没对上。本地部署时IDEA配置的Application context是/score_war_exploded,打成WAR包后如果直接放在webapps目录,访问地址变成了http://ip:8080/score_war/。代码里的跳转地址如果全部写成了绝对路径而没有带request.getContextPath(),就会跳到一个不存在的路径上。

解决:所有resp.sendRedirect和req.getRequestDispatcher的路径拼接里,必须首先包含req.getContextPath()作为前缀。代码里凡是出现以/开头的跳转地址,检查是否漏了contextPath。这是JSP+Servlet项目部署到真实服务器时最常踩的坑之一。

6. 进阶:用EL表达式和JSTL重构页面,再用单元测试兜底

6.1 重构思路:把Java代码从JSP页面里彻底赶出去

一个可以打80分的JSP+Servlet成绩管理系统,除了功能能跑通,还要看代码的可维护性。我常见的做法是,功能完成后花半小时把页面里所有的<%...%>脚本片段替换成EL表达式和JSTL标签。替换后页面文件里不再出现import语句、不再出现for循环、不再出现类型强转。

以前遍历成绩列表,新手写的JSP是下面这个样子:

<% List<Score> list = (List<Score>) request.getAttribute("scoreList"); for (Score s : list) { %> <tr> <td><%= s.getStudentNo() %></td> </tr> <% } %>

改成JSTL后,一个c:forEach就完成了同样的事。EL表达式自动从PageContext、Request、Session、Application四个作用域里寻找变量名。唯一要注意的是Servlet里setAttribute的名字必须与${}里的名字完全一致,大小写都不能错,否则页面上就会原样显示一个空字符串,不报错但很难排查。

6.2 配置Tomcat里的数据源:比每次手动创建Connection更可靠

毕业设计阶段使用DRIVER_MANAGER.getConnection获取数据库连接没有问题。但如果你打算把这个项目当作后续SpringMVC、SpringBoot学习的基础,理解连接池的概念会更少吃苦头。在Tomcat的context.xml中配置JNDI DataSource,然后Servlet通过Context.lookup获取数据源,这个过程值得手动做一遍。

<Context> <Resource name="jdbc/scoreDB" auth="Container" type="javax.sql.DataSource" maxTotal="20" maxIdle="10" maxWaitMillis="10000" username="root" password="123456" driverClassName="com.mysql.cj.jdbc.Driver" url="jdbc:mysql://localhost:3306/score_system?useUnicode=true&amp;characterEncoding=utf8&amp;useSSL=false&amp;serverTimezone=Asia/Shanghai"/> </Context>

配置里的maxTotal=20表示连接池最多同时保持20个物理连接,maxWaitMillis=10000表示当连接用尽时,调用方最多等10秒。DBUtil获取连接时不再创建新连接,而是从连接池里借一个。项目关停时Tomcat会自动把连接收回并关闭,省去了在DAO里手动conn.close()可能遗漏的风险。

6.3 写一个不带页面的Servlet单元测试:验证成绩计算的边界

成绩管理系统除了增删改查,还经常要输出“学生平均分”和“课程最高分”,这部分计算逻辑值得单独提取到Service层,再用JUnit写一个脱离容器的单元测试。这样不仅验证了算法,还为后续重构提供了回归测试保障。

public class ScoreCalculatorTest { @Test public void testCalculateAverage_EmptyList() { ScoreCalculator calculator = new ScoreCalculator(); List<Double> scores = new ArrayList<>(); double avg = calculator.calculateAverage(scores); assertEquals(0.0, avg, 0.001); } @Test public void testCalculateAverage_NormalScores() { ScoreCalculator calculator = new ScoreCalculator(); List<Double> scores = Arrays.asList(80.0, 90.0, 70.0); double avg = calculator.calculateAverage(scores); assertEquals(80.0, avg, 0.001); } @Test public void testCalculateAverage_NullValueShouldBeIgnored() { ScoreCalculator calculator = new ScoreCalculator(); List<Double> scores = Arrays.asList(80.0, null, 100.0); double avg = calculator.calculateAverage(scores); assertEquals(90.0, avg, 0.001); } }

单元测试的价值在于,当前台页面要求成绩列表页显示平均分时,你不必部署整个Tomcat跑到页面上看结果,本地直接跑JUnit就能判断计算逻辑对不对。空集合返回0、正常集合求均值、集合中含有null值时跳过不参与计算,这三个边界条件如果不在测试里定清楚,将来在页面上遇到就会变成一行行报错的焦虑。

我最早做成绩管理系统时也经历过把脏数据写进库里、忘记重定向导致重复提交、改完JSP看不到变化的痛苦。后来形成一套习惯:每次改动前先跑一遍所有单元测试,再把项目部署到本地Tomcat,走一遍“登录-列表-新增-编辑-删除”完整流程。这套操作看起来朴素,但能确保系统在答辩演示时不翻车。JSP+Servlet这门技术虽然老,但它足够简单,恰好让你把Web开发的每一个环节都看破说透,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询