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

2,448 阅读19分钟

作者:聂晓龙(率鸽)

读 A Philosophy of Software Design 有感,软件设计与架构复杂度,你是战术龙卷风吗?

前言

有一天,一个医生和一个土木工程师在一起争论“谁是世界上最古老的职业”。医生说:“上帝用亚当的肋骨造出了夏娃,这是历史上第一次外科手术,所以最古老的职业应该是医生”,土木工程师说:“在创世纪之前,上帝从混沌中创造了天堂与人间,这是更早之前的一次土木作业,所以最古老的职业应该是土木工程”。这时软件工程师拖着键盘走出来说,“那你认为,是谁创造了那片混沌?”

建筑师不会轻易给100层的高楼增加一个地下室,但我们却经常在干这样的事,并且总有人会对你说,“这个需求很简单”。到土里埋个地雷,这确实不复杂,但我们往往面临的真实场景其实是:“在这片雷区里加一个雷”,而雷区里哪里有雷,任何人都不知道 。

什么是复杂性

我们一直在说系统很复杂,那到底什么是复杂性?关于复杂的定义有很多种,其中比较有代表的是Thomas J. McCabe 在1976提出的理性派的复杂性度量,与John Ousterhout 教授提出的感性派的复杂性认知。

理性度量

复杂性并不是什么新概念,早在上世纪70年代,软件就已经极其复杂,开发与维护的成本都非常高。1976年McCabe&Associates公司开始对软件进行结构测试,并提出了McCabe Cyclomatic Complexity Metric,我们也称之为McCabe圈复杂度。它通过多个维度来度量软件的复杂度,从而判断软件当前的开发/维护成本。

感性认知

复杂度高的代码一定不是好代码,但复杂度低的也不一定就是好代码。John Ousterhout教授认为软件的复杂性相对理性的分析,可能更偏感性的认知。

Complexity is anything that makes software hard to understand or to modify
译:所谓复杂性,就是任何使得软件难于理解和修改的因素。

  • John Ousterhout 《A Philosophy of Software Design》

50年后的今天,John Ousterhout教授在 A Philosophy of Software Design 书中提到了一个非常主观的见解,复杂性就是任何使得软件难于理解和修改的因素。

模糊性与依赖性是引起复杂性的2个主要因素,模糊性产生了最直接的复杂度,让我们很难读懂代码真正想表达的含义,无法读懂这些代码,也就意味着我们更难去改变它。而依赖性又导致了复杂性不断传递,不断外溢的复杂性最终导致系统的无限腐化,一旦代码变成意大利面条,几乎不可能修复,成本将成指数倍增长。

复杂性的表现形式

复杂的系统往往也有一些非常明显的特征,John教授将它抽象为变更放大(Change amplification)、认知负荷(Cognitive load)与未知的未知(Unknown unknowns)这3类。当我们的系统出现这3个特征,说明我们的系统已经开始逐渐变得复杂了。

症状1-变更放大

Change amplification: a seemingly simple change requires code modifications in many different places.

译:看似简单的变更需要在许多不同地方进行代码修改。

  • John Ousterhout 《A Philosophy of Software Design》

变更放大(Change amplification)指得是看似简单的变更需要在许多不同地方进行代码修改。比较典型的代表是Ctrl-CV式代码开发,领域模型缺少内聚与收拢,当需要对某段业务进行调整时,需要改动多个模块以适应业务的发展。

/**
 * 销售捡入客户
 */
public void pick(String salesId, String customerId) {
  // 查询客户总数
  long customerCnt = customerDao.findCustomerCount(salesId);
  // 查询销售库容
  long capacity = capacityDao.findSalesCapacity(salesId);
  // 判断是否超额
  if(customerCnt >= capacity) {
    throws new BizException("capacity over limit");
  }
  // 代码省略 do customer pick
}

在CRM领域,销售捡入客户时需要进行库容判断,这段代码也确实可以满足需求。但随着业务的发展,签约的客户要调整为不占库容。而客户除了销售捡入,还包括主管分发、leads分发、手工录入、数据采买等多个场景,如果没对库容域做模型的收拢,一个简单的逻辑调整,就需要我们在多个场景做适配才能满足诉求。

症状2-认知负荷

