Skip to content

5.1 认识领域驱动设计

领域驱动设计(DDD)[23] 是 Eric Evans 提出的一种软件设计方法和思想,主要解决业务系统的设计和建模。DDD 有大量难以理解的概念,尤其是翻译的原因,某些词汇非常生涩,例如:模型、限界上下文、聚合、实体、值对象等。

实际上 DDD 的概念和逻辑本身并不复杂,很多概念和名词是为了解决一些特定的问题才引入的,并和面向对象思想兼容,可以说 DDD 也是面向对象思想中的一个子集。如果遵从奥卡姆剃刀的原则,"如无必要,勿增实体",我们先把 DDD 这些概念丢开,从一个案例出发,在必要的时候再将这些概念引入。

从纸和笔思考 IT 系统的工作逻辑

让我真正对计算机软件和建模有了更深入的认识是在一家餐厅吃饭的时候。数年以前,我还在一家创业公司负责餐饮软件的服务器端的开发工作,因为工作的原因,外出就餐时常都会对餐厅的点餐系统仔细观察,以便于改进我们自己产品的设计。

一次偶然的情况,我们就餐的餐厅停电了,所幸是在白天,对我们的就餐并没有什么影响。我突然很好奇这家店,在收银系统无法工作的情况下怎么让业务继续运转,因此我饶有趣味的等待服务员来接受我们的点单。

故事的发展并没有超出预期,服务员拿了纸和笔,顺利的完成了点餐,并将复写纸复写的底单麻溜的撕下来交给了后厨。

我这时候才回过神来,软件工程师并没有创造新的东西,只不过是数字世界的砖瓦工,计算机系统中合乎逻辑的过程,停电后人肉使用纸和笔一样合乎逻辑。

合乎现实世界的逻辑和和规则,使用鼠标和键盘代替纸和笔,就是软件设计的基本逻辑。如果我们只是关注于对数据库的增、删、改、查(CRUD),实际上没有对业务进行正确地识别,这是导致代码组织混乱的根本原因。

会计、餐饮、购物、人员管理、仓储,这些都是各个领域实实在在发生的事情,分析业务逻辑,从中找出固定的模式,抽象成计算机系统中对象并存储。这就是 DDD 和面向对象思想中软件开发的一般过程。

你可能会想,我们平时不就是这样做的吗?

现实是,我们往往马上关注到数据库的设计上,想当然的设计出一些数据库表,然后着手于界面、网络请求、如何操作数据库上,业务逻辑被封装到一个叫做 Service 对象上或散落在其他地方,然后基于对数据的操作来完成对业务的模拟。

面向数据库的编程方法

一般来说这种方法也没有大的问题,甚至工作的很好,Martin Fowler 将这种方法称作为事务脚本(Transaction Script)。还有其他的设计模式,将用户界面、业务逻辑、数据存储作为一个"模块",可以实现用户拖拽就可以实现简单的编程,.net、VF曾经提供过这种设计模式,这种设计模式叫做 SMART UI。

这种模式有一些好处。

  • 非常直观,开发人员学习完编程基础知识和数据库 CRUD 操作之后就可以开发。 非常直观,开发人员学习完编程基础知识和数据库 CRUD 操作之后就可以开发。

  • 效率高,能短时间完成应用开发。 效率高,能短时间完成应用开发。

  • 模块之间非常独立。 模块之间非常独立。

麻烦在于,当业务复杂后,这种模式会带来一些问题。

虽然最终都是对数据库的修改,但是中间存在大量的业务逻辑,并没有得到良好的封装。客人退菜,并不是将订单中的菜品移除这么简单。需要将订单的总额重新计算,以及需要通知后厨尝试撤回在做的菜。

不长眼的新手程序员擅自修改数据片段,整体业务逻辑被破坏。这是因为并没有真正的对一个 "订单" 的对象负责执行相关的业务逻辑,Service 上的一个方法直接就对数据库修改了。保持业务逻辑的完整,完全凭程序员对系统的了解。

