【架构整洁之道系列】设计与架构的相关内容&一些思考

1,252 阅读19分钟

最近一直在读《Clean Architecture》这本书,书中对与软件设计与架构的阐述是非常深刻的。因此开了一篇文章,来记录书中一些优秀的架构设计理念,以及我自己的一些思考。


一、设计与架构

1-1、设计与架构是什么?

设计(Design)与架构(Architecture)这两个概念让大多数人十分迷惑——什么是设计?什么是架构?二者究竟有什么区别?

实际上,两者说的是相同的东西,“架构”这个词往往使用于“高层级”的讨论中,这类讨论一般都把“底层”的实现细节排除在外。而“设计”一词,往往用来指代具体的系统底层组织结构和实现的细节。

但实际上,在软件设计的过程中底层设计细节和高层架构是不可分割的。它们组合在一起,共同定义了整个软件系统,缺一不可。所谓的底层和高层本身就是一系列决策组成的连续体,并没有清晰的分界线。

而我们追求好的设计和架构的目的是什么呢——

我们希望能够用最小的人力成本来满足构建和维护该系统的需求。

1-2、设计与架构的存在意义

为什么我们需要设计与架构呢?下面这个例子或许能解答这一问题:

下面这几张图显示了随着开发人员的增长,软件包体积和开发成本的关系。

image.png image.png image.png

是的,随着开发人员的变多,我们的开发成本会有一个指数级的上升,但是产出(这里使用生成的软件包体积来计算)却没有对应的增长——甚至到后面已经有所停滞了。

这样就会面临一个问题,随着软件的不断迭代,ROI 会变得越来越低。

image.png

image.png

那么是什么原因导致的这个问题呢——

“代码质量可以往后放放,项目按时上线最重要”的思维方式。

但实际上我们也知道,项目上线后就不会有人提“重构”了。行业竞争压力这么大,工程师忙着开发新功能,哪有时间重构代码呢?久而久之就成了“代码屎山”。

image.png

然而事实上,不论从短期还是从长期来看,这种粗糙的开发方式其实比循规蹈矩的开发速度更慢。上面这张图展示了使用一些业界优质代码方法论(比如 TDD)来指导开发,对开发效率的提升。可以看到随着迭代次数的增加,完成工作所需要的时间也就越少。这也揭示了软件开发的一个核心特点:

要想跑得快,先要跑得稳。

1-3、软件的价值维度

每个软件系统都有“行为”、“架构”两个维度的价值。

行为价值很好理解,就是这个软件所具有的功能所带来的价值,就像聊天软件的价值就在于“聊天”,仿真模拟软件的价值就在于“仿真模拟”等等。

架构价值指的是软件的灵活性,也就是这个软件是否是易于扩展或变更的,这其实很好理解,如果这个软件很粗糙,那么我们会付出极大的变更成本,这样肯定是会影响它的价值的。

那么哪个维度更重要呢?

如果这个问题问业务部门,那肯定会得到“行为价值更重要”的回答,但这种回答是否正确呢?我们可以来看一个简单的推论:

如果某程序可以正常工作,但是无法修改,那么当需求变更的时候它就不再能够正常工作了,我们也无法通过修改让它能继续正常工作。因此,这个程序的价值将成为 0。 如果某程序目前无法正常工作,但是我们可以很容易地修改它,那么将它改好,并且随着需求变化不停地修改它,都应该是很容易的事。因此,这个程序会持续产生价值。

所以软件的行为价值和架构价值实际上是一个乘算关系,任意一者归零了,那么软件的整体价值就归零了。也正是因为如此,两者都是十分重要的,而在一些需要长期维护软件的场景中,架构价值是要优于行为价值的。

当然,的确有一些系统是不能更改的,比如那些已经跑了几十年的老设备的驱动程序,对它们实施变更的成本要远大于变更所带来的价值。相信大家在开发中已经见到不少这样的例子了。

二、编程范式

2-1、结构化编程

结构化编程是一种编程范式,它采用子程序、块结构、条件分支以及循环等结构,来取代传统的 goto 语句,从而优化计算机程序的可读性和开发时间,避免写出面条式代码。

具体来说,结构化编程是以一些简单、有层次的程序流程架构所组成,可分为“顺序”、“条件分支”及“循环”三大类。

