DDD分層架構之值對象(介紹篇)


DDD分層架構之值對象(介紹篇)

前面介紹了DDD分層架構的實體,並完成了實體層超類型的開發,同時提供了驗證方面的支持。本篇將介紹另一個重要的構造塊——值對象,它是聚合中的主要成分。

  如果說你已經在使用DDD分層架構,但你卻從來沒有使用過值對象,這毫不奇怪,因為多年來養成的數據建模思維已經牢牢把你禁錮,以致於你在使用面向對象方式進行開發時,還是以數據為中心。

  當我們完成了基本的需求分析以后,如果說需要進行設計,那么你能想到的就是數據庫表及表關系的設計,這就是數據建模。數據建模的主要依據是數據庫范式設計,根據要求嚴格程度的遞增分為第N范式,基本的要求是把每個標量屬性值用單獨的一列來存儲,每個非鍵屬性必須完全依賴於鍵屬性。數據庫范式設計的目標是消除存儲在多個位置上的冗余數據,以免導致更新異常。為了達到這個目的,需要進行不斷的表拆分,直到每個表都只表示一個單一的概念。這可以認為是SRP(單一職責原則)在表上的應用,從而使表中的數據產生更高的內聚性。這從數據庫的角度看可能是不錯的,但對於面向對象開發卻不見得是個好事。

  每一個表稱為一個數據庫實體。當你完成了表設計以后,很自然的把數據庫實體與DDD實體等同起來,這產生了一個直觀的映射,所以每個表在你的系統中都是一個實體。受這個根深蒂固的開發模式影響,你與值對象無緣相見。

  值對象不僅在概念上提供強大的幫助,而且在技術上,特別是持久化方面能夠大幅簡化系統設計,后面我將逐步介紹聚合與值對象是如何幫助你降低系統復雜性而脫困的。

什么是值對象                                                  

  通過對象屬性值來識別的對象,它將多個相關屬性組合為一個概念整體。

  在值對象的概念中,隱含了如下信息:

  1. 值對象可以對某些簡單業務概念建模。
  2. 值對象沒有標識。值對象比實體簡單得多,不需要跟蹤變化,所以它沒有標識。
  3. 值對象是不可變的。這是值對象的核心特征,后面將詳述。
  4. 值對象的相等性比較是通過各個屬性值的比較來完成的。
  5. 由於值對象代表一個概念整體,所以只能進行整體替換,而不是修改值對象的某個屬性。

值對象的價值                                                  

  看了上面的概念描述,可能並不能打動你。你會說“實體不就比值對象多一個標識,能復雜到哪去”。由於你使用實體同樣可以對業務概念建模,所以是否使用值對象,對你來說根本不重要。

  下面來看看使用值對象的其它好處。

  值對象的一個作用是可以幫助優化性能。當一個值對象需要在多個地方使用時,可以共享同一個值對象。為了共享同一個值對象,你可以使用工廠來創建單例模式的值對象實例,由於值對象是不可變的,所以可以安全的使用。

  當然,你可能對使用值對象來提升性能也不感興趣,你需要更實在的好處,否則就免談。下面將介紹值對象的重型武器,它對你將產生空前的影響,甚至顛覆你平時的建模習慣和開發模式。

  前面已經說過,你為了滿足數據庫規范化設計,創建大量的表,各個表之間關系錯綜復雜,而且你也意識到正是表的膨脹導致了系統復雜性的上升。如果能夠減少表的數量,那么表之間的關系也會變得簡單和清晰,有什么辦法可以減少表的數量嗎?答案就是值對象與逆范式設計。

  首先來看一個簡單情況。現在要為人力資源系統建立員工檔案,我們使用一個名為Employee的員工類來表示這個業務概念,除了名字以外,還要管理他的地址信息,我們可以將地址信息直接放到員工實體上,數據庫表結構與員工實體一樣,代碼如下所示。

 

