从DDD视角探讨代码复用的成本及效益
从DDD视角探讨代码复用的成本及效益
代码复用是软件工程中的一个核心问题。从领域驱动设计(DDD)的视角来看,代码复用并不是越多越好,而是需要在成本和效益之间进行权衡。本文将从DRY原则、模块复用的权衡、深浅模块的概念,以及DDD中的子域划分理论等多个维度,深入探讨代码复用的最佳实践。
初识代码复用
刚工作时,面对代码审查(CR),师兄总是建议将重复的代码抽象成类或函数,理由是为了方便复用。随着工作经验的积累,作者发现这种做法在复杂业务场景下可能会带来新的问题,例如类中充斥着大量的if...else语句,业务中台的发布和开发变得异常复杂。这促使作者思考:类和函数的存在究竟是为了什么?
根据奥卡姆剃刀原则,这个问题可以用一句话来概括:类和函数不是为了复用而存在,而是他们本来就“应该”在那里。这句话来自《复杂软件设计之道》,它揭示了一个重要的观点:代码复用需要从更高的视角来理解。
DRY原则与重复代码的权衡
设计模式中的DRY原则(Don't Repeat Yourself)鼓励开发者避免编写重复代码。然而,在复杂工程中,过度追求DRY可能导致函数内部逻辑变得异常复杂,修改风险也随之增加。相反,适度的重复代码反而可以降低后续的修改风险,为未来的重构留出空间。
《架构整洁之道》中提到,“拖延决策”是优秀架构设计的特点之一。因为随着软件的开发和业务的迭代,我们掌握的信息越来越多,后期做出的决策肯定比项目早期的草率决定要靠谱。《复杂软件设计之道》中吐槽道:架构师们总是在只掌握20%信息的情况下,就已经做出了80%的决策。
大师们的原则常常是相互矛盾的,没有什么绝对的更好或者更坏。下表简要总结了DRY与重复代码各自的优缺点:
特征 | DRY | 重复代码 |
---|---|---|
优点 | 减少代码量 | 降低修改风险 |
缺点 | 增加函数复杂性 | 可能导致代码冗余 |
从上表可以看出,重复代码和DRY很难说孰优孰劣,有时候费了半天劲抽取代码,反而系统复杂性更高了。符合设计原则的代码不一定是好代码,不符合设计原则的代码不一定是坏代码。通过纯粹设计原则的角度是看不出来软件设计决策是否正确的,必须从更高的视角出发才行。
复用是一个权衡
复用软件的好处众所周知,但其成本往往被忽视。为了复用一个代码模块,需要经历以下几个步骤:
- 首先需要知道可复用构件的存在
- 然后了解其中的结构和接口
- 对接模块的接口,并且测试无误
- 最后,只是会用还不够,如果线上出现,必须保证对它有足够的了解,可以去排查该模块的问题
而只要有成本的东西就是需要权衡的。没人愿意花费10元价格,只买回来一个价值8元的产品。
复用软件的好处可以从两个角度来理解:
- 降低开发成本。通过整合业务中台已有的支付、供应链等能力,可以快速支撑新的业务上线。
- 提升软件产品的核心竞争力。已有的模块经过线上检验,其中积累了过去成功的经验,并且未来还会继续积累,直接复用能够大大提升产品的竞争力。
第一点是从成本角度,而第二点是从效益角度。下文将分别从这两个角度与成本进行比较,引出两位大师的观点,从而更好地理解软件复用。
深浅模块:成本角度谈复用
谈到文件系统,或者数据库,应用肯定都是直接复用现有的开源软件,或者公司内专业团队定制的。不可能复制一份数据库代码到应用中。一方面是没这实力,更重要是不划算。
文件系统对上层提供了非常简单的文件模型,数据库对应用也提供了非常好理解的表模型。而他们的实现非常复杂,需要考虑并发、数据完整性、事务等一系列问题。相比理解他们的实现,学习模型和接口成本几乎可以忽略不计。
学习SQL相比学习数据库实现的成本,从相关书籍的厚度上就能看出一二,更何况它们的阅读难度相差也很大。
上面的案例有共同的特点,即模块的接口很简单,但是提供的功能却是深刻的。这个时候复用就非常的合算。
这刚好就是John Ousterhout教授(Raft的发明者)在其著作《软件设计哲学》中提到深模块的概念。
深模块在简单的接口后隐藏了许多功能。深模块代表很好的抽象,其内部复杂性只有很小一部分对其用户可见。
其反例就是浅模块,浅模块接口很复杂,提供的功能却不多。在项目中经常会下面这样的代码:
public void addParameter(List<String> params, String param) {
params.add(param);
}
它接收两个参数,却只实现了一个最简单的列表增加元素功能,寻找和复用它的成本已经超过了复用的好处。
浅模块的接口复杂度和实现复杂度接近,与其去了解模块的接口,开发人员还不如自己重新实现一遍。
《软件设计哲学》书中的配图,方块的宽度代表模块接口的复杂程度,深度代表功能的深刻程度,接口应该越简单越好,功能应该越深刻越好。深模块就是接口简单但是功能深刻的模块。
塑造产品的核心竞争力:效益角度谈复用
什么情况下,复用能够提升产品的核心竞争力呢?
Supercell游戏公司将之前的爆款中备受玩家欢迎的风格、素材和程序逻辑沉淀下来,通过复用之前积累,可以快速产出新的爆款。
钉钉的审批流程配置功能经过多年的迭代,操作习惯已经深入人心。后来钉钉又推出CRM应用,直接复用这套配置界面和逻辑,虽然需要开发一些适配逻辑,但大大降低了用户的学习成本,提升了竞争力。
上面的两个例子刚好就代表了两种提升产品核心竞争力的逻辑:
- 复用之前具有竞争力的技术模块,让过去的成功经验助力未来的产品成功
- 给用户提供一致的体验,考虑用户的使用习惯,降低学习成本
复用不同模块能取得效果的程度也是不同的,复用什么样模块更有可能获得上述两点效果呢?DDD中对子域的划分或许能够给我们答案,在DDD中软件存在三种子域:
- 核心子域
- 特点:能够给公司带来核心竞争力的领域模块,拥有很高的复杂度和差异化价值
- 案例:比如滴滴的司机调度算法,支付宝的交易系统,钉钉的IM系统等等
- 复用策略:属于该子域的模块应该尽可能地复用,将其竞争力也注入到其他产品,甚至投入精兵强将,提升其可扩展性,进一步拉开和竞争对手差距
- 支持子域
- 特点:用来支撑核心子域,但是不能带来竞争力
- 案例:比如运营管理系统,后台排查系统等等
- 复用策略:因为不能带来核心竞争力,不如各个业务根据自己需求,使用脚手架快速搭建,定制起来还更加方便
- 通用子域
- 特点:通用的业务或者技术问题领域,比较复杂,却不能给企业带来核心竞争力。好在一般有现成的解决方案,可以直接采购
- 案例:比如财务系统,可以直接采购用友,金蝶;分库分表,消息队列可以直接使用开源软件,或者购买云上解决方案
- 复用策略:尽可能复用,但是复用的目的与核心子域不同,主要是为了降低研发成本
以DDD中经典的货运管理系统为例(简化):
相比对于业务的助力,复用的成本就显得微不足道了。因此DDD要求技术和业务深度结合,如果不了解业务的话,单从设计原则角度,很难理解为什么要复用一个技术模块。
成功的设计来自对业务问题的深刻理解。最符合其业务子域的地方,才是类/函数应该在的地方。
结语
世上只有一种英雄主义,就是在认清生活真相之后依然热爱生活。工程师对技术也只有一种热爱,就是当发现任何技术都无法代替对业务的深入认知后,依旧热爱代码。
DDD的思想和工具能够帮我们站在更高的视角,从业务分析的视角看待复用的成本和效益,帮助我们更好地做出决策。
参考资料
[01] 《复杂软件设计之道》
https://book.douban.com/subject/35216922/
[02] 《架构整洁之道》
https://book.douban.com/subject/30333919/
[03] 《软件设计哲学》
https://yingang.github.io/aposd-zh/