DDD

首先就有一个疑问,什么是DDD?

用英文的全称是Domain-Driven Design,领域驱动设计,其主要的思想是,我们在设计软件时先从业务出发,理解真实的业务含义,再将业务中的一些概念吸收到软件建模中来,其实网上一搜一大把,概念其实很抽象,但实际上又是很贴合真实的开发,为啥我们好像从来没听过但又为何提起来呢?

DDD其实在最早的时期的软件开发就是在使用的一种模式,不同于MVC,更专注的是业务的过程(至少我这么认为),当然还需要结合一些方法论,比如战略设计和战术设计,而随着当前快速迭代的需求和更快的上手,MVC这种方式更被大众接收和推广,因为DDD的开发模式非常注重前期的设计,而MVC甚至设计图还没出你就可以开始把表的字段给设计出来了

那为啥现在很多人又拿出来说呢?在2016年的时候微服务的盛行,系统的复杂度和边界设计成了大家头疼的问题,刚好DDD的概念就是为了解决复杂度,于是不谋而合又被人挖出来,不可否认,DDD中许多概念非常贴合当下微服务架构的业务治理,并且能很好指导微服务中难以维护的业务代码和边界问题,同时在代码分层方面也给予了一定指导,我们先来认识一下DDD,它到底是个什么东西?

引用一下架构师的文章和观点

一、软件开发复杂度

业务在实际开展中遇到的问题
那实际业务开展中,业务到底会遇到有哪些问题呢?我们按业务的生命周期进行切分,然后具体查看每个业务生命周期的诉求

业务启动期:业务能力快速搭建 - 系统提供快速试错的能力
业务发展期:业务能力扩展 - 系统需要支持原来越多的业务功能
业务平台期:业务能力复制 - 系统需要支持原来越多的业务场景
业务衰退期:业务能力创新 - 系统提高生产力延长业务的生命周期

我们技术要做的事情是:在业务验证没有问题的情况下,如果尽可能的延长业务的发展和平台期,让业务获取的利益最大化。所以为了支持业务的发展,业务的本身的功能支持诉求以及业务对技术的要求也会越来多,在这种情况下考验软件开发人员的一个非常关键的能力就是: 软件复杂度的控制的能力

软件复杂度的定义是多个维度的

高性能:单机性能、集群性能

高可用:计算高可用、存储高可用

可拓展性

低成本

规模:业务规模、系统物理规模

另外一种复杂度是现在现代软件对于高效协作的要求必须把软件的生产过程拆分到不同的专业工种,且每个工种需要竟可能的并行来提高效率

如上图,产品构思出原型,形成需求文档,在像研发、测试传递信息的时候可能会存在信息遗漏。或者研发理解错误的问题。

代码的复杂度随着时间的推移只会必之前更复杂(是一个熵增的过程)

二、DDD本质

DDD的原名是模型驱动的设计方法:通过领域模型(Domain Model)捕捉领域知识,使用领域模型构造更易维护的软件。

DDD本质上就是一种减低软件复杂度的手段, 其推荐的方法论可以适用于上面包括了业务规模,可扩展性两个维度的复杂度应对。其实业务规模的复杂度的处理包括了对可扩展性的支持。

那么我们进一步对业务规模的复杂度进行拆解,又分为下面两类:

领域复杂度:问题域的准确性

技术实现复杂度:扩展性差、代码是面向过程开发、分层不合理、代码无规范、代码没有按照产品分析模型进行编码

DDD用两个方式解决以上问题:

战略模式:领域划分,界限上下文映射

战术模式:规划内部领域层架构

DDD不只是编程方式、架构风格、不具体指导建模

以下问题DDD解决不了:

容量规划
架构设计
数据库设计
缓存设计
框架选型
发布方案
数据迁移、同步方案
分库分表方案
回滚方案
高并发解决方案
一致性选型
性能压测方案
监控报警方案

在需求转换为真正能工作的软件过程中需要各种复杂的思维活动。每个过程中核心关注点存在巨大差异

1、需求定义的核心关注点是逻辑是否闭环

2、方案设计核心关注点是 选择合适的存储与通信模型。

3、代码编写阶段核心关注点是代码的正确性、可维护性。

