65.9K
CodeProject 正在变化。 阅读更多。
Home

领域驱动设计:战术设计模式。第 2 部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2017 年 1 月 6 日

CPOL

20分钟阅读

viewsIcon

36814

在 DDD 方法中,了解战术设计模式与深入理解战略设计模式同等重要。在本文中,我将解释关于 DDD 战术建模过程你需要知道的一切。

上一篇文章中,我曾提到 DDD 方法中的所谓战略建模和战略设计模式。我演示了如何通过定义概念边界来解决领域内的特定任务——限界上下文。

要实现特定的限界上下文,你需要依赖底层战术设计模式,这些模式用于解决特定的工程与开发问题。这些模式包括:实体 (Entity)、值对象 (Value Object)、领域服务 (Domain Service)、领域事件 (Domain Entity)、模块 (Module)、聚合 (Aggregate)、工厂 (Factory)、仓储 (Repository)。在本文中,我将深入探讨这些模式。

当战术设计模式使用得当,你就可以在限界上下文中使用通用语言。包含所有模式的软件设计模型需要展示特定上下文中的通用语言有多么丰富。如果某个 DDD 术语无法用通用语言来描述或表达,就不应该在模型中使用。如果建模过程是在不使用通用语言的情况下(使用战术设计模式)进行的,那么这意味着使用的是所谓的轻量级 DDD 方法。

因此,让我们按照 Vaughn Vernon 的《实现领域驱动设计》一书中的描述顺序,来了解几种战术设计模式。

实体

如果领域中的某个术语是独一无二的,并且与其他系统对象不同,我们就需要使用实体来建模。这些值对象在给定的生命周期内可能形式不同,但你仍应始终通过查询来识别和查找它们。为此,你可以依赖唯一标识符,这些标识符需要在开发过程的早期(在开发实体时)创建。

有几种策略可以用来创建唯一标识符

#1 用户输入唯一值

如果需要你的应用程序标识符易于阅读,请使用此方法。但是有一点:你需要确保所有标识符都在应用程序内部进行了唯一性和准确性检查。此外,对标识符进行任何更改都会很昂贵,并且通常不允许用户操作或更改它们。因此,你应该依赖能够保证每个标识符质量和一致性的方法。

#2 应用程序生成标识符

有一些快速可靠的生成器可用于自动生成唯一标识符。例如,在 Java 中,我们有 java.util.UUID 类,它允许你通过四种不同的方法生成通用唯一标识符:基于时间的、DCE 安全的、基于名称的、随机生成的 UUID。

这里有一个 UUID 示例

046b6c7f-0b8a-43b9-b35d-6489e6daee91

(基本上,一个 36 字节的字符串)

长标识符(如下所示)由于内存过载而难以存储。因此,使用这个 36 字节 UUID 字符串的一个或两个片段是有意义的。(当然,你需要确保这些片段得到良好的保护。)如果缩短的标识符被用作聚合内的本地实体标识符,它会得到更好的保护。为了说明这一点,让我们看下面的标识符

APM-P-08-14-2016-046B6C7F
  • APM 是控制设计过程的特定上下文
  • P 是项目本身
  • 08-14-2016 是创建日期
  • 046B6C7F 是 UUID 的第一个片段。

当开发人员找到类似这样的标识符时,他们可以轻松地弄清楚它们起源于何地以及何时。

#3 永久存储机制生成标识符

要创建此类标识符,你需要请求数据库。通过此方法,你可以始终确保你的标识符得到保护且唯一。而且它也会相当短,你可以将其用作连接标识符的一部分。

但是,此策略有一个根本性的缺点——性能。每次需要获取特定值时都请求数据库,这会花费大量时间。如果标识符是由应用程序生成的,处理速度会快得多。

#4 其他限界上下文的标识符归属

有时你需要集成不同的限界上下文来获取标识符。例如,你可以使用上下文图来实现这一点,正如我在上一篇文章中演示的那样。

要在另一个限界上下文中查找特定标识符,你可以指定几个属性(电子邮件、账号等),这些属性可以识别外部实体的唯一标识符,然后将其用作本地标识符。你也可以将特定的附加值从外部实体复制到本地实体。

