戲說領域驅動設計(十六)——實體概念

  現在開始正式的進入戰術部分,我看前面發的一些文章,只要有代碼的閱讀量就高,沒代碼的就差太多了,難道是因為平台只要看到代碼才會加強推薦嗎?真要是這樣那我是真醉了,其實學習DDD光看代碼還真不行,需要很多理論支持的。如果您是新的讀者我建議先把前面的內容都翻看一下,至少得有一些理論依據作支撐後面學習起來才會更有效率。本章主要講解實體,屬於戰術部分最為核心的內容。有人說聚合重要,但聚合也是實體,重要度都高,所以要先講基礎的。

一、兩類模型

  實體包含兩類。如果只有屬性及「getter/setter」方法,這叫「貧血模型」,DDD不推薦使用這種模型。再說了,數據模型本身就是貧血的,再多引個貧血的領域模型除了各種賦值操作外根本就沒個卵用。另外一種模型叫「充血模型」,「充血模型」不僅要包含屬性,還要包含業務方法,下面兩張圖展示了兩類模型在設計時的區別。把充血模型比喻成「人」最合適,有屬性還有行為。本章及後續文章所涉及實體和值對象都屬於充血模型。

 

  您可別簡簡單單的認為這兩種模型只是在包含方法上有區別,這裏面的學問大着呢,你理解透了才能在用的時候不至於抓瞎。

  首先咱們先說其意義,貧血模型是一種數據傳輸對象,它用於表現數據;充血模型由於其包含了對象屬性和業務能力,可以有效的表達真實世界中各類活靈活現的事務,比較適合作為領域模型來用。還有一點,您就沒法通過貧血模型來進行業務推測,所以一般稱之為反模式。是不是會驚呆了?「啥玩意兒,什麼叫通過模型推導業務?」。這東西明擺着嘛,您通過對業務進行分析來設計領域模型,當然也可以通過領域模型反向推導出業務能力了。別抬杠說不行,那隻能說明設計不到位,不是業務上沒講明白就是模型上少東西。比如您看上面「貧血模型」那張圖,你能知道這個實體的業務能力是什麼嗎?第二張的「充血模型」就可以看出來:1)支持修改訂單價格,且修改價格時訂單不能是已完成的狀態;2)不支持變更下單日期。為什麼人常說業領域模型反映了業務規則,就是指這種可相互推導的能力。那麼好了,領域模型這麼重要,從何而來?答:根據業務需求進行推斷和設計出來的,實體的識別會佔用您的大部分工作,只要這東西搞定,編寫代碼就分分鐘的事情了。我見過有些架構師只管設計然後讓開發去實現,這種行為基本上是來搞笑的。您只要敢這麼干,開發就敢違背你的設計而放飛自我。所以,好的架構師一定也是個優秀的研發,也要實際的參與一線的研發任務。

  第二,您別以為使用了Java或C#這種面向對象的語言就能寫出面向對象的程序,無數的程序員用着面向對象的語言寫着面向過程的代碼。沒辦法,下了班就想玩兒王者榮耀,一點學習的心思都沒有。再說了,咱可是大學生,天之驕子,只有破大專才需要惡補上學時的不足。

  第三,貧血模型一般出現在事務腳本式開發中,學習曲線較低,代碼幾乎無復用性;充血模型自治力高,可也不是屬樣都特別牛掰。這東西拆分出的組件(子對象、嵌套對象等)特別多,代碼編寫複雜也不易理解。我自己的代碼三個月不看都會蒙圈。

  最後,假如您在設計或開發時發現存在着大量的無業務方法的貧血模型,在排除設計方式不正確的原因之外,也說明了此時使用事務腳本的方式實現代碼會更好。您也別抬杠,OOP就是特別麻煩,需要多寫業務模型、資源倉庫等相關的代碼。實現方式不對除了費力不討好外只能用於拿出去裝開發高手了。

