cover_image

DDD在经销商的应用

柏刚 之家技术
2022年09月23日 10:00

关注“之家技术”,获取更多技术干货

图片

总篇170篇 2022年第45篇

1

为什么要用?

最近几年随着微服务的流行,DDD(领域驱动设计)被提到的次数比以前变得更多,但是相信很多人还是有个疑问:用现有方式写代码也都把各种需求实现了,为什么还要用DDD呢?我们可以先来看一个需求的例子。

需求:顾问名如果传的是英文名

新增时随机生成一个中文名保存下来

修改时不再更新,保留新增时生成的中文名


事务脚本的方式,现有代码如下:



图片

图片

图片

图片

图片

图片

改动涉及十几个地方,而且看起来都是差不多的重复代码,而如果是用领域驱动的方式:


图片

图片


只需要改这两个地方。

两种方式在应对同一个需求的时候,工作量的差距是显而易见的。那DDD适用什么样的场景呢?其实提出DDD那本书的副标题已经指出了:软件核心复杂性应对之道。现有方式和DDD在随着需求复杂度上升的时候,随之而来的维护成本变化趋势大概可以用下图来表示:


图片


就是说,需求越复杂,使用DDD的收益越高。当产品只提出一个逻辑修改,而开发评估需要几十人日的时候,可能就该考虑是否应该用DDD来降低重复度,提高可维护性和效率了。

业务复杂怎么界定?没有明确的统一标准,可以看看是否有以下情形:

1.业务不断变化,变化不可预期;

2.不了解所要处理的领域。


2

 是什么?


2.1

含义

刚开始看到领域驱动设计这个词,可能不太好理解。我们先从字面上看看,驱动在这里是动词,“领域”来驱动设计,这样看关键就在于理解“领域”这个词。领域其实就是业务的意思,比如汽车之家,既涉及汽车领域,也涉及互联网领域。业务内部又可以逐级细分,比如经销商这边又可以分i车商领域、营销活动领域。营销活动业务内部又可以分礼品领域、活动领域等等。


图片




    2.2

相关概念

为了方便沟通,DDD也提出了一些名词概念,主要的概念如下图:


图片



主要可以分为两大部分,一部分是领域、子域、限界上下文的划分,着眼于较大的层面,类似于战略设计。最近几年DDD被提起的多了,就是因为微服务的流行,在微服务的划分方式上,大多倾向于按业务划分,正好契合了DDD的战略设计部分。另一部分是实体、值对象、聚合根、工厂、仓储、领域服务,着眼于具体代码实现,类似于战术设计。而通用语言是贯穿全局的。


2.2.1通用语言:一个团队,一种语言

通用语言不是说大家都说普通话,而是说一个业务概念应该有一个唯一通用的命名,在整个团队内业务、运营、产品、开发、测试通用,不管是口头沟通,还是需求文档,都使用这一个通用的命名。为了方便口头沟通,就像我们的姓名一样,每个概念命名最好不要超过4个字。为了让大家更好理解命名代指的概念,这个命名应该有一定含义,至少部分表达概念的意思。而且一个业务内也经常会有相似的概念,这些命名在同一组概念下应该能清晰地互相区分开。如:营销活动的礼品就有场次礼品、车展礼品、单店礼品。


►2.2.2 限界上下文:有显式的边界

同一个名词在不同领域有不同的含义,如:素材,在营销活动和松鼠平台完全是两个不同的概念。为了能区分开,各个子业务间就需要有一个显式的边界,将各个子业务划分成一个个限界上下文。


►2.2.3 实体&值对象

实体很好理解,咱们平时用到和表对应的那些类就是实体。至于值对象,可以先看一个需求的例子。

需求:

在工作时间段内,按工作时接听类型

在休息时间段内,按休息时接听类型

事务脚本的方式实现:




图片

图片

图片


接听方式的设置分工作时间和休息时间两种,而这两种设置字段是一样的,都是开始、结束时间和手机/座机接听,这种情况就可以把接听方式作为一个值对象来实现:


图片

图片

图片

图片