然而,当我们通常处理实体时,我们需要同时考虑领域标识符和代理标识符。第一个标识符受领域内特定规则的约束,而第二个标识符则专门用于 ORM(如 Hibernate)。要创建代理键,我们需要创建诸如 long 和 int 之类的实体属性。同时,唯一标识符在数据库中生成,然后可以用作主键。之后,通过 ORM 工具将此键的可视化实现到属性中。这样的代理标识符通常是隐藏的,因为它不包含在领域本身中。

注意!为了在对象的生命周期内保护标识符的唯一性,标识符本身和对象必须能抵御任何类型的修改。通常,保护是通过隐藏标识符的 setter 或开发一个特定的检查机制来扫描 setter 并查找任何修改和更改来实现的。上一篇文章中的 PFM 系统很好地演示了这一原理。

首先,我们应该分离给定领域中的特定实体。在本例中,我们有一个 BankingAccount 实体,可以通过 accountNumber 来识别。虽然此号码在一个给定银行内是唯一的,但可以在许多其他银行中重复。(当然,如果你居住在欧盟,你可以始终依赖 IBAN。)换句话说,你需要不仅使用 accountNumber,还需要使用特定的 UUID 片段。这样,我们的标识符将由以下部分组成

PFM-A-424214343245-046b6c7f
  • PFM — 上下文名称
  • A — 账户
  • 424214343245 — 账号
  • 046b6c7f — UUID 片段

你也可以将任何标识符设置为值对象。因此,让我们详细研究这个关键的 DDD 模式。

值对象

如果对象的唯一特征不是很重要;如果这些对象通过其独特属性来指定,那么你应该将它们视为所谓的值对象。要弄清楚某个概念是否应被视为值,你需要检查它是否具有以下特征

  1. 它衡量、评估和描述领域对象
  2. 它可以被视为一个持久的概念
  3. 它建模一个概念上不可分割的概念,将多个属性捆绑在一起
  4. 如果评估或描述方法发生改变,它可以被替换
  5. 它可以通过值的相等关系与其他对象进行比较
  6. 它将无副作用的函数转移到捆绑/边界对象。

你应该比看起来更频繁地遇到这类对象。它们易于开发、测试和维护。正因如此,你应该尽可能使用值对象而不是实体。

很少情况下,值对象是专门创建以便稍后修改的。为了限制对特定字段的访问,setter 设置为私有,而对象的构造函数则为公共。构造函数接收所有充当值属性的对象。基本上,值对象的生成需要是一个受保护的、原子操作。

当涉及值对象时,正确实现相等性验证操作很重要。为了使两个值对象相等,属性的所有类型和值都需要相等。

此外,重要的是值对象的所有方法都必须是无副作用的函数。由于它们不应违反持久性属性,因此它们可以返回对象但不能修改其状态。让我们看下面的示例

public class Money implements Serializable {
private BigDecimal amount;
private String currency;
public Money (BigDecimal anAmount, String aCurrency) {
    this.setAmount(anAmount);
    this.setCurrency(aCurrency);
}
…
}

setter 是隐藏/私有的,而值对象的生成被设置为一个受保护的、原子操作。在此示例中,{50 000 USD} 是一个值。单独来看,这些属性要么描述其他事物,要么根本没有意义。但是 50,000 和 USD 与这种特定的关系有关。也就是说,这些属性创建了一个概念上整数值,该值描述了一个特定的金额。这种概念上的统一性起着巨大的作用,因为存在各种类型的值和值本身,它们存在于限界上下文中的通用语言的指导下。

到目前为止,我们已经研究了实体和值对象。我们继续。

领域服务

当你使用通用语言时,它的名词总是显示为对象,而动词则影响这些对象。然而,经常有一些动词或任何其他操作无法归属到任何特定的实体或值对象。如果领域中存在类似的 S 操作,它将被声明为领域服务,这与充当客户端的应用程序服务截然不同。服务具有以下特征

  • 服务执行的操作与领域相关,但不属于任何现有实体。
  • 该操作是使用领域模型的特定对象执行的。
  • 该操作没有状态。