顺序是指程序正常的执行方式,执行完一个指令后,执行后面的指令。 条件分支是根据程序的状态,选择数段程序中的一个来执行,一般会使用 if、switch/case 等关系字来识别。 循环是指一直执行某一段程序,直到满足特定条件,或是集合中的所有元素均已处理过后结束,一般会使用 for、while 等关键字识别。

另外,若一个编程语言的语法允许用成对的关键字包围一段程序,形成一个结构,这种结构我们称之为“块结构”,比如在 C 语言中用大括号 {...} 包围的一段程序。

结构化编程范式中最有价值的地方就是,它赋予了我们编写“可测试的程序单元”的能力。同样的,这也是为什么在架构设计领域,功能性的拆分一直是最佳实践的原因。

2-2、 面向对象编程

面向对象编程到底是什么?业界在这个问题上存在着很多不同的说法和意⻅。然而对一个软件架构师来说,其含义应该是非常明确的:

面向对象编程就是以对象为手段来对源代码中的依赖关系进行控制的能力,这种能力能让软件架构师可以构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层组件也能编译成插件,实现独立于高层组件的开发和部署。

2-3、 函数式编程

函数式编程会将所有的程序视为函数运算,并且避免使用“状态”以及“可变对象”。

相比于指令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是直接设计一个复杂的执行过程。

在函数式编程中,函数是最重要的,意思是说一个函数,既可以作为其它函数的输入参数值,也可以从函数中返回值,被修改或者被分配给一个变量。

2-4、 总结一下

结构化编程、面向对象编程、函数式编程这三个编程范式都对编写的程序提出的限制。每个范式都约束了某种编写代码的方式。

结构化编程是多对程序控制权的直接转移(也就是 goto 语句)的限制。 面向对象编程是对程序控制权的间接转移(也就是指针)的限制。 函数式编程是对程序中赋值操作的限制。

也就是说,不论是框架还是编程范式,核心都是一种规范和约束,而这种约束底层的指导思想就是——什么不该做。

三、设计原则

3-1、概述

设计原则的主要作用就是告诉我们如何将数据和函数组织成为类(注意,这里虽然用到了“类”这个词,但是并不意味着我们将要讨论的这些设计原则仅仅适用于面向对象编程。这里的类仅仅代表一种数据和函数的分组),以及如将这些类组合起来成为程序。

而这些设计原则的目标主要由以下几点: 1、使软件能够灵活、低成本地进行改动。 2、使软件具有更高的可读性和可维护性。 3、构建能在多个软件系统中复用的组件。

3-2、内容

SRP:单一职责原则。软件系统能否达到的“最好的架构”依赖于这个系统内部的模块是如何组织的。所以为了达到“最好的架构”,我们需要尽量保证软件的每个模块都有且只有一个需要被改变的理由。换句话说,就是每个模块只负责干一件事。

OCP:开闭原则。软件需要对扩展开放,但对修改封闭。如果我们希望一个软件系统能够更灵活地应对变更,那么在设计他的架构的时候,就必须要允许能够通过新增代码来修改系统行为,并尽可能的避免修改原来的代码。

LSP:里氏替换原则。所有引用基类的地方必须能透明地使用其子类的对象,换个角度来看,这项原则的意思是如果想用可替换的组件来构建软件系统,那么这些组件就必须遵守同一个约定,以便让这些组件可以相互替换。

ISP:接口隔离原则。这项设计原则主要告诫软件设计师应该在设计中避免不必要的依赖,要尽量避免使用无用的方法和功能。

DIP:依赖反转原则。该设计原则指出高层策略性的代码不应该依赖实现底层细节的代码,恰恰相反,那些实现底层细节的代码应该依赖高层策略性的代码。

四、组件构建原则

4-1、什么是组件

组件是软件的部署单元,是整个软件系统在部署过程中可以独立完成部署的最小实体。多个组件可以组装成一个独立的可执行文件。

比如 Java 中的 jar 文件,.Net 中的 DLL 文件。

程序规模的墨菲定律: 程序规模会一直不断增长下去,直到把有限的编译和链接时间填满为止。

4-2、组件聚合的原则

REP:复用、发布等同原则 组件复用的最小粒度等同于其发布的最小粒度。

