zoukankan      html  css  js  c++  java
  • IDDD 实现领域驱动设计-CQRS(命令查询职责分离)和 EDA(事件驱动架构)

    上一篇:《IDDD 实现领域驱动设计-SOA、REST 和六边形架构

    阅读目录:

    1. CQRS-命令查询职责分离

    2. EDA-事件驱动架构

      1. Domin Event-领域事件

      2. Long-Running Process(Saga)-长时处理过程

      3. Event Sourcing-事件溯源

    3. CQRS Journey-微软示例项目

    4. ENode-netfocus 实践项目

    存在即是理由,每一种架构的产生都会有一种特定的场景,或者解决某一种实际应用问题,经验的累积促成了某一种架构的产生。

    1. CQRS-命令查询职责分离

    说明:本图摘自 MSDN

    CQRS(Command & Query Responsibility Segregation)命令查询职责分离,和 REST 同属于架构风格,如果单纯理解 CQRS,是比较容易的,另一种方式解释就是,一个方法要么是执行某种动作的命令,要么是返回数据的查询,命令的体现是对系统状态的修改,而查询则不会,职责的分离更加有利于领域模型的提炼,系统的灵活性和可扩展性也得到进一步加强。

    为什么要进行命令和查询职责分离?

    如果你有时间,可以先阅读下上面几篇博文及相关评论。

    我们都知道 Repository 的职责就是管理聚合根(Aggregate)对象,一般是一一对应关系,领域层中的业务逻辑要对某种聚合根对象进行操作,必须要通过 Repository,而应用层接受用户请求获取数据对象显示,也必须要通过 Repository 进行聚合根对象转换,这个一般没有涉及到领域业务操作,仅仅只是获取聚合根对象数据。领域层中的业务逻辑要求 Repository 实现对聚合根状态的管理,所以我们一般会在领域层 IRepository 接口中定义 Add、Update、GetById 等方法,然后在基础设施层中的 Repository 进行实现,而来自应用层的要求,需要获取聚合根对象数据,所以在 Repository 中还需要添加一些 GetList 等操作,而根据 IRepository 的接口契约,返回的类型必须是聚合根,而在这种场景中,是不需要获取聚合根对象的,只需要获取数据(DTO)就可以了。。。

    我大致列一下上面描述中,所出现的一系列问题:

    1. Repository 职责变得飘忽不定。
    2. IRepository 会被污染,导致的结果是领域层也会被污染。
    3. Repository 会出现本不应该出现的 DTO 概念。
    4. Repository 会被大量 GetList 操作所吞没。
    5. Repository 最后会变得“人不像人,鬼不像鬼”。

    如果你带着这些问题去理解 CQRS,就会有这样的感慨:“天哪,这简直就是老天派下的一个救星啊!”。

    回到一开始的那张图上,看起来感觉很简单的样子,来自用户 UI 的请求分为 Query(查询)和 Command(命令),这些请求操作都会被 Service Interfaces(服务接口,只是一个统称)接收,然后再进行分发处理,对于命令操作会更新 Update Data store,因为读与写分离,为了保持数据的一致性,我们还需要把数据更新应用到 Read Data store。对于一般的应用系统来说,查询会占很大的比重,因为读与写分离了,所以我们可以针对查询进行进一步性能优化,而且还可以保持查询的灵活性和独立性,这种方式在应对大型业务系统来说是非常重要的,从这种层面上来说,CQRS 不用于 DDD 架构好像也是可以的,因为它是一种风格,并不局限于一种架构实现,所以你可以把它有价值的东西进行提炼,应用到合适的一个架构系统中也是可以的。

    如果 CQRS 中包含有 Domain(领域)的概念,会是怎样的一种情形呢?

    说明:本图摘自 AxonFramework

    上面图中包含有很多的概念,但本质是和第一张图是一样的,只不过在其基础上进行了扩展和延伸,先列举一下所涉及的概念:

    • Command Bus(命令总线):图中没有,应该放在 Command Handler 之前,可以看作是 Command 发布者。
    • Command Handler(命令处理器):处理来自 Command Bus 分发的请求,可以看作是 Command 订阅者、处理者。
    • Event Bus(事件总线):一般在 Command Handler 完成之后,可以看作是 Event 发布者。
    • Event Handler(事件处理器):处理来自 Event Bus 分发的请求,可以看作是 Event 订阅者、处理者。
    • Event Store(事件存储):对应概念 Event Sourcing(事件溯源),可以用于事件回放处理,还原指定对象状态。

    上面有些是 EDA(事件驱动架构)中的概念,这个在后面会有详细说明,我简单描述一下处理流程,首先抽离两个重要概念:Command(命令)和 Event(事件),Command 是一种命令的语气(本身就是命令的意思,呵呵),它的效果就是对某种对象状态的修改,Command Bus 收集来自 UI 的 Command 命令,并根据具体命令分发给具体的 Command Handler 进行处理,这时候就会产生一些领域操作,并对相应的领域对象进行修改,Command Handler 只是修改操作,并不会涉及到修改之后的操作(比如保存、事件发布等),Command Handler 完成之后并不表示这个 Command 命令就此结束,它需要把接下来的操作交给 Event Bus(完成之后的操作),并分发给相应的 Event Handler 订阅者进行处理,一般是数据保存、事件存储等。

    我们来看 IDDD 中的一段代码(P126):

    public void commitBacklogItemToSprint(
            String aTenantId, String aBacklogItemId, String aSprintId) {
    
        TenantId tenantId = new TenantId(aTenantId);
    
        BacklogItem backlogItem = backlogItemRepository().backlogItemOfId(
                tenantId, new BacklogItemId(aBacklogItemId));
    
        Sprint sprint = sprintRepository().backlogItemOfId(
                tenantId, new SprintId(aSprintId));
    
        backlogItem.commitTo(sprint);
    }
    

    commitBacklogItemToSprint 就可以看作是一个 Command Handler,注意其命名(commitXXXXToXXXX),一眼看过去就是命令的意思,commitTo 之后的操作是提交给 Event Bus,然后分发给相应 Event Handler 订阅者,来完成状态修改后确定的操作,这样一个领域对象状态的变更才算完成。

    关于 Event Handler 保存领域状态操作,其实说简单也简单,说复杂会很复杂,对于它的实现一般会采用异步的方式,也就是说领域状态的保存操作不会延时领域中的业务操作,数据的一致性使用 Unit of Work,具体的领域状态保存用 Repository 实现。

    梳理 Command 整个流程,你会发现一个关键词:状态(Status),在上一篇博文讲 REST 概念时,也有一个相似的概念:应用状态(Application State),REST 其中的一个含义就是状态转换,从客气端的发起请求开始,到服务端响应请求结束,应用状态在其过程中会进行不断的转换,请求响应的整个过程也就是应用状态转换的过程,对于 Command 处理流程来说,领域对象的状态和应用状态其实是相类似。我举一个例子,在 REST 架构风格中,应用状态是不会保存到服务端的,客户端发起请求(包含应用状态信息),服务端做出相应处理,此时的状态会转换成资源状态呈现给客户端,这就是表现层状态转换的意思,回到 Command 处理流程上,Command Bus 接收来自 UI 的请求,分发给相应的 Command Handler 进行处理,在处理过程中,就会对领域对象进行修改操作,但它不会保存修改之后的状态信息,而是交给 Event Handler 进行保存状态信息。

    和 Command 相比,Query 的处理流程就简单很多了,Query Service 接收来自 UI 的查询请求,这个查询处理可以用各种方式实现,你可以使用 ORM,也可以直接写 SQL 代码,反正是:怎么能提高性能,就怎么来!返回的结果类型一般是 DTO(数据传输对象),根据 UI 进行设计,可以减少不必要的数据传输。

    2. EDA-事件驱动架构

    Event-Driven Architecture(事件驱动架构),来自解道的定义:

    事件代表过去发生的事件,事件既是技术架构概念,也是业务概念,以事件为驱动的编程模型称为事件驱动架构 EDA。

    EDA 架构的三个特性:

    1. 异步
    2. 实时
    3. 彻底解耦

    EDA 架构的核心是基于消息的发布订阅模式,通过发布订阅模式实现事件的一对多灵活分发。消息消费方对发送方而言完全透明,消息发送方只管把消息发送到消息中间件,其它事情全部不用关心,由于消息中间件中的 MQ 等技术,即使发送消息时候,消息接收方不可用,但仍然可以正常发送,这才叫彻底解耦。其次一对多的发布订阅模式也是一个核心重点,对于消息的订阅方和订阅机制,可以在消息中间件灵活的进行配置和管理,而对于消息发送方和发送逻辑基本没有任何影响。

    EDA 要求我们的是通过业务流程,首先要识别出有价值的业务事件,这些事件符合异步、实时和发布订阅等基本的事件特征;其次是对事件进行详细的分析和定义,对事件对应的消息格式进行定义,对事件的发布订阅机制进行定义等,最后才是基于消息事件模式的开发和测试等工作。

    在上一篇博文中有讲到 SOA,我们知道分为客户端和服务端,客户端发起请求给服务端,服务端做出相应的响应,也就是说客户端是主动的,服务端是被动的,这种情况就会造成服务的分散,也就是说,我们一般在设计服务的时候,会根据客户端的响应而被迫的切分业务逻辑,最后导致的情况是各个业务模块所属的服务,被分散在各个业务系统中,这种设计就会导致很多问题的发生。而对于 EDA 架构来说,订阅者向 Event Bus 订阅事件,告诉事件总线我要这个,而 Event Bus 接收订阅后,并不会立即进行处理,而是想什么时候处理就什么时候处理,主动权在 Event Bus 手中,当 Event Bus 想进行处理的时候,一般是接受来自 Command Handler 的请求,然后就分别向指定订阅者发布通知,告诉它们我已经处理了,你们可以接着做下面的事了。

    从上面的描述中,我们可以看到 SOA 和 EDA 的明显区别,相对于 SOA 来说,EDA 更加有利于领域的聚合,主动权在领域手中,我们就可以从容面对很多的情形,简单画了一张图:

    另外,需要注意的一点,CQRS 可以结合 EDA,也可以不结合,但反过来对于 EDA 来说,则必须结合 CQRS 使用。

    2.1 Domin Event-领域事件

    领域事件和 Domain Service(领域服务)一样,同属于 DDD 战术模式,这部分内容在 IDDD 第八章有详细介绍,因为我还没学习到那部分,这边就简单说明一下。在 EDA 的定义中说到:事件代表过去发生的事件,换句话说它是代表已完成的事件,准备来说,还应该包含正在完成的事件,既然是属于 DDD 战术模式的一种,那在领域设计中必然有所用武之地。

    我用大白话来描述下领域事件在领域中的作用:我们知道行军打仗需要做出抉择,也就是说,需要指挥部商量后下达作战命令,然后把命令交给各个负责的作战中心,有陆军、海军、空军、导弹部队等,它们是命令的实施者,而指挥部是命令的决策者,这个和领域事件是一样的,领域中处理一些业务逻辑后,就会对领域对象的状态做出一些改变,这个就相当于作战命令,然后根据作战命令分配的作战中心进行完成,也就是领域事件的订阅者去完成领域对象状态改变之后的操作,简单而言,领域事件就是领域中的“跑腿者”。

    在上面 EDA 的介绍中,有这样的一段代码:backlogItem.commitTo(sprint);,用通用语言表述就是:待定项提交到冲刺,这是领域中完成的一个操作,由 Command Handler 进行委派完成,backlogItem 是一个聚合根对象,commitTo 是聚合根中的一个操作,这个操作完成后,backlogItem 聚合根对象的状态就会被修改了,那在 commitTo 中具体有怎么的操作呢?看下示例代码:

    public void commitTo(Sprint aSprint)
    {
        this.assertArgumentNotNull(aSprint, "Sprint must not be null.");
        this.assertArgumentEquals(aSprint.tenantId(), this.tenantId(), "Sprint must be of same tenant.");
        this.assertArgumentEquals(aSprint.productId(), this.productId(), "Sprint must be of same product.");
    
        if (!this.isScheduledForRelease())
        {
            throw new IllegalStateException("Must be scheduled for release to commit to sprint.");
        }
    
        if (this.isCommittedToSprint())
        {
            if (!aSprint.sprintId().equals(this.sprintId()))
            {
                this.uncommitFromSprint();
            }
        }
    
        this.elevateStatusWith(BacklogItemStatus.COMMITTED);
    
        this.setSprintId(aSprint.sprintId());
    
        DomainEventPublisher
            .instance()
            .publish(new BacklogItemCommitted(
                    this.tenantId(),
                    this.backlogItemId(),
                    this.sprintId()));
    }
    

    注意 commitTo 所处在 BacklogItem 聚合根内,前面都是对聚合根对象的一些状态操作,在后面我们会看到 DomainEventPublisher(领域事件发布者),BacklogItemCommitted 继承自 DomainEvent,BacklogItemCommitted 对应的领域事件,在 BacklogItemApplicationService 中进行订阅,一般是聚合根对象在初始化的时候。

    根据上面这个代码示例,然后结合 EDA 的三个特性就可以很好理解了,首先对于领域事件的处理操作一般是异步完成,这样就不会影响聚合根中的其他业务操作,当领域事件发布的时候,会实时的告知订阅者进行处理,因为它不管订阅者的具体处理情况,订阅者和发布者的规范在 DomainEvent 中,而不是像接口定义和实现那么强制,所以,当领域事件发布的时候,就说明订阅者已经被告知并进行了处理,所以他们直接的关系可以彻底的解耦。

    在之前的短消息项目中,我没用到领域事件,对它也不是很深入的了解,在后面的博文中,再进行详细说明。

    2.2 Long-Running Process(Saga)-长时处理过程

    来自 IDDD 中的定义:

    事件驱动的、分布式的并行处理模式。

    关于 Saga 的相关博文,国内几乎没有(netfocus 有一篇相关说明),长时处理过程,说明它是一个需要耗时、多任务并行的处理过程,那在领域中,什么时候会有它的用武之地呢?比如一个看似简单的业务逻辑,可能会涉及到领域中很复杂的业务操作,而且对于这些处理需要耗费很长的时间。

    在电子商城提交一个订单操作,用户看来可能会非常简单,但在领域进行处理的时候,会涉及到订单操作、客户操作、商品操作、库存操作、消息通知操作等等,有些会进行及时的处理,但有些则不会,比如消息通知操作等等,我们可以把这个业务操作分离一下,对于一些耗时比较长的操作精拣一下,商品的减少对应库存的减少,减少之后会进行警戒线判断,如果低于警戒下,则会给库存管理人员发送消息,商品减少了对应的商品统计也要进行更新,客户购买之后也要进行发送消息通知,我们可以把这些用一个 Saga 进行处理,因为是基于事件驱动,所以一个 Saga 中会订阅多个事件,Saga 会对这些事件进行跟踪,对于一些事件处理失败,也要进行做出相应的弥补措施,当所有的操作完成后,Saga 会返回一个状态给领域,也许这个返回操作已经在开始的几天以后了。

    说明:本图摘自 MSDN

    上图描述的是:一个会议购买座位的业务过程,中间的 Order Process Manager 就是一个 Saga,在 CQRS 架构中的表现就是 Process Manager(过程管理),我们一般会用它处理多个聚合根交互的业务逻辑,比如 netfocus 博文中列举的 TransferProcessCommandHandlers 操作,还有上图中的购买座位业务操作,那我们应该怎么设计 Saga 呢?或者说在设计的时候,应该需要注意哪些地方呢?我大致列一下:

    • 将 Saga 尽量设计成组合任务的形式,你可以把它看作是一个任务的结合体,并对内部每个任务进行跟踪操作。
    • Saga 也可以用一组聚合的形式体现,也就是上面的图中示例。
    • 无状态处理,因为是基于事件驱动,状态信息会包裹在事件中,对于 Sage 整个处理过程来说,可以看作是无状态处理。
    • 可以适用于分布式设计。

    2.3 Event Sourcing-事件溯源

    字面上的理解:

    事件即 Event,溯是一个动词,可以理解为追溯的意思,源代表原始、源头的意思,合起来表示一个事件追踪过程。

    我们都知道在源代码管理的时候,可以使用 SVN、Git、CVS 进行对代码修改的跟踪操作,从一个代码文件的创建开始,我们可以查看各个版本对代码的修改情况,甚至可以指定版本进行还原操作,这就是对代码跟踪所带来的好处。而对于领域对象来说,我们也应该知晓其整个生命周期的演变过程,这样有利于查看并还原某一“时刻”的领域对象,在 EDA 架构中,对于领域对象状态的保存是通过领域事件进行完成,所以我们要想记录领域对象的状态记录,就需要把领域对象所经历的所有事件进行保存下来,这就是 Event Store(事件存储),这个东西就相当于 Git 代码服务器,存储所有领域对象所经历的事件,对于某一事件来说,可以看作是对应领域对象的“快照”。

    总的来说,ES 事件溯源可以概括为两点:

    1. 记录
    2. 还原

    最后,贴一张 CQRS、EDA、Saga、ES 结合图:

    说明:本图来自 netfocus

    CQRS 参考资料:

    EDA 参考资料:


    未完成的两点:

    • 3. CQRS Journey-微软示例项目
    • 4. ENode-netfocus 实践项目

    本来还想把这两个项目分析一下,至少可以看懂一个业务流程,比如 Conference 项目中的 AssignSeats、ConferencePublish 等,ENode 项目中的 BankTransferSample 示例,但分析起来,真的有些吃力,有时候概念是一方面,实践又是另一方面,后面有时间理解了,再把这两点内容进行补充下。

    这篇博文内容有点虚,大家借鉴有用的地方就行,也欢迎拍砖斧正。

  • 相关阅读:
    事务传播机制,搞懂。
    洛谷 P1553 数字反转(升级版) 题解
    洛谷 P1200 [USACO1.1]你的飞碟在这儿Your Ride Is Here 题解
    洛谷 P1055 ISBN号码 题解
    洛谷 P2141 珠心算测验 题解
    洛谷 P1047 校门外的树 题解
    洛谷 P1980 计数问题 题解
    洛谷 P1008 三连击 题解
    HDU 1013 题解
    HDU 1012 题解
  • 原文地址:https://www.cnblogs.com/xishuai/p/iddd-cqrs-and-eda.html
Copyright © 2011-2022 走看看