DDD 领域驱动设计-三个问题思考实体和值对象(续)

时间:2021-08-12 14:39:06

上一篇:DDD 领域驱动设计-三个问题思考实体和值对象

说实话,整理现在这一篇博文的想法,在上一篇发布出来的时候就有了,但到现在才动起笔来,而且写之前又反复读了上一篇博文的内容及评论,然后去收集资料,真正去写的时候,才发现这类的博文真不是一般的难写,一句话要反复揣摩,并进行理解,最重要的是半天才蹦出一句话。

看了上面的文字,你可能会觉得我是为了写博文而写博文,其实并不是如此,我现在觉得写这类博文的目的在于梳理自己的观点,然后再进行表达出来,有的人可能会觉得为什么要纠结某一类观点?或者认为陷在一个“陷阱”中出不来,其实这只是表面如此,我的想法是通过某一类东西,去体会、学习它的过程,就像我们去某一地方旅行,你在乎到达目的地的心情吗?其实并不尽然,你应该在意的是,在这个旅行过程中,你自己有没有享受、体会或得到什么?这才是旅行的真正意义所在,我个人觉得这个过程对我非常有帮助,但如果把这个过程分享出来,不经意的一瞬间,对一部分朋友有所共鸣,那我觉得这是额外惊喜。


言归正传,上篇博文主要是通过三个问题,然后去思考实体和值对象的概念,通过实际场景去学习、理解领域模型的概念,感觉确实非常好,但第三个问题,我和 netfocus 兄在上一篇博文中探讨了好久,但遗憾的是,到最后也没准确的确定下来,这也是我写这篇博文的一部分初衷,希望可以再次通过这个“难缠”的问题,可以更深一步的理解实体和值对象。

  • 主题:消息场景中,发件人、收件人是实体?还是值对象?

发件人、收件人设计为实体会怎样?

在上一篇博文中,第一个问题是:实体的最重要特性是什么?最后归纳为两点:连续性(continuity)和标识(identity),然后在第三个问题分析中,结合发件人、收件人(以下用联系人表示)是否符合或存在这两个特性,可能在我的分析中有些牵强,所以最后我的结论是:联系人应该设计为实体。

具体实体的两个特性分析可以参考上一篇博文,消息场景中的业务非常简单,其实就存在两种“东西“:消息和联系人,当然还有一些其他的,但都不是主要的,他们俩才是主角,这两个东西设计的稍微不同,最后实现起来可能就会千差万别。但首先明确一点的是,在消息场景中,联系人是依附于消息的,脱离于消息,联系人将毫无意义,毕竟这是消息场景,而不是人员管理场景,也可以这样说:消息是男一号,那联系人是男二号,并且男二号没有“上位”的可能。

DDD 领域驱动设计-三个问题思考实体和值对象(续)

在其他的业务场景中,你会发现这种“依附”关系非常普遍,也可以说是一个应用场景最基本的关系,比如购物车场景中的 Order 和 Customer 等等,在特定的场景中,依附关系是确定的,但换一种场景,这两者之间的关系可能就会“逆向”过来,那针对这种最普遍的关系该怎么进行设计呢?

在上篇评论中我有提到,《领域驱动设计》书中第5.3.1章设计值对象,作者列出了这样一个关联设计的例子:

在电力运营公司的软件中,一个地址对应于公司线路和服务的目的地。如果多个住所都申请了电力服务,那么这个公司需要知道这一点,因此地址是实体。我们也可以用另一种方法,在模型中将“住所”关联到运营服务,其中“住所”是一个包含地址属性的实体。此时,地址就是一个值对象。

虽然很简短的一段话,但信息量太大了,我觉得理解了这段话对如何设计实体和值对象非常有帮助,我们看一下后面这段话:“住所”关联到运营服务(注意场景是电力运营),是不是有点像联系人关联消息呢,在电力运营场景中。“住所”的概念脱离运营服务也将毫无意义,再到后面:其中“住所”是一个包含地址属性的实体,是不是又有点像联系人包含名称以及其他属性的实体,它最后说的“此时,地址就是一个值对象”,其中的地址可以看作是联系人的某一个属性,比如联系人名称。

