前言
随着需求和规模的增大,软件系统变成了一个复杂系统,除了实现业务需求和非功能需求的必要复杂性外,我们希望系统的复杂性越低越好。当然我们的目的不是要消除复杂性,而是要能够驾驭它、利用它。在这篇文章中,笔者尝试以软件领域的两个基础原则和两个重要概念出发,来对如何做软件设计做一些思考。
首先来看一下什么是复杂系统。
复杂系统
复杂系统(Complex systems),是指由许多可能相互作用的组成成分所组成的系统。在很多情况下,将这样的系统表示为网络是有用的,其节点代表组成成分,链接则代表它们的交互作用。
复杂系统里的关系是非线性的。实际上,这意味着一个小的扰动可能会产生很大的影响(参见“蝴蝶效应”)、一个比例效应、或甚至根本没有效果。(出自维基百科)
破窗效应
心理学上有著名的破窗效应,也就是当某间房子的玻璃有一个小洞时,过不了很久,这间房子的所有窗户的玻璃都会被打碎,这个效应告诉人们在现实生活中不要忽视细微的缺陷,因为这个细微的缺陷很容易导致其它细微缺陷的出现,最后这些细微的缺陷作用于复杂系统时可能会演变成重大的事故。
软件系统中也经常是这样,当我们发现软件系统中某个差的设计会腐蚀架构,但没人去修改时,往往很快又会有另一个腐蚀架构的设计会出现,并且这两次差的设计对于架构的腐蚀力比单独一项要大的多,因为差的设计之间会相互作用。对设计的质量会造成大的伤害的一个思维是:每个人都觉得自己这样的做法没有问题,因为其他人也是这么做的。
软件系统的两个基础原则
1、可服务性是软件系统的第一需要。
软件系统需要优先保证的就是对于外部调用者的可服务能力。
2、随着软件系统需求的不断加入以及用户量的增加,系统的熵以及所需资源都在不断增大。
系统的熵的增大代表软件系统的复杂性增加,系统所需资源的增大代表软件系统的规模在增大,当复杂性和规模一起增大时,根据排列组合原理,它们会产生四种不同的组合:
(1)复杂性增加的很快,规模增加的很快;
(2)复杂性增加的很快,规模增加的很慢;
(3)复杂性增加的很慢,规模增加的很快;
(4)复杂性增加的很慢,规模增加的很慢。
软件系统的设计目标应该是复杂性和规模都增加的很慢,并且软件系统的复杂度要能够在我们的驾驭范围之内。但是目前软件设计中普遍的困境就是:随着复杂性越来越高,软件系统变成了一个复杂系统,人们往往只能理解复杂系统的一部分,所以越来越难以从全局的角度去思考越来越复杂的软件系统的设计,导致软件的熵的增大会越来越快。当熵值超过了一定的阈值后,系统会突然变得非常难以维护和扩展,这样就会导致软件系统的可服务性越来越差,并且差的趋势会越来越明显。
面对软件系统的熵日益增大的问题,通常的解决办法就是把软件系统非常明确地切割成几部分,使得每一部分的熵都在人理解的阈值以下,每一部分都安排专门负责设计的人。但是这种解决办法不能解决这个问题,因为架构是一个统一的综合体系,这种考虑就决定了必须有一个掌握全局的人,这种解决办法存在以下几个严重的问题:
(1)每一部分负责设计的人都很难看到全局,不可能站在全局的高度去思考整个系统的设计;
(2)局部最优并不能够说明是全局最优,甚至有可能局部最优的方案会使得全局受到很大的伤害。
(3)没有一个人能够掌控全局,这样会使得整个软件系统的熵增越来越难以控制,整个系统迟早会掉入到无法维护的境地。
为了解决上面的这个困境,必须使得软件系统的熵控制在合理的范围内,并且还要保证所需的资源尽量的少,这样必须有更加合理的设计方式和设计理念。这就需要引出软件领域中两个重要的概念:可维护性和性能。系统具有好的可维护性可以让系统的熵保持在可理解的范围内,系统具有好的性能可以让系统的规模保持在合理的范围内。
软件系统的两个重要概念
可维护性
经常会听到开发人员抱怨说某个系统维护性太差了,可能主要表现在几个方面:
(1)当增加新的需求时,不能确定该需求的实现要在哪几个模块中进行,也不确定每个模块完成该需求的哪些部分。
(2)需要在同一模块的多个“意想不到”的地方修改代码:修改某个bug时,发现要修改的代码分散在不同的地方,而且这些要修改的地方很多都是人“意想不到”的。
(3)很容易造成修改引入:修改某个bug时,这个bug修改好了,很大概率会引入其它bug,而且这种修改没办法用扩展的方式进行(使用扩展方式成本太高等原因)。
(4)代码无法自注释或者缺少必要的文字注释,完全不明白代码要处理的业务逻辑是什么。
(5)对于线上运行系统的问题定位非常困难,依据线上的日志没办法定位出具体问题,需要人工在测试环境中进行重现,并且不断地打补丁进行定位。
等等。
可维护性是软件系统好坏的关键衡量指标之一,一个维护性差的系统不管是需求的设计和开发、运行维护等都会非常困难,并且效率低下,可维护性主要包括以下几个方面:
1、设计可维护。
笔者认为设计的主要工作有两个:(1)定义设计相关的概念;(2)定义概念和概念之间的关系。
当然事物的概念以及概念和概念之间的关系并不是只有一种定义,设计的目的不一样,看事物的角度不一样,对事物的看法也会不一样。因此在设计的时候可以以合适的方式和语言来说明事物的概念以及概念和概念之间的关系。
设计可维护要求设计简洁,设计相关的概念定义明确,并且概念和概念之间的关系清晰。
设计简洁不是说设计简单。凡事应该尽量往简单处想,但是不能过于简单。因为现实世界本来就是一个非常复杂的系统,而软件系统可以认为是现实世界系统在软件领域的投影,如果现实系统非常复杂,其投影也会相应地变得复杂。但是既然是投影,那肯定就会有相对于现实世界“抽象”的部分,如果“抽象”的好,那投影会比现实系统简单很多,所以怎么去抽象很重要,可能决定了软件系统初始复杂度的高低。(另外,现实世界系统的不同形状投影到软件领域时可能是同一个事物,这样就可以做到复用,我们在做软件设计时需要进行这种非常重要的思考)。
业务设计和实现设计的可维护
设计可维护分为两个方面:业务设计的可维护和实现设计的可维护。业务设计的可维护是站在客户的角度思考,而实现设计的可维护是站在软件系统实现的角度来思考。
业务设计的目标是为客户带来最大化的价值,所以业务设计修改的前提是修改这个业务可以为客户带来更大的价值,并且这种价值是要站在整个系统的角度去衡量而不是单项业务的角度。业务设计可维护是要去思考当客户所处的商业环境或者内部的运作流程发生变化时,软件系统的业务设计怎么去更好地适应这种变化。
实现设计的可维护则是在业务设计变更的前提下,怎么去保障实现的速度最快、交付的软件版本的质量最高。
有哪些差的可维护设计
说什么是好的可维护设计非常困难,因为受到很多因素的限制和评判,但是什么样的设计维护非常困难,这个倒可以列举几个典型的,笔者给出根本没办法维护的设计的几个典型特征:
(1)各个服务的职责不清晰。
(2)服务之间严重耦合,服务间的关系彼此交错,互相依赖,非常混乱。
(3)服务的设计跟特定的实现耦合太紧,没有进行合理的抽象。
(4)设计中引入太多没有必要的概念,并且概念之间的关系不清晰,导致对于软件设计的理解非常复杂。
那是否避免上述这些典型差的特征,软件的设计可维护性就会很好吗?答案是否定的。人首要的任务不是去努力看清楚远处模糊的东西,而是去做好身边清楚的事情。设计的关键是要结合具体的业务逐渐理清楚什么样的设计是不可维护的,并且在以后的设计中要避免它们,我们需要有一个清单来记录哪些是差的设计方法,在后面做设计时再根据这个清单来进行逐一检查。
2、代码可维护。
在实际的项目中,很难维护的代码通常有以下几个典型特征:
(1)命名随意:代码命名不规范、不合理。
(2)违反DRY原则:重复代码多。
(3)上帝类和上帝方法:类和方法职责不清晰、类和方法过长等。
(4)上下层循环依赖:上层和下层的循环依赖等。
等等。
从笔者的实际经验来看,代码层面的可维护性虽然跟开发人员的设计能力有一定的关系,但是主要还是代码编写时的意识问题。开发人员需要去注重培养怎么去写好代码的意识,试想命名合理、注释规范、几乎没有重复代码、不存在上帝类和上帝方法、没有循环依赖这几个基本代码的要求,其实要做到也不难。
当然代码的可维护性里也包括扩展的灵活性,要做到对扩展开放、对修改关闭,需要有一定的抽象设计能力,这个需要有一定的经验积累才能做到。
3、运行可维护。
运行可维护主要包括运行时的问题定位、运行时服务的动态扩展等。笔者认为目前阻碍软件系统的运行可维护的两个最大障碍是:
(1)经常忽视对于可维护性的设计。
( 2)复杂的技术架构、流程以及组织结构导致软件系统的维护非常复杂。
很多时候我们在设计系统时,往往会忽略系统的运行可维护性设计,包括各服务的日志、服务的调用链分析、关键服务的实时监控、服务降级、熔断等处理。我们需要设计一个实时监控的运维地图,通过这个运维地图我们可以获知各个服务节点的运行状态、哪些服务需要扩容、哪些服务出现了问题等。
复杂的系统必然导致复杂的运维。运行可维护性的优化,也可以从优化不合理的技术架构、流程以及组织结构入手,试想一个有着复杂的技术架构、流程以及组织结构的系统,它的运维往往也会非常复杂,维护起来就会非常吃力。所以在设计的时候首先应该去试着做减法,对以前设计不合理、复杂的地方进行清理。只有简单的东西才好管理。
性能
说到性能,我们往往关注的是系统运行的速度,而笔者认为系统的性能不仅仅是这个,还需要关注设计、编码等整个软件交付过程的速度。
1、设计速度快
设计怎么同时兼顾速度和质量?笔者认为的一个可能有效的做法如下:
(1)弄清楚要解决的问题是什么?
(2)设计现状可视化
(3)设计动作标准化
弄清楚要解决的问题是什么非常重要,但是这也是很多设计人员没有仔细思考的地方。尽快采取行动没有问题,但是我们要清楚行动只是解决方案,一个没有针对问题的解决方案即便实现的再好,速度再快也是没有用的。爱因斯坦曾经说过:如果只有一个小时的时间来解决问题,我会花55分钟来思考问题是什么,五分钟来思考解决方案。把问题想清楚后,设计出来的东西往往就会更加简洁、易理解并且可重用性很好。
设计现状可视化要求我们把系统的现有设计用图形化的方式展现出来,可以保证不同的人对于同一个设计的理解是基本一致的,理解的程度也是差不多的,这样就可以做到不管是谁来设计,设计的速度有保证。
设计动作标准化要求把设计的输出要素整理成一个详细的清单,清单里列举了系统设计的必须步骤和详细描述,这样不管谁来设计,设计的结果质量有保证。这里需要注意的是,设计清单不要大而空,只是一些原则性的东西,而是一定要实际的可以落地的东西。
2、编码速度快
编码是一项把设计意图实现的活动。只有在设计意图明确清楚的情况下,编码的速度和质量才会有保障。
要保证编码的速度快,笔者认为的一些有效做法是:
(1)理解清楚设计意图。
(2)避免技术上的欠债。
(3)重构、重构、再重构。
理解清楚设计意图要求我们的设计的输出是规范、易理解的。现在一般有两种方式:一种是设计和编码实现是同一个人,类似于全栈的形式,就是由某一个人负责从需求的分析、设计、编码及后续的上线维护。另一种形式就是设计和编码实现是不同的人。不管是哪种形式,对于设计输出的规范和易理解再强调也不为过的,因为还需要考虑到后面换人或者维护时候的编码效率问题。
避免技术上的欠债要求我们不能为了短期的利益而放弃长远的利益。有时候为了一些紧急的需求采取了一些临时的解决方案,但这些解决方案对于整个系统的长远利益是有危害的。
重构要求我们看到代码中不合理的地方就要实行修改,避免破窗效应的发生。重构是一个trade-off的过程,根据业务需求来平衡可维护性、性能等方面。好的代码很大部分是重构出来的,只有在基于好代码的基础上,开发的速度才能有保障。
3、运行速度快
运行速度就是我们通常理解的性能了,它的快慢也很大程度影响到用户的直观体验好坏。
如果运行速度比较慢,笔者认为一些有效的做法是:
(1)重新审视业务方案,看能否从业务处理流程的优化上来优化性能。
(2)审视代码实现方式,尤其是对于一些特别耗费时间的操作,如IO操作(磁盘和网络IO)、数据库操作、多层循环计算等。
重新审视业务方案要求我们把事情放在根本上来解决,根据笔者的经验,性能问题绝大部分是业务方案的问题,如果优化来业务方案,性能问题可能就也随着解决了。这就要求我们更加深入地去了解业务,去发现业务处理过程中有哪些可以改进优化的流程。
审视代码实现要求我们在编码过程中保持对于耗时操作的代码编写的谨慎,需要多考虑在多并发、多线程、资源共用等运行状态下的高性能实现方案。笔者曾经在没有修改业务方案的前提下,通过优化代码,把运行性能提高了将近一百倍。