復制代碼
    /// <summary>
    /// 員工
    /// </summary>
    public class Employee : EntityBase {
        /// <summary>
        /// 姓名
        /// </summary>
        public string Name { get; set; }

        /// <summary>
        /// 省份
        /// </summary>
        public string Province { get; set; }

        /// <summary>
        /// 城市
        /// </summary>
        public string City { get; set; }

        /// <summary>
        /// 區縣
        /// </summary>
        public string County { get; set; }

        /// <summary>
        /// 街道
        /// </summary>
        public string Street { get; set; }

        /// <summary>
        /// 郵政編碼
        /// </summary>
        public string Zip { get; set; }
    }
復制代碼

  不過你的數據庫規范化專業技能非常敏感,讓你察覺到這幾個地址屬性都不完全依賴於員工主鍵,所以你決定專門建一張地址表,再把地址表與員工表關聯起來。

  你的代碼也作出相應調整如下。

復制代碼
    /// <summary>
    /// 員工
    /// </summary>
    public class Employee : EntityBase{

        /// <summary>
        /// 姓名
        /// </summary>
        public string Name { get; set; } 

        /// <summary>
        /// 地址編號
        /// </summary>
        public Guid AddressId { get; set; } 

        /// <summary>
        /// 地址
        /// </summary>
        public Address Address { get; set; }
    } 

    /// <summary>
    /// 地址
    /// </summary>
    public class Address : EntityBase {

        /// <summary>
        /// 省份
        /// </summary>
        public string Province { get; set; }

        /// <summary>
        /// 城市
        /// </summary>
        public string City { get; set; }

        /// <summary>
        /// 區縣
        /// </summary>
        public string County { get; set; }

        /// <summary>
        /// 街道
        /// </summary>
        public string Street { get; set; }

        /// <summary>
        /// 郵政編碼
        /// </summary>
        public string Zip { get; set; }
    }
復制代碼

  可以看到,對於這樣的簡單場景,一般有兩個選擇,要么把屬性放到外部的實體中,只創建一張表,要么建立兩個實體,並相應的創建兩張表。第一種方法的問題是,一個整體業務概念被弱化成一堆零碎的屬性值,不僅無法表達業務語義,而且使用起來非常困難,同時將很多不必要的業務知識泄露到調用端。第二種方法的問題是導致了不必要的復雜性。

  更好的方法很簡單,就是把以上兩種方法結合起來。我們通過把地址建模成值對象,而不是實體,然后把值對象的屬性值嵌入外部員工實體的表中,這種映射方式被稱為嵌入值模式。換句話說,你現在的數據庫表采用上面的第一種方式定義,而你在c#代碼中通過第二種方式使用,只是把實體改成值對象。這樣做的好處是顯而易見的,既將業務概念表達得清楚,而且數據庫也沒有變得復雜,可謂魚和熊掌兼得。

  使用嵌入值模式映射值對象,你發現將部分違反范式設計的規則,這正是數據建模與對象建模一個重要的不同之處。要想盡量的發揮對象的威力,就需要弱化數據庫的作用,只把他作為一個保存數據的倉庫。對象建模越成功,與數據建模就會差別越大。所以當違反數據庫設計原則時,不用大驚小怪,只要業務能夠順利運行,就沒什么關系。

  使用嵌入值進行映射的另一個優勢是能夠優化查詢性能,因為不需要進行聯表,單表索引調優也要容易得多。

  嵌入值映射基本沒什么副作用,它是單個值對象的標准映射方式。但是,嵌入值映射只能映射單個值對象,如果值對象是一個集合會怎樣?

  繼續我們的員工管理模塊,客戶要求能夠管理員工的教育經歷、職務變動等一系列和該員工相關的附屬信息,而且這些附屬信息都是多行記錄,比如教育經歷,他從小學一直到博士的所有教育經歷,需要多次錄入。從數據庫的角度,就是主從表設計,客戶是主表,其它都是從表。從對象的角度考慮,外層的客戶是聚合根,附屬的所有信息都是聚合內部的子對象,要么建模成實體,要么建模成值對象,它們從概念上構成一個整體,即聚合。

  現在先來看傳統的主從表建模方式,每個附屬信息都需要創建一個表,並映射成一個實體。如果附屬信息有10種,那么一共需要創建11個表,可以看到,表數據大量增加,從而導致系統變得復雜。另外,考慮員工管理在界面上的操作,可以在界面上放一個選項卡來顯示員工的每項附屬信息,現在如果要添加員工的教育經歷,一種簡單的方法是在添加完一條教育經歷以后立即保存並刷新。但有時為了易用性等考慮,允許客戶在界面上隨意操作,並在最后一步點擊保存按鈕一次性提交。把一個包含多個實體集合的聚合提交到服務端進行持久化,這可能非常復雜,需要從數據庫中將聚合取出,然后通過標識判斷出每個子實體,哪些是新增的,哪些是修改的,哪些是已經刪除的。

  如果把實體換成值對象,情況就大不相同了,將大幅簡化系統設計。前面介紹了單個值對象通過嵌入值模式映射,那么現在是值對象集合,如何映射呢?由於你不可能把值對象集合的每個元素映射到外層的實體表中,但是創建多個表又增加復雜性,所以一個變態的方法是使用序列化大對象模式。把一個值對象的集合直接序列化到表中的一個字段中,這甚至違反了數據建模第一范式。可以看到,這種保存數據的方式已經顛覆了你平時的習慣。

  說到這里,很多人可能准備質疑這個示例的建模方案了,這些子對象能不能被建模成值對象,甚至應不應該放到員工聚合中都要看具體情況,需要考慮多方面因素,諸如業務需求,查詢需求,並發和性能需求等,現在假設,員工的附屬信息使用值對象建模沒什么問題,我們來看看對系統的簡化有多大改觀。

  首先,11個表被簡化成了1個表,在表中增加了10個列而已。這個簡化簡直驚人。

  另外再來看看界面上的操作,如果需要一次性提交整個聚合,由於值對象沒有標識,而且是整體替換的,所以你不需要從數據庫中把聚合拿出來作比較,只需要重新一個序列化,就萬事大吉。

  從上面可以看出,值對象可以幫你大幅簡化持久化方面的工作,這都打動不了你,我確實也無話可說。

