DDD之實體與值對象
傳統(tǒng)的系統(tǒng)架構(gòu)設(shè)計階段,通常我們會將關(guān)注點放在數(shù)據(jù)上面,而不是領(lǐng)域上面。這種設(shè)計風格在軟件開發(fā)中,使數(shù)據(jù)庫占據(jù)了主導地位,我們總是有限考慮數(shù)據(jù)的屬性(對應(yīng)數(shù)據(jù)庫的列)和關(guān)聯(lián)關(guān)系(外鍵關(guān)聯(lián)),而不是富有行為的領(lǐng)域概念。這樣做的結(jié)果是直接將數(shù)據(jù)模型反映在對象模型上,導致這些表示領(lǐng)域模型的實體中含有大量的getter、setter方法,也就是貧血領(lǐng)域模型這不符合DDD的做法。
與傳統(tǒng)數(shù)據(jù)模型設(shè)計優(yōu)先不同,DDD 是先構(gòu)建領(lǐng)域模型,針對實際業(yè)務(wù)場景構(gòu)建實體對象和行為,再將實體對象映射到數(shù)據(jù)持久化對象。傳統(tǒng)數(shù)據(jù)模型不具備行為能力,而DDD的領(lǐng)域模型實體含有豐富的行為能力。
實體
在DDD的領(lǐng)域模型中,實體應(yīng)該是富有業(yè)務(wù)行為且具有唯一標識符的對象。在不同的設(shè)計階段實體是可以改變的,但是根據(jù)唯一標識符始終能定位到這個唯一對象。
唯一標識符可以是用戶指定的,也可以是通過應(yīng)用程序生成的UUID或者通過持久化機制生成的序列值(Sequence),當然也可以是限界上下文中傳遞的過來的,但無論是哪一種生產(chǎn)方式都要具備全局唯一性(比如訂單的流水號,一些電商場景訂單的流水號是通過專門的工具生產(chǎn)全局唯一的)。
實體的可變性主要體現(xiàn)在不同的設(shè)計階段,實體會根據(jù)所處階段的側(cè)重點不同,發(fā)生一定地形態(tài)變化。
實體的業(yè)務(wù)形態(tài)
在戰(zhàn)略設(shè)計時,實體是領(lǐng)域模型的一個重要對象。領(lǐng)域模型中的實體是多個屬性、操作或行為的載體。在事件風暴中,我們可以根據(jù)命令、操作或者事件,找出產(chǎn)生這些行為的業(yè)務(wù)實體對象,進而按照一定的業(yè)務(wù)規(guī)則將依存度高和業(yè)務(wù)關(guān)聯(lián)緊密的多個實體對象和值對象進行聚類,形成聚合。你可以這么理解,實體和值對象是組成領(lǐng)域模型的基礎(chǔ)單元。
實體的代碼形態(tài)
在代碼模型中,實體的表現(xiàn)形式是實體類,這個類包含了實體的屬性和方法,通過這些方法實現(xiàn)實體自身的業(yè)務(wù)邏輯。在 DDD 里,這些實體類通常采用充血模型,與這個實體相關(guān)的所有業(yè)務(wù)邏輯都在實體類的方法中實現(xiàn),跨多個實體的領(lǐng)域邏輯則在領(lǐng)域服務(wù)中實現(xiàn)。
實體的運行形態(tài)
實體以 DO(領(lǐng)域?qū)ο螅┑男问酱嬖?,每個實體對象都有唯一的 ID。我們可以對一個實體對象進行多次修改,修改后的數(shù)據(jù)和原來的數(shù)據(jù)可能會大不相同。但是,由于它們擁有相同的 ID,它們依然是同一個實體。比如商品是商品上下文的一個實體,通過唯一的商品 ID 來標識,不管這個商品的數(shù)據(jù)如何變化,商品的 ID 一直保持不變,它始終是同一個商品。
實體的數(shù)據(jù)庫形態(tài)
在領(lǐng)域模型映射到數(shù)據(jù)模型時,一個實體可能對應(yīng) 0 個、1 個或者多個數(shù)據(jù)庫持久化對象。大多數(shù)情況下實體與持久化對象是一對一。在某些場景中,有些實體只是暫駐靜態(tài)內(nèi)存的一個運行態(tài)實體,它不需要持久化。比如,基于多個價格配置數(shù)據(jù)計算后生成的折扣實體。
而在有些復(fù)雜場景下,實體與持久化對象則可能是一對多或者多對一的關(guān)系。比如,用戶 user 與角色 role 兩個持久化對象可生成權(quán)限實體,一個實體對應(yīng)兩個持久化對象,這是一對多的場景。再比如,有些場景為了避免數(shù)據(jù)庫的聯(lián)表查詢,提升系統(tǒng)性能,會將客戶信息 customer 和賬戶信息 account 兩類數(shù)據(jù)保存到同一張數(shù)據(jù)庫表中,客戶和賬戶兩個實體可根據(jù)需要從一個持久化對象中生成,這就是多對一的場景。
如何創(chuàng)建一個實體
在通用語言的術(shù)語中,名詞用于給概念命名,形容詞用于描述這些概念,而動詞則表示可以完成的操作。在對一個業(yè)務(wù)場景或者需求進行分析時,團隊成員需要仔細閱讀需求文字,聽取領(lǐng)域?qū)<业慕馕?,從中提煉出關(guān)鍵的詞組。比如:當我們聽到“修改”這個詞的時候,應(yīng)該能聯(lián)想到對應(yīng)一個實體操作,當我們聽到“校驗、認證”這類詞時,我們應(yīng)該提供一些查詢能力。實體也就是團隊在這種一次次的討論、總結(jié)過程中增加修改屬性、確認唯一標識符、予以豐富的業(yè)務(wù)行為,最終形成一個領(lǐng)域模型實體。
當我們新建一個實體時,我們希望通過構(gòu)造函數(shù)來初始化足夠多的實體狀態(tài),這一方面有助于表該實體的身份,另一方面可以幫助客戶端更容易地查找該實體。
值對象
在 DDD 中用來描述領(lǐng)域的特定方面,并且是一個沒有標識符的對象,叫作值對象。也可理解為是若干個用于描述目的、具有整體概念和不可修改的屬性的集合。在領(lǐng)域建模的過程中,值對象可以保證屬性歸類的清晰和概念的完整性,避免屬性零碎。

