万字长文助你上手软件领域驱动设计 DDD

DDD 通过解锁新角色”领域专家”以及模型驱动设计,有效地降低产品和研发的认知差异。领域专家是具有丰富行业经验和领域知识储备的人,他们能够在易变的、定制化的需求中提炼出清晰的边界,稳定的、可复用的领域概念和业务规则,并携手产品和研发共同构建出领域模型。领域模型是对业务需求的知识表达形式,它不涉及具体的技术细节(但能够指导研发同学进行编程实现),因此消除了产品和研发在需求认知上的鸿沟。而模型驱动设计则要求领域模型能够关联业务需求和编码实现,模型的变更意味着需求变更和代码变更,协作围绕模型为中心。

5.全局分析阶段

全局分析阶段对问题空间进行的梳理和分析,形成统一语言(ubiquitous language), 获取问题空间的价值需求以及业务需求

5.1 形成统一语言

统一语言:蕴含领域知识的、团队内统一的领域术语。产品、领域专家以及开发人员掌握的领域知识存在差异,往往导致对同一个事物使用不同的术语。比如,商品的价格(Price)和商品的金额(Amount),它们本质是同一个东西,但是却有不同的术语表示。

统一语言会参与 DDDRUP 的全流程,且会在精炼循环过程中不断进行调整,以反映出更合适、更深层次的领域知识。

根据业务需求形成统一语言,有助于团队对事物的认知达成一致。统一语言可以通过词汇表的形式展示,其中词汇表最好还要包含术语对应的英文描述,便于研发同学在代码层面表达统一语言。示例-SMS 的统一语言词汇表如下。

5.3.2 子领域

通过业务流程、业务场景和业务服务的梳理,基本可以分析出业务需求所需要的业务服务。然而,业务服务粒度太细,而问题空间又太大,我们需要找一个更粗粒度的业务单元,来帮助我们对业务服务进行聚类,一方面可以降低管理过多细粒度业务服务导致的额外复杂度,另一方面可以帮助领域专家和开发团队分析问题和设计方案时不至于陷入到业务细节中。而这个更粗粒度的业务单元就是子领域

子领域的作用

  • 划分问题空间,作为业务服务分类的边界;

  • 用于分辨问题空间的核心问题和次要问题。

子领域的分类:

  • 核心子领域:能够体现系统愿景,具有产品差异化和核心竞争力的业务服务;

  • 通用子领域:包含的内容缺乏领域个性,具有较强的通用性,例如权限管理和邮件管理;

  • 支撑子领域:包含的内容多为“定制开发”,其为核心子领域的功能提供了支撑。

子领域的功能分类策略:问题空间应该分为哪些子领域,需要团队对目标系统整体进行探索,并根据功能分类策略进行分解。

  • 业务职能:当目标系统运用于企业的生产和管理时,与目标系统业务有关的职能部门往往会影响目标系统的子领域划分,并形成一种简单的映射关系。这是康威定律的一种运用。

  • 业务产品:当目标系统为客户提供诸多具有业务价值的产品时,可以按照产品的内容与方向进行子领域划分。

  • 业务环节对贯穿目标系统的核心业务流程进行阶段划分,然后按照划分出来的每个环节确定子领域。(这也是我们最常用的策略)

  • 业务概念:捕捉目标系统中一目了然的业务概念,将其作为子领域。

划分子领域的过程存在很多经验因素,一个对该行业领域知识了如指掌的领域专家,可以在完成价值需求分析后,结合自身的领域经验,能够选择合适的聚类策略并给出稳定的子领域列表。但,没有领域经验也没有关系!因为根据知识消化循环思路,再经历多个迭代后收敛出来的子领域划分也会逐渐合理,逼急领域专家凭经验得出的子领域划分,只是可能需要的时间要长一些。

6.架构映射阶段

在架构映射阶段,我们需要识别限界上下文,并通过上下文映射表示限界上下文之间的协作关系。

