实现领域驱动设计
领域驱动设计在最近几年里越来越多地走进了主流架构设计的中心舞台上来了,这一切其实似乎是在业界精神领袖Martin Folwer在 他的《企业软件架构模式》一书和各种各样的讨论中的推荐之下之后忽然就火了起来。 甚至有一种说法是:如果学完了设计模式之后感觉没有什么用武之地,那么你需要去好好看一下Eric Evans写的《领域驱动设计》一书以吸收新的灵感, 然后才能知道你工作中的面性对象设计用错在了什么地方。
和领域驱动设计的接触史
领域驱动设计对我而言并不是一个很新鲜的名词,因为在我看来它讨论的还是面向对象的设计思想和基本的设计模式应用; 只不过是它将这些思想直接地应用到了软件所要解决的问题域中去了而已。 只是我对它的理解还是经历了一个很长的曲折过程。直接用中文的“领域驱动设计”一词来描述无疑会使行文变得累赘不少,后面就暂且不时偷懒地用DDD来指代它。
职业生涯最早期的擦肩而过
我第一次和领域驱动设计的思想擦肩而过的日子应该是在十多年前,那时候我还是一个刚刚离开校门不就的青涩的初级程序员; 自恃学了一些面向对象的设计方法,然后在TopLanguage社区上读到了一些大神推荐Martin Fowler的网站的文章。因为恰好在外企里面工作,英语还算可以, 就仔细读了Martin博客上的几篇文章,恰好看到了关于领域驱动设计的讨论。
由于自己的工作经验实在是浅薄的可怜,眼界是是出奇的狭窄,可惜自己还不自知,对于软件工程中的领域知识复杂性实在了解的有限。 这个时候有公司高级设计师在刚刚火起来的微博上讨论领域驱动设计和领域特定语言,我看到这样新鲜的名词居然没有听说过,马上找来了Martin的书读了一遍。 可惜实在是所知有限,并没有很深入的理解进去;得到的只是一些非常皮毛的概念。
二次接触
大概三四年前,在做了长时间的软件工程师、高级工程师并尝试了其它团队Leader和项目管理的角色之后,我做出了深入技术领域的决定,成为了一名初级的软件架构师。 出于自己爱读书的习惯,我又重新翻起了那本被认为是划时代设计思想巨著的《领域驱动设计》一书。这一次因为有了之前的工作经验, 作者所谈的一些技术痛点读起来就变得显而易见,虽然Eric Evans所经历的行业我没有直接的感受,其中所谈的困境和作者想解决的问题却深深地引起了我的共鸣。
这个时候我心里最深切的想法是:再来深入体会和理解领域驱动设计其实应该是水到渠成的事情。 可惜这一次我又错了,尽管我专门集中了最好的碎片时间尽量大块儿地阅读这本巨著;最终因为自己操作的机会毕竟有限, 很多方面的认识还是知其然而不知其所以然。
作者提到的关于Unbiqutous Language的概念和其它形形色色的概念,其实还是不免流于理论形式;虽然对从数学专业毕业的我而言,理解起这些理论来并不觉得吃力。 这个时候我正在做架构师的工作,平时也亲自主持设计一些关键系统模块的高层设计和抽象思考,并有大量的时间去评审别的高级工程师设计出来的模型。
有意无意地我会讲Eric里面讲述的思想引入到我们日常的工作中,并努力身先示范并尽力去影响大家来推进这些思想的引用。 这个时候的项目用的Java环境,而Spring本身到这个时候已经支持了不少DDD自身的一些逻辑注解;里面的文档都会给你一句简单的介绍说这个Service的概念来自于Eric Evans等。
很多时候我觉得自己明明很认真地告诉大家使用这些想法会得到怎样的好处,其它的同事也会对这些想法表示赞同,然而真正具体实施的时候, 别人还是会觉得这些做法很费劲儿,真正做出来的软件还是不自然地陷入到了“大泥球”之中,其实这些努力并不能算得上是成功。 具体的原因当时的自己其实并没有理解的很深刻。
第三次亲密接触
时光飞速地从身边流过,四年过去我已经从当初迷茫而不自知的初级架构师成长成为了一个积累了更多的经验又更加惶恐地认识到自己不足的一个中高级架构师。 当时的迷茫和手足无措其实很大部分来自于自己知识积累上的不足和实际工作经历的欠缺;毕竟当时转过来重新深入钻研技术有一些其它方面的原因,并不是完全的水到渠成。
还好我尚能做到知不足而奋进;几年的时间我尽量用自己身边可用的渠道弥补自己之前相关知识上的欠缺。这些弥补的方式主要还是读书和思考, 某种程度上来说,读书可以算作是我个人的比较优势,我的动手能力也还算可以,但是比较起来我还是更擅长于读书。
优秀的描述架构和设计的图书其实并没有许多;因为关于软件设计和架构的方法学和思想也就那么一些花样;其它一些具体的技术和框架是不断有花样出来, 底层的这些本质的东西并没有发生太大的变化。商业软件的复杂性很多时候并不在于这些具体的软件基础技术;当然脱离这些基础技术的话,很多时候并不可能做出有使用价值的软件来。
基于这些其它方面技术的积累,我又一次对领域驱动设计发生了兴趣,于是就顺手来啃一下另外一本这个领域赫赫有名的《实现领域驱动设计》。 简单翻了一下目录就不禁感叹:这不就是为现在我的知识和认知水平储备的吗?有广度又有深度,没有过多地重复一些其它的理论只是, 却又不简单地堆砌代码;连书中的文字和插画风格也和Bob大叔的很相像,每一个部分都有一个关于牛仔的漫画和对白,可惜我对美国的牛仔文化并不是很熟悉。
简单的为什么和怎么做
对于任何一个稍微复杂一点的实体来说,想清楚这两个问题都会有不小的挑战。是否采用DDD方法,为什么要采用DDD以及怎样很好地时间DDD也是这样的复杂问题。
为什么需要DDD
为什么的问题其实取决于我们需要解决的问题域的复杂性,如果是非常简单的系统,用数十行的脚本或者SQL查询语言就可以完成的事情,断然是用不到DDD这么高级的抽象系统的, 因为那样做不产生太多实际的价值,可能还会折进去不少的精力损耗而得不偿失。
DDD本身希望解决的是复杂的业务领域,这些业务领域在需要用软件来表述和实现的时候,需要多个团队成员的协作来完成需求讨论、设计、开发、测试已经维护才能实现商业目标。 这种情况下,使用DDD可以极大地简化业务领域的复杂性;用更小的成本实现更大的商业价值。
最简单的怎么做
DDD的核心思想可以认为是两个:
- 无处不在的领域业务语言即Ubiquitous Language,这是DDD最为强大的一个工具之一
- 绑定的领域上下文即Bounded Context
这两者的关系是互相依存的,没有清晰的领域业务语言,就没有办法识别清楚特定的绑定上下文; 同样没有清晰的领域上下文,那么领域业务语言就会变得模糊不清出而失去威力。
Ubiquitous Language
这是一门特殊的团队使用的通用开发语言,需要由领域知识专家(系统分析和需求知识专家)和开发技术专家(程序员)共同来开发和维系。 只有如此,两者才能共享相同的知识;通常情况下,该语言可能由领域专家先根据领域知识和需求来创建,然后该语言需要保持演进,以便领域知识专家和开发技术专家互相学习, 共同产生目标软件模块甚至系统。
它是一种团队内部用来捕捉核心业务逻辑概念和内部领域软件模型的工具。它的作用范围需要限制在一个绑定的上下文中, 从而有可能出现同一个实体在不同的绑定上下文中会有不同的语言名称,这个并没有什么问题。 一个特定的Ubiquitous Language总是为特定的绑定上下文所服务的。
这种语言是领域专家和程序员之间的桥梁:通过共享相同的领域语言,知识专家和程序员共同写作,完成最终的软件产品; 这种知识和技能上的互补是DDD方法的一个内生的优势。 当然如果组织的沟通和行政结构不匹配的话,这也可能成为实现DDD的一个阻碍因素。
挑战和陷阱
这里有一个巨大的挑战在:开发组织是否有足够的时间和投入来保证领域知识专家能够坐下来和开发人员一起讨论和发现这些无处不在的通用领域语言, 识别其中的抽象模型和核心业务价值的关系,不断地提炼领域通用语言。 同样的开发人员或者程序员也要被不断地鼓励用该通用的语言和领域专家进行沟通,确保相互的理解和语言的演进,而不是一头扎进代码的细节中实现偏离了该共同语言的最终产品。
很多年以来,软件开发行业都有些过于追逐炫目的底层技术细节了(当然不是说这些不重要),而DDD的思想需要我们适当地变得少一点技术化,多讨论一些业务语言。 因为领域问题的复杂性才是大部分商业软件复杂性的主要来源;新的技术和工具可以解决一些痛点,但是很多时候它们不应该是软件的核心价值所在。
领域粒度切分和绑定上下文
很多时候我们面对的领域问题非常复杂,然而开发人员往往禁不起诱惑而希望将所有的问题都放在一个领域中来解决, 这样的副作用就是理解和沟通起来相当困难,出现问题也非常难以排查,从而使得系统维护和演进的成本变得日渐高昂甚至最终会被废弃而完全失去价值。
解决这一问题的办法是需要将问题领域切分为多个子领域,并确切地区分子领域和核心领域。 核心领域关系到业务成功因此需要配备最好的领域知识专家和最好的开发人员,从而使他们形成适合于他们的统一语言,保证即使其它领域不成功,核心的商业价值也可以得以保全。 而非核心领域则是一些支持性的领域,这些领域中有些可能可以被多个其它领域所使用,这样的领域就是一些通用的子领域。
通过讲问题领域分割为多个子领域,复杂的问题得以简化;通过聚焦于核心领域,核心的商业价值得以最大化。 每一个子领域和围绕它形成的领域专家和技术专家共同产生和维护的统一语言构成了特定的绑定上下文。
领域边界
划分好的子领域形成了自然的领域边界;因此绑定上下其实是用领域统一语言的边界来分隔的。 不同的绑定上下文自然就不能共享统一的业务语言了,因为这个边界其实是个语言边界;这也是前边一直强调的通用领域语言的重要性所在了。 这也意味着
- 没有必要也没有可能在不同的子领域之间统一术语和上下文,这个是由绑定上下文的本质决定的;不同的领域自然有不同的绑定上下文和自己的领域术语
- 开发人员和领域专家需要认识到并且拥抱不同子领域的模型和概念不需要一致的现实
- 跨越领域来分享概念和名词容易带来混淆和混乱,尽力不这么做
领域不是组件、模块等纯软件概念
领域模型是基于领域专家和技术专家的共同语言而存在和得以维系的;因此我们不能草率地将传统的纯软件架构技术和领域概念混为一谈。 一个子领域并不是一个组件,或者一个模块,或者应用服务实例等纯软件组织概念。 领域驱动设计要求我们总是在大脑里记清楚两者不是一回事儿;我们的模型总是柔和了业务领域知识和软件抽象技术的一个综合体。
绑定上下文的粒度和大小
绑定上下文的粒度取决于所采用的领域统一语言所表述的概念边界;最理想的情况是两者刚好能够完全匹配;但是没有匹配的话,还可以不断的修正它, 这也正是敏捷软件开发思想的应用,不是什么太大的问题。
一个绑定上下文中所使用的其它的DDD设施,包括Entity,Aggregates,Events,Service等, 应该是恰到好处地匹配DDD的需要,是“增之一分则太肥,减之一分则太瘦”的恰到好处。尤其是核心领域对应的绑定上下文, 更要努力做到如此;自然这需要上面所说的最好的资源支持才可以做的到。
DDD的思想还要求我们界定绑定上下文的边界的时候,必须避免软件架构技术产生决定性的影响; 因为这一方法的指导性原则是,需要倾听和修正领域模型和语言边界,而不是软件架构实现技术的边界。 有时候可以用具体承载领域的软件架构技术来指代领域,只要领域边界的界定不是根据这些架构技术得来的就行。
上下文映射
处于上述绑定上下文粒度和领域通用语言的考虑,复杂一点的领域设计系统中总会有多个不同的子领域;描述问题的解决方案的时候,我们需要考虑不同子领域之间的映射关系; 而这正式上下文映射所要解决的问题。如果将多个绑定上下文描述在一张图中,他们之间的相互关系就是上下文映射。
上下文映射不光给我们提供了一个关于整个系统的高层视图,还架起了一座帮助我们在多个绑定上下文(子领域)之间沟通的桥梁。 这一点之所以重要,是因为不同的子领域可能使用完全不同的”通用领域语言”。
如何描述上下文映射
一般情况下,上下文映射描述的是现在所理解的领域边界和这些相关子领域的相互关系;实际操作的时候我们需要保持敏捷而不是考虑过多想象的未来图景。 大部分情况下,基本的领域框架和寥寥数笔领域关系就足够使用;如果需要描绘更多的细节信息, 可以采用DDD中的其它诸如Aggregates、modules方法来细化这些关系; 只是大部分情况下这样做并无必要。
需要留意的是上下文映射并不是企业软件架构或者系统拓扑结构;它不是特别为这方面的意图所准备的。 它的主要作用是帮助各个领域团队开发的时候作为沟通和交流的参照;在有必要的时候,它依然可以部分地用在这些场景,凸显系统集成的瓶颈所在;只是不要本末倒置就可以。
大部分情况下,上下文映射可以用白板的形式放置在领域开发团队的办公区域,使得参与其中的开发和业务人员随时可以看到它们。 其它的方式可以根据实际团队的沟通习惯来实施,比如放在大家经常访问的Wiki中;不过如果这个Wiki长久没有人访问,还是不要浪费力气的好。
上下文映射关系
实际描述多个绑定上下文之间的相互关系的时候,可以采用如下几种Evans所定义的集成模式
- 伙伴式关系:两个领域之间是通过紧密写作来完成各自的领域职责;一荣俱荣一损俱损的关系,两者之间有直接而深入的耦合关系。相互关联的功能必须要通过两个团队之间的紧密合作才可能开发和集成成功。
- 共享内核:两个领域之间共享一些基本的公用模型或者核心设施、代码等;从而两个领域的设计可以得到极大的简化;如果存在共享内核,需要尽力使得共享内核足够的小和简单。
- 上下游关系:上游领域的改动无需通知下游,而下游的成功非常依赖于上游提供的服务。
- 反破坏层:当上游领域和下游领域团队之间的沟通异常困难,或者下游非常难以协调上游的资源支持的时候,下游领域团队往往会需要选择构建一个针对上游领域的反破坏层;这在大公司里面非常常见。
- 开放宿主服务:一个领域通过想其它领域提供一个开放协议的方式提供支持;新的领域如果需要和它继承,需要根据已有的协议添加适配即可。很多时候,下游的领域会将这个开发协议的提供方看做是一个难以可靠协调的上游, 并且构建反破坏层来维护自身的稳定性。
- 已发布的公用语言:两个领域之间可以通过共享一部分领域通用语言的方式来沟通和交互。
- 大泥球:大泥球现象在一些上了历史的老项目中非常场景,当需要和这些遗留领域打交道的时候,我们需要格外小心构建隔离边界,防止大泥球问题的蔓延。
软件架构
DDD方法强调它并不需要和某一种架构风格相互绑定,也不依赖于某些特定的软件架构方法,甚至于可以和风险驱动的软件架构方法相处融洽。 不同的软件架构技术和方法有不同的适用场景,而DDD方法则力图和这些架构方法和谐共存,不管是经典的封层架构, 还是稍微现代一点的形形色色的架构风格和方法,DDD都可以根据需要来适用他们解决领域内的问题。
分层架构和依赖导致原则
传统的封层架构按照层次组织软件系统,严格的分层架构仅仅允许高层的组件依赖于低层的组件,并且不允许跨越层次调用依赖;而松散一点的分层架构则允许跨越一些中间层次来调用底层的组件; 当然两者都允许底层的组件调用高层;并且需要确保分层架构中没有环产生,以免有难缠的系统依赖问题。
依赖倒置原则其实从某种程度上来看打破了传统的分层架构的严谨性,但是同时又允许更大的灵活性,因为组织依赖上都要求大家尽量依赖于抽象而不是依赖于具体的实现; 甚至可以认为层次的概念呗弱化的同时,软件的灵活性还得到了保证。
端口和适配器架构
这一架构风格原来也被称为是六边形架构;或者另外一个所谓的洋葱架构指向的也是这种风格。任何时候一个组件想要和另外一个组件交互的时候,只需要做这两件事情
- 拿到对方的端口
- 准备一个新的适配器组件,将自己的内部逻辑翻译为对外部端口的调用即可
今天的很多封层架构其实都是端口和适配器架构,尤其是很多基于依赖注入设施的项目更是如此;即使是他们称呼自己为分层架构。 这一架构的巨大好处是,即使外部模块或者组件没有就绪,我们也可以根据端口和适配器很方便地构建测试用例来验证用户场景。
基于服务的架构
基于服务的架构包括传统的SOA架构和新兴的微服务架构,他们的侧重点和针对服务切分的颗粒度乃至服务治理方式有形形色色的差异,好在从DDD方法认为这种架构风格并没有破坏领域的完整性。 相反我们可以根据DDD的方法让一个服务描述一个对应的绑定上下文,在这个服务内部,所有的代码共享同一个通用的领域语言。
REST和DDD
当今微服务大热的背景下,REST多多少少已经被很多场景滥用了,甚至可以说不该用而滥用的场景比应该使用而没有使用的情况要多。 作为服务的提供方来说,REST端口提供的是对于资源的抽象和操纵接口,它是随着HTTP协议的兴起而变得无处不在的,其核心仍然是围绕着关于服务器资源的访问展开的;只不过这些资源的抽象本身一直在不断地扩大。 对使用REST的客户端而言,它提供的是关于资源访问的URI和一系列资源操作的方法。
理想情况下,REST服务提供方需要按照超媒体的组织方式提供基于HATEOS风格的可发现式的API,将客户端的访问从具体而又死板的URI信息中解耦; 而客户端可以从一个简单的URI开始,根据实际的资源访问情况,步步深入访问其它需要的资源完成业务场景。
从DDD的角度考虑,一般不建议采用直接用REST API来暴露领域模型的做法,而是要尽量采用适度的隔离措施,使得核心的领域模型不至于变得过度脆弱。有两种办法可以达到该目的
- 一种是专门创建一个用于提供REST表示接口的子领域,让该子领域访问核心的领域。
- 另外一种做法是将注意力放置在媒体类型上,用一类媒体类型表述一系列的领域交互模型资源;这种做法其实是上述的共享内核或者已发布公共语言的继承方法。
CQRS
CQRS即命令和查询职责分离的简称;这一做法要求我们任何一个方法要么负责查询,要么负责执行命令修改状态;但是永远不要将两者混合在一起。 依据这种做法,传统的领域模型就不得不分为两个部分:一个子模型负责查询,另外一个则负责执行命令。
这样的做法虽然会带来意外耦合的问题,却有一些额外的好处,尤其是系统需要有不同的扩展性要求和查询、修改性能指标;将两者分离的做法很容易轻松地应对这些传统方法难以处理的难题。 往往采用CQRS技术的系统需要仔细考虑和应对最终一致性的问题。
事件驱动架构
事件驱动的架构方法鼓励使用各种事件产生、发送、检测、消费以及对时间做出反应来组织软件系统。DDD系统内部可能有来自内部、外部的多种不同的事件;我们用领域事件的做法来融合DDD和事件驱动架构。 通过使用领域事件来传递领域模型需要关注的关键信息,各个领域模型可以采用事件驱动架构的各种方法来监控、检测、过滤、合并领域时间,或者对领域事件做出反应,完成复杂的用户场景。
Event Sagas
Event Sagas用于描述长期运行、持续不断的事件处理过程;有如下三种常见的办法来描述长期运行的长时间处理过程
- 将处理过程作为一个组合任务过程,用一个可以持久化的对象来记录任务执行时间、完成度信息
- 将处理过程表述为收集各种活动交互的聚合的集合;其中一个或者多个维护整体的执行和状态信息
- 处理过程本身设计为无状态的,但是每个处理时间的任务在处理完毕之后都增加更多信息到输出事件上,从而把进度、完成情况等信息递增地包含在事件中
Event Sagas的方式要求我们必须拥抱最终一致性模型,并且妥善处理好超时和重试处理,尽量补偿可能出现的异常情况,甚至在情况复杂的时候尽量引入工作流来降低领域问题的复杂性。
Event Sourcing
一般情况下使用Event Sagas就可以追踪和记录系统中发生的事件,只是在某些业务场景下我们想更进一步地追踪每一个时刻系统发生的所有事件,而不仅仅是某个实体最后一次被修改后的状态和时间戳。 Event Sourcing技术通过记录实体上所发生的所有事件信息,并将其放置在单独的存储实体上来满足这一需求, 用户甚至可以通过简单的提供实体的唯一标识来查询和定位具体的事件,甚至还原某一个时刻领域实体的状态。
这样的做法在很多时候可以提供足够灵活性的同时,满足极大的性能和扩展性需求。这一方法能工作的前提是依赖于上述提过的最终一致性行为。 同时使用Event Sourcing技术构建的系统往往是满足CQRS的要求或者可以认为是在CQRS基础上的进一步定制。
领域实体
开发人员一种常见的错误倾向就是过分地关注主题的数据模型,这也许于我们过多地关注于数据库技术有关。 然而领域驱动开发的核心是关于领域的行为和特征,而不是数据的获取和读取。 之所以要用领域实体的概念来封装业务特征,而不是直接在数据集的基础上采用CRUD来直接实现业务规则,主要是因为系统的业务复杂度不断提高的时候,直接CRUD的方式带来了软件复杂度的急剧上升。
领域实体和另外一个DDD术语Value Object有很多方面比较接近;两者的一个最显著的区别是: 领域实体具有一个唯一的标识符,它可以区分不同的领域实体,而值对象可以直接复制并且复制的对象和原来的对象完全表述同样的领域抽象信息。
领域实体标识符
领域实体标识符的一个最重要的要求就是需要保证在具体的领域内是唯一的,并且这个唯一性不受时间的改变而改变 - 即使程序退出重启了,如果这些信息被持久化保存,重新加载的时候这个唯一性仍然需要保留; 否则就会带来很严重的不一致问题。
有很多技术手段来产生唯一的领域实体标识符,这些方法实现有简有繁,并且每一种都有它们自己的优点和不足。
用户保证的唯一性
一种最简单的思路是,让用户提供输入来保证领域实体的唯一性;这种方法简单直接并且在很多场景下是一种不错的解决方案。 这样的做法有一个显而易见的不足就是扩展性和性能可能不高;甚至需要考虑好用户可能输入非法的标识符的情况, 如果这是可能发生的,设计一个健壮的校验系统或者工作流验证系统也是有意义的。
应用程序自动产生
有很多技术手段可以让应用程序在需要的时候自动产生一个唯一的实体标识符,比如常见的UUID、GUID技术可以保证产生一个在绝大多数情况下都不会重复的唯一标识符。 这些技术方法也有一些明显的陷阱需要我们选择的时候留意
- 分布式节点的ID产生可能需要防止意外的ID碰撞,这个和选择使用的算法参数密切相关。
- 直接用String对象来表述这些ID可能不是一个好主意,最好是用一个值对象来表示它,尤其是这样的ID里面包含了其它比如机器地址等信息的时候。
- 如果有些领域对象需要知道这些基于随机数算法产生的标识符,会有额外的挑战需要我们应对。
使用数据库系统产生的标识符
直接使用数据库系统来产生标识符有一个额外的好处是我们不需要考虑它的唯一性,存储系统自己封装了如何保证唯一性的重要细节我们直接拿来使用就好了。 常见的关系数据库系统比如Oracle、MySQL都提供了这样的设施;其不足之处是性能可能不能我们的需要;这一点尤其在大型分布式系统中格外明显。
请求另外一个绑定上下文来提供标识符
这种方式的思想也很直接,需要唯一标识符的时候,请求上游的绑定上下文(领域)要求对方分配一个标识符。 这样做的一个天然的问题是,如果上游的绑定上下文发生了变化该如何处理?我们可以使用事件驱动架构的办法,监控上游领域的领域事件,然后根据变化做出响应。
这也是一种最复杂的策略,因为领域内的实体不仅仅依赖于领域边界上的适配器翻译,还依赖于外部绑定上下文的领域事件更新。 因而在大部分情况下,我们必须谨慎选择采用该策略来决定领域实体的标识符。
何时产生实体标识符
一般来说我们有两种时机来决定领域实体的唯一标识符:一种是在领域实体对象被创建的时候,自动分配一个标识符;另外一种策略则是只有当实际需要讲实体放置在一个Repsoitory中的时候,才实际分配标识符。
第二种方法有一个显然的好处是,在实际放入Repostory之前我们依然可以像操作值对象一样操作这个领域实体。 潜在的问题是,如果系统架构风格是事件驱动的,那么实体对象被创建的时候,所发布的领域事件的实体标识符就无法拿到了。 要处理这个问题,我们要么只能回到第一种方式做预先分配,要么我们在比较对象的时候,采用基于属性比较的方式而不是比较领域实体标识符。
当然第一种方式的情况下,我们实现对象的比较或者取hash的时候,可以直接用领域实体而无须关心其它的内部属性值。
多套标识符的协调和映射
如果我们采用存储系统提供的标识符,这些标识符可能和我们自己设计的领域实体的标识符中间产生不协调;比如ORM系统产生的标识符是一个数字串而领域模型需要提供另外一种表示方法。 这种情况下,一般的做法是隐藏这些实际来自于其它地方的标识符,而仅仅对外暴露领域模型抽象出来的实体标识符。 这种情况下,如果需要讲标识符当做数据的键来使用,那么也不需要使用领域模型抽象的实体标识符。
实体标识符的稳定性
大部分情况下出于标识符稳定性的要求,我们选择将标识符设计为不可变的,即在创建或者分配的时候可以设置一次, 而一旦创建就不允许修改了。这种设计策略和标识符合适创建没有直接关系。 如果领域内部需要做标识符的修改,需要务必小心做好封装和信息隐藏,隔离对其它绑定上下文的影响。
挖掘实体的角色和职责
角色和职责的挖掘是传统的面性对象设计方法中最困难的部分,也是最有价值的部分。 我们需要使用的底层技术还是一样的,用接口来抽象实体所需要扮演的角色,然后用类来建模实际的领域实体。 不论怎样选择这里的角色抽象,需要始终牢记的是跨职能团队根据领域业务逻辑而统一的领域语言才是决定这些角色和实体类的职责的最重要的因素, 而实际实现这些抽象的面性对象技术应该作为具体实现的支撑细节。
领域实体的创建、校验
从面向对象编程的角度来看,领域实体本身是一个对象,因此需要调用对应的类的构造方法来生成实体对象。 该实体类内部封装的属性之间可能存在一些非空值之外的一些相互约束关系,这些约束关系称之为Invariant,它对应的参数需要在外部构造的时候,把对应的参数传入,并且在类的内部实现上加以隐藏。 负责更新这些单个属性参数的内部操作方法不应该暴露给外部。
如果构造的过程比较复杂,可以用基于工厂模式的设计方法将实体对象的构造过程封装起来,方便外部用户更简单地生成实体对象; 这一方法本身是设计模式的直接应用,实现起来并不麻烦。
另一个相关的常见的实体处理约束是关于实体对象的校验 - 其背景是我们往往需要处理多个对象的组合的情况;此时每一个子对象是合法的并不意味着这些对象的组合也依然是合乎领域实体模型约束,因此需要合理的校验和判断。 我们需要的是基于整个对象的校验;需要实现整对象校验的前提是,每一个子对象(可能是一个实体或者值对象)自身需要满足自身的校验约束。
一种常见的处理方法是使用自封装,即在实体类内部提供多个Accessor方法,并且要求实体类内部的访问也需要使用这些自封装方法。 这种处理方法也是“防御式编程”所推荐的处理方法。与此相反的一种反模式是,期望于用存储系统提供的校验工具来实现实体对象校验,这在某些情况下可能有一些比较大的缺陷; 典型地如在MySQL数据库中,单个行记录最多只能包含65535个字符,如果超出此限制,数据库的校验则无法处理了。 因而重要的是应该更好地关注与关注点分离,这也是计算机科学的基本处理思路。
领域实体变更跟踪
大部分情况下,领域实体对象的状态变化不需要被特别关注;而当这一需要的确被领域专家所确认的时候,我们可以用领域事件这一方法来满足,或者使用事件存储机制来达成目的。 有时候我们纯粹是出于一些技术上的原因而不是领域专家的意见需要关注领域实体的每一个变更,这种情况下解决之道是使用Event Sourcing方法。
值对象
值对象在很多方面和领域实体对象非常想象,然而两者有一些至关重要的不同。所谓的值对象它所表述的是一些没有唯一标识的类似纯粹数值的概念。 值对象本身在DDD方法中占据重要的位置;我们可能感到惊讶的是,在DDD方法中我们总是应该力求多使用值对象而不是领域实体对象;并且即使我们决定了使用领域实体来描述一些聚合概念, 那么该实体内部如果可以用值对象的集合来表示,就尽量少用子领域实体的集合来表示。
值对象的特征
值对象一般需要满足下面一些特征
- 描述领域中的一种数值,度量或者其它类似的概念
- 可以用不可变对象来表示
- 可以用等价关系来比较另外一个完全不同的对象
- 可以被另外一个对象替换而不影响语义和行为
- 当被其它的协作对象使用的时候,能满足无副作用的行为约束
不可变性
值对象可以表述为一个不可变对象的想法具有重要的简化意义;满足不可变对象意味着,一旦值对象被构造完成之后, 其自身的方法调用并不会改变内部封装的属相值。这种情况下,如果需要变更,最好的办法是替换产生一个新的对象。这样的处理方式和值对象本身的领域语义直接对应,该值对象才能和其它DDD概念更好地协作。 当我们需要对构造好的值对象变更某些属性的时候,需要思考能否用替换的方法来达到同样的效果。
整对象概念
值对象需要能表述为一个完整对象的概念,而不是一堆属性之间的无意义拼凑;这些属性之间应该满足相互之间的高内聚;有可能的话则讲没有内聚的属性移除到其它地方。 同时时刻谨记值对象需要和绑定上下文中的领域概念相匹配,值对象本身所表述的概念整体上要能和领域通用语言协调一致;因此用一个基本的原始字符串类型来描述值对象远远不如用一个自定义的类类型来描述对应的领域概念要来的自然。
可替换性和等价判断
我们期望它能够满足不可变性的同时就意味着可替换性是显而易见的;因为当我们需要变更一个值对象的时候, 只需要构造一个新的值对象并返回出去即可。同时可替换性也要求我们能够基于值的语义来判别两个值对象是否相当。 这里的相等依赖于它所封装的内部属性是否都相等,其处理方式和领域实体有着明显的不同
- 领域实体对象的相等比较往往基于其唯一标识即可
- 值对象的想等下判断需要基于各个属性的等价性来判别
无副作用
无副作用本身要求对象的方法调用中不能修改对象自身的内部状态;值对象的无副作用其实是在满足了上面的不可变性和可替换性之后的自然可得的特征,因为除了构造方法除外,任何其它方法都不能违反不可变属性。 如果发现某个值对象的方法必须要修改内部的状态,我们需要停下来挑战一下自己:能否用替换的方法而不是变更内部属性的方法达到同样的目的。
实现最小化集成
稍微复杂一点的DDD系统中都会有很多个需要被集成在一起的多个绑定上下文;此时使用值对象来完成他们之间的相互集成往往是最简单的: 在下游的绑定上下文中,只需要用一个值对象来集成上游绑定上下文中传入的对象即可。
将领域标准类型表述为值对象
在很多系统中,我们需要处理关于某些信息的有限种的分类信息,比如货币种类、HTTP协议的MIME类型等;这些用于表示事物的类型的信息如果用值对象来描述看起来简单易于理解。 只是在实际实现上,可能用枚举类型来表示会是一个更自然的选择,尤其是如Java的枚举值类型允许实现抽象和多态行为,即允许在枚举类本身中定义抽象行为, 然后在具体的一个一个的值中提供具体实现;这种方法其实是状态模式的一个语言层面的简化实现。
这些标准类型的值对象定义最好是拿出来放在一个共享的绑定上下文中被多个其它的领域上下文所使用;同时需要计划好对这些标准类型值对象的更新和维护,因为实际上会有多个下游的共享上下文受此影响。 如果系统中的此类定义很多,可以考虑使用代码生成的方法来减小人工维护的负担。
持久化值对象
值对象的持久化有很多种不同的做法,我们可以使用Repository概念来完成具体的持久化。基于值对象的上述特征, 现在更流行的方法是使用NoSQL数据存储来持久化值对象,但是更传统的基于ORM的持久化方法依然是有广泛的使用。
在基于ORM的方法中,如果值对象的属性中包含有聚合属性,当我们考虑将多个值序列化到一个列中的时候,就必须考虑存储系统的本身的限制。 比如上述的MySQL最大行宽的限制不仅会影响到单个属性列还对整个行记录的可用空间产生直接的约束。 另外一个值得主意的情况是当将多个对象写入一个数据库列的时候,就不能指望使用SQL查询的方法来实现高效检索。
服务
DDD中的服务用来描述实现领域特定的任务的无状态的操作的概念, 它和和一般的软件架构理论(比如SOA或者微服务)中所谓的服务或者组件这些粗粒度的软件隔离实体的含义截然不相同, 不能简单地将后者的概念拿来对比,否则会产生更多的混淆和误解。
DDD服务本身描述的还是一个和领域相关的概念而不是一个抽象的通用的概念。 当我们需要将一大块和实体或者值对象有些关系而又没有状态的职责剥离出来的时候,服务就派上用场了。通常来说我们出于这些目的来决定引入服务
- 业务逻辑操作比较复杂
- 需要将某种领域逻辑的组合从一种形式转换为另外一种
- 需要从多个领域对象的输入中通过某种运算产生出新的值对象
确保仅在必要的时候创建服务
一种常见的使用服务的误区是滥用了服务的概念导致本该由领域对象或者值对象表达的概念都泄露到了服务对象中。 这也是上面提到的服务需要保证是无状态的原因;如果需要保留状态,那么显然使用服务是一种误用。 同时需要谨记的是服务仍然是一种领域知识相关的设施,如果想表述和领域通用语言不相关的概念,那么可能需要用到应用服务。
是否需要用分离的接口来描述服务
是否应该采用一个隔离的接口来描述服务并没有一个清晰的界定,因为它的好处和不足同样明显。 采用分离的接口可以提高软件系统的可测试性,但是只有唯一实现的服务类再加上一个接口定义会显得比较怪异和画蛇添足。
领域事件
Evans将领域事件描述为领域内发生的一部分完整的事实,它可以用来描述某个时间绑定上下文中发生的某些事情的表述。 如何决定什么是领域事件而什么不是?最好的策略是倾听领域专家的声音,并从中发现这些事件的蛛丝马迹,比如领域专家的口中说出”当xxx发生的时候“或者”如果YYY发生“,这里的XXX和YYY就是清晰的领域事件。 这些时间的出现往往意味着由某些地方需要关注这些事件出现的后果并做出反应。
使用领域事件的概念往往默认我们需要采用最终一致性模型,这一隐含的假设我们应该始终铭记于心。 使用领域事件的方法可以达到很高的并发处理性能和较低的系统耦合;当然不利之处是我们需要考虑它带来的系统行为的额外复杂性,因为依赖从显示的调用变成了异步的发布/通知模型。
领域事件的建模
我们总是用某个发生的事件来表述领域事件,比如somethingHappened,这个概念描述的是一件事实已经发生过; 有时候我们还需要描述事件发生的上下文信息,比如发生的时间,出现次数等。 不论如何建模,我们还需要保证领域事件总是不可变的,就如已经发生的事情不可更改一样; 同时参照前面所述的值对象的语义,保证领域事件没有副作用也是一项隐含的要求。
领域事件标识符
大部分情况下,当领域事件仅仅被一个绑定上下文消化、处理的时候,没有必要给领域事件指定一个唯一的标识符; 然而当领域事件可能被从一个绑定上下文中通过底层的事件转发基础设施被发送到另外一个领域中去的时候, 我们就不得不为领域事件准备一个唯一的标识符,使得不同的事件可以在其被处理的范围内可以被唯一识别。 同样的当一个领域事件需要被持久化存储或者从存储介质中恢复到内存的时候,事件标识符也必不可少。
从领域模型中发布事件
当事件需要发送到某个远程的领域中去的时候,我们需要尽量把领域事件的细节与底层的事件分发中间件或者框架相解耦,避免将领域事件的细节泄露给基础设施中间件。
发布事件的时候,最终一致性模型带来的副作用和它本身的一致性保证机制值得详细考虑。 当通过一个中间件或者事件转发设施来分发事件的时候,领域逻辑对事件延迟的容忍性也需要根据领域业务要求进详细分析。 有时候我们甚至需要对历史发生的事件做一些后续处理和分析,这种情况下,我们可以采用事件存储的方式,设置一个完成事件序列化保存的特殊的事件监听者。
模块
一些现代的变成语言或者框架都自带了模块机制,因而模块的概念和这些语言框架中的模块的用法并没有特别本质的区别。 唯一需要留意的是DDD中的模块的命名和使用是以领域模型的内聚为依据的,它并不一定和代码的具体部署和运行方式强绑定。
聚合
聚合是一种用于将实体或者值对象仔细汇聚在一起并形成清晰的边界的领域设计概念。从描述来看聚合好像很简单直接,然而它也许是DDD中最没有被准确理解和实施的概念。
聚合的设计实现上很容易落入(需要尽力避免)如下的两个极端
- 为了组合的方便而将聚合的引用关系设计的非常复杂和庞大
- 或者将聚合的关系拆分地太独立以至于没有办法真正地维护领域逻辑中的不变式
在一致性边界内的不变式上建模聚合
为了发现领域模型中的聚合,我们需要仔细甄别领域边界内的业务规则的内在不变式。 这些不变式属于一些业务规则,并且这些业务规则必须总是一致的。这样的一致性由很多种形式, 一种是传统的原子性的一致性,他们是即使生效的,并且是原子不可变更的;这种也往往被成为事务一致性;还有一种新的一致性模型是最终一致性。 聚合不变式种考虑的是传统的事务一致性。
设计良好的聚合可以保证每次修改的时候其内在的领域逻辑的一致性总是在修改后被保留,也就是说它的不变式在修改前后总是可以被满足。 这也意味着,从用户界面来的修改每次仅仅能够在一个聚合上被执行,否则事务一致性就很难满足。
保持聚合设计尽量的小
因为我们需要保证聚合的事务一致性语义,考虑到性能和可扩展性,自然而然地我们想要将聚合的设计保持足够的小,因为庞大的事务处理会带来明显的性能瓶颈。 想象具有复杂的关联关系的聚合,当我们需要更新一个很小的属性的时候,为了维护一致性, 我们可能不得不将所有有关联的记录都从其它的地方加载的内存中完成处理。
小规模的聚合带来的一个额外的好处是产生冲突的概率大大地降低了,从而使得系统的可用性自然得以提高;因而不论任何情况,都要想办法降低聚合的体量保持它足够可能的小。
组合聚合对象的时候用标识符来引用之
一个很常见的想法是可以组合多个聚合,在这种场景下,我们需要抵制住如下的诱惑:将引用的聚合想象为是在同一个一致性保证的边界内。 其实这样的做法会得不偿失,因为如果那么做了,聚合的规模就不可能保持的很小,性能惩罚就会悄悄地潜入进来,危及系统扩展性和可用性。
更好的处理方法是不在引用外部聚合对象(指针或者引用),而是在使用的地方用对方的全局唯一标识符来替代。 显然地得益于对象标识符本身保证了对象自己并不会被自动加载入内存中;不管是静态的内存分配还是垃圾收集器的负担都得以大大减小。
在聚合边界之外采纳最终一致性模型
对于跨越多个聚合的业务运算,业务处理用例其实跨越了多个领域上下文,这种情况下我们需要考虑最终一致性模型保证而不是聚合本身默认的事务一致性模型。 在大部分业务场景下,领域专家对最终一致性模型的容忍程度往往要高于开发人员的想象。
某些领域场景可能有一些额外的挑战,因为是采用最终一致性模型还是采用事务一致性模型并不容易得到一个清晰的答案。 熟悉传统的事务模型的DDD实践者可能更倾向于采用强一致性的事务保证,而喜欢CQRS模型的开发者则更喜欢最终一致性模型。 问题的核心在于深入理解领域模型的不变式要求,因为DDD开发的首要思想就是始终考虑领域的实际需求而不是具体的软件实现技术设施。
例外规则
凡事都难免有例外,某些情况下我们处于特殊的需要会主动选择打破上述这些聚合规则,譬如
- 用户接口操作的遍历,比如在批量创建一系列聚合对象操作和创建一个聚合对象来操作所有处理没有很大区别的时候,我们可以选择打破上述的规则
- 缺乏最终一致性要求的基础设施,比如计时器、后台线程调度、消息传递设施;此时强行采用最终一致性会有额外的挑战,我们不得不妥协
- 全局事务或者一些遗留技术的影响,譬如我们不得不于其它依赖于两阶段提交的分布式事务系统打交道的时候,最终一致性处理的引入可能得不偿失
- 查询性能需要优化的时候,我们不得不缓存外部聚合对象的指针而不是每次在需要的时候加载进内存做查询,当然性能的影响需要仔细掂量权衡
避免使用依赖注入
使用依赖注入技术来自动传入一个聚合类所依赖的服务或者仓库对象应该被认为是一个糟糕的想法, 原因很简单:我们期望用对象标识符的方法来引用其它的聚合对象而不是用对象引用自身而依赖注入技术会自动将对象的引用传递进来。
其它一些DDD方法和术语
除了上面探讨的DDD方法和术语,还有如下一些比较容易理解的设施
- 工厂类,工厂方法、抽象工厂等,这些概念在传统的设计模式中已经得到了广泛的研究,DDD只是拿来使用。
- 仓库概念和传统的数据库中的DAO或者对象集合的技术很接近,可以用传统的Hibernate技术也可以用NoSQL的方法来实现仓库,Spring等开源库已经提供了很好的封装手段。
- 集成领域上下文的技术,可以是用基于传统的RPC的方式,也可以采用基于REST API的方法来完成,相关的软件架构技术已经探讨的比较充分了。
Leave a Comment
Your email address will not be published. Required fields are marked *