系统设计之降低复杂性

木小丰 2020年12月13日 1,138次浏览

人活着就是在对抗熵增定律,生命以负熵为生。——薛定谔

一、熵增定律

1、熵增定律

熵的概念最早起源于物理学,用于度量一个热力学系统的无序程度。热力学第二定律,又称“熵增定律”,表明了在自然过程中,一个孤立的系统总是从最初的集中、有序的排列状态,趋向于分散、混乱和无序;当熵达到最大时,系统就会处于一种静寂状态。

通俗的讲:系统的熵增过程,就是由原始到死亡的过程。“熵”是“活跃”的反义词,代表负能量。

非生命,比如物质总是向着熵增演化,屋子不收拾会变乱,手机会越来越卡,耳机线会凌乱,热水会慢慢变凉,太阳会不断燃烧衰变……直到宇宙的尽头——热寂。

2、软件系统的熵增

在软件开发、维护过程中。软件的生命力总是从最初的理想状态,逐步趋向于复杂、混乱和无序状态发展,直到软件不可维护而被迫下线或重构。这种损坏软件质量的因素的逐步增长,叫做软件的熵增现象,也即本文讨论的软件复杂性。

二、系统复杂性的表现

1、表象

  1. 代码混乱、新人不易上手
  2. 代码高度冗余,复用性低,开发效率低
  3. 扩展和修改困难,牵一发动全身
  4. 业务数据错乱
  5. 程序性能低下
  6. 系统难以移置
  7. BUG率居高不下
  8. 其它……

2、深层原因

(1)变更放大

复杂性的第一个征兆是,看似简单的变更需要在许多不同地方进行代码修改

(2)认知负荷

复杂性的第二个症状是认知负荷,这是指开发人员需要多少知识才能完成一项任务。较高的认知负担意味着开发人员必须花更多的时间来学习所需的信息,并且由于错过了重要的东西而导致错误的风险也更大。

(3)未知的未知

复杂性的第三个症状是,必须修改哪些代码才能完成任务,或者开发人员必须获得哪些信息才能成功地执行任务,这些都是不明显的。

3、小结

复杂性的三种表现形式中,未知的未知是最糟糕的。一个未知的未知意味着你需要知道一些事情,但是你没有办法找到它是什么,甚至是否有一个问题。你不会发现它,直到错误出现后,你做了一个改变。更改放大是令人恼火的,但是只要清楚哪些代码需要修改,一旦更改完成,系统就会工作。同样,高的认知负荷会增加改变的成本,但如果明确要阅读哪些信息,改变仍然可能是正确的。对于未知的未知,不清楚该做什么,或者提出的解决方案是否有效。唯一确定的方法是读取系统中的每一行代码,这对于任何大小的系统都是不可能的。甚至这可能还不够,因为更改可能依赖于一个从未记录的细微设计决策。

三、复杂性的原因

复杂性是由两件事引起的:依赖性和模糊性。

1、依赖关系

依赖关系是软件的基本组成部分,不能完全消除。实际上,我们在软件设计过程中有意引入了依赖性。每次编写新类时,都会围绕该类的 API 创建依赖关系。但是,软件设计的目标之一是减少依赖关系的数量,并使依赖关系保持尽可能简单和明显。

2、模糊性

当重要的信息不明显时,就会发生模糊。

一个简单的例子是一个变量名,它是如此的通用,以至于它没有携带太多有用的信息(例如,时间)。或者,一个变量的文档可能没有指定它的单位,所以找到它的惟一方法是扫描代码,查找使用该变量的位置。

晦涩常常与依赖项相关联,在这种情况下,依赖项的存在并不明显。例如,如果向系统添加了一个新的错误状态,可能需要向一个包含每个状态的字符串消息的表添加一个条目,但是对于查看状态声明的程序员来说,消息表的存在可能并不明显。

不一致性也是造成不透明性的一个主要原因:如果同一个变量名用于两个不同的目的,那么开发人员就无法清楚地知道某个特定变量的目的是什么。

3、依赖性和模糊性的积累

