☰
基于JSP+Servlet+MySQL的实验室设备管理系统:从课设到工程实践
2026/10/5 11:29:24 网站建设 项目流程

简介:一套基于jsp+servlet+mysql的实验室设备管理系统完整项目,主要面向JavaWeb初学者、课程设计及毕业设计者,用于解决实验室设备登记、分类、申请与人员管理等日常信息化需求,覆盖设备信息维护、借用申请、分类统计、用户权限控制等典型模块。压缩包共182个文件,包含34个jsp页面、java源码及编译后的class文件,以及jar依赖库、css/js前端样式、jpg图片、sql数据库脚本等类型,整体约6.21MB,目录结构清晰便于部署。目前已有454人学习下载,适合作为综合实训或毕业设计的参考模板。系统采用MVC架构,视图由jsp承担,控制由servlet处理,数据通过JDBC存取mysql;通过阅读源码与数据库脚本,读者可以清晰理解请求转发流程、数据库连接池配置、增删改查实现思路,以及设备管理功能从界面到后端实现的全过程,对提升JavaWeb项目实践能力很有帮助。

1. 实验室设备管理系统为什么值得你花一个周末读完

每年 JavaWeb 课设交作业的高峰期,“基于jsp+servlet+mysql的实验室设备管理系统.zip”这类压缩包就会被反复下载、改个名字又重新提交。别看 JSP 在很多招聘 JD 里已经不出现,但它在课设里依旧是霸榜的技术栈,因为它把从前端页面到数据库的整条请求链路串在了一个完整项目里。这个系统要处理的核心事就三件:设备台账的增删改查、设备的借出与归还、登录用户的权限区分,恰好覆盖了 Servlet 阶段最需要练习的知识点。对正在赶课设的学生、想补 JavaWeb 基础的新手、以及准备技术答辩的开发者来说,把这个 zip 拆开跑通,比看一百段零散 demo 都有用。

2. 拿到zip先别急着解压运行:先把项目结构和请求链路弄清楚

很多人的第一反应是把 zip 解压后直接扔进 Tomcat 的 webapps 目录,结果报错一堆、黑窗口一闪而过。我建议你先花十分钟把压缩包内部的结构看清楚,再决定怎么部署。这类项目的目录结构是标准的 JavaWeb 分层,源码、页面、配置、数据库脚本各司其职,看懂了它,你就看懂了大多数课设项目。

2.1 解压后先看三个目录:src、WebRoot、sql

打开 zip 第一件事不是找 readme,而是确认以下目录是否齐全:

src/ com/yourname/dao/DeviceDao.java com/yourname/servlet/DeviceServlet.java com/yourname/filter/AuthFilter.java WebRoot/ WEB-INF/web.xml login.jsp index.jsp device/list.jsp device/edit.jsp lib/ mysql-connector-java-8.0.30.jar sql/ lab_device.sql

src 是 Java 源码目录,正常情况下里面至少要有 dao、servlet、filter 三组包;WebRoot 是页面和配置目录,web.xml 是所有 Servlet 和过滤器映射的入口;lib 是依赖 jar 包,必须包含 MySQL 驱动,少了它,项目一启动就会在 Class.forName 那行报 ClassNotFoundException。sql 目录存放建库脚本,有些项目把它叫 db 或 doc,名字不重要,关键是里面得有 .sql 文件。

如果你拿到的 zip 里只有 JSP 和 .class 文件、没有源码,那说明是编译好的部署包,改不了业务逻辑,课设最好别用这种。判断项目完整度的方法也很简单:源码里有 DeviceDao.java,页面里有 list.jsp,数据库脚本里有 CREATE TABLE,这三样齐全,就说明这条链路能跑通。lib 里的驱动 jar 版本要和本机 MySQL 匹配,这一点我在第 5 章单独展开。

2.2 从浏览器到数据库:一次“设备列表查询”的四段路

