JSP+Servlet+JDBC 学生信息管理系统:MVC 分层与请求链路

发布时间:2026/10/23 16:23:18
JSP+Servlet+JDBC 学生信息管理系统:MVC 分层与请求链路

简介:面向高校课程设计与Java Web入门练习的学生信息管理系统项目包,围绕学生自助注册、教师集中管理学生资料等场景展开,采用JSP+Servlet为核心,并结合Layui、jQuery完成页面交互,数据库使用MySQL,开发运行环境涉及JDK7、Tomcat8与IntelliJ IDEA。资源共2个文件,由1个项目压缩包和1个htm说明页组成,整体约2.13MB,说明页可用于了解项目结构、数据库文件与lib依赖包等信息,源码包则便于直接导入IDE查看和调试。已有6437人学习下载,适合需要完成课程设计、期末项目或Java Web综合练习的读者参考。项目涵盖注册登记、学生信息维护、教师管理、Servlet请求处理、JSP页面展示及MySQL数据存取等典型环节,可帮助读者梳理分层结构、前后端协作与基础增删改查实现,也便于在此基础上做功能扩展与界面调整。

1. 一套不靠框架的学生信息管理系统:JSP + Servlet 到底怎么把请求跑完

学生信息管理系统这个题目,几乎每个学期的 Java Web 课程设计都会撞上一次。需求一句话能说完:登录进去,把学生记录增删改查。可真动手时翻车的点一个不少——JSP 提交的中文变问号、Servlet 里getParameter拿到 null、MySQL 8 报 Public Key Retrieval is not allowed、war 丢进 Tomcat 之后 404。这套基于 JSP + Servlet 的简单实现走的是最朴素的 MVC:Servlet 收请求做控制,DAO 走 JDBC 碰 MySQL,JSP 只负责渲染,不引入 Spring 全家桶,mvn package出来的 war 丢进 Tomcat 就能访问。适合两类人:要交课程设计、需要一份能把请求链路和分层讲清楚的完整代码的同学;以及从框架里退回来、想搞明白 DispatcherServlet 之前那些活到底谁在干的开发者。

2. 工程骨架先立住:Maven 目录、依赖坐标与一条请求的完整链路

很多人拿到一份 JSP + Servlet 的代码,第一反应是双击index.jsp用浏览器打开,然后发现 EL 表达式原样输出、${}一片空白。Java Web 的调试方法里,这是最容易让人怀疑人生的玄学之一:它根本不是静态页面,必须由容器编译执行。所以第一步不是看代码,是把工程骨架和运行环境对齐。

2.1 为什么不用框架也能自洽:MVC 三分法与目录职责

这套系统能读得懂,靠的是一条硬规矩:每一层只做自己那一层的事。Servlet 里不出现一行 SQL,DAO 里不出现HttpServletRequest,JSP 里不出现<% %>脚本片段。听起来像老生常谈,但课程设计代码最常见的烂法就是从这条线开始塌的——一个StudentServlet里既prepareStatement又out.print("<table>"),两百行下去没人敢改。

目录按职责切,不按文件类型切:

student-manager ├── pom.xml └── src/main ├── java/com/example/sms │ ├── controller # 控制器:收参数、调 DAO、决定跳哪 │ │ ├── StudentServlet.java │ │ └── LoginServlet.java │ ├── dao # 数据访问:只认 SQL 和实体 │ │ └── StudentDao.java │ ├── entity # 实体:字段跟表一一对应 │ │ ├── Student.java │ │ └── SysUser.java │ ├── filter # 过滤器:编码、登录拦截 │ │ ├── EncodingFilter.java │ │ └── LoginFilter.java │ └── util # 工具:连接池、分页、密码 │ ├── DBUtil.java │ ├── Page.java │ └── PasswordUtil.java └── webapp ├── WEB-INF │ ├── web.xml │ └── views # 放在 WEB-INF 下,浏览器访问不到 │ ├── list.jsp │ ├── edit.jsp │ └── login.jsp ├── css/ └── index.jsp # 只做一次重定向,不写业务

视图放WEB-INF/views是个小设计但很值:外部无法直接拼 URL 访问list.jsp,所有入口都被 Servlet 卡住,登录拦截才有意义。否则你在LoginFilter里拦得再严,别人敲一个/WEB-INF之外的老页面照样绕过去。

