day7

领域事件

何时and为什么使用领域事件

https://www.lagou.com/lgeduarticle/26269.html
领域事件的实战篇

领域事件往往需要发布到外部系统,比如发布到另一个限界上下文中,由于这样的事件需要由订阅方处理,它将对本地和远程上下文产生深远的影响。

当领域事件到达目的地后,不论是本地还是外部系统,我们通常将领域事件用于维护事件的一致性,例如聚合的其中一个原则是单个事务只允许对一个聚合实例进行修改,由此产生的其他改变。另外,领域事件还可以使远程依赖系统与本地系统保持一致,而二者解耦有助于提高双方协作服务的可伸缩性。

在这里插入图片描述
作者同时指出,采用事件的形式捕获,然后将事件发布给订阅方处理,可以达到简化系统的目的。因为这样可以消除复杂查询,且知道何时发生了什么事,限界上下文也由此知道接下来该干什么。这样原本的批量集中处理过程可以分散成许多粒度较小的处理单元。


针对官方释义,我们可以理出以下几个要点:

  1. 领域事件作为领域模型的重要部分,是领域建模的工具之一。
  2. 用来捕获领域中已经发生的事情。
  3. 并不是领域中所有发生的事情都要建模为领域事件,要忽略无业务价值的事件。
  4. 领域事件是领域专家所关心的(需要跟踪的、希望被通知的、会引起其他模型对象改变状态的)发生在领域中的一些事情。

简而言之,领域事件是用来捕获领域中发生的具有业务价值的一些事情。它的本质就是事件,不要将其复杂化。在DDD中,领域事件作为通用语言的一种,是为了清晰表述领域中产生的事件概念,帮助我们深入理解领域模型。

具体认识领域事件

举个例子:当用户在购物车点击结算时,生成待付款订单,若支付成功,则更新订单状态为已支付,扣减库存,并推送捡货通知信息到捡货中心。

在以上例子中 支付成功就是一个领域事件

  • 简单直接的方法调用:在一个事务中分别去调用状态更新方法、扣减库存方法、发送捡货通知方法。

有什么问题?

  1. 试想一下,若现在要求支付成功后,需要额外发送一条付款成功通知到微信公众号,我们怎么实现?想必我们需要额外定义发送微信通知的接口并封装参数,然后再添加对方法的调用。这种做法虽然可以解决需求的变更,但很显然不够灵活耦合性强,也违反了OCP。
  2. 将多个操作放在同一个事务中,使用事务一致性可以保证多个操作要么全部成功要么全部失败。在一个事务中处理多个操作,若其中一个操作失败,则全部失败。但是,这在业务上是不允许的。客户成功支付了,却发现订单依旧为待付款,这会导致纠纷的。
  3. 违反了聚合的一大原则:在一个事务中,只对一个聚合进行修改。在这个用例中,很明显我们在一个事务中对订单聚合和库存聚合进行了修改。

借助领域事件的力量。

  1. 解耦,可以通过发布订阅模式,发布领域事件,让订阅者自行订阅;
  2. 通过领域事件来达到最终一致性,提高系统的稳定性和性能;
  3. 事件溯源;

建模领域事件

在建模领域事件时,我们应该根据限界上下文中的通用语言来命名事件及其属性。事件的名字表明了聚合上的命令方法在执行成功之后发生的事情。有了正确的事件名之后我们需要时间戳表示事件发生的时间,Java 中可使用 java.util.Date。
在这里插入图片描述

抽象事件源

事件源应该至少包含事件发生的时间和触发事件的对象。提取IEventData接口来封装事件源:

/// <summary>
/// 定义事件源接口,所有的事件源都要实现该接口
/// </summary>
public interface IEventData{   
     /// <summary>
    /// 事件发生的时间
    /// </summary>
    DateTime EventTime { get; set; }  
    /// <summary>
    /// 触发事件的对象
    /// </summary>
    object EventSource { get; set; }
}

抽象事件处理

针对事件处理,我们提取一个IEventHandler接口:

 /// <summary>
 /// 定义事件处理器公共接口,所有的事件处理都要实现该接口
 /// </summary>
 public interface IEventHandler
 {
 
 }

