领域驱动设计(DDD)在百度爱番番的实践

avatar
架构师 @百度

图片

导读:领域驱动设计(Domain Driven Design - DDD)起源于2004年Eric Evans出版《领域驱动设计》,相比于在国外IT圈享有盛誉且行之有效不同,国内IT圈了解DDD的人很少,落地实践的少之又少。最近几年随着微服务架构的普及和中台的兴起,DDD也成了各大技术论坛和微信公众号文章里经常谈起的话题。

DDD的热度是起来了,但业界介绍DDD的资料大多偏理论,缺乏生产项目可借鉴的实践经验。因此大多人读了很多DDD材料后还是一脸懵,怎么衡量DDD带来的价值?老板能同意搞DDD吗?什么样的业务和团队适合DDD?DDD跟互联网强调的小步快跑快速迭代能搭吗?如果要实践DDD产研团队都要做些啥?研发写代码跟平时有什么不一样?本文结合百度爱番番产研团队在过去一年多经历的从探索、推广到全面落地DDD的过程,尝试回答上述问题,力求给大家带来一些借鉴意义。

全文约9500字,预计阅读时间25分钟。

1  初心:以客户为中心,产研团队如何高效交付需求

百度爱番番围绕营销拓客和销售提效帮助企业收集、扩充、清洗、培养、跟进和转化线索。一方面爱番番的业务特点是典型的企业级(ToB)业务,具有一定的复杂度。业务对象多,单个业务对象提供的功能多,单个功能面向的场景多,业务对象之间组合出来的业务流程多。并且会随着交付的功能越多而变的越复杂。另一方面产品处于爬坡阶段,功能需要快速迭代交付到客户,从而快速获得客户的反馈。产研团队在资源一定的情况下如何高效交付更复杂的需求成为了主要矛盾。

分析当前阶段需求迭代过程中的问题,可以总结为以下几类问题:

  1. 业务逻辑不能从产品团队精准传递到研发团队,有时研发进行了一段时间开发才发现需求理解有偏差,导致需要重新跟产品经理讨论需求。

  2. 产品团队和研发团队对于业务复杂度没有的认识不统一,产品经理认为一个需求的开发不难,理应在较少时间内开发完毕。

  3. 研发团队面对需求增长和变化时,缺乏对业务逻辑的抽象,往往开发一个需要点需要改动多处,容易出错且开发效率低,代码维护性差。

  4. 需求文档和代码逻辑不匹配,线上功能的业务逻辑为什么实现成那样没有依据可查,领域知识得不到沉淀,团队得不到可持续成长。

2  探索:找到适合的开发模式


上述问题集中体现在两个方面,一是产品属于企业应用类,功能本身复杂,如何让产研团队快速理解业务、快速交付。二是如何让领域知识能够比较准确的得到开发实现,让代码有比较好的可维护性。借鉴业内处理复杂企业级软件的开发经验,加上部分团队成员曾经有过DDD使用的经验,团队决定尝试运用DDD设计思想来指导产研团队的日常需求迭代。

2.1  DDD是啥?

DDD是一种围绕领域建模来解决复杂业务交付的设计思想。读者不妨自问几个问题,什么是复杂?什么是领域建模?

1. 什么是复杂?如何理解复杂?

复杂可能是现状业务就复杂,也可能是业务日渐演变成复杂。复杂来自规模在变,比如几个业务对象的逻辑不复杂,几十上百个业务对象就会变得错综复杂。复杂来自结构化不足,比如下图所示,结构化的中国结比非结构化的意大利面更有序、易于大脑理解。此外,一旦协同方多了,如何协同不同团队完成软件交付也是一种复杂。

图片

2. 什么是领域建模?

领域模型跟技术毫无关系,而是为了更有结构化的拆解和表达业务逻辑。业务逻辑来自现实世界里的具体场景,涉及可视画面、操作动作和流程。要准确表达业务逻辑需要先讲清楚每个概念是什么,再建立概念之间的联系,基于这些关系再组合出更多的流程。概念、联系、流程就是领域模型。围绕领域模型去表达业务时也自然而然地把技术实现细节分离出去了。后续代码实现就是将业务架构映射到系统架构的过程,以后业务架构调整了能快速的调整技术架构。