Cognitive load: how much a developer needs to know in order to complete a task.
译:开发人员需要多少知识才能完成一项任务。

  • John Ousterhout 《A Philosophy of Software Design》

认知负荷(Cognitive load)是指开发人员需要多少知识才能完成一项任务。使用功能性框架时,我们希望它操作简单,部署复杂系统时,我们希望它架构清晰,其实都是降低一项任务所需的成本。盲目的追求高端技术,设计复杂系统,增加学习与理解成本都属于本末倒置的一种。

TMF是整个星环的支柱,也是业务中台面向可复用可扩展架构的核心。但TMF太过复杂,认知与学习成本非常高,我们日常中所面临的一些扩展诉求99%(或者应该说100%)都不适合TMF,可能通过一些设计模式或者就是一些if else,可能更适合解决我们的问题。

除此之外,还包括一些简单搜索场景却用到了blink等流式引擎,简单后台系统通过DDD进行构建,几个商品发布的状态机转换用上了规则引擎等等,都属于认知负荷复杂度的一种。

症状3-未知的未知

Unknown unknowns: it is not obvious which pieces of code must be modified to complete a task
译:必须修改哪些代码才能完成任务。

  • John Ousterhout 《A Philosophy of Software Design》

未知的未知(Unknown unknowns)是指必须修改哪些代码才能完成任务,或者说开发人员必须获得哪些信息才能成功地执行任务。这一项也是John Ousterhout教授认为复杂性中最糟糕的一个表现形式。

当你维护一个有20年历史的项目时,这种问题的出来相对而言就没那么意外。由于代码的混乱与文档的缺失,导致你无法掌控一个500万行代码的应用,并且代码本身也没有明显表现出它们应该要阐述的内容。这时“未知的未知”出现了,你不知道改动的这行代码是否能让程序正常运转,也不知道这行代码的改动是否又会引发新的问题。这时候我们发现,那些“上帝类”真的就只有上帝能拯救了。

为什么会产生复杂性

那软件为什么越来越复杂,是不是减少一些犯错就能避免一场浩劫呢?回顾那些复杂的系统,我们可以找到很多因素导致系统腐化。

  1. 想简单图省事,没有及时治理不合理的内容
  2. 缺少匠心追求,对肮脏代码视而不见
  3. 技术能力不够,无法应对复杂系统
  4. 交接过渡缺失,三无产品几乎无法维护

除了上述内容外,还可以想到很多理由。但我们发现他们好像有一个共同的指向点 - 软件工程师,似乎所有复杂的源头就是软件工程师的不合格导致,所以其实一些罪恶的根因是我们自己?

1、统一的中国与分裂的欧洲

欧洲大陆面积大体与中国相当,但为什么欧洲是分裂的,而中国是统一的。有人说他们文化不一样,也有人说他们语言不通是主要原因,也有人说他们缺一个秦始皇。其实我们回顾欧洲的历史,欧洲还真不缺一个大一统的帝国。罗马帝国曾经让地中海成为自己的内海,拿破仑鼎盛时期掌管着1300万平方公里的领地。欧洲也曾出现过伟大的帝国,但都未走向统一。

我们再观察地图,其实除了中国、俄罗斯以外,全世界99%的国家都是小国。分裂才是常态,统一才不正常。马老师也曾说过,成功都有偶然性只有失败才存在必然。只有极少国家才实现了大一统,所以我们不应该问为什么欧洲是分裂的,而应该问为什么中国是统一的。类比到我们的软件也同样如此,复杂才是常态,不复杂才不正常。

2、软件固有的复杂性

The Complexity of software is an essential property, not an accidental one.

译:软件的复杂性是一个基本特征,而不是偶然如此。

  • Grady Booch 《Object-Oriented Analysis and Design with Applications》

Grady Booch在 Object-Oriented Analysis and Design with Applications 中提出这样一个观念,他认为软件的复杂性是固有的,包括问题域的复杂性、管理开发过程的困难性、通过软件可能实现的灵活性与刻画离散系统行为的问题,这4个方面来分析了软件的发展一定伴随着复杂,这是软件工程这本科学所必然伴随的一个特性。

Everything, without exception, requires additional energy and order to maintain itself. I knew this in the abstract as the famous second law of thermodynamics, which states that everything is falling apart slowly.

译:世间万物都需要额外的能量和秩序来维持自身,无一例外。这就是著名的热力学第二定律,即所有的事务都在缓慢地分崩离析。

