Spring Data JPA Specification API

最後更新: 2022-11-09 01:13:09 +0800 +0800
tags: Java | Spring | Jpa
原文以英文撰寫。您正在閱讀AI翻譯版本。

當 Spring Data JPA 儲存庫需要一個可選、可組合,且在執行時決定的 WHERE 子句時,通常使用的 API 是 org.springframework.data.jpa.repository.JpaSpecificationExecutor<T>。

它連接了以下元件:

  1. Spring Data 儲存庫方法,例如 findAll(...)、count(...) 和 exists(...)
  2. Spring 的 org.springframework.data.jpa.domain.Specification<T>
  3. 以 jakarta.persistence.criteria.CriteriaBuilder 為核心的 Jakarta Persistence Criteria API

JpaSpecificationExecutor<T> #

假設有一個實體:

java
@Entity
@Table(name = "orders")
public class PurchaseOrder {
    @Id
    private Long id;

    @Column(nullable = false)
    private String status;

    @Column(nullable = false)
    private String customerEmail;

    @Column(nullable = false)
    private Instant createdAt;

    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;
}

以及一個儲存庫:

java
public interface PurchaseOrderRepository
        extends JpaRepository<PurchaseOrder, Long>,
                JpaSpecificationExecutor<PurchaseOrder> {
}

JpaRepository 提供 CRUD 與分頁 API。JpaSpecificationExecutor 則新增一組方法,其篩選邏輯由 Specification<PurchaseOrder> 描述。

典型方法如下:

java
List<PurchaseOrder> findAll(Specification<PurchaseOrder> spec);
Page<PurchaseOrder> findAll(Specification<PurchaseOrder> spec, Pageable pageable);
List<PurchaseOrder> findAll(Specification<PurchaseOrder> spec, Sort sort);
Optional<PurchaseOrder> findOne(Specification<PurchaseOrder> spec);
long count(Specification<PurchaseOrder> spec);
boolean exists(Specification<PurchaseOrder> spec);

核心概念很簡單:不需要手動組合 JPQL 字串。將 Specification<T> 交給 Spring Data 後,Spring Data 會替你建立 Criteria 查詢。

Specification<T> 的本質 #

org.springframework.data.jpa.domain.Specification<T> 是函式介面:

java
public interface Specification<T> extends Serializable {
    Predicate toPredicate(
        Root<T> root,
        CriteriaQuery<?> query,
        CriteriaBuilder criteriaBuilder
    );
}

換句話說,Specification<T> 只是一個可重複使用的函式,知道如何為實體型別 T 貢獻一個 Predicate。

三個參數都很重要:

  1. Root<T> 代表正在查詢的根資料表或根實體
  2. CriteriaQuery<?> 是正在建立的完整查詢
  3. CriteriaBuilder 是建立運算式與述詞的工廠,例如 equal、like、between、and、or、not、lower、count 等

實務上,大部分應用程式碼只會關心 root 與 criteriaBuilder。當需要 distinct、groupBy、自訂排序或 fetch join 時,query 才會變得重要。

實務上如何取得 Specification #

最常見的方式,是從小型靜態工廠方法中回傳它:

java
public final class PurchaseOrderSpecs {
    private PurchaseOrderSpecs() {}

    public static Specification<PurchaseOrder> hasStatus(String status) {
        return (root, query, cb) -> cb.equal(root.get("status"), status);
    }

    public static Specification<PurchaseOrder> customerEmailContains(String token) {
        return (root, query, cb) ->
                cb.like(
                        cb.lower(root.get("customerEmail")),
                        "%" + token.toLowerCase(Locale.ROOT) + "%"
                );
    }

    public static Specification<PurchaseOrder> createdAtGte(Instant lowerBound) {
        return (root, query, cb) -> cb.greaterThanOrEqualTo(root.get("createdAt"), lowerBound);
    }

