领域驱动设计(DDD)实践之路(二):事件驱动与CQRS

本文首发于 vivo互联网技术 微信公众号 
连接: https://mp.weixin.qq.com/s/Z3...
做者:wenbo zhang

【领域驱动设计实践之路】系列往期精彩文章:html

领域驱动设计(DDD)实践之路(一)》 主要讲述了战略层面的DDD原则。java

这是“领域驱动设计实践之路”系列的第二篇文章,分析了如何应用事件来分离软件核心复杂度。探究CQRS为何普遍应用于DDD项目中,以及如何落地实现CQRS框架。固然咱们也要警戒一些失败的教训,利弊分析之后再去抉择正确的应对之道。spring

1、前言:从物流详情开始

你们对物流跟踪都不陌生,它详细记录了在什么时间发生了什么,而且数据做为重要凭证是不可变的。我理解其背后的价值有这么几个方面:业务方能够管控每一个子过程、知道目前所处的环节;另外一方面,当须要追溯时候仅仅经过每一步的记录就能够回放整个历史过程。sql

我在以前的文章中提出过“软件项目也是人类社会生产关系的范畴,只不过咱们所创造的劳动成果看不见摸不着而已”。因此咱们能够借鉴物流跟踪的思路来开发软件项目,把复杂过程拆解为一个个步骤、子过程、状态,这和咱们事件划分是一致的,这就是事件驱动的典型案例。数据库

2、领域事件

领域事件(Domain Events)是领域驱动设计(Domain Driven Design,DDD)中的一个概念,用于捕获咱们所建模的领域中所发生过的事情。编程

领域事件自己也做为通用语言(Ubiquitous Language)的一部分红为包括领域专家在内的全部项目成员的交流用语。设计模式

好比在前述的跨境物流例子中,货品达到保税仓之后须要分派工做人员进行分拣分包,那么“货品已到达保税仓”即是一个领域事件。微信

首先,从业务逻辑来讲该事件关系到整个流程的成功或者失败;同时又将触发后续子流程;而对于业务方来讲,该事件也是一个标志性的里程碑,表明本身的货品就快配送到本身手中。网络

因此一般来讲,一个领域事件具备如下几个特征:较高的业务价值,有助于造成完整的业务闭环,将致使进一步的业务操做。这里还要强调一点,领域事件具备明确的边界。架构

好比:若是你建模的是餐厅的结帐系统,那么此时的“客户已到达”便不是你关心的重点,由于你不可能在客户到达时就当即向对方要钱,而“客户已下单”才是对结帐系统有用的事件。

一、建模领域事件

在建模领域事件时,咱们应该根据限界上下文中的通用语言来命名事件及属性。若是事件由聚合上的命令操做产生,那么咱们一般根据该操做方法的名字来命名领域事件。

对于上面的例子“货品已到达保税仓”,咱们将发布与之对应的领域事件

GoodsArrivedBondedWarehouseEvent(固然在明确的界限上下文中也能够去掉聚合的名字,直接建模为ArrivedBondedWarehouseEvent,这都是命名方面的习惯)。

事件的名字代表了聚合上的命令方法在执行成功以后所发生的事情,换句话说待定项以及不肯定的状态是不能做为领域事件的。

一个行之有效的方法是画出当前业务的状态流转图,包含前置操做以及引发的状态变动,这里表达的是已经变动完成的状态因此咱们不用过去时态表示,好比删除或者取消,即表明已经删除或者已经取消。

而后对于其中的节点进行事件建模。以下图是文件云端存储的业务,咱们分别对预上传、上传完成确认、删除等环节建模“过去时”事件,PreUploadedEvent、ConfirmUploadedEvent、RemovedEvent。

二、领域事件代码解读

package domain.event;

import java.util.Date;
import java.util.UUID;

/**
 * @Description:
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */
public class DomainEvent {

    /**
     * 领域事件还包含了惟一ID,
     * 可是该ID并非实体(Entity)层面的ID概念,
     * 而是主要用于事件追溯和日志。
     * 若是是数据库存储,该字段一般为惟一索引。
     */
    private final String id;

