你一定看得懂的 DDD+CQRS+EDA+ES 核心思想与极简可运行代码示例

前言

随着分布式架构微服务的兴起,DDD(领域驱动设计)、CQRS(命令查询职责分离)、EDA(事件驱动架构)、ES(事件溯源)等概念也一并成为时下的火热概念,我也在早些时候阅读了一些大佬的分析文,学习相关概念,不过一直有种雾里看花、似懂非懂的感觉。经过一段时间的学习和研究大佬的代码后,自己设计实现了一套我消化理解后的代码。为了突出重点,避免受到大量实现细节的干扰,当然也是懒(这才是主要原因),其中的所有基础设施都使用了现成的库。所实现的研究成果也做成了傻瓜式一键体验(我对对着黑框框敲命令没什么兴趣,能点两下鼠标搞定的事我绝不在键盘上敲又臭又长的命令,敲命令能敲出优越感的人我觉得应该是抖M)。

正文

DDD(领域驱动设计)

这一定是最群魔乱舞的一个概念,每个大佬都能讲出一大篇演讲稿,但都或多或少存在差异或分歧,在我初看 DDD 时,我就被整懵了,这到底是咋回事?

现在回过头来看,DDD 其实是一个高阶思想概念,并不能指导开发者如何敲键盘,是指导人如何思考领域问题,而不是指导人思考出具体的领域的。正是因为中间隔了一层虚幻飘渺的概念,导致不同的人得出了不同的结论。还好 DDD 存在一些比较具体容易落实的概念,现在就来讲下我对这些常见基础概念的理解和我编码时的基本原则,希望大家能在看大佬的文章时不用一脸懵逼,也进行下心得交流。

Entity(实体)

实体是一个存储数据的类,如果类中包含自身的合法性验证规则之类的方法,一般称之为充血模型,相对的单纯保存数据的则称为贫血模型(有时也叫做 POCO 类)。实体有一个重要性质,相等性是由标识属性决定的,这个标识可以是一个简单的 int 型的 Id,也可以是多个内部数据的某种组合(类似数据库表的复合字段主键)。除标识外的其他东西均不对两个实体对象的相等性产生影响。并且实体的数据属性是可更改的。

有很多大佬认为实体应该是充血的,但在我看来,贫血的似乎更好,因为需求的不稳定性可能导致这些规则并不稳定,或规则本身并不唯一,在不同场合可能需要不同规则。这时候充血模型无论怎么办都很别扭,如果把规则定义和校验交给外部组件,这些需求就很容易满足,比如使用 FluentValidate 为一种实体定义多套规则或对内部的规则条目按情况重新组合。

ValueObject(值对象)

值对象也是用来存储数据的类。与实体相对,值对象没有标识属性,其相等性由所有内部属性决定,当且仅当两个值对象实例的所有属性一一相等时,这两个值对象相等。并且值对象的所有属性为只读,仅能在构造函数中进行唯一一次设置,如果希望修改某个值对象的某一属性,唯一的办法是使用新的值对象替换旧的值对象。并且值对象经常作为实体的属性存在。

这个概念看起来和实体特别相似,都是用来存储数据的,但也有些性质上的根本不同。网上的大佬通常会为值对象编写基类,但我认为,值对象和实体在代码实现上并没有这么大的区别。可以看作整数和小数在计算机中表现为不同的数据类型,但在数学概念上他们没有区别,仅仅只是因为离散的计算机系统无法完美表示连续的数学数字而产生的缝合怪。我倾向于根据类的代码定义所表现出来的性质与谁相符就将其视为谁,而不是看实现的接口或继承的基类。因为需求的不确定性会导致他们可能会发生转换,根据代码进行自我描述来判断可以避免很多潜在的麻烦。

Aggregate,Aggregate Root(聚合及聚合根)

聚合根表示一个领域所操作的顶级实体类型,其他附属数据都是聚合根的内部属性,聚合根和其所属的其他实体的组合称为聚合。这是一个纯概念性的东西。对领域实体的操作必须从聚合根开始,也就是说确保数据完整性的基本单位是聚合。大佬的代码中经常会用一个空接口来表示聚合根,如果某个实体实现了这个接口,就表示这个实体可以是一个聚合根。请注意,聚合根不一定必须是顶级类型,也可以是其他实体的一个属性。这表示一个实体在,某些情况下是聚合根,而其他情况下是另一个聚合根的内部属性。也就是说实体之间并非严格的树状关系,而是一般有向图状关系。

我认为定义这样的空接口实际意不大,反而可能造成一些误会。如果某个实体由于需求变动导致不再会成为聚合根,那这个实体事实上将不再是聚合根,但人是会犯错的,很可能忘记去掉聚合根接口,这时代码与事实将产生矛盾。所以我认为聚合根应该基于事实而不是代码。当一个实体不再会作为聚合根使用时,将相关代码删除,就同时表示它不再是聚合根,阅读代码的人也因为看不到相关代码而自动认为它不是聚合根。在代码中的体现方式与下一个的概念有关。

Repository(仓储)

仓储表示对聚合根的持久化的抽象,在代码上可表现为声明了增删查改的相关方法的接口,而仓储的实现类负责具体解决如何对聚合根实体进行增删查改。例如在仓储内部使用数据库完成具体工作。

如果一个仓储负责管理一个聚合根实体的持久化或者说存取,那这个实体就是一个事实上的聚合根。那么在这里,就可以在代码操作上将看到某个实体被仓储管理等价为这个实体是聚合根,反之就不是。也就是说,如果将某个实体的仓储的最后一个实际使用代码删除,这个实体就在事实上不再是聚合根,此时代码表现与事实将完美同步,不再会产生矛盾。至于由于没看到某个实体的仓储而将实体误认为不是聚合根,这其实并没有任何问题。这说明在你所关注的领域中这个实体确实不是聚合根,而这个实体可能作为聚合根使用的领域你根本不关心,所以看不到,那这个实体是否在其他领域作为聚合根使用对你而言其实是无所谓的。

