從壹開始微服務 [ DDD ] 之五 ║聚合:實體與值對象 (上)


前言

哈嘍,老張是周四放松又開始了,這些天的工作真的是繁重,三個項目同時啟動,沒辦法,只能在深夜寫文章了,現在時間的周四凌晨,白天上班已經沒有時間開始寫文章了,希望看到文章的小伙伴,能給個辛苦贊👍哈哈,當然看心情很隨意。廢話不多說,話說上次咱們對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、可變性特征,狀態信息一直可以變化。
 

二、定義一個實體

 在我們之前的代碼中,我們定義了 Student 模型,我們是在當前模型中,添加了唯一標識 
  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、創建領域核心類庫,並添加實體

在領域驅動設計中,我們會有一些核心的公共的核心內容,所以類庫 Christ.Domain.Core 就是起到的這個作用,除了領域模型外,還有以后的事件、命令和通知等核心內容類。
因為實體屬於領域模型內容,所以我們新建一個 Models 文件夾,並在其新建 Entity.cs 文件
這個時候,如果你問我,為什么要單單定義一個 Entity 基類,而不把 Id 放到每一個實體中,嗯,那就是還沒有命名領域設計中,基於業務的考慮,我們平時都是直接用面向數據庫數據的思想來考慮的,duang duang設計表結構,自然而然的想到每一個表(實體模型)必須有一個Id,但是現在,我們是基於業務考慮的,每一個業務下邊會有子領域,然后每個子領域都是聚合的,通過一個聚合根來關聯,把相似的功能或者根單獨拿出來,這個就是實體基類 Entity 的作用,當然除了 Id 還會有一些方法,比如以下:
 
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

 修改我們的 Student 模型,繼承 Entity,並把屬性 Id 去掉。

 

這個時候,我們就已經把實體說完了,其實很簡單,我們平時也都在用,總結來說以下兩點:
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、創建值對象基類

在 Christ3D.Domain.Core 類庫下的Models文件夾中,新建 ValueObject.cs
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、實體與值對象的區別:

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

五、結語(待續)

今天因為時間的問題暫時就說這么多吧,這里只是把 實體 和值對象的概念和使用說明了下,具體的好處和強大的優勢還沒有來得及說,下一篇文章,我會說繼續說聚合的內容,包括實體驗證等,這篇文章也需要慢慢的潤潤色,加油吧
 

六、Github & Gitee

https://github.com/anjoy8/ChristDDD

https://gitee.com/laozhangIsPhi/ChristDDD 


免責聲明!

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



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