当前位置: 首页 > 图灵资讯 > 技术篇> 后台开发进阶!白话DDD从入门到实践

后台开发进阶!白话DDD从入门到实践

来源:图灵教育
时间:2023-07-05 17:38:08

试着用每个人都能理解的话来分享我们从入门到实践DDD的一些经验,结合我们在增值业务中的具体实现。

0. 写在前面的

DDD(领域驱动设计)Eric Evans于2003年提出的解决复杂中大型软件的方法,一开始并不生气。直到Martin Fowler于2014年发表的论文《Microservices》DDD慢慢回到了大众的视野,引起了大家对微服务的关注。近年来,随着DDD的升温,也收到了很多业内人士对DDD的负面意见。主要原因可能是“晦涩难懂,过于抽象”、“很难找到实际案例参考”、"不知如何着陆"等等。在学习DDD的过程中,作者也遇到了这些问题。然而,经过几个月的学习-实践,逐渐掌握了DDD的一些思想之后,感觉还是有益的,因此,在这里,我们尝试用白话来总结我们从入门到实践的过程,并尝试用我们的具体实现来举例说明每个概念。,希望对想一起学DDD的同事有所帮助。

1.维护中业务系统引起的思考

近年来,我们的背景+前端大约有6-8个开发同事共同维护了一个带货项目。我们使用最传统的三层模型来构建这个项目,可能是以下模型:

后台开发进阶!白话DDD从入门到实践_微服务

经过几年的项目维护,逐渐出现了一些有趣的情况,我选择了一些主要环节中发现的代表性问题:

情况1(代码层):代码可读性的一小部分不同人员的长期修改它变得越来越糟。例如,带货的核心rpc在一个函数中没有嵌套平铺逻辑,单函数代码行数达到数百行,可读性和维护性极差,成功化身为“技术护城河”。情况二(微服务水平):一些微服务的初始功能划分相对简单,导致少量模块在后续快速迭代中快速扩展。例如,MP模块最初用于承担B端门户的功能,当我们决定拆分这个庞大的模块时,这个模块已经携带了204个rpc。一旦出现问题,过多的能力承担会使其编译变慢,成为链路单点,变化更大,影响更大。

情况3(业务团队层面):当这些业务系统想要修改这些接口和数据结构时,带货项目将使用其他业务系统的接口和数据结构 ,偶尔可能没有注意到这里的依赖导致了在线问题, 或者通过沟通发现耦合更多,不容易改变

在一个复杂的业务系统中,这个项目的维护引出了我们的一些思考:如何设计代码结构,如何划分微服务的横向/纵向功能,如何与业务团队互动,才能持续在长期快速,多人合作确保系统的可维护性、扩展性、高耦合性、低内聚性和稳定性。

传统的开发模式是面向过程的(POP)或者面对对象(OOP)没有办法引导我们从微服务层面找到这些问题的答案。解决这个问题大概有两种方法:1)找一个中心节点的同事,他总是有时间,总是能做出正确的决定,干预每一个全局/细节的设计,做出统一的决定。2)寻找新的规则/规范作为指导,让每一个开发人员都有做出正确决策的依据。在Tencent的氛围和环境中,2)无疑更合理,所以我们想到了领域驱动设计(DDD)。

2.分层结构DDD

DDD最具标志性的一点是将传统软件设计的三层模型转化为四层模型,如下图所示:

后台开发进阶!白话DDD从入门到实践_微服务_02

乍一看,四层架构引入了许多概念,如领域服务、领域对象、 DTO、仓储等等。我们不必关心这些细节,因为我们将在下一节逐一分析和列出我们的实现例子。让我们首先关注这些关键层:用户界面层、应用层、领域层、基础设施层。让我们来看看他们的职能分工:

用户界面层:网络协议转换/统一鉴权/Session管理/限流配置/前缓存/异常转换

应用层:业务流程安排(只有安排,没有业务逻辑)/ DTO出入转化

领域层:领域模型/领域服务/仓储和防腐层的界面定义

