领域驱动设计

一、了解DDD

​ Eric Evans的“Domain-Driven Design领域驱动设计”简称DDD,Evans DDD是一套综合软件系统分析和设计的面向对象建模方法。

​ 定义:DDD是一种以领域为核心的设计和开发理念。领域驱动设计(Domain-driven design 缩写DDD)不是一种技术或者方法,DDD提供了一种实践性的指导原则,用来解决和加速处理复杂领域的软件项目。DDD通过维护一个深度反应领域概念的模型,以及提供了可行的经过实践检验的大量模式来应对领域的复杂性

过去系统分析和系统设计都是分离的,正如我们国家“系统分析师” 和“系统设计师” 两种职称考试一样,这样割裂的结果导致,需求分析的结果无法直接进行设计编程,而能够进行编程运行的代码却扭曲需求,导致客户运行软件后才发现很多功能不是自己想要的,而且软件不能快速跟随需求变化。

DDD则打破了这种隔阂,提出了领域模型概念,统一了分析和设计编程,使得软件能够更灵活快速跟随需求变化。见下面DDD与传统CRUD或过程脚本或者面向数据表等在开发效率上比较:

在这里插入图片描述

​ 开发人员在使用Spring应用是非常擅长谈论依赖注入的好处。不幸的是,他们不是那么真的利用它的好处,如单一职责原则,分离关注原则。如果我们一起来看看大部分Spring的Web应用程序,常见的错误的设计如下:

1.领域模型对象用来存储应用的数据(当作DTO使用),领域模型是贫血模型这样的反模式。

2.服务层每个实体有一个服务。

问题是这样很普遍,错误在哪里呢?

​ Spring的web应用程序之所以这样是因为他们做事物的方式一直都是这样做的,老习惯难改,特别是如果他们是高级开发人员或软件架构师,这些人捍卫这样做的论据之一是:我们的应用程序遵循关注分离的原则,因为它已经被分为若干层,每个层有自己的特定职责。

  1. Web层负责处理用户输入,并返回正确的响应返回给用户。 web层与服务层通信。

    	2. 服务层作为一个事务边界。它也负责授权和包含我们的应用程序的业务逻辑。服务层管理的域模型对象,并与其他服务和存储库层进行通信。
            		3. 存储库/数据访问层负责与所使用的数据的存储进行通信。
    

​ 分离关注(Soc)是分离计算机程序为不同的部分,每个部分有一个关注聚焦,一个典型的Spring Web应用在一定程度上遵循这一原则,但现实是,该应用程序有一个整体的服务层,它有太多的责任。更具体地,服务层有两个主要问题:

​ 1.在服务层发现业务逻辑

​ 业务逻辑被分散在各个服务层。如果我们需要检查一个业务规则是如何实现的,我们必须先找到它。这可能并不容易。此外,如果相同的业务规则需要在多个服务类,问题是,规则需要从一个服务到另一个简单地复制。这将导致维护的噩梦。

​ 2.每个领域模型一个服务

​ 这完全违反了单一职责原则,它被定义为如下:单一职责原则指出,每一个类都应该有一个责任,责任应该由类完全封装。其所有的服务应该狭义与责任相一致。(不应将原属于领域模型的行为方法等划放在服务中实现,对象不但有属性还有行为)

​ 提倡充血模型,实际就是让过去被肢解被黑crack的业务模型回归正常,当然这也会被一些先入为主或被洗过脑的程序员看成反而不正常,这更是极大可悲之处。

​ DDD最大的好处是:接触到需求第一步就是考虑领域模型。而不是将其切割成数据和行为,然后数据用数据库实现,行为使用服务实现,最后造成需求的首肢分离。DDD让你首先考虑的是业务语言,而不是数据。重点不同导致编程世界观不同。

​ DDD革命性在于:领域模型准确反映了业务语言,而传统J2EE或Spring+Hibernate等事务性编程模型只关心数据,这些数据对象除了简单setter/getter方法外,没有任何业务方法,被比喻成失血模型,那么领域模型这种带有业务方法的充血模型到底好在哪里?