我们在各个餐厅交流的时候,发现这并不是一个 IT 系统的问题。某些餐厅,所有的服务员都可以收银,即使用纸和笔,收银员划掉菜品没有更新小计,另外的服务员结账时会发生错误。于是餐厅,约定修改菜品必须更新订单总价。

一致性问题

我们吸收到这个业务逻辑到 IT 系统中来,并意识到系统中这里有一些隐藏的模型:

  • 订单。 订单。

  • 菜品。 菜品。

我们决定,抽象出订单、菜品的对象,菜品不应该被直接修改,而是通过订单才能修改,无论任何情况,菜品的状态变化都通过订单来完成。

复杂系统的状态被清晰的定义出来了, Service 承担处理各个应用场景的差异,模型对象处理一致的业务逻辑。

在接触 Eric Evans 的 DDD 概念之前,我们没有找到这种开发模式的名字,暂时称作为 朴素模型驱动开发。

朴素模型驱动开发

模型和领域模型

从上面的例子中,模型是能够表达系统业务逻辑和状态的对象。

我们知道要想做好一个可持续维护的 IT 系统,实际上需要对业务进行充分的抽象,找出这些隐藏的模型,并搬到系统中来。如果发生在餐厅的所有事物,都能在系统中找到对应的对象,那么这个系统的业务逻辑就非常完备。

现实世界中的业务逻辑,在 IT 系统业务分析时,是和某个行业和领域相关的,因此又被叫做领域。

领域,指的特定行业或者场景下的业务逻辑。

DDD 中的模型是指反映 IT 系统的业务逻辑和状态的对象,是从具体业务(领域)中提取出来的,因此又叫做领域模型。

通过对实际业务出发,而非马上关注数据库、程序设计。通过识别出固定的模式,并将这些业务逻辑的承载者抽象到一个模型上。这个模型负责处理业务逻辑,并表达当前的系统状态。这个过程就是领域驱动设计。

我从这里面学到了什么呢?

我们做的计算机系统实际上,是替代了现实世界中的一些操作。按照面向对象思想来说的话:我们的系统是一个电子餐厅。现实餐厅中的实体,应该对应到我们的系统中去,用于承载业务,例如收银员、顾客、厨师、餐桌、菜品,这些虚拟的实体表达了系统的状态,在某种程度上就能指代系统,这就是模型,如果找到了这些元素,就很容易设计出软件。

后来,如果我什么业务逻辑想不清楚,我就会把电断掉,假装自己是服务员,用纸和笔走一遍业务流程。

分析业务,设计领域模型,编写代码。这就是领域驱动设计的基本过程。领域模型设计好后(通俗来说,领域模型就是一些 UML 类图),可以指导后续的工作:

  • 指导数据库设计。 指导数据库设计。

  • 指导模块分包和代码设计。 指导模块分包和代码设计。

  • 指导 RESTful API 设计。 指导 RESTful API 设计。

  • 指导事务策略 指导事务策略

  • 指导权限设计。 指导权限设计。

  • 指导微服务划分(有必要的情况)。 指导微服务划分(有必要的情况)。

软件设计过程

在我们之前的例子中,收银员需要负责处理收银的操作,同时表达这个餐厅有收银员这样的一个状态。收银员收到钱并记录到账本中,账本负责处理记录钱的业务逻辑,同时表达系统中有多少钱的状态。

技术和业务复杂度

我们进行业务系统开发时,大多数人都会认同一个观点:将业务和模型设计清楚之后,开发起来会容易很多。

但是实际开发过程中,我们既要分析业务,也要处理一些技术细节,例如:如何响应表单提交、如何存储到数据库、事务该怎么处理等。

使用领域驱动设计还有一个好处,我们可以通过隔离这些技术细节,先进行业务逻辑建模,然后再完成技术实现,因为业务模型已经建立,技术细节无非就是响应用户操作和持久化模型。