Domain Service(领域服务)

这就涉及到业务代码的编写了。如果一个业务需要由多个聚合根配合完成,也就是需要多个仓储,那么就应该将这些对仓储的调用封装进一个服务,统一对外暴露提供服务。

如果这些仓储操作需要具有事务性,也可以在这里进行协调管理。如果某个业务只需要一个仓储参与,要不要专门封装一个服务就看你高兴了。

CQRS(命令查询职责分离)

CQRS 本质上是一种指导思想,指导开发者如何设计一个低耦合高可扩展架构的思想。传统的 CURD 将对数据的操作分为 读、写、改、删,将他们封装在一起导致他们将紧密耦合在相同的数据源中,不利于扩展。CQRS 则将对数据的操作分为会改变数据源的和不会改变数据源的,前者称为命令,后者称为查询。将他们分别封装能让他们各自使用不同的数据源,提高可扩展性。

其中命令是一个会改变数据源,但不返回任何值的方法;查询是会返回值,但绝不会改变数据源的方法。但是在我的编码中,命令是可以返回值的,至于要返回什么,根据实际情况调整。比如最简单的返回一个 bool 表示操作是否成功以决定接下来的业务流程该走向何方,这是很常见的情况。所以在我的概念里,一个方法是命令还是查询实际上只看这个方法是否会改变数据源,要封装在一起还是分别封装都无所谓。建议分开封装到不同的仓储中,通过仓储关联到具体的数据源,命令和查询的仓储关联到不同的数据源的时候,自然就完成了读写分离。通过起名来明示方法的目的应该可以轻松分辨一个方法属于命令还是查询。只要脑子里有这个概念,要实现扩展办法多的是。

事件驱动架构(EDA)

可以说所有图形界面(Gui)编程都是清一色的事件驱动架构,这东西一点也不稀奇。说白了,EDA 就是一种被动架构,通过某些事情的发生来触发某些操作的执行,否则系统就随时待命,按兵不动。

EDA 的实现需要一个中介才能实现,在 Windows 中,这个东西叫做 Windows 消息队列(消息循环)和事件处理器。同样的,在非 Gui 编程中也需要这俩东西,但通常被称为消息总线和消息消费者。在分布式系统中,这个中介将不与系统在同一进程甚至不在同一设备中,称为分布式消息总线。这样在开发时可以分成两拨,一拨负责写生产并发送事件的代码,一拨负责写接收事件信息并进行处理的代码。他们之间的沟通仅限于交流关心的事件叫什么以及事件携带了什么信息。至于产生的消息是如何送到正确的消费端并触发消费处理器的,那是消息总线的事。如果一个消息总线需要这两拨人了解中间的过程甚至需要自己去实现,那这个消息总线是个废品,也起不到什么解耦的效果,甚至是个拖后腿的东西。

EDA + CQRS

当他们结合在一起,就产生了命令或查询的发起和实际处理实现可以分离的效果。命令的发起方向命令总线发送一条命令消息并带上必要参数,消费方收到消息后获取参数完成任务并返回结果。命令可以看作一种特殊的事件,命令只由一个命令处理器处理,并可向发送方返回一个处理结果;事件由所有对同种事件感兴趣的事件处理器处理,不向事件发送方返回任何结果。

事件处理器的执行顺序是不确定的,所以任何事件处理器都必须独立完成事件处理。如果两个事件处理之间存在因果依赖,应该在前置事件处理后由事件处理器发布新事件,并由后置事件处理器去处理前置事件产生的新事件,而不是让它们处理同一事件。

ES(事件溯源)

事件溯源表示能追查一个事件的源头,甚至与之相关的其他事件的概念,说句大白话就是刨祖坟。ES 对历史状态回溯的需求有着天然的支持,最常见的如撤销重做。而 ES 一般会配合 EDA 使用,ES 保存 EDA 产生的事件信息,并且这些信息有只读性和因果连贯性。这顺便能让我们对系统中的实体究竟是如何一步一步变成现在这个样子有一个清晰的了解。毕竟实体具有可变性,实体信息一旦改变,旧的信息就会丢失,ES 刚好弥补了这个缺陷。

代码展示说明

此处的事件消息中介使用 MediatR 实现。

接口

DDD 相关

实体

定义一个实体的基本要素,实现接口的类就是实体,值对象没有接口或基类,只看代码所展现的性质是否符合值对象的定义,聚合根没有接口或基类,只看实体是否被仓储使用,领域服务说白了就是个打包封装,根据情况来决定,例如重构时提取方法即可视为封装服务。在此处可简单认为没有实现实体接口的数据类是值对象:

12345678910111213/// <summary>
/// 实体接口
/// </summary>
public interface IEntity {}

/// <summary>
/// 泛型实体接口,约束Id属性
/// </summary>
public interface IEntity<TKey> : IEntity
    where TKey : IEquatable<TKey>
{
    TKey Id { get; set; }
}

仓储接口

仓储接口细分为可读仓储和可写仓储,可写仓储有一个分支为可批量提交仓储,表示修改操作会在调用提交保存方法后批量保存,也就是事务(就是用来替代操作单元的,这东西就有一个提交操作,名字也莫名其妙,我曾经一直无法理解这东西是干嘛的),接口声明参考 EF Core,示例实现也基于 EF Core。由于已经公开了查询接口类型的 Set 属性,使用者可以任意自定义查询。

 

public interface IBulkOperableVariableRepository<TResult, TVariableRepository, TEntity>
    where TEntity : IEnti
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值