域模型、DDD、CQRS与12306的设计

一系列好文摘录与汇总,具体出处参见文末参考连接

前言

不管在架构层面还是编码层面,采用CQRS的都会增加程序的复杂度和代码量,不过,这种复杂性可以在很大程度上被其所带来的“条理性”所抵消,“有条理的多”恰恰是为了简单。

域模型

什么是域模型

域模型(domain model)英文又称为问题域模型(problem space model)。维基百科(Wikipedia)对它的定义是A conceptual model of all the topics related to a specific problem 。 可以翻译成: “域模型是针对某个特定问题的所有相关方面的抽象模型”。 这个定义有几个要点:第一是“特定问题”, 也即是说域模型是针对性某个问题域而言的, 脱离的这个特定问题,域模型的构建其实不存在一个最优或者是最合理的构建。 第二是抽象, 域模型是一个抽象模型, 不是对某个问题的各个相关方面的一个映射, 也不是解决方案的构建

关于域模型, 经常会看到大家把逻辑数据模型(logical data model) 或者是物理数据模型(physical data model)和域模型混为一谈。 甚至有同学把数据里的表结构抽取出来作为域模型来研究。 其实这是域模型的最大误区。 数据模型实质上都归属于结果域模型solution space model), 是对某个问题域的解决方案的一个描述, 实质上是对解决方案的一个具体描述

另外一个常见的误区和领域驱动设计(DDD, Domain Driven Design)有关。 我个人对DDD比较推崇, 但是DDD里提到的域模型实质上是结果域模型(Solution Space Model), 不是问题域模型。 我在这个系列的文章里集中介绍的是问题域模型的构建, 而不是结果域模型的构建。这两者的区别在于前者的建立主要是为了统一我们对未知世界的了解, 也就是说我们需要统一思想, 搞清楚我们要解决什么问题和问题的本质。 而后者的主要是想解决针对近些年来敏捷开发模式中所普遍存在的对领域认知不完整而导致设计不合理的问题。 前者是一个对未知方向的探索过程,适用在一个相对较为模糊的命题,产出是对语言,边界和思路的统一。后者是一个方法论,适用于具体一个项目, 产出是一个项目的数据模型。

总结一下:(问题)域模型是为了准确定义需要解决问题而构造的抽象模型。特别值得强调的是域模型的功能是统一认知:对要解决的问题有一个完整,规范,并且是一致的认识。

 

领域驱动设计(DDD)与聚合

DDD是什么

wiki释义:

领域驱动设计(英语:Domain-driven design,缩写 DDD)是一种通过将实现连接到持续进化的模型满足复杂需求的软件开发方法。领域驱动设计的前提是:

  • 把项目的主要重点放在核心领域(core domain)和域逻辑
  • 把复杂的设计放在有界域(bounded context)的模型上
  • 发起一个创造性的合作之间的技术和域界专家以迭代地完善的概念模式,解决特定领域的问题

领域驱动设计是一种由域模型来驱动着系统设计的思想,不是通过存储数据词典(DB表字段、ES Mapper字段等等)来驱动系统设计。领域模型是对业务模型的抽象,DDD是把业务模型翻译成系统架构设计的一种方式。

 

为什么建立一个领域模型是重要的

一些教条式的知识,更多的是在实际开发中自己去体会、感受、总结

领域驱动设计告诉我们,在通过软件实现一个业务系统时,建立一个领域模型是非常重要和必要的,因为领域模型具有以下特点:

  1. 领域模型是对具有某个边界的领域的一个抽象,反映了领域内用户业务需求的本质;领域模型是有边界的,只反应了我们在领域内所关注的部分;
  2. 领域模型只反映业务,和任何技术实现无关;领域模型不仅能反映领域中的一些实体概念,如货物,书本,应聘记录,地址,等;还能反映领域中的一些过程概念,如资金转账,等;
  3. 领域模型确保了我们的软件的业务逻辑都在一个模型中,都在一个地方;这样对提高软件的可维护性,业务可理解性以及可重用性方面都有很好的帮助;
  4. 领域模型能够帮助开发人员相对平滑地将领域知识转化为软件构造;
  5. 领域模型贯穿软件分析、设计,以及开发的整个过程;领域专家、设计人员、开发人员通过领域模型进行交流,彼此共享知识与信息;因为大家面向的都是同一个模型,所以可以防止需求走样,可以让软件设计开发人员做出来的软件真正满足需求;
  6. 要建立正确的领域模型并不简单,需要领域专家、设计、开发人员积极沟通共同努力,然后才能使大家对领域的认识不断深入,从而不断细化和完善领域模型;
  7. 为了让领域模型看的见,我们需要用一些方法来表示它;图是表达领域模型最常用的方式,但不是唯一的表达方式,代码或文字描述也能表达领域模型;
  8. 领域模型是整个软件的核心,是软件中最有价值和最具竞争力的部分;设计足够精良且符合业务需求的领域模型能够更快速的响应需求变化;

 

领域建模时思考问题的角度

“用户需求”不能等同于“用户”,捕捉“用户心中的模型”也不能等同于“以用户为核心设计领域模型”。比如,一座房子要住人,我们在建造房子时,建造的房子是空的,唯有空的才能容纳人的居住。因此,建立领域模型时也要将用户置于模型之外,这样才能包容用户的需求。

所以,我的理解是:

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

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

Eric Evans(DDD之父)在他的书中的一个货物运输系统为例子简单说明一下。在经过一些用户需求讨论之后,在用户需求相对明朗之后,Eric这样描述领域模型:

  1. 一个Cargo(货物)涉及多个Customer(客户,如托运人、收货人、付款人),每个Customer承担不同的角色;
  2. Cargo的运送目标已指定,即Cargo有一个运送目标;
  3. 由一系列满足Specification(规格)的Carrier Movement(运输动作)来完成运输目标;

从上面的描述我们可以看出,他完全没有从用户的角度去描述领域模型,而是以领域内的相关事物为出发点,考虑这些事物的本质关联及其变化规律的。上述这段描述完全以货物为中心,把客户看成是货物在某个场景中可能会涉及到的关联角色,如货物会涉及到托运人、收货人、付款人;货物有一个确定的目标,货物会经过一系列列的运输动作到达目的地;其实,我觉得以用户为中心来思考领域模型的思维只是停留在需求的表面,而没有挖掘出真正的需求的本质;我们在做领域建模时需要努力挖掘用户需求的本质,这样才能真正实现用户需求

例子对于理解上面的两条还是很重要的

 

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

聚合,它通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,并避免了错综复杂的难以维护的对象关系网的形成。聚合定义了一组具有内聚关系的相关对象的集合,我们把聚合看作是一个修改数据的单元

一般来说对于课程设计并不需要对聚合有太深的认识,一方面这些东西很抽象,难以把控,尤其是对于从没了解过且开发经验本来就少的新手,另一方面课程设计也不会太复杂

以支付系统为例:我们认为“订单”就是一个聚合。订单可以包含多个“交易”, 同时订单也是一个实体,因为订单号是订单的唯一“标识”,订单本身也可以作为订单自身聚合的聚合根,外界通过访问订单才能访问订单中的交易。

 

聚合有的一些特点

  1. 每个聚合有一个根和一个边界,边界定义了一个聚合内部有哪些实体或值对象,根是聚合内的某个实体
  2. 聚合内部的对象之间可以相互引用,但是聚合外部如果要访问聚合内部的对象时,必须通过聚合根开始导航,绝对不能绕过聚合根直接访问聚合内的对象,也就是说聚合根是外部可以保持对它的引用的唯一元素
  3. 聚合内除根以外的其他实体的唯一标识都是本地标识,也就是只要在聚合内部保持唯一即可,因为它们总是从属于这个聚合的;
  4. 聚合根负责与外部其他对象打交道并维护自己内部的业务规则
  5. 基于聚合的以上概念,我们可以推论出从数据库查询时的单元也是以聚合为一个单元,也就是说我们不能直接查询聚合内部的某个非根的对象
  6. 聚合内部的对象可以保持对其他聚合根的引用;
  7. 删除一个聚合根时必须同时删除该聚合内的所有相关对象,因为他们都同属于一个聚合,是一个完整的概念

关于如何识别聚合以及聚合根的问题:

我觉得我们可以先从业务的角度深入思考,然后慢慢分析出有哪些对象是:

  1. 有独立存在的意义,即它是不依赖于其他对象的存在它才有意义的
  2. 可以被独立访问的,还是必须通过某个其他对象导航得到的;

 

如何识别聚合?

我觉得这个需要从业务的角度深入分析哪些对象它们的关系是内聚的,即我们会把他们看成是一个整体来考虑的;然后这些对象我们就可以把它们放在一个聚合内。所谓关系是内聚的,是指这些对象之间必须保持一个固定规则,固定规则是指在数据变化时必须保持不变的一致性规则。当我们在修改一个聚合时,我们必须在事务级别确保整个聚合内的所有对象满足这个固定规则。作为一条建议,聚合尽量不要太大,否则即便能够做到在事务级别保持聚合的业务规则完整性,也可能会带来一定的性能问题。有分析报告显示,通常在大部分领域模型中,有70%的聚合通常只有一个实体,即聚合根,该实体内部没有包含其他实体,只包含一些值对象;另外30%的聚合中,基本上也只包含两到三个实体。这意味着大部分的聚合都只是一个实体,该实体同时也是聚合根

 