值對象的設計要點                                                  

  值對象必須不可變。

  不變性是值對象的一個基本特征,為何要如此嚴格的規定?有幾個原因:

  1. 值對象代表的就是一個值,這個值是一個整體,如果需要修改,必須整個替換,不能部分修改。這是從概念上說明值對象的不變性。
  2. 為了安全的使用值對象,防止別名Bug。前面說過,值對象的一個作用是優化性能,減少內存占用,這是通過共享同一個值對象來實現的。如果值對象允許修改,當一個值對象被多個其它對象共享時,如果其中一個對象改變了值對象的某個屬性值,這個改變在其它對象上也會馬上生效,可能導致嚴重的問題,這被稱為別名Bug。另外,將值對象進行引用傳遞時,值對象在其它代碼中可能發生任何操作。這是從技術上保證值對象只有不可變,才能安全的使用,不然隨時可能擔心吊膽,當發生Bug時也很難跟蹤。
  3. 當把值對象作為Dictionary這樣的哈希集合的鍵時,哈希集合會使用值對象的GetHashCode計算出一個地址,並將值保存在這里,之后,如果需要查找一個值,通過值對象的GetHashCode重新計算出該地址,然后把值提取出來。如果值對象是可變的,當把數據保存到哈希集合之后,修改了值對象,那么通過值對象重新計算出來的hashcode可能不同,從而丟失了這個值。

  使用object建模值對象,而不是struct。

  想想看,我們現在討論的值對象,它的不變性與.Net提供的值類型struct如此相似,那么是不是應該使用struct建模值對象呢?不行,原因如下:

  1. struct用來實現基元類型,比如int,這些類型都非常小,專家建議不要超過16字節大小。我們現在的值對象雖然比實體可能簡單些,但還是可能很龐大。一個比較大的對象,從性能上考慮,放入堆中進行垃圾回收更合適,實際上string就是一個值對象。
  2. 如果使用像Entity Framwork這樣的ORM框架,它可能不支持struct的映射。

  嵌入值模式映射列名可以遵循一定命名規則。

  當使用嵌入值模式進行映射時,在聚合表中,可以根據層次關系命名列名。比如員工聚合中的地址值對象的城市屬性,可以命名為:Employee_Address_City,或者Address_City,這樣可以更清晰的表達子對象的映射關系。

