领域驱动设计到底难在哪?
有位朋友最近在为企业做领域驱动设计(Domain Driven Design)内训时,遇到一位资深学员向他抱怨该技术 “每次一听就会,一用就不会”!回想到自己也曾在不同场合下听到人们对领域驱动设计的各种争辩:掌握它的人觉得这没什么复杂的,不过是一种很自然的设计方法选择;而另外的人却在抱怨这一技术过于晦涩、在现实中根本无处着手。到底是什么造就了领域驱动设计在人们的心中有如此大的gap?本文尝试回答一下这个问题!
对某一事物的认知差异,往往来自于人们所处的环境差异和经验差异。所以,让我们从领域驱动设计的由来说起,从历史中追溯人们的环境和经验差异源自何处。
在瀑布过程大行其道的时候,软件设计和软件开发是两个独立的阶段。软件设计阶段使用某种设计范式对领域(要解决的问题域)进行抽象,给出设计模型。随后的软件开发阶段则用对应的编程语言和技术框架实现该设计模型。
在上述过程中,人们普遍认为分析设计阶段是核心,分析设计得到的模型往往决定了软件能否正确地解决领域问题,因此这一阶段需要业务专家和资深的软件工程师的协作。分析设计阶段的软件工程师往往被委以“架构师”之类高大上的名称。
分析设计阶段采用什么样的软件设计范式建模领域,反映了人们透过软件看待现实世界的思维模式。
面向过程范式认为一切皆过程
,在这里现实世界的问题被分解为一个个的过程,最终通过串联它们来解决问题。然而随着计算机软件开始大规模地解决复杂的商业问题,这种设计范式被证明缺乏足够的模块化和抽象手段。过程和被操作数据的分离导致软件容易走向违背高内聚、低耦合
的方向,带来维护成本剧增。虽然如此,对于一些简单的符合事务脚本模型
的程序用这种范式来描述仍是最自然的,这也就是为什么即使像Ruby这种纯面向对象语言仍旧允许在顶层写零散的过程式代码。
面向对象范式通过对象
来建模世界。对象有自己的属性和接口,很容易和现实世界中的事物相映射。对象通过封装紧密依赖的属性和行为,只允许通过公开接口进行交互的特性,为软件设计提供了一种逻辑层面的模块化手段。对象通过组合可以表示更复杂的概念,对象通过接口的泛化可以表示更抽象的概念。人们用面向对象技术大规模地解决复杂商业问题,积累了大量的建模经验,并最终发展出了“统一建模语言(UML)”。
由于UML诞生于瀑布开发模式仍占主流的时代,所以在标准的UML过程中,软件设计被明确分为面向对象分析(OOA),面向对象设计(OOD)和面向对象编码(OOP)阶段。实际操作中OOD的工作往往被OOA和OOP各自承担了一部分。OOA针对要解决的问题在领域中寻找并抽象合适的概念,定义它们之间的关系。OOP则负责在编码阶段补充被OOA忽略的和领域无关但是和软件开发效率息息相关的因素(软硬件平台、数据存储约束、并发、编程框架...),进一步按照软件工程的要求(各种设计原则和模式)重塑了OOA给出的业务模型。
由于在这一过程中OOA和OOP是两个分裂的阶段,所以必然出现两个阶段的相互钳制。人们曾经有一度追求直接根据架构师给出的UML设计图自动生成代码。最后实践证明,这一做法只有在安全性盖过成本约束并且需求相对稳定的领域可以工作。而在日益复杂的商业软件开发中,成本因素更多被消耗在软件代码的维护和演进上,这时能够指导人们实际编码工作的是软件设计原则(例如SOLID)和设计模式(例如GOF),这就是面向对象的学院派和工程派之争。在工程派眼中面向对象方法里类
比对象
更重要,类
是代码层面提供模块化
以及接口抽象
的重要手段,其次才是运行态的对象
所表述的领域概念。这也就是为何遵循了良好工程化原则的面向对象代码中容易存在为了消除重复而产生的大量碎片化的类,以及为了代码灵活性而创造的和领域概念相去甚远的抽象接口。
最终上述的软件开发过程中一般同时存在两个模型,一个是隐藏在各种设计文档和UML图中的面向领域的设计模型,另一个是隐藏于软件源码中的面向实现的设计模型。这两个模型的割裂极大地阻碍了软件产品的交付效率。为了解决这一问题,敏捷软件开发方法倡议从改善软件开发端到端的沟通方式做起。所以在敏捷开发中,团队更倾向于被定义为一个个独立的特性团队。在特性团队内部,业务专家、架构师、开发人员和测试人员要能够无障碍的沟通,并集体为特性的端到端交付负责。
在敏捷软件开发中,被普遍接受的一种观点是“软件源代码是唯一真正的设计产物”。一些偏重技术实践的敏捷软件开发方法(如XP)极大的推动了这方面的实践:人们优先将精力投入到代码上,持续保持代码的清晰和灵活性,通过重构代码来演进设计模型,并通过自动化测试来确保设计演进的正确性和安全性。在这种开发模式下,那些能够做到业务、设计、编码、测试技能互相融合的技术人员会更受到青睐。传统的架构师开始遭受到质疑,很多组织要求内部业务专家和架构师都要具备编码的能力。
敏捷的这一做法在互联网应用井喷的时代中取得了成功,但在一些传统的领域知识复杂的组织内却一直饱受质疑,敏捷也曾因此被讹传是一种完全不要设计和文档的开发方式!这种情况一直延续到了Eric Evans提出《领域驱动设计-软件核心复杂性应对之道》(后面简称DDD)。
在DDD中,Eric Evans认为软件开发中最核心的资产应该是领域模型
,软件开发中的所有参与者都应该围绕着一个统一一致的领域模型而工作。为了得到领域模型,DDD拥抱了敏捷软件开发方法。首先DDD要求软件开发中的各种角色要紧密地工作在一起,无障碍地沟通。其次DDD认为领域模型需要借助演进式设计得到,在这过程中需要重构和自动化测试等技术实践的协助。但同时DDD也演进了一些敏捷中的观念,领域模型不是设计文档,但也不是代码!如果我们承认软件开发的本质是一个学习过程,那么所有参与者对软件如何解决领域问题在脑海中所构建的一致画面才是关键!在DDD中,这体现在通用语言(Ubiquitous Language)
中,业务专家和开发人员通过领域模型走查每个用例的时候所采用的术语以及脑海中对应的认识应该是高度一致的。
从上可见DDD首先应该是一种软件开发过程,它拥抱了敏捷开发方法,采用演进式设计和各种先进的软件技术实践,追求一个统一一致的领域模型(而不是曾经分裂的分析模型和实现模型),目标是做到模型既设计、代码与设计保持一致!
同时,DDD发展了敏捷,它显示地把领域和设计放到了软件开发的核心,业务人员和软件开发人员被得到同样的重视,他们合作来构建领域模型。这让敏捷开发方法真正的在领域知识复杂的行业内得以有效应用。
为了做到在代码中凸显领域模型,DDD提出了分层架构。首先代码中需要把用户界面、调度框架、基础设施等与领域无关的实现元素分离到不同的层次中去,让领域层中的代码可以和领域模型保持高度一致!然后DDD从战略和战术两个层面给出了可以得到领域模型的一些最佳实践。
由于通用语言
的重要性,所以需要让每个概念在各自上下文中是清晰无歧义的,于是DDD在战略上提出了划分Bounded Context。从实践的角度看,Kent Beck很早在《实现模式》中说过如果一个类中的属性被它不同接口访问的内聚度不同或者访问频率不同,就应该将这些属性和接口拆分出来形成一个新类。这些新类往往和原有的类表示一个概念的不同方面,例如OrderedBook
、DeliveredBook
。当如此需要依赖前缀区分的概念逐渐变多则代表着一种味道,提醒着我们需要考虑将它们拆分到不同的BC中去。最终这些概念在每个BC下的含义又变得唯一和一致,也就不再需要前缀的修饰。不同BC间通过Context Mapping
集成在一起工作,每个BC都会有一个领域模型。拆分BC的同时也分离了关注点,降低了每个BC下领域模型的复杂度。
对于如何获得每个BC的领域模型,DDD并没有给出具体的方法。DDD在战术层面只是对领域模型中应该有的元素进行了分类:Entity
、Value Object
、Aggregate
、Service
、Factory
、Repository
,并给出了每类元素在领域模型中的职责和特征。上述分类基本上是站在面向对象范式的基础上给出的,这些词汇对没有面向对象基础的人会显得晦涩!
DDD虽然采用面向对象设计范式,但并不意味所有场景下只有面向对象最适合来构建领域模型。由于领域模型是从领域问题出发人为构建的一种面向领域的指示性语义,选择某种基本设计范式只是选择了一种构建基础而已。理论上选择使用面向过程
、面向对象
还是函数式
做为构建基础都是图灵完备的,但在工程上需要考量应用哪种范式和要构建的领域语义之间的gap最小、成本最低。另外现代编程语言基本都支持多范式编程,提供程序员在局部使用多种范式的自由。虽然如此,主流的编程语言仍旧将面向对象作为主范式,这不仅是因为面向对象的适应场景更广,人们在面向对象建模上积累了大量的经验,更是因为面向对象提供了低成本的模块化手段和抽象能力,可以让程序员从一个不错的起点开始工作。
但遗憾的是DDD并没有教人们如何在某个具体领域找到合适的领域模型。即使对熟悉面向对象的程序员来说,要在领域中找出合理的领域模型也不是容易的,这中间的gap往往需要实践者在DDD之外去补齐!
很明显的是领域模型中的概念应该来自领域,模型要尽可能反映领域本质!从这个角度来说领域模型更应该靠近分析模型!所以传统的领域分析技术(例如各种OOA技术和企业架构模式)对领域建模都是有益的。区别是我们要确保这个模型是被代码清晰表达的,和业务专家及开发人员脑海中的理解是一致的,并且是需要被一直演进着的!
由于领域模型的最终目的是解决领域问题,所以任何脱离use case的领域建模都是无源之水!传统的分析技术在这方面已经积累了很多经验,例如借助四色建模可以让我们针对要解决的问题识别出完备合理的概念和关系。另一方面模型还需要兼顾软件复杂度和性能等其他制约因素,例如单纯地问模型中Customer
和Order
是何种引用关系是没有意义的,一方面我们根据要解决的问题来决定谁引用谁(单向引用)或者双向引用,另一方面我们会根据实际的软件复杂度或者性能来决定是引用地址还是引用ID,所以即使在相同的领域下解决的问题不同,领域模型都是不同的。而业务专家和开发人员需要做的则是紧密配合,不断从use case或者test case出发借助各种建模技术建立模型,根据各种约束来调整模型,同时重构代码保持领域层代码和领域模型的一致。
正如前面所说学习各种企业架构模式以及分析模式是做好领域建模的必要条件,但遗憾的是不同领域在这方面的学习曲线陡峭程度差异很大。我们能轻易从各种书或者教程中学习到的案例,往往是研究得比较成熟的领域,例如电子商务、人力资源管理等。类似的领域极端情况下去观察没有软件之前人是怎么做的和记录的,就能把涉及到的领域概念关系挖掘的差不多了。由于这类系统中交互对象之间边界天然且清晰,玩各种建模技术(例如Event Storming
)都会相对容易很多。而另一类系统,例如“电信系统”、“能源系统”等,复杂度全在系统内部。这类系统大多比较庞大,且有复杂的领域知识,涉及到复杂的事务、协议和算法。出于性能原因,往往分布式部署在各种差异化的软硬件平台上。这类系统中的各种设计模型和概念都是经过多年沉淀后得到的结果,且相对封闭,没有经验继承的分析建模很难设计得合理。虽然按照DDD提倡的做法确实可以让这件事做得更科学和高效,但在本质上并没有让这件事变得简单!
自Eric Evans提出DDD之后,这些年该技术又得到很多新的发展。人们用DCI(Data Context Interactive)架构对DDD进行补充,试图解决领域对象中行为边界和数据边界不一致的问题(即service
带来的贫血与充血之争)。人们为DDD补充了Domain Event
的建模元素,发展出了CQRS架构。人们提出了六边形架构更进一步解耦了传统的DDD分层架构。Anyway,这些最终都没抵上微服务架构的出现对DDD带来的推动作用。微服务架构从一出来就没有很好的理论支撑如何合理的划分服务边界,人们常常为服务要划分多大而争吵不休。而DDD被发现恰好可以弥补微服务的营养不良:服务最大不要大过一个BC,否则服务内会存在有歧义的领域概念;服务最小不要小过一个聚合,否则会引入分布式事务的复杂度;服务间最好通过Domain Event
来进行交互,这样可以让服务保持松耦合。微服务和DDD的结合,让微服务架构看起来似乎更加稳健了!但其实微服务需要的不只是DDD,微服务虽然让某些事变得简单了,但是构建好微服务对软件设计的优秀技术实践和基础设施的要求都变高了。
对DDD的追溯就到这里,现在我们分析一下人们对DDD的认知gap会发生在哪些方面!
-
如果你还工作在瀑布软件开发模式下,很遗憾,即使你在做领域建模,你的团队也很难工作在一个统一一致的模型下。要记住DDD首先要求我们改变原有的软件开发过程。
-
如果你的团队没有领域专家,很遗憾,你在挖掘领域本质的过程中会走很多弯路,你需要找到领域专家的协助或者把自己变成领域专家。
-
如果你不熟悉面向对象软件设计,很遗憾,市面上大多DDD的教程与你无关,人们在面向对象建模上积累的大量经验也很难直接为你所用。
-
如果你不熟悉面向对象分析技术,很遗憾,你的建模过程可能不是高效的,你需要通过学习和实践来弥补这中间的能力gap。
-
如果你的团队编码能力比较差,或者你的团队不具备重构的能力和相应的基础设施,很遗憾,你的模型很难落地!DDD最重要的是要保持代码和领域模型的一致,并且是同时演进着的!
-
如果你工作在相对封闭且有复杂领域知识的领域,那么你需要找到或者培养精通DDD的工程师,并愿意长期耕耘在该领域!
-
如果你还没有读过《领域驱动设计》这本书,那么对不起,此文到此为止,你应该先去好好读一下这本书!