二、實體定義

  實體的簡單定義為:一種領域模型,此模型的定義並非來自於屬性,而是一連串的連續事件和標識。說它是領域模型這個很好理解,「一連串的連續事件」是什麼意思?「標識」是什麼意思?讓我們分別解釋一下。「一連串的連續事件」就是說實體這個東西會由於某些事件而引發變化,可不管怎麼變化其本質是不變的。比如說「人」這個實體,體重屬性會隨着減肥事件而產生變化,可再怎麼變,這個人本質上仍然還是他。類似的還有性別,以當前的科技也不是不可變的,比如去趟泰國……就算是這人掛了,他還是他。不過話又說回來了,「人」的屬性比如外形的變化可以讓熟悉他的人都認不出來,那要怎麼去唯一定位這個人呢?這其實就是「標識」的作用了。現實中,「人」一般會有身份證號,這個就可作為標識來用。標識一定是唯一且不變的,極端情況下實體可能沒有屬性,但也得有ID。當然了,咱不能抬扛說沒有方法和屬性只有ID能否還算為實體,設計出這麼一個東西除了作為超類用,沒其它太大的價值。

  實體並不是獨立存在的,它還會同其它的對象產生關聯。一個「人」有各種屬性,會幹各類事情,這是「人」的特徵與能力,可人並不是獨立的。在與其它的人產生了關係關聯後就出現了各類角色比如父親、母親、上下級等;在與其它的事務產生了行為關聯後就出現了需要其它事務配合才能完成能的活動比如結婚、離婚。可以這麼說,正是因為有了關聯,實體才能變得有血有肉有活力。所以在設計實體時就會出現繼承、嵌套對象、外部對象依賴等各類關聯,也讓實體變得複雜。

三、實體的特徵

  實體的特徵主要有三點,不過最值得一說的就是ID這塊。另外,實體設計起來非常容易和我們後面講的「值對象」弄混了,也就是初學者常常遇到的不知道一個對象到底應該設計為實體還是值對象。這東西別說小白了,好多有經驗的人做的時候也經常搞亂呢,所以需要使用迭代式設計來解決。回到ID這個問題上面,您在實際使用的時候盡量別用UUID,這東西做個Token什麼的還湊合,作為業務ID就差了點意思,本身沒什麼規律也慢,畢竟我們通常會將實體的ID也同時作為數據庫表的ID來用,而UUID的無序性造成插入和檢索效率都不怎麼高。想簡單一點整個雪花算法就差不多了,絕對夠用,畢竟並不是所有的單位都和大廠一樣能資源和能力建立分佈式ID生成系統。

  有些工程師喜歡使用數據庫的自增長ID作為實體ID,這種方式我在項目中用過,效果一般,有些情況下還特別麻煩。比如在進行實體的批量新建時,引入了「工作單元」後,需要把待執行久的對象放到一個Map中,但這個時候實體沒有ID,多實體的情況下絕對扯犢子。另外的場景比如序列化實體後再發佈一個領域事件,事件中需要有一個實體的ID,所以你就需要使用一些手段來保證實體序列化後事件能獲取到這個ID。所以以我個人的經驗,最好使用預生成ID也就是在創建實體的時候進行ID的創建,延遲ID的方式寫代碼比較難受。

  實體的ID通常會跟隨實體的一生,因為是不變的,在設計實體的時候不應該有類似「setId()」這種方法,唯一給ID賦值的方式就是通過構造函數。另外還需要注意的就是有些人喜歡使用一些框架比如「Entity Framework」,使用後就不用再考慮領域模型持久化的事情。我本人寫了10年左右的C#後轉行Java,所以不太清楚EF框架這幾年的變化。但就我個人而言,不是很喜歡使用這種編程方式,過於依賴框架不說,涉及一些關係特別複雜的聚合時簡直麻煩死了。但是,EF可以讓工程師聚焦於業務模型的設計,由框架自動生成業務模型的子類並隱蔽的完成序列化工作的這種方式是推薦的。所以如果BC設計的合理,使用EF框架也挺好。

