【项目扩展实战|第8篇】Spring Boot + MyBatis 分页条件查询接口优化

📅 2026/7/23 22:41:24 👁️ 阅读次数 📝 编程学习
【项目扩展实战|第8篇】Spring Boot + MyBatis 分页条件查询接口优化

前言

前面我们已经写过商品详情缓存、文件上传等功能。这一篇继续回到后台管理系统里非常高频的一个接口:分页条件查询

只要是后台列表页面,基本都离不开分页查询。比如:

  1. 商品列表分页查询
  2. 用户列表分页查询
  3. 订单列表分页查询
  4. 操作日志分页查询
  5. 文件记录分页查询

如果只是查全部数据,接口很简单;但真实后台一般都需要支持条件筛选,例如商品名称、状态、价格区间、创建时间范围等。

这一篇我们就以商品列表为例,实现一个完整的分页条件查询接口,并顺带优化参数对象、分页返回结构和 MyBatis 动态 SQL。


一、分页条件查询的整体需求

我们要实现的接口是:

GET /admin/product/page

支持这些查询条件:

  1. 当前页码page
  2. 每页条数pageSize
  3. 商品名称关键字keyword
  4. 商品状态status
  5. 最低价格minPrice
  6. 最高价格maxPrice
  7. 创建开始时间beginTime
  8. 创建结束时间endTime

返回结果大概是:

{"code":200,"message":"操作成功","data":{"total":100,"rows":[]}}

这里使用total + rows的结构,前端分页组件比较容易使用。


二、准备分页返回对象

先定义一个通用分页返回类。

packagecom.example.common;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;importjava.util.List;@Data@NoArgsConstructor@AllArgsConstructorpublicclassPageResult<T>{privateLongtotal;privateList<T>rows;}

文字说明

PageResult是一个通用类,不只商品列表能用,用户列表、订单列表、日志列表也都能用。

其中:

  1. total表示总记录数
  2. rows表示当前页数据

前端拿到total后,就可以计算总页数;拿到rows后,就可以渲染表格。


三、添加 PageHelper 依赖

分页可以自己写limit,也可以使用 PageHelper。

这里使用 PageHelper,因为它和 MyBatis 配合比较方便。

<dependency><groupId>com.github.pagehelper</groupId><artifactId>pagehelper-spring-boot-starter</artifactId><version>1.4.7</version></dependency>

文字说明

PageHelper 的核心用法是:

PageHelper.startPage(page,pageSize);List<ProductVO>list=productMapper.selectPage(queryDTO);Page<ProductVO>pageResult=(Page<ProductVO>)list;

它会自动帮我们在 SQL 后面加分页语句,并统计总记录数。


四、准备查询参数对象

不要把所有查询参数都散落在 Controller 方法参数里,更推荐封装成一个 DTO。

packagecom.example.dto;importlombok.Data;importorg.springframework.format.annotation.DateTimeFormat;importjava.math.BigDecimal;importjava.time.LocalDateTime;@DatapublicclassProductPageQueryDTO{privateIntegerpage=1;privateIntegerpageSize=10;privateStringkeyword;privateIntegerstatus;privateBigDecimalminPrice;privateBigDecimalmaxPrice;@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")privateLocalDateTimebeginTime;@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")privateLocalDateTimeendTime;}

文字说明

这个 DTO 封装了所有分页查询条件。

这里给pagepageSize设置了默认值:

privateIntegerpage=1;privateIntegerpageSize=10;

这样前端如果没传分页参数,也不会直接报错。

时间参数加了:

@DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss")

这样 Spring MVC 才能把字符串时间转换成LocalDateTime

例如前端传:

beginTime=2026-07-01 00:00:00

后端就能正常接收。


五、准备商品列表 VO

分页列表通常不需要返回商品详情里的全部字段,所以单独定义一个列表 VO。