事件处理要与事件源进行绑定,所以我们再来定义一个泛型接口:

 /// <summary>
 /// 泛型事件处理器接口 
 /// </summary> 
 /// <typeparam name="TEventData">
 </typeparam>
  public interface IEventHandler<TEventData> : IEventHandler where TEventData : IEventData {     /// <summary>     
  /// 事件处理器实现该方法来处理事件    
   /// </summary>     
   /// <param name="eventData"></param>     
   void HandleEvent(TEventData eventData); }

以上,我们就完成了领域事件的抽象。在代码中我们通过实现一个IEventHandler来表达领域事件的概念。

从领域模型中发布领域事件

高效的发布领域事件的方法是使用观察者模式,其可以在领域模型和外部组件间解耦。
在这里插入图片描述
领域事件的发布可以使用发布–订阅模式来实现。而比较常见的实现方式就是事件总线。

事件总线是一种集中式事件处理机制,允许不同的组件之间进行彼此通信而又不需要相互依赖,达到一种解耦的目的。Event Bus就相当于一个介于Publisher(发布方)和Subscriber(订阅方)中间的桥梁。它隔离了Publlisher和Subscriber之间的直接依赖,接管了所有事件的发布和订阅逻辑,并负责事件的中转。

这里就简要说明一下事件总线的实现的要点:

  1. 事件总线维护一个事件源与事件处理的映射字典;
  2. 通过单例模式,确保事件总线的唯一入口;
  3. 利用反射或依赖注入完成事件源与事件处理的初始化绑定;
  4. 提供统一的事件注册、取消注册和触发接口。

最终一致性

领域一致性

简单理解就是在领域中的操作要满足领域中定义的业务规则。比如你转账,并不是你余额充足就可以转账的,还要求账户的状态为非挂失、锁定状态。

回到我们的案例,当支付成功后,更新订单状态,扣减库存,并发送捡货通知。按照我们以往的做法,为了维护订单和库存的数据一致性,我们将这三个操作放到一个应用服务去做(因为应用服务管理事务),事务的一致性可以保证要么全部成功要么全部失败。但是,试想一下,客户支付成功后,订单依旧为待付款状态,这会引起纠纷。另外,由于库存没有及时扣减,很可能会导致库存超卖。怎么办呢?
将事务拆解,使用领域事件来达到最终一致性。

分析一下,针对我们案例,我们发现一个用例需要修改多个聚合根的情况,并且不同的聚合根还处于不同的限界上下文中。其中订单和库存均为聚合根,分别属于订单系统和库存系统。我们可以这样做:

  1. 在订单所在的聚合根中更新订单支付状态,并发布“订单成功支付”的领域事件;
  2. 然后库存系统订阅并处理库存扣减逻辑;
  3. 通知系统订阅并处理捡货通知。
    通过这种方式,我们即保证了聚合的原则,又保证了数据的最终一致性。

向远程限界上下文发布领域事件

存在多种消息中间件,ActiveMQ、RabbitMQ等,我们也可以通过 RESTful 自己实现一套消息机制。对于消息的最终一致性,我们至少需要在两种存储介质之间保持最终一致性:领域模型所使用的持久化和消息设施所使用的持久化存储。方法有:

  1. 共享持久化存储。性能高,缺点就是数据集聚
  2. 全局事务控制。可以分开存储,缺点是需要额外支持
  3. 创建事件存储

自治服务和系统

自治服务表示一个设计良好的业务服务,可以避免对RPC的使用,带来更高程度的独立性,简化系统间的依赖。在与远程系统交互时,客户方可以通过异步的消息来达到更高的独立性——自治性。

容许时延

数据不同步可能造成负面影响,有些业务服务可能需要更高的吞吐量,此时我们需要好好考虑最大容许时延,系统架构应该满足该需求。对于自治服务和支持它们的消息设施来说,应该在可用性和可伸缩性上下功夫。

转发存储事件的架构风格

一旦领域事件被保存在了事件存储中,我们可以对这些时间进行转发以通知其他系统。这种转发事件架构一种是基于 REST 资源一种是基于消息中间件。
在这里插入图片描述

