资讯动态

Spring Boot + MyBatis + JSP 图书管理系统实战:从选型到分页优化

发布时间:2026/10/22 10:32:05 来源:尧图企业网站定制
简介这是一套面向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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis 整合替代原生 JDBC -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency !-- JSP 解析核心缺这个 JSP 直接变下载 -- dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId scopeprovided/scope /dependency !-- JSTL 标签库用于 JSP 里写循环和判断 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 方式复杂动态查询 ListBook searchBooks(Param(keyword) String keyword, Param(author) String author); }!-- BookMapper.xml -- select idsearchBooks resultTypeBook SELECT * FROM book where if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR isbn LIKE CONCAT(%, #{keyword}, %)) /if if testauthor ! 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 借阅成功; } }!-- 行锁查询防止并发下两个请求同时读到 stock1 -- select idfindByIdForUpdate resultTypeBook SELECT * FROM book WHERE id #{id} FOR UPDATE /selectTransactional的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 条 PageInfoBook 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把数据传给 JSPJSP 里用 EL 表达式${pageInfo.list}取出来。返回的book/list会被视图解析器拼成/WEB-INF/jsp/book/list.jsp。4.2 JSP 页面里 JSTL 的循环与条件渲染JSP 里不要写 Java 代码块用 JSTL 标签。这是保持页面可维护性的底线。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle图书列表/title/head body form action${pageContext.request.contextPath}/book/list methodget input typetext namekeyword value${keyword} placeholder书名或ISBN button typesubmit搜索/button /form table border1 trthISBN/thth书名/thth作者/thth库存/thth操作/th/tr c:forEach items${pageInfo.list} varbook 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 stylecolor: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 /htmlc: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.2JSP 页面头部的 taglib 指令 URI 和版本匹配。如果用的是 Spring Boot 3.xJSTL 依赖要换成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 做上限校验。否则有人传 page999999数据库直接扫全表服务就卡住了。我一般会限制最大页码比如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 一路查下去没有黑匣子。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