从代码战术角度解释领域驱动设计 - Cyrille


在您当前的应用程序中,您的业务逻辑有多复杂?它的范围可能从微不足道到极其复杂。人们不应该为一个微不足道的问题使用复杂的工具。
我们大多数人,包括我自己,都习惯于编写所谓的事务脚本。我们编写一个控制器来处理请求、处理数据库并返回结果,所有这些都直接在控制器内部进行。

对于大多数简单的用例来说都很好。它的可读性很强,几乎不可测试,而且都在一个地方。
它只是完成工作。但是有很多警告:

  • 代码根本不反映域,只有函数名createUser说明发生了什么
  • 该方法处理不同层次的抽象。从一个对象中提取数据是一个低级操作,做一个SQL查询就更底层了。这是两个不同层次的细节。
  • 该方法有太多责任:它处理从请求中获取输入、进行数据库查询和发送响应。您必须了解许多实施细节:此代码明确使用 SQL 数据库,并且可能通过 HTTP API 调用进行调用。

我们不会一次解决所有这些问题。但是我们可以从向代码添加更多域模型开始,进入下一步:Active Records
Active Record 是一个对象,是数据库中一行的直接表示(如果您使用的是 NoSQL,则为文档)。它嵌入了可用于改变其属性的setter和gettersave ,以及一种将更改持久保存到数据库的方法。


通过这个小动作,我们抽象出了数据库实现细节。无论用户如何保存,只要知道该save方法会为您完成就足够了。
但是现在我们有另一个问题:逻辑重复。如果另一个用例需要进行相同的操作(例如,从管理面板创建用户),您别无选择,只能复制并粘贴相同的代码。
听起来不是很重要吗?那是因为我们的用例非常简单。如果注册实际上是一项复杂的操作,需要发送电子邮件、OTP、签署文件、创建实体、填写 CRM、通知报告和分析等等,该怎么办?突然之间,您需要做很多工作来确保一致性。

这就是您进入 DDD 领域的地方。作为非常简单的第一步,您可以转换您的 Active Record 并让它封装域逻辑而不是使用 setter。

我们得到了更少的重复和更高的可读性,因为方法名称揭示了之前被命令式代码(what)隐藏的意图(why)。除其他好处外:

  • 您可以通过以下方式测试您的域模型User.createAccount
  • 需求的变化被本地化到一个单一的类(用户)
  • 实施起来仍然简单快捷

我们正处于DDD 的边缘。您可以更进一步,将用户创建代码包装在Service中。现在,您有一个调用Service的Controller ,这是大多数后端开发中非常常用的模式。每一层都有责任:
  • 控制器捕获请求,将其分派到正确的服务并在发送回之前格式化响应。
  • 该服务接受请求,对其进行清理并执行。


大多数简单的工作流都对此设置感到满意。它是可测试的并且已充分分解。然而,这可能还不够:订阅新用户所产生的副作用(例如发送电子邮件、填写 CRM ……)又如何呢?
有两种处理方法:

  • 同步地,在登录代码之后
  • 与事件驱动架构异步

同步法简单快捷,但容易产生大泥团。此外,它使请求越来越长,这是不必要的,因为初始命令已经完成(用户已创建)。显然,它无法扩展。
异步方法在 DDD 中得到了缓解:当用户被创建时,我们派发一个域事件,称为UserCreated通知我们的系统关于新用户的信息,并允许其他感兴趣的各方对此事件做出反应。这种方法称为事件驱动架构,是一整套实践(尤其是CQRS,一种在 DDD 社区中占有重要地位的模式)的切入点。


我们很快就从事务脚本转到了事件驱动。至此,我们的需求已经满足了,没必要再深究了吧?
但不要忘记:我们在这里谈论的是 DDD。


