DDD领域语言对云架构设计的重要性 - architectelevator


近年来,人们对领域驱动设计 (DDD) 的兴趣激增,正如DDD Europe等热门活动和Learning Domain-Driven Design等书籍所表明的那样。虽然对复杂业务领域进行建模对于成功的系统始终发挥着重要作用,但需要明确领域边界的模块化运行时架构(例如微服务应用程序)为 DDD 提供了火箭助推器。

不过,领域建模并不局限于业务领域。现代运行时或云架构等技术领域提供了灵活性和强大的工具,但也可能给开发人员带来额外的复杂性负担。这就是为什么这样的系统需要对其领域进行表达建模,而这项任务经常被忽视。


DDD和EIP
Eric Evan 的开创性著作《领域驱动设计》将 DDD 牢牢置于现代软件交付的雷达范围内。
Eric 的书与阿纳海姆OOPSLA 2003上的《企业集成模式EIP》同一天发布。
也许这并不完全是巧合,因为它们的内容共享的内容比表面上看的要多。

EIP 的65 种集成模式的模式目录构建在强大的底层域模型之上,该模型提供了描述异步消息传递系统的语言。
该模型分为两层,其中消息或转换器等基本实体通过命令消息或内容过滤器等模式进行细化。
沿着水平轴,该模型遵循消息从端点构建到路由、转换的生命周期。

随着时间的推移,我们通过额外的考虑来增强 EIP 域,例如用于消息传递的推与拉模型。
因此,EIP 在面向消息系统的技术领域非常基于 DDD。


Eric 和我已经认识 20 多年了,并且就领域模型和库/API 设计进行了各种交谈。
在 JDK 早期,当数据和时间处理类相当薄弱时, Eric 使用一个对时间和金钱进行建模的示例库作为教学工具。
那个时代的开发人员会记得缺少日期(与时区无关)和日期时间(必须指示时区)之间的区别,以及令人厌恶的行为,例如用日期时间结构表示日期,其中时间设置为零(一旦支持多个时区,就会破坏最新的日期)。

March(三月) 不是一个数字,而是一个包含 31 天的范围。

尽管对于简单的日期数学来说,将诸如月份之类的域概念映射到整数似乎很方便,但它揭示了域建模的缺乏。

让我们快进二十年,进入云计算、无服务器应用程序和自动化时代。发生了这么多很酷的事情,我们肯定有足够的时间来解决那些讨厌的领域怪事?

如果将重要的域概念(例如月份)表示为整数是一个非常糟糕的主意,那么将复杂的云资源输入为字符串可能意味着我们仍然需要进行一些域建模工作

语言塑造设计
领域语言很重要,因为与其他语言一样,它们为您提供表达想法的词汇。

人们普遍认为语言也会塑造你的思想,但我只读了早川的《思想和行动中的语言》的一半,所以我会推迟更深入的见解,直到我让自己变得更聪明。

在熟悉的(而且更加结构化的)IT 转型背景下,我确实观察到组织的思维方式和所使用的语言之间存在相当清晰的联系,这意味着您只需通过聆听所使用的词汇就可以对组织的数字成熟度做出相当有根据的猜测。

语言确实反映了思维方式,因此明智地选择领域语言不仅仅是一个设计细节。

面向分布式系统的领域语言
现代云应用程序(例如无服务器应用程序)是分布式的。他们经常通过交换消息和事件进行交流。这个简短的描述听起来并没有特别有争议,不是吗?那么是否值得为其创建域模型呢?

消息和事件是非常不同的事物:
发布-订阅与点对点是消息传输的问题,而事件或命令描述了通过此类传输传递的消息的语义。
事件与命令以及发布-订阅与点对点是独立的考虑因素。
很容易看出,如果没有领域模型,您可能会在这个问题上来来回回很长时间。

在处理云计算或分布式系统等复杂领域时,您选择的语言很重要。
当将云服务名称翻译成供应商中立且结构良好的领域语言时,我们可能会发现需要做很多工作,因为领域概念通常隐藏在服务内部。

示例:无服务器集成
那么,让我们看看使用什么语言来描述流行的集成服务。
这里举例是因为我很接近 Amazon EventBridge,它具有事件总线和管道风格。我仅将它们用作示例 - 大多数云提供商在其服务组合中都包含消息通道和路由器。

总线和管道有什么区别?
EventBridge 事件总线非常适合事件驱动服务之间的多对多事件路由。EventBridge Pipes 旨在用于这些源和目标之间的点对点集成......

此描述区分了多对多路由与点对点集成,这意味着多对多路由非常适合事件。对于管道,消息的类型没有拼写出来,但是缺少单词事件可能暗示它更适合命令消息。
在这种情况下,多对多路由是否等同于 Pub-Sub 并未得到澄清。