6.1 限界上下文的定义和特征

6.1.1 限界上下文的定义

限界上下文是语义和语境的边界。在问题空间,统一语言形成了团队对领域概念的统一表达,子领域形成了领域概念之间的边界。而在解空间,限界上下文可以看做是统一语言+子领域的融合体,统一语言需要在限界上下文内才具有明确的业务含义。

以电商购物场景为例。在进行商品下单后,系统会生成一个订单;在用户付款完成后,系统也会生成一个订单;到了物流派送流程,系统还会生成一个订单。虽然这三个步骤中的领域概念都叫订单,但是他们的关注点/职责却不同:商品订单关注的是商品详情,支付订单关注的是支付金额和分润情况,物流订单关注的是收货地址。也就是说,商品、支付和物流分别为三个限界上下文,而订单作为统一语言需要在特定的限界上下文内,我们才能够明确其关注点/负责的职责。

6.1.2 限界上下文的特征

最小完备:限界上下文在履行属于自己的业务能力时,拥有的领域知识是完整的,无须针对自己的信息去求助别的限界上下文。

自我履行:限界上下文能够根据自己拥有的知识来完成业务能力。自我履行体现了限界上下文纵向切分业务能力的特征。

这里需要强调一下业务模块(横向切分)限界上下文(纵向切分)的区别。业务模块不具备完整、独立的业务能力,它没有按照同一个业务变化的方向进行。而限界上下文是对目标系统架构的纵向切分,切分的依据是从业务进行考虑的领域维度。为了提供完整的业务能力,在根据领域维度进行划分时,还需要考虑支撑业务能力的基础设施实现,如与该业务相关的数据访问逻辑,以及将领域知识持久化的数据库模型,形成纵向的逻辑边界,即限界上下文边界。

6.3.2 开放主机服务

开放主机服务定义公开服务的协议(亦称为“服务契约”),包括通信方式、传递消息的格式(协议),让限界上下文可以被当做一组服务访问。开放主机服务也可以视为一种承诺,保证开放的服务不会轻易做出变化。

对于进程内的开放主机服务,称为本地服务(对应 DDD 中的应用服务)。

对于进程间的开放主机服务,成为远程服务。根据选择的分布式通信技术的不同,又可以定义出类型不同的远程服务:

  • 面向服务行为,比如基于 RPC,称为提供者(Provider);

  • 面向服务资源,比如基于 REST,称为资源(Resource);

  • 面向事件,比如基于消息中间件,称为订阅者(Subscriber);

  • 面向视图模型,比如基于 MVC,称为控制器(Controller);

6.3.4 共享内核

共享内核指将限界上下文中的领域模型直接暴露给其他限界上下文使用。注意,这会削弱了限界上下文边界的控制力。上面我们讲述的防腐层、开放主机服务以及发布语言无不传达一种思想,限界上下文不能直接暴露自己的领域模型或直接访问其他限界上下文的领域模型,一定要有隔离层!

但是,在特定的场景下,共享内核不见得不是一种合理的方式。任何软件设计决策都要考量成本与收益,只有收益高于成本,决策才是合理的。一般对于一些领域通用的值对象是相对稳定的,这些类型通常属于通用子领域,会被系统中几乎所有的限界上下文复用,那么这些领域模型就适合使用共享内核的方式。共享内核的收益不言而喻,而面临的风险则是共享的领域模型可能产生的变化。

7.领域建模阶段

领域建模阶段由领域分析建模,领域设计建模和领域实现建模组成。在正式讲解建模活动前,先了解一下什么是模型驱动设计。

7.1 模型驱动设计

模型是一种知识形式,它对知识进行了选择性的简化和有意的结构化,从而解决信息超载的问题。模型便于人们理解信息的意义,并专注核心问题。

建模过程一般由分析活动设计活动实现活动组成。每一次建模活动都是一次对知识的提炼和转换,并产生相应的模型,即分析模型设计模型实现模型

