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

SKevin發表於2022-03-21

  現在開始正式的進入戰術部分,我看前面發的一些文章,只要有程式碼的閱讀量就高,沒程式碼的就差太多了,難道是因為平臺只要看到程式碼才會加強推薦嗎?真要是這樣那我是真醉了,其實學習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。

總結

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

相關文章