领域驱动设计——在软件领域反映业务





5.00/5 (11投票s)
本文介绍如何在软件领域中实现领域驱动设计(DDD),并处理复杂软件的复杂性。
引言
当我们开始一个新项目时,有时会被分配估算工作所需时间。通常,我们会尝试从需求中分解出一些小任务,估算时间并准备我们的冲刺计划。但是,我们能将任务分解得足够小以估算时间吗?对于大型复杂项目,由于对项目领域缺乏充分的了解,有时几乎不可能估算时间。在这些情况下,我们估算时间,然后突然发现时间不够。反之亦然,有时我们高估了时间,因为我们不知道实际需要做什么。还有一些情况是,开发人员开发了一些东西,然后突然意识到他们并不需要它。此外,在大多数情况下,我们的代码以及代码的语言并不能反映业务或领域。因此,几天后,即使我们一开始认为我们使用了正确的命名约定,也几乎不可能理解代码的实际功能。通过遵循领域驱动设计,我们可以轻松地消除这些问题,该设计可以在代码中反映业务领域,使其更易于维护和理解,即使对于非开发人员或软件工程师也是如此。
讨论的主题
- 定义域
- 领域模型
- 通用语言
- 实体
- 值对象
- 聚合和聚合根
- 限界上下文
- DDD 中的分层
- 存储库
定义域
要理解领域驱动设计,首先需要理解我们应用程序的领域是什么。根据 Eric Evans 的说法,
引用“任何软件程序都与用户的某些活动或兴趣有关。用户应用程序的那个主题领域是软件的‘领域’。”
假设,您被分配构建一个商店管理系统。我们需要考虑,“商店管理”这个术语是否正确地代表了我们的领域?有些人可能认为是的。但真的是这样吗?因为如果您要为超市构建系统,它将与杂货店或男女服装店不同。所以我们必须具体。我们需要说明我们要构建一个超市管理系统。这比通用的商店管理系统更有意义。另一个例子可以是建筑管理系统,其中更合适的是居民建筑管理系统或商业建筑管理系统。
领域模型
领域模型是反映应用程序领域的图。领域模型应包含领域的整体概念,并且在看到它之后,任何人都能理解其含义。如果一个软件开发人员被告知开发一个关于飞机交通路由的应用程序,没有领域知识,他将无法开发该应用程序。但不幸的是,软件开发人员对航空管制一无所知。那么谁知道呢?控制空中交通的人是这方面的专家,他清楚地了解它,并且在没有空中交通管制员的帮助下,开发人员无法开发应用程序。在这里,我以公寓租赁申请为例。在这里,DE 代表领域专家,SD 代表软件开发人员。
DE:我需要一份租赁申请表来出租我的公寓。
SD:好的,没问题。那么租赁申请人应该填写哪些信息?
DE:租赁申请人可能需要填写不同类型的信息。首先,租赁申请人应填写所有个人详细信息,然后是财务信息,最后是紧急联系人信息。
SD:好的,让我们称紧急联系人是“紧急联系人”。是这样吗?
DE:是的。但这还不是全部。还有共同申请人。共同申请人需要填写与主要申请人相同的字段。但他们需要向租赁申请人发送接受请求,租赁申请人需要接受该请求。
SD:我完全明白了。看起来是这样的。
但我有一个问题。共同申请人目前的系统是如何工作的?是否有任何 ID 可以用来识别这个共同申请人表格与那个租赁申请表相关联?
DE:哦,是的!每份租赁申请表都有一个唯一的申请号和一个 4 位数的 PIN 码。主要租赁申请人会收到多份共同申请人表格。申请号也打印在共同申请人表格和租赁申请人表格上。但是 PIN 码只打印在租赁申请人表格上。他需要将 PIN 码告诉共同申请人。当共同申请人提交表格时,我们会匹配申请号和 PIN 码,以防止欺诈。
SD:所以,在我们的系统中,将有一个带有唯一申请号的租赁申请表。还有一个 PIN 码将由 PIN 码生成器生成,我们将只在主要租赁申请人表格中添加 PIN 码。共同申请人将在提供有效的 PIN 码并填写所有必需信息后附加到该租赁申请。所以最终系统看起来是这样的:
DE:是的,就是这样。
这是领域模型的一个小例子。领域模型也可以包含所有属性/特性的名称。通常,真实世界的项目场景比我的例子复杂。这里有一点是,我没有使用任何特定的图。我只是画了一个图,可能有助于我和我的团队理解领域。但是,使用 UML 图并不能正确地可视化应用程序的领域。所以领域模型可以是任何图、文本甚至图片,只要它能反映领域。根据 Eric Evans 的说法,
引用“领域模型不是特定的图;它是图所要传达的理念。它不仅仅是领域专家头脑中的知识;它是该知识的严格组织和选择性抽象。图可以表示和传达模型,精心编写的代码可以,一句英语也可以。”
我们领域模型中的内容,也应该存在于软件的领域中。如果一个事物不存在于领域模型中,它就不应该成为软件领域的一部分。
通用语言
领域专家可能对软件开发一无所知。如果软件开发人员用他们的语言与领域专家交谈,领域专家可能听不懂。
示例
在之前的对话中,如果软件开发人员对领域专家说这样的话,
SD:所以,在我们的系统中,将有一个带有唯一申请号的租赁申请表。该号码可能是一个 GUID。还有一个 PIN 码将由 PIN 码生成器生成,我们将只在主要租赁申请人表格中添加 PIN 码。当共同申请人输入正确的 PIN 时,我们将设置一个标志为“validcoapplicant”,然后该应用程序将被附加到租赁申请人。
除了解代码和 GUID、标志含义之外,任何人都很难理解这种语言。同样,有时领域专家的语言对开发人员来说可能很难理解。因此,领域专家和开发人员需要“找到一种通用语言”,他们都能理解。这并不容易,可能需要时间来开发这种语言,但却极其必要。“领域模型应该提供通用语言的骨干。”这种语言应该由开发人员和领域专家之间相互沟通使用。由于领域模型提供了通用语言的骨干,这种语言也会反映在代码中。在团队(这里的团队是指系统分析师、开发人员、领域专家等所有与项目相关的人员)中使用通用语言的最大优点之一是,由于团队成员之间反复使用该语言,它将帮助我们识别领域模型的弱点并快速修复它。如果通用语言中的任何内容发生变化,这意味着领域模型和代码也发生了变化。如果领域模型发生任何变化,通用语言和代码也会发生变化。如果代码没有变化,尽管语言发生了变化,这意味着您没有正确遵循 DDD,因为“领域模型反映在您的代码中”。
实体
简单来说,实体是具有概念身份的对象。它们拥有身份,这样我们就可以通过它们的身份来跟踪和区分它们。实体不应该只有 getter 和 setter,而应该封装其所有行为,这意味着它可以拥有与其行为相关的方法或指示其行为的方法。实体是可变的。我们可以在不改变其身份的情况下更改实体的属性,但不必销毁整个实体。
示例
从图中可以看出,租赁申请人和共同申请人是实体。因为租赁申请人有一个概念 ID,我们需要跟踪租赁申请人并维护其持久性,而共同申请人需要向租赁申请人发送接受请求,因此我们需要唯一地标识它们,并且它们应该有一个 ID。但是紧急联系人、财务信息和个人详细信息不是实体,因为它们是租赁申请人和共同申请人的一部分,我们不需要单独标识它们。
值对象
有些对象没有概念身份。这些对象称为值对象。我们必须记住,我们谈论的不是数据库的 ID 属性,而是对象真正需要的 ID。Eric Evans 在谈到值对象时说:
引用“值对象被实例化,以表示我们关心的设计元素,仅因为它们是什么,而不是谁是什么。”
值对象是不可变的。因此它们不能被更改。我们不关心值对象的任何特定实例。如果我们需要删除一个值对象,我们会销毁整个对象并创建一个新的。值得注意的是,一个系统的“值对象”可能成为另一个系统的“实体”,而一个“实体”可能成为另一个系统的“值对象”,这取决于不同的应用程序需求。
示例
紧急联系人、个人信息和财务信息是值对象,因为它们不需要身份。我们可以通过将 setter 设置为 private 来使值对象不可变。下面是一个示例。
另一个例子,假设你有一个博客,并且你写了关于高质量软件开发主题的文章。很多人喜欢你的帖子,并评论了这些帖子。这里,“帖子”是实体,因为我们需要单独跟踪每个帖子,但“评论”是值对象,因为它们没有概念身份,我们不需要单独标识一个评论。
贫血域模型
贫血域模型是一种反模式。在 OO 设计中,对象应该拥有其所有的行为。但有时,我们创建只有一些 getter 和 setter 的实体,并创建一个相应的服务对象,其中包含方法等其他行为并进行委托。我们通常在该服务对象中使用实体的引用。但根据面向对象设计原则,对象应该封装其所有行为,这就是我们所说的封装。当我们创建一个单独的服务类或对象时,我们明显违反了这一原则,因此也违反了 SOLID 原则。贫血域模型的一个特点是,乍一看,它看起来很完美,因为我们将类命名为名词,并将方法命名为动词,但当尝试分析其行为时,就会发现问题,因为有时它不包含任何行为,或者包含行为的混合,有时还包含部分行为,而其他对象则拥有其部分行为。根据单一职责原则,
引用“一个对象应该只有一个改变的理由。”
如果一个对象包含多种行为、没有行为或部分行为,那么该对象可能出于多种原因而改变。因此,它违反了单一职责原则。
示例
我们有类名为 LeaseApplicant、CoApplicant、EmergencyContact、PersonalDetail 和 FinancialInformation。假设我们有两个服务类 CoApplicantService 和 LeaseApplicantService。CoApplicantService 有一个名为 SendAcceptRequest 的方法,LeaseApplicantService 有一个名为 AddCoApplicant 的方法。这使得我们的领域模型贫血,因为 SendAcceptRequest 是 CoApplicant 的特征,应该在 CoApplicant 类中,而不是在一个单独的服务类中。AddCoApplicant 是 LeaseApplicant 的行为,所以它应该在 LeaseApplicant 类中,而不是在 LeaseApplicantService 中。
聚合和聚合根
聚合是一个由实体和值对象组成的单一单元。单一单元的根实体称为聚合根。一个聚合可以持有另一个聚合的引用。
示例
在我们之前讨论的例子中,租赁申请人、共同申请人、财务信息、紧急联系人和个人详细信息构成一个聚合,租赁申请人是根实体,因此是聚合根,因为没有它,其他就没有价值。*“聚合外部的任何内容只能引用聚合根。”*也就是说,我们只能从聚合边界外部引用租赁申请人。我们无法从聚合边界外部访问共同申请人、财务信息等。如果我们需要对它们执行任何操作,我们必须在聚合边界内部执行。因此,聚合将帮助我们维护事务完整性。
限界上下文
一个大型系统有不同的部分,这些部分包含一些共同的概念。如果我们考虑一个电子商务系统,我们可能会发现两个基本部分:一个是收集来自不同供应商和销售中心的产品,另一个是销售产品和管理在线交易。这两个是不同的有界上下文。
DDD 中的分层
我从 Eric Evans 的 DDD 书中借鉴了这张图片,因为我认为它最好地描述了 DDD 的分层。
DDD 中的服务
领域服务
领域服务通常包含那些不太适合放在领域对象中的内容。通常这些不是 CRUD 操作。我们当然应该尝试将我们的操作放到实体中,但有些时候,一些特定的操作并不适合放入领域。在这些情况下,领域服务就派上用场了。在我上面的对话中,PIN 码生成器是一个领域服务。另一个例子是银行账户之间的资金转账。资金转账并不真正适合 Account 对象,它需要一个服务。另一个例子可能是计算学生的 CGPA。CGPA 计算并不是学生的一部分,所以它需要一个领域服务。
基础设施服务
基础设施服务通常用于数据库或文件访问、发送电子邮件。它们不是领域的一部分,也不存在于通用语言和领域模型中。领域层不关心数据如何保存或通知如何发送。这一层完全是技术性的。
应用程序服务
应用程序层通常将领域的功能暴露给其他应用程序层,并提供 UI 所需的必要信息。有时领域层需要访问数据库或其他 API 来完成业务逻辑。我们可以在实体中执行该任务,但这将违反 SOLID 原则。最好的解决方案是使用应用程序层获取数据,并将必要的数据提供给领域层执行操作。假设我们需要计算公司的年度收入。应用程序层将调用数据库以获取所有与收入相关的信息,并将其传递给领域层来计算收入。
存储库
到目前为止,我们已经创建了一个持久化领域模型,现在我们需要将数据保存并检索到数据库或任何其他 IO 存储中。Repository 为我们提供了这种方式。但我们应该记住,Repository 不是数据访问层。Martin Fowler 指出:
引用“在领域和数据映射层之间进行中介,使用类似集合的接口来访问领域对象。”
因此,我们可以创建一个通用 Repository,并将聚合根或聚合根的集合添加到其中。我们不能将值对象添加到 Repository,因为我们只能从聚合外部引用聚合根。
参考文献
- www.amazon.com/Domain-Driven-Design-Tackling-Complexity-Software/dp/0321125215
- https://channel9.msdn.com/events/TechEd/NorthAmerica/2013/DEV-B311#fbid=
- http://dddcommunity.org/library/vernon_2011/
- http://dddcommunity.org/
- http://www.infoq.com/news/2015/01/aggregates-value-objects-ddd
- http://culttt.com/2014/04/30/difference-entities-value-objects/
- https://lostechies.com/joeocampo/2007/04/23/a-discussion-on-domain-driven-design-value-objects/
- https://martinfowler.com.cn/bliki/DDD_Aggregate.html
- https://martinfowler.com.cn/bliki/AnemicDomainModel.html
- https://lostechies.com/jimmybogard/2008/08/21/services-in-domain-driven-design/
- http://mahmudisblogging.blogspot.com/2012/03/domain-driven-design-clear-your.html