在另外一本 DDD 著作《实现领域驱动设计》第5章实体,作者一开始说了这样一段话:

唯一的身份标识和可变性(mutability)特性讲实体对象和值对象区分开来。

先看第一个,联系人是否存在唯一标识?这个在上一篇博文中就已经分析了,在消息场景中,联系人必须是唯一的,这个没什么可争议的,即使是另一种设计 SenderId、RecipientId,那这个值也是唯一的,这其实就是联系人的标识,后面可变性(mutability)是什么意思呢?和上一篇博文说的连续性(continuity)有什么区别?其实我个人觉得是一个意思,值对象从应用程序一开始就创建了,并在整个过程中,它是不可变的,而实体在其自己的生命周期内,是可变的,连续性指的是实体可变的连续,它是一个过程,就像一个人从出生到死亡,在其生命过程中,他必须首先确定他是哪个人,比如可以通过身份证号进行标识,然后他自己的一些特征可能会发生变化,比如工作、生活等,这个可以看作是可变性的体现,但必须都是在唯一标识确定的前提下,这部分内容我自己表达的有些杂,可能不太好理解,大家意会就行了。

接上面,在消息场景中,最基本的业务用例是:用户 A 给用户 B 发一个消息,然后用户 B 给用户 A 回复一个消息。。。在这个过程中,我们用 SenderId、RecipientId 来区分是哪个联系人,发送是一个动作,但在这个基本用例中,除了发送可能还会包含一些其他的东西,比如我要对联系人进行验证,就像我们买车票一样,在买之前会有一些身份验证,来确定你的身份是否合法?那这个联系人的验证过程是消息场景中的一部分?还是用户场景的一部分?我觉得这是消息场景的一部分,因为针对用户的验证都是在发消息这个动作基础上完成的,可以理解为这不属于发消息,是独立的联系人验证,但这个必须是在消息场景下。针对联系人的设计之前可能只有 Id,但消息中要进行联系人显示啊,所以后来加了 Name,再后来又要对联系人进行验证,所以又加了 IsGagged。。。这是一个不断完善的过程,这时候你会发现,在联系人对象中,除了标识之外,其他一些属性都是可能会变化的,也就是《实现领域驱动设计》书中所提到的可变性。

以上扯的有点“云里雾里”的感觉,回到这个标题上,联系人设计成实体会怎样?首先看一下 Message 消息实体中的部分代码:

public virtual Contact Sender { get; set; }
public virtual Contact Recipient { get; set; }

Message 实体中有类型为 Contact 的 Sender、Recipient 对象,用来标识此消息的发件人和收件人,这一点没什么问题,虽在实体中为对象关联,但在数据库的体现可能是 SenderId 和 RecipientId,这不是我们关心的,我们只需要操作模型中的对象即可,至于 Contact 中实体的具体设计,可以根据具体的消息场景进行设计,比如最简单的示例代码:

public class Contact
{
public int ID { get; set; }
public string DisplayName { get; set; }
public bool IsGagged { get; set; }
}

联系人设计为实体,首先符合实体的一些特性,并且在消息场景中,可以更好的对联系人进行验证,联系人存储虽不在消息中进行存储,但消息缺少联系人同样不行,所以针对消息中的联系人验证还是很有必要的,还有就是,如果哪一天消息中联系人要单独进行管理了,这时候首先确定的是联系人肯定为实体,另一个重要需要考虑的是,消息和联系人聚合问题,就像购物车中的 Orde 和 Custorm 一样。

发件人、收件人设计为值对象会怎样?