进一步对分布式系统和消息路由的技术领域进行建模,我们很快发现它不仅限于消息语义(事件、命令、消息)和传输模型(发布-订阅、收件人列表、点对点)。
可靠且高性能的消息传递系统必须考虑控制流、事件批处理、重试和错误处理。
EventBridge 也不例外。
让我更详细地总结一下总线和管道之间的区别:

  • 总线是通过队列将进入事件发送给事件接收器。
  • 管道Pipes会轮询事件,因此不需要队列,可以通过背压降低轮询速率。管道 还支持事件批处理,以使轮询和处理更加高效。甚至还可以分裂对于不处理批次的目标,将批次分为单独的消息。但是批处理消息会迫使您注意部分失败,即批处理中的某些消息失败。重试整个批次可能会导致重复的消息处理,需要幂等性。

这两种服务都可以将消息传递到异步服务(例如 SNS)或同步、速率受限的服务(例如 API 网关或 API 目标)。

举一个简单的例子,假设您想要将消息从 SQS 队列移动到CloudWatch Logs(AWS 的日志系统)。这看起来很简单,直到您意识到 SQS 需要一个主动轮询的使用者(即轮询使用者),而 CloudWatch 日志作为事件驱动的使用者接收事件。

因此,两端都有凹口,因此需要一个带有两个“鼻子”的连接器来连接这些部件。我们将这样的一块称为Driver。EventBridge Pipes是一个Driver。

即使您不太熟悉推拉控制流以及 EventBridge 总线与管道,您也可以只寻找“有两个鼻子的东西”并正确选择 EventBridge 管道作为连接器,而不是深入文档。

在用户界面用语中,鼻子和凹口被称为可供性(affordance)。由于“DX”如今已成为一个流行词,我们强烈建议将成功的用户体验概念(例如可供性)转化为 DX 世界。类型系统提供了很好的功能可供性。

Azure 事件网格文档包含了推式与拉式控制流的相当一致的描述

云自动化中的 DDD——仍然是一个新兴学科吗?
自动化,又名 IaC(基础设施即代码),云自动化的开发人员往往聚集在两个不同阵营之一:那些喜欢文档式自动化(主要以 YAML 文档或 TerraForm 语法表示)的人和那些喜欢面向对象编程语言(主要使用 CDK 或 Pulumi)的人。

如果自动化代码是描述云解决方案的最佳方式,那么这段代码应该表达重要的领域概念,对吗?

让我们以 YAML 文档的形式查看EventBridge Pipes(缩写)的 CloudFormation 结构:

Type: AWS::Pipes::Pipe
Properties: 
  Description: String
  Name: String
  RoleArn: String
  Source: String
  SourceParameters: 
    PipeSourceParameters
  Target: String
  TargetParameters: 
    PipeTargetParameters

Pipe 的消息源表示为 string ,将其保留在那里。使用字符串作为通用类型是一个非常普遍的问题:字符串类型。

我看到一张幻灯片,标题是“最常用的领域概念”,上面有两个大字:string和int。不幸的是,我无法再挖掘它,但它很好地捕捉了领域建模和日常编码之间的差距。

我职业生涯的大部分时间都用 Java 构建了大型领域模型,例如用于移动广告的模型。尽管可以用数据结构来表示领域概念,但面向对象的语言带来了更多的表达工具,例如接口、继承或组合。这些构造非常方便,因为它们提供了可供性,这意味着它们建议正确的使用而无需阅读手册。

我的背景使得我偏爱面向对象的语言来进行应用程序和自动化也就不足为奇了。

我们可以做更多的事情来利用面向对象和类型系统的力量,使云编程变得更容易。回到鼻子和凹口,人们很容易想象一组像这样的伪语法的基本类型或接口

interface Source {} // source that can be polled
interface Sink {}  
// target that you can push messages to

class Pipes {
  Source source;
  Sink target;
}

class EventBridge : Sink {}

class SQS: Sink, Source {}

使用这个相当原始的类型系统,您的 IDE 已经可以告诉您哪个连接器适用于哪个源和目标。人们可以进一步采用这种方法,将这些类型包装到一个流畅的 API 中,通过简单地按 CTRL-SPACE 来引导程序员获得有效的组合。

如果类型系统本来可以做到这一点,为什么还要等待部署告诉您事情无法正常工作呢?

类型系统为开发人员提供了最紧密的内部循环:在您键入时出现红色下划线。如果类型系统在部署之前消除了许多简单的错误源,那么部署时间将不再是一个瓶颈。显然,这个领域还有很多有趣的工作要做!