    public static Specification<PurchaseOrder> customerIdEquals(Long customerId) {
        return (root, query, cb) -> cb.equal(root.get("customer").get("id"), customerId);
    }
}

接著將它們組合起來:

java
Specification<PurchaseOrder> spec = Specification.where(null);

if (status != null) {
    spec = spec.and(PurchaseOrderSpecs.hasStatus(status));
}
if (emailToken != null && !emailToken.isBlank()) {
    spec = spec.and(PurchaseOrderSpecs.customerEmailContains(emailToken));
}
if (createdAfter != null) {
    spec = spec.and(PurchaseOrderSpecs.createdAtGte(createdAfter));
}
if (customerId != null) {
    spec = spec.and(PurchaseOrderSpecs.customerIdEquals(customerId));
}

Page<PurchaseOrder> page =
        purchaseOrderRepository.findAll(spec, PageRequest.of(0, 20, Sort.by("createdAt").descending()));

在大多數程式碼庫中,這個 API 的實際形式是:將請求參數轉換成一連串可選的 Specification,再將它們串接起來。

另一種常見風格,是保留請求物件,並讓一個方法建立完整的 specification:

java
public record PurchaseOrderFilter(
        String status,
        String emailToken,
        Instant createdAfter,
        Long customerId
) {}

public static Specification<PurchaseOrder> fromFilter(PurchaseOrderFilter filter) {
    Specification<PurchaseOrder> spec = Specification.where(null);

    if (filter.status() != null) {
        spec = spec.and(hasStatus(filter.status()));
    }
    if (filter.emailToken() != null && !filter.emailToken().isBlank()) {
        spec = spec.and(customerEmailContains(filter.emailToken()));
    }
    if (filter.createdAfter() != null) {
        spec = spec.and(createdAtGte(filter.createdAfter()));
    }
    if (filter.customerId() != null) {
        spec = spec.and(customerIdEquals(filter.customerId()));
    }

    return spec;
}

CriteriaBuilder 的運作方式 #

jakarta.persistence.criteria.CriteriaBuilder 是一個工廠 API。它本身不會執行 SQL,而是建立一棵運算式物件樹,之後由 JPA 提供者(例如 Hibernate)將它轉換成 SQL。

方法通常可分為以下類別:

  1. 比較述詞:equal、notEqual、greaterThan、lessThan、between
  2. 字串操作:like、lower、upper、trim、concat
  3. null/boolean 檢查:isNull、isNotNull、isTrue、isFalse
  4. 布林組合:and、or、not、conjunction、disjunction
  5. 彙總或查詢運算式:count、avg、sum、exists

範例:

java
cb.equal(root.get("status"), "PAID");
cb.like(cb.lower(root.get("customerEmail")), "%gmail.com");
cb.between(root.get("createdAt"), start, end);
cb.and(
        cb.equal(root.get("status"), "PAID"),
        cb.isNotNull(root.get("customerEmail"))
);

因此,Criteria API 基本上是用來建立類似 SQL 查詢的型別化 AST 建構器。

相關的重要 Criteria API 型別 #

若要理解 Specification,以下介面值得認識:

Root<T> #

Root<T> 是 FROM 子句中的根實體。如果儲存庫查詢的是 PurchaseOrder,那麼 root 就對應到該資料表或實體。

範例:

java
root.get("status");
root.get("createdAt");
root.get("customer").get("id");

Path<X> #

每個 root.get(...) 都會回傳一個 Path<?>,代表對屬性的導覽。這就是為什麼屬性存取可以鏈式呼叫。

java
Path<String> email = root.get("customerEmail");
Path<Long> customerId = root.get("customer").get("id");

Predicate #

Predicate 是 toPredicate(...) 回傳的布林運算式,並會成為產生之 WHERE 子句的一部分。

CriteriaQuery<?> #

CriteriaQuery<?> 是完整的查詢定義。必要時,specification 可以修改它:

java
return (root, query, cb) -> {
    query.distinct(true);
    return cb.equal(root.get("status"), "PAID");
};

需要注意的是:一旦 specification 開始變更查詢形狀,它就不再只是小型的篩選片段。它雖然很強大,但也會使組合行為變得較難預測。

Join<Z, X> #

依據關聯實體進行篩選時,明確的 join 通常比鏈式呼叫 get(...) 更清楚:

java
public static Specification<PurchaseOrder> customerRegionEquals(String region) {
    return (root, query, cb) -> {
        Join<PurchaseOrder, Customer> customer = root.join("customer");
        return cb.equal(customer.get("region"), region);
    };
}

Order #

排序通常應該交由 Spring Data 的 Sort 或 Pageable 處理,而不是硬編碼在 specification 中。不過,如果 specification 必須透過 query.orderBy(...) 影響排序,可以使用 CriteriaBuilder.asc(...) / desc(...)。

為什麼存在 Specification #

沒有 Specification 時,通常會遇到以下其中一個問題:

  1. 出現許多像 findByStatusAndCustomerEmailContainingAndCreatedAtAfter(...) 的儲存庫方法
  2. 使用包含大量可為 null 條件的手寫 JPQL 字串

Specification 讓每個篩選器都成為可以透過 .and(...) 和 .or(...) 組合的物件或函式,藉此解決「動態 WHERE 子句」問題。

它特別適合以下用途:

  1. 包含許多可選欄位的搜尋畫面
  2. 支援可分頁篩選的管理 API
  3. 可重複使用的業務篩選器,例如「對租戶可見」、「未被軟刪除」或「由目前使用者建立」

型別安全的存取與字串屬性名稱 #

範例通常會使用:

java
root.get("status")

這種方式很方便,但屬於字串型別化。拼寫錯誤只有在執行時才會失敗。

如果建置過程會產生 JPA 靜態中繼模型,可以使用更安全的形式:

java
root.get(PurchaseOrder_.status)

使用 Specification 並不要求採用這種方式,但如果希望 Criteria 程式碼能安全地配合重構,它就是重要的配套 API。

Spring Data 在幕後替你做了什麼 #

在一般應用程式碼中,不需要自行建立 CriteriaBuilder。Spring Data 的 JPA 儲存庫實作會取得 EntityManager,向它要求 CriteriaBuilder,建立 CriteriaQuery,呼叫你的 Specification#toPredicate(...),然後透過 JPA 提供者執行最終查詢。

因此,控制流程大致如下:

  1. 應用程式建立或組合 Specification<T>
  2. 應用程式呼叫 repository.findAll(spec, pageable)
  3. Spring Data 取得 EntityManager 基礎設施
  4. Spring Data 建立 Criteria 查詢
  5. Specification 貢獻述詞樹
  6. Hibernate 將 Criteria 樹轉換成 SQL
  7. 資料庫執行 SQL

這說明了為什麼 Specification 看起來很小:它只是篩選片段,而不是完整的儲存庫實作。

實務上的判斷原則 #

當查詢仍屬於「一個實體根加上一棵動態述詞樹」的範圍時,使用 JpaSpecificationExecutor。一旦查詢變成包含投影、彙總、複雜分組、供應商特定 SQL 或棘手 fetch 計畫的報表,Specification API 就會變得不太適用;此時,自訂儲存庫實作或其他查詢方式通常更清楚。

不過,對於一般的搜尋與篩選使用案例,這個架構很簡潔:

  1. JpaSpecificationExecutor<T> 是儲存庫的入口點
  2. Specification<T> 是可重複使用的篩選器抽象化
  3. CriteriaBuilder 是建立述詞/運算式樹的工廠
  4. Root<T>、Path<?>、Join<?, ?>、Predicate 和 CriteriaQuery<?> 是主要的支援型別

一旦這個心智模型清楚,這個 API 就不再神秘。

評論

在 GitHub 上閱讀或評論(發表評論需要登入)