-- Kevin Kelly 《The Inevitable》

Kevin Kelly在 The Inevitable 也有提过类似的观点,他认为世间万物都需要额外的能量和秩序来维持自身,所有的事物都在缓慢地分崩离析。没有外部力量的注入事物就会逐渐崩溃,这是世间万物的规律,而非我们哪里做得不对。

软件架构治理复杂度

为软件系统注入的外力就是我们的软件架构,以及我们未来的每一行代码。软件架构有很多种,从最早的单体架构,到后面的分布式架构、SOA、微服务、FaaS、ServiceMesh等等。所有的软件架构万变不离其宗,都在致力解决软件的复杂性。

架构的本质

编程范式指的是程序的编写模式,软件架构发展到今天只出现过3种编程范式( paradigm ),分别是结构化编程,面向对象编程与函数式编程。

  • 结构化编程取消 goto 移除跳转语句,对程序控制权的直接转移进行了限制和规范
  • 面向对象编程限制 指针 的使用,对程序控制权的间接转移进行了限制和规范
  • 函数式编程以 λ演算法 为核心思想,对程序中的赋值进行了限制和规范

面向对象的五大设计原则 S.O.L.I.D。依赖倒置限制了模块的依赖顺序、单一职责限制模块的职责范围、接口隔离限制接口的提供形式。

软件的本质是约束。商品的代码不能写在订单域,数据层的方法不能写在业务层。70年的软件发展,并没有告诉我们应该怎么做,而是教会了我们不该做什么。

递增的复杂性

软件的复杂性不会凭空消失,并且会逐级递增。针对递增的复杂性有3个观点:

  1. 模糊性创造了复杂,依赖性传播了复杂
  2. 复杂性往往不是由单个灾难引起的
  3. 我们可以容易地说服自己,当前变更带来的一点点复杂性没什么大不了

曾经小李跟我抱怨,说这段代码实在是太恶心了,花了很长时间才看懂,并且代码非常僵硬,而正好这个需求需要改动到这里,代码真的就像一坨乱麻。我问他最后是怎么处理的,他说,我给它又加了一坨。

编程思维论

战术编程

其实小李的这种做法并非是一个个体行为,或许我们在遇到复杂代码时都曾这样苟且过,John教授这种编程方法称之为“战术编程”。战术编程最主要的特点是快,同时具备如下几个特点。

  1. 当前一定是最快的
  2. 不会花费太多时间来寻找最佳设计
  3. 每个编程任务都会引入一些复杂度
  4. 重构会减慢当前任务速度,所以保持最快速度
@HSFProvider(serviceInterface = AgnDistributeRuleConfigQueryService.class)
public class AgnDistributeRuleConfigQueryServiceImpl implements AgnDistributeRuleConfigQueryService {

    @Override
    public ResultModel<AgnDistributeRuleConfigDto> queryAgnDistributeRuleConfigById(String id) {
        logger.info("queryAgnDistributeRuleConfigById id=" + id);
        ResultModel<AgnDistributeRuleConfigDto> result = new ResultModel<AgnDistributeRuleConfigDto>();
        if(StringUtils.isBlank(id)){
            result.setSuccess(false);
            result.setErrorMsg("id cannot be blank");
            return result
        }
        try {
            AgnDistributeRuleConfigDto agnDistributeRuleConfigDto = new AgnDistributeRuleConfigDto();
            AgnDistributeRuleConfig agnDistributeRuleConfig = agnDistributeRuleConfigMapper.selectById(id);
            if(agnDistributeRuleConfig == null){
                logger.error("agnDistributeRuleConfig is null");
                result.setSuccess(false);
                result.setErrorMsg("agnDistributeRuleConfig is null");
                return result
            }
            this.filterDynamicRule(agnDistributeRuleConfig);
            BeanUtils.copyProperties(agnDistributeRuleConfig, agnDistributeRuleConfigDto);
            result.setSuccess(true);
            result.setTotal(1);
            result.setValues(agnDistributeRuleConfigDto);
        } catch (Exception e) {
            logger.error("queryAgnDistributeRuleConfigById error,", e);
            result.setSuccess(false);
            result.setErrorMsg(e.getMessage());
        }
        return result;
    }
}

