安然写字的地方

关山难越,谁悲失路之人?萍水相逢,尽是他乡之客

0%

在实践DDD的道路上,我们一开始容易被各种概念带偏,写出各种样板式代码。首先定义聚合 实体 值对象,对聚合操作要有Repository,当聚合比较复杂时还要有Factory,后面又冒出来一个领域服务的概念,这个是大家最容易迷惑的,领域服务不是领域内的服务,不是对领域能力包装成的一个服务,领域服务是实在不知道放到哪个聚合下才妥协的产物,领域服务能不用最好就不用,因为它本来是不该存在的。同时传统DDD在实践过程中增加的读写放大,多次操作数据库怎么优化,没有给出很好的解法。

综上:我觉得这也是大家实践起来一个很大的门槛

CQRS的迷思

近些年,对我影响最大的一个概念就是CQRS,在各种业务场景的设计中,或多或少都会有类似的设计,相比传统的CRUD,CQ是一种思维,在遇到业务场景时,会主动的识别Command和Query,因为两者面临要解决的问题域不尽相同,现有的存储体系,读和写几乎是不可兼得的,所以我们会有异构数据源,针对读库的个性化定制又特别多,写模型又要保障很好的性能,稳定性和扩展性。CQRS的思路我觉得是现有体系下比较合理的一个解

EventSourcing

事件溯源,比较难落地的一个概念,短平快的互联网应用,要记录每次操作的事件,无疑是一种负担

最终一致性

当我们采取了传统DDD的架构思路,为了写出符合DDD的代码,在复杂技术的实现上还要考虑到会不会兼容这种写法,为了实现聚合间的最终一致,我们的分布式事务如何做

DDD应用框架

enode提供了一个思路解决上述问题,和axon的解决思路还不太一样

整体架构

这个是enode的架构图
image.png

使用约束

  • 一个命令一次只修改一个聚合根
  • 聚合间只能通过领域消息交互
  • 聚合内强一致性
  • 聚合间最终一致性

核心思路

一个命令一次只修改一个聚合根

首先做这个限制是从业务研发的角度来考虑的,这会让命令的职责更加具体,便于问题的拆解,职责的划分,如果一个命令要修改多个聚合根,应该通过Saga(Process Manage)来完成

加上这个约定后带来的收益:

  • 同一个聚合根的命令操作都会路由到同一个分区,聚合根就可以常驻内存(In-Memory),这样就不必每次重建聚合根,缓存利用率聚合是100%,是一种大限度利用内存的设计
  • 命令路由到同一个分区,命令的操作顺序就可以保障(命令会携带聚合根的版本),这就保障了聚合根在同一时刻只有一个在操作,直接避免了并发问题,因为在设计上是无锁的
  • 关于命令操作顺序的保障,为了提升吞吐,要求队列是无序消费,但队列无序了怎么保证操作是有序的呢,这点就有点类似Flink中的watermarker的设计了,聚合根的mailbox会记录每个消息的版本,如果高版本的数据先到,数据就会暂存,等到中间的版本处理完成才处理,通过mailbox中的顺序保证了操作的有序

基建依赖

分布式消息队列

依赖队列的原因主要有三点:

  1. 面向不同服务场景资源隔离,可针对性的优化
  2. 为了C端高吞吐,可通过队列无限扩缩容,且节省资源
  3. 为了同一个聚合根路由到同一个消费者,减少聚合的重建,缓存利用率高

EventStore

在存储方面需要额外保障两张表,这个也是不得已而为之的一个设计,因为要实现EventSourcing,事件记录总要有个地方放
事件表(event_stream),聚合根消费进度表(published_version)
限定要提供的能力:

  1. 批量提交事件,同时能识别出哪些是重复命令和重复的版本
  2. 通过聚合根id和commandId点查
  3. 通过聚合根id和version点查
  4. 通过聚合根id和最小最大version范围查找

同时留了扩展,可自主的选择实现,目前提供默认的实现(MySQL,PG,MongoDB)

