ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JSP+Servlet学生信息管理系统:MVC请求链路与避坑

JSP+Servlet学生信息管理系统:MVC请求链路与避坑

简介:这是一套面向高校课程设计与Java Web入门者的学生信息管理系统项目资料,采用JSP+Servlet为核心,结合Layui、jQuery完成页面交互,后端连接MySQL保存数据,支持学生自主注册登记、教师集中管理学生信息,适合用来练习分层开发、表单提交、会话控制与数据库增删改查,也便于理解传统Java Web项目的请求响应流程。资源包共2个文件,以1个zip源码压缩包和1个htm说明页为主,整体约2.13MB;源码包内可查看JSP页面、Servlet处理逻辑、实体与数据库操作代码,说明页便于快速了解目录和运行方式。项目基于JDK7、Tomcat8、MySQL与IntelliJ IDEA环境,依赖jar已随项目lib目录提供,可直接导入调试或在此基础上扩展班级、成绩等模块。目前已有6437人学习,适合需要完成课程设计、进行Java Web入门实战或参考Servlet项目结构的学生与开发者,对课程设计答辩、项目复现和二次开发均有参考价值。

1. 一套能跑通的 JSP+Servlet 学生信息管理系统,适合谁拿来练手

有位同学拿着一份课程设计来找我,页面能打开、数据也能查出来,但答辩老师问了一句“你的请求是从哪进到哪出的”,他半天没答上来。问题不在功能,而在他全程把 Java 代码和 SQL 写在 JSP 里,Servlet 只当了个摆设。这套基于 JSP+Servlet 的简单学生信息管理系统正好是反面教材的解药:它把控制器交给 Servlet,把视图留在 JSP,把 SQL 统一放在 DAO 层,学生信息的新增、删除、修改、查询、模糊搜索和分页都跑在一条清晰的 MVC 链路上,数据库用 MySQL,容器用 Tomcat,没有 Spring 那一堆注解和依赖要啃。适合两类人:一是要交课程设计、需要一个完整可演示后台的学生,二是写久了框架、想回头把 Java Web 请求链路重新摸一遍的开发者。它的体量刚好,一晚上能读完,一周能改出自己的东西。

2. 工程骨架与技术选型:Tomcat 里到底放了哪些东西

拿到一份 Java Web 项目,先别急着点运行,先看目录。Eclipse 时代的动态 Web 工程和 IDEA 的 Maven 工程长得不一样,这套源码属于前者,也就是src+WebContent那种经典结构。目录看懂了,后面 404、ClassNotFound 这类问题基本能自己定位,不用靠猜。

2.1 目录结构:src 放 Java,WebContent 放 JSP

标准结构大概是这样,我用tree的形式列出来:

StudentManager/ ├── src/ # Java 源码,编译后进 WEB-INF/classes │ └── com/example/sms/ │ ├── bean/ # 实体类,和表一一对应 │ │ ├── Student.java │ │ └── Admin.java │ ├── dao/ # 数据访问层,SQL 只在这层出现 │ │ ├── StudentDao.java │ │ └── AdminDao.java │ ├── servlet/ # 控制器,接收请求、调 DAO、转发 │ │ ├── StudentListServlet.java │ │ ├── StudentAddServlet.java │ │ ├── StudentUpdateServlet.java │ │ ├── StudentDeleteServlet.java │ │ └── LoginServlet.java │ ├── filter/ # 编码过滤器、登录拦截器 │ │ ├── EncodingFilter.java │ │ └── LoginFilter.java │ └── util/ # JDBC 工具类 │ └── DBUtil.java ├── WebContent/ # Web 资源根目录 │ ├── WEB-INF/ │ │ ├── web.xml # Servlet/Filter 映射,或改用注解 │ │ └── lib/ # 第三方 jar,必须放这里 │ │ ├── mysql-connector-java-5.1.47.jar │ │ ├── jstl.jar │ │ └── standard.jar │ ├── jsp/ # 视图页面 │ │ ├── login.jsp │ │ ├── student_list.jsp │ │ ├── student_add.jsp │ │ └── student_edit.jsp │ ├── css/ │ └── index.jsp # 入口,一般重定向到列表或登录 └── .classpath / .project # Eclipse 工程描述文件