DDD 试图找到一种用围绕业务概念来构建领域模型的方式来真实反映业务的复杂性,并且代码实现是绑定模型的。

三、落地DDD

需求分析

DDD的核心思想,就是分析模型要和代码模型保持一致。 那么如果不保持一致到底会产生什么样的负面影响

如果技术实现和业务实现不在用一水平线上,那技术模型的行进路线只会考虑劈开技术障碍并且可能会撞在未来的业务障碍的墙上。这样就很容易出现,业务持续演进等技术想实现的时候,却发现当前的实现依赖于“业务不会这样发展”的假设上。这也是为什么会出现现在众多业务需求,技术无法实现或者是需要花大量时间去实现的原因。但是如果技术和业务通过统一语言打破知识的壁垒保持一致,那么如果后面技术遇到问题即是业务碰到的问题,业务人员需求的变更和迭代会自然而然的帮助技术同学越过一些门槛。也就是说业务方与技术方参与到对方的工作中,就在双方之间带来了更好的协同,形成1+1>2的功效。

什么是问题域

根据百度百科的解释【3】 在软件工程中,问题域是指待开发系统的应用领域,即在客观世界中由该系统处理的业务范围那么问题域内的组成是什么呢?就是我们的域模型。域模型(domain model)英文又称为问题域模型(problem space model)。维基百科(Wikipedia)对它的定义是” A conceptual model of all the topics related to a specific problem” 可以翻译成:“域模型是针对某个特定问题的所有相关方面的抽象模型”。这个定义有几个要点:第一是“特定问题”, 也即是说域模型是针对性某个问题域而言的, 脱离的这个特定问题,域模型的构建其实不存在一个最优或者是最合理的构建。第二是抽象, 域模型是一个抽象模型, 不是对某个问题的各个相关方面的一个映射, 也不是解决方案的构建。 
如何实现问题域的分析

在 DDD 中,Eric Evans 提倡出一种叫做知识消化(Knowledge Crunching)的方法帮助我们去提炼领域模型。简单来说就是五个步骤:

  1. 关联模型与软件实现;
  2. 基于模型提取统一语言;
  3. 开发富含知识的模型;
  4. 精炼模型;
  5. 头脑风暴与试验。

开发人员和业务专家在一起通过一个个业务用例仔细讨论应用程序的应用场景,从而使得业务人员深刻理解业务知识,开发人员和业务人员就重要的业务概念建立起统一的语言,开发人员将这些概念根据业务用例的上下文抽象出模型,并且这些模型将会最终成为最终软件实现中的领域模型。随后随着更多的业务用例的输入,开发人员和业务人员会逐渐对已经构建的模型进行精化,并且也会用新的用例去检验之前构建模型的合法性和适用性。DDD在这一步其实没有给出详实标准的如何建模的方法,毕竟建模还是来自于每个人的世界观,其过程还是倾向于经验的。但是还是有不少人总结一些标准的建模方法论例如:
1 四色原型法  
http://apframework.com/2020/03/22/ddd-color/


2 用例分析法 
https://baike.baidu.com/item/%E7%94%A8%E4%BE%8B%E5%88%86%E6%9E%90/2859078?fr=aladdin

3 事件风暴建模法

https://zhuanlan.zhihu.com/p/427786290

问题域的拆分
大家应该发现上面的知识消化的流程是一个非常耗时和复杂耗脑力的过程, 涉及到产品,业务,技术等多方团队, 所以为了让有限的资源投入到最最核心的子域,我们需要对问题域进行这份,把重点的精力放到最核心的领域上。核心领域一定是业务价值最高的,而非技术难度最高或者是基础设施框架部分。 要切分问题域,首先需要了解问题域的种类:

1 通用域: 非应用独有的,多个应用都会有的功能。例如发送邮件,触达等
2 核心域:和竞争对手区别开来的区域,或者是在市场上被赋予了竞争优势的区域。
3 支撑子域:不是你的核心竞争力,但又不得不做,市场上也找不到现成方案的子域。既不包含决定产品和公司核心竞争力的功能,也不包含通用功能的子域,但又必需。如行业字典,页面操作记录。

下图是一个拍卖网站的核心域划分:

