简介:这是一套面向Java Web初学者与课程设计开发者的图书管理系统完整源码,基于SpringBoot整合MyBatis持久层与JSP视图层实现,覆盖图书增删改查、分类管理、借阅归还、分页查询及基于Spring Security的权限控制等核心模块,采用MVC架构,适合作为毕业设计、实训项目或框架入门练手参考。压缩包共1192个文件,约95.3MB,其中648个class为编译产物,173个jsp负责页面渲染,148个xml承载MyBatis映射与Spring配置,另有51个java源码、65个jar依赖及properties、js、css等资源文件,并附带war包与Maven构建脚本,目录结构完整可直接导入运行。目前已有2207人学习下载。通过研读源码,读者可掌握SpringBoot自动配置、MyBatis的Mapper接口与分页插件用法、JSP动态页面渲染以及前后端数据交互的完整链路,快速理解企业级Java Web项目的分层设计与部署方式。
1. 图书管理系统为什么还在用 JSP:一个被低估的选型真相
如果你最近在招聘网站搜过“Java 开发”,大概率会看到一类岗位要求里写着“熟悉 JSP/Servlet 者优先”。很多人第一反应是:这技术不是早就过时了吗?但现实是,大量中小型内部系统、校园项目、企业后台,仍然跑在 springboot + mybatis + jsp 这套组合上。原因不复杂:开发快、部署简单、前端后端一把梭,一个人就能把整套图书管理系统从建表写到借阅归还。
这套技术栈解决的核心问题是:用最低的学习成本和最少的协作环节,快速交付一个功能完整的 CRUD 系统。图书管理系统恰好是这类需求的典型代表——图书入库、借阅、归还、查询、用户管理,全是标准的数据操作,没有高并发,没有复杂前端交互。适合谁?适合刚接触 Java Web 的开发者、需要快速交作业的学生、以及维护内部工具的一线工程师。你不需要会 Vue 或 React,会写 HTML 和 jQuery 就能把界面跑起来。
2. 环境搭建与项目骨架:从零到能跑的最小闭环
2.1 依赖选型与版本搭配的硬约束
Spring Boot 和 JSP 的整合有一个绕不开的坑:JSP 在 Spring Boot 内嵌 Tomcat 里默认不生效。你必须引入tomcat-embed-jasper,并且打包方式必须是 war,不能是 jar。这是第一个硬约束,很多人在这里翻车。
我一般会锁定一套经过验证的版本组合:Spring Boot 2.7.x + MyBatis Starter 2.3.x + JSP + JSTL。Spring Boot 3.x 对 JSP 的支持更麻烦,因为 Jakarta EE 命名空间变了,JSTL 的 taglib URI 也要跟着改。如果你不是非要用 3.x,建议停在 2.7.x,省去大量排查时间。
<!-- pom.xml 关键依赖 --> <dependencies> <!-- Web 基础,提供内嵌 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- MyBatis 整合,替代原生 JDBC --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <!-- JSP 解析核心,缺这个 JSP 直接变下载 --> <dependency> <groupId>org.apache.tomcat.embed</groupId> <artifactId>tomcat-embed-jasper</artifactId> <scope>provided</scope> </dependency> <!-- JSTL 标签库,用于 JSP 里写循环和判断 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>逻辑说明:tomcat-embed-jasper的 scope 设为 provided,是因为打包成 war 部署到外部 Tomcat 时,容器本身会提供 JSP 解析能力,不需要重复打包。如果你用java -jar跑 war,这个 scope 会导致 JSP 无法解析,此时要改成默认 scope。参数上,MyBatis Starter 2.3.x 对应 Spring Boot 2.7.x,版本错配会报NoSuchMethodError。
2.2 配置文件与目录结构
Spring Boot 的配置文件里,JSP 的路径必须显式指定,否则默认去src/main/resources/templates找,那是 Thymeleaf 的地盘。
# application.yml spring: datasource: url: jdbc:mysql://localhost:3306/library_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mvc: view: prefix: /WEB-INF/jsp/ # JSP 文件存放目录 suffix: .jsp # 视图解析器自动补全后缀 mybatis: mapper-locations: classpath:mapper/*.xml # XML 映射文件位置 type-aliases-package: com.example.library.entity # 实体类包名 configuration: map-underscore-to-camel-case: true # 数据库 snake_case 自动转 Java camelCase目录结构建议这样组织:src/main/webapp/WEB-INF/jsp/放所有 JSP 文件,src/main/java/com/example/library/下分controller、service、mapper、entity四个包,src/main/resources/mapper/放 MyBatis 的 XML 文件。这个结构不是唯一解,但它是被大量项目验证过的、最不容易出路径问题的布局。
注意:
webapp目录在 Spring Boot 的默认 classpath 之外,IDEA 里需要手动标记 Web 资源目录,否则 JSP 不会被编译。
3. 数据层与业务层:MyBatis 映射的三种写法和选型逻辑
3.1 图书与借阅表的结构设计
图书管理系统的核心表就三张:book(图书)、user(用户)、borrow_record(借阅记录)。设计时有两个关键决策:图书的库存字段怎么维护,借阅记录的状态怎么表示。
我一般会在book表里放一个stock字段表示可借数量,借出时减一,归还时加一。同时在borrow_record里用status字段标记状态:0 表示借出未还,1 表示已归还,2 表示逾期。这样查询“当前借出”和“历史记录”只需要改 WHERE 条件,不用联表算。
CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50), stock INT DEFAULT 0, -- 可借库存 total INT DEFAULT 0, -- 总库存 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, book_id BIGINT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, return_time DATETIME, status TINYINT DEFAULT 0, -- 0借出 1归还 2逾期 INDEX idx_user (user_id), INDEX idx_book (book_id) );stock和total分开存是为了处理“图书丢失”或“下架”的情况——总库存不变,可借库存单独调整。索引加在user_id和book_id上,因为借阅查询几乎都是按用户或按图书维度过滤。
3.2 注解方式与 XML 方式的取舍
MyBatis 有两种写法:注解和 XML。简单查询用注解,复杂动态 SQL 用 XML。图书管理系统里,图书列表查询需要支持按书名、作者、ISBN 模糊搜索,这种动态条件用 XML 的<if>标签写起来更清晰。
// BookMapper.java 接口 @Mapper public interface BookMapper { // 注解方式:简单查询 @Select("SELECT * FROM book WHERE id = #{id}") Book findById(Long id); // XML 方式:复杂动态查询 List<Book> searchBooks(@Param("keyword") String keyword, @Param("author") String author); }<!-- BookMapper.xml --> <select id="searchBooks" resultType="Book"> SELECT * FROM book <where> <if test="keyword != null and keyword != ''"> AND (title LIKE CONCAT('%', #{keyword}, '%') OR isbn LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="author != null and author != ''"> AND author LIKE CONCAT('%', #{author}, '%') </if> </where> ORDER BY create_time DESC </select>逻辑说明:<where>标签会自动处理第一个条件前的 AND,避免WHERE AND的语法错误。CONCAT('%', #{keyword}, '%')是 MySQL 的字符串拼接,用#{}而不是${}是为了防止 SQL 注入。参数说明:@Param注解给参数起名,XML 里才能用keyword和author引用。
3.3 借阅业务的事务控制
借书操作涉及两个写操作:插入借阅记录、扣减库存。这两个必须在一个事务里,否则会出现“记录插入了但库存没扣”的数据不一致。
@Service public class BorrowService { @Autowired private BookMapper bookMapper; @Autowired private BorrowRecordMapper recordMapper; @Transactional(rollbackFor = Exception.class) public String borrow(Long userId, Long bookId) { // 先查库存,用行锁防止并发超借 Book book = bookMapper.findByIdForUpdate(bookId); if (book == null || book.getStock() <= 0) { return "库存不足"; } // 扣库存 bookMapper.decreaseStock(bookId); // 插借阅记录 BorrowRecord record = new BorrowRecord(); record.setUserId(userId); record.setBookId(bookId); record.setStatus(0); recordMapper.insert(record); return "借阅成功"; } }<!-- 行锁查询,防止并发下两个请求同时读到 stock=1 --> <select id="findByIdForUpdate" resultType="Book"> SELECT * FROM book WHERE id = #{id} FOR UPDATE </select>@Transactional的rollbackFor = Exception.class是关键参数。默认只回滚RuntimeException,如果抛出受检异常不会回滚,库存扣了但记录没插进去就麻烦了。FOR UPDATE是悲观锁,在并发借同一本书时保证只有一个请求能读到最新的 stock 值。图书管理系统的并发量通常不高,悲观锁足够用,不需要上分布式锁。
4. 控制层与 JSP 视图:把数据渲染到页面的完整链路
4.1 Controller 的参数接收与模型传递
Spring MVC 的 Controller 负责接收请求、调用 Service、把数据塞进 Model、返回视图名。图书列表页需要支持分页和搜索,参数接收方式直接影响代码整洁度。
@Controller @RequestMapping("/book") public class BookController { @Autowired private BookService bookService; @GetMapping("/list") public String list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(required = false) String keyword, Model model) { // 分页查询,每页 10 条 PageInfo<Book> pageInfo = bookService.searchBooks(keyword, page, 10); model.addAttribute("pageInfo", pageInfo); model.addAttribute("keyword", keyword); // 回显搜索词 return "book/list"; // 对应 /WEB-INF/jsp/book/list.jsp } }@RequestParam的defaultValue保证不传 page 时默认第一页,required = false让 keyword 可以为空。model.addAttribute把数据传给 JSP,JSP 里用 EL 表达式${pageInfo.list}取出来。返回的"book/list"会被视图解析器拼成/WEB-INF/jsp/book/list.jsp。
4.2 JSP 页面里 JSTL 的循环与条件渲染
JSP 里不要写 Java 代码块,用 JSTL 标签。这是保持页面可维护性的底线。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>图书列表</title></head> <body> <form action="${pageContext.request.contextPath}/book/list" method="get"> <input type="text" name="keyword" value="${keyword}" placeholder="书名或ISBN"> <button type="submit">搜索</button> </form> <table border="1"> <tr><th>ISBN</th><th>书名</th><th>作者</th><th>库存</th><th>操作</th></tr> <c:forEach items="${pageInfo.list}" var="book"> <tr> <td>${book.isbn}</td> <td>${book.title}</td> <td>${book.author}</td> <td>${book.stock} / ${book.total}</td> <td> <c:if test="${book.stock > 0}"> <a href="${pageContext.request.contextPath}/borrow/${book.id}">借阅</a> </c:if> <c:if test="${book.stock <= 0}"> <span style="color:red;">已借完</span> </c:if> </td> </tr> </c:forEach> </table> <!-- 分页导航 --> <c:if test="${pageInfo.pageNum > 1}"> <a href="?page=${pageInfo.pageNum - 1}&keyword=${keyword}">上一页</a> </c:if> <span>第 ${pageInfo.pageNum} / ${pageInfo.pages} 页</span> <c:if test="${pageInfo.pageNum < pageInfo.pages}"> <a href="?page=${pageInfo.pageNum + 1}&keyword=${keyword}">下一页</a> </c:if> </body> </html><c:forEach>遍历分页数据,<c:if>根据库存决定显示“借阅”链接还是“已借完”。${pageContext.request.contextPath}获取应用上下文路径,避免部署到不同路径时链接失效。分页导航里把 keyword 带上,保证翻页时搜索条件不丢失。
4.3 借阅与归还的请求处理
借阅和归还都是状态变更操作,处理完后应该重定向到列表页,而不是直接返回视图。这是 PRG 模式(Post-Redirect-Get),防止用户刷新页面重复提交。
@GetMapping("/borrow/{bookId}") public String borrow(@PathVariable Long bookId, HttpSession session, RedirectAttributes redirectAttributes) { User user = (User) session.getAttribute("loginUser"); if (user == null) { return "redirect:/login"; } String result = borrowService.borrow(user.getId(), bookId); redirectAttributes.addFlashAttribute("msg", result); return "redirect:/book/list"; }RedirectAttributes的addFlashAttribute把消息存到 session 里,重定向后取出一次就消失,适合提示“借阅成功”或“库存不足”。@PathVariable从 URL 路径里取 bookId,对应/borrow/123这种形式。
5. 避坑与排查:JSP + Spring Boot 整合中最容易翻车的五个点
5.1 JSP 页面直接变成文件下载
现象:浏览器访问接口,没有渲染页面,而是弹出一个下载框,文件名是list.jsp。
原因:缺少tomcat-embed-jasper依赖,或者打包方式不是 war。Spring Boot 内嵌 Tomcat 默认不支持 JSP 解析,必须显式引入 Jasper 引擎。
解决:检查 pom.xml 里是否有tomcat-embed-jasper,并且<packaging>标签的值是war而不是jar。如果用的是 IDEA,还要确认webapp目录被标记为 Web 资源目录。
5.2 404 错误但路径看起来没问题
现象:Controller 返回"book/list",控制台没有报错,但浏览器显示 404。
原因:视图解析器前缀配错了,或者 JSP 文件放错了位置。默认情况下 Spring Boot 去src/main/resources/templates/找视图,JSP 必须放在src/main/webapp/WEB-INF/下并手动配置前缀。
解决:在 application.yml 里确认spring.mvc.view.prefix和suffix的值,确保 JSP 文件实际路径是src/main/webapp/WEB-INF/jsp/book/list.jsp。注意WEB-INF目录下的文件不能通过 URL 直接访问,必须走 Controller 转发。
5.3 MyBatis 查询返回 null 但数据库有数据
现象:Mapper 接口调用后返回 null,或者返回的实体字段全是 null。
原因:map-underscore-to-camel-case没开启,数据库的create_time映射不到 Java 的createTime。或者 XML 里的resultType写成了全限定名但包名拼错。
解决:在 application.yml 的 mybatis 配置里加上map-underscore-to-camel-case: true。如果字段名和列名完全不一致,需要在 XML 里手写<resultMap>做映射。另外检查type-aliases-package是否指向了正确的实体类包。
5.4 事务不生效导致库存扣减异常
现象:借书时库存扣了,但借阅记录没插入,或者反过来。
原因:@Transactional注解的方法不是 public,或者同类内部方法调用绕过了代理。Spring 的事务是基于 AOP 代理的,自调用不会触发事务。
解决:确保@Transactional标注的方法是 public,并且从 Controller 直接调用 Service 的代理对象。如果确实需要自调用,把事务方法拆到另一个 Service 里,或者注入自身代理。
5.5 JSTL 标签不解析直接输出原文
现象:页面上显示<c:forEach>的源码,而不是渲染后的表格。
原因:JSTL 依赖没引入,或者 taglib URI 写错。Spring Boot 2.7 用的是javax.servlet命名空间,JSTL 的 URI 是http://java.sun.com/jsp/jstl/core。如果升级到 Spring Boot 3.x,要换成jakarta.tags.core。
解决:确认 pom.xml 里有javax.servlet:jstl:1.2,JSP 页面头部的 taglib 指令 URI 和版本匹配。如果用的是 Spring Boot 3.x,JSTL 依赖要换成jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api。
6. 分页查询的进阶写法与一个被忽视的索引技巧
分页是图书管理系统里最容易被写烂的地方。很多人直接用LIMIT offset, size,数据量小的时候没问题,一旦借阅记录表到了几十万行,翻到后面几页就会明显变慢。原因是LIMIT 100000, 10会先扫描前 100000 行再丢弃,不是直接跳到第 100000 行。
我一般会用“延迟关联”来优化:先用覆盖索引查出主键,再用主键回表取数据。
-- 优化前:全表扫描后丢弃 SELECT * FROM borrow_record ORDER BY id DESC LIMIT 100000, 10; -- 优化后:先用索引拿主键,再回表 SELECT r.* FROM borrow_record r INNER JOIN ( SELECT id FROM borrow_record ORDER BY id DESC LIMIT 100000, 10 ) AS t ON r.id = t.id;子查询里的SELECT id只走主键索引,不需要回表,扫描成本低。外层再用这 10 个 id 去取完整行,回表次数只有 10 次。这个技巧在借阅记录表上效果明显,因为id是自增主键,索引有序。
另一个容易被忽视的点是联合索引的顺序。借阅记录查询经常按user_id过滤再按borrow_time排序,这时候索引应该建(user_id, borrow_time),而不是单独建两个索引。联合索引的最左前缀原则决定了WHERE user_id = ? ORDER BY borrow_time能直接走索引排序,避免 filesort。
-- 联合索引,覆盖过滤和排序 ALTER TABLE borrow_record ADD INDEX idx_user_time (user_id, borrow_time DESC);验证方法很简单:在 SQL 前面加EXPLAIN,看type是不是ref或range,看Extra里有没有Using filesort。如果有 filesort,说明排序没走索引,需要调整索引顺序或查询写法。
还有一个实际开发中的习惯:分页参数不要直接暴露给前端传 offset。前端传页码 page,后端算 offset = (page - 1) * size,并且对 page 做上限校验。否则有人传 page=999999,数据库直接扫全表,服务就卡住了。我一般会限制最大页码,比如page = Math.min(page, 1000),超出就返回空列表。
最后说一个 JSP 页面上的细节:分页导航不要把所有页码都渲染出来。如果总共 500 页,页面上放 500 个链接,HTML 体积会很大。常见做法是只显示当前页前后 2 页,加上首页和尾页的快捷链接。这个逻辑可以在 JSP 里用<c:forEach begin="..." end="...">控制,也可以在后端算好页码列表再传给 JSP。我倾向于后端算,因为 JSP 里写复杂逻辑容易乱。
这套 springboot + mybatis + jsp 的组合,说到底就是用最直接的方式把数据从数据库搬到页面上。它不时髦,但胜在每一层都透明,出了问题你能顺着 Controller → Service → Mapper → XML → SQL 一路查下去,没有黑匣子。希望帮到你。
本文还有配套的精品资源,点击获取