概述

本文将介绍领域驱动设计(DDD)战术模式中另一个常见且非常重要的概念 - 实体。相对战术模式中其他的一些概念(例如 值对象、领域服务等)来说,实体应该比较容易让人理解和运用。但是我们如何去发现所在领域中的实体呢?如何保证建立的实体是富含行为的?实体运用时又有那些注意的细节呢?本文将从不同的角度来带大家重新认识一下“实体”这个概念,并且给出相应的代码片段(本教程的代码片段都使用的是C#,后期的实战项目也是基于 DotNet Core 平台)。

何为实体

按照国际惯例呢,我们先吹牛。直接来看看原著《领域驱动设计:软件核心复杂性应对之道》 中对实体的解释:

  • 实体(Entity,又称为Reference Object) 很多对象不是通过他们的属性定义的,而是通过一连串的连续事件和标识定义的。
  • 主要由标识定义的对象被称为ENTITY。

上面的两句话多读了几遍,好像这个定义还是能够理解嘛。不像上一篇文章 如何运用DDD - 值对象 中的概念那么深奥。说白了,上面就是说明了一个问题,只要你所发现的事物/对象有一个唯一的标识,那么它可能就是实体了。而唯一的标识就是我们代码中快写烂了的那个ID。

似曾相识

来想一下,我们在以传统的设计思路和开发过程中,我们会在什么情况下为一个对象赋予一个ID呢?给它赋予这个ID的作用呢?一般来说我们的目的无非就是 1、为了区分本对象,如果是在数据库中,那就是为了区分本条数据和另外一条数据,而这个ID也往往作为主键而存在 2、加个索引吧,来提升关联查找速度。所以我们如果将数据库中的表映射到我们的代码中以类的形式呈现的时候,它可能就是这个样子:

//旅行的行程
public class Itinerary
{
public int ID { get; set; } //参加本次旅行的人员
public List<Person> Participants { get; set; } //旅行的地点
public List<string> Places { get; set; } //关于该行程的备注笔记信息
public string Note { get; set; } //旅行开始时间
public DateTime StartTime { get; set; } //旅行开始时间
public DateTime? EndTime { get; set; } //旅行的状态(进行中 or 已完成)
public int Status { get; set; }
}

上面的代码对我们来说应该丝毫都不陌生,我们建立了一个旅行行程的类,至于为什么我们会选取旅行行程,而不是各个博客都出现的以订单啊电商平台作为案例。那是因为在后期我们会一起动手来实现一个旅行记账的微信小程序,并且借助于我们慢慢所学习到的DDD理论作为基础,开发属于我们自己的领域驱动框架,当然项目也是基于 DotNet Core(版本应该是3.x)。

好了,还是回到我们这个例子,来思考一下ID出现的目的。你可能会说:“这还不简单吗?老夫纵横代码界多年,你现在还来问我这个问题!ID肯定是用来区分的呀,行程千千万万,我要找出这一条行程肯定需要这个ID了呀。” 是的,这是一个毫无争议的问题。我们需要一个唯一的身份标识来区别对象之间的差异。DDD中实体的这一点与我们平时所接触的类的ID有异曲同工之妙,所以本文开头也说了实体可能是相对其他战术概念最为让人理解的。

你确定它真的需要ID吗

还记得我们在上一篇文章 如何运用DDD - 值对象 中所提到过的一个问题吗? “当前上下文的值对象可能是另一个上下文的实体”。所以说,当前你所判定的实体一定是基于领域当前环境(上下文)的。脱离了该环境之后,一切都将存在变数。同样的事物(对象),在当前环境需要一个唯一标识来识别它,而在另一个环境中可能这个唯一标识对它来说是没有意义的,则实体就有可能成为了值对象。请考虑下面的这个例子:

在一个银行业应用程序中,一位顾客可能会在她的银行账户中放入100美元。当她未来某一天提取她这100美元时,相较于她存进银行的钱,她可能会收到不同的钞票或硬币。不过,这一差异是无关紧要的,因为资金的身份不重要;顾客只关心资金的价值。所以在这个领域中,资金无疑是一个值对象。但在另一个领域中,比如涉及钞票印刷制作或钞票可追溯性的行业,个体钞票或硬币的身份实际上可能就是一个重要的领域概念了。所以每一张钞票都会是一个具有唯一标识符的实体

运用实体

结合值对象

千万不要忘记了我们上一章所学习到了的值对象:在实体的内部,除了它自己的唯一标识ID之外,也许还有许许多多表明它属性的东西,而这些东西往往可以通过使用值对象来标识。

接下来让我们来改写一下上面的Itinerary类:

public class Itinerary
{
public int ID { get; set; } public List<Person> Participants { get; set; } public List<Address> Places { get; set; } public ItineraryNote Note { get; set; } public ItineraryTime TripTime { get; set; } public ItineraryStatus Status { get; set; }
} public class ItineraryNote
{
public string Content { get; set; }
public DateTime NoteTime { get; set; } public ItineraryNote(string content)
{
Content = content;
NoteTime = DateTime.Now;
}
}

为实体赋予它的行为

当对象建立好了之后,为了实现我们的业务逻辑处理,我们需要对实例化的对象进行操作。现在我们为该系统提出第一个需求:用户可以修改行程中的备注信息。

回到我们的第一版代码中,如果我们需要处理这个操作,我们会怎么做呢?

itineraryInstance.Note = "this is my new note info";

是不是会像上面这样,将需要添加的值赋予实例化的对象呢。 这种操作,对我们现在正在进行的编程习惯来说,是再正常不过了。

那么我们来思考,如果我们的项目有多处需要对“备注信息”处理呢。则对该属性的变更将被散落在代码各处。而当我们对该需求进行了一个增强验证时,比如此时我们需要增加:用户修改行程中的备注信息时,只允许用户录入200个字以内的文本。 OMG,此时我们需要去查找所有散落的片段,并且为他加上验证。

从另外个角度来看,第一个版本我们所建立的类,我们无法通过仅仅查看它本身就能读懂有关旅行行程有关的业务,我们仅仅知道它具有起始时间,备注信息等,而对他们应该如何相互作用无从所知。

所以这种仅仅具有类的属性,或者说以POCO呈现的类型,我们称之为“贫血模型”

接下来,我们回到第二版代码中,我们为它赋予属于它的行为。从需求中我们得知了,行程的备注信息是可以修改的,而备注信息是属于行程的,因此修改备注信息改行为理应属于行程本身。我们稍微改动代码:

public class Itinerary
{
public int ID { get; set; } public List<Person> Participants { get; set; } public List<Address> Places { get; set; } public ItineraryNote Note { get; set; } public ItineraryTime TripTime { get; set; } public ItineraryStatus Status { get; set; } //ctor public void ChangeNote(string content)
{
if(content.Length > 200 )
throw new NoteIsOverlengthException();
Note = new ItineraryNote(content);
}
}

此时我们为Itinerary赋予了一个ChangeNote的行为,当外界需要更改备注时,则只需通过调用改方法既可以实现,而且当展开其他开发人员阅读此类时,也会清楚的明白,业务上允许用户更改200字以内的备注。

但是,我们依然有一个地方美中不足,我想你可能也发现了:属性还是对外暴露的! 对,也就是说,我们除了通过类公开的行为修改类自身的属性外,我们还可以在外界随意更改。这显然不符合我们设计的初衷。因此我们可以将所有属性的set私有化。所以,一定要注意,我们在考虑实体的时候,一定要知道“实体是高度内聚和自治的”(敲重点!!!!!)。

当然,有的开发者还会尝试另外的写法,让实体完全自治,将上面的代码中的属性,全部转变为私有的字段,外界只能通过公开的行为来对实体进行处理。

public class Itinerary
{
public int ID { get; set; } private List<Person> participants; private List<Address> places; private ItineraryNote note; private ItineraryTime tripTime; private ItineraryStatus status; //ctor public void ChangeNote(string content)
{
if(content.Length > 200 )
throw new NoteIsOverlengthException();
note = new ItineraryNote(content);
}
}

但是当外界需要获取该实体的值,或者需要ORM映射的时候可能就不是很友好了,不过你可以使用类似于像 备忘录模式 的快照方法来处理。后期我们也会采用这种模式来实现部分案例。

通过将实体赋予它应用的行为所建立出来的实体我们称为“充血模型”。那么贫血模型好还是充血模型好呢? 很多同学肯定会说,这还用问吗,肯定是充血模型啦。 其实这个答案并没有一个真正的答案,实体自身的行为是通过我们对领域的慢慢分析(可能是通过与领域专家沟通)得来的,如果因为为了使用充血模型而盲目的将一些不属于实体的行为赋予给它,只会让实体变的更加混乱,从而得不偿失。所以,此时的贫血模型并不意味着一直是贫血模型,后期随着领域的深入它可能会不断丰富属于自身的行为。

尝试转移一部分行为给值对象

保持实体专注于身份这一职责很重要,因为这样会避免它们变得臃肿————这是它们将许多相关行为拉到一起时容易掉入的陷阱。实现这一专注需要将相关行为委托给值对象和领域服务(领域服务也将在后期的文章中进行介绍)。

来考虑一下最近一版的代码,我们已经将行为划分给了Itinerary了,但是仔细看一看,我们在后期增加需求时增加了一条验证的规则,那么这个规则我们可以转移给值对象吗? 答案是,可以的。而且转移是有必要的,因为对备注的效验这一行为往往应该属于它自身。就好比机器启动时的自我效验,这一行为是属于操作者还是机器自己呢?

所以我们来将部分行为转移给值对象,优化后的代码可能是这样的:

public class Itinerary
{
public int ID { get; set; } public List<Person> Participants { get; set; } public List<Address> Places { get; set; } public ItineraryNote Note { get; set; } public ItineraryTime TripTime { get; set; } public ItineraryStatus Status { get; set; } //ctor public void ChangeNote(string content)
{
Note = new ItineraryNote(content);
}
} public class ItineraryNote
{
public string Content { get; set; }
public DateTime NoteTime { get; set; } public ItineraryNote(string content)
{
if(content.Length > 200 )
throw new NoteIsOverlengthException();
Content = content;
NoteTime = DateTime.Now;
}
}

愿景是美好的 现实是残酷的

到这里,我们仿佛真的一帆风顺:建立了属于自己的实体,并且融合了该有的值对象,实体的行为也被高度内聚在了其中。那是不是我们直接就可以将DDD落地了呢? 不好意思,就如同这个小标题一样,现实真的是非常残酷的。如果单单从代码阅读和业务处理上来说,我们可能确实已经成功了,但是!!!我们需要保存我们的数据,也就是持久化。因为实体中包含了大量的值对象,所有值对象持久化所面临的问题,它都会遇到,甚至是让难度翻倍!有关值对象持久化的难点可以参考上一篇文章 如何运用DDD - 值对象

回看我们最后一版代码,我们有两个集合的属性(Participants、Places)。单一的值对象的持久化已经让我们头痛了,现在我们不得不面对持久化值对象集合的问题。假如你通过使用EF Core这类的ORM框架来进行持久化操作,你会发现我们不得不为List中的值对象加上一个ID,此时拥有了唯一标示的值对象显然已经成为了实体,这是非常可怕的一件事。我们辛辛苦苦建立的领域模型在最后一步落地时居然成为改变了,这往往也是DDD落地困难的一个重要原因,被ORM框架或者关系型数据库所限制,导致领域模型不断被打乱,重构领域模型变得越来越四不像,最终又写回了传统的三层架构或者面向数据库建模。

但是至少在现在,请相信自己的所见,认真考虑和发现你项目领域所拥有的值对象和实体,不要因为知道持久化的问题而放弃和妥协,这也是我们开发者应有的勇气。在后面的文章中,我们会关于值对象和实体的一些问题提出解决办法,当然包括持久化的问题。

总结

本文我们介绍了实体的概念以及怎么去运用实体到实际代码中,请牢记前人为我们提供的有关实体的经验:比如“实体一定是基于领域当前环境(上下文)的”“实体是高度内聚和自治的”“应该专注于实体的行为而非数据”等等。后面的文章会为大家带来实体和值对象的一些注意事项以及领域服务的内容。

如何运用DDD - 实体的更多相关文章

  1. Lind.DDD~实体属性变更追踪器的实现

    回到目录 看着这个标题很复杂,大叔把它拆开说一下,实体属性-变更-追踪器,把它拆成三部分大家看起来就容易懂一些了,实体属性:领域实体里有自己的属性,属性有getter,setter块,用来返回和设置属 ...

  2. Lind.DDD敏捷领域驱动框架~介绍

    回到占占推荐博客索引 最近觉得自己的框架过于复杂,在实现开发使用中有些不爽,自己的朋友们也经常和我说,框架太麻烦了,要引用的类库太多:之前架构之所以这样设计,完全出于对职责分离和代码附复用的考虑,主要 ...

  3. 应用程序框架实战十六:DDD分层架构之值对象(介绍篇)

    前面介绍了DDD分层架构的实体,并完成了实体层超类型的开发,同时提供了验证方面的支持.本篇将介绍另一个重要的构造块——值对象,它是聚合中的主要成分. 如果说你已经在使用DDD分层架构,但你却从来没有使 ...

  4. DDD分层架构之值对象(介绍篇)

    DDD分层架构之值对象(介绍篇) 前面介绍了DDD分层架构的实体,并完成了实体层超类型的开发,同时提供了验证方面的支持.本篇将介绍另一个重要的构造块——值对象,它是聚合中的主要成分. 如果说你已经在使 ...

  5. 如何运用DDD - 领域服务

    目录 如何运用DDD - 领域服务 概述 什么是领域服务 从实际场景下手 更贴近现实 领域服务VS应用服务 扩展上面的需求 最常见的认证授权是领域服务吗 使用领域服务 不要过多的使用领域服务 不要将过 ...

  6. .net架构设计读书笔记--第三章 第9节 域模型实现(ImplementingDomain Model)

        我们长时间争论什么方案是实现域业务领域层架构的最佳方法.最后,我们用一个在线商店案例来说明,其中忽略了许多之前遇到的一些场景.在线商店对很多人来说更容易理解. 一.在线商店项目简介 1. 用例 ...

  7. 如何使用ABP进行软件开发(2) 领域驱动设计和三层架构的对比

    简述 上一篇简述了ABP框架中的一些基础理论,包括ABP前后端项目的分层结构,以及后端项目中涉及到的知识点,例如DTO,应用服务层,整洁架构,领域对象(如实体,聚合,值对象)等. 笔者也曾经提到,AB ...

  8. DDD 领域驱动设计-两个实体的碰撞火花

    上一篇:<DDD 领域驱动设计-领域模型中的用户设计?> 开源地址:https://github.com/yuezhongxin/CNBlogs.Apply.Sample(代码已更新) 在 ...

  9. 应用程序框架实战十五:DDD分层架构之领域实体(验证篇)

    在应用程序框架实战十四:DDD分层架构之领域实体(基础篇)一文中,我介绍了领域实体的基础,包括标识.相等性比较.输出实体状态等.本文将介绍领域实体的一个核心内容——验证,它是应用程序健壮性的基石.为了 ...

随机推荐

  1. float使用0xFF

    1. float f = 0xFFFFFFFF; 这一句完全是错误的用法,它不会使f变量内存变为4个0xFF,因为0xFFFFFFFF根本就不是有效的float数值,编译器无从处理,如果用printf ...

  2. 在jupyter中调用R

    目录 安装R 关联jupyter notebook 安装R 系统:Ubuntu:16.04 步骤1.添加镜像源 $ sudo echo "deb http://cran.rstudio.co ...

  3. Oauth 2.0学习

    OAuth是一个关于授权(authorization)的开放网络标准,在全世界得到广泛应用,目前的版本是2.0版. 本文对OAuth 2.0的设计思路和运行流程,做一个简明通俗的解释,主要参考材料为R ...

  4. Oracle运行脚本:exp,hist 和 err

    上一篇我们讲到:首次使用rman备份数据库时,为了安全起见,我们应将整个数据库exp出来. 显而易见,每次都手敲exp代码是不可取的. ----费时费力还不规范! 为此,我们可以写一个exp脚本,之后 ...

  5. jvm原理和代码运行的过程

    一次编译,到处运行 java一直宣传的口号是:一次编译,到处运行.那么它如何实现的呢?我们看下图: graph TD java原程序--javac编译-->java字节码 java字节码--&g ...

  6. Linux Cannot allocate memory问题

    查找了一下相关文档,发现这个错误的含义其实就是像它自己说的,没法分配内存了. The problem is inherent with the way Java allocates memory wh ...

  7. 【XSY2495】余数

    Input Output Input 3 4 Output 4 HINT 原式 =n*m-n除以i向下取整 用数论分块做就可以了 #include<bits/stdc++.h> #defi ...

  8. .NET进阶篇06-async异步、thread多线程1

    知识需要不断积累.总结和沉淀,思考和写作是成长的催化剂 异步多线程挺大一块内容,既想拆开慢慢学,又想一股脑全倒出.纠结再三,还是拆开吃透,也不至于篇幅过长,劝退许多人 本篇先做一个概述,列明一些基本概 ...

  9. JVM性能调优详解

    前面我们学习了整个JVM系列,最终目标的不仅仅是了解JVM的基础知识,也是为了进行JVM性能调优做准备.这篇文章带领大家学习JVM性能调优的知识. 性能调优 性能调优包含多个层次,比如:架构调优.代码 ...

  10. python机器学习——随机梯度下降

    上一篇我们实现了使用梯度下降法的自适应线性神经元,这个方法会使用所有的训练样本来对权重向量进行更新,也可以称之为批量梯度下降(batch gradient descent).假设现在我们数据集中拥有大 ...