四、實體設計經驗

  實體的設計需要研發人員投入很多的精力,也是最容易出現設計不當的情況。如果細節上的失誤其實問題不大,因為有BC進行隔離出現問題後一般並不會出現大面積蔓延。就怕是在設計的時候讓實體脫離了BC的約束而出現了超級類,這種情況下,且不說BC間的交互來往變多,代碼的維護也比較噁心。所以在設計的時候,要確保你的實體不論是在屬性方面還是責任方面都不要脫離BC的責任約束。比如在「鑒權」的BC中,關注的就是用戶的角色和權限,您就不要把用戶的交易流水信息做為用戶的屬性,那是「賬務」BC所關心的。我們設計BC的目的就是為了實現業務責任單一化,那其內部的實體也得遵從這個原則,不能做超出所在BC責任範圍外的事兒。

  另外一條需要額外注意的原則就是實體的構造。構造實體通常包含兩種方式,一是構造函數,二是使用工廠。使用構造函數時你需要保證其參數應能夠使得當前實體在構造後是合法的,該有值的有值,該不為「null」不能為「null」。比如「用戶」對象包含了ID和用戶名兩個屬性,那麼你在使用構造函數時需要為這兩個屬性賦值,而且要保證其值是合法的比如用戶名是空字符串,不能超過某個長度。那位可能會問,要是不合法要怎麼辦?拋個業務異常唄,看一下如下代碼。

public class Order extends EntityModel<Long> {
    private String name;

    public Order(Long id, String name) throws OrderCreationException {
        super(id);
        this.setName(name);
    }

    public String getName() {
        return name;
    }

    public void setName(String name) throws OrderCreationException {
        if (StringUtils.isEmpty(name)) {
            throw new OrderCreationException();
        }
        this.name = name;
    }
}

  以我的經驗來看,每個實體都應該有一個構造函數用於構造實體,不論其有多少個屬性。屬性多了您可以將部分屬性包裝成值對象,如果不想值對象對外暴露可以將構造函數設置為「protected」,然後建立一個繼承於本實體的工廠對象,將構造實體時所需要的數據以視圖模型的方式傳進去,然後在工廠內部進行實體的構造,請看如下代碼。

public class Order extends EntityModel<Long> {
    private String name;
    private Contact contact;

    protected Order(Long id, String name, Contact contact) throws OrderCreationException {
        super(id);
        this.name = name;
        this.contact = contact;
    }

    public String getName() {
        return name;
    }

    public Contact getContact() {
        return contact;
    }
}

public class OrderFactory extends Order {

    private OrderFactory(Long id, String name, Contact contact) throws OrderCreationException {
        super(id, name, contact);
    }


    public static Order create(OrderVO orderInfo) throws OrderCreationException {
        if (orderInfo == null) {
            throw new OrderCreationException();
        }
        Contact contact = new Contact(orderInfo.getEmail(), orderInfo.getName());

        return new Order(0L, orderInfo.getName(), contact);
    }
}

  上面的案例中,如果想直接通過構造函數創建「Order」類型的對象,就需要創建「Contact」類型的對象也就是您還需要了解「Contact」對象的構造方式。這還只是一個嵌套對象,如果多了那簡直就是構造噩夢。通過使用工廠的方式,您不僅把對象的創建細節放在了工廠中進行封裝,而且還能避免如「Contact」類型的泄露,這是一舉幾得來着?上述代碼請注意標紅的部分,雖然是細節但很重要。對象的創建方式總結一下,屬性少時直接使用構造函數否則建立一個對象工廠。

  還有一條值得分享的實體設計經驗就是我們在確定實體的屬性後只提供getter方法,根據需要再提供對應的用於修正屬性的方法,這種方式可以最大化保障實體信息的安全以及防止屬性被意外篡改。實際上,對象的封裝性越好,後續出現問題的可能性就會越少。尤其是團隊協作時,您哪知道誰手欠意外改了某個屬性而引發程序BUG。

總結

  其實涉及實體的內容挺多的,比如實體的存儲、驗證、編寫方法時的注意事項等,能想得上來的原則就好多。比如在驗證方面,我個人一般會使用二級驗證+內、外驗證的方式來實現,這方面內容值得開一個新的章節做細講。下一章,我展示一下個人在實際的項目中是如何使用實體的,敬請關注。