DDD 中值對象的設計思考

作者 / 王晨

之前寫過一篇關於 DDD 領域驅動設計的思考,是比較系統性的進行總結,在 DDD 的戰術設計中,值對象相對來說是一個比較簡單的概念,相對於實體、聚合根、事件處理等戰術工具來說,簡單很多。

但是使用好值對象卻可以帶來非常大的好處,對代碼的可讀性,內聚性,可測試性等方面都有很大幫助,個人覺得在 DDD 體系裏值對象工具是一個學習投入產出比很高的工具。

那麼今天結合一定的業務場景,針對 DDD 領域模型中的值對象,展開討論,引申出的 Domain Primitive 概念,值對象的定義,如何設計值對象,以及值對象的好處。

DDD 樣例項目代碼:

ddd-sample-code(https://github.com/citerus/dddsample-core)

會發現類似如下形式的一些值對象:

public final class TrackingId implements ValueObject<TrackingId> {
    private String id;
    public TrackingId(final String id){
        Validate.notNull(id);
        this.id = id;
    }
}

這些值對象看起來什麼也沒做,只是簡單地封裝了一個 String,乍看起來有點過度設計之嫌

經過一番研究之後,發現這種寫法還是很有好處的,我們以用戶註冊領域中的手機號字段爲例,來分析下使用簡單 String 類型和使用值對象包裝類型的區別。

對於 User 類中的手機號字段,大部分人會用一個 String phoneNumber 來表示,而如果模仿 ddd-sample-code 中的寫法,手機號會封裝成一個值對象,類似下面這樣:

public class PhoneNumber {     
    private final String number;      
    public String getNumber() {         
        return number;     
    }     
    public PhoneNumber(String number) {         
        if (number == null) {             
            throw new ValidationException("number不能爲空");         
        } else if (isValid(number)) {             
            throw new ValidationException("number格式錯誤");         
        }         this.number = number;     
    }     
    public String getAreaCode() {         
        for (int i = 0; i < number.length(); i++) {             
            String prefix = number.substring(0, i);             
            if (isAreaCode(prefix)) {                 
                return prefix;             
            }         
        }         
        return null;     
    }     
    public static boolean isValid(String number) {         
        String pattern = "^0?[1-9]{2,3}-?\\d{8}$";         
        return number.matches(pattern);     
    } 
}

我們來想想將一個 phone 字段封裝成一個 PhoneNumber 類後

我們獲得了什麼

1、一個類。

我們獲得了一個 PhoneNumber Class,在這個 Class 中我們可以收斂部分代碼,比如自身的校驗,獲取區號等方法。關於手機號處理相關的代碼都收斂到了這個 Class 中。

2、一種類型。 

我們獲得了一個 PhoneNumber 類型,代碼庫裏多了一種類型,代碼之間的交互不再是 String 類型,而是 PhoneNumber 類型了。PhoneNumber 的概念突顯出來了。

這樣做之後可以給代碼帶來如下好處:

1、接口更加清晰,代碼可讀性更高。

有了 PhoneNumber 類型之後,我們接口的定義不再是 String phone,而是 PhoneNumber phone。

比如發送短信接口的兩種定義方式:

sendSms(String phone, String text)

sendSms(PhoneNumber phoneNumber, String text)

第一種形式我只能通過參數名才知道需要先傳手機號再傳信息,粗心的用戶調用時可能會犯參數錯位的錯誤,比如:

sendSms("myText","15088683360")

這樣的調用,代碼編譯是完全合法的。第二種方式接口通過類型強制了調用方先傳手機號,語意更明確,也杜絕了調用參數錯位的問題

2、校驗收斂。

使用 String 類型:

interfaces -> application -> domain 

這裏每一層接口中的手機號參數都需要做校驗,因爲 String 類型的手機號是不可信的。

使用 PhoneNumber 值對象作爲參數後,校驗邏輯只需要在最外層做,interfaces 層傳遞的 String 轉成 PhoneNumber 之後,後續 application、domain、infras 層的接口入參都是 PhoneNumber,PhoneNumber 類型只要創建出來就一定是合法可信的,不需要在做校驗(當然判空還是需要的)。

3、測試收斂。

是由第 2 點帶來的額外效果。如果每一層的接口定義的參數都是 String phone 類型,則每一層的接口都需要測試不合法手機號的 case。手機號校驗收斂到 PhoneNumber 類之後,只需要針對 PhoneNumber 類做測試即可。

4、可以讓業務代碼和 Entity 類從細節中脫身。

也是得益於 PhoneNumber 類收斂了和手機號相關的邏輯,業務代碼和 Entity 類不用再處理類似 getAreaCode() 之類的邏輯,這部分邏輯內聚到了 PhoneNumber 類中。

當然,

除了單個字段可以 wrapper 成一個值對象,更多情況下是多個緊密相關的字段組成一個值對象。

Dan Bergh Johnsson 在他的書籍《Security by Design》中,把這些值對象稱爲 Domain Primitive, 直譯過來就是領域原語。類似 String、Integer 等是 Common Language Primitive(通用語言原語),相應地,UserId,PhoneNumber,Address 等是 DomainPrimitive,是用戶賬號領域的原語,他們和賬號領域強相關。

經過對領域知識的不斷消化和理解,可以沉澱出一套 DomainPrimitive 類,他們就是這個領域的最小構造塊,是針對這個領域的一套 API 庫,新需求來了基於這套 API 庫編寫代碼,可以更快更安全。可以類比一下 DSL,對於特定的領域,設計良好的 DSL 可以使編程大大簡化,相應地,定義領域的 DomainPrimitive 就像設計一套領域的 DSL 一樣,針對領域沉澱出一套合適的 DomainPrimitive 類可以使針對該領域編碼工作大大簡化。

最後

再說一說哪些字段適合做成 DomainPrimitive 類:

1、有格式要求的 string,有範圍要求的數值類型,有參數格式校驗的字段。

2、可以關聯一些行爲的字段或字段集,比如手機號等。

3、領域中的核心概念。比如用戶註冊領域中的 Phone,IdCard 等,特意將這些概念建模成一個值對象,是爲了概念的顯性化,可以使代碼之間的交互更加清晰。

研究到最後會發現,其實值對象和 DomainPrimitive 也只不過是 OOA/OOP 的基本要求,本質就是封裝、內聚、數據和行爲一起放到值對象中。

而我們平時編碼的時候,可能更關注業務邏輯的實現,直接用語言的基本類型 + 過程式的邏輯來完成大部分業務需求,沒有認真去思考一下應該怎麼設計一些對象出來,怎麼把領域中的一些核心概念識別出來,顯性化的表達出來,說白了是缺少 OOA/OOP 的一些基本素養,最終導致了麪條式的代碼。

通過有意識的構建一些值對象或領域原語,我們可以積累出這個領域對應的一套 API,如果設計的合理,這一套領域 API 是非常有價值的,有助於領域知識的提煉,傳承和表達,也有助於編寫出更優雅的代碼。

延展

簡單再聊下 DDD 分層架構在微服務架構的演進變化,因爲架構設計是一個演進的過程,DDD 設計深陷其中。

領域模型不是一成不變的,因爲業務的變化會影響領域模型,而領域模型的變化則會影響微服務的功能和邊界。領域模型中對象的層次從內到外依次是:值對象、實體、聚合和限界上下文。。

實體或值對象的簡單變更,一般不會讓領域模型和微服務發生大的變化。但聚合的重組或拆分卻可以。這是因爲聚合內業務功能內聚,能獨立完成特定的業務邏輯。那聚合的重組或拆分,勢必就會引起業務模塊和系統功能的變化了。

以聚合爲基礎單元,完成領域模型和微服務架構的演進。聚合可以作爲一個整體,在不同的領域模型之間重組或者拆分,或者直接將一個聚合獨立爲微服務。

我們結合上圖,以微服務 1 爲例,講解下微服務架構的演進過程:

當你發現微服務 1 中聚合 a 的功能經常被高頻訪問,以致拖累整個微服務 1 的性能時,我們可以把聚合 a 的代碼,從微服務 1 中剝離出來,獨立爲微服務 2。這樣微服務 2 就可輕鬆應對高性能場景。

在業務發展到一定程度以後,你會發現微服務 3 的領域模型有了變化,聚合 d 會更適合放到微服務 1 的領域模型中。這時你就可以將聚合 d 的代碼整體搬遷到微服務 1 中。如果你在設計時已經定義好了聚合之間的代碼邊界,這個過程不會太複雜,也不會花太多時間。

最後我們發現,在經歷模型和架構演進後,微服務 1 已經從最初包含聚合 a、b、c,演進爲包含聚合 b、c、d 的新領域模型和微服務了。你看,好的聚合和代碼模型的邊界設計,可以讓你快速應對業務變化,輕鬆實現領域模型和微服務架構的演進。

我是王晨,一名互聯網的從業者,現任阿里巴巴集團,新零售技術事業羣,技術專家,如果你也有意願想來阿里闖一下的話,不妨私信我,甩個簡歷過來,我們共同學習,共同進步。

郵箱:chenge.wcg@alibaba-inc.com

微信號:wangchengewcg

今天最好的表現,是明天最低的要求。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/1IGQB5EcNpR7c-dnlQElYw