CQRS与Event Sourcing之浅见
引言
DDD是近年软件设计的热门。CQRS与Event Sourcing作为实施DDD的一种选择,也逐步进入人们的视野。围绕这两个主题,软件开发的大咖[Martin Fowler]、[Greg Young]、[Udi Dahan]分别有所论述,[MSDN CQRS Journey]、[Implementing DDD]、[Patterns, Principles, and Practices of DDD]等著述也提供了范例,国内外各大论坛的文章和DDD开源框架更是数不胜数,为学习CQRS和Event Sourcing提供了大量指导。
其中,Greg Young的论文最为系统。故本文参照其论文的结构,简单梳理了CQRS与Event Sourcing的发展脉络,厘出其中的主要技术重点进行解读,并提出以Akka作为落地方案,以求对这两个主题有一个较为全面的总结。错谬之处,还望指正。
本文并未就DDD的相关方法和战术模式等进行介绍。
传统系统等于数据库表
这是典型的传统架构,其中Application Service是Domain Model的屏风,负责与Client打交道:
在传统架构下,数据在UI、模型和持久化的数据库之间流动,遵循下图所示的循环。
系统从数据库中读出DTO(数据传输对象,Data Transfer Object)后,根据DTO与领域对象的映射规则,将其转换为领域对象,同时呈现在UI上。当用户在UI上完成修改后,又根据同样的映射规则,将其更新到领域对象上,同时持久化到数据库当中。最后,系统根据持久化结果刷新UI,完成一次领域操作,从而保证了从UI到领域对象,再到持久化数据之间的一致性。这当中,DTO只是为防止暴露模型细节而设计的领域对象的投影,UI则体现为对该DTO各字段的对照呈现。
很自然地,系统的所有业务流程也随之演变成一系列围绕DTO的CRUD操作(Create-Read-Update-Delete)。于是在很长一段时间里,下图这样千篇一律的界面发展成为MIS(信息管理系统,Management Infomation System)类软件的主流,图中左下角的记录导航条成为标配元素。在这种情况下,如果把DTO当作数据库里的一行记录,那么整个系统可以视作以DTO为Row、以CRUD操作为主要事务的一系列数据库Table。
传统架构利弊明显
传统架构(包括分层架构)简单直观,只要设计好数据模型,系统设计就算成功了一大半,而且所有写入操作都由事务包裹,能够达到强一致性要求。但它也有以下几个弊端:
- 在经过Application Service这层屏风后,用户意图全部被分解为CRUD操作,在领域对象之间无法得以体现。
- 为保证DTO的信息完整和数据一致性,部分与操作无关的信息也将一并被纳入DTO,查询和构造DTO将成为系统的主要任务,而领域模型的业务流程相应被肢解和冲淡。
- 完成一次领域操作,需要在DTO与领域对象间进行多次转译,增加了系统额外负担。这种转译被称为阻抗失配(Impedance Mismatch),其实质就是多维的对象图Graph与二维的关系Relationship之间相互转换时发生的、不可避免的信息丢失。
- 读写操作将围绕同一数据模型展开,即使有数据库分库分表方案支持,其效率也不可避免地要受到竞态影响。
- 当需求不断变化,逻辑日趋复杂,系统规模不断增大后,既需要DDD这样的方法提供战略上的指引,也需要设计模式和下文将要提到的CQRS、Event Sourcing等战术方法作为补充。
贫血模型换汤不换药
传统架构中的CRUD模式,其最大的弊端在于语义与操作的脱节。Application Service中的API通常代表着用例的某个方面,因此尚含有领域语义,比如API PlaceOrder()
表示用户下单。然而该API在到达内部模型后,就被拆解映射为CRUD操作Order.Create()
。相应地,API AddOrderItem()
被映射为Order.Update()
,CancelOrder()
被映射为操作Order.Delete()
,等等。这样的别扭,又在DTO转译的负担之上,给理解和维护模型带来了一定的困难。
于是,为了尽可能保留用户意图,我们首先想到通过命名规范和方法的二次封装,使CRUD操作在字面上接近API的语义,比如用Product.Rename()
封装Product.Update(Name = "NewName")
。但这样的做法并未能改变实质,因此即使套用了Aggregate、Value Object和Repository等DDD的战术概念,但这种完全以“DTO结构 + CRUD操作”为主要元素构成的模型,被Martin Fowler等人称为“[贫血模型]”(Anemic Model)。
接下来,吸取贫血模型的教训,开始着手建立富含领域行为的各种领域对象。当API PlaceOrder()
最终交给Order.Place()
完成时,工作似乎已经画上了圆满句号。
在函数式编程范式中,“抽象数据类型ADT + 代数方法”组成的模型,与“DTO + CRUD”的方式非常类似,但从函数式编程的视角,这才是最合理、最优雅的模型。那么,它究竟是不是贫血模型呢?😄
加入Command一举多得
当模型不再贫血之后,对充血模型中领域对象的方法调用,将与CRUD存在典型区别,因为传递给领域对象方法的不再是“肥胖的”DTO,而只有那些必要的少量参数,且方法名直接就表达了领域语义。比如,Order.RelocateAddress(address)
,而不是Order.Update(Address="NewAddress")
。
接下来,采用重构手法,将这些必要参数封装为Command对象,缩短方法调用的参数列表长度。进而在此基础上,引入[Command Pattern],将原本直接调用领域对象的方法,变成先构造Command对象、再委托Command对象执行统一的Execute()接口方法的两个步骤。
最后,再以Service API为请求方,Command对象为载体,领域对象的方法为Command Handler,使上述模式演变为Requst-Reponse Pattern,实现了API与领域对象方法之间调用关系的脱耦,接口变得更加一致和优雅。上面的例子就变成Service.Send(RelocateAddressCommand)
和Order.HandleCommand(RelocateAddressCommand)
。
至此,改进模型的工作应该算真正结束了吧。用户经Service API构造并发出Command对象,领域对象接收并处理Command对象,完成自身状态更新,然后把状态转译为DTO持久化到数据库。同时,Service API根据Command对象处理结果,将情况反馈给呈现层实现UI刷新。
这样的结果,虽然增加了系统的复杂度,但为实现Undo/Redo等复杂机制提供了基础,同时Command对象借助消息中间件传递,还可以实现Application Service Layer与Domain Model的跨主机部署,为分布式应用提供了条件。最关键的是,Command对象本身富含领域语义,其名称体现了用户意图,其字段限制了模型受影响的范围。
从中还可以得到如下的启示:
- 虽然Command同DTO一样都是静态结构,但它用命名更清晰地表达了“要模型做什么”的含义,而且其属性只包含了“做什么”所需要的必要信息,因而更能准确地表达用户的意图。
- Command与Command Handler组成了命令及其解释器的特定结构,Command的祈使时态也说明了它只是一种请求,可能会被拒绝。
- 在发现Command时,要尽量避免Create、Edit、Update、Change或者Delete这样的用词,而要去发掘RegisterCustomer、CorrectAddress或者RelocateCustomer这样更富含领域的用词,否则无疑会再回到CRUD的老路上(Udi Dahan在[演讲]里也特别提到Delete的问题)。
改进UI以适配新架构
走到这一步,Service API、Command对象、Domain Model这几方面都已经做到“面向领域”了,剩下的只有UI和持久化了。
Microsoft在[Inductive User Interface]指南中,总结了改进用户体验的一些建议,强调不要寄希望于用户完全了解软件的整体架构和工作原理、流程,而要尽量使用引导式、聚焦式的UI设计,帮助用户专注于当前某个具体领域行为,确保一次只完成一项任务。在目前架构条件下,UI是Command的发起人,所以UI的关注点可以相应地限制于Command所需的那部分,这便得到了Task-based UI。
之前的例子按Task-based UI的要求改造后,当用户点击列表项“已离职”下方的复选框时,就会弹出第二个对话框,提示填写离职的原因。
这样的UI设计变化,就好比论述题与填空题的区别。传统UI就象论述题,用户得知道解答论述题的套路:先解释主要概念,再回答特性、分类等等。而Task-based UI就象填空题,用户始终是在一个上下文里回答当前的提问,这样必然更直观和人性化。
引入CQS开辟新天地
经过前述改进,架构与循环分别变成下面这样:
如果把循环按左右一分为二,左半部分都执行的查询操作,右半部分都是写入操作,于是设想把API一分为二,其中Command部分的方法都没有返回值,但会修改聚合对象实例状态;Query部分的方法只返回查询结果,但不会修改任何东西。这便得到了CQS原则(Command Query Separation)。
关于CQS原则,Meyer的这句话非常准确:“Asking a question should not change the answer”。
在CQRS Journey的[Conference案例]中,ConferenceService就是典型的CQS示例。
CQRS和ES走入视野
在使用CQS原则对Service API进行切分后,进一步根据读写职责不同,把领域模型切分为Command端与Query端两个部分,便得到了下图所示的CQRS模式(命令与查询职责分离,Command and Query Responsibility Segregation)。Command端与Query端共享同一份持久数据,但Command端只写入状态,Query端只读取状态。
为进一步提高效率,读写端的持久数据分离成为必然选择,但也产生了新的矛盾——如何在两端进行数据同步,以达到最终一致性(Eventual Consistency)。
一方面,从CQRS模式的结构看,系统状态变化都发生在Command端,因此只有Command端掌握着具体是哪些内容发生了变化,如果把变化的这些内容封装在一起,表明系统“刚刚发生了哪些变化”,就得到了所谓的事件Event。
反观Query端,查询返回的总是反映系统当前状态的静态数据。根据“当前状态 + 变化 = 新的状态”,如果能从Command端得到“变化”,就能得到变化后的“新的状态”。而Event正好符合“变化”的定义,所以选择从Command端将Event推送到Query端,Query端根据Event刷新状态,就能保证两端的模型都反映系统的最终状态,达到最终一致性。
另一方面,在解决了取得最终一致性的难题后,还得设法改进数据的持久化。
首先能确定的是,从Query端查询得到的总是系统当前状态的静态数据,所以从传统架构一直沿用到CQRS模式下的DTO方案依然有效。但是,由于这样的DTO直接映射领域对象,会暴露领域对象细节,而且这种映射会产生阻抗失配,导致过多的间接查询和多聚合数据的联结,使优化查询变得非常困难。所以,为提高查询效率,可以采取类似关系数据库中“视图”的方式,直接面向数据模型,采用一切可使用的数据库技术,构造一个Thin Read Layer。
再是Command端的持久化。根据“初始状态 + 若干次变化 = 当前状态”,在初始状态上依次叠加每一次变化,同样能得到当前状态。其中聚合对象实例的初始状态是固定的,每一次变化即处理Command后产出的事件Event,那么只要保存好所有发生过的历史事件,就能从初始对象重现(Replay)到当前状态。所以,Command端的持久化最终演变成事件历史的持久化,这便是事件存储(Event Storage)。
最终,事件的产生、存储、推送和重现,即构成了完整的事件溯源(Event Sourcing)。
在CQRS与Event Sourcing的支持下,系统架构也相应地变成了下图这样:
探究新架构
CQRS使Event Sourcing成为改变和存储系统状态的核心机制。在这种模式下,由Application Service Layer统揽整个业务流程。Service首先从Query端查询系统状态,为执行Command准备好上下文环境;然后Service构造好Command,并发送给利用Repository.GetById()
加载(重现)得到的聚合对象实例;接着聚合对象实例使用内置的Command Handler完成命令处理,更新聚合状态,并产生Event,在其被持久化的同时推送往Query端;Query端收到Event后,对其自身维护的系统状态也进行更新,达到与Command端同样的一致,以迎接下一次Service的查询。
从上述过程可知,Service是一切活动的发起者和组织者,Command的执行环境均由Service准备,Command是活动内容的承载者,聚合是活动的执行者,而Event是活动的推动者。
同时要注意,Command本质是对领域模型的一种请求,可能会被模型拒绝执行(悲伤路径)。而Event则不同,它代表着系统刚刚完成了某项任务,必定发生了某种变化。Event的用语必定是肯定的过去式,而不仅仅是某个事实,比如应该是OpenFileFailed,而不是FileNotFound。
对需要多个步骤、跨越多个聚合协作才能完成的活动,本质上同样遵循上述循环,但为保证步骤间的有效衔接,又有一个新的模式Saga推出(在CQRS Journey和部分框架中,被称为Process Manager)。
Saga发出Command,也订阅Event。它在向某个聚合发出第一个Command后,就等待Event的回馈,然后根据该Event准备下一个步骤所需的上下文环境,接着向某个聚合发出下一个Command,再等待下一个Event回馈,如此周而复始,直到流程结束。
关于Saga应否有状态,争论也非常多。CQRS Journey第6章A Saga on Sagas专门就Saga进行了讲解。个人意见,Saga应当是无状态的(Stateless),否则还得花费额外精力去持久化Saga的状态。在这方面,可以参考Web服务的一些设计原则与方案。
⚡ 重要提示
“世上没有后悔药,只有亡羊补牢”——由于事件意味着改变已经发生,所以无法被Undo,因此在以事件驱动的系统里,没有还原和回滚,只有善后和补救,这是与以事务为中心的传统架构的重要差别。
此外,C端与Q端的差别主要有以下几点:
Command Side | Query Side | |
---|---|---|
一致性 | 通常使用事务维护强一致性。 | 通常采用最终一致性。 |
数据存储 | 为限制事务边界,通常要求符合第三范式。 | 为减少联结操作,通常满足第一范式即可。 |
扩展性 | 处理命令通常只占到系统事务很小的一部分,所以对扩展要求不高。 | 通常是命令处理量的数倍,因此对扩展性有较高要求。 |
方法 | 改变聚合对象实例的状态,而不返回任何结果(或者仅返回成功与否的标志)。Repository将剔除GetById以外的其他方法。 | 通常返回DTO给调用者,再呈现到UI。 |
数据来源 | 处理的目标即领域对象的本身。 | 处理的目标是DTO,但它已经从领域对象的投影演变成直接面向数据模型的特异化结构。 |
了解程度 | 对领域模型必须有完整的理解和掌握。 | 只需能理解数据模型并从中拼合出DTO即可,对业务规则等无需关注。 |
一些实现细节
Command与Event
Command的常见实现如下所示,其中AggregateId指示是由哪个聚合对象实例处理,Version指示在将Command发送给该聚合时聚合的最新版本,以备发生并发冲突时进行检验。
class Command {
Guid Id;
Guid AggregateId;
Int Version;
// 包含其他信息的字段
}
Event的常见实现与Command基本相同,区别只是AggregateId指示是由哪个聚合对象实例产生的Event,Version表示Event发生时聚合对象实例的版本。
Command与Event的Handler
聚合Aggregate是Command的处理器和Event的发布器,其Command Handler与Event Handler的基本结构如下:
class Aggregate {
public readonly Guid AggregateId;
public readonly List<Event> UnsavedEvents = new List<Event>();
public Int Version = 0;
public void HandleCommand(Command c) {
if (!Valid(c))
throw new AggregateException();
var e = new SomeEvent(AggregateId, ...);
this.HandleEvent(e);
e.Version = this.Version;
this.UnsavedEvents.Add(e);
DoAnythingWithSideEffect();
}
void HandelEvent(Event e) {
ModifyState();
this.Version ++;
}
public void Replay(List<Event> events) {
foreach(var e in events) {
this.HandleEvent(e);
}
}
}
Repository与Event/Data Storage
Repository是聚合的集合,其主要方法GetById()
负责返回聚合对象实例给调用者。当该实例尚未在内存当中之时,将从Event Storage读取所有对应该聚合Id的事件,接着构造一个空白的初始对象,利用获取的历史事件按版本先后重现到对象的最新版本,此后便可直接从内存中返回实例,而不再需要重复上述加载过程了,这被称为In-Memory特性。
重现部分的简单实现,参见前述Aggregate.Replay()
Event Storage是一个追加型的数据库。由于事件总与聚合对象实例相关,所以一个以聚合对象实例的Id为key、事件序列化流为value的Key-Value型NoSQL数据库将非常适合这样的场景。当然,传统的关系数据库也完全能胜任。数据库的结构也很简单,每条Event作为一条记录,大致为下面这样的结构。其中,Data字段的序列化除采用二进制流的方式,也可以使用Json或者XML等结构化文本方式。而且除上述字段外,还可附加Time Stamp等字段,这给系统回溯到指定时点提供了最基本的数据支持。
Name | Type | Content |
---|---|---|
Id | Guid | Event的Id,方便索引 |
AggregateId | Guid | 产出该事件的聚合对象实例Id |
Version | Integer | 该事件的版本编号 |
Data | Blob | Event序列化得到的二进制流 |
而在Query端,其数据主要目的为前端展示,所以在数据模型设计上,更趋向于“面向界面”或“面向查询”,需要一次性加载呈现所需的全部数据,所以私以为MongoDB这样的文档型NoSQL数据库非常符合Query端的情况。
延迟加载与快照
在传统架构下,Repository从Data Storage中加载聚合对象实例,通常很纠结于是否使用延迟加载(Lazy Load)。
而在Event Sourcing条件下,因为写模型本质是历史的叠加,每一次操作都是追加事件,而不是刷新整个对象,所以延迟加载没有存在必要。
在CQRS Journey第33页有一段关于Lazy Load在CQRS条件下有无必要的对话可以借鉴。
但是,每次从Event Storage读取所有属于某个聚合对象实例的事件然后进行重现,仍是可以改进的,方法就是使用快照(Snapshot)。
快照就是特定版本的聚合对象实例,所以构建快照的方法和重现获得一个聚合对象实例是类似的:构造一个空白的初始对象,利用获取的历史事件,按版本先后重现到特定版本。正因为快照等价于某个版本的聚合对象,所以快照的生成可以完全独立并行于系统运行,而且可以在快照基础上重现其后续版本的事件,以得到更新版本的聚合对象实例。
并发冲突
Command只有一个接收者,而Event可以有若干个订阅者,所以Command总与特定类型的聚合Command Handler绑定。在引入Command队列后,根据聚合对象实例的Id进行Command分组,即可保证一个聚合对象实例在任意时刻只会处理一条Command,从而保证聚合的线程安全。这也是借鉴了Actor模式(此处的Actor并非特指Akka框架里的Actor,而是范指以下这样的模式。)
每个Actor,都是一个封闭的、有状态的、自带邮箱、通过消息与外界进行协作的并发实体。在Actor之间的消息发送、接收都是并发的,但是在Actor内部,消息被邮箱存储后都是串行处理的。即Actor在同一时刻只会对一条异步消息做出回应,从而回避加锁等并发策略。
如果不采用Actor模式,那么就需要自己处理并发冲突。由于Command与Command Handler是一对一的,所以只有当存在多个相同Id的聚合对象实例时,比如为提高吞吐量而将多个同一Id的聚合对象实例分布于不同结点,或者因结点切换导致发生同一聚合对象实例被同时修改时,可能会发生并发冲突。此时聚合的版本号,将成为并发控制的有力武器之一,主要策略不外乎乐观或者悲观两种方式:
- 乐观策略:仅当聚合当前版本与Event Storage中的最新版本一致,才证明聚合是最新的,可以提交对聚合的修改,否则进行重试。
- 悲观策略:每一次都从Event Storage重塑整个聚合,并利用同步锁等机制,保证排他性地修改聚合状态。
另一方面,正如CQRS Journey第256页的“Commands and optimistic concurrency”所述,由于Command的执行环境来自于UI和Query端,所以当Query端与UI未同步时,比如管理员Tom刚停售某Product,而此时顾客Jimmy已经在提交包含该Product的Order,这便会出现破坏最终一致性的情况。相应的一个解决方案,就是在Query模型里保存当前聚合对象实例的最新版本号(即最近一个事件的版本号),然后由Service在构造Command对象时附上该版本号(参见前述Command的常见结构)。最后,由聚合对象实例在收到该Command对象时,与自身当前版本号作对比。若两者一致,即表明Query端目前发送来的Command正是基于聚合对象实例的当前最新版本。
两步提交
在CQRS与Event Sourcing搭配的情况下,事件在持久化的同时更新Query端是一个显著的技术难点,因为这两个动作必须同时成功,否则将会破坏最终一致性。如果持久化成功,而更新Query端失败,那么Query端呈现的就不是正确的系统状态;如果持久化失败,而更新Query端成功,那么Command端执行环境与系统实际状态不符。
为此,CQRS Journey总结了业内的三种方案:
- 将两个动作放进一个事务中执行。由于该事务将跨越读写两端,是典型的分布式事务,所以性能和可用性都较差,只有当分布式事务框架足以满足要求时才会考虑这个方案。
- 引入消息队列,将原本分散在读写两端的两步提交,改为集中在写端的一个事务中,完成事件存入Event Storage和向消息队列推送事件的工作,再由读端负责从消息队列取出事件自行完成更新。这种情况下,两步提交的工作都主要在写端实现,相比第一种方案有了明显进步。
- 在第二种方案基础上,改进Event Storage设计,由Event Storage本身实现将消息压入消息队列,此时写模型将只需要一个事务完成事件的持久化即可。这种方式下,事务的边界进一步缩小,写模型原本要负担的“两步提交”被简化为“一步提交”,性能得到更大幅的提升。但是Event Storage的推送能力,将成为重大考验。
Greg Young在论文及其开发的框架[EventStore]中,都采用了最后一种方案。其主要思想是给每条Event添加一个Long类型的SequenceNumber字段,该字段在库中是唯一且递增的,代表着事件被推送的顺序号。只要Event Storage保存好推送成功的最后一条事件的SequenceNumber,就可以确定推送完成的情况了。
使用Akka框架实现
Akka简介
Actor模型最早出自1973年Carl Hewitt等人所著论文A Universal Modular ACTOR Formalism for Artificial Intelligence。
Akka是Lightbend公司推出的一个基于Actor模型的分布式框架,目前主要支持的语言包括Java和Scala。
以下是官网及我的笔记链接:
实现细节
用Akka实现CQRS与Event Sourcing的示意图如下:
- 由Command与Event组成的Protocol,是Actor与外界沟通的唯一媒介。
- EventSourcedBehavior是Write Model的核心,承担着聚合的主体责任,主要定义了Command和Event的Handler。
- Event的SequenceNumber由框架自动生成,reply和snapshot由框架提供。
- 聚合状态单独定义在State里,借State模式实现状态迁移。
- Tag为事件做上标记,方便Read Model选择使用。
- PersistenceQuerier是Read Model的核心,负责从Read Journal中根据Tag读取事件流,更新自身的读数据模型,从而实现读写模型的最终一致性。
- Serialize为Command和Event提供序列化支持,可使用Json或二进制格式。
微服务
Lightbend公司在Akka基础上,推出了一个微服务框架Lagom。
Lagom框架坚持,微服务是按服务边界Boundary将系统切分为若干个组成部分的结果,这意味着要使它们与限界上下文Bounded Context、业务功能和模块隔离等要求保持一致,才能达到可伸缩性和弹性要求,从而易于部署和管理。因此,在设计微服务时应考虑大小是否“Lagom”,而非是否足够“Micro”。
以下是官网和我的笔记链接:
Lagom封装了服务定位、服务网关、消息队列和路由、集群等功能。每个服务由服务描述子、调用标识符、消息处理器等组成,在服务的内部实现中,由Akka提供的EventSourcedBehavior承担实际的消息处理和持久化。
写在最后
本文是近年个人学习CQRS和Event Sourcing的心得总结。限于篇幅,没有就更多细节进行探讨。
就个人理解而言,DDD本质上是一种分治问题的方法论,主要解决“模型应该是什么样”的问题,所以它关注的是模型应该持有哪些状态、彼此的关系如何,以及模型由哪些行为驱动运转。至于状态和行为如何表示,在不同编程范式下有不同的表达方式,有不同的模式可以发现和遵循,除了最常见的面向对象编程(Object-oriented Programming),函数式编程(Functional Programming)也是一个不错的选择。比如订单Order作为一个领域概念,它本身和采用Java还是Haskell语言无关、和采用Restful还是远程过程调用(Remote Procedure Call)方式也无关,但在表达时可能是Java里的一个class或是Haskell里的一个data type,在传递时可能是一段Json串或者二进制流,这便是“设计”与“实现”的区别,二者既有区别也有联系。
DDD既包括战略方法,也有战术方法。在落地DDD时,不该把DDD当作某种实现技术,而应坚持用DDD的思想去划分问题域、指导建模,在实现时引入CQRS、Event Sourcing、六边形适配器和Actor等模式作为对Aggregate、Repository等DDD战术模式的有益补充,并采用Docker、集群等分布式方法改进基础设施。在实践中,我使用DDD指导建模的流程可简单总结如下:
- 使用事件风暴,查找所有可能的Event。
- Command是Event的起因,因此从Event逐一倒查所有的Command。
- Command与Command Handler一一对应,所以逐步向聚合添加职责。
- 根据Command属性,为聚合添加相应属性,形成领域概念一览表。
- 当聚合中的一些属性无法用Int、String等基本数据类型进行描述时,封装Value Object对领域概念进行说明。
- 根据Command涉及不同聚合之间的协作,厘清聚合之间的关系,逐步丰富聚合图谱。
- 待聚合图谱完整和清晰之后,根据变化边界进行划分,形成各BC及模块。