戏说领域驱动设计(廿五)——领域事件

  任何事物都在变化着包括领域驱动设计这门学问。Evans在首次提到DDD概念后,后来出现了陆续又出现了很多的专家与学者对其理论进行了扩充比如:“领域事件”、“事件源”、“命令查询责任分离”等。也正是由于这些补充,不仅让DDD的适用范围变得更大也让后来出现的微服务架构系统受益良多,为系统落地提供了非常优秀的理论指导。这节我们主要讨论领域事件,不夸张的说,在现代化的业务系统中它的应用普度度非常高,将其看成一种事实上的标准也并不为过。尤其在使用基于Saga的分布式事务时,领域事件完全是不能少的。此外,DDD中不推荐一个事务更新多个聚合,那如果有这种需要的时候要怎么做呢?答案还是“领域事件”,所以让我们开始今天的学习之旅。

一、概览

  主流的基于事件的业务处理流程大概如下图所示。为什么说是主流呢?有些特殊情况下可能会使用多线程+远程服务调用的方式进行事件的投递,但这种情况大多都发生在遗留的系统中。很多系统中早已经引入了消息队列中间件或者一些消息队列组件,使用它们作为消息的载体已经是主流。所以后续的内容中一旦涉及到消息的投递我们默认就是指使用消息队列 。

 

  单体时代,想要实现模块间的交流最简单的方式是通过进程内函数调用,比较直观,程序员用起来也更方便。到了微服务的时代,由于业务被划分到多个独立部署的服务中,想要实现业务串联方式之一是使用进程间通讯技术比如RPC或基于HTTP调用。但使用远程调用的方式所带来的隐患比较多,一是由于同步的调用会产生性能瓶颈,其实基于进行内调用也是一样,单线程情况之下整个业务执行的时间等于其所调用的所有方法的执行时间之和; 二是分布式部署的服务需要通过网络连接进行协作,你不能假设网络是稳定的,而不稳定的网络所带来的隐患也很多,比如性能、后期运维等。所以使用消息及消息队列中间件作为服务间的信息交换方式成为另外一种主流,不论是在微服务的内部还是在微服务之间。而且呢,由于各服务都是与消息中间件进行交互也不用知道其它服务的地址,能大大减少服务间的相互依赖(即使引入了服务治理工具也不代表没有依赖,而是服务的客户端不再像过去一样需要了解服务端的IP地址和端口等信息)。引入领域事件的另一个优势就是系统的扩展性被增强:在使用基于远程调用的方式实现某个业务时,当业务需要进行扩展时很多时候你需要增加对另外的服务的调用;而使用事件的机制,您只需要再引入一个事件的监听者即可,成本非常低,也符合了我们所追求的“开闭原则”。虽然消息这种方式看起来要美好很多,但需要额外引入新的消息中间键,必然会加大学习与运营的成本。不过这个账得看你怎么算,通过硬件与人员的投入虽然有额外的支出,但能让系统更加稳定,吞吐量更高,实际上又节约了成本。再说了,为了应对请求的高峰有的时候你必须要引入消息队列进行缓冲以实现削峰填谷。事件本质上不就一种消息吗?大部分情况下可以复用系统中的基础设施,反正一个羊是赶,两个羊也是放,也不差领域事件那点消耗。

领域事件的提出其实是在Evans那本书之后,有的时候我在想:在没有领域事件的情况下,他是如何处理多聚合的协作呢?猜测的结果有两个:一是和当时的时代背景有关,03或04年他提出这个概念,当时单体是主流并不会有那么多的子服务存在,因此在实践中应该是允许一个事务更新多个聚合的,也就是通过应用服务完成聚合的协作。二是当时EJB比较流行,里面有企业消息总线的使用,可以通过它实现聚合间的协作,但作者并未给消息赋予领域事件之名。具体原因不可考,总得来说领域事件的使用的确让哪怕技术一般的团队也能开发出较高吞吐量的系统。