编程模型

事件驱动的迷思:

  • 什么时候采取事件驱动,什么时候使用过程式编程呢?

  • 命令和事件的区别,两者都是消息,为什么要分开表示呢?

我的理解如下,

命令可以被拒绝。事件已经发生。

这可能是最重要的原因。在事件驱动的体系结构中,毫无疑问,引发的事件代表了已发生的事情。

现在,因为命令是我们想要发生的事情,并且事件已经发生了,所以当我们命名这些事情时,我们应该使用不同的词,命令一般是名词,事件一般是过去分词

举个例子,拿订单系统来说,我们有个外部支付系统的依赖。

当用户在支付系统完成支付后,支付系统会向订单系统发送一个Command,MarkOrderAsPayed(标记订单已支付),订单在处理这个Command时,获取当前订单,调用订单的标记已支付(行为),产生了OrderPayed(订单已支付)事件。

我们可以看到,命令通常由系统外调用,事件是由处理程序和系统中的其他代码提供的。

这是他们分开表示的另一个原因。概念清晰度。

命令和事件都是消息。但它们实际上是独立的概念,应该明确地对概念进行建模。

这两者我理解都是符合人类思维的,首先是基于大脑接收到感知到的消息(Event)产生一个想法【意图】(Command),然后如何实现这个想法,思考的维度是过程式的,在实现的过程中,会产生一些事件消息,这个消息又会影响到大脑。如此循环往复。

事件风暴

简单说下事件风暴的一些经验

  • 按照用例维度开始分析
  • 每个用例以终为始
  • 先枚举主流程,然后补充异常处理,MECE,逻辑闭环,穷尽
  • 补充actor和命令信息
  • 最后补充Policy

Saga实现

Saga的实现有两种模式,一种是控制,一种是编排,通过事件消息,完全不需要业务关心技术实现的过程,通过和业务专家沟通需求,事件风暴,画出命令事件,逻辑是不是立马就清晰了。通过打点也可以把可观测性做的很好,同时分布式的事务模型,Saga在性能方面更有优势,这里和axon的unit of work有着本质的区别。

什么是领域

领域是指组织的业务范围以及在其中所进行的活动,也就是平台能力需要支撑的业务范围。因此,为了构建出平台能力,需要对业务领域进行深入的理解和设计。

在业务架构部分,将进行领域战略层级的建模,主要包括:“子域”、“领域对象”、“领域事件” 部分的设计。

领域事件识别

领域事件(Domain Event): 是领域专家关心的,在业务上真实发生的事件,这些事件对系统会产生重要的影响,如果没有这些事件的发生,整个业务逻辑和系统实现就不能成立。我们可以通过领域事件对过去发生的事情进行溯源,因为过去所发生的对业务有意义的信息都会通过某种形式保存下来。 比如:“用户地址已更新”、“订单已发货” 等领 域事件。
领域事件对系统常见的影响有:
• 对内

  • 产生了某种数据
  • 触发了某种流程或事情
  • 状态发生了某种变化

•对外

  • 发送了某些消息

目前比较常用的领域事件识别方法是“事件风暴 (Event Storming)”,
主要步骤如下:
• 邀请业务专家(或领域专家)和技术专家共同 参与事件风暴工作坊,其它参与角色按需补充。
• 明确和选择需要分析的业务场景。
• 确定起始事件和结束事件,事件以“XXX 已 YYY”的形式进行命名(对于英文版过去完成时的中文表达方法)。
• 根据场景和业务复述的复杂度,决定以时间线的哪个方向开始梳理事件(正向或逆向)【注】有些理论会要求从后往前识别。
• 以先发散再收敛的方式,按照时间线的先后顺序和并行关系,补充和完善领域事件。
• 使用“规则”抽象分支条件或复杂的规则细节,通过抽象降低分支复杂度,规则以“XXX规则” 的名词形式进行命名。
• 完成一遍事件梳理之后,通过问问题的方式,逆向检查(Reverse Check)事件流的逻辑合理性,例如:

  • 该事件真的需要在系统实现的时候考虑吗?
  • 该事件如果存在,那它的前提条件是什么?
  • 该事件如果要产生,那它的前一个事件必须是?
    • 重复以上步骤,迭代式的完成全部领域事件的识别。

