news 2026/9/11 23:13:58

Java图书管理系统实战:从Servlet/JSP到MySQL事务与部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java图书管理系统实战:从Servlet/JSP到MySQL事务与部署

简介:基于Java+JSP+MySQL实现的Web图书管理系统,定位于帮助Java Web初学者和高校学生理解B/S架构下的完整业务闭环,可作为课程设计、毕业设计或入门实战项目参考。资源压缩包为ZIP格式,体积约4.04MB,围绕图书查询、借阅、归还等具体流程,覆盖Servlet与JavaBeans业务处理、JSP页面动态渲染、MySQL表设计与增删改查、Tomcat容器部署等Web开发核心环节。系统采用MVC分层思想,将显示、控制与业务模型分离,兼顾用户登录、密码存储、事务一致性等工程化问题;还可借机熟悉JSTL标签简化页面逻辑、Bootstrap布局以及错误处理与日志记录等实践。已有7143人学习,适合希望以完整案例驱动学习、快速提升Java Web项目实操能力的读者。

1. 从毕业设计到生产环境,这套Java图书管理系统的真实边界

如果你在学校或者面试里见过“Java+JSP+MySQL图书管理系统”,大概率会下意识觉得这就是个增删改查的练习项目。实际上,等你自己把用户登录、图书借阅、归还、库存扣减、权限控制串起来跑通一遍,会发现真正的难点根本不在CRUD,而在于借阅状态的一致性、会话状态的保持、SQL注入的防御,以及把项目从IDEA里搬到Tomcat上部署的过程。

这个系统用Servlet处理HTTP请求,JSP负责展示,MySQL存数据,典型的B/S三层结构。它适合两类人:一是刚学完Java基础、想做一个完整Web项目来串联知识点的初学者;二是需要快速搭一套可演示、可扩展的管理系统做毕设或内部工具的开发者。下面我用一个可复现的方式,带你把整条链路从数据库设计一路拆到部署验证。

2. 数据库设计先行:MySQL表结构、事务与JDBC访问层的取舍

2.1 三张核心表的设计思路

图书管理系统的数据模型并不复杂,复杂的是一开始就把类型和状态字段定义对,否则后面写业务逻辑时会处处别扭。一般我会建立三张表:用户表、图书表、借阅记录表。用户表存登录账号和密码摘要,图书表存书籍信息和库存数量,借阅记录表存每一次借还行为。

建表SQL参考如下:

CREATE TABLE `user` ( `id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` CHAR(64) NOT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '0:普通用户 1:管理员', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `book` ( `id` INT NOT NULL AUTO_INCREMENT, `isbn` VARCHAR(20) NOT NULL, `title` VARCHAR(200) NOT NULL, `author` VARCHAR(100) NOT NULL, `publisher` VARCHAR(100) DEFAULT NULL, `total_count` INT NOT NULL DEFAULT 0, `available_count` INT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_isbn` (`isbn`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `borrow_record` ( `id` INT NOT NULL AUTO_INCREMENT, `user_id` INT NOT NULL, `book_id` INT NOT NULL, `borrow_date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `return_date` DATETIME DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0:借出 1:已还 2:逾期', PRIMARY KEY (`id`), KEY `idx_user` (`user_id`), KEY `idx_book` (`book_id`), CONSTRAINT `fk_borrow_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`), CONSTRAINT `fk_borrow_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这套结构里有几个关键决策:用户密码字段固定长度64是因为后面要存SHA-256的十六进制摘要;所有表统一用utf8mb4字符集,避免BOOK标题里出现生僻字或表情符号时乱码;借阅记录表里冗余了status字段,而不是完全靠return_date是否为空来判断状态,这样写统计SQL时不需要额外计算,查询性能更好。

2.2 关于外键和事务的取舍

很多教程在建表时喜欢把所有外键约束都加上,但在实际Web项目里我不建议过度依赖数据库外键。原因是:当系统规模变大后,你可能会拆表、分库,外键会成为迁移的阻力。图书管理系统这种规模,改成在Service层维护数据一致性更灵活。上面SQL里保留了两条外键索引,是为了保证比赛或毕设答辩时数据完整性经得起检查,如果你打算接手改造,可以按需移除后再做分表。

事务处理是借还书功能里最容易出错的地方。一次借书操作至少涉及两步:插入一条借阅记录,然后扣减图书的可用库存。如果第二步失败,第一步的数据就成了脏数据。JDBC连接默认是自动提交的,所以Service层必须显式开启事务:

// BorrowService.java public boolean borrowBook(int userId, int bookId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); BookDAO bookDAO = new BookDAO(); Book book = bookDAO.findById(bookId, conn); if (book == null || book.getAvailableCount() <= 0) { conn.rollback(); return false; } BorrowRecordDAO recordDAO = new BorrowRecordDAO(); boolean inserted = recordDAO.insert(userId, bookId, conn); if (!inserted) { conn.rollback(); return false; } boolean updated = bookDAO.decreaseAvailableCount(bookId, conn); if (!updated) { conn.rollback(); return false; } conn.commit(); return true; } catch (Exception e) { try { if (conn != null) conn.rollback(); } catch (SQLException se) { se.printStackTrace(); } e.printStackTrace(); return false; } finally { DBUtil.close(conn); } }

注意这段代码里所有DAO方法都接收Connection参数,这是让多个数据库操作共享同一个事务的关键。如果DAO各自去DBUtil里拿新连接,事务就失效了。另一个细节是:在执行任何写操作之前先查一次库存并判断,虽然不能完全解决并发超卖,但在这个体量的项目里已经足够,真遇到高并发可以用SELECT ... FOR UPDATE做行锁,但那是后话。

2.3 DAO层:用PreparedStatement堵住SQL注入

访问数据库时,我见过太多新手用字符串拼接SQL,比如"SELECT * FROM book WHERE title = '" + title + "'"。这在小项目里跑起来没问题,但一旦遇到用户输入' OR '1'='1,整张表就裸奔了。JDBC本身提供了PreparedStatement来预编译SQL,既安全又能提升重复执行效率。

// BookDAO.java public List<Book> searchByTitle(String keyword) { List<Book> books = new ArrayList<>(); String sql = "SELECT id, isbn, title, author, publisher, total_count, available_count " + "FROM book WHERE title LIKE ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book book = new Book(); book.setId(rs.getInt("id")); book.setIsbn(rs.getString("isbn")); book.setTitle(rs.getString("title")); book.setAuthor(rs.getString("author")); book.setPublisher(rs.getString("publisher")); book.setTotalCount(rs.getInt("total_count")); book.setAvailableCount(rs.getInt("available_count")); books.add(book); } } } catch (SQLException e) { e.printStackTrace(); } return books; }

这里?占位符由驱动填值,MySQL驱动会自动处理特殊字符转义,从机制上杜绝SQL注入。用try-with-resources语法可以自动关闭ConnectionPreparedStatementResultSet,省掉一堆finally代码块。模糊查询时通配符%要放在setString的参数里,而不是拼进SQL模板。

3. Servlet + JSP + JSTL实现图书借阅核心流程

3.1 用BaseServlet统一请求分发

一个图书管理系统如果每个功能都写一个Servlet,class文件会爆炸式增长。常见做法是写一个BaseServlet抽象类,利用反射统一处理请求方法的分发。这种模式不是SpringMVC的专利,手写一个也很简单:

// BaseServlet.java public abstract class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null || action.isEmpty()) { action = "list"; } try { Method method = this.getClass().getMethod(action, HttpServletRequest.class, HttpServletResponse.class); method.invoke(this, req, resp); } catch (NoSuchMethodException e) { resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Unknown action: " + action); } catch (Exception e) { throw new ServletException("Action invocation failed", e); } } }

action参数对应Servlet里一个公开方法名,比如borrowreturnBooklist。这样每个业务实体只需一个Servlet类。反射调用的性能损耗在这个规模下可以忽略不计。sendError返回400状态码而不是直接跳转到错误页,便于前端识别请求是否成功。

前端调用时只需要在URL上加参数:/book?action=borrow&bookId=3。如果开发时在浏览器里直接访问这个地址,登录状态会丢失,因为每次请求都是独立的HTTP连接。

3.2 借书流程代码拆解

借书是整个系统里链路最长的操作,涉及Controller、Service、DAO三层。Controller层做的事情很纯粹:取请求参数、判断用户会话、调用Service、根据结果跳转或返回JSON。

// BookServlet.java public void borrow(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session = req.getSession(); User user = (User) session.getAttribute("currentUser"); if (user == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } String bookIdStr = req.getParameter("bookId"); if (bookIdStr == null || !bookIdStr.matches("\\d+")) { resp.sendRedirect(req.getContextPath() + "/book?action=list&error=invalid_param"); return; } int bookId = Integer.parseInt(bookIdStr); boolean success = new BorrowService().borrowBook(user.getId(), bookId); if (success) { resp.sendRedirect(req.getContextPath() + "/book?action=list&msg=borrow_success"); } else { resp.sendRedirect(req.getContextPath() + "/book?action=list&error=borrow_failed"); } }

这里有两个细节值得学习。第一,bookIdStr.matches("\\d+")用正则把参数先校验一遍,非法输入直接重定向,不进入Service层。第二,重定向用sendRedirect会产生新的请求,所以状态信息通过URL参数传递,这也是Post/Redirect/Get模式(PRG)的简单实现,可以防止用户刷新页面时重复提交借书请求。

3.3 JSP页面渲染:JSTL替代Scriptlet

老式JSP页面里写大量<% %>脚本片段,页面和Java代码耦合严重,改个样式都提心吊胆。现代做法是用EL表达式配合JSTL标签库,把Java代码从页面里完全拿掉。图书列表页的核心段落如下:

<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> <table class="table table-bordered table-hover"> <thead> <tr> <th>ISBN</th> <th>书名</th> <th>作者</th> <th>出版社</th> <th>库存/总量</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${bookList}" var="book"> <tr> <td>${fn:escapeXml(book.isbn)}</td> <td>${fn:escapeXml(book.title)}</td> <td>${fn:escapeXml(book.author)}</td> <td>${fn:escapeXml(book.publisher)}</td> <td>${book.availableCount} / ${book.totalCount}</td> <td> <c:if test="${book.availableCount > 0}"> <a href="/book?action=borrow&bookId=${book.id}" class="btn btn-primary btn-sm">借阅</a> </c:if> <c:if test="${book.availableCount <= 0}"> <span class="text-muted">不可借</span> </c:if> </td> </tr> </c:forEach> </tbody> </table>

fn:escapeXml是JSTL函数库里的一个容易被忽略但极其实用的函数,它会把HTML特殊字符转换成实体编码,避免用户录入的数据破坏页面结构,是防御XSS攻击的最基础手段。c:if做条件判断时,注意el表达式里的属性名和JavaBean的getter要严格对应,比如availableCount对应getAvailableCount()

每个<td>里渲染的是从后端放进request域的对象属性,管理员和普通用户看到的内容是一样的。要在页面里区分权限,可以在Servlet里做判断,也可以在JSP里用sessionScope.currentUser.role来控制按钮显示,我倾向于后者,因为不改变URL就能按角色渲染界面。

3.4 会话状态管理的正确姿势

HTTP是无状态的,JSP里内置的session对象本质上是服务器在内存里维护的一个Map,靠JSESSIONID这个Cookie来识别用户。登录成功后将User对象放入session,后续请求就能认出你是谁。但要注意session默认超时时间是30分钟,Tomcat的web.xml里可以调这个值:

<session-config> <session-timeout>60</session-timeout> </session-config>

单位是分钟,设置为60表示用户闲置一小时后会话才失效。如果做的是借阅系统,30到60分钟比较合理。会话失效后用户再点“借阅”会被sendRedirect到登录页,这个跳转逻辑必须在Servlet层校验,不能只靠JSP隐藏按钮判断,因为请求可以直接发到Servlet的URL上。

4. 登录安全与典型故障排查

4.1 密码不存明文:SHA-256加盐的正确姿势

用户表的password字段长度为64,恰好对应SHA-256输出的十六进制长度。但直接对密码做SHA-256是不安全的,因为彩虹表早就把常见密码的摘要算好了。标准做法是加盐,也就是把用户名或随机字符串拼到密码里再哈希:

// PasswordUtil.java public static String hashPassword(String rawPassword, String salt) { String salted = salt + rawPassword; MessageDigest digest; try { digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(salted.getBytes(StandardCharsets.UTF_8)); StringBuilder hexString = new StringBuilder(); for (byte b : hash) { String hex = Integer.toHexString(0xff & b); if (hex.length() == 1) hexString.append('0'); hexString.append(hex); } return hexString.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("SHA-256 algorithm not available", e); } }

登录校验时把用户提交的密码用库里的salt重新哈希,和库里存的摘要比对。如果一致,说明密码正确。这个工具类在注册和登录两个环节都要用。有人可能会问,为什么不直接用消息摘要算法之外更现代的方式,比如BCrypt?因为JDK自带实现为零依赖,对学习和演示更友好,真要做生产级系统,再换Spring Security的BCryptPasswordEncoder也不迟。

4.2 拦截器思想:每个Servlet入口先过权限关

系统里有一种接口只允许管理员操作,比如新增图书、删除用户。如果每个Servlet都写一遍角色判断,代码冗余且容易漏。我一般会在BaseServlet里预留一个钩子方法:

// BaseServlet.java protected boolean checkPermission(HttpServletRequest req) { return true; } // 在service方法分发前调用 if (!checkPermission(req)) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; }

然后在AdminServlet里重写checkPermission,从session里取用户角色判断。类似myFilterInterceptor的思想,但又不用引入Spring框架的依赖,在纯Servlet项目里是一种轻量级的权限控制方案。

4.3 三大高频故障排查手册

开发过程中总会遇到几个反复出现的经典问题。我把它们列成一个排查表,方便你对照处理。

现象根本原因解决办法
浏览器访问JSP页面中文全部乱码JSP文件编码与响应编码不一致JSP文件头部加<%@ page pageEncoding="UTF-8" %>web.xml里配置CharacterEncodingFilter强制请求和响应使用UTF-8
启动Tomcat时提示端口被占用本机8080端口被其他进程占用netstat -ano查PID,任务管理器结束进程,或者改Tomcat的server.xml里Connector的port属性
页面报500错误,日志里出现java.lang.ClassNotFoundException: com.mysql.cj.jdbc.DriverMySQL驱动jar包没有放到WEB-INF/lib目录下载mysql-connector-j对应版本jar放到WEB-INF/lib,重新构建WAR包
修改JSP后浏览器不生效Tomcat缓存了编译后的class清理Tomcat的work/Catalina目录,或者重启Tomcat,开发时开启JSP热部署配置

关于JSP修改不生效,这个几乎是初学者必踩的坑。Tomcat对JSP的处理是先翻译成Java源码再编译成class,翻译和编译都有缓存。如果你的IDE配置了自动部署到Tomcat,有时候改了JSP但没触发重新编译,清理work目录是最快的解决方式。生产环境里一般不开启JSP热部署,但在开发阶段,可以在web.xml里确保没有把JSP的development参数设为false。

中文乱码问题则涉及三层编码统一:JSP文件本身的编码、Tomcat连接器URI编码、MySQL连接参数编码。JDBC连接串里一定要加characterEncoding=utf8,否则数据存进MySQL后再取出来可能就变成问号了。以Tomcat 8.5及以上版本为例,默认URI编码是UTF-8,但老项目从Tomcat 7迁移上来时,server.xml里最好显式配上URIEncoding="UTF-8",防止GET请求中文参数乱码。

5. 部署验证与借阅链路优化技巧

5.1 用Maven打WAR包并部署到Tomcat

到了联调阶段,最忌讳的就是把IDEA里运行的临时状态当成部署产物。我一般会用Maven生命周期打成WAR包,手动扔到Tomcat的webapps目录下验证。

mvn clean package -DskipTests

执行完后在target/目录下会生成xxx.war。把它复制到Tomcat的webapps目录,启动Tomcat后会自动解压。这时访问路径是:

http://localhost:8080/xxx/ (xxx是WAR包的文件名)

如果你的项目在IDEA里跑得好好的,部署到独立Tomcat后却出现了连接数据库失败,优先检查三个地方:MySQL连接串里的localhost对不对、数据库账号密码有没有权限、连接MySQL的驱动jar有没有打进去。WAR包可以通过jar tf命令查看内容,确认WEB-INF/lib下有JDBC驱动。

5.2 提升借阅响应速度的3个低成本手段

排除代码本身的问题后,系统在数据量变大时会出现明显的卡顿。我一般会优先做三件事:给borrow_record表加索引、在DAO层使用数据库连接池复用连接、减少页面请求数。

连接池替换最直接的方法是引入HikariCP。它是目前Java生态里性能最好的连接池实现,替换成本极低,只需要改DBUtil的初始化代码:

static { HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://localhost:3306/library"); config.setUsername("root"); config.setPassword("password"); config.setMaximumPoolSize(10); config.setMinimumIdle(2); connectionFactory = new HikariDataSource(config); }

这样每次DBUtil.getConnection()从池里拿连接,用完归还,避免了每次请求都新建TCP连接带来的延迟瓶颈。maximumPoolSize一般设置为CPU核心数的两倍即可,不需要贪多。设置了连接池之后,DBUtil.close(conn)在HikariCP下会把连接状态重置并放回池中,逻辑上仍然只需要调用close

5.3 用浏览器开发者工具验证整个借阅链路

部署完成后,我习惯在浏览器里按F12打开开发者工具,在Network标签页里手动走一遍完整流程:登录、搜索图书、发起借阅、查看列表。

重点看几个指标:第一个是登录请求的响应头里有没有Set-Cookie: JSESSIONID=...,没有说明session没建起来或响应被缓存。第二个是借书请求的URL参数是否正确拼上了bookId,状态码是302而不是500。第三个是数据库里对应书籍的available_count有没有减一。

如果available_count没变,回到MySQL里直接执行这条SQL看锁和事务状态:

SELECT id, user_id, book_id, borrow_date, return_date, status FROM borrow_record ORDER BY id DESC LIMIT 5;

如果记录存在但库存没扣,那问题出在Service层的两个DAO方法没有共享同一个连接,对照2.2节里的事务代码检查每个DAO方法是不是都接收了Connection参数。如果记录和库存都对不上,再查一遍浏览器Network面板里是不是有重复请求发出,PRG模式没有生效时可以给借阅按钮加一个disabled状态,或者在Servlet里校验当前用户是否已经有未归还的同一本书。

整个系统跑通后,你手里就有一套完整可演示的Java Web全栈项目了。接口的路径设计、数据库事务的边界、会话与权限的处理、部署排查的手段,这些经验都比运行成功的页面本身更有价值。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/11 23:09:35

用LTP构建《红楼梦》知识图谱:NER、SRL与Neo4j全流程

简介&#xff1a;本资源是一套基于知识图谱的《红楼梦》人物关系可视化与智能问答系统完整实现&#xff0c;面向自然语言处理与知识图谱初学者及高校课程设计者&#xff0c;解决古典文学文本中实体识别、关系抽取与结构化知识应用的实际问题。资源共246个文件&#xff0c;包含8…

作者头像 李华
网站建设 2026/9/11 23:09:26

STM32智能导盲拐杖超声波避障方案:从硬件选型到状态机实现

简介&#xff1a;基于STM32的智能导盲拐杖完整开发包&#xff0c;面向嵌入式、物联网及电子类专业学生&#xff0c;可用于毕业设计、课程设计或项目初期演示。包内含全新源码与详细设计文档&#xff0c;代码经测试可直接运行&#xff0c;适合从入门到进阶的开发者学习参考。资源…

作者头像 李华
网站建设 2026/9/11 23:08:18

解决Google Generative AI 403认证错误的完整指南

1. Google Generative AI 403认证错误深度解析 遇到"Request had insufficient authentication scopes"报错时&#xff0c;通常意味着你的API访问令牌(ACCESS_TOKEN)缺少必要的权限范围。这个问题在调用Google Generative AI服务时尤为常见&#xff0c;特别是当开发者…

作者头像 李华
网站建设 2026/9/11 23:08:04

2026企业AI办公工具选型指南:从场景匹配到Agent落地

一、企业选AI办公工具&#xff0c;为什么不能只看功能列表 当前企业在调研AI办公产品时&#xff0c;普遍存在几类典型选型误区&#xff1a;横向对比功能清单数量、优先按席位单价做决策、以消费端产品知名度作为核心判断依据。很多团队上线工具后发现&#xff0c;大量对话类能力…

作者头像 李华