二、领域事件本质

  领域事件的本质需要从两个维度进行说明:业务与技术。在业务方面,领域事件表达了在领域中发生的某些事件,为了表达这个事件我们对其进行了建模并使其成为通用语言的一部分。单纯的构建一个领域事件其实没什么作用,在业务中由于某个领域对象的动作被触发会引发与之关联的另外的领域对象也受到影响,那么我们要怎么通知受波及的对象呢?答:领域事件。通过领域事件我们可以驱动业务的流向。其实您仔细想一想会发现很多的业务都是由于某个事件的发生而推动其流程前进的,所以我有的时候在想“基于事件的架构”是不是更符合业务本质或者说更有助于系统的实现。此外,在领域驱动设计中还有一种架构风格叫“事件溯源(ES)”,其也使用领域事件,虽然在架构风格和开发风格上有别于我们传统的模式,但其本质上也是由事件进行驱动的,只不于更注重于实体驱动实体属性的变更。

  有这样的一个需求:“订单支付后需要给其所属账户增加10点成就值”。在使用微服务架构的系统下,您可以很明显的看出来系统中应该包含两个服务:“订单服务”用于处理订单相关的业务; “账户服务”用于处理成就值业务。这段需求中您也可以发现一个明显的领域事件“订单支付后”。在引入了领域事件后这个业务的处理流程可分解为:订单服务在订单支付后产生“订单支付”事件;账户服务可以根据事件触发积分逻辑。此处,为了实现事件在服务间的投递通常会引入事件发布与订阅组件,具体细节后面说明。因为领域事件的引入,您可以让微服务系统发挥出最大的效能,每个系统都专注于完成各自的责任;从技术的角度来看由于使用了消息队列,整个业务的执行也会由原来的同步变为异步,性能更高。代码案例如下所示。

public class OrderService {
    public void pay(Long orderId, Money cost) {
        Order order = this.orderRepository.findBy(orderId);
        OrderPaid orderPaid = order.pay(cost);
        this.eventBus.post(orderPaid);
    }
}
public class AccountService {
    public void handle(OrderPaid orderPaid) {
        Account account = this.accountRepository.findBy(orderPaid.getAccountId());
        account.increaseRewardPoints();
    }
}

  让我们再进行一个反推,如果没有领域事件要如何处理示例业务呢?您需要在应用服务中在执行订单的支付业务后再通过远程调用的方式让账户服务执行积分的增加,大致的代码如下所示。

