前言
大型系统的本质问题是复杂性问题。互联网软件,是典型的大型系统,如下图所示,数百个甚至更多的微服务相互调用/依赖,组成一个组件数量大、行为复杂、时刻在变动(发布、配置变更)当中的动态的、复杂的系统。而且,软件工程师们常常自嘲,“when things work, nobody knows why”。
本文将重点围绕软件复杂度进行剖析,希望能够帮助读者对软件复杂度成因和度量方式有所了解,同时,结合自身的实践经验谈谈我们在实际的开发工作中如何尽力避免软件复杂性问题。
导致软件复杂度的原因
导致软件复杂度的原因是多种多样的。
宏观层面讲,软件复杂是伴随着需求的不断迭代日积月累的必然产物,主要原因可能是:
1.对代码腐化的退让与一直退让。
2.缺乏完善的代码质量保障机制。如严格的 CodeReview、功能评审等等。
3.缺乏知识传递的机制。如无有效的设计文档等作为知识传递。
4.需求的复杂性导致系统的复杂度不断叠加。比如:业务要求今天 A 这类用户权益一个图标展示为✳️,过了一段时间,从 A 中切分了一部分客户要展示 。
对于前三点我觉得可以通过日常的工程师文化建设来尽量避免,但是随着业务的不断演化以及人员的流动、知识传递的缺失,长期的叠加之下必然会使得系统越发的复杂。此时,我觉得还需要进行系统的重构。
从软件开发微观层面讲,导致软件复杂的原因概括起来主要是两个:依赖(dependencies) 和隐晦(obscurity)。
依赖会使得修改过程牵一发而动全身,当你修改模块一的时候,也会牵扯到模块二、模块三等等的修改,进而容易导致系统 bug。而隐晦会让系统难于维护和理解,甚至于在出现问题时难于定位问题的根因,要花费大量的时间在理解和阅读历史代码上面。
软件的复杂性往往伴随着如下几种表现形式:
修改扩散
修改时有连锁反应,通常是因为模块之间耦合过重,相互依赖太多导致的。比如,在我们认证系统中曾经有一个判断权益的接口,在系统中被引用的到处都是,这种情况会导致一个严重问题,今年这个接口正好面临升级,如果当时没有抽取到一个适配器中去,那整个系统会有很多地方面临修改扩散的问题,而这样的变更比较抽取到适配器的修改成本是更高更风险的。
@Override
public boolean isAllowed(Long accountId, Long personId, String featureName) {
boolean isPrivilegeCheckedPass = privilegeCheckService.isAllowed(
accountId, personId, featureName);
return isPrivilegeCheckedPass;
}
认知负担
当我们说一个模块隐晦、难以理解时,它就有过重的认知负担,开发人员需要较长的时间来理解功能模块。比如,提供一个没有注释的计算接口,传入两个整数得到一个计算结果。从函数本身我们很难判断这个接口是什么功能,所以此时就不得不去阅读内部的实现以理解其接口的功能。
int calculate(int v1, int v2);
不可知(Unknown Unknowns)
相比于前两种症状,不可知危险更大,在开发需求时,不可知的改动点往往是导致严重问题的主要原因,常常是因为一些隐晦的依赖导致的,在开发完一个需求之后感觉心里很没谱,隐约觉得自己的代码哪里有问题,但又不清楚问题在哪,只能祈祷在测试阶段能够暴露出来。
软件复杂度度量
Manny Lehman 教授在软件演进法则中首次系统性提出了软件复杂度:
软件(程序)复杂度是软件的一组特征,它由软件内部的相互关联引起。随着软件的实体(模块)的增加,软件内部的相互关联会指数式增长,直至无法被全部掌握和理解。
软件的高复杂度,会导致在修改软件时引入非主观意图的变更的概率上升,最终在做变更的时候更容易引入缺陷。在更极端的情况下,软件复杂到几乎无法修改。
在软件的演化过程中,不断涌现了诸多理论用于对软件复杂度进行度量,比如,Halstead 复杂度、圈复杂度、John Ousterhout 复杂度等等。
Halstead 复杂度
Halstead 复杂度(霍尔斯特德复杂度量测) (Maurice H. Halstead, 1977) 是软件科学提出的第一个计算机软件的分析“定律”,用以确定计算机软件开发中的一些定量规律。Halstead 复杂度根据程序中语句行的操作符和操作数的数量计算程序复杂性。针对特定的演算法,首先需计算以下的数值:
上述的运算子包括传统的运算子及保留字,运算元包括变数及常数。
依上述数值,可以计算以下的量测量:
举一个 ,这是一段我们当前应用中接入 AB 实验的适配代码:
try {
DiversionRequest diversionRequest = new DiversionRequest()