层代表类该干的活不该出现的东西
控制层StudentServlet取参数、校验、调 DAO、forwardSQL 语句、HTML 拼接
数据层StudentDao拼 SQL、映射结果集request、session、response
视图层list.jsp遍历数据、渲染表格Class.forName、DriverManager
工具层DBUtil拿连接、还连接业务判断

index.jsp只写一句response.sendRedirect(request.getContextPath() + "/student?action=list");,让首页就是列表页,避免出现“首页空白”这种一看就废的第一印象。

2.2 用 Maven 搭骨架:pom、web.xml 与容器版本对齐

依赖这块坑最多,先把坐标写死。下面这份pom.xml只留必需项,注意scope的用法——servlet-api和jsp-api必须是provided,因为 Tomcat 自己带了一份,你把它打进WEB-INF/lib就会出现同一个类被两个类加载器加载,报错信息往往是一句毫无指向性的ClassCastException。

<properties> <maven.compiler.source>8</maven.compiler.source> <maven.compiler.target>8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- 容器自带,必须 provided,否则和 Tomcat 的类冲突 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <!-- JSTL 容器不带,必须打进 war --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> </dependencies>

web.xml用 Servlet 4.0 的 schema,跟 Tomcat 9 对齐。虽然现在@WebServlet注解很香,但web.xml至少留着做welcome-file和 Filter 顺序声明,两者不冲突。

<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <display-name>student-manager</display-name> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> </web-app>

容器版本必须和包名对齐,这一条能省掉大半“编译通过但跑不起来”的时间:

Tomcat 版本Servlet 规范包名前缀JSTL 标签 uri 前缀
8.53.1javax.servlethttp://java.sun.com/jsp/jstl/core
9.04.0javax.servlethttp://java.sun.com/jsp/jstl/core
10.05.0jakarta.servletjakarta.tags.core
10.16.0jakarta.servletjakarta.tags.core

我一般把 JDK 固定在 8,Tomcat 固定在 9.0.x,这一组是课程设计环境里最省心的组合:javax命名空间、JSTL 1.2 直接可用、MySQL 8 驱动也兼容。上来就选 Tomcat 10 的话,所有import javax.servlet.*全要改成jakarta.*,JSTL 坐标也得换成jakarta.servlet.jsp.jstl那一套,改到一半人容易崩。

打包命令只有一句,产物在target下:

mvn clean package -DskipTests # 产物:target/student-manager.war,直接丢进 TOMCAT_HOME/webapps/

2.3 追一条请求:从 index.jsp 到 list.jsp 的完整链路

骨架立住之后,最有价值的动作是把一条请求从头到尾追一遍。追通了,后面加功能就是填空;追不通,写十遍 CRUD 还是糊涂的。