    /**
     * 建立时间用于追溯,另外一方面无论使用了
     * 哪一种事件存储都有可能遇到事件延迟,
     * 咱们经过建立时间可以确保其发生顺序。
     */
    private final Date occurredOn;

    public DomainEvent() {
        this.id = String.valueOf(UUID.randomUUID());
        this.occurredOn = new Date();
    }
}

在建立领域事件时,须要注意2点:

  • 领域事件自己应该是不变的(Immutable);
  • 领域事件应该携带与事件发生时相关的上下文数据信息,可是并非整个聚合根的状态数据。例如,在建立订单时能够携带订单的基本信息,而对于用户更新订单收货地址事件AddressUpdatedEvent事件,只须要包含订单、用户以及新的地址等信息便可。
public class AddressUpdatedEvent extends DomainEvent {
    //经过userId+orderId来校验订单的合法性;
    private String userId; 
    private String orderId;
    //新的地址
    private Address address;
    //略去具体业务逻辑
}

三、领域事件的存储

事件的不可变性与可追溯性都决定了其必需要持久化的原则,咱们来看看常见的几种方案。

3.1单独的EventStore

有的业务场景中会建立一个单独的事件存储中心,多是Mysql、Redis、Mongo、甚至文件存储等。这里以Mysql举例,business_code、event_code用来区分不一样业务的不一样事件,具体的命名规则能够根据实际须要。

这里须要注意该数据源与业务数据源不一致的场景,咱们要确保当业务数据更新之后事件可以准确无误的记录下来,实践中尽可能避免使用分布式事务,或者尽可能避免其跨库的场景,不然你就得想一想如何补偿了。千万要避免,用户更新了收货地址,可是AddressUpdatedEvent事件保存失败。

总的原则就是对分布式事务Say No,不管如何,我相信方法总比问题多,在实践中咱们总能够想到解决方案,区别在于该方案是否简洁、是否作到了解耦。

# 考虑是否须要分表,事件存储建议逻辑简单
CREATE TABLE `event_store` (
  `event_id` int(11) NOT NULL auto increment,
  `event_code` varchar(32) NOT NULL,
  `event_name` varchar(64) NOT NULL,
  `event_body` varchar(4096) NOT NULL,
  `occurred_on` datetime NOT NULL,
  `business_code` varchar(128) NOT NULL,
  UNIQUE KEY (`event id`)
) ENGINE=InnoDB COMMENT '事件存储表';

3.2 与业务数据一块儿存储

在分布式架构中,每一个模块都作的相对比较小,准确的说是“自治”。若是当前业务数据量较小,能够将事件与业务数据一块儿存储,用相关标识区分是真实的业务数据仍是事件记录;或者在当前业务数据库中创建该业务本身的事件存储,可是要考虑到事件存储的量级必然大于真实的业务数据,考虑是否须要分表。

这种方案的优点:数据自治;避免分布式事务;不须要额外的事件存储中心。固然其劣势就是不能复用。

四、领域事件如何发布

4.1 由领域聚合发送领域事件

/*
* 一个关于比赛的充血模型例子
* 贫血模型会构造一个MatchService,咱们这里经过模型来触发相应的事件
* 本例中略去了具体的业务细节
*/
public class Match {
    public void start() {
        //构造Event....
        MatchEvent matchStartedEvent = new MatchStartedEvent();
        //略去具体业务逻辑
        DefaultDomainEventBus.publish(matchStartedEvent);
    }

    public void finish() {
        //构造Event....
        MatchEvent matchFinishedEvent = new MatchFinishedEvent();
        //略去具体业务逻辑
        DefaultDomainEventBus.publish(matchFinishedEvent);
    }

    //略去Match对象基本属性
}

4.2 事件总线VS消息中间件

微服务内的领域事件能够经过事件总线或利用应用服务实现不一样聚合之间的业务协同。即微服务内发生领域事件时,因为大部分事件的集成发生在同一个线程内,不必定须要引入消息中间件。但一个事件若是同时更新多个聚合数据,按照 DDD“一个事务只更新一个聚合根”的原则,能够考虑引入消息中间件,经过异步化的方式,对微服务内不一样的聚合根采用不一样的事务