基础设施层:仓储和防腐层接口实现/存储等基础层能力

这里必须说的是,这四层不一定意味着物理四层,也可以在微服务中拆分逻辑四层。四层结构有许多变化,如六边形结构、洋葱结构、清洁结构、清晰结构等。我们在这里不讨论这些各种各样的概念。以洋葱结构为例,重点是强度在DDDD中调整依赖倒置(DIP),以后更容易介绍仓储/防腐层等概念。

依赖倒置(DIP):1.高级模块不应依赖于低级模块。两者都应该依赖于抽象。2.抽象不应该依赖于细节。细节应该依赖于抽象。

后台开发进阶!白话DDD从入门到实践_基础设施_03

如上所述,洋葱结构越依赖,核心能力就越低。基础设施层在最外层,依赖于其他层,因为DDD中的其他层需要定义所需的基本能力接口,而基础设施层负责依赖和实现这些接口,从而实现整体依赖的倒置。这反映了DDD从整体到微妙、从顶层到下层的设计思维。

3.DDD概念与实践1)战略与战术

实际上,DDD的着陆过程就是战略建模和战术建模。

战略建模是指通过DDD理论拆解和分析业务需求,划分子域,梳理边界上下文,从战略层面通过领域语言划分领域,构建领域模型。在构建领域模型的过程中,梳理相应的业务聚合、实体和值对象。

战术建模是指以领域模型为基础,微服务分割以上下文为服务划分的边界,在每个微服务中分层,实现领域服务,从而实现代码映射的目的,最终实现DDD的实施。

后台发展进步!白话DDD从入门到实践_基础设施_04'

当然,战略和战术的建模不仅要考虑业务形式,还要考虑组织结构,就像康威定律中的表达,沟通架构会影响技术架构

康威定律:任何组织在设计系统(广义概念系统)时,交付的设计方案在结构上都与组织的沟通结构一致。

2)领域

DDDD在解决复杂问题时使用了分而治之的想法。这种分而治之的想法是从领域开始,一个领域是一个问题空间,当我们拆分这个问题空间时,也就是划分子领域并找到它的解决系统的过程。

实践例子:

例如,我们的一个新的增值业务被视为一个大的增值业务领域。接下来,我们将通过DDD指导其拆分。

后台开发进阶!白话DDD从入门到实践_值对象_05

3)子域

如果一个领域太大太复杂,涉及的业务规则、交互流程、领域概念太多,就不能直接为这个大领域建模。此时,有必要拆分该领域,本质上是将大问题分为小问题,将大领域分为多个小领域(子领域)。

子域可分为三类:

核心子域:业务成功的核心竞争力。

通用子域:不是核心,而是用于整个业务系统 。

支撑子域:不是核心,不是整个系统使用,完成业务的必要能力。

子域的划分不仅分为大问题空间,而且划分了工作的优先级。我们应该给予核心领域最高的优先级和最大的资源。在实施DDD的过程中,我们也主要关注核心领域。

实践例子:

子域的划分需要强有力的业务知识和产品研发集体讨论,准确深入的业务意见在现阶段尤为重要。在这里,我们不深入讨论业务知识,只展示我们对增值业务领域的拆解结果。

后台开发进阶!白话DDD从入门到实践_值对象_06

这里要说的是,由于产品需求的变化,包装域的概念在实现过程中被废弃,但由于我们的子域被拆分,包装域与其他域之间没有耦合,因此废弃包装域概念的废弃就像拆除积木一样,对整个系统没有影响,也不会留下任何不必要的负担代码。

4)限制上下文

要理解上下文的界限,首先要介绍一般语言。一般语言是DDD非常重要的一点。例如,商品的概念是指在商品领域上架的商品, 包含了id、介绍、文档等。在交易领域,它实际上是指订单中交易的实体,关注id、交易时间的价格等参数和数量。如果这些概念和它们之间的关系不清楚,开发人员的实现就会变得随意和模糊。

边界的上下文是划分边界。当领域模型被显示的边界包围时,每个概念的含义应该是清晰和唯一的。