浏览器敲http://localhost:8080/student-manager/student?action=list&page=2,发生的事按顺序是:

  1. Tomcat 按 context path/student-manager找到应用,把 URL 剩余部分/student映射到StudentServlet。
  2. 请求先过EncodingFilter(/*匹配),把request的字符集设成 UTF-8。
  3. 再过LoginFilter,从session里取loginUser,取不到就redirect到登录页。
  4. StudentServlet.doGet()读到action=list、page=2,调StudentDao.page()拿数据。
  5. DAO 从DBUtil借一条连接,执行两条 SQL:一条count算总数,一条带LIMIT拿当页数据。
  6. Servlet 把结果塞进request.setAttribute("page", pageObj),然后forward到/WEB-INF/views/list.jsp。
  7. JSP 编译成 Servlet(第一次访问会慢几百毫秒,属于正常现象),用 JSTL 遍历渲染表格,输出 HTML。

对应到代码,Servlet 的最小骨架长这样:

@WebServlet("/student") public class StudentServlet extends HttpServlet { private final StudentDao dao = new StudentDao(); // 无状态,可复用 @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null || action.isEmpty()) { action = "list"; // 默认动作,别让空 action 掉进 500 } switch (action) { case "list": list(req, resp); break; case "edit": edit(req, resp); break; // 跳到编辑页并回填 case "delete": delete(req, resp); break; default: resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "unknown action"); } } private void list(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String kw = req.getParameter("kw"); String cls = req.getParameter("cls"); int pageNo = parsePage(req.getParameter("page")); // 非法值兜底成 1 Page<Student> page = dao.page(kw, cls, pageNo, 10); req.setAttribute("page", page); req.setAttribute("kw", kw); req.setAttribute("cls", cls); // forward:同一个 request,属性带得过去,地址栏不变 req.getRequestDispatcher("/WEB-INF/views/list.jsp").forward(req, resp); } }

这里有两个容易被忽略的点。第一,parsePage必须对page=abc、page=-1、page=99999都做兜底,前端手改 URL 是常态,不兜底就是LIMIT -10,10这种 SQL 语法错误。第二,增删改完成后要用sendRedirect而不是forward,否则用户按 F5 会把删除操作再提交一遍——这是血泪经验,演示时最容易出洋相的地方。

3. 数据层落地:MySQL 建表、Druid 连接池与分页 SQL 的写法

数据层是这套系统里唯一“错一次、错一片”的部分。表结构定得随意,后面每个页面都要打补丁;连接忘了还,跑几十次点击就卡死。这一章按建表、连接池、分页三块拆。

3.1 建表:字段、类型与索引怎么定

学生表的字段不要贪多,课程设计里够用就行,但几个关键决策要有理由。

CREATE DATABASE IF NOT EXISTS sms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE sms; CREATE TABLE student ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, sno VARCHAR(20) NOT NULL COMMENT '学号,业务唯一', name VARCHAR(32) NOT NULL COMMENT '姓名', gender TINYINT NOT NULL DEFAULT 0 COMMENT '0未知 1男 2女', birthday DATE DEFAULT NULL, class_name VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级', phone VARCHAR(20) NOT NULL DEFAULT '', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_sno (sno), KEY idx_name (name), KEY idx_class (class_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sys_user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password CHAR(64) NOT NULL COMMENT 'sha256(salt + 明文)', salt CHAR(16) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

逐项说一下为什么这么定。字符集必须是utf8mb4,utf8三字节存不下 emoji 和部分生僻字,姓名里带生僻字的情况真的会碰到,一存就报Incorrect string value。学号单独建唯一键uk_sno,不要用学号当主键——学生转专业、退学复学改学号的场景虽然少见,但业务键和物理主键分离是基本习惯,外键关联也更好写。class_name建普通索引,因为列表页按班级筛选是高频操作;name建索引支持姓名模糊查询的前缀匹配,虽然%张%这种左模糊用不上索引,但数据量小的时候无所谓,知道这个边界即可。

gender用TINYINT不用CHAR(1),省空间且排序可控,页面渲染时用 JSTL 的c:choose转成“男/女”,别在数据库里存中文。create_time给DEFAULT CURRENT_TIMESTAMP,插入语句就不用管这个字段,少一处出错点。

提示:唯一键冲突要在 DAO 里捕获SQLIntegrityConstraintViolationException,翻译成“学号已存在”返给页面。让异常直接冒到 Tomcat 的 500 页面,用户看到的是堆栈,体验直接掉档。

插入语句显式写列名,不要INSERT INTO student VALUES (...):

INSERT INTO student (sno, name, gender, birthday, class_name, phone) VALUES (?, ?, ?, ?, ?, ?);

显式列名的好处是以后加字段不用改这条 SQL,顺序也不会因为表结构变动而错位。

3.2 Druid 连接池与 JDBC 工具类

课设代码里十份有八份在 DAO 里写DriverManager.getConnection(url, user, pwd)。能跑,但每敲一次查询就新建一条 TCP 连接、做一次握手、退出时再断,页面上点十次就开始明显卡顿。连接池不是“高级特性”,在这里是刚需。

配置文件放src/main/resources/druid.properties,跟代码分离:

driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://127.0.0.1:3306/sms?useUnicode=true&characterEncoding=utf8\ &useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username=root password=your_password initialSize=5 minIdle=5 maxActive=20 maxWait=3000 validationQuery=SELECT 1 testWhileIdle=true testOnBorrow=false removeAbandoned=true removeAbandonedTimeout=60

URL 上的参数一个都别省,每一个都对应一类报错:

参数建议值不写的后果
useUnicode+characterEncodingtrue/utf8中文入库变问号
useSSLfalse控制台刷 SSL 警告,本地调试噪音
serverTimezoneAsia/Shanghai报 The server time zone value is unrecognized
allowPublicKeyRetrievaltrueMySQL 8 默认认证插件下报 Public Key Retrieval is not allowed
rewriteBatchedStatementstrue批量插入退化成逐条,慢一个数量级

maxWait=3000是故意的:等 3 秒拿不到连接就抛异常,比默认无限等待好排查得多。默认值下连接泄漏的表现是“页面转圈转到天荒地老”,日志里什么都没有,纯黑匣子。

工具类只暴露两个方法,别让它长成万能类:

public final class DBUtil { private static final DataSource DS; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("druid.properties")) { Properties props = new Properties(); props.load(in); DS = DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { // 静态块里失败必须抛 Error,否则类初始化半死不活,后面全是空指针 throw new ExceptionInInitializerError(e); } } private DBUtil() {} public static Connection getConnection() throws SQLException { return DS.getConnection(); // 这里拿到的是池化代理对象 } }

关键认知:conn.close()在池化环境下不是断连,是把连接还回池子。所以 DAO 里必须关,而且要放在finally或者用 try-with-resources。用 try-with-resources 最省事,编译器保证逆序关闭,ResultSet→Statement→Connection,顺序错了某些驱动会警告。

3.3 DAO 的分页与动态条件 SQL

分页要两条 SQL:一条算总数,一条拿当页数据。两条 SQL 的 where 条件必须完全一致,这是最容易出错的地方——筛选条件只加在 list 上,count 没加,结果就是“总共 200 条,翻到第 3 页后面全空”。我一般把条件拼装抽成一个方法,两边共用。

public class StudentDao { /** 拼 where 子句和参数,count 与 list 共用,杜绝条件不一致 */ private String buildWhere(String kw, String cls, List<Object> args) { StringBuilder sb = new StringBuilder(" WHERE 1=1"); if (kw != null && !kw.trim().isEmpty()) { sb.append(" AND (sno LIKE ? OR name LIKE ?)"); String like = "%" + kw.trim() + "%"; args.add(like); args.add(like); } if (cls != null && !cls.trim().isEmpty()) { sb.append(" AND class_name = ?"); args.add(cls.trim()); } return sb.toString(); } public Page<Student> page(String kw, String cls, int pageNo, int size) throws SQLException { List<Object> args = new ArrayList<>(); String where = buildWhere(kw, cls, args); long total; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT COUNT(*) FROM student" + where)) { bind(ps, args); try (ResultSet rs = ps.executeQuery()) { rs.next(); total = rs.getLong(1); } } // 页码越界时回头取最后一页,避免前端改 URL 直接白屏 int pages = (int) ((total + size - 1) / size); if (pages > 0 && pageNo > pages) pageNo = pages; if (pageNo < 1) pageNo = 1; List<Object> listArgs = new ArrayList<>(args); listArgs.add((pageNo - 1) * size); // offset listArgs.add(size); // limit List<Student> rows = new ArrayList<>(); String sql = "SELECT id, sno, name, gender, birthday, class_name, phone, create_time" + " FROM student" + where + " ORDER BY id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { bind(ps, listArgs); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) rows.add(map(rs)); } } return new Page<>(rows, total, pageNo, size); } private void bind(PreparedStatement ps, List<Object> args) throws SQLException { for (int i = 0; i < args.size(); i++) { ps.setObject(i + 1, args.get(i)); } } private Student map(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.getInt("gender")); s.setBirthday(rs.getDate("birthday")); s.setClassName(rs.getString("class_name")); s.setPhone(rs.getString("phone")); s.setCreateTime(rs.getTimestamp("create_time")); return s; } }