public class OrderService {
    public void pay(Long orderId, Money cost) {
        Order order = this.orderRepository.findBy(orderId);
        order.pay(cost);
        this.remoteAccountService.increaseRewardPoints(10L);
    }
}

  哪种代码更好一点?目测还是使用领域事件的方案更优秀:异步操作,性能是杠杠的。远程调用的方式就差了点意思,案例中只展示了基本的逻辑,如果想要确保“订单支付后需要给其所属账户增加10点成就值”这个业务能够顺利完成,你还得加上一个分布式事务,这可就复杂了。当然了,使用了领域事件的方式你也得做一些工作来保证消息不丢失。但总得来看方案二要复杂一点,如果一个业务涉及到多个服务共同参与才能完成,那这个性能低得可就不是一点半点了。是不是在您的心里已经首先把方案二给否了?我这性子已经够急了,您这比我还急。先别着急下结论,亲!具体使用哪种方案还得看需求呢,请听我慢慢道来。

  首要的一点,您心里得有一个谱,咱们这个案例是基于微服务风格的,那考虑问题的时候就得站在微服务的角度而不能仍然使用单体的思维来看待问题,说白了就是需要把眼光放宽一点。分布式系统有一个重要的特性您时刻都不能忘掉的即“CAP”,大师已经证明了您只能选择一种,要不是“AP”要不就是“CP”。不仅是那些我们常用的中间件如此,您所做的业务系统也需要一同考虑。为什么很多人会忽略这一点?因为我们使用的这些中间件也好,工具也好,人家已经帮你决定了到底“AP”或“CP”。比如Zookeeper,雅虎帮您确认这个就是“CP”的,用户不用操心这些事情,直接使用即可。这种问题造成了很多的软件工程师在建设分布式系统的时候时常忽略“CAP”这个东西,也就造成了对于上述的案例先入为主的认为方案一比较好。那为什么我说评估方案的好坏要看业务需求呢?假如业务强烈要求你必须要保证账户的积分必须与订单支付保持同步,那方案二才是首选。当然,这里所谓的“强烈要求”需要工程师做好判断,从用户的角度来看他们肯定要求数据需要时刻保持同步尤其是不懂技术的客户,可是大多数的时候其实他们是容忍这种同步存在着延迟的。可以假想一下,如果没有系统的支撑,通过手工来实现业务是不是也存在不一致呢?说到这里您应该知道为什么DDD强调最终一致性了吧?因为的确是大多数情况下不需要严格保持数据的强一致性的。我在前面的文章中曾强调过在微服务风格系统中使用Saga代替强分布式事务是一种事实上的标准,也是由于业务的特性造成的,也就是说大多数业务其实只要实现AP就足够了。不过话又得说回来了,假如你做的系统出现长时间的数据不一致比如一天,那您也别怪用户怼你,谁也不能容忍如此夸张的延迟,我们所说最终一致性虽然没有一个标准规定这个最终要经历多久,那也不能几小时、几天都不一致吧?

  以DDD的眼光来看,其实方案二的问题是在建模上,没有对于需求中的“订单支付后”这个动作进行建模,不够纯粹。而领域事件的好处是其能够更加精确的表达通用语言。使用了领域事件后,您可以在需求中提炼出很多的领域模型,这样会使得建模的工作做得很细致,十分有利于挖掘到业务的本质。当然,这话就有点虚了,具体的好处是你对业务本质认识的越清楚做出的系统就会更加健壮,可扩展性也更强。写了这么多东西,其实虽然只有这一句话“领域事件能够更加精确的表达通用语言”对应了标题,不过那些陪衬的内容也是精华,加紧找个小本本儿记下来。

三、领域事件与领域命令

  领域事件从技术的角度来看其实就是消息,类似的还包括领域命令,说白了就是给消息一个业务术语(使用消息表示两者是比较普遍的情况,我们此处只谈主流的使用方式)。可就是这些术语才能对应我们的主题“领域驱动设计”,叫“消息驱动”总是差点意思。让我们先解释一下这两者的异同。

  相同方面:1)两者都需要使用通用语言来命名;2)都是对动作的建模,只不过一个表示已经发生,一个表示未发生;3)一般都以消息的方式来实现;4)都需要遵从相同的使用约束比如都应该放到BO层中;不应当在其中放入领域实体;5)一般都会触发额外的业务动作;6)针对两者的投递方式,主流方式是使用消息队列。

  不同方面:1)从业务上来看两者所表达的含义完全不同。领域事件表示某个已经发生的业务动作,是对于发生后的事件的建模;而领域命令所表示的动作还尚未发生;2)语义不同,事件所触发的动作具备被动色彩:某些业务动作被引发是由于某个事件发生了。您稍微注意一下会发现我这里使用了“某些业务动作”,说明一个事件可能触发多个业务行为。此外,事件的发布方在生成事件后并不期待事件的订阅方给出响应。领域命令在业务上表示主动的含义。命令产生方主动的发起某个动作,它十分期待收到命令的那个接收者给出响应,比如通过消息队列给出一个响应事件。这里还是需要注意一下命令的接收者数量:只能有一个。

  使用领域命令的场景以我个人的经历没法概括出全部,但在此列出有代表性的且经过个人实践过的两点:1)CQRS架构的应用,一般C端面使用异步的领域命令。因为使用了这种架构一般是由于高并发的需要,使用异步的消息模式能更好的应对;2)Saga,Saga的使用模式是接收事件并发送命令。使用事件的场景相对就会普遍很多,我觉得在使用DDD的战术方式进行系统建设的时候几乎多多少少的都会涉及到 ,最起码在有事务需求的时候少不了。

  理论说得天花乱坠,那么领域事件到底如何产生呢?咱们这不是严谨的学术型文章,所以我基于日常的实践总结出两种方式:1)领域模型或服务在做出某个动作后,将事件以返回值的形式生成;2)领域事件的组成需要的信息相对复杂,需要在应用服务中进行构建。方式一我在前面展示过代码此处便不再重复说明,方式二如下列代码所示。“(1)”部分所使用的“ApplyFormTerminated”事件需要“OperatorInfo”信息,而这个信息并不参与业务逻辑,所以我们直接使用事件的构造函数在应用服务中创建。

