万字长文,结合电商支付业务一文搞懂DDD

2020-11-30 10:52:37 浏览数 (1)

作者范钢,曾任航天信息首席架构师,《大话重构》一书的作者。本文结合电商支付场景详细描述了领域驱动模型的实际应用。

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

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

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

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

软件退化的根源

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

杜绝软件退化:两顶帽子

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

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

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

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

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

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

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

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

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

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

两顶帽子:

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

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

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

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

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

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

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

保持软件质量:领域驱动

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

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

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

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

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

结合电商支付实际演练DDD


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

运用 DDD 进行软件设计

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

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

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

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

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

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

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

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

商品折扣的需求变更

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

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

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

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

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

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

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

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

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

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

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

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

同样的道理:

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

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

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

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

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

VIP 会员的需求变更

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

增加 VIP 会员功能:

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

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

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

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

  • “用户”要做的是用户的注册、变更、注销等操作;
  • “VIP 会员”要做的是会员折扣、会员福利与会员特权;
  • 而“付款”与“VIP 会员”的关系是在付款的过程中去调用会员折扣、会员福利与会员特权。

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

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

支付方式的需求变更

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

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

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

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

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

0 人点赞