复杂性不是由单个灾难性错误引起的;它堆积成许多小块。单个依赖项或模糊性本身不太可能显着影响软件系统的可维护性。之所以会出现复杂性,是因为随着时间的流逝,成千上万的小依赖性和模糊性逐渐形成。最终,这些小问题太多了,以至于对系统的每次可能更改都会受到其中几个问题的影响。

四、降低复杂性的方法

1、日常开发留出一点战略规划时间

大多数程序员日常以战术编程的心态来进行软件开发。例如新功能或错误修复。乍一看,这似乎是完全合理的:还有什么比编写有效的代码更重要的呢?但是战术编程几乎不可能产生出良好的系统设计。

与之相对应的是战略规划,成为一名优秀的软件设计师的第一步是要意识到仅工作代码是不够的。尽管代码当然必须工作,但不应将“能跑通的代码”视为主要目标。战略设计的主要目标必须是制作出出色的设计,考虑后续的可维护性及扩展性。

战略性编程需要一种投资心态。尽管前提投入会比战术编程花费更多的时间,但随着系统的迭代,战略编程的优势就开始逐渐显现。

当然既然是投资,就要考虑投入产出比,不应该吹毛求疵,只要发现一点不合理的地方就整体大重构。推荐的方式小步快跑的方式,在日常开发中留出5%-10%的时间来做战略设计。

2、模块的设计

开发一个新模块,如果有不可避免的复杂性。两种设计思路哪个更好:1、应该让模块用户处理复杂性,2、应该在模块内部处理复杂性?如果复杂度与模块提供的功能有关,则第二个答案通常是正确的答案。

作为开发人员,很容易以相反的方式行事:解决简单的问题,然后将困难的问题推给其他人。如果出现不确定如何处理的条件,最简单的方法是引发异常并让调用方处理它。这样的方法短期内会使您的生活更轻松,但它们会加剧复杂性。大多数模块拥有的用户多于开发人员,因此此模块还会有许多人来维护。作为模块开发人员,您应该努力使模块用户的生活尽可能轻松,即使这对您来说意味着额外的工作。另一种更好的方法是,模块具有简单的接口比简单的实现更为重要。

模块是设计应该是深的,最好的模块是那些其接口比其实现简单得多的模块。这样的模块具有两个优点。1、一个简单的接口可以将模块强加于系统其余部分的复杂性降至最低。2、如果以不更改其接口的方式修改了一个模块,则该修改不会影响其他模块。如果模块的接口比其实现简单得多,则可以在不影响其他模块的情况下更改模块的许多方面。

3、如何编写注释

编写注释的原因是,使用编程语言编写的语句无法捕获编写代码时开发人员想到的所有重要信息。注释记录了这些信息,以便后来的开发人员可以轻松地理解和修改代码。注释的指导原则是,注释应描述代码中不明显的内容。

注释的最重要原因之一是抽象,其中包括许多从代码中看不到的信息。抽象的思想是提供一种思考问题的简单方法,但是代码是如此详细,以至于仅通过阅读代码就很难看到抽象。注释可以提供一个更简单,更高级的视图(“调用此方法后,网络流量将被限制为每秒 maxBandwidth 字节”)。即使可以通过阅读代码推断出此信息,我们也不想强迫模块用户这样做:阅读代码很耗时,并且迫使他们考虑很多不需要使用的信息模块。开发人员应该能够理解模块提供的抽象,而无需阅读其外部可见声明以外的任何代码。

4、重视命名

名称是一种抽象形式:名称提供了一种简化的方式来考虑更复杂的基础实体。良好的名字是一种文档形式:它们使代码更易于理解。它们减少了对其他文档的需求,并使检测错误更加容易。相反,名称选择不当会增加代码的复杂性,并造成可能导致错误的歧义和误解。

命名需满足以下几个要求:

(1)准确性

名称最常见的问题是名称太笼统或含糊不清。结果,读者很难说出这个名字指的是什么。读者可能会认为该名称所指的是与现实不符的事物,如上面的代码错误所示。考虑以下方法声明:

/**
 * Returns the total number of indexlets this object is managing.
 */
int IndexletManager::getCount() {...}

术语“计数”太笼统了:计数什么?如果有人看到此方法的调用,除非他们阅读了它的文档,否则他们不太可能知道它的作用。像 getActiveIndexlets 或 numIndexlets 这样的更精确的名称会更好:因为使用这些名称,读者可能无需查看其文档就能猜测该方法返回的内容。