可以看出,值对象和实体很类似,都是领域概念中名词对应的类。而区别在于,实体具有唯一性和可变性。唯一性是指实体有唯一id,判断是否同一个对象用id判断。可变性是指实体创建后,属性值是可以修改的,可以提供一些修改自身属性值的update方法,如第一个例子中的updateSalesName。而相对应的,值对象通常不具有唯一性,没有唯一id,不可变,需要修改某个属性时,直接创建一个新的对象。

什么时候适合使用值对象呢?如果两个对象的所有的属性的值都相同,我们会认为它们是同一个对象的话,那么我们就可以把这种对象设计为值对象。


2.2.4 聚合&聚合根

聚合是我们平时经常在打交道的,就是一个复杂的对象,由多个有层级关系的类组成。以前没有一个合适的通用名称,DDD里叫做聚合。先看一个需求的例子——

需求:

一个组下有两个及以上店铺就是——迁入

只有一个店铺就是——迁出

图片


有多处位置都需要判断这个逻辑,用现有方式实现,虽然是一个看起来很简单的逻辑,但是却出现了3处bug,因为更新到数据库和逻辑是混在一起的,在更新前判断和在更新后判断是不一样的,更新前可能应该按>0判断,更新后可能就应该按>1判断了。一个简单的逻辑实际代码写起来却很容易出问题。按现有方式实现代码如下:

(1)切换数据源时:

如果组是迁入状态,不允许切换数据源;



图片

图片


(2)添加店铺时:

这个组的 状态 要变,

这个组下的所有店铺的 状态 都要变;


图片


这里只修改了当前店铺,漏了对该组下其他店铺的修改。

(3) 店铺换组时:

原组的 状态 要变,

原组下的所有店铺的 状态 都要变,

新组的 状态 要变,

新组下的所有店铺的 状态 都要变;


图片


而如果把逻辑封装到聚合

(2)添加店铺时:


图片

图片


(3)店铺换组时:


图片

图片


代码就和业务逻辑一样简单,也不需要多处去写重复代码,出错的可能性就小多了。

图片


这些概念之间的关系大概如上图,一个领域可以划分多个限界上下文,一个限界上下文里可以有多个聚合,聚合不会跨多个限界上下文。一个聚合只有一个聚合根,可以有多个实体和值对象,也可以有多个层级。


►2.2.5 工厂

如果一个service类作用是创建一个聚合、实体,就适合命名为工厂。所以工厂不是必须的,只是遵循单一职责原则,给这种service类一个合适的名称。如:

图片

►2.2.6 仓储

仓储最容易理解的方式是视为对象数据库,负责聚合的存取,save将整个聚合保存,get时将整个聚合取出。如:

图片


►2.2.7 领域服务

为了实现对其它层屏蔽领域对象,需要有一些对象转换的代码放在领域层,有时候还有一些操作多个领域对象的代码,这些代码需要放在领域层的service中,这些service就叫做领域服务。如:

图片


    2.3

区别

现在我们常用的方式可以叫做数据库驱动,因为我们拿到需求后可能先想到的就是如何设计数据库表。而DDD需要先考虑的是有哪些实体、聚合,实体之间的关系是一对一、一对多还是多对多。咋一看好像区别不大,因为大部分情况下实体和数据库表是一一对应的。可以看下面一个例子:

图片


经销商实体和车系在表里是这样建立关系的,一个实体有多个店铺,一个店铺可以发布多款车型报价,多个车型属于一个车系。而从实体间的关系来看,经销商实体和车系之间就是多对多的关系:

图片

换到营销活动的场景,更多关注的是实体售卖哪些车系,于是在这个聚合里就有了一个经销商车系的概念,一个实体可以售卖多个经销商车系:


图片

在分层上DDD也和传统三层架构有一定区别:


图片


业务逻辑都在领域层实现:



图片

图片

图片

图片


仓储在基础设施层,使用仓储就像使用对象数据库:


图片

图片

图片


相信有很多人对这种写法会有一个疑问:性能是否会变差?

答案是会,因为增加了数据库读写操作,但是:

1.可以通过ORM工具的跟踪变化、延迟加载来尽量避免;

2. 优化性能的关键在于找到瓶颈,大部分情况下多一两次简单的数据库操作不会成为性能瓶颈。

