复杂软件设计的解决方案

DDD是Domain-Driven Design(领域驱动设计)的简称,它是一种解决复杂软件设计的方法论,它试图分离技术实现的复杂性,并围绕业务概念构建的领域模型,以解决软件难以理解,难以演进的问题。

传统的软件开发流程

我们目前的开发模式一般是这样的:需求分析 -> 数据库设计 -> 编码

数据库设计环节的目的,我认为更多的是站在数据的角度来翻译(理解)业务,既把业务的变化映射成数据的流转,以此来降低开发人员理解业务的难度。出发点是好的,但是实际情况确事与愿违,时间长了我们的工作就变成了数据的搬运工,眼里只有了数据没有了业务。

自行脑补一下我们写XxDao、XxServiceImpl时的感觉吧!是不是某某功能,其实就是把某个数据库的值更新成某个指定的值~

这样也可以把系统开发出来,并且在一定时间内也不会出现什么问题。但是,如果团队中来了新人,Ta该如何理解原有的业务呢?
看产品原型?分析数据库表设计?分析Service中的业务代码?……
时间成本大,信息分布散乱,很容易出现理解偏差的问题。

随着时间的推移,系统必然会变得越来越复杂,当有一天我们的业务实在是多的维护不动了,该怎么办?

重构?嗯,是个好主意,但是,怎么做呢?从头开始看原型、分析数据库设计、分析Service中的所有逻辑、分析和其他系统的交互……先不说能不能把上面的事都做了,即使把所有业务都梳理了一遍,在现有的框架下,有人敢动手重构吗?还不如说服公司推翻重写吧。

使用DDD的软件开发流程

DDD号称可以解决复杂的软件设计,他是如何处理这种情况的呢?

相对于之前的『面向数据库编程』,DDD是围绕领域模型编程,它的工作流程大致是这样的:

领域模型是最基本的业务单元,它不仅包含业务的具体属性,还包含了相应的行为,它通过映射真实业务场景,将业务行为集中在领域模型中(充血模型),这样不仅仅真正体现了面向对象的思想,也降低了开发人员对业务需求理解的复杂度,即使换了人员来维护,新同事只需要搞清楚领域模型就可以理解业务,而不必像之前一样需要通过数据库表、Service等等来理解业务。

在进行领域分析时,DDD有一套自己的指导思想,借助于事件风暴,我们就可以落地领域分析:

  • 战略设计(宏观方向)
    从业务视角出发:建立领域模型、确定通用语言、划分限界上下文,以此来从大方向指导微服务的拆分。

  • 战术设计(微观方向)
    从技术视角出发:侧重于领域模型的技术实现,围绕领域模型完成软件开发和落地。
    DDD是围绕领域模型编程,所有的业务逻辑都集成在领域对象中,这里弱化了数据库,它只是领域模型持久化的一种方式,一种非必要的方式。

在面对复杂的软件设计时,DDD先从宏观方面进行微服务拆分,确定各个业务领域的边界,然后在特定的业务域内围绕领域模型进行分析,识别出实体、值对象、聚合关系等内容,最后再进行系统的编码落地。在整个过程中,事件风暴是我们常用的一种方法。

DDD中的基本术语

DDD是一种解决复杂软件设计的方法论,接触新概念时,必然会涉及到一些陌生的术语,一些人在看到陌生的概念之后就望而生畏,其实大可不必,抓住事物的本质即可,无论这些术语叫什么,他们都会为了消除歧义而存在的,一旦消除了沟通上的歧义,在配合上一些战术上的方法,我们就可以落地DDD了。

通用语言 & 限界上下文

在软件系统设计的过程中一般会有很多人参与:业务、产品、架构师、开发人员、测试等,不同的角色站在各自的角度看待同一个问题,最终看到的结果可能不同,更要命的是往往我们以为在会议上达成了一致,但实际上每个人的理解确是千差万别。

