1

2004 年,软件大师 Eric Evans 的不朽著作《领域驱动设计:软件核心复杂性应对之道》面世,从书名可以看出,这是一本应对软件系统越来越复杂的方法论的图书。然而,在当时,中国的软件业才刚刚起步,软件系统还没有那么复杂,即使维护了几年,软件退化了,不好维护了,推倒重新开发就好了。因此,在过去的那么多年里,真正运用领域驱动设计开发(DDD)的团队并不多。一套优秀的方法论,因为现实阶段的原因而一直不温不火。

不过,这些年随着中国软件业的快速发展,软件规模越来越大,生命周期也越来越长,推倒重新开发的成本和风险越来越大。这时,软件团队急切需要在较低成本的状态下持续维护一个系统很多年。然而,事与愿违。随着时间的推移,程序越来越乱,维护成本越来越高,软件退化成了无数软件团队的噩梦。

这时,微服务架构成了规模化软件的解决之道。不过,微服务对设计提出了很高的要求,强调“小而专、高内聚”,否则就不能发挥出微服务的优势,甚至可能令问题更糟糕。

因此,微服务的设计,微服务的拆分都需要领域驱动设计的指导。那么,领域驱动为什么能解决软件规模化的问题呢?我们先从问题的根源谈起,即软件退化。

软件退化的根源

最近 10 年的互联网发展,从电子商务到移动互联,再到“互联网+”与传统行业的互联网转型,是一个非常痛苦的转型过程。而近几年的人工智能与 5G 技术的发展,又会带动整个产业向着大数据与物联网发展,另一轮的技术转型已经拉开帷幕。

那么,在这个过程中,一方面会给我们带来诸多的挑战,另一方面又会给我们带来无尽的机会,它会带来更多的新兴市场、新兴产业与全新业务,给我们带来全新的发展机遇。

然而,在面对全新业务、全新增长点的时候,我们能不能把握住这样的机遇呢?我们期望能把握住,但每次回到现实,回到正在维护的系统时,却令人沮丧。我们的软件总是经历着这样的轮回,软件设计质量最高的时候是第一次设计的那个版本,当第一个版本设计上线以后就开始各种需求变更,这常常又会打乱原有的设计。

因此,需求变更一次,版本迭代一次,软件就修改一次,软件修改一次,质量就下降一次。不论第一次的设计质量有多高,软件经历不了几次变更,就进入一种低质量、难以维护的状态。进而,团队就不得不在这样的状态下,以高成本的方式不断地维护下去,维护很多年。

这时候,维护好原有的业务都非常不易,又如何再去期望未来更多的全新业务呢?比如,这是一段电商网站支付功能的设计,最初的版本设计质量还是不错的:

image.png

当第一个版本上线以后,很快就迎来了第一次变更,变更的需求是增加商品折扣功能,并且这个折扣功能还要分为限时折扣、限量折扣、某类商品的折扣、某个商品的折扣。当我们拿到这个需求时怎么做呢?很简单,增加一个 if 语句,if 限时折扣就怎么怎么样,if 限量折扣就怎么怎么样……代码开始膨胀了。

接着,第二次变更需要增加 VIP 会员,除了增加各种金卡、银卡的折扣,还要为会员发放各种福利,让会员享受各种特权。为了实现这些需求,我们又要在 payoff() 方法中加入更多的代码。

第三次变更增加的是支付方式,除了支付宝支付,还要增加微信支付、各种银行卡支付、各种支付平台支付,此时又要塞入一大堆代码。经过这三次变更,你可以想象现在的 payoff() 方法是什么样子了吧,变更是不是就可以结束了呢?其实不能,接着还要增加更多的秒杀、预订、闪购、众筹,以及各种返券。程序变得越来越乱而难以阅读和维护,每次变更也变得越来越困难。

image.png

问题来了:为什么软件会退化,会随着变更而设计质量下降呢?在这个问题上,我们必须寻找到问题的根源,才能对症下药、解决问题。

