简介:这是一套面向高校计算机相关专业课程设计场景的 JavaWeb 学生管理系统项目源码,采用 MVC 分层组织,适合正在完成 JavaWeb 课设、需要参考登录校验与增删改查实现的学生。压缩包共 35 个文件、约 2.15MB,包含 9 个 Java 源文件、8 个 HTML 页面以及 JPG/PNG 界面素材、CSS、JS、XML 配置和 docx 说明文档,可据此了解项目结构、页面跳转与数据操作流程。系统覆盖账号密码验证、背景图轮播登录页,以及学生信息的新增、按学号查询、查询全部、修改和删除等核心功能,并附有效果图与描述文档辅助理解。已有 4376 人学习,适合作为课程设计原型、功能拆分参考和 MVC 入门练习素材。
1. 学生管理系统能干什么:一份 JavaWeb 课程设计源码的真实边界
下载下来的课程设计源码,第一眼往往是二十几个 JSP 加一堆 Servlet,文件名还叫StudentServlet1.java,心里先没底:这东西能跑吗?跑起来能改吗?这份JavaWeb 学生管理系统课程设计走的是最经典的MVC路线——JSP 当视图、Servlet 当控制器、JavaBean 加 DAO 当模型,功能覆盖学生信息增删改查、分页列表、按姓名或学号模糊搜索、成绩录入、登录鉴权五块。它不是 Spring Boot 那种一上来就自动装配的现代工程,但对答辩和新手理解请求链路反而是好事:一个请求从哪进、Session 什么时候写、SQL 在哪张表上拼,全都摆在明面上,没有黑匣子。适合两类人:要交课程设计、需要一套能跑能改骨架的学生,和刚学完 Servlet、想找个完整项目把 MVC 串起来的人。
2. 技术选型与工程骨架:Servlet + JSP 的 MVC 分层怎么摆
这套代码的技术栈没有任何花哨的东西:JDK 8、Servlet 3.1、JSP 2.3、Tomcat 8/9、MySQL 5.7 或 8.0、原生 JDBC 加DriverManager,前端是 JSP 里嵌 JSTL 加一点 jQuery 的 ajax。选型逻辑很实际——课程设计要讲清楚"每一层在干什么",框架越薄越好讲。用 Spring Boot 反而会把依赖注入、自动配置这些东西推到台前,答辩老师一问"你的请求怎么到 Controller 的",答不上来就尴尬了。
原生 JDBC 的代价是连接管理要自己写,没有连接池的话并发一高就Too many connections。常见做法是加一个 Druid 或者 C3P0 的 jar,把DBUtil.getConnection()从DriverManager换成dataSource.getConnection(),改动量不到十行,这是我在这种项目里第一步就做的事。
2.1 目录结构:webapp 与 src 的双树干
先看清楚文件都放哪,比一上来就读代码有用得多。典型的 Eclipse/IDEA 动态 Web 工程结构大致如下(IDEA 的 Artifacts 输出目录会是out/artifacts/...,本质一样):
student-manager/ ├── src/ │ └── com/example/sms/ │ ├── controller/ # Servlet 层,只做收参、转发 │ │ ├── LoginServlet.java │ │ ├── StudentServlet.java │ │ └── ScoreServlet.java │ ├── service/ # 业务层,事务边界在这里 │ │ ├── StudentService.java │ │ └── impl/StudentServiceImpl.java │ ├── dao/ # 持久层,只碰 SQL │ │ ├── StudentDao.java │ │ └── impl/StudentDaoImpl.java │ ├── entity/ # 与表一一对应的 JavaBean │ │ ├── Student.java │ │ ├── Score.java │ │ └── User.java │ ├── util/ # 工具类 │ │ ├── DBUtil.java │ │ ├── PageBean.java │ │ └── MD5Util.java │ └── filter/ │ ├── EncodingFilter.java │ └── LoginFilter.java ├── web/ │ ├── WEB-INF/ │ │ ├── web.xml # 老项目在这里配 Servlet,新写法用注解 │ │ ├── lib/mysql-connector-java-8.0.28.jar │ │ └── classes/ # 编译输出,千万别提交到仓库 │ ├── jsp/ │ │ ├── login.jsp │ │ ├── student_list.jsp │ │ └── student_edit.jsp │ ├── css/ js/ │ └── index.jsp └── README.md这个结构里有两个位置是新手最容易放错的。第一是db.properties,它必须放在src根目录下,编译后才会落到WEB-INF/classes/db.properties,getResourceAsStream("db.properties")才读得到;放到web/下面,类加载器是看不见的。第二是 JSP 的位置,凡是想被WEB-INF保护的页面(不希望用户直接在地址栏敲.jsp访问)就放进WEB-INF/jsp/,代价是只能靠forward过去,不能redirect。这份代码里的student_edit.jsp放在web/jsp/下,等于允许直接访问,带参数的页面会有空指针风险,后面第 5 章会再提。
2.2 一张表看清各层职责与依赖方向
MVC 说起来简单,真写起来最容易犯的错是"controller 里直接写 SQL"或者"DAO 里拿到 request 对象"。这套代码的依赖方向是单向的:controller → service → dao → entity,谁都不许反向依赖。把这张表贴在显示器边上,写代码时对一遍就知道自己有没有越界:
| 层 | 拿到什么 | 只该做什么 | 越界后的典型症状 |
|---|---|---|---|
| controller(Servlet) | HttpServletRequest | 收参数、做非空校验、调 service、forward到 JSP | 出现SELECT语句,改需求要动两层 |
| service | 普通 Java 参数 | 业务规则、事务、组装分页对象 | 出现request.setAttribute,单元测试跑不起来 |
| dao | 基本类型 / entity | 拼 SQL、PreparedStatement赋值、结果集映射 | 出现HttpSession,换前端就得重写 |
| entity | 无 | 字段 + getter/setter,字段名对齐列名 | 塞了业务方法,变成四不像 |
| filter | ServletRequest | 编码、鉴权、日志,成对调用chain.doFilter | 忘了放行,整站 404 或白屏 |
依赖方向反过来就是灾难。我见过有人把LoginFilter里读出来的用户对象直接塞进StudentDaoImpl做权限过滤,结果 DAO 单测必须启动一个 Tomcat 才能跑,血泪经验——DAO 只认参数,不认上下文。
2.3 环境与依赖清单:版本对不上就是灾难
跑不起来的项目,八成死在版本上。这套代码的兼容窗口如下:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 8(也可 11) | 源码是source=1.8,JDK 17 编译会因模块化报错 |
| Tomcat | 8.5 或 9.0 | 别用 Tomcat 10,javax.servlet全部改名成jakarta.servlet,一行都编译不过 |
| MySQL | 5.7 / 8.0 | 5.7 不强制时区,8.0 必须带serverTimezone |
| JDBC 驱动 | mysql-connector-java-8.0.28.jar | 5.x 驱动连 8.0 库会报Unknown system variable 'query_cache_size' |
| IDE | IDEA / Eclipse | IDEA 要手动配 Artifacts 和 Tomcat 部署,Eclipse 直接 Run on Server |
两个坑现在就点掉。第一,web/WEB-INF/lib/下的驱动包必须在 IDEA 的 Project Structure → Artifacts 里被包含进输出,否则启动报ClassNotFoundException: com.mysql.cj.jdbc.Driver,而代码明明没错。第二,Tomcat 9 的conf/server.xml里Connector默认没有URIEncoding,GET 请求里的中文搜索词会乱码,虽然 Tomcat 8 之后默认是 UTF-8,但老项目常常显式写着URIEncoding="ISO-8859-1",看到就改成UTF-8。
3. 从源码到能登录:建库、改配置、部署到 Tomcat 的完整链路
代码看得懂但跑不起来,是最打击人的状态。这一章按照"建库 → 改配置 → 编译打包 → 部署验证"的顺序走一遍,每一步都给出能直接抄的命令或脚本,以及失败时该看哪里。
3.1 建库建表:字段设计与初始账号
先把库和两张主表建好,字符集统一utf8mb4,否则学生名字里的生僻字会变成问号。密码字段存 MD5 小写值,不存明文,这是答辩时能加分的细节。
-- 建库,字符集必须显式指定,依赖 MySQL 默认值会在别人机器上翻车 CREATE DATABASE IF NOT EXISTS student_manager DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE student_manager; -- 登录账号表 CREATE TABLE `sys_user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL COMMENT '存 32 位小写 MD5', `real_name` VARCHAR(32) DEFAULT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0 管理员 1 教师' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 学生信息表,sno 学号必须唯一,业务上它就是主键的替代品 CREATE TABLE `student` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `sno` VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', `name` VARCHAR(32) NOT NULL, `gender` CHAR(2) DEFAULT '男', `age` INT DEFAULT NULL, `major` VARCHAR(64) DEFAULT NULL, `phone` VARCHAR(20) DEFAULT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_name` (`name`) -- 模糊搜索走这个索引前缀才有意义 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 初始管理员:admin / admin,MD5("admin") 就是下面这串 INSERT INTO `sys_user` (`username`, `password`, `real_name`, `role`) VALUES ('admin', '21232f297a57a5a743894a0e4a801fc3', '管理员', 0);这段 SQL 有两处值得多说一句。sno上加了唯一索引,意味着前端必须对重复学号做提示,否则用户点保存只会看到一片 500 白页,体验极差,service 层要捕获SQLIntegrityConstraintViolationException转成友好提示。idx_name是给LIKE '张%'这种前缀匹配用的,如果代码里写的是LIKE '%张%',索引直接失效,几千条数据无所谓,几万条就开始卡。初始账号的密码是admin,MD5 值可以直接用在线工具核对,别自己手写错一位。
3.2 db.properties 与 DBUtil:配置读取的唯一入口
数据库连接信息绝对不能硬编码在 Java 里,换台机器就要重新编译是自找麻烦。放到src/db.properties:
# MySQL 8.0 驱动类名带 cj,5.x 是 com.mysql.jdbc.Driver jdbc.driver=com.mysql.cj.jdbc.Driver # serverTimezone 不写,8.0 驱动会抛 The server time zone value is unrecognized jdbc.url=jdbc:mysql://127.0.0.1:3306/student_manager?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=123456读取逻辑封装在DBUtil里,用静态代码块保证只加载一次:
public class DBUtil { private static String url, user, password, driver; static { // 用类加载器读 classpath 根目录,路径不要带前导斜杠 try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { if (in == null) { throw new IllegalStateException("db.properties 不在 classpath 里"); } Properties p = new Properties(); p.load(in); // load 默认按 ISO-8859-1 解析,值里别写中文 driver = p.getProperty("jdbc.driver"); url = p.getProperty("jdbc.url"); user = p.getProperty("jdbc.username"); password = p.getProperty("jdbc.password"); Class.forName(driver); // 8.0 驱动可省略,但显式写便于排查 } catch (Exception e) { // 静态块抛异常会被包成 ExceptionInInitializerError,这里给出可读原因 throw new ExceptionInInitializerError("数据库配置加载失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } /** 关资源必须按 ResultSet → Statement → Connection 的逆序,各自 try 住 */ public static void close(ResultSet rs, Statement st, Connection conn) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (st != null) st.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }几个参数说明一下。allowPublicKeyRetrieval=true是 MySQL 8.0 默认caching_sha2_password插件下必须加的,不加会报Public Key Retrieval is not allowed,这个报错看着像网络问题,实际是认证插件问题。useSSL=false是为了消掉启动时的警告刷屏。静态块里抛ExceptionInInitializerError而不是RuntimeException,是因为类初始化失败后再次访问该类会直接抛NoClassDefFoundError,有个明确的第一现场信息排查会快很多。
3.3 编译打包与部署到 Tomcat
不用 Maven 的老工程,编译得手动带 classpath。Windows 下用分号分隔,Linux/Mac 用冒号:
# 1) 编译:把 src 下所有 java 编到 WEB-INF/classes # -encoding UTF-8 不加,源码里的中文注释在 GBK 环境下会编译报错 javac -encoding UTF-8 \ -cp "web/WEB-INF/lib/mysql-connector-java-8.0.28.jar:$CATALINA_HOME/lib/servlet-api.jar" \ -d web/WEB-INF/classes \ $(find src -name "*.java") # 2) 把 db.properties 一起拷进去,否则运行期读不到 cp src/db.properties web/WEB-INF/classes/ # 3) 在 web 目录内打 war,注意末尾的 "." 表示当前目录内容 cd web && jar -cvf ../student-manager.war . # 4) 丢进 Tomcat 并启动 cp ../student-manager.war $CATALINA_HOME/webapps/ $CATALINA_HOME/bin/startup.sh # Windows 换成 startup.bat参数含义:-cp里除了 JDBC 驱动,还必须包含servlet-api.jar,否则HttpServlet找不到,编译期就崩;-d指定输出目录必须和WEB-INF/classes对齐,放错位置运行期就是ClassNotFoundException;war 包名决定访问路径,叫student-manager.war就对应http://localhost:8080/student-manager/。
启动后看logs/catalina.out,出现Server startup in xxx ms才算成功。然后访问http://localhost:8080/student-manager/login.jsp,输入admin / admin。能跳转到列表页说明 JDBC、Filter、Servlet 三条链路都通了。踩坑提示:如果登录后页面空白但日志无异常,八成是forward的路径写成了/WEB-INF/jsp/student_list.jsp而实际文件在web/jsp/下,路径对不上时 Tomcat 会给 404,但浏览器如果被前端 js 吞掉就只剩白屏。
4. 核心请求链路拆解:一个 /student/list 请求穿过 Filter、Servlet 和 DAO
跑通之后就该看核心实现了。挑最有代表性的一条链路:用户带着搜索关键词和页码访问/student/list,请求依次穿过编码过滤器、登录过滤器、Servlet、Service、DAO,最后回到 JSP 渲染。这条链路能看懂,整个项目的骨架就摸清了。
4.1 两个 Filter:编码与鉴权,顺序不能反
过滤器执行顺序由web.xml里<filter-mapping>的声明顺序决定(注解方式则按类名字母序,不可控,生产项目建议写死web.xml)。编码过滤器必须在前,否则登录过滤器里读中文参数就已经乱码了。
// 编码过滤器:只处理 POST body,GET 的 URL 编码要靠 server.xml 的 URIEncoding @WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); // 必须在取任何参数之前调用 resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); // 放行;别在这里 return,否则请求被吞 } }// 登录过滤器:白名单放行登录页和静态资源,其余查 Session @WebFilter(urlPatterns = "/*", dispatcherTypes = {DispatcherType.REQUEST}) public class LoginFilter implements Filter { private static final String[] WHITE = {"/login.jsp", "/login", "/css/", "/js/", "/images/"}; @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // getRequestURI 带上下文路径,减掉才是业务路径 String uri = request.getRequestURI().substring(request.getContextPath().length()); for (String w : WHITE) { if (uri.startsWith(w)) { chain.doFilter(req, resp); return; } } if (request.getSession().getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; // 未登录必须 return,不能继续放行 } chain.doFilter(req, resp); } }dispatcherTypes = {DispatcherType.REQUEST}这行是重点。默认值是REQUEST,但如果有人手贱写成{REQUEST, FORWARD},Servlet 内部forward到 JSP 时会被再拦一次,此时如果白名单没覆盖/jsp/目录,就会出现"登录成功却一直跳回登录页"的玄学现象。我第一次遇到这个现象时查了两个小时,最后发现是转发被二次拦截。
4.2 Servlet 收参、Service 算页、DAO 拼 SQL
三层各管一段,界面上的"第 3 页,共 12 页"就是这么一层层算出来的。Servlet 只负责把字符串参数转成数字,不写任何业务判断:
@WebServlet("/student/list") public class StudentServlet extends HttpServlet { private final StudentService service = new StudentServiceImpl(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int pageNum = parseInt(req.getParameter("pageNum"), 1); // 缺省第 1 页 int pageSize = parseInt(req.getParameter("pageSize"), 10); String keyword = req.getParameter("keyword"); keyword = (keyword == null) ? "" : keyword.trim(); // trim 掉首尾空格再查 PageBean<Student> page = service.page(pageNum, pageSize, keyword); req.setAttribute("page", page); req.setAttribute("keyword", keyword); // 回填搜索框,刷新后关键词还在 req.getRequestDispatcher("/WEB-INF/jsp/student_list.jsp").forward(req, resp); } private int parseInt(String s, int def) { try { return Integer.parseInt(s); } catch (Exception e) { return def; } } }Service 层负责边界处理,这里的三个if是分页最容易出 bug 的地方:
@Override public PageBean<Student> page(int pageNum, int pageSize, String keyword) { if (pageNum < 1) pageNum = 1; // 限制上限,防止有人传 pageSize=999999 把整张表拉进内存 if (pageSize < 1 || pageSize > 100) pageSize = 10; int total = dao.count(keyword); // 向上取整必须写 (total + size - 1) / size,写成 total/size 会少一页 int totalPage = (total + pageSize - 1) / pageSize; // 删掉最后一页的最后一条后,当前页可能越界,回退到最后一页 if (totalPage > 0 && pageNum > totalPage) pageNum = totalPage; List<Student> rows = dao.findByPage((pageNum - 1) * pageSize, pageSize, keyword); PageBean<Student> pb = new PageBean<>(); pb.setPageNum(pageNum); pb.setPageSize(pageSize); pb.setTotal(total); pb.setTotalPage(totalPage); pb.setRows(rows); return pb; }DAO 层拼动态 SQL,用StringBuilder加参数列表,绝不做字符串拼接(SQL 注入就出在这):
@Override public List<Student> findByPage(int offset, int limit, String keyword) { StringBuilder sql = new StringBuilder( "SELECT id, sno, name, gender, age, major, phone FROM student WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.isEmpty()) { sql.append("AND (name LIKE ? OR sno LIKE ?) "); // 括号不能省,否则 AND 优先级出错 params.add("%" + keyword + "%"); params.add("%" + keyword + "%"); } sql.append("ORDER BY id DESC LIMIT ?, ?"); // MySQL 的 LIMIT 两个参数都可占位 params.add(offset); params.add(limit); List<Student> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); // JDBC 占位符下标从 1 开始 } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setSno(rs.getString("sno")); s.setName(rs.getString("name")); s.setGender(rs.getString("gender")); s.setAge(rs.getInt("age")); s.setMajor(rs.getString("major")); s.setPhone(rs.getString("phone")); list.add(s); } } } catch (SQLException e) { throw new RuntimeException("分页查询学生失败: " + e.getMessage(), e); } return list; }WHERE 1=1的写法看着土,但它是拼动态条件最省事的做法,避免了"第一个条件不加 AND"的判断分支。params必须与 SQL 里?的出现顺序严格一致,所以先拼条件再拼LIMIT,顺序错了参数就全串位,查出来的数据毫无规律——这类 bug 靠看 SQL 日志才能发现。
4.3 新增与修改:表单回填和主键判断
新增和修改共用同一个student_edit.jsp,靠 URL 上有没有id区分。修改时先把对象查出来塞进 request,JSP 用 EL 回填,否则用户点"编辑"看到一片空表单,体验直接崩:
@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String idStr = req.getParameter("id"); 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)); s.setMajor(req.getParameter("major")); s.setPhone(req.getParameter("phone")); if (idStr == null || idStr.isEmpty()) { service.add(s); // 没有 id 走 insert } else { s.setId(Integer.parseInt(idStr)); service.update(s); // 有 id 走 update where id=? } // 改完重定向到列表,避免刷新重复提交 resp.sendRedirect(req.getContextPath() + "/student/list"); }最后这句sendRedirect是必须的。如果改成forward,用户按 F5 会再提交一次表单,同一条学生数据进两遍,唯一索引拦得住学号,拦不住姓名。这个毛病在演示环节特别容易暴露,老师手一抖按个刷新,数据就多了一行。
5. 排查清单:五个把课程设计拖到凌晨的报错
下面五条基本覆盖了这类项目 90% 的翻车场景,每条都按"现象 → 原因 → 解决"写,遇到问题时对号入座比逐行读代码快得多。
5.1 POST 正常、GET 一搜中文就变问号
现象:表单提交的学生姓名正常显示,但用搜索框按姓名查(GET 请求)时,日志里打出来的 SQL 参数是LIKE '%??%',一条也查不到。
原因:request.setCharacterEncoding("UTF-8")只影响请求体(POST),对 URL 查询串(GET)无效。GET 参数的解码用的是Connector的URIEncoding,老配置里常常是ISO-8859-1。
解决:打开$CATALINA_HOME/conf/server.xml,找到 8080 端口的Connector,显式加URIEncoding="UTF-8",重启 Tomcat。改完还不行,检查数据库连接串里有没有characterEncoding=utf8,两头都对齐才彻底。
5.2 启动即崩:ExceptionInInitializerError
现象:一访问页面就抛ExceptionInInitializerError: 数据库配置加载失败: null,控制台没有更详细的信息。
原因:db.properties没有被复制到WEB-INF/classes/下。IDEA 里如果只把src标记为 Sources Root 而没把资源目录配进 Artifacts,getResourceAsStream返回null,p.load(null)抛空指针,被静态块包成了初始化错误。
解决:确认out/artifacts/项目名/WEB-INF/classes/db.properties这个文件真实存在。不存在就去 Project Structure → Modules → Sources 把src的资源类型勾上,或者手动cp一次。写代码时给getResourceAsStream的返回值加个null判断,报错信息会清楚十倍。
5.3 登录后 404:Servlet 注解没生效
现象:login.jsp能打开,提交表单后跳到http://localhost:8080/student-manager/login,直接 404。
原因:@WebServlet("/login")注解要求web.xml的version不低于 3.0 且metadata-complete不为true。老模板里常常写着version="2.5",注解被整体忽略,Servlet 根本没注册上。
解决:把web.xml开头的web-app版本改成 3.1 或 4.0,删掉metadata-complete="true"。或者老实一点,在web.xml里补<servlet>和<servlet-mapping>,两者取其一,别混着用,混用时映射路径冲突的报错非常难读。
5.4 跑一会儿就Too many connections
现象:连着点十几次列表页,页面开始 500,日志里Communications link failure,数据库那边show processlist看到一堆Sleep连接。
原因:DAO 里每次查询都用DriverManager.getConnection()新建物理连接。这种写法在没有连接池的情况下,只要哪个分支提前return没走到close,连接就漏一个,泄漏速度取决于用户点得多快。
解决:临时办法是把close放进try-with-resources里,保证异常也能关。正经办法是引入连接池,把DBUtil.getConnection()改成从DruidDataSource取,close()改成归还连接,业务代码一行不用动。这一步做完,项目从"能演示"变成"能扛住答辩现场五个人同时点"。
5.5 删完最后一页的数据后列表空白
现象:一共 21 条数据,每页 10 条,翻到第 3 页删掉唯一那条,页面变成空白表格,页码显示"第 3 页 / 共 2 页",点上一页才能恢复。
原因:删除后没有重算当前页码,offset = (3-1)*10 = 20,而总数只剩 20 条,LIMIT 20, 10自然查不到任何记录。
解决:在 Service 的page()里做越界回退(见 4.2 的if (totalPage > 0 && pageNum > totalPage)),并且在删除成功后重定向回列表时不要带原来的pageNum,让它重新从第一页开始算,逻辑更简单也更不容易错。
6. 二次开发改造:把课程设计改成能讲出设计取舍的系统
课程设计交完就扔太浪费了。这套代码的骨架是对的,缺的是工程细节,按下面的顺序改,两三天就能从"作业"变成简历上能讲的项目。
第一刀砍在安全上。密码明文比对改成 MD5 加盐,MD5Util里加个固定盐值(比如学号后四位),登录时先查用户再校验,别在 SQL 里直接where password = md5(?)。所有 DAO 方法检查一遍,凡是用了字符串拼接的Statement全部换成PreparedStatement,这是 SQL 注入的唯一入口。
第二刀砍在连接管理上。引入 Druid,DBUtil改成静态初始化DruidDataSource,加一个druid.properties配初始连接数 5、最大 20、validationQuery=SELECT 1。改完压测一下,用jmeter或者简单写个for循环起 20 个线程打列表接口,看show processlist里连接数是否稳定在 20 以内。
| 改造点 | 改之前 | 改之后 | 验证方式 |
|---|---|---|---|
| 密码存储 | 明文/MD5 裸值 | MD5 + 固定盐 | 库里的值对不上原始密码 |
| SQL 执行 | Statement拼接 | PreparedStatement | 输入' OR 1=1 --查不出全表 |
| 连接管理 | DriverManager | Druid 连接池 | 并发 20 请求连接数不涨 |
| 参数校验 | 前端 required | 后端 service 兜底 | 绕过前端直接发请求被拒 |
第三刀是把事务边界补上。现在的service.add()只操作一张表,所以没暴露问题;一旦加"新增学生同时初始化成绩记录",两条 SQL 就必须在一个事务里。做法是把Connection从 Service 传进 DAO,Service 里setAutoCommit(false),try成功commit、catch里rollback,finally恢复自动提交。这一步做完,你对"为什么事务该在 Service 层"会有完全不同的理解。
最后一刀留给测试。DAO 层写三个main方法级的用例(增、查、删),Service 层的分页逻辑用一个假 DAO 实现StudentDao接口注入进去,专门测pageNum越界和pageSize上限这些边界。别嫌土,我第一次把这套分页代码改出 bug 就是因为在第 3 页删数据,之后每次动分页逻辑,我都强制先把这三个用例跑一遍再提代码。
这套东西跑通、改完、测完,你手里就不只是一份能交的课程设计,而是一个能把"为什么用原生 JDBC 而不是 ORM""分页为什么要在 Service 算""过滤器顺序为什么不能反"讲清楚的项目。希望帮到你。
本文还有配套的精品资源,点击获取