(2)一致性

在任何程序中,都会反复使用某些变量。例如,文件系统反复操作块号。对于每种常见用法,请选择一个用于该目的的名称,并在各处使用相同的名称。例如,文件系统可能总是使用 fileBlock 来保存文件中块的索引。一致的命名方式与重用普通类的方式一样,可以减轻认知负担:一旦读者在一个上下文中看到了该名称,他们就可以重用其知识并在不同上下文中看到该名称时立即做出假设。

一致性具有三个要求:

  1. 始终将通用名称用于给定目的;
  2. 除了给定目的外,切勿使用通用名称;
  3. 确保目的足够狭窄,以使所有具有名称的变量都具有相同的行为。

(3)通过命名能构建起系统功能图

选择名称的目标是在读者的脑海中创建一幅关于被命名事物的性质的图像。

一个好名字传达了很多有关底层实体是什么,以及同样重要的是,不是什么的信息。在考虑特定名称时,请问自己:“如果有人孤立地看到该名称,而没有看到其声明,文档或使用该名称的任何代码,他们将能够猜到该名称指的是什么?还有其他名称可以使画面更清晰吗?” 当然,一个名字可以输入多少信息是有限制的。更泛化一些,能根据几个名字构建起一个模块的视图,根据模块的名称构建起单个系统的视图,根据单个系统命名构建起整个业务的视图。

5、辅助工具

(1)敏捷开发

敏捷开发是一种软件开发方法,它是在 1990 年代末期出现的,其思想涉及如何使软件开发更加轻量,灵活和增量。

敏捷开发中最重要的元素之一是开发应该是渐进的和迭代的概念。在敏捷方法中,软件系统是通过一系列迭代开发的,每个迭代都添加并评估了一些新功能。

现代的敏捷开发思想已经融合进了devops、持续集成等工具当中,在企业中可以使用现成的工具来实现软件开发的快速验证及迭代,开源工具也有很多。比如Jenkins。

持续集成工具中集成了很多有用的工具,比如静态代码检查、单元测试、自动化测试、预合并、自动部署等功能。

(2)Coca

Coca是一个用于系统重构、系统迁移和系统分析的瑞士军刀。它可以分析代码中的 badsmell,行数统计,分析调用与依赖,进行 Git 分析,以及自动化重构等。

借助coca工具,可以实现快速掌握系统整体的情况,比如重点方法调用图、类之间关系、系统命名健康情况、代码质量评估等。

示例一:类依赖关系

android-gradle-elements

示例二:方法调用图

call_demo

示例三:变量命名出现次数统计

+------------------+--------+
|      WORDS       | COUNTS |
+------------------+--------+
| context          |    590 |
| resolve          |    531 |
| path             |    501 |
| content          |    423 |
| code             |    416 |
| resource         |    373 |
| property         |    372 |
| session          |    364 |
| attribute        |    349 |
| properties       |    343 |
| headers          |    330 |
+------------------+--------+

示例四:代码质量评估

+--------------------------------+-------+-----------------------+-------+-----------+
|              TYPE              | COUNT |         LEVEL         | TOTAL |   RATE    |
+--------------------------------+-------+-----------------------+-------+-----------+
| Nullable / Return Null         |     0 | Method                |  1615 | 0.00%     |
| Utils                          |     7 | Class                 |   252 | 2.78%     |
| Static Method                  |     0 | Method                |  1615 | 0.43%     |
| Average Method Num.            |  1615 | Method/Class          |   252 |  6.408730 |
| Method Num. Std Dev / 标准差   |  1615 | Class                 | -     |  7.344917 |
| Average Method Length          | 13654 | Without Getter/Setter |  1100 | 12.412727 |
| Method Length Std Dev / 标准差 |  1615 | Method                | -     | 20.047092 |
+--------------------------------+-------+-----------------------+-------+-----------+

五、参考资料

声明
本博客内容均为原创,授权公共号:肉眼品世界 首发。博客链接

作者简介

美团Java高级工程师,关注软件架构及职业成长,不定期分享各种技术、资源,对文章中涉及的技术感兴趣或有任何问题请关注微信交流。

公共号:Java研发