要探寻软件退化的根源,先要从探寻软件的本质及其规律开始,软件的本质就是对真实世界的模拟,每个软件都能在真实世界中找到它的影子。因此,软件中业务逻辑正确与否的唯一标准就是是否与真实世界一致。如果一致,则软件是 OK 的;不一致,则用户会提 Bug、提新需求。

在这里发现了一个非常重要的线索,那就是,软件要做成什么样,既不由我们来决定,也不由用户来决定,而是由客观世界决定。用户为什么总在改需求,是因为他们也不确定客观世界的规则,只有遇到问题了他们才能想得起来。因此,对于我们来说,与其唯唯诺诺地按照用户的要求去做软件,不如在充分理解业务的基础上去分析软件,这样会更有利于我们减少软件维护的成本。

那么,真实世界是怎样的,我们就怎样开发软件,不就简单了吗?其实并非如此,因为真实世界是非常复杂的,要深刻理解真实世界中的这些业务逻辑是需要一个过程的。因此,我们最初只能认识真实世界中那些简单、清晰、易于理解的业务逻辑,把它们做到我们的软件里,即每个软件的第一个版本的需求总是那么清晰明了、易于设计。

然而,当我们把第一个版本的软件交付用户使用的时候,用户却会发现,还有很多不简单、不明了、不易于理解的业务逻辑没做到软件里。这在使用软件的过程中很不方便,和真实业务不一致,因此用户就会提 Bug、提新需求。

在我们不断地修复 Bug,实现新需求的过程中,软件的业务逻辑也会越来越接近真实世界,使得我们的软件越来越专业,让用户感觉越来越好用。但是,在软件越来越接近真实世界的过程中,业务逻辑就会变得越来越复杂,软件规模也越来越庞大。

你一定有这样一个认识:简单软件有简单软件的设计,复杂软件有复杂软件的设计。

比如,现在的需求就是将用户订单按照“单价 × 数量”公式来计算应付金额,那么在一个 PaymentBus 类中增加一个 payoff() 方法即可,这样的设计没有问题。不过,如果现在的需要在付款的过程中计算各种折扣、各种优惠、各种返券,那么我们必然会做成一个复杂的程序结构。

image.png

但是,真实情况却不是这样的。真实情况是,起初我们拿到的需求是那个简单需求,然后在简单需求的基础上进行了设计开发。但随着软件的不断变更,软件业务逻辑变得越来越复杂,软件规模不断扩大,逐渐由一个简单软件转变成一个复杂软件。

这时,如果要保持软件设计质量不退化,就应当逐步调整软件的程序结构,逐渐由简单的程序结构转变为复杂的程序结构。如果我们总是这样做,就能始终保持软件的设计质量,不过非常遗憾的是,我们以往在维护软件的过程中却不是这样做的,而是不断地在原有简单软件的程序结构下,往 payoff() 方法中塞代码,这样做必然会造成软件的退化。

也就是说,软件退化的根源不是版本迭代和需求变更,版本迭代和需求变更只是一个诱因。如果每次软件变更时,适时地进行解耦,进行功能扩展,再实现新的功能,就能保持高质量的软件设计。但如果在每次软件变更时没有调整程序结构,而是在原有的程序结构上不断地塞代码,软件就会退化。这就是软件发展的规律,软件退化的根源。

杜绝软件退化:两顶帽子

前面谈到,要保持软件设计质量不退化,必须在每次需求变更的时候,对原有的程序结构适当地进行调整。那么应当怎样进行调整呢?还是回到前面电商网站付款功能的那个案例,看看每次需求变更应当怎样设计。

在交付第一个版本的基础上,很快第一次需求变更就到来了。第一次需求变更的内容如下。

增加商品折扣功能,该功能分为以下几种类型:

  • 限时折扣
  • 限量折扣
  • 对某类商品进行折扣
  • 对某个商品进行折扣
  • 不折扣