src下的包按 bean、dao、servlet、filter、util 分,这是 Java Web 最朴素的分层,没有任何框架魔法。WebContent是浏览器能直接访问的根目录,但它下面的WEB-INF是个禁区,浏览器访问不到,所以web.xml和lib放在这里是安全的。很多人把mysql-connector的 jar 拖到项目根目录或者WebContent外面,编译期一切正常,一启动就ClassNotFoundException,血泪经验就来自这里——jar 必须落在WEB-INF/lib下,Tomcat 才会把它加进 Web 应用的类加载器。

提示:如果用 IDEA,目录名可能是web而不是WebContent,含义一样,都是 Web 资源根。配置 Artifact 时记得把WEB-INF/lib一起打进 war。

2.2 技术选型:为什么是 JSP+Servlet 而不是框架

这套系统里,Servlet 承担的是 DispatcherServlet 的角色,web.xml或@WebServlet承担的是 HandlerMapping 的角色,DAO 承担的是 Mapper 的角色。你现在用 Spring MVC 写的东西,它一样不少,只是全部手写。选它的理由很实际:

第一,依赖极少。一个 MySQL 驱动、两个 JSTL 的 jar,加上 Servlet API(Tomcat 自带),就能跑。没有 Maven 仓库连不上、依赖冲突、版本不兼容的折磨,对于要在机房电脑或者别人的笔记本上现场演示的课程设计来说,这一点比什么都重要。

第二,请求链路是可见的。请求从http://localhost:8080/StudentManager/student/list进来,容器根据web.xml里的<url-pattern>找到对应的 Servlet 类,调service(),再分发到doGet(),你在 IDE 里打断点,能一路跟到底。框架帮你藏起来的东西,这里全是明牌,适合把 Java Web 的基础打牢。

第三,改起来没有心理负担。加一个字段,改实体类、改建表语句、改 JSP、改 DAO 里两条 SQL,四处就完事,不用去翻注解、配置类和自动装配。当然代价也明显:业务逻辑会和 Servlet 混在一起,事务要靠手动提交,代码量随功能线性膨胀。所以我在第 6 章会讲怎么往 Service 层和连接池上改,那是这套代码真正能“长大”的地方。

数据库这边是 MySQL,字符集建议直接utf8mb4,连接串里显式带上characterEncoding=utf8,否则中文姓名、家庭住址这类字段很可能在插入时变成问号。JDK 用 8 就够,Tomcat 8.5 或 9 都行,但要注意 Tomcat 10 之后包名从javax.servlet变成了jakarta.servlet,源码里所有import javax.servlet.*都会编译不过,这是一个很常见的翻车点。

2.3 环境准备与第一次跑起来

按顺序做,不要跳步:

  1. 装 JDK 8,配置JAVA_HOME,命令行敲java -version确认。
  2. 装 MySQL 5.7 或 8.0,建库student_manager,字符集选utf8mb4。
  3. 把项目里的建表 SQL 导进去(下一章会给完整语句)。
  4. 打开DBUtil.java,改数据库地址、用户名、密码,这是唯一需要改配置的地方。
  5. Eclipse 导入 Existing Projects into Workspace,或者 IDEA 用File → Open打开,配置好 Tomcat 运行时。
  6. 确认WEB-INF/lib下的三个 jar 都在,右键项目Build Path里没有红色报错。
  7. 部署到 Tomcat,启动,浏览器访问http://localhost:8080/StudentManager/。

启动之后第一件事不是点功能,是看控制台。Tomcat 的日志里如果出现ClassNotFoundException、No suitable driver、Unknown database,基本就是驱动、连接串、库名三类问题,第 5 章会逐条拆。日志干净了,页面能出来,这套系统最难的坎就过去了。

