06-PageResult分页封装-从JPA Page到前端协议大家好我是黒漂技术佬。上几篇我们把「URM Ultra」的整体方案和工程骨架过了一遍这篇开始扎进后端的具体代码细节。先把一个最不起眼、但几乎每个列表接口都要用到的东西讲透——分页响应体 PageResult。很多新手写接口列表一多就直接return ListT前端问总共多少条、第几页就傻眼了。分页不是多查几条那么简单它牵涉到数据库方言、JPA 的对象模型、以及前后端对第几页的认知差异。今天我们就把这套封装从根上捋清楚。一、为什么需要一个专门的 PageResult先想清楚一个问题前端要显示一个商品列表它真正需要的不只是这一页的数据还需要list当前页的数据集合total符合条件的总条数用来算共几页pageNum当前第几页pageSize每页多少条。如果后端只丢一个ListProduct过去前端既不知道总数也不知道自己现在翻到第几页分页器直接废了。那为什么不直接复用 JPA 自己给的Page对象这就到了第二个坑。二、JPA 的 Page 对象好用但不该直接出门Spring Data JPA 做分页查询非常省事。你只要在 Repository 里把返回值写成PageT再传一个Pageable进去框架就帮你把 SQL 的limit/offset拼好并顺手统计总数。项目源码里分页查询大致长这样示意// Repository 层返回 Spring Data JPA 的 PagePageProductfindAll(Pageablepageable);Service 里构造分页条件// pageNum 是前端传的第几页从 1 开始// 但 JPA 的 Pageable 页码从 0 开始所以要减 1PageablepageablePageRequest.of(pageNum-1,pageSize,Sort.by(Sort.Direction.DESC,id));PageProductpageproductRepository.findAll(pageable);Page对象确实啥都有getContent()拿数据、getTotalElements()拿总数、getNumber()拿页码、getSize()拿每页大小。但问题来了Page是 Spring Data 的基础设施类里面还塞着Pageable、Sort、totalPages等一堆框架内部细节。我们真要把这一坨直接序列化给前端吗两个理由不推荐协议耦合前端不该关心 Spring 的内部对象长啥样哪天你换持久层框架前端协议就跟着抖。字段噪音Page序列化出来字段又多又杂前端解析费劲文档也不好看。所以正确姿势是用 JPA 的Page在后端干活最后转成我们自己的干净对象再吐给前端。这个干净对象就是PageResultT。三、PageResult 长什么样项目源码urmeric common 包定义如下// 项目源码com.urm.ultra.common.PageResultDataNoArgsConstructorAllArgsConstructorpublicclassPageResultT{privateListTlist;// 当前页数据privatelongtotal;// 总条数privateintpageNum;// 当前页码从 1 开始privateintpageSize;// 每页大小}四个字段清清爽爽没有一点框架私货。Data是 Lombok 的注解自动生成 getter/setter/equals/hashCode/toString省得手写样板代码。注意字段类型total用long而不是int。别小看这点——商品、订单、设备表一旦上量单表几千万条时int上限 21 亿会溢出。total代表全表满足条件的条数用long更稳妥。四、核心of(Page, mapper) 静态工厂光有四个字段还不够好用。如果每次都手动new PageResult(...)、page.getContent()、page.getTotalElements()十几个列表接口写下来又是一堆重复代码。项目里用一个静态工厂方法把它收口了// 项目源码com.urm.ultra.common.PageResultpublicstaticE,TPageResultTof(PageEpage,FunctionE,Tmapper){returnnewPageResult(page.getContent().stream().map(mapper).toList(),page.getTotalElements(),page.getNumber()1,// 关键0 基 - 1 基page.getSize());}这一行代码干了四件事我们逐个拆page.getContent().stream().map(mapper).toList()取出当前页数据并用mapper把实体E转成给前端的T。这一步直接解决了实体不能直接给前端的老大难——比如数据库实体Product里有成本价costPrice你肯定不想把它暴露给小程序端那就用 mapper 转成ProductVO只留售价、名称等安全字段。page.getTotalElements()总条数来自 JPA 额外跑的那条count(*)查询。page.getNumber() 1这是整篇最关键的约定下面单独讲。page.getSize()每页大小原样回传。FunctionE, T mapper是 Java 8 的函数式接口调用时传一个 lambda 即可例如p - new ProductVO(p.getId(), p.getName(), p.getPrice())。如果你前后端用同一个实体Demo 里有些列表就这么偷懒也可以传Function.identity()。五、为什么 pageNum 要从 1 开始这里有个经典的程序员数数 vs 普通人数数的鸿沟。数据库 / JPA / 数组下标一律从 0 开始。PageRequest.of(0, 10)表示第 1 页。普通用户 / 前端 UI“第 1 页就是 1没人会说请翻到第 0 页”。如果后端也用 0 基页码前端传pageNum0过来产品经理看了直接懵接口文档写pageNum 从 0 开始也会被吐槽反人类。所以项目定了一条约定对外协议 pageNum 从 1 开始对内 JPA 用 pageNum - 1。这个转换只在两个地方发生入参Controller 收到pageNum1Service 构造Pageable时pageNum - 1变成0交给 JPA出参PageResult.of里page.getNumber() 1把 JPA 的 0 基页码加回 1再还给前端。// 入参1 基 - 0 基PageablepageablePageRequest.of(pageNum-1,pageSize,...);// 出参0 基 - 1 基在 PageResult.of 内部page.getNumber()1两边对称约定集中永远不会出现前端说第 2 页、数据库查的是第 3 页的错位 bug。这条约定看着小却是分页接口最容易翻车的地方新人务必记牢。六、实战在 Controller 里怎么用把上面串起来一个商品分页接口的完整链路是// Controller 层示意GetMapping(/page)publicResultPageResultProductVOpage(RequestParam(requiredfalse)Stringname,RequestParam(defaultValue1)intpageNum,RequestParam(defaultValue10)intpageSize){PageablepageablePageRequest.of(pageNum-1,pageSize,Sort.by(Sort.Direction.DESC,id));PageProductpageproductService.page(name,pageable);PageResultProductVOresultPageResult.of(page,p-newProductVO(p.getId(),p.getName(),p.getCode(),p.getPrice(),p.getStatus()));returnResult.ok(result);}返回的 JSON 长这样示意{code:0,message:success,data:{list:[{id:7,name:纸巾,price:4.00}],total:7,pageNum:1,pageSize:10}}前端拿到total和pageSize立刻能算总页数 ceil(total / pageSize)分页器自己就转起来了。七、进阶mapper 不只是搬字段mapper这一参数是PageResult.of设计的妙处。除了脱敏藏掉costPrice它还能做聚合计算把订单实体的items列表长度算成itemCount放进 VO枚举转中文ProductStatus.ON_SALE转成前端要的在售关联查询实体只存了categoryIdmapper 里调一次类目服务把categoryName补上注意别在 mapper 里疯狂查库引发 N1关联多时建议一次性in查询预取。一句话PageResult 负责分页壳子mapper 负责每个元素长啥样职责切得干干净净。八、小结与避坑清单列表接口永远返回PageResultT而不是裸List前端才拿得到总数和页码JPA 的Page留在后端用别直接序列化给前端避免协议耦合pageNum对外从 1 开始对内Pageable用pageNum - 1对称转换不出错of(Page, mapper)一个静态工厂把取数 转换 封装收口重复代码清零total用long防溢出mapper顺便做脱敏和聚合别把实体原样出门。分页是后端最日常的活儿把这个壳子打好后面几十个列表接口都能一行PageResult.of(...)搞定。下一篇我们聊比分页更救命的东西——全局异常处理看看怎么把错误优雅地拦在业务之外。