以往我们拿到这个需求,就很不冷静地开始改代码,修改成了如下一段代码:

image.png

这里增加了的 if else 语句,并不是一种好的变更方式。如果每次都这样变更,那么软件必然就会退化,进入难以维护的状态。这种变更为什么不好呢?因为它违反了“开放-封闭原则”。

开闭原则(OCP) 分为开放原则与封闭原则两部分。

  • 开放原则:我们开发的软件系统,对于功能扩展是开放的(Open for Extension),即当系统需求发生变更时,可以对软件功能进行扩展,使其满足用户新的需求。
  • 封闭原则:对软件代码的修改应当是封闭的(Close for Modification),即在修改软件的同时,不要影响到系统原有的功能,所以应当在不修改原有代码的基础上实现新的功能。也就是说,在增加新功能的时候,新代码与老代码应当隔离,不能在同一个类、同一个方法中。

前面的设计,在实现新功能的同时,新代码与老代码在同一个类、同一个方法中了,违反了“开闭原则”。怎样才能既满足“开闭原则”,又能够实现新功能呢?在原有的代码上你发现什么都做不了!难道“开闭原则”错了吗?

问题的关键就在于,当我们在实现新需求时,应当采用“两顶帽子”的方式进行设计,这种方式就要求在每次变更时,将变更分为两个步骤。

两顶帽子:

  • 在不添加新功能的前提下,重构代码,调整原有程序结构,以适应新功能;
  • 实现新的功能。

按以上案例为例,为了实现新的功能,我们在原有代码的基础上,在不添加新功能的前提下调整原有程序结构,我们抽取出了 Strategy 这样一个接口和“不折扣”这个实现类。这时,原有程序变了吗?没有。但是程序结构却变了,增加了这样一个接口,称之为“可扩展点”。在这个可扩展点的基础上再实现各种折扣,既能满足“开放-封闭原则”来保证程序质量,又能够满足新的需求。当日后发生新的变更时,什么类型的折扣有变化就修改哪个实现类,添加新的折扣类型就增加新的实现类,维护成本得到降低。

image.png

“两顶帽子”的设计方式意义重大。过去,我们每次在设计软件时总是担心日后的变更,就很不冷静地设计了很多所谓的“灵活设计”。然而,每一种“灵活设计”只能应对一种需求变更,而我们又不是先知,不知道日后会发生什么样的变更。最后的结果就是,我们期望的变更并没有发生,所做的设计都变成了摆设,它既不起什么作用,还增加了程序复杂度;我们没有期望的变更发生了,原有的程序依然不能解决新的需求,程序又被打回了原形。因此,这样的设计不能真正解决未来变更的问题,被称为“过度设计”。

有了“两顶帽子”,我们不再需要焦虑,不再需要过度设计,正确的思路应当是“活在今天的格子里做今天的事儿”,也就是为当前的需求进行设计,使其刚刚满足当前的需求。所谓的“高质量的软件设计”就是要掌握一个平衡,一方面要满足当前的需求,另一方面要让设计刚刚满足需求,从而使设计最简化、代码最少。这样做,不仅软件设计质量提高了,设计难点也得到了大幅度降低。

简而言之,保持软件设计不退化的关键在于每次需求变更的设计,只有保证每次需求变更时做出正确的设计,才能保证软件以一种良性循环的方式不断维护下去。这种正确的设计方式就是“两顶帽子”。

但是,在实践“两顶帽子”的过程中,比较困难的是第一步。在不添加新功能的前提下,如何重构代码,如何调整原有程序结构,以适应新功能,这是有难度的。很多时候,第一次变更、第二次变更、第三次变更,这些事情还能想清楚;但经历了第十次变更、第二十次变更、第三十次变更,这些事情就想不清楚了,设计开始迷失方向。