3. 数据库与 DAO 层:五张表撑起的增删改查

一个学生信息管理系统听起来简单,但真要做完整,至少得有三类数据:登录账号、学生档案、以及可选的班级和成绩。这套源码常见做法是四到五张表:admin存管理员账号,student存学生档案,class_info存班级,score选修课成绩,再加一张major或者把专业直接冗余在student表里。表不多,但每一张都对应一个明确的 DAO 方法,这就是它能被当作教学样本的原因。

3.1 表结构设计与建表 SQL

先把库和表建好。下面这份 SQL 是我按这套系统的字段习惯补出来的,字段名和后续 Java 代码一一对应:

-- 创建数据库,字符集用 utf8mb4,避免生僻字和 emoji 存不进去 CREATE DATABASE IF NOT EXISTS student_manager DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE student_manager; -- 管理员表:登录用,密码字段在原始版本里多为明文 CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(32) ); -- 学生表:核心表,学号唯一,班级用外键或冗余字段 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(32) NOT NULL COMMENT '姓名', gender CHAR(2) DEFAULT '男' COMMENT '性别', age INT DEFAULT 18, birthday DATE, phone VARCHAR(20), address VARCHAR(200), class_id INT COMMENT '所属班级', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 班级表:下拉框数据来源 CREATE TABLE class_info ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '班级名称', major VARCHAR(50) COMMENT '专业' ); -- 初始化一条管理员和一个班,方便第一次登录 INSERT INTO admin(username, password, real_name) VALUES ('admin', '123456', '管理员'); INSERT INTO class_info(name, major) VALUES ('软件一班', '软件工程');

这段 SQL 有三个地方值得说。sno加了UNIQUE,因为学号重复是学生管理里最典型的脏数据来源,靠程序判重不如让数据库兜底。class_id用的是INT而不是班级名字符串,后续做“按班级筛选”时只需要传一个 id,查询效率和不一致风险都好控制。create_time给了DEFAULT CURRENT_TIMESTAMP,插入时不用手动 set,少一个字段就少一个 NPE 的机会。

注意:sno和name这种字段如果用了utf8mb4,在低版本 MySQL 上联合索引长度可能超限,课程设计规模没问题,真要上量就把学号独立索引。

3.2 JDBC 工具类:连接、关闭、异常

所有 SQL 最终都从DBUtil拿连接,这个类写得好不好,直接决定后面会不会出现“跑两天连接耗尽”。原始版本常见写法是每次DriverManager.getConnection()新建连接,用完close(),小并发下没毛病,但要知道它的边界在哪里。

