实用的软件架构方法

阅读数:1827 2019 年 11 月 18 日 16:10

实用的软件架构方法

导读

软件架构就是软件的基本结构,它是有关软件整体结构与组件的抽象描述,用于指导大型软件系统各个方面的设计。软件架构是一个系统的草图。软件架构描述的对象是直接构成系统的抽象组件。各个组件之间的连接则明确和相对细致地描述组件之间的通讯。在实现阶段,这些抽象组件被细化为实际的组件,比如具体某个类或者对象。在面向对象领域中,组件之间的连接通常用接口来实现。由此可见,软件架构的意义非常重大。那么,实用的软件方法都有哪些呢?

本文最初发表于 Medium 博客,经原作者 Eugene Ghanizadeh 授权,由 InfoQ 中文站翻译并分享。

前言

对代码库而言,架构通常是最重要的方面之一。架构对代码库质量、可维护性和可靠性都有着重要的影响。这也是软件工程中最有争议的一个话题,往往会激起项目贡献者之间的激烈争论,这些争论似乎没有任何潜在的逻辑解决方案,比如“对我们当前项目来说,什么才是好的架构?”这样的问题,很多时候似乎并没有一个明确的答案。

如果你去询问经验丰富的软件工程师“什么是好的软件架构?”这种一般性的问题,也许你会听到这些话:

好的软件架构是简单而优雅的。

(这一普遍的回答,太过含糊,过于主观,并不能作为关于该问题的客观讨论和决策的适当依据。)

好的软件架构,增加了可维护性。

(这个答案稍好一些,但仍然无助于回答这个问题:“如何衡量可维护性,除了一两年内听到的从事架构设计的程序员说,这个架构设计要么容易维护,要么难以维护?”。)

好的软件架构具有高内聚性和松散耦合。

(这是一个更好的答案,因为你甚至可以精确地度量耦合,但耦合仍然是一个不可避免的现象,这并不能为你提供任何线索,比如多少耦合对你的情况来说是可接受的,或者多少内聚才算内聚过多。)

好的软件架构正确地结合了已建立的设计模式 / 很好地利用这个或那个范例 / 等等。

(是的,已经建立的实践、范例和模式确实有助于提出更好的设计,但我如何才能知道哪种范例最适合项目呢?或者我应该在多大程度上坚持一个特定的模式呢?)

所有这些答案要么过于主观,要么仅仅只是提出了一些更客观的工具,如范例、模式和度量标准,而没有任何客观指标来说明应该如何使用这些工具。在“什么是好的架构”这个核心问题上存在着如此多的主观性,因此,关于如何为项目设计好的架构的讨论很容易导致主观的矛盾,而这些矛盾可能永远不会得到客观的解决,也就不足为奇了。

然而,如果我们后退一步,看看软件架构实际上是什么,它扮演着什么角色,使它成为软件开发中不可分割的一部分,也许我们可以指定更具体的、可量化的度量标准,以此作为我们评估各种架构选择和设计的基础。

什么是软件架构?

一般来说,特定代码库或项目的架构是隐式和显式的规则和约定,指导如何设计每个组件,以及如何与其他组件通信。这些规则可以影响到项目的任何方面,从它所基于的范例或框架,或者它被分解到的抽象层,到命名约定、文件夹和模块的结构,等等。它会影响哪些代码可以知道哪些其他代码元素的存在,或者这些元素应该在何时以何种方式相互通讯。

目的是什么?

为了理解软件结构的目的,我们可以想象,如果项目没有软件架构的话会发生什么情况。假设一个项目没有任何一致同意的规则或约定的话,会怎么样?这些规则或约定用于建模和创建各种组件,或者指定它们之间的通讯方式。

如果有许多人要并行处理这个项目,但由于缺乏一致同意的约定和规则,每个人就会使用自己的约定和思维模式来编写自己的元素。如此一来,会导致不同的风格、模型、模式和 API,这不仅使贡献者更难处理另一个贡献者的代码,而且也使不同的元素和组件更难相互通讯,大大增加了开发时间,不管是在初始阶段还是后来的变更和迭代开发。

所以,基本上就是一致性?