值對象可以非常容易地創(chuàng)建、測試、使用、優(yōu)化和維護,因此我們應(yīng)該盡量使用值對象來建模而不是實體對象。在定義值對象時我們需要考慮其是否具備以下特性:
它度量或者描述了領(lǐng)域中的一件東西
它可以作為不變量
它將不同的相關(guān)的屬性組合成一個概念整體(Conceptual Whole)
它度量和描述改變時,可以用另外一個值對象予以替換
它可以和其他值對象進行相等性比較(因為沒有唯一標識符)
它不會對協(xié)作對象造成副作用
eg:一個人擁有名字和年齡屬性,這里的名字和年齡不是一個具體能夠映射成對象的東西,年齡是一個度量概念,名字是一個描述概念,把這些概念整合成一個概念整體就是值對象的具體表現(xiàn)形式。
除了沒有唯一標識符外,值對象和實體對象另外一個比較大的區(qū)別就是,值對象具有不變性。體現(xiàn)到我們代碼層面就是,在值對象初始化之后,任何方法都不能對該對象的屬性狀態(tài)進行修改。
值對象的業(yè)務(wù)形態(tài)
值對象是 DDD 領(lǐng)域模型中的一個基礎(chǔ)對象,它跟實體一樣都來源于事件風暴所構(gòu)建的領(lǐng)域模型,都包含了若干個屬性,它與實體一起構(gòu)成聚合。值對象的屬性集雖然在物理上獨立出來了,但在邏輯上它仍然是實體屬性的一部分,用于描述實體的特征。在值對象中也有部分共享的標準類型的值對象,它們有自己的限界上下文,有自己的持久化對象,可以建立共享的數(shù)據(jù)類微服務(wù),比如數(shù)據(jù)字典。
值對象的代碼形態(tài)
值對象在代碼中有這樣兩種形態(tài)。如果值對象是單一屬性,則直接定義為實體類的屬性;如果值對象是屬性集合,則把它設(shè)計為 Class 類,Class 將具有整體概念的多個屬性歸集到屬性集合,這樣的值對象沒有 ID,會被實體整體引用。
我們看一下下面這段代碼,person 這個實體有若干個單一屬性的值對象,比如 Id、name 等屬性;同時它也包含多個屬性的值對象,比如地址 address。