3、Saga分布式事务

一、Saga概要

咱们看看如何使用 Saga 模式维护数据一致性?

Saga 是一种在微服务架构中维护数据一致性的机制,它能够避免分布式事务所带来的问题。

一个 Saga 表示须要更新的多个服务中的一个,即Saga由一连串的本地事务组成。每个本地事务负责更新它所在服务的私有数据库,这些操做仍旧依赖于咱们所熟悉的ACID事务框架和函数库。

模式:Saga

经过使用异步消息来协调一系列本地事务,从而维护多个服务之间的数据一致性。

请参阅(强烈建议):https://microservices.io/patterns/data/saga.html

Saga与TCC相比少了一步Try的操做,TCC不管最终事务成功失败都须要与事务参与方交互两次。而Saga在事务成功的状况下只须要与事务参与方交互一次, 若是事务失败,须要额外进行补偿回滚。

  • 每一个Saga由一系列sub-transaction Ti 组成;
  • 每一个Ti 都有对应的补偿动做Ci,补偿动做用于撤销Ti形成的结果;

能够看到,和TCC相比,Saga没有“预留”动做,它的Ti就是直接提交到库。

Saga的执行顺序有两种:

  • success:T1, T2, T3, ..., Tn ;
  • failure:T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n;

因此咱们能够看到Saga的撤销十分关键,能够说使用Saga的难点就在于如何设计你的回滚策略。

二、Saga实现

经过上面的例子咱们对Saga有了初步的体感,如今来深刻探讨下如何实现。当经过系统命令启动Saga时,协调逻辑必须选择并通知第一个Saga参与方执行本地事务。一旦该事务完成,Saga协调选择并调用下一个Saga参与方。

这个过程一直持续到Saga执行完全部步骤。若是任何本地事务失败,则 Saga必须以相反的顺序执行补偿事务。如下几种不一样的方法可用来构建Saga的协调逻辑。

2.1 协同式(choreography)

把 Saga 的决策和执行顺序逻辑分布在 Saga的每个参与方中,它们经过交换事件的方式来进行沟通。

( 引用于《微服务架构设计模式》相关章节)

  1. Order服务建立一个Order并发布OrderCreated事件。
  2. Consumer服务消费OrderCreated事件,验证消费者是否能够下订单,并发布ConsumerVerified事件。
  3. Kitchen服务消费OrderCreated事件,验证订单,在CREATE_PENDING状态下建立故障单,并发布TicketCreated事件。
  4. Accounting服务消费OrderCreated事件并建立一个处于PENDING状态的Credit CardAuthorization。
  5. Accounting服务消费TicketCreated和ConsumerVerified事件,向消费者的信用卡收费,并发布信用卡受权失败事件。
  6. Kitchen服务使用信用卡受权失败事件并将故障单的状态更改成REJECTED。
  7. 订单服务消费信用卡受权失败事件,并将订单状态更改成已拒绝。

2.2 编排式(orchestration)

把Saga的决策和执行顺序逻辑集中在一个Saga编排器类中。Saga 编排器发出命令式消息给各个 Saga 参与方,指示这些参与方服务完成具体操做(本地事务)。相似于一个状态机,当参与方服务完成操做之后会给编排器发送一个状态指令,以决定下一步作什么。

( 引用于《微服务架构设计模式》相关章节)

咱们来分析一下执行流程

  1. Order Service首先建立一个Order和一个建立订单控制器。以后,路径的流程以下:
  2. Saga orchestrator向Consumer Service发送Verify Consumer命令。
  3. Consumer Service回复Consumer Verified消息。
  4. Saga orchestrator向Kitchen Service发送Create Ticket命令。
  5. Kitchen Service回复Ticket Created消息。
  6. Saga协调器向Accounting Service发送受权卡消息。
  7. Accounting服务部门使用卡片受权消息回复。
  8. Saga orchestrator向Kitchen Service发送Approve Ticket命令。
  9. Saga orchestrator向订单服务发送批准订单命令。

2.3 补偿策略

