要将领域模型映射到程序设计,最终都会落实到 3 种类型的对象设计:服务、实体和值对象。 统一语言建模是指导思想,事件风暴会议是实践方法
1. 服务、实体与值对象
1.1 服务(Service)
服务,标识的是那些在领域对象之外的操作与行为。 在 DDD 中,“服务”通常承担了两种类型的职责:接收用户的请求和执行某些操作。当用户在系统界面中进行一些操作时,就会向系统发送请求。这时,是由“服务”首先去接收用户的这些请求,然后再根据需求去执行相应的方法。在执行这些方法的过程中,“服务”会去操作相应的实体与值对象。最后,当所有操作都完成以后,再将实体或值对象中的数据持久化到数据库中。
譬如,当用户需要下单的时候,就会从前端发起一个“下单”请求。该请求被“订单”Service 接收到,并执行下单的相应操作。在执行过程中,“订单”Service 会对“订单”实体中的数据进行校验,完成各种数据操作,最后将其保存到数据库中。
1.2 实体(Entity)
实体,就是那些通过一个唯一标识字段来区分真实世界中的每一个个体的领域对象。例如,在学籍管理系统中的“学员”对象就是一个实体,它通过标识字段“学员编号”将每一个学员进行了区分,通过某个学员编号就能唯一地标识某个学员;并且,这个学员有许多属性,如姓名、性别、年龄等,这些属性也是随着时间不断变化。这样的设计就叫作“实体”。
1.3 值对象
值对象,代表的是真实世界中那些一成不变的、本质性的事物,这样的领域对象叫作 “值对象”,如地理位置、行政区划、币种、行业、职位等。
1.4 实体和值对象的区分
在 DDD 中,对实体与值对象进行了严格的区分。可变性是实体的特点,而不变性则是值对象的本质。例如,北京是一个城市,架构师是一个职务,人民币是一个币种,这些事物的特性是永远不变的。
在实际项目中,我们可以根据业务需求的不同,灵活选用实体还是值对象。比如,在线订餐系统中,根据业务需求的不同,菜单既可以设计成实体,也可以设计成值对象。例如,“宫保鸡丁”是一个菜品,如果将其按照值对象设计,则整个系统中“宫保鸡丁”只有一条记录,所有饭店的菜单如果有这道菜,都是引用的这条记录;如果按照实体进行设计,则是认为每个饭店的“宫保鸡丁”都是不同的,比如每个饭店的“宫保鸡丁”的价格都是不尽相同的。因此,将其设计成有多条记录、有各自不同的 ID,每个饭店都是使用自己的“宫保鸡丁”。
2. 贫血模型 vs 充血模型
服务、实体与值对象是领域驱动设计的基本元素。然而,要将业务领域模型最终转换为程序设计,还要加入相应的设计。通常,将业务领域模型转换为程序设计,有两种设计思路:贫血模型与充血模型
所谓的“贫血模型”,就是在软件设计中,有很多的 POJO(Plain Ordinary Java Object)对象,它们除了有一堆 get/set 方法,几乎没有任何业务逻辑。这样的设计被称为“贫血模型”。
所谓“充血模型”,就是将领域模型的原貌直接转换为程序中领域对象的设计。这时,各种业务操作就不再在“服务”中实现了,而是在领域对象中实现。
两种设计思路的优劣比较
充血模型在理论上非常优雅,然而在工程实践上却不尽人意。而贫血模型虽然从表面上看简单粗暴,但在工程实践上依然有许多优异的特性,主要体现在以下 3 个方面
- 贫血模型比充血模型更加简单易行
- 充血模型需要具备更强的设计与协作能力
- 贫血模型更容易应对复杂的业务处理场景
小结
贫血模型的业务逻辑在 Service 中实现,但充血模型是在领域对象中实现。清楚了这一点,在今后的软件设计时,可以将那些需要封装的业务逻辑放到领域对象中,按照充血模型去设计;除此之外的其他业务逻辑放到 Service 中,按照贫血模型去设计。
小结
领域模型分析只是软件需求分析的中间过程,它最终需要落地到程序设计。领域模型的最终落地是三种类型的对象:服务、实体与值对象,而设计思路有两种:贫血模型与充血模型。通过这样的落地,领域模型就能很好地指导程序开发,提高设计质量。
在 DDD 落地的过程中,不必过于纠结到底是实体还是值对象,应当将更多的精力集中于对业务的分析与理解。同时,将贫血模型与充血模型结合起来,取长补短、合理编码。
3. 聚合的设计思路
聚合体现的是一种整体与部分的关系。正是因为有这样的关系,在操作整体的时候,整体就封装了对部分的操作。但并非所有对象间的关系都有整体与部分的关系,而那些不是整体与部分的关系是不能设计成聚合的。因此,正确地识别聚合关系就变得尤为重要
判断聚合关系最有效的方法就是去探讨:如果整体不存在时,部分是否存在。如果不存在,就是聚合;反之,则不是
3.1 聚合根——外部访问的唯一入口
有了聚合关系,部分就会被封装在整体里面,这时就会形成一种约束,即外部程序不能跳过整体去操作部分,对部分的操作都必须要通过整体。这时,整体就成了外部访问的唯一入口,被称为 “聚合根”。
也就是说,一旦将对象间的关系设计成了聚合,那么外部程序只能访问聚合根,而不能访问聚合中的其他对象。这样带来的好处就是,当聚合内部的业务逻辑发生变更时,只与聚合内部有关,只需要对聚合内部进行更新,与外部程序无关,从而有效降低了变更的维护成本,提高了系统的设计质量。
然而,这样的设计有时是有效的,但并非都有效。譬如,在管理订单时,对订单进行增删改,聚合是有效的。但是,如果要统计销量、分析销售趋势、销售占比时,则需要对大量的订单明细进行汇总、进行统计;如果每次对订单明细的汇总与统计都必须经过订单的查询,必然使得查询统计变得效率极低而无法使用。
因此,领域驱动设计通常适用于增删改的业务操作,但不适用于分析统计。在一个系统中,增删改的业务可以采用领域驱动的设计,但在非增删改的分析汇总场景中,则不必采用领域驱动的设计,直接 SQL 查询就好了,也就不必再遵循聚合的约束了。
3.2 聚合的实现
- 订单仓库在查询订单时,只是简单地查询订单表,不会去 Join 其他表,比如 Join 用户表,不会做这些事情;
- 当查询到该订单以后,将其封装在订单对象中,然后再去通过查询补填用户对象、订单明细对象;
- 通过补填以后,就会得到一个用户对象、多个订单明细对象,需要将它们装配到订单对象中。
这时,那些创建、装配的工作都交给了另外一个组件——工厂来完成
4. DDD 的工厂
DDD 中的工厂,与设计模式中的工厂不是同一个概念,它们是有差别的。在设计模式中,为了避免调用方与被调方的依赖,将被调方设计成一个接口下的多个实现,将这些实现放入工厂中。这样,调用方通过一个 key 值就可以从工厂中获得某个实现类。工厂就负责通过 key 值找到对应的实现类,创建出来,返回给调用方,从而降低了调用方与被调方的耦合度
而 DDD 中的工厂,与设计模式中的工厂唯一的共同点可能就是,它们都要去做创建对象的工作。
DDD 中的工厂,主要的工作是通过装配,创建领域对象,是领域对象生命周期的起点。譬如,系统要通过 ID 装载一个订单:
- 这时订单仓库会将这个任务交给订单工厂,订单工厂就会分别调用订单 DAO、订单明细 DAO 和用户 DAO 去进行查询;
- 然后将得到的订单对象、订单明细对象、用户对象进行装配,即将订单明细对象与用户对象,分别 set 到订单对象的“订单明细”与“用户”属性中;
- 最后,订单工厂将装配好的订单对象返回给订单仓库。
5. DDD 的仓库
然而,当订单工厂将订单对象返回给订单仓库以后,订单仓库不是简单地将该对象返回给客户程序,它还有一个缓存的功能。在DDD 中“仓库”的概念,就是如果服务器是一个非常强大的服务器,那么我们不需要任何数据库。系统创建的所有领域对象都放在仓库中,当需要这些对象时,通过 ID 到仓库中去获取。
但是,在现实中没有那么强大的仓库,因此仓库在内部实现时,会将领域对象持久化到数据库中。
- 数据库是仓库进行数据持久化的一种内部实现,
- 它也可以有另外一种内部实现,就是将最近反复使用的领域对象放入缓存中。
这样,当客户程序通过 ID 去获取某个领域对象时,仓库会通过这个 ID 先到缓存中进行查找,找不到则 通知工厂 调用DAO 去数据库中查询,然后装配成领域对象返回给仓库,仓库在收到这个领域对象以后,在返回给客户程序的同时,将该对象放到缓存中。
以上是通过 ID 装载订单的过程,那么通过某些条件查询订单的过程又是怎么做呢?查询订单的操作同样是交给订单仓库去完成。
- 订单仓库会先通过订单 DAO 去查询订单表,但这里是只查询订单表,不做 Join 操作;
- 订单 DAO 查询了订单表以后,会进行一个分页,将某一页的数据返回给订单仓库;
- 这时,订单仓库就会将查询结果交给订单工厂,让它去补填其对应的用户与订单明细,完成相应的装配,最终将装配好的订单对象集合返回给仓库。
简而言之,采用领域驱动的设计以后,对数据库的访问就不是一个简单的 DAO 了,这不是一种好的设计。通过仓库与工厂,对原有的 DAO 进行了一层封装,在保存、装载、查询等操作中,加入聚合、装配等操作。并将这些操作封装起来,对上层的客户程序屏蔽。这样,客户程序不需要以上这些操作,就能完成领域模型中的各自业务。技术门槛降低了,变更与维护也变得简便了
小结
DDD 中一个非常重要的设计思想:聚合,以及它的设计实现:工厂与仓库,它们是 DDD 中充血模型设计的重要支柱。
6. 限界上下文
假如将整个系统中那么多的场景、涉及的那么多领域对象,全部绘制在一张大图上,可以想象这张大图需要绘制出密密麻麻的领域对象,以及它们之间纷繁复杂的对象间关系。绘制这样的图,绘制的人非常费劲,看这张图的人也非常费劲,这样的图也不利于我们理清思路、交流思想及提高设计质量。
正确的做法就是将整个系统划分成许多相对独立的业务场景,在一个一个的业务场景中进行领域分析与建模,这样的业务场景称为 “问题子域”,简称“子域”。
领域驱动核心的设计思想,就是将对软件的分析与设计还原到真实世界中,那么就要先分析和理解真实世界的业务与问题。而真实世界的业务与问题叫作 “问题域”,这里面的业务规则与知识叫 “业务领域知识”譬如:
- 电商网站的“问题域”是人们如何进行在线购物,购物的流程是怎样的;
- 在线订餐系统的“问题域”是人们如何在线订餐,饭店如何在线接单,系统又是如何派送骑士去配送的。
然而,不论是电商网站还是在线购物系统,都有一个非常庞大而复杂的问题域。要一次性分析清楚这个问题域对我们来说是有难度的,因此需要采用 “分而治之”的策略,将这个问题域划分成许多个问题子域。比如:
- 电商网站包含了用户选购、下单、支付、物流等多个子域;
- 在线订餐系统包含了用户下单、饭店接单、骑士派送等子域。
如果某个子域比较复杂,在子域的基础上还可以进一步划分子域。
因此,一个复杂系统的领域驱动设计,就是以子域为中心进行领域建模,绘制出一张一张的领域模型设计,然后以此作为基础指导程序设计。这一张一张的领域模型设计,称为“限界上下文”(Context Bounds,CB)
DDD 中限界上下文的设计,很好地体现了高质量软件设计中 “单一职责原则” 的要求,即每个限界上下文中实现的都是软件变化同一个原因的业务。比如,“用户下单”这个限界上下文都是实现用户下单的相关业务。这样,当“用户下单”的相关业务发生变更的时候,只与“用户下单”这个限界上下文有关,只需要对它进行修改就行了,与其他限界上下文无关。这样,需求变更的代码修改范围缩小了,维护成本也就降低了
在用户下单的过程中,对用户信息的读取是否也应该在“用户下单”这个限界上下文中实现呢?答案是否定的,因为读取用户信息不是用户下单的职责,当用户下单业务发生变更的时候,用户信息不一定变;用户信息变更的时候,用户下单也不一定变,它们是软件变化的两个原因。
因此,应当将读取用户信息的操作交给“用户信息管理”限界上下文,“用户下单”限界上下文只是对它的接口进行调用。通过这样的划分,实现了限界上下文内的高内聚和限界上下文间的低耦合,可以很好地降低日后代码变更的成本、提高软件设计质量。而限界上下文之间的这种相互关系,称为“上下文地图”(Context Map)
6.1 限界上下文与微服务
所谓“限界上下文内的高内聚”,也就是每个限界上下文内实现的功能,都是软件变化的同一个原因的代码。因为这个原因的变化才需要修改这个限界上下文,而不是这个原因的变化就不需要修改这个限界上下文,修改与它无关。正是因为限界上下文有如此好的特性,才使得现在很多微服务团队,运用限界上下文作为微服务拆分的原则,即每个限界上下文对应一个微服务。
所谓“限界上下文间的低耦合”,就是限界上下文通过上下文地图相互调用时,通过接口进行调用,在代码实现时,可以通过微服务来实现“限界上下文间”的“低耦合”
6.2 微服务拆分的困局
为了让微服务设计做到高内聚,最佳的实践则是 DDD:
- 先从 DDD 开始需求分析、领域建模,逐渐建立起多个问题子域;
- 再将问题子域落实到限界上下文,它们之间的关联形成上下文地图;
- 最后,各子域落实到微服务中贫血模型或充血模型的设计,从而在微服务之间依据上下文地图形成接口。
唯有这样的设计,才能很好地做到“微服务之间低耦合,微服务之内高内聚”的设计目标