建模过程并非是分析、设计和实现单向的前后串行过程,而是相互影响,不断切换和递进的关系。模型驱动设计的建模过程是:分析中蕴含了设计,设计中夹带了实现,甚至实现后还要回溯到设计和分析的一种迭代的螺旋上升的演进过程。

根据分解问题的视角不同,我们日常建立的模型可以大致分为以下三类:

  • 数据模型:将问题空间抽取出来的概念视为数据信息,在求解过程中关注数据实体的样式和它们之间的关系,由此建立的模型就是数据模型。

  • 服务模型:将每个问题视为目标系统为客户端提供的服务,在求解过程就会关注客户端发起的请求以及服务返回的响应,由此建立的模型就是服务模型。

  • 领域模型:围绕问题空间的业务需求,在求解过程中力求提炼出表达领域知识的逻辑概念,由此建立的模型就是领域模型。

7.1.1 领域模型驱动设计

一个优秀的领域模型应该具备以下的特征(我们也可以说具备这些特征的模型就是领域模型):

  • 运用统一语言来表达领域中的概念;

  • 蕴含业务活动和规则等领域知识;

  • 对领域知识进行适度的提炼和抽象;

  • 由一个迭代的演进过程建立;

  • 有助于产品、领域专家和开发同学进行交流。

领域建模阶段目的便是建立领域模型。领域模型由领域分析模型领域设计模型以及领域实现模型共同组成,它们也分别是领域分析建模、领域设计建模和领域实现建模三个建模活动的产物。

值得注意的是,领域模型并非由开发团队单方面输出的产物,而是由产品、领域专家和开发团队共同协作的结果。领域专家通过领域模型能够判断系统所支持的领域能力,以及由此编排出来的上层业务能力;开发团队通过领域模型能够形成基本的代码框架(包括架构分层,每层需要定义的接口,接口的命名等)。同理,领域模型的调整,也意味着领域知识或业务规则的变化,也预示着系统所支持的业务能力和代码实现同样需要作出改变。

7.2 领域分析建模

领域分析建模:在限界上下文内,以“领域”为中心,提炼业务服务中的领域概念,确定领域概念之间的关系,最终形成领域分析模型。领域分析模型描述了各个限界上下文中的领域概念,以及领域概念之间的关系。

下面讲述如何通过“快速建模法”来构建领域分析模型。

7.2.1 名词建模

找到业务服务中的名词,在统一语言指导下将其映射为领域概念。

7.2.2 动词建模

识别动词并不是为领域模型对象分配职责、定义方法,而是将识别出来的动词当做一个领域行为,然后看它是否产生了影响管理、法律或财务的过程数据。若存在,则将这些过程数据作为领域概念放到领域分析模型中。注意,这里的过程数据是要求会对企业运营和管理产生影响的数据,比如示例-SMS 系统中老师提交修改申请,就会产生申请单这个过程数据,而请求流水记录、任务执行记录都不属于过程数据。动词建模通过分析领域行为是否产生过程数据来找到隐藏的领域概念,弥补了名词建模的不足。

特别地,对于会产生领域事件的动词,一般可以抽象出一个已完成该动作的状态。

7.2.3 提取隐式概念

除了“名词”和“动词”,概念中其他重要的类别也可以在模型中显式地表现出来,主要包括:约束规格

约束

约束一般是对领域概念的限制,我们可以将约束条件提取到自己的方法中,并通过方法名显式地表达约束的含义。比如示例-SMS 中关于 GPA 运算的约束。

规格由“谓词”概念演变而来,因此我们可以使用“AND”,“OR”和“NOT”等运算对规格进行组合和修改。比如在 SMS 中,教务员需要查询流程完结的申请单,我们就可以通过“AND”组合不同的规格进行实现。

7.3 领域设计建模

