简介:这是一套基于Java+JSP+MySQL的Web图书管理系统实践资源,定位为Java Web入门者及课程设计参考项目,覆盖图书信息查询、借阅、归还、用户管理等常见业务场景,帮助读者理解B/S架构下Servlet与JSP的协作方式,以及JDBC操作MySQL、事务提交回滚等典型写法。资源采用zip打包,大小约4.04MB,包内文件总数与类型明细暂无统计;从内容描述判断,应包含项目源码、JSP页面、数据库建表脚本及部署说明,适合对照学习或在此基础上二次开发。目前已有7143人浏览学习,学习热度较高。通过该系统可掌握MVC分层设计、JSTL标签库使用、Bean封装业务数据、数据库表关系设计、基础安全防护(如密码散列与SQL注入防范)以及Tomcat环境部署等关键技能,是串联Java Web后端与前端页面的练手佳例。
1. 为什么 2025 年还在用 Java+JSP+Mysql 写图书管理系统
先想想场景:一个学校图书馆或社区资料室,管理员还在用 Excel 记录书号、借还日期,想换轻量 Web 系统,但预算有限、内部服务器也是万年前的老机器。用 Spring Boot 全家桶是能写,但对这类小体量项目,Java+JSP+Mysql 这套经典组合仍然是最快落地、最容易让后来人接手的方案。接下来顺着这个标题,把从数据库建模、JDBC 连接到前端 JSP 页面的完整链路拆开讲,适合正在做课程设计、毕业设计,尤以基于 Java 的图书管理系统毕业设计最典型,也适合需要维护老系统的工程师。它的价值在于:JSP 本身是 Java Web 的基石,理解了这一套,再看 Spring MVC 只是一条更顺的路。
2. JSP+Servlet 技术选型与项目架构——先搞清楚 JSP 在 Web 里的位置
2.1 为什么这套组合至今还出现在课程设计和外包需求里
JSP+Servlet+Mysql 确实不算新,但现实是很多高校的《Java Web 开发》课程、毕业设计题目、甚至小型外包系统都在用。原因一是教学大纲还停留在这个体系,二是它足够轻,Tomcat+JDK+一个 mysql-connector-java 就能把项目跑起来,对服务器要求极低,一台 1G 内存的云主机就能扛住上千注册用户的并发查询。三是这个生态踩坑资料极多,遇到问题几乎都能检索到现成答案,新手不至于卡死在某一个环境问题上。
这里要先统一一个认知:JSP 本质上是一个被容器翻译成 Servlet 的 Java 类。你在 JSP 里写的 HTML 标签会被 out.write() 输出,JSP 脚本片段会被原样塞进_jspService方法体,它不是一个独立模板引擎。JSP 生命周期由容器管理,依次经过翻译、编译、装载、实例化、初始化、服务、销毁。像开发岗 Java 面试题里常考的 JSP 九大内置对象,也都是由这个体系派生出来的。搞清楚这条链路,后面排查“JSP 改了不生效”的问题就简单了。
2.2 MVC 在 JSP 时代的拆分——页面、控制器与数据层各管一摊
最常见的错误是把所有逻辑都塞进 JSP:页面里直接写 JDBC、写 ResultSet 循环。这么做在 100 行以内的小页面确实快,但图书、读者、借阅三个模块加起来,JSP 里螺成一团,改一个字段要全页面搜索,新人根本看不懂。我一般会按 MVC 三块拆:
- View:
*.jsp,只负责展示数据和收集表单输入,用 EL 表达式或 JSTL 循环,不在页面写 Java 业务逻辑 - Controller:Servlet,接收请求、调用 Service/DAO、设置 request 属性、转发或重定向
- Model:JavaBean 实体类 + DAO 数据访问层,必要时加 Service 业务层
对于图书管理系统这个体量,Service 层可以视情况省略,但 DAO 层必须有。一个典型的项目目录结构如下:
bookmanager/ ├── src/main/java │ ├── com.example.entity # Book, Reader, Borrow 实体 │ ├── com.example.dao # 数据访问接口与实现 │ ├── com.example.servlet # LoginServlet, BookServlet 等 │ └── com.example.util # DBUtil, StringUtil ├── webapp │ ├── WEB-INF │ │ ├── lib # 存放驱动和依赖 jar │ │ └── web.xml # Servlet 与过滤器注册 │ ├── css, js │ ├── login.jsp │ ├── book_list.jsp │ └── borrow_add.jsp关键点在 web.xml:Servlet 的 URL 映射路径、过滤器顺序、welcome-file。如果你用的是 Servlet 3.0+ 版本,可以跳过 web.xml 直接用@WebServlet注解,但很多老教材还在用配置文件,推荐项目里统一用一种方式,别一半注解一半 XML 混着来,否则新人接手时容易找不到请求为何被拦截。
2.3 连接池选型——为什么不要用 DriverManager 直连
初学者最容易写这样一段代码:每次请求都Class.forName然后DriverManager.getConnection。这在课设演示数据量下看不出问题,但如果部署到真实环境,连接反复创建销毁会让数据库在后端压力上来时瞬间打满。连接是宝贵资源,合理做法是用连接池。三个常见选项:
| 连接池 | 特点 | 适用场景 |
|---|---|---|
| C3P0 | 配置直观,维护较少 | 老教材、课程设计 |
| DBCP2 | 与 Tomcat 集成方便 | 容器固定为 Tomcat |
| Druid | 自带监控和慢 SQL 统计 | 需要排查性能问题的小型项目 |
本地课设推荐 Druid,配置简洁且自带监控页。关键参数如下:
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/book_manager?characterEncoding=utf8mb4"/> <property name="username" value="root"/> <property name="password" value="123456"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> <property name="maxWait" value="60000"/> </bean>这是 Spring 的写法,Druid 本身也支持纯 Java 配置。核心参数就四个:initialSize 是启动时预创建的连接数,maxActive 是连接池上限,maxWait 是获取连接超时毫秒数,超出会抛异常而不是死等。对图书管理系统,初期连接 5 个、上限 20 个足够,别把 maxActive 调太大,否则空闲连接反而占用数据库内存。
2.4 不改代码也能提升性能的三个小配置项
除了连接池,几个容易忽略的参数值得单独说。图书表超过十万条时,查询条件里的 category 和 title 前缀匹配都要有对应索引,否则全表扫描一次可能几百毫秒。分页查询每页条数建议固定为 10–20 条,不要暴露给前端随意传值。MySQL 表引擎务必使用 InnoDB,事务和行锁是借阅场景正确性的前提。这些都不是 JSP 代码层面的事,但往往比优化页面本身见效更快。
3. MySQL 数据库建模——图书管理系统的表设计比代码更影响后期维护
3.1 从需求反推数据表——管理员、图书、读者、借阅记录缺一不可
核心业务流程可以归纳为:管理员录入和维护图书信息,读者查询书目并完成借阅与归还,系统记录每一次借阅行为。围绕这个流程,最少需要四张核心表:管理员用户表、图书表、读者表、借阅记录表。最小的可用版本不要引入分类表和出版社表,业务关系越多,JSP 页面里做 ResultSet 拼接就越痛苦。先画一份简化的 E-R 图,再对照落成表结构,比直接写 SQL 更不容易漏字段。
| 表名 | 职责 | 核心字段 |
|---|---|---|
| admin_user | 管理员登录认证 | username, password |
| book | 图书信息与库存 | isbn, title, stock |
| reader | 读者信息 | reader_no, name |
| borrow_record | 借阅与归还流水 | reader_id, book_id, status |
数据库名单独创建,不与其他项目混用,字符集必须用 utf8mb4。MySQL 的 utf8 实际只支持三字节编码,生僻字和部分特殊符号存不进去,utf8mb4 才是完整的四字节 UTF-8 支持。用 Navicat 或 MySQL Workbench 执行建库脚本时,注意连接字符集也保持一致,否则脚本里的中文注释可能直接乱码入库。
3.2 四张表的字段设计与建表 SQL
下面给一份可以直接执行的建表脚本:
CREATE DATABASE IF NOT EXISTS book_manager DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE book_manager; CREATE TABLE admin_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(255) NOT NULL COMMENT '密码,建议存加盐哈希,勿存明文', real_name VARCHAR(50) COMMENT '管理员姓名', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB COMMENT='管理员表'; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) UNIQUE COMMENT '国际标准书号,业务维度唯一', title VARCHAR(200) NOT NULL COMMENT '书名', author VARCHAR(100) COMMENT '作者', publisher VARCHAR(100) COMMENT '出版社', category VARCHAR(50) COMMENT '分类', stock INT NOT NULL DEFAULT 1 COMMENT '当前库存数量', total_count INT NOT NULL DEFAULT 1 COMMENT '总数量', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='图书表'; CREATE TABLE reader ( id INT PRIMARY KEY AUTO_INCREMENT, reader_no VARCHAR(20) UNIQUE COMMENT '借书证号', name VARCHAR(50) NOT NULL, phone VARCHAR(20), email VARCHAR(100), status TINYINT DEFAULT 1 COMMENT '1激活 0停用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='读者表'; CREATE TABLE borrow_record ( id INT PRIMARY KEY AUTO_INCREMENT, reader_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, due_time DATETIME COMMENT '应还时间', return_time DATETIME NULL COMMENT '实际归还时间,NULL表示未还', status TINYINT DEFAULT 0 COMMENT '0借出 1已还 2逾期', KEY idx_reader (reader_id), KEY idx_book (book_id), KEY idx_status (status) ) ENGINE=InnoDB COMMENT='借阅记录表';几个容易被忽略的设计决策值得停下来想清楚。isbn 字段加了 UNIQUE 约束,但同一 ISBN 有很多副本,所以主键用自增 id,isbn 作为业务唯一键。如果系统要支持同一本书的不同馆藏编号,再拆一张 book_copy 表。stock 和 total_count 分开,让查询在架数量时直接做减法,不需要反向数记录,避免大数据量下的 COUNT(*) 慢查询。
borrow_record 里最关键的是 status 字段。很多人用 return_time 是否为空判断是否归还,这不够,因为逾期状态需要额外计算。用一个 TINYINT 状态字段,再在查询时根据 due_time 刷新:超期未还则置为 2。这就是图书管理系统里状态机的雏形,后续做统计报表时,直接按 status 分组,不必每次扫描全部流水。
3.3 借阅流程中的外键、索引与事务控制
教材里往往要求建物理外键,但实际开发中不少团队选择不建,只在 DAO 层维护逻辑关系。原因是借阅记录在库存不足时会被拒绝,删除图书时也可能需要级联处理,物理外键会带来不必要的锁竞争。折中方案是:所有关联字段一律建索引,但不加 FOREIGN KEY,靠代码保证数据完整性。
借阅动作里事务必不可少。一次借阅需要两步:扣减库存、插入借阅记录。如果不放在同一个事务里,第二步失败会导致库存减了但记录没有,盘点时账实不符。下面是完整的关键 SQL:
START TRANSACTION; SELECT stock FROM book WHERE id = 1 FOR UPDATE; UPDATE book SET stock = stock - 1 WHERE id = 1 AND stock > 0; INSERT INTO borrow_record (reader_id, book_id, due_time) VALUES (101, 1, DATE_ADD(NOW(), INTERVAL 30 DAY)); COMMIT;SELECT ... FOR UPDATE是在事务内给该行加排他锁,防止两个并发请求同时读到库存为 1,各自以为还能借走。这一步不加锁,图书数量就可能出现负数。借阅期限设为 30 天,归还时用两条 UPDATE 并把它们放进事务:
UPDATE borrow_record SET return_time = NOW(), status = 1 WHERE id = ? AND status = 0; UPDATE book SET stock = stock + 1 WHERE id = ?;这时候再回头看索引就具体了:borrow_record 上的 idx_reader 和 idx_book 各是一棵 B+ 树,查询某个读者的借阅历史走 idx_reader,查询某本书被谁借走走 idx_book。在设计期就把索引加上,后面 JSP 页面做列表筛选就不会慢。要注意组合索引的列顺序,如果实际查询都是reader_id + status,索引就应该是(reader_id, status)而不是分开的两个单列索引。
3.4 初始数据与密码安全
建完表需要插入初始管理员账户。很多人图方便直接 INSERT 明文密码,这在对外部署时很危险。如果追求完整性,至少用 SHA-256 处理一次:
INSERT INTO admin_user (username, password) VALUES ('admin', SHA2('admin123', 256));登录校验时先对输入做同样摘要再比对。SHA2 函数在 MySQL 5.5+ 就支持,8.0 里仍然有效。这样表结构、字段、索引、事务和初始数据都齐了,下一章进入 Java 侧实现。
4. Servlet+JSP 实现登录、图书分页与借阅归还——关键代码与参数说明
4.1 登录功能:从表单到 Session 的完整链路
Web 图书管理系统必然要挡住未登录用户,登录的焦点是防 SQL 注入和密码校验逻辑。常规做法是建一个 LoginServlet,接收 login.jsp 提交的 username/password:
@WebServlet("/login") public class LoginServlet extends HttpServlet { 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 (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) { resp.sendRedirect("login.jsp?error=1"); return; } try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement( "SELECT id, username FROM admin_user WHERE username = ? AND password = SHA2(?, 256)")) { ps.setString(1, username.trim()); ps.setString(2, password); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { HttpSession session = req.getSession(); session.setAttribute("adminId", rs.getInt("id")); session.setAttribute("adminName", rs.getString("username")); resp.sendRedirect("book_list"); } else { resp.sendRedirect("login.jsp?error=1"); } } } catch (Exception e) { throw new ServletException("登录查询异常", e); } } }这段代码有几个重点。PreparedStatement 的?占位符是防注入的第一道防线,不要用字符串拼接 SQL;密码通过 MySQL 的 SHA2() 比较,少一层 Java 依赖;登录成功后只把 adminId 和 adminName 放进 session,不要把整个实体对象塞进去,减少 Session 序列化压力。
还需要一个过滤器统一校验登录状态,避免每个 Servlet 里重复判断:
@WebFilter("/*") public class AuthFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); if (uri.endsWith("login.jsp") || uri.endsWith("/login") || uri.endsWith(".css") || uri.endsWith(".js")) { chain.doFilter(req, resp); return; } if (request.getSession().getAttribute("adminId") == null) { response.sendRedirect("login.jsp"); return; } chain.doFilter(req, resp); } }放行列表要求精确,图片、CSS、JS 等静态资源都要放行,不然登录页面样式加载不出来,表单布局直接乱掉。
4.2 图书列表分页:LIMIT 偏移参数和 JSTL 渲染
图书数量多时页面不可能一次渲染完,分页查询有明确的写法,坑在于页码与 LIMIT 偏移的换算。每页 10 条时,第 1 页应LIMIT 0, 10,第 2 页LIMIT 10, 10:
SELECT id, isbn, title, author, publisher, stock FROM book ORDER BY id DESC LIMIT ?, ?;Java 侧这样传参数:
int pageNum = 1; int pageSize = 10; if (req.getParameter("page") != null) { pageNum = Integer.parseInt(req.getParameter("page")); } int offset = (pageNum - 1) * pageSize;这里从请求参数里读 page,要做一次 Integer.parseInt,如果前端传了非数字,会抛 NumberFormatException。稳妥做法是加一个工具方法解析并设置默认值,免得页面直接报 500。pageSize 建议固定,不暴露给前端修改,避免一次请求传超大值拖垮数据库。
| 参数 | 含义 | 示例值 | 约束 |
|---|---|---|---|
| page | 当前页码 | 1 | 最小为 1 |
| pageSize | 每页条数 | 10 | 建议 1–50 |
| offset | LIMIT 偏移量 | 0 | 等于 (page - 1) * pageSize |
页面上用 JSTL 的 c:forEach 循环渲染表格,varStatus 可以拿到当前行号:
<c:forEach items="${pageBean.list}" var="book" varStatus="st"> <tr> <td>${st.count}</td> <td>${book.isbn}</td> <td>${book.title}</td> <td>${book.stock}</td> <td><a href="book_edit?id=${book.id}">编辑</a></td> </tr> </c:forEach>JSP 页面里有一个高频报错和 JSONArray 相关。不少同学需要返回 JSON 数据时,顺手在 JSP 顶部写<%@ page import="net.sf.json.JSONArray" %>,结果启动后 JSP 编译直接报 ClassNotFound。原因通常是 WEB-INF/lib 下没有对应的 json-lib jar。更推荐的做法是 Servlet 里用 Gson 或 Jackson 序列化后通过resp.getWriter().write(json)输出,JSP 只负责渲染页面,不承担 JSON 序列化职责。
4.3 借阅功能:事务、库存检查与返回信息
借阅核心逻辑放在 Service 里而不是 Servlet,一个完整借阅动作如下:
public boolean borrowBook(int readerId, int bookId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); PreparedStatement checkBook = conn.prepareStatement( "SELECT stock FROM book WHERE id = ? FOR UPDATE"); checkBook.setInt(1, bookId); ResultSet rs = checkBook.executeQuery(); if (!rs.next() || rs.getInt("stock") <= 0) { conn.rollback(); return false; } PreparedStatement updateStock = conn.prepareStatement( "UPDATE book SET stock = stock - 1 WHERE id = ?"); updateStock.setInt(1, bookId); updateStock.executeUpdate(); PreparedStatement insertBorrow = conn.prepareStatement( "INSERT INTO borrow_record (reader_id, book_id, due_time) VALUES (?, ?, DATE_ADD(NOW(), INTERVAL 30 DAY))"); insertBorrow.setInt(1, readerId); insertBorrow.setInt(2, bookId); insertBorrow.executeUpdate(); conn.commit(); return true; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { e.addSuppressed(ex); } } return false; } finally { DBUtil.close(conn); } }几个要点务必掌握:setAutoCommit(false) 之后才能保证多条语句同生共死;SELECT ... FOR UPDATE 是悲观锁,保证两个并发借阅不会同时通过库存检查;所有写操作必须共用一个 Connection,如果每次 DAO 调用都重新拿连接,事务就形同虚设。
4.4 减少 Servlet 数量的 BaseServlet 套路
图书管理系统的 Servlet 会随功能增加而膨胀,每个 Servlet 重复写 doGet/doPost 同构代码很浪费时间。常见做法是做一个 BaseServlet,用反射根据请求参数 method 调用子类对应方法:
public class BaseServlet extends HttpServlet { protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String method = req.getParameter("method"); if (method == null || method.isEmpty()) { resp.sendRedirect("login.jsp"); return; } try { Method target = this.getClass().getMethod( method, HttpServletRequest.class, HttpServletResponse.class); target.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(404, "method not found"); } catch (Exception e) { throw new ServletException(e); } } }子类只需写具体方法,比如public void list(HttpServletRequest req, HttpServletResponse resp)。访问路径统一为book_servlet?method=list&page=2。这种方法在小型系统里大幅减少类文件数量,也能让新人直观理解 URL 到方法名的映射规则。要注意反射出的方法必须是 public,且不能暴露给外部随意调用内部方法,method 参数要做白名单校验,别让用户通过 URL 直接调 delete。
5. 部署与排错:一台全新机器上跑通图书管理系统的关键步骤
把开发环境的项目换到另一台机器上跑,最容易卡住的不是代码而是环境。这里按顺序说一套踩坑之后的落地流程,把前面零散提到的参数统合一遍。
5.1 环境配置清单与三个最容易报错的位置
JDK 8、Tomcat 8.5 或 9.0、MySQL 8.0 是这类项目最稳的组合。部署前检查三件事:JAVA_HOME 是否正确指向 JDK 目录,PATH 里有没有配 bin 路径;mysql-connector-java-8.x.jar 是否放在 WEB-INF/lib 下且驱动类名匹配;web.xml 里有没有配置全局字符集过滤器。其中驱动类名这个坑最密集:MySQL 8.0 的驱动类从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.Driver,老教程里的旧类名在 8.0 下会直接抛 ClassNotFoundException。
5.2 MySQL 8.0 连接串参数——时区、编码与免安装版初始化
MySQL 8.0 连接串必须带 serverTimezone,否则启动时会报时区错误。规范的连接串是:
jdbc:mysql://localhost:3306/book_manager?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=falseuseSSL=false 是为了避免本地环境的 SSL 握手告警。即使连接串没问题,还可能出现数据时间比本地时间差 8 小时,原因是 MySQL 会话时区和 JVM 时区不一致。连接串指定 Asia/Shanghai 之后,还要检查操作系统 date 命令显示是否正确。MySQL 免安装版尤其要注意初始化参数,解压后不改 my.ini 的字符集和时区,驱动连接时一样会出怪问题。
5.3 JSP 改了不生效与中文乱码的排查思路
“JSP 改了不生效”是出现频率极高的搜索词。如果改的是 JSP 页面,浏览器里看到的还是旧内容,先按三个位置排查:项目是否部署成 war 包,war 包部署需要重新构建;Tomcat 的 server.xml 里 host 的 autoDeploy 和 reloadable 是否开启,开发环境建议都设为 true;JSP 编译后的 class 缓存在 work/Catalina/localhost/项目名/org/apache/jsp 目录,可以手动清理确认。中文乱码则是三处编码统一:JSP 页面顶部设置contentType="text/html; charset=UTF-8"和pageEncoding="UTF-8",HTML 里加 meta charset,再配置一个全局过滤器强制 request 和 response 都走 UTF-8。GET 请求链接传参乱码时,还要检查 Tomcat 的 URIEncoding 配置。
5.4 用 SQL 核对借阅状态的验证技巧
借阅系统改完功能后,别只盯着页面看,用 MySQL 客户端跑几条 SQL 能更快确认数据链路是否正确。比如某读者还了书,顺手查借阅记录和库存:
SELECT r.name, b.title, br.borrow_time, br.return_time, br.status FROM borrow_record br JOIN reader r ON r.id = br.reader_id JOIN book b ON b.id = br.book_id ORDER BY br.id DESC LIMIT 5; SELECT id, title, stock FROM book WHERE id = 1;如果页面显示已还但 status 仍是 0,优先检查事务是否提交、UPDATE 语句是否真正执行。把借书、还书、查询库存这几条 SQL 放进一个可重复执行的脚本,既是自测工具,也能在演示前快速确认业务闭环。这套“先查库再断代码”的思路,比在 JSP 页面里反复打 System.out.println 高效得多。
本文还有配套的精品资源,点击获取