Java Web 入门:手写学生管理系统跑通 Servlet+JSP+JDBC 完整链路

📅 发布时间:2026/10/21 8:55:30
Java Web 入门:手写学生管理系统跑通 Servlet+JSP+JDBC 完整链路
简介本资源是一套基于Java Web技术栈开发的学生信息管理系统完整实现面向Java初学者与Web开发入门者聚焦JSP动态页面、Servlet业务逻辑处理及MVC分层实践帮助学习者系统掌握从请求响应、数据库交互JDBCMySQL、会话管理到前后端协同的全流程开发能力。压缩包共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 开发环境实测配置清单亲测可复现这不是理论推荐而是我在三台不同配置机器上反复验证过的最小可行组合JDKjdk1.8.0_291必须是 8u202 以上否则javax.servlet.*包缺失Tomcatapache-tomcat-9.0.839.0.x 系列对 Servlet 4.0 兼容性最稳10.x 已升级到 Jakarta EE 命名空间包名全变jakarta.servlet.*IDEIntelliJ 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 version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 !-- 1. 全局编码过滤器必须放在所有 Servlet 映射之前 -- filter filter-nameCharacterEncodingFilter/filter-name filter-classcom.example.util.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameCharacterEncodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping !-- 2. Servlet 定义按业务流顺序排列 -- servlet servlet-nameStudentListServlet/servlet-name servlet-classcom.example.servlet.StudentListServlet/servlet-class /servlet servlet-mapping servlet-nameStudentListServlet/servlet-name url-pattern/list/url-pattern /servlet-mapping servlet servlet-nameStudentAddServlet/servlet-name servlet-classcom.example.servlet.StudentAddServlet/servlet-class /servlet servlet-mapping servlet-nameStudentAddServlet/servlet-name url-pattern/add/url-pattern /servlet-mapping !-- 3. 默认欢迎页 -- welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-app关键点说明过滤器位置决定生死CharacterEncodingFilter必须在所有servlet-mapping之前声明否则 POST 请求的中文参数永远是??URL 模式匹配优先级/list和/add是精确匹配若写成/student/*则/student/list会匹配该模式但当前项目没这么设计version4.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 和分页参数一起传入 ListStudent 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)是分页的核心每次点击“下一页”链接都会带?page2keyword张参数Servlet 解析后重新查库StudentDAO.getStudentsByKeyword()方法内部执行的是带LIMIT和OFFSET的 SQLMySQL不是内存分页request.setAttribute(keyword, keyword)是关键一步JSP 页面通过${keyword}取值填充搜索框的value属性实现“搜索后翻页仍保持关键词”。3.2 StudentDAOJDBC 手写模板的血泪经验DAO 层是纯 JDBC 操作没有 ORM。这里展示getStudentsByKeyword的实现它暴露了传统 JDBC 的典型问题和解决方案// com/example/dao/StudentDAO.java public class StudentDAO { public static ListStudent getStudentsByKeyword(String keyword, int page, int pageSize) { ListStudent 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页 offset0 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)工具方法按反向顺序关闭资源避免NullPointerExceptione.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?useUnicodetruecharacterEncodingutf8mb4, 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 contentTypetext/html;charsetUTF-8%缺失或写错③CharacterEncodingFilter未生效或位置错误④ MySQL 连接 URL 缺少characterEncodingutf8mb4。解决四步同步改① IDE 中将所有.jsp文件编码设为 UTF-8② 每个 JSP 顶部加% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-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 hreflist?page2第2页/a点击后列表为空原因list.jsp中生成分页链接时未携带keyword参数导致第2页请求的request.getParameter(keyword)为nullDAO 查询了全部学生但LIMIT 5 OFFSET 5正好跳过前5条而总记录不足10条结果为空。解决分页链接必须动态拼接参数c:forEach begin1 end${totalPages} vari a hreflist?page${i}keyword${keyword}${i}/a /c:forEach4.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-fileindex.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 11); ListString 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, ListString params, ...)修改list.jsp搜索表单form actionlist methodget 姓名input typetext namekeyword value${keyword} 专业 select namemajor option value全部/option option value计算机科学 ${param.major 计算机科学 ? selected : }计算机科学/option option value软件工程 ${param.major 软件工程 ? selected : }软件工程/option /select 性别input typeradio namegender value男 ${param.gender 男 ? checked : }男 input typeradio namegender value女 ${param.gender 女 ? checked : }女 年龄input typenumber nameminAge placeholder最小 value${param.minAge} input typenumber namemaxAge placeholder最大 value${param.maxAge} button typesubmit搜索/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.javaprotected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String keyword request.getParameter(keyword); ListStudent 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; filenamestudents_ System.currentTimeMillis() .xlsx); workbook.write(response.getOutputStream()); workbook.close(); }在list.jsp加导出按钮a hrefexport?keyword${keyword} classbtn btn-success导出 Excel/a注意Integer.MAX_VALUE是偷懒写法大数据量时应分批查询否则 OOM。生产环境需加LIMIT 10000保护。5.3 第三步登录拦截——用 Filter 实现基础权限控制现在任何人都能访问/list我们加一个登录校验 Filter新建LoginCheckFilter.javapublic 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-nameLoginCheckFilter/filter-name filter-classcom.example.util.LoginCheckFilter/filter-class /filter filter-mapping filter-nameLoginCheckFilter/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”“乱码”问题当场定位。不是玄学是十年踩坑后形成的肌肉记忆。希望帮到你。本文还有配套的精品资源点击获取