简介:这是一套面向高校计算机专业学生与Java Web初学者的图书管理系统完整项目,可直接用于毕业设计、课程大作业或自学练手。系统基于JSP、Servlet、Layui与MySQL开发,运行于IDEA、JDK1.8、MySQL5.7及Tomcat9环境,界面美观且带公告功能。功能按角色划分:用户可查看公告、浏览图书与个人违章;图书管理员负责借还图书及借阅、还书报表;系统管理员则管理书籍、借阅规则,并进行图书信息查询与借阅统计。资源包共1046个文件,约40.91MB,包含212个Java源文件、156个JSP页面、120个JS脚本、57个CSS样式及3个SQL脚本,另附源码、数据库与配套文档,结构完整便于二次开发。目前已有93人学习下载,适合需要完整赛题方案、模块化代码参考与数据库设计思路的读者,可快速理解三层架构与权限控制逻辑。
1. 图书管理系统为什么还在用 JSP Servlet:一次讲清它的真实定位
如果你正在翻毕业设计选题,看到「java web图书管理系统,jsp servlet界面美观,带公告」这类描述,第一反应大概是:这技术栈是不是太老了?Spring Boot 都出到 3.x 了,谁还手写 Servlet?但现实是,每年计算机毕业设计里,JSP + Servlet 的图书管理系统依然是高频选题,原因很直接——它能把 Java Web 的核心链路完整暴露给你:请求怎么进来、Session 怎么管、JDBC 怎么连、页面怎么渲染,全是你自己写的,没有框架帮你兜底。答辩老师问「你这个登录是怎么实现的」,你能从doPost一路讲到数据库查询,而不是一句「Spring Security 配的」。
这篇笔记面向两类人:一是要交毕业设计或大作业、需要一套能跑通、能讲清、界面不丑的图书管理系统的同学;二是想回头补 Java Web 基础、理解 Model2 思想到底怎么回事的开发者。我会按「环境怎么搭 → 数据库怎么设计 → 核心功能怎么写 → 界面怎么做得不像十年前 → 公告模块怎么加 → 怎么排查翻车」的顺序,把一套可复现的方案讲透。不吹架构,不堆术语,只讲你照着敲能跑起来的东西。
2. 环境搭建与项目骨架:从零到第一个能跑的 Servlet
2.1 JDK、Tomcat、IDE 的版本选择与目录结构
这套技术栈的版本兼容性比你想的敏感。我一般用 JDK 8 或 JDK 11 配 Tomcat 8.5 或 9.0,这是最稳的组合。JDK 17 配 Tomcat 10 也能跑,但 Tomcat 10 把javax.servlet换成了jakarta.servlet,网上大部分 JSP Servlet 教程还是javax包名,你照着抄会一片红。毕业设计求稳,别追新。
IDE 用 IntelliJ IDEA 社区版够用,Eclipse 也行。项目结构用标准 Web 项目布局:
BookManage/ ├── src/ │ └── com/book/ │ ├── dao/ # 数据库操作 │ ├── entity/ # 实体类 │ ├── servlet/ # 控制器 │ └── util/ # 工具类 ├── web/ │ ├── css/ │ ├── js/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── lib/ # 放 mysql-connector-java.jar │ └── index.jspWEB-INF下的内容浏览器访问不到,这是 Servlet 规范的安全设计,JSP 放这里只能通过 Servlet 转发访问。lib目录放 MySQL 驱动 jar 包,别忘了 Add as Library。
2.2 用 Maven 管理依赖还是手动导 jar
手动导 jar 不是不行,但毕业设计代码要交给老师看,Maven 的pom.xml一眼就能看出你用了什么。核心依赖就三个:
<dependencies> <!-- Servlet API,scope 必须是 provided,因为 Tomcat 自带 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSP API,同样 provided --> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <!-- MySQL 驱动,这个要打包进去 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> </dependencies>scope写provided是关键,否则 Tomcat 启动时类冲突,报ClassCastException你都找不到原因。这是血泪经验,我见过太多人卡在这里。
2.3 第一个 Servlet:用注解还是 web.xml 配置
Servlet 3.0 以后可以用@WebServlet注解,不用再写web.xml。两种方式我都写过,注解更简洁,但web.xml能让你看清 URL 映射的全貌。毕业设计建议用注解,代码少,答辩时也好讲。
package com.book.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; // 映射路径,浏览器访问 /login 就会进这个 Servlet @WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // GET 请求直接转发到登录页 req.getRequestDispatcher("/login.jsp").forward(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置编码,必须在获取参数之前 req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); // 这里先写死,后面接数据库 if ("admin".equals(username) && "123456".equals(password)) { HttpSession session = req.getSession(); session.setAttribute("user", username); resp.sendRedirect(req.getContextPath() + "/book/list"); } else { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }doGet和doPost分开写是规范做法。setCharacterEncoding必须在getParameter之前调用,否则中文乱码。sendRedirect和forward的区别要搞清楚:前者是浏览器重新发请求,地址栏变;后者是服务器内部转发,地址栏不变。登录成功用重定向,防止刷新重复提交。
3. 数据库设计与 JDBC 封装:图书、用户、公告三张表怎么建
3.1 表结构设计与字段类型选择
图书管理系统的核心就三张表:用户表、图书表、公告表。别一上来就搞十几张表,毕业设计够用就行,逻辑清晰比表多重要。
CREATE DATABASE book_db DEFAULT CHARACTER SET utf8mb4; USE book_db; -- 用户表 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT DEFAULT 0 COMMENT '0普通用户 1管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 图书表 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, author VARCHAR(50), price DECIMAL(10,2), stock INT DEFAULT 0, category VARCHAR(50), cover VARCHAR(200) COMMENT '封面图片路径', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 公告表 CREATE TABLE notice ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, content TEXT, publisher VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP );字符集用utf8mb4而不是utf8,因为utf8在 MySQL 里存不了 emoji,公告里如果有人发了表情符号就翻车。price用DECIMAL不用FLOAT,金额计算不能有精度损失。cover存图片相对路径,不存二进制,图片文件放服务器目录。
3.2 JDBC 工具类:一个能复用的连接管理方案
每个 Servlet 都写一遍DriverManager.getConnection是灾难。抽一个工具类出来:
package com.book.util; import java.sql.*; public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/book_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 关闭资源,顺序不能错:ResultSet -> PreparedStatement -> Connection 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(); } } }URL 里的serverTimezone=Asia/Shanghai不加会报时区错误,MySQL 8 的驱动必须指定。useSSL=false是关掉 SSL 警告,本地开发无所谓。关闭顺序必须是 ResultSet → PreparedStatement → Connection,反过来会报错。
3.3 用 PreparedStatement 写增删改查:以图书查询为例
package com.book.dao; import com.book.entity.Book; import com.book.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class BookDao { // 查询所有图书,支持按书名模糊搜索 public List<Book> findAll(String keyword) { List<Book> list = new ArrayList<>(); String sql = "SELECT * FROM book WHERE name LIKE ? ORDER BY id DESC"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + (keyword == null ? "" : keyword) + "%"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book b = new Book(); b.setId(rs.getInt("id")); b.setName(rs.getString("name")); b.setAuthor(rs.getString("author")); b.setPrice(rs.getBigDecimal("price")); b.setStock(rs.getInt("stock")); b.setCategory(rs.getString("category")); b.setCover(rs.getString("cover")); list.add(b); } } } catch (SQLException e) { e.printStackTrace(); } return list; } // 新增图书 public boolean insert(Book b) { String sql = "INSERT INTO book(name, author, price, stock, category, cover) VALUES(?,?,?,?,?,?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, b.getName()); ps.setString(2, b.getAuthor()); ps.setBigDecimal(3, b.getPrice()); ps.setInt(4, b.getStock()); ps.setString(5, b.getCategory()); ps.setString(6, b.getCover()); return ps.executeUpdate() > 0; } catch (SQLException e) { e.printStackTrace(); return false; } } }用 try-with-resources 自动关资源,比手动 close 干净。PreparedStatement的?占位符是防 SQL 注入的核心,千万别用字符串拼接。setBigDecimal对应DECIMAL类型,别用setDouble。
4. 界面美观的实现路径:JSP 页面怎么摆脱「十年前」的观感
4.1 用 Bootstrap 5 快速搭出干净的列表页和表单页
JSP 本身只是模板技术,界面丑不丑取决于你用什么 CSS 框架。Bootstrap 5 是最省事的选择,CDN 引入,不用构建工具。图书列表页:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>图书列表</title> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> </head> <body> <div class="container mt-4"> <div class="d-flex justify-content-between align-items-center mb-3"> <h3>图书管理</h3> <form class="d-flex" action="${pageContext.request.contextPath}/book/list" method="get"> <input class="form-control me-2" type="search" name="keyword" placeholder="搜索书名"> <button class="btn btn-primary" type="submit">搜索</button> </form> </div> <table class="table table-hover align-middle"> <thead class="table-light"> <tr> <th>封面</th><th>书名</th><th>作者</th><th>价格</th><th>库存</th><th>操作</th> </tr> </thead> <tbody> <c:forEach items="${bookList}" var="b"> <tr> <td><img src="${pageContext.request.contextPath}/${b.cover}" width="50" height="65" style="object-fit:cover"></td> <td>${b.name}</td> <td>${b.author}</td> <td>¥${b.price}</td> <td> <c:choose> <c:when test="${b.stock > 0}"> <span class="badge bg-success">${b.stock}</span> </c:when> <c:otherwise> <span class="badge bg-danger">缺货</span> </c:otherwise> </c:choose> </td> <td> <a href="${pageContext.request.contextPath}/book/edit?id=${b.id}" class="btn btn-sm btn-outline-primary">编辑</a> <a href="${pageContext.request.contextPath}/book/delete?id=${b.id}" class="btn btn-sm btn-outline-danger" onclick="return confirm('确定删除?')">删除</a> </td> </tr> </c:forEach> </tbody> </table> </div> </body> </html>JSTL 的c:forEach和c:choose是 JSP 里做逻辑判断的标准方式,比在页面里写 Java 代码块<% %>干净得多。${pageContext.request.contextPath}拿项目根路径,避免部署路径变化导致 404。封面图用object-fit:cover保证不变形。
4.2 登录页和公告栏的视觉细节
登录页是老师第一眼看到的页面,值得多花十分钟。用 Bootstrap 的卡片组件居中布局,加个渐变背景:
<div class="d-flex justify-content-center align-items-center vh-100" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);"> <div class="card shadow-lg" style="width: 380px;"> <div class="card-body p-4"> <h4 class="text-center mb-4">图书管理系统</h4> <c:if test="${not empty msg}"> <div class="alert alert-danger py-2">${msg}</div> </c:if> <form action="${pageContext.request.contextPath}/login" method="post"> <div class="mb-3"> <label class="form-label">用户名</label> <input type="text" name="username" class="form-control" required> </div> <div class="mb-3"> <label class="form-label">密码</label> <input type="password" name="password" class="form-control" required> </div> <button type="submit" class="btn btn-primary w-100">登 录</button> </form> </div> </div> </div>公告栏放在首页顶部,用 Bootstrap 的 alert 或者 card 都行。关键是公告内容要支持换行显示,用white-space: pre-wrap样式,否则数据库里存的换行符在页面上会被吞掉。
4.3 分页查询:别让列表页一次性加载几千条
图书多了以后列表页会卡,分页是必须的。核心 SQL 用LIMIT和OFFSET:
public List<Book> findByPage(int page, int size, String keyword) { List<Book> list = new ArrayList<>(); String sql = "SELECT * FROM book WHERE name LIKE ? ORDER BY id DESC LIMIT ? OFFSET ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + (keyword == null ? "" : keyword) + "%"); ps.setInt(2, size); ps.setInt(3, (page - 1) * size); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { // 映射逻辑同上 } } } catch (SQLException e) { e.printStackTrace(); } return list; }page从 1 开始,OFFSET是(page-1)*size。前端传页码,后端算偏移量。总页数用SELECT COUNT(*)单独查一次,别用List.size(),那是当前页的数量不是总数。
5. 公告模块与权限控制:让系统看起来「完整」的两个关键点
5.1 公告的增删改查与首页展示
公告模块代码量和图书模块几乎一样,但它是让系统显得完整的关键。老师看毕业设计,最怕的就是「只有增删改查图书」。加一个公告,首页就能有动态内容,答辩时也多个功能点讲。
公告的 Servlet 和 Dao 照着图书的写就行,注意首页展示时只取最新 5 条:
public List<Notice> findLatest(int limit) { List<Notice> list = new ArrayList<>(); String sql = "SELECT * FROM notice ORDER BY create_time DESC LIMIT ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, limit); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Notice n = new Notice(); n.setId(rs.getInt("id")); n.setTitle(rs.getString("title")); n.setContent(rs.getString("content")); n.setPublisher(rs.getString("publisher")); n.setCreateTime(rs.getTimestamp("create_time")); list.add(n); } } } catch (SQLException e) { e.printStackTrace(); } return list; }首页 JSP 里用c:forEach渲染,标题加链接跳详情页。公告内容在详情页用<c:out value="${notice.content}" escapeXml="false"/>输出,escapeXml="false"允许 HTML 标签生效,但要注意 XSS 风险,毕业设计里自己发的公告无所谓,生产环境必须过滤。
5.2 用 Filter 做登录拦截和角色判断
没有权限控制的系统是不完整的。写一个 Filter,拦截所有/book/*和/notice/*路径,检查 Session 里有没有用户:
@WebFilter(urlPatterns = {"/book/*", "/notice/*", "/user/*"}) public class AuthFilter 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") || uri.endsWith("/login.jsp")) { chain.doFilter(request, response); return; } if (session == null || session.getAttribute("user") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } // 管理员才能进用户管理 if (uri.contains("/user/") && !"1".equals(String.valueOf(session.getAttribute("role")))) { resp.sendError(403, "无权限访问"); return; } chain.doFilter(request, response); } }getSession(false)表示没有 Session 就返回 null,不新建。chain.doFilter是放行,不调用就中断了。角色判断用 Session 里存的 role 值,登录成功时一起存进去。
5.3 文件上传:图书封面的处理
封面图片上传用 Servlet 3.0 的@MultipartConfig注解:
@WebServlet("/book/upload") @MultipartConfig(maxFileSize = 2 * 1024 * 1024) // 最大 2MB public class UploadServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Part part = req.getPart("cover"); String fileName = System.currentTimeMillis() + "_" + part.getSubmittedFileName(); // 存到 web 目录下的 upload 文件夹 String savePath = req.getServletContext().getRealPath("/upload"); File dir = new File(savePath); if (!dir.exists()) dir.mkdirs(); part.write(savePath + File.separator + fileName); resp.getWriter().write("upload/" + fileName); } }getRealPath拿到的是服务器上的绝对路径,部署到 Tomcat 后指向webapps/项目名/upload。文件名加时间戳防止重名覆盖。maxFileSize限制大小,超了会抛异常,前端也要做校验。
6. 避坑与排查:JSP Servlet 项目最容易翻车的五个地方
6.1 中文乱码:POST 和 GET 要分开处理
现象:表单提交中文,数据库里存的是???或者乱码。
原因:POST 请求默认用 ISO-8859-1 解码,GET 请求在 Tomcat 8 以后默认 UTF-8 但 URL 编码方式不同。
解决:POST 在 Servlet 第一行写req.setCharacterEncoding("UTF-8");GET 如果乱码,在 Tomcat 的server.xml里给 Connector 加URIEncoding="UTF-8"。JSP 页面头部<%@ page contentType="text/html;charset=UTF-8" %>也要写对。数据库连接 URL 加characterEncoding=utf8。四个地方都对了才不会乱码。
6.2 404:路径到底该不该带项目名
现象:页面跳转 404,但手动输地址又能访问。
原因:sendRedirect和forward的路径规则不一样。sendRedirect是浏览器行为,需要带项目名;forward是服务器行为,不带项目名。
解决:统一用req.getContextPath()拼路径。重定向写resp.sendRedirect(req.getContextPath() + "/book/list"),转发写req.getRequestDispatcher("/book/list.jsp").forward(req, resp)。JSP 里的链接也用${pageContext.request.contextPath}开头。
6.3 Session 失效:登录后刷新就退出了
现象:登录成功,点几个页面后突然跳回登录页。
原因:Session 默认 30 分钟过期,或者浏览器禁用了 Cookie 导致 JSESSIONID 丢失。
解决:检查web.xml里的<session-config>配置,毕业设计可以设长一点比如 60 分钟。如果是 Cookie 问题,用 URL 重写resp.encodeURL()兜底。另外,session.invalidate()别乱调,退出登录时才用。
6.4 JDBC 连接泄漏:Tomcat 跑一会儿就卡死
现象:系统运行一段时间后响应越来越慢,最后报Too many connections。
原因:Connection 没关闭,每次请求都新建连接,数据库连接池耗尽。
解决:所有 JDBC 操作必须放在 try-with-resources 里,或者 finally 块里 close。检查每个 Dao 方法,ResultSet、PreparedStatement、Connection 三层都要关。用SHOW PROCESSLIST能看到堆积的连接。
6.5 JSTL 标签不生效:页面直接输出了${bookList}
现象:JSP 页面把 EL 表达式原样输出,c:forEach也不循环。
原因:缺少 JSTL 的 jar 包,或者<%@ taglib %>指令写错。
解决:Maven 加jstl依赖,或者手动放jstl.jar和standard.jar到WEB-INF/lib。taglib 的 uri 写http://java.sun.com/jsp/jstl/core,前缀用c。如果还是不行,检查web.xml的版本,2.3 以下不支持 EL。
7. 从能跑到能讲:答辩前该做的三件事和一个习惯
系统跑起来只是第一步,毕业设计答辩才是真正的关卡。我见过太多人代码写得不错,但老师一问就说不清。这里给你三个具体动作。
第一,画一张请求流程图。从浏览器输入 URL 开始,经过 Filter、Servlet、Dao、数据库,再回到 JSP 渲染,每个环节标上你用的类名和方法名。这张图贴在论文里,答辩时对着讲,老师立刻知道你确实是自己写的。不用画得多漂亮,Visio 或者 draw.io 都行,关键是链路完整。
第二,准备三个「为什么」的答案。为什么用 JSP 不用 Thymeleaf?为什么用 JDBC 不用 MyBatis?为什么 Session 存用户信息不用 Token?答案不用多高深,比如「JSP 是课程要求的技术栈,我想把 Model2 的流程走一遍」「JDBC 能让我看清 SQL 执行的全过程,MyBatis 封装太深不适合学习阶段」「Session 是服务端存储,毕业设计部署在单台 Tomcat 上够用,Token 适合分布式场景但这里用不上」。老师要的是你有思考,不是技术多先进。
第三,把数据库脚本和部署步骤写成 README。很多人代码交上去,老师想跑一下发现跑不起来,印象分直接扣光。README 里写清楚:MySQL 版本、建库脚本路径、数据库账号密码改哪里、Tomcat 版本、访问地址。最好录一个两分钟的屏幕录像,从启动到登录到增删改查走一遍,放在项目根目录。
最后说一个习惯:每写完一个功能,立刻用浏览器走一遍完整流程,别攒到最后一起测。JSP Servlet 项目的错误往往是连锁的,一个路径写错,后面全 404,你根本不知道从哪查起。我一般写完一个 Servlet 就重启 Tomcat,点一遍相关页面,确认没问题再写下一个。这个习惯看起来慢,实际上省下的调试时间远超你的想象。
这套方案不复杂,但每个环节都有细节。你照着走一遍,图书管理系统能跑,公告能发,权限能控,界面不难看,答辩能讲清。值不值得做?如果你要的是理解 Java Web 的完整链路,它比任何框架都直接。希望帮到你。
本文还有配套的精品资源,点击获取