从壹开始微服务 [ DDD ] 之五 ║聚合:实体与值对象 (上)

前言

哈喽,老张是周四放松又开始了,这些天的工作真的是繁重,三个项目同时启动,没办法,只能在深夜写文章了,现在时间的周四凌晨,白天上班已经没有时间开始写文章了,希望看到文章的小伙伴,能给个辛苦赞👍哈哈,当然看心情很随意。废话不多说,话说上次咱们对DDD简单说明了下存在的意义,还有就是基于教学上下文的第一次定义,今天咱们就继续说说DDD领域驱动设计中的聚合相关知识,聚合这一块比较多,我暂时决定用两到三篇文章来说说,今天就主要说一下“实体和值对象”的相关概念,其实之前我在定计划的时候,感觉这一块应该很好说,但是晚上吃完饭搜索资料的时候,发现真的好多人对实体理解的还好,但是对值对象真是各种不理解,甚至嗤之以鼻,这一点我感觉是不好的,希望我的读者不要只会说这个不好,那个不对,而是想,这个东西既然产生了,并且一直被大家说着,也有在使用的,肯定有存在的意义,举个栗子,可能今天大家看完对值对象还是蒙胧胧,多想想,多跟着DDD的思想走,也许就好多了,思想真的很难改变,不过只要努力了就是成功了。

好!咱们还是开篇一个小问题,给大家正好一个思考的时间:

咱们从壹大学的后台系统中,每个学生都有自己的家庭住址,肯定会有这样或那样的原因,会变化,那我们是如何设计 Student模型 和 Address 模型的呢,这里只是说代码实现上,数据库其实是对应的。

1、在Students实体中,添加家庭地址属性:省、市、县、街道;

2、新建家庭地址Address实体,在Student中引入地址外键;

3、新建 Students 、Address、StuAdd三个表,在Students中引入List<Address>,一对多;

这个就是我们平时的思路,无论是第一种的一对一(一个学生一个家庭地址),还是第三种的一对多(一个学生多个家庭地址),如果你对这个思路很熟悉,那就需要好好看看今天的文章了,因为上边的这种还是面向数据库数据开发的,希望下边的说明,能让你对DDD的思想有一定的体验。

零、今天要实现蓝色的部分

image

一、实体 —— 唯一标识

实体对应的英语单词为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 + "]";
        }

    }
}
image

3、实体模型继承该Entity

修改我们的 Student 模型,继承 Entity,并把属性 Id 去掉。

image

这个时候,我们就已经把实体说完了,其实很简单,我们平时也都在用,总结来说以下两点:

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
{ public Address(string province, string city, string county, string street, string zip)
        { this.Province = province; this.City = city; this.County = county; this.Street = street;
        } /// <summary>
    /// 地址 /// </summary>
    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; } 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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容