几个细节值得展开。第一,SELECT显式列名而不是SELECT *,字段顺序变动、加字段都不会让map里的列名对不上。第二,全程PreparedStatement加?占位,kw里塞' OR 1=1 --也不会出事;字符串拼接 SQL 是这套代码里最不应该出现的写法。第三,LIMIT ?, ?在 JDBC 里可以用占位符,MySQL 驱动会把它们当整数绑定,不用担心被转成字符串。第四,ORDER BY id DESC保证翻页顺序稳定,缺了这行 MySQL 的返回顺序不保证,同一页可能重复出现某条记录,看起来像数据错乱,其实是排序没定死。

深分页的问题也要知道边界:LIMIT 100000, 10会先扫过前十万行再丢掉,数据量上百万时会明显变慢。课设数据量在几千条以内,不用管;真要做大,改成基于id的游标翻页(WHERE id < 上一页最后一条的 id ORDER BY id DESC LIMIT 10),这是另一套东西了。

4. Servlet 与 JSP 的对接:action 分发、乱码处理与 JSTL 渲染

分层写对之后,剩下最容易出问题的就是“Servlet 收到的东西不对”和“JSP 显示的东西不对”。这两个问题的根源都在参数和字符集上,属于看一眼就懂、不写就忘的类型。

4.1 一个 Servlet 管五个动作:action 分发怎么写不烂