我们看上面这段代码,是一段查询分发规则的业务逻辑。虽然功能能够work,但不规范的地方其实非常多

  1. Facade层定义全部逻辑 - 未做结构分层
  2. 业务与技术未做分离 - 耦合接口信息与业务数据
  3. Try catch 满天飞 - 缺少统一异常处理机制
  4. 没有规范化的日志格式 - 日志格式混乱

但不可否认,他一定是当前最快的。这就是战术设计的特点之一,永远按当前最快速交付的方案进行推进,甚至很多组织鼓励这种工作方式,为了使功能更快运作,只注重短期收益而忽略长期价值。

战术龙卷风

Almost every software development organization has at least one developer who takes tactical programming to the extreme: a tactical tornado.

译:几乎每个软件开发组织都有至少一个将战术编程发挥到极致的开发人员:战术龙卷风。

  • John Ousterhout 《A Philosophy of Software Design》

将战术编程发挥到极致的人,叫战术龙卷风。战术龙卷风以腐化系统为代价换取当前最高效的解决方案(或许他自己并未觉得)。战术龙卷风也有如下几个特点:

  1. 是一位多产的程序员,没人比龙卷风更快完成任务
  2. 总能留下龙卷风后毁灭的痕迹留给后人去清理
  3. 是真的很卷

一些组织甚至会将战术龙卷风视为英雄,为什么能干得又多又快?因为他将成本放到了未来。软件工程最大的成本在于维护,我们每一次代码的改动,都应该是对历史代码的一次整理,而非单一的功能堆积。龙卷风能赢得现在,但终将失去未来,而这个失败的未来或许需要全团队与他一起买单。

战略编程

John教授提出与战术编程相对的是战略编程,战略编程更注重长期价值,不满足于功能work,致力于制作出色的设计,以满足对未来扩展的诉求(注意,不要过度)。战略设计有如下4个特点

  1. 工作代码远远不够
  2. 引入不必要的复杂度不可接受
  3. 不断对系统设计进行小幅改进
  4. 投资心态(每位工程师都需要对良好的设计进行连续的少量投资 10~20%)

John Ousterhout教授在 A Philosophy of Software Design 书中提到了战略设计与战术设计的总成本投入。随着时间的流逝,战略设计可以有效控制软件成本,但战术设计会随着时间的推移线性递增。这与Martin Fowler在 Patterns of Enterprise Application Architecture 这本书中所提的关于数据驱动与领域驱动关于复杂度的治理是同样的含义,要致力于长期的价值投资。

系统的困境与演进

没有系统是天然复杂的,为了快速完成任务不断引入新的复杂度至系统逐渐腐化,无限增长与无限传递的复杂度让软件需求越来越难“快速完成”。当有一天我们意识到系统的复杂性时再试图通过战略设计进行软件的迭代,你会发现举步维艰,一处很小的修改需要投入大量的基建修复,最终我们不得不向成本低头,不断再通过战术设计无限的苟且。

A condition that is often incorrectly labeled software maintenance. To be more precise, it is maintenance when we correct errors; it is evolution when we respond to changing requirements; it is preservation when we continue to use extraordinary means to keep an ancient and decaying piece of software in operation. Unfortunately, reality suggests that an inordinate percent- age of software development resources are spent on software preservation.

译:我们总是说我们需要“维护”这些老系统。而准确的说,在软件发展过程里,只有我们修正错误时,才是维护;在我们应对改变的需求时,这是演进;当我们使用一些极端的手段来保持古老而陈腐的软件继续工作时,这是保护(苟且)。事实证明我们更多的时间是在应对最后一种状况。

  • Grady Booch 《Object-Oriented Analysis and Design with Applications》

如同Grady Booch在 Object-Oriented Analysis and Design with Applications 中所提到的观点,当我们使用一些极端的手段来保持古老而陈腐的软件继续工作时,这确实是一种苟且。我们小心翼翼、集成测试、灰度发布、及时回滚等等,我们没有在“维护”他们,而是以一种丑陋的方式让这些丑陋的代码继续能够成功苟且下去。当代码变成意大利面条时,将几乎是不可能修复,成本将成指数倍增长,并且似乎我们的系统已经存在这样的代码,并且可能还在持续增加中。

架构伪论