但你不应该过于频繁地使用服务。如果你频繁依赖它们,可能会导致贫血的应用程序领域模型。业务逻辑应在实体和值之间划分。只有当你无法在通用语言内完成时,才能使用领域服务。重要的是它需要源自通用语言。

为了说明这一点,让我们来看一个将资金从一个账户转移到另一个账户的操作。基本上,你不知道可以将此转移操作存储在哪个对象中。因此,你可以使用服务。就像这样

领域事件

研究一个给定的领域,你会很快发现有些事实对主题专家来说尤其重要。例如,他们可以使用这样的短语来介绍这些事实

  • 当……
  • 如果出现这种情况……
  • 如果……请联系我
  • 在……的情况下

也就是说,如果某件事发生在另一件事之后,你需要设计一个特定的领域事件。

在此过程中,你应该特别注意以下事实:领域事件基本上是过去发生的任何动作。因此,它的名称应该反映该动作发生在过去,但现在通过限界上下文中的通用语言进行归属。

与值对象类似,领域事件被设计为防止任何修改;它们的功能是无副作用的函数。领域事件被设计为一个对象,其接口指定其功能,而其属性则是原因。

让我们看下面的 FundsDeposited 示例

occuredOn 是领域事件的时间标记。然后,你需要指定存储有关正在发生的事情(过程)的信息的重要属性。最重要的属性之一是实体和聚合的标识符,其中生成了 accountId 领域事件。此外,订阅者可能会找到传输聚合从一个状态到另一个状态的特定参数。

在这种情况下,我们设计了一个领域事件,该事件在有人给账户充值时激活。因此,你可以发送消息(当钱在账户中时),发送电子邮件,或执行任何其他类型的操作。

要发布和处理领域事件,你可以使用观察者模式或发布者-订阅者模式。如果操作在一个给定的限界上下文内处理,你就不能使用多个基础设施组件和不应该存在于该领域边界内的元素。你只需将观察者模式添加到设计中。

也就是说,你可以创建一个 DomainEventPublisher 对象,该对象将存储、注册所有订阅者并发布领域事件。向订阅者的发布是在特定的周期和特定的事务内同步管理的。基本上,每个订阅者都可以单独处理其领域事件。

重要的是要强调,领域事件是存在于领域内的概念,而不是一个单独的限界上下文。因此,你可以异步地将领域事件传输到外部限界上下文,这是通过内置的消息通信系统完成的。

有许多组件用于传输与中间件类相关的消息(例如,RabbitMQ、NServiceBus)。你也可以通过 REST 传输消息,其中自主系统引用发布系统,要求非处理的通知。

RESTful 方法对领域事件的发布系统与通过典型消息通信结构完成的发布过程相反。“发布者”不支持几种类型的已注册“订阅者”,因为感兴趣方根本不会收到任何消息。相反,此方法依赖于 REST 客户端通过 URI 来请求。

至关重要的是要理解,你需要实现消息基础设施发布的内容与领域实际状态之间的某种程度的一致性。你需要确保领域事件已被真正传输,并且该领域事件反映了发布它的模型中的真实状态。

有不同的方法可以确保正确实现这种一致性。通常,你可以在限界上下文内依赖仓储。该仓储主要由领域的設計模式使用,并利用外部组件通过消息传输机制发布未发布的领域事件。但是,此方法假定客户端需要对传入消息进行去重,以确保在消息重发时客户端能够正确处理。

在这两种情况下,当订阅者使用中间件传输消息,或依赖 REST 时,你需要跟踪所有已处理的消息 ID,并存储有关领域本地状态的更改和修改的所有信息。

模块

存储在模型内的模块是所谓的显式容器,用于领域中特定相互关联的对象组。它们的目标是削弱多个模块内类之间的连接。从 DDD 的角度来看,模块是非正式或通用的模式。它们的名称选择是通用语言的功能。