3. DDD中的领域如何理解?

  • DDD中表示业务逻辑的领域概念是:实体、值对象、领域服务、领域事件。这意味着所有领域逻辑都应该在这四种对象里,统一称为领域模型对象,这将极大减少业务逻辑的蔓延。

  • 引入聚合进一步封装实体和值对象,让领域逻辑更内聚,起到边界保护的作用。聚合的引入使得业务对象间的关联变少。如何设计聚合见下面实践部分。

  • 围绕聚合的操作引入工厂和资源库。工厂负责复杂聚合的创建,资源库负责聚合的加载、添加、修改、删除。聚合内的实体状态变更通过领域事件来推动。

  • 引入应用服务,对领域逻辑编排、封装。供上层接口层调用。一个应用服务就是一次编排,一次编排就是一个用户用例。

4. DDD领域概念详细解释和举例

image.png

2.2  DDD如何开展?

DDD包含战略设计、战术设计、技术实现三个部分。战略设计侧重于高层次、宏观上去划分限界上下文,而战术设计则关注使用建模工具来细化上下文,通过领域模型来表达业务。技术实现主要通过分层架构来隔离领域模型代表的业务逻辑和技术细节。一个整体过程大致包括:宏观划分各领域 → 领域内划分限界上下文,定义上下文之间的关系 → 上下文内分析业务,识别领域概念,定义合适的领域概念 → 通过分层架构实现编码,并验证领域模型的合理性,必要时重新回到前面步骤重构领域模型。

1. 战略设计

战略设计是团队领导层或业务负责人关心的,该步骤需要针对产品愿景、业务要解决的问题域,规划核心域、通用域、支撑域,做合适的资源投入。

1. 什么是领域和限界上下文?

领域代表现实世界的特定问题和解决方案的集合,比如销售领域、营销领域。DDD里的限界上下文(Bouded Context)是对领域的软件实现,比如线索系统、商机系统就是销售领域内的限界上下文。限界上下文定义了解决方案的明显边界,边界里的每一个领域概念,包括领域概念内的属性和行为都有特殊含义。出了限界上下文这个边界这层含义就不复存在。

2. 如何划分限界上下文?

#1:根据相关性做归类。一般是优先考虑功能相关性而不是语义相关性,比如创建订单和支付订单都是订单语义,但功能相差比较大,应该划分为两个限界上下文。

#2:根据团队粒度做裁剪、根据技术特点做裁剪。一些通用的技术功能应该尽可能归拢到一个限界上下文,比如每个业务限界上下文都有监控,但监控能力应该归拢到监控限界上下文。

3. BC与微服务什么关系?

微服务是包含高度相关功能的一个开发部署单元,有自己的技术自治性包括技术选型、弹性扩缩容、发布上线频率等,有自己的业务演变自治性。BC是根据领域逻辑的内聚情况形成的一个整体。一个微服务可以包含一个或多个BC,到底包含几个?需要根据团队大小、BC复杂度和技术特性来定。

2. 战术设计

DDD设计思想里领域建模是最核心的一步,该阶段主要目标是提炼和定义出领域模型和之间的关系。

1. 领域建模

建模就是设计的过程,建模的过程就是梳理、走查业务逻辑,拆解为要解决的问题和涉及的业务场景、业务流程、业务概念,在这个过程中形成对应的领域概念。

如果团队对于业务比较陌生适合采用事件风暴方法进行梳理;如果团队对业务比较熟悉,如果业务流程相对简单,则可以采用四色建模法进行业务梳理。采用这些分析业务的方法可以保证产研团队对业务逻辑的理解在一个水平上。

2. 业务逻辑的显性表达