领域设计建模的核心工作就是设计聚合设计服务,在这之前我们需要先了解一下设计要素(实体、值对象、聚合、工厂、资源库、领域服务、领域事件)。

7.3.1 设计要素

领域驱动设计强调以“领域”为核心驱动力。设计领域模型时应该尽量避免陷入到技术实现的细节约束中。但很多时候我们又不得不去思考一些非领域相关的问题:

  • 领域模型对象在身份上是否存在明确的差别/p>

  • 领域模型对象的加载以及对象间的关系如何处理/p>

  • 领域模型对象如何实现数据的持久化/p>

  • 领域模型对象彼此之间如何做到弱依赖地完成状态的变更通知/p>

为了解答上述的四个问题,DDD 提供了很多的设计要素,它们能够帮助我们在不陷入到具体技术细节的情况下进行领域模型的设计

7.3.1.1 实体

实体的核心三要素:身份标识属性领域行为

身份标识:身份标识的主要目的是管理实体的生命周期。身份标识可分为:通用类型和领域类型。通用类型 ID 没有业务含义;而领域类型 ID 则组装了业务逻辑,建议使用值对象作为领域类型 ID。

属性:实体的属性用来说明主体的静态特征,并持有数据与状态。属性分为:原子属性和组合属性。组合属性可以是实体,也可以是值对象,取决于该属性是否需要身份标识。我们应该尽可能将实体的属性定义为组合属性,以便于在实体内部形成各自的抽象层次。

领域行为:体现了实体的动态特征。实体具有的领域行为一般可以分为:

  • 变更状态的领域行为:变更状态的领域行为体现的是实体/值对象内部的状态转移,对应的方法入参为期望变更的状态。(有入参,无出参);

  • 自给自足的领域行为:自给自足意味着实体对象只操作了自己的属性,不外求于别的对象。(无入参);

  • 互为协作的领域行为:需要调用者提供必要的信息。(有入参,有出参);

  • 创建行为:代表了对象在内存的从无到有。创建行为由构造函数履行,但对于创建行为较为复杂或需要表达领域语义时,我们可以在实体中定义简单工厂方法,或使用专门的工厂类进行创建。(有出参,且出参为特定实体实例)。

7.3.1.2 值对象

一个领域概念到底该用值对象还是实体类型,判断依据:

  • 业务的参与者对它的相等判断是依据值还是依据身份标识;

  • 确定对象的属性值是否会发生变化,如果变化了,究竟是产生一个完全不同的对象,还是维持相同的身份标识;

  • 生命周期的管理。值对象无需进行生命周期管理。

值对象具有不变性。值对象完成创建后,其属性和状态就不应该再进行变更了,如果需要更新值对象,则通过创建新的值对象进行替换。

由于值对象的属性是在其创建的时候就完成传入的,那么值对象所具有的领域行为大部分情况下都是“自给自足的领域行为”,即入参为空。这些领域行为一般提供以下的能力。

  • 自我验证:验证传入值对象的外部数据是否正确,一般在创建该值对象时进行验证。

  • 自我组合:当值对象涉及到数值运算时,可以定义相同类型值对象的方法,使值对象具有自我组合能力。比如示例-SMS 中,在统计成绩时会涉及学分相加的运算,因此我们可以将相加运算定义为可组合的方法,便于调用者使用。

  • 自我运算:根据业务规则对属性值进行运算的行为。

在进行领域设计建模时,要善于运用值对象而非内建类型去表达细粒度的领域概念。相比于内建类型,值对象的优势有:

  • 值对象在类型层面就可以表达领域概念,而不仅仅依赖命名;

  • 值对象可以封装领域行为,进行自我验证,自我组合,自我运算。

7.3.1.3 聚合

聚合的基本特征:

  • 聚合是包含了实体和值对象的一个边界。

  • 聚合内包含的实体和值对象形成一棵树,只有实体才能作为这棵树的根。

  • 外部对象只允许持有聚合根的引用,以起到边界控制作用。

  • 聚合作为一个完整的领域概念整体,其内部会维护这个领域概念的完整性。

  • 由聚合根统一对外提供履行该领域概念职责的行为方法,实现内部各个对象之间的行为协作。