设备列表是这类系统的第一个业务功能。你在浏览器输入http://localhost:8080/lab/device?action=list,Tomcat 会在 web.xml 里找匹配的 Servlet 映射,调用 DeviceServlet 的 doGet 方法。Servlet 里不直接拼 HTML,而是调 DeviceDao 查询 MySQL,把结果放到 request 作用域里,再 forward 给 device/list.jsp,由 JSP 渲染成表格。

web.xml 里对应的映射一般长这样:

<servlet> <servlet-name>DeviceServlet</servlet-name> <servlet-class>com.yourname.servlet.DeviceServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>DeviceServlet</servlet-name> <url-pattern>/device</url-pattern> </servlet-mapping>

url-pattern 写/device,意味着所有?action=list、?action=add之类的请求都会进同一个 Servlet,再由 action 参数分流到对应方法。这是课设里最常见的“单 Servlet 多方法”写法,优点是配置少,缺点是 Servlet 会越写越长,到第 6 章我会说怎么拆。servlet-name 只是标识,真正决定路由的是 url-pattern,改类名的时候记得同步改这里。

这条链路里还有个关键点:Servlet 转发给 JSP 用的是request.getRequestDispatcher("/device/list.jsp").forward(request, response),不是 sendRedirect。前者是服务器内部转发,地址栏不变,request 里的数据能带到页面;后者是浏览器重定向,会丢 request 属性,导致列表页永远拿不到数据。很多新手在这里翻车,页面空白却不知道去哪查。

2.3 导入数据库和启动 Tomcat:顺序错了,部署就白费

第一步不是启动 Tomcat,而是先把数据库准备好。用命令行或图形工具执行 sql 目录里的脚本:

mysql -u root -p < lab_device.sql

然后在项目的数据库配置文件里改连接信息。老项目通常是一个 db.properties 或 DBUtil.java,开头长这样:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/lab_device?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&sslMode=DISABLED jdbc.username=root jdbc.password=123456

driver 写的是 MySQL 8.0 之后的驱动类;如果你本地装的是 5.7,要改回 com.mysql.jdbc.Driver。url 里的 useUnicode 和 characterEncoding 控制中文字符,serverTimezone 解决 8.0 的时区报错,sslMode=DISABLED 避免 MySQL 8.0.33 以后频繁出现的 SSL 握手告警。password 改成你数据库自己的密码,别直接复制。

DBUtil 的核心就是加载完配置后用 DriverManager 拿连接:

public class DBUtil { private static String url = "jdbc:mysql://localhost:3306/lab_device?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&sslMode=DISABLED"; private static String user = "root"; private static String password = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } }

静态块里 Class.forName 的作用是触发驱动类注册,这一步不能省。如果这里抛 ClassNotFoundException,多数情况是 lib 里的 jar 没放进去或者驱动类写错。启动 Tomcat 前,还要确认项目目录放在 webapps 下,访问路径就是http://localhost:8080/项目名/设备列表地址。我的启动习惯是:先用 mysql -u root -p 登录确认能进库,再启动 Tomcat,最后看 catalina.out 日志,每一步出问题都能把范围缩到最小。

3. 数据库设计是这类系统的命门:设备表、用户表、借用表怎么建才不返工

链路走通之后,决定这个系统能不能站稳的是数据库。很多课设评审老师不看前端样式,但一定会打开数据库看表结构。设备管理系统的表数量不多,三到四张就能覆盖全部业务,设计得当,后面 Servlet 代码能少写一半。

3.1 三张核心表:先想清楚状态机再动笔

设备管理系统至少要三张表:用户表、设备表、借用记录表。先想业务闭环:用户登录后查询设备,借出时把设备状态从“可用”改成“已借出”,同时插入一条借用记录;归还时更新记录的归还时间,再把设备状态改回“可用”。这个状态流转就是表结构设计的核心。

字段设计上,我的习惯是:

