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

📅 2026/7/23 22:31:22
【项目扩展实战|第8篇】Spring Boot + MyBatis 分页条件查询接口优化
前言前面我们已经写过商品详情缓存、文件上传等功能。这一篇继续回到后台管理系统里非常高频的一个接口分页条件查询。只要是后台列表页面基本都离不开分页查询。比如商品列表分页查询用户列表分页查询订单列表分页查询操作日志分页查询文件记录分页查询如果只是查全部数据接口很简单但真实后台一般都需要支持条件筛选例如商品名称、状态、价格区间、创建时间范围等。这一篇我们就以商品列表为例实现一个完整的分页条件查询接口并顺带优化参数对象、分页返回结构和 MyBatis 动态 SQL。一、分页条件查询的整体需求我们要实现的接口是GET /admin/product/page支持这些查询条件当前页码page每页条数pageSize商品名称关键字keyword商品状态status最低价格minPrice最高价格maxPrice创建开始时间beginTime创建结束时间endTime返回结果大概是{code:200,message:操作成功,data:{total:100,rows:[]}}这里使用total rows的结构前端分页组件比较容易使用。二、准备分页返回对象先定义一个通用分页返回类。packagecom.example.common;importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;importjava.util.List;DataNoArgsConstructorAllArgsConstructorpublicclassPageResultT{privateLongtotal;privateListTrows;}文字说明PageResult是一个通用类不只商品列表能用用户列表、订单列表、日志列表也都能用。其中total表示总记录数rows表示当前页数据前端拿到total后就可以计算总页数拿到rows后就可以渲染表格。三、添加 PageHelper 依赖分页可以自己写limit也可以使用 PageHelper。这里使用 PageHelper因为它和 MyBatis 配合比较方便。dependencygroupIdcom.github.pagehelper/groupIdartifactIdpagehelper-spring-boot-starter/artifactIdversion1.4.7/version/dependency文字说明PageHelper 的核心用法是PageHelper.startPage(page,pageSize);ListProductVOlistproductMapper.selectPage(queryDTO);PageProductVOpageResult(PageProductVO)list;它会自动帮我们在 SQL 后面加分页语句并统计总记录数。四、准备查询参数对象不要把所有查询参数都散落在 Controller 方法参数里更推荐封装成一个 DTO。packagecom.example.dto;importlombok.Data;importorg.springframework.format.annotation.DateTimeFormat;importjava.math.BigDecimal;importjava.time.LocalDateTime;DatapublicclassProductPageQueryDTO{privateIntegerpage1;privateIntegerpageSize10;privateStringkeyword;privateIntegerstatus;privateBigDecimalminPrice;privateBigDecimalmaxPrice;DateTimeFormat(patternyyyy-MM-dd HH:mm:ss)privateLocalDateTimebeginTime;DateTimeFormat(patternyyyy-MM-dd HH:mm:ss)privateLocalDateTimeendTime;}文字说明这个 DTO 封装了所有分页查询条件。这里给page和pageSize设置了默认值privateIntegerpage1;privateIntegerpageSize10;这样前端如果没传分页参数也不会直接报错。时间参数加了DateTimeFormat(patternyyyy-MM-dd HH:mm:ss)这样 Spring MVC 才能把字符串时间转换成LocalDateTime。例如前端传beginTime2026-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;}文字说明列表页一般展示核心字段即可比如商品 ID商品名称价格库存封面状态创建时间如果列表页字段太多会导致接口响应变大也会让前端表格变得很复杂。六、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.*;RestControllerRequestMapping(/admin/product)RequiredArgsConstructorpublicclassAdminProductController{privatefinalProductServiceproductService;GetMapping(/page)publicResultPageResultProductPageVOpage(ProductPageQueryDTOqueryDTO){PageResultProductPageVOpageResultproductService.page(queryDTO);returnResult.success(pageResult);}}文字说明这里没有使用RequestBody。因为分页条件查询一般是 GET 请求参数通常放在 URL 后面例如GET /admin/product/page?page1pageSize10keyword手机status1Spring MVC 会自动把这些请求参数封装到ProductPageQueryDTO中。Controller 层依旧保持简单只负责接收参数、调用 Service、返回结果。七、Service 层实现分页逻辑1. Service 接口packagecom.example.service;importcom.example.common.PageResult;importcom.example.dto.ProductPageQueryDTO;importcom.example.vo.ProductPageVO;publicinterfaceProductService{PageResultProductPageVOpage(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;ServiceRequiredArgsConstructorpublicclassProductServiceImplimplementsProductService{privatestaticfinalintMAX_PAGE_SIZE100;privatefinalProductMapperproductMapper;OverridepublicPageResultProductPageVOpage(ProductPageQueryDTOqueryDTO){checkPageParams(queryDTO);PageHelper.startPage(queryDTO.getPage(),queryDTO.getPageSize());ListProductPageVOlistproductMapper.selectPage(queryDTO);PageProductPageVOpage(PageProductPageVO)list;returnnewPageResult(page.getTotal(),page.getResult());}privatevoidcheckPageParams(ProductPageQueryDTOqueryDTO){if(queryDTOnull){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()!nullqueryDTO.getMaxPrice()!nullqueryDTO.getMinPrice().compareTo(queryDTO.getMaxPrice())0){thrownewBusinessException(ResultCode.PARAM_ERROR.getCode(),最低价格不能大于最高价格);}if(queryDTO.getBeginTime()!nullqueryDTO.getEndTime()!nullqueryDTO.getBeginTime().isAfter(queryDTO.getEndTime())){thrownewBusinessException(ResultCode.PARAM_ERROR.getCode(),开始时间不能晚于结束时间);}}}文字说明Service 层主要做三件事。第一校验分页参数checkPageParams(queryDTO);防止前端传入异常参数比如page-1、pageSize99999。第二开启分页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{ListProductPageVOselectPage(ProductPageQueryDTOqueryDTO);}2. Mapper XML 动态 SQL?xml version1.0 encodingUTF-8 ?!DOCTYPEmapperPUBLIC-//mybatis.org//DTD Mapper 3.0//ENhttps://mybatis.org/dtd/mybatis-3-mapper.dtdmappernamespacecom.example.mapper.ProductMapperselectidselectPageresultTypecom.example.vo.ProductPageVOselect id, name, price, stock, cover_url, status, create_time from productwhereiftestkeyword ! null and keyword ! and name like concat(%, #{keyword}, %)/ififteststatus ! nulland status #{status}/ififtestminPrice ! nulland pricegt; #{minPrice}/ififtestmaxPrice ! nulland pricelt; #{maxPrice}/ififtestbeginTime ! nulland create_timegt; #{beginTime}/ififtestendTime ! nulland create_timelt; #{endTime}/if/whereorder 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?page1pageSize10表示查询第一页每页 10 条。2. 根据名称模糊查询GET /admin/product/page?page1pageSize10keyword手机表示查询名称中包含“手机”的商品。3. 根据状态查询GET /admin/product/page?page1pageSize10status1表示查询上架商品。4. 根据价格区间查询GET /admin/product/page?page1pageSize10minPrice100maxPrice500表示查询价格在 100 到 500 之间的商品。5. 根据时间范围查询GET /admin/product/page?page1pageSize10beginTime2026-07-01 00:00:00endTime2026-07-05 23:59:59表示查询指定时间范围内创建的商品。十、为什么要限制 pageSize如果不限制pageSize前端或者恶意请求可能传pageSize100000这样一次查询就可能查出大量数据导致数据库压力变大接口响应变慢内存占用升高前端渲染卡顿所以 Service 里做了限制if(queryDTO.getPageSize()MAX_PAGE_SIZE){queryDTO.setPageSize(MAX_PAGE_SIZE);}后台管理系统中一般每页 10、20、50 条已经够用最大限制到 100 比较合理。十一、涉及知识点1. PageHelperPageHelper 是 MyBatis 常用分页插件。它可以自动帮我们拼接分页 SQL并计算总数。使用时要注意PageHelper.startPage(page,pageSize);必须放在 Mapper 查询之前。2. MyBatis 动态 SQL动态 SQL 适合处理可选查询条件。常用标签有whereifsetforeach本篇主要使用where和if。3. DTO 和 VO 分层ProductPageQueryDTO用来接收查询参数。ProductPageVO用来返回列表数据。这样比直接用实体类更清晰也更适合前后端接口设计。4. 模糊查询模糊查询常见写法是namelikeconcat(%,#{keyword}, %)注意不要直接拼接字符串避免 SQL 注入风险。5. 时间范围查询时间范围查询是后台列表中非常常见的需求。前端传字符串时间后端通过DateTimeFormat转成LocalDateTimeMapper 再用于 SQL 查询。十二、常见问题1. PageHelper 为什么没有生效常见原因有没有引入 PageHelper starterPageHelper.startPage没有紧挨着 Mapper 查询Mapper 查询之前执行了其他查询返回类型转换方式不正确正确顺序是PageHelper.startPage(page,pageSize);ListProductPageVOlistproductMapper.selectPage(queryDTO);PageProductPageVOpage(PageProductPageVO)list;2. GET 请求能不能接收对象可以。只要参数名和 DTO 字段名一致Spring MVC 会自动封装。例如?page1pageSize10keyword手机可以封装到ProductPageQueryDTO3. 查询条件为空时会不会 SQL 报错不会。因为where会自动处理条件。如果所有if都不满足就不会生成where。4. 排序字段能不能让前端传可以但要谨慎。不能直接把前端传入的排序字段拼进 SQL否则有 SQL 注入风险。更安全的做法是后端定义白名单比如只允许按create_time、price、stock排序。十三、实际开发建议1. 分页参数一定要兜底不要完全相信前端传参。后端要处理页码为空页码小于 1每页条数为空每页条数过大2. 列表接口不要返回过多字段列表页只返回展示需要的字段。详情页再返回完整信息。这样接口更轻前端也更好处理。3. 模糊查询字段要考虑索引like %关键字%在数据量大时可能导致索引效果不好。如果商品量很大可以考虑只支持前缀匹配增加搜索字段使用全文索引接入 Elasticsearch4. 后台查询要注意组合条件后台列表的核心不只是分页而是多个条件组合查询。所以动态 SQL 是这一类接口必须掌握的内容。十四、总结这一篇我们完成了 Spring Boot MyBatis 分页条件查询接口。整个流程包括定义分页返回对象PageResult定义查询参数 DTO定义列表返回 VOController 接收 GET 查询参数Service 校验分页参数并调用 PageHelperMapper 使用动态 SQL 组合查询条件返回统一分页结构给前端相比基础 CRUD这一篇更接近后台管理系统真实列表页的写法。分页条件查询是非常高频的能力不管是商品、用户、订单、日志基本都能套用这一套思路。学会这篇之后后面写后台列表接口会顺很多。