> 技术文档 > ✨ 优雅处理前端动态排序请求 Spring Data JPA分页与自定义排序实战 ✨_springboot前端多字段动态排序

✨ 优雅处理前端动态排序请求 Spring Data JPA分页与自定义排序实战 ✨_springboot前端多字段动态排序


✨ 优雅处理前端动态排序请求 🚀 Spring Data JPA分页与自定义排序实战 ✨

哈喽,各位代码魔法师们!🧙‍♂️ 在日常的Web开发中,我们经常需要处理前端发送过来的分页和排序请求。如果排序规则是固定的,那很简单。但如果前端希望能够动态指定按哪个字段排序(properties)以及排序方向(direction,升序ASC/降序DESC),后端就需要灵活应对了。

今天,我们就来聊聊如何在Spring Boot项目中,结合Spring Data JPA (Java Persistence API,Java持久化应用程序接口) 和自定义的DTO (Data Transfer Object,数据传输对象) 来优雅地实现这一功能。

📝 本文内容概览 (表格总结)

方面 传统/固定排序方式 本文推荐的动态排序方式 优点 PageRequest创建 PageRequest.of(page, size, Sort.by(\"fixedField\")) pageWithSearch.toPageable() (内部处理 directionproperties) ✅ 代码更简洁
✅ 动态性强
✅ 逻辑内聚到DTO 排序参数处理 后端硬编码或通过大量if-else判断 DTO的 toPageable() 方法统一处理 ✅ 减少Service层复杂度
✅ 易于维护和扩展 前端友好度 前端无法自由控制排序 前端通过传递 directionproperties 即可控制排序 ✅ 提升用户体验
✅ API (Application Programming Interface,应用程序编程接口)更灵活 代码示例 PageRequest.of(0, 10, Sort.Direction.DESC, \"id\") Pageable pageRequest = pageWithSearch.toPageable(); ✅ 一行代码搞定分页与排序对象的创建

🤔 问题背景:当排序不再“固定”

想象一下,我们有一个列表页面,用户不仅想看第一页的数据,还想按照“创建时间倒序”、“价格升序”或者“销量倒序”等不同方式查看。如果后端每次都写死排序规则,那前端的需求一来,我们就得改代码,这显然不够优雅。

前端通常会这样传递参数:

  • page: 页码
  • size: 每页大小
  • direction: 排序方向 (如 ASCDESC)
  • properties: 排序字段 (如 [\"createdDate\"][\"price\", \"name\"])

我们的目标就是在后端接收这些参数,并正确地应用到数据库查询中。

💡 解决方案:PageWithSearch DTO 与 toPageable() 方法

在您的项目中,已经有了 PageWithSearch 类(它继承自 BasePage),这个类非常棒,因为它已经内置了处理分页和排序参数的逻辑,特别是 toPageable() 方法。

让我们看看 BasePage 中的关键方法 toPageable()

// BasePage.java (部分代码)public Pageable toPageable() { page = page != null ? page : 0; // 默认页码为0 size = size != null ? size : 9999; // 默认页大小 (注意:9999可能过大,按需调整) // 默认倒序 Sort.Direction dir = Sort.Direction.fromOptionalString(this.direction).orElse(Sort.Direction.DESC); // 默认按创建时间排序,如果properties为空或未提供 Sort sort = (properties != null && properties.length > 0 && !StringUtils.isEmpty(properties[0])) ? Sort.by(dir, properties) : Sort.by(dir, \"createdDate\"); // 默认排序字段 return PageRequest.of(page, size, sort);}

这个方法做了几件事:

  1. 设置默认的页码和页大小(如果前端没传)。
  2. 解析 direction 字符串为 Sort.Direction 枚举,默认为 DESC (降序)。
  3. 如果前端传递了 properties (排序字段),则使用这些字段和解析出的方向创建 Sort 对象。
  4. 如果未传递 properties,则默认使用 \"createdDate\" 字段进行排序。
  5. 最后,使用页码、页大小和 Sort 对象创建并返回一个 Pageable 实例。

🛠️ Service层代码改造

现在,我们来看看如何在Service层利用这个 toPageable() 方法。

改造前 (可能的样子):

// @Transactional(readOnly = true)// public Page findPaginatedConsignmentSettlementByAdminIdAndSearch(// Integer adminId, @Valid PageWithSearch pageWithSearch) {//// // 手动创建PageRequest,排序规则可能固定或需要复杂逻辑判断// PageRequest pageRequest = PageRequest.of(//  pageWithSearch.getPage() != null ? pageWithSearch.getPage() : 0,//  pageWithSearch.getSize() != null ? pageWithSearch.getSize() : 10,//  Sort.by(Sort.Direction.DESC, \"sequence\") // 假设这里是固定排序// );// String field = pageWithSearch.getField();// String value = pageWithSearch.getValue();//// // ...后续查询逻辑...// }

改造后 (使用 toPageable()): 🎉

@Transactional(readOnly = true)public Page<ConsignmentSettlement> findPaginatedConsignmentSettlementByAdminIdAndSearch( Integer adminId, @Valid PageWithSearch pageWithSearch) { // 关键改动:直接调用 toPageable() 获取包含动态排序的 Pageable 对象 Pageable pageRequest = pageWithSearch.toPageable(); // ✨ 就是这么简单! String field = pageWithSearch.getField(); String value = pageWithSearch.getValue(); log.debug(\"分页查询 ConsignmentSettlement: adminId={}, field={}, value={}, page={}, size={}, sort={}\", adminId, field, value, pageRequest.getPageNumber(), pageRequest.getPageSize(), pageRequest.getSort()); Page<ConsignmentSettlement> settlementPage; if (!StringUtils.isEmpty(field) && !StringUtils.isEmpty(value)) { settlementPage = consignmentSettlementRepository.findPaginatedConsignmentSettlementByAdminIdAndFieldAndValue( adminId, field, value, pageRequest); // 传递 pageRequest } else { // 注意:如果方法名中已包含 OrderBy (如 findByAdminIdOrderBySequenceDesc), // Pageable 中的 sort 可能会覆盖或与方法名中的排序规则结合。 // 为了清晰,推荐 Repository 方法只接受 Pageable 来控制排序。 // 例如:findByAdminId(Integer adminId, Pageable pageable) settlementPage = consignmentSettlementRepository.findByAdminIdOrderBySequenceDesc(adminId, pageRequest); // 传递 pageRequest } // ... 后续的业务逻辑,如计算金额等 ... // (代码与您原先的保持一致) return settlementPage;}

看到了吗?仅仅一行 Pageable pageRequest = pageWithSearch.toPageable(); 就替代了之前可能需要多行代码来处理分页和排序的逻辑。这使得Service层的代码更加简洁,并将分页和排序的构建逻辑内聚到了 PageWithSearch (及其父类 BasePage) 中。

🌊 流程图 (Mermaid Flowchart)

让我们用流程图梳理一下整个过程:

#mermaid-svg-wIcIa9oMGUooZDwF {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-wIcIa9oMGUooZDwF .error-icon{fill:#552222;}#mermaid-svg-wIcIa9oMGUooZDwF .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wIcIa9oMGUooZDwF .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-wIcIa9oMGUooZDwF .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wIcIa9oMGUooZDwF .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wIcIa9oMGUooZDwF .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wIcIa9oMGUooZDwF .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wIcIa9oMGUooZDwF .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wIcIa9oMGUooZDwF .marker.cross{stroke:#333333;}#mermaid-svg-wIcIa9oMGUooZDwF svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wIcIa9oMGUooZDwF .label{font-family:\"trebuchet ms\",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wIcIa9oMGUooZDwF .cluster-label text{fill:#333;}#mermaid-svg-wIcIa9oMGUooZDwF .cluster-label span{color:#333;}#mermaid-svg-wIcIa9oMGUooZDwF .label text,#mermaid-svg-wIcIa9oMGUooZDwF span{fill:#333;color:#333;}#mermaid-svg-wIcIa9oMGUooZDwF .node rect,#mermaid-svg-wIcIa9oMGUooZDwF .node circle,#mermaid-svg-wIcIa9oMGUooZDwF .node ellipse,#mermaid-svg-wIcIa9oMGUooZDwF .node polygon,#mermaid-svg-wIcIa9oMGUooZDwF .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wIcIa9oMGUooZDwF .node .label{text-align:center;}#mermaid-svg-wIcIa9oMGUooZDwF .node.clickable{cursor:pointer;}#mermaid-svg-wIcIa9oMGUooZDwF .arrowheadPath{fill:#333333;}#mermaid-svg-wIcIa9oMGUooZDwF .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wIcIa9oMGUooZDwF .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wIcIa9oMGUooZDwF .edgeLabel{background-color:#e8e8e8;text-align:center;}#mermaid-svg-wIcIa9oMGUooZDwF .edgeLabel rect{opacity:0.5;background-color:#e8e8e8;fill:#e8e8e8;}#mermaid-svg-wIcIa9oMGUooZDwF .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wIcIa9oMGUooZDwF .cluster text{fill:#333;}#mermaid-svg-wIcIa9oMGUooZDwF .cluster span{color:#333;}#mermaid-svg-wIcIa9oMGUooZDwF div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wIcIa9oMGUooZDwF :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}获取page, size, direction, properties前端发送分页与排序请求 (含page, size, direction, properties)Controller接收请求参数并封装到PageWithSearch对象Service方法 (findPaginated...) 被调用,传入PageWithSearch对象Service内部调用 pageWithSearch.toPageable() 方法toPageable()方法内部:1. 处理默认值 (page, size, direction)2. 根据direction和properties构建Sort对象 (若properties为空,则使用默认排序字段如\'createdDate\')3. 使用page, size和Sort对象创建Pageable实例toPageable()方法返回构建好的Pageable对象给ServiceService将Pageable对象传递给Repository方法进行查询Repository根据Pageable中的分页和排序信息执行数据库查询数据库返回排序和分页后的结果集数据逐层返回给前端

🔄 时序图 (Mermaid Sequence Diagram)

下面是参与者之间交互的时序图:

#mermaid-svg-7uLBkuRQOFrMhqjP {font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;fill:#333;}#mermaid-svg-7uLBkuRQOFrMhqjP .error-icon{fill:#552222;}#mermaid-svg-7uLBkuRQOFrMhqjP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7uLBkuRQOFrMhqjP .edge-thickness-normal{stroke-width:2px;}#mermaid-svg-7uLBkuRQOFrMhqjP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7uLBkuRQOFrMhqjP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7uLBkuRQOFrMhqjP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7uLBkuRQOFrMhqjP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7uLBkuRQOFrMhqjP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7uLBkuRQOFrMhqjP .marker.cross{stroke:#333333;}#mermaid-svg-7uLBkuRQOFrMhqjP svg{font-family:\"trebuchet ms\",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7uLBkuRQOFrMhqjP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7uLBkuRQOFrMhqjP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-7uLBkuRQOFrMhqjP .actor-line{stroke:grey;}#mermaid-svg-7uLBkuRQOFrMhqjP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-7uLBkuRQOFrMhqjP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-7uLBkuRQOFrMhqjP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-7uLBkuRQOFrMhqjP .sequenceNumber{fill:white;}#mermaid-svg-7uLBkuRQOFrMhqjP #sequencenumber{fill:#333;}#mermaid-svg-7uLBkuRQOFrMhqjP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-7uLBkuRQOFrMhqjP .messageText{fill:#333;stroke:#333;}#mermaid-svg-7uLBkuRQOFrMhqjP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7uLBkuRQOFrMhqjP .labelText,#mermaid-svg-7uLBkuRQOFrMhqjP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-7uLBkuRQOFrMhqjP .loopText,#mermaid-svg-7uLBkuRQOFrMhqjP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-7uLBkuRQOFrMhqjP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-7uLBkuRQOFrMhqjP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-7uLBkuRQOFrMhqjP .noteText,#mermaid-svg-7uLBkuRQOFrMhqjP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-7uLBkuRQOFrMhqjP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7uLBkuRQOFrMhqjP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7uLBkuRQOFrMhqjP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-7uLBkuRQOFrMhqjP .actorPopupMenu{position:absolute;}#mermaid-svg-7uLBkuRQOFrMhqjP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-7uLBkuRQOFrMhqjP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-7uLBkuRQOFrMhqjP .actor-man circle,#mermaid-svg-7uLBkuRQOFrMhqjP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-7uLBkuRQOFrMhqjP :root{--mermaid-font-family:\"trebuchet ms\",verdana,arial,sans-serif;}前端控制器服务层PageWithSearch对象数据访问层数据库发起HTTP请求 (GET /settlements?page=0&size=10&properties=name&direction=ASC)调用findPaginated...(adminId, pageWithSearch)调用toPageable()返回Pageable实例 (已包含Sort信息)调用repository.findByXXX(..., pageable)执行SQL查询 (含LIMIT, OFFSET, ORDER BY子句)返回查询结果返回Page返回Page响应JSON数据前端控制器服务层PageWithSearch对象数据访问层数据库

💡 关键点与注意事项

  1. DTO的设计:拥有一个像 BasePagePageWithSearch 这样能够处理通用分页和排序逻辑的DTO基类是非常有用的。
  2. toPageable() 的默认值
    • page 默认 0
    • size 默认 9999请注意:这个默认值可能非常大,如果前端不传 size,可能会导致查询大量数据,引发性能问题。建议根据业务场景设置一个更合理的默认值,比如 1020
    • direction 默认 DESC
    • properties (排序字段) 默认 [\"createdDate\"]。确保您的实体确实有这个字段,或者修改为您业务中常用的默认排序字段。
  3. Repository方法签名
    • 如果您的Repository方法名中已经包含了 OrderBy 子句(例如 findByAdminIdOrderBySequenceDesc),那么传递给它的 Pageable 对象中的 Sort 信息可能会覆盖方法名中定义的排序,或者与之结合(具体行为取决于Spring Data JPA的版本和实现)。
    • 为了更清晰地控制排序,推荐使用不包含 OrderBy 的方法名,例如 findByAdminId(Integer adminId, Pageable pageable),完全由 Pageable 对象来决定排序规则。
  4. 字段名转换BasePage 中的 getOrder(String defaultOrder) 方法使用了 SqlUtil.camelToUnderline(order),这表示它会将驼峰命名的属性(如 createdDate)转换为下划线命名(如 created_date)再用于SQL排序。这在数据库表列名是下划线风格时非常有用。请确保这符合您的数据库设计。
  5. 多字段排序properties 是一个字符串数组 String[],这意味着前端可以传递多个排序字段,例如 properties=name&properties=age (在Spring MVC中通常这样接收数组参数,或者前端直接传递JSON数组 [\"name\", \"age\"]),Sort.by(dir, properties) 会按数组顺序应用排序。

🏁 总结

通过利用 PageWithSearch DTO 中的 toPageable() 方法,我们可以非常优雅地处理前端传递的动态排序请求。这不仅简化了Service层的代码,还提高了代码的可维护性和灵活性。记住检查并调整 toPageable() 中的默认值,以适应您的具体业务需求。

希望这篇博客能帮助你更好地处理动态排序!Happy coding! 💻🎉


🧠 思维导图 (Markdown格式)

✨ 优雅处理前端动态排序请求 Spring Data JPA分页与自定义排序实战 ✨_springboot前端多字段动态排序