Spring Boot+MyBatis+JSP图书管理系统实战拆解
简介面向 Java Web 初学者、课程设计与毕业设计实践者的 SpringBoot 图书管理系统完整工程采用 SpringBoot 搭建服务端、MyBatis 完成数据库增删改查与事务管理、JSP 渲染图书列表和详情页面功能覆盖图书信息维护、分类管理、借阅归还、分页查询、登录权限控制与可执行 jar 部署较完整地呈现 MVC 分层、Mapper 映射和前后端数据传递等常见做法。压缩包共 1192 个文件约 95.3MB内含 51 个 java 源文件、173 个 jsp 页面、148 个 xml 映射与配置、65 个 jar 依赖以及 properties、js、css、图片和 war 包、构建脚本等目录按业务与技术层次组织便于导入运行、断点调试和逐层对照阅读。已有 2208 人学习适合据此拆解持久层 SQL 映射、PageHelper 分页、视图渲染与权限校验的实现也能作为二次开发、课程答辩和 Java Web 入门练习的基础项目对掌握 SpringBoot 自动配置、MyBatis XML 映射与 JSP 视图协作有直接参考价值。1. 一套还在用 JSP 的图书管理系统值得拆的地方在哪前阵子接了个二手项目需求方要做图书管理系统代码拉下来一看Spring Boot 起服务MyBatis 管 SQL视图层还老老实实写着 JSP 和 JSTL。第一反应是想推倒重来翻完一遍却觉得不该动——启动、事务、连接池这些 Spring Boot 已经接管MyBatis 的 XML 里 SQL 写得干净JSP 只负责把 List 渲染成表格Controller-Service-Mapper 四层直连没有花活改起来比拆起来便宜。这套 Java 组合适合两类人一类是做课程设计或毕设想找一个结构完整、能跑通的 JavaWeb 项目图书、读者、借阅三张主表把增删改查和事务都覆盖到了另一类是接手存量系统的开发想看一个 Spring Boot MyBatis JSP 的样板搞清楚老技术栈怎么在新容器里活着。后面按工程骨架、数据层、请求链路、本地跑通、排错、二次开发拆该抄的代码直接抄。2. 工程骨架Spring Boot 里塞进 JSP 需要补哪几块拼图JSP 和 Spring Boot 放在一起是这类项目最容易翻车的地方。很多人第一次跑页面直接 404控制台也没有明显报错翻半天以为是 Controller 映射写错了其实是打包方式选错了。Spring Boot 默认打成可执行 jar内部是自定义的嵌套结构Servlet 容器没法从 jar 里把 JSP 找出来编译所以视图层的请求会被静默吞掉。这个黑匣子我踩过一次之后再看这类项目第一眼就是先瞄 pom 里的 packaging。2.1 依赖清单与关键取舍pom 里真正决定这套东西能不能跑的是四项web 依赖、JSP 解析引擎、JSTL 标签库、以及打包相关的容器作用域。代码里的版本号不用死记按本地仓库里能拉到的填关键是别漏项。packagingwar/packaging dependencies !-- Web 基础自带内嵌 Tomcat 与 Spring MVC -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- JSP 解析引擎缺了它 JSP 会被当成静态文件原样吐给浏览器 -- dependency groupIdorg.apache.tomcat.embed/groupId artifactIdtomcat-embed-jasper/artifactId /dependency !-- JSTL 标签库c:forEach、fmt:formatDate 全靠它 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- 外置 Tomcat 部署时内嵌容器由容器提供避免包冲突 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-tomcat/artifactId scopeprovided/scope /dependency !-- 持久层 -- dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency !-- MySQL 8 驱动注意驱动类名带 cj -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependenciespackaging写 war 不是风格问题是 JSP 能否工作的前提。tomcat-embed-jasper负责把.jsp编译成 Servlet没有它日志里只会出现一行无关痛痒的提示jstl的版本和命名空间要跟 Servlet API 对齐Spring Boot 2.x 用javax.servlet升到 3.x 之后整条链路换成jakarta标签库 URI 也跟着变这一步后面排错章还会提。spring-boot-starter-tomcat的provided作用域只在打 war 丢进外部 Tomcat 时才有意义本地用 IDEA 直接跑 main 方法时它照样在。2.2 目录结构webapp 与 resources 的双轨制这套项目的目录有一种“两套标准共存”的味道Java 代码和配置走 Maven 默认的src/main/java、src/main/resources页面走老 Web 工程的src/main/webapp。分清楚这条线找文件就不会乱。src/main/java/com/example/library ├── LibraryApplication.java 启动类 ├── config/WebMvcConfig.java 拦截器、视图配置 ├── interceptor/LoginInterceptor.java 登录校验 ├── controller/ BookController、BorrowController ├── service/ 接口 impl 实现 ├── mapper/ MyBatis Mapper 接口 ├── entity/ Book、Reader、BorrowRecord └── common/ 统一异常、分页结果 src/main/resources ├── application.yml ├── mapper/BookMapper.xml SQL 映射文件 └── static/ css、js、图片 src/main/webapp/WEB-INF/jsp ├── book/list.jsp ├── borrow/record.jsp └── login.jspJSP 放在WEB-INF下面是有意为之这个目录浏览器直接访问不到只能由服务端转发进来等于天然挡住了“绕过 Controller 直接拉页面”的路径。静态资源放resources/staticSpring Boot 会自动映射到根路径如果非要放到webapp里那就要在拦截器里额外放行否则会被登录校验拦下来这也是新人常问的“CSS 加载不出来”的来源之一。2.3 启动类与 application.yml 的关键几行启动类除了常规的SpringBootApplication还有两个细节值得看一眼MapperScan免去每个接口写Mapper的重复劳动继承SpringBootServletInitializer是为了打成 war 丢进外部容器时还能正常启动。SpringBootApplication MapperScan(com.example.library.mapper) // 扫描 Mapper 接口不用逐个加 Mapper public class LibraryApplication extends SpringBootServletInitializer { Override protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) { // 外置 Tomcat 部署时走这里启动 return builder.sources(LibraryApplication.class); } public static void main(String[] args) { SpringApplication.run(LibraryApplication.class, args); } }配置文件里三块内容决定了页面能不能出来视图前缀后缀、数据源、MyBatis 的映射文件位置。server: port: 8080 servlet: context-path: /library # 影响 JSP 里取路径的方式 spring: mvc: view: prefix: /WEB-INF/jsp/ # Controller 返回 book/list 时拼成完整路径 suffix: .jsp datasource: url: jdbc:mysql://localhost:3306/library_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.library.entity configuration: map-underscore-to-camel-case: true # isbn_no 自动映射到 isbnNoprefix和suffix决定了return book/list最终落到哪个文件少写一个斜杠就是 404。context-path一旦设了/libraryJSP 里所有绝对路径都必须用${pageContext.request.contextPath}拼写死/book/list会直接跑飞。map-underscore-to-camel-case打开之后库里available_copies这类下划线字段才能自动对上实体的驼峰属性省掉一堆resultMap。3. 数据层落点MyBatis 动态 SQL 与借阅事务怎么写才不出事图书管理系统的业务复杂度不高但有两处最容易写出 bug一是多条件检索时的动态 SQL二是借书时的库存扣减。前者写不好就是拼接注入和条件失效后者写不好就是超借。这一层基本决定了整个项目能不能拿去演示。3.1 表结构与实体映射三张主表撑起全部业务图书、读者、借阅记录。图书表里同时存总量和可借数量用冗余字段换查询性能这是这类系统最常见的做法。CREATE TABLE book ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(100) NOT NULL, author VARCHAR(50), category_id INT, total_copies INT NOT NULL DEFAULT 0, available_copies INT NOT NULL DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE borrow_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, book_id BIGINT NOT NULL, reader_id BIGINT NOT NULL, borrow_time DATETIME NOT NULL, due_time DATETIME NOT NULL, return_time DATETIME, status TINYINT NOT NULL DEFAULT 0, -- 0 借出 1 已还 2 逾期 INDEX idx_book (book_id), INDEX idx_reader (reader_id) );available_copies和total_copies分开存好处是列表页不用每次去borrow_record里count一遍坏处是两处数据必须靠事务保持一致。实体类用 Lombok 简化字段名跟表字段的驼峰对应关系交给配置项处理这也是我一般不给每张表单独写resultMap的原因除非有复杂关联。3.2 Mapper 接口与动态 SQL检索接口是整个项目里调用最频繁的一个关键词要能匹配书名、作者、ISBN分类要能叠加还要支持分页。接口定义上把参数用Param显式命名XML 里引用才不会出现“参数找不到”的运行时异常。public interface BookMapper { // 条件分页查询offset 由 Service 层算好传进来 ListBook selectByCondition(Param(keyword) String keyword, Param(categoryId) Integer categoryId, Param(offset) int offset, Param(limit) int limit); int countByCondition(Param(keyword) String keyword, Param(categoryId) Integer categoryId); Book selectById(Param(id) Long id); // 条件更新库存不足时返回 0 行 int decreaseAvailable(Param(bookId) Long bookId, Param(num) int num); int increaseAvailable(Param(bookId) Long bookId, Param(num) int num); }对应的 XML 里where标签会自动处理第一个AND避免拼出WHERE AND这种语法错误。select idselectByCondition resultTypeBook SELECT id, isbn, title, author, category_id, total_copies, available_copies FROM book where if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR author LIKE CONCAT(%, #{keyword}, %) OR isbn #{keyword}) /if if testcategoryId ! null AND category_id #{categoryId} /if /where ORDER BY id DESC LIMIT #{offset}, #{limit} /select select idcountByCondition resultTypeint SELECT COUNT(*) FROM book where if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR author LIKE CONCAT(%, #{keyword}, %) OR isbn #{keyword}) /if if testcategoryId ! null AND category_id #{categoryId} /if /where /select这里的#{}是预编译占位符值会走PreparedStatement模糊查询用CONCAT拼而不是${}直接插字符串SQL 注入这条线就断掉了。test表达式里的判空不能省keyword传空串进来如果不判就会拼出LIKE %%虽然结果不差但索引直接失效。LIMIT的两个参数用#{}传数值是可以的前提是类型对得上传字符串会报语法错误。3.3 借书还书事务边界与库存扣减借书这个动作在业务上是“查库存 → 扣库存 → 写借阅记录”三件事必须在一个事务里。按顺序照写会有一个隐蔽问题两个请求同时查到库存为 1都认为可以借最后扣成负数。解决办法是把判断塞进 UPDATE 的 WHERE 里用影响行数当结果。update iddecreaseAvailable UPDATE book SET available_copies available_copies - #{num} WHERE id #{bookId} AND available_copies #{num} /updateService public class BookServiceImpl implements BookService { Autowired private BookMapper bookMapper; Autowired private BorrowRecordMapper borrowRecordMapper; Override Transactional(rollbackFor Exception.class) // 检查型异常也回滚 public void borrow(Long bookId, Long readerId) { Book book bookMapper.selectById(bookId); if (book null) { throw new BizException(图书不存在); } // 条件更新兜底并发下最多一个请求能扣成功 int rows bookMapper.decreaseAvailable(bookId, 1); if (rows 0) { throw new BizException(该书已全部借出); } BorrowRecord record new BorrowRecord(); record.setBookId(bookId); record.setReaderId(readerId); record.setBorrowTime(LocalDateTime.now()); record.setDueTime(LocalDateTime.now().plusDays(30)); // 默认借期 30 天 record.setStatus(0); borrowRecordMapper.insert(record); } }rollbackFor Exception.class是关键参数。Spring 默认只在遇到RuntimeException和Error时回滚BizException如果继承的是Exception而不是RuntimeException扣了库存却没写记录这种脏数据就会留在库里。rows 0主动抛异常是把并发竞争的结果转成事务回滚的信号比先select ... for update轻便得多。还书逻辑同理increaseAvailable加上 borrowing 记录的状态更新两个操作包在同一个Transactional方法里。4. 请求链路从 Controller 到 JSP 页面的一次完整渲染数据层通了之后剩下的问题就是“请求怎么进来、页面怎么拿到数据”。这套项目的分层很直白Controller 接参数、调 Service、往Model里塞数据、返回视图名JSP 用 EL 和 JSTL 把数据摊开。整条链路上没有前后端分离的 JSON 转换调试时反而更好定位问题。4.1 图书列表分页的 Controller 写法分页参数在这个项目里是最容易出错的一块。pageNum用户能改传 0 或者负数进来offset就变成负值MySQL 直接报语法错误。Controller RequestMapping(/book) public class BookController { private static final int PAGE_SIZE 10; Autowired private BookService bookService; GetMapping(/list) public String list(RequestParam(defaultValue ) String keyword, RequestParam(required false) Integer categoryId, RequestParam(defaultValue 1) Integer pageNum, Model model) { // 兜底防止前端传 0 或负数导致 offset 为负 int current Math.max(pageNum, 1); int offset (current - 1) * PAGE_SIZE; ListBook books bookService.page(keyword, categoryId, offset, PAGE_SIZE); int total bookService.count(keyword, categoryId); model.addAttribute(books, books); model.addAttribute(pageNum, current); model.addAttribute(totalPages, (total PAGE_SIZE - 1) / PAGE_SIZE); model.addAttribute(keyword, keyword); model.addAttribute(categoryId, categoryId); return book/list; // 结合前缀后缀落到 /WEB-INF/jsp/book/list.jsp } }defaultValue 1只处理“参数没传”的情况传了0还是能穿进来所以Math.max这行不是冗余。总页数用(total PAGE_SIZE - 1) / PAGE_SIZE向上取整比浮点数再ceil稳妥。Model里的属性名要和 JSP 里 EL 取值名完全一致大小写错了不会报错只会渲染空白这是 JSP 调试里最费眼的一类问题。4.2 JSP JSTL 渲染表格与分页条页面这一层没什么设计含量但标签库的 URI 和 EL 取值方式必须写对否则整个表格是空的。% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % table border1 thead trthISBN/thth书名/thth作者/thth可借/总量/thth操作/th/tr /thead tbody c:forEach items${books} varb tr td${b.isbn}/td td${b.title}/td td${b.author}/td td${b.availableCopies} / ${b.totalCopies}/td td c:choose c:when test${b.availableCopies 0} form action${pageContext.request.contextPath}/borrow/do methodpost input typehidden namebookId value${b.id} button typesubmit借阅/button /form /c:when c:otherwisespan已借完/span/c:otherwise /c:choose /td /tr /c:forEach /tbody /table %-- 分页条保留当前查询条件翻页时不丢失筛选 --% c:if test${totalPages 1} c:forEach begin1 end${totalPages} varp a href${pageContext.request.contextPath}/book/list?pageNum${p}keyword${keyword}${p}/a /c:forEach /c:if${b.availableCopies}走的是实体类的 getter属性名大小写必须和 JavaBean 规范一致。表单的action如果写成/borrow/do在设置了context-path的项目里会跳到一个不存在的路径所以统一用${pageContext.request.contextPath}前缀。分页链接里把keyword带上翻页才不会退回全量列表这个细节在实际演示时特别容易被问到。4.3 登录拦截器与会话管理这类系统权限不复杂一个基于 Session 的拦截器就够用没必要上完整的安全框架。public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { Object user request.getSession().getAttribute(loginUser); if (user null) { // 未登录跳登录页注意带 contextPath response.sendRedirect(request.getContextPath() /login.jsp); return false; } return true; } }注册拦截器时放行路径要一次写全漏掉静态资源目录页面就会变成一串没有样式的裸 HTML。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /login.jsp, /css/**, /js/**, /images/**); } }addPathPatterns(/**)会把静态资源和 JSP 也纳入拦截范围excludePathPatterns必须把登录接口、登录页、三类静态目录都列出来。登录成功后往 Session 里setAttribute(loginUser, reader)拦截器里取的键名必须一模一样拼错一个字母就是无限重定向到登录页。5. 本地跑通与排错建库、打包、启动阶段最容易翻车的五个点代码能看懂和能跑起来是两码事。这套项目我至少见过三种“启动成功但页面打不开”的情况全卡在配置和打包上。这一章按启动顺序走一遍再把高频故障按“现象 → 原因 → 解决”列清楚照着查基本能定位。5.1 环境准备与建库环境不复杂JDK 8 及以上、Maven、MySQL 5.7 或 8.0IDE 用哪个都行。建库脚本先执行library_db建好之后把第 3 章那两张表的 DDL 跑一遍再插几条测试数据避免启动后列表页空得让人怀疑代码。# 1. 建库 mysql -uroot -p -e CREATE DATABASE library_db DEFAULT CHARSET utf8mb4; # 2. 导入表结构和测试数据 mysql -uroot -p library_db sql/schema.sql mysql -uroot -p library_db sql/data.sql # 3. 启动 mvn spring-boot:runutf8mb4而不是utf8是因为书名里偶尔会有生僻字和特殊符号三字节的utf8存不下会变成乱码。命令里的-p后面不跟密码回车再输避免密码进 shell 历史。5.2 打包方式与部署路径本地用mvn spring-boot:run或者 IDE 直接跑 main 方法都能看到页面因为此时 JSP 能从工程目录读出来。真要交付打包命令和部署位置得对上。# 打 war 包别打 jar mvn clean package -DskipTests # 产物丢进外置 Tomcat cp target/library-0.0.1-SNAPSHOT.war $TOMCAT_HOME/webapps/library.warwar 包名决定了访问路径。丢进去叫library.war访问就是http://localhost:8080/library/这时候application.yml里的context-path就不要再重复设置了否则路径会变成/library/library/book/list。用java -jar跑 war 也可以但别指望打成可执行 jar 之后 JSP 还能工作。5.3 五个高频故障的排查清单现象一所有页面 404控制台没有任何异常。原因打包方式是 jar或者tomcat-embed-jasper没引进来JSP 请求被当成静态资源找不到。解决确认packagingwar/packaging确认依赖里有 jasper然后看日志里有没有 JSP 编译相关的行。现象二浏览器直接下载了一个.jsp文件或者把源码显示出来了。原因JSP 被当成了静态文件返回本质还是解析引擎缺失另一种情况是把 JSP 放在了src/main/resources/static下。解决JSP 统一放src/main/webapp/WEB-INF/jsp并确保视图前缀指向这个目录。现象三列表页中文全是问号数据库里查出来是好的。原因三处编码没对齐。解决JDBC URL 带useUnicodetruecharacterEncodingutf8JSP 页头写pageEncodingUTF-8application.yml里加server.servlet.encoding.charset: UTF-8和force: true三处都齐了才不会乱码。现象四借书失败抛了异常但库存已经扣掉了。原因Transactional没生效。常见三种异常在方法里被catch吞了没往外抛同类内部方法直接this.borrow()调用没走代理方法不是public。解决异常往上抛、跨类调用或注入自身代理、保证public同时确认rollbackFor覆盖到实际抛出的异常类型。现象五接口能返回数据但页面样式全丢F12 里 CSS 请求 302 到登录页。原因拦截器把静态资源也拦了。解决excludePathPatterns里补上/css/**、/js/**、/images/**如果静态资源放在webapp下而不是resources/static路径要按实际目录改。6. 二次开发把借还书扩成预约与逾期体系这套系统的骨架够用真正要动手改的地方通常集中在两个方向业务从“借还”扩到“预约、续借、逾期”技术从“页面直出”往“接口化”走。我的建议是先别急着换视图层把状态和定时任务补上收益更直接。借阅记录现在只有三个状态逾期是靠人眼看due_time判断的。加一个定时任务每天凌晨扫一遍把超期的记录状态改掉顺带算罚金逻辑很轻。Component public class OverdueTask { private static final Logger log LoggerFactory.getLogger(OverdueTask.class); Autowired private BorrowRecordMapper borrowRecordMapper; // 每天凌晨 1 点执行 Scheduled(cron 0 0 1 * * ?) public void markOverdue() { int rows borrowRecordMapper.markOverdue(LocalDateTime.now()); log.info(本次标记逾期记录 {} 条, rows); } }update idmarkOverdue UPDATE borrow_record SET status 2 WHERE status 0 AND due_time lt; #{now} /updateXML 里的必须转义成lt;否则 Mapper 解析阶段直接报错这是改 SQL 时最常见的低级翻车。定时任务要在启动类上加EnableSchedulingcron的六位分别对应秒、分、时、日、月、周。预约功能的改法是把book的库存拆成“在架”和“预约锁定”两个字段读者预约时锁定一本到书后 48 小时内不来取再释放。这里别用synchronized兜并发多实例部署会失效还是走条件更新那套。视图层如果以后要迁 Thymeleaf 或前后端分离最省事的路径是先把 Controller 里的返回改成ResponseBody加统一响应体JSP 保留不动两套并存跑一段时间再逐个页面替换。验证方式我一般用两层Service 层写 JUnit 直接调borrow断言库存和记录数Controller 层用 MockMvc 打接口重点测pageNum0和库存为 0 这两个边界。改完一个模块就跑一遍比上线后靠用户反馈强得多。从那以后我每次接手这类项目都先把packaging、视图前缀、拦截器放行清单这三样过一遍再谈改需求省下来的时间比写代码多。希望帮到你。本文还有配套的精品资源点击获取