比如有一个『发货仓』的概念:
普通配送流程:A仓 -> 用户,发货仓就是A仓
但是,在内配(跨仓调货)流程中呢? A仓 -> B仓 -> 用户,发货仓是谁?

X:货物是从A仓发出的,所以发货仓是A仓。
Y:货物是从B仓配送到用户的,对于用户来讲,发货仓应该是B仓。

试想一下,如果对于同一个术语的理解不一致,开发出来的代码逻辑必然是有问题的。其实,如果我们能给『发货仓』下一个定义,并且在团队中达成了共识,这样就不会出现歧义了,如果在讨论的时候还能限定出一个业务范围,那么效果会更好。

  • 通用语言
    通过团队交流达成共识的,能够简单、清晰、准确描述业务含义和规则的语言就是通用语言。
    也就是说,通用语言是团队统一的语言,不管你在团队中承担什么角色,都可以使用统一的语言进行无障碍的交流。

  • 限界上下文
    这里可以拆分成两个意思:限界和上下文。限界就是边界,而上下文则是语义环境。通过领域的限界上下文,我们就可以在统一的领域边界内用统一的语言进行交流。

实体 & 值对象

实体:拥有唯一标识符的对象称为实体,比如学生(有学生ID)就是一个实体。对于实体而言,重要的不是其他属性,而是唯一标识,和其延续性。

比如学生今天可以剪个寸头,明天可以染个红头发,无论学生怎么变,他的学号是唯一的。

值对象:没有唯一标识符(或者所有属性结合起来唯一标识)的对象叫做值对象。他是一种结构或者说是一组有关联的属性的聚合体,比如地图上的一个点(经纬度)或者用户的地址(省、市、区、门牌号)

实体是领域模型中的一个重要对象,他是对象属性和行为的载体。

聚合 & 聚合根

  • 聚合
    描述的是领域对象之间的关系,比如订单和订单明细项之间就是聚合关系,订单和订单明细项之间具有相同的生命周期,订单明细不能脱离订单独立存在。

    聚合就是由业务和逻辑紧密关联的实体和值对象组合而成的,聚合是数据修改和持久化的基本单元,每一个聚合对应一个仓库,实现数据的持久化。

  • 聚合根
    聚合根的主要目的是为了避免由于复杂数据模型缺少统一的业务规则控制,而导致聚合、实体之间数据不一致性的问题。

    传统数据模型中的每一个实体都是对等的,如果任由实体进行无控制地调用和数据修改,很可能会导致实体之间数据逻辑的不一致。而如果采用锁的方式则会增加软件的复杂度,也会降低系统的性能。

    如果把聚合比作组织,那聚合根就是这个组织的负责人。聚合根也称为根实体,它不仅是实体,还是聚合的管理者。

    在聚合之间,聚合根还是聚合对外的接口人,以聚合根ID关联的方式接受外部任务和请求,在上下文内实现聚合之间的业务协同。也就是说,如果需要访问其它聚合的实体,就要先访问聚合根,再导航到聚合内部实体,外部对象不能直接访问聚合内实体。

举个例子:假如微信账号被删除了,微信账户里面的余额还能使用吗?

肯定不能!但是在我们传统的MVC结构中,账号和资金账户可能使用了不同的Service,如果某个功能正好需要引入资金账户的Service,传入了已经被标记为删除的微信账号信息,那么是不是就可能存在不验证账号状态而直接操作资金账户的情况?

在DDD中是如何避免这种情况的呢?

经过分析可知:微信的资金账户一定归属于某个微信账号下,他们具有相同的生命周期,所以资金账户实体和微信账号实体之间是聚合关系(资金账户实体归属于账号实体),微信账号实体是聚合根。在业务上,微信的资金账户不会脱离微信账号而独立存在,所以如果要操作资金账户,就必须先获取到微信账号实体,如果微信账号实体不存在了,就没有如何入口可以操作该账号下的资金账户了。