领域对象识别

领域对象(Domain Object):是对业务的高度抽象,作为业务和系统实现的核心联系,领域对象封装和承载了业务逻辑,是系统设计的基础。
领域建模中重要的部分之一就是对“领域对象”及 领域对象之间关系的识别和设计。而领域对象识别 将基于前面领域事件识别的结果开展。
领域对象,通常包含(但不限于):
• 领域事件中出现了的名词;
• 如果没有信息系统,在现实中会看得见摸得着的事物(例如订单);
• 虽然在当前业务中看不见摸不着,但是可以在未来抽象出来的业务概念。

在领域驱动设计(Domain-Driven Design)中一般存在三类领域对象:
聚合根: 是领域对象的根节点,具有全局标识,对象其它的实体只能通过聚合根来导航;
如订单可以分为订单头和订单行,订单头是聚合根,它包含了订单基本信息;订单行是实体,它包含订单的明细信息,聚合跟所代表的聚合实现了对于业务一致性的保障,是业务一致性的边界。

实体: 是领域对象的主干,具有唯一标识和生命周期,可以通过标识判断相等性,并且是可变的,如常见的用户实体、订单实体;

值对象: 实体的附加业务概念,用来描述实体所包含的业务信息,无唯一标识,可枚举且不可变,如收货地址、合同种类等等。

业务架构只负责初步和整体识别领域对象,而对领域对象的分类(聚合根、实体、值对象)和战术层级的详细设计将在应用架构设计部分完成。

领域对象识别的主要步骤如下:
• 对每一个领域事件,快速识别或抽象出与该领域事件最相关(或隐含的)的业务概念,并将其以名词形式予以贴出。
• 检查领域名词和领域事件在概念和粒度(例如数量,单数还是集合)上的一致性,通过重命名的方式统一语言,消除二义性。
• 如果在讨论过程中,有任何因为问题澄清和知识增长带来的对于之前各种产出物的共识性调整,请不要犹豫,立刻予以调整和优化。
• 重复以上步骤,迭代式的完成全部领域对象的识别。

子域划分

子域(Subdomain):是对问题域的澄清和划分, 同时也是对于资源投入优先级的重要参考。比如: “订单子域”、“物流子域”等,子域的划分仍属于业务架构关注范畴。

核心域(Core Domain): 是当前产品的核心差异化竞争力,是整个业务的盈利来源和基石,如果核心域不存在那么整个业务就不能运作。对于核心域,需要投入最优势的资源(包括能力高的人), 和做严谨良好的设计。

支撑域(Supporting Subdomain): 解决的是支撑核心域运作的问题,其重要程度不如核心域,但具备强烈的个性化需求,难以在业内找到现成的解决方案,需要专门的团队定制开发。

通用域(Generic Subdomain): 该类问题在业内非常常见,所以很可能有现成的解决方案,通过购买或简单修改的方式就可以使用。

子域划分的主要步骤如下:

根据“每一个问题子域负责解决一个有独立业 务价值的业务问题”的视角出发,可以通过疑问句的方式来澄清和分析子域需要解决的业务问题,例如“如何进行库存管理?(英文描述 类似 How to…?)”。

利用虚线,将解决同一个业务问题的限界上下文以切割图像的方式划在一起,并以“XXX 子域” 的形式对每个子域进行命名。

根据三种类型的子域定义,共同结合业务实际 (或者参考设计思维中的电梯演讲),确定每个子域的子域类型

识别对象是否是聚合根

假设有一个业务系统,里面有项目,项目成员的概念,只有项目成员才能访问该项目。这个例子中,项目成员是否是聚合根?

