前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >DDD 在 Go 中的落地 | 如何在业务中使用领域事件?

DDD 在 Go 中的落地 | 如何在业务中使用领域事件?

作者头像
用户6543014
发布2023-03-02 14:44:58
1.4K0
发布2023-03-02 14:44:58
举报
文章被收录于专栏:CU技术社区CU技术社区

在实际的建模中,一般会通过事件风暴的形式,来发现、提取领域事件。简单来说,就是领域专家和项目团队成员通过头脑风暴的形式,来识别出领域中那些已经发生了的,并且会对业务流程产生重要影响的事件。

作者 | 于振

责编 | 韩楠

朋友,你好,今天我想与你聊聊如何在业务中正确使用领域事件,通过前面几篇文章的分享,相信你对 DDD 在 Go 中如何落地已经有了一定的了解。这里,我将几篇文章的链接贴在下面,如果你对哪里还不太清楚,方便你回过头去再看一看:

严格意义上来讲,领域事件是属于领域层的内容,很多书本或文章里,都会将其跟值对象、实体等领域对象放在一起说。

但是在本系列专题文章中,我是将领域事件的介绍放在了最后再来说的。

这么做的原因是由于,领域事件虽然是在领域层进行定义的,但是事件的发布是在基础设施层,而事件的消费又是在应用层/领域层完成的。当我们对什么是应用服务,什么是基础设施有了一定的了解后,再来看领域事件的处理,就简单的多了。

好了,我们言归正传。

领域事件是在 Evans 的《领域驱动设计》一书出版之后才提出的,因此,在这本书中你并不能找到关于领域事件的相关介绍。如果你想找一些参考资料,可以看一下 Vaughn Vernon 的《实现领域驱动设计》,这本书中,作者对领域事件做了非常精简的定义:

领域专家所关心的发生在领域中的一些事件。

在这个简短的定义中,有两个点需要特别注意,一个是“领域中的”,另一个是“领域专家关心的”。领域中发生的活动可以建模成一系列的离散事件,但只有那些对领域专家是重要的事件才被认为是领域事件。

在实际的建模中,一般会通过事件风暴的形式,来发现、提取领域事件。简单来说,就是领域专家和项目团队成员通过头脑风暴的形式,来识别出领域中那些已经发生了的,并且会对业务流程产生重要影响的事件。

比如考虑在线商城中购物的场景,一个典型流程是:

• 用户提交订单,成功后产生“订单已创建”事件。

• 库存服务在收到“订单已创建”这个事件后,对相应产品的库存进行锁定并扣减。

• 之后,用户对订单进行支付,成功后产生“订单已支付”事件。

• 售后服务在收到“订单已支付”这个事件后,对商品进行打包,同时产生“订单打包完毕”事件。

• 物流系统在收到“订单打包完毕”事件后,安排相应的物流进行发货处理。

在这个流程中,每一次领域事件的产生都会带来实体(Order)状态的变更和迁移,并且推动了业务流程的继续执行。

同时,也可以看到,参与到整个事件通知过程中的,除了事件的发布者和事件本身,还需要有事件的订阅者,这有点类似于设计模式中的观察者模式。

因此,在本文介绍领域事件的处理时,也会从这三者出发,站在不同的视角,来说明领域事件如何跟既有的一些概念融合在一起。

01⎪ 事件的定义

领域事件表示的是在领域中已经发生的对业务有价值的事实,为了更好地表达这个领域概念,我们就先从领域事件的命名说起。

▶︎ 使用过去完成时对事件命名

既然是领域中的概念,所以对领域事件的定义应该放在 domain 包内,享有与值对象、实体同样的待遇:

同时,在事件的命名上,应当遵循过去完成时的命名方式,比如,订单已提交:OrderCreated;订单已支付:OrderPaid,等等。

确定了位置和命名,下一个问题就是确定在事件中,应该包含哪些属性?

▶︎ 包含必要的属性

首先,领域事件在建模时,一些通用属性是必须要有的,比如事件的id、事件产生的时间。其中,事件的id要能唯一的标识这个事件。

因为这两个属性比较重要,我们用一个接口来表示通用的领域事件:

注意这里的 Id() string 方法返回的并不是某个领域实体的唯一标识,而是当前领域事件的唯一标识。

另外,领域事件的产生,一般是由于聚合状态的变更引起的,因此,在领域事件上,还应该包含对应的聚合根id。

因为我们不太确定聚合根id的类型,所以如果将一个 AggregateId() interface{} 方法放到 DomainEvent 上是不太合适的,毕竟使用起来不太方便。

但是,我们可以针对每个聚合根定义一个对应的 Event 接口,比如对于订单,可以定义下面的订单事件接口:

对于产品,定义对应的产品事件接口:

之后,在这两个聚合根上产生的所有事件都可以通过实现对应接口的方式来定义,比如创建订单:

通过不同的接口,我们也可以方便地识别出事件是来自于哪个聚合的,对于某些监听者,可能只关心某个聚合根上的事件,这就变得很有用了。

在这个示例中,所有的属性都采用了大驼峰法。

在更严格的意义上来看,事件应该是具有不变性的,毕竟已经发生了的事实是不容许更改的,因此,事件跟值对象有一定的相似性,而值对象里的属性使用的是小驼峰法,这里为何不同?

主要原因在于Go语言的特性,这是一种妥协的写法。

对于事件来说,我们大概率是需要将其序列化为json字符串,然后通过消息队列广播出去的。如果采用小驼峰法,这个序列化的操作就会变得比较麻烦,所以这里妥协使用了大驼峰法。

至此,项目中所有的领域事件看起来是具有类似下面这种继承关系的集合:

▶︎ 携带适当的上下文信息

最后,在领域事件中还应该包含事件发生时的上下文信息。

例如,在一个 ProductInventoryChanged 事件中,就应该同时包含变更之前的状态和变更之后的状态:

适当的上下文,有助于消费者构建成一个自治的系统。通俗点说,就是消费者根据收到的消息,在不需要访问其他上下文的情况下,就可以自己完成后续的业务流程。

比如上面的 ProductInventoryChanged 事件,如果只携带了 AggregateId 属性:

虽然传输的数据少了,但是事件的消费者,就不得不在收到该事件后通过 RPC/HTTP 的方式,来访问 ProductInventory 上下文,一次来获取最新的库存是多少。

我们知道,这种远程调用其实并不能保证一定会成功的,因此,避免对 RPC/HTTP 的使用,可以大大简化系统之间的依赖,提高系统的稳定性。

事件定义好了,下一步就是在合适的时机进行发布。

02⎪ 事件的发布

领域事件一般在聚合根中生成,这里的主要问题是如何将领域层定义好的事件发布出去。

发布这个动作本身是偏技术的,所以,我们的原则还是业务逻辑能跟技术细节进行解耦。

接下来,会讨论几种不同的实现方式,并给出最推荐的形式。

▶︎ 发布事件的几种实现方式

首先,我们定义一个发布接口,用来表示对发布能力的技术抽象:

1、EventPublisher 作为参数,传递给聚合根上需要发布领域事件的方法。

比如在 Order 上有一个修改产品数量的方法:

这种写法,我们在前面谈到领域服务的时候也提到过,其最大的问题是对接口的污染,多出来的这个参数不仅给调用方带来不便,对领域的职责也是一个负担,因为其本不应该关心这些。

2、采用静态方法发布领域事件。

为了避免在方法参数中传递 EventPublisher,人们又提出了另外一种方法,即使用静态方法。

在 Java 里,静态方法可以直接通过类来访问,比如:

在 Go 里虽然没有静态方法,但是我们可以通过 var eventPublisher EventPublisher 的形式,来模拟类似静态方法的调用形式:

之后在聚合根中直接使用:

我个人而言,不是太喜欢这种写法,首先在使用之前需要调用Init函数,我们可能并非每次都能清楚地记得去做这件事。

其次,这种方式虽然避免了接口的污染,但是又带来了新的问题,即,如果想对 ChangeProductCnt 方法进行测试,就不那么容易了。

3、实体中不直接发布领域事件,而是返回。

如上所示,领域实体不承担发布功能,那相应的发布逻辑就需要放到领域服务或应用服务中。

针对这种方式,我在网上看到过一篇很久之前的文章,可以参考这个链接,不过大家貌似并不能就这种方式完全达成一致。

在我看来,返回领域事件的主要问题是与业务概念不太契合,但在代码处理上比较清晰,也更接近我们日常的开发习惯。同时,如果我们希望对事件发布的时机有更多的控制,比如我们希望在业务数据持久化后再发布领域事件,这种需求就很好实现了。

4、在实体中临时保存领域事件,在仓储中进行发布。

最后一种方式是在聚合根中临时保存领域事件,有点类似上面提到的返回领域事件的方式,但是稍微做了改进。

我们在 Order 这个实体中加入一个 OrderEvent 的 slice:

在业务方法里通过调用 RaiseEvent 来发布对应的领域事件,发布的领域事件会暂时存放在 events 切片中。

领域对象在修改完毕后,我们需要在仓储中对其进行持久化,同时,我们也在这里对领域事件进行真正的发布,在发布完毕后,还要将领域事件清空。