如何识别聚合根?

如果一个聚合只有一个实体,那么这个实体就是聚合根;如果有多个实体,那么我们可以思考聚合内哪个对象有独立存在的意义并且可以和外部直接进行交互。

 

设计领域模型的一般步骤

这个显得更加重要

  1. 根据需求建立一个初步的领域模型,识别出一些明显的领域概念以及它们的关联,关联可以暂时没有方向但需要有(1:1,1:N,M:N)这些关系;可以用文字精确的没有歧义的描述出每个领域概念的涵义以及包含的主要信息;

  2. 分析主要的软件应用程序功能,识别出主要的应用层的类;这样有助于及早发现哪些是应用层的职责,哪些是领域层的职责;

  3. 进一步分析领域模型,识别出哪些是实体,哪些是值对象,哪些是领域服务;

  4. 分析关联,通过对业务的更深入分析以及各种软件设计原则及性能方面的权衡,明确关联的方向或者去掉一些不需要的关联;

  5. 找出聚合边界及聚合根,这是一件很有难度的事情;因为你在分析的过程中往往会碰到很多模棱两可的难以清晰判断的选择问题,所以,需要我们平时一些分析经验的积累才能找出正确的聚合根;

  6. 为聚合根配备仓储,一般情况下是为一个聚合分配一个仓储,此时只要设计好仓储的接口即可;

    • 我没有写仓储是什么,我直接认为是数据库。原文有讲
  7. 走查场景,确定我们设计的领域模型能够有效地解决业务需求;

  8. 考虑如何创建领域实体或值对象,是通过工厂还是直接通过构造函数;

  9. 停下来重构模型。寻找模型中觉得有些疑问或者是蹩脚的地方,比如思考一些对象应该通过关联导航得到还是应该从仓储获取?聚合设计的是否正确?考虑模型的性能怎样,等等;

领域建模是一个不断重构,持续完善模型的过程,大家会在讨论中将变化的部分反映到模型中,从而是模型不断细化并朝正确的方向走。领域建模是领域专家、设计人员、开发人员之间沟通交流的过程,是大家工作和思考问题的基础。

 

领域驱动设计的其他一些主题

上面只是涉及到DDD中最基本的内容,DDD中还有很多其他重要的内容在上面没有提到,如:

  1. 模型上下文、上下文映射、上下文共享;
  2. 如何将分析模式和设计模式运用到DDD中;
  3. 一些关于柔性设计的技巧;
  4. 如果保持模型完整性,以及持续集成方面的知识;
  5. 如何精炼模型,识别核心模型以及通用子领域;

 

几个聚合设计的原则

  1. 聚合是用来封装真正的不变性,而不是简单的将对象组合在一起;
  2. 聚合应尽量设计的小;
  3. 聚合之间的关联通过ID,而不是对象引用;
  4. 聚合内强一致性,聚合之间最终一致性;

 

聚合是用来封装真正的不变性,而不是简单的将对象组合在一起

这个原则,就是强调聚合的真正用途除了封装我们本身所关心的信息外,最主要的目的是为了封装业务规则,保证数据的一致性。在我看来,这一点是设计聚合时最重要和最需要考虑的点;当我们在设计聚合时,要多想想当前聚合封装了哪些业务规则,实现了哪些数据一致性。所谓的业务规则是指,比如一个银行账号的余额不能小于0,订单中的订单明细的个数不能为0,订单中不能出现两个明细对应的商品ID相同,订单明细中的商品信息必须合法,商品的名称不能为空,回复被创建时必须要传入被回复的帖子(因为没有帖子的回复不是一个合法的回复),等;

 

聚合应尽量设计的小

这个原则,更多的是从技术的角度去考虑的。作者通过一个例子来说明,该例子中,一开始聚合设计的很大,包含了很多实体,但是后来发现因为该聚合包含的东西过多,导致多人操作时并发冲突严重,导致系统可用性变差;后来开发团队将原来的大聚合拆分为多个小聚合,当然,拆分为小聚合后,原来大聚合内维护的业务规则同样在多个小聚合上有所体现。所以实现了既能解决并发冲突的问题,也能保证让聚合来封装业务规则,实现模型级别的数据一致性;聚合设计的小还有一个好处,就是:业务决定聚合,业务改变聚合。聚合设计的小除了可以降低并发冲突的可能性之外,同样减少了业务改变的时候,聚合的拆分个数,降低了聚合大幅重构(拆分)的可能性,从而能让我们的领域模型更能适应业务的变化

 

聚合之间通过ID关联

这个原则怪怪的

这个原则,是考虑到,其实聚合之间无需通过对象引用的方式来关联;

  1. 首先通过引用关联,会导致聚合的边界不够清晰,如果通过ID关联,由于ID是值对象,且值对象正好是用来表达状态的;所以,可以让聚合内只包含只属于自己的实体或值对象,那这样每个聚合的边界就很清晰;每个聚合,关心的是自己有什么信息,自己封装了什么业务规则,自己实现了哪些数据一致性;
  2. 如果通过引用关联,那需要实现LazyLoad的效果,否则当我们加载一个聚合的时候,就会把其关联的其他聚合也一起加载,而实际上我们有时在加载一个聚合时,不需要用到关联的那些聚合,所以在这种时候,就给性能带来一定影响,不过幸好我们现在的ORM都支持LazyLoad,所以这点问题相对不是很大;
  3. 你可能会问,聚合之间如果通过对象引用来关联,那聚合之间的交互就比较方便,因为我可以方便的直接拿到关联的聚合的引用;是的,这点是没错,但是如果聚合之间要交互,在经典DDD的架构下,一般可以通过两种方式解决:1)如果A聚合的某个方法需要依赖于B聚合对象,则我们可以将B聚合对象以参数的方式传递给A聚合,这样A对B没有属性上的关联,而只是参数上的依赖;一般当一个聚合需要直接访问另一个聚合的情况往往是在职责上表明A聚合需要通知B聚合做什么事情或者想从B聚合获取什么信息以便A聚合自己可以实现某种业务逻辑;2)如果两个聚合之间需要交互,但是这两个聚合本身只需要关注自己的那部分逻辑即可,典型的例子就是银行转账,在经典DDD下,我们一般会设计一个转账的领域服务,来协调源账号和目标账号之间的转入和转出,但源账号和目标账号本身只需要关注自己的转入或转出逻辑即可。这种情况下,源账号和目标账号两个聚合实例不需要相互关联引用,只需要引入领域服务来协调跨聚合的逻辑即可;
  4. 如果一个聚合单单保存另外的聚合的ID还不够,那是否就需要引用另外的聚合了呢?也不必,此时我们可以将当前聚合所需要的外部聚合的信息封装为值对象,然后自己聚合该值对象即可。比如经典的订单的例子就是,订单聚合了一些订单明细,每个订单明细包含了商品ID、商品名称、商品价格这三个来自商品聚合的信息;此时我们可以设计一个ProductInfo的值对象来包含这些信息,然后订单明细持有该ProductInfo值对象即可;实际上,这里的ProductInfo所包含的商品信息是在订单生成时对商品信息的状态的冗余,订单生成后,即便商品的价格变了,那订单明细中包含的ProductInfo信息也不会变,因为这个信息已经完全是订单聚合内部的东西了,也就是说和商品聚合无关了。
  5. 实际上通过ID关联,也是达到设计小聚合的目标的一种方式;

 

聚合内强一致性,聚合之间最终一致性

这个原则主要的背景是:如果用CQRS+Event Sourcing的架构来实现DDD,那聚合之间因为通过Domain Event(领域事件)来实现交互了,所以同样也不需要聚合与聚合之间的对象引用,同时也不需要领域服务了,因为领域服务已经被Process(流程聚合根)和Process Manager(流程管理器,无状态)所替代。流程聚合根,负责封装流程的当前状态以及流程下一步该怎么走的逻辑,包括流程遇到异常时的回滚处理逻辑;流程管理器,无状态。负责协调流程中各个参与者聚合根之间的消息交互,它会接受聚合根产生的domain event,然后发送command。另外一方面,由于CQRS的引入,使得我们的domain只需要处理业务逻辑,而不需要应付查询相关的需求了,各种查询需求专门由各种查询服务实现;所以我们的domain就可以非常瘦身,仅仅只需要通过聚合根来封装必要的业务规则(保证聚合内数据的强一致性)即可,然后每个聚合根做了任何的状态变更后,会产生相应的领域事件,然后事件会被持久化到EventStore,EventStore用来持久化所有的事件,整个domain的状态要恢复,只需要通过Event Sourcing的方式还原即可;另外,当事件持久化完成后,框架会通过事件总线将事件发布出去,然后Process Manager就可以响应事件,然后发送新的command去通知相应的聚合根去做必要的处理;

