简介:这是一套面向Java Web初学者的Servlet+JSP+MySQL宿舍管理系统实战项目,适合刚接触Web开发、需要完整练手案例的学生与自学者。项目围绕宿舍信息管理展开,涵盖用户注册登录、宿舍数据增删改查等核心业务,帮助理解Servlet处理请求、JSP渲染视图、JDBC连接数据库的完整链路。压缩包共63个文件,约10.01MB,包含18个java源码与18个class编译文件、5个jsp页面、3个jar依赖包,以及sql建表脚本、配置说明文档和演示视频,源码、数据库与讲解材料配套齐全。已有1160人学习下载。通过该项目可掌握MVC分层组织代码、session保持登录状态、SQL语句操作数据等实用技能,并参考配置文档与录屏快速跑通环境,是入门Java Web开发、积累课程设计或毕业设计素材的合适选择。
1. 从零手写 Servlet + JSP + MySQL 宿舍管理系统:为什么原生 JSP 依然值得练
很多同学一上来就问:都 2025 年了,Spring Boot 一把梭不香吗,为什么还要用 Servlet + JSP 写宿舍管理系统?我自己的血泪经验是——正因为这套东西“土”,它才把 HTTP 请求怎么进来、Session 怎么存、JDBC 怎么连、JSP 怎么被编译成 Servlet 这些黑匣子全摊在你面前。你写一遍原生 JSP,再去看 Spring MVC 的 DispatcherServlet,会有一种“原来你只是帮我包了一层”的顿悟。这篇笔记就围绕“java基于servlet+jsp+mysql简单宿舍管理系统jsp原生界面开发”这个标题,把环境搭建、表结构设计、登录鉴权、宿舍与学生的增删改查、JSP 页面复用、分页与排错一条线讲透。适合正在做课程设计、毕设选题,或者想补 JavaWeb 基础的人,跟着敲能跑起来,也能看清边界在哪。
2. 环境与工程骨架:把 Servlet 跑起来的最小闭环
2.1 版本选型与依赖清单
原生 JSP 项目最怕版本打架。我一般锁定一套“老而稳”的组合:JDK 8 或 11、Tomcat 8.5/9.0、MySQL 5.7/8.0、Servlet API 3.1+、JSTL 1.2。JDK 别上 17 配老 Tomcat,容易在模块化上翻车。MySQL 8.0 记得驱动用com.mysql.cj.jdbc.Driver,连接串要带时区和 SSL 参数,否则控制台会甩你一脸时区异常。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 8 / 11 | 与 Tomcat 8.5/9 兼容性最好 |
| Tomcat | 8.5.x / 9.0.x | 支持 Servlet 3.1/4.0 |
| MySQL | 5.7 / 8.0 | 8.0 注意驱动类名与连接参数 |
| Servlet API | 3.1+ | 用注解@WebServlet免去 web.xml |
| JSTL | 1.2 | JSP 页面循环与判断用 |
目录结构按 Maven 标准来,别用 Eclipse 默认那种WebContent老结构,后面换 IDE 会后悔:
dorm-system/ ├── pom.xml └── src/main/ ├── java/com/dorm/ │ ├── dao/ // 数据访问 │ ├── entity/ // 实体类 │ ├── servlet/ // 控制器 │ └── util/ // DBUtil 等工具 └── webapp/ ├── WEB-INF/web.xml ├── css/ js/ └── *.jsp2.2 用 Maven 拉齐依赖
pom.xml里把 Servlet、JSP、JSTL、MySQL 驱动一次配好。注意 Servlet 和 JSP 的 scope 用provided,因为 Tomcat 自带,打进 war 反而冲突。
<dependencies> <!-- Servlet API:Tomcat 已提供,scope 必须 provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> <!-- JSP API 同理 --> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.1</version> <scope>provided</scope> </dependency> <!-- JSTL:页面标签库,必须打进 war --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 8 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> </dependencies>参数说明:provided表示编译期可见、运行期由容器提供;JSTL 若也写provided,页面会报NoClassDefFoundError。MySQL 驱动版本和你的 MySQL 服务端大版本对齐即可,8.0.28 对 8.0.x 服务端足够。
2.3 数据库连接工具类 DBUtil
JDBC 连接不要在每个 Servlet 里new,抽一个工具类,用静态块加载驱动,提供获取和关闭连接的方法。生产环境该上连接池,课程设计阶段用 DriverManager 足够,但要知道它的边界:每次请求都新建物理连接,并发一高就顶不住。
public class DBUtil { // MySQL 8 连接串:必须带时区与 SSL 参数,否则报时区异常 private static final String URL = "jdbc:mysql://localhost:3306/dorm_db?useUnicode=true&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PWD = "你的密码"; static { try { // MySQL 8 用 cj 驱动,5.x 用 com.mysql.jdbc.Driver Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("驱动加载失败", e); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } // 统一关闭,顺序:ResultSet -> Statement -> Connection public static void close(Connection c, Statement s, ResultSet r) { try { if (r != null) r.close(); } catch (SQLException ignored) {} try { if (s != null) s.close(); } catch (SQLException ignored) {} try { if (c != null) c.close(); } catch (SQLException ignored) {} } }逻辑说明:静态块只执行一次,保证驱动注册;getConn每次返回新连接;close按依赖倒序关闭,避免游标泄漏。参数上serverTimezone不写会在 MySQL 8 上抛The server time zone value ... is unrecognized,这是新手最常见的翻车点之一。
3. 表结构设计与登录鉴权:Session 到底存了什么
3.1 四张核心表怎么建
宿舍管理系统的数据关系很清晰:一栋楼有多个宿舍,一个宿舍住多个学生,一个管理员管全部。我一般建四张表:admin(管理员)、dorm(宿舍)、student(学生)、building(楼栋,可选)。学生表用dorm_id外键关联宿舍,删除宿舍前要先处理学生,否则外键约束会拦你。
CREATE DATABASE dorm_db DEFAULT CHARSET utf8mb4; USE dorm_db; CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL -- 存 MD5 或加盐哈希,别存明文 ); CREATE TABLE dorm ( id INT PRIMARY KEY AUTO_INCREMENT, room_no VARCHAR(16) NOT NULL UNIQUE, -- 如 3-201 capacity INT DEFAULT 4, -- 默认容量,mysql 设置默认值为 0 同理 building VARCHAR(32), used INT DEFAULT 0 -- 已住人数,冗余字段便于列表展示 ); CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE, -- 学号 name VARCHAR(32) NOT NULL, gender CHAR(1) DEFAULT '男', phone VARCHAR(20), dorm_id INT, CONSTRAINT fk_dorm FOREIGN KEY (dorm_id) REFERENCES dorm(id) );参数说明:utf8mb4支持 emoji 和生僻字;capacity和used用DEFAULT给初值,避免插入时漏字段导致 NULL;外键fk_dorm保证学生不会挂到不存在的宿舍。注意used是冗余字段,入住/退宿时要同步更新,否则列表显示和实际人数会对不上,这是典型的“数据不一致”坑。
3.2 登录 Servlet 与 Session 鉴权
登录流程:表单 POST 到LoginServlet,查库比对密码,成功则把用户对象塞进HttpSession,重定向到列表页;失败回登录页带错误提示。关键点是——每个需要登录的页面都要校验 Session,否则别人直接敲 URL 就能进。
@WebServlet("/login") public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String u = req.getParameter("username"); String p = req.getParameter("password"); AdminDao dao = new AdminDao(); Admin admin = dao.findByUsername(u); // 密码比对:实际项目应比对哈希值 if (admin != null && admin.getPassword().equals(MD5Util.md5(p))) { HttpSession session = req.getSession(); session.setAttribute("admin", admin); // 登录凭证 session.setMaxInactiveInterval(30 * 60); // 30 分钟过期 resp.sendRedirect(req.getContextPath() + "/dorm/list"); } else { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }逻辑说明:getSession()无参调用会在没有会话时新建;setAttribute把管理员对象存进服务端内存,浏览器只拿到JSESSIONID。参数setMaxInactiveInterval单位是秒,不设则用 Tomcat 默认 30 分钟。注意sendRedirect和forward的区别:前者让浏览器重新发请求,地址栏变化,适合登录成功;后者服务器内部转发,地址栏不变,适合带错误信息回显。
3.3 一个登录过滤器顶住所有页面
与其在每个 Servlet 里写校验,不如用一个Filter统一拦截。放行登录页、登录接口和静态资源,其余一律检查 Session。
@WebFilter("/*") public class AuthFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest r = (HttpServletRequest) req; HttpServletResponse p = (HttpServletResponse) resp; String uri = r.getRequestURI(); // 放行登录相关与静态资源 if (uri.endsWith("login.jsp") || uri.endsWith("/login") || uri.contains("/css/") || uri.contains("/js/")) { chain.doFilter(req, resp); return; } if (r.getSession().getAttribute("admin") == null) { p.sendRedirect(r.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); } }逻辑说明:@WebFilter("/*")拦截全部请求;白名单用endsWith/contains判断,简单直接。参数上要注意getRequestURI带上下文路径,判断时别写死。这个过滤器是整套系统的安全底线,漏了它,后面所有页面都是裸奔。
4. 宿舍与学生的增删改查:Servlet 到 JSP 的完整链路
4.1 DAO 层:把 SQL 关进一个类里
所有 SQL 集中在 DAO,Servlet 只负责取参、调 DAO、跳页面。这样改表结构时只动一处。以宿舍列表和新增为例:
public class DormDao { // 查询全部宿舍,供列表页展示 public List<Dorm> findAll() { List<Dorm> list = new ArrayList<>(); String sql = "SELECT id, room_no, capacity, building, used FROM dorm ORDER BY room_no"; try (Connection c = DBUtil.getConn(); PreparedStatement ps = c.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Dorm d = new Dorm(); d.setId(rs.getInt("id")); d.setRoomNo(rs.getString("room_no")); d.setCapacity(rs.getInt("capacity")); d.setBuilding(rs.getString("building")); d.setUsed(rs.getInt("used")); list.add(d); } } catch (SQLException e) { throw new RuntimeException("查询宿舍失败", e); } return list; } // 新增宿舍 public int insert(Dorm d) { String sql = "INSERT INTO dorm(room_no, capacity, building, used) VALUES(?,?,?,0)"; try (Connection c = DBUtil.getConn(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, d.getRoomNo()); ps.setInt(2, d.getCapacity()); ps.setString(3, d.getBuilding()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException("新增宿舍失败", e); } } }逻辑说明:用PreparedStatement占位符防 SQL 注入,别用字符串拼接;try-with-resources 自动关连接,比手动 close 干净。参数上ORDER BY room_no让列表稳定排序,used插入时固定为 0。注意executeUpdate返回受影响行数,可用于判断是否成功。
4.2 Servlet 控制器:一个类管一个模块
宿舍模块的 Servlet 用action参数区分操作,避免建一堆类。列表、跳新增页、保存、删除都走同一个入口。
@WebServlet("/dorm/*") public class DormServlet extends HttpServlet { private final DormDao dao = new DormDao(); protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String path = req.getPathInfo(); // 如 /list、/delete if ("/list".equals(path)) { req.setAttribute("dorms", dao.findAll()); req.getRequestDispatcher("/dorm_list.jsp").forward(req, resp); } else if ("/delete".equals(path)) { int id = Integer.parseInt(req.getParameter("id")); dao.delete(id); resp.sendRedirect(req.getContextPath() + "/dorm/list"); } else if ("/add".equals(path)) { req.getRequestDispatcher("/dorm_add.jsp").forward(req, resp); } } protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 表单提交新增 Dorm d = new Dorm(); d.setRoomNo(req.getParameter("roomNo")); d.setCapacity(Integer.parseInt(req.getParameter("capacity"))); d.setBuilding(req.getParameter("building")); dao.insert(d); resp.sendRedirect(req.getContextPath() + "/dorm/list"); } }逻辑说明:@WebServlet("/dorm/*")用通配符把子路径都收进来,getPathInfo()拿到/list这类后缀做分发。参数上Integer.parseInt要防非数字输入,实际项目应加 try-catch 或前端校验。删除后必须重定向,否则刷新页面会重复提交。
4.3 JSP 页面:用 JSTL 循环渲染,别写 Java 脚本
原生 JSP 最容易犯的错是在页面里写<% %>脚本。用 JSTL 的c:forEach和c:if,页面干净,也符合 Model2 思想。列表页核心片段:
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table border="1"> <tr><th>宿舍号</th><th>楼栋</th><th>容量</th><th>已住</th><th>操作</th></tr> <c:forEach items="${dorms}" var="d"> <tr> <td>${d.roomNo}</td> <td>${d.building}</td> <td>${d.capacity}</td> <td>${d.used}</td> <td> <a href="${pageContext.request.contextPath}/dorm/delete?id=${d.id}" onclick="return confirm('确认删除?')">删除</a> </td> </tr> </c:forEach> </table>逻辑说明:c:forEach遍历 Servlet 放进 request 的dorms;${pageContext.request.contextPath}拼上下文路径,避免部署路径变化导致链接失效。参数上onclick加二次确认,防止误删。注意 JSTL 标签库的uri必须和 jar 版本匹配,1.2 就是上面这个地址。
4.4 学生入住与退宿:同步更新冗余字段
学生入住时,除了更新student.dorm_id,还要把对应宿舍的used加一;退宿则减一。这两步要么放一个事务里,要么接受短暂不一致。课程设计阶段用事务更稳妥。
public void checkIn(int studentId, int dormId) { String sql1 = "UPDATE student SET dorm_id=? WHERE id=?"; String sql2 = "UPDATE dorm SET used = used + 1 WHERE id=? AND used < capacity"; Connection c = null; try { c = DBUtil.getConn(); c.setAutoCommit(false); // 开启事务 try (PreparedStatement ps1 = c.prepareStatement(sql1); PreparedStatement ps2 = c.prepareStatement(sql2)) { ps1.setInt(1, dormId); ps1.setInt(2, studentId); ps1.executeUpdate(); ps2.setInt(1, dormId); int rows = ps2.executeUpdate(); if (rows == 0) { // 宿舍已满,回滚 c.rollback(); throw new RuntimeException("宿舍已满"); } c.commit(); } } catch (SQLException e) { try { if (c != null) c.rollback(); } catch (SQLException ignored) {} throw new RuntimeException("入住失败", e); } finally { try { if (c != null) c.close(); } catch (SQLException ignored) {} } }逻辑说明:setAutoCommit(false)开启事务,两步都成功才commit;used < capacity条件保证不会超员,返回 0 行说明满了就回滚。参数上注意used + 1是数据库端自增,避免并发下读改写丢失更新。这是整套系统里最值得写事务的地方。
5. 避坑与排查:那些让我熬夜的原生 JSP 问题
5.1 中文乱码:POST 和 GET 要分开治
现象:表单提交后数据库里是问号,或页面显示乱码。原因:Tomcat 默认按 ISO-8859-1 解码请求体,JSP 页面没声明编码。解决:POST 在读取参数前设req.setCharacterEncoding("UTF-8");GET 在 Tomcat 8 以后默认 UTF-8,老版本要在server.xml的 Connector 加URIEncoding="UTF-8";JSP 顶部写<%@ page contentType="text/html;charset=UTF-8" %>;数据库和表用utf8mb4。四处都对齐才彻底。
5.2 404:路径对不上是常态
现象:访问/dorm/list报 404。原因:@WebServlet的路径和浏览器请求不一致,或没配web.xml的metadata-complete。解决:确认注解路径、上下文路径、web.xml版本是 3.1+(否则注解不生效)。用req.getContextPath()拼链接,别写死/项目名。
5.3 500 空指针:DAO 返回 null 没判
现象:登录成功但列表页 500,日志指向NullPointerException。原因:findByUsername查不到返回 null,调用方直接.getPassword()。解决:DAO 查不到返回 null 是约定,调用方必须先判空;列表查询返回空集合而不是 null,页面c:forEach才不会炸。
5.4 连接泄漏:忘了关 ResultSet
现象:跑一会儿就报Too many connections。原因:异常路径下连接没关,或只关了 Connection 没关 ResultSet。解决:统一用 try-with-resources,或走 DBUtil.close 按序关闭。压测时用SHOW PROCESSLIST看连接数,能直观发现泄漏。
5.5 JSTL 报错:jar 没打进 war
现象:页面报The absolute uri ... cannot be resolved。原因:JSTL 依赖 scope 写成provided,或版本不对。解决:改成默认 scope 打进 war,确认jstl-1.2.jar在WEB-INF/lib下。这是新手配 JSP 页面时的高频翻车点。
6. 分页、复用与验证:让原生 JSP 项目更像样
6.1 手写分页:两个参数搞定
列表数据一多就得翻页。核心就两个参数:当前页page和每页条数size,SQL 用LIMIT偏移。先查总数算总页数,再查当前页数据。
public List<Dorm> findByPage(int page, int size) { int offset = (page - 1) * size; // 偏移量从 0 开始 String sql = "SELECT * FROM dorm ORDER BY room_no LIMIT ?, ?"; List<Dorm> list = new ArrayList<>(); try (Connection c = DBUtil.getConn(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setInt(1, offset); ps.setInt(2, size); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Dorm d = new Dorm(); d.setId(rs.getInt("id")); d.setRoomNo(rs.getString("room_no")); list.add(d); } } } catch (SQLException e) { throw new RuntimeException("分页查询失败", e); } return list; }参数说明:page从 1 开始,offset = (page-1)*size;LIMIT ?, ?第一个是偏移,第二个是条数。注意 MySQL 的LIMIT偏移大时性能会下降,数据量大要改用游标或索引优化。总页数用SELECT COUNT(*)单独查,别在分页 SQL 里算。
6.2 页面复用:include 抽公共头尾
每个 JSP 都写一遍导航和页脚是灾难。用<%@ include file="header.jsp" %>静态包含,或<jsp:include page="header.jsp"/>动态包含。静态包含在编译期合并,适合不变的头部;动态包含运行期调用,适合内容会变的场景。头部里放导航和${sessionScope.admin.username}显示当前用户,一处改处处生效。
6.3 怎么验证这套系统真的能用
别只看页面能打开。我一般按这几步验证:一是用SHOW PROCESSLIST看有没有连接堆积;二是故意提交超员入住,确认事务回滚且used没变;三是直接敲未登录的列表 URL,确认被过滤器拦回登录页;四是把某条 SQL 写错,看异常是否被包装成可读信息而不是堆栈直出。这几步走完,系统的健壮性心里就有数了。
6.4 一个我常留的习惯
我写这类原生 JSP 项目,最后一定会留一个README记三件事:数据库初始化脚本、Tomcat 版本、连接串参数。因为过两周再打开,最先忘的就是这些。原生 JSP 不是终点,但它是你理解 JavaWeb 全链路最扎实的一站,把 Session、Filter、JDBC 事务、JSTL 这几块吃透,再上框架就是降维。希望帮到你。
本文还有配套的精品资源,点击获取