划分界限上下文

一般情况下,一个复杂系统由一系列的模型来表示解答域, 理想状态是一个子域一个模型。但是有些当业务需要且系统复杂的时候,一个模型可能被多个域共享,这个时候这个模型的概念可能变得不清楚。因此为了保护这些模型概念的完整性, 清晰的定义模型的责任边界很重要。实现领域驱动设计书中举了下面这个例子:

为了维护模型的概念的完整性,最直观的方法就是为这个模型化一个边界。

e.g. 这个商品所表现的意思就是履约的时候用到的"商品",而不是下单的时候的"商品"。只要有一个这样的边界定义,系统就会但是出现多个边界,毕竟"商品"在不同业务上下文中有不同的含义, 例如库存域的货品,物流域的运输品, 价格域的商品等等。

举个更通俗的例子比方说有一个关公的神像,老百姓都买回家当神供看,但是这个神像,在工匠手里,是“活”,在商人手里,是“货”,只有被老百姓买回家了,才是“神”。

再比如说支付领域,上游的一个订单推送过来做支付,支付系统当然可以用订单的订单号做为支付单的唯一标识,但是这个订单号在支付系统中就不应该再被叫做订单号了,而应该被称作支付单号,或者业务关键key。你看同一个实体在不同领域流转会有不同的语言来表述。

界限上下文包括两部分

1、统一语言

2、上下文映射

限界上下文的本质

领域的划分是站在问题域的角度对问题本身进行拆分,有界上下文的划分是解答域对问题域的求解过程。

上下文映射

上下文的映射是什么, 简单来说就是描述了不同上下文之间的关系。为不同的上下文间的协作方式定基调。

shared kernel共享内核 shared kernel :通常是共享核心领域或者是一组通用子领域

customer/supplier

客户/供应商关系 customer/supplier:上下游关系。不同客户需要协商来平衡,上游团队需要有自动测试套件。

conformist

跟随者模式 conformist:单方面跟随模式。上游的设计质量较好,容易兼容,可以采用严格遵循上游团队的模型。

anticorruption layer

防腐层 anticorruption layer:防腐层、隔离层,使用 facade or adapter 等模式。可以减少其它系统变动对本系统的影响。

separate way

各行其道 separate way:声明一个与其它上下文毫无关联的 bounded context,使开发人员能够在这个小范围内找到简单、专用的解决方案。

总结:

  • 界限上下文是跨领域协作的产物。
  • 领域的划分是问题域的分割,界限上下文作用就是对问题域解答。
  • 通常来说一个领域对应一个界限上下文,在一个界限上线文中需要统一通用语言。
  • 上下文关系用来识别两个上下文的关系,可以是现状也可以是未来的方向。

分层

传统的三层架构

这种传统架构的缺点

1、业务逻辑层和数据访问层有明显的耦合。
2、没有领域的概念,所有的逻辑沉淀到service中。所以传统架构只能针对小型的,没有过多的业务逻辑场景。由于这种架构不能够满足领域能力的沉淀,所以复杂业务场景基本不会被使用。

DDD的四层架构

1、分离领域

首先,DDD 对代码架构最核心的要求就是要将领域层分离出来。领域层封装了领域数据和逻辑,我们前面的领域模型所对应的代码,主要就体现在领域层。只有将领域层独立出来,才能保证与领域模型的一致,也才能让领域层独立演化。下面是分离领域层后的示意图:

2、给领域一个“门面”

那么,领域层封装的逻辑通常是细粒度的,并不适合直接作为 API 暴露给外部。另外,还有一些不属于领域层的横切关注点,比如像事务控制,应该单独处理。所以,我们往往要在领域层外面再加一层,DDD 和六边形架构都将这一层称为 Application,也就是应用层。如下图所示:

3、用“适配器”处理输入输出

除了业务功能之外,程序里还有另一个重要的关注点——输入输出技术。我们的系统要和外界打交道,可以通过不同技术来实现,比如 Restful API、 RPC,以及传统的 Web 页面等等。对于同一个业务功能,可能过去使用 Restful API ,现在由于技术变革,需要改为 RPC。但不论具体技术是哪一种,背后实现的业务功能很可能都是一样的。所以,输入输出技术和业务功能是两个不同的关注点。为了分离这两个关注点,我们在应用层外面再加一层,专门处理输入输出技术,如下图所示:

4、用“适配器”处理数据持久化

最后,我们还要处理一个关注点,就是数据的持久化。在传统上,数据持久化就是访问数据库。但是现在,对缓存、文件系统、对象存储服务等等的访问,一般也算作数据的持久化。不过,在引入新的分层之前,我们先讲 DDD 里的另一个模式,叫做 Repository,中文可以叫仓库。这个模式用于封装持久化的代码,大体上类似于传统上说的 DAO(Data Access Object),也就是“数据访问对象”。但和 DAO 不同的是,仓库是以聚合为单位的,每个聚合有一个仓库,而 DAO 是以表为单位的,每个表有一个 DAO。我们在第二个迭代才会正式介绍聚合,现在咱们姑且认为,一个实体就对应一个仓库。那么,仓库和适配器有什么关系呢?其实,数据库访问也是和具体技术相关的。同样的数据,可以存到 Oracle,也可以存到 MySQL;既可以用 MyBatis 访问,也可以用 JPA 访问。这些都是具体的技术,和前面一样,我们需要一种适配器把具体的持久化技术和应用层以及领域层隔离开,而仓库就充当了这种适配器。但是仔细想一下,你可能会发现,仓库和前面的 Controller 虽然都是适配器,但有一个重要的区别。Controller 处理的是从外界向系统的调用,比如说来自 HTTP 客户端的调用;而仓库处理的是由系统向外界的调用,比如说对数据库的调用。也就是说,两者的方向不同。在六边形架构里,把由外向内的适配器叫做 driving adapter,我把它译作主动适配器;而由内向外的适配器叫做 driven adapter,可以译作被动适配器。准确地说,被动适配器的作用不限于访问数据库,而是访问所有外部资源。现在,我们可以把原来的适配器层分成两个部分,像下面这样。

5、存放通用工具和框架

到现在为止,我们已经讲了 DDD 分层架构中最主要的几层,但还有另外一些代码没有考虑。比如说,我们写了一些用于字符串和日期处理的工具类,这些工具可能被上面说的任何一层调用。又比如说,我们可能对 Spring 框架进行薄薄的一层封装,以便更适合自己的产品使用,甚至可以写一些自己的小框架,这些框架性的代码也可能用于上面说的任何一层。既然这些代码可能被前面的所有层依赖,那么是不是说,这些代码应该处于整个系统的最内层呢?如果这样做,那么和 DDD 所强调的以领域层为核心的思想就矛盾了。但如果不这么做,是不是又违反了层间依赖原则呢?事实上,我们可以认为这些代码和前面说的各层根本不在同一个维度,它们是对各层代码起到公共的支撑作用的。用下面这张图比较容易说明这个思路。

CQRS

很多情况产品构建出来的数据展示,需要横跨几个领域的数据的支撑,也就是我们日常构建的大宽表,在这种情况使用CQRS模式可以完美解决这个问题。其主导视图模型和领域模型分开,让领域模型更加专注业务逻辑,流程和规则而非业务视图。 

CQRS的思想很简单,就是把服务中对数据的更新操作(Command)和读取操作(Query)分离, 一部分逻辑只处理和数据更新有关的业务,另外一部分只处理和数据读取有关的逻辑。这种处理方式,可以让我们辛苦构建的领域模型不被业务中所需要的这类视图需求所干扰。

这里的读操作指的是用户在视图上的读取操作,并不是事务内的读取操作。所以他不受事务是否提交成功的约束。

领域建模

现在开始讲解"战术模式",也就是向大家介绍DDD是如何构建和组织自己领域层的。值得一提的是在DDD中,领域的划分, 领域层次的建立, 领域之间关系的建立我们一般叫做DDD的"战略模式",而此章节提到的值对象,实体,域服务,工厂,repository, 聚合/聚合根, 领域事件等都是DDD的战术模式。战略模式的重要性是要远大于DDD的战术模式的,我们如果在领域划分,领域通信协议,分层方面没有大的问题, 那么即使再糟糕系统整体也还是可控的。 在领域层面, DDD通过聚合/聚合根的概念来划分单个领域中的类似于类集合的边界,从而降低单个领域层的复杂度。DDD通过实体,值对象,领域服务,repository, factory 来规划集合内部的类组织, 另外DDD也通过领域事件来处理领域之间的交互,来匹配异步和需要解耦的业务场景。

