JSP+Servlet+JDBC+MySQL学生管理系统:从零搭建Java Web全栈项目
2026/9/9 6:23:40 网站建设 项目流程

简介:基于jsp+servlet+jdbc+MySQL开发的学生管理系统,面向计算机相关专业需要完成课程设计或毕业设计的在校生。系统涵盖学生信息、课程成绩、用户管理等常见模块,采用经典的MVC分层结构,配有可直接运行的完整工程。压缩包共341个文件,包含84个Java源码、84个编译后class文件、36个JSP页面、61个JS脚本及28个CSS样式,另有SQL脚本和项目配置文件,大小仅6.99MB,便于快速部署学习。资源已由作者提前测试,确保能完美运行,并附有项目说明链接供查看显示效果,遇到问题可留言或私信交流。目前已有3320人学习下载,适合用来理解Servlet与JDBC交互、掌握JavaBean封装及前后端数据流转的完整实现思路。 最近在整理硬盘的时候翻出一个大学时期的Java Web课设项目:基于JSP+Servlet+JDBC+MySQL的学生管理系统。说实话,这套技术栈现在看着确实有点"复古",但我在实际开发和带新人的过程中发现,它依然是理解Java Web后端运行机制最直观的组合之一。很多新手直接上手Spring Boot,往往搞不清请求是怎么被处理的、Session是怎么维持的、数据库连接为什么会泄漏,而这些在JSP+Servlet时代都是必须亲手解决的问题。

这个项目包含了学生信息管理系统中非常典型的功能模块:登录认证、CRUD操作、分页查询、模糊搜索、会话管理。虽然不是微服务也不是分布式,但它五脏俱全,特别适合用来打通"前端页面→Servlet控制器→Service业务层→DAO数据访问→MySQL存储"这条完整链路。如果你正在学Java Web,或者马上要交课程设计,又或者想回头补一补基础,这篇文章应该能帮你少走不少弯路。

1. 这套"老古董"技术栈为什么还值得写

先说一个很多人会问的问题:现在企业里都是Spring Boot、MyBatis Plus,学JSP+Servlet还有意义吗?我的看法是,有意义,而且意义比你想的要大。

Servlet是Java Web的基石,Spring MVC的前端控制器DispatcherServlet本质上就是一个Servlet,只是它帮你封装了太多东西。当你在Spring Boot里写一个@RequestMapping注解时,背后发生的URL匹配、请求分发、参数绑定,底层逻辑和手写Servlet是一模一样的。只是框架把这些过程自动化了,所以你感受不到。如果你不懂Servlet规范,遇到404、405、请求参数丢失这类问题,排查起来会比较痛苦。

另外,JSP也有它的价值。虽然现在主流是前后端分离,但JSP里那种"Java代码直接嵌入HTML"的方式,对于理解服务端渲染(SSR)的概念非常有帮助。你亲手在<%%>标签里写一段循环把数据库里的学生列表渲染成表格,比看十篇"前后端分离架构优势"的文章都管用。等你理解了渲染发生在服务端和客户端到底有什么区别,再去看Thymeleaf、FreeMarker甚至Vue的SSR方案,都会觉得顺理成章。

实际开发这套系统的过程中,我还发现一个很现实的好处:它对机器配置要求极低。一个Tomcat 8.5,一个JDK 1.8,一个MySQL 5.7,加起来不到300MB的部署环境,随便一台老旧笔记本都能跑起来。不像现在的前端工程动辄npm install装出几个GB的node_modules,这套东西轻量、直观、没有黑魔法。

2. 学生管理系统的功能拆解与数据库设计

写任何项目之前,先把功能边界划清楚。我这个系统的用户角色只有一种:管理员。功能没有做得很花哨,专注解决学生信息管理的几个核心场景。

2.1 功能模块清单

