研究思考丨关于软件复杂度的困局
Aliware
大型系统的本质问题是复杂性问题。互联网软件,是典型的大型系统,如下图所示,数百个甚至更多的微服务相互调用/依赖,组成一个组件数量大、行为复杂、时刻在变动(发布、配置变更)当中的动态的、复杂的系统。而且,软件工程师们常常自嘲,“when things work, nobody knows why”。本文将重点围绕软件复杂度进行剖析,希望能够帮助读者对软件复杂度成因和度量方式有所了解,同时,结合自身的实践经验谈谈我们在实际的开发工作中如何尽力避免软件复杂性问题。
导致软件复杂度的原因
Aliware 导致软件复杂度的原因是多种多样的。 宏观层面讲,软件复杂是伴随着需求的不断迭代日积月累的必然产物,主要原因可能是: 1.对代码腐化的退让与一直退让。 2.缺乏完善的代码质量保障机制。如严格的 CodeReview、功能评审等等。 3.缺乏知识传递的机制。如无有效的设计文档等作为知识传递。 4.需求的复杂性导致系统的复杂度不断叠加。比如:业务要求今天 A 这类用户权益一个图标展示为✳️,过了一段时间,从 A 中切分了一部分客户要展示🌟。 对于前三点我觉得可以通过日常的工程师文化建设来尽量避免,但是随着业务的不断演化以及人员的流动、知识传递的缺失,长期的叠加之下必然会使得系统越发的复杂。此时,我觉得还需要进行系统的重构。 从软件开发微观层面讲,导致软件复杂的原因概括起来主要是两个:依赖(dependencies) 和隐晦(obscurity)。 依赖会使得修改过程牵一发而动全身,当你修改模块一的时候,也会牵扯到模块二、模块三等等的修改,进而容易导致系统 bug。而隐晦会让系统难于维护和理解,甚至于在出现问题时难于定位问题的根因,要花费大量的时间在理解和阅读历史代码上面。 软件的复杂性往往伴随着如下几种表现形式: 修改扩散 修改时有连锁反应,通常是因为模块之间耦合过重,相互依赖太多导致的。比如,在我们认证系统中曾经有一个判断权益的接口,在系统中被引用的到处都是,这种情况会导致一个严重问题,今年这个接口正好面临升级,如果当时没有抽取到一个适配器中去,那整个系统会有很多地方面临修改扩散的问题,而这样的变更比较抽取到适配器的修改成本是更高更风险的。
认知负担 当我们说一个模块隐晦、难以理解时,它就有过重的认知负担,开发人员需要较长的时间来理解功能模块。比如,提供一个没有注释的计算接口,传入两个整数得到一个计算结果。从函数本身我们很难判断这个接口是什么功能,所以此时就不得不去阅读内部的实现以理解其接口的功能。@Overridepublic 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)
相比于前两种症状,不可知危险更大,在开发需求时,不可知的改动点往往是导致严重问题的主要原因,常常是因为一些隐晦的依赖导致的,在开发完一个需求之后感觉心里很没谱,隐约觉得自己的代码哪里有问题,但又不清楚问题在哪,只能祈祷在测试阶段能够暴露出来。
软件复杂度度量
Aliware Manny Lehman 教授在软件演进法则中首次系统性提出了软件复杂度: 软件(程序)复杂度是软件的一组特征,它由软件内部的相互关联引起。随着软件的实体(模块)的增加,软件内部的相互关联会指数式增长,直至无法被全部掌握和理解。 软件的高复杂度,会导致在修改软件时引入非主观意图的变更的概率上升,最终在做变更的时候更容易引入缺陷。在更极端的情况下,软件复杂到几乎无法修改。 在软件的演化过程中,不断涌现了诸多理论用于对软件复杂度进行度量,比如,Halstead 复杂度、圈复杂度、John Ousterhout 复杂度等等。 Halstead 复杂度 Halstead 复杂度(霍尔斯特德复杂度量测) (Maurice H. Halstead, 1977) 是软件科学提出的第一个计算机软件的分析“定律”,用以确定计算机软件开发中的一些定量规律。Halstead 复杂度根据程序中语句行的操作符和操作数的数量计算程序复杂性。针对特定的演算法,首先需计算以下的数值:
-
为不同运算子(操作符)的个数。
-
为不同运算元(操作数)的个数。
-
为所有运算子合计出现的次数。
-
为所有运算元合计出现的次数。
我们梳理这段代码中的预算子和运算元以及分别统计出其个数: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;}
Aliware 软件复杂度问题可以完全避免么?我觉得不可能,但是这并不能成为我们忽视软件复杂度的理由,有很多措施可以帮助我们尽量避免自身的需求开发或工作中引入问题代码而导致软件复杂。这里结合日常的开发理解谈一下自己的认知: 1.开发前:我们可以通过需求梳理沉淀需求分析、架构设计等文档作为知识传递的载体。 2.开发中:我们需要强化系统架构理解,战略优先于战术,系统分层架构清晰统一,开发中接口设计要做到高内聚和低耦合同时保持良好代码注释的习惯。 3.维护阶段:我们可以进行代码重构,针对之前存在设计问题的代码,以新的思维和架构实现方案进行重构使得代码越来越清晰。 战略先于战术 在战术编程中,开发者主要关注点是能够 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}}// .....}
简化接口设计
简单的接口往往意味着调用者使用更加方便,如果我们为了实现简单,提供一个复杂的接口给外部使用者,此时往往带来的是耦合度增大,内聚降低,继而当该接口出现升级等场景时会产生修改扩散的问题,进而影响面发生扩散,带来一定的隐患。 因此,在模块设计的时候,要尽量遵守 把简单留给别人,把复杂留给自己的原则 。 比如这样一个例子,下面两段代码实现的是同样的逻辑,方法一的设计明显要由于方法二,为什么?方法一更简单,而方法二明显违背了把简单留给别人,把复杂留给自己的原则。如果你是接口的使用者当你使用方法二的时候,你一定会遇到的两个问题:第一,需要传递哪些参数要靠问方法的提供方,或者要看方法的内部实现;第二,你需要在取到返回值后从返回值中解析自己想要的结果。这些问题无疑会让系统复杂度提升。 所以,我们要简化接口设计,把简单留给别人,把复杂留给自己,从而保证接口的高内聚和低耦合,进而降低系统的复杂度。@Overridepublic boolean createProcess(StartProcessDto startProcessDto) {// XXXXXXX}@Overridepublic HashMap createProcess(HashMap dataMap) {// XXXXXXX}
隐藏实现细节
隐藏细节指的就是只给调用者暴露重要的信息,把不重要的细节隐藏起来。接口设计时,我们要通过接口告诉使用者我们需要哪些信息,同时也要通过接口告诉使用者我会给到你哪些信息,至于内部如何实现使用者不需要关心的。 还是以上面的接口的实现为例子,方法一对内部实现细节达到了屏蔽,使得当前接口具备更好的内聚性,当内部实现的服务需要调整时只需要修改内部的实现即可,而方法二则不然。通过这个案例也能够实际体会到,把内部的实现细节隐藏在实现方的内部能够有效的提升接口的内聚性降低系统耦合,随之带来的是系统复杂度的降低。@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;}}
通用接口设计
通用接口设计并不是说所有的场景都为了通用而设计,而是针对具有同样能力的多套实现代码而言,我们可以抽取成通用的接口设计,通过业务类型等标识区分实现一个接口完成。 举一个例子,有一个需求是同时实现多种会员的权益列表功能,由于不同会员的权益并不完全相同,所以刚开始的想法是分开设计不同的接口来承接不同会员的权益内容的获取,但是本质上实现的是同样的内容:查询会员权益,所以最终通过对领域模型的重构抽取了统一的模型从而实现了通用的权益查询的接口。分层架构 从经典的三层架构到领域驱动设计都有涉及到分层架构,分层架构的核心其实我理解是隔离,将不同职责的对象划分到不同的层中实现,良好的分层能够实现软件内部复杂度问题的隔离,降低“洪泛”效应。 端口适配器架构将系统划分为内部(业务逻辑)和外部(客户请求/基础设施层/外部系统)。主动适配器(Driving adapters)承接了外部请求,系统内部业务逻辑能对其进行主动适配,独立于不同的调用方式提供通用的接口。被动适配器(Driven adapters)承接了内部业务逻辑调用外部系统的诉求,为了避免外部系统污染内部业务逻辑,通过适配屏蔽外部系统的底层细节,有利于内部业务逻辑的独立性。在复杂软件的开发过程中,很容易出现分层的混淆,逐渐出现分层不清晰,系统业务逻辑和交互 UI/基础设施等代码逻辑逐渐耦合,导致业务逻辑被污染的问题,而端口适配器正是要解决该类问题。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;}
重构:对软件内部结构的一种调整,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。使用一系列重构手法,在不改变软件可观察行为的前提下,调整结构。傻瓜都能写出计算机可以理解的代码。唯有能写出人类容易理解的代码的,才是优秀的程序员。看一个简化版本的例子,下面的代码部分是一个查询升金报告详情数据的接口,会发现中间有一大段的信息是在转换 aliId,但实际上这个行为并不是当前方法的重点,所以这里的单纯针对这一段我觉得应该单独抽取一个公用的方法出来。
-- Martin Fowler 《重构 改善既有代码的设计》
总结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);}
Aliware 本文主要阐述了个人对软件复杂度的思考,分析了导致软件复杂度的原因、软件复杂度的度量方式以及阐述了自我理解的如何避免软件复杂度的问题。 只要每个人在每一个需求的开发中秉持匠心,持续提升自身架构设计的能力,先战略设计后战术实现,并针对开发过程中遇到的问题代码能够积极的进行重构,相信软件复杂度的问题也会不断的被我们击溃,胜利的旗帜永远属于伟大的程序员。