驾一叶之扁舟 举匏樽以相属
寄蜉蝣于天地,渺沧海之一粟。哀吾生之须臾,羡长江之无穷。
挟飞仙以遨游,抱明月而长终。知不可乎骤得,托遗响于悲风。

从壹开始微服务 [ 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 

posted @ 2018-11-01 15:38  老张的哲学  阅读(11920)  评论(20编辑  收藏  举报
作者:老张的哲学
好好学习,天天向上
返回顶部小火箭
好友榜:
如果愿意,把你的博客地址放这里
jianshu.com/u/老张
SqlSugar codeisbug.com