尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Data Sort 与 QueryDSL 排序转换:从原理到工程实践

Spring Data Sort 与 QueryDSL 排序转换:从原理到工程实践 1. 混合使用 Spring Data Sort 与 QueryDSL 时的排序痛点最近在重构一个订单查询接口时遇到一个非常典型的问题接口入参是 Spring Data 体系的Pageable前端通过?sortcreatedAt,descsortuser.name,asc这样的参数传递排序条件但查询部分已经全面切换到 QueryDSL用的是JPAQueryFactory。结果就是 Pageable 里的Sort在 QueryDSL 这边根本没法直接用只能眼睁睁看着 Sorting 失效。1.1 两边排序体系的设计差异先说清楚这两个东西的本质区别。Spring Data 的Sort是一个与实现技术无关的排序描述对象它只表达语义按什么属性排序、升序还是降序、是否忽略大小写、空值排前还是排后。真正执行排序时由 Spring Data JPA 的Querydsl工具类把它翻译成 JPA Criteria 或 JPQL。而 QueryDSL 的OrderSpecifier是 QueryDSL 类型体系里的排序表达式它直接持有一个Expression对象表达的是对某个具体路径表达式应用某个排序方向。差异点在于Spring Data Sort 的属性是字符串比如user.name是运行期拼接的OrderSpecifier 的属性路径通常来自 Q 类是编译期生成的类型安全路径。这两个体系各有用武之地但一混用就难受。Spring Data Sort 的好处是能和Pageable无缝配合Controller 层可以直接绑定OrderSpecifier 的好处是在手写 JPAQuery 时能确保类型安全、不会打错字段名。实际情况往往是想保留 Pageable 的便捷又不想放弃 QueryDSL 的动态查询能力于是就需要在中间做一层转换。1.2 典型场景手写 JPAQuery 却收到 Pageable 的 Sort我重构的这个订单查询接口查询条件非常多状态、时间范围、关键字、金额区间加起来有十几个参数。用 Spring Data JPA 的JpaSpecificationExecutor写是可以但 Predicate 的构造逻辑在复杂场景下很痛苦团队后来统一改成了 QueryDSL。Controller 层的代码长这样GetMapping(/orders) public PageOrderDTO search(OrderSearchRequest request, Pageable pageable) { return orderService.search(request, pageable); }Service 层拿到Pageable后传给 Repository 实现类。Repository 实现类里是手写的JPAQueryJPAQueryOrder query queryFactory.selectFrom(order) .leftJoin(order.user, user) .fetchJoin(); // 各种 where 条件拼接 if (StringUtils.hasText(request.getOrderNo())) { query.where(order.orderNo.contains(request.getOrderNo())); } // 排序怎么办pageable.getSort() 不能直接塞进来在网上搜过一圈最常见的做法是手动解析 Sort 里的每个Sort.Order然后逐个 new 一个OrderSpecifier。思路没问题但网上很多代码写得都比较简陋要么没处理ignoreCase要么没处理NULLS FIRST/LAST要么嵌套属性解析有问题。用在一个简单接口上没问题一旦排序字段涉及关联对象属性、或者前端传了带点分路径的排序字段就暴露出各种边界问题。1.3 为什么直接沿用 Spring Data 的转换实现不满足需求有人可能会问Spring Data JPA 内部不也是做类似转换吗直接把它的实现复制过来不就行了Spring Data JPA 的Querydsl类里确实有一个toOrderSpecifier方法但它依赖一个重要的前提需要把 Sort 的属性名和实体类的 Q 类绑定起来。它在SimpleEntityPathResolver内部查找实体路径再结合PathInformation去解析属性。这套机制绑定的是 Spring Data JPA 的 Repository 代理逻辑复用起来耦合很重不是拿过来就能作为独立工具类用的。另一个问题是Spring Data JPA 对 Sort 属性的合法性校验、JpaSort.unsafe() 的特殊处理都假设排序最终在 Spring Data 自己的查询执行链路上走。而我们手写 JPAQuery 的场景排序要交给 QueryDSL 执行两套语义在属性不存在时怎么报错空值默认策略是什么上都可能不一致。所以更务实的方案是写一个独立、通用、可控的转换器只依赖 QueryDSL 自身的 API不依赖 Spring Data JPA 的实现细节。这也是下面要展开的核心内容。2. 通用转换器实现PathBuilder 桥接两种排序2.1 PathBuilder 为什么适合做这个桥QueryDSL 给动态查询提供了一个关键类PathBuilder。它允许你在运行期根据字符串属性名解析出对应的Path表达式。比如PathBuilderOrder pathBuilder new PathBuilder(Order.class, order); PathObject createdAtPath pathBuilder.get(createdAt);createdAtPath和QOrder.order.createdAt在最终的 JPQL 生成上作用类似。有了这个机制Spring Data Sort 里的字符串属性名就能被翻译成 QueryDSL 能识别的路径表达式。PathBuilder 尤其适合干这个事的原因有三个它不依赖具体的 Q 类。只要传入实体类型和变量名就能动态解析任意属性路径。它支持点分嵌套路径。pathBuilder.get(user.name)能生成order.user.name这样的嵌套属性表达式。它不是 QueryDSL 代码生成器的产物本身就是为运行期场景设计的通用性和扩展性都更好。当然类型安全上它就比不过 Q 类了。属性写错不会在编译期报错只能运行期暴露这点后面章节会专门讲怎么防守。2.2 完整的 Sort2QuerydslConverter 代码先写一个最基础的版本把骨架搭起来public class Sort2QuerydslConverter { public static ListOrderSpecifier? convert(Sort sort, PathBuilder? pathBuilder) { if (sort null || sort.isUnsorted()) { return Collections.emptyList(); } ListOrderSpecifier? orderSpecifiers new ArrayList(); for (Sort.Order order : sort) { OrderSpecifier? specifier convertOrder(order, pathBuilder); if (specifier ! null) { orderSpecifiers.add(specifier); } } return orderSpecifiers; } private static OrderSpecifier? convertOrder(Sort.Order order, PathBuilder? pathBuilder) { String property order.getProperty(); if (!StringUtils.hasText(property)) { return null; } boolean ascending order.isAscending(); Order direction ascending ? Order.ASC : Order.DESC; // 构造目标路径支持 order.user.name 这种点分嵌套 PathObject targetPath pathBuilder.get(property); OrderSpecifier? specifier new OrderSpecifier(direction, targetPath); // 空值处理对齐 Spring Data 的 NullHandling 语义 if (order.getNullHandling() Sort.NullHandling.NULLS_FIRST) { specifier specifier.nullsFirst(); } else if (order.getNullHandling() Sort.NullHandling.NULLS_LAST) { specifier specifier.nullsLast(); } return specifier; } }注意这里有个类型细节pathBuilder.get(property)的返回值是PathObject泛型是 Object。在 QueryDSL 里OrderSpecifier要求第一个参数是Order枚举第二个参数是ExpressionT用PathObject完全可以构造OrderSpecifierObject。在最终生成 JPQL 时QueryDSL 会根据路径元数据感知到实际字段类型这里不需要过于纠结泛型类型对不对。2.3 在 Repository 实现类中怎么挂接有了转换器业务代码就干净了。以订单查询为例Override public PageOrder search(OrderSearchRequest request, Pageable pageable) { QOrder order QOrder.order; QUser user QUser.user; JPAQueryOrder query queryFactory .selectFrom(order) .leftJoin(order.user, user) .fetchJoin(); // 动态条件拼接 if (StringUtils.hasText(request.getOrderNo())) { query.where(order.orderNo.contains(request.getOrderNo())); } if (StringUtils.hasText(request.getUserName())) { query.where(user.name.contains(request.getUserName())); } // 排序把 Pageable 的 Sort 转成 OrderSpecifier if (pageable.getSort().isSorted()) { PathBuilderOrder pathBuilder new PathBuilder(Order.class, order); ListOrderSpecifier? orderSpecifiers Sort2QuerydslConverter.convert(pageable.getSort(), pathBuilder); query.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])); } // 分页 long total query.fetchCount(); ListOrder content query .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); return new PageImpl(content, pageable, total); }这里有两点要特别说清楚。第一new PathBuilder(Order.class, order)的第二个参数order是变量名。它必须和QOrder.order的变量名一致否则生成的 JPQL 里别名匹配不上运行期直接报错。最稳妥的写法是PathBuilderOrder pathBuilder new PathBuilder( QOrder.order.getType(), QOrder.order.getMetadata() );不过日常为了可读性直接用new PathBuilder(Order.class, order)也够前提是你知道 Q 类的变量名是什么。第二count 查询和 fetch 查询用的是同一个 JPAQuery 实例。QueryDSL 的fetchCount()会把原查询包装成select count(*)如果查询里带了fetchJoin()生成的 count SQL 有时会不合法。这个坑后面章节详细说这里先记住如果 count 有问题单独写一个不含 fetchJoin 的 count 查询别省那几行代码。3. 边界处理ignoreCase、NullHandling 与嵌套属性基础版转换器能用但离通用还有距离。排序场景里常见的边界情况至少有这么几个忽略大小写、空值排序策略、嵌套属性解析。3.1 ignoreCase 只对字符串有效否则会踩类型坑Spring Data 的Sort.Order有一个ignoreCase()方法语义是排序时忽略大小写。在数据库层面JPA 会生成order by lower(column)之类的表达式。QueryDSL 里对字符串路径做忽略大小写排序要用StringExpression.lower()if (order.isIgnoreCase()) { StringPath stringPath pathBuilder.getString(property); if (ascending) { return stringPath.lower().asc(); } else { return stringPath.lower().desc(); } }但这里有个隐含风险getString(property)会强制把属性解析为 String 类型。如果前端传的排序属性实际是数值类型或日期类型运行期就会抛类型转换异常。所以不能无脑对任意属性调用getString。我在实际项目里的处理方式是把 ignoreCase 的处理放到一个独立的方法里由调用方显式传入哪些排序属性需要忽略大小写的集合如果属性不在这个集合里即使order.isIgnoreCase()为 true也按普通排序处理。这样既不会破坏接口兼容性也避免了对所有字段做字符串强转。改进后的代码public static ListOrderSpecifier? convert(Sort sort, PathBuilder? pathBuilder, SetString caseInsensitiveProperties) { // ... for (Sort.Order order : sort) { if (order.isIgnoreCase() caseInsensitiveProperties.contains(order.getProperty())) { // 走忽略大小写分支 } else { // 走普通分支 } } }或者更简单一点在项目规范里约定需要忽略大小写的排序字段必须显式在配置类中声明而不是依赖前端传参。毕竟排序字段通常就那么几个一百个字段都能排序本身也是一种设计失误。3.2 nullsFirst 和 nullsLast 不是所有数据库都支持Sort.NullHandling有三种取值NATIVE、NULLS_FIRST、NULLS_LAST。默认是NATIVE即交给数据库自己决定空值默认排前面还是排后面。QueryDSL 的OrderSpecifier也提供了.nullsFirst()和.nullsLast()方法映射到 JPQL 里就是nulls first和nulls last。转换逻辑本身不复杂上面基础版代码已经写了。真正的坑在数据库方言上。PostgreSQL、Oracle、H2 支持NULLS FIRST/LAST语法但 MySQL 不支持。如果生产环境是 MySQL前端传了nullsFirst的排序生成的 SQL 拿到 MySQL 上执行会直接语法报错。我在一个项目里就踩过这个坑本地开发用的 H2 数据库测试一切正常部署到生产 MySQL 后某个带空值排序的接口 500 了排查了半天才发现是排序语法不兼容。解决方案有两个方向在转换器里加一个开关根据数据库方言决定是否应用 nullsFirst/nullsLast 逻辑。如果是 MySQL 系就忽略NullHandling设置退回默认行为。数据库层面处理比如 MySQL 下用order by (column is null), column这样的表达式但 QueryDSL 的 OrderSpecifier 不太好表达这种混合逻辑不如直接在转换层规避。我的建议是第一种简单直接把兼容性问题挡在转换层之外。毕竟排序语义和数据库能力发生冲突时业务上的排序往往是有最好没有也能接受。3.3 点分嵌套路径的正确姿势与安全校验PathBuilder 的get(String property)方法支持点分路径吗答案是支持。pathBuilder.get(user.name)在 QueryDSL 内部会解析为一个嵌套路径order.user.name在最终 JPQL 里表现为order by order.user.name。这功能在测试环境看起来一切正常因为 Spring Data Sort 也支持传user.name这种点分属性。但有个隐患如果user是集合属性比如用户有多个订单在order by里访问集合关联属性是 JPQL 规范不允许的运行期会抛异常。单值关联ManyToOne、OneToOne则没问题会自动隐式 join。为了确保转换器的健壮性我通常会对外提供一个白名单校验方法在转换之前先检查排序属性是否是允许的public static boolean isValidSortProperty(Sort sort, SetString allowedProperties) { for (Sort.Order order : sort) { if (!allowedProperties.contains(order.getProperty())) { return false; } } return true; }这个白名单机制看起来保守实际价值很大。它同时解决了三个问题避免前端传任意字段导致运行期报错、避免排序字段涉及多值关联导致查询异常、避免恶意传参导致数据库压力过大。4. 实际踩过的坑从排序结果异常到数据库报错这一节把我在真实项目里遇到过的排序转换相关坑逐一梳理下每个都有完整的排查链路。排序功能看起来简单出错时的表现却往往很隐蔽。4.1 count 查询与排序 JOIN 不一致导致的重复数据现象列表页刷新后偶现重复数据但总条数不变翻页混乱。从接口返回看content 里的 id 有重复totalElements 却是对的。排查过程大概走了这么几条线第一步先怀疑 SQL 本身。把 QueryDSL 打印出来的 SQL 拿到了数据库里执行发现排序生效了没有重复行。这就说明在数据库层面查询结果是对的。第二步怀疑 fetchJoin。打印 SQL 后发现排序字段涉及order.user.name时QueryDSL 会生成一个隐式 join。而查询里已经有一个显式的leftJoin(order.user, user)两个 join 语义不同隐式 join 是 inner join如果字段路径是单值关联属性显式 join 是 left join。当排序字段的隐式 join 过滤掉了user为 null 的订单后查询结果的行数和只做 leftJoin 时不同但 Pageable 的 count 查询是单独执行的它聚合逻辑不一样于是 totalElements 和 content 就出现了错位。真正修复的关键是让排序不产生额外的隐式 join。做法是在查询里显式声明这个 joinJPAQueryOrder query queryFactory .selectFrom(order) .leftJoin(order.user, user) .fetchJoin();然后转换器生成的排序表达式基于显式 join 过的路径来构造而不是让 QueryDSL 重新解析一个隐式 join。实际操作时因为PathBuilder.get(user.name)会生成新的路径无法直接复用 QUser.user.name所以最稳妥的方案是把排序属性映射到已经存在的 join 路径上。这也是为什么后面会讲自定义属性映射扩展。通用转换器解决 80% 场景剩下的 20% 复杂查询需要能够手动指定排序表达式。4.2 PathBuilder 别名与 Q 类变量不一致现象接口在联调环境正常部署到测试环境后报unknown property之类的异常或者直接 SQL 解析失败。这类问题多半是 PathBuilder 的变量名和 Q 类的变量名不一致导致的。QueryDSL 的 JPQL 生成时变量名必须唯一且和 from 子句一致。如果 Q 类的变量名改了PathBuilder 没跟着改生成的 JPQL 就会出现 order 找不到 的异常。排查方法很直接把最终 SQL 打印出来看。QueryDSL 的 JPAQuery 可以通过toString()拿到 JPQL 字符串log.debug(query sql: {}, query);你会发现 JPQL 里的 from 子句是Order order而 order by 子句却引用了另一个变量名自然就报错了。修复方式上面说过最靠谱的是从 Q 类元数据构造 PathBuilder而不是手写字符串变量名PathBuilderOrder pathBuilder new PathBuilder( QOrder.order.getType(), QOrder.order.getMetadata() );getMetadata()返回的 PathMetadata 里带着正确的变量名这样无论 Q 类怎么生成都不会对不上。4.3 空值排序在 MySQL 上的兼容性故障现象开发环境H2里排序正常生产环境MySQL 8.0某个列表接口直接 500。错误日志明确显示是语法错误位置在 order by 子句附近。完整的排查链路是先看 querydsl 打印的 SQL发现是order by order.created_at desc nulls last。这个语法 MySQL 8.0 不支持。H2 支持所以本地测不出来。当时临时修复是直接把转换器里的 nullsFirst/nullsLast 逻辑去掉但这么做等于放弃了排序空值策略不够优雅。后来在转换器里加了数据库方言判断// 在启动或第一次使用时判断当前数据库类型 if (databaseType ! DatabaseType.MYSQL) { // 只有支持 nulls first/last 语法的数据库才应用 NullHandling }实现上可以注入 DataSource 或者直接读数据库连接的 metadata 来判定。这个判断只做一次性能影响可以忽略。这个坑想强调一个经验用 QueryDSL 这类动态 SQL 生成框架时不能假设 JPQL 语法在所有数据库方言上都一样。凡是和排序、null 处理、分页相关的特性最好在目标数据库上做一轮完整的集成测试而不是只在 H2 上自测。5. 为了团队共用做了一层小扩展5.1 字段白名单别让前端随意排序任意列通用转换器本身是无脑的给什么属性名就解析什么路径。但生产环境里排序字段的值来自前端这就存在两个问题一是排序字段名合法性问题字段不存在时 PathBuilder 要到执行阶段才报错影响接口稳定性二是性能问题前端可以传任意排序包括一些没有索引的文本列排序耗时会直线上升。团队内部后来在通用转换器外面包了一层public class OrderSearchSupport { private static final MapClass?, SetString WHITE_LIST new ConcurrentHashMap(); public static ListOrderSpecifier? convertSafe(Sort sort, PathBuilder? pathBuilder, Class? entityClass) { if (sort null || sort.isUnsorted()) { return Collections.emptyList(); } SetString allowedFields WHITE_LIST.computeIfAbsent(entityClass, OrderSearchSupport::loadConfig); for (Sort.Order order : sort) { if (!allowedFields.contains(order.getProperty())) { throw new IllegalArgumentException( 排序字段不允许: order.getProperty()); } } return Sort2QuerydslConverter.convert(sort, pathBuilder); } }白名单的来源可以是配置文件、注解、或者一个显式的资源表。实际用下来白名单机制的价值比想象中大得多因为它把排序字段变成一个显式受控的接口约定而不是开发者单方面相信前端不会乱传。5.2 自定义属性映射覆盖复杂 JOIN 场景前文提到的隐式 JOIN 问题终极解法是允许调用方为某些排序字段指定一个具体的表达式映射。这就是自定义属性映射函数FunctionalInterface public interface SortFieldMapper { Expression? map(String property); }转换器里增加一个重载方法public static ListOrderSpecifier? convert(Sort sort, FunctionString, Expression? propertyResolver) { ListOrderSpecifier? orderSpecifiers new ArrayList(); for (Sort.Order order : sort) { Expression? expression propertyResolver.apply(order.getProperty()); if (expression null) { continue; } OrderSpecifier? specifier new OrderSpecifier( order.isAscending() ? Order.ASC : Order.DESC, expression ); orderSpecifiers.add(specifier); } return orderSpecifiers; }业务侧的用法MapString, Expression? sortMapping new HashMap(); sortMapping.put(userName, QOrder.order.user.name); sortMapping.put(orderTime, QOrder.order.createdAt); ListOrderSpecifier? specifiers Sort2QuerydslConverter.convert(pageable.getSort(), sortMapping::get);这样既能保留 Pageable 的接口便利性又能把复杂 JOIN 场景的排序表达式牢牢控制在开发者手里。通用 PathBuilder 负责处理简单属性排序自定义映射负责处理复杂业务排序两个互不干扰。5.3 排序字段解析错误的快速定位最后分享一个排查排序问题的通用思路。QueryDSL 排序出错时报错位置往往离真正问题根源比较远比如在底层 SQL 执行时才暴露。这时候最快的方法永远是看最终生成的 JPQL/SQL。在实际项目里我会在转换器里加一个 debug 日志把每个 Sort.Order 被解析成了什么表达式打印出来log.debug(Sort property [{}] - target expression [{}], order.getProperty(), targetPath);同时把整个 JPAQuery 的 toString() 输出log.debug(Generated JPQL: {}, query);这两行日志在平时看没什么用真出问题的时候能大幅缩短排查时间。尤其是那种开发环境正常、测试环境报错的问题基本靠对比 SQL 就能快速定位。排查时还有个技巧把报错信息里的异常栈往上看如果是IllegalArgumentException: Unsupported expression或类似的路径解析异常基本可以断定是 PathBuilder 解析路径的问题优先检查属性名拼写和点分路径。如果是数据库语法错误优先检查 nullsFirst/nullsLast、字符串排序等方言差异。捋清楚这两个方向排序问题其实不难排查。从最开始只处理 ASC/DESC 的基础转换器到现在带白名单校验、方言兼容、自定义映射的完整工具类这个转换器在团队内部迭代了快一年。回过头看排序这件事看着简单真要做得通用、稳当涉及的边界情况一点也不少。最后再补充一个建议如果你也在维护类似的基础工具类尽量把日志打全一点按实体类分类维护白名单并且把它当成一个独立小模块持续迭代别指望一次性写出适用于所有场景的终极代码。
返回列表