如果让我从这几年看新人代码的经验里挑一个最容易被“我以为会了”糊弄过去的知识点,JSP 里的增删改绝对高票当选。很多同学照着教程敲,列表能出来,新增能入库,删除也能少一行,但一到“编辑”按钮就卡住——点进去表单是空的,或者姓名填在文本框里了,下拉框没选中、单选没勾上、多选更是乱成一团。这个问题的核心,就是标题里专门点出来的那两个字:回显。增删改查这四件事,回显不是单独的一步,而是编辑功能的骨架,也是判断一个人是真懂还是只会背代码的分水岭。这篇文章我就拿一个学生信息管理系统的完整案例,把 JSP 做增删改、以及编辑回显和校验失败回显的链路从头到尾捋一遍,适合正在做 JSP 课设、或者刚从 Servlet 学到 JSP 的初学者参考。
1. 先搞明白“回显”到底是哪一步的活
1.1 回显的两种典型场景
很多人一听到“回显”,第一反应就是“编辑页面把数据库的值填回去”。对,但只对了一半。实际开发里回显至少有两种场景,如果不区分,很容易写出看着能跑、一改就崩的代码。
第一种是编辑回显。用户点列表页的“编辑”按钮,系统根据这条记录的 id 去数据库查出完整数据,然后把这些数据填充到 edit.jsp 表单的各个控件里。下拉框要选中用户之前选的项,单选框要勾上用户之前的性别,复选框要勾上所有用户勾过的爱好。这是标题里说“回显”时默认指的场景。
第二种是校验失败回显。用户在新增页面提交数据时,后端校验发现“姓名为空”或者“年龄不是数字”,把请求打回 add.jsp,同时把用户已经填好的其他字段重新显示出来。这个场景如果处理不好,用户每次提交失败都要从头再填一遍,体验极差。
这两种回显虽然都叫回显,但数据来源完全不一样:一个是数据库查出来的,一个是上一次请求带过来的参数。很多教程只讲第一种,导致初学者在第二种场景里翻车。
1.2 一句话说清回显的数据流向
回显看起来是页面上的事,本质上是数据在“数据库 → Java对象 → request作用域 → JSP页面 → 表单控件”之间流动的过程。
数据库里的一行记录,先被 DAO 层查出来封装成 Student 对象,Servlet 拿到这个对象后调用 request.setAttribute("stu", student),然后通过请求转发把请求交给 JSP,JSP 里再通过 EL 表达式 ${stu.name} 把对象的属性取出来,最终输出到 HTML 控件的 value 属性或者选中状态里。
这个链条上任何一环断了,回显都会失败。最常见的断点有三个:DAO 没查到数据返回了 null、Servlet 用了重定向导致 request 里的数据丢了、JSP 里 EL 表达式属性名写错。后面我会逐个演示正确写法,你只要跟着把链路走通,再遇到回显问题就知道该往哪个环节排查。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 搭骨架:建表、JDBC 工具类、三层分包
2.1 学生表字段怎么设计
用什么业务做例子,直接决定回显的覆盖面。我选学生信息管理,是因为学生实体天然包含多种数据类型:性别是单选框、专业是下拉框、爱好是多选框、备注是文本域。把这些控件全部照顾到,你以后换成商品管理、订单管理,回显逻辑完全一样。
先建数据库表:
sql复制CREATE DATABASE student_ms DEFAULT CHARACTER SET utf8mb4;
USE student_ms;
CREATE TABLE student (
id INT AUTO_INCREMENT PRIMARY KEY,
stu_no VARCHAR(20) NOT NULL COMMENT '学号',
name VARCHAR(20) NOT NULL COMMENT '姓名',
gender VARCHAR(4) COMMENT '性别',
age INT COMMENT '年龄',
major VARCHAR(30) COMMENT '专业',
hobby VARCHAR(100) COMMENT '爱好,多个用逗号分隔',
remark VARCHAR(255) COMMENT '备注'
);
这里字段类型有几个细节值得注意。gender 用 VARCHAR 而不是 CHAR,是因为有些同学会传入空字符串,VARCHAR 处理起来更宽容。hobby 用逗号分隔存成一个字段,是刻意简化——如果做第三张关联表来表达多对多,会让课设代码量翻倍,初学者容易绕晕。真正生产环境我会拆表,但作为演示 JSP 回显,一个字符串字段配合 fn:contains 判断,反而能让回显逻辑更清晰。
2.2 DBUtil 工具类:把重复代码收起来
每次操作数据库都要经历“注册驱动、获取连接、预编译、执行、关闭资源”这五步,如果每个 DAO 方法里都写一遍,代码会膨胀到没法看。我习惯先抽一个 DBUtil:
java复制package com.example.util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class DBUtil {
private static final String URL = "jdbc:mysql://localhost:3306/student_ms"
+ "?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai";
private static final String USER = "root";
private static final String PASSWORD = "123456";
static {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USER, PASSWORD);
}
public static void close(ResultSet rs, PreparedStatement ps, Connection conn) {
if (rs != null) { try { rs.close(); } catch (SQLException e) { /* ignore */ } }
if (ps != null) { try { ps.close(); } catch (SQLException e) { /* ignore */ } }
if (conn != null) { try { conn.close(); } catch (SQLException e) { /* ignore */ } }
}
}
强调两点。第一,如果用 MySQL 8.x,驱动类名必须是 com.mysql.cj.jdbc.Driver,URL 里要带 serverTimezone,否则会报时区错误。第二,characterEncoding=utf8 这一项别省略,它和后端 request.setCharacterEncoding("UTF-8") 各管一段,少了哪个中文都可能乱码。
2.3 包结构:MVC 在 JSP 项目里怎么落位
这个案例的 package 结构我按最传统的三层来分:
text复制src/main/java
├── com.example.bean Student 实体类
├── com.example.dao StudentDao,所有 SQL 都在这
├── com.example.servlet ListServlet / AddServlet / DeleteServlet / EditServlet / UpdateServlet
└── com.example.util DBUtil
web
├── list.jsp
├── add.jsp
└── edit.jsp
Servlet 的角色是控制器:接收请求、调用 DAO、把数据放进 request、决定是转发还是重定向。JSP 的角色是视图:只负责展示数据和收集表单。Student 类就是纯 POJO,属性对应表字段,getter/setter 必不可少,因为 EL 表达式取值的底层依赖的就是 getter 方法,这里少写一个 getter,页面就多一个空值。
3. 列表页:增删改的起点,也是跳转的中枢
3.1 查询全部学生的 Servlet
列表页是用户操作的入口,没有列表页,新增了看不到、编辑了不知道去哪点。查询的 Servlet 非常短,但有两个细节值得说。
java复制package com.example.servlet;
import com.example.bean.Student;
import com.example.dao.StudentDao;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
@WebServlet("/list")
public class ListServlet extends HttpServlet {
private StudentDao studentDao = new StudentDao();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
response.setContentType("text/html;charset=UTF-8");
List<Student> students = studentDao.findAll();
request.setAttribute("students", students);
request.getRequestDispatcher("list.jsp").forward(request, response);
}
}
第一个细节是 response.setContentType("text/html;charset=UTF-8"),这行决定浏览器用 UTF-8 解析响应内容,漏掉的话页面中文会乱码。第二个细节是 request 作用域,setAttribute 放进去的 students 列表,在请求转发之后仍然存活,list.jsp 能直接拿到。这里必须用 forward 而不是 sendRedirect,重定向会让浏览器发起一次全新请求,request 里的数据就没了。
对应 DAO 中的 findAll:
java复制public List<Student> findAll() {
String sql = "SELECT * FROM student ORDER BY id DESC";
List<Student> list = new ArrayList<>();
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Student s = new Student();
s.setId(rs.getInt("id"));
s.setStuNo(rs.getString("stu_no"));
s.setName(rs.getString("name"));
s.setGender(rs.getString("gender"));
s.setAge(rs.getObject("age") == null ? null : rs.getInt("age"));
s.setMajor(rs.getString("major"));
s.setHobby(rs.getString("hobby"));
s.setRemark(rs.getString("remark"));
list.add(s);
}
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
这里 rs.getObject("age") 的判断是防止数据库里 age 为 NULL 时 rs.getInt 返回 0 造成误判,算是一个细节处理。
3.2 list.jsp 循环输出与操作按钮
list.jsp 的完整逻辑不算复杂,但操作按钮的写法值得单独展开,因为这里藏着一个常见的小坑。
jsp复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%
String base = request.getContextPath();
%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>学生列表</title>
</head>
<body>
<h2>学生信息列表</h2>
<a href="<%=base%>/add.jsp">新增学生</a>
<table border="1" cellpadding="8" cellspacing="0">
<tr>
<th>学号</th><th>姓名</th><th>性别</th><th>年龄</th>
<th>专业</th><th>爱好</th><th>备注</th><th>操作</th>
</tr>
<c:forEach items="${students}" var="s">
<tr>
<td>${s.stuNo}</td>
<td>${s.name}</td>
<td>${s.gender}</td>
<td>${s.age}</td>
<td>${s.major}</td>
<td>${s.hobby}</td>
<td>${s.remark}</td>
<td>
<a href="<%=base%>/edit?id=${s.id}">编辑</a>
<a href="javascript:void(0)" onclick="del(${s.id})">删除</a>
</td>
</tr>
</c:forEach>
</table>
<script type="text/javascript">
function del(id) {
if (confirm("确定要删除这条记录吗?")) {
location.href = "<%=base%>/delete?id=" + id;
}
}
</script>
</body>
</html>
每次用 ${s.id} 拼链接时,外面一定要加 <%=base%> 前缀。base 在页面开头通过 request.getContextPath() 拿到,它等于项目部署路径,比如 /student_ms。这样无论页面被转发到哪个层级,链接都不会飘。我见过太多因为没有加 contextPath,部署到带项目名的路径下就 404 的情况,这个习惯请务必养成。
3.3 列表页里值得留意的两个细节
第一个是 ${s.gender} 如果数据库里是 NULL,页面会显示空白,而不是报错。EL 表达式对 null 的容忍度远高于 JSP 脚本片段,这也是我推荐 JSP 里用 EL + JSTL 替代 <%= %> 脚本的原因。
第二个是删除按钮没有直接用 <a href="delete?id=${s.id}">,而是包了一层 JavaScript confirm。这么做是为了防止误点直接删数据,属于最基础但最实用的人机交互保护。如果你想把确认弹窗做得更漂亮,可以换成 Bootbox 或 layui 的 confirm,但原理都是让用户先确认再跳转。
4. 新增数据:表单提交、POST 编码与自增主键
4.1 add.jsp 的表单设计
新增页面的表单控件,和后面编辑页面的表单控件应该保持一致,这样回显逻辑能复用。我用的是传统的 HTML 表单:
jsp复制<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%
String base = request.getContextPath();
%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>新增学生</title>
</head>
<body>
<h2>新增学生</h2>
<p style="color:red">${error}</p>
<form action="<%=base%>/add" method="post">
学号:<input type="text" name="stuNo" value="${student.stuNo}" /><br/>
姓名:<input type="text" name="name" value="${student.name}" /><br/>
性别:
<input type="radio" name="gender" value="男" ${student.gender == '男' ? 'checked' : ''} />男
<input type="radio" name="gender" value="女" ${student.gender == '女' ? 'checked' : ''} />女<br/>
年龄:<input type="text" name="age" value="${student.age}" /><br/>
专业:
<select name="major">
<option value="">请选择</option>
<option value="计算机" ${student.major == '计算机' ? 'selected' : ''}>计算机</option>
<option value="软件工程" ${student.major == '软件工程' ? 'selected' : ''}>软件工程</option>
<option value="电子信息" ${student.major == '电子信息' ? 'selected' : ''}>电子信息</option>
</select><br/>
爱好:
<input type="checkbox" name="hobby" value="篮球" ${fn:contains(student.hobby, '篮球') ? 'checked' : ''} />篮球
<input type="checkbox" name="hobby" value="阅读" ${fn:contains(student.hobby, '阅读') ? 'checked' : ''} />阅读
<input type="checkbox" name="hobby" value="音乐" ${fn:contains(student.hobby, '音乐') ? 'checked' : ''} />音乐
<input type="checkbox" name="hobby" value="旅行" ${fn:contains(student.hobby, '旅行') ? 'checked' : ''} />旅行<br/>
备注:<textarea name="remark" rows="4" cols="30">${student.remark}</textarea><br/>
<input type="submit" value="保存" />
<a href="<%=base%>/list">返回列表</a>
</form>
</body>
</html>
注意我在 form 标签里写了 method="post",这非常重要。表单里包含中文,用 GET 提交会出现两个问题:URL 里中文编码复杂、浏览器可能截断超长参数。POST 配合后端 setCharacterEncoding 才是稳妥方案。另外 add.jsp 里已经带上了 ${student.xxx} 回显,你现在打开页面它们全为空,但到讲校验失败回显时,这些表达式就会发挥作用。
4.2 AddServlet 接收参数与多选值的拼接
新增的 Servlet 核心逻辑是:收参数、封对象、调 DAO、重定向。
java复制package com.example.servlet;
import com.example.bean.Student;
import com.example.dao.StudentDao;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/add")
public class AddServlet extends HttpServlet {
private StudentDao studentDao = new StudentDao();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
String stuNo = request.getParameter("stuNo");
String name = request.getParameter("name");
String gender = request.getParameter("gender");
String ageStr = request.getParameter("age");
String major = request.getParameter("major");
String remark = request.getParameter("remark");
String[] hobbies = request.getParameterValues("hobby");
String hobby = "";
if (hobbies != null && hobbies.length > 0) {
hobby = String.join(",", hobbies);
}
Student student = new Student();
student.setStuNo(stuNo);
student.setName(name);
student.setGender(gender);
if (ageStr != null && !ageStr.trim().isEmpty()) {
student.setAge(Integer.parseInt(ageStr));
}
student.setMajor(major);
student.setHobby(hobby);
student.setRemark(remark);
boolean ok = studentDao.add(student);
if (ok) {
response.sendRedirect("list");
} else {
request.setAttribute("error", "保存失败,请重试");
request.setAttribute("student", student);
request.getRequestDispatcher("add.jsp").forward(request, response);
}
}
}
request.setCharacterEncoding("UTF-8") 这一行必须放在任何 getParameter 之前调用,否则 POST 提交的中文一定会乱码。年龄字段我做了空值判断,用户不填不会抛 NumberFormatException,而是存成 null。多选框 hobby 用 getParameterValues 拿到字符串数组,再用 String.join 拼成逗号分隔字符串,这和后面数据库里的存储格式对应。
4.3 新增成功后为什么必须重定向
AddServlet 保存成功之后,我用了 response.sendRedirect("list"),而不是转发到 list.jsp。这里面的道理值得展开说。
如果改成转发到 list.jsp,浏览器地址栏停在 /add,用户按下 F5 刷新,浏览器会再次提交同样的 POST 请求,数据库里就多了一条一模一样的记录。这是表单重复提交的经典问题。用重定向之后,地址栏变成 /list,F5 刷新只是重新查询列表,不会重复插入。
所以这套代码里有一个简单的规矩:写操作完成之后一律重定向到列表页,读操作才用请求转发。这个规矩放到后来学 Spring MVC 时依然适用,叫 Post/Redirect/Get 模式。
5. 删除数据:确认弹窗与整行删除的细节
5.1 DeleteServlet 的写法
删除比新增更简单,因为不需要表单,只需根据 id 删一行。但简单不代表没坑。
java复制package com.example.servlet;
import com.example.dao.StudentDao;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/delete")
public class DeleteServlet extends HttpServlet {
private StudentDao studentDao = new StudentDao();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String idStr = request.getParameter("id");
if (idStr == null || idStr.trim().isEmpty()) {
response.sendRedirect("list");
return;
}
try {
int id = Integer.parseInt(idStr);
studentDao.delete(id);
} catch (NumberFormatException e) {
// id 不是数字,不做任何操作
}
response.sendRedirect("list");
}
}
这里对 idStr 做了空值和数字格式的双重校验,防止用户手动输入 delete?id=abc 导致 500 错误。虽然删错数据不是大事,但服务端报出 NumberFormatException 的堆栈很丢人,更重要的是这暴露了代码没有做参数校验。
DAO 的 delete 方法如下:
java复制public int delete(Integer 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) {
e.printStackTrace();
}
return 0;
}
5.2 前端确认弹窗的交互细节
前面 list.jsp 里的 confirm 已经做到了“点删除先弹确认框”。这里补充一个容易忽视的点:用户点击确认之后用 location.href 跳转,属于 GET 请求,DeleteServlet 中我选用 doGet 来处理。有的教程会把删除放在 doPost 里,但那样前端就要通过表单或者 AJAX POST 才能触发,对课设来说没必要绕弯。GET 删除在严格意义上不符合 RESTful 规范,因为 GET 请求应当是无副作用的,但对于 JSP 课设这种场景,可控的链接删除加 confirm 足够实用。
5.3 删除时遇到外键约束怎么处理
如果你后续给系统加了“成绩表”这类关联表,直接删 student 会报外键约束异常,因为成绩表里有记录引用着学生的主键。处理方案有三种:一是先删子表数据再删主表;二是把删除改成软删除——加一个 status 字段,列表只查 status=1,删除时执行 UPDATE student SET status=0;三是删主表时把子表外键设为 ON DELETE CASCADE。课设阶段我建议优先考虑软删除,因为它能保留历史数据,而且实现非常简单。数据库层面在表设计时就加一个 status TINYINT DEFAULT 1,SQL 里所有查询和删除都带上 status 条件即可。
6. 编辑回显:这是你这个项目标题专门点的技能点
6.1 EditServlet 的完整链路
编辑功能比新增多的关键一步,就是“先根据 id 查一条数据出来”。EditServlet 负责这件事:
java复制package com.example.servlet;
import com.example.bean.Student;
import com.example.dao.StudentDao;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/edit")
public class EditServlet extends HttpServlet {
private StudentDao studentDao = new StudentDao();
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String idStr = request.getParameter("id");
if (idStr == null || idStr.trim().isEmpty()) {
response.sendRedirect("list");
return;
}
try {
int id = Integer.parseInt(idStr);
Student student = studentDao.findById(id);
if (student == null) {
response.sendRedirect("list");
return;
}
request.setAttribute("student", student);
request.getRequestDispatcher("edit.jsp").forward(request, response);
} catch (NumberFormatException e) {
response.sendRedirect("list");
}
}
}
这里再次强调,EditServlet 查完数据后必须用 request.getRequestDispatcher("edit.jsp").forward(...),不能用 sendRedirect。一旦重定向,request 里 setAttribute 的 student 对象就丢了,edit.jsp 拿不到数据,回显自然失败。这是回显失败的头号原因,几乎每个初学者的编辑页面打不开值,最后都查到这一行。
DAO 的 findById 方法:
java复制public Student findById(Integer id) {
String sql = "SELECT * FROM student WHERE id = ?";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
Student s = new Student();
s.setId(rs.getInt("id"));
s.setStuNo(rs.getString("stu_no"));
s.setName(rs.getString("name"));
s.setGender(rs.getString("gender"));
s.setAge(rs.getObject("age") == null ? null : rs.getInt("age"));
s.setMajor(rs.getString("major"));
s.setHobby(rs.getString("hobby"));
s.setRemark(rs.getString("remark"));
return s;
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
6.2 文本框和文本域的回显
edit.jsp 的样子和 add.jsp 几乎一样,差别仅在两点:form 的 action 改为 update,以及多一个隐藏域 id。核心的控件回显写法如下:
jsp复制<form action="<%=base%>/update" method="post">
<input type="hidden" name="id" value="${student.id}" />
学号:<input type="text" name="stuNo" value="${student.stuNo}" /><br/>
姓名:<input type="text" name="name" value="${student.name}" /><br/>
备注:<textarea name="remark" rows="4" cols="30">${student.remark}</textarea><br/>
...
</form>
文本框回显的规则很直接:把 EL 表达式塞进 value 属性。文本域的回显是写在标签体内部的,不是写在 value 里,很多新手会把 <textarea value="${student.remark}"> 当成输入框来写,这是不行的,textarea 没有 value 属性,内容要放在开始标签和结束标签之间。
6.3 单选框、下拉框、复选框的回显
单选框和下拉框的回显思路本质相同:比较当前选项的值和数据库里存的值,相等就输出 checked 或 selected。
jsp复制性别:
<input type="radio" name="gender" value="男" ${student.gender == '男' ? 'checked' : ''} />男
<input type="radio" name="gender" value="女" ${student.gender == '女' ? 'checked' : ''} />女<br/>
专业:
<select name="major">
<option value="">请选择</option>
<option value="计算机" ${student.major == '计算机' ? 'selected' : ''}>计算机</option>
<option value="软件工程" ${student.major == '软件工程' ? 'selected' : ''}>软件工程</option>
<option value="电子信息" ${student.major == '电子信息' ? 'selected' : ''}>电子信息</option>
</select><br/>
这段 EL 三元表达式看着短,但它是回显的核心。${student.gender == '男' ? 'checked' : ''} 的意思是:如果 student.gender 的值等于"男",就输出 checked,否则输出空字符串。你甚至可以先用 JSP 脚本片段写一个 if 判断,等彻底理解了再换成 EL。
复选框的回显稍微复杂,因为数据库里存的是逗号分隔的字符串,比如 "篮球,阅读"。要判断某个复选框是否应该选中,得看这个字符串里是否包含当前选项的值。JSTL 的 fn:contains 函数正好干这个:
jsp复制<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
<input type="checkbox" name="hobby" value="篮球" ${fn:contains(student.hobby, '篮球') ? 'checked' : ''} />篮球
<input type="checkbox" name="hobby" value="阅读" ${fn:contains(student.hobby, '阅读') ? 'checked' : ''} />阅读
<input type="checkbox" name="hobby" value="音乐" ${fn:contains(student.hobby, '音乐') ? 'checked' : ''} />音乐
<input type="checkbox" name="hobby" value="旅行" ${fn:contains(student.hobby, '旅行') ? 'checked' : ''} />旅行<br/>
如果 student.hobby 是 NULL,fn:contains 不会报错,会正常返回 false,所有复选框都不选中。这个特性在新增页面的回显场景里很有用。
6.4 回显失败最常见的两个原因以及排查顺序
我把这些年看过的回显失败案例做个总结。排第一的是上面说过的重定向替代了转发,导致 request 里的 student 对象丢失;排第二的是 JSP 里 EL 表达式没生效或者属性名拼错。EL 表达式 student.name 对应的是 student.getName() 方法,如果你的实体类 getter 没写全,EL 取不到值,页面就空着。排查时先看浏览器地址栏:如果点编辑后地址是 /edit?id=3,再看页面源码里 input 的 value 是不是为空,最后去实体类确认 getter 是否存在。按这个顺序走,十分钟内基本能定位。
还有一个容易忽略的点:如果你在 edit.jsp 中使用脚本片段 <%=student.getName()%>,而 student 是 null,页面直接抛 NullPointerException。EL 遇到 null 只是输出空白,脚本片段会崩。所以在 JSP 页面中,能用 EL 就不要用脚本片段,这也是回显稳定性的保障之一。
7. 修改提交与校验失败回显的双保险
7.1 UpdateServlet 的 SQL 和参数顺序
修改提交的 Servlet 与 AddServlet 几乎同构,但有一个特别容易忽略的差别:UPDATE 的 SQL 里参数顺序和实体字段顺序必须一一对应,而且 id 通常放在最后。
java复制package com.example.servlet;
import com.example.bean.Student;
import com.example.dao.StudentDao;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/update")
public class UpdateServlet extends HttpServlet {
private StudentDao studentDao = new StudentDao();
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
String idStr = request.getParameter("id");
String stuNo = request.getParameter("stuNo");
String name = request.getParameter("name");
String gender = request.getParameter("gender");
String ageStr = request.getParameter("age");
String major = request.getParameter("major");
String remark = request.getParameter("remark");
String[] hobbies = request.getParameterValues("hobby");
String hobby = hobbies == null ? "" : String.join(",", hobbies);
Student student = new Student();
if (idStr != null && !idStr.trim().isEmpty()) {
student.setId(Integer.parseInt(idStr));
}
student.setStuNo(stuNo);
student.setName(name);
student.setGender(gender);
if (ageStr != null && !ageStr.trim().isEmpty()) {
student.setAge(Integer.parseInt(ageStr));
}
student.setMajor(major);
student.setHobby(hobby);
student.setRemark(remark);
boolean ok = studentDao.update(student);
if (ok) {
response.sendRedirect("list");
} else {
request.setAttribute("error", "更新失败,请重试");
request.setAttribute("student", student);
request.getRequestDispatcher("edit.jsp").forward(request, response);
}
}
}
对应的 DAO 方法:
java复制public int update(Student s) {
String sql = "UPDATE student SET stu_no=?, name=?, gender=?, age=?, major=?, hobby=?, remark=? WHERE id=?";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setString(1, s.getStuNo());
ps.setString(2, s.getName());
ps.setString(3, s.getGender());
if (s.getAge() != null) {
ps.setInt(4, s.getAge());
} else {
ps.setNull(4, java.sql.Types.INTEGER);
}
ps.setString(5, s.getMajor());
ps.setString(6, s.getHobby());
ps.setString(7, s.getRemark());
ps.setInt(8, s.getId());
return ps.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
}
return 0;
}
注意 UPDATE 语句中 id 是第 8 个占位符,而实体类里 setId 在第 7 行就完成了,这中间没有任何逻辑冲突,但很多初学着照着新增的代码改,容易把 id 留在第一位或者干脆忘了设置,结果出现“更新后所有记录都变成同一条”的严重问题。为什么?因为 WHERE id=? 的 id 没被正确赋值时,PreparedStatement 会用默认值或者抛异常,一旦漏设,部分驱动会把 NULL 当作条件,结果就是一条都更新不了,或者更糟——SQL 变成 UPDATE student SET ... WHERE id=null,永远不报错但永远不生效。遇到“更新没反应”的诡异问题,先检查这一句。
7.2 校验失败后的回显:用后端对象兜底
现在回到新增部分的校验失败回显。我在 AddServlet 里故意埋了一条分支:如果名字为空,返回 add.jsp 并带上 error 和 student 对象。页面里对应的写法是:
jsp复制<p style="color:red">${error}</p>
姓名:<input type="text" name="name" value="${student.name}" />
这样即使 age 填了非法字符、性别没选,只要后端把 student 对象放回 request,页面上的所有控件都能通过回显把用户已经填的内容恢复回来。文本框恢复 value,单选框恢复 checked,下拉框恢复 selected,复选框恢复 fn:contains 判断,备注恢复文本域内容——和编辑回显用的是同一套 EL 表达式。
有同学问,我能不能直接用 ${param.name} 来取上一次请求的参数?对于文本框,确实可以。但 param 对象对多选框不友好,paramValues 处理起来很啰嗦。所以我更推荐“后端组装对象再回传”的做法,一套代码同时应对编辑回显和校验失败回显,统一、清晰、不易出错。
想在校验失败时把年龄的非法值也原样保留,而不是显示空,需要稍微绕一下:把 age 设置成 NULL 会丢失用户输入。更好的做法是实体类里 age 用 Integer 接收,如果解析失败,把原始字符串单独放到 request.setAttribute("ageError") 和 request.setAttribute("ageRaw")。课设阶段不必追求到这个粒度,但心里要知道有这个边界。
8. 代码能跑之后,回看这套 JSP 方案的取舍
8.1 PreparedStatement 为什么不可替换
这套代码里所有 SQL 都用的 PreparedStatement 预编译 + 占位符,没有一处用 Statement 拼字符串。原因不只是防止 SQL 注入,还有可读性。
对比一下两种写法。不用 PreparedStatement 的删除是这样:
java复制String sql = "DELETE FROM student WHERE id=" + id;
一旦 id 来自用户输入,比如传入 1 OR 1=1,拼接后变成 DELETE FROM student WHERE id=1 OR 1=1,整张表都被删空。而用占位符后,驱动会把这个值作为字符串字面量处理,永远不可能改变 SQL 结构。初学者觉得 PreparedStatement 代码啰嗦,但这是保护数据安全的最底线。JSP 课设将来可能还要交到老师手里检查,用 PreparedStatement 是最稳妥的选择。
8.2 增删改回显之外的三个实用补充
第一,新增和修改的 Servlet 里有大段重复的“取参数、封对象”代码,可以抽一个公共方法,比如从 request 里构造 Student 的工具方法,减少两个 Servlet 之间的复制粘贴。
第二,列表页可能还需要一个按姓名模糊查询的搜索框,这属于增删改之外的“查”的扩展。原理是在 findAll 的 SQL 中拼接 WHERE name LIKE ?,参数传 %关键字%,列表页加一个 form 用 GET 提交到一个带搜索条件的 Servlet。这里有个细节:GET 提交中文参数会涉及 URL 编码,最好在页面用 form method="get",Tomcat 8 及以上默认 UTF-8 解码,基本能避免乱码。
第三,真实项目里 JSP 只是视图层,Java 逻辑应该收敛在 Servlet 和 Service 层。我见过课设代码把数据库连接写在 JSP 里,页面崩了只能看到满屏 Java 异常堆栈,维护成本极高。这次的三层分包看起来多写几个类,但每个类都很短,后续要扩展也方便。
8.3 这套代码还能怎么演进
当把 JSP 增删改和回显跑通之后,再往后走无非两个方向:一是把 JDBC 换成 MyBatis 或 JPA,把 DAO 层的 SQL 交给框架管理;二是把 JSP 换成 Vue/React 前端 + 后端 API,回显逻辑变成前端拿到 JSON 数据后自己填充表单。但不管换成什么技术,回显的核心思想没变:数据从服务端到达页面后,根据条件和控件自身状态做输出。把这一层想通了,JSP 时代的代码就不会白学。
我个人在实际写这套东西时的体会是,回显最忌讳一上来就把所有控件全写满。建议先在 edit.jsp 只放一个文本框,跑通“查出来填进去”的最小链路,再逐步加单选框、下拉框、复选框。每加一种控件,就验证一次回显,这样出问题能精准定位。整个增删改项目做完,你会发现自己收获最大的不是那几个 SQL,而是理解了数据在请求、页面、数据库之间来回流动的完整脉络。这个理解,会让你在后续任何 Web 技术栈里都游刃有余。
