领域驱动视频(四)
程序员文章站
2024-02-03 21:17:40
...
4.1 Introduction(简介)
在接下来的两个模块中 讨论如何管理域的复杂性。这个模块将集中在聚合模式上。
4.2 Goals(目标)
我们已经讨论了领域模型,并且需要进行有效的交流,以确保模型是客户问题领域的的有用的代表。然而,大部分不使用领域驱动设计的问题都是相当复杂的,所以现在我们要特别关注一些模式和技术,这可以用于管理这种复杂性。我们将介绍一些新的术语,包括聚合和聚合根。你会了解不变量和 对他们负责。然后,我们将查看我们的应用程序,并了解如何考虑聚合根模式帮助我们修改和简化 模型,最后我们将介绍如何在代码中实现此模式。
4.3 解决数据的复杂性
让我们从考虑数据复杂性开始。如果您曾经研究过一个相对较大或比较成熟的应用程序,您可能已经看到了一些相当复杂的数据模型。我们已经讨论过的减少复杂性的一种方法是限制你的双向关系。另一个是使用聚合和聚合根。如果您的设计没有任何清晰的聚合概念,那么您的实体之间的依赖关系可能会失去控制,从而产生一个如下的模型 。
没事的,你的眼没近视~~
如果你的对象模型像上面这样,来反应数据模型的话,那么试图填充一个对象及其所依赖的对象的话,那么可能会尝试着将整个数据库加载进内存中~~同样的问题还存在于保存更改的时候。所以如果有这么样一个没有限制的模型,那么数据模型就会受到影响。即使你的系统所对应的现实中确实是这样的对应关系,我们需要能够将它们分开,以保持系统的复杂性。我已经深入到很多客户那里,他们的实体数据模型看起来是这样的,他们在整个系统中正在使用这个大的,巨大的,单一的模型,所以我和他们一起工作的其中一件事就是打破这个,使用限界上下文,从上下文开始考虑对较小模型的意义。是的,一个这样设计的系统就是我们所说的大泥球,因为所有的东西都混杂在一起,一旦达到某种程度的复杂性,它就会崩溃。很好,让我们——看看我们如何来使用聚合来帮助我们解决这个问题。
4.4 简介聚合和聚合根
Aggregates由一个或多个实体和值对象组成,这些实体和值对象共同变化。我们需要把它们作为数据变化的一个单元,我们需要在应用更改之前,考虑整个聚合的一致性。在这里的示例中,Address是Customer的一部分,component也实际上是product其中的一部分。
我们可以将一个客户信息的变更和他们的地址作为单个事务处理。每个聚合都必须有一个聚合根,它是所有聚合成员的父(这里没写父类,就是要告诉你成员与聚合根之间没有什么继承关系,至少是表明,成员的查询修改,都是由聚合根公共出去的,我们只能与聚合根交互,然后聚合根再讲逻辑分发给成员类上),有可能有一个聚合,其中只有一个对象,在这种情况下,这个对象仍然是聚合根。 在某些情况下,聚合可能有强制执行跨多个对象的数据一致性的规则。例如,也许我们的product(产品)是由一个component(组件)的集合,但为了处于一个有效的状态,它需要有一组特定的组件。
举个例子,如果product(产品)是乐高迷你图的集合,如果不包括头部、上半身、下半身、两条胳膊、两只手和两条腿,那么没有这些component(组件),那么它就不是一个有效的product(产品)。如果我们允许component(组件)的集合可以独立于它所关联的product(产品)进行修改,我们很容易就会遇到一致性问题。如果我们想在这个例子中修改product(产品)的组成,我们应该以事务的方式进行,以便我们以在开始和结束时候都保证product(产品)是正确可用的。
聚合的数据更改也要遵循ACID,即Atomic原子性, Consistent一致性, Isolated隔离性, Durable持久性。它也是聚合根所维护其不变量的责任,例如在这个例子中component(组件)所需要的数量和类型,而不变量的意思呢,就是使得该聚合的状态一直保持一致性的条件。当你考虑某个指定的对象是否可以作为一个聚合根时,你应该从删除它是否应该级联这一点来考虑。换句话说,如果你需要删除它,连带的你还需要删除它的聚合层次结构中的其他对象。如果是这样,那么问题的对象就应该被看作是一个聚合根。
另一种思考方式来思考一个对象是否是聚合根,就是分离你认为的聚合根及其聚合成员,看看分离之后是否还有意义?在这里的示例中,每个组件表示产品的特定部分。组件的部分定义可能是标准的部件编号码,但它还包括特定于的产品信息,比如它是乐高模型的左边还是右边,虽然在应用程序中的某处,引用一个黄色的手部是有意义的,但是引用特定产品的某个黄色手部其实不会与产品对象的概念分离的。
因此,我们不会为属于product(产品)中的某个单独的component(组件)的检索提供任何持久性选项。我们会整个的检索和保存product(产品)以及其component(组件))。在由DDD书籍中,埃里克·埃文斯(Eric Evans)非常简单地阐述了这一点。他说,“聚合是我们为了数据修改的目的,而作为一个单元对待的一系列相关对象”。,。。。。
4.5 与聚合交互
聚合在我们的应用程序中作为逻辑分组之间的边界。我们通过禁止直接引用聚合中的不是聚合根的对象来加强这些边界。考虑客户的地址。让我们来考虑一个带有Address的Customer,引用Address是完全可以的。Address可能是一个实体或它可能是一个值对象,在这个场景中并不重要。但重要的是,获取Address的唯一方式就是通过Customer。这个Address并不会被聚合之外的其他地方所引用,但是Customer却并不是这样,因为他是聚合根,聚合根时可以被其他的聚合所引用的。
在这个常见的示例中,Order(订单)可以引用一个Customer(客户)。但可能我们目前有一个场景,使得从Customer(客户)去引用一个Order(订单)也是有意义的,但是在这种情况下,是我们假定了订单是应用软件设计中的核心概念。那么为什么Order(订单)无法直接去引用Customer(客户)的Address(地址)呢??因为这违反了客户聚合的完整性。
记住,聚合和聚合根只应用于对象而不是数据,当我们讨论引用时,我们讨论的是对象引用,即使用该对象作为我的一个属性,就像上图中Address引用了Customer一样。这在使用ORM时候是很重要的。
例如,你现在想要保存一个有Customer对象绑定在其customer属性上的Address时候(见上图),在一些场景中,实体框架在数据库插入或更新中都会涉及到客户,甚至可能是删除,这种行为会导致很多混乱。我经常建议开发人员 只是删除掉导航属性(即Customer customer),改而使用主键来维护一个引用即可。这可能会带来不少的工作,但是移除了ORM的魔法,却让我们收获到了对行为的更多控制,以一个常见的方式:强制聚合内的非聚合根对象将直接的对象引用替换为标识符引用的方式来构造聚合模型,这还减少了模型中依赖关系的数量。
4.6 演变Appointments 聚合
由于我们处理的是预约调度,我们最初的设计可能是这样的。一个Appointment(预约)就是把一个Patient(病人)和一个Doctor (医生)带到一个检测Room中进行制定类型的检测,因为在调度的同时,我们通常需要知道该宠物的所有者(即Client)的信息,那么自然拥有一个从Patient到Client的引用是很重要的。如果我们以这种方式对系统进行建模,任何时候我们保存了一个预约,它都将扫描所有这些更改了的对象,并保存它们。
用这种方式建模,我们的预约调度领域的范围要要比它所需要的大得多,对我们而言,:我们不期望 在创建约会时修改其他对象。对的,预约基本上只是一个与特定时间跨度相关联的资源列表。它建模了who, what, when, 和where,,但它不需要改变任何相关的概念。
因此,我们可以通过在Appointment(约会)类中消除大部分的对象关联关系来简化我们的设计。回想一下,对于一个对象来说,作为一个聚合根的好候选者应该是这样的----删除该对象还应该删除聚合内的其他对象。
在上图的这个的设计中,如果一个客户取消了一个Appointment(约会),我们就从系统上删除它,这并不意味着应该删除所有相关的对象。
这是同一个模型的另一个观点。通过简单地包括相关领域概念的id,而不是对象引用,我们能够确保创建和更改Appointment(约会)对我们的系统有最小的影响 持续的任命。这种关系是有效的,因为在现实世界中的预约实际上只是一个便签,其中包括一个地点、时间和其他细节。添加和删除预约不应该影响涉及到的人和地方,这一修改的新的模型设计反映了这一点。
4.7 使用不变量来更好地理解我们的聚合
不过,我们仍然需要更多的学习这个模型。在我们的设计中,我们需要强制对预约进行应用某些不变量,比如他们不应该重复预定。我们目前的想法是,预定需要把这种丰富的行为包括在计划的安排上。聚合的聚合根负责验证聚合可能具有的任何不变量,在这种情况下,Appointment仍然充当着聚合根的角色,即使我们将他可能会协同工作的直接对象关联已经全部替换为ID的关联。
让我们确保我们已经明确了不变量,然后我们将看到不变量如何影响我们的设计。在现实世界中,一个不变的例子就是光速,它是一个常数,正如我们所知,你不能违背宇宙的物理规律。在您的系统中,某些东西必须是正确的,以便模型保持有效。其他不变的例子可能是采购订单(purchase order,即PO)上的项目总数不超过PO的amount,或者预约不能重叠,或者对象的结束日期必须遵循开始日期对象。
有时一个不变量只涉及一个对象,可能只需要一个特定的属性或字段,例如name属性。在这种情况下,我们可以对系统进行建模,这样就不能在没有必要信息的情况下创建对象。我们的值对象是这样的。例如,您不能没有定义开始和结束时间的情况下创建一个dateTimeRange对象。
然而,不变量牵涉到多重物体之间的相互关系。在这个例子中 在这里,采购订单(purchase order,即PO)和行条目(item)很可能被建模为单独的对象,但是,采购订单(purchase order,即PO)将是聚合根,自然它也要肩负其验证不变量的职责。采购订单上的单个item(行条目)可能不了解彼此,也不应该知道彼此,因此在item(行条目)中执行这个不不变量的验证任务是没有意义的。
那么我们的Appointment(预约)呢??一个预约如何知道它是否重叠?当我们专注于这些不变量以及它们在我们的设计中的位置时,我们清楚地发现,这个Appointment并不是我们真正意义上的聚合根。如果你把这种想法应用到我们的预约日程安排中,那么你就会发现,一个预约并不真正了解其他预约,但日程安排却知道这样的事情。让我们来发展我们的领域模型来遵循这个模式,看看它是如何引导我们的。
4.8 建模的突破和重构
这对模型来说是一个很大的改变,当你在做模型的时候,这些灵感就会发生,但这并不是坏事。这并不是说你浪费了很多时间把预约当做一个聚合根。这也是领域建模的美妙之处,通过与不同的人进行交流,比方说领域专家,因为像这样的想法,突然之间,因为像这样的想法冒了出来,像这样的大事情变得清晰起来,所以你不会第一次得到100%正确的模型结构。
随着你对这个领域的了解越来越多,你的理解也会不断发展,你会意识到有一些重大的变化可以极大地改善你的设计。
在领域驱动设计书中,Eric Evans在他的关于重构的章节中谈到了这些突破。这是领域驱动设计的一个重要部分,大约有四分之一的书是专门为它设计的。
4.9 考虑将调度作为我们新的聚合根
尽管我们最初的设计是关于日程调度安排的,但Scheduler本身却并没有成为我们模型的一部分。一旦在我们的模型中包含了日程调度安排作为它自己的显式对象,它就会使设计更加简单。预约不再需要了解其他预约了。确保预约不被重复预订的责任,以及类似的不变量,可以由日程调度安排(Scheduler,即聚合根)来执行,因为他是聚合根。让我们看看这样的设计是否满足了定义聚合根的其他的测试。
当我们将更改保存到一个Scheduler(日程调度表)时,更新任何更改的预约有意义吗?是的,这是有意义的,如果我们要删除整个日程表,删除所有的预约是有意义的吗?是的,我认为这也是有道理的。是的,我想这是某个特定诊所的时间调度安排表。目前我们只在诊所里,但是如果我们想象这么一个场景,即多个诊所每一个都有自己的日程调度安排表,那么删除诊所的时间调度安排表是没有意义的,但是要让它的预约流着,所以我认为这是可行的。太好了。
如果每个诊所都有一个Scheduler(日程调度表),那么坚持使用这个Scheduler是有意义的,这也意味着它需要一个ID,因此,它确实是一个实体,当我们检索一个Scheduler(日程调度表)时,我们很可能会过滤掉我们想要的相关的预约。例如,检索今天的日程安排或本周的日程安排。这意味着我们想要所有的今天或所有这周的预约从一个特定的诊所的日程调度安排过滤出来。对我来说,把Appointment绑定在Scheduler(日程调度表)上,真的很有意义,我很喜欢它。那么,让我们来看看它如何影响我们的设计。
4.10 应用中的Scheduler
在我们的解决方案中,我们重新命名了存储我们的聚合的文件夹--我们现在有一个scheduleAggregate,而不是一个AppointmentAggregate文件夹,该文件夹包含这个聚合中涉及的Scheduler、Appointment和其他类。
看一下Scheduler类,它几乎没有属性。其中重最重要的就是ClinicId(诊所主键)。它还有一个DateRange类型的属性,它定义了这个特定实例所包含的预约区间。我们不需要总是得到所有的约会,但是注意到DateRange不是持久化的,我们这里做了个注解告诉我们它不是持久的。原因是,Scheduler并不是由DateRange来定义,而且Scheduler存在于任何时间,但是当我们在我们的应用程序中与Scheduler进行交互时候,我们确实需要一个DateRange来约束它,比方说今日调度安排,或者是本周的调度安排。因此,每当我们创建Scheduler实例时,我们就想应用DateRange,来告知这是这个日期的Scheduler(日程调度表),所以它也是有趣的,这也是一个我们的领域模型没有精确地映射到我们的数据模型的例子,因为我们在Scheduler(日程调度表)中并不存储DateRange。现在,它是否会被持久化,这并不是我们的模型所关心的,而是由我们的持久化层来负责的,实际上,我们已经应用了一个实体框架映射,指定该属性不会映射到数据库,所以在查询时候不要管担心它。
Scheduler(日程调度表)的主要目的是拥有一个Appointment(预约)集合并执行一些操作,或者强制执行验证某些不变量。你看,我们已经定义了这个Appointment(预约),然后在我们如何构建这个Scheduler(日程调度表),这里我们有几个不同的构造函数。我们有一个无参的构造器,这是必须提供的,毕竟我们使用的是ORM框架。还有一个构造器则需要使用ID和DateRange。
在我们将要构建的存储库中,我们还需要有在特定的Scheduler(日程调度表)中添加一组现有的Appointment(预约)实例的功能。我们在Scheduler类型中有一个AddExistingAppointments 方法,但是Steve和我都同意这一点——它有一点代码味道。
这让我们很困扰,因为为了使它可用于存储库,我们已经把它设计为public了,但是因为它是公开的,它现在是Scheduler的公共接口的一部分,而且我们只需要在存储库中使用它,我们将在几分钟内重构它。对,我们的公共接口实际上只是添加和删除个人预约,并做一些业务逻辑,比如检查两个预约之间是否存在冲突。这个存储库指定了逻辑---我们要把它拉出来。我们向你们展示的是,我们用我们的方法来处理所有的复杂性,我们已经确定了时间表的集合,但不要以为它很容易就能得到我们。
我们在这一点上做了很多重构。例如,这个方法:MarkConflictingAppointments,实际上我们在AddAppointment方法里面有这个逻辑,但后来我们意识到我们需要通过DeleteAppointments访问它,当我们将他提取出来时候,我们能够对它进行越来越多的调整。我们已经提到,重构在领域驱动设计中起着巨大的作用。
对这个领域逻辑进行部分构建,也导致我们为dateTimeRange创建了一个名为Overlaps的新方法,这使得我们可以很容易地看到多个预约在时间范围上重叠的地方。
是的,把Overlaps放进值对象中,可以有如此多的意义,因为这不是一个容易理解的逻辑,所以现在它是dateTimeRange的一部分,我们可以在应用程序的任何时候使用它,任何人都不需要去*上问,或者是弄清楚如何再做这个逻辑,因此, 我们刚刚得到了一个很好的值对象方法, 使开发人员容易(我想这里其实想说的是,部分功能放在value Object中是一个很好的方式,这个在上面的一讲中,Evans说过)。
Yeah, and putting Overlaps into the value object made so much sense because it's not an easy bit of logic to figure out, so it's part of dateTimeRange and now any other time we use that in our application nobody has to, you know, go to stack overflow or whatever to figure out how to do that logic again, so we've just got a nice value object method in there to make like easy for the developers.(即上面这一段的英文,太拗口,翻译不出来了~~)
现在我们有了作为聚合根的Scheduler,如果我们回头看一下Appointment,我们可以看到它变得简单多了。这里有一些我们想要指出的属性,比如我们使用的TrackingState来确定某个特定实体是否被标记为删除,我们稍后再讨论这个,然后我们还设置了这个IsPotentiallyConflicting属性。这是另一个不需要持久化的属性。我们已经告诉上下文不保存到数据库,但是在用户界面上我们却需要使用这个属性,所以当系统的用户添加预约可能冲突,他们将看到一个可视化的队列,让他们知道,也许他们应该重新安排时间表。
我们来看看UI中会是什么样子。我将为史蒂夫的可爱狗Darwin创建一个预约,所以我先选择Darwin,然后我将创建一个由于同一房间已经被预约导致的的冲突预约。请注意,我在两个相互冲突的预约中立即得到了这些红色的框,所以在我的模型中,IsPotentiallyConflicting已经被设置为对这两个约定都是正确的,而UI只是响应这个。我可以删除其中的一个预约你可以看到在1号房间的预约红线已经消失了,这是因为IsPotentiallyConflicting 属性又回到了false,UI知道不再有红线了。
()###4.11 复习Aggregate
让我们回顾一下我们刚刚学过的关于设计聚合的知识。首先,聚合的存在是为了减少复杂性。你可能并不总是需要一个聚合。不要仅仅为了使用聚合而增加复杂性。另一种点是,聚合中的实体只能引用另一个聚合的根实体,但您可以始终使用外键值作为对另一个聚合内实体的引用。这是完全可行的,它将避免当你去保存聚合的时候需要串联性的将其所引用的其他聚合也保存。如果你发现你需使用了大量的外键作为关联,引用其他聚合的子元素的话,那么你可能需要重新考虑你在你的领域模型中的总体设计。
另外的一个指点是,不要害怕聚合只有一个成员的情况。最后,不要忘记了级联删除规则。记住,测试一个元素是否具有作为聚合根的意义的方式是:考虑删除该对象是否还应该删除该聚合层次结构中的所有其他子对象。如果没有,那么你可能选择了错误的总体结构。
4.12 术语表
再一次,我们在这个模块中讨论了很多。让我们回顾一下你在这段视频中学到的一些术语。
我们首先讨论的是聚合。聚合是一组在事务中协同工作的相关对象。当我们通过这个模块进化的时候,我们最终得到的是一个Schudler和一系列的Appointment。而这个Schudler成为了聚合的根。
聚合根变成了你与聚合做任何事的入口点,而且聚合根还要负责确保所有适用于对象图的规则都得到满足。每个描述系统必须为有效的状态的规则被称为不变量,而聚合根则会强制应用不变量以保证系统的一致性。
4.13 资料
这里又有一个参考文献列表,你可以进一步了解我们在这个模块中讨论过的东西。当然,Eric和Vaughn的书,Eric的网站:DDDCommunity网站,一直是一个很好的资源,这里还有另外两门课,我们在这个模块中提到过。