领域驱动设计(DDD)在有赞教育线索资源管理的实践

一、项目背景

教育领域,完整的流程板块包括:招生拓客、线索管理、教务管理、学员管理、互动督学、口碑传播。首先,在招生拓客环节,会通过线上营销工具或线下地推方式收集潜在的学员线索信息,并录入到线索管理系统中。在线索管理环节,会采用线索资源管理系统对收集的线索做统一管理,并将潜在学员转为真正的学员,提供给后续的教务管理使用。可见,线索管理在整个教育领域承担着承上启下的作用,重要性不言而喻。

此项目业务场景总览见图1-1,整个项目分为两大业务域,分别是线索域、配置中心域。其中,线索域主要负责线索收集、线索管理等功能;配置中心域负责管理一些公共配置资源,比如,线索关联的标签、来源等。

图1-1 线索管理业务总览图

二、领域驱动基础概念介绍

在介绍DDD相关基础概念前,我先说明下为什么要使用DDD?在非DDD设计思路下的项目,我们一般先根据需求做数据库表的设计,然后根据表结构设计推导出相应的实体对象,这样的实体对象是数据模型转换的结果。此时,这些对象只是数据的载体,是没有行为的。在这种设计模式下,业务流程实现上仍旧是面向过程式,是一种以数据为中心的过程式思想,其开发过程可以理解为是对数据移动、处理和实现的过程。而如果采用DDD的思想去设计,我们将建立一个基于面向对象设计的系统。接下来,我先介绍DDD的标准分层架构,然后介绍下需求分析阶段非常有用的四色原型分析模式,最后简要介绍下方案设计阶段常用到的几个DDD领域概念。

2.1 领域驱动设计标准分层架构

当前,业界比较通用的DDD架构采用的是四层模型,从下到上依次为基础设施层、领域层、应用层和用户界面层。具体的分层架构见图2-1。

图2-1 领域驱动架构模型

2.1.1 基础设施层

基础设施层主要为其他层提供通用的技术能力,比如,应用的消息发送、领域持久化等。在实际的项目应用中,本层主要用于持久化数据的读取和写入,我们可以在这一层,将需要持久化的领域对象序列化到指定的存储介质中,比如:数据库、Hbase、MongoDB、ES等,同理,亦可从这些存储介质中取出数据并组装成领域对象。在这一层,一般会采用仓储机制实现领域持久化能力。

2.1.2 领域层

领域层,亦称模型层,属于业务系统中最核心的一层,整个系统中几乎所有的业务逻辑均会在该层实现。本层主要包含领域模型和领域服务。

(1)领域模型

领域模型用来抽象复杂的业务逻辑,将其转换为便于理解的概念图模型,一般由实体和值对象构成。它与数据模型的不同点在于:数据模型描述的是对象的持久化方式,而领域模型表述的是领域中各个类,以及各类之间的关系。

(2)领域服务

领域服务可以认为是领域模型的一种补充,因为在实际建模过程中,一些概念本质上是一些操作,它们会涉及到多个领域对象,并且需要协调这些领域对象来完成这个操作,如果强行将这个操作归类到某个对象,那么这个对象就会承担一些本不属于它的职责,进而出现对象职责不明确的现象,此时,就需要领域服务来承载这些操作,用来串联多个领域对象。比如,在线索管理项目中,线索详情页信息是由“线索基础信息”、“标签信息”、“来源信息”、“线索处理日志信息”等构成的,我们在建模时,考虑到合理性将四者定义为了四个单独的实体,此时就出现了问题,线索详情获取时,完整的线索信息怎么聚合,该放在哪里?考虑到四者信息领域相关性,我们便引入了领域服务,用来承载线索信息聚合操作。

2.1.3 应用层

应用层用来提供应用服务,主要负责业务用例的编排和组装,它和领域服务的主要区别,个人观点是:是否处理业务逻辑。具体介绍的话,就是协调领域层与用户界面层之间的关系,对外为用户界面层提供各种应用功能,对内调用领域层的领域对象或领域服务完成各种业务编排、组装。

2.1.4 用户界面层

用户界面层主要负责用户信息的展示,具体来说就是:请求应用层获取用户所需要展现的数据,以及发送命令给应用层要求其执行某个用户命令。在实际应用中,本层是可以不存在的,比如,在教育团队早期的项目中,前端是通过http的方式调用后端服务的,我们在这一层通过提供REST服务的方式与前端交互,之后,统一采用了RPC调用的方式,便弱化了这一层:在该层声明二方服务接口与前端node层交互,然后在应用层作具体的接口实现。

2.1.5 线索管理应用工程结构简单介绍

本小节会先给出线索管理项目涉及应用的工程目录结构,然后结合DDD的四层架构做对比分析。首先,我们来看下工程目录结构,见图2-2。

出于商业保密性,实际工程结构中部分模块做了隐藏