实体

当我们需要考虑一个对象的个性特征,获取需要区分对象的时候,就需要引入实体。一般我们发现实体概念,是在和业务产品人员或者领域专家讨论发现的那些需要有唯一标示性或者生命周期连续性很重要的时候。举个例子加入用户需要预定酒店,如果领域专家说了我们定了A酒店了,就不能定B酒店了,哪怕A,B其他的属性完全一样。从领域专家扣中我们可以识别出酒店是有唯一标示性的,且哪怕A,B属性一样,也不能认为A,B 是一样的,这也说明了酒店的唯一性不是从属性来的。这两点我们可以推断酒店是一个实体。唯一标示性可以是现实有意义的,例如工商注册号,也可以无现实意义,例如数据库主键。 

实体建模的注意点

1、为实体分配唯一标识符

  • 现实意义标识符
  • 人工生成的标识 :自增、uuid、数据库主键、自定义sequence

2、验证和不变行
实体必须自己负责自己保持自己状态的合法性 (validation) 和不变性(Invariants)。他们的区别是合法性是根据上下文的,而不变性是不用考虑上下文且必须正确的。例如酒店必须有房间这个就是不变性,而酒店的营业时间就是validation.

3、聚焦在行为,而不是属性状态
不要暴露属性给外面,如果外面得到属性,很可能就自己实现了一些领域逻辑,那么领域逻辑就外漏了。

 4、把一些行为逻辑下方到值对象中
需要警惕实体逻辑膨胀,从而混绕了实体所要表达的概念。 例如预定是一个实体, 现在要加上逻辑预定的天数不能小于N天。这个时候我们可以为Booking 抽象出 Stay 对象,让Stay对象去管理规则逻辑。而不是让预定这个实体去做。让预定只关注预定。 

5、不要为世界建模
不要过度设计,只要满足需求就好。不要让技术需求污染领域设计,除非真的万不得已。

6、分布式的设计
不需要让领域概念横跨多个bounded context, 如果我们域模型所涉及的概念横跨了,我们就需要用两种设计方法.

1.只是用id引用;2.value objects

值对象

什么时候需要使用到值对象?

概念需要凸显的时候。 or 表述一个描述性的,但是没有实体编号的概念的时候。

值对象的特征

无标识:他们只是标识对象的属性。

基于属性的相等性: 所有的属性值相等即值对象相等

富含行为:值对象实现业务概念的抽象,其也有自己的行为

内聚:将不同的相关属性组成一个概念整体,例如Money, 是由一个long 和一个currency组成的

不变性:值对象是不变的对象,如果需要改变属性,那最好是建立一个新的对象并且进行值对象替换。如果一定是需要改变,那就需要考虑设置为值对象是否合理。

不变性是值对象非常重要的一个属性,是可以保障值对象不会被"坏味道"代码侵入的一个原则之一。 例如如果一个值对象引入了另外一个类实例, 另外一个值对象也引入了相同的类实例, 如果值对象允许改动,当一个值对象对这个类实例的内容进行修改,势必会影响另外一个值对象。 所以最安全的方式还是通过对象替换的方式。

域服务 

什么时候用域服务
发现和多个实体相关联,但是放入任何一个单独的实体都不适合,这个适合用域服务.

域服务应该包含什么内容
域服务应该包含业务/系统流程和业务规则,不应该包含技术的元素在内,技术的元素都应该在业务服务(Application Service)中实现。

应用服务与领域服务的区别:

领域服务掌握领域知识,而应用服务只是对领域服务的编排。

应用服务是领域服务的客户方,也就是说应用服务会调用领域服务里的方法。

当领域中的某个操作过程不属于实体或者值对象的职责时,需要将个操作放在领域服务中。而且确保领域服务是无状态的 。

领域服务中包含的是业务逻辑,而应用服务关注的应该是安全和事务等非业务逻辑。对事务的管理绝对不能放在领域服务层,事务管理需要放在应用服务层。因为和领域模型相关的操作的粒度都很细,无法用于事务管理。而且领域模型也不应该意识到事务的存在。

通常的可以放在应用服务中的逻辑有:参数验证、错误处理、监控日志、事务处理、认证与授权。

聚合/聚合根

聚合是什么?
其实聚合的原理和领域划分,限界上下文划分的原理是一致的,都是为了通过归类分组的方式让整个系统宏观上 N * N 的关系复杂度减低为 T * T 的复杂度。 T远小于N。

 聚合前: 

聚合后:

聚合设计的原则

聚合是用来封装真正的不变性,而不是简单的将对象组合在一起;

聚合应尽量设计的小;

聚合之间的关联通过ID,而不是对象引用;

聚合内强一致性,聚合之间最终一致性;

什么是聚合根

聚合根就是聚合的入口,聚合外部只能通过聚合根和聚合内部通信。由于聚合外部只能通过聚合根和聚合内部通信, 这也就意味着外部不能操作除聚合根以外的任何类进行数据库操作,因为这样有可能会导致破坏业务的规则。
举个例子,一个汽车四个轮子,如果我们用 Repo 直接操作轮子,对轮子采取delete,而这个时候汽车对象的状态却可能是 "running".

如何选出聚合根

1、聚合根一定至少有一个对应的datastore 
2、聚合根一定更够完全描述一组后者多组业务规则(invariant)。绝对不会存在一个业务规则需要多个聚合根联合作用才能做判断的。
3、聚合根一定有自己的独立的生命周期。

Repository

reponsitory主要用来处理集合根的存储和获取的。提供一个facade接口且是面向domain层的,是domain model和data model的桥梁。其最大的作用就是通过反向依赖的方式充分隔离数据层和领域层。Repository最常见的用法是被applicaiton service层去使用获取聚合根。在repository实现中,我们一般会有下面的一些逻辑:

uniqe ID 的生成

数据库的操作

数据模型到领域模型的相互转换

横跨多个数据模型构建出一个实体模型。

编码

项目/应用的规范设计的长期价值一定是不可忽视的,规范就相当于一个架构内部组件的收纳的容器,架构指定了这些组件在逻辑上的组织形式,但而规范则是则是妥妥的物理组织形式。如果没有合理清晰的规范设计,项目很快就会因为个人开发习惯的不同,导致物理结构混乱,给接下来的应用/项目的维护和扩展产生影响。规范设计总共分为两类:

放对位置

程序架构目录

贴好标签

类名约定

方法名约定

错误码约定

Domain Event约定

测试约定

层次包名功能
Adapter层web处理页面请求的Controller
event处理事件的请求
feign处理feign的请求
job处理定时器的请求
message处理消息的请求
App层convertor包含转换层类的目录
interceptor包含拦截器目录
handlerapp 层的一些中间处理逻辑
Domain层domain领域模型
Infra层repository仓储的实现实现
mapperibatis数据库映射
config配置信息
Client SDKapi服务对外透出的API
vo服务对外的DTO

总结:

领域驱动设计是一套指导从需求到代码实现的设计方法论,其解决的问题是业务复杂度的问题。

DDD 落地分为两个阶段,战略设计、战术设计。战略设计解决真实世界需求到设计模型(包括服务划分、团队划分、概念模型设计、问题域识别等)的映射,战术设计解决的是设计模型和代码的映射关系(包括代码分层,领域模型实现)

我们可以把 DDD 看作是软件设计到编码落地各个阶段的模式。就像学习设计模式一样:
1、首先要知道这个模式解决的是什么问题?
2、你是不是遇到了这个问题?
3、该模式是如何解决这个问题的,是否可以变通裁剪?(避免生搬硬套)

不必为所有场景建模,只为核心域进行建模。

如果你的业务场景不打算用完整的 DDD 来落地,也建议你在模型设计阶段考虑 DDD 的一些思考方式,比如聚合根、值对象。(因为我们目前的很多业务场景并不是一种存储模型可以通吃的,通常需要多种存储一起使用。)

Leave a Reply

Your email address will not be published. Required fields are marked *