当程序员大多都有一个共同的经历:当你在改一段复杂的代码时,你一边吐槽是哪个小可爱写的这段像一坨*一样的代码时,一边打开了提交记录,赫然发现竟然是自己3个月前写的!
明明看起来很简单的业务,但写出来的软件代码为什么会这么复杂呢?这是所有程序员都可能会思考的问题。
“领域驱动设计”号称是一种能够应对软件复杂性的解决方案,它的核心思路是从业务视角出发,去设计软件,并试图把技术复杂性和业务复杂性分离开来。但领域驱动设计是20年前就提出来的,那时候的软件面临的技术挑战和技术复杂性和现在不可同日而语,有些规则可能已经不那么适用。
那软件为什么复杂呢?前段时间看张逸前辈的《解构领域驱动设计》,里面对这块有非常详尽的解释,我觉得他总结得挺好的,这里精炼一下分享给大家:
简单来说,软件复杂是分为两个维度的。从理解维度上来说,软件的规模越大,结构越混乱,软件也就越复杂。从预测能力维度来说,软件可能会因为不能很好地适应未来的变化而导致改动成本太大而复杂。
我们可以用一些开发上的概念对应这几个维度,比如规模对应了代码行数和微服务数量;结构对应的是代码的分层设计、服务的调用关系;过度设计指的是为了把软件设计得过分通用,而导致代码设计复杂、可读性降低;设计不足就是很多写死的代码,导致业务变化时会有“改不动”的现象。
那么领域驱动设计的解决方案是什么呢?——通过分解领域,分而治之来**「控制规模」;通过合理的架构来「清晰代码结构」;通过抽象合适的领域模型,高内聚低耦合来「应对变化」**。
其中,在清晰结构这一部分,我们应该尽量把“业务复杂度”和“技术复杂度”区分开来。比如一个订单业务,业务上关注的是订单的校验,计算订单金额,提交订单和整个订单后续状态的流转。而技术上关注的是保证订单能够正确的完成,保障数据的正确性,一致性,稳定性,更具体一点,比如如何防止重复提交,如何防止超卖,如何应对高并发,下游服务或者数据库挂了怎么办等等一系列技术问题。
那么我们以“提交订单”这个业务为例,来看看整个过程中会有哪些代码,哪些算是业务代码,哪些算是技术代码,它们应该如何被组织。
我们先来看看一个简化版的提交订单用例图,真实情况可能会比这个更复杂得多。
这个用例图看起来并不算很复杂,一共只有6步操作步骤,我们来一步一步解析。
分解步骤
1 提交订单
在提交订单这个环节,业务上并不会考虑太多。但从技术上,处于安全性考虑,我们可能需要校验一些参数的合法性,比如提交的商品id不能为空列表,提交的商品数量必须大于0等。
这些代码通常写在我们应用的最外层,是一进来就需要校验的。一些语言(比如Java)可能会有更优雅的解决方案,比如使用Validation注解。
所以**「这里的参数校验是纯技术代码」**。
2 校验并占用库存
其实从业务上来讲,这一步应该只是叫“校验库存”,也就是校验这个商品是否还有库存,如果库存不足的话,应该及时中断流程,提示用户库存不足。
但处于技术上考虑,这里可能并不只是单纯的校验就可以的。我们还需要“占用”库存,防止超卖现象。比如在秒杀场景,某个商品有100个库存,但同时有1万人抢,大家几乎在同一时间抢购,如果在校验库存这个地方做限制,那很有可能有远超100人都校验通过,流程往后走,一直到用户付款后才发现库存不足了,给用户带来不好的体验,这在业务上是不允许的。
我把这一步合在了一起,叫“校验并占用库存”,但实际设计的时候可能是两个接口,也可能是一个接口。
所以**「这里的校验是业务代码,但占用是技术代码」**。但占用也是为了业务上的体验更好,业务上数据正确,所以有些团队会把占用也认为是业务代码。
3 查询商品价格
从业务视角看,其实只关心的是这个订单的总价。计算逻辑也很简单,就是订单上每种商品的价格 * 数量之和。
但技术上要考虑安全性,所以这个金额不可能是从前端页面传过来的,尽管此时前端页面已经拿到了每种商品的金额数据展示给用户。
出于保险起见,订单系统会去商品系统查询数据。
那么问题来了,假如查不到某个商品(下游挂了或者数据不正确),或者这个商品已下架/已违规怎么办?
此时如果是因为下游挂了或者数据不正确,而查不到商品,这里算是一种技术问题,应该终止流程并提示用户。而如果商品下架/违规,这里算是一个业务问题。当然也应该终止流程,并提示用户相应的文案。
但无论是技术问题还是业务问题,如果这里不能正常查询商品的价格,出于数据一致性的考虑,「都应该回滚在第二步占用的库存」。
那么问题来了,回滚失败怎么办?
这里就涉及到一个问题了,「我们值不值得为了这种“极小概率场景”去做一套方案」?比如占用超时释放?
出于成本考虑,我想大多数团队不会做得很严谨,事实上也不可能完美解决这个问题,毕竟可能根本没有完美的方案。真要是发生了这种情况,可能多数人的选择还是打个ERROR日志,然后触发告警,手动看一看。
4 计算订单总价
好了,终于来到纯业务的领域了。计算订单总价,看起来好像很简单,乘起来再加起来就行了。
然后业务同学告诉你,这块现在是直接计算没问题,但我们后面可能要搞满减活动,它是分档次的。满100减10,满200减25,满500减80……
而且会员有折上折,vip 1 打9.8折,vip 2打9.7折……
在大促期间,我们可能还会有优惠券活动,用户可以使用各种各样的优惠券……
最恐怖的是,可能产品同学提需求的时候并不会跟你说这些,而是后面再提n个需求让你改。
好吧,这就是业务代码。它可能是随时会发生变化的。
在计算完后,应该把这个订单的信息保存到DB,生成一个单据。但业务不关心这个,这是技术代码。
5 确认扣减库存
其实提交订单在业务上看就是一瞬间的事情,提交订单,然后扣减库存。但技术上因为会分为上面的步骤,所以要有占用,回滚,确认扣减这几个步骤。也是处于数据一致性考虑。
所以个人觉得“确认扣减库存”,更像是一个技术代码。
6 生成支付单据
这个我觉得是业务代码了。但同样会有上面的一系列技术问题,比如生成失败怎么办,如何设计幂等,防止重复提交等等。
领域代码
假设我们是按领域来划分的系统。那我们就有订单领域,库存领域,商品领域,支付领域。对于电商场景来说,它们可能都是我们系统中的核心领域(支付领域也许不是,这个看公司策略)。
在用户提交订单这个业务,订单领域要做的是生成一个“订单”模型,完成订单的计算,生成订单单据。其中完成订单的计算这一步骤是纯业务逻辑,也是最复杂的,它应该放在领域模型内部。但其实也不尽然,当规则复杂和易变到一定程度,我们可能会使用**「规则引擎」**,那订单模型的职责就变成了“应用从规则引擎读取到的规则来计算金额”了。
在库存领域,核心的领域代码应该是库存被占用。
在商品领域,这个步骤不涉及领域状态的变更,更多的只是商品信息的查询。在领域驱动设计提倡的CQRS(读写分离)的架构下,商品领域只是提供一个查询接口,所以不会涉及领域模型的相关代码。
在支付领域,也是生成一个“支付单”,此处逻辑较简单。
技术代码
你可以很明显的能够感知到,如果光靠领域代码,基本上是不可能顺利地正确完成“订单提交”这个业务操作的。
比如库存占用,就需要上锁才能保证不超占。更别说还有回滚和确认。比如参数的校验、幂等的设计,也是不属于业务代码的。
我们再来看领域驱动设计倡导的两个领域之间“基于事件通信”,在这个业务里面也是不能完全使用事件来通信的。订单领域就是需要实时调用库存领域的接口来保证强一致性。
反思
所以我们再回过头来反思,领域驱动设计可以解决这个问题吗?业务代码和技术代码真的能分开吗?
很明显,在订单领域,单纯的一个订单模型已经不能够内聚所有的逻辑了。那么加一个订单领域服务呢?其实理论上是可以的。大概代码组织是这样:
public String submitOrder(SubmitOrderCommand command) { stockAdapter.checkAndOccupyStock(command.getCommodityInfos()); try { Commodities = commodityAdapter.Getcommodities(command.getCommodityInfos()); Order order = OrderFactory.generateOrder(command); order.computeTotalAmount(); orderRepository.save(order); stockAdapter.confirmOccupyn(command.getCommodityInfos()); } catch(Throwable t) { logger.Error("提交订单失败。", t) stockAdapter.rollbackOccupy(command.getCommodityInfos()); } paymentAdapter.submitPayment(order.getPaymentInfo()) }
可以看到,上述代码涵盖了第2步到第6步的所有步骤(第1步没放进来是因为参数校验通常在更外层就做了)。但很明显我们仍然**「不可避免地把技术代码和业务代码糅杂在了一起」**。比如保存数据库、确认占用等。但我们也尽力把技术代码挪到了adapter和repository的实现里面,在领域层只是调用了一下接口。相较于传统的面条式各种service调用代码,使用领域服务和领域对象就清晰得多了。
再回过头来看过度设计和设计不足的问题。这其实考验的是一个程序员对业务的理解程度和思考程度。如果可以显然预料到未来会发生明显的变化(比如文中提到的计算规则的变化),那确实应该在设计之初更灵活地设计好。而如果对未来的变化把握并不清晰,或者确定,那满足当前业务需求就可以了,如果架构合理,代码清晰,改起来成本倒也没那么大。这里提倡的是开发者尽量多与领域专家(比如业务人员或者产品经理)沟通,这样才能更好地把握代码未来的走向。