简介:基于Java Web的学生信息管理系统完整源码,采用JSP+Servlet+layui+jQuery实现,面向正在做课程设计的计算机专业学生及Java Web入门开发者,可解决学生自主注册、教师统一管理学生信息等典型场景问题。压缩包共2个文件,以zip源码包和htm说明文档为主,整体仅2.13MB,源码内含数据库脚本及lib目录下jar包依赖,数据库文件可通过配套网盘链接另行获取。运行环境为JDK7+Tomcat8+MySQL+IntelliJ IDEA,按登录注册、学生信息管理、教师后台等模块组织,结构清晰,便于导入IDE直接运行和二次扩展。已有6433人学习下载,可从中掌握Servlet请求处理、JSP页面渲染、Layui表格交互及登录注册模块的完整落地思路,对完成课程设计或理解Java Web基础分层开发具有实用参考价值。
1. 基于JSP+Servlet的学生信息管理系统,为什么我劝你别一上来就上Spring Boot
做课设或者刚学完Java基础想练手的同学,十有八九被布置过“基于java web的简单学生信息管理系统”这类题目。不少人第一反应是:都2025年了,谁还写JSP+Servlet?直接上Spring Boot不香吗?但从我带过的十几个课设项目来看,JSP+Servlet这套组合在“理解Web本质”这个目标上依然没有替代品。几十个类的Servlet里你能亲眼看到request怎么进来、response怎么出去、session怎么维持,这些在Spring Boot中全被封进了注解背后。这个系统也不只是课设专用,很多老企业的内部OA系统至今还在跑类似架构。适合的人群很明确:准备Java面试被“Servlet生命周期”问懵的、用IDEA新建Web工程不知道从哪里下手的、以及想要一个能快速答辩又能讲清楚原理的JavaWeb项目的人。
2. 从JDK到Tomcat把运行环境一次配齐:版本搭配与项目骨架
2.1 为什么还在用JSP+Servlet而不是Spring Boot:选型边界
先说服自己为什么值得花精力把它搭出来。JSP+Servlet作为Java Web最初的完整形态,把HTTP协议的处理过程暴露得干干净净:Servlet负责接收请求、处理逻辑、返回响应;JSP负责在服务端生成动态HTML页面。你写下的每一行代码都能直接映射到HTTP报文上,这种透明度是框架无法给你的。
另外从实际需求来看,这类“简单学生信息管理系统”通常只有登录、增删改查、列表查询几个功能,数据量也就是一个班几十号人。这种体量用Spring Boot加MyBatis Plus,光起步配置就比业务代码多。学框架之前先用标准JavaWeb把业务跑通,后续理解Spring MVC的DispatcherServlet、拦截器机制时会有一种“原来如此”的通透感。
选择这套技术栈还有一个非常现实的理由:期末答辩时面试官和评委老师最爱问的“Servlet是单例吗?线程安全吗?”“JSP和Servlet什么关系?”这些问题,用这套项目实战过的人才能答出细节,只背面试题的人一追问就翻车。
2.2 IDEA 2024创建Web项目的正确流程:别被Gradle和Maven带偏
用IDEA 2024新建JavaWeb项目时,不少新人会在Gradle和Maven之间纠结半天,其实这个阶段都不需要。常见做法是直接用IDEA的“Java Enterprise”模板创建,或者干脆创建一个普通Java项目手动加Web目录结构。
我一般会走这个流程:
第一步,确认JDK版本。这里有一个重要的版本匹配问题:如果你要用经典的javax.servlet包下的Servlet API,请务必选择JDK 8或JDK 11配合Tomcat 9及以下版本。Tomcat 10及以上已经把包名改成了jakarta.servlet,代码写法完全不同。标题里的JSP+Servlet通常暗示的是javax.servlet那一套,所以选Tomcat 9最稳妥。
# 验证JDK版本 java -version # 期望输出类似: java version "1.8.0_202" 或 "11.0.x"第二步,在IDEA中配置Tomcat。打开“Run”菜单下的“Edit Configurations”,点击“+”加号,选择“Tomcat Server”下的“Local”,然后在“Application server”中选择本地的Tomcat 9解压目录。部署选项卡里点击“+”选择“Artifact”,把Web项目挂上去,Application context填/student,这样启动后访问路径就是http://localhost:8080/student。
第三步,校验项目的Web目录结构。一个标准的JavaWeb工程必须有src/main/java存放源码、src/main/webapp存放JSP和静态资源。如果用的是普通Java项目改造,需要手动在webapp/WEB-INF下创建web.xml文件。这个文件是Web应用的“注册中心”,Servlet和过滤器都要在这里声明,IDEA只会帮你建骨架,不会帮你写映射。
student-management/ ├── src/ │ ├── main/ │ │ ├── java/ # 源码目录 │ │ │ └── com/student/ │ │ │ ├── dao/ # 数据访问层 │ │ │ ├── entity/ # 实体类 │ │ │ ├── servlet/ # Servlet控制器 │ │ │ └── util/ # 工具类 │ │ └── webapp/ │ │ ├── WEB-INF/ │ │ │ └── web.xml │ │ ├── login.jsp │ │ ├── studentList.jsp │ │ └── static/ │ │ ├── css/ │ │ └── js/ │ └── resources/ # 数据库配置文件2.3 web.xml关键配置逐行拆解:Servlet映射和欢迎页
web.xml是整个项目的总控开关。在这份配置里,你需要告诉Tomcat:哪个URL路径交给哪个Servlet类处理,以及默认访问哪个页面。
<?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 Management System</display-name> <!-- 登录Servlet映射 --> <servlet> <servlet-name>loginServlet</servlet-name> <servlet-class>com.student.servlet.LoginServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>loginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <!-- 学生列表Servlet映射 --> <servlet> <servlet-name>studentListServlet</servlet-name> <servlet-class>com.student.servlet.StudentListServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>studentListServlet</servlet-name> <url-pattern>/studentList</url-pattern> </servlet-mapping> <!-- 欢迎页 --> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> <!-- 字符编码过滤器防止中文乱码 --> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.student.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> </web-app>这份配置的核心逻辑是URL到Java类的映射关系。/login这个路径被访问时,Tomcat会实例化LoginServlet并调用其doGet或doPost方法。注意url-pattern的写法:/login是精确匹配,/*是通配所有路径。很多人在过滤器里写/*没问题,但在Servlet映射里如果用/*就会拦截掉所有JSP页面的访问,这是最常见的翻车点之一。
3. 核心链路落地:登录校验、学生表CRUD与JSP页面渲染
3.1 JDBC连接封装类:一次封装,全程复用
学生信息管理系统的数据操作离不开数据库。这里选择MySQL配合JDBC,连接信息写在src/main/resources/db.properties配置文件中,方便换环境时只改配置不改代码。
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/student_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=123456连接封装类是整套代码的地基。我一般会写一个静态工具类,把驱动加载和连接获取统一管理:
package com.student.util; import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; import java.util.Properties; public class DBUtil { private static String driver; private static String url; private static String username; private static String password; // 静态代码块: 类加载时只执行一次, 避免反复注册驱动 static { try { InputStream is = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties"); Properties props = new Properties(); props.load(is); driver = props.getProperty("jdbc.driver"); url = props.getProperty("jdbc.url"); username = props.getProperty("jdbc.username"); password = props.getProperty("jdbc.password"); Class.forName(driver); // 注册驱动 } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置初始化失败"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(AutoCloseable... resources) { for (AutoCloseable rc : resources) { if (rc != null) { try { rc.close(); } catch (Exception ignored) { // 关闭失败不影响主流程 } } } } }这段代码值得注意的有两个点:一是Class.forName(driver)在JDBC 4.0之后其实可以省略,但在Java 8配合MySQL 8.x驱动时保留更稳妥;二是close方法用可变参数接收Connection、PreparedStatement、ResultSet三类资源,调用时按rs, pstmt, conn的顺序从里往外关。这个习惯能避免连接泄漏——别小看这个,Tomcat运行两天后突然报连接池耗尽,多半就是资源没关干净。
3.2 登录流程的Servlet实现:request、session和重定向
登录是学生信息管理系统的第一道门。用户在login.jsp输入用户名和密码,表单以POST方式提交到/login,LoginServlet负责校验。校验成功后把用户信息存进session,再重定向到学生列表页;失败则携带错误信息回到登录页。
package com.student.servlet; import javax.servlet.ServletException; import javax.servlet.http.*; import java.io.IOException; public class LoginServlet extends HttpServlet { // doGet直接转发给doPost, 地址栏手动访问也能正确处理 @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 从请求参数中获取用户输入 String username = request.getParameter("username"); String password = request.getParameter("password"); // 非空校验: 前端做了校验, 后端必须再做一次 if (username == null || username.isEmpty() || password == null || password.isEmpty()) { request.setAttribute("errorMsg", "用户名和密码不能为空"); request.getRequestDispatcher("login.jsp").forward(request, response); return; } // 简单模拟校验: 实际项目中改为查数据库admin表 if ("admin".equals(username) && "123456".equals(password)) { // 登录成功, 把用户写入Session并设置过期时间 HttpSession session = request.getSession(); session.setAttribute("currentUser", username); session.setMaxInactiveInterval(30 * 60); // 30分钟无操作失效 // 使用重定向防表单重复提交 response.sendRedirect(request.getContextPath() + "/studentList"); } else { // 登录失败, 回显错误消息 request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } } }这段登录逻辑当中有三个细节处理值得说明。request.getParameter拿到的是字符串,后端判空能防掉那些绕过前端直接构造请求的情况。登录成功后用sendRedirect而不用forward,是为了符合PRG(Post/Redirect/Get)模式——刷新列表页时不会把登录请求再提交一次。失败时用forward并携带errorMsg属性,JSP页面就能通过${errorMsg}把这句提示渲染出来。你还可以为密码存储预留一个升级空间,数据库里存MD5或BCrypt哈希后的密文,比明文密码稳妥得多。
3.3 学生信息DAO层:PreparedStatement的增删改查样板
DAO层是业务逻辑和数据库之间的隔离带。学生实体类Student包含id、studentNo(学号)、name、gender、age、className(班级)、phone这些字段。StudentDAO提供add、update、delete、findById、findAll5个方法。
package com.student.dao; import com.student.entity.Student; import com.student.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class StudentDAO { // 查询所有学生 public List<Student> findAll() { String sql = "SELECT id, student_no, name, gender, age, class_name, phone FROM student ORDER BY id"; List<Student> list = new ArrayList<>(); Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); pstmt = conn.prepareStatement(sql); rs = pstmt.executeQuery(); while (rs.next()) { list.add(mapRowToStudent(rs)); } } catch (SQLException e) { throw new RuntimeException("查询学生列表失败", e); } finally { DBUtil.close(rs, pstmt, conn); } return list; } // 新增学生 public boolean add(Student student) { String sql = "INSERT INTO student (student_no, name, gender, age, class_name, phone) " + "VALUES (?, ?, ?, ?, ?, ?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, student.getStudentNo()); pstmt.setString(2, student.getName()); pstmt.setString(3, student.getGender()); pstmt.setInt(4, student.getAge()); pstmt.setString(5, student.getClassName()); pstmt.setString(6, student.getPhone()); return pstmt.executeUpdate() > 0; } catch (SQLException e) { throw new RuntimeException("新增学生失败", e); } } // 根据ID删除学生 public boolean delete(int id) { String sql = "DELETE FROM student WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setInt(1, id); return pstmt.executeUpdate() > 0; } catch (SQLException e) { throw new RuntimeException("删除学生失败", e); } } // 更新学生信息 public boolean update(Student student) { String sql = "UPDATE student SET student_no = ?, name = ?, gender = ?, " + "age = ?, class_name = ?, phone = ? WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, student.getStudentNo()); pstmt.setString(2, student.getName()); pstmt.setString(3, student.getGender()); pstmt.setInt(4, student.getAge()); pstmt.setString(5, student.getClassName()); pstmt.setString(6, student.getPhone()); pstmt.setInt(7, student.getId()); return pstmt.executeUpdate() > 0; } catch (SQLException e) { throw new RuntimeException("更新学生失败", e); } } // 结果集映射为实体对象 private Student mapRowToStudent(ResultSet rs) throws SQLException { Student stu = new Student(); stu.setId(rs.getInt("id")); stu.setStudentNo(rs.getString("student_no")); stu.setName(rs.getString("name")); stu.setGender(rs.getString("gender")); stu.setAge(rs.getInt("age")); stu.setClassName(rs.getString("class_name")); stu.setPhone(rs.getString("phone")); return stu; } }所有SQL都用PreparedStatement的?占位符拼参数,而不是用字符串拼接。这不是风格问题,而是一条安全红线:用"SELECT * FROM student WHERE name = '" + name + "'"这种写法,用户输入' OR 1=1 --就能把整个表拖出来,这就是SQL注入。setString和setInt这些方法会自动处理特殊字符转义,相当于把用户输入当成数据而非SQL片段。另一个值得学习的是try-with-resources写法只在add和delete方法中使用了,因为这两个方法不需要ResultSet;而findAll需要承接查询结果,所以保留了传统finally块手动关闭资源。
3.4 学生列表页JSP渲染:JSTL和EL表达式让页面告别小脚本
早期写JSP的老程序员习惯在页面里塞<% while(rs.next()) { %>这种脚本片段,页面上Java代码掺着HTML标签,改一行样式都可能破坏业务逻辑。现在更规范的做法是用EL表达式取数据、JSTL标签做循环判断。在studentList.jsp的顶部引入标签库:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>学生信息列表</title> </head> <body> <h2>学生信息管理</h2> <p>欢迎,<c:out value="${sessionScope.currentUser}"/>!</p> <table border="1" cellpadding="8" cellspacing="0"> <tr> <th>ID</th> <th>学号</th> <th>姓名</th> <th>性别</th> <th>年龄</th> <th>班级</th> <th>联系电话</th> <th>操作</th> </tr> <c:forEach items="${studentList}" var="student"> <tr> <td>${student.id}</td> <td>${student.studentNo}</td> <td>${student.name}</td> <td>${student.gender}</td> <td>${student.age}</td> <td>${student.className}</td> <td>${student.phone}</td> <td> <a href="editStudent?id=${student.id}">编辑</a> <a href="deleteStudent?id=${student.id}" onclick="return confirm('确认删除该学生?')">删除</a> </td> </tr> </c:forEach> </table> </body> </html>这段JSP的关键在于请求转发时往request域里塞了一个名为studentList的List<Student>对象。StudentListServlet中的写法是:request.setAttribute("studentList", dao.findAll());然后request.getRequestDispatcher("studentList.jsp").forward(request, response);。JSP里的${studentList}会自动调用toString或者按JavaBean规范调getter方法,所以实体类必须保留getId()、getName()这类命名方式。
${sessionScope.currentUser}前面加sessionScope是为了限定取值范围。HttpServletRequest、HttpSession、ServletContext三个作用域都有setAttribute方法,如果不限定前缀,EL会按照page、request、session、application的顺序逐层查找。刚学的时候觉得不加也能出结果,一旦项目里同名属性多了,排查起来像在黑匣子里摸。养成写清作用域前缀的习惯,能省掉不少调试时间。
4. 数据表设计与分页模糊查询:让系统从玩具变成结课设计
4.1 三张核心表怎么拆:学生表、班级表、成绩表
学生信息管理系统如果只有一张student表,功能会显得单薄,也撑不起结课设计要求的“业务复杂度”。这里使用三张表设计:学生表student、班级表class_info、成绩表score。班级表解决学生所属班级信息的冗余,成绩表则让系统具备“按学号查询考试成绩”的扩展能力。
CREATE DATABASE IF NOT EXISTS student_db DEFAULT CHARACTER SET utf8mb4; USE student_db; -- 班级表 CREATE TABLE class_info ( id INT PRIMARY KEY AUTO_INCREMENT, class_name VARCHAR(50) NOT NULL UNIQUE, grade VARCHAR(20) COMMENT '年级, 如: 2024级', head_teacher VARCHAR(20) COMMENT '班主任' ); -- 学生表 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL, gender ENUM('男', '女') DEFAULT '男', age INT, class_id INT COMMENT '关联班级表的ID', phone VARCHAR(15), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 成绩表 CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT COMMENT '关联学生表的ID', subject VARCHAR(30) COMMENT '科目', score DECIMAL(5, 1), exam_date DATE, FOREIGN KEY (student_id) REFERENCES student(id) ON DELETE CASCADE );三张表之间用外键关联:student.class_id指向class_info.id,score.student_id指向student.id。外键约束能保证数据完整性,但也不是越多越好。比如score表对学生表的外键设置了ON DELETE CASCADE,意味着删除学生时成绩自动级联删除,这在演示系统里很方便;实际生产系统中通常不会启用级联删除,而是用逻辑删除(加deleted标记字段)保护历史数据。gender字段用ENUM类型在MySQL中没问题,但如果后续要迁移到SQL Server就需要改成CHAR(1),这一点在做数据库选型时要想清楚。
4.2 分页查询的参数对照表:LIMIT ? OFFSET ?的实际用法
数据量小的时候findAll一把梭还看不出问题,等往课程设计里插入50条以上演示数据时,列表页会被拉得很长。分页是整个系统里性价比最高的一项优化。前端页面传递两个参数:页码pageNum和每页条数pageSize。DAO层的分页查询方法:
public List<Student> findByPage(int pageNum, int pageSize) { String sql = "SELECT s.id, s.student_no, s.name, s.gender, s.age, " + "s.phone, c.class_name " + "FROM student s LEFT JOIN class_info c ON s.class_id = c.id " + "ORDER BY s.id LIMIT ?, ?"; List<Student> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { // MySQL的LIMIT从0开始, 第1页的offset是0 int offset = (pageNum - 1) * pageSize; pstmt.setInt(1, offset); pstmt.setInt(2, pageSize); ResultSet rs = pstmt.executeQuery(); while (rs.next()) { list.add(mapRowToStudent(rs)); } } catch (SQLException e) { throw new RuntimeException("分页查询失败", e); } return list; }同时查询总数来算总页数:
public int count() { String sql = "SELECT COUNT(*) FROM student"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql); ResultSet rs = pstmt.executeQuery()) { rs.next(); return rs.getInt(1); } catch (SQLException e) { throw new RuntimeException("统计学生总数失败", e); } }分页参数需要特别说明的是LIMIT ? ?的含义:第一个参数是跳过的行数,第二个参数是返回的最大行数。代码中offset = (pageNum - 1) * pageSize,当pageSize=10时,第1页的offset是0,第2页的offset是10,第3页是20,以此类推。很多新手会把第一页的offset写成1,结果第一条数据被跳过。另一个常见问题是页码越界——用户手动在URL里传入pageNum=99时,查询会返回空列表。所以Servlet里必须对页码做防御性校验:小于1时重设为1,大于总页数时重设为总页数。不处理的话,演示时极可能因为随意改动URL参数而当场翻车。
4.3 多条件模糊查询:动态SQL的雏形
分页之后,课程设计的高频需求就是按姓名、学号、班级模糊查询。这三个条件可能组合出现,也可能是空条件——SQL需要根据用户实际输入动态拼接。这是MyBatis动态SQL要解决的问题,在纯Servlet时代只能靠StringBuilder手动拼接。
public List<Student> searchStudents(String keyword, int classId, int pageNum, int pageSize) { StringBuilder sql = new StringBuilder(); sql.append("SELECT s.id, s.student_no, s.name, s.gender, s.age, s.phone, c.class_name "); sql.append("FROM student s LEFT JOIN class_info c ON s.class_id = c.id WHERE 1=1 "); List<Object> params = new ArrayList<>(); // 动态拼关键字条件 if (keyword != null && !keyword.trim().isEmpty()) { sql.append("AND (s.name LIKE ? OR s.student_no LIKE ?) "); String likeKeyword = "%" + keyword.trim() + "%"; params.add(likeKeyword); params.add(likeKeyword); } // 动态拼班级条件 if (classId > 0) { sql.append("AND s.class_id = ? "); params.add(classId); } sql.append("ORDER BY s.id LIMIT ?, ?"); params.add((pageNum - 1) * pageSize); params.add(pageSize); List<Student> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql.toString())) { for (int i = 0; i < params.size(); i++) { pstmt.setObject(i + 1, params.get(i)); } ResultSet rs = pstmt.executeQuery(); while (rs.next()) { list.add(mapRowToStudent(rs)); } } catch (SQLException e) { throw new RuntimeException("条件查询失败", e); } return list; }这段代码有一个经典技巧:WHERE 1=1。这个恒真条件看起来没用,却让后面的AND拼接变得无脑——有条件就接AND ...,不用判断“当前是不是第一个条件”。参数列表用List<Object>收集,再统一通过pstmt.setObject(i + 1, params.get(i))设值,顺序必须和SQL中的占位符严格一致。值得注意的是LIKE关键字在MySQL中默认不区分大小写,但下划线_和百分号%如果出现在用户输入内容中会被当成通配符,需要转义的话可以加ESCAPE子句。对课设场景,不处理影响也不大。
5. 基于JSP+Servlet的5个翻车现场:从中文乱码到404
5.1 POST请求中文乱码:filter配置了还是没用
现象:登录成功后跳转到学生列表,页面上所有中文姓名全部变成问号???。新增一条包含中文的记录后,数据库里存进去的也是乱码。
原因:Tomcat 9的server.xml中默认的URIEncoding是UTF-8,但POST请求的表单参数编码取自请求头的Content-Type字符集。如果JSP页面没有指定pageEncoding="UTF-8",表单提交的数据在浏览器端就用系统默认编码(Windows下是GBK)发送了出去。只写<%@ page contentType="text/html;charset=UTF-8" %>还不够,acceptcharset或filter里的setCharacterEncoding调用顺序有讲究。
解决:在EncodingFilter里对所有请求统一设置编码:
public class EncodingFilter implements Filter { private String encoding = "UTF-8"; @Override public void init(FilterConfig filterConfig) { String enc = filterConfig.getInitParameter("encoding"); if (enc != null) { encoding = enc; } } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(encoding); // 只对POST请求体生效 response.setCharacterEncoding(encoding); // 响应内容编码 response.setContentType("text/html;charset=" + encoding); chain.doFilter(request, response); } }关键点是request.setCharacterEncoding必须在读取任何请求参数之前调用。HttpServletRequest第一次调用getParameter时会按当前编码解析请求体,之后再设置就晚了。所以我建议EncodingFilter的url-pattern写/*并置于web.xml最前面,保证它是第一个执行过滤器。现代浏览器和Tomcat 9组合下,这样的配置可以同时解决POST和GET请求的乱码问题。
5.2 Servlet映射写成/*导致JSP样式全部丢失
现象:输入http://localhost:8080/student/studentList地址,原本正常的页面变成一片空白,按F12看Network面板,发现所有CSS、JS文件全部404。
原因:web.xml里某个Servlet的url-pattern写成了/*。这个通配符匹配所有路径,包括/css/style.css和JSP页面本身。Servlet优先于默认Servlet处理请求,本应由Tomcat内置的DefaultServlet处理的静态资源和JSP编译请求全部被截胡,所以样式文件全部返回404。
解决:检查所有Servlet映射的url-pattern。精确匹配/studentList、/login只拦截指定路径,不会影响静态资源。如果确实需要通配某个前缀,用/api/*这种带路径前缀的写法,让JSP和静态资源路径如/css/*、/static/*不在匹配范围内。
5.3 启动时报ClassNotFoundException: javax.servlet.http.HttpServlet
现象:Tomcat启动后,访问项目地址出现java.lang.ClassNotFoundException: javax.servlet.http.HttpServlet的异常堆栈,页面直接报500。
原因:pom.xml或lib目录下没有引入Servlet API依赖。有些人用的是JDK 11及以上版本创建项目,IDEA默认解析的模块依赖里没有Tomcat运行环境提供的servlet-api.jar。
解决:在pom.xml中引入Servlet API依赖,注意使用Tomcat 9对应的javax.servlet坐标:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency>这里的scope设为provided很重要:编译时需要Servlet API,但部署到Tomcat时容器自带实现,如果改成compile会把Tomcat自带的类再打包一遍,版本冲突时反而会出诡异问题。
5.4 JDBC驱动加载失败:MySQL 8.x驱动的class名坑
现象:启动项目后调用DAO层方法,控制台打印ClassNotFoundException: com.mysql.jdbc.Driver。
原因:数据库驱动MySQL Connector/J 8.x已经把驱动类名从com.mysql.jdbc.Driver改成com.mysql.cj.jdbc.Driver。老教程里的com.mysql.jdbc.Driver在MySQL 8及以上版本中已移除。同时新版驱动要求url中必须指定serverTimezone,否则连接时抛出时区相关的异常。
解决:db.properties中已写明com.mysql.cj.jdbc.Driver,url带上了serverTimezone=Asia/Shanghai。数据库驱动jar包通过Maven依赖引入:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>5.5 修改代码后不生效:Tomcat缓存和重启玄学
现象:改了Java代码后重启Tomcat,访问效果还是旧逻辑。有时改了JSP代码,刷新页面也看不到变化。
原因:Tomcat默认开启JSP编译缓存,部署时工作目录下的work/Catalina保存了已编译的class文件。IDEA的热部署(Update classes and resources)对Java方法体内部的改动有效,但对新增方法、修改类签名、修改web.xml不生效。更隐蔽的是IDEA的Build输出目录和Tomcat部署目录不同步。
解决:最稳妥的做法是在IDEA的Tomcat配置中启用On frame deactivation为Update classes and resources,同时在Deployment选项卡选择“Exploded”而不是“Archive”。如果修改后依然不生效,手动删除target目录和Tomcat的work目录再重启,基本能解决90%的“改动不生效”问题。
6. 给课设加分的三个小动作:Filter日志、事务回滚和URL重写
项目骨架和核心功能跑通后,想让答辩评分往上走一个档次,这三个优化值得花半天时间加进去。
第一个是Filter统一访问日志。在现有过滤器基础上再写一个AccessLogFilter,拦截所有请求并记录IP地址、访问路径、处理耗时。这个功能技术上不复杂,却非常有答辩表现力——评委问“系统有什么亮点”时,你可以直接打开控制台展示每一条请求的处理时间,证明自己考虑了可观测性问题。
public class AccessLogFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; String ip = request.getRemoteAddr(); String uri = request.getRequestURI(); long start = System.currentTimeMillis(); chain.doFilter(req, resp); long cost = System.currentTimeMillis() - start; System.out.println("[AccessLog] " + ip + " " + uri + " " + cost + "ms"); } }第二个是数据库事务回滚。当前add和update方法各自独立提交事务,如果“新增学生后同步往score表插入默认成绩”这两步操作中途发生异常,数据库里会出现只有学生没有成绩的脏数据。正确做法是在一个业务方法里拿到同一个连接,手动控制提交和回滚:
Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); studentDAO.addWithConn(conn, student); scoreDAO.initScoreWithConn(conn, student.getId()); conn.commit(); } catch (Exception e) { conn.rollback(); } finally { conn.setAutoCommit(true); DBUtil.close(conn); }setAutoCommit(false)是事务开始的分界线,后续所有SQL都在同一个数据库连接上执行,直到commit才真正落盘。只要过程中任何一步抛出异常,rollback会把之前的所有操作全部撤销。这个“事务管理器”模式在面试题里出现的频率极高,亲手实现一次比背十遍八股文都管用。
第三个是URL重写与页面上编辑、删除链接的规范传参。/editStudent?id=18这种方式把参数放在查询字符串里,对数据修改类操作不够安全——别人把地址改成id=17就能操作别人的数据。你可以做一个简单的权限校验:确认session中有currentUser才允许执行修改操作。删除用POST加confirm弹窗,将链接改为带隐藏表单的方式提交。
做过这几个毕业设计周期后,我最深的体会是:JSP+Servlet这套老技术栈真正磨炼人的地方不在于“能不能跑”,而在于你能不能讲清楚为什么这么搭建。如果能把从环境配置、数据库设计、Servlet控制流程、Filter解决乱码到事务回滚的完整链路给评委讲明白,哪怕代码规模才两千行,也比一万行“Ctrl+C/Ctrl+V”的Spring Boot项目更有含金量。希望帮到你。
本文还有配套的精品资源,点击获取