public CommandHandlingResult terminate(Long id, OperatorInfo operatorInfo) {    
    OprApplyForm oprApplyForm = this.oprApplyFormRepository.findBy(id);
    if (oprApplyForm == null) {
        throw new InvalidOperationException(OperationMessages.APPLY_FORM_NOT_EXIST);
    }

    oprApplyForm.terminate();

    TransactionScope tScope = TransactionScope.create(UnitOfWorkFactory.INSTANCE, oprApplyFormRepository);
    this.oprApplyFormRepository.update(oprApplyForm);
    CommitHandlingResult commitResult = tScope.commit();
    if (commitResult.isSucceed()) {
        this.localEventBus.post(new ApplyFormTerminated(operatorInfo, oprApplyForm.getId())); // (1)
    }
}

四、事件的组成

  事件本质上是一个实体对象,正常情况下不会在里面加入业务方法,即便有也不能修改其内部的属性。我个人在用的时候还会将其当作DTO一般来看待并让其具备值对象的不变特性,不会将事件作为某个实体的属性,也不会在其中嵌入任何的实体或值对象,所有的属性皆使用基本类型。实践中,我们一般会给事件一些公共属性如事件源即由谁来触发的事件、事件产生的日期、事件ID等、请参看如下示例。

public class DomainEventBase {
    private String sourceService;
private Object sourceAggreateId;
private String id; private Date occurredOn; }

  此处我多废话两句。针对事件的来源“sourceService”,我一般情况下会把产生事件的类的全名+服务名赋给它。有的时候我们在应用中会发布各种各样的事件,在排查问题的时候你都不知道这个事件到底是谁发出来的,又没有文档来作为指导,项目着急上线也没人写那个东西。大多数文档都是系统上线后、验收前后补的,做过开发的人你懂的……。这个字段可以很有效的帮助排查问题。“sourceAggreateId”表示产生这个事件的聚合的ID。注意一点,我们这里把事件称之为“领域事件”,表示其作用范围在整个领域内。比较现实的情况是并不是所有的限界上下文的实现都使用对象驱动的方式,存在着大比例数量的服务使用了事件脚本。在这种情况下虽然没有聚合的概念但不代表不能产生事件,所以我一般也会把某个数据实体的ID赋给“sourceAggreateId”。最后要说的是“id”这个属性,表示事件的ID,建议把它加到事件中。因为对于事件的幂等性处理几乎是一种事实上的标准,您可以使用一些业务信息作为幂等的判断标准,也可以使用事件ID,比如把它放到Redis中。收到事件后可以判断ID是否在Redis中存在来决策是否要正常的处理这个事件。