首先,现在的消息模型就是把联系人设计为值对象,具体是怎么设计的,我再详细描述下,Contact 的设计就类似上面的代码,只不过命名空间为:CNBlogs.Msg.Domain.ValueObject,然后 Contact 和 Message 的关联也像上面如此,只不过在存储的时候,需要把 Contact 中所有属性映射到 Message 中,而不只是上面的 SenderId 和 RecipientId,为什么?因为既然联系人为值对象,那其中所有属性值必须唯一,一个值不同或发生变化,那就是一个全新的值对象,而且值对象中的某一个属性代表不了整个值对象。

针对上面的问题,我举一个例子进行说明,比如 NBA 球队之间打比赛,都是五个人之间的对抗,某一个人代表不了整个球队,即使他再牛叉,而且一个完整的球队,如果某一个人离开了,那这个球队就会发生变化,对手就会根据球队的变化做出相应的调整,这个例子可能说的有些牵强,意会就行了。

按照上面设计就会造成一个结果,如果消息场景中联系人的信息比较简单,是可以了,但如果比较复杂,然后这些属性都必须体现在 Message 中,这样就会造成 Message 实体变的非常冗余,有人看到这,可能会说,为什么要在 Message 实体中去关联 Contact 对象,直接用 SenderId 和 RecipientId 表示不行吗?比如 Message 实体中的部分代码:

public int SenderId { get; set; }
public int RecipientId { get; set; }

这样设计我觉得没什么不可以,更加简化了消息场景中的复杂度,直接一个 Message 实体就可以了,操作或存储起来也很方便,比如我要获取一个消息进行展示,这个操作可能会在仓储中进行完成的,获取 Message 对象后,还要在应用层进行“组装”DTO,因为消息联系人展示肯定要用名称,而不是标识 Id,听起来似乎很合理。但上面曾说过的联系人验证,这个该怎么实现呢?关于这个实现,现在的操作是放在应用层中,因为没有联系人对象的说法,它现在表现出来的只是一个 Id 值,而且发送是根据显示名称发送的,在发送消息操作中,先根据名称获取 Id,然后再根据 Id 获取 IsGagged,然后才是发送操作,这部分的实现现在和领域没有半毛钱关系,那它是什么?应用层控制的是工作流程,但显然这部分工作并不是工作流程,它应该是消息场景中业务的一部分。

还有就是,这部分设计最直白的问题是,首先看上去就有点“不合理”,难道以后应用中对象之间的关联都必须使用 Id?那这样的话,也就没有了“对象关联”的概念存在了。

再次回到标题上来,联系人设计为值对象会怎么?在现有的场景中,我觉得没有什么问题,但针对联系人的验证或管理变的复杂的话,这时候就要考虑下,联系人设计为值对象是否合理,因为现有针对这部分的实现都不是在领域中完成的,为什么不放在领域中?因为现有设计中,联系人没有对象的概念,它只是一个值,一个具体的值。所以在领域模型演化的过程中,针对不断变化的业务场景,根据现有的设计,还需要考虑模型的合理性。

之前列出了实体的两个特征,下面列一下值对象的几个特征,来自《实现领域驱动设计》第六章值对象:

  • 它度量或者描述了领域中的一件东西。
  • 它可以作为不变量。
  • 它将不同的相关的属性组合成一个概念整体(Conceptual Whole)。
  • 当度量和描述改变时,可以用另一个值对象予以替换。
  • 它可以和其它值对象进行相等性比较。
  • 它不会对协作对象造成副作用。

当在应用设计过程中,如果不能准确的区分实体和值对象,可以不妨把应用程序所抽离出来的对象,往实体和值对象的几个特征上面套,看看哪一个是否更加合理,但设计不是绝对的,一种思想就会导致一种设计,思想的稍微不同,最后的设计可能就会千差万别。

其实写到这,就会发现这篇博文的主题并不只是来确定:消息场景中,发件人、收件人是实体?还是值对象?只不过通过这个问题,可以去发现实体和值对象的一些不常遇到的地方,这些东西在以后的设计中可能会有所帮助。当然对于这个问题,你问我是设计为实体?还是值对象?我个人还是比较偏向于实体,嘿嘿。

通过问题探讨去学习领域驱动设计,这种方式会一直持续下去,这篇博文就写到这!