从壹开始微服务 [ 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、可变性特征,状态信息一直可以变化。
二、定义一个实体
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、实体与值对象的区别:
- 实体拥有标识,而值对象没有。
- 相等性测试方式不同。实体根据标识判等,而值对象根据内部所有属性值判等。
- 实体允许变化,值对象不允许变化。
- 持久化的映射方式不同。实体采用单表继承、类表继承和具体表继承来映射类层次结构,而值对象使用嵌入值或序列化大对象方式映射。