魔法値をどうやって我慢する?JPAのSpecification大改造!

发表于 2024-08-01 23:00 3269 字 17 min read

簡単なクエリインターフェースを書く JPAを使ってプログラム的なSQL構築を行うとしたら、どうしますか?一般的には、Specificationを使って条件を構築し、動的な条件クエリを行います。例えば、私が関わっているプロジェクトでは、多くが以下のように書かれています:

簡単なクエリインターフェースを書く

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を使って全ての条件を一つのコレクションに入れ、最後にcriteriaBuilderを使ってPredicateオブジェクトを構築しています。

では、私たちも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;
}

その他の条件については説明を省略しますが、必要に応じて自分で拡張してください。

以上が今回の改造のプロセスです。皆さんの助けになれば幸いです。ありがとうございました!

気に入ったならばコメントを残してくださいね~

© 2019 - 2026 VincentHo @VincentHo
Powered by theme astro-koharu · Inspired by Shoka