在完成了实体和值对象的设计后,有的时候会发现有些概念其实在领域上是存在的,但设计和代码里没有Class来体现,可能仅仅是一个基本类型参数加上散落的对该参数的判断检验逻辑,这个时候还需要思考应该把这个概念显性化,定义专门的Class并包含相应逻辑,入出参以相应Class为类型。但凡业务代码逻辑包含了一堆if-else,这时候需要考虑尽可能给这段逻辑建模成一个领域概念。

比如CRM系统里判断一条线索是否为推广线索需要看线索的渠道属性是否来自推广平台,那么比较好的方式是这段逻辑用"推广线索"这个概念来显性表达,而不是淹没在代码里不容易理解和维护。

3. 统一语言

为了解决业务逻辑衔接的问题引入了统一语言。每个业务名词的含义具有明确的定义,产品和研发都统一认识。没有统一语言的沟通严重缺乏效率。比如CRM线索的概念,没有统一语言的时候每个人的理解不一样,有的人理解为有过咨询记录的访客是线索,有的人理解为留下过联系方式的访客是线索,有的人理解为有购买意愿的访客是线索等等。

有了统一语言描述,每个概念就有了明确定义,可以节省非常大的沟通交流成本。并且这个概念也同样应用在相关的需求文档、设计文档、代码编写中。每个概念从引入到日常交流,从需求文档到代码实现都有了一致的表达,代码实现和需求描述的真实度高,可理解性和可维护性就变好了。

3. 技术实现

1. 分层架构

为了让代码实现围绕领域模型开展,尽量降低业务代码和纯技术选型代码的耦合,DDD引入了分层架构。确保了最核心的领域层不依赖其他层,反过来让领域之外的代码依赖领域代码,降低了技术升级带来的影响。

2. DDD框架

框架内定义不同领域概念需要实现的接口,比如实现了聚合根接口的实体类就成为了聚合的根实体。定义了异常管理规范,不同的分层应该抛出什么类型的异常。定义了数据访问的资源库接口等等。

3. 领域事件

领域事件是对领域内发生的活动进行的建模,即聚合内的实体状态变化的一个载体。DDD提倡限界上下文间尽量解耦,尽可能使用发布订阅领域事件的协作模式进行上下游解耦。

2.3  DDD vs 数据模型驱动

传统的业务开发模式里,研发受到关系型数据库设计范式、ER图等影响深远,在做软件详细设计过程中往往先想到如何设计对应的表结构,由此倒推出业务逻辑代码该如何组织。这就是典型的数据模型驱动设计,或者叫面向数据表设计编程。数据模型设计关注的是数据存储,数据尽量不要冗余,控制表数量不膨胀,更多考虑数据的扩展性,比如新加一个字段尽量不要在几张表都加,能用一个字段表达就不用两个字段。

这样的思维跟DDD是相反的,DDD优先考虑领域概念的业务语义表达,具有独立业务概念的东西会尽量抽象成一个内聚的领域对象。领域对象不仅仅有属性,还有该有的行为。

因此,基于数据模型驱动的设计结果往往是:

1. 业务逻辑代码非常过程式,领域实体只包含一堆属性,只是数据表的映射,没有业务行为。也就是常说的只有getter和setter方法的贫血对象。非常缺乏领域概念的表达,业务逻辑散乱。比如值对象的设计在DDD里是一个类,在数据模型设计里往往是其他类的几个属性。

2. 聚合是DDD最小的复用单元,粒度更粗。数据模型设计里领域实体的数量跟表数量一一对应,数据表是最小的复用单元,粒度太细。导致业务逻辑对应的实现类需要访问很多的领域实体,实现类之间的调用关系发散而错综复杂。下图是贫血模型和DDD富血模型的区别。

图片

3. 数据表的关系表达很受限,具有主从关系的表之间很难看出主从。在DDD里聚合和聚合内的实体、值对象之间的关系在代码层面有显示的表达。

当然,DDD思想里不是说不用考虑数据表设计,而是要优先考虑领域概念的识别和建模。表设计需要服务于领域模型的设计,是技术实现的细节。因此明白DDD和数据模型驱动设计的区别反过来能更好地理解DDD。

3  实践:案列分析


3.1  业务背景