我最终实现的功能模块如下:

  • 登录模块:管理员输入账号密码登录,使用Session保存登录状态,未登录用户访问受限页面时自动跳转到登录页。
  • 学生信息列表:分页展示学生基本信息,每页10条,底部有页码导航。
  • 新增学生:表单录入学号、姓名、性别、年龄、班级、联系方式,提交后写入数据库。
  • 编辑学生:点击"编辑"按钮,表单回显当前学生的既有数据,修改后提交更新。
  • 删除学生:点击"删除"按钮,根据学号(主键)删除对应记录。
  • 模糊查询:支持按学生姓名或班级进行模糊匹配,查询结果同样支持分页。
  • 退出登录:销毁Session并跳转回登录页。

看起来功能不算多,但"增删改查+登录+分页"已经覆盖了Java Web课设的绝大多数要求。如果你需要交作业,在这个基础上加个"成绩管理"或"选课管理"模块,架构是完全兼容的。

2.2 数据库表结构设计

数据库我取名为student_db,核心表只有一张t_student。设计上有一个细节值得注意:我用stu_no(学号)作为业务主键,同时设置了唯一索引。为什么不直接用自增ID?因为学号本身就是唯一标识学生的业务字段,用它做查询条件的频率远高于自增ID。但自增ID也保留了一份,作为物理主键,防止业务主键将来发生变化(比如学号规则调整)时牵一发动全身。

CREATE DATABASE IF NOT EXISTS student_db DEFAULT CHARSET utf8mb4; USE student_db; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL ); CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL, name VARCHAR(50) NOT NULL, gender CHAR(1), age INT, class_name VARCHAR(50), phone VARCHAR(20), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_no (stu_no) ); INSERT INTO t_user (username, password) VALUES ('admin', '123456');

这里有个很常见的坑:MySQL 5.7默认的字符集是latin1,如果你建表时没指定utf8mb4,插入中文姓名时就会变成???。而且utf8mb4utf8还不一样,utf8mb4是真正的四字节UTF-8,可以存储emoji等特殊字符。在这个项目中直接用utf8mb4是更稳妥的选择。

另外关于年龄字段,我的表里直接用INT存储。如果你要做得更规范,可以用TINYINT UNSIGNED,因为年龄不可能超过255,也不可能为负。虽然对这个小项目来说差别不大,但这种字段设计的思考习惯会在你写更复杂的业务时派上用场。

3. 后端核心实现:JDBC连接管理的两种姿势

JDBC是这套系统里最容易写错的部分,也是最能体现一个开发者经验的地方。我在写这个项目时,一开始把获取连接的代码写在每个DAO方法里,后来重构时发现重复代码太多,而且连接管理极度混乱。这里我把两种写法的对比展开讲一下。

3.1 第一种写法:每个DAO方法内获取连接(反面教材)

最初的学生DAO是这样的:

public List<Student> findAll() { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; List<Student> list = new ArrayList<>(); try { Class.forName("com.mysql.jdbc.Driver"); conn = DriverManager.getConnection( "jdbc:mysql://localhost:3306/student_db?useUnicode=true&characterEncoding=utf8", "root", "123456"); String sql = "SELECT * FROM t_student"; ps = conn.prepareStatement(sql); rs = ps.executeQuery(); while (rs.next()) { // 封装对象... } } catch (Exception e) { e.printStackTrace(); } finally { // 关闭 rs、ps、conn... } return list; }

这段代码有两个问题。第一,Class.forName这行其实可以省略,因为MySQL 5.0以上的JDBC驱动在加载时已经通过SPI机制自动注册了驱动类,你直接DriverManager.getConnection是能连上的。但这行代码在面试里经常被问到,所以保留它作为"显式加载驱动"的示范也无妨。第二,每个方法都写一遍完整的"加载驱动→建立连接→执行SQL→关闭资源"流程,代码冗余度极高。一旦数据库连接信息变化(比如密码改了),你得把所有DAO方法都改一遍,极易遗漏。

3.2 第二种写法:抽取DBUtil工具类管理连接

后来的重构版本中,我把连接管理抽取成一个独立的DBUtil类:

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/student_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USERNAME = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps != null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }

这里有几个连接参数的细节非常关键。useSSL=false是MySQL 5.7的新要求,不设置的话每次连接都会在控制台打印一大段SSL警告。serverTimezone=Asia/Shanghai是解决时区问题必须加的,否则会报The server time zone value 'Öйú±ê׼ʱ¼ä'的乱码时区错误。这些参数看着不起眼,但少了任何一个都可能在运行时踩坑。

JDBC的"六步走"流程:加载驱动、获取连接、创建Statement、执行SQL、处理结果集、关闭资源。这套流程在任何Java数据库操作里都不会变,只是框架帮你封装了。所以在DBUtil里我做的是"第一步"和"第二步"的统一封装,而"第六步"也做了一个静态的close方法,让调用方的finally块简洁很多。

3.3 数据库连接池的补充说明

这个项目为了保持浅显易懂,用的是DriverManager直接获取连接。但在实际的Java Web项目里,这种写法只适合学习和课设,不适合生产环境。因为每次请求都要经历"建立TCP连接→身份验证→断开连接"的完整过程,而一次SQL执行可能只需要几毫秒,连接建立的消耗反而成了主要瓶颈。

生产环境应该使用连接池(如Druid、HikariCP),原理很简单:启动时预先创建一批连接放在池子里,谁要用就借出去,用完还回来,而不是销毁。连接池还自带超时回收、空闲检测、SQL防注入过滤等功能。如果你在这个项目基础上做毕业设计,强烈建议引入Druid连接池,代码改动量不大,但在技术面试时可以理直气壮地说自己熟悉数据库连接池的原理和使用。

4. Servlet层设计:URL映射与控制器的编写思路

有了数据库访问层,接下来就是Servlet层。这一层的核心职责是:接收HTTP请求、解析参数、调用Service层方法、决定跳转到哪个JSP页面。我在这个项目里没有引入任何MVC框架,所有的请求分发都通过Servlet手动完成。

4.1 使用web.xml方式配置Servlet映射

这里我特意用了web.xml方式而不是@WebServlet注解方式,原因是课设阶段老师往往要求掌握配置文件方式。另外,web.xml方式可以更直观地看到请求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> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> <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-name>StudentListServlet</servlet-name> <servlet-class>com.student.servlet.StudentListServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>StudentListServlet</servlet-name> <url-pattern>/list</url-pattern> </servlet-mapping> <!-- 其他Servlet映射类似,省略 --> </web-app>

注意一个细节:<url-pattern>的写法。/login这种精确匹配,只有URL完全是/login时才会命中。如果你写的是/login/*,那/login/anything也会命中。还有种写法是*.do,表示以.do结尾的URL都命中,比如Struts时代的约定。不同写法的匹配优先级在Servlet规范里有明确规定:精确匹配 > 最长路径匹配 > 扩展名匹配 > 默认匹配。如果你发现请求被莫名其妙的Servlet拦截了,优先检查这个优先级顺序。

4.2 典型的LoginServlet实现

登录逻辑是整个系统的入口,也是Session管理的关键。我给出核心代码:

@WebServlet("/login") // 如果你用的Servlet 3.0+,也可以这样替代web.xml public class LoginServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); // 简单的前端校验 if (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) { request.setAttribute("msg", "用户名和密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } User user = userService.login(username, password); if (user != null) { // 登录成功,保存会话信息 HttpSession session = request.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); // 30分钟过期 response.sendRedirect(request.getContextPath() + "/list"); } else { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); } }

关于doGetdoPost的处理,这里我直接让doGet也调用doPost,是为了防止用户直接在浏览器地址栏输入/login时出现405错误。但要注意:登录操作严格来说应该只接受POST请求,因为GET请求的URL会携带密码参数,既有安全风险又会污染访问日志。这里做成两种方式都支持,是出于演示方便,生产环境建议在doGet里直接返回405或重定向到登录页。

另一个关键点是request.setCharacterEncoding("UTF-8")的位置。这行代码必须放在获取任何参数之前,否则POST提交的中文会出现乱码。GET请求的乱码问题则是另一套逻辑:Tomcat 8及以上版本默认的GET请求URI编码已经是UTF-8,但如果你用的Tomcat 7,还需要在server.xml里配置URIEncoding="UTF-8"。这算是一个经典的历史坑。

4.3 登录验证过滤器(可选但推荐)

在学生管理系统的最终版本里,我没有加登录过滤器,而是简单地在每个受保护Servlet内部检查Session。但如果你想让代码更规范,应该用一个Filter统一处理未登录的拦截:

@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); // 不创建新Session String uri = req.getRequestURI(); if (session != null && session.getAttribute("loginUser") != null || uri.endsWith("login.jsp") || uri.contains("/login")) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } } }

注意我用了req.getSession(false),这表示"如果当前没有Session就返回null",而不是创建一个新的。很多新手会写成getSession(),这会带来一个安全隐患:即使你只是打开登录页,服务器也会为你的浏览器创建一次Session。虽然在这个小项目里影响不大,但养成getSession(false)的习惯是好的,尤其在开发高并发系统时,无意义的Session创建会占用内存。

5. JSP页面渲染与表单回显的细节

JSP页面是整个系统的最上层,也是很多初学者写起来最容易给自己挖坑的地方。核心问题集中在:如何正确使用EL表达式与JSTL标签、表单回显时怎么处理空值、页面之间跳转路径怎么找准。

5.1 使用EL表达式与JSTL渲染学生列表

我最终的学生列表面板使用JSTL标签库替代了原始的<%%>脚本片段。如果你去翻很多老项目,会发现页面上写满了<% for(int i=0; i<list.size(); i++){ %>这种脚本片段。这种写法的缺点是页面维护困难,而且脚本片段里出现的异常会导致整个页面报错,错误信息还极其难定位。而JSTL+EL是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> <link rel="stylesheet" type="text/css" href="css/style.css"> </head> <body> <div class="container"> <h2>学生信息列表</h2> <div class="toolbar"> <a href="add.jsp" class="btn-add">新增学生</a> <form action="list" method="get" class="search-form"> <input type="text" name="keyword" value="${keyword}" placeholder="输入姓名或班级"> <button type="submit">查询</button> </form> </div> <table border="1" cellpadding="10" cellspacing="0"> <tr> <th>学号</th><th>姓名</th><th>性别</th><th>年龄</th><th>班级</th><th>联系方式</th><th>操作</th> </tr> <c:forEach items="${page.list}" var="stu"> <tr> <td>${stu.stuNo}</td> <td>${stu.name}</td> <td>${stu.gender}</td> <td>${stu.age}</td> <td>${stu.className}</td> <td>${stu.phone}</td> <td> <a href="edit?stuNo=${stu.stuNo}">编辑</a> <a href="delete?stuNo=${stu.stuNo}" onclick="return confirm('确定删除该学生吗?');">删除</a> </td> </tr> </c:forEach> </table> <div class="pagination"> <c:if test="${page.currentPage > 1}"> <a href="list?currentPage=${page.currentPage - 1}&keyword=${keyword}">上一页</a> </c:if> <span>第 ${page.currentPage} / ${page.totalPage} 页</span> <c:if test="${page.currentPage < page.totalPage}"> <a href="list?currentPage=${page.currentPage + 1}&keyword=${keyword}">下一页</a> </c:if> </div> </div> </body> </html>

一个重要的踩坑点:EL表达式中访问的是JavaBean的getXxx()方法去掉get后首字母小写的属性名。如果数据库字段是stu_no,我在Java对象里定义为stuNo,那么在JSP里就要用${stu.stuNo}。有些人习惯把数据库字段的下划线照搬到Java属性里(stu_no),这样EL表达式就要写成${stu.getStu_no()},既不美观也容易出错。所以在设计JavaBean时,属性名要遵循驼峰命名规则,与数据库字段的映射关系在DAO层的ResultSet封装中处理好。

5.2 编辑页面的表单回显:空值防御

编辑页面的核心需求是:页面加载时,将数据库中已有的学生信息填充到表单的各个输入框中。实现的逻辑是在EditServlet中根据stuNo查出学生对象,放入request域,然后forward到edit.jsp,在JSP中通过EL表达式输出到value属性里。

<tr> <td>学号</td> <td><input type="text" name="stuNo" value="${student.stuNo}" readonly></td> </tr> <tr> <td>姓名</td> <td><input type="text" name="name" value="${student.name}"></td> </tr>

这里有一个很多新手必踩的坑:如果${student.name}的值是null,浏览器显示时输入框不会为空,而是会出现一行null字面量。原因是EL表达式对null值的输出结果确实是空字符串,但如果你在Servlet中初始化对象时不小心把某个字段设成了字符串"null"(比如从request参数直接用request.getParameter("name")得到的是"null"字符串而不是null),页面上就会显示出来。解决方法是:

value="${empty student.name ? '' : student.name}"

这样做的好处是,当字段为null或空字符串时,输入框显示空白,不会出现"null"字样的尴尬情况。这个细节在你做其他任何Web项目时都会用到,建议形成条件反射。

5.3 JSP页面之间的路径问题

JSP里写hrefsrc时,最安全的写法是使用<c:url>标签或者${pageContext.request.contextPath}拼接绝对路径。比如:

<link rel="stylesheet" type="text/css" href="${pageContext.request.contextPath}/css/style.css"> <a href="${pageContext.request.contextPath}/list">学生列表</a>

为什么不直接写href="list"?因为在转发(forward)的情况下,浏览器的地址栏URL不会改变。比如你在/login页面通过request.getRequestDispatcher("/list").forward()跳转后,地址栏仍然是/login,但页面内容已经是列表页了。此时如果你在列表页里写了一个相对路径href="edit?stuNo=001",浏览器解析时会基于/login目录去拼路径,得到/login/edit,然后404。而加上${pageContext.request.contextPath}前缀后,永远是/student_db/list/student_db/edit,不会因为页面来源不同而错乱。

6. 分页和模糊查询的组合实现:一个经典业务场景

分页和模糊查询是两个独立的功能,但放到一起实现时,非常考察代码组织的严密性。尤其是查询条件的持久化——用户在第一页搜索"张",翻到第二页时,搜索条件"张"应该仍然保留。这个细节很多课设项目都做得不对。

6.1 PageBean的设计

我定义了一个PageBean<T>泛型类来封装分页相关的所有数据:

public class PageBean<T> { private int currentPage; // 当前页码,从1开始 private int pageSize; // 每页显示条数 private int totalCount; // 总记录数 private int totalPage; // 总页数 private List<T> list; // 当前页的数据集合 private String keyword; // 查询关键字 // getters and setters... }

为什么把这些字段封装成一个对象而不是在Servlet里散乱地传递多个参数?因为这样可以一次性从request中获取、放置,页面渲染时只需从${page.xxx}取数据,逻辑非常集中。

6.2 DAO层的动态SQL拼接

模糊查询的关键在于SQL语句里使用LIKE关键字。但如果你直接拼接字符串,会引入SQL注入的风险。正确的做法是使用PreparedStatement的占位符(?)机制:

public List<Student> findByPage(int currentPage, int pageSize, String keyword) { List<Student> list = new ArrayList<>(); String sql = "SELECT * FROM t_student"; StringBuilder sb = new StringBuilder(sql); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { sb.append(" WHERE name LIKE ? OR class_name LIKE ?"); params.add("%" + keyword.trim() + "%"); params.add("%" + keyword.trim() + "%"); } sb.append(" ORDER BY id DESC LIMIT ?, ?"); params.add((currentPage - 1) * pageSize); params.add(pageSize); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sb.toString())) { for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } ResultSet rs = ps.executeQuery(); while (rs.next()) { // 封装Student对象... } } catch (SQLException e) { e.printStackTrace(); } return list; }

这里我特别注意了两个地方。第一,LIMIT后面的偏移量计算:第1页的偏移量是0,第2页是10,第3页是20。公式是(currentPage - 1) * pageSize,因为数据库的偏移量是从0开始的。第二,PreparedStatement的下标从1开始,而不是从0开始,这个和ResultSet的下标是一样的,新手容易在这里越界。

关于防SQL注入,PreparedStatement的预编译机制会在数据库端将参数转义处理,比如用户输入' OR '1'='1时,它会把整个字符串当成一个字面量匹配,而不是当成SQL语句的一部分。如果你用字符串拼接的方式写SQL,用户输入'; DROP TABLE t_student;--,可能导致灾难性的后果。所以无论项目大小,只要写JDBC操作,就用PreparedStatement,不要用Statement

6.3 查询条件的跨页面保留

查询条件丢失是模糊查询+分页最常见的bug。用户在第一页输入"张三",点击查询后正确显示了结果。翻到第二页时,如果请求里没有携带keyword=张三,查询条件就丢失了,分页变成了全表数据的第二页,而不是查询结果的第二页。

解决思路是:在分页导航的链接里面,把keyword也作为参数传递。我上面的JSP代码里已经体现了这一点:

<a href="list?currentPage=${page.currentPage - 1}&keyword=${keyword}">上一页</a>

而在Servlet端,每次处理请求时都要把keyword从request参数中重新取出来,存入PageBean对象中,再放入request域。这样无论用户怎么翻页,关键词都不会丢。

一个要注意的小细节:当页码超过总页数时怎么处理。比如总页数只有3页,用户手动把URL里的currentPage改成99,DAO层查询结果会是空。更好的做法是在Servlet里加一个校验:

int currentPage = 1; if (request.getParameter("currentPage") != null) { currentPage = Integer.parseInt(request.getParameter("currentPage")); } if (currentPage < 1) currentPage = 1;

这里Integer.parseInt如果遇到非数字参数会抛出NumberFormatException,导致500错误。更加健壮的做法是包一层try-catch,解析失败时默认使用1。

7. 项目打包、部署与踩坑记录

最后聊聊整个项目的部署流程和我在这个过程中踩过的坑。这部分内容比较零散,但每一条都是真实经历,写出来帮你省点时间。

7.1 从IDE导出war包并部署到Tomcat

开发完成后,项目需要部署到Tomcat运行。我使用的开发环境是IntelliJ IDEA,但直接依赖IDEA的Tomcat集成插件运行项目时,偶尔会出现一些诡异的类加载问题。所以最终测试时我选择手动打包部署,这样也更能理解Web应用的标准目录结构。

在IDEA中,操作路径是:File → Project Structure → Artifacts → 点"+" → Web Application: Exploded → 在"Output Layout"里检查是否有lib目录并添加所有依赖的JDBC驱动JAR包。然后通过Build → Build Artifacts → Build来生成war包。

war包的本质是一个zip压缩文件,它的目录结构必须符合Java Web规范:

student-management.war ├── META-INF/ │ └── MANIFEST.MF ├── WEB-INF/ │ ├── classes/ (编译后的.class文件) │ │ └── com/student/... │ ├── lib/ (第三方jar包,如mysql-connector-java.jar) │ └── web.xml ├── css/ ├── js/ ├── login.jsp ├── list.jsp └── add.jsp

把war包直接扔到Tomcat的webapps目录下,启动Tomcat,它就会自动解压并部署。访问路径是http://localhost:8080/student-management/,注意这个路径名默认与war包的文件名一致。

7.2 部署过程中的三大经典报错

第一个报错:java.lang.ClassNotFoundException: com.mysql.jdbc.Driver

这个错误几乎每个初学者都遇到过,原因多半是JDBC驱动JAR包没有正确放到WEB-INF/lib目录下。注意是WEB-INF/lib,不是WEB-INF/classes,也不是Tomcat的lib目录。启动Tomcat时,WEB-INF/lib下的jar包只对当前应用可见,不会污染其他应用。判断jar包是否加载成功,可以在Tomcat启动日志里看到类似Deploying web application archive的信息。

第二个报错:The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized

这个我在前面提到过,MySQL 8.0以上的驱动对时区敏感,URL里必须加serverTimezone=Asia/Shanghai。另外还有一个变体报错是java.sql.SQLException: Connection is not available,如果你用了连接池,通常是连接没有正确归还导致的连接池耗尽。

第三个报错:Error during artifact deployment. See server log for details

这是IDEA部署时的通用错误,具体原因要看日志。最常见的原因有两个:一个是web.xml<web-app>版本号与Tomcat版本不匹配,比如Tomcat 9不支持web-app的3.0版本;另一个是某个Servlet类漏写了@WebServletweb.xml中的servlet-class写错导致类加载失败。

7.3 MySQL 8.0驱动兼容性

这个项目的开发经历了MySQL 5.7和8.0两个版本,其中一个很隐蔽的问题是驱动类名的变化。MySQL 5.x时代,驱动类是com.mysql.jdbc.Driver;MySQL 8.x时代,驱动类改成了com.mysql.cj.jdbc.Driver。旧类名的兼容性在8.0中被保留了一段时间,但会在启动时打印警告日志。

另一个是我在实际运行中遇到的:MySQL 8.0的密码加密方式变了,默认使用caching_sha2_password,而某些老版本的JDBC驱动(5.1.x系列)不支持这种认证方式,会报Unable to load authentication plugin 'caching_sha2_password'。解决方法是:

  1. 使用较新的mysql-connector-java-8.0.x.jar驱动;
  2. 或者在MySQL里把用户的认证方式改回mysql_native_password
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';

如果你是在本地练手,更推荐第二种方案,因为很多老教程默认root密码是123456,改认证方式不影响已有代码。

7.4 JSP文件修改后不生效的检查链

很多人在修改JSP后遇到"改了没反应"的问题。这个问题的检查链是:

  1. 浏览器缓存:按Ctrl+F5强制刷新,排除浏览器缓存干扰。
  2. IDEA中JSP的编译缓存:IDEA有时会缓存JSP的翻译结果,尝试Rebuild Project。
  3. Tomcat的work目录:Tomcat会把JSP翻译成Java文件再编译成class文件,存在Tomcat安装目录/work/Catalina/localhost/下。如果你直接在Tomcat的webapps里改了JSP而没重启Tomcat,正常情况下Tomcat会检测到文件变化自动重编译,但有时文件时间戳没变就不会触发。手动删除work目录下的对应文件夹,重启Tomcat即可。
  4. 文件编码问题:JSP文件必须保存为UTF-8编码,且在页面头部声明<%@ page contentType="text/html;charset=UTF-8" %>。如果你用的IDE默认编码是GBK,而Tomcat按UTF-8解析,页面中文就会乱码,看起来就像"没有正确修改"。

8. 这套代码的后续扩展方向

如果这篇博文里的项目是你用来交课设或起步学习的,你可以在这个基础上做几个有趣的扩展。这里我根据自己的经验给三个方向:

方向一:引入Druid连接池。改动量不大,Dao层所有DBUtil.getConnection()换成DruidDataSource.getConnection()即可,但随之带来的是连接池配置、监控、防注入等功能,能写进简历的技术点更多。

方向二:把Servlet精简成BaseServlet。通过反射机制将/list/add/edit/delete映射到同一个StudentServlet的不同方法上,避免创建大量Servlet类。这其实就是MVC框架中路由分发器的雏形。

方向三:增加成绩管理模块。设计t_score表,加一个班级平均分统计的SQL查询,用JSP展示统计结果。这个扩展虽然听着普通,但在课设答辩时老师非常喜欢问"你是怎么统计平均分的""如果数据量大了怎么优化",回答好这些问题展示的不只是CRUD能力。

我在实现这些扩展时最大的感受是:这个项目就像一套乐高积木的底座,功能模块之间耦合度低,替换和添加都相对容易。这也是它作为课设项目被广泛使用的原因——结构简单清晰,每一层都能独立修改而不影响其他层。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询