架构整洁之道导读(三)
组件耦合
上回说到组件聚合,反映的是组件内部的“基本元素”的选择标准。第14章介绍的组件耦合则是指组件和组件之间的关系,这些依赖关系有些是好的,有些是不好的,我们即将看到的这些原则就是在澄清什么是好的依赖标准。
本章关键点
- ADP(Acyclic Dependencies Principle 无依赖环原则)
- SDP(Stable Dependencies Principle 稳定依赖原则)
- SAP(Stable Abstractions Principle 稳定抽象原则)
依赖即信任
依赖关系其实就是一种信任关系这句话是我总结出来的。为什么大家笃信一个事实——当很多比你聪明的人都开始投身某个新领域时,没错,我说的是区块链,这个领域一定会成为未来。因为在我看来,每个投身其中的聪明人都在筑成一条新的依赖关系,依赖愈来愈多,底层建筑就会愈加稳定,稳定是信任的基础,进而会吸引更多的优秀人才投身其中,形成规模效应。就像为什么那么多人敢用支付宝和微信支付?这种信任感不仅仅来源于背后的大厂品牌,还来自于广泛的用户群体。每次用户完成的安全支付,其实都在加固信任感。
区块链,所谓的降本增效,构建完善的征信体系,也是同样的道理。一次不可篡改的交易积累不出信任感,但是一千次,一百万次,以至于每个人一生中所有的交易记录都不可篡改地被记录下来,那么这个人的信誉体系就建立起来了。当一个行业,甚至国家的所有交易数据都被记录下来,征信体系自然而然就完善了。
这样的例子,我还可以举出很多,放到软件开发行业更加浅显易懂。我见过两个不同团队构建了具有依赖关系的两个微服务,虽然服务可以轻松通过API的方式进行通讯,但是因为双方对彼此服务可用性的不信任,他们选择在两个服务之间建立高可用的消息队列。一个服务发布数据消息,另一个订阅。这个看似良好解耦的方案其实不过是妥协的结果。因为不信任所以不去依赖,不去依赖导致根本不肯信任,就这样形成了恶性循环。
还有上回在组件聚合原则中提到REP(复用发布等同原则),其实组件的发布也类似一种发布、订阅模式。这种模式将紧耦合的源代码级别的依赖,转换成了对版本号、发布文档的依赖关系。发布团队可以在自己的私有仓库里继续开发,而订阅团队则可以自行决定升级与否。这种做法本质上是把对代码的依赖反转到对版本发布的依赖上。发布的产出物是稳定的,因此值得信任,所以才可以安全地放到自己的代码中。
现在的软件开发过程,引用第三方组件已经是司空见惯的事情,而且必不可少,这也是软件复用的初衷。稍微开发大型的软件系统,就会涉及依赖多种组件,这些关系有时候会变得错综复杂,处理起来也是一件麻烦事。比如,在Java工程中,偶尔会处理某个组件的多版本冲突。这个问题是由于Maven等依赖管理工具允许传递依赖(transitive depenency)造成的,一般解决方案是将某个版本从依赖它的组件内部排除掉。众多依赖的问题中,最不能接受的是循环依赖。
ADP 无依赖环原则
无依赖环组件依赖关系图中不应该出现环
在组件之间,循环依赖导致的问题是任何组件的变更必然导致其它组件同时变更。我们试想一种组件之间没有依赖的场景,每个组件在这里都能独立的变更而不影响其它组件。再试想一种只有单向的依赖的场景,被依赖的组件发生变更势必会影响依赖它的组件,所以我们会小心翼翼,尽量减少这种组件发布的频率。而此时,依赖方的变更却是自由的。在双向(循环)依赖存在的场景中,任何一方的变更导致的影响几乎相当于粒子回旋加速器造成的动能,这样的结果是几乎无法得到任何一个组件稳定可用的版本。
我们知道,在编码时,类与类之间是不应该有互相依赖的,因为循环依赖往往会导致类加载器陷入加载的死循环,它相当于遇到了先有鸡,还是先有蛋的难题。不过,循环依赖问题是可以消除的,而这恰恰是DIP(依赖反转原则)大显身手的地方。
DIP原则指导我们在组件出现循环依赖的时候可以有两种消除方式。
第一种是产生循环依赖的组件内声明接口,将它依赖的组件反转成依赖自己的接口。这种方式,不仅破除了循环依赖,同时也避免生成新的组件。
第二种是生成新的组件,让互相依赖的双方都来指向它。适配器模式就是一个很好的诠释。何时生成一个新的组件?这种问题需要利用上回提到的组件聚合原则[1]来解答,此处不再赘述。
SDP 稳定依赖原则
稳定依赖依赖关系必须指向更稳定的方向
稳定是相对于不稳定而言,不稳定是因为组件老是要变更,如果用根因分析,频繁变更的很快就会上升到需求易变PM不是人的高度。不过,一定需要澄清的是:需求总归是要变的。不能快速响应需求变更的软件架构是一潭死水,也就没有实施任何设计原则的必要了。
既然“向外求玄”不可得,那么“反求诸己”好歹也是条路。鲍勃大叔说,稳定是因为依赖的足够多。你先撇开一脸黑线和心中的碎碎念:我考察“什么是稳定”的目的是想找到稳定的方向再去依赖它,你这会儿告诉我,依赖的足够多就稳定了?那么问题来了,先有鸡,还是先有蛋?
我当时也被这种观点吓得虎躯一震,心中一阵翻滚。鲍勃大叔提出这种观点的自信到底从何而来?稍微静下心,我们捋一捋他的思路。在阐述上述ADP原则时,鲍勃大叔从消除循环依赖的过程中总结出一个有点遗憾的结论:组件结构图不可能自上而下被设计出来。原因是它必须跟随软件系统的变化而变化和扩张。虽然人们普遍以为项目组粒度的组件分组规则所产生的就是组件的依赖结构,但事实上,组件依赖结构图并不是用来描述应用程序功能的,它更像是应用程序在构建性和维护性方面的一张地图。结合我前面提到的依赖即信任的观点,不难发觉稳定是软件系统变化过程中逐渐沉淀出来的,组件不断被拆合,依赖不断被分解,被依赖的最多的组件才会慢慢浮现。
怎么界定组件的稳定程度呢?既然利用了依赖多寡的指标,那就可以很方便的构造出一个不稳定函数:
Fan-in指的是外部指向组件的类的数量,相反,Fan-out指的是组件内部指向外部组件的类的数量。用图相关的知识解释,Fan-in是节点的入度,Fan-in是节点的出度。当I=0
时,表示组件最稳定,因为只有入度没有出度;I=1
时,组件最不稳定,此时只有出度而没有入度。
SAP 稳定抽象原则
稳定抽象一个组件的抽象化程度应该与其稳定性保持一致
软件系统中,总有一部分内容不应该经常发生变化,比如:DDD方法论中领域和子域。这部分应该放到较为稳定的组件里被其它组件依赖。但是这也导致一个很棘手的问题,如果这部分发生了变化,那影响的范围将是巨大的。
怎么办?OCP原则闪亮登场,既然我们知道对修改封闭,对扩展开放,扩展也是拥抱变化的一种手段。所以稳定和抽象具有一种妙不可言的联系,这种联系要求稳定的组件也应该是抽象的,虽然不易修改,但是容易扩展。
SDP和SAP合到一起就是DIP在组件级别的诠释。SDP告诉我们要朝着稳定的方向依赖,而SAP则告诉我们稳定的方向蕴含着抽象的要求。所以依赖也应该朝着抽象的方向。
如何衡量组件的抽象化程度?抽象类和接口的占比就是很好的指标:
其中,A是抽象化程度函数。Na是组件中抽象类和接口的数量,Nc是组件中所有类的数量。当A=0
时,表明组件抽象程度最低,没有抽象类;A=1
时,表明组件抽象程度最高,因为内部全是抽象类或接口。
既然依赖应该朝着稳定和抽象的方向,那么这两方面制约因素就要求组件的稳定性和抽象程度具有合力。这个合力的最优位置就是上图中斜率为-1的那条直线上。
我们注意,坐标(0, 0)表示组件无抽象但稳定。稳定意味着很难修改,再加上不够抽象,所以也无法扩展。这往往是软件系统维护难以为继的根源。所以那块区域被称为痛苦区。
坐标(1, 1)表明组件很抽象但极不稳定,不稳定就是说它只会依赖其它组件但不被任何组件依赖,那么这种情况下的抽象通常是无用的。最典型的例子就是某些抽象类孤零零地躺在代码的角落里,无人问津,美其名曰方便以后扩展,但是我们深刻地知道,YAGNI(You Ain't Gonna Need It)。所以那块区域被叫做无用区。
小结
组件之间的耦合应该遵循上述原则的约束,所谓原则就是优秀架构应该有的模样。同时,我们也解答了“自顶而下”的设计不靠谱的深层次原因。
其实,在我看来,组件的耦合和聚合并不是一刀切的关系,SAP原则同样指导了每个组件内部应该具备怎样的抽象程度,它也更加巩固了组件结构图一定会不断演化的观点。
我们讲了这么多回,从基础构件[2]谈到组件,就好比有了砖头,切成了一个个独立的房间,那么如何安排这些房间构造出一幢幢高楼大厦就是下回需要聊到的话题。想知道,什么是软件架构?且听下回分解。
于 2018-11-04