这已经不止一次发生在我身上,我接手了一些采用DDD方法的项目中,最让我头疼的问题是:紧密耦合。
首先,我尝试用AI生成图片 🤔 对这个结果并不满意。
更糟糕的是,我发现这种情况:尤其是在Medium上,一些帖子教你如何通过做领域驱动设计(DDD)和使用领域事件来实现软件系统的解耦——但随后却把这些事件暴露给所有人。
结果呢?这就和根本不使用事件一样,没有一点效果。
领域事件必须保密。
耦合是你所有麻烦的源头。它会使事情随着时间的推移变得越来越困难,甚至在某些情况下变得不可能。
耦合导致了你的压力以及技术团队的低效率。
总之,耦合就是恶魔,必须被消灭!
我们可以列出很多好处,但特意突出几个关键点。
- 团队之间的协同工作:
DDD促使我们(技术人员)避免将所有的技术约束强加给非技术人员。
它鼓励我们理解业务,合作,并与业务专家一起建模。 - 减少复杂性和过度工程化:
通过专注于核心领域,我们将大部分精力集中在最关键的部分——这些部分为企业带来最大价值的部分。
技术只是实现目标的一种手段,而不是出发点。我们先考虑“业务”,而不是立即投身于微服务、Docker、框架等。 - 改进沟通:
通用语言: 技术人员和非技术人员使用相同的语言——我们的业务语言。
无需再将技术术语翻译成业务术语,反之亦然。这减少了认知负担和误解。 - 更大的灵活性和可扩展性:
通过业务建模,我们为自己开辟了新的机会,特别是在业务发展时尤为重要。
今天的事实可能明天就不再适用。
这种分离允许系统随着业务发展而演变,而无需拆解所有东西——至少不会打乱整个设置。 - 降低风险:
不再开发多余的特性。
相反,团队(包括技术人员和非技术人员)拥有清晰的愿景,确定优先级,并理解所面临的风险。
领域事件表示我们在业务中发生的某个无可否认的事实。其命名规范通常使用过去时,并通常带有唯一的标识符(如 UUID、ULID 等)。它通常包含一些属性,不过这并不是强制性的。
例子:
SubscriptionCanceled
可能会在客户取消订阅时被发出。
事件的有效载荷可能包括:
- 事件ID
- 客户的唯一标识
- 相关订阅的唯一标识符
- 取消时间
- 可选的取消理由
值得注意的是,此事件可能属于“订阅”这样一个Bounded Context。
// 订阅取消事件
{
"id": "de305d54-75b4-431b-adb2-eb6b9e546013",
"userId": "123e4567-e89b-12d3-a456-426614174000",
"subscriptionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"取消时间": "2024-12-01T14:30:00+01:00",
"取消原因": null,
}
消费由外部上下文产生的事件吗?
让我们想象一个对 SubscriptionCanceled
事件感兴趣的 “计费” 有界上下文——例如,停止每月向客户计费。
诱惑可能是订阅有界上下文发出的事件时进行监听,并根据需要在计费系统中进行调整。
这听起来很简单,对吧?很多时候,当某件事感觉太简单时,这时不妨退一步分析情况。
消费来自另一个业务领域的领域事件会直接把你的业务环境和他们的绑在一起。
正如我们之前提到的,耦合简直就是个大麻烦!
首先,这个领域事件根本就不应该在它所在上下文之外被使用!
不论是采用modulith方法(多个服务在一个单一应用内)还是分布式微服务,至少也要明确告诉他们这是不允许的。
将一个领域事件暴露到外部,实际上与任何消费它的方建立了隐含契约。
- 最好的情况是: 你知道你的消费者是谁。
- 最坏的情况是: 你不知道具体是谁。
你将失去对你活动的控制和管理,因为每一次改动都可能打破合同,从而使系统变得不稳定。在最糟糕的情况下,一切都可能像纸牌屋一样坍塌。
更好的做法是使你的合同更加明确。如果你想继续发出事件,你应该创建公开事件,并定义一个明确的协议,这样你就可以在不失去灵活性的情况下遵守它。
这可以这样实现:
在你的基础设施层中,你可以添加一个监听器来监听领域事件。
在这个阶段,所有事情都还在你的领域上下文中进行,所以完全没问题。
这个订阅者接着会对您的事件有所反应,并在公共消息总线上发布一个公共事件——例如,SubscriptionCanceled
。
等等,慢着,啥都没变吧?
实际上,变化可真不少!
这个公开活动特意设计为公开。它的条款在首次发布时就被定义了。
这意味着内容不能随意更改。如果需要修改或移除特性,你必须通知所有受影响的用户。
考虑到这一点,你可以简单地添加一个属性来表明这个事件是版本 1.0,使之清晰易懂且管理起来更方便。
// BoundedContext/订阅/基础设施/事件/订阅取消
{
"domainEventVersion": 1,
"id": "550e8400-e29b-41d4-a716-446655440000",
"userId": "123e4567-e89b-12d3-a456-426614174000",
"subscriptionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"取消时间": "2024-12-01T14:30:00+01:00",
"取消原因": 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": "太贵了", // String version of a Type (ex SubscriptionCancelReasonType)
"comment": "最近的涨价简直是滥用价格!"
}
我们保持灵活性,但不违背合同精神。
为了适应变化并保持系统灵活性,同时不影响现有合同,我们可以调整我们的架构。
在 基础设施层,我们将更新监听器程序,将 取消类型 与客户评论合并成一个单个字符串,格式如下:“新的取消类型+客户评论”。
取消原因类型 : 可选客户意见
此外,我们将借此机会将当前的事件移到 **v1**
目录下,以清晰地表明其版本信息。这为我们引入新的事件版本做好了准备,同时保持与原版本的向后兼容。
// BoundedContext/Subscription/Infrastrucuture/Events/v1/SubscriptionCanceled
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"domainEventVersion": 1.1, // 增加0.1表示有新的属性
"userId": "123e4567-e89b-12d3-a456-426614174000",
"subscriptionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"canceledAt": "2024-12-01T14:30:00+01:00",
"cancelationReason": "[太贵了]:最近涨价简直是滥用!",
"subscriptionId": "订阅ID"
}
接下来,我们将创建我们公共活动的新版本,它看起来会是这样:
// BoundedContext/Subscription/基础设施/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": "废弃的领域事件",
"useDomainEventVersionInstead": "请使用领域事件版本2",
"userId": "123e4567-e89b-12d3-a456-426614174000",
"subscriptionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"canceledAt": "2024-12-01T14:30:00+01:00",
"cancelationReason": "取消原因:[太贵了]:最近的涨价简直是胡作非为!",
}
你可能在想:添加这些属性不会破坏契约吗?
其实并不会——因为在这里,我们并没有影响系统正常运作。增加属性不会产生副作用,而修改或移除则可能会。
一般情况下,已经公开的事件中可以包含额外的属性。
关于小数部分的事件版本,我同意,这里我简化了一些,只是为了简化这篇帖子。如果你懂整个概念,你就会知道该怎么做 :)
(如果懂了整个概念,你就会知道该怎么做 :))
改进我们的公共活动到目前为止提供的例子相对基础,还没有经过生产优化。
一个常见的最佳做法是将数据分成两个主要部分。
**数据**
:包含事件特有的属性。**元数据**
:包含事件技术方面的属性。
这里是我们优化后的活动
{
"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",
"type": "订阅取消",
"domain": {
"id": "de305d54-75b4-431b-adb2-eb6b9e546013",
"eventVersion": 1.3,
"deprecated": true,
"useInstead": "建议使用2.1"
}
}
}
// 上下文边界/订阅/基础设施/事件/v2/订阅取消
{
"data": {
"userId": "123e4567-e89b-12d3-a456-426614174000",
"subscriptionId": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"canceledAt": "2024-12-01T14:30:00+01:00",
"取消理由": "[太贵了点]",
"customerComment": "最近的涨价真是太过分了!"
},
"metadata": {
"id": "9b1deb4d-72f1-4f24-9106-95e8c31b5d0b",
"type": "订阅取消",
"domain": {
"id": "de305d54-75b4-431b-adb2-eb6b9e546013",
"事件版本号": 2.1
}
}
}
通过采用这种方法,数据专注于业务信息,而元数据则清晰地定义了版本信息、事件类型信息和其他标识符等技术属性。这种分离提高了清晰度、扩展性和可维护性。
事件的不同版本一个小的注释,强调一个重要的不同点:不要混淆 事件版本。
如果你在事件中引入了事件溯源,事件的版本和领域模型中的事件版本是不同的。一个标识了合约的版本号,而另一个管理并发问题。
在事件溯源的背景下,事件版本用于确保处理顺序的正确性。它有助于保持一致性并防止在并发操作期间出现冲突。
如果你对这个话题感兴趣,以后我可以在另一篇文章里再深入讲讲,怎么样? :)
如果你对软件耦合感兴趣,可以看看这里的真实故事:https://medium.com/yield-studio/a-simple-decision-to-save-you-from-one-of-your-worst-nightmares-5e995d50e8c4?source=friends_link&sk=a7ccf01ea0564510cbcc136eeffc470b
共同學習,寫下你的評論
評論加載中...
作者其他優(yōu)質文章