需要再次强调的一点是,聚合如果只需要关注如何实现业务规则而不需要考虑查询需求所带来的好处,那就是我们不需要在domain里维护各种统计信息了,而只要维护各种业务规则所潜在的必须依赖的状态信息即可;举个例子,假如一个论坛,有版块和帖子,以前,我们可能会在版块对象上有一个帖子总数的属性,当新增一个帖子时,会对这个属性加1;而在CQRS架构下,domain内的版块聚合根无需维护总帖子数这个统计信息了,总帖子数会在查询端的数据库独立维护;

 

关于一个聚合内应该聚合哪些信息的思考

  1. 把我们所需要关心的属性设计进去;
  2. 分析该聚合要封装和实现哪些业务规则,从而像上面的例子(商品库存)那样推导出需要设计哪些属性状态到该聚合内;
  3. 如果我们在创建或修改一个对象时,总是会级联创建或修改一些级联信息,比如在一个任务系统,当我们创建一个任务时,可能会上传一些附件,那这些附件的描述信息(如附件ID,附件名称,附件下载地址)就应该被聚合在任务聚合根上;
  4. 聚合内只需要值对象和内部的实体即可,不需要引用其他的聚合根,引用其他的聚合根只会让当前聚合的边界模糊,对其他聚合根的引用应该通过ID关联;
  5. 聚合内的实体和值对象应该具有相同的生命周期,整个聚合是一个整体,从外部看就像是一个对象一样,聚合应该遵循同生共死的原则;

 

关于如何更合理的设计聚合来封装各种业务规则的思考

这一点在最上面的几个原则中,实际上已经提到过一点,那就是尽量设计小聚合,这里的出发点主要是从技术的角度去思考,为了降低对公共对象(大聚合)的并发修改,从而减小并发冲突的可能性,从而提高系统的可用性(因为系统用户不会经常因为并发冲突而导致它的操作失败);关于这一点,我还想再举几个例子,来说明,其实要实现各种业务规则,可以有多种聚合的设计方式,大聚合只是其中一种;

比如,帖子和回复,大家都知道一个帖子有多个回复,没有帖子,回复就没有意义;所以很多人就会认为帖子应该聚合回复;但实际上不需要这样,如果你这样做了,那对于一个论坛来说,同一个帖子被多个人同时回复的可能性是非常高的,那这样的话,多个人同时回复一个帖子,就会导致多个人同时修改同一个帖子对象,那就导致大家都回复不了,因为会有并发冲突或者数据库事务的等待超时,因为大家都在修改同一个帖子聚合根;实际上如果我们从业务规则的角度去思考一下,那可以发现,其实帖子和回复之间,只有一个简单的规则,那就是回复一旦被创建,那他所对应的帖子不能被修改即可;这样的话,要实现这个规则其实很简单,把回复作为聚合根,然后把帖子传入回复聚合根的构造函数,然后回复保存帖子ID,然后回复将帖子ID设置为不允许外部修改(private set;即可),这样我们就实现了这个业务规则,同时还做到了多人同时推一个帖子回复时,不会对同一个帖子对象就并发修改,而是每个回复都是并行的往数据库插入一条回复记录即可;

所以,通过这个例子,我们发现,要实现领域模型内的各种业务规则,方法不止一种,我们除了要从业务角度考虑对象的内聚关系外,还要从技术角度考虑,但是不管从什么角度考虑,都是以实现所要求的业务规则为前提;

从这个例子,我们其实还发现了另外一件有意义的事情,那就是一个论坛中,发表帖子和发表回复是两个独立的业务场景;一个人发表了帖子,然后可能过了一段时间,另一个人对该帖子发表了回复;所以将帖子和回复都设计为独立的很容易理解;这里虽然帖子和回复是一对多,回复离开帖子确实也没意义,但是将回复设计在帖子内没任何好处,反而让系统的可用性降低;相反,像上面提到的关于创建任务时同时上传一些附件的例子,虽然一个任务也是对应多个附件信息,但是我们发现,人物的附件信息总是随着任务被创建或修改时,一起被修改的。也就是说,我们没有独立的业务场景需要独立修改任务的某个附件信息;所以,没有必要将任务的附件信息设计为独立聚合根;

 

CQRS架构

https://www.jdon.com/54180

命令查询的责任分离Command Query Responsibility Segregation (简称CQRS)模式是一种架构体系模式,能够使改变模型的状态的命令和模型状态的查询实现分离。这属于DDD应用领域的一个模式,主要解决DDD在数据库报表输出上处理方式。

Greg Young在infoQ的采访中“State Transitions in Domain-Driven Design”谈到了CQRS,Greg 解释了把领域模型分为两种:状态校验,以及状态转换,维持当前状态的一个视图。

DTO:数据传输对象(Data Transfer Object)

在客户端就将数据的CRUD的新增修改删除CUD等操作和查询R进行分离,前者称为Command,走Command bus进入Domain对模型进行操作,而查询则从另外一条路径直接使用SQL对数据进行操作,比如报表输出等,发挥SQL的特点。

当一个Command进来时,从仓储Repository加载一个聚合aggregate对象群,然后执行其方法和行为。这样,会激发聚合对象群产生一个事件,这个事件可以分发给仓储Repository,或者分发给Event Bus事件总线,比如JavaEE的消息总线等等。事件总线将再次激活所有监听本事件的处理者。当然一些处理者会执行其他聚合对象群的操作,包括数据库的更新。

查询与写入数据库的分离,可以实现专门为各自查询读取而设计特别的数据表结构,专门为查询进行优化

如果采取事件溯源EventSourcing,保存记录的不是聚合当前状态,而是导致状态变化的事件日志 ,那么可以回放,从而找到重要状态改变的轨迹与原因,这是从事件日志追溯来源。

虽然这种架构有些复杂,但是好处却很多,主要的是实现透明的分布式处理Transparent distributed processing当使用事件作为状态改变的引擎时,你可以通过实现多任务并发处理,比如通过JVM并行计算或事件消息总线机制,事件能够很容易序列化,并在多个服务器之间传送。而查询操作则专门优化。

 

Java的CQRS和事件溯源ES入门:如何从CRUD切换到CQRS/ES

在本教程中,我们将探索命令查询责任隔离(CQRS)和事件源设计模式的基本概念。

虽然通常被称为互补模式,但我们将尝试分别理解它们,并最终了解它们如何相互补充。这些模式通常在企业应用程序中一起使用。在这方面,他们还受益于其他几种企业架构模式。我们将讨论其中的一些内容。有多种工具和框架可帮助采用这些模式,但是我们将使用Java创建一个简单的应用程序以了解基础知识。

 

事件溯源ES

ES为我们提供了一种新的方式来将应用程序状态保持为事件的有序序列。我们可以有选择地查询这些事件并在任何时间点重建应用程序的状态。当然,要使其工作,我们需要将对应用程序状态的所有更改重新映射为事件:

这些事件是已经发生并且不能更改的事实,换句话说,它们必须是不变的。重新创建应用程序状态只是重播所有事件。
请注意,这还提供了选择性重播事件,反向重播某些事件等的可能性。因此,我们可以将应用程序状态本身视为次要的,而事件日志则是我们的主要事实来源。

 

CQRS

CQRS是关于将应用程序体系结构的命令和查询分开。CQRS基于Bertrand Meyer提出的命令查询分离(CQS)原理。CQS建议将对域对象的操作分为两个不同的类别:查询和命令:

查询会返回结果,并且不更改系统的可观察状态。命令会更改系统的状态,但不一定会返回值。

我们通过完全分离域模型的Command和Query端来实现这一点。当然,我们可以采取进一步的措施,通过引入一种使数据存储保持同步的机制来拆分数据存储区的写和读侧。

 

一个简单的应用

我们将从描述一个简单的Java应用程序开始,该应用程序可以构建域模型。

该应用程序将在域模型上提供CRUD操作,并且还将具有域对象的持久性。CRUD代表创建,读取,更新和删除,这是我们可以对域对象执行的基本操作。

在后面的部分中,我们将使用相同的应用程序来介绍事件源和CQRS

在此过程中,我们将在示例中利用域驱动设计(DDD)中的一些概念

DDD解决了依赖复杂领域特定知识的软件的分析和设计。它基于这样的思想,即软件系统必须基于完善的域模型。Evans首先将DDD规定为模式目录。我们将使用其中一些模式来构建示例。

创建用户配置文件并对其进行管理是许多应用程序中的典型要求。我们将定义一个简单的域模型来捕获用户配置文件以及持久性:

如我们所见,我们的域模型已规范化并公开了几个CRUD操作。这些操作仅用于演示,根据需要可以简单或复杂。而且,这里的持久性存储库可以在内存中,也可以使用数据库。

 

简单CRUD应用

首先,我们必须创建代表域模型的Java类。这是一个非常简单的领域模型,甚至可能不需要复杂的事件源和CQRS等设计模式。但是,我们将保持简单,着重于了解基础知识:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class User {
private String userid;
private String firstName;
private String lastName;
private Set<Contact> contacts;
private Set<Address> addresses;
// getters and setters
}