这是一个非常好理解的原则,毕竟如果你想要复用某个组件的话,就必须要用该组件发布的某一个版本。 毕竟,只有引入了版本,才能让使用方知道组件发布的时间,以及每次发布带来的变更。之后,使用方才能够根据发布所更新的内容来决定继续使用旧版本还是做升级。

CCP:共同闭包原则 我们应该将那些会同时修改,并且为相同目的而修改的类放到同一个组件中。

这其实是单一职责原则在组件层面上的描述,对于大部分应用来说,可维护性的重要性远远高于可复用性。如果我们必须要对一些代码进行变更,那么它们最好在同一个组件里。这样在测试和部署的时候都只需要对该组件进行操作就可以了。

CRP:共同复用原则 不要强迫一个组件的用户依赖他们不需要用的东西。

CRP 的作用不仅是告诉我们应该将那些模块放到一起,更重要的是告诉我们应该将哪些模块分开。因为每当一个组件引用了另一个组件时,就会增加一条依赖关系,而当引用的组件发生变更时,引用它的组件也得做相应的变更,这样可能会消耗大量的精力来做不必要的组件部署。

4-3、组件耦合的原则

1、组件之间不应该出现环形依赖,合理的组件耦合结构应该是一个有向无环图。

image.png

2、依赖关系必须要指向更稳定的方向。换句话说,越是需要频繁修改的组件,越需要依赖那些相对稳定的组件。

image.png

而像下面这种依赖关系,就会导致 Flexible 组件的变更会非常困难。

image.png

也正是如此,组件的稳定性应该和他抽象的程度是保持一致的,也就是说,组件越稳定,其抽象程度应该就越高。

4-4、衡量组件的稳定性与抽象化的标准

image.png

对于组件的稳定性和抽象化我们可以用 IA 图来衡量,I 是稳定性,数值越高稳定性越低。A 是抽象化程度,0 代表没有抽象类,1 代表只有抽象类。

也就是说,最稳定的,包含了所有抽象类的组件应该在左上角,而最不稳定的,最具体的组件,应该在右下角。

而图中画的两个区域——“痛苦区”和“无用区”,则是设计组件时必须要避免踏入的区域。

在痛苦区的组件,会有很高的稳定性和很具体的逻辑实现,这就意味着该组件很难进行改动和扩展,这不是一个好的设计。

在无用区的组件,会有一个很低的稳定性和很抽象的逻辑实现,这就意味着这个组件基本没有其他组件去依赖它,但它的逻辑还很抽象,这种组件根本没办法使用。

五、软件架构师及软件架构

5-1、什么是软件架构师

软件架构师首先必须是程序员,而且得是能力技术强的一线程序员。他们会在自身承接编程任务的同时逐步引导团队向最佳的系统设计方向前进。如果不在一线亲自编码,就体会不到设计的好与坏,就会迷失正确的设计方向。

5-2、软件架构的目的

设计软件架构的目的就是为了在工作中更好地对这些组件进行研发、部署、运行以及维护。

而在设计过程中,我们需要在保证系统能够正常工作的基础上,尽可能长时间地保留尽可能多的“可选项”,这样才能够更灵活地应对未来的功能变更。

比如说,在早期开发阶段,我们其实不需要过于关心我们要使用哪一种数据库,也不需要过早的引入诸如 DI、ORM 之类的框架。这样做的好处是我们在设计架构时如果能有意地让自己摆脱实现细节的纠缠,我们就能够更灵活地进行决策。

5-3、业务逻辑

业务逻辑是一个软件系统存在的意义,是最核心的功能,是给我们创造收益的那部分代码。

这些业务逻辑应该保持纯净,不要掺杂用户界面或者所使用的数据库相关的东⻄。在理想情况下,这部分代表业务逻辑的代码应该是整个系统的核心,其他低层概念的实现应该以插件形式接入系统中。业务逻辑应该是系统中最独立、复用性最高的代码。

5-4、划分边界

设计软件架构本身就是一种划分边界的行为,我们需要把一个软件切分为各个模块。而如何合理的划分每个模块的边界,是一个架构师必须要考虑的事情。

划分边界线的原则:边界线应该划在那些不相关的事情中间,比如 GUI 与业务逻辑无关,这两者中间应该要划边界线。

而当我们把系统切割成一个又一个的模块之后,就会发现有一部分模块是系统的核心业务逻辑,另一部分是与核心业务逻辑无关但也提供了必要的功能。而后者我们可以将它们视为一个又一个的插件,然后通过对源代码的修改让这些插件依赖于核心业务组件。这样,我们就能使用插拔插件的方式,来切换很多不同类型的模块,提高系统面对功能变更时的灵活性。

