SSM整合分页实战:Spring+SpringMVC+MyBatis与PageHelper详解
简介面向Java Web初学者与SSM整合实践者这份资源完整演示如何将Spring、Spring MVC与MyBatis集成并实现一个带分页功能的具体应用。压缩包共116个文件、约22.37MB主要包含33个jar依赖、30个xml配置文件、10个class编译产物及java源码同时配有前端页面所需的html、css、js与图片资源以及IDE相关配置文件可支撑完整项目的导入与二次开发方便查看运行效果。内容从分页参数接收、基于MyBatis动态SQL的分页查询、Page分页对象封装到Controller数据转换与前端无刷新AJAX交互均有完整呈现并包含RESTful接口设计、依赖注入、事件监听等SSM整合关键知识帮助读者串联起一个分页模块从后端到前端的完整实现链路。资源中Controller、Service、Mapper分层清晰目录结构便于定位源码、配置与依赖可直接导入IDE运行调试。已有120人学习下载适合希望快速掌握SSM整合套路、落地分页模块的开发者参考也可作为Java Web课程设计与毕业设计的分页功能实现范例。1. 项目整体思路拆解SSM整合到底在整合什么先把这个项目说透。SSM不是三个框架的简单堆叠而是 Spring、SpringMVC、MyBatis 三个框架各司其职、通过约定和配置串联起来的一条完整请求链路。很多初学者写SSM项目配置文件一抄一大把结果启动时报错、请求404、数据查不出来根本原因是没搞懂这三层各自管什么活。在这套体系里Spring 是容器负责管理所有对象的创建和依赖关系也就是 IoC 控制反转和 AOP 面向切面编程那一套。SpringMVC 是表现层框架接收浏览器请求、调用业务层、把数据渲染到视图。MyBatis 是持久层框架封装 JDBC 操作把 SQL 语句和 Java 方法的映射关系打理得井井有条。三者整合的本质就是解决两个核心问题Spring 容器如何管理 SpringMVC 的 Controller 和 MyBatis 的 Mapper请求从浏览器发出来之后如何按照 Controller - Service - Mapper - 数据库的顺序完整走通。分页功能则是这个整合项目的点睛之笔。听起来只是每页显示10条数据但真正落地时涉及一个核心原理数据库分页永远不要全表查出来再内存截取而是直接在 SQL 层面用 LIMIT 语句限制返回行数。MySQL 的 LIMIT 语法是LIMIT offset, sizeoffset 是跳过的行数size 是返回的行数比如查询第3页每页10条就是LIMIT 20, 10。这个简单的机制搭配 MyBatis 的拦截器机制就能实现一个自动化程度很高的分页插件也就是后面会讲到的 PageHelper。这个项目适合谁看适合已经有 Java 基础和 MySQL 基础、想搞懂 Java Web 服务端开发全流程的读者。如果你只是会用 Spring Boot 点几下生成项目却不知道底层 SpringMVC 和 MyBatis 是怎么配置出来的这篇文章同样值得一读因为 Spring Boot 的本质就是 SSM 的自动配置封装。2. 分页方案选型为什么我用 PageHelper 而不是手写 LIMIT先交代一个设计决策。SSM 项目里实现分页有两条路一是每个 Mapper 接口里手写带有 LIMIT 的 SQL自己算 offset 和 totalPages二是引入 PageHelper 这样的分页插件通过 AOP 拦截器机制自动改写 SQL自动查询总记录数。我最终选了 PageHelper理由是它能显著减少重复代码同时不牺牲可控性。手写 LIMIT 的方案不是不能用但痛点很明显。比如用户列表、订单列表、日志列表都需要分页每个 Mapper 里都要写一遍LIMIT #{offset}, #{size}Controller 里都要重新计算页码、手动查询 total 并封装返回结构。项目一大了这套重复劳动会让人暴躁还容易出现计算错误这类低级 bug。更麻烦的是如果某个列表后来要增加复杂的筛选条件手写分页的 SQL 就得跟着大改。PageHelper 的原理一句话就能说清它实现了 MyBatis 的 Interceptor 接口在执行 Executor 的 query 方法之前拦截 SQL通过 Page 对象里携带的页码和每页条数自动改写原 SQL 为分页查询 SQL同时生成一条 COUNT 查询语句来获取总条数。这对业务代码的侵入性极小你只需要在 Service 层调用PageHelper.startPage(pageNum, pageSize)紧接着执行一条 Mapper 查询返回的结果就被自动分页了。当然PageHelper 也有一个使用禁忌很多人踩过坑startPage方法必须在查询语句之前紧挨着调用中间不能穿插任何其他 SQL 操作。这一点我后面在常见问题里会详细展开。选型的时候还要确认版本兼容性我这次用的是 PageHelper 5.3.x搭配 MyBatis 3.5.x实测稳定。提示如果项目对分页 SQL 有非常特殊的定制需求比如需要强制走某个索引、需要自定义 count 查询逻辑手写 LIMIT 反而更直接。技术选型没有绝对的优劣关键看场景复杂度。3. SSM 整合实操从零搭建一个可运行的工程骨架3.1 目录结构和 Maven 依赖配置我习惯用 Maven 来管理这个项目目录结构采用标准的 Java Web 分层src/main/java放源码src/main/resources放配置文件和 Mapper XMLsrc/main/webapp放前端页面和静态资源。Java 包结构按controller、service、mapper、entity、common划分清晰直观。pom.xml里最核心的是依赖版本不能乱配。Spring 用 5.xSpringMVC 跟随 Spring 版本一致MyBatis 用 3.5.xmybatis-spring 桥接包用 2.x。数据库驱动用 MySQL Connector/J 8.x连接池选 DruidJSON 处理用 Jackson。这里有一个实操建议所有版本号统一用properties声明避免依赖冲突时到处找版本。properties spring.version5.3.20/spring.version mybatis.version3.5.11/mybatis.version /properties dependencies !-- Spring核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency !-- SpringMVC -- dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency !-- MyBatis -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version${mybatis.version}/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency !-- 分页插件 -- dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper/artifactId version5.3.2/version /dependency !-- 数据库连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.15/version /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.28/version /dependency /dependencies3.2 Spring 容器的两个关键配置类SSM 整合不走 Spring Boot 的自动配置一切都需要手动通过配置文件或配置类加载。我习惯把配置拆成两个 Spring 容器这是很多新手搞不清的地方。第一个是 Root 容器监听器加载负责 Service 层、MyBatis Mapper、数据源这些底层组件。第二个是 SpringMVC 容器DispatcherServlet 加载只负责 Controller 层的扫描。为什么要拆因为 Controller 可能需要注入 Service但 Service 不需要知道 Controller 的存在。拆开之后事务管理、AOP 切面都放在 Root 容器里职责更清晰。Root 容器的注解配置类核心内容如下Configuration ComponentScan(basePackages com.example.ssm, excludeFilters ComponentScan.Filter( type FilterType.ANNOTATION, classes Controller.class)) public class RootConfig { }而 SpringMVC 容器只扫描 Controller。为了避免两个容器扫描范围重叠我特意排除了 Controller 注解。这个细节如果不注意会出现 Service 被初始化两份、事务不生效等诡异问题。3.3 SpringMVC 配置和 web.xml 初始化SpringMVC 侧最重要的三件事开启注解驱动、配置 JSP 视图解析器、约定静态资源放行。注解驱动是让RequestMapping、RequestBody、ResponseBody这些注解生效的前提。web.xml 里需要注册 Spring 的 ContextLoaderListener 和 DispatcherServlet并且 DispatcherServlet 要配置映射规则。我通常映射为/让所有请求都进入 SpringMVC 处理。这样做的代价是静态资源CSS、JS、图片需要额外的资源映射配置但换来的是 RESTful 风格的 URL 支持。!-- Spring根容器 -- listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener !-- SpringMVC前端控制器 -- servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextClass/param-name param-valueorg.springframework.web.context.support.AnnotationConfigWebApplicationContext/param-value /init-param init-param param-namecontextConfigLocation/param-name param-valuecom.example.ssm.config.WebConfig/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mapping3.4 MyBatis 整合SqlSessionFactory 与 Mapper 扫描MyBatis 整合进 Spring 的关键是 SqlSessionFactoryBean。它负责把数据源、Mapper XML 文件位置、类型别名、分页插件这些配置打包起来最终交给 Spring 管理。这一步最容易出问题的点是 Mapper 扫描。我在配置类上加了MapperScan(com.example.ssm.mapper)注解让 Spring 自动为所有 Mapper 接口生成代理对象。如果你不用这个注解就得一个个手动注册非常痛苦。另外MyBatis 的configuration.addInterceptor(new PageInterceptor())就是在这里注册的别漏掉。Configuration MapperScan(com.example.ssm.mapper) public class MyBatisConfig { Bean public DataSource dataSource() { DruidDataSource ds new DruidDataSource(); ds.setDriverClassName(com.mysql.cj.jdbc.Driver); ds.setUrl(jdbc:mysql://localhost:3306/ssm_demo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai); ds.setUsername(root); ds.setPassword(123456); return ds; } Bean public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setTypeAliasesPackage(com.example.ssm.entity); // 注册分页插件 PageInterceptor interceptor new PageInterceptor(); Properties props new Properties(); props.setProperty(helperDialect, mysql); props.setProperty(reasonable, true); interceptor.setProperties(props); factoryBean.setPlugins(new Interceptor[]{interceptor}); return factoryBean; } }4. 分页功能的核心实现从 Controller 到 Mapper 的完整链路4.1 前端页面和分页参数的传递规则分页功能的触发点在前端。我在 user/list.jsp 页面上放了一个用户列表表格底部是分页导航条每页显示10条数据。前端需要传给后端的参数有两个pageNum当前页码、pageSize每页条数。URL 设计成/user/list?pageNum1pageSize10。前端的核心逻辑就是拼接 URL 并跳转。点击页码时根据当前 searchKeyword 等搜索条件拼出新的查询链接。这里有一个实用技巧如果分页导航还希望保留排序字段就在 URL 参数里一起带上服务端从 request 参数中取出来回填到查询条件里。分页不只是翻页它和搜索、排序天然绑定在一起否则一翻页搜索条件就丢了那是很蠢的交互。4.2 Controller 层接收参数并封装统一分页结构Controller 是整个链路的入口。我在这个方法里接收 pageNum 和 pageSize顺便做了参数校验。pageNum 没传时默认是1pageSize 没传时默认是10同时用Math.max和Math.min限制 pageSize 在 1~100 之间避免有人恶意传一个特别大的数值把数据库打崩。分页结果的统一结构我定义了一个PageResultT类包含 total、list、pageNum、pageSize、pages 五个字段。total 是总记录数pages 是总页数这两个值通过PageInfo对象获取。为什么要单独封装这个结构因为前端分页组件需要的字段是固定的如果不统一结构每个接口返回格式都不一样前端就得为每个接口写不同的解析逻辑。Controller RequestMapping(/user) public class UserController { Autowired private UserService userService; RequestMapping(/list) public String list(RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, Model model) { PageResultUser page userService.pageQuery(pageNum, pageSize); model.addAttribute(page, page); return user/list; } }4.3 Service 层PageHelper 的用法和几条铁律分页的核心魔法发生在 Service 层。我在 UserServiceImpl 里写了一个 pageQuery 方法调用PageHelper.startPage(pageNum, pageSize)之后紧接着执行userMapper.selectUserList()查询返回的是一个PageUser对象。这段代码只有三步但每一步都有讲究。startPage返回的是 Page 对象本质上就是一个 ThreadLocal 变量MyBatis 拦截器在执行 SQL 前会去检查这个 ThreadLocal 里有没有页码信息有的话就改写 SQL。所以它必须作用于线程的整个 SQL 执行链路而且必须是紧挨着下一个查询。如果你在 startPage 和 select 之间调用了其他 Mapper 查询这个分页参数会被错误的查询消费掉导致目标列表没有分页。Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override public PageResultUser pageQuery(Integer pageNum, Integer pageSize) { PageHelper.startPage(pageNum, pageSize); ListUser userList userMapper.selectUserList(); PageInfoUser pageInfo new PageInfo(userList); return new PageResult(pageInfo); } }关键点是SELECT * FROM user这条语句本身不需要写 LIMITPageHelper 会自动补上。而且它还会自动执行一条SELECT COUNT(*)来获取总条数然后把数据封装成 PageInfo。4.4 Mapper 层XML 中查询条件的动态拼装虽然分页 SQL 交给 PageHelper 自动生成但列表查询本身往往需要支持模糊搜索、状态筛选等条件。我在 UserMapper.xml 里用了where标签动态拼装条件这样既能复用又不会出现多余的 AND 导致 SQL 语法错误。select idselectUserList resultTypecom.example.ssm.entity.User SELECT id, username, email, create_time FROM user where if testkeyword ! null and keyword ! AND username LIKE CONCAT(%, #{keyword}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select注意这里没有写 LIMIT。很多人在 Mapper XML 里手动写上LIMIT #{offset}, #{size}然后发现 PageHelper 不生效原因就是自己写的 LIMIT 会干扰插件对 SQL 的解析。分页这件事应该完全交给 PageHelper 处理业务 SQL 只负责查当前条件下的所有数据。提示分页查询的时候ORDER BY 要写清楚否则分页结果翻几页之后数据会乱序甚至重复。MySQL 在不指定排序规则时返回顺序是不保证的这个坑很隐蔽。4.5 JSP 页面上如何渲染分页导航栏后端把 PageResult 放进 Model 之后JSP 页面通过 JSTL 标签库渲染。我在列表底部写了一个通用的分页导航片段逻辑是如果当前页大于1显示上一页遍历所有页码当前页高亮如果当前页小于总页数显示下一页。页码遍历我使用了一个小技巧从 firstPage 到 lastPage 只显示当前页前后两页的页码避免总页数很多时导航栏过长。这个逻辑放在 EL 表达式里做会比较啰嗦所以我提前在 Controller 里计算出 startNavPage 和 endNavPage存到 Model 中页面只负责输出。c:if test${page.pages 1} div classpagination c:if test${page.pageNum 1} a href/user/list?pageNum${page.pageNum - 1}pageSize${page.pageSize}上一页/a /c:if c:forEach begin${page.startNavPage} end${page.endNavPage} varp c:choose c:when test${p page.pageNum} span classcurrent${p}/span /c:when c:otherwise a href/user/list?pageNum${p}pageSize${page.pageSize}${p}/a /c:otherwise /c:choose /c:forEach c:if test${page.pageNum page.pages} a href/user/list?pageNum${page.pageNum 1}pageSize${page.pageSize}下一页/a /c:if /div /c:if4.6 mybatis-config 中 NOT NULL 的大坑这里要单独提一个我实际踩过的坑mybatis-config.xml 里如果对settings配置不当分页查询结果会出现莫名其妙的行为。比如mapUnderscoreToCamelCase这个配置它决定了数据库下划线字段create_time能否自动映射到 Java 驼峰属性createTime。如果不开启查询结果里 createTime 就是 null而分页列表的很多排序操作又依赖这个字段。SSM 整合从零搭建时我建议显式开启这个配置避免从 Spring Boot 转过来的人不适应。开启方式是在 MyBatis Config 中增加 settings 或通过factoryBean.setConfiguration设置。org.apache.ibatis.session.Configuration config new org.apache.ibatis.session.Configuration(); config.setMapUnderscoreToCamelCase(true); factoryBean.setConfiguration(config);5. 常见问题与排查技巧实录5.1 PageHelper 分页完全没有生效分页失效是出现频率最高的问题。排查思路我总结了一个口诀看调用位置、看执行顺序、看依赖版本。先看依赖确认 pagehelper.jar 真的被引入了再看配置确认 SqlSessionFactory 里注入了 PageInterceptor最后看代码确认 startPage 和 Mapper 调用之间没有其他 SQL 执行。最典型的错误写法是这样的startPage 之后先调用了某个 Service 方法去查询字典表然后再查用户列表。这样 PageHelper 的分页参数被字典表那条 SQL 消费了用户列表自然就分不了页。// 错误写法 PageHelper.startPage(pageNum, pageSize); SysConfig config sysConfigService.queryByKey(name); // 这条查询消费了分页参数 ListUser users userMapper.selectUserList(); // 这里已经没有分页了5.2 分页查询出的总记录数不对这个坑通常出现在多表关联查询场景。PageHelper 自动生成的 COUNT 语句是SELECT COUNT(*) FROM user LEFT JOIN user_role但业务上可能希望按 user 主表去重计数。解决办法有两种第一种是配置countSuffix或用自定义 count 查询第二种是在 SQL 里显式使用DISTINCT让自动生成的 count 也带上 distinct。实际开发中如果列表 SQL 是单表查询或简单的 left joinPageHelper 自动 count 基本不会出错。一旦出现 group by 或者子查询就必须仔细检查 count 结果是否符合预期。5.3 前端页码溢出或翻页时搜索条件丢失页码溢出是指用户手动修改 URL 传入一个大页码比如总共3页传 pageNum100。PageHelper 有个参数我在配置里开了reasonabletrue它的作用就是让查询合法化页码超过总页数时回退到最后一页页码小于1时回退到第一页。这个配置强烈建议开启用户体验和安全性都能兼顾。搜索条件丢失的问题则出在 URL 拼接上。分页导航链接只拼了 pageNum 和 pageSize漏掉了 keyword 等查询条件。解决办法是把当前查询条件作为隐藏字段放在页面表单里或者统一封装一个查询条件对象在分页链接里通过 queryString 回写所有参数。5.4 分页 SQL 打印出来有 LIMIT 但不执行这种看着像生效了但没完全生效的情况十有八九是事务缓存导致的问题。如果 Service 方法上标了TransactionalMyBatis 的一级缓存会缓存查询期间自动提交的 SQL。排查时可以在 mybatis-config 里配置logImpl为 StdOutImpl把执行的 SQL 打印出来对照日志确认 LIMIT 语句是否真实出现了一次。settings setting namelogImpl valueSTDOUT_LOGGING/ /settings6. 扩展思路分页功能还能怎么做得更完善最后聊聊分页功能的进一步演进方向。现在这个项目用的是 PageHelper JSP 的传统方式但如果你进入生产级项目有几个方向值得深入。第一个方向是前后端完全分离。前端用 Vue、React 这类框架后端只返回 JSON 数据分页参数和返回结构都走 RESTful 接口。这时候分页组件通常由前端的 Table 组件自带你只需要把 PageResult 返回给前端即可PageInfo 里的各字段正好对上主流 Table 组件的 props。第二个方向是引入更强大的查询引擎MyBatis-Plus 或 spring-data-jpa。拿 MyBatis-Plus 来说它内置了乐观锁、分页插件且分页逻辑更加面向对象不再需要通过 startPage 这种魔法来触发而是 Service 层直接调用 page 方法代码更直观。第三个方向是性能优化。数据量大的时候COUNT(*)可能成为瓶颈。如果列表数据有几十万行甚至上百万行可以结合业务场景考虑最多返回前1000条的业务限制或者用延迟计数、缓存计数的方式来优化。分页是一个基础功能但要做到生产可用离不开对业务场景的理解和数据量的预估。我在实际做这个项目的时候还有个感触SSM 的整合过程本身就是一次很好的底层框架复习。你手动配置一次 Root 容器、SpringMVC 容器、MyBatis 会话工厂才会真正理解 Spring Boot 自动配置背后帮你做了什么。如果你到了 Spring Boot 阶段偶尔遇到约定大于配置失效的情况也能从底层原理出发快速定位。所以别看 SSM 现在老派把这一套整合逻辑啃透后面的路会顺很多。本文还有配套的精品资源点击获取