我们可以把系统复杂的问题分为两类:

  • 技术复杂度。软件设计中和技术实现相关的问题,例如处理用户输入,持久化模型,处理网络通信等。 技术复杂度。软件设计中和技术实现相关的问题,例如处理用户输入,持久化模型,处理网络通信等。

  • 业务复杂度。软件设计中和业务逻辑相关的问题,例如为订单添加商品,需要计算订单总价,应用折扣规则等。 业务复杂度。软件设计中和业务逻辑相关的问题,例如为订单添加商品,需要计算订单总价,应用折扣规则等。

技术复杂度和业务复杂度

当我们分析业务并建模时,不要关注技术实现,会带来极大地干扰。和上一章聊到的断电法理解业务一样,就是在这个过程把"电"断掉,技术复杂度中的用户交互想象成人工交谈,持久化想象成用纸和笔记录。

DDD 还强调,业务建模应该充分的和业务专家在一起,不应该只是实现软件的工程师自嗨。业务专家是一个虚拟的角色,有可能是一线业务人员、项目经理、或者软件工程师。

由于和业务专家一起完成建模,因此尽量不要选用非常专业的绘图的工具和使用技术语言。可以看出 DDD 只是一种建模思想,并没有规定使用的具体工具。我这里使用 PPT 的线条和形状,用 E-R 的方式表达领域模型,如果大家都很熟悉 UML 也是可以的。甚至实际工作中,我们大量使用便利贴和白板完成建模工作,好处是一屋子的人方便参与到共创的工作坊中来。

这个建模过程可以是技术人员和业务专家一起讨论,也可以使用 "事件风暴" 这类工作坊的方式完成。这个过程非常重要,DDD 把这个过程称作 协作设计。通过协作设计,我们得到了领域模型(这里以简单的图表表示,也可以用 UML)。

领域模型 v1

上图是我们通过业务分析得到的一个非常基本的领域模型,我们的点餐系统中,会有座位、订单、菜品、评价几个模型。一个座位可以关联多个订单,每个订单可以有多个菜品和评价。同时,菜品也会被不同的订单使用。

上下文、二义性、统一语言

我们用领域模型驱动的方式开发软件系统,相对于事务脚本的方式,已经容易和清晰很多了,但还是有一些问题。

有一天,市场告诉我们,这个系统会有一个逻辑问题。就是系统中菜品被删除,订单也不能查看。在我们之前的认知里面,订单和菜品是一个多对多的关系,菜品都不存在了,这个订单还有什么用。

菜品,在这里存在了致命的二义性!!!这里的菜品实际上有两个含义:

  • 在订单中,表达这个消费项的记录,也就是订单项。例如,5号桌消费的鱼香肉丝一份。 在订单中,表达这个消费项的记录,也就是订单项。例如,5号桌消费的鱼香肉丝一份。

  • 在菜品管理中,价格为30元的鱼香肉丝,包含菜单图片、文字描述,以及折扣信息。 在菜品管理中,价格为30元的鱼香肉丝,包含菜单图片、文字描述,以及折扣信息。

菜品管理中的菜品下架后,不应该产生新的订单,同时也不应该对订单中的菜品造成任何影响。这些问题是因为,技术专家和业务专家的语言没有统一, DDD 一书提到了这个问题,统一语言是实现良好领域模型的前提,因此应该 "大声地建模"。我在参与这个过程目睹过大量有意义的争吵,正是这些争吵让领域模型变得原来越清晰。

这个过程叫做 统一语言。

领域模型 v2

和现实生活中一样,产生二义性的原因是我们的对话发生在不同的上下文中,我们在谈一个概念必须在确定的上下文中才有意义。在不同的场景下,即使使用的词汇相同,但是业务逻辑本质都是不同的。想象一下,发生在《武林外传》中同福客栈的几段对话。

关于上下文的对话