使用值對象的挑戰                                                  

  使用值對象的第一個挑戰來自關系數據庫。

  從上面的例子可以看到,值對象可以極度簡化系統設計是因為采用了序列化大對象模式。但是這種設計方式存在很多弊端,最重要的是導致搜索值對象屬性值變得異常困難。比如,客戶提出,需要根據員工教育經歷的學校名稱進行搜索,以查找哪些員工在某個學校曾經讀過。

  采用序列化大對象模式,一種方式是序列化成二進制流,然后保存到Sql Server的varbinary(MAX)字段中。如果采用這種方式存儲,當我們要搜索教育經歷的學校名稱時,只能把所有員工讀取到內存進行過濾。除此之外,當你直接查看數據庫時,將完全不知所雲,相信你不會牛B到能讀懂二進制流的境界。還有一個問題是,當值對象的結構發生變化,比如你增加了幾個屬性,可能在反序列化時失敗。所以這種方式不被推薦。

  另一種方式是序列化成文本流,保存到Sql Server的nvarchar(MAX)字段中。你可以選擇XML格式,或者JSON格式。一般來講JSON要好得多,不僅占更少空間,而且更加簡單清晰。當我們要搜索教育經歷的學校名稱時,可以在nvarchar(MAX)字段中通過Like進行搜索,這樣雖然不是太高效,但比起讀取全部員工實體進行過濾還是要強些。

  值對象集合的搜索解決辦法如下:

  1. 根本不提供值對象屬性的查詢條件。這一點需要你的客戶或老板通人性才行,另外也有一些技巧。如果你直接告訴老板,這個搜索功能做不了,你的老板會大發雷霆“這么簡單都做不出來,我要你來干嘛”。但是,如果你告訴老板不提供這幾個搜索條件,可以提前兩天完工,他有可能就批了。
  2. 更換成NOSQL數據庫,比如MongoDB。MongoDB支持層次化存儲和查詢,從而從根本上解決問題。但不是每個系統都能用上MongoDB,也不是每個系統都適合使用MongoDB,比如你的系統需要很強的事務控制,但MongoDB只有一些有限的原子操作能力,不支持事務。
  3. 使用Like進行搜索,這在數據不太大的時候,也能湊活。
  4. 建立單獨的查詢數據庫或表。為了提升查詢效率,專門為查詢創建一些表,這些表的結構按照搜索最方便的方式設計,這樣將查詢與操作分離開來。這樣做的問題是比較麻煩,另外導致復雜度上升,但它能夠兼顧操作的簡便性和查詢性能,所以也不失為一種解決方法。使用這種方法需要將數據保存兩份,在同一事務中采用同步更新可能導致更新上的性能損失。如果采用異步方式更新,雖然性能提升,又可能導致更新延時,造成界面顯示異常等問題。
  5. 轉成實體。如果上面的方法,你覺得都不好,可能轉成實體更簡單方便。
  6. 在《實現領域驅動設計》一書中,提供了另一種設計方案,它采用實體的表設計方式,然后在值對象的層超類型中隱藏標識,這樣在代碼中感覺它還是一個值對象,同時又可查詢。不過我個人不是太喜歡這個方案,我如果創建了單獨的表,可能使用實體更方便。

  使用值對象的另一個挑戰來自表現層界面。

  值對象的一個關鍵設計是支持不變性,這意味着值對象的每個屬性都沒有setter,或者setter只在對象內部允許訪問,這對我們有什么影響呢?

  現在你的表現層正在使用Mvc或Wpf,它們都支持模型綁定。當你在Mvc表單界面進行輸入之后,提交到控制器操作,你可以在控制器操作上使用一個實體來接收參數。想像一下,你現在需要把員工地址傳遞到控制器操作,但由於Address是不可變的,從而導致模型綁定失敗。

  為了解決這個問題,使用值對象的必備條件是創建一個配套的可變值對象,對於Address,你可以給這個可變值對象取名為AddressViewModel,或者AddressDto都行,我一般叫它AddressInfo。這個對象的所有屬性都有setter,並且是public的,這樣才可以在表現層使用,然后它會轉換成值對象,供領域層使用。

  從以上可以看出,雖然說考慮領域模型時,不要考慮數據庫和界面,但最終這兩個大環境對設計決策是可能造成影響的。