7.3.1.4 工厂

聚合中的工厂:一个类或方法只要封装了聚合对象的创建逻辑,都可以认为是工厂。表现形式如下:

  • 引入专门的聚合工厂(尤其适合需要通过访问外部资源来完成创建的复杂创建逻辑)

  • 聚合自身担任工厂(简单工厂模式)

  • 服务契约对象或装配器(assembler)担任工厂(负责将外部请求对象 DTO 转换为实体)

  • 使用构建者组装聚合

注意!这里工厂创建的基本单元是聚合,而非实体,注意与实体中的创建行为区分。

7.3.1.5 资源库

资源库是对数据访问的一种业务抽象,用于解耦领域层与外部环境,使领域层变得更为纯粹。资源库可以代表任何可以获取资源的仓库,例如 络或其他硬件环境,而不局限于数据库。

一个聚合对应一个资源库。领域驱动设计引入资源库,主要目的是管理聚合的生命周期。资源库负责聚合记录的查询与状态变更,即“增删改查”操作。资源库分离了聚合的领域行为和持久化行为,保证了领域模型对象的业务纯粹性。

值得注意的是,资源库的操作单元是聚合。当我们定义资源库的接口时,接口的入参应该为聚合的根实体。如果要访问聚合内的非根实体,也只能通过资源库获得整个聚合后,将根实体作为入口,在内存中访问封装在聚合边界内的非根实体对象。

**7.3.1.6 领域服务 **

聚合通过聚合根的领域行为对外提供服务,而领域服务则是对聚合根的领域行为的补充。因此,我们应该尽量优先通过聚合根的领域行为来满足业务服务

那什么场景下我们会需要用到领域服务呢如下两个:

  • 生命周期管理。为了避免领域知识的泄露,应用服务不会直接引用聚合生命周期相关的服务(工厂、资源库接口),而聚合根实体一般不会依赖资源库接口,此时就需要领域服务进行组合对外暴露。

  • 依赖外部资源为了保证聚合的稳定性,聚合根实体不会依赖防腐层接口。因此,当聚合对外暴露的服务需要设计外部资源访问时,就需要通过领域服务来完成。

7.3.1.7 领域事件

领域事件属于领域层的领域模型对象,由限界上下文中的聚合发布,感兴趣的聚合(同一限界上下文/不同限界上下文)可以进行消费。而当一个事件由应用层发布,则该事件为应用事件。

引入领域事件首要目的是更好地跟踪实体状态的变更,并在状态变更时,通过事件消息的通知完成领域模型对象之间的协作。

领域事件的特征

  • 领域事件代表了领域的概念;

  • 领域事件是已经发生的事实(表示事件的名称应该是过去时,比如 Committed);

  • 领域事件是不可变的领域对象;

  • 领域事件会基于某个条件而触发。

领域事件的用途

  • 发布状态变更;

  • 发布业务流程中的阶段性成果;

  • 异步通信。

领域事件应该包含:

  • 身份标识,即事件 ID,为通用类型的身份标识;

  • 事件发生的时间戳,便于记录和跟踪;

  • 属性需要针对订阅者的需求,在增强事件反向查询之间进行权衡。增强事件指属性中包含订阅者所需的所有数据;反向查询则是属性包含事件 ID,当订阅者需要数据时通过事件 ID 进行反向查询。

7.3.2 设计聚合

在领域设计模型中,聚合是最小的设计单元。

7.3.2.1 设计的经验法则

这里有四条经验法则:

  1. 在聚合边界内保护业务规则不变性。

  2. 聚合要设计得小巧。

  3. 通过身份标识符关联关系其他聚合。

  4. 使用最终一致性更新其他聚合。