图2-2 线索管理应用工程目录

目录中各模块的定义如下:

  • demo-api:接口层,系统之间或者对外的接口声明,通过RPC调用的方式对外提供二方服务。
  • demo-biz:应用服务、领域服务处理层,接口层所声明接口的具体实现。
  • demo-dependency:外部系统的调用封装,比如,系统需要调用商品中心的服务,则需要在本module中封装client。
  • demo-domain:领域层,系统领域的一些model、上下文对象、仓储接口定义等。
  • demo-web:对外的REST接口。
  • demo-dal:基础设施层,数据持久化。

与DDD四层构架的对应关系见下表。

用户界面层 demo-web
demo-api
应用层 demo-biz(api)
领域层 demo-biz(domain)
demo-domain
demo-dependency
基础设施层 demo-dal

2.2 需求分析利器 — 四色原型图

简单的业务需求,一般使用用例图就可以表述清楚了,如果业务再复杂一些,我们可以附加一些时序图、状态图等加以说明,但是当业务非常复杂时,怎么去寻找业务中的关键点以及各个点之间的联系呢?或者有没有一个比较科学的理论,指引我们去分析呢?我们可以考虑使用四色原型分析模式。它主要用于业务分析阶段,用来分析业务行为、参与对象、业务对象关系等。那什么是四色原型图呢?我们先来看下它的四个构成元素,具体如下:

(1)时刻-时间段原型(Moment-Interval Archetype)

原型简称MI,表述的是某刻或某段时间内发生的一件事,比如:租房合同签署,是在某个时刻签署的,它有发生日期、行为人;租房行为是在一段时间内发生的,它有开始、结束时间和退租行为。这些我们都是可以通过此原型来表达的。在画原型图时,采用粉红色表示。

(2)参与方-地点-物品原型(Part-Place-Thing Archetype)

原型简称PPT,用来表示参与某个活动的人或物,地点则是活动的发生地。比如签署租房合同这个行为,合同、承租人分别对应这里的物、人,中介办公室对应这里的地点。在画原型图时,使用绿色表示。

(3)描述原型(Description Archetype)

原型简称DESC,是对PPT公共属性的描述,拿“签署租房合同”这个场景为例,在合同中会有一些租期、租金、押金、违约条件等约定,这些约定信息便可采用DESC原型来描述。绘制原型图时,采用蓝色表示。

(4)角色原型(Role Archetype)

原型简称Role,这里的角色,就是我们平时所理解的“身份”。以“签署租房合同”这个场景为例,签署行为人有承租人和中介工作人员,这里的角色便是指“承租人”和“中介工作人员”。绘制原型图时,采用黄色表示。

总结:如果必须要用一句话来概括四色原型的话,那就是:一个什么样的人或物以某种角色在某个时刻或某段时间内在某个地点参与某个活动。 其中“什么样的”就是DESC,“人或物”、“地点”就是PPT,“角色”就是Role,而”某个时刻或某段时间内的某个活动"就是MI。

2.3 DDD几个核心领域概念

2.3.1 实体

实体是一个具有身份和连贯性的概念,它具有以下几个特征: - 实体是数据(属性)和行为(业务逻辑关系)的结合体; - 每个实体都有自己的唯一标识,判断两个实体对象是否相等,是通过唯一标识来判断的。比如,两个实体对象,如果唯一标识相等,即使其他属性不相等,这两个实体也会认为是同一个。实体的其他属性不相等,表征的是同一个实体在其生命周期的不同阶段。 - 实体的唯一标识属性值是不可变的,其他属性值是可变的。

举个例子简单说明下,比如在有赞精选内容平台(类似于小红书的电商导购平台)这个业务域中,每一篇“博文”就是一个业务实体,可以采用“博文id”作为实体的唯一标识,然后这个博文实体拥有着属性(标题、作者、发表时间、内容等)和行为(更新博文、删除博文、关联导购商品等),同时,属性是会随着行为而不断变化的。

2.3.2 值对象

值对象一般会作为一个属性存放于一个实体内部,它具有以下几个特征: - 值对象不需要唯一标识,判断两个值对象是否相等,是通过值对象内部所有属性值是否相等来判断的。 - 值对象的属性值是不允许变化的,即值对象的实体在创建之后就不会变了,如果要改变其属性值,就需要先把此对象删除,然后重新创建一个新对象。

同样以“有赞精选内容平台”为例说明下,用户可以针对博文发起留言,同时,我们会精选出一些留言置顶,对于“置顶的留言”我们可以定义为值对象,并将其作为一个属性放置于博文实体中,一旦置顶留言发生变化,我们只需要将新的置顶留言重建为值对象,并赋值给博文实体的这个属性即可。

2.3.3 聚合

