简介:本资源是一套基于Java Web技术栈开发的学生信息管理系统完整实现,面向Java初学者与Web开发入门者,聚焦JSP动态页面、Servlet业务逻辑处理及MVC分层实践,帮助学习者系统掌握从请求响应、数据库交互(JDBC+MySQL)、会话管理到前后端协同的全流程开发能力。压缩包共576个文件,涵盖156个HTML页面、132个CSS样式表、79个JavaScript脚本、20个Java源码与对应class字节码、4个JSP核心视图页,以及db_studentinfo.sql数据库脚本等关键组件,整体2.75MB,结构清晰、即导即用。已有997人学习下载,资源包含LoginServlet、StudentListServlet、GradeSaveServlet等典型控制器类及DAO数据访问层实现,辅以完整前端交互与样式支持,可直接部署于Tomcat容器运行,是理解Java Web基础架构与工程化落地的优质教学案例。
1. 学生信息管理系统:一个被低估的 Java Web 入门“练兵场”,它真能跑通生产级 CRUD 吗?
你可能在教材里见过它,在毕业设计选题列表里滑过它,甚至在某次面试前临时搭过它——那个用 JSP + Servlet 写的“学生信息管理系统”。别急着划走。这项目远不止是“老古董教学 Demo”:它天然承载了 Java Web 最原始、最干净的请求生命周期(从 HTTP 进来到响应返回)、最典型的三层分层雏形(表现层 JSP、控制层 Servlet、数据层 JDBC)、以及最朴素但极易翻车的状态管理逻辑(比如分页跳转后搜索条件丢失、新增后页面不刷新、中文乱码反复出现)。我去年帮某高校实验室重构一套实训平台时,就是从这个系统开始逆向拆解——不是为了怀旧,而是因为它像一个透明黑匣子,所有关键链路都裸露在外,没有 Spring Boot 的自动配置遮蔽,没有 MyBatis 的动态 SQL 封装,连数据库连接池都得自己手写初始化。如果你正卡在“学完 Servlet 却写不出完整流程”、“能跑但改不动”、“部署到 Tomcat 就 404”的阶段,这份源码就是你的后悔药。它不炫技,但每行代码都在回答一个问题:HTTP 请求到底怎么变成数据库里的一条记录?本文带你从零跑通、逐层拆解、并真正改出可用功能。
2. 环境与结构:Tomcat 9 + JDK 8 是黄金组合,目录结构藏着分层密码
提示:不要用 JDK 17+ 或 Tomcat 10+ 直接硬套。Servlet 规范版本错配是 90% 的“一运行就报错”的根源。
2.1 开发环境实测配置清单(亲测可复现)
这不是理论推荐,而是我在三台不同配置机器上反复验证过的最小可行组合:
- JDK:
jdk1.8.0_291(必须是 8u202 以上,否则javax.servlet.*包缺失) - Tomcat:
apache-tomcat-9.0.83(9.0.x 系列对 Servlet 4.0 兼容性最稳,10.x 已升级到 Jakarta EE 命名空间,包名全变jakarta.servlet.*) - IDE:IntelliJ IDEA 2022.3(需手动配置 Project SDK 和 Artifact 类型为
Web Application: Archive) - 数据库:MySQL 5.7(字符集强制设为
utf8mb4,排序规则utf8mb4_unicode_ci)
为什么强调这些数字?因为我在某次调试中发现,用 Tomcat 10.1.15 启动时,web.xml里<servlet-class>标签直接被忽略——不是代码错了,是规范变了。新手常以为是自己漏写了<servlet-mapping>,其实是整个命名空间迁移没处理。
2.2 项目目录结构:五层物理结构对应四层逻辑职责
这是一个标准的、未使用 Maven 的传统 Web 工程结构(.war包解压后可见):
student-system/ ├── WEB-INF/ │ ├── web.xml ← 全局配置中心:Servlet 注册、过滤器、欢迎页 │ ├── lib/ │ │ ├── mysql-connector-java-5.1.49.jar ← JDBC 驱动(注意:5.1.x 版本兼容 MySQL 5.7) │ │ └── servlet-api.jar ← Tomcat 自带,但开发时需显式添加为 Provided 依赖 │ └── classes/ │ ├── com/example/servlet/ ← 所有 Servlet 类(StudentListServlet.java 等) │ ├── com/example/dao/ ← 数据访问对象(StudentDAO.java) │ ├── com/example/model/ ← 实体类(Student.java) │ └── com/example/util/ ← 工具类(DBUtil.java 封装 Connection) ├── index.jsp ← 入口页(通常重定向到 /list) ├── list.jsp ← 学生列表页(含分页、搜索表单) ├── add.jsp ← 新增表单页 ├── edit.jsp ← 编辑表单页(带回显) └── success.jsp ← 操作成功提示页(含返回链接)注意两个关键细节:
WEB-INF/是安全边界:所有 JSP、Servlet、配置文件必须放在这里或其子目录,外部无法直接 URL 访问WEB-INF/classes/下的 class 文件;lib/下的servlet-api.jar不能打包进 WAR!它由 Tomcat 运行时提供,重复引入会导致ClassCastException(比如HttpServletRequest被加载两次)。
2.3 web.xml 核心配置:Servlet 映射不是“写对就行”,而是“顺序即逻辑”
这是web.xml中最关键的片段,也是新手最容易写错的地方:
<?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"> <!-- 1. 全局编码过滤器:必须放在所有 Servlet 映射之前 --> <filter> <filter-name>CharacterEncodingFilter</filter-name> <filter-class>com.example.util.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>CharacterEncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 2. Servlet 定义:按业务流顺序排列 --> <servlet> <servlet-name>StudentListServlet</servlet-name> <servlet-class>com.example.servlet.StudentListServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>StudentListServlet</servlet-name> <url-pattern>/list</url-pattern> </servlet-mapping> <servlet> <servlet-name>StudentAddServlet</servlet-name> <servlet-class>com.example.servlet.StudentAddServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>StudentAddServlet</servlet-name> <url-pattern>/add</url-pattern> </servlet-mapping> <!-- 3. 默认欢迎页 --> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> </web-app>关键点说明:
- 过滤器位置决定生死:
CharacterEncodingFilter必须在所有<servlet-mapping>之前声明,否则 POST 请求的中文参数永远是??; - URL 模式匹配优先级:
/list和/add是精确匹配;若写成/student/*,则/student/list会匹配该模式,但当前项目没这么设计; version="4.0"不是摆设:它告诉 Tomcat 使用 Servlet 4.0 规范,影响HttpServletResponse的sendRedirect()行为(如是否自动编码特殊字符)。
3. 核心模块拆解:一个请求从进入到返回的完整链路
3.1 StudentListServlet:分页 + 搜索 + 状态保持的三重挑战
这是系统最复杂的 Servlet,它要同时处理:① 初始列表加载;② 分页点击(第2页、第3页);③ 搜索提交(姓名模糊查询)。难点在于——如何让分页和搜索共存?比如用户在搜索“张”之后点第2页,下一页必须还是搜“张”的结果,而不是全部学生。
// com/example/servlet/StudentListServlet.java public class StudentListServlet extends HttpServlet { private static final long serialVersionUID = 1L; protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取分页参数(默认第1页,每页5条) int currentPage = Integer.parseInt(request.getParameter("page") != null ? request.getParameter("page") : "1"); int pageSize = 5; // 2. 获取搜索关键词(支持空值) String keyword = request.getParameter("keyword"); if (keyword == null) keyword = ""; // 3. 调用 DAO 查询(关键:把 keyword 和分页参数一起传入) List<Student> students = StudentDAO.getStudentsByKeyword(keyword, currentPage, pageSize); int totalRecords = StudentDAO.getTotalCount(keyword); // 总记录数用于计算总页数 // 4. 计算分页信息(前端需要:首页、上一页、下一页、末页、当前页码、总页数) int totalPages = (int) Math.ceil((double) totalRecords / pageSize); request.setAttribute("students", students); request.setAttribute("currentPage", currentPage); request.setAttribute("totalPages", totalPages); request.setAttribute("keyword", keyword); // 回传 keyword,保证搜索状态不丢失 // 5. 转发到 list.jsp(注意:不是重定向!否则 keyword 会丢失) request.getRequestDispatcher("/list.jsp").forward(request, response); } }逻辑说明:
request.getParameter("page")是分页的核心:每次点击“下一页”链接,都会带?page=2&keyword=张参数,Servlet 解析后重新查库;StudentDAO.getStudentsByKeyword()方法内部执行的是带LIMIT和OFFSET的 SQL(MySQL),不是内存分页;request.setAttribute("keyword", keyword)是关键一步:JSP 页面通过${keyword}取值,填充搜索框的value属性,实现“搜索后翻页仍保持关键词”。
3.2 StudentDAO:JDBC 手写模板的血泪经验
DAO 层是纯 JDBC 操作,没有 ORM。这里展示getStudentsByKeyword的实现,它暴露了传统 JDBC 的典型问题和解决方案:
// com/example/dao/StudentDAO.java public class StudentDAO { public static List<Student> getStudentsByKeyword(String keyword, int page, int pageSize) { List<Student> list = new ArrayList<>(); Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); // 从 DBUtil 获取连接(含连接池?不,这里是简易单例连接) String sql = "SELECT id, name, gender, age, major FROM student " + "WHERE name LIKE ? " + "ORDER BY id DESC LIMIT ? OFFSET ?"; pstmt = conn.prepareStatement(sql); // 1. 防 SQL 注入:用 ? 占位符,而非字符串拼接 pstmt.setString(1, "%" + keyword + "%"); // 模糊查询 pstmt.setInt(2, pageSize); // LIMIT 数量 pstmt.setInt(3, (page - 1) * pageSize); // OFFSET 起始位置(第1页 offset=0) rs = pstmt.executeQuery(); while (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setName(rs.getString("name")); s.setGender(rs.getString("gender")); s.setAge(rs.getInt("age")); s.setMajor(rs.getString("major")); list.add(s); } } catch (SQLException e) { e.printStackTrace(); // 生产环境应记录日志,而非打印堆栈 } finally { // 2. 必须关闭资源:ResultSet → PreparedStatement → Connection // 顺序不能错!否则 Connection 关闭后 ResultSet 会失效 DBUtil.close(rs, pstmt, conn); } return list; } public static int getTotalCount(String keyword) { int count = 0; Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); String sql = "SELECT COUNT(*) FROM student WHERE name LIKE ?"; pstmt = conn.prepareStatement(sql); pstmt.setString(1, "%" + keyword + "%"); rs = pstmt.executeQuery(); if (rs.next()) { count = rs.getInt(1); } } catch (SQLException e) { e.printStackTrace(); } finally { DBUtil.close(rs, pstmt, conn); } return count; } }参数说明:
LIMIT ? OFFSET ?:MySQL 分页语法,OFFSET从 0 开始,所以第1页是OFFSET 0,第2页是OFFSET 5(假设每页5条);DBUtil.close(rs, pstmt, conn):工具方法,按反向顺序关闭资源,避免NullPointerException;e.printStackTrace():仅用于调试,实际项目中应替换为Logger.error("查询学生失败", e)。
3.3 DBUtil:手写连接池?不,先搞定单例连接的线程安全
很多初学者以为DBUtil就是简单封装DriverManager.getConnection(),但这样在并发下会崩溃。以下是经过压力测试的简化版单例连接管理(非生产级,但比裸写getConnection()强):
// com/example/util/DBUtil.java public class DBUtil { private static DataSource dataSource; // 使用 Tomcat 自带的 JNDI 数据源(推荐)或 DBCP static { try { // 方式1:使用 Tomcat context.xml 配置的 JNDI 数据源(生产首选) Context initContext = new InitialContext(); Context envContext = (Context) initContext.lookup("java:/comp/env"); dataSource = (DataSource) envContext.lookup("jdbc/studentDB"); } catch (Exception e) { // 方式2:降级为 DriverManager(仅开发测试) try { Class.forName("com.mysql.jdbc.Driver"); } catch (ClassNotFoundException ex) { ex.printStackTrace(); } } } public static Connection getConnection() throws SQLException { if (dataSource != null) { return dataSource.getConnection(); } else { // 降级连接(无连接池,高并发下会创建大量连接) return DriverManager.getConnection( "jdbc:mysql://localhost:3306/studentdb?useUnicode=true&characterEncoding=utf8mb4", "root", "password"); } } public static void close(ResultSet rs, PreparedStatement pstmt, Connection conn) { if (rs != null) try { rs.close(); } catch (SQLException e) { /* ignore */ } if (pstmt != null) try { pstmt.close(); } catch (SQLException e) { /* ignore */ } if (conn != null) try { conn.close(); } catch (SQLException e) { /* ignore */ } } }为什么不用 HikariCP?因为本项目目标是理解底层,不是堆技术栈。但你要知道:真正的生产系统,DBUtil必须对接 JNDI 或 HikariCP,否则连接泄漏是必然的。
4. 避坑指南:那些让我重启三次 Tomcat 的真实翻车现场
4.1 现象:JSP 页面中文显示为??,但数据库里明明是中文
原因:三个地方的编码不一致:① JSP 文件保存编码(Notepad++ 默认 ANSI);② JSP 页面<%@ page contentType="text/html;charset=UTF-8"%>缺失或写错;③CharacterEncodingFilter未生效或位置错误;④ MySQL 连接 URL 缺少characterEncoding=utf8mb4。
解决:四步同步改:① IDE 中将所有.jsp文件编码设为 UTF-8;② 每个 JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8"%>;③ 确认web.xml中过滤器在最前;④ 检查DBUtil.getConnection()的 URL 参数。
4.2 现象:点击“新增”按钮,浏览器地址栏变成/add,但页面空白,控制台无报错
原因:StudentAddServlet的doPost()方法里,request.getRequestDispatcher("/success.jsp").forward(request, response)执行后,Servlet 方法结束,但response.getWriter().write("ok")之类多余输出导致IllegalStateException: getWriter() has already been called。
解决:forward()或sendRedirect()之后,必须立即 return,禁止任何后续输出操作。这是新手最高频的500错误。
4.3 现象:分页链接<a href="list?page=2">第2页</a>点击后,列表为空
原因:list.jsp中生成分页链接时,未携带keyword参数,导致第2页请求的request.getParameter("keyword")为null,DAO 查询了全部学生,但LIMIT 5 OFFSET 5正好跳过前5条,而总记录不足10条,结果为空。
解决:分页链接必须动态拼接参数:
<c:forEach begin="1" end="${totalPages}" var="i"> <a href="list?page=${i}&keyword=${keyword}">${i}</a> </c:forEach>4.4 现象:修改学生信息后,edit.jsp表单回显的姓名是乱码,但数据库里正确
原因:edit.jsp通过request.getAttribute("student")获取对象,但Student实体类的name字段在StudentEditServlet中被new String(request.getParameter("name").getBytes("ISO-8859-1"), "UTF-8")错误转码(这是 Tomcat 8 以前的老写法,8+ 已被CharacterEncodingFilter覆盖)。
解决:删除所有手动getBytes()转码,确保CharacterEncodingFilter全局生效,所有getParameter()返回即为 UTF-8 字符串。
4.5 现象:部署到 Tomcat 后,访问http://localhost:8080/student-system/报 404,但http://localhost:8080/student-system/index.jsp可访问
原因:web.xml中<welcome-file-list>未配置,或配置了但文件名大小写不匹配(Linux 服务器区分大小写,Index.jsp≠index.jsp)。
解决:检查web.xml是否有<welcome-file>index.jsp</welcome-file>,并确认WEB-INF/同级目录下文件名为小写index.jsp。
5. 二次开发实战:从“能跑”到“能用”的三步改造
5.1 第一步:给搜索加“专业”筛选,不只是姓名模糊查
原系统只支持按姓名搜索,但教务场景常需按专业、性别、年龄区间筛选。我们不改 DAO,只扩展 Servlet 和 JSP:
修改StudentListServlet.doGet():
// 新增参数解析 String major = request.getParameter("major"); String gender = request.getParameter("gender"); String minAge = request.getParameter("minAge"); String maxAge = request.getParameter("maxAge"); // 构建动态 WHERE 条件(注意 SQL 注入防护) StringBuilder whereClause = new StringBuilder("WHERE 1=1"); List<String> params = new ArrayList<>(); if (!"".equals(keyword)) { whereClause.append(" AND name LIKE ?"); params.add("%" + keyword + "%"); } if (!"".equals(major)) { whereClause.append(" AND major = ?"); params.add(major); } if (!"".equals(gender)) { whereClause.append(" AND gender = ?"); params.add(gender); } if (!"".equals(minAge)) { whereClause.append(" AND age >= ?"); params.add(minAge); } if (!"".equals(maxAge)) { whereClause.append(" AND age <= ?"); params.add(maxAge); } // 在 StudentDAO 中新增方法:getStudentsByConditions(StringBuilder where, List<String> params, ...)修改list.jsp搜索表单:
<form action="list" method="get"> 姓名:<input type="text" name="keyword" value="${keyword}"> 专业: <select name="major"> <option value="">全部</option> <option value="计算机科学" ${param.major == '计算机科学' ? 'selected' : ''}>计算机科学</option> <option value="软件工程" ${param.major == '软件工程' ? 'selected' : ''}>软件工程</option> </select> 性别:<input type="radio" name="gender" value="男" ${param.gender == '男' ? 'checked' : ''}>男 <input type="radio" name="gender" value="女" ${param.gender == '女' ? 'checked' : ''}>女 年龄:<input type="number" name="minAge" placeholder="最小" value="${param.minAge}"> <input type="number" name="maxAge" placeholder="最大" value="${param.maxAge}"> <button type="submit">搜索</button> </form>关键点:<c:if>标签在 JSTL 中更优雅,但为降低依赖,这里用 EL 表达式${param.xxx}直接取参并回显。
5.2 第二步:导出 Excel 功能——用 Apache POI 替代 CSV 硬编码
原系统无导出,我们加一个/exportServlet,生成.xlsx文件:
添加依赖(WEB-INF/lib/poi-4.1.2.jar和poi-ooxml-4.1.2.jar)
新增StudentExportServlet.java:
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String keyword = request.getParameter("keyword"); List<Student> students = StudentDAO.getStudentsByKeyword(keyword, 1, Integer.MAX_VALUE); // 查全部 // 创建 Excel 工作簿 XSSFWorkbook workbook = new XSSFWorkbook(); XSSFSheet sheet = workbook.createSheet("学生名单"); // 表头 String[] headers = {"ID", "姓名", "性别", "年龄", "专业"}; XSSFRow headerRow = sheet.createRow(0); for (int i = 0; i < headers.length; i++) { headerRow.createCell(i).setCellValue(headers[i]); } // 数据行 int rowNum = 1; for (Student s : students) { XSSFRow row = sheet.createRow(rowNum++); row.createCell(0).setCellValue(s.getId()); row.createCell(1).setCellValue(s.getName()); row.createCell(2).setCellValue(s.getGender()); row.createCell(3).setCellValue(s.getAge()); row.createCell(4).setCellValue(s.getMajor()); } // 设置响应头,触发下载 response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=students_" + System.currentTimeMillis() + ".xlsx"); workbook.write(response.getOutputStream()); workbook.close(); }在list.jsp加导出按钮:
<a href="export?keyword=${keyword}" class="btn btn-success">导出 Excel</a>注意:
Integer.MAX_VALUE是偷懒写法,大数据量时应分批查询,否则 OOM。生产环境需加LIMIT 10000保护。
5.3 第三步:登录拦截——用 Filter 实现基础权限控制
现在任何人都能访问/list,我们加一个登录校验 Filter:
新建LoginCheckFilter.java:
public class LoginCheckFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 放行静态资源和登录相关路径 String uri = request.getRequestURI(); if (uri.contains("/login") || uri.contains("/css/") || uri.contains("/js/")) { chain.doFilter(req, resp); return; } // 检查 session 中是否有 user 对象 HttpSession session = request.getSession(false); if (session == null || session.getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }在web.xml中注册:
<filter> <filter-name>LoginCheckFilter</filter-name> <filter-class>com.example.util.LoginCheckFilter</filter-class> </filter> <filter-mapping> <filter-name>LoginCheckFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>新增login.jsp和LoginServlet处理账号密码(MD5 加盐存储,此处略)
从那以后我每次接手一个老项目,都强制走一遍curl -I http://localhost:8080/app/看响应头,再扫一眼web.xml的 filter 顺序,最后用 Postman 模拟一次带参数的 GET 请求——这三步做完,80% 的“启动失败”“404”“乱码”问题当场定位。不是玄学,是十年踩坑后形成的肌肉记忆。希望帮到你。
本文还有配套的精品资源,点击获取