使用值對象的建議                                                  

  1. 聚合中盡量使用值對象。值對象與實體在很多時候可能是可互換的,由於值對象可以簡化系統,所以當它的缺點可以克服就應該堅決采用。
  2. 值對象必須設計成不可變,並且值對象的任何方法都不能修改屬性值。如果值對象的方法需要進行修改,可以通過該方法返回一個該值對象的新實例。如果對象是可變的,應該建模為實體,而不是值對象。
  3. 如果需要跟蹤對象的生命周期,或者在聚合外部,需要進行標識引用,應該采用實體,而不是值對象。

最后,總結一下                                                  

你排斥值對象的主要原因:

  1. 長期以來,我們使用數據庫所造成的思維定勢影響。
  2. 序列化大對象,造成查詢不便。
  3. 不可變值對象在界面上無法綁定,需要額外創建配套的可變值對象,讓你覺得工作量變大。
  4. 代碼生成器無法直接創建值對象,需要將生成出來的代碼手工調整,你不想這么麻煩。

值對象為你提供的主要價值:

  1. 更簡單,更清晰的表達簡單業務概念。
  2. 幫助你優化系統性能。
  3. 幫助你簡化系統設計,特別是持久化方面。

值對象的設計要點:

  1. 值對象必須不可變。
  2. 值對象的任何方法都不能直接修改屬性值,可以通過該方法返回一個新實例。
  3. 使用object建模值對象,而不是struct。
  4. 當值對象是單個時,優先使用嵌入值模式映射。在EF中通過ComplexTypeConfiguration配置映射。
  5. 當值對象是集合,或者值對象的內部層次關系很復雜時,優先使用序列化大對象模式映射。
  6. 嵌入值模式映射列名可以遵循一定命名規則,比如Employee_Address_City。
  7. 序列化大對象時,優先使用Json格式保存。
  8. 為每個值對象創建一個配套的可變值對象,以方便界面使用。

實體與值對象的區別:

  1. 實體擁有標識,而值對象沒有。
  2. 相等性測試方式不同。實體根據標識判等,而值對象根據內部所有屬性值判等。
  3. 實體允許變化,值對象不允許變化。
  4. 持久化的映射方式不同。實體采用單表繼承、類表繼承和具體表繼承來映射類層次結構,而值對象使用嵌入值或序列化大對象方式映射。

參考                                                  

  1. 如果你對映射模式感興趣,請參考《企業應用架構模式》第12章——對象關系結構模式。
  2. 如果你對別名Bug感興趣,請參考《企業應用架構模式》第18章值對象一節。
  3. 如果你對創建配套可變值對象感興趣,請參考《領域驅動設計 c# 2008實現》第97頁MutableAddress類一節。
  4. 《實現領域驅動設計》一書非常經典,建議你直接買了。

  本篇為大家簡要介紹了值對象,下一篇我們將完成值對象層超類型的開發。

  .Net應用程序框架交流QQ群: 386092459,歡迎有興趣的朋友加入討論。

  謝謝大家的持續關注,我的博客地址:http://www.cnblogs.com/xiadao521/

 

版權所有,轉載請注明出處  何鎮汐的技術博客
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM