当前位置:   article > 正文

DDD---领域驱动设计(一)_在领域驱动设计,只有聚合根才是访问聚合边界的唯一入口

在领域驱动设计,只有聚合根才是访问聚合边界的唯一入口


前言

  DDD架构思想虽然在2004就被提出但却一直并未受到重视,本身也因为这种架构思想自身的抽象性,凭空的论证让人难以理解。在代码层面缺乏足够约束的情况下,导致 DDD 在实际应用中上手门槛很高,甚至可以说绝大部分人都对 DDD 的理解有所偏差。想要落地实现十分困难。同时市面上也没有很多最佳实践能让人们作为参考,但是随着微服务的兴起DDD领域驱动设计模式开始受到越来越多人的重视,并逐渐被青睐。因为它解决了传统开发模式中存在的许多痛点,一直以来我们大多数甚至说百分之90的环境下都直接使用了MVC 的四层应用架构(UI、Business、Data Access、Database)并已经完全固定死了自己所熟悉的那一套开发流程经常采用表驱动设计的思想来进行开发。但是在当今所有的东西都能被称之为“服务”的时代传统的开发模式已经越来越不适用,它的缺点也越来越被放大化软件退化带来的问题更加凸显。这套系列文章我将跟大家一起学习实践DDD并在一个大型项目中实施落地。学习指导信息主要来源于以下技术分享。第一章节主要偏向于概念层次,后续我会将我的一些实际落地案例拿出与大家分享。

去哪网技术大本营:https://space.bilibili.com/1281381784
领域驱动设计峰会2020:https://www.bilibili.com/video/BV14A411s77v?from=search&seid=1212689119340987516
美团技术团队:https://tech.meituan.com/2017/12/22/ddd-in-practice.html
vivo互联网技术:https://my.oschina.net/vivotech/blog/3171589
爱奇艺DDD实践:https://zhuanlan.zhihu.com/p/342826364
书籍-实现领域驱动设计: https://item.jd.com/68288252471.html


一、DDD的优势

  在学习一项技术的时候我们首先要明确学习它的目的是什么,能带给我们什么价值。业务初期,我们的功能大都非常简单,普通的CRUD就能满足,此时系统是清晰的也算是代码质量最高的阶段。随着项目迭代的不断演化,业务逻辑变得越来越复杂,我们的系统也越来越冗杂。模块彼此关联,谁都很难说清模块的具体功能意图是啥。修改一个功能时,往往光回溯该功能需要的修改点就需要很长时间,更别提修改带来的不可预知的影响面。借助DDD可以改变开发者对业务领域的思考方式,要求开发者花费大量的时间和精力来仔细思考业务领域,研究概念和术语,并且和领域专家交流以发现,捕捉和改进通用语言,甚至发现模型乃至系统架构层面的不合理之处。当然有可能你的团队中并没有相关业务的专家,那么此时你自己必须成为业务专家。一些名词不太理解不要紧,后面都会有详细的阐释。下面是爱奇艺技术团队在使用DDD后带来的好处。我们可以直观的看到DDD的优势所在。

会员业务部门在打赏业务进行了DDD实践后,效率有显著提升:

  • 新需求接入开发成本节约20%;
  • 更换底层中间件开发成本节约20%;
  • 项目熟悉成本节约30%(对DDD有基本了解为前提);
  • 单测开发成本指数级降低;
  • 上线风险、成本降低。

下面是vivo技术团队对DDD业务价值的概括:

  • 你获得了一个非常有用的领域模型

  • 你的业务得到了更准确的定义和理解;

  • 领域专家可以为软件设计做出贡献;

  • 更好的用户体验;

  • 清晰的模型边界;

  • 更好的企业架构;

  • 敏捷、迭代式和持续建模;

  简单总结下来就是DDD能让我们开发更高效,架构更合理的能力的同时。让团队成员对业务的认识更加清晰,让开发人员不仅仅成为编码的工具更能深入理解业务从而开发出更加符合用户期望的产品。大大加强团队协作效率跟产品产出能力,同时也能更好的适应后续产品升级迭代。
在这里插入图片描述

二、领域模型的重要性

  软件的本质就是对真实世界的模拟。因此,我们会有一种想法,能不能将软件设计与真实世界对应起来,真实世界是什么样子,那么软件世界就怎么设计。如果是这样的话,那么在每次需求变更时,将变更还原到真实世界中,看看真实世界是什么样子的,根据真实世界进行变更。这样,日后不论怎么变更,经过多少轮变更,都按照这样的方法进行设计,就不会迷失方向,设计质量就可以得到保证,这就是“领域驱动设计”的思想。那么,如何将真实世界与软件世界对应起来呢?这个时候就需要我们建立领域模型来将软件与现实世界进行映射。

  • 领域模型是对具有某个边界的领域的一个抽象,反映了领域内用户业务需求的本质;领域模型是有边界的,只反应了我们在领域内所关注的部分

  • 领域模型只反映业务,和任何技术实现无关;领域模型不仅能反映领域中的一些实体概念,如货物,书本,应聘记录,地址,等;还能反映领域中的一些过程概念,如资金转账,等

  • 领域模型确保了我们的软件的业务逻辑都在一个模型中,都在一个地方;这样对提高软件的可维护性,业务可理解性以及可重用性方面都有很好的帮助;领域模型能够帮助开发人员相对平滑地将领域知识转化为软件构造

  • 领域模型贯穿软件分析、设计,以及开发的整个过程;领域专家、设计人员、开发人员通过领域模型进行交流,彼此共享知识与信息;因为大家面向的都是同一个模型,所以可以防止需求走样,可以让软件设计开发人员做出来的软件真正满足需求

  • 要建立正确的领域模型并不简单,需要领域专家、设计、开发人员积极沟通共同努力,然后才能使大家对领域的认识不断深入,从而不断细化和完善领域模型

  • 为了让领域模型看的见,我们需要用一些方法来表示它;图是表达领域模型最常用的方式,但不是唯一的表达方式,代码或文字描述也能表达领域模型;领域模型是整个软件的核心,是软件中最有价值和最具竞争力的部分;设计足够精良且符合业务需求的领域模型能够更快速的响应需求变化

三、DDD落地代表

  阿里零售通、京东物流库存仿真、网易新闻APP、去哪网机票报价、去哪网门票玩乐、哈罗交易中台、爱奇艺打赏业务等。看到以上DDD的实践项目是否会让你产生:DDD只适用于大型互联网项目的误区呢。我们当前业务比较简单,不适合DDD。虽然DDD只是一个流派,谈不上压倒性优势,更不是完美无缺。而且在没有经验的前提下确实会产生很多负面的阻力,导致我们的项目前期进度缓慢。而且对开发人员会产生很大的学习成本。对于一些小体量的团队来说更关心的是快速产出,未来项目的生命周期和走向并不是他们短期内所关心的问题。其实从爱奇艺给出的数据不难看出,DDD给项目带来的帮助与效率的提升还是巨大的。我个人认为不管是大项目还是小项目还是要根据自己业务的实情进行裁定,但是我相信DDD还是很值得我们广大开发者去学习实践的它会是短期几年内一个不错的软件开发流程。并且现在越来越多具有代表性的公司落地实践给我提供了很好的研究与学习方向不再想十多年前只存在于抽象的概念。DDD也不一定要完整的按照其规范进行落地,我们也可以只将DDD作为设计指导思想。下面我们正式进入DDD,对其中笼统概念有个初步认识。
在这里插入图片描述

四、DDD建设流程

  以往当拿到这个需求时,开发人员往往草草设计以后就开始编码,设计质量也就不高。而采用领域驱动的方式,在拿到新需求以后,应当先进行需求分析,设计领域模型。

DDD设计流程大体可以粗略划分为两个阶段:

  • 1.建模阶段: 以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型;
  • 2.编码阶段: 由领域模型驱动软件设计,用代码来实现该领域模型;

专业术语分为: 战略设计和战术设计,具体流程如下所示
在这里插入图片描述

4.1 战略设计

  战略设计用大白话来解释就是,在某个系统核心围绕子系统的设计。主要关注系统的划分、交互方式、系统内的核心术语定制。大致为提出问题找到问题解决方案并建立模型的这么一个过程。专业术语称之为,领域分析与领域建模。详细来讲大致有如下几个步骤:从业务视角出发建立业务领域模型,划分业务边界,建立通用语言,识别上下文等这是一个由抽象到具体的过程。而界限上下文划分与通用语言识别是战略设计最基本的两个工具。

战略设计的方式:
  通过事件风暴,采用用例分析和场景分析拆解我们的业务。然后建立领域模型,梳理领域对象之间的关系。在事件风暴过程中会产生许多领域对象,比如,实体、命令、值对象等。我们可以将这些领域对象归结,归类形成聚合。有了聚合我们就能清晰的划分界限上下文从而建立领域模型。

战略设计常规流程:
在这里插入图片描述

在这里插入图片描述  在战略设计抽象的过程中会产生问题空间与解决空间这两层,经过上下文的划分产出了领域,领域下面是各种子域。领域中充斥着各种各样的问题,我们如何找出那些是我们核心关注的,那些是我们要核心实现的功能。这又是一个不断向下抽象的过程,比如我们会和产品讨论在某某领域内
在这里插入图片描述  战略设计不管是建立领域模型,还是识别通用语言其根本目的都是让团队成员对业务理解加深并达成共识的一个过程。
在这里插入图片描述

4.1.1通用语言

常规流程:
在这里插入图片描述在这里插入图片描述

概念:
  团队在事件风暴交流过程中达成共识,能够明确简单清晰的描述业务规则和业务含义的语言就是通用语言。通用语言贯穿整个DDD设计的过程,基于通用语言我们可以开发出可读性更好的代码,将业务更准确的转化为代码设计。后面所提到的领域事件也属于通用语言中的一部分。

出现的原因:

  我们认识到由软件专家和领域专家通力合作开发出一个领域的模型是绝对需要的,但是,那种方法通常会由于一些基础交流的障碍而存在难点。开发人员满脑子都是类、方法、算法、模式、架构,等等,总是想将实际生活中的概念和程序工件进行对应。他们希望看到要建立哪些对象类,要如何对对象类之间的关系建模。他们会习惯按照封装、继承、多态等面向对象编程中的概念去思考,会随时随地这样交谈,这对他们来说这太正常不过了,开发人员就是开发人员。但是领域专家通常对这一无所知,他们对软件类库、框架、持久化甚至数据库没有什么概念。他们只了解他们特有的领域专业技能。比如,在空中交通监控样例中,领域专家知道飞机、路线、海拔、经度、纬度,知道飞机偏离了正常路线,知道飞机的发射。他们用他们自己的术语讨论这些事情,有时这对于外行来说很难直接理解。如果一个人说了什么事情,其他的人不能理解,或者更糟的是错误理解成其他事情,又有什么机会来保证项目成功呢?

  在交流的过程中,需要做翻译才能让其他的人理解这些概念。开发人员可能会努力使用外行人的语言来解析一些设计模式,但这并一定都能成功奏效。领域专家也可能会创建一种新的行话以努力表达他们的这些想法。在这个痛苦的交流过程中,这种类型的翻译并不能对知识的构建过程产生帮助。

  领域驱动设计的一个核心的原则是使用一种基于模型的语言。因为模型是软件满足领域的共同点,它很适合作为这种通用语言的构造基础。使用模型作为语言的核心骨架,要求团队在进行所有的交流是都使用一致的语言,在代码中也是这样。在共享知识和推敲模型时,团队会使用演讲、文字和图形。这儿需要确保团队使用的语言在所有的交流形式中看上去都是一致的,这种语言被称为“通用语(Ubiquitous Language)”。通用语言应该在建模过程中广泛尝试以推动软件专家和领域专家之间的沟通,从而发现要在模型中使用的主要的领域概念。

用途:

  • 通用语言是团队的统一语言,在团队中不管你是什么角色,在同一领域的软件生命周期里都使用统一语言进行交流。通用语言是提炼领域知识的产出物,获得统一语言就是需求分析的过程,也是团队中各个角色就系统目标、范围与具体功能达成一致的过程。

  • 领域语言团队专有,负责解释和维护,相同名称概念,跨出这个团队,理解可以完全不一样。

  • 领域专家、产品经理、开发人员共同的语言,这种语言是将领域专家和技术人员联系在一起的纽带。

  • 在各种文档和平时沟通中,保持概念统一

价值:

  • 解决各岗位的沟通障碍问题,沟通达成一致的提前,消除歧义和理解偏差,提升需求和知识消化的效率。促进不同岗位的和合作,确保业务需求的正确表达。 特别提一下,做一个中文对照, 把概念和代码连接起来,在代码做到概念名称统一,减少混淆。

  • 通用语言贯穿于整个设计过程,基于通用语言可以开发出可读性更好的代码,能准确的把业务需求转化为代码。

    在这里插入图片描述

4.1.2 限界上下文

概念:
  限界上下文本身是一个领域它仅仅是语义和语境上的边界,它需要在这个领域里去保证通用语言和领域对象没有二义性。是一个带边界的语义环境。通常情况对应的就是我们某一个子系统。对应于通用语言,限界上下文是语言的边界,对于领域模型,限界上下文是模型的边界代表一个域或者子域,二者对应于问题空间的界定。但是需要注意的是,一个界限上下文并不一定只包含在一个子域中。

动作:
  根据划分界限从而确定产生一个上下文环境,限界上下文是为了分解大型模型。 是微服务拆分和设计的主要边界依据,当然微服务还有其他的划分边界依据,需要综合考虑;我们将限界上下文内的领域模型映射到微服务就完成了从问题域到软件的解决方案。

作用:
在这里插入图片描述

在这里插入图片描述

  限界上下文的存在帮我们准确的定义和表达了一个领域模型中所有对象的确切含义。如果缺失了界限上下文就会造成同一概念理解的偏差。比如说苹果一词,同一名词在不用领域不同阶段它的含义都可能会产生变更。在手机领域苹果代表如iphone13的手机,在水果领域苹果则会被人们理解为红富士苹果。还有像如上图所描述,不同阶段保单对应不同的事件。我们需要界限上下文帮助我们明确对象在特定环境下的含义。

11
美团实践指导:

  显然我们不应该按技术架构或者开发任务来创建限界上下文,应该按照语义的边界来考虑。我们的实践是,考虑产品所讲的通用语言,从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整。简言之,限界上下文应该从需求出发,按领域划分。前文提到,我们的用户划分为运营和用户。其中,运营对抽奖活动的配置十分复杂但相对低频。用户对这些抽奖活动配置的使用是高频次且无感知的。根据这样的业务特点,我们首先将抽奖平台划分为C端抽奖和M端抽奖管理平台两个子域,让两者完全解耦。

项目结构建议

模块(Module)是DDD中明确提到的一种控制限界上下文的手段,在我们的工程中,一般尽量用一个模块来表示一个领域的限界上下文。

如代码中所示,一般的工程中包的组织方式为{com.公司名.组织架构.业务.上下文.*},这样的组织结构能够明确的将一个上下文限定在包的内部。

import com.company.team.bussiness.lottery.*;//抽奖上下文
import com.company.team.bussiness.riskcontrol.*;//风控上下文
import com.company.team.bussiness.counter.*;//计数上下文
import com.company.team.bussiness.condition.*;//活动准入上下文
import com.company.team.bussiness.stock.*;//库存上下文
  • 1
  • 2
  • 3
  • 4
  • 5

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

import com.company.team.bussiness.lottery.domain.valobj.*;//领域对象-值对象
import com.company.team.bussiness.lottery.domain.entity.*;//领域对象-实体
import com.company.team.bussiness.lottery.domain.aggregate.*;//领域对象-聚合根
import com.company.team.bussiness.lottery.service.*;//领域服务
import com.company.team.bussiness.lottery.repo.*;//领域资源库
import com.company.team.bussiness.lottery.facade.*;//领域防腐层
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
package com.company.team.bussiness.lottery.domain.aggregate;
import ...;
  
public class DrawLottery {
    private int lotteryId; //抽奖id
    private List<AwardPool> awardPools; //奖池列表
  
    //getter & setter
    public void setLotteryId(int lotteryId) {
        if(id<=0){
            throw new IllegalArgumentException("非法的抽奖id"); 
        }
        this.lotteryId = lotteryId;
    }
  
    //根据抽奖入参context选择奖池
    public AwardPool chooseAwardPool(DrawLotteryContext context) {
        if(context.getMtCityInfo()!=null) {
            return chooseAwardPoolByCityInfo(awardPools, context.getMtCityInfo());
        } else {
            return chooseAwardPoolByScore(awardPools, context.getGameScore());
        }
    }
     
    //根据抽奖所在城市选择奖池
    private AwardPool chooseAwardPoolByCityInfo(List<AwardPool> awardPools, MtCifyInfo cityInfo) {
        for(AwardPool awardPool: awardPools) {
            if(awardPool.matchedCity(cityInfo.getCityId())) {
                return awardPool;
            }
        }
        return null;
    }
  
    //根据抽奖活动得分选择奖池
    private AwardPool chooseAwardPoolByScore(List<AwardPool> awardPools, int gameScore) {...}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

建议:
一个团队应该在一个界限上下文中工作。每个界限上下文应该拥有一个独立的源码仓库。我们应该采用喝分离通用语言同样的样式,干净地把不同界限上下文的源码喝数据库数据库模式隔离开。并且,将同一个界限上下文中的验收测试,单元测试喝主要源码存放在一起。

4.1.3 领域

  领域在业务场景下指的是业务边界,同时他也是指定范围内待解决的业务问题,而子域指的是更小、更细化的领域,而DDD通过分治的思想,通过不断的解决小领域,最终产生大领域的解决方案。它是众多子域的组合。领域划分的原则需要综合考虑,根据业务规则、设计模式、性能、伸缩性等从多维度考虑。

  我们拿打车领域来举个例子,我们将打车领域细分成司乘域、交易域、结算域、评价域、与和用户增长域,而其中交易域又可以细分形成于形成域、匹配域、订单域和退款域,解决了此域并将此域的能力去做串联,打车领域的问题就自然而然的解决了。

子域

  每一个子域都对应一个业务和问题域,根据重要性的区分为核心域和支撑域。一般最佳建模成果为限界上下文与子域之间一一对应。换句话说,敏捷项目管理核心即一个清晰的界限上下文,也是一个清晰的子域。在某些情况下,一个接线上下文中可能存在多个子域,单着并非是最理想的建模结果。通过DDD来创建子域,它将会被实现成一个清晰的界限上下文

核心域

  它是一个唯一的,定义明确的领域模型,要对它进行战略投资,并在一个明确的界限上下文中投入大量资源去精心打磨通用语言。它是组织中最重要的项目,以为这将是你宇其它竞争者的区别所在。正是因为你的组织无法在所有的领域都出类拔萃,所以必须把核心域打造成组织的核心竞争力。做出这样的决定需要对核心域进行深入的学历理解,而这需要承诺,协作与实验。这是组织最需要再软件中倾斜其投资的方向
  比如电商系统中的订单与商品,当限界上下文被当做组织的关键战略举措进行开发时,即被称为核心域。核心域的识别是一个持续的精炼过程,是把一堆混杂在一起的组件分离,以某种形式提炼出最重要的内容,这种形式也将使核心域更具有价值。一个严峻的显示是,我们不可能对所有的设计部分投入同等的资源进行优化,产品研发需要聚焦再最小优化可行产品上,不断获取用户反馈,并在这个最小可行产品上持续快速迭代,从而获得一个稳定的核心产品。在有限的资源下,为了使领域模型成为最有价值的资产,我们必须有效地梳理出模型的真正核心,并完全根据这个核心来实现软件服务,这也是核心域的战略价值所在。

通用子域

  如,视频点播,评论等每个系统都能用到的就可以设置为通用域,通用域的解决方案也可以采购现成的。

支撑子域

  比如调第三方支付,银行,支付宝,微信等。即我们俗称的下游。这类建模场景提倡的是“定制开发”,因为找不到现成的解决方案或者自研成本过高效果还未必能达到理想程度。对它的投入无论如何也达不到与核心域相同的程度。很多时候我们可以使用SaaS,如在IM即时通讯域我们就采用了网易云的云信服务来快捷的支撑起我们的业务。避免自研白白投入大量人力物力又做不到很好的效果。

4.1.3 上下文映射

  所有不属于敏捷项目管理上下文(即核心域)的概念都会被迁移到其它某个界限上下文中。核心域必须和其它界限上下文进行集成,这种集成关系在DDD中称为上下文映射。分类:

  • 合作关系
  • 共享内核
  • 客户供应商
  • 跟随者
  • 防腐层

别的概念都很好理解可以见名知意,单独解释下跟随者关系的概念。跟随者关系存在于上游团队和下有团队之间。上游团队没有任何动机满足下游团队的具体需求。由于各种原因,下游团队也无法投入资源去翻译上游模型的通用语言来适应自己的特定需求,因此只能顺应上游的模型。例如,当一个团队需要与一个非常庞大复杂的模型集成,而且这个模型已经非常成熟时,团队往往会成为它的跟随者。

4.1.4 事件风暴

超级棒的事件风暴教程推荐:
https://zhuanlan.zhihu.com/p/399103071
在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述在这里插入图片描述其他需要注意的问题:

  在实际的分析过程中,有些场景中「命令」并不是通过作用在「聚合」上之后再生成「事件」的,而是作用在一个「外部系统」上,在事件风暴中可以通过一个粉色的便签表示「外部系统」。

  还有就是如果在事件风暴进行过程中发现任何问题,不应该陷入长时间的讨论,可以设定一个简单的 time box,例如 5 分钟。如果在时间范围内没有达成共识,可以用一张红色的便签标注这个问题,留待之后进行专项的讨论。

  最后一点是可以通过事件风暴来划分系统内的限界上下文,主要的方法是寻找连接不同业务模块的关键事件。

对于事件风暴的一些思考:
  在使用了几次事件风暴之后,我也对这种方法论有了一些相对深入的思考。我们先不妨看一下,事件风暴完成后的交付是什么?最显著的成果当然是来自于领域模型的「聚合」。但是依照我的经验,这里的聚合更多的是「概念层面的模型」,并不适合直接拿来做数据模型甚至是代码层面的设计。由于进行事件风暴的事件长度一般为 2~3 小时,因此不可能牵涉到太多的业务细节,所以这里得到的聚合模型更多的价值在于帮助开发人员挖掘领域实体的概念,澄清各个领域实体之间的关系。对于业务人员而言,事件风暴的价值在于将开发系统所需的领域知识通过一种简单,清晰,且整个团队都理解的方式呈现出来。所以事件风暴另一项我觉得非常有价值的作用就是「统一语言」,帮助整个团队对于领域知识,领域对象以及之间的关系达成一致。

  那么有了事件风暴梳理的业务流程,是否还需要需求文档呢?我的答案是需要,而且应该在事件风暴开始之前就准备好,至少是个初版。在事件风暴的过程中需要讨论大量的业务规则,但是不可能在有限的时间内,在现场将这些规则一一罗列,特别是牵涉到大量的业务数据项。因此在开始之前,业务部门应该首先梳理一个大致的业务流程与相关的业务规则,避免在现场再开始回忆,从而消耗时间,并且可能遗漏重要的业务规则。

  不必纠结于在事件风暴的现场需要列出所有的业务规则与流程,这是不现实也是不可能的。就如我之前提到的,事件风暴带给团队更多的是对于领域知识认知的一致性,以及对于概念模型的认知。在此基础上需要更进一步的分析与设计工作才能落实到具体的代码,注意不遗漏关键的主线流程和核心的业务规则即可,可以在事件风暴结束前做一个总体性的回顾,看看有没有疏漏之处即可。

  最有一点就是「没有银弹」,不应该寄希望于某种方法论一锤定音,能够得到完美的系统架构和领域模型。提升团队整体对于领域知识的理解与共识,提高自身的设计能力,避免系统的强耦合,快速短小的迭代才是实践领域驱动设计的不二法门。

分析问题空间

在这里插入图片描述

4.2 战术设计

用一句简单的话总结就是:指导程序员如何一面向对象的思想来设计类和属性等。战术设计最常用的工具便是聚合。
在这里插入图片描述

4.2.0 基础架构知识

在这里插入图片描述用户层: 数据处理
应用层:调领域订单聚合,掉持久化服务
基础层: 真实进行持久化的地方

具体DDD实践过程中选用了张建飞老师的CLOA4.0架构:张建飞-cloa架构

4.2.1 实体

  在进行DDD开发时要求我们将关注点从数据转移到领域上。我们首先要考虑的应该是富有行为的领域概念,这样做就不会将数据模型直接映射到对象模型上。从而导致表示领域模型的实体存在大量getter和setter方法。这里又会引出贫血模型与充血模型两个概念,我们会放在后文介绍。

概念:
实体的定义在原书《领域驱动设计》中的描述如下:

一些对象主要不是由它们的属性定义的。它们实际上表示了一条“标识线”,这条线跨越时间,而且常常经历多种不同的表示。
  • 1