假设这个系统是一个任务管理系统,支持用户创建项目,项目管理员可以设置项目的成员,项目成员可以在项目下创建任务,并把任务分配给其他成员。
在我看来,成员和任务,相对项目而言是平等的,他们都是项目下的业务数据,我们不能因为他们在某个项目下才有意义而把它们聚合在项目下

判断一个对象是否是独立聚合根,一般就是看创建该对象的场景是否是独立的。判断一个聚合根是否要聚合其他实体,主要看其是否关心其他实体的存在,以及从自身完整性的考虑。

场景是否是独立怎么理解的?

针对上面的例子,创建项目,添加项目成员,创建任务,分别是三个不同的业务场景,分别对应三个聚合根;项目聚合根不关心下面有哪些成员,哪些任务。项目的目的只是为了隔离业务数据,所以对于业务数据来说,项目只是一个分类标识

我们设计领域模型时不能以用户为中心作为出发点去思考问题,不能老是想着用户会对系统做什么;而应该从一个客观的角度,根据用户需求挖掘出领域内的相关事物,思考这些事物的本质关联及其变化规律作为出发点去思考问题。

领域模型是排除了人之外的客观世界模型,但是领域模型包含人所扮演的参与者角色,但是一般情况下不要让参与者角色在领域模型中占据主要位置,如果以人所扮演的参与者角色在领域模型中占据主要位置,那么各个系统的领域模型将变得没有差别,因为软件系统就是一个人机交互的系统,都是以人为主的活动记录或跟踪;比如:论坛中如果以人为主导,那么领域模型就是:人发帖,人回帖,人结贴,等等;DDD的例子中,如果是以人为中心的话,就变成了:托运人托运货物,收货人收货物,付款人付款,等等;因此,当我们谈及领域模型时,已经默认把人的因素排除开了,因为领域只有对人来说才有意义,人是在领域范围之外的,如果人也划入领域,领域模型将很难保持客观性。领域模型是与谁用和怎样用是无关的客观模型。归纳起来说就是,领域建模是建立虚拟模型让我们现实的人使用,而不是建立虚拟空间,去模仿现实。

参考

可观测性

首先可观测性的三个维度,logging metrics tracing
应用中是有自己的日志系统的,那么为了统计metrics,同一份数据需要记录两次吗?
如何划分这个界线?毫无疑问,Metrcis和Logging是有数据重叠的。我们可以认为Metrics是对可观测性指标的一种度量,例如请求数,函数调用次数等,但是对于Metrcis来说,它有着自己独特的属性【聚合】。另外,我们知道我们记录日志(Logging)是以事件为元数据,即记录当前发生了什么,这是Logging的关注属性。
在构想产品全链路追踪系统时,类似的问题再一次出现,我在记录Tracing数据的时候,或多多少会有Logging的数据,在Tracing中我认为重要的是链路数据指标属性,例如调用了哪些函数栈,该请求处理时间是多少等等,同样我们会在函数中记录得到了哪些请求,即Logging,但Tracing也有着自己独特的属性——请求范围。

关于监控指标的计算与实时统计,目前想到的两种方案

  • 一种是硬编码,侵入性的打点
    这种易于扩展,定制结构化的逻辑

  • 一种是根据日志来收集信息
    这种要求有规范化的日志格式,约束起来容易出问题

我觉得常规监控组件应该提供便利的SDK来进行指标的计算,底层是不是用打日志的方式统计实现的就不是那么重要了。

在过去,一个物理机器的状态确实可以通过几个监控指标描述,但是随着我们的系统越来越复杂,我们的观测对象正渐渐的从「Infrastructure」转到「应用」,观察行为本身从「Monitoring(监控)」到「Observability(观测)」。虽然看上去这两者只是文字上的差别,但是请仔细思考背后的含义。

参考

画图

在画时序图,类图时使用代码绘图会比手动画的效率更高,同时更容易表达思路