packagecom.example.vo;importlombok.Data;importjava.math.BigDecimal;importjava.time.LocalDateTime;@DatapublicclassProductPageVO{privateLongid;privateStringname;privateBigDecimalprice;privateIntegerstock;privateStringcoverUrl;privateIntegerstatus;privateLocalDateTimecreateTime;}

文字说明

列表页一般展示核心字段即可,比如:

  1. 商品 ID
  2. 商品名称
  3. 价格
  4. 库存
  5. 封面
  6. 状态
  7. 创建时间

如果列表页字段太多,会导致接口响应变大,也会让前端表格变得很复杂。


六、Controller 层实现

packagecom.example.controller;importcom.example.common.PageResult;importcom.example.common.Result;importcom.example.dto.ProductPageQueryDTO;importcom.example.service.ProductService;importcom.example.vo.ProductPageVO;importlombok.RequiredArgsConstructor;importorg.springframework.web.bind.annotation.*;@RestController@RequestMapping("/admin/product")@RequiredArgsConstructorpublicclassAdminProductController{privatefinalProductServiceproductService;@GetMapping("/page")publicResult<PageResult<ProductPageVO>>page(ProductPageQueryDTOqueryDTO){PageResult<ProductPageVO>pageResult=productService.page(queryDTO);returnResult.success(pageResult);}}

文字说明

这里没有使用@RequestBody

因为分页条件查询一般是 GET 请求,参数通常放在 URL 后面,例如:

GET /admin/product/page?page=1&pageSize=10&keyword=手机&status=1

Spring MVC 会自动把这些请求参数封装到ProductPageQueryDTO中。

Controller 层依旧保持简单,只负责接收参数、调用 Service、返回结果。


七、Service 层实现分页逻辑

1. Service 接口

packagecom.example.service;importcom.example.common.PageResult;importcom.example.dto.ProductPageQueryDTO;importcom.example.vo.ProductPageVO;publicinterfaceProductService{PageResult<ProductPageVO>page(ProductPageQueryDTOqueryDTO);}

2. Service 实现类

packagecom.example.service.impl;importcom.example.common.PageResult;importcom.example.common.ResultCode;importcom.example.dto.ProductPageQueryDTO;importcom.example.exception.BusinessException;importcom.example.mapper.ProductMapper;importcom.example.service.ProductService;importcom.example.vo.ProductPageVO;importcom.github.pagehelper.Page;importcom.github.pagehelper.PageHelper;importlombok.RequiredArgsConstructor;importorg.springframework.stereotype.Service;importjava.util.List;@Service@RequiredArgsConstructorpublicclassProductServiceImplimplementsProductService{privatestaticfinalintMAX_PAGE_SIZE=100;privatefinalProductMapperproductMapper;@OverridepublicPageResult<ProductPageVO>page(ProductPageQueryDTOqueryDTO){checkPageParams(queryDTO);PageHelper.startPage(queryDTO.getPage(),queryDTO.getPageSize());List<ProductPageVO>list=productMapper.selectPage(queryDTO);Page<ProductPageVO>page=(Page<ProductPageVO>)list;returnnewPageResult<>(page.getTotal(),page.getResult());}privatevoidcheckPageParams(ProductPageQueryDTOqueryDTO){if(queryDTO==null){thrownewBusinessException(ResultCode.PARAM_ERROR);}if(queryDTO.getPage()==null||queryDTO.getPage()<=0){queryDTO.setPage(1);}if(queryDTO.getPageSize()==null||queryDTO.getPageSize()<=0){queryDTO.setPageSize(10);}if(queryDTO.getPageSize()>MAX_PAGE_SIZE){queryDTO.setPageSize(MAX_PAGE_SIZE);}if(queryDTO.getMinPrice()!=null&&queryDTO.getMaxPrice()!=null&&queryDTO.getMinPrice().compareTo(queryDTO.getMaxPrice())>0){thrownewBusinessException(ResultCode.PARAM_ERROR.getCode(),"最低价格不能大于最高价格");}if(queryDTO.getBeginTime()!=null&&queryDTO.getEndTime()!=null&&queryDTO.getBeginTime().isAfter(queryDTO.getEndTime())){thrownewBusinessException(ResultCode.PARAM_ERROR.getCode(),"开始时间不能晚于结束时间");}}}