下面展开讲述法则 1 和法则 3。

法则 1 在聚合边界内保护业务规则不变性

法则 1 包含了两个关键点:a) 参与维护业务规则不变性的领域概念应该置于同一个聚合内;b) 在任何情况下都要保护业务规则不变性。比如,在 sms 系统中分数和绩点具有转换关系,这是业务规则的不变性,因此这两个概念被放在了同一个聚合边界内;当出现老师修改分数的场景时,需要保证绩点的换算同时被执行。由于这里绩点对象是值对象,不需要关心其生命周期管理的问题。当业务规则涉及到多个实体时,就需要通过本地事务来保证规则不变性(即实体间基于业务规则的数据一致性)。

法则 3 通过身份标识符关联其他聚合。

注意这里强调了关联关系,关联关系会涉及聚合 A 对聚合 B 的生命周期管理的问题,对于这种聚合间的关联关系,我们通过身份标识建立关联。而当聚合 A 引用聚合 B,但不需要对聚合 B 进行生命周期管理时,我们认为这是一种依赖关系(比如方法中的入参,而非类中的属性),对于聚合间的依赖关系,我们可以通过对象引用(聚合根实体的引用)的方式建立依赖。(PS:假设设计之初难以判断聚合之间到底是关联关系,还是依赖关系,我们就统一使用身份标识符作为关系引用即可)

df0392174f62ec627dd38e6b5dc4ceea.png

聚合间的依赖关系通常分为两种方式

  • 职责的委派:一个聚合作为另一个聚合的方法参数, 就会形成职责的委派。

  • 聚合的创建:一个聚合创建另外一个聚合,就会形成实例化的依赖关系。

7.3.2.2 设计步骤

1. 理顺对象图

分析对象是实体还是值对象。

2. 分解关系薄弱处

聚合本质是一个高内聚的边界,因此我们可以根据领域对象之间关系的强弱来定义出聚合的边界。对象间的关系由强到弱可以分为:泛化关系,关联关系和依赖关系。其中关联关系和依赖关系在 7.3.2.1 小节已讲述,而泛化关系可以理解为是继承关系(即父子关系)。

泛化关系

虽然泛化关系是强耦合关系,但是根据对业务理解的视角不同,会产生不同的设计:

  • 整体视角:调用者并不关心特化的子类之间的差异,而是将整个继承体系视为一个整体。此时应以泛化的父类作为聚合根。

  • 独立视角:调用这只关注具体的特化子类,体现了概念的独立性,此时应以特化的子类作为独立的聚合根。

关联关系

上述提到过,聚合间的关联关系会涉及聚合 A 对聚合 B 的生命周期管理,这其实是一个比较宽松的约束。那聚合内实体的关联关系应该是怎么样的呢strong>生命周期一致的、共存亡的,当主实体被销毁时,从实体也随之会被销毁。比如商品实体和商品明细实体。而在示例-SMS 中,成绩和总成绩会被定义为两个聚合,原因是总成绩在成绩锁定后被统计,随后将不再发生改变,可见两者不存在上述的共存亡的关联关系。

PS: 实际上根据关联关系来区分边界的方法同样适用于限界上下文的边界划分。比如示例-SMS 中的课程和成绩生命周期不同,先有课程,后有成绩;而且成绩锁定后,课程被撤销也不会对成绩有影响,因此就可以定义出课程上下文和成绩上下问。

依赖关系

依赖关系主要体现的是实体间的职责委派和创建行为,可以分到不同的聚合边界。

3. 调整聚合边界

根据业务规则调整聚合边界。为了维护业务规则的不变性,相关的实体应该至于同一个聚合边界内。

7.3.3 设计服务

这里的服务是对应用服务领域服务领域行为(实体提供的方法)和端口(资源库接口、防腐层接口)的统称。

7.3.3.1 分解任务

声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!

上一篇 2022年2月24日
下一篇 2022年2月24日

相关推荐