
对DDD领域驱动设计感兴趣,在.NET/JAVA平台都有多年工作经验。架构方面专注于CQRS/Event Souring/EDA架构的研究和框架开发。热衷于开源,拥有两个个人开源项目:ENode,EQueue
设计文档模板: 系统背景和定位 业务需求描述 系统用例图 关键业务流程图 领域语言整理,主要是整理领域中的各种术语的定义,名词解释 领域划分(分析出子域、核心域、支撑域) 每个子域的领域模型设计(实体、值对象、聚合、领域事件,需要注意的是:领域模型是需要抽象的,要分析业务本质,而不是简单的直接对需求进行建模) 领域模型详细说明(如为什么这样设计的原因、模型内对象的关系、各种业务规则、数据一致性规则等) 领域服务、仓储、工厂设计 Saga流程设计 场景走查(讲述如何通过领域模型、领域服务、仓储、Saga流程等完成系统用例以及关键业务流程的) 架构设计(如传统三层架构、经典四层架构、CQRS/ES架构) 一些其他的思考: 去除一切花俏的建模技巧,我觉得最重要的方向就是去努力分析问题和事物的本质,针对这个本质进行领域建模。这个领域建模,最主要的还是锻炼的人的事物抽象能力。10个人,建出来的领域模型都不同。本质原因就是大家对同一个问题的理解不同,对事物的本质的理解不同。虽然最终都能解决当前的问题,但是对适应未来需求变化的能力却是不同。 所以,我们要把时间花在多理解业务上,让自己成为领域专家,只有这样,才能充分理解业务。多理解一点业务,你才能更好的抽象出业务本质最后的领域模型。 领域建模是一个迭代的过程,人无完人,时间也很多时候不是很充足。所以,不太可能一步到位把领域设计做的很完美,会有一个持续的过程。整体项目规划的时候可能会有个大的架构设计、业务大图,但是不可能达到领域设计的粒度,只能是一期一期的完善,到最后可能才会有完整的上面的目录内容。每一期都需要考虑支持的场景约束、上下文、系统边界、持续集成的相关设计。设计product, not project。
从遇到问题开始 当人们要做一个软件系统时,一般总是因为遇到了什么问题,然后希望通过一个软件系统来解决。 比如,我是一家企业,然后我觉得我现在线下销售自己的产品还不够,我希望能够在线上也能销售自己的产品。所以,自然而然就想到要做一个普通电商系统,用于实现在线销售自己企业产品的目的。 再比如,我是一家互联网公司,公司有很多系统对外提供服务,面向很多客户端设备。但是最近由于各种原因,导致服务经常出故障。所以,我们希望通过各种措施提高服务的质量和稳定性。其中的一个措施就是希望能做一个灰度发布的平台,这个平台可以提供灰度发布的服务。然后,当某个业务系统做了一些修改并需要发布时,可以使用我们的灰度发布平台来非常方便的实现灰度发布的功能。比如在灰度发布平台上方便的定制允许哪些特定的客户端才会访问新服务,哪些客户端继续使用老服务。灰度发布平台可以提供各种灰度的策略。有了这样的灰度发布机制,那即便系统的新逻辑有什么问题,受影响的面也不会很大,在可控范围内。所以,如果公司里的所有对外提供服务的系统都接入了灰度平台,那这些系统的发布环节就可以更加有保障了。 总之,我们做任何一个软件系统,都是有原因的,否则就没必要做这个系统,而这个原因就是我们遇到的问题。所以,通过问题,我们就知道了我们需要一个什么样的系统,这个系统解决什么样的问题。最后,我们就很自然的得出了一个目标,即知道了自己要什么。比如我要做一个论坛、一个博客系统、一个电商平台、一个灰度发布系统、一个IDE、一个分布式消息队列、一个通信框架,等等。 DDD切入点1 - 理解概念 DDD的全称为Domain-driven Design,即领域驱动设计。下面我从领域、问题域、领域模型、设计、驱动这几个词语的含义和联系的角度去阐述DDD是如何融入到我们平时的软件开发初期阶段的。要理解什么是领域驱动设计,首先要理解什么是领域,什么是设计,还有驱动是什么意思,什么驱动什么。 什么是领域(Domain)? 前面我们已经清楚的知道我们现在要做一个什么样的系统,这个系统需要解决什么问题。我认为任何一个系统都会属于某个特定的领域,比如论坛是一个领域,只要你想做一个论坛,那这个论坛的核心业务是确定的,比如都有用户发帖、回帖等核心基本功能。比如电商平台、普通电商系统,这种都属于网上电商领域,只要是这个领域的系统,那都有商品浏览、购物车、下单、减库存、付款交易等核心环节。所以,同一个领域的系统都具有相同的核心业务,因为他们要解决的问题的本质是类似的。 因此,我们可以推断出,一个领域本质上可以理解为就是一个问题域,只要是同一个领域,那问题域就相同。所以,只要我们确定了系统所属的领域,那这个系统的核心业务,即要解决的关键问题、问题的范围边界就基本确定了。通常我们说,要成为一个领域的专家,必须要在这个领域深入研究很多年才行。因为只有你研究了很多年,你才会遇到非常多的该领域的问题,同时你解决这个领域中的问题的经验也非常丰富。很多时候,领域专家比技术专家更加吃香,比如金融领域的专家。 什么是设计(Design)? DDD中的设计主要指领域模型的设计。为什么是领域模型的设计而不是架构设计或其他的什么设计呢?因为DDD是一种基于模型驱动开发的软件开发思想,强调领域模型是整个系统的核心,领域模型也是整个系统的核心价值所在。每一个领域,都有一个对应的领域模型,领域模型能够很好的帮我们解决复杂的业务问题。 从领域和代码实现的角度来理解,领域模型绑定了领域和代码实现,确保了最终的代码实现就一定是解决了领域中的核心问题的。因为:1)领域驱动领域模型设计;2)领域模型驱动代码实现。我们只要保证领域模型的设计是正确的,就能确定领域模型可以解决领域中的核心问题;同理,我们只要保证代码实现是严格按照领域模型的意图来落地的,那就能保证最后出来的代码能够解决领域的核心问题的。这个思路,和传统的分析、设计、编码这几个阶段被割裂(并且每个阶段的产物也不同)的软件开发方法学形成鲜明的对比。 什么是驱动(Driven)? 上面其实已经提到了,就是:1)领域驱动领域模型设计;2)领域模型驱动代码实现。这个就和我们传统的数据库驱动开发的思路形成对比了。DDD中,我们总是以领域为边界,分析领域中的核心问题(核心关注点),然后设计对应的领域模型,再通过领域模型驱动代码实现。而像数据库设计、持久化技术等这些都不是DDD的核心,而是外围的东西。 领域驱动设计(DDD)告诉我们的最大价值我觉得是:当我们要开发一个系统时,应该尽量先把领域模型想清楚,然后再开始动手编码,这样的系统后期才会很好维护。但是,很多项目(尤其是互联网项目,为了赶工)都是一开始模型没想清楚,一上来就开始建表写代码,代码写的非常冗余,完全是过程是的思考方式,最后导致系统非常难以维护。而且更糟糕的是,出来混总是要还的,前期的领域模型设计的不好,不够抽象,如果你的系统会长期需要维护和适应业务变化,那后面你一定会遇到各种问题维护上的困难,比如数据结构设计不合理,代码到处冗余,改BUG到处引入新的BUG,新人对这种代码上手困难,等。而那时如果你再想重构模型,那要付出的代价会比一开始重新开发还要大,因为你还要考虑兼容历史的数据,数据迁移,如何平滑发布等各种头疼的问题。所以,就导致我们最后天天加班。 虽然,我们都知道这个道理,但是我也明白,人的习惯很难改变的,大部分人都很难从面向过程式的想到哪里写到哪里的思想转变为基于系统化的模型驱动的思维。我想,这或许是DDD很难在中国或国外流行起来的原因吧。但是,我想这不应该成为我们放弃学习DDD的原因,对吧! 概念总结: 领域就是问题域,有边界,领域中有很多问题; 任何一个系统要解决的那个大问题都对应一个领域; 通过建立领域模型来解决领域中的核心问题,模型驱动的思想; 领域建模的目标针对我们在领域中所关心的问题,即只针对核心关注点,而不是整个领域中的所有问题; 领域模型在设计时应考虑一定的抽象性、通用性,以及复用价值; 通过领域模型驱动代码的实现,确保代码让领域模型落地,代码最终能解决问题; 领域模型是系统的核心,是领域内的业务的直接沉淀,具有非常大的业务价值; 技术架构设计或数据存储等是在领域模型的外围,帮助领域模型进行落地; DDD切入点2 - 理解领域、拆分领域、细化领域 理解领域知识是基础 上面我们通过第一步,虽然我们明确了要做一个什么样的系统,该系统主要解决什么问题,但是就这样我们还无法开始进行实际的需求分析和模型设计,我们还必须将我们的问题进行拆分,需求进行细化。有些时候,需求方,即提出问题的人,很可能自己不清楚具体想要什么。他只知道一个概念,一个大的目标。比如他只知道要做一个股票交易系统,一个灰度发布系统,一个电商平台,一个开发工具,等。但是他不清楚这些系统应该具体做成什么样子。这个时候,我认为领域专家就非常重要了,DDD也非常强调领域专家的重要性。因为领域专家对这个领域非常了解,对领域内的各种业务场景和各种业务规则也非常清楚,总之,对这个领域内的一切业务相关的知识都非常了解。所以,他们自然就有能力表达出系统该做成什么样子。所以,要知道一个系统到底该做成什么样子,到底哪些是核心业务关注点,只能靠沉淀领域内的各种知识,别无他法。因此,假设你现在打算做一个电商平台,但是你对这个领域没什么了解,那你一定得先去了解下该领域内主流的电商平台,比如淘宝、天猫、京东、亚马逊等。这个了解的过程就是你沉淀领域知识的过程。如果你不了解,就算你领域建模的能力再强,各种技术架构能力再强也是使不上力。领域专家不是某个固定的角色,而是某一类人,这类人对这个领域非常了解。比如,一个开发人员也可以是一个领域专家。假设你在一个公司开发和维护一个系统已经好几年了,但是这个系统的产品经理(PD)可能已经换过好几任了,这种情况下,我相信这几任产品经理都没有比你更熟悉这个领域。 拆分领域 上面我们明白了,领域建模的基础是要先理解领域,让自己成为领域专家。如果做到了这点,我们就打好了坚实的基础了。但是,有时一个领域往往太复杂,涉及到的领域概念、业务规则、交互流程太多,导致我们没办法直接针对这个大的领域进行领域建模。所以,我们需要将领域进行拆分,本质上就是把大问题拆分为小问题,然后各个击破的思路。然后既然把一个大的领域划分为了多个小的领域(子域),那最关键的就是要理清每个子域的边界;然后要搞清楚哪些子域是核心子域,哪些是非核心子域,哪些是公共支撑子域;然后,还要思考子域之间的联系是什么。那么,我们该如何划分子域呢?我的个人看法是从业务相关性的角度去思考,也就是我们平时说的按业务功能为出发点进行划分。还是拿经典的电商系统来分析,通常一个电商系统都会包含好几个大块,比如: 会员中心:负责用户账号登录、用户信息的管理; 商品中心:负责商品的展示、导航、维护; 订单中心:负责订单的生成和生命周期管理; 交易中心:负责交易相关的业务; 库存中心:负责维护商品的库存; 促销中心:负责各种促销活动的支持; 上面这些中心看起来很自然,因为大家对电子商务的这个领域都已经非常熟悉了,所以都没什么疑问,好像很自然的样子。所以,领域划分是不是就是没什么挑战了呢?显然不是。之所以我们觉得子域划分很简单,是因为我们对整个大领域非常了解了。如果我们遇到一个冷门的领域,就没办法这么容易的去划分子域了。这就需要我们先去努力理解领域内的知识。所以,我个人从来不相信什么子域划分的技巧什么的东西,因为我觉得这个工作没有任何诀窍可以使用。当我们不了解一个东西的时候,如何去拆解它?当我们对整个领域有一定的熟悉了,了解了领域内的相关业务的本质和关系,我们就自然而然的能划分出合理的子域了。不过并不是所有的系统都需要划分子域的,有些系统只是解决一个小问题,这个问题不复杂,可能只有一两个核心概念。所以,这种系统完全不需要再划分子域。但不是绝对的,当一个领域,我们的关注点越来越多,每个关注点我们关注的信息越来越多的时候,我们会不由自主的去进一步的划分子域。比如,也许我们一开始将商品和商品的库存都放在商品中心里,但是后来由于库存的维护越来越复杂,导致揉在一起对我们的系统维护带来一定的困难时,我们就会考虑将两者进行拆分,这个就是所谓的业务垂直分割。 细化子域 通过上面的两步,我们了解了领域里的知识,也对领域进行了子域划分。但这样还不够,凭这些我们还无法进行后续的领域模型设计。我们还必须再进一步细化每个子域,进一步明确每个子域的核心关注点,即需求细化。我觉得我们需要细化的方面有以下几点: 梳理领域概念:梳理出领域内我们关注的概念、概念的关系,并统一交流词汇,形成统一语言; 梳理业务规则:梳理出领域内我们关注的各种业务规则,DDD中叫不变性(invariants),比如唯一性规则,余额不能小于零等; 梳理业务场景:梳理出领域内的核心业务场景,比如电商平台中的加入购物车、提交订单、发起付款等核心业务场景; 梳理业务流程:梳理出领域内的关键业务流程,比如订单处理流程,退款流程等; 从上面这4个方面,我们从领域概念、业务规则、交互场景、业务流程等维度梳理了我们到底要什么,整理了整个系统应该具备的功能。这个工作我觉得是一个非常具有创造性和有难度的工作。我们一方面会主观的定义我们想要什么;另一方面,我们还会思考我们要的东西的合理性。我认为这个就是产品经理的工作,产品经理必须要负起职责,把他的产品充分设计好,从各个方面去考虑,如何设计一个产品,才能更好的解决用户的核心诉求,即领域内的核心问题。如果对领域不够了解,如果想不清楚用户到底要什么,如果思考问题不够全面,谈何设计出一个合理的产品呢? 关于领域概念的梳理,我觉得可以采用四色原型分析法,这个分析法通过系统的方法,将概念划分为不同的种类,为不同种类的概念标注不同的颜色。然后将这些概念有机的组合起来,从而让我们可以清晰的分析出概念和概念之间的关系。有兴趣的同学可以在网上搜索下四色原型。 注意:上面我说的这四点,重点是梳理出我们要什么功能,而不是思考如何实现这些功能,如何实现是软件设计人员的职责。 DDD切入点3 - 领域模型设计 这部分内容,我想学习DDD的人都很熟悉了。DDD原著中提出了很多实用的建模工具:聚合、实体、值对象、工厂、仓储、领域服务、领域事件。我们可以使用这些工具,来设计每一个子域的领域模型。最终通过领域模型图将设计沉淀下来。要使用这些工具,首先就要理解每个工具的含义和使用场景。不要以为很简单哦,比如聚合的划分就是一个非常具有艺术的活。同一个系统,不同的人设计出来的聚合是完全不同的。而且很有可能高手之间的最后设计出来的差别反而更大,实际上我认为是世界观的相互碰撞,呵呵。所以,要领域建模,我觉得每个人都应该去学学哲学知识,这有助于我们更好的认识世界,更好的理解事物的本质。 关于这些建模工具的概念和如何运用我就不多展开了,我博客里也有很多这方面的介绍。下面我再讲一下我认为比较重要的东西,比如到底该如何领域建模?步骤应该是怎么样的? 领域建模的方法 通过上面我介绍的细化子域的内容,现在再来谈该如何领域建模,我觉得就方便很多了。我的主要方法是: 划分好边界上下文,通常每个子域(sub domain)对应一个边界上下文(bounded context),同一个边界上下文中的概念是明确的,没有任何歧义; 在每个边界上下文中设计领域模型,具体的领域模型设计方法有很多种,如以场景为出发点的四色原型分析法,或者我早期写的这篇文章;这个步骤最核心的就是找出聚合根,并找出每个聚合根包含的信息;关于如何设计聚合,可以看一下我写的这篇文章; 画出领域模型图,圈出每个模型中的聚合边界; 设计领域模型时,要考虑该领域模型是否满足业务规则,同时还要综合考虑技术实现等问题,比如并发问题;领域模型不是概念模型,概念模型不关注技术实现,领域模型关心;所以领域模型才能直接指导编码实现; 思考领域模型是如何在业务场景中发挥作用的,以及是如何参与到业务流程的每个环节的; 场景走查,确认领域模型是否能满足领域中的业务场景和业务流程; 模型持续重构、完善、精炼; 领域模型的核心作用: 抽象了领域内的核心概念,并建立概念之间的关系; 领域模型承担了领域内的状态的维护; 领域模型维护了领域内的数据之间的业务规则,数据一致性; 下图是我最近做个一个普通电商系统的商品中心的领域模型图,给大家参考: 领域模型设计只是软件设计中的一小部分 需要特别注意的是,领域模型设计只是整个软件设计中的很小一部分。除了领域模型设计之外,要落地一个系统,我们还有非常多的其他设计要做,比如: 容量规划 架构设计 数据库设计 缓存设计 框架选型 发布方案 数据迁移、同步方案 分库分表方案 回滚方案 高并发解决方案 一致性选型 性能压测方案 监控报警方案 等等。上面这些都需要我们平时的大量学习和积累。作为一个合格的开发人员或架构师,我觉得除了要会DDD领域驱动设计,还要会上面这么多的技术能力,确实是非常不容易的。所以,千万不要以为会DDD了就以为自己很牛逼,实际上你会的只是软件设计中的冰山一角而已。 总结 本文的重点是基于我个人对DDD的一些理解,希望能整理出一些自己总结出来的一些感悟和经验,并分享给大家。我相信很多人已经看过太多DDD书上的东西,我总是感觉书上的东西看似都太”正规“,很多时候我们读了之后很难消化,就算理解了书里的内容,当我们想要运用到实践中时,总是感觉无从下手。本文希望通过通俗易懂的文字,介绍了一部分我对DDD的学习感悟和实践心得,希望能给大家一些启发和帮助。
关于DDD的理论知识总结,可参考这篇文章。 DDD社区官网上一篇关于聚合设计的几个原则的简单讨论: 文章地址:http://dddcommunity.org/library/vernon_2011/,该地址中包含了一篇关于介绍如何有效的设计聚合的一些原则,共3个pdf文件。该文章中指出了以下几个聚合设计的原则: 聚合是用来封装真正的不变性,而不是简单的将对象组合在一起; 聚合应尽量设计的小; 聚合之间的关联通过ID,而不是对象引用; 聚合内强一致性,聚合之间最终一致性; 上面这几条原则,作者通过一个例子来逐步阐述。下面我按照我的理解对每个原则做一个简单的描述。 聚合是用来封装真正的不变性,而不是简单的将对象组合在一起 这个原则,就是强调聚合的真正用途除了封装我们本身所关心的信息外,最主要的目的是为了封装业务规则,保证数据的一致性。在我看来,这一点是设计聚合时最重要和最需要考虑的点;当我们在设计聚合时,要多想想当前聚合封装了哪些业务规则,实现了哪些数据一致性。所谓的业务规则是指,比如一个银行账号的余额不能小于0,订单中的订单明细的个数不能为0,订单中不能出现两个明细对应的商品ID相同,订单明细中的商品信息必须合法,商品的名称不能为空,回复被创建时必须要传入被回复的帖子(因为没有帖子的回复不是一个合法的回复),等; 聚合应尽量设计的小 这个原则,更多的是从技术的角度去考虑的。作者通过一个例子来说明,该例子中,一开始聚合设计的很大,包含了很多实体,但是后来发现因为该聚合包含的东西过多,导致多人操作时并发冲突严重,导致系统可用性变差;后来开发团队将原来的大聚合拆分为多个小聚合,当然,拆分为小聚合后,原来大聚合内维护的业务规则同样在多个小聚合上有所体现。所以实现了既能解决并发冲突的问题,也能保证让聚合来封装业务规则,实现模型级别的数据一致性;另外,回复中的一位道友“殇、凌枫”提到,聚合设计的小还有一个好处,就是:业务决定聚合,业务改变聚合。聚合设计的小除了可以降低并发冲突的可能性之外,同样减少了业务改变的时候,聚合的拆分个数,降低了聚合大幅重构(拆分)的可能性,从而能让我们的领域模型更能适应业务的变化。 聚合之间通过ID关联 这个原则,是考虑到,其实聚合之间无需通过对象引用的方式来关联; 首先通过引用关联,会导致聚合的边界不够清晰,如果通过ID关联,由于ID是值对象,且值对象正好是用来表达状态的;所以,可以让聚合内只包含只属于自己的实体或值对象,那这样每个聚合的边界就很清晰;每个聚合,关心的是自己有什么信息,自己封装了什么业务规则,自己实现了哪些数据一致性; 如果通过引用关联,那需要实现LazyLoad的效果,否则当我们加载一个聚合的时候,就会把其关联的其他聚合也一起加载,而实际上我们有时在加载一个聚合时,不需要用到关联的那些聚合,所以在这种时候,就给性能带来一定影响,不过幸好我们现在的ORM都支持LazyLoad,所以这点问题相对不是很大; 你可能会问,聚合之间如果通过对象引用来关联,那聚合之间的交互就比较方便,因为我可以方便的直接拿到关联的聚合的引用;是的,这点是没错,但是如果聚合之间要交互,在经典DDD的架构下,一般可以通过两种方式解决:1)如果A聚合的某个方法需要依赖于B聚合对象,则我们可以将B聚合对象以参数的方式传递给A聚合,这样A对B没有属性上的关联,而只是参数上的依赖;一般当一个聚合需要直接访问另一个聚合的情况往往是在职责上表明A聚合需要通知B聚合做什么事情或者想从B聚合获取什么信息以便A聚合自己可以实现某种业务逻辑;2)如果两个聚合之间需要交互,但是这两个聚合本身只需要关注自己的那部分逻辑即可,典型的例子就是银行转账,在经典DDD下,我们一般会设计一个转账的领域服务,来协调源账号和目标账号之间的转入和转出,但源账号和目标账号本身只需要关注自己的转入或转出逻辑即可。这种情况下,源账号和目标账号两个聚合实例不需要相互关联引用,只需要引入领域服务来协调跨聚合的逻辑即可; 如果一个聚合单单保存另外的聚合的ID还不够,那是否就需要引用另外的聚合了呢?也不必,此时我们可以将当前聚合所需要的外部聚合的信息封装为值对象,然后自己聚合该值对象即可。比如经典的订单的例子就是,订单聚合了一些订单明细,每个订单明细包含了商品ID、商品名称、商品价格这三个来自商品聚合的信息;此时我们可以设计一个ProductInfo的值对象来包含这些信息,然后订单明细持有该ProductInfo值对象即可;实际上,这里的ProductInfo所包含的商品信息是在订单生成时对商品信息的状态的冗余,订单生成后,即便商品的价格变了,那订单明细中包含的ProductInfo信息也不会变,因为这个信息已经完全是订单聚合内部的东西了,也就是说和商品聚合无关了。 实际上通过ID关联,也是达到设计小聚合的目标的一种方式; 聚合内强一致性,聚合之间最终一致性 这个原则主要的背景是:如果用CQRS+Event Sourcing的架构来实现DDD,那聚合之间因为通过Domain Event(领域事件)来实现交互了,所以同样也不需要聚合与聚合之间的对象引用,同时也不需要领域服务了,因为领域服务已经被Process(流程聚合根)和Process Manager(流程管理器,无状态)所替代。流程聚合根,负责封装流程的当前状态以及流程下一步该怎么走的逻辑,包括流程遇到异常时的回滚处理逻辑;流程管理器,无状态。负责协调流程中各个参与者聚合根之间的消息交互,它会接受聚合根产生的domain event,然后发送command。另外一方面,由于CQRS的引入,使得我们的domain只需要处理业务逻辑,而不需要应付查询相关的需求了,各种查询需求专门由各种查询服务实现;所以我们的domain就可以非常瘦身,仅仅只需要通过聚合根来封装必要的业务规则(保证聚合内数据的强一致性)即可,然后每个聚合根做了任何的状态变更后,会产生相应的领域事件,然后事件会被持久化到EventStore,EventStore用来持久化所有的事件,整个domain的状态要恢复,只需要通过Event Sourcing的方式还原即可;另外,当事件持久化完成后,框架会通过事件总线将事件发布出去,然后Process Manager就可以响应事件,然后发送新的command去通知相应的聚合根去做必要的处理; 上面这个过程可以在任何一个CQRS的架构图(包括enode的架构图)中找到,我这里就不贴图了。enode中对经典的转账场景用这种思路实现了一下,有兴趣可以去下载enode源代码,然后看一下其中的BankTransferSample这个例子就清楚了。另外,因为事件的响应和Command的发送是异步的,所以,这种架构下,聚合根的交互是异步的; 需要再次强调的一点是,聚合如果只需要关注如何实现业务规则而不需要考虑查询需求所带来的好处,那就是我们不需要在domain里维护各种统计信息了,而只要维护各种业务规则所潜在的必须依赖的状态信息即可;举个例子,假如一个论坛,有版块和帖子,以前,我们可能会在版块对象上有一个帖子总数的属性,当新增一个帖子时,会对这个属性加1;而在CQRS架构下,domain内的版块聚合根无需维护总帖子数这个统计信息了,总帖子数会在查询端的数据库独立维护; 从聚合和哲学的角度思考,为什么需要状态? 聚合的角度 首先,什么是状态?很简单,比如一个商品的库存信息,那么该库存信息有一个商品的数量这个属性,表示当前商品在库存中还有多少件;那么我们为什么需要记录该属性呢?也就是为什么需要记录这个状态呢?因为有业务规则的存在。以这个例子为例,因为存在“商品的库存不能为负数”这样的一个业务规则,那这个规则如果要能保证,首先必须先记录商品的库存数量;因为商品的库存数量是会随着商品的卖出而减少的,而减少就是通过:Product.Count = Product.Count - 1这样的逻辑运算来实现;这个逻辑运算要能运行的前提就是商品要有库存信息。从这个例子我们不难理解,一个聚合根的很多状态,不是平白无辜设计上去的,而是某些业务规则潜在的要求,必须要设计这些状态才能实现相应的业务规则;这样的例子还有很多,比如银行账号的余额不能小于0,导致我们的银行账号必须要设计一个当前余额的属性; 另外一个原因是,看起来像是废话,呵呵。就是:因为我们关心这些信息,所以需要设计在当前聚合上;比如,以一个论坛的帖子为例,作为一个帖子,我们通常都会关心帖子的标题、描述、发帖人、发帖时间、所属版块(如果论坛有版块这个概念的话);所以,我们就会在帖子聚合根上设计出这些属性,以表达我们所关心的这些信息的状态; 哲学的角度 下面在从偏哲学的角度表达一下对象的概念吧: 人类永远无法认识完整的事物,因为我们认识到的总是事物的某一方面。我们所说的对象实际上是客观事物在人头脑里的反应,而事物则是不因人的认识发生改变的客观存在。同样一根铁棒,在钢材生产厂家看来,它是成品;在机械加工厂家看来,它是原料;在废品站看来,他是商品。成品、原料、商品,这三者拥有不同的属性,有本质的不同。为什么同一事物在不同人的眼里就截然不同了呢?这是因为我们总是取对我们有用的方面来认识事物。当这根铁棒作为商品时,它的原料属性依然存在,只是我们不关心了。 所以,总结出来就是,因为我们关心一个对象的某些方面,所以我们才会为他设计某些状态属性; 关于聚合的设计的一些思考 上面只是简单提到,聚合的设计应该多考虑它封装了哪些业务规则这个问题。下面我想再多讲一点我的一些想法: 关于GRASP九大模式中的最重要模式:信息专家模式 还是以论坛的帖子为例,创建一个帖子时,有一个业务规则,那就是帖子的发帖人、标题、描述、所属板块(如果论坛有板块这个概念的话)都不能为空或无效的值,因为这些信息只要有任何一个无效,那就意味着被创建出来的帖子是无效的,那就是没有保证业务规则,也就没办法谈领域模型的数据一致性了;如果像以往的三层贫血架构,那帖子只是一个数据的载体,不包含任何业务规则,帖子会先被构造一个空的帖子对象出来,然后我们给这个空帖子对象的某些属性赋值,然后保存该帖子对象到数据库;这种设计,帖子对象只是一个数据的容器,它完全控制不了自己的状态,因为它的状态都是被别人(如service)去修改的;这样的设计,相当于是没有把业务规则封装在业务对象内部,而是转移到了外部service中,虽然这样通常也没问题,事实上我们大部分人都一直在这么干,因为这样干写代码很随意,也很高效,呵呵。 GRASP九大模式中有一个面向对象的模式叫信息专家模式,不知道大家有了解过没有,该模式的描述是:将职责分配给拥有执行该职责所需信息的对象;这个模式告诉我们,如果一个对象负责维护一些信息,那它就有职责维护好这些信息。体现到对象的属性上,那就是这个对象的属性不能被外部随便更改,对象自己的属性必须自己负责维护修改。构造函数和普通的方法都会改变对象的状态,所以,我们对构造函数和对象普通的公共方法,都要秉持这个原则;这点非常重要,否则,如果像贫血模型那样,那对象就不叫对象了,而只是一个普通的容纳数据的容器而已,和数据库里的一条记录也无本质差别了。实际上,在我看来,这也是DDD中的聚合区别于贫血模型中的实体的最大的地方。聚合不仅有状态,还有严格维护好自己状态的各种方法,包括构造函数在内;而贫血模型,则只有状态,没有行为; 关于DDD中一个领域对象是否是聚合根的考虑 这个问题,没有非常清晰的放之四海而皆准的确定方法,我的想法是: 首先从我们对领域的最基本的常识方面的理解去思考,该对象是否有独立的生命周期,如果有,那基本上是聚合根了; 如果领域内的一个对象,我们会在后台有一个独立的模块去管理它,那它基本上也是聚合根了; 是否有独立的业务场景会去创建或修改一个对象; 如果对象有全局唯一的标识,那它也是聚合根了; 如果你不能确定一个对象是否是聚合根的的时候,就先放一下,就先假定它是聚合根也无妨,然后可以先分析一下你已经确定的那些聚合根应该具体聚合哪些信息;也许等你分析清楚其他的那些聚合的范围后,也推导出了你之前不确定是否是聚合根的那个对象是否应该是聚合根了呢。 关于一个聚合内应该聚合哪些信息的思考 把我们所需要关心的属性设计进去; 分析该聚合要封装和实现哪些业务规则,从而像上面的例子(商品库存)那样推导出需要设计哪些属性状态到该聚合内; 如果我们在创建或修改一个对象时,总是会级联创建或修改一些级联信息,比如在一个任务系统,当我们创建一个任务时,可能会上传一些附件,那这些附件的描述信息(如附件ID,附件名称,附件下载地址)就应该被聚合在任务聚合根上; 聚合内只需要值对象和内部的实体即可,不需要引用其他的聚合根,引用其他的聚合根只会让当前聚合的边界模糊; 关于如何更合理的设计聚合来封装各种业务规则的思考 这一点在最上面的几个原则中,实际上已经提到过一点,那就是尽量设计小聚合,这里的出发点主要是从技术的角度去思考,为了降低对公共对象(大聚合)的并发修改,从而减小并发冲突的可能性,从而提高系统的可用性(因为系统用户不会经常因为并发冲突而导致它的操作失败);关于这一点,我还想再举几个例子,来说明,其实要实现各种业务规则,可以有多种聚合的设计方式,大聚合只是其中一种; 比如,帖子和回复,大家都知道一个帖子有多个回复,没有帖子,回复就没有意义;所以很多人就会认为帖子应该聚合回复;但实际上不需要这样,如果你这样做了,那对于一个论坛来说,同一个帖子被多个人同时回复的可能性是非常高的,那这样的话,多个人同时回复一个帖子,就会导致多个人同时修改同一个帖子对象,那就导致大家都回复不了,因为会有并发冲突或者数据库事务的等待超时,因为大家都在修改同一个帖子聚合根;实际上如果我们从业务规则的角度去思考一下,那可以发现,其实帖子和回复之间,只有一个简单的规则,那就是回复一旦被创建,那他所对应的帖子不能被修改即可;这样的话,要实现这个规则其实很简单,把回复作为聚合根,然后把帖子传入回复聚合根的构造函数,然后回复保存帖子ID,然后回复将帖子ID设置为不允许外部修改(private set;即可),这样我们就实现了这个业务规则,同时还做到了多人同时推一个帖子回复时,不会对同一个帖子对象就并发修改,而是每个回复都是并行的往数据库插入一条回复记录即可; 所以,通过这个例子,我们发现,要实现领域模型内的各种业务规则,方法不止一种,我们除了要从业务角度考虑对象的内聚关系外,还要从技术角度考虑,但是不管从什么角度考虑,都是以实现所要求的业务规则为前提; 从这个例子,我们其实还发现了另外一件有意义的事情,那就是一个论坛中,发表帖子和发表回复是两个独立的业务场景;一个人发表了帖子,然后可能过了一段时间,另一个人对该帖子发表了回复;所以将帖子和回复都设计为独立的很容易理解;这里虽然帖子和回复是一对多,回复离开帖子确实也没意义,但是将回复设计在帖子内没任何好处,反而让系统的可用性降低;相反,像上面提到的关于创建任务时同时上传一些附件的例子,虽然一个任务也是对应多个附件信息,但是我们发现,人物的附件信息总是随着任务被创建或修改时,一起被修改的。也就是说,我们没有独立的业务场景需要独立修改任务的某个附件信息;所以,没有必要将任务的附件信息设计为独立聚合根; ENode框架对聚合设计和聚合之间交互的支持 enode提供了一个基于DDD+CQRS+Event Sourcing+In Memory+EDA这些技术的应用开发架构; enode在框架层面就限制了一个command只能修改一个聚合根,这就杜绝了我们使用Unit of Work的模式来以事务的方式来一次性修改多个聚合根; enode提供了可靠的原子操作和并发冲突检测机制,来保证对单个聚合的操作的强一致性; enode提供了可靠的事件机制,来保证我们的domain中的聚合之间数据交互可以通过事件异步通信的方式来实现聚合之间的最终一致性;如果有些复杂业务场景是一个流程,那我们可以通过Process+Process Manager的思想来实现流程状态的跟踪和流程的流转; enode因为基于domain event,所以,我们的聚合根不需要引用,每个聚合根只需要负责自己的状态更新,然后更新完后产生相应的domain event即可,这本质就是就是实现了:Don’t Ask, Tell这个设计原则; enode提供了可靠的事件发布机制,可以确保command side和query side的数据最终一定是一致的; enode提供了in memory的设计,使得我们的domain可以非常高效的运行,持久化事件不需要事务,获取聚合根直接从in memory获取; enode提供了很多设计,可以让我们最大化的对不同的聚合根实例做并行操作,从而提高整个系统的吞吐量; 使用enode,将会迫使你思考如何设计聚合,如何通过流程实现聚合之间的异步交互;迫使你思考如何定义domain event,将领域内的状态更改显式化;迫使你将外部对领域的各种操作显式化,即定义出各种command;迫使你将command side和query side的数据分离和架构分离,技术分离。减少的是,我们不必再设计unit of work,不必设计domain service,不必让聚合设计各种非第一手的冗余的统计信息;
领域驱动设计之领域模型 加一个导航,关于如何设计聚合的详细思考,见这篇文章。 2004年Eric Evans 发表Domain-Driven Design –Tackling Complexity in the Heart of Software (领域驱动设计),简称Evans DDD。领域驱动设计分为两个阶段: 以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型; 由领域模型驱动软件设计,用代码来实现该领域模型; 由此可见,领域驱动设计的核心是建立正确的领域模型。 为什么建立一个领域模型是重要的 领域驱动设计告诉我们,在通过软件实现一个业务系统时,建立一个领域模型是非常重要和必要的,因为领域模型具有以下特点: 领域模型是对具有某个边界的领域的一个抽象,反映了领域内用户业务需求的本质;领域模型是有边界的,只反应了我们在领域内所关注的部分; 领域模型只反映业务,和任何技术实现无关;领域模型不仅能反映领域中的一些实体概念,如货物,书本,应聘记录,地址,等;还能反映领域中的一些过程概念,如资金转账,等; 领域模型确保了我们的软件的业务逻辑都在一个模型中,都在一个地方;这样对提高软件的可维护性,业务可理解性以及可重用性方面都有很好的帮助; 领域模型能够帮助开发人员相对平滑地将领域知识转化为软件构造; 领域模型贯穿软件分析、设计,以及开发的整个过程;领域专家、设计人员、开发人员通过领域模型进行交流,彼此共享知识与信息;因为大家面向的都是同一个模型,所以可以防止需求走样,可以让软件设计开发人员做出来的软件真正满足需求; 要建立正确的领域模型并不简单,需要领域专家、设计、开发人员积极沟通共同努力,然后才能使大家对领域的认识不断深入,从而不断细化和完善领域模型; 为了让领域模型看的见,我们需要用一些方法来表示它;图是表达领域模型最常用的方式,但不是唯一的表达方式,代码或文字描述也能表达领域模型; 领域模型是整个软件的核心,是软件中最有价值和最具竞争力的部分;设计足够精良且符合业务需求的领域模型能够更快速的响应需求变化; 领域通用语言(UBIQUITOUS LANGUAGE) 我们认识到由软件专家和领域专家通力合作开发出一个领域的模型是绝对需要的,但是,那种方法通常会由于一些基础交流的障碍而存在难点。开发人员满脑子都是类、方法、算法、模式、架构,等等,总是想将实际生活中的概念和程序工件进行对应。他们希望看到要建立哪些对象类,要如何对对象类之间的关系建模。他们会习惯按照封装、继承、多态等面向对象编程中的概念去思考,会随时随地这样交谈,这对他们来说这太正常不过了,开发人员就是开发人员。但是领域专家通常对这一无所知,他们对软件类库、框架、持久化甚至数据库没有什么概念。他们只了解他们特有的领域专业技能。比如,在空中交通监控样例中,领域专家知道飞机、路线、海拔、经度、纬度,知道飞机偏离了正常路线,知道飞机的发射。他们用他们自己的术语讨论这些事情,有时这对于外行来说很难直接理解。如果一个人说了什么事情,其他的人不能理解,或者更糟的是错误理解成其他事情,又有什么机会来保证项目成功呢? 在交流的过程中,需要做翻译才能让其他的人理解这些概念。开发人员可能会努力使用外行人的语言来解析一些设计模式,但这并一定都能成功奏效。领域专家也可能会创建一种新的行话以努力表达他们的这些想法。在这个痛苦的交流过程中,这种类型的翻译并不能对知识的构建过程产生帮助。 领域驱动设计的一个核心的原则是使用一种基于模型的语言。因为模型是软件满足领域的共同点,它很适合作为这种通用语言的构造基础。使用模型作为语言的核心骨架,要求团队在进行所有的交流是都使用一致的语言,在代码中也是这样。在共享知识和推敲模型时,团队会使用演讲、文字和图形。这儿需要确保团队使用的语言在所有的交流形式中看上去都是一致的,这种语言被称为“通用语言(Ubiquitous Language)”。通用语言应该在建模过程中广泛尝试以推动软件专家和领域专家之间的沟通,从而发现要在模型中使用的主要的领域概念。 将领域模型转换为代码实现的最佳实践 拥有一个看上去正确的模型不代表模型能被直接转换成代码,也或者它的实现可能会违背某些我们所不建议的软件设计原则。我们该如何实现从模型到代码的转换,并让代码具有可扩展性、可维护性,高性能等指标呢?另外,如实反映领域的模型可能会导致对象持久化的一系列问题,或者导致不可接受的性能问题。那么我们应该怎么做呢? 我们应该紧密关联领域建模和设计,紧密将领域模型和软件编码实现捆绑在一起,模型在构建时就考虑到软件和设计。开发人员会被加入到建模的过程中来。主要的想法是选择一个能够恰当在软件中表现的模型,这样设计过程会很顺畅并且基于模型。代码和其下的模型紧密关联会让代码更有意义并与模型更相关。有了开发人员的参与就会有反馈。它能保证模型被实现成软件。如果其中某处有错误,会在早期就被标识出来,问题也会容易修正。写代码的人会很好地了解模型,会感觉自己有责任保持它的完整性。他们会意识到对代码的一个变更其实就隐含着对模型的变更,另外,如果哪里的代码不能表现原始模型的话,他们会重构代码。如果分析人员从实现过程中分离出去,他会不再关心开发过程中引入的局限性。最终结果是模型不再实用。任何技术人员想对模型做出贡献必须花费一些时间来接触代码,无论他在项目中担负的是什么主要角色。任何一个负责修改代码的人都必须学会用代码表现模型。每位开发人员都必须参与到一定级别的领域讨论中并和领域专家联络。 领域建模时思考问题的角度 “用户需求”不能等同于“用户”,捕捉“用户心中的模型”也不能等同于“以用户为核心设计领域模型”。 《老子》书中有个观点:有之以为利,无之以为用。在这里,有之利,即建立领域模型;无之用,即包容用户需求。举些例子,一个杯子要装满一杯水,我们在制作杯子时,制作的是空杯子,即要把水倒出来,之后才能装下水;再比如,一座房子要住人,我们在建造房子时,建造的房子是空的,唯有空的才能容纳人的居住。因此,建立领域模型时也要将用户置于模型之外,这样才能包容用户的需求。 所以,我的理解是: 我们设计领域模型时不能以用户为中心作为出发点去思考问题,不能老是想着用户会对系统做什么;而应该从一个客观的角度,根据用户需求挖掘出领域内的相关事物,思考这些事物的本质关联及其变化规律作为出发点去思考问题。 领域模型是排除了人之外的客观世界模型,但是领域模型包含人所扮演的参与者角色,但是一般情况下不要让参与者角色在领域模型中占据主要位置,如果以人所扮演的参与者角色在领域模型中占据主要位置,那么各个系统的领域模型将变得没有差别,因为软件系统就是一个人机交互的系统,都是以人为主的活动记录或跟踪;比如:论坛中如果以人为主导,那么领域模型就是:人发帖,人回帖,人结贴,等等;DDD的例子中,如果是以人为中心的话,就变成了:托运人托运货物,收货人收货物,付款人付款,等等;因此,当我们谈及领域模型时,已经默认把人的因素排除开了,因为领域只有对人来说才有意义,人是在领域范围之外的,如果人也划入领域,领域模型将很难保持客观性。领域模型是与谁用和怎样用是无关的客观模型。归纳起来说就是,领域建模是建立虚拟模型让我们现实的人使用,而不是建立虚拟空间,去模仿现实。 以Eric Evans(DDD之父)在他的书中的一个货物运输系统为例子简单说明一下。在经过一些用户需求讨论之后,在用户需求相对明朗之后,Eric这样描述领域模型: 一个Cargo(货物)涉及多个Customer(客户,如托运人、收货人、付款人),每个Customer承担不同的角色; Cargo的运送目标已指定,即Cargo有一个运送目标; 由一系列满足Specification(规格)的Carrier Movement(运输动作)来完成运输目标; 从上面的描述我们可以看出,他完全没有从用户的角度去描述领域模型,而是以领域内的相关事物为出发点,考虑这些事物的本质关联及其变化规律的。上述这段描述完全以货物为中心,把客户看成是货物在某个场景中可能会涉及到的关联角色,如货物会涉及到托运人、收货人、付款人;货物有一个确定的目标,货物会经过一系列列的运输动作到达目的地;其实,我觉得以用户为中心来思考领域模型的思维只是停留在需求的表面,而没有挖掘出真正的需求的本质;我们在做领域建模时需要努力挖掘用户需求的本质,这样才能真正实现用户需求; 关于用户、参与者这两个概念的区分,可以看一下下面的例子: 试想两个人共同玩足球游戏,操作者(用户)是驱动者,它驱使足球比赛领域中,各个“人”(参与者)的活动。这里立下一个假设,假设操作者A操作某一队员a,而队员a拥有着某人B的信息,那么有以下说法,a是B的镜像,a是领域参与者,A是驱动者。 领域驱动设计的经典分层架构 用户界面/展现层 负责向用户展现信息以及解释用户命令。更细的方面来讲就是: 请求应用层以获取用户所需要展现的数据; 发送命令给应用层要求其执行某个用户命令; 应用层 很薄的一层,定义软件要完成的所有任务。对外为展现层提供各种应用功能(包括查询或命令),对内调用领域层(领域对象或领域服务)完成各种业务逻辑,应用层不包含业务逻辑。 领域层 负责表达业务概念,业务状态信息以及业务规则,领域模型处于这一层,是业务软件的核心。 基础设施层 本层为其他层提供通用的技术能力;提供了层间的通信;为领域层实现持久化机制;总之,基础设施层可以通过架构和框架来支持其他层的技术需求; 领域驱动设计过程中使用的模式 所有模式的总揽图 关联的设计 关联本身不是一个模式,但它在领域建模的过程中非常重要,所以需要在探讨各种模式之前,先讨论一下对象之间的关联该如何设计。我觉得对象的关联的设计可以遵循如下的一些原则: 关联尽量少,对象之间的复杂的关联容易形成对象的关系网,这样对于我们理解和维护单个对象很不利,同时也很难划分对象与对象之间的边界;另外,同时减少关联有助于简化对象之间的遍历; 对多的关联也许在业务上是很自然的,通常我们会用一个集合来表示1对多的关系。但我们往往也需要考虑到性能问题,尤其是当集合内元素非常多的时候,此时往往需要通过单独查询来获取关联的集合信息; 关联尽量保持单向的关联; 在建立关联时,我们需要深入去挖掘是否存在关联的限制条件,如果存在,那么最好把这个限制条件加到这个关联上;往往这样的限制条件能将关联化繁为简,即可以将多对多简化为1对多,或将1对多简化为1对1; 实体(Entity) 实体就是领域中需要唯一标识的领域概念。因为我们有时需要区分是哪个实体。有两个实体,如果唯一标识不一样,那么即便实体的其他所有属性都一样,我们也认为他们两个不同的实体;因为实体有生命周期,实体从被创建后可能会被持久化到数据库,然后某个时候又会被取出来。所以,如果我们不为实体定义一种可以唯一区分的标识,那我们就无法区分到底是这个实体还是哪个实体。另外,不应该给实体定义太多的属性或行为,而应该寻找关联,发现其他一些实体或值对象,将属性或行为转移到其他关联的实体或值对象上。比如Customer实体,他有一些地址信息,由于地址信息是一个完整的有业务含义的概念,所以,我们可以定义一个Address对象,然后把Customer的地址相关的信息转移到Address对象上。如果没有Address对象,而把这些地址信息直接放在Customer对象上,并且如果对于一些其他的类似Address的信息也都直接放在Customer上,会导致Customer对象很混乱,结构不清晰,最终导致它难以维护和理解; 值对象(Value Object) 在领域中,并不是没一个事物都必须有一个唯一标识,也就是说我们不关心对象是哪个,而只关心对象是什么。就以上面的地址对象Address为例,如果有两个Customer的地址信息是一样的,我们就会认为这两个Customer的地址是同一个。也就是说只要地址信息一样,我们就认为是同一个地址。用程序的方式来表达就是,如果两个对象的所有的属性的值都相同我们会认为它们是同一个对象的话,那么我们就可以把这种对象设计为值对象。因此,值对象没有唯一标识,这是它和实体的最大不同。另外值对象在判断是否是同一个对象时是通过它们的所有属性是否相同,如果相同则认为是同一个值对象;而我们在区分是否是同一个实体时,只看实体的唯一标识是否相同,而不管实体的属性是否相同;值对象另外一个明显的特征是不可变,即所有属性都是只读的。因为属性是只读的,所以可以被安全的共享;当共享值对象时,一般有复制和共享两种做法,具体采用哪种做法还要根据实际情况而定;另外,我们应该给值对象设计的尽量简单,不要让它引用很多其他的对象,因为他只是一个值,就像int a = 3;那么”3”就是一个我们传统意义上所说的值,而值对象其实也可以和这里的”3”一样理解,也是一个值,只不过是用对象来表示。所以,当我们在C#语言中比较两个值对象是否相等时,会重写GetHashCode和Equals这两个方法,目的就是为了比较对象的值;值对象虽然是只读的,但是可以被整个替换掉。就像你把a的值修改为”4”(a = 4;)一样,直接把”3”这个值替换为”4”了。值对象也是一样,当你要修改Customer的Address对象引用时,不是通过Customer.Address.Street这样的方式来实现,因为值对象是只读的,它是一个完整的不可分割的整体。我们可以这样做:Customer.Address = new Address(…); 领域服务(Domain Service) 领域中的一些概念不太适合建模为对象,即归类到实体对象或值对象,因为它们本质上就是一些操作,一些动作,而不是事物。这些操作或动作往往会涉及到多个领域对象,并且需要协调这些领域对象共同完成这个操作或动作。如果强行将这些操作职责分配给任何一个对象,则被分配的对象就是承担一些不该承担的职责,从而会导致对象的职责不明确很混乱。但是基于类的面向对象语言规定任何属性或行为都必须放在对象里面。所以我们需要寻找一种新的模式来表示这种跨多个对象的操作,DDD认为服务是一个很自然的范式用来对应这种跨多个对象的操作,所以就有了领域服务这个模式。和领域对象不同,领域服务是以动词开头来命名的,比如资金转帐服务可以命名为MoneyTransferService。当然,你也可以把服务理解为一个对象,但这和一般意义上的对象有些区别。因为一般的领域对象都是有状态和行为的,而领域服务没有状态只有行为。需要强调的是领域服务是无状态的,它存在的意义就是协调领域对象共完成某个操作,所有的状态还是都保存在相应的领域对象中。我觉得模型(实体)与服务(场景)是对领域的一种划分,模型关注领域的个体行为,场景关注领域的群体行为,模型关注领域的静态结构,场景关注领域的动态功能。这也符合了现实中出现的各种现象,有动有静,有独立有协作。 领域服务还有一个很重要的功能就是可以避免领域逻辑泄露到应用层。因为如果没有领域服务,那么应用层会直接调用领域对象完成本该是属于领域服务该做的操作,这样一来,领域层可能会把一部分领域知识泄露到应用层。因为应用层需要了解每个领域对象的业务功能,具有哪些信息,以及它可能会与哪些其他领域对象交互,怎么交互等一系列领域知识。因此,引入领域服务可以有效的防治领域层的逻辑泄露到应用层。对于应用层来说,从可理解的角度来讲,通过调用领域服务提供的简单易懂但意义明确的接口肯定也要比直接操纵领域对象容易的多。这里似乎也看到了领域服务具有Façade的功能,呵呵。 说到领域服务,还需要提一下软件中一般有三种服务:应用层服务、领域服务、基础服务。 应用层服务 获取输入(如一个XML请求); 发送消息给领域层服务,要求其实现转帐的业务逻辑; 领域层服务处理成功,则调用基础层服务发送Email通知; 领域层服务 获取源帐号和目标帐号,分别通知源帐号和目标帐号进行扣除金额和增加金额的操作; 提供返回结果给应用层; 基础层服务 按照应用层的请求,发送Email通知; 所以,从上面的例子中可以清晰的看出,每种服务的职责; 聚合及聚合根(Aggregate,Aggregate Root) 聚合,它通过定义对象之间清晰的所属关系和边界来实现领域模型的内聚,并避免了错综复杂的难以维护的对象关系网的形成。聚合定义了一组具有内聚关系的相关对象的集合,我们把聚合看作是一个修改数据的单元。 聚合有以下一些特点: 每个聚合有一个根和一个边界,边界定义了一个聚合内部有哪些实体或值对象,根是聚合内的某个实体; 聚合内部的对象之间可以相互引用,但是聚合外部如果要访问聚合内部的对象时,必须通过聚合根开始导航,绝对不能绕过聚合根直接访问聚合内的对象,也就是说聚合根是外部可以保持 对它的引用的唯一元素; 聚合内除根以外的其他实体的唯一标识都是本地标识,也就是只要在聚合内部保持唯一即可,因为它们总是从属于这个聚合的; 聚合根负责与外部其他对象打交道并维护自己内部的业务规则; 基于聚合的以上概念,我们可以推论出从数据库查询时的单元也是以聚合为一个单元,也就是说我们不能直接查询聚合内部的某个非根的对象; 聚合内部的对象可以保持对其他聚合根的引用; 删除一个聚合根时必须同时删除该聚合内的所有相关对象,因为他们都同属于一个聚合,是一个完整的概念; 关于如何识别聚合以及聚合根的问题: 我觉得我们可以先从业务的角度深入思考,然后慢慢分析出有哪些对象是: 有独立存在的意义,即它是不依赖于其他对象的存在它才有意义的; 可以被独立访问的,还是必须通过某个其他对象导航得到的; 如何识别聚合? 我觉得这个需要从业务的角度深入分析哪些对象它们的关系是内聚的,即我们会把他们看成是一个整体来考虑的;然后这些对象我们就可以把它们放在一个聚合内。所谓关系是内聚的,是指这些对象之间必须保持一个固定规则,固定规则是指在数据变化时必须保持不变的一致性规则。当我们在修改一个聚合时,我们必须在事务级别确保整个聚合内的所有对象满足这个固定规则。作为一条建议,聚合尽量不要太大,否则即便能够做到在事务级别保持聚合的业务规则完整性,也可能会带来一定的性能问题。有分析报告显示,通常在大部分领域模型中,有70%的聚合通常只有一个实体,即聚合根,该实体内部没有包含其他实体,只包含一些值对象;另外30%的聚合中,基本上也只包含两到三个实体。这意味着大部分的聚合都只是一个实体,该实体同时也是聚合根。 如何识别聚合根? 如果一个聚合只有一个实体,那么这个实体就是聚合根;如果有多个实体,那么我们可以思考聚合内哪个对象有独立存在的意义并且可以和外部直接进行交互。 工厂(Factory) DDD中的工厂也是一种体现封装思想的模式。DDD中引入工厂模式的原因是:有时创建一个领域对象是一件比较复杂的事情,不仅仅是简单的new操作。正如对象封装了内部实现一样(我们无需知道对象的内部实现就可以使用对象的行为),工厂则是用来封装创建一个复杂对象尤其是聚合时所需的知识,工厂的作用是将创建对象的细节隐藏起来。客户传递给工厂一些简单的参数,然后工厂可以在内部创建出一个复杂的领域对象然后返回给客户。领域模型中其他元素都不适合做这个事情,所以需要引入这个新的模式,工厂。工厂在创建一个复杂的领域对象时,通常会知道该满足什么业务规则(它知道先怎样实例化一个对象,然后在对这个对象做哪些初始化操作,这些知识就是创建对象的细节),如果传递进来的参数符合创建对象的业务规则,则可以顺利创建相应的对象;但是如果由于参数无效等原因不能创建出期望的对象时,应该抛出一个异常,以确保不会创建出一个错误的对象。当然我们也并不总是需要通过工厂来创建对象,事实上大部分情况下领域对象的创建都不会太复杂,所以我们只需要简单的使用构造函数创建对象就可以了。隐藏创建对象的好处是显而易见的,这样可以不会让领域层的业务逻辑泄露到应用层,同时也减轻了应用层的负担,它只需要简单的调用领域工厂创建出期望的对象即可。 仓储(Repository) 仓储被设计出来的目的是基于这个原因:领域模型中的对象自从被创建出来后不会一直留在内存中活动的,当它不活动时会被持久化到数据库中,然后当需要的时候我们会重建该对象;重建对象就是根据数据库中已存储的对象的状态重新创建对象的过程;所以,可见重建对象是一个和数据库打交道的过程。从更广义的角度来理解,我们经常会像集合一样从某个类似集合的地方根据某个条件获取一个或一些对象,往集合中添加对象或移除对象。也就是说,我们需要提供一种机制,可以提供类似集合的接口来帮助我们管理对象。仓储就是基于这样的思想被设计出来的; 仓储里面存放的对象一定是聚合,原因是之前提到的领域模型中是以聚合的概念去划分边界的;聚合是我们更新对象的一个边界,事实上我们把整个聚合看成是一个整体概念,要么一起被取出来,要么一起被删除。我们永远不会单独对某个聚合内的子对象进行单独查询或做更新操作。因此,我们只对聚合设计仓储。 仓储还有一个重要的特征就是分为仓储定义部分和仓储实现部分,在领域模型中我们定义仓储的接口,而在基础设施层实现具体的仓储。这样做的原因是:由于仓储背后的实现都是在和数据库打交道,但是我们又不希望客户(如应用层)把重点放在如何从数据库获取数据的问题上,因为这样做会导致客户(应用层)代码很混乱,很可能会因此而忽略了领域模型的存在。所以我们需要提供一个简单明了的接口,供客户使用,确保客户能以最简单的方式获取领域对象,从而可以让它专心的不会被什么数据访问代码打扰的情况下协调领域对象完成业务逻辑。这种通过接口来隔离封装变化的做法其实很常见。由于客户面对的是抽象的接口并不是具体的实现,所以我们可以随时替换仓储的真实实现,这很有助于我们做单元测试。 尽管仓储可以像集合一样在内存中管理对象,但是仓储一般不负责事务处理。一般事务处理会交给一个叫“工作单元(Unit Of Work)”的东西。关于工作单元的详细信息我在下面的讨论中会讲到。 另外,仓储在设计查询接口时,可能还会用到规格模式(Specification Pattern),我见过的最厉害的规格模式应该就是LINQ以及DLINQ查询了。一般我们会根据项目中查询的灵活度要求来选择适合的仓储查询接口设计。通常情况下只需要定义简单明了的具有固定查询参数的查询接口就可以了。只有是在查询条件是动态指定的情况下才可能需要用到Specification等模式。 设计领域模型的一般步骤 根据需求建立一个初步的领域模型,识别出一些明显的领域概念以及它们的关联,关联可以暂时没有方向但需要有(1:1,1:N,M:N)这些关系;可以用文字精确的没有歧义的描述出每个领域概念的涵义以及包含的主要信息; 分析主要的软件应用程序功能,识别出主要的应用层的类;这样有助于及早发现哪些是应用层的职责,哪些是领域层的职责; 进一步分析领域模型,识别出哪些是实体,哪些是值对象,哪些是领域服务; 分析关联,通过对业务的更深入分析以及各种软件设计原则及性能方面的权衡,明确关联的方向或者去掉一些不需要的关联; 找出聚合边界及聚合根,这是一件很有难度的事情;因为你在分析的过程中往往会碰到很多模棱两可的难以清晰判断的选择问题,所以,需要我们平时一些分析经验的积累才能找出正确的聚合根; 为聚合根配备仓储,一般情况下是为一个聚合分配一个仓储,此时只要设计好仓储的接口即可; 走查场景,确定我们设计的领域模型能够有效地解决业务需求; 考虑如何创建领域实体或值对象,是通过工厂还是直接通过构造函数; 停下来重构模型。寻找模型中觉得有些疑问或者是蹩脚的地方,比如思考一些对象应该通过关联导航得到还是应该从仓储获取?聚合设计的是否正确?考虑模型的性能怎样,等等; 领域建模是一个不断重构,持续完善模型的过程,大家会在讨论中将变化的部分反映到模型中,从而是模型不断细化并朝正确的方向走。领域建模是领域专家、设计人员、开发人员之间沟通交流的过程,是大家工作和思考问题的基础。 在分层架构中其他层如何与领域层交互 从经典的领域驱动设计分层架构中可以看出,领域层的上层是应用层,下层是基础设施层。那么领域层是如何与其它层交互的呢? 对于会影响领域层中领域对象状态的应用层功能 一般应用层会先启动一个工作单元,然后: 对于修改领域对象的情况,通过仓储获取领域对象,调用领域对象的相关业务方法以完成业务逻辑处理; 对于新增领域对象的情况,通过构造函数或工厂创建出领域对象,如果需要还可以继续对该新创建的领域对象做一些操作,然后把该新创建的领域对象添加到仓储中; 对于删除领域对象的情况,可以先把领域对象从仓储中取出来,然后将其从仓储中删除,也可以直接传递一个要删除的领域对象的唯一标识给仓储通知其移除该唯一标识对应领域对象; 如果一个业务逻辑涉及到多个领域对象,则调用领域层中的相关领域服务完成操作; 注意,以上所说的所有领域对象都是只聚合根,另外在应用层需要获取仓储接口以及领域服务接口时,都可以通过IOC容器获取。最后通知工作单元提交事务从而将所有相关的领域对象的状态以事务的方式持久化到数据库; 关于Unit of Work(工作单元)的几种实现方法 基于快照的实现,即领域对象被取出来后,会先保存一个备份的对象,然后当在做持久化操作时,将最新的对象的状态和备份的对象的状态进行比较,如果不相同,则认为有做过修改,然后进行持久化;这种设计的好处是对象不用告诉工作单元自己的状态修改了,而缺点也是显而易见的,那就是性能可能会低,备份对象以及比较对象的状态是否有修改的过程在当对象本身很复杂的时候,往往是一个比较耗时的步骤,而且要真正实现对象的深拷贝以及判断属性是否修改还是比较困难的; 不基于快照,而是仓储的相关更新或新增或删除接口被调用时,仓储通知工作单元某个对象被新增了或更新了或删除了。这样工作单元在做数据持久化时也同样可以知道需要持久化哪些对象了;这种方法理论上不需要ORM框架的支持,对领域模型也没有任何倾入性,同时也很好的支持了工作单元的模式。对于不想用高级ORM框架的朋友来说,这种方法挺好; 不基于快照,也不用仓储告诉工作单元数据更改了。而是采用AOP的思想,采用透明代理的方式进行一个拦截。在NHibernate中,我们的属性通常要被声明为virtual的,一个原因就是NHibernate会生成一个透明代理,用于拦截对象的属性被修改时,自动通知工作单元对象的状态被更新了。这样工作单元也同样知道需要持久化哪些对象了。这种方法对领域模型的倾入性不大,并且能很好的支持工作单元模式,如果用NHibernate作为ORM,这种方法用的比较多; 一般是微软用的方法,那就是让领域对象实现.NET框架中的INotifiyPropertyChanged接口,然后在每个属性的set方法的最后一行调用OnPropertyChanged的方法从而显示地通知别人自己的状态修改了。这种方法相对来说对领域模型的倾入性最强。 对于不会影响领域层中领域对象状态的查询功能 可以直接通过仓储查询出所需要的数据。但一般领域层中的仓储提供的查询功能也许不能满足界面显示的需要,则可能需要多次调用不同的仓储才能获取所需要显示的数据;其实针对这种查询的情况,我在后面会讲到可以直接通过CQRS的架构来实现。即对于查询,我们可以在应用层不调用领域层的任何东西,而是直接通过某个其他的用另外的技术架构实现的查询引擎来完成查询,比如直接通过构造参数化SQL的方式从数据库一个表或多个表中查询出任何想要显示的数据。这样不仅性能高,也可以减轻领域层的负担。领域模型不太适合为应用层提供各种查询服务,因为往往界面上要显示的数据是很多对象的组合信息,是一种非对象概念的信息,就像报表; 为什么面向对象比面向过程更能适应业务变化 对象将需求用类一个个隔开,就像用储物箱把东西一个个封装起来一样,需求变了,分几种情况,最严重的是大变,那么每个储物箱都要打开改,这种方法就不见得有好处;但是这种情况发生概率比较小,大部分需求变化都是局限在一两个储物箱中,那么我们只要打开这两个储物箱修改就可以,不会影响其他储物柜了。 而面向过程是把所有东西都放在一个大储物箱中,修改某个部分以后,会引起其他部分不稳定,一个BUG修复,引发新的无数BUG,最后程序员陷入焦头烂额,如日本东京电力公司员工处理核危机一样,心力交瘁啊。 所以,我们不能粗粒度看需求变,认为需求变了,就是大范围变,万事万物都有边界,老子说,无欲观其缴,什么事物都要观察其边界,虽然需求可以用“需求”这个名词表达,谈到需求变了,不都意味着最大边界范围的变化,这样看问题容易走极端。 其实就是就地画圈圈——边界。我们小时候写作文分老三段也是同样道理,各自职责明确,划分边界明确,通过过渡句实现承上启下——接口。为什么组织需要分不同部门,同样是边界思维。画圈圈容易,但如何画才难,所以OO中思维非常重要。 需求变化所引起的变化是有边界,若果变化的边界等于整个领域,那么已经是完全不同的项目了。要掌握边界,是需要大量的领域知识的。否则,走进银行连业务职责都分不清的,如何画圈圈呢? 面向过程是无边界一词的(就算有也只是最大的边界),它没有要求各自独立,它可以横跨边界进行调用,这就是容易引起BUG的原因,引起BUG不一定是技术错误,更多的是逻辑错误。分别封装就是画圈圈了,所有边界都以接口实现。不用改或者小改接口,都不会牵一发动全身。若果面向过程中考虑边界,那么也就已经上升到OO思维,即使用的不是对象语言,但对象已经隐含其中。说白了,面向对象与面向过程最大区别就是:分解。边界的分解。从需求到最后实现都贯穿。 面向对象的实质就是边界划分,封装,不但对需求变化能够量化,缩小影响面;因为边界划分也会限制出错的影响范围,所以OO对软件后期BUG等出错也有好处。 软件世界永远都有BUG,BUG是清除不干净的,就像人类世界永远都存在不完美和阴暗面,问题关键是:上帝用空间和时间的边界把人类世界痛苦灾难等不完美局限在一个范围内;而软件世界如果你不采取OO等方法进行边界划分的话,一旦出错,追查起来情况会有多糟呢? 软件世界其实类似人类现实世界,有时出问题了,探究原因一看,原来是两个看上去毫无联系的因素导致的,古人只好经常求神拜佛,我们程序员在自己的软件上线运行时,大概心里也在求神拜佛别出大纰漏,如果我们的软件采取OO封装,我们就会坦然些,肯定会出错,但是我们已经预先划定好边界,所以,不会产生严重后果,甚至也不会出现难以追查的魔鬼BUG。 领域驱动设计的其他一些主题 上面只是涉及到DDD中最基本的内容,DDD中还有很多其他重要的内容在上面没有提到,如: 模型上下文、上下文映射、上下文共享; 如何将分析模式和设计模式运用到DDD中; 一些关于柔性设计的技巧; 如果保持模型完整性,以及持续集成方面的知识; 如何精炼模型,识别核心模型以及通用子领域; 这些主题都很重要,因为篇幅有限以及我目前掌握的知识也有限,并且为了突出这篇文章的重点,所以不对他们做详细介绍了,大家有兴趣的可以自己阅读一下。 一些相关的扩展阅读 CQRS架构 核心思想是将应用程序的查询部分和命令部分完全分离,这两部分可以用完全不同的模型和技术去实现。比如命令部分可以通过领域驱动设计来实现;查询部分可以直接用最快的非面向对象的方式去实现,比如用SQL。这样的思想有很多好处: 实现命令部分的领域模型不用经常为了领域对象可能会被如何查询而做一些折中处理; 由于命令和查询是完全分离的,所以这两部分可以用不同的技术架构实现,包括数据库设计都可以分开设计,每一部分可以充分发挥其长处; 高性能,命令端因为没有返回值,可以像消息队列一样接受命令,放在队列中,慢慢处理;处理完后,可以通过异步的方式通知查询端,这样查询端可以做数据同步的处理; Event Sourcing(事件溯源) 对于DDD中的聚合,不保存聚合的当前状态,而是保存对象上所发生的每个事件。当要重建一个聚合对象时,可以通过回溯这些事件(即让这些事件重新发生)来让对象恢复到某个特定的状态;因为有时一个聚合可能会发生很多事件,所以如果每次要在重建对象时都从头回溯事件,会导致性能低下,所以我们会在一定时候为聚合创建一个快照。这样,我们就可以基于某个快照开始创建聚合对象了。 DCI架构 DCI架构强调,软件应该真实的模拟现实生活中对象的交互方式,代码应该准确朴实的反映用户的心智模型。在DCI中有:数据模型、角色模型、以及上下文这三个概念。数据模型表示程序的结构,目前我们所理解的DDD中的领域模型可以很好的表示数据模型;角色模型表示数据如何交互,一个角色定义了某个“身份”所具有的交互行为;上下文对应业务场景,用于实现业务用例,注意是业务用例而不是系统用例,业务用例只与业务相关;软件运行时,根据用户的操作,系统创建相应的场景,并把相关的数据对象作为场景参与者传递给场景,然后场景知道该为每个对象赋予什么角色,当对象被赋予某个角色后就真正成为有交互能力的对象,然后与其他对象进行交互;这个过程与现实生活中我们所理解的对象是一致的; DCI的这种思想与DDD中的领域服务所做的事情是一样的,但实现的角度有些不同。DDD中的领域服务被创建的出发点是当一些职责不太适合放在任何一个领域对象上时,这个职责往往对应领域中的某个活动或转换过程,此时我们应该考虑将其放在一个服务中。比如资金转帐的例子,我们应该提供一个资金转帐的服务,用来对应领域中的资金转帐这个领域概念。但是领域服务内部做的事情是协调多个领域对象完成一件事情。因此,在DDD中的领域服务在协调领域对象做事情时,领域对象往往是处于一个被动的地位,领域服务通知每个对象要求其做自己能做的事情,这样就行了。这个过程中我们似乎看不到对象之间交互的意思,因为整个过程都是由领域服务以面向过程的思维去实现了。而DCI则通用引入角色,赋予角色以交互能力,然后让角色之间进行交互,从而可以让我们看到对象与对象之间交互的过程。但前提是,对象之间确实是在交互。因为现实生活中并不是所有的对象在做交互,比如有A、B、C三个对象,A通知B做事情,A通知C做事情,此时可以认为A和B,A和C之间是在交互,但是B和C之间没有交互。所以我们需要分清这种情况。资金转帐的例子,A相当于转帐服务,B相当于帐号1,C相当于帐号2。因此,资金转帐这个业务场景,用领域服务比较自然。有人认为DCI可以替换DDD中的领域服务,我持怀疑态度。 四色原型分析模式 时刻-时间段原型(Moment-Interval Archetype) 表示在某个时刻或某一段时间内发生的某个活动。使用粉红色表示,简写为MI。 参与方-地点-物品原型(Part-Place-Thing Archetype) 表示参与某个活动的人或物,地点则是活动的发生地。使用绿色表示。简写为PPT。 描述原型(Description Archetype) 表示对PPT的本质描述。它不是PPT的分类!Description是从PPT抽象出来的不变的共性的属性的集合。使用蓝色表示,简写为DESC。 举个例子,有一个人叫张三,如果某个外星人问你张三是什么?你会怎么说?可能会说,张三是个人,但是外星人不知道“人”是什么。然后你会怎么办?你就会说:张三是个由一个头、两只手、两只脚,以及一个身体组成的客观存在。虽然这时外星人仍然不知道人是什么,但我已经可以借用这个例子向大家说明什么是“Description”了。在这个例子中,张三就是一个PPT,而“由一个头、两只手、两只脚,以及一个身体组成的客观存在”就是对张三的Description,头、手、脚、身体则是人的本质的不变的共性的属性的集合。但我们人类比较聪明,很会抽象总结和命名,已经把这个Description用一个字来代替了,那就是“人”。所以就有所谓的张三是人的说法。 角色原型(Role Archetype) 角色就是我们平时所理解的“身份”。使用黄色表示,简写为Role。为什么会有角色这个概念?因为有些活动,只允许具有特定角色(身份)的PPT(参与者)才能参与该活动。比如一个人只有具有教师的角色才能上课(一种活动);一个人只有是一个合法公民才能参与选举和被选举;但是有些活动也是不需要角色的,比如一个人不需要具备任何角色就可以睡觉(一种活动)。当然,其实说人不需要角色就能睡觉也是错误的,错在哪里?因为我们可以这样理解:一个客观存在只要具有“人”的角色就能睡觉,其实这时候,我们已经把DESC当作角色来看待了。所以,其实角色这个概念是非常广的,不能用我们平时所理解的狭义的“身份”来理解,因为“教师”、“合法公民”、“人”都可以被作为角色来看待。因此,应该这样说:任何一个活动,都需要具有一定角色的参与者才能参与。 用一句话来概括四色原型就是:一个什么什么样的人或组织或物品以某种角色在某个时刻或某段时间内参与某个活动。 其中“什么什么样的”就是DESC,“人或组织或物品”就是PPT,“角色”就是Role,而”某个时刻或某段时间内的某个活动"就是MI。 以上这些东西如果在学习了DDD之后再去学习会对DDD有更深入的了解,但我觉得DDD相对比较基础,如果我们在已经了解了DDD的基础之上再去学习这些东西会更加有效和容易掌握。 希望本文对大家有所帮助。
原文地址:http://blog.csdn.net/rdhj5566/article/details/50646599 一、背景 我们实际系统中有很多操作,是不管做多少次,都应该产生一样的效果或返回一样的结果。 例如: 1. 前端重复提交选中的数据,应该后台只产生对应这个数据的一个反应结果。 2. 我们发起一笔付款请求,应该只扣用户账户一次钱,当遇到网络重发或系统bug重发,也应该只扣一次钱; 3. 发送消息,也应该只发一次,同样的短信发给用户,用户会哭的; 4. 创建业务订单,一次业务请求只能创建一个,创建多个就会出大问题。 等等很多重要的情况,这些逻辑都需要幂等的特性来支持。 二、幂等性概念 幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。 在编程中.一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“getUsername()和setTrue()”函数就是一个幂等函数. 更复杂的操作幂等保证是利用唯一交易号(流水号)实现. 我的理解:幂等就是一个操作,不论执行多少次,产生的效果和返回的结果都是一样的 三、技术方案 1. 查询操作 查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作 2. 删除操作 删除操作也是幂等的,删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个) 3.唯一索引,防止新增脏数据 比如:支付宝的资金账户,支付宝也有用户账户,每个用户只能有一个资金账户,怎么防止给用户创建资金账户多个,那么给资金账户表中的用户ID加唯一索引,所以一个用户新增成功一个资金账户记录 要点: 唯一索引或唯一组合索引来防止新增数据存在脏数据 (当表存在唯一索引,并发时新增报错时,再查询一次就可以了,数据应该已经存在了,返回结果即可) 4. token机制,防止页面重复提交 业务要求: 页面的数据只能被点击提交一次 发生原因: 由于重复点击或者网络重发,或者nginx重发等情况会导致数据被重复提交 解决办法: 集群环境:采用token加redis(redis单线程的,处理需要排队) 单JVM环境:采用token加redis或token加jvm内存 处理流程: 1. 数据提交前要向服务的申请token,token放到redis或jvm内存,token有效时间 2. 提交后后台校验token,同时删除token,生成新的token返回 token特点: 要申请,一次有效性,可以限流 注意:redis要用删除操作来判断token,删除成功代表token校验通过,如果用select+delete来校验token,存在并发问题,不建议使用 5. 悲观锁 获取数据的时候加锁获取 select * from table_xxx where id='xxx' for update; 注意:id字段一定是主键或者唯一索引,不然是锁表,会死人的 悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,根据实际情况选用 6. 乐观锁 乐观锁只是在更新数据那一刻锁表,其他时间不锁表,所以相对于悲观锁,效率更高。 乐观锁的实现方式多种多样可以通过version或者其他状态条件: 1). 通过版本号实现 update table_xxx set name=#name#,version=version+1 where version=#version# 如下图(来自网上): 2). 通过条件限制 update table_xxx set avai_amount=avai_amount-#subAmount# where avai_amount-#subAmount# >= 0 要求:quality-#subQuality# >= ,这个情景适合不用版本号,只更新是做数据安全校验,适合库存模型,扣份额和回滚份额,性能更高 注意:乐观锁的更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会锁表,上面两个sql改成下面的两个更好 update table_xxx set name=#name#,version=version+1 where id=#id# and version=#version# update table_xxx set avai_amount=avai_amount-#subAmount# where id=#id# and avai_amount-#subAmount# >= 0 7. 分布式锁 还是拿插入数据的例子,如果是分布是系统,构建全局唯一索引比较困难,例如唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统(redis或zookeeper),在业务系统插入数据或者更新数据,获取分布式锁,然后做操作,之后释放锁,这样其实是把多线程并发的锁的思路,引入多多个系统,也就是分布式系统中得解决思路。 要点:某个长流程处理过程要求不能并发执行,可以在流程执行之前根据某个标志(用户ID+后缀等)获取分布式锁,其他流程执行时获取锁就会失败,也就是同一时间该流程只能有一个能执行成功,执行完成后,释放分布式锁(分布式锁要第三方系统提供) 8. select + insert 并发不高的后台系统,或者一些任务JOB,为了支持幂等,支持重复执行,简单的处理方法是,先查询下一些关键数据,判断是否已经执行过,在进行业务处理,就可以了 注意:核心高并发流程不要用这种方法 9. 状态机幂等 在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机(状态变更图),就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。 注意:订单等单据类业务,存在很长的状态流转,一定要深刻理解状态机,对业务系统设计能力提高有很大帮助 10. 对外提供接口的api如何保证幂等 如银联提供的付款接口:需要接入商户提交付款请求时附带:source来源,seq序列号 source+seq在数据库里面做唯一索引,防止多次付款,(并发时,只能处理一个请求) 重点: 对外提供接口为了支持幂等调用,接口有两个字段必须传,一个是来源source,一个是来源方序列号seq,这个两个字段在提供方系统里面做联合唯一索引,这样当第三方调用时,先在本方系统里面查询一下,是否已经处理过,返回相应处理结果;没有处理过,进行相应处理,返回结果。注意,为了幂等友好,一定要先查询一下,是否处理过该笔业务,不查询直接插入业务系统,会报错,但实际已经处理了。 总结: 幂等性应该是合格程序员的一个基因,在设计系统时,是首要考虑的问题,尤其是在像支付宝,银行,互联网金融公司等涉及的都是钱的系统,既要高效,数据也要准确,所以不能出现多扣款,多打款等问题,这样会很难处理,用户体验也不好
ENode架构图 什么是ENode ENode是一个.NET平台下,纯C#开发的,基于DDD,CQRS,ES,EDA,In-Memory架构风格的,可以帮助开发者开发高并发、高吞吐、可伸缩、可扩展的应用程序的一个应用开发框架。 开源项目地址:https://github.com/tangxuehua/enode 作者博客地址:http://www.cnblogs.com/netfocus/category/496012.html QQ交流群号:185916873 微信公众号:ENode ENode框架特色 一个DDD开发框架,完美支持基于六边形架构思想的开发 实现CQRS架构思想,并且框架提供C端命令的处理结果的返回,支持同步返回和异步返回 内置Event Sourcing(ES)架构模式,让C端的数据持久化变得通用化 聚合根常驻内存,in-memory domain model 聚合根的处理基于Command Mailbox, Event Mailbox的思想,类似Actor Model, Actor Mailbox 严格遵守聚合内强一致性、聚合之间最终一致性的原则 Group Commit Domain event 基于聚合根ID+事件版本号的唯一索引,实现聚合根的乐观并发控制 框架保证Command的幂等处理 通过聚合根ID对命令或事件进行路由,做到最小的并发冲突、最大的并行处理 消息发送和接收基于分布式消息队列EQueue,支持分布式部署 基于事件驱动架构范式(EDA,Event-Driven Architecture) 基于队列的动态扩容/缩容 EventDB中因为存放的都是不可变的事件,所以水平扩展非常容易,框架可内置支持 支持Process Manager(Saga),以支持一个用户操作跨多个聚合根的业务场景,如订单处理,从而避免分布式事务的使用 ENode实现了CQRS架构面临的大部分技术问题,让开发者可以专注于业务逻辑和业务流程的开发,而无需关心纯技术问题
明天就是大年三十了,今天在家有空,想集中整理一下CQRS架构的特点以及相比传统架构的优缺点分析。先提前祝大家猴年新春快乐、万事如意、身体健康! 最近几年,在DDD的领域,我们经常会看到CQRS架构的概念。我个人也写了一个ENode框架,专门用来实现这个架构。CQRS架构本身的思想其实非常简单,就是读写分离。是一个很好理解的思想。就像我们用MySQL数据库的主备,数据写到主,然后查询从备来查,主备数据的同步由MySQL数据库自己负责,这是一种数据库层面的读写分离。关于CQRS架构的介绍其实已经非常多了,大家可以自行百度或google。我今天主要想总结一下这个架构相对于传统架构(三层架构、DDD经典四层架构)在数据一致性、扩展性、可用性、伸缩性、性能这几个方面的异同,希望可以总结出一些优点和缺点,为大家在做架构选型时提供参考。 前言 CQRS架构由于本身只是一个读写分离的思想,实现方式多种多样。比如数据存储不分离,仅仅只是代码层面读写分离,也是CQRS的体现;然后数据存储的读写分离,C端负责数据存储,Q端负责数据查询,Q端的数据通过C端产生的Event来同步,这种也是CQRS架构的一种实现。今天我讨论的CQRS架构就是指这种实现。另外很重要的一点,C端我们还会引入Event Sourcing+In Memory这两种架构思想,我认为这两种思想和CQRS架构可以完美的结合,发挥CQRS这个架构的最大价值。 数据一致性 传统架构,数据一般是强一致性的,我们通常会使用数据库事务保证一次操作的所有数据修改都在一个数据库事务里,从而保证了数据的强一致性。在分布式的场景,我们也同样希望数据的强一致性,就是使用分布式事务。但是众所周知,分布式事务的难度、成本是非常高的,而且采用分布式事务的系统的吞吐量都会比较低,系统的可用性也会比较低。所以,很多时候,我们也会放弃数据的强一致性,而采用最终一致性;从CAP定理的角度来说,就是放弃一致性,选择可用性。 CQRS架构,则完全秉持最终一致性的理念。这种架构基于一个很重要的假设,就是用户看到的数据总是旧的。对于一个多用户操作的系统,这种现象很普遍。比如秒杀的场景,当你下单前,也许界面上你看到的商品数量是有的,但是当你下单的时候,系统提示商品卖完了。其实我们只要仔细想想,也确实如此。因为我们在界面上看到的数据是从数据库取出来的,一旦显示到界面上,就不会变了。但是很可能其他人已经修改了数据库中的数据。这种现象在大部分系统中,尤其是高并发的WEB系统,尤其常见。 所以,基于这样的假设,我们知道,即便我们的系统做到了数据的强一致性,用户还是很可能会看到旧的数据。所以,这就给我们设计架构提供了一个新的思路。我们能否这样做:我们只需要确保系统的一切添加、删除、修改操作所基于的数据是最新的,而查询的数据不必是最新的。这样就很自然的引出了CQRS架构了。C端数据保持最新、做到数据强一致;Q端数据不必最新,通过C端的事件异步更新即可。所以,基于这个思路,我们开始思考,如何具体的去实现CQ两端。看到这里,也许你还有一个疑问,就是为何C端的数据是必须要最新的?这个其实很容易理解,因为你要修改数据,那你可能会有一些修改的业务规则判断,如果你基于的数据不是最新的,那意味着判断就失去意义或者说不准确,所以基于老的数据所做的修改是没有意义的。 扩展性 传统架构,各个组件之间是强依赖,都是对象之间直接方法调用;而CQRS架构,则是事件驱动的思想;从微观的聚合根层面,传统架构是应用层通过过程式的代码协调多个聚合根一次性以事务的方式完成整个业务操作。而CQRS架构,则是以Saga的思想,通过事件驱动的方式,最终实现多个聚合根的交互。另外,CQRS架构的CQ两端也是通过事件的方式异步进行数据同步,也是事件驱动的一种体现。上升到架构层面,那前者就是SOA的思想,后者是EDA的思想。SOA是一个服务调用另一个服务完成服务之间的交互,服务之间紧耦合;EDA是一个组件订阅另一个组件的事件消息,根据事件信息更新组件自己的状态,所以EDA架构,每个组件都不会依赖其他的组件;组件之间仅仅通过topic产生关联,耦合性非常低。 上面说了两种架构的耦合性,显而易见,耦合性低的架构,扩展性必然好。因为SOA的思路,当我要加一个新功能时,需要修改原来的代码;比如原来A服务调用了B,C两个服务,后来我们想多调用一个服务D,则需要改A服务的逻辑;而EDA架构,我们不需要动现有的代码,原来有B,C两订阅者订阅A产生的消息,现在只需要增加一个新的消息订阅者D即可。 从CQRS的角度来说,也有一个非常明显的例子,就是Q端的扩展性。假设我们原来Q端只是使用数据库实现的,但是后来系统的访问量增大,数据库的更新太慢或者满足不了高并发的查询了,所以我们希望增加缓存来应对高并发的查询。那对CQRS架构来说很容易,我们只需要增加一个新的事件订阅者,用来更新缓存即可。应该说,我们可以随时方便的增加Q端的数据存储类型。数据库、缓存、搜索引擎、NoSQL、日志,等等。我们可以根据自己的业务场景,选择合适的Q端数据存储,实现快速查询的目的。这一切都归功于我们C端记录了所有模型变化的事件,当我们要增加一种新的View存储时,可以根据这些事件得到View存储的最新状态。这种扩展性在传统架构下是很难做到的。 可用性 可用性,无论是传统架构还是CQRS架构,都可以做到高可用,只要我们做到让我们的系统中每个节点都无单点即可。但是,相比之下,我觉得CQRS架构在可用性方面,我们可以有更多的回避余地和选择空间。 传统架构,因为读写没有分离,所以可用性要把读写合在一起综合考虑,难度会比较更大。因为传统架构,如果一个系统的高峰期的并发写入很大,比如为2W,并发读取也很大,比如为10W。那该系统必须优化到能同时支持这种高并发的写入和查询,否则系统就会在高峰时挂掉。这个就是基于同步调用思路的系统的缺点,没有一个东西去削峰填谷,保存瞬间多出来的请求,而必须让系统不管遇到多少请求,都必须能及时处理完,否则就会造成雪崩效应,造成系统瘫痪。但是一个系统,不会一直处在高峰,高峰可能只有半小时或1小时;但为了确保高峰时系统不挂掉,我们必须使用足够的硬件去支撑这个高峰。而大部分时候,都不需要这么高的硬件资源,所以会造成资源的浪费。所以,我们说基于同步调用、SOA思想的系统的实现成本是非常昂贵的。 而在CQRS架构下,因为CQRS架构把读和写分离了,所以可用性相当于被隔离在了两个部分去考虑。我们只需要考虑C端如何解决写的可用性,Q端如何解决读的可用性即可。C端解决可用性,我觉得是更加容易的,因为C端是消息驱动的。我们要做任何数据修改时,都会发送Command到分布式消息队列,然后后端消费者处理Command->产生领域事件->持久化事件->发布事件到分布式消息队列->最后事件被Q端消费。这个链路是消息驱动的。相比传统架构的直接服务方法调用,可用性要高很多。因为就算我们处理Command的后端消费者暂时挂了,也不会影响前端Controller发送Command,Controller依然可用。从这个角度来说,CQRS架构在数据修改上可用性要更高。不过你可能会说,要是分布式消息队列挂了呢?呵呵,对,这确实也是有可能的。但是一般分布式消息队列属于中间件,一般中间件都具有很高的可用性(支持集群和主备切换),所以相比我们的应用来说,可用性要高很多。另外,因为命令是先发送到分布式消息队列,这样就能充分利用分布式消息队列的优势:异步化、拉模式、削峰填谷、基于队列的水平扩展。这些特性可以保证即便前端Controller在高峰时瞬间发送大量的Command过来,也不会导致后端处理Command的应用挂掉,因为我们是根据自己的消费能力拉取Command。这点也是CQRS C端在可用性方面的优势,其实本质也是分布式消息队列带来的优势。所以,从这里我们可以体会到EDA架构(事件驱动架构)是非常有价值的,这个架构也体现了我们目前比较流行的Reactive Programming(响应式编程)的思想。 然后,对于Q端,应该说和传统架构没什么区别,因为都是要处理高并发的查询。这点以前怎么优化的,现在还是怎么优化。但是就像我上面可扩展性里强调的,CQRS架构可以更方便的提供更多的View存储,数据库、缓存、搜索引擎、NoSQL,而且这些存储的更新完全可以并行进行,互相不会拖累。理想的场景,我觉得应该是,如果你的应用要实现全文索引这种复杂查询,那可以在Q端使用搜索引擎,比如ElasticSearch;如果你的查询场景可以通过keyvalue这种数据结构满足,那我们可以在Q端使用Redis这种NoSql分布式缓存。总之,我认为CQRS架构,我们解决查询问题会比传统架构更加容易,因为我们选择更多了。但是你可能会说,我的场景只能用关系型数据库解决,且查询的并发也是非常高。那没办法了,唯一的办法就是分散查询IO,我们对数据库做分库分表,以及对数据库做一主多备,查询走备机。这点上,解决思路就是和传统架构一样了。 性能、伸缩性 本来想把性能和伸缩性分开写的,但是想想这两个其实有一定的关联,所以决定放在一起写。 伸缩性的意思是,当一个系统,在100人访问时,性能(吞吐量、响应时间)很不错,在100W人访问时性能也同样不错,这就是伸缩性。100人访问和100W人访问,对系统的压力显然是不同的。如果我们的系统,在架构上,能够做到通过简单的增加机器,就能提高系统的服务能力,那我们就可以说这种架构的伸缩性很强。那我们来想想传统架构和CQRS架构在性能和伸缩性上面的表现。 说到性能,大家一般会先思考一个系统的性能瓶颈在哪里。只要我们解决了性能瓶颈,那系统就意味着具有通过水平扩展来达到可伸缩的目的了(当然这里没有考虑数据存储的水平扩展)。所以,我们只要分析一下传统架构和CQRS架构的瓶颈点在哪里即可。 传统架构,瓶颈通常在底层数据库。然后我们一般的做法是,对于读:通常使用缓存就可以解决大部分查询问题;对于写:办法也有很多,比如分库分表,或者使用NoSQL,等等。比如阿里大量采用分库分表的方案,而且未来应该会全部使用高大上的OceanBase来替代分库分表的方案。通过分库分表,本来一台数据库服务器高峰时可能要承受10W的高并发写,如果我们把数据放到十台数据库服务器上,那每台机器只需要承担1W的写,相对于要承受10W的写,现在写1W就显得轻松很多了。所以,应该说数据存储对传统架构来说,也早已不再是瓶颈了。 传统架构一次数据修改的步骤是:1)从DB取出数据到内存;2)内存修改数据;3)更新数据回DB。总共涉及到2次数据库IO。 然后CQRS架构,CQ两端加起来所用的时间肯定比传统架构要多,因为CQRS架构最多有3次数据库IO,1)持久化命令;2)持久化事件;3)根据事件更新读库。为什么说最多?因为持久化命令这一步不是必须的,有一种场景是不需要持久化命令的。CQRS架构中持久化命令的目的是为了做幂等处理,即我们要防止同一个命令被处理两次。那哪一种场景下可以不需要持久化命令呢?就是当命令时在创建聚合根时,可以不需要持久化命令,因为创建聚合根所产生的事件的版本号总是为1,所以我们在持久化事件时根据事件版本号就能检测到这种重复。 所以,我们说,你要用CQRS架构,就必须要接受CQ数据的最终一致性,因为如果你以读库的更新完成为操作处理完成的话,那一次业务场景所用的时间很可能比传统架构要多。但是,如果我们以C端的处理为结束的话,则CQRS架构可能要快,因为C端可能只需要一次数据库IO。我觉得这里有一点很重要,对于CQRS架构,我们更加关注C端处理完成所用的时间;而Q端的处理稍微慢一点没关系,因为Q端只是供我们查看数据用的(最终一致性)。我们选择CQRS架构,就必须要接受Q端数据更新有一点点延迟的缺点,否则就不应该使用这种架构。所以,希望大家在根据你的业务场景做架构选型时一定要充分认识到这一点。 另外,上面再谈到数据一致性时提到,传统架构会使用事务来保证数据的强一致性;如果事务越复杂,那一次事务锁的表就越多,锁是系统伸缩性的大敌;而CQRS架构,一个命令只会修改一个聚合根,如果要修改多个聚合根,则通过Saga来实现。从而绕过了复杂事务的问题,通过最终一致性的思路做到了最大的并行和最少的并发,从而整体上提高系统的吞吐能力。 所以,总体来说,性能瓶颈方面,两种架构都能克服。而只要克服了性能瓶颈,那伸缩性就不是问题了(当然,这里我没有考虑数据丢失而带来的系统不可用的问题。这个问题是所有架构都无法回避的问题,唯一的解决办法就是数据冗余,这里不做展开了)。两者的瓶颈都在数据的持久化上,但是传统的架构因为大部分系统都是要存储数据到关系型数据库,所以只能自己采用分库分表的方案。而CQRS架构,如果我们只关注C端的瓶颈,由于C端要保存的东西很简单,就是命令和事件;如果你信的过一些成熟的NoSQL(我觉得使用文档性数据库如MongoDB这种比较适合存储命令和事件),且你也有足够的能力和经验去运维它们,那可以考虑使用NoSQL来持久化。如果你觉得NoSQL靠不住或者没办法完全掌控,那可以使用关系型数据库。但这样你也要付出努力,比如需要自己负责分库分表来保存命令和事件,因为命令和事件的数据量都是很大的。不过目前一些云服务如阿里云,已经提供了DRDS这种直接支持分库分表的数据库存储方案,极大的简化了我们存储命令和事件的成本。就我个人而言,我觉得我还是会采用分库分表的方案,原因很简单:确保数据可靠落地、成熟、可控,而且支持这种只读数据的落地,框架内置要支持分库分表也不是什么难事。所以,通过这个对比我们知道传统架构,我们必须使用分库分表(除非阿里这种高大上可以使用OceanBase);而CQRS架构,可以带给我们更多选择空间。因为持久化命令和事件是很简单的,它们都是不可修改的只读数据,且对kv存储友好,也可以选择文档型NoSQL,C端永远是新增数据,而没有修改或删除数据。最后,就是关于Q端的瓶颈,如果你Q端也是使用关系型数据库,那和传统架构一样,该怎么优化就怎么优化。而CQRS架构允许你使用其他的架构来实现Q,所以优化手段相对更多。 结束语 我觉得不论是传统架构还是CQRS架构,都是不错的架构。传统架构门槛低,懂的人也多,且因为大部分项目都没有什么大的并发写入量和数据量。所以应该说大部分项目,采用传统架构就OK了。但是通过本文的分析,大家也知道了,传统架构确实也有一些缺点,比如在扩展性、可用性、性能瓶颈的解决方案上,都比CQRS架构要弱一点。大家有其他意见,欢迎拍砖,交流才能进步,呵呵。所以,如果你的应用场景是高并发写、高并发读、大数据,且希望在扩展性、可用性、性能、可伸缩性上表现更优秀,我觉得可以尝试CQRS架构。但是还有一个问题,CQRS架构的门槛很高,我认为如果没有成熟的框架支持,很难使用。而目前据我了解,业界还没有很多成熟的CQRS框架,java平台有axon framework, jdon framework;.NET平台,ENode框架正在朝这个方向努力。所以,我想这也是为什么目前几乎没有使用CQRS架构的成熟案例的原因之一。另一个原因是使用CQRS架构,需要开发者对DDD有一定的了解,否则也很难实践,而DDD本身要理解没个几年也很难运用到实际。还有一个原因,CQRS架构的核心是非常依赖于高性能的分布式消息中间件,所以要选型一个高性能的分布式消息中间件也是一个门槛(java平台有RocketMQ),.NET平台我个人专门开发了一个分布式消息队列EQueue,呵呵。另外,如果没有成熟的CQRS框架的支持,那编码复杂度也会很复杂,比如Event Sourcing,消息重试,消息幂等处理,事件的顺序处理,并发控制,这些问题都不是那么容易搞定的。而如果有框架支持,由框架来帮我们搞定这些纯技术问题,开发人员只需要关注如何建模,实现领域模型,如何更新读库,如何实现查询,那使用CQRS架构才有可能,因为这样才可能比传统的架构开发更简单,且能获得很多CQRS架构所带来的好处。
设计目标 尽量快的处理命令和事件,保证吞吐量; 处理完一个命令后不需要等待命令产生的事件持久化完成就能处理下一个命令,从而保证领域内的业务逻辑处理不依赖于持久化IO,实现真正的in-memory; 保证命令、事件处理的顺序性,先来的先处理,先产生的先处理; 保证一个聚合根的事件只有一个线程在持久化,并按事件产生的顺序持久化; 持久化事件时如果遇到并发冲突时(聚合根ID+事件版本号出现重复)的处理代价要轻; 要能利用多核的优势; 总体设计思路 先将命令根据聚合根ID路由到CommandMailBox里; 单线程处理CommandMailBox中的命令,由于聚合根在in-memory本地内存,所以处理非常快; 处理成功后更新聚合根的in-memory内存; 内存更新后将聚合根产生的事件同样原理路由到EventMailBox里; 单线程批量处理EventMailBox中的事件;由于是批量,所以持久化的吞吐量也可以保证; 处理完成一批事件后,把这一批事件对应的命令从CommandMailBox中移除; 详细设计思路 设计N个存放命令的CommandMailBox,命令首先按聚合根ID的hashcode取摸路由到对应的CommandMailBox; 每个CommandMailBox都有一个maxOffset, consumeOffset,以及一个CommandProcessor(单线程)在不停的处理;maxOffset表示最后一个命令的位置;consumeOffset表示当前正在处理的命令的位置; CommandProcessor的处理逻辑; 创建、修改聚合根; 更新聚合根的in-memory缓存; 将聚合根产生的事件按聚合根ID的hashcode取摸路由到对应的EventMailBox;EventMailBox的个数也是程序启动时配置; 每个EventMailBox都有一个maxOffset, consumeOffset,以及一个EventProcessor(单线程)在不停的处理;maxOffset表示最后一个事件的位置;consumeOffset表示当前正在处理的事件的位置; EventProcessor的处理逻辑: 按次序批量获取一批要处理的事件; 批量持久化事件到EventStore,采用SqlBulkCopy; 如果持久化一切顺利,则publish这一批事件(publish如果遇到网络IO异常,则重试,直到成功为止),然后继续持久化下一批,并同时将当前这一批事件对应的命令从CommandMailBox中删除;.如果持久化遇到并发冲突(事件的aggregateRootId+Version重复),则对当前这一批事件一个个按顺序持久化。如果当前事件持久化成功,则同样publish该事件,当然遇到IO异常时也要不断重试,直到成功为止;成功后通知CommandMailBox移除当前事件对应的命令;如果当前事件持久化出现并发冲突,就做如下处理: 先通知当前事件对应聚合根暂停处理后续的命令; 用Event Sourcing技术将in-memory中的聚合根的状态还原到最新状态,确保下次执行command时基于的聚合根的状态是最新的; 把这一批里该聚合根的所有事件移除,把EventMailBox中的该聚合根的所有事件移除; 将CommandMailBox的处理位置重置为当前事件对应的命令的offset;从而可以确保产生并发冲突的事件对应的命令以及后续的命令能再重新被处理一遍; 通知当前事件对应聚合根继续处理后续的命令(从哪个位置开始处理,在上面第4步已经重置过了); 这一批的所有事件都一个个处理完之后,按同样的逻辑继续处理下一批事件; 其他说明 上面的设计基于一个前提,就是一个聚合根几乎不会同时在两台服务器上同时存在并处理命令,否则就会出现并发冲突,而并发冲突的处理的代价还是比较复杂的,应该尽量避免;这点可以通过EQueue保证; 当聚合根处理了命令,尝试更新in-memory内存时,可能有一种情况会失败。就是如果这个命令是创建聚合根的,而有可能并发的时候这个聚合根可能在内存中已经有了,则创建完聚合根添加到内存时,应该能检测出来并记录错误日志,然后该命令产生的事件也不必放入EventMailBox,然后认为该命令处理成功即可。 上面的设计中没有谈到当遇到命令重复执行时的设计思路,大家可以自己想想:)
前言 最近花了我几个月的业余时间,对EQueue做了一个重大的改造,消息持久化采用本地写文件的方式。到现在为止,总算完成了,所以第一时间写文章分享给大家这段时间我所积累的一些成果。 EQueue开源地址:https://github.com/tangxuehua/equeue EQueue相关文档:http://www.cnblogs.com/netfocus/category/598000.html EQueue Nuget地址:http://www.nuget.org/packages/equeue 昨天,我写过一篇关于EQueue 2.0性能测试结果的文章,有兴趣的可以看看。 文章地址:http://www.cnblogs.com/netfocus/p/4926305.html 为什么要改为文件存储? SQL Server的问题 之前EQueue的消息持久化是采用SQL Server的。一开始我觉得没什么问题,采用的是异步定时批量持久化,使用SqlBulkCopy的方法,这个方法测试下来,批量插入消息的性能还不错,就决定使用了。一开始我并没有在使用到EQueue后做集成的性能测试。在功能上确实没什么问题了。而且使用DB持久化也有很多好处,比如消息查询很简单,DB天生支持各种方式的查询。删除消息也非常简单,一条DELETE语句即可。所以功能实现比较顺利。但后来当我对EQueue做性能测试时,发现一些问题。当数据库服务器和Broker本身部署在不同的服务器上时,持久化消息也会走网卡,消耗带宽,影响消息的发送和消费的TPS。而如果数据库服务器部署在Broker同一台服务器上,则因为SQLServer本身也会消耗CPU以及内存,也会影响Broker的消息发送和消费的TPS。另外SqlBulkCopy的速度,再本身机器正在接收大量的发送消息和拉取消息的请求时,会不太稳定。经过一些测试,发现整个EQueue Broker的性能不太理想。然后又想想,Broker服务器有有一个硬件一直没有好好利用起来,那就是硬盘。假设我们的消息是持久化到本地硬盘的,顺序写文件,就应该能解决SQL Server的问题了。所以,开始调研如何实现文件持久化消息的方案了。 消息缓存在托管内存的GC的问题 之前消息存储在SQL Server,如果消费者每次读取消息时,总是从数据库去读取,那对数据库就是不断的写入和读取,性能不太理想。所以当初的思路是,尽量把最近可能要被消费的消息缓存在本地内存中。当初的做法是设计了一个很大的ConcurrentDictionary<long, Message>,这个字典就是存放了所有可能会被消费的消息。如果要消费的消息当前不在这个字典里,就批量从DB拉取一批出来消费。这个设计可以尽可能的避免读取DB的情况。但是带来了另一个问题。就是我们对这个字典在高并发不断的写入和读取。且这个字典里缓存的消息又很多,到到达几百上千万时,GC的压力过大,导致很多线程都会被阻塞。严重影响Broker的TPS。 所以,基于上面的两个主要原因,我想到了两个思路来解决:1)采用写文件的方式来持久化消息;2)使用非托管内存来缓存将要被消费的消息;下面我们来看看这两个设计的一些关键问题的设计思路。 文件存储的关键问题设计 心路背景 之前一直无法驾驭写文件的设计。因为精细化的将数据写入文件,并能要精确的读取想要的数据,实在没什么经验。之前虽然也知道阿里的RocketMQ的消息持久化也是采用顺序写文件的方式的,但是看了代码,发现设计很复杂,一下子也比较难懂。尝试看了多次也无法完全理解。所以一直无法掌握这种方式。有一天不经意间想到之前看过的EventStore这个开源项目中,也有写文件的设计。这个项目是CQRS架构之父greg young所主导的开源项目,是一个专门为ES(Event Sourcing)设计模式中提供保存事件流支持的事件流存储系统。于是下定决心专研其源码,看C#代码肯定还是比Java容易,呵呵。经过一段时间的摸索之后,基本学到了它是如何写文件以及如何读文件的。了解了很多设计思路。然后,在看懂了EventStore的文件存储设计之后,再去看RocketMQ的文件持久化的设计,发现惊人的相似。原来看不懂的代码现在也能看懂了,因为思路差不多的。所以,这给我开始动手提供了很大的信心。经过自己的一些准备(文件读写的性能验证)和设计思路整理后,终于开始动手了。 如何写消息到文件? 其实说出来也很简单。之前一直以为写文件就是一个消息一行呗。这样当我们要找哪个消息时,只需要知道行号即可。确实,理论上这样也挺好。但上面这两个开源项目都不是这样做的,而是都是采用更精细化的直接写二进制的方式。搞清楚写入的格式之后,还要考虑一个文件写不下的时候怎么办?因为一个文件总是有大小的,比如1G,那超过1G后,必然要创建新的文件,然后把消息写入新的文件。所以,我们就又有了Chunk的概念。一个Chunk就是一个文件,假设我们现在实现了一个FileMessageStore,表示对文件持久化的封装,那这个FileMessageStore肯定维护了一堆的Chunk。然后我们也很容易想到一点,就是Chunk有3种状态:1)New,表示刚创建的Chunk,这种Chunk我们可以写入新消息进去;2)Completed,已写入完成的Chunk,这种Chunk是只读的;3)OnGoing的Chunk,就是当FileMessageStore初始化时,要从磁盘的某个chunk的目录下加载所有的Chunk文件,那不难理解,最后一个文件之前的Chunk文件应该都是Completed的;最后一个Chunk文件可能写入了一半,就是之前没完全用完的。所以,本质上New和Ongoing的Chunk其实是一样的,只是初始化的方式不同。 至此,我们知道了写文件的两个关键思路:1)按二进制写;2)拆分为Chunk文件,且每个Chunk文件有状态;按二进制写主要的思路是,假如我们当前要写入的消息的二进制数组大小为100个字节,也就是说消息的长度为100,那我们可以先把消息的长度写入文件,再接着写入消息本身。这样我们读取消息时,只要知道了写入消息长度时的那个Position,就能先读取到消息的长度,然后就能知道接下来要读取多少字节为消息内容。从而能正确读取消息出来。 另外再分享一点,EventStore中,写入一个事件到文件中时,还会在写入消息内容后再写入这个消息的长度到文件里。也就是说,写入一个数据到文件时,会在头尾都写入该数据的长度。这样做的好处是什么呢?就是当我们想从后往前读数据时,也能方便的做到,因为每个数据的前后都记录了该数据的长度。这点应该不难理解吧?而EventStore是一个面向流的存储系统,我们对事件流确实可能从前往后读,也可能是从后往前读。另外这个设计还有一个好处,就是起到了校验数据合法性的目的。当我们根据长度读取数据后,再数据之后再读取一个长度,如果这两个长度一致,那数据应该就没问题的。在RocketMQ中,是通过CRC校验的方式来保证读取的数据没有问题。我个人还是比较喜欢EventStore的做法。所以EQueue里现在写入数据就是这样做的。 上面我介绍了一种写入不定长数据到文件的设计思路,这种设计是为了解决写入消息到文件的情况,因为消息的长度是不定的。在EQueue中,我们还有一另一种写文件的场景。就是队列信息的持久化。EQueue的架构是一个Topic下有多个Queue,每个Queue里有很多消息,消费者负载均衡是通过给消费者分配均匀数量的Queue的方式来达到的。这样我们只要确保写入Queue的消息是均匀的,那每个Consumer消费到的消息数就是均匀的。那一个Queue里记录的是什么呢?就是一个消息和其在队列的位置的对应关系。假设消息写入在文件的物理位置为10000,然后这个消息在Queue里的索引是100,那这个队列就会把这两个位置对应起来。这样当我们要消费这个Queue中索引为100的消息时,就能找到这个消息在文件中的物理位置为10000,就能根据这个位置找到消息的内容了。如果是托管内存,我们只需要弄一个Dictionary,key是消息在队列中的Offset,value是消息在文件中的物理Offset即可。这样我们有了这个dict,就能轻松建立起对应关系了。但上面我说过,这种巨大的dict是要占用内存的,会有GC的问题。所以更好的办法是,把这个对应关系也写入文件,那怎么做呢?这时就又需要更精细化的设计了。想到了其实也很简单,这个设计我是从RocketMQ中学到的。就是我们设计一种固定长度的结构体,这个结构体里就存放一个数据,就是消息在文件的物理位置(为了后面好表达,我命名为MessagePosition),一个Long值,一个Long的长度是8个字节。也就是说,这个文件中,每个写入的数据的长度都是8个字节。假设我们一个文件要保存100W个MessagePosition。那这个文件的长度就是100W * 8这么多字节,大概为7.8MB。那么这样做有什么好处呢?好处就是,假如我们现在要消费这个Queue里的第一个消息,那这个消息的MessagePosition在这个文件中的位置0,第二个消息在这个文件中的位置是8,第三个就是16,以此类推,第N 个消息就是(N-1) * 8。也就是说,我们无须显式的把消息在队列中的位置信息也写入到文件,而是通过这样的固定算法,就能精确的算出Queue中某个消息的MessagePosition是写入在文件的哪个位置。然后拿到了MessagePosition之后,就能从Message的Chunk文件中读取到这个消息了。 通过上面的分析,我们知道了,Producer发送一个消息到Broker时,Broker会写两次磁盘。一次是现将消息本身写入磁盘(Message Chunk里),另一次是将消息的写入位置写入到磁盘(Queue Chunk里)。细心的朋友可能会问,假如我第一次写入成功,但第二次写入时失败,比如正好机器断电或者当前Broker服务器正好出啥问题 了,没有写入成功。那怎么办呢?这个没有什么大的影响。因为首先这种情况会被认为是消息发送失败。所以Producer还会重新发送该消息,然后Broker收到消息后还会再做一次这两个写入操作。也就是说,第一次写入的消息内容永远也不会用到了,因为那个写入位置永远也不会在Queue Chunk里有记录。 下面的代码展示了写消息到文件的核心代码: //消息写文件需要加锁,确保顺序写文件 MessageStoreResult result = null; lock (_syncObj) { var queueOffset = queue.NextOffset; var messageRecord = _messageStore.StoreMessage(queueId, queueOffset, message); queue.AddMessage(messageRecord.LogPosition, message.Tag); queue.IncrementNextOffset(); result = new MessageStoreResult(messageRecord.MessageId, message.Code, message.Topic, queueId, queueOffset, message.Tag); } StoreMessage方法内部实现: public MessageLogRecord StoreMessage(int queueId, long queueOffset, Message message) { var record = new MessageLogRecord( message.Topic, message.Code, message.Body, queueId, queueOffset, message.CreatedTime, DateTime.Now, message.Tag); _chunkWriter.Write(record); return record; } queue.AddMessage方法的内部实现: public void AddMessage(long messagePosition, string messageTag) { _chunkWriter.Write(new QueueLogRecord(messagePosition + 1, messageTag.GetHashcode2())); } ChunkWriter的内部实现: public long Write(ILogRecord record) { lock (_lockObj) { if (_isClosed) { throw new ChunkWriteException(_currentChunk.ToString(), "Chunk writer is closed."); } //如果当前文件已经写完,则需要新建文件 if (_currentChunk.IsCompleted) { _currentChunk = _chunkManager.AddNewChunk(); } //先尝试写文件 var result = _currentChunk.TryAppend(record); //如果当前文件已满 if (!result.Success) { //结束当前文件 _currentChunk.Complete(); //新建新的文件 _currentChunk = _chunkManager.AddNewChunk(); //再尝试写入新的文件 result = _currentChunk.TryAppend(record); //如果还是不成功,则报错 if (!result.Success) { throw new ChunkWriteException(_currentChunk.ToString(), "Write record to chunk failed."); } } //如果需要同步刷盘,则立即同步刷盘 if (_chunkManager.Config.SyncFlush) { _currentChunk.Flush(); } //返回数据写入位置 return result.Position; } } 当然,我上面为了简化问题的复杂度。所以没有引入关于如何根据某个全局的MessagePosition找到其在哪个Message Chunk的问题。这个其实也很好做,我们首先固定好每个Message Chunk文件的大小。比如大小为256MB,然后我们为每个Chunk文件设计一个ChunkHeader,每个Chunk文件总是先把这个ChunkHeader写入文件,这个Header里记录了这个文件的起始位置和结束位置,以及文件的大小。这样我们根据某个MessagePosition计算其在哪个Chunk文件时,只需要把这个MessagePositon对Chunk的大小做取摸操作即可。根据数据的位置找其在哪个Chunk的代码看起来如下面这样这样: public Chunk GetChunkFor(long dataPosition) { var chunkNum = (int)(dataPosition / _config.GetChunkDataSize()); return GetChunk(chunkNum); } public Chunk GetChunk(int chunkNum) { if (_chunks.ContainsKey(chunkNum)) { return _chunks[chunkNum]; } return null; } 代码很简单,就不多讲了。拿到了Chunk对象后,我们就可以把dataPosition传给Chunk,然后Chunk内部把这个全局的dataPosition转换为本地的一个位置,就能准确的定位到这个数据在当前Chunk文件的实际位置了。将全局位置转换为本地的位置的算法也很简单直接: public int GetLocalDataPosition(long globalDataPosition) { if (globalDataPosition < ChunkDataStartPosition || globalDataPosition > ChunkDataEndPosition) { throw new Exception(string.Format("globalDataPosition {0} is out of chunk data positions [{1}, {2}].", globalDataPosition, ChunkDataStartPosition, ChunkDataEndPosition)); } return (int)(globalDataPosition - ChunkDataStartPosition); } 只需要把这个全局的位置减去当前Chunk的数据开始位置,就能知道这个全局位置相对于当前Chunk的本地位置了。 好了,上面介绍了消息如何写入的主要思路以及如何读取数据的思路。 另外一点还想提一下,就是关于刷盘的策略。一般我们写数据到文件后,是需要调用文件流的Flush方法的,确保数据最终刷入到了磁盘上。否则数据就还是在缓冲区里。当然,我们需要注意到,即便调用了Flush方法,数据可能也还没真正逻辑到磁盘,而只是在操作系统内部的缓冲区里。这个我们就无法控制了,我们能做到的是调用了Flush方法即可。那当我们每次写入一个数据到文件都要调用Flush方法的话,无疑性能是低下的,所以就有了所谓的异步刷盘的设计。就是我们写入消息后不立即调用Flush方法,而是采用一个独立的线程,定时调用Flush方法来实现刷盘。目前EQueue支持同步刷盘和异步刷盘,开发者可以自己配置决定采用哪一种。异步刷盘的间隔默认是100ms。当我们在追求高吞吐量时,应该考虑异步刷盘,但要求数据可靠性更高但对吞吐量可以低一点时,则可以使用同步刷盘。如果又要高吞吐又要数据高可靠,那就只有一个办法了,呵呵。就是多增加一些Broker机器,通过集群来弥补单台Broker写入数据的瓶颈。 如何从文件读取消息? 假设我们现在要从一个文件读取数据,且是多线程并发的读取,要怎么设计?一个办法是,每次读取时,创建文件流,然后创建StreamReader,然后读取文件,读取完成后释放StreamReader并关闭文件流。但每次要读取文件的一个数据都要这样做的话性能不是太好,因为我们反复的创建这样的对象。所以,这里我们可以使用对象池的概念。就是Chunk内部,预先创建好一些Reader,当需要读文件时,获取一个可用的Reader,读取完成后,再把Reader归还到对象池里。基于这个思路,我设计了一个简单的对象池: private readonly ConcurrentQueue<ReaderWorkItem> _readerWorkItemQueue = new ConcurrentQueue<ReaderWorkItem>(); private void InitializeReaderWorkItems() { for (var i = 0; i < _chunkConfig.ChunkReaderCount; i++) { _readerWorkItemQueue.Enqueue(CreateReaderWorkItem()); } _isReadersInitialized = true; } private ReaderWorkItem CreateReaderWorkItem() { var stream = default(Stream); if (_isMemoryChunk) { stream = new UnmanagedMemoryStream((byte*)_cachedData, _cachedLength); } else { stream = new FileStream(_filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, _chunkConfig.ChunkReadBuffer, FileOptions.None); } return new ReaderWorkItem(stream, new BinaryReader(stream)); } private ReaderWorkItem GetReaderWorkItem() { ReaderWorkItem readerWorkItem; while (!_readerWorkItemQueue.TryDequeue(out readerWorkItem)) { Thread.Sleep(1); } return readerWorkItem; } private void ReturnReaderWorkItem(ReaderWorkItem readerWorkItem) { _readerWorkItemQueue.Enqueue(readerWorkItem); } 当一个Chunk初始化时,我们预先初始化好固定数量(可配置)的Reader对象,并把这些对象放入一个ConcurrentQueue里(对象池的作用),然后要读取数据时,从从ConcurrentQueue里拿一个可用的Reader即可,如果当前并发太高拿不到怎么办,就等待直到拿到为止,目前我是等待1ms后继续尝试拿,直到最后拿到为止。然后ReturnReaderWorkItem就是数据读取完之后归还Reader到对象池。就是不是很简单哦。这样的设计,可以避免不断的创建文件流和Reader对象,可以避免GC的副作用。 Broker重启时如何做? 大家知道,当Broker重启时,我们是需要扫描磁盘上Chunk目录下的所有Chunk文件的。那怎么扫描呢?上面其实我也简单提到过。首先,我们可以对每个Chunk文件的文件名的命名定义一个规则,第一个Chunk文件的文件名比如为:message-chunk-000000000,第二个为:message-chunk-000000001,以此类推。那我们扫描时,只要先把所有的文件名获取到,然后对文件名升序排序。那最后一个文件之前的文件肯定都是写入完全了的,即上面我说的Completed状态的,而最后一个文件是还没有写入完的,还可以接着写。所以我们初始化时,只需要先初始化最后一个之前的所有Chunk文件,最后再初始化最后一个文件即可。这里我所说的初始化不是要把整个Chunk文件的内容都加载到内存,而是只是读取这个文件的ChunkHeader的信息维护在内存即可。有了Header信息,我们就可以为后续的数据读取提供位置计算了。所以,整个加载过程是很快的,读取100个Chunk文件的ChunkHeader也不过一两秒的时间,完全不影响Broker的启动时间。对于初始化Completed的Chunk比较简单,只需要读取ChunkHeader信息即可。但是初始化最后一个文件比较麻烦,因为我们还要知道这个文件当前写入到哪里了?从而我们可以从这个位置的下一个位置接着往下写。那怎么知道这个文件当前写入到哪里了呢?其实比较复杂。有很多技术,我看到RocketMQ和EventStore这两个开源项目中都采用了Checkpoint的技术。就是当我们每次写入一个数据到文件后,都会更新一下Checkpoint,即表示当前写入到这个文件的哪里了。然后这个Checkpoint值我们也是定时异步保存到某个独立的小文件里,这个文件里只保存了这个Checkpoint。这样的设计有一个问题,就是假如数据写入了,但由于Checkpoint的保存不是实时的,所以理论上会出现Checkpint值会小于实际文件写入的位置的情况。一般我们忽略这种情况即可,即可能会存在初始化时,下次写入可能会覆盖一定的之前已经写入的数据,因为Checkpoint可能是稍微老一点的。 而我在设计时,希望能再严谨一点,取消Checkpoint的设计,而是采用在初始化Ongoing状态的Chunk文件时,从文件的头开始不断往下读,当最后无法往下读时,我们就知道这个文件我们当前写入到哪里了。那怎么知道无法往下读了呢?也就是说怎么确定后续的文件内容不是我们写入的?也很简单。对于不固定数据长度的Chunk来说,由于我们每次写入一个数据时都是同时在前后写入这个数据的长度;所以我们再初始化读取这个文件时,可以借助这一点来校验,但出现不符合这个规则的数据时,就认为后续不是正常的数据了。对于固定长度的Chunk来说,我们只要保证每次写入的数据的数据是非0了。而对于EQueue的场景,固定数据的Chunk里存储的都是消息在Message Chunk中的全局位置,一个Long值;但这个Long值我们正常是从0开始的,怎么办呢?很简单,我们写入MessagePosition时,总是加1即可。即假如当前的MessagePosition为0,那我们实际写入1,如果为100,则实际写入的值是101。这样我们就能确保这个固定长度的Chunk文件里每个数据都是非0的。然后我们在初始化这样的Chunk文件时,只要不断读取固定长度(8个字节)的数据,当出现读取到的数据为0时,就认为已经到头了,即后续的不是我们写入的数据了。然后我们就能知道接下来要从哪里开始读取了哦。 如何尽量避免读文件? 上面我介绍了如何读文件的思路。我们也知道了,我们是在消费者要消费消息时,从文件读取消息的。但对从文件读取消息总是没有比从内存读取消息来的快。我们前面的设计都没有把内存好好利用起来。所以我们能否考虑把未来可能要消费的Chunk文件的内容直接缓存在内存呢?这样我们就可以避免对文件的读取了。肯定可以的。那怎么做呢?前面我提高多,曾经我们用托管内存中的ConcurrentDictionary<long, Message>这样的字典来缓存消息。我也提到这会带来垃圾回收而影响性能的问题。所以我们不能直接这样简单的设计。经过我的一些尝试,以及从EventStore中的源码中学到的,我们可以使用非托管内存来缓存Chunk文件。我们可以使用Marshal.AllocHGlobal来申请一块完整的非托管内存,然后再需要释放时,通过Marshal.FreeHGlobal来释放。然后,我们可以通过UnmanagedMemoryStream来访问这个非托管内存。这个是核心思路。那么怎样把一个Chunk文件缓存到非托管内存呢?很简单了,就是扫描这个文件的所有内容,把内容都写入内存即可。代码如下: private void LoadFileChunkToMemory() { using (var fileStream = new FileStream(_filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 8192, FileOptions.None)) { var cachedLength = (int)fileStream.Length; var cachedData = Marshal.AllocHGlobal(cachedLength); try { using (var unmanagedStream = new UnmanagedMemoryStream((byte*)cachedData, cachedLength, cachedLength, FileAccess.ReadWrite)) { fileStream.Seek(0, SeekOrigin.Begin); var buffer = new byte[65536]; int toRead = cachedLength; while (toRead > 0) { int read = fileStream.Read(buffer, 0, Math.Min(toRead, buffer.Length)); if (read == 0) { break; } toRead -= read; unmanagedStream.Write(buffer, 0, read); } } } catch { Marshal.FreeHGlobal(cachedData); throw; } _cachedData = cachedData; _cachedLength = cachedLength; } } 代码很简单,不用多解释了。需要注意的是,上面这个方法针对的是Completed状态的Chunk,即已经写入完成的Chunk的。已经写入完全的Chunk是只读的,不会再发生更改,所以我们可以随便缓存在内存中。 那对于新创建出来的Chunk文件呢?正常情况下,消费者来得及消费时,我们总是在不断的写入最新的Chunk文件,也在不断的从这个最新的Chunk文件读取消息。那我们怎么确保消费最新的消息时,也不需要从文件读取呢?也很简单,就是在新建一个Chunk文件时,如果内存足够,也同时创建一个一样大小的基于非托管内存的Chunk。然后我们再写入消息到文件Chunk成功后,再同时写入这个消息到非托管内存的Chunk。这样,我们在消费消息,读取消息时总是首先判断当前Chunk是否关联了一个非托管内存的Chunk,如果有,就优先从内存读取即可。如果没有才从文件Chunk读取。 但是从文件读取时,可能会遇到一个问题。就是我们刚写入到文件的数据可能无法立即读取到。因为写入的数据没有立即刷盘,所以无法通过Reader读取到。所以,我们不能仅通过判断当前写入的位置来判断当前是否还有数据可以被读取,而是考虑当前的最后一次刷盘的位置。理论上只能读取刷盘之前的数据。但即便这样设计了,在如果当前硬盘不是SSD的情况下,好像也会出现读不到数据的问题。偶尔会报错,有朋友在测试时已经遇到了这样的问题。那怎么办呢?我想了一个办法。因为这种情况归根接地还是因为我们逻辑上认为已经写入到文件的数据由于未及时刷盘或者操作系统本身的内部缓存的问题,导致数据未能及时写入磁盘。出现这种情况一定是最近的一些数据。所以我们如果能够把比如最近写入的10000(可配置)个数据都缓存在本地托管内存中,然后读取时先看本地缓存的托管内存中有没有这个位置的数据,如果有,就不需要读文件了。这样就能很好的解决这个问题了。那怎么确保我们只缓存了最新的10000个数据且不会超出10000个呢?答案是环形队列。这个名字听起来很高大上,其实就是一个数组,数组的长度为10000,然后我们在写入数据时,我们肯定知道这个数据在文件中的位置的,我们可以把这个位置(一个long值)对10000取摸,就能知道该把这个数据缓存在这个数组的哪个位置了。通过这个设计确保缓存的数据不会超过10000个,且确保一定只缓存最新的数据,如果新的数据保存到数组的某个下标时,该下标已经存在以前已经保存过的数据了,就自动覆盖掉即可。由于这个数组的长度不是很长,所以每什么GC的问题。 但是光这样还不够,我们这个数组中的每个元素至少要记录这个元素对应的数据在文件中的位置。这个是为了我们在从数组中获取到数据后,进一步校验这个数据是否是我想要的那个位置的数据。这点大家应该可以理解的吧。下面这段代码展示了如何从环形数组中读取想要的数据: if (_cacheItems != null) { var index = dataPosition % _chunkConfig.ChunkLocalCacheSize; var cacheItem = _cacheItems[index]; if (cacheItem != null && cacheItem.RecordPosition == dataPosition) { var record = readRecordFunc(cacheItem.RecordBuffer); if (record == null) { throw new ChunkReadException( string.Format("Cannot read a record from data position {0}. Something is seriously wrong in chunk {1}.", dataPosition, this)); } if (_chunkConfig.EnableChunkReadStatistic) { _chunkStatisticService.AddCachedReadCount(ChunkHeader.ChunkNumber); } return record; } } _cacheItems是当前Chunk内的一个环形数组,然后假如当前我们要读取的数据的位置是dataPosition,那我们只需要先对环形数据的长度取摸,得到一个下标,即上面代码中的index。然后就能从数组中拿到对应的数据了,然后如果这个数据存在,就进一步判断这个数据dataPosition是否和要求的dataPosition,如果一致,我们就能确定这个数据确实是我们想要的数据了。就可以返回了。 所以,通过上面的两种缓存(非托管内存+托管内存环形数组)的设计,我们可以确保几乎不用再从文件读取消息了。那什么时候还是会从文件读取呢?就是在1)内存不够用了;2)当前要读取的数据不是最近的10000个;这两个前提下,才会从文件读取。一般我们线上服务器,肯定会保证内存是可用的。EQueue现在有两个内存使用的水位。一个是当物理内存使用到多少百分比(默认值为40%)时,开始清理已经不再活跃的Chunk文件的非托管内存Chunk;那什么是不活跃呢?就是在最近5s内没有发生过读写的Chunk。这个设计我觉得是非常有效的,因为假如一个Chunk有5s没有发生过读写,那一般肯定是没有消费者在消费它了。另一个水位是指,最多EQueue Broker最多使用物理内存的多少百分比(默认值为75%),这个应该好理解。这个水位是为了保证EQueue不会把所有物理内存都吃光,是为了确保服务器不会因为内存耗尽而宕机或导致服务不可用。 那什么时候会出现大量使用服务器内存的情况呢?我们可以推导出来的。正常情况下,消息写入第一个Chunk,我们就在读取第一个Chunk;写入第二个Chunk我们也会跟着读取第二个Chunk;假设当前写入到了第10个Chunk,那理论上前面的9个Chunk之前缓存的非托管内存都可以释放了。因为肯定超过5s没有发生读写了。但是假如现在消费者有很多,且每个消费者的消费进度都不同,有些很快,有些很慢,当所有的消费者的消费进度正好覆盖到所有的Chunk文件时,就意味着每个Chunk文件都在发生读取。也就是说,每个Chunk都是活跃的。那此时就无法释放任何一个Chunk的非托管内存了。这样就会导致占用大量非托管内存了。但由于75%的水位的设计,Broker内存的使用是不会超过物理内存75%的。在创建新的Chunk或者尝试缓存一个Completed的Chunk时,总是会判断当前使用的物理内存是否已经超过75%,如果已经超过,就不会分配对应的非托管内存了。 如何删除消息? 删除消息的设计比较简单。主要的思路是,当我们的消息已经被所有的消费者都消费过了,且满足我们的删除策略了,就可以删除了。RocketMQ删除消息的策略比较粗暴,没有考虑消息是否经被消费,而是直接到了一定的时间就删除了,比如最多只保留2天。这个是RocketMQ的设计。EQueue中,会确保消息一定是被所有的消费者都消费了才会考虑删除。然后目前我设计的删除策略有两种: 按Chunk文件数;即设计一个阀值,表示磁盘上最多保存多少个Chunk文件。目前默认值为100,每个Chunk文件的大小为256MB。也就是大概总磁盘占用25G。一般我们的硬盘肯定有25G的。当我们不关心消息保存多久而只从文件数的角度来决定消息是否要删除时,可以使用这个策略; 按时间来删除,默认是7天,即当某个Chunk是7天前创建的,那我们就可以创建了。这种策略是不关心Chunk总共有多少,完全根据时间的维度来判断。 实际上,应该可能还有一些需求希望能把两个策略合起来考虑的。这个目前我没有做,我觉得这两种应该够了。如果大家想做,可以自己扩展的。 另外,上面我说过EQueue中目前有两种Chunk文件,一种是存储消息本身的,我叫做Message Chunk;一种是存储队列信息的,我叫做Queue Chunk;而Queue Chunk的数据是依赖于Message Chunk的。上面我说的两种删除策略是针对Message Chunk而言的。而Queue Chunk,由于这个依赖性,我觉得比较合理的方式是,只需要判断当前Queue Chunk中的所有的消息对应的Message Chunk是否已经都删除了,如果是,难说明这个Queue Chunk也已经没意义了,就可以删除了。但只要这个Queue Chunk中至少还有一个消息的Chunk文件没删除,那这个Queue Chunk就不会删除。 上面这个只是思路哦,真实的代码肯定比这个复杂,呵呵。有兴趣的朋友还是要看代码的。 如何查消息? 之前用SQL Server的方式,由于DB很容易查消息,所以查询消息不是大问题。但是现在我们的消息是放在文件里的,那要怎么查询呢?目前支持根据消息ID来查询。当Producer发送一个消息到Broker,Broker返回结果里会包含消息的ID。Producer的正确做法应该是要用日志或其他方式记录这个ID,并最好和自己的当前业务消息的某个业务ID一起记录,比如CommandId或者EventId,这样我们就能根据我们的业务ID找到消息ID,然后根据消息ID找到消息内容了。那消息ID现在是怎么设计的呢?也是受到RocketMQ的启发,消息ID由两部分组成:1)Broker的IP;2)消息在Broker的文件中的全局位置;这样,当我们要根据某个消息ID查询时,就可以先定位到这个消息在哪个Broker上,也同时知道了消息在文件的哪个位置了,这样就能最终读取这个消息的内容了。 为什么要这样设计呢?如果我们的Broker没有集群,那其实不需要包含Broker的IP;这个设计是为了未来EQueue Broker会支持集群的,那个时候,我们就必须要知道某个消息ID对应的Broker是哪个了。 如何保存队列消费进度? EQueue中,每个Queue,都会有一个对应的Consumer。消费进度就是这个Queue当前被消费到哪里了,一个Offset值。比如Offset为100,就表示当前这个Queue已经消费到第99(因为是从0开始的)个位置的消息了。因为一个Broker上有很多的Queue,比如有100个。而我们现在是使用文件的方式来存储信息了。所以自然消费进度也是用文件了。但由于消费进度的信息很少,也不是递增的形式。所以我们可以简单设计,目前EQueue采用一个文件的方式来保存所有Queue的消费进度,文件内容为JSON,JSON里记录了每个Queue的消费进度。文件内容看起来像下面这样: {"SampleGroup":{"topic1-3":89055,"topic1-2":89599,"topic1-1":89471,"topic1-0":89695}} 上面的JSON标识一个名为SampleGroup的ConsumerGroup,他消费了一个名为topic1的topic,然后这个topic下的每个Queue的消费进度记录了下来。如果有另一个ConsumerGroup,也消费了这个topic,那消费进度是隔离的。如果还不清楚ConsumerGroup的同学,要去看一下我之前写的EQueue的文章了。 还有没有可以优化的地方? 到目前为止,还有没有其他可优化的大的地方呢?有。之前我做EQueue时,总是把消息从数据库读取出来,然后构造出消息对象,再把消息对象序列化为二进制,再返回给Consumer。这里涉及到从DB拿出来,再序列化为二进制。学习了RocketMQ的代码后,我们可以做的更聪明一点。因为其实基于文件存储时,我们从文件里拿出来的已经是二进制了。所以可以直接把二进制返回给消费者即可。不需要先转换为对象再做序列化了。通过这个设计的改进,我们现在的消费者消费消息,可以说无任何瓶颈了,非常快。 如何统计消息读写情况? 在测试写文件的这个版本时,我们很希望知道每个Chunk的读写情况的统计,从而确定设计是正确的。所以,我给EQueue的Chunk增加了实时统计Chunk读写的统计服务。目前我们在运行EQueue自带的例子时,Broker会每个一秒打印出所有Chunk的读写情况,这个特性极大的方便我们判断消息的发送和消费是否正常,消费是否有延迟等。 其他新增功能 更完善和安全的队列扩容和缩容设计 这次我给EQueue的Web后台管理控制台也完善了一下队列的增加和减少的设计。增加队列(即队列的扩容)比较简单,直接新增即可。但是当我们要删除一个队列时,怎样安全的删除呢?主要是要确保删除这个队列时,已经没有Producer或Consumer在使用这个队列了。要怎么做到呢?我的思路是,为每个Queue对象设计两个属性,表示对Producer是否可见,对Consumer是否可见。当我们要删除某个Queue时,可以:1)先让其对Producer不可见,这样Producer后续就不会再发送新的消息到这个队列了;然后等待,直到这个队列里的消息都被所有的消费者消费掉了;然后再设置为对Consumer不可见。然后再过几秒,确保每个消费者都不会再向这个队列发出拉取消息的请求了。这样我们就能安全的删除这个队列了。删除队列的逻辑大概如如下: public void DeleteQueue(string topic, int queueId) { lock (this) { var key = QueueKeyUtil.CreateQueueKey(topic, queueId); Queue queue; if (!_queueDict.TryGetValue(key, out queue)) { return; } //检查队列对Producer或Consumer是否可见,如果可见是不允许删除的 if (queue.Setting.ProducerVisible || queue.Setting.ConsumerVisible) { throw new Exception("Queue is visible to producer or consumer, cannot be delete."); } //检查是否有未消费完的消息 var minConsumedOffset = _consumeOffsetStore.GetMinConsumedOffset(topic, queueId); var queueCurrentOffset = queue.NextOffset - 1; if (minConsumedOffset < queueCurrentOffset) { throw new Exception(string.Format("Queue is not allowed to delete, there are not consumed messages: {0}", queueCurrentOffset - minConsumedOffset)); } //删除队列的消费进度信息 _consumeOffsetStore.DeleteConsumeOffset(queue.Key); //删除队列本身,包括所有的文件 queue.Delete(); //最后将队列从字典中移除 _queueDict.Remove(key); } } 代码应该很简单直接,不多解释了。队列的动态新增和删除,可以方便我们线上应付在线活动时,随时为消费者提供更高的并行消费能力,以及活动结束后去掉多余的队列。是非常实用的功能。 支持Tag功能 这个功能,也是非常实用的。这个版本我加了上去。以前EQueue只有Topic的概念,没有Tag的概念。Tag是对Topic的二级过滤。比如当某个Producer发送了3个消息,Topic都是topic,然后tag分别是01,02,03。然后Consumer订阅了这个Topic,但是订阅这个Topic时同时制定了Tag,比如指定为02,那这个Consumer就只会收到一个消息。Tag为01,03的消息是不会收到的。这个就是Tag的功能。我觉得Tag对我们是非常有用的,它可以极大的减少我们定义Topic。本来我们必须要定义一个新的Topic时,现在可能只需要定义一个Tag即可。关于Tag的实现,我就不展开了。 支持消息堆积报警 终于到最后一点了,终于坚持快写完了,呵呵。EQueue Web后台管理控制台现在支持消息堆积的报警了。当EQueue Broker上当前所有未消费的消息数达到一定的阀值时,就会发送邮件进行报警。我们可以把我们的邮件和我们的手机短信进行绑定,比如移动的139邮箱我记得就有这个功能。这样我们就能第一时间知道Broker上是否有大量消息堆积了,可以让我们第一时间处理问题。 结束语 这篇文章感觉是我有史以来写过的最有干货的一篇了,呵呵。一气呵成,也是对我前面几个月的所有积累知识经验的一次性释放吧。希望能给大家一些帮助。我写文章比较喜欢写思路,不太喜欢介绍如何用。我觉得一个程序员,最重要的是要学会如何思考去解决自己想解决的问题。而不是别人直接告诉你如何去解决。通过做EQueue这个分布式消息队列,也算是我自己的一个实践过程。我非常鼓励大家写开源项目哦,当你专注于实现某个你感兴趣的开源项目时,你就会有目标性的去学习相关的知识,你的学习就不会迷茫,不会为了学技术而学技术了。我在做EQuque时,要考虑各种东西,比如通信层的设计、消息持久化、整个架构设计,等等。我觉得是非常锻炼人的。 一个人时间短暂,如果能用有限的时间做出好的东西可以造福后人,那我们来到这个世上也算没白来了,你说对吗?所以,我们千万不要放弃我们的理想,虽然坚持理想很难,但也要坚持。
前言 最近用了几个月的时间,一直在对EQueue做性能优化。到现在总算告一段落了,现在把一些优化的结果分享给大家。EQueue是一个分布式的消息队列,设计思路基本和阿里的RocketMQ一致,只是是用纯C#写的,这点大家应该都知道了。 EQueue开源地址:https://github.com/tangxuehua/equeue EQueue相关文档:http://www.cnblogs.com/netfocus/category/598000.html EQueue Nuget地址:http://www.nuget.org/packages/equeue 之前EQueue 1.*版本,消息持久化是使用SQLServer的,之前也想做性能测试,但发现效果不理想。所以放弃了测试,转为专心对消息持久化这块进行优化。之前的持久化设计是通过Sql Bulk Copy的方式异步批量持久化消息。但是当数据库服务器和Broker本身部署在不同的服务器上时,持久化消息也会走网卡,消耗带宽,影响消息的发送和消费的TPS。而如果数据库服务器部署在Broker同一台服务器上,则因为SQLServer本身也会消耗CPU以及内存,也会影响Broker的消息发送和消费的TPS。所以,经过考虑后,最终决定狠下心来,采用终极方案来持久化消息,就是通过本地顺序写文件的方式来持久化消息。支持异步刷盘和同步刷盘两种方式。采用文件存储后,EQueue可以轻易的支持大量消息的堆积,你的硬盘有多大,就能堆积多少量的消息。 到现在为止,经过不断的测试,功能上我认为已经比较稳定了,性能也基本满足我的要求。另外,EQueue单纯的消息持久化模块(MessageStore)以及TCP通信层的性能是非常高的。MessageStore我设计时,尽量独立一些,因为我发现写文件和读文件的设计是通用的,以后可以被复用到其他项目,所以我设计时,确保持久化模块的通用性;而TCP通信层,也是也是一样,通用和高效。目前我自己写的通信层(在ECommon类库中)可以轻松把网卡压满。 关于EQueue 2.0文件持久化版本的具体设计,我下一篇文章会具体介绍,本文主要是想分享一下EQueue 2.0的性能测试结果。为大家在使用到线上环境前做架构选型给出性能方面的参考。 测试目的 测试单个EQueue Broker服务器的发送消息、消费消息的性能。 硬件环境 全部采用UCloud云主机进行测试。一台Broker,4台Client机器。Broker的配置为8核16G内存,120G的SSD硬盘,Client的配置为4核8G内存,普通硬盘。所有服务器操作系统均为Windows Server 2012。具体如下图所示: 测试场景1 1台生产者、1台消费者、1台Broker,异步刷盘,每隔100ms刷一次盘。 消息大小(字节) 发送TPS 消费TPS 发送网络吞吐量 消费网络吞吐量 Broker磁盘写入吞吐量 Broker CPU Broker内存 Producer CPU Producer内存 Consumer CPU Consumer内存 128 56400 56400 10MB 13MB 13MB 35% 35% 30% 12% 30% 13% 512 41000 41000 22MB 25MB 25MB 32% 35% 30% 13% 30% 13% 1024 29000 29000 30MB 34MB 34MB 32% 35% 27% 12% 25% 13% 2048 20000 20000 41MB 43MB 43MB 30% 35% 27% 12% 25% 13% 测试场景2 2台生产者、2台消费者、1台Broker,异步刷盘,每隔100ms刷一次盘。 消息大小(字节) 发送总TPS 消费总TPS 发送总网络吞吐量 消费总网络吞吐量 Broker磁盘写入吞吐量 Broker CPU Broker内存 Producer CPU Producer内存 Consumer CPU Consumer内存 128 50000 50000 8.6MB 11MB 11MB 46% 35% 19% 13% 19% 13% 512 40000 40000 21MB 24MB 24MB 48% 35% 19% 13% 19% 13% 1024 31200 31200 32MB 35MB 35MB 47% 35% 19% 13% 19% 13% 2048 23000 23000 48MB 51MB 51MB 45% 35% 19% 13% 19% 13% 结束语 一些说明 因为EQueue的Producer发送消息时,本地会有缓存队列。所以Producer发送消息采用的线程数对发送TPS并无什么影响。所以,我上面的测试中并没有考虑发送线程数; Broker的内存使用我们是可以控制的,我测试时配置为当内存使用到达40%时,开始自动清理内存,所以上面的测试结果,内存使用都不会超过40%; 因为消费者从Broker拉取消息时,EQueue的设计在内存足够的情况下,总是会将需要消费的消息缓存在非托管内存,所以消费者拉取消息时,总是从内存获取消息的。所以Broker没有磁盘的读取;且由于这个设计,消费者消费消息不会成为瓶颈,假设当前Broker上存在大量消息可以被消费,然后我们开一个Consumer,那消费速度是非常快的,1K大小的消息,10W TPS没有问题。所以,消息的消费不会成为瓶颈,除非消费者自己内部处理消息的代码太慢。这种情况下,消费者需要增加Consumer机器,确保消费速度跟得上发送速度。 上面的测试结果是基于UCloud的虚拟机,由于我没有物理机,所以不知道物理机上的性能。有条件的朋友我非常希望能帮忙测试下哦。像这种重要的分布式消息队列服务器,是应该部署在物理机上的。另外,我测试时,Producer和Consumer服务器使用的是普通的虚拟机,尽量真实的模仿一般应用的虚拟机配置。 测试结论 从上面的测试结果可知: 随着消息体的不断增大,发送消息的TPS不断降低,一般我们的消息大小为1KB,在有两个发送者客户端时,EQueue的发送消息TPS大概为31000。我觉得性能上应该满足大部分需求场景了。如果一个系统平均每秒产生31200个消息,一天是86400秒。那一天的消息量是27亿。这个数字已经很大了,呵呵。相信国内大部分网站都没有这么大的规模。当然我们不能这么简单的来算,实际的系统肯定有高峰期的,实际大家要根据自己的系统的高峰期发送消息的TPS来衡量具体要部署多少台EQueue Broker服务器。线上环境使用时,我们不能把Broker压满的,要给其有足够的处理余量,比如高峰期发送TPS为10W,每台Broker最大能承受3W,那我们至少要分配6台机器,确保应付可能出现的高峰。这样才能应付特别高的高峰。 大家可以看到上面的数据中,开两个生产者,两个消费者,消息大小为2KB时,Broker的进出总网络流量为99MB,这个数据还没有到达千兆网卡的总流量(125MB)。目前,我只申请了5台机器,4个客户端机器。但实际我们的应用的集群,客户端机器应该会更多,我不知道当客户端机器增加时,能否把Broker的网卡压满,如果压不满,则说明Broker本身的设计实现已经到达瓶颈了,也就是说EQueue Broker本身设计上可能还有性能优化的空间。 在上面的性能数据情况下,所有服务器的CPU,内存使用稳定,符合预期。在稳定性方面,我在个人笔记本上同时发送和消费消息,连续运行1天没有问题;也有其他网友帮忙测试了稳定性,运行几天也没有问题。各项指标均正常。所以我认为目前EQueue的稳定性应该问题不大了。 Broker的消息持久化,采用的是顺序写文件的方式;目前的测试结果来看,完全还没达到写文件的瓶颈;如果我们单独测试写文件模块的性能,可以轻松达到300MB每秒,而我们的千兆网卡最大流量也只有125MB。所以,消息持久化不会再成为很大的瓶颈了,但写文件的响应时间本身也会影响Broker对外的TPS,目前的响应时间为60ms,我感觉是比较慢的,不知道是否是虚拟机的原因,不知道物理机上会如何。我觉得SSD硬盘,写入文件的响应时间不会这么慢的。 最后一点,当Broker的客户端增加时,Broker的CPU的使用也会相应增加一点。这个可以理解,因为TCP长连接的数量多了一倍。而生产者和消费者服务器的CPU内存的使用都非常稳定,符合预期。
要持久化的关键数据有三种 消息; 队列,队列中存放的是消息索引信息,即消息在文件中的物理位置(messageOffset)和在队列中的逻辑位置(queueOffset)的映射信息; 队列消费进度,表示当前队列中的消息消费到第几个了; 发送消息的设计 producer将消息的二进制数据发送到broker; broker做的事情: 单线程持久化消息到内存映射文件; 将当前消息的索引信息放入缓冲区,可以使用disruptor的ringbuffer实现,单线程写,无锁。 单线程从缓冲区读取消息索引信息,并将索引信息写入内存映射文件; 消息的内存映射文件、消息索引的内存映射文件都定时刷新到磁盘,比如每隔1s刷新一次,可配置; broker将当前消息的索引信息放入缓冲区后,就立即返回了,然后producer就收到了消息发送的结果; 其他说明: 因为不可能用一个文件来保存所有的消息,所以肯定是用多个文件的方式。也就是说,无论是保存消息还是保存消息索引,都用多个文件。另外,由于队列有多个,所以每个队列都对应多个内存映射文件。队列文件的目录命名规则:rootPath / topic / queueId / queue mapped files broker在将消息的索引信息放入缓冲区时,要检查缓冲区是否到达一定的水位,比如ringbuffer总大小100W个槽,假如水位是80%,那就是当现在ringbuffer中可用的槽不到20%时,应该要做流控,比如sleep 100s;理论上应该不会到达水位,因为写消息索引肯定比写消息本身要快; 消费消息的设计 consumer告诉broker当前需要拉取哪个topic下的哪个队列里的第几个位置(queueOffset)开始的消息,并告诉要最多拉取多少个消息; broker根据topic和queueId找到对应的队列; 根据queueOffset从队列拿到消息在文件中的物理位置,即messageOffset; 根据messageOffset从消息的内存映射文件获取消息二进制数据; 将消息二进制数据写入临时的内存流里,该内存流里包含了所有要返回的消息; 消息拉取数量达到要求或没有新的消息可以拉取后,将内存流对应的二进制数据返回给consumer; consumer解析二进制数据,得到所有的消息对象; broker定时清理过期的消息和消息索引 每隔10s扫描是否有过期的消息文件,过期时间可配置,比如三天;扫描时,发现文件的最后修改时间是3天前,则删除; 每隔10s扫描是否有过期的消息索引文件,判断是否过期的依据是扫描每个消息索引文件,判断该文件中的最后一个消息索引的messageOffset是否比最小的messageOffset还要小;如果小,就说明这个消息索引文件已经无意义了,可以删除; broker启动时的逻辑 扫描磁盘上所有的消息的存储文件,为每个文件建立内存映射; 扫描磁盘上所有的队列(消息索引)的存储文件,为每个文件建立内存映射; 对每个队列,预恢复几个文件(比如最后的3个文件)的数据到内存,剩余的用到时再恢复; 同理,对于存储消息的文件,也预恢复几个(比如最后的3个文件)到内存;一般大部分消息者只要消费进度不是太慢,总是应该已经赶上了最后那三个文件了; 关于异常关闭broker时的逻辑,暂时还没想清楚,还需要再细思;
1.发送消息吞吐量的测试: 1)单台producer单个进程的发送消息tps 2)单台producer多个进程的发送消息tps 3)单台broker的接收消息tps,由于单台producer可能压不满,所以需要可能两台producer来发消息 2.消费消息吞吐量的测试: 1)单台consumer消费消息的tps 2)两台consumer消费消息的tps 3.同时发送和接收消息的吞吐量、消费延迟的测试: 1)单台producer发送消息,单台consumer消费消息 2)两台producer发送消息,两台consumer消费消息 服务器配置: broker: c3.2xlarge, 8核15G内存 (比一般的主流MQ服务器要配置差一点) producer: m3.xlarge, 4核15G内存 (一般的主流应用服务器配置) consumer: m3.xlarge, 4核15G内存 (一般的主流应用服务器配置) 网络带宽: 千兆 消息大小: 1K(一般消息的大小1K应该差不多了) 用例测试时间: 1、2两种,只测试性能,时间不会太长,估计1小时足矣(AWS按使用小时计费,真是时间就是金钱那!); 第3种中的1),除了测试性能,还要测试稳定性,打算用6小时; 亚马逊AWS支持测试完成后,查看CPU,内存,IO的使用曲线,这点对出报告非常方便。
前言 前面的文章,我介绍了Conference案例的业务、上下文划分、领域模型、架构,以及代码整体流程。接下来想针对案例中一些重要的场景,分别做进一步的分析。本文想先介绍一下Conference案例的核心业务场景 - 订单处理减库存的设计。 下单以及订单处理流程描述 下单过程 预订者浏览某个已发布的会议; 进入会议的详情页面,该页面显示了所有可预订的座位分类信息; 预订者选择好要预订的座位分类,录入每个分类的预定数量; 预订者点击提交按钮,提交下单请求到Server端; Server端订单处理过程 Server端Controller提交处理订单的命令到分布式消息队列(EQueue),然后后台的Command Processor就可以消费该命令并异步处理订单了。核心处理步骤: 生成订单(初始状态); 扣减库存(内部有预扣逻辑); 更新订单状态; Server端Controller发送命令后,立即重定向页面到查单订单处理结果页面,该页面会以轮训的方式查看订单处理结果; 用户等待订单处理结果 如果下单成功(库存足够),预订者被导航到支付页面进行支付;预订者可以选择支付,也可以放弃支付; 如果下单失败(库存不足),则提示用户下单失败,因为库存不足; 如果轮训等待超时,则告诉用户暂时无法知道订单处理状态,然后当前页面继续定时(5s)轮训订单处理结果; 用户支付订单 如果支付成功,则提示预订者订单处理完成,交易完成; 如果拒绝支付,则关闭订单; 如果超过规定时间(15分钟)未支付,则视作订单已过期,系统自动回收订单所预定的座位; 流程结束; 上面用文字描述了整个下单和订单处理以及支付的过程,而我们实际关心的核心还是服务端对订单处理的过程。所以下面我们就看看如何来进行代码实现。 订单处理Saga流程图 Conference案例中,服务端处理订单是采用CQRS Saga流程的方式实现的。一个Saga流程是一个事件驱动的业务流程,它的周期可能比较长,因为流程中某些步骤需要用户参与的。上图描述了服务端处理订单的正常处理逻辑。为什么说是正常处理逻辑,因为实际的代码比上面的流程图还要复杂一点,上面的流程图中没有画出库存不足、用户拒绝付款、或者付款超时等情况的处理。我觉得这些特殊的情况,只要读者自己看一下代码就能很快理解了。只要我们能够把正常的逻辑搞清楚,那我们心里就对整个订单处理的流程有了解了。 上图中,聚合根之间棕色的箭头表示Command,蓝色的箭头表示Event。Order Process Manager表示一个无状态的Saga流程管理器,它负责协调其他有状态的聚合根,负责整个订单处理的流程控制逻辑。从代码表现上来看,它的任务就是响应Event,然后发出下一个Command。然后Order, Conference, Payment三个聚合根分别表示订单、会议、支付。这三个聚合根分别封装自己的状态和业务规则。 订单处理之减库存的设计思路 库存信息在哪里维护 大家都知道,电子商务系统,订单处理时,核心的环节就是减库存。那我们首先要思考的问题是,库存在哪里维护呢?在我看了微软的Conference案例的代码后,发现它的库存信息是在Registration(订单处理)的上下文中维护的。当ConferenceManagement(会议管理)上下文中,对会议的库存有修改时,会通过事件异步同步到订单处理上下文。我在思考它这样设计的理由是什么,我能想到的唯一理由是,这样的好处是减库存时,就只需要在Registration当前的上下文中处理即可,这样就不需要依赖会议管理上下文了。但代价就是需要从会议管理上下文同步库存信息。 我个人认为,库存信息还是应该在会议管理上下文中维护比较合理,因为会议管理上下文的职责就是维护会议的基本信息以及会议的座位类型的实体信息。如果我们的库存管理没有独立为独立的上下文,那最合理的维护地方就是会议管理上下文。这样,一份数据就只需要在一个地方维护,不需要同步。然后当订单处理上下文需要减库存时,可以通过远程调用或者异步消息通信的方式来实现上下文之间的交互。 但实际的电商系统,比如像淘宝这种,由于库存管理也是一块复杂的业务,所以一般会独立出一个上下文,叫库存中心。然后这个库存中心独立于商品中心以及订单中心。当订单中心要求减库存时,只需要和库存中心进行交互即可。这样的方式,会让系统的职责更明确,商品中心不需要关心商品的库存了,只需要关注商品本身的属性信息即可。然后,本案例由于只是案例,所以没有独立出库存中心,即库存上下文。所以会议座位的库存管理放在会议管理上下文中。 我当初看微软的例子,第一反应就觉得把库存放到订单上下文不合理,因为我没见过这样的设计。然后我看到会议管理上下文里,它也对会议作为的库存做了管理,而且是源头(库存的第一手数据在会议管理上下文产生),另外,会议管理上下文还会发布会议。所以,这些都让我意识到,会议管理就是商品中心和库存中心的结合体。但是让我费解的是,微软自己自相矛盾了,居然为了bc之间尽量解耦,居然把库存信息同步到订单上下文了。这样的设计导致代码非常丑陋,我认为再怎么样也不能把库存放到订单上下文里。所以,最后才有了我的enode的conference这样的bc的划分的考虑。再联想到阿里的电商平台,库存上下文是独立于订单上下文的。而我这里的实现,只是偷懒了(因为只是案例),没有把库存上下文独立出来而已。 所以,库存上下文是合并到订单上下文比起合并到商品中心上下文更不合理。 库存怎么预扣 明确了库存所属的上下文后,我们接下来思考怎么实现减库存。减库存主要的问题是,在并发减库存的情况下,可能会出现超卖的情况。为了解决超卖的问题,一般主流的做法是采用预扣库存的方式,类似分布式事务的二阶段提交的过程。预扣的意思是先预扣库存,如果预扣成功,就可以通知用户下单成功,然后就可以让用户去付款了;如果预扣时发现库存不足,则提示用户库存不足。 然后,虽然是预扣,但因为大家同时预扣同一个会议聚合根的座位库存,所以还是会产生并发问题。但由于我们操作的是同一个聚合根,所以ENode框架帮我们确保不会有并发问题。我们先看看Conference聚合根内部关于座位的库存管理的设计实现。 如上面的代码所示,Conference聚合根里聚合了所有的座位类型子实体,每个座位类型维护了座位的名称、价格、数量;然后Conference聚合根里还维护了所有的预定记录,这个应该不难理解。MakeReservation方法就是Conference聚合根对外提供预定座位支持的方法。该方法接收一些要预定的项,以及一个预定的ID,表示这次预定是谁(实际上就是订单ID)要预定。该方法内部的逻辑是: 判断当前会议是否没有发布,如果没有发布,那是不允许预定的; 判断这个预定(reservationId)是否是重复预定,如果重复,也会抛出异常;为什么会出现重复预定,因为订单处理上下文是通过发送命令的方式来通知Conference进行预定的,而由于是分布式消息队列(EQueue),所以命令可能会被重复执行。 检查预定的座位明细是否为空,如果为空,就认为是无效的预定,抛异常; 接下来就是循环处理每个预定项,先检查预定项本身需要预定的数量是否无效(小于等于0),如果无效,则抛出异常;再从Conference聚合根里找到当前要预定的座位类型子实体;然后计算当前的座位类型是否有足够的可用库存,GetTotalReservationQuantity方法就是计算当前该座位类型总共已经预定的总数。如果库存不足,则直接抛出异常。当然这里直接抛出异常可能还是太草率了一点。因为真正的电子商务系统,应该会明确提示用户,哪些商品库存不足,是否要修改订单只购买剩余的库存。本案例为了让代码不会太复杂,所以简化了功能。只要被预定的座位类型出现一个库存不足,就认为下单失败了。 当所有的预定项都处理完成后,就可以产生“已预定”的领域事件了。注意,这里我们产生事件的时候,同时把当前每个座位类型剩余的库存数量也放在领域事件里了。这样的好处是,当Q端的Event Handler在更新Conference的读库时,不需要再计算了,直接用Update语句更新DB即可。这个设计大家可以参考下,这样的设计,体现了Domain中封装了一切数据更新的业务规则判断和逻辑处理,然后通过事件的方式通知Domain外部当前事件发生后,聚合根的当前状态(一定是第一手数据,不会是脏数据)是什么。这样外部的Event Handler的逻辑就非常简单了,都只需要简单的用Update语句更新DB即可(不用动脑子,呵呵)。 并发问题的处理 Domain不会考虑并发这种技术问题,它只关心自己的业务规则和数据一致性,完全从业务角度来写代码。我们可以看到Conference聚合根里封装了很多的规则和逻辑。然后Conference聚合根产生的Event持久化到EventStore时的并发问题,ENode框架会帮我们解决,应用开发者不用担心了。如果大家关心是怎么解决的,可以去看一下ENode我以前写过的一些介绍,核心思路是乐观并发控制(通过聚合根版本号)+ 自动重试的机制,这里我就不展开了。 通过上面的设计,我们知道每次预扣时总是会判断当前可用的库存,并且已经考虑了其他已经预扣了的订单;这就从业务逻辑上保证了不会出现超卖;然后ENode框架解决了并发问题,所以最后我们可以确保一定不会出现超卖的情况。 用户付款后怎么真正减库存 当预扣成功后,用户就会去付款,假如付款成功了,那系统就会自动提交之前的预扣记录,做真正的减库存。我们来看看逻辑是怎么样的: CommitReservation方法是Conference聚合根用来提供支持提交减库存的方法。它接收一个要提交减库存的reservationId,通过该ID,先找到之前它预定的所有预定项,然后产生一个事件,事件中包含每个预定项所对应的座位类型的扣除后的库存数量,最后产生领域事件。然后聚合根内部会响应领域事件,更新聚合根自己的状态。我们在Commit阶段是不用担心数据有什么问题的,因为肯定是之前预扣过了,只要预扣记录存在,那就可以放心的做减库存逻辑的。这是我们通过业务上的2PC协议保证的。 代码很直接,就是先删除预定记录,并把预定记录的每个明细对应的座位类型的库存更新即可。然后,我们的读库的更新也是这样的逻辑,只是更新的是读库DB而已。 结束语 好了,本文基本把订单处理的核心环节减库存讲了一下,本来还想再结合订单状态的变更讲一下订单状态在这个过程中是如何变化的。但由于今天时间比较完了,不准备讲了。我在前面的领域模型的介绍中,已经基本讲了。
前言 前一篇文章介绍了Conference案例的架构设计,本篇文章开始介绍Conference案例的代码实现。由于代码比较多,一开始就全部介绍所有细节,估计很多人接受不了,也理解不了。所以,我先进行一次QuickStart的介绍,即选取某个简单典型的场景从前到后过一下每个环节。这样大家就能够快速对代码的重要关键环节有大概的理解。另外,我现在正在做ENode的官网,到时会像axon framework一样,介绍ENode框架本身、使用场景、性能数据、案例,以及论坛社区等功能; 本文打算选择Conference案例中一个不太复杂的场景(发布会议),来快速过一下需要开发者实现的代码环节。 UI 首先,我们来看一下发布一个会议的UI入口,前面的文章介绍过,当客户创建好一个会议后,他可以先编辑会议的所有座位类型,然后如果允许预订者预定了,那他就需要先发布这个会议。就像淘宝的卖家要上架商品后商品才会对买家可见一样。本质上,发布一个会议,其实就是将会议聚合根的isPublished修改为true。UI如下图所示: Controller 当客户点击Publish按钮后,前台提交HttpPost请求到服务端,然后请求就会被ASP.NET MVC的Controller处理,Controller的Action的逻辑如下: 上面的代码比较清晰,我们先判断当前的_conference实例是否为空,如果为空,则直接返回HttpNotFound结果。那_conference实例哪里来的?这里考虑到ConferenceController中的大部分Action都要使用当前的_conference实例,所以我们为了代码的复用,在Controller的OnActionExecuting方法里,提前获取了当前的_conference实例,代码我稍后再贴。_conference实例有了之后我们就可以构建一个PublishConference的命令。该命令只需要一个当前要发布的Conference的ID即可。然后,我们调用ExecuteCommandAsync方法,去异步执行一个命令。然后,我们使用await关键字异步等待命令的处理结果。最后判断结果是否成功,做相应的处理。 ExecuteCommandAsync方法: 该方法内部使用_commandService的ExecuteAsync方法来异步执行一个命令。_commandService是ENode框架提供的分布式的命令发送或执行服务,该服务是通过ConferenceController的构造函数注入的,代码如下: 使用ENode框架开发的Controller,一般是需要两个服务,一个是commanService,用于发送命令;另一个是某个queryService,用语查询数据;Controller依赖这两个服务充分体现了CQRS架构的特点。当然,有时查询服务可能不止一个,那就可以注入多个,看我们自己需要即可。ENode使用的依赖注入框架是Autofac。大家可能在想,为何要弄一个ExecuteCommandAsync方法出来呢?因为要处理超时的情况,假如一个命令处理超时了(比如5s),那Controller的Action也需要立即返回了。TimeoutAfter的代码如下: 大家可以看到TimeoutAfter方法内部,为了实现当超过指定时间后要求的Task还未处理完的情况,我们创建了一个延后指定时间执行的Task,然后通过Task.WhenAny方法异步等待任务执行,最后判断完成的Task是哪个,从而实现超时的处理。这个做法是我在网上找到的,觉得还不错,这个做法可以让我们在实现完全异步的同时还能实现超时处理。最后,我们看一下OnActionExecuting方法: OnActionExecuting 这个方法的代码的逻辑也比较简单,就是根据HttpRequest中包含的slug参数先获取一个Conference聚合根;如果存在,则进一步根据accessCode参数检查accessCode参数是否合法。通过合法,则认为提供的slug和accessCode有效。大家可以把slug理解为唯一定位一个Conference的,而accessCode是使用该Conference的密码。由于这个只是一个案例,所以我们通过这种简单有效的方法来为用户授权。 Command 了解了Controller的实现,我们接下来看看Command的定义,Command是一个DTO对象。代码如下: 非常简单,由于ENode框架基类提供的Command类已经提供了一个AggregateRootId的属性,所以我们的PublishConference命令无需再定义其他额外的属性了。需要提一下的是,ENode框架要求,所有Command要创建或修改的聚合根的ID必须在Command发送之前赋值,这个是框架的一个约束,我认为这个通常不是问题。如果你希望聚合根的ID是一个long,那也许你需要自己部署一个全局long生成服务了,有兴趣的朋友可以和我交流实现方案,我有实现经验。如果你的ID是一个字符串,那用ENode框架提供的ObjectId类即可,它可以帮你自动生成一个24位长度的全局唯一顺序字符串。接下来我们看看Command Handler的实现。 Command Handler 一个CommandHandler中的代码通常是一句话,ENode框架的最大好处是可以让开发者无需关注C端的技术实现,开发者只需要关心如何实现自己的业务逻辑即可。如上图所示,我们会先定义一个ConferenceCommandHandler的类,然后实现ICommandHandler<PublishConference>接口,然后进一步实现对应的Handle方法。在Handle方法内部,我们只需要从当前的上下文根据Command所关联的聚合根ID获取当前要操作的聚合根,然后调用聚合根的业务方法即可。我们不需要像经典DDD那样把聚合根从IConferenceRepository中取出来,再修改聚合根,再保存聚合根。并且经典DDD往往还会和工作单元(Unit of Work)配合;因为经典DDD,是支持一个应用层的方法同时修改多个聚合根的,而ENode框架是要求一个命令一次只能创建或修改一个聚合根,即架构设计上就是面向最终一致性的,主要目的为了实现更高的吞吐量,这点开发者需要明确与了解。CommandHandler,从代码实现的角度,我相信ENode框架提供的方式是非常简单和直接的,没有任何多余的东西。大家可以看到使用ENode框架开发,大部分情况是不需要定义Repository的。下面我们来看看Domain聚合根的实现。 Domain & Domain Event 使用ENode框架开发的领域模型,聚合根的实现通常是这样的: 继承基类AggregateRoot<TAggregateRootId> 构造函数要传给基类聚合根的ID 然后聚合根自己会提供一些可以修改自己状态的公共方法,例如上面的Publish,Unpublish方法 聚合根内部会有一些私有的Handle方法,这些Handle方法是根据对应的事件,更新自己的状态(事件驱动聚合根状态的修改) 当前我们这里被调用的方法是Publish,该方法内部,先判断当前聚合根是否已经处于发布状态,如果是,则抛出异常即可,当然,你选择忽略也没问题;如果不是,则调用ApplyEvent方法Apply当前领域事件。ApplyEvent方法的逻辑是,先找到当前事件对应的Handle方法,然后调用该Handle方法;然后调用完成后,把当前事件放入一个聚合根内部的事件队列中。 如果对ENode框架的实现有一定了解的朋友应该知道,ENode在处理一个命令时,ENode框架处理Command的核心流程是这样的: 先创建一个空的ICommandContext对象; 调用CommandHandler的Handle方法,并把ICommandContext传给Handle方法; 当Handle方法结束后,ENode框架就能知道当前ICommandContext中有哪些聚合根修改了或创建了(框架要求一个命令一次只能涉及一个聚合根的修改)然后框架如果拿到了某个修改的聚合根,它就拿出该聚合根里上面提到的内部的事件队列里的事件。然后根据这些事件生成一个EventStream的对象; 持久化EventStream对象到EventStore; 发布(Publish)EventStream到EQueue,然后外部的Event Handler就都能响应领域事件了; 上面这个是正常流程,在这里我顺便提一下,为了让大家更好的理解内部实现的机制。通过上面这些介绍,我想大家应该至少可以理解上面的Publish方法和Handle方法了吧。 另外,有些朋友可能会想,为何是先产生事件,再修改状态呢? 主要原因是因为这个Handle方法是会在事件溯源(ES)的时候被重复利用的。当我们要从EventStore通过ES还原某个聚合根时,我们是先从EventStore获取该聚合根所产生的所有的事件,然后对每个事件调用聚合根的对应的Handle方法,从而实现聚合根状态的还原。这个过程也就是我们常说的事件溯源,即ES(Event Sourcing)。 需要强调的是,聚合根应该在产生事件之前把各种业务规则和业务逻辑实现掉,然后只有当前操作满足所有的业务规则时,才调用ApplyEvent方法。然后在聚合根里的所有Handle方法中,就是仅仅简单的等于号赋值操作,不能有任何业务逻辑,这点非常重要。为什么要这样呢?因为假如我们把一些业务规则和逻辑放在Handle方法中,比如if怎么样的时候做什么赋值,else的时候做另外的赋值。那假如哪一天我们的Handle方法里的判断逻辑变化了,那我们通过事件溯源还原出来的聚合根的状态就不对了。这点应该不难理解吧。 从更高层面(哲学)的角度来理解,EventStore中存储的事件并不是完整的历史。事件+聚合根的Handle方法才是完整的历史,两者结合才可以完整地将聚合根的状态还原到最新状态。因为是历史,历史无法改变,所以我们的事件和Handle方法也都不能修改;或者如果真的要修改,也必须确保兼容老的结构和实现,这点非常重要。下面我们来看看Event Handler的实现: Event Handler EventHandler的作用是根据C端聚合根产生的事件来更新CQRS的读库。需要注意的是ENode整个框架对外提供的API基本都是异步IO的(实际上内部的实现也都是异步IO的,只有整个链路都是异步得,才能发挥异步的好处)。所以我们更新读库时,需要使用ADO.NET提供的Async方法类更新DB。这里我使用ENode自带的Dapper轻量级高性能ORM来实现对读库的更新。上面的代码中就是更新Conference表的IsPublish字段。但是为了确保避免并发导致的数据覆盖,所以我们需要严谨的利用乐观控制来确保数据不会被覆盖,ENode要求我们使用Version机制来实现乐观锁。 关于并发控制的讨论,其实还有非常多的细节可以讨论。我之前写过一篇文章,大家有兴趣的可以去看一下,本文的目的是做一个QuickStart,所以不做过多展开了。TryUpdateRecordAsync方法的内部实现如下,很简单,我就不做介绍了。 还有一点需要特别提一下,就是为何要使用Dapper而不使用EF这种ORM框架。因为ENode框架实现的是CQRS+ES的架构。所以,我们在更新读库时,是根据事件更新读库。那怎么样的更新是最快的呢?就是直接通过Insert或Update语句来更新DB。而如果通过EF这种框架,因为是面向OO的ORM,所以一般是需要先从DB取出数据转换为对象,再更新对象,再保存对象这样的思路。这个过程我个人认为,对于CQRS+ES架构的应用来说,是比较繁琐和低效(2次IO,先读出来,再保存回去)的。我们在更新读库时,更好的方式应该是利用像Dapper这样的ORM框架,简单直接的更新读库(一次IO操作即可)。另外,我通过对Dapper做了一些简单的二次封装,可以做到用最直接的代码实现目的,且兼顾了代码的可读性、可维护性、灵活性,以及性能。另外,查询数据时,通过Dapper也非常简单,而且还支持返回dynamic对象。Dapper是基于约定的框架,不需要做ORM映射方面的配置。我个人认为使用在CQRS+ES架构中是非常合理的。所以,对我来说,EF可以退休了,呵呵。 总结 好了,上面介绍了发布会议的所有需要用户写的代码,是不是很简单呢?我个人认为和经典DDD的架构相比,由于有ENode框架的支持,所以开发基于CQRS+ES架构的应用,是非常简单的。下一篇要写什么还没想好,大家还想了解什么,可以及时给我反馈啊。
ENode是一个.NET平台下,纯C#开发的,基于DDD,CQRS,ES,EDA,In-Memory架构风格的,可以帮助开发者开发高并发、高吞吐、可伸缩、可扩展的应用程序。 开源项目地址:https://github.com/tangxuehua/enode ENode可能的应用场景如下: 当你正在找一个DDD的开发框架时,可以考虑ENode; 当你想找一个CQRS架构的实现框架时,可以考虑ENode;当你的系统具有大量的写入,同时又有更大量的读取时,只要系统能接受写入数据和读取数据的最终一致性(秒级),那就可以考虑使用ENode;ENode可以让我们对读写两端做不同的技术架构,分开优化,互不影响; 当你的业务场景从用户的角度去看,读和写操作就相互明确区分的,就是用户在写入或更新一个数据后不需要立即看到结果的场景,那就可以考虑使用ENode; 当你想实现CQRS,但还是想能在Command发送后可以同步获取Command处理结果时,可以使用ENode; 当你要实现ES模式时,可以使用ENode;ES的最大好处是整个系统的所有数据的变化都可以追溯其历史,我们可以把数据还原到任意的某个历史状态; 当你要找一个异步的、事件驱动的应用开发框架时,可以考虑ENode;ENode在实现EDA架构的同时,可以帮助我们自动从架构层面解决消息的幂等处理、消息不丢,以及并发处理等技术问题; 当你希望你的应用能支持高并发、高吞吐、可伸缩、可扩展这些非功能性需求时,可以考虑ENode; 当你需要找一个Saga开发框架来代替分布式事务时,可以考虑使用ENode; 只要你的系统需要满足以上的若干点,就可以考虑选择使用ENode。
前面一片文章,我介绍了Conference案例的核心业务,为了方便后面的分析,我这里再列一下: 业务描述 Conference是这样一个系统,它提供了一个在线创建会议以及预订会议座位的平台。这个系统的用户有两类:1)客户,可以创建和管理会议;2)会议座位预定者,可以预订会议座位。具体的关键业务描述如下: 客户创建一个会议,并录入会议的基本信息,比如名称、时间段、地点,等;会议创建后,系统会为客户自动生成一个AccessCode,客户可以通过AccessCode访问自己创建的会议; 客户定义某个会议的座位类型,可以定义多个,每个座位类型包含的信息有:名称、座位价格、座位数量; 客户发布或取消发布某个会议,当一个会议发布后,预订者就可以在线预订会议的座位了;如果取消发布,则该会议对预订者不可见;只有未发布状态的会议才能修改; 预订者在预订会议座位时,会生成订单,订单需要进行支付才会生效; 订单生成后,预订者可以有15分钟的时间付款,超过15分钟,订单预定的座位就会回收,允许其他人预定; 订单生成后,系统会为预订者生成一个AccessCode,用户可以通过AccessCode查看自己的订单; 预订者成功预订了座位后,可以指定每个座位的实际参会人信息; 客户(会议的Owner)可以管理他创建的每个会议的所有订单,比如可以查看该会议的所有订单以及参会人信息,以方便联系参会人; 上下文 从上面的业务描述中,我们知道,这个系统主要有三大块:1)会议管理;2)预定会议;3)支付。然后本文的目标是要对领域进行建模。而在建模之前,我们需要先划分模型的上下文边界。 我对上下文的理解 DDD的关键思想是领域建模,领域就是问题域,领域建模的结果就是领域模型。而一个上下文定义了一个领域模型的应用范围,即边界,它规定了什么应该在领域模型内,什么不应该。同时,由于上下文划定了领域模型的边界,所以由领域模型而衍生出来的代码实现、团队成员、数据库,以及UI等元素,也都被划定在这个上下文里面。也就是说,每个上下文里的东西都是自成一体的,解决某个特定问题的,它的领域模型以及解决方案(每个上下文的解决方案或架构可以完全不同,数据存储也可以完全分离),以及团队成员不会受到外界其他上下文的干扰。上下文之间可以通过外层的adapter(适配器,防腐层)来相互交换数据,进行通信,即我们平时所说的context-mapping。 另外,我觉得上下文的划分是一个动态的过程。动态的意思是,当系统随着时间的推移,变得越来越复杂的时候。也许这个系统一开始只有一个上下文即可,这个上下文中有一个领域模型,然后这个领域模型中的所有概念都很明确,无歧义,所以这个领域模型可以有效的解决当下的领域问题。但是,随着系统的业务越来越复杂,比如系统中的某几块业务都变得不断复杂,我们再通过一个大的领域模型就难以有效的解决领域的问题。所以我们需要对领域进行分解,然后每个领域对应的领域模型会有各自的上下文。比如,淘宝原来的商品中心、订单中心、库存中心也许是在一个上下文中,但随着库存管理的复杂,订单处理的复杂;我们慢慢将商品中心、订单中心、库存中心划分到了不同的上下文中。然后每个上下文内各自解决各自的领域问题。商品中心只关心商品的基本信息管理和展示;订单中心只关注订单流程的处理;库存中心则维护商品的库存;然后这三个上下文通过系统集成的方式进行交互。所以,我们通过这种分而自治的思想,对不断变复杂的领域问题,进行切分,然后持续集成。 其实划分上下文,我觉得也是和我们OO中提出的高内聚、低耦合的原则是一致的。用上下文,将一个大领域模型划分为多个小领域模型,每个小领域模型都具有很高的内聚性,容易维护,自成一体,独立演化。同时,由于上下文维护了每个小领域模型的边界,边界之间不会直接通信(通过防腐层),所以做到了低耦合。所以,我们可以看到,没有划分出上下文,我们根本无法开始DDD。我们在寻找聚合根、实体、值对象这些东西前,首先要划分好上下文。 上下文划分思路 上面说了一下我对DDD中的上下文的看法,那我们要如何划分上下文呢?我个人觉得划分上下文是一件很难的事情,因为可能完全要凭我们的经验,没有明确的放之四海都正确的方法论。所以,划分上下文在我看来,更多的是一个主观的思考过程。也许这就是软件设计的魅力所在吧,或者说是我们程序员的价值所在,呵呵。但有一点我认为是非常肯定的,那就是划分上下文之前,我们必须要对我们的业务有很深入的理解。那如何来理解业务呢?一种可行的方法就是从场景入手,通过分析场景用例来理解业务。然后我们可以从用例的所实现的业务功能的角度对用例进行分类,然后不同的分类就代表了不同的上下文。 前面我提到,上下文内部是高内聚的,上下文之间是低耦合的,这个只是表象,或者说是我们希望达到的目的,它并没有指导我们如何划分上下文。我觉得划分上下文最根本的出发点就是从业务功能的角度来划分。因为我们通常都很清楚,哪些功能应该在一起实现,哪些功能最好不好和其他功能掺和在一起。比如我们希望处理订单的时候,最好不要直接和支付系统耦合,尽管处理订单时,必须要和支付系统打交道。为什么我们认为处理订单的部分不要直接和处理支付的部分耦合呢?因为我们通常认为,这是两件不同的事情。关于这点,又可以用OO的另一个原则来解释,就是单一职责原则。单一职责原则告诉我们,一个类只做一件事情,上升到上下文,我想也同样适用。如果我们的一个上下文只做一件重要的事情,那这个上下文的命名也就比较容易,比如ConferenceManagement BC, Registration BC, Payment BC。当你发现要为上下文命名时,需要用好几个单词组合起来才行的时候,很可能是你的上下文划分的过大了。 Conference上下文划分 上面我们了解了上下文的概念以及划分思路,现在我们可以开始我们这个案例的上下文划分了。首先,我们先找出上面的业务描述中的关键业务场景: 客户创建会议; 客户管理会议座位; 客户发布会议; 预订人预定会议座位; 预订人支付订单; 预订人填写参会人信息; 客户查询会议的订单信息; 上面这几个我觉得就是这个系统的主要业务场景了。然后我们分析这些场景的功能,看看有哪几类。我们发现1,2,3,7这几个场景,都是由客户发起,并且做的事情都是和管理会议相关。所以我们可以认为它们表示的是同一类功能,即会议管理相关的功能;我们可以把它们放在一个上下文中,我命名为会议管理上下文。然后预订人操作的有4,5,6三个场景。那这三个场景应该在一个上下文中吗?仔细看一下,我们感觉,支付订单这个场景,最好能独立出来,因为支付的过程和逻辑往往需要和第三方系统交互。如果直接把支付耦合到处理订单的过程逻辑中,那会让订单处理的逻辑边界不清楚。所以,我们希望通过一个独立的上下文来封装和第三方支付系统交互的逻辑。这样的话,以后支付和订单处理这两块业务,都可以独立演化,互相不受影响。所以,我们又识别出了两个上下文:预定上下文、支付上下文。 到这里,我们划分好了Conference这个系统的上下文。接下来,我们开始来进行领域建模。 领域建模 领域建模的目标就是要设计出领域模型。首先我想说明一下,我所理解的领域模型是不包括领域服务、仓储、工厂这些东西的。而仅仅只有聚合,聚合根、实体、值对象这些概念。因为我认为领域服务、仓储、工厂这些是属于领域层的,领域模型不等于领域层。所以,下面我的目的是要设计出领域模型,即找出有哪些聚合,以及每个聚合里包括的内容。 我的领域建模的主要方法是,从场景出发,分析出每个场景中出现的领域概念,然后分析哪些概念是场景中的核心概念,哪些是相对次要的或者仅仅是描述性的概念。从而一步步识别出每一个聚合根。找出了聚合根之后,我们再去关心我们对每个聚合根关心哪些信息,把这些信息作为聚合根上的属性(值对象)即可。这样说可能还是比较含糊。我从另一个角度来说明一下:一般一个业务场景的结果都是核心聚合根,然后参与这个场景的所有角色的扮演者往往也是聚合根,这些聚合根最后就体现在前面我说的核心聚合根的某个属性或者子实体一部分(值对象)。比如一个买家在淘宝上下一个订单,那下订单的结果就是订单核心聚合根,然后订单由买家信息和卖家信息,这些是订单的值对象。另外,订单里还有订单明细,每个明细是一个子实体,然后订单明细里包含了商品信息,商品信息是订单明细的值对象。简单的说,这种分析思路是一种面向结果的分析法。我个人认为很有效,至少对我比较有效。 下面我们来看看上面所描述的业务,出现了哪些领域概念: 这个图就是上面业务描述中加粗字体的那些领域概念,这里只是对领域里出现的概念的罗列,并不是最后的领域模型。罗列这些概念,有助于我们不会在建模的过程中漏掉一些明显的概念从而导致漏掉一些聚合根的识别。 上面,我们已经知道了有哪些业务场景,下面我们分析一下每个业务场景的结果聚合根和参与者聚合根。 业务场景之 - 客户创建会议 场景结果:会议,即一个Conference对象; 场景参与者:客户; Conference肯定是聚合根了,因为它是场景的结果。那客户是聚合根吗?不是,因为在我们的系统中,相对于会议来说,我们只关心会议是谁创建的,即会议的Owner。所以,对于Conference聚合根来说,客户只需要作为Conference聚合根的一个值对象属性即可。另一方面,我们的系统中不会对所有的客户进行维护,也就是说,客户不需要有生命周期。所以,客户不是聚合根。 另外,当这个会议有一个新的订单产生后,由于订单会先进行预扣库存的处理。所以,需要Conference支持预扣会议座位的支持,我们可以在Conference聚合根内部维护一个当前所有订单的预定信息。然后,当订单要求提交预扣信息时,才删除预扣信息,并真正扣减座位的库存余额。 最后,我们还会关心会议的一些基本信息,如名称、开始时间、结束时间、开会地点,描述,等信息;这些都作为Conference聚合根的属性值对象即可。另外,为了方便客户下次访问这个会议,并做后续的座位管理,系统会为这个会议聚合根自动生成一个AccessCode。 业务场景之 - 客户管理会议座位 当客户创建完会议后,客户可以凭借AccessCode访问管理这个会议。然后他就可以管理会议的座位信息了。这里所说的座位信息实际上是指座位的分类,比如给开发者做的座位,给嘉宾坐的位置,等。某一类座位可以设置价格以及座位数。我们很容易理解,一类座位就是Conference聚合根下的一个实体。因为我们需要区分是这类座位还是那类座位,且无法通过名称来区分,必须通过ID来区分。所以,我们得出结论,一个Conference聚合根下有一个座位分类的明细,每个座位分类明细包含了座位的名称、价格,以及数量。这里有一个业务规则需要提一下,就是当客户删除某一类座位时,系统要检查当前是否有人预定了这类座位,如果有,则不允许删除。这个规则应该也好理解。因为如果允许删除,那意味着可能有些订单的数据一致性就无法保证了。 业务场景之 - 客户发布会议 客户创建完一个会议后,预订者还不能理解看到预定的座位。需要客户发布该会议后,预订者才能看得到。 这个应该很好理解,就像淘宝上的宝贝,需要店家发布后才能对买家可见。同理,店家还可以下架宝贝,这样对买家就不可见了。 要实现这个功能,很简单,我们只需要为Conference聚合根设计一个IsPublished标记位属性即可。 业务场景之 - 预订人预定会议座位 这个场景可以说是整个系统的核心业务场景,是整个系统的核心价值所在,是我们做这个系统的目的。 就像淘宝、天猫平台的目的就是为了提供一个平台,让买家可以在平台上订购商品。淘宝天猫其他环节都可以挂,但是下订单绝对不能挂。可见下订单业务的可用性是多么重要。所以,我们应该花最好的资源,最多的时间,做最好的设计,在这一块业务上面。 这个场景的交互流程大概是下面这样的: 预订者浏览某个已发布的会议; 进入会议的详情页面,该页面显示了所有可预订的座位分类信息; 预订者选择好要预订的座位分类,录入每个分类的预定数量; 点击提交按钮,提交订单; 系统处理订单,处理订单; 处理订单的主要步骤是:1)生成订单(初始状态,对用户不可见);2)扣减库存(内部有预扣逻辑);3)修改订单状态;4)通知预订者下单是否成功; 如果下单成功,预订者被导航到支付页面进行支付;预订者可以选择支付,也可以放弃支付; 如果支付成功,则提示预订者订单处理完成,交易完成;如果放弃支付,则关闭订单;如果超过规定时间(15分钟)未支付,则视作订单已过期,系统自动回收订单所预定的座位; 如果订单交易完成,则预订者可以通过系统给定的AccessCode访问自己的订单,然后可以接下来安排预定座位的参会人信息; 预定流程结束; 通过上面的流程我们知道,整个场景主要都是在围绕订单在进行处理。所以,毫无疑问,订单是核心聚合根,且订单有一个状态,表示当前被处理的阶段。然后,订单在一定的业务规则下进行状态变更。订单的状态是订单的核心属性。那我们如何设计订单的状态呢?还是要分析上面的订单处理流程。通过上面的分析我们可以知道,订单处理主要有:订单生成、减库存、支付三个环节。所以我们可以针对每个环节可能出现的不同情况,设计不同的状态即可。目前我设计了如下的状态枚举: 当订单创建出来后,默认状态为Placed;然后系统自动去进行预扣座位的库存,如果预扣成功,则状态为ReservationSuccess;失败,则状态为ReservationFailed;然后,如果预扣成功,那用户就可以去支付页面支付订单,如果支付成功,则状态为PaymentSuccess;如果用户拒绝支付,则状态为PaymentRejected;然后,如果用户忘记支付,超过规定时间,则状态由系统自动设置为Expired,即过期;如果用户支付完成,则状态为Success,即订单交易成功;最后,当订单过期或用户拒绝付款后,订单最后的状态会被设置为Closed,即关闭。 这些状态是我对这个系统的理解设计出来的,和微软的Conference案例并不同。这里我不对微软的案例做分析,大家有兴趣的可以自己去看一下。 大家可以看到,整个订单的状态还是比较多的,订单处理的流程也有点复杂。订单处理的流程是我们的系统的关键,我们需要用最好的最健壮的设计来支持它。 我们再来看看订单还有什么其他的重要信息我们需要关心的。订单的明细也是必须要关心的,没有明细的订单就不是一个有效的订单。明细包括什么?这些都很简单,大家都思考过。那就是需要包括预定的Conference的座位信息以及数量,也就是说,我们关心这个订单预定了哪个Conference的哪些座位,分别预定了多少个。这里我就不展开了。 最后,我们还关心预订人的信息,因为我们总要知道是谁预定了这些座位的。 业务场景之 - 预订人支付订单 上面的订单处理流程中,还有一个很重要的环节就是订单支付。上面分析上下文时我们知道了支付是在一个独立的上下文中处理的。那这个上下文要负责做什么事情呢?我想,主要的事情就是: 根据订单生成支付信息; 然后提供用户支付功能(可以跳转到其他外部网站进行实际支付); 向订单处理的上下文通知支付结果; 总结起来,就是负责封装支付信息(Payment)的存储以及与外部系统集成以便完成实际支付的交互逻辑; 订单支付现实生活中,就是像在淘宝上买东西后跳转到支付宝站点那样,进行付款。而我们这个由于只是一个案例,所以没有真正实现支付的功能。但是我们可以通过做一个简单的页面来模拟这个支付的场景。比如通过提供用户一个页面,让用户来选择是否同意支付。如果用户选择同意,则系统认为用户已经支付完成;如果用户选择拒绝,则系统认为用户拒绝支付;不管用户选择哪个,都把支付的结果通知给订单处理上下文即可。对于订单处理上下文,它不关心支付的细节,只关心支付的结果。 所以,我们很清楚的知道,我们需要设计一个Payment聚合根,表示订单的支付信息。Payment需要记录是对哪个订单的支付,同时还可以记录这次支付的具体明细描述(可以来自订单信息的明细)。同时,还会关心此次支付共需支付多少钱,以及支付的结果(比如成功还是拒绝)。 业务场景之 - 预定人填写参会人信息 当预订人支付完成后,订单状态就变成交易成功了。然后预订人就可以开始填写他预定的座位的参会人的信息了,主要填写参会人的姓名和联系方式即可。方便Conference的Owner与参会人联系。那问题是,这些参会人信息需要聚合在Order聚合根下吗?你可以聚合。但我选择不聚合,理由是:此时订单的生命周期可以理解为已经结束了,然后预订人填写参会人信息,是属于后续的活动操作。所以,我们应该用新的聚合根来跟踪这个活动,这个聚合根关心的是这个订单的所有参会人信息,以及每个参会人和对应的座位的对应关系的维护。这些信息由订单来维护不是很妥当,订单的职责是记录预定的座位的信息以及记录订单处理的状态,一旦订单处理完成,那他的生命周期就结束了。它不关心预订人后面又做了什么事情。所以,我设计了一个新的聚合根叫OrderSeatAssignments。 业务场景之 - 查看会议信息、查看订单信息 这个场景只涉及到数据的查询,不涉及到修改。所以,对领域建模无影响,我就忽略了。 最后的领域模型 通过上面的分析,我们终于可以得到一个最终的领域模型了,如下图所示: 上图中,每个边界就是一个聚合,Conference, Order, OrderSeatAssignments, Payment这四个分别是聚合根。 结束语 本文通过从业务需求描述,到上下文划分,最后到领域模型的设计。重点表达了我个人的思考过程,DDD Bounded Context我划分的依据,以及整个领域模型是怎么出来的。写这篇文章费了我不少脑子,希望对大家有帮助哦!下一篇文章打算介绍一下Conference案例的架构设计。
前言 ENode是一个应用开发框架。通过ENode,我们可以方便的开发基于DDD+CQRS+EventSourcing+EDA架构的应用程序。之前我已经写了很多关于ENode的架构以及设计原理的文章,但是因为没有和具体的例子结合来进行分析,所以可能很多人还是无法理解ENode的功能和设计。所以,接下来,我想通过一个较为完整的案例来一步步从业务分析到领域模型设计再到代码实现,以案例的方式讲解ENode如何帮助我们落实DDD的编码实现。 本文是这个系列的第一篇,所以需要先介绍这个案例的一些业务。 前段时间,我用业余时间开发了一个DDD的案例,叫Conference。它是一个支持多租户的会议管理和预定的系统。这个项目不是我个人想出来的,而是微软的一个CQRS实践的一个开源项目,项目主页:http://cqrsjourney.github.io/ Conference业务简介 Conference是这样一个系统,它提供了一个在线创建会议以及预订会议座位的平台。这个系统的用户有两类:1)客户,可以创建和管理会议;2)会议座位预定者,可以预订会议座位。具体的关键业务描述如下: 客户创建一个会议,并录入会议的基本信息,比如名称、时间段、地点,等;会议创建后,系统会为客户自动生成一个AccessCode,客户可以通过AccessCode访问自己创建的会议; 客户定义某个会议的座位类型,可以定义多个,每个座位类型包含的信息有:名称、座位价格、座位数量; 客户发布或取消发布某个会议,当一个会议发布后,预订者就可以在线预订会议的座位了;如果取消发布,则该会议对预订者不可见;只有未发布状态的会议才能修改; 预订者在预订会议座位时,会生成订单,订单需要进行支付才会生效; 订单生成后,预订者可以有15分钟的时间付款,超过15分钟,订单预定的座位就会回收,允许其他人预定; 订单生成后,系统会为预订者生成一个AccessCode,用户可以通过AccessCode查看自己的订单; 预订者成功预订了座位后,可以指定每个座位的实际参会人信息; 客户(会议的Owner)可以管理他创建的每个会议的所有订单,比如可以查看该会议的所有订单以及参会人信息,以方便联系参会人; 结束语 通过上面的业务介绍,我们不难理解,这个系统本质是一个简易的电子商务系统。它提供了商品管理、下订单、支付三大功能。大家可以看到,这个系统没有用户注册、登录的业务,而是简单的采用AccessCode来让用户访问自己的数据,因为这是一个学习案例。我之所以选择这个案例来进行分析,就是因为大家一般对电子商务系统的业务相对比较熟悉,这样我们讨论就有了一定的基础。下一篇文章,我想从DDD的角度,分析如何进行战略设计(划分子域以及BC)和战术设计(建立领域模型)。
测试环境 两台笔记本网线直连,通过测速工具(jperf)测试,确定两台电脑之间的数据传输速度可以达到1Gbps,即千兆网卡的最大速度。两台电脑硬件配置如下: client服务器,CPU:Intel i5-3230 2.6GHz 内存:8G server服务器,CPU:Intel i5-3210 2.5GHz 内存:4G ENode使用的通信层(有兴趣的可以下载ECommon的源代码,运行代码中的Remoting的Sample),支持Oneway, Async, Sync三种通信模式。 Oneway表示client将数据通过socket发送后不关心server的处理结果,这种模式类似于Push-Pull的通信方式; Async表示client将数据发送到server后异步等待server的处理结果; Sync表示client将数据发送到server后同步等待server的处理结果; 由于Sync的方式效率较低,且因为ENode中使用通信层只会用到Oneway和Async两种方式;所以我只测试了Oneway, Async这两种通信模式。 测试结果 Oneway 说明: 理论上如果两台电脑之间的带宽是千兆,即1Gbps,即125MB的话,那假设一个消息大小是1KB,那如果发送者和接收者的CPU和内存不会成为瓶颈,那理论上每秒应该可以发送接收125 * 1024 = 128000/s。 关于客户端数量是指在client机器上开几个进程进行发送消息,目前我是一个进程一个TCP连接。 大家可以看到,当消息大小为1K时,客户端机器4个进程同时发送,服务端机器全部接收完用时39.5s,也就是每秒101265接近网卡的极限。为什么没有达到网卡的极限,其实可以到达只要我开6个进程发送消息即可。只是我上面为了方便对比,每个消息大小最多开4个进程。 测试过程中,我发现客户端向服务端发送数据的吞吐量,主要的瓶颈还是在CPU。如果CPU好,那发送速度会很快,直到把网卡压满位置。 在消息大小为2K时,我没有测试4个进程同时发送消息的场景,因为内存不够用了。 Async 说明: 大家可以看到Async的通信模式,性能下降很多。是因为Async要比Oneway的方式,在逻辑上要多很多逻辑。比如发送前要先把一个TaskCompletionSource加入到一个ConcurrentDictionary中,然后当对应的RemotingRequest有回复时,获取到对应的TaskCompletionSource,然后设置回复结果,从而让发送数据的Task完成。以此实现异步发送数据的过程。 测试Async的过程中,我发现假如瞬间假如100W个TaskCompletionSource到ConcurrentDictionary,然后通过Socket进行异步发送数据,一开始CPU会压力非常大;所以我为了降低CPU的瓶颈,让每个客户端只发送50W数据; 从上面的测试结果可以看出,1K大小的数据,Async模式发送,吞吐量最大在3.3W。经过我的分析,主要的瓶颈还是在CPU。因为此时CPU基本接近极限,而网卡只跑了250MB左右。说明我们应该尽量优化CPU的利用率,减少并发冲突。将来我准备使用Disruptor.Net来尝试看看是否能提高Async模式的性能。 在消息大小在1K和2K的情况下,我没有测试客户端数量为4的情况,因为此时Client端机器的CPU,内存都已经成为瓶颈,没必要测试了。实际上,在客户端进程数量在3的时候,也已经成为瓶颈。 关于阿里云ECS的测试计划 接下来准备再阿里云ECS服务器上再做一下类似的测试,之前简单摸底了一下。购买了两台ECS虚拟机,配置都是4核8G内存。通过内网IP进行TCP测试(使用jperf工具)。发现两台虚拟机之间,最大只能达到60MB的速度。这说明,阿里云的ECS服务器之间,带宽无法达到125MB,比较遗憾。但这也是我未来希望部署ENode案例的真实服务器,所以在这个环境上的测试结果,应该更具参考价值。 未来的测试计划 大概知道通信层的性能之后,我准备对EQueue发送消息和接收做性能测试。这个测试结果,就是决定ENode各个节点之间,消息传递吞吐量的主要依据。
系统思维就是把认识对象作为系统,从系统和要素、要素和要素、系统和环境的相互联系、相互作用中综合地考察认识对象的一种思维方法。系统思维以系统论为思维基本模式的思维形态,它不同于创造思维或形象思维等本能思维形态。系统思维能极大地简化人们对事物的认知,给我们带来整体观。 系统思维是一种逻辑抽象能力,也可以称为整体观、全局观。 另外,首先应该明白什么是系统?生物学中有生态系统,是指一个能够自我完善,达到动态平衡的生物链,如:一个池塘。 系统一般是可以封闭运作的,可以自我完善,并且能够动态平衡的物品集合。 系统思维,简单来说就是对事情全面思考,不只就事论事。是把想要达到的结果、实现该结果的过程、过程优化以及对未来的影响等一系列问题作为一个整体系统进行研究。 系统是一系统是一个概念,反映了人们对事物的一种认识论,即系统是由两个或两个以上的元素相结合的有机整体,系统的整体不等于其局部的简单相加。这一概念揭示了客观世界的某种本质属性,有无限丰富的内涵和处延,其内容就是系统论或系统学。系统论作为一种普遍的方法论是迄今为止人类所掌握的最高级思维模式。 系统思维是指以系统论为思维基本模式的思维形态,它不同于创造思维或形象思维等本能思维形态。系统思维能极大地简化人们对事物的认知,给我们带来整体观。 按照历史时期来划分,可以把系统思维方式的演变区分为四个不同的发展阶段:古代整体系统思维方式——近代机械系统思维方式——辩证系统思维方式——现代复杂系统思维方式。 系统思维就是把认识对象作为系统,从系统和要素、要素和要素、系统和环境的相互联系、相互作用中综合地考察认识对象的一种思维方法。系统思维以系统论为思维基本模式的思维形态,它不同于创造思维或形象思维等本能思维形态。系统思维能极大地简化人们对事物的认知,给我们带来整体观。 系统思维是一种逻辑抽象能力,也可以称为整体观、全局观。 另外,首先应该明白什么是系统?生物学中有生态系统,是指一个能够自我完善,达到动态平衡的生物链,如:一个池塘。 系统一般是可以封闭运作的,可以自我完善,并且能够动态平衡的物品集合。 系统思维,简单来说就是对事情全面思考,不只就事论事。是把想要达到的结果、实现该结果的过程、过程优化以及对未来的影响等一系列问题作为一个整体系统进行研究。 系统是一系统是一个概念,反映了人们对事物的一种认识论,即系统是由两个或两个以上的元素相结合的有机整体,系统的整体不等于其局部的简单相加。这一概念揭示了客观世界的某种本质属性,有无限丰富的内涵和处延,其内容就是系统论或系统学。系统论作为一种普遍的方法论是迄今为止人类所掌握的最高级思维模式。 系统思维是指以系统论为思维基本模式的思维形态,它不同于创造思维或形象思维等本能思维形态。系统思维能极大地简化人们对事物的认知,给我们带来整体观。 按照历史时期来划分,可以把系统思维方式的演变区分为四个不同的发展阶段:古代整体系统思维方式——近代机械系统思维方式——辩证系统思维方式——现代复杂系统思维方式。
普通哈希: var x = hash(dataKey) % N 一致性哈希: 将数据的key的hashcode与存放数据的节点(如缓存节点)的IP(或服务器名)的hashcode都分布到同一个环形数值空间,比如0~2^32-1。然后,把数据的hashcode沿着顺时针方向找第一个存放数据的节点的hashcode,找到的那个就是要存放的节点。 var dataValue = hash(dataKey) % N var nodeValue = hash(nodeIP) % N 将dataValue沿数值空间顺时针寻找第一个nodeValue,找到的那个对应的node就是要存放数据的节点。 一致性哈希数据不均匀的问题: 通过增加虚拟节点的思路,为每个node设计多个虚拟节点(比如100个),虚拟节点可以在物理节点的IP的基础之上加上数字后缀。然后把虚拟节点hash分布到hash环。然后我们先按照上面的一致性哈希思路计算出需要存放的虚拟节点,然后再根据虚拟节点和物理节点的对应关系,找到具体的物理节点。
规则如下: 判断一个ID在哪个库里的公式是:id % 4 / 2 判断一个ID在哪个表里的公式是:id % 4 % 2 其中4表示总共有多少个分表,2表示总共有多少个数据库;上面这个例子,表示总共有2个数据库,每个数据库有2个分表,所以是2 * 2 = 4; 所以,对于ID是1,2,3,4,5的这些情况,落到的库和表分别如下: ID=1 1 % 4 / 2 = 0 (注:0表示是index=0的库,即第一个库,这里计算得到的数值都是从0开始) 1 % 4 % 2 = 1 (注:1表示是index=1的表,即第二个表,这里计算得到的数值都是从0开始) 即在第1个库里的第2个表 ID=2 2 % 4 / 2 = 1 2 % 4 % 2 = 0 即在第2个库里的第1个表 ID=3 3 % 4 / 2 = 1 3 % 4 % 2 = 1 即在第2个库里的第2个表 ID=4 4 % 4 / 2 = 0 4 % 4 % 2 = 0 即在第1个库里的第1个表 ID=5 5 % 4 / 2 = 0 5 % 4 % 2 = 1 即在第1个库里的第2个表
领域驱动设计(DDD)是一种基于模型驱动的软件设计方式。它以领域为核心,分析领域中的问题,通过建立一个领域模型来有效的解决领域中的核心的复杂问题。Eric Ivans为领域驱动设计提出了大量的最佳实践和经验技巧。只有对领域的不断深入认识,才能得到一个解决领域核心问题的领域模型。如果一个应用的复杂性不是在技术方面的,而是在领域本身,即领域内的业务很复杂,那这种应用,使用领域驱动设计的价值就越大。 领域驱动开发也是一种敏捷开发过程(极限编程,XP),强调迭代开发。在迭代过程中,强调开发人员与领域专家需要保持密切的合作关系。极限编程假设我们能通过不断快速重构完善设计。所以,对开发人员的要求非常高。 领域驱动设计提出了一套核心构造块(Building Blocks,如聚合、实体、值对象、领域服务、领域工厂、仓储、领域事件,等),这些构造块是对面向对象领域建模的一些核心最佳实践的浓缩。这些构造块可以使得我们的设计更加标准、有序。 统一语言(Ubiquitous Language),是领域驱动设计中一个非常重要的概念。任何一个领域驱动设计的项目,都需要一种通用语言,一套通用的词汇。因为没有通用的语言,就没有一致的概念,沟通就会遇到障碍,最后的领域模型和软件也就无法满足领域内的真实业务需求。通用语言是领域专家和开发人员在对领域问题的沟通、需求的讨论、开发计划的制定、领域模型的设计,以及开发人员之间对领域模型的具体编码落地实现,等一系列过程中,所有人员使用的一种通用语言。话句话说,就是无论是沟通时所用的词汇、还是领域模型中的概念、还是代码中出现的类名与方法,只要是相同的意思,那就应该使用相同的词汇。可以看出,这种通用语言不是一下子就可以形成,而是在一个各方人员讨论的过程中,不断发现、明确,与精炼出来的。 领域模型是领域驱动设计的核心。统一语言中的所有关键词汇,在领域模型上应该都能找到。各方人员沟通时,都应该以领域模型为基础。通过讨论的不断深入,大家对领域的认识也会不断深入,领域模型也会不断得到完善,统一语言的词汇也会不断丰富和精准。需要特别强调的是,开发人员应该尽量保证代码实现和领域模型相绑定,时刻保持代码与模型的一致。如果不绑定,那代码就会慢慢和模型相脱节,就会出现像我们以前那样的设计文档和代码相脱节一样的问题,甚至模型还会起到误导作用。通过这样一种思路,我们确保语言、模型、代码三者紧密绑定,确保最后实现出来的软件可以准确无误的实现业务需求,并且还能让我们的软件可以快速的和业务同时演进。而不像传统的开发方式那样,分析、设计、实现三个阶段完全脱节,最后出来的软件没有很好的满足业务需求,也不能在未来很快的跟业务需求一起演进。所以,领域模型同时承载了分析的结果和设计的结果,这里的分析是指对领域内业务需求的分析,设计是指对模型的设计以及软件的设计。所以,我们的领域模型,不能只考虑业务需求,还要同时考虑软件设计的原则,是一种综合考虑的、平衡的设计结果。 领域模型可以复用,因为特定的领域模型解决的都是某个特定的问题域;比如淘宝网有个商品中心,有个商品模型,核心概念有商品分类、商品;商品模型负责解决电子商务领域中的商品目录(Product Catalog)子域。后来阿里又出了个天猫,也会有商品中心,但是这两个商品中心基本是一样的问题域。所以,我们可以复用之前淘宝实现的商品中心领域模型,并复用之前淘宝商品中心的解决方案,来解决天猫的商品维护和展示。当然,这个只是我个人的认识,一个例子。具体阿里是否是一个商品中心同时解决淘宝和天猫的业务,没具体调研过。 Bounded Context,属于一种软件构件,作用是用来对领域模型进行划分。Bounded Context有两层含义: Bounded,即有边界的,表示领域模型有边界;这个边界定义了模型的适用范围,以便让负责该模型的团队知道什么该在模型中实现,什么不该; Context,即领域模型的产生是在某个上下文中产生的;上下文是一个和环境相关的概念。比如一次头脑风暴会议大家达成了一个模型,那这次会议的讨论就是该模型的上下文;比如某本书中谈到了某个东西,那这个东西的上下文就是那本书,那个东西要有意义的前提离不开那本书这个上下文;所以,上下文是模型有意义的前提; 领域建模的方法有很多种,我分享一下自己的一种基于场景为核心的分析方法。大概的思路是: 通过与领域专家和业务需求人员沟通,找出领域中的关键业务场景; 针对每个业务场景分析出有哪些场景参与者,哪些参与者以对象(聚合)的形式参与,哪些参与者以服务的形式参与; 分析每个场景参与者对象的基本状态特征; 分析每个场景参与者对象分别扮演什么角色参与场景,整个场景的完整交互过程是怎样的,对象在参与场景的过程中执行了哪些交互行为; 分析如何记录和跟踪这一次交互行为,分析这次交互行为会产生哪些额外的信息; 上面,只是简单列了一下条目,具体的描述,请参看我的另一篇文章,有详细的叙述。 关于领域(Domain)、领域模型(Domain Model)、边界上下文(Bounded Context)的关系 领域就是问题域,问题空间; 领域模型是一种模型,表达了领域中哪些业务需求以及业务规则必须被满足; 每一个领域中的问题,都会有一个对应的领域模型去解决; Bounded Context的作用是用来对领域模型进行划分; 划分领域就是对问题空间的划分,通俗的理解,就是将大问题拆分为小问题; 划分Bounded Context就是将一个大的领域模型划分为多个小的领域模型; 可以把Bounded Context看成是一种解决方案空间,所以,Bounded Context也可以理解为是对解决方案空间的划分; 理论上,一个Domain可能会对应多个Bounded Context;同样,一个Bounded Context可能也会对应多个Domain;所以他们之间没有绝对的关系。主要是他们划分的依据不同,一个是针对领域(问题空间),一个是针对领域模型(解决方案空间);理想情况,一个Domain最好对应一个Bounded Context; 关于Domain、Sub Domain、Core Domain、Generic Domain,以及Shared Kernal的理解: 一个领域(Domain)会拆分为多个子领域(Sub Domain); 子领域中最核心(最重要)的那个叫Core Domain;我们应该讲团队的核心资源用在核心子域上,因为它是产品成败的关键; 除了Core Domain外,其他的是支撑子域(Supporting Subdomain); 有些支撑子域比较特殊,因为它解决的是一类通用问题,比如账号和权限;这类子域我们叫做通用子域(Generic Subdomain);通常,通用子域对应的Bounded Context,会跨域多个子域; 多个子领域有时会有相交的部分,我们称作共享内核(Shared Kernel);体现到代码上,就是同一份代码,在两个领域模型中复用; 一般只有Domain比较大的时候,我们才会划分出Sub Domain; 为什么一个大的领域模型需要划分?因为,通常一个大的领域模型需要多个团队合作完成。如果多个团队基于一个共同的领域模型工作,由于每个团队的关注点不同,且一些看似叫法一样的概念,对于不同的团队,其背后的意思完全不同。所以,这样的概念含义模糊会给团队以及成员之间的合作带来很大的困扰。所以,我们需要通过一种手段(Bounded Context),将领域模型划分为不同的部分,确保同一个Bounded Context内的领域模型所表达的概念含义明确。然后,同一个Bounded Context下面,相关人员都使用一种统一的语言,以此来保证团队成员之间沟通能畅通无阻;
一年前,当我第一次开发完EQueue后,写过一篇文章介绍了其整体架构,做这个框架的背景,以及架构中的所有基本概念。通过那篇文章,大家可以对EQueue有一个基本的了解。经过了1年多的完善,EQueue无论是功能上还是成熟性上都完善了不少。所以,希望再写一篇文章,介绍一下EQueue的整体架构和关键特性。 EQueue架构 EQueue是一个分布式的、轻量级、高性能、具有一定可靠性,纯C#编写的消息队列,支持消费者集群消费模式。 主要包括三个部分:producer, broker, consumer。producer就是消息发送者;broker就是消息队列服务器,负责接收producer发送过来的消息,以及持久化消息;consumer就是消息消费者,consumer从broker采用拉模式到broker拉取消息进行消费,具体采用的是long polling(长轮训)的方式。这种方式的最大好处是可以让broker非常简单,不需要主动去推消息给consumer,而是只要负责持久化消息即可,这样就减轻了broker server的负担。同时,consumer由于是自己主动去拉取消息,所以消费速度可以自己控制,不会出现broker给consumer消息推的太快导致consumer来不及消费而挂掉的情况。在消息实时性方面,由于是长轮训的方式,所以消息消费的实时性也可以保证,实时性和推模型基本相当。 EQueue是面向topic的架构,和传统的MSMQ这种面向queue的方式不同。使用EQueue,我们不需要关心queue。producer发送消息时,指定的是消息的topic,而不需要指定具体发送到哪个queue。同样,consumer发送消息也是一样,订阅的是topic,不需要关心自己想从哪个queue接收消息。然后,producer客户端框架内部,会根据当前的topic获取所有可用的queue,然后通过某种queue select strategy选择一个queue,然后把消息发送到该queue;同样,consumer端,也会根据当前订阅的topic,获取其下面的所有的queue,以及当前所有订阅这个topic的consumer,按照平均的方式计算出当前consumer应该分配到哪些queue。这个分配的过程就是消费者负载均衡。 Broker的主要职责是: 发送消息时:负责接收producer的消息,然后持久化消息,然后建立消息索引信息(把消息的全局offset和其在queue中的offset简历映射关系),然后返回结果给producer; 消费消息时:负责根据consumer的pull message request,查询一批消息(默认是一次pull request拉取最多32个消息),然后返回给consumer; 各位看官如果对EQueue中的一些基本概念还不太清楚,可以看一下我去年写的介绍1,写的很详细。下面,我想介绍一下EQueue的一些有特色的地方。 EQueue关键特性 高性能与可靠性设计 网络通信模型,采用.NET自带的SocketAsyncEventArgs,内部基于Windows IOCP网络模型。发送消息支持async, sync, oneway三种模式,无论是哪种模式,内部都是异步模式。当同步发送消息时,就是框架帮我们在异步发送消息后,同步等待消息发送结果,等到结果返回后,才返回给消息发送者;如果一定时间还不返回,则报超时异常。在异步发送消息时,采用从EventStore开源项目中学习到的优秀的socket消息发送设计,目前测试下来,性能高效、稳定。通过几个案例运行很长时间,没有出现通信层方面的问题。 broker消息持久化的设计。采用WAL(Write-Ahead Log)技术,以及异步批量持久化到SQL Server的方式确保消息高效持久化且不会丢。消息到达broker后,先写入本地日志文件,这种设计在db, nosql等数据库中很常见,都是为了确保消息或请求不丢失。然后,再异步批量持久化消息到SQL Server,采用.NET自带的SqlBulkCopy技术。这种方式,我们可以确保消息持久化的实时性和很高的吞吐量,因为一条消息只要写入本地日志文件,然后放入内存的一个dict即可。 当broker意外宕机,可能会有一些消息还没持久化到SQL Server;所以,我们在重启broker时,我们除了先从SQL Server恢复所有未消费的消息到内存外,同时记录当前SQL Server中的最后一条消息的offset,然后我们从本地日志文件扫描offset+1开始的所有消息,全部恢复到SQL Server以及内存。 需要简单提一下的是,我们在把消息写入到本地日志文件时,不可能全部写入到一个文件,所以要拆文件。目前是根据log4net来写消息日志,每100MB一个日志文件。为什么是100MB?是因为,我们的这个消息日志文件的用途主要是用来在Broker重启时,恢复SQL Server中最后还没来得及持久化的那些消息的。正常情况下,这些消息量应该不会很多。所以,我们希望,当扫描本地日志文件时,尽量能快速的扫描文件。通常100MB的消息日志文件,已经可以存储不少的消息量,而SQL Server中未持久化的消息通常不会超过这个量,除非当机前,出现长时间消息无法持久化的情况,这种情况,应该会被我们监控到并及时发现,并采取措施。当然,每个消息日志文件的大小,可以支持配置。另外一点,就是从日志文件恢复的时候,还是需要有一个算法的,因为未被持久化的消息,有可能不只在最近的一个消息日志文件里,有可能在多个日志文件里,因为就像前面所说,会出现大量消息没有持久化到SQL Server的情况。 但总之,在保证高性能的前提下,消息不丢(可靠性)是完全可以保证的。 消费消息方面,采用批量拉取消息进行消费的方式。默认consumer一个pull message request会最多拉取32个消息(只要存在这么多未消费消息的话);然后consumer会并行消费这些消费,除了并行消费外,也可以配置为单线程线性消费。broker在查询消息时,一般情况未消费消息总是在内存的,只有有一种情况不在内存,这个下面详细分析。所以,查询消息应该说非常快。 不过上面提到的消息可靠性,只能尽量保证单机不丢消息。由于消息是放在DB,以及本地日志。所以,如果DB服务器硬盘坏了,或者broker的硬盘坏了,那就会有丢消息的可能性。要解决这个问题,就需要做replication了。EQueue下一步会支持broker的集群和故障转移(failover)。目前,我开发了一个守护进程服务,会监控broker进程是否挂掉,如果挂掉,则自动重启,一定程度上也会提高broker的可用性。 我觉得,做事情,越简单越好,不要一开始就搞的太复杂。复杂的东西,往往难以维护和驾驭,虽然理论很美好,但总是会出现各种问题,呵呵。就像去中心化的架构虽然理论好像很美好,但实际使用中,发现还是中心化的架构更好,更具备实战性。 支持消费者负载均衡 消费者负载均衡是指某个topic的所有消费者,可以平均消费这个topic下的所有queue。我们使用消息队列,我认为这个特性非常重要。设想,某一天,我们的网站搞了一个活动,然后producer产生的消息猛增。此时,如果我们的consumer服务器如果还是只有原来的数量,那很可能会来不及处理这么多的消息,导致broker上的消息大量堆积。最终会影响用户请求的响应时间,因为很多消息无法及时被处理。 所以,遇到这种情况,我们希望分布式消息队列可以方便的允许我们动态添加消费者机器,提高消费能力。EQueue支持这样的动态扩展能力。假如某个topic,默认有4个queue,然后每个queue对应一台consumer机器进行消费。然后,我们希望增加一倍的consumer时,只要在EQueue Web控制台上,为这个topic增加4个queue,然后我们再新增4台consumer机器即可。这样EQueue客户端会支持自动负载均衡,几秒钟后,8个consumer就可以各自消费对应的queue了。然后,当活动过后,消息量又会回退到正常水平,那么我们就可以再减少queue,并下线多余的consumer机器。 另外,EQueue还充分考虑到了下线queue时的平滑性,可以支持先冻结某个queue,这样可以确保不会有新的消息发送到该queue。然后我们等到这个queue的消息都消费完后,就可以下线consumer机器和删除该queue了。这点,应该说,阿里的rocketmq也没有做到,呵呵。 broker支持大量消息堆积 这个特性,我之前专门写过一篇文章,详细介绍设计思路,这里也简单介绍一下。MQ的一个很重要的作用就是削峰,就是在遇到一瞬间大量消息产生而消费者来不及一下子消费时,消息队列可以起到一个缓冲的作用,从而可以确保消息消费者服务器不会垮掉,这个就是削峰。如果使用RPC的方式,那最后所有的请求,都会压倒DB,DB就会承受不了这么多的请求而挂掉。 所以,我们希望MQ支持消息堆积的能力,不能因为为了快,而只能支持把消息放入服务器内存。因为服务器内存的大小是有限的,假设我们的消息服务器内存大小是128G,每个消息大小为1KB,那差不多最多只能堆积1.3亿个消息。不过一般来说1.3亿也够了,呵呵。但这个毕竟要求大内存作为前提的。但有时我们可能没有那么大的服务器内存,但也需要堆积这么多的消息的能力。那就需要我们的MQ在设计上也提供支持。EQueue可以允许我们在启动时配置broker服务器上允许在内存里存放的消息数以及消息队列里消息的全局offset和queueOffset的映射关系(我称之为消息索引信息)的数量。我们可以根据我们的服务器内存的大小进行配置。然后,broker上会有定时的扫描线程,定时扫描是否有多出来的消息和消息索引,如果有,则移除多出来的部分。通过这个设计,可以确保服务器内存一定不会用完。但是否要移除也有一个前提,就是必须要求这个消息已经持久化到SQL Server了。否则就不能移除。这个应该通常可以保证,因为一般不会出现1亿个消息都还没持久化到DB,如果出现这个情况,说明DB一定出了什么严重的问题,或者broker无法与db建立连接了。这种情况下,我们应该早就已经发现了,EQueue Web监控控制台上随时可以查看消息的最大全局offset,已经持久化的最大全局offset。 上面这个设计带来的一个问题是,假如现在consumer要拉取的消息不在内存了怎么办?一种办法是从DB把这个消息拉取到内存,但一条条拉,肯定太慢了。所以,我们可以做一个优化,就是发现当前消息不在内存时,因为很可能下一条消息也不在内存,所以我们可以一次性从Sql Server DB拉取10000个消息(可配置),这样后续的10000个消息就一定在内存了,我们需要再访问DB。这个设计其实是在内存使用和拉取消息性能之间的一个权衡后的设计。Linux的pagecache的目的也是这个。 另外一点,就是我们broker重启时,不能全部把所有消息都恢复到内存,而是要判断是否已经到达内存可以承受的最大消息数了。如果已经到达,那就不能再放入内存了;同理,消息索引信息的恢复也是一样。否则,在消息堆积过多的时候,就会导致broker重启时,内存爆掉了。 消息消费进度更新的设计 EQueue的消息消费进度的设计,和kafka, rocketmq是一个思路。就是定时保存每个queue的消费进度(queue consumed offset),一个long值。这样设计的好处是,我们不用每次消费完一个消息后,就立即发送一个ack回复消息到broker。如果是这样,对broker的压力是很大的。而如果只是定时发送一个消费进度,那对broker的压力很小。那这个消费进度怎么来?就是采用滑动门技术。就是consumer端,在拉取到一批消息后,先放入本地内存的一个SortedDictionary里。然后继续去拉下一批消息。然后会启动task去并行消费这些刚刚拉取到的消息。所以,这个本地的SortedDictionary就是存放了所有已经拉取到本地但还没有被消费掉的消息。然后当某个task thread消费掉一个消息后,会把它从SortedDictionary中移除。然后,我上面所说的滑动门技术,就是指,在每次移除一个消息后,获取当前SortedDictionary里key最小的那个消息的queue offset。随着消息的不断消费,这个queue offset也会不断增大,从宏观的角度看来,就像是一扇门在不停的往前移动。 但这个设计有个问题,就是假如这个Dict里,有一个offset=100的消息一直没被消费掉,那就算后面的消息都被消费了,最后这个滑动门还是不会前进。因为这个dict里的最后的那个queue offset总是100。这个应该好理解的吧。所以这种情况下,当consumer重启后,下次消费的位置还是会从100开始,后面的也会重新消费一遍。所以,我们的消费者内部,需要都支持幂等处理消息。 支持消息回溯 因为broker上的消息,不是消息消费掉了就立即删除,而是定时删除,比如每2天删除一次(可以配置)。所以,当我们哪天希望重新消费1天前的消息的时候,EQueue也是完全支持的。只要在consumer启动前,修改消费进度到以前的某个特定的值即可。 Web管理控制台 EQueue有一个完善的Web管理控制台,我们可以通过该控制台管理topic,管理queue,查看消息,查看消息消费进度,查看消息堆积情况等信息。但是目前还不支持报警,以后会慢慢增加报警功能。 通过这个控制台,我们使用EQueue就会方便很多,可以实时了解消息队列服务器的健康状况。贴一个管理控制台的UI界面,让大家有个印象: EQueue未来的计划 broker支持集群,master-slave模式,使其具备更高的可用性和扩展性;Web管理控制台支持报警;出一份性能测试报告,目前我主要是没有实际服务器,没办法实际测试;考虑支持非DBC持久化的支持,比如本地key/value存储支持,或者完全的本地文件持久化消息(难度很大);其他小功能完善和代码局部调整; 我相信:没有做不好,只有没耐心。
问题背景 Conference案例,是一个关于在线创建会议(类似QCon这种全球开发者大会)、在线管理会议位置信息、在线预订某个会议的位置的,这样一个系统。具体可以看微软的这个项目的主页:http://cqrsjourney.github.io。 然后我们设计了一个Conference聚合根,对应领域中的会议这个领域概念。Conference聚合根下面,有一些位置信息SeatType。一个会议聚合根下面可以添加不同类型的位置,每种类型的位置可以指定数量以及价格。所以,Conference是聚合根,Conference本身有一些我们所关心的基本属性,同时它内部聚合了一些SeatType子实体。每个SeatType包含了位置的价格、数量这两个信息。 然后,在UI层面,我们会有如下界面边界管理一个会议的所有位置信息。 上图列出了某个会议的两类位置,Quota表示位置的配额数量;当我们要修改某种位置时,可以点击链接,然后出现如下图所示: 出现四个编辑框,我们可以修改任何一个框。修改完后点击保存,我们就能更新某个类型的位置信息了。然后,我们在domain里,设计了两个domain event;分别表示位置基本信息改变和位置配额数量的改变。 为什么要独立出数量改变的domain event呢?因为当用户在前台下单订购位置时,这个数量也会变化。也就是位置数量可能会单独变化。所以,我们考虑单独为位置数量的变化定义一个domain event。 然后,我们目前的代码是,当点击保存时,首先更新会更新位置的基本信息,然后判断数量是否有变化,如果没变化,则只产生位置基本信息变化的domain event;如果有变化,则同时产生位置数量改变的domain event。Conference聚合根相关方法的具体实现如下: 上面的代码的大致意思是,先从聚合内找出需要修改的位置类型,如果不存在就抛异常;如果存在,则先产生位置基本信息的改变事件;然后判断数量是否有变化,如果有变化,则继续判断当前输入的数量是否太小,如果太小也是不允许的。 比如,假如用户录入的数量是10,但是当前这种类型的位置已经有11个被预定了,那就不能改为10,而是必须至少为11。最后,如果一切都合法,就产生一个SeatTypeQuantityChanged的事件,表示某个类型的位置的数量发生了变化,同时在事件中带上可预定的剩余位置的数量。 然后读库我们就根据上面这两个事件来更新。 现在的问题是,假如两个事件都发生了,那读库要怎么原子更新(在一个事务里更新)?我们的一个event handler只能处理一个event;也就是说,我们会有两个event handler,分别处理对应的事件。由于domain aggregate是一次性原子的方式同时产生两个domain event。所以,我们要确保两个event handler要么都更新成功,要么都不更新成功,这个问题之前没考虑到过,下面我们来想想办法。 解决思路 思路1 想办法把这两个event handler包装在一个事务里,但这要求框架支持这样的跨多个event handler的事务机制;对框架要求的的改造有点大,复杂度高,不太可行。因为框架要考虑的问题是要更通用的,比如,一旦引入事务,也许还会引入分布式事务等问题。而且这种做法,性能也不高,违反ENode一开始就是为高并发设计的初衷。 思路2 要求领域里不要设计两个domain event了,就用一个domain event解决;这个event包含所有信息的修改,包括数量的修改。这个办法可行,但要求模型做出妥协和让步了。假如有一天我们遇到模型必须要产生多个事件的情况,那怎么办呢?所以,这个思路还是在逃避问题。 思路3 不采用事务,而是采用乐观锁+顺序控制+幂等支持的方式解决问题。思路是,框架按照顺序调用这两个event handler,调用的顺序和这两个事件的顺序一致;两个event handler允许不在一个事务里。 这样的问题是,假如第一个事件处理成功了,然后此时机器断电了,第二个事件没被处理,怎么办?那就是要做到,当下一次机器重启后,第二个事件能被处理。然后,因为整个架构是分布式的,所以第一个事件也是有可能被重复处理的,框架在调用event handler时,为了性能方面的考虑,只会尽量保证同一个event不会被同一个event handler重复处理,不会绝对保证;但是框架有提供机制,让开发人员在event handler内部通过依赖版本号的方式来解决重复处理的问题。所以,总结一下,我们需要处理的问题有以下3个: 需要保证任何event handler内部自己能做到绝对的幂等,框架提供支持; 需要保证任何一个event至少被处理一次,即便是在任何时候断电的情况下; 需要保证同一个事件流里的事件,处理的顺序也要按照事件流的顺序处理; 为了做到上面这3点,我对ENode做了一个完善,就是为事件引入了一个子版本号的概念。 就是当聚合根每次做出修改后,不管产生多少个domain event,这些domain event都是在一个event stream里;每个event stream都有一个版本号,然后每个domain event的主版本号就是其所在的event stream的版本号。比如某个聚合根某次变化产生了2个domain event,它们被保证在一个event stream里,然后假如这个event stream的版本号为10,那每个domain event的主版本号也是10;这点ENode框架可以做保证。那event stream的版本号哪里来的呢?就是从聚合根上得来,因为每个聚合根都维护了当前自己的版本号是什么,用version表示,那它下一次产生的event stream的版本号就是version+1。 上面解释了什么是事件的主版本号。下面我们在说一下什么是事件的子版本号。子版本号比较简单,就是假如一个event stream里包含2个事件,那第一个事件的子版本号是1,第二个则是2;所以,其实子版本号就是事件在事件流里的顺序号。 然后,有了事件的主版本号和子版本号的概念。我们就可以做到上面的3点要求了。其中的第2点,EQueue会做到确保任何一个消息至少被处理一次,这里不做展开了。第1、3点,我们通过下面的代码结合分析讨论。 为了代码效果好一点,我直接通过截图的方式了,博客园以后官方提供一套这样的代码模板吧,呵呵。@蟋蟀,上次你跟我说的那个模板,我后来忘记使用了:) 上面的代码中,每个event handler内部有一个事务,为什么还需要事务?因为我们现在更新的是聚合根,子实体(位置信息)是聚合根的一部分;所以读库更新时,自然也要更新聚合根本身的。只不过这里只需要更新聚合根的版本号即可。 第一个event handler,我们先启动一个事务,然后先更新聚合根的主版本号,以及次版本号;假如数据库里conference记录的当前的主版本号是10,次版本号是1,那这个evnt.Version就是11,evnt.Sequence是1,Sequence就是次版本号。然后通过第一条Update SQL我们就能更新聚合根的主版本号以及次版本号了。由于单条update sql是原子事务(无并发问题)的,所以我们只要判断更新的影响行数是否为1。如果是1,则说明更新成功,那就可以更新位置那条记录了。然后,由于这两条更新语句在一个事务里,所以要么全部完成,要么什么都不做,不会有做了一半的情况。 第二个event handler,同样,我们也是先启动一个事务。然后区别是,因为我们知道SeatTypeQuantityChanged事件和SeatTypeUpdate事件总是在一个事件流里发生的,且它总是位于第二个顺序。所以,当这个event handler被执行时,聚合根的主版本号一定已经是11了,且子版本号是1。那么,我们在第二个event handler中,对聚合根,只需要更新子版本号为2即可。就是第一个Update语句。然后同样判断影响行数是否为1。如果是,则更新位置的数量以及可用数量;如果不是1,则什么都不做。 有一个问题,什么时候会出现不是1呢?就是在这个event handler被重复执行的时候。这种情况,我们忽略即可。因为我们就是为了要做到update的幂等处理。 到这里基本差不多了。但是还需要说明一个大前提。就是上面这个大家可以看到,第一个event handler里,更新聚合根的主版本号时,where条件里会判断聚合根记录的当前版本号是evnt.version - 1;这个就是为了保证,读库更新时,总是按照domain event的发生顺序依次更新的,不能跳过更新,也不能乱序。否则读库的最终数据就不一致了。所以,event handler内部要做这样的判断,确保绝对不会发生这样的事情。但光event handler内部判断还不够。ENode框架也要保证event stream消息的处理顺序也是这样依次按照顺序的,否则event handler里聚合根更新的影响行数也许永远都不能为1了。 ENode已经意识到这个问题,所以已经帮我们做了这样的保证! 总结 上面的最后一个方案,我觉得是比较通用的解决方案。框架不需要做支持跨event handler的事务,改动比较小。同时还能保证读库更新的性能,另外,在断电的时候,也能保证事件被处理。 总之,一切的一切都是为了高性能、为了保证最终一致性。又花了一篇文章分享了一点小小的设计,呵呵。
先从著名的c10k问题谈起。有一个叫Dan Kegel的人在网上(http://www.kegel.com/c10k.html)提出:现在的硬件应该能够让一台机器支持10000个并发的client。然后他讨论了用不同的方式实现大规模并发服务的技术,归纳起来就是两种方式:一个client一个thread,用blocking I/O;多个clients一个thread,用nonblocking I/O或者asynchronous I/O。目前asynchronous I/O的支持在Linux上还不是很好,所以一般都是用nonblocking I/O。大多数的实现都是用epoll()的edge triggering(传统的select()有很大的性能问题)。这就引出了thread和event之争,因为前者就是完全用线程来处理并发,后者是用事件驱动来处理并发。当然实际的系统当中往往是混合系统:用事件驱动来处理网络时间,而用线程来处理事务。由于目前操作系统(尤其是Linux)和程序语言的限制(Java/C/C++等),线程无法实现大规模的并发事务。一般的机器,要保证性能的话,线程数量基本要限制几百(Linux上的线程有个特点,就是达到一定数量以后,会导致系统性能指数下降,参看SEDA的论文)。所以现在很多高性能web server都是使用事件驱动机制,比如nginx,Tornado,node.js等等。事件驱动几乎成了高并发的同义词,一时间红的不得了。 其实线程和事件,或者说同步和异步之争早就在学术领域争了几十年了。1978年有人为了平息争论,写了论文证明了用线性的process(线程的模式)和消息传递(事件的模式)是等价的,而且如果实现合适,两者应该有同等性能。当然这是理论上的。针对事件驱动的流行,2003年加大伯克利发表了一篇论文叫“Why events are a bad idea (for high-concurrency servers)”,指出其实事件驱动并没有在功能上有比线程有什么优越之处,但编程要麻烦很多,而且特别容易出错。线程的问题,无非是目前的实现的原因。一个是线程占的资源太大,一创建就分配几个MB的stack,一般的机器能支持的线程大受限制。针对这点,可以用自动扩展的stack,创建的先少分点,然后动态增加。第二个是线程的切换负担太大,Linux中实际上process和thread是一回事,区别就在于是否共享地址空间。解决这个问题的办法是用轻量级的线程实现,通过合作式的办法来实现共享系统的线程。这样一个是切换的花费很少,另外一个可以维护比较小的stack。他们用coroutine和nonblocking I/O(用的是poll()+thread pool)实现了一个原型系统,证明了性能并不比事件驱动差。 那是不是说明线程只要实现的好就行了呢。也不完全对。2006年还是加大伯克利,发表了一篇论文叫“The problem with threads”。线程也不行。原因是这样的。目前的程序的模型基本上是基于顺序执行。顺序执行是确定性的,容易保证正确性。而人的思维方式也往往是单线程的。线程的模式是强行在单线程,顺序执行的基础上加入了并发和不确定性。这样程序的正确性就很难保证。线程之间的同步是通过共享内存来实现的,你很难来对并发线程和共享内存来建立数学模型,其中有很大的不确定性,而不确定性是编程的巨大敌人。作者以他们的一个项目中的经验来说明,保证多线程的程序的正确性,几乎是不可能的事情。首先,很多很简单的模式,在多线程的情况下,要保证正确性,需要注意很多非常微妙的细节,否则就会导致deadlock或者race condition。其次,由于人的思维的限制,即使你采取各种消除不确定的办法,比如monitor,transactional memory,还有promise/future,等等机制,还是很难保证面面俱到。以作者的项目为例,他们有计算机科学的专家,有最聪明的研究生,采用了整套软件工程的流程:design review, code review, regression tests, automated code coverage metrics,认为已经消除了大多数问题,不过还是在系统运行4年以后,出现了一个deadlock。作者说,很多多线程的程序实际上存在并发错误,只不过由于硬件的并行度不够,往往不显示出来。随着硬件的并行度越来越高,很多原来运行完好的程序,很可能会发生问题。我自己的体会也是,程序NPE,core dump都不怕,最怕的就是race condition和deadlock,因为这些都是不确定的(non-deterministic),往往很难重现。 那既然线程+共享内存不行,什么样的模型可以帮我们解决并发计算的问题呢。研究领域已经发展了一些模型,目前越来越多地开始被新的程序语言采用。最主要的一个就是Actor模型。它的主要思想就是用一些并发的实体,称为actor,他们之间的通过发送消息来同步。所谓“Don’t communicate by sharing memory, share memory by communicating”。Actor模型和线程的共享内存机制是等价的。实际上,Actor模型一般通过底层的thread/lock/buffer 等机制来实现,是高层的机制。Actor模型是数学上的模型,有理论的支持。另一个类似的数学模型是CSP(communicating sequential process)。早期的实现这些理论的语言最著名的就是erlang和occam。尤其是erlang,所谓的Ericsson Language,目的就是实现大规模的并发程序,用于电信系统。Erlang后来成为比较流行的语言。 类似Actor/CSP的消息传递机制。Go语言中也提供了这样的功能。Go的并发实体叫做goroutine,类似coroutine,但不需要自己调度。Runtime自己就会把goroutine调度到系统的线程上去运行,多个goroutine共享一个线程。如果有一个要阻塞,系统就会自动把其他的goroutine调度到其他的线程上去。 一些名词定义:Processes, threads, green threads, protothreads, fibers, coroutines: what's the difference? Process: OS-managed (possibly) truly concurrent, at least in the presence of suitable hardware support. Exist within their own address space. Thread: OS-managed, within the same address space as the parent and all its other threads. Possibly truly concurrent, and multi-tasking is pre-emptive. Green Thread: These are user-space projections of the same concept as threads, but are not OS-managed. Probably not truly concurrent, except in the sense that there may be multiple worker threads or processes giving them CPU time concurrently, so probably best to consider this as interleaved or multiplexed. Protothreads: I couldn't really tease a definition out of these. I think they are interleaved and program-managed, but don't take my word for it. My sense was that they are essentially an application-specific implementation of the same kind of "green threads" model, with appropriate modification for the application domain. Fibers: OS-managed. Exactly threads, except co-operatively multitasking, and hence not truly concurrent. Coroutines: Exactly fibers, except not OS-managed.Coroutines are computer program components that generalize subroutines to allow multiple entry points for suspending and resuming execution at certain locations. Coroutines are well-suited for implementing more familiar program components such as cooperative tasks, iterators, infinite lists and pipes.Continuation: An abstract representation of the control state of a computer program.A continuation reifies the program control state, i.e. the continuationis a data structure that represents the computational process at a given point in the process' execution; the created data structure can be accessed by the programming language, instead of being hidden in the runtime environment. Continuations are useful for encoding other control mechanisms in programming languages such as exceptions, generators, coroutines, and so on. The "current continuation" or "continuation of the computation step" is the continuation that, from the perspective of running code, would be derived from the current point in a program's execution. The term continuations can also be used to refer to first-class continuations, which are constructs that give a programming language the ability to save the execution state at any pointand return to that point at a later point in the program.(yield keywork in some languages, such as c# or python) Goroutines: They claim to be unlike anything else, but they seem to be exactly green threads, as in, process-managed in a single address space and multiplexed onto system threads. Perhaps somebody with more knowledge of Go can cut through the marketing material.
关于DDD的理论知识总结,可参考这篇文章。 DDD社区官网上一篇关于聚合设计的几个原则的简单讨论: 文章地址:http://dddcommunity.org/library/vernon_2011/,该地址中包含了一篇关于介绍如何有效的设计聚合的一些原则,共3个pdf文件。该文章中指出了以下几个聚合设计的原则: 聚合是用来封装真正的不变性,而不是简单的将对象组合在一起; 聚合应尽量设计的小; 聚合之间的关联通过ID,而不是对象引用; 聚合内强一致性,聚合之间最终一致性; 上面这几条原则,作者通过一个例子来逐步阐述。下面我按照我的理解对每个原则做一个简单的描述。 聚合是用来封装真正的不变性,而不是简单的将对象组合在一起 这个原则,就是强调聚合的真正用途除了封装我们本身所关心的信息外,最主要的目的是为了封装业务规则,保证数据的一致性。在我看来,这一点是设计聚合时最重要和最需要考虑的点;当我们在设计聚合时,要多想想当前聚合封装了哪些业务规则,实现了哪些数据一致性。所谓的业务规则是指,比如一个银行账号的余额不能小于0,订单中的订单明细的个数不能为0,订单中不能出现两个明细对应的商品ID相同,订单明细中的商品信息必须合法,商品的名称不能为空,回复被创建时必须要传入被回复的帖子(因为没有帖子的回复不是一个合法的回复),等; 聚合应尽量设计的小 这个原则,更多的是从技术的角度去考虑的。作者通过一个例子来说明,该例子中,一开始聚合设计的很大,包含了很多实体,但是后来发现因为该聚合包含的东西过多,导致多人操作时并发冲突严重,导致系统可用性变差;后来开发团队将原来的大聚合拆分为多个小聚合,当然,拆分为小聚合后,原来大聚合内维护的业务规则同样在多个小聚合上有所体现。所以实现了既能解决并发冲突的问题,也能保证让聚合来封装业务规则,实现模型级别的数据一致性;另外,回复中的一位道友“殇、凌枫”提到,聚合设计的小还有一个好处,就是:业务决定聚合,业务改变聚合。聚合设计的小除了可以降低并发冲突的可能性之外,同样减少了业务改变的时候,聚合的拆分个数,降低了聚合大幅重构(拆分)的可能性,从而能让我们的领域模型更能适应业务的变化。 聚合之间通过ID关联 这个原则,是考虑到,其实聚合之间无需通过对象引用的方式来关联; 首先通过引用关联,会导致聚合的边界不够清晰,如果通过ID关联,由于ID是值对象,且值对象正好是用来表达状态的;所以,可以让聚合内只包含只属于自己的实体或值对象,那这样每个聚合的边界就很清晰;每个聚合,关心的是自己有什么信息,自己封装了什么业务规则,自己实现了哪些数据一致性; 如果通过引用关联,那需要实现LazyLoad的效果,否则当我们加载一个聚合的时候,就会把其关联的其他聚合也一起加载,而实际上我们有时在加载一个聚合时,不需要用到关联的那些聚合,所以在这种时候,就给性能带来一定影响,不过幸好我们现在的ORM都支持LazyLoad,所以这点问题相对不是很大; 你可能会问,聚合之间如果通过对象引用来关联,那聚合之间的交互就比较方便,因为我可以方便的直接拿到关联的聚合的引用;是的,这点是没错,但是如果聚合之间要交互,在经典DDD的架构下,一般可以通过两种方式解决:1)如果A聚合的某个方法需要依赖于B聚合对象,则我们可以将B聚合对象以参数的方式传递给A聚合,这样A对B没有属性上的关联,而只是参数上的依赖;一般当一个聚合需要直接访问另一个聚合的情况往往是在职责上表明A聚合需要通知B聚合做什么事情或者想从B聚合获取什么信息以便A聚合自己可以实现某种业务逻辑;2)如果两个聚合之间需要交互,但是这两个聚合本身只需要关注自己的那部分逻辑即可,典型的例子就是银行转账,在经典DDD下,我们一般会设计一个转账的领域服务,来协调源账号和目标账号之间的转入和转出,但源账号和目标账号本身只需要关注自己的转入或转出逻辑即可。这种情况下,源账号和目标账号两个聚合实例不需要相互关联引用,只需要引入领域服务来协调跨聚合的逻辑即可; 如果一个聚合单单保存另外的聚合的ID还不够,那是否就需要引用另外的聚合了呢?也不必,此时我们可以将当前聚合所需要的外部聚合的信息封装为值对象,然后自己聚合该值对象即可。比如经典的订单的例子就是,订单聚合了一些订单明细,每个订单明细包含了商品ID、商品名称、商品价格这三个来自商品聚合的信息;此时我们可以设计一个ProductInfo的值对象来包含这些信息,然后订单明细持有该ProductInfo值对象即可;实际上,这里的ProductInfo所包含的商品信息是在订单生成时对商品信息的状态的冗余,订单生成后,即便商品的价格变了,那订单明细中包含的ProductInfo信息也不会变,因为这个信息已经完全是订单聚合内部的东西了,也就是说和商品聚合无关了。 实际上通过ID关联,也是达到设计小聚合的目标的一种方式; 聚合内强一致性,聚合之间最终一致性 这个原则主要的背景是:如果用CQRS+Event Sourcing的架构来实现DDD,那聚合之间因为通过Domain Event(领域事件)来实现交互了,所以同样也不需要聚合与聚合之间的对象引用,同时也不需要领域服务了,因为领域服务已经被Process(流程聚合根)和Process Manager(流程管理器,无状态)所替代。流程聚合根,负责封装流程的当前状态以及流程下一步该怎么走的逻辑,包括流程遇到异常时的回滚处理逻辑;流程管理器,无状态。负责协调流程中各个参与者聚合根之间的消息交互,它会接受聚合根产生的domain event,然后发送command。另外一方面,由于CQRS的引入,使得我们的domain只需要处理业务逻辑,而不需要应付查询相关的需求了,各种查询需求专门由各种查询服务实现;所以我们的domain就可以非常瘦身,仅仅只需要通过聚合根来封装必要的业务规则(保证聚合内数据的强一致性)即可,然后每个聚合根做了任何的状态变更后,会产生相应的领域事件,然后事件会被持久化到EventStore,EventStore用来持久化所有的事件,整个domain的状态要恢复,只需要通过Event Sourcing的方式还原即可;另外,当事件持久化完成后,框架会通过事件总线将事件发布出去,然后Process Manager就可以响应事件,然后发送新的command去通知相应的聚合根去做必要的处理; 上面这个过程可以在任何一个CQRS的架构图(包括enode的架构图)中找到,我这里就不贴图了。enode中对经典的转账场景用这种思路实现了一下,有兴趣可以去下载enode源代码,然后看一下其中的BankTransferSample这个例子就清楚了。另外,因为事件的响应和Command的发送是异步的,所以,这种架构下,聚合根的交互是异步的; 需要再次强调的一点是,聚合如果只需要关注如何实现业务规则而不需要考虑查询需求所带来的好处,那就是我们不需要在domain里维护各种统计信息了,而只要维护各种业务规则所潜在的必须依赖的状态信息即可;举个例子,假如一个论坛,有版块和帖子,以前,我们可能会在版块对象上有一个帖子总数的属性,当新增一个帖子时,会对这个属性加1;而在CQRS架构下,domain内的版块聚合根无需维护总帖子数这个统计信息了,总帖子数会在查询端的数据库独立维护; 从聚合和哲学的角度思考,为什么需要状态? 聚合的角度 首先,什么是状态?很简单,比如一个商品的库存信息,那么该库存信息有一个商品的数量这个属性,表示当前商品在库存中还有多少件;那么我们为什么需要记录该属性呢?也就是为什么需要记录这个状态呢?因为有业务规则的存在。以这个例子为例,因为存在“商品的库存不能为负数”这样的一个业务规则,那这个规则如果要能保证,首先必须先记录商品的库存数量;因为商品的库存数量是会随着商品的卖出而减少的,而减少就是通过:Product.Count = Product.Count - 1这样的逻辑运算来实现;这个逻辑运算要能运行的前提就是商品要有库存信息。从这个例子我们不难理解,一个聚合根的很多状态,不是平白无辜设计上去的,而是某些业务规则潜在的要求,必须要设计这些状态才能实现相应的业务规则;这样的例子还有很多,比如银行账号的余额不能小于0,导致我们的银行账号必须要设计一个当前余额的属性; 另外一个原因是,看起来像是废话,呵呵。就是:因为我们关心这些信息,所以需要设计在当前聚合上;比如,以一个论坛的帖子为例,作为一个帖子,我们通常都会关心帖子的标题、描述、发帖人、发帖时间、所属版块(如果论坛有版块这个概念的话);所以,我们就会在帖子聚合根上设计出这些属性,以表达我们所关心的这些信息的状态; 哲学的角度 下面在从偏哲学的角度表达一下对象的概念吧: 人类永远无法认识完整的事物,因为我们认识到的总是事物的某一方面。我们所说的对象实际上是客观事物在人头脑里的反应,而事物则是不因人的认识发生改变的客观存在。同样一根铁棒,在钢材生产厂家看来,它是成品;在机械加工厂家看来,它是原料;在废品站看来,他是商品。成品、原料、商品,这三者拥有不同的属性,有本质的不同。为什么同一事物在不同人的眼里就截然不同了呢?这是因为我们总是取对我们有用的方面来认识事物。当这根铁棒作为商品时,它的原料属性依然存在,只是我们不关心了。 所以,总结出来就是,因为我们关心一个对象的某些方面,所以我们才会为他设计某些状态属性; 关于聚合的设计的一些思考 上面只是简单提到,聚合的设计应该多考虑它封装了哪些业务规则这个问题。下面我想再多讲一点我的一些想法: 关于GRASP九大模式中的最重要模式:信息专家模式 还是以论坛的帖子为例,创建一个帖子时,有一个业务规则,那就是帖子的发帖人、标题、描述、所属板块(如果论坛有板块这个概念的话)都不能为空或无效的值,因为这些信息只要有任何一个无效,那就意味着被创建出来的帖子是无效的,那就是没有保证业务规则,也就没办法谈领域模型的数据一致性了;如果像以往的三层贫血架构,那帖子只是一个数据的载体,不包含任何业务规则,帖子会先被构造一个空的帖子对象出来,然后我们给这个空帖子对象的某些属性赋值,然后保存该帖子对象到数据库;这种设计,帖子对象只是一个数据的容器,它完全控制不了自己的状态,因为它的状态都是被别人(如service)去修改的;这样的设计,相当于是没有把业务规则封装在业务对象内部,而是转移到了外部service中,虽然这样通常也没问题,事实上我们大部分人都一直在这么干,因为这样干写代码很随意,也很高效,呵呵。 GRASP九大模式中有一个面向对象的模式叫信息专家模式,不知道大家有了解过没有,该模式的描述是:将职责分配给拥有执行该职责所需信息的对象;这个模式告诉我们,如果一个对象负责维护一些信息,那它就有职责维护好这些信息。体现到对象的属性上,那就是这个对象的属性不能被外部随便更改,对象自己的属性必须自己负责维护修改。构造函数和普通的方法都会改变对象的状态,所以,我们对构造函数和对象普通的公共方法,都要秉持这个原则;这点非常重要,否则,如果像贫血模型那样,那对象就不叫对象了,而只是一个普通的容纳数据的容器而已,和数据库里的一条记录也无本质差别了。实际上,在我看来,这也是DDD中的聚合区别于贫血模型中的实体的最大的地方。聚合不仅有状态,还有严格维护好自己状态的各种方法,包括构造函数在内;而贫血模型,则只有状态,没有行为; 关于DDD中一个领域对象是否是聚合根的考虑 这个问题,没有非常清晰的放之四海而皆准的确定方法,我的想法是: 首先从我们对领域的最基本的常识方面的理解去思考,该对象是否有独立的生命周期,如果有,那基本上是聚合根了; 如果领域内的一个对象,我们会在后台有一个独立的模块去管理它,那它基本上也是聚合根了; 是否有独立的业务场景会去创建或修改一个对象; 如果对象有全局唯一的标识,那它也是聚合根了; 如果你不能确定一个对象是否是聚合根的的时候,就先放一下,就先假定它是聚合根也无妨,然后可以先分析一下你已经确定的那些聚合根应该具体聚合哪些信息;也许等你分析清楚其他的那些聚合的范围后,也推导出了你之前不确定是否是聚合根的那个对象是否应该是聚合根了呢。 关于一个聚合内应该聚合哪些信息的思考 把我们所需要关心的属性设计进去; 分析该聚合要封装和实现哪些业务规则,从而像上面的例子(商品库存)那样推导出需要设计哪些属性状态到该聚合内; 如果我们在创建或修改一个对象时,总是会级联创建或修改一些级联信息,比如在一个任务系统,当我们创建一个任务时,可能会上传一些附件,那这些附件的描述信息(如附件ID,附件名称,附件下载地址)就应该被聚合在任务聚合根上; 聚合内只需要值对象和内部的实体即可,不需要引用其他的聚合根,引用其他的聚合根只会让当前聚合的边界模糊; 关于如何更合理的设计聚合来封装各种业务规则的思考 这一点在最上面的几个原则中,实际上已经提到过一点,那就是尽量设计小聚合,这里的出发点主要是从技术的角度去思考,为了降低对公共对象(大聚合)的并发修改,从而减小并发冲突的可能性,从而提高系统的可用性(因为系统用户不会经常因为并发冲突而导致它的操作失败);关于这一点,我还想再举几个例子,来说明,其实要实现各种业务规则,可以有多种聚合的设计方式,大聚合只是其中一种; 比如,帖子和回复,大家都知道一个帖子有多个回复,没有帖子,回复就没有意义;所以很多人就会认为帖子应该聚合回复;但实际上不需要这样,如果你这样做了,那对于一个论坛来说,同一个帖子被多个人同时回复的可能性是非常高的,那这样的话,多个人同时回复一个帖子,就会导致多个人同时修改同一个帖子对象,那就导致大家都回复不了,因为会有并发冲突或者数据库事务的等待超时,因为大家都在修改同一个帖子聚合根;实际上如果我们从业务规则的角度去思考一下,那可以发现,其实帖子和回复之间,只有一个简单的规则,那就是回复一旦被创建,那他所对应的帖子不能被修改即可;这样的话,要实现这个规则其实很简单,把回复作为聚合根,然后把帖子传入回复聚合根的构造函数,然后回复保存帖子ID,然后回复将帖子ID设置为不允许外部修改(private set;即可),这样我们就实现了这个业务规则,同时还做到了多人同时推一个帖子回复时,不会对同一个帖子对象就并发修改,而是每个回复都是并行的往数据库插入一条回复记录即可; 所以,通过这个例子,我们发现,要实现领域模型内的各种业务规则,方法不止一种,我们除了要从业务角度考虑对象的内聚关系外,还要从技术角度考虑,但是不管从什么角度考虑,都是以实现所要求的业务规则为前提; 从这个例子,我们其实还发现了另外一件有意义的事情,那就是一个论坛中,发表帖子和发表回复是两个独立的业务场景;一个人发表了帖子,然后可能过了一段时间,另一个人对该帖子发表了回复;所以将帖子和回复都设计为独立的很容易理解;这里虽然帖子和回复是一对多,回复离开帖子确实也没意义,但是将回复设计在帖子内没任何好处,反而让系统的可用性降低;相反,像上面提到的关于创建任务时同时上传一些附件的例子,虽然一个任务也是对应多个附件信息,但是我们发现,人物的附件信息总是随着任务被创建或修改时,一起被修改的。也就是说,我们没有独立的业务场景需要独立修改任务的某个附件信息;所以,没有必要将任务的附件信息设计为独立聚合根; ENode框架对聚合设计和聚合之间交互的支持 enode提供了一个基于DDD+CQRS+Event Sourcing+In Memory+EDA这些技术的应用开发架构; enode在框架层面就限制了一个command只能修改一个聚合根,这就杜绝了我们使用Unit of Work的模式来以事务的方式来一次性修改多个聚合根; enode提供了可靠的原子操作和并发冲突检测机制,来保证对单个聚合的操作的强一致性; enode提供了可靠的事件机制,来保证我们的domain中的聚合之间数据交互可以通过事件异步通信的方式来实现聚合之间的最终一致性;如果有些复杂业务场景是一个流程,那我们可以通过Process+Process Manager的思想来实现流程状态的跟踪和流程的流转; enode因为基于domain event,所以,我们的聚合根不需要引用,每个聚合根只需要负责自己的状态更新,然后更新完后产生相应的domain event即可,这本质就是就是实现了:Don’t Ask, Tell这个设计原则; enode提供了可靠的事件发布机制,可以确保command side和query side的数据最终一定是一致的; enode提供了in memory的设计,使得我们的domain可以非常高效的运行,持久化事件不需要事务,获取聚合根直接从in memory获取; enode提供了很多设计,可以让我们最大化的对不同的聚合根实例做并行操作,从而提高整个系统的吞吐量; 使用enode,将会迫使你思考如何设计聚合,如何通过流程实现聚合之间的异步交互;迫使你思考如何定义domain event,将领域内的状态更改显式化;迫使你将外部对领域的各种操作显式化,即定义出各种command;迫使你将command side和query side的数据分离和架构分离,技术分离。减少的是,我们不必再设计unit of work,不必设计domain service,不必让聚合设计各种非第一手的冗余的统计信息;
开源地址:https://github.com/tangxuehua/enode 上一篇文章,简单介绍了enode框架内部的整体实现思路,用到了staged event-driven architecture的思想。通过前一篇文章,我们知道了enode内部有两种队列:command queue、event queue;用户发送的command会进入command queue排队,domain model产生的domain event会进入event queue,然后等待被dispatch到所有的event handlers。本文介绍一下enode框架中这两种消息队列到底是如何设计的。 先贴一下enode框架的内部实现架构图,这样对大家理解后面的分析有帮助。 我们需要什么样的消息队列 enode的设计初衷是在单个进程内提供基于DDD+CQRS+EDA的应用开发。如果我们的业务需要和其他系统交互,那也可以,就是通过在event handler中与其他外部系统交互,比如广播消息出去或者调用远程接口,都可以。也许将来,enode也会内置支持远程消息通信的功能。但是不支持远程通信并不表示enode只能开发单机应用了。enode框架需要存储的数据主要有三种: 消息,包括command消息和event消息,目前出于性能方面的考虑,是存储在mongodb中;之所以要持久化消息是因为消息队列里的消息不能丢失; 聚合根,聚合根会被序列化,然后存储在内存缓存中,如redis或memcached中; 事件,就是由聚合根产生的事件,事件存储在eventstore中,如mongodb中; 好,通过上面的分析,我们知道enode框架运行时的所有数据,就存储在mongodb和redis这两个地方。而这两种存储都是部署在独立的服务器上,与web服务器无关。所以运行enode框架的每台web服务器上是无状态的。所以,我们就能方便的对web服务器进行集群,我们可以随时当用户访问量的增加时增加新的web服务器,以提高系统的响应能力;当然,当你发现随着web服务器的增加,导致单台mongodb服务器或单台redis服务器处理不过来成为瓶颈时,也可以对mongodb和redis做集群,或者对数据做sharding(当然这两种做法不是很好做,需要对mongodb,redis很熟悉才行),这样就可以提高mongodb,redis的吞吐量了。 好了,上面的分析主要是为了说明enode框架的使用范围,讨论清楚这一点对我们分析需要什么样的消息队列有很大帮助。 现在我们知道,我们完全不需要分布式的消息队列了,比如不需要MSMQ、RabbitMQ,等重量级成熟的支持远程消息传递的消息队列了。我们需要的消息队列的特征是: 基于内存的消息队列; 虽然基于内存,但消息不能丢失,也就是消息要支持持久化; 消息队列要性能尽量高; 消息队列里没有消息的时候,队列的消费者不能让CPU空转,CPU空转会直接导致CPU占用100%,导致机器无法工作; 要支持多个消费者线程同时从队列取消息,但是同一个消息只能被一个消费者处理,也就是一个消息不能同时被两个消费者取走,也就是要支持并发的dequeue; 需要一种设计,实现消息至少会被处理一次;具体指:消息被消费者取走然后被处理的过程中,如果没有处理成功(消费者自己知道有没有处理成功)或者根本没来得急处理(比如那时正好断电了),那需要一种设计,可以我们有机会重新消费该消息; 因为我们做不到100%不会重复处理一个消息,所以我们的所有消息消费者要尽量做到支持等幂操作,就是重复的操作不会引起副作用;比如插入前先查询是否存在就是一种支持等幂的措施;这一点,框架会尽量提供支持等幂的逻辑,当然,用户自己在设计command handler或event handler时,也要尽量考虑等幂的问题。注意:一般command handler不用考虑,我们主要要考虑的是event handler。原因,下次文章中再细谈吧。 内存队列的设计 内存队列,特点是快。但是我们不光是需要快,还要能支持并发的入队和出对。那么看起来ConcurrentQueue<T>似乎能满足我们的要求了,一方面性能还可以,另一方面内置支持了并发操作。但是有一点没满足,那就是我们希望当队列里没有消息的时候,队列的消费者不能让CPU空转,CPU空转会直接导致CPU占用100%,导致机器无法工作。幸运的是,.net中也有一个支持这种功能的集合,那就是:BlockingCollection<T>,这种集合能提供在队列内无元素的时候block当前线程的功能。我们可以用以下的方式来实例化一个队列: private BlockingCollection<T> _queue = new BlockingCollection<T>(new ConcurrentQueue<T>()); 并发入队的时候,我们只要写下面的代码即可: _queue.Add(message); 并发出队的时候,只要: _queue.Take(); 我们不难看出,ConcurrentQueue<T>是提供了队列加并发访问的支持,而BlockingCollection<T>是在此基础上再增加blocking线程的功能。 是不是非常简单,经过我的测试,BlockingCollection<T>的性能已经非常好,每秒10万次入队出对肯定没问题,所以不必担心成为瓶颈。 关于Disruptor的调研: 了解过LMAX架构的朋友应该听说过Disruptor,LMAX架构能支持每秒处理600W订单,而且是单线程。这个速度是不是很惊人?大家有兴趣的可以去了解下。LMAX架构是完全in memory的架构,所有的业务逻辑基于纯内存实现,粗粒度的架构图如下: Business Logic Processor完全在in memory中跑,简称BLP; Input Disruptor是一种特殊的基于内存运行的环形队列(基于一种叫Ring Buffer的环形数据结构),负责接收消息,然后让BLP处理消息; Output Disruptor也是同样的队列,负责将BLP产生的事件发布出去,给外部组件消费,外部组件消费后可能又会产生新的消息塞入到Input Disruptor; LMAX架构之所以能这么快,除了完全基于in memory的架构外,还归功于延迟率在纳秒级别的disruptor队列组件。下面是disruptor与java中的Array Blocking Queue的延迟率对比图: ns是纳秒,我们可以从数据上看到,Disruptor的延迟时间比Array Blocking Queue快的不是一个数量级。所以,当初LMAX架构出来时,一时非常轰动。我曾经也对这个架构很好奇,但因为有些细节问题没想清楚,就不敢贸然实践。 通过上面的分析,我们知道,Disruptor也是一种队列,并且也完全可以替代BlockingCollection,但是因为我们的BlockingCollection目前已经满足我们的需要,且暂时不会成为瓶颈,所以,我暂时没有采用Disruptor来实现我们的内存队列。关于LMAX架构,大家还可以看一下这篇我以前写的文章。 队列消息的持久化 我们不光需要一个高性能且支持并发的内存队列,还要支持队列消息的持久化功能,这样我们才能保证消息不会丢失,从而才能谈消息至少被处理一次。 那消息什么时候持久化? 当我们发送一个消息给队列,一旦发生成功,我们肯定认为消息已经不会丢了。所以,很明显,消息队列内部肯定是要在接收到入队的消息时先持久化该消息,然后才能返回。 那么如何高效的持久化呢? 第一个想法: 基于txt文本文件的顺序写。原理是:当消息入队时,将消息序列化为文本,然后append到一个txt1文件;当消息被处理完之后,再把该消息append到另一个txt2文件;然后,如果当前机器没重启,那内存队列里当前存在的消息就是还未被处理的消息;如果机器重启了,那如何知道哪些消息还没被处理?很简单,就是对比txt1,txt2这两个文本文件,然后只要是txt1中存在,但是txt2中不存在的消息,就认为是没被处理过,那需要在enode框架启动时读取txt1中这些没被处理的消息文本,反序列化为消息对象,然后重新放入内存队列,然后开始处理。这个思路其实挺好,关键的一点,这种做法性能非常高。因为我们知道顺序写文本文件是非常快的,经过我的测试,每秒200W行普通消息的文本不在话下。这意味着我们每秒可以持久化200W个消息,当然实际上我们肯定达不到这个高的速度,因为消息的序列化性能达不到这个速度,所以瓶颈是在序列化上面。但是,通过这种持久化消息的思路,也会有很多细节问题比较难解决,比如txt文件越来越大,怎么办?txt文件不好管理和维护,万一不小心被人删除了呢?还有,如何比较这两个txt文件?按行比较吗?不行,因为消息入队的顺序和处理的顺序不一定相同,比如command就是如此,当用户发送一个command到队列,但是处理的时候发现第一次由于并发冲突,导致command执行没成功,所以会重试command,如果重试成功了,然后持久化该command,但是我们知道,此时持久化的时候,它的顺序也许已经在后面的command的后面了。所以,我们不能按行比较;那么就要按消息的ID比较了?就算能做到,那这个比较过程也是很耗时的,假设txt1有100W个消息;txt2中有80W个消息,那如果按照ID来比较txt1中哪20W个消息还没被处理,有什么算法能高效比较出来吗?所以,我们发现,这个思路还是有很多细节问题需要考虑。 第二个想法: 采用NoSQL来存储消息,通过一些思考和比较后,觉得还是MongoDB比较合适。一方面MongoDB实际上所有的存取操作优先使用内存,也就是说不会马上持久化到磁盘。所以性能很快。另一方面,mongodb支持可靠的持久化功能,可以放心的用来持久化消息。性能方面,虽然没有写txt那么快,但也基本能接受了。因为我们毕竟不是整个网站的所有用户请求的command都是放在一个队列,如果我们的网站用户量很大,那肯定会用web服务器集群,且每个集群机器上都会有不止一个command queue,所以,单个command queue里的消息我们可以控制为不会太多,而且,单个command queue里的消息都是放在不同的mongodb collection中存储;当然持久化瓶颈永远是IO,所以真的要快,那只能一个独立的mongodb server上设计一个collection,该collection存放一个command queue里的消息;其他的command queue的消息就也采用这样的做法放在另外的mongodb server上;这样就能做到IO的并行,从而根本上提高持久化速度。但是这样做代价很大的,可能需要好多机器呢,整个系统有多少个queue,那就需要多少台机器,呵呵。总而言之,持久化方面,我们还是有一些办法可以去尝试,还有优化的余地。 再回过头来简单说一下,采用mongodb来持久化消息的实现思路:入队的时候持久化消息,出队的时候删除该消息;这样当机器重启时,要查看某个队列有多少消息,只要通过一个简单的查询返回mongodb collection中当前存在的消息即可。这种做法设计简单,稳定,性能方面目前应该还可以接受。所以,目前enode就是采用这种方法来持久化所有enode用到的内存队列的消息。 代码示意,有兴趣的可以看看: View Code 如何保证消息至少被处理一次 思路应该很容易想到,就是先把消息从内存队列dequeue出来,然后交给消费者处理,然后由消费者告诉我们当前消息是否被处理了,如果没被处理好,那需要尝试重试处理,如果重试几次后还是不行,那也不能把消息丢弃了,但也不能无休止的一直只处理这个消息,所以需要把该消息丢到另一个专门用于处理需要重试的本地纯内存队列。如果消息被处理成功了,那就把该消息从持久化设备中删除即可。看一下代码比较清晰吧: private void ProcessMessage(TMessageExecutor messageExecutor) { var message = _bindingQueue.Dequeue(); if (message != null) { ProcessMessageRecursively(messageExecutor, message, 0, 3); } } private void ProcessMessageRecursively(TMessageExecutor messageExecutor, TMessage message, int retriedCount, int maxRetryCount) { var result = ExecuteMessage(messageExecutor, message); //这里表示在消费(即处理)消息 //如果处理成功了,就通知队列从持久化设备删除该消息,通过调用Complete方法实现 if (result == MessageExecuteResult.Executed) { _bindingQueue.Complete(message); } //如果处理失败了,就重试几次,目前是3次,如果还是失败,那就丢到一个重试队列,进行永久的定时重试 else if (result == MessageExecuteResult.Failed) { if (retriedCount < maxRetryCount) { _logger.InfoFormat("Retring to handle message:{0} for {1} times.", message.ToString(), retriedCount + 1); ProcessMessageRecursively(messageExecutor, message, retriedCount + 1, maxRetryCount); } else { //这里是丢到一个重试队列,进行永久的定时重试,目前是每隔5秒重试一下,_retryQueue是一个简单的内存队列,也是一个BlockingCollection<T> _retryQueue.Add(message); } } } 代码应该很清楚了,我就不多做解释了。 总结: 本文主要介绍了enode框架中消息队列的设计思路,因为enode中有command queue和event queue,两种queue,所以逻辑是类似的;所以本来还想讨论一下如何抽象和设计这些queue,已去掉重复代码。但时间不早了,下次再详细讲吧。
开源地址:https://github.com/tangxuehua/enode 上一篇文章,简单介绍了enode框架的command service api设计思路。本文介绍一下enode框架对Staged Event-driven architecture思想的运用。通过前一篇文章我们知道command service是会被高并发的访问,我们除了可以用异步的方式执行command以及集群的方式来提高系统响应性能外。最根本上要解决的问题是尽量快的处理单个command。这样才能在单位时间内处理更多的command。 先贴一下enode框架的内部实现架构图,这样对大家理解后面的分析有帮助。 我觉得要尽量快的处理command,主要思路有两点: 能并行处理的尽量并行 command service接收到command后,会把command发送到某个可用的command队列。然后该command队列的出口端,如果只有单个线程在处理command,而且这个线程如果有IO操作,那肯定快不到哪里去;因为只要处理单个command的速度跟不上command进入队列的速度,那command队列里的command就会不断增多,导致command执行的延迟增加。所以,思路就是,设计多个线程(就是上图中的Command Processor中的worker)来同时从command队列拿command,然后处理。这样就能实现多个线程在同时处理不同的command。 但是光这样还不够,实际上我们还可以做的更好,那就是command queue也可以设计为多个。也就是说command service接收到command后,会通过一个command router,将当前command路由到某个可用的command队列,然后将该command发送到该队列。这样做的好处是,我们的command service背后有多个command队列(上图画了两个command queue)支撑着,每个command队列的出口端又有多个线程在同时处理。这样的话,我们就能最大化的压榨我们的服务器CPU和内存等资源了。当然,框架要支持允许用户配置多少个command队列,以及每个队列多少个线程处理。这样框架使用者就能根据当前服务器的CPU个数来决定该如何配置了。 同理,domain model产生的事件(domain event)的处理也应该要并行处理;那具体是什么处理呢?就是上图中的Event Processor所做的事情。Event Processor会包含多个worker,每个worker就是一个线程。每个worker会从event queue中拿出事件,然后将事件进一步分发(dispatch)给所有的事件订阅者。 那么上面这些并行执行的逻辑是如何访问共享资源的呢? 对于command processor中的每个work线程,从上面的架构图可以清晰的看到,共享资源是event store和memory cache。event store,我们会并发的写入事件;memory cache,我们会并发的更新聚合根。所以,这两种存储都必须很好的支持高并发的写入,且要高效;经过我的一些调研,个人觉得mongodb比较适合作为eventstore。原因是:1)支持集群和sharding;2)支持唯一索引;3)支持关系型查询;4)高性能,默认是先保存到内存,每100ms将内存数据写入日志,每1分钟将内存数据正式写入磁盘;基于这4点,我们能利用mongodb实现一个比较理想的eventstore。而内存缓存(memory cache),我觉得memcached或者redis都还不错,都是比较成熟的分布式缓存。利用分布式缓存,我们不必担心数据放不下的问题,因为我们可以对数据按特征进行分区存放。这个思路就像数据库的分库分表类似。需要注意的是,eventstore必须支持严格控制并发冲突,mongodb的唯一索引可以确保这一点;而memory cache,不用支持并发冲突检测,只要能保障快速的根据key读写即可。因为我们总是先持久化完事件后再将最新状态的聚合根更新到memory cache,而持久化事件到eventstore已经做了并发冲突检测,所以更新到memory cache就一定也是按照事件持久化的顺序被更新到memory cache的。另外,实际上event store和memory cache是被整个web服务器集群所共享的。不过幸好mongodb,redis等产品足够强大,都支持横向扩展,所以我们完全有信心在web服务器不断增加的情况下,也对mongodb,redis做相应的横向扩展,从而不会让这两个地方产生瓶颈。 能允许延迟处理的尽量延迟 处理每个command时,会调用domain model执行业务逻辑,然后domain model会产生事件(domain event)。然后框架会在command处理完毕后自动对事件进行后续的处理。主要做的事情是:1)持久化事件到eventstore;2)更新memory cache;3)将事件publish出去。这三步分别对应上图中的3、4、5三个箭头。大家可以看到,对于publish事件这一步,我们不是马上将事件dispatch给事件订阅者的,而是先发送到event queue,然后异步的方式dispatch事件。 这里可以这样做的原因是,当领域事件被持久化到eventstore,本质上就已经表示业务逻辑处理完成了。事件是一件已发生的事情,事件被保存了就表示这件已发生的事情被记载了,也就是说,成为了历史。当我们下次要获取最新的聚合根时,如果从内存缓存里获取到的聚合根的状态是旧的,那从eventstore中通过event sourcing得到的聚合根一定是最新的,因为eventstore中存放了所有最新的历史。所以,我们可以知道,只要事件被持久化完成了,那后续的所有步骤都可以异步的方式来做。但是为什么更新memory cache没有异步的做呢?因为command handler在处理业务逻辑时,获取聚合根是从memory cache获取的,所以越早更新memory cache,我们拿到的聚合根的数据就越可能是最新的,拿到的数据越新,就意味着产生并发冲突的可能性就越低。实际上,因为像memcached, redis这样的分布式缓存,性能是非常高的,每秒1万次的读写操作问题应该不大。所以,我们可以认为内存缓存中的数据总是与eventstore实时保持一致的,因为延迟在0.1毫秒以内。因此,我们会在事件被保存到eventstore后,马上将最新状态的聚合根写入到memory cache。 但是,将事件dispatch给所有的事件订阅者这个操作是非常耗时的,因为我们无法知道每个事件订阅者具体做的事情,比如有些是更新CQRS查询端的读库的表,有些是调用外部系统的接口,等等。所以,dispatch这个逻辑必须异步,实际上,这也是CQRS架构的核心思路。另外,我们为了尽量快的dispatch事件,如上面提到,我们会开多个线程去并行的dispatch事件给事件订阅者。 有一个需要好好考虑的问题是:我们如何保证事件的持久化顺序与publish出去的顺序相同?这个问题,下次专门写一篇文章好好讨论吧。有兴趣的也可以想想为什么要解决这个问题,也非常欢迎和我讨论解决方案。 关于来自ebay的经验学习 可伸缩性最佳实践:来自eBay的经验,这篇文章中提到,提高可伸缩性的一项关键措施是积极地采取异步策略。 如果组件A同步调用组件B,那么A和B就是紧密耦合的,而紧耦合的系统其可伸缩性特征是各部分 必须共同进退——要伸缩A必须同时伸缩B。同步调用的组件在可用性方面也面临着同样的问题。我们回到最基本的逻辑:如果A推出B,那么非B推出非A。也就 是说,若B不可用,则A也不可用。如果反过来A和B的联系是异步的,不管是通过队列、多播消息、批处理还是什么其他手段,它们就可以分别地伸缩。而且,此 时A和B的可用性特征是相互独立的——即使B受困或者死掉,A仍然能够继续前进。 整个基础设施从上到下都应该贯彻这项原则。即使在单个组件内部也可通过SEDA(分阶段的事件驱动架构,Staged Event-Driven Architecture)等技术实现异步性,同时保持一个易于理解的编程模型。组件之间也遵守同样的原则——尽可能避免同步带来的耦合。在多数情况下, 两个组件在任何事件中都不会有直接的业务联系。在所有的层次,把过程分解为阶段(stages or phases),然后将它们异步地连接起来,这是伸缩的关键。 用异步的原则解耦程序,尽可能将过程变为异步的。对于要求快速响应的系统,这样做可以从根本上减少请求者所经历的响应延迟。对于网站或者交易系统, 牺牲数据或执行的延迟时间(完成全部工作的实践)来换取用户的延迟时间(用户得到响应的时间)是值得的。活动跟踪、单据开付、决算和报表等处理过程显然都 应该属于后台活动。主要用例过程中常常有很多步骤可以进一部分解成异步运行。任何可以晚点再做的事情都应该晚点再做。 还有一个同等重要的方面认识到的人不多:异步性可以从根本上降低基础设施的成本。同步地执行操作迫使你必须按照负载的峰值来配备基础设施——即使在任务最重的那一天里任务最重的那一秒,设施也必须有能力立即完成处理。而将昂贵的处理过程转变为异步的流,基础设施就不需要按照峰值来配备,只需要满足平 均负载。而且也不需要立即处理所有的请求,异步队列可以将处理任务分摊到较长的时间里,因而起到削峰的作用。系统的负载变化越大,曲线越多尖峰,就越能从异步处理中得益。 enode框架内部的实现思路,正是学习了这种SEDA的思想,将一个command的处理过程分为两个阶段(command执行,event分发),每个阶段尽量调用多的资源去并行处理,两个阶段之间通过队列连接,实现这两个阶段之间相互不受影响,比如事件分发失败不会影响command的执行;并且,因为两个阶段之间没有直接联系,所以事件分发虽然相对较慢,但也不会影响command的执行效率;这就是SEDA的好处,将过程分阶段,分阶段的依据是找出这个过程中哪些地方可以延迟执行,即可以允许异步执行。然后用队列衔接每个阶段,对每个阶段优化处理。 总结 通过上面的分析,我们知道了,enode框架内部实现的主要设计思路是: 每个系统依赖于enode框架都可以支持web服务器集群; 每台web服务器上面部署了一个enode框架实例; 每个enode框架实例有一个唯一的command service; 每个command service内部有多个command queue,通过command router来路由command; 每个command queue的出口端都有一个command processor在消费command; 每个command processor内部实际是通过多个worker线程在并行的消费command; 每个worker线程都访问共享的event store和memory cache资源; 每个worker线程在消费完一个command后,产生的事件先发送到一个可用的event queue,同样也会通过一个event queue router来路由; 每个event queue的出口端都有一个event processor在消费event; 每个event processor内部实际也是通过多个worker线程在并行的分发event给事件订阅者; 就写这些吧,希望本文能对大家理解enode有所帮助。
开源地址:https://github.com/tangxuehua/enode 上一篇文章,介绍了enode框架的物理部署思路。本文我们再简单分析一下Command Service的API设计: Command Service在enode框架中的地位非常重要,用户使用enode框架的主入口就是command service。UI层如controller会通过发送command给command service,然后框架就开始处理该command。不然看出,command service有可能会被高并发的访问。那么command service该提供什么样的API呢? 首先command service的职责是什么?是处理controller发送过来的command,那如何处理呢?大方向一般就两种,即同步执行command和异步处理command;同步的时候,用户希望command完全处理完才返回,中间如果遇到任何错误,就报异常,然后controller会捕获该异常,然后做后续处理;一般用户希望马上知道command有没有执行成功时,会用同步的方法来执行command。异步处理command,用户只需要把command发送给command service,然后不用等待command处理完成。但是用户可能希望知道command什么处理完成了,所以需要提供一个回调函数,通过回调函数来通知用户某个command处理完成了,一般异步编程的风格都会有回调函数的设计。基于上面的分析,enode的ICommandService接口的设计如下: /// <summary>Represents a service to send or execute command. /// </summary> public interface ICommandService { /// <summary>Send a command asynchronously. /// </summary> void Send(ICommand command, Action<CommandAsyncResult> callback = null); /// <summary>Execute a command synchronously. /// </summary> void Execute(ICommand command); } 代码应该很清晰了,就不解释了。如果我们追求最快的用户可用性,那可以选择异步执行command,即调用send方法;如果每次都希望command执行完才返回,则可以使用同步执行command,即execute方法;目前框架中同步执行command的实现原理其实是在异步的基础上加了一个同步控制, 优点: 框架内部对command的处理流程可以完全一致了,不必因为需要同步和异步的处理而设计重复的代码,当然,我们可以做一些抽象,以减少重复的代码,但实际上这比较困难; 内部都是异步处理可以很方便的实现command的重试机制; 利用ManualResetEvent实现异步同步化,我们可以很方便的实现command的处理超时控制; 因为内部所有command的处理都是异步的,也就是所有的command都在固定的一些队列中排队等候处理,而队列的消费者,即处理command的线程我们在框架启动时就做了配置,所以访问domain in memory的并发线程数量可控,这样我们就可以一定程度上降低并发冲突的可能性; 缺点: 用户调用ICommandService.Execute方法法执行某个command时,他的意图是希望马上执行某个command并返回结果;但是我们现在内部并不是马上处理该command,而是先排队,然后用ManualResetEvent卡住当前线程,然后当command处理完成后,才允许当前线程继续往下走。当然,如果command迟迟未被执行(默认是10秒),则会自动超时,然后返回给command发起者;这样做的坏处是当并发很高的时候,同步执行command可能会超时;对于这一点,在这篇博文中已经对如何提高系统的“高吞吐量、低延迟、高可用”做了比较详细的思路分析,还没看过的朋友可以去看一下。
开源地址:https://github.com/tangxuehua/enode 上一篇文章,介绍了enode框架的总体目标,以及如何实现高吞吐、低延迟、高可用、无单点问题的实现思路。本篇文章,我们再分析一下其他一些需要考虑的问题。我发现写文章挺累的,费时费脑经,但我会坚持下去。本文主要分析一下enode框架的物理部署: 物理部署思路:集群的web站点+分布式缓存和存储 集群的概念:多台机器做相同的业务,对外如一台机器在做事情一样,集群中任意一台机器挂了没有影响,因为其他机器还在工作;集群的机器要访问的数据的设计,我觉得一般有两种思路: 集群中每台机器都用自己的数据。数据一致性是通过每台机器之间的数据同步来实现,这样做的主要难题是数据同步的延迟带来的数据不一致的问题;但是好处是,因为没有任何共享数据,所以一台机器挂了完全对整个系统没有任何影响。因为这样的设计相当于是完全同时由很多独立的且没有共享任何数据的机器在同时工作,当然是最能容灾的了; 有一台机器存放数据的共享数据,集群中每台机器都访问这些共享数据。这种设计的好处是数据不用同步了,没有数据延迟带来数据不一致的问题;但是坏处是,有单点问题,万一共享数据的服务器挂了不是麻烦了。幸好,现在有很多开源的成熟的分布式缓存和分布式存储的产品,如memcached, redis这些都是分布式的缓存,可以有效的避免单点故障的问题,虽然挂了的单台memcached服务器会影响一部分数据的读取和写入,但是至少不会给整个系统带来挂掉的后果;同样分布式存储如mongodb,也能做到这样的效果。这两种产品,在分布式部署方面我还没有任何实际经验,平时工作中也不曾遇到过,所以今后还需要好好的学习和实践。 分布式的概念:一个业务在不同的物理点上做,比如web服务器(处理UI逻辑)、应用服务器(处理业务逻辑),这两个节点分开部署在不同的机器上,共同完成一个业务;分布式的特点是,每个节点都不能挂,否则这个业务就不能完成了;当然,我们可以给分布式中的每个节点都做集群处理,这样可以降低分布式系统的单节点故障; 但是因为分布式要完成一个业务,内部要夸网络通信如调用远程服务,所以性能肯定比没有调用远程服务的设计要低。一般我们不会采用分布式,用分布式都是被逼的,比如以下情况下,我们可能会采用分布式的设计: 一个系统,有几大块业务,相互比较独立,为了让每块业务都能独立设计和发展,我们会把这些不同的业务模块分开设计与实现;比如一个电子商务网站的交易中心和商品中心,可以独立分开设计; 数据量太大,没办法存放在一个点,所以只能分开存储;这种情况我们一般会把数据分区,不同分区的数据放在不同的点;如数据库的分库分表,还有一些分布式缓存如memcached、redis,还有如mongodb这样的支持分布式存储的文档型数据库; 一个系统,不同的层次使用完全不同的技术实现,比如由于历史原因,我们要对一个系统改造,但是这个系统的业务逻辑很复杂,而且都是用c++写的,我们不敢随便动;但是我们希望可以在UI上给这个系统重新设计以带来更好的用户体验,比如原来是用c++写的界面,现在希望通过WPF这种更高级更炫开发维护成本更炫的技术来实现。那么我们就会在同一个系统使用不同的语言和技术来实现。这种情况下,我们可能需要将c++实现的业务逻辑通过远程服务暴露出来,比如通过WCF暴露,WCF远程服务本身可以由c#编写,然后c#调用managed c++,然后managed c++调用unmanaged c++。从而实现业务逻辑的远程服务暴露;而在UI层,我们可以使用c#+WPF的方式来实现,然后UI层调用WCF远程服务。这种架构就是因为一个系统中不同层次因为使用了完全不同的技术而需要使用分布式的情况。
原文地址:http://www.douban.com/note/164191021/ “模型、状态和行为特征、场景”和“四象图”,建模观的命名与立象。 建模原语:四象图 作者:achieveidea@gmail.com 命名:模型、结构特征、行为特征、场景(及其规约)。 释义: 模型,描述事物为一组时间函数,蕴藏了与事物相关的所有事实。 特征,从模型上剥离的一组时间函数。特征分为两大类,一类是结构特征,一类是行为特征。 场景,模型凝聚相应的特征持续一段时间,描述一段时间内与模型相关的事实。场景中隐藏的一些规则、约定,称之为场景规约。 用法: 一笔一纸,一横一竖,四象顿生。 一象画景,三象画物,物在景中。 二象画形,四象画神,形神兼备。 万象入画,浑然一体,一目了然。 注:第一象限画景,即描述场景;第三现象画物,即描述模型。第二现象画物之形,即描述模型的结构特征;第四象限画物之神,即描述模型的行为特征。 诸子百家: 如果你熟悉四色原型,DESC可理解为结构特征,PPT可理解为模型,Role可理解为行为特征,MI可理解为场景。 如果你熟悉DDD, VO可理解为结构特征,Entity可理解为模型,Aggregate可理解为行为特征,Service可理解为场景, Specification可以理解为场景规约。 如果你熟悉DCI,D可理解为模型,C可以理解为场景,I可以理解为行为特征。 如果你熟悉MVC, M可理解为模型,V可理解为结构特征,C可理解为行为特征。 如果你熟悉Web,HTML可理解为模型,CSS可理解为结构特征,JS可理解为行为特征,“页面”可理解为场景,HTTP可理解为场景规约, URI可理解为场景的命名。 如果你熟悉Database, Table可理解为模型,View可理解为结构特征,Trigger可理解为行为特征,“增删改查”可理解为场景,SQL(关系代数)可理解为场景规约。 如果你对上述诸子百家的解释一无所知,恭喜你!你可能将在最佳的状态下,快速掌握四象图的精髓。 为了有一个感性的认识,以电影为隐喻。Actor(演员)表示模型,Props(道具)表示结构特征,Role(角色)表示行为特征,Script(剧本)表示场景。电影《英雄》中的陈道明是模型(演员),龙袍是结构特征(道具),秦始皇是行为特征(角色),刺伤秦始皇片段是场景(剧本)。 最后也会有两个简单的代码案例(都源自Jdon道友提出的案例),让你初步感受一下四象图的作用。 开放课题: 1)描述原语的具体语言 静态语言,动态语言?命令式语言,声明式语言?Web给我们树立了一个极好的榜样,模型使用HTML描述,结构特征使用CSS语言描述,行为特征使用JavaScript描述,场景规约使用HTTP描述,场景使用URI命名。事实上Android UI就汲取了Web的灵感,将UI的模型及结构特征使用XML描述。 2)特征剥离的方法 如果特征与模型使用同一种语言(比如Java),结构特征和行为结构的剥离需要付出额外的精力,比如前者侧重“一致性”,后者侧重“交互性”。而在Web中,结构特征和行为特征的剥离易如反掌。一致性要求画面在一段时间呈现相同的风格,这将给用户良好的界面体验的必要基础。CSS的设计者之一Bert Bos,解释了为什么CSS高度强调一致性。 Why “variables” in CSS are harmful? 3)特征凝聚的方法 模型凝聚特征,最简单的方法就是赋值。当模型具有复杂的结构特征时,可以使用“结构型”模式进行凝聚;当模型具有复杂的行为特征时,可以使用“行为型”模式进行凝聚。 凝聚特征的方法远不止赋值和GoF模式,比如JQuery中的selector、 Android中的R.java等等。 4)语言引擎的设计与实现 在不同的“系统”或“层次”之间,可能需要引入“语言引擎”这个概念,语言引擎的工作主要是协调两个说不同“语言”的“系统”之间的信息交流。比如Web应用的前端客户端说的是JavaScript语言,后端服务器使用是其它的语言(比如Java, PHP等)。Ajax(Asynchronous JavaScript and XML,由Jesse James Gaiiett发明)引擎负责两者的信息交流。Ajax的本意是通过JavaScript启动引擎,发送Request,异步接收以XML的数据格式返回的Response。现在XML已逐渐被JSON取而代之,因为相对于XML, JavaScript天生对JSON非常熟悉,是自家兄弟。 这里我修改Ajax的含义使其具有普适的意义。A = asynchronous, J = jabber, A = And, X = X。A 表示异步(同步是异步的特殊状态),Jabber的含义是“快而含糊不清的话”,X代表是任意对话者将的语言。 当某个层次或系统说的语言(Jabber),对于另一个层次或系统(使用X语言)而言可能就是“快而含糊不清的话(Jabber)”(我们多数人听老外讲话不就如此?老外听我们说话亦然!),此时就需要一个“语言引擎”进行翻译,使两者流畅地交流。语言引擎有两个工作模式:异步模式和同步模式。 对于Web UI来说,JavaScript为了自己的话能够被服务器(比如使用Java编写的服务器)听懂,使用Ajax语言引擎告知自服务器自己说了啥,要做什么。 对于Web Server(比如Java编写的服务器),为了Database能够听懂自己的话,知道自己想要些什么,做些什么,也需要构造一个Ajax引擎。语言引擎如果足够智能,可自动切换工作模式。比如在同步工作模式下,与SQL数据库建立会话;在异步工作模式,与NoSQL建立会话。 语言引擎的设计者需要具备精通数据库语言和服务器使用的语言,才可能建立通用的语言引擎。这个有能力的人可以考虑怎么实现,将其开源奉献给社区。 我目前是不知道啦。语言引擎不是Object/Relation Mapping,多多借鉴Asynchronous JavaScript and XML才是正道。 5)架构风格的设计与实现 Roy Thomas Fielding的博士论文《架构风格与基于网络的软件架构设计》是一篇极好的文章。李锟、廖志刚、刘丹、杨光的翻译也非常到位,感谢他们无私的奉献。我提出四象图的灵感部分(模型)源自于这篇论文对“资源”的定义,还有一部分灵感(特征)源于曾经对系统“特征向量空间”的痴迷。 Fielding在文中提及,“更精确地说,资源R是一个随时间变化的成员函数MR(t),该函数将时间t映射到等价的一个实体或值的集合,集合中的值可能是资源的表述和/或资源的标识符。”“REST对于信息的核心抽象是资源。任何能够被命名的信息都能够作为一个资源。” 在四象图中,Resource犹如“凝聚特征的模型”; Representational State表示“模型进入的场景”; State Transfer Protocol表示“场景的规约”;URI则是对“场景的命名”,其应绑定恰当的语义。 四象图与REST描述具有相似的“画面感”。 资源的表述性状态是一个持续一段时间的画面,状态转移协议切换各个画面。模型进入场景持续一段时间的画面,场景规约切换各个画面。两者之所以,具有相似画面感,是因为模型与资源的定义都是时间函数,是一个随着时间变化的概念。 体验与感受REST架构风格、四色原型、GoF设计模式,想象与其提出者进行交流,聆听、汲取其智慧,是我提出“四象图”的源泉。Web是一个成功的REST架构,运行了几十年。碰触Web,与接触TCP/IP协议栈、信号与系统、香农的信息论、机器学习一样,都令我感到震撼,开阔了原有的视野。 架构风格的设计与实现,我也是个在不断学习的初学者。之所以列出这个课题,是因为我觉得其非常重要,不可或缺。 6)程序之道:象数理 Niklaus Wirth提出公式“程序 = 数据结构 + 算法”,我认为这个公式少了一个东西,必须显现出来,即“模式”。古人有“象数理”之说,“象”在程序中抽象为“数据结构”,“数”抽象为“算法”,“理”抽象为“模式”。 “象”可以描述变量,过程,对象,函数,进程,事实,……,基于不同的“象”(数据结构),我们需要思考相应的“数”(算法),发现“象数”之后的“理”。这是无止境开放的课题,我们可能永远都无法找到永恒的真理。 建模原语和程序之道的来龙去脉,可以参考帖子《领域驱动设计之我见》和《Hello, World! 我心中的道》,但那两个帖子充斥着混乱和错误,有时间可以了解一下,没有时间,看这个帖子就行了。这些是我提炼、加工后的结果。 提出“六大开放课题”,因为其与“四象图”在虚拟世界中的落实息息相关。有空我会写一些特征凝聚的代码例子,作为参考。uda1341的设计的作品完成差不多了,可让他普及一下程序之道这个课题出现的新方法论:Fact-Oriented Programming。 希望有更多的人,了解和使用四象图,并对随之提出的“六大课题”感兴趣,去研究和解决他们。 欢迎转载此文。最好注明原出处。 achieveidea@gmail.com 一个中心,两个基本点。即以“模型”为中心,以“特征”与“场景”为两个基本点。 模型,描述事物为一组时间函数,蕴藏了与事物相关的所有事实。 特征,表示描述观察事物的角度,主要有两个角度:状态(结构)与行为(功能)。 场景,表示使用哪些角度观察和使用模型(模型凝聚特征),描述特定时间内所有与事物相关的事实。 MVC和DCI的提出者Trygve Reenskaug提倡用可读性极佳的代码直接捕获用户心中的模型,我采纳了这个观点,同时在编写原型时借鉴了四色原型及DCI的部分思想。 这里先陈述我的一些思考和观点,可能与一些经典著的定义有所出入,随后再将代码贴出来。 1)领域是客观的,不以人的意志为转移,但人之于领域具有能动性,可以认识和改造它。 2)领域模型是主观的,体现了程序员对领域的认识,是程序员心中对领域的素描。 3)用户需求是主观的,体现了用户对领域的认识,是用户心中对领域的素描。 4)领域模型需要捕捉和包容用户需求。领域模型与用户需求的关系十分重要,下面展开来讲。 “用户需求”是对领域的“素描”,用户的需求来自对领域的“诉求”,这些诉求往往是深刻的,因为其来源于用户对领域长期观察和使用的经验,比起我们程序员,一般更完整、更真实地接近领域的本质。我们对“用户需求进行素描”,就是“借鉴用户的宝贵经验”,可以更快、更好地素描客观领域,这可以说是一条认识(未知)领域的捷径。但是当用户需求不明朗或不清晰时,我们需要超越“用户需求”,对领域进行深入的摸索,去寻求更清晰的视角,对领域进行刻画。 此外,“用户需求”不能等同于“用户”,捕捉“用户心中的模型”也不能等同于“以用户为核心模型”,这是不同的概念,不能忽略其差异。《老子》书中有个观点:有之以为利,无之以为用。在这里,有之利,即建立领域模型;无之用,即包容用户需求。举些例子,一个杯子要装满一杯水,我们在制作杯子时,制作的是空杯子,即要把水倒出来,之后才能装下水;再比如,一座房子要住人,我们在建造房子时,建造的房子是空的,唯有空的才能容乃人的居住。我们建立领域模型时也要将用户置于模型之外,这样才能包容用户的需求。 现在尝试提出一套更浅显的建模原语,来诠释我的领域建模观。 前几天,还打算从系统的角度去解释四色原型(分析模式)、领域驱动设计(设计模式),发现这种做法可能会比较枯燥,放弃了。 现在,结合隐喻的方式,看是否能将两者解释得通俗易懂,并且提供一组领域建模的原语,作为分析、设计、实现阶段的通用语言。 大家先回想熟悉的画面: 1)花木兰从战场凯旋归来,辞去将军之职,脱下戎装,现出女儿身。 2)花木兰战场上英勇作战,披上戎装,行将军之职,无人知晓是女儿身。 四色原型的描述:戎装是Desc,花木兰是PPT, 将军是Role,作战是MI。 DDD的描述:戎装是Value Object, 花木兰是Entity, 将军是Aggregate, 作战是Service。 DCI的描述:花木兰是Data, 将军是Interaction, 作战是Context。 生活化的描述:戎装是“道具”,花木兰是“演员”,将军是“角色”,作战是“剧本”。 在上面的分析之上,提出四个建模原语: 状态特征(state feature), 别名特征(feature)。 模型(model),别名实体(entity),事物(thing)。 行为特征(behavioral feature),别名角色(role)。 场景(context),别名服务(service)、活动(activity)。 模型是领域的核心。 状态特征是从模型(model)上剥离的具有“一致性”的状态特征。 行为特征是从模型(mdoel)上剥离的具有“交互性”的行为特征。 场景中模型将“剥离”的状态和行为特征重新“凝聚”起来,相互作用,完成任务。 -------------------------------------------------------- 采用这套建模原语从新设计“图书馆”这个案例。 0) 工厂:CardFactory ---------------------------------- 1)模型: Libarary, Card, Book 2)状态特征:CardType、BookDetail 3)行为特征:BorrowedBook ---------------------------------- 4)场景:BorroweBook、BorrowedBookReturn、BorrowingIterm 模型,将现实中最核心的事物自然映射到领域模型中。 状态特征的剥离,看模型的部分属性是否具有“一致性”。上面的CardType, 剥离的有必要,但BookDetail则没有明显的业务需求,可以不剥离。 行为特征的剥离,看模型的部分行为是否具有“交互性”。BorrowedBook实际上并不表示“交互过程”,而是“交互结果”,但能反映“交互”特征,所以实现时可这样表达。 场景:BorrowingTerm包含场景的前置条件、交互约束,体现业务规则。DDD的术语好像是规约(Specification),是一种深层次的重构。之前我几乎无意识地把“借阅规则”放在一块,看来是对了。 参与场景时,如果模型符合场景的规约,模型就会凝聚剥离的特征—状态特征(根聚合)和行为特征(扮演角色),成为真正的“对象”,“对象”在彼此之间形成的关系网络中传递消息(可采用事件驱动),完成任务。 -------------------------------------------------------- 总结一下建模过程: 1)从业务中将最核心的事物自然映射为领域中的“模型”。 2)从“模型”上剥离出状态特征和行为特征,剥离的基本角度是“一致性”与“交互性”。 3)根据在场景中业务规则,“模型”重新凝聚剥落的“特征”,成为真正的“对象”。 4)“对象”之间相互“传递消息”,完成任务。 -------------------------------------------------------- 1)面向类(抽象的对象),不是真正的OO, 面向对象(静态的对象),也不是真正的OO;类+对象+时间,才是真正的OO。 2)关系代数,关系的定义不是指外键,外键只是外部对关系一个字段属性的约束而已,字段属性有很多种约束,外键只是其中一种。 3)关系的数学描述是一个集合,在OO中,等价于“类及其所有对象之和”。关系运算,即对“关系”的操作,是真正意义上对“对象”的操作。 4)目前的数据库没有时间观,目前的OO也没有时间观,但是关系代数,有! 5)GoF设计模式没有时间观,其灵感源于《建筑的永恒之道》,建筑是一个空间艺术,永恒试图超越时间,即追求特征不变性。 6)没有时间的OO世界观中,状态共享是并行之道;在有时间的OO世界观中,状态共享只是一种特例,消息传递才是并行之道。 7)掌握并行编程,最好能掌握爱因斯坦的广义相对论,在物理意义和数学意义上深刻认识时间和空间,如果不能,也要尝试去了解,因为并行 编程实际上是时空观在计算机上的落实。 8)数学是一个伟大的学科,是所有学科中最“无为”的一个学科,为其他学科提供必要的工具,可谓“利万物而不争”。不要去批判数学思维,要去批判那些对数学有误解和偏见的思维。因为“无为”,数学“无所不为”,被人封为“无冕之王”,功高震主,“哲学”的霸主地位岌岌可危。但“数学”本身并不想争夺“帝位”,是它的崇拜者,要把它推向至尊的王位。“数学”的崇拜者,想通过让“数学”登上宝座,来获取更多的荣耀与话语权,但忘记了“数学”崇尚“无为”。 9)比如,数学上一个矩阵,可以是一堆无意义的数据的堆积,可以是一幅图像的表示,可以是一个电路的描述,可以是一个化学反应方程组的表示,可以是一个网络的描述,可以是一个系统的描述,... 这就是“数学”,“无为”而“无所不为”。 10)我喜欢数学,但不是数学的崇拜者,更不想通过让“数学”登上宝座,使自己获取更多的荣耀,因为“数学”根本不在乎这个“宝座”,那是其崇拜者自己心中的欲望而已。 11)我也喜欢领域建模,也试图提取一套建模原语。这个帖子,就是开始做这个尝试,关于实施的细节,需要更多的思考和讨论,我会先写一部分,抛砖引玉。 先说说,一些关于在场景中模型凝聚“状态特征”和“行为特征”的思考,以后会更多的论述。 1)在复杂的场景中,模型对状态特征的凝聚,可能需要使用结构型模式;对行为特征的凝聚可能需要使用行为型模式。 2)比如,使用组合和修饰器模式去凝聚一些状态特征;使用命令和观察者模式去凝聚一些行为特征。 3)模型的创建可能需要使用创建模式,比如Card,使用CardFactory进行创建。 4)特征的创建也可能需要使用创建模式,比如行为特征(角色)在诸多场景中类似时,就需要使用“动态代理工厂”进行创建,控制特征类的数量的膨胀。 我尝试提出的这套原语意图在“分析、设计、实现”三个阶段通用,分析人员、设计人员、实现人员可以很自然地理解它,使用它。 这里解释一下定义这套术术语背后的思考过程。 1、模型,是事物的含义。但事物这个概念太广泛了,不宜直接拿来用,就像在REST中,使用资源这个词去代替事物,事物可作为别名。 那么为什么不选择DDD的Entity、四色原型的PPT或者DCI的Data呢?因为我们是在创建一套建模原语,最核心的概念自然要与模型有关,除了“模型”,我实在想不出更贴切的词汇了。模型,是建模的根本。 2、特征,描述事物(模型)具有怎么样的特点。特征既具有生活化的气息,也是一个在被各种学科广泛使用的术语,浅显而深刻。 比如矩阵轮、系统论上,特征向量、特征值、特征向量空间是出现频率极高的词语,常用于描述各种系统。我们使用特征来刻画、描述我们的模型。这里DDD的Value Object, 四色原型的Description,在直观上,在深刻性上,都远不如这个词汇。 3、场景,这也是一个生活化的语言,需求分析时,经常会有这个说法,有哪些业务场景。场景是一种活动,但活动与事物一样,太广泛了,只能作为别名。 这三个词汇,我个人觉得很好,因为它们既直观,又具有深刻性。 4、特征,分为状态特征和行为特征,代表从“状态”和“行为”两个维度去观察“模型”,它们是从“模型”身上剥离出来的,除了它们本身具有独立性和可复用性,更为重要的是使“模型”更为精炼。 5、在场景中,“模型”凝聚与此时此景有关的“状态特征”和“行为特征”,成为一个活生生的对象,与其他对象进行交互,完成任务。 这套原语的数学描述。 1)模型,表示为一组时间函数,包含状态特征和行为特征。 model = {f1(t),f2(t),...}; 2) 特征,从模型的一组时间函数中,提取相对独立的状态特征和行为特征。 state = {f1(t),f2(t),...}; behavior = {f1(t),f2(t),...}; 3) 场景,在场景模型凝聚特征持续一段时间(如从t0时刻到t1时刻)。 context => model-state-behavior = {f1(t0->t1),f2(t0->t1),...}。 借鉴四色原型的形象思维,使用四个原语。 在一张纸上画一个四象图,一横一竖的笛卡尔坐标平面。 1)把领域中的核心事物映射到四象中,放在第三象限,作为模型;(可以涂上绿色) 2)把领域中的业务场景映射到四象中,放在第一象限,作为场景;(可以涂上红色) 3)考虑所有场景,将模型的行为特征剥离出来,放在第四象限;(可以涂上黄色) 4)考虑所有场景,将模型的状态特征剥离出来,放在第二象限。(可以涂上蓝色) 不太一样,我的出发点是以“模型”为中心,以“特征”与“场景”为两个基本点。拿那个花木兰的例子分析一下。 大家熟悉的画面: 1)花木兰从战场凯旋归来,辞去将军之职,脱下戎装,现出女儿身。 2)花木兰战场上英勇作战,披上戎装,行将军之职,无人知晓是女儿身。 分析过程,设计过程,实现过程将呈现高度一致。 1)很容易识别出“花木兰”是“模型”,放在“四象图”的第三象限,涂以绿色; 2)很容易识别识别在战场“作战”是“场景”,放在“四象图”的第一现象,涂以红色; 3)从“花木兰”这个模型身上剥离出“状态特征”,即“戎装”,放在第二象限,涂以蓝色; 4)从“花木兰”这个模型身上玻璃出“行为特征”,即“将军”,放在第四象限,涂以黄色。 再分析一下图书馆这个领域中的借阅场景,读者是不在这个图书馆领域中的,读者是图书馆领域的使用者和观察者,是图书馆领域模型的用户。 1)图书馆、图书卡、图书,是“模型”,放在“四象图”的第三象限,涂以绿色; 2)卡的类型,表征“状态特征”,放在“四象图”的第二象限,涂以蓝色; 3)被借阅的书,表征“行为特征”,放在“四象图”的第四象限,涂以黄色; 4)借阅场景、借阅规则,是场景,放在“四象图”的第一想象,涂以红色。 “四象图”不同于四色原型的画图方式,与UML的画图的角度差别更大。这是这套原语的“象”;如果具备一定数学常识,可以理解这套原语的数学描述,即“数”;实际上也可以从“理”即纯粹逻辑的角度来剖析这套术语,这个我着墨较少,毕竟“理”隐藏在“象数之中”,要解析出来,需要借助一些逻辑观并探讨世界观,这可能会使这套原语失去“亲和力”,所以暂时先不多说。 这套原语对分析人员、设计人员、开发人员都具有“亲和力”,甚至对于外行都具有亲和力,容易理解,“四象图”,这个工具,可以让他们几乎不用思考(不思考是不可能的,只能说解放了一部分脑力)就可以进行分析、设计和实现。如果能理解其数学描述,理解其背后的逻辑观,那当然更好,但不会太影响“四象图”的使用。 所以,“模型、状态和行为特征、场景”和“四象图”,可以视为对建模观的命名与立象,浑然一体而泾渭分明,强调了模型的整体观和动态性,不像UML将模型割裂为类、状态图、交互图、用例等等,而且UML的状态图与交互图不能直接落实为代码,而状态特征与行为特征可以,两者实际上是不能等价视之的。 四象图的整体性和动态性,体现在用一张图形象描述了领域中可能出现的所有静态或动态的画面;模型、状态特征、行为特征,横跨整个领域;场景根据业务规则(规约)选取模型,模型在此时此景内,凝聚了相关的状态特正、行为特征,进行协作,完成任务。 与四色原型看起来很像,但背后的思考却已经发生变化了。 1)这套原语的核心是以“模型”为中心,不同四色原型或DCI以“角色”或“场景”为中心,是把“角色”以及“描述”看作模型的附属物。 2)状态特征和行为特征,是因为具有独立性,从“模型”身上剥离。战场上的花木兰身上的“戎装”(状态特征)和“将军之职”(行为特征),是可以剥离出来复用的。戎装可以别人穿,将军可以别人当。 3)行为结果可以用来反映行为特征,行为过程也可以用来反映行为特征。角色是一个名词呀,能表示行为特征?实际上角色这个概念也是行为结果呀。行为特征的刻画方式,可以通过“角色”(行为结果),也可以通过“(时间)函数”(行为过程)来刻画。 4)场景是在一段时间内,模型根据业务规则凝聚相关的特征(披上戎装(戎装不一定是当将军的人才可以穿的),行将军之职)进行作战。 5)例子只是为了形象说明,便于大家理解。如果你看看数学描述,理解起来应该就不会有什么困惑了。 6)忘记四色原型、DCI、DDD等琳琅满目、层出不穷的词汇吧,提出这几个原语的一个目标就是希望消除这些buzzword,为大家腾出更多的时间和精力思考更优雅的技术实现。 这个不是角色,是一个活生生的对象,角色是行为特征的抽象。 模型(花木兰)凝聚了状态特征(批上戎装)和行为(扮演了将军),成为一个活生生的对象(真正的对象),在战场上进行战斗。 不是将军(角色)在战斗,完整的描述是一个披上戎装身为将军的花木兰在战斗。 如果你把模型凝聚特征后的对象,理解为角色,实际上还是以角色为中心,还是停留在一个“抽象”的层次,在场景中,模型凝聚特征后成为一个活生生的对象,一个活生生的生命。 你说的两个过程不一样:一个是用来“刻画”特征,一个是表示模型“凝聚”特征的过程。 行为特征可以表示为一个类(比如角色、比如事件监听器),可以表示为一个函数(比如function() {}), 状态特征可以表示为一个类(比如颜色、金额),也可以表示一个函数(比如2t+3)。 特征可以有更多的表达形式,甚至我们还不知道的形式。 行为特征与状态特征的区别不在于其在表现形式,在于从模型剥离出来的角度。 状态特征考虑模型部分特征的“一致性”从“模型”上剥离; 行为特征考虑模型部分特征的“交互性”从“模型”上剥离; 状态特征与行为特征,都是模型的特征,严格区分它们不是特别重要, 倘若特征剥离从两个角度都说得通,此时区分是模型的状态特征还是行为特征就没有必要了。 特征剥离的基本目标有两个:1)复用状态和行为特征;2)使模型更精炼。 注入,是模型在场景凝聚特征的一种方式,状态和行为特征都可以。 关于模型在场景中凝聚特征的方式,简单的可以通过传参的方式。 比如把状态特征和行为特征通过作为模型的构造参数传递进去。 还可以考虑使用设计模式进行特征凝聚,比如通过模型绑定事件监听器来凝聚行为特征(观察者模式),通过组合方式凝聚多个状态特征(组合模式)。 在前面,我已经写了一点关于场景中模型如何凝聚特征的思考。 结构型模式和行为型模式分别是模型凝聚状态特征和行为特征的有效工具,可以根据场景特点,灵活选择。 从模型上剥离特征(分),模型凝聚特征(合)的方式,是一个开放的课题,我只是在方向上,考虑了个大概。比如剥离的角度,一致性、交互性;凝聚的方式,结构型模式、行为型模式等。 你可以使用这套原语重新组织你的论坛代码。我在这里开个头。 模型:帐号、帖子、论坛 状态特征:帐号类型、帖子类型、帖子状态(置顶、锁定等)、论坛状态(主题总数,帖子总数,最新的帖子ID等) 行为特征: 场景:发帖子、跟贴等 这里模型好像没有明显的交互特征,业务场景相对单一,没有剥离的必要。 假如该帐号不仅可以用在论坛,也可以用在图书馆系统中, 那么帐号的行为特征就可以剥离出来,分成两类,一个与论坛有关的,一个与图书馆系统有关的。 发现图书馆的那个例子,有问题,需要修正一下。 图书卡这个现实中的事物,在系统中的映射实际上是帐号。 图书卡是用户在现实中使用的工具,帐号是用户在系统上使用的工具。 所以把Card全部改为Account,就可以了。 而且Account上的行为特征也可以剥离,如果Account的用途不仅仅在图书馆领域上,仅仅考虑图书馆领域时就不需要剥离。 帖子与图书基本上很相似。但还是有区别,帖子没有依赖场景的行为特征,图书却有(BorrowedBook)。 行为特征,一种直白的解释就是模型依赖于场景的用途,跟之前讨论的角色很相似,在定义行为特征时,角色是作为其别名的,所以关于角色的认识仍有效。 关于特征已经写了不少,下面再写一点关于场景的思考,算是终结吧。因为我已经倾囊相赠,拿不出更好的东西了。 场景可分为两大类:查询和命令,前者是观察模型及其特征,后者是让诸多模型凝聚特征一起做事情。 场景中模型凝聚行为特征的方式,可以是EDA,或所谓的Domain Events。 这套原语也可以从MVC的角度去理解。MVC的M表示模型,VC可以理解为场景,V和C将场景分为两大类,V表示从观察模型,C表示从控制模型。 从系统论的角度看,模型与特征(结构/状态和行为两个角度)是对系统的内部描述,场景时对系统的外部描述。 外部描述可以从很多个角度进行分类,比如输出、输入;比如查询、命令;比如视图、控制器;比如读和写;比如视图和触发器;其实它们是相通的。 这套原语用于理解和学习界面设计、数据库设计、领域模型设计都是可以的。原语,承载的是一种思想,一种看事物、看系统的思想,不会在乎你用它来看那种具体的东西,比如界面、比如数据库、比如领域,因为它是建模原语,不仅仅是数据库、界面或领域的原语,只要你想用,就有用。 这套原语可以从MVC、CQRS、DCI、四色原型、DDD、EDA、Domain Events、REST等甚至更多我们都还不知道的角度去理解和使用,原语本身不会去限定使用原语的角度,更不会去限定原语具体的实现方式,因为这套原语背后的数学描述实际上是“无为”的。
开源地址:https://github.com/tangxuehua/enode 本文想介绍一下enode框架要实现的目标以及部分实现分析思路剖析。总体来说enode框架是一个基于cqrs架构和消息驱动的应用开发框架。在说实现思路之前,我们先看一下enode框架希望实现的一些目标吧! 框架总体目标 高吞吐量(High Throughput)、低延迟(Low Latency)、高可用性(High Availability); 需要能充分利用CPU,即要允许方便配置需要使用的并行处理线程数,以提高单台机器的command处理能力; 支持command的同步和异步处理,同步处理时要允许客户端捕获异常,异步处理时要允许客户端设置回调函数; 应用编程模型要统一,框架api要简单、好用、一致、好理解; 能让开发人员只关注业务,不用关心数据哪里来,以及如何保存,也不用关心并发、重试、超时等技术相关的问题; 基于消息驱动的架构,那消息投递方面,要能做到:至少投递一次(即如果宕机了消息也不能丢)、且能做到最多投递一次,因为有时我们无法做到消息的等幂处理; 要足够可扩展,框架中每个组件都要允许用户自定义并替换掉,包括IOC容器; 因为是CQRS架构,那必须要确保单个聚合根的事件的持久化顺序与分发给查询端的顺序要完全一致,否则会出现严重的数据不一致的问题; 实现高吞吐量、低延迟、高可用的思路分析 关于性能的几个重要概念 吞吐量是指系统每秒可以处理的请求数;延迟是指系统在处理一个请求时的延迟;一般来说,一个系统的性能受到这两个条件的约束,缺一不可。比如,我的系统可以顶得住一百万的并发,但是系统的延迟是2分钟以上,那么,这个一百万的负载毫无意义。系统延迟很短,但是吞吐量很低,同样没有意义。所以,一个好的系统的性能测试必然受到这两个条件的同时作用。有经验的朋友一定知道,这两个东西的一些关系:Throughput越大,Latency会越差。因为请求量过大,系统太繁忙,所以响应速度自然会低。Latency越好,能支持的Throughput就会越高。因为Latency短说明处理速度快,性能高,于是就可以处理更多的请求。所以,可以看出,最根本的,我们是要尽量缩短单次请求处理的时间。另外,可用性是指系统的平均无故障时间,系统的可用性越高,平均无故障时间越长。如果你的系统能保持一年365天都能7*24全天候正常运行,那说明你的系统可用性非常高。 思路分析 要实现高可用,要怎么办?简单的办法就是主备模式,即一份站点同时运行在主备服务器上,主服务器如果正常,那所有请求都由主服务器处理,当主服务器挂了,那自动切换到备服务器;这种方式能确保高可用;甚至我们还能设置多台备的服务器增加可用性;但是主备模式解决不了高吞吐量的问题,因为一台机器能处理的请求数总是有限的,那怎么办呢?我觉得就需要让我们的系统支持集群部署了,也就是说,不是只有一台机器在服务,而是同时有很多台机器在服务,这些同时服务的机器称为一个集群。而且为了能让集群中的服务器的负载能平衡,为了尽量避免某台服务器很忙,其他服务器很空的情况,我们还需要负载均衡技术。当然,真正的高可用同样意味着不能有单点故障问题,就是不能因为集群中的一个点挂了导致整个集群挂掉,所以我们要杜绝所有的数据都要经过某个点的设计;相反,要做到每个点都能横向扩展,web应用站点(enode框架支持)、内存缓存(memcached,redis都支持)、持久化(mongodb支持),都要能支持集群与负载均衡。好,整个系统所有层次都支持集群+负载均衡解决了高吞吐高可用无单点的问题,但并没有解决低延迟的问题,那怎么办呢?如何才能尽量快的处理一个用户请求呢?我觉得关键是三个方面:In Memory+尽量快的IO+无阻赛,也就是内存模式加很快的数据持久化加无阻塞的编程模型。 In Memory in memory是什么意思呢?在enode框架中,主要的体现是,当我们要获取领域聚合根对象然后进行一些业务逻辑操作时,是从内存获取,而不是从数据库。这样的好处就是快。那这样做要面临的一些问题,如内存不够怎么办?用分布式缓存,如memcached, redis这样的成熟基于key-value模式的nosql产品。redis服务器挂了怎么办?没关系,我们可以让框架自动处理,即当发现内存缓存中不存在时,自动在从eventstore取,就是取出当前聚合根的所有事件,然后使用事件溯源(event sourcing,简称ES)的机制还原聚合根,然后尝试更新到缓存,然后返回给用户。这样就解决了缓存挂了的问题,当redis缓存服务器重启后,又能继续从缓存中取聚合根了;实际上,我们也要根据情况进行分布式集群部署redis服务器,这样一方面是为了能将数据sharding,另一方面能提高缓存的可用性,因为不会因为一台redis缓存服务器挂了导致整个系统所有的缓存数据都丢失了。另外,你可能会奇怪,redis缓存服务器里的数据哪里来呢?同样利用ES模式,因为我们在eventstore中存储了所有聚合根的所有的事件,所以我们就能在redis缓存服务器启动时,对所有需要放在缓存中的聚合根根据ES模式来得到。 尽量快的持久化 怎样才能尽量快的持久化呢?我们先分析下enode框架需要持久化的关键数据是什么,就是事件。因为enode框架是一个基于event sourcing架构模式的,我们不会存储对象的最终状态,而是存储对象每次发生的事件;并且,每次事件都是append的方式追加到eventstore。我们唯一需要确保的是eventstore中的事件表中的聚合根ID+事件版本号唯一即可;通过这个唯一索引,我们能检测同一个聚合根是否有并发冲突产生。除了这个唯一性索引的要求外,我们不需要事务的支持,因为我们每次总是只插入一条记录;好了,那这样的话,我们要选择传统的关系型数据库来持久化事件吗?显然不太合适,因为慢!更明智的选择是用性能更高的NoSQL DB。如MongoDB,MongoDB默认的持久化是先放入内存,然后每隔100毫秒写入日志,然后可能60秒写入一次磁盘。这样的特性使得我们可以非常快速的持久化事件,因为持久化事件实际上只是写到mongodb server的内存中而已。另外,当数据被写入到日志后,我们就可以认为数据已经被安全的持久化了,因为即使断电了,mongodb也能将数据从日志恢复。当然你的疑问是,那如果断电了,那理论上这100毫秒的数据不是就丢了,没关系,我们还可以同时把数据写入到多台mongodb server,也就是我们可以部署一个MongoDB server的集群,一般整个集群的所有机器都同时挂掉的可能性是很低的,所以我们可以认为这样的思路是可行的。当然,这里所说的一切要能实现,还需要很过重要的细节问题要考虑。本文主要是给出思路。我一直觉得解决问题的思路最重要,是吗?另外,mongodb是介于key-value结构的NoSQL产品和关系型DB之间,它是一个文档型数据库,最主要的是它也支持像数据库一样的关系查询、更新、删除等操作,再加上高性能以及支持集群分布式等特性;所以我觉得非常适合用来作为eventstore。 另外,还有一个问题很重要,那就是序列化。数据存储到mongodb时,要被序列化,而.net自带的二进制序列化类(BinaryFormatter)不是太快,所以会成为持久化的瓶颈,那怎么办呢?呵呵,当然也是去找一个更高效的二进制序列化类库了。目前为止,我找到的是一个开源的NetSerializer,测试下来发现是.net自带的10倍左右,这样的性能完全可以满足我们的要求了;再简单谈一下为什么NetSerializer能这么快呢?很简单,.net自带的BinaryFormatter每次都需要反射,而NetSerializer在程序启动时已经将所有要序列化的类型的元数据都一次性生成了,所以系列化或反序列化的时候就不用再做这一步耗时的操作,所以当然就快了。当然像google protocol buffer也性能非常高,也很成熟,对,总之序列化方面我们还有很多解决方案来优化。 无阻塞的编程模型 接下来我们来看看如何实现无阻塞。先想一下为什么要无阻赛?举个例子:比如电商网站通过信用卡来订购商品。一般的做法就很直接,就是先获取订单信息,通过银联的外部服务来验证信用卡信息是否有效(这意味着信用卡号如果有问题,根本就不会生成订单),然后生成订单信息入库,这两步放在一个操作里。这样做的问题是,由于信用卡验证服务是一个外部服务,因此操作往往会被阻塞较长的一段时间。这样就导致整个系统无法高效的运行。 无阻赛的方式是:把整个操作分为两个,第一个操作是获取用户填写的订单。这个操作的结果是产生一个“信用卡验证请求”的事件。第二个操作是当它接受一个“信用卡验证成功响应”的事件,生成订单入库。我们的系统在完成第一个操作之后会接下来执行另外其他的事件,也就是不会依赖于信用卡验证的结果了,直到“信用卡验证成功响应”事件产生了,我们的系统才会继续处理后续的创建订单的事情。 可以看出,这样的设计实际上就是一种事件驱动(event-driven)的思想。基于这样的思想,我们的系统一直在不停的运转,不会因为和外部系统的交互而要同步等待外部系统的处理结果。同样,对于一个用户操作如果涉及多个聚合根的修改的情况,也是采用这样的事件驱动的思想,采用我常提到的saga的思想。我们不会在一个command中把所有事情都做完,而是会通过command+event不断串联的无阻塞的方式来实现整个过程。这一点在我之前的博文中应该已经做了比较详细讨论了。 目前只能想到这么多分析思路吧,希望对大家有帮助。为了篇幅不要太长的原因,框架的其他一些目标的分析思路只能在后续的文章中慢慢讨论了。希望我能坚持下去。我个人能思考到的问题毕竟有限,希望大家看了后能多多提一些问题,然后大家讨论解决,这样才能让框架不断完善起来。
开源地址:https://github.com/tangxuehua/enode 上一篇文章,我给大家分享了我的一个基于DDD以及EDA架构的框架enode,但是只是介绍了一个大概。接下来我准备用很多一篇篇详细但不冗长的文章介绍每个点。尽量争取一次不介绍太多内容,但希望每次介绍完后都能让大家知道这个小点的设计思想,以及为了解决的问题。 好了,这篇文章,我主要想介绍的是EDA思想在enode框架中如何体现? 经典DDD的基于领域服务的实现方式 一般的应用程序,如果一个用户动作会涉及多个聚合根的修改,我们通常会在应用层服务中创建一个unit of work,然后,我们可能会设计一个领域服务类,在该领域服务类里,修改多个聚合根,然后应用层服务将整个unit of work中的修改一次性以事务的方式提交到数据库。这种方式就是以事务的方式来实现涉及多个聚合根修改的强一致性。以银行转账这个经典的场景作为分析案例: public interface IBankAccountService { void TransferMoney(Guid sourceBankAccountId, Guid targetBankAccountId, double amount); } public class BankAccountService : IBankAccountService { private IContextManager _contextManager; private TransferMoneyService _transferMoneyService; public BankAccountService(IContextManager contextManager, TransferMoneyService transferMoneyService) { _contextManager = contextManager; _transferMoneyService = transferMoneyService; } public void TransferMoney(Guid sourceBankAccountId, Guid targetBankAccountId, double amount) { using (var context = _contextManager.GetContext()) { var sourceAccount = context.Load<BankAccount>(sourceBankAccountId); var targetAccount = context.Load<BankAccount>(targetBankAccountId); _transferMoneyService.TransferMoney(sourceAccount, targetAccount, amount); context.SaveChanges(); } } } 一次银行转账,最核心的动作就是源账号转出钱,目标账号转入钱;当然实际的银行转账肯定不是这么简单,也肯定不是这么实现。我拿这个作为例子只是为了通过这个大家都熟知的简单例子来分析如果一个用户场景涉及不止一个聚合根的修改的时候,如果基于经典的DDD的方式,我们是如何实现的。如上面的代码所示,我们可能会设计一个应用层服务,如上面的IBankAccountService,该应用层服务里有一个TransferMoney的方法,表示用于实现银行转账的功能;然后该应用层服务会进一步调用一个领域层的转账领域服务,就是上面代码中的TransferMoneyService,按照Eric Evans所说,领域服务应该是一个以动词命名的服务,一个领域服务可以明确对应到领域中的一个有业务含义的领域动作,此例就是“转账”,所以我设计了一个TransferMoneyService的以动词来命名的领域服务,该服务的TransferMoney方法实现了银行转账的核心业务逻辑。 上面这个例子中,按照经典DDD,我们应该在应用层实现流程控制逻辑以及事务等东西;所以大家可以看到,以上代码中,我们是先获取一个unit of work,即上面代码中的context,最后调用context.SaveChanges方法,该方法的职责就是将当前上下文的所有修改以事务的方式提交到数据库。好了,上面这个例子我们分析了经典DDD关于如何实现一个会涉及多个聚合根新建或修改的用户场景; enode的事件驱动的实现方式 我一直说enode是一个基于事件驱动架构(EDA,Event-Driven Architecture)的框架。且深蓝医生在前面的回复中也对什么是事件驱动的架构有疑惑。所以我想说一下我对事件驱动架构的理解。 EDA,顾名思义,我觉得就是事件驱动的,那事件到底驱动了什么呢?我觉得就是事件驱动状态的修改。如何理解呢?就是说,假如你要修改一个对象的状态,那就不是直接调用该对象的某个方法来修改它或者直接通过修改某个对象的属性来达到修改该对象状态的目的;取而代之的是,我们需要先触发一个事件,然后该对象会响应该事件,然后在响应函数中修改对象自己的状态。当然,更广义和权威的事件驱动架构的定义和解释,我觉得很容易找啊,比如直接去百度上搜一下或直接到wikipedia上搜一下,也很容易就能找到标准的解释。比如这里就是我找到的解释。其实,更大范围的解释,就是一种publish-subscriber模式,就是有一个事件生产者产生事件,然后有一个类似event publisher的东西会把这个事件广播出去,然后所有的事件消费者就能消费该事件了。通过这样的pub-sub,我们的应用程序的各个组件之间可以做到很彻底的解耦,并且可以做到更灵活的扩展性。这两点的好处应该是很容易体会到的。比如更彻底的解耦是,比如本来一个对象要和另一个对象交互,那它可能要引用该对象,然后调用该对象的某个方法,从而实现对象之间的交互。这种实现方式会让两个对象绑定在一起,比如a对象调用b对象的方法,那意味着a需要依赖b对象;而通过事件驱动的方式,a对象只要publish一个事件,然后b对象响应该事件即可,这样a对象就不知道b对象的存在了,也就是a对象不在依赖b对象;扩展性,就是本来一个事件,可能只有1个事件响应者,但是后面可能由于功能扩展等原因,我们需要增加一个事件响应者,这样就能方便的做到在不改变原来任何代码的基础之上,增加新功能了;其他的好处就不多分析了,有兴趣的可以再去看看资料吧。 上面这一段,我简单介绍了我所理解的EDA,以及它的基本的好处。下面我们看看,在enode中,我们是如何利用EDA这种原理的。为了简化,我先用一个简单的例子说明一下,就用我源代码中的NoteSample吧,反正也能一样说明事件驱动的影子在哪里。看以下的代码: [Serializable] public class Note : AggregateRoot<Guid>, IEventHandler<NoteCreated>, //订阅事件 IEventHandler<NoteTitleChanged> { public string Title { get; private set; } public DateTime CreatedTime { get; private set; } public DateTime UpdatedTime { get; private set; } public Note() : base() { } public Note(Guid id, string title) : base(id) { var currentTime = DateTime.Now; //触发事件 RaiseEvent(new NoteCreated(Id, title, currentTime, currentTime)); } public void ChangeTitle(string title) { //触发事件 RaiseEvent(new NoteTitleChanged(Id, title, DateTime.Now)); } //事件响应函数 void IEventHandler<NoteCreated>.Handle(NoteCreated evnt) { //在响应函数中修改自己的状态,这里可以体现出EDA的影子,就是事件驱动状态的修改 Title = evnt.Title; CreatedTime = evnt.CreatedTime; UpdatedTime = evnt.UpdatedTime; } //事件响应函数 void IEventHandler<NoteTitleChanged>.Handle(NoteTitleChanged evnt) { //同上解释 Title = evnt.Title; UpdatedTime = evnt.UpdatedTime; } } 上面的例子中,Note是一个聚合根,它会响应两个事件:NoteCreated, NoteTitleChanged。要实现事件响应,我们可以通过实现框架提供的IEventHandler<T>接口,就能告诉框架,我要订阅什么事件了。 上面代码中,应该比较详细的注释了每段代码的含义了,应该都能看懂吧。上面这个例子说明了,聚合跟自己的状态不是在public方法中直接改的,而是基于事件驱动的方式来修改的,所以,大家可以看到,聚合根状态的修改是在一个内部响应函数中修改的。下面我们再来看一下外部其他对象,如何响应该事件: //这是一个事件订阅者,它也响应了Note的两个事件 public class NoteEventHandler : IEventHandler<NoteCreated>, IEventHandler<NoteTitleChanged> { public void Handle(NoteCreated evnt) { //这里为了简单,所以只是输出了一串文字,实际我们可以在这里做任何你想做的事情; Console.WriteLine(string.Format("Note created, title:{0}", evnt.Title)); } public void Handle(NoteTitleChanged evnt) { Console.WriteLine(string.Format("Note title changed, title:{0}", evnt.Title)); } } 通过上面两个简单的例子,不知道有没有解释清楚,在enode框架中,如何体现EDA? 总结: 我之所以比较喜欢事件驱动这种思想是基于以下理由: 就是上面我说的解耦+可扩展; 事件可以并行执行;就是说,一个系统中,同时可以有很多事件在并行的产生、传递、响应;这样说,大家可能还理解不了这一点的价值。我说一下并发的概念。通常我们所说的一个网站的并发,比如有5000,是指一个网站在1秒内的所有并发请求数,这么多并发请求数是针对系统中所有的聚合根的;也就是如果平摊到每个聚合根,那并发修改数一般就很低了,比如每秒只有10个并发,甚至只有1个或两个。这点每个系统有所不同,比如淘宝的商品秒杀活动,那当秒杀开始的时刻,对同一个商品的下单的并发数很高,因为每个商品的每个订单都意味着要减库存,所以这个减库存的并发操作一定很高,实现起来肯定很困难了,不通过可靠的分布式缓存以及乐观锁机制,估计很难实现;而比如新浪微博上,我们每个人发微博,虽然整个新浪微博网站的整体并发数很高,因为肯定每秒有非常多的人在写微博,但是我们同时也知道,大家写的微博都是独立的,没有共享资源,每发表一条微博实际上就是创建一条数据库记录而已。所以可以理解为,单个对象无并发;而一般的企业应用或一般的互联网应用,针对同一资源(同一个聚合根)的并发修改,一般都不高;所以基于这样的分析和理解,我们知道了,理论上,事件什么时候可以并行产生和执行,什么时候必须排队。就是:如果两个事件不是同一个聚合根产生的,那就可以并行处理,事件也可以并行持久化;如果是单个聚合根产生的,那必须按照顺序被持久化;所以,根据这样的理解,我们知道了,一个应用程序,除了单个聚合根上的修改只能串行进行外,其他情况理论上都可以并行执行;这段话说了这么多关于并发数以及事件并行方面的东西,那究竟知道这些有什么用呢?很简单,只要和传统的事务模式对比下就知道了,传统的事务模式,如果要修改多个聚合根,那事务在执行的那一段时间,所有涉及到的聚合根都不能被其他事务所修改;只有等到当前事务执行完成后,其他事务才能执行;而通过事件的方式,由于我们没有事务的概念,我们唯一要确保的只是一个聚合根上产生的事件必须被一个个按顺序持久化,这点我们很简单,比如我们只要建一个联合主键:聚合根ID+事件版本号,然后做乐观并发控制即可;所以,事件持久化时,排他的粒度比事务要小,这样的好处是无阻塞;那么换来的好处就是网站整体的可用性高;但是带来的坏处是,可能有可能会出现乐观并发冲突,但这点我们可以通过框架的自动重试功能解决掉;而且,我们也刚分析过,同一个聚合根的并发修改一般是很低的;所以通过事件的方式来达到这种细粒度的对聚合根的修改是非常有意义的。 配合Event Sourcing模式,可以让EDA发挥更大的价值,更准确的说,我们可以让事件发挥更大的价值;就是:我们不仅可以让事件作为消息,在系统各个对象或组件甚至是各个系统之间传递,还可以用事件来还原整个系统的状态。这点我会在后面详细介绍enode框架中如何使用event sourcing这种模式;
前言 今天是个开心的日子,又是周末,可以安心轻松的写写文章了。经过了大概3年的DDD理论积累,以及去年年初的第一个版本的event sourcing框架的开发以及项目实践经验,再通过今年上半年利用业余时间的设计与开发,我的enode框架终于可以和大家见面了。 自从Eric Evan提出DDD领域驱动设计以来已经过了很多年了,现在已经有很多人在学习或实践DDD。但是我发现目前能够支持DDD开发的框架还不多,至少在国内还不多。据我所知道的java和.net平台,国外比较有名的有:基于java平台的是axon framework,该框架很活跃,作者也很勤奋,该框架已经在一些实际商业项目中使用了,算比较成功;基于.net平台的是ncqrs,该框架早起比较活跃,但现在没有发展了,因为几乎没人在维护,让人很失望;国内有:banq的jdon framework可以支持DDD+CQRS+EventSourcing的开发,但是它是基于java平台的,所以对于.net平台的人,没什么实际用处;.net平台,开源的主要就是园子里的晴阳兄开发的apworks框架。晴阳兄在DDD方面,在国内的贡献很大,写了很多DDD系列的文章,框架和案例并行,很不错。当然,我所关注的紧紧是c#和java语言的框架,基于scala等其他语言实现的框架也有很多,这里就不一一例举了。 上面这么多框架都有各自的特点和优势,这里就不多做评价了,大家有兴趣的自己去看看吧。我重点想介绍的是我的enode框架,框架的特色,以及使用的前提条件。 框架简介 框架名称:enode 框架特色:提供一个基于DDD设计思想,实现了CQRS + EDA + Event Sourcing + In Memory这些架构模式的,支持负载均衡的,轻量级应用开发框架。 开源地址:https://github.com/tangxuehua/enode 完整使用enode的一个论坛的地址:https://github.com/tangxuehua/forum nuget包Id:enode 使用该框架需要了解或遵守以下几个约定: 一个command只允许导致一个聚合根的修改或一个聚合根的创建,如果违反这个规则,则框架不允许; 如果一个用户操作会涉及多个聚合根的修改,则需要通过saga (process manager)来实现;拥抱最终一致性,简单的说就是通过将command+domain event不断的串联来最终实现最终一致性;如果想彻底的知道enode哪里与众不同,可以看一下源代码中的BankTransferExample,相信这个会让你明白什么是我所说的事件驱动设计; 框架的核心编程思想是异步消息处理加最终一致性,所以,如果你想实现强一致性需求,那这个框架不太适合,至少目前没有提供这样的支持; 框架的设计目标不是针对企业应用开发,传统企业应用一般访问量不大且要求强一致性事务;enode框架更多的是针对互联网应用,特别是为一些需要支持访问量大、高性能、可伸缩且允许最终一致性的互联网站点提供支持;看过:可伸缩性最佳实践:来自eBay的经验的人应该知道要实现一个可伸缩的互联网应用,异步编程和最终一致性是必须的;另外,因为如果数据量一大,那我们一般会把数据分开存放,这就意味着,如果你还想实现强一致性,那就要靠分布式事务。但是很不幸,分布式事务的成本代价太高。伸缩、性能和响应延迟都受到分布式事务协调成本的反面影响,随着依赖的资源数量和用户访问数量的上升,这些指标都会以几何级数恶化。可用性亦受到限制,因为所有依赖的资源都必须就位。 框架定位:目前定位于单台机器上运行的单个应用内的CQRS架构前提下的command端的实现;如果要实现多台机器多个应用之间的分布式集成,则大家需要再进一步借助ESB来与更高层的SOA架构集成; 框架架构图: CQRS架构图 上面的架构图是enode框架的内部实现架构。当然,上面这个架构图并不是完整的CQRS架构图,而是CQRS架构图中command端的实现架构。完整的CQRS架构图一般如下: 从上图我们可以看到,传统的CQRS架构图,一般画的都比大范围,command端具体如何实现,实现方案有很多种。而enode框架,只是其中一种实现。 框架的关键内部实现说明 首先,client会发送command给command service,command service接受到command后,会通过一个command queue router来路由该command应该放到哪个command queue,每个command queue就是一个消息队列,队列里存放command。该消息队列是本地队列,但是支持消息的持久化,也就是说command被放入队列后,就算机器挂了,下次机器重启后,消息也不会丢失。另外,command queue我们可以根据需要配置多个,上图为了示意,只画了两个; command queue的出口端,有一个command processor,command processor的职责是处理command。但是command processor本身不直接处理command,直接处理command的是command processor内部的一些worker线程,每个worker线程会不断的从command queue中取出command,然后按照图中标出的5个步骤对command进行处理。可以看出,由于command processor中的worker线程都是在并行工作的,所以我们可以发现,同一时刻,会有多个command在被同时处理。为什么要这样做?因为client发送command到command queue的速度很快,比如每秒发送1W个command过来,也就是并发是1W,但是command processor如果内部只有单线程在处理command,那速度跟不上这个并发量,所以我们需要设计支持多个worker同时处理command,这样延迟就会降低;我们从架构图可以看到,command processor获取聚合根是从内存缓存(如支持分布式缓存的redis)获取,性能比较高;持久化事件,用的是MongoDB,由于mongoDB性能也很高;如果觉得事件持久化到单台MongoDB server还是有瓶颈问题,那我们可以对MongoDB server做集群,然后对事件进行sharding,将不同的event存储到不同的MongoBD Server,这样,事件的持久化也不会成为瓶颈;这样,整个command processor的处理性能理论上可以很高,当然我还没测试过集群情况下性能可以达到多少;单个mongodb server,持久化事件的性能,5K不成问题;这里有一点借此在说明下,被持久化的其实不是单个事件,而是一个事件流,即EventStream。为什么是事件流是因为单个聚合根一次可能产生不止一个领域事件,但是这些事件比如一起被持久化,所以设计思路是把这些事件设计为一个事件流,然后将这个事件流作为一条mongodb的记录插入到mongodb;事件流在mongodb中的主键是聚合根ID+事件流的版本号,通过这两个联合字段作为主键,用来实现乐观锁;假如有两个事件流都是针对同一个聚合根的,且他们的版本号相同,那插入到mongodb时,会报主键索引冲突,这就是并发冲突了。需要对command进行自动重试(enode框架会帮你自动做掉这个自动重试)来解决这个问题; command processor中的worker处理完一个command后,会把产生的事件发布给一个合适的event queue。同样,内部也会有一个event queue router来路由到底该放到哪个event queue。那么event queue中的事件接下来要被如何处理呢?也就是event processor会做身事情呢?很简单,就是分发事件给所有的事件订阅者,即dispatch event to subscribers。那这些event subscribers都会做什么事情呢?一般是做两种处理:1)因为是采用CQRS架构,所以我们不能仅仅持久化领域事件,还要通过领域事件来更新CQRS的查询端数据库(这种为了更新查询库的事件订阅者老外一般叫做denormalizer);由于更新查询库没有必要同步,所以设计event queue;2)上面提到过,有些操作会影响多个聚合根,比如银行转账,订单处理,等。这些操作本质上是一个流程,所以我们的方案是通过在领域事件的event handler中发送command来异步的实现串联整个处理流程;当然,如何实现这个流程,还是有很多问题需要讨论。我个人觉得比较靠谱的方案是通过process manager,类似BPM的思想,国外也有很多人把它叫做saga。对saga或process manager感兴趣的看官,可以看看微软的这个例子:http://msdn.microsoft.com/en-us/library/jj591569.aspx,对于如何用enode来实现一个process manager,由于信息太多,所以我接下来会写一篇文章专门系统的介绍。 回顾框架所使用的关键技术 基于整个enode框架的架构图以及上面的文字描述说明,我们在看一下上面最开始框架简介中提到的框架所使用的关键技术。 DDD:指架构图中的domain model,采用DDD的思想去分析设计实现,enode框架会提供实现DDD所必要的基类聚合根以及触发领域事件的支持; CQRS:指整个enode架构实现的是CQRS架构中的command端,CQRS架构的查询端,enode框架没做任何限制,我们可以随意设计; EDA:指整个编程模型的思路,都要基于事件驱动的思想,也就是领域模型的状态更改是基于响应事件的,聚合根之间的交互,也不是基于事务,而是基于事件驱动和响应; Event Sourcing:中文意思是事件溯源,关于什么是事件溯源,可以看一下这篇文章。通过事件溯源,我们可以不用ORM来持久化聚合根,而是只要持久化领域事件即可,当我们要还原聚合根时只要对该聚合根进行一次事件溯源即可; In Memory:是指整个domain model的所有数据都存储在内存缓存中,比如分布式缓存redis中,且缓存永远不会被释放。这样当我们要获取聚合根时,只要从内存缓存拿即可,所以叫in memory; NoSQL:是指enode用到了redis,mongodb这样的nosql产品; 负载均衡支持:是指,基于enode框架的应用程序,可以方便的支持负载均衡;因为应用程序本身是无状态的,in memory是存储在全局的redis分布式缓存中,独立于应用本身;而event store则是用MongoDB,同样也是全局的,且也支持集群。所以,我们可以将基于enode框架开发的应用程序部署任意多份在不同的机器,然后做负载均衡,从而让我们的应用程序支撑更高的并发访问。 框架API使用简介 框架初始化 public void Initialize() { var connectionString = "mongodb://localhost/EventDB"; var eventCollection = "Event"; var eventPublishInfoCollection = "EventPublishInfo"; var eventHandleInfoCollection = "EventHandleInfo"; var assemblies = new Assembly[] { Assembly.GetExecutingAssembly() }; Configuration .Create() .UseTinyObjectContainer() .UseLog4Net("log4net.config") .UseDefaultCommandHandlerProvider(assemblies) .UseDefaultAggregateRootTypeProvider(assemblies) .UseDefaultAggregateRootInternalHandlerProvider(assemblies) .UseDefaultEventHandlerProvider(assemblies) //使用MongoDB来支持持久化 .UseDefaultEventCollectionNameProvider(eventCollection) .UseDefaultQueueCollectionNameProvider() .UseMongoMessageStore(connectionString) .UseMongoEventStore(connectionString) .UseMongoEventPublishInfoStore(connectionString, eventPublishInfoCollection) .UseMongoEventHandleInfoStore(connectionString, eventHandleInfoCollection) .UseAllDefaultProcessors( new string[] { "CommandQueue" }, "RetryCommandQueue", new string[] { "EventQueue" }) .Start(); } Command定义 [Serializable] public class ChangeNoteTitle : Command { public Guid NoteId { get; set; } public string Title { get; set; } } 发送Command到ICommandService var commandService = ObjectContainer.Resolve<ICommandService>(); commandService.Send(new ChangeNoteTitle { NoteId = noteId, Title = "Modified Note" }); Command Handler public class ChangeNoteTitleCommandHandler : ICommandHandler<ChangeNoteTitle> { public void Handle(ICommandContext context, ChangeNoteTitle command) { context.Get<Note>(command.NoteId).ChangeTitle(command.Title); } } Domain Model [Serializable] public class Note : AggregateRoot<Guid>, IEventHandler<NoteCreated>, IEventHandler<NoteTitleChanged> { public string Title { get; private set; } public DateTime CreatedTime { get; private set; } public DateTime UpdatedTime { get; private set; } public Note() : base() { } public Note(Guid id, string title) : base(id) { var currentTime = DateTime.Now; RaiseEvent(new NoteCreated(Id, title, currentTime, currentTime)); } public void ChangeTitle(string title) { RaiseEvent(new NoteTitleChanged(Id, title, DateTime.Now)); } void IEventHandler<NoteCreated>.Handle(NoteCreated evnt) { Title = evnt.Title; CreatedTime = evnt.CreatedTime; UpdatedTime = evnt.UpdatedTime; } void IEventHandler<NoteTitleChanged>.Handle(NoteTitleChanged evnt) { Title = evnt.Title; UpdatedTime = evnt.UpdatedTime; } } Domain Event [Serializable] public class NoteTitleChanged : Event { public Guid NoteId { get; private set; } public string Title { get; private set; } public DateTime UpdatedTime { get; private set; } public NoteTitleChanged(Guid noteId, string title, DateTime updatedTime) { NoteId = noteId; Title = title; UpdatedTime = updatedTime; } } Event Handler public class NoteEventHandler : IEventHandler<NoteCreated>, IEventHandler<NoteTitleChanged> { public void Handle(NoteCreated evnt) { Console.WriteLine(string.Format("Note created, title:{0}", evnt.Title)); } public void Handle(NoteTitleChanged evnt) { Console.WriteLine(string.Format("Note title changed, title:{0}", evnt.Title)); } } 后续需要讨论的关键问题 既然是消息驱动,那如何保证消息不会丢失; 如何保证消息至少被执行一次,且不能被重复执行; 如何确保消息没执行成功就不能丢,也就是要求消息队列支持事务; 因为是多线程并行持久化事件并且是多台机器集群负载均衡部署的,那如何保证领域事件被持久化的顺序与发布到事件订阅者的顺序完全一致; 整个架构中,基于redis实现的memory cache以及基于mongodb实现的eventstore,是两个关键的存储点,如何确保高吞吐量和可用性; 因为事件是并行持久化的,那如果遇到并发冲突如何解决? 命令的重试如何实现?消息队列中的消息的重试机制如何实现? 既然抛弃了强一致性的事务概念,而用process manager来实现聚合根交互,那如何具体实现一个process manager? 目前暂时想到以上8个我觉得比较重要的问题,我会在接下来的文章中,一一讨论这些问题的解决思路。我觉得写这种介绍框架的文章,一方面要介绍框架本身,更重要的是要告诉别人你设计以及实现框架时遇到的问题以及解决思路。要把这个分析和解决的思路写出来,这才是对读者意义最大的;
原文地址:http://www.uml.org.cn/zjjs/201108111.asp 1.集群 1.1定义:是一组独立的计算机系统构成一个松耦合的多处理器系统,它们之间通过网络实现进程间的通信。应用程序可以通过网络共享内存进行消息传送,实现分布式计算机。 是一组连在一起的计算机,从外部看它是一个系统,各节点可以是不同的操作系统或不同硬件构成的计算机。如一个提供Web服务的集群,对外界来看是一个大Web服务器。不过集群的节点也可以单独提供服务。 1.2负载均衡系统:集群中所有的节点都处于活动状态,它们分摊系统的工作负载。一般Web服务器集群、数据库集群和应用服务器集群都属于这种类型。 负载均衡集群一般用于相应网络请求的网页服务器,数据库服务器。这种集群可以在接到请求时,检查接受请求较少,不繁忙的服务器,并把请求转到这些服务器上。从检查其他服务器状态这一点上看,负载均衡和容错集群很接近,不同之处是数量上更多。 1.3集群系统主要解决下面几个问题: 高可靠性(HA):利用集群管理软件,当主服务器故障时,备份服务器能够自动接管主服务器的工作,并及时切换过去,以实现对用户的不间断服务。 负载均衡:即把负载压力根据某种算法合理分配到集群中的每一台计算机上,以减轻主服务器的压力,降低对主服务器的硬件和软件要求 高性能计算(HP):即充分利用集群中的每一台计算机的资源,实现复杂运算的并行处理,通常用于科学计算领域,比如基因分析,化学分析等。 2.负载均衡系统 先从集群讲起 负载均衡又有DNS负载均衡(比较常用)、IP负载均衡、反向代理负载均衡等,也就是在集群中有服务器A、B、C,它们都是互不影响,互不相干的,任何一台的机器宕了,都不会影响其他机器的运行,当用户来一个请求,有负载均衡器的算法决定由哪台机器来处理,假如你的算法是采用round算法,有用户a、b、c,那么分别由服务器A、B、C来处理; 2.1基于DNS的负载均衡 通过DNS服务中的随机名字解析来实现负载均衡,在DNS服务器中,可以为多个不同的地址配置同一个名字,而最终查询这个名字的客户机将在解析这个名字时得到其中一个地址。因此,对于同一个名字,不同的客户机会得到不同的地址,他们也就访问不同地址上的Web服务器,从而达到负载均衡的目的。 2.2反向代理负载均衡 (如Apache+JK2+Tomcat这种组合) 使用代理服务器可以将请求转发给内部的Web服务器,让代理服务器将请求均匀地转发给多台内部Web服务器之一上,从而达到负载均衡的目的。这种代理方式与普通的代理方式有所不同,标准代理方式是客户使用代理访问多个外部Web服务器,而这种代理方式是多个客户使用它访问内部Web服务器,因此也被称为反向代理模式。 2.3基于NAT(Network Address Translation)的负载均衡技术 (如Linux Virtual Server,简称LVS) 网络地址转换为在内部地址和外部地址之间进行转换,以便具备内部地址的计算机能访问外部网络,而当外部网络中的计算机访问地址转换网关拥有的某一外部地址时,地址转换网关能将其转发到一个映射的内部地址上。因此如果地址转换网关能将每个连接均匀转换为不同的内部服务器地址,此后外部网络中的计算机就各自与自己转换得到的地址上服务器进行通信,从而达到负载分担的目的。 3.分布式是指将不同的业务分布在不同的地方。 而集群指的是将几台服务器集中在一起,实现同一业务。 分布式中的每一个节点,都可以做集群。 而集群并不一定就是分布式的。 举例:就比如新浪网,访问的人多了,他可以做一个群集,前面放一个响应服务器,后面几台服务器完成同一业务,如果有业务访问的时候,响应服务器看哪台服务器的负载不是很重,就将给哪一台去完成。 分布式,从窄意上理解,也跟集群差不多, 但是它的组织比较松散,不像集群,有一个组织性,一台服务器垮了,其它的服务器可以顶上来。 分布式的每一个节点,都完成不同的业务,一个节点垮了,哪这个业务就不可访问了。 在群集的这三种基本类型之间,经常会发生交叉、混合。比如:在高可用性的群集系统中也可以在其节点之间实现负载均衡,同时仍然维持着其高可用性。还有一种概括性说法:cluster是手段,load banlance是目标之一。 读后感: 集群: 多台机器做相同的业务,对外如一台机器在做事情一样,集群中任意一台机器挂了没有影响,因为其他机器还在工作; 负载均衡: 是一种优化手段,目的是为了让集群中的每台机器的负载保持均衡,这样就不会出现集群中某台机器挂了的情况; 分布式: 一个业务在不同的物理点上做,比如web服务器、应用服务器、数据库服务器,这三个节点分开部署在不同的机器上,共同完成一个业务;分布式的特点是,每个节点都不能挂,否则这个业务就不能完成了;当然,我们可以给分布式中的每个节点都做集群处理,这样可以降低分布式系统的单节点故障;
出处:http://coolshell.cn/articles/3649.html 春节前的一篇那些炒作过度的技术和概念中对敏捷和中国ThoughtWorks的微辞引发了很多争议,也惊动了中国ThoughtWorks公司给我发来了邮件想来找我当面聊聊。对于Agile的Fans们,意料之中地也对我进行了很多质疑和批评。我也回复了许多评论。不过,我的那些回复都是关于中国ThoughtWorks咨询师以及其咨询的方法的。我对Agile方法论中的具体内容评价的不是很多,所以,我想不妨讨论一下Agile方法论中的具体的实践(以前本站也讨论过结对编程的利与弊)。 那么,这次就说说TDD吧,这是ThoughtWorks中国和Agile的Fans们最喜欢的东西了。我在原来的那篇文章中,我把TDD从过度炒作的技术剔除了出去,因为我还是觉得TDD有些道理的,不过,回顾我的经验,我也并不是很喜欢TDD。我这篇文章是想告诉大家,TDD并没有看上去的那么美,而且非常难以掌控,并且,这个方法是有悖论之处的。 TDD简介 TDD全称Test Driven Development,是一种软件开发的流程,其由敏捷的“极限编程”引入。其开发过程是从功能需求的test case开始,先添加一个test case,然后运行所有的test case看看有没有问题,再实现test case所要测试的功能,然后再运行test case,查看是否有case失败,然后重构代码,再重复以上步骤。其理念主要是确保两件事: 确保所有的需求都能被照顾到。 在代码不断增加和重构的过程中,可以检查所有的功能是否正确。 我不否认TDD的一些有用的地方,如果我们以Test Case 开始,那么,我们就可以立刻知道我们的代码运行的情况是什么样的,这样可以让我们更早地得到我们实现思路的反馈,于是我们更会有信心去重构,去重新设计,从而可以让我们的代码更为正确。 不过,我想提醒的是,TDD和Unit Test是两码子事儿。有很多人可能混淆了自动化的Unit Test(如:XUnit系例)和TDD的软件开发过程。另外,可能还会有人向鼓吹“TDD让你进行自顶向下的设计方式”,对此,请参阅本站的《Richard Feynman, 挑战者号, 软件工程》——NASA的挑战者号告诉你自顶向下设计的危险性。 TDD的困难之处 下面是几个我认为TDD不容易掌控的地方,甚至就有些不可能(如果有某某TDD的Fans或是ThoughtWorks的咨询师和你鼓吹TDD,你可以问问他们下面这些问题) 测试范围的确定。TDD开发流程,一般是先写Test Case。Test Case有很多种,有Functional的,有Unit的,有Integration的……,最难的是Test Case要写成什么样的程度呢。 如果写的太过High Level,那么,当你的Test Case 失败的时候,你不知道哪里出问题了,你得要花很多精力去debug代码。而我们希望的是其能够告诉我是哪个模块出的问题。只有High Level的Test Case,岂不就是Waterfall中的Test环节? 如果写的太过Low Level,那么,带来的问题是,你需要花两倍的时间来维护你的代码,一份给test case,一份给实现的功能代码。 另外,如果写得太Low Level,根据Agile的迭代开发来说,你的需求是易变的,很多时候,我们的需求都是开发人员自己做的Assumption。所以,你把Test Case 写得越细,将来,一旦需求或Assumption发生变化,你的维护成本也是成级数增加的。 当然,如果我把一个功能或模块实现好了,我当然知道Test 的Scope在哪里,我也知道我的Test Case需要写成什么样的程度。但是,TDD的悖论就在于,你在实现之前先把Test Case就写出来,所以,你怎么能保证你一开始的Test Case是适合于你后面的代码的?不要忘了,程序员也是在开发的过程中逐渐了解需求和系统的。如果边实现边调整Test Case,为什么不在实现完后再写Test Case呢?如果是这样的话,那就不是TDD了。 关注测试而不是设计。这可能是TDD的一个弊端,就像《十条不错的编程观点》中所说的一样——“Unit Test won’t help you write the good code”,在实际的操作过程中,我看到很多程序员为了赶工或是应付工作,导致其写的代码是为了满足测试的,而忽略了代码质量和实际需求。有时候,当我们重构代码或是fix bug的时候,甚至导致程序员认为只要所有的Test Case都通过了,代码就是正确的。当然,TDD的粉丝们一定会有下面的辩解: 可以通过结对编程来保证代码质量。 代码一开始就是需要满足功能正确,后面才是重构和调优,而TDD正好让你的重构和优化不会以牺牲功能为代价。 说的没错,但仅在理论上。操作起来可能会并不会得到期望的结果。1)“结对编程”其并不能保证结对的两个人都不会以满足测试为目的,因为重构或是优化的过程中,一旦程序员看到N多的test cases 都failed了,人是会紧张的,你会不自然地去fix你的代码以让所有的test case都通过。2)另外,我不知道大家怎么编程,我一般的做法是从大局思考一下各种可行的实现方案,对于一些难点需要实际地去编程试试,最后权衡比较,挑选一个最好的方案去实现。而往往着急着去实现某一功能,通常在会导致的是返工,而后面的重构基本上因为前期考虑不足和成为了重写。所以,在实际操作过程中,你会发现,很多时候的重构通常意味着重写,因为那些”非功能性”的需求,你不得不re-design。而re-design往往意味着,你要重写很多Low-Level的Test Cases,搞得你只敢写High Level的Test Case。 TDD导致大量的Mock和Stub。相信我,Test Case并不一定是那么容易的。比如,和其它团队或是系统的接口的对接,或是对实现还不是很清楚的模块,等等。于是你需要在你的代码中做很多的Mock和Stub,甚至fake一些函数来做模拟,很明显,你需要作大量的 assumption。于是,你发现管理和维护这些Mock和Stub也成了一种负担,最要命的是,那不是真正的集成测试,你的Test Case中的Mock很可能是错的,你需要重写他们。 也许,你会说,就算是不用TDD,在正常的开发过程中,我们的确需要使用Mock和Stub。没错!的确是这样的,不过,记住,我们是在实现代码后来决定什么地方放一个Mock或Stub,而不是在代码实现前干这个事的。 Test Case并没有想像中的那么简单。和Waterfall一样,Waterfall的每一个环节都依赖于前面那个环节的正确性,如果我们没有正确的理解需求,那么对于TDD,Test Case和我们的Code都会的错的。所以,TDD中,Test Case是开发中最重要的环节,Test Case的质量的问题会直接导致软件开发的正确和效率。而TW的咨询师和Agile的Fans们似乎天生就认为,TDD比Waterfall更能准确地了解需求。如果真是这样,用TDD进行需求分析,后面直接Waterfall就OK了。 另外,某些Test Case并不一定那么好写,你可能80%的编程时间需要花在某个Test Case的设计和实现上(比如:测试并发),然后,需求一变,你又得重写Test Case。有时候,你会发现写Test Case其实和做实际设计没有差别,你同样要考虑你Test Case的正确性,扩展性,易读性,易维护性,甚至重用性。如果说我们开发的Test Case是用来保证我们代码实现的正确性,那么,谁又来保证我们的Test Case的正确性呢?编写Test Case也需要结对或是Code review吗?软件开发有点像长跑,如果把能量花在了前半程,后半程在发力就能难了。 也许,TDD真是过度炒作的,不过,我还真是见过使用TDD开发的不错的项目,只不过那个项目比较简单了。更多的情况下,我看到的是教条式的生硬的TDD,所以,不奇怪地听到了程序员们的抱怨——“自从用了TDD,工作量更大了”。当然,这也不能怪他们,TDD本来就是很难把控的方法。这里送给软件开发管理者们一句话——“当你的软件开发出现问题的时候,就像bug-fix一样,首要的事是找到root cause,然后再case by case的解决,千万不要因为有问题就要马上换一种新的开发方法”。相信我,大多数的问题是人和管理者的问题,不是方法的问题。
关于借书场景的领域建模,我从以下几个方面进行分析: 分析模型静态结构 我分析一个领域模型的静态结构的思路一般是:先找出我们需要关心的对象,对于借书这个场景,我们关心的有: 1. Account(账号):Id(账号唯一标识,自动生成), Number(卡号), Owner(账号当前拥有者用户信息), BorrowedBooks(账号当前借到的书) 2. Book(书本):Id(唯一标识,自动生成),BookInfo(值对象,包含书本基本信息),Count(表示当前库存数量) 3. BorrowHistory(借书历史、借书日志):AccountId(借书账号),BookId(书本Id),Count(数量,表示借了几本),BorrowTime(借书时间) 4. BorrowedBook(借到的书):BookId(书本Id),Count(书本数量) 通过上面的分析,那么模型的静态结构就很容易画出来了。 按面向过程的思维实现逻辑 这种分析思路是最容易的,因为我们不用考虑对象之间如何交互,我们只需要考虑场景结束后,每个对象会发生什么变化即可。所以,按照这个思路,我们得出借书这个场景发生后有以下几个对象会发生变化: Book的Count属性会变化(减少,因为书本被借出); Account的BorrowedBooks属性会增多(因为借到书); BorrowHistory会被创建,因为发生了一次借书的操作; 上面这3步在经典DDD中,我们通常会设计一个领域服务来完成,比如叫BorrowBookService 可以发现,其实面向过程的分析思路是一种面向结果的分析方法;我们只需要考虑一个交互过程的结果改变了哪些对象的什么状态即可,而对象之间到底如何交互的我们不用显式的建模出来;所以这种建模方法相对简单,因为我们考虑的东西比下面第这种方式要少一样东西,那就是对象之间的交互。 按面向对象的思维实现逻辑 也许有人会说,上面的面向过程的建模思路不是真正的OO,因为对象之间没有交互,对象只是一个data,只是一个对数据的封装而已。 那么,如果要让对象之间体现出交互,那我们该如何分析呢?我觉得最关键的是要把握一点:我们分析的时候要时刻按照“谁通知谁做什么事情,或谁被通知做什么事情”这个思路来分析; 好,那按照这个思路,那么对上面的对象:Account,Book,BorrowedHistory,我们如何来分析呢? 首先假设我们已经设计好了这个软件,然后有一个界面显示在屏幕上,然后用户用它的卡号(account.Number)登陆了系统,然后用户通过查询选择了几本书,然后点击“借书”按钮。整个借书场景就是从这个“借书”按钮开始启动。另外,整个借书场景的参与者信息有:accountId,bookId,count,表示哪个账号对哪本书借了几本。 好,那么,“借书”按钮被点击后,应该有一个对象被激活,先不管该对象是什么,我们只要知道该对象会做一件事情,就是: var borrower = repository.load<Account>(accountId); //将借书人账号load到内存 var book = repository.load<Book>(bookId); //将书本对象load到内存 borrower.BorrowBook(book, count); //启动账号的借书行为 接下来borrower.BorrowBook方法内部会发生什么呢?想想现实世界怎么发生的就知道了,你去图书馆借书,你肯定告诉管理员说“我要借这几本书”,这句话潜在的意思是,请你把这几本书借给我,谢谢,呵呵。 那就很明白了,应该有一个对象,如图书馆管理员(administrator),他有借出书(LendBook)的职责行为,那么上面的BorrowBook方法内看起来就是如下这样: borrower.BorrowBook(book, count) { //通知图书馆管理员把指定的书借给我count本,this就是我,呵呵 administrator.LendBook(this, book, count); //管理员把书借出来后,更新账号自己的当前借到的书的信息 var borrowedBook = _borrowedBooks.SingleOrDefault(x => x.BookId == book.Id); if (borrowedBook == null) { borrowedBooks.Add(new BorrowedBook(book, count)); } else { borrowedBook.AddBookCount(count); } } 接下来我们可以考虑administrator.LendBook这个行为做了什么? administrator.LendBook(account, book, count) { //通知书本减少其库存数量 book.DecreaseCount(count); //方法内部需要检查库存数量是否足够,如果不够需要抛异常; //这里应该要记录借书记录了,因为译本书是否被借出的衡量标准是图书馆管理员说了算的,当他 //用扫描仪对该本书进行了扫描并确认后,就表示该本书确定被借出去了,所以我们可以在这里做 //创建借书记录的逻辑。 var borrowHistory = new BorrowHistory(account, book, count, DateTime.Now); //下面理想情况下我们不希望在这里保存borrowHistory,但是如果光是new一个BorrowHistory对象出来, //是没办法被持久化出来的,必须通过某种方式通知框架保存new出来的这个对象, //如果用经典的ddd,那如何保存borrowHistory呢?后面我会谈到一些关于这个的思考。 } 好,上面的分析我想应该很清晰地表达了对象之间如何交互,从而完成整个借书场景。但是,上面提到“借书”按钮被点击后,应该有一个对象被激活。那么这个对象会是什么呢? 我觉得你可以设计一个BorrowBookService领域服务,也可以设计一个BorrowBookContext场景类,它有一个Interaction(交互的意思)方法: borrowBookContext.Interaction(Guid accountId, Guid bookId, int count) { var borrower = repository.load<Account>(accountId); //将借书人账号load到内存 var book = repository.load<Book>(bookId); //将书本对象load到内存 borrower.BorrowBook(book, count); //启动账号的借书行为 } 所以,整个交互的过程就是: 软件使用者(user)通知系统(system)我要借书; 系统于是创建一个BorrowBookContext场景对象,并通知该场景对象启动交互过程(Interaction); borrowBookContext通知仓储(repository)将account,book这两个对象从内存激活,通过load方法实现; 然后通知借书账号执行其借书行为,其实此时借书账号是扮演了IBorrower角色(即借书人的角色),所以严格来讲,BorrowBook这个行为是属于IBorrower这个角色的; 然后borrower通知administrator把书借出来; 然后administrator通知book减少其余额; 然后administrator创建借书记录,即产生借书日志; 从上可以明显的看出,每一个交互都是对象a通知对象b做什么,这是关键;上面的分析和代码充分体现了“对象交互”,也许这样的代码才是更OO吧,呵呵。 为了大家更好的理解这种OO的方式,我特地写了一个完整的例子。源代码下载地址:http://files.cnblogs.com/netfocus/BookLibraryExample.rar 最后一些补充 因为,现实生活中,你去图书馆借书,那执行借出书的那个管理员(administrator)代表的就是图书馆。甚至我们可以这样想,假设现在有一个自动借书机或借书网站,你插入你的借书卡(网站用户登录),然后输入要借的书,然后点击确定,然后书本就自动从借书机里吐出来了,呵呵。如果是网站,那就是会自动邮寄过来(当然还要输入寄送地址,呵呵)。所以,从这个分析可以知道,其实图书馆管理员不重要,它其实代表的是图书馆,而图书馆本质上就是提供借书服务。当然,因为我们上面只考虑的借书的场景,我们有没有想过books这个集合放在哪个对象上比较合适呢?我觉得很显而易见把,那就是图书馆,即library.Books,图书馆维护了所有的书本信息;所以,从整体来看,图书馆也有状态。 有时我们认为产生借书日志不是核心领域逻辑,因为并不是所有的图书借阅系统都需要记录借书记录,那这样的话,我们可以在应用层(也就是我上面的BorrowBookContext中)生成借书记录; 虽然按照OO的思路去领域建模出来的结果看起来很舒服,但实际上不是很实用,我个人认为属于中看不中用的设计,呵呵。因为这样的设计虽然做到了对象与对象之间的交互,但实际上当我们在面对并发和数据一致性时,都会引入事务。像上面的分析,我们知道一次借书,至少会影响3个聚合根的修改或新增,那意味着一次事务会跨3个聚合根。一旦引入事务,那在当用户访问量大,并发高的情况下,系统可用性是很差的;所以,国外DDD专家才推荐,一次事务只更新一个聚合根,那如果要遵守这样的规定,那如何实现上面的一次要修改3个聚合根的需求呢?呵呵,为了解决这个问题,我们需要通过saga了,就是类似于一个流程管理器的东西。引入saga,相当于实现了聚合根与聚合根之间的异步通信,而不是直接调用聚合根的方法通知其做事情;上面的设计的最大问题就是都是某个聚合根直接调用另一个聚合根的方法通知其做事情。实际上每个聚合根都自己内部维护了其一致性,聚合根之间完全可以通过异步的方式实现交互。saga就是用来实现聚合根之间异步交互的一种技术。saga就是将方法调用修改为:publish-subscribe,以及command的模式,呵呵。学习Saga的一个例子可以看看这篇文章:http://msdn.microsoft.com/en-us/library/jj591569.aspx
最近一直在思考一个问题:有没有这样一种可能,就是一个领域模型的状态不依赖于外部,它只负责接收外部的事件,然后根据这些事件做出响应;响应分两种: 根据模型当前的内存状态进行业务逻辑处理,然后产生事件,注意:这个过程不会改变模型当前的内存状态; 根据事件改变自己的状态; 另外,也是最重要的,领域模型不用关心自己所产生的事件到底怎么样了,比如不关心有没有持久化,不关心是否和别的事件有并发冲突。它只管根据自己当前的内存状态做上面这两点的响应; 如果这样的设想有可能,那领域模型就是真正的中央业务逻辑处理器了,和CPU很类似了。这样它才能真正快起来。 简单的说就是:事件->模型->事件 模型只管响应事件,然后响应处理,然后产生新的事件 领域模型就是一黑盒,它只能帮你处理业务逻辑,其他的什么处理结果它一概不关心;当然,领域模型肯定有它自己的状态,但这个状态是驻留在内存的,和领域模型是一体的。 我为什么会有这个想法是因为,我在想,为什么要让领域模型的处理逻辑依赖于它的处理结果是否被正确顺利持久化了?感觉这很荒唐。 既然领域模型有自己的内存状态空间,他的所有逻辑也应该只依赖于这个状态空间,不再依赖于其他任何外部的东西。 当然,以前我们设计的IRepository,实际背后都是直接从数据库取。这样的话,领域模型的状态空间就是数据库了。但是这样其实很不好,为什么不用内存作为领域模型的状态空间呢? 现在再想想LMAX就是我刚才的想法的一个实际例子。 事件->模型->事件,这样的设计,理论上并不需要必须要求单线程来访问模型,因为领域模型不依赖于任何外部的状态,只依赖于自己所在存活内存空间;单线程有一个很大的好处就是可以防止并发冲突的产生。我们其实完全支持多线程或集群的方式,只不过这样会有可能访问到的领域对象的状态是了老的,因为不同的机器之间的领域模型内存对象的状态需要做一些同步,访问到老数据的可能性的大小取决于并发的大小以及机器之间数据同步的快慢; LMAX之所以用单线程,是考虑了,这单线程的领域模型和性能之间,性能已经非常高其足以达到他们的要求了。 这样的架构,我觉得领域模型中的任何一个对象的一次完整的状态更新至少会响应两个事件,举个例子: 先响应ChangeNoteCommand(command也是一种事件,可以理解为NoteChangeRequested),然后Note模型产生一个NoteChanged事件,注意,此时模型自己的状态还未改变,此时只是先产生了一个事件表示什么事情发生了; 然后该事件(NoteChanged)最终又被发送到领域模型让其响应,此时,领域模型才去更改自己的Note状态并将最新状态保存到自己的内存空间,如一个dict中或redis中; 经过对这两个事件的响应,才完成了Note的最终状态的修改;而我们以前都是从数据库取Note,然后更改,然后保存到数据库。这样不慢才怪! 通过上面的两次事件响应,可以换来领域模型对事件的极快的响应,因为完全无IO。 剩下的我们只要考虑(我目前考虑了以下六个问题): 消息的序列化和反序列化; 消息传递的速度; 事件持久化的速度; 并发冲突后重试的设计; 消息丢失了怎么办; 集群部署时,各台服务器之间内存的同步如何实现; 需要明白的是:这些都不是领域模型该考虑的问题。这些外围的任何问题,都不要让领域模型自己去考虑,我们应该对出现的各种问题逐个寻求解决方案。 每个问题的解决方案我大概理了下我的对策: 消息的序列化和反序列化:这个简单,用BinaryFormatter,或更快的开源序列化组件,对于事件这样大小的对象可以达到每秒10W次每秒; 消息传递的速度:用MSMQ/RabbitMq,等带持久化功能的队列组件;如果嫌太慢,就用ZeroMq(无消息持久化功能),但可以达到30W消息每秒; 事件持久化的速度:由于事件都是跟着单个聚合根,所以我们只要确保单个聚合根的事件不会冲突(即没有重复的版本号的事件);为了更快的持久化,我们可以对事件按照聚合根或者其他方式进行分区存放,不同的服务器存放不同的聚合根的事件;这样通过集群持久化的方式可以实现多事件同时被持久化,从而提高整体的事件持久化吞吐量;如单个mongodb server每秒持久化5000个,那10个mongodb server就能每秒持久化5W个; 并发冲突后怎么办:一般来说就是选择重试,但为了确保不会出现不可控的局面(可能由于某种原因一直在重试,引起消息堵塞),那需要设置一个最大的重试次数;超过最大重试次数后不再重试,然后记录日志,以供以后查找问题;这里的重试的意思是:重新找到对应该事件的command,然后再次发送该command给领域模型处理; 消息丢失:丢失就丢失了呗,呵呵;要是你觉得消息决不能丢失,那就用可靠的带持久化功能的消息传输队列,如MSMQ;当然,就算消息丢失了,我们很多时候都要想想有没有影响的,一般来说,消息丢失,至少我们是知道程序有问题了的,因为模型的状态此时一定是不对的。我们可以通过在消息发出时和接收时记录日志,这样方便以后查找消息是在哪个环节丢的; 任何其他的异常出现,这个我觉得如果都是托管代码,那可以在必要的地方加try catch,然后记录日志。至于是否要重试,还要看情形; 另外,如果是多线程访问模型,或集群访问,那很多时候访问到的内存的领域对象的状态都是老的,那怎么办?其实这不是问题,因为事件持久化的时候会被检测到这种并发重复,然后对应的command会被重试。 如果一个事件被成功的持久化了,那如何让各台应用服务器知道?这个我觉得也简单,就是当事件持久化完成后,通过zeromq publish给所有的应用服务器,每台应用服务器都有一个后台的线程在不停的接收已被成功持久化了的事件,然后根据这些事件更新自己内存空间中的领域对象的状态。这一步完全可以由框架自动做掉;这里相当于我上面提到的第二个事件(NoteChanged)是由框架自动处理的,不需要用户写代码干预;前面说到,因为是publish-subscribe模式,所以各台应用服务器上的数据就会自然保持同步了; 另外,这种架构,传输的是事件,事件都是很小的,所以不用担心消息传输的性能。 对于以上的想法,有人有下面的两点担心: 事件是否就是解决当前复杂软件架构的银弹? 系统中如果出现海量的事件是否会出现另一种灾难? 我记得不知道是谁说过,OO的本质就是消息通信。command也好,event也好,或者直接的方法调用也好,本质上都是对象与对象之间的消息通信。 方法调用太生硬(这点我记得你曾今也提到过,当然我觉得聚合内很适合用方法调用来实现聚合内的对象的通信) command, event本质上都是通过message作为媒介,实现对象与对象之间的通信。这让我想起有一位高人曾经说过的一个比喻,下面是摘录的他的原话: “现在的SOA、ESB之类的东西是不是就像打造一个企业的“神经脉络”,而“OO”是不是就像“神经元”,它们之间的通讯就是靠生物电脉冲,这就是消息驱动。” 所以,我在想,软件实现用户的需求,是不是也应该有很多的对象以及很多的消息(event)这两样东西作为核心组成,对象相当于神经元,消息相当于生物电脉冲。整个软件在运行过程中就是这样一个由对象以及消息组成的网络。 至于复杂性,我觉得框架可以帮我们实现消息通信的部分,而我们程序员要做的就是定义对象结构,然后让对象具有发送消息和接收消息的行为功能。我觉得这点并不是很复杂吧! 最近我一直在努力实现我这个想法,因为我师兄说:“我现在不相信什么架构,just show me the code”。 有想法和能实现出来是两回事,你有多少能力,你的设计能力,对细节的把控能力,程序员内在素养,一看代码便知,呵呵。 有人回复说:事件本身没有错,我想强调的是“事件”的定位问题。“事件”是一个界与另一个界交互的方式,但界是分层次的。用人体比喻很好理解,细胞之间的事件,组织之间的事件,器官之间的事件。构建这样的事件体系是非常复杂的,目前的技术很难达到,不是一个EventBus就可以解决的。 针对上面的说法,我觉得这里主要还是一个编程思路的转变问题。事件驱动天生是一种异步编程。我之所以想自己搞一个这样的框架,主要是因为: 事件驱动的编程模型让model不在有任何负担,让model只面向in memory,从而实现高性能不是梦了; 事件的version机制让我们方便的实现乐观并发,确保单个聚合根内强一致,聚合根之间最终一致;然后配合框架自动实现的重试功能,可以在并发冲突后自动重试,这样极大避免command的执行失败率; 事件数据不是关系型数据,所以事件产生者和处理者都可以多个,这意味着我们做集群非常容易,且事件的存储可以任意拆分,只要确保同一个聚合根的事件放在一起即可,不同聚合根的事件理论上都可以放在不同的服务器上,这样我们持久化事件也可以并发,我们只要对聚合根id+commitSequence这两个字段建立唯一索引即可。从而克服事件持久化(IO操作)慢的瓶颈; 在这么多诱人的特性面前,我们还有什么说不的理由呢?困难不要紧,我们可以一步步来,呵呵。总比没有想法好,你说呢? ------------------------------------------------------------------------------------------------------------- 目前就想到这些。后续再完善思路。 知识决定命运,学习积累知识,而正确的思维方式是一切高效学习的基础。所以学会如何清晰地思考问题是非常重要的! 呵呵!
原文地址:http://www.dancres.org/reading_list.html Introduction I often argue that the toughest thing about distributed systems is changing the way you think. The below is a collection of material I've found useful for motivating these changes. Thought Provokers Ramblings that make you think about the way you design. Not everything can be solved with big servers, databases and transactions. Harvest, Yield and Scalable Tolerant Systems - Real world applications of CAP from Brewer et al On Designing and Deploying Internet Scale Services - James Hamilton Latency Exists, Cope! - Commentary on coping with latency and it's architectural impacts Latency - the new web performance bottleneck - not at all new (see Patterson), but noteworthy The Perils of Good Abstractions - Building the perfect API/interface is difficult Chaotic Perspectives - Large scale systems are everything developers dislike - unpredictable, unordered and parallel Website Architecture - A collection of scalable architecture papers from various of the large websites Data on the Outside versus Data on the Inside - Pat Helland Memories, Guesses and Apologies - Pat Helland SOA and Newton's Universe - Pat Helland Building on Quicksand - Pat Helland Why Distributed Computing? - Jim Waldo A Note on Distributed Computing - Waldo, Wollrath et al Stevey's Google Platforms Rant - Yegge's SOA platform experience Amazon Somewhat about the technology but more interesting is the culture and organization they've created to work with it. A Conversation with Werner Vogels - Coverage of Amazon's transition to a service-based architecture Discipline and Focus - Additional coverage of Amazon's transition to a service-based architecture Vogels on Scalability SOA creates order out of chaos @ Amazon Google Current "rocket science" in distributed systems. MapReduce Chubby Lock Manager Google File System BigTable Data Management for Internet-Scale Single-Sign-On Dremel: Interactive Analysis of Web-Scale Datasets Large-scale Incremental Processing Using Distributed Transactions and Notifications Megastore: Providing Scalable, Highly Available Storage for Interactive Services - Smart design for low latency Paxos implementation across datacentres. Consistency Models Key to building systems that suit their environments is finding the right tradeoff between consistency and availability. CAP Conjecture - Consistency, Availability, Parition Tolerance cannot all be satisfied at once Consistency, Availability, and Convergence - Proves the upper bound for consistency possible in a typical system CAP Twelve Years Later: How the "Rules" Have Changed - Eric Brewer expands on the original tradeoff description Consistency and Availability - Vogels Eventual Consistency - Vogels Avoiding Two-Phase Commit - Two phase commit avoidance approaches 2PC or not 2PC, Wherefore Art Thou XA? - Two phase commit isn't a silver bullet Life Beyond Distributed Transactions - Helland If you have too much data, then 'good enough' is good enough - NoSQL, Future of data theory - Pat Helland Starbucks doesn't do two phase commit - Asynchronous mechanisms at work You Can't Sacrifice Partition Tolerance - Additional CAP commentary Optimistic Replication - Relaxed consistency approaches for data replication Theory Papers that describe various important elements of distributed systems design. Distributed Computing Economics - Jim Gray Rules of Thumb in Data Engineering - Jim Gray and Prashant Shenoy Fallacies of Distributed Computing - Peter Deutsch Impossibility of distributed consensus with one faulty process - also known as FLP [access requires account and/or payment, a free version can be found here] Unreliable Failure Detectors for Reliable Distributed Systems. A method for handling the challenges of FLP Lamport Clocks - How do you establish a global view of time when each computer's clock is independent The Byzantine Generals Problem Lazy Replication: Exploiting the Semantics of Distributed Services Scalable Agreement - Towards Ordering as a Service Languages and Tools Issues of distributed systems construction with specific technologies. Programming Distributed Erlang Applications: Pitfalls and Recipes - Building reliable distributed applications isn't as simple as merely choosing Erlang and OTP. Infrastructure Principles of Robust Timing over the Internet - Managing clocks is essential for even basics such as debugging Storage Consistent Hashing and Random Trees Amazon's Dynamo Storage Service Paxos Consensus Understanding this algorithm is the challenge. I would suggest reading "Paxos Made Simple" before the other papers and again afterward. The Part-Time Parliament - Leslie Lamport Paxos Made Simple - Leslie Lamport Paxos Made Live - An Engineering Perspective - Chandra et al Revisiting the Paxos Algorithm - Lynch et al How to build a highly available system with consensus - Butler Lampson Reconfiguring a State Machine - Lamport et al - changing cluster membership Implementing Fault-Tolerant Services Using the State Machine Approach: a Tutorial - Fred Schneider Other Consensus Papers Mencius: Building Efficient Replicated State Machines for WANs - consensus algorithm for wide-area network Gossip Protocols (Epidemic Behaviours) How robust are gossip-based communication protocols? Astrolabe: A Robust and Scalable Technology For Distributed Systems Monitoring, Management, and Data Mining Epidemic Computing at Cornell Fighting Fire With Fire: Using Randomized Gossip To Combat Stochastic Scalability Limits Bi-Modal Multicast P2P Chord: A Scalable Peer-to-peer Lookup Protocol for Internet Applications Kademlia: A Peer-to-peer Information System Based on the XOR Metric Pastry: Scalable, decentralized object location and routing for large-scale peer-to-peer systems PAST: A large-scale, persistent peer-to-peer storage utility - storage system atop Pastry SCRIBE: A large-scale and decentralised application-level multicast infrastructure - wide area messaging atop Pastry Experience at MySpace One of the larger websites out there with a high write load which is not the norm (most are read dominated). Inside MySpace Mix06 - Running a Megasite on Microsoft Technologies - Mix 06 Sessions/ MySpace Storage Challenges eBay Interesting they dumped most of J2EE and use a lot of db partitioning. Check out their site upgrade tool as well. SD Forum 2006
I don't believe there is anything wrong with using multiple repositories to fetch data in a transaction. Often during a transaction an aggregate will need information from other aggregates in order to make a decision on whether to, or how to, change state. That's fine. It is, however, the modifying of state on multiple aggregates within one transaction that is deemed undesirable, and I think this what your referenced quote was trying to imply. The reason this is undesirable is because of concurrency. As well as protecting the in-variants within it's boundary, each aggregate should be protected from concurrent transactions. e.g. two users making a change to an aggregate at the same time. This protection is typically achieved by having a version/timestamp on the aggregates' DB table. When the aggregate is saved, a comparison is made of the version being saved and the version currently stored in the db (which may now be different from when the transaction started). If they don't match an exception is raised. It basically boils down to this: In a collaborative system (many users making many transactions), the more aggregates that are modified in a single transaction will result in an increase of concurrency exceptions. The exact same thing is true if your aggregate is too large & offers many state changing methods; multiple users can only modify the aggregate one at a time. By designing small aggregates that are modified in isolation in a transaction reduces concurrency collisions. Vaughn Vernon has done an excellent job explaining this in his 3 part article. However, this is just a guiding principle and there will be exceptions where more than one aggregate will need to be modified. The fact that you are considering whether the transaction/use case could be re-factored to only modify one aggregate is a good thing.
Just a quick question on saga persistence - how do you persist saga state and dispatch messages while avoiding transactions and 2PC? Long story: I'm trying to reason out the logic behind sagas, in order to understand everything better (and map concepts back to the reactive programming) Basically a saga is an entity, that is used to coordinate some long- running process. It can subscribe to events (UserAccountCreated), keep track of time (i.e.: user should activate his account within 24 hours) and send commands (CancelUserRegistration). Additionally, since saga is an entity and could be addressed in the scalable world, we can send command directly to the saga (StopRegistrationProcess). Sagas can be modeled and perceived as finite state machines. So far - so good and rather straightforward. However just a quick question: how do you persist saga state and send messages out of it? Logically, in order to avoid 2PC and transactions you would need to join state transition and publication in one atomic operation (just like with the aggregate roots and event sourcing) and reuse message dispatching mechanism that catches up with the history (append-only persistence scales much better anyway) This feels like more sensible and simple operation, than introducing relational DBs or any kind of transactions into the system. However, as I recall, I've never heard of using event sourcing for the saga state persistence. Is there a reason for this? How do you implement your sagas and persist their state? All feedback would be appreciated! Best regards, Rinat Abdullin Rinat, There are a few options two avoid 2PC. One of the easiest ways is to simply have the saga entity store a list of all command IDs internally. Rarely will you have sagas that exist beyond even several dozen commands/events. That being the case, you can effectively treat the saga as a kind of aggregate root using event sourcing. (More on this in a minute.) By storing the command IDs internal to the saga, you can avoid 2PC by having two completely separate transactions--an outer as well as an inner transaction. The inner transaction is related to committing the saga "aggregate" to the event store. The outer transaction is related to removing the message from the message queue. If the message queue doesn't support TransactionScope, it's not a big deal--it will attempt to deliver the message at least once and you can easily detect it as a duplicate and drop it because it's already been handled. Let the event store do the publishing for you asynchronously. I've outlined a few of these concepts in some blog posts I wrote a few months back (one of which you commented on): http://jonathan-oliver.blogspot.com/2010/04/extending-nservicebus-avoiding-two.html http://jonathan-oliver.blogspot.com/2010/04/idempotency-patterns.html http://jonathan-oliver.blogspot.com/2010/04/message-idempotency-patterns-and-disk.html The other part of your question is how to leverage event sourcing to take care of sagas. It's not unlike your typically aggregate root. Some kind of stimulus comes in (either a command or event), you transition the state (this being the part that's distinct from DDD aggregates), which results in a message being "raised". Then, you commit the new state to the event store and let it perform the message dispatch asynchronously. Jonathan Oliver The only thing that I would add is that sagas should be more like a state machine which is about *process*, whereas our aggregates are more about *logic* (if statements and flow control). Jonathan Oliver Ah, thanks a lot guys. So basically for the saga persistence we can have either event sourcing (command is saved along with the events in the transaction) or simple state storage (command is saved along with the latest state and possible outgoing events). Dispatcher could dispatch in async later in both cases. Once we have command info persisted atomically with the resulting changes, we can have all the idempotence we need (still staying away from the 2PC). Consistency is 100% even if process dies between the commit and ACK. So technically sagas are just like the aggregates (they are entities), and the primary difference is in the intent (similar to the differencebetween commands and events) and life span expectations. This way everything that happens in saga between the handler and message dispatch is rather straightforward, reliable and simple (and similar to the aggregate behavior). Thanks again for helping to think though the logic of this part of CQRS! Best regards, Rinat I agree. Sagas and aggregates have different intent plus resulting differences in behavior, life cycle and persistence. Ignoring this in the project might kick in the natural selection process for it. However, implementation logic of command handlers outside of these "inner" specifics seem to be similar for both cases (i.e.: questions of reliability, 2PC, transactions and message dispatch). Don't you think? Best regards, Rinat hi Rinat, I'm using Esper for my "sagas" and currently I can rebuild it's state by replaying events at startup. Esper allows one send timetick events to control the flow of time when replaying in isolation, and it's pretty awesome! Pedro H S Teixeira Hi, Can anybody provide with a pseudo code for saga? That would make things more clear. Bhoomi Kakaiya Bhoomi, I've published an article that goes into some deeper on Sagas (as per discussions in this thread and outside of it). Although there is still no source code, but it might help to understand everything. http://abdullin.com/journal/2010/9/26/theory-of-cqrs-command-handlers-sagas-ars-and-event-subscrip.html Just a caveat: I'm sorry for going into deep details about the partitioning logic (this was needed by the specifics). In practice implementations will probably skip this part completely in 95% of cases (and go lightly on a few other explicit constraints as well). Best regards, Rinat
Clone git clone git@github.com:tangxuehua/eventsourcing.git Pull git pull Commit & Push git status git add -A git commit -a -m "commit detail" git push Pull-Request http://www.worldhello.net/gotgithub/04-work-with-others/010-fork-and-pull.html git add remote yourProject git@github.com:yourName/yourProject.git git remote -v git fetch yourProject git branch -a git merge yourproject/master git log --graph -2 Pull-Request With Local Repos http://stackoverflow.com/questions/5775580/git-pulling-changes-between-two-local-repositories that's cool! git add remote yourProject-local http://www.cnblogs.com/yourName/yourProject git remote -v git fetch yourProject-local git branch -a git merge yourProject-local/master Link Commit to Issue git commit -a -m "fix #1, other messages" Add or Remove tags View all tags: git tag Add a new tag for the newest stable release: git tag -a v0.5 -m "Version 0.5 Stable" Push the latest tag to GitHub (two dashes): git push --tags Delete the v0.4 tag locally: git tag -d v0.4 Delete the v0.4 tag on GitHub (which removes its download link): git push origin :v0.4 Branch create branch: git branch testingBranch push branch to remote: git push origin testingBranch view all the branches: git branch switch branch: git checkout testingBranch remove branch of local: git branch -D testingBranch remove branch of remote: git push origin :testingBranch refresh local branches from remote: git fetch -v --progress --prune origin
最近又学习了一下LMAX架构,让我对该架构以及event sourcing模式又有了很多新的认识和疑问。 注:如果不知道什么是lmax架构和event sourcing模式的看官可以自己先去查查资料: LMAX可以看看martin写的一篇文章:http://martinfowler.com/articles/lmax.html Event Sourcing的资料比较多,随便google一下即可。 当然,我的博客里也有大量关于这两个方面的笔记,有兴趣的可以看看。 下面是我的一些最新的想法。 LMAX architecture:input event + business logic processor(BLP) + output event 架构主要执行过程: 首先input event由上层(如controller)创建并最后统一汇集到input disruptor(一个并发控制组件),然后BLP在单个线程内处理所有的input event,一般处理的情况有:1)简单时,直接让aggregate 处理,处理完之后aggregate会产生output event;2)如果是复杂的过程,如long running process,则通过saga的方式来控制整个业务流程;首先也是由aggregate来处理input event,然后产生的output event会由saga响应,然后saga会根据流程逻辑决定接下来要做什么,即产生哪个input event;实际上我把saga也看成是一种聚合,因为saga也有状态,saga表达了一个流程的处理状态,saga也有唯一标识,saga也需要被持久化;总之,BLP在处理完input event后会产生output event。然后这些output event会被某些关心的event handler处理;另外有些event handler在处理output event时又会产生另外的input event并最终也发送到input disruptor,整个过程大概是这样。不知我理解的是否正确。 下面针对我上面的理解再做一些总结: 整个过程有下面这几个主要元素构成:input event + BLP(包含aggregate,saga) + output event; input event,output event用于消息(message)传递,实际上他们都属于消息,并且也都是domain event? BLP用于处理业务逻辑(由aggregate负责)和流程控制逻辑(由saga负责); aggregate产生output event,output event会最终被发送到output disruptor; output event有两个主要作用:1)可以让领域外知道领域内发生了什么;2)可以通过output event串联某些复杂的业务过程,如银行转账,如提交订单,etc; 值得注意的是:整个BLP(saga+aggregate)是in-memory的,重建BLP是用input event来实现,而不是output event;这也是为什么LMAX架构中在BLP处理input event之前必须先通过一个叫journaler的东西持久化input event的原因。目的就是为了在需要的时候利用这些input event通过event sourcing(事件溯源)模式重建整个BLP。其实这个行为更直白的理解就是让BLP再重新处理一遍所有的input event;当然,在重建过程中对于任何要访问外部系统接口的地方,都要禁止访问,否则会带来问题,尤其是更新外部系统的时候,这个其实比较简单,只要设计一个gateway即可,重建blp的时候设置一下该gateway即可。 接下来我想阐述一些我觉得自己比较纠结的地方: event sourcing的中文解释是事件溯源,关键是如何理解溯源?我的理解是:根据已经发生的事情来重现历史。如果这个理解是正确的,那何为已经发生的事情?lmax是通过input event来溯源,也就是说Lmax认为已经发生的事情是input event,而非output event,即LMAX认为已经发生是指只要input event一旦被创建就表示事情已经发生了,即已经发生是针对用户而言的,如用户提交了订单,那就是OrderSubmitted,用户点击了修改资料的保存按钮,那就是UserProfileChangeRequested;而我们之前的做法是根据aggregate产生的output event来溯源,即我们认为已经发生是相对aggregate而言的;那么到底哪种思路更好呢?虽然两种做法都能最终还原BLP。但就我个人理解,我觉得lmax的做法更合理,实际上如果让LMAX和CQRS架构的command端做对比,那么input event相当于command,只不过command一般都是动词,所以就是CreateOrder,ChangeUserProfile。所以可以理解为lmax架构实际上是在replay command;所以问题就演变为我们到底应该replay command还是replay event?想想replay是谁在replay?是聚合根,这点毫无疑问。另外,replay从语义上来说实际上就是和play做的事情是一样的,只不过是“重做”的意思。那么要理解重做首先要理解什么是“做”?我对“做”的理解就是执行行为并改变状态。所以“重做”就是重新重新执行行为并改变状态;replay command相当于是在重做别人给aggregate一些命令;而replay event相当于是在重做aggregate自己以前曾今做过的一些事情。其实,最重要的一点是,到底要重做什么?是重做用户的要求(what user want to do)还是重做聚合根内已经发生的事情(what domain has happened.),这个问题的回答直接决定到底该replay command 还是 replay event,呵呵。所以,按照这样的思路来思考就很明显了,LMAX是在重做用户的要求,而我们之前的replay event则是在重做聚合根内已经发生的事情。如果我认为重做应该是重做用户的要求,那replay event就不是真正意义上的重做了,而仅仅只是改变状态。举例:假设有一个Note聚合根,有一个ChangeTitle的公共方法,然后还有一个ChangeTitleCommand,ChangeTitleCommand的handler会调用Note的ChangeTitle方法;另外Note还有一个OnTitleChanged的私有方法,用于响应TitleChanged事件。如果是replay command,那会导致ChangeTitle会被重新调用,这就是重做用户的要求;而如果是replay event,则只有OnTitleChanged方法被重新调用,也就是说只是在重做聚合根内已经发生的事情。思考到这里,我不得不承认第一个思考出这种思路的人很厉害,因为他用了这种绕个弯的做法(将本来可以放在一个方法内一次性完成的任务(先改状态然后再产生output event))拆分为两个步骤,第一步是先仅仅产生一个TitleChanged的事件,第二步才是响应该事件并作出状态改变。这样拆分的目的是可以让第二步的方法(OnTitleChanged方法)可以用于event sourcing。另外,这两步对聚合根外部来说是透明的,因为外部根本不知道内部是通过两个步骤来实现的。不得不承认这种做法在replay的时候远比replay command要容易的多,因为所有的aggregate的内部事件响应函数都不会涉及与任何外部系统的交互。虽然这种做法挺好,但是我觉得我们非常有必要搞清楚这两种不同的event sourcing的区别。 另一方面,从确保event必须被持久化的角度来讲:我觉得LMAX的架构,即replay command的好处是,可以很容易在进入BLP之前持久化command,真正做到在BLP处理之前确保所有事件已经被持久化了;而如果是replay event,那我们就没办法实现一个in-memory的BLP了,因为首先BLP是in-memory的,即没有任何IO,但是我们又要求必须持久化output event。那怎么办呢?如果是同步的方式持久化output event,那就不是in-memory了;如果是异步的方式来持久化output event,那虽然可以做到in-memory,但怎么确保output event一定已经被持久化了呢? 目前就这些了,以后有更多的思考内容再补充上来。 ------------------------------------------------------------------------------------------------------------------------------- 后来又做了一些思考。想来想去,最终还是倾向于应该通过output event来做event sourcing。因为毕竟只有output event才真正表示domain aggregate认可的可以发生的domain event。而我们要重建的就是聚合根,到底是应该通过重复执行用户的命令来让模型达到最新状态还是通过让聚合根重新执行已经发生过的事情呢?现在想来,应该是后者。虽然前者也可以,但是要付出的代价相对比较大,比如重建时要禁用外部系统的调用,最麻烦的还是重启发布时的很多细节问题要考虑;而通过聚合根已经发生的事情来重建,则相对很容易,因为重建时不会涉及任何模型之外的东西!但是因为我们现在采用了in-memory domain的架构,所以传统的基于数据库事务的做法已经无法使用了。所以需要设计另外一种架构确保在domain修改状态之前domain event已经被持久化了,为什么要做这个保证是因为event sourcing+in memory的架构实际上是一种event driven architecture,即整个领域模型的状态的修改都是由事件驱动的,这意味着如果要改变内存中的领域模型的状态,那必须先确保引起该状态修改的domain event必须已经被持久化了。从用户发起一个command后执行的流程如下:disruptor是并发控制组件,大家可以暂时理解为一个消息队列。如果要进一步了解disruptor,可以看看LMAX架构。 Send ChangeNoteTitleCommand to input disruptor; Command handler execute method called by input disruptor; Note.ChangeTitle method called by command handler execute method; NoteTitleChanged domain event is created and raised in note.ChangeTitle method; The infrastructure framework send the above NoteTitleChanged event to input disruptor when the raise method is called; Journal event handler called by input disruptor to persist the event; Another event handler called by input disruptor to really apply all the note state changes according with the event. A third event handler called by input disruptor to send the event to the output disruptor; All the external event handlers are called by the output disruptor; for example, some external event handlers will update the CQRS query side data; 以上步骤必须严格按照上面的顺序一步步执行下来,否则无法确保逻辑正确。 另外,以上流程目前只考虑单台机器,未考虑主备或集群的架构如何实现;之所以用英文写是因为我还要拿去和老外讨论,呵呵。不过这几句英文应该比较简单吧。
安装完Mono for Android(简称:MonoDroid)之后,可以用MonoDevelop或Visual Studio来开发Mono for Android应用程序;目前只能在模拟器上调试和部署,必须购买后才能在真机上调试和部署;目前遇到的最大的问题是:模拟器上调试速度非常慢,通过单步调试每一行代码都需要几秒钟。有人开了个帖子抱怨以及一些回复的相关讨论:http://mono-for-android.1047100.n5.nabble.com/Free-version-Emulator-only-Bye-bye-td5091443.html,另外,如果购买了正式版,那支持直接用设备来调试的相关文章介绍:http://docs.xamarin.com/android/tutorials/debug_on_device MonoDroid应用程序,应该说所有的Android应用程序只要在处理5秒后还未完成,则会自动提示用户“应用程序无响应,是否结束应用”类似这样的提示信息。所以我们一般在处理一些可能比较耗时的操作时,比如与服务器进行通信请求数据或Post数据。这里操作都需要通过异步的方式来完成; MonoDroid提供的API与原生Java平台下的Android开发基本一致,类的名称以及方法名称都保持一致,这样只要会开发原生的Android应用,那在MonoDroid下也可以开发; 虽然说MonoTouch, MonoDroid可以允许我们用C#来开发在IOS以及Android应用,但是并不是所有的代码都只要用c#写一次就可以在这两个平台上跑了。实际上,能重用的代码也许只有业务层的代码。因为UI的实现,两种平台不同,MonoDroid下依赖于Mono.Android来实现UI,而MonoTouch下则是用另外一套不同的UI实现方式。实际上Mono更多的是考虑了与原生API一致的方式来开发UI,所以设计了两套不同的类库来实现UI架构;所以UI层的代码无法重用;另外,数据访问层,也不能共用,因为虽然都是访问sqlite,但是Mono在这两个平台上分别对应实现的API不同,MonoTouch下使用:MonoTouch.CoreData,而MonoDroid下使用Mono.Data.Sqlite。当然我们还是可以将数据访问层进行抽象,比如抽象成IRepository,然后业务层调用IRepository的接口即可,IRepository的具体实现需要基于不同平台分别实现; 之前可以在Windows上跑的Castle框架在MonoDroid上不再支持,编译会遇到错误,因为Castle程序集依赖于System.Configuration这个程序集,但是在MonoDroid平台上没有这个程序集;MomoDroid平台上支持的.Net程序集有限,见下面的介绍。基于这个原因,但是又希望能像以前那样使用某个IOC框架,所以找了一个跨各种手机平台的轻量级开源Ioc框架(TinyIoC),该框架非常小,只有一个cs文件就能使用,使用后感觉效果还不错,基本容器功能都支持了。git开源项目地址:https://github.com/grumpydev/TinyIoC log4net在MonoDroid上也不支持,因为:Log4Net uses classes in .Net namespaces such as System.Web, and System.Diagnostics that are not yet implemented in Mono for Android. 不过幸好,Android平台自带了一个Log记录器,在MonoDroid下可以使用Android.Util.Log来记录日志。如果是用Visual Studio来开发,则可以直接在VS的Output窗口看到日志,另外VS还有一个专门的窗口(View -> Other Windows -> Android Device Logging)用来查看Android记录的日志。另外,也可以通过命令行的方式查看日志,定位到目录:C:\Program Files (x86)\Android\android-sdk\platform-tools,执行命令:adb logcat,详细方法可以参考:http://docs.xamarin.com/android/advanced_topics/android_debug_log MonoDroid的数据库是用sqlite,目前内置支持两种数据访问方式:原生方式(游标的方式)以及ADO.NET类似的接口,使用起来ADO.NET的方式非常简单,我们只需要引用:Mono.Data.SQLite这个程序集就能像ADO.NET那样来访问sqlite数据库了。 游标方式举例: //查询数据 ICursor cursor = this.db.Query(DatabaseTable, new[] { KeyRowId, KeyTitle, KeyBody }, null, null, null, null, null); //新增数据 var initialValues = new ContentValues(); initialValues.Put(KeyTitle, title); initialValues.Put(KeyBody, body);this.db.Insert(DatabaseTable, null, initialValues); //更新数据 var args = new ContentValues(); args.Put(KeyTitle, title); args.Put(KeyBody, body);this.db.Update(DatabaseTable, args, KeyRowId + "=" + rowId, null); //删除数据 this.db.Delete(DatabaseTable, KeyRowId + "=" + rowId, null); //事务支持 this.db.BeginTransaction(); //Start a transaction.try { var result = func(); //Do update db operations. db.SetTransactionSuccessful(); //tell db the update operations successfully. return result; } catch { //Error in between database transaction}finally { //commit the transaction. //if the setTransactionSuccessful method have not been called, then the transaction will auto rollback. db.EndTransaction(); } ADO.NET方式举例,(需要引用:Mono.Data.Sqlite) //查询数据 public static IEnumerable<Note> GetAllNotes() { var sql = "SELECT * FROM ITEMS;"; using (var conn = GetConnection()) { conn.Open(); using (var cmd = conn.CreateCommand()) { cmd.CommandText = sql; using (var reader = cmd.ExecuteReader()) { while (reader.Read()) yield return new Note(reader.GetInt32(0), reader.GetString(1), reader.GetDateTime(2)); } } } } //新增和更新数据 public static void SaveNote(Note note) { using (var conn = GetConnection()) { conn.Open(); using (var cmd = conn.CreateCommand()) { if (note.Id < 0) { // Do an insert cmd.CommandText = "INSERT INTO ITEMS (Body, Modified) VALUES (@Body, @Modified); SELECT last_insert_rowid();"; cmd.Parameters.AddWithValue("@Body", note.Body); cmd.Parameters.AddWithValue("@Modified", DateTime.Now); note.Id = (long)cmd.ExecuteScalar(); } else { // Do an update cmd.CommandText = "UPDATE ITEMS SET Body = @Body, Modified = @Modified WHERE Id = @Id"; cmd.Parameters.AddWithValue("@Id", note.Id); cmd.Parameters.AddWithValue("@Body", note.Body); cmd.Parameters.AddWithValue("@Modified", DateTime.Now); cmd.ExecuteNonQuery(); } } } } //删除数据 public static void DeleteNote(Note note) { var sql = string.Format("DELETE FROM ITEMS WHERE Id = {0};", note.Id); using (var conn = GetConnection()) { conn.Open(); using (var cmd = conn.CreateCommand()) { cmd.CommandText = sql; cmd.ExecuteNonQuery(); } } } //事务支持 using (var conn = GetConnection ()) { conn.Open (); var transaction = conn.BeginTransaction(); try { //Do db operations. transaction.Commit(); } catch { transaction.Rollback(); } } ORM,NHibernate不能运行在Mono for Android上,不过手机应用的业务逻辑相对简单,ORM的需求优先级应该不是很急,暂时可以通过上面的数据访问方式来访问sqlite数据库。 类库方面,目前支持的.net类库有限,主要有以下几个: 1 mscorlib 2 System 包含System.Net命名空间,支持HttpWebRequest, HttpWebResponse,这两个类可以实现与服务器端通信 3 System.Core 包含IO, LINQ, Collections,etc 4 System.Data 该类库实现了ADO.NET的相关基础架构,如DataReader, DataAdapter, Connection, Command, etc. 5 System.Data.Services.Client 6 System.EnterpriseServices 7 System.Json 提供了简单的JSON序列化和反序列化支持 8 System.Numberics 9 System.Runtime.Serialization10 System.ServiceModel11 System.ServiceModel.Web12 System.Transactions 提供事务支持,包括分布式事务13 System.Web.Services14 System.Xml15 System.Xml.Linq16 Microsoft.CSharp17 //以下几个是Android开发需要的类库18 Mono.Android MonoDroid核心类库,该类库中提供的API与原生的JAVA API基本一致,所以使用起来很方便;19 Mono.Android.Export20 Mono.Android.GoogleMaps21 Mono.Android.Support.v422 Mono.CompilerServices.SymbolWriter23 Mono.CSharp24 Mono.Data.SQLite 提供封装了Sqlite数据库的ADO.NET接口支持25 Mono.Data.Tds26 Mono.Security 与服务器通信,可以像平时一样通过HttpWebRequest或WebClient来发送请求。以下代码设计了一个通用的通过异步的方式发送HttpWebRequest /// <summary>/// 异步发送HttpWebRequest/// </summary>/// <param name="cookie"></param>/// <param name="url"></param>/// <param name="postData"></param>/// <param name="callback"></param>public static void SendHttpPostRequest(Cookie cookie, string url, string postData, Action<HttpWebResponse> callback) { //解决https下的证书问题 HttpRequestCredentialHelper.SetDefaultCredentialValidationLogic(); var request = HttpWebRequest.Create(url) as HttpWebRequest; //设置请求类型为POST request.Method = "POST"; //设置Post的数据 if (!string.IsNullOrEmpty(postData)) { request.ContentLength = postData.Length; request.ContentType = "application/x-www-form-urlencoded"; using (var writer = new StreamWriter(request.GetRequestStream())) { writer.Write(postData); writer.Close(); } } //将Cookie放入请求,以让服务器知道当前用户的身份 var container = new CookieContainer(); request.CookieContainer = container; if (cookie != null) { container.SetCookies(new Uri(Constants.ROOT_URL), string.Format("{0}={1}", cookie.Name, cookie.Value)); var logger = DependencyResolver.Resolve<ILoggerFactory>().Create(typeof(HttpWebRequestHelper)); logger.InfoFormat("HttpWebRequest CookieName:{0}, Value:{1}", cookie.Name, cookie.Value); } //异步发送请求 request.BeginGetResponse(new AsyncCallback(asyncResult => { var httpRequest = asyncResult.AsyncState as HttpWebRequest; using (var response = httpRequest.EndGetResponse(asyncResult) as HttpWebResponse) { callback(response); } }), request); } 发送请求示例代码: HttpWebRequestHelper.SendHttpPostRequest(null, url, postData, response => { var response = HttpWebRequestHelper.GetTextFromResponse(response); //这里处理HttpWebResponse //如果要反问UI相关元素,则需要封装为一个委托然后在RunOnUiThread方法内执行 RunOnUiThread(() => { var folders = _taskFolderService.GetAllTaskFolders(); _listView.Adapter = new TaskFolderAdapter(this, Resource.Layout.TaskFolderListItem, folders.ToArray()); }); }); 分层架构,我觉得我们可以采用以下的分层架构: UI 界面层,MonoTouch,MonoDroid分别实现 Model 模型层,实现核心业务逻辑,代码可重用,如果采用DDD领域模型来实现,则可以包括:Service,Aggregate,Entity,VO,IRepository Model.Infrastructure 基础框架层,实现公共基础代码,供上层调用,如DI,log,configuration,httprequest, constants, etc Model.Repositories 仓储实现层,对Model层的IRepository接口的实现,不同平台采用不同实现 更多介绍关于Mono for Android开发的文章: http://www.cnblogs.com/liping13599168/archive/2012/06/10/2543549.html 这一篇是介绍关于开发原生Android应用的文章,基本上文章中提到的方法也同样适用于Mono for Android http://blog.csdn.net/wlanye/article/details/7199831
<component/>是NHibernate中一个有趣的特性,即是用来映射DDD(Data-Display-Debuger)概念形式的值类型。这是一种创建比物理数据模型具有更高粒度的对象模型的方式。 举例, 看下表中的数据: 对应的对象模型: 它们十分不同,在一个单一表中包括了所有物理数据,我们想在对象模型中调整为用两个分离的类型来映射该表。这就是<component/>的用法: <class name="Person" table="People"> <id name="Id"> <generator class="identity"/> </id> <property name="Name" /> <component name="Address"> <property name="Line1"/> <property name="Line2"/> <property name="City"/> <property name="Country"/> <property name="ZipCode"/> </component> </class> 这个映射将会在物理数据模型和对象模型中转换。我们还可以让NHibernate对<component>进行排序并给我们所预期的对象图。
DDD的核心是聚合。这没有问题,大家都认同。但关于DDD中的聚合方式,其实我还是有些担心,下面说说我的想法,希望大家参与讨论。 其实当初第一次看到DDD中关于聚合根部分论述的时候,就感觉有些僵化。DDD中的聚合根的分析设计思路大致是这样:1、业务本质逻辑分析;2、确认聚合对象间的组成关系;3、所有的读写必须沿着这些固有的路径进行。 这是一种静态聚合的设计思路。理论上讲,似乎没有什么问题。但实际上,人对第一步中的业务逻辑分析就是一个渐进的过程,不是稳定不变的。不是谁都可以成为业务领域专家,就算是业务领域专家也不一定都是对的。在我看来,从时间维度和多用户场景下看,这种静态的聚合分析设计方法是根本无法保证领域模型的稳定性。 也许有人不理解,那可以打个比喻:过去几个孩子可以和爸爸妈妈高高兴兴地一家人生活在一起,但是孩子们长大后是必然要分家的。其实我只是在强调,人们对业务过程的认识是有局限性的,谁也无法避免。 DDD本来就是处理复杂业务逻辑设计问题。我看到大家用DDD去分析一些小项目的时候,往往为谁是聚合根而无法达成共识。这说明每个人对业务认识的角度、深度和广度都不同,自然得出的聚合根也不同。试想,这样的情况下,领域模型怎么保持稳定。 更现实的解决方式是怎么在动态过程中尽可能地保证业务领域模型的稳定性。在我看来:对象之间是平等的,没有谁高人一等(也就是没有聚合根);场景(业务)是聚合对象行为的唯一理由;复杂的场景是由简单场景聚合而成。不管业务如何变化,总有子场景是不变的,这样就能获得最大的“维护利润”(业务不变性)。 作为企业软件开发而言,最大的挑战就是业务变化。这一方面来源于业务本身的变化(应用系统应用不断深入和推广),另一方面是我们对业务认识不断深入的过程。不能适应变化的系统只有死路一条。复杂的业务要求软件架构必须具备很强的适应能力。如前面所说,这是一个渐进的过程。DDD本身就是为了解决复杂业务的软件开发问题的。“如何避免颠覆式修改”是最大的挑战。如果发现找的聚合根是错误的,那领域模型还可重用的价值还有多大,这种代价和成本是否能够承受? 核心观点: 每个人对业务认识的角度、深度和广度都不同,得出的聚合根也就会不同;这才有了很多时候我们无法对谁是聚合根以及聚合根的边界大小达成共识; 我们对业务的认识是一个不断深入的过程,在这个过程中我们的模型也会相应调整; 从DDD的最后落地实现角度来看,最终出来的是一个个聚合。但是因为聚合不仅仅只是一个根实体,而是还内聚了一堆子实体和值对象。那它的这种内聚结构对于后期因各种原因而发现原来的聚合是错误的时候,此时模型重构的成本和代价会相对于“一个所有Entity对象都地位平等的模型”的重构成本会更大,特别是在引入了Event Sourcing时问题更加凸显,这点也可见我上篇发表的帖子;
基于DDD+Event Sourcing设计的模型如何处理模型重构? 问题背景:ddd的核心是聚合,一个聚合内包含一些实体,其中一个是根实体,这个大家都有共识;另外,如果将DDD与Event Sourcing结合,那就是一个聚合根会产生一些event;那么这里的问题是:如果一个领域对象,一开始是entity,后来升级为聚合根,但是该entity之前根本没有对应的event,因为它不是聚合根。因此它升级后我们如何通过event sourcing获取升级后的聚合根最新状态;同理,相反的例子是聚合根降级为实体,该如何处理。 基于哲学方面的一些思考: 之前ORM时代,数据就是数据,我们直接存储数据,然后读取存储的数据即可,很简单; 现在Event Sourcing了,数据用事件表示,我们不在存储数据本身,而是存储与该数据相关的所有事件,包括数据被创建的事件在内;这种思维是好的,我们希望通过保存数据的“完整的历史”来达到任意时刻都能还原数据的目标。但是我们仅仅保存event就真的保存了“完整的历史”了吗?显然不是,我认为历史包含两部分信息:1)事件;2)逻辑;目前我们只保存事件而没有保存逻辑;但是我们又要希望通过事件溯源还原“完整的历史”,怎么可能?! 但是,我们为了确保能还原数据,所以代码重构都小心翼翼,比如确保尽量不改原来的事件,尽量用新事件实现业务变化或新业务功能。另外,对于处理事件的逻辑也尽量确保能兼容老的事件。之所以要这么别扭是因为我们没办法把历史的事件和历史的事件处理逻辑一同持久化。实际上我们总是在用老的事件与最新的代码逻辑相结合进行重演,这实际上是很危险的事情。 然后碰到我上面提出的尖锐问题,实际上很难有优雅的解决方案了。上面我提出的问题其实很难解决:无论是聚合根升级还是降级,都意味着新对象的事件我们无法获取或者说根本之前没有任何与新对象相关的事件,自然就无法再用事件溯源的方式得到该对象了。而实际上这个对象什么都没做,只是做了个升级或降级处理而已; 那么问题出在哪里呢?我认为是DDD的聚合导致的问题。我们之所以要设计出聚合,主要原因是为了通过聚合的手段确保业务上具有内聚关系具有数据一致性规则(Invariants)的领域对象之间方便的维护其一致性;而事件溯源从概念上来说并不针对整个aggregate,而是针对单个的entity.现在一旦将DDD与event sourcing结合,那势必会导致模型中一些对象没有与其相关的event,这就会给我们后期模型重构带来巨大的问题。 既然问题找到了,那我想解决方案也很容易了。就是如果要用event soucing,就必须抛弃聚合的概念,让一切对象回归平等,所有的entity都相互平等,当然value object还是保持不变,因为其只是一个值而已;然后让每个entity都能产生事件,这样就不会有因为某些entity没有事件而导致重构时遇到巨大问题的情况了。 自此,也许你会说,没有聚合那不就是贫血模型了吗?我不这么认为!聚合的意义有两个:1)更好的表达业务完整概念,因为有些对象却是在概念上就是内聚其他一些对象的,比如一辆汽车有四个轮子,汽车内聚轮子;2)为了维护对象之间的Invariants,这个不多解释了,我想大家都理解;那我认为第一点其实和功能无关,是概念上好理解才这样做;关于第二点维护对象之间的Invariants,我认为有很多方法,不必必须显式的定义聚合来实现,我们只要确保所有的entity都能很好的规定其自身哪些属性必须有,哪些属性不能变,哪些可以变,哪些可以在什么范围内变,等等规则约束。这样也同样能实现不变性约束;实际上这种方式和DDD看起来非常接近,但是绝不是贫血模型,因为贫血模型是所有entity的所有属性当然id除外都有get;set;然后所有逻辑全部在service中以transaction script的方式实现;而我上面说的方式实际上entity该有的职责和业务规则判断还是放在entity内部做掉,但是和经典DDD相比,经典DDD的大部分规则和一致性逻辑都在聚合根内完成,而我的方式则由各个entity合起来实现相同的规则和一致性约束; 到这里,其实event sourcing还是面临小范围(单个entity内部)的代码重构的压力,但这我们总能找到相对成本比较轻的解决方案,比如尽量不改原来事件,只新增事件属性,不删除事件属性。即总是采用与原事件兼容的修改方式来修改事件,这其实是可以接受的。 大家觉得怎么样呢?很希望能多听听大家的想法。 -------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------------------------------- 为了能更好的说明问题,我写了个简单的小例子。下面有对这个例子的详细描述,以及基于该例子的问题描述; //团队聚合根public class Team : EntityBase<int>, IAggregateRoot { private IList<Member> _members = new List<Member>(); public IEnumerable<Member> Members { get { return _members; } } public void AddMember(string name, string email) { ApplyEvent(new MemberAdded(name, email, this.Id)); } public void UpdateMemberName(int memberId, string newName) { ApplyEvent(new MemberNameUpdated(memberId, newName, this.Id)); } private void OnMemberAdded(MemberAdded evnt) { _members.AddMember(new Member(evnt.Name, evnt.Email)); } private void OnMemberNameUpdated(MemberNameUpdated evnt) { var member = _members.FindMemberById(evnt.MemberId); member.SetName(evnt.NewName); } }//团队成员新增事件public class MemberAdded { public string Name { get; private set; } public string Email { get; private set; } public int TeamId { get; private set; } public MemberAdded(string name, string email, int teamId) { this.Name = name; this.Email = email; this.TeamId = teamId; } }//团队成员名称修改事件public class MemberNameUpdated { public int MemberId { get; private set; } public string NewName { get; private set; } public int TeamId { get; private set; } public OnMemberNameUpdated(int memberId, string newName, int teamId) { this.MemberId = memberId; this.NewName = newName; this.TeamId = teamId; } }//团队成员实体public class Member : EntityBase<int> { public string Name { get; private set; } public string Email { get; private set; } public Member(string name, string email) { this.Name = name; this.Email = email; } public void SetName(string name) { Assert.IsNotNullOrEmpty(name); Assert.LengthLessThen(name, 255); this.Name = name; } } 上面的例子中,有一个聚合根,Team,表示一个团队;Team内聚了一些团队成员,Member;Member是实体; 这里聚合根,实体,就是DDD中的Aggregate Root与Entity。这里没问题吧!另外,上面的例子,我采用了Event Sourcing的方式来实现模型。 Event Sourcing的核心思想有两点: 1)用与某个对象相关的事件来记录对象的每一次变化,一次变化一个事件,对象的创建是第一个事件,如TeamCreated事件表示一个团队被创建了; 2)对象的重建不需通过ORM,而是直接使用之前记录的事件进行逐个重演最终得到对象最新状态,这个重演的过程我们称为事件溯源,英文叫Event Sourcing; 不知我上述对Event Sourcing的描述是否和大家的理解一致? 好了,本文提到的关于“历史不仅仅由事件组成,还必须由处理该事件的逻辑组成”。这句话的意思是,事件要进行重演,必须与一定的逻辑结合,事件本质上只是一些数据, 包含了某次变化的相关信息,它不包含逻辑,是静态的值对象;那逻辑是什么呢?主要指两方面: 1)上面Team类里的OnMemberAdded和OnMemberNameUpdated这两个方法,这两个方法实际上是事件的处理函数,职责是负责更新聚合的相关状态; 2)这些事件处理函数在更新聚合状态时实际上是依赖于当前聚合的内部结构的; 所以,事件要能够顺利的按照和历史的方式完全一致的重演,依赖于三个要素必须和历史一致: 1)事件不变; 2)聚合内部的事件处理逻辑不变,或者即便要变也必须和以前的逻辑兼容; 3)事件处理逻辑依赖的聚合的内部结构不变,或者即便要变也必须和以前的结构兼容; 而我们现在做到的只是第一个要素不变,第二和第三个要素我们很可能会进行重构; 当然你可能会说,第二点你也基本不会变,因为你的事件处理逻辑一般都是简单的属性赋值,即简单的更改聚合相关属性的状态,那行,如果你真这样做,那确实问题不大;实际上也必须这样做! 但是第三个要素呢?第三个要素实际上就是我说的模型结构重构,最严重的重构情况则是:聚合根降级为实体,或者实体升级为聚合根,简称聚合根的升级与降级; 对于这两种情况,在应用了Event Sourcing的情况下,那是很可怕的。因为从上面我的代码中可以看出Member起初只是个实体,它没有自己的事件,所有的事件都只和聚合根关联,即Team。 但是我们之后如果想重构,把Member升级为聚合根了,这个重构之前在ORM时代,那时非常简单的事情,基本什么都不必变,但是在Event Sourcing的模式下,就有大问题。 因为我们没有与Member对应的事件,自然就无法应用事件溯源来重建Member聚合根了。这里实际上就是我说的上面的第三个要素发生了结构性变化,导致我们无法通过事件溯源重建对象 看到这里,大家再回过头去看一下我最上面对问题的阐述可能更好理解一点吧!
大家看看能否看得懂哦,呵呵! 最早的想法是产生于对OO方法的不满,主要的想法是将对象拆开为方法和属性以实现更加灵活的组合,在此之上构想了很多特性,但是过于零散,没有统一的理论,还肯定存在严重的特性冲突问题。 将计算机代码看做一个由逻辑实现的符号运算展开的结果,类似于元编程,自称为“逻辑宏”,用逻辑宏实现声明式与过程式的结合,可以实现AOP,LOP,更理想的OO,等诸多特性。 认识到时间在语言表达中的重要性,因而提出了记忆机制,通过构造一个带有记忆机制,事件-动作响应的逻辑语言来实现代码,让诸多抽象的语言结构得到实现,将2中所描述的逻辑宏在这个机制上构造。这个描述仍然需要一个虚拟机去执行。 时间不再是一个需要特殊看待的东西,一切都包括在一个关于世界的逻辑结构中,世界的现在,过去和未来都是这个逻辑结构的一部分。将3中所描述的记忆,事件-动作响应,通过纯粹的逻辑方式构造出来。虚拟机再也不需要了,而虚拟机本身也是逻辑构造的一部分。 逻辑本身,是可以由图灵机所依赖的时空法则所构造出来的,最基础的三段论,经典逻辑,一阶逻辑,高阶逻辑,模态逻辑及各种非经典逻辑,将在一个一致的基础上构造出来,在这个构造中,可以看到时空法则,逻辑,图灵机的纠缠关系,还可以看到逻辑是如何在与现实的交互中发挥作用。 ------------------------------------------------------------------- Java和C#的改进实际上都在第一层 先进的函数式语言一般都是在第二层上做工作 计算机语言领域只看到lisp的发明人的新语言试图在第三层上有所作为 计算机语言的语义学在第四层上有一些零星的成果。 第五层只是在哲学意义上被提及。 ------------------------------------------------------------------- 这个层怎么分,值得大作文章,更确切地说,不是我们想怎么分,而是去发现自然的分层法则。逻辑体系的分层(与分类),一阶、二阶……也是我一直看到的最重要的线索之一,不过,似乎就逻辑方面的研究本身,也没有把这个层级体系作为一个课题充分地阐明吧?我现在也还很朦胧。 我对你的计划和思路很感兴趣的地方,正是把图灵机作为最原始的逻辑,然后层次地构造更复杂、高级的逻辑,还有,就是你对于关系模型的一些基本方式——这二者都具有坚实的数学基础。现在,软件界主流基本上用“OO”来填补这两者之间的空缺,这个你批判得很透彻了。 然而,我想要提醒或补充的是,这二者并不能直接地连接,这中间还有一些重要的关系需要揭示,而且也有(我认为已经初步显露)一块重要的数学基础(主要就是是有限模型论),以及在这个数学基础之上建立的,一种与图灵机、关系模型几乎同等位置的“计算”模型(或一种理论,姑且这么说吧)。 简单地说吧,中间这一块,解决的就是所谓逻辑和现实世界(无限、开放的)之间的关系 ------------------------------------------------------------------- 实话说,对第五层我实在没信心,因为这是在图灵机假设下探索最基本的哲学问题了。你提到的有限模型论我今天查阅了一下,我觉得应该对我将来在理论方面的工作启发很大,非常感谢。 对于一定的工程成果,我还是很有信心的,我大量的日记都是在写如何用这样一种统一理论来解决工程实践中的各种问题,包括DSL,效率,解释和编译,类型体系,等等等等。这些方方面面的问题和解决方法,我已经花费了大量时间和精力来寻找,真正的突破是在最近2到3年产生的。 我这个层次划分,也不是单纯从理论本身出发,而是在探索过程中感受到的几个台阶,每上一个台阶都会解决原来所无法想明白的很多问题。 我的方法论,是从这个探索过程中总结出来的,而且起到了非常大的作用,每当我把分析哲学的问题放到一个软件的环境中去思索的话,思想很快会被澄清,而且结论几乎是确凿无疑的,将来,在我的语言中也是可以验证的。 现在的精力主要集中在用prolog和本地程序相配合,构造一个类似emacs的IDE,做为第一步要发布的成果。期望在1年左右的时间内能够进行展示。 ------------------------------------------------------------------- 关系模型(代数)+模型论+图灵机,以及它们关联的逻辑体系,是我十多年思考所得到的最宝贵的拼图,我相信,没有中间那一块,就无法真正解决图灵机与现实世界(最终应用问题)的关系问题。关于这一点,我在国外的软件工程社区也在尝试做一点交流,总的来说,很难找到对这个问题有真正感觉的人,即使个别已经熟悉有限模型论与关系模型方面进展的人,似乎也表现得缺乏一些基本的宏观视图。 此外,可留意一下模型论语义学和本体方面(与模型论应用有关)的研究。我的着眼点和层次与你的工作有些不同,我会从更宏观,以及“应用架构”的角度切入,最近正在准备在博客上做一些更具体的讨论,希望有机会多与你交流! 理解你的计划的艰巨和意义,相信这是一个卓越的尝试,等着看你的结果:-) ------------------------------------------------------------------- 还有,你说的第五层……我不敢说理解了多少,但我认为,我们只需要找到眼前的构建性的原则,和一些整体的原则,然后由最基础的层次(已经有了:图灵机)开始,一个层次一个层次地构造,其实,后面的层次具体是什么样,我们很难甚至也不必太早知道……这也是我的构建的哲学。 ------------------------------------------------------------------- 好,希望多交流,的确非常难得,我英文很烂,而我的理论又涉及到一些分析哲学中的概念,就更难以交流了。还好现在以实际产品为成果,将来可以直接用代码解释。 我在英语世界中仅仅曾经把构思中的一个很小的语法特性发到LTU论坛上,结果有个家伙用我的两倍篇幅重新替我解释了一遍,最后评论道: update: p.s.: it's a very subversive idea, too, in that it suggests a completely different approach to problem that ever-more-sophisticated type-theory-inspired systems are coming up with. A much simpler and yet plausibly more powerful approach. 这的确让我受到了很大的鼓舞。
1. 聚合根、实体、值对象的区别? 从标识的角度: 聚合根具有全局的唯一标识,而实体只有在聚合内部有唯一的本地标识,值对象没有唯一标识,不存在这个值对象或那个值对象的说法; 从是否只读的角度: 聚合根除了唯一标识外,其他所有状态信息都理论上可变;实体是可变的;值对象是只读的; 从生命周期的角度: 聚合根有独立的生命周期,实体的生命周期从属于其所属的聚合,实体完全由其所属的聚合根负责管理维护;值对象无生命周期可言,因为只是一个值; 2. 聚合根、实体、值对象对象之间如何建立关联? 聚合根到聚合根:通过ID关联; 聚合根到其内部的实体,直接对象引用; 聚合根到值对象,直接对象引用; 实体对其他对象的引用规则:1)能引用其所属聚合内的聚合根、实体、值对象;2)能引用外部聚合根,但推荐以ID的方式关联,另外也可以关联某个外部聚合内的实体,但必须是ID关联,否则就出现同一个实体的引用被两个聚合根持有,这是不允许的,一个实体的引用只能被其所属的聚合根持有; 值对象对其他对象的引用规则:只需确保值对象是只读的即可,推荐值对象的所有属性都尽量是值对象; 3. 如何识别聚合与聚合根? 明确含义:一个Bounded Context(界定的上下文)可能包含多个聚合,每个聚合都有一个根实体,叫做聚合根; 识别顺序:先找出哪些实体可能是聚合根,再逐个分析每个聚合根的边界,即该聚合根应该聚合哪些实体或值对象;最后再划分Bounded Context; 聚合边界确定法则:根据不变性约束规则(Invariant)。不变性规则有两类:1)聚合边界内必须具有哪些信息,如果没有这些信息就不能称为一个有效的聚合;2)聚合内的某些对象的状态必须满足某个业务规则; 例子分析1:订单模型 Order(一 个订单)必须有对应的客户信息,否则就不能称为一个有效的Order;同理,Order对OrderLineItem有不变性约束,Order也必须至少有一个OrderLineItem(一条订单明细),否 则就不能称为一个有效的Order;另外,Order中的任何OrderLineItem的数量都不能为0,否则认为该OrderLineItem是无效 的,同时可以推理出Order也可能是无效的。因为如果允许一个OrderLineItem的数量为0的话,就意味着可能会出现所有 OrderLineItem的数量都为0,这就导致整个Order的总价为0,这是没有任何意义的,是不允许的,从而导致Order无效;所以,必须要求 Order中所有的OrderLineItem的数量都不能为0;那么现在可以确定的是Order必须包含一些OrderLineItem,那么应该是通 过引用的方式还是ID关联的方式来表达这种包含关系呢?这就需要引出另外一个问题,那就是先要分析出是OrderLineItem是否是一个独立的聚合 根。回答了这个问题,那么根据上面的规则就知道应该用对象引用还是用ID关联了。那么OrderLineItem是否是一个独立的聚合根呢?因为聚合根意 味着是某个聚合的根,而聚合有代表着某个上下文边界,而一个上下文边界又代表着某个独立的业务场景,这个业务场景操作的唯一对象总是该上下文边界内的聚合 根。想到这里,我们就可以想想,有没有什么场景是会绕开订单直接对某个订单明细进行操作的。也就是在这种情况下,我们 是以OrderLineItem为主体,完全是在面向OrderLineItem在做业务操作。有这种业务场景吗?没有,我们对 OrderLineItem的所有的操作都是以Order为出发点,我们总是会面向整个Order在做业务操作,比如向Order中增加明细,修改 Order的某个明细对应的商品的购买数量,从Order中移除某个明细,等等类似操作,我们从来不会从OrderlineItem为出发点去执行一些业 务操作;另外,从生命周期的角度去理解,那么OrderLineItem离开Order没有任何存在的意义,也就是说OrderLineItem的生命周 期是从属于Order的。所以,我们可以很确信的回答,OrderLineItem是一个实体。 例子分析2:帖子与回复的模型,做个对比,以便更好地理解。 不 变性分析:帖子和回复之间有不变性规则吗?似乎我们只知道一点是肯定的,那就是帖子和回复之间的关系,1:N的关系;除了这个之外,我们看不到任何其他的 不变性规则。那么这个1:N的对象关系是一种不变性规则吗?不是!首先,一个帖子可以没有任何回复,帖子也不对它的回复有任何规则约束,它甚至都不知道自 己有多少个回复;再次,发表了一个回复和帖子也没有任何关系;其次,发表回复对帖子没有任何改变;从业务场景的角度去分析,我们有发表帖子的场景,有发表 回复的场景。当在发表回复的时候,是以回复为主体的,帖子只是这个回复里所包含的必要信息,用于说明这个回复是对哪个帖子的回复。这些都说明帖子和回复之 间找不出任何不变性约束的规则;因为帖子和回复都有各自独立的业务场景的需要,所以可以很容易理解它们都是独立的聚合根;那也很容易知道该如何建立他们之 间的关联了,但是我们要尽量减少关联,所以只保留回复对帖子的关联即可;帖子没有任何必要去保存一个回复的ID的列表;那么你可能会说,当我删除一个帖子 后,回复应该是没有存在的意义的呀?不对,不是没有存在的意义,而是删除了帖子后导致了回复对帖子的关联信息的缺失,导致数据不一致。这是因为帖子和回复 之间有一种必然的联系(1:N),回复一定会有一个对应的帖子;但是回复有其自己的生命周期,不应该随着帖子的删除而级联删除。这种情况下,如果你删除了 帖子,就导致回复也成为了一条无效的数据;所以,我们绝对不允许删除任何聚合根,因为一旦你删除了聚合根,那就意味着与该聚合根相关的其他任何聚合根都会 有外键引用缺失的问题,会导致整个领域模型数据的不一致;所以,永远都不要删除聚合根;