实际DDD分层用六边形架构(也叫端口与适配器架构、洋葱架构)描述更合适,将领域模型封装在领域层,通过适配器与外部交互:


图片


现有方式的实体类大多是贫血模型,只有get、set,没有逻辑。而DDD建议的是实体都要有各自的行为方法,就是对应的充血模型,如:


图片


以及第一个例子中的:


图片

       

为什么要将逻辑尽量封装到实体类中呢?可以看一个需求的例子:

原需求:一个顾问在一个机构下只能有一个机构顾问;

新需求:一个顾问在一个机构下可以有多个机构顾问;

未封装到机构顾问中时,实现这个新需求需要改3处代码,而这3处代码分散在不同的类中,实际改的时候就改漏了一处。


图片

图片

图片


而如果把这个简单的判断逻辑封装到机构顾问中,实现这个需求的时候就只需要改这一个方法,增加了入参,调用这个方法的地方编辑器就会帮忙找出来,也不用担心会改漏了:


图片

图片


就是一个简单的相等判断的逻辑,相信大家在实际写代码中也有很多类似的逻辑是按没有封装的方式实现的。从这个例子可以看出,封装不仅减少了重复代码,提高了应对需求变化时的效率,而且降低了出bug的可能性,提高了质量。

至于一个方法适合移到哪个实体类中,可以按照重构的原则:一个方法适合放到用到数据最多的类中。


3

怎么做?


那怎么用DDD来进行开发呢?首先要在团队内建立通用语言,根据需求进行事件风暴,将业务流梳理出来,然后每个步骤参与的角色、产生的事件、涉及的名词概念一一整理出来,如:

图片


然后梳理这些名词间的关系,就是领域建模,如:


图片


再根据关联紧密程度划分限界上下文,如:


图片


在实现代码的时候将业务逻辑放在领域层中,如:


图片


在实现实体类的时候避免贫血模型,如:


图片

最后,也可以视需求复杂程度全部或部分使用DDD。如我们工作中经常会遇到数据推送,推送的数据很可能是一个多层结构的复杂对象,绝大部分情况就适合作为一个聚合来对待。

图片


类似这样的,推送方只需要关注整个聚合是否发生变化,至于是活动变了还是车系变了,车系是增加了/修改了/减少了,不用过度关注这些细节,都视为聚合发生变化即可,可以大大简化推送方的实现复杂度。而接收方也只需要将聚合整个保存即可,通常不会增加复杂度。

产品、测试也可以用领域建模思想来分析一些复杂的场景,可以看一个需求的例子:

活动车系 -> 语音版本1(线上露出)

-> 活动审核(运营) -> 语音2(等待运营审核活动)

-> 语音审核(商务) -> 语音3(等待商务审核语音)

本来一个活动车系只会有一份语音,但是因为需要两次审核,且审核中的数据不能影响原线上数据的露出,所以一个活动车系最多会同时存在3份语音。而这3份语音以及4个审核状态需要在下面两个页面显示,就变得比较复杂了。


图片
图片


产品在整理这个需求的时候就很难理清,什么地方应该怎么取,场景有几十个。

而我们如果用领域驱动的思想来看这个需求,把活动和语音看成两个聚合,他们在审核中的数据作为一份数据copy,事情就变得简单多了。在整理上述需求场景的时候应该取哪个聚合,也变得清晰了很多,对产品整理需求、测试理解需求都有一定帮助:

图片

最后,借用一句网上的总结:DDD不是一个特殊的架构设计,而是所有Transction Script(事务脚本)代码经过合理重构后一定会抵达的终点。

 

参考文献


[1]《领域驱动设计:软件核心复杂性应对之道》-Eric.Evans

[2]《实现领域驱动设计》-Vaughn Vernon


作者简介

图片


柏刚

 经销商技术部,营销活动团队。

 2015年加入汽车之家,主要负责营销活动服务端开发,对领域驱动设计,提升研发效率、代码质量颇有兴趣。


图片

阅读更多:


▼ 关注「之家技术」,获取更多技术干货 


图片



修改于2022年09月23日
继续滑动看下一个
之家技术
向上滑动看下一个