学生模块的增删改查总共五六个入口,一个StudentServlet全管,用action参数区分。这样路由集中、日志好打,也不用在web.xml里注册一堆。代价是方法会变长,所以要拆私有方法,每个方法只做一件事。

@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doGet(req, resp); // 表单提交走 POST,复用同一套分发 } private void delete(HttpServletRequest req, HttpServletResponse resp) throws IOException { String idStr = req.getParameter("id"); if (idStr == null || !idStr.matches("\\d+")) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "id 非法"); return; } try { dao.deleteById(Integer.parseInt(idStr)); } catch (SQLException e) { throw new ServletException("删除失败", e); // 保留堆栈,别吞异常 } // PRG:Post-Redirect-Get,刷新不会重复提交 resp.sendRedirect(req.getContextPath() + "/student?action=list"); }

doGet里调doPost、doPost里调doGet都行,选一个方向固定下来就好,最怕两个方法各写一套逻辑,改一处漏一处。action分发时一定要有default分支并返回 400,否则拼错一个字母就静默失败,页面白屏、日志空白,排查成本极高。

还有一个常被忽视的线程安全问题:Servlet 在容器里是单实例多线程,StudentServlet只会被 new 一次,所有请求共用一个对象。所以成员变量只能是dao这种无状态的东西,Connection、HttpServletRequest、当前登录用户这些绝对不能存成成员变量,一存就会串数据——A 同学刷新页面看到 B 同学的信息,这种 bug 演示时出现一次就凉了。

4.2 中文乱码与参数绑定:Filter 与 BeanUtils

乱码是 Java Web 的头号经典问题,但它的成因非常确定:字符集在解码那一刻就已经确定了,之后改不回来。request.setCharacterEncoding("UTF-8")必须写在任何一次getParameter之前,晚一行都无效。

用 Filter 统一处理,别在每个 Servlet 里抄一遍:

@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.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); // 放行,后续 getParameter 才按 UTF-8 解 } }

GET 请求的参数在 URL 上,走的是容器配置,不受这行影响。Tomcat 8 之后Connector的URIEncoding默认就是 UTF-8,一般不用动;如果跑在更老的容器上,得去conf/server.xml里给<Connector>补URIEncoding="UTF-8"。这也是为什么有些人本地好好的,换台机器就乱码——代码没问题,容器配置不一样。

多个 Filter 时,顺序由web.xml里的<filter-mapping>决定,@WebFilter的顺序是不保证的。所以登录拦截要读到参数的话,把编码过滤器在web.xml里显式排在前面更稳。

参数封装建议手写,别急着上反射工具:

private Student readForm(HttpServletRequest req) { Student s = new Student(); s.setSno(req.getParameter("sno").trim()); s.setName(req.getParameter("name").trim()); s.setGender(parseInt(req.getParameter("gender"), 0)); String birthday = req.getParameter("birthday"); // yyyy-MM-dd if (birthday != null && !birthday.isEmpty()) { s.setBirthday(java.sql.Date.valueOf(birthday)); // 格式不对会抛 IllegalArgumentException } s.setClassName(req.getParameter("class_name").trim()); s.setPhone(req.getParameter("phone").trim()); return s; }

BeanUtils.populate那种写法确实少几行,但String到java.sql.Date的类型转换要自己注册Converter,表单字段名还必须和实体属性名一模一样,改个名字就静默丢值。课设量级下,手写反而更好调试,出问题能直接看到是哪一行。校验也别全靠前端required,后端至少检查学号非空、姓名非空、学号格式,前端校验只是体验,后端校验才是数据。

4.3 JSP 渲染与 JSTL:把 Java 从页面里赶出去

视图层的目标很简单:JSP 里不出现<% %>。用 JSTL 加 EL 就能做到,页面长这样:

<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>学生列表</title></head> <body> <form action="${pageContext.request.contextPath}/student" method="get"> <input type="hidden" name="action" value="list"> <input name="kw" value="${kw}" placeholder="学号或姓名"> <input name="cls" value="${cls}" placeholder="班级"> <button type="submit">查询</button> </form> <table border="1" cellpadding="6"> <tr><th>学号</th><th>姓名</th><th>性别</th><th>生日</th> <th>班级</th><th>电话</th><th>操作</th></tr> <c:forEach var="s" items="${page.rows}"> <tr> <td><c:out value="${s.sno}"/></td> <td><c:out value="${s.name}"/></td> <td> <c:choose> <c:when test="${s.gender == 1}">男</c:when> <c:when test="${s.gender == 2}">女</c:when> <c:otherwise>未知</c:otherwise> </c:choose> </td> <td><fmt:formatDate value="${s.birthday}" pattern="yyyy-MM-dd"/></td> <td><c:out value="${s.className}"/></td> <td><c:out value="${s.phone}"/></td> <td> <a href="${pageContext.request.contextPath}/student?action=edit&id=${s.id}">编辑</a> <a href="${pageContext.request.contextPath}/student?action=delete&id=${s.id}" onclick="return confirm('确认删除?')">删除</a> </td> </tr> </c:forEach> </table> <p>共 ${page.total} 条,第 ${page.pageNo}/${page.pages} 页</p> </body> </html>

这里有三个实践要点。第一,输出用户数据一律用<c:out>,它会转义 HTML 标签,姓名里填个<script>也不会执行,这是最省事的一层 XSS 防护。第二,链接用${pageContext.request.contextPath}拼前缀,不要写死/student-manager/...,否则改个 war 包名全站链接失效,这种问题在换环境部署时必现。第三,删除链接加confirm只是体验优化,真正的防误删还是靠 PRG 加后端二次校验,前端弹窗挡不住手快的。

分页条也放在同一个 JSP 里,用c:url拼参数可以自动做 URL 编码:

<c:url var="prevUrl" value="/student"> <c:param name="action" value="list"/> <c:param name="page" value="${page.pageNo - 1}"/> <c:param name="kw" value="${kw}"/> <c:param name="cls" value="${cls}"/> </c:url> <c:if test="${page.pageNo > 1}"> <a href="${prevUrl}">上一页</a> </c:if>

c:url加c:param会自动处理中文参数和&转义,手拼字符串很容易在kw=张&page=2上翻车。

5. 避坑与排查:五个让人返工的翻车现场

下面这五条基本都是我在拆这类项目时真实踩过或者帮别人定位过的,现象看着各不相同,根因集中在“环境版本”“字符集”“资源释放”三件事上。按现象 → 原因 → 解决写,遇到类似的直接对号。

5.1 容器、依赖与连接层的三处坑

坑一:换了 Tomcat 10,所有 Servlet 都 404。

现象:工程编译无报错,war 部署成功,控制台没有异常,访问任何路径都是 404,web.xml里的配置看着也没问题。

原因:Tomcat 10 起把javax.servlet.*整体迁到了jakarta.servlet.*,命名空间换了。用javax编译出来的 class,容器里根本没有对应的父类,@WebServlet注解也不会被扫描到,所以你注册的路由一条都不存在,表现就是全站 404。

解决:二选一,别混。要么把 Tomcat 降到 9.0.x 继续用javax;要么把pom.xml里的 servlet-api 换成jakarta.servlet:jakarta.servlet-api:5.0.0,代码里所有import javax.servlet.*改成import jakarta.servlet.*,JSTL 换成 jakarta 坐标,标签 uri 改成jakarta.tags.core。改完记得mvn clean,target里的旧 class 不清会骗人。

坑二:MySQL 8 连不上,报 Public Key Retrieval is not allowed。

现象:Navicat 能连,Java 代码连不上;或者第一次能连,重启服务后报The server time zone value '中国标准时间' is unrecognized。

原因:MySQL 8 默认认证插件是caching_sha2_password,首次连接需要客户端主动索取服务端公钥,而 JDBC 驱动默认关掉了这个行为;时区那条是因为驱动拿到的服务端时区名它不认识。

解决:URL 上补allowPublicKeyRetrieval=true&useSSL=false&serverTimezone=Asia/Shanghai。生产环境不要关 SSL,本地调试为了方便可以关。补完还是连不上,就去看驱动版本,5.1.x 的驱动配 MySQL 8 会有各种奇怪问题,统一升到 8.0.x。

坑三:点几次之后页面卡死,日志里什么都没有。

现象:刚启动正常,连续操作十来次以后,所有请求都挂住,maxWait到了抛GetConnectionTimeoutException。

原因:DAO 里拿到了Connection但没关,连接池的 20 条连接被借空。池化环境下close()是还池,但前提是你得调。也有人把Connection存成了 Servlet 的成员变量,多线程下不仅泄漏还会串事务。

解决:所有 JDBC 资源一律 try-with-resources;Connection只在方法内局部变量里存在;顺手把removeAbandoned=true、removeAbandonedTimeout=60打开,让池子自己回收跑飞的连接,这是一层后悔药。排查时打开 Druid 的监控页(StatViewServlet),看ActiveCount是不是只涨不跌,一眼定位。

5.2 页面与编码层的两处坑

坑四:JSP 页面把<c:forEach>原样输出到浏览器。

现象:页面能看到表格表头,但数据区显示的是一串标签文本,或者直接 500 报According to TLD or attribute directive in tag file, attribute items does not accept any expressions。

原因:JSTL 的 jar 没打进 war,或者taglib的 uri 跟容器版本不匹配。WEB-INF/lib里没有jstl-1.2.jar,容器就把c:forEach当普通 HTML 标签输出;uri 写错则直接报 TLD 解析失败。另一个隐蔽原因是scope写成provided,本地 IDE 跑得动(依赖在 classpath 里),打成 war 就丢了。

解决:确认jstl依赖没有provided;mvn dependency:tree看一眼有没有被别的依赖顶掉版本;解压 war 检查WEB-INF/lib/jstl-1.2.jar是否存在。Tomcat 10 环境下 uri 必须换成 jakarta 那套,用旧 uri 一样报错。

坑五:中文一会儿正常一会儿乱码,尤其是搜索框输入中文时。

现象:新增能存进去中文,列表页也显示正常,但在搜索框输入“张”点查询,结果为空或者变成å¼。

原因:POST 请求体由request.setCharacterEncoding决定,Filter 生效就没问题;GET 请求的参数在 URL 上,由容器的URIEncoding决定。很多人只在doPost里设了编码,doGet没设,或者 Filter 的url-pattern写成了/student漏掉了其他路径。还有一种情况是response.setContentType忘了带charset=UTF-8,浏览器按 ISO-8859-1 猜,页面整体花掉。

解决:Filter 映射写/*,request和response的字符集都在里面设;确认容器URIEncoding是 UTF-8;JSP 页头同时写contentType和pageEncoding,两个都要;数据库和表的字符集确认是utf8mb4,连接串带上characterEncoding=utf8。乱码问题要一层层排除,从浏览器 → 容器 → 应用 → 驱动 → 表,别跳步猜。

6. 上线前的最后一段:会话拦截、密码存储与部署自检清单

代码能跑通只是及格线,能不能拿去演示、能不能讲清楚,取决于最后这几步收尾做得扎不扎实。

登录这块,LoginServlet校验通过后只做一件事:req.getSession().setAttribute("loginUser", user)。拦截交给LoginFilter,在doFilter里判断session.getAttribute("loginUser") == null就重定向到登录页,同时放行/login、/css/*、/js/*这几类路径,否则登录页自己被拦,死循环。密码别存明文,也别只做一次 MD5——彩虹表早就把常见密码打穿了。存salt加sha256(salt + 明文),盐每次注册随机生成 16 位十六进制:

public static String hash(String raw, String salt) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] bs = md.digest((salt + raw).getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : bs) sb.append(String.format("%02x", b)); return sb.toString(); // 64 位十六进制,对应 password CHAR(64) } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }

部署前我会照着下面这张清单走一遍,少一项都可能在演示现场翻车:

检查项判断标准常见失败表现
容器与包名Tomcat 9 +javax或 Tomcat 10 +jakarta,不混用全站 404
依赖 scopeservlet-api/jsp-api是provided,JSTL 不是本地正常,部署 500
数据库连接URL 带时区和allowPublicKeyRetrieval启动即报连接异常
字符集库、表、连接串、JSP、Filter 五处都是 UTF-8中文变问号
资源释放无Connection泄漏,ActiveCount稳定点十几次后卡死
路由入口index.jsp能跳列表,未登录跳登录页打开是一片空白

从那以后我每次交付这类项目,都会先在干净的 Tomcat 里解压 war、删掉work目录缓存、用新库跑一遍完整流程,再打开监控页看一眼连接数曲线。这几分钟能挡掉八成“在我机器上是好的”的尴尬。希望帮到你。

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