本文作者是组内同事 杜宁,目前负责美团外卖活动管理模块业务。
什么是领域驱动模型?
2004年Eric Evans 发表《领域驱动设计——软件核心复杂性应对之道》(Domain-Driven Design –Tackling Complexity in the Heart of Software),简称Evans DDD,领域驱动设计思想进入软件开发者的视野。领域驱动设计分为两个阶段:
1、以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型;
2、由领域模型驱动软件设计,用代码来实现该领域模型;
简单地说,软件开发不是一蹴而就的事情,我们不可能在不了解产品(或行业领域)的前提下进行软件开发,在开发前,通常需要进行大量的业务知识梳理,而后到达软件设计的层面,最后才是开发。而在业务知识梳理的过程中,我们必然会形成某个领域知识,根据领域知识来一步步驱动软件设计,就是领域驱动设计的基本概念。而领域驱动设计的核心就在于建立正确的领域驱动模型。
传统软件开发与贫血模型
传统的开发思想
传统开发四层架构
在传统模型中,对象是数据的载体,只有简单的getter/setter方法,没有行为。以数据为中心,以数据库ER设计作驱动。分层架构在这种开发模式下,可以理解为是对数据移动、处理和实现的过程。
以商家活动为例,首先设计数据库表配置
设计WmActPoi对象,只有简单的get和set属性方法
public class WmActPoi {
private Long id;
private String wmPoiId;
private Integer startTime;
private Integer endTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Integer getWmPoiId() {
return wmPoiId;
}
public WmActPoiDB setWmPoiId(Integer wmPoiId) {
this.wmPoiId = wmPoiId;
}
......
}
Service层代码实现
class WmActPoiService {
saveWmActPoi(); //保存活动
checkWmActPoi(); //活动校验
....
}
class WmActPoiQueryService {
queryWmActPoi(); //查询活动
queryWmActPoiByWmPoiId(); //根据门店查询活动
....
}
可以看到,业务逻辑都是写在Service中的,WmActPoi充其量只是个数据载体,没有任何行为,是一种贫血模型。简单的业务系统采用这种贫血模型和过程化设计是没有问题的,但在业务逻辑复杂了,业务逻辑、状态会散落到在大量方法中,原本的代码意图会渐渐不明确,我们将这种情况称为由贫血症引起的失忆症。
传统架构的特点:
a. 以数据库为中心
b. 贫血模型
c. 业务逻辑散落在大量的方法中
d. 当系统越来越复杂时,开发时间指数增长,维护成本很高
DDD设计思想
采用DDD的设计思想,业务逻辑不再集中在几个大型的类上,而是由大量相对小的领域对象(类)组成,这些类具备自己的状态和行为,每个类是相对完整的独立体,并与现实领域的业务对象映射。领域模型就是由这样许多的细粒度的类组成。
建立领域知识(Build Domain Model)
说了这么多领域模型的概念,到底什么是领域模型呢?作为一个软件开发者,我们很难在对一个领域不了解的情况下着手开发,所以我们首先需要和领域专家沟通,建立领域只是。以飞机航行为例子:
现要为航空公司开发一款能够为飞机提供导航,保证无路线冲突监控软件。那我们应该从哪里开始下手呢?根据DDD的思路,我们第一步是建立领域知识
:作为平时管理和维护机场飞行秩序的工作人员来说,他们自然就是这个领域的专家,我们第一个目标就是与他们沟通,也许我们并不能从中获取所有想要的知识,但至少可以筛选出主要的内容和元素。你可能会听到诸如起飞,着陆,飞行冲突,延误等领域名词,让们从一个简单的例子开始:
- 起点->飞机->终点
这个模型很直接,但有点过于简单,因为我们无法看出飞机在空中做了什么,也无法得知飞机怎么从起点到的终点,那么如此似乎会好些:
-
飞机->路线->起点/终点
既然点构成线,那何不:
-
飞机->路线->points(含起点,终点)
这个过程,是我们不断建立领域知识的过程,其中的重点就是寻找领域专家频繁沟通,从中提炼必要领域元素。
通用语言(Ubiquitous Language)
上面的例子的确看起来简单,但过程并非容易:我们(开发人员)和领域专家在沟通的过程中是存在天然屏障的:我们满脑子都是类,方法,设计模式,算法,继承,封装,多态,如何面向对象等等;这些领域专家是不懂的,他们只知道飞机故障,经纬度,航班路线等专业术语。
所以,在建立领域知识的时候,我们(开发人员和领域专家)必须要交换知识,知识的范围范围涉及领域模型的各个元素,如果一方对模型的描述令对方感到困惑,那么应该立刻换一种描述方式,直到双方都能够接受并且理解为止。在这一过程中,就需要建立一种通用语言,作为开发人员和领域专家的沟通桥梁。
可如何形成这种通用语言呢?其实答案并不唯一,确切的说也没有什么标准答案。
(a) UML
利用UML可以清晰的表现类,并且展示它们之间的关系。但是一旦聚合关系复杂,UML叶子节点将会变的十分庞大,可能就没有那么直观易懂了。最重要的是,它无法精确的描述类的行为。为了弥补这种缺陷,可以为具体的行为部分补充必要说明(可以是标签或者文档),但这往往又很耗时,而且更新维护起来十分不便。
(b) 文档/绘图
文档耗时很长,可能不久就要变化,为模型从一开 始到它达到比较稳定的状态会发生很多次变化, 可能在完成之前它们就已经作废了。对于复杂系统,绘图容易混乱。
(c) 伪代码
极限编程推荐这么做,但是使用难度大
领域驱动设计的分层架构和构成要素
一个通用领域驱动设计的架构性解决方案包含4 个概念层:
层结构的划分是很有必要的,只有清晰的结构,那么最终的领域设计才宜用,比如用户要预定航班,向Application Layer的service发起请求,而后Domain Layler从Infrastructure Layer获取领域对象,校验通过后会更新用户状态,最后再次通过Infratructure Layer持久化到数据库中。
领域驱动模型的一些要素
实体(Entity) & 值对象(Value Object)
实体
与面向对象中的概念类似,在这里再次提出是因为它是领域模型的基本元素。在领域模型中,实体应该具有唯一的标识符,从设计的一开始就应该考虑实体,决定是否建立一个实体也是十分重要的。
值对象
和我们说的编程中数值类型的变量是不同的,它仅仅是没有唯一标识符的实体,比如有两个收获地址的信息完全一样,那它就是值对象,并不是实体。值对象在领域模型中是可以被共享的,他们应该是“不可变的”(只读的),当有其他地方需要用到值对象时,可以将它的副本作为参数传递。
服务(Services)
当我们在分析某一领域时,一直在尝试如何将信息转化为领域模型,但并非所有的点我们都能用Model
来涵盖。对象应当有属性,状态和行为,但有时领域中有一些行为是无法映射到具体的对象中的,我们也不能强行将其放入在某一个模型对象中,而将其单独作为一个方法又没有地方,此时就需要服务
.
服务是无状态的,对象是有状态的。所谓状态,就是对象的基本属性:高矮胖瘦,年轻漂亮。服务本身也是对象,但它却没有属性(只有行为),因此说是无状态的。
服务存在的目的就是为领域提供简单的方法。为了提供大量便捷的方法,自然要关联许多领域模型,所以说,行为(Action)天生就应该存在于服务中。
服务具有以下特点:
- a)服务中体现的行为一定是不属于任何实体和值对象的,但它属于领域模型的范围内
- b)服务的行为一定涉及其他多个对象
- c)服务的操作是无状态的
模块(Moudles)
对于一个复杂的应用来说,领域模型将会变的越来越大,以至于很难去描述和理解,更别提模型之间的关系了。模块的出现,就是为了组织统一的模型概念来达到减少复杂性的目的。而另一个原因则是模块可以提高代码质量和可维护性,比如我们常说的高内聚,低耦合
就是要提倡将相关的类内聚在一起实现模块化。
模块应当有对外的统一接口供其他模块调用,比如有三个对象在模块a中,那么模块b不应该直接操作这三个对象,而是操作暴露的接口。模块的命名也很有讲究,最好能够深层次反映领域模型。
聚合(Aggregates)
聚合表示一组领域对象(包括实体和值对象),用来表述一个完整的领域概念。而每个聚合都有一个根实体,这个根实体又叫做聚合根。举个简单的例子,一个电脑包含硬盘、CPU、内存条等,这一个组合就是一个聚合,而电脑就是这个组合的聚合根。在聚合中,根是唯一允许外部对象保持对它的引用的元素,而边界内部的对象之间则可以互相引用。除根以外的其他Entity都有本地表示,但这些标识只有在聚合内部才需要加以区别,因为外部对象除了根Entity之外看不到其他对象。
工厂(Factory)
一个对象的创建可能是它自身的主要操作,但是复杂的组装操作不应该成为被创建对象的职责。组合这样的职责会产生笨拙的设计, 也很难让人理解。因此,有必要引入一个新的概念,这个概念可以帮助封装复杂的对象创建过程,它就是工厂(Factory)。工厂用来封装对象创建所必需的知识,它们对创建聚合特别有用。当聚合的根建立时,所有聚合包含的对象将随之建立。
资源库(Repositories)
资源库的是封装所有获取对象引用所需的逻辑。领域对象不需处理基础设施,以得到领域中对其他对象的所需的引用。只需从资源库中获取它们,于是模型重获它应有的清晰和焦点。
资源库会保存对某些对象的引用。当一个对象被创建出来时,它可以被保存到资源库中,然后以后使用时可从资源库中检索到。如果客户程序从资源库中请求一个对象,而资源库中并没有它,就会从存储介质中获取它。换种说法是,资源库作为一个全局的可访问对象的存储点而存在。
Repository的接口应当采用领域通用语言。作为客户端,不应当知道数据库实现的细节。
Repository和DAO的作用类似,二者的主要区别:
DAO是比Repository更低的一层,包含了如何从数据库中提取数据的代码。
Repository以“领域”为中心,所描述的是“领域语言”。Repository把ORM框架与领域模型隔离,对外隐藏封装了数据访问机制。
public
interface AccountRepository {
Account findAccount(String accountId);
void addAccount(Accountaccount);
}
工厂和资源库之间存在一定的关系。它们都是模型驱动设计中的模式,它们都能帮助我们关联领域对象的生命周期。然而工厂关注的是对象的创建,而资源库关心的是已经存在的对象。资源库可能会 在本地缓存对象,但更常见的情况是需要从一个持久化存储中检索 它们。对象可以用构造函数创建,也可以被传递给一个工厂来构 建。从这个原因上讲,资源库也可以被看作一个工厂,因为它创建对象。不过它不是从无到有创建新的对象,而是对已有对象的重建。我们将不把资源库视为一个工厂。工厂创建新的对象,而资源库应该是用来发现已经创建过的对象。当一个新对象被添加到资源库时,它应该是先由工厂创建过的,然后它应该被传递给资源库以便将来保存它,见下面的例子:
持续集成与模型一致性
规约(Factory)
规约是一种布尔断言。
规约是业务规则的 部分 理论上规约类中的方法只有个:isSatisfiedBy(Object obj)。
规约用来测试对象是否满足某种条件,用来进行对象查询,也可以作为某个对象的创建条件。
单一规约规则。多个规约通过组合表现复杂的规约。
限界上下文(Bounded Context)
明确的定义模型所应用的上下文。根据团队的组织、软件系统的功能和物理表现(代码数据库)来设置模型的边界。在这些边界中严格保持模型的一致性,而不要受到边界之外问题的混淆。每个团队负责自己的模型,并为其他模型提供服务。
上下文映射(Context Map)
一个企业应用有多个模型,每个模型有自己的界定的上下文。建议用上下文作为团队组织的基础。在同一个团队里的人们能更容易地 沟通,也能很好地将模型集成和实现。但是每个团队都工作于自己 的模型,所以最好让每个人都能了解所有的模型。上下文映射(Context Map)是指抽象出不同界定上下文和它们之间关系的文 档,它可以是像下面所说的一个试图(Diagram),也可以是其他任 何写就的文档。详细的层次各有不同。它的重要之处是让每个在项 目中工作的人都能够得到并理解它。
共享内核(Shared Kernel)
总结
领域驱动设计的核心是领域模型,这一方法论可以通俗的理解为先找到业务中的领域模型,以领域模型为中心驱动项目的开发。而领域模型的设计精髓在于面向对象分析,在于对事物的抽象能力,一个领域驱动架构师必然是一个面向对象分析的大师。
在面向对象编程中讲究封装,讲究设计低耦合,高内聚的类。而对于一个软件工程来讲,仅仅只靠类的设计是不够的,我们需要把紧密联系在一起的业务设计为一个领域模型,让领域模型内部隐藏一些细节,这样一来领域模型和领域模型之间的关系就会变得简单。这一思想有效的降低了复杂的业务之间千丝万缕的耦合关系。
DDD开发案例
本文作者是组内同事 杜宁,目前负责美团外卖活动管理模块业务。
本文首发在 高广超的简书博客 转载请注明!