事件风暴:微服务拆分的利器

简介

事件风暴是一种探索复杂业务全貌的活动,它要求我们面对面沟通,将讨论内容限定在一定的范围内,使用通用语言(持续的过程)来交流,以此来达到消除歧义的目的。

理想:

现实:

进行事件风暴的过程中,我们会识别出领域事件、命令、角色、领域模型、聚合、限界上下文等内容,最终会形成如下图所示的内容。当内容确定之后,我们就可以以此为依据进行程序设计了。

外卖系统事件风暴:

  • 事件 Event
    事件既事实(已经发生的事),他们是可以被保存下来,或者让别人响应的。

比如外卖系统:
用户已下单事件、商家已接单事件、骑手已送达事件。

事件是对系统产生了业务上的影响的动作,如果仅仅是一个查询操作,就不能被定义为一个事件。

  • 命令 Command
    命令产生了事件,可以理解为产生事件的动作,命令与事件是一一对应的。比如用户注册操作,会发出一个用户注册命令,用户注册命令,会产生一个用户注册成功事件。

通常来讲,一个命令也就对一个程序中的API。

  • 发起命令的参与者 User/Actor
    既命令是由谁发起的,可以是具体用户,也可以是外部系统或者某种规则。

工作流

  • 准备阶段

    1. 一个会议室:足够容纳所有参与讨论的人
    2. 一个白板:需要足够大
    3. 不同颜色的便利贴
  • 工作开始

假设我们要分析一个外卖的配送系统,该如何使用事件风暴进行系统分析呢?

  1. 寻找领域事件
    每个人尽可能的写出自己认为比较重要的『事件』,暂时不用考虑事件之间的先后顺序,也不用过分的考虑事件是否合理,尽可能多的写下所有我们认为的事件。

  1. 寻找命令和角色
    思考每个事件是有什么命令触发的,每个命令是由谁发起的。
    命令的发起者可以是具体的角色,也可以是某种规则或者某个系统。

  2. 寻找领域模型和聚合
    围绕事件,思考与之有关联的业务实体,然后逐个分析实体之间是否存在聚合关系。

  3. 划分子域和限界上线文
    当我们完成了上述步骤之后,其实服务的边界就慢慢清晰了,微服务的划分其实也就出来了。

注意
事件风暴可以打破『部门墙』,让所有人都可以看到业务全貌,避免主观臆断的问题。他适合解决How(怎么做),但是不适合解决Why(为什么做)的问题。

四层架构

微服务的架构模型有很多,比如整洁架构、CQRS、六边形架构等等,这些架构提出的核心理念都是为了设计出『高内聚、低耦合』的系统,同时还可以方便的演进。DDD也提出了一种四层架构模型。

用户接口层

负责向用户现实信息或者接收用户指令。这里的用户可以是真实的个人也可以是其他程序。

应用层

应用层很薄,不包含任何业务规则,主要的作用就是领域服务的编排、组合,共同完成业务操作。
除此之外,微服务之间的调用、鉴权、发布/订阅领域事件等内容,也是在这里完成。

领域层

上面所讨论的实体、聚合、领域事件、领域服务等内容都是在领域层,这里是业务的核心,主要通过领域模型来表达业务能力。
领域模型的业务逻辑主要是通过实体、领域服务来实现的,实体会采用充血模型来实现业务功能。
如果单一实体不能完成领域中的某些功能时,可以通过领域服务来协调多个实体一起协作。

基础层

基础层的作用是为各层提供通用服务,比如数据库持久化、消息中间件等。

落地DDD的基本思路

程序落地

