简介:基于Servlet+JSP+MySQL构建的学生宿舍管理系统,主要面向JavaWeb初学者、期末大作业和毕业设计人群。源码已在本地编译运行通过,评审得分98分,难度适中,并经助教老师审定,可直接用于课程设计或项目二次开发,也可作为学习Servlet与JSP协作的完整范例。系统覆盖宿舍管理常见的数据与操作流程,配套使用教程说明环境配置与运行方法,适合理解JavaWeb前后端交互、SQL查询及分层开发思路。资源共202个文件,压缩包仅7.21MB,其中JSP页面(22个)和Java类(21个)构成核心业务代码,SQL脚本便于快速建库,CSS/JS/PNG等资源用于前端展示与交互,Bootstrap相关文件提供响应式样式支持,另有jar依赖库、HTML静态页及配置文件,目录结构清晰,便于定位与修改。目前已有248人学习使用,适合需要快速上手JavaWeb项目、完成课设或毕设的同学参考。
1. 从课程设计到生产代码:Servlet+JSP+MySQL宿舍管理系统在2025年的价值
很多人在看到“Servlet+JSP+MySQL”这种组合时,第一反应是“这不是Java Web课程设计的标配吗”。确实,在Spring Boot一统天下的今天,这套技术栈看起来像是上个时代的产物。但恰恰是这种判断的偏差,让大量从业者在面试和实际维护中栽了跟头——你接手的老系统、外包交付的项目、甚至是某些政府教育机构的内部平台,依然跑在Servlet容器里。学生宿舍管理系统作为Java Web最经典的业务场景,正好可以完整展示这套组合的运作逻辑:从请求分发到会话保持,从JDBC连接到前端渲染,每一步都暴露了Web框架底层的真实机制。这篇教程要解决的不是“如何用Spring Boot做个宿舍系统”,而是“在Servlet+JSP+MySQL这个技术栈约束下,如何把学生管理、宿舍分配、来访登记这些事务做成一个能跑、能查、能部署的真实系统”。适合正在做课程设计的学生、需要接手传统Java Web项目的一线开发、以及想搞懂框架底层原理的工程师。文章会用完整源码结构和可执行步骤,带你走通从建表到部署的全过程。
2. JavaWeb技术栈选型:为什么Servlet+JSP在宿舍管理场景依旧成立
2.1 Servlet容器与JSP页面渲染的核心机制
Servlet本身是Java EE规范中处理HTTP请求的组件,它没有绑定任何框架。JSP在运行时会被容器翻译成Servlet源码再编译成class,所以你和页面模板打交道的本质,还是在写Servlet。用Eclipse创建基于Maven的Servlet项目时,实际生成的pom.xml只需要引入javax.servlet-api和jsp-api两个依赖即可:
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> </dependencies>这里有一个关键参数:scope为provided,意味着这两个依赖在运行时由Tomcat提供,打包时不会被打入WAR或JAR中。注意这里的版本号对应的是Servlet 4.0规范,对应Tomcat 9.x;如果你的运维环境用的是Tomcat 10,包名会从javax.*变成jakarta.*。宿舍管理系统通常部署在旧版Tomcat上,所以默认按javax命名空间来写。
JSP页面在第一次被访问时会经过翻译、编译、加载、实例化四个阶段,这也是为什么“Lazy Loading”模式下首屏请求会慢。你可以通过Tomcat的server.xml把reloadable设为true来开启热加载,但这只建议在开发环境使用,生产环境开启会导致频繁的类加载器重建和PermGen内存泄漏。
2.2 请求流程中的DispatcherServlet与双WebApplicationContext概念
这里需要纠正一个常见的认知混淆:DispatcherServlet并不是Spring MVC独有的概念。在纯Servlet开发中,web.xml中配置的Servlet映射也可以视为一种Dispatcher的雏形:
<servlet> <servlet-name>StudentManage</servlet-name> <servlet-class>com.dorm.servlet.StudentServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>StudentManage</servlet-name> <url-pattern>/student/*</url-pattern> </servlet-mapping>当所有/student/*的请求都落到这个Servlet时,它就是业务层面的Dispatcher。对于使用Spring框架的JavaWeb项目,容器启动时会创建两个上下文:ContextLoaderListener初始化的RootWebApplicationContext负责加载数据源、事务等全局Bean;DispatcherServlet初始化时创建的ServletWebApplicationContext负责加载控制器、视图解析器等Web组件。这个双上下文机制是面试中高频考察点,你可以通过ContextLoader.getCurrentWebApplicationContext()来获取根上下文,但实际操作中,更常见的是在applicationContext.xml中用<context:component-scan>扫描Service和DAO层,在spring-servlet.xml中专门扫描Controller层。
如果你在启动时看到类似NoClassDefFoundError: org/springframework/boot/web/servlet/support/SpringBootServletInitializer的错误,说明你的项目虽然用了Servlet API,但依赖里混入了Spring Boot的嵌入容器类。宿舍管理系统如果用Maven构建,检查是否在pom.xml错误引入了spring-boot-starter-web,移除后清理Maven仓库缓存即可。
2.3 JSP页面渲染的组件化思路
JSP页面在宿舍系统里承担三重角色:数据录入、列表展示、操作反馈。纯粹用<scriptlet>写Java代码在JSP里会快速变成灾难,一个合格的页面应该把Java代码量控制在最小范围。常见的做法是使用JSTL标签库:
<%@ 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> <table border="1"> <thead> <tr> <th>学号</th> <th>姓名</th> <th>宿舍号</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${students}" var="stu"> <tr> <td>${stu.studentNo}</td> <td>${stu.name}</td> <td>${stu.dormName}</td> <td> <a href="${pageContext.request.contextPath}/student?action=edit&id=${stu.id}">编辑</a> <a href="${pageContext.request.contextPath}/student?action=delete&id=${stu.id}" onclick="return confirm('确定删除?')">删除</a> </td> </tr> </c:forEach> </tbody> </table> </body> </html>逻辑说明:${students}是Servlet在请求作用域里setAttribute("students", list)设置的数据集合,c:forEach遍历这个集合并把当前迭代项赋值给stu变量。${pageContext.request.contextPath}动态获取项目部署路径,这样做的好处是无论最后打成WAR放到Tomcat的webapps下以什么名称部署,页面里的超链接都不会因为上下文路径变化而失效。
JSP页面有个很实际的坑是个人信息的展示页面需要屏蔽离开页面时的提示。浏览器默认会在页面有未提交表单数据时弹出离开确认框。你可以在页面的<body>标签上挂载onbeforeunload事件来定制行为:
window.onbeforeunload = function(e) { // 页面发生导航时触发, 如果不做任何事, 大多数浏览器会直接放行 // 如果需要拦截, 需要设置returnValue // e.returnValue = '确定离开吗?'; // 旧式写法 };但注意,这个事件在现代浏览器中不能自定义提示文案,只能通过赋值e.returnValue触发默认弹窗。如果只是想静默离开,直接用空函数覆盖即可,不要在函数体里放任何return语句。
3. 数据库设计与JDBC访问层:宿舍管理系统的数据地基
3.1 MySQL建库建表与索引设计
宿舍管理系统的核心表有四张:student(学生)、dormitory(宿舍)、visit_log(来访登记)、repair(报修)。建表时需要注意字符集和排序规则,MySQL 5.7和8.0的默认字符集分别是latin1和utf8mb4,如果你的mysql安装教程里没有特别指定,在8.0环境里建表显式声明字符集仍然是最稳妥的做法:
CREATE DATABASE dorm_management DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE dorm_management; CREATE TABLE dormitory ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '宿舍ID', dorm_name VARCHAR(20) NOT NULL UNIQUE COMMENT '宿舍楼栋+房号', capacity INT NOT NULL DEFAULT 4 COMMENT '额定人数', current_count INT NOT NULL DEFAULT 0 COMMENT '当前入住人数', manager_name VARCHAR(50) COMMENT '楼管员姓名', manager_phone VARCHAR(20) COMMENT '楼管员电话' ) ENGINE=InnoDB COMMENT='宿舍信息表'; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '学生ID', student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL COMMENT '姓名', gender ENUM('男','女') NOT NULL COMMENT '性别', phone VARCHAR(20) COMMENT '联系电话', dorm_id INT COMMENT '宿舍ID', entry_date DATE COMMENT '入住日期', status TINYINT DEFAULT 1 COMMENT '1-在住 0-退宿', CONSTRAINT fk_student_dorm FOREIGN KEY (dorm_id) REFERENCES dormitory(id) ) ENGINE=InnoDB COMMENT='学生信息表'; CREATE INDEX idx_student_dorm_id ON student(dorm_id); CREATE INDEX idx_student_name ON student(name);参数说明:idx_student_dorm_id是外键字段的索引,这个索引的主要作用是加速“查询某间宿舍居住的所有学生”这种高频操作;idx_student_name索引是针对“按名字模糊搜索学生”场景,若你的系统有WHERE name LIKE '张%'这种前缀匹配查询,这个索引会生效。如果业务上经常按“性别+宿舍号”组合查询,可以考虑建联合索引(gender, dorm_id),但联合索引的字段顺序必须按查询条件的最左前缀原则来排。
MySQL 8.0中的ENUM类型在某些场景下可能引起UPDATE语句的隐式转换问题,实际业务中更推荐使用TINYINT加comment标注含义。status字段的设计是为了保留学生历史数据,删除操作只做逻辑删除,这样宿舍楼栋的入住统计才不会因为退宿学生而丢失记录。
3.2 JDBC连接池与PreparedStatement防注入
JDBC连接是重量级资源,每次请求都创建物理连接的性能损耗不可接受。最常见的做法是用C3P0或Druid连接池初始化数据库连接,在web.xml的<listener>中配置DruidDataSource:
<context-param> <param-name>jdbcUrl</param-name> <param-value>jdbc:mysql://localhost:3306/dorm_management?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8</param-value> </context-param> <context-param> <param-name>username</param-name> <param-value>root</param-value> </context-param> <context-param> <param-name>password</param-name> <param-value>123456</param-value> </context-param>在代码里读取以上参数并创建连接池:
import com.alibaba.druid.pool.DruidDataSource; public class DbUtil { private static DruidDataSource dataSource; static { dataSource = new DruidDataSource(); dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); dataSource.setUrl("jdbc:mysql://localhost:3306/dorm_management?useSSL=false&serverTimezone=Asia/Shanghai"); dataSource.setUsername("root"); dataSource.setPassword("123456"); dataSource.setInitialSize(5); // 连接池初始连接数 dataSource.setMaxActive(20); // 最大活跃连接数 dataSource.setMinIdle(3); // 最小空闲连接数 dataSource.setMaxWait(3000); // 获取连接的超时时间, 单位毫秒 } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt != null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } // 此处不是真正关闭物理连接, 而是归还到连接池 } }关键逻辑:DruidDataSource的连接关闭不会销毁物理连接,而是把连接对象恢复到池中。setMaxWait(3000)的数值含义是当连接池无空闲连接且已到达setMaxActive(20)上限时,线程等待获取连接的最长毫秒数,超过后直接抛出SQLTimeoutException。在宿舍管理这种并发量不高的系统里,initialSize设为5、maxActive设为20足够应对。
查询学生列表时必须使用PreparedStatement而不能直接用Statement拼接SQL:
public List<Student> findStudentsByDorm(String dormName) { List<Student> list = new ArrayList<>(); String sql = "SELECT s.*, d.dorm_name FROM student s " + "JOIN dormitory d ON s.dorm_id = d.id " + "WHERE d.dorm_name LIKE ?"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + dormName + "%"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { 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.setDormName(rs.getString("dorm_name")); list.add(stu); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这段代码里ps.setString(1, "%" + dormName + "%")的参数绑定机制是把用户输入当作字符串字面量传给MySQL驱动,驱动会对单引号、反斜杠等特殊字符做转义处理,从根本上堵住' OR '1'='1这类SQL注入。参数说明:try-with-resources语法会在代码块结束后自动关闭Connection、PreparedStatement和ResultSet,这样即使业务代码抛异常也能正确释放数据库连接。
3.3 MySQL的UPDATE与子查询更新语法差异
宿舍管理中最为常见的操作是分配宿舍:把学生入住信息写入student表,同时把宿舍的current_count加1。两条UPDATE语句如果按先后顺序执行,可能在中间状态崩溃时导致数据不一致。这时可以使用MySQL的UPDATE ... JOIN语法实现原子更新:
UPDATE dormitory d JOIN student s ON s.dorm_id = d.id SET d.current_count = d.current_count + 1 WHERE s.student_no = '20250001';它的逻辑是把符合条件的s.student_no对应的宿舍行直接做current_count自增。另一种场景是“退宿时把某宿舍的学生搬走”,更新子查询的常规写法是:
-- 将学号为20250001的学生从原宿舍迁出, 同时原宿舍人数减1 UPDATE dormitory SET current_count = current_count - 1 WHERE id = (SELECT dorm_id FROM student WHERE student_no = '20250001');这里有个MySQL经典报错:不允许在UPDATE语句的WHERE子查询中直接引用同一张被更新的表。如果上述SQL中student和dormitory换成同一张表就会出现You can't specify target table for update in FROM clause错误。解决手段是把子查询二次包装:
UPDATE dormitory SET current_count = current_count - 1 WHERE id = (SELECT dorm_id FROM (SELECT dorm_id FROM student WHERE student_no = '20250001') AS tmp);外层多包的一层SELECT只是让MySQL产生一个临时派生表,从而绕过“不能在同一语句中同时查询和更新同一张表”的限制。在宿舍管理系统中,这种写法常用于学生调换宿舍时的两个宿舍人数变更。
4. 业务代码分层与会话管理:从Servlet到JSP的完整链条
4.1 建立MVC分层结构
一个基于Servlet的JavaWeb项目不需要引入Spring就能实践清晰的分层:entity包放实体类,dao包放JDBC数据库访问,service包放业务逻辑,servlet包放控制器,filter包放编码和权限过滤。宿舍管理系统的典型包结构如下:
com.dorm.entity.Student com.dorm.entity.Dormitory com.dorm.entity.VisitLog com.dorm.dao.StudentDao com.dorm.dao.DormitoryDao com.dorm.service.StudentService com.dorm.service.impl.StudentServiceImpl com.dorm.servlet.StudentServlet com.dorm.servlet.DormitoryServlet com.dorm.filter.EncodingFilter com.dorm.filter.LoginFilter实体类Student把数据库表的字段映射为Java属性,其中宿舍名称是跨表冗余字段,可以额外加一个dormName属性而不需要对应数据库列:
public class Student { private int id; private String studentNo; private String name; private String gender; private String phone; private int dormId; private Date entryDate; private int status; private String dormName; // 非表字段, 用于页面展示 // 省略getter和setter }DAO层每个方法的入参和出参尽量使用实体类或int、String这种简单类型,避免把ResultSet泄露到上层。Service层聚焦业务规则,例如“分配宿舍”必须同时执行学生信息和宿舍人数两个变更,不满足条件时抛出业务异常:
public boolean assignDorm(Student student) { Dormitory dorm = dormitoryDao.findById(student.getDormId()); if (dorm.getCurrentCount() >= dorm.getCapacity()) { throw new BusinessException("宿舍已满员, 当前容量" + dorm.getCapacity()); } boolean flag = studentDao.insert(student); if (flag) { dormitoryDao.increaseCurrentCount(dorm.getId()); } return flag; }上面的BusinessException是自定义运行时异常,抛出后由Servlet统一捕获并跳转到带错误提示的JSP页面。
4.2 学生信息展示页面的Servlet路由设计
在StudentServlet的doGet和doPost里,通过action参数分发具体操作。一个简单的操作分发写法如下:
@WebServlet("/student") public class StudentServlet extends HttpServlet { private StudentService studentService = new StudentServiceImpl(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null) action = "list"; switch (action) { case "list": listStudents(req, resp); break; case "edit": toEditPage(req, resp); break; case "delete": deleteStudent(req, resp); break; default: listStudents(req, resp); } } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String action = req.getParameter("action"); if ("save".equals(action)) { saveStudent(req, resp); } else { doGet(req, resp); } } private void listStudents(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { List<Student> studentList = studentService.findAll(); req.setAttribute("students", studentList); req.getRequestDispatcher("/student_list.jsp").forward(req, resp); } }参数说明:@WebServlet("/student")注解等价于在web.xml中配置的<url-pattern>/student</url-pattern>。doGet和doPost各自处理不同类型的请求,POST专门处理表单提交,避免在浏览器的地址栏直接暴露敏感操作。req.getRequestDispatcher("/student_list.jsp").forward(req, resp)是服务端跳转,浏览器URL不会变化,req作用域里的students属性可以在JSP里直接用${students}访问。切勿使用resp.sendRedirect("student_list.jsp")来跳转,因为重定向是两次请求,第一次请求中setAttribute的数据会丢失。
toEditPage方法除了把学生的现有数据查出回显到编辑页面外,还需要把所有宿舍的列表一并放进请求作用域,供JSP里的<select>下拉框渲染。
4.3 防止JSP页面在失去焦点时弹出离开提示
宿舍管理系统中的编辑页面通常时间较长,用户不小心点击其他标签页时,浏览器的beforeunload提示会干扰体验。要精准屏蔽这种提示,在主页面中绑定事件即可:
// 系统主框架页面, 屏蔽所有子页面的beforeunload提示 window.onbeforeunload = null;如果仍然弹窗,问题可能出在JSP里引入了某些第三方组件(比如富文本编辑器TinyMCE)自带的beforeunload监听。常见的做法是把onbeforeunload事件处理函数显式赋为空,这样即使框架内部设置过拦截也会被覆盖。但注意,如果是通过addEventListener("beforeunload", handler)注册的监听,直接给window.onbeforeunload赋值是覆盖不掉的,需要用removeEventListener解绑:
window.removeEventListener('beforeunload', handler);这行代码需要放在handler函数定义之后调用,并且保证handler不是匿名函数。在JSP中,推荐把这段逻辑放在<body onload="init()">里执行,确保页面资源全部加载完成后再解除提示拦截。
4.4 基于Session的登录状态与权限控制
宿舍管理系统的角色通常分为管理员和宿管员。登录成功后把用户信息写入HttpSession:
User loginUser = userService.login(username, password); if (loginUser != null) { HttpSession session = req.getSession(); session.setAttribute("loginUser", loginUser); session.setMaxInactiveInterval(30 * 60); // 会话有效期30分钟 resp.sendRedirect("index.jsp"); } else { req.setAttribute("errorMsg", "用户名或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); }session.setMaxInactiveInterval(30 * 60)中的数值单位是秒,意味着用户30分钟内没有动作,服务器会自动销毁这个会话。这个参数在Tomcat的web.xml中有默认值30分钟,显式设置的好处是可以在不同模块中差异化控制,比如管理后台超时时间短一些,操作页面超时时间长一些。
再写一个LoginFilter拦截所有需要登录的页面:
@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); String uri = req.getRequestURI(); if (uri.endsWith("login.jsp") || uri.endsWith("/login") || uri.contains("/static/") || (session != null && session.getAttribute("loginUser") != null)) { chain.doFilter(request, response); } else { resp.sendRedirect(req.getContextPath() + "/login.jsp"); } } }注意req.getSession(false)和req.getSession()的区别:前者不会主动创建新会话,在用户未登录时返回null;后者会强制创建一个会话。加了false能避免为每个静态资源都新建无用会话,降低服务器内存负担。uri.contains("/static/")是放行CSS、JS、图片等静态资源的常用手段,防止因未登录导致全站样式丢失。
5. 页面交互增强与国际化乱码处理
5.1 JSP个人信息展示页面的模板复用
宿舍系统的“个人信息展示页面”通常需要展示学号、姓名、宿舍号、联系方式、入住时间等字段。可以抽取一个通用的student_info.jsp模板,通过EL表达式和JSTL组合渲染:
<%@ 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> <div class="info-card"> <h3>学生个人信息</h3> <table> <tr><td>学号</td><td>${student.studentNo}</td></tr> <tr><td>姓名</td><td>${student.name}</td></tr> <tr><td>性别</td><td>${student.gender}</td></tr> <tr><td>宿舍</td><td>${student.dormName}</td></tr> <tr><td>入住日期</td><td><fmt:formatDate value="${student.entryDate}" pattern="yyyy-MM-dd"/></td></tr> </table> </div> </body> </html><fmt:formatDate>标签来自JSTL的格式化库,它接收一个java.util.Date对象并按指定pattern输出字符串。如果没有这个格式化标签,直接输出${student.entryDate}会打印Wed Mar 05 10:30:00 CST 2025这种不友好的默认格式。
5.2 全站编码过滤器与POST提交乱码
Servlet规范中,POST请求的表单数据的字符编码不会自动跟随页面编码。必须在最前面的Filter中强制设置UTF-8:
@WebFilter("/*") public class EncodingFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8"); chain.doFilter(req, resp); } }chain.doFilter(req, resp)必须放在设置编码之后,保证后续所有Servlet和JSP拿到的请求参数已经是解码后的正确字符。如果你在web.xml里配置了多个Filter,@WebFilter("/*")的拦截顺序是不确定的。你可以在web.xml中显式声明Filter的映射顺序,<filter-mapping>出现的先后顺序就是执行顺序。编码Filter必须放在最前面,否则如果权限Filter先执行了sendRedirect,编码设置就不再生效。
GET请求的乱码问题和POST不同,Tomcat 8及以上版本默认对GET请求使用UTF-8解码,而老代码里如果使用了new String(req.getParameter("name").getBytes("ISO-8859-1"), "UTF-8")这类回退写法,在Tomcat 9中反而会得到错误结果。正确的做法是修改Tomcat的conf/server.xml中<Connector>标签增加URIEncoding="UTF-8"属性,而代码层不要做任何二次转码。
5.3 前端调用Servlet接口并解析JSON数据
学生宿舍管理系统中,选择宿舍时通常需要联动显示宿舍的剩余床位。这个功能用传统的表单提交加整页刷新也能实现,但体验较差。更常见的做法是用Ajax请求一个专门输出JSON的Servlet:
@WebServlet("/dorm/beds") public class DormBedsServlet extends HttpServlet { DormitoryDao dormitoryDao = new DormitoryDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { String dormIdStr = req.getParameter("dormId"); int dormId = Integer.parseInt(dormIdStr); Dormitory dorm = dormitoryDao.findById(dormId); int remaining = dorm.getCapacity() - dorm.getCurrentCount(); resp.setContentType("application/json;charset=UTF-8"); PrintWriter out = resp.getWriter(); out.write("{\"dormId\":" + dormId + ",\"remaining\":" + remaining + ",\"success\":true}"); out.flush(); } }前端JSP页面中通过JQuery的$.getJSON读取这个接口的数据:
$(function(){ $("#dormSelect").change(function(){ var dormId = this.value; if(dormId === "") return; $.getJSON("${pageContext.request.contextPath}/dorm/beds", {dormId: dormId}, function(data){ if(data.success){ $("#remainingBeds").text(data.remaining + " 个床位"); }else{ $("#remainingBeds").text("查询失败"); } }); }); });需要提醒的是,Servlet中自己拼接JSON字符串在字段值包含引号时会有转义问题。如果你的宿舍名称里出现了双引号,这段代码输出的JSON会损坏。更安全的方式是用Jackson库的ObjectMapper来序列化实体的Map对象:
ObjectMapper mapper = new ObjectMapper(); mapper.writeValue(resp.getWriter(), resultMap);前端JSP JSONArray import的场景也类似,在JSP中使用Java原生JSONArray时需要在pom.xml引入依赖:
<dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.83</version> </dependency>用来在服务端把取到的List<Student>直接转成JSON字符串返回前端去渲染表格。
6. 部署Troubleshooting三件套:编译路径、404排查与MySQL连接
6.1 修改eclipse和IDEA的JSP编译输出位置
很多初学者困惑于“JSP编译的class文件保存在哪里”。Tomcat 9之前,JSP编译后的class文件位于Tomcat安装目录的work/Catalina/localhost/<contextPath>/org/apache/jsp下。每一次JSP修改后重新访问,Tomcat会对比时间戳来决定是否重新编译。
使用Eclipse开发时,项目部署到Tomcat默认的wtpwebapps目录,JSP编译后的类就放在此目录对应的work文件夹里。如果你在IDEA使用Artifact部署,输出的位置在out/artifacts/<projectName>/文件夹下。排查NoClassDefFoundError: org/springframework/boot/web/servlet/support/SpringBootServletInitializer这类问题时,务必先检查编译产物目录里是否存在WEB-INF/lib中的jar包,以及是否错误地把Spring Boot的依赖打进了lib。常见原因是在pom.xml里把spring-boot-starter-web声明为compile作用域,而正确做法应该是:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <scope>provided</scope> </dependency>6.2 404错误排查顺序表
宿舍管理系统部署后最常见的返回结果是404。排查时按以下顺序逐层定位,配合这个顺序表可以快速缩小范围:
| 检查项 | 验证命令或动作 | 错误案例 |
|---|---|---|
| URL路径是否正确 | 查看浏览器地址栏是否带上项目上下文 | 访问localhost:8080/student而不是localhost:8080/dorm/student |
| Servlet映射是否命中 | 在@WebServlet注解的URL后加一个不存在的方法测试 | <url-pattern>/student/*</url-pattern>和@WebServlet("/student")混用 |
| JSP物理路径是否存在 | 查看webapp目录下的JSP文件名大小写 | 访问student_list.jsp但实际文件名为StudentList.jsp |
| 部署是否成功 | 查看IDEA控制台或Tomcat日志里的Deploying web application archive | Tomcat的webapps下没有对应WAR目录 |
在Eclipse创建基于Maven的Servlet项目时,还需要检查项目的Deployment Assembly中是否把src/main/webapp映射到了/路径。如果没有,启动后访问任何JSP都会404。为应对这个问题,我一般会在项目的web.xml中增加一个转发页作为默认页兜底:
<welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list>这样即使你没有显式输入页面名,访问项目根URL也能跳转到登录页,方便排查到底是映射问题还是文件缺失。
6.3 docker运行MySQL与Navicat连接参数
在本地用免安装版MySQL时,最常见的问题是用Navicat连接报1130 Host not allowed to connect错误。这是因为MySQL默认只允许localhost连接。执行以下SQL开启远程访问:
USE mysql; UPDATE user SET host = '%' WHERE user = 'root'; FLUSH PRIVILEGES;如果你使用Docker安装MySQL,需要同时做好数据目录挂载和端口映射:
docker run -d \ --name dorm-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=123456 \ -e MYSQL_DATABASE=dorm_management \ -v /opt/mysql-data:/var/lib/mysql \ mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_general_ci参数说明:-p 3306:3306把容器内的3306端口暴露到宿主机,第一个3306是宿主机端口,第二个是容器内MySQL端口,两者可以不同;MYSQL_DATABASE让MySQL首次启动时自动创建dorm_management数据库;--character-set-server=utf8mb4和--collation-server=utf8mb4_general_ci覆盖容器默认的字符集设置,确保表和字段默认就是中文友好的utf8mb4编码。
连接时,Navicat的URL字符串中必须加上useSSL=false&serverTimezone=Asia/Shanghai两个参数。useSSL=false是避免MySQL 8.0默认开启SSL而导致连接握手阶段证书校验报错;serverTimezone=Asia/Shanghai是让JDBC驱动知道当前会话使用的时区,不设置时会直接抛The server time zone value 'EDT' is unrecognized异常。
如果你想做定时备份,用Windows批处理脚本调用mysqldump是最省事的方式,脚本中务必把mysqldump.exe的全路径写正确。
6.4 使用mysqlbinlog判断数据异常更新
宿舍系统上线后如果发现某天某个宿舍的人数不符,除了看应用日志,还可以直接查MySQL的binlog。首先确认当前是否开启binlog:
SHOW VARIABLES LIKE 'log_bin';ON代表开启。然后通过mysqlbinlog工具解析最近的日志,按时间范围过滤出针对dormitory表的UPDATE:
mysqlbinlog --start-datetime="2025-03-01 00:00:00" --stop-datetime="2025-03-02 00:00:00" /var/lib/mysql/binlog.000014 | grep -A 20 "UPDATE dormitory"通过对比日志中显示的SET current_count = current_count + 1和实际应用里是否有对应的update操作,可以确认是否有人绕过Service层直接改库。在Docker部署时,mysqlbinlog工具在容器内部,宿主机的/var/lib/mysql是挂载目录,需要先进入容器再执行:
docker exec -it dorm-mysql mysqlbinlog --start-datetime="2025-03-01 00:00:00" /var/lib/mysql/binlog.000014grep出来的日志块会包含事务的原始SQL和执行时刻,这是定位脏数据最直接的手段。你不需要把这个操作养成习惯,但至少要知道宿舍管理系统出现数据问题时的排查链条是从这里开始的。
本文还有配套的精品资源,点击获取