那么,有没有一种方法,让我们在第十次变更、第二十次变更、第三十次变更时,依然能够找到正确的设计呢?有,那就是“领域驱动设计”。

保持软件质量:领域驱动

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

那么,如何将真实世界与软件世界对应起来呢?这样的对应就包括以下三个方面的内容:

  • 真实世界有什么事物,软件世界就有什么对象;
  • 真实世界中这些事物都有哪些行为,软件世界中这些对象就有哪些方法;
  • 真实世界中这些事物间都有哪些关系,软件世界中这些对象间就有什么关联。

image.png

(真实世界与软件世界的对应图)

在领域驱动设计中,就将以上三个对应,先做成一个领域模型,然后通过这个领域模型指导程序设计;在每次需求变更时,先将需求还原到领域模型中分析,根据领域模型背后的真实世界进行变更,然后根据领域模型的变更指导软件的变更,设计质量就可以得到提高。

结合电商支付实际演练DDD

现在,我们以电商网站的支付功能为例,来演练一下基于 DDD 的软件设计及其变更的过程。

运用 DDD 进行软件设计

开发人员在最开始收到的关于用户付款功能的需求描述是这样的:

  • 在用户下单以后,经过下单流程进入付款功能;
  • 通过用户档案获得用户名称、地址等信息;
  • 记录商品及其数量,并汇总付款金额;
  • 保存订单;
  • 通过远程调用支付接口进行支付。

以往当拿到这个需求时,开发人员往往草草设计以后就开始编码,设计质量也就不高。

而采用领域驱动的方式,在拿到新需求以后,应当先进行需求分析,设计领域模型。按照以上业务场景,可以分析出:

  • 该场景中有“订单”,每个订单都对应一个用户;
  • 一个用户可以有多个用户地址,但每个订单只能有一个用户地址;
  • 此外,一个订单对应多个订单明细,每个订单明细对应一个商品,每个商品对应一个供应商。

最后,我们对订单可以进行“下单”“付款”“查看订单状态”等操作。因此形成了以下领域模型图:

image.png

有了这样的领域模型,就可以通过该模型进行以下程序设计:

image.png

通过领域模型的指导,将“订单”分为订单 Service 与值对象,将“用户”分为用户 Service 与值对象,将“商品”分为商品 Service 与值对象……然后,在此基础上实现各自的方法。

商品折扣的需求变更

当电商网站的付款功能按照领域模型完成了第一个版本的设计后,很快就迎来了第一次需求变更,即增加折扣功能,并且该折扣功能分为限时折扣、限量折扣、某类商品的折扣、某个商品的折扣与不折扣。当我们拿到这个需求时应当怎样设计呢?很显然,在 payoff() 方法中去插入 if else 语句是不 OK 的。这时,按照领域驱动设计的思想,应当将需求变更还原到领域模型中进行分析,进而根据领域模型背后的真实世界进行变更。

image.png

这是上一个版本的领域模型,现在我们要在这个模型的基础上增加折扣功能,并且还要分为限时折扣、限量折扣、某类商品的折扣等不同类型。这时,我们应当怎么分析设计呢?

首先要分析付款与折扣的关系。

付款与折扣是什么关系呢?你可能会认为折扣是在付款的过程中进行的折扣,因此就应当将折扣写到付款中。这样思考对吗?我们应当基于什么样的思想与原则来设计呢?这时,另外一个重量级的设计原则应该出场了,那就是“单一职责原则”。

单一职责原则:软件系统中的每个元素只完成自己职责范围内的事,而将其他的事交给别人去做,我只是去调用。

单一职责原则是软件设计中一个非常重要的原则,但如何正确地理解它成为一个非常关键的问题。在这句话中,准确理解的关键就在于“职责”二字,即自己职责的范围到底在哪里。以往,我们错误地理解这个“职责”就是做某一个事,与这个事情相关的所有事情都是它的职责,正因为这个错误的理解,带来了许多错误的设计,而将折扣写到付款功能中。那么,怎样才是对“职责”正确的理解呢?