public class Contact {
private String type;
private String detail;
// getters and setters
}

public class Address {
private String city;
private String state;
private String postcode;
// getters and setters
}

同样,我们将为应用程序状态的持久性定义一个简单的内存存储库。当然,这并没有增加任何价值,但足以满足我们稍后的演示要求:

1
2
3
public class UserRepository {
private Map<String, User> store = new HashMap<>();
}

现在,我们将定义一个服务以在我们的域模型上公开典型的CRUD操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class UserService {
private UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
// 即:
// @Resources
// private UserRepository repository

public void createUser(String userId, String firstName, String lastName) {
User user = new User(userId, firstName, lastName);
repository.addUser(userId, user);
}

public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses){
User user = repository.getUser(userId);
user.setContacts(contacts);
user.setAddresses(addresses);
repository.addUser(userId, user);
}

public Set<Contact> getContactByType(String userId, String contactType) {
User user = repository.getUser(userId);
Set<Contact> contacts = user.getContacts();
return contacts.stream()
.filter(c -> c.getType().equals(contactType))
.collect(Collectors.toSet());
}

public Set<Address> getAddressByRegion(String userId, String state) {
User user = repository.getUser(userId);
Set<Address> addresses = user.getAddresses();
return addresses.stream()
.filter(a -> a.getState().equals(state))
.collect(Collectors.toSet());
}
}

这几乎是我们设置简单CRUD应用程序所要做的。这远非生产就绪型代码,但它揭示了一些我们在本教程后面将要讨论的重要点。

 

CRUD应用程序中的问题

在继续进行与事件源和CQRS的讨论之前,值得讨论当前解决方案中的问题。毕竟,我们将通过应用这些模式来解决相同的问题!

在我们可能在这里注意到的许多问题中,我们只想关注其中两个:

  • 域模型:读写操作是在同一域模型上进行的。尽管对于像这样的简单域模型来说这不是问题,但随着域模型变得复杂,它可能会恶化。我们可能需要优化我们的域模型和基础存储,以适合读写操作的各个需求。

  • 持久性:我们对域对象的持久性仅存储域模型的最新状态。尽管这对于大多数情况已经足够了,但它使某些任务具有挑战性。例如,如果我们必须对域对象如何更改状态进行历史审核,则这里是不可能的。我们必须用一些审核日志来补充我们的解决方案,以实现此目的。

    • 这一点是以前从未考虑过的,因为我们几乎从未考虑过回退的可能性

更多的解释:以支付系统为例:无论是增加订单还是查询订单,在这样的系统中,订单被建模成一个单一的实体。无论是存储还是读取,都会将整个订单对象序列化到数据存储或者反序列出来。如果我们只是修改一个订单的状态,并且查询最新的订单状态,真的需要将整个对象都写入数据存储或者读取出来吗?

怎么解决呢?对读和写的操作分别进行数据结构的建模。

 

CQRS重构

我们将通过在应用程序中引入CQRS模式来解决上一节中讨论的第一个问题。作为其中的一部分,我们将域模型及其持久性分开以处理写入和读取操作。让我们看看CQRS模式如何重组我们的应用程序:

此图说明了我们打算如何彻底分离应用程序体系结构以进行写入和读取。但是,我们在这里引入了很多新组件,我们必须更好地理解它们。请注意,这些与CQRS并不严格相关,但是CQRS可以从中受益匪浅。

 

Aggregate聚合/Aggregator聚合器

聚合是域驱动设计(DDD)中描述的一种模式,该模式通过将实体绑定到聚合根逻辑上对不同的实体进行分组。聚合模式提供了实体之间的事务一致性。

CQRS自然受益于聚合模式,该模式将写域模型组合在一起,提供了交易事务保证。聚合通常保持高速缓存状态以提高性能,但是如果没有它,也可以完美地工作

 

Projection投影/Projector投射器

投影是另一个大大有利于CQRS的重要模式。投影本质上是指以不同的形状和结构表示领域对象

这些原始数据投影是只读的,并且经过高度优化,以提供增强的读取体验。我们可能会再次决定缓存预测以提高性能,但这不是必须的。

其实就是针对读操作做的优化

 

  • CQRS写入端

让我们首先实现应用程序的写入端。

我们将从定义所需的命令开始。甲命令是一个意图突变域模型的状态。它是否成功取决于我们配置的业务规则。

让我们看看我们的命令:

1
2
3
4
5
6
7
8
9
10
11
public class CreateUserCommand {
private String userId;
private String firstName;
private String lastName;
}

public class UpdateUserCommand {
private String userId;
private Set<Address> addresses;
private Set<Contact> contacts;
}

这些是非常简单的类,用于保存我们打算改变的数据。

接下来,我们定义一个聚合器,负责接收和处理命令。聚合可以接受或拒绝命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class UserAggregate {
private UserWriteRepository writeRepository;
public UserAggregate(UserWriteRepository repository) {
this.writeRepository = repository;
}
// 即:
// @Resources
// private UserWriteRepository writeRepository

public User handleCreateUserCommand(CreateUserCommand command) {
User user = new User(command.getUserId(), command.getFirstName(), command.getLastName());
writeRepository.addUser(user.getUserid(), user);
return user;
}

public User handleUpdateUserCommand(UpdateUserCommand command) {
User user = writeRepository.getUser(command.getUserId());
user.setAddresses(command.getAddresses());
user.setContacts(command.getContacts());
writeRepository.addUser(user.getUserid(), user);
// 这个就有意思了,从仓储中取了原来的用户信息,改完再存进去但原数据还被保留
return user;
}
}

聚合使用存储库来检索当前状态并保留对它的任何更改。而且,它可以在本地存储当前状态,以避免在处理每个命令时往返存储库的开销。

最后,我们需要一个存储库来保存域模型的状态。这通常是数据库或其他持久性存储,但是在这里我们将简单地将它们替换为内存中的数据结构:

1
2
3
4
public class UserWriteRepository {
private Map<String, User> store = new HashMap<>();
// accessors and mutators
}

 

  • CQRS读取端

现在让我们切换到应用程序的读取端。我们将从定义域模型的读取端开始:

1
2
3
4
5
6
7
public class UserAddress {
private Map<String, Set<Address>> addressByRegion = new HashMap<>();
}

public class UserContact {
private Map<String, Set<Contact>> contactByType = new HashMap<>();
}

接下来,我们将定义读取存储库。同样,我们将只使用内存中的数据结构,即使这将在实际应用程序中提供更持久的数据存储:

1
2
3
4
5
public class UserReadRepository {
private Map<String, UserAddress> userAddress = new HashMap<>();
private Map<String, UserContact> userContact = new HashMap<>();
// accessors and mutators
}

现在,我们将定义我们必须支持的必需查询。查询是为了获取数据的意图,它不一定会导致数据生成:

1
2
3
4
5
6
7
8
9
public class ContactByTypeQuery {
private String userId;
private String contactType;
}

public class AddressByRegionQuery {
private String userId;
private String state;
}

同样,这些是保存数据以定义查询的简单Java类。我们现在需要的是可以处理以下查询的投影:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class UserProjection {
private UserReadRepository readRepository;
public UserProjection(UserReadRepository readRepository) {
this.readRepository = readRepository;
}

public Set<Contact> handle(ContactByTypeQuery query) {
UserContact userContact = readRepository.getUserContact(query.getUserId());
return userContact.getContactByType().get(query.getContactType());
}

public Set<Address> handle(AddressByRegionQuery query) {
UserAddress userAddress = readRepository.getUserAddress(query.getUserId());
return userAddress.getAddressByRegion().get(query.getState());
}
}

这里的投影使用我们之前定义的读取存储库来解决我们所拥有的查询。这几乎也总结了我们应用程序的读取方面。

 

  • CQRS同步读写数据

这个难题的一个难题仍然没有解决:没有什么可以使我们的读写存储库同步。这是我们需要投影器的地方。一个投影机投射写域模型到读取域模型的逻辑。有很多更复杂的方法可以解决此问题,但我们将使其保持相对简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class UserProjector {
UserReadRepository readRepository = new UserReadRepository();
public UserProjector(UserReadRepository readRepository) {
this.readRepository = readRepository;
}

public void project(User user) {
UserContact userContact = Optional.ofNullable(
readRepository.getUserContact(user.getUserid()))
.orElse(new UserContact());
Map<String, Set<Contact>> contactByType = new HashMap<>();
for (Contact contact : user.getContacts()) {
Set<Contact> contacts = Optional.ofNullable(
contactByType.get(contact.getType()))
.orElse(new HashSet<>());
contacts.add(contact);
contactByType.put(contact.getType(), contacts);
}
userContact.setContactByType(contactByType);
readRepository.addUserContact(user.getUserid(), userContact);

UserAddress userAddress = Optional.ofNullable(
readRepository.getUserAddress(user.getUserid()))
.orElse(new UserAddress());
Map<String, Set<Address>> addressByRegion = new HashMap<>();
for (Address address : user.getAddresses()) {
Set<Address> addresses = Optional.ofNullable(
addressByRegion.get(address.getState()))
.orElse(new HashSet<>());
addresses.add(address);
addressByRegion.put(address.getState(), addresses);
}
userAddress.setAddressByRegion(addressByRegion);
readRepository.addUserAddress(user.getUserid(), userAddress);
}
}

