DDD:不要泄露领域事件

banq


领域事件必须保持私密。

耦合是所有问题的根源。随着时间的推移,它会让事情变得更加困难,在某些情况下甚至是不可能的。
耦合是造成您的压力和技术团队效率低下的罪魁祸首。
简而言之,耦合是邪恶的,必须与之斗争!

开始之前:为什么要使用 DDD?
我们可以列出很多好处,但我将特意强调几个主要的好处:

  • 团队之间的协调:DDD 促使我们这些技术人员避免将所有技术限制强加给其他人(非技术人员)。它鼓励我们了解业务、协作并与业务专家一起建模。
  • 减少复杂性或过度工程:通过专注于核心领域,我们将大部分精力投入到最关键的方面——那些为业务带来最大价值的方面。技术只是一个手段,而不是起点。在进入微服务、Docker、框架等之前,我们会先考虑“业务”。
  • 改善沟通:无处不在的语言:在技术人员和非技术人员之间,我们使用同一种语言——业务语言。无需同时将技术概念翻译成业务术语,反之亦然。这减少了心理负担和误解。
  • 更大的灵活性和可扩展性:通过对业务进行建模,我们为自己打开了一扇大门,这在业务发展时变得更加重要。今天适用的事情明天可能就不适用了。这种分离允许系统在不破坏一切的情况下发展——或者至少不会破坏整个设置。
  • 降低风险:不再开发不必要的功能。相反,团队(技术和非技术)共享清晰的愿景,在优先事项上保持一致,并了解利害关系。

那么什么是“领域事件”?
领域事件代表了我们业务中发生的不可否认的事实。其命名约定通常使用过去时,并且通常由唯一标识符(UUID、ULID 等)标识。它通常包含一些属性(尽管这不是强制性的)。

例子:
SubscriptionCanceled一旦客户取消订阅,就会发出该事件。该事件的有效负载可能包括:

  • 事件 ID
  • 客户的唯一标识符
  • 相关订阅的唯一标识符
  • 取消日期
  • 可选的取消原因

值得注意的是,该事件可能属于一个有界上下文BC(设定上下文,以下或称BC或界限上下文),例如“订阅”:

// BoundedContext/Subscription/Domain/Events/SubscriptionCanceled 

 “id” : “de305d54-75b4-431b-adb2-eb6b9e546013” ,
 “userId” : “123e4567-e89b-12d3-a456-426614174000” ,
 “subscriptionId” : “f47ac10b-58cc-4372-a567-0e02b2c3d479” ,
 “canceledAt” : “2024-12-01T14:30:00 + 01:00” ,
 “cancelationReason” : null
}

是否使用外BC产生的事件?
假设有一个“计费”限界上下文BC对事件感兴趣SubscriptionCanceled— 例如,停止每月向客户计费。您可能会想监听“订阅”BC发出的事件,并在“计费”系统中进行必要的更改。

这看起来很简单,对吧?但通常,当某件事让人感觉太简单时,是时候退后一步,花点时间分析一下情况了。

从另一个业务上下文消费领域事件会直接将您的上下文与他们的上下文耦合。

  • 正如我们之前所说,耦合是邪恶的!

那么,我们该如何消费这个活动事件呢?
首先,这个领域事件甚至不应该在其上下文之外使用

无论您使用的是模块化方法(单个应用程序中的多个服务)还是分布式微服务,任何人都不应该能够直接使用您的领域事件,除非至少明确告知不允许这样做。

向外部公开领域事件会与使用它的人建立隐性契约

  • 最好的情况是:你知道消费者是谁。
  • 最坏的情况是:你不知道。

您将失去对事件的控制和管理权,因为每次更改都有可能破坏合同,从而可能破坏系统的稳定性。
在最坏的情况下,一切都可能像纸牌屋一样倒塌。

更好的方法是明确你的合同。如果你仍然想发出事件,你应该创建具有明确定义的合同的公共事件,这样你就可以坚持下去而不会失去自由。