“一个职责就是软件变化的一个原因”是著名的软件大师 Bob 大叔在他的《敏捷软件开发:原则、模式与实践》中的表述。但这个表述过于精简,很难深刻地理解其中的内涵。这里我好好解读一下这句话。

先思考一下什么是高质量的代码?你可能立即会想到“低耦合、高内聚”,以及各种设计原则,但这些评价标准都太“虚”。最直接、最落地的评价标准就是,当用户提出一个需求变更时,为了实现这个变更而修改软件的成本越低,那么软件的设计质量就越高。当来了一个需求变更时,怎样才能让修改软件的成本降低呢?如果为了实现这个需求,需要修改 3 个模块的代码,完后这 3 个模块都需要测试,其维护成本必然是“高”。那么怎样才能降到最低呢?如果只需要修改 1 个模块就可以实现这个需求,维护成本就要低很多了。

那么,怎样才能在每次变更的时候都只修改一个模块就能实现新需求呢?那就需要我们在平时就不断地整理代码,将那些因同一个原因而变更的代码都放在一起,而将因不同原因而变更的代码分开放,放在不同的模块、不同的类中。这样,当因为这个原因而需要修改代码时,需要修改的代码都在这个模块、这个类中,修改范围就缩小了,维护成本降低了,修改代码带来的风险自然也降低了,设计质量也就提高了。

总之,单一职责原则要求我们在维护软件的过程中需要不断地进行整理,将软件变化同一个原因的代码放在一起,将软件变化不同原因的代码分开放。按照这样的设计原则,回到前面那个案例中,那么应当怎样去分析“付款”与“折扣”之间的关系呢?只需要回答两个问题:

  • 当“付款”发生变更时,“折扣”是不是一定要变?
  • 当“折扣”发生变更时,“付款”是不是一定要变?

当这两个问题的答案是否定时,就说明“付款”与“折扣”是软件变化的两个不同的原因,那么把它们放在一起,放在同一个类、同一个方法中,合适吗?不合适,就应当将“折扣”从“付款”中提取出来,单独放在一个类中。

同样的道理:

当“限时折扣”发生变更的时候,“限量折扣”是不是一定要变?

当“限量折扣”发生变更的时候,“某类商品的折扣”是不是一定要变?

……

最后发现,不同类型的折扣也是软件变化不同的原因。将它们放在同一个类、同一个方法中,合适吗?通过以上分析,我们做出了如下设计:

image.png

在该设计中,将折扣功能从付款功能中独立出去,做出了一个接口,然后以此为基础设计了各种类型的折扣实现类。这样的设计,当付款功能发生变更时不会影响折扣,而折扣发生变更的时候不会影响付款。同样,当“限时折扣”发生变更时只与“限时折扣”有关,“限量折扣”发生变更时也只与“限量折扣”有关,与其他折扣类型无关。变更的范围缩小了,维护成本就降低了,设计质量提高了。这样的设计就是“单一职责原则”的真谛。

接着,在这个版本的领域模型的基础上进行程序设计,在设计时还可以加入一些设计模式的内容,因此我们进行了如下的设计:

image.png

显然,在该设计中加入了“策略模式”的内容,将折扣功能做成了一个折扣策略接口与各种折扣策略的实现类。当哪个折扣类型发生变更时就修改哪个折扣策略实现类;当要增加新的类型的折扣时就再写一个折扣策略实现类,设计质量得到了提高。

VIP 会员的需求变更

在第一次变更的基础上,很快迎来了第二次变更,这次是要增加 VIP 会员,业务需求如下。

增加 VIP 会员功能:

  • 对不同类型的 VIP 会员(金卡会员、银卡会员)进行不同的折扣;
  • 在支付时,为 VIP 会员发放福利(积分、返券等);
  • VIP 会员可以享受某些特权。