​ 以比赛为案例,比赛有“开始”和“结束”等业务行为,但是传统经典的方式是将“开始”和“结束”行为放在比赛的服务Service中,而不是放在比赛对象本身之中。我们不能因为用了计算机,用了数据库,用了框架,业务模型反而被技术框架给绑架,就像人虽然是由母亲生的,但是人的吃喝拉撒母亲不能替代,更不能以母爱名义肢解人的正常职责行为,如果是这样,这个人就是被母爱绑架了。

在这里插入图片描述

​ 提倡充血模型,实际就是让过去被肢解被黑crack的业务模型回归正常,当然这也会被一些先入为主或被洗过脑的程序员看成反而不正常,这更是极大可悲之处。看到领域模型代码,就看到业务需求,没有翻译没有转换,保证软件真正实现“拷贝不走样”。

DDD最大的好处是:接触到需求第一步就是考虑领域模型,而不是将其切割成数据和行为,然后数据用数据库实现,行为使用服务实现,最后造成需求的首肢分离。DDD让你首先考虑的是业务语言,而不是数据。重点不同导致编程世界观不同。

​ DDD是解决复杂中大型软件的一套行之有效方式,在国外已经成为主流。DDD认为很多原因造成软件的复杂性,我们不可能避免这些复杂性,能做的是对复杂的问题进行控制。而一个好的领域模型是控制复杂问题的关键。领域模型的价值在于提供一种通用的语言,使得领域专家和软件技术人员联系在一起,沟通无歧义。

DDD在软件生产流程中定位i如下图,DDD落地实现离不开in-memory缓存、 CQRS、 DCI、 EDA或Event Source几大大相关领域。

二、DDD组成:

通用语言:

让领域专家和技术人员都能听懂的语言,让团队在交流时能达成一致。

在这里插入图片描述

在这里插入图片描述


实体(Entity)

  1. 对象不是由属性定义的,而是标志定义的(id)
  2. 对象内容的变化不会影响标识符
  3. 无论保存到硬盘还是装入内存或者通过网络发送,标识符都不变
  4. 在DDD中十个充血模型,对象有丰富的行为

实体就是领域中需要唯一标识的领域概念。因为我们有时需要区分是哪个实体。有两个实体,如果唯一标识不一样,那么即便实体的其他所有属性都一样,我们也认为他们两个不同的实体;因为实体有生命周期,实体从被创建后可能会被持久化到数据库,然后某个时候又会被取出来。所以,如果我们不为实体定义一种可以唯一区分的标识,那我们就无法区分到底是这个实体还是哪个实体。
比如Customer实体,他有一些地址信息,由于地址信息是一个完整的有业务含义的概念,所以,我们可以定义一个Address对象,然后把Customer的地址相关的信息转移到Address对象上。如果没有Address对象,而把这些地址信息直接放在Customer对象上,并且如果对于一些其他的类似Address的信息也都直接放在Customer上,会导致Customer对象很混乱,结构不清晰,最终导致它难以维护和理解.


值对象(Entity)

  1. 对象是根据值来确定的
  2. 可以在不同的实体中使用
  3. 值对象通常是不可变的
  4. Color,Point,Money、Address

