写点什么

软件复杂度的思考与解决之道

作者:阿里技术
  • 2023-04-18
    浙江
  • 本文字数:8601 字

    阅读完需:约 28 分钟

软件复杂度的思考与解决之道

作者:王洋 阿里 ICBU 技术团队


本文将重点围绕软件复杂度进行剖析,希望能够帮助读者对软件复杂度成因和度量方式有所了解,同时,结合自身的实践经验谈谈我们在实际的开发工作中如何尽力避免软件复杂性问题。


大型系统的本质问题是复杂性问题。互联网软件,是典型的大型系统,数百个甚至更多的微服务相互调用/依赖,组成一个组件数量大、行为复杂、时刻在变动(发布、配置变更)当中的动态的、复杂的系统。而且,软件工程师们常常自嘲,“when things work, nobody knows why”。


一、导致软件复杂度的原因


导致软件复杂度的原因是多种多样的。


宏观层面讲,软件复杂是伴随着需求的不断迭代日积月累的必然产物,主要原因可能是:

  • 对代码腐化的退让与一直退让。

  • 缺乏完善的代码质量保障机制。如严格的 CodeReview、功能评审等等。

  • 缺乏知识传递的机制。如无有效的设计文档等作为知识传递。

  • 需求的复杂性导致系统的复杂度不断叠加。比如:业务要求今天 A 这类用户权益一个图标展示为✳️,过了一段时间,从 A 中切分了一部分客户要展示🌟。


对于前三点我觉得可以通过日常的工程师文化建设来尽量避免,但是随着业务的不断演化以及人员的流动、知识传递的缺失,长期的叠加之下必然会使得系统越发的复杂。此时,我觉得还需要进行系统的重构。


从软件开发微观层面讲,导致软件复杂的原因概括起来主要是两个:依赖(dependencies) 和 隐晦(obscurity)。


依赖会使得修改过程牵一发而动全身,当你修改模块一的时候,也会牵扯到模块二、模块三等等的修改,进而容易导致系统 bug。而隐晦会让系统难于维护和理解,甚至于在出现问题时难于定位问题的根因,要花费大量的时间在理解和阅读历史代码上面。


软件的复杂性往往伴随着如下几种表现形式。


1.1 修改扩散


修改时有连锁反应,通常是因为模块之间耦合过重,相互依赖太多导致的。比如,在我们认证系统中曾经有一个判断权益的接口,在系统中被引用的到处都是,这种情况会导致一个严重问题,今年这个接口正好面临升级,如果当时没有抽取到一个适配器中去,那整个系统会有很多地方面临修改扩散的问题,而这样的变更比较抽取到适配器的修改成本是更高更风险的。


@Overridepublic 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 方法优点


  • 不需要对程序进行深层次的分析,就能够预测错误率,预测维护工作量;

  • 有利于项目规划,衡量所有程序的复杂度;

  • 计算方法简单;

  • 与所用的高级程序设计语言类型无关。


Halstead 方法的缺点


  • 仅仅考虑程序数据量和程序体积,不考虑程序控制流的情况;

  • 不能从根本上反映程序复杂性。给我的直观感受是他能够对软件复杂性进行度量,但是很难讲清楚每一部分代码是好还是坏。


2.2 圈复杂度


圈复杂度(Cyclomatic complexity)是一种代码复杂度的衡量标准,在 1976 年由 Thomas J. McCabe, Sr. 提出。


在软件测试的概念里,圈复杂度用来衡量一个模块判定结构的复杂程度,数量上表现为线性无关的路径条数,即合理的预防错误所需测试的最少路径条数。圈复杂度大说明程序代码可能质量低且难于测试和维护,根据经验,程序的可能错误和高的圈复杂度有着很大关系,一般来说,圈复杂度大于 10 的方法存在很大的出错风险。



计算方法


计算公式 1:V(G)=e-n+2。其中,e 表示控制流图中边的数量,n 表示控制流图中节点的数量。