我们拿到这样的需求又应当怎样设计呢?同样,先回到领域模型,分析“用户”与“VIP 会员”的关系,“付款”与“VIP 会员”的关系。在分析的时候,还是回答那两个问题:

  • “用户”发生变更时,“VIP 会员”是否要变;
  • “VIP 会员”发生变更时,“用户”是否要变。

通过分析发现,“用户”与“VIP 会员”是两个完全不同的事物。

  • “用户”要做的是用户的注册、变更、注销等操作;
  • “VIP 会员”要做的是会员折扣、会员福利与会员特权;

而“付款”与“VIP 会员”的关系是在付款的过程中去调用会员折扣、会员福利与会员特权。

通过以上的分析,我们做出了以下版本的领域模型:

image.png

有了这些领域模型的变更,然后就可以以此作为基础,指导后面程序代码的变更了。

支付方式的需求变更

同样,第三次变更是增加更多的支付方式,我们在领域模型中分析“付款”与“支付方式”之间的关系,发现它们也是软件变化不同的原因。因此,我们果断做出了这样的设计:

image.png

而在设计实现时,因为要与各个第三方的支付系统对接,也就是要与外部系统对接。为了使第三方的外部系统的变更对我们的影响最小化,在它们中间果断加入了“适配器模式”,设计如下:

image.png

通过加入适配器模式,订单 Service 在进行支付时调用的不再是外部的支付接口,而是“支付方式”接口,与外部系统解耦。只要保证“支付方式”接口是稳定的,那么订单 Service 就是稳定的。比如:

  • 当支付宝支付接口发生变更时,影响的只限于支付宝 Adapter;
  • 当微信支付接口发生变更时,影响的只限于微信支付 Adapter;
  • 当要增加一个新的支付方式时,只需要再写一个新的 Adapter。

日后不论哪种变更,要修改的代码范围缩小了,维护成本自然降低了,代码质量就提高了。

写在最后

软件发展的规律就是逐步由简单软件向复杂软件转变。简单软件有简单软件的设计,复杂软件有复杂软件的设计。因此,当软件由简单软件向复杂软件转变时,就需要通过两顶帽子适时地对程序结构进行调整,再实现新需求,只有这样才能保证软件不退化。然而,在变更的时候,如何调整代码以适应新的需求呢?

DDD 给了我们思路:在每次变更的时候,先回到领域模型,基于业务进行领域模型的变更。然后,再基于领域模型的变更,指导程序的变更。这样,不论经历多少次需求变更,始终能够保持设计质量不退化。这样的设计,才能保障系统始终在低成本的状态下,可持续地不断维护下去。

本文我们演练了如何运用 DDD 进行软件的设计与变更,以及在设计与变更的过程中如何分析思考、如何评估代码、如何实现高质量。后续文章,我们将结合具体案例分析如何将领域模型的设计进一步落实到软件系统的微服务设计与数据库设计。

来源:二马读书

作者:范钢 曾任航天信息首席架构师,《大话重构》一书的作者。

声明:文章获得作者授权在IDCF社区公众号(devopshub)转发。优质内容共享给思否平台的技术同伴,如原作者有其他考虑请联系小编删除,致谢。

7月每周四晚8点,【冬哥有话说】研发效能工具专场,公众号留言“效能”可获取地址

  • 7月8日,LEANSOFT-周文洋《微软DevOps工具链的 "爱恨情仇"(Azure DevOps)》
  • 7月15日,阿里云智能高级产品专家-陈逊《复杂型研发协作模式下的效能提升实践》
  • 7月22日,极狐(GitLab)解决⽅案架构师-张扬分享《基础设施即代码的⾃动化测试探索》
  • 7月29日,字节跳动产品经理-胡贤彬分享《自动化测试,如何做到「攻防兼备」?》
  • 8月5日,声网 AgoraCICD System负责人-王志分享《从0到1打造软件交付质量保证的闭环》

用户bPcN1SC
149 声望55 粉丝