以前的描述中咱们说过Saga最重要的是如何处理异常,状态机还定义了许多异常状态。如上面的6就会发生失败,触发AuthorizeCardFailure,此时咱们就要结束订单并把以前提交的事务进行回滚。这里面要区分哪些是校验性事务、哪些是须要补偿的事务。

 一个Saga由三种不一样类型的事务组成:可补偿性事务(能够回滚,所以有一个补偿事务);关键性事务(这是 Saga的成败关键点,好比4帐户代扣);以及可重复性事务,它不须要回滚并保证可以完成(好比6更新状态)。

在Create Order Saga 中,createOrder()、createTicket()步骤是可补偿性事务且具备撤销其更新的补偿事务。

verifyConsumerDetails()事务是只读的,所以不须要补偿事务。authorizeCreditCard()事务是这个 Saga的关键性事务。若是消费者的信用卡能够受权,那么这个Saga保证完成。approveTicket()和approveRestaurantOrder()步骤是在关键性事务以后的可重复性事务。

认真拆解每一个步骤、而后评估其补偿策略尤其重要,正如你看到的,每种类型的事务在对策中扮演着不一样的角色。

4、CQRS

前面讲述了事件的概念,又分析了Saga如何解决复琐事务,如今咱们来看看CQRS为何在DDD中普遍被采用。除了读写分离的特征之外,咱们用事件驱动的方式来实践Command逻辑能有效下降业务的复杂度。

当你明白如何建模事件、如何规避复琐事务,明白何时用消息中间件、何时采用事件总线,才能理解为何是CQRS、怎么正确应用。

( 图片来源于网络)

下面是咱们项目中的设计,这里为何会出现Read/Write Service,是为了封装调用,service内部是基于聚合发送事件。由于我发如今实际项目中,不少人都会第一时间问我要XXXService而不是XXX模型,因此在DDD没有彻底普及的项目中建议你们采起这种居中策略。这也符合我们的解耦,对方依赖个人抽象能力,然而我内部是基于DDD仍是传统的流程代码对其是无关透明的。

咱们先来看看事件以及处理器的时序关系。

这里仍是以文件云端存储业务为例,下面是一些处理器的核心代码。注释行是对代码功能、用法以及扩展方面的解读,请认真阅读。

package domain;

import domain.event.DomainEvent;
import domain.handler.event.DomainEventHandler;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @Description: 事件注册逻辑
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

public class DomainRegistry {

    private Map<String, List<DomainEventHandler>> handlerMap =
        new HashMap<String, List<DomainEventHandler>>();

    private static DomainRegistry instance;

    private DomainRegistry() {
    }

    public static DomainRegistry getInstance() {
        if (instance == null) {
            instance = new DomainRegistry();
        }
        return instance;
    }

    public Map<String, List<DomainEventHandler>> getHandlerMap() {
        return handlerMap;
    }

    public List<DomainEventHandler> find(String name) {
        if (name == null) {
            return null;
        }
        return handlerMap.get(name);
    }

    //事件注册与维护,register分多少个场景根据业务拆分,
    //这里是业务流的核心。若是多个事件须要维护先后依赖关系,
    //能够维护一个priority逻辑
    public void register(Class<? extends DomainEvent> domainEvent,
                         DomainEventHandler handler) {
        if (domainEvent == null) {
            return;
        }
        if (handlerMap.get(domainEvent.getName()) == null) {
            handlerMap.put(domainEvent.getName(), new ArrayList<DomainEventHandler>());
        }
        handlerMap.get(domainEvent.getName()).add(handler);
        //按照优先级进行事件处理器排序
        。。。
    }
}

文件上传完毕事件的例子。

package domain.handler.event;

import domain.DomainRegistry;
import domain.StateDispatcher;
import domain.entity.meta.MetaActionEnums;
import domain.event.DomainEvent;
import domain.event.MetaEvent;
import domain.repository.meta.MetaRepository;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