计算公式 2:V(G)=区域数=判定节点数+1。圈复杂度所反映的是“判定条件”的数量,所以圈复杂度实际上就是等于判定节点的数量再加上 1,也即控制流图的区域数。


计算公式 3:V(G)=R。其中 R 代表平面被控制流图划分成的区域数。


举个例子,以前面 AB 实验的代码片段为例子,画出流程图如下,通过计算得出其圈复杂度为 4:


流程图


2.3 John Ousterhout 的复杂度定义


John Ousterhout(约翰欧斯特霍特),在他的著作《A Philosophy of Software Design》中提出,软件设计的核心在于降低复杂性。他选择从认知的负担和开发工作量的角度来定义软件的复杂性,并且给出了一个复杂度量公式:

子模块的复杂度乘以该模块对应的开发时间权重值,累加后得到系统的整体复杂度 C。系统整体的复杂度并不简单等于所有子模块复杂度的累加,还要考虑开发维护该模块所花费的时间在整体时间中的占比(对应权重值)。也就是说,即使某个模块非常复杂,如果很少使用或修改,也不会对系统的整体复杂度造成大的影响。


三、如何避免复杂度问题


软件复杂度问题可以完全避免么?我觉得不可能,但是这并不能成为我们忽视软件复杂度的理由,有很多措施可以帮助我们尽量避免自身的需求开发或工作中引入问题代码而导致软件复杂。这里结合日常的开发理解谈一下自己的认知:


1)开发前:我们可以通过需求梳理沉淀需求分析、架构设计等文档作为知识传递的载体。


2)开发中:我们需要强化系统架构理解,战略优先于战术,系统分层架构清晰统一,开发中接口设计要做到高内聚和低耦合同时保持良好代码注释的习惯。


3)维护阶段:我们可以进行代码重构,针对之前存在设计问题的代码,以新的思维和架构实现方案进行重构使得代码越来越清晰。


3.1 战略先于战术


在战术编程中,开发者主要关注点是能够 work,比如修复一个 bug 或者增加一段兼容逻辑。乍一看,代码能够 work,功能也得到了修复,然而,战术编程已经为系统设计埋下了坏的味道,只是还没人察觉,当相同的代码交接给后人的时候,经常会听到一句“屎山一样的代码”,这就是以战术编程长期累积的结果,是短视的,缺乏宏观设计导致系统不断的引入复杂性问题以至于代码很容易变得隐晦。


成为一名优秀的软件设计师的第一步是认识到仅仅为了完成工作编写代码是不够的。为了更快地完成当前的任务而引入不必要的复杂性是不可接受的。最重要的是这个系统的长期结构。 

--John Ousterhout(约翰欧斯特霍特),《A Philosophy of Software Design》


目前我们所维护的系统往往都是在前人代码的基础上进行升级和扩展,日常需求开发工作中,一个重要的工作是借助需求开发的契机,推动需求所涉及到坏味道的设计能够面向未来扩展,而非仅仅着眼于完成当前的需求,这就是我理解的战略编程