在画类图的应用中,最关注的应该是核心领域模型,以及各领域模型之间的依赖关系,属性、方法可以不用英文,便于理解就行

在画时序图时一定要分不同的场景case,这里我描述为用例,按用例画不同流程的时序图

看到的架构图经常会画一个四不像的流程图,把各用例的操作都包含进来,我觉得这不是一种好的实践,因为没有标准在里面,语义的表达也不够

代码绘图

反应式宣言

https://www.reactivemanifesto.org/

非反应式架构的问题(RPC/SOA)

  1. 同步等待,大量线程block,load高 资源利用率低
  2. 微服务化,在边界上划分开了,不能按照业务流程的依赖来并行,RT会累积
  3. 为了隔离RT累积引入Cache,超时时间,实现复杂

最近对这么一句话很有感触:
“reactive这味药吃多了容易伤身,真的用了就会回过头来求coroutine来救命。”
业务模块使用协程, 全局编排reactive才是合理思路。而不是为了背压全部上reactive

所以协程是否会取代反应式?
随着jvm loom项目和kotlin语言协程的推出,一些人认为反应式没有必要了,因为通过开销更小的协程来进行IO相关请求,同样可以提高CPU的利用率,但只是用协程,无法做到资源的最大化利用,并且无背压等支持,以及在处理实时数据流等业务场景中较为困难。如果反应式中基于线程池的调度转为基于协程池进行调度,这样可以很容易将只支持同步的SQL等阻塞IO改造为符合反应式的风格异步IO

优秀专题

对suspend的理解

suspend 标记只是一个提醒,作用在方法上只是表明“该方法会比较耗时,建议在协程中运行”
如果要实现程序的运行,需要将方法包装在这个结构中

1
2
CoroutineScope(Dispatchers.Default).launch{ }
CoroutineScope(Dispatchers.Default).async{ }

async/await做为库函数提供了
async的返回结果是个Deferred,类似CompletableFuture
kotlin提供了一个库可以将两者互转

详细资料

https://kaixue.io/kotlin-coroutines-1/
https://kaixue.io/kotlin-coroutines-2/
https://kaixue.io/kotlin-coroutines-3/

kotlin coroutine的实现

CPS,stackless

enode的整体线程模型的思考

技术选型采用了vert.x 实现异步反应式JDBC客户端,同时代码实现中存在大量的异步操作
如果不使用协程,会涉及到频繁的线程切换。由于C#中有好用的async/await语法
Java的实现要如何改造才好呢?这是个困扰我好久的问题,目前性能上不去我觉得和这个也有很大原因

另外,接口的实现,提供一个异步化的接口暴露出去是否合适,接口的设计需要优化,对应用中暴露的接口不应该出现Future之类的字样。

现有mailbox的实现,为了支持聚合根的批量提交消息,实现的有点复杂,我在想着能不能换成akka实现,同时使用disruptor
目前的策略,一个聚合根有一个command mailbox和一个 event mailbox,多个聚合根公用一批event commit mailbox,如果把这部分逻辑交给akka托管

消息返回类型选择

什么时候返回
三种模式

  • command消息发送完成
  • command消息执行完成
  • event消息消费完成

为什么一定要是顺序消息

对同一个聚合根的修改要路由到同一个队列中,如果rebalance或者不在同一个队列,会有并发冲突问题

间接模拟 async/await 语法糖

Java 中模拟async的执行时
如果是循环执行一个异步方法时,还要保证执行的顺序,这时可能就要借住递归的方式来实现

并发重复创建同一个聚合

两种情况
1.同一个命令并发执行创建同一个聚合根
在第一个命令执行未完成时,会直接抛出重复命令注册异常,如果第一个命令执行完成,则都幂等返回创建成功

2.不同命令并发创建同一个聚合根
开启批量提交时,检测出同时修改时,会抛出重复领域事件异常,错误返回

会出现一个创建成功,一个创建失败

published_version表的作用

记录对事件消息的消费进度