这样实现的话,Order 实体因为不跟任何外部组件耦合,测试比较容易。

其次,EventPublisher 和 OrderRepository 都是具体的技术实现,代码也都放在 infra 包下,因此它们之间进行引用是合理的。

最后,我们来对上面的几种实现方式进行一下总结:

• 对于第一种和第二种方式,坚决不要使用。

• 对于第三种返回领域事件的形式,可以选择性使用,如果你觉得最后一种方式太过于复杂的话。

• 我们推荐在实际业务中使用最后一种方式,虽然看起来复杂一些,但是我们可以对关键的逻辑进行封装,从而减轻使用的成本。具体的我们会在最后一篇文章中进行详细的介绍。

▶︎ 通过事件表保证原子性

到这里,大部分对事务没有特别严格要求的场景,就已经得到满足了。但是对于严格要求的场景呢?如何保证消息的发布与领域对象的存储这两个流程是原子的呢?

我们首先想到的可能是使用分布式事务,但是这种方式不仅实现起来复杂,性能也不高。

在《微服务架构设计模式》一书中,提到了另外一个思路,即事件表的形式。

简单来说,在 Repository 中不再对事件直接进行发布,而是将事件同聚合根一起存储到同一个数据库里,通过数据库的本地事务即可实现这一步的原子性。

之后,利用一个异步任务,来读取数据库里存储的所有未发送事件,在发送成功后将对应的事件从数据库中删除。

我们可以用代码简单表示如下:

当然,这种方式也不是完美的,异步任务读取事件表并进行发送,这仍然是两个步骤,这个过程依然需要保证原子性。

貌似事情又回到了原点。

解决方案是将消费方做成幂等的,即使不使用事件表,这也同样重要。

异步任务读取到未发送事件时,先发送事件,成功后将事件删除。如果事件发送成功了,但是删除的时候失败了,可以再次进行重试,因为消费方幂等,所以重试并不会带来问题。

同时,为了不给数据库带来太大的负担,定时任务的时间间隔不应设置的过小,其更多的应该是一种兜底策略。

所以,为了能够及时地将事件发布出去,我们可以在事务提交后触发这个流程,在某些框架中,通常可以在 Middleware 中进行触发操作。

03⎪ 事件的消费

▶︎ 在应用服务中完成对事件的消费

对于消费者,事件可以理解成是一种特殊的 Command,与应用层作为外部请求的入口一样,事件的消费入口同样是在应用层。

既然如此,我们就可以在 app 包下定义一个 DomainEventApp:

DomainEventApp 里的每个方法,都是对特定某个领域事件的处理。

方法的参数一般是 Context 和对应监听的领域事件,而返回值只是一个error,用来标识当前处理是否成功。

▶︎ 向领域事件注册订阅方

在整个领域事件处理流程中,存在两种类型的消费,一种是本地消费,另一种是远程消费。

对于本地消费者,就需要先注册一个监听,表示其对哪类的事件感兴趣。

在 DDDCore 这个库中,提供了便捷的 RegisterSyncEventSubscriber 方法,我们可以在 DomainEventApp 实例化的时候,对本地消费者进行注册:

而对于远程消费,一般就是通过消息队列进行实现。

这里需要注意的是, 消息队列通常能够保证的是“至少一次投递”,这也就要求我们在进行消费时必须保证消费的幂等性。

幂等性消费有很多种实现方式,比较通用的办法是记录下当前已经消费了的消息的唯一id,下次再收到该类型的消息时,先根据唯一id检查是否已经消费过。

我们需要一个 handler 函数作为入口,在这个 handler 里主要的工作,是对消息进行解码并进行基本校验。之后,调用 DomainEventApp 里的相关方法,来完成具体的逻辑:

04⎪ 结语

在这篇文章中,我为你介绍了领域事件相关的概念,并着重说明了事件在定义、发布、消费过程中的注意事项。这里,我用脑图的形式,将这些内容汇总如下:

这里的内容还是比较多的,但是也不需要死记硬背。当我们对领域事件的本质理解得比较深刻后,在实现的时候只要多想一想是不是满足了那些本质特征,就自然可以给出答案。

那领域事件的特征是什么呢?下面几条希望你可以结合自己的经验加以记忆:

• 领域事件代表了领域里的某个概念;

• 领域事件是已经发生了的事实;

• 既然是已经发生的,那么领域事件就是不可变的;

• 最后,领域事件基于某个条件而触发,触发后还会导致进一步的状态变更和流程流转。

好了,今天对领域事件的介绍就到这里。在下一篇文章中,我们会结合前面这些内容,在应用架构的层次来看下如何组织对DDD的实现。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-02-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 SACC开源架构 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档