簡単なクエリインターフェースを書く
JPAを使ってプログラム的なSQL構築を行うとしたら、どうしますか?一般的には、Specificationを使って条件を構築し、動的な条件クエリを行います。例えば、私が関わっているプロジェクトでは、多くが以下のように書かれています:
/**
* <p>条件に基づいて注文情報を取得する</p>
* @param orderQueryCondition 注文クエリ条件
* @return java.util.List<com.demo.po.OrderPO>
*/
public List<OrderPO> findByCondition(OrderQueryCondition orderQueryCondition) {
Specification specification = (root, criteriaQuery, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
if(!StringUtils.isEmpty(orderQueryCondition.getOrderItemName())) {
predicates.add(criteriaBuilder.equal(root.get("orderItemName"), orderQueryCondition.getOrderItemName()));
}
if(!StringUtils.isEmpty(orderQueryCondition.getOrderNo())) {
predicates.add(criteriaBuilder.equal(root.get("orderNo"), orderQueryCondition.getOrderNo()));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
};
return orderDao.findAll(specification);
}
コードは非常に簡単で、簡単に説明します
実際には、ラムダを使ってSpecificationインターフェースのtoPredicate()メソッドを実装しています。orderDao.findAll(specification)メソッド内で、その実装クラスSimpleJpaRepositoryがコンテナからEntityManagerオブジェクトを取得し、それぞれtoPredicate()メソッドに必要なroot、criteriaQuery、criteriaBuilderの3つのオブジェクトを取得し、最終的にPredicateオブジェクトを構築します。このPredicateオブジェクトがJPAに必要な最終的な条件を封装するオブジェクトです。
このコードに問題はないか?
実行の観点から見ると、非常に良いです。動的にSQLを生成でき、非常に柔軟です(この例ではインデックスなどの問題は一時的に無視してください)。コードも非常にクリアで理解しやすいです。
しかし、それは良いか?
私はそうは思いません。なぜなら、それには魔法値が含まれているからです。
魔法値
魔法値とは何ですか?ここで言うのは、ゲーム内のマジックポイントではなく、ハードコーディングされた数値や文字列のことです。魔法値には2つの問題点があります。
理解しにくい
プロジェクト内で最もよく見かける魔法値の場所は、いくつかのif条件判断内です。例えば、以下のコード:
if ("1".equals(this.medicareFlag) || "2".equals(this.medicareFlag)){
return 1;
}else if ("0".equals(this.medicareFlag)){
return 0;
}else {
return 2;
}
このコードを見た時、メンテナンス担当者としては頭痛がしませんか?1とは何か、2とは何か、あなたは誰で、私は誰なのか(
メンテナンスが難しい
例えば、注文の状態を判断する必要があり、その状態を変数として宣言せずに、上記のように判断するたびにチェックする場合、状態名を変更する必要がある時に、あちこち変更しなければなりません。例えば、以下のように:
public void 方法1(Order order) {
if("已支付".equals(order)) {
// blabla...
}
}
public void 方法2(Order order) {
if("已支付".equals(order)) {
// blabla...
}
}
上記のように書くと、もし顧客が「已支付」を「已付款」に変更したいと言った場合、それを変更するのは非常に困難です。
魔法値を排除しよう!
上記の例から、魔法値の悪い点は理解できたと思います。同様に、最初のクエリ例も魔法値を使用しています。フィールド名のようなものは通常あまり変更されませんが、このように書くと間違いやすく、また変更が必要になった場合には非常に困難です。
実際、mybatis-plusには同様の機能があります。それは、LambdaQueryWrapperとLambdaQueryChainWrapperを定義して、ラムダを使ってクエリ条件を構築します。LambdaQueryChainWrapperはチェーン方式で構築することもできます。
やるからには徹底的に、LambdaQueryChainWrapperを模倣して、フィールド名をラムダで指定でき、かつチェーン方式で構築できるツールを作ることに決めました。
実際にやってみよう! 始める前にもう一度原理を確認する 先ほど説明したように、Specificationの条件構築の原理を覚えていますか?SimpleJpaRepositoryのソースコードを簡単に見てみましょう。
詳細を表示:SimpleJpaRepository部分のソースコード
private final EntityManager em;
/*
* (non-Javadoc)
* @see org.springframework.data.jpa.repository.JpaSpecificationExecutor#findAll(org.springframework.data.jpa.domain.Specification)
*/
@Override
public List<T> findAll(@Nullable Specification<T> spec) {
// このメソッドはDao経由で呼び出されるエントリーポイント
return getQuery(spec, Sort.unsorted()).getResultList();
}
/**
* Creates a {@link TypedQuery} for the given {@link Specification} and {@link Sort}.
*
* @param spec can be {@literal null}.
* @param sort must not be {@literal null}.
* @return
*/
protected TypedQuery<T> getQuery(@Nullable Specification<T> spec, Sort sort) {
return getQuery(spec, getDomainClass(), sort);
}
/**
* Creates a {@link TypedQuery} for the given {@link Specification} and {@link Sort}.
*
* @param spec can be {@literal null}.
* @param domainClass must not be {@literal null}.
* @param sort must not be {@literal null}.
* @return
*/
protected <S extends T> TypedQuery<S> getQuery(@Nullable Specification<S> spec, Class<S> domainClass, Sort sort) {
// コンテナから取得したEntityManagerを通じてCriteriaBuilderオブジェクトとCriteriaQueryオブジェクトを取得する
CriteriaBuilder builder = em.getCriteriaBuilder();
CriteriaQuery<S> query = builder.createQuery(domainClass);
Root<S> root = applySpecificationToCriteria(spec, domainClass, query);
query.select(root);
if (sort.isSorted()) {
query.orderBy(toOrders(sort, root, builder));
}
return applyRepositoryMethodMetadata(em.createQuery(query));
}
/**
* Applies the given {@link Specification} to the given {@link CriteriaQuery}.
*
* @param spec can be {@literal null}.
* @param domainClass must not be {@literal null}.
* @param query must not be {@literal null}.
* @return
*/
private <S, U extends T> Root<U> applySpecificationToCriteria(@Nullable Specification<U> spec, Class<U> domainClass,
CriteriaQuery<S> query) {
Assert.notNull(domainClass, "Domain class must not be null!");
Assert.notNull(query, "CriteriaQuery must not be null!");
Root<U> root = query.from(domainClass);
if (spec == null) {
return root;
}
// 最も重要な部分、EntityManagerを通じてCriteriaBuilderを取得し、SpecificationのtoPredicate()メソッドを呼び出す
CriteriaBuilder builder = em.getCriteriaBuilder();
Predicate predicate = spec.toPredicate(root, query, builder);
if (predicate != null) {
query.where(predicate);
}
return root;
}
SpecificationオブジェクトがapplySpecificationToCriteria()メソッド内でtoPredicate()メソッドを呼び出されていることが分かります。これが最も重要な部分です。つまり、toPredicate()メソッドを実装すれば、この部分で処理を行うことができます。
Specificationインターフェースを実装し、LambdaSpecificationクラスを定義する
まず、Specificationインターフェースを実装し、必要な準備を確認しましょう。
元のコードを分析すると、List
では、私たちもList
それはできません。先ほどの原理から、Specificationはラムダを使って構築され、実際のroot, criteriaQuery, criteriaBuilderの3つのオブジェクトはSimpleJpaRepositoryオブジェクトの実行時にのみ渡されるため、チェーンメソッドを書く際にこれらのオブジェクトを取得できません。
ではどうしますか?
同様に、Functionを使えばいいのです。RootとCriteriaBuilderの2つのオブジェクトを受け取るために、BiFunctionを定義します。
private List<BiFunction<Root, CriteriaBuilder, Predicate>> predicateFunctions;
それでは、チェーン条件メソッドを定義する際に、次のように書けばいいのです。私たちの等しい条件はeq()と命名し、判定処理を追加します:
public LambdaSpecification<T> eq(Boolean ignoreNull, String columnName, Object value) {
// null値を無視するかどうかを選択し、nullの場合は条件を追加しない
if(ignoreNull && value == null) {
return this;
}
// functionをListに追加し、後でtoPredicate()で処理する
predicateFunctions.add((root, criteriaBuilder) -> criteriaBuilder.equal(root.get(columnName), value));
return this;
}
これで、私たちのLambdaSpecificationクラスが完成しました:
public class LambdaSpecification<T> implements Specification<T> {
private List<BiFunction<Root, CriteriaBuilder, Predicate>> predicateFunctions;
private LambdaSpecification() {
this.predicateFunctions = new ArrayList<>();
}
public static <T> LambdaSpecification<T> query() {
return new LambdaSpecification<>();
}
public LambdaSpecification<T> eq(Boolean ignoreNull, String columnName, Object value) {
// null値を無視するかどうかを選択し、nullの場合は条件を追加しない
if(ignoreNull && value == null) {
return this;
}
// functionをListに追加し、後でtoPredicate()で処理する
predicateFunctions.add((root, criteriaBuilder) -> criteriaBuilder.equal(root.get(columnName), value));
return this;
}
@Override
public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
// 事前に構築されたpredicateFunctionsを実行してPredicateオブジェクトを取得する
List<Predicate> predicates = predicateFunctions.stream()
.map(function -> function.apply(root, criteriaBuilder))
.collect(Collectors.toList());
// Predicateオブジェクトを構築する
return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
}
}
そして、呼び出しコードを変更します:
Specification specification = LambdaSpecification.query()
.eq(true, "orderNo", orderQueryCondition.getOrderNo())
.eq(true, "orderItemName", orderQueryCondition.getOrderItemName());
return orderDao.findAll(specification);
これで、コードは非常にシンプルでクリアになりました。実行して結果を確認しましょう。order_noのみを渡した場合と、両方を渡した場合の2つの例を試しました。完全に機能し、以下は実行されたSQLです:
-- 両方を渡したSQL
Hibernate: select orderpo0_.pid as pid1_0_, orderpo0_.order_item_name as order_item_name2_0_, orderpo0_.order_no as order_no3_0_ from ipn_order orderpo0_ where ( orderpo0_.is_available = 'Y') and orderpo0_.order_no=250037 and orderpo0_.order_item_name=?
-- order_noのみを渡したSQL
Hibernate: select orderpo0_.pid as pid1_0_, orderpo0_.order_item_name as order_item_name2_0_, o
lambdaのサポートを追加する
チェーン呼び出しは完璧に実現されましたので、次はlambdaの実装に取り掛かります。私たちの目標は、OrderPO::getOrderNoを渡して、orderNoフィールド名を認識できるようにすることです。実際には簡単で、eq()メソッドのString columnNameをFunctionに変更するだけです。
Functionでメソッド名を取得する方法 まず、Functionを使う場合、どのようにしてメソッド名を取得するかを見てみましょう。
実際には、Functionオブジェクト自体にはメソッド名を取得する方法がありません。メソッド名を取得するためには、シリアライズをサポートし、SerializedLambdaオブジェクトに変換する必要があります。これは、lambda式がシリアライズをサポートするときに、JavaがwriteReplace()メソッドを実装し、このメソッドがSerializedLambdaオブジェクトを返すためです。このオブジェクトには、求めているメソッド名が含まれています。
そのため、直接Functionクラスを使用することはできず、Functionクラスを継承し、シリアライズをサポートするSerializableFunctionインターフェースを定義する必要があります。
/**
* <p>シリアライズ可能なFunction</p>
*
* @param <T> Functionの入力型
* @param <R> Functionの結果型
*/
public interface SerializableFunction<T, R> extends Function<T, R>, Serializable {
}
次に、リフレクションを使用して具体的なメソッド名とフィールド名を取得します。
/**
* <p>フィールド名を取得する</p>
* @param columnNameGetter フィールド名を取得するFunction
* @return フィールド名
*/
private <T> String getColumnName(SerializableFunction<T, Object> columnNameGetter) {
String methodName = getMethodName(columnNameGetter);
if (methodName.startsWith("get")) {
String fieldName = methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
return fieldName;
} else {
throw new RuntimeException(String.format("動的クエリ生成に失敗しました。メソッド名はgetメソッドである必要があります。現在のメソッド名: %s", methodName));
}
}
/**
* <p>メソッド名を取得する</p>
* `writeReplace()`メソッドを通じて`SerializedLambda`オブジェクトを取得し、メソッド名を取得します。
* @param columnNameGetter メソッド名を取得するFunction
* @return メソッド名
*/
@SneakyThrows
private <T> String getMethodName(SerializableFunction<T, Object> columnNameGetter) {
Method writeReplace = columnNameGetter.getClass().getDeclaredMethod("writeReplace");
writeReplace.setAccessible(true);
Object sl = writeReplace.invoke(columnNameGetter);
SerializedLambda serializedLambda = (SerializedLambda)sl;
return serializedLambda.getImplMethodName();
}
また、ジェネリックの型消去の問題があるため、直接ジェネリックを通じてOrderPO::getOrderNoを渡すと、型を認識できずエラーになります。そのため、LambdaSpecificationオブジェクトにジェネリック型Tが何であるかを認識させる必要があります。Class
// Class<T>オブジェクトを追加
private Class<T> poClass;
// コンストラクタを修正
private LambdaSpecification(Class<T> poClass) {
this.predicateFunctions = new ArrayList<>();
this.poClass = poClass;
}
以下は最終的に修正されたコード:
展開して表示:LambdaSpecificationの最終完成コード
public class LambdaSpecification<T> implements Specification<T> {
private List<BiFunction<Root, CriteriaBuilder, Predicate>> predicateFunctions;
private Class<T> poClass;
private LambdaSpecification(Class<T> poClass) {
this.predicateFunctions = new ArrayList<>();
this.poClass = poClass;
}
public static <T> LambdaSpecification<T> query(Class<T> poClazz) {
return new LambdaSpecification<>(poClazz);
}
public LambdaSpecification<T> eq(Boolean ignoreNull, SerializableFunction<T, Object> columnNameGetter, Object value) {
// null値を無視するかどうかを選択する。nullの場合はこの条件を追加しない
if (ignoreNull && value == null) {
return this;
}
predicateFunctions.add((root, criteriaBuilder) -> criteriaBuilder.equal(root.get(getColumnName(columnNameGetter)), value));
return this;
}
@Override
public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
// 構築したpredicateFunctionsを実行し、Predicateオブジェクトを取得
List<Predicate> predicates = predicateFunctions.stream()
.map(function -> function.apply(root, criteriaBuilder))
.collect(Collectors.toList());
// Predicateオブジェクトを統合して構築
return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
}
/**
* <p>フィールド名を取得する</p>
* @param columnNameGetter フィールド名を取得するFunction
* @return フィールド名
*/
private <T> String getColumnName(SerializableFunction<T, Object> columnNameGetter) {
String methodName = getMethodName(columnNameGetter);
if (methodName.startsWith("get")) {
String fieldName = methodName.substring(3, 4).toLowerCase() + methodName.substring(4);
return fieldName;
} else {
throw new RuntimeException(String.format("動的クエリ生成に失敗しました。メソッド名はgetメソッドである必要があります。現在のメソッド名: %s", methodName));
}
}
/**
* <p>メソッド名を取得する</p>
* `writeReplace()`メソッドを通じて`SerializedLambda`オブジェクトを取得し、メソッド名を取得します。
* @param columnNameGetter メソッド名を取得するFunction
* @return メソッド名
*/
@SneakyThrows
private <T> String getMethodName(SerializableFunction<T, Object> columnNameGetter) {
Method writeReplace = columnNameGetter.getClass().getDeclaredMethod("writeReplace");
writeReplace.setAccessible(true);
Object sl = writeReplace.invoke(columnNameGetter);
SerializedLambda serializedLambda = (SerializedLambda) sl;
return serializedLambda.getImplMethodName();
}
}
次に、呼び出しコードを修正します:
public List<OrderPO> findByCondition(OrderQueryCondition orderQueryCondition) {
Specification specification = LambdaSpecification.query(OrderPO.class)
.eq(true, OrderPO::getOrderNo, orderQueryCondition.getOrderNo())
.eq(true, OrderPO::getOrderItemName, orderQueryCondition.getOrderItemName());
return orderDao.findAll(specification);
}
これで改造が完了し、魔法の値とはお別れです!実行結果も正しく、素晴らしいです!
复制代码
Hibernate: select orderpo0_.pid as pid1_0_, orderpo0_.order_item_name as order_item_name2_0_, orderpo0_.order_no as order_no3_0_ from ipn_order orderpo0_ where ( orderpo0_.is_available = 'Y') and orderpo0_.order_no=250037
上記では等号条件のみを示しましたが、in条件やlike条件なども必要に応じて拡張できます。例えば、in条件の場合:
public LambdaSpecification<T> in(Boolean ignoreNull, SerializableFunction<T, Object> columnNameGetter, Object ... values) {
if (ignoreNull && values == null) {
return this;
}
predicateFunctions.add(
(root, criteriaBuilder) -> {
CriteriaBuilder.In in = criteriaBuilder.in(root.get(getColumnName(columnNameGetter)));
for (Object value : values) {
in.value(value);
}
return in;
}
);
return this;
}
その他の条件については説明を省略しますが、必要に応じて自分で拡張してください。
以上が今回の改造のプロセスです。皆さんの助けになれば幸いです。ありがとうございました!
気に入ったならばコメントを残してくださいね~