在领域中,并不是没一个事物都必须有一个唯一标识,也就是说我们不关心对象是哪个,而只关心对象是什么。就以上面的地址对象Address为例,如果有两个Customer的地址信息是一样的,我们就会认为这两个Customer的地址是同一个。也就是说只要地址信息一样,我们就认为是同一个地址。用程序的方式来表达就是,如果两个对象的所有的属性的值都相同我们会认为它们是同一个对象的话,那么我们就可以把这种对象设计为值对象。因此,值对象没有唯一标识,这是它和实体的最大不同。另外值对象在判断是否是同一个对象时是通过它们的所有属性是否相同,如果相同则认为是同一个值对象;而我们在区分是否是同一个实体时,只看实体的唯一标识是否相同,而不管实体的属性是否相同;值对象另外一个明显的特征是不可变,即所有属性都是只读的。因为属性是只读的,所以可以被安全的共享;当共享值对象时,一般有复制和共享两种做法,具体采用哪种做法还要根据实际情况而定;另外,我们应该给值对象设计的尽量简单,不要让它引用很多其他的对象,因为他只是一个值,就像int a = 3;那么”3”就是一个我们传统意义上所说的值,而值对象其实也可以和这里的”3”一样理解,也是一个值,只不过是用对象来表示。所以,当我们在C#语言中比较两个值对象是否相等时,会重写GetHashCode和Equals这两个方法,目的就是为了比较对象的值;值对象虽然是只读的,但是可以被整个替换掉。就像你把a的值修改为”4”(a = 4;)一样,直接把”3”这个值替换为”4”了。值对象也是一样,当你要修改Customer的Address对象引用时,不是通过Customer.Address.Street这样的方式来实现,因为值对象是只读的,它是一个完整的不可分割的整体。我们可以这样做:Customer.Address = new Address(…);


领域服务(Domain Services)

  1. 有些领域逻辑是动词,标识了一种重要的行为很难映射为对象,无法归结到实体和值对象中
  2. 例如转账

在这里插入图片描述

特征:

1. 服务之星的操作涉及一个领域概念,这个领域概念通常不属于一个实体或者值对象。
2. 被执行的操作涉及到领域中的其他对象
3. 操作是无状态的

领域中的一些概念不太适合建模为对象,即归类到实体对象或值对象,因为它们本质上就是一些操作,一些动作,而不是事物。这些操作或动作往往会涉及到多个领域对象,并且需要协调这些领域对象共同完成这个操作或动作。如果强行将这些操作职责分配给任何一个对象,则被分配的对象就是承担一些不该承担的职责,从而会导致对象的职责不明确很混乱。但是基于类的面向对象语言规定任何属性或行为都必须放在对象里面。所以我们需要寻找一种新的模式来表示这种跨多个对象的操作,DDD认为服务是一个很自然的范式用来对应这种跨多个对象的操作,所以就有了领域服务这个模式。和领域对象不同,领域服务是以动词开头来命名的,比如资金转帐服务可以命名为MoneyTransferService。当然,你也可以把服务理解为一个对象,但这和一般意义上的对象有些区别。因为一般的领域对象都是有状态和行为的,而领域服务没有状态只有行为。需要强调的是领域服务是无状态的,它存在的意义就是协调领域对象共完成某个操作,所有的状态还是都保存在相应的领域对象中。我觉得模型(实体)与服务(场景)是对领域的一种划分,模型关注领域的个体行为,场景关注领域的群体行为,模型关注领域的静态结构,场景关注领域的动态功能。这也符合了现实中出现的各种现象,有动有静,有独立有协作。

领域服务还有一个很重要的功能就是可以避免领域逻辑泄露到应用层。因为如果没有领域服务,那么应用层会直接调用领域对象完成本该是属于领域服务该做的操作,这样一来,领域层可能会把一部分领域知识泄露到应用层。因为应用层需要了解每个领域对象的业务功能,具有哪些信息,以及它可能会与哪些其他领域对象交互,怎么交互等一系列领域知识。因此,引入领域服务可以有效的防治领域层的逻辑泄露到应用层。对于应用层来说,从可理解的角度来讲,通过调用领域服务提供的简单易懂但意义明确的接口肯定也要比直接操纵领域对象容易的多。


聚合及聚合根(Aggregate,Aggregate Root)

1. 聚合表示逻辑上联系很紧密的对象,每个聚合都有一个根,有一个边界
2. 聚合根控制了对这个聚合内所有的对象的访问,外部想要内部对象,必须通过聚合根才行