通过事件风暴我们可以识别出服务、实体、值对象,把这些领域模型转化成程序设计时有两种思路:贫血模型、充血模型。

  • 贫血模型
    一个对象中只有一些属性和一堆getter setter,几乎没有其他的方法(业务行为),我们目前使用的MVC结构大多数都是贫血模型。使用这种模型变成,也叫面向数据库表编程,写出的代码也叫事务脚本(总之都是围绕数据库开展的)

  • 充血模型
    对象中不仅仅包含数据,还包含了该对象的行为,这些行为往往对应着相应的业务。比如学校的学生,不仅仅包含姓名、性别、年龄、班级等属性,还具有study()的行为。

其实这才是我们入门时学的面向对象编程:一个对象,应该具有什么样的行为,对象和行为是绑定的,而不是借助于一个XXXService来完成对象应该具备的行为。

数据库落地

前文中说到,数据库只是领域对象持久化的一种方式,领域对象可以持久化到RDBMS中,也可以持久化到NoSQL中,甚至还可以存在文件系统中,无论存在哪儿,DDD不是特别关心。

这是很重要的思想转变!
在DDD中,我们聚焦的是领域模型,他包含了具体的业务逻辑,所有的业务价值都在领域模型中。

虽然说DDD不是特别关注对象的持久化,但是其实当完成事件风暴之后,已经隐式的完成了数据库设计。事件风暴中的独立的实体可以单独设计一张表,具有聚合关系的实体可以设计成主表、子表的结构,中间通过实体ID进行关联。

实现高质量的软件设计

软件,在一定程度上来讲,应该可以反应真实世界,如果要设计出高质量的软件,其实就是做好软件和真实世界的映射关系。

现实世界的事物对应着DDD中的领域对象;
现实世界中事物的行为,对应着领域对象中的方法;
现实世界事物与事物之间的关系,对应着DDD中领域对象之间的关联(聚合)。

真实世界是在不断变化的,软件也应该是不断变化的,正是由于变化的存在,软件必然会变得越来越复杂,如果在软件变化的过程中,我们不加以干预,软件就会变得臃肿不堪,到最后就无法维护,DDD是如何应对这一情况的呢?

  1. 每次变更时,先回到领域模型
  2. 基于业务进行领域模型的变更

简而言之,每次软件变化,都从领域模型开始,结合现实世界的情况,给模型增加相关的行为,这里要把握一个原则:单一职责。软件中的每个元素都应该只完成自己职责范围内的事,其他的事由其他人完成,我只负责调用。

如何定义高质量的代码?
如果每次提出一个需求时,软件修改的范围最小,测试范围可控,出问题的风险也最小,那么这样的代码质量就很高。

如何确保每次新需求来时,影响范围最小呢?
这就需要我们平时不断的整理代码(不断的小范围重构):将因为同一个原因而变更的代码放在一起,将因为不同原因而变更的代码放在不同的模块中。

举个例子:
早期的订单付款功能,订单中商品的单价乘以数量,既为此订单应付金额。
代码很好写。

为了配合运营活动,增加了满减规则,满500减50,此时代码应该如何调整呢?
在计算出订单总额之后,添加是否大于500的判断逻辑吗?

如果新增了双11,全场8折的逻辑呢?
继续在计算订单金额的逻辑中修改吗?

有人会说,这里可以使用策略模式。有没有人会想到第一次做满减活动时就使用策略模式呢?这里其实是有一个套路可循的。

我们首选要来分析一下付款和打折之间的关系:

  1. 付款功能的变化,是否会影响打折功能?
  2. 打折功能的变化,是否会影响付款功能?

两个答案都是否定的,所以回到之前说的话『将因为同一个原因而变更的代码放在一起,将因为不同原因而变更的代码放在不同的模块中』,因为付款和打折逻辑互不干扰,所以他们之间不应该耦合在一起,各自的逻辑应该分开维护。他们之间有联系的地方在于付款逻辑中提供一个打折的扩展点(打折接口),具体的打折逻辑由程序运行的时候再具体选择实现类即可。

同理,后续如果要增加不同的付款渠道呢?

也是一样的处理,付款逻辑中增加一个付款渠道的扩展点(接口)即可。