这是执行此操作的一种非常粗略的方法,但可以使我们对CQRS正常运行所需的功能有足够的了解。而且,没有必要将读写存储库放在不同的物理存储中。分布式系统有其自身的问题!

请注意,将写入域的当前状态投影到不同的读取域模型并不方便。我们在这里采取的示例非常简单,因此我们看不到问题所在。

但是,随着读写模型变得越来越复杂,投影将变得越来越困难。我们可以通过基于事件的投影来解决此问题,而不是通过事件搜索来解决基于状态的投影。我们将在本教程的后面部分中介绍如何实现此目的。

我们讨论了CQRS模式,并学习了如何在典型应用程序中引入它。我们一直试图解决与域模型在处理读取和写入时的刚性相关的问题。

现在让我们讨论CQRS带给应用程序体系结构的其他一些好处:

  • CQRS为我们提供了一种方便的方式来选择适用于写入和读取操作的单独域模型;我们不必创建支持两者的复杂域模型
  • 它可以帮助我们选择适合于处理读写操作复杂性的存储库,例如写入的高吞吐量和读取的低延迟
  • 它通过提供关注点分离和更简单的域模型,自然地补充了分布式体系结构中基于事件的编程模型

但是,这不是免费的。从这个简单的示例可以明显看出,CQRS为体系结构增加了相当大的复杂性。在许多情况下,它可能不合适或不值得付出痛苦:

  • 只有复杂的领域模型才能从此模式增加的复杂性中受益;一个简单的域模型可以在没有所有这些的情况下进行管理
  • 自然地在某种程度上导致代码重复,这与它带来的收益相比是可以接受的。但是,建议个人判断
  • 分开的存储库会导致一致性问题,并且很难始终保持写入和读取存储库的完美同步。我们经常必须为最终的一致性做好准备
事件溯源

接下来,我们将解决在简单应用程序中讨论的第二个问题。回想一下,它与我们的持久性存储库有关。

我们将介绍事件源来解决此问题。事件源极大地改变了我们对应用程序状态存储的看法。

在这里,我们已经构建了存储库,以存储域事件的有序列表。域对象的每次更改都被视为事件。事件的粗略程度应该是域设计的问题。这里要考虑的重要事项是事件具有时间顺序并且是不可变的。
事件驱动的应用程序中的基本对象是事件,事件源无异。正如我们之前所看到的,事件表示在特定时间点域模型状态的特定变化。因此,我们将从为简单应用程序定义基本事件开始:

1
2
3
4
public abstract class Event {
public final UUID id = UUID.randomUUID();
public final Date created = new Date();
}

这只是确保我们在应用程序中生成的每个事件都具有唯一的标识和创建的时间戳。这些是进一步处理它们所必需的。

当然,可能还有其他一些我们可能感兴趣的属性,例如用于建立事件来源的属性。

接下来,让我们创建一些从该基本事件继承的特定于域的事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class UserCreatedEvent extends Event {
private String userId;
private String firstName;
private String lastName;
}

public class UserContactAddedEvent extends Event {
private String contactType;
private String contactDetails;
}

public class UserContactRemovedEvent extends Event {
private String contactType;
private String contactDetails;
}

public class UserAddressAddedEvent extends Event {
private String city;
private String state;
private String postCode;
}

public class UserAddressRemovedEvent extends Event {
private String city;
private String state;
private String postCode;
}

这些是Java中包含域事件详细信息的简单POJO。但是,这里要注意的重要事项是事件的粒度

我们可以为用户更新创建一个事件,但是相反,我们决定创建单独的事件来添加和删除地址及联系方式。选择被映射到使域模型更有效的方式。

现在,自然地,我们需要一个存储库来保存我们的域事件:

1
2
3
public class EventStore {
private Map<String, List<Event>> store = new HashMap<>();
}

这是一个简单的内存数据结构,用于保存我们的域事件。实际上,有几种专门创建的用于处理事件数据的解决方案,例如Apache Druid。有许多能够处理事件源的通用分布式数据存储,包括KafkaCassandra

 

生产和消费事件

因此,现在我们处理所有CRUD操作的服务将发生变化。现在,它将更新域事件,而不是更新移动域的状态。它还将使用相同的域事件来响应查询。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class UserService {
private EventStore repository;
public UserService(EventStore repository) {
this.repository = repository;
}

public void createUser(String userId, String firstName, String lastName) {
repository.addEvent(userId, new UserCreatedEvent(userId, firstName, lastName));
}

public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses){
User user = UserUtility.recreateUserState(repository, userId);
user.getContacts().stream()
.filter(c -> !contacts.contains(c))
.forEach(c -> repository.addEvent(
userId, new UserContactRemovedEvent(c.getType(), c.getDetail())));
contacts.stream()
.filter(c -> !user.getContacts().contains(c))
.forEach(c -> repository.addEvent(
userId, new UserContactAddedEvent(c.getType(), c.getDetail())));
user.getAddresses().stream()
.filter(a -> !addresses.contains(a))
.forEach(a -> repository.addEvent(
userId, new UserAddressRemovedEvent(a.getCity(), a.getState(), a.getPostcode())));
addresses.stream()
.filter(a -> !user.getAddresses().contains(a))
.forEach(a -> repository.addEvent(
userId, new UserAddressAddedEvent(a.getCity(), a.getState(), a.getPostcode())));
}

public Set<Contact> getContactByType(String userId, String contactType) {
User user = UserUtility.recreateUserState(repository, userId);
return user.getContacts().stream()
.filter(c -> c.getType().equals(contactType))
.collect(Collectors.toSet());
}

public Set<Address> getAddressByRegion(String userId, String state) throws Exception{
User user = UserUtility.recreateUserState(repository, userId);
return user.getAddresses().stream()
.filter(a -> a.getState().equals(state))
.collect(Collectors.toSet());
}
}

请注意,作为此处处理更新用户操作的一部分,我们将生成多个事件。另外,有趣的是要注意我们如何通过重播到目前为止生成的所有域事件来生成域模型的当前状态。

当然,在实际的应用程序中,这不是可行的策略,我们必须维护本地缓存以避免每次生成状态。事件存储库中还有其他策略(例如快照和聚合)可以加快此过程。

到此结束了我们在简单应用程序中引入事件溯源的工作。

事件溯源好处和缺点

现在,我们已经成功采用了使用事件源存储域对象的另一种方法。事件源是一种强大的模式,如果使用得当,它将为应用程序体系结构带来很多好处:

  • 由于不需要读取,更新和写入,因此使写入操作快得多;写只是将事件附加到日志
  • 消除了对象关系阻抗,从而消除了对复杂映射工具的需求;当然,我们仍然需要重新创建对象
  • 恰好提供作为副产品的审核日志,这是完全可靠的;我们可以准确调试域模型的状态如何变化
  • 它使支持时态查询和实现时间旅行成为可能(过去某个时间点的域状态)!
  • 很自然地适合设计微服务架构中的松耦合组件,这些组件通过交换消息进行异步通信

但是,像往常一样,即使事件源也不是万灵丹。它确实迫使我们采用截然不同的方式来存储数据。在某些情况下,这可能没有用:

  • 有一个相关的学习曲线,并且采用事件源需要思维方式的转变。
  • 除非我们将状态保留在本地缓存中,否则它使处理典型查询变得相当困难,因为我们需要重新创建状态
  • 尽管它可以应用于任何领域模型,但它更适合事件驱动的体系结构中基于事件的模型

 

事件溯源的CQRS

既然我们已经了解了如何将事件源和CQRS分别引入到我们的简单应用程序中,是时候将它们组合在一起了。现在应该很直观,因为这些模式可以相互受益。但是,在本节中我们将使其更加明确。

首先让我们看看应用程序体系结构如何将它们组合在一起:

到目前为止,这应该不足为奇。我们已将存储库的写端替换为事件存储,而存储库的读端仍然相同。

请注意,这不是在应用程序体系结构中使用事件源和CQRS的唯一方法。我们可以非常有创意,可以将这些模式与其他模式一起使用,并提供几种架构选择。

这里重要的是确保我们使用它们来管理复杂性,而不是简单地进一步增加复杂性!

我们将从引入CQRS的应用程序开始,然后进行相关更改以使事件源变得更加重要。我们还将利用在引入事件源的应用程序中定义的相同事件和事件存储。