聚合是通过定义领域对象之间清晰的所属关系以及边界来实现领域模型的内聚,以此来避免形成错综复杂的、难以维护的对象关系网。聚合定义了一组具有内聚关系的相关领域对象的集合,我们可以把聚合看作是一个修改数据的单元。

聚合根属于实体对象,它是领域对象中一个高度内聚的核心对象。(聚合根具有全局的唯一标识,而实体只有在聚合内部有唯一的本地标识,值对象没有唯一标识,不存在这个值对象或那个值对象的说法)

若一个聚合仅有一个实体,那这个实体就是聚合根;但要有多个实体,我们就要思考聚合内哪个对象有独立存在的意义且可以和外部领域直接进行交互。
在这里插入图片描述


工厂(Factory)

在这里插入图片描述

DDD中的工厂也是一种封装思想的体现。引入工厂的原因是:有时创建一个领域对象是一件相对比较复杂的事情,而不是简单的new操作。工厂的作用是隐藏创建对象的细节。事实上大部分情况下,领域对象的创建都不会相对太复杂,故我们仅需使用简单的构造函数创建对象就可以。隐藏创建对象细节的好处是显而易见的,这样就可以不会让领域层的业务逻辑泄露到应用层,同时也减轻应用层负担,它只要简单调用领域工厂来创建出期望的对象就可以了。


仓储(repository)

仓储被设计出来的目的是基于这个原因:领域模型中的对象自从被创建出来后不会一直留在内存中活动的,当它不活动时会被持久化到数据库中,然后当需要的时候我们会重建该对象;重建对象就是根据数据库中已存储的对象的状态重新创建对象的过程;所以,可见重建对象是一个和数据库打交道的过程。从更广义的角度来理解,我们经常会像集合一样从某个类似集合的地方根据某个条件获取一个或一些对象,往集合中添加对象或移除对象。也就是说,我们需要提供一种机制,可以提供类似集合的接口来帮助我们管理对象。仓储就是基于这样的思想被设计出来的;
仓储里面存放的对象一定是聚合,原因是之前提到的领域模型中是以聚合的概念去划分边界的;聚合是我们更新对象的一个边界,事实上我们把整个聚合看成是一个整体概念,要么一起被取出来,要么一起被删除。我们永远不会单独对某个聚合内的子对象进行单独查询或做更新操作。因此,我们只对聚合设计仓储。
仓储还有一个重要的特征就是分为仓储定义部分和仓储实现部分,在领域模型中我们定义仓储的接口,而在基础设施层实现具体的仓储。这样做的原因是:由于仓储背后的实现都是在和数据库打交道,但是我们又不希望客户(如应用层)把重点放在如何从数据库获取数据的问题上,因为这样做会导致客户(应用层)代码很混乱,很可能会因此而忽略了领域模型的存在。所以我们需要提供一个简单明了的接口,供客户使用,确保客户能以最简单的方式获取领域对象,从而可以让它专心的不会被什么数据访问代码打扰的情况下协调领域对象完成业务逻辑。这种通过接口来隔离封装变化的做法其实很常见。由于客户面对的是抽象的接口并不是具体的实现,所以我们可以随时替换仓储的真实实现,这很有助于我们做单元测试。
尽管仓储可以像集合一样在内存中管理对象,但是仓储一般不负责事务处理。一般事务处理会交给一个叫“工作单元(Unit Of Work)”的东西。关于工作单元的详细信息我在下面的讨论中会讲到。
另外,仓储在设计查询接口时,可能还会用到规格模式(Specification Pattern),我见过的最厉害的规格模式应该就是LINQ以及DLINQ查询了。一般我们会根据项目中查询的灵活度要求来选择适合的仓储查询接口设计。通常情况下只需要定义简单明了的具有固定查询参数的查询接口就可以了。只有是在查询条件是动态指定的情况下才可能需要用到Specification等模式。

三、模型的分裂

在这里插入图片描述

