JSP+Servlet学生信息管理系统:手写MVC、跑通排错与二次开发
简介面向Java Web初学者与课程设计需求者的简单学生信息管理系统源码包围绕学生自助注册、教师集中管理学生信息这一典型场景展开可用于课程设计参考、Servlet入门练习与小型信息管理原型搭建。压缩包共2个文件以zip源码包和htm说明页为主整体约2.13MB源码、数据库脚本与lib依赖便于按项目结构查阅和部署。项目采用JSPServlet结合Layui、jQuery实现运行环境为JDK7、Tomcat8、MySQL与IntelliJ IDEA功能路径清晰适合对照学习请求处理、页面交互与数据表操作。已有6437人学习能帮助读者快速理解Java Web分层开发流程并在此基础上进行功能扩展、界面调整或答辩演示准备。1. 答辩前一晚的 404往往不是代码写错了把工程拷到别人电脑上双击启动浏览器一开就是 404换个页面数据库连不上再换个页面中文全变成问号。这套基于 JSP Servlet 的简单学生信息管理系统功能其实一点都不花哨管理员登录、学生信息增删改查、按学号或姓名模糊搜索、分页列表数据落在 MySQL 里。它没有 Spring 那套自动装配请求映射、参数接收、页面跳转全靠 Servlet 手写也正因为如此Servlet 生命周期、request/response 域、Session、Filter 这些概念在它身上是裸露的没有框架帮你兜底。它适合两类人一类是要交课程设计、手上需要一个能跑起来也能改得动的完整骨架另一类是想在碰 Spring 之前先把手写 MVC 这条链路完整走一遍的人。读下去你会看到它的目录怎么分层、一次请求怎么流转、跑起来要几步、改功能该从哪个文件下手。2. 工程结构与请求链路先看清这些文件各自负责什么拿到一个 JSP Servlet 工程最忌讳上来就翻代码。先看目录目录本身就是架构说明。传统的 Java Web 项目不走 Maven 的时候源码在src页面和配置在web编译产物扔进WEB-INF/classes换成 Maven 结构就是src/main/java和src/main/webapp。两种放法都能跑但 jar 包的位置只有一个正确答案——WEB-INF/lib放错了容器就找不到类后面避坑那章会专门说这个。2.1 按职责切开的包结构一个能维护得下去的版本包名通常长这样student-manager/ ├── src/ │ └── com/example/sms/ │ ├── entity/ Student.java 实体字段和表列一一对应 │ ├── dao/ StudentDao.java SQL 只出现在这一层 │ ├── service/ StudentService.java 业务规则、事务边界 │ ├── servlet/ LoginServlet.java 接收请求、调 service、选页面 │ │ StudentServlet.java │ ├── filter/ EncodingFilter.java 统一 UTF-8 │ │ LoginFilter.java 未登录拦截 │ └── util/ DBUtil.java 取连接、关资源 ├── web/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── lib/ mysql-connector-java.jar、jstl.jar、standard.jar │ ├── jsp/ login.jsp、list.jsp、edit.jsp │ └── css/ └── sql/init.sql 建库建表 一条管理员初始数据这套分层不是摆样子。entity 只放字段和 getter/setter绝不放 SQLdao 只做“拿连接、拼 SQL、映射结果集”不判断业务service 负责“学号不能重复”“删除前要确认存在”这类规则servlet 只管拿参数、调方法、把结果塞进 request 域再转发。课程设计阶段很多人图省事把 JDBC 代码直接写在 Servlet 的doPost里一个方法两百行改一个字段要翻三个地方答辩老师随口问一句“这段 SQL 在哪儿”就答不上来。我一般会强制自己守一条线Servlet 里不允许出现Connection、PreparedStatement、ResultSet这三个类型。守住这条线后面想加成绩模块、想换连接池都不用动控制层。2.2 一次请求从浏览器到 JSP 的完整路径用户点“查询”浏览器发出GET /student?actionlistpage2。Tomcat 按映射找到StudentServlet调用它的service()再分派到doGet。Servlet 里通常用一个action参数做二级分发这是 JSP Servlet 时代最常见的手写路由方式一个 Servlet 管一个模块的所有动作WebServlet(/student) public class StudentServlet extends HttpServlet { private final StudentService service new StudentService(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 所有列表类请求走 GET String action req.getParameter(action); if (action null || list.equals(action)) { list(req, resp); } else if (toEdit.equals(action)) { toEdit(req, resp); } else if (delete.equals(action)) { delete(req, resp); } else { resp.sendError(404); } } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 所有写操作走 POST避免刷新页面重复提交 String action req.getParameter(action); if (save.equals(action)) { save(req, resp); } else if (update.equals(action)) { update(req, resp); } else { resp.sendError(404); } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int page parseInt(req.getParameter(page), 1); // 页码默认第 1 页 int size 10; // 每页条数写死也行 String keyword req.getParameter(keyword); // 模糊搜索关键词 PageResultStudent result service.queryPage(keyword, page, size); req.setAttribute(page, result); // 交给 JSP 渲染 req.getRequestDispatcher(/jsp/list.jsp).forward(req, resp); } }WebServlet(/student)是 Servlet 3.0 之后替代web.xml的注解写法容器启动时扫描到它就把路径注册上。doGet和doPost分开是有意为之查询走 GET参数在地址栏里能被收藏、能被刷新新增和修改走 POST刷新只会重发一次请求虽然也可能重复提交但比 GET 触发删除安全得多。req.setAttribute加forward是这套架构里最核心的一对动作。转发发生在服务端浏览器地址栏不变request 域里的数据能被 JSP 用 EL 表达式读到。很多人在这里翻车用resp.sendRedirect去做“带数据跳转”结果 JSP 上一个字段都取不到——重定向是让浏览器重新发一次请求request 域早就没了。2.3 学生表设计与 JDBC 工具类数据层的第一件事是表结构。课程设计级别的字段够用就行别一上来就设计十几张关联表CREATE DATABASE IF NOT EXISTS sms DEFAULT CHARSET utf8mb4; USE sms; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, sno VARCHAR(20) NOT NULL UNIQUE COMMENT 学号业务唯一键, name VARCHAR(50) NOT NULL COMMENT 姓名, gender CHAR(1) DEFAULT 男 COMMENT 性别, age INT COMMENT 年龄, major VARCHAR(50) COMMENT 专业, phone VARCHAR(20) COMMENT 联系电话, create_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL -- 存 MD5 或明文课程设计常见明文上线前必须改 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO admin(username, password) VALUES (admin, 123456);utf8mb4不是可选项。用utf8在某些版本里只支持三字节存中文没问题存 emoji 或者某些生僻字就会报错而建库时的一句DEFAULT CHARSET能省掉后面一堆排查。连接管理统一收进DBUtilpublic class DBUtil { // 驱动、URL、账号密码写在常量里方便一处修改 private static final String DRIVER com.mysql.cj.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/sms?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PWD root; static { try { Class.forName(DRIVER); // 注册驱动容器里只执行一次 } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL 驱动未加载 e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } // 关闭顺序必须是 ResultSet - Statement - Connection public static void close(Connection c, Statement s, ResultSet r) { try { if (r ! null) r.close(); } catch (SQLException ignored) {} try { if (s ! null) s.close(); } catch (SQLException ignored) {} try { if (c ! null) c.close(); } catch (SQLException ignored) {} } }URL 里三个参数值得单独说useUnicodetruecharacterEncodingutf8决定驱动和数据库之间用什么编码通信serverTimezoneAsia/Shanghai在 MySQL 8 驱动上是必须的不写会抛The server time zone value is unrecognizeduseSSLfalse只是本地开发省掉证书握手生产环境不要照抄。Class.forName放在静态块里类第一次被加载时执行一次。getConnection每次新建连接这是最朴素的做法并发一上来数据库连接就成瓶颈后面二次开发那章会换成连接池。3. 核心模块拆解登录、CRUD 与分页查询功能列出来只有五项但每一项背后都有一两个必须写对的地方登录难在会话保持和拦截新增难在参数校验和 SQL 注入防护分页难在偏移量算法和页码回显。这一章按“登录 → 写入 → 读取”的顺序拆。3.1 登录鉴权与 Session 过滤器登录 Servlet 做两件事校验账号密码把用户对象塞进 Session。WebServlet(/login) public class LoginServlet extends HttpServlet { private final AdminDao adminDao new AdminDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); Admin admin adminDao.findByUsername(username); // 真实项目必须用 BCrypt 之类做哈希比对课程设计常见明文 if (admin ! null admin.getPassword().equals(password)) { req.getSession().setAttribute(loginUser, admin); resp.sendRedirect(req.getContextPath() /student?actionlist); } else { req.setAttribute(msg, 用户名或密码错误); req.getRequestDispatcher(/jsp/login.jsp).forward(req, resp); } } }req.getSession()不加参数时没有会话就新建一个有就复用。setAttribute把用户对象挂上去后续任何 Servlet 都能用getSession().getAttribute(loginUser)取到。登录成功这里用了重定向而不是转发目的是防止用户按 F5 时重复提交表单——这是重定向与转发最典型的分工场景。只把用户塞进 Session 还不够未登录的人直接敲地址student?actionlist一样能进。所以需要一个登录过滤器WebFilter(/*) public class LoginFilter implements Filter { // 白名单登录页、登录接口、静态资源放行 private static final String[] WHITE {/login, /jsp/login.jsp, /css/, /js/}; Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String path req.getRequestURI().substring(req.getContextPath().length()); for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(request, response); // 放行 return; } } if (req.getSession().getAttribute(loginUser) null) { resp.sendRedirect(req.getContextPath() /jsp/login.jsp); return; // 不放行直接结束 } chain.doFilter(request, response); // 已登录继续 } }WebFilter(/*)表示拦截所有路径substring那一步是把上下文路径比如部署名/sms切掉剩下的才是真正要匹配的部分。忘了切/sms/login和/login对不上白名单永远匹配不到登录页自己也被拦形成死循环——这类问题在浏览器里表现为“重定向次数过多”看到这个提示基本可以直接往过滤器上找。chain.doFilter是分水岭。调用了请求才继续往后走不调用直接return请求就在过滤器这儿终止了。很多人写了sendRedirect还顺手调一次chain.doFilter页面跳了但后台逻辑照跑删数据的接口被未登录用户触发就是这么来的。3.2 新增与修改参数接收和 SQL 写法学生新增的 Servlet 短但每一行都要注意private void save(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Student s new Student(); s.setSno(req.getParameter(sno).trim()); s.setName(req.getParameter(name).trim()); s.setGender(req.getParameter(gender)); s.setAge(parseInt(req.getParameter(age), 0)); // 空值或非法返回 0 s.setMajor(req.getParameter(major)); s.setPhone(req.getParameter(phone)); // 服务端校验不能省前端 required 拦不住 Postman 和手改表单 if (s.getSno().isEmpty() || s.getName().isEmpty()) { req.setAttribute(msg, 学号和姓名不能为空); req.getRequestDispatcher(/jsp/edit.jsp).forward(req, resp); return; } if (service.existsSno(s.getSno(), 0)) { // 0 表示新增时不排除任何 id req.setAttribute(msg, 学号已存在); req.getRequestDispatcher(/jsp/edit.jsp).forward(req, resp); return; } service.add(s); resp.sendRedirect(req.getContextPath() /student?actionlist); }req.getParameter返回的永远是字符串年龄这种数字字段必须自己转。转换函数里要能吃掉null和空串否则抛NumberFormatException异常栈一路打到浏览器用户看到的是一片 500。DAO 层只用PreparedStatement拼接字符串的老路不要走public int insert(Student s) { String sql INSERT INTO student(sno,name,gender,age,major,phone) VALUES(?,?,?,?,?,?); try (Connection c DBUtil.getConnection(); PreparedStatement ps c.prepareStatement(sql)) { ps.setString(1, s.getSno()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setInt(4, s.getAge()); ps.setString(5, s.getMajor()); ps.setString(6, s.getPhone()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(新增学生失败, e); // 包一层再抛保留原始栈 } }try-with-resources保证异常时连接也被关掉不必再手写 finally。占位符?是按顺序从 1 开始编号setInt和setString类型要对上把字符串塞进 int 列会在执行时报错。executeUpdate返回受影响行数新增成功应该是 1返回 0 说明没插进去值得在 service 里判断一下。修改和新增的差别只有两处SQL 换成UPDATE ... WHERE id ?以及唯一性校验要排除自己existsSno(sno, id)里传当前 id。表单提交时不带 id修改就会变成“把所有学生都改掉”这个坑后面还会提到。3.3 分页查询偏移量算法与 JSP 渲染分页是这类系统里唯一需要点算法的地方但算法本身只有一行offset (page - 1) * size。public PageResultStudent queryPage(String keyword, int page, int size) { if (page 1) page 1; int offset (page - 1) * size; String where ; ListObject params new ArrayList(); if (keyword ! null !keyword.trim().isEmpty()) { // 学号和姓名都参与模糊匹配 where WHERE sno LIKE ? OR name LIKE ? ; String like % keyword.trim() %; params.add(like); params.add(like); } int total count(where, params); // 符合条件的总条数 String sql SELECT * FROM student where ORDER BY id DESC LIMIT ? OFFSET ?; params.add(size); params.add(offset); ListStudent rows jdbcTemplateQuery(sql, params); // 自行封装的查询 return new PageResult(rows, total, page, size); }逻辑顺序要先想清楚先算总条数才知道总页数再拿分页数据。LIMIT ? OFFSET ?是 MySQL 的写法两个占位符都要setInt。不用String.format拼进去虽然 offset 是从数字算出来的、大概率安全但一旦有人把 page 传成1;DROP拼字符串就是灾难现场。PageResult是个简单的承载对象字段固定为rows、total、page、size另加两个计算属性public int getTotalPages() { return (total size - 1) / size; // 向上取整7 条 10 条一页 1 页 } public boolean isHasPrev() { return page 1; } public boolean isHasNext() { return page getTotalPages(); }向上取整用(total size - 1) / size别用Math.ceil(total / size)整数相除先把小数丢掉ceil 就没意义了。页面这边用 JSTL 的forEach循环% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table classtbl trth学号/thth姓名/thth性别/thth年龄/thth专业/thth操作/th/tr c:forEach items${page.rows} vars tr td${s.sno}/td td${s.name}/td td${s.gender}/td td${s.age}/td td${s.major}/td td a hrefstudent?actiontoEditid${s.id}编辑/a a hrefstudent?actiondeleteid${s.id} onclickreturn confirm(确认删除)删除/a /td /tr /c:forEach /table div classpager 共 ${page.total} 条第 ${page.page}/${page.totalPages} 页 c:if test${page.hasPrev} a hrefstudent?actionlistpage${page.page - 1}keyword${param.keyword}上一页/a /c:if c:if test${page.hasNext} a hrefstudent?actionlistpage${page.page 1}keyword${param.keyword}下一页/a /c:if /div${page.rows}对应的是PageResult的getRows()EL 表达式会自动去掉 get 前缀并首字母小写所以对象里必须有这个 getter。翻页链接带了keyword${param.keyword}是为了翻页时不丢搜索条件忘了带就会变成“搜索后点下一页条件失效”这种问题用户一看就发现自己测的时候偏偏容易漏。4. 本地跑通环境、建库、部署三步走工程能跑起来的前提是三个东西版本对得上JDK、Tomcat、MySQL。我一般用 JDK 8、Tomcat 9、MySQL 8.0 这一套兼容性最省心Servlet 4.0 的注解、JSTL、驱动都在这个组合里能直接用。4.1 环境准备与目录放置先把 JDK 装好java -version能出版本号Tomcat 解压到没有中文和空格的路径下bin/startup.bat或者startup.sh起一遍浏览器打开http://localhost:8080看到欢迎页说明容器没问题。这一步必须先过因为后面所有 404 都要先排除“Tomcat 根本没起来”。MySQL 装好后建库执行sql/init.sql。注意init.sql里建库、建表、插初始数据要一起跑很多人只跑了建表部分登录时发现admin表是空的还以为代码有问题。依赖只有三个 jarMySQL 驱动、jstl.jar、standard.jar全部丢进web/WEB-INF/lib。三者缺一不可驱动缺失报ClassNotFoundExceptionJSTL 缺失报Unable to find taglib。Eclipse 里还要把 jar 加到项目的 Build PathIDEA 里则要在 Project Structure 的 Artifacts 里确认这些 jar 会被打进输出目录只加到库不放进 Artifacts 是部署时缺类最常见的来源。4.2 连接参数与字符集两处改动工程里所有跟环境相关的配置理想情况是只出现在一个地方。DBUtil里的 URL、账号、密码就是这类配置换机器只改这三行private static final String URL jdbc:mysql://localhost:3306/sms?...; private static final String USER root; private static final String PWD 你的本机密码;如果对方 MySQL 版本是 5.x驱动类名要换成com.mysql.jdbc.Driverjar 包也不一样。这种版本差异导致的报错信息很含糊看到Communications link failure先确认端口看到Access denied先确认账号密码看到Unknown column才去查 SQL。编码要三层对齐数据库建库时utf8mb4JDBC URL 里characterEncodingutf8Servlet 里request.setCharacterEncoding(UTF-8)。三层缺一层中文就可能在某一段路上变问号。第三层最规范的放法是写个EncodingFilter挂在最前面WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); // POST 体编码 resp.setContentType(text/html;charsetUTF-8); chain.doFilter(req, resp); } }setCharacterEncoding对 GET 请求的地址栏参数不起作用GET 的中文要靠 Tomcat 的server.xml里Connector上的URIEncodingUTF-8或者干脆在代码里new String(name.getBytes(ISO-8859-1), UTF-8)转一次。前者一次配置长期有效后者每处都写容易漏。4.3 部署与冒烟测试部署有两种方式把工程整个拷进tomcat/webapps/下或者用 IDE 的 Server 配置热部署。课程设计演示建议用第一种路径清晰出问题好定位。访问地址通常是http://localhost:8080/工程名/jsp/login.jsp“工程名”就是 webapps 下的文件夹名也是getContextPath()返回的那段代码里所有跳转都带它写死了部署名的话换名字就全断所以统一用req.getContextPath()。跑通之后按这个顺序点一遍比随便乱点有效登录页能正常显示中文输入错误的密码提示“用户名或密码错误”。正确登录后跳到列表页页码和总数显示正常。新增一个学生返回列表能看到最新一条排在最前ORDER BY id DESC。编辑该学生改年龄后保存列表里数据变了改学号为已存在值提示“学号已存在”。搜索一个不存在的关键词显示“共 0 条”翻页按钮消失页面不报 500。点删除弹确认框确认后列表少一条。退出登录或者直接敲地址访问列表被踢回登录页。这一串走完基本可以判断工程是完整的剩下的差异只在数据和样式上。5. 避坑与排查这套组合最容易翻车的五个位置JSP Servlet 的报错信息往往只有一行异常栈翻起来像拆黑匣子500 页面上写着“服务器内部错误”真正的Caused by藏在 Tomcat 日志里。下面五条是我被坑得最多、也最容易被新手上交前忽略的位置每条按现象、原因、解决办法写。5.1 中文变问号或表单提交后成乱码现象列表里的姓名显示成???或者新增之后再看变成问号但数据库里查出来是对的。原因编码在三层里有一层没对齐。常见组合是数据库utf8mb4、页面UTF-8但请求体按容器默认的ISO-8859-1解码中间就断了一次。解决EncodingFilter放在过滤器链最前面web.xml的filter-mapping顺序或WebFilter的相对顺序拦截/*同时把所有.jsp顶部写% page contentTypetext/html;charsetUTF-8 %。GET 参数的中文单独处理改server.xml的Connector加URIEncodingUTF-8改完重启容器。5.2 页面 404日志里也没大异常现象启动日志干净浏览器却是 404换几个 URL 都一样。原因三种可能——工程名和访问路径不一致Servlet 的WebServlet值和表单action对不上JSP 放在WEB-INF下被直接访问。WEB-INF是容器的保护目录浏览器不能直接访问只能由forward进去把list.jsp放在WEB-INF/jsp/下再加个直接访问就是必然的 404。解决看启动日志里有没有Mapping相关输出确认工程实际部署名核对表单action与注解路径把 JSP 放web/jsp/或者全部走 Servlet 转发。5.3 报 ClassNotFoundException: com.mysql.cj.jdbc.Driver现象一调数据库就抛这个代码里明明 import 了驱动类。原因jar 在项目的 Build Path 里但没进WEB-INF/lib或者 IDEA 的 Artifacts 里没勾上。IDE 编译期用类路径运行期用部署目录两者不是一回事。解决把 jar 复制一份到WEB-INF/libEclipse 的 Deployment Assembly 里确认包含 lib 目录IDEA 的 Artifacts 里确认Available Elements中的库在WEB-INF/lib右侧列表里。改完重新部署清掉tomcat/work缓存。5.4 提交后整个表都被改了现象编辑一个学生保存后列表里所有人都变成了同一条数据。原因UPDATE student SET name? ...后面漏了WHERE id?或者表单里 id 的隐藏域没提交上来Servlet 拿到null后被忽略SQL 就变成了全表更新。解决编辑页必须有input typehidden nameid value${student.id}DAO 层在 update 前断言 id 大于 0if (s.getId() 0) throw new IllegalArgumentException(缺少主键)把这种错误拦在写库之前。5.5 刷新页面就重复提交或者转发之后样式丢失现象新增成功后按 F5浏览器提示重新提交表单一确认就多一条重复数据另一种是转发到 JSP 后 HTML 出来了但没有样式。原因写操作完成后用了forward而不是sendRedirect浏览器地址栏还停在 POST 的 URL 上。样式丢失则是 JSP 里的 CSS 用了相对路径页面 URL 一深相对路径就指错了目录。解决写操作成功后一律resp.sendRedirect(req.getContextPath() /student?actionlist)这就是常说的 PRG 模式。CSS 和图片统一用${pageContext.request.contextPath}/css/main.css拼绝对路径换部署名也不用改。6. 二次开发把它改造成一个能交付的小系统课程设计和能交付之间差的不只是界面主要是三件事分层是否彻底、异常有没有统一处理、换个人接手能不能在半小时内加一个模块。下面这套改造顺序是我自己在类似工程上验证过的改动量不大但对后续维护收益明显。第一步把 DAO 抽成接口。现在StudentService里是new StudentDao()直接依赖实现类改成接口 实现构造器注入public interface StudentDao { int insert(Student s); int update(Student s); int deleteById(int id); Student findById(int id); ListStudent query(String keyword, int offset, int size); int count(String keyword); }Service 里写private final StudentDao dao;构造器接收。单测时塞一个假实现不用连数据库就能测“学号重复要报错”这类规则。这一步不改任何业务逻辑纯结构改造风险最低适合作为动手的第一个动作。第二步加成绩模块。新建score表字段id、student_id、course、score外键指向student.id。按现有分层照着抄四个文件Score实体、ScoreDao、ScoreService、ScoreServlet。唯一要额外注意的是删除学生时的级联要么数据库建外键时写ON DELETE CASCADE要么在StudentService.delete里先删成绩再删学生两者选其一别两头都不做否则删学生之后留下孤儿记录后面按学生统计成绩会漏数据。第三步把连接方式换掉。DriverManager每次新建连接压测几十个并发就开始报Too many connections。换成连接池只需要改DBUtil一处DAO 里的getConnection和close调用完全不动改造点改造前改造后影响范围连接获取DriverManager.getConnection池的dataSource.getConnection()仅 DBUtil连接关闭close()真正断开close()归还池仅 DBUtil关闭顺序手动三级关闭try-with-resourcesDAO 可逐步替换配置常量写死db.properties新增一个文件db.properties里放 url、user、password、initialSize、maxActiveDBUtil静态块读一次用Properties.load加载改配置不用重新编译。改完怎么验证。别只点页面。我的习惯是准备三条断言新增两个学号相同的学生第二个必须失败且数据库里只有一条把页码传成0和-1列表要能回到第 1 页而不是抛异常把keyword传成 OR 11 --搜索结果应为 0 条而不是全部。三条都过说明参数校验、边界处理、SQL 注入防护这三个最容易出事的点都守住了。改造到这儿这个学生信息管理系统就不再只是一个能演示的课程设计而是一个结构清楚、能往里加模块的小工程。从那以后我每次把工程交给别人之前都会先在自己的干净环境里按文档从头走一遍一条命令一条命令地敲确认没有“我这里能跑”的假设。希望帮到你。本文还有配套的精品资源点击获取