简介:面向Java Web初学者与课程设计需求者的简单学生信息管理系统源码包,围绕学生自助注册、教师集中管理学生信息这一典型场景展开,可用于课程设计参考、Servlet入门练习与小型信息管理原型搭建。压缩包共2个文件,以zip源码包和htm说明页为主,整体约2.13MB,源码、数据库脚本与lib依赖便于按项目结构查阅和部署。项目采用JSP+Servlet结合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,绝不放 SQL;dao 只做“拿连接、拼 SQL、映射结果集”,不判断业务;service 负责“学号不能重复”“删除前要确认存在”这类规则;servlet 只管拿参数、调方法、把结果塞进 request 域再转发。课程设计阶段很多人图省事,把 JDBC 代码直接写在 Servlet 的doPost里,一个方法两百行,改一个字段要翻三个地方,答辩老师随口问一句“这段 SQL 在哪儿”就答不上来。
我一般会强制自己守一条线:Servlet 里不允许出现Connection、PreparedStatement、ResultSet这三个类型。守住这条线,后面想加成绩模块、想换连接池,都不用动控制层。
2.2 一次请求从浏览器到 JSP 的完整路径
用户点“查询”,浏览器发出GET /student?action=list&page=2。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"); // 模糊搜索关键词 PageResult<Student> 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL -- 存 MD5 或明文,课程设计常见明文,上线前必须改 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO admin(username, password) VALUES ('admin', '123456');utf8mb4不是可选项。用utf8在某些版本里只支持三字节,存中文没问题,存 emoji 或者某些生僻字就会报错,而建库时的一句DEFAULT CHARSET能省掉后面一堆排查。
连接管理统一收进DBUtil:
public class DBUtil { // 驱动、URL、账号密码写在常量里,方便一处修改 private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/sms?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/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 里三个参数值得单独说:useUnicode=true&characterEncoding=utf8决定驱动和数据库之间用什么编码通信;serverTimezone=Asia/Shanghai在 MySQL 8 驱动上是必须的,不写会抛The server time zone value is unrecognized;useSSL=false只是本地开发省掉证书握手,生产环境不要照抄。
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?action=list"); } else { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/jsp/login.jsp").forward(req, resp); } } }req.getSession()不加参数时,没有会话就新建一个,有就复用。setAttribute把用户对象挂上去,后续任何 Servlet 都能用getSession().getAttribute("loginUser")取到。登录成功这里用了重定向而不是转发,目的是防止用户按 F5 时重复提交表单——这是重定向与转发最典型的分工场景。
只把用户塞进 Session 还不够,未登录的人直接敲地址student?action=list一样能进。所以需要一个登录过滤器:
@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?action=list"); }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 PageResult<Student> queryPage(String keyword, int page, int size) { if (page < 1) page = 1; int offset = (page - 1) * size; String where = ""; List<Object> 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); List<Student> 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 prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table class="tbl"> <tr><th>学号</th><th>姓名</th><th>性别</th><th>年龄</th><th>专业</th><th>操作</th></tr> <c:forEach items="${page.rows}" var="s"> <tr> <td>${s.sno}</td> <td>${s.name}</td> <td>${s.gender}</td> <td>${s.age}</td> <td>${s.major}</td> <td> <a href="student?action=toEdit&id=${s.id}">编辑</a> <a href="student?action=delete&id=${s.id}" onclick="return confirm('确认删除?')">删除</a> </td> </tr> </c:forEach> </table> <div class="pager"> 共 ${page.total} 条,第 ${page.page}/${page.totalPages} 页 <c:if test="${page.hasPrev}"> <a href="student?action=list&page=${page.page - 1}&keyword=${param.keyword}">上一页</a> </c:if> <c:if test="${page.hasNext}"> <a href="student?action=list&page=${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表是空的,还以为代码有问题。
依赖只有三个 jar:MySQL 驱动、jstl.jar、standard.jar,全部丢进web/WEB-INF/lib。三者缺一不可:驱动缺失报ClassNotFoundException,JSTL 缺失报Unable to find taglib。Eclipse 里还要把 jar 加到项目的 Build Path,IDEA 里则要在 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.Driver,jar 包也不一样。这种版本差异导致的报错信息很含糊,看到Communications link failure先确认端口,看到Access denied先确认账号密码,看到Unknown column才去查 SQL。
编码要三层对齐:数据库建库时utf8mb4,JDBC URL 里characterEncoding=utf8,Servlet 里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;charset=UTF-8"); chain.doFilter(req, resp); } }setCharacterEncoding对 GET 请求的地址栏参数不起作用,GET 的中文要靠 Tomcat 的server.xml里Connector上的URIEncoding="UTF-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 contentType="text/html;charset=UTF-8" %>。GET 参数的中文单独处理,改server.xml的Connector加URIEncoding="UTF-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/lib,Eclipse 的 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 type="hidden" name="id" value="${student.id}">;DAO 层在 update 前断言 id 大于 0,if (s.getId() <= 0) throw new IllegalArgumentException("缺少主键"),把这种错误拦在写库之前。
5.5 刷新页面就重复提交,或者转发之后样式丢失
现象:新增成功后按 F5,浏览器提示重新提交表单,一确认就多一条重复数据;另一种是转发到 JSP 后 HTML 出来了但没有样式。
原因:写操作完成后用了forward而不是sendRedirect,浏览器地址栏还停在 POST 的 URL 上。样式丢失则是 JSP 里的 CSS 用了相对路径,页面 URL 一深,相对路径就指错了目录。
解决:写操作成功后一律resp.sendRedirect(req.getContextPath() + "/student?action=list"),这就是常说的 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); List<Student> 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-resources | DAO 可逐步替换 |
| 配置 | 常量写死 | db.properties | 新增一个文件 |
db.properties里放 url、user、password、initialSize、maxActive,DBUtil静态块读一次,用Properties.load加载,改配置不用重新编译。
改完怎么验证。别只点页面。我的习惯是准备三条断言:新增两个学号相同的学生,第二个必须失败且数据库里只有一条;把页码传成0和-1,列表要能回到第 1 页而不是抛异常;把keyword传成' OR 1=1 --,搜索结果应为 0 条而不是全部。三条都过,说明参数校验、边界处理、SQL 注入防护这三个最容易出事的点都守住了。
改造到这儿,这个学生信息管理系统就不再只是一个能演示的课程设计,而是一个结构清楚、能往里加模块的小工程。从那以后我每次把工程交给别人之前,都会先在自己的干净环境里按文档从头走一遍,一条命令一条命令地敲,确认没有“我这里能跑”的假设。希望帮到你。
本文还有配套的精品资源,点击获取