  引入实体领域概念是为了区分不同对象或者考虑一个对象的个性特征,一个实体是一个唯一的东西,并且可以在相当长一段时间内持续的变化。我们可以对实体做多次修改,故一个实体对象可能和它先前的状态大不相同。但是,由于他们拥有相同的身份标识,它们依然是同一个实体。对于某电商平台而言,一个个的用户就是实体,我们要对他们加以区别并且持续的关注他们的行为。实体有特殊的建模和设计思路。它们具有生命周期,这期间它们的形式和内容可能发生根本改变,但必须保持一种内在的连续性,即全局唯一的id。它们的类定义、职责、属性和关联必须由其标识来决定,而不依赖于其所具有的属性。即使对于那些不发生根本变化或者生命周期不太复杂的实体,也可以在语义上把它们作为实体来对待,这样可以得到更清晰的模型和更健壮的实现。当然,软件系统中的大多数实体可以是任何事物,只要满足两个条件即可,一是它在整个生命周期中具有连续性,二是它的区别并不是由那些对用户非常重要的属性决定的。根据业务场景的不同,实体可以是一个人、一座城市、一辆汽车、一张彩票或一次银行交易。

  跟踪实体的标识是非常重要的,但为其他所有对象也加上标识会影响系统性能并增加分析工作,而且会使模型变得混乱,因为所有对象看起来都是相同的。软件设计要时刻与复杂性做斗争,我们必须区别对待问题,仅在真正需要的地方进行特殊处理。比如在上面的例子中,我们把收货地址“XX市YY街道ZZ园区”建模成具有唯一标识的实体,那么三个用户就会创建三个地址,这对于系统来说完全没有必要甚至还会导致性能或者数据一致性问题。另外,不应该给实体定义太多的属性或行为,而应该寻找关联,发现其他一些实体或值对象,将属性或行为转移到其他关联的实体或值对象上。比如Customer实体,他有一些地址信息,由于地址信息是一个完整的有业务含义的概念,所以,我们可以定义一个Address对象,然后把Customer的地址相关的信息转移到Address对象上。如果没有Address对象,而把这些地址信息直接放在Customer对象上,并且如果对于一些其他的类似Address的信息也都直接放在Customer上,会导致Customer对象很混乱,结构不清晰,最终导致它难以维护和理解。

在这里插入图片描述
在这里插入图片描述

设计规范:

  • 实体本身是一种充血模型,对比普通的POJO对象,只是多了行为。不可在实体中依赖注入外部的服务,实体只能保留自有的状态。不能因为实体要实现一些功能,需要依赖外部的服务,就通过注入的方式引入外部服务,这样会污染实体且使实体单测变得复杂。正确的引用方式是通过方法参数引入(Double Dispatch)。

  • 一个实体的原则是高内聚、低耦合,即一个实体类不能直接在内部直接依赖一个外部的实体或服务。这个原则和绝大多数ORM框架都有比较严重的冲突,所以是一个在开发过程中需要特别注意的。这个原则的必要原因包括:对外部对象的依赖性会直接导致实体无法被单测;以及一个实体无法保证外部实体变更后不会影响本实体的一致性和正确性。

  • 任何实体的行为只能直接影响到本实体(和其子实体),这个原则更多是一个确保代码可读性、可理解的原则,即任何实体的行为不能有“直接”的”副作用“,即直接修改其他的实体类。这么做的好处是代码读下来不会产生意外。另一个遵守的原因是可以降低未知的变更的风险。在一个系统里一个实体对象的所有变更操作应该都是预期内的,如果一个实体能随意被外部直接修改的话,会增加代码bug的风险。

建议:
再确定一个实体的身份应该是什么时,你首先应该考虑该实体在问题域中是否已经具有了唯一标识。这些标识符被称为实体键。举例:

  • 国建名称
  • 社会安全号码(SSN)
  • 身份证号码
  • 工资单编号

4.2.2 值对象

  vo属性不能修改(注:可以整体替换),使用final修饰。vo为表达模型减负,如商品有100多个属性,铺平开不能体现结构化,不能体现分层分类,将相似描述性属性分组封装成一个个vo。值对象常见的例子包括,数字、文本字符串、日期、时间;还有更加详细的对象,比如某人的全名,其中包含姓氏,名字和头衔;再比如货币、颜色、电话号码和邮寄地址等。常用于度量和描述事务,我们可以非常容易的对值对象进行创建、测试、使用、优化和维护。DDD推荐我们尽量使用值对象来建模而不是实体对象。
sp; 当我们只关心一个模型元素的属性时,应把它归类为值对象。我们应该使这个模型元素能够表示出其属性的意义,并为它提供相关功能。值对象应该是不可变的;不要为它分配任何标识,而且不要把它设计成像实体那么复杂。即描述了领域中的一些属性,比如用户的名字、联系方式。当然也会存在一些复杂的描述信息,其本身可能就是一个对象,甚至是另一个实体概念。

特征:
当我们决定一个领域概念是否为值对象时,我们需要考虑它是否拥有以下特征

  • 它度量或描述了领域中的一件东西
  • 它可以作为常量
  • 它将不同相关属性组合成一个概念整体
  • 当度量和描述改变时,可以用另外一个值对象予以替换
  • 它可以和其它值对象进行相等性比较
  • 它不会对协作对象造成副作用
  • 一个值对象在创建之后便不能改变了,初始化后任何方法不可修改值对象状态。但是可以替换,值对象替换更能简化设计
  • 当一个概念缺乏明显身份时,就基本可以断定它是模型概念中的值对象

建议:

  • 一个值对象可以只处理一个属性,如果组属性联合起来并不能表达一个整体概念,那么这种联合并无多大用处
  • 值对象的构建最好能使用构造函数一次性构建好,方法必须是无副作用用函数
    在这里插入图片描述

  在电商例子中地址是一个值对象。但在国家的邮政系统中,国家可能组织为一个由省、城市、邮政区、街区以及最终的个人地址组成的层次结构。这些地址对象可以从它们在层次结构中的父对象获取邮政编码,而且如果邮政服务决定重新划分邮政区,那么所有地址都将随之改变。在这里地址是一个实体。

  在电力运营公司的软件中,一个地址对应于公司线路和服务的一个目的地。如果几个室友各自打电话申请电力服务,公司需要知道他们其实是住在同一个地方,因为我们真实服务的是用户所在地方的电力资源,在这种情况下,我们会认为地址是一个实体。但是随着思考的深入,我们发现可以换种方式,抽象出一个电力服务模型并与地址关联起来。通过这样的设计以后,我们发现真正的实体是电力服务,地址不过是一个具有描述性的值对象而已。