聚合是一组具有内聚关系的领域对象(包括实体和值对象)的集合,这里的一组可以是一个或多个实体。每个聚合都会有一个根实体(亦称聚合根),它主要用来和外界交互,即外部对象如果想访问聚合内的实体,必须先访问聚合根,然后聚合根再和内部要访问的实体进行交互。

还是拿“有赞精选内容平台”举例说明,一篇博文中,它包含博文基础信息(内容、标题等)、关联的商品信息、关联的标签信息等,这一组合就是一个聚合,其中,“博文基础信息”可以设置为这个组合的聚合根。

2.3.4 仓储

首先说明下仓储被设计出来的初衷,在领域模型中,对象被创建出来后一般会在内存中活动,待其不活动了后,需要将其进行持久化存储。然后,当我们需要重建对象时,需要根据对象当前状态进行重建。可见这整个过程中,会频繁的与数据库(广义的数据库,包括关系型数据库、NoSql数据库等)打交道,进行对象的创建、组装等。因而,能否提供一种机制,帮助我们管理领域对象以及做对象持久化,仓储并应运而生了。

仓储,又称资源库,它具有以下几个特征: - 仓储是连接领域层和基础设施层的桥梁,一般将仓储接口定义放在领域层,仓储的具体实现放在基础设施层。这样做的好处是:解耦了领域层与ORM之间的联系,任何ORM相关的变更,只需要修改仓储的实现便可,对于领域层仓储接口的定义一般是不需要做修改的。 - 仓储里面存储的对象一定是聚合,因为领域模型中都是以聚合来划分业务边界的,所以在实际应用中,我们只会对聚合设计仓储。同理,我们在仓储中做数据更新、删除等操作时,应该以聚合为单位进行操作,而不是仅操作聚合中的某一个实体。

三、线索资源管理DDD实战

结合四色原型图,设计领域模型的步骤可概括为以下几步:

  • 根据需求,采用四色原型分析法建立一个初步的领域模型;
  • 进一步分析领域模型,识别出哪些是实体,哪些是值对象,哪些是领域服务;
  • 对实体、值对象进行关联和聚合,提炼出聚合边界和聚合根;
  • 为聚合根设计仓储(一般情况下,一个聚合分配一个仓储),同时,思考实体、值对象的创建方式,是通过工厂创建,还是直接通过构造函数;
  • 走查需求场景,验证设计的领域模型的合理性。

在图1-1(线索管理业务总览图)中可以看出,“线索域”是其核心部分,接下来,我主要针对“线索域”,按上述步骤一点一点推导出其领域模型。

3.1 场景分析提炼四色原型图

在线索收集部分,不管是采用线上营销工具渠道,还是线下地推渠道,最终触发的业务场景都是线索新增;在线索管理部分,业务场景主要是:新增/更新线索、查询线索、分配线索、跟踪线索、放弃线索等。业务场景用例可见图3-1。

图3-1 线索域业务场景用例图


(1)新增/更新线索四色原型图

业务规定,只有高级管理员、课程顾问、普通管理员等有操作线索的权限,按照四色原型一句话理念“一个什么样的人或物以某种角色在某个时刻或某段时间内在某个地点参与某个活动”,可推导出操作的参与方原型是商家,参与方角色有商家高级管理员、课程顾问、普通管理员等;物品原型是线索,其中线索由线索基础信息、线索标签、线索来源三者构成;参与的活动是“新增/更新线索”。提炼出的四色原型图见图3-2。

图3-2 新增/更新线索四色原型图


(2)查询线索

参与方原型是商家,参与方角色有商家高级管理员、课程顾问、普通管理员等;物品原型是线索基础信息、线索标签、线索来源;参与的活动是“查询线索”。提炼出的四色原型图见图3-3。

图3-3 查询线索四色原型图


(3)分配线索

每个线索如果需要人跟进的话,必须给线索分配一个跟进人。如果当前跟进人无法完成此线索跟踪,其可以将此线索转让给其他人(这里的线索分配者、线索承接人、线索原跟进人的身份均是“高级管理员、课程顾问、普通管理员”之一)。从上述场景可以得出:参与方原型是商家,参与方角色有商家高级管理员、课程顾问、普通管理员等;物品原型是线索基础信息、线索标签、线索来源;参与的活动是“分配线索”。提炼出的四色原型图见图3-4。

图3-4 分配线索四色原型图


(4)跟踪线索

课程顾问拿到分配过来的线索后,就需要去跟踪了,在跟踪期间,课程顾问可以记录相应的跟踪记录。此场景中,参与方原型是商家,参与方角色是课程顾问;物品原型是跟踪记录;参与的活动是“添加跟踪记录”。归纳出的四色原型见图3-5。

图3-5 线索添加跟进记录四色原型图


(5)放弃线索