我认为初学者最常遇到的问题一定是“明明已经有子域了,为什么会有限界上下文的概念?“。子域是一个子问题空间,上下文的作用是指导如何设计这个问题空间的解决系统。换句话说,上下文是真正用来指导微服务划分的。一般来说一个子域对应一个或多个限界上下文。

上下文可参考以下规则:1) 概念是否存在歧义:如果一个模型在上下文中存在歧义,则表示可以继续拆分上下文。

2)外部系统:可以拆分与外部系统交互的部分,减少外部系统对我们核心业务逻辑的影响。

3)组织结构:不同的团队最好在不同的界限内开发,避免沟通不畅、集成困难等问题。请参考以上内容。”康威定律"。

实践例子1:

如上所述,商品的概念需要在不同的场景中使用上下文。当然,这也会导致两个上下文之间的依赖。通过DDD的概念,我们可以指导以下实现。

后台开发进阶!白话DDD从入门到实践_微服务_07

其中,gateway/gatewayimpl是防腐层的实现,DTO是指数据传输对象,APP是指商品应用层。两种不同颜色的商品是指两种上下文中定义的不同实体或值对象。

实践例子2:

在交易领域,有两个订单概念,其中第一个订单概念是指业务层的订单, 第二个订单的概念是指内部的基本订单。业务订单更关注交易商品信息,这是用户需要的。基本订单更关注交易的底层流程信息,这是我们内部人员需要的,用户不理解。

当时,有一个想法是让基础团队的学生开发额外的业务信息,直接支持基础订单存储,这显然不符合1)和3)的上下文划分规则,需要通过上下文解耦。因此,我们在交易领域分为两个上下文,后续的微服务也是相互独立的微服务,各自管理各自的领域实体和价值对象。

后台开发进阶!白话DDD从入门到实践_微服务_08

5)防腐层

当两个边界的上下文相互调用时,需要使用防腐层(ACL)隔离两个限界的上下文,实现value object的转换。避免直接调用不同的上下文,否则一旦调用上下文进行修改,可能会产生很大的影响。实践例子:

实现链路可参考3.4例1,在商品领域,我们的防腐层是按照以下目录实现的, 领域层定义了领域层所需的防腐接口,基础设施层继承并实现了防腐接口,并直接在基础设施层调用了上下文。

productdomainsvr (上下文商品限制)├── domain(领域层)│   ├── aggregate│   │   ├── spu.cpp                        //1)spu领域的对象需要调用其他界限的上下文生成id│   │   └── spu.h│   └── gateway│       └── gen_id_gateway.h         //2)领域层定义调用其他界限上下文生成id的防腐界面├── infrastructure(基础设施层)│   └── gatewayimpl│      └── acl(防腐层)│         ├── gen_id_gateway_impl.cpp //3)基础设施层实现领域层定义的防腐接口,其他上下文的真实调用│         └── gen_id_gateway_impl.h

6)领域事件

除了使用防腐层直接调用外,两个边界的上下文通常通过领域事件解耦。

并不是所有发生在该领域的事情都需要建模为该领域的事件,我们只关注具有商业价值的事情。该领域的一些事情发生在该领域(需要跟踪、希望被通知,并会导致其他模型对象改变状态)。

事实上,领域事件的本质是事件,我们常见的kafka、wq等都可以作为实现领域事件的基础设施。通过领域事件,可以轻松解耦两个极限的上下文

实践例子:

在我们的增值业务中,交易领域的“支付成功”是一个领域事件。计费领域订阅此领域事件,以便根据此事件调整客户的计费资源包实体。

后台开发进阶!白话DDD从入门到实践_基础设施_09

如果这里没有采用领域事件,可以想象, 但交易域直接调用计费域RPC通知交易成功,当其他域需要接受“支付成功”事件时,或计费域调用的接口出现故障。 这将使交易领域陷入麻烦。前者需要交易领域不断堆叠和调用外部RPC代码,使系统不稳定。后者将直接影响计费领域的故障,影响用户交易。

7)实体/值对象

