DDD 中的那些模式 — 使用 Specification 管理業務規則

JoshuaJin發表於2020-03-31

許多開發者在專案中希望能夠使用 DDD 原因在於能夠管理業務的複雜度,避免在業務規則愈發複雜的情況下程式碼以及架構發生腐化,最終變的難以維護。系統複雜度體現在多個層面,例如繁瑣的流程,繁複的校驗規則,資料的多樣性等,DDD 對於不同層面的複雜度提供了不同的應對模式,今天的文章會聚焦與如何使用 Specification 模式解決「業務規則」相關的複雜性。

業務規則

在介紹 Specification 模式之前,我們先明確一下什麼是「業務規則」。作為一個開發者,以下的這些一定是你日常工作中常見的工作。

  • 校驗業務物件的某些狀態是否合法,例如當前賬戶是否啟用,賬戶餘額是否充足,事故日期是否在保險單的有效時間內。
  • 從業務物件的集合中篩選出符合條件的結果集,例如從使用者的交易記錄中找出購買打折產品的記錄。
  • 檢查一個新建立的業務物件是否符合某些業務條件,例如一張新建立的訂單,它對應的客戶與商戶都應該是合法系統使用者。

為了方便後續的討論,我們將業務規則的概念窄化為以上三種型別。接下來的問題是這些業務規則在系統中是如何實現的?

最原始的方法是編寫了許多小方法實現這些校驗或是篩選邏輯,分散在各個 Domain Service 類中。這樣做的缺點很明顯,其一是難以管理,當業務規則越來越多時,這些散落在各處的方法就無法複用,而開發人員也沒有辦法集中的管理這些方法。其二是丟失了業務知識,這些方法大部分都很短小,簡單,但是這些規則其實包含了大量的業務知識,如果任其分散在不同的 Domain Service 中,後續的開發過程中就很容易丟失這些業務知識。

在此基礎上的另一個方案也是實際專案中使用比較多的,即編寫各種不同的 Validator 類,每個 Validator 類中有大量對於領域物件的校驗方法。這種做法一定程度上解決了第一個問題,通過特定的類將檢驗方法集中起來,可以方便開發人員進行維護和擴充套件。下面是一個典型的 Validator 的示例程式碼:

public class CustomerValidator {
public static boolean isVIP(Customer customer) {
……
}
}
複製程式碼

但是這對於業務知識的傳遞揖讓沒有太大的幫助。Validator 類更像是一些工具類,和領域層並沒有什麼關聯。而當需要對這些校驗方式進行復用時,特別是將幾個校驗規則按照 andor 這樣的邏輯關係組合起來時,Validator 就支援不是那麼好了。

另一種方法是是將這些校驗的規則在領域物件中實現,作為該領域物件的一個方法。參考如下的程式碼:

public class Customer {
public boolean isVIP() {
……
}
}
複製程式碼

這總做法優點是校驗規則與領域物件結合的非常緊密,開發人員一看就明白。但是缺點同樣明顯,就是你的領域物件會變得日益臃腫,充斥著大量類似這種的校驗方法掩蓋了核心的業務規則。

那麼有沒有更好些的做法呢?Specification 模式提供了一種不錯的選擇。

Specification 模式

DDD 中認為這些規則都是純粹的「動詞」,因此需要單獨的建立模型,而這些模型都應該是簡單的「值物件」。一個 Specification 介面示例如下:

public interface Specification<T> {
boolean isMatch(T domainObject);
}
複製程式碼

然後我們可以實現是否 VIP 客戶的校驗:

public class VIPCustomerSpecification<Customer> {
@Override
public boolean isMatch(Customer customer) {
……
}
}
複製程式碼

通過實現 Specification 介面,我們可以對不同的領域物件擴充套件不同的校驗邏輯,而這些類都是可以複用的。同時這些 Specification 可以作為基礎元素進行任意的組合,組合更為複雜的校驗規則與篩選邏輯。例如下面的例子中,我們將所有更上層的領域邏輯封裝在 CustomerSpecifications 中,而它可以通過組合各個單獨的 Specification 提供具體的功能。

 public class CustomerSpecification {
public boolean isSpecialCustomer(List<Specification<Customer>> specifications, Customer customer) {
……
}
}
複製程式碼

在上面的 isSpecialCustomer 的方法中可以傳入校驗所需的一系列 Specification,並依次校驗,這種做法也便於擴充套件與複用,與領域模型結合的更為緊密。

使用 Specification 模式過濾資料

從一個資料集中篩選出符合條件的結果也是日常開發中常常需要實現的業務規則。那麼我們一般的做法是如何的呢?

假設我們需要篩選出某個客戶名下在一月到二月的訂單記錄,一種最簡單也是最常見的做法就是通過 SQL(假設這些資料都是存放在關係型資料庫中)。例如通過如下的 SQL 查詢:

select * from t_order where t_order.customer_id = ? and t_order.created_at >= ? and t_order.created_at <= ?
複製程式碼

使用 SQL 在查詢中直接實現篩選邏輯看起來很自然,但問題是這部分的邏輯本來應該是屬於領域層的,現在卻洩漏到了資料層,造成的後果就是維護的難度大大提升,很多業務系統到後期都是在和大段大段的 SQL 做鬥爭,而應該編寫邏輯的 Service 層,Domain 層都成了擺設,退化成了純粹的資料物件,傳來傳去,與 DTO 沒什麼差別。而 Specification 模式可以提供一種不錯的解決思路。

我們可以在有如下的程式碼:

public class OrderSpecifications {
public Specification<Order> inPeriod(LocalDateTime beginTime, LocalDateTime endTime) {
……
}
}
複製程式碼

在這裡 OrderSpecifications 並沒有直接進行資料篩選,而是通過輸入引數建立了一個特有的 Specification 物件,然後由 OrderRepository 物件接受 Specification 為引數進行真正的資料篩選操作。這樣就將查詢與過濾的邏輯分開了。

此時需要考慮的問題是效能,如果按照 SQL 的做法,那麼在資料庫端就會完成資料的查詢與過濾,返回給應用端的資料量不會很大,但是如果使用 Specification 模式那麼,就是在記憶體中進行過濾了,在資料量大的情況下必然會遭遇效能的問題。之前在專案中的確也遇到過類似的問題,由於查詢結果資料量過大,而且需要分頁展示,這些都在記憶體中完成的化,併發量一大記憶體的佔用量以及響應速度都變得非常差。

解決的辦法有兩種,一種如 DDD 書上所介紹,在 Specification 介面上提供一個類似 asSQL() 的方法,將當前的 Specification 物件轉化為 SQL 語句。在我一些專案的實踐中這種做法比較麻煩,可以認為是換了一種方式拼接 SQL,效果並不好,且難以處理。而另一種則是使用 ORM 框架或是其他高階框架的能力。例如 Spring Data JPA 就提供了基於 JPA 的 Specification 模式的查詢功能,使用起來非常方便,也是我建議大家在專案中可以嘗試的方式。

小結

Specification 模式是一種非常實用的模式,能夠很方便的幫助開發人員對狀態校驗,資料篩選這樣的業務規則進行管理與抽象,而且實際操作的難度較低,對外部的依賴也少,是個十分值得推薦的 DDD 最佳實踐。

歡迎關注我的微訊號「且把金針度與人」,獲取更多高質量文章

DDD 中的那些模式 — 使用 Specification 管理業務規則

相關文章