SSM框架整合实战:SpringMVC+Spring+MyBatis图书管理系统搭建与避坑 简介这份基于Spring MVC、Spring、MyBatis整合的图书信息管理系统二项目包定位为Java Web学习者与应届毕业生的SSM实战参考适合课程设计、毕业设计或框架整合练习。压缩包共188个文件约55.89MB内含103个jar依赖库、28个xml配置文件、12个Java源文件、12个class编译文件、7个JSP页面和2个SQL脚本。其中jar包保证依赖环境完整xml与properties负责Spring、MyBatis及数据库连接配置Java源文件承载控制器与业务逻辑JSP页面用于前端展示SQL脚本则提供数据表初始化数据。项目目录清晰便于导入MyEclipse/Eclipse配合Tomcat、MySQL直接运行对照该资源已有1119人学习下载。随包另附docx说明文档对SSM整合流程、图书信息管理中的主要业务功能及增删改查实现思路作了梳理遇到环境配置或功能实现问题时可快速查阅排错是快速上手SSM项目开发和同类管理系统设计的实用资料。1. 项目定位把 SpringMVC、Spring、MyBatis 缝成一个可运行闭环的图书管理系统第一次看到这套 SSM 项目时我第一反应是“怎么又是图书管理”。但把它下载解压、启动 Tomcat 之后体会完全不一样SpringMVC 处理请求、Spring 管理对象和事务、MyBatis 操作 MySQL 这三件事在同一个工程里被安排得明明白白。它不是玩具而是一个结构完整的图书信息管理系统——图书列表分页展示、按书名/作者组合条件查询、新增、编辑、删除、分类筛选数据存在 MySQL视图是 JSP。适用人群非常清晰刚学完框架理论、想要一份能跑通全流程代码的同学以及面试前想快速把 SpringMVC、Spring、MyBatis 之间的关系串成一条链的开发者。这套系统最大的价值不在功能多稀奇而在于它能让你在 WEB-INF、mapper.xml、Spring 配置之间来回跳转时搞清楚每个文件到底在回答哪个问题。2. 工程结构拆解从 web.xml 到 mapper.xml 的调用链长什么样2.1 三层架构在工程目录里的真实落位很多初学者装完 SpringMVC、Spring、MyBatis 三份依赖之后第一个困扰是这些框架到底怎么分活答案其实就藏在目录结构里。这个项目的代码按三层切包controller 包放请求入口service 包放业务逻辑mapper 包放数据库交互entity 包放和表映射的实体类。先看整体目录book-system ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/example/book │ │ │ ├── controller │ │ │ │ └── BookController.java │ │ │ ├── entity │ │ │ │ └── Book.java │ │ │ ├── mapper │ │ │ │ └── BookMapper.java │ │ │ ├── service │ │ │ │ ├── BookService.java │ │ │ │ └── impl │ │ │ │ └── BookServiceImpl.java │ │ │ └── common分页辅助类、工具类 │ │ ├── resources │ │ │ ├── spring │ │ │ │ ├── spring-mvc.xml │ │ │ │ └── spring-mybatis.xml │ │ │ ├── mybatis │ │ │ │ └── mybatis-config.xml │ │ │ └── mapper │ │ │ └── BookMapper.xml │ │ └── webapp │ │ ├── static │ │ ├── WEB-INF │ │ │ ├── web.xml │ │ │ └── views │ │ │ ├── bookList.jsp │ │ │ └── bookEdit.jsp │ └── sql │ └── init.sqlsrc/main/resources 下面有个容易看花眼的点spring 目录放 Spring 容器配置和 SpringMVC 配置mapper 目录放 MyBatis 的 SQL 映射文件mybatis 目录放 MyBatis 全局设置。webapp 里有个容易被忽略的细节JSP 页面放在 WEB-INF/views 下外部没法通过 URL 直接访问只能由 Controller 转发进去——这是 JSP 时代比较标准的做法既防止用户绕过请求直接打开页面也逼着所有入口过一遍 SpringMVC。2.2 web.xmlTomcat 启动时最先碰到的三个入口SSM 工程里最容易让人一头雾水的文件就是 web.xml。看清它只需要抓住三段配置ContextLoaderListener 负责启动 spring-mybatis.xml 根容器CharacterEncodingFilter 处理编码DispatcherServlet 负责把请求分发到 Controller。下面把核心部分抽出来看context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-mybatis.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namedispatcherServlet/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring/spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcherServlet/servlet-name url-pattern//url-pattern /servlet-mappingload-on-startup 设为 1意思是 Tomcat 启动时就初始化 DispatcherServlet而不是等第一个请求来了才加载。把 ContextLoaderListener 和 DispatcherServlet 分别指向两类配置文件是为了形成 Spring 里的“双亲容器”父容器包含 Service、Mapper 这些业务和数据对象子容器包含 Controller。父容器的 bean 对子容器可见子容器里的 bean 不会反向泄漏。这样 Controller 能注入 service而 service 不会依赖 controller分层边界就守住了。2.3 两套 Spring 配置一个管业务对象一个管 Web 请求理解 SSM 绕不开 spring-mybatis.xml 与 spring-mvc.xml 的职责划分。前者管数据源、SqlSessionFactory、Mapper 扫描、事务管理器后者管组件扫描 controller、开启注解驱动、配置视图解析器。先看 spring-mybatis.xmlcontext:component-scan base-packagecom.example.book context:exclude-filter typeannotation expressionorg.springframework.stereotype.Controller/ /context:component-scan bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/book_db?useUnicodetrueamp;characterEncodingutf-8/ property nameusername valueroot/ property namepassword value123456/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property nameconfigLocation valueclasspath:mybatis/mybatis-config.xml/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.book.mapper/ /bean bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean tx:annotation-driven/component-scan 里的 exclude-filter 表示扫描 com.example.book 下所有要交给 Spring 管理的对象但把 Controller 排除出去。Controller 交给 spring-mvc.xml 管理剩下的 Service、Mapper 实现都进父容器。dataSource 用 DriverManagerDataSource在中小型项目里最省事跑起来没问题生产环境我会换成 Druid 或 HikariCP但初学阶段没必要让连接池参数先干扰主逻辑。sqlSessionFactory 的 configLocation 指向 MyBatis 全局配置mapperLocations 指定 SQL 映射文件的位置启动时 MyBatis 会把 XML 和 Mapper 接口绑起来。tx:annotation-driven 开启后Service 层写 Transactional 就能生效。再看 spring-mvc.xmlcontext:component-scan base-packagecom.example.book.controller/ mvc:annotation-driven/ bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean mvc:resources mapping/static/** location/static//mvc:annotation-driven 是必须的一行它把处理器映射、参数解析、消息转换这些默认能力全部打开。没有它Controller 上的 RequestMapping 不会生效。视图解析器决定了 Controller return bookList 时SpringMVC 会去找 /WEB-INF/views/bookList.jsp。静态资源映射解决的是 CSS、JS 请求被 DispatcherServlet 拦掉的问题把 /static/ 开头的请求直接交给容器处理。2.4 依赖版本组合SSM 为何容易一换就翻车SSM 项目结构不算复杂但版本搭配是个容易翻车的地方。我的经验是Spring 用 5.3.x 系列spring-webmvc、spring-jdbc、spring-tx 的版本号必须完全一致MyBatis 用 3.5.xmybatis-spring 用 2.0.x数据库驱动换成 mysql-connector-java 8.0.x对应 MySQL 8.0驱动类名写 com.mysql.cj.jdbc.Driver。如果照老博客配 Spring 4 mybatis-spring 1.x MySQL 5 的老驱动在新 JDK 上很容易遇到 UnsupportedClassVersionError。pom 里的依赖大体长这样properties spring.version5.3.x/spring.version mybatis.version3.5.x/mybatis.version /properties dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.x/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.x/version /dependency上面版本里的 x 代表小版本号实际导入项目时换成本地 Maven 仓库能下载到的最新补丁即可。项目自带的 pom.xml 里已经有现成组合如果你是自己搭工程照着这个区间选基本不会踩版本冲突。3. 核心模块实战一次图书查询请求从 Controller 落到 SQL 的完整链路3.1 实体类和 Mapper 接口先定数据形状图书模块最外层的类型是 Book 实体字段和 tb_book 表一一对应。这个项目的字段命名用了驼峰映射也就是 bookName 对应 book_namemybatis-config.xml 里打开 mapUnderscoreToCamelCase 后查询返回的列会自动映射到实体属性不需要给每个字段写 resultMap。public class Book { private Integer id; private String bookName; private String author; private String publisher; private Double price; private String category; // getter、setter 省略可以用 Lombok 的 Data 替代 }BookMapper 是数据访问接口直接放操作数据库的方法声明。它的方法名要和 BookMapper.xml 里的 SQL id 一一对应MyBatis 运行时通过 JDK 动态代理帮你实现接口public interface BookMapper { ListBook selectByCondition(MapString, Object params); int countByCondition(MapString, Object params); Book selectById(Integer id); int insert(Book book); int update(Book book); int deleteById(Integer id); }Mapper 接口没有实现类SQL 全在 XML 里。MyBatis 启动时会通过 spring-mybatis.xml 里 MapperScannerConfigurer 的 basePackage 扫描接口再把 resources/mapper/BookMapper.xml 关联进来。如果接口方法找不到对应 SQL就会抛 Invalid bound statement这是后面避坑那节第一个要讲的问题。3.2 Controller 层参数绑定、分页参数和视图转发Controller 是请求链路的第一站。图书列表页需要接收当前页、每页大小、书名和作者筛选条件SpringMVC 的 RequestParam 可以给 pageNo 设默认值Book condition 会把同名请求参数直接绑定到实体对象Controller RequestMapping(/book) public class BookController { private final BookService bookService; Autowired public BookController(BookService bookService) { this.bookService bookService; } RequestMapping(/list) public String list( RequestParam(value pageNo, defaultValue 1) Integer pageNo, RequestParam(value pageSize, defaultValue 10) Integer pageSize, Book condition, Model model) { MapString, Object params new HashMap(); params.put(pageNo, pageNo); params.put(pageSize, pageSize); params.put(bookName, condition.getBookName()); params.put(author, condition.getAuthor()); ListBook bookList bookService.queryBookList(params); int total bookService.countBook(params); model.addAttribute(bookList, bookList); model.addAttribute(totalPages, (int) Math.ceil(total * 1.0 / pageSize)); model.addAttribute(currentPage, pageNo); model.addAttribute(condition, condition); return bookList; } }这里把分页拆成“列表查询”和“总数统计”两个动作后端算好 totalPages 再放进 ModelJSP 就能直接生成页码链接。Controller 不做业务计算只负责收集参数、调 service、把结果放进 Model然后返回逻辑视图名。视图解析器拿到 bookList 会拼出 /WEB-INF/views/bookList.jsp。3.3 Service 层事务注解和分页偏移量的计算Service 层是业务规则容器也是 Spring 事务的边界。查询方法标 Transactional(readOnly true)增删改标 Transactional只要方法里涉及多个 mapper 操作事务就由这里统一开启和回滚MyBatis 自己管不了多表写操作的原子性。Service public class BookServiceImpl implements BookService { Autowired private BookMapper bookMapper; Override Transactional(readOnly true) public ListBook queryBookList(MapString, Object params) { int pageNo (int) params.get(pageNo); int pageSize (int) params.get(pageSize); params.put(offset, (pageNo - 1) * pageSize); return bookMapper.selectByCondition(params); } Override Transactional(readOnly true) public int countBook(MapString, Object params) { return bookMapper.countByCondition(params); } Override Transactional public boolean addBook(Book book) { if (book.getBookName() null || book.getBookName().trim().isEmpty()) { return false; } return bookMapper.insert(book) 0; } }分页上最容易犯的错是把 pageNo 直接传到 SQL再在 LIMIT 里做计算。我习惯在 Service 里先把 pageNo、pageSize 换算成 offset再交给 mapperSQL 只认 offset 和 pageSize。这样以后换分页组件SQL 不需要动。业务校验也放在这层addBook 里判断书名为空就直接返回 falseController 再根据返回值决定跳成功页还是失败页。3.4 mapper.xml 动态 SQL组合查询和分页的实现重点MyBatis 容易出彩的地方在 mapper.xml。列表页“书名模糊查 作者模糊查 分类精确匹配”是典型的动态 SQL 场景where 标签配合 if 标签能覆盖空与非空的组合。分页用 MySQL 的 LIMIT 加 OFFSETmapper namespacecom.example.book.mapper.BookMapper select idselectByCondition resultTypecom.example.book.entity.Book SELECT id, book_name, author, publisher, price, category, create_time FROM tb_book where if testbookName ! null and bookName ! AND book_name LIKE CONCAT(%, #{bookName}, %) /if if testauthor ! null and author ! AND author LIKE CONCAT(%, #{author}, %) /if if testcategory ! null and category ! AND category #{category} /if /where ORDER BY id LIMIT #{pageSize} OFFSET #{offset} /select select idcountByCondition resultTypeint SELECT COUNT(1) FROM tb_book where if testbookName ! null and bookName ! AND book_name LIKE CONCAT(%, #{bookName}, %) /if if testauthor ! null and author ! AND author LIKE CONCAT(%, #{author}, %) /if if testcategory ! null and category ! AND category #{category} /if /where /select /mapper几个关键点where 标签能自动处理首个子条件多余的 AND只有 category 有值时拼出来是 WHERE category ?不会出现 WHERE AND category ?。if 标签里判断的是 Map 的 key所以 Service 层传参时 key 不能写错。LIKE 用 CONCAT(%, #{bookName}, %)不要直接拼 %${bookName}%后者存在 SQL 注入风险条件查询这块很多教程会顺手埋一个雷。count 查询和列表查询的条件必须一致否则分页总数和列表对不上。3.5 JSP 视图c:forEach 渲染表格和操作链接最后落到 JSP。Controller 已经把 bookList、totalPages、currentPage 放进 ModelJSP 用 JSTL 标签渲染表格。操作列里的删除链接加一个 confirm 弹窗防止误点% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table tr th编号/thth书名/thth作者/thth出版社/thth价格/thth操作/th /tr c:forEach items${bookList} varbook tr td${book.id}/td td${book.bookName}/td td${book.author}/td td${book.publisher}/td td${book.price}/td td a href${pageContext.request.contextPath}/book/toEdit?id${book.id}编辑/a a href${pageContext.request.contextPath}/book/delete?id${book.id} onclickreturn confirm(确定删除该图书吗)删除/a /td /tr /c:forEach /tablepageContext.request.contextPath 拿到的是应用根路径避免项目部署成其他名字时链接 404。到这一步一条“列表页 → Controller → Service → mapper.xml → MySQL → JSP”的完整链路就通了。编辑和删除只是换方法名和 SQL整体结构完全一致照着这条链往下加功能就行。4. 本地跑通与避坑从 MySQL 初始化到分页列表刷出来的操作路径4.1 环境要求与建库脚本跑这个 SSM 工程需要 JDK 8 及以上、Maven 3.6、Tomcat 8.5 或 9、MySQL 5.7 或 8.0。项目资源里有 sql/init.sql我按 MySQL 8 的语法写好直接导入即可。如果本地是 MySQL 5.7字符集相关内容也兼容差别不大DROP TABLE IF EXISTS tb_book; CREATE TABLE tb_book ( id INT PRIMARY KEY AUTO_INCREMENT, book_name VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(100), price DECIMAL(10,2), category VARCHAR(30), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO tb_book (book_name, author, publisher, price, category) VALUES (后端开发实践, 示例作者A, 示例出版社, 79.00, 计算机), (数据库系统原理, 示例作者B, 示例出版社, 99.00, 计算机), (分布式系统入门, 示例作者C, 示例出版社, 45.00, 计算机);价格字段我用了 DECIMAL不要用 FLOAT 或 DOUBLE 存金额不然价格会有一堆浮点尾巴。create_time 用数据库默认值Java 代码里新增图书时不用手动塞时间。建库命令mysql -uroot -p src/main/resources/sql/init.sql如果脚本路径和工程分离也可以先登录 MySQL 再 sourcemysql -uroot -p source /你的路径/init.sql;Windows 下 source 路径不要用反斜杠容易报文件找不到。4.2 修改配置、打包、部署到 Tomcat数据库就绪后需要改 spring-mybatis.xml 里三样东西url 后面的库名、username、password。url 里的 useUnicodetrue 和 characterEncodingutf-8 保持不变这是 MySQL 中文编码的后悔药删掉之后乱码会找上门。IDEA 里跑最省事步骤就几步用 IDEA 打开 pom.xml等 Maven 依赖下载完成。在 Project Structure 的 Artifacts 里确认 war exploded 已生成。配置 Tomcat ServerDeployment 里添加这个工程Application context 设为 /book。启动后访问 http://localhost:8080/book/book/list。其中第二个 book 是 Controller 的 RequestMapping 前缀。不用 IDEA 的话命令行也能跑mvn clean package -DskipTests cp target/book-system.war $CATALINA_HOME/webapps/ $CATALINA_HOME/bin/startup.shTomcat 会自动解压 war访问地址变成 http://localhost:8080/book-system/book/list。如果 context 路径变了JSP 里的 pageContext.request.contextPath 会自动适配静态资源不会 404。4.3 避坑SSM 启动阶段最常见的五个问题坑一项目启动就报 Invalid bound statementmapper 方法找不到 SQL。现象是控制台抛 org.apache.ibatis.binding.BindingException提示某个方法找不到。原因是 MapperScannerConfigurer 的 basePackage 写错或者 mapperLocations 没匹配到 XML也可能是 XML 的 namespace 和接口全限定名不一致。解决先核对 spring-mybatis.xml 里 basePackage 是否等于接口所在包再打开 BookMapper.xml 确认根节点 namespace 写的是 com.example.book.mapper.BookMapper 完整类名。坑二Tomcat 启动时报 Access denied for user rootlocalhost连接池创建失败。原因很直接数据库密码填错或者 MySQL 8 的 root 账户默认走 caching_sha2_password 认证而驱动版本太旧无法认证。解决先在命令行用 mysql -uroot -p 验证能否登录再把驱动升到 mysql-connector-java 8.0.xdriverClassName 同步写成 com.mysql.cj.jdbc.Driver。坑三页面传中文条件查询结果是乱码或查不到数据。原因分两段POST 请求体乱码是 web.xml 的 CharacterEncodingFilter 没生效GET 请求参数乱码是 Tomcat 按 ISO-8859-1 解析查询串。解决web.xml 里确认 encodingFilter 对 /* 生效GET 乱码还要去 Tomcat 的 server.xml给 Connector 加 URIEncodingUTF-8同时检查 spring-mybatis.xml 的 url 里 characterEncodingutf-8 和表字符集一致。坑四列表页能开但点“编辑”链接跳 404。原因是浏览器地址栏发的是 GET 请求Controller 里 toEdit 方法如果只写了 PostMapping就匹配不上。解决把方法注解改成 RequestMapping(value /toEdit, method RequestMethod.GET)确认视图解析器拼出的 JSP 路径真实存在就能定位。坑五Tomcat 报 UnsupportedClassVersionError或者 Spring 的 NoSuchMethodError。原因是编译用的 JDK 版本和运行 Tomcat 的 JDK 版本不一致或者 Spring 相关包版本号没对齐。解决把 IDEA 的 Maven JRE 设置成和 Tomcat 一致的 JDK检查 pom 里 spring-webmvc、spring-jdbc、spring-tx 的版本号必须完全相同mybatis-spring 和 mybatis 的版本对应关系也要固定。这些问题我基本都踩过一遍按上面方式排查通常几分钟到半小时就能定位。最后一条经验看到异常先看完整堆栈的第一行业务异常不要反复重启瞎试。5. 二次开发思路从图书 CRUD 扩到借阅业务要动哪几层借阅是图书管理最自然的扩展给这个系统增加“借阅记录”模块动作就是完全顺着第 3 章的链路补一层。先在数据库加一张 tb_borrow 表包含 borrow_id、book_id、reader_name、borrow_time、return_time表之间不建物理外键用逻辑关联就行。然后新建 BorrowRecord 实体和 BorrowRecordMapper 接口在 XML 里放一段 join 查询把书名带出来public interface BorrowRecordMapper { ListBorrowRecord selectJoinBook(MapString, Object params); int insert(BorrowRecord borrowRecord); }select idselectJoinBook resultTypecom.example.book.entity.BorrowRecord SELECT b.borrow_id, b.book_id, bk.book_name, b.reader_name, b.borrow_time, b.return_time FROM tb_borrow b LEFT JOIN tb_book bk ON b.book_id bk.id ORDER BY b.borrow_time DESC /selectBorrowRecord 实体里要加一个字符串字段 bookName 来接 join 出来的书名这就是最简单的 VO 写法。Service 层同样加 TransactionalController 增加 /borrow/list 方法JSP 复制 bookList 改成借阅展示。新增借阅表单就提交到 /borrow/add逻辑和 addBook 完全对称。如果想把列表接口改成给前端 Ajax 用只需要在 Controller 加一个返回 JSON 的入口。保留原来的 JSP 跳转同时增加RequestMapping(/listJson) ResponseBody public MapString, Object listJson(RequestParam Integer pageNo, RequestParam Integer pageSize) { MapString, Object result new HashMap(); MapString, Object params new HashMap(); params.put(pageNo, pageNo); params.put(pageSize, pageSize); result.put(code, 0); result.put(count, bookService.countBook(params)); result.put(data, bookService.queryBookList(params)); return result; }这个 Map 会被 Jackson 序列化成 JSON前端表格组件直接消费以后想接分页插件或者换成表格组件都容易。ResponseBody 的作用是关键SpringMVC 不再走视图解析器而是把返回值直接写回响应体。需要提醒的是不要看到新需求就埋头写代码先把“建表 → 实体 → Mapper → XML → Service → Controller → 页面”这条链走一遍确认和现有模块一一映射再动手。我之前给类似系统加借阅排行时因为跳过了实体设计直接把统计 SQL 塞进 BookMapper导致 Book 查出来的行和排行榜对象对不上返工花了一个下午。从那以后我每次加模块都强制把所有层走一遍宁可多写一个 VO 类也不偷懒。希望帮到你。本文还有配套的精品资源点击获取