简介:这是一份面向Java Web开发者的SSM整合项目资源包,以Spring、Spring MVC和MyBatis三框架集成为基础,完整实现员工信息的分页查询与展示功能,适合正在学习框架整合或需要分页实现参考的开发者。压缩包约22.37MB,共116个文件,其中包括33个jar依赖库、30个xml配置与映射文件、5个java源码、10个class文件,以及若干html、css、js和图片素材,工程结构相对完整。目前已有120人学习下载,可作为SSM入门和分页功能的对照实例。资源重点展示了分页的完整调用链:控制层接收页码和每页条数,业务层组装查询条件,持久层通过动态SQL生成分页语句,再用分页对象封装总记录数、总页数及当前页数据返回前端;同时包含AJAX无刷新翻页与REST风格接口的配置参考。导入开发工具后可直接运行调试,也有助于理解SSM项目的分层思想与前后端交互设计。
1. SSM 整合与分页:为什么这套组合值得认真做
做 SSM 整合时,分页功能往往是最能暴露框架理解深浅的一块试金石。某公司的内部系统就是反例:列表查询把全表数据一次性加载到内存里再截取,数据量到几十万条后页面直接卡到几十秒,运维同事看到慢查询日志才知道问题出在应用层“假分页”。SSM 整合不单是把 Spring、SpringMVC、MyBatis 的配置堆在一起,而是要让请求、事务、SQL 映射三者形成一条稳定链路;分页也要真正落到数据库 LIMIT,而不是在应用层做截断。这份笔记从零开始:先搭起一个能跑的 SSM 工程,再实现一个走数据库的分页查询,把参数设置、多表查询、高频故障一次讲清。适合刚做完 SSM 基础入门、正想独立做一个完整功能的开发者。
2. SSM 整合最小骨架:配置文件与三大组件怎么串成一条线
第一步不是写业务,而是把工程跑起来。我习惯先做一个“最小闭环”:建一张表、写一个查询接口、在浏览器里看到数据库里的数据,确认 Spring 容器、SpringMVC、MyBatis 三个组件已经打通。分页是在这条链路跑通之后才加的东西,顺序反了,后面排查问题会非常痛苦。
2.1 pom.xml 依赖:版本组合是这个环节唯一容易翻车的地方
Maven 工程的核心是依赖声明。SSM 整合涉及的依赖不少,但真正决定成败的是三组配套:Spring 与 SpringMVC、MyBatis 与 mybatis-spring、以及后面的分页插件。
<properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- Spring Web MVC,同时引入 spring-context 与 spring-webmvc --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.31</version> </dependency> <!-- 事务与数据库操作需要 spring-jdbc --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.31</version> </dependency> <!-- MyBatis 核心 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.16</version> </dependency> <!-- MyBatis 与 Spring 集成的桥接包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.2</version> </dependency> <!-- MySQL 8.x 驱动 --> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <!-- 分页插件,后续章节会用到 --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.3.3</version> </dependency> <!-- JSON 序列化,返回 @ResponseBody 时必须 --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.15.2</version> </dependency> <!-- Servlet 与 JSP 相关 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>版本这里我踩过坑:把 mybatis-spring 升到 3.x,项目里还是 MyBatis 3.5 的老写法,启动直接报方法找不到。上面这组版本组合是 Java 8 时代很经典的搭配,能稳定运行;如果你所在团队的仓库已经统一维护了其他版本,优先用团队版本,但务必保持 Spring 5.x 对应 mybatis-spring 2.x、Spring 6.x 对应 mybatis-spring 3.x 这种大版本关系。乱配的结果是报错信息千奇百怪,而且很难一眼看出是版本冲突。
2.2 Spring 两个容器各管各的:applicationContext.xml 与 spring-mvc.xml
SSM 整合里有一个极易被忽视的约定:Spring 的根容器和 SpringMVC 的子容器各管各的 Bean。根容器管 Service、Mapper、事务等与 Web 无关的组件;SpringMVC 容器只管 Controller。如果扫描范围重叠,轻则出现重复代理,重则事务注解失效。
先看根容器applicationContext.xml:
<!-- 扫描除 Controller 以外的所有 Bean --> <context:component-scan base-package="com.example.ssm"> <context:exclude-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <!-- 读取数据库配置 --> <context:property-placeholder location="classpath:jdbc.properties" ignore-unresolvable="true"/> <!-- 数据源:演示用 DriverManagerDataSource,生产环境换成 HikariCP 或 Druid --> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean>注意context:exclude-filter的作用是让根容器主动忽略@Controller,避免一个 Bean 被两个容器各创建一次。ignore-unresolvable="true"也很关键:根容器里如果引用了 Web 层才有的配置占位符,不打开这个选项启动时会直接报错。
再写spring-mvc.xml:
<!-- 只扫描 Controller,避免 Service 被 MVC 容器重复管理 --> <context:component-scan base-package="com.example.ssm" use-default-filters="false"> <context:include-filter type="annotation" expression="org.springframework.stereotype.Controller"/> </context:component-scan> <!-- 开启 SpringMVC 注解驱动,支持 @ResponseBody 等 --> <mvc:annotation-driven/> <!-- 静态资源交给默认 Servlet 处理 --> <mvc:default-servlet-handler/> <bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean>两个容器通过com.example.ssm这个基包分开扫描:根容器用exclude-filter排除 Controller,MVC 容器用include-filter只收 Controller。这样 Service 被@Service注册到根容器,Controller 被@Controller注册到子容器,两者通过@Autowired也能正常装配,这正是 SSM 整合最常见的标准做法。
2.3 MyBatis 的 SqlSessionFactory 与 Mapper 扫描的配置顺序
MyBatis 接入 Spring 的核心是SqlSessionFactoryBean。它负责把数据源、Mapper XML 位置、实体别名、分页插件全部收拢到一个工厂里,再由MapperScannerConfigurer把接口批量生成实现类。这块配置顺序不能乱:SqlSessionFactory 必须先定义,Mapper 扫描依赖它会拿到的会话工厂。
<!-- SqlSessionFactory:MyBatis 与 Spring 集成的核心 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="typeAliasesPackage" value="com.example.ssm.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <!-- 分页插件在第 3 章加到这里 --> </bean> <!-- 扫描 Mapper 接口,自动生成代理 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.example.ssm.mapper"/> <property name="sqlSessionFactoryBeanName" value="sqlSessionFactory"/> </bean> <!-- 事务管理器 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>typeAliasesPackage的作用是让 Mapper XML 里写resultType="User"而不是com.example.ssm.entity.User,省去一长串类名。mapperLocations指向classpath:mapper/*.xml,这是 XML 映射文件的存放目录。MapperScannerConfigurer的sqlSessionFactoryBeanName建议用字符串形式而不是ref,可以避免在 Spring 容器初始化早期出现循环引用——这个细节不致命,但用字符串效果更稳。
2.4 从 Controller 到 Mapper 的查询链路:确认整合成功
配置写完,用一张表和一个查询接口验证整条链路。先建表:
CREATE TABLE `sys_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(64) NOT NULL, `nickname` VARCHAR(64), `status` TINYINT DEFAULT 1, `create_time` DATETIME ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;Mapper 接口与 XML 映射:
public interface UserMapper { List<User> selectUsers(@Param("keyword") String keyword); }<mapper namespace="com.example.ssm.mapper.UserMapper"> <select id="selectUsers" resultType="User"> SELECT id, username, nickname, status, create_time FROM sys_user <where> <if test="keyword != null and keyword != ''"> AND (username LIKE CONCAT('%', #{keyword}, '%') OR nickname LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY id DESC </select> </mapper>Service 与 Controller:
@Service public class UserServiceImpl implements UserService { @Autowired private UserMapper userMapper; @Override public List<User> queryUsers(String keyword) { return userMapper.selectUsers(keyword); } }@Controller @RequestMapping("/user") public class UserController { @Autowired private UserService userService; @RequestMapping("/list") public String list(@RequestParam(required = false) String keyword, Model model) { model.addAttribute("users", userService.queryUsers(keyword)); return "user/list"; } }整个请求在 SpringMVC 容器里从 Controller 进去,调用根容器里的 Service,Service 调用 MyBatis 生成的 Mapper 代理,最后通过 SqlSession 执行 XML 里的 SQL。这个链路如果能在浏览器正常看到表格数据,就说明 SSM 整合已经成功,可以把注意力完全放到分页上。如果这里出了问题,优先检查第 5.4 节里列的绑定条件,不要急着往后走。
3. 分页查询实现:引入 PageHelper 与三处必调参数
链路跑通后,分页就是“在查询前加一行代码”的事。但这一行代码背后牵涉到 SQL 改写、参数传递、前端回显三个层面。我见过太多人把分页当黑匣子,出了问题就去翻配置文件,所以我先把原理讲清楚,再给可复现的配置。
3.1 分页的本质:数据库 LIMIT 才是最终答案
分页查询在不同数据库里写法不同,MySQL 用LIMIT offset, pageSize,Oracle 用ROWNUM,SQL Server 用OFFSET ... FETCH。它们的共同点是:由数据库决定每次只取一页数据,而不是先把全部数据查出来再丢弃。内存截断法的性能瓶颈在于全表数据要经过网络传输到应用服务器,还要占用 JVM 堆内存,数据量一大就会把应用拖垮。
SSM 里实现分页有两个路径。第一个是手写 SQL,在 Mapper XML 里写LIMIT #{offset}, #{pageSize},再手写一条SELECT COUNT(*)统计总条数;第二个是用 PageHelper 这类分页插件,它会拦截 MyBatis 执行的 SQL,自动拼出带 LIMIT 的查询,并额外执行一条 count 查询。手写 SQL 的优势是可控,但每个查询都要写两遍,条件一变就要同步改两处;PageHelper 的优势是改动极小,但要求你把参数放对位置。这两个路径我都用过,项目里大量列表查询时,PageHelper 的维护成本明显更低。
3.2 PageHelper 依赖与拦截器配置:helperDialect、reasonable、supportMethodsArguments 三个参数
pom 里已经引入了 pagehelper 依赖,接下来要在SqlSessionFactoryBean里注册PageInterceptor。这里有一个容易被老教程带偏的点:早期版本配置的是PageHelper类,5.x 开始统一改成PageInterceptor,配置方式也从 mybatis-config.xml 挪到了 Spring 配置里。
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="typeAliasesPackage" value="com.example.ssm.entity"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="plugins"> <array> <bean class="com.github.pagehelper.PageInterceptor"> <property name="properties"> <props> <prop key="helperDialect">mysql</prop> <prop key="reasonable">true</prop> <prop key="supportMethodsArguments">true</prop> <prop key="params">pageNum=pageNum;pageSize=pageSize;</prop> </props> </property> </bean> </array> </property> </bean>helperDialect告诉插件数据库方言,写死mysql比写auto更稳。auto虽然会自动识别,但在多数据源或连接池做了代理的情况下偶尔会识别错,生成的 SQL 就不对。reasonable的作用是页码纠偏:传 0 或负数自动回拨到第 1 页,传超过总页数的页码自动回拨到最后一页,这个参数强烈建议打开。supportMethodsArguments允许插件从查询方法的参数里自动识别分页参数,但我在团队里一般不建议依赖它,而是显式调用startPage,因为显式调用在代码审查时一眼就能看到,不会出现“分页悄悄生效”的困惑。
3.3 Service 层 startPage 的正确位置:紧贴第一条 Mapper 查询
PageHelper 的工作原理可以简单理解为 ThreadLocal 标记:调用startPage(pageNum, pageSize)后,分页参数被存入当前线程的上下文,紧接着执行的第一条 Mapper 查询会被插件拦截并改写 SQL,查询结束后上下文自动清除。这个机制决定了两件事:第一,startPage必须紧贴着查询;第二,它前面不能有任何其他 Mapper 查询,否则分页会作用到那一条 SQL 上。
@Override public PageResult<User> queryUsersByPage(int pageNum, int pageSize, String keyword) { // startPage 之后必须紧跟着第一条查询,中间不要插入任何其他 Mapper 调用 PageHelper.startPage(pageNum, pageSize); List<User> users = userMapper.selectUsers(keyword); // 查询完成后立即用 PageInfo 拿分页信息 PageInfo<User> pageInfo = new PageInfo<>(users); return new PageResult<>(pageInfo); }startPage的返回值是Page对象,但没有必要接收它,因为真正要拿的数据在查询结果里。PageInfo是 PageHelper 提供的结果包装类,里面有total、pages、pageNum、pageSize、hasNextPage等一系列分页字段。注意如果你在startPage和查询之间调用了userMapper.selectOther(),这一条会被分页,后面的真正查询反而没有 LIMIT——这是分页功能里最常见的翻车现场之一。
3.4 Controller 接收分页参数与前端分页条展示
Controller 负责把前端传过来的参数透传给 Service,并给出默认值。这里要养成一个习惯:pageNum和pageSize不要直接声明在方法签名里而不给默认值,否则前端少传一个参数,SpringMVC 直接会返回 400。
@RequestMapping("/list") public String list(@RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "10") int pageSize, @RequestParam(required = false) String keyword, Model model) { PageResult<User> page = userService.queryUsersByPage(pageNum, pageSize, keyword); model.addAttribute("page", page); model.addAttribute("keyword", keyword); return "user/list"; }前端分页条在 JSP 里的写法比较固定:
<c:forEach items="${page.list}" var="u"> <tr> <td>${u.id}</td> <td>${u.username}</td> <td>${u.nickname}</td> <td>${u.status}</td> </tr> </c:forEach> <div class="pagination"> 共 ${page.total} 条,第 ${page.pageNum} / ${page.pages} 页 <c:if test="${page.hasPrev}"> <a href="/user/list?pageNum=1&pageSize=${page.pageSize}&keyword=${keyword}">首页</a> <a href="/user/list?pageNum=${page.pageNum - 1}&pageSize=${page.pageSize}&keyword=${keyword}">上一页</a> </c:if> <c:if test="${page.hasNext}"> <a href="/user/list?pageNum=${page.pageNum + 1}&pageSize=${page.pageSize}&keyword=${keyword}">下一页</a> <a href="/user/list?pageNum=${page.pages}&pageSize=${page.pageSize}&keyword=${keyword}">末页</a> </c:if> </div>这里分页条上的链接必须把pageSize传回去,否则用户点了第二页,分页大小就可能变回默认的 10。keyword也要原样传,不然搜索后翻页会丢掉条件。一个小技巧是给keyword做一下 URL 编码,中文搜索词直接拼在链接里可能会被浏览器截断。
4. 进阶封装:分页结果对象、多表查询与条件搜索
分页跑通只是开始,真正决定代码好不好维护的是结果返回方式和多表场景的处理。很多人直接把PageInfo丢给前端,短期内能用,但接口一多就会发现字段不可控,前端拿到一堆用不上的属性,后端改动一个字段名就全局报错。分页结果必须做一层封装。
4.1 封装 PageResult:total、pages、list 一把抓
建议在每个项目里定义一个统一的PageResult<T>,所有分页接口都返回这个对象。它只暴露前端需要的字段,内部把PageInfo适配进去。
public class PageResult<T> implements Serializable { private long total; // 总条数 private int pageNum; // 当前页码 private int pageSize; // 每页条数 private int pages; // 总页数 private boolean hasPrev; // 是否有上一页 private boolean hasNext; // 是否有下一页 private List<T> list; // 当前页数据 public PageResult(PageInfo<T> page) { this.total = page.getTotal(); this.pageNum = page.getPageNum(); this.pageSize = page.getPageSize(); this.pages = page.getPages(); this.hasPrev = page.isHasPreviousPage(); this.hasNext = page.isHasNextPage(); this.list = page.getList(); } // getter / setter 省略 }total用long而不是int,因为 MySQL 的 count 结果本身就是 bigint,数据量大时 int 会溢出。这个封装的价值在于:前端拿到的字段是固定的,后端无论底层用 PageHelper、手写分页还是未来换成别的组件,接口契约都不变。
4.2 多表 JOIN 分页:先分页主表再关联子表
多表关联是分页最容易算错总数的场景。比如sys_user一对多关联sys_order,直接 JOIN 查询时,一个用户有几条订单就会出现几行,PageHelper 的 count 针对 JOIN 后的结果统计,总数比实际用户数多。这个问题的根源是“分页针对的是 JOIN 结果集”,而业务上通常希望“先按主表分页,再带出子表数据”。
我推荐的做法是分两步:第一步按主表分页,第二步用当前页的主表 ID 批量查询子表,再在 Service 层组装。
-- 主查询只查主表,分页和 count 都准确 SELECT id, username, nickname, status FROM sys_user WHERE status = 1 ORDER BY id DESC LIMIT #{offset}, #{pageSize}<select id="selectOrdersByUserIds" resultType="OrderDTO"> SELECT user_id, order_no, amount, create_time FROM sys_order WHERE user_id IN <foreach collection="userIds" item="userId" open="(" separator="," close=")"> #{userId} </foreach> </select>Service 层拿到当前页的用户列表后,把userIds收集成集合传给selectOrdersByUserIds,再把订单按userId分组后 set 到对应用户的字段里。这个方案的代价是多一次查询,但换来的是 count 绝对准确、SQL 简单可控。对于大多数管理后台的列表页,这个代价完全值得;如果子表数据量特别大,再考虑用嵌套循环或者单独的分页接口。
4.3 搜索条件 + 分页:动态 SQL 与查询对象
条件搜索与分页组合时,最忌讳的做法是把条件拼成字符串传给 Mapper。正确做法是定义一个查询对象,把页码、每页条数、关键字、状态、时间范围都放在里面。
public class UserQuery { private int pageNum = 1; private int pageSize = 10; private String keyword; private Integer status; private Date beginTime; private Date endTime; // getter / setter 省略 }Mapper XML 用动态 SQL 拼接条件:
<select id="selectUserPage" resultType="User"> SELECT id, username, nickname, status, create_time FROM sys_user <where> <if test="keyword != null and keyword != ''"> AND (username LIKE CONCAT('%', #{keyword}, '%') OR nickname LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND status = #{status} </if> <if test="beginTime != null"> AND create_time >= #{beginTime} </if> <if test="endTime != null"> AND create_time <= #{endTime} </if> </where> ORDER BY id DESC </select>注意 XML 里的转义:>=要写成>=,<=要写成<=,否则 XML 解析直接报错。Controller 里不需要给UserQuery加@RequestParam,SpringMVC 会自动把同名请求参数绑定到对象的属性上。搜索页的分页链接要带上所有搜索条件,前端通常会把查询表单的值回显到隐藏字段,再拼进分页 URL。
4.4 返回 JSON 给前端:序列化契约要固定
如果列表页用 Ajax 渲染而不是 JSP 直接拿 model,Controller 就要改成@ResponseBody返回PageResult:
@RequestMapping("/listJson") @ResponseBody public PageResult<User> listJson(UserQuery query) { return userService.queryUserPage(query); }这里最容易踩的坑是序列化字段名不一致。PageResult里的hasPrev和hasNext是由 getter 方法名决定的,如果 getter 写成getHasPrev(),JSON 字段就是hasPrev;如果 IDE 生成的是isHasPrev(),字段就可能变成prev或hasPrev,不同版本的 Lombok 行为还不一样。前后端联调时先固定一个字段清单,前端以文档为准,不要靠猜。时间字段也要统一格式,否则前端渲染出“2024-01-01T00:00:00.000+00:00”这种带时区的字符串,会以为后端出了 bug。
5. SSM 整合与分页的避坑指南:5 个高频故障与排查
分页功能翻车的概率远高于其他模块,原因在于它横跨了前端、Controller、Service、MyBatis SQL 四层,任何一环失联都会出现诡异现象。下面这些坑是我实际排查过的,按“现象→原因→解决”的顺序写,方便你对照定位。
5.1 分页不生效:startPage 被临近的其他查询“截胡”
现象:页面上第一页数据正常,点击下一页后 URL 变了但数据还是原来的;或者日志里能看到分页 SQL,但查的不是目标表。
原因:startPage与目标查询之间多了一次其他的 Mapper 调用。PageHelper 的分页参数是 ThreadLocal 标记,同一线程里先执行哪条查询,分页就作用在哪条上;目标查询被后移,分页自然失效。
解决:把startPage紧贴查询,之间不写任何其他 DAO 调用。
// 错误写法 PageHelper.startPage(pageNum, pageSize); List<Role> roles = roleMapper.selectAllRoles(); // 分页作用到了这里 List<User> users = userMapper.selectUsers(keyword); // 正确写法 PageHelper.startPage(pageNum, pageSize); List<User> users = userMapper.selectUsers(keyword); PageInfo<User> page = new PageInfo<>(users);这个问题的隐蔽点在于,如果前面那次查询数据量小,有时候视觉上“看起来正常”,实际上总数和列表对不上。排查时先看 MyBatis 输出的 SQL,LIMIT 出现在哪条 SQL 后面,问题一目了然。
5.2 总数不对:JOIN 与 GROUP BY 造成 count 结果偏高
现象:列表数据能翻页,但底部显示“共 120 条”,实际翻到最后一页只有 30 条;或者 list 里有重复行。
原因:PageHelper 的 count 是对原始 SQL 包一层SELECT COUNT(0) FROM (原SQL) tmp。当原 SQL 是 JOIN 一对多结果时,同一主表记录出现 N 次,总数被放大 N 倍;如果原 SQL 里有GROUP BY,count 的实际含义变成了分组数量,和列表行数根本不是一个维度。
解决:多表查询优先用第 4.2 节的“两步走”——主表分页,子表单独查,彻底绕开 count 膨胀。如果一定要在一条 SQL 里 JOIN,可以在 XML 里手动写 count 语句,用SELECT COUNT(DISTINCT u.id)这种带去重的统计方式,再用PageHelper的countSuffix关联到这条自定义 count SQL。但两条 SQL 要维护同一个查询条件,维护成本就是双份,能不用尽量不用。
5.3 页码越界与负数:reasonable 不是万能的
现象:pageNum 传 -1 或 0,页面返回空白;传一个很大的值,数据库执行LIMIT 999990, 10后响应明显变慢;传非数字abc,SpringMVC 直接返回 400。
原因:reasonable=true只能在 PageHelper 拿到分页参数后纠正越界页码。但如果参数在进入 Service 之前就丢了,或者 Controller 不知道它是个数字,插件根本没有任何纠偏机会。特别是LIMIT的偏移量很大时,MySQL 需要扫描并丢弃 offset 之前的行,即使最终只返回 10 条,代价也是随页码线性增长的。
解决:Controller 层给默认值,Service 层再设一个上限。
// Controller 里处理非数字传参 String pageNumStr = request.getParameter("pageNum"); int pageNum = StringUtils.isNumeric(pageNumStr) ? Integer.parseInt(pageNumStr) : 1; int pageSize = Integer.parseInt(request.getParameter("pageSize") == null ? "10" : request.getParameter("pageSize")); pageSize = Math.min(pageSize, 100); // 防止一次拉全表@RequestParam(defaultValue)能解决参数缺失,但解决不了非数字字符串。业务量大的系统,我习惯对 pageSize 做上限校验,最大不超过 100,避免有人恶意传一个 999999 把数据库拖垮。
5.4 整合失败:Mapper 接口与 XML 绑定不上
现象:应用启动正常,第一次请求接口时抛Invalid bound statement (not found): com.example.ssm.mapper.UserMapper.selectUsers。
原因:Mapper 接口与 XML 映射文件的绑定条件被破坏。最常见的四个原因:XML 的 namespace 和接口全限定名不一致;XML 里的 id 和接口方法名不一致;mapperLocations路径写错扫描不到 XML;XML 文件放在src/main/java目录下但没被 Maven 打包进 classpath。
解决:先检查编译产物里有没有 XML:
find target/classes -name "UserMapper.xml"如果输出为空,说明 XML 没有被复制到 classpath。src/main/resources下的文件会被自动打包,但很多人习惯把 XML 和接口放在同一个包路径下。此时需要在 pom.xml 的 build 节点声明资源位置:
<build> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> </includes> </resource> </resources> </build>然后逐个核对 namespace、id、方法名。这类报错的排查顺序是:先看文件在不在 classpath,再看 namespace 对不对,最后看 id。大部分情况不是配置复杂,而是某个单词大小写写错了。
5.5 JSON 返回异常:total 字段和前端对不上
现象:Ajax 能拿到 list,但total一直为 0;或者分页条显示“共 NaN 条”;控制台报 JSON parse error。
原因:PageResult的total是 long,前端某些模板把它当作 int 处理时出现溢出或显示异常;也有可能是PageResult里的 getter 被序列化成别的字段名,前端取total取不到。还有一类是 LocalDateTime 字段未注册 Jackson 的 jsr310 模块,序列化直接抛异常。
解决:后端先自测接口返回的 JSON 结构。
curl 'http://localhost:8080/user/listJson?pageNum=1&pageSize=10'看到total字段确实是长整型数字,再从前端角度排查。前端拿到响应后先console.log(res.total),确认不是 undefined。如果是时间字段的问题,在 spring-mvc.xml 的mvc:annotation-driven里配置消息转换器,注册 JavaTimeModule,或者把实体里的 LocalDateTime 统一格式化后再返回。这些问题的共性是“接口契约不明”,收口到PageResult并固定序列化配置,能解决大部分隐患。
6. 把分页沉淀成通用模板:PageQuery 与 PageResult 的复用
项目里每加一个列表模块,都要重新写一遍 Controller 参数接收、Service 里 startPage、PageInfo 转 PageResult 的逻辑。这类代码重复度高且容易出错,我的做法是把它们沉淀成一个通用模板,新模块只需要写 Mapper 和一行查询逻辑。
public class PageQuery { private int pageNum = 1; private int pageSize = 10; // getter / setter 省略 public int getOffset() { return (pageNum - 1) * pageSize; } }public class PageService { public <T> PageResult<T> selectPage(PageQuery query, Function<PageQuery, List<T>> queryFunction) { // 分页参数统一在这里设置,queryFunction 内部必须只查一次 Mapper PageHelper.startPage(query.getPageNum(), query.getPageSize()); List<T> list = queryFunction.apply(query); return new PageResult<>(new PageInfo<>(list)); } }使用这个模板时,新模块的 Service 只要一行:
public PageResult<User> queryUserPage(UserQuery query) { return pageService.selectPage(query, q -> userMapper.selectUserPage(q)); }这里有一个要约定清楚的边界:queryFunction内部不能再次调用 PageHelper,也不能先查别的表再查目标表。模板的好处是把“分页必须在 Service 层且只执行一次”这个约束变成代码结构,而不是靠口头约定。另外,PageQuery 可以考虑加上@Min(1)和@Max(100)注解,配合 Spring 的参数校验,把 pageSize 上限固化在基类里。
把分页抽成模板后,我团队的代码风格基本统一了:Controller 只接收参数,Service 只暴露业务方法,PageResult 只承载接口契约。后来排查线上问题时,发现大多数所谓分页“玄学”都出在有人绕过模板、自己在某个地方偷偷调了 startPage。现在我把这条规则写成团队约定——分页只允许出现在 PageService 这一个类里。这个习惯替我们挡掉了不少线上事故。希望帮到你。
本文还有配套的精品资源,点击获取