package com.example.sms.util; import java.sql.*; public class DBUtil { // 8.0 驱动类名是 com.mysql.cj.jdbc.Driver,5.x 才是 com.mysql.jdbc.Driver private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/student_manager" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASSWORD = "root"; static { try { // 静态块只加载一次驱动,避免每条请求都反射 Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL 驱动未找到,检查 WEB-INF/lib: " + e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 统一关闭,顺序必须是 ResultSet -> Statement -> Connection public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }

URL里那串参数每一个都有原因:useUnicode+characterEncoding决定中文能不能正确传输,useSSL=false是为了避免 8.0 驱动默认要求 SSL 时控制台刷警告,serverTimezone=Asia/Shanghai则是 8.0 驱动不认系统时区、插入时间差 8 小时的标准解法。close方法把三个资源包在一起,调用方只要把引用传进来,哪怕某一步为 null 也不会炸,这是比每处try-finally更省心的写法。

3.3 StudentDao:把 SQL 收口到一层

DAO 层的价值在于,全项目只有这里出现SELECT、INSERT。列表查询要分页,我把findByPage和count拆成两个方法,因为分页组件需要先知道总数才能算页数:

package com.example.sms.dao; import com.example.sms.bean.Student; import com.example.sms.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class StudentDao { /** 按关键字模糊查询,分页返回。keyword 为空时查全部 */ public List<Student> findByPage(String keyword, int page, int pageSize) { List<Student> list = new ArrayList<>(); // LIMIT 的两个占位符分别是 offset 和 size,Math.max 防止负数页码 String sql = "SELECT s.*, c.name AS class_name FROM student s " + "LEFT JOIN class_info c ON s.class_id = c.id " + "WHERE s.name LIKE ? OR s.sno LIKE ? " + "ORDER BY s.id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + (keyword == null ? "" : keyword.trim()) + "%"; ps.setString(1, like); ps.setString(2, like); ps.setInt(3, Math.max(0, (page - 1)) * pageSize); ps.setInt(4, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { list.add(mapRow(rs)); } } } catch (SQLException e) { throw new RuntimeException("查询学生列表失败", e); } return list; } /** 与 findByPage 使用同一套 WHERE 条件,保证总数和列表对得上 */ public int count(String keyword) { String sql = "SELECT COUNT(*) FROM student WHERE name LIKE ? OR sno LIKE ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String like = "%" + (keyword == null ? "" : keyword.trim()) + "%"; ps.setString(1, like); ps.setString(2, like); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getInt(1) : 0; } } catch (SQLException e) { throw new RuntimeException("统计学生数量失败", e); } } public int insert(Student s) { String sql = "INSERT INTO student(sno,name,gender,age,birthday,phone,address,class_id) " + "VALUES(?,?,?,?,?,?,?,?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, s.getSno()); ps.setString(2, s.getName()); ps.setString(3, s.getGender()); ps.setInt(4, s.getAge() == null ? 18 : s.getAge()); // java.util.Date 转 java.sql.Date,这是初学者最容易漏的一步 ps.setDate(5, s.getBirthday() == null ? null : new java.sql.Date(s.getBirthday().getTime())); ps.setString(6, s.getPhone()); ps.setString(7, s.getAddress()); ps.setInt(8, s.getClassId() == null ? 0 : s.getClassId()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("新增学生失败", e); } } public int deleteById(int id) { String sql = "DELETE FROM student WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("删除学生失败", e); } } /** 把结果集一行映射成实体,新增字段只改这一处 */ private Student mapRow(ResultSet rs) throws SQLException { 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.setBirthday(rs.getDate("birthday")); s.setPhone(rs.getString("phone")); s.setAddress(rs.getString("address")); s.setClassId(rs.getInt("class_id")); s.setClassName(rs.getString("class_name")); return s; } }

findByPage里用了LEFT JOIN,是为了列表页能直接显示班级名而不是一个数字 id,避免 JSP 里再发一次查询。PreparedStatement的?占位符不只是拼串方便,更重要的是防 SQL 注入——搜索框里输入' OR '1'='1这类内容,用字符串拼接的版本会把整张表捞出来,用占位符则只会当成普通文本匹配。mapRow单独抽出来是因为增删改查之外,后面加“按 id 查询”时还能复用。insert里那个java.util.Date到java.sql.Date的转换,是birthday字段为DATE类型时必须做的适配,不做的话编译期就过不去。

提示:DAO 里抛RuntimeException是为了让 Servlet 层能统一接住并跳错误页,真实项目里更推荐自定义DaoException,这里保持简单。

4. Servlet 与 JSP 的配合:一次「查询学生列表」请求的完整链路

功能里最能体现 MVC 的就是列表页:地址栏带page和keyword两个参数,Servlet 接住、调 DAO、把结果塞进 request 域,再 forward 给 JSP 渲染。这条链路走通了,增删改只是换个方法名的事。下面按请求进入的顺序拆。

4.1 请求进来:Servlet 的 doGet 与参数接收

列表请求是 GET,参数从request.getParameter()拿。有两个坑要提前处理:页码可能是空串,关键字可能带空格,任何一个没判空都会在 DAO 里变成NumberFormatException或者查出奇怪的结果。

package com.example.sms.servlet; import com.example.sms.bean.Student; import com.example.sms.dao.StudentDao; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.util.List; // 注解方式映射,等价于 web.xml 里的 servlet + servlet-mapping @WebServlet("/student/list") public class StudentListServlet extends HttpServlet { private final StudentDao studentDao = new StudentDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 取参数,给默认值,杜绝 null 往下传 String keyword = req.getParameter("keyword"); String pageStr = req.getParameter("page"); int page = 1; try { page = Integer.parseInt(pageStr); } catch (NumberFormatException ignored) { // 参数缺失或非法时回落到第一页,不抛异常给用户看 } int pageSize = 8; // 每页条数,可按需调整 // 2. 调 DAO,先总数后列表 int total = studentDao.count(keyword); int totalPages = (total + pageSize - 1) / pageSize; // 向上取整 if (page > totalPages && totalPages > 0) page = totalPages; List<Student> students = studentDao.findByPage(keyword, page, pageSize); // 3. 放进 request 域,供 JSP 用 EL 取 req.setAttribute("students", students); req.setAttribute("page", page); req.setAttribute("totalPages", totalPages); req.setAttribute("total", total); req.setAttribute("keyword", keyword == null ? "" : keyword); // 4. 转发不是重定向:地址栏不变,request 域还在 req.getRequestDispatcher("/jsp/student_list.jsp").forward(req, resp); } }

totalPages那个(total + pageSize - 1) / pageSize是整数向上取整的老写法,比Math.ceil少一次浮点转换。page > totalPages的判断是为了处理“删完最后一页数据后页码越界”的情况,不加的话列表空白但分页条还显示在第 5 页,用户会以为数据丢了。forward和redirect的区别在这里很关键:转发是服务器内部跳,浏览器地址栏还是/student/list?page=2,刷新不会重复提交;如果用sendRedirect,request 域里的students会全部丢失,页面直接空掉。这个点我在代码评审里见过太多人写反。

4.2 分页与模糊搜索的参数怎么联动

分页条上的每个链接都得把keyword带上,否则点第二页搜索条件就丢了。JSP 里拼 URL 时容易漏转义,keyword里有中文会被浏览器编码成%E5%BC%A0,这没问题,但如果含&或#就会截断。稳妥做法是先URLEncoder.encode一次再拼,或者在 Servlet 里统一处理。

分页本身有两个参数值得调:pageSize写死 8 是课程设计的习惯,实际项目一般 10 或 20,并且做成配置项;page的边界处理除了上面说的上限,还要防负数,DAO 里Math.max(0, page-1) * pageSize兜的就是这个。搜索这里用的是LIKE '%关键字%',前置通配符会让索引失效,数据量上万以后会明显变慢,改造时换成name LIKE '关键字%'或者上全文索引,第 6 章会提。

4.3 转发到 JSP:request 域与 JSTL 渲染

JSP 只负责展示,一行 Java 脚本都不该有。用 JSTL 的c:forEach遍历,c:if控制空数据提示,fmt:formatDate格式化生日:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <head><title>学生列表</title></head> <body> <form action="${pageContext.request.contextPath}/student/list" method="get"> 姓名/学号:<input type="text" name="keyword" value="${keyword}"> <button type="submit">搜索</button> </form> <table border="1" cellspacing="0"> <tr><th>学号</th><th>姓名</th><th>性别</th><th>年龄</th> <th>生日</th><th>班级</th><th>操作</th></tr> <c:forEach items="${students}" var="s"> <tr> <td>${s.sno}</td> <td>${s.name}</td> <td>${s.gender}</td> <td>${s.age}</td> <%-- birthday 是 java.util.Date,用 fmt 标签格式化,避免打印出 GMT 字符串 --%> <td><fmt:formatDate value="${s.birthday}" pattern="yyyy-MM-dd"/></td> <td>${s.className}</td> <td> <a href="${pageContext.request.contextPath}/student/edit?id=${s.id}">编辑</a> <a href="${pageContext.request.contextPath}/student/delete?id=${s.id}" onclick="return confirm('确认删除?')">删除</a> </td> </tr> </c:forEach> </table> <%-- 空结果提示,比一张空表友好 --%> <c:if test="${empty students}"> <p>没有匹配的学生记录</p> </c:if> <p>共 ${total} 条,第 ${page} / ${totalPages} 页</p> </body> </html>

${pageContext.request.contextPath}拿的是项目上下文路径/StudentManager,所有链接都拼上它,项目改名或者部署到根路径都不会 404。fmt:formatDate必须用,直接${s.birthday}会输出Mon Jan 01 00:00:00 CST 2001这种字符串,用户看不懂。删除链接上的confirm是一层最廉价的防误触,真项目里还得配权限校验,防止有人直接构造 URL 删数据。

注意:如果页面出现${s.name}原样输出,八成是 JSTL 的 jar 没放全。jstl.jar和standard.jar两个都要,缺一个 EL 就不解析。

4.4 web.xml 映射与注解两种写法

这套源码大概率两套都用得上:Servlet 用@WebServlet,Filter 因为执行顺序需要,常写在web.xml里。两种方式并存时注意别重复注册,同一个类既加注解又在web.xml里配一遍,启动会报IllegalArgumentException。

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> <!-- 编码过滤器放最前面,保证后续所有 request 都是 UTF-8 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.example.sms.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 登录拦截,放行登录页和登录请求 --> <filter> <filter-name>loginFilter</filter-name> <filter-class>com.example.sms.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/student/*</url-pattern> </filter-mapping> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> </web-app>

过滤器顺序按filter-mapping在web.xml里出现的先后执行,所以编码过滤器必须排在登录拦截前面,否则拦截器里读中文参数会先乱码。LoginFilter里判断session.getAttribute("admin")是否为空,为空就sendRedirect到登录页,这是这套系统唯一的权限控制。它很薄,但足够挡住“不登录直接敲 URL 进列表页”的情况。

5. 避坑与排查:部署、乱码、连不上库的高频翻车现场

这套系统的功能不复杂,真正花时间的往往是环境和配置。下面五条是我在帮人看这类项目时出现频率最高的,按“现象 → 原因 → 解决”记下来,下次遇到直接对号入座。

5.1 启动就 404:路径、映射、上下文三选一

现象:Tomcat 起来了,访问http://localhost:8080/StudentManager/student/list返回 404,控制台没有任何异常。

原因:三种可能。一是@WebServlet("/student/list")写成了/studentList,注解值和访问路径不一致;二是web.xml里配了<url-pattern>/student/list</url-pattern>但类上也有注解,容器只认其中一份;三是访问时漏了项目名,直接敲localhost:8080/student/list。

解决:先在 Tomcat 日志里搜Mapping关键字,看容器实际注册了哪些路径;再把项目Deployment Assembly或 IDEA 的 Artifact 里上下文路径确认成/StudentManager;最后统一映射方式,要么全注解要么全 XML,别混着来。

5.2 中文变问号:四个地方要一起改

现象:新增学生时姓名输入“张三”,列表页显示???,或者搜索中文查不出结果。

原因:编码链路有四处断点——JSP 页面pageEncoding、request 的setCharacterEncoding、数据库连接串的characterEncoding、以及数据库和表的字符集。任何一处是 ISO-8859-1,整条链就废了。

解决:JSP 顶部写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;在EncodingFilter里统一request.setCharacterEncoding("UTF-8")和response.setContentType("text/html;charset=UTF-8");连接串加useUnicode=true&characterEncoding=utf8;建库建表用utf8mb4。如果是 GET 请求中文乱码,还得在 Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8",Tomcat 8 以后默认就是 UTF-8,老版本才需要。

5.3 ClassNotFoundException:jar 放错地方了

现象:启动或第一次查询时报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。

原因:驱动 jar 只加进了 IDE 的编译路径(Build Path),没放进WEB-INF/lib。编译期能过,运行时 Tomcat 的 Web 类加载器找不到它。

解决:把mysql-connector-java-*.jar复制一份到WebContent/WEB-INF/lib下,重新部署。顺便检查驱动类名,MySQL 5.x 是com.mysql.jdbc.Driver,8.x 是com.mysql.cj.jdbc.Driver,版本和类名对不上一样报这个错。

5.4 连接失败:时区、SSL、库名三连

现象:Communications link failure或者The server time zone value '...' is unrecognized。

原因:MySQL 8.0 驱动默认要求服务器时区可识别,系统时区是CST时会解析失败;另外 8.0 默认尝试 SSL,本地没配证书就会警告甚至拒绝连接。

解决:连接串补上serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true。如果报Unknown database 'student_manager',那就是库没建或者名字拼错,回去执行第 3 章的建库语句。

5.5 连接不关,跑一会儿就卡死

现象:功能刚启动正常,点几十次之后页面一直转圈,最后超时,重启 Tomcat 又好了。

原因:某个 DAO 方法里Connection、PreparedStatement、ResultSet有分支没走到close,连接池(或者 MySQL 的max_connections)被慢慢占满。异常分支里最容易漏。

解决:统一用 try-with-resources,像第 3 章StudentDao那样把资源声明在try (...)里,异常时也会自动关闭。临时排查可以在 MySQL 里执行SHOW PROCESSLIST,看到大量Sleep状态的连接就是泄漏的铁证。

6. 二次开发:把这套课程设计改造成能交付的后台

这套代码能跑,但直接拿去接活会很难受。我最先动的是分层:把 Servlet 里的参数校验、业务判断挪进StudentService,Servlet 只留“收参数、调 Service、转发”三件事。这一步做完,同一个业务逻辑在列表、新增、修改三个入口就不用抄三遍,改规则只改一处。

第二步换连接池。DriverManager每次新建 TCP 连接,在几十并发下延迟很明显。加一个 HikariCP 或者 Druid 的 jar,把DBUtil.getConnection()改成从DataSource取,代码里其他一行不用动:

// HikariCP 初始化,放在静态块里,全应用共用一个池 private static final HikariDataSource DS; static { HikariConfig cfg = new HikariConfig(); cfg.setJdbcUrl(URL); cfg.setUsername(USER); cfg.setPassword(PASSWORD); cfg.setMaximumPoolSize(10); // 按 Tomcat 线程数和 DB 上限调 cfg.setConnectionTimeout(3000); // 拿不到连接 3 秒就失败,别让用户干等 DS = new HikariDataSource(cfg); } public static Connection getConnection() throws SQLException { return DS.getConnection(); // 调用方 close() 时归还到池,不是真关 }

第三步是安全。admin表里明文存123456,这在任何交付场景都说不过去,用 BCrypt 在注册/改密时哈希,登录时BCrypt.checkpw()比对,改动量不到二十行。同时把LoginFilter的放行名单显式列出来,别用“只要不是登录页就拦”这种反向逻辑,后期加静态资源会误伤。

第四步才是界面。JSP 里那套border="1"的表格换成 Bootstrap 的table table-hover,分页条换成组件,前端观感立刻不一样,但底层请求链路没变,风险可控。改完按这个顺序验证:先跑一遍增删改查确认数据对,再用两个浏览器同时登录确认 session 互不干扰,最后用ab或 JMeter 压五十个并发看连接池会不会被打满。

改造项改动位置风险验证方式
抽 Service 层servlet 包 → 新增 service 包低三个入口功能回归
引入连接池DBUtil中并发 50 次查询无超时
密码哈希AdminDao、LoginServlet中老账号需重置密码
分页组件化student_list.jsp低首页/末页/越界页

有次我图省事,改完连接池没压测就交了,结果对方导入了两万条学生数据,列表页一开就Connection is not available,查了半天才发现池大小设成了 2。从那以后我每次动数据访问层,都强制先跑一遍“翻到最后一页 + 连续搜索十个关键词”的流程再提交。这套 JSP+Servlet 的底子不新,但把这几处磨平,它完全能撑起一个内部使用的学生档案后台,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进