五、事件的载体

  前面我们说过事件在技术上可以等同于消息,不过并不是一个严格的定义。你当然可以使用比如REST进行事件的传输,这种方式虽然能满足通用语言的需要但不能享受事件所带来的性能上的提升。既然主流的使用方式是消息队列 ,那我们在实践其实有很多的选择。可以使用基于内存的BlockingQueue、Guava EventBus,也可以使用大型的分布式消息队列如Kafka、RabbitMQ等。涉及消息中间件的部署与结构不是本文的重点,所以我们只谈应用。这两种方式在实践中我都使用过,基于内存的自治性很好,也就是说你不需要依赖于外部的消息队列,不会因为队列出现问题而导致应用不可用。基于内存的优势还在于你通常情况下只需引用一个Jar包即可,拎包入住,在不怕消息丢失的场景这是一个很好的选择。所以您在使用前要评估一下是否可以容忍消息的丢失,毕竟应用一重启消息也就丢了。但无论如何最好别自己写一套新的,好多的现成工具可用何必重新造轮子,你能保证你写得一定比Guava EvenBus好?

  另外一点就是消息队列的可靠性需要多加思考,比如如何避免消息的丢失就是一个很值得投入精力的地方。当然,想保障消息不丢失,首先在消息队列中间件的选择上就不能随意了。你整个内存型的消息队列还要要求消息处理的可靠性基本上没戏。我个人经历的项目中使用过两种分布式MQ:RabbitMQ和Kafka,在此我们只以前者为例介绍一下如何保障消息的不丢失。通常下我们可选择三种方式来进行保障:1)生产者使用Confirm机制,出现投递问题后将消息写入到数据库以用于重试;2)配置消息队列的时候开启“Durable”模式并将消息在服务器端进行存储(注意:此处使用的是消息队列集群,单实例无论你怎么折腾都没戏);3)消费者开启ACK机制。这里面的前两点消息队列都可以帮忙实现,而在消费端的消息不丢除了ACk能起到部分作用外,还需要消费者进行保障,简单来说只要消息到达消费者就必须保障其成功的处理,类似于“TCC”事务中的“Confirm”处理。这一点不仅是针对RabbitMQ,包括Kafka、RocketMQ等都是一样的要求。

  还有一点需要着重说明:在消息的发送端仅使用“Confirm”机制是不能保障消息完全不丢失的。比如下列代码。“(1)”处的代码提交了一个数据库的事务,假如此刻系统挂掉,事件也就一并丢失了。这种情况比较极端但不代表不发生。据小道消息说“本地消息表”方案可以解决这个问题,但到底要不要真的引入还请慎重。我们在生产者、消费者和消息队列配置上下得功夫已经不少了,已经能大大的保障消息不丢。而引入本地消息表又要做很多的工作。所以在考虑人工的介入还是严格的系统约束间要找到平衡,尽管作为一个技术人员我不应该说这种不负责任的话,但实现本来与理想就是存在差距的。

public class orderService {
    public void pay1(Long orderId, Money cost) {
        Order order = this.orderRepository.findBy(orderId);
        OrderPaid orderPaid = order.pay(cost);
        
        this.orderRepository.update(order);
        this.uniteOfWork.commit(); // (1)
        
        this.eventBus.post(orderPaid);
    }
}

  其实我个人也经常在项目中使用内存型的消息队列Guava EvenBus,当时的使用场景是对业务告警进行接收并用于后续的处理。虽然可能面临消息丢失风险,但偶然丢个一条两条其实也不会造成多大的影响。因为业务异常有一个特性:其往往是重复错误,丢失部分消息并不会有多大的问题。之所要提到这个事情其实就是想提醒读者在项目建设的时候要一定要考虑系统建设的成本,原则上我们肯定要求不能有任何消息的丢失,但这个事情得从两个方面看而且绝对不可以上纲上线,极左或极右都不可能把事情做好。