命令消息的顺序执行机制

command mailbox,类似actor,保证顺序消费

数据分类

  • 业务数据
  • 索引数据
  • 计算数据

设计思路

在做数据库设计时应该考虑几个点
索引和数据分离
数据和计算分离

做面向CQRS的业务系统设计是和这个思路完美契合的

场景分析

当我们的传统的MySQL无法满足 多样化的查询需求时,我们一般会引入lucene,ElasticSearch类似的搜索引擎
那么在设计时有个问题,是将业务中所有的数据都异构到搜索引擎中,还是只把索引字段的数据存储进去只查询出id

看似简单的选择后面其实会完全走向不同的设计

一股脑的把业务数据全部异构到搜索引擎里面,我能想到的优点就是

  • 依赖数据源只有一个,不需要回源数据
  • 代码实现简单

反之带来的问题

  • 索引构建频繁,业务中任何数据的变更都要同步到搜索引擎中
  • 吞吐量低

但如果采用数据回源的方案,优点我想到的如下:

  • 索引和数据分离,搜索引擎索引的数据量下来了,吞吐量大,可针对性的横向扩容
  • get by primary key操作对于任何数据库都是极快的,为了极致性能时,很容易针对性的做缓存优化
  • 更容易和DDD中聚合根结合起来
  • 修改索引时,对业务数据不影响
  • 计算和数据分离,数据和索引分离

实际案例选型

es在数据量很大的情况下(数十亿级别)提高查询效率
性能优化离不开filesystem cache,比如说你现在有一行数据。id,name,age …. 30 个字段。但是你现在搜索,只需要根据 id,name,age 三个字段来搜索。如果你傻乎乎往 es 里写入一行数据所有的字段,就会导致说 90% 的数据是不用来搜索的,结果硬是占据了 es 机器上的 filesystem cache 的空间,单条数据的数据量越大,就会导致 filesystem cahce 能缓存的数据就越少。其实,仅仅写入 es 中要用来检索的少数几个字段就可以了,比如说就写入 es id,name,age 三个字段,然后你可以把其他的字段数据存在 mysql/hbase 里,我们一般是建议用 es + hbase 这么一个架构。

hbase 的特点是适用于海量数据的在线存储,就是对 hbase 可以写入海量数据,但是不要做复杂的搜索,做很简单的一些根据 id 或者范围进行查询的这么一个操作就可以了。从 es 中根据 name 和 age 去搜索,拿到的结果可能就 20 个 doc id,然后根据 doc id 到 hbase 里去查询每个 doc id 对应的完整的数据,给查出来,再返回。

单元测试应该如何写

先上结论:

  • 不建议使用@Autowired直接属性注入,应使用构造器和setter方法注入

先抛出几个问题

  1. 按照应用的module划分模式,单测应该写在哪里?是每个module独立有自己的单测,还是在启动module中统一写单测?

  2. 单测时遇到的外部接口是否要采用mock的方式?

  3. 在Spring成为一种事实标准时,单测时一定要启动Spring容器吗? @Autowired的迷思?

简单来说下自己的思考

意图导向编程,测试用来表达接口设计契约
单测不是做测试,更是描述接口的职责

单测写在哪里主要是单测粒度的问题,每个module负责每个module的单测

单测时的外部依赖我们需要mock,减少我们对外部的依赖,保证程序的逻辑自治,外部依赖可替换

单测时一定要启动Spring容器吗,虽然Spring提供了很便利的测试工具,但每个测试都要启动容器,是不是带来了很多没必要的依赖,同时由于Autowired的便利性,我们可以很容易的将私有变量属性注入
这样就带来了一个问题,如果我们想要mock注入接口功能时,程序根本没有提供修改这个注入实例的方法
那为什么建议采用构造器注入呢,当我们想在这个类中增加功能时,这样可以思考下这个接口是不是应该在这个类中定义,会不会导致构造参数过多,而不是一味地增加私有属性变量