战术领域驱动设计-领域驱动设计系列2
与战略领领域驱动设计相比,战术设计更加实际,更接近实际代码。战略设计涉及抽象整体,而战术设计则涉及类和模块。战术设计的目的是将领域模型细化到可以转换为工作代码的阶段。
设计是一个迭代的过程,因此将战略设计与战术设计相结合是有意义的。你从战略设计开始,然后是战术设计。最大的领领域模型设计启示和突破将可能发生在战术设计,这反过来,可以影响战略设计,所以你重复的过程。
同样,内容基于书籍领域驱动设计:解决复杂的软件心脏由埃里克埃文斯和实施领域驱动设计由沃恩弗农和我强烈建议你阅读他们两个。和上一篇文章一样,我选择用我自己的话尽可能多地解释,在适当的时候注入我自己的想法、想法和经验。
通过这个简短的介绍,是时候拿出战术DDD工具箱,看看里面有什么了。
值对象
战术 DDD 中最重要的概念之一是价值对象。这也是我在非 DDD 项目中使用最多的 DDD 构建块,我希望在阅读本文后,也会使用。
值对象是其价值重要的对象。这意味着两个具有完全相同值的值对象可以被视为相同的值对象,因此可以互换。因此,价值对象应始终不可改变。而不是改变值对象的状态,而是用一个新的实例替换它。对于复杂的值对象,请考虑使用构建器或本质模式。
价值对象不仅是数据容器,它们还可以包含业务逻辑。价值对象也是不变的,这使得业务操作既无线程安全,又无副作用。这也是我非常喜欢值对象的原因之一,也是为什么应该尝试将尽可能多的领域概念建模为价值对象的原因之一。此外,尝试使价值对象尽可能小和连贯 - 这使得它们更容易维护和重用。
制作价值对象的一个好起点是将所有具有商业意义的单一价值属性作为价值对象进行包装。例如:
- 与其使用货币值,不如使用包裹 a . 的价值对象 。如果正在处理多个货币,可能还希望创建一个值对象,并使对象包成一对.
BigDecimal``Money``BigDecimal``Currency``Money``BigDecimal``Currency
- 与其使用字符串来处理电话号码和电子邮件地址,不如使用和估价包裹字符串的对象。
PhoneNumber``EmailAddress
使用这样的值对象有几个优点。首先,它们为价值带来了背景。不需要知道特定字符串是否包含电话号码、电子邮件地址、名字或邮政编码,也不需要知道一个字符串是否具有货币价值、百分比或完全不同的内容。类型本身会立即告诉你你正在处理什么。BigDecimal
其次,可以将可根据特定类型的值执行的所有业务操作添加到值对象本身。例如,对象可以包含用于添加和减去金额或计算百分比的操作,同时确保底层的精度始终正确,并且操作中涉及的所有对象具有相同的货币。Money``BigDecimal``Money
第三,可以确定值对象始终包含有效值。例如,可以验证价值对象构造器中的电子邮件地址输入字符串。EmailAddress
代码示例
Java 中的值对象可能看起来像这样(代码未经测试,为了清晰起见,省略了某些方法实现):Money
|
|
Java 中的值对象和相应的构建器可能看起来像这样(代码未经测试,为了清晰起见,省略了某些方法实现):StreetAddress
|
|
实体
战术 DDD 中的第二个重要概念和对象是实体。实体是其身份重要的对象。为了能够确定实体的身份,每个实体都有一个唯一的ID,该 ID 在创建实体时分配,并在实体的整个整个生命周期内保持不变。
即使所有其他属性不同,同类型和具有相同 ID 的两个实体也被视为同一实体。同样,同类型和相同属性的两个实体,但不同的 ID 被视为不同的实体,就像两个同名的个体不被视为相同一样。
与价值对象相反,实体是可变的。但是,这并不意味着应该为每个属性创建设置方法。尝试将所有状态更改操作建模为与业务操作相对应的动词。setters 只会告诉正在更改的属性,但不会告知更改的原因。例如:假设有一个实体,它具有属性。就业合同可能因为只是临时性的而终止,首先是因为公司一个分支机构向另一个分公司进行内部转移,因为雇员辞职或雇主解雇了雇员。在所有这些情况下,被改变,但出于非常不同的原因。此外,根据合同终止的原因,可能还需要采取其他行动。一种方法已经不仅仅是一种方法。EmploymentContract``endDate``endDate``terminateContract(reason, finalDay)``setEndDate(finalDay)
这就是说, 设置者在 Ddd 中仍然占有一席之地。在上述示例中,可能有一种私人方法,可确保结束日期在设置开始日期之前。此setters 将由其他实体方法使用,但不会暴露在外部世界中。对于主数据和参考数据以及描述实体而不改变其业务状态的属性,使用设置比尝试将操作调整为动词更有意义。一种叫做方法的方法可以说是比。setEndDate(..)``setDescription(..)``describe(..)
我要用另一个例子来说明这一点。让我们说,你有一个实体,代表,嗯,一个人。此人拥有财产。现在,如果这只是一个简单的通讯录,你会让用户根据需要更改此信息,你可以使用setters 和。但是,如果正在建立一个官方的公民政府登记册,则更需要更改名称。你最终可能会遇到类似的东西。同样,上下文就是一切。Person``firstName``lastName``setFirstName(..)``setLastName(..)``changeName(firstName, lastName, reason, effectiveAsOfDate)
关于Getters的说明
作为JavaBean规范的一部分引入Java的获取方法。此规范不存在于 Java 的第一个版本中,这就是为什么可以在标准 Java API 中找到一些不符合它的方法(例如,与标准 Java API 相反)。String.length()``String.getLength()
就我个人而言,我希望看到对Java真实的支持。即使他们可以在幕后使用Getters和setters ,我也希望以与普通字段相同的方式访问属性值: .我们还不能在Java岛这样做, 但我们可以从我们的后缀中找出后缀, 从而非常接近。在我看来,这使得代码更流畅,特别是如果你需要深入到对象层次结构来获取东西: .mycontact.phoneNumber``get``mycontact.address().streetNumber()
然而,摆脱获取者也有缺点,这就是模具支持。所有 Java IDE 和许多图书馆都依赖于 JavaBean 标准,这意味着最终可能会手动编写本可自动生成的代码,并添加通过遵守公约可以避免的注释。
实体还是价值对象?
并不总是很容易知道是将某物建模为价值对象还是作为实体。完全相同的真实世界概念可以在一个上下文中作为一个实体进行建模,在另一个上下文中作为价值对象进行建模。让我们以街道地址为例。
如果正在构建发票系统,街道地址只是在发票上打印的东西。只要发票上的文本正确,使用什么对象实例并不重要。在这种情况下,街道地址是一个值对象。
如果正在为公共事业构建一个系统,需要确切地知道哪些煤气管线或哪条电线进入给定公寓。在这种情况下,街道地址是一个实体,它甚至可能被拆分为较小的实体,如建筑或公寓。
价值对象更容易处理,因为它们是不可变的和小的。因此,应该针对一个实体很少、价值对象多的设计。
代码示例
Java 中的实体可能看起来像这样(代码未经测试,为了清楚起见,省略了某些方法实现):Person
|
|
本示例中需要注意的事项:
- 值对象 - 用于实体 ID。我们本可以使用 UUID、字符串或长线,但一个值对象会立即告诉我们,这是识别特定特定的 ID。
PersonId``Person
- 除了实体 ID 之外,该实体还使用许多其他值对象:(是的,这也是一个值对象,即使它是标准 Java API 的一部分),并且.
PersonName``LocalDate``StreetAddress``EmailAddress``PhoneNumber
- 我们使用的业务方法不是使用setters 更改名称,而是将更改存储在事件日志中,以及更改名称的原因。
- 检索更改名称的历史有一个Getters。
equals
并且仅检查实体 ID。hashCode
领域驱动设计和 CRUD
我们现在已经到了应该解决关于DD和CRUD问题的地步。CRUD 代表创建、检索、更新和删除,也是企业应用程序中常见的 UI 模式:
- 主视图由网格组成,也许通过筛选和排序,在那里可以查找实体(检索)。
- 在主要视图中,有一个按钮用于创建新实体。单击按钮会产生一个空表单,当提交表单时,新实体会显示在网格中(创建)。
- 在主视图中,有一个按钮用于编辑选定的实体。单击按钮会显示包含实体数据的表单。提交表格时,实体会更新新信息(更新)。
- 在主视图中,有一个按钮用于删除选定的实体。单击按钮可从网格中删除实体(删除)。
这种模式当然有其位置,但应该是例外,而不是领域驱动应用程序中的规范。原因是:CRUD 应用程序仅适用于构建、显示和编辑数据。它通常不支持基础业务流程。当用户将某些东西输入系统、更改某些东西或删除某些东西时,该决策背后有一个商业原因。也许这种变化是作为更大业务流程的一部分发生的?在 CRUD 系统中,更改的原因丢失,业务流程由用户负责。
真正的领域驱动用户界面将基于本身是无处不在的语言(从而成为领域模型)的一部分的操作,并且业务流程内置于系统中,而不是在用户的头脑中。这反过来又导致一个更强大的,但可以说是比纯粹的CRUD应用程序更不灵活的系统。我要用一个讽刺的例子来说明这种区别:
A 公司拥有管理员工的领域驱动系统,而 B 公司则采用 CRUD 驱动的方法。两家公司都有员工辞职。发生以下情况:
- A公司:
- 经理查找员工在系统中的记录。
- 经理选择"终止雇佣合同"操作。
- 系统要求终止日期和原因。
- 经理输入所需的信息并单击"终止合同"。
- 系统会自动更新员工记录,撤销员工的用户凭据和电子办公密钥,并向工资单系统发送通知。
- B公司:
- 经理查找员工在系统中的记录。
- 经理在"合同终止"复选框中输入支票并输入终止日期,然后单击"保存"。
- 管理器登录用户管理系统,查找用户帐户,在"禁用"复选框中放入支票并单击"保存"。
- 经理登录办公室密钥管理系统,查找用户的密钥,在"禁用"复选框中放一张支票,然后单击"保存"。
- 经理向工资部门发送电子邮件,通知员工已辞职。
关键要点如下:并非所有应用程序都适合领域驱动设计,领域驱动应用程序不仅具有领域驱动的后端,而且具有领域驱动的用户界面。
聚合
现在,当我们知道什么是实体和价值对象时,我们将研究下一个重要概念:聚合。聚合物是具有某些特征的实体和值对象组:
- 聚合体被创建、检索和存储为一个整体。
- 聚合始终处于一致状态。
- 聚合由称为聚合根的实体拥有,其 ID 用于识别聚合本身。
此外,关于聚合物有两个重要的限制:
- 聚合只能从外部通过其根部引用。聚合之外的对象不得引用聚合内的任何其他实体。
- 聚合根负责在聚合内执行*业务不变,*确保聚合始终处于一致状态。
这意味着,无论何时设计实体,都必须决定要建立什么样的实体:该实体会充当聚合根,还是我称之为生活在聚合内并在聚合根监督下的本地实体?由于无法从聚合体之外引用本地实体,因此其 ID 在聚合中是独一无二的(它们具有本地身份),而聚合根必须具有全球唯一的 ID(它们具有全球身份)。但是,此语义差异的重要性因选择存储聚合方式而异。在关系数据库中,对所有实体使用相同的初级关键生成机制最有意义。另一方面,如果将整个聚合体保存为文档数据库中的单个文档,则为本地实体使用真实的本地 ID 更有意义。
那么,如何知道实体是否是聚合根?首先,两个实体之间存在亲子关系(或主细节)关系,这不会自动将父子变成聚合根,也不会将孩子变成本地实体。在做出这一决定之前,需要更多的信息。以下是我如何做到这一点:
- 如何在应用程序中访问该实体?
- 如果实体会通过 ID 或通过某种搜索进行查找,则可能是聚合根。
- 其他集合需要参考它吗?
- 如果实体将从其他聚合体中引用,则它绝对是聚合根。
- 该实体将如何在应用程序中修改?
- 如果它可以独立修改,它可能是一个聚合根。
- 如果不更改其他实体,则无法修改该实体,则它可能是本地实体。
一旦你知道你正在创建一个聚合根,你如何使它强制执行业务不变,这甚至意味着什么?业务不变是一个规则,无论聚合发生什么,都必须始终保留。一个简单的业务不变可能是,在发票中,总金额必须始终是行项目金额的总和,无论项目是添加、编辑还是删除。不变符应该是无处不在的语言和领域模型的一部分。
从技术上讲,聚合根可以以不同的方式执行业务不变:
- 所有状态更改操作都通过聚合根执行。
- 允许对当地实体进行国家更改操作,但每当它们发生变化时,它们都会通知聚合根。
在某些情况下,例如在发票总数示例中,每次请求时,可以通过让聚合根动态计算总金额来强制执行不变。
我亲自设计我的集合,以便立即和所有的时间执行不变。可以说,可以通过引入在保存聚合之前执行的严格数据验证(Java EE 方式)实现相同的最终结果。归根结底,这是个人品味的问题。
聚合设计指南
在设计集合时,需要遵循某些准则。我选择称它们为准则而不是规则, 因为在某些情况下, 打破它们是有意义的。
原则 1:保持的聚合体小
聚合物始终作为整体检索和存储。要读取和写的数据越少,的系统的表现就越好。出于同样的原因,应该避免无限制的一对多关联(集合),因为这些关联(集合)会随着时间而变大。
拥有小集合也使聚合根更容易执行业务不变,如果喜欢在聚合中使用值对象(不可变),而不是本地实体(可变),则情况就更加如此。
原则 2:按 ID 引用其他聚合体
而不是直接引用另一个聚合,创建一个值对象,包裹聚合根的 ID,并将其用作参考。这使得维护聚合一致性边界变得更加容易,因为甚至不能意外地从另一个聚合体中更改一个聚合状态。它还可防止在检索聚合物时从数据存储中检索深物树。
如果真的需要访问其他聚合的数据,并且没有更好的方法来解决问题,则可能需要打破此准则。你可以依靠持久框架的懒惰加载能力,但根据我的经验,他们往往造成更多的问题,而不是他们解决。需要更多编码但更明确的方法是将存储库(稍后更多有关这些)作为方法参数:
|
|
在任何情况下,都应该避免聚合之间的双向关系。
原则 3:每笔交易更改一个聚合
尝试设计的操作,以便只对单个交易中的一个聚合进行更改。对于跨越多个聚合的操作,请使用领域事件和最终一致性(稍后将对此进行更多介绍)。这可以防止无意的副作用,并使系统在未来更容易分发,如果需要的话。作为奖励,它还使在没有交易支持的情况下更轻松地使用文档数据库。
然而,这伴随着额外的复杂性的成本。需要设置一个基础架构,以便可靠地处理领域事件。特别是在单体应用程序中,可以在同一线程和事务中同步发送领域事件,在我看来,增加的复杂性很少是有动机的。在我看来,一个很好的折衷方案是仍然依靠领域事件来更改其他聚合体,但要在同一交易中进行更改:
在任何情况下,都应该尽量避免从另一个聚合中直接更改聚合状态。
稍后,我们将在报道领域事件时讨论更多有关此内容的事宜。
原则 4:使用乐观锁定
聚合的一个关键特点是执行业务不变,并确保数据始终一致。如果聚合由于相互冲突的数据存储更新而最终损坏,则这一切都是徒劳的。因此,在保存聚合物时,应使用乐观锁定来防止数据丢失。
乐观锁定比悲观锁定更倾向于的原因是,如果持久性框架不支持它开箱即用,并且易于分发和扩展,则很容易实现自己。
坚持第一个准则也将有助于解决这个问题,因为小聚合(因此小交易)也降低了冲突的风险。
聚合、不变、UI 绑定和验证
你们中的一些人现在可能想知道聚合和强制执行业务不变因素如何与用户界面协同工作,更具体地说,如何形成绑定。如果始终执行不变,并且聚合必须始终处于一致状态,那么当用户填写表格时,该怎么办?此外,如果没有setters ,如何将表单字段绑定到聚合器中?
处理这个问题的方法有很多种。最简单的解决方案是将不变的强制执行推迟到保存聚合之前,为所有属性添加setters ,并将实体直接绑定到表单上。我个人不喜欢这种方法,因为我相信它比领域驱动更受数据驱动。实体降级为数据的贫血模式的风险很高,而业务逻辑最终处于服务层(或者更糟的是,在 UI 中)。
相反,我更喜欢另外两种方法。首先是将表格及其内容建模到自己的领域模型概念中。在现实世界中,如果你申请某样东西,你经常需要填写申请表并提交。然后处理应用程序,一旦提供了所有必要的信息,并且符合规则,应用程序就会被授予,并且将获得申请的任何信息。可以在领域模型中模拟此过程。例如,如果有聚合根,也可以有一个聚合根,用于收集创建聚合根所需的所有信息。然后,在创建成员对象时,应用对象可以用作输入。Membership``MembershipApplication``Membership
第二种方法是第一种方法的变体,即本质模式。对于需要编辑的每个实体或值对象,创建包含相同信息的可变本质对象。然后,此本质对象与形式绑定。一旦本质对象包含所有必要的信息,它可以用来创建真正的实体或价值对象。与第一种方法不同的是,本质对象不是领域模型的一部分,它们只是存在的技术结构,使与真实领域对象进行交互更加容易。在实践中,本质模式可能看起来像这样:
|
|
如果愿意,如果更熟悉该模式,可以用构建器替换本质。最终结果是一样的。
代码示例
下面是一个具有本地标识的聚合根 () 和本地实体 (代码未经测试,为了清楚起见,已省略了某些方法实现) 示例:Order``OrderItem
|
|
|
|
领域事件
到目前为止,我们只查看了领域模型中的"内容"。但是,这些只能用于描述模型在任何给定时刻处于的静态状态。在许多商业模式中,还需要能够描述发生的情况并改变模型的状态。为此,可以使用领域事件。
领域事件没有包含在 Evans 关于领域驱动设计的书中。它们后来被添加到工具箱中,并包含在弗农的书中。
领域事件是领域模型中可能发生的可能与系统其他部分感兴趣的任何事件。领域事件可以是粗粒形的(例如创建特定的聚合根或启动过程)或细粒(例如更改特定聚合根的特定属性)。
领域事件通常具有以下特征:
- 它们是不变的(毕竟,你不能改变过去)。
- 当事件发生时,他们有一个时间戳。
- 他们可能有一个独特的 ID,有助于区分一个事件和另一个事件。这取决于事件的类型以及事件的分布方式。
- 它们通过聚合根或领域服务发布(更多关于以后的根或领域服务)。
领域事件发布后,可以由一个或多个领域事件收听者接收该事件,而该事件听众又可能触发其他处理和新领域事件等。发布者不知道事件会发生什么,听者也不应该能够影响发布者(换句话说,发布领域事件应该从发布者的角度来看是无副作用的)。因此,建议领域事件听众不要运行在发布事件的相同事务中。
从设计的角度来看,领域事件的最大优点是它们使系统可扩展。可以添加尽可能多的领域事件听众,因为需要触发新的业务逻辑,而无需更改现有代码。这自然假定正确的事件首先被发布。有些事件,你可能事先知道,但其他将揭示自己进一步的道路上。当然,可以尝试猜测需要哪些类型的事件,并将它们添加到的模型中,但随后也可能会将未在任何地方使用的领域事件堵塞系统。更好的方法是尽可能容易地发布领域事件,然后在意识到需要领域事件时添加缺失的事件。
事件源说明
事件源是一种设计模式,系统状态作为事件的有序日志持续存在。每个甚至会更改系统的状态,并且可以通过从头到尾重播事件日志随时计算当前状态。这种模式在财务分类账或医疗记录等应用中特别有用,因为历史记录与当前状态同样重要(甚至更重要)。
根据我的经验,典型业务系统的大多数部分不需要事件采购,但有些部分确实需要。在我看来, 强迫整个系统使用事件采购作为持久性模型会过于杀戮。但是,我发现领域事件可用于在需要时实现事件采购。实际上,这意味着更改模型状态的每个操作也会发布存储在某些事件日志中的领域事件。从技术上讲,如何做到这一点不在本文的范围范围之外。
分发领域事件
只有当有可靠的方法将其分发给听众时,才能访问领域事件。在巨石内部,可以使用标准观察器模式处理内存中的分布。但是,即使在这种情况下,如果遵循在单独交易内运行事件发布商的良好做法,也可能需要一些更复杂的东西。如果其中一个事件听众失败,并且必须重新发送事件,该怎么办?
弗农提出了两种不同的分发远程和本地活动的方式。我鼓励你阅读他的书的细节,但我要给一个简短的总结,这里的选择。
通过消息队列分发
此解决方案需要外部消息解决方案 (MQ),如 AMQP 或 JMS。解决方案需要支持发布订阅模式和保证交付。领域事件发布后,生产者将其发送到 MQ。领域事件听众订阅 MQ,并将立即收到通知。
此模型的优点是它的速度快,易于实现,并依赖于现有的尝试和真实的消息传递解决方案。缺点是,你必须设置和维护MQ解决方案,如果新的消费者订阅,没有办法接收过去的事件。
通过事件日志分发
此解决方案不需要其他组件,但需要一些编码。领域事件发布后,将附加到事件日志中。领域事件听众定期对此日志进行投票,以检查是否有新事件。他们还跟踪他们已经处理过哪些事件,以避免每次都必须浏览整个事件日志。
此模型的优点是它不需要任何其他组件,它包括一个完整的事件历史记录,可以重播为新的事件听众。缺点是,它需要一些工作来实施,而由听众发布和接收的事件之间的延迟最多是投票间隔。
关于最终一致性的说明
数据一致性始终是分布式系统或多个数据存储参与相同逻辑交易的挑战。高级应用程序服务器支持可用于解决此问题的分布式交易,但它们需要专门的软件,而且配置和维护可能很复杂。如果强一致性是绝对要求,别无选择,只能使用分布式交易,但在许多情况下,从业务角度来看,强一致性实际上并不重要。我们只习惯于从单个应用程序在单个 ACID 交易中与单个数据库交谈的时间的强一致性来思考。
强一致性的替代方案是最终的一致性。这意味着应用程序中的数据最终会变得一致,但有时系统并非所有部分都同步*,这是完全正确的*。设计最终一致性应用程序需要不同的思维方式,但反过来,将产生一个系统,比仅要求强一致性的系统更具弹性和可扩展性。
在领域驱动的系统中,领域事件是实现最终一致性的优秀方式。当另一个模块或系统中发生某些情况时,需要自行更新的任何系统或模块都可以订阅来自该系统的领域事件:
在上示例中,对系统 A 所做的任何更改最终都会通过领域事件传播到系统 B、C 和 D。每个系统将使用自己的本地交易实际更新数据存储。根据事件分配机制和系统的负载,传播时间范围从不到一秒(所有系统都在同一网络中运行,事件立即被推送到订阅者)到几个小时甚至几天(有些系统处于离线,只是偶尔连接到网络以下载上次签入以来发生的所有领域事件)。
为了成功实现最终的一致性,必须有一个可靠的系统来分发领域事件,即使某些订阅者当前在事件首次发布时尚未联机。还需要围绕任何数据随时可能过时这一假设来设计的业务逻辑和用户界面。还需要制定数据可能不一致的时间限制。可能会惊讶地发现,有些数据可能会连续数天不一致,而其他数据必须在几秒钟内甚至更少的时间更新。
代码示例
下面是当订单发货时发布领域事件 () 的聚合根 () 示例。领域收听者 () 将接收事件,并在单独的交易中创建新发票。假定有一种机制在保存聚合根时发布所有已注册事件(代码未经测试,某些方法实现已省略以澄清):Order``OrderShipped``InvoiceCreator
|
|
|
|
|
|
可变量和静态对象
在我们继续之前,我想向介绍可变量和静态物体。这些不是真正的 DDD 术语,而是我在思考领域模型的不同部分时自己使用的东西。在我的世界中,可变量物体是任何可以存在多个实例的对象,可以在应用程序的不同部分之间传递。值对象、实体和领域事件都是可变量对象。
另一方面,静态对象是单位(或汇集资源),它始终位于一个地方,并被应用程序的其他部分调用,但很少传递(除非被注入其他静态对象)。存储库、领域服务和工厂都是静态对象。
这种差异很重要,因为它决定了物体之间可以建立什么样的关系。静态对象可以保留对其他静态对象和可变量对象的引用。
可变量对象可以保留其他可变量对象的引用。但是,可变量对象永远不能保留静态对象的引用。如果可变量对象需要与静态对象相互作用,则静态对象必须作为方法参数传递到与静态对象相互作用的方法参数中。这使得可变量物体更加便携和自足,因为不需要每次去隔离静止物体时,都会向上查找并注入静态物体的任何参考。
其他领域对象
当处理领域驱动代码时,有时会遇到类无法真正适合值对象、实体或领域事件模具的情况。根据我的经验,这种情况通常发生在以下情况下:
- 来自外部系统的任何信息(+ 另一个边界上下文)。从的角度来看,这些信息是不变的,但它具有用于唯一识别信息的全球 ID。
- 用于描述其他实体的类型数据(沃恩·弗农称这些对象为标准类型)。这些对象具有全球 ID,甚至可能在某种程度上是可变的,但对于应用程序本身的所有实际目的,它们是不变的。
- 用于存储数据库中的审核条目或领域事件等框架/基础架构级实体。他们可能拥有或可能没有全球 ID,并且可能或可能不会变异,具体取决于使用案例。
我处理这些情况的方式是使用基础类和接口的层次结构,从所谓的"a"开始。领域对象是任何与领域模型以某种方式相关的可变量对象。如果对象纯粹是一个值对象,或者不是纯粹的实体,我可以宣布它为领域对象,在 JavaDocs 中解释它的作用和原因,并继续。DomainObject
我喜欢在层次结构的顶部使用接口,因为可以以任何喜欢的方式组合它们,甚至实现它们。有些接口是标记接口,没有任何方法仅用于指示实施类在领域模型中的作用。在上图中,类和界面如下:enums
DomainObject
- 所有领域对象的顶级标记界面。DomainEvent
- 所有领域事件的界面。这通常包含一些有关事件的元数据,例如事件的日期和时间,但它也可能是一个标记界面。ValueObject
- 所有值对象的标记界面。此接口的实现必须是不变的和实现的。不幸的是,没有办法从接口级别执行此,即使这将是很好的。equals()``hashCode()
IdentifiableDomainObject
- 在某些上下文中可以单独识别的所有领域对象的界面。我经常将此设计为与 ID 类型作为通用参数的通用界面。StandardType
- 标准类型的标记界面。Entity
- 实体的抽象基础类。我经常包括一个字段的 ID 和实现,并相应地。我也可以添加乐观锁定信息到这个类,这取决于持久性框架。equals()``hashCode()
LocalEntity
- 本地实体的抽象基础类。如果我使用本地实体的本地身份,此类将包含用于管理该身份的代码。否则,它可能只是一个空的标记类。AggregateRoot
- 聚合根的抽象基数类。如果我使用本地实体的本地身份,此类将包含生成新本地 ID 的代码。该类还将包含用于调度领域事件的代码。如果乐观锁定信息未包含在类中,则此处肯定包含此内容。审核信息(创建、上次更新等)也可以根据应用程序的要求添加到此类中。Entity
代码示例
在此代码示例中,我们有两个界限上下文,身份管理和员工管理:
员工管理上下文需要从身份管理上下文中了解用户的一些信息,但不是全部。此有一个 REST 端点,数据被序列化为 JSON。
在身份管理上下文中,a 是这样表示的:User
|
|
在员工管理上下文中,我们只需要用户 ID 和名称。用户将由 ID 单独识别,但名称在 UI 中显示。我们显然不能更改任何用户信息,因此用户信息是不变的。代码看起来像这样:
|
|
存储 库
我们现在已经覆盖了领域模型的所有可变对象,是时候继续静态对象了。第一个静态对象是存储库。储存库是一个恒久的聚合物容器。保存到存储库中的任何聚合都可以在稍后时间从那里检索,即使在系统重新启动后。
至少,存储库应具备以下功能:
- 在某种数据存储中保存完整聚合的能力
- 基于 ID 检索完整聚合的能力
- 基于 ID 删除完整聚合的能力
在大多数情况下,要真正可用,存储库还需要更先进的查询方法。
实际上,存储库是外部数据存储(如关系数据库、NoSQL 数据库、目录服务甚至文件系统)中的领域感知界面。即使实际存储隐藏在存储库后面,其存储语义通常会泄漏,并对存储库的外观施加限制。因此,存储库通常不是以收集为导向,就是以持久性为导向。
面向集合的存储库旨在模仿内存对象集合。一旦集合添加到集合中,对它所做的任何更改都将自动持续,直到集合从存储库中删除。换句话说,面向集合的存储库将具有诸如保存方法,但没有保存方法。add()``remove()
另一方面,以持久性为导向的存储库不会尝试模仿集合。相反,它充当外部持久性解决方案的门面,并包含的方法,如, 和 .对聚合所做的任何更改必须通过调用方法明确保存到存储库。insert()``update()``delete()``update()
在项目开始时正确获取存储库类型非常重要,因为它们在语义上大不相同。通常,以持久性为导向的存储库更容易实现,并且与大多数现有的持久性框架配合使用。以收集为导向的存储库更难实现,除非基本的持久性框架开箱即用地支持它。
代码示例
此示例显示了面向集合和持久性存储库之间的区别。首先,我们查看面向集合的存储库:
|
|
然后是以持久性为导向的存储库:
|
|
关于 CQRS 的说明
存储库始终保存和检索完整的聚合物。这意味着它们可能相当慢,具体取决于它们是如何实现的,以及必须为每个聚合构建的对象图的大小。从 UX 的角度来看,这可能是个问题,尤其是想到两个使用案例。第一个是小型列表,其中需要显示聚合列表,但仅使用一个或两个属性。当你只需要几个属性值时,提出一个完整的对象图是浪费时间和计算资源,并经常导致用户体验迟缓。另一种情况是,需要合并来自多个聚合的数据,以便在列表中显示单个项目。这可能导致更差的性能。
只要数据集和聚合很小,绩效处罚可能是可以接受的,但如果出现性能根本不可以接受的时候,就有一个解决方案:命令查询责任隔离 (CQRS)。
CQRS 是一种模式,可以完全将书写(命令)和读取(查询)操作从彼此分离。进入本文的范围之外,但在 DDD 方面,将应用这样的模式:
- 所有改变系统状态的用户操作都以正常方式通过存储库。
- 所有查询绕过存储库,直接转到基础数据库,只获取所需的数据,而无需获取其他数据。
- 如果需要,甚至可以为用户界面中的每个视图设计单独的查询对象
- 查询对象返回的数据传输对象 (DTO) 必须包含聚合 ID,以便在需要更改存储库时从存储库中检索正确的聚合。
在许多项目中,最终可能会在某些视图中使用 CQRS,并在其他视图中直接存储库查询。
领域服务
我们已经提到,价值对象和实体都可以(而且应该)包含业务逻辑。但是,有些情况下,一个逻辑根本不适合一个特定的价值对象或一个特定实体。把商业逻辑放错地方是个坏主意, 所以我们需要另一个解决方案。输入我们的第二个静态对象:领域服务。
领域服务具有以下特点:
- 他们是无界的
- 它们具有很强的凝聚力(这意味着他们专门做一件事,只有一件事)
- 它们包含的业务逻辑,自然不适合其他地方
- 它们可以与其他领域服务以及存储库进行某种程度的交互
- 他们可以发布领域事件
在其最简单的形式中,领域服务可以是一个实用程序类,其中具有静态方法。更高级的领域服务可以作为具有其他领域服务和存储库的单体实施。
领域服务不应与应用程序服务混淆。我们将在此系列的下一篇文章中更仔细地查看应用程序服务,但简言之,应用程序服务充当了孤立的领域模型与世界其他地区之间的中间人。应用程序服务负责处理交易、确保系统安全、查找适当的聚合物、调用交易方法以及将更改保存回数据库。应用程序服务本身不包含任何业务逻辑。
可以总结应用程序和领域服务之间的区别如下:领域服务只负责做出业务决策,而应用程序服务只负责编排(查找正确的对象并按正确顺序调用正确的方法)。对此,领域服务通常不应调用任何改变数据库状态的存储库方法 - 这是应用程序服务的责任。
代码示例
在第一个示例中,我们将创建一个领域服务,检查是否允许某个货币交易继续进行。实施大大简化,但显然正在根据一些预先定义的业务规则做出业务决策。
在这种情况下,由于业务逻辑非常简单,可能能够直接将其添加到类中。但是,一旦更高级的业务规则发挥作用,将决策转移到自己的类别(特别是如果规则会随着时间而改变或依赖于某些外部配置)是有意义的。另一个迹象表明,此逻辑可能属于领域服务,它涉及多个聚合(两个帐户)。Account
|
|
在第二个示例中,我们将查看具有特殊功能的领域服务:其界面是领域模型的一部分,但其实现性不是。当需要来自外部世界的信息才能在领域模型中做出业务决策时,可能会出现这种情况,但对这些信息的来源不感兴趣。
|
|
当领域模型连接起来时,例如使用依赖性注入框架时,可以注入此接口的正确实现。可以有一个调用本地缓存,另一个调用远程 Web 服务,第三个仅用于测试,等等。
工厂模式
最后一个静态物体,我们将看起来像是工厂。顾名思义,工厂负责创建新的聚合物。但是,这并不意味着需要为每个聚合创建一个新工厂。在大多数情况下,聚合根的构造器将足以设置聚合,使其处于一致状态。在以下情况下,通常需要一个单独的工厂:
- 业务逻辑涉及聚合的创建
- 聚合的结构和内容可能因输入数据而异
- 输入数据非常广泛,因此需要构建器模式(或类似内容)
- 工厂正在将一个边界上下文翻译为另一个上下文
工厂可以是聚合根类或单独的工厂类的静态工厂方法。工厂可以与其他工厂、存储库和领域服务进行交互,但绝不能更改数据库的状态(因此不得保存或删除)。
代码示例
在此示例中,我们将查看一个在两个边界上下文之间转换的工厂。在装运环境中,客户不再被称为客户,而是作为装运收件人。客户 ID 仍存储,以便在需要时以后将这两个概念关联在一起。
|
|
模块
现在几乎是时候进入下一篇文章,但在我们离开战术领领域驱动的设计之前,还有一个概念,我们需要看看,这就是模块。
DDD 中的模块对应于 Java 中的包和 C#中的名称空间。模块可以对应到有边界的上下文,但通常,有边界上下文将具有多个模块。
属于一起的类应分组到同一模块中。但是,不应根据类类型创建模块,而应基于类如何从业务角度融入领域模型。即,不应将所有存储库放入一个模块中,将所有实体放入另一个模块,等等。相反,应该将所有与特定聚合或特定业务流程相关的类放入同一模块中。这使得它更容易导航你的代码*,因为属于一起和一起工作的类也生活在一起*。
模块示例
这是按类型对类进行分组的模块结构的示例。不要这样做:
- foo. bar. 领域. 模型。服务
AuthenticationService
PasswordEncoder
- foo. bar. 领域. 模型。存储库
UserRepository
RoleRepository
- foo. bar. 领域. 模型。实体
User
Role
- foo. bar. 领域. 模型。值对象
UserId
RoleId
UserName
更好的方法是按过程和聚合对类进行分组。改为这样做:
- foo. bar. 领域. 模型。身份验证
AuthenticationService
- foo. bar. 领域. 模型。用户
User
UserRepository
UserId
UserName
PasswordEncoder
- foo. bar. 领域. 模型。角色
Role
RoleRepository
RoleId
为什么战术领域驱动设计很重要?
正如我在本系列第一篇文章的导言中提到的,我首先遇到了领域驱动的设计,同时挽救了一个患有严重数据不一致问题的项目。在没有任何领域模型或无处不在的语言的情况下,我们开始将现有数据模型转换为聚合,将数据访问对象转换为存储库。由于这些引入到软件的限制,我们设法摆脱了不一致的问题,并最终软件可以部署到生产中。
第一次遇到战术领域驱动设计向我证明,即使项目在所有其他方面不是领域驱动,也可以从中受益。我最喜欢的DDD积木,我倾向于用于我参与的所有项目是价值对象。它易于介绍,并立即使代码更容易阅读和理解,因为它带来了上下文到的属性。这种不变性也往往使复杂的事情变得简单。
我还经常尝试将数据模型分组到聚合器和存储库中,即使数据模型本来是完全贫血的(只有Getters和setters 没有任何业务逻辑)。这有助于保持数据的一致性,并避免奇怪的副作用和乐观锁定例外,当同一实体正在通过不同的机制更新。
领域事件有助于脱钩的代码,但这是一把双刃剑。如果过分依赖事件,则的代码将变得更加难以理解和调试,因为目前尚不清楚特定事件将触发哪些其他操作或导致特定操作触发的最初操作。
与其他软件设计模式一样,战术领域驱动设计为通常遇到的一组问题提供了解决方案,尤其是在构建企业软件时。拥有的工具越多,就越容易解决作为软件开发人员职业生涯中不可避免的问题。
- 原文作者:知识铺
- 原文链接:https://geek.zshipu.com/post/DDDzl/%E6%88%98%E6%9C%AF%E9%A2%86%E5%9F%9F%E9%A9%B1%E5%8A%A8%E8%AE%BE%E8%AE%A1-%E9%A2%86%E5%9F%9F%E9%A9%B1%E5%8A%A8%E8%AE%BE%E8%AE%A1%E7%B3%BB%E5%88%972/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 免责声明:本页面内容均来源于站内编辑发布,部分信息来源互联网,并不意味着本站赞同其观点或者证实其内容的真实性,如涉及版权等问题,请立即联系客服进行更改或删除,保证您的合法权益。转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。也可以邮件至 sblig@126.com