前言
2004年,Eric Evans 发表了《Domain-Driven Design –Tackling Complexity in the Heart of Software 》(领域驱动设计)这本书,简称Evans DDD,书里对领域驱动做了开创性的理论阐述。它为我们提供了设计软件的一个全新视角,同时也给开发者留下了一大难题:如何将领域驱动设计付诸实践?
Vaughn Vernon的《实现领域驱动设计》则分别从战略和战术层面详尽地讨论了如何实践DDD,其中包含了大量的最佳实践、设计准则和对一些问题的折中性讨论。战略部分向我们讲解了领域、核心域、支撑域、子域、通用域、限界上下文、上下文映射图和架构等内容,战术部分包括实体、值对象、领域服务、领域事件、工厂、聚合根、规格约束和资源库等内容。
所以如果想要实践DDD的话,建议按顺序两本书都看一下。
领域驱动涉及的知识和概念非常丰富,本文不可能全部涵盖,只能起到入门的作用。
领域模型探讨
- 领域模型设计:Data Modeling VS Object Modeling
a. Data Modeling:通过数据抽象系统关系,也就是面向数据库设计
b. Object Modeling:通过面向对象方式抽象系统关系,也就是面向对象设计
大部分架构师都是从data modeling开始设计软件系统,少部分人通过object modeling方式开始设计软件系统。这两种建模方式并不互相冲突,都很重要,但从哪个方向开始设计,对系统最终形态有很大的区别。以下以一个实实在在的需求为例,来分析两种设计方式对系统最终形态的影响。
1.1 需求
假设现在我们有一家汽车组装公司,可以按照用户的定制化要求(比如什么品牌的轮胎、哪家公司的发动机)给用户组装汽车,并支持零配件的更换和升级。该组装公司的零配件需要从下游供应商购买。
系统的物理视图:
当用户请求汽车组装公司时,该公司先调用下游供应商,然后为用户创建汽车或者更换零件,并将用户及汽车信息存储到数据库。对汽车组装公司而言,下游供应商都属于外部系统,暂不考虑。我们需要考虑的是汽车组装公司的系统架构。下面通过面向数据库设计和面向对象设计两种方式来对汽车组装公司进行设计,并对比两者的最终差异。首先是面向数据库设计。
1.2 面向数据库设计
数据库设计的根本,是一切开发的围绕着这本数据字典展开。
上图为针对该需求设计的数据库表。现假设用户要升级中控台,那么开发需要怎么做呢?很显然,先查询“客户-汽车关联表”,校验用户是否拥有这辆汽车,如果校验通过,则将将客户-汽车关联表 汽车信息表 中控台表三表连表查询,然后在逻辑层更新中控台,最后再将中控台持久化。所有校验、查询、修改、持久化都由逻辑层来完成。整个分层架构如下所示:
在service层通过我们非常喜欢的manager去manage大部分的逻辑,POJO(贫血对象)作为数据在manager手(上帝之手)里不停地变换和组合,service层在这里是一个巨大的加工工厂(几乎所有的业务逻辑都在这里,很重),围绕着数据库完成业务逻辑。
1.3 面向对象设计
在聊到DDD的时候,我们经常会做一个假设:假设你的机器内存无限大,永远不宕机,在这个前提假设下,我们是不需要持久化数据的,也就是我们可以不需要数据库,那么你将会怎么设计你的软件?这就是我们说的Persistence Ignorance:持久化无关设计。所以面向对象设计又可以说是面向内存设计。
在不需要持久化的这个前提下,再来看我们的需求:用户需要创建一辆汽车,那么我们可以在内存中直接以对象的形式保存这辆汽车;当用户需要升级中控台时,我们直接获取内存中汽车的中控台并升级即可。你可以简单粗暴地将两者的差异理解为如下对比图:
面向数据库设计 面向对象设计
即采用面向数据库设计时,代码的逻辑层操纵的是这辆汽车的各个零散的零件;面向对象设计时,逻辑层操纵的是这辆汽车的整体。怎么样才能算是整体呢?那就是封装。内存中的领域模型如下所示:
Talk is cheap, show you my code:
领域层代码(golang):
代码语言:javascript复制type CarShelf struct {}//代码略
type Tyre struct {}//代码略
type CenterControl struct {}//代码略
type User struct{}//代码略
type Car struct {
id int64 //汽车的唯一标志
carShelf CarShelf //车架
tyre []Tyre //轮胎
control CenterControl //中控
user User //用户
}
func (car *Car) ChangeControl(control CenterControl) {
car.control = control//示例代码较简单,封装的价值体现不出来,对于一些复杂逻辑,就能体现出封装的好处。
}
func (car *Car) UpdateControl(control CenterControl) {//成员方法
car.control.update(control)
}
逻辑层代码(把大象放进冰箱总共需要几步?逻辑层不再需要关心冰箱放不放得下的问题):
代码语言:javascript复制func changeController(ctrBrand string, carId uint64) {
//第1步:供应商根据中控台品牌名获取中控台
control:= ctrMerchant.buyControlByBrand(ctrBrand)
//第2步:carRepository根据carId从数据库加载出一个完整的汽车对象
car := carRepository.loadByCarId(carId)
//第3步:调用汽车对象提供的方法完成中控台的替换工作
car.UpdateControl(control)//将具体的修改逻辑封装到车内部
//第4步:将car持久化
carRepository.persist(car)
}
可以看到,当我们的领域对象不再是贫血模型之后,我们的逻辑层的代码将变得很轻,它只负责调度领域层的各种对象来完成业务操作,而具体的业务操作逻辑则封装到了领域对象中。
采用面向对象设计时,逻辑层对汽车的任何操作,都是通过“汽车”这个聚合根来间接操作的,比如逻辑层想要更换汽车的中控台,它将调用汽车整车提供的中控台操作方法,而无法直接接触到车的内部结构。聚合根成了汽车整车的唯一标识,外界无法直接操作汽车内部,汽车整车内部高度自治。由此可知,原本很多逻辑层的操作被封装到了汽车这个领域对象内部,我们的汽车不仅具有了成员属性,还具有了维护属性的各种行为能力。由此我们得到面向对象的分层架构图:
当然,这个分层架构图是在不需要持久化的前提下才成立的,现在放开这个假设,得到最新的架构图如下所示:
到这里,由上面这幅图,引出另外一个概念,叫CQRS(Command Query Responsibility Segregation),即读写分离。在DDD架构落地时,我们通常会结合CQRS来使用。关于具体的落地方案,后面介绍DDD架构演进时会再介绍。
小结:相信到这里大家对面向数据库设计和面向对象设计的区别有了一个大体的认知了,那面向对象相比面向过程到底有什么好处呢?一个最直观的好处就是,复杂系统的代码变得更容易理解和维护。为什么这么说呢?我们的世界本身就是由各种各样的对象以及对象之间的关联关系组成。这些对象不仅拥有各种属性,还有丰富的行为(方法)。人们理解对象属性及其行为是很自然的一件事情。而面向数据库设计强行将对象的变成了POJO(贫血对象,只有属性,没有行为),而将对象的行为赋予给各种manager,导致了过程化的编程,业务逻辑被散落到了过程代码中。通过领域化设计,领域模型能直观反应业务模型,理解了业务模型就能很容易地理解代码。反之,理解了代码(领域模型)也很容易理解业务。当然,对于DDD的其他优点以及缺点下面会进一步分析。
- 分层架构VS六边形架构
Evans在它的《领域驱动设计:软件核心复杂性应对之道》书中推荐采用分层架构去实现领域驱动设计:
其实这种分层架构我们早已驾轻就熟,MVC模式就是我们所熟知的一种分层架构,我们尽可能去设计每一层,使其保持高度内聚性,让它们只对下层进行依赖,体现了高内聚低耦合的思想。
分层架构的落地就简单明了了,用户界面层我们可以理解成web层的Controller,应用层和业务无关,它负责协调领域层进行工作,领域层是领域驱动设计的业务核心,包含领域模型和领域服务,领域层的重点放在如何表达领域模型上,无需考虑显示和存储问题,基础实施层是最底层,提供基础的接口和实现,领域层和应用服务层通过基础实施层提供的接口实现类如持久化、发送消息等功能。
DDD分层架构是一种可落地的架构,但是我们依然可以进行改进,Vernon在它的《实现领域驱动设计》一书中提到了采用依赖倒置原则改进的方案。
代码语言:javascript复制所谓的依赖倒置原则指的是:高层模块不应该依赖于低层模块,两者都应该依赖于抽象,抽象不应该依赖于细节,细节应该依赖于抽象。
从图中可以看到,基础实施层位于其他所有层的上方,接口定义在其它层,基础实施实现这些接口。依赖原则的定义在DDD设计中可以改述为:领域层等其他层不应该依赖于基础实施层,两者都应该依赖于抽象,具体落地的时候,这些抽象的接口定义放在了领域层等下方层中。这也就是意味着一个重要的落地指导原则: 所有依赖基础实施实现的抽象接口,都应该定义在领域层或应用层中。
采用依赖倒置原则改进DDD分层架构除了上面说的DIP的好处外,还有什么好处吗?其实这种分层结构更加地高内聚低耦合。每一层只依赖于抽象,因为具体的实现在基础实施层,无需关心。只要抽象不变,就无需改动那一层,实现如果需要改变,只需要修改基础实施层就可以了。
采用依赖倒置原则的代码落地中,资源库Repository的抽象接口定义就会放在领域层了。如下图所示,抽象接口Repository实际上定义在领域层,它定义了领域层的对象持久化的要求,但具体如何持久化则由不同的底层存储来实现。
《实现领域驱动设计》一书中提到了DDD架构更深层次的变化,Vernon放弃了分层架构,采用了对称性架构:六边形架构,作者认为这是一种具有持久生命力的架构。当你真正理解这种架构的时候,相信你也不得不佩服这种角度不同的设计。
六边形架构将整个系统分成了内部和外部,类比linux操作系统的话,领域模型对应linux的内核core(包括硬件),而应用层对应包裹内核的系统调用层。应用层是外界操作领域层对象的入口。而在领域层内部,所有的概念(字段名),所有的领域对象,将是含义明确的、统一的。
拙劣的设计各有不同,优秀的设计大同小异!!!
外界来的所有请求,需要由基础设施层的入口适配器将外部系统的概念统一转换成系统内部可以理解的概念,并通过应用层的入口对领域对象进行操作,而操作的结果如果需要影响外部系统(可以是数据库或者通过rpc调用),则需要在基础设施层通过出口适配器将内部系统的概念统一转化成外部系统的概念。
通过入口适配器和出口适配器,保证了系统内部概念的统一性和确定性,而不会受到外部系统的影响,当外部系统发生变化时,只需要调整对应的适配器,从而保证了系统内部的稳定性。
DDD CQRS架构落地
如图所示,这是DDD实践中通常的系统架构。我们前面提到的领域模型实际上都是针对写操作,纯读的操作则没有必要走领域模型。
- 领域、子域、限界上下文及上下文映射图
到此为止,我们讨论的都只是在汽车组装公司这一个系统内部进行领域模型的设计和抽象,但实际系统中,系统常常不是孤立的,而是存在交互。这就需要从战略层面来进行领域分析和设计。
如上图所示,一个抽象的业务领域,可以包含一个核心域、多个支撑子域和通用子域。比如我们的数据库云交易这个业务就是一个领域,其核心域就是集群(实例)的生命周期,而计费、用户等则是支撑子域。
问题空间对应领域和子域;解决方案空间对应限界上下文。啥意思呢?整个领域是我们需要解决的业务问题,所以叫问题空间;我们通过将整个领域划分为多个子问题,对应子域;而我们通过对整个领域划分很多限界上下文来解决这个问题,所以对应解决方案空间。
通常,我们希望将一个子域一对一地对应到限界上下文。这种做法显式地将领域模型分离到不同的业务板块中,并将问题空间和解决方案空间融合在一起。在实践中,这种做法并不总是可能的,但通过新的努力,我们是可以做到这一点的。让我们考虑一个遗留系统,它有可能是一个大泥球,其中子域和限界上下文存在相交的地方。在一个大型企业中,通过对问题空间的评估,我们可以减少错误,进而降低成本。我们可以在概念上使用两个或者多个子域来分解限界上下文,或者将多个限界上下文包含在同一个子域中。
3.1 理解限界上下文
代码语言:javascript复制限界上下文是一个显式边界,领域模型存在于边界之内,在边界内,通用语言中的所有术语和词组都有特定的含义,而模型需要准确地反应通用语言。
个人理解:限界上下文就是为统一通用语言界定一个范围而存在的(封装)。在这个范围内,领域模型中的所有概念都是统一的,而不同限界上下文之间,同一个概念可以是不同的。通过限界上下文,避免了团队开发人员之间对概念的理解偏差。不同限界上下文之间的交互(不管是通过rpc调用还是通过消息机制,都需要通过防腐层(ACL)来屏蔽不同限界上下文之间的影响。
为了进一步解释这个问题,我们在前面的汽车组装公司的例子上再加一个限界上下文:客户驾驶汽车上下文,即客户通过汽车组装公司购买到汽车之后,驾驶该汽车。在汽车组装上下文中,汽车这个对象拥有的能力是管理它的发动机、轮胎、中控台等功能;而在客户驾驶上下文中,汽车这个对象拥有的能力则是启动、熄火、踩刹车等功能。由此可见,都是汽车,在不同上下文中代表的含义是不一样的。通过限定上下文,能保证同一个上下文中的对象的唯一性。
保证限界上下文中所有术语、概念、对象甚至字段明确性(无歧义)对系统的长期可维护非常关键。它降低了研发组成员的沟通成本,避免开发人员因为对概念的理解偏差而导致的问题。
- DDD的优势和劣势
4.1 优势
1)降低系统认知和维护成本:面向对象的领域模型很好地反应了业务模型;充分运用了面向对象的封装性,使得业务逻辑内聚,便于理解;严格的分层,层之间不直接依赖,而是依赖抽象,实现了解耦;
2)便于自动化测试:主要的业务逻辑都在领域层,自动化测试可以主要针对领域层进行,而不再是面向数据库测试;
3)减少了系统出bug的概率:通过将领域知识内聚,降低了维护代码时因为考虑不全而导致的系统bug;
4)便于分模块、分层开发,良好的封装性让影响范围变得可控;
5)代码的可复用性高,可持续维护性好。
4.2 劣势
- 性能问题:每次操作需要加载整个对象(有些操作实际上只需用到部分成员,加载整个对象必然存在性能问题,解决办法是懒加载机制,用到才加载);
- 好的模型设计需要花较多时间,而且对开发成员有较高的认知要求。
推荐书目:
《领域驱动设计 软件核心复杂性应对之道》
《实现领域驱动设计》
《领域驱动设计模式、原理与实践》
《分析模式——可复用的对象模型》
参考:
- 领域驱动设计,盒马技术团队这么做
- 领域驱动架构篇 菱形对称架构
- 领域驱动设计DDD和CQRS落地