前言
哈嘍,老張是周四放松又開始了,這些天的工作真的是繁重,三個項目同時啟動,沒辦法,只能在深夜寫文章了,現在時間的周四凌晨,白天上班已經沒有時間開始寫文章了,希望看到文章的小伙伴,能給個辛苦贊👍哈哈,當然看心情很隨意。廢話不多說,話說上次咱們對DDD簡單說明了下存在的意義,還有就是基於教學上下文的第一次定義,今天咱們就繼續說說DDD領域驅動設計中的聚合相關知識,聚合這一塊比較多,我暫時決定用兩到三篇文章來說說,今天就主要說一下“實體和值對象”的相關概念,其實之前我在定計划的時候,感覺這一塊應該很好說,但是晚上吃完飯搜索資料的時候,發現真的好多人對實體理解的還好,但是對值對象真是各種不理解,甚至嗤之以鼻,這一點我感覺是不好的,希望我的讀者不要只會說這個不好,那個不對,而是想,這個東西既然產生了,並且一直被大家說着,也有在使用的,肯定有存在的意義,舉個栗子,可能今天大家看完對值對象還是蒙朧朧,多想想,多跟着DDD的思想走,也許就好多了,思想真的很難改變,不過只要努力了就是成功了。
好!咱們還是開篇一個小問題,給大家正好一個思考的時間:
咱們從壹大學的后台系統中,每個學生都有自己的家庭住址,肯定會有這樣或那樣的原因,會變化,那我們是如何設計 Student模型 和 Address 模型的呢,這里只是說代碼實現上,數據庫其實是對應的。
1、在Students實體中,添加家庭地址屬性:省、市、縣、街道;
2、新建家庭地址Address實體,在Student中引入地址外鍵;
3、新建 Students 、Address、StuAdd三個表,在Students中引入List<Address>,一對多;
這個就是我們平時的思路,無論是第一種的一對一(一個學生一個家庭地址),還是第三種的一對多(一個學生多個家庭地址),如果你對這個思路很熟悉,那就需要好好看看今天的文章了,因為上邊的這種還是面向數據庫數據開發的,希望下邊的說明,能讓你對DDD的思想有一定的體驗。
零、今天要實現藍色的部分

一、實體 —— 唯一標識
實體對應的英語單詞為Entity。提到實體,你可能立馬就想到了代碼中定義的實體類。在使用一些ORM框架時,比如Entity Framework,實體作為直接反映數據庫表結構的對象,就更尤為重要。特別是當我們使用EF Code First時,我們首先要做的就是實體類的設計。在DDD中,實體作為領域建模的工具之一,也是十分重要的概念。
但DDD中的實體和我們以往開發中定義的實體是同一個概念嗎?
不完全是。在以往未實施DDD的項目中,我們習慣於將關注點放在數據上,而非領域上。這也就說明了為什么我們在軟件開發過程中會首先做數據庫的設計,進而根據數據庫表結構設計相應的實體對象,這樣的實體對象是數據模型轉換的結果。
在DDD中,實體作為一個領域概念,在設計實體時,我們將從領域出發。
1、DDD中的實體是什么
許多對象不是由它們的屬性來定義,而是通過一系列的連續性(continuity)和標識(identity)來從根本上定義的。只要一個對象在生命周期中能夠保持連續性,並且獨立於它的屬性(即使這些屬性對系統用戶非常重要),那它就是一個實體。對於實體Entity,實體核心是用唯一的標識符來定義,而不是通過屬性來定義。即即使屬性完全相同也可能是兩個不同的對象。同時實體本身有狀態的,實體又演進的生命周期,實體本身會體現出相關的業務行為,業務行為會實體屬性或狀態造成影響和改變。
如果從值對象本身無狀態,不可變,並且不分配具體的標識層面來看。那么值對象可以僅僅理解為實際的Entity對象的一個屬性結合而已。該值對象附屬在一個實際的實體對象上面。值對象本身不存在一個獨立的生命周期,也一般不會產生獨立的行為。
2、為什么要使用實體
1、有唯一的標識,不受狀態屬性的影響。2、可變性特征,狀態信息一直可以變化。
二、定義一個實體
public class Student { protected Student() { } public Student(Guid id, string name, string email, DateTime birthDate) { Id = id; Name = name; Email = email; BirthDate = birthDate; } public Guid Id { get; private set; }//模型的唯一標識 public string Name { get; private set; } public string Email { get; private set; } public string Phone { get; private set; } public DateTime BirthDate { get; private set; } }
我們平時用到的標識都是 Int 類型,優點是占位少,內存小等,當然有時候受到長度的影響,我們就用 long,
1、唯一標識都是什么類型
一般我們都是會傾向於使用int類型,映射到數據庫中的自增長int。它的優勢是簡單,唯一性由數據庫保障,占用空間小,查詢速度快。我之前也采用了很長時間,大部分時候很好用,不過偶爾會很頭痛。由於實體標識需要等到插入數據庫之后才創建出來,所以你在保存之前不可能知道標識值是多少,如果在保存之前需要拿到Id,唯一的方法是先插入數據庫,得到Id以后,再執行另外的操作,換句話說,需要把本來是同一個事務中的操作分成多個事務執行。除了這個問題,還有多個數據庫表合並的問題,如果兩個分表都是自增,那肯定需要單獨再一個字段來做標識,勞民傷財。
后來我就用string字符串來設置主鍵,最大的問題就出現了,就是有時候會出現一致的情況,倒是保存失敗,然后用戶反饋,當測試的時候,又好了,這種幽靈事件。所以我就決定使用 Guid 了。
它的主要優勢是生成Guid非常容易,不論是Js,C#還是在數據庫中,都能輕易的生成出來。另外,Guid的唯一性很強,基本不可能生成出兩個相同的Guid。
Guid類型的主要缺點是占用空間太大。另外實體標識一般映射到數據庫的主鍵,而Sql Server會默認把主鍵設成聚集索引,由於Guid的不連續性,這可能導致大量的頁拆分,造成大量碎片從而拖慢查詢。一個解決辦法是使用Sql Server來生成Guid,它可以生成連續的Guid值,但這又回到了老路,只有插入數據庫你才知道具體的Id值,所以行不通。另一個解決辦法是把聚集索引移到其它列上,比如創建時間。如果你打算把聚集索引繼續放到Guid標識列上,可以觀察到碎片一般都在90%以上,寫一個Sql腳本,定時在半夜整理一下碎片,也算一個勉強的辦法。
如果生成一個有意義的流水號來作為標識,這時候標識類型就是一個字符串。
有些時候可能還要使用更復雜的組合標識,這一般需要創建一個值對象作為標識類型。
既然每個實體都有一個標識,那么為所有實體創建一個基類就顯得很有用了,這個基類就是層超類型,它為所有領域實體提供基礎服務。
2、創建領域核心類庫,並添加實體
namespace Christ.Domain.Core.Models { /// <summary> /// 定義領域實體基類 /// </summary> public abstract class Entity { /// <summary> /// 唯一標識 /// </summary> public Guid Id { get; protected set; } /// <summary> /// 重寫方法 相等運算 /// </summary> /// <param name="obj"></param> /// <returns></returns> public override bool Equals(object obj) { var compareTo = obj as Entity; if (ReferenceEquals(this, compareTo)) return true; if (ReferenceEquals(null, compareTo)) return false; return Id.Equals(compareTo.Id); } /// <summary> /// 重寫方法 實體比較 == /// </summary> /// <param name="a">領域實體a</param> /// <param name="b">領域實體b</param> /// <returns></returns> public static bool operator ==(Entity a, Entity b) { if (ReferenceEquals(a, null) && ReferenceEquals(b, null)) return true; if (ReferenceEquals(a, null) || ReferenceEquals(b, null)) return false; return a.Equals(b); } /// <summary> /// 重寫方法 實體比較 != /// </summary> /// <param name="a"></param> /// <param name="b"></param> /// <returns></returns> public static bool operator !=(Entity a, Entity b) { return !(a == b); } /// <summary> /// 獲取哈希 /// </summary> /// <returns></returns> public override int GetHashCode() { return (GetType().GetHashCode() * 907) + Id.GetHashCode(); } /// <summary> /// 輸出領域對象的狀態 /// </summary> /// <returns></returns> public override string ToString() { return GetType().Name + " [Id=" + Id + "]"; } } }

3、實體模型繼承該Entity
1、實體的2大特性:唯一標識、可變性特性;2、通過業務的思維,去思考為什么定義 Entity 的作用,主要也是起到了一個聚合的目的。
三、值對象 —— 不變性
前面介紹了DDD分層架構的實體,並完成了實體層超類型的開發( 就是Entity ),本篇將介紹另一個重要的構造塊——值對象,它是聚合中的主要成分。在我們之前的開發中,因為是基於數據庫數據的,所以我們基本都是通過數據表來建立模型,這就是數據建模,然后依賴的是數據庫范式設計,這樣我們就把每一個數據庫表就對應一個實體模型,每一個表字段就對應應該實體屬性。
在看我們文章開頭的那個問題,我們就常常用第一種方法,
public class Student : Entity { protected Student() { } public Student(Guid id, string name, string email, DateTime birthDate) { Id = id; Name = name; Email = email; BirthDate = birthDate; } //public Guid Id { get; private set; } /// <summary> /// 姓名 /// </summary> public string Name { get; private set; } /// <summary> /// 郵箱 /// </summary> public string Email { get; private set; } /// <summary> /// 手機 /// </summary> public string Phone { get; private set; } /// <summary> /// 生日 /// </summary> public DateTime BirthDate { get; private set; } /// <summary> /// 省份 /// </summary> public string Province { get; private set; } /// <summary> /// 城市 /// </summary> public string City { get; private set; } /// <summary> /// 區縣 /// </summary> public string County { get; private set; } /// <summary> /// 街道 /// </summary> public string Street { get; private set; } }
但是,為了考慮不該有的屬性,比如家庭地址信息,不應該出現在學生student的業務模型中,我們就拆開,用兩個實體進行表示,然后引入外鍵,就是我們第二種方法。
public class Student : Entity { //.....其他屬性 /// <summary> /// 地址外鍵 /// </summary> public Address Address { get; private set; } } /// <summary> /// 地址 /// </summary> public class Address :Entity {/// <summary> /// 省份 /// </summary> public string Province { get; private set; } /// <summary> /// 城市 /// </summary> public string City { get; private set; } } }
可以看到,對於這樣的簡單場景,一般有兩個選擇,要么把屬性放到外部的實體中,只創建一張表,要么建立兩個實體,並相應的創建兩張表。第一種方法的缺點是,全部屬性值放到一起,沒有了整體業務概念,不僅無法表達業務語義,而且使用起來非常困難,同時將很多不必要的業務知識泄露到調用端。第二種方法的問題是導致了不必要的復雜性。
更好的方法很簡單,就是把以上兩種方法結合起來。我們通過把地址建模成值對象,而不是實體,然后把值對象的屬性值嵌入外部員工實體的表中,這種映射方式被稱為嵌入值模式。換句話說,你現在的數據庫表采用上面的第一種方式定義,而你在c#代碼中通過第二種方式使用,只是把實體改成值對象。這樣做的好處是顯而易見的,既將業務概念表達得清楚,而且數據庫也沒有變得復雜。
1、值對象的概念
1、它描述了領域中的一個東西2、可以作為一個不變量。3、當它被改變時,可以用另一個值對象替換。4、可以和別的值對象進行相等性比較。
四、如何創建一個地址值對象
1、創建值對象基類
namespace Christ3D.Domain.Core.Models { /// <summary> /// 定義值對象基類 /// 注意沒有唯一標識了 /// </summary> /// <typeparam name="T"></typeparam> public abstract class ValueObject<T> where T : ValueObject<T> { /// <summary> /// 重寫方法 相等運算 /// </summary> /// <param name="obj"></param> /// <returns></returns> public override bool Equals(object obj) { var valueObject = obj as T; return !ReferenceEquals(valueObject, null) && EqualsCore(valueObject); } protected abstract bool EqualsCore(T other); /// <summary> /// 獲取哈希 /// </summary> /// <returns></returns> public override int GetHashCode() { return GetHashCodeCore(); } protected abstract int GetHashCodeCore(); /// <summary> /// 重寫方法 實體比較 == /// </summary> /// <param name="a"></param> /// <param name="b"></param> /// <returns></returns> public static bool operator ==(ValueObject<T> a, ValueObject<T> b) { if (ReferenceEquals(a, null) && ReferenceEquals(b, null)) return true; if (ReferenceEquals(a, null) || ReferenceEquals(b, null)) return false; return a.Equals(b); } /// <summary> /// 重寫方法 實體比較 != /// </summary> /// <param name="a"></param> /// <param name="b"></param> /// <returns></returns> public static bool operator !=(ValueObject<T> a, ValueObject<T> b) { return !(a == b); } /// <summary> /// 克隆副本 /// </summary> public virtual T Clone() { return (T)MemberwiseClone(); } } }
2、在 Christ3D.Domain 類庫下的Models文件夾中,新建 Address 值對象
namespace Christ3D.Domain.Models { /// <summary> /// 地址 /// </summary> [Owned] public class Address : ValueObject<Address> { /// <summary> /// 省份 /// </summary> public string Province { get; private set; } /// <summary> /// 城市 /// </summary> public string City { get; private set; } /// <summary> /// 區縣 /// </summary> public string County { get; private set; } /// <summary> /// 街道 /// </summary> public string Street { get; private set; } public Address() { } public Address(string province, string city, string county, string street) { this.Province = province; this.City = city; this.County = county; this.Street = street; } protected override bool EqualsCore(Address other) { throw new NotImplementedException(); } protected override int GetHashCodeCore() { throw new NotImplementedException(); } } }
至此,我們的Address就具有了值的特征,我們可以直接使用Address address = new Address("北京市", "北京市", "海淀區", "一路 ");)來表示一個具體的通過屬性識別的不可變的位置概念。在DDD中,我們稱這個Address為值對象。
3、實體與值對象的區別:
- 實體擁有標識,而值對象沒有。
- 相等性測試方式不同。實體根據標識判等,而值對象根據內部所有屬性值判等。
- 實體允許變化,值對象不允許變化。
- 持久化的映射方式不同。實體采用單表繼承、類表繼承和具體表繼承來映射類層次結構,而值對象使用嵌入值或序列化大對象方式映射。