值對象的運行形態(tài)
值對象實例化的對象則相對簡單和乏味。除了值對象數(shù)據(jù)初始化和整體替換的行為外,其它業(yè)務(wù)行為就很少了。值對象嵌入到實體的話,有這樣兩種不同的數(shù)據(jù)格式,也可以說是兩種方式,分別是屬性嵌入的方式和序列化大對象的方式。
引用單一屬性的值對象或只有一條記錄的多屬性值對象的實體,可以采用屬性嵌入的方式嵌入。引用一條或多條記錄的多屬性值對象的實體,可以采用序列化大對象的方式嵌入。比如,人員實體可以有多個通訊地址,多個地址序列化后可以嵌入人員的地址屬性。值對象創(chuàng)建后就不允許修改了,只能用另外一個值對象來整體替換。
案例 1:以屬性嵌入的方式形成的人員實體對象,地址值對象直接以屬性值嵌入人員實體中。

案例 2:以序列化大對象的方式形成的人員實體對象,地址值對象被序列化成大對象 Json 串后,嵌入人員實體中。

即使是關(guān)系型數(shù)據(jù)比如MySQL現(xiàn)在版本也支持了json格式存儲和解析,配合json格式實現(xiàn)列存儲可以很好的兼容這種嵌入實體模式。
值對象的數(shù)據(jù)庫形態(tài)
DDD 引入值對象是希望實現(xiàn)從“數(shù)據(jù)建模為中心”向“領(lǐng)域建模為中心”轉(zhuǎn)變,減少數(shù)據(jù)庫表的數(shù)量和表與表之間復(fù)雜的依賴關(guān)系,盡可能地簡化數(shù)據(jù)庫設(shè)計,提升數(shù)據(jù)庫性能。值對象在數(shù)據(jù)庫持久化方面簡化了設(shè)計,它的數(shù)據(jù)庫設(shè)計大多采用非數(shù)據(jù)庫范式,值對象的屬性值和實體對象的屬性值保存在同一個數(shù)據(jù)庫實體表中。具體體現(xiàn)在領(lǐng)域建模時,我們可以將部分對象設(shè)計為值對象,保留對象的業(yè)務(wù)涵義,同時又減少了實體的數(shù)量;在數(shù)據(jù)建模時,我們可以將值對象嵌入實體,減少實體表的數(shù)量,簡化數(shù)據(jù)庫設(shè)計。
把地址信息以一個序列化大對象的方式嵌入用戶表時就是一種很好的應(yīng)用案例場景。即減少了單獨設(shè)計一張地址表,也體現(xiàn)出了地址信息不具備任何業(yè)務(wù)行為的特性。
如何創(chuàng)建一個值對象
值對象是一把雙刃劍,它的優(yōu)勢是可以簡化數(shù)據(jù)庫設(shè)計,提升數(shù)據(jù)庫性能。但如果值對象使用不當,它的優(yōu)勢就會很快變成劣勢。
序列化大對象嵌入實體:值對象采用序列化大對象的方法簡化了數(shù)據(jù)庫設(shè)計,減少了實體表的數(shù)量,可以簡單、清晰地表達業(yè)務(wù)概念。這種設(shè)計方式雖然降低了數(shù)據(jù)庫設(shè)計的復(fù)雜度,但卻無法滿足基于值對象的快速查詢,會導致搜索值對象屬性值變得異常困難。
屬性嵌入實體:值對象采用屬性嵌入的方法提升了數(shù)據(jù)庫的性能,但如果實體引用的值對象過多,則會導致實體堆積一堆缺乏概念完整性的屬性,這樣值對象就會失去業(yè)務(wù)涵義,操作起來也不方便。
知道了兩種使用方式的優(yōu)缺點其實結(jié)果自己的項目場景就可以知道如何設(shè)計一個值對象了,在適當?shù)膱鼍笆褂眠m當?shù)姆绞綄⑹且话言O(shè)計利刃。
實體和值對象之間的關(guān)系
唯一的身份標識和可變性特征將實體對象和值對象進行了區(qū)分。本質(zhì)上,實體是看得到、摸得著的實實在在的業(yè)務(wù)對象,實體具有業(yè)務(wù)屬性、業(yè)務(wù)行為和業(yè)務(wù)邏輯。而值對象只是若干個屬性的集合,只有數(shù)據(jù)初始化操作和有限的不涉及修改數(shù)據(jù)的行為,基本不包含業(yè)務(wù)邏輯。
實體和值對象是微服務(wù)底層的最基礎(chǔ)的對象,一起實現(xiàn)實體最基本的核心領(lǐng)域邏輯。同時實體對象和值對象共同構(gòu)成了聚合。
在設(shè)計的時候應(yīng)該用實體對象還是值對象,我覺得本著一個是否具有業(yè)務(wù)行為的原則就夠了,有業(yè)務(wù)行為的就用實體對象,沒有業(yè)務(wù)行為的就設(shè)計成值對象。