工作原理如下:在有BC上下文的基础设施层中,您可以添加一个监听域事件的订阅者

  • 在此阶段,一切仍在您的BC上下文中发生,因此完全没问题。
  • 然后,该订阅者会对您的事件做出反应,并在公共事件总线上调度一个公共事件(例如SubscriptionCanceled)

很多事情都变了!
此公共事件专门设计为公开。其契约在首次发布时定义。这意味着有效载荷不能任意更改。如果需要修改或删除属性,您必须通知消费者。

考虑到这一点,您可以添加一个简单的属性来向消费者表明此事件是 1.0 版本,使每个人都能清楚且易于管理。

//BoundedContext/Subscription/Infrastrucuture/Events/SubscriptionCanceled 

 “domainEventVersion” : 1 ,
 “id” : “550e8400-e29b-41d4-a716-446655440000” ,
 “userId” : “123e4567-e89b-12d3-a456-426614174000” ,
 “subscriptionId” : “f47ac10b-58cc-4372-a567-0e02b2c3d479” ,
 “canceledAt” : “2024-12-01T14:30:00 + 01:00” ,
 “cancelationReason” : null
}

业务需求变更发生
现在让我们考虑一个经常发生的场景:您的业务在发展,因此您的领域事件也在发展。

例如,以前可选的取消订阅原因不再是自由格式的字符串 — — 而且它不再是可选的。
现在,它是一种取消类型,可能还附带客户的评论。

我们的领域事件的更新版本可能如下所示:

// BoundedContext/Subscription/Domain/Events/SubscriptionCanceled 

 
"id" :  "de305d54-75b4-431b-adb2-eb6b9e546013"
 
"userId" :  "123e4567-e89b-12d3-a456-426614174000"
 
"subscriptionId" :  "f47ac10b-58cc-4372-a567-0e02b2c3d479"
 
"reason" :  "太贵了" ,  // 类型的字符串版本(例如 SubscriptionCancelReasonType)
 
"comment" :  "最近的增长是一种滥用!" 
}

我们保持灵活性,但不会违反合同。

为了适应变化,同时保持我们的系统灵活性并且不违反现有合同,我们可以调整我们的基础设施。

在基础设施层中,我们将更新监听器,将新的取消类型和客户评论连接成一个字符串,格式如下:

" : "

此外,我们将借此机会将现有事件移至v1目录,以明确标明其版本。这让我们能够推出新版本的活动,同时保持与原始版本的向后兼容性。

// BoundedContext/Subscription/Infrastrucuture/Events/v1/SubscriptionCanceled 

 
"id" :  "550e8400-e29b-41d4-a716-446655440000"
 
"domainEventVersion" :  1.1 ,  // 增加 .1 表示有新属性
 
"userId" :  "123e4567-e89b-12d3-a456-426614174000"
 
"subscriptionId" :  "f47ac10b-58cc-4372-a567-0e02b2c3d479"
 
"canceledAt" :  "2024-12-01T14:30:00+01:00"
 
"cancelationReason" :  "[Too昂贵]:最近的增长是一种滥用!“ 
}

接下来,我们将创建公共活动的新版本,如下所示:

// BoundedContext/Subscription/Infrastrucuture/Events/v2/SubscriptionCanceled 

 “id” :  “9b1deb4d-72f1-4f24-9106-95e8c31b5d0b” , 
 “domainEventVersion” :  2 , 
 “userId” :  “123e4567-e89b-12d3-a456-426614174000” , 
 “subscriptionId” :  “f47ac10b-58cc-4372-a567-0e02b2c3d479” , 
 “canceledAt” :  “2024-12-01T14:30:00+01:00” , 
 “cancelationReason” :  “[太贵了]” , 
 “customerComment” :  “最近的涨幅是滥用!“ 
}

我们可以看到,每次发布都会发布两个公共事件。我们在创建新版本的合同时保留了上一个版本的合同。