并非如此,这里还有好的架构和差的架构之间的区别。让我们想象一个具有特定架构的特定代码库。随着时间的推移,项目将会面临一些可能无法预料的变更和迭代,并且,面对任何这样的变化,会发生以下情况之一(或者多种组合):

  1. 该架构完全符合预期的更改,甚至没有指导性的更改。一切都很好,没什么可看的。
  2. 该架构没有说明任何预期更改的内容。有时候,开发人员可能会与同事讨论、决定和交流架构中必要的补充,而在其他时候,(例如,可能由于某个截止日期),他们可能只是根据与已建立的架构不一致的模型和指导方针来实现更改,从而导致代码库一致性的下降。
  3. 开发人员认为架构与预期的更改之间存在冲突,例如,因为它增加了大量的开销,或者由于变更本身非常简单而直接,从而使架构变得非常混乱。他们可能会选择尊重架构,并以更高的成本来实现更改,或者可能会选择打破原有模式,而这反过来又会引起代码库一致性的急剧下降。

在其中两个情况中,很有可能在更改之后,代码库的一致性会降低,并且随着时间的推移,这种不一致性还会增加,以至于看起来像是项目一开始就没有采用架构设计的样子。从实用主义的角度来看,我们现在可以将主要符合第一种情况的架构称为好的架构,而将导致第二种和第三种情况的架构,称为差的架构。

衡量好的架构

因此,根据我们前面讨论的内容,一个好的架构基本上应该:

  1. 尽可能多地考虑未来的变化。
  2. 让这些更改对开发人员来说更容易,而不是更困难。

当然,第一个并不是一个真正可以衡量的指标,但它确实为我们提供了一个切实可行的方向。试着设想未来可能的变化,例如,设想项目的未来阶段,堆栈的某些部分可能会因为外部需求而被替换掉,等等,并思考我们的架构决策和设计将如何面对这些变化。

但是,第二个则带来了更多可量化的指标。从表面上看,“容易”和“困难”似乎都是主观的术语,没有合适的方法来衡量它们。然而,任何代码库的改变最终都是人(程序员)和计算机(键盘)交互的结果,幸运的是,我们已经有一个专门用于测量这种交互难易程度的计算机科学领域,这个领域称为人机交互(Human-Computer Interaction,HCI),这个名称真是恰如其分。

对于那些不熟悉该领域的人来说,人机交互是一个致力于尽可能量化的方式分析人机交互各个方面的领域。在这些方面中,最重要的是难度(更确切地说,是任何给定交互的难度指数)。对于许多基本的交互,我们能够计算这个指数,事实上我们已经这样做了,这就是为什么窗口会有“最小化”、“最大化”、“关闭”的按钮,如果你正在电脑上阅读本文的话,你会在屏幕的角落看到这些按钮(基本上,角落会导致与难度指数成反比关系的参数大幅增加)。非常方便的是,难度指数也与执行任务所需的时间(一系列人机交互)成线性关系,在我们的用例中,这可以转化为任务本身的字面成本(字面意义上的金钱成本)

这一切意味着什么?简单地说:

好的架构降低了未来更改的成本。

如何评估特定架构决策的成本?首先,想象一下你可能要进行的一些更改,例如,你可能希望在上面提到的项目的下一阶段添加特性,或者甚至对一些随机选取的方法 / 函数 / 类的签名进行随机更改。然后评估实现这一更改可能包括的一系列交互,从真正基本的交互(例如需要键入多少字符),到开发人员可能需要查看代码的其他部分,再到某些特定概念或函数的可理解性。你可以更精确地估计底层级别的交互难度(使用一些基本的人机交互规则),或者根据它们的难度假定任意的常数,然后大致估计更高级别的交互难度(或许要去掉那些过于抽象的交互),那么你就可以很好地估算出这些潜在变化的难度(以及成本)了。

实践起来是什么样子的?

让我们举一个简化的例子,将这种方法付诸实践。为简单起见,让我们假设开发人员不能访问任何特定的 IDE(特别是使用跨文件搜索工具或其他重构工具)。假设这个项目的结构如下:

复制代码
src/
| -- module-a/
| -- | -- index.ts
| -- | -- | -- function aOne()
| -- | -- | -- function aTwo()
| -- | -- a-one.ts
| -- | -- | -- function aOneFuncOne()
| -- | -- | -- function aOneFuncTwo()
| -- | -- a-two.ts
| -- | -- | -- function aTwoFuncOne()
| -- | -- | -- function aTwoFuncTwo()
| -- module-b/
| -- | -- index.ts
| -- | -- | -- function bOne()
| -- | -- | -- function bTwo()
| -- | -- b-one.ts
| -- | -- | -- function bOneFuncOne()
| -- | -- | -- function bOneFuncTwo()
| -- | -- b-two.ts
| -- | -- | -- function bTwoFuncOne()
| -- | -- | -- function bTwoFuncTwo()

现在,假设更改 src/module-a/a-one/aOneFuncOne() 的名称或签名。如果没有任何既定规则的话,我就需要检查其他 11 个函数的主体,看看它们是否使用了 aOneFuncOne() ,以及它们是否需要更改。请注意,与 src/module-b/b-one 的函数相比,src/module-b/b-one 函数的检查难度并不相同,因为 a-one.ts 函数已经与 aOneFuncOne() 在同一个文件中。列出交互作用,对于前者我惟有如此做:

  • 读取 aOneFuncTwo() 的主体,看看是否需要更改,如果需要的话,就执行更改。

对于后者,交互列表如下所示:

  • 列出 src/ 的所有子模块。
  • 列出 src/module-b/ 的所有文件。
  • 打开 src/module-b/b-one.ts
  • 读取 bOneFuncOne()bOneFuncTwo() 的主体,看看是否需要更改,如果需要,就执行更改。

这些交互都需要一些时间(以及认知上的努力),因为它们每个交互都至少涉及一次点击(或许还需要一些滚动和一些键入)。类似地,检查 src/module-a 中的其他文件,要比检查 src/module-b 中的文件更容易,因为它需要更少的原始交互,尽管这比只检查 src/module-a/a-one.ts 中的其他函数更困难,原因是需要更多的交互。

现在,如果架构强制执行 src/module-a 以外的模块中的文件,只能使用 src/module-a/index.ts 中定义的函数的规则,这样更改的难度就大为降低了。当然,这样做的缺点是,如果有一天,我需要在 module-b 中的某个地方使用 aOneFuncOne(),那么我还需要更改 module-a/index.ts 以符合该规则,这一开销的概率和交互成本我们可以再次非常精确地估计。

我甚至可以更进一步,要求 src/module-a/a-one.ts 显式地提到它导出到其他模块的函数(因为 TypeScript 需要你这样做,而不管你的架构设计如何),然后我就可以检查 aOneFuncOne() 是否为导出的函数,如果不是,那么进行更改的成本也会大大降低。

