用领域驱动设计实现订单业务的重构
The following article is from 八里庄技术沙龙 Author 八里庄技术沙龙
大家好,我是来自罗辑思维得到app的韩宇斌,很荣幸能有机会和大家分享我的一些心得,我分享的主题是《DDD战略建模在重构业务系统时的实践》。我分享的内容分为三部分:第一部分是:用领域驱动来把握真正的业务需求;第二部分:领域驱动设计指导架构设计与建模;第三部分:用限界上下文来保护领域。
一、用领域驱动来把握真正的业务需求
如果把我今天的分享比作一个故事的话,那么故事的主线是:领域驱动设计帮助我解决了工作的难题。这个难题表现在两个方面,首先无路可退 :入职第一个任务,做不成就意味着回家。其次是左右为难 :实现技术重构的目标,满足不了业务需求!不去实现,又不知道该做什么?
在进入到主题之前,我们先来从商家视角了解一些电商业务的背景知识。一个商家想要卖自己的商品,不管是在线下实体店面,还是线上电商,至少要有卖货,收钱,发货三个环节,如果我们从网上找一个开源的商城系统,也会包括这三部分的功能。然而,对于商家来说,只有这三个环节是不够的,还需要有一个非常重要的环节就是算账。我分享内容,就是和收钱,发货,算账相关的。
我们再来看一下得到APP电商业务涉及的组织和系统。我所在的听书属于业务后端,主要负责卖货和发货环节,而收钱环节需要由交易平台组和基础平台组提供的服务来完成,算账相关的由财务平台组负责。大家先对红框的三个系统有个印象,后面会频繁的提到。
得到app目前如何确认收入呢?财务部门在算账交税前,首先要确认收入,卖了多少商品,收了多少钱,实收账款和应收账款能不能对上。用户在APP内下单后生成订单记录,收到钱后生成支付记录,发货生成权益记录。虚拟商品的发货不需要快递,就是我们在表里面写一条数据。财务部门需要把三张记录表中的每条数据都用订单号关联起来,并且状态含义严格匹配,才能确认为是收入,否则就会成为坏账或者呆账,需要人工处理。
然而,开始的时候,并不是这样的。用户在APP中购买虚拟商品,我们没有生成订单记录,只有支付记录和权益记录。当出现数据不一致时,就给财务确收带来了问题。主要分为两类,只有支付记录没有权益记录数据,是有支付无交付,我们收了钱没发货;而只有权益找不到对应的支付记录的数据,是有交付无支付,发货了没收到钱。每个财务结算周期,两个开发组的人,几乎都要去排查问题数据。你也许会好奇,一个电商平台居然没有订单?我相信“存在即合理”,当时这么做肯定有当时的原因和背景,说白了一切都是为了快速上线,快速验证得到app的商业模式,活下去对于创业公司来说,比设计实现一个完美的系统优先级更高。
我们看下没有订单的情况下,系统之间的调用关系。为了说明核心问题,我挑选了流程最简单的节操币购买的方式,节操币是得到APP内的虚拟货币,节操币系统是由我们的基础平台组负责的。以购买听书的内容为例,APP去请求听书系统的购买接口,然后由听书系统调用节操币系统完成扣款,扣款成功后写入已购。
这张图是没有订单时,听书业务实现全部售卖方式的调用关系,从图中我们可以看到,听书系统需要直接与许多外部系统交互,系统间的依赖和耦合是比较高的。
我们再回到财务结算的场景。由于没有订单,给财务核算工作带来很多问题,财务就要求必须记录订单及交易状态。于是各业务系统就花时间去改造,用户购买时先调用订单系统的CreateOrder来生成订单记录,支付完成后要调用PayOrder来标记订单的支付状态,发货后调用SignOrder来把订单签收,我们内部把增加订单的这几个动作叫做“订单化”。实现了订单化以后,财务就可以按照订单记录、支付记录、权益记录的严格匹配来进行核算与确认收入了。
我们看一下实现了“订单化”后的调用关系,听书系统需要增加3个请求去调用订单系统,而原来的每个接口都要传递订单ID。例如创建完订单以后,在扣除节操币的环节,要告诉节操币系统是为具体的哪笔订单扣除余额的。
实现了订单化后,业务系统对外部系统的依赖和耦合有些加剧,增加了对订单系统的三次调用,同时要给别的系统调用请求中传递订单号。
上线后没多久,原系统架构带来的痛点很快就出现了。财务要求在订单加个“签收时间”字段,据说20多天才上线!为什么需要20多天,因为所有的业务系统都要修改相关的代码,而测试人员要把所有业务的购买都要回归测试一遍,在需求开发时间非常紧张的情况下,投入与
财务又提了需求,要求尽快把所有交付的内容实现“订单化”,因为我们除了有资金往来的购买,还有很多用户免费领取的方式,这些也是要核算成本和交税的。如果再来一次前面那样的修改,可要命了。于是团队思考该怎么办?结论是内部实现个系统,代理全部订单相关的功能,这样再有修改,只改这个代理服务就行了!实现隔离变化!
这就是我接到的重构任务:订单代理(订单化)系统。实现 一个代理服务,对接 交易平台组的订单系统和基础平台组的支付系统,推动若干个业务系统改造,改成调用新的代理服务。
我们看下订单代理系统是如何隔离了变化的。从图中我们看到,业务系统不在直接依赖外部系统的了,订单相关的参数由代理系统去组织,如果再有订单相关的修改,只改这个代理服务就行了。还以增加“签收时间”的字段为例,业务系统就不需要关注这个字段了。
表面看,我们的设计方案, “同时满足”了业务需求和技术目标。业务需求:所有的商品都实现“订单化”:技术这边不光都实现“订单化”,还实现个“订单化的代理系统”,应对外部系统的变化。
这张图很多人一定都看到过,他描述的是对用户需求理解偏差造成的软件项目的失败,在我这个场景,方案确定了!但这是业务的目标吗?
我读了些代码并做了系统分析后,带着掌握的内容,满怀信心的去和合作部门交流,却感觉大家关注的点甚至方向都常常不一致!!!在需求理解上,我们和业务方存在认知的偏差。开发最关心的是,完成全部商品的订单化,实现订单代理系统,降低业务系统与外部系统的耦合。而业务关心的是,一定要正确的交付(面向现在),能够高效准确的算账(面向未来),把过去的账给解释清楚(面向过去),我给总结为三个面向。订单代理系统的目标在财务那里只是个过程!他们甚至丝毫不关心我们是不是要实现一个订单化的代理系统。真正的目标需求是什么???如果一个系统还没有开始做,你就知道即使做完也达不到业务的目标,那种心情是很纠结的。
这就是前面说的,我面临的挑战。首先是无路可退,入职的第一个任务。第二是左右为难:实现“订单代理系统”,满足不了业务需求!如果不去实现“订单代理系统”,那该做什么?
思考了没有把握到真正需求的原因。技术人员理解到的所谓“需求”是一种内部视角,是有局限性的。而业务方是外部视角,看到的要比我们全面。领域驱动设计,它能让技术人员和业务都能从外部视角——也就是领域来看问题。
DDD思想指导的开发过程,是一个全程强调领域的过程,开发人员和领域专家,从业务需求中提炼出【统一语言】,基于统一语言建立【领域模型】,用领域模型指导设计及编码实现。
从财务的诉求中,我们把握到了需求的问题域是:电商的发货与算账,而业务的期望是精确交付。
前面说了很多订单相关的,也做了很多分析,但是我所要实现订单代理系统,在整个罗辑思维这个电商业务平台中,是个什么地位呢?带着问题我去找业务沟通。下面的图,是一次找财务方向的产品经理沟通讨论时给我画的,产品经理说第一次有技术主动和她聊财务相关的业务,一高兴就给我讲了很多。
为了让自己的理解和产品经理想要表达的不产生太大的偏差,当天结合这个草图,赶紧画了一个自己理解的图,第二天又去给产品经理讲了一遍。反述的过程,自己明白订单化在全局的位置,虽然貌似不起眼但是却担负着得到所有虚拟商品的交付。
经过继续深入调研后,把“订单化”要完成的内容,划分成了支付和交付两部分,而所在的得到后端,应该关注得到商品的交付部分。
和业务的充分沟通与协作,渐提炼出来了一些“统一语言”,成为了我们的交易领域内的业务语言。例如,订单完整的生命周期:下单,支付,交付,签收。而确收的内容指的是:已购权益和时间权益。
在领域驱动设计思想的指导下,我们找到了真正的业务需求,并不只是开始的,实现一个订单代理系统那么简单,而是要实现“财务核算级别的精确交付”。
二、领域驱动设计指导架构设计与建模
在第一部分,我们找到了真正的业务需求,第二部分,来介绍一下“领域驱动设计指导架构设计与建模”。在这部分,业务的真正需求将会落地。
我们再来看一下电商业务的基本模型。我们在电商平台所完成的的购买行为,其实就是买卖双方,围绕着交易物,以双方认可的价格签订合同后,展开的履约行为。签约生成的合同,就是订单,买方付钱后合同开始生效,卖方收钱发货,买方收货签收后,合同结束。卖方还要具备卖货,算账等能力。
我们想一下,当一个人或者一个组织具备了电商业务的全部功能后,他是不是就可以成为“个体户”或者“小商贩”了呢。“个体户”和“小商贩”用自己劳动换来回报,值得我们尊敬。但是在一个电商平台中,每个业务系统都把自己做成“小商贩”系统,并不是件好事。
我们再来看下订单代理系统架构的弊端。首先,每个业务都是个“小商贩”,相同功能的代码依然会重复。即使业务系统把原来直接调用外部系统的方式改成调用订单代理系统,交付数据的准确性依然达不到财务要求,因为无论是业务系统的开发还是产品经理,对于交付领域和交付数据都缺乏足够的敏感度,毕竟术业有专攻。
小商贩模式能够不那么优雅的解决技术的问题,但却不能满足业务“财务核算级别的精确交付”的需求,因为交易领域中缺少一个专注交付的子领域。于是,我们重新理解和确定了领域问题。在前面的概念模型中,我们得到了几个概念:交易是基于合同的行为,订单是合同,交付是履约。得到后端的核心子领域问题:是“履约”是交付,到此,一个订单交付系统就呼之欲出了。
在我们这个场景中,业务系统实现了交易的全部功能,就是个“小商贩”。每个小商贩都卖货收钱发货,而和现实中小商贩不同的是,每个商贩都不对卖货产生的账务数据负责,而账务却是由一拨人算的。我们通过DDD的限界上下文划分来分析,就会发现,应该把每个业务上下文中和交易相关的功能独立出来,把自己变成一个商品的供货商,入驻超市卖场,让专业的人去做专业的事,业务系统自己变为“超市卖家”。
由订单交付系统接管业务的交易行为,原来听书商品的购买,变成了这样的调用关系。我们的订单交付系统前置,直接和app对接,创建订单和交付统一由交付系统负责完成,原业务系统只需要提供一个返回商品价格、上下架状态的接口,就可以完成售卖。我们用这样的方式,让订单交付系统从所有的业务方接管了售卖交付行为,而业务方几乎不需要做什么开发,甚至感觉不到订单的存在。
最终,订单交付系统满足了业务和技术的目标。业务方不再是“小商贩”,入驻“超市”成为“卖家”,交付的数据达到财务精准核算的要求。绿框部分,就像一个超市,业务系统将自己的商品上架后,就不需要关注收钱、交付和算账了,可以把更多的精力投入到自己业务产品的研发中,
三、是用限界上下文来保护领域
首先,我们来认识一下,强调上下文的重要性。我抽象了一个乘客到飞机场登机的例子。
机场,为了维护自己的秩序,是要采取许多措施来保护这个领域的。而我们识别了领域后,也要保护领域。保护是手段,目标:边界内的“完美世界”不可侵犯,完美世界是什么,我们想一下架构优良,职责单一,代码整洁等等让人感觉到美好的词语。保护的依据:就是识别了限界上下文后划分的边界,以及限界上下文内的规则。
在订单交付系统开发和推进的过程中,采取了一些保护这个领域的措施,我按照上下文之间和上下文内部总结为如下的分类。上下文之间,第一、规范可以进入上下文内的对象模型,第二、用领域事件解耦合与其它上下文的关系,第三、把握领域的职责,第四、隔离上下文间的业务内涵。上下文内部,保护业务抽象行为的一致性。后面我们结合实例,来看一下具体的行动措施。
1、保护领域的行动:规范可以进入上下文内的对象模型。
《领域驱动设计》一书中解释限界上下文时,Eric Evans 用细胞来形容限界上下文,认为“细胞之所以能够存在,是因为细胞膜限定了什么在细胞内,什么在细胞外,并且确定了什么物质可以通过细胞膜。”当进入机场上下文的时,“人物”要变为“乘客”;当进入订单交付上下文的时,“业务对象”要变为“商品”。
为什么要这么做,因为在之前,进入各业务上下文的模型是由业务自己决定的,客户端结算台是根据许多的if……else来判断该请求哪个业务系统的接口。而由于缺乏领域规范,这些参数形态各异,业务端的实现也是各不相同,导致出现问题排查排查困难。
所以,在新的订单交付系统推广的第一步,就是通过统一参数来规范进入交付领域的对象。App开发新的功能和我们对接时,我们要求不管原来是什么类型的业务对象,进入订单交付领域,必须转换为商品,必须传递ProductID + ProductType。而对于老版本的业务,我们通过直接在网关转发时,把接口转到我们的防腐层,转换为商品再去调用交付逻辑。
2、保护领域:用领域事件解耦与其它上下文的关系。
由于购买数据有很多系统会关注,所以在之前,每记录一条用户权益,就会至少给四个相关上下文推送格式大同小异的消息供相关方消费,浪费资源不说,维护成本也高,还被外部的上下文业务规则所绑架。磐石,关心商品sku和价格,勋章关系价格和数量,已购只关心数量,大数据方面关心商品和价格。4个需求方,不同时间段提的需求,由于没有之前没有领域,就都是定制开发。在重构新的订单交付系统时,我们认为交付结果推送的消息是一个领域对外提供的服务,应该统一由领域来提供标准外部去适配,而不是外部提要求我们来定制开发。于是,就在记录用户权益后,发布统一的领域事件,由外部系统的上下文来订阅和适配。
3、保护领域:把握领域的职责,领域之外的事少管。
在之前,用户的充值工作完全是由得到后端来全权负责的,要反复和订单系统和支付系统交互,前后至少要经过9次调度和响应,极易出现问题,所以在财务对账中,充值也是被财务吐槽比较多的一个地方。前面分析过,交付的节操币这个商品其实不属于得到后端的商品,我们做了那么多,是出力不讨好。
交易中心是我们中台化的一个产物,这个系统接管了包括订单和支付的所交易行为,所以在和交易中心对接时,我认为既然节操币不属于得到后端,就不应该由我们来负责交付,于是我们就把节操币充值的交付,“让”给交易中心。充值行为变成了这样的调用关系,我们的订单交付系统比原来少做了很多事情,全局的充值结果却更有保障了,因为减少了不必要的调用。红线部分,是完成同样的充值行为,我们订单交付系统需要做的事情,连订单的支付和签收也不需要我们关心了。
4、保护领域:使用上下文隔离相同事物的不同内涵。
之前,用户在得到商城内购买一个课程,要先产生一个商城订单,然后再推送给得到后端,会再生成一个得到的订单并交付签收。在财务审计和对账中,这个逻辑也带来了很多的问题数据,因为两笔订单的对应关系是很不稳定的。一次购买,产生两笔订单,并不符合我们这个场景。
一次购买,产生两笔订单的适用用场景,适用于两个商家彼此之间是完全独立核算的场景。我之前有个同事,开了个网店,但是她一点货都不存。她是怎么玩的呢?从网上把别的商家的图片商品信息抓过来,在自己的网店上加10~100块钱不等就上架,当用户在她的网店下单后,她再去别的网店下个同样商品的订单,收货人填买家。由真正的卖家发货。这个场景下,虽然看着是同一个东西,但内涵上其实是两个不同的事物,如果严格点,肯定是两个不同的sku。
而我们这个场景,只收了一份钱,内部也没有再次产生资金流水行为,商城上下文和我们订单交付上下文应该是合作关系,一起接力完成一笔订单的交付。虽然在不同的上下文中,表现为订单和交付单两个不同的东西,但内涵上是同一个事物的不同状态。
于是,在对接这部分业务时,我们打破了原有规则,说服大家接受商城购买是一种下单途径,流转到得到这边,是订单履约的方式,就是一个订单。这样做以后,商城同步售卖的订单核算,可以和其它支付类型购买的商品采用一样方式核算了,技术人员再也不用给财务导两笔订单的对应关系了。
5、保护领域:保护业务抽象行为的一致性。
我们先看一下订单交付系统实现层面的设计。使用了桥模式,一边抽象了支付方式,另一边抽象了商品的交付。中间通过统一的交付逻辑来控制。
在重构对接老业务的时候,遇到了一个历史产品,会破坏“业务抽象行为一致性”,会破坏我们的完美世界,使我们的代码和架构坏腐。用户只要购买过这个产品,那么在有效期内可零元购买“得到app”的所有内容,为了实现这个产品功能,代码中几乎所有购买场景,都要增加if……else……,即破坏设计,污染代码。这个产品一张一年,而有的用户,一下子把这个产品的有效期买到了2037年。
记得一次开会,我们的老板说过这个产品未来可以下线,我就记下了。所以当这个老的产品要再来破坏我们领域内的完美世界时,我就决定有法可依的去推进这个产品的下线。当选择不写代码去解决业务问题,必然就要去做很多沟通协调方面的工作。这虽然是一个极端的例子,但是可以说明我们技术人员要有保护领域的决心。
我分享的内容即将结束,最后来说一下,DDD指导的设计建模带来的几个长尾收益:可以快速支持商城直接售卖各种商品,可以快速接入书单等多商品打包的购买,可以快速接入各种产品的赠送功能,客户端可以封装统一的结算台组件,其中最我认为重要的是我们可以逐渐从每月核查财务数据的工作中解放了……
最后的结语是:不要把DDD只当做一门技术来学习,ta可以是指导开发流程的方法论。
往期推荐:
技术琐话
以分布式设计、架构、体系思想为基础,兼论研发相关的点点滴滴,不限于代码、质量体系和研发管理。