四、CQRS

在这里插入图片描述

五、如何让DDD落地

​ DDD 概念理解起来有点抽象, 这个有点像设计模式,感觉很有用,但是自己开发的时候又不知道怎么应用到代码里面,或者生搬硬套后自己看起来都很别扭,那么接下来我们就以一个简单的转盘抽奖案例来分析一下 DDD 的应用。

1、针对功能层面划分边界

这个系统可以划分为运营管理平台和用户使用层,运营平台对于抽奖的配置比较复杂但是操作频率会比较低。而用户对抽奖活动页面的使用是高频率的但是对于配置规则来 说是误感知的,根据这样的特点,我们把抽奖平台划分针 对 C 端抽奖和 M 端抽奖两个子域。

在确认了 M 端领域和 C 端的限界上下文后,我们再对各 自上下文内部进行限界上下文的划分,接下来以 C 端用户为例来划分界限上下文。

2、确认基本需求

首先我们要来了解该产品的基本需求

  1. 抽奖资格(什么情况下会有抽奖机会、抽奖次数、抽 奖的活动起始时间) 。
  2. 抽奖的奖品(实物、优惠券、理财金、购物卡…) 。
  3. 奖品自身的配置,概率、库存、某些奖品在有限的概率下还只能被限制抽到多少次等。
  4. 风控对接, 防止恶意薅羊毛。
3、针对产品功能划分边界

在这里插入图片描述

​ 抽奖上下文是整个领域的核心,负责处理用户抽奖的核心业务。

  1. 对于抽奖的限制,我们定义了抽奖资格的通用语言,将抽奖开始 / 结束时间,抽奖可参与次数等限制条件都收拢到抽奖资格子域中。
  2. 由于 C 端存在一些刷单行为,我们根据产品需求定义了风控上下文,用于对抽奖进行风控。
  3. 由于抽奖和发放奖品其实可以认为是两个领域,一个负责根据概率去抽奖、另一个负责将抽中的奖品发放出去,所以对于这一块也独立出来一个领域。
4、细化上下文

​ 通过上下文划分以后,我们还需要进一步梳理上下文之间的关系,梳理的好处在于:

  1. 任务更好拆分(一个开发人员可以全身心投入到相关子域的上下文中) 。
  2. 方便沟通,明确自身上下文和其他上下文之间的依赖关 系,可以实现更好的对接。
5、代码设计

​ 在实际开发中,我们一般会采用模块来表示一个领域的界 限上下文,比如:

  • 抽奖上下文:com.hafiz.business.lottery.*
  • 风控上下文:com.hafiz.business.riskcontroller.*
  • 奖品上下文:com.hafiz.business.prize.*
  • 活动资格上下文 : com.hafiz.business.qualification.*
  • 库存上下文:com.hafiz.business.stock.*

​ 对于模块内的组织结构,一般情况下我们是按照领域对象、 领域服务、领域资源库、防腐层等组织方式定义的。

  • 领域对象-值对象: com.hafiz.business.lottery.domain.valobj.*

  • 领域对象-实体: com.hafiz.business.lottery.domain.entity.*

  • 领域对象-聚合根: com.hafiz.business.lottery.domain.aggregate.*

  • 领域服务: com.hafiz.business.lottery.domain.service.*

  • 领域资源库: com.hafiz.business.lottery.domain.repo.*

  • 领域防腐层:com.hafiz.business.lottery.domain.facade.*

    防腐层就是把外界的概念翻译成本上下文的对象。

部分代码如下:

抽奖聚合根:

​ 拥有抽奖活动id和该活动下所有可用的奖池列表,它最主要的领域功能是根据一个抽奖的场景(DrawLotteryContext),通过chooseAwardPool方法筛选出一个匹配的奖池。

package com.hafiz.business.lottery.domain.aggregate;
import ...;
public class DrawLottery {    
    private int lotteryId; // 抽奖id    
    private List<AwardPool> awardPools; // 奖池列表    
                                                                                         