交易事务边界
除非我们正在处理事务边界,否则我们不会谈论下一步。
简而言之,事务边界封装了必须持续更新的元素(关系数据库中的行、NoSQL 数据库中的文档等)。要么事务作为一个整体成功,要么回滚到之前的一致状态(称为Atomicity的特性,即[url=https://en.wikipedia.org/wiki/ACID]ACID[/url]中的 A )
假设您正在管理一组用户,并且您的代码将一个新用户添加到该组中。您首先加载用户组,确保您可以添加此用户(例如,通过检查该用户不在该组中),然后添加它。
如果没有事务边界,当这个用例被同时调用多次时会出现很多问题。例如,在组中添加同一用户的两次调用可能会导致该组具有两次相同的用户。你可能不想要的东西。
通过事务边界, 数据库访问层 (DAL)和数据库本身采用一组复杂的技术来确保您一致地更新数据。 在此示例中,如果两个事务对同一组进行操作,则第一个提交将成功,但第二个将失败,因为数据库检测到更改发生在第二个提交之前,从而导致回滚。
这就是聚合发挥作用的地方。

聚合
最难掌握的概念之一(连同限界上下文)是聚合。但既然我们已经看到了事务边界,就更容易理解聚合的需求了。
回到我们的例子,我们的服务将:

  • 取一组
  • 验证我们要添加的用户不在组中
  • 将用户添加到组

但是我们刚刚看到如果没有事务边界,这会如何变坏。那么,现在我们有了解决问题的策略(想法),它如何转化为具体代码?答案是聚合
聚合的作用是封装必须被视为单个单元的实体。在我们的例子中,我们必须更新一组用户,所以我们管理两个实体:组和用户。聚合本身有一个接口:
  • 添加和删​​除组的用户
  • 设置用户数量限制
  • 确保组中没有两次相同的用户
  • 以及其他业务定义的不变量。

当然,您必须始终使用聚合来对组进行操作。您不能在其他地方人为地更改事务脚本中的组,这是出于我们首先编写聚合的非常准确的原因:保持数据和代码的一致性。

每个聚合都有一个称为聚合根的霸主,其职责是维护聚合的完整性。在这里,聚合根是Group。

现在有一个问题:每个实体要么属于一个聚合,要么是聚合的聚合根。在我们的例子中:

  • GroupUserAggregate是一个聚合,其中Group是根,这个聚合允许我们对管理组内的用户进行操作
  • UserAggregate是一个以User为根的聚合,你通过UserAggregate对单个用户进行操作

如您所见,一个 User 属于许多聚合,因为它可以在不同的上下文中以不同的方式使用:
  • GroupUserAggregate管理组内的用户并在组内执行规则(添加/删除用户,更改组名)
  • UserAggregate管理用户并围绕用户本身执行规则(例如更改名称、电子邮件地址/密码等)

架构模式
事情变得越来越复杂,我们的战术工具集演变为:

  • 一个控制器
  • 一项服务
  • 事件分发器
  • 许多聚合体

我们还没有完成一半,但这些涵盖了大部分可用的实现模式:
  • 事务脚本只是实现你的行为的方法,没有域封装。您直接访问数据库(例如使用原始 SQL)
  • Active Records将数据访问抽象为代表数据库中一行的对象
  • 领域模型将行为封装在具有代表业务的精确名称和用例的类中(您不再使用 setter,而是使用清晰的域名)

但这些不是架构,它们是实现模式:它们是实现域本身的工具。

另一方面,架构模式是支持这些实现的工具:

  • 事务脚本最常通过出色的3 层架构(表示、业务、数据)实现。调用控制器的 UI/CLI 是表示层,控制器内部的代码是我们的业务层,我们的数据库保存数据层。
  • Active Records也可以通过 3 层模式充分实现,但有些人喜欢使用4 层架构,在表示和业务之间添加一个应用程序(或服务)层。
  • 域模型本身就是另一块蛋糕,因为它经常与六角形架构一起使用(称其为CleanOnion等)。您的域变得越来越不了解实现细节(例如数据库或第三方 API 调用),并且越来越依赖接口(通过神奇的控制反转模式)。

现在架构模式是另一个完全独立于领域驱动设计的主题,因为你真的可以使用上述任何架构来构建领域模型。六边形架构非常适合 DDD,因为它最大限度地提高了领域模型的独立性。但这不是必需的,特别是如果您已经有一个代码库并打算逐步迁移它。
不要让任何人让你认为没有完整的DDD工具,你就不能做一个合适的领域模型。

限界上下文呢?

限界上下文就像 Monad。一旦你明白了,就很难向别人解释。希望有界上下文不是内函子类别中的幺半群
甚至我自己也很难给出一个令人满意的定义,而其他人提供的定义也无济于事:

  • 限界上下文是领域驱动设计的核心模式,Martin Fowler
  • 有界上下文只是应用特定域模型的域内的边界,Microsoft
  • 有界上下文定义了某些子域适用性的有形边界。这是一个特定子域有意义而其他子域没有意义的区域。瓦迪姆萨莫欣

也许对你来说太大了
让我们缩小并离开代码区域,纯粹从战略的角度思考:我们正在为之编码的业务如何运作。
对我来说最困难的事情之一是“上下文”的概念。这是一个没有规模意义的词:相对于我们的应用程序的上下文有多大?

如果你有一个非常非常大的公司/软件有多个领域,那么限界上下文通常是有意义的。让我们来处理一个电子商务系统的案例。这样的软件可以处理:

  • 目录管理
  • 存货
  • 订购
  • 支付
  • 船运
  • 支持

所有这些区域(我们称它们为子域)都属于一个大区域:Commerce Bounded Context。与此同时,您的企业可能还有其他顾虑:广告管理(在 Google Ads 和 Facebook Ads 上)、客户管理(通过 CRM)、分析等。所有这些都是其他的上下文Bounded Contexts。

无处不在的语言
每个限界上下文都有一种通用语言:它是在每个限界上下文中工作的人们用来谈论某事的语言:一个实体、一个事件、一个动作……
通常,当人们在Commerce Bounded Context中谈论 Users 时,他们与Customer Management中的谈论方式不同。

  • 在 Commerce中,我们谈论身份验证机制、付款和送货地址等。
  • 在 CRM中,我们谈论他从哪里来,他买东西的频率,它的满意率等等。

它们在概念上共享相同的身份:Commerce 中的用户与 CRM 中的用户相同,但它们携带不同的信息,并且在这些领域工作的人们对它们的谈论也不同。
但是Analytics也有一个 User 实体,这个与 Commerce & CRM Users 非常不同。在这里,用户是与网站交互的任何人,例如甚至没有适当身份的简单访问者。
所以你看,简单的User这个词,有时可以用不同的信息描述同一个人(身份),或者完全不同的人。一种限界上下文与另一种限界上下文之间的通用语言可以完全不同或相对接近。

但实际上,什么是限界上下文?
到目前为止,我给出的是启发式而不是定义。获得这些知识后,理解限界上下文的定义可能会更容易。引用微软:
有界上下文只是应用特定领域模型的领域内的边界
就是这样:Commerce 限界上下文中有关用户的代码将不同于 CRM 限界上下文中的代码。

  • 在 Commerce中,我们将有方法来验证和创建帐户、关联支付信息、发送折扣券等。
  • 在 CRM中,我们将有方法来获取用户、支付和订单的历史记录、联系信息(如果销售代理联系过他)以及之前代理输入的注释的历史记录。

在 CRM 中获取有关用户的身份验证信息是没有意义的。在 Commerce 中获取销售代理呼叫历史记录是没有意义的。
限界上下文本质上非常大,您希望它尽可能大以最小化限界上下文之间的交互(防止耦合)。

你说的领域模型?
啊,你终于注意到领域模型的使用了,对吧?在我们的示例中,我们有一个存在于我们域中的用户实体。但是不同的组件将与用户的不同部分进行交互:一些会谈身份验证,其他的个人信息和其他支付历史。这是同一用户域实体上的 3 个不同的域模型。
领域模型只是对 Domain 的同一元素的不同视图。六个盲人将以相同的方式触摸大象的不同部分

子域
对于大型限界上下文,您有称为子域的细分。在 Commerce 示例中,Inventory 和 Authentication 是两个子域。每个子域都适合由单个团队负责(工作分工的另一种启发式)。
子域分为三类:核心、支持和通用。在定义它们之前,让我们(再次)看一些启发式方法。

  • 核心:它们是您域的秘诀。他们处理对您的业务至关重要的职责和用例,使您的业务与众不同并有助于实现利润最大化的事情。
  • 支持:那些是支持您的业务核心但不提供任何特定竞争优势的子域。他们通常会解决显而易见但不是很复杂的问题。
  • 通用:它们是已经解决的明显问题(由第三方或开源解决)。您的每个竞争对手或多或少都以相同的方式解决问题,不值得投入时间。

请注意,我们仍在战略性地讨论:子域甚至可能不是技术代码。
让我们以您销售手表的电子商务为例,假设它是一个奢侈手表电子商务。
  • 核心:您的核心子域是手表制造。因为这就是您与竞争对手的区别:手表的质量。您可能正在制作自己的手表,因为这是您的秘方,或者让制造商使用某种特殊配方为您制作。
  • 支持:所有编目、库存管理和订购系统都是支持子域:它们不提供任何特定的竞争优势,但它们支持公司的核心业务。它们专门与您的电子商务业务相关联。您可以在内部解决它们,也可以外包。
  • 通用:支付和身份验证是众所周知的,解决了大多数公司必须解决的问题。在内部实施它们没有任何好处,而是由专门的实体实施。

如您所见,我们的核心子域没有代码表示(您没有影响手表创建的代码)。最终,您作为 CEO 决定他想要软件来帮助创建手表,突然间您开始拥有代表这个核心子域的代码。这个决定涉及另一个:制造限界上下文。
现在,即手表核心子域,跨越了多个有界上下文:
  • 商务BC:关于手表是如何制造的知识以及证明它是顶级质量手表的论据是商务的一部分
  • 制造BC:制造手表的知识以及如何确保其质量、通过控制和指挥各个部件都是制造过程的一部分。

拥有跨界的子域不一定是坏事,实际上可能是可取的:子域是团队负责的干净边界。一个拥有手表质量和制造知识的团队可以同时实施软件来构建和确保质量,同时为电子商务展示提供论据和信息。毕竟,负责创建电子商务的团队可能不知道这些信息,并且可能更关心可伸缩性、可用性和其他分布式系统问题。
这是一个漫长的旅程

总结
我们只是触及了表面。DDD 是一种非常庞大的方法论,涵盖从战略 规划和分解(通过会议、事件风暴和所有爵士乐)到战术 实施(模式、层次和边界之间的通信)。
问题区域非常密集和模糊:如何限制边界,子域是什么?它们是如何相互联系的?谁拥有什么?没有灵丹妙药,需要经验和成熟度才能擅长。
解决方案领域也非常大:有很多模式和工具、不同的层和架构可供选择。我们几乎没有提到事件驱动架构、事件溯源和 CQRS。正确确定要使用的工具需要严谨和仔细的思考。
但是再一次。
不要让任何人让您认为没有完整的 DDD 工具集就无法进行领域驱动设计。
您可以拥有一个跨越整个项目的限界上下文:它使事情更容易推理。
您可以在任何地方使用交易脚本,获得很好的效果,尤其是当您正在测试您的想法并且不确定它是否适合市场时。
随着时间的推移,总是可以向您的项目中添加越来越多的领域驱动设计方法。所有增强都是渐进的。

结束语
最近,工匠精神已经在整个开发界掀起了一场风暴。越来越多的开发者热衷于创建可长期维护的软件。毕竟,我们大部分时间都在维护软件,而不是编写新代码。
这个领域很宽广,因为有很多书籍和技术可以学习,以使我们在这个领域变得更好。领域驱动设计只是一个可以研究的地方,它可能是最深入和最难走的路。请记住,DDD决不是一个普遍的真理,只是一些人决定采用的观点。
我相信它是一套很好的原则、实践和工具。但不幸的是,学习曲线是非常陡峭的。
请带着一点盐来接受这些知识,在做任何事情之前,请根据你的需要来调整它。再一次,没有银弹。