而我们按照这种方式设计出来的系统,通常都会有如下特点:

1、独立于框架:这些系统的架构并不依赖某个功能丰富的框架之中的某个函数。框架可以被当成工具来使用,但不需要让系统来适应框架。 2、可被测试:这些系统的业务逻辑可以脱离诸如 UI、数据库或其他的外部元素来进行测试。 3、独立于 UI:这些系统的 UI 变更起来很容易,不需要修改其他的系统部分。例如,我们可以在不修改业务逻辑的前提下将一个系统的 UI 由 Web 界面替换成命令行界面。 4、独立于数据库:我们可以轻易地替换系统使用的数据库。因为业务逻辑与数据库之间已经完成了解耦。 5、独立于任何外部系统:这些系统的业务逻辑并不需要知道任何其他外部接口的存在。

5-5、举个例子

假设我们要做一款基于文本的冒险游戏,这个游戏的操作是通过玩家输入“上下左右”这样的简单文字命令来完成的。玩家在输入命令之后,计算机就会返回玩家⻆色触发的事件。

现在,假设我们决定保留这种基于文字的交互方式,但是需要将 UI 与游戏业务逻辑之间的耦合解开,以便我们的游戏版本可以在不同地区使用不同的语言。我们该怎么做呢——我们需要让游戏的业务逻辑与 UI 之间用一种与自然语言无关的 API 来进行通信,而由 UI 负责将 API 传递进来的信息转换成合适的自然语言,就像下图这样:

image.png

同时,假设玩家在游戏中的状态会被保存到某种持久化存储模块中(比如闪存,又或者是云端存储),但我们不希望游戏引擎了解这些细节,所以,我们仍然需要创建一个 API 来负责游戏的业务逻辑组件与数据存储组件之间的通信。

image.png

另外,语言并不是 UI 变更的唯一方向,我们也可能会改变文字的输入输出方式(比如采用命令行,或者信息窗口),这样我们就又要构建一套 API,这样整个系统就更加复杂了。

image.png

然后我们将它简化一下,去掉具体实现,就变成下面这样:

image.png

可以看到所有模块和箭头共同组成了一个有向无环图,且箭头最终都指向了 Game Rules 这个核心模块,这种设计就是很合理的设计。但实际上,我们很难遇到这种所有数据流都汇聚到同一个组件上的情况。就拿这个 Game Rules 来说,我需要处理玩家的移动逻辑,同时也要处理玩家的血量、攻击力等逻辑。那么这个系统的架构就会变成这样:

image.png

再比如,如果我们需要把这个游戏变成一个多人在线的游戏,然后我们规定玩家的逻辑在本地处理,移动逻辑在服务端处理,那系统就变得更复杂了:

image.png

所以你看,一个很简单的文字冒险游戏,也能拓展成一个具有相当多模块和边界的复杂程序。

六、总结一下

作为架构师,我们必须要小心审视究竟在什么地方才需要设计架构边界。另外,我们还必须弄清楚完全实现这些边界将会带来多大的成本。同时,我们也必须要了解如果事先忽略了这些边界,后续再添加会有多么困难。

所以作为架构师,我们应该怎么办?这个问题恐怕没有一个通用的答案。

一方面,就像一些很聪明的人多年来一直告诉我们的那样,不应该将未来的需求抽象化,臆想中的需求事实上住往是不存在的,过度的工程设计往往比工程设计不足还要糟糕。

但另一方面,如果我们发现自己在某个位置确实需要设置架构边界,却又没有事先准备的时候,再添加边界所需要的成本和⻛险往往是很高的。

现实就是这样。作为软件架构师,我们必须有一点未卜先知的能力。有时候要依靠猜测,有时需要依赖过往的经验,当然最重要的是要用点脑子。软件架构师必须仔细权衡成本,决定哪里需要设计架构边界,以及这些地方需要的是完整的边界,还是不完全的边界,还是可以忽略的边界。

当出现问题时,我们还需要权衡一下实现这个边界的成本,并拿它与不实现这个边界的成本对比。我们的目标是找到设置边界的优势超过其成本的拐点,那就是实现该边界的最佳时机。