你需要设计松散耦合的模块,因为这可以简化设计模式和概念的维护和重构。如果需要绑定,你应该更多地关注对等模块之间的无环依赖。(对等模块是指位于同一功能层并具有项目相似价值的模块。)模块不应被设计为模型的静态概念,因为它们需要根据它们组织的对象的修改而修改。

模块名称选择有一些规则。模块名称用于描绘组织的层次结构。名称的层次结构通常从负责模块开发的组织名称开始。像这样

com.bankingsystems

下一个模块名称段标识限界上下文。此段的名称应跟在限界上下文名称之后。像这样

com.bankingsystems.pfm

然后,你有一个标识给定领域模块的修饰符

com.bankingsystems.pfm.domain

你可以将所有模块放在 domain 部分

com.bankingsystems.pfm.domain.account
  <<Entity>>BankingAccount
  <<ValueObject>>AccountId

存储在此模型之外的设计模式根据架构进行命名。在一个众所周知的多层架构的上下文中,命名将如下所示

  1. com.bankingsystems.resources
  2. com.bankingsystems.resources.view (用户界面层(视图存储))
  3. com.bankingsystems.application.account (应用层(应用服务的子模块))

模块用于聚合领域内的边界对象,并与非边界或松散边界对象分开。限界上下文通常包装多个模块,因为它们将所有概念捆绑/边界到一个模型中,如果没有多个上下文之间的清晰边界。

Aggregate

聚合是领域驱动设计中最复杂的战术设计模式。

聚合是实体和值的集群。基本上,从数据修改的角度来看,这些对象被视为一个统一的整体。每个聚合都有一个特定的根和边界;并且,在该特定边界内应满足所有可能的约束。

所有对聚合的请求都应通过其根执行。根是具有自己全局唯一标识符的一种特定实体。聚合的所有内部对象只有所谓的局部身份,但可以无限制地相互引用。外部对象只能存储指向特定根的链接,而不是指向内部对象的链接。

不变式是始终保持一致性的业务规则。这种一致性通常被称为原子事务一致性。然而,也存在所谓的整体一致性。对于不变式,使用事务一致性。事务一致性边界也可以称为聚合。该边界存储所有不变式规则,而不管执行了哪些操作。如果你想在限界上下文中定义聚合,你需要分析模型的实际不变式,以确定哪些对象要捆绑到聚合中。

在设计聚合时,请注意,就性能和可伸缩性而言,小簇聚合优于大簇聚合。上传大型聚合需要更多内存。同时,较小的聚合不仅工作速度更快,而且提高了事务性能。更可取的是在聚合中使用值对象而不是实体。值对象更容易维护和测试(如我上面提到的)。

每个聚合都可以存储作为其他聚合根的链接。但是,它不会将此聚合放入第一个聚合的一致性边界中。链接不会建立一个全面的聚合。

在限界上下文的事务上下文中,只能修改一个聚合。最好使用聚合根的全局标识符创建链接。你不应存储直接链接作为对象或定位符。通过这样做,你减少了对象的内存占用,从而使它们能够更快地上载和扩展。

如果用户请求与多个聚合相关,请依赖整体一致性原则。你可以通过发布领域事件来实现整体一致性。基本上,当聚合被修改时,它会发布一个事件,该事件会导致其他聚合完成操作并维持系统的整体性。

为了说明这个原理,请看下面的信用报告

每个信用报告都需要包含信用用户的 ID 数据。使用 customerId 来保存和存储外部链接(通过标识符)。Customer 是一个独立的聚合,保留有关信用用户(姓名、地址、联系方式)的所有信息。假设信用评级估算规则被用作不变式,并根据信用历史中存储的特定数据进行修改。如何计算或估算此信用评级并不重要;重要的是如何在聚合内实现事务一致性。例如,当信用历史以某种方式修改时,信用分数也应该改变。这应该是一个原子操作。如果你使用数据库,那么创建独立事务是有意义的。一旦对聚合内对象的数据进行了任何更改,就应该激活并应用不变式。

Inquiry 是第三方组织提出的特定信用评分请求。聚合根是实体,具有全局身份。如果你应该链接到此聚合,你只能使用标识符根。如果你删除信用报告的聚合,所有值都将被删除。