/**
 * @Description:一个事件操做的处理器
 * 咱们混合使用了Saga的两种模式,外层事件交互;
 * 对于单个复杂的事件内部采起状态流转实现。
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

@Component
public class MetaConfirmUploadedHandler implements DomainEventHandler {

    @Resource
    private MetaRepository metaRepository;

    public void handle(DomainEvent event) {
        //1.咱们在当前的上下文中定义个ThreadLocal变量
        //用于存放事件影响的聚合根信息(线程共享)

        //2.固然若是有须要额外的信息,能够基于event所
        //携带的信息构造Specification从repository获取
        // 代码示例
        // metaRepository.queryBySpecification(SpecificationFactory.build(event));

        DomainEvent domainEvent = metaRepository.load();

        //此处是咱们的逻辑
        。。。。

        //对于单个操做比较复杂的,可使用状态流转进一步拆分
        domainEvent.setStatus(nextState);
        //在事件触发以后,仍须要一个状态跟踪器来解决大事务问题
        //Saga编排式
        StateDispatcher.dispatch();
    }

    @PostConstruct
    public void autoRegister() {
        //此处能够更加细分,注册在哪一类场景中,这也是事件驱动的强大、灵活之处。
        //避免了if...else判断。咱们能够有这样的意识,一旦你的逻辑里面充斥了大量
        //switch、if的时候来看看本身注册的场景是否能够继续细分
        DomainRegistry.getInstance().register(MetaEvent.class, this);
    }

    public String getAction() {
        return MetaActionEnums.CONFIRM_UPLOADED.name();
    }

    //适用于先后依赖的事件,经过优先级指定执行顺序
    public Integer getPriority() {
        return PriorityEnums.FIRST.getValue();
    }
}

事件总线逻辑

package domain;

import domain.event.DomainEvent;
import domain.handler.event.DomainEventHandler;
import java.util.List;

/**
 * @Description:
 * @Author: zhangwenbo
 * @Since: 2019/3/6
 */

public class DefaultDomainEventBus {

    public static void publish(DomainEvent event, String action,
                               EventCallback callback) {

        List<DomainEventHandler> handlers = DomainRegistry.getInstance().
            find(event.getClass().getName());
        handlers.stream().forEach(handler -> {
            if (action != null && action.equals(handler.getAction())) {
                Exception e = null;
                boolean result = true;
                try {
                    handler.handle(event);
                } catch (Exception ex) {
                    e = ex;
                    result = false;
                    //自定义异常处理
                    。。。
                } finally {
                    //write into event store
                    saveEvent(event);
                }

                //根据实际业务处理回调场景,DefaultEventCallback能够返回
                if (callback != null) {
                    callback.callback(event, action, result, e);       
                }
            }
        });
    }
}

5、自治服务和系统

DDD中强调限界上下文的自治特性,事实上,从更小的粒度来看,对象仍然须要具有自治的这四个特性,即:最小完备、自我履行、稳定空间、独立进化。其中自我履行是重点,由于不强依赖外部因此稳定、由于稳定才可能独立进化。这就是六边形架构在DDD中较为广泛的缘由。

( 图片来源于网络)

6、结语

本文所讲述的事件、Saga、CQRS的方案都可以单独使用,能够应用到你的某个method、或者你的整个package。项目中咱们并不必定要实践一整套CQRS,只要其中的某些思想解决了咱们项目中的某个问题就足够了。

也许你如今已经磨刀霍霍,准备在项目中实践一下这些技巧。不过咱们要明白“每个硬币都有两面性”,咱们不只看到高扩展、解耦的、易编排的优势之外,仍然要明白其所带来的问题。利弊分析之后再去决定如何实现才是正确的应对之道。

  • 这类编程模式有必定的学习曲线;
  • 基于消息传递的应用程序的复杂性;
  • 处理事件的演化有必定难度;
  • 删除数据存在必定难度;
  • 查询事件存储库很是有挑战性。

不过咱们仍是要认识到在其适合的场景中,六边形架构以及DDD战术将加速咱们的领域建模过程,也迫使咱们从严格的通用语言角度来解释一个领域,而不是一个个需求。任何更强调核心域而不是技术实现的方式均可以增长业务价值,并使咱们得到更大的竞争优点。

附:参考文献

  1. Pattern: Saga
  2. 分布式事务:Saga模式
  3. 书籍:《微服务架构设计模式》

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:Labs2020 联系。

相关文章
相关标签/搜索