  在房屋设计软件中,可以把每种窗户样式视为一个对象。我们可以将“窗户样式”连同它的高度、宽度以及修改和组合这些属性的规则一起放到“窗户”对象中。这些窗户就是由其他值对象组成的复杂值对象,比如圆形天窗、1m规格平开窗、狭长的哥特式客厅窗户等等。对于“墙”对象而言,所关联的“窗户”就是一个值对象,因为仅仅起到描述的作用,“墙”不会去关心这个窗子昨天是什么样,以至于当我们觉得这个窗户不合适的时候直接用另外一个窗户替换即可。

  归根结底,我们使用这个窗户对象来描述墙的窗户属性。但是在该房屋设计软件的素材系统中,它的主要职责就是管理窗户这一类的附属组件,那么对它而言窗户就是一个鲜活的实体。从这个例子中我们可以看出,所属业务域很重要,这也就是我们之前所讲述的上下文,即同一对象在不同上下文中是不一样的。

4.2.3 聚合

  举个例子来阐述一下:比如说我们在电商场景下,一个用户购买了多件商品,在这个场景下我们设计了主订单,然后我们是用主订单实体去承载买家信息,以及订单金额等属性,应用子订单去承载商品级别的信息。
  在主订单实体中,买家信息实际上就可以作为一个值对象,因为这个值对象本身是不需要去做状态流转的,在这个场景里面也涉及到聚合,因为我们在创建订单的时候,本身是要将主订单实体和子订单实体一起创建,所以我们需要将主订单实体和子订单实体形成一个聚合,来提供一个创建清单的能力。
在这里插入图片描述如何创建好的聚合?

  • 边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。每个聚合都会形成保证事务一致性的边界。
    - 设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
    - 通过唯一标识来引用其他聚合或实体:当存在对象之间的关联时,建议引用其唯一标识而非引用其整体对象。如果是外部上下文中的实体,引用其唯一标识或将需要的属性构造值对象。 如果聚合创建复杂,推荐使用工厂方法来屏蔽内部复杂的创建逻辑。
    - 使用最终一致性更新其它聚合

聚合内部多个组成对象的关系可以用来指导数据库创建,但不可避免存在一定的抗阻。如聚合中存在List<值对象>,那么在数据库中建立1:N的关联需要将值对象单独建表,此时是有id的,建议不要将该id暴露到资源库外部,对外隐蔽。

如何识别聚合?

  我觉得这个需要从业务的角度深入分析哪些对象它们的关系是内聚的,即我们会把他们看成是一个整体来考虑的;然后这些对象我们就可以把它们放在一个聚合内。所谓关系是内聚的,是指这些对象之间必须保持一个固定规则,固定规则是指在数据变化时必须保持不变的一致性规则。当我们在修改一个聚合时,我们必须在事务级别确保整个聚合内的所有对象满足这个固定规则。作为一条建议,聚合尽量不要太大,否则即便能够做到在事务级别保持聚合的业务规则完整性,也可能会带来一定的性能问题。有分析报告显示,通常在大部分领域模型中,有70%的聚合通常只有一个实体,即聚合根,该实体内部没有包含其他实体,只包含一些值对象;另外30%的聚合中,基本上也只包含两到三个实体。这意味着大部分的聚合都只是一个实体,该实体同时也是聚合根。

Aggregate 的完整性规则

所谓的完整性规则又由下面两点组成:

  • 所有的代码只能通过 Aggregate Root,即聚合根这个特殊的 Entity 访问系统的 Entity,而不能随便的操作任一的 Entity。
  • 每个「事务」范围只能只能更新一个 Aggregate Root 及它所关联的 Entity 状态。

聚合根

  聚合本身也是一个实体,如果一个聚合只有一个实体,那么这个实体就是聚合根也称为根实体。如果有多个实体,那么我们可以思考聚合内哪个对象有独立存在的意义并且可以和外部直接进行交互。它是一组相关对象的集合,作为一个整体被外界访问,聚合根(Aggregate Root)是这个聚合的根节点。

根实体

   每个聚合的根实体控制着所有聚集在其中的其它元素。根实体的名称就是聚合概念上的名称。

聚合根配置

当实体为一个聚合根时,一个聚合根通常配置以下三个模块:

  • 工厂(Factory): 只是负责创建聚合根,聚合根内部的子实体,与实体的行为无关。创建与使用分开,保证类的单一权责规范。
  • 领域服务(DomainService): 完成聚合根内实体的相关行为,处理所有的业务逻辑,如业务判断,业务数据生成等。对于跨多个实体的应用,单独编写第三方领域服务处理。第三方领域服务的功能不属于单独某一个实体的行为。
  • 仓库(Repository): 仓库提供聚合根与底层数据的存储功能。仓库仅保存数据,查询数据,不做实体行为的逻辑处理。

配置原则:

  • 对于简单的实体创建,可基于构造函数,或直接设置值生成实体,不一定非要使用工厂创建。
  • 工厂,领域服务等都不能直接与底层的数据存储系统交互,他们都要通过仓库层来获取数据,存储数据。
  • 聚合根统一配置仓库等模块,内部的子实体不用再单独配置仓库和工厂了。

4.2.4 工厂

工厂提供一个创建对象的接口,该接口封装了所有创建对象的复杂操作过程,同时,它并不需要客户去引用实际被创建的对象。

常规创建对象方式的缺陷:

  • 在使用setter方式初始化对象时很容易造成错漏。当一个对象结构较为复杂时,很容易遗忘调用某个setter方法。
  • 如果使用构造函数创建对象来保证完整性,又会带来新的问题。在不同业务场景下 Aggregate 初始化所需要的参数可能是不同的,如果通过自定义构造函数的方式来控制数据初始化,那就需要定义多个参数不同的构造函数,即所谓函数重载(Overload)。因为方法名是相同,无法表达业务含义,因此开发人员还是无法确定到底应该使用哪一个都早函数进行数据初始化。

为了解决这个问题,DDD 比较推荐的一种方式使用经典的 Factory Pattern(工厂模式)。工厂模式作为最简单的设计模式之一,被广大的开发人员所熟知,在 GOF 的书中,工厂模式也是第一个介绍的设计模式。简单来说,工厂模式通过一个特定方法,封装了对象数据初始化的逻辑。而这个方法其实就是个普通的方法,因此可以自由的定义方法名,而不必像构造函数那样受限,所以可以自由的表达业务含义。在项目中具体的实现方式也有两种选择,让我们依次来看一下。

public class PolicyIssueService {
    public Insured createInsuredFrom(PolicyProduct product, BillingInfo billingInfo, ContactAddress contactAddress) {
        ……
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5

上述的代码中我们定义了一个领域服务类,用来实现新保单承保的逻辑,其中的方法 createInsured 会返回一个 Insured 的实例,这就是我们定义的用来创建 Aggregate 的工厂方法。通过这样在领域服务中定义专门的方法,可以很好的封装领域对象的初始化逻辑,保证数据完整性的同时也不丢失业务含义。

Factory Pattern具体两种实现:

  1. 由领域服务提供的 Factory Method

我们之前在分层架构中提到过领域服务的概念,如果说领域对象从某种程度上代表了领域知识中的名词,那么领域服务就对应了动词。我们可以在领域服务中定义所需要的方法来返回一个 Aggregate。


public class PolicyIssueService {
    public Insured createInsuredFrom(PolicyProduct product, BillingInfo billingInfo, ContactAddress contactAddress) {
        ……
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

  上述的代码中我们定义了一个领域服务类,用来实现新保单承保的逻辑,其中的方法 createInsured 会返回一个 Insured 的实例,这就是我们定义的用来创建 Aggregate 的工厂方法。通过这样在领域服务中定义专门的方法,可以很好的封装领域对象的初始化逻辑,保证数据完整性的同时也不丢失业务含义。

  1. 由领域服务提供的 Factory Method

  除了在领域服务上定义相关的工厂方法之外,在 Aggregate 上也能定义专门的方法来管理另一个 Aggregate 或是 Entity 的初始化。我们通过一个保险业务上的例子来说明这种情况。当被保人发生意外,如果在保险单的保障范围内,可以申请理赔。在申请理赔时需要录入许多事故相关和保险单相关的信息,因此可以将理赔申请设计为一个 Aggregate。而初始化这个 Aggregate 的方法可以交给另一个 Aggregate,即保险单的 Aggregate。具体代码可参考如下:


//代表理赔申请的 Aggregate
public class ClaimApplication {
    ……
}

//代表保险单的 Aggregate
public class Policy {
    //创建 ClaimApplication 的工厂方法
    public ClaimApplication applyClaimWith(Accident accident) {
        ……
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

  上面的方法很好理解,在 Policy 上有个方法,applyClaimWith,它接受一个事故信息 Accident 对象,返回另一个 Aggregate ClaimApplication 。当采用这种解决方案时,我们需要更多的分析领域对象之间的关系,在合理的对象上定义工厂方法,切忌在一个 Aggregate 上定义过多的工厂方法,这样也就丢失了相关的领域知识。

4.2.5 仓储

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

  对于资源库,我们的实践是资源库作为业务与数据的隔离层,屏蔽底层数据表细节,同时完成PO与DO的转化。DO与PO的转化带来的好处是领域层不会直接依赖底层实现,便于后续更换底层实现或功能迁移。资源库接口定义在领域层,接口实现在基础设施层。一些开发者可能会把 Repository 与 DAO 混淆在一起,由于 Spring JPA 这样的框架在命名方面把两者交织在一起,更加容易加深大家的误解。Repository 从字面以上来看更加偏重业务的含义,作为一个「仓库」它所要做的是将领域对象重新拿出来,但是不必关心底层的细节。例如我们是使用一种关系型数据库,还是 NoSQL 数据库,作为领域层其实是不关心的,它们关心的是领域对象是否被正确的还原出来。而 DAO 在实际项目中往往会更底层些,它抽象的是不同关系型数据库的异同,你可以使用 MySQL,也可以使用 Oracle,但是对于 DAO 层暴露的接口应该是相同的。我们来看一个具体的例子。

public interface InsuredRepository {
    public void save(Insured insured);
    public Insured findBy(Long id);
    ……
}

public interface ProductRepository {
    public void save(Product product);
    public Product findBy(Long id);
    ……
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

以上的代码中我们对两个领域对象,Insured 与 Product 定义了两个 Repository 接口,用以与某种存储机制进行交互。接下来看我们的实现。

public abstract class InsuredDBDAO implements InsuredRepository {
    ……
}

public class MySQLInsuredDBDAO extends InsuredDBDAO {
    ……
}

public class MongoDBProductRepository implements ProductRepository {
    ……
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

  我们使用关系型数据库存储 Insured 的数据,同时为了保证不耦合到特定的关系型数据库,我们定义了一个额外的 DAO 抽象类,然后提供了基于 MySQL 实现的具体类。而在 Product 这方面,我们更希望使用 MongoDB 这样一个 NoSQL 存储数据,因此我们直接使用了一个具体的类实现了 ProductRepository 的接口。但是这两个接口在领域层暴露的几口都是一致的,所以需要牢记的是 Repository 是属于领域层的,而具体存储机制的实现,无论是 DAO 还是其他的实现,都应该属于 infrastructure 层,属于具体的实现机制。

仓库层注意事项

  • 仓库层入参不应该使用底层数据格式,Repository操作的是Entity对象(实际上应该是Aggregate Root),而不应该直接操作底层的数据对象(数据表映射的贫血对象)。更近一步,Repository接口实际上应该存在于Domain层,根本看不到数据层的实现。这个也是为了避免底层实现逻辑渗透到业务代码中的强保障。
  • 实体状态变更,行为处理,仓库层入参可以接收处理命令(如XxxUpdateCommand)。
  • 仓库层接口放在领域层模块,但仓库层的实现放在基础层模块。
  • 当发现数据存储要求更多的字段,实体缺乏某些数据项时(如一些加工生成的中间数据),不要将缺少的数据,通过参数的方式传递到仓库层。应反思实体是否设计的完善合理,尽可能的完善实体后,再存储数据。
  • 仓库层的业务接口入参一般为实体,实体的唯一身份标识,部分基础数据类型。
  • 仓库层的查询接口入参可以为Query对象,单个主键编码。
  • 仓库层的数据操作接口,原则上由应用层调用,不要在领域层中调用,领域层一般调用查询接口。

4.2.6 领域事件

定义:
  领域事件其实比较好理解的,就是当某个领域触发变更之后,通知其他领域的事件。它是领域专家所关心发生在领域中的一些事。领域事件本身也作为通用语言(Ubiquitous Language)的一部分成为包括领域专家在内的所有项目成员的交流用语。比如:如果你建模的是餐厅的结账系统,那么此时的“客户已到达”便不是你关心的重点,因为你不可能在客户到达时就立即向对方要钱,而“客户已下单”才是对结账系统有用的事件。

特征:
较高的业务价值,有助于形成完整的业务闭环,将导致进一步的业务操作。这里还要强调一点,领域事件具有明确的边界。举个例子:比如说在交易场景下,订单支付成功之后,我们是需要增加用户积分的。在这种场景下,订单实体是需要发出订单支付成功的事件,用来通知用户的积分予以去做增长积分的一个行为。

关键词汇:

  • “当…”
  • “如果发生…”
  • “当…时候,请通知我”
  • “发生…时”

判断是否是一个领域事件时并不能完全根据词汇上述只是一个常规案例,具体还要根据实际业务含义分析。一个很明显的特性就是领域事件具备很重要的特征。它在业务进程中是很重要的一环。

4.2.7 领域服务

概念:
  当一个操作不适合放在聚合和值对象中时,最好的方式便是使用领域服务。我们要尽量不要将领域服务与应用服务相混淆。在应用服务中,我们并不会处理业务逻辑,但领域服务却恰恰是处理业务逻辑的。虽然领域服务中有“服务“这个词,但它并不意味着需要远程的、重量级的事务操作。一般实体行为的具体业务规则实现,单独编写一个实现类,这种类在DDD里被叫做领域服务(Domain Service)。

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

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

场景:

  • 执行一个显著的业务操作过程
  • 对领域对象进行转换
  • 以多个领域对象作为输入进行计算,结果产生一个值对象
跨对象事务型(多实体)-第三方领域服务

当一个行为会直接修改多个实体时,不能再通过单一实体的方法作处理,而必须直接使用领域服务的方法来做操作。在这里,领域服务更多的起到了跨对象事务的作用,确保多个实体的变更之间是有一致性的。该领域服务是一个多实体的综合服务实现类,不是任何一个单独实体的领域实现类。
如账户转账模块:有两个账户实体,一个支出,一个收入,两个实体的行为同时完成支出和收入才算转账完成。

  很多人认为DDD中的聚合就是在与贫血模型做抗争,所以在领域层是不能出现“service”的,这等于是破坏了聚合的操作性。但有些重要的领域操作无法放到实体或值对象中,这当中有些操作从本质上讲是一些活动或动作,而不是对象。比如我们的身份认证、支付转账业务,我们很难去抽象一个金融对象去协调转账、收付款等业务逻辑;有时候我们也不太可能让对象自己执行auth逻辑。因为这些操作从概念上来讲不属于任何业务对象,所以我们考虑将其实现成一个service,然后注入到业务领域或者说是业务域委托这些service去实现某些功能。

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

常见问题汇总:

  • 如何识别聚合根?

  如果一个聚合只有一个实体,那么这个实体就是聚合根;如果有多个实体,那么我们可以思考聚合内哪个对象有独立存在的意义并且可以和外部直接进行交互。

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

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

  • 如何创建好的聚合?

  边界内的内容具有一致性:在一个事务中只修改一个聚合实例。如果你发现边界内很难接受强一致,不管是出于性能或产品需求的考虑,应该考虑剥离出独立的聚合,采用最终一致的方式。
设计小聚合:大部分的聚合都可以只包含根实体,而无需包含其他实体。即使一定要包含,可以考虑将其创建为值对象。
聚合之间的关联通过ID,而不是对象引用
聚合内强一致性,聚合之间最终一致性

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

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

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

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

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

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

  • 实体和值对象之间什么关系?

  唯一的身份标识和可变性特征将实体对象和值对象进行了区分。本质上,实体是看得到、摸得着的实实在在的业务对象,实体具有业务属性、业务行为和业务逻辑。而值对象只是若干个属性的集合,只有数据初始化操作和有限的不涉及修改数据的行为,基本不包含业务逻辑。

实体和值对象是微服务底层的最基础的对象,一起实现实体最基本的核心领域逻辑。同时实体对象和值对象共同构成了聚合。

在设计的时候应该用实体对象还是值对象,我觉得本着一个是否具有业务行为的原则就够了,有业务行为的就用实体对象,没有业务行为的就设计成值对象。

  • BC与微服务什么关系?

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

  • 业务处理如何依赖实体行为?
    一个业务的完成,往往会关联多个实体,需要多个实体的不同行为协调运行。
    如在售后补偿中,补偿单整体流程结束:包括履约单完成和补偿单状态完成。两个实体都完成了,才算整个补偿业务完成。不同实体之间不能直接调用,参考实体的行为规范,针对多个实体的情况,一般存在以下三个常用场景:

1.多实体强一致性:完成一个业务必须保证相关的实体同时完成,具有事务性质。可采用第三方领域服务处理,处理完成后,在应用层加上事务保证数据一致性的存储到系统。

2.实体副作用:完成一个实体后,其他实体监听处理,实体不依赖于其他实体的处理结果。如履约单完成后,发出一个事件。

3.多实体先后处理:在应用层,应用服务调用领域层实体相关的功能,做业务编排,先执行一个实体的行为,在执行其他实体的行为。如补偿单审批通过后,调用履约单的处理功能。

  • 为什么资源库只提供一个save方法持久化聚合根?
      原因是在DDD中,资源库是聚合根的容器,但并不限制容器是什么做的,也就是前面说的与底层解耦。如果容器是Key-value数据库做的,是不支持update某个字段的,并且inset和update是不区分的。资源库与DAO不同,资源库只是向领域模型提供聚合根以及持久化聚合根。
      如果我们选择关系型数据库作为聚合根的容器,那么在存储聚合根时可能就需要将聚合根以及聚合根下的实体拆分到多个表存储,这就可能导致每次save聚合根都需要执行多条update语句,即便聚合根下的实体并没有发生任何的改变,即便只是聚合根修改了一个值对象,因此会严重影响到应用的性能。为解决选择关系数据库作为聚合根容器导致的性能问题,我们需要付出额外的努力,如用内存快照去判断每次save聚合根只需要更新哪些表。基于每个业务用例都需要通过资源库获取聚合根最后也通过资源库持久化聚合根的特性,我们可以在获取聚合根时创建快照,并且在持久化聚合根时对比(diff)快照,获取差异信息,只执行需要更新的差异信息。
声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/我家小花儿/article/detail/559448
推荐阅读
相关标签
  

闽ICP备14008679号