这段对话中实际上有三个上下文,这里的 "菜" 这个词出现了三次,但是实际上业务含义完全不同。

  • 大嘴说去买菜,这里的菜应该被理解为食材,如果掌柜对这个菜进行管理,应该具有采购者、名称、采购商家、采购价等属性。 大嘴说去买菜,这里的菜应该被理解为食材,如果掌柜对这个菜进行管理,应该具有采购者、名称、采购商家、采购价等属性。

  • 秀才说实习生把账单中的菜算错了价格,秀才需要对账单进行管理,这里的菜应该指的账单科目,现实中一般是会计科目。 秀才说实习生把账单中的菜算错了价格,秀才需要对账单进行管理,这里的菜应该指的账单科目,现实中一般是会计科目。

  • 老白说的客人点了一个酱鸭,这里老白关注的是订单下面的订单项,订单项包含的属性有价格、数量、小计、折扣等信息。 老白说的客人点了一个酱鸭,这里老白关注的是订单下面的订单项,订单项包含的属性有价格、数量、小计、折扣等信息。

实际上,还有一个隐藏的模型——上架中商品。掌柜需要添加菜品到菜单中,客人才能点,这个商品就是我们平时一般概念上的商品。

我们把语言再次统一,得到新的模型。

领域模型 v3

4个被虚线框起来的区域中,我们都可以使用 "菜品" 这个词汇(尽量不要这么做),但大家都明确 "菜品" 具有不同的含义。这个区域被叫做 上下文。当然上下文不只是由二义性决定的,还有可能是完全不相干的概念产生,例如订单和座位实际概念上并没有强烈的关联关系,我们在谈座位的时候完全在谈别的东西,所以座位也应该是单独的上下文。

识别上下文的边界是 DDD 中最难得一部分,同时上下文边界是由业务变化动态变化的,我们把识别出边界的上下文叫做限界上下文(Bounded Context)。限界上下文是一个非常有用的工具,限界上下文可以帮助我们识别出模型的边界,并做适当的拆分。

限界上下文的识别难以有一个明确的准则,上下文的边界非常模糊,需要有经验的工程师并充分讨论才能得到一个好的设计。同时需要注意,限界上下文的划分没有对错,只有是否合适。跨限界上下文之间模型的关联有本质的不同,我们用虚线标出,后面会聊到这种区别。

领域模型 v4

使用上下文之后,带来另外一个收获。模型之间本质上没有多对多关系,如果有,说明存在一个隐含的成员关系,这个关系没有被充分的分析出来,对后期的开发会造成非常大的困扰。

聚合根、实体、值对象

上面的模型,尤其是解决二义性这个问题之后,已经能在实际开发中很好地使用了。不过还是会有一些问题没有解决,实际开发中,每种模型的身份可能不太一样,订单项必须依赖订单的存在而存在,如果能在领域模型图中体现出来就更好了。

举个例子来说,当我们删除订单时候,订单项应该一起删除,订单项的存在必须依赖于订单的存在。这样业务逻辑是一致的和完整的,游离的订单项对我们来说没有意义,除非有特殊的业务需求存在。

为了解决这个问题,对待模型就不再一视同仁了。我们将相关性极强的领域模型放到一起考虑,数据的一致性必须解决,同时生命周期也需要保持同步,我们把这个集合叫做聚合。

聚合中需要选择一个代表负责和全局通信,类似于一个部门的接口人,这样就能确保数据保持一致。我们把这个模型叫做聚合根,聚合根充当一组领域模型领航员的角色。当一个聚合业务足够简单时,聚合有可能只有一个模型组成,这个模型就是聚合根,常见的就是配置、日志相关的。

在聚合中,无论是否是聚合根,对于有自己的身份(ID)的模型,我们都可以叫做实体。

领域模型 v5