实体是指上下文中唯一可持续变化的基本单元,在其生命周期中可以通过稳定的唯一id来识别。实体以领域对象的形式存在于我们的代码中,具有属性和方法。实体是DDD实现充血编程和解决贫血问题的关键

与实体相对应的是值对象,如果没有唯一的标识就是值对象。值对象通常嵌套在实体中。

实践例子:

商品域的实体和值对象如下

实体

描述

关键值对象

SPU

指上架的服务。

spu_id, spu_type,状态等。

SKU

指具体服务的单项套餐。

sku_id, 规格、价格等。

折扣

定制折扣。

折扣id、折扣类型、折扣比例等。

8)聚合/聚合根

将密切相关的实体放入聚合物中,每个聚合物中都有一个实体作为聚合物根,所有对聚合物的访问都通过聚合物根进行,外部对象只能持有对聚合物根的引用。每个聚合物都可以有一个独立的上下文边界。

聚合物应尽可能小。聚合物只包含聚合物根实体和不可分割的实体,实体只包含最小数量的属性。设计这样的小聚合物有助于拆分后续的微服务。

如果RPC的功能是跨聚合的,则应在应用层中实现跨聚合的安排和协调。

实践例子:

我们可以将以下聚合分为6)中的例子。

聚合

实体

是否是根

聚合1

服务SPU

服务SKU

聚合2

折扣

在底层存储落表上, 以spu实体/折扣实体为表, 在这种聚合建模的指导下,sku实体设计成spu聚合根的一列。

如果要将微服务拆分到最细粒度, 两个聚合物可以根据自己的上下文分为独立的微服务。当然,这种实现并不是DDD强制要求的。我认为有时我们也可以从开发和维护效率的角度来考虑, 把一些相关的小上下文放在一个微服务上。我们选择后者进行商品处理。

9)DTO//领域对象/Data object

当一个请求进入DDD设计的系统时,该请求的形式会根据所在的级别发生以下变化,DTO<->领域对象<->Data object。

DTO是指对外传输的其它服务需要了解的结构,领域对象是指同时包含属性和方法的领域实体包装,Data object是真正用于最终存储的数据结构。

高级后台开发!白话DDD从入门到实践_微服务_10

事实上,这里很容易发现,尽管DTO的存在与其他调用方一致至少知识原则(LKP),但即使是最简单的查询要求也需要进行这三个层次的转换,这无疑会加剧开发的复杂性,成为设计模式和设计模式。

至少知识原则(迪米特法则),LKP):软件实体应尽可能少地与其他实体相互作用。这里的软件实体是一个广义的概念,不仅包括对象,还包括系统、类别、模块、函数、变量等。

因此,DDD通常在这里使用CQRS(读写责任分离)架构,以确保由于领域建模,一些简单的查询请求不会变得太复杂。CQRS(读写责任分离)基于CQS(读写分离),CQRS的DDD对象转换流程如下:

后台开发进阶!白话DDD从入门到实践_值对象_11

实践例子:

我们的实现是在现场对象中包装转换的convert函数(当然,convert方法也可以在基础设施层中拆分进行单独包装),用于将DTO转换为现场对象,或者将现场对象转换为DO。以下是我们明细域的实际转换代码和转换过程。

//1.classs定义领域对象的convert方法 DetailRecord {public:    int ConvertFromDTO(const google::protobuf::Message& oDto);    int ConvertToDO(detailrecordinfrastructure::DetailRecordDO & oDo);    /*...*/};//2.将DTO转换为领域对象的应用层调用方法, 然后调用存储接口进行持久int DetailrecordApplication::InsertDetailRecord(unsigned int head_uin, const InsertDetailRecordReq& req,  InsertDetailRecordResp* resp) {   int iRet = 0;    class DetailRecord oRecord;       iRet = oRecord.ConvertFromDTO(req); ///生成领域对象,可同时使用领域对象的方法进行自检等操作    /*...*/   iRet = m_oDetailRecordGateway->Save(oRecord); ///调用存储接口进行持久性    /*...*/   return iRet;}//3.将领域对象在仓储中转化为Dataobject,进行落存操作,并发布int领域事件 DetailRecordGatewayImpl::Save(DetailRecord & oEntity){   detailrecordinfrastructure::DetailRecordDO oDo;   int iRet = oEntity.ConvertToDO(oDo);    /*...*/   iRet = oKvMapper.insert(oDo);      //实际存储    /*...*/   iRet = oEventMapper.publish(oDo);  //发送领域事件     /*...*/   return iRet;}