举一个例子,有一个消息监听的处理逻辑,根据不同的业务执行对应的业务处理,其中一部分关键代码如下,可以猜想按照战术编程的思路以后会还会有无数的 else if 在后面进行拼接实现,而这里完全可以通过策略模式的方式进行简单的重构,使得后续业务接入时更加清晰和简单。


 public void receiveMessage(Message message, MessageStatus status) {    // .....    if(StringUtils.equals(authType, OnetouchChangeTypeParam.IC_INFO_CHANGE.getType())                  || StringUtils.equals(authType, OnetouchChangeTypeParam.SUB_COMPANY_CHANGE.getType())){             if(StringUtils.equals("success", authStatus)){                 oneTouchDomainContext.getOneTouchDomain().getOnetouchEnableChangeDomainService().notifySuccess(userId.toString(), authRequestId);             }         } else if(StringUtils.equals(authType,AUTH_TYPE_INTL_CGS_ONSITE)){             // XXXXXX         } else if(StringUtils.equals(authType,AUTH_TYPE_INTL_CGS_ONSITE_CHANGE)) {             // XXXXXX         } else if (AUTH_TYPE_VIDEO_SHOOTING.equals(authType)) {             if (AUTH_STATUS_SUCCESS.equals(authStatus)) {                 // XXXXXX             } else if (AUTH_STATUS_PASS.equals(authStatus)) {                 // XXXXXX             } else if (AUTH_STATUS_SUBMIT.equals(authStatus)) {                 // XXXXXX             }         }         // ..... }
复制代码


短期来看战略编程的成本会高于战术编程,但是从上面的案例长期来看,这样的成本是值得的,他能够有效的降低系统的复杂度,从而长期来看最终能降低后续投入的成本。开发同学在需求迭代的过程中应该先通过战略编程的思维进行设计和思考,然后再进行战术实现,所以我的观点是战略设计要优先于战术实现。



3.2 高内聚低耦合设计


高内聚低耦合,是判断软件设计好坏的标准,主要用于程序的面向对象的设计,主要看类的内聚性是否高,耦合度是否低。目的是使程序模块的可重用性、移植性大大增强。通常程序结构中各模块的内聚程度越高,模块间的耦合程度就越低,当模块内聚高耦合低的情况下,其内部的腐化问题不容易扩散,从而带给系统本身的好处就是复杂度的降低。


内聚是从功能角度来度量模块内的联系,好的内聚模块应当做好一件事情,它描述了模块内部的功能联系;而耦合是软件结构中各模块之间相互连接的一种度量,耦合强弱取决于模块间接口的依赖程度,如调用一个模块的点以及通过接口的数据等。那么如何实现一个高内聚低耦合的接口呢?


3.2.1 简化接口设计


简单的接口往往意味着调用者使用更加方便,如果我们为了实现简单,提供一个复杂的接口给外部使用者,此时往往带来的是耦合度增大,内聚降低,继而当该接口出现升级等场景时会产生修改扩散的问题,进而影响面发生扩散,带来一定的隐患。


因此,在模块设计的时候,要尽量遵守把简单留给别人,把复杂留给自己的原则。


比如这样一个例子,下面两段代码实现的是同样的逻辑,方法一的设计明显要由于方法二,为什么?方法一更简单,而方法二明显违背了把简单留给别人,把复杂留给自己的原则。如果你是接口的使用者当你使用方法二的时候,你一定会遇到的两个问题:第一,需要传递哪些参数要靠问方法的提供方,或者要看方法的内部实现;第二,你需要在取到返回值后从返回值中解析自己想要的结果。这些问题无疑会让系统复杂度提升。


所以,我们要简化接口设计,把简单留给别人,把复杂留给自己,从而保证接口的高内聚和低耦合,进而降低系统的复杂度。


@Overridepublic boolean createProcess(StartProcessDto startProcessDto) {    // XXXXXXX}@Overridepublic HashMap createProcess(HashMap dataMap) {    // XXXXXXX}
复制代码


3.2.2 隐藏实现细节


隐藏细节指的就是只给调用者暴露重要的信息,把不重要的细节隐藏起来。接口设计时,我们要通过接口告诉使用者我们需要哪些信息,同时也要通过接口告诉使用者我会给到你哪些信息,至于内部如何实现是使用者不需要关心的。


还是以上面的接口的实现为例,方法一对内部实现细节达到了屏蔽,使得当前接口具备更好的内聚性,当内部实现的服务需要调整时只需要修改内部的实现即可,而方法二则不然。通过这个案例也能够实际体会到,把内部的实现细节隐藏在实现方的内部能够有效的提升接口的内聚性降低系统耦合,随之带来的是系统复杂度的降低。


@Overridepublic boolean createProcess(StartProcessDto startProcessDto) {    Validate.notNull(startProcessDto);    try {        HashMap<String, Object> dataMap = new HashMap<>(8);        dataMap.put(MEMBER_ID, startProcessDto.getMemberId());        dataMap.put(CUSTOMER_NAME, startProcessDto.getCustomerName());        dataMap.put(GLOBAL_ID, startProcessDto.getGlobalId());        dataMap.put(REQUEST_ID, startProcessDto.getAvRequestId());        String authType = startProcessDto.getAuthType();        String taskCode = getTaskCode(authType);        HashMap resultMap = esbCommonTaskService.createProcess(AV_ORIGIN_AV, taskCode, dataMap);        return (MapUtils.isNotEmpty(resultMap) && TRUE.equals(resultMap.get(IS_SUCCESSED)));    } catch (Exception e) {        LOGGER.error("createProcess error. startProcessDto:{}",                JSON.toJSONString(startProcessDto), e);        throw e;    }}@Overridepublic HashMap createProcess(HashMap dataMap) {    Validate.notNull(dataMap);    try {        HashMap process = esbCommonTaskService.createProcess(ORIGIN_AV, TASK_CODE, dataMap);        return process;    } catch (Exception e) {        LOGGER.error("createProcess error. dataMap:{}", JSON.toJSONString(dataMap), e);        throw e;    }}
复制代码


3.2.3 通用接口设计


通用接口设计并不是说所有的场景都为了通用而设计,而是针对具有同样能力的多套实现代码而言,我们可以抽取成通用的接口设计,通过业务类型等标识区分实现一个接口完成。


举一个例子,有一个需求是同时实现多种会员的权益列表功能,由于不同会员的权益并不完全相同,所以刚开始的想法是分开设计不同的接口来承接不同会员的权益内容的获取,但是本质上实现的是同样的内容:查询会员权益,所以最终通过对领域模型的重构抽取了统一的模型从而实现了通用的权益查询的接口。


public List<RightE> getRights(RightQueryParam rightQueryParam) {    // 参数校验    checkParam(rightQueryParam);    Locale locale = LocaleUtil.getLocale(rightQueryParam.getLocale());    // 查询商家权益    RightHandler rightHandler = rightHandlerConfig.getRightHandler(rightQueryParam.getMemberType());    if (rightHandler == null) {        log.error("getRightHandler error, not found handler, rightQueryParam:{}", rightQueryParam);        throw new BizException(ErrorCode.NOT_EXIST);    }    List<RightE> rightEList = rightHandler.getRights(rightQueryParam.getAliId(), locale);    return rightEList;}
复制代码


3.3 分层架构


从经典的三层架构到领域驱动设计都有涉及到分层架构,分层架构的核心其实我的理解是隔离,将不同职责的对象划分到不同的层中实现,良好的分层能够实现软件内部复杂度问题的隔离,降低“洪泛”效应。


端口适配器架构将系统划分为内部(业务逻辑)和外部(客户请求/基础设施层/外部系统)。主动适配器(Driving adapters)承接了外部请求,系统内部业务逻辑能对其进行主动适配,独立于不同的调用方式提供通用的接口。被动适配器(Driven adapters)承接了内部业务逻辑调用外部系统的诉求,为了避免外部系统污染内部业务逻辑,通过适配屏蔽外部系统的底层细节,有利于内部业务逻辑的独立性。


在复杂软件的开发过程中,很容易出现分层的混淆,逐渐出现分层不清晰,系统业务逻辑和交互 UI/基础设施等代码逻辑逐渐耦合,导致业务逻辑被污染的问题,而端口适配器正是要解决该类问题。


六边形架构


Onion Architecture(洋葱架构,于 2008 年)由杰弗里 · 帕勒莫提出,洋葱架构是建立在端口适配器架构的基础上,将领域层放在应用的中心,外部化 UI 和基础设施层(ORM,消息服务,搜索引擎)等,更进一步增加内部层次划分。洋葱模型将应用分层细化,抽取了应用服务层、领域服务层、领域模型层等,并且也明确了应用调用依赖的方向:


  • 外层依赖于内层

  • 内层对外层无感知


洋葱架构


3.4 注释与文档


注释与文档往往在开发过程中会被忽视,作为知识传递的载体,其实是很重要的存在,他们能够帮助我们更快速的理解实现逻辑。


注释能够帮助理解逻辑,注释是开发过程中思维逻辑最直接的体现,因为其和代码绑定在一起,相对于文档阅读更方便,查看和理解代码时有助于理解。


文档能够帮助理解架构设计,在团队的合作或者交接过程中,很难用几句话就能够讲清楚,此时需要通过文档帮助合作方来更好的理解每一处细节以及整体的架构设计方案的全貌。


3.5 重构


如果日常开发过程中已经很注意了,但是多年之后发现其实之前的实现并不是最优,此时,就可以通过系统重构来解决。


当你维护一个多年生长成的系统时,一定会发现系统中一些不合理的地方,这是软件复杂度问题长期积聚的结果,此时就需要我们在日常的开发过程中对系统内部的实现逻辑进行适当的重构以使得系统对未来具备更好的扩展性和可维护性。


重构:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。使用一系列重构手法,在不改变软件可观察行为的前提下,调整结构。傻瓜都能写出计算机可以理解的代码。唯有能写出人类容易理解的代码的,才是优秀的程序员。

-- Martin Fowler 《重构 改善既有代码的设计》


看一个简化版本的例子,下面的代码部分是一个查询报告详情数据的接口,会发现中间有一大段的信息是在转换 aliId,但实际上这个行为并不是当前方法的重点,所以这里的单纯针对这一段我觉得应该单独抽取一个公用的方法出来。


public ReportDetailDto getDetail(ReportQueryParam queryParam) {    if (null == queryParam) {        log.error("queryParam is null");        throw new BizException(PARAM_ERROR);    }    Long aliId = queryParam.getAliId();    if (null == aliId) {        if (StringUtils.isBlank(queryParam.getToken())) {            log.error("aliId and token are both null. queryParam: {}",                    JSON.toJSONString(queryParam));            throw new BizException(PARAM_ERROR);        }        aliId = recommendAssistantServiceAdaptor.getAliIdByToken(queryParam.getToken());        if (null == aliId) {            log.error("cannot get aliId by token. queryParam: {}", JSON.toJSONString(queryParam));            throw new BizException("ALIID_NULL", "aliId is null");        }    }    // 获取同步数据    // 数据结构转换    return convertModel(itemEList);}
复制代码


四、总结


本文主要阐述了个人对软件复杂度的思考,分析了导致软件复杂度的原因、软件复杂度的度量方式以及阐述了自我理解的如何避免软件复杂度的问题。


只要每个人在每一个需求的开发中秉持匠心,持续提升自身架构设计的能力,先战略设计后战术实现,并针对开发过程中遇到的问题代码能够积极的进行重构,相信软件复杂度的问题也会不断的被我们击溃,胜利的旗帜永远属于伟大的程序员。


参考阅读


[01] 系统困境与软件复杂度,为什么我们的系统会如此复杂

https://mp.weixin.qq.com/s?__biz=MzIzOTU0NTQ0MA==&mid=2247507488&idx=1&sn=fb365890920245ad627df364cafc3550


[02] 《A Philosophy of Software Design》


[03] 《Clean Architecture》

发布于: 2023-04-18阅读数: 30
用户头像

阿里技术

关注

专注分享阿里技术的丰富实践和前沿创新。 2022-05-24 加入

阿里技术的官方号,专注分享阿里技术的丰富实践、前沿洞察、技术创新、技术人成长经验。阿里技术,与技术人一起创造成长与成就。

评论

发布
暂无评论
软件复杂度的思考与解决之道_技术成长_阿里技术_InfoQ写作社区