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