    public void setLotteryId(int lotteryId) {        
        if (lotteryId < 0) {            
            throw new IllegalArgumentException("非法的抽奖id");        
        }        
        this.lotteryId = lotteryId;    
    }    
    
    public AwardPool chooseAwardPool(DrawLotteryContext context) {        ...    }}

奖池值对象:

package com.hafiz.business.lottery.domain.valobj;

import ...;

public class AwardPool {
    private String cityIds; // 奖池支持的城市
    private String scores; // 奖池支持的得分
    private int userGroupType; // 奖池匹配的用户类型
    private List<Award> awards; // 奖池中包含的奖品

    public boolean matchedCity(int cityId) {
        ...
    }

    public boolean matchedScore(int score) {
        ...
    }

    public Award randomGetAward() {
        int sumOfProbablity = 0;
        for (Award award : awards) {
            sumOfProbablity += award.getAwardProbablity();
        }
        int randomNumber = ThreadLocalRandom.current().netInt(sumOfProbablity);
        int range = 0;
        for (Award award : awards) {
            range += award.getAwardProbablity();
            if (randomNumber < range) {
                return award;
            }
        }
        return null;
    }
}

抽奖资源库:

​ 我们屏蔽对底层奖池及奖品的直接访问,仅对抽奖的聚合根资源进行管理。

package com.hafiz.business.lottery.domain.repo;

import ...;

public class DrawLotteryRepository {
    @Autowried
    private AwardDao awardDao;
    @Autowried
    private AwardPoolDao awardPoolDao;
    @Autowried
    private DrawLotteryCacheAccessObj drawLotteryCacheAccessObj;

    public DrawLottery getDrawLotteryById(int lotteryId) {
        DrawLottery drawLottery = drawLotteryCacheAccessObj.get(lotteryId);
        if (drawLottery != null) {
            return drawLottery;
        }
        drawLottery = getDrawLotteryFromDB(lotteryId);
        drawLotteryCacheAccessObj.add(lotteryId, drawLottery);
        return drawLottery;
    }

    private DrawLottery getDrawLotteryFromDB() {
        ...
    }
}

防腐层:

​ 以用户信息防腐层为例,它的入参是抽奖请求参数(LotteryContext),输出为城市信息(CityInfo)。

package com.hafiz.business.lottery.domain.facade;

import ...;

public class UserCityInfoFacade {
    @Autowried
    private CityService cityService;
    public CityInfo getCityInfo (LotteryContext context) {
        CityRequest request = new CityRequest();
        request.setLat(context.getLat());
        request.setLng(context.getLng());
        CityReponse reponse = cityService.getCityInfo(request);
        return buildCityInfo(reponse);
    }

    private CityInfo buildCityInfo(CityReponse reponse) {
        ...
    }
}

抽奖领域服务:

package com.hafiz.business.lottery.domain.service.impl;

import ...;

@Service
public class LotteryServiceImpl implements LotteryService {
    @Autowried
    private DrawLotteryRepository drawLotteryRepository;
    @Autowried
    private UserCityInfoFacade userCityInfoFacade;
    @Autowried
    private AwardSenderService awardSenderService;
    @Autowried
    private AwardCountFacade awardCountFacade;

    public LotteryReponse drawLottery(LotteryContext context) {
        // 获取抽奖聚合根
        DrawLottery drawLottery = drawLotteryRepository.getDrawLotteryById(context.getLotteryId());
        // 增加抽奖计数信息
        awardCountFacade.incrTryCount(context);
        // 选中奖池
        AwardPool awardPool = drawLottery.chooseAwardPool(context);
        // 抽出奖品
        Award award = awardPool.randomGetAward();
        // 发出奖品
        return buildLotteryReponse(awardSenderService.sendeAward(award, context));
    }
    private LotteryReponse buildLotteryReponse(AwardSendReponse awardSendReponse) {
        ...
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值