以爱番番业务中"线索"功能举例,线索管理功能特别多,有创建、清洗、分配、打标签、跟进、回收、退回和转化等十几个管理动作。仅线索创建就分为手工录入创建、文件导入创建、营销系统的后台自动创建、开放平台创建,创建还分为单个创建和批量创建等等。线索这个对象跟其他对象比如客户、商机等联动组合出来很多场景和流程。

3.2  规划阶段

规划阶段需要考虑产品愿景和服务蓝图,需要划分出产品的核心领域,支撑领域,通用领域。如果从0到1开发产品的话规划阶段需要做很多的工作,比如开发一个CRM产品需要考虑产品愿景和服务蓝图,需要聚焦到哪些业务领域,是售前、售中还是售后?售前还可以细分为营销领域还是销售领域等等。百度爱番番致力打造易用的、灵活可配的线索管家功能。因此销售领域的线索功能自然是核心模块。需要提供什么线索功能?需要通过分析阶段来拆解。

3.3  分析阶段

分析阶段是基于业务流程和功能分析出具体的业务对象,不同的业务对象归属划分到限界上下文。因为线索功能复杂,团队对于线索功能认知不一,有必要让相关人员一起采用事件风暴方法来分析和梳理业务。事件风暴认为事件流很⼤程度上反映了现实业务逻辑,参与人员基于领域事件发生的时间线,把事件的前因后果逐步挖掘出来。整个过程包含识别领域事件、决策命令、领域名词三个步骤。通过尝试回答这几个问题:这个业务涉及的系统产生了什么变化?变化由哪个角色通过什么方式触发的?系统变化产生了哪些结果?

基于上述步骤,领域专家和相关人员针对线索业务进行事件风暴的结果为:

图片

事件风暴关键图例:

图片

事件风暴实践过程的几点tips:

  1. 事件流几乎等同业务逻辑,以此来推敲业务逻辑的严密性,有果必有因。

  2. 紧扣事件要素:事件、规则、名词、命令、角色。

  3. 命名:紧扣业务,不参杂技术元素,警惕使用泛泛的词汇,尽可能地消除命名的⼆义性。

  4. 优先关注happy-path即正常路径,聚焦核心领域里的路径。

  5. 事件风暴不是一蹴而就,保持迭代更新。

基于事件风暴的结果,需要把领域名词和规则等划分到合适的限界上下文。根据前面介绍的如何划分限界上下文的方法,线索相关功能划分为几个限界上下文合适呢?这个时候需要看业务逻辑的复杂程度,还要结合团队规模大小。由于线索功能包含很多业务逻辑,线索归集和创建、线索的分配、线索的跟进等都可以成为一个独立的限界上下文。定义好限界上下文后还需要定义不同限界上下文的协作关系。一般情况下如果业务允许的情况尽量选择通过领域事件来协作。根据《领域驱动设计》所述常见的协作关系还包括开放主机服务(即通过暴露接口)、共享内核、防腐层等9种。微服务架构下的限界上下文之间的关系比较常见的有领域事件、开放主机服务、防腐层等。

3.4  设计阶段

设计阶段就是把分析阶段产出的领域名词,领域事件,决策命令用DDD领域概念来承接,并细化每个领域概念的数据和行为。这也是一种领域建模的过程。

建议的建模过程是:

  1. 业务需求的分析过程自上而下,由业务流程,到用户用例,到领域模型。而设计过程是自下而上的。从领域元素设计开始,最后才是应用服务的编排。

  2. 建议设计优先级是先值对象 → 再实体 → 再聚合 → 再领域服务→ 最后是应用服务,优先考虑领域是否应该为值对象,其次是否为实体,划分出聚合。不属于实体或值对象中的领域行为放到领域服务,需要协调聚合的领域行为设计为领域服务或者应用服务。

  3. 任何业务代码逻辑优先映射到原子性的领域模型,比如值对象、实体、领域事件、资源库接口、外部适配接口,其次再映射到组合性领域模型,比如领域服务、应用服务。

建模过程中经常会被问到的问题有:

1 值对象可以定义自己的行为吗?