如何向消费者告知新版本?
最简单的技术之一是向您的合约添加一个属性,表明该事件已被弃用,应改用最新支持的版本。

在我们的公共事件版本 1 中,我们可以简单地添加一个属性,例如deprecatedDomainEvent true。此外,我们可以包含另一个属性,例如useDomainEventVersionInstead: 2。

// BoundedContext/Subscription/Infrastructure/Events/v1/SubscriptionCanceled 

 “domainEventVersion” :  1.2 , 
 “deprecatedDomainEvent” :  true
 “useDomainEventVersionInstead” :  2 , 
 “userId” :  “123e4567-e89b-12d3-a456-426614174000” , 
 “subscriptionId” :  “f47ac10b-58cc-4372-a567-0e02b2c3d479” , 
 “canceledAt” :  “2024-12-01T14:30:00+01:00” , 
 “cancelationReason” :  “[太贵了]:最近的涨价是滥用!” 
}

您可能会想:添加这些属性不会破坏合同吗?
其实不会 — 因为在这里,我们不会破坏系统。添加属性不会带来副作用,而修改或删除可能会。

通常,允许在已经公开的事件中添加附加属性是可以接受的。

关于十进制事件版本,我同意,我在这里走捷径,但这只是为了简化帖子。如果你理解整个概念,你就会知道该怎么做:)

改善我们的公共活动事件
到目前为止提供的示例都比较基础,并未完全针对生产进行优化。
常见的最佳做法是将数据分为两个主要属性:

  • data:包含特定于事件的所有属性。
  • metadata:包含事件的所有技术属性。

这是我们活动事件的改进版本:

// BoundedContext/Subscription/Infrastructure/Events/v1/SubscriptionCanceled 

 "data" :  { 
  "userId" :  "123e4567-e89b-12d3-a456-426614174000" , 
  "subscriptionId" :  "f47ac10b-58cc-4372-a567-0e02b2c3d479" , 
  "canceledAt" :  "2024-12-01T14:30:00+01:00" , 
  "cancelationReason" :  "[太贵了] : 最近的涨价是滥用!" } 
 } , 
 “metadata” :  { 
  “id” :  “550e8400-e29b-41d4-a716-446655440000” , 
  “类型” :  “SubscriptionHasBeenCanceled” , 
  “域” :  { 
   “id” :  “de305d54-75b4-431b-adb2-eb6b9e546013” , 
   “事件版本” :  1.3 , 
   “已弃用” :  true , 
   “useInstead” :  2.1 
  } 
 } 
}

// BoundedContext/Subscription/Infrastructure/Events/v2/SubscriptionCanceled 

 “data” :  { 
  “userId” :  “123e4567-e89b-12d3-a456-426614174000” , 
  “subscriptionId” :  “f47ac10b-58cc-4372-a567-0e02b2c3d479” , 
  “canceledAt” :  “2024-12-01T14:30:00+01:00” , 
  “cancelationReason” :  “[太贵了]” , 
  “customerComment” :  “最近的涨价是滥用!” 
 } ,
 "metadata" : { 
  "id" : “9b1deb4d-72f1-4f24-9106-95e8c31b5d0b” ,
  "类型" : “SubscriptionHasBeenCanceled” ,
  "域" : { 
   "id" : “de305d54-75b4-431b-adb2-eb6b9e546013” ,
   "事件版本" : 2.1 
  } 
 } 
}

通过采用这种方法,数据将专注于业务信息,而元数据则明确定义技术细节,如版本控制、事件类型和其他标识符。这种分离提高了清晰度、可扩展性和可维护性。

关于事件版本
另外要强调一个重要的区别:不要混淆事件版本。

如果你将事件源ES纳入到事件中,事件的版本与域事件的版本不同。

  • 一个标识合同的版本,
  • 而另一个管理并发性。

在事件溯源ES上下文中,事件的版本用于确保正确的处理顺序。它有助于保持一致性并防止并发操作期间发生冲突。