Factory

工厂设计模式相当流行,并且比其他设计模式使用更广泛。

某些聚合和实体可能相当复杂,而复杂的对象可以使用构造函数自行创建。情况可能会更糟:当你将复杂对象的创建过程传递给客户端时。基本上,这意味着客户端需要了解项目的内部结构和依赖关系。这会破坏封装,并将客户端绑定到特定的实现,并且在引入对对象的任何更改时,你也必须修改客户端部分。

长话短说,单独创建复杂的聚合或其他对象是更好的选择。这时你就需要依赖工厂。工厂是负责创建其他对象的程序元素。

通常,工厂设计为聚合根中的工厂方法。工厂方法很有效,因为它们可以表达通用语言(而构造函数做不到)。

当你在聚合根中创建工厂方法时,你需要遵循所有聚合不变式的规则,并将其创建为一个整体。此方法必须是完整且不可分割的。所有工厂方法的创建数据(值对象)都应在一个通信操作中传递。所有细节都隐藏了。

值对象和实体创建方式不同。由于值是不可修改的,因此所有属性都应在创建后立即传递。与此同时,你只能为实体添加特定属性(对该特定聚合及其不变式重要的属性)。

存储库

仓储是用于安全存储所有必要元素的内存段。这正是领域特定仓储的本质。仓储用于存储聚合。当聚合被放入特定仓储然后从中提取出来时,你会得到一个统一的对象。如果聚合被修改,所有修改都将被保存。如果聚合被删除,你将无法再提取它。

每个需要存储一段时间的聚合都应该有自己的仓储。

通常,仓储被设计为保证所有先前生成的聚合都能轻松找到。因此,有两种类型的仓储

  1. 模仿集合的仓储;
  2. 永久存储数据的仓储。

模仿集合的仓储在模仿集合方面做得很好,至少模拟了其部分接口。其接口以任何可能的方式都不显示永久存储机制,并用作标准的 DDD 模式。

你可以将此仓储视为 HashSet<ObjectId, Object>。在此集合中,你不能两次插入相同的元素。当对象被接收和修改时,所有更改都会立即保存并应用。

虽然客户端不应该处理永久存储机制,但你需要时刻注意该机制如何运行以及它如何存储对象的所有更改。要做到这一点,你可以

  1. 依赖隐式写时复制方法(永久存储机制每次请求数据库时都会复制存储的对象,并在事务进行时将关闭的副本与客户端的副本进行比较)
  2. 使用隐式写时保存方法(该机制使用代理对象管理已上载的对象)。

像 Hibernate 这样的机制允许你创建任何类型的仓储,这些仓储专门用于模仿集合。

在高性能条件下,将所有对象存储在内存中成本很高。它会过度加载内存及其系统。但在使用永久存储机制的仓储中,你不必过多关注对象何时何地被修改;你只需使用 save() 方法保存所有更改。像这样

要实现和使用仓储,请依赖永久存储机制的多种方法和对象。重要的是仓储是在基础设施层面创建的,但接口是在领域中声明的。

在这种情况下,你需要使用第一种仓储类型(我用来模仿集合的那种)。它可以与 save()put() 一起使用。只有集合方法。

到数据库和其他机制的访问被封装在仓储中。客户端将非常简单,并且不会依赖用于创建仓储本身的方法。

结论

好了。我们已经看完了所有的 DDD 模式。它们都可以用于开发特定的限界上下文。首先,你可以从定义实体和值对象开始。然后,你可以将它们分解成单独的聚合,以调整和协调所有数据并在聚合边界内维护业务规则。之后,你需要继续创建工厂和聚合仓储。为了调整系统中的所有数据,你可以依赖事件。它们可以在统一的系统中创建,而不仅仅是在限界上下文中。

我还可以稍微谈谈应用程序、它的层、层级和架构。但我决定不这样做,以便使文章版本更短。希望这能帮助你更好地理解 DDD!

请在下面的评论区告诉我你对 DDD 及其战术设计模式的看法!
 

© . All rights reserved.