DDD分層架構之領域實體(基礎篇)
上一篇,我介紹了自己在DDD分層架構方面的一些感想,本文開始介紹領域層的實體,代碼主要參考自《領域驅動設計C#2008實現》,另外參考了網上找到的一些示例代碼。
什么是實體
由標識來區分的對象稱為實體。
實體的定義隱藏了幾個信息:
- 兩個實體對象,只要它們的標識屬性值相等,哪怕標識屬性以外的所有屬性值都不相等,這兩個對象也認為是同一個實體,這意味着兩個對象是同一實體在其生命周期內的不同階段。
- 為了能正確區分實體,標識必須唯一。
- 實體的標識屬性值是不可變的,標識屬性以外的屬性值是可變的。如果標識值不大穩定,偶爾會變化,那么就無法將該實體在生命周期內的所有變化關聯在一起,這可能導致嚴重的問題。
實體標識
從實體的定義可以發現,標識是實體的關鍵特征。關於標識,有幾個值得思考的問題。
將什么選作標識
比如中國人都有身份證,身份證號碼是唯一的,那么可能會有人使用身份證號作為實體標識。這看起來好像沒什么問題,但身份證每隔N年就會換代,身份證號可能發生變化。這違反了標識不可變性和穩定性要求,所以不適合作為實體標識。
對於手工錄入流水號作為實體標識的情況,要用戶自己保證唯一性已經很困難,如果提供了修改標識的功能,將導致標識不穩定,如果不提供,用戶錄入錯誤就只能刪除后重新輸入,這就太不人道了。
通過程序自動生成一個有意義的流水號作為實體標識,並且不提供修改,這可能是可行的,對於唯一性要求,程序和數據庫可以保證,另外不允許修改,就可以保證穩定性。對於像訂單號一類的場景可能有效。
可以看到,使用有意義的值作為標識有一定風險,並且難度比較大,為了簡單和方便,生成一個無意義的唯一值作為標識更可行。
為標識選擇什么類型
對於使用Sql Server的同學,一般會傾向於使用int類型,映射到數據庫中的自增長int。它的優勢是簡單,唯一性由數據庫保障,占用空間小,查詢速度快。我之前也采用了很長時間,大部分時候很好用,不過偶爾會很頭痛。
由於實體標識需要等到插入數據庫之后才創建出來,所以你在保存之前不可能知道標識值是多少,如果在保存之前需要拿到Id,唯一的方法是先插入數據庫,得到Id以后,再執行另外的操作,換句話說,需要把本來是同一個事務中的操作分成多個事務執行。
使用自增長int類型的第二個毛病是,如果需要合並同一個實體對應的多個數據表記錄,悲劇就會發生。比如你現在把一個實體對應的記錄水平分區到多個數據庫的表中,由於Id是自增長的,每個表都會從1開始自增,你要合並到一個表中,Id就會發生沖突。所以對於比較大點的項目,使用自增長int類型是有一些風險的。
對於比較小,且不是太復雜的項目,使用自增長int類型是個不錯的選擇,但如果你經常碰到上面提到的問題,說明你需要重新選擇標識類型了。
要解決以上問題,最簡單的方法是選擇Guid作為標識類型。
它的主要優勢是生成Guid非常容易,不論是Js,C#還是在數據庫中,都能輕易的生成出來。另外,Guid的唯一性很強,基本不可能生成出兩個相同的Guid。
Guid類型的主要缺點是占用空間太大。另外實體標識一般映射到數據庫的主鍵,而Sql Server會默認把主鍵設成聚集索引,由於Guid的不連續性,這可能導致大量的頁拆分,造成大量碎片從而拖慢查詢。一個解決辦法是使用Sql Server來生成Guid,它可以生成連續的Guid值,但這又回到了老路,只有插入數據庫你才知道具體的Id值,所以行不通。另一個解決辦法是把聚集索引移到其它列上,比如創建時間。如果你打算把聚集索引繼續放到Guid標識列上,可以觀察到碎片一般都在90%以上,寫一個Sql腳本,定時在半夜整理一下碎片,也算一個勉強的辦法。
如果生成一個有意義的流水號來作為標識,這時候標識類型就是一個字符串。
有些時候可能還要使用更復雜的組合標識,這一般需要創建一個值對象作為標識類型。
我目前一般都使用Guid作為標識類型,偶爾使用字符串類型。
對於需要更詳細的了解實體標識,請參考《企業應用架構模式》標識域一節。
實體層超類型的實現
既然每個實體都有一個標識,那么為所有實體創建一個基類就顯得很有用了,這個基類就是層超類型,它為所有領域實體提供基礎服務。
為了降低依賴性,現在需要在本系列應用程序框架的VS解決方案中增加一個類庫Util.Domains和單元測試項目Util.Domains.Tests,並使用解決方案文件夾進行分類,如下圖所示。
各程序集的依賴關系如下圖所示。
實體基類可以取名為EntityBase,它應該是一個抽象類,具有一個名為Id的屬性。如果采用int作為標識類型,代碼可能是這樣。
namespace Util.Domains { /// <summary> /// 領域實體 /// </summary> public abstract class EntityBase{ /// <summary> /// 初始化領域實體 /// </summary> /// <param name="id">標識</param> protected EntityBase( int id ) { Id = id; } /// <summary> /// 標識 /// </summary> public int Id { get; private set; } } }
觀察上面的代碼,這里要考慮的關鍵問題是Id的set屬性是否應該公共出來。根據前面的介紹,實體標識應該是不可變的,如果把Id的set屬性設為公開,那么任何人都可以隨時很方便的修改它,從而破壞了封裝性。
那么,把Id的set屬性設成私有,外界確實無法修改它,設置Id的唯一方法是在創建這個實體時,從構造函數傳進來。但這會導致哪些問題?先看看ORM,它需要將數據庫中的Id列映射到實體的Id屬性上,如果set被設為私有,還能映射成功嗎。通過測試,一般的ORM都具備映射私有屬性的能力,比如EF,所以這不是問題。再來看看表現層,比如Mvc,Mvc提供了一個模型綁定功能,可以把表現層的數據映射到實體的屬性上,如果屬性是私有的會如何?測試以后,發現只有包含public 的set屬性才可以映射成功,甚至字段都不行。再測試Wpf的雙向綁定,也基本如此。所以把Id的set屬性設為私有,將導致實體在表現層無法直接使用,需要通過Dto或ViewModel進行中轉。
所以你需要在封裝性和易用性上作出權衡,如果你希望更高的健壯性,那就把Id的set屬性隱藏起來,否則直接把Id暴露出來,通過約定告訴大家不要在創建了實體之后修改Id的值。由於本系列准備演示Dto的用法,所以會把Id setter隱藏起來,並通過Dto來轉換。如果你需要更方便,請刪除Id setter上的private。
現在Id類型為int,如果要使用Guid類型的實體,我們需要創建另一個實體基類。
namespace Util.Domains { /// <summary> /// 領域實體 /// </summary> public abstract class EntityBase{ /// <summary> /// 初始化領域實體 /// </summary> /// <param name="id">標識</param> protected EntityBase( Guid id ) { Id = id; } /// <summary> /// 標識 /// </summary> public Guid Id { get; private set; } } }
它們的唯一變化是Id數據類型不同,我們可以把Id類型設為object,從而支持所有類型。
namespace Util.Domains { /// <summary> /// 領域實體 /// </summary> public abstract class EntityBase{ /// <summary> /// 初始化領域實體 /// </summary> /// <param name="id">標識</param> protected EntityBase( object id ) { Id = id; } /// <summary> /// 標識 /// </summary> public object Id { get; private set; } } }
但弱類型的object將導致裝箱和拆箱,另外也不太易用,這時候是泛型准備登場的時候了。
namespace Util.Domains { /// <summary> /// 領域實體 /// </summary> /// <typeparam name="TKey">標識類型</typeparam> public abstract class EntityBase<TKey> { /// <summary> /// 初始化領域實體 /// </summary> /// <param name="id">標識</param> protected EntityBase( TKey id ) { Id = id; } /// <summary> /// 標識 /// </summary> [Required] public TKey Id { get; private set; } } }
將標識類型通過泛型參數TKey傳進來,由於標識類型可以任意,所以不需要進行泛型約束。另外在Id上方加了一個Required特性,當Id為字符串或其它引用類型的時候,就能派上用場了。
下面要解決的問題是實體對象相等性比較,需要重寫Equals,GetHashCode方法,另外需要重寫==和!=兩個操作符重載。
/// <summary> /// 相等運算 /// </summary> public override bool Equals( object entity ) { if ( entity == null ) return false; if ( !( entity is EntityBase<TKey> ) ) return false; return this == (EntityBase<TKey>)entity; } /// <summary> /// 獲取哈希 /// </summary> public override int GetHashCode() { return Id.GetHashCode(); } /// <summary> /// 相等比較 /// </summary> /// <param name="entity1">領域實體1</param> /// <param name="entity2">領域實體2</param> public static bool operator ==( EntityBase<TKey> entity1, EntityBase<TKey> entity2 ) { if ( (object)entity1 == null && (object)entity2 == null ) return true; if ( (object)entity1 == null || (object)entity2 == null ) return false; if ( entity1.Id == null ) return false; if ( entity1.Id.Equals( default( TKey ) ) ) return false; return entity1.Id.Equals( entity2.Id ); } /// <summary> /// 不相等比較 /// </summary> /// <param name="entity1">領域實體1</param> /// <param name="entity2">領域實體2</param> public static bool operator !=( EntityBase<TKey> entity1, EntityBase<TKey> entity2 ) { return !( entity1 == entity2 ); }
在操作符==的代碼中,有一句需要注意,entity1.Id.Equals( default( TKey ) ),比如,一個實體的標識為int類型,這個實體在剛創建的時候,Id默認為0,另外創建一個該類的實例,Id也為0,那么這兩個實體是相等還是不等?從邏輯上它們是不相等的,屬於不同的實體, 只是標識目前還沒有創建,可能需要等到保存到數據庫中才能產生。這有什么影響呢?當進行某些集合操作時,如果你發現操作N個實體,但只有一個實體操作成功,那很有可能是因為這些實體的標識是默認值,而你的相等比較沒有識別出來,這一句代碼能夠解決這個問題。
考慮領域實體基類還能幫我們干點什么,其實還很多,比如狀態輸出、初始化、驗證、日志等。下面先來介紹一下狀態輸出。
當我在操作每個實體的時候,我經常需要在日志中記錄完整的實體狀態,即實體所有屬性名值對的列表。這樣方便我在查找問題的時候,可以了解某個實體當時是個什么情況。
要輸出實體的狀態,最方便的方法是重寫ToString,然后把實體狀態列表返回回來。這樣ToString方法將變得有意義,因為它輸出一個實體的類名基本沒什么用。
要輸出實體的全部屬性值,一個辦法是通過反射在基類中進行,但這可能會造成一點性能下降,由於通過代碼生成器可以輕松生成這個操作,所以我沒有采用反射的方法。
/// <summary> /// 描述 /// </summary> private StringBuilder _description; /// <summary> /// 輸出領域對象的狀態 /// </summary> public override string ToString() { _description = new StringBuilder(); AddDescriptions(); return _description.ToString().TrimEnd().TrimEnd( ',' ); } /// <summary> /// 添加描述 /// </summary> protected virtual void AddDescriptions() { } /// <summary> /// 添加描述 /// </summary> protected void AddDescription( string description ) { if ( string.IsNullOrWhiteSpace( description ) ) return; _description.Append( description ); } /// <summary> /// 添加描述 /// </summary> protected void AddDescription<T>( string name, T value ) { if ( string.IsNullOrWhiteSpace( value.ToStr() ) ) return; _description.AppendFormat( "{0}:{1},", name, value ); }
在子類中需要重寫AddDescriptions方法,並在該方法中調用AddDescription這個輔助方法來添加屬性名值對的描述。
由於驗證和日志等內容需要一些公共操作類提供幫助,所以放到后面幾篇進行介紹。
為了使泛型的EntityBase<TKey>用起來更簡單一點,我創建了一個EntityBase,它從泛型EntityBase<Guid>派生,這是因為我現在主要使用Guid作為標識類型。
namespace Util.Domains { /// <summary> /// 領域實體基類 /// </summary> public abstract class EntityBase : EntityBase<Guid> { /// <summary> /// 初始化領域實體 /// </summary> /// <param name="id">標識</param> protected EntityBase( Guid id ) : base( id ) { } } }
完整單元測試代碼如下。

完整EntityBase代碼如下。

為了完成實體基類的驗證,我需要先提供兩個公共操作類,即驗證和自定義異常類,待把這兩個類完成后,我們再繼續介紹實體基類在驗證方面的支持。
.Net應用程序框架交流QQ群: 386092459,歡迎有興趣的朋友加入討論。如果發現代碼中有BUG,請及時告知,我將迅速修復。
謝謝大家的持續關注,我的博客地址:http://www.cnblogs.com/xiadao521/
下載地址:http://files.cnblogs.com/xiadao521/Util.2014.11.17.1.rar