前言
哈喽,老张是周四放松又开始了,这些天的工作真的是繁重,三个项目同时启动,没办法,只能在深夜写文章了,现在时间的周四凌晨,白天上班已经没有时间开始写文章了,希望看到文章的小伙伴,能给个辛苦赞👍哈哈,当然看心情很随意。废话不多说,话说上次咱们对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
{ 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、实体与值对象的区别:
- 实体拥有标识,而值对象没有。
- 相等性测试方式不同。实体根据标识判等,而值对象根据内部所有属性值判等。
- 实体允许变化,值对象不允许变化。
- 持久化的映射方式不同。实体采用单表继承、类表继承和具体表继承来映射类层次结构,而值对象使用嵌入值或序列化大对象方式映射。
五、结语(待续)
今天因为时间的问题暂时就说这么多吧,这里只是把 实体 和值对象的概念和使用说明了下,具体的好处和强大的优势还没有来得及说,下一篇文章,我会说继续说聚合的内容,包括实体验证等,这篇文章也需要慢慢的润润色,加油吧