模块

Module,即模块,是指提供特定功能的相对独立的单元。提到模块,你肯定就会想到模块化设计思想,也就是功能的分解和组合。对于简单问题,可以直接构建单一模块的程序。而对于复杂问题,则可以先创建若干个较小的模块,然后将它们组装、链接在一起,从而构成复杂的软件系统。
在DDD中,模块的用途也是如此,通过分解领域模型为不同的模块,以降低领域模型的复杂性,提高领域模型的可读性。

对于顾客来说,一般需要维护顾客的个人信息、收货地址、支付方式。这些信息是紧密相关的,不可独立存在。我们可以抽象出三个简单的聚合Customer、AddressBook和 Wallet。那这些类该如何存放呢?是为每一个聚合创建一个文件夹存放还是放在同一个文件夹?

这三个聚合就是一个模块,一个客户模块。通过定义一个Customer文件夹,来将相关联的领域对象组合起来。而这个文件夹体现在C#中就是命名空间的概念。

通过模块完成设计

DDD中模型中的模块表示一个命名的容器,用于存放领域中内聚在一起的类,将类放在不同模块中目的在于松耦合。模块是通用语言的重要组成部分。设计模块的一些原则:
在这里插入图片描述
软件的当前进展正迈向一个更高层次的模块化,这种趋势将那些松耦合的、但具有逻辑内聚性的软件分成具有版本号的部署单元。它们的版本号和依赖关系都可以通过捆绑包/模块予以管理。比如DDD中的模块划分将领域模型中存在松耦合关系的各个部分封装到不同的捆绑包中是有好处的。

模块的基本命名规范

Java 中模块都是以一种层级形式,通常以顶级域名开头,然后是公司、组织名。

领域模型命名规范

在这里插入图片描述

接下来模块名中添加另一层,用于定位领域中某个特定的模块:
在这里插入图片描述
这种命名规范与传统的分层架构和六边形架构是兼容的。以上 domain 部分可能不直接包含实际的接口、类,而是作为更底层的模块的容器,以下是 domain 的下一层:
在这里插入图片描述

先考虑模块,再是限界上下文

我们应该仔细考虑对待,何时对领域模型进行分离、何时将领域模型建模成一个整体。我们首先可以将它们放在一起,用模块而不是限界上下文来进行划分。但这不意味着限制创建限界上下文,限界上下文不是用来代替模块的,使用模块的目的在于组织那些内聚在一起的领域对象,对于那些内聚性不强的领域对象,应该划分在不同模块。

但不要将模块与子域和限界上下文混淆。在复杂的领域模型中,为了对领域模型中进行准确建模,需要将领域模型拆分成多个子域,每个子域对应一个或多个限界上下文。在限界上下文中,可以将限界上下文中具体的领域概念分解成不同的模块。所以,从子域到限界上下文再到模块,应该是依次包含关系。
在这里插入图片描述

聚合(重要)

聚合,最初是UML类图中的概念,表示一种强的关联关系,是一种整体与部分的关系,且部分能够离开整体而独立存在,如车和轮胎。在DDD中,聚合也可以用来表示整体与部分的关系,但不再强调部分与整体的独立性。聚合是将相关联的领域对象进行显示分组,来表达整体的概念(也可以是单一的领域对象)。

比如将表示订单与订单项的领域对象进行组合,来表达领域中订单这个整体概念。我们知道,领域模型是由一系列反映问题域概念的领域对象(实体和值对像)组成,聚合正是应用在领域对象之上。如果要正确应用聚合,我们首先得理清领域对象间的关联关系

不变性和一致性边界

这里的不变性指的是业务规则,该规则应该始终保持一致。一致性边界的意思是单个事务的修改范围。 原则上我们应该在一个事务里只修改一个聚合。

聚合的主要作用

  1. 主要为了维护对象生命周期内的完整性。