我们把这个图完善一下,聚合之间也是用虚线链接,为聚合根标上更深一点的颜色。识别聚合根需要一些技巧。

  • 聚合根本质上也是实体,同属于领域模型,用于承载业务逻辑和系统状态。 聚合根本质上也是实体,同属于领域模型,用于承载业务逻辑和系统状态。

  • 实体的生命周期依附于聚合根,聚合根删除实体应该也需要被删除,保持系统一致性,避免游离的脏数据。 实体的生命周期依附于聚合根,聚合根删除实体应该也需要被删除,保持系统一致性,避免游离的脏数据。

  • 聚合根负责和其他聚合通信,因此聚合根往往具有一个全局唯一标识。例如,订单有订单 ID 和订单号,订单号为全局业务标识,订单 ID 为聚合内关联使用。聚合外使用订单号进行关联应用。 聚合根负责和其他聚合通信,因此聚合根往往具有一个全局唯一标识。例如,订单有订单 ID 和订单号,订单号为全局业务标识,订单 ID 为聚合内关联使用。聚合外使用订单号进行关联应用。

还有一类特殊的模型,这类模型只负责承载一组字段值的表达,没有自己的身份。在我们饭店的例子中,如果需要对账单支持多国货币,我们将纯数字的 price 字段修为 Price 类型。

价格这个模型,没有自己的生命周期,一旦被创建出来就无须修改,因为修改就改变了这个值本身。所以我们会给这类的对象一个构造方法,然后去除掉所有的 setter 方法。

我们把没有自己生命周期的模型,仅用来呈现多个字段的值的模型和对象,称作为值对象。

值对象一开始不是很容易理解,但是理解之后会让系统设计非常清晰。"地址" 是一个显著的值对象。当订单发货后,地址中的某一个属性不应该被单独修改,因为被修改之后这个"地址"就不再是刚刚那个"地址",判断地址是否相同我们会使用它的具体值:省、市、地、街道等。

最简单的理解,值对象就是"属性包",就是一些自己定义的通用拓展类型,持久化时展开到数据库表或者存为 JSON 字符串。

值对象是相对于实体而言的,对比如下。

有 ID 标识

无 ID 标识

有自己的生命周期

一经创建就不要修改

可以对实体进行管理

使用新的值对象替换

使用 ID 进行相等性比较

使用属性进行相等性比较

另外值得一提的是,一个模型被作为值对象还是实体看待不是一成不变的,某些情况下需要作为实体设计,但是在另外的条件下却最好作为值对象设计。

地址,在一个大型系统充满了二义性。

  • 作为订单中的收货地址时,无需进行管理,只需要表达街道、门牌号等信息,应该作为值对象设计。为了避免歧义,可以重新命名为收货地址。 作为订单中的收货地址时,无需进行管理,只需要表达街道、门牌号等信息,应该作为值对象设计。为了避免歧义,可以重新命名为收货地址。

  • 作为系统地理位置信息管理中的地址具有自己的生命周期,应该作为实体设计,并重命名为系统地址。 作为系统地理位置信息管理中的地址具有自己的生命周期,应该作为实体设计,并重命名为系统地址。

  • 作为用户添加的自定义地址,用户可以根据 ID 进行管理,应该作为实体,并重命名为用户地址。 作为用户添加的自定义地址,用户可以根据 ID 进行管理,应该作为实体,并重命名为用户地址。

我们使用浅色表达值对象以便区别于聚合根和实体,更新后的模型图如下:

领域模型 v6

虽然我们使用 E-R 的方式描述模型和模型之间的关系,但是这个 E-R 图使用了颜色(如果是黑白印刷的纸质版可能看不到具体的颜色,可以自行体会即可)、虚线,已经和传统的 E-R 图大不相同,把这种图暂时叫做 CE-R 图(Classified Entity Relationship)。DDD 没有规定如何画图,你可以使用其他任何画图的方法表达领域模型,如果需要严谨一点可以采用 UML 的类图绘制(推荐使用 UML 绘制领域模型)。

Released under the MIT License.