只有几处更改。我们将首先更改聚合以生成事件,而不是更新state:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class UserAggregate {
private EventStore writeRepository;
public UserAggregate(EventStore repository) {
this.writeRepository = repository;
}

public List<Event> handleCreateUserCommand(CreateUserCommand command) {
UserCreatedEvent event = new UserCreatedEvent(command.getUserId(),
command.getFirstName(), command.getLastName());
writeRepository.addEvent(command.getUserId(), event);
return Arrays.asList(event);
}

public List<Event> handleUpdateUserCommand(UpdateUserCommand command) {
User user = UserUtility.recreateUserState(writeRepository, command.getUserId());
List<Event> events = new ArrayList<>();

List<Contact> contactsToRemove = user.getContacts().stream()
.filter(c -> !command.getContacts().contains(c))
.collect(Collectors.toList());
for (Contact contact : contactsToRemove) {
UserContactRemovedEvent contactRemovedEvent = new UserContactRemovedEvent(contact.getType(),
contact.getDetail());
events.add(contactRemovedEvent);
writeRepository.addEvent(command.getUserId(), contactRemovedEvent);
}
List<Contact> contactsToAdd = command.getContacts().stream()
.filter(c -> !user.getContacts().contains(c))
.collect(Collectors.toList());
for (Contact contact : contactsToAdd) {
UserContactAddedEvent contactAddedEvent = new UserContactAddedEvent(contact.getType(),
contact.getDetail());
events.add(contactAddedEvent);
writeRepository.addEvent(command.getUserId(), contactAddedEvent);
}

// similarly process addressesToRemove
// similarly process addressesToAdd

return events;
}
}

唯一需要进行的其他更改是在投影仪中,它现在需要处理事件而不是域对象状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class UserProjector {
UserReadRepository readRepository = new UserReadRepository();
public UserProjector(UserReadRepository readRepository) {
this.readRepository = readRepository;
}

public void project(String userId, List<Event> events) {
for (Event event : events) {
if (event instanceof UserAddressAddedEvent)
apply(userId, (UserAddressAddedEvent) event);
if (event instanceof UserAddressRemovedEvent)
apply(userId, (UserAddressRemovedEvent) event);
if (event instanceof UserContactAddedEvent)
apply(userId, (UserContactAddedEvent) event);
if (event instanceof UserContactRemovedEvent)
apply(userId, (UserContactRemovedEvent) event);
}
}

public void apply(String userId, UserAddressAddedEvent event) {
Address address = new Address(
event.getCity(), event.getState(), event.getPostCode());
UserAddress userAddress = Optional.ofNullable(
readRepository.getUserAddress(userId))
.orElse(new UserAddress());
Set<Address> addresses = Optional.ofNullable(userAddress.getAddressByRegion()
.get(address.getState()))
.orElse(new HashSet<>());
addresses.add(address);
userAddress.getAddressByRegion()
.put(address.getState(), addresses);
readRepository.addUserAddress(userId, userAddress);
}

public void apply(String userId, UserAddressRemovedEvent event) {
Address address = new Address(
event.getCity(), event.getState(), event.getPostCode());
UserAddress userAddress = readRepository.getUserAddress(userId);
if (userAddress != null) {
Set<Address> addresses = userAddress.getAddressByRegion()
.get(address.getState());
if (addresses != null)
addresses.remove(address);
readRepository.addUserAddress(userId, userAddress);
}
}

public void apply(String userId, UserContactAddedEvent event) {
// Similarly handle UserContactAddedEvent event
}

public void apply(String userId, UserContactRemovedEvent event) {
// Similarly handle UserContactRemovedEvent event
}
}

如果我们回想起在处理基于状态的投影时所讨论的问题,那么这可能是一种解决方案。

基于事件的投影相当方便且易于实现。我们要做的就是处理所有发生的领域事件,并将它们应用于所有读取的领域模型。通常,在基于事件的应用程序中,投影仪将收听其感兴趣的领域事件,而不依赖于直接调用它的人。

 

CQRS与高性能

参看https://www.cnblogs.com/netfocus/p/4055346.html

文中提出了一些可能的性能优化设计。

 

12306核心模型设计思路和架构设计

https://www.cnblogs.com/netfocus/p/5187241.html

前言

春节期间,无意中看到一篇文章,文章中讲到12306的业务复杂度远远比淘宝天猫这种电商网站要复杂。后来自己想想,也确实如此。所以,很想挑战一下12306这个系统的核心领域模型的设计。一般的电商网站,购买都是基于商品的概念,每个商品有一定量的库存,用户的购买行为是针对商品的。当用户发起购买行为时,系统只需要生成订单并对用户要购买的商品减库存即可。但是,12306就不是那么简单了,具体复杂在哪里,我下面会进一步分析。

另外一个让我写这篇文章的原因,是我发现也许是否是因为目前12306的核心领域模型设计的不够好,导致用户购票时要处理的业务逻辑异常复杂,维护数据一致性的难度也几百倍的上升,同时面对高并发的订票也难以支持很高的TPS。我觉得,越是复杂的业务,就越要重视业务分析,重视领域模型的抽象和设计。如果不假思索,凭以往经验行事,则很可能会被以往的设计经验先入为主,陷入死胡同。我发现技术人员往往更注重技术层面的解决方案,比如一上来就分析如何集群、如何负载均衡、如何排队、如何分库分表、如何用锁,如何用缓存等技术问题,而忽略了最根本的业务层面的思考,如分析业务、领域建模。我认为越是复杂的业务系统,则越要设计一个健壮的领域模型。如果一个系统的架构我们设计错了,还有补救的余地,因为架构最终沉淀的只是代码,调整架构即可(一个系统的架构本身就是不断演进的);而如果领域模型设计错了,那要补救的代价是非常大的,因为领域模型沉淀的是数据结构及其对应的大量数据,对任何一个大型系统,要改核心领域模型都是成本非常高的

本文的重点不是在如何解决高并发的问题,而是希望从业务角度去分析,12306的理想模型应该是怎么样的。

 

需求概述

12306这个系统,核心要解决的问题是网上售票。涉及到2个角色使用该系统:用户、铁道部。用户的核心诉求是查询余票、购票;铁道部的核心诉求是售票。购票和售票其实是一个场景,对用户来说是购票,对铁道部来说是售票。因此,我们要设计一个在线的网站系统,解决用户的查询余票、购票,以及铁道部的售票这3个核心诉求。看起来,这3个场景都是围绕火车票展开的

看起来如此

查询余票:用户输入出发地、目的地、出发日三个条件,查询可能存在的车次,用户可以看到每个车次经过的站点名称,以及每种座位的余票数量。

购票:购票分为订票和付款两个阶段,本文重点分析订票的模型设计和实现思路。

其实还有很多其他的需求,比如给不同的车次设定销售座位数配额,以及不同的区段设置不同的限额。但相比前面两个需求来说,我觉得这个需求相对次要一些。

 

需求分析

确实,12306也是一个电商系统,而且看起来商品就是票了。因为如果把一张票看成是一个商品,那购票就类似于购买商品,然后每张票都有库存,商品也有库存的概念。但是如果我们仔细想想,会发现12306要复杂很多,因为我们无法预先确定好所有的票,如果非要确定,那只能通过穷举法了。

那么肯定会涉及一个问题,我们既然要动态确定一张票,采用怎么样的数据结构。当然了,这是一个开发的技术问题,我们继续往后看

我们以北京西到深圳北的G71车次高铁为例(这里只考虑南下的方向,不考虑深圳北到北京西的,那是另外一个车次,叫G72),它有17个站(北京西是01号站,深圳北是17号站),3种座位(商务、一等、二等)。表面看起来,这不就是3个商品吗?G71商务座、G71一等座、G71二等座。大部分轻易喷12306的技术人员(包括某些中等规模公司的专家、CTO)就是在这里栽第一个跟头的。实际上,G71有136*3=408种商品(408个SKU),怎么算来的?如下:

如果卖北京西始发的,有16种卖法(因为后面有16个站),北京西到:保定、石家庄、郑州、武汉、长沙、广州、虎门、深圳等等等等,都是一个独立的商品,同理,石家庄上车的,有15种下车的可能,以此类推,单以上下车的站来计算,有136种票:16+15+14…+2+1=136。每种票都有3种座位,一共是408个商品。

为了方便后面的讨论,我们先明确一下票是什么?

一张票的核心信息包括:出发时间、出发地、目的地、车次、座位号。持有票的人就拥有了一个凭证,该凭证表示持有它的人可以坐某个车次的某个座位号,从某地到某地。所以,一张票,对用户来说是一个凭证,对铁道部来说是一个承诺;那对系统来说是什么呢?不知道。这就是我们要分析业务,领域建模的原因,我们再继续思考吧。

明白了票的核心信息后,我们再看看G71这个车次的高铁,可以卖多少张票?