关于聚合的生命周期,在初期的时候我们使用工厂 Factory 来创建聚合或者复杂对象,在生命周期的中期末期我们使用资源库 Repository 来提供检索对象或者持久化对象。虽然工厂和资源库本身不属于领域,但我们在使用聚合的过程当中,可以更容易的操作聚合。
2. 通过定义清晰的所属关系和边界,在这个边界中的模型元素在生命周期内必须维护一致性,通俗的讲就是业务规则。

聚合就是一组相关对象的集合,我们将他作为数据修改的单元。通俗的说,比如以往我们在一个事务中需要修改三张表,那这三张表映射出的实体和值对象就可以组成一个聚合。

聚合特征:

  1. 根实体具有全局的标识,它最终负责检查固定规则。
  2. 边界内的实体具有本地标识,这些标识只在聚合内部才是唯一的。
  3. 聚合外部的对象不能引用根实体之外的聚合内部对象。根实体可以将内部实体的引用传递给它们,但只能临时使用。或者传递一个值对象的副本出去,而不用关心它发生了什么变化。
  4. 只有根实体才能直接通过数据库直接查询,其他对象必须通过遍历关联来发现。(意思是根实体可以从资源库中的某个方法获取,但是聚合内的其他对象,资源库不提供直接的访问方法,而是在资源库内生成聚合的时候,直接添加进聚合)
  5. 根实体可以保持其他根实体的引用。
  6. 删除操作,比如删除聚合边界内的所有对象。
  7. 当对聚合边界内的任何对象做了修改时,整个聚合的所有固定规则都必须被满足。

聚合的设计

根据上面的阐述:聚合不仅仅是简单的对象组合,其主要的目的是用来封装业务和保证聚合内领域对象的数据一致性。

那聚合设计时要遵循怎样的原则呢?

  1. 遵循领域不变性
  2. 聚合内实现事务一致性,聚合外实现最终一致性
    一个事物一次仅更新一个聚合。当业务用例要跨域多个聚合时,使用领域事件进行事务拆分,实现最终一致性。
    基于业务用例而非现实生活场景
  3. 避免成为集合或容器
    对聚合的一大误解就是,把聚合当作领域对象的集合或容器。当发现这个征兆时,你要考虑你聚合是否需要改造。
    不仅仅是HAS-A关系
  4. 聚合不是简单的包含关系,要确定包含的领域对象是否为了满足某个行为或不变性。
    在这里插入图片描述
  5. 不要基于用户界面设计聚合
  6. 聚合不应该根据UI界面的需求进行设计。而应该通过加载多个聚合数据映射到UI展示需要的视图模型中。
  7. 创建具有唯一标识的聚合根
    聚合根作为聚合的网关,通过聚合根完成聚合中领域对象的持久化和检索。
  8. 优先使用值对象
    聚合根内的其他领域对象优先设计成值对象
  9. 使用ID关联,而非对象引用
    对象引用不仅会导致聚合边界的模糊,而且会导致延迟加载的问题。
  10. 通过唯一标识引用其他聚合
    聚合边界之外的对象不能持有聚合内部对象的引用;聚合内部的领域对象可以持有其他聚合根的引用。
    在这里插入图片描述
  11. 避免在聚合内使用依赖注入
    对于依赖的对象,我们应该在调用聚合方法之前查找获取并通过参数传递。可以在应用服务中通过依赖注入资源库或领域服务获取聚合依赖的对象,然后传入聚合。
  12. 使用小聚合
    通常,较小的聚合使系统更快且更可靠,因为更少的数据传输以及更少的并发冲突。
    在这里插入图片描述
    大聚合的缺点
  • 大聚合会影响性能:聚合的每一个成员都增加了从数据库加载和保存到数据库的数据量,直接影响到性能。
  • 大聚合容易导致并发冲突:大的聚合可能有多个职责,意味着它涉及到多个业务用例。我们可以量化一个聚合涉及到的业务用例数,数量越大,设计的聚合边界越应该被质疑,尝试将其细化拆解成小聚合。
  • 大聚合扩展性差:聚合的设计要关注可扩展性。大聚合可能会跨越多个数据库表或文档,这就在数据库级别形成了耦合,它将阻碍你对数据子集进行数据迁移。同时,在业务改变时,大聚合不能很好的适应变化。

聚合使用的规则

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值