t_user: id, username, password, real_name, role t_device: id, device_name, model, location, status, create_time t_borrow: id, user_id, device_id, borrow_time, return_time

role 字段区分管理员和普通用户,普通用户只能查,管理员能增删改。status 用 tinyint 而不是 varchar,0 代表可用,1 代表已借出,2 代表维修中,数字的等值查询走索引更快,Java 端只需要一个 switch 就能映射成中文。borrow 表不直接存设备名称,而是存 device_id,这就是常见的“逻辑外键”,查询时通过 JOIN 拿名称,避免设备改名后历史记录跟着变。

为什么不建物理外键约束?我的做法是不建。课设里物理外键能加的分不多,反而因为插入顺序和删除限制频繁报错,业务代码里的逻辑外键更可控。但不用物理外键不等于不校验:借出设备时,代码里必须先判断 status=0 再更新,否则会出现一台设备被两个人同时借走的脏数据,这个防守点在 4.2 节给出具体写法。

3.2 建库脚本:从 CREATE DATABASE 到 INSERT

建库脚本通常长这样:

CREATE DATABASE IF NOT EXISTS lab_device DEFAULT CHARSET utf8mb4; USE lab_device; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), role TINYINT DEFAULT 1 ); CREATE TABLE t_device ( id INT PRIMARY KEY AUTO_INCREMENT, device_name VARCHAR(100) NOT NULL, model VARCHAR(100), location VARCHAR(200), status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_borrow ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, device_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, return_time DATETIME, INDEX idx_device_return (device_id, return_time) ); INSERT INTO t_user (username, password, real_name, role) VALUES ('admin', '123456', '管理员', 0);

脚本执行后要立刻插入一条管理员账号,否则登录页永远进不去。密码字段课设范围可以直接明文,但论文里要写一句“生产环境应当加密存储”。DEFAULT CHARSET 写 utf8mb4 而不是 utf8,因为 utf8mb4 是完整的四字节字符集,生僻字和 emoji 都能存,MySQL 8.0 默认就是它,显式写出来更保险。

我特别解释一下最后一行 INDEX:idx_device_return(device_id, return_time)是复合索引,保证“查某台设备当前有没有未归还记录”走索引。否则随着借用记录增多,每次借出前都要全表扫描。课设数据量小感觉不出来,但面试聊起索引设计时,这个点能证明你不是只会建表。

3.3 为什么不许直接拼 SQL:PreparedStatement 的两个理由

“为什么不用拼接 SQL”是技术答辩必问题。很多同学图省事这样写:

String sql = "SELECT * FROM t_device WHERE device_name = '" + keyword + "'";

keyword 里如果有单引号,这条 SQL 就会断掉;如果有人输入' OR '1'='1,查询条件永远成立,这就是最典型的 SQL 注入。换成 PreparedStatement:

String sql = "SELECT * FROM t_device WHERE device_name LIKE ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, "%" + keyword + "%"); ResultSet rs = ps.executeQuery();

问号是占位符,setString 会把特殊字符转义,数据库端把 SQL 模板和参数分开处理,关键字永远不会被当成 SQL 执行。另一个实际好处是模板只用解析一次,循环执行多条同构 SQL 时性能更好。注意 setString 的下标从 1 开始,很多人第一反应写成 setString(0),会立刻报“Parameter index out of range”,这个异常我见过太多次,不是代码逻辑问题,就是下标没对齐。

4. Servlet 层的机房纪律:登录拦截、设备借还、分页查询这样写能少改三次

数据库就位后,剩下的活都在 Servlet 层。这些代码写的时候多花十分钟,后面改需求能少返工三次。这一章我给三类最常见业务给出可以直接抄的结构。

4.1 登录拦截不用每个 Servlet 都塞一遍,放进 Filter

登录校验是内部页面的准入证,但它不该出现在每个 Servlet 里。最干净的做法是写一个过滤器拦截所有页面地址,统一检查 session:

public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; Object loginUser = request.getSession().getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(request, response); } }

这段代码的逻辑只有四行精髓:从 session 里取登录标记,取不到就重定向到登录页,取到了就交给下一个过滤器或 Servlet 继续走。注意重定向一定要写request.getContextPath()拼前缀,否则部署在带项目名的路径下时会跳错位置。用这个方法,你后续新增十个页面,一行登录校验都不用再写。

只拦 Servlet 还不够,理论上别人可以绕过 /device 直接访问 /device/list.jsp。想堵住这条路,要么把受保护的 JSP 移到 WEB-INF 下面,要么再配一层拦截 *.jsp 的过滤器。我的习惯是把页面挪进 WEB-INF,外部 URL 不能直接访问,只能由 Servlet 转发进去,权限边界才闭合。

登录成功后的主页通常就是个人信息展示页面,顶部显示当前登录用户的姓名和角色,下面接业务模块入口。这个页面是检验 session 是否写入成功的验证点:登录后能看到自己的名字,说明 session 生命周期正常。

4.2 设备借还的状态切换与事务处理

借出和归还是这类系统里唯一涉及“两步写库”的操作。借出设备时,第一步更新设备状态为已借出,第二步往借用记录插一条数据;如果第一步成功第二步失败,设备就变成“既已借出又没有记录”的脏数据。这两条 SQL 必须包进同一个事务:

Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); PreparedStatement ps1 = conn.prepareStatement( "UPDATE t_device SET status = 1 WHERE id = ? AND status = 0"); ps1.setInt(1, deviceId); int affected = ps1.executeUpdate(); if (affected == 0) { throw new RuntimeException("设备当前不在可借状态"); } PreparedStatement ps2 = conn.prepareStatement( "INSERT INTO t_borrow (user_id, device_id) VALUES (?, ?)"); ps2.setInt(1, userId); ps2.setInt(2, deviceId); ps2.executeUpdate(); conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); }

这段有两个容易忽略的地方。第一是 UPDATE 语句里带AND status = 0,这是数据库层面的乐观锁,保证只有当前状态为可用时才会更新成功,executeUpdate 返回 0 就说明设备已经被借走,直接抛异常回滚,避免超卖。第二是 finally 里要把 autoCommit 还原成 true,因为连接是从连接池借出来的,还回去时状态必须是干净的,否则下一个使用者的事务边界会被污染。这个细节答辩时讲出来,面试官会加印象分。

设备状态在列表页展示时,可以用背景图坐标定位的方式切换图标。具体做法是一张精灵图,把可用、已借、维修三个小图标横向排列,在 JSP 里根据 status 输出不同的 background-position。这个方案比三张图片三行 if 判断省请求,也比绝对定位固定像素靠谱,窗口缩放时不会错位。

4.3 条件查询加排序分页:参数怎么拼、回显怎么保存

设备列表一般都要支持按名称模糊查询、按状态筛选、按时间排序。动态拼 SQL 时,最稳的姿势是:先写死WHERE 1=1,然后按条件往后追加,参数统一装进 List:

public List<Device> search(String name, Integer status, int pageNo, int pageSize) { StringBuilder sql = new StringBuilder("SELECT * FROM t_device WHERE 1=1"); List<Object> params = new ArrayList<>(); if (name != null && !name.isEmpty()) { sql.append(" AND device_name LIKE ?"); params.add("%" + name + "%"); } if (status != null) { sql.append(" AND status = ?"); params.add(status); } sql.append(" ORDER BY create_time DESC LIMIT ? OFFSET ?"); params.add(pageSize); params.add((pageNo - 1) * pageSize); PreparedStatement ps = conn.prepareStatement(sql.toString()); for (int i = 0; i < params.size(); i++) { ps.setObject(i + 1, params.get(i)); } // 执行查询并封装结果 }

pageNo 从 1 开始传,OFFSET 必须是(pageNo-1) * pageSize,很多人直接拿页码当 offset,从第 0 条开始查,第一页永远少一条。另一个坑是 ORDER BY 后面的字段不能用占位符拼,排序字段属于结构而非数据,只能做白名单校验:把允许排序的列名放进一个 Set,传进来的找不到就默认 create_time,直接把参数拼进 ORDER BY 等于开了一个注入后门。

JSP 回显的关键点是“提交后输入框别清空”。表单里的搜索框这样写:

<input name="deviceName" value="${param.deviceName}" /> <select name="status"> <option value="">全部</option> <option value="0" ${param.status == '0' ? 'selected' : ''}>可用</option> <option value="1" ${param.status == '1' ? 'selected' : ''}>已借出</option> </select>

${param.deviceName} 会从 URL 参数里取回上次输入的值,不需要在 Servlet 里多存一遍。分页导航的页码超链接也要把查询参数原样带回去,例如list?action=search&deviceName=${param.deviceName}&pageNo=${pageNo+1},否则用户翻页时会丢搜索条件。这里最常犯的错是把当前页写死成 1,用户从第三页点“下一页”又跳回第一页。

5. 避坑手册:这类项目部署时的 5 个高发问题

很多同学拿到的 zip 在自己电脑上跑得挺好,换个环境就趴窝。下面这些问题不是代码逻辑错,而是环境和认知偏差,按我这些年带课设见到的频率排序。

5.1 Tomcat 10 下编译报错:javax.servlet 不存在

现象:项目一编译就报“程序包 javax.servlet 不存在”,或者运行时 404,控制台全是 ClassNotFound。

原因:Tomcat 从 10.0 开始,Servlet API 的包名从 javax.servlet 换成了 jakarta.servlet,老代码基于 javax 写,放到 Tomcat 10 里自然找不到类。这个是版本迁移的历史遗留问题,跟代码本身没关系。

解决:优先换回 Tomcat 9,它是 Servlet 4.0 规范,跟老项目完全兼容。如果你坚持用 Tomcat 10,就把项目里所有import javax.servlet.*改成import jakarta.servlet.*,同时注意 lib 里不要再放 servlet-api 包,这个 jar 由 Tomcat 自己提供,手动放进去会出现 ClassCastException。我一般直接换 Tomcat 9,省事且稳定。

5.2 连不上 MySQL:驱动类、时区和 SSL 三连错

现象:启动后访问页面报 ClassNotFoundException,通常是 com.mysql.jdbc.Driver 找不到;或者报The server time zone value;MySQL 8.0.33 以上还会报 SSL 连接错误。

原因:MySQL 5.7 时代的教程驱动类是 com.mysql.jdbc.Driver,而 MySQL 8.0 的驱动类改成了 com.mysql.cj.jdbc.Driver。很多人装的是 8.0 数据库,代码里写的还是老驱动类;时区报错是 8.0 新增的校验;SSL 告警也是 8.0.33 以后才频繁出现的。如果你按教程装的 mysql 5.7.44,则保持老驱动写法即可,问题通常出在“数据库 8.0 + 老教程代码”这种混搭。

解决:驱动类和数据库版本对齐,URL 里加serverTimezone=Asia/Shanghai,再加sslMode=DISABLED。完整连接串在第 2.3 节,直接抄过去。驱动 jar 的版本也建议选与数据库主版本一致的,别拿 5.1 的 jar 去连 8.0 的库,那会触发各种奇怪的协议错误。

5.3 Tomcat 启动闪退:日志在哪看

现象:双击 startup.bat,黑窗口一闪而过,浏览器访问 8080 就是连不上。

原因:Tomcat 把错误信息写在日志里,不会停在黑窗口上,所以黑窗口一闪而过不是没启动,是启动失败立刻退出了。最常见的两个:8080 端口被占用,日志里有Address already in use;或者 JAVA_HOME 环境变量没指到 JDK 路径。

解决:打开 Tomcat 安装目录下的 logs/catalina.out(Windows 下可能是 catalina.<日期>.log),看末尾异常堆栈。端口被占用则改 conf/server.xml 里 Connector 的 port 为 8081 或 9090;JAVA_HOME 错误则在系统环境变量里重新指定 JDK 路径。记住看日志而不是看黑窗口,这是第一条血泪经验。

5.4 中文乱码:数据库正常但网页是问号

现象:用 Navicat 查表,中文正常;但网页显示乱码或变成???。

原因:三处编码没对齐。JSP 页面本身的 pageEncoding 没设 UTF-8,数据库连接 URL 里没带 characterEncoding=utf8,建表时 DEFAULT CHARSET 用了 latin1 而不是 utf8mb4。任何一个环节断掉,中文都会在链路某个地方变乱码。

解决:把三处统一为 UTF-8。JSP 头部写<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>;连接 URL 加 characterEncoding=utf8;SQL 脚本把 DEFAULT CHARSET utf8mb4 写清楚;如果库已经建了,执行ALTER TABLE t_device CONVERT TO CHARACTER SET utf8mb4。还有一个细节是 Servlet 里 request.setCharacterEncoding("UTF-8") 必须在第一次读取参数前调用,否则 POST 表单的中文在入口就坏了。

5.5 改了 JSP 代码,浏览器刷新还是旧页面

现象:明明改了 JSP 源码,Ctrl+F5 也按了,页面还是旧内容;改 Servlet 也不生效。

原因:有两层。一是浏览器缓存了静态资源;二是 Tomcat 没有热部署,你改的是源码目录,但 webapps 下跑的还是编译后的副本,两边不是同一份文件。

解决:先强制刷新 Ctrl+F5;如果仍无效,在 Tomcat 的 manager 页面触发 reload,或者直接重启 Tomcat;确认你改的是真正运行的那份文件,而不是项目副本。JSP 页面加载后想自动刷新一次的话,可以在 head 里放<meta http-equiv="refresh" content="1">,但这个只用来调试,别留在正式代码里,否则用户每 1 秒刷新一次,页面性能承受不住。

6. 从课设到工程:三个改造方向和一条验证主线

如果把这个 zip 交上去就完事,它只有分数价值。我更推荐把它当成半成品继续改造,这里给三个成本最低、收益最明显的方向。

第一个改造是把 DBUtil 换成连接池。用 druid 或 HikariCP 都行,配置大同小异,以 druid 为例,在项目里加一个 druid.properties:

initialSize=2 maxActive=10 jdbc.url=jdbc:mysql://localhost:3306/lab_device?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&sslMode=DISABLED jdbc.username=root jdbc.password=123456

原来每次请求都 DriverManager.getConnection 一次,换成连接池后,连接是池里借来的,用完 close 只是归还,并发场景下性能天差地别。第二个改造是把 Servlet 里越写越长的业务代码拆出 Service 层:DeviceServlet 只负责接参数和跳页面,借还判断、状态流转移到 DeviceService,DAO 只做 SQL。第三个改造是把 JSP 里散落的<% %>脚本片段换成 EL 和 JSTL,页面清爽,维护成本也低。

改完之后,用一条主线验证系统是否完整:管理员登录 → 新增一台设备 → 在列表页按名称搜索到它 → 借出 → 确认状态变为已借出 → 归还 → 确认状态回到可用 → 退出登录后直接访问设备列表 URL,确认被拦截回登录页。这条路径覆盖了登录、增删改查、状态流转、访问控制,能走通,这个项目就能交差。我当年改这类项目时最惨的一次,是把 Tomcat 8 的项目直接扔进 Tomcat 10,全是 jakarta 报错,查了半天才意识到不是代码问题。所以环境问题先看版本,业务问题再看日志,希望帮到你。

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

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

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

立即咨询