可以,尽可能把属于值对象自己的行为放到值对象里。比如联系方式定义成一个值对象,如果它的校验只依赖自身数据,那校验行为应该属于在联系方式这个值对象。

2 聚合该设计为多大粒度?

聚合设计要尽量小,如果一个实体不是根实体,但同时需要被外界直接访问到,那么这个实体不应该在这个聚合中,应该独立成新的聚合。

3 一个聚合如何访问另外一个聚合?

只有聚合根才是访问聚合边界的唯一入口,因此一个聚合需要通过另一个的聚合的聚合根来访问它,聚合根可以理解为聚合的根实体的Id。

4 应用服务与领域服务的区别?

领域服务处在分层架构的领域层,是领域逻辑的一部分。应用服务处在应用层,负责领域模型的编排。当业务逻辑不属于任何聚合时,应该考虑用领域服务来封装这些逻辑。比如判定订单是否重复,应该属于订单限界上下文的一种业务逻辑,订单聚合本身不能判断是否重复,因此订单判重应该定义为领域服务。

5 应用服务可以直接调用聚合和资源库吗?

可以,可被应用服务编排的对象包括聚合、资源库、领域服务和适配接口。

6 领域事件内容是包含整个聚合里的信息,还是身份标识信息(订阅方再通过单独接口根据标识进行查询),还是只包含聚合中一些特定的信息?

领域事件是用于跟其他聚合协作,事件内容不应是整个聚合,而是经过裁剪的特定信息。

根据分析阶段的产出结果,需要把领域名词、规则映射到领域模型。主要几个线索相关领域对象如下图示:

图片

3.5  实现阶段

传统的接口-逻辑-数据访问三层架构里,业务逻辑层的XxxServiceImpl类是个上帝类,往往通过过程式业务逻辑实现。前几行代码做校验,接下来做数据类型转换,然后是业务处理逻辑的代码,中间穿插着通过接口或者dao获取更多的数据;拿到数据后,又是类型转换代码,然后接着一段业务逻辑代码,最后可能还要落库、发布消息等等。这样的代码参杂了太多不同的代码,非常难以维护。

业界自从DDD的分层架构提出后陆续出现过洋葱架构、六边形架构、整洁架构等,其目标都是为了分离业务和技术,保证领域模型的纯粹性。下图是结合业界架构实践后定制的分层架构,具有以下几个特点:

  1. 接口层负责对外暴露各种协议的接口比如http、tcp,转换成应用服务能认识的协议。

  2. 核心的领域层不依赖其他层,通过资源库包下的接口定义做到依赖倒置,接口参数不能体现具体技术实现细节,领域模型里的实现逻辑只依赖接口。这样做到对领域逻辑的一层防腐。本层里以聚合为单位放置代码,便于以后系统拆分,以聚合为单位。

  3. 应用层定义应用服务,一个接口对应业务场景的一个用例。此外应用层还可以处理横切面事务比如启动数据库事务。

  4. 基础设施层完成资源库的实际实现,以及领域层定义的其他接口的实现如对外部服务的访问,领域事件发布到消息队列中间件等。

  5. 分层架构还定义了每层的项目包结构,不同的领域概念和数据对象相应的命名规范。

图片

实现阶段经常会被问到的问题有:

1

每层应该用什么类型数据对象承载和传递数据?

如上面分层架构图所示,接口层和应用服务层用DTO对象传递数据,领域层只能见到领域对象即聚合、实体Entity和值对象VO。应用服务层负责把DTO对象转换成领域对象传输到领域层。基础设施层用PO表示数据表,跟领域层调用时需要把PO和领域对象相互做转换。

2

repository和dao的区别?

聚合设计要尽量小,如果一个实体不是根实体,但同时需要被外界直接访问到,那么这个实体不应该在这个聚合中,应该独立成新的聚合。

3

领域事件的发布应该在领域层还是应用层?

只要不会破坏各层的依赖顺序,在哪发布都行。取决于领域事件定义在哪层?一般推荐定义在领域层的聚合内。当然即便在应用层发布事件也不会破坏依赖方向。因此聚合、领域服务、应用服务都可以发布事件。