在架构设计中,总有一些软件工程师所坚信的诗和远方,但到不了的乌托邦不一定就是遥不可及的美好圣地,实则也可能是对系统无益甚至有害的架构设计。这里列举其中2条可能存在的架构伪论。

1、好的代码自解释

Comments do not make up for bad code

译:注释不是对劣质代码的补救

  • Martin Fowler 《Clean Code》

Martin Fowler在 Clean Code 书中提到注释不是对劣质代码的补救,以前我也一直坚信如果代码足够好是不需要注释的。但实则这是一个伪命题,John教授这么评价它 ‘good code is self-documenting’ is a delicious myth。

/**
 * 批量查询客户信息
 */
public List<CustomerVO> queryCustomerList(){
  // 查询参数准备
  UserInfo userInfo = context.getLoginContext().getUserInfo();
  if(userInfo == null || StringUtils.isBlank(userInfo.getUserId())){
    return Collections.emptyList();
  }
  LoginDTO loginDTO = userInfoConvertor.convert(userInfo);
  // 查询客户信息
  List<CustomerSearchVO> customerSearchVOList = customerRemoteQueryService.queryCustomerList(loginDTO);
  Iterator<CustomerSearchVO> it = customerSearchVOList.iterator();
  // 排除不合规客户
  while(it.hasNext()){
    CustomerSearchVO customerSearchVO = it.next();
    if(isInBlackList(customerSearchVO) || isLowQuality(customerSearchVO)){
      it.remove();
    }
  }
  // 补充客户其他属性信息
  batchFillCustomerPositionInfo(customerSearchVOList);
  batchFillCustomerAddressInfo(customerSearchVOList);
  return customerSearchVOList;
}

这段代码我们可以很轻松的在5秒内看明白这个函数是做什么的,并且知道它内部的一些业务规则。无限的私有方法封装会让代码链路过深,无限类的拆解会造成更多网状依赖,至少有3点内容,让我们绝不能抛弃注释。

  1. 无法精准命名
    命名的含义是抽象实体隐藏细节,我们不能在一个名字上赋予它全部的信息,而必要的注释可以完美的进行辅佐。
  2. 设计思想的阐述
    代码只能实现设计不能阐述设计,这也是为什么一些复杂的架构设计我们需要文档的支撑而非代码的‘自解释’,在文档与代码之间的空隙,由注释来填补。
  3. 母语的力量
    这点尤其适合我们中国人,有时并不是因为注释少代码多,所以我们下意识会首先看代码。而是我们几十年感受的文化,让我们对中文与ABC具有完全不一样的感观。

2、永远追求最优雅

雷布斯曾自夸自己写的代码像诗一样优雅,追求优雅的代码应该是每个软件工程师的心中的圣地。但有时存在一些不优雅,存在一些‘看似不合理’并不代表就不对,反而有时在追求更优雅的路上我们持续跑偏。

The goal of software architecture is to minimize the human resources required
to build and maintain the required system.

译:软件架构的终极目标是,用最小的人力成本来满足构建和维护该系统的需求

  • Robert C.Martin 《Clean Architecture》

Robert C.Martin在 Clean Architecture 一书中提到了架构终极目标,用最小的人力成本来满足构建和维护该系统的需求。架构始终是我们解决复杂度的一个工具,如果当前系统并不复杂,我们不需要为了所谓的优雅去过分改造与优化它,持续将成本置在一个较低水位,就是软件最好的解决办法。

业务简单的系统不应用DDD架构,弱交互场景也无需进行前后端分离,哪怕是邓总设计师在规划新中国的发展上,也是制定了一套‘中国特色社会主义’制度。不要盲从一些教条的观念,选择适合自己的,控制在可控制范围内,既不过度也不缺失。毕竟没有绝对的优雅,甚至没有绝对的正确。

写在最后

很多人认为做业务开发显得没那么有挑战性,但其实正好相反。最难解决的bug是无法重现的bug,最难处理的问题域是不确定性的问题域。业务往往是最复杂的,面向不确定性设计才是最复杂的设计。软件工程学科最难的事情是抽象,因为它没有标准、没有方法、甚至没有对错。如何在软件固有的复杂性上找到一条既不过度也不缺失的路,是软件工程师的终身课题,或许永远也无法达到,或许我们已经在路上了。

参阅书籍:

关注【阿里巴巴移动技术】,阿里前沿移动干货&实践给你思考!