讨论前先说明一下,一辆火车的物理座位数(站票也可以看成是一种座位,因为站票也有数量配额)不等于可用的最大配合。所有的物理座位不可能都通过12306网站来销售,而是只会销售一部分,比如40%。其余的还是会通过线下的方式销售。不仅如此,可能有些站点上车的人会比较多,有些比较少,所以我们还会给不同的区间配置不同的**限额。**比如D31北京南至上海共有765张,北京南有260张,杨柳青有80张,泰安有76张。如果杨柳青的80张票售完就会显示无票,就算其他站有票也会显示无票的。每个车次肯定会有各种座位的配额和限额的配置的,这种配置我目前无法预料,但我已经把这些规则都封装近车次聚合根里了,所有的配置策略都是基于座位类型、站点、区间配置的。关于票的配置抽象出来,我觉得主要有3种:1)某个区段最多允许出多少张;2)某个区段最少允许出多少张;3)某个站点上车的最多多少张;当用户订票时,把用户指定的区段和这3种配置条件进行比较,3个条件都满足,则可以出票。不满足,则认为无票了。下面举个例子:

ABCDEFG,这是所有站点。座位总配额是100,假设B站点上车,E站下车的人比较少,那我们就可以设定BE这个区段最多只能出10张票。所以,只要是用户的订票是在这个区段内的,就最多出10张。再比如,一列车次,总共100个座位配额,希望全程票最少满足80张,那我们只要给AG这个区段设定最少80张。那任何订票请求,如果是子区间的,就不能超过100-80,即20张。这两种条件必须同时满足,才允许出票。

这种限额可以考虑单独一张表进行存储

但是,不管如何做配额和限额,我们总是针对某个车次进行配置,这些配置只是车次内部售票时的一些额外的判断条件(业务规则),不影响车次模型的核心地位和对外暴露的功能。所以,为了本文讨论的清楚起见,我后续的讨论都不涉及配额和限额的问题,而是认为任何区段都可以享受火车最大的物理座位数

并且,为了讨论问题方便,我们减少一些站点来讨论。假设某个车次有A,B,C,D四个站点。那001这个人购买了A,B这个区间,系统会分配给001一个座位x;但是因为001坐到B站点后会下车,所以相当于x这个座位又空出来了,也就是说,从B站点开始,系统又可以认为x这个座位是可用的。所以,我们得出结论:同一个座位,其实可以同时出售AB,BC这两张票。通过这个简单的分析,我们知道,一列火车虽然只有有限的座位数,比如1000个座位。但可以卖出的票远远不止1000个。还是以A,B,C,D四个站点为例,假如火车总共有1000个座位,那AB可以卖1000张,BC也可以卖1000张,同样,CD也可以卖1000张。也就是说,理论上最多可以卖出3000张票。但是如果换一种卖法,所有人都是买ABCD的票,也就是说所有的票都是经过所有站点的,那就是最多只能卖出1000张票了。而实际的场景,一定是介于1000到3000之间。然后实际的G71这个车次,有17个站,那到底可以卖出多少个票,大家应该可以算了吧。理论上这17个站中的任意两个站点之间所形成的线段,都可以出售为一张票。

通过上面的分析,我们知道一张票的本质是某个车次的某一段区间(一条线段),这个区间包含了若干个站点。然后我们还发现,只要区间不重叠,那座位就不会发生竞争,可以被回收利用,也就是说,可以同时预先出售

另外,经过更深入的分析,我们还发现区间有4种关系:1)不重叠;2)部分重叠;3)完全重叠;4)覆盖;不重叠的情况我们已经讨论过了,而覆盖也是重叠的一种。所以我们发现如果重叠,比如有两个区间发生重叠,那重叠部分的区间(可能夸一个或多个站点)是在争抢座位的。因为假设一列火车有100个座位,那每个原子区间(两个相邻站点的连线),最多允许重叠99次。

所以,经过上面的分析,我们知道了一个车次能够出售一张车票的核心业务规则是什么?就是:这张车票所包含的每个原子区间的重叠次数加1都不能超过车次的总座位数,实际上重叠次数+1也可以理解为线段的厚度。

比如两条线的重叠是重叠了一次,所以要加一

 

模型设计

上面我分析了一下票的本质是什么。那接下来我们再来看看怎么设计模型,来快速实现购票的需求,重点是怎么设计商品聚合以及减库存的逻辑

传统电商的思路

如果按照普通电商的思路,把票(站点区间)设计为商品(聚合根),然后为票设计库存数量。我个人觉得是很糟糕的。因为一方面这种聚合根非常多(上面的G71就有408个);另一方面,即便枚举出来了,一次购票也一定会影响非常多其他聚合根的库存数量(只要被部分或全部重叠的区间都受影响)。这样的一次订单处理的复杂度是难以评估的。而且这么多聚合根的更新要在一个事务里,这不是为难数据库吗?而且,这种设计必然带来大量的事务的并发冲突,很可能导致数据库死锁。总之,我认为这种是典型的由于领域模型的设计错误,导致并发冲突高、数据持久化落地困难。或者如果要解决并发问题,只能排队单线程处理,但是仍然解决不了要在一个事务里修改大量聚合根的尴尬局面。听说12306是采用了Pivotal Gemfire这种高大上的内存数据库,我对这个不太了解。我不可想象要是不使用内存数据库,他们要怎么实现车次内的票之间的数据强一致性(就是保证所有出售的票都是符合上面讨论的业务规则的)?所以,这种设计,我个人认为是思维定势了,把火车票看成是普通电商的商品来看待。所以,我们有时做设计又要依赖于经验,又要不能被以往经验所束缚,真的不容易,关键还是要根据具体的业务场景多多深入分析,尽量分析抽象出问题的本质出来,这样才能对症下药。那是否有其他的设计思路呢?

 

我的思路

聚合设计

通过上面的分析我们知道,其实任何一次购票都是针对某个车次的,我认为车次是负责处理订票的聚合根。我们看看一个车次包含了哪些信息?一个车次包括了:

  • 车次名称,如G71;

  • 座位数,实际座位数会分类型,比如商务座20个,一等座200个;二等座500个;我们这里为了简化问题,可以暂时忽略类型,我认为这个类型不影响核心的模型的设计决策。需要格外注意的是:这里的座位数不要理解为真实的物理座位数,很有可能比真实的座位数要少。因为我们不可能把一个车次的所有座位都在网上通过12306来出售,而是只出售一部分,具体出售多少,要由工作人员人工指定。

  • 经过的站点信息(包括站点的ID、站点名称等),注意:车次还会记录这些站点之间的顺序关系;

  • 出发时间;看过GRASP九大模式中的信息专家模式的同学应该知道,将职责分配给拥有执行该职责所需信息的类。我们这个场景,车次具有一次出票的所有信息,所以我们应该把出票的职责交给车次。另外学过DDD的同学应该知道,聚合设计有一个原则,就是:聚合内强一致性,聚合之间最终一致性。

经过上面的分析,我们知道要产生一张票,其实要影响很多和这个票对应的线段相交的其他票的可用数量。因为所有的站点信息都在车次聚合内部,所以车次聚合内部自然可以维护所有的原子区间,以及每个原子区间的可用票数(相当于是库存数)。当一个原子区间的可用票数为0的时候,意味着火车针对这个区间的票已经卖完了。所以,我们完全可以让车次这个聚合根来保证出票时对所有原子区间的可用票数的更新的强一致性。对于车次聚合根来说,这很简单,因为只是几次简单的内存操作而已,耗时可以忽略。一列火车假如有ABCD四个站点,那原子区间就是3个。对于G71,则是16个。

 

怎么判断是否能出票

基于上面的聚合设计,出票时扣减库存的逻辑是:

根据订单信息,拿到出发地和目的地,然后获取这段区间里的所有的原子区间。然后尝试将每个原子区间的可用票数减1,如果所有的原子区间都够减,则购票成功;否则购票失败,提示用户该票已经卖完了。是不是很简单呢?知道了出票的逻辑,那退票的逻辑也就很简单了,就是把这个票的所有原子区间的可用票数加1就OK了。如果我们从线段的厚度的角度去考虑,那出票时,每个原子区间的厚度就是+1,退票时就是减一。就是相反的操作,但本质是一样的。

所以,通过这样的思路,我们将一次订票的处理控制在了一个聚合根里,用聚合根内的强一致性的特性保证了订票处理的强一致性,同时也保证了性能,免去了并发冲突的可能性。传统电商那种把票单做类似商品的核心聚合根的设计,我当时第一眼看到就觉得不妥。因为这违背了DDD强调的强一致性应该由聚合根来保证、聚合根之间的最终一致性通过Saga来保证的原则。

还有一个很重要的概念我想说一下我的看法,就是座位和区间的关系。因为有些朋友和我讲,考虑座位号的问题,虽然都能减1,座位号也必须是同一个。我觉得座位是全局共享的,和区段无关(也许我的理解完全有误,请大家指正)。座位是一个物理概念,一个用户成功购买了一张票后,座位就会少一个,一张票唯一对应一个座位,但是一个座位有可能会对应多张票;而区间是一个逻辑上的概念,区间的作用有两个:1)表示票的出发地和目的地;2)记录票的可用数额。如果区间能连通(即该区间内的每个原子区间的可用数额都大于0),则表示允许拥有一个座位。所以,我觉得座位和票(区间)是两个维度的概念。

 

如何为票分配座位