六、事件处理

  我们已经说过,一个事件会有多个订阅者。 在六边型架构中,事件的“Adapter”处在架构的左侧作为事件的输入,但您不应该在Adapter中完成事件的处理而是应该和一般的REST调用一样使用应用程序服务进行业务的协调处理。这里有一点需要特别的注意即事件的“幂等性”,实际上在基于消息的业务场景中大部分情况下都需要考这个事情 。可能由于网络、消息组件和消费者处理异常等原因需要进行消息的重发;当事件有多个订阅方的时候,如果有一个订阅方出现失败可能也需要进行业务补偿,而最简单的补偿方式就是把事件重发一次。总之呢,同一个消息被重复的收到多次是非常常见的场景,那您在使用的时候就必须要投入精力做好保障。前面我们曾经说过,您可以给事件一个唯一ID比如“UUID”并在消费端把ID进行存储以达到排重的目的;您也可以通过使用业务标记进行排除,这种方式在使用Saga的时候会经常被使用以达到事务的隔离效果。下面代码片段来自于我曾经做过的一个项目,此处使用业务信息来决策某个事件是否被收到过如“(1)”处。

public void handle(WorkOrderAccepted workOrderAccepted) {
    if (this.status == ResourceBuildStatusEnum.UN_START) { // (1)
        this.status = ResourceBuildStatusEnum.SAVING_WORK_ORDER;
        this.updatedDate = new Date();
        this.message = this.status.getDescription();

        SaveWorkOrder saveWorkOrder = new SaveWorkOrder();
        saveWorkOrder.processManagerId = this.getId();
        this.commands.add(saveWorkOrder);
    }
}

  针对事件的存储,这个其实要看具体的需要。如果不是使用ES架构的服务,至少要对核心的事件进行持久化,十分有利于后续系统的运维。由于事件是只读的,其存储的记录也不会进行更改。所以不论是使用MySQL这种关系型数据还是使用MongoDB这种NoSQL,并没有太大的限制,主要看您的系统现状。不过在运维工作中有一点请务必要注意:请对事件记录进行周期性转存。一是可以方便后续的安全审计,二是可以减少其数据占用量以避免与其它业务数据发生空间争抢。我个人在使用的时候直接存到了MySQL中,和业务数据进行了分离,每隔一个月备份一次数据。其实也只起到了备份的作用,平常几乎不查。对了,最好在事件生产侧进行存储,万一丢了呢。

 七、反思

  微服务架构下的事件使用,存在这样一个场景,我们还是以本章中的“订单支付后需要给其所属账户增加10点成就值”这个需求为例。假如订单服务发布了一个“OrderPaid”事件,在账户服务中要如何进行处理呢?我们是否需要设计一个和“OrderPaid”结构一模一样的类且保持“OrderPaid”命名不变,简单来说就是把这个事件的代码复制到账户服务中。另外一个选择是我们在账户服务中建立一个和“OrderPaid”结构一样但叫做“ChangeRewardPoint”的领域命令,使用命令代替原来的事件来处理“积分变更”这个业务。请发挥您的聪明才智,也期待您的回复。

总结

  本节讲解了领域事件的使用,在实践中请您结合自身的业务需求尤其是基于“CAP”理论来决策是否应该使用,不要被先入为主的想法蒙蔽双眼。我们还讲解了事件的通常结构、事件的载体和事件的存储。您别一时用得痛快结果由于不能全面考虑造成后续运维成本的加大。我个人的工作经历中有一段时间是作为运营运维的角色存在,相信您在我的文章中总会看到我会提及系统的运维。个人其实更中意软件设计与研发的工作,可也正是因为这段运维经历让自己在考虑事情的时候不会那么局限,能够站在不同的维度去思考。

  客观来讲,基于事件驱动的服务用起来的确很痛快。一是建模的粒度比较细,让系统的扩展点增加了很多。很多的时候加个功能不过是增加一个事件的消费者而矣,并不会因为新加入的逻辑引发全局BUG或性能损耗。二是系统的性能会有很多的提升,服务解耦处理做得也比较优雅。然而事情有利也有弊,请客观的、务实的、谨慎的进行选择。

posted @ 2022-05-06 19:56  SKevin  阅读(1558)  评论(6编辑  收藏  举报