3.6  代码示例

以java代码为例,DDD骨架代码包含了分层架构,每层就是一个maven pom项目,根据用途定义好了多层包结构,每个领域对象和数据传输对象都有具体的命名方式。基于自研的ddd-framework规范了不同领域对象需要实现的接口或继承于特定的基类。

总之,尽可能做到了能根据需求文档里的业务逻辑很快找到代码所在之处,让不同的代码待在应该待的分层和包下面。团队成员开玩笑说,现在开发业务代码就像在做填空题,简单直白。

图片

3.7  收益

目前百度爱番番的新服务默认都会在符合DDD架构的骨架代码基础上开发,存量的核心模块也进行过DDD改造。全面实施DDD后产研团队目标更对齐,协作效率更高,收获了很多收益,包括但不限于以下几点:

  1. 产研团队协同成本降低,领域知识得到积累和沉淀。统一语言的使用和维护极大提高了大家对齐的成本。

  2. 业务语义得到显性表达,业务逻辑内聚可复用程度提高,避免了很多散弹式修改和发散式修改。一个需求不用改多个地方,多个需求也不用几个研发集中改同一个地方。

  3. 限界上下文的划分从业务合理性出发,进而微服务的划分会更合理,减少了团队间的耦合和不必要的协同代价。

  4. 接口数量精简、可控。由于业务代码聚焦领域模型,逻辑内聚,复用性高,急剧减少了接口数量,降低接口维护成本。

  5. 通过预定义好的脚手架创建符合DDD规范的代码骨架,提高了新服务开发的效率。

  6. 代码可读性高,不是代码作者也能快速定位到代码位置,代码设计能够得到传承,可维护性也提高了。

  7. 新人熟悉新业务和新代码的速度极大提高,业务和技术知识的转移代价减低。

3.8  实践总结

从需求到交付的一次典型软件开发流程包括收集提炼需求、需求分析、业务&技术设计、代码实现、测试上线等环节。如何结合软件开发流程,每个流程阶段具体要做什么、怎么做,特别在编码落地阶段该有什么保障措施?爱番番产研团队在落地过程中逐步总结出了一套行之有效的DDD实施指南。包括规划、分析、设计到实现四个阶段对应的方法和产出等实施要点。

图片


4  结语:殊途同归、没有银弹


DDD一方面使用分而治之的思想,引入划分领域、限界上下文、模块分层、划分聚合在不同层次、不同粒度来降低问题的复杂度。另一方主张聚焦领域逻辑,通过不同手段来减少业务和技术的耦合。因此DDD只是大部分软件设计思想一种,软件设计的本质都是为了高内聚低耦合。但是DDD并不是万能的,不是所有业务开发场景都适合用DDD。有些简单业务场景不使用DDD反而更恰当。因为DDD有较高的学习门槛,需要整个团队形成统一认识和协同,需要相应的编码规范和架构落地。因此学习和落地DDD时要时刻记住自己的出发点是为了应对现在或者将来的复杂业务领域而来。不必太拘泥于某些点是否遵守了DDD原则,如果觉得用了DDD会比没有用好一点点,也值得迈出这一步。

爱番番产研团队始终秉持“以客户为中心”的理念,运用DDD设计思想构建统一的业务模型,实现业务功能的复用和融合。随着爱番番业务的发展,我们相信DDD带来的收益会更大。今后我们会从产品、技术、流程和组织方面持续关注能有效解决软件工程复杂性问题的方法。

本期作者|飞邪

在百度爱番番主要负责销售域和连通域的技术,长期关注技术团队如何高效服务产品团队等研发效能话题,擅长ToB企业级应用的规划和落地。

招聘信息

无论你是后端,前端 ,大数据还是算法,这里有若干职位在等你,欢迎投递简历, 爱番番业务部期待你的加入!

阅读原文:领域驱动设计(DDD)在百度爱番番的实践

更多干货、内推福利,欢迎关注同名公众号「百度Geek说」~