如果课程顾问觉得当前线索比较难跟进,可以选择放弃此线索。从上述场景可以得出:参与方原型是商家,参与方角色是课程顾问;物品原型是线索(基础信息、标签、来源);参与的活动是“放弃线索”。提炼出的四色原型图见图3-6。

图3-6 放弃线索四色原型图


综合以上所有场景,可得出图3-7所示的“线索域”四色原型图。

图3-7 线索域四色原型图

3.2 领域模型中实体/值对象/领域服务/聚合识别

一般来说,可以将四色原型图中的原型和DDD做简单的映射,比如:PPT原型描述的是某个活动下的唯一个体,其可对应到DDD中的实体;Role原型表述的是实体在不同状态下的表现,一般将其放置于实体中,一起构成一个完整的带状态实体;DESC原型描述的是PPT的公共属性,一般作为值对象存储;MI原型描述的是某个活动,可间接对应领域服务。

我们回过来看下图3-7,在图中,有“商家、线索基础信息、跟踪记录、来源信息、标签信息”5个PPT,我们可以据此定义5个实体,“高级管理员”、“课程顾问”、“普通管理员”可以认为是商家在不同身份下的表现,可在商家对象中使用一个标识符来描述。于是,我们可以总结出以下实体,见图3-8。

实际的线索信息比图3-8中定义的要复杂,出于商业保密性,这里仅列出部分字段,且部分字段采用xxx来表示。

图3-8 线索域实体


接着,我们进一步分析实体间的关系,提炼出聚合边界和聚合根,并定义出仓储。

在线索域中,线索是核心,很明显ClueEntity与SourceEntity、RecordEntity、TagEntity、UserEntity是相关联的,而后四者间是没有联系的。首先,来看下ClueEntity和UserEntity,线索在创建之初是可以没有跟进人(用户)的,但在之后被跟进的过程中,需要强制绑定一个跟进人(用户),而用户脱离线索是不具有存在价值的。同时,本项目中,用户信息仅作为线索的归属属性存在,最终我们将UserEntity(改名为UserVO)作为值对象放置于ClueEntity内,且令线索信息实体为聚合根;然后,分析下ClueEntity和SourceEntity、TagEntity、RecordEntity,主要从两个方面考虑是否需要组成聚合:

(1)聚合代表的是一个完整的概念,具有内部一致性,即聚合内的对象要么一起获取,要么一起更新,要么一起删除。假如聚合在被保存时,内部任意一个对象被修改了,都需视为聚合被修改了,此时应令保存失败。所以,在定义聚合时,在保证合理性的情况下,尽量设计小的聚合。在线索管理中,线索管理人员会频繁给线索关联标签、来源、跟进记录等信息,从内部一致性角度考虑,三者分开可能会更好。

(2)聚合内聚合根和对象间要保持不变性。何为不变性?简单来说,对象之间存在某种不变的规则。举个例子说明下,x=y+5,如果规定y大于1,那么x一定大于6。回到线索管理,ClueEntity和SourceEntity、TagEntity、RecordEntity间并不存在这种不变性,因为任意一个来源、标签、跟踪记录一定有一条对应的线索,但一条线索可以没有来源、标签、动态记录,同时,来源、标签、跟踪记录均可以在各自领域被单独访问到。

结合以上两点,这里我采用的策略是:SourceEntity、TagEntity、RecordEntity三者各自定义为一个聚合,本身作为聚合根。那问题也来了,在查询线索详情时,线索是包含来源、标签、跟踪记录信息的,但是ClueEntity聚合内又不包含三者,此时该怎么解决信息聚合呢?我们采用了领域服务,来做领域对象间的聚合。

最后,定义下仓储,这里我采用一个聚合对应一个仓储的原则来定义的。最终,我们得到下表所示的领域模型。

仓储 聚合 聚合根
ClueRepository ClueAggregate ClueEntity, UserVO
SourceRepository SourceAggregate SourceEntity
TagRepository TagAggregate TagEntity
RecordRepository RecordAggregate RecordEntity

对应的类图可见图3-9。

图3-9 线索域类图结构

四、总结与思考

本文主要从“线索资源管理”这一实际项目出发,详细的阐述了从需求分析到方案设计阶段,如何采用DDD思想一步一步提炼领域模型。首先,在第一章中重点介绍了项目的背景,以及它在教育域中的价值,同时给出了其主要的业务场景,方便大家对本项目有一个整体的印象。然后,在第二章中详细的介绍了DDD的分层结构,在需求阶段可采用的利器 — 四色原型分析法,以及方案设计阶段需要使用的几个DDD领域概念。最后,在第三章中,结合第一、二章的项目背景、领域概念,一步一步提炼出了本次项目的领域模型。鉴于作者经验有限,我对领域驱动的理解难免会有不足之处,欢迎大家共同探讨,共同提高。(附上内推邮箱:chengyingjie@youzan.com,欢迎加入有赞教育团队)

欢迎关注我们的公众号