10)仓储

仓储它是一个定义界面的领域层,它抽象了业务逻辑中实体访问(包括读取和存储)的技术细节。它的作用是隔离特定的存储层技术,以确保业务逻辑的稳定性。请注意,存储只是界面的定义是在领域层,但它的实现是在基础设施层

仓库不是数据库Dao!!!!

仓库不是数据库Dao!!!!

仓库不是数据库Dao!!!!

重要的事情说三遍,仓储是从业务逻辑的角度抽象出来的接口,所以仓储的接口在实现上,一般情况下,一个聚合对应于一个仓库来实现,仓库需要使用领域对象作为参数。仓储接口的命名也可以以save命名,这是一个更业务的名称, 避免传统dao的insert/set等。

实践例子:

通过3.9的例子,我们可以发现存储用于持久界面,不仅包括写kv操作,还包括发布领域事件,这是因为存储是从业务逻辑的角度抽象界面,领域层只需要了解业务操作,而不是理解存储、发布领域事件等具体流程。

//1.领域层定义DetailRecord存储的接口class DetailRecordGateway {    public:       /*...*/       virtual int Save(DetailRecord & oEntity) = 0;       /*...*/};//2.基础设施层继承领域层的存储接口实现class DetailRecordGatewayImpl : public DetailRecordGateway {    public:       /*...*/       virtual int Save(DetailRecord & oEntity);       /*...*/ };/3.仓储save接口实现intte DetailRecordGatewayImpl::Save(DetailRecord & oEntity){   detailrecordinfrastructure::DetailRecordDO oDo;   int iRet = oEntity.ConvertToDO(oDo);   /*...*/   iRet = oKvMapper.insert(oDo);      //实际存储   /*...*/     iRet = oEventMapper.publish(oDo);  //发布领域事件    /*...*/   return iRet;}

11)领域服务

当某些能力不适合在某个领域的对象中实现时,它们不应该在应用层中实现,因为它们太复杂了。这些操作可以包装成领域服务的中间方法,具体的业务功能可以通过应用层安排领域对象和领域服务方法来完成。

4.代码脚手架DDD

基于对DDD的理解和WXG的svrkit框架,我们设置了我们的代码脚手架。脚手架目录如下。我希望你能为那些想一起练习的同事做出贡献。欢迎与我们讨论:

项目目录├── adapter(物理用户界面模块)├── domainsvr(领域微服务)│   ├── detailrecorddomainsvr(明细域微服务)│   │   ├── adapter(用户界面层)│   │   ├── application(应用层)│   │   │   ├── detailrecord_application.cpp(应用层法)│   │   ├── domain(领域层)│   │   │   ├── aggregate(聚合根)│   │   │   │   ├── detail_record.cpp(领域对象)│   │   │   │   └── detailrecordaggregate.proto(聚合根的值对象)│   │   │   ├── entity(非根实体)│   │   │   │   └── detailrecordentity.proto(非根实体的值对象)│   │   │   ├── gateway│   │   │   │   └── detail_record_gateway.h(仓储接口)│   │   │   └── detailrecord_domain_service.cpp(领域服务)│   │   ├── infrastructure(基础设施层)│   │   │   ├── gatewayimpl│   │   │   │   ├── acl(实现防腐层)│   │   │   │   └── detail_record_gateway_impl.cpp(仓储实现)│   │   │   └── detailrecordinfrastructure.proto(Data object定义)│   │   └── detailrecord.proto(DTO定义)└── infrastructuresvr(物理基础设施模块)