类似地,如果其他文件必须通过显式导入语句显式地导入 aOneFuncOne()(即 import { aOneFuncOne } from 'src/module-a/a-one ),那么检查需要更改哪些函数就会容易得多。现在,如果我在开始时有另一个(未提到的文件)带有导入命令,其中有 10 个其他函数,但实际上只有一个使用了 aOneFuncOne(),我最好将这个异常值放到它自己的文件中,因为这样可以减少检测和再次进行更改所需的交互次数。

请注意,将我所有 12 个函数全部放在 src/module-a/a-one.ts 中也不是一个好主意,尽管此举可以降低检查所有其他函数的难度,但对于检查几个其他函数主体来说,它仍然是一个低于标准的解决方案。

考虑到所有这些因素,我们现在就可以在决定两个架构设计之间做出更为客观的判断,取决于我的函数在每个设计中的相互依赖程度,以及这些相互依赖的函数在结构上有多近或多远。请注意,这只是另一种说法,即我们应该选择更有内聚性和松散耦合的架构,但是这一次,我们有更为具体的度量标准来评估我们所讨论的内聚性或松散耦合的程度。

可读性 / 直觉性如何?

在我们的示例中,忽略了计算某些交互难度的一个关键因素:实际上,阅读和导航某些代码的难度并不是恒定不变的。相反,它受到代码可读性(局部范围)和架构底层模型的直觉性(在较大的范围内)的极大影响。一堆随机的字符,更难以阅读,从中找出内容也很困难,而且,这种缺乏直觉性的结构,会将导航代码库的交互变成一种盲目且极易出错的蛮力行为,而不是通过对底层模型的直观理解来实现轻松快速的导航。

幸运的是,出于可读性的考虑,有很多关于如何衡量和改进它们的分析研究,这些都很不错。还有大量更通用的人机交互研究,这些研究在这方面可能很有帮助(有些甚至对找出最有效率的编辑器颜色主题很有用)。

直觉看起来似乎更加难以捉摸,因为它肯定更主观。然而,我们只需考虑“架构有意义”中的直觉性,这样就可以降低更改的成本,这反过来意味着“架构对将要进行更改的人来说是有意义的”。除此之外,我们还知道,如果(几乎但不总是)某些事与他们已经知道的和 / 或经历过的有关,并且更多地考虑重复的经历或更近的经历,那么对于某些人来说(或者至少需要他们花费更少的时间和精力去理解它)更有意义。结合这两个事实,我们对直观的架构设计就有了更客观的看法:

一个架构,如果更接近于你团队已熟悉和有经验的架构类型,那么这个架构对他们来说更直观。

请注意,这个事实透露出来的含义是,对于特定项目来说,最好的架构设计和选择,不仅由项目本身决定,还由将要进行该项目的团队决定。如果你选择的架构风格具有明显的学习曲线(著名的 MVC 就是其中一个著名的例子),那么对于已经熟悉该风格的团队来说,这可能是一个非常好的选择;但对于没有这种经验的团队来说,就不太合适了。此外,如果未来的团队组成,从有经验的人员变成几乎没有经验的人员,那么就必须相应地考虑重构整个架构的成本,与团队中每个新成员的学习成本相比较。

结论

这个例子太简单了,现实中能行么?

尽管我们的示例在本质上是相对抽象和简单的,但它涉及了软件架构的最基本方面之一:耦合和内聚。更详细和复杂的示例,大多数都可以归结为同样的考虑和优化(以及问题域模型的直觉性)。例如,MVC 架构将代码库分为三层,基于这样一个假设,即同一层(例如视图)比其他层(如模型层)中的代码更依赖于同一层中的其他代码(其他视图逻辑)。例如,当视图逻辑高度依赖于某个渲染框架 / 库,而不是依赖于控制器层逻辑时,这种分离就是正确的,并且这种分离大大降低了更改的成本,例如换出渲染框架 / 库(这就是为什么在当时是一种比较流行的模式,这种变化更有可能发生的原因)。

这是否意味着我不再需要依赖已建立的范例和模式?

不是这样的。这是一种客观和定量地分析各种架构设计 / 决策 / 甚至模式和范例的方法,而不是提出它们。如果软件的架构是问题所在(“我应该使用哪些规则来设计代码库,以减少未来变更的成本?”),然后,各种架构范例和模式为这个问题提供了解决方案 / 解决方案模板,前面提到的难度度量方法让你可以分析、比较这些解决方案。

此外,已建立的概念、模式和范例都是基于相同的考虑(直接或间接)设计的解决方案。如前所述,MVC(或任何其他基于层的架构)倾向于包含对特定层的最可能的更改,以降低其潜在的成本,正如我们上面所看到的,内聚性和耦合基本上是从变更难度优化产生的。但是,这种方法允许你正确地验证那些模式和范例最适用于你的案例,那些模式和范例最终可能会造成更多的开销。

我意识到,说到底,软件架构仍然是软件工程师们热议的话题。围绕关于什么是理想的软件架构这一问题,狂热分子们有着自己的信念,而不是认为程序员不分享这些观点,就不配程序员的头街。甚至在更灵活的工程师中,也有这样的观点:对于每一个问题(因此就是项目),都有一个独一无二、精美优雅、艺术品级的架构设计,它是未来的证明,直到世界末日也是如此。但我担心,在大多数情况下,这些激进的想法是源于这样一个事实,即人们没有接触到为什么需要好的项目架构的实际原因,更不用说这意味着什么了。但是,如果我们抛开这些不合理的偏见,以最超然和务实的态度来看待软件架构,一切都会变得非常清楚:

对那些进行更改的人们来说,好的架构可以降低未来变更的成本。

作者介绍:

Eugene Ghanizadeh,拥有多重角色:程序员、设计师、产品经理,甚至在某个时候还是人力资源专家。也曾经当过老师。 https://github.com/loreanvictor

原文链接:
A Pragmatic Approach to Software Architecture

评论

发布