我觉得车次聚合根内部应该维护所有该车次已经售出的票,已经出售的票的的本质是区间和座位的对应关系。系统处理订票时,用户提交过来的是一段区间。所以,系统应该做两个事情:

  1. 先根据区间去判断是否有可用的座位;
  2. 如果有可用座位,则再通过算法去选择一个可用的座位;

当得到一个可用座位后,就可以生成一张票了,然后保存这个票到车次聚合根内部即可。下面举个例子:

假设现在的情况是座位有3个,站点有4个

座位:1,2,3

站点:abcd

票的卖法1

票1:ab,1

票2:bc,2

票3:cd,3

票4:ac,3

票5:bd,1

这种选座位的方式应该比较高效,因为总是优先从座位池里去拿座位,只有在万不得已的时候才会去回收可重复利用的票。

上面的4,5两个票,就是考虑回收利用的结果。

票的卖法2

票1:ab,1

票2:bc,1

票3:cd,1

票4:ac,2

票5:bd,3

这种选座位的方式应该相对低效,因为总是优先会去扫描是否有可回收的座位,而扫描相对直接从座位池里去拿票总是成本相对要高的。

上面的2,3两个票,就是考虑回收利用的结果。

但是,优先从座位池里拿票的算法有缺陷,就是会出现虽然第一步判断认为有可用的座位,但是这个座位可能不是全程都是同一个座位。举例:

假设现在的情况是座位有3个,站点有4个

票的卖法3:

票1:ab,1

票2:bc,2

票3:cd,3

现在如果有人要买ad的票,那可用的座位有2,或者3。但是无论是2还是3,都要这个乘客中途换车位。比如卖给他座位2,那他ab是坐的座位2,但是bc的时候要坐座位1的。否则拿票2的那个人上车时,发现座位2已经有人了。而通过优先回收利用的算法,是没这个问题的。

所以,从上面的分析我们也知道选座位的算法该怎么写了,就是采用优先回收利用座位的算法。我认为不管我们这里怎么设计算法,都不影响大局,因为这一切都只发生在车次聚合根内部,这就是预先设计好聚合根,明确出票职责在哪个对象上的好处。

扫描的代价相对于纠错的成本来说是可以接受的。

 

模型分析总结

  1. 我认为票不是核心聚合根,票只是一次出票的结果,一个凭证而已。
  2. 12306真正的核心聚合根应该是车次,车次具有出票的职责,一次出票具体做的事情有:
    • 判断是否可出票;
    • 选择可用的座位;
    • 更新一次出票时所有原子区间的可用票数,用于判断下次是否能出票;
    • 维护所有已售出的票,用于为选择可用座位提供依据;

通过这样的模型设计,我们可以确保一次出票处理只会在一个车次聚合根内进行。这样的好处是:

  1. 不需要依赖数据库事务就能实现数据修改的强一致性,因为所有修改只在一个聚合根内发生;
  2. 在保证数据强一致性的同时还能提供很高的并发处理能力,具体设计见下面的架构设计;

 

架构设计

**我觉得12306这样的业务场景,非常适合使用CQRS架构;因为首先它是一个查多写少、但是写的业务逻辑非常复杂的系统。所以,非常适合做架构层面的读写分离,即采用CQRS架构。**而且应该使用数据存储也分离的CQRS。这样CQ两端才可以完全不需要顾及对方的问题,各自优化自己的问题即可。我们可以在C端使用DDD领域模型的思路,用良好设计的领域模型实现复杂的业务规则和业务逻辑。而Q端则使用分布式缓存方案,实现可伸缩的查询能力。

 

订票的实现思路

同时借助像ENode这样的框架,我们可以实现in-memory + Event Sourcing的架构。Event Sourcing技术,可以让领域模型的所有状态修改的持久化统一起来,本来要用ORM的方式保存聚合根最新状态的,现在只需要简单的通用的方式保存一个事件即可(一次订票只涉及一个车次聚合根的修改,修改只产生一个事件,只需要持久化一个事件(一个JSON串)即可,保证了高性能,无须依赖事务,而且通过ENode可以解决并发问题)。我们只要保存了聚合根每次变化的事件(事件的结构怎么设计,本文不做多的介绍了,大家可以思考下),就相当于保存了聚合根的最新状态。而正是由于Event Sourcing技术的引入,让我们的模型可以一直存活在内存中,即可以使用in-memory技术。不要小看in-memory技术,in-memory技术在某些方面对提高命令的处理性能非常有帮助。比如就以我们车次聚合根处理出票的逻辑,假设某个车次有大量的命令发送到分布式消息队列,然后有一台机器订阅了这个队列的消息,然后这台机器处理这个车次的订票命令时,由于这个车次聚合根一直在内存,所以就省去了每次要去数据库取出聚合根的步骤,相当于少了一次数据库IO。这样的好处是,因为一个车次能够真正出售的票是有限的,因为座位就那么几个,比如就1000个座位,估计一般正常情况也就出个2000个左右的票吧(具体能出多少张票要取决于区间的相交程度,上面分析过)。也就是说,这个聚合根只会产生2000个事件,也就是说只会有2000个订票命令的处理是会产生事件,并持久化事件;而其余的大量命令,因为车次在内存计算后发现没有余票了,就不会做任何修改,也不会产生领域事件,这样就可以直接处理下一个订票命令了。这样就可以大大提高处理订票命令的性能。

另外一个问题我觉得还需要提一下,因为用户订票成功后,还需要付款。但用户有可能不去付款或者没有在规定的时间内完成付款。那这种情况下,系统会自动释放该用户之前订购的票。所以基于这样的需求,我们在业务上需要支持业务级别的2pc。即先预扣库存,也就是先占住这张票一定时间(比如15分钟),然后付款成功后再真实给你这张票,系统做真正的库存修改。通过这样的预扣处理,可以保证不会出现超卖的情况。这个思路其实和传统电商比如淘宝这样的系统类似,我就不多展开了,我之前写的Conference案例也是这样的思路,大家有兴趣的可以去看一下我之前录制的视频。

 

查询余票的实现思路

我觉得余票的查询的实现相对简单。虽然对于12306来说,查询的请求占了80%,提交订单的请求只占20%。但查询由于对数据没有修改,所以我们完全可以使用分布式缓存来实现。我们只需要精心设计好缓存的key即可;缓存key的多少要看成本,如果所有可能的查询都设计对应的key,那时间复杂度为1,查询性能自然高;但代价也大,因为key多了。如果想key少一点,那查询的复杂度自然要上去一点。所以缓存设计无非就是空间换时间的思路。然后,缓存的更新无非就是:自动失效、定时更新、主动通知3种。通过CQRS架构,由于CQ两端是事件驱动的,当C端有任何状态变化,都会产生对应的事件去通知Q端,所以我们几乎可以做到Q端的准实时更新。

同时由于CQ两端的完全解耦,Q端我们可以设计多种存储,如数据库和缓存(Redis等);数据库用于线下维护关系型数据,缓存用户实时查询。数据库和缓存的更新速度相互不受影响,因为是并行的。对同一个事件,可以10台机器负责更新缓存,100台机器负责更新数据库。即便数据库的更新很慢,也不会影响缓存的更新进度。这就是CQRS架构的好处,CQ的架构完全不同,且我们随时可以重建一种新的Q端存储。不知道大家体会到了没有?

关于缓存key的设计,我觉得主要从查询余票时传递的信息来考虑。12306的关键查询是:出发地、目的地、出发日期三个信息。我觉得有两种key的设计思路:1)直接设计了该查询条件的key,然后快速拿到车次信息,直接返回;这种方式就是要求我们系统已经枚举了所有车次的所有可能出现的票(区间)的缓存key,相信你一定知道这样的key是非常多的。2)不是枚举所有区间,而是把每个车次的每个原子区间(相邻的两个站点所连成的直线)的可用票数作为key。这样,key就非常少了,因为车次假如有10000个,然后每个车次平均15个区间,那也就15W个key而已。当我们要查询时,只需要把用户输入的出发地和目的地之间的所有原子区间的可用票数都查出来,然后比较出最小可用票数的那个原子区间。则这个原子区间的可用票数就是用户输入的区间的可用票数了。当然,到这里我提到考虑出发日期。我认为出发日期是用来决定具体是哪个车次聚合根的。同一个车次,不同的日期,对应的聚合根实例是不同的,即便是同一天,也可能有多个车次聚合根,因为有些车次一天有几班的,比如上午9点发车的一班,下午3点发车的一般。所以,我们也只要把日期也作为缓存key的一部分即可。

 

参考

https://zhuanlan.zhihu.com/p/109114670

https://www.cnblogs.com/netfocus/archive/2011/10/10/2204949.html

https://www.cnblogs.com/netfocus/p/3307971.html

https://www.cnblogs.com/netfocus/p/4055346.html

https://www.jdon.com/cqrs.html

https://www.jdon.com/54180

https://blog.csdn.net/luxianping/article/details/122857386?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_title~default-2-122857386-blog-79849285.pc_relevant_vip_default&spm=1001.2101.3001.4242.2&utm_relevant_index=5