文字说明

Service 层主要做三件事。

第一,校验分页参数:

checkPageParams(queryDTO);

防止前端传入异常参数,比如page=-1pageSize=99999

第二,开启分页:

PageHelper.startPage(queryDTO.getPage(),queryDTO.getPageSize());

注意:PageHelper.startPage必须紧挨着下一次 MyBatis 查询执行。

第三,封装分页结果:

returnnewPageResult<>(page.getTotal(),page.getResult());

这样前端拿到的就是统一分页格式。


八、Mapper 层实现

1. Mapper 接口

packagecom.example.mapper;importcom.example.dto.ProductPageQueryDTO;importcom.example.vo.ProductPageVO;importorg.apache.ibatis.annotations.Mapper;importjava.util.List;@MapperpublicinterfaceProductMapper{List<ProductPageVO>selectPage(ProductPageQueryDTOqueryDTO);}

2. Mapper XML 动态 SQL

<?xml version="1.0" encoding="UTF-8" ?><!DOCTYPEmapperPUBLIC"-//mybatis.org//DTD Mapper 3.0//EN""https://mybatis.org/dtd/mybatis-3-mapper.dtd"><mappernamespace="com.example.mapper.ProductMapper"><selectid="selectPage"resultType="com.example.vo.ProductPageVO">select id, name, price, stock, cover_url, status, create_time from product<where><iftest="keyword != null and keyword != ''">and name like concat('%', #{keyword}, '%')</if><iftest="status != null">and status = #{status}</if><iftest="minPrice != null">and price&gt;= #{minPrice}</if><iftest="maxPrice != null">and price&lt;= #{maxPrice}</if><iftest="beginTime != null">and create_time&gt;= #{beginTime}</if><iftest="endTime != null">and create_time&lt;= #{endTime}</if></where>order by create_time desc, id desc</select></mapper>

文字说明

这里使用了 MyBatis 动态 SQL。

<where>标签会自动处理where和多余的and

比如只传了status时,最终 SQL 类似:

select...fromproductwherestatus=?orderbycreate_timedesc,iddesc

如果什么条件都没传,就不会拼接where,最终查询全部商品分页。

关键字查询使用:

name like concat('%', #{keyword}, '%')

价格和时间范围分别使用:

price >= #{minPrice} price <= #{maxPrice} create_time >= #{beginTime} create_time <= #{endTime}

这就是后台列表条件查询里非常常见的写法。


九、分页查询接口测试示例

1. 查询第一页

GET /admin/product/page?page=1&pageSize=10

表示查询第一页,每页 10 条。


2. 根据名称模糊查询

GET /admin/product/page?page=1&pageSize=10&keyword=手机

表示查询名称中包含“手机”的商品。


3. 根据状态查询

GET /admin/product/page?page=1&pageSize=10&status=1

表示查询上架商品。


4. 根据价格区间查询

GET /admin/product/page?page=1&pageSize=10&minPrice=100&maxPrice=500

表示查询价格在 100 到 500 之间的商品。


5. 根据时间范围查询

GET /admin/product/page?page=1&pageSize=10&beginTime=2026-07-01 00:00:00&endTime=2026-07-05 23:59:59

表示查询指定时间范围内创建的商品。


十、为什么要限制 pageSize

如果不限制pageSize,前端或者恶意请求可能传:

pageSize=100000

这样一次查询就可能查出大量数据,导致:

  1. 数据库压力变大
  2. 接口响应变慢
  3. 内存占用升高
  4. 前端渲染卡顿

所以 Service 里做了限制:

if(queryDTO.getPageSize()>MAX_PAGE_SIZE){queryDTO.setPageSize(MAX_PAGE_SIZE);}

后台管理系统中,一般每页 10、20、50 条已经够用,最大限制到 100 比较合理。


十一、涉及知识点

1. PageHelper

PageHelper 是 MyBatis 常用分页插件。

它可以自动帮我们拼接分页 SQL,并计算总数。

使用时要注意:

PageHelper.startPage(page,pageSize);

必须放在 Mapper 查询之前。

2. MyBatis 动态 SQL

动态 SQL 适合处理可选查询条件。

常用标签有:

  1. <where>
  2. <if>
  3. <set>
  4. <foreach>

本篇主要使用<where><if>

3. DTO 和 VO 分层

ProductPageQueryDTO用来接收查询参数。

ProductPageVO用来返回列表数据。

这样比直接用实体类更清晰,也更适合前后端接口设计。

4. 模糊查询

模糊查询常见写法是:

namelikeconcat('%',#{keyword}, '%')

注意不要直接拼接字符串,避免 SQL 注入风险。

5. 时间范围查询

时间范围查询是后台列表中非常常见的需求。

前端传字符串时间,后端通过@DateTimeFormat转成LocalDateTime,Mapper 再用于 SQL 查询。


十二、常见问题

1. PageHelper 为什么没有生效?

常见原因有:

  1. 没有引入 PageHelper starter
  2. PageHelper.startPage没有紧挨着 Mapper 查询
  3. Mapper 查询之前执行了其他查询
  4. 返回类型转换方式不正确

正确顺序是:

PageHelper.startPage(page,pageSize);List<ProductPageVO>list=productMapper.selectPage(queryDTO);Page<ProductPageVO>page=(Page<ProductPageVO>)list;

2. GET 请求能不能接收对象?

可以。

只要参数名和 DTO 字段名一致,Spring MVC 会自动封装。

例如:

?page=1&pageSize=10&keyword=手机

可以封装到:

ProductPageQueryDTO

3. 查询条件为空时会不会 SQL 报错?

不会。

因为<where>会自动处理条件。

如果所有<if>都不满足,就不会生成where

4. 排序字段能不能让前端传?

可以,但要谨慎。

不能直接把前端传入的排序字段拼进 SQL,否则有 SQL 注入风险。

更安全的做法是后端定义白名单,比如只允许按create_timepricestock排序。


十三、实际开发建议

1. 分页参数一定要兜底

不要完全相信前端传参。

后端要处理:

  1. 页码为空
  2. 页码小于 1
  3. 每页条数为空
  4. 每页条数过大

2. 列表接口不要返回过多字段

列表页只返回展示需要的字段。

详情页再返回完整信息。

这样接口更轻,前端也更好处理。

3. 模糊查询字段要考虑索引

like '%关键字%'在数据量大时可能导致索引效果不好。

如果商品量很大,可以考虑:

  1. 只支持前缀匹配
  2. 增加搜索字段
  3. 使用全文索引
  4. 接入 Elasticsearch

4. 后台查询要注意组合条件

后台列表的核心不只是分页,而是多个条件组合查询。

所以动态 SQL 是这一类接口必须掌握的内容。


十四、总结

这一篇我们完成了 Spring Boot + MyBatis 分页条件查询接口。

整个流程包括:

  1. 定义分页返回对象PageResult
  2. 定义查询参数 DTO
  3. 定义列表返回 VO
  4. Controller 接收 GET 查询参数
  5. Service 校验分页参数并调用 PageHelper
  6. Mapper 使用动态 SQL 组合查询条件
  7. 返回统一分页结构给前端

相比基础 CRUD,这一篇更接近后台管理系统真实列表页的写法。

分页条件查询是非常高频的能力,不管是商品、用户、订单、日志,基本都能套用这一套思路。学会这篇之后,后面写后台列表接口会顺很多。