软件架构实践
- 第一部分 简介
- 第1章 什么是软件架构?
- 第2章 为什么软件架构很重要?
- 第二部分 质量属性
- 第3章 理解质量属性
- 第4章 可用性
- 第5章 可部署性
- 第6章 能效性
- 第7章 可集成性
- 第8章 可修改性
- 第9章 性能
- 第10章 安全性
- 第11章 信息安全性
- 第12章 可测试性
- 第13章 易用性
- 第14章 其他质量属性
- 第三部分 架构解决方案
- 第15章 软件接口
- 第16章 虚拟化
- 第17章 云计算和分布式计算
- 第18章 移动系统
- 第四部分 可扩展的架构实践
- 第19章 架构重要需求
- 第20章 设计架构
- 第21章 评估架构
- 第22章 记录架构
- 第23章 管理架构债务
- 第五部分 架构和组织
- 第24章 架构师在项目中的角色
- 第25章 架构能力
- 第六部分 总结
- 第26章 未来一瞥:量子计算
第一部分 简介
第一部分 简介
第1章 什么是软件架构?
我们应成为未来的架构师,而非受害者。
——R. 巴克敏斯特・富勒(R. Buckminster Fuller)
我们(作者)撰写和你们(读者)阅读一本关于软件架构的书,这本书提炼了许多人的经验,这预先假定了以下两点:
- 拥有合理的软件架构对软件系统的成功开发很重要。
- 有足够的软件架构知识来写满一本书。
曾经有一段时间,这两个假设都需要证明其合理性。本书的早期版本试图说服读者这两个假设是正确的,并且一旦你被说服,就会为你提供基础知识,以便你自己能够应用架构实践。如今,对于这两个目标似乎没有什么争议了,所以这本书更多的是提供知识,而不是说服读者。
软件架构的基本原则是,每个软件系统的构建都是为了满足一个组织的业务目标,并且系统的架构是这些(通常是抽象的)业务目标和最终(具体的)成果系统之间的桥梁。虽然从抽象目标到具体系统的路径可能很复杂,但好消息是,可以使用已知的技术来设计、分析和记录软件架构,这些技术将有助于实现这些业务目标。这种复杂性是可以被驾驭的,是可以处理的。
那么,这些就是本书的主题:架构的设计、分析和文档记录。我们还将研究影响这些活动的因素,主要是以导致质量属性要求的业务目标的形式。
在本章中,我们将严格从软件 “工程” 的角度来关注架构。也就是说,我们将探讨软件架构给开发项目带来的价值。后面的章节将从业务和组织的角度进行探讨。
1.1 软件架构是什么以及不是什么
软件架构有很多定义,通过网络搜索很容易找到,但我们喜欢的定义是:
一个系统的软件架构是一组用于对该系统进行推理所需的结构。这些结构包括:软件元素、它们之间的关系以及两者的属性。
这个定义与其他谈论系统 “早期”、“主要” 或 “重要” 决策的定义形成对比。虽然许多架构决策确实是早期做出的,但并非所有决策都是如此 —— 尤其是在敏捷和螺旋式开发项目中。同样,许多早期做出的决策也并非我们所认为的架构决策。而且,很难仅通过查看一个决策就判断它是否 “主要”。有时候只有时间才能证明。并且由于确定架构是架构师最重要的职责之一,我们需要知道架构由哪些决策组成。
相比之下,在软件中结构很容易识别,并且它们为系统设计和分析提供了强大的工具。
所以,定义就是这样:架构是关于支持推理的结构。
让我们看看这个定义的一些含义。
架构是一组软件结构
这是我们定义的第一个也是最明显的含义。结构只是由一种关系维系在一起的一组元素。软件系统由许多结构组成,没有单个结构可以声称是唯一的架构。结构可以分为不同的类别,而这些类别本身为思考架构提供了有用的方式。架构结构可以组织成三个有用的类别,它们将在架构的设计、文档记录和分析中发挥重要作用:
- 组件和连接器结构
- 模块结构
- 分配结构
我们将在下一节中深入地探讨这些类型的结构。
虽然软件包含无穷无尽的结构,但并非所有结构都是架构性的。例如,包含字母 “z” 的源代码行集合,按照长度从最短到最长排序,是一种软件结构。但它不是一个很有趣的结构,也不是架构性的。如果一个结构支持对系统及其属性进行推理,那么它就是架构性的。这种推理应该是关于对系统的某些利益相关者来说重要的系统属性。这些属性包括系统实现的功能、系统在面对故障或被攻击时保持有效运行的能力、对系统进行特定更改的难易程度、系统对用户请求的响应能力等等。在本书中,我们将花费大量时间探讨架构与诸如此类的质量属性之间的关系。
因此,架构结构的集合既不是固定的也不是有限的。什么是架构性的取决于在你的系统上下文中对什么进行推理是有用的。
架构是一种抽象
由于架构由结构组成,而结构由元素 [^1] 和关系组成,所以架构包括软件元素以及这些元素之间的相互关系。这意味着架构特意且有目的地省略了关于元素的某些对系统推理无用的信息。因此,架构首先是对系统的一种抽象,它选择某些细节并抑制其他细节。在所有现代系统中,元素通过将元素的细节划分为公共部分和私有部分的接口相互作用。架构关注这个划分的公共方面;元素的私有细节 —— 仅与内部实现有关的细节 —— 不是架构性的。这种抽象对于驾驭架构的复杂性至关重要:我们根本无法也不想一直处理所有的复杂性。我们希望并且需要对系统架构的理解比理解该系统的每个细节容易几个数量级。你无法将即使是中等规模系统的每个细节都记在脑海中;架构的意义就在于让你不必这样做。
架构与设计
架构是设计,但并非所有设计都是架构。也就是说,许多设计决策不受架构的约束 —— 毕竟,它是一种抽象 —— 并且取决于下游设计师甚至实现者的判断力和良好决策。
每个软件系统都有软件架构
每个系统都有一个架构,因为每个系统都有元素和关系。然而,这并不意味着架构为任何人所知。也许设计系统的所有人都早已离开,文档已经消失(或者从未生成),源代码已经丢失(或者从未交付),而我们手头只有正在执行的二进制代码。这揭示了系统的架构与该架构的表示之间的区别。鉴于架构可以独立于其描述或规范而存在,这就提高了架构文档记录的重要性,这将在 第 22 章 中介绍。
并非所有架构都是好架构
我们的定义对于一个系统的架构是好是坏并不关心。一个架构可能支持也可能阻碍实现系统的重要需求。假设我们不接受试错作为为系统选择架构的最佳方法(即随机选择一个架构,基于它构建系统,然后不断修改并寄希望于最好的结果)这就提高了架构设计的重要性,这将在 第 20 章 中讨论,以及架构评估的重要性,这将在 第 21 章 中处理。
架构包含行为
每个元素的行为在有助于你对系统进行推理的范围内是架构的一部分。元素的行为体现了它们如何相互作用以及与环境作用。这显然是我们对架构定义的一部分,并且将对系统所表现出的属性产生影响,例如其运行时性能。
行为的某些方面不在架构师的关注层面。然而,在元素的行为影响整个系统的可接受性的程度上,这种行为必须被视为系统架构设计的一部分,并应如此进行文档记录。
系统架构与企业架构
与软件架构相关的两个学科是系统架构和企业架构。这两个学科所关注的范围都比软件更广泛,并通过建立软件系统及其架构师必须遵循的约束条件来影响软件架构。
系统架构
系统的架构是对系统的一种表示,其中包括将功能映射到硬件和软件组件上、将软件架构映射到硬件架构上,以及关注人与这些组件的交互。也就是说,系统架构关注的是硬件、软件和人的整体。
例如,系统架构会影响分配给不同处理器的功能以及连接这些处理器的网络类型。软件架构将决定此功能是如何构建的以及驻留在各种处理器上的软件程序如何交互。
对软件架构映射到硬件和网络组件的描述,可以让人们对性能和可靠性等质量进行推理。对系统架构的描述将允许对功耗、重量和物理尺寸等其他质量进行推理。
在设计特定系统时,系统架构师和软件架构师之间经常就功能的分布进行协商,因此也会就对软件架构施加的约束进行协商。
企业架构
企业架构是对一个组织的流程、信息流、人员和组织子单元的结构和行为的描述。企业架构不一定包括计算机化的信息系统(显然,在计算机出现之前,组织就有符合上述定义的架构)但如今,除了最小型的企业外,没有信息系统支持的企业架构是不可想象的。因此,现代企业架构关注软件系统如何支持企业的业务流程和目标。通常,这一系列关注点中包括一个决定企业应该支持哪些具有何种功能的系统的过程。
例如,企业架构将指定各种系统用于交互的数据模型。它还将指定企业系统与外部系统交互的规则。
软件只是企业架构的一个关注点。人类如何使用软件来执行业务流程以及确定计算环境的标准是企业架构解决的另外两个常见关注点。
有时,支持系统之间以及与外部世界通信的软件基础设施被视为企业架构的一部分;在其他时候,这种基础设施被视为企业中的一个系统。(无论哪种情况,该基础设施的架构都是一个软件架构!)这两种观点将导致与基础设施相关的个人有不同的管理结构和影响范围。
这些学科在本书的范围内吗?是!(好吧,也不是。)
系统和企业为软件架构提供环境和约束。软件架构必须存在于系统和企业中,并且越来越成为实现组织业务目标的焦点。企业架构和系统架构与软件架构有很多共同之处。所有这些都可以进行设计、评估和记录;都要满足需求;都旨在满足利益相关者;都由结构组成,而结构又由元素和关系组成;在各自架构师的处理范围内都有一系列模式等等。因此,在这些架构与软件架构有共同之处的范围内,它们在本书的范围内。但是,像所有技术学科一样,每个学科都有自己的专业词汇和技术,我们不会涵盖这些内容。有大量其他资源可以涵盖这些内容。
1.2 架构结构与视图
由于架构结构是我们对软件架构的定义和处理的核心,本节将更深入地探讨这些概念。在 第 22 章 中,我们将更深入地讨论这些概念,在那里我们将讨论架构文档。
架构结构在自然界中有对应物。例如,神经学家、骨科医生、血液学家和皮肤科医生对人体的各种结构都有不同的看法,如 图 1.1 所示。眼科医生、心脏病专家和足病医生专注于特定的子系统。运动学家和精神病学家关注整个结构的行为的不同方面。尽管这些视图的描绘方式不同,并且具有非常不同的属性,但它们本质上都是相互关联和相互连接的:它们共同描述了人体的架构。

架构结构在人类的努力中也有对应物。例如,电工、水管工、供暖和空调专家、屋顶工和框架工各自关注建筑物中的不同结构。你可以很容易地看到这些结构中每一个的重点品质。
软件也是如此。
三种结构类型
根据它们所展示的元素的广泛性质以及它们所支持的推理类型,架构结构可以分为三大类:
-
“组件和连接器(C&C)结构” 关注元素在运行时相互交互以执行系统功能的方式。它们描述了系统如何被构建为一组具有运行时行为(组件)和交互(连接器)的元素。组件是计算的主要单元,可以是服务、对等体、客户端、服务器、过滤器或许多其他类型的运行时元素。连接器是组件之间的通信工具,例如调用返回、进程同步运算符、管道或其他。C&C 结构有助于回答以下问题:
- 主要的执行组件是什么,它们在运行时如何交互?
- 主要的共享数据存储是什么?
- 系统的哪些部分是重复的?
- 数据如何在系统中流动?
- 系统的哪些部分可以并行运行?
- 系统的结构在执行时是否可以改变,如果可以,如何改变?
由此推广,这些结构对于询问关于系统运行时属性(如性能、安全性、可用性等)的问题至关重要。
C&C 结构是我们最常见的结构,但另外两类结构也很重要,不应被忽视。
图 1.2 使用一种非正式的表示法展示了一个系统的 C&C 结构草图,图中的关键部分对其进行了解释。该系统包含一个由服务器和一个管理组件访问的共享存储库。一组客户端出纳员可以与账户服务器交互,并使用发布 - 订阅连接器在它们之间进行通信。

-
“模块结构”将系统划分为实现单元,在本书中我们将其称为“模块”。模块结构展示了一个系统如何被构建为一组必须被构建或获取的代码或数据单元。模块被分配特定的计算职责,并且是编程团队工作分配的基础。在任何模块结构中,元素都是某种类型的模块(可能是类、包、层,或者仅仅是功能划分,所有这些都是实现单元)。模块代表了一种考虑系统的静态方式。模块被分配功能职责区域;在这些结构中,对所得到的软件在运行时如何表现的强调较少。模块实现包括包、类和层。模块结构中模块之间的关系包括使用、泛化(或“是一个”)和“是……的一部分”。图 1.3和图 1.4分别使用统一建模语言(UML)符号展示了模块元素和关系的示例。


模块结构使我们能够回答以下问题:
- 分配给每个模块的主要功能职责是什么?
- 一个模块被允许使用哪些其他软件元素?
- 它实际上使用和依赖哪些其他软件?
- 哪些模块通过泛化或特化(即继承)关系与其他模块相关?
模块结构直接传达了这些信息,但它们也可以用于回答当分配给每个模块的职责发生变化时对系统的影响问题。因此,模块结构是推理系统可修改性的主要工具。
-
“分配结构” 建立了从软件结构到系统的非软件结构(如组织、开发、测试和执行环境)的映射。分配结构回答以下问题:
- 每个软件元素在哪个处理器上执行?
- 在开发、测试和系统构建期间,每个元素存储在哪个目录或文件中?
- 每个软件元素分配给哪个开发团队?
一些有用的模块结构
有用的模块结构包括:
-
“分解结构”。单元是通过 “是…… 的子模块” 关系相互关联的模块,展示了模块如何递归地分解为更小的模块,直到模块小到足以容易理解为止。在此结构中的模块代表了设计的一个常见起点,因为架构师列举了软件单元必须要做的事情,并将每个项目分配给一个模块以进行后续(更详细的)设计和最终实现。模块通常与产品(如接口规范、代码和测试计划)相关联。分解结构在很大程度上决定了系统的可修改性。也就是说,变化是否在少数(最好是小的)模块的权限范围内?这种结构通常用作开发项目组织的基础,包括文档结构以及项目的集成和测试计划。图 1.5 展示了一个分解结构的示例。

-
“使用结构”。在这个重要但经常被忽视的结构中,单元也是模块,可能还有类。这些单元通过“使用”关系相互关联,这是一种特殊形式的依赖关系。如果一个软件单元的正确性需要另一个单元的正确运行版本(而不是一个桩)存在,那么第一个单元就使用了第二个单元。使用结构用于设计可以扩展以添加功能的系统,或者可以从中提取有用的功能子集。能够轻松创建系统的子集允许进行增量开发。这种结构也是衡量社会债务(实际发生的而不仅仅是应该发生的团队之间的沟通量)的基础,因为它定义了哪些团队应该相互交流。图 1.6展示了一个使用结构,并突出显示了如果模块
admin.client存在,则在增量中必须存在的模块。
-
“层结构”。此结构中的模块称为层。一个层是一个抽象的“虚拟机”,它通过一个受管理的接口提供一组内聚的服务。层可以以受管理的方式使用其他层;在严格分层的系统中,一个层只允许使用一个其他层。这种结构赋予系统可移植性,即改变底层虚拟机的能力。图 1.7 展示了 UNIX System V 操作系统的层结构。

-
“类(或泛化)结构”。此结构中的模块称为类,它们通过“继承自”或“是……的实例”关系相互关联。这种视图支持对具有相似行为或能力的集合以及参数化差异进行推理。类结构允许人们对复用和功能的增量添加进行推理。如果遵循面向对象分析和设计过程的项目存在任何文档,通常就是这种结构。图 1.8展示了一个来自架构专家工具的泛化结构。

-
“数据模型”。数据模型从数据实体及其关系方面描述静态信息结构。例如,在银行系统中,实体通常包括账户、客户和贷款。账户有几个属性,如账号、类型(储蓄或支票)、状态和当前余额。一种关系可能规定一个客户可以有一个或多个账户,并且一个账户与一个或多个客户相关联。图 1.9 展示了一个数据模型的示例。

一些有用的组件和连接器(C&C)结构
C&C 结构展示了系统的运行时视图。在这些结构中,刚才描述的模块都已被编译为可执行形式。因此,所有 C&C 结构都与基于模块的结构正交,并处理正在运行的系统的动态方面。例如,一个代码单元(模块)可以被编译为一个在执行环境中被复制数千次的服务。或者 1000 个模块可以被编译并链接在一起以生成一个单一的运行时可执行文件(组件)。
在所有 C&C 结构中的关系是 “连接”,展示了组件和连接器是如何连接在一起的。(连接器本身可以是熟悉的构造,如 “调用”。)有用的 C&C 结构包括:
- “服务结构”。这里的单元是通过服务协调机制(如消息)进行交互操作的服务。服务结构是帮助设计由可能彼此独立开发的组件组成的系统的重要结构。
- “并发结构”。这种 C&C 结构允许架构师确定并行的机会以及可能发生资源争用的位置。单元是组件,连接器是它们的通信机制。组件被排列成 “逻辑线程”。逻辑线程是一系列计算,可以在设计过程的后期分配到单独的物理线程中。并发结构在设计过程的早期用于识别和管理与并发执行相关的问题。
一些有用的分配结构
分配结构定义了 C&C 或模块结构中的元素如何映射到非软件的事物上 —— 通常是硬件(可能是虚拟化的)、团队和文件系统。有用的分配结构包括:
-
“部署结构”。部署结构展示了软件如何分配到硬件处理和通信元素上。元素是软件元素(通常是 C&C 结构中的一个进程)、硬件实体(处理器)和通信路径。关系是 “分配到”,展示了软件元素驻留在哪个物理单元上,如果分配是动态的,还有 “迁移到”。这种结构可用于推理性能、数据完整性、安全性和可用性。它在分布式系统中特别受关注,并且是实现可部署性质量属性(见 第 5 章)所涉及的关键结构。图 1.10 以 UML 展示了一个简单的部署结构。

-
“实现结构”。这种结构展示了软件元素(通常是模块)如何映射到系统的开发、集成、测试或配置控制环境中的文件结构。这对于管理开发活动和构建过程至关重要。
-
“工作分配结构”。这种结构将实现和集成模块的责任分配给将执行这些任务的团队。将工作分配结构作为架构的一部分,明确了关于谁来做这项工作的决定具有架构以及管理方面的影响。架构师将了解每个团队所需的专业知识。例如,亚马逊决定为其每个微服务分配一个单独的团队,这是关于其工作分配结构的一种声明。在大型开发项目中,确定功能共性的单元并将其分配给一个团队,而不是让每个需要它们的人都去实现它们,这是很有用的。这种结构还将确定团队之间的主要沟通途径:定期网络会议、维基、电子邮件列表等等。
表 1.1 总结了这些结构。它列出了每种结构中元素和关系的含义,并说明了每种结构可能的用途。
| 软件结构 | 元素类型 | 关系 | 适用于 | 受影响的质量关注点 | |
|---|---|---|---|---|---|
| 模块结构 | 分解 | 模块 | 是……的子模块 | 资源分配以及项目结构规划;封装。 | 可修改性 |
| Uses | 模块 | 使用(即需要正确存在)。 | 设计子集和扩展。 | “可子集化”、可扩展性。 | |
| 分层 | 层 | 被允许使用……的服务;提供抽象。 | 增量开发;在“虚拟机”之上实现系统。 | 可移植性、可修改性 | |
| 类 | 类、对象 | 是……的一个实例;是……的一种泛化。 | 在面向对象系统中,提取共性;规划功能扩展。 | 可修改性、可扩展性 | |
| 数据模型 | 数据实体 | {一,多}-对-{一,多};泛化;特化。 | 设计全局数据结构以实现一致性和性能。 | 可修改性、性能 | |
| C&C结构 | 服务 | 服务,服务注册 | 连接(通过消息传递)。 | 调度分析;性能分析;健壮性分析。 | 互操作性、可用性、可修改性 |
| 并发 | 进程、线程 | 连接(通过通信和同步机制)。 | 确定资源竞争存在的位置以及并行的机会。 | 性能 | |
| 分配结构 | 部署 | 组件、硬件元素 | “分配到”;“迁移到”。 | 将软件元素映射到系统元素。 | 性能、安全性、能源、可用性、可部署性 |
| 实现 | 模块、文件结构 | 储存于 | 配置控制、集成、测试活动 | 开发效率 | |
| 工作分配 | 模块、组织单位 | 分配给 | 项目管理、专业知识和可用资源的最佳利用、共性管理。 | 开发效率 |
结构之间的关联
这些结构中的每一个都为系统提供了不同的视角和设计抓手,并且每一个结构本身都是有效且有用的。尽管这些结构给出了不同的系统视角,但它们并非相互独立。一个结构中的元素将与其他结构中的元素相关联,我们需要对这些关系进行推理。例如,分解结构中的一个模块在某个 C&C(组件和连接器)结构中可能表现为一个组件、一个组件的一部分或几个组件,反映了它在运行时的对应物。一般来说,结构之间的映射是多对多的。
图 1.11 展示了两个结构如何相互关联的一个简单示例。左边的图像展示了一个小型客户端 - 服务器系统的模块分解视图。在这个系统中,必须实现两个模块:客户端软件和服务器软件。右边的图像展示了同一系统的 C&C 视图。在运行时,有十个客户端正在运行并访问服务器。因此,这个小系统有两个模块和十一个组件(以及十个连接器)。

虽然分解结构中的元素与客户端 - 服务器结构之间的对应关系很明显,但这两种视图用于非常不同的目的。例如,右边的视图可用于性能分析、瓶颈预测和网络流量管理,而使用左边的视图来做这些事情则极其困难或不可能。(在 第 9 章 中,我们将学习 Map-Reduce 模式,在该模式中,简单、相同功能的副本分布在数百或数千个处理节点上 —— 对于整个系统是一个模块,但每个节点是一个组件。)
个别项目有时会认为一种结构是主导的,并在可能的情况下根据主导结构来塑造其他结构。通常,主导结构是模块分解结构,这是有充分理由的:它往往会衍生出项目结构,因为它反映了开发团队的结构。在其他项目中,主导结构可能是一个 C&C 结构,该结构展示了系统的功能和(或)关键质量属性在运行时是如何实现的。
越少越好
并非所有系统都需要考虑许多架构结构。系统越大,这些结构之间的差异往往就越显著;但对于小型系统,我们通常可以用较少的结构来应对。例如,通常只需要一个 C&C 结构就可以了,而不是使用多个。如果只有一个进程,那么进程结构就会缩减为一个节点,并且在设计中无需明确表示。如果不会进行分布(即如果系统在单个处理器上实现),那么部署结构就很简单,无需进一步考虑。一般来说,只有在这样做能带来积极的投资回报(通常是降低开发或维护成本)时,才应该设计和记录一个结构。
选择哪些结构?
我们简要介绍了许多有用的架构结构,当然还有更多可能的结构。架构师应该选择哪些结构进行处理?架构师应该选择哪些结构进行记录?肯定不是所有的结构。一个好的答案是,你应该考虑可用的各种结构如何为系统最重要的质量属性提供洞察力和影响力,然后选择在实现这些属性方面发挥最佳作用的结构。
架构模式
在某些情况下,架构元素以解决特定问题的方式组合在一起。随着时间的推移和在许多不同领域中,这些组合被发现是有用的,因此它们被记录和传播。这些架构元素的组合为解决系统面临的一些问题提供了打包的策略,被称为模式。本书的 第二部分 将详细讨论架构模式。
1.3 什么造就了 “良好” 的架构?
并不存在本质上绝对好或坏的架构。架构只是对于某些目的而言更合适或不太合适。一个三层分层的面向服务架构可能非常适合大型企业的基于网络的 B2B 系统,但对于航空电子应用来说可能完全错误。为实现高可修改性而精心设计的架构对于一次性原型来说没有意义(反之亦然!)。本书要传达的一个信息是,实际上架构是可以被 “评估” 的(关注架构的一大好处就是如此)但这种评估只有在特定明确目标的背景下才有意义。
然而,在设计大多数架构时应遵循一些经验法则。不应用这些准则中的任何一条并不一定意味着架构会有致命缺陷,但至少它应该作为一个需要调查的警告信号。这些规则可以积极地应用于全新开发,以帮助正确地构建系统。或者它们可以作为分析启发式方法应用,以了解现有系统中的潜在问题区域并指导其演进方向。
我们将观察结果分为两类:过程建议和产品(或结构)建议。我们的过程建议如下:
- 软件(或系统)架构应该是由一位架构师或一小群有明确技术领导的架构师的产物。这种方法对于赋予架构概念完整性和技术一致性非常重要。这个建议适用于敏捷和开源项目以及 “传统” 项目。架构师和开发团队之间应该有紧密的联系,以避免出现 “象牙塔” 式不切实际的设计。
- 架构师(或架构团队)应该持续地将架构建立在明确优先级的、具体规定的质量属性需求列表之上。这些将为始终存在的权衡提供依据。功能的重要性相对较低。
- 架构应该使用 “视图” 进行记录。(视图只是一个或多个架构结构的表示。)视图应解决最重要的利益相关者的关注点,以支持项目时间表。这可能意味着一开始文档最少,然后在以后逐步详细阐述。关注点通常与系统的构建、分析和维护以及新利益相关者的教育有关。
- 应该评估架构实现系统重要质量属性的能力。这应该在生命周期的早期进行,此时能获得最大的收益,并在适当的时候重复进行,以确保架构的变化(或其预期的环境变化)没有使设计过时。
- 架构应该适合增量实现,以避免必须一次性集成所有内容(这几乎从来都不起作用),并尽早发现问题。一种方法是通过创建一个 “骨架” 系统,在其中测试通信路径,但一开始功能最少。这个骨架系统可以用来逐步 “生长” 系统,并在必要时进行重构。
我们的结构经验法则如下:
- 架构应该具有定义良好的模块,其功能职责按照信息隐藏和关注点分离的原则进行分配。信息隐藏模块应该封装可能发生变化的内容,从而使软件免受这些变化的影响。每个模块都应该有一个定义良好的接口,该接口将可变方面从使用其功能的其他软件中封装或 “隐藏” 起来。这些接口应该允许各自的开发团队在很大程度上独立工作。
- 除非你的需求是前所未有的 —— 有可能,但不太可能 —— 你的质量属性应该通过使用针对每个属性的众所周知的架构模式和策略(在 第 4 章 至 第 13 章 中描述)来实现。
- 架构永远不应该依赖于商业产品或工具的特定版本。如果必须依赖,它应该被构建成使得切换到不同版本是直接且成本低廉的。
- 产生数据的模块应该与消费数据的模块分开。这往往会增加可修改性,因为变化通常局限于数据的生产方或消费方。如果添加了新数据,双方都必须改变,但这种分离允许分阶段(增量)升级。
- 不要期望模块和组件之间有一一对应的关系。例如,在具有并发的系统中,一个组件的多个实例可能并行运行,其中每个组件都是由同一个模块构建的。对于具有多个并发线程的系统,每个线程可能使用来自几个组件的服务,每个组件都是由不同的模块构建的。
- 每个进程都应该被编写成可以轻松地改变其分配到特定处理器的方式,甚至可能在运行时进行改变。正如我们将在 第 16 章 和 第 17 章 中讨论的,这是虚拟化和云部署日益增长趋势的一个驱动力。
- 架构应该具有少量简单的组件交互模式。也就是说,系统应该在整个过程中以相同的方式做相同的事情。这种做法将有助于提高可理解性、减少开发时间、增加可靠性并增强可修改性。
- 架构应该包含一小组特定(且数量少)的资源竞争区域,其解决方案应明确规定并得到维护。例如,如果网络利用率是一个关注领域,架构师应该为每个开发团队制定(并强制执行)将导致可接受网络流量水平的指南。如果性能是一个关注点,架构师应该制定(并强制执行)时间预算。
1.4 小结
系统的软件架构是用于对该系统进行推理所需要的一组结构。这些结构由软件元素、它们之间的关系以及两者的属性构成。
结构分为三类:
- 模块结构将系统展示为一组需要构建或获取的代码或数据单元。
- 组件与连接器结构将系统展示为一组具有运行时行为(组件)和交互(连接器)的元素。
- 分配结构展示了来自模块结构和组件与连接器结构的元素如何与非软件结构(如 CPU、文件系统、网络和开发团队)相关联。
结构代表了架构的主要工程着力点。每种结构都具备操控一个或多个质量属性的能力。总体而言,这些结构代表了一种创建架构(以及随后对其进行分析并向利益相关者解释)的有力方法。并且,正如我们将在 第 22 章 中看到的,架构师选作工程着力点的结构也是选择作为架构文档编制基础的主要候选对象。
每个系统都有软件架构,但这种架构可能有文档记录并传播,也可能没有。
不存在本质上绝对好或坏的架构。架构只是对于某些目的而言更合适或不太合适。
1.5 扩展阅读
如果你对软件架构这一研究领域有着浓厚的兴趣,那么你可能会有兴趣阅读一些开创性的著作。其中大部分根本没有提及 “软件架构” 这个词,因为这个短语直到 20 世纪 90 年代中期才出现,所以你得从字里行间去领会其含义。
艾兹格・迪科斯彻(Edsger Dijkstra)1968 年关于 T.H.E. 操作系统的论文引入了分层的概念 [Dijkstra 68]。大卫・帕纳斯(David Parnas)的早期著作奠定了许多概念基础,包括信息隐藏 [Parnas 72]、程序族 [Parnas 76]、软件系统固有的结构 [Parnas 74] 以及利用结构构建系统的子集和超集 [Parnas 79]。帕纳斯的所有论文都可以在更容易获取的他的重要论文集 [Hoffman 00] 中找到。现代分布式系统的存在得益于协作顺序进程的概念,在这方面(以及其他方面),C. A. R.(托尼)・霍尔(Sir C. A. R. (Tony) Hoare)在概念化和定义方面起到了重要作用 [Hoare 85]。
1972 年,迪科斯彻、霍尔以及奥利 - 约翰・达尔(Ole-Johan Dahl)认为,程序应该被分解成具有小型且简单接口的独立组件。他们将自己的方法称为结构化编程,但可以说这就是软件架构的首次亮相 [Dijkstra 72]。
玛丽・肖(Mary Shaw)和大卫・加兰(David Garlan)两人合作以及各自独立完成了大量的工作,这些工作帮助创建了我们称之为软件架构的研究领域。他们确立了该领域的一些基本原则,并且除其他事项外,还对一系列具有开创性的架构风格(一个与模式类似的概念)进行了分类,其中几种在本章中作为架构结构呈现。可以先从 [Garlan 95] 开始阅读。
软件架构模式在《面向模式的软件架构》(Pattern-Oriented Software Architecture)系列丛书 [布施曼 96 及其他作者] 中已有广泛的分类。在本书的 第二部分 中我们也会通篇讨论架构模式。
关于工业开发项目中所使用的架构视图的早期论文有 [Soni 95] 和 [Kruchten 95]。前者发展成了一本书 [Hofmeister 00],该书全面展示了在开发和分析中使用视图的情况。
许多书籍都聚焦于与架构相关的实际实现问题,比如乔治・费尔班克斯(George Fairbanks)的《恰到好处的软件架构》(Just Enough Software Architecture)[Fairbanks 10]、伍兹(Woods)和罗赞斯基(Rozanski)的《软件系统架构》(Software Systems Architecture)[Woods 11] 以及马丁(Martin)的《整洁架构:软件结构与设计的工匠指南》(Clean Architecture: A Craftsman’s Guide to Software Structure and Design)[Martin 17]。
1.6 问题讨论
1. 你是否熟悉软件架构的不同定义?如果是,将其与本章给出的定义进行比较和对比。许多定义包含诸如 “基本原理”(说明架构之所以如此的原因)或架构如何随时间演变等考虑因素。你是否同意这些考虑因素应成为软件架构定义的一部分?
2. 讨论架构如何作为分析的基础。那么在决策方面呢?架构能支持哪些类型的决策?
3. 架构在降低项目风险方面的作用是什么?
4. 找到一个普遍接受的 “系统架构” 定义,并讨论它与软件架构的共同之处。对 “企业架构” 也做同样的分析。
5. 找到一个已发布的软件架构示例。展示了哪些结构?根据其目的,本应展示哪些结构?该架构支持哪些分析?对其进行评论:你有哪些该表示例没有回答的问题?
6. 帆船有架构,这意味着它们有 “结构”,可用于对船舶的性能和其他质量属性进行推理。查找 “三桅帆船”、“双桅横帆船”、“独桅纵帆船”、“护卫舰”、“双桅纵帆船”、“纵帆船” 和 “单桅帆船” 的技术定义。提出一套用于区分和推理船舶架构的有用 “结构”。
7. 飞机有其架构,其特点可以通过它们如何解决一些主要设计问题来体现,例如发动机位置、机翼位置、起落架布局等等。几十年来,大多数为客运设计的喷气式飞机都具有以下特征:
- 发动机安装在机翼下方的吊舱中(而非内置在机翼中或安装在机身尾部);
- 机翼在机身底部与机身相连(而非在顶部或中部)。
首先,在网上搜索,从波音、巴西航空工业公司、图波列夫和庞巴迪等每个制造商中找到这种设计类型的一个示例和一个反例。接下来,进行一些在线研究并回答以下问题:这种设计为飞机提供了哪些重要的品质?
[^1]: 在本书中,当我们提到模块(module)或者组件(component),并且不想对两者加以区分时,我们会使用 “元素(element)” 这个术语。
第2章 为什么软件架构很重要?
啊,建造,建造! 这是所有艺术中最崇高的艺术。
—— 亨利・沃兹沃思・朗费罗(Henry Wadsworth Longfellow)
如果架构是答案,那么问题是什么?
本章从技术角度重点关注为什么软件架构很重要。我们将探讨十三个最重要的原因。你可以使用这些原因来推动新架构的创建,或者对现有系统架构进行分析和演进。
- 架构可以抑制或实现系统的关键质量属性。
- 架构中的决策使你能够在系统演进时对变更进行推理和管理。
- 对架构的分析能够及早预测系统的质量。
- 有文档记录的架构可增强利益相关者之间的沟通。
- 架构承载着最早的、因而也是最根本的、最难改变的设计决策。
- 架构定义了对后续实现的一组约束。
- 架构决定组织的结构,反之亦然。
- 架构可以为增量开发提供基础。
- 架构是使架构师和项目经理能够对成本和进度进行推理的关键工件。
- 架构可以创建为可转移、可重用的模型,从而构成产品线的核心。
- 基于架构的开发将注意力集中在组件的组装上,而不仅仅是它们的创建上。
- 通过限制设计选择,架构引导开发人员的创造力,降低设计和系统复杂性。
- 架构可以成为培训新团队成员的基础。
即使你已经相信我们架构很重要,不需要这一点再被强调十三次,也可以将这十三个要点(构成了本章的大纲)视为在项目中使用架构或证明投入架构的资源合理性的十三种有用方法。
2.1 抑制或实现系统的质量属性
一个系统满足其期望(或要求)质量属性的能力在很大程度上由其架构决定。如果从本书中你什么都没记住,那就记住这一点。
这种关系非常重要,以至于我们在本书的 第二部分 中用了全部篇幅来详细阐述这一观点。在那之前,先记住这些例子作为一个起点:
- 如果你的系统需要高性能,那么你需要注意管理元素基于时间的行为、它们对共享资源的使用以及它们之间通信的频率和数量。
- 如果可修改性很重要,那么你需要注意将职责分配给元素,并限制这些元素的交互(耦合),以便系统的大多数变更只会影响少数这些元素。理想情况下,每个变更只影响单个元素。
- 如果你的系统必须高度安全,那么你需要管理和保护元素之间的通信,并控制哪些元素被允许访问哪些信息。你可能还需要在架构中引入专门的元素(如授权机制)来建立一个强大强大的 “边界” 以防止入侵。
- 如果你希望你的系统安全可靠,你需要设计安全防护措施和恢复机制。
- 如果你认为性能的可扩展性对系统的成功很重要,那么你需要将资源的使用本地化,以便引入更高容量的替代品,并且你必须避免在资源假设或限制上进行硬编码。
- 如果你的项目需要能够交付系统的增量子集,那么你必须管理组件间的使用。
- 如果你希望系统中的元素在其他系统中可重用,那么你需要限制元素之间的耦合,以便当你提取一个元素时,它不会因为与当前环境有太多的关联而变得难以使用。
这些和其他质量属性的策略在很大程度上是架构性的。但是,仅靠架构不能保证系统所需的功能或质量。糟糕的下游设计或实现决策总是会破坏一个适当的架构设计。正如我们喜欢说的(大部分是开玩笑):架构给予的,实现可能会拿走。在生命周期的所有阶段(从架构设计到编码、实现和测试)的决策都会影响系统质量。因此,质量不完全是架构设计的事。但这是质量的起点。
2.2 对变更进行推理和管理
这是前一点的必然结果。
可修改性(对系统进行变更的容易程度)是一种质量属性(因此在前一节的论述中有所涉及),但它是如此重要的一种质量属性,以至于我们在 “十三个要点” 中专门为它留出了一个位置。软件开发社区社区正在认识到这样一个事实:一个典型软件系统的总成本中大约有 80% 是在初始部署之后产生的。大多数人们所从事的系统都都处于这个阶段。许多程序员和软件设计师永远不会从事新的开发工作 —— 他们在现有架构和现有代码体的约束下工作。实际上,所有的软件系统在其生命周期中都会发生变更,以适应新功能、新环境、修复漏洞等等。但现实情况是,这些变更往往充满困难。
每一种架构,无论它是什么,都将可能的变更分为三类:局部变更、非局部变更和架构变更。
- 局部变更可以通过修改单个元素来实现 —— 例如,向定价逻辑模块添加一个新的业务规则。
- 非局部变更需要对多个元素进行修改,但保持底层架构方法不变 —— 例如,向定价逻辑模块添加一个新的业务规则,然后向该新业务规则所需的数据库中添加新字段,然后在用户界面中显示应用该规则的结果。
- 架构变更会影响元素之间相互交互的基本方式,并且可能需要在整个系统中进行变更 —— 例如,将一个系统从单线程改为多线程。
显然,局部变更是最理想的,所以一个 “有效的” 架构是那种最常见的变更是局部的,因而容易实现的架构。非局部变更不是那么理想,但确实有一个优点,即它们通常可以分阶段 —— 也就是逐步 —— 以有序的方式随着时间推出。例如,你可能首先进行变更以添加一个新的定价规则,然后进行变更以实际部署这个新规则。
决定何时变更至关重要、确定哪些变更路径风险最小、评估提议变更的后果以及对请求的变更进行排序和确定优先级,所有这些都需要对系统软件元素的关系、性能和行为有广泛的了解。这些任务都是架构师工作职责的一部分。对架构进行推理和分析架构可以提供对预期变更做出决策所需的见解。如果你不采取这一步骤,并且如果你不注意保持架构的概念完整性,那么你几乎肯定会积累 “架构债务”。我们将在 第 23 章 中讨论这个问题。
2.3 预测系统质量
这一点是前两点的延续:架构不仅赋予系统质量属性,而且是以可预测的方式做到这一点。
这可能看起来很明显,但情况并非必然如此。否则,设计架构将包括做出一系列几乎是随机的设计决策、构建系统、测试质量属性并寄希望于最好的结果。哎呀 —— 不够快或者极易受到攻击?那就开始胡乱修改吧。
幸运的是,仅基于对系统架构的评估就有可能对系统的质量进行预测。如果我们知道某些类型的架构决策会导致系统中的某些质量属性,那么我们就可以做出这些决策,并合理地期望获得相关的质量属性作为回报。在事后,当我们检查一个架构时,我们可以确定是否已经做出了这些决策,并自信地预测该架构将展现出相关的质量。
这一点和前一点结合起来意味着架构在很大程度上决定了系统的质量属性,而且(更好的是!)我们知道它是如何做到的,并且我们知道如何让它这样做。
即使你没有进行有时为确保架构将提供其规定的好处所必需的定量分析建模,基于质量属性影响评估决策的这一原则至少在早期发现潜在问题方面也是非常宝贵的。
2.4 利益相关者之间的沟通
在 第 1 章 中提到的一点是,架构是一种抽象,这很有用,因为它代表了整个系统的简化模型,(与整个系统的无限细节不同)你可以将其记在脑海中。你的团队中的其他人也可以。架构代表了一个系统的共同抽象,系统的大多数(如果不是全部)利益相关者可以将其用作建立相互理解、进行协商、形成共识以及相互沟通的基础。架构(或者至少是其一部分)足够抽象,以至于大多数非技术人员在一定程度上可以理解他们所需的内容,特别是在架构师的一些指导下,而且这种抽象可以细化为足够丰富的技术规范,以指导实现、集成、测试和部署。
软件系统的每个利益相关者(客户、用户、项目经理、编码人员、测试人员等等)都关注受系统架构影响的系统的不同特性。例如:
- 用户关心系统快速、可靠且在需要时可用;
- 客户(为系统付费的人)关心架构能够按时并按照预算实现;
- 经理担心(除了成本和进度问题之外)架构将允许团队在很大程度上独立工作,以有纪律和受控制的方式进行交互;
- 架构师担心实现所有这些目标的策略。
架构提供了一种共同的语言,在这种语言中,可以在即使对于大型、复杂系统也在智力上可管理的层面上表达、协商和解决不同的关注点。没有这样一种语言,就很难充分理解大型系统,以做出影响质量和实用性的早期决策。正如我们将在 第 21 章 中看到的,架构分析既依赖于这种沟通水平,又对其进行了增强。
关于架构文档的 第 22 章 将更深入地探讨利益相关者及其关注点。
“按下这个按钮会发生什么?”:架构作为利益相关者沟通的工具
项目评审冗长而沉闷地进行着。这个由政府资助的开发项目进度落后且超出预算,而且由于规模庞大,这些失误引起了美国国会的关注。现在,政府为弥补过去的疏忽,正在举行一场马拉松式的全员评审会议。承包商最近经历了一次收购,这对事情毫无帮助。现在是第二天下午,议程安排是介绍软件架构。年轻的架构师(系统首席架构师的助手)勇敢地解释着这个庞大系统的软件架构将如何使其满足极其苛刻的实时性、分布式和高可靠性要求。他有一个扎实的演示和一个扎实的架构要介绍。它既合理又明智。但是听众(大约 30 名在这个棘手项目的管理和监督中扮演不同角色的政府代表)都很疲惫。他们中的一些人甚至在想,也许他们应该去从事房地产行业,而不是再忍受这样一场马拉松式的 “这次一定要把事情做好” 的评审。
幻灯片以半正式的方框和线条符号展示了系统运行时视图中的主要软件元素。名称都是首字母缩写,没有解释的话没有语义,年轻的架构师进行了解释。线条表示数据流、消息传递和进程同步。正如架构师所解释的,元素是内部冗余的。“在发生故障时,” 他开始说道,用激光笔指着其中一条线,“一个重启机制会沿着这条路径触发,当……”
“按下模式选择按钮会发生什么?” 一位听众打断了他。他是代表这个系统用户群体的政府参会者。
“对不起,你说什么?” 架构师问道。
“模式选择按钮,” 他说。“按下它会发生什么?”
“嗯,那会在设备驱动程序中触发一个事件,在这里,” 架构师开始用激光笔指着说。“然后它读取寄存器并解释事件代码。如果是模式选择,那么,它会向黑板发送信号,黑板又会向订阅了该事件的对象发送信号……”
“不,我的意思是系统会做什么,” 提问者打断了他。“它会重置显示器吗?如果在系统重新配置期间发生这种情况会怎样?”
架构师看起来有点惊讶,关掉了激光笔。这不是一个架构问题,但由于他是架构师,因此对需求很熟悉,他知道答案。“如果命令行处于设置模式,显示器将重置,” 他说。“否则,控制台上会出现一个错误消息,但信号将被忽略。” 他又打开了激光笔。“现在,回到我刚才正在说的重启机制……”
“嗯,我只是在想,” 用户代表说。“因为我从你的图表中看到显示控制台正在向目标位置模块发送信号流量。”
“应该发生什么?” 另一位听众成员问第一个提问者。“你真的希望用户在系统重新配置期间获取模式数据吗?” 在接下来的 45 分钟里,架构师看着听众们占用了他的时间,就系统在各种深奥状态下应该有什么样的正确行为进行辩论 —— 这是一场绝对必要的对话,本应该在制定需求时进行,但由于某种原因没有进行。
这场辩论不是关于架构的,但架构(以及它的图形表示)引发了辩论。很自然地会认为架构是除了架构师和开发人员之外的一些利益相关者之间沟通的基础:例如,经理们使用架构来创建团队并在团队之间分配资源。但是用户呢?毕竟,架构对用户来说是不可见的;为什么他们会把它当作理解系统的工具呢?
事实是他们确实会这样做。在这种情况下,提问者已经听了两天关于功能、操作、用户界面和测试的幻灯片。但正是第一张关于架构的幻灯片(尽管他很疲惫,想回家)让他意识到他不明白一些事情。参加过许多架构审查让我确信,以一种新的方式看待系统会刺激思维并引出新的问题。对于用户来说,架构常常作为这种新的方式,而用户提出的问题将是行为性质的。在几年前一次令人难忘的架构评估活动中,用户代表对系统将要做什么比对它将如何做更感兴趣,这是很自然的。在那之前,他们与供应商的唯一接触是通过其营销人员。架构师是他们能够接触到的关于系统的第一个合法专家,他们毫不犹豫地抓住了这个机会。
当然,仔细而全面的需求规格说明会改善这种情况,但由于各种原因,它们并不总是被创建或可用。在没有需求规格说明的情况下,架构的规格说明常常会引发问题并提高清晰度。认识到这种可能性比抵制它可能更为明智。
有时这样的活动会揭示不合理的需求,然后可以重新审视其效用。这种强调需求和架构之间协同作用的审查类型会让我们故事中的年轻架构师从困境中解脱出来,在整个审查会议中给他一个位置来处理那种信息。而用户代表也不会觉得自己在不恰当的时候提出问题而不自在了。
—PCC
2.5 早期设计决策
软件架构是关于一个系统的最早设计决策的体现,这些早期的决策在系统的后续开发、部署和维护生命周期中具有巨大的影响力。这也是能够对影响系统的这些重要设计决策进行仔细审查的最早阶段。
在任何学科中,任何设计都可以看作是一系列决策的过程。当画家绘画时,在开始作画之前,就会决定画布的材料和记录的媒介 —— 油画颜料、水彩颜料、蜡笔。一旦开始作画,其他决策会立即做出:第一条线在哪里,它的粗细如何,它的形状是什么?所有这些早期设计决策对画作的最终外观有很大的影响,并且每个决策都限制了后续的许多决策。单独来看,每个决策可能看起来都没什么问题,但早期的决策尤其具有不成比例的重要性,仅仅是因为它们对后续的很多决策产生影响并加以限制。
架构设计也是如此。架构设计也可以看作是一组决策。改变这些早期决策会产生连锁反应,涉及到现在必须改变的其他决策。是的,有时架构必须进行重构或重新设计,但这不是一个我们可以轻易进行的任务 —— 因为这个 “连锁反应” 可能会变成一场雪崩。
软件架构所体现的这些早期设计决策是什么呢?考虑以下几点:
- 系统将在一个处理器上运行还是分布在多个处理器上?
- 软件会分层吗?如果是,会有多少层?每一层将做什么?
- 组件之间的通信是同步的还是异步的?它们通过传递控制信息还是数据进行交互,或者两者都有?
- 在系统中流动的信息会被加密吗?
- 我们将使用哪个操作系统?
- 我们将选择哪种通信协议?
想象一下不得不改变这些或无数其他相关决策的噩梦。像这样的决策开始充实架构的一些结构及其相互作用。
2.6 对实现的约束
如果你希望你的实现符合一个架构,那么它就必须符合该架构所规定的设计决策。它必须具有该架构所规定的元素集合,这些元素必须以该架构所规定的方式相互交互,并且每个元素都必须按照该架构的规定履行对其他元素的责任。这些规定中的每一个都是对实现者的约束。
元素构建者必须熟悉他们各自元素的规范,但他们可能不知道架构上的权衡 —— 架构(或架构师)只是以满足权衡的方式对他们进行约束。一个经典的例子是,当架构师为涉及某些更大功能的软件部分分配性能预算时。如果每个软件单元都在其预算范围内,那么整个事务将满足其性能要求。每个组成部分的实现者可能不知道整体预算,而只知道他们自己的预算。
相反,架构师不必是算法设计的所有方面或编程语言的复杂细节的专家 —— 尽管他们当然应该知道得足够多,以免设计出难以构建的东西。然而,架构师是负责建立、分析和执行架构决策和权衡的人。
2.7 对组织架构的影响
架构不仅规定了正在开发的系统的结构,而且这种结构还会铭刻在开发项目的结构中(有时是整个组织的结构中)。在大型项目中划分工作的常规方法是为不同的团队分配系统的不同部分来构建。这种所谓的系统工作分解结构体现在 第 1 章 所描述的工作分配结构的架构中。由于架构包含了系统最广泛的分解,它通常被用作工作分解结构的基础。工作分解结构进而决定了规划、调度和预算的单位;团队间的沟通渠道;配置控制和文件系统组织;集成与测试计划和程序;甚至是项目的细枝末节,比如项目内部网是如何组织的,公司野餐时谁和谁坐在一起。团队之间根据其元素的接口规范进行沟通。当开展维护活动时,这也会反映软件结构,会组建团队来维护架构中的特定元素 —— 数据库、业务业务规则、用户界面、设备驱动程序等等。
建立工作分解结构的一个副作用是固化软件架构的某些方面。负责其中一个子系统的团队可能会抵制将其职责分配给其他团队。如果这些职责已经在合同关系中正式确定,变更职责可能会变得成本高昂,甚至引发诉讼。
因此,一旦架构达成一致,出于管理和业务原因,对其进行重大修改的成本就会变得非常高。这是(众多理由中的)一个在确定具体选择之前分析大型系统软件架构的理由。
2.8 支持增量式开发
一旦定义了架构,它就可以作为增量式开发的基础。第一个增量可以是一个框架系统,其中至少有一些基础结构(元素如何初始化、通信、共享数据、访问资源、报告错误、记录活动等等)是存在的,但系统的大部分应用功能并不存在。
构建基础结构和构建应用功能可以同时进行。设计并构建一点基础结构来支持一点端到端的功能;重复这个过程直到完成。
许多系统都是作为框架系统构建的,可以使用插件、包或扩展进行扩展。例如 R 语言、Visual Studio Code 和大多数网络浏览器。当添加扩展时,它们在框架已有的功能之上提供额外的功能。这种方法通过确保系统在产品生命周期的早期可执行来辅助开发过程。随着扩展的添加,或者早期版本被这些软件部分的更完整版本所取代,系统的保真度会增加。在某些情况下,这些部分可能是最终功能的低保真版本或原型;在其他情况下,它们可能是仅以适当的速率消耗和产生数据但几乎不做其他事情的 “替代品”。除此之外,这允许在产品生命周期的早期识别潜在的性能(和其他)问题。
这种实践在 21 世纪初通过阿利斯泰尔・科克本(Alistair Cockburn)的思想及其 “行走的骨架” 概念而受到关注。最近,采用 MVP(最小可行产品)作为降低风险策略的人也采用了这种方法。
增量式开发的好处包括降低项目中的潜在风险。如果架构是针对一系列相关系统的,那么基础框架可以在整个系列中重复使用,从而降低每个系统的成本。
2.9 成本和进度估算
成本和进度估算对项目经理来说是一个重要的工具。它们帮助项目经理获取必要的资源,并监控项目的进展。架构师的职责之一是在项目生命周期的早期帮助项目经理创建成本和进度估算。虽然自上而下的估算对于设定目标和分配预算很有用,但基于自下而上理解系统各个部分的成本估算通常比纯粹基于自上而下的系统知识的估算更准确。
正如我们所说,项目的组织和工作分解结构几乎总是基于其架构。负责一个工作项的每个团队或个人将能够比项目经理更准确地估算他们负责的部分,并且在使这些估算成为现实时会有更强的责任感。但是,最好的成本和进度估算通常来自于自上而下的估算(由架构师和项目经理创建)和自下而上的估算(由开发人员创建)之间的共识。这个过程中产生的讨论和协商所创建的估算比单独使用任何一种方法都要准确得多。
如果对系统的需求进行了审查和验证,会很有帮助。你对范围了解得越多,成本和进度估算就会越准确。
第 24 章 深入探讨了架构在项目管理中的使用。
2.10 可转移、可重用的模型
在生命周期中越早应用重用,就能从这种实践中获得越大的收益。虽然代码重用有好处,但架构的重用为具有类似需求的系统提供了巨大的影响力机会。当架构决策可以在多个系统中重用时,我们在前面章节中描述的所有早期决策的后果也会转移到那些系统中。
产品线或产品族是一组系统,它们都是使用相同的一组共享资产构建的 —— 软件组件、需求文档、测试用例等等。这些资产中最重要的是为满足整个产品族的需求而设计的架构。产品线架构师选择一个架构(或一组密切相关的架构),它将服务于产品线中所有可预见的成员。架构定义了对于产品线的所有成员来说什么是固定的,什么是可变的。
产品线代表了一种强大的多系统开发方法,在上市时间、成本、生产力和产品质量方面已经显示出数量级的回报。架构的力量是这种范式的核心。与其他资本投资类似,产品线的架构成为开发组织的共享资产。
2.11 架构允许纳入独立开发的元素
早期的软件范式将 “编程(programming)” 作为主要活动,以代码行数来衡量进展,而基于架构的开发通常侧重于 “组合(composing)” 或 “组装元素(assembling elements)”,这些元素很可能是分别甚至独立开发的。这种组合是可能的,因为架构定义了可以纳入系统的元素。架构根据元素与环境的交互方式、接收和放弃控制的方式、消耗和产生的数据、访问数据的方式以及用于通信和资源共享的协议来约束可能的替换(或添加)。我们将在 第 15 章 中详细阐述这些想法。
商用现成组件、开源软件、公开可用的应用程序和网络服务都是独立开发元素的例子。将许多独立开发的元素集成到你的系统中的复杂性和普遍性催生了一整个软件工具行业,例如 Apache Ant、Apache Maven、MSBuild 和 Jenkins。
对于软件来说,回报可能有以下形式:
- 缩短上市时间(使用别人现成的解决方案应该比自己构建更容易。)
- 提高可靠性(广泛使用的软件应该已经消除了其漏洞。)
- 降低成本(软件供应商可以在其客户群中分摊开发成本。)
- 增加灵活性(如果你想要购买的元素不是非常特定用途的,它很可能有多个来源,这反过来又增加了你的购买影响力。)
一个 “开放系统” 是定义了一组软件元素标准的系统 —— 它们的行为方式、与其他元素的交互方式、如何共享数据等等。开放系统的目标是使许多不同的供应商能够生产元素,并甚至鼓励他们这样做。这可以避免 “供应商锁定”,即只有一个供应商能够提供一个元素并为此收取高价的情况。开放系统是由定义元素及其交互的架构实现的。
2.12 限制设计选择的词汇
随着有用的架构解决方案被收集起来,很明显,虽然软件元素可以以或多或少无限的方式组合,但通过自愿将我们自己限制在相对较少的元素选择及其交互方式中,可以获得一些好处。通过这样做,我们最小化了我们正在构建的系统的设计复杂性。
软件工程师不是 “艺术家”,在那里创造力和自由是至高无上的。相反,工程是关于纪律的,而纪律部分来自于对经过验证的解决方案限制设计选择的 “词汇”。这些经过验证的设计解决方案的例子包括策略和模式,我们将在 第二部分 中广泛讨论。重用现成的元素是限制你的设计词汇的另一种方法。
将你的设计词汇限制在经过验证的解决方案可以带来以下好处:
- 增强重用性
- 更规则和更简单的设计,更容易理解和沟通,并带来更可靠的可预测结果
- 更容易进行分析,具有更大的信心
- 更短的选择时间
- 更大的互操作性
前所未有的设计是有风险的。经过验证的设计是,嗯,经过验证的。这并不是说软件设计永远不能创新或提供新的令人兴奋的解决方案。它可以。但这些解决方案不应该为了新颖而被发明;相反,当现有解决方案不足以解决手头的问题时,应该寻求这些解决方案。
软件的属性来自于架构策略或模式的选择。对于特定问题更可取的策略和模式应该改进最终的设计解决方案,也许通过使冲突的设计约束更容易仲裁、通过增加对理解不深的设计上下文的洞察以及通过帮助揭示需求中的不一致性。我们将在 第二部分 中讨论架构策略和模式。
2.13 培训的基础
架构,包括元素如何相互作用以执行所需行为的描述,可以作为新的项目成员对系统的首次介绍。这强化了我们的观点,即软件架构的一个重要用途是支持和鼓励各种利益相关者之间的沟通。架构作为所有这些人的共同参考点。
模块视图是向某人展示项目结构的极好方式:谁做什么,哪个团队被分配到系统的哪个部分等等。组件和连接器视图是解释系统如何预期工作并完成其任务的极好选择。分配视图向新的项目成员展示他们被分配的部分在项目的开发或部署环境中的位置。
2.14 小结
软件架构由于各种技术和非技术原因而非常重要。我们的 “十三个要点” 包括以下好处:
- 架构可以抑制或实现系统的关键质量属性。
- 架构中的决策使你能够在系统演进时对变更进行推理和管理。
- 对架构的分析能够早期预测系统的质量。
- 有文档记录的架构可增强利益相关者之间的沟通。
- 架构承载着最早的、因而也是最根本的、最难改变的设计决策。
- 架构定义了对后续实现的一组约束。
- 架构决定组织的结构,反之亦然。
- 架构可以为增量开发提供基础。
- 架构是使架构师和项目经理能够对成本和进度进行推理的关键工件。
- 架构可以创建为可转移、可重用的模型,构成产品线的核心。
- 基于架构的开发将注意力集中在组件的组装上,而不仅仅是它们的创建上。
- 通过限制设计选择,架构有效地引导开发人员的创造力,降低设计和系统复杂性。
- 架构可以成为培训新团队成员的基础。
2.15 扩展阅读
格雷戈尔・霍普(Gregor Hohpe)所著的《软件架构师电梯:重新定义数字企业中架构师的角色》描述了架构师在组织内部和外部与各个层面的人进行互动以及促进利益相关者沟通的独特能力 [Hohpe 20]。
关于架构和组织的经典论文鼻祖是康威(Conway)的论文 [Conway 68]。康威定律指出:“设计系统的组织…… 受限于生产出与这些组织的沟通结构类似的设计。”
科克本(Cockburn)的 “行走的骨架” 概念在《敏捷软件开发:合作游戏》 [Cockburn 06] 中有所描述。
汽车行业开发的 AUTOSAR 是开放系统架构标准的一个很好的例子(autosar.org)。
关于构建软件产品线的全面论述,请参阅《软件产品线工程》 [Clements 16]。基于特性的产品线工程是一种以自动化为中心的现代构建产品线的方法,将范围从软件扩展到系统工程。在 [INCOSE 19] 中可以找到一个很好的总结。
2.16 问题讨论
1. 如果你从这本书中什么都没记住,那么记住…… 什么呢?不偷看回答可获得额外加分。
2. 对于本章中阐述的架构之所以重要的十三个理由中的每一个,采取相反的立场:提出一组在何种情况下架构对于实现所指出的结果并非必要的情况。并为你的立场提供理由。(尽量为十三个理由中的每一个都想出不同的情况。)
3. 本章认为架构带来了许多切实的好处。在一个特定的项目中,你将如何衡量这十三个要点中的每一个的好处呢?
4. 假设你想在你的组织中引入以架构为中心的实践。你的管理层对这个想法持开放态度,但想知道这样做的投资回报率(ROI)。你会如何回应?
5. 根据对你有意义的某些标准对本章中的十三个理由列表进行优先级排序。为你的答案提供理由。或者,如果你只能选择两到三个理由来在项目中推广架构的使用,你会选择哪些理由,为什么?
第二部分 质量属性
- 第3章 理解质量属性
- 第4章 可用性
- 第5章 可部署性
- 第6章 能效性
- 第7章 可集成性
- 第8章 可修改性
- 第9章 性能
- 第10章 安全性
- 第11章 信息安全性
- 第12章 可测试性
- 第13章 易用性
- 第14章 其他质量属性
第3章 理解质量属性
第3章 理解质量属性
质量绝非偶然;它始终是高远的意图、真诚的努力、明智的引导以及精湛的执行共同作用的结果。
—— 威廉・A・福斯特(William A. Foster)
许多因素决定了系统架构必须具备的质量特性。这些质量特性超出了功能范畴,功能只是对系统能力、服务及行为的基本描述。尽管功能与其他质量特性密切相关,正如你将会看到的那样,在开发方案中功能往往占据首要位置。然而,这种偏向是短视的。系统常常需要重新设计,并不是因为它们在功能上有缺陷 —— 替代系统在功能上往往是相同的 —— 而是因为它们难以维护、移植或扩展,或是运行速度太慢,又或是遭到了黑客攻击。在 [第 2 章][ch02] 中,我们提到在软件创建过程中,架构是能够着手解决质量需求实现问题的首要环节。正是将系统功能映射到软件结构上的这一过程,决定了架构对质量特性的支持情况。在 [第 4 章][ch04] 至 [第 14 章][ch14] 中,我们会讨论架构设计决策是如何对各种质量特性提供支持的。在 [第 20 章][ch20] 中,我们将展示如何把包括质量属性决策在内的所有驱动因素整合到一个协调一致的设计当中。
我们之前一直在宽泛地使用 “质量属性” 这个术语,但现在是时候更严谨地对其进行定义了。质量属性(QA)是系统的一种可度量或可测试的特性,用于表明系统在满足利益相关者需求方面的表现如何,这里的需求是超出系统基本功能之外的。你可以把质量属性看作是沿着利益相关者感兴趣的某个维度对产品 “实用性” 的一种衡量。
在本章中,我们的重点在于理解以下内容:
- 如何表述我们希望架构展现出的质量特性;
- 如何通过架构手段来实现这些质量特性;
- 如何确定针对这些质量特性可能做出的设计决策。
本章为 [第 4 章][ch04] 至 [第 14 章][ch14] 中对各个质量属性的讨论提供了背景知识。
3.1 功能性
功能性是指系统具备执行其预期工作的能力。在所有的需求当中,功能性与架构有着最为奇特的关联。
首先,功能性并不能决定架构。也就是说,给定一组所需的功能,能够创建出用以满足该功能的架构可以有无数种。最起码,你可以用任意多种方式对功能进行划分,并将各个子功能分配给不同的架构元素。
事实上,如果只有功能性是重要的,那你根本无需将系统拆分成多个部分:一个没有内部结构的单一整体模块就完全可以了。然而,我们会将系统设计成由相互协作的架构元素(如模块、层、类、服务、数据库、应用程序、线程、对等节点、层级等等)构成的结构化集合,目的是使其易于理解,并支持各种各样的其他用途。那些 “其他用途” 就是我们将在本章剩余部分以及 [第二部分][part02] 后续的质量属性章节中探讨的其他质量属性。
尽管功能性独立于任何特定的结构,但它是通过给架构元素分配职责来实现的。这一过程产生了最基本的架构结构之一 —— 模块分解结构。
虽然职责可以随意分配给任何模块,但当其他质量属性比较重要时,软件架构会对这种分配加以约束。例如,系统通常(或者说总是)会进行划分,以便多人能够协作构建它们。架构师对于功能性的关注点在于它如何与其他质量属性相互作用以及如何对它们构成约束。
功能需求
在对功能需求和质量需求之间的区别进行了 30 多年的著述与讨论之后,功能需求的定义仍然让我捉摸不透。质量属性需求有着明确的界定:性能与系统的时间特性相关,可修改性关乎系统在初次部署后支持自身行为或其他特性变更的能力,可用性涉及系统在出现故障时仍能正常运行的能力,等等。
然而,功能是一个更为模糊的概念。一项国际标准(ISO 25010)将功能适用性定义为 “软件产品在规定条件下使用时,提供满足既定及隐含需求的功能的能力”。也就是说,功能性就是提供功能的能力。对这一定义的一种解释是,功能性描述的是系统做什么,而质量描述的是系统执行其功能的好坏程度。即质量是系统的属性,而功能是系统的目的。
不过,当你考虑某些 “功能” 的本质时,这种区分就站不住脚了。如果软件的功能是控制发动机的行为,若不考虑时间特性,又怎能正确实现该功能呢?要求用户名 / 密码组合来控制访问的能力难道不是一种功能吗,即便它并非任何系统的主要目的所在?
我更倾向于使用 “职责” 一词来描述系统必须执行的计算任务。诸如 “那一组职责有哪些时间限制?”、“针对那一组职责预计会有哪些修改?”、“允许哪类用户执行那一组职责?” 之类的问题是合理且可操作的。
质量的实现会引发职责;想想刚才提到的用户名 / 密码的例子就明白了。而且,人们可以确定与特定需求集相关联的职责。
那么,这是否意味着不应使用 “功能需求” 这个术语呢?人们对这个术语是有一定理解的,但当需要精准表述时,我们应该谈论具体的职责集合才对。
保罗・克莱门茨(Paul Clements)长期以来一直在抨击随意使用 “非功能” 一词的做法,现在轮到我来抨击随意使用 “功能” 一词的做法了 —— 可能同样收效甚微。
—LB
3.2 质量属性考量
正如系统的功能若不充分考虑质量属性就无法独立存在一样,质量属性自身也无法孤立存在;它们与系统的功能息息相关。如果一项功能需求是 “当用户按下绿色按钮时,‘选项’对话框出现”,那么一项性能质量属性注释可能会描述该对话框出现的速度有多快;一项可用性质量属性注释可能会描述此功能允许出现故障的频率以及修复的速度有多快;一项易用性质量属性注释可能会描述学习这项功能的难易程度。
至少自 20 世纪 70 年代以来,软件界就一直在对质量属性这一独特的主题进行研究。已经发布了各种各样的分类法和定义(我们会在 [第 14 章][ch14] 中讨论其中的一些),其中许多都有各自的研究和从业者群体。然而,关于系统质量属性的大多数讨论存在三个问题:
- 为某一属性提供的定义是不可测试的。说一个系统是 “可修改的” 毫无意义。对于某一组变更而言,每个系统可能是可修改的,但对于另一组变更则可能是不可修改的。其他质量属性在这方面也是类似的:一个系统对于某些故障可能很健壮,但对于其他故障可能很脆弱,等等。
- 讨论往往聚焦于某个特定问题属于哪种质量属性。针对系统的拒绝服务攻击是可用性方面的问题、性能方面的问题、安全性方面的问题,还是易用性方面的问题呢?这四个属性相关群体都会声称对拒绝服务攻击拥有 “所属权”。在某种程度上,它们都是正确的。但这种关于分类的争论对我们架构师理解并创建用以实际管理相关属性的架构解决方案并无帮助。
- 每个属性相关群体都发展出了自己的术语。性能相关群体有 “事件” 抵达系统的说法,安全相关群体有 “攻击” 抵达系统的表述,可用性相关群体会说 “故障” 来临,而易用性相关群体则用 “用户输入” 来描述。实际上,所有这些可能指的都是同一情况,只是用了不同的术语来描述。
解决前两个问题(不可测试的定义和重叠的问题)的一个办法是使用 “质量属性场景” 作为描述质量属性的一种手段(见 [第 3 章第 3 节][ch03_sec03])。解决第三个问题的办法是以一种通用形式阐释对该属性群体来说至关重要的概念,我们会在 [第 4 章][ch04] 至 [第 14 章][ch14] 中进行此项工作。
我们将重点关注两类质量属性。第一类包括那些描述系统运行时某些特性的属性,比如可用性、性能或易用性。第二类包括那些描述系统开发过程中某些特性的属性,比如可修改性、可测试性或可部署性。
质量属性绝不可能孤立地实现。实现任何一个质量属性都会对其他质量属性的实现产生影响 —— 有时是积极影响,有时是消极影响。例如,几乎每一个质量属性都会对性能产生负面影响。以可移植性为例:实现可移植软件的主要技术是隔离系统依赖项,这会给系统的执行引入开销,通常体现为进程或程序边界,进而影响性能。确定一个可能满足质量属性需求的设计,在一定程度上是要进行适当权衡的问题;我们会在 [第 21 章][ch21] 中讨论设计相关内容。
在接下来的三节中,我们将重点关注质量属性如何能够被明确规定、哪些架构决策能够促成特定质量属性的实现,以及关于质量属性的哪些问题能够让架构师做出正确的设计决策。
3.3 明确质量属性需求:质量属性场景
我们采用一种通用形式将所有质量属性(QA)需求规定为场景形式。这解决了我们之前提到的术语问题。这种通用形式是可测试且明确无误的;它不受分类随意性的影响。因此,它为我们处理所有质量属性的方式提供了规范性。
质量属性场景包含六个部分:
- 触发事件(Stimulus):我们使用 “触发” 这一术语来描述作用于系统或项目的一个事件。对于性能相关群体而言,触发可以是一个 “事件”;对于易用性相关群体来说,它可以是一个 “用户操作”;对于安全性相关群体,它则可以是一次 “攻击”,等等。我们用同一个术语来描述引发开发质量相关情况的触发性动作。因此,对于可修改性而言,触发就是一项修改请求;对于可测试性来说,触发就是一个开发单元的完成。
- 触发源(Stimulus source):一个触发源必须有其来源 —— 它必定来自某个地方。某个实体(人、计算机系统或任何其他参与者)必定产生了这个触发源。触发源的来源可能会影响系统对其的处理方式。来自可信用户的请求不会像来自不可信用户的请求那样受到严格审查。
- 响应(Response):响应是触发源出现后所产生的活动。响应是架构师为满足需求而采取的行为。它由系统(针对运行时质量属性)或开发人员(针对开发时质量属性)为响应触发源而应履行的职责构成。例如,在性能场景中,一个事件发生(触发源),系统就应该处理该事件并生成响应。在可修改性场景中,一项修改请求出现(触发源),开发人员就应该实施修改(且无副作用)然后对修改进行测试和部署。
- 响应度量(Response measure):当响应发生时,它应该能以某种方式进行度量,以便该场景可以被测试 —— 也就是说,以便我们能够确定架构师是否达成了相应目标。对于性能而言,这可以是延迟或吞吐量的度量;对于可修改性来说,它可以是进行修改、测试以及部署修改所需的人力或实际耗时。
场景的这四个特征是我们质量属性规范的核心内容。但还有另外两个重要却常被忽视的特征:环境和作用对象。
- 环境(Environment):环境是场景发生所处的一系列情形。它通常指的是一种运行时状态:系统可能处于过载状态、正常运行状态或其他相关状态。对于许多系统来说,“正常” 运行可以指多种模式中的某一种。对于这类系统,环境应该明确说明系统正在执行的是哪种模式。但环境也可以指系统根本未运行时的状态:比如处于开发阶段、测试阶段、数据刷新阶段,或者在运行间隙进行电池充电阶段。环境为场景的其余部分设定了背景。例如,在代码为某次发布而冻结之后收到的修改请求,其处理方式可能与冻结之前收到的请求不同。某个组件出现的第五次连续故障,其处理方式可能与该组件首次出现故障时不同。
- 作用对象(Artifact):触发作用于某个目标。这通常仅被视作系统或项目本身,但如果可能的话,更精确一些会更有帮助。作用对象可能是一组系统、整个系统,或者系统的一个或多个部分。失效或变更请求可能只影响系统的一小部分。数据存储中的失效与元数据存储中的失效处理方式可能不同。对用户界面的修改可能比对中间件的修改响应速度更快。
总而言之,我们将质量属性需求以场景形式(包含六个部分)呈现出来。虽然在考虑质量属性的早期阶段,通常会省略这六个部分中的一个或多个部分,但知晓所有这些部分的存在会促使架构师去考虑每个部分是否相关。
我们为 [第 4 章][ch04] 至 [第 13 章][ch13] 中介绍的每个质量属性都创建了一个通用场景,以方便进行头脑风暴以及引出具体场景。我们区分了 “通用” 质量属性场景(通用场景)—— 它们与具体系统无关,可适用于任何系统,以及 “具体” 质量属性场景(具体场景)—— 它们特定于正在考虑的具体系统。
要将这些通用属性特征转化为针对特定系统的需求,就需要使通用场景具体化到特定系统上。但正如我们所发现的,对于利益相关者来说,将通用场景调整为适合其系统的场景,要比凭空生成一个场景容易得多。
[图 3.1][ch03fig01] 展示了刚刚讨论过的质量属性场景的各个部分。[图 3.2][ch03fig02] 展示了一个通用场景的示例,这里是以可用性为例的。


与我无关
不久前,我正在对劳伦斯利弗莫尔国家实验室自行创建并供其内部使用的一个复杂系统进行架构分析。如果你访问该机构的网站(llnl.gov),并试图弄清楚利弗莫尔实验室的业务范围,你会反复看到 “安全” 这个词。该实验室专注于核安全、国际与国内安全以及环境和能源安全等重要事务……
考虑到他们对安全的重视,我请我的客户描述一下我正在分析的这个系统所涉及的质量属性。我敢肯定你能想象到我当时有多惊讶,因为他们一次都没提到 “安全”!系统的利益相关者提到了性能、可修改性、可演进性、互操作性、可配置性和可移植性等,还有另外一两个属性,但就是没提到 “安全” 这个词。
作为一名优秀的分析师,我对这个看似令人震惊又明显的遗漏提出了质疑。他们的回答很简单,现在回想起来也很直白:“我们不关心这个。我们的系统没有连接到任何外部网络,而且我们有带刺铁丝网围栏,还有持枪警卫呢。”
当然,利弗莫尔实验室里肯定有人对安全问题非常关注。但软件架构师们并不关心。这里得到的教训就是,软件架构师可能并不需要对每一项质量属性(QA)需求都承担责任。
—RK
3.4 通过架构模式和策略实现质量属性
现在我们来探讨架构师可用于 “实现” 所需质量属性的技术:架构模式和策略。
策略是一种影响质量属性响应达成的设计决策 —— 它直接影响系统对某些触发的响应。策略可能会给一种设计赋予可移植性,给另一种设计带来高性能,给第三种设计提供可集成性。
架构模式描述了在特定设计情境中反复出现的特定设计问题,并针对该问题给出了经充分验证的架构解决方案。该解决方案通过描述其组成元素的角色、它们的职责与关系以及它们相互协作的方式来明确。与策略的选择一样,架构模式的选择对质量属性(通常是多个质量属性)有着深远的影响。
模式通常包含多个设计决策,实际上,往往还包含多个质量属性策略。我们说模式常常将策略捆绑在一起,因此也常常会在质量属性之间进行权衡。
在我们各章针对特定质量属性的内容中,我们会查看策略与模式之间的示例关系。[第 14 章][ch14] 解释了如何构建针对任意质量属性的一组策略;实际上,这些策略就是我们用来生成本书中所呈现的各策略集的步骤。
虽然我们在讨论模式和策略时,仿佛它们是基础性的设计决策,但实际情况是,架构往往是诸多小决策和业务因素共同作用的结果而逐渐形成并演进的。例如,一个原本具有一定可修改性的系统,随着开发人员添加功能和修复漏洞等操作,可能会随着时间的推移而逐渐变差。同样,系统的性能、可用性、安全性以及任何其他质量属性都可能(而且通常确实)会随着时间的推移而变差,这同样是由于那些专注于眼前任务而不注重维护架构完整性的程序员的善意之举造成的。
这种 “千刀万剐式死亡”(逐渐恶化)在软件项目中很常见。开发人员可能由于对系统结构缺乏了解、进度压力,或者一开始架构就不够清晰等原因而做出次优决策。这种恶化是一种被称为架构债务的技术债务形式。我们会在 [第 23 章][ch23] 中讨论架构债务。要扭转这种债务局面,我们通常会进行重构。
进行重构可能有诸多原因。例如,你可能会重构一个系统以提高其安全性,根据不同模块的安全属性将它们置于不同的子系统中。或者你可能会重构一个系统以提高其性能,消除瓶颈并重写代码中运行缓慢的部分。又或者你可能会重构以提高系统的可修改性。例如,当两个模块因为彼此(至少部分)重复而反复受到相同类型的变更影响时,可以将公共功能提取出来形成一个独立的模块,从而提高内聚性,并减少下一次(类似的)变更请求到来时需要修改的地方。
代码重构是敏捷开发项目中的一项主要实践活动,作为一种清理步骤,以确保团队不会生成重复或过于复杂的代码。不过,这一概念同样适用于架构元素。
要成功实现质量属性,除了与架构相关的决策外,往往还涉及与流程相关的决策。例如,如果你的员工容易受到网络钓鱼攻击或者不选用强密码,那么再好的安全架构也毫无价值。我们在本书中不涉及流程方面的内容,但要知道它们很重要。
3.5 运用策略进行设计
系统设计由一系列决策构成。其中一些决策有助于控制质量属性响应;其他决策则确保系统功能的实现。我们在 [图 3.3][ch03fig03] 中展示了这种关系。与模式一样,策略也是架构师多年来一直在使用的设计技术。在本书中,我们对它们进行了梳理、分类并加以描述。我们并非在此创造策略,而只是对优秀架构师在实践中的做法进行总结归纳。

我们为何要关注策略呢?原因有三:
- 模式是许多架构的基础,但有时可能并不存在能完全解决你问题的模式。例如,你可能需要高可用性、高安全性的代理模式,而不是教科书式的常规代理模式。架构师经常需要根据特定的应用场景对模式进行修改和调整,而策略为扩充现有模式以填补空白提供了一种系统性的方法。
- 如果不存在可实现架构师设计目标的模式,策略能让架构师依据 “基本原理” 构建出一个设计片段。策略能帮助架构师深入了解所生成设计片段的特性。
- 策略在一定限制条件下为使设计和分析更具系统性提供了一种途径。我们将在下一节探讨这一理念。
与任何设计概念一样,我们在此介绍的策略在应用于系统设计时可以而且应该进行细化。以性能为例:“资源调度” 是一种常见的性能策略。但为了特定目的,这一策略需要细化为具体的调度策略,比如最短作业优先、轮询等等。“使用中间件” 是一种可修改性策略。但中间件有多种类型(仅举几例,如分层、代理、代理服务器、层级结构等),它们的实现方式各不相同。因此,设计人员会进行细化,使每个策略变得具体。
此外,策略的应用取决于具体情境。还是以性能为例:“管理采样率” 在某些实时系统中是适合的,但并非在所有实时系统中都适合,在数据库系统或股票交易系统中肯定不适合,因为在这些系统中丢失单个事件都会造成很大问题。
请注意,存在一些 “超级策略”—— 这些策略非常基础且应用极为广泛,值得特别提及。例如,封装、限制依赖关系、使用中间件以及抽象出公共服务这些可修改性策略,几乎在以往实现的每一种模式中都能见到!但其他策略,比如来自性能方面的调度策略,也会在很多地方出现。例如,负载均衡器就是一种进行调度的中间件。我们发现监控会出现在许多质量属性中:我们对系统的各方面进行监控以实现能效、性能、可用性以及安全性等目标。因此,我们不应期望一个策略仅适用于一处,仅针对单一的质量属性。策略是设计的基本要素,正因如此,它们会在设计的不同方面反复出现。这实际上也说明了策略为何如此强大,值得我们以及你们去关注。去了解它们吧,它们会成为你的好帮手。
3.6 质量属性设计决策分析:基于策略的调查问卷
在本节中,我们将介绍一种分析人员可用来了解架构设计各个阶段潜在质量属性表现的工具:基于策略的调查问卷。
分析质量属性的实现程度是架构设计任务中的一个关键部分。(毫不意外的是)你不应等到设计完成后才开始进行此项分析。在软件开发生命周期的许多不同阶段,甚至是在非常早期的阶段,都会出现进行质量属性分析的机会。
在任何阶段,分析人员(可能就是架构师本人)都需要对可供分析的各类成果做出恰当的回应。分析的准确性以及对分析结果的预期置信度会根据现有成果的成熟度而有所不同。但无论设计处于何种状态,我们都发现基于策略的调查问卷有助于深入了解架构提供所需质量属性的能力(或者说是随着不断完善而可能具备的能力)。
在 [第 4 章][ch04] 至 [第 13 章][ch13] 中,我们针对各章所涵盖的每个质量属性都包含了一份基于策略的调查问卷。对于调查问卷中的每个问题,分析人员需记录以下信息:
- 系统架构是否支持各项策略。
- 使用(或未使用)该策略是否存在任何明显风险。如果使用了该策略,则记录它在系统中是如何实现的,或者打算如何实现(例如,通过自定义代码、通用框架或外部生成的组件来实现)。
- 为实现该策略所做的具体设计决策,以及在代码库中的什么位置可以找到该实现(具体落实情况)。这对于审计和架构重构很有用处。
- 在实现该策略过程中所依据的任何理由或假设。
要使用这些调查问卷,只需遵循以下四个步骤:
- 对于每个策略相关问题,如果架构支持该策略,则在 “支持” 栏中填 “Y”,否则填 “N”。
- 如果 “支持” 栏中的答案是 “Y”,那么就在 “设计决策与位置” 栏中描述为支持该策略所做的具体设计决策,并列举出这些决策在架构中体现(所处)的位置。例如,指明哪些代码模块、框架或软件包实现了该策略。
- 在 “风险” 栏中,使用(高 = H,中 = M,低 = L)等级来标明实施该策略的风险。
- 在 “理由” 栏中,描述所做设计决策的理由(包括决定不使用该策略的理由)。简要解释该决策的影响。例如,从成本、进度、演进等方面的影响来解释决策的理由和影响。
虽然这种基于调查问卷的方法听起来可能比较简单,但实际上它可能非常有效且能给人启发。回答这一系列问题会促使架构师退后一步,从更宏观的角度去思考问题。这个过程也可以相当高效:一份针对单个质量属性的典型调查问卷通常需要 30 到 90 分钟来完成。
3.7 小结
通过在设计中纳入一组恰当的职责来满足 “功能” 需求。通过架构的结构和行为来满足 “质量属性” 需求。
架构设计中的一个挑战在于,这些需求往往即便有被记录下来,记录的情况也很不理想。为了获取并表述质量属性需求,我们建议使用质量属性场景。每个场景由以下六个部分组成:
- 触发来源
- 触发事件
- 环境
- 作用对象
- 响应
- 响应度量
架构策略是一种会影响质量属性响应的设计决策。策略的关注点在于单个质量属性响应。架构模式描述了在特定设计情境中反复出现的特定设计问题,并针对该问题给出了经充分验证的架构解决方案。架构模式可被视为策略的 “集合体”。
分析人员可以通过使用基于策略的检查表来了解架构中所做的决策。这种轻量级的架构分析技术能够在很短的时间内洞察架构的优势与劣势。
3.8 扩展阅读
一些展示了在设计中如何运用策略和模式的扩展案例研究可在 [[Cervantes 16][ref_54]] 中找到。
在由弗兰克・布施曼(Frank Buschmann)等人所著的五卷本《面向模式的软件架构》(Pattern-Oriented Software Architecture)中,可以找到大量的架构模式目录。
能够表明许多不同架构可以提供相同功能的论据 —— 也就是说,架构和功能在很大程度上是相互正交的论据 —— 可在 [[Shaw 95][ref_230]] 中找到。
3.9 问题讨论
1. 用例与质量属性场景之间的关系是什么?如果你想在用例中添加质量属性信息,你会怎么做?
2. 你认为针对某一质量属性的策略集合是有限的还是无限的?为什么?
3. 列举自动柜员机(ATM)应支持的职责集合,并提出一个能适应该职责集合的设计方案。对你的方案进行论证。
4. 选择一个你熟悉的架构(或者选择你在问题 3 中定义的自动柜员机架构),然后梳理一遍性能策略调查问卷(可在 [第 9 章][ch09] 中找到)。这些问题对你所做(或未做)的设计决策提供了怎样的洞察?
第4章 可用性
技术并不总是与完美和可靠性相契合。 实际上,二者相差甚远!
——让-米歇尔·雅尔(Jean-Michel Jarre)
可用性是指软件的一种特性,即当你需要软件执行任务时,它能够正常运行并随时准备执行任务。这是一个宽泛的概念,涵盖了通常所说的可靠性(尽管它可能还包含诸如定期维护导致的停机时间等其他考量因素)。可用性基于可靠性的概念,增加了恢复这一概念,也就是说,当系统出现故障时,它能够自我修复。正如我们将在本章中看到的那样,修复可以通过多种方式实现。
可用性还包括系统屏蔽或修复故障的能力,使故障不至于演变成失效,从而确保在特定时间间隔内累计服务中断时长不超过规定值。这一定义包含了可靠性、健壮性以及任何涉及不可接受失效概念的其他质量属性相关概念。
失效是指系统偏离其规格说明的情况,且这种偏离是外部可见的。要确定失效已经发生,需要环境中有外部观察者来判断。
失效的原因被称为 “故障”。故障可以是所考虑系统的内部故障,也可以是外部故障。从故障发生到失效发生之间的中间状态被称为错误。故障可以被预防、容忍、排除或预测。通过这些措施,系统能够对故障具备 “弹性”。我们所关注的方面包括系统故障如何被检测、系统故障可能多频繁地发生、故障发生时会出现什么情况、允许系统停止运行多长时间、故障或失效在什么情况下可以安全发生、如何预防故障或失效以及失效发生时需要什么样的通知等。
可用性与安全性密切相关,但又明显有别。拒绝服务攻击的明确目的就是使系统失效,也就是让系统变得不可用。可用性也与性能密切相关,因为可能很难判断系统是已经失效了,还是仅仅响应极其缓慢。最后,可用性与安全性紧密相连,安全性关注的是防止系统进入危险状态,以及在系统进入危险状态时进行恢复或限制损害。
构建高可用性容错系统时,最具挑战性的任务之一就是了解系统运行过程中可能出现的失效的性质。一旦了解了这些,就可以在系统中设计相应的缓解策略。
由于系统失效是用户可观察到的,修复时间就是直到失效不再能被观察到所花费的时间。这可能是用户响应时间中难以察觉的延迟,也可能是某人要飞到安第斯山脉的偏远地区去维修一台采矿机械所花费的时间(这是一位负责维修采矿机引擎软件的人员向我们讲述的事例)。这里 “可观察性” 的概念至关重要:如果一个失效 “本可以被观察到”,那么它就是失效,无论实际上是否被观察到。
此外,我们通常还关注系统在发生失效后仍保留的功能水平,即降级运行模式。
区分故障和失效使我们能够讨论修复策略。如果包含故障的代码被执行了,但系统能够从故障中恢复,且没有出现与原本规定行为有任何可观察到的偏离,我们就说没有发生失效。
系统的可用性可以通过在特定时间间隔内,系统在规定界限内提供规定服务的概率来衡量。有一个广为人知的表达式可用于推导稳态可用性(该表达式源于硬件领域):
MTBF / (MTBF + MTTR)
其中,MTBF指平均故障间隔时间,MTTR指平均修复时间。在软件领域,应该这样解读这个公式:在考虑可用性时,应当思考是什么会导致系统出现故障、此类事件发生的可能性有多大以及修复故障需要多长时间。
依据这个公式,可以计算概率,并做出诸如 “系统具备 99.999% 的可用性” 或者 “系统在需要时无法运行的概率为 0.001%” 之类的论断。在计算可用性时,不应考虑计划内停机时间(即系统有意停止服务的时间),因为那时系统被视为 “不需要运行”;当然,这取决于系统的具体需求,这些需求通常会在服务水平协议(SLA)中明确规定。这可能会导致一些看似奇怪的情况,比如系统停机了,用户正在等待其恢复运行,但由于停机是计划内的,所以不会计入任何可用性要求的考量范围。
检测到的故障在上报和修复之前可以进行分类。这种分类通常基于故障的严重程度(严重、较严重或轻微)以及对服务的影响(影响服务或不影响服务)。它能为系统操作员提供及时且准确的系统状态信息,并便于采用恰当的修复策略。修复策略可能是自动化的,也可能需要人工干预。
如前文所述,系统或服务所期望的可用性通常会通过服务水平协议(SLA)来表述。服务水平协议明确了所保证的可用性级别,并且通常还规定了如果违反该协议,服务提供商将受到的惩罚。例如,亚马逊为其亚马逊弹性计算云(EC2)服务提供了如下服务水平协议:
亚马逊网络服务(AWS)将尽商业上合理的努力,使所包含的各项服务在每个亚马逊网络服务区域内每月的正常运行时间百分比至少达到 99.99%,在每个月度计费周期内均如此(即 “服务承诺”)。如果所包含的任何服务未达到服务承诺,你将有资格按如下所述获得服务补偿。
表 4.1 列举了系统可用性要求的示例以及在 90 天和 1 年观测期内可接受的系统停机时间的相关阈值。“高可用性” 一词通常指以 99.999%(“五个 9”)或更高可用性为目标的设计。如前文所述,只有非计划内的停机才会计入系统停机时间。
| 可用性 | 停机时间/90 天 | 停机时间/年 |
|---|---|---|
| 99.0% | 21小时36分 | 3天15.6小时 |
| 99.9% | 2小时10分 | 8小时45分 |
| 99.99% | 12分58秒 | 52分34秒 |
| 99.999% | 1分18秒 | 5分15秒 |
| 99.9999% | 8秒 | 32秒 |
4.1 可用性通用场景
现在我们可以对 表 4.2 中所总结的可用性通用场景的各个部分进行描述。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 这规定了故障的来源。 | 内部/外部:人员、硬件、软件、物理基础设施、物理环境 |
| 触发事件 | 可用性场景中的触发事件是故障。 | 故障:遗漏、崩溃、时间错误、响应错误 |
| 构件 | 这明确了系统的哪些部分应对该故障负责以及会受到该故障的影响。 | 处理器、通信通道、存储设备、进程、系统环境中受影响的构件。 |
| 环境 | 我们可能不仅对系统在其“正常”环境中的运行表现感兴趣,而且对它在诸如已经从故障中恢复时等情况下的运行表现也感兴趣。 | 正常运行、启动、关机、修复模式、降级运行、过载运行 |
| 响应 | 最常见的期望响应是防止故障演变成失效,但其他响应可能也很重要,比如通知相关人员或记录故障以便后续分析。本节明确了期望的系统响应。 | 防止故障演变成失效 - 检测故障: - 记录故障 - 通知相关实体(人员或系统) - 从故障中恢复 - 禁用导致故障的事件源 - 在进行修复时暂时不可用 - 修复或屏蔽故障/失效,或控制其造成的损害 - 在进行修复时以降级模式运行 - 系统必须可用的时间或时间区间 |
| 响应度量 | 根据所提供服务的关键程度,我们可能会重点关注多项可用性指标。 | - 可用性百分比(例如,99.999%) - 故障检测时间 - 故障修复时间 - 系统可处于降级模式的时间或时间区间 - 系统能够预防或在不出现失效的情况下处理某类故障的比例(例如,99%)或速率(例如,每秒最多100次) |
图4.1展示了一个源自表4.2中通用场景的具体可用性场景示例。该场景如下:服务器集群中的一台服务器在正常运行期间出现故障,系统会通知操作员,并且能够持续运行,无停机时间。

4.2 可用性策略
当系统不再提供与其规格说明相符的服务,且这种失效情况能被系统相关参与者观察到时,就会发生失效。故障(或多个故障的组合)有可能导致失效。相应地,可用性策略旨在使系统能够预防或承受系统故障,从而使系统所提供的服务始终符合其规格要求。我们在本节中讨论的策略将防止故障演变成失效,或者至少限制故障的影响,并使修复成为可能,如 图 4.2 所示。

可用性策略有三个目的之一:故障检测、故障恢复或故障预防。可用性策略如 图 4.3 所示。这些策略通常会由软件基础设施(如中间件包)来提供,所以作为架构师,你的工作可能是选择并评估(而非实现)正确的可用性策略以及策略的正确组合。

检测故障
在任何系统能够针对故障采取行动之前,必须先检测到故障的存在或者对其有所预见。这一类别的策略包括:
- 监控(Monitor):该组件用于监控系统中其他各个部分的健康状态,包括处理器、进程、输入 / 输出、内存等等。系统监控器能够检测网络或其他共享资源中的失效或拥塞情况,例如检测是否遭受拒绝服务攻击。它会协调使用此类别的其他策略的软件,以检测出现故障的组件。例如,系统监控器可以启动 “自检(self-tests)”,或者成为检测有故障的 “时间戳(timestamps)” 或丢失的 “心跳(heartbeats)” 的组件。^1
- Ping / echo:在此策略中,节点之间会交换异步请求 / 响应消息对,用于确定可达性以及通过相关网络路径的往返延迟。此外,echo表明被 Ping 的组件处于运行状态。Ping 通常由系统监控器发送。Ping / echo 需要设置一个时间阈值,该阈值告知发送 Ping 的组件在判定被 Ping 的组件出现故障(“超时”)之前需要等待echo的时长。通过互联网协议(IP)互联的节点有标准的 Ping / echo 实现方式。
- 心跳(Heartbeat):这种故障检测机制采用系统监控器与被监控进程之间周期性的消息交换方式。心跳的一种特殊情况是,被监控的进程会定期重置其监控器中的看门狗定时器,以防止定时器到期进而发出故障信号。对于关注可扩展性的系统,可以通过将心跳消息附加到正在交换的其他控制消息上来减少传输和处理开销。心跳与 Ping / echo 的区别在于由谁负责发起健康检查 —— 是监控器还是组件自身。
- 时间戳(Timestamp):该策略用于检测事件的不正确顺序,主要应用于分布式消息传递系统中。事件的时间戳可以通过在事件发生后立即将本地时钟的状态赋予该事件来确定。序列号也可用于此目的,因为分布式系统中的时间戳在不同处理器上可能不一致。有关分布式系统中时间主题的更全面讨论,请参见 第 17 章。
- 状态监测(Condition monitoring):该策略涉及检查进程或设备中的状态,或者验证设计过程中所做的假设。通过监测状态,此策略可防止系统产生错误行为。校验和的计算就是这种策略的常见示例。然而,监控器本身必须简单(理想情况下,应能被证明是正确的),以确保它不会引入新的软件错误。
- 合理性检查(Sanity checking):该策略检查组件特定操作或输出的有效性或合理性。它通常基于对内部设计、系统状态或所审查信息的性质的了解。它最常用于接口处,用于检查特定信息流。
-
表决(Voting):表决涉及对多个本应产生相同结果的来源的计算结果进行比较,如果结果不一致,则决定采用哪些结果。该策略关键取决于表决逻辑,表决逻辑通常实现为一个简单、经过严格审查和测试的单例,以便降低出错概率。表决还关键取决于要有多个可评估的来源。典型方案如下:
- 副本(Replication):这是表决的最简单形式,在此形式下,各组件彼此完全相同。拥有多个相同组件的副本对于防范硬件的随机故障可能很有效,但无法防范硬件或软件中的设计或实现错误,因为该策略中没有嵌入任何形式的多样性。
- 功能冗余(Functional redundancy):相反,该策略旨在通过实现设计多样性来解决硬件或软件组件中的共模故障问题(即副本由于共享相同的实现而同时出现相同故障)。此策略试图通过为冗余增加多样性来应对设计故障的系统性本质。在给定相同输入的情况下,功能冗余组件的输出应该相同。功能冗余策略仍然容易受到规格说明错误的影响 —— 当然,开发和验证功能副本的成本也会更高。
- 解析冗余(Analytic redundancy):该策略不仅允许组件私有部分之间存在多样性,还允许组件的输入和输出之间存在多样性。此策略旨在通过使用独立的需求规格说明来容忍规格说明错误。在嵌入式系统中,当某些输入源有时可能不可用时,解析冗余会有所帮助。例如,航空电子程序有多种计算飞机高度的方法,比如利用气压、雷达高度计以及通过几何方法(利用地面前方某点的直线距离和俯视角)来计算。与解析冗余一起使用的表决机制需要比简单的多数决定或计算简单平均值更为复杂。它可能需要了解哪些传感器当前是可靠的(或不可靠的),并且可能需要通过随时间对各个值进行混合和平滑处理来生成比任何单个组件所能提供的更高保真度的值。
-
异常检测(Exception detection):该策略侧重于检测改变正常执行流程的系统状况。它可进一步细化如下:
- 系统异常(System exceptions):会因所采用的处理器硬件架构不同而有所变化。它们包括诸如除以零、总线和地址故障、非法程序指令等故障。
- 参数边界(Parameter fence):该策略在对象的任何可变长度参数之后立即放置一个已知的数据模式(如 0xDEADBEEF),这样就可以在运行时检测到对分配给对象可变长度参数的内存的覆盖情况。
- 参数类型(Parameter typing):采用一个基类,该基类定义了用于添加、查找以及遍历类型 - 长度 - 值(TLV)格式消息参数的函数。派生类使用基类函数来提供构建和解析消息的函数。参数类型的使用确保消息的发送方和接收方就内容类型达成一致,并能检测出双方不一致的情况。
- 超时(Timeout):该策略是指当一个组件检测到它自身或另一个组件未能满足其时间约束时就会引发异常。例如,一个正在等待另一个组件响应的组件,如果等待时间超过某个值,就可以引发异常。
- 自检(Self-test):组件(或者更有可能是整个子系统)可以运行一些程序来测试自身是否能正确运行。自检程序可以由组件自身启动,也可以由系统监控器不时调用。这些程序可能会涉及使用状态监测中发现的一些技术,比如校验和。
从故障中恢复
“从故障中恢复” 策略可细分为准备及修复策略和重新引入策略。后者涉及将出现故障(但已修复)的组件重新引入正常运行状态。
准备及修复策略基于重试计算或引入冗余的多种组合方式:
-
冗余备件:该策略指的是一种配置,即如果主组件出现故障,一个或多个备用组件可以介入并接管工作。此策略是热备、温备和冷备模式的核心,这些模式的主要区别在于备份组件在接管时其数据的更新程度。
-
回滚:回滚允许系统在检测到失效时恢复到之前已知的良好状态(称为 “回滚线”)—— 即时光倒流。一旦达到良好状态,执行就可以继续。该策略通常与事务策略以及冗余备件策略相结合,以便在回滚发生后,将故障组件的备用版本提升为活动状态。回滚依赖于正在回滚的组件能够获取之前良好状态的副本(检查点)。检查点可以存储在固定位置,并按固定间隔更新,或者在处理过程中的方便或关键时间点进行更新,比如在完成一项复杂操作时。
-
异常处理:一旦检测到异常,系统将以某种方式对其进行处理。最简单的做法就是直接崩溃 —— 但当然,从可用性、易用性、可测试性以及常理角度来看,这是个糟糕的主意。还有更有效的处理方式。所采用的异常处理机制在很大程度上取决于所使用的编程环境,从简单的函数返回代码(错误代码)到使用包含有助于故障关联信息(如异常名称、异常来源和异常原因)的异常类不等。软件随后可以利用这些信息来屏蔽或修复故障。
-
软件升级:该策略的目标是以不影响服务的方式对可执行代码镜像进行在线升级。相关策略包括以下几种:
- 函数补丁:这种在过程式编程中使用的补丁,利用增量链接器 / 加载器将更新后的软件函数存储到目标内存的预分配段中。软件函数的新版本将使用被弃用函数的入口和出口点。
- 类补丁:这种升级适用于执行面向对象代码的目标,其中类定义包含一种后门机制,可实现在运行时添加成员数据和函数。
- 无损在线软件升级(ISSU):这借助冗余备件策略来实现对软件及相关架构不影响服务的升级。
在实际应用中,函数补丁和类补丁用于修复漏洞,而无损 ISSU 则用于提供新特性和功能。
-
重试:重试策略假定导致失效的故障是临时性的,重新尝试操作可能会成功。它常用于网络和服务器集群中,这些地方预计会出现失效且失效较为常见。在判定为永久性失效之前,应当对尝试重试的次数设置限制。
-
忽略故障行为:当我们确定来自特定来源的消息是虚假的时,该策略要求忽略这些消息。例如,我们希望忽略来自传感器实时失效产生的消息。
-
优雅降级:该策略在组件出现失效的情况下维持最关键的系统功能,同时舍弃不太关键的功能。这适用于个别组件失效会平稳降低系统功能,而非导致整个系统失效的情况。
-
重新配置:重新配置试图通过将职责重新分配给仍在运行的(可能有限的)资源或组件,同时尽可能维持更多功能,以此从失效中恢复。
重新引入策略,当一个出现故障的组件在修复后被重新引入时,就会涉及重新引入策略。重新引入策略包括以下几种:
- 影子模式:该策略指的是让之前出现故障或进行了在线升级的组件在恢复到活动角色之前,以 “影子模式” 运行一段预先定义的时间。在此期间,可以对其行为的正确性进行监控,并且它可以逐步重新填充自身状态。
- 状态再同步:这种重新引入策略是冗余备件策略的辅助策略。当与主动冗余(冗余备件策略的一种形式)一起使用时,由于主动和备用组件各自并行接收并处理相同的输入,状态再同步会自然发生。实际上,主动和备用组件的状态会定期进行比较以确保同步。这种比较可能基于循环冗余校验计算(校验和),或者对于提供安全关键服务的系统,基于消息摘要计算(单向哈希函数)。当与冗余备件策略的被动冗余版本一起使用时,状态再同步仅仅基于从主动组件定期向备用组件传输的状态信息,通常是通过检查点的方式。
- 逐步重启:这种重新引入策略允许系统通过改变重启组件的粒度并尽量减小对服务的影响程度,从而从故障中恢复。例如,考虑一个支持四级重启(编号为 0 - 3)的系统。最低级别的重启(0 级)对服务的影响最小,采用被动冗余(温备)方式,即故障组件的所有子线程会被终止并重新创建。这样,只有与子线程相关的数据会被释放并重新初始化。下一级别的重启(1 级)会释放并重新初始化所有未受保护的内存,受保护的内存则不受影响。再下一级别的重启(2 级)会释放并重新初始化所有内存,包括受保护和未受保护的内存,迫使所有应用程序重新加载并重新初始化。最高级别的重启(3 级)涉及完全重新加载并重新初始化可执行镜像以及相关的数据段。对逐步重启策略的支持对于优雅降级概念尤为有用,在这种情况下,系统能够在维持对关键任务或安全关键应用程序支持的同时,降低其提供的服务级别。
- 不间断转发:这一概念源于路由器设计,它假定功能分为两部分:管理平面或控制平面(负责管理连接性和路由信息)和数据平面(负责实际执行将数据包从发送方路由到接收方的工作)。如果路由器的活动管理组件出现故障,它可以在路由协议信息恢复和验证期间,与相邻路由器一起沿着已知路由继续转发数据包。当控制平面重启时,它会实现 “优雅重启”,在数据平面继续运行的同时逐步重建其路由协议数据库。
预防故障
与其先检测故障然后再尝试从故障中恢复,要是你的系统一开始就能防止故障发生,那会怎样呢?尽管听起来似乎需要某种预见能力,但事实证明,在很多情况下确实可以做到这一点。^2
- 停用服务:该策略指的是为了减轻潜在的系统失效,暂时将系统组件置于停用状态。例如,系统的某个组件可能会被停用并重置,以清除潜在故障(比如内存泄漏、内存碎片,或者未受保护缓存中的软错误),避免故障积累到影响服务的程度,进而导致系统失效。这一策略的其他叫法还有 “软件再生” 和 “修复性重启”。如果你每晚都重启电脑,那就是在运用停用服务这一策略。
- 事务处理:旨在提供高可用性服务的系统利用事务语义来确保分布式组件之间交换的异步消息具备原子性、一致性、隔离性和持久性 —— 这些特性统称为 “ACID 特性”。事务处理策略最常见的实现方式是 “两阶段提交”(2PC)协议。该策略可防止因两个进程试图同时更新同一数据项而引发的竞争条件。
- 预测模型:预测模型与监控器相结合,用于监控系统进程的健康状态,以确保系统在其正常运行参数范围内运行,并在系统接近临界阈值时采取纠正措施。所监控的运行性能指标用于预测故障的发生,示例包括会话建立速率(在 HTTP 服务器中)、阈值跨越情况(对某些受限的共享资源监控高低水位线)、进程状态统计信息(例如,正在服务、停用、维护中、空闲)以及消息队列长度统计信息。
- 异常预防:该策略指的是用于防止系统异常发生的技术。前面已经讨论过使用异常类,它能让系统从系统异常中透明地恢复。异常预防的其他示例包括纠错码(用于电信领域)、抽象数据类型(如智能指针),以及使用包装器来防止悬空指针或信号量访问违规等故障。智能指针通过对指针进行边界检查,并确保在无数据引用资源时自动释放资源,从而避免资源泄漏,以此来预防异常。
- 扩大能力集:程序的能力集是指它能够 “胜任” 运行的状态集合。例如,分母为零的状态超出了大多数除法程序的能力集范围。当一个组件抛出异常时,这意味着它发现自己处于能力集之外了;本质上就是它不知道该怎么做,于是放弃了。扩大组件的能力集意味着将其设计为能在正常运行过程中处理更多情况 —— 也就是故障。例如,一个假定能够访问共享资源的组件,如果发现访问被阻止,可能就会抛出异常。而另一个组件可能只是等待访问,或者立即返回并提示下次能够访问时会自行完成操作。在这个例子中,第二个组件的能力集比第一个组件的更大。
4.3 基于可用性策略的调查问卷
基于 4.2 节 中所述的策略,我们可以创建一组受可用性策略启发的问题,如 表 4.3 所示。为了全面了解为支持可用性而做出的架构选择,分析人员会提出每个问题,并将答案记录在表格中。然后,这些问题的答案可成为进一步活动的重点,例如文档调研、代码或其他构件分析、代码逆向工程等等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策与定位 | 推理和假设 |
|---|---|---|---|---|---|
| 检测故障 | 系统是否使用Ping/echo来检测组件或连接失效,又或者网络拥塞情况? | ||||
| 系统是否使用某个组件来监控系统其他部分的健康状态?系统监控器能够检测网络或其他共享资源中的失效或拥塞情况,例如检测是否遭受拒绝服务攻击。 | |||||
| 系统是否使用**心跳(heartbeat)**机制(即系统监控器与某个进程之间周期性的消息交换)来检测组件或连接失效,又或者网络拥塞情况? | |||||
| 系统是否使用**时间戳(timestamp)**来检测分布式系统中事件的不正确顺序? | |||||
| 系统是否使用**表决(voting)**机制来检查复制的组件是否产生相同的结果?这些复制的组件可能是完全相同的副本、功能冗余组件,也可能是解析冗余组件。 | |||||
| 系统是否使用异常检测来检测改变正常执行流程的系统状况(例如系统异常、参数边界、参数类型、超时)? | |||||
| 系统能否进行自检以检测自身是否能正常运行? | |||||
| 从故障中恢复(准备及修复) | 系统是否采用冗余备件?组件作为活动组件还是备用组件的角色是固定的,还是会在出现故障时发生变化?切换机制是什么?切换的触发条件是什么?备用组件承担其职责需要多长时间? | ||||
| 系统是否采用异常处理机制来应对故障?通常,这种处理涉及报告、纠正或屏蔽故障。 | |||||
| 系统是否采用回滚机制,以便在出现故障时能够恢复到先前保存的良好状态(“回滚线”)? | |||||
| 系统能否以不影响服务的方式对可执行代码镜像进行在线软件升级? | |||||
| 在组件或连接失效可能是临时性的情况下,系统是否会有计划地进行重试? | |||||
| 系统能否直接忽略故障行为(例如,当确定某些消息是虚假消息时,忽略这些消息)? | |||||
| 当资源受损时,系统是否有降级策略,即在组件出现失效的情况下维持最关键的系统功能,同时舍弃不太关键的功能? | |||||
| 系统在出现失效后是否有一致的重新配置策略和机制,在尽可能维持更多功能的同时,将职责重新分配给仍在运行的资源? | |||||
| 从故障中恢复(重新引入) | 系统能否在将先前出现故障或进行了在线升级的组件恢复到活动角色之前,让该组件以“影子”模式运行一段预定义的时间? | ||||
| 如果系统采用主动或被动冗余机制,它是否也会使用状态再同步功能,将状态信息从活动组件发送到备用组件呢? | |||||
| 系统是否采用逐步升级式重启,通过改变重启组件的粒度并尽量减小受影响的服务级别,从而从故障中恢复? | |||||
| 系统的消息处理和路由部分能否采用不间断转发,即将功能划分为管理平面和数据平面? | |||||
| 预防故障 | 系统能否使组件停止服务,即为了预防潜在的系统失效,暂时将系统组件置于停用状态? | ||||
| 系统是否采用事务机制——将状态更新进行捆绑,以便分布式组件之间交换的异步消息具备“原子性”“一致性”“隔离性”和“持久性”? | |||||
| 系统是否使用预测模型来监测组件的健康状态,以确保系统在正常参数范围内运行?当检测到预示未来可能出现故障的状况时,该模型会启动纠正措施。 |
4.4 可用性模式
本节介绍一些最为重要的可用性架构模式。
前三种模式均围绕冗余备件策略展开,将一并进行描述。它们的主要区别在于备份组件的状态与活动组件状态相匹配的程度(当组件是无状态时属于一种特殊情况,此时前两种模式是相同的)。
-
主动冗余(热备用):对于有状态组件,这是指一种配置方式,在保护组 ^3 中的所有节点(活动节点或冗余备用节点)并行接收并处理相同的输入,使得冗余备用节点能够与活动节点保持同步状态。由于冗余备用节点的状态与活动处理器的状态完全相同,它可以在数毫秒内接管出现故障的组件。一个活动节点和一个冗余备用节点这种简单情况通常被称为 “一加一冗余”。主动冗余也可用于设施保护,通过使用活动和备用网络链路来确保高可用性的网络连接。
-
被动冗余(温备用):对于有状态组件,这是指一种配置方式,在这种配置下,只有保护组中的活动成员处理输入流量。它们的职责之一是定期向冗余备用节点提供状态更新。由于冗余备用节点所维护的状态与保护组中活动节点的状态只是松散耦合(耦合的松散程度取决于状态更新的周期),所以这些冗余节点被称为温备用节点。被动冗余提供了一种解决方案,在可用性更高但计算资源消耗更大(且成本更高)的主动冗余模式和可用性较低但复杂度明显更低(且成本也明显更低)的冷备用模式之间实现了一种平衡。
-
备用(冷备用):冷备用是指一种配置方式,在这种配置下,冗余备用节点在故障转移发生之前一直处于停用状态,在将其投入使用之前,需要对冗余备用节点执行加电复位 ^4 程序。由于其恢复性能较差,因而平均修复时间较长,这种模式不太适合有高可用性要求的系统。
优势:
- 冗余备用的优势在于,在出现失效时,系统仅经过短暂延迟后就能继续正常运行。与之相对的情况是,系统会停止正常运行,或者完全停止运行,直至故障组件被修复,而这一修复过程可能需要数小时或数天时间。
权衡:
- 采用这些模式中的任何一种都需要在提供备用组件方面付出额外的成本并增加复杂性。
- 这三种模式之间的权衡在于从故障中恢复所需的时间与为使备用组件保持最新状态而产生的运行时成本之间的对比。例如,热备用成本最高,但恢复时间最快。
其他可用性模式包括以下几种:
-
三模冗余(TMR):这是一种广泛应用的表决策略实现方式,它采用三个执行相同任务的组件。每个组件接收相同的输入,并将其输出转发给表决逻辑,表决逻辑会检测三个输出状态之间的任何不一致情况。面对不一致情况时,表决器会报告故障,它还必须决定使用哪个输出,而且该模式的不同实例会采用不同的决策规则。典型的选择是遵循多数原则或者选择对不同输出进行某种计算得出的平均值。
当然,采用 5 个、19 个或 53 个冗余组件的该模式的其他版本也是可行的。不过,在大多数情况下,3 个组件足以确保可靠的结果。
优势:
- 三模冗余易于理解和实现。它完全不受可能导致不同结果的因素影响,只关心做出合理选择,以便系统能够继续运行。
权衡:
- 在增加副本数量(这会增加成本)和所获得的可用性之间需要进行权衡。在采用三模冗余的系统中,两个或更多组件同时出现故障的统计概率极小,3 个组件在可用性和成本之间达到了一个理想的平衡点。
-
断路器:一种常用的可用性策略是重试。在调用服务时出现超时或故障的情况下,调用者会不断地重试。断路器可防止调用者无休止地尝试,避免其一直等待永远不会到来的响应。通过这种方式,当它判定系统正在处理故障时,就会打破无休止的重试循环。这就是提示系统开始处理故障的信号。在断路器 “复位” 之前,后续的调用会立即返回,而不会传递服务请求。
优势:
- 这种模式可以让各个组件无需再制定在判定故障前允许重试多少次的策略。
- 最糟糕的情况是,无休止的无效重试会使调用组件变得和出现故障的被调用组件一样毫无用处。这个问题在分布式系统中尤为突出,在分布式系统中,可能会有许多调用者调用一个无响应的组件,结果自身实际上也停止服务,导致故障在整个系统中连锁反应。断路器与监听它并启动恢复程序的软件协同工作,可防止出现这种问题。
权衡:
- 在选择超时(或重试)值时必须谨慎。如果超时时间过长,就会增加不必要的延迟。但如果超时时间过短,断路器就会在不必要的时候触发 —— 这属于一种 “误报” 情况,会降低这些服务的可用性和性能。
其他常用的可用性模式还包括以下几种:
-
进程对:这种模式采用检查点和回滚机制。在出现故障的情况下,备份进程一直在设置检查点,并(如有必要)回滚到安全状态,以便在失效发生时能够随时接管。
-
前向纠错恢复:这种模式提供了一种通过 “向前推进” 到理想状态来摆脱不良状态的方法。这通常依赖于内置的纠错能力,比如数据冗余,这样就可以在无需回退到先前状态或重试的情况下纠正错误。前向纠错恢复会找到一个安全的、可能是降级的状态,以便操作能够继续向前推进。
4.5 扩展阅读
可用性模式:
- 你可以在 [Hanmer 13] 中阅读有关容错模式的内容。
可用性的通用策略:
- 本章中部分可用性策略的更详细讨论见 [Scott 09]。本章的大部分内容取材于此。
- 互联网工程任务组(IETF)已经颁布了多项支持可用性策略的标准。这些标准包括《不间断转发》[IETF 2004]、《Ping / Echo》(《网际控制报文协议》[IETF 1981] 或《网际控制报文协议第 6 版》[RFC 2006b]《Echo Request/Response》)以及多协议标签交换(标签交换路径 Ping)网络 [IETF 2006a]。
可用性策略 —— 故障检测:
- 三模冗余(TMR)由莱昂斯在 20 世纪 60 年代早期提出 [Lyons 62]。
- 表决策略中的故障检测基于冯・诺依曼对自动机理论的基础性贡献,他展示了如何用不可靠的组件构建具有规定可靠性的系统 [Von Neumann 56]。
可用性策略 —— 故障恢复:
- 在开放系统互连(OSI)七层模型的物理层 [Bellcore 98, 99; Telcordia 00] 以及网络 / 链路层 [IETF 2005],都存在基于标准的主动冗余实现方式用于保护网络链路(即设施)。
- [Nygard 18] 给出了一些系统如何因使用而降级(性能衰退)的示例。
- 关于参数类型方面已经有大量的论文发表,不过 [Utas 05] 是在可用性的背景下(相对于其通常所在的防错背景而言)对其进行论述的。[Utas 05] 还写过有关逐步升级式重启的内容。
- 硬件工程师经常使用准备及修复策略。例如差错检测与纠正(EDAC)编码、前向纠错(FEC)以及时间冗余。差错检测与纠正编码通常用于保护高可用性分布式实时嵌入式系统中的控制内存结构 [Hamming 80]。相反,前向纠错编码通常用于从外部网络链路中出现的物理层差错中恢复 [Morelos-Zaragoza 06].。时间冗余涉及在超过要容忍的任何瞬态脉冲宽度的时间间隔内对空间冗余的时钟或数据线进行采样,然后剔除检测到的任何缺陷 [Mavis 02]。
可用性策略 —— 故障预防:
灾难恢复:
- 灾难是诸如地震、洪水或飓风之类的事件,这类事件会摧毁整个数据中心。美国国家标准与技术研究院(NIST)明确了在发生灾难时应考虑的八种不同类型的计划,详见美国国家标准与技术研究院特别出版物 800 - 34《联邦信息系统应急规划指南》的 第 2.2 节,网址为https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-34r1.pdf。
4.6 问题讨论
1. 针对通用场景中的每一种可能应对方式,编写一组具体的可用性场景示例。
2. 为(假设的)无人驾驶汽车的软件编写一个具体的可用性场景示例。
3. 为类似微软 Word 这样的程序编写一个具体的可用性场景示例。
4. 冗余是实现高可用性的关键策略。查看本章所介绍的模式和策略,确定其中有多少利用了某种形式的冗余,又有多少没有利用。
5. 可用性与可修改性和可部署性之间如何权衡?对于一个要求每周 7 天、每天 24 小时不间断运行(即永远不存在计划内或计划外停机时间)的系统,你会如何对其进行变更?
6. 考虑故障检测策略(Ping / echo、心跳检测、系统监控、表决以及异常检测)。使用这些策略会对性能产生哪些影响?
7. 负载均衡器(见 第 17 章)在检测到某个实例出现失效时会使用哪些策略?
8. 查阅恢复点目标(RPO)和恢复时间目标(RTO),并解释在使用回滚策略时如何利用它们来设置检查点间隔。
第5章 可部署性
从我们来到这个星球的那一天起 眨着眼睛,走进阳光里 有太多的风景,难以尽览 有太多的事情,难以做完
——《狮子王》(The Lion King)
有那么一天,软件也像我们人类一样,必须离开 “安乐窝”,走向外面的世界,去体验真实的生活。与我们不同的是,由于需要进行各种更改和更新,软件往往要经历多次这样的 “旅程”。本章旨在探讨如何尽可能有序、高效,尤其是尽可能快速地完成这一转变。这就是持续部署的范畴,而可部署性这一质量属性在很大程度上推动了持续部署的实现。
为什么可部署性在众多质量属性中占据了重要地位呢?
在过去的 “糟糕岁月” 里,软件发布的频率很低 —— 大量的更改被打包成一个版本,按计划发布。一个版本中会包含新功能和漏洞修复。每月发布一次、每季度发布一次,甚至每年发布一次都是很常见的情况。在许多领域,尤其是在电子商务的引领下,竞争压力使得软件需要更短的发布周期。在这种情况下,软件可以随时发布 —— 有时一天可能会发布数百次 —— 而且每次发布可能都是由组织内不同的团队发起的。能够频繁发布意味着,特别是漏洞修复无需等到下一次计划发布时才进行,而是一旦发现并修复了漏洞,就可以立即发布。这也意味着新功能无需打包到某个版本中,而是可以在任何时候投入生产环境。
然而,这种情况并非在所有领域都可取,甚至在某些领域根本无法实现。如果你的软件处于一个复杂的生态系统中,存在许多依赖关系,那么在不与其他部分协调发布的情况下,可能无法单独发布其中的某一部分。此外,许多嵌入式系统、位于难以触及位置的系统以及未联网的系统,都不太适合采用持续部署的理念。
本章重点关注数量庞大且不断增长的一类系统,对于这类系统而言,即时发布新功能是一项重要的竞争优势,而即时修复漏洞对于安全性、保密性或持续运行至关重要。这类系统通常是基于微服务和云计算的,不过,这里介绍的技术并不局限于这些技术。
5.1 持续部署
部署是一个,从编码开始到生产环境中真实用户与系统进行交互结束的过程。如果这个过程完全自动化,也就是说没有人为干预,那么它就被称为 “持续部署”。如果在将系统的(部分)投入生产环境之前的过程是自动化的,而在最后这一步需要人为干预(可能是由于法规或政策要求),那么这个过程就被称为 “持续交付”。
为了加快发布速度,我们需要引入 “部署流水线” 的概念:这是一系列工具和活动的集合,从你将代码提交到版本控制系统开始,到你的应用程序部署完成并可供用户发送请求结束。在这期间,一系列工具会对新提交的代码进行集成和自动测试,对集成后的代码进行功能测试,以及对应用程序进行诸如负载性能、安全性和许可证合规性等方面的测试。
部署流水线中的每个阶段都在一个专门建立的环境中进行,这个环境旨在支持该阶段的隔离,并执行适合该阶段的操作。主要的环境如下:
- 代码在 “开发环境” 中针对单个模块进行开发,在此环境中代码要接受独立的单元测试。一旦代码通过测试,并经过适当的评审,就会提交到版本控制系统,从而触发集成环境中的构建活动。
- “集成环境” 会构建出你的服务的可执行版本。持续集成服务器会编译 ^1 你的新代码或已更改的代码,以及你的服务其他部分的最新兼容版本代码,并为你的服务构建一个可执行镜像 ^2。集成环境中的测试包括来自各个模块的单元测试(现在针对已构建的系统运行),以及专门为整个系统设计的集成测试。当各项测试都通过后,构建好的服务就会被推送到预发布环境。
- “预发布环境” 会对整个系统的各种质量属性进行测试。这些测试包括性能测试、安全性测试、许可证合规性检查,可能还包括用户测试。对于嵌入式系统,在这个环境中会使用物理环境模拟器(向系统输入模拟数据)来进行测试。通过了所有预发布环境测试(可能包括现场测试)的应用程序,会使用蓝绿部署模式或滚动升级模式(见 5.6 节)部署到生产环境。在某些情况下,会采用部分部署的方式来进行质量控制,或者测试市场对某项提议的更改或产品的反应。
- 一旦进入 “生产环境”,就会对服务进行密切监控,直到各方对其质量都有一定程度的信心。到那时,它就被视为系统的正常组成部分,会像系统的其他部分一样受到同等关注。
在每个环境中都要进行不同类型的测试,测试范围从开发环境中对单个模块的单元测试,扩展到集成环境中对构成服务的所有组件的功能测试,最后到预发布环境中的全面质量测试以及生产环境中的使用情况监控。
但并非一切都总是按计划进行。如果软件在生产环境中发现问题,通常需要在修复缺陷的同时回滚到之前的版本。
架构选择会影响可部署性。例如,通过采用微服务架构模式(见 5.6 节),负责每个微服务的团队都可以做出自己的技术选择;这就消除了之前在集成时可能会发现的不兼容问题(例如,在使用某个库的版本选择上存在不兼容情况)。由于微服务是独立的服务,所以这样的选择不会引发问题。
同样,持续部署的理念会促使你在开发过程的早期就考虑测试基础设施。这是必要的,因为为持续部署进行设计需要持续的自动化测试。此外,由于需要能够回滚或禁用功能,这就会导致在架构上做出关于功能开关和接口向后兼容性等机制的决策。这些决策最好尽早做出。
虚拟化对不同环境的影响
在虚拟化技术广泛应用之前,我们这里所描述的环境都是物理设施。在大多数组织中,开发环境、集成环境和预发布环境包含由不同团队采购和运维的硬件与软件。开发环境可能由几台被开发团队改作服务器使用的台式计算机组成。集成环境由测试团队或质量保证团队负责运维,可能由一些机架构成,机架上配备着从数据中心淘汰下来的上一代设备。预发布环境由运维团队负责运维,其硬件可能与生产环境中使用的硬件类似。
人们花费了大量时间去弄清楚为什么在一个环境中通过的测试,在另一个环境中却失败了。采用虚拟化技术的环境的一大优势在于能够实现 “环境一致性”,即各个环境可能在规模上有所不同,但在硬件类型或基础结构方面不会存在差异。各种配置工具支持环境一致性,它们允许每个团队轻松构建一个通用环境,并确保这个通用环境尽可能地模拟生产环境。
衡量部署流水线质量的三个重要方面如下:
- 周期时间指的是通过部署流水线的进展速度。许多组织每天会向生产环境部署数次,甚至数百次。如果需要人工干预,如此快速的部署是不可能实现的。如果一个团队在将其服务投入生产环境之前必须与其他团队进行协调,那么快速部署同样也无法实现。在本章的后面部分,我们将了解到一些架构技术,这些技术能够让团队在无需与其他团队协商的情况下进行持续部署。
- 可追溯性是指能够追溯导致某个组件出现问题的所有相关制品的能力。这包括该组件中包含的所有代码和依赖项,还包括在该组件上运行过的测试用例以及用于生成该组件的工具。部署流水线中使用的工具出现错误可能会在生产环境中引发问题。通常,可追溯性信息会保存在一个 “制品数据库” 中。这个数据库将包含代码版本号、系统所依赖的组件(如库)的版本号、测试版本号以及工具版本号。
- 可重复性是指使用相同的制品执行相同的操作时能够得到相同的结果。这并不像听起来那么容易。例如,假设你的构建过程获取的是某个库的最新版本。那么下次执行构建过程时,该库可能已经发布了新版本。再比如,假设某个测试修改了数据库中的某些值。如果没有恢复这些原始值,后续的测试可能就无法产生相同的结果。
开发运维
“开发运维(DevOps)” 是 “开发(development)” 和 “运维(operations)” 两个词的合成词,是一个与持续部署密切相关的概念。它是一种运动(与敏捷运动非常相似),是对一系列实践和工具的描述(同样,与敏捷运动类似),也是销售这些工具的供应商所鼓吹的一种营销模式。开发运维的目标是缩短产品上市时间(或发布时间)。与传统软件开发实践相比,其目标是大幅缩短从开发人员对现有系统进行更改(实现新功能或修复漏洞)到系统交付给终端用户之间的时间间隔。
开发运维的正式定义涵盖了发布的频率以及按需修复漏洞的能力:
开发运维是一系列实践,旨在减少将对系统的更改提交到将该更改投入正常生产之间的时间,同时确保高质量。[Bass 15]
实施开发运维是一项流程改进工作。开发运维不仅包括任何流程改进工作中的文化和组织要素,还高度依赖工具和架构设计。当然,所有环境都各不相同,但我们所描述的工具和自动化功能通常可以在为支持开发运维而构建的典型工具链中找到。
我们在此描述的持续部署策略是开发运维的核心概念。反过来,自动化测试是持续部署的一个至关重要的组成部分,而用于自动化测试的工具往往是开发运维在技术方面的最大障碍。某些形式的开发运维包括日志记录以及对这些日志的部署后监控,以便在 “总部” 自动检测错误,甚至通过监控来了解用户体验。当然,这需要系统具备 “回传信息” 或日志传输功能,而在某些系统中,这可能是无法实现的,或者是不被允许的。
“开发安全运维(DevSecOps)” 是开发运维的一个分支,它将安全措施(针对基础设施及其所生成的应用程序)融入到整个流程中。开发安全运维在航空航天和国防应用领域日益流行,但在任何开发运维有实用价值且安全漏洞可能导致高昂代价的应用领域中,它同样适用。许多信息技术应用都属于这一范畴。
5.2 可部署性
可部署性是指软件所具备的一种特性,表明该软件能够在可预测且可接受的时间和精力范围内完成部署,也就是将其分配到某个环境中执行。此外,如果新的部署不符合其规格要求,也能够在可预测且可接受的时间和精力范围内回滚。随着世界日益向虚拟化和云基础设施发展,并且随着已部署的软件密集型系统规模不可避免地不断扩大,确保以高效且可预测的方式进行部署,将整体系统风险降至最低,是架构师的职责之一。^3
为了实现这些目标,架构师需要考虑可执行文件在主机平台上如何更新,以及随后如何对其进行调用、度量、监控和控制。特别是移动系统,由于带宽方面的问题,在更新方式上给可部署性带来了挑战。部署软件所涉及的一些问题如下:
- 软件如何到达主机(即采用推送方式,也就是未经请求就部署更新;还是采用拉取方式,即用户或管理员必须明确请求更新)?
- 软件如何集成到现有系统中?能否在现有系统运行时进行集成?
- 采用何种介质进行部署,比如 DVD、USB 驱动器还是通过互联网传输?
- 软件的封装形式是什么(例如,可执行文件、应用程序、插件)?
- 软件集成到现有系统后会产生什么结果?
- 执行部署过程的效率如何?
- 部署过程的可控性如何?
考虑到所有这些问题,架构师必须能够评估相关风险。架构师主要关注架构对以下类型部署的支持程度:
- 粒度性:部署可以是对整个系统进行,也可以是对系统内的元素进行。如果架构为更细粒度的部署提供了多种选择,那么某些风险就可以降低。
- 可控性:架构应具备在不同粒度级别进行部署的能力,能够监控已部署单元的运行情况,并对不成功的部署进行回滚。
- 高效性:架构应支持以合理的工作量实现快速部署(如有需要,还应支持快速回滚)。
这些特性将在可部署性通用场景的应对措施中得以体现。
5.3 可部署性通用场景
表 5.1 列举了描述可部署性的通用场景中的各项要素。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 部署的触发因素 | 终端用户、开发人员、系统管理员、运维人员、组件市场、产品负责人。 |
| 触发事件 | 是什么引发了这些触发因素? | 有一个新元素可供部署。这通常是一个用新版本替换软件元素的请求(例如,修复缺陷、应用安全补丁、升级到某个组件或框架的最新版本、升级到内部生产的某个元素的最新版本)。新元素已获批纳入系统。现有一个元素或一组元素需要回滚。 |
| 构件 | 将要更改的内容是什么? | 特定的组件或模块、系统的平台、用户界面、运行环境,或者是与之进行互操作的另一个系统。因此,相关的构件可能是单个软件元素、多个软件元素,或者是整个系统。 |
| 环境 | 预发布环境、生产环境(或者两者中的特定子集) | 完全部署。对指定的部分用户、虚拟机、容器、服务器、平台进行子集部署。 |
| 响应 | 应该发生什么 | 整合新组件。部署新组件。监控新组件。回滚之前的部署。 |
| 响应度量 | 对一次部署,或一段时间内一系列部署的成本、时间或流程有效性的一种衡量方式。 | 成本方面的考量: 本次部署 / 回滚对其他功能或质量属性的影响程度。部署失败的次数。流程的可重复性。流程的可追溯性。流程的周期时间。 |
图5.1展示了一个具体的可部署性场景:“在组件市场上推出了一个身份验证/授权服务的新版本(我们的产品会用到该服务),产品负责人决定将这个版本纳入产品发布内容中。新服务经过测试,并在40个小时的时间内部署到了生产环境,且投入的人力不超过120人时。此次部署没有引入任何缺陷,也没有违反任何服务级别协议。”

5.4 可部署性策略
新软件或硬件元素的发布推动了部署工作的开展。如果这些新元素能够在可接受的时间、成本和质量限制范围内完成部署,那么此次部署就是成功的。我们在 图5.2 中展示了这种关系,同时也展示了可部署性策略的目标。

可部署性的策略如 图 5.3 所示。在很多情况下,这些策略至少有一部分会由你购买而非自行搭建的持续集成 / 持续部署(CI/CD)基础设施来提供。在这种情况下,作为架构师,你的工作往往是选择并评估(而非实施)合适的可部署性策略以及这些策略的恰当组合。

接下来,我们将更详细地介绍这六种可部署性策略。第一类可部署性策略侧重于管理部署流水线的策略,第二类则涉及在系统部署过程中以及部署完成后对系统进行管理的策略。
管理部署流水线
- 渐进式发布:渐进式发布并非将服务的新版本部署到全体用户,而是逐步将其部署到用户群体中经过筛选的子集,并且通常不会向这些用户发出明确通知。(其余用户继续使用该服务的旧版本。)通过逐步发布,可以对新部署的影响进行监控和评估,如有必要,还可以进行回滚操作。这种策略能将部署有缺陷服务可能带来的负面影响降至最低。它需要一种架构机制(并非所部署服务的一部分),根据用户身份,将用户的请求路由至新服务或旧服务。
- 回滚:如果发现某次部署存在缺陷或未达到用户预期,那么可以将其 “回滚” 到之前的状态。由于部署可能涉及多个服务及其数据的多次协同更新,回滚机制必须能够跟踪所有这些更新,或者能够撤销部署所进行的任何更新操作的影响,理想情况下应实现完全自动化。
- 编写部署命令脚本:部署工作通常较为复杂,需要精确执行和协调多个步骤。因此,部署过程常常会编写脚本。这些部署脚本应像代码一样进行处理,包括编写文档、进行审查、测试以及版本控制。脚本引擎会自动执行部署脚本,这样既能节省时间,又能最大程度减少人为错误的发生。
管理已部署的系统
- 管理服务交互:此策略支持系统服务的多个版本同时进行部署和执行。客户端的多个请求可以按照任意顺序被导向任一版本。然而,运行同一服务的多个版本可能会引入版本兼容性问题。在这种情况下,需要对服务之间的交互进行协调,以便主动避免版本兼容性问题。该策略是一种资源管理策略,无需完全复制资源来分别部署旧版本和新版本。
- 打包依赖项:此策略将一个元素及其依赖项打包在一起,以便它们能一同部署,并且在该元素从开发阶段进入生产阶段时,依赖项的版本能够保持一致。依赖项可能包括库、操作系统版本以及实用容器(例如边车容器、服务网格),我们将在 第 9 章 中对此进行讨论。打包依赖项的三种方式是使用容器、Pod 或虚拟机;我们将在 第 16 章 中更详细地讨论这些内容。
- 功能开关:即使你的代码经过了全面测试,在部署新功能后仍可能会遇到问题。因此,能够为新功能集成一个 “紧急关闭开关”(即功能开关)会非常方便。这个紧急关闭开关可以在运行时自动禁用系统中的某个功能,而无需你启动新的部署操作。这使得你能够在不承担实际重新部署服务的成本和风险的情况下,对已部署的功能进行控制。
5.5 基于策略的可部署性调查问卷
根据 5.4 节 中描述的策略,我们可以设计一系列受可部署性策略启发的问题,如下表 5.2 所示。为了全面了解为支持可部署性而做出的架构选择,分析师会提出每个问题,并将答案记录在表格中。这些问题的答案随后可成为后续工作的重点,比如对文档进行调查、对代码或其他制品进行分析、对代码进行逆向工程等等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策和定位 | 推理与假设 |
|---|---|---|---|---|---|
| 管理部署流水线 | 你们是否采用渐进式发布的方式,逐步推出新版本(而不是采取要么全推要么全不推的方式)? | ||||
| 如果你们判定已部署的服务运行状况不理想,是否能够自动回滚这些服务? | |||||
| 你们是否会编写部署命令脚本,以便自动执行复杂的一系列部署指令? | |||||
| 管理已部署的系统 | 你们是否会对服务交互进行管理,以便能够安全地同时部署多个版本的服务? | ||||
| 你们是否会对依赖项进行打包,从而使服务与其所依赖的所有库、操作系统版本以及实用容器一同进行部署? | |||||
| 如果发现新发布的功能存在问题,你们是否会使用功能开关来自动禁用该功能(而不是回滚新部署的服务)? |
5.6 可部署性模式
可部署性模式可分为两类。第一类包含用于构建待部署服务结构的模式。第二类包含关于如何部署服务的模式,这又可细分为两个主要的子类别:全量部署或部分部署。可部署性的这两个主要类别并非完全相互独立,因为某些部署模式依赖于服务的特定结构属性。
服务结构模式
微服务架构
微服务架构模式将系统构建为一组可独立部署的服务集合,这些服务仅通过服务接口以消息的形式进行通信。不允许存在其他形式的进程间通信:没有直接链接,不能直接读取其他团队的数据存储,没有共享内存模型,也完全没有任何后门。服务通常是无状态的,并且(由于它们由一个相对较小的团队开发 ^4)规模相对较小,因此被称为 “微服务”。服务之间的依赖关系是非循环的。此模式的一个重要组成部分是服务发现机制,以便消息能够被正确路由。
优点:
- 缩短产品上市时间。由于每个服务都很小且可独立部署,对某个服务的修改可以在无需与负责其他服务的团队进行协调的情况下进行部署。因此,一旦某个团队完成了对服务新版本的开发并通过测试,就可以立即进行部署。
- 每个团队都可以为其负责的服务自主选择技术,只要所选技术支持消息传递即可。在库版本或编程语言方面无需进行协调。这减少了因集成过程中出现的不兼容问题而导致的错误,而这些不兼容问题正是集成错误的主要来源。
- 与粒度较粗的应用程序相比,微服务更易于扩展。由于每个服务都是独立的,动态添加服务实例非常简单。通过这种方式,服务的供应能够更轻松地与需求相匹配。
权衡:
- 与内存内通信相比,开销会增加,因为服务之间的所有通信都要通过网络消息进行。使用服务网格模式(见 第 9 章)在一定程度上可以缓解这一问题,该模式将某些服务的部署限制在同一主机上,以减少网络流量。此外,由于微服务部署的动态特性,服务发现机制会被大量使用,这进一步增加了开销。最终,这些服务发现机制可能会成为性能瓶颈。
- 由于在分布式系统中难以同步活动,微服务不太适合处理复杂事务。
- 每个团队自由选择技术是有代价的,组织必须维护这些技术以及所需的技术经验基础。
- 由于微服务数量众多,对整个系统进行全面的管理和把控可能会比较困难。这就需要有接口目录和数据库来辅助进行有效的管理。此外,合理组合服务以实现预期结果的过程可能会很复杂且微妙。
- 设计出具有适当职责和合适粒度的服务是一项艰巨的设计任务。
- 为了实现版本独立部署的能力,服务的架构设计必须支持这种部署策略。使用 5.4 节 中描述的管理服务交互策略有助于实现这一目标。
大量采用微服务架构模式的组织包括谷歌、网飞、贝宝、推特、脸书和亚马逊。许多其他组织也采用了微服务架构模式;市面上有相关书籍,也举办了一些会议,专注于探讨组织如何根据自身需求采用微服务架构模式。
服务完全替换模式
假设有 N 个服务 A 的实例,你希望用 N 个服务 A 的新版本实例来替换它们,且不保留原始版本的任何实例。你希望在不降低服务对客户端的服务质量的情况下完成此操作,因此必须始终保持有 N 个服务实例在运行。
有两种不同的完全替换策略模式,它们都是渐进式发布策略的具体实现。我们将一起介绍这两种模式:
- 蓝绿部署:在蓝绿部署中,会创建 N 个新的服务实例,并在每个实例中部署新的服务 A(我们将这些称为绿色实例)。在安装好 N 个新服务 A 的实例后,DNS 服务器或服务发现机制会被修改,使其指向服务 A 的新版本。一旦确定新实例运行正常,此时才会移除 N 个原始服务 A 的实例。在切换之前,如果发现新版本存在问题,只需简单地切换回原始版本(蓝色服务),几乎不会造成中断或中断时间很短。
-
滚动升级:滚动升级是一次用一个服务 A 的新版本实例替换一个旧版本实例。(实际上,你可以一次替换多个实例,但在每一步中替换的实例数量都很少。)滚动升级的步骤如下:
- 为服务 A 的一个新实例分配资源(例如,一台虚拟机)。
- 安装并注册服务 A 的新版本。
- 开始将请求导向服务 A 的新版本。
- 选择一个旧服务 A 的实例,让它完成所有正在进行的处理,然后销毁该实例。
- 重复上述步骤,直到所有旧版本的实例都被替换。
图 5.4 展示了网飞公司的 Asgard 工具在亚马逊 EC2 云平台上实现的滚动升级过程。

图 5.4 网飞公司的 Asgard 工具所实现的滚动升级模式流程图
优点:
- 这些模式的优点在于能够在无需让系统停止服务的情况下,完全替换已部署的服务版本,从而提高了系统的可用性。
权衡:
- 蓝绿部署方式的资源使用峰值为 2N个实例,而滚动升级的资源使用峰值为N + 1 个实例。无论哪种情况,都必须获取用于承载这些实例的资源。在云计算广泛普及之前,获取资源意味着购买:企业必须购买物理计算机来进行升级。大多数时候并没有升级操作在进行,所以这些额外的计算机大多处于闲置状态。这使得在成本方面的权衡一目了然,因此滚动升级曾是标准的做法。现在,计算资源可以按需租用,而无需购买,虽然成本方面的权衡不再那么突出,但仍然存在。
- 假设在部署新的服务 A 时发现了一个错误。尽管在开发、集成和预发布环境中进行了各种测试,但当服务部署到生产环境中时,仍然可能存在潜在的错误。如果使用蓝绿部署,当发现新服务 A 中的错误时,所有原始实例可能已经被删除,回滚到旧版本可能需要相当长的时间。相比之下,滚动升级可能使你在旧版本实例仍然可用时就发现新版本服务中的错误。
- 从客户端的角度来看,如果使用蓝绿部署模式,在任何时间点,要么是新版本处于活动状态,要么是旧版本处于活动状态,不会同时存在两个版本。如果使用滚动升级模式,两个版本会同时处于活动状态。这就带来了两种可能的问题:“时间不一致性” 和 “接口不匹配”。
服务部分替换模式
有时,对某个服务的所有实例进行更改并非理想之举。部分部署模式旨在为不同用户群体同时提供某个服务的多个版本,其用途包括质量控制(金丝雀测试)和市场测试(A/B 测试)等。
金丝雀测试
在全面推出新版本之前,在生产环境中对其进行测试是较为谨慎的做法,但参与测试的用户范围应有所限制。“金丝雀测试” 类似于持续部署环境下的公测。金丝雀测试会指定一小部分用户来测试新版本。有时,这些测试用户是所谓的高级用户,或者是来自组织外部的抢先体验用户,他们更有可能使用到普通用户较少触及的代码路径和边缘情况。这些用户可能知道,也可能不知道自己被当作了 “小白鼠”—— 或者说,“金丝雀”。另一种方法是使用软件开发组织内部的测试人员。例如,谷歌员工几乎从不使用外部用户所使用的版本,而是充当即将发布版本的测试人员。当测试的重点在于确定新功能的受欢迎程度时,会采用一种名为 “暗启动” 的金丝雀测试变体。
在上述两种情况下,被指定为 “金丝雀” 的用户会通过 DNS 设置或服务发现配置,被引导至相应版本的服务。测试完成后,所有用户将被统一引导至新版本或旧版本,被弃用版本的实例将被销毁。可以使用滚动升级或蓝绿部署的方式来部署新版本。
优点:
- 金丝雀测试能够让真实用户以模拟测试无法做到的方式对软件进行实际使用。这使得部署服务的组织能够收集 “实际使用中” 的数据,并以相对较低的风险开展可控实验。
- 金丝雀测试产生的额外开发成本极低,因为正在测试的系统最终无论如何都会投入生产。
- 金丝雀测试将可能接触到新系统严重缺陷的用户数量降至最低。
权衡:
- 金丝雀测试需要额外的前期规划和资源投入,并且需要制定一套评估测试结果的策略。
- 如果金丝雀测试针对的是高级用户,就必须先识别出这些用户,然后将新版本导向他们。
A/B 测试
市场营销人员会使用 A/B 测试,对真实用户开展实验,以确定几种不同方案中哪种能带来最佳的商业效果。一小部分但数量足够有意义的用户会接受与其他用户不同的体验。这种差异可能很细微,比如字体大小或表单布局的改变,也可能较为显著。例如,HomeAway(现 Vrbo)曾使用 A/B 测试来改变其全球网站的格式、内容和外观,以追踪哪些版本能带来最多的租赁业务。“获胜” 的版本会被保留,“失败” 的版本则会被弃用,然后再设计并部署新的测试版本。另一个例子是银行针对新账户推出不同的促销活动。一个广为人知的案例是,谷歌曾测试了 41 种不同深浅的蓝色,以决定使用哪种蓝色来显示搜索结果。
与金丝雀测试一样,A/B 测试会通过设置 DNS 服务器和服务发现配置,将客户端请求发送至不同版本。在 A/B 测试过程中,会对不同版本进行监测,以从商业角度判断哪个版本能获得最佳反馈。
优点:
- A/B 测试使市场营销和产品开发团队能够在真实用户身上开展实验,并收集相关数据。
- A/B 测试能够根据任意一组用户特征来精准定位目标用户。
权衡:
- A/B 测试需要实现多个不同方案,其中必有一个会被弃用。
- 需要提前识别出不同类型的用户及其特征。
5.7 扩展阅读
本章的大部分内容改编自伦恩・巴斯(Len Bass)和约翰・克莱因(John Klein)所著的《软件工程师的部署与运维》[Bass 19],以及 [Kazman 20b]。
与 DevOps 相关的,关于可部署性与架构的一般性讨论,可在 [Bass 15] 中找到。
可部署性策略在很大程度上借鉴了马丁・福勒(Martin Fowler)及其同事的研究成果,相关内容可在 [Fowler 10]、[Lewis 14] 和 [Sato 14] 中查阅。
[Humble 10] 中对部署流水线进行了更为详细的描述。
[Newman 15] 中介绍了微服务以及向微服务迁移的过程。
5.8 问题讨论
1. 利用通用场景中的每一种可能回复,编写一组具体的可部署性场景。
2. 为汽车(如特斯拉)的软件编写一个具体的可部署性场景。
3. 为一款智能手机应用编写一个具体的可部署性场景。现在,为与该应用通信的服务器端基础设施编写一个具体的可部署性场景。
4. 如果你需要显示搜索操作的结果,你会进行 A/B 测试,还是直接使用谷歌选择的颜色?为什么?
5. 参考 第 1 章 中描述的各种结构,在实现打包依赖项策略时会涉及哪些结构?你会使用 “使用” 结构吗?为什么会或为什么不会?是否还有其他需要考虑的结构?
6. 参考 第 1 章 中描述的各种结构,在实现管理服务交互策略时会涉及哪些结构?你会使用 “使用” 结构吗?为什么会或为什么不会?是否还有其他需要考虑的结构?
7. 在哪些情况下,你更倾向于向前推进到服务的新版本,而不是回滚到之前的版本?在什么情况下,向前推进是一个糟糕的选择?
第6章 能效性
能量有点像金钱:如果你有盈余,就可以以各种方式支配它,但根据本世纪初人们所信奉的经典定律,你是不允许出现透支情况的。
——斯蒂芬·霍金(Stephen Hawking )
过去,计算机所消耗的能源似乎是免费且无穷无尽的——至少我们的行为表现得好像是这样。以往,架构师们很少会过多考虑软件的能源消耗问题。但如今,那些日子已经一去不复返了。随着移动设备成为大多数人主要的计算工具,占据主导地位,随着物联网在工业和政府领域的应用日益广泛,以及云服务作为我们计算基础设施的支柱无处不在,能源已经成为架构师们再也无法忽视的一个问题。电力不再是“免费”且取之不尽的了。移动设备的能源效率与我们每个人都息息相关。同样,云服务提供商也越来越关注其服务器集群的能源效率。2016年,有报道称,全球数据中心的能源消耗占比(40%)高于整个英国的能源消耗——约占全球能源总消耗的3%。而最近的估计显示,这一占比已经高达10%。运行大型数据中心,尤其是为其降温所产生的能源成本,促使人们开始考虑将整个数据中心搬到太空的成本,因为在太空中降温无需额外费用,而且太阳能够提供无穷无尽的能量。按照如今的发射成本来计算,从经济角度看,这种做法实际上已经开始显得颇具优势。值得注意的是,将服务器集群设置在水下以及北极气候环境中的情况已经成为现实。
无论是在低端设备还是高端设备领域,计算设备的能源消耗都已成为我们需要考虑的一个问题。这意味着,作为架构师,我们现在需要在设计系统时所考虑的众多相互矛盾的质量属性清单中,再加上 “能源效率” 这一项。而且,和其他所有质量属性一样,我们需要考虑一些不容小觑的权衡因素:能源消耗与性能、可用性、可修改性或产品上市时间之间的权衡。因此,将能源效率视为一个至关重要的质量属性十分重要,原因如下:
- 若要掌控任何一项重要的系统质量属性,都需要采取架构层面的方法,能源效率也不例外。如果缺乏用于监控和管理能源的系统级技术,开发人员就只能自行去摸索。最好的情况是,这会导致采用一种临时拼凑的方式来提高能源效率,从而构建出一个难以维护、难以衡量且难以发展演进的系统。最坏的情况是,所采用的方法根本无法可靠地实现预期的能源效率目标。
- 大多数架构师和开发人员并未将能源效率视为一个值得关注的质量属性,因此也不知道如何围绕它进行工程设计和编码。更根本的是,他们缺乏对能源效率需求的理解 —— 不知道如何收集这些需求,以及如何分析其完整性。在如今的教育课程中,能源效率并未被作为程序员需要关注的问题来教授,甚至通常都不会提及。结果是,有些学生可能在获得工程学或计算机科学学位毕业时,都从未接触过这些问题。
- 大多数架构师和开发人员缺乏合适的设计理念,如模型、模式、策略等,用于进行提高能源效率的设计,以及在运行时对其进行管理和监控。但由于能源效率是软件工程领域相对较新的关注点,这些设计理念仍处于起步阶段,目前还没有相关的概念汇编。
云平台通常无需担心能源耗尽的问题(除非在灾难场景下),而对于移动设备和某些物联网设备的用户来说,这却是每天都要面对的问题。在云环境中,扩展和缩减规模是核心能力,因此必须定期就最优资源分配做出决策。对于物联网设备而言,其尺寸、外形规格和散热情况都会限制其设计空间 —— 没有空间容纳体积庞大的电池。此外,预计在未来十年内部署的物联网设备数量极其庞大,这使得它们的能源消耗成为一个令人担忧的问题。
在所有这些情况下,都必须在能源效率与性能和可用性之间寻求平衡,这就要求工程师有意识地权衡这些因素。在云环境中,分配更多的资源,如更多的服务器、更多的存储等,能够提升性能,并增强对单个设备故障的抵御能力,但代价是能源消耗和资金支出的增加。在移动设备和物联网的场景中,通常无法选择分配更多的资源(尽管可以将计算负担从移动设备转移到云后端),所以权衡往往集中在能源效率与性能和可用性之间。最后,在所有场景中,都需要在能源效率与可构建性和可修改性之间进行权衡。
6.1 能效性通用场景
基于这些考量,我们现在能够确定能源效率通用场景的各个部分,具体内容如 表 6.1 所示。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 这明确了是谁或什么事物提出或发起了节约能源或管理能源的请求。 | 终端用户、经理、系统管理员、自动化代理 |
| 触发事件 | 节约能源的请求。 | 总使用量、最大瞬时使用量、平均使用量等等。 |
| 构件 | 这明确了需要管理的内容。 | 特定的设备、服务器、虚拟机、集群等等。 |
| 环境 | 能源通常在运行时进行管理,但基于系统的不同特性,也存在许多值得关注的特殊情况。 | 运行时、已连接、电池供电、低电量模式、节能模式 |
| 响应 | 系统会采取哪些行动来节约或管理能源使用。 | 以下一项或多项: |
| 响应度量 | 这些措施围绕着节省或消耗的能源量,以及对其他功能或质量属性的影响展开。 | 所管理或节省的能源依据以下方面来衡量: …… 同时仍要维持所需的功能水平以及其他质量属性处于可接受的范围。 |
图6.1 展示了一个具体的能源效率场景:一位经理希望在运行时通过在非高峰时段释放未使用的资源来节约能源。系统在释放资源的同时,将数据库查询的最坏情况延迟保持在2秒以内,平均节省了所需总能源的50%。

6.2 能效性策略
能源效率场景的触发因素是在仍能提供所需功能(尽管不一定是全部功能)的同时,产生节约或管理能源的需求。如果在可接受的时间、成本和质量限制范围内实现了能源方面的应对措施,那么这个场景就算是成功的。我们在 图6.2 中展示了这种简单的关系,也就是能源效率策略的目标。

能源效率的核心在于有效地利用资源。我们将相关策略大致分为三大类:资源监测、资源分配以及资源适配(图 6.3)。这里所说的 “资源”,指的是在提供功能的同时会消耗能源的计算设备。这与 第 9 章 中 “硬件” 资源的定义类似,其中包括中央处理器(CPU)、数据存储设备、网络通信设备以及内存。

监控资源
你无法管理无法度量的事物,因此我们从资源监测入手。资源监测的策略包括计量、静态分类和动态分类。
- 计量:计量策略是指通过传感器基础设施近乎实时地收集有关计算资源能源消耗的数据。最粗略的层面,可以从整个数据中心的电表来测量其能源消耗。对于单个服务器或硬盘,可以使用安培计或电度表等外部工具进行测量,也可以使用内置工具,比如带计量功能的机架式电源分配单元(PDU)、专用集成电路(ASIC)等提供的工具。在电池供电的系统中,电池剩余电量可以通过电池管理系统来确定,这是现代电池的一个组成部分。
- 静态分类:有时实时数据收集并不可行。例如,如果一个机构使用的是外部云服务,可能无法直接获取实时能源数据。静态分类使我们能够通过对所使用的计算资源及其已知的能源特性进行编目来估算能源消耗,比如,一个存储设备每次读取操作所消耗的能量。这些特性可以从基准测试中获取,或者参考制造商的规格说明。
- 动态分类:在计算资源的静态模型不够用的情况下,可能需要动态模型。与静态模型不同,动态模型根据诸如工作负载等瞬态条件的信息来估算能源消耗。这个模型可以是简单的表格查询,也可以是基于先前执行过程中收集的数据建立的回归模型,或者是一个模拟模型。
分配资源
资源分配是指在考虑能源消耗的情况下,分配资源来执行任务。资源分配的策略包括减少使用、发现和调度。
- 减少使用:在设备层面,可以通过特定于设备的操作来减少能源使用,比如降低显示器的刷新率或调暗背景亮度。当需求不再需要某些资源时,移除或停用这些资源是另一种降低能源消耗的方法。这可能包括使硬盘进入休眠状态、关闭 CPU 或服务器、降低 CPU 的时钟频率运行,或者切断未使用的处理器模块的电源。还可以采取将虚拟机迁移到最少数量的物理服务器上(整合),并关闭闲置的计算资源这种形式。在移动应用中,假设通信的能源消耗低于计算的能源消耗,那么将部分计算任务发送到云端也可以实现节能。
- 发现:正如我们将在 第 7 章 中看到的,发现服务会将(来自客户端的)服务请求与服务提供商进行匹配,支持对这些服务的识别和远程调用。传统上,发现服务是根据服务请求的描述(通常是一个 API)来进行匹配的。在能源效率的背景下,这个请求可以标注能源信息,使请求者能够根据服务提供商(资源)的(可能是动态的)能源特性来选择服务提供商。对于云服务,这些能源信息可以存储在一个 “绿色服务目录” 中,该目录由计量、静态分类或动态分类(资源监测策略)所提供的信息填充。对于智能手机,这些信息可以从应用商店获取。目前,这类信息充其量只是临时的,而且在服务 API 中通常根本不存在。
- 调度资源:调度是将任务分配给计算资源。正如我们将在 第 9 章 中看到的,调度资源策略可以提高性能。在能源方面,考虑到任务约束并尊重任务优先级,它可以用于有效地管理能源使用。调度可以基于使用一种或多种资源监测策略收集的数据。在云环境中使用能源发现服务,或者在多核环境中使用控制器,计算任务可以在计算资源(如服务提供商)之间动态切换,选择那些能源效率更高或能源成本更低的资源。例如,一个服务提供商的负载可能比另一个更轻,这样它就可以调整能源使用,也许采用前面提到的一些策略,从而平均每单位工作消耗更少的能源。
降低资源需求
这一类策略将在 第 9 章 中详细介绍。这一类策略包括管理事件到达、限制事件响应、对事件进行优先级排序(也许让低优先级事件得不到处理)、减少计算开销、限制执行时间以及提高资源使用效率,所有这些策略都通过减少工作量来直接提高能源效率。这是与减少使用策略相辅相成的策略,因为减少使用策略假定需求保持不变,而降低资源需求策略是一种明确管理(并减少)需求的手段。
6.3 基于策略的能源效率调查问卷
正如 第 3 章 中所述,这份基于策略的调查问卷旨在快速了解一个架构在运用特定策略来管理能源效率方面的程度。
根据 6.2 节 中描述的策略,我们可以设计出一系列受这些策略启发的问题,具体内容如 表 6.2 所示。为了全面了解为提高能源效率所做出的架构选择,分析师需要提出每个问题,并将答案记录在表格中。随后,这些问题的答案可以作为进一步工作的重点,比如:对文档进行调查、对代码或其他制品进行分析、对代码进行逆向工程等等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策和定位 | 推理和假设 |
|---|---|---|---|---|---|
| 资源监控 | 你的系统是否会对能源使用情况进行计量?也就是说,该系统是否会通过传感器基础设施近乎实时地收集有关计算设备实际能源消耗的数据? | ||||
| 系统是否会对设备和计算资源进行静态分类?也就是说,在实时计量不可行或计算成本过高的情况下,系统是否有参考值来估算设备或资源的能源消耗? | |||||
| 系统是否会对设备和计算资源进行动态分类?在由于负载变化或环境条件不同而导致静态分类不准确的情况下,系统是否会根据先前收集的数据,使用动态模型来估算设备或资源在运行时不断变化的能源消耗? | |||||
| 资源分配 | 系统是否会通过减少使用量来缩减资源使用规模?也就是说,当需求不再需要某些资源时,系统能否停用这些资源,以此来节约能源?这可能包括使硬盘进入休眠状态、调暗显示屏亮度、关闭中央处理器(CPU)或服务器、以较低的时钟频率运行CPU,或者关闭处理器中未使用的内存模块。 | ||||
| 考虑到任务约束并尊重任务优先级,系统是否会通过将计算资源(如服务提供商)切换为能源效率更高或能源成本更低的资源,来调度资源,从而更有效地利用能源?调度是否基于(使用一种或多种资源监测策略)收集到的有关系统状态的数据? | |||||
| 系统是否利用发现服务将服务请求与服务提供商进行匹配?在能源效率方面,服务请求可以标注能源需求信息,使请求者能够根据服务提供商(可能是动态的)能源特性来选择服务提供商。 | |||||
| 降低资源需求 | 你是否始终尝试降低资源需求?在此,你可以插入 第9章 中基于策略的性能调查问卷里,属于这一类别的相关问题。 |
6.4 模式
用于提高能源效率的模式示例包括传感器融合、终止异常任务以及电量监测。
传感器融合
移动应用程序和物联网系统常常会使用多个传感器从其所处环境中收集数据。在这种模式下,来自低功耗传感器的数据可用于推断是否需要从高功耗传感器收集数据。在手机应用场景中一个常见的例子是,利用加速度计数据来判断用户是否移动,若用户已移动,则更新全球定位系统(GPS)的位置信息。该模式假定,就能源消耗而言,访问低功耗传感器的成本要比访问高功耗传感器低得多。
优点:
- 这种模式的显著优点是能够以智能的方式尽量减少对高能耗设备的使用,而不是仅仅简单地降低查询高能耗传感器的频率。
权衡:
- 查询并比较多个传感器会增加前期的复杂性。
- 高能耗传感器能够提供更高质量的数据,不过代价是功耗增加。而且,由于单独使用高能耗传感器所需的时间比先查询辅助传感器再使用高能耗传感器的时间要短,所以它能更快地提供数据。
- 在推断结果频繁导致需要访问高功耗传感器的情况下,这种模式可能会导致整体能耗更高。
终止异常任务
移动系统通常会运行来源不明的应用程序,因此可能会在不知不觉中运行一些极其耗电的应用。这种模式提供了一种方法,用于监测此类应用的能源消耗情况,并中断或终止那些耗电量大的操作。例如,如果某个应用程序正在发出声音警报并使手机震动,而用户没有对这些警报做出回应,那么在预设的超时时间过后,该任务就会被终止。
优点:
- 这种模式为管理能源属性未知的应用程序的能耗提供了一种 “故障安全” 的选择。
权衡:
- 任何监测过程都会给系统操作增加少量的开销,这可能会影响系统性能,并且在一定程度上也会影响能源消耗。
- 需要考虑这种模式的易用性。终止耗电量大的任务可能与用户的意图相悖。
电量监测
电量监测模式用于监测和管理系统设备,尽量减少设备处于活动状态的时间。这种模式试图自动禁用那些未被应用程序积极使用的设备和接口。长期以来,这种模式在集成电路中得到应用,在集成电路中,当电路模块不被使用时,会将其关闭以节省能源。
优点:
- 假设被关闭的设备确实不再需要,那么这种模式可以在几乎不影响终端用户的情况下实现智能节能。
权衡:
- 与让设备持续运行相比,一旦设备被关闭,再次开启设备时,在其能够做出响应之前会有一定的延迟。而且,在某些情况下,设备启动所消耗的能量可能比其在一定时间段内稳定运行所消耗的能量还要多。
- 电量监测器需要了解每个设备及其能源消耗特性,这会增加系统设计前期的复杂性。
6.5 扩展阅读
首批发表的能源策略出自 [Procaccianti 14]。在一定程度上,这些策略为本文所呈现的策略提供了灵感。2014 年的这篇论文随后启发了 [Paradis 21]。本章中介绍的许多策略都得益于这两篇论文。
若想全面且通俗地了解软件开发中的能源使用情况,以及开发者尚未知晓的相关内容,你应当阅读 [Pang 16]。
有几篇研究论文探讨了设计选择对能源消耗的影响,比如 [Kazman 18] 和 [Chowdhury 19]。
在 [Fonseca 19] 中,你可以找到关于开发 “能源感知型” 软件重要性的一般性讨论。
[Cruz 19] 和 [Schaarschmidt 20] 对移动设备的能源模式进行了分类整理。
6.6 问题讨论
- 利用通用场景中每种可能的应对措施,编写一组具体的能源效率场景。
- 为智能手机应用程序(例如健康监测应用)创建一个具体的能源效率场景。
- 为数据中心的一组数据服务器创建一个具体的能源效率场景。这个场景与你为问题 2 创建的场景之间有哪些重要区别?
- 列举出你当前使用的笔记本电脑或智能手机所采用的能源效率技术。
- 在你的智能手机中,使用 Wi-Fi 和蜂窝网络在能源方面有哪些权衡取舍?
- 计算你在平均寿命期间,以二氧化碳形式排放到大气中的温室气体量。这相当于进行了多少次谷歌搜索?
- 假设谷歌每次搜索的能源消耗降低 1%。那么每年能节省多少能源?
- 回答问题 7 时,你消耗了多少能源?
第7章 可集成性
融合是生命的基本法则;当我们抗拒融合时,无论是内心还是外在,瓦解都将是必然的结果。因此,我们通过融合领悟到了和谐的概念。
—— 诺曼・卡曾斯(Norman Cousins )
根据韦氏词典的解释,形容词 “integrable” 意为 “能够被集成的”。我们给你一点时间缓口气,消化一下这个深刻的见解。但对于实际的软件系统而言,软件架构师需要关注的不仅仅是让分别开发的组件协同工作;他们还需要考虑预期的以及(在不同程度上)未预期到的未来集成任务的成本和技术风险。这些风险可能与进度安排、性能或技术相关。
对集成问题的一种通用的抽象表述是,一个项目需要将一个软件单元C,或者一组单元C₁、C₂、……Cₙ集成到一个系统S中。S可能是一个平台,我们要将 {Cᵢ} 集成到这个平台中;或者它也可能是一个已经包含了 {C₁、C₂、……、Cₙ} 的现有系统,而我们的任务是为集成 {Cₙ₊₁、……Cₘ} 进行设计,并分析其成本和技术风险。
我们假定我们能够对S进行控制,但 {Cᵢ} 可能不在我们的控制范围之内 —— 比如由外部供应商提供,所以我们对每个Cᵢ的了解程度可能会有所不同。我们对Cᵢ的了解越清晰,设计就会越有能力,分析也会越准确。
当然,S并不是一成不变的,它会不断发展演变,而这种演变可能需要重新进行分析。可集成性(就像可修改性等其他质量属性一样)具有挑战性,因为它涉及在我们可利用的信息不完整的情况下为未来做规划。简而言之,有些集成工作会比其他的更简单,因为在架构设计中已经预见到了这些集成并做好了相应的安排;而有些集成工作则会更复杂,因为在架构设计中没有预见到它们。
考虑一个简单的类比:要将一个北美插头(Cᵢ的一个例子)插入北美插座(电力系统S提供的一个接口),这种 “集成” 轻而易举。然而,要将北美插头插入英式插座,就需要一个转换插头。而且带有北美插头的设备可能只能在 110 伏的电压下运行,那么在它能在 220 伏的英式插座中使用之前,还需要进一步的适配。此外,如果该组件设计为在 60 赫兹的频率下运行,而系统提供的频率是 70 赫兹,那么即使插头能够正常插入,该组件也可能无法按预期运行。S和Cᵢ的设计者所做出的架构决策 —— 比如,是否提供插头转换器或电压适配器,或者是否让组件在不同频率下都能正常运行 —— 将会影响集成的成本和风险。
7.1 评估架构的可集成性
集成难度(即成本和技术风险)可以被看作是 {Cᵢ} 与S的接口规模以及它们之间 “距离” 的函数:
规模指的是 {Cᵢ} 与S之间潜在依赖关系的数量。
距离指的是在每一个依赖关系中解决差异的难度。
依赖关系通常从语法层面进行衡量。例如,如果模块 A 调用组件 B、从 B 继承,或者使用 B,我们就说模块 A 依赖于组件 B。不过,尽管语法层面的依赖关系很重要,并且在未来也依然重要,但依赖关系还可能以任何语法关系都无法检测到的形式出现。两个组件可能会在时间层面产生耦合,或者通过资源产生耦合,因为它们在运行时共享并争夺有限的资源(比如内存、带宽、中央处理器),共同控制一个外部设备,或者存在时间上的依赖关系。又或者它们可能会在语义层面产生耦合,因为它们共享相同协议、文件格式、度量单位、元数据或其他某些方面的知识。区分这些不同类型的依赖关系之所以重要,是因为时间和语义层面的依赖关系往往没有得到充分的理解、明确的认知,也没有被恰当地记录下来。对于一个大型且长期运行的项目来说,缺失的或隐含的知识始终是一种风险,而这些知识缺口必然会增加集成以及集成测试的成本和风险。
想想如今计算领域中服务和微服务的发展趋势。这种方法从根本上来说是为了将组件解耦,以减少它们之间依赖关系的数量和距离。服务之间仅通过已发布的接口相互 “了解”,而且如果该接口是一个合适的抽象,那么对一个服务的修改就不太可能对系统中的其他服务产生连锁反应。组件之间不断增强的解耦是一个已经持续了数十年的行业趋势。面向服务本身仅处理(即减少)了依赖关系在语法层面的问题;它并没有涉及时间或语义层面的问题。那些本应解耦但却对彼此有详细了解并对彼此做出假设的组件,实际上耦合度很高,未来对它们进行修改很可能代价高昂。
为了评估可集成性,“接口” 绝不能仅仅被理解为简单的应用程序编程接口(API)。接口必须描述元素之间所有相关的依赖关系。在尝试理解组件之间的依赖关系时,“距离” 这个概念很有帮助。当组件相互交互时,它们在如何成功协作以完成交互方面的契合度有多高呢?距离可能意味着:
- 语法距离。协作的元素必须就所共享数据元素的数量和类型达成一致。例如,如果一个元素发送的是整数,而另一个元素期望接收的是浮点数,或者对数据字段中的位的解释不同,这种差异就构成了必须弥合的语法距离。数据类型的差异通常很容易观察和预测。比如,这类类型不匹配的问题可能会被编译器检测到。位掩码的差异虽然本质上类似,但往往更难检测,分析师可能需要依靠文档或仔细检查代码来识别这些差异。
- 数据语义距离。协作的元素必须就数据语义达成一致;也就是说,即使两个元素共享相同的数据类型,它们对数据值的解释也可能不同。例如,如果一个数据值表示的海拔高度单位是米,而另一个表示的海拔高度单位是英尺,这就构成了必须弥合的数据语义距离。这类不匹配的问题通常很难观察和预测,不过,如果涉及的元素使用了元数据,分析师的工作会稍微轻松一些。如果有接口文档或元数据描述,数据语义方面的不匹配问题可以通过比较这些文档或描述来发现;如果有代码,也可以通过检查代码来发现。
- 行为语义距离。协作的元素必须就行为达成一致,尤其是在系统的状态和模式方面。例如,在系统启动、关闭或恢复模式下,对一个数据元素的解释可能会有所不同。在某些情况下,这些状态和模式可能会在协议中被明确记录下来。再比如,Cᵢ和Cⱼ可能会对控制做出不同的假设,比如它们都期望对方发起交互。
- 时间距离。协作的元素必须就时间方面的假设达成一致。时间距离的例子包括以不同的速率运行(比如,一个元素以 10 赫兹的速率发送值,而另一个元素期望以 60 赫兹的速率接收值),或者做出不同的时间假设(比如,一个元素期望事件 A 在事件 B 之后发生,而另一个元素期望事件 A 在事件 B 之后发生,且延迟不超过 50 毫秒)。虽然这可以被看作是行为语义的一个子情况,但它非常重要(而且往往很微妙),所以我们将其单独列出。
- 资源距离。协作的元素必须就共享资源方面的假设达成一致。资源距离的例子可能涉及设备(比如,一个元素需要独占对某个设备的访问权,而另一个元素期望共享访问权)或计算资源(比如,一个元素需要 12GB 内存才能达到最佳运行状态,另一个元素需要 10GB 内存,但目标中央处理器只有 16GB 物理内存;或者三个元素同时以每秒 3 兆比特的速率生成数据,但通信信道的峰值容量只有每秒 5 兆比特)。同样,这种距离可以被视为与行为距离相关,但应该有意识地对其进行分析。
在编程语言的接口描述中,通常不会提及这些细节。然而,在组织层面,这些未明确说明、隐含的接口常常会给集成任务(以及修改和调试任务)增加时间成本和复杂性。这就是为什么接口是架构层面需要关注的问题,我们将在 第 15 章 中进一步讨论。
从本质上讲,可集成性在于识别并弥合每个潜在依赖关系中元素之间的距离。这是一种为可修改性做规划的形式。我们将在 第 8 章 中再次探讨这个话题。
7.2 可集成性的通用场景
表 7.1 展示了可集成性的通用场景。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 触发从何而来? | 来自以下一种或多种情况: |
| 触发事件 | 触发事件是什么?也就是说,这里所描述的是哪种类型的集成? | 以下情况之一: |
| 构件 | 系统的哪些部分会参与到这次集成中? | 以下情况之一: |
| 环境 | 当触发事件出现时,系统处于何种状态? | 以下情况之一: |
| 响应 | 一个“可集成的”系统将如何对这种触发事件做出反应? | 以下一种或多种情况: |
| 响应度量 | 如何衡量这种响应? | 以下一种或多种情况: |
图7.1 展示了一个根据通用场景构建的可集成性示例场景:组件市场中出现了一个新的数据过滤组件。该新组件被集成到系统中,并在1个月内完成部署,所需工作量不超过1个人月。

7.3 可集成性策略
可集成性策略的目标是降低添加新组件、重新集成已更改组件以及将多组组件集成在一起以满足系统演进需求的成本和风险,如 图 7.2 所示。

这些策略通过减少组件之间潜在依赖关系的数量,或者缩小组件之间预期的 “距离” 来实现上述目标。图 7.3 展示了可集成性策略的概述。

限制依赖关系
封装
封装是所有其他可集成性策略的基础。因此,它很少单独出现,但其作用隐含在本文描述的其他策略之中。
封装为一个元素引入了一个明确的接口,并确保对该元素的所有访问都通过这个接口进行。对元素内部的依赖关系被消除了,因为所有的依赖关系都必须通过该接口。通过减少依赖关系的数量或缩短它们之间的 “距离”,封装降低了一个元素的变更传播到其他元素的可能性。然而,由于接口限制了外部职责与该元素交互的方式(可能通过一个包装器),这些优势会有所削弱。因此,外部职责只能通过暴露的接口与该元素进行直接交互(间接交互,比如对服务质量的依赖,可能不会改变)。
封装还可以隐藏与特定集成任务不相关的接口。一个例子是某个服务使用的库,它可以对所有使用者完全隐藏,并且在进行更改时,这些更改不会传播给使用者。
因此,封装可以减少依赖关系的数量,以及C和S之间在语法、数据和行为语义方面的 “距离”。
使用中介
中介用于打破一组组件 Cᵢ 之间,或者 Cᵢ 与系统 S 之间的依赖关系。中介可用于解决不同类型的依赖关系。例如,诸如发布 - 订阅总线、共享数据存储库或动态服务发现之类的中介,通过消除数据生产者和消费者双方了解对方身份的必要性,减少了它们之间的依赖关系。其他中介,如数据转换器和协议翻译器,则用于解决语法和数据语义方面的 “距离” 问题。
要确定特定中介的具体优势,需要了解该中介的实际功能。分析师需要判断中介是否减少了组件与系统之间的依赖关系数量,以及它能解决哪些方面的 “距离” 问题(如果有的话)。
中介通常在集成过程中引入,以解决特定的依赖关系,但也可以将其纳入架构中,以提高针对预期场景的可集成性。在架构中包含一个像发布 - 订阅总线这样的通信中介,然后将传感器与外界的通信路径限制在该总线上,这就是一个使用中介来提高传感器可集成性的例子。
限制通信路径
此策略限制了给定元素可以与之通信的元素集合。在实践中,该策略通过限制元素的可见性(当开发人员看不到某个接口时,他们就无法使用它)和授权(即只允许访问经过授权的元素)来实现。在面向服务的架构(SOA)中可以看到限制通信路径的策略,在这种架构中,不鼓励点对点的请求,而是强制所有请求都通过企业服务总线,以便能够一致地进行路由和预处理。
遵循标准
系统实现中的标准化是实现跨平台和跨供应商的可集成性与互操作性的主要推动因素。标准在其规定的范围方面差异很大。有些标准侧重于定义语法和数据语义。其他标准则包含更丰富的描述,例如那些描述包含行为和时间语义的协议的标准。
标准在其适用范围或采用程度上也有所不同。例如,由被广泛认可的标准制定组织发布的标准,如电气和电子工程师协会(IEEE)、国际标准化组织(ISO)和对象管理组织(OMG)发布的标准,更有可能被广泛采用。一个组织内部的惯例,特别是如果有详细的文档记录并且得到严格执行,也可以提供与 “内部标准” 类似的好处,不过在集成来自该内部标准适用范围之外的组件时,所期望获得的好处就会少一些。
采用标准可以是一种有效的可集成性策略,尽管其有效性取决于该标准所解决的差异方面能够带来的好处,以及未来的组件供应商遵循该标准的可能性。将与系统 S 的通信限制为必须使用该标准,通常会减少潜在的依赖关系数量。根据标准中所定义的内容,它还可能解决语法、数据语义、行为语义和时间方面的 “距离” 问题。
抽象通用服务
当两个元素提供的服务相似但不完全相同时,将这两个特定元素隐藏在一个更通用服务的公共抽象背后可能会很有用。这种抽象可以通过由这两个元素共同实现的公共接口来体现,也可能涉及一个中介,该中介将对抽象服务的请求转换为对隐藏在该抽象背后的元素的更具体请求。这样产生的封装可以向系统中的其他组件隐藏这些元素的细节。从可集成性的角度来看,这意味着未来的组件可以与单个抽象进行集成,而不是分别与每个特定元素进行集成。
当抽象通用服务策略与中介(如包装器或适配器)结合使用时,它还可以规范特定元素之间在语法和语义上的差异。例如,当系统使用来自不同制造商的许多相同类型的传感器时,我们就能看到这种情况,每个传感器都有自己的设备驱动程序、精度或定时特性,但架构为它们提供了一个通用接口。再比如,你的浏览器可以兼容各种广告拦截插件,由于有插件接口,浏览器本身可以完全不必在意你选择了哪种插件。
抽象通用服务使得在处理常见的基础设施问题(如转换、安全机制和日志记录)时能够保持一致性。当这些功能发生变化,或者实现这些功能的组件的新版本发生变化时,可以在较少的地方进行修改。抽象服务通常与一个中介配合使用,该中介可能会进行处理,以隐藏特定元素之间在语法和数据语义上的差异。
适配
发现
发现服务是一个相关地址的目录,每当需要将一种形式的地址转换为另一种形式时,每当目标地址可能是动态绑定的,或者存在多个目标时,它都能派上用场。它是应用程序和服务相互定位的机制。发现服务可用于枚举在不同产品中使用的特定元素的变体。
发现服务中的条目之所以存在,是因为它们已被注册。这种注册可以是静态的,也可以在服务实例化时动态进行。当发现服务中的条目不再相关时,应该将其注销。同样,这可以像在 DNS 服务器中那样静态完成,也可以动态完成。动态注销可以由发现服务本身对其条目进行健康检查来处理,也可以由一个外部软件来执行,该软件知道目录中的某个特定条目何时不再相关。
发现服务中可能包含其本身就是发现服务的条目。同样,发现服务中的条目可能具有其他属性,查询时可以引用这些属性。例如,天气发现服务可能有一个 “预报成本” 的属性;然后你可以向天气发现服务请求一个提供免费预报的服务。
发现策略的作用是减少协作服务之间的依赖关系,这些服务在编写时应该互不了解。这使得服务之间的绑定以及绑定发生的时机都具有灵活性。
定制接口
定制接口是一种在不改变应用程序编程接口(API)或实现的情况下,向现有接口添加功能或隐藏功能的策略。可以在不改变接口的情况下,向其添加诸如转换、缓冲和数据平滑等功能。隐藏功能的一个例子是向不可信用户隐藏特定的函数或参数。这种策略的一个常见动态应用是拦截过滤器,它添加数据验证等功能,以帮助防止 SQL 注入或其他攻击,或者在数据格式之间进行转换。另一个例子是使用面向切面编程的技术,在编译时融入预处理和后处理功能。
定制接口策略允许根据上下文添加或隐藏许多服务所需的功能,并对其进行独立管理。它还使存在语法差异的服务能够在不修改任何一方的情况下实现互操作。
这种策略通常在集成过程中应用;然而,设计一种便于进行接口定制的架构可以提高可集成性。接口定制通常用于在集成过程中解决语法和数据语义方面的 “距离” 问题。它也可以用于解决某些形式的行为语义 “距离” 问题,尽管这样做可能会更复杂(例如,维护一个复杂的状态以适应协议差异),并且也许更准确地说,这应该归类为引入一个中介。
配置行为
配置行为策略由软件组件使用,这些组件按规定的方式实现为可配置的,以便它们能够更轻松地与一系列组件进行交互。组件的行为可以在构建阶段(使用不同的标志重新编译)、系统初始化阶段(读取配置文件或从数据库获取数据)或运行时(在请求中指定协议版本)进行配置。一个简单的例子是配置一个组件,使其在接口上支持标准的不同版本。确保有多种选项可用,会增加系统 S 和未来的组件 C 的假设相匹配的可能性。
在系统 S 的部分功能中构建可配置行为是一种可集成性策略,它使 S 能够支持更广泛的潜在组件 C。这种策略有可能解决语法、数据语义、行为语义和时间方面的 “距离” 问题。
协作
编排
编排是一种使用控制机制来协调和管理特定服务调用的策略,以便这些服务可以相互保持独立。
编排有助于集成一组松散耦合的可重用服务,以创建一个满足新需求的系统。当在架构中以一种支持未来可能集成的服务的方式包含编排时,集成成本会降低。这种策略使未来的集成活动能够专注于与编排机制的集成,而不是与多个组件进行点对点的集成。
工作流引擎通常会使用编排策略。工作流是一组有组织的活动,用于对软件组件进行排序和协调,以完成一个业务流程。它可能由其他工作流组成,而每个工作流本身又可能由聚合的服务组成。工作流模型鼓励重用和灵活性,从而带来更灵活的业务流程。业务流程可以在业务流程管理(BPM)的理念下进行管理,这种理念将流程视为一组需要管理的竞争资产。复杂的编排可以用诸如 BPEL(业务流程执行语言)这样的语言来指定。
编排通过将系统 S 和新组件 {Cᵢ} 之间的依赖关系集中到编排机制上,从而减少了它们之间的依赖关系数量,并完全消除了组件 {Cᵢ} 之间的显式依赖关系。如果编排机制与遵循标准等策略结合使用,它还可以减少语法和数据语义方面的 “距离”。
管理资源
资源管理器是一种特定形式的中介,用于管理对计算资源的访问;它类似于限制通信路径的策略。采用这种策略时,软件组件不允许直接访问某些计算资源(例如,线程或内存块),而是要从资源管理器请求这些资源。资源管理器通常负责以一种保持某些不变量(例如,避免资源耗尽或并发使用)、执行某些公平访问策略或同时满足这两个条件的方式,在多个组件之间分配资源访问权限。资源管理器的例子包括操作系统、数据库中的事务机制、企业系统中线程池的使用,以及在安全关键系统中用于空间和时间分区的 ARINC 653 标准的应用。
管理资源策略通过清楚地暴露资源需求并管理它们的共享使用,来减少系统 S 和组件 C 之间的资源 “距离”。
7.4 基于策略的可集成性调查问卷
基于 7.3 节 中描述的策略,我们可以提出一系列受可集成性策略启发的问题,如 表 7.2 所示。为了全面了解为支持可集成性而做出的架构选择,分析师会提出每个问题,并将答案记录在表格中。这些问题的答案随后可成为进一步工作的重点,例如:研究文档、分析代码或其他工件、对代码进行逆向工程等等。
| 策略组 | 策略问题 | 支持与否 | 风险 | 设计决策和定位 | 推理和假设 |
|---|---|---|---|---|---|
| 限制依赖关系 | 系统是否通过引入明确的接口,并要求对每个元素的所有访问都必须通过这些接口,来封装每个元素的功能? | ||||
| 系统是否广泛使用中介来打破组件之间的依赖关系 —— 例如,消除数据生产者对其消费者的依赖(即让数据生产者无需了解消费者的具体情况)? | |||||
| 系统是否对通用服务进行了抽象,为类似的服务提供了一个通用的、抽象的接口? | |||||
| 系统是否提供了一种方法来限制组件之间的通信路径? | |||||
| 在组件之间如何相互交互以及共享信息方面,系统是否遵循相关标准? | |||||
| 适配 | 系统是否具备静态(即在编译时)定制接口的能力 —— 也就是说,在不改变组件接口的应用程序编程接口(API)或实现方式的情况下,能够添加或隐藏该接口的功能? | ||||
| 系统是否提供了一种“发现服务”,用于对有关服务的信息进行编目和传播? | |||||
| 系统是否提供了一种在构建阶段、初始化阶段或运行时对组件行为进行配置的方法? | |||||
| 协作 | 系统是否包含一种“编排机制”,用于协调和管理组件的调用,从而使这些组件能够相互独立运行(彼此无需知晓对方的存在)? | ||||
| 系统是否提供了一个用于管理对计算资源访问权限的“资源管理器”? |
7.5 模式
前三种模式都围绕着定制接口策略展开,在此将它们作为一组进行介绍:
-
包装器:包装器是一种封装形式,通过这种方式,某个组件被封装在另一种抽象形式之中。包装器是唯一被允许使用该组件的元素;其他所有软件都需通过包装器来使用该组件的服务。包装器会对其所包装组件的数据或控制信息进行转换。例如,某个组件可能期望使用英制单位的输入,但却处于一个其他所有组件都使用公制单位的系统中。包装器能够:
- 将组件接口的某个元素转换为另一种元素。
- 隐藏组件接口的某个元素。
- 不做任何改动地保留组件基本接口的某个元素。
-
桥接器:桥接器将一个任意组件的某些 “需求” 假设转换为另一个组件的某些 “提供” 假设。桥接器与包装器的关键区别在于,桥接器独立于任何特定的组件。此外,桥接器必须由某个外部代理显式调用 —— 有可能是由桥接器所连接的组件之一调用,但并非一定如此。最后这一点应表明,桥接器通常是临时存在的,并且特定的转换是在桥接器构建时(例如,桥接器编译时)定义的。在讨论中介器时,这两个区别的重要性将更加清晰。
与包装器相比,桥接器通常关注的接口转换范围更窄,因为桥接器处理的是特定的假设。桥接器试图处理的假设越多,它所适用的组件就越少。
-
中介器:中介器兼具桥接器和包装器的特性。桥接器和中介器的主要区别在于,中介器包含一个规划功能,可在运行时确定转换方式,而桥接器则是在构建时就确定这种转换。
中介器在成为系统架构中的一个显式组件这方面,也与包装器类似。也就是说,从语义层面看较为基础且通常是临时存在的桥接器,可以被视为附带的修复机制,其在设计中的作用可能是隐含的。相比之下,中介器具有足够的语义复杂性和运行时自主性(持久性),能够在软件架构中发挥重要作用。
优点:
- 这三种模式都允许在不强制对元素或其接口进行修改的情况下访问该元素。
权衡:
- 创建上述任何一种模式都需要预先进行开发工作。
- 所有这些模式在访问元素时都会引入一定的性能开销,不过通常这种开销较小。
面向服务的架构模式
面向服务的架构(SOA)模式描述了一组提供和 / 或使用服务的分布式组件。在 SOA 中,“服务提供者” 组件和 “服务使用者” 组件可以使用不同的实现语言和平台。服务在很大程度上是独立的实体:服务提供者和服务使用者通常是独立部署的,并且往往属于不同的系统,甚至不同的组织。组件具有接口,这些接口描述了它们向其他组件请求的服务以及它们所提供的服务。服务的质量属性可以通过服务级别协议(SLA)来指定和保证,该协议有时可能具有法律约束力。组件通过相互请求服务来执行计算。服务之间的通信通常使用 Web 服务标准,如 Web 服务描述语言(WSDL)或简单对象访问协议(SOAP)来进行。
SOA 模式与微服务架构模式相关(请参阅 第 5 章)。不过,微服务架构被认为是构成单个系统,并由单个组织进行管理,而 SOA 提供的可重用组件被认为是异构的,且由不同的组织进行管理。
优点:
- 服务被设计为可被各种客户端使用,这使得它们更加通用。许多商业组织提供和推广其服务的目标就是被广泛采用。
- 服务是独立的。访问服务的唯一方式是通过其接口以及网络上的消息。因此,除了通过接口之外,服务与系统的其他部分不会进行交互。
- 服务可以使用最合适的语言和技术以异构方式实现。
权衡:
- 由于 SOA 的异构性和不同的所有权,它配备了许多诸如 WSDL 和 SOAP 之类的互操作性特性。这增加了系统的复杂性和开销。
动态发现
动态发现应用了 “发现” 策略,以便在运行时发现服务提供者。因此,服务使用者和具体服务之间可以在运行时进行绑定。
使用动态发现功能意味着期望系统能够清晰地公布可与未来组件集成的可用服务,以及每个服务的最少必要信息。可用的具体信息会有所不同,但通常包括在发现和运行时集成过程中可以通过机器搜索的数据(例如,通过字符串匹配来识别接口标准的特定版本)。
优点:
- 这种模式为将服务绑定在一起形成一个协作的整体提供了灵活性。例如,可以在启动或运行时根据服务的价格或可用性来选择服务。
权衡:
- 动态发现的注册和注销必须实现自动化,并且必须获取或开发用于此目的的工具。
7.6 扩展阅读
本章的大部分内容受到了 [Kazman 20a] 的启发,并引用自其中。
关于可集成性这一质量属性的深入讨论,可在 [Hentonnen 07] 中找到。
[MacCormack 06] 和 [Mo 16] 定义了架构层面的耦合度量标准,并提供了实证依据,这些度量标准在衡量设计的可集成性方面可能会很有用。
《设计模式:可复用的面向对象软件元素》一书 [Gamma 94] 定义并区分了桥接模式、包装器模式和适配器模式。
7.7 问题讨论
1. 回想一下你过去进行过的一次集成工作,也许是将某个库或框架集成到你的代码中。找出你必须处理的各种 “距离”,如 7.1 节 中所讨论的那样。在这些 “距离” 中,哪一个最难解决?
2. 为你正在开发的一个系统编写一个具体的可集成性场景(也许是针对你正在考虑集成的某个组件的探索性场景)。
3. 你认为在实际应用中,哪种可集成性策略最容易实现,为什么?哪种最难以实现,又为什么?
4. 许多可集成性策略与可修改性策略类似。如果你让你的系统具有高度的可修改性,这是否就自动意味着它很容易被集成到另一个环境中?
5. 面向服务架构(SOA)的一个常见应用是为电子商务网站添加购物车功能。哪些商业可用的 SOA 平台提供不同的购物车服务?这些购物车的属性是什么?这些属性在运行时能够被发现吗?
6. 编写一个程序,通过谷歌应用商店(Google Play Store)的应用程序编程接口(API)访问该商店,并返回一份天气预报应用程序及其属性的列表。
7. 大致勾勒出一个动态发现服务的设计。这种服务有助于减少哪些类型的 “距离”?
第8章 可修改性
生存下来的物种,既不是最强壮的,也不是最聪明的,而是最能适应变化的。
——查尔斯·达尔文(Charles Darwin)
变化总会发生。
一项又一项研究表明,典型软件系统的大部分成本产生于其首次发布 之后 。如果说变化是宇宙中唯一不变的东西,那么软件变化不仅持续不断,而且无处不在。进行软件变更,可能是为了添加新功能、更改甚至淘汰旧功能;可能是为了修复缺陷、强化安全或提升性能;可能是为了增强用户体验;可能是为了接纳新技术、新平台、新协议、新标准;也可能是为了让系统协同工作,即便它们原本并非为此设计。
可修改性关乎变化,我们关注它,是为了降低变更的成本与风险。为了规划可修改性,架构师必须考虑四个问题:
- 什么会发生变更? 系统的任何方面都可能发生变更:系统所实现的功能、平台(硬件、操作系统、中间件)、系统运行的环境(必须与之互操作的其他系统、与外界通信所使用的协议)、系统展现出的特性(性能、可靠性,甚至是未来的修改)以及其容量(支持的用户数量、同时进行的操作数量)。
- 变更发生的可能性有多大? 人们无法针对所有潜在的变更来规划一个系统,否则系统永远无法完成,或者即便完成了,成本也会过高,而且很可能在其他方面出现质量属性问题。尽管任何事情都 有可能 发生变更,但架构师必须做出艰难抉择,判断哪些变更有可能发生,进而确定哪些变更将得到支持,哪些不会。
- 何时进行变更以及由谁来进行变更? 在过去,最常见的情况是对源代码进行变更。也就是说,开发人员必须进行变更,然后进行测试,再在新版本中部署。然而现在,何时进行变更的问题与由谁来进行变更的问题交织在一起。终端用户更改屏幕保护程序,显然是对系统的某一方面进行了变更。同样明显的是,这与更改系统以便使用不同的数据库管理系统不属于同一类别。变更可以在实现阶段(通过修改源代码)、编译阶段(使用编译时开关)、构建阶段(通过选择库)、配置设置阶段(通过一系列技术,包括参数设置)或执行阶段(通过参数设置、插件、硬件分配等)进行。变更可以由开发人员、终端用户或系统管理员来实施。能够学习和自适应的系统,对于何时进行变更以及 “谁” 进行变更这个问题,给出了完全不同的答案 —— 系统自身就是变更的主体。
-
变更的成本是多少? 提高系统的可修改性涉及两类成本:
- 引入使系统更具可修改性的机制的成本。
- 使用这些机制进行修改的成本。
例如,进行变更最简单的机制是等待变更请求到来,然后修改源代码以满足请求。在这种情况下,引入机制的成本为零(因为没有特殊机制),使用该机制的成本就是修改源代码并重新验证系统的成本。
在另一个极端,像用户界面生成器这样的应用程序生成器,它将通过直接操作技术生成的设计好的用户界面描述作为输入,然后可能生成源代码。引入这种机制的成本就是获取用户界面生成器的成本,这可能相当可观。使用该机制的成本包括生成输入提供给生成器的成本(此成本可能很高,也可能微不足道)、运行生成器的成本(几乎为零),以及最后对结果进行的任何测试的成本(通常远低于手工编码)。
在这个范围的更远处,是能够发现自身环境、学习并自我修改以适应任何变化的软件系统。对于这些系统,进行修改的成本为零,但实现和测试学习机制的成本可能相当高,而这种能力正是通过这些成本换来的。
对于 次类似的修改,一种变更机制的简化合理性判断是:
N × 不使用该机制进行变更的成本 ≤ 创建该机制的成本 + (N × 使用该机制进行变更的成本)
这里,N 是预期将使用该可修改性机制的修改次数,但这也是一种预测。如果实际发生的变更次数少于预期,那么昂贵的修改机制可能就不合理。此外,创建可修改性机制的成本本可以用在其他方面(机会成本),比如添加新功能、提升性能,甚至用于非软件方面的投资,如招聘或培训。而且,这个等式没有考虑时间因素。从长远来看,构建一个复杂的变更处理机制可能成本更低,但你可能等不及它完成。然而,如果你的代码经常被修改,不引入一些架构机制,只是在已有变更之上不断叠加变更,通常会导致大量的技术债务。我们将在 第 23 章 中探讨架构债务这一主题。
变化在软件系统的生命周期中如此普遍,以至于针对特定类型的可修改性都有了专门的名称。以下突出介绍一些常见的类型:
- 可扩展性 是关于容纳更多的某种事物。就性能而言,可扩展性意味着添加更多资源。性能可扩展性有两种类型:横向可扩展性和纵向可扩展性。横向可扩展性(向外扩展)是指为逻辑单元添加更多资源,例如向服务器集群中添加另一台服务器。纵向可扩展性(向上扩展)是指向物理单元添加更多资源,例如为单台计算机添加更多内存。这两种扩展方式都会出现的问题是,如何有效地利用这些额外资源。所谓 “有效”,是指额外资源能够使系统的某些质量得到可衡量的提升,添加资源时不需要付出过多努力,并且不会过度干扰系统运行。在基于云的环境中,横向可扩展性被称为 弹性 。弹性是一种使客户能够从资源池中添加或移除虚拟机的属性(有关此类环境的进一步讨论,请参阅 第 17 章)。
- 可变性 是指系统及其支持构件(如代码、需求、测试计划和文档)能够以预先规划的方式支持生成一组彼此不同的变体的能力。可变性在产品线中是一个尤为重要的质量属性,产品线是一组相似但在特性和功能上有所不同的系统。如果与这些系统相关的工程资产能够在产品线的成员之间共享,那么整个产品线的成本就会大幅下降。这可以通过引入一些机制来实现,这些机制允许选择构件,并使其适应产品线范围内不同产品环境中的使用。软件产品线中可变性的目标是在一段时间内便于构建和维护该产品线中的产品。
- 可移植性 是指为在一个平台上运行而构建的软件,能够轻松更改以在不同平台上运行的程度。实现可移植性的方法是尽量减少软件对平台的依赖,将依赖关系隔离到明确标识的位置,并编写软件使其在 “虚拟机”(例如 Java 虚拟机)上运行,该虚拟机封装了所有平台依赖。描述可移植性的场景涉及将软件迁移到新平台,要求付出的努力不超过一定水平,或者计算软件中必须更改的位置数量。处理可移植性的架构方法与处理 可部署性 的方法相互交织,我们将在 第 5 章 中讨论可部署性这一主题。
- 位置独立性 是指在分布式软件中,两个部分进行交互时,其中一个或两个部分的位置在运行时之前是未知的。或者,这些部分的位置可能在运行时发生变化。在分布式系统中,服务通常被部署到任意位置,这些服务的客户端必须动态发现它们的位置。此外,分布式系统中的服务在部署到某个位置后,通常必须使其位置可被发现。为实现位置独立性而设计系统,意味着位置易于修改,且对系统其他部分的影响最小。
8.1 可修改性通用场景
基于这些考虑,我们可以构建可修改性的通用场景。表 8.1 总结了该场景。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 引发变更的主体。大多数情况下是人类行为者,但系统也可能是具有学习或自我修改能力的,在这种情况下,变更源就是系统自身。 | 终端用户、开发人员、系统管理员、产品线负责人、系统自身 |
| 触发事件 | 系统需要适应的变更。(在此分类中,我们将修复缺陷视为一种变更,即对原本可能无法正常工作的部分进行更改。) | 添加/删除/修改功能的指令,或更改质量属性、容量、平台或技术的指令;向产品线添加新产品的指令;将服务位置更改到另一地点的指令 |
| 构件 | 被修改的构件。具体的组件或模块、系统平台、用户界面、运行环境,或与之交互的其他系统。 | 代码、数据、接口、组件、资源、测试用例、配置、文档 |
| 环境 | 进行变更的时间或阶段。 | 运行时、编译时、构建时、初始化时、设计时 |
| 响应 | 进行更改并将其整合到系统中。 | 以下一项或多项: |
| 响应度量 | 为实现变更所耗费的资源。 | 成本涉及以下方面: |
图8.1 展示了一个具体的可修改性场景:一位开发人员希望更改用户界面。此更改将在设计时对代码进行,完成更改并测试耗时将不到三小时,且不会产生副作用。

8.2 可修改性的策略
用于控制可修改性的策略,其目标在于控制变更的复杂程度,以及进行变更所需的时间和成本。图 8.2 展示了这种关系。

为了理解可修改性,我们从软件设计中一些最早且最基本的复杂度度量(耦合和内聚)入手,它们最早在 20 世纪 60 年代就有相关描述。
一般来说,影响单个模块的变更,相较于影响多个模块的变更,实施起来更容易且成本更低。然而,如果两个模块的职责在某种程度上存在重叠,那么单个变更很可能会同时影响这两个模块。我们可以通过衡量对一个模块的修改传播到另一个模块的概率,来量化这种重叠。这种关系被称为 “耦合”,高耦合是可修改性的大敌。降低两个模块之间的耦合度,将降低任何影响其中一个模块的修改的预期成本。降低耦合的策略是在原本高度耦合的两个模块之间设置各种类型的中介。
“内聚” 衡量的是模块职责之间的关联紧密程度。通俗来讲,它衡量的是模块的 “目的一致性”。目的一致性可以通过影响模块的变更场景来衡量。模块的内聚性是指影响某一职责的变更场景也会影响其他(不同)职责的概率。内聚性越高,给定变更影响多个模块的概率就越低。高内聚有利于可修改性,低内聚则不利于可修改性。如果模块 A 的内聚性较低,那么可以通过去除不受预期变更影响的职责来提高内聚性。
影响变更成本和复杂度的第三个因素是 “模块的规模”。在其他条件相同的情况下,模块越大,变更就越困难、成本越高,并且更容易出现错误。
最后,我们需要关注变更发生在软件开发生命周期中的哪个阶段。如果忽略为修改准备架构的成本,我们希望变更尽可能晚地绑定。只有当架构做好了适当准备以适应变更时,才能在生命周期后期成功进行变更(即快速且低成本地进行)。因此,可修改性模型中的第四个也是最后一个参数是 “修改的绑定时间”。平均而言,一个为在生命周期后期适应变更而做好充分准备的架构,其成本会低于一个迫使在早期进行相同修改的架构。系统的准备程度意味着,对于在生命周期后期发生的变更,某些成本将为零或非常低。
现在我们可以理解,各种策略及其产生的影响,会作用于这些参数中的一个或多个:减小模块大小、提高内聚性、降低耦合度以及推迟绑定时间。这些策略如 图8.3 所示。

提高内聚
有几种策略涉及在模块之间重新分配职责。采取这一步骤是为了降低单个变更影响多个模块的可能性。
- 拆分模块。如果正在被修改的模块包含内聚性不强的职责,那么修改成本可能会很高。将该模块重构为几个内聚性更强的模块,应该会降低未来变更的平均成本。拆分模块不应只是简单地将一半代码行放入每个子模块;相反,应该合理且恰当地得到一系列本身具有内聚性的子模块。
- 重新分配职责。如果职责 A、A′和 A″(所有相似职责)分散在几个不同的模块中,就应该将它们放在一起。这种重构可能涉及创建一个新模块,也可能涉及将职责移动到现有模块。识别要移动职责的一种方法是,假设一系列可能的变更场景。如果这些场景始终只影响模块的某一部分,那么也许其他部分有独立的职责,应该被移动。或者,如果某些场景需要对多个模块进行修改,那么也许受影响的职责应该被组合到一个新模块中。
降低耦合
现在我们来看降低模块之间耦合的策略。这些策略与 第 7 章 中描述的可集成性策略有重叠,因为减少独立组件之间的依赖(为了可集成性)与减少模块之间的耦合(为了可修改性)类似。
- 封装。见 第 7 章 的讨论。
- 使用中介。见 第 7 章 的讨论。
- 抽象公共服务。见 第 7 章 的讨论。
- 限制依赖。此策略限制给定模块与之交互或依赖的模块。在实践中,通过限制模块的可见性(当开发人员看不到某个接口时,就无法使用它)和授权(只允许授权模块访问)来实现这一策略。在分层架构中可以看到限制依赖策略,其中一层只允许使用更低层(有时只允许使用紧邻的下一层),并且在使用包装器时,外部实体只能看到(并因此依赖)包装器,而看不到它所包装的内部功能。
延迟绑定
由于人的工作几乎总是比计算机工作成本更高且更容易出错,所以尽可能让计算机处理变更几乎总能降低变更成本。如果我们设计的工件具有内在的灵活性,那么运用这种灵活性通常比手工编写特定变更代码成本更低。
参数可能是引入灵活性最广为人知的机制,其使用让人联想到抽象公共服务策略。参数化函数 f(a, b) 比类似的假设 b = 0 的函数 f(a) 更通用。当我们在生命周期中与定义参数不同的阶段绑定某些参数的值时,我们就是在推迟绑定。
一般来说,在生命周期中我们能绑定值的时间越晚越好。然而,设置便于后期绑定的机制往往成本更高,这是一种众所周知的权衡。因此本章前面给出的等式就发挥作用了。只要允许后期绑定的机制具有成本效益,我们就希望尽可能晚地绑定。
以下策略可用于在编译时或构建时绑定值:
- 组件替换(例如,在构建脚本或 makefile 中)
- 编译时参数化
- 面向切面编程
以下策略可用于在部署、启动或初始化时绑定值:
- 配置时绑定
- 资源文件
用于在运行时绑定值的策略包括以下几种:
- 发现(见 第 7 章)
- 解释参数
- 共享存储库
- 多态性
将可修改性机制的构建与使用该机制进行修改区分开来,就有可能涉及不同的利益相关者:一个利益相关者(通常是开发人员)提供机制,另一个利益相关者(管理员或安装人员)稍后使用该机制,可能是在完全不同的生命周期阶段。安装一种机制,以便其他人无需更改任何代码就能对系统进行变更,有时被称为将变更 “外部化”。
8.3 基于策略的可修改性调查问卷
基于 8.2 节 中描述的策略,我们可以设计一组受策略启发的问题,如表 8.2 所示。为全面了解为支持可修改性而做出的架构选择,分析师需提出每个问题,并将答案记录在表格中。这些问题的答案随后可作为进一步工作的重点,如文档调查、代码或其他工件分析、代码逆向工程等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策与定位 | 推理与假设 |
|---|---|---|---|---|---|
| 提高内聚 | 你是否通过拆分模块来提高模块的内聚性?例如,如果你有一个庞大且复杂的模块,能否将其拆分为两个(或更多)内聚性更强的模块? | ||||
| 你是否通过重新分配职责来提高模块的内聚性?例如,如果一个模块中的职责并非服务于同一目的,那么这些职责应被划分到其他模块中。 | |||||
| 降低耦合 | 你是否始终如一地进行封装功能操作?这通常包括将受审查的功能隔离开来,并为其引入一个明确的接口。 | ||||
| 你是否始终使用中介来避免模块之间耦合过紧?例如,如果A调用具体功能C,你可以引入一个抽象层B,在A和C之间进行协调。 | |||||
| 您是否以系统的方式限制模块之间的依赖关系?还是任何系统模块都可以自由地与任何其他模块进行交互? | |||||
| 在您提供若干类似服务的情况下,您是否抽象出通用服务?例如,当您希望您的系统能够跨操作系统、硬件或其他环境变化进行移植时,经常会使用此技术。 | |||||
| 延迟绑定 | 系统是否会定期推迟重要功能的绑定,以便在生命周期的后期进行替换?例如,是否存在可以扩展系统功能的插件、附加组件、资源文件或配置文件? |
8.4 模式
可修改性模式将系统划分为模块,使得这些模块可以分别开发和演进,模块之间几乎没有交互,从而支持可移植性、可修改性和重用性。可能有比任何其他质量属性更多的旨在支持可修改性的模式。我们在此介绍一些最常用的模式。
客户端 - 服务器模式
客户端 - 服务器模式由一个服务器同时为多个分布式客户端提供服务组成。最常见的例子是一个 Web 服务器为网站的多个同时用户提供信息。
服务器与其客户端之间的交互遵循此顺序:
发现:
-
通信由客户端发起,客户端使用发现服务来确定服务器的位置。
-
服务器使用约定的协议响应客户端。
-
交互:
- 客户端向服务器发送请求。
- 服务器处理请求并作出响应。
关于此顺序有几点值得注意:
- 如果客户端的数量超过单个服务器实例的容量,服务器可能有多个实例。
- 如果服务器相对于客户端是无状态的,则独立处理来自客户端的每个请求。
- 如果服务器相对于客户端维护状态,则:
- 每个请求必须以某种方式标识客户端。
- 客户端应发送 “会话结束” 消息,以便服务器可以删除与该特定客户端相关的资源。
- 如果客户端在指定时间内未发送请求,服务器可能会超时,以便可以删除与客户端相关的资源。
优点:
- 服务器与其客户端之间的连接是动态建立的。服务器事先不知道其客户端 - 即服务器与其客户端之间的耦合度低。
- 客户端之间没有耦合。
- 客户端的数量可以轻松扩展,并且仅受服务器容量的限制。如果服务器容量超过,则服务器功能也可以扩展。
- 客户端和服务器可以独立演进。
- 多个客户端可以共享公共服务。
- 与用户的交互被隔离到客户端。这一因素导致了用于管理用户界面的专门语言和工具的开发。
权衡:
- 此模式的实现方式使得通信通过网络(甚至可能是互联网)进行。因此,消息可能会因网络拥塞而延迟,导致性能下降(或至少不可预测)。
- 对于通过其他应用程序共享的网络与服务器通信的客户端,必须为实现安全性(特别是保密性)和保持完整性做出特殊规定。
插件(微内核)模式
插件模式有两种类型的元素 - 提供核心功能集的元素和通过一组固定接口向核心添加功能的专用变体(称为插件)。这两种类型通常在构建时或之后绑定在一起。
使用示例包括以下情况:
- 核心功能可能是一个精简的操作系统(微内核),提供实现操作系统服务所需的机制,例如低级地址空间管理、线程管理和进程间通信(IPC)。插件提供实际的操作系统功能,例如设备驱动程序、任务管理和 I/O 请求管理。
- 核心功能是为其用户提供服务的产品。插件提供可移植性,例如操作系统兼容性或支持库兼容性。插件还可以提供核心产品中未包含的其他功能。此外,它们可以充当适配器,实现与外部系统的集成(见 第 7 章)。
优点:
- 插件提供了一种受控机制来扩展核心产品,并使其在各种环境中有用。
- 插件可以由与微内核开发人员不同的团队或组织开发。这允许开发两个不同的市场:核心产品市场和插件市场。
- 插件可以独立于微内核演进。由于它们通过固定接口进行交互,只要接口不变,这两种类型的元素就不会有其他耦合。
权衡:
- 因为插件可以由不同的组织开发,所以更容易引入安全漏洞和隐私威胁。
分层模式
层模式以这样一种方式划分系统,使得模块可以分别开发和演进,各部分之间几乎没有交互,这支持可移植性、可修改性和重用性。为了实现这种关注点分离,层模式将软件划分为称为层的单元。每一层都是一组提供内聚服务的模块的分组。层之间的 “允许使用” 关系受到一个关键约束:关系必须是单向的。
层完全划分了一组软件,并且每个分区都通过公共接口公开。创建层是为了根据严格的排序关系进行交互。如果(A,B)处于此关系中,我们说分配给层 A 的软件被允许使用层 B 提供的任何公共设施。(在几乎无处不在的垂直排列的层表示中,A 将绘制在 B 上方。)在某些情况下,一层中的模块需要直接使用非相邻的下层模块,尽管通常只允许使用相邻的下层模块。这种较高层的软件使用非相邻下层模块的情况称为 “层桥接”。此模式不允许向上使用。
优点:
- 因为一层被限制仅使用下层,所以下层软件可以更改(只要接口不变)而不影响上层。
- 较低级别的层可以在不同的应用程序中重用。例如,假设某一层允许跨操作系统的可移植性。此层在任何必须在多个不同操作系统上运行的系统中都有用。最低层通常由商业软件提供 - 例如操作系统或网络通信软件。
- 因为 “允许使用” 关系受到约束,所以任何团队必须理解的接口数量减少。
权衡:
- 如果分层设计不正确,实际上可能会碍事,因为没有提供高层程序员所需的低级抽象。
- 分层通常会给系统带来性能损失。如果从最顶层的函数进行调用,它可能必须遍历许多下层才能由硬件执行。
- 如果发生许多层桥接实例,系统可能无法满足其可移植性和可修改性目标,而严格分层有助于实现这些目标。
发布 - 订阅模式
发布 - 订阅是一种架构模式,其中组件主要通过异步消息进行通信,有时称为 “事件” 或 “主题”。发布者不知道订阅者,订阅者只知道消息类型。使用发布 - 订阅模式的系统依赖于隐式调用;即,发布消息的组件不会直接调用任何其他组件。组件在一个或多个事件或主题上发布消息,其他组件注册对发布的兴趣。在运行时,当发布消息时,发布 - 订阅(或事件)总线通知所有注册对该事件或主题感兴趣的元素。通过这种方式,消息发布导致其他组件(中的方法)的隐式调用。结果是发布者和订阅者之间的松散耦合。
发布 - 订阅模式有三种类型的元素:
- 发布者组件。发送(发布)消息。
- 订阅者组件。订阅然后接收消息。
- 事件总线。作为运行时基础设施的一部分管理订阅和消息分发。
优点:
- 发布者和订阅者是独立的,因此耦合松散。添加或更改订阅者只需要注册事件,不会对发布者造成更改。
- 系统行为可以通过更改发布的消息的事件或主题轻松更改,从而可能影响哪些订阅者接收并处理此消息。这个看似小的更改可能会产生很大的影响,因为可以通过添加或抑制消息来打开或关闭功能。
- 事件可以轻松记录,以便记录和回放,从而重现手动难以重现的错误条件。
权衡:
- 发布 - 订阅模式的某些实现可能会对性能(延迟)产生负面影响。使用分布式协调机制将改善性能下降。
- 在某些情况下,组件无法确定接收发布消息需要多长时间。通常,在发布 - 订阅系统中,系统性能和资源管理更难以推理。
- 使用此模式可能会对同步系统产生的确定性产生负面影响。由于事件导致的方法调用顺序在某些实现中可能会有所不同。
- 使用发布 - 订阅模式可能会对可测试性产生负面影响。事件总线看似小的更改 - 例如与哪些事件相关联的组件的更改 - 可能对系统行为和服务质量产生广泛影响。
- 某些发布 - 订阅实现限制了灵活实现安全性(完整性)的可用机制。由于发布者不知道其订阅者的身份,反之亦然,端到端加密受到限制。从发布者到事件总线的消息可以唯一加密,从事件总线到订阅者的消息可以唯一加密;然而,任何端到端加密通信都要求所有涉及的发布者和订阅者共享相同的密钥。
8.5 扩展阅读
软件工程及其历史的认真学习者应该阅读两篇关于可修改性设计的早期论文。第一篇是埃德加・迪克斯特拉(Edsger Dijkstra)1968 年关于 T.H.E. 操作系统的论文,这是第一篇谈论设计使用层的系统以及这种方法带来的可修改性好处的论文 [Dijkstra 68] 。第二篇是大卫・帕纳斯(David Parnas)1972 年的论文,该论文引入了信息隐藏的概念。 [Parnas 72] 建议不是根据模块的功能来定义模块,而是根据其内部化变更影响的能力来定义。
更多关于可修改性的模式在《Software Systems Architecture: Working With Stakeholders Using Viewpoints and Perspectives》[Woods 11] 中给出。
解耦级别指标 [Mo 16] 是一个架构级别的耦合指标,可以深入了解架构在全球范围内的耦合程度。此信息可用于跟踪随时间的耦合情况,作为技术债务的早期预警指标。
在 [Mo 19] 中描述了一种检测模块性违规以及其他类型设计缺陷的全自动方法。检测到的违规可作为重构的指南,以提高内聚性并减少耦合。
打算用于软件产品线的软件模块通常充满了变化机制,允许它们快速修改以用于不同的应用程序 - 即在产品线的不同成员中。产品线中组件的变化机制列表可以在 Bachmann 和 Clements [Bachmann 05]、Jacobson 及其同事 [Jacobson 97] 以及 Anastasopoulos 及其同事 [Anastasopoulos 00] 的作品中找到。
层模式有多种形式和变体 - 例如 “带有边车的层”。 [DSA2] 的 第 2.4 节 对它们进行了分类,并讨论了为什么(令人惊讶的是,对于一个半个多世纪前发明的架构模式)您曾经见过的大多数软件层图都非常模糊。如果您不想购买这本书,那么 [Bachmann 00a] 是一个很好的替代品。
8.6 问题讨论
1. 可修改性有多种形式并且有许多名称;我们在本章的开头部分讨论了一些,但该讨论只是触及表面。查找一个涉及质量属性的 IEEE 或 ISO 标准,并编制一份提及某种形式的可修改性的质量属性列表。讨论差异。
2. 在您为问题 1 编制的列表中,哪些策略和模式对每个特别有帮助?
3. 对于由于问题 2 而发现的每个质量属性,编写一个表达它的可修改性场景。
4. 在许多自助洗衣店中,洗衣机和烘干机接受硬币但不找零。而是由单独的机器找零。在普通的自助洗衣店中,每台找零机对应六到八台洗衣机和烘干机。在这种安排中,您看到了哪些可修改性策略在起作用?关于可用性,您能说些什么?
5. 对于问题 4 中的自助洗衣店,描述似乎是按照所述安排机器的目的的特定形式的可修改性(使用可修改性场景)。
6. 在 第 7 章 中介绍的 “包装器” 是一种常见的有助于可修改性的架构模式。包装器体现了哪些可修改性策略?
7. 其他可以提高系统可修改性的常见架构模式包括黑板、代理、点对点、模型 - 视图 - 控制器和反射。根据其封装的可修改性策略对每个进行讨论。
8. 一旦在架构中引入了中间件,某些模块可能会试图绕过它,无论是无意地(因为它们不知道中间件)还是有意地(出于性能、方便或习惯)。讨论一些防止不希望的绕过中间件的架构手段。也讨论一些非架构手段。
9. 抽象通用服务策略旨在减少耦合,但也可能降低内聚性。讨论。
10. 讨论客户端 - 服务器模式是具有运行时绑定的微内核模式这一命题。
第9章 性能
身行一例,胜似千言。
—— 梅・韦斯特(Mae West)
是时候谈谈性能了。
这里所说的性能,关乎时间以及软件系统满足时间要求的能力。令人无奈的是,计算机上的操作都需要耗费时间。计算耗时以数千纳秒为单位,磁盘访问(无论是固态硬盘还是旋转磁盘)耗时以数十毫秒为单位,而网络访问耗时从同一数据中心内的数百微秒到跨洲际消息的 100 毫秒以上不等。在为系统设计性能时,必须考虑时间因素。
当事件发生时(中断、消息、来自用户或其他系统的请求,或者标记时间流逝的时钟事件),系统或系统的某个组件必须及时做出响应。描述可能发生的事件(以及何时发生),以及系统或组件对这些事件基于时间的响应,这就是讨论性能的关键所在。
基于 Web 的系统事件以用户通过诸如 Web 浏览器之类的客户端发出的请求形式出现(数量可达数万甚至数千万)。服务会从其他服务接收事件。在内燃机控制系统中,事件来自操作员的控制操作以及时间的流逝;系统必须控制气缸处于正确位置时的点火时机,以及燃料的混合比例,以实现功率和效率最大化并减少污染。
对于基于 Web 的系统、以数据库为中心的系统,或者处理来自其环境输入信号的系统,期望的响应可能表示为单位时间内能够处理的请求数量。对于发动机控制系统,响应可能是点火时间的允许偏差。在每种情况下,都可以对到达的事件模式和响应模式进行描述,这种描述构成了构建性能场景的语言。
在软件工程发展的大部分历程中,由于早期计算机速度慢且成本高,要执行的任务远远超出了计算机的处理能力,性能一直是架构设计的驱动因素。因此,性能常常以牺牲其他所有质量属性为代价。随着硬件性价比的不断下降以及软件开发成本的持续上升,其他质量属性已成为与性能同等重要的考量因素。
但性能仍然至关重要。仍然存在(并且可能永远存在)一些重要问题,我们知道如何用计算机解决这些问题,但却无法快速解决到足以让其发挥作用的程度。
所有系统都有性能要求,即使这些要求没有明确表述出来。例如,文字处理工具可能没有任何明确的性能要求,但毫无疑问,你会认同在屏幕上看到输入字符出现之前等待一个小时(或者一分钟、一秒钟)是不可接受的。性能对于所有软件而言,始终是一个至关重要的质量属性。
性能通常与可扩展性相关联 —— 也就是说,在保持良好性能的同时,提高系统的工作处理能力。它们确实相互关联,尽管从技术上讲,可扩展性是指让系统易于以特定方式进行更改,因此它属于一种可修改性,正如 第 8 章 所讨论的那样。此外,第 17 章 会明确讨论云端服务的可扩展性。
通常,性能提升是在构建出系统版本并发现其性能不足之后才进行的。你可以通过在架构设计时就考虑性能因素来避免这种情况。例如,如果你在设计系统时采用了可扩展的资源池,随后从监测数据中确定该资源池成为了瓶颈,那么你可以轻松扩大资源池的规模。否则,你的选择就会很有限(而且大多都不理想)并且可能需要大量返工。
将大量时间花在优化仅占总时间很小比例的系统部分上是没有意义的。通过记录时间信息来监测系统,将有助于你确定实际时间花在了哪里,从而让你专注于提升系统关键部分的性能。
9.1 性能通用场景
性能场景始于一个事件到达系统。要正确响应该事件,需要消耗资源(包括时间)。在此过程中,系统可能同时在处理其他事件。
并发
并发是架构师必须理解的较为重要的概念之一,但在计算机科学课程中却鲜少讲授。并发指的是并行发生的操作。例如,假设有一个线程执行某些语句,同时另一个线程也执行相同的语句。那么两个线程都执行完这些语句后,x 的值是多少呢?可能是 2,也可能是 3。至于值为 3 是如何出现的,就留给你去思考啦 —— 或者我该说,把这个问题穿插留给你思考?
x = 1; x++;只要系统创建一个新线程,就会出现并发情况,因为根据定义,线程是独立的控制序列。系统中的多任务处理是由独立线程支持的。通过使用线程,系统可以同时支持多个用户。只要系统在多个处理器上执行,无论这些处理器是独立封装还是多核处理器,都会出现并发。此外,当你使用并行算法、诸如 MapReduce 这样的并行化基础架构、NoSQL 数据库,或者使用各种并发调度算法中的某一种时,都必须考虑并发问题。换句话说,并发以多种方式为你所用。
当你拥有多个 CPU 或者可以利用的等待状态时,并发是有益的。允许操作并行发生可以提高性能,因为一个线程中出现的延迟能让处理器在另一个线程上继续推进。但是,由于刚才描述的交错现象(称为 “竞态条件”),并发也必须谨慎管理。
正如我们的例子所示,当存在两个控制线程且有共享状态时,就可能出现竞态条件。并发管理常常归结为管理状态的共享方式。防止竞态条件的一种技术是使用锁来强制对状态进行顺序访问。另一种技术是根据执行部分代码的线程对状态进行分区。也就是说,如果我们有两个
x的实例,那么x就不会被这两个线程共享,也就不会出现竞态条件。竞态条件是最难发现的错误类型之一;这类错误的出现具有偶然性,并且取决于(可能很微小的)时间差异。我曾经在一个操作系统中遇到过一个竞态条件,却一直无法追踪到原因。我在代码中添加了一个测试,以便下次竞态条件出现时,能触发调试过程。结果过了一年多这个错误才再次出现,从而得以确定原因。
不要因为并发相关的困难就放弃使用这一非常重要的技术。只要在使用时清楚,你必须仔细识别代码中的关键部分,并确保(或采取措施确保)这些部分不会出现竞态条件。
—LB
表9.1 总结了性能的通用场景。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 触发可能来自一个用户(或多个用户)、外部系统,或者正在研究的系统的某个部分。 | 外部: 内部: |
| 触发事件 | 触发是事件的发生。该事件可以是服务请求,也可以是所考虑系统或外部系统某种状态的通知。 | 周期性、偶发性或随机性事件的发生: |
| 构件 | 被触发的构件可能是整个系统,也可能只是系统的一部分。例如,开机事件可能会触发整个系统。用户请求可能会到达(触发)用户界面。 | |
| 环境 | 触发到来时系统或组件的状态。异常模式(如错误模式、过载模式)会影响响应。例如,设备在被锁定前允许三次不成功的登录尝试。 | 运行时。系统或组件可能处于以下运行模式: |
| 响应 | 系统将处理该触发事件。处理触发事件需要花费时间。这一时间可能用于计算,也可能是因为处理过程因共享资源的竞争而受阻。由于系统过载或处理链中某个环节出现故障,请求可能无法得到满足。 | |
| 响应度量 | 时间度量指标可以包括延迟或吞吐量。有时间期限要求的系统还可以衡量响应的抖动情况以及是否能够满足期限要求。统计有多少请求未得到满足,这也是一种度量方式,计算资源(如CPU、内存、线程池、缓冲区)的利用率同样属于度量范畴。 |
图9.1 给出了一个具体的性能场景示例:“在正常运行情况下,500名用户在30秒的时间间隔内发起2000个请求。系统处理所有请求的平均延迟为2秒。”

9.2 性能策略
性能策略的目标是在基于时间或资源的某些限制条件下,对到达系统的事件生成响应。该事件可以是单个事件或事件流,并且是执行计算的触发器。性能策略控制用于生成响应所花费的时间或资源,如 图 9.2 所示。

在事件到达后至系统对其响应完成这段时间内的任何时刻,系统要么正在努力响应该事件,要么因某种原因处理被阻塞。这就导致了影响响应时间和资源使用的两个基本因素:处理时间(系统努力响应并积极消耗资源时)和阻塞时间(系统无法响应时)。
-
处理时间与资源使用:处理过程会消耗资源,而这需要时间。事件由一个或多个组件的执行来处理,这些组件所耗费的时间本身就是一种资源。硬件资源包括中央处理器(CPU)、数据存储、网络通信带宽和内存。软件资源包括所设计系统定义的实体。例如,必须对线程池和缓冲区进行管理,并且对关键部分的访问必须按顺序进行。
例如,假设一个组件生成了一条消息。该消息可能会被发送到网络上,之后到达另一个组件。然后它会被放入一个缓冲区;以某种方式进行转换;根据某种算法进行处理;为输出再次转换;放入输出缓冲区;并继续发送给某个组件、另一个系统或某个参与者。这些步骤中的每一步都会增加该事件处理的总体延迟和资源消耗。
不同资源在其利用率接近自身容量(即达到饱和)时,表现各不相同。例如,随着 CPU 负载加重,性能通常会较为平稳地下降。相比之下,当内存开始耗尽时,在某个节点上,页面交换会变得不堪重负,性能会突然崩溃。
-
阻塞时间与资源争用:计算可能会因为对某些所需资源的争用、资源不可用,或者因为计算依赖于其他尚未完成的计算结果而被阻塞:
- 资源争用:许多资源在同一时间只能被单个客户端使用。因此,其他客户端必须等待获取这些资源。图 9.2 展示了到达系统的事件。这些事件可能是单一流中的,也可能来自多个流。多个流争夺同一资源,或者同一流中的不同事件争夺同一资源,都会导致延迟。对某一资源的争用越激烈,延迟就越大。
- 资源可用性:即使不存在争用情况,如果资源不可用,计算也无法继续。资源不可用可能是因为资源离线,或者组件因任何原因出现故障。
- 对其他计算的依赖:一项计算可能必须等待,因为它必须与另一项计算的结果同步,或者因为它在等待自己发起的一项计算的结果。如果一个组件调用另一个组件,并且必须等待该组件响应,当被调用组件位于网络另一端(而非与调用组件位于同一处理器上),或者当被调用组件负载过重时,等待时间可能会很长。
无论原因如何,你都必须在架构中找出那些资源限制可能对总体延迟产生重大影响的地方。
基于上述背景,我们来探讨策略类别。我们要么减少对资源的需求(控制资源需求),要么让现有资源更有效地处理需求(管理资源)。
控制资源需求
提高性能的一种方法是谨慎管理对资源的需求。这可以通过减少处理的事件数量或限制系统响应事件的速率来实现。此外,还可以应用多种技术,确保明智地使用现有资源:
-
管理工作请求:减少工作量的一种方法是减少进入系统进行处理的请求数量。具体方法如下:
- 管理事件到达:管理来自外部系统的事件到达的常用方法是制定服务水平协议(SLA),规定愿意支持的最大事件到达速率。SLA 是一种 “系统或组件将以 Y 的响应时间处理每单位时间内到达的 X 个事件” 形式的协议。该协议既约束了系统(它必须提供相应响应),也约束了客户端(如果客户端每单位时间发出超过 X 个请求,则无法保证响应)。因此,从客户端的角度来看,如果每单位时间需要处理超过 X 个请求,就必须使用处理请求元素的多个实例。SLA 是管理基于互联网系统可扩展性的一种方法。
- 管理采样率:在系统无法维持足够响应水平的情况下,可以降低触发事件的采样频率,例如降低从传感器接收数据的速率或每秒处理的视频帧数。当然,这样做的代价是视频流的保真度或从传感器数据中收集到的信息的准确性。不过,如果结果 “足够好”,这就是一种可行的策略。这种方法常用于信号处理系统,例如,可以选择具有不同采样率和数据格式的不同编码方式。这种设计选择旨在维持可预测的延迟水平;你必须决定较低保真度但稳定的数据流,是否优于延迟不稳定的数据流。一些系统会根据延迟测量结果或准确性需求动态管理采样率。
- 限制事件响应:当离散事件到达系统(或组件)的速度过快而无法处理时,必须将这些事件排队,直到可以处理,或者直接丢弃。你可以选择仅以设定的最大速率处理事件,从而确保对实际处理的事件进行可预测的处理。此策略可以由队列大小或处理器利用率超过某个警告级别触发。或者,也可以由违反 SLA 的事件速率触发。如果你采用此策略,且不允许丢失任何事件,那么必须确保队列足够大,以处理最坏的情况。相反,如果你选择丢弃事件,那么需要选择一种策略:是记录丢弃的事件还是直接忽略?是否通知其他系统、用户或管理员?
- 对事件进行优先级排序:如果并非所有事件都同等重要,可以实施一种优先级方案,根据处理事件的重要程度对事件进行排序。如果事件发生时没有足够的资源来处理它们,低优先级事件可能会被忽略。与始终处理所有事件的系统相比,忽略事件消耗的资源(包括时间)极少,从而提高了性能。例如,建筑管理系统可能会发出各种警报。诸如火灾警报等危及生命的警报应比诸如房间温度过低等信息性警报具有更高的优先级。
-
降低计算开销:对于进入系统的事件,可以采用以下方法来减少处理每个事件所涉及的工作量:
- 减少间接层次:正如我们在 第 8 章 中看到的,使用中介(这对可修改性非常重要)会增加处理事件流的计算开销,因此去除中介可以减少延迟。这是经典的可修改性与性能的权衡。关注点分离(可修改性的另一个关键因素),如果导致事件由一系列组件而非单个组件处理,也会增加处理事件所需的处理开销。不过,你或许能够两全其美:巧妙的代码优化可以让你在编程时使用支持封装的中介和接口(从而保持可修改性),但在运行时减少甚至在某些情况下消除代价高昂的间接层次。同样,一些代理允许客户端和服务器之间直接通信(在最初通过代理建立关系之后),从而消除所有后续请求的间接步骤。
- 将通信资源置于同一位置:上下文切换和组件间通信成本会不断累积,尤其是当组件位于网络上的不同节点时。降低计算开销的一种策略是将资源置于同一位置。置于同一位置可能意味着将协同工作的组件托管在同一处理器上,以避免网络通信的时间延迟;也可能意味着将资源置于同一运行时软件组件中,以避免子程序调用的开销;或者意味着将多层架构的各层放置在数据中心的同一机架上。
- 定期清理:降低计算开销的一个特殊情况是定期清理效率变低的资源。例如,哈希表和虚拟内存映射可能需要重新计算和重新初始化。许多系统管理员甚至普通计算机用户定期重启系统,正是出于这个原因。
- 限制执行时间:可以对用于响应事件的执行时间设置限制。对于迭代的、依赖数据的算法,限制迭代次数是限制执行时间的一种方法。然而,代价通常是计算结果的准确性降低。如果你采用此策略,需要评估其对准确性的影响,看看结果是否 “足够好”。这种资源管理策略通常与管理采样率策略结合使用。
- 提高资源使用效率:提高关键领域所使用算法的效率可以减少延迟,提高吞吐量并改善资源消耗。对于一些程序员来说,这是他们的主要性能策略。如果系统性能不佳,他们会尝试 “优化” 处理逻辑。如你所见,这种方法实际上只是众多可用策略之一。
管理资源
即使对资源的需求无法控制,但对这些资源的管理是可以做到的。有时可以用一种资源来换取另一种资源。例如,中间数据可以保存在缓存中,也可以根据哪种资源更关键(时间、空间或网络带宽)来重新生成。以下是一些资源管理策略:
- 增加资源:更快的处理器、额外的处理器、更多的内存和更快的网络都有可能提高性能。在选择资源时,成本通常是一个需要考虑的因素,但在许多情况下,增加资源是实现即时性能提升最经济的方式。
- 引入并发:如果请求可以并行处理,阻塞时间就可以减少。可以通过在不同线程上处理不同的事件流,或创建额外的线程来处理不同的活动集来引入并发。(引入并发后,可以使用调度资源策略选择调度策略,以实现你期望的目标。)
- 维护计算的多个副本:这种策略减少了如果将所有服务请求都分配给单个实例时可能发生的争用。微服务架构中的复制服务或服务器池中的复制 Web 服务器就是计算副本的示例。负载均衡器是一种软件,它将新工作分配给可用的重复服务器之一;分配标准各不相同,但可以简单到采用轮询方案,或将下一个请求分配给最不繁忙的服务器。负载均衡器模式将在 9.4 节 中详细讨论。
- 维护数据的多个副本:维护数据多个副本的两个常见示例是数据复制和缓存。“数据复制” 是指保留数据的单独副本,以减少多个同时访问造成的争用。由于复制的数据通常是现有数据的副本,因此系统必须承担保持副本一致性和同步的责任。“缓存” 也涉及保留数据副本(其中一组数据可能是另一组数据的子集),但存储在具有不同访问速度的存储设备上。不同的访问速度可能是由于内存速度与二级存储速度的差异,或者本地与远程通信速度的差异造成的。缓存的另一个任务是选择要缓存的数据。一些缓存只是简单地保留最近请求的数据副本,但也可以根据行为模式预测用户未来的请求,并在用户提出请求之前开始进行必要的计算或预取。
- 限制队列大小:这种策略控制排队到达的最大数量,从而控制用于处理到达请求的资源。如果你采用此策略,需要制定队列溢出时的处理策略,并确定不响应丢失的事件是否可以接受。此策略通常与限制事件响应策略结合使用。
- 调度资源:每当发生对资源的争用时,就必须对资源进行调度。处理器需要调度,缓冲区需要调度,网络也需要调度。作为架构师,你需要了解每种资源使用的特点,并选择与之相匹配的调度策略。(请参阅 “调度策略” 侧边栏。)
图 9.3 总结了性能策略。

调度策略从概念上讲包含两个部分:优先级分配和调度。所有调度策略都会分配优先级。在某些情况下,分配方式简单如先进先出(即 FIFO)。而在其他情况下,优先级可能与请求的截止期限或语义重要性相关联。调度的竞争标准包括资源的最优使用、请求的重要性、资源使用数量最小化、延迟最小化、吞吐量最大化、防止饥饿以确保公平性等等。你需要了解这些可能相互冲突的标准,以及所选调度策略对系统满足这些标准能力的影响。
只有在资源可用时,高优先级的事件流才能被调度(分配给该资源)。有时这取决于是否抢占当前资源使用者。可能的抢占选项如下:可随时抢占、只能在特定的抢占点抢占,或者正在执行的进程不可被抢占。以下是一些常见的调度策略:
先进先出:先进先出队列将所有资源请求平等对待,并依次满足它们。先进先出队列存在一种可能性,即一个请求可能会被排在另一个生成响应耗时较长的请求之后。只要所有请求确实同等重要,这就不是问题,但如果某些请求的优先级高于其他请求,就会带来挑战。
固定优先级调度:固定优先级调度为每个资源请求源分配特定的优先级,并按照该优先级顺序分配资源。这种策略确保为高优先级请求提供更好的服务。然而,它也存在一种可能性,即低优先级但仍然重要的请求可能会等待任意长的时间才能得到处理,因为它被排在一系列高优先级请求之后。以下是三种常见的优先级确定策略:
- 语义重要性:语义重要性根据生成任务的某些领域特性静态地分配优先级。
- 截止期限单调调度:截止期限单调调度是一种静态优先级分配方式,为截止期限较短的流分配更高的优先级。这种调度策略用于调度具有实时截止期限的不同优先级的流。
- 速率单调调度:速率单调调度是针对周期性流的一种静态优先级分配方式,为周期较短的流分配更高的优先级。这种调度策略是截止期限单调调度的一种特殊情况,但更为人熟知,且更有可能得到操作系统的支持。
动态优先级调度:策略包括以下几种:
- 轮询调度:轮询调度策略对请求进行排序,然后在每次有分配机会时,按照该顺序将资源分配给下一个请求。轮询调度的一种特殊形式是循环执行,即可能的分配时间以固定的时间间隔指定。
- 最早截止期限优先调度:最早截止期限优先调度根据截止期限最早的待处理请求来分配优先级。
- 最少松弛时间优先调度:这种策略将最高优先级分配给 “松弛时间” 最少的任务,松弛时间是指剩余执行时间与任务截止期限之间的差值。
对于单个处理器和可抢占的进程,最早截止期限优先调度和最少松弛时间优先调度策略都是最优选择。也就是说,如果一组进程可以被调度以满足所有截止期限,那么这些策略就能成功调度该组进程。
静态调度:循环执行调度是一种调度策略,其中抢占点和资源分配顺序是离线确定的。这样就避免了调度程序的运行时开销。
实际应用中的性能策略
策略是通用的设计原则。为了说明这一点,思考一下你所在地区的道路和高速公路系统的设计。交通工程师采用了一系列设计 “技巧” 来优化这些复杂系统的性能,性能有多种衡量指标,例如吞吐量(每小时有多少辆车从郊区到达足球场)、平均延迟(从你家到市中心平均需要多长时间)以及最坏情况下的延迟(急救车辆将你送到医院需要多长时间)。这些技巧是什么呢?正是我们熟知的策略。
让我们来看一些例子:
- 管理事件速率:高速公路入口匝道的信号灯只在设定的时间间隔允许车辆驶入高速公路,车辆必须在匝道上等待(排队)轮到自己。
- 对事件进行优先级排序:开着警灯、鸣着警笛的救护车和警车比普通市民车辆具有更高的优先级;一些高速公路设有高承载车辆(HOV)车道,优先让载有两名或更多乘客的车辆通行。
- 维护多个副本:在现有道路上增加车道或修建平行路线。
此外,系统的使用者也可以采用他们自己的技巧:
- 增加资源:例如购买一辆法拉利。在其他条件相同的情况下,在空旷道路上,一辆由熟练司机驾驶的最快的车能让你更快到达目的地。
- 提高效率:找到一条比当前路线更快和 / 或更短的新路线。
- 降低计算开销:跟紧前面的车,或者让更多人乘坐同一辆车(即拼车)。
这段讨论的意义何在呢?套用格特鲁德・斯泰因(Gertrude Stein)的话来说:性能就是性能,无论在何种场景下。几个世纪以来,工程师们一直在分析和优化复杂系统,试图提高它们的性能,并且他们一直在采用相同的设计策略来实现这一目标。所以,当你试图提高基于计算机的系统的性能时,你应该感到欣慰,因为你所应用的策略已经过充分的 “实际检验”。
—RK
9.3 基于策略的性能调查问卷
基于 9.2 节 中描述的策略,我们可以设计一组受策略启发的问题,如 表 9.2 所示。为全面了解为支持性能而做出的架构选择,分析师需提出每个问题,并将答案记录在表格中。这些问题的答案随后可成为进一步活动的重点,如文档调研、代码或其他构件分析、代码逆向工程等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策和定位 | 推理和假设 |
|---|---|---|---|---|---|
| 控制资源需求 | 是否制定了服务水平协议(SLA),明确规定愿意支持的最大事件到达速率? | ||||
| 能否管理到达系统的事件的采样速率? | |||||
| 系统将如何限制对事件的响应(处理量)? | |||||
| 是否定义了不同类别的请求,并为每个类别定义了优先级? | |||||
| 例如,你能否通过资源同置、清理资源或减少间接层次等方式来降低计算开销? | |||||
| 能对算法的执行时间进行限制吗? | |||||
| 能否通过选择算法来提高计算效率? | |||||
| 管理资源 | 能否为系统或其组件分配更多资源? | ||||
| 是否在使用并发?如果请求能够并行处理,阻塞时间就可以减少。 | |||||
| 计算操作能否在不同处理器上进行复制? | |||||
| 数据能否进行缓存(保存一份可快速访问的本地副本)或复制(以减少争用)? | |||||
| 能否对队列大小进行限制,从而为处理触发所需的资源设置上限? | |||||
| 是否已确保所采用的调度策略与对性能的考量相适配? |
9.4 性能模式
几十年来,性能问题一直困扰着软件工程师,因此,针对性能的各个方面管理开发出大量模式也就不足为奇了。在本节中,我们仅介绍其中几种。请注意,有些模式具有多种用途。例如,我们在 第 4 章 中看到了断路器模式,当时它被视为一种可用性模式,但它对性能也有益处,因为它减少了等待无响应服务的时间。
我们在此介绍的模式有服务网格、负载均衡器、限流和 Map-Reduce。
服务网格
服务网格模式用于微服务架构。网格的主要特点是边车,这是一种伴随每个微服务的代理,它提供广泛有用的功能,以解决与应用程序无关的问题,如服务间通信、监控和安全。边车与每个微服务并行执行,处理所有服务间的通信与协调。(正如我们将在 第 16 章 中所述,这些元素通常被打包到 “容器组” 中。)它们一起部署,减少了网络带来的延迟,从而提升性能。
这种方法使开发人员能够将微服务的功能(核心业务逻辑)与诸如身份验证和授权、服务发现、负载均衡、加密和可观测性等跨领域问题的实现、管理及维护分离开来。
Benefits:
优点:
- 用于管理跨领域问题的软件可以购买现成的,或者由专门的团队来实现和维护,这样业务逻辑的开发人员就可以只专注于业务逻辑。
- 服务网格强制将实用功能部署到与使用这些实用功能的服务相同的处理器上。由于通信无需使用网络消息,这减少了服务与其实用功能之间的通信时间。
- 服务网格可以配置为使通信依赖于上下文,从而简化了如 第 3 章 中所述的金丝雀(Canary)测试和 A/B 测试等功能。
权衡:
- 边车引入了更多的执行进程,每个进程都会消耗一定的处理能力,增加了系统开销。
- 边车通常包含多种功能,但并非每个服务或每次服务调用都需要所有这些功能。
负载均衡
负载均衡器是一种中介,它处理来自某些客户端的消息,并确定应由服务的哪个实例来响应这些消息。此模式的关键在于,负载均衡器作为传入消息的单一联系点,例如单个 IP 地址,但随后它会将请求分发给一组能够响应请求的提供者(服务器或服务)。通过这种方式,可以在提供者池中平衡负载。负载均衡器实现了某种形式的调度资源策略。调度算法可能非常简单,如轮询,也可能会考虑每个提供者的负载,或者每个提供者等待服务的请求数量。
优点:
- 服务器的任何失效对客户端来说都是不可见的(假设仍有一些剩余处理资源)。
- 通过在多个提供者之间分担负载,可以为客户端保持更低且更可预测的延迟。
- 相对容易向负载均衡器可用的池中添加更多资源(更多服务器、更快的服务器),且无需客户端知晓。
权衡:
- 负载均衡算法必须非常快,否则它本身可能会导致性能问题。
- 负载均衡器是一个潜在的瓶颈或单点故障,因此它本身通常也会被复制(甚至进行负载均衡)。
负载均衡器将在 第 17 章 中进行更详细的讨论。
限流
限流模式是管理工作请求策略的一种封装。它用于限制对某些重要资源或服务的访问。在这种模式中,通常有一个中介(限流器),它监控(对服务的)请求,并确定传入的请求是否可以得到处理。
优点:
- 通过限制传入请求,你可以优雅地处理需求的变化。这样,服务永远不会过载,可以保持在一个性能 “最佳点”,高效地处理请求。
权衡:
- 限流逻辑必须非常快,否则它本身可能会导致性能问题。
- 如果客户端需求经常超过容量,缓冲区就需要非常大,否则就有丢失请求的风险。
- 在客户端和服务器紧密耦合的现有系统中,添加此模式可能会很困难。
Map-Reduce
Map-Reduce 模式可高效地对大型数据集进行分布式并行排序,并为程序员提供一种简单的方式来指定要进行的分析。与我们介绍的其他与应用程序无关的性能模式不同,Map-Reduce 模式是专门为解决一类特定的常见问题(对大型数据集进行排序和分析)而设计,以实现高性能。任何处理海量数据的组织,如谷歌、脸书、雅虎和网飞,都会遇到这个问题,而这些组织实际上都在使用 Map-Reduce。
Map-Reduce 模式有三个部分:
- 首先是一个专门的基础设施,它负责在大规模并行计算环境中将软件分配到硬件节点,并根据需要处理数据排序。一个节点可以是虚拟机、独立处理器或多核芯片中的一个核心。
- 第二和第三部分是两个由程序员编写的函数,显然,分别称为 “映射(map)” 和 “归约(reduce)”。
- “映射” 函数以一个键和一个数据集作为输入。它使用该键将数据哈希到一组桶中。例如,如果我们的数据集由扑克牌组成,键可以是花色。映射函数还用于过滤数据,即确定一条数据记录是参与进一步处理还是被丢弃。继续以扑克牌为例,我们可能选择丢弃大小王或字母牌(A、K、Q、J),只保留数字牌,然后根据花色将每张牌映射到一个桶中。通过拥有多个映射实例,每个实例处理数据集的不同部分,可提高 Map-Reduce 模式映射阶段的性能。一个输入文件被分成多个部分,并创建多个映射实例来处理每个部分。继续我们的例子,假设我们有 10 亿张扑克牌,而不只是一副牌。由于每张牌可以单独检查,映射过程可以由成千上万的实例并行执行,它们之间无需通信。一旦所有输入数据都被映射,这些桶将由 Map-Reduce 基础设施进行混洗,然后分配给新的处理节点(可能会重用映射阶段使用的节点)进行归约阶段。例如,所有梅花牌可以分配给一个实例集群,所有方块牌分配给另一个集群,依此类推。
- 所有繁重的分析都在 “归约” 函数中进行。归约实例的数量与映射函数输出的桶的数量相对应。归约阶段进行一些由程序员指定的分析,然后输出分析结果。例如,我们可以统计梅花、方块、红桃和黑桃的数量,或者我们可以对每个桶中所有牌的数值进行求和。输出集几乎总是比输入集小得多,这就是 “归约” 这个名字的由来。
映射实例是无状态的,并且彼此之间不通信。映射实例与归约实例之间唯一的通信是映射实例以 <键,值> 对形式发出的数据。
优点:
- 通过利用并行性,可以高效地分析极其庞大的未排序数据集。
- 任何实例的故障对处理的影响都很小,因为 Map-Reduce 通常会将大型输入数据集分解为许多较小的数据集进行处理,每个数据集分配给其自己的实例。
权衡:
- 如果没有大型数据集,Map-Reduce 模式带来的开销就不合理。
- 如果不能将数据集划分为大小相似的子集,并行性的优势就会丧失。
- 需要多次归约的操作编排起来很复杂。
9.5 扩展阅读
性能是众多文献探讨的主题。以下是我们推荐的几本关于性能的综合性概述书籍:
- 《软件与系统性能工程基础:流程、性能建模、需求、测试、可扩展性及实践》[Bondi 14]。本书全面概述了性能工程,涵盖从技术实践到组织层面的内容。
- 《软件性能与可扩展性:量化方法》[Liu 09]。本书聚焦于面向企业应用的性能,重点讲解排队论和度量。
- 《性能解决方案:创建响应迅速、可扩展软件的实用指南》[Smith 01]。本书介绍了以性能为导向的设计,重点在于构建(并用真实数据填充)实用的预测性性能模型。
若要全面了解众多性能模式,可参阅《实时设计模式:实时系统的稳健可扩展架构》[Douglass 99] 以及《面向模式的软件架构 第 3 卷:资源管理模式》[Kircher 03]。此外,微软发布了基于云应用的性能和可扩展性模式目录:https://docs.microsoft.com/en-us/azure/architecture/patterns/category/performance-scalability 。
9.6 问题讨论
1. “每个系统都有实时性能约束。” 对此展开讨论。你能给出反例吗?
2. 编写一个具体的性能场景,描述一家航空公司航班的平均准点到达性能。
3. 为一个在线拍卖网站编写几个性能场景。思考你主要关注的是最坏情况延迟、平均情况延迟、吞吐量,还是其他某种响应指标。你会使用哪些策略来满足这些场景?
4. 基于 Web 的系统常使用代理服务器,它是系统中第一个接收来自客户端(比如你的浏览器)请求的组件。代理服务器能够提供经常被请求的网页,如公司主页,而无需打扰执行交易的实际应用服务器。一个系统可能包含多个代理服务器,并且它们通常设置在地理位置上靠近大型用户群体的地方,以减少常规请求的响应时间。你认为这里运用了哪些性能策略?
5. 交互机制的一个根本区别在于交互是同步还是异步。针对延迟、截止期限、吞吐量、抖动、失误率、数据丢失或你熟悉的其他任何与性能相关的响应,分别讨论同步和异步交互的优缺点。
6. 找出应用每种管理资源策略的现实世界(非软件)示例。例如,假设你在经营一家实体大型零售商店。你会如何运用这些策略让顾客更快地通过收银台?
7. 用户界面框架通常是单线程的。为什么会这样?这对性能有什么影响?(提示:考虑竞态条件)。
第10章 安全性
贾尔斯:哎呀,看在老天的份上,小心点…… 要是你受伤或者送命,我会很生气的。 薇洛:嗯,我们尽量不送命。这可是我们使命宣言的一部分:别送命。 贾尔斯:很好。
——《吸血鬼猎人巴菲》第三季,“安妮” 一集
“不造成人员伤亡” 应成为每位软件架构师使命宣言的一部分。
软件可能导致人员伤亡、造成损害,这种想法曾一度只存在于计算机失控的科幻小说领域。想想在经典老片《2001:太空漫游》中,哈尔彬彬有礼地拒绝打开舱门,致使戴夫被困太空。
遗憾的是,如今这已不再仅仅是科幻情节。随着软件在我们生活中控制的设备越来越多,软件安全已成为一个至关重要的问题。
软件(由 0 和 1 组成的字符串)竟能造成伤亡或破坏,这一概念仍让人觉得匪夷所思。公平地说,造成破坏的并非这些 0 和 1—— 至少不是直接造成的。而是它们所连接的事物。软件及其运行的计算机,必须以某种方式与外部世界相连,才可能造成损害。这算是个好消息。但坏消息是,这个好消息也没那么好。软件始终与外部世界相连。如果你的程序对外部毫无可观测的影响,那它可能毫无用处。
2009 年,舒申斯卡亚水电站的一名员工通过网络远程操作时,因几个错误的按键,意外启动了一台闲置的涡轮机。这台离线的涡轮机引发了 “水锤效应”,导致电站被洪水淹没并遭到破坏,数十名工人丧生。
类似的臭名昭著的例子还有很多。Therac 25 放射治疗仪导致的致命辐射过量事故、阿丽亚娜 5 型火箭爆炸,以及其他许多不太知名的事故,都是因为计算机与外部环境相连而造成了危害:上述例子中分别是涡轮机、X 射线发射器和火箭的转向控制系统。臭名昭著的 “震网” 病毒则是被蓄意制造出来造成破坏的。在这些案例中,软件指令其所处环境中的某些硬件采取了灾难性的行动,而硬件照做了。执行器是连接硬件与软件的设备,它们是 0 和 1 的数字世界与运动和控制的现实世界之间的桥梁。向执行器发送一个数字值(或在与执行器对应的硬件寄存器中写入一个位串),这个值就会转化为某种机械动作,无论好坏。
但与外部世界相连并不一定意味着连接机械臂、铀离心机或导弹发射器:连接一个简单的显示屏就足够了。有时,计算机只需向操作人员发送错误信息。1983 年 9 月,一颗苏联卫星向其地面系统计算机发送数据,该计算机将这些数据解读为美国发射了一枚瞄准莫斯科的导弹。几秒钟后,计算机又报告有第二枚导弹在飞行。很快,第三枚、第四枚、第五枚导弹的信号相继出现。苏联战略火箭军中校斯坦尼斯拉夫・叶夫格拉福维奇・彼得罗夫做出了惊人的决定,他认为计算机出错,选择无视这些信息。他觉得美国不太可能只发射几枚导弹,从而引发大规模报复性破坏。他决定等待,看看这些导弹是否真的存在,也就是说,看看自己国家的首都是否会被焚毁。如我们所知,并没有导弹来袭。苏联的系统把一种罕见的阳光反射条件误判为飞行中的导弹。你和(或)你的父母很可能要感谢彼得罗夫中校的救命之恩。
当然,当计算机出错时,人类也并非总能做出正确判断。2009 年 6 月 1 日暴风雨的夜晚,法国航空 447 号航班从里约热内卢飞往巴黎,尽管飞机发动机和飞行控制系统运行正常,但飞机仍坠入大西洋,机上 228 人全部遇难。这架空客 A - 330 的飞行记录器直到 2011 年 5 月才找到,记录显示飞行员始终未察觉飞机进入了高空失速状态。测量空速的传感器被冰堵塞,导致数据不可靠,自动驾驶仪因此解除了。人类飞行员以为飞机飞得太快(有结构损坏的危险),而实际上飞机飞得太慢(正在坠落)。在飞机从 35000 英尺高空坠落的 3 分多钟里,飞行员一直试图拉起机头并减小油门以降低速度,而他们真正需要做的是压低机头以提高速度,恢复正常飞行。很可能加剧混乱的是 A - 330 失速警告系统的工作方式。当系统检测到失速时,会发出响亮的警报声。当软件 “认为” 仰角测量值无效时,就会停用失速警告。空速读数很低时就会出现这种情况。法航 447 号航班就是如此:其前进速度降至 60 节以下,仰角极高。由于这种飞行控制软件规则,失速警告多次响起又停止。更糟糕的是,每当飞行员向前推杆(增加空速,使读数进入 “有效” 范围,但仍处于失速状态)时,警报就会响起,而当他向后拉杆时,警报就会停止。也就是说,做正确的操作却得到了完全错误的反馈,反之亦然。这是一个不安全的系统,还是一个被不安全操作的安全系统呢?最终,这类问题要由法庭来裁决。
在本书即将出版之时,波音公司仍因 737 MAX 飞机停飞而陷入困境。两起坠机事故似乎至少部分是由一个名为 MCAS 的软件造成的,该软件在错误的时间将飞机机头向下推。这里似乎也涉及传感器故障,以及一个令人费解的设计决策,即该软件仅依赖一个传感器来决定其运行状态,而飞机上其实有两个可用传感器。此外,波音公司似乎从未在传感器故障的条件下测试过该软件。该公司确实提供了一种在飞行中禁用该系统的方法,但当飞机几乎要坠毁时,要求机组人员记住如何操作,这可能有些强人所难 —— 尤其是他们一开始根本不知道 MCAS 的存在。737 MAX 的两起坠机事故共造成 346 人死亡。
好了,恐怖的故事讲得够多了。让我们来谈谈这些故事背后影响软件和架构的原理。
安全性涉及系统避免陷入导致其环境中的参与者受到损害、伤害或生命损失的状态的能力。这些不安全状态可能由多种因素引起:
- 遗漏(某个事件未发生)。
- 错误执行(出现了不期望的事件)。该事件在某些系统状态下可能是可以接受的,但在其他状态下则不可接受。
- 时间问题。时间过早(事件在规定时间之前发生)或过晚(事件在规定时间之后发生)都可能引发潜在问题。
- 系统值问题。这分为两类:明显错误的值虽然错误但容易被检测,而细微错误的值通常难以察觉。
- 序列遗漏和错误执行。在一系列事件中,要么某个事件缺失(遗漏),要么插入了意外事件(错误执行)。
- 顺序错误。一系列事件到达,但顺序与规定不符。
安全性还涉及检测这些不安全状态并从中恢复,以防止或至少尽量减少由此造成的损害。
系统的任何部分都可能导致不安全状态:软件、硬件部分或环境都可能以意想不到的、不安全的方式运行。一旦检测到不安全状态,系统可能的响应与 第 4 章 中列举的可用性响应类似。应识别不安全状态,并通过以下方式使系统恢复安全:
- 从不安全状态恢复后继续运行,或将系统置于安全模式,或者
- 关闭(故障安全),或者
- 转换到需要手动操作的状态(例如,如果汽车的动力转向系统故障,则采用手动转向)。
此外,应立即报告和(或)记录不安全状态。
为实现安全性而进行架构设计,首先要通过故障模式与影响分析(FMEA,也称为危害分析)和故障树分析(FTA)等技术,识别系统的安全关键功能 —— 即那些可能如前文所述造成危害的功能。FTA 是一种自上而下的演绎方法,用于识别可能导致系统进入不安全状态的故障。一旦确定了故障,架构师就需要设计机制来检测和减轻故障(最终减轻危害)。
本章概述的技术旨在发现系统运行可能导致的潜在危害,并帮助制定应对这些危害的策略。
10.1 安全性通用场景
基于上述背景,我们可以构建如 表 10.1 所示的安全性通用场景。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 数据源(传感器、计算数值的软件组件、通信信道)、时间源(时钟),或者一次用户操作 | 以下各项的具体实例: |
| 触发事件 | 遗漏、错误执行,或出现不正确的数据或时间问题 | 遗漏的具体示例: 错误执行的具体示例: 数据错误的具体示例: 时间故障: |
| 环境 | 系统运行模式 | |
| 构件 | 构件是系统的某个部分。 | 系统中关乎安全的关键部分 |
| 响应 | 系统不会脱离安全状态空间,或者系统返回安全状态空间,又或者系统以降级模式继续运行,以防止(进一步的)伤害或损坏,或将伤害或损坏降至最低。告知用户不安全状态或已防止进入不安全状态。同时记录该事件。 | 识别不安全状态,并采取以下一项或多项措施: |
| 响应度量 | 返回安全状态空间所需时间;已造成的损害或伤害 | 以下一项或多项指标: |
安全场景示例:“患者监护系统中的一个传感器在100毫秒后未能报告危及生命的值。系统记录该故障,控制台上的警示灯亮起,同时启用备用(精度较低)的传感器。不超过300毫秒后,系统使用备用传感器对患者进行监测。” 图10.1 展示了此场景。

10.2 安全性策略
安全性策略大致可分为避免不安全状态、检测不安全状态或纠正不安全状态。图10.2 展示了这一系列安全性策略的目标。

要避免或检测到进入不安全状态,一个逻辑前提是能够识别什么构成不安全状态。以下策略假定具备这种能力,这意味着一旦你有了架构,就应该自行开展危害分析或故障树分析。你的设计决策本身可能引入了需求分析阶段未考虑到的新安全性漏洞。
你会注意到,此处介绍的策略与 第 4 章 中关于可用性的策略有大量重叠。之所以出现这种重叠,是因为可用性问题往往会导致安全性问题,而且修复这些问题的许多设计解决方案在这两种质量属性方面是通用的。
图10.3 总结了实现安全性的架构策略。

避免不安全状态
替换
此策略针对潜在危险的软件设计特性采用保护机制,这类机制通常基于硬件。例如,诸如看门狗、监视器和联锁装置等硬件保护设备可用来替代软件版本。这些机制的软件版本可能会缺乏资源,而单独的硬件设备能自行提供和控制自身资源。仅当被替换的功能相对简单时,替换策略通常才会有成效。
预测模型
如 第 4 章 所介绍的,预测模型策略通过(基于对状态的监测)预测系统进程、资源或其他属性的健康状态,不仅确保系统在其标称运行参数范围内运行,还能对潜在问题发出早期预警。例如,一些汽车巡航控制系统会计算车辆与前方障碍物(或另一辆车)之间的接近速度,并在距离和时间过小到可能导致碰撞之前向驾驶员发出警告。预测模型通常与状态监测相结合,我们稍后会讨论状态监测。
检测不安全状态
超时
超时策略用于判断组件的操作是否满足其时间约束。这可以通过引发异常的形式实现,以表明如果组件未满足其时间约束则发生了失效。因此,此策略可检测到时间过晚和遗漏故障。超时在实时或嵌入式系统以及分布式系统中是一种尤为常见的策略。它与系统监控、心跳和 ping - echo 等可用性策略相关。
时间戳
如 第 4 章 所述,时间戳策略主要用于检测分布式消息传递系统中事件的错误顺序。在事件发生后,可通过将本地时钟的状态立即赋给该事件来确定事件的时间戳。序列号也可用于此目的,因为分布式系统中的时间戳在不同处理器之间可能不一致。
状态监测
此策略涉及检查进程或设备中的状态,或通过使用断言等方式验证设计过程中做出的假设。状态监测可识别可能导致危险行为的系统状态。然而,监测器应简单(理想情况下可证明其正确性),以确保它不会引入新的软件错误,也不会显著增加总体工作量。状态监测为预测模型和合理性检查提供输入。
合理性检查
合理性检查策略用于检查特定操作结果、组件的输入或输出的有效性或合理性。此策略通常基于对内部设计、系统状态或所审查信息性质的了解。它最常在接口处使用,以检查特定的信息流。
比较
比较策略通过比较多个同步或复制元素产生的输出,使系统能够检测到不安全状态。因此,比较策略通常与冗余策略协同工作,典型的是在可用性讨论中提到的主动冗余策略。当复制元素数量为三个或更多时,比较策略不仅能检测到不安全状态,还能指出是哪个组件导致了该状态。比较与可用性中使用的表决策略相关。然而,比较并不总是会导致表决;另一种选择是如果输出不同则直接关闭系统。
遏制
遏制策略旨在限制已进入的不安全状态所带来的危害。这一类别包括三个子类别:冗余、限制后果和屏障。
冗余
乍一看,冗余策略似乎与可用性讨论中提到的各种备用 / 冗余策略类似。显然,这些策略存在重叠,但由于安全性和可用性的目标不同,备份组件的使用方式也有所差异。在安全领域,冗余使系统在不希望完全关闭或进一步降级的情况下能够继续运行。
复制是最简单的冗余策略,它仅仅涉及拥有组件的克隆体。拥有多个相同组件的副本对于防范硬件随机故障可能是有效的,但它无法防范硬件或软件中的设计或实现错误,因为此策略中没有任何形式的多样性。
相比之下,功能冗余旨在通过实现设计多样性来解决硬件或软件组件中的共模失效问题(即由于副本具有相同的实现,它们会同时出现相同的故障)。此策略试图通过在冗余中增加多样性来应对设计故障的系统性。在相同输入的情况下,功能冗余组件的输出应该相同。然而,功能冗余策略仍然容易受到规范错误的影响,当然,开发和验证功能副本的成本会更高。
最后,解析冗余策略不仅允许组件具有多样性,还允许在输入和输出级别体现更高级别的多样性。因此,它可以通过使用单独的需求规范来容忍规范错误。解析冗余通常涉及将系统划分为高保证和高性能(低保证)部分。高保证部分被设计得简单且可靠,而高性能部分通常被设计得更复杂、更精确,但稳定性较差:它变化更快,可能不如高保证部分可靠。(因此,这里所说的高性能并非指延迟或吞吐量方面的高性能;而是指这部分比高保证部分 “更好地执行” 其任务。)
限制后果
遏制策略的第二个子类别称为限制后果。这些策略旨在限制系统进入不安全状态可能产生的不良影响。
在概念上,中止策略最为简单。如果确定某项操作不安全,则在其造成损害之前将其中止。这种技术被广泛应用以确保系统安全地发生故障。
降级策略在组件发生故障时维持最关键的系统功能,以可控的方式放弃或替换部分功能。这种方法允许单个组件失效以一种有计划、慎重且安全的方式适度降低系统功能,而不是导致系统完全瘫痪。例如,汽车导航系统在失去 GPS 卫星信号的长隧道中,可能会继续使用(不太精确的)航位推算算法运行。
屏蔽策略通过比较多个冗余组件的结果,并在一个或多个组件结果不同时采用表决程序来屏蔽故障。为使此策略按预期工作,表决器必须简单且高度可靠。
屏障
屏障策略通过阻止问题传播来控制问题。
防火墙策略是限制访问策略的一种具体实现,这在 第 11 章 中有描述。防火墙限制对特定资源(通常是处理器、内存和网络连接)的访问。
联锁策略防范因事件顺序不正确而产生的故障。此策略的实现通过控制对受保护组件的所有访问,包括控制影响这些组件的事件的正确顺序,来提供精细的保护方案。
恢复
最后一类安全策略是恢复,其作用是使系统进入安全状态。它包括三个策略:回滚、修复状态和重新配置。
回滚策略允许系统在检测到故障时恢复到先前已知良好状态的保存副本 —— 回滚线。此策略通常与检查点和事务相结合,以确保回滚完整且一致。一旦达到良好状态,执行可以继续,可能会采用重试或降级等其他策略,以确保故障不再发生。
修复状态策略修复错误状态 —— 有效地增加组件能够正确处理(即无故障)的状态集 —— 然后继续执行。例如,车辆的车道保持辅助功能会监测驾驶员是否保持在车道内,如果车辆偏离,会主动将其恢复到车道线之间的位置 —— 安全状态。此策略不适合作为从意外故障中恢复的方法。
重新配置试图通过将逻辑架构重新映射到(可能有限的)仍在运行的资源上,从组件故障中恢复。理想情况下,这种重新映射可维持全部功能。当无法做到这一点时,系统可以结合降级策略维持部分功能。
10.3 基于策略的安全性调查问卷
基于 10.2 节 中描述的策略,我们可以设计一系列受这些策略启发的问题,如 表 10.2 所示。为全面了解为保障安全性而做出的架构选择,分析师需提出每个问题,并将答案记录在表格中。这些问题的答案随后可作为进一步工作的重点,如研究文档、分析代码或其他工件、对代码进行逆向工程等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策和定位 | 推理和假设 |
|---|---|---|---|---|---|
| 避免不安全状态 | 是否采用替换策略,即为潜在危险的软件设计特性采用更安全的、通常基于硬件的保护机制? | ||||
| 是否使用预测模型,基于所监测的信息,对系统进程、资源或其他属性的健康状态进行预测,以便不仅能确保系统在其标称运行参数范围内运行,还能对潜在问题发出早期预警? | |||||
| 检测不安全状态 | 是否使用超时机制来判断组件的运行是否满足其时间约束条件? | ||||
| 是否使用时间戳来检测事件的错误顺序? | |||||
| 是否采用状态监测来检查流程或设备中的状况,尤其是验证设计过程中所做的假设? | |||||
| 是否采用合理性检查来检验特定操作结果,或组件的输入与输出的有效性或合理性? | |||||
| 系统是否通过比较基于多个同步或复制元素产生的输出,采用比较方法来检测不安全状态? | |||||
| 遏制:冗余 | 是否使用复制(即组件的克隆体)来防范硬件的随机故障? | ||||
| 是否使用功能冗余,通过实现设计多样的组件来应对共模故障? | |||||
| 是否使用解析冗余(即包含高保证/高性能和低保证/低性能选项的功能 “副本”)来容忍规范错误? | |||||
| 遏制:限制后果 | 系统能否在确定某项操作不安全时,在其造成损害之前将其中止? | ||||
| 系统是否提供可控的降级功能,即在组件发生故障时维持最关键的系统功能,同时放弃或降低不太关键的功能? | |||||
| 系统是否通过比较多个冗余组件的结果来屏蔽故障,并在一个或多个组件结果不同时采用表决程序? | |||||
| 遏制:屏障 | 系统是否支持通过防火墙限制对关键资源(如处理器、内存和网络连接)的访问? | ||||
| 系统是否通过联锁装置来控制对受保护组件的访问,并防范因事件顺序错误而引发的故障? | |||||
| 恢复 | 系统在检测到故障时,是否能够回滚,即恢复到先前已知的良好状态? | ||||
| 系统能否修复被判定为错误的状态,在不出现故障的情况下继续执行? | |||||
| 系统在发生失效时,能否通过将逻辑架构重新映射到仍在运行的资源上,对资源进行重新配置? |
在开始基于策略的安全调查问卷之前,你应评估所审查的项目是否已进行危害分析或故障树分析,以确定在你的系统中,哪些情况构成不安全状态(需检测、避免、控制或从中恢复)。若未进行此分析,安全设计可能效果欠佳。
10.4 安全性模式
一个系统若意外停止运行、开始错误运行或进入降级运行模式,即便不会造成灾难性后果,也很可能对安全性产生负面影响。因此,寻找安全性模式首先可着眼于可用性模式,比如在 第 4 章 中描述的那些模式。它们在此处同样适用。
-
冗余传感器:如果某个传感器产生的数据对于判断一种状态是否安全至关重要,那么该传感器应设置为冗余配置。这能防止单个传感器出现故障。此外,应使用独立的软件监控每个传感器 —— 本质上,这就是将 第 4 章 中的冗余备用策略应用于安全关键型硬件。
优点:
-
这种应用于传感器的冗余形式可防范单个传感器故障。
权衡:
-
冗余传感器会增加系统成本,而且处理多个传感器的输入比处理单个传感器的输入更为复杂。
-
监控 - 执行器:此模式聚焦于两个软件元素 —— 一个监控器和一个执行器控制器,在向物理执行器发送命令之前使用它们。执行器控制器进行必要的计算,以确定要发送给物理执行器的值。监控器在发送这些值之前会检查其合理性。这将值的计算与值的测试分离开来。
优点:
-
在这种应用于执行器控制的冗余形式中,监控器对执行器控制器的计算起到冗余检查的作用。
权衡:
- 开发和维护监控器需要耗费时间和资源。
- 由于该模式实现了执行器控制与监控的分离,通过根据需要使监控器尽可能简单(易于开发,但可能会遗漏错误)或尽可能复杂(更复杂,但能捕获更多错误),这种特定的权衡很容易进行调整。
-
安全分离:安全关键型系统通常必须由某个权威机构认证为安全。认证一个大型系统成本高昂,但将系统划分为安全关键部分和非安全关键部分可降低这些成本。安全关键部分仍必须经过认证。同样,对系统进行安全关键和非关键部分的划分也必须经过认证,以确保非安全关键部分不会对安全关键部分产生影响。
优点:
- 由于只需认证整个系统的(通常较小的)一部分,系统认证成本得以降低。
- 由于工作重点仅放在与安全密切相关的系统部分,成本和安全方面都能受益。
权衡:
- 进行分离所涉及的工作可能成本高昂,例如在系统中安装两个不同的网络,以区分安全关键和非安全关键消息。然而,这种方法限制了非安全关键部分中的错误影响安全关键部分的风险和后果。
- 将系统分离,并说服认证机构相信分离操作正确执行,且非安全关键部分对安全关键部分没有影响,这具有一定难度,但远比另一种选择容易得多:让认证机构对所有内容都按照相同的严格标准进行认证。
设计保证等级
安全分离模式强调将软件系统划分为安全关键部分和非安全关键部分。在航空电子领域,这种区分更为细致。DO - 178C《机载系统和设备认证中的软件考量》是美国联邦航空管理局(FAA)、欧盟航空安全局(EASA)以及加拿大运输部等认证机构批准所有基于商业软件的航空航天系统时所依据的主要文件。它为每个软件功能定义了一种名为设计保证等级(DAL)的分级。DAL 是通过安全评估流程和危害分析,研究系统中故障状况的影响来确定的。故障状况根据其对飞机、机组人员和乘客的影响进行分类:
- A 级:灾难性的。失效可能导致人员死亡,通常伴随着飞机失事。
- B 级:危险性的。失效对安全性或性能有重大负面影响,或因身体不适或更高的工作负荷降低机组人员操作飞机的能力,或导致乘客严重受伤甚至死亡。
- C 级:重大的。失效显著降低安全裕度或显著增加机组人员工作负荷,并可能导致乘客不适(甚至轻微受伤)。
- D 级:较小的。失效略微降低安全裕度或略微增加机组人员工作负荷。例如,可能会给乘客带来不便或导致常规飞行计划变更。
- E 级:无影响。失效对安全、飞机运行或机组人员工作负荷无影响。
软件验证和测试是一项成本极高的任务,而预算非常有限。DAL 有助于确定将有限的测试资源投入到何处。下次你乘坐商业航班时,如果你看到娱乐系统出现故障,或者阅读灯一直闪烁,不妨想想为确保飞行控制系统正常运行所投入的大量验证资金,这样心里会好受些。
—PC
10.5 扩展阅读
为了认识到软件安全的重要性,我们建议阅读一些因软件故障引发的灾难故事。一个不错的信息来源是 ACM 风险论坛,网址为risks.org 。自 1985 年起,该论坛由彼得・诺伊曼(Peter Neumann)主持,至今依然活跃。
有两个著名的标准安全 流程 ,分别在以下文件中有所描述:由国际自动机工程师学会(SAE International)制定的 ARP - 4761《民用机载系统和设备安全评估流程指南与方法》,以及由美国国防部制定的 MIL STD 882E《标准实践:系统安全》。
2004 年,吴(Wu)和凯利(Kelly)[Wu 04] 基于对现有架构方法的调研,发布了一系列安全策略,这为本章的诸多思考带来了启发。
南希・莱夫森(Nancy Leveson)是软件与安全领域的思想领袖。如果你从事安全关键系统方面的工作,就应该熟悉她的研究成果。你可以先从类似 [Leveson 04] 这样的论文入手,该论文探讨了一些导致航天器事故的软件相关因素。或者你也可以直接研读她的著作 [Leveson 11],这本书探讨了在当今复杂的、社会技术融合且软件密集型系统背景下的安全问题。
美国联邦航空管理局(Federal Aviation Administration)是负责监管美国空域系统的政府机构,极其重视安全问题。其 2019 年版的《系统安全手册》很好地对该主题进行了实用性概述。这本手册的 第 10 章 涉及软件安全内容。可以从http://faa.gov/regulations_policies/handbooks_manuals/aviation/risk_management/ss_handbook/下载。
菲尔・库普曼(Phil Koopman)在汽车安全领域颇负盛名。他有几个在线教程涉及安全关键模式。例如,可以查看http://youtube.com/watch?v=JA5wdyOjoXg和http://youtube.com/watch?v=4Tdh3jq6W4Y 。库普曼所著的《更好的嵌入式系统软件》[Koopman 10] 对安全模式有更详细的阐述。
故障树分析起源于 20 世纪 60 年代初,其经典参考资料是美国核管理委员会 1981 年发布的《故障树手册》。美国国家航空航天局(NASA)2002 年发布的《航空航天应用故障树手册》是对核管理委员会那本手册的更新版综合入门指南。这两本手册都可在网上以 PDF 格式下载。
与设计保证等级类似,安全完整性等级(SILs)对各类功能的安全关键程度给出了定义。这些定义不仅有助于参与系统设计的架构师达成共识,还能辅助安全评估工作。国际电工委员会(IEC)61508 标准《电气 / 电子 / 可编程电子安全相关系统的功能安全》定义了四个安全完整性等级,其中 SIL 4 可靠性最高,SIL 1 可靠性最低。该标准通过特定领域的标准得以具体应用,比如铁路行业的 IEC 62279 标准《铁路应用:通信、信号和处理系统:铁路控制与保护系统软件》。
在半自动驾驶和自动驾驶汽车成为大量研发主题的当下,功能安全正变得愈发重要。长期以来,ISO 26262 一直是道路车辆功能安全方面的标准。此外,也涌现出一批新规范,如 ANSI/UL 4600《自动驾驶汽车及其他产品评估的安全标准》,该标准旨在应对软件在实际和象征意义上 “掌控方向盘” 时所出现的各种挑战。
10.6 问题讨论
1. 列出目前你日常生活中使用的 10 种计算机控制设备,并设想恶意或出现故障的系统可能会如何利用它们来伤害你。
2. 编写一个安全场景,旨在防止静止的机器人设备(如生产线上的装配臂)伤人,并讨论实现该场景的策略。
3. 美国海军的 F/A - 18 “大黄蜂” 战斗机是早期应用电传飞控技术的机型之一,机上计算机根据飞行员对操纵杆和方向舵踏板的输入,向控制面(副翼、方向舵等)发送数字指令。飞行控制软件经过编程,可防止飞行员做出某些可能导致飞机进入不安全飞行状态的剧烈机动动作。在早期飞行测试中,常常需要将飞机推向(甚至超越)其极限状态,有一次一架飞机进入了不安全状态,而此时恰恰需要 “剧烈机动动作” 来挽救飞机,但计算机却尽职地阻止了这些操作。最终,这架飞机因旨在保障其安全的软件而坠入大海。编写一个安全场景来解决这种情况,并讨论本可避免这种结果的策略。
4. 据slate.com及其他消息来源报道,德国一名少女 “因忘记将 Facebook 生日邀请设为私密,意外地向全网发出邀请,随后便躲了起来。在 15000 人确认会参加后,女孩的父母取消了派对,通知了警方,并雇佣私人安保人员守护家门。” 然而,仍有 1500 人到场,导致数人受轻伤,现场一片混乱。Facebook 不安全吗?请讨论。
5. 编写一个安全场景,保护这位德国不幸少女免受 Facebook 类似事件的困扰。
6. 1991 年 2 月 25 日,海湾战争期间,一套美国 “爱国者” 导弹系统未能拦截一枚来袭的 “飞毛腿” 导弹,该导弹击中了一座兵营,造成 28 名士兵死亡,数十人受伤。失效原因是由于软件中的算术误差,随着时间推移积累导致对系统启动后时间的计算不准确。编写一个安全场景来解决 “爱国者” 导弹的失效问题,并讨论可能避免该失效的策略。
7. 作家詹姆斯・格雷克(James Gleick)在《一个漏洞与一次坠毁》(“A Bug and a Crash,” http://around.com/ariane.html)中写道:“欧洲航天局花了 10 年时间和 70 亿美元打造出阿丽亚娜 5 型火箭,这是一种巨型火箭,每次发射都能将两颗重达三吨的卫星送入轨道…… 而在其首次航行不到一分钟后,导致火箭爆炸的…… 仅仅是一个小计算机程序试图将一个 64 位数字塞进一个 16 位空间。一个漏洞,一次坠毁。在计算机科学史上记载的所有粗心代码行中,这一行可能堪称破坏力最强的。” 编写一个安全场景来解决阿丽亚娜 5 型火箭的灾难问题,并讨论可能避免该灾难的策略。
8. 讨论你认为安全性通常是如何与性能、可用性和互操作性等质量属性进行 “权衡” 的。
9. 讨论安全性与可测试性之间的关系。
10. 安全性和可修改性之间有什么关系?
11. 结合法航 447 航班的事件,讨论安全性与可用性之间的关系。
12. 为自动取款机创建一份故障列表或故障树。其中应包括硬件组件故障、通信故障、软件故障、耗材用尽、用户错误和安全攻击等方面的故障。你将如何运用策略来应对这些故障?
第11章 信息安全性
如果你把秘密告诉风,就不该怪风告诉树。
——卡里·纪伯伦(Kahlil Gibran)
信息安全性是衡量系统保护数据和信息免受未经授权的访问的能力,同时仍向授权的人员和系统提供访问权限的指标。攻击(即针对计算机系统采取的旨在造成伤害的操作)可以采取多种形式。它可能是未经授权尝试访问数据或服务或者是修改数据,也可能是拒绝向合法用户提供服务。
表征安全性的最简单方法侧重于三个特征:保密性、完整性和可用性 (CIA):
-
保密性 是保护数据或服务免受未经授权的访问的属性。例如,黑客无法在政府计算机上访问你的所得税申报表。
-
完整性 是数据或服务不受未经授权的操纵的属性。例如,自从老师分配成绩后,你的成绩就不会发生变更。
-
可用性 是系统可供合法使用的属性。例如,拒绝服务攻击不会阻止你从在线书店订购本书。
我们将在通用场景中使用这些特征来讨论信息安全性。
安全域中使用的一种技术是威胁建模。“攻击树”类似于第4章中讨论的故障树,安全工程师使用它来确定可能的威胁。树的根是成功的攻击,节点可能是成功攻击的直接原因。子节点分解直接原因,依此类推。攻击是试图破坏中央情报局,攻击树的叶子是场景中的触发条件。对攻击的响应是保护中央情报局或通过监视他们的活动来阻止攻击者。
隐私
与安全密切相关的一个问题是隐私的质量。近年来,隐私问题变得越来越重要,欧盟通过《通用数据保护条例》(GDPR)将其纳入法律。其他司法管辖区也采用了类似的规定。
实现隐私是关于限制对信息的访问,而信息又是关于哪些信息应该被限制访问以及应该允许谁可以访问。应保密的信息的一般术语是个人身份信息 (PII)。美国国家标准与技术研究院 (NIST) 将 PII 定义为“由机构维护的有关个人的任何信息,包括 (1) 可用于区分或追踪个人身份的任何信息,例如姓名、社保号码、生日和籍贯、母亲的婚前姓氏或生物识别记录;(2)与个人链接或可链接的任何其他信息,例如医疗、教育、财务和就业信息。
允许谁访问此类数据的问题更为复杂。用户通常会被要求查看并同意组织发起的隐私协议。这些隐私协议详细说明了收集组织之外谁有权查看 PII。收集组织本身应具有管理该组织内谁可以访问此类数据的策略。例如,考虑一个软件系统的测试人员。要执行测试,应使用实际数据。这些数据是否包括个人身份信息?通常,PII 需要被遮盖后用于测试。
通常,架构师(可能代表项目经理)被要求验证 PII 是否对不需要访问 PII 的开发团队成员隐藏。
11.1 信息安全性的通用场景
基于这些考虑,我们现在可以描述信息安全性的通用场景的各个部分,总结在 表 11.1 中。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 攻击可能来自组织外部或组织内部。攻击可能源自人类或其他系统。它可能以前已被识别(正确或不正确)或可能未知的。 | 即: |
| 触发事件 | 该触发事件是一次攻击。 | 未经授权的尝试: |
| 构件 | 攻击的目标是什么? | |
| 环境 | 攻击发生时系统的状态如何? | 该系统是: |
| 响应 | 该系统确保可以维护保密性、完整性和可用性。 | 处理的执行方式是 系统通过以下方式跟踪其中的活动 |
| 响应度量 | 系统响应的度量与成功攻击的频率、抵抗和修复攻击的时间和成本以及这些攻击的间接损害有关。 | 以下一项或多项: |
图 11.1 显示了从通用场景派生出的具体场景的示例:远程位置的一名心怀不满的员工试图在正常操作期间不正确地修改工资率表。检测到未经授权的访问,系统维护审计跟踪,并在一天内恢复正确的数据。

11.2 信息安全策略
思考如何在系统中实现信息安全性的一种方法是关注物理安全性。安全设施只允许有限地进入(例如,通过使用围栏和安全检查站),有侦查入侵者的手段(例如,通过要求合法访客佩戴徽章),有威慑机制(例如,通过武装警卫),有应对机制(例如,自动锁门)和恢复机制(例如,异地备份)。这些导出了四类策略:检测、抵抗、反应和恢复。信息安全策略的目标显示在 图 11.2 中,图 11.3 概述了这些策略类别。


检测攻击
检测攻击包含四类策略:检测入侵、检测服务拒绝、验证消息完整性和检测消息延迟。
-
检测入侵。 此策略将系统中的网络流量或服务请求模式与存储在数据库中的一组签名或已知恶意行为模式进行比较。签名可以基于协议特征、请求特征、有效负载大小、应用程序、源或目标地址或端口号。
-
检测拒绝服务。 此策略将进入系统的网络流量的模式或特征与已知拒绝服务 (DoS) 攻击的历史配置文件进行比较。
-
验证消息完整性。 此策略采用校验和或哈希值等技术来验证消息、资源文件、部署文件和配置文件的完整性。校验和是一种验证机制,其中系统单独维护文件和消息的冗余信息,并使用此冗余信息来验证文件或消息。哈希值是由哈希函数生成的唯一字符串,其输入可以是文件或消息。即使原始文件或消息中的微小更改也会导致哈希值发生重大更改。
-
检测消息传递异常。 此策略旨在检测潜在的中间人攻击,其中恶意方正在拦截(并可能修改)消息。如果消息传递时间通常是稳定的,则通过检查传递或接收消息所需的时间,可以检测到可疑的耗时行为。同样,连接和断开连接的异常数量可能指示出此类攻击。
抵抗攻击
有许多众所周知的抵抗攻击的方法:
-
识别参与者。识别参与者(用户或远程计算机)侧重于识别系统的任何外部输入的来源。用户通常通过用户 ID 进行标识。其他系统可以通过访问代码、IP 地址、协议、端口或其他方式“识别”。
-
验证参与者。身份验证意味着确保参与者实际上是或者它声称的那样。密码、一次性密码、数字证书、双因素身份验证和生物识别提供了一种身份验证方法。另一个例子是CAPTCHA(完全自动化的公共图灵测试,用于区分计算机和人类),这是一种用于确定用户是否是人类的挑战-响应测试。系统可能需要定期重新进行身份验证,例如当你的智能手机在一段时间不活动后自动锁定时。
-
授权参与者。授权意味着确保经过身份验证的参与者有权访问和修改数据或服务。此机制通常通过在系统内提供一些访问控制机制来启用。可以按参与者、参与者类或角色分配访问控制。
-
限制访问。此策略涉及限制对计算机资源的访问。限制访问可能意味着限制资源的访问点数量,或限制可以通过访问点的流量类型。这两种限制都会最大程度地减少系统的攻击面。例如,当组织希望允许外部用户访问某些服务但不允许访问其他服务时,将使用隔离区 (DMZ)。DMZ 位于外网和内网之间,由一对防火墙保护,两端各一个。内部防火墙是对内联网的单点访问;它的作用是对接入点数量的限制,并控制允许通过 内网 的流量类型。
-
限制暴露。 这种策略的重点是尽量减少敌对行动造成的损害的影响。这是一种被动防御,因为它不会主动阻止攻击者造成伤害。限制暴露通常是通过减少可以通过单个接入点访问的数据或服务量来实现的,因此减少在单个攻击中受到损害。
-
加密数据。保密性通常是通过对数据和通信应用某种形式的加密来实现的。加密为永久维护的数据在授权之外提供额外的保护。相比之下,通信链路可能没有授权控制。在这种情况下,加密是通过可公开访问的通信链路传递数据的唯一保护。加密可以是对称的(读取器和写入器使用相同的密钥)或非对称的(读取器和写入器使用成对的公钥和私钥)。
-
分离实体。分离不同的实体可以限制攻击的范围。系统内部的分离可以通过以下方式实现:在连接到不同网络的不同服务器上进行物理分离,使用虚拟机,或者通过“空气间隙”——即系统的不同部分之间没有电子连接。最后,敏感数据通常与非敏感数据分离,以减少那些可以访问非敏感数据的用户发动攻击的可能性。
-
验证输入。在系统或其部分接收到输入时进行清理和检查是抵御攻击的重要早期防线。这通常通过使用安全框架或验证类来实现,以执行诸如过滤、规范化和输入的清理等操作。数据验证是防御诸如SQL注入(在其中插入恶意代码到SQL语句中)和跨站脚本攻击(XSS,服务器上的恶意代码在客户端上运行)等攻击的主要形式。
-
更改凭据设置。许多系统在交付时都分配了默认的安全设置。强制用户更改这些设置可以防止攻击者通过可能公开可用的设置来访问系统。同样地,许多系统要求用户在一定时间后选择新的密码。
对攻击做出反应
有几种策略旨在应对潜在的攻击。
-
撤销访问权限。 如果系统或系统管理员认为攻击正在进行,则访问可能会严格限制为敏感资源,即使对于正常合法的用户和使用也是如此。例如,如果你的桌面受到病毒的威胁,则在从系统中删除病毒之前,你对某些资源的访问可能会受到限制。
-
限制登录。重复的登录失败尝试可能表明存在潜在的攻击。许多系统如果从某台计算机反复尝试登录账户失败,会限制从该计算机访问。当然,合法用户在尝试登录时可能会犯错,因此限制访问可能只持续一段特定时间。在某些情况下,系统会在每次不成功的登录尝试后将锁定时间加倍。
-
通知参与者。持续的攻击可能需要操作员、其他人员或合作系统采取行动。当系统检测到攻击时,必须通知此类人员或系统(一组相关参与者)。
Recover from Attacks
从攻击中恢复
一旦系统检测到并尝试抵抗攻击,它就需要恢复。恢复的一部分是恢复服务。例如,可以为此目的保留其他服务器或网络连接。由于成功的攻击可以被视为一种失效,因此处理从失效中恢复的可用性策略集(来自 第 4 章)也可以用于这方面的安全性。
除了用于恢复的可用性策略之外,还可以使用审计和不可否认性策略:
-
审计。 我们对系统进行审计——即记录用户和系统的行为及其影响——以帮助追踪攻击者的行为并识别攻击者。我们可能会分析审计追踪,试图起诉攻击者或为未来创建更好的防御措施。
-
不可否认性。 这种策略确保消息的发送者不能在事后否认发送过该消息,同时接收者也不能否认收到过该消息。例如,你不能否认从互联网上订购了某样东西,而商家也不能否认收到了你的订单。这可以通过数字签名和可信第三方认证的某种组合来实现。
11.3 基于策略的安全问卷
基于 第 11.2 节 中描述的策略,我们可以创建一组受启发的安全策略问题,如 表 11.2 所示。为了获得支持安全的架构选择的概览,分析师会问每个问题,并将答案记录在表格中。这些问题的答案随后可以成为进一步活动的重点:文档调查、代码或其他工件的分析、代码的逆向工程等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策和定位 | 推理和假设 |
|---|---|---|---|---|---|
| 入侵检测 | 系统是否支持入侵检测,例如,通过将系统中的网络流量或服务请求模式与存储在数据库中的一组特征或已知恶意行为模式进行比较? | ||||
| 系统是否支持拒绝服务攻击的检测,例如,通过将进入系统的网络流量的模式或特征与已知 DoS 攻击的历史配置文件进行比较? | |||||
| 系统是否支持通过校验和或哈希值等技术验证消息完整性? | |||||
| 系统是否支持检测消息延迟,例如,通过检查传递消息所需的时间? | |||||
| 抵御攻击 | 系统是否支持通过用户ID、访问码、IP 地址、协议、端口等进行参与者识别? | ||||
| 系统是否支持通过密码、数字证书、双因素身份验证或生物识别等方式对参与者进行身份验证? | |||||
| 系统是否支持参与者授权,确保经过身份验证的参与者有权访问和修改数据或服务? | |||||
| 系统是否支持通过限制对资源的接入点数量或限制可以通过接入点的流量类型来限制对计算机资源的访问? | |||||
| 系统是否支持通过减少可经过单个接入点访问的数据或服务量来限制暴露? | |||||
| 系统是否支持传输中的数据或静态数据的数据加密? | |||||
| 系统设计是否考虑通过在连接到不同网络、虚拟机或“气隙”的不同服务器上进行物理分离来分离实体? | |||||
| 系统是否支持更改凭证设置,强制用户定期或在关键事件时更改这些设置? | |||||
| 系统是否在系统范围内以一致的方式验证输入,例如,使用安全框架或验证类来执行外部输入的筛选、规范化和清理等操作? | |||||
| 应对攻击 | 系统是否支持通过限制对敏感资源的访问来撤销访问,即使对于正常合法的用户和正在发生攻击的用户? | ||||
| 系统是否支持在多次登录尝试失败等情况下限制登录? | |||||
| 当系统检测到攻击时,系统是否支持通知参与者,例如操作员、其他人员或协作系统? | |||||
| 从攻击中恢复 | 系统是否支持维护审计跟踪以帮助跟踪攻击者的操作并识别攻击者? | ||||
| 系统是否保证不可否认性的属性,这保证消息的发送者以后不能否认已发送消息,并且收件人不能否认已收到消息? | |||||
| 你是否检查过 第 4 章 中的“从故障中恢复”策略类别? |
11.4 信息安全的模式
两种更广为人知的安全模式是拦截验证器和入侵防御系统。
拦截验证器
此模式在消息的源和目标之间插入一个软件元素(包装器)。当消息源位于系统外部时,此方法具有更大的重要性。此模式最常见的职责是实现验证消息完整性策略,但它也可以包含检测入侵和检测拒绝服务(通过将消息与已知入侵模式进行比较)或检测消息传递异常等策略。
好处:
- 根据您创建和部署的具体验证器,这种模式可以涵盖“检测攻击”类别下的大部分策略,全部包含在一个包中。
权衡:
-
通常,引入中介需要付出额外的性能代价。
-
入侵模式会随着时间而变化和演化,因此这个组件必须保持更新,以保持其有效性。这给负责系统的组织带来了维护的责任。
入侵防御系统
入侵预防系统(IPS)是一个独立组件,其主要目的是识别和分析任何可疑活动。如果该活动被认为是可接受的,它将被允许。相反,如果该活动是可疑的,那么该活动将被阻止并报告。这些系统寻找整体使用中的可疑模式,而不仅仅是异常消息。
好处:
- 这些系统可以包含大多数“检测攻击”和“对攻击做出反应”策略。
权衡:
-
IPS 查找的活动模式会随着时间的推移而变化和发展,因此模式数据库必须不断更新。
-
采用 IPS 的系统会产生性能代价。
-
IPS可作为现成的商用组件提供,这使得它们无需开发,但可能并不完全适合特定应用。
其他值得注意的安全模式包括分隔和分布式责任。这两者结合了“限制访问”和“限制曝光”的策略——前者针对信息,后者针对活动。
正如我们在安全策略列表中包含(通过引用)可用性策略一样,可用性的模式也适用于信息安全性,通过抵消试图阻止系统运行的攻击。还要考虑 第 4 章 中讨论的可用性的模式。
11.5 扩展阅读
我们在本章中描述的架构策略只是使系统信息安全的一个方面。其他方面包括以下内容:
-
编码。C 和 C++ 中的安全编码 [Seacord 13] 描述了如何安全地编码。
-
组织流程。组织必须拥有负责信息安全各个方面的流程,包括确保系统升级以实施最新的保护。NIST 800-53 提供了组织流程的枚举列表 [NIST 09]。组织流程必须考虑内部威胁,这些威胁占攻击的 15-20%。[Cappelli 12] 讨论内部威胁。
-
技术流程。Microsoft的信息安全开发生命周期包括威胁建模:microsoft.com/download/en/details.aspx?id=16420。
常见弱点枚举列表是系统中发现的最常见漏洞类别的列表,包括 SQL 注入和 XSS: https://cwe.mitre.org/。
NIST已经出版了几卷,给出了信息安全术语[NIST 04]的定义,信息安全控制类别[NIST 06],以及组织可以使用的信息安全控制的枚举列表[NIST 09]。信息安全控制可能是一种策略,但也可以是组织、编码或技术性质的。
关于信息安全工程系统的好书包括Ross Anderson的信息安全工程:构建可靠分布式系统指南,第三版[Anderson 20],以及Bruce Schneier的系列书籍。
不同的领域具有与其领域相关的不同安全实践集合。例如,支付卡行业(PCI)已经为参与信用卡处理的人员建立了一套标准(pcisecuritystandards.org)。
维基百科关于“安全模式”的页面包含大量安全模式的简要定义。
访问控制通常使用称为OAuth的标准执行。你可以在 https://en.wikipedia.org/wiki/OAuth 阅读有关OAuth的信息。
11.6 问题讨论
1. 编写一组具体的汽车信息安全场景。特别要考虑如何指定有关车辆控制的场景。
2. 有记录以来最复杂的攻击之一是由一种名为Stuxnet的病毒进行的。Stuxnet于2009年首次出现,但在2011年广为人知,当时有消息称它显然严重损坏或使伊朗铀浓缩计划中涉及的高速离心机丧失能力。阅读有关Stuxnet的信息,看看你是否可以根据本章中描述的策略制定防御策略。
3. 信息安全性和易用性通常被视为相互矛盾。信息安全性通常会强加一些过程和流程,这些过程和流程对临时用户来说似乎是不必要的开销。然而,有人说信息安全性和易用性是(或应该)齐头并进的,并认为使系统易于安全使用是向用户提高信息安全性的最佳方式。请讨论。
4. 列出一些信息安全关键资源的示例,DoS 攻击可能针对这些资源并试图耗尽这些资源。可以使用哪些架构机制来防止此类攻击?
5. 本章中详述的哪些策略可以抵御内部威胁?你能想到任何应该添加的吗?
6. 在美国,Netflix通常占所有互联网流量的10%以上。你如何识别对 Netflix.com 的 DoS 攻击?你能创建一个场景来描述这种情况吗?
7. 公开披露组织生产系统中的漏洞是一个有争议的问题。讨论为什么会这样,并确定公开披露漏洞的利弊。这个问题对于作为架构师的你有什么影响?
8. 同样,公开披露组织的信息安全措施和实现这些措施的软件(例如通过开源软件)也是一个有争议的问题。讨论为什么会这样,确定公开披露信息安全措施的利弊,并描述这会如何影响你作为架构师的角色。
第12章 可测试性
测试会导致失效,而失效会带来理解。
—— 伯特・鲁坦(Burt Rutan)
开发设计良好的系统的成本中,有很大一部分用于测试。如果经过深思熟虑的软件架构能够降低这一成本,回报将是巨大的。
软件可测试性指的是通过(通常基于执行的)测试来展示软件故障的难易程度。具体而言,可测试性是指假设软件至少存在一个故障,其在下一次测试执行中失败的概率。直观地说,如果一个系统能轻易“暴露”其故障,那么它就是可测试的。如果系统中存在故障,那么我们希望它在测试期间尽快失败。当然,计算这个概率并不容易,而且——正如我们在讨论可测试性的应对措施时你将看到的——会使用其他的衡量标准。此外,架构可以通过使复制错误和缩小错误可能的根本原因更容易来提高可测试性。我们通常不认为这些活动本身是可测试性的一部分,但最终仅仅揭示bug是不够的:您还需要找到并修复bug!
图 12.1 展示了一个简单的测试模型,其中程序处理输入并产生输出。判定器是一个代理(人工或计算的),通过将输出与预期结果进行比较来确定输出是否正确。输出不仅是功能上产生的值,还可以包括质量属性的派生度量,例如产生输出所花费的时间。图 12.1 还表明程序的内部状态可以展示给判定器,判定器可以确定该状态是否正确——也就是说,它可以检测程序是否进入错误状态,并对程序的正确性做出判断。设置和检查程序的内部状态是测试的一个方面,这在我们的可测试性策略中将占据重要地位。

要使一个系统具有适当的可测试性,必须能够控制每个组件的输入(并且可能操纵其内部状态),然后观察其输出(并且可能在计算输出之后或在计算输出的过程中观察其内部状态)。通常,控制和观察是通过使用测试工具来实现的,这是一组专门设计用于测试软件的软件(在某些情况下,也可能是硬件)。测试工具具有各种形式,可能包括诸如跨接口发送的数据的记录和回放功能,或者用于测试嵌入式软件的外部环境的模拟器,甚至是在生产过程中运行的独立软件(请参阅侧栏“NETFLIX的猿猴军团”)。测试工具可以在执行测试程序和记录输出方面提供帮助。测试工具及其配套的基础设施本身可能是相当重要的软件部分,具有自己的架构、利益相关者和质量属性要求。
网飞(Netflix)通过 DVD 和流媒体视频来发行电影和电视节目。其流媒体视频服务取得了极大的成功。事实上,在 2018 年,网飞的流媒体视频占据了全球互联网流量的 15%。自然地,高可用性对网飞来说很重要。
网飞(Netflix)在亚马逊 EC2 云平台上托管其计算机服务,该公司在其测试过程中使用了一系列最初被称为“猿猴军团”(Simian Army)的服务。网飞从“混沌猴子”(Chaos Monkey)开始,它会随机终止运行系统中的进程。这使得能够监测进程失败的影响,并能够确保系统不会因进程失败而出现故障或严重性能下降。
混沌猴子(Chaos Monkey)有了一些协助测试的朋友。除了混沌猴子,网飞的猿猴军团(Netflix Simian Army)还包括以下这些:
- 延迟猴子(Latency Monkey)在网络通信中引入人工延迟以模拟服务降级,并测量上游服务是否做出了适当的响应。
- 合规猴子(Conformity Monkey)会识别出不符合最佳实践的实例并将其关闭。例如,如果一个实例不属于自动缩放组,那么当需求增加时,它就无法进行适当的缩放。
- 医生猴子(Doctor Monkey)接入在每个实例上运行的健康检查以及监测其他外部健康迹象(例如,CPU 负载),以检测不健康的实例。
- 保洁猴子(The Janitor Monkey)确保网飞(Netflix)的云环境运行时没有杂乱和浪费。它会查找未使用的资源并进行处理。
- 安全猴子(The Security Monkey)是合规猴子(Conformity Monkey)的扩展。它会发现安全违规或漏洞,例如安全组配置不当,并终止违规实例。它还确保所有 SSL 和数字版权管理 (DRM) 证书有效且未临近更新。
- 10 - 18 猴子(本地化 - 国际化)检测为多个地理区域、使用不同语言和字符集的客户提供服务的实例中的配置和运行时问题。“10 - 18”这个名称来自“L10n - i18n”,这是“本地化”和“国际化”这两个词的一种简写形式。
猿猴军团的一些成员使用故障注入以可控和受监控的方式将故障引入运行中的系统。其他成员则监控系统及其环境的各个专门方面。这两种技术的适用范围都不仅仅局限于网飞。
鉴于并非所有故障在严重程度上都是等同的,相比发现其他故障,应更侧重于发现最严重的故障。“猿猴军团”反映了网飞的一个决心,即所针对的故障就其影响而言是最严重的。
网飞(Netflix)的策略表明,有些系统过于复杂和具有适应性,无法进行全面测试,因为它们的某些行为是突发的。在这种情况下,测试的一个方面是对系统产生的运行数据进行记录,以便在出现失效时,能够在实验室中分析记录的数据以尝试重现故障。
—LB
测试由各种开发人员、用户或质量保证人员进行。可以对系统的部分或整个系统进行测试。可测试性的响应措施涉及测试在发现故障方面的有效性以及达到某种期望的覆盖水平所需的测试时间。测试用例可以由开发人员、测试组或客户编写。在某些情况下,测试实际上推动了开发,就像测试驱动开发的情况一样。
代码测试是验证的一种特殊情况,它需要确保工程制品满足其利益相关者的需求或适合使用。在 第 21 章 中,我们将讨论架构设计评审——另一种验证,其中被测试的制品是架构。
12.1 可测试性的通用场景
表 12.1 列举了表征可测试性的通用场景的元素。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 测试用例可以由人工或自动测试工具执行。 | 以下一项或多项: 手动运行测试或使用自动测试工具 |
| 触发事件 | 启动一个测试或一组测试。 | 这些测试用于: |
| 环境 | 测试发生在各种事件或生命周期里程碑上。 | 由于以下原因执行测试集: |
| 构件 | 被测试的制品是系统的一部分以及任何所需的测试基础设施。 | 被测试的部分: |
| 响应 | 系统及其测试基础设施可以被控制以执行所需的测试,并且可以观察测试的结果。 | 以下一项或多项: |
| 响应度量 | 响应度量旨在表示被测试系统有多容易“暴露”其故障或缺陷。 | 以下一项或多项: |
图 12.2 显示了可测试性的具体场景: 开发人员在开发过程中完成一个代码单元并执行一个测试序列,其结果被捕获,并在 30 分钟内提供 85% 的路径覆盖率。

12.2 可测试性策略
可测试性策略旨在促进更轻松、更高效和更强大的测试。图 12.3 说明了可测试性策略的目标。用于增强软件可测试性的架构技术不像可修改性、性能和可用性等其他质量属性学科那样受到那么多关注,但正如我们之前所说,架构师为降低测试的高成本所做的任何事情都将产生显著的效益。

可测试性策略分为两类。第一类涉及为系统增加可控性和可观测性。第二类涉及限制系统设计的复杂性。
控制和监视系统状态
控制和观测对于可测试性至关重要,以至于一些作者正是基于这些方面来定义可测试性。这两者相辅相成;如果您无法观察到进行控制操作时所发生的情况,那么进行控制就没有意义。控制和观测的最简单形式是为软件组件提供一组输入,让其工作,然后观察其输出。然而,可测试性策略中关于控制和观测的类别对软件的洞察超出了其输入和输出。这些策略使组件能够维护某种状态信息,允许测试人员为该状态信息赋值,并根据需要向测试人员提供该信息。状态信息可能是操作状态、某些关键变量的值、性能负载、中间处理步骤,或者任何有助于重现组件行为的其他有用信息。具体策略包括以下内容:
-
专用接口:拥有专门的测试接口,使你能够通过应用测试工具或正常执行来控制或捕获组件的变量值。专门的测试例程示例(其中一些可能除了测试目的外不可用)包括以下这些:
- 针对重要变量、模式或属性的“设置”(set)和“获取”(get)方法
- 返回对象完整状态的“报告”(report)方法
- 将内部状态(例如,类的所有属性)设置为指定内部状态的“重置”(reset)方法
- 用于开启详细输出、不同级别事件日志记录、性能检测或资源监控的方法
专门的测试接口和方法应明确标识出来,或与所需功能的访问方法和接口分开,以便在需要时可以将其删除。然而,请注意,在性能关键和某些安全关键系统中,部署与测试不同的代码是有问题的。如果您删除测试代码,您如何知道发布的代码与您测试的代码具有相同的行为,特别是相同的时间行为?因此,此策略对于其他类型的系统更有效。
-
记录/回放:导致故障的状态通常难以重新创建。在状态跨越接口时记录该状态,允许使用该状态“回放系统”并重新创建故障。“记录”指的是捕获跨越接口的信息,“回放”指的是将其用作进一步测试的输入。
-
本地化状态存储:为了在任意状态下启动系统、子系统或组件进行测试,如果该状态存储在一个单一的位置,那将是最方便的。相比之下,如果状态被隐藏或分布,这种方法即使不是不可能,也会变得困难。状态可以是细粒度的,甚至是位级别的,也可以是粗粒度的,以表示广泛的抽象或整体操作模式。粒度的选择取决于在测试中如何使用这些状态。一种方便的“外部化”状态存储的方式(即,使其通过接口特性易于操作)是使用状态机(或状态机对象)作为跟踪和报告当前状态的机制。
-
抽象数据源:与控制程序状态的情况类似,能够控制其输入数据会使测试更容易。对接口进行抽象可以让你更轻松地替换测试数据。例如,如果你有一个客户交易数据库,你可以设计你的架构,以便你能够轻松地将你的测试系统指向其他测试数据库,甚至可能指向测试数据文件,而无需更改你的功能代码。
-
沙盒:“沙盒化”指的是将系统的一个实例与现实世界隔离开来,以便进行实验,而无需担心必须撤销实验的后果。能够以这样一种方式操作系统,即它没有永久性后果,或者任何后果都可以回滚,这有助于测试。沙盒策略可用于场景分析、培训和仿真。特别是在现实世界中的可能失效导致严重后果的情况下,仿真是一种常用于测试和培训的策略。
沙盒化的一种常见形式是虚拟化资源。测试一个系统通常涉及与行为不受系统控制的资源进行交互。使用沙盒,您可以构建一个行为受您控制的资源版本。例如,系统时钟的行为通常不受我们控制 —— 它每秒递增一秒。因此,如果我们想让系统认为在所有数据结构都应该溢出的那一天是午夜,我们需要一种方法来实现,因为等待不是一个好选择。当我们能够将系统时间从时钟时间中抽象出来时,我们可以让系统(或组件)以比实际时钟更快的速度运行,并在关键时间边界(如夏令时的下一次转换)测试系统(或组件)。对于其他资源,如内存、电池、网络等,也可以进行类似的虚拟化。桩、模拟和依赖注入是简单但有效的虚拟化形式。
-
可执行断言:使用此策略,断言(通常)是手动编码的,并放置在所需的位置,以指示程序何时何地处于错误状态。断言通常旨在检查数据值是否满足指定的约束。断言是根据特定的数据声明定义的,并且必须放置在数据值被引用或修改的位置。断言可以表示为每个方法的前置条件和后置条件,也可以表示为类级不变量。这增加了系统的可观测性,因为断言可能会被标记为失败。在数据值发生变化的位置系统地插入断言可以被视为生成“扩展”类型的手动方式。本质上,用户正在用额外的检查代码为类型添加注释。每当该类型的对象被修改时,检查代码会自动执行,如果违反任何条件,就会生成警告。在断言涵盖测试用例的范围内,它们有效地将测试预言嵌入到代码中 —— 假设断言是正确的并且编码正确。
所有这些策略都为软件添加了一些能力或抽象,如果我们对测试不感兴趣,那么这些就不会存在。它们可以被视为为基本的、完成工作的软件增加了更精细的软件,这些软件具有一些旨在提高测试效率和效果的特殊能力。
除了可测试性策略外,还有许多技术可用于将一个组件替换为其不同版本以方便测试:
- 组件替换 只是将一个组件的实现换成具有(在可测试性方面)便于测试的特性的不同实现。组件替换通常在系统的构建脚本中完成。
- 预处理器宏,在激活时,可以扩展为状态报告代码或激活返回或显示信息的探测语句,或将控制权返回给测试控制台。
- 方面(在面向方面的程序中)可以处理如何报告状态的横切关注点
限制复杂性
复杂的软件更难测试。它的操作状态空间很大,并且(在其他条件相同的情况下)在大状态空间中重新创建确切状态比在小状态空间中更难。因为测试不仅仅是让软件失败,还在于找到导致失效的故障以便将其消除,所以我们经常关注使行为具有可重复性。此类别包括两个策略:
-
限制结构复杂性:此策略包括避免或解决组件之间的循环依赖,隔离和封装对外部环境的依赖,以及总体上减少组件之间的依赖(通常通过降低组件之间的耦合来实现)。例如,在面向对象的系统中,您可以简化继承层次结构:
- 限制一个类派生自的类的数量,或者限制从一个类派生的类的数量。
- 限制继承树的深度,以及一个类的子类数量。
- 限制多态性和动态调用。
从经验上看,已被证明与可测试性相关的一个结构度量是类的“响应”。类 C 的响应是 C 的方法数量加上 C 的方法所调用的其他类的方法数量的总和。保持这个度量值较低可以提高可测试性。此外,架构级别的耦合度量,例如传播成本和去耦级别,可以用于测量和跟踪系统架构中的整体耦合水平。
确保系统具有高内聚、松耦合和关注点分离——所有可修改性策略(见第 8 章)——也有助于提高可测试性。这些特性通过给每个元素一个集中的任务,限制其与其他元素的交互,从而限制了架构元素的复杂性。关注点分离有助于实现可控性和可观测性,同时减小整个程序的状态空间大小。
最后,某些架构模式本身就有利于可测试性。在分层模式中,您可以先测试较低的层,然后在对较低层有信心的情况下测试较高的层。
-
限制不确定性:与限制结构复杂性相对应的是限制行为复杂性。在测试方面,不确定性是一种有害的复杂行为形式,不确定性系统比确定性系统更难测试。此策略包括找出所有不确定性的来源,例如不受约束的并行性,并尽可能地将其消除。有些不确定性的来源是不可避免的——例如,在对不可预测事件作出响应的多线程系统中——但对于此类系统,可以使用其他策略(如记录/回放)来帮助管理这种复杂性。
图 12.4 总结了用于可测试性的策略。

12.3 基于策略的可测试性问卷
基于 12.2 节 中描述的策略,我们可以创建一组受策略启发的问题,如 表 12.2 所示。为了全面了解为支持可测试性所做的架构选择,分析师会提出每个问题,并将答案记录在表中。然后,这些问题的答案可以成为进一步活动的重点:文档调查、代码或其他制品的分析、代码的逆向工程等等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策和定位 | 推理和假设 |
|---|---|---|---|---|---|
| 控制和观察系统状态 | 系统是否具有用于获取和设置值的专用接口? | ||||
| 系统是否有录制/播放机制? | |||||
| 系统的状态存储是否已本地化? | |||||
| 系统是否抽象其数据源? | |||||
| 部分或全部系统是否可以在沙盒中运行? | |||||
| 系统中是否有可执行断言的角色? | |||||
| 限制复杂性 | 系统是否以系统的方式限制了结构复杂性? | ||||
| 您的系统中是否存在不确定性,是否有方法来控制或限制这种不确定性? |
12.4 可测试性模式
可测试性模式都能使将特定于测试的代码从系统的实际功能中解耦变得更加容易。在此,我们讨论三种模式:依赖注入、策略和拦截过滤器。
依赖注入模式
在依赖注入模式中,客户端的依赖项与其行为是分离的。这种模式利用了控制反转。与传统的声明式编程不同,在传统编程中,控制和依赖关系明确存在于代码中,控制反转的依赖关系意味着控制和依赖关系由某些外部源提供,并注入到代码中。
在这种模式中,存在四个角色:
- 一项服务(您希望广泛提供的)
- 该服务的客户端
- 一个接口(被客户端使用,由服务实现)
- 一个注入器(创建服务的实例并将其注入客户端)
当接口创建服务并将其注入客户端时,编写客户端时无需了解具体的实现。换句话说,所有的实现细节通常在运行时被注入。
好处:
- 测试实例可以被注入(而非生产实例),并且这些测试实例可以管理和监控服务的状态。因此,编写客户端时无需知道如何对其进行测试。实际上,许多现代测试框架就是这样实现的。
权衡:
- 依赖注入使得运行时性能更难以预测,因为它可能会改变正在测试的行为。
- 采用这种模式会增加少量前期的复杂性,并且可能需要对开发人员进行重新培训,让他们从控制反转的角度思考。
策略模式
在策略模式中,一个类的行为可以在运行时改变。当执行给定任务可以采用多种算法,并且可以动态选择要使用的特定算法时,通常会采用这种模式。该类仅包含所需功能的抽象方法,此方法的具体版本是根据上下文因素选择的。这种模式经常用于将某些功能的非测试版本替换为提供额外输出、额外内部健全性检查等的测试版本。
好处:
- 这种模式通过不将多个关注点(例如同一函数的不同算法)合并到一个类中,使类变得更简单。
权衡:
- 与所有设计模式一样,策略模式会增加少量前期的复杂性。如果类很简单或者运行时的选择很少,那么这种增加的复杂性可能是浪费的。
- 对于小类,策略模式可能会使代码的可读性略微降低。然而,随着复杂性的增加,以这种方式分解类可以提高可读性。
拦截过滤器模式
拦截过滤器模式用于在客户端和服务之间向请求或响应注入预处理和后处理。在将请求传递给最终服务之前,可以以任意顺序定义并应用任意数量的过滤器到请求。例如,日志记录和身份验证服务是经常有用的过滤器,只需实现一次即可普遍应用。测试过滤器可以以这种方式插入,而不会干扰系统中的任何其他处理。
好处:
- 与策略模式一样,这种模式通过不将所有的预处理和后处理逻辑都放在类中,使类更简单。
- 使用拦截过滤器可以成为重用的强大动力,并能显著减少代码库的规模。
权衡:
- 如果有大量数据被传递到服务,这种模式可能效率非常低,并且会增加相当大的延迟,因为每个过滤器都要对整个输入进行完整的遍历。
12.5 扩展阅读
关于软件测试的文献多到能让一艘战舰沉没,但从架构的角度探讨如何让您的系统更具可测试性的著述则相对较少。关于测试的良好概述,请参阅 [Binder 00] 。杰夫·沃斯(Jeff Voas)关于可测试性以及可测试性与可靠性之间关系的基础性工作也值得研究。有好几篇论文可供选择,但 [Voas 95] 是一个不错的起点,它会为您指引其他相关内容。
贝托利诺(Bertolino)和斯特里吉尼(Strigini)[Bertolino 96a,96b] 是 图 12.1 中所示测试模型的开发者。
“鲍勃大叔”马丁(“Uncle Bob” Martin)撰写了大量关于测试驱动开发以及架构和测试之间关系的内容。关于这方面最好的书是罗伯特·C·马丁(Robert C. Martin)的《整洁架构:软件结构与设计工匠指南》[Martin 17]。肯特·贝克(Kent Beck)撰写的关于测试驱动开发的早期权威参考书籍是《测试驱动开发:示例》[Beck 02]。
传播成本耦合度量标准最初在[MacCormack 06]中被描述。解耦级别度量标准在[Mo 16]中被描述。
模型检查是一种象征性地执行所有可能代码路径的技术。使用模型检查能够验证的系统规模是有限的,但设备驱动程序和微内核已成功进行了模型检查。有关模型检查工具的列表,请访问 https://en.wikipedia.org/wiki/Model_checking 。
12.6 问题讨论
1. 一个可测试的系统是容易暴露其错误的系统。也就是说,如果一个系统包含错误,那么不需要花费很长时间或很大努力就能让该错误显现出来。相比之下,容错性完全是关于设计极力隐藏其错误的系统;在这种情况下,整个理念是让系统很难暴露其错误。是否有可能设计一个既高度可测试又高度容错的系统,或者这两个设计目标本质上是不相容的?请讨论。
2. 您认为可测试性与其他哪些质量属性冲突最大?您认为可测试性与其他哪些质量属性最兼容?
3. 许多用于提高可测试性的策略对于实现可修改性也很有用。您认为这是为什么?
4. 为基于 GPS 的导航应用程序编写一些具体的可测试性场景。您会在设计中采用哪些策略来应对这些场景?
5. 我们的策略之一是限制不确定性,其中一种方法是使用锁来强制同步。使用锁对其他质量属性有什么影响?
6. 假设您正在构建下一个出色的社交网络系统。您预计在首次亮相后的一个月内,您将拥有 50 万用户。您无法支付 50 万人来测试您的系统,但当所有 50 万人都在使用它时,它必须强大且易于使用。您应该怎么做?哪些策略会对您有帮助?为这个社交网络系统编写一个可测试性场景。
7. 假设您使用可执行断言来提高可测试性。请提出支持和反对在生产系统中运行断言(而不是在测试后删除它们)的理由。
第13章 易用性
人们会忽略那些忽略人的设计。
—— 弗兰克・奇梅罗(Frank Chimero)
易用性关注的是用户完成期望任务的难易程度以及系统提供的用户支持类型。多年来,对易用性的关注已被证明是提高系统质量(或者更确切地说,是用户对质量的感知)以及最终用户满意度的最经济、最简便的方法之一。
易用性包括以下几个方面:
- 学习系统特性:如果用户不熟悉特定系统或其某一方面,系统可以采取哪些措施来使学习任务更容易?这可能包括提供帮助功能。
- 高效使用系统:系统可以采取哪些措施来提高用户的操作效率?这可能包括使用户能够在发出命令后重新引导系统。例如,用户可能希望暂停一项任务,执行几项操作,然后恢复该任务。
- 将用户错误的影响最小化:系统可以采取哪些措施来确保用户错误的影响最小?例如,用户可能希望取消错误发出的命令或撤销其影响。
- 使系统适应用户需求:用户(或系统本身)如何适应以使用户的任务更容易?例如,系统可能会根据用户过去的输入自动填写网址。
- 增强信心和满意度:系统采取什么措施让用户相信正在采取正确的行动?例如,提供反馈表明系统正在执行一项长期运行的任务,并显示到目前为止的完成百分比,这将增加用户对系统的信心。
专注于人机交互的研究人员使用了“用户主导”“系统主导”和“混合主导”这些术语来描述人机组合中哪一方在执行某些操作时采取主动以及交互是如何进行的。可用性场景可以结合来自这两个方面的主导。例如,在取消命令时,用户发出取消指令(用户主导),系统做出响应。然而,在取消过程中,系统可能会显示进度指示器(系统主导)。因此,取消操作可能包含混合主导。在本章中,我们将利用用户主导和系统主导之间的这种区别来讨论架构师用于实现各种场景的策略。
易用性的实现与可修改性之间存在着紧密的联系。用户界面设计过程包括生成用户界面设计,然后对其进行测试。第一次就做对的可能性极小,所以你应该计划对这个过程进行迭代——因此你应该设计你的架构,以使这种迭代不那么痛苦。这就是易用性与可修改性紧密相关的原因。在迭代过程中,希望设计中的缺陷能够得到纠正,然后这个过程会重复进行。
这种联系导致了支持用户界面设计的标准模式。事实上,要实现易用性,您能做的最有帮助的事情之一就是一遍又一遍地修改你的系统,随着你从用户那里了解到情况并发现需要改进的地方,从而让系统变得更好。
13.1 易用性的通用场景
表 13.1 列举了表征易用性的通用场景的要素。
| 场景部分 | 描述 | 可能的值 |
|---|---|---|
| 来源 | 触发来自何处? | 最终用户(可能处于专门的角色,例如系统或网络管理员)是易用性触发的主要来源。到达系统的外部事件(用户可能对此做出反应)也可能是触发源。 |
| 触发事件 | 最终用户想要什么? | 最终用户希望: |
| 环境 | 触发何时到达系统? | 易用性所关注的用户操作总是发生在运行时或系统配置时。 。 |
| 构件 | 系统的哪个部分受到触发? | 常见的例子包括: |
| 响应 | 系统应该如何响应? | 系统应: |
| 响应度量 | 如何衡量响应? | 以下一项或多项: |
图 13.1 给出了一个具体的易用性场景示例,您可以使用 表 13.1 生成:用户下载了一个新的应用程序,经过 2 分钟的尝试就能有效地使用它

13.2 易用性的策略
图 13.2 展示了这组易用性策略的目标。

Support User Initiative
支持用户主导
一旦系统在执行,通过向用户反馈系统正在做什么以及允许用户做出适当的响应,可以增强易用性。例如,接下来描述的策略——“取消”“撤销”“暂停/恢复”和“聚合”——支持用户纠正错误或提高效率。
架构师通过列举并分配系统响应用户命令的职责,来设计针对用户主导的响应。以下是一些支持用户主导的常见策略示例:
-
“取消”:当用户发出取消命令时,系统必须能够监听该命令(因此,有责任设置一个持续的监听程序,该监听程序不会被正在取消的操作所阻塞);正在被取消的活动必须终止;被取消的活动所使用的任何资源必须被释放;与被取消的活动协作的组件必须得到通知,以便它们也能采取适当的行动。
-
“撤销”:为了支持撤销功能,系统必须保存足够的关于系统状态的信息,以便应用户的请求恢复到较早的状态。这种记录可以采取状态“快照”的形式——例如,检查点——或者一组可逆操作。并非所有操作都能轻易地被撤销。例如,在文档中将所有出现的字母“a”更改为字母“b”,不能通过将所有的“b”更改为“a”来撤销,因为其中一些“b”的实例可能在原始更改之前就已经存在。在这种情况下,系统必须保存更详细的更改记录。当然,有些操作根本无法撤销:例如,您无法召回已寄出的包裹或已经发射的导弹。
撤销有多种形式。有些系统允许单次撤销(再次调用撤销会将您恢复到发出第一次撤销命令时的状态,实质上是撤销了撤销操作)。在其他系统中,多次调用撤销操作会使您逐步回退到许多之前的状态,要么达到某个限制,要么一直回退到应用程序上次打开时的状态。
-
“暂停/恢复”:当用户启动了一个长时间运行的操作——比如,从服务器下载一个大文件或一组文件——提供暂停和恢复该操作的能力通常是有用的。暂停长时间运行的操作可能是为了暂时释放资源,以便将其重新分配给其他任务。
-
“聚合”:当用户执行重复操作,或者以相同方式影响大量对象的操作时,提供将较低级别的对象聚合为一个组的能力是有用的,这样操作就可以应用于该组,从而使用户免除重复进行相同操作的繁琐和潜在的错误。一个例子是将幻灯片中的所有对象聚合,并将文本更改为 14 点字体。
支持系统主导
当系统采取主导时,它必须依赖于用户模型、用户正在执行的任务模型或系统状态模型。每个模型都需要各种类型的输入来完成其主导性。支持系统主导策略确定系统用于预测自身行为或用户意图的模型。封装此信息将使其更易于定制或修改。定制和修改可以基于过去的用户行为动态进行,也可以在开发期间离线进行。相关策略描述如下:
- “维护任务模型”:任务模型用于确定上下文,以便系统能够对用户试图做什么有一定的了解并提供帮助。例如,许多搜索引擎提供预测性的提前输入功能,许多邮件客户端提供拼写纠正。这两个功能都基于任务模型。
- “维护用户模型”:此模型明确表示用户对系统的了解、用户在预期响应时间方面的行为以及特定于用户或用户类别的其他方面。例如,语言学习应用程序会持续监控用户出错的领域,然后提供额外的练习来纠正这些行为。这种策略的一个特殊情况常见于用户界面“定制”,其中用户可以明确修改系统的用户模型。
- “维护系统模型”:系统维护自身的显式模型。这用于确定预期的系统行为,以便能够向用户提供适当的反馈。系统模型的常见表现形式是预测完成当前活动所需时间的进度条。
图13.3 总结了实现易用性的策略。

13.3 基于策略的易用性调查问卷
基于 第 13.2 节 中描述的策略,我们可以创建一组受易用性策略启发的问题,如 表 13.2 所示。为了全面了解为支持易用性所做的架构选择,分析师提出每个问题,并将答案记录在表中。然后,这些问题的答案可以成为进一步活动的重点:文档调查、代码或其他工件的分析、代码的逆向工程等等。
| 策略组 | 策略问题 | 支持与否(是/否) | 风险 | 设计决策和定位 | 推理和假设 |
|---|---|---|---|---|---|
| 支持用户主导 | 系统是否能够监听并响应“取消”命令? | ||||
| 是否可以“撤销”上一个命令,或者上几个命令? | |||||
| 是否可以“暂停”然后“恢复”长时间运行的操作? | |||||
| 是否可以将用户界面对象聚合为一组,并对该组应用操作? | |||||
| 支持系统主导 | 系统是否维护一个任务模型? | ||||
| 系统是否维护一个用户模型? | |||||
| 系统是否维护一个自身模型? |
13.4 易用性模式
我们将简要讨论三种易用性模式:模型 - 视图 - 控制器(MVC)及其变体、观察者和备忘录。这些模式主要通过促进关注点分离来提高易用性,这反过来又使得用户界面的设计迭代变得容易。其他类型的模式也是可能的——包括在用户界面本身的设计中使用的模式,如面包屑导航、购物车或渐进式披露——但我们在这里不讨论它们。
模型-视图-控制器
MVC 可能是最广为人知的易用性模式。它有许多变体,例如 MVP(模型 - 视图 - 展示器)、MVVM(模型 - 视图 - 视图模型)、MVA(模型 - 视图 - 适配器)等等。本质上,所有这些模式都专注于将模型(系统底层的“业务”逻辑)与其在一个或多个用户界面视图中的实现相分离。在原始的 MVC 模型中,模型会向视图发送更新,用户可以看到并与之交互。用户交互(按键、按钮点击、鼠标移动等等)被传输到控制器,控制器将其解释为对模型的操作,然后将这些操作发送到模型,模型会相应地改变其状态。反向路径也是原始 MVC 模式的一部分。也就是说,模型可能会发生更改,控制器会向视图发送更新。
更新的发送取决于 MVC 是在一个进程中还是分布在多个进程(可能跨网络)中。如果 MVC 在一个进程中,那么使用观察者模式发送更新(在下一小节中讨论)。如果 MVC 分布在多个进程中,那么通常使用发布 - 订阅模式来发送更新(见 第 8 章)。
好处:
- 因为 MVC 促进了清晰的关注点分离,所以对系统某一方面(例如 UI 的布局(视图))的更改通常对模型或控制器没有影响。
- 此外,由于 MVC 促进了关注点分离,开发人员可以相对独立且并行地处理模式的所有方面——模型、视图和控制器。这些独立的方面也可以并行测试。
- 一个模型可以在具有不同视图的系统中使用,或者一个视图可能在具有不同模型的系统中使用。
权衡:
- 对于复杂的用户界面,MVC 可能会变得繁琐,因为信息通常分散在多个组件中。例如,如果同一模型有多个视图,对模型的更改可能需要对几个原本不相关的组件进行更改。
- 对于简单的用户界面,MVC 增加了前期的复杂性,可能无法在下游节省成本方面得到回报。
- MVC 给用户交互增加了少量延迟。虽然这通常是可以接受的,但对于需要非常低延迟的应用程序可能会有问题。
观察者
观察者模式是一种将某些功能与一个或多个视图相链接的方式。这种模式有一个“主体”——被观察的实体——以及该主体的一个或多个“观察者”。观察者需要向主体注册自己;然后,当主体的状态发生变化时,观察者会收到通知。这种模式经常用于实现 MVC(及其变体)——例如,作为一种通知模型更改的各种视图的方式。
好处:
- 此模式将某些底层功能与该功能如何呈现以及呈现多少次的问题分离开来。
- 观察者模式使得在运行时轻松更改主体和观察者之间的绑定关系。
权衡:
- 如果不需要主体的多个视图,观察者模式就过于复杂了。
- 观察者模式要求所有观察者向主体注册和注销。如果观察者忽略注销,那么它们的内存永远不会被释放,这实际上会导致内存泄漏。此外,这可能会对性能产生负面影响,因为过时的观察者将继续被调用。
- 观察者可能需要做大量工作来确定是否以及如何反映状态更新,并且每个观察者可能都需要重复这项工作。例如,假设主体正在以精细粒度更改其状态,例如温度传感器报告百分之一度的波动,但视图更新仅以整度更改。在这种存在“阻抗不匹配”的情况下,可能会浪费大量的处理资源。
备忘录
备忘录模式是实现撤销策略的常用方法。此模式具有三个主要组件:“原发器”、“管理者”和“备忘录”。原发器正在处理一些会改变其状态的事件流(源自用户交互)。管理者向原发器发送导致其状态改变的事件。当管理者即将更改原发器的状态时,它可以请求一个备忘录——现有状态的快照——并且如果需要,可以通过将备忘录传递回原发器来使用此工件恢复该现有状态。通过这种方式,管理者对状态如何管理一无所知;备忘录只是管理者使用的一种抽象。
好处:
- 这种模式的明显好处是,你将实现撤销的复杂过程以及确定要保留的状态委托给实际创建和管理该状态的类。因此,原发器的抽象得以保留,系统的其余部分不需要知道细节。
权衡:
- 根据所保存状态的性质,备忘录可能会消耗任意大量的内存,这可能会影响性能。在一个非常大的文档中,尝试剪切和粘贴许多大的部分,然后撤销所有这些操作。这很可能导致您的文本处理器明显变慢。
- 在某些编程语言中,很难将备忘录强制作为一个不透明的抽象。
13.5 扩展阅读
克莱尔·玛丽·卡拉特(Claire Marie Karat)研究了易用性与商业优势之间的关系[Karat 94]。
Jakob Nielsen也写了大量关于这个主题的文章,包括易用性ROI的计算[Nielsen 08]。
Bonnie John和Len Bass研究了易用性和软件架构之间的关系。他们列举了大约二十几种对架构有影响的可用性场景,并为这些场景提供了相关模式 [Bass 03]。
格雷格·哈特曼(Greg Hartman)将专注度定义为系统支持用户主动性并允许取消或暂停/恢复的能力 [Hartman 10] 。
13.6 问题讨论
1. 为您的汽车编写一个具体的易用性场景,明确你设置最喜欢的广播电台所需的时间。现在考虑驾驶员体验的另一部分,并创建场景来测试通用场景表(表 13.1)中响应的其他方面。
2. 易用性与安全性如何权衡?它如何在性能间权衡?
3. 挑选几个你喜欢的做类似事情的网站,例如社交网络或在线购物网站。现在从易用性通用场景(例如“预测用户的需求”)中选择一两个适当的响应,并选择相应的适当响应度量。使用您选择的响应和响应度量,比较这些网站的易用性。
4. 为什么在如此多的系统中,对话框中的取消按钮似乎没有响应?你认为这些系统中忽略了哪些架构原则?
5. 你认为为什么进度条经常表现得不稳定,一下子从 10% 跳到 90% ,然后卡在 90% ?
6. 研究 1988 年法航 296 航班在法国哈布斯海姆森林坠毁的事件。飞行员表示他们无法读取无线电高度表的数字显示或听到其声音读数。在此背景下,讨论易用性和安全性之间的关系。
第14章 其他质量属性
质量不是当你所做的符合你的意图时所发生的情况。质量是当你所做的符合客户期望时所发生的情况。
——瓜斯帕里(Guaspari)
[第 4 章][ch04]——[第 13 章][ch13] 分别讨论了对软件系统很重要的一个特定质量属性(QA)。那些章节中的每一章都讨论了其特定质量属性是如何定义的,给出了关于该质量属性的通用场景,并展示了如何编写具体场景以表达关于该质量属性的精确含义层次。此外,每一章都提供了一组在架构中实现该质量属性的技术。简而言之,每一章都呈现了一种用于指定和设计以实现特定质量属性的组合(方法)。
然而,你无疑可以推断出,那十章内容仅仅是触及了你在开发的软件系统中可能需要的各种质量属性的皮毛。
本章将展示如何针对我们“A 清单”中未涵盖的质量属性构建同样的规范和设计方法。
14.1 其他种类的质量属性
本书 [第二部分][part02] 到目前为止所涵盖的质量属性都有一些共同点:它们要么涉及正在运行的系统,要么涉及创建和部署系统的开发项目。换句话说,要测量这些质量属性之一,要么在系统运行时测量它(可用性、能效性、性能、安全性、可靠性、易用性),要么在系统不运行时测量对系统进行某些操作的人员(可修改性、可部署性、可集成性、可测试性)。虽然这些质量属性无疑为你提供了一份重要质量属性的“A 清单”,但还有其他可能同样有用的质量属性。
架构的质量属性
另一类质量属性侧重于衡量架构本身。以下是三个例子:
-
可构建性:这个质量属性衡量架构本身对于快速高效开发的适宜程度。它通过将架构转化为满足其所有需求的工作产品所需的成本(通常以金钱或时间衡量)来度量。从这个意义上说,它类似于衡量开发项目的其他质量属性,但不同之处在于测量所针对的知识与架构本身有关。
-
概念完整性:概念完整性是指架构设计的一致性,它有助于架构的可理解性,并在其实现和维护中减少混乱并增加可预测性。概念完整性要求在整个架构中以相同的方式做相同的事情。在具有概念完整性的架构中,少即是多。例如,组件之间有无数种相互发送信息的方式:消息、数据结构、事件信号等等。具有概念完整性的架构将采用少量的方式,并且只有在有充分理由的情况下才提供替代方案。类似地,组件都应该以相同的方式报告和处理错误,以相同的方式记录事件或事务,以相同的方式与用户交互,以相同的方式清理数据等等。
-
市场适销性:架构的“市场适销性”是另一个值得关注的质量属性。有些系统因其架构而闻名,这些架构有时具有其自身的意义,独立于它们为系统带来的其他质量属性。当前对构建基于云的和基于微服务的系统的强调告诉我们,对架构的认知至少与架构所带来的实际质量属性一样重要。例如,许多组织感到有必要构建基于云的系统(或其他一些“当下流行的技术”),无论这是否是正确的技术选择。
开发可分发性
开发可分发性是设计软件以支持分布式软件开发的质量属性。与可修改性一样,这种质量是根据开发项目的活动来衡量的。如今许多系统都是由全球分布式团队开发的。采用这种方法时必须克服的一个问题是协调团队的活动。系统的设计应使团队之间的协调最小化,也就是说,主要子系统应表现出低耦合性。无论是对于代码还是对于数据模型,都需要实现这种最小化的协调。开发相互通信的模块的团队可能需要协商这些模块的接口。当一个模块被许多其他模块使用,且每个模块由不同的团队开发时,沟通和协商就会变得更加复杂和繁重。因此,项目的架构结构和社会(及商业)结构需要合理地协调一致。对于数据模型也有类似的考虑。开发可分发性的场景将涉及正在开发的系统的通信结构和数据模型的兼容性,以及进行开发的组织所采用的协调机制。
系统质量属性
诸如飞机、汽车和厨房电器等依赖于嵌入式软件的物理系统,其设计要满足一系列质量属性要求:重量、尺寸、耗电量、功率输出、污染排放、耐候性、电池寿命等等。软件架构往往会对系统的质量属性产生深远影响。例如,不能高效利用计算资源的软件可能需要额外的内存、更快的处理器、更大的电池,甚至可能需要额外的处理器(我们在 [第 6 章][ch06] 中讨论了能效性作为质量属性的话题)。当然,额外的处理器会增加系统的耗电量,同时也会增加其重量、物理外形尺寸以及成本。
相反,一个系统的架构或实现能够使软件满足其质量属性要求,也可能阻止软件满足其质量属性要求。例如:
- 一款软件的性能从根本上受到运行它的处理器性能的限制。无论你把软件设计得多么好,你都无法在爷爷的古董笔记本电脑上运行最新的全球天气预报模型并期望知道明天是否会下雨。
- 在防止欺诈和盗窃方面,物理安全可能比软件安全更重要且更有效。如果你不相信这一点,那就把你笔记本电脑的密码写在一张纸条上,把它贴在你的笔记本电脑上,然后把它留在一辆车窗未关的未上锁的汽车里。(实际上,请不要这样做。把这当作一个思想实验。)
这里的教训是,如果你是驻留在物理系统中的软件的架构师,你将需要了解对整个系统实现而言重要的质量属性,并与系统架构师和工程师合作,以确保你的软件架构对实现这些质量属性有积极贡献。
我们为软件质量属性引入的场景技术同样适用于系统质量属性。如果系统工程师和架构师尚未使用这些技术,请尝试引入它们。
14.2 使用质量属性标准列表——或者不使用
架构师手头不乏软件系统的质量属性列表。标题为“ISO/IEC FCD 25010:系统与软件工程:系统与软件产品质量要求与评估(SQuaRE):系统与软件质量模型”的标准就是一个很好的例子([图 14.1][ch14fig01])。该标准将质量属性分为支持“使用质量”模型的那些和支持“产品质量”模型的那些。这种划分在某些地方有点牵强,但它仍然开启了对一系列令人惊叹的质量属性进行分而治之的探索。

ISO 25010 列出了以下涉及产品质量的质量属性:
- 功能适用性:产品或系统在规定条件下使用时,提供满足明示和隐含需求的功能的程度。
- 性能效率:在规定条件下相对于所使用资源量的性能。
- 兼容性:产品、系统或组件在共享相同硬件或软件环境时,与其他产品、系统或组件交换信息和/或执行其所需功能的程度。
- 易用性:产品或系统在特定使用环境下,能被特定用户有效地、高效地和满意地用于实现特定目标的程度。
- 可靠性:系统、产品或组件在规定条件下、规定时间内执行规定功能的程度。
- 安全性:产品或系统保护信息和数据,以使人员或其他产品或系统具有与其授权类型和级别相适应的数据访问程度。
- 可维护性:产品或系统能够被预期的维护人员进行修改的有效性和效率程度。
- 可移植性:系统、产品或组件能够从一个硬件、软件或其他操作或使用环境转移到另一个环境的有效性和效率程度。
在 ISO 25010 中,这些“质量特性”各自由“质量子特性”组成(例如,不可抵赖性是安全性的一个子特性)。该标准以这种方式艰难地完成了对近六十种不同质量子特性的描述。它为我们定义了“愉悦”和“舒适”的质量。它区分了“功能正确性”和“功能完备性”,然后又恰当地添加了“功能适当性”。要表现出“兼容性”,系统必须具有“互操作性”或者仅仅是“共存性”。“易用性”是产品质量,而不是使用质量,尽管它包括“满意度”,而“满意度”是使用质量。“可修改性”和“可测试性”都是“可维护性”的一部分。“模块化”也是如此,它是实现质量的一种策略,而不是其自身的目标。“可用性”是“可靠性”的一部分。“互操作性”是“兼容性”的一部分。而“可扩展性”根本没有被提及。
都明白(上述内容)了吗?
像这样的列表——而且有很多这样的列表在流传——确实有其用途。它们可以作为有用的检查清单,帮助需求收集者确保没有重要需求被忽略。比独立的列表更有用的是,它们可以作为创建你自己的检查清单的基础,该清单包含你所在领域、行业、组织、产品中所关注的质量属性。质量属性列表也可以作为制定度量标准的基础,尽管这些名称本身对于如何做到这一点几乎没有提供线索。如果“趣味性”在你的系统中是一个重要的关注点,你如何度量它以了解你的系统是否提供了足够的趣味性呢?
像这样的通用列表也有一些缺点。首先,没有一个列表会是完整的。作为一名架构师,你不可避免地会被要求设计一个系统来满足利益相关者的关切,而这种关切是任何列表制定者都没有预见到的。例如,一些作者提到了“可管理性”,它表示系统管理员管理应用程序的难易程度。这可以通过插入用于监控操作以及调试和性能调优的有用工具来实现。我们知道有一个架构是有意识地以留住关键员工并吸引有才华的新员工到美国中西部一个安静地区为目标而设计的。那个系统的架构师谈到要给系统注入“爱荷华(州)适用性”。他们通过引入最先进的技术并给予他们的开发团队广泛的创作自由度来实现这一目标。在任何标准的质量属性列表中找到“爱荷华(州)适用性”都不太可能,但那个质量属性对那个组织来说和其他任何质量属性一样重要。
其次,列表往往引发的争议多于带来的理解。你可能会很有说服力地争辩说“功能正确性”应该是“可靠性”的一部分,或者“可移植性”只是一种“可修改性”,或者“可维护性”是一种“可修改性”(而不是相反情况)。ISO 25010 的编写者显然花费了时间和精力来决定将安全性作为其自身的特性,而不是像前一版本那样将其作为功能性的一个子特性。我们坚信,在进行这些争论上所花费的精力可以更好地用在其他地方。
第三,这些列表往往声称是分类法——也就是说,具有这样一种特殊属性的列表:每个成员都能被准确地归到一个位置。但在这方面,质量属性是出了名的难以明确界定。例如,我们在 [第 3 章][ch03] 中讨论过拒绝服务,它涉及安全性、可用性、性能和易用性等多个方面。
这些观察结果强化了在 [第 3 章][ch03] 中介绍的教训:质量属性名称本身基本上是无用的,最多只是开启对话的引子。此外,花费时间担心哪些质量属性是其他质量属性的子属性几乎是毫无用处的。相反,场景为我们提供了最佳方式,以便在我们谈及质量属性时准确说明我们的意思。
在质量属性标准列表作为检查清单有所帮助的范围内使用它们,但不必拘泥于其术语或结构。并且不要欺骗自己认为这样的检查清单消除了进行更深入分析的需要。
14.3 处理“X 能力”:将新的质量属性纳入考量
假设作为一名架构师,你必须处理一个没有完备知识体系的质量属性,没有像 [第 4 章][ch04] 至 [第 13 章][ch13] 为那些质量属性所提供的那种“汇总资料”。假设你发现自己必须处理诸如“开发可分发性”或“可管理性”甚至“爱荷华(州)适用性”这样的质量属性,你会怎么做?
为新质量属性捕捉场景
第一步是与那些其关注点导致对该质量属性有需求的利益相关者进行面谈。你可以与他们合作,无论是单独还是作为一个团队,来构建一组属性特征描述,以细化该质量属性的含义。例如,你可以将开发可分发性分解为软件分割、软件组合和团队协调等子属性。在进行了这种细化之后,你可以与利益相关者一起制定一组具体的场景,以描述该质量属性的含义。这个过程的一个例子可以在[第 22 章][ch22]中找到,我们在那里描述了构建一个“效用树”的情况。
一旦你有了一组具体的场景,那么你就可以对这些场景集合进行概括。查看你收集的刺激集合、响应集合、响应度量集合等等。通过将通用场景的每个部分都概括为你收集的具体实例,利用这些来构建一个通用场景。
对质量属性进行建模
如果你能构建(或者更好的是,找到)质量属性的概念模型,那么这个基础对于为其创建一组设计方法会很有帮助。这里所说的“模型”,我们的意思不过是对质量属性敏感的参数集以及影响这些参数的架构特征集的一种理解。例如,一个可修改性的模型可能会告诉我们,可修改性是系统中为响应修改而必须更改的位置数量以及这些位置之间相互关联程度的函数。一个性能模型可能会告诉我们,吞吐量是事务工作负载、事务之间的依赖关系以及可以并行处理的事务数量的函数。
[图 14.2][ch14fig02] 展示了一个简单的性能排队模型。此类模型被广泛用于分析各种类型排队系统的延迟和吞吐量,包括制造和服务环境以及计算机系统。

在此模型中,七个参数可能会影响模型预测的延迟:
- 到达率
- 排队规则
- 调度算法
- 服务时间
- 拓扑结构
- 网络带宽
- 路由算法
这些是在此模型中能够影响延迟的仅有的参数。这就是该模型的效力所在。此外,这些参数中的每一个都可能受到各种架构决策的影响。这就是该模型对架构师有用的原因。例如,路由算法可以是固定的,也可以是负载均衡算法。必须选择一种调度算法。拓扑结构可能会因动态添加或移除新服务器而受到影响。等等。
如果你正在创建自己的模型,你的场景集将为你的研究提供信息。其参数可以从触发事件(及其来源)、响应(及其度量)、工件(及其属性)以及环境(及其特征)中推导出来。
为新质量属性组合设计方法
基于模型生成一组机制的过程包括以下步骤:
- 列举模型的参数。
- 对于每个参数,列举能够影响该参数的架构特性(以及实现这些特性的机制)。你可以通过以下方式做到这一点:
- 重新审视你熟悉的一组机制,并自问每个机制如何影响质量属性参数。
- 搜索成功处理此质量属性的设计。你可以搜索你为质量属性所取的名称,但你也可以搜索在将质量属性细化为子属性时所选的术语。
- 搜索关于此质量属性的出版物和博客文章,并尝试概括它们的观察结果和发现。
- 找到该领域的专家并对他们进行访谈,或者简单地写信向他们征求建议。
结果是得到了一份机制列表,在上述示例中是用于控制性能的机制列表,更一般地说,是用于控制模型所关注的质量属性的机制列表。这使得设计问题更易于处理。这份机制列表是有限且相对较小的,因为模型的参数数量是有限的,并且对于每个参数,影响该参数的架构决策数量也是有限的。
14.4 扩展阅读
所有质量属性列表之母可能是在——还能在哪儿呢?——维基百科上的那个列表。很自然地,这个列表可以在“系统质量属性列表”下找到。在本书出版时,你可以尽情浏览 80 多个不同质量属性的定义。我们最喜欢的是“可演示性”,它被很有帮助地定义为具有可演示的特性。谁说你不能相信在互联网上读到的内容呢?
请参阅[Bass 19]的[第 8 章][ch08],以获取部署管道的质量列表。这些质量包括可追溯性、(部署管道的)可测试性、工具使用以及周期时间。
14.5 问题讨论
1. 不丹王国衡量其国民的幸福度,并且制定政府政策以提高不丹的国民幸福总值(GNH)。了解一下国民幸福总值是如何衡量的(试试grossnationalhappiness.com),然后为“幸福”这一质量属性勾勒一个通用场景,以便你能够表达软件系统具体的幸福需求。
2. 选择一个在 [第 4 章][ch04] 至 [第 13 章][ch13] 中未描述的质量属性。对于该质量属性,整理一组具体的场景来描述其含义。使用该场景集为其构建一个通用场景。
3. 对于你在问题 2 中选择的质量属性,整理一组有助于实现它的设计机制(模式和策略)。
4. 针对“开发成本”这一质量属性重复问题 2 和问题 3,然后针对“运营成本”这一质量属性也重复问题 2 和问题 3。
5. 什么可能会导致你在 [第 4 章][ch04] 至 [第 13 章][ch13](或者对于任何其他质量属性而言)已经描述的质量属性集合中添加一个策略或模式呢?
6. 讨论你认为开发可分发性如何与性能、可用性、可修改性和可集成性等质量属性进行权衡。
7. 研究一些非软件系统的质量属性列表:例如,一辆好车的品质,或者一个适合交往的好人的品质。在你找到的列表中添加你自己选择的品质。
8. 开发时的策略与分离和封装职责有关。性能策略与将事物整合在一起有关。这就是它们一直存在冲突的原因。情况一定总是如此吗?是否有一种有原则的方法来量化这种权衡呢?
9. 是否存在策略分类法?化学家有元素周期表和分子相互作用定律,原子物理学家有亚原子粒子目录以及它们碰撞时会发生什么的定律,药理学家有化学物质目录以及它们与受体和代谢系统相互作用的定律等等。策略的对应物是什么呢?并且对于它们的相互作用是否存在定律呢?
10. 安全性是一种质量属性,它对计算机外部物理世界中发生的过程特别敏感:应用补丁的过程、选择和保护密码的过程、物理保护计算机和数据所在设施的过程、决定是否信任一款导入软件的过程、决定是否信任一名人类开发者或用户的过程等等。对于性能来说,相应的重要过程是什么呢?或者对于可用性呢?存在这样的过程吗?为什么安全性对过程如此敏感呢?过程应该是质量属性结构的一部分还是与它正交呢?
11. 以下列表中每对质量属性之间的关系是什么?
- 性能和安全性
- 安全性和可构建性
- 能效性和上市时间
[ch14fig01]: ch14.md#ch14fig01 "Figure 14.1" [ch14fig02]: ch14.md#ch14fig02 "Figure 14.2"
[part02]: part02.md "Part II" [ch03]: ch03.md "Chapter 3" [ch04]: ch04.md "Chapter 4" [ch06]: ch06.md "Chapter 6" [ch08]: ch08.md "Chapter 8" [ch13]: ch13.md "Chapter 13" [ch22]: ch22.md "Chapter 22"
第三部分 架构解决方案
第15章 软件接口
With Cesare Pautasso
美国国家航空航天局(NASA)损失了其价值 1.25 亿美元的火星气候轨道器,原因是航天器工程师在该航天器发射前交换重要数据时未能从英制单位转换为公制单位……
美国国家航空航天局(NASA)的一个导航团队在计算中使用了以毫米和米为单位的公制,而设计和建造航天器的[公司]提供的关键加速度数据则是以英寸、英尺和磅为单位的英制……
从某种意义上说,这艘航天器在单位换算中迷失了。
——罗伯特·李·霍茨(Robert Lee Hotz),《火星探测器因简单数学错误丢失》,《洛杉矶时报》,1999年10月1日
这一章描述了有关接口的概念,并讨论了如何设计和记录它们。
接口(无论是软件接口还是其他类型的接口)是一个边界,元素在此相遇、交互、通信和协调。元素具有控制对其内部访问的接口。元素也可以细分,每个子元素都有自己的接口。
一个元素的参与者是与其相互作用的其他元素、用户或系统。一个元素与之相互作用的参与者的集合被称为该元素的环境。所谓“相互作用”,我们指的是一个元素所做的任何可能影响另一个元素处理的事情。这种相互作用是该元素接口的一部分。相互作用可以有多种形式,不过大多数涉及控制和/或数据的传输。有些是由标准编程语言结构支持的,例如本地或远程过程调用(RPC)、数据流、共享内存和消息传递。
这些提供了与元素直接交互点的结构被称为资源。其他交互是间接的。例如,在元素 A 上使用资源 X 会使元素 B 处于特定状态这一事实,如果这会影响其他元素的处理,那么使用该资源的其他元素可能需要知道,即使它们从未与元素 A 直接交互。关于 A 的这一事实是 A 与其环境中其他元素之间接口的一部分。在本章中,我们仅关注直接交互。
回想一下,在 [第 1 章][ch01] 中,我们从元素及其关系的角度定义了架构。在本章中,我们关注一种关系类型。接口是将元素连接在一起所必需的基本抽象机制。它们对系统的可修改性、可用性、可测试性、性能、可集成性等方面有着巨大的影响。此外,作为分布式系统中常见部分的异步接口,需要事件处理程序——这是一种架构元素。
对于给定元素的接口,可以有一个或多个实现,每个实现可能具有不同的性能、可扩展性或可用性保证。同样,对于同一接口的不同实现可能针对不同的平台构建。
到目前为止的讨论暗含了以下三点:
- 所有元素都有接口。 所有元素都与某些参与者进行交互;否则,该元素存在的意义何在?
- 接口是双向的。 在考虑接口时,大多数软件工程师首先想到的是元素所提供内容的概述。该元素提供了哪些方法?它处理哪些事件?但是,一个元素还通过利用其外部资源或以其环境以某种方式表现为前提与其环境进行交互。如果这些资源缺失或环境未如预期表现,该元素就无法正确运行。因此,接口不仅仅是元素提供的内容;接口还包括元素需要的内容。
- 一个元素可以通过同一个接口与多个参与者进行交互。 例如,网络服务器通常会限制可以同时打开的 HTTP 连接数量。
15.1接口的概念
在本节中,我们将讨论多重接口、资源、操作、属性和事件的概念,以及接口的演变。
多个接口
将单个接口拆分为多个接口是可能的。每个接口都有相关的逻辑目的,并为不同类别的参与者服务。多个接口提供了一种关注点分离。特定类别的参与者可能只需要可用功能的子集;此功能可以由其中一个接口提供。相反,元素的提供者可能希望授予参与者不同的访问权限,例如读或写,或者实施安全策略。多个接口支持不同级别的访问。例如,一个元素可能通过其主接口公开其功能,并通过单独的接口提供对调试或性能监控数据或管理功能的访问。可能存在针对匿名参与者的公共只读接口和允许经过身份验证和授权的参与者修改元素状态的私有接口。
资源
资源具有语法和语义:
- 资源语法。语法是资源的签名,其中包括其他程序在编写语法正确的使用该资源的程序时所需的任何信息。签名包括资源的名称、参数(如果有)的名称和数据类型等等。
-
资源语义。调用此资源的结果是什么?语义有多种形式,包括以下内容:
- 为调用该资源的参与者能够访问的数据进行值的赋值。这种值的赋值可能简单到设置一个返回参数的值,也可能影响深远,比如更新一个中央数据库。
- 关于跨越接口的值的假设。
- 使用资源所导致的元素状态的变化。这包括异常情况,例如部分完成操作的副作用。
- 使用资源所导致的将被发出信号的事件或将要发送的消息。
- 由于使用此资源,未来其他资源的行为将如何不同。例如,如果您要求资源销毁一个对象,未来通过其他资源尝试访问该对象可能会因此产生错误。
- 人类可观察到的结果。这些在嵌入式系统中很常见。例如,调用一个打开驾驶舱显示屏的程序会产生非常明显的效果——显示屏亮起。此外,语义的陈述应明确资源的执行是原子性的还是可能被暂停或中断。
操作、事件和属性
所提供接口的资源由操作、事件和属性组成。这些资源还通过对访问每个接口资源时所引起的行为或所交换的数据在语法、结构和语义方面的明确描述来加以补充。(如果没有这种描述,程序员或参与者怎么知道是否或如何使用这些资源呢?)
操作 被调用以将控制和数据传输到元素进行处理。大多数操作也会返回一个结果。操作可能会失败,作为接口的一部分,应该明确参与者如何检测错误,无论是作为输出的一部分发出信号还是通过一些专用的异常处理通道。
此外,事件——通常是异步的——可能在接口中进行描述。传入的事件可以表示从队列中接收的消息,或者要处理的流元素的到达。主动元素——那些不是被动等待被其他元素调用的元素——会产生传出事件,用于向监听者(或订阅者)通知元素内部发生的有趣事情。
除了通过操作和事件传输的数据之外,接口的一个重要方面是元数据,例如访问权限、度量单位或格式假设。这种接口元数据的另一个名称是属性。正如本章开头的引述所强调的,属性值可以影响操作的行为。属性值也会根据元素的状态影响元素的行为。
具有状态且活跃的元素的复杂接口将具有操作、事件和属性的组合特征。
接口演进
所有软件都会演进,包括接口。只要接口本身不变,被接口封装的软件可以自由演进,而不会对使用该接口的元素产生影响。然而,接口是元素与其参与者之间的契约。正如法律合同只能在某些限制内更改一样,软件接口的更改应当谨慎。有三种技术可用于更改接口:弃用、版本控制和扩展。
- 弃用。弃用意味着移除一个接口。弃用接口的最佳实践是向元素的参与者发出广泛通知。从理论上讲,此警告会给参与者留出时间来适应接口的移除。但实际上,许多参与者不会提前进行调整,而是在接口被移除时才发现已弃用。弃用接口的一种技术是引入一个错误代码,表示此接口将在(特定日期)被弃用或者此接口已被弃用。
- 版本控制。多个接口通过保留旧接口并添加新接口来支持演进。当不再需要旧接口或已决定不再支持它时,可以弃用旧接口。这就要求参与者指定其正在使用的接口版本。
- 扩展。扩展接口意味着保持原始接口不变,并向接口添加体现所需更改的新资源。[图 15.1(a)][ch15fig01] 展示了原始接口。如果扩展与原始接口不存在任何不兼容,那么元素可以直接实现外部接口,如 [图 15.1(b)][ch15fig01] 所示。相反,如果扩展引入了一些不兼容,那么元素就需要有一个内部接口,并添加一个中介来在外部接口和内部接口之间进行转换,如 [图 15.1(c)][ch15fig01] 所示。作为不兼容的一个示例,假设原始接口假定公寓号码包含在地址中,但扩展接口将公寓号码作为一个单独的参数分离出来。内部接口会将公寓号码作为一个单独的参数。然后,如果从中介从原始接口被调用,它会解析地址以确定任何公寓号码,而中介会将包含在单独参数中的公寓号码原封不动地传递给内部接口。

图 15.1 (a) 原始接口。(b) 扩展接口。(c) 使用中介。
15.2 设计接口
关于哪些资源应该对外可见的决策应该由使用这些资源的参与者的需求驱动。向接口添加资源意味着承诺只要元素仍在使用,就将这些资源作为接口的一部分进行维护。一旦参与者开始依赖您提供的资源,如果资源被更改或删除,他们的元素将会出现故障。当元素之间的接口契约被打破时,您的架构的可靠性就会受到影响。
这里重点介绍了一些接口的额外设计原则:
- 最小意外原则。接口的行为应当与参与者的预期保持一致。名称在这里起作用:一个恰当命名的资源能给参与者一个关于该资源用途的良好提示。
- 小接口原则。如果两个元素需要交互,让它们交换尽可能少的信息。
- 统一访问原则。避免通过接口泄露实现细节。对于参与者来说,无论资源如何实现,都应以相同的方式访问资源。例如,参与者不应知道一个值是从缓存中返回、通过计算得出,还是从某些外部源新获取的。
- 不要重复自己原则。接口应当提供一组可组合的原语,而不是许多实现相同目标的冗余方式。
一致性是设计清晰接口的一个重要方面。作为架构师,您应该建立并遵循关于资源如何命名、API 参数如何排序以及如何处理错误的约定。当然,并非所有接口都在架构师的控制之下,但在可能的范围内,同一架构的所有元素的接口设计应当保持一致。如果接口遵循底层平台的约定或开发者所期望的编程语言习惯,他们也会很欣赏。然而,一致性不仅仅是赢得开发者的好感,还将有助于最大程度地减少因误解而导致的开发错误数量。
与接口的成功交互需要在以下方面达成一致:
- 接口范围
- 交互方式
- 交换数据的表示形式和结构
- 错误处理
这些中的每一项都构成了设计接口的一个重要方面。我们将依次进行介绍。
接口的范围
接口的范围定义了直接可供参与者使用的资源集合。作为接口设计者,你可能希望公开所有资源;或者,你可能希望限制对某些资源或某些参与者的访问。例如,出于安全、性能管理和可扩展性等原因,你可能希望限制访问。
限制和调解对一个元素或一组元素的资源的访问的一种常见模式是建立一个网关元素。网关(通常称为消息网关)将参与者的请求转换为对目标元素(或多个元素)的资源的请求,因此成为目标元素的参与者。[图 15.2][ch15fig02] 提供了一个网关的示例。网关之所以有用,原因如下:
- 一个元素所提供的资源粒度可能与参与者的需求不同。网关可以在元素和参与者之间进行转换。
- 参与者可能需要访问资源的特定子集,或者被限制访问特定子集。
- 资源的具体情况——它们的数量、协议、类型、位置和属性——可能会随时间变化,而网关可以提供一个更稳定的接口。

现在我们来谈谈设计特定接口的细节。这意味着要决定它应该具有哪些操作、事件和属性。此外,您必须选择合适的数据表示格式和数据语义,以确保您的架构元素相互兼容和互操作。我们开头的引述给出了这些决策重要性的一个例子。
交互方式
接口旨在相互连接,以便不同的元素能够进行通信(传输数据)和协调(传输控制)。这种交互的发生方式有很多种,这取决于通信和协调之间的混合方式,以及元素是共处一地还是远程部署。例如:
- 共处一地的元素的接口可以通过本地共享内存缓冲区提供对大量数据的高效访问。
- 预期同时可用的元素可以使用同步调用以调用它们所需的操作。
- 部署在不可靠的分布式环境中的元素将需要依赖基于消费和产生事件的异步交互,通过消息队列或数据流进行交换。
存在许多不同的交互风格,但我们将重点关注两种最广泛使用的:RPC(远程过程调用)和 REST(表述性状态转移)。
-
远程过程调用(RPC)。RPC 是以命令式语言中的过程调用为模型的,不同之处在于被调用的过程位于网络中的其他位置。程序员编写过程调用时,就好像调用的是本地过程(带有一些语法上的变化);然后该调用被转换为发送到实际调用过程所在的远程元素的消息。最后,结果作为消息发送回调用元素。
RPC 始于 20 世纪 80 年代,自诞生以来经历了多次修改。该协议的早期版本是同步的,消息的参数以文本形式发送。最新的 RPC 版本称为 gRPC,以二进制形式传输参数,是异步的,并支持认证、双向流和流量控制、阻塞或非阻塞绑定以及取消和超时。gRPC 使用 HTTP 2.0 进行传输。
-
表述性状态转移(REST)。REST 是一种网络服务协议。它源自万维网推出时使用的原始协议。REST 包含对元素之间交互施加的一组六项约束:
- 统一接口。所有交互都使用相同的形式(通常是 HTTP)。接口提供方的资源通过 URI(统一资源标识符)进行指定。命名约定应保持一致,并且通常应遵循最小意外原则。
- 客户端 - 服务器。参与者是客户端,资源提供者是使用客户端 - 服务器模式的服务器。
- 无状态。所有客户端 - 服务器交互都是无状态的。也就是说,客户端不应假定服务器保留了关于客户端上一个请求的任何信息。因此,诸如授权之类的交互被编码到令牌中,并且令牌随每个请求传递。
- 可缓存。在适用时对资源应用缓存。缓存可以在服务器端或客户端实现。
- 分层系统架构。“服务器”可以分解为多个独立的元素,这些元素可以独立部署。例如,业务逻辑和数据库可以独立部署。
- 按需代码(可选)。服务器有可能向客户端提供要执行的代码。JavaScript 就是一个例子。
尽管并非是可与 REST 一起使用的唯一协议,但 HTTP 是最常见的选择。由万维网联盟(W3C)标准化的 HTTP 具有<命令>
表 15.1 HTTP 中最重要的命令及其与 CRUD 数据库操作的关系
| “HTTP 命令” | CRUD 操作等效 |
|---|---|
| post |
create |
| get |
read |
| put |
update/replace |
| patch |
update/modify |
| delete |
delete |
交换数据的表示形式和结构
每个接口都提供了将内部数据表示(通常使用编程语言的数据类型(例如对象、数组、集合)构建)抽象为不同数据表示的机会,即更适合在不同编程语言实现之间交换和通过网络发送的数据表示。从内部表示转换为外部表示被称为“序列化”、“编组”或“转换”。
在接下来的讨论中,我们重点关注为通过网络发送信息选择一种通用的数据交换格式或表示形式。这一决策基于以下考虑因素:
- 表达能力。该表示形式能否序列化任意数据结构?它是否针对对象树进行了优化?它是否需要携带用不同语言编写的文本?
- 互操作性。接口所使用的表示形式是否与参与者的期望相匹配,并且他们是否知道如何解析?标准表示形式(例如本节后面描述的 JSON)将使参与者能够轻松地将通过网络传输的位转换为内部数据结构。该接口是否实现了标准?
- 性能。所选的表示形式是否允许有效利用可用的通信带宽?将表示形式解析为内部元素表示形式的算法复杂度是多少?在发送消息之前准备消息需要花费多少时间?所需带宽的货币成本是多少?
- 隐式耦合。参与者和元素之间共享的哪些假设可能导致在解码消息时出现错误和数据丢失?
- 透明度。是否可以拦截交换的消息并轻松观察其内容?这是一把双刃剑。一方面,如果自描述消息有助于开发人员更轻松地调试消息有效负载,并且使窃听者更容易拦截和解释其内容。另一方面,二进制表示形式,特别是加密的表示形式,需要特殊的调试工具,但更安全。
最常见的独立于编程语言的数据表示风格可以分为文本(例如 XML 或 JSON)和二进制(例如protocol buffers)选项。
可扩展标记语言(XML)
XML 于 1998 年由万维网联盟(W3C)标准化。对文本文档的 XML 注释,称为“标签”,用于通过将信息分解为块或字段并标识每个字段的数据类型来指定如何解释文档中的信息。标签可以用属性进行注释。
XML 是一种元语言:开箱即用,它除了允许您定义一种自定义语言来描述您的数据外,什么都不做。您的自定义语言由一个 XML 模式 定义,其本身就是一个 XML 文档,它指定了您将使用的标签、用于解释每个标签所包含字段的数据类型,以及适用于您文档结构的约束。XML 模式使您作为架构师能够指定丰富的信息结构。
XML 文档出于多种目的被用作结构化数据的表示形式:用于分布式系统中交换的消息(SOAP)、网页的内容(XHTML)、矢量图像(SVG)、商业文档(DOCX)、Web 服务接口描述(WSDL)以及静态配置文件(例如,MacOS 属性列表)。
XML 的一个优势在于,使用这种语言注释的文档可以进行检查,以验证其是否符合模式。这可以防止因文档格式错误而导致的故障,并消除了读取和处理文档的代码进行某些类型错误检查的需要。但权衡之下,解析文档并进行验证在处理和内存方面的成本相对较高。在能够进行验证之前,必须完整读取文档,并且可能需要多次读取才能解组。这种要求,再加上 XML 的冗长,可能会导致不可接受的运行时性能和带宽消耗。虽然在 XML 的鼎盛时期,经常有人提出“XML 具有人类可读性”的论点,但如今这种好处被提及的频率要低得多。
JavaScript 对象表示法(JSON)
JSON 将数据结构化为嵌套的名称/值对和数组数据类型。JSON 符号源于 JavaScript 语言,并于 2013 年首次标准化;然而,如今它独立于任何编程语言。与 XML 一样,JSON 是一种具有自己的模式语言的文本表示形式。然而,与 XML 相比,JSON 要简洁得多,因为字段名称只出现一次。使用名称/值表示而不是开始和结束标签,JSON 文档可以在读取时进行解析。
JSON 数据类型源自 JavaScript 数据类型,与任何现代编程语言的数据类型相似。这使得 JSON 的序列化和反序列化比 XML 高效得多。该符号的最初用例是在浏览器和 Web 服务器之间发送 JavaScript 对象——例如,传输轻量级数据表示以便在浏览器中呈现为 HTML,而不是在服务器端执行呈现并必须下载使用 HTML 表示的更冗长的视图。
Protocol Buffers
协议缓冲区(Protocol Buffer)技术起源于谷歌,在内部使用了几年后,于 2008 年作为开源技术发布。与 JSON 一样,协议缓冲区使用与编程语言数据类型相近的数据类型,使得序列化和反序列化非常高效。与 XML 一样,协议缓冲区消息具有定义有效结构的模式,并且该模式可以指定必需元素、可选元素和嵌套元素。然而,与 XML 和 JSON 都不同的是,协议缓冲区是一种二进制格式,因此它们非常紧凑,能非常有效地利用内存和网络带宽资源。在这方面,协议缓冲区让人回想起更早的一种名为抽象语法标记一(Abstract Syntax Notation One,ASN.1)的二进制表示形式,它起源于 20 世纪 80 年代初,当时网络带宽是一种宝贵的资源,不能浪费任何一位。
协议缓冲区开源项目提供了代码生成器,以便在许多编程语言中轻松使用协议缓冲区。您在一个 proto 文件中指定消息模式,然后由特定语言的协议缓冲区编译器进行编译。编译器生成的程序将被一个参与者用于序列化,并被一个元素用于反序列化数据。
就像使用 XML 和 JSON 时一样,交互的元素可能是用不同的语言编写的。然后每个元素都使用其特定语言的协议缓冲区编译器。尽管协议缓冲区可用于任何数据结构化目的,但它们主要被用作 gRPC 协议的一部分。
协议缓冲区是使用接口描述语言来指定的。由于它们是由特定语言的编译器进行编译的,因此该规范对于确保接口的正确行为是必要的。它还充当接口的文档。将接口规范放在数据库中,可以对其进行搜索,以查看值如何在各个元素中传播。
错误处理
在设计接口时,架构师自然会专注于在一切按计划进行的正常情况下接口应如何使用。然而,现实世界远非正常情况,一个设计良好的系统必须知道如何在面对意外情况时采取适当的行动。当使用无效参数调用操作时会发生什么?当资源所需的内存超过可用内存时会发生什么?当对某个操作的调用永远不会返回,因为它失败了,会发生什么?当接口应该根据传感器的值触发通知事件,但传感器没有响应或响应的是乱码时,会发生什么?
参与者需要知道元素是否正常工作、他们的交互是否成功以及是否发生了错误。为此采取的策略包括以下内容:
- 失败的操作可能会抛出异常。
- 操作可能会返回带有预定义代码的状态指示符,需要对其进行测试以检测错误结果。
- 属性可用于存储数据,以指示最新操作是否成功,或者有状态的元素是否处于错误状态。
- 对于失败的异步交互,可能会触发诸如超时之类的错误事件。
- 可以通过连接到特定的输出数据流来读取错误日志。
用于描述错误结果的是哪些异常、哪些状态代码、哪些事件以及哪些信息的规范,成为了一个元素接口的一部分。常见的错误来源(接口应妥善处理)包括以下内容:
- 不正确、无效或非法的信息被发送到接口——例如,使用不应为空的空值参数调用操作。将错误情况与资源相关联是谨慎的做法。
- 元素处于处理请求的错误状态。元素可能由于之前的操作或同一或另一个参与者之前缺少操作而进入不正确的状态。后者的示例包括在元素初始化完成之前调用操作或读取属性,以及向已被系统操作人员离线的存储设备写入。
- 发生了硬件或软件错误,导致元素无法成功执行。处理器故障、网络无响应以及无法分配更多内存是这类错误情况的示例。
- 元素配置不正确。例如,其数据库连接字符串指向错误的数据库服务器。
指出错误的来源有助于系统选择适当的纠正和恢复策略。具有幂等操作的临时错误可以通过等待并重试来处理。由于无效输入导致的错误需要修复错误请求并重新发送。缺失的依赖项应在重新尝试使用接口之前重新安装。实现中的错误应该通过添加使用失败场景作为额外的测试用例来修复,以避免回归。
15.3 接口文档化
虽然接口包括元素与其环境进行交互的所有方面,但我们选择公开的关于接口的内容——即我们在接口文档中放入的内容——则较为有限。记录每个可能交互的每个方面既不实际,也几乎从来都不是理想的。相反,您应该只公开接口上的参与者与接口进行交互所需知道的内容。换句话说,您选择哪些信息是允许的,以及哪些信息对于人们对元素的假设是合适的。
接口文档指明了其他开发人员在将接口与其他元素结合使用时需要了解的有关接口的信息。随后,开发人员可能会观察到一些属性,这些属性是元素实现方式的一种体现,但接口文档中并未详细说明。由于这些不属于接口文档的一部分,它们可能会发生变化,开发人员使用它们需自担风险。
还要认识到不同的人需要了解关于接口的不同种类的信息。您可能需要在接口文档中包含不同的部分,以适应接口的不同利益相关者。在记录元素的接口时,请牢记以下利益相关者角色:
-
元素的开发人员。需要了解其接口必须履行的契约。开发人员只能测试接口描述中包含的信息。
-
维护人员。一种特殊类型的开发人员,对元素及其接口进行指定的更改,同时尽量减少对现有参与者的干扰。
-
使用该接口的元素的开发人员。需要理解接口的契约以及如何使用它。此类开发人员可以根据接口应支持的用例为接口设计和文档编制过程提供输入。
-
系统集成商和测试人员。将系统从其组成元素组合在一起,并对最终组合的行为有浓厚的兴趣。此角色需要有关元素提供和需要的所有资源和功能的详细信息。
-
分析师。此角色取决于所进行的分析类型。例如,对于性能分析师,接口文档应包括服务水平协议(SLA)保证,以便参与者能够适当地调整其请求。
-
在新系统中寻找可重用资产的架构师。通常从检查前一个系统的元素接口开始。架构师也可能在商业市场中寻找可以购买并完成工作的现成元素。要确定一个元素是否是候选者,架构师对接口资源的功能、其质量属性以及元素提供的任何可变性感兴趣。
描述一个元素的接口意味着对该元素做出其他元素可以依赖的陈述。记录接口意味着您必须描述哪些服务和属性是契约的一部分——这一步骤代表着向参与者承诺该元素确实将履行此契约。任何不违反契约的元素实现都是有效的实现。
必须区分元素的接口和该接口的文档。您对元素所能观察到的部分属于其接口——例如,一项操作所花费的时间。接口的文档涵盖了该行为的一个子集:它规定了我们希望我们的参与者能够依赖的内容。
“海勒姆定律”(www.hyrumslaw.com)指出:“对于一个接口的足够数量的用户来说,您在契约中承诺什么并不重要:您系统的所有可观察行为都会被某些人所依赖。”确实如此。但是,正如我们之前所说,依赖于您未发布的元素接口相关内容的参与者这样做要自担风险。
15.4 小结
架构元素具有接口,这些接口是元素相互交互的边界。接口设计是一项架构职责,因为兼容的接口使具有许多元素的架构能够共同完成有成效和有用的事情。接口的一个主要用途是封装元素的实现,以便此实现可以更改而不影响其他元素。
元素可能有多个接口,为不同类别的参与者提供不同类型的访问和权限。接口说明了元素向其参与者提供哪些资源,以及元素为了正确运行需要从其环境中获取什么。与架构本身一样,接口应该尽可能简单,但不能过于简略。
接口具有操作、事件和属性;这些是架构师可以设计的接口的组成部分。要做到这一点,架构师必须确定元素的
- 接口的范围
- 交互风格
- 交换数据的表示形式和结构
- 错误处理
其中一些问题可以通过标准化手段来解决。例如,数据交换可以使用诸如 XML、JSON 或 Protocol Buffers 之类的机制。
所有软件都会演进,包括接口。可用于更改接口的三种技术是弃用、版本控制和扩展。
接口文档说明了其他开发人员需要了解关于某个接口的哪些内容,以便将其与其他元素结合使用。记录接口涉及决定向元素的参与者公开哪些元素操作、事件和属性,并详细说明接口的语法和语义。
15.5 扩展阅读
要查看邮政地址的 XML 表示形式、JSON 表示形式和 Protocol Buffer 表示形式之间的区别,请访问 https://schema.org/PostalAddress 、https://schema.org/PostalAddress 和 https://github.com/mgravell/protobuf-net/blob/master/src/protogen.site/wwwroot/protoc/google/type/postal_address.proto 。
可以在 https://grpc.io/ 上了解更多关于 gRPC 的信息。
REST 由 Roy Fielding 在他的博士论文中定义:ics.uci.edu/~fielding/pubs/dissertation/top.htm 。
15.6 问题讨论
1. 描述一只狗或您熟悉的其他动物的接口。描述它的操作、事件和属性。狗是否有多个接口(例如,一个针对熟悉的人类,另一个针对陌生人)?
2. 记录灯泡的接口。记录其操作、事件和属性。记录其性能和资源利用率。记录它可能进入的任何错误状态以及结果。您能想到具有您刚刚描述的相同接口的多种实现方式吗?
3. 在什么情况下性能(例如,一项操作所需的时间)应该成为元素已发布接口的一部分?在什么情况下不应该?
4. 假设一个架构元素将用于高可用性系统。这会如何影响其接口文档?假设同一个元素现在将用于高安全性系统。您可能会记录哪些不同的内容?
5. “[错误处理][ch15sec0208]”部分列出了许多不同的错误处理策略。对于每一种策略,何时使用是合适的?何时是不合适的?每种策略会增强或削弱哪些质量属性?
6. 如本章开头所述,对于导致火星气候轨道器损失的接口错误,您会采取什么措施来预防?
7. 1996 年 6 月 4 日,一枚阿丽亚娜 5 型火箭在发射仅 37 秒后就极其壮观地失败了。研究这次失败,并讨论更好的接口规范原本可以如何防止它。
8. 数据库模式代表了元素与数据库之间的接口;它提供了访问数据库的元数据。鉴于这种观点,模式演变是接口演变的一种形式。讨论模式可以演变而不破坏现有接口的方式,以及会破坏现有接口的方式。描述弃用、版本控制和扩展如何应用于模式演变。
第16章 虚拟化
“虚拟”意味着永远不知道你的下一个字节从哪里来。
—— 佚名
在 20 世纪 60 年代,计算领域面临着这样一个难题:如何在一台物理机器上让多个独立的应用程序共享诸如内存、磁盘、I/O 通道和用户输入设备等资源。由于无法共享资源,这意味着在同一时间只能运行一个应用程序。在那个时候,计算机的成本高达数百万美元 (在当时那可是实实在在的一大笔钱)而大多数应用程序通常只使用可用资源的一小部分,大约 10% 左右,所以这种情况对计算成本产生了重大影响。
虚拟机以及后来的容器的出现就是为了解决共享问题。这些虚拟机和容器的目标是将一个应用程序与另一个应用程序隔离开来,同时仍然能够共享资源。隔离使得开发人员能够编写应用程序,就好像他们是唯一使用计算机的人一样,而共享资源则允许多个应用程序同时在计算机上运行。由于应用程序在共享一台具有固定资源集的物理计算机,所以隔离所创造的假象是有限度的。例如,如果一个应用程序消耗了所有的 CPU 资源,那么其他应用程序就无法执行。然而,在大多数情况下,这些机制已经改变了系统和软件架构的面貌。它们从根本上改变了我们构思、部署和支付计算资源的方式。
为什么这个主题会引起架构师的兴趣和关注呢?作为一名架构师,你可能倾向于(或者实际上被要求)使用某种形式的虚拟化来部署你所创建的软件。对于越来越多的应用程序,你将把它们部署到云端(将在 [第 17 章][ch17] 中介绍)并使用容器来实现。此外,在需要部署到专用硬件的情况下,虚拟化允许你在一个比专用硬件更容易访问的环境中进行测试。
本章的目的是介绍在使用虚拟资源时一些最重要的术语、考虑因素和权衡。
16.1 共享资源
出于经济原因,许多组织采用了某种形式的共享资源。这可以极大地降低部署系统的成本。我们通常关心共享的有四种资源:
- 中央处理器(CPU):现代计算机有多个 CPU(并且每个 CPU 可以有多个处理核心)。它们还可能有一个或多个图形处理单元(GPU)或其他专用处理器,例如张量处理单元(TPU)。
- 内存:一台物理计算机有固定数量的物理内存。
- 磁盘存储:磁盘为指令和数据提供持久存储,在计算机重新启动和关闭期间都能保留。一台物理计算机通常有一个或多个连接的磁盘,每个磁盘都有固定的存储容量。磁盘存储可以指旋转的磁性或光学硬盘驱动器设备,也可以指固态磁盘驱动器设备;后者既没有磁盘也没有任何驱动的移动部件。
- 网络连接:如今,每台重要的物理计算机都有一个或多个网络连接,所有消息都通过这些连接传输。
现在我们已经列举了我们想要共享的资源,我们需要考虑如何共享它们,以及如何以足够 “隔离” 的方式进行共享,以便不同的应用程序不知道彼此的存在。
处理器共享是通过线程调度机制实现的。调度程序选择一个执行线程并将其分配给一个可用的处理器,该线程保持控制直到处理器被重新调度。没有应用程序线程可以在不经过调度程序的情况下获得对处理器的控制。当线程让出处理器的控制、固定时间间隔到期或发生中断时,就会进行重新调度。
从历史上看,随着应用程序的增长,所有的代码和数据无法装入物理内存。为了应对这一挑战,开发了虚拟内存技术。内存管理硬件将一个进程的地址空间划分为页面,并根据需要在物理内存和辅助存储之间交换页面。在物理内存中的页面可以立即被访问,其他页面存储在辅助存储器中,直到需要时为止。硬件支持将一个地址空间与另一个地址空间隔离。
磁盘共享和隔离是通过几种机制实现的。首先,只能通过磁盘控制器访问物理磁盘,磁盘控制器确保流向和来自每个线程的数据流按顺序传送。此外,操作系统可以用诸如用户 ID 和组等信息标记正在执行的线程以及磁盘内容(如文件和目录),并通过比较请求访问的线程的标记和磁盘内容来限制可见性或访问。
网络隔离是通过消息的识别来实现的。每个虚拟机(VM)或容器都有一个互联网协议(IP)地址,用于识别发送到该 VM 或容器或从该 VM 或容器发出的消息。本质上,IP 地址用于将响应路由到正确的 VM 或容器。另一种用于发送和接收消息的网络机制依赖于端口的使用。每个针对服务的消息都有一个与之相关联的端口号。一个服务在一个端口上监听,并接收到达服务正在执行的设备上的、指定给该服务正在监听的端口的消息。
16.2 虚拟机
现在我们已经了解了如何将一个应用程序的资源使用与另一个应用程序的资源使用隔离开来,我们可以运用并结合这些机制。“虚拟机” 允许在一台物理计算机中执行多个模拟的或虚拟的计算机。
[图 16.1][ch16fig01] 描绘了几个驻留在一台物理计算机中的虚拟机。物理计算机被称为 “主机”,虚拟机被称为 “客户机”。[图 16.1][ch16fig01] 还展示了一个 “虚拟机管理程序”,它是虚拟机的操作系统。这个虚拟机管理程序直接在物理计算机硬件上运行,通常被称为 “裸机” 或 “类型 1” 虚拟机管理程序。它所托管的虚拟机实现应用程序和服务。裸机虚拟机管理程序通常在数据中心或云中运行。

[图 16.2][ch16fig02] 描绘了另一种类型的虚拟机管理程序,称为 “托管” 或 “类型 2” 虚拟机管理程序。在这种情况下,虚拟机管理程序作为一种服务在主机操作系统之上运行,而虚拟机管理程序又托管一个或多个虚拟机。托管虚拟机管理程序通常在台式机或笔记本电脑上使用。它们允许开发人员运行和测试与计算机主机操作系统不兼容的应用程序(例如,在 Windows 计算机上运行 Linux 应用程序,或在苹果计算机上运行 Windows 应用程序)。它们还可以用于在开发计算机上复制生产环境,即使操作系统在两者上是相同的。这种方法确保开发环境和生产环境相互匹配。

虚拟机管理程序要求其客户虚拟机使用与底层物理 CPU 相同的指令集 —— 虚拟机管理程序不翻译或模拟指令执行。例如,如果你有一个用于使用 ARM 处理器的移动或嵌入式设备的虚拟机,你不能在使用 x86 处理器的虚拟机管理程序上运行那个虚拟机。另一种与虚拟机管理程序相关的技术支持跨处理器执行;它被称为 “模拟器”。模拟器读取目标或客户处理器的二进制代码,并在主机处理器上模拟客户指令的执行。模拟器通常还模拟客户 I/O 硬件设备。例如,开源的 QEMU 模拟器 ^1 可以模拟一个完整的 PC 系统,包括 BIOS、x86 处理器和内存、声卡、显卡,甚至软盘驱动器。
托管 / 类型 2 虚拟机管理程序和模拟器允许用户通过主机的屏幕显示、键盘和鼠标 / 触摸板与在虚拟机内部运行的应用程序进行交互。开发桌面应用程序或在专用设备上工作的开发人员,如移动平台或物联网设备,可以使用托管 / 类型 2 虚拟机管理程序和 / 或模拟器作为他们构建 / 测试 / 集成工具链的一部分。
虚拟机管理程序执行两个主要功能:(1)管理在每个虚拟机中运行的代码,(2)管理虚拟机本身。详细说明如下:
-
通过访问虚拟磁盘或网络接口与虚拟机外部进行通信的代码被虚拟机管理程序拦截,并由虚拟机管理程序代表虚拟机执行。这允许虚拟机管理程序标记这些外部请求,以便对这些请求的响应可以路由到正确的虚拟机。
对 I/O 设备或网络的外部请求的响应是一个异步中断。这个中断最初由虚拟机管理程序处理。由于多个虚拟机在一台单一的物理主机上运行,并且每个虚拟机可能都有未完成的 I/O 请求,虚拟机管理程序必须有一种方法将中断转发到正确的虚拟机。这就是前面提到的标记的目的。
-
虚拟机必须被管理。例如,它们必须被创建和销毁,以及其他操作。管理虚拟机是虚拟机管理程序的功能。虚拟机管理程序不会自行决定创建或销毁一个虚拟机,而是根据用户的指令,或者更常见的是根据云基础设施的指令(你将在 [第 17 章][ch17] 中了解更多关于这方面的内容)来行动。创建虚拟机的过程涉及加载一个 “虚拟机镜像”(在下一节中讨论)。
除了创建和销毁虚拟机之外,虚拟机管理程序还对它们进行监控。健康检查和资源使用情况是监控的一部分。虚拟机管理程序也位于虚拟机的防御安全边界内,作为对攻击的防御。
最后,虚拟机管理程序负责确保虚拟机不超过其资源使用限制。每个虚拟机在 CPU 利用率、内存、磁盘和网络 I/O 带宽方面都有限制。在启动虚拟机之前,虚拟机管理程序首先确保有足够的物理资源可满足该虚拟机的需求,然后在虚拟机运行时执行这些限制。
虚拟机的启动方式与裸机物理机的启动方式相同。当机器开始执行时,它会自动从磁盘存储(无论是计算机内部的还是通过网络连接的)读取一个特殊的程序,称为引导加载程序。引导加载程序将操作系统代码从磁盘读入内存,然后将执行转移到操作系统。对于物理计算机,在加电过程中建立与磁盘驱动器的连接。对于虚拟机,在虚拟机管理程序启动虚拟机时建立与磁盘驱动器的连接。“虚拟机镜像” 部分将更详细地讨论这个过程。
从虚拟机内部的操作系统和软件服务的角度来看,似乎软件是在裸机物理机内部执行。虚拟机提供一个 CPU、内存、I/O 设备和一个网络连接。
鉴于它必须解决的许多问题,虚拟机管理程序是一个复杂的软件。虚拟机的一个问题是虚拟化所需的共享和隔离所带来的开销。也就是说,与直接在裸机物理机上运行相比,服务在虚拟机上运行会慢多少?这个问题的答案很复杂:它取决于服务的特性和所使用的虚拟化技术。例如,执行更多磁盘和网络 I/O 的服务比不共享这些主机资源的服务产生更多的开销。虚拟化技术一直在不断改进,但据微软报告,其 Hyper-V 虚拟机管理程序的开销约为 10%。^2
虚拟机对架构师有两个主要影响:
- 性能:虚拟化会带来性能成本。虽然类型 1 虚拟机管理程序只带来适度的性能损失,但类型 2 虚拟机管理程序可能会带来显著更大的开销。
- 关注点分离:虚拟化允许架构师将运行时资源视为商品,将供应和部署决策推迟到另一个人或组织。
16.3 虚拟机镜像
我们将启动虚拟机的磁盘存储内容称为 “虚拟机镜像”。这个镜像包含代表构成我们将运行的软件(即操作系统和服务)的指令和数据的位。这些位根据你的操作系统使用的文件系统组织成文件和目录。镜像还包含存储在其预定位置的引导加载程序。
有三种方法可以用来创建新的虚拟机镜像:
- 你可以找到一台已经在运行你想要的软件的机器,并对该机器内存中的位进行快照复制。
- 你可以从一个现有的镜像开始,并添加额外的软件。
- 你可以从头开始创建一个镜像。在这里,你首先获取你选择的操作系统的安装介质。你从安装介质启动你的新机器,它会格式化机器的磁盘驱动器,将操作系统复制到驱动器上,并在预定位置添加引导加载程序。
对于前两种方法,有机器镜像存储库可用(通常包含开源软件),提供各种只有操作系统内核的最小镜像、其他包含完整应用程序的镜像以及介于两者之间的所有镜像。这些高效的起点可以支持你快速尝试一个新的软件包或程序。
然而,当你下载并运行一个不是你(或你的组织)创建的镜像时,可能会出现一些问题:
- 你无法控制操作系统和软件的版本。
- 镜像可能包含有漏洞的软件或没有安全配置的软件;更糟糕的是,镜像可能包含恶意软件。
虚拟机镜像的其他重要方面是:
- 这些镜像非常大,所以通过网络传输它们可能非常慢。
- 一个镜像与它的所有依赖项捆绑在一起。
- 你可以在你的开发计算机上构建一个虚拟机镜像,然后将其部署到云中。
- 你可能希望向虚拟机添加你自己的服务。
虽然在创建镜像时你可以很容易地安装服务,但这会导致每个服务的每个版本都有一个独特的镜像。除了存储成本之外,这种镜像的扩散变得难以跟踪和管理。因此,通常的做法是创建只包含操作系统和其他基本程序的镜像,然后在虚拟机启动后向这些镜像添加服务,这个过程称为配置。
16.4 容器
虚拟机解决了共享资源和保持隔离的问题。然而,虚拟机镜像可能很大,在网络上传输虚拟机镜像很耗时。假设你有一个 8GB 的虚拟机镜像。你希望将其从网络上的一个位置移动到另一个位置。理论上,在每秒 1Gb 的网络上,这将需要 64 秒。然而,实际上 1Gbps 的网络运行效率约为 35%。因此,在现实世界中传输一个 8GB 的虚拟机镜像将需要超过 3 分钟。虽然你可以采用一些技术来减少这个传输时间,但结果仍然是以分钟为单位测量的时间。在镜像传输后,虚拟机必须启动操作系统并启动你的服务,这还需要更多的时间。
“容器” 是一种在减少镜像传输时间和启动时间的同时保持虚拟化大部分优势的机制。与虚拟机和虚拟机镜像一样,容器被打包成可执行的容器镜像进行传输。(然而,在实践中并不总是遵循这个术语。)
重新审视 [图 16.1][ch16fig01],我们看到虚拟机在虚拟机管理程序的控制下在虚拟化硬件上执行。在 [图 16.3][ch16fig03] 中,我们看到几个容器在 “容器运行时引擎” 的控制下运行,而容器运行时引擎又在一个固定的操作系统之上运行。容器运行时引擎充当一个虚拟化的操作系统。就像物理主机上的所有虚拟机共享相同的底层物理硬件一样,主机内的所有容器通过运行时引擎(并且通过操作系统,它们共享相同的底层物理硬件)共享相同的操作系统内核。操作系统可以加载到裸机物理机或虚拟机上。

图 16.3 在虚拟机管理程序(或裸机)之上的操作系统之上的容器运行时引擎之上的容器
虚拟机是通过找到一个有足够未使用资源来支持一个额外虚拟机的物理机来分配的。从概念上讲,这是通过查询虚拟机管理程序以找到一个有空闲容量的来实现的。容器是通过找到一个有足够未使用资源来支持一个额外容器的容器运行时引擎来分配的。这可能反过来需要创建一个额外的虚拟机来支持一个额外的容器运行时引擎。[图 16.3][ch16fig03] 描绘了在虚拟机管理程序控制下的虚拟机中的操作系统上运行的容器运行时引擎上运行的容器。
这种操作系统的共享代表了在传输镜像时性能提升的一个来源。只要目标机器上有一个标准的容器运行时引擎在运行(如今所有的容器运行时引擎都是按照标准构建的),就没有必要将操作系统作为容器镜像的一部分进行传输。
性能提升的第二个来源是在容器镜像中使用 “层”。(注意,容器层与我们在 [第 1 章][ch01] 中介绍的模块结构中的层概念不同。)为了更好地理解容器层,我们将描述容器镜像是如何构建的。在这种情况下,我们将说明构建一个用于运行 “LAMP 栈” 的容器,并以层的方式构建镜像。(LAMP—— 代表 Linux、Apache、MySQL 和 PHP—— 是一种广泛用于构建 Web 应用程序的栈。)
使用 LAMP 栈构建镜像的过程如下:
- 创建一个包含 Linux 发行版的容器镜像。(这个镜像可以使用容器管理系统从库中下载。)
- 一旦你创建了镜像并将其标识为一个镜像,就执行它(即实例化它)。
- 使用那个容器加载服务 —— 在我们的例子中是 Apache,使用 Linux 的功能。
- 退出容器并通知容器管理系统这是第二个镜像。
- 执行这个第二个镜像并加载 MySQL。
- 退出容器并给这个第三个镜像一个名称。
- 再重复这个过程一次并加载 PHP。现在你有了第四个容器镜像;这个映像包含整个 LAMP 栈。
因为这个镜像是逐步创建的,并且你告诉容器管理系统将每个步骤都作为一个镜像,所以容器管理系统认为最终的镜像由 “层” 组成。
现在你可以将 LAMP 栈容器镜像移动到不同的位置用于生产。初始移动需要移动栈的所有元素。然而,假设你将 PHP 更新到一个较新的版本并将这个修订后的栈投入生产(前面过程中的步骤 7)。容器管理系统知道只有 PHP 被修订了,只移动映像的 PHP 层。这节省了移动栈的其余部分所涉及的工作量。由于在镜像中更改软件组件比初始镜像创建要频繁得多,将容器的新版本投入生产比使用虚拟机要快得多。加载虚拟机需要几分钟的时间量级,而加载容器的新版本需要微秒或毫秒的时间量级。请注意,这个过程仅适用于栈的最上层。例如,如果你想用一个较新的版本更新 MySQL,你将需要执行前面列表中的步骤 5 到 7。
你可以创建一个包含创建容器镜像步骤的脚本并将其存储在一个文件中。这个文件特定于你用于创建容器镜像的工具。这样的文件允许你指定哪些软件片段要被加载到容器中并作为镜像保存。对规范文件使用版本控制确保你的团队的每个成员都可以创建一个相同的容器镜像,并根据需要修改规范文件。将这些脚本视为代码带来了许多优势:这些脚本可以被有意识地设计、测试、进行配置控制、审查、记录和共享。
16.5 容器和虚拟机
在虚拟机中交付服务与在容器中交付服务之间有哪些权衡?
正如我们前面提到的,虚拟机对物理硬件(CPU、磁盘、内存和网络)进行虚拟化。在虚拟机上运行的软件包括一个完整的操作系统,并且你几乎可以在虚拟机中运行任何操作系统。你也几乎可以在虚拟机中运行任何程序(除非它必须直接与物理硬件交互),这在处理遗留软件或购买的软件时很重要。拥有完整的操作系统还允许你在同一个虚拟机中运行多个服务 —— 当服务紧密耦合或共享大型数据集时,或者如果你想利用在同一虚拟机环境中运行的服务之间可用的高效内部服务通信和协调时,这是一个理想的结果。虚拟机管理程序确保操作系统启动,监控其执行,并在操作系统崩溃时重新启动它。
容器实例共享一个操作系统。操作系统必须与容器运行时引擎兼容,这限制了可以在容器上运行的软件。容器运行时引擎启动、监控并重新启动在容器中运行的服务。这个引擎通常在一个容器实例中只启动和监控一个程序。如果那个程序正常完成并退出,该容器的执行就会结束。出于这个原因,容器通常只运行一个服务(尽管该服务可以是多线程的)。此外,使用容器的一个好处是容器映像的大小很小,只包括支持我们想要运行的服务所必需的那些程序和库。容器中的多个服务可能会使映像大小膨胀,增加容器启动时间和运行时内存占用。正如我们很快将看到的,我们可以将运行相关服务的容器实例分组,以便它们在同一台物理机器上执行并可以高效地通信。一些容器运行时引擎甚至允许一个组内的容器共享内存和诸如信号量之类的协调机制。
虚拟机和容器之间的其他差异如下:
- 虚拟机可以运行任何操作系统,而目前容器仅限于 Linux、Windows 或 iOS。
- 虚拟机中的服务通过操作系统功能启动、停止和暂停,而容器中的服务通过容器运行时引擎功能启动和停止。
- 虚拟机在其中运行的服务终止后仍然存在;容器则不是。
- 在使用容器时存在一些对端口使用的限制,而在使用虚拟机时不存在这些限制。
16.6 容器的可移植性
我们已经介绍了容器与之交互的容器运行时管理器的概念。有几家供应商提供容器运行时引擎,最著名的是 Docker、containerd 和 Mesos。这些供应商中的每一个都有一个容器运行时引擎,该引擎提供创建容器映像以及分配和执行容器实例的功能。容器运行时引擎和容器之间的接口已由开放容器倡议(Open Container Initiative)标准化,这使得由一个供应商的软件包(例如 Docker)创建的容器能够在另一个供应商(例如 containerd)提供的容器运行时引擎上执行。
这意味着你可以在自己的开发计算机上开发一个容器,将其部署到生产计算机上,并使其在那里执行。当然,每种情况下可用的资源会有所不同,所以部署仍然不是一件轻而易举的事。如果你将所有资源指定为配置参数,那么将容器迁移到生产环境的过程就会简化。
16.7 Pod(容器组)
Kubernetes 是用于部署、管理和扩展容器的开源编排软件。它的层级结构中还有一个元素:Pod(容器组)。一个 “Pod” 是一组相关的容器。在 Kubernetes 中,节点(硬件或虚拟机)包含 Pod,Pod 包含容器,如 [图 16.4][ch16fig04] 所示。Pod 中的容器共享一个 IP 地址和端口空间以接收来自其他服务的请求。它们可以使用进程间通信(IPC)机制(如信号量或共享内存)相互通信,并且它们可以共享在 Pod 生命周期内存在的临时存储卷。它们具有相同的生命周期 ——Pod 中的容器会一起被分配和释放。例如,在 [第 9 章][ch09] 中讨论的服务网格通常被打包为一个 Pod。

图 16.4 包含多个 Pod(容器组)的节点,每个 Pod 又包含多个容器
Pod(容器组)的目的是降低紧密相关的容器之间的通信成本。在 [图 16.4][ch16fig04] 中,如果容器 1 和容器 2 频繁通信,它们作为一个 Pod(容器组)进行部署(从而被分配到同一个虚拟机上)这一事实,使得它们能够使用比消息传递更快的通信机制。
16.8 无服务器架构
回想一下,分配虚拟机首先要找到一台有足够空闲容量的物理机,然后将虚拟机映像加载到该物理机中。因此,这些物理计算机构成了一个可从中分配资源的资源池。现在假设你希望将容器分配到容器运行时引擎中,而不是将虚拟机分配到物理机中。也就是说,你有一个容器运行时引擎池,容器被分配到这个池中。
容器的加载时间非常短 —— 冷启动只需几秒,重新分配只需几毫秒。现在让我们更进一步。由于虚拟机的分配和加载相对耗时,可能需要数分钟来加载和启动实例,所以即使在请求之间存在空闲时间,你通常也会让虚拟机实例保持运行。相比之下,由于将容器分配到容器运行时引擎的速度很快,就没有必要让容器一直运行。我们可以为每个请求重新分配一个新的容器实例。当你的服务完成一个请求的处理时,它不会循环回去接收另一个请求,而是直接退出,容器停止运行并被释放。
这种系统设计方法被称为 “无服务器架构”—— 尽管实际上并非没有服务器。存在托管容器运行时引擎的服务器,但由于它们是根据每个请求动态分配的,所以服务器和容器运行时引擎体现在基础设施中。作为开发人员,你不需要负责分配或释放它们。支持这种功能的云服务提供商特性被称为函数即服务(FaaS)。
响应单个请求进行动态分配和释放的一个结果是,这些短生命周期的容器无法维护任何状态:容器必须是无状态的。在无服务器架构中,任何用于协调的所需状态必须存储在云提供商提供的基础设施服务中,或者作为参数传递。
云提供商对 FaaS 特性施加了一些实际限制。首先,提供商提供的基础容器镜像选择有限,这限制了你的编程语言选项和库依赖关系。这样做是为了减少容器加载时间 —— 你的服务被限制为在提供商的基础映像层之上的一个薄镜像层。下一个限制是,当你的容器首次被分配和加载时的 “冷启动” 时间可能长达数秒。后续请求几乎可以即时处理,因为你的容器镜像被缓存在某个节点上。最后,对一个请求的执行时间有限制 —— 你的服务必须在提供商的时间限制内处理请求并退出,否则将被终止。云提供商这样做是出于经济原因,以便能够针对 FaaS 定制价格(相较于其他运行容器的方式),并确保没有 FaaS 用户过度消耗资源池。一些无服务器系统的设计者花费大量精力来绕过或克服这些限制 —— 例如,预启动服务以避免冷启动延迟、发送虚拟请求以保持服务在缓存中,以及将请求从一个服务派生或链接到另一个服务以延长有效执行时间。
16.9 小结
虚拟化对软件和系统架构师来说是一个福音,因为它为联网(通常是基于网络的)服务提供了高效、经济的分配平台。硬件虚拟化允许创建多个共享同一台物理机的虚拟机。它在这样做的同时确保了 CPU、内存、磁盘存储和网络的隔离。因此,物理机的资源可以在多个虚拟机之间共享,同时将一个组织必须购买或租用的物理机数量降至最低。
虚拟机镜像是加载到虚拟机中以使其能够执行的一组比特。虚拟机镜像可以通过各种供应技术创建,包括使用操作系统功能或加载预先创建的镜像。
容器是一种对操作系统进行虚拟化的打包机制。如果有兼容的容器运行时引擎可用,容器就可以从一个环境迁移到另一个环境。容器运行时引擎的接口已经标准化。
将多个容器放入一个 Pod(容器组)意味着它们将一起被分配,并且容器之间的任何通信都可以快速完成。
无服务器架构允许容器快速实例化,并将分配和释放的责任转移到云提供商的基础设施上。
16.10 扩展阅读
本章的材料取自《软件工程师的部署与运维》[[Bass 19][ref_17]],在该书中你能找到更详细的论述。
维基百科始终是查找协议、容器运行时引擎和无服务器架构的最新详细信息的好去处。
16.11 问题讨论
1. 使用 Docker 创建一个 LAMP 容器。将你创建的容器镜像大小与你在互联网上找到的一个进行比较。差异的来源是什么?在什么情况下,这会成为你作为架构师所担心的问题?
2. 容器管理系统如何知道只有一层被更改,从而只需传输一层?
3. 我们已经关注了在虚拟机管理程序上同时运行的虚拟机之间的隔离。虚拟机可能会关闭并停止执行,新的虚拟机可能会启动。虚拟机管理程序如何在不同时间运行的虚拟机之间保持隔离或防止泄漏?提示:考虑内存、磁盘、虚拟 MAC 地址和 IP 地址的管理。
4. 将哪些服务集组合成一个 Pod(就像服务网格那样)是合理的?为什么?
5. 与容器相关的安全问题有哪些?你将如何缓解这些问题?
6. 在嵌入式系统中使用虚拟化技术会带来哪些问题?
7. 使用虚拟机、容器和 Pod 可以避免哪些类型的集成和部署错误?哪些类型不能避免?
第17章 云计算和分布式计算
分布式系统是这样一种系统:在其中,一台你甚至都不知道其存在的计算机出现故障,就可能会使你自己的计算机无法使用。
——莱斯利·兰波特(Leslie Lamport)
“云计算” 是指按需提供资源。这个术语被用于指代多种计算能力。例如,你可能会说:“我所有的照片都备份到云端了。” 但这意味着什么呢?这意味着:
-
我的照片存储在别人的计算机上。他们负责资金投入、维护、保养以及备份工作。
-
我可以通过互联网访问我的照片。
-
我只需为我使用或申请的空间付费。
-
存储服务是弹性的,这意味着它可以根据我的需求变化而扩展或收缩。
-
我对云服务的使用是自行配置的:我创建一个账户后就可以立即开始使用它来存储我的资料。
从云端提供的计算能力包括从照片(或其他类型的数字制品)存储等应用程序,到通过 API(应用程序接口)暴露的细粒度服务(例如,文本翻译或货币转换),再到低层级的基础设施服务,如处理器、网络和存储虚拟化。
在本章中,我们将重点关注软件架构师如何使用来自云端的基础设施服务来交付其正在设计和开发的服务。在此过程中,我们将深入探讨分布式计算的一些最重要的原理和技术。这意味着使用多台(真实的或虚拟的)计算机协同工作,从而产生比单台计算机完成所有工作更快的性能和更健壮的系统。我们将这一主题包含在本章中,是因为分布式计算在基于云的系统中体现得最为深入。我们在此给出的内容是对与架构最相关的原理的简要概述。
我们首先讨论云如何提供和管理虚拟机。
17.1 云基础
公有云由云服务提供商拥有和提供。这些机构向任何同意服务条款并且能够为使用服务付费的人提供基础设施服务。一般来说,使用这种基础设施构建的服务可通过公共互联网访问,不过你可以设置防火墙等机制来限制可见性和访问权限。
一些组织运营私有云。私有云由一个组织拥有并运营,仅供该组织的成员使用。一个组织可能出于控制、安全和成本等方面的考虑而选择运营私有云。在这种情况下,云基础设施及其上开发的服务仅在组织内部网络中可见和可访问。
混合云方法是一种混合模式,其中一些工作负载在私有云中运行,其他工作负载在公有云中运行。在从私有云向公有云(反之亦然)迁移期间可能会使用混合云,或者也可能因为某些数据在法律上需要受到比公有云所能提供的更强的控制和审查而使用混合云。
对于使用云服务设计软件的架构师来说,从技术角度看,私有云和公有云之间没有太大差异。因此,我们在这里将讨论重点放在基础设施即服务(IaaS)公有云上。
一个典型的公有云数据中心拥有数以万计的物理设备 —— 接近 10 万而不是 5 万。数据中心规模的限制因素是其消耗的电力以及设备产生的热量:将电力引入建筑物、分配给设备以及消除设备产生的热量都存在实际限制。[图 17.1][ch17fig01] 展示了一个典型的云数据中心。每个机架包含 25 台以上的计算机(每台计算机有多个 CPU),确切数量取决于可用的电力和冷却能力。数据中心由一排排这样的机架组成,高速网络交换机连接着这些机架。云数据中心是能源效率(在 [第 6 章][ch06] 讨论的主题)在某些应用中成为关键质量属性的原因之一。

当你通过公有云提供商访问云时,实际上你正在访问分布在全球各地的数据中心。云提供商将其数据中心划分为 “区域”。云区域既是一种逻辑结构,也是一种物理结构。由于你开发并部署到云中的服务是通过互联网访问的,云区域可以帮助你确保服务在物理上靠近其用户,从而减少访问服务的网络延迟。此外,一些监管限制,如《通用数据保护条例》(GDPR),可能会限制某些类型的数据跨境传输,所以云区域有助于云提供商遵守这些法规。
一个云区域有许多物理上分布的、具有不同电力来源和互联网连接的的数据中心。一个区域内的数据中心被分组为 “可用区”,这样,两个不同可用区中的所有数据中心同时发生故障的概率极低。
选择你的服务将运行的云区域是一个重要的设计决策。当你请求提供一个在云中运行的新虚拟机(VM)时,你可以指定该虚拟机将在哪个区域运行。有时可用区可能会被自动选择,但出于可用性和业务连续性的原因,你通常会希望自己选择可用区。
对公有云的所有访问都通过互联网进行。进入云有两个主要网关:管理网关和消息网关([图 17.2][ch15fig02])。在这里,我们将重点关注管理网关;我们在 [第 15 章][ch15] 中讨论过消息网关。

假设你希望在云中为自己分配一个虚拟机(VM)。你向管理网关发送一个请求,要求创建一个新的虚拟机实例。这个请求有许多参数,但三个基本参数是新实例将运行的云区域、实例类型(例如,CPU 和内存大小)以及一个虚拟机映像的标识(ID)。管理网关负责管理数以万计的物理计算机,每台物理计算机都有一个虚拟机管理程序(hypervisor)来管理其上的虚拟机。所以,管理网关将通过询问来确定一个能够管理你所选类型的额外虚拟机的虚拟机管理程序:那台物理机上是否有足够未分配的 CPU 和内存容量来满足你的需求?如果有,它将要求该虚拟机管理程序创建一个额外的虚拟机;虚拟机管理程序将执行此任务并将新虚拟机的 IP 地址返回给管理网关。然后管理网关将该 IP 地址发送给你。云提供商确保其数据中心有足够的物理硬件资源可用,这样你的请求就永远不会因为资源不足而失败。
管理网关不仅返回新分配的虚拟机的 IP 地址,还返回一个主机名。分配虚拟机后返回的主机名反映了该 IP 地址已被添加到云域名系统(DNS)这一事实。任何虚拟机镜像都可用于创建新的虚拟机实例;也就是说,虚拟机镜像可能包含一个简单的服务,或者只是创建一个复杂系统的部署过程中的一个步骤。
除了分配新虚拟机之外,管理网关还执行其他功能。它支持收集有关虚拟机的计费信息,并且提供监控和销毁虚拟机的能力。
管理网关可通过互联网向其应用程序接口(API)发送消息来访问。这些消息可以来自另一个服务,例如部署服务,或者它们可以由你计算机上的命令行程序生成(允许你编写操作脚本)。管理网关也可以通过云服务提供商运营的基于网络的应用程序来访问,不过这种交互界面对于除最基本操作之外的操作来说效率不高。
17.2 云中的故障
当一个数据中心包含数以万计的物理计算机时,几乎可以肯定每天都会有一台或多台计算机出现故障。亚马逊报告称,在一个拥有约 64,000 台计算机(每台计算机有两个旋转磁盘驱动器)的数据中心里,每天大约会有 5 台计算机和 17 个磁盘发生故障。谷歌也报告了类似的统计数据。除了计算机和磁盘故障之外,网络交换机也可能发生故障;数据中心可能过热,导致所有计算机故障;或者一些自然灾害可能会使整个数据中心瘫痪。尽管你的云提供商总体停机次数相对较少,但你的特定虚拟机正在运行的物理计算机可能会出现故障。如果可用性对你的服务很重要,你就需要仔细考虑你希望达到何种级别的可用性以及如何实现它。
我们将讨论两个与云中故障特别相关的概念:超时和长尾延迟。
超时
回顾 [第 4 章][ch04] 可知,超时是一种提高可用性的策略。在分布式系统中,超时被用于检测故障。使用超时会产生以下几个结果:
-
超时无法区分是计算机故障或网络连接中断,还是对消息的回复过慢(超出了超时期限)。这将导致你把一些缓慢的响应标记为故障。
-
超时不会告诉你故障或缓慢发生在哪里。
-
很多时候,对一个服务的请求会触发该服务向其他服务发出请求,而其他服务又会发出更多请求。即使这个链中的每个响应的延迟接近(但慢于)预期的平均响应时间,总体延迟可能(错误地)暗示存在故障。
超时(即判定一个响应耗时过长)通常被用于检测故障。超时无法确定故障是由于被请求服务的软件、该服务运行的虚拟或物理机器,还是与服务的网络连接出现问题。在大多数情况下,原因并不重要:你发出了一个请求,或者你在等待一个周期性的保活或心跳消息,但没有及时收到响应,现在你需要采取行动来补救这种情况。
这看似简单,但在实际系统中可能很复杂。恢复行动通常是有成本的,例如延迟惩罚。你可能需要启动一个新的虚拟机,这可能需要几分钟才能准备好接受新请求。你可能需要与不同的服务实例建立一个新的会话,这可能会影响你系统的可用性。云系统中的响应时间可能会有很大差异。在实际只是暂时延迟的情况下就断定存在故障,可能会在不必要的时候增加恢复成本。
分布式系统设计者通常会对超时检测机制进行参数化,以便能够针对某个系统或基础设施进行调整。一个参数是超时间隔 —— 系统在判定一个响应失败之前应该等待多长时间。大多数系统不会在仅仅错过一次响应后就触发故障恢复。相反,典型的做法是在较长的时间间隔内查看错过的响应次数。错过的响应次数是超时机制的第二个参数。例如,可以将超时设置为 200 毫秒,并在 1 秒的时间间隔内错过 3 条消息后触发故障恢复。
对于在单个数据中心运行的系统,可以激进地设置超时和阈值,因为网络延迟极小,错过响应很可能是由于软件崩溃或硬件故障。相比之下,对于在广域网、蜂窝无线网络甚至卫星链路运行的系统,在设置参数时应该更加慎重,因为这些系统可能会经历间歇性但较长的网络延迟。在这种情况下,可以放宽参数以反映这种可能性,避免触发不必要的恢复操作。
长尾延迟
不管原因是实际故障还是仅仅是响应缓慢,对你原始请求的响应可能会表现出所谓的长尾延迟。[图 17.3][ch17fig03] 展示了对亚马逊网络服务(AWS)的 1000 个 “启动实例” 请求的延迟直方图。注意,有些请求需要很长时间才能得到满足。在评估像这样的测量数据集时,你必须谨慎选择用于描述数据集特征的统计量。在这种情况下,直方图在 22 秒的延迟处达到峰值;然而,所有测量的平均延迟是 28 秒,中位数延迟(一半的请求在小于此值的延迟内完成)是 23 秒。甚至在 57 秒的延迟之后,仍有 5% 的请求尚未完成(即第 95 百分位数是 57 秒)。所以,尽管对基于云的服务的每个服务到服务请求的平均延迟可能在可容忍的范围内,但相当数量的这些请求可能会有长得多的延迟 —— 在这种情况下,是平均延迟的 2 到 10 倍。这些就是直方图右侧长尾部分的测量值。

图 17.3 对亚马逊网络服务(AWS)1000 个 “启动实例” 请求的长尾分布
长尾延迟是服务请求路径中某处拥塞或故障的结果。许多因素可能导致拥塞 —— 服务器队列、虚拟机管理程序调度或其他因素 —— 但作为服务开发者,拥塞的原因不在你的控制范围内。你的监控技术以及实现所需性能和可用性的策略必须反映长尾分布的实际情况。
处理长尾问题的两种技术是对冲请求(hedged requests)和替代请求(alternative requests)。
-
对冲请求:发出比所需更多的请求,然后在收到足够的响应后取消请求(或忽略响应)。例如,假设要启动 10 个微服务(见 [第 5 章][ch05])实例。发出 11 个请求,在 10 个请求完成后,终止尚未响应的那个请求。
-
替代请求:对冲请求技术的一种变体称为替代请求。在刚刚描述的场景中,发出 10 个请求。当 8 个请求完成时,再发出 2 个请求,当总共收到 10 个响应时,取消仍未完成的 2 个请求。
17.3 利用多个实例提升性能和可用性
如果云中托管的服务收到的请求数量超出了它在规定延迟内能够处理的数量,该服务就会过载。这可能是因为 I/O 带宽、CPU 周期、内存或其他资源不足。在某些情况下,你可以通过在能提供更多所需资源的不同实例类型中运行服务来解决服务过载问题。这种方法很简单:服务的设计无需改变;相反,服务只是在更大的虚拟机上运行。这种被称为纵向扩展(垂直扩展)或向上扩展的方法,对应于 [第 9 章][ch09] 中的增加资源性能策略。
纵向扩展能够达成的效果是有限的。特别是,可能不存在足够大的虚拟机实例类型来支持工作负载。在这种情况下,横向扩展(向外扩展)能提供更多所需类型的资源。横向扩展涉及拥有同一服务的多个副本,并使用负载均衡器在这些副本之间分配请求 —— 这分别等同于 [第 9 章][ch09] 中的维持多个计算副本策略和负载均衡器模式。
分布式计算和负载均衡器
负载均衡器可以是独立系统,也可以与其他功能捆绑在一起。负载均衡器必须非常高效,因为它位于从客户端到服务的每条消息的路径上,而且即使它与其他功能打包在一起,在逻辑上也是独立的。在这里,我们将讨论分为两个主要方面:负载均衡器如何工作,以及位于负载均衡器后面的服务必须如何设计以管理服务状态。一旦我们理解了这些过程,我们就能探究系统健康状况的管理以及负载均衡器如何提高其可用性。
负载均衡器解决了以下问题:在虚拟机或容器中运行的单个服务实例收到了太多请求,以至于无法提供可接受的延迟。一种解决方案是拥有该服务的多个实例并在它们之间分配请求。在这种情况下,分配机制是一个独立的服务 —— 负载均衡器。[图 17.4][ch17fig04] 展示了负载均衡器在两个虚拟机(服务)实例之间分配请求。如果是两个容器实例,同样的讨论也适用。(容器在 [第 16 章][ch16] 中已经讨论过。)

图 17.4 负载均衡器将来自两个客户端的请求分配到两个服务实例
你可能想知道什么是 “过多请求” 和 “合理响应时间”。在本章后面讨论自动扩展时我们会再回到这些问题。现在,让我们先关注负载均衡器是如何工作的。
在 [图 17.4][ch17fig04] 中,每个请求都被发送到负载均衡器。为了便于我们的讨论,假设负载均衡器将第一个请求发送到实例 1,第二个请求发送到实例 2,第三个请求再发回实例 1,依此类推。这样就将一半的请求发送到每个实例,在两个实例之间实现了 “负载” 的 “均衡”—— 这就是它名字的由来。
关于这个简单的负载均衡器示例的一些观察:
-
我们提到的算法(在两个实例之间交替分配消息)被称为 “轮询(round - robin)” 算法。只有当每个请求在响应时消耗大致相同的资源时,该算法才能在服务实例间均匀地平衡负载。当处理请求所需的资源消耗不同时,还存在其他用于分配消息的算法。
-
从客户端的角度来看,服务的 IP 地址实际上就是负载均衡器的地址。这个地址可能与域名系统(DNS)中的主机名相关联。客户端不知道,也不需要知道存在多少个服务实例或者这些服务实例中任何一个的 IP 地址。这使得客户端能够适应此类信息的变更 —— 这是 [第 8 章][ch08] 中讨论的使用中间件的一个例子。
-
可能存在多个客户端共存的情况。每个客户端将其消息发送到负载均衡器,而负载均衡器并不关心消息的来源。负载均衡器在消息到达时对其进行分配。(我们暂时先忽略所谓的 “粘性会话” 或 “会话亲和性” 概念。)
-
负载均衡器可能会过载。在这种情况下,解决方案是对负载均衡器的负载进行均衡,有时这被称为全局负载均衡。也就是说,消息在到达服务实例之前要经过一系列的负载均衡器。
到目前为止,我们对负载均衡器的讨论主要集中在增加可处理的工作量上。在这里,我们将考虑负载均衡器如何也用于提高服务的可用性。
[图 17.4][ch17fig04] 展示了来自客户端的消息经过负载均衡器,但没有展示返回消息。返回消息直接从服务实例发送到 IP 消息头 “源” 字段中标识的服务(通常,这会绕过负载均衡器,尽管在某些配置中返回消息可能会发送到负载均衡器)。在绕过负载均衡器的情况下,它无法得知某个消息是否已被服务实例处理,或者处理一个消息花费了多长时间。如果没有额外的机制,负载均衡器就不会知道任何服务实例是否处于存活并正在处理请求的状态,或者是否有某个实例或者所有实例都已发生故障。
运行状况健康检查是一种允许负载均衡器确定实例是否正常运行的机制。这就是 [第 4 章][ch04] 中可用性策略里 “故障检测” 类别的目的。负载均衡器将定期检查分配给它的实例的健康状况。如果一个实例未能对健康检查做出响应,它就会被标记为不健康,并且不会再向其发送任何消息。健康检查可以包括负载均衡器向实例发送 ping(网络检测)、与实例建立 TCP 连接,甚至发送消息进行处理。在后一种情况下,返回的 IP 地址是负载均衡器的地址。
一个实例有可能从健康状态变为不健康状态,然后又恢复健康。例如,假设实例有一个过载的队列。当最初被联系时,它可能不会对负载均衡器的健康检查做出响应,但一旦队列被清空,它可能就又能做出响应了。出于这个原因,负载均衡器在将一个实例移到不健康列表之前会进行多次检查,然后定期检查不健康列表以确定某个实例是否又能做出响应了。在其他情况下,严重故障或崩溃可能会导致故障实例重新启动并向负载均衡器重新注册,或者可能会启动一个新的替换实例并向负载均衡器注册,以维持整体的服务交付能力。
具有健康检查功能的负载均衡器通过向客户端隐藏服务实例的故障来提高可用性。服务实例池的规模可以设置为能够容纳一定数量的同时发生的服务实例故障,同时仍然提供足够的整体服务能力,以便在期望的延迟内处理所需数量的客户端请求。然而,即使使用健康检查,服务实例有时可能会开始处理客户端请求但永远不返回响应。在设计客户端时必须考虑到,如果没有及时收到响应就要重新发送请求,这样负载均衡器就可以将请求分配到不同的服务实例。相应地,服务也必须被设计为能够处理多个相同的请求。
分布式系统中的状态管理
“状态” 是指服务内部影响对客户端请求响应计算的信息。状态 —— 或者更准确地说,存储状态的变量或数据结构的值的集合 —— 取决于对服务请求的历史记录。
当一个服务能够同时处理多个客户端请求时(要么是因为服务实例是多线程的,要么是因为负载均衡器后面有多个服务实例,或者两者皆有),状态管理就变得很重要。关键问题是状态存储在哪里。有以下三个选项:
-
在每个服务实例中维护历史记录,在这种情况下,这些服务被描述为 “有状态的”。
-
在每个客户端中维护历史记录,在这种情况下,这些服务被描述为 “无状态的”。
-
历史记录在服务和客户端之外的数据库中持久化,在这种情况下,这些服务被描述为 “无状态的”。
通常的做法是设计和实现无状态的服务。有状态的服务如果发生故障会丢失其历史记录,而且恢复该状态可能很困难。此外,正如我们将在下一节看到的,可能会创建新的服务实例,而将服务设计为无状态可以使新的服务实例处理客户端请求并产生与任何其他服务实例相同的响应。
在某些情况下,设计无状态的服务可能很困难或者效率低下,所以我们可能希望来自客户端的一系列消息由同一个服务实例处理。我们可以通过让该系列中的第一个请求由负载均衡器处理并分配到一个服务实例,然后允许客户端直接与该服务实例建立会话,并且后续请求绕过负载均衡器来实现这一点。或者,一些负载均衡器可以被配置为将某些类型的请求视为粘性(sticky)请求,这会使负载均衡器将来自客户端的后续请求发送到处理该客户端上一个消息的同一个服务实例。这些方法(直接会话和粘性消息)应该只在特殊情况下使用,因为存在实例故障的可能性以及消息所粘性(附着)的实例可能会有过载的风险。
通常,需要在一个服务的所有实例之间共享信息。这些信息可能包括前面讨论过的状态信息,也可能是服务实例高效协作所需的其他信息 —— 例如,该服务的负载均衡器的 IP 地址。接下来将讨论一种用于管理在一个服务的所有实例之间共享的相对少量信息的解决方案。
分布式系统中的时间协调
确切知道当前时间看似是一项简单的任务,但实际上并非易事。计算机中的硬件时钟大约每 12 天就会快或慢 1 秒。可以说,如果你的计算设备处于外界环境中,它可能能够接收来自全球定位系统(GPS)卫星的时间信号,这种信号提供的时间精度可达到 100 纳秒或更精确。
让两个或更多设备就当前时间达成一致可能更具挑战性。网络上两个不同设备的时钟读数肯定会有所不同。网络时间协议(NTP)用于在通过局域网或广域网连接的不同设备之间同步时间。它涉及在时间服务器和客户端设备之间交换消息以估计网络延迟,然后应用算法将客户端设备的时钟与时间服务器同步。在局域网中,NTP 的精度约为 1 毫秒,在公共网络中约为 10 毫秒。网络拥塞可能会导致 100 毫秒或更多的误差。
云服务提供商为其时间服务器提供非常精确的时间参考。例如,亚马逊和谷歌使用原子钟,其漂移几乎可以忽略不计。因此,两者都能对 “现在是什么时间?” 这个问题提供极其精确的答案。当然,当你得到答案时是什么时间那就是另一回事了。
幸运的是,在很多情况下,近似准确的时间就足够了。然而,实际上,你应该假定两个不同设备的时钟读数之间存在一定程度的误差。出于这个原因,大多数分布式系统在设计时都确保应用程序正常运行不需要设备之间的时间同步。你可以使用设备时间来触发周期性操作、为日志条目添加时间戳以及用于其他一些不需要与其他设备精确协调的目的。
同样幸运的是,在很多情况下,知道事件的顺序比知道这些事件发生的时间更为重要。股票市场上的交易决策属于这种情况,任何形式的在线拍卖也是如此。两者都依赖于按照数据包传输的相同顺序来处理数据包。
对于设备之间的关键协调,大多数分布式系统使用诸如向量时钟(实际上不是时钟,而是随着动作在应用程序中的服务间传播而进行跟踪的计数器)之类的机制来确定一个事件是否发生在另一个事件之前,而不是比较时间。这确保了应用程序能够以正确的顺序执行操作。我们在下一节讨论的大多数数据协调机制都依赖于这种动作顺序。
对于架构师来说,成功的时间协调包括知道你是否真的需要依赖实际的时钟时间,或者确保正确的顺序是否就足够了。如果前者很重要,那么了解你的精度要求并据此选择解决方案。
分布式系统中的数据协调
考虑创建一个在分布式机器之间共享的资源锁的问题。假设某个关键资源正在由运行在两台不同物理计算机上的两个不同虚拟机中的服务实例访问。我们假设这个关键资源是一个数据项 —— 例如,你的银行账户余额。更改账户余额需要读取当前余额、加上或减去交易金额,然后写回新的余额。如果我们允许两个服务实例独立地对这个数据项进行操作,就有可能出现竞争条件,例如两笔同时进行的存款相互覆盖。在这种情况下的标准解决方案是锁定该数据项,这样一个服务在获取锁之前就无法访问你的账户余额。我们避免了竞争条件,因为服务实例 1 被授予了对你银行账户的锁,并且可以独立地进行存款操作,直到它释放锁。然后一直在等待锁可用的服务实例 2 就可以锁定银行账户并进行第二笔存款。
当服务是在单台机器上运行的进程时,使用共享锁的这种解决方案很容易实现,请求和释放锁是简单的内存访问操作,速度非常快且具有原子性。然而,在分布式系统中,这种方案会出现两个问题。首先,传统上用于获取锁的两阶段提交协议需要在网络上传输多个消息。在最好的情况下,这只会给操作增加延迟,但在最坏的情况下,这些消息中的任何一个都可能无法传递。其次,服务实例 1 在获取锁之后可能会发生故障,从而阻止服务实例 2 继续进行操作。
解决这些问题涉及复杂的分布式协调算法。本章开头引用的莱斯利・兰波特(Leslie Lamport)开发了最早的此类算法之一,他将其命名为 “Paxos”。Paxos 和其他分布式协调算法依赖于一种共识机制,即使在计算机或网络出现故障时也能让参与者达成一致。这些算法的设计正确性非常复杂,而且由于编程语言和网络接口语义的微妙之处,即使实现一个已被证明的算法也很困难。实际上,分布式协调是那些你不应该尝试自己解决的问题之一。使用现有的解决方案包,如 Apache Zookeeper、Consul 和 etcd 等,几乎总是比自己编写更好的主意。当服务实例需要共享信息时,它们将信息存储在一个使用分布式协调机制的服务中,以确保所有服务看到相同的值。
我们最后一个分布式计算主题是实例的自动创建和销毁。
自动扩展:实例的自动创建和销毁
考虑一个传统的数据中心,你的组织拥有所有的物理资源。在这种环境下,你的组织需要为一个系统分配足够的物理硬件来处理它承诺处理的最大工作量峰值。当工作量低于峰值时,分配给该系统的部分(或大部分)硬件容量处于闲置状态。现在将其与云环境进行比较。云的两个决定性特征是,你只需为你申请的资源付费,并且你可以轻松快速地添加和释放资源(弹性)。这些特征结合在一起,使你能够创建具有处理工作量能力的系统,并且你无需为任何多余的容量付费。
弹性适用于不同的时间尺度。一些系统的工作量相对稳定,在这种情况下,你可能会考虑以月或季度为时间尺度手动审查和更改资源分配,以匹配这种缓慢变化的工作量。其他系统的工作量更具动态性,请求速率有快速的增减,因此需要一种自动添加和释放服务实例的方法。
自动扩展是一种基础设施服务,它在需要时自动创建新实例,并在不再需要时释放多余的实例。它通常与负载均衡协同工作,以增加和减少负载均衡器后面的服务实例池。自动扩展容器与自动扩展虚拟机略有不同。我们先讨论自动扩展虚拟机,然后讨论自动扩展容器时的差异。
自动缩放是一项基础结构服务,可在需要时自动创建新实例,并在不再需要时释放多余的实例。它通常与负载平衡结合使用,以增加和收缩负载均衡器后面的服务实例池。自动缩放容器与自动缩放 VM 略有不同。我们首先讨论自动缩放 VM,然后讨论自动缩放容器时的差异。
自动扩展虚拟机
回到 [图 17.4][ch17fig04],假设两个客户端产生的请求数量超过了图中所示的两个服务实例所能处理的数量。自动扩展会基于用于前两个实例的相同虚拟机镜像创建第三个实例。新实例向负载均衡器注册,这样后续请求就会在三个实例而不是两个实例之间分配。[图 17.5][ch17fig05] 展示了一个新组件 —— 自动扩展器(autoscaler),它监控服务器实例的利用率并进行自动扩展。一旦自动扩展器创建了一个新的服务实例,它就会将新的 IP 地址通知负载均衡器,以便负载均衡器除了向其他实例分配请求之外,还能向新实例分配请求。

由于客户端不知道存在多少个实例,也不知道哪个实例正在处理它们的请求,所以自动扩展活动对服务客户端是不可见的。此外,如果客户端请求速率降低,可以在客户端不知情的情况下将一个实例从负载均衡器池中移除、停止并释放。
作为基于云服务的架构师,你可以为自动扩展器设置一组规则来控制其行为。你提供给自动扩展器的配置信息包括以下内容:
- 创建新实例时要启动的虚拟机(VM)映像,以及云提供商要求的任何实例配置参数,如安全设置
- 任何实例的 CPU 利用率阈值(按时间测量),超过该阈值将启动一个新实例
- 任何实例的 CPU 利用率阈值(按时间测量),低于该阈值将关闭一个现有实例
- 创建和删除实例的网络 I/O 带宽阈值(按时间测量)
- 你希望在此组中的实例的最小和最大数量
自动扩展器不会基于 CPU 利用率或网络 I/O 带宽指标的瞬时值来创建或移除实例,原因有二。首先,这些指标有高峰和低谷,只有在合理的时间间隔内取平均值时才有意义。其次,分配和启动一个新的虚拟机需要相对较长的时间,大约是几分钟的量级。虚拟机映像必须被加载并连接到网络,并且操作系统必须启动后才能准备好处理消息。因此,自动扩展器规则通常采用这样的形式:“当 CPU 利用率在 5 分钟内高于 80% 时创建一个新的虚拟机”。
除了基于利用率指标创建和销毁虚拟机之外,你还可以设置规则来提供虚拟机的最小或最大数量,或者基于时间表创建虚拟机。例如,在典型的一周内,工作时间的负载可能会更重;基于这一情况,你可以在工作日开始前分配更多的虚拟机,并在工作日结束后移除一些。这些基于时间表的分配应该基于有关服务使用模式的历史数据。
当自动扩展器移除一个实例时,它不能直接关闭虚拟机。首先,它必须通知负载均衡器停止向该服务实例发送请求。其次,由于该实例可能正在处理一个请求,自动扩展器必须通知该实例它应该终止其活动并关闭,之后才能将其销毁。这个过程被称为 “排空”(draining)实例。作为服务开发者,你有责任实现适当的接口来接收终止和排空服务实例的指令。
自动缩放容器
由于容器在虚拟机(VM)上托管的运行时引擎上执行,扩展容器涉及两种不同类型的决策。在扩展虚拟机时,自动扩展器判定需要额外的虚拟机,然后分配一个新的虚拟机并加载适当的软件。扩展容器意味着做出两级决策。首先,判定当前工作负载需要一个额外的容器(或容器组 Pod)。其次,判定新的容器(或容器组 Pod)是否可以分配到现有的运行时引擎实例上,还是必须分配一个新的实例。如果必须分配一个新的实例,你需要检查是否有足够容量的虚拟机可用,或者是否需要分配一个额外的虚拟机。
控制容器扩展的软件独立于控制虚拟机扩展的软件。这使得容器的扩展可以在不同的云提供商之间具有可移植性。容器的发展有可能会整合这两种类型的扩展。在这种情况下,你应该意识到你可能正在你的软件和云提供商之间创建一种可能难以打破的依赖关系。
17.4 小结
云由分布式数据中心组成,每个数据中心包含数以万计的计算机。它通过一个可通过互联网访问的管理网关进行管理,该网关负责分配、释放和监控虚拟机(VM),以及计量资源使用情况和计算费用。
由于数据中心计算机数量众多,数据中心内计算机发生故障的情况相当频繁。作为服务架构师,你应该假定在某个时刻,运行你的服务的虚拟机将会发生故障。你还应该假定,你对其他服务的请求会呈现长尾分布,即多达 5% 的请求耗时会比平均请求长 5 到 10 倍。因此,你必须关注服务的可用性。
由于你的服务的单个实例可能无法及时满足所有请求,你可能决定运行多个包含服务实例的虚拟机或容器。这些多个实例位于负载均衡器之后。负载均衡器接收来自客户端的请求,并将请求分配到各个实例。
服务存在多个实例以及多个客户端,这对处理状态的方式有重大影响。关于状态存储位置的不同决策会导致不同的结果。最常见的做法是保持服务无状态,因为无状态服务更易于从故障中恢复并且更易于添加新实例。通过使用分布式协调服务,可以在服务实例之间共享少量数据。分布式协调服务的实现很复杂,但有几个经过验证的开源实现可供你使用。
云基础设施可以通过在需求增长时创建新实例、在需求减少时移除实例来自动扩展服务。通过一组给出创建或删除实例条件的规则来指定自动扩展器的行为。
17.5 扩展阅读
关于网络和虚拟化如何工作的更多细节可以在 [[Bass 19][ref_17]] 中找到。
云环境下的长尾延迟现象首次在 [[Dean 13][ref_76]] 中被提出。
Paxos 算法最初由 [[Lamport 98][ref_158]] 提出。人们发现原始论文难以理解,但在维基百科(https://en.wikipedia.org/wiki/Paxos_(computer_science))上可以找到对 Paxos 非常详尽的描述。大约在同一时间,布赖恩・奥基(Brian Oki)和芭芭拉・利斯科夫(Barbara Liskov)独立开发并发表了一种名为视图标记复制(Viewstamped Replication)的算法,后来证明该算法与兰波特的 Paxos 算法等效 [[Oki 88][ref_200]]。
可以在 https://zookeeper.apache.org/ 找到对 Apache Zookeeper 的描述。可以在 https://www.consul.io/ 找到 Consul 的相关信息,在 https://etcd.io/ 可以找到 etcd 的相关信息。
不同类型负载均衡器的讨论可在 https://docs.aws.amazon.com/AmazonECS/latest/developerguide/load-balancer-types.html 找到。
分布式系统中的时间问题在 https://medium.com/coinmonks/time-and-clocks-and-ordering-of-events-in-a-distributed-system-cdd3f6075e73 中有讨论。
分布式系统中的状态管理在 https://conferences.oreilly.com/software-architecture/sa-ny-2018/public/schedule/detail/64127 中有讨论。
17.6 问题讨论
1. 负载均衡器是一种中间件。中间件能增强可修改性但会降低性能,然而负载均衡器的存在是为了提高性能。解释这个明显的矛盾。
2. 上下文图展示一个实体以及它与之通信的其他实体。它将分配给所选实体的职责与分配给其他实体的职责分开,并展示完成所选实体职责所需的交互。绘制一个负载均衡器的上下文图。
3. 概述在云中分配虚拟机并显示其 IP 地址的一系列步骤。
4. 研究一个主要云提供商提供的服务。编写一组用于管理你将在此云上实现的服务的自动扩展的规则。
5. 一些负载均衡器使用一种称为消息队列的技术。研究消息队列并描述使用和不使用消息队列的负载均衡器之间的差异。
第18章 移动系统
与 Yazid Hamdi 和 Greg Hartman 合作完成
电话将被用来通知人们电报已发出。
——亚历山大·格拉汉姆·贝尔(Alexander Graham Bell)
那么,亚历山大・格雷厄姆・贝尔(Alexander Graham Bell)到底知道些什么呢?移动系统,包括尤其是手机,在当今世界无处不在。除了手机之外,它们还包括火车、飞机和汽车;它们包括轮船和卫星、娱乐和个人计算设备以及机器人系统(自主或非自主的);它们基本上包括任何与持续充足电源没有永久连接的系统或设备。
移动系统具有在移动过程中持续提供部分或全部功能的能力。这使得处理其某些特性与处理固定系统有所不同。在本章中,我们将重点关注其中的五个特性:
- 能源。移动系统的电源有限,必须关注高效用电。
- 网络连接性。移动系统往往在移动过程中通过与其他设备交换信息来实现其大部分功能。因此,它们必须与这些设备连接,但它们的移动性使这些连接变得棘手。
- 传感器和执行器。移动系统往往比固定系统从传感器获取更多信息,并且它们经常使用执行器与环境交互。
- 资源。移动系统往往比固定系统受到更多资源限制。一方面,它们通常体积很小,以至于物理封装成为一个限制因素。另一方面,它们的移动性常常使重量成为一个因素。必须小巧轻便的移动设备在可提供的资源方面存在限制。
- 生命周期。移动系统的测试与其他系统的测试有所不同。部署新版本也会带来一些特殊问题。
当为移动平台设计系统时,你必须处理大量特定领域的需求。自动驾驶汽车和自主无人机必须安全;智能手机必须为各种各样差异极大的应用程序提供一个开放平台;娱乐系统必须能处理多种内容格式和服务提供商。在本章中,我们将重点关注许多(如果不是全部的话)移动系统共有的特性,这些特性是架构师在设计系统时必须考虑的。
18.1 能源
在本节中,我们重点关注与管理移动系统能源最相关的架构问题。对于许多移动设备而言,其能源来源是电池,而电池的能量供应能力非常有限。其他移动设备,如汽车和飞机,依靠发电机产生的电力运行,而发电机又可能由使用燃料的发动机提供动力 —— 这同样是一种有限的资源。
架构师的关注点
架构师必须关注电源监测、控制能源使用以及容忍断电情况。我们将在接下来的三个小节中详细阐述这些关注点。
监测电源
在关于能源效率的 [第 6 章][ch06] 中,我们介绍了一类称为 “资源监测” 的策略,用于监测作为能源消耗者的计算资源的使用情况。在移动系统中,我们需要监测能源来源,以便在可用能量变低时能够启动适当的行为。具体而言,在由电池供电的移动设备中,我们可能需要告知用户电池电量低,将设备切换到省电模式,提醒应用程序设备即将关机以便它们为重启做准备,并确定每个应用程序的功耗。
所有这些用途都依赖于监测电池的当前状态。大多数笔记本电脑或智能手机使用智能电池作为电源。智能电池是一种带有内置电池管理系统(BMS)的可充电电池组。可以查询 BMS 以获取电池的当前状态。其他移动系统可能使用不同的电池技术,但都具有某种等效功能。在本节中,我们将假设读数表示剩余电量的百分比。
电池供电的移动系统包含一个组件(通常位于操作系统内核中),该组件知道如何与 BMS 交互,并能根据请求返回当前电池容量。电池管理器负责定期查询该组件以获取电池状态。这使得系统能够告知用户能量状态,并在必要时触发省电模式。为了通知应用程序设备即将关机,应用程序必须向电池管理器注册。
电池随着使用时间增长有两个特性会发生变化:最大电池容量和最大持续电流。架构师必须考虑在可用功率不断变化的范围内管理能耗,以便设备仍能在可接受的水平上运行。监测在配备发电机的系统中也起着作用,因为当发电机输出较低时,一些应用程序可能需要关闭或进入待机状态。电池管理器还可以确定哪些应用程序当前处于活动状态以及它们的能耗是多少。然后可以根据此信息估算电池容量总体变化的百分比。
当然,电池管理器本身会占用资源 —— 内存和 CPU 时间。电池管理器消耗的 CPU 时间量可以通过调整查询间隔来管理。
控制能源使用
可以通过终止或降低系统中消耗能量的部分来减少能耗;这就是 [第 6 章][ch06] 中描述的控制使用策略。具体的实现方式取决于系统的各个组件,但一个常见的例子是降低智能手机显示屏的亮度或刷新率。其他控制能源使用的技术包括减少处理器的活动核心数量、降低核心的时钟频率以及减少传感器读数的频率。例如,不要每隔几秒就获取一次 GPS 位置数据,而是改为大约每分钟获取一次。不要依赖诸如 GPS 和手机信号塔等不同的位置数据源,而只使用其中一个。
容忍断电
移动系统应该妥善地容忍断电和重启情况。例如,此类系统的一项要求可能是在恢复供电后,系统能在 30 秒内重新启动并以正常模式运行。这一要求意味着对系统不同部分有不同要求,例如以下方面:
- 硬件要求示例:
- 如果在任何时候断电,系统的计算机不会遭受永久性损坏。
- 只要提供足够的电力,系统的计算机就能稳健地(重新)启动操作系统。
- 系统的操作系统在准备就绪后就能立即启动计划中的软件。
- 软件要求示例:
- 运行时环境可以随时被终止,而不会影响永久存储中的二进制文件、配置和操作数据的完整性,并且在重启(无论是重置还是恢复)后保持状态一致。
- 应用程序需要一种策略来处理在其不运行时到达的数据。
- 运行时能够在故障后启动,使得从系统上电到软件处于就绪状态的启动时间小于规定时长。
18.2 网络连接性
在本节中,我们重点关注与移动系统网络连接性最相关的架构问题。我们将聚焦于移动平台与外部世界之间的无线通信。网络可能被用于控制设备或发送和接收信息。
无线网络根据其作用距离进行分类。
- 在 4 厘米以内:近场通信(NFC)用于钥匙卡和非接触式支付系统。GSM 联盟正在制定这一领域的标准。
- 在 10 米以内:IEEE 802.15 系列标准涵盖了这个距离范围。蓝牙和 Zigbee 是这一类别中的常见协议。
- 在 100 米以内:IEEE 802.11 系列标准(Wi-Fi)在这个距离范围内使用。
- 在几公里以内:IEEE 802.16 标准涵盖了这个距离范围。WiMAX 是 IEEE 802.16 标准的商业名称。
- 超过几公里:这通过蜂窝通信或卫星通信实现。
在所有这些类别中,技术和标准都在迅速发展。
架构师的关注点
设计通信和网络连接性要求架构师平衡大量关注点,包括以下内容:
- 要支持的通信接口数量:由于存在各种不同的协议且它们在快速发展,架构师很容易想要包含所有可能的网络接口。但设计移动系统的目标恰恰相反:应仅包含严格需要的接口,以优化功耗、热量产生和空间分配。
- 从一种协议转换到另一种协议:尽管需要采用极简主义方法处理接口,但架构师必须考虑到在一个会话过程中,移动系统可能从支持一种协议的环境移动到支持另一种协议的环境的可能性。例如,视频可能正在通过 Wi-Fi 流传输,但随后系统可能移动到没有 Wi-Fi 的环境,视频将通过蜂窝网络接收。这种转换对用户来说应该是无缝的。
- 动态选择合适的协议:如果多个协议同时可用,系统应根据成本、带宽和功耗等因素动态选择一个协议。
- 可修改性:鉴于大量的协议及其快速发展,在移动系统的生命周期内,很可能需要支持新的或替代的协议。系统应设计为支持涉及通信的系统元素的更改或替换。
- 带宽:应分析要与其他系统通信的信息的距离、容量和延迟要求,以便做出适当的架构选择。这些协议在这些特性方面各不相同。
- 间歇性 / 有限 / 无连接:设备在移动过程中可能会失去通信(例如,智能手机穿过隧道)。系统应设计为在连接丢失的情况下保持数据完整性,并且在连接恢复时能够无一致性损失地恢复计算。系统应设计为妥善处理有限连接甚至无连接的情况。应动态提供降级和回退模式以应对此类情况。
- 安全性:移动设备特别容易受到欺骗、窃听和中间人攻击,因此应对此类攻击应是架构师关注的一部分。
18.3 传感器和执行器
传感器 是一种检测其环境的物理特性并将这些特性转换为电子表示的设备。移动设备收集环境数据,要么是为了指导自身的操作(例如无人机中的高度计),要么是将该数据报告回给用户(例如你智能手机中的磁罗盘)。
换能器 感应外部电子脉冲并将其转换为更有用的内部形式。在本节中,我们将使用 “传感器” 一词来涵盖换能器,并假设电子表示是数字的。
传感器中枢 是一个协处理器,有助于整合来自不同传感器的数据并进行处理。传感器中枢可以帮助将这些任务从产品的主中央处理器上卸载下来,从而节省电池消耗并提高性能。
在移动系统内部,软件将抽象出环境的某些特性。这种抽象可能直接映射到一个传感器,例如温度或压力的测量,或者它可能整合多个传感器的输入,例如在自动驾驶汽车控制器中识别出的行人。
执行器 与传感器相反:它以数字表示作为输入并在环境中引起某种动作。汽车中的车道保持辅助功能利用执行器,就像你的智能手机发出的音频警报一样。
架构师的关注点
架构师在传感器方面有几个关注点:
- 如何根据传感器输入创建环境的准确表示。
- 系统应如何响应环境的这种表示。
- 传感器数据和执行器命令的安全性和隐私性。
- 降级操作。如果传感器发生故障或无法读取,系统应进入降级模式。例如,如果在隧道中无法获得 GPS 读数,系统可以使用航位推算技术来估计位置。
由系统创建并对其采取行动的环境表示是特定于领域的,降级操作的适当方法也是如此。我们在 [第 8 章][ch08] 中详细讨论了安全性和隐私性,但在这里我们将只关注第一个关注点:根据传感器返回的数据创建环境的准确表示。这是通过传感器栈来实现的 —— 一组设备和软件驱动程序的组合,有助于将原始数据转换为关于环境的解释信息。
不同的平台和领域往往有自己的传感器栈,并且传感器栈通常带有自己的框架,以便更容易地处理设备。随着时间的推移,传感器可能会涵盖越来越多的功能;反过来,特定栈的功能也会随时间而变化。在这里,我们列举了无论特定分解将它们放置在何处,栈中必须实现的一些功能:
- 读取原始数据。栈的最低层是一个用于读取原始数据的软件驱动程序。该驱动程序直接读取传感器,或者在传感器是传感器中枢的一部分的情况下,通过中枢读取传感器。驱动程序定期从传感器获取读数。周期频率是一个参数,它将影响读取和处理传感器的处理器负载以及所创建表示的准确性。
- 平滑数据。原始数据通常有很大的噪声或变化。电压变化、传感器上的污垢或灰尘以及无数其他原因可能使传感器的两次连续读数不同。平滑是一个使用一段时间内的一系列测量来产生一个估计值的过程,这个估计值往往比单次读数更准确。计算移动平均值和使用卡尔曼滤波器是平滑数据的众多技术中的两种。
- 转换数据。传感器可以以多种格式报告数据 —— 从毫伏的电压读数到以英尺为单位的海拔高度再到摄氏度的温度。然而,两个测量同一现象的不同传感器可能以不同的格式报告其数据。转换器负责将传感器报告的任何形式的读数转换为对应用程序有意义的通用形式。正如你可能想象的那样,这个功能可能需要处理各种各样的传感器。
- 传感器融合。传感器融合将来自多个传感器的数据组合起来,以建立比任何单个传感器都更准确、更完整或更可靠的环境表示。例如,汽车如何在白天或夜晚、各种天气条件下识别其路径中或在其到达时可能在其路径中的行人?没有单个传感器可以完成这一壮举。相反,汽车必须智能地组合来自热成像仪、雷达、激光雷达和摄像头等传感器的输入。
18.4 资源
在本节中,我们从物理特性的角度讨论计算资源。例如,在能源来自电池的设备中,我们需要关注电池的体积、重量和热性能。对于网络、处理器和传感器等资源也是如此。
资源选择中的权衡在于所考虑的特定资源的贡献与其体积、重量和成本之间。成本始终是一个因素。成本包括制造成本和非经常性工程成本。许多移动系统是以数百万计制造的,并且对价格高度敏感。因此,处理器价格的微小差异乘以嵌入该处理器的系统的数百万副本数量,可能会对生产该系统的组织的盈利能力产生重大影响。批量折扣和跨不同产品重复使用硬件是设备供应商用来降低成本的技术。
体积、重量和成本是由组织的市场部门和其使用的物理考虑因素所给出的约束。市场部门关注客户的反应。设备使用的物理考虑因素取决于人为因素和使用因素。智能手机显示屏必须足够大,以便人能够阅读;汽车受到道路重量限制的约束;火车受到轨道宽度的约束等等。
对移动系统资源(以及因此对软件架构师)的其他约束反映了以下因素:
- 安全考虑。具有安全后果的物理资源不得发生故障或必须有备份。备用处理器、网络或传感器会增加成本和重量,并占用空间。例如,许多飞机都有一个紧急电源,在发动机故障时可以使用。
- 热限制。热量可以由系统本身产生(想想你放置笔记本电脑的膝盖),这可能对系统性能产生不利影响,甚至导致故障。环境的环境温度 —— 过高或过低 —— 也可能产生影响。在做出硬件选择之前,应该了解系统将在其中运行的环境。
- 其他环境问题。其他问题包括暴露于潮湿或灰尘等不利条件下或被掉落。
架构师的关注点
架构师必须围绕资源及其使用做出许多重要决策:
-
将任务分配给电子控制单元(ECU)。较大的移动系统,如汽车或飞机,有多个不同功率和容量的 ECU。软件架构师必须决定哪些子系统将分配给哪些 ECU。这个决策可以基于以下几个因素:
- ECU 与功能的适配性。功能必须分配给有足够能力执行该功能的 ECU。一些 ECU 可能有专门的处理器;例如,带有图形处理器的 ECU 更适合图形功能。
- 关键程度。更强大的 ECU 可能被保留用于关键功能。例如,发动机控制器比舒适功能子系统更关键且更可靠。
- 在车辆中的位置。头等舱乘客可能比二等舱乘客有更好的 Wi-Fi 连接。
- 连接性。一些功能可能分布在几个 ECU 之间。如果是这样,它们必须在同一个内部网络上并且能够相互通信。
- 通信的局部性。将相互之间强烈通信的组件放在同一个 ECU 上会提高它们的性能并减少网络流量。
- 成本。通常制造商希望最小化部署的 ECU 数量。
- 将功能卸载到云端。诸如路线确定和模式识别等应用可以部分由移动系统本身(传感器所在的位置)执行,部分由驻留在云端的应用部分执行(那里有更多的数据存储和更强大的处理器可用)。架构师必须确定移动系统是否有足够的能力执行特定功能,是否有足够的连接性来卸载一些功能,以及当功能在移动系统和云端之间分割时如何满足性能要求。架构师还应考虑本地可用的数据存储、数据更新间隔和隐私问题。
- 根据操作模式关闭功能。未使用的子系统可以缩小其占用空间,允许竞争的子系统访问更多资源,从而提供更好的性能。在跑车上,一个例子是开启 “竞赛模式”,这会禁用负责根据道路轮廓计算舒适悬挂参数的进程,并激活扭矩分配、制动功率、悬挂硬化和离心力的计算。
- 信息显示策略。这个问题与可用的显示分辨率相关。在 320×320 像素的显示屏上可以进行 GPS 风格的映射,但需要付出很多努力来最小化显示屏上的信息。在 1280×720 的分辨率下,有更多的像素,所以信息显示可以更丰富。(能够更改显示屏上的信息是诸如 MVC 模式的强大动力 [见 [第 13 章][ch13]],以便可以根据特定的显示特性交换视图)。
18.5 生命周期
移动系统的生命周期往往具有一些架构师需要考虑的特殊之处,这些与为传统(非移动)系统所做的选择不同。我们继续深入探讨。
架构师的关注点
架构师必须关注硬件选择、测试、部署更新和日志记录。我们将在接下来的四个小节中详细阐述这些关注点。
硬件优先
对于许多移动系统,硬件是在软件设计之前被选择的。因此,软件架构必须适应所选硬件带来的约束。
早期硬件选择的主要利益相关者是管理层、销售部门和监管机构。他们的关注点通常集中在降低风险的方法上,而不是提升质量属性的方法上。对于软件架构师来说,最好的方法是积极推动这些早期讨论,强调其中的权衡,而不是被动地等待结果。
测试
移动设备在测试方面有一些独特的考虑因素:
- 测试显示布局。智能手机和平板电脑有各种各样的形状、大小和宽高比。验证所有这些设备上的布局正确性很复杂。一些操作系统框架允许从单元测试中操作用户界面,但可能会遗漏一些不愉快的边界情况。例如,假设你在屏幕上显示控制按钮,布局用 HTML 和 CSS 指定,并且假设它是为你预期使用的所有显示设备自动生成的。对于一个非常小的显示屏,一个幼稚的生成可能会在 1×1 像素上产生一个控制,或者控制在显示屏的边缘,或者控制重叠。这些在测试期间很容易被遗漏。
-
测试操作边界情况:
- 应用程序应该在电池耗尽和系统关闭的情况下存活下来。在这种情况下,状态的保存需要得到确保并进行测试。
- 用户界面通常与提供功能的软件异步操作。当用户界面没有正确反应时,重现导致问题的事件序列是困难的,因为问题可能取决于时间,或者取决于当时正在进行的特定操作集。
- 测试资源使用情况。一些供应商会向软件架构师提供他们设备的模拟器。这很有帮助,但用模拟器测试电池使用情况是有问题的。
- 测试网络转换。确保系统在有多个通信网络可用时做出最佳选择也很困难。当设备从一个网络移动到另一个网络(例如,从 Wi-Fi 网络到蜂窝网络,然后再到另一个 Wi-Fi 网络)时,用户应该不会察觉到这些转换。
交通或工业系统的测试往往在四个层面上进行:单个软件组件层面、功能层面、设备层面和系统层面。它们之间的层面和边界可能因系统而异,但在一些参考流程和标准中是隐含的,如汽车软件过程改进及能力评定(Automotive SPICE)。
例如,假设我们正在测试汽车的车道保持辅助功能,车辆在道路标记所定义的车道内行驶,并且无需驾驶员输入即可做到。对这个系统的测试可能涉及以下层面:
- 软件组件。一个车道检测软件组件将通过单元测试和端到端测试的常用技术进行测试,目的是验证软件的稳定性和正确性。
- 功能。下一步是在模拟环境中运行软件组件以及车道保持辅助功能的其他组件,例如用于识别高速公路出口的地图组件。目的是在功能的所有组件一起工作时验证接口和安全并发。在这里,模拟器用于向软件功能提供与车辆在有标记的道路上行驶相对应的输入。
- 设备。捆绑的车道保持辅助功能,即使它在模拟环境和开发计算机上通过了测试,也需要部署在其目标电子控制单元(ECU)上,并在那里进行性能和稳定性测试。在这个设备测试阶段,环境仍然是模拟的,但这次是通过连接到 ECU 端口的模拟外部输入(来自其他 ECU 的消息、传感器输入等)。
- 系统。在最后的系统集成测试阶段,所有具有所有功能和所有组件的设备都构建成全尺寸配置,首先在测试实验室中,然后在测试原型中。例如,车道保持辅助功能可以与它对转向和加速 / 制动功能的作用一起进行测试,同时提供投影图像或道路视频。这些测试的作用是确认集成的子系统一起工作并提供所需的功能和系统质量属性。
这里的一个重要点是测试可追溯性:如果在步骤 4 中发现问题,它需要在所有测试设置中可重现和可追溯,因为修复必须再次通过所有四个测试层面。
部署更新
在移动设备中,系统更新要么修复问题,要么提供新功能,要么安装未完成但可能在早期版本发布时部分安装的功能。这样的更新可能针对软件、数据或(较少情况下)硬件。例如,现代汽车需要软件更新,可以通过网络获取或通过 USB 接口下载。除了在操作期间提供更新能力之外,以下具体问题与部署更新有关:
- 保持数据一致性。对于消费设备,升级往往是自动的且单向的(没有办法回滚到早期版本)。这表明将数据保存在云端是个好主意 —— 但然后需要测试云端和应用程序之间的所有交互。
- 安全性。架构师需要确定系统的哪些状态可以安全地支持更新。例如,在汽车在高速公路上行驶时更新汽车的发动机控制软件是个坏主意。这反过来意味着系统需要了解与更新相关的安全状态。
- 部分系统部署。重新部署整个应用程序或大型子系统将消耗带宽和时间。应用程序或子系统应该进行架构设计,以便经常变化的部分可以很容易地更新。这需要一种特定类型的可修改性(见 [第 8 章][ch08])和对可部署性的关注(见 [第 5 章][ch05])。此外,更新应该容易且自动化。访问设备的物理部分进行更新可能很麻烦。回到发动机控制器的例子,更新控制器软件不应该需要访问发动机。
-
可扩展性。移动车辆系统往往具有相对较长的寿命。在某些时候,对汽车、火车、飞机、卫星等进行改装可能会变得必要。改装意味着通过替换或添加将新技术添加到旧系统中。这可能是由于以下原因:
- 组件在整个系统达到其寿命终点之前达到其寿命终点。寿命终点意味着支持将停止,这在出现故障时会产生高风险:将没有可信赖的来源以合理的成本获得答案或支持 —— 也就是说,无需对有问题的组件进行剖析和逆向工程。
- 出现了更新更好的技术,促使进行硬件 / 软件升级。一个例子是用与智能手机连接的信息娱乐系统改装 21 世纪初的汽车,而不是旧的收音机 / CD 播放器。
- 有新的技术可用,可以在不替换现有功能的情况下添加功能。例如,假设 21 世纪初的汽车根本没有收音机 / CD 播放器,或者没有倒车摄像头。
日志
在调查和解决已经发生或可能发生的事件时,日志至关重要。在移动系统中,日志应该搬到一个无论移动系统本身是否可访问都可以访问的位置。这不仅对事件处理有用,而且对系统的使用进行各种类型的分析也很有用。许多软件应用程序在遇到问题时会做类似的事情,并请求允许将详细信息发送给供应商。对于移动系统,这种日志记录功能特别重要,并且它们很可能不会请求获得数据的许可。
18.6 小结
移动系统涵盖了广泛的形式和应用,从智能手机和平板电脑到汽车和飞机等交通工具。我们将移动系统与固定系统之间的差异归类为基于五个特征:能源、连接性、传感器、资源和生命周期。
许多移动系统的能源来自电池。对电池进行监测,以确定电池的剩余使用时间以及各个应用程序的能耗。可以通过限制单个应用程序的能耗来控制能源使用。应用程序应构建为能够在电源故障时存活下来,并在电源恢复时无缝重新启动。
连接性意味着通过无线方式连接到其他系统和互联网。无线通信可以通过短距离协议(如蓝牙)、中距离协议(如 Wi-Fi 协议)和长距离蜂窝协议进行。当从一种协议类别移动到另一种协议类别时,通信应该是无缝的,并且诸如带宽和成本等考虑因素有助于架构师决定支持哪些协议。
移动系统利用各种传感器。传感器提供外部环境的读数,架构师随后使用这些读数在系统内部开发外部环境的表示。传感器读数由特定于每个操作系统的传感器栈处理;这些栈将提供对表示有意义的读数。可能需要多个传感器才能开发出有意义的表示,然后将这些传感器的读数进行融合(整合)。传感器也可能随着时间的推移而性能下降,因此可能需要多个传感器才能准确表示正在测量的现象。
资源具有物理特性(如尺寸和重量)、具有处理能力并且有成本。设计选择涉及在这些因素之间进行权衡。关键功能可能需要更强大和可靠的资源。某些功能可以在移动系统和云端之间共享,并且某些功能在某些模式下可以关闭,以释放资源供其他功能使用。
生命周期问题包括硬件选择、测试、部署更新和日志记录。与固定系统相比,移动系统的用户界面测试可能更加复杂。同样,由于带宽、安全考虑和其他问题,部署也更加复杂。
18.7 扩展阅读
电池大学(https://batteryuniversity.com/)有关于各种类型电池及其测量的大量资料,多到超出你的需求。
你可以在以下网站上阅读更多关于各种网络协议的内容:
link-labs.com/blog/complete-list-iot-network-protocols
https://en.wikipedia.org/wiki/Wireless_ad_hoc_network
https://searchnetworking.techtarget.com/tutorial/Wireless-protocols-learning-guide
https://en.wikipedia.org/wiki/IEEE_802
你可以在 [[Gajjarby 17][ref_96]] 中了解更多关于传感器的信息。
一些移动应用程序的测试工具可以在以下两个网站找到:
https://codelabs.developers.google.com/codelabs/firebase-test-lab/index.html#0
https://firebase.google.com/products/test-lab
菲利普・库普曼(Philip Koopman)在 Slideshare 上的演讲 “自动驾驶汽车安全历险记” 讨论了使自动驾驶汽车安全所涉及的一些困难:slideshare.net/PhilipKoopman1/adventures-in-self-driving-car-safety?qid=eb5f5305-45fb-419e-83a5-998a0b667004&v=&b=&from_search=3。
你可以在automotivespice.com上了解有关汽车软件过程改进及能力评定(Automotive SPICE)的信息。
ISO 26262《道路车辆:功能安全》是汽车电气和 / 或电子系统功能安全的国际标准(iso.org/standard/68383.html)。
18.8 问题讨论
1. 为设计一个能够容忍完全断电并能够在不损害数据完整性的情况下从上次中断的地方重新启动的系统,你会做出哪些架构选择?
2. 在网络转换中涉及哪些架构问题,例如通过蓝牙开始文件传输,然后移出蓝牙范围并切换到 Wi-Fi,同时保持传输无缝进行?
3. 确定你拥有的一个移动系统中电池的重量和尺寸。你认为架构师因为尺寸和重量做出了哪些妥协?
4. CSS 测试工具可以发现哪些类型的问题?它会遗漏哪些问题?这些考虑因素如何影响移动设备的测试?
5. 考虑一个星际探测器,例如美国国家航空航天局(NASA)火星探测计划中使用的那些探测器。它符合移动设备的标准吗?描述其能源特性、网络连接性问题(显然,[18.2 节][ch18sec02] 中讨论的网络类型都无法胜任这项任务)、传感器、资源问题和特殊的生命周期考虑因素。
6. 不要将移动性视为一类计算系统,而是将其视为一种质量属性,如安全性或可修改性。为移动性编写一个通用场景。为你选择的一个移动设备编写一个特定的移动性场景。描述一组实现 “移动性” 质量属性的策略。
7. [18.5 节][ch18sec05] 讨论了移动系统中更具挑战性的几个测试方面。[第 12 章][ch12] 中的哪些可测试性策略可以帮助解决这些问题?
第四部分 可扩展的架构实践
第19章 架构重要需求
软件开发最重要的一个方面是明确你正在尝试构建的东西是什么。
——比雅尼·斯特劳斯特鲁普(Bjarne Stroustrup),C++ 语言创造者
架构的存在是为了构建满足需求的系统。这里的 “需求” 并不一定是指使用需求工程所能提供的最佳技术生成的一份有文档记录的目录。相反,我们指的是这样一组特性:如果你的系统不能满足这些特性,那么这个系统就会失败。需求以与软件开发项目一样多的形式存在 —— 从精心编写的规范到主要利益相关者之间的口头共同理解(真实的或想象的)。你的项目需求实践的技术、经济和哲学依据超出了本书的范围。在本书范围内的是,无论需求以何种方式被捕获,它们都确立了成功或失败的标准,而架构师需要了解这些标准。
对架构师来说,并非所有需求都是同等重要的。有些需求对架构的影响比其他需求大得多。一个 “架构重要需求(ASR)” 是一个将对架构产生深远影响的需求 —— 也就是说,如果没有这样的需求,架构很可能会有很大的不同。
如果你不知道 ASR,就不可能设计出一个成功的架构。ASR 通常(但不总是)以质量属性(QA)需求的形式出现 —— 即架构必须为系统提供的性能、安全性、可修改性、可用性、易用性等等。在 [第 4 章][ch04] 至 [第 14 章][ch14] 中,我们介绍了实现质量属性的模式和策略。每次你在架构中选择一个模式或策略来使用时,都是因为需要满足质量属性需求。质量属性需求越困难、越重要,就越有可能对架构产生重大影响,因此也就越有可能是一个 ASR。
架构师必须确定 ASR,通常是在做了大量工作以发现候选 ASR 之后。有能力的架构师知道这一点。事实上,当我们观察有经验的架构师履行他们的职责时,我们会注意到他们做的第一件事就是开始与重要的利益相关者交谈。他们正在收集他们需要的信息,以产生能够响应项目需求的架构 —— 无论这些信息之前是否已经被确定。
本章提供了一些系统的技术来确定 ASR 以及其他将塑造架构的因素。
19.1 从需求文档中收集架构重要需求(ASRs)
寻找候选架构重要需求的一个明显位置是在需求文档或用户故事中。毕竟,我们正在寻找需求,而需求应该(嗯)在需求文档中。不幸的是,情况通常并非如此,尽管需求文档中的信息肯定是有用的。
不要抱太大希望
许多项目不会创建或维护软件工程课程教授或传统软件工程书籍作者喜欢规定的那种需求文档。此外,没有架构师会只是坐着等待需求 “完成” 后才开始工作。架构师必须在需求仍在变化时就开始工作。因此,当架构师开始工作时,质量属性需求很可能是不确定的。即使它们存在且稳定,需求文档通常在两个方面让架构师失望:
-
需求规格说明中找到的大部分信息不会影响架构。正如我们反复看到的,架构主要由质量属性需求驱动或 “塑造”,质量属性需求决定并约束最重要的架构决策。即便如此,大多数需求规格说明的绝大部分内容都集中在系统所需的特性和功能上,而这些对架构的影响最小。最佳的软件工程实践确实规定要捕获质量属性需求。例如,软件工程知识体系(SWEBOK)指出,质量属性需求与其他任何需求一样:如果它们很重要,就必须被捕获,并且应该明确指定且可测试。
但在实践中,我们很少看到对质量属性需求的充分捕获。你有多少次看到过 “系统应具有模块化” 或 “系统应具有高可用性” 或 “系统应满足用户的性能期望” 这样的需求?这些都不是有用的需求,因为它们不可测试;它们不可证伪。但是,从好的方面看,它们可以被视为邀请架构师开始讨论这些领域的真正需求是什么。
-
即使是最好的需求文档中也找不到对架构师有用的很多内容。许多驱动架构的关注点根本不会在正在被指定的系统中作为可观察的事物显现出来,因此也不是需求规格说明的主题。架构重要需求通常源自开发组织本身的业务目标;我们将在 [第 19.3 节][ch19sec03] 中探讨这种联系。开发质量也不在范围内;例如,你很少会看到描述团队合作假设的需求文档。在采购环境中,需求文档代表采购方的利益,而不是开发方的利益。利益相关者、技术环境和组织本身都在影响架构方面发挥作用。当我们在 [第 20 章][ch20] 中讨论架构设计时,我们将更详细地探讨这些需求。
从需求文档中找出架构重要需求
虽然需求文档不能向架构师讲述完整的故事,但它们仍然是架构重要需求的一个重要来源。当然,架构重要需求不会被方便地标记出来;架构师应该预期进行一些调查和挖掘工作以找出它们。
一些具体要寻找的信息类别如下:
- 使用情况:用户角色与系统模式、国际化、语言差异。
- 时间方面:及时性和元素协调。
- 外部元素:外部系统、协议、传感器或执行器(设备)、中间件。
- 网络方面:网络属性和配置(包括其安全属性)。
- 编排方面:处理步骤、信息流。
- 安全属性:用户角色、权限、认证。
- 数据方面:持久性和时效性。
- 资源方面:时间、并发性、内存占用、调度、多用户、多活动、设备、能源使用、软资源(例如缓冲区、队列)以及可扩展性需求。
- 项目管理方面:团队组建计划、技能组合、培训、团队协调。
- 硬件选择方面:处理器、处理器系列、处理器的演进。
- 功能灵活性、可移植性、校准、配置。
- 指定的技术、商业软件包。
任何关于它们计划或预期演进的已知信息也将是有用的信息。
这些类别不仅本身在架构上具有重要意义,而且每一个的可能变化和演进也很可能在架构上具有重要意义。即使你正在挖掘的需求文档没有提到演进,也要考虑前面列表中的哪些项目可能随时间而变化,并相应地设计系统。
19.2 通过访谈利益相关者收集架构重要需求
假设你的项目没有生成一份全面的需求文档。或者也许有需求文档,但在你需要开始设计工作的时候,质量属性需求还没有确定下来。你该怎么办呢?
首先,利益相关者通常不知道他们的质量属性需求实际上是什么。在这种情况下,架构师被要求帮助为系统设定质量属性需求。认识到这种合作需求并鼓励合作的项目比不这样做的项目更有可能成功。珍惜这个机会!不断地纠缠你的利益相关者也不会突然让他们拥有必要的洞察力。如果你坚持要定量的质量属性需求,你可能会得到一些随意的数字,而且至少其中一些需求将很难满足,最终实际上会减损系统的成功。
有经验的架构师通常对类似系统所表现出的质量属性响应有深刻的见解,并且知道在当前情况下哪些质量属性响应是合理预期和可以提供的。架构师通常也可以快速反馈哪些质量属性响应容易实现,哪些可能有问题甚至难以实现。
例如,利益相关者可能要求 24/7 的可用性 —— 谁不想要呢?然而,架构师可以解释这个需求可能要花费多少成本,这将给利益相关者提供信息,以便在可用性和可负担性之间进行权衡。此外,架构师是对话中唯一可以说 “我实际上可以交付一个比你想象中更好的架构 —— 这对你有用吗?” 的人。
访谈相关利益相关者是了解他们所知道的和需要的最可靠方法。再次强调,一个项目应该以系统、清晰和可重复的方式捕获这些关键信息。可以通过许多方法从利益相关者那里收集这些信息。其中一种方法是质量属性研讨会(QAW),如侧边栏中所述。
质量属性研讨会
质量属性研讨会(QAW)是一种在软件架构完成之前,以促进者引导、以利益相关者为中心的方法,用于生成、确定优先级并细化质量属性场景。它强调系统级别的关注点,特别是软件在系统中将发挥的作用。QAW 非常依赖系统利益相关者的参与。
在介绍和概述研讨会步骤之后,QAW 包括以下要素:
- 业务 / 任务介绍:代表系统背后业务关注点的利益相关者(通常是经理或管理代表)花费大约一小时介绍系统的业务背景、广泛的功能需求、约束和已知的质量属性需求。在后续步骤中将要细化的质量属性将主要从本步骤中介绍的业务 / 任务需求中得出。
- 架构计划介绍:虽然可能不存在详细的系统或软件架构,但有可能已经创建了广泛的系统描述、上下文图或其他工件,这些描述了系统的一些技术细节。在研讨会的这个时候,架构师将介绍现有的系统架构计划。这让利益相关者了解现有的架构思路。
- 确定架构驱动因素:促进者将分享他们在前两个步骤中整理的关键架构驱动因素列表,并请利益相关者进行澄清、添加、删除和更正。目的是就一份精简的架构驱动因素列表达成共识,其中包括总体需求、业务驱动因素、约束和质量属性。
- 场景头脑风暴:每个利益相关者表达一个场景,代表他们对系统的关注点。促进者通过指定明确的触发事件和响应来确保每个场景都解决了一个质量属性关注点。
- 场景整合:在场景头脑风暴之后,在合理的情况下整合相似的场景。促进者要求利益相关者识别那些内容非常相似的场景。只要提出这些场景的人同意并且觉得他们的场景在这个过程中不会被淡化,相似的场景就可以合并。
- 场景优先级确定:场景的优先级确定是通过给每个利益相关者分配一定数量的选票来完成的,选票数量等于整合后生成的场景总数的 30%。利益相关者可以将他们的任何数量的选票分配给任何一个场景或场景组合。对选票进行计数,并相应地确定场景的优先级。
- 场景细化:在确定优先级之后,对最重要的场景进行细化和详细阐述。促进者帮助利益相关者将场景放入我们在 [第 3 章][ch03] 中描述的六部分场景形式,即来源 - 触发事件 - 制品 - 环境 - 响应 - 响应度量。随着场景的细化,围绕其满足的问题将会出现,应该被记录下来。这个步骤持续的时间取决于时间和资源的允许。
利益相关者访谈的结果应包括一份架构驱动因素列表和一组由利益相关者(作为一个群体)确定优先级的质量属性场景。此信息可用于以下目的:
- 细化系统和软件需求。
- 理解并澄清系统的架构驱动因素。
- 为架构师随后做出某些设计决策提供理由。
- 指导原型和模拟的开发。
- 影响架构开发的顺序。
我不知道那个需求应该是什么
在采访利益相关者并探寻架构重要需求时,他们常常抱怨 “我不知道那个需求应该是什么”,这种情况并不少见。虽然他们确实有这样的感受,但他们通常也对这个需求有所了解,特别是如果利益相关者在该领域有经验的话。在这种情况下,引出他们的 “某些了解” 远比你自己凭空编造需求要好得多。例如,你可以问:“系统对这个交易请求应该多快做出响应?” 如果回答是 “我不知道”,我的建议是装糊涂。你可以说:“那么……24 小时可以吗?” 通常得到的回应是愤怒和惊讶的 “不!”“那么,1 小时怎么样?”“不!”“5 分钟呢?”“不!”“10 秒钟怎么样?”“嗯,(嘟囔,咕哝)我想我可以接受类似这样的……”
通过装糊涂,你常常可以让人们至少给你一个可接受的值的范围,即使他们不知道需求的确切值应该是多少。而这个范围通常足以让你选择架构机制。对架构师来说,24 小时、10 分钟、10 秒钟和 100 毫秒的响应时间意味着选择非常不同的架构方法。有了这些信息,你现在就可以做出明智的设计决策了。
—RK
19.3 通过理解业务目标收集架构重要需求
业务目标是构建一个系统的存在理由。没有一个组织会毫无理由地构建一个系统;相反,参与其中的人希望推进他们的组织以及他们自己的使命和抱负。常见的业务目标当然包括盈利,但大多数组织所关心的远不止盈利这一点。在其他一些组织中(例如非营利组织、慈善机构、政府部门),盈利是最不被考虑的事情。
业务目标对架构师来说很有意义,因为它们常常直接导致架构重要需求。业务目标和架构之间有三种可能的关系:
- 业务目标常常导致质量属性需求。每一个质量属性需求 —— 比如用户可见的响应时间、平台灵活性、坚如磐石的安全性或者其他十几种需求中的任何一种 —— 都源于某种更高的目的,可以用附加值来描述。想要使一个产品与竞争对手区分开来并让开发组织占领市场份额的愿望可能会导致对看起来异常快速的响应时间的需求。此外,了解一个特别严格的需求背后的业务目标,能使架构师以有意义的方式质疑这个需求 —— 或者调集资源来满足它。
- 业务目标可能影响架构而根本不产生质量属性需求。一位软件架构师告诉我们,几年前他向他的经理提交了一份架构的早期草案。经理指出架构中缺少一个数据库。架构师很高兴经理注意到了这一点,并解释了他(架构师)是如何设计出一种方法,从而避免了使用庞大而昂贵的数据库的需求。然而,经理坚持要求设计中包含一个数据库,因为组织有一个数据库部门,雇用了一些高薪的技术人员,他们目前没有任务,需要工作。没有任何需求规格说明会捕获这样的需求,也没有任何经理会允许这样的动机被捕获。然而,从经理的角度来看,如果那个架构在没有数据库的情况下被交付,就会像没有交付一个重要功能或质量属性一样有缺陷。
- 业务目标对架构没有影响。并非所有的业务目标都会导致质量属性。例如,“降低成本” 的业务目标可以通过在冬天降低设施的恒温器温度或者降低员工的工资或养老金来实现。
[图 19.1][ch19fig01] 说明了本次讨论的主要观点。在图中,箭头表示 “导致”。实线箭头突出了对架构师最有意义的关系。

图 19.1 一些业务目标可能会导致质量属性需求,或者直接导致架构决策,或者导致非架构性的解决方案。
架构师通常通过潜移默化的方式 —— 工作、倾听、交谈以及吸收组织中正在起作用的目标 —— 来了解组织的业务和业务目标。潜移默化并非没有好处,但确定这些目标的更系统的方法既是可能的也是可取的。此外,明确地捕获业务目标是值得的,因为它们常常隐含着架构重要需求,否则这些需求在发现时可能已经太晚或者解决成本太高。
一种实现这一目标的方法是采用 PALM 方法,这需要架构师和关键业务利益相关者一起举办研讨会。PALM 的核心包括以下步骤:
- 引出业务目标。使用本节后面给出的类别来引导讨论,从利益相关者那里捕获这个系统的重要业务目标集合。详细阐述业务目标,并将其表示为业务目标场景。[^1] 合并几乎相同的业务目标以消除重复。让参与者确定所得集合的优先级,以识别出最重要的目标。
- 从业务目标中识别潜在的质量属性。对于每个重要的业务目标场景,让参与者描述一个质量属性和响应度量值,如果将其构建到系统中,将有助于实现该目标。
在捕获业务目标的过程中,准备一组候选业务目标作为谈话的起点会很有帮助。例如,如果你知道许多企业都想获得市场份额,你可以利用这种动机让组织中的合适利益相关者参与进来:“对于这个产品,我们在市场份额方面的雄心是什么,架构如何能为实现这些目标做出贡献?”
我们对业务目标的研究使我们采用了如下列表中所示的类别。这些类别可以作为头脑风暴和引出目标的辅助工具。通过使用类别列表,并询问利益相关者每个类别中可能的业务目标,可以获得一定程度的覆盖保证。
- 组织的成长和连续性
- 实现财务目标
- 实现个人目标
- 对员工负责
- 对社会负责
- 对国家负责
- 对股东负责
- 管理市场地位
- 改进业务流程
- 管理产品的质量和声誉
- 管理环境随时间的变化
19.4 在效用树中捕获架构重要需求
在一个完美的世界中,[19.2 节][ch19sec02] 和 [19.3 节][ch19sec03] 中描述的技术会在你的开发过程早期就被应用:你会采访关键利益相关者,引出他们的业务目标和驱动架构的需求,并让他们为你确定所有这些输入的优先级。当然,遗憾的是,现实世界并不完美。通常情况下,由于组织或业务原因,当你需要这些利益相关者时,你却无法接触到他们。那么你该怎么办呢?
当需求的 “主要来源” 不可用时,架构师可以使用一种称为 “效用树” 的结构。效用树是一种自上而下的表示,展示了你作为架构师认为对系统成功至关重要的与质量属性相关的架构重要需求。
效用树以 “效用” 一词作为根节点。效用是系统整体 “良好程度” 的一种表达。然后,你通过列出系统需要展现的主要质量属性来详细阐述这个根节点。(你可能还记得我们在 [第 3 章][ch03] 中说过,质量属性名称本身并不是很有用。别担心 —— 它们只是作为后续阐述和细化的中间占位符被使用!)
在每个质量属性下,记录该质量属性的具体细化内容。例如,性能可以分解为 “数据延迟” 和 “事务吞吐量”,或者也可以是 “用户等待时间” 和 “网页刷新时间”。你选择的细化内容应该是与你的系统相关的。在每个细化内容下,你可以接着记录具体的架构重要需求,以质量属性场景的形式表示。
一旦架构重要需求以场景的形式被记录下来并放置在树的叶子节点上,你就可以根据两个标准来评估这些场景:候选场景的业务价值和实现它的技术风险。你可以使用任何你喜欢的尺度,但我们发现一个简单的 “H”(高)、“M”(中)和 “L”(低)评分系统对于每个标准来说就足够了。对于业务价值,“高” 表示必须具备的需求,“中” 表示重要但如果省略也不会导致项目失败的需求,“低” 表示满足起来很好但不值得付出太多努力的需求。对于技术风险,“高” 意味着满足这个架构重要需求会让你夜不能寐,“中” 表示满足这个架构重要需求令人担忧但风险不高,“低” 表示你有信心满足这个架构重要需求。
[表 19.1][ch19tab01] 展示了一个医疗领域系统的效用树的一部分示例。每个架构重要需求都标有其业务价值和技术风险的指示。
| 质量属性 | 属性细化 | ASR 场景 |
|---|---|---|
| 性能 | 事务响应时间 | 一个用户在系统处于峰值负载时响应地址变更通知来更新患者的账户,并且该事务在不到 0.75 秒内完成。(高业务价值,高风险)。 |
| 吞吐量 | 在峰值负载下,系统每秒能够完成150个标准化事务。(中价值,中风险) | |
| 易用性 | 熟练度训练 | 有两年或以上业务经验的新员工经过一周的培训,可以在不到 5 秒内执行该系统的任何核心功能。(中价值,低风险)。 |
| 运营效率 | 一名医院收费员在与患者互动的同时为患者启动支付计划,并在没有输入错误的情况下完成该流程。(中价值,中风险)。 | |
| 可配置性 | 数据可配置性 | 一家医院提高了某项服务的费用。配置团队在一个工作日内完成变更并进行测试;无需更改源代码。(高价值,低风险)。 |
| 可维护性 | 例行更改 | 一名维护人员遇到响应时间不足的问题,修复了这个错误,并发布了错误修复,所花费的努力不超过 3 个人工日。(高价值,中风险)。 |
| 一项报告要求需要对生成报告的元数据进行更改。更改在 4 个人工时内完成并进行测试。(中价值,低风险)。 | ||
| 升级到商业组件 | 数据库供应商发布了一个新的主要版本,该版本在不到 3 个人周的时间内成功进行了测试和安装。(高价值,中风险)。 | |
| 添加新功能 | 一个追踪血库捐赠者的功能在 2 个人月内被创建并成功集成。(中价值,中风险)。 | |
| 安全 | 保密性 | 物理治疗师被允许查看患者病历中与骨科治疗相关的部分,但不能查看其他部分或任何财务信息。(高价值,中风险)。 |
| 抵御攻击 | 系统抵御未经授权的入侵尝试,并在 90 秒内向有关部门报告该尝试。(高价值,中风险)。 | |
| 可用性 | 无停机时间 | 数据库供应商发布新软件,该软件被热插拔到位,没有停机时间。(高价值,中低风险)。 |
| 该系统支持患者全年无休(24/7/365)通过网络访问账户。(中价值,中风险)。 |
一旦你填好了效用树,就可以用它来进行重要的检查。例如:
- 没有任何 ASR 场景的 QA(质量属性)或 QA 细化不一定是需要纠正的错误或遗漏,而是表明你应该调查在那个区域是否存在未记录的 ASR 场景。
- 获得(高价值,高风险)评级的 ASR 场景显然是最值得你关注的;这些是重要需求中最为重要的。大量这样的场景可能会让人担心这个系统实际上是否可实现。
19.5 变化总会发生
爱德华・贝拉尔说过:“如果两者都被冻结,那么在水面上行走和根据规格开发软件都很容易。” 本章中的任何内容都不应被认为这种神奇的情况有可能存在。需求(无论是否被捕获)一直在变化。架构师必须适应并跟上变化,以确保他们的架构仍然是正确的,能够为项目带来成功。在 [第 25 章][ch25] 中,我们讨论架构能力时,我们会建议架构师需要成为出色的沟通者,这意味着要成为出色的双向沟通者,既要接收信息也要提供信息。始终与确定 ASR 的关键利益相关者保持沟通渠道畅通,这样你就可以跟上不断变化的需求。本章提供的方法可以重复应用以适应变化。
比跟上变化更好的是领先变化一步。如果你听到 ASR 即将发生变化的风声,你可以采取初步措施进行针对该变化的设计,作为理解其影响的练习。如果这个变化的成本高得令人望而却步,与利益相关者分享这个信息将是一个有价值的贡献,而且他们越早知道越好。更有价值的可能是提出一些在满足目标方面(几乎)同样有效的但又不会超出预算的变更建议。
19.6 小结
架构是由具有架构重要性的需求所驱动的。一个架构重要需求(ASR)必须具备:
- 对架构有深远影响。包含这个需求很可能会导致与不包含它时不同的架构。
- 具有高业务或任务价值。如果架构要满足这个需求(可能以不满足其他需求为代价)那么它对重要的利益相关者必须具有高价值。
架构重要需求可以从需求文档中提取,可以在研讨会(例如,质量属性研讨会(QAW))期间从利益相关者那里捕获,可以在效用树中从架构师那里捕获,或者从业务目标中推导出来。将它们记录在一个地方是很有帮助的,这样可以对列表进行审查、参考,用于证明设计决策的合理性,并在随着时间的推移或在重大系统变更的情况下重新审视。
在收集这些需求时,你应该留意组织的业务目标。业务目标可以用一种常见的、结构化的形式表达,并表示为业务目标场景。可以使用 PALM(一种结构化的引导方法)来引出并记录这些目标。
质量属性(QA)需求的一种有用表示是效用树。这样的图形描述有助于以结构化的形式捕获这些需求,从粗略、抽象的质量属性概念开始,逐渐细化到以场景的形式捕获它们。然后对这些场景进行优先级排序,这个优先级集合作为架构师的 “行动指令”。
19.7 扩展阅读
可在opengroup.org/togaf/获取的开放组架构框架提供了一个完整的模板,用于记录包含大量有用信息的业务场景。尽管我们认为架构师可以使用更轻量级的方法来捕获业务目标,但它值得一看。
质量属性研讨会的权威参考资料是 [[Barbacci 03][ref_12]]。
“架构重要需求” 这一术语是由软件架构审查与评估(SARA)小组创造的,作为可在 http://pkruchten.wordpress.com/architecture/SARAv1.pdf 检索到的文档的一部分。
《软件工程知识体系》(SWEBOK)第三版可在此处下载:computer.org/education/bodies-of-knowledge/software-engineering/v3。在我们付印之时,第四版正在开发中。
关于 PALM 的完整描述 [[Clements 10b][ref_64]] 可在此处找到:https://resources.sei.cmu.edu/asset_files/TechnicalNote/2010_004_001_15179.pdf。
19.8 问题讨论
1. 采访你所在公司或大学正在使用的业务系统的具有代表性的利益相关者,并为其确定至少三个业务目标。为此,请使用 “扩展阅读” 部分中提到的 PALM 的七部分业务目标场景大纲。
2. 根据你在问题 1 中发现的业务目标,提出一组相应的架构重要需求(ASR)。
3. 为自动取款机(ATM)创建一个效用树。(如果你希望你的朋友和同事提供质量属性方面的考虑和场景,可以采访他们。)考虑至少四种不同的质量属性。确保你在叶节点创建的场景有明确的响应和响应度量。
4. 找到一个你认为高质量的软件需求规格说明。使用彩色笔(如果文档是打印的,就用真正的彩色笔;如果文档是在线的,就用虚拟的彩色笔),将你认为与该系统的软件架构完全无关的所有内容涂成红色。将你认为可能相关但需要进一步讨论和阐述的所有内容涂成黄色。将你确定具有架构重要性的所有内容涂成绿色。当你完成后,文档中除了空白部分之外的每一部分都应该是红色、黄色或绿色。你的文档最终每种颜色大约占多少百分比?结果让你感到惊讶吗?
[^1]: 业务目标场景是一种结构化的七部分表达式,用于捕获业务目标,在意图和用途上与质量属性场景类似。本章的“进一步阅读”部分包含一个参考资料,它详细描述了 PALM 和业务目标场景。
第20章 设计架构
与 Humberto Cervantes 合作
设计师知道,不是当没有什么可以添加的时候,而是当没有什么可以删减的时候,他才达到了完美。
——安托万·德·圣埃克苏佩里(Antoine de Saint-Exupéry)
设计 —— 包括架构设计 —— 是一项复杂的活动。它涉及做出无数决策,这些决策要考虑到系统的许多方面。在过去,这项任务只委托给拥有数十年来之不易经验的高级软件工程师 —— 大师。一种系统的方法为执行这项复杂活动提供指导,以便普通人也能学习并胜任这项工作。
在本章中,我们详细讨论一种方法 —— 属性驱动设计(ADD),它允许以系统、可重复且具有成本效益的方式设计架构。可重复性和可传授性是一门工程学科的标志。为了使一种方法具有可重复性和可传授性,我们需要一套任何经过适当培训的工程师都能遵循的步骤。
我们首先提供 ADD 及其步骤的概述。在此概述之后,对一些关键步骤进行更详细的讨论。
20.1 属性驱动设计
软件系统的架构设计与一般设计并无不同:它涉及做出决策,并利用可用的材料和技能来满足需求和约束。在架构设计中,我们将关于架构驱动因素的决策转化为结构,如[图 20.1][ch20fig01] 所示。架构驱动因素包括具有架构重要性的需求(ASRs—— [第 19 章][ch19] 的主题),但也包括功能、约束、架构关注点和设计目的。由此产生的结构随后以我们在 [第 2 章][ch02] 中阐述的多种方式指导项目:它们指导分析和构建。它们作为教育新的项目成员的基础。它们指导成本和进度估算、团队组建、风险分析和缓解,当然还有实现。

在开始架构设计之前,确定系统的范围很重要——即正在创建的系统中哪些部分在内部,哪些部分在外部,以及系统将与哪些外部实体进行交互。这个上下文可以使用系统上下文图来表示,就像 [图 20.2][ch20fig02] 中所示的那样。上下文图在 [第 22 章][ch22] 中有更详细的讨论。

在属性驱动设计(ADD)中,架构设计以轮次进行,每一轮可能由一系列设计迭代组成。一轮包括在一个开发周期内执行的架构设计活动。通过一次或多次迭代,你会生成一个适合本轮既定设计目的的架构。
在每次迭代中,会执行一系列设计步骤。ADD 对每次迭代中需要执行的步骤提供了详细的指导。[图 20.3][ch20fig03] 展示了与 ADD 相关的步骤和工件。在图中,步骤 1 至 7 构成一轮。在一轮中,步骤 2 至 7 构成一轮中的一次或多次迭代。在以下小节中,我们将对这些步骤中的每一个进行概述。

20.2 ADD的步骤
接下来的部分描述了属性驱动设计(ADD)的步骤。
步骤 1:评审输入
在开始一轮设计之前,你需要确保架构驱动因素(设计过程的输入)是可用且正确的。这些包括:
- 本轮设计的目的。
- 主要功能需求。
- 主要质量属性(QA)场景。
- 任何约束条件。
- 任何关注点。
为什么我们要明确捕获设计目的呢?你需要确保自己清楚本轮的目标。在包含多轮的增量设计环境中,一轮设计的目的可能是,例如,为早期估算生成设计、细化现有设计以构建系统的新增量,或者设计并生成原型以减轻某些技术风险。此外,如果这不是全新开发,你需要了解现有架构的设计。
在这一点上,主要功能 —— 通常以一组用例或用户故事的形式捕获 —— 和 QA 场景应该已经被确定了优先级,理想情况下是由你最重要的项目利益相关者确定。(你可以使用几种不同的技术来引出并确定它们的优先级,如在 [第 19 章][ch19] 中所讨论的)。作为架构师,你现在必须 “拥有” 这些。例如,你需要检查在原始需求引出过程中是否有任何重要的利益相关者被忽略,以及自确定优先级以来是否有任何业务条件发生了变化。这些输入确实 “驱动” 着设计,所以正确地获得它们以及正确地确定它们的优先级是至关重要的。我们再怎么强调这一点也不为过。软件架构设计,就像软件工程中的大多数活动一样,是一个 “输入垃圾,清出垃圾” 的过程。如果输入形式不佳,ADD 的结果就不会好。
这些驱动因素成为架构设计待办事项的一部分,你应该使用它来执行不同的设计迭代。当你做出的设计决策考虑了待办事项中的所有项目时,你就完成了这一轮。(我们将在 [第 20.8 节][ch20sec08] 中更深入地讨论待办事项的概念。)
步骤 2 至 7 构成了在本轮设计中执行的每次设计迭代的活动。
步骤 2:通过选择驱动因素建立迭代目标
每次设计迭代都专注于实现一个特定目标。这样的目标通常涉及进行设计以满足一部分驱动因素。例如,一个迭代目标可以是从元素中创建结构,以实现特定的性能场景或一个用例。因此,在进行设计活动时,你需要在开始特定的设计迭代之前确定一个目标。
步骤 3:选择要优化的一个或多个系统元素
满足驱动因素需要你做出架构设计决策,这些决策随后会在一个或多个架构结构中体现出来。这些结构由相互关联的元素组成 —— 模块和 / 或组件,如 [第 1 章][ch01] 所定义 —— 并且这些元素通常是通过细化你在先前迭代中确定的其他元素而获得的。细化可以意味着分解为更细粒度的元素(自顶向下方法)、将元素组合为更粗粒度的元素(自底向上方法)或者改进先前确定的元素。对于全新开发,你可以从建立系统上下文开始,然后选择唯一可用的元素 —— 即系统本身 —— 通过分解进行细化。对于现有系统或全新系统中的后续设计迭代,你通常会选择细化在先前迭代中确定的元素。
你将选择的元素是那些与满足特定驱动因素相关的元素。因此,当设计针对现有系统时,你需要很好地理解作为系统已构建架构一部分的那些元素。获取此信息可能涉及一些 “侦查工作”、逆向工程或与开发人员进行讨论。
在某些情况下,你可能需要颠倒步骤 2 和步骤 3 的顺序。例如,当设计一个全新系统或充实某些类型的参考架构时,至少在设计的早期阶段,你将专注于系统的元素,并通过选择特定元素开始迭代,然后考虑你想要解决的驱动因素。
步骤 4:选择一个或多个满足所选驱动因素的设计概念
选择设计概念可能是你在设计过程中面临的最困难的决定,因为这需要你确定各种可能合理地用于实现迭代目标的设计概念,然后从这些备选方案中进行选择。有许多不同类型的设计概念可供选择——例如,策略、模式、参考架构和外部开发的组件——并且对于每种类型,可能存在许多选项。这可能会导致在做出最终选择之前需要分析相当多的备选方案。在 [第 20.3 节][ch20sec03] 中,我们将更详细地讨论设计概念的识别和选择。
步骤5:实例化架构元素、分配职责和定义接口
一旦你选择了一个或多个设计概念,你必须做出另一种类型的设计决策:如何从你刚刚选择的设计概念中 “实例化” 元素。例如,如果你选择分层模式作为设计概念,你必须决定将使用多少层以及它们之间允许的关系,因为模式本身并没有规定这些。
在实例化元素之后,你需要为每个元素分配职责。例如,在一个应用程序中,通常至少存在三层:表示层、业务层和数据层。这些层的职责不同:表示层的职责包括管理所有的用户交互,业务层管理应用程序逻辑并执行业务规则,数据层管理数据的持久性和一致性。
实例化元素只是创建满足驱动因素或关注点的结构的一部分。已实例化的元素也需要连接起来,从而使它们能够相互协作。这需要在元素之间存在 “关系”,并通过某种接口进行信息交换。接口是一种合同规范,指示信息应如何在元素之间流动。在 [第 20.4 节][ch20sec04] 中,我们将更详细地介绍不同类型的设计概念是如何实例化的、结构是如何创建的以及接口是如何定义的。
步骤 6:绘制视图草图并记录设计决策
此时,你已经完成了本次迭代的设计活动。然而,你可能还没有采取任何行动来确保视图(你所创建的结构的表示)得以保留。例如,如果你在会议室中执行了步骤 5,那么你很可能在白板上得到了一系列图表。这些信息对后续流程至关重要,你必须记录下来,以便稍后分析并传达给其他利益相关者。记录视图的方法可以简单到给白板拍照。
你所创建的视图几乎肯定是不完整的;因此,这些图表可能需要在后续迭代中进行重新审视和细化。这通常是为了容纳因你为支持其他驱动因素而做出的其他设计决策所产生的元素。这就是为什么我们在属性驱动设计(ADD)中说 “绘制草图” 形式的视图,这里的 “草图” 指的是一种初步类型的文档。这些视图更正式、更完整的文档(如果你选择生成的话,见 [第 22 章][ch22])仅在设计迭代完成后才会产生(作为架构文档活动的一部分)。
除了记录视图的草图之外,你还应该记录在设计迭代中做出的重要决策以及促使这些决策的原因(即基本原理),以便于以后对这些决策进行分析和理解。例如,关于重要权衡的决策应该在此时记录下来。在设计迭代过程中,决策主要在步骤 4 和步骤 5 中做出。在 [第 20.5 节][ch20sec05] 中,我们解释了如何在设计过程中创建初步文档,包括记录设计决策及其基本原理。
步骤 7:对当前设计进行分析,并审查迭代目标以及设计目的的达成情况
到步骤 7 时,你应该已经创建了一个部分设计,该设计解决了为迭代确定的目标。确保实际情况确实如此是个好主意,这样可以避免利益相关者不满意以及后续返工。你可以通过审查你记录的视图草图和设计决策来自己进行分析,但让别人帮助你审查这个设计是个更好的主意。我们这样做的原因与组织经常设立单独的测试 / 质量保证小组的原因相同:另一个人不会与你有相同的假设,并且会有不同的经验基础和不同的视角。这种多样性有助于在代码和架构中发现 “漏洞”。我们在 [第 21 章][ch21] 中更深入地讨论架构分析。
一旦对迭代中执行的设计进行了分析,你应该根据既定的设计目的审查架构的状态。这意味着考虑此时你是否已经进行了足够的设计迭代以满足与本轮设计相关的驱动因素。这也意味着考虑设计目的是否已经实现,或者在未来的项目增量中是否需要额外的设计轮次。在第 20.6 节中,我们讨论了一些简单的技术,可让你跟踪设计进度。
必要时迭代
应该针对每个被考虑的驱动因素进行额外的迭代,并重复步骤 2 至 7。然而,由于时间或资源的限制,这种重复往往是不可能的,这些限制会迫使你停止设计活动并转向实现。
评估是否需要更多设计迭代的标准是什么?让 “风险” 作为你的指南。你至少应该已经解决了具有最高优先级的驱动因素。理想情况下,你应该确定关键驱动因素已得到满足,或者至少确定设计 ““足够好” 以满足它们。
20.3 关于 ADD 步骤 4 的更多内容:选择一个或多个设计概念
在大多数时候,作为架构师,你不需要也不应该重新发明轮子。相反,你的主要设计活动是识别和选择设计概念,以应对最重要的挑战并在设计迭代中解决关键驱动因素。设计仍然是一项具有原创性和创造性的努力,但创造性在于恰当地识别这些现有的解决方案,然后将它们组合并调整以适应手头的问题。即使有现有的解决方案库可供选择——而且我们并不总是有幸拥有丰富的解决方案库——这仍然是设计中最困难的部分。
设计概念的识别
设计概念的识别可能看起来令人生畏,因为有大量的选项可供选择。可能有几十种设计模式和外部开发的组件可用于解决任何特定问题。更糟糕的是,这些设计概念分散在许多不同的来源中:在从业者的博客和网站、研究文献以及书籍中。此外,在许多情况下,一个概念没有标准的定义。例如,不同的网站会以不同的、很大程度上是非正式的方式定义代理模式。最后,一旦你确定了可能有助于实现迭代设计目标的替代方案,你需要为你的目的选择最佳的一个或多个方案。
为了解决特定的设计问题,你可以并且经常会使用和组合不同类型的设计概念。例如,为了解决安全驱动因素,你可能会采用安全模式、安全策略、安全框架或这些的某种组合。
一旦你对希望使用的设计概念类型有了更清晰的认识,你仍然需要确定替代方案 —— 即设计候选方案。你可以通过几种方式实现这一点,尽管你可能会结合使用这些技术而不是单一的方法:
- 利用现有最佳实践。你可以通过利用现有目录来确定替代方案。一些设计概念,如模式,有大量的文档记录;其他的,如外部开发的组件,则记录得不太全面。这种方法的好处是你可以确定许多替代方案,并利用他人的大量知识和经验。缺点是搜索和研究这些信息可能需要相当多的时间,记录知识的质量通常是未知的,作者的假设和偏见也是未知的。
- 利用你自己的知识和经验。如果你正在设计的系统与你过去设计的其他系统相似,你可能会想从你以前使用过的一些设计概念开始。这种方法的好处是可以快速而自信地确定替代方案。缺点是你可能最终会反复使用相同的想法,即使它们不是最适合你面临的所有设计问题的,或者如果它们已经被更新、更好的方法所取代。正如俗话所说:如果你只有一把锤子,那么整个世界看起来都像钉子。
- 利用他人的知识和经验。作为一名架构师,你拥有多年来积累的背景和知识。这种背景和知识因人而异,特别是如果他们过去处理的设计问题类型不同。你可以通过与一些同行进行头脑风暴来进行设计概念的识别和选择,从而利用这些信息。
设计理念的选择
一旦你确定了一系列替代设计概念,你就需要选择其中哪个替代方案最适合解决手头的设计问题。你可以通过一种相对简单的方式来实现这一点,即创建一个表格,列出与每个替代方案相关的优缺点,并根据这些标准和你的驱动因素选择其中一个替代方案。该表格还可以包含其他标准,例如与使用该替代方案相关的成本。像 SWOT(优势、劣势、机会、威胁)分析这样的方法可以帮助你做出这个决定。
在识别和选择设计概念时,请记住作为架构驱动因素一部分的约束条件,因为某些约束会限制你选择特定的替代方案。例如,一个约束可能是所有的库和框架必须采用经批准的许可证。在这种情况下,即使你找到了一个可能满足你的需求的框架,但如果它没有经批准的许可证,你可能需要放弃它。
你还需要记住,你在先前迭代中做出的关于选择设计概念的决策可能会由于不兼容性而限制你现在可以选择的设计概念。例如,在初始迭代中选择了一个 Web 架构,然后在后续迭代中为本地应用程序选择一个用户界面框架。
原型的创建
如果前面提到的分析技术不能指导你做出恰当的设计概念选择,你可能需要创建原型并从它们那里收集测量数据。创建早期的 “一次性” 原型是一种有助于选择外部开发组件的有用技术。这种类型的原型通常在创建时不考虑可维护性、可重用性或实现其他重要目标的可能性。这样的原型不应该被用作进一步开发的基础。
虽然创建原型可能成本高昂,但某些情况强烈促使人们这样做。在考虑是否应该创建原型时,问问这些问题:
- 项目是否包含新兴技术?
- 该技术在公司中是新的吗?
- 是否有某些驱动因素,特别是质量属性,使用所选技术满足这些因素存在风险(即,不清楚它们是否能够被满足)?
- 是否缺乏可信赖的内部或外部信息,这些信息能在一定程度上确定所选技术将对满足项目驱动因素有用?
- 是否有与该技术相关的配置选项需要进行测试或理解?
- 不清楚所选技术是否能容易地与项目中使用的其他技术集成吗?
如果对这些问题的大多数回答是 “是”,那么你应该强烈考虑创建一个一次性原型。
创建原型还是不创建原型?
架构决策通常必须在信息不完整的情况下做出。为了决定走哪条路,一个团队可以进行一系列实验(例如构建原型),以试图减少他们对应该遵循哪条路径的不确定性。问题是,这样的实验可能会带来巨大的成本,而且从实验中得出的结论可能不是确定的。
例如,假设一个团队需要决定他们正在设计的系统应该基于传统的三层架构还是应该由微服务组成。由于这是该团队第一个使用微服务的项目,他们对这种方法没有信心。他们对这两种选择进行成本估算,预计开发三层架构的成本为 50 万美元,开发微服务的成本为 65 万美元。如果在开发了三层架构之后,团队后来得出结论认为选择了错误的架构,估计的重构成本将为 30 万美元。如果首先开发微服务架构,并且后来需要进行重构,估计的额外成本将为 10 万美元。
这个团队应该怎么做呢?
为了决定进行实验是否值得,或者相对于要获得的信心和犯错的成本,我们应该在实验上花费多少,团队可以使用一种称为信息价值(VoI)的技术来解决这些问题。信息价值技术用于通过某种形式的数据收集活动(在这种情况下是构建原型)来计算由于围绕决策的不确定性降低而带来的预期收益。要使用信息价值,团队需要评估以下参数:做出错误设计选择的成本、进行实验的成本、团队对每个设计选择的信心水平以及他们对实验结果的信心水平。使用这些估计值,信息价值然后应用贝叶斯定理来计算两个量:完全信息的预期价值(EVPI)和样本或不完全信息的预期价值(EVSI)。EVPI 表示如果实验能够提供确定的结果(例如,没有假阳性或假阴性),人们应该愿意为实验支付的最大金额。EVSI 表示在知道实验结果可能无法 100% 确定地识别正确解决方案的情况下,人们应该愿意花费多少。
由于这些结果代表预期值,因此应在团队的风险偏好的背景下进行评估。
—Eduardo Miranda
20.4 关于属性驱动设计(ADD)步骤 5 的更多内容:生成结构
设计概念本身并不能帮助你满足驱动因素,除非你生成 “结构”;也就是说,你需要识别并连接从所选设计概念中派生出来的元素。这是属性驱动设计(ADD)中架构元素的 “实例化” 阶段:创建元素以及它们之间的关系,并将职责与这些元素相关联。回想一下,软件系统的架构由一组结构组成。正如我们在 [第 1 章][ch01] 中看到的,这些结构可以分为三大类:
- 模块结构,由开发时存在的元素组成,如文件、模块和类。
- 组件和连接器(C&C)结构,由运行时存在的元素组成,如进程和线程。
- 分配结构,由软件元素(来自模块或 C&C 结构)和在开发时和运行时都可能存在的非软件元素组成,如文件系统、硬件和开发团队。
当你实例化一个设计概念时,实际上你可能会影响不止一种结构。例如,在特定的迭代中,你可能会实例化 [第 4 章][ch04] 中介绍的被动冗余(温备用)模式。这将产生一个 C&C 结构和一个分配结构。作为应用此模式的一部分,你需要选择备用数量、备用状态与活动节点状态保持一致的程度、管理和转移状态的机制以及检测节点故障的机制。这些决策是职责,必须存在于模块结构的元素中的某个地方。
实例化元素
以下是每个设计概念类别可能的实例化方式:
- 参考架构:对于参考架构,实例化通常意味着进行某种定制。这将需要你添加或删除参考架构所定义的结构中的元素。例如,如果你正在设计一个需要与外部应用程序通信以处理支付的 Web 应用程序,你可能需要在传统的表示层、业务层和数据层旁边添加一个集成组件。
- 模式:模式提供了一个由元素及其关系和职责组成的通用结构。由于这个结构是通用的,你需要将其调整以适应你的特定问题。实例化通常涉及将模式定义的通用结构转换为适应你正在解决的问题需求的特定结构。例如,考虑客户端 - 服务器架构模式。它确定了计算的基本元素(即客户端和服务器)及其关系(即连接和通信),但没有指定对于你的问题应该使用多少个客户端或服务器,每个的功能应该是什么,哪些客户端应该与哪些服务器通信,或者它们应该使用哪种通信协议。实例化填补了这些空白。
- 策略:这种设计概念没有规定特定的结构。因此,要实例化一个策略,你可以调整另一种类型的设计概念(你已经在使用的)以实现该策略。或者,你可以利用一个无需任何调整就已经实现该策略的设计概念。例如,你可以(1)选择 “对参与者进行身份验证” 的安全策略,并通过你编织到预先存在的登录过程中的自定义编码解决方案来实例化它;或者(2)采用包含参与者身份验证的安全模式;或者(3)集成一个外部开发的组件,如对参与者进行身份验证的安全框架。
- 外部开发的组件:这些组件的实例化可能意味着创建新元素,也可能不意味着创建新元素。例如,对于面向对象的框架,实例化可能要求你创建从框架中定义的基类继承的新类。这将产生新元素。一个不涉及创建新元素的例子是为选定的技术指定配置选项,例如线程池中线程的数量。
分配职责并确定属性
当你通过实例化设计概念来创建元素时,你需要考虑分配给这些元素的职责。例如,如果你实例化微服务架构模式([第 5 章][ch05]),你需要决定微服务将做什么、每种微服务你将部署多少个以及这些微服务的属性是什么。在实例化元素并分配职责时,你应该牢记设计原则,即元素应具有高内聚性(内部)、由一小组职责定义,并表现出低耦合性(外部)。
在实例化设计概念时,你需要考虑的一个重要方面是元素的属性。这可能涉及诸如配置选项、有状态性、资源管理、优先级,甚至是你所选择技术的硬件特性(如果创建的元素是物理节点)等方面。确定这些属性有助于分析和记录你的设计原理。
建立元素之间的关系
结构的创建还需要针对元素之间存在的关系及其属性做出决策。再次考虑客户端 - 服务器模式。在实例化此模式时,你需要决定哪些客户端将通过哪些端口和协议与哪些服务器通信。你还需要决定通信是同步的还是异步的。谁发起交互?传输多少信息以及以何种速率传输?
这些设计决策对于实现诸如性能等质量属性可能会产生重大影响。
定义接口
接口建立了一个契约规范,允许元素进行协作和交换信息。它们可以是外部的也可以是内部的。
外部接口是你的系统必须与之交互的其他系统的接口。这些可能会对你的系统形成约束,因为你通常无法影响它们的规范。正如我们之前提到的,在设计过程开始时建立系统上下文对于识别外部接口很有用。由于外部实体和正在开发的系统通过接口进行交互,所以每个外部系统至少应该有一个外部接口(如图 20.2 所示)。
内部接口是设计概念实例化所产生的元素之间的接口。为了确定关系和接口细节,你需要了解元素如何相互作用以支持用例或质量属性场景。正如我们在 [第 15 章][ch15] 讨论软件接口时所说,“相互作用” 意味着一个元素所做的任何可能影响另一个元素处理的事情。一种特别常见的交互类型是运行时的信息交换。
诸如 UML 序列图、状态图和活动图等行为表示(见 [第 22 章][ch22])允许你对执行期间元素之间交换的信息进行建模。这种类型的分析对于识别元素之间的关系也很有用:如果两个元素需要直接交换信息或以其他方式相互依赖,那么这些元素之间就存在关系。任何交换的信息都成为接口规范的一部分。
接口的识别通常不会在所有设计迭代中以相同的方式进行。例如,当你开始设计一个全新的系统时,你的第一次迭代只会产生诸如层这样的抽象元素;这些元素将在后续迭代中进行细化。像层这样的抽象元素的接口通常是未充分指定的。例如,在早期迭代中,你可能只是简单地指定用户界面层向业务逻辑层发送 “命令”,而业务逻辑层返回 “结果”。随着设计过程的进行,特别是当你创建结构以解决特定用例和质量属性场景时,你将需要细化参与这些交互的元素的接口。
在一些特殊情况下,识别适当的接口可能会大大简化。例如,如果你选择一个完整的技术栈或一组被设计为可互操作的组件,那么接口将已经由这些技术定义。在这种情况下,接口的规范是一个相对简单的任务,因为所选择的技术已经 “内置” 了许多接口假设和决策。
最后,请注意,在任何给定的属性驱动设计(ADD)迭代中,并非所有内部接口都需要被识别。有些可以委托给后续的设计活动。
20.5 关于属性驱动设计(ADD)步骤 6 的更多内容:在设计过程中创建初步文档
正如我们将在 [第 22 章][ch22] 中看到的,软件架构被记录为一组“视图”,这些视图代表了组成架构的不同结构。这些视图的正式文档记录不是属性驱动设计(ADD)的一部分。然而,结构是作为设计的一部分被生成的。即使它们只是以非正式的方式(如草图)表示,也应该记录这些结构从而能够让你创建这些结构的设计决策,这是作为正常 ADD 活动的一部分应该执行的任务。
记录视图草图
当你通过实例化你为解决特定设计问题而选择的设计概念来生成结构时,你通常不仅会在脑海中生成这些结构,还会为它们创建一些 “草图”。在最简单的情况下,你可以在白板、活动挂图、绘图工具上,甚至只是在一张纸上绘制这些草图。此外,你可以使用建模工具以更严谨的方式绘制这些结构。你所绘制的草图是你架构的初始文档,你应该记录下来,并且如果有必要,可以在以后进一步完善。当你创建草图时,你不一定需要使用像 UML 这样更正式的语言 —— 尽管如果你对这个过程很熟练且感到舒适,那么请使用它。如果你使用一些非正式的符号,你应该注意在符号的使用上保持一致性。最终,你需要为你的图表添加一个图例以提供清晰性并避免歧义。
在创建结构时,你应该养成写下分配给元素的职责的习惯。原因很简单:当你确定一个元素时,你正在脑海中确定该元素的一些职责。在那个时刻把它们写下来可以确保你以后不必记住预期的职责。此外,逐渐写下与你的元素相关的职责比在以后一起记录所有职责要容易得多。
在设计架构时创建这个初步文档需要一些规则。不过,这样做的好处是值得付出努力的,因为你以后能够相对容易且快速地生成更详细的架构文档。如果你正在使用白板或活动挂图,一个简单的记录职责的方法是拍摄你所绘制的草图的照片,并将其粘贴在一个文档中,同时附上一个表格,总结图表中描绘的每个元素的职责(参见 [图 20.4][ch20fig04] 中的一个示例)。如果你使用设计工具,你可以选择一个元素进行创建,并使用通常出现在元素属性表中的文本区域来记录其职责,然后自动生成文档。

该图表由一个描述元素职责的表格作为补充。[表 20.1][ch20tab01] 为 [图 20.4][ch20fig04] 中确定的一些元素起到了这个作用。
| 元素 | 职责 |
|---|---|
| 数据流 | 此元素实时从所有数据源收集数据,并将其分发到批处理组件和快速处理组件进行处理。 |
| 批处理 | 这负责存储原始数据并预先计算要存储在服务组件中的批处理视图。 |
| . . . | . . . |
当然,在这个阶段没有必要记录所有的内容。文档的三个目的是分析、构建和教育。在你进行设计的时候,你应该根据你的风险缓解关注点选择一个文档目的,然后为了实现这个目的进行记录。例如,如果你有一个关键的质量属性场景需要你的架构设计来满足,并且如果你需要在分析中证明所提出的设计满足这个标准,那么你必须注意记录与分析相关的信息,以便分析令人满意。同样地,如果你预计需要培训新团队成员,那么你应该绘制系统的组件和连接器视图,展示它是如何运行的以及元素在运行时是如何交互的,也许还可以绘制系统的模块视图,至少展示主要的层次或子系统。
最后,在你进行记录时请记住,你的设计最终可能会被分析。因此,你需要考虑应该记录哪些信息来支持这种分析。
记录设计决策
在每个设计迭代中,你都会做出重要的设计决策以实现迭代目标。当你研究一个代表架构的图表时,你可能会看到一个思考过程的最终产物,但并不总是能轻易理解为实现这个结果所做出的决策。记录超出所选元素、关系和属性表示之外的设计决策,对于帮助阐明你是如何得出结果(即设计原理)至关重要。我们将在 [第 22 章][ch20fig04] 中详细探讨这个主题。
20.6 关于属性驱动设计(ADD)步骤 7 的更多内容:对当前设计进行分析,并审查迭代目标以及设计目的的达成情况
在一次迭代结束时,进行一些分析以反思你刚刚做出的设计决策是明智的。我们在 [第 21 章][ch21] 中描述了几种进行此类分析的技术。在这一点上你需要进行的一种分析是评估你是否已经做了足够的设计工作。具体而言:
- 你需要做多少设计?
- 到目前为止你已经做了多少设计?
- 你完成了吗?
使用待办事项列表和看板等实践可以帮助你跟踪设计进度并回答这些问题。
使用架构待办事项列表
架构待办事项列表是一个待办事项清单,其中包含作为架构设计过程的一部分仍需执行的未完成行动。最初,你应该用你的驱动因素填充设计待办事项列表,但也可以包括支持架构设计的其他活动,例如:
- 创建原型以测试特定技术或解决特定的质量属性风险。
- 探索和理解现有资产(可能需要进行逆向工程)。
- 在对目前为止的设计决策进行审查时发现的问题。
此外,随着决策的做出,你可以向待办事项列表中添加更多项目。例如,如果选择了一个参考架构,你可能需要向架构设计待办事项列表中添加特定的关注点或从中派生的质量属性场景。例如,如果我们选择了一个 Web 应用程序参考架构,并且发现它没有提供会话管理,那么这就成为一个需要添加到待办事项列表中的关注点。
使用设计看板
另一个可用于跟踪设计进度的工具是看板,如图 20.5 所示。这个看板建立了三类待办事项:“未处理”、“部分处理” 和 “完全处理”。

在一次迭代开始时,设计过程的输入成为待办事项列表中的条目。最初(在步骤 1),本次设计回合的待办事项列表中的条目应该位于看板的 “未处理” 列中。当你开始一次设计迭代时,在步骤 2,与你在设计迭代目标中处理的驱动因素相对应的待办事项条目应该移动到 “部分处理” 列中。最后,一旦你完成一次迭代,并且对你的设计决策的分析表明某个特定的驱动因素已经得到处理(步骤 7),该条目应该移动到看板的 “完全处理” 列中。
建立明确的标准以允许将驱动因素移动到 “部分处理” 或 “完全处理” 列是很重要的。例如,“完全处理” 的一个标准可以是该驱动因素已经过分析,或者它已经在原型中实现,并且你确定该驱动因素的要求已经得到满足。在特定迭代中选择的驱动因素可能在该迭代中没有完全得到处理。在这种情况下,它们应该留在 “部分处理” 列中。
选择一种技术来根据优先级区分看板中的条目可能会很有用。例如,你可以根据优先级为条目使用不同的颜色。
看板可以让你很容易地直观跟踪设计的进展,因为你可以快速看到在迭代中有多少(最重要的)驱动因素正在被处理或已经被处理。这种技术也有助于你决定是否需要进行额外的迭代。理想情况下,当大多数你的驱动因素(或者至少是具有最高优先级的那些)位于 “完全处理” 列下时,设计回合就可以结束了。
20.7 小结
设计是困难的。需要一些方法来使其更易于处理(且可重复)。在本章中,我们详细讨论了属性驱动设计(ADD)方法;它允许以一种系统且具有成本效益的方式来设计架构。
我们还讨论了在设计过程的各个步骤中需要考虑的几个重要方面。这些方面包括包括设计概念的识别和选择、它们在生成结构中的用途、接口的定义、初步文档的制作以及跟踪设计进度的方法。
20.8 扩展阅读
ADD 的第一个版本最初被称为 “基于架构的设计”,记录在 [[Bachmann 00b][ref_8]] 中。
随后在 2006 年发布了 ADD 2.0 的描述。它是第一个专门关注质量属性及其通过选择不同类型的结构并通过视图表示来实现的方法。ADD 2.0 版本首先记录在一份 SEI 技术报告 [[Wojcik 06][ref_253]] 中。
本章中描述的 ADD 版本是 ADD 3.0。与原始版本相比,一些重要的改进包括更多地考虑将实现技术的选择作为主要设计概念,考虑诸如设计目的和架构关注点等其他驱动因素,使初始文档编制和分析成为设计过程的明确步骤,并提供关于如何开始设计过程以及如何在敏捷环境中使用它的的指导。有一整本书 [[Cervantes 16][ref_54]] 专门介绍使用 ADD 3.0 进行架构设计。ADD 3.0 的一些概念首先在一篇《IEEE Software》文章 [[Cervantes 13][ref_53]] 中被引入。
George Fairbanks 写了一本引人入胜的书,描述了一种风险驱动的架构设计过程,书名为《恰到好处的软件架构:一种风险驱动的方法》[[Fairbanks 10][ref_85]]。
信息价值技术可追溯到 20 世纪 60 年代 [[Raiffa 00][ref_215]]。在 [[Hubbard 14][ref_118]] 中可以找到一种更现代的处理方法。
对于系统设计的一般方法,你可以阅读 Butler Lampson 的经典著作 [[Lampson 11][ref_159]]。
看板利用精益生产的概念,是一种用于安排系统生产的方法,如 Corey Ladas 所描述的 [[Ladas 09][ref_157]]。
20.9 问题讨论
1. 遵循既定的设计方法有哪些优点?又有哪些缺点?
2. 进行架构设计与敏捷开发方法是否兼容?选择一种敏捷方法并在该背景下讨论属性驱动设计(ADD)。
3. 设计与分析之间有什么关系?是否存在一些知识是其中一个需要而另一个不需要的?
4. 如果你必须在设计过程中向你的经理论证创建和维护架构文档的价值,你会提出哪些论据?
5. 如果你进行全新开发(greenfield development)与二次开发(brownfield development),你对 ADD 步骤的实现会有哪些不同?
第21章 评估架构
医生可以掩埋自己的错误,但建筑师只能建议客户种上藤蔓。
—— 弗兰克・劳埃德・赖特(Frank Lloyd Wright)
在 [第 2 章][ch02] 中,我们提到架构之所以重要的一个主要原因是,在构建系统之前,通过检查其架构,你可以预测从中衍生出来的任何系统的质量属性。如果你仔细想想,这是一个相当不错的事情。而这一章就是这种能力得以体现的地方。
“架构评估” 是确定架构在多大程度上适合其预期目的的过程。架构对系统和软件工程项目的成功起着如此重要的作用,因此停下来确保你正在设计的架构能够提供所有预期的功能是有意义的。这就是评估的作用,它基于对各种替代方案的分析。幸运的是,有一些成熟的方法可以分析架构,这些方法使用了你在本书中已经学到的许多概念和技术。
为了有用,评估的成本需要低于它所提供的价值。考虑到这种关系,一个重要的问题是 “评估将花费多少时间和金钱?” 不同的评估技术有不同的成本,但所有这些都可以根据参与评估活动的准备、执行和后续工作的人员所花费的时间来衡量。
22.1 评估作为一种降低风险的活动
每一个架构都伴随着风险。架构评估的输出包括识别架构中的风险部分。风险是一个既有影响又有概率的事件。风险的预估成本是该事件发生的概率乘以影响的成本。修复这些风险不是评估的输出。一旦风险被识别出来,那么修复它们就像评估本身一样,是一个成本 / 收益的问题。
将这个概念应用到架构评估中,你可以看到,如果正在构建的系统成本达数百万或数十亿美元,或者具有重大的安全关键影响,那么风险事件的影响就会很大。相比之下,如果系统是一个基于控制台的游戏,创建成本为几万或几十万美元,那么风险事件的影响就会小得多。
风险事件的概率与正在开发的系统及其架构的先例性或无前例性等因素有关。如果你和你的组织在这个领域有长期而深入的经验,那么产生不良架构的概率就会比这个项目是你的首次尝试时要小。
因此,评估就像一份保险。你需要多少保险取决于你对不适当架构的风险暴露程度以及你的风险承受能力。
评估可以在开发过程的不同阶段进行,由不同的评估人员进行,并且评估的执行方式也有所不同 —— 我们将在本章中介绍一些选项。无论其具体细节如何,评估都建立在你已经学到的概念之上:系统是为了满足业务目标而构建的,业务目标通过质量属性场景来体现,而质量属性目标是通过应用策略和模式来实现的。
21.2 有哪些关键的评估活动?
无论由谁进行评估以及何时进行评估,评估都是基于架构驱动因素 —— 主要是以质量属性场景表示的架构重要需求(ASRs)。[第 19 章][ch19] 描述了如何确定 ASRs。进入评估的 ASRs 的数量是上下文因素和评估成本的函数。接下来我们描述架构评估的可能上下文因素。
在设计过程中的任何一点,只要存在候选架构,或者至少存在一个连贯的可审查部分,就可以进行评估。
每次评估都应该包括(至少)以下步骤:
- 评审人员各自确保他们理解架构的当前状态。这可以通过共享文档、架构师的演示或这些方式的某种组合来实现。
- 评审人员确定一些驱动因素来指导评审。这些驱动因素可能已经有文档记录,也可以由评审团队或其他利益相关者制定。通常,最重要的评审驱动因素是高优先级的质量属性场景(而不是纯粹的功能用例)。
- 对于每个场景,每个评审人员都应该确定该场景是否得到满足。评审人员提出问题以确定两种类型的信息。首先,他们要确定场景实际上得到了满足。这可以通过让架构师讲解架构并解释场景是如何得到满足的来实现。如果架构已经有文档记录,那么评审人员可以使用该文档进行评估。其次,他们要确定由于正在评审的架构部分中做出的决策,是否有任何其他正在考虑的场景将无法得到满足。评审人员可以针对当前设计的任何有风险的方面提出替代方案,这些替代方案可能更好地满足场景。这些替代方案应该接受相同类型的分析。时间限制在确定这一步骤可以持续多长时间方面起作用。
- 评审人员记录在前一步中暴露的潜在问题。这个潜在问题列表构成了评审后续工作的基础。如果潜在问题是一个实际问题,那么要么必须解决它,要么设计师和项目经理必须明确做出决定,即他们愿意接受风险。
你应该进行多少分析呢?为实现一个驱动架构需求而做出的决策应该比其他决策进行更多的分析,因为它们将塑造架构的关键部分。一些具体的考虑因素包括:
- 决策的重要性。决策越重要,在做出决策和确保其正确性时就应该越谨慎。
- 潜在替代方案的数量。替代方案越多,评估它们可能花费的时间就越多。
- 足够好而不是完美。很多时候,两个可能的替代方案在其后果上没有显著差异。在这种情况下,做出选择并继续进行设计过程比绝对确定正在做出最佳选择更为重要。
21.3 谁可以进行评估?
评估人员应该在系统所要评估的领域以及各种质量属性方面具备高技能。出色的组织和引导能力对于评估人员来说也是必不可少的。
由架构师进行评估
每次架构师做出关键设计决策以解决架构重要需求(ASR)或完成设计里程碑时,都会进行(隐性或显性的)评估。这种评估涉及在相互竞争的替代方案中做出决策。正如我们在 [第 20 章][ch20] 中讨论的那样,由架构师进行的评估是架构设计过程的一个组成部分。
通过同行评审进行评估
针对 ASR 的架构设计可以像代码一样进行同行评审。应该为同行评审分配固定的时间,通常是几个小时到半天。
如果设计人员正在使用 [第 20 章][ch20] 中描述的属性驱动设计(ADD)过程,那么可以在每次 ADD 迭代的步骤 7 结束时进行同行评审。评审人员还应该使用我们在 [第 4 章][ch04]-[第 13 章][ch13] 中介绍的基于策略的问卷。
由外部人员进行评估
外部评估人员可以更客观地看待架构。“外部” 是相对的;这可能意味着在开发项目之外、项目所在业务单元之外但在同一家公司内,或者完全在公司之外。在一定程度上,评估人员越是 “外部的”,他们就越不太可能害怕提出敏感问题,或者因为组织文化或 “我们一直都是这样做的” 而不明显的问题。
通常,选择外部人员参与评估是因为他们拥有专业知识或经验,例如对正在审查的系统很重要的质量属性的知识、对所采用的特定技术的技能,或者在成功评估架构方面有长期经验。
此外,无论是否合理,经理们往往更倾向于听取由花费大量成本聘请的外部团队发现的问题,而不是组织内的团队成员提出的问题。对于可能已经抱怨了数月相同问题却毫无结果的项目工作人员来说,这可能会令人沮丧,这是可以理解的。
原则上,外部团队可以评估一个完整的架构、一个不完整的架构或架构的一部分。在实践中,由于聘请他们很复杂且通常很昂贵,所以他们往往被用于评估完整的架构。
21.4 背景因素
对于同行评审或外部分析,在设置评估时必须考虑许多背景因素:
- 有哪些工件可用? 要进行架构评估,必须有一个既描述架构又容易获取的工件。一些评估可能在系统运行后进行。在这种情况下,可以使用一些架构恢复和分析工具来帮助发现架构、查找架构设计缺陷,并测试已建成的系统是否符合设计的系统。
- 谁能看到结果? 一些评估是在所有利益相关者完全知情并参与的情况下进行的。其他评估则更私密地进行。
- 哪些利益相关者将参与? 评估过程应该包括一种方法来引出重要利益相关者对系统的目标和关注点。在这个阶段,确定所需的人员并确保他们参与评估至关重要。
- 业务目标是什么? 评估应该回答系统是否将满足业务目标。如果在评估之前没有明确捕获并确定业务目标的优先级,那么评估的一部分应该致力于此任务。
同行和外部评估人员进行的评估很常见,因此我们有正式的流程来指导评估。这些流程定义了谁应该参与以及在评估期间应该进行哪些活动。正式确定流程可以让组织使该流程更具可重复性,帮助利益相关者了解评估将需要什么以及将交付什么,培训新的评估人员使用该流程,并了解进行评估所需的投入。
我们首先描述一个外部评估人员的流程(架构权衡分析方法);然后描述一个同行评审的流程(轻量级架构评估)。
21.5 架构权衡分析方法
架构权衡分析方法(ATAM)是我们为进行架构评估而正式确定的流程。二十多年来,ATAM 一直被用于评估从汽车到金融再到国防等领域的大型系统的软件架构。ATAM 的设计使得评估人员无需事先熟悉架构或其业务目标,并且系统也无需已经构建完成。ATAM 评估可以面对面进行,也可以远程进行。
ATAM 的参与者
ATAM 需要三个群体的参与和相互合作:
-
评估团队:这个群体来自正在被评估架构的项目外部。它通常由三到五人组成。在评估期间,团队的每个成员都被分配了一些特定的角色;在一次 ATAM 评估中,一个人可能承担多个角色。(有关这些角色的描述请参见 [表 21.1][ch21tab01]。)评估团队可以是一个定期进行架构评估的常设单位,或者其成员可以从一群有架构意识的人员中为特定场合挑选出来。他们可以与架构正在被评估的开发团队来自同一组织,也可以是外部顾问。在任何情况下,他们都需要被认为是有能力、无偏见的外部人员,没有隐藏的议程或别有用心的目的。
角色 职责 团队领导 进行评估设置;与客户进行协调,确保满足客户需求;建立评估合同;组建评估团队;确保最终报告得以生成并交付。 评估负责人 主持评估;促进场景的引出;管理场景优先级确定过程;促进根据架构对场景进行评估。 场景记录员 在场景引出过程中以可共享的、公开的形式编写场景;记录每个场景的商定措辞,在准确措辞被记录下来之前暂停讨论。 电子记录员 以电子形式记录评估过程:原始场景、激发每个场景的问题(这些问题常常在场景的措辞中被遗漏)以及每个场景分析的结果;还生成一份已采纳场景的列表分发给所有参与者。 提问者 提出基于质量属性的深入问题。 -
项目决策者。这些人有权代表开发项目发言,或者有权强制对项目进行变更。他们通常包括项目经理,如果有可识别的客户为开发项目付费,那么该客户的代表也可能出席。架构师总是包括在内——架构评估的一条重要规则是架构师必须自愿参与。
-
架构利益相关者。利益相关者在架构如所宣传的那样运行时有既得利益。他们是那些能否完成工作取决于架构是否促进可修改性、安全性、高可靠性等的人。利益相关者包括开发人员、测试人员、集成人员、维护人员、性能工程师、用户以及与正在考虑的系统进行交互的其他系统的构建者。在评估期间,他们的工作是明确架构应该满足的特定质量属性目标,以使系统被认为是成功的。一个经验法则 —— 也仅仅是一个经验法则 —— 是在评估一个大型企业关键架构时,你应该期望招募 10 到 25 个利益相关者。与评估团队和项目决策者不同,利益相关者并不参与整个评估过程。
ATAM 的产出
- 简洁的架构呈现:ATAM 的一个要求是在一个小时或更短的时间内呈现架构,这使得架构呈现既简洁又通常易于理解。
- 明确阐述业务目标:在 ATAM 评估过程中呈现的业务目标常常是一些参与者首次看到的,这些目标会在输出结果中被捕获。对业务目标的这种描述会在评估后留存下来,成为项目遗产的一部分。
- 以质量属性场景形式表达的已确定优先级的质量属性需求:这些质量属性场景采用 [第 3 章][ch03] 中描述的形式。ATAM 使用已确定优先级的质量属性场景作为评估架构的基础。这些场景可能已经存在(也许是由于先前的需求捕获活动或属性驱动设计(ADD)活动的结果),但如果不存在,它们将由参与者在 ATAM 评估过程中生成。
- 一组风险和非风险:架构风险是指根据已声明的质量属性需求可能导致不良后果的决策。类似地,架构非风险是指经过分析被认为是安全的决策。已识别的风险构成架构风险缓解计划的基础。这些风险是 ATAM 评估的主要输出结果。
- 一组风险主题:当分析完成时,评估团队检查所有已发现的风险,以寻找总体主题,这些主题可以确定架构甚至架构过程和团队中的系统性弱点。如果不加以处理,这些风险主题将威胁项目的业务目标。
- 将架构决策映射到质量需求:架构决策可以根据它们支持或阻碍的驱动因素来解释。对于在 ATAM 评估过程中检查的每个质量属性场景,确定并捕获那些有助于实现该场景的架构决策。它们可以作为这些决策的理由陈述。
- 一组已确定的敏感点和权衡点:敏感点是对质量属性响应有显著影响的架构决策。当两个或多个质量属性响应对同一架构决策敏感,但其中一个得到改善而另一个恶化时,就会出现权衡。
ATAM 评估的输出结果可用于构建最终报告,该报告回顾评估方法、总结评估过程、捕获场景及其分析,并对评估结果进行分类。
基于 ATAM 的评估还会产生一些不应被忽视的无形结果。这些结果包括利益相关者的社区感、架构师与利益相关者之间开放的沟通渠道,以及所有参与者对架构及其优缺点的更好整体理解。虽然这些结果难以衡量,但它们与其他结果同样重要。
ATAM 的阶段
基于 ATAM 的评估活动分布在四个阶段:
- 在第 0 阶段 “合作与准备” 中,评估团队领导和关键项目决策者制定评估的细节。项目代表向评估人员介绍项目,以便评估团队可以由具有适当专业知识的人员补充。两个小组共同商定后勤事宜,例如评估的时间和用于支持会议的技术。他们还商定利益相关者的初步名单(按姓名而不仅仅是角色),并协商最终报告的交付时间和交付对象。他们处理诸如工作说明书或保密协议等手续。评估团队检查架构文档以了解架构及其包含的主要设计方法。最后,评估团队领导解释在第 1 阶段期望经理和架构师展示哪些信息,并在必要时帮助他们构建演示文稿。
- 在第 1 和第 2 阶段,统称为 “评估”,每个人都开始进行分析工作。到这时,评估团队将已经研究了架构文档,并对系统的内容、采用的主要架构方法以及至关重要的质量属性有很好的了解。在第 1 阶段,评估团队与项目决策者会面,开始信息收集和分析。在第 2 阶段,架构的利益相关者为评估过程和分析提供他们的输入。
- 在第 3 阶段 “跟进” 中,评估团队生成并交付最终报告。这份报告 —— 可能是一份正式文件或仅仅是一组幻灯片 —— 首先分发给关键利益相关者,以确保其中没有理解错误。在这次审查完成后,它将交付给客户。
[表 21.2][ch21tab02] 显示了 ATAM 的四个阶段、每个阶段的参与者以及在该活动上花费的典型累计时间 —— 可能分几个阶段进行。
| 阶段 | 活动 | 参与者 | 典型累计时间 |
|---|---|---|---|
| 0 | 合作与准备。 | 评估团队领导和关键项目决策者。 | 根据需要非正式地进行,可能持续几周。 |
| 1 | 评估 | 评估团队和项目决策者。 | 1–2 天 |
| 2 | 评估(续) | 评估团队、项目决策者和利益相关者。 | 2天 |
| 3 | 跟进 | 评估团队和评估客户。 | 1 周 |
來源:改編自 [[Clements 01b][ref_61]]。
评估阶段的步骤
ATAM 分析阶段(阶段 1 和阶段 2)包括九个步骤。步骤 1 至步骤 6 在阶段 1 由评估团队和项目决策者(通常是架构团队、项目经理和客户)执行。在阶段 2,所有利益相关者参与进来,对步骤 1 至步骤 6 进行总结,并执行步骤 7 至步骤 9。
步骤 1:介绍 ATAM
步骤1要求评估负责人向聚集的项目代表介绍 ATAM。这个时间用于解释每个人将遵循的流程、回答问题,并为后续活动设定背景和期望。使用标准演示文稿,负责人简要描述 ATAM 步骤和评估的输出结果。
步骤 2:介绍业务目标
参与评估的每个人 —— 项目代表以及评估团队成员 —— 都需要了解系统的背景以及推动其开发的主要业务目标。在这一步中,项目决策者(理想情况下是项目经理或客户代表)从业务角度呈现系统概述。这个演示应描述项目的以下方面:
- 系统最重要的功能。
- 任何相关的技术、管理、经济或政治约束。
- 与项目相关的业务目标和背景。
- 主要利益相关者。
- 架构驱动因素(强调具有架构重要性的需求)。
步骤 3:介绍架构
首席架构师(或架构团队)进行演示,以适当的详细程度描述架构。“适当的程度” 取决于几个因素:架构设计和记录的程度、可用的时间以及行为和质量需求的性质。
在这个演示中,架构师涵盖技术约束,例如操作系统、规定使用的平台以及该系统必须与之交互的其他系统。最重要的是,架构师描述用于满足需求的架构方法(或模式,或策略,如果架构师熟悉这些词汇)。
我们期望如 [第 1 章][ch01] 中介绍并在 [第 22 章][ch22] 中详细描述的架构视图成为架构师传达架构的主要手段。上下文图、组件和连接器视图、模块分解或分层视图以及部署视图在几乎每个评估中都很有用,架构师应准备好展示它们。如果其他视图包含与手头架构相关的信息,特别是与满足重要质量属性需求相关的信息,则可以展示这些视图。
步骤 4:识别架构方法
ATAM 通过理解架构方法来分析架构。架构模式和策略对于(除其他原因外)每种方法以已知方式影响特定质量属性是有用的。例如,分层模式往往会为系统带来可移植性和可维护性,可能以性能为代价。发布 - 订阅模式在数据的生产者和消费者数量方面具有可扩展性,而主动冗余模式可提高高可用性。
步骤 5:生成质量属性效用树
质量属性目标通过质量属性效用树进行详细阐述,我们在 [19.4 节][ch19sec04] 中介绍了效用树。效用树通过精确定义架构师努力提供的相关质量属性需求来使需求具体化。
在考虑中的架构的重要质量属性目标在 “步骤2:介绍业务目标” 时被命名或暗示,但没有达到足以进行分析的具体程度。诸如 “可修改性”“高吞吐量” 或 “能够移植到多个平台的能力” 等广泛目标确立了背景和方向,并为后续信息的呈现提供了背景。然而,它们不够具体,无法让我们判断架构是否足以实现这些目标。在哪些方面可修改?吞吐量有多高?移植到哪些平台以及需要多少时间?这些问题的答案以表示具有架构重要性的需求的质量属性场景的形式表达。
回想一下,效用树是由架构师和项目决策者构建的。他们一起确定每个场景的重要性:架构师对场景的技术难度或风险进行评级(H、M、L 级别),项目决策者对其业务重要性进行评级。
步骤 6:分析架构方法
估团队依次检查排名最高的场景(如在效用树中确定的);要求架构师解释架构如何支持每个场景。评估团队成员 —— 尤其是提问者 —— 探寻架构师用于实现场景的架构方法。在此过程中,评估团队记录相关架构决策,并识别和编目其风险、非风险和权衡。对于众所周知的方法,评估团队询问架构师如何克服该方法中的已知弱点,或者架构师如何获得该方法足以满足需求的保证。目标是让评估团队确信该方法的实例化对于满足其旨在满足的特定属性需求是适当的。
场景演练会引发对可能的风险和非风险的讨论。例如:
- 心跳频率影响系统检测故障组件的时间。某些分配将导致此响应的不可接受的值;这些是风险。
- 心跳频率决定故障检测时间。
- 更高的频率会提高可用性,但也会消耗更多的处理时间和通信带宽(可能导致性能降低)。这是一种权衡。
这些问题反过来可能会引发更深入的分析,具体取决于架构师的回应。例如,如果架构师无法描述客户端的数量,也不能说明如何通过将进程分配到硬件来实现负载均衡,那么进行任何性能分析都没有意义。如果这些问题可以得到回答,评估团队可以进行至少初步的或粗略的分析,以确定这些架构决策在满足它们旨在解决的质量属性需求方面是否存在问题。
步骤 6 中的分析并不意味着是全面的。关键是引出足够的架构信息,以在已做出的架构决策和需要满足的质量属性需求之间建立一些联系。
[图 21.1][ch21fig01] 显示了用于捕获针对场景的架构方法分析的模板。如图所示,基于这一步骤的结果,评估团队可以识别并记录一组风险和非风险、敏感点和权衡。

图 21.1 架构方法分析示例(改编自 [[Clements 01b][ref_61]])
在步骤 6 结束时,评估团队应该对整个架构的最重要方面、关键设计决策的基本原理以及风险、非风险、敏感点和权衡点列表有清晰的了解。
至此,阶段 1 结束。
暂停与阶段 2 的开始
评估团队总结他们所学到的内容,并在大约一周的暂停期间与架构师进行非正式的互动。如果需要,在此期间可以分析更多的场景,或者可以澄清在阶段 1 中提出的问题的答案。
参加阶段 2 会议的人员包括更多的参与者,其他利益相关者加入讨论。用编程来打个比方:阶段 1 就像你用自己的标准测试自己的程序。阶段 2 就像你把你的程序交给一个独立的质量保证小组,他们可能会让你的程序在更广泛的测试和环境中进行测试。
在阶段 2 中,重复步骤 1,以便利益相关者理解该方法以及他们要扮演的角色。然后,评估负责人回顾步骤 2 至步骤 6 的结果,并分享当前的风险、非风险、敏感点和权衡点列表。在让利益相关者了解到目前的评估结果后,就可以进行剩下的三个步骤了。
步骤 7:头脑风暴并确定场景优先级
评估团队要求利益相关者就对他们各自的角色具有实际意义的质量属性场景进行头脑风暴。维护人员可能会提出一个可修改性场景,而用户可能会提出一个表达易操作性的场景,质量保证人员可能会提出一个关于测试系统或能够复制导致故障的系统状态的场景。
虽然生成效用树(步骤 5)主要是为了理解架构师如何感知和处理质量属性架构驱动因素,但场景头脑风暴的目的是了解更广泛的利益相关者群体的想法:了解对他们来说系统成功意味着什么。场景头脑风暴在较大的群体中效果很好,营造出一种一个人的想法和观点激发其他人的想法的氛围。
一旦收集到场景,就必须对其进行优先级排序,原因与效用树中的场景需要进行优先级排序的原因相同:评估团队需要知道在哪里投入有限的分析时间。首先,要求利益相关者合并他们认为代表相同行为或质量关注点的场景。接下来,他们对他们认为最重要的场景进行投票。每个利益相关者被分配的票数等于场景数量的 30%(向上取整)^1。因此,如果收集了 40 个场景,每个利益相关者将获得 12 票。这些票可以由利益相关者以任何他们认为合适的方式分配:12 票都投给一个场景,1 票投给 12 个不同的场景中的每一个,或者介于两者之间的任何分配方式。
将已确定优先级的场景列表与效用树练习中的场景进行比较。如果它们一致,这表明架构师的想法与利益相关者的实际需求之间有良好的一致性。如果发现了其他驱动场景 —— 通常会这样 —— 如果差异很大,这本身可能就是一种风险。这样的发现表明利益相关者和架构师在系统的重要目标上存在一定程度的分歧。
步骤 8:分析架构方法
在步骤 7 中收集并确定场景优先级后,评估团队引导架构师分析排名最高的场景。架构师解释架构决策如何有助于实现每个场景。理想情况下,此活动将主要由架构师根据先前讨论的架构方法解释场景。
在这一步中,评估团队执行与第6步相同的活动,使用新生成的排名最高的场景。通常,根据时间允许,这一步可能涵盖前五个到十个场景。
步骤 9:呈现结果
在步骤 9 中,评估团队召集会议,并根据一些共同的潜在关注点或系统性缺陷将风险分组为风险主题。例如,一组关于文档不足或过时的风险可能被归为一个风险主题,表明文档没有得到足够的考虑。一组关于系统在面对各种硬件和 / 或软件故障时无法运行的风险可能导致一个关于对备份能力或提供高可用性关注不足的风险主题。
对于每个风险主题,评估团队确定第2步中列出的哪些业务目标受到影响。确定风险主题,然后将它们与特定的驱动因素联系起来,通过将最终结果与初始演示联系起来,使评估圆满完成,从而为评估活动提供了令人满意的结束。同样重要的是,它将发现的风险提升到管理层的关注层面。原本在经理看来可能像是深奥的技术问题,现在明确地被确定为对经理公开表示关心的事情的威胁。
从评估中收集的信息被总结并呈现给利益相关者。呈现以下输出结果:
- 记录的架构方法。
- 头脑风暴产生的场景及其优先级。
- 效用树。
- 发现的风险和非风险。
- 发现的敏感点和权衡点。
- 风险主题以及每个主题所威胁的业务目标。
脱离脚本
多年的经验告诉我们,没有一次架构评估活动会完全按照书本进行。然而,尽管评估活动可能会以各种方式出现严重错误,可能会忽略所有的细节,可能会伤害到脆弱的自尊心,也可能会涉及到很高的风险,但我们从未有过一次架构评估活动失去控制。每一次评估都取得了成功,这是通过我们从客户那里收集到的反馈来衡量的。
虽然它们都成功了,但也有一些令人难忘的惊险时刻。
有好几次,我们开始进行架构评估,却发现开发组织没有可评估的架构。有时只有一堆类图或模糊的文本描述伪装成架构。有一次,我们得到承诺说在评估活动开始时架构就会准备好,但尽管有良好的意图,它却没有准备好。(我们并不总是在评估前的准备和资格审查方面如此谨慎。我们现在的勤勉是这些经历的结果。)但没关系。在这种情况下,评估的主要结果包括明确阐述的质量属性集、在评估过程中绘制的 “白板” 架构,以及为架构师规定的一组文档义务。在所有情况下,客户都认为详细的场景、我们能够对引出的架构进行的分析以及对需要做什么的认识,完全证明了评估活动是合理的。
有几次我们开始评估,却在评估过程中失去了架构师。有一次,架构师在评估的准备和执行之间辞职了。这个组织处于混乱之中,架构师只是在其他更平静的环境中得到了更好的机会。通常,没有架构师我们是不会继续进行的,但这次没关系,因为架构师的徒弟介入了。进行了一些额外的准备工作让他做好准备,我们就一切就绪了。评估按计划进行,徒弟为评估所做的准备极大地帮助他接替了架构师的角色。
有一次,在一次 ATAM 评估活动进行到一半时,我们发现我们准备评估的架构被抛弃了,取而代之的是一个新的架构,而没有人提及过这个新架构。在阶段 1 的步骤 6 中,架构师在回应一个场景提出的问题时不经意地提到 “新架构” 不会有那个缺陷。房间里的每个人,包括利益相关者和评估人员,都面面相觑,陷入了困惑的沉默。“什么新架构?” 我茫然地问道,然后事情就暴露了。开发组织(为美国军方承包项目的承包商,委托进行了这次评估)为系统准备了一个新架构,以应对他们知道未来会出现的更严格的要求。我们叫了暂停,与架构师和客户商议,决定继续进行评估活动,以新架构为评估对象而不是旧架构。我们退回到步骤 3(架构展示),但其他所有的东西 —— 业务目标、效用树、场景 —— 仍然完全有效。评估继续进行,在评估活动结束时,我们的军方客户对所获得的知识非常满意。
在我们经历过的也许是最奇怪的一次评估中,我们在阶段 2 进行到一半时失去了架构师。这次评估的客户是一个正在进行大规模重组的组织中的项目经理。这位经理是一个和蔼可亲、有敏锐幽默感的人,但有一种潜在的感觉是他不能被违抗。架构师在不久的将来会被重新分配到组织的另一个部门;这无异于被从这个项目中解雇,经理说他想在他的架构师尴尬离开之前确定架构的质量。(我们直到评估后才发现这些情况。)当我们设置 ATAM 评估活动时,经理建议初级设计师参加。“他们可能会学到一些东西,” 他说。我们同意了。随着评估活动的开始,我们的时间表(一开始就非常紧张)不断被打乱。经理希望我们与他公司的高管会面。然后他希望我们和一个他说能给我们更多架构见解的人一起吃一顿长时间的午餐。结果,在我们预定的会面时间,高管们很忙。所以经理问我们是否可以回来以后再和他们会面。
到现在,阶段 2 已经严重偏离了时间表,以至于让我们惊恐的是,架构师不得不离开去飞回他在遥远城市的家。他对在他不在的情况下评估他的架构非常不高兴。他说,初级设计师永远无法回答我们的问题。在他离开之前,我们的团队聚在一起商议。评估活动似乎处于灾难的边缘。我们有一个不高兴离开的架构师、一个被打乱的时间表和有问题的可用专业知识。我们决定把我们的评估团队一分为二。一半的团队将继续进行阶段 2,以初级设计师为我们的信息资源。另一半的团队将在第二天通过电话与架构师一起继续进行阶段 2。我们将以某种方式在这种糟糕的情况下尽力而为。
令人惊讶的是,项目经理似乎对这些情况的转变完全无动于衷。“我确定会没事的,” 他愉快地说,然后退回去与各位副总裁商议重组事宜。
我带领团队采访初级设计师。我们从未从架构师那里得到一个完全令人满意的架构展示。对文档中的差异,他轻松地回应说:“哦,好吧,实际情况不是那样的。” 所以我决定从 ATAM 步骤 3 重新开始。我们问这大约六个设计师他们对架构的看法是什么。“你们能画出来吗?” 我问他们。他们紧张地互相看了看,但有一个人说:“我想我能画出一部分。” 他走到白板前,画出了一个非常合理的组件和连接器视图。另一个人自愿画出一个进程视图。第三个人画出了系统一个重要离线部分的架构。其他人也加入进来帮忙。
当我们环顾房间时,每个人都在忙着抄写白板上的图片。没有一张图片与我们迄今为止在文档中看到的任何东西相对应。“这些图在任何地方有记录吗?” 我问。一个设计师从他忙碌的抄写中抬起头来笑了笑。“现在有了,” 他说。
当我们进行到步骤 8,使用先前捕获的场景分析架构时,设计师们在共同回答我们的问题方面做得非常出色。没有人知道所有的事情,但每个人都知道一些事情。在半天的时间里,他们一起呈现出了一个清晰一致的整个架构的画面,比架构师在为期两天的评估前讨论中愿意呈现的任何东西都更加连贯和易于理解。在阶段 2 结束时,设计团队发生了转变。这个以前缺乏信息、只有有限的局部知识的团队变成了一个真正的架构团队。成员们展示并认可了彼此的专业知识。这种专业知识在每个人面前 —— 最重要的是,在他们的项目经理面前 —— 被揭示和验证,项目经理悄悄回到房间观察。他脸上露出了极度满意的表情。我开始明白 —— 你猜对了 —— 没关系。
结果发现,这个项目经理知道如何以一种会让马基雅维利印象深刻的方式操纵事件和人。架构师的离开不是因为重组,只是与之巧合。项目经理精心策划了这一切。经理觉得架构师变得过于专制独裁,他想给初级设计人员一个成长和做出贡献的机会。架构师在评估过程中的离开正是项目经理所希望的。而设计团队在压力下的崛起一直是这次评估活动的主要目的。虽然我们发现了与架构有关的几个重要问题,但项目经理在我们到达之前就已经知道了每一个问题。事实上,他通过在休息时间或一天的会议后发表一些谨慎的言论,确保我们发现了其中的一些问题。
这次评估活动成功了吗?客户再满意不过了。他对架构的优势和劣势的直觉得到了证实。我们在帮助他的设计团队方面发挥了作用,这个团队将在公司重组的风暴中引导系统,在恰当的时候作为一个有效和有凝聚力的团队团结在一起。客户对我们的最终报告非常满意,他确保公司董事会看到了这份报告。
这些惊险时刻在我们的记忆中肯定非常突出。没有记录的架构。但没关系。不是正确的架构。但没关系。没有架构师。但没关系。客户真正想要的是实现团队重组。在每一个例子中,我们都尽可能合理地做出反应,每次都没关系。
为什么?为什么一次又一次地结果都还好呢?我认为有三个原因。
首先,委托进行架构评估的人真的希望它成功。应客户要求聚集在一起的架构师、开发人员和利益相关者也希望它成功。作为一个群体,他们帮助评估活动朝着获得架构洞察力的目标前进。
其次,我们总是诚实的。如果我们觉得评估活动偏离了轨道,我们会叫暂停,在我们自己之间商议,通常也会与客户商议。虽然在评估活动中一点虚张声势可能会派上用场,但我们永远不会试图在评估中蒙混过关。参与者本能地能察觉到虚假的迹象,评估团队绝不能失去其他参与者的尊重。
第三,这些方法是为了在整个评估活动中建立和保持稳定的共识而构建的。在最后没有意外。参与者为构成合适架构的要素制定基本规则,并且他们在评估的每一步都为发现的风险做出贡献。
所以:尽你最大的努力。诚实。相信这些方法。相信你聚集在一起的人的善意和良好意图。那么就会没事的。
—PCC (Adapted from [[Clements 01b][ref_61]])
21.6 轻量级架构评估
轻量级架构评估(LAE)方法旨在项目内部环境中使用,由同行定期进行评审。它使用与 ATAM 相同的概念,并旨在定期进行。可以召开一次 LAE 会议,重点关注自上次评审以来架构或架构驱动因素的变化,或者检查架构中以前未检查过的部分。由于范围有限,许多 ATAM 的步骤可以省略或缩短。
LAE 评估的持续时间取决于生成和检查的质量属性场景的数量,而这又基于评审的范围。检查的场景数量取决于被评审系统的重要性。因此,一次 LAE 评估可以短至几个小时,也可以长达一整天。它完全由组织内部成员进行。
由于参与者都是组织内部人员,并且数量比 ATAM 少,所以让每个人发表意见并达成共同理解所需的时间要少得多。此外,因为 LAE 是一个轻量级过程,所以可以定期进行;反过来,该方法的许多步骤可以省略或只是简要提及。LAE 评估中的潜在步骤以及我们在实践中的经验如 [表 21.3][ch21tab03] 所示。LAE 评估通常由项目架构师召集并领导。
| 步骤 | 笔记 |
|---|---|
| 1: 介绍方法步骤 | 假如参与者熟悉该流程,此步骤可以省略。 |
| 2:审查业务目标。 | 参与者应该理解系统及其业务目标以及它们的优先级。可以进行简要审查,以确保这些内容在每个人的脑海中都是清晰的,并且没有意外情况。 |
| 3:审查架构 | 所有参与者都应该熟悉该系统,所以会使用至少模块视图和组件与连接件视图对架构进行简要概述,突出自上次审查以来的任何变化,并通过这些视图追踪一两个场景。 |
| 4:审查架构方法 | 架构师强调针对特定质量属性关注点所使用的架构方法。这通常在步骤3的一部分中完成。 |
| 5:审查质量属性效用树 | 效用树应该已经存在;团队审查现有效用树,并在需要时使用新场景、新响应目标或新的场景优先级和风险评估对其进行更新。 |
| 6:头脑风暴并确定场景优先级 | 此时可以进行一个简短的头脑风暴活动,以确定是否有任何新场景值得分析。 |
| 7:分析架构方法 | 这一步(将高优先级的场景映射到架构上)会耗费大部分时间,并且应该聚焦于架构的最新变化,或者团队之前未分析过的架构部分。如果架构发生了变化,应该根据这些变化重新分析高优先级的场景。 |
| 8:记录结果 | 在评估结束时,团队审查现有的和新发现的风险、非风险、敏感性和权衡,并讨论是否出现了任何新的风险主题。 |
没有最终报告,但(与 ATAM 一样)有一名记录员负责记录结果,这些结果可以随后被分享,并作为风险补救的基础。
整个轻量级架构评估可以在不到一天的时间内完成 —— 也许一个下午就可以。结果将取决于参与的团队对该方法的目标、技术以及系统本身的理解程度。评估团队由于是内部的,通常比外部评估团队的客观性要低,这可能会影响其结果的价值:人们往往听到较少的新想法和不同意见。然而,这种评估版本成本低、容易召集,并且相对不那么正式,所以每当项目需要进行架构质量保证的合理性检查时,它可以快速部署。
基于策略的问卷调查
在 [第 3 章][ch03] 中我们讨论过的另一种(甚至更轻量级的)轻量级评估方法是基于策略的问卷调查。基于策略的问卷调查每次聚焦于一个质量属性。它可以被架构师用于辅助反思和内省,或者可以用于构建评估者(或评估团队)与架构师(或一组设计师)之间的问答环节。这种环节通常很短 —— 每个质量属性大约一小时,但可以揭示出很多关于为了控制某个质量属性而做出的以及未做出的设计决策,以及那些决策中常常隐藏的风险。我们在 [第 4 章][ch04] 至 [第 13 章][ch13] 中提供了特定质量属性的问卷调查,以帮助你在这个过程中进行指导。
基于策略的分析可以在很短的时间内产生令人惊讶的结果。例如,有一次我在分析一个管理医疗保健数据的系统。我们同意分析安全这个质量属性。在这个环节中,我尽职地按照基于安全策略的问卷调查进行,依次询问每个问题(你可能还记得,在这些问卷调查中,每个策略都被转化为一个问题)。例如,我问:“系统支持入侵检测吗?”“系统支持消息完整性验证吗?” 等等。当我问到 “系统支持数据加密吗?” 这个问题时,架构师停顿了一下,然后笑了。接着他(不好意思地)承认系统有一个要求,即任何数据都不能在网络上以明文形式(即不加密)传输。所以他们在通过网络发送所有数据之前对其进行了异或运算。
这是一个很好的例子,说明基于策略的问卷调查可以非常快速且低成本地揭示出这种风险。是的,从严格意义上说,他们满足了要求 —— 他们没有以明文形式发送任何数据。但是他们选择的加密算法一个能力一般的高中生就能破解!
—RK
21.7 小结
如果一个系统重要到需要你明确地设计其架构,那么这个架构就应该被评估。
评估的次数以及每次评估的程度可能因项目而异。设计师应该在做出重要决策的过程中进行评估。
ATAM 是一种用于评估软件架构的综合方法。它的工作方式是让项目决策者和利益相关者明确列出一份精确的质量属性需求清单(以场景的形式),并阐明与分析每个高优先级场景相关的架构决策。然后可以从风险或非风险的角度理解这些决策,以找出架构中的任何问题点。
轻量级评估可以作为项目内部同行评审活动的一部分定期进行。基于 ATAM 的轻量级架构评估提供了一种成本低、形式简单的架构评估,可以在不到一天的时间内完成。
21.8 扩展阅读
关于 ATAM 的更全面论述,请参见 [[Clements 01b][ref_61]]。
有多个应用 ATAM 的案例研究可供参考。可以通过访问sei.cmu.edu/library并搜索 “ATAM 案例研究” 找到它们。
已经开发了几种更轻量级的架构评估方法。它们可以在 [[Bouwers 10][ref_41]]、[[Kanwal 10][ref_132]] 和 [[Bachmann 11][ref_11]] 中找到。
对从 ATAM 中得出的各种见解的分析可以在 [[Bass 07][ref_14]] 和 [[Bellomo 15][ref_25]] 中找到。
21.9 问题讨论
1. 想一个你正在参与的软件系统。准备一个 30 分钟的关于这个系统的业务目标的演示。
2. 如果你要评估这个系统的架构,你希望谁来参与?利益相关者的角色是什么,你能找到谁来代表这些角色?
3. 计算一个基于 ATAM 的大型企业级系统架构评估的成本。假设参与者的完全负担劳动成本为每年 25 万美元。假设评估发现了一个架构风险,并且缓解这个风险可以节省项目成本的 10%,在什么情况下,这个 ATAM 对一个项目来说是一个明智的选择?
4. 研究一个可以归因于一个或多个糟糕的架构决策的代价高昂的系统故障。你认为架构评估可能会发现这些风险吗?如果是,将故障的成本与评估的成本进行比较。
5. 一个组织评估两个相互竞争的架构并不罕见。你将如何修改 ATAM 以产生有助于这种比较的定量输出?
6. 假设你被要求秘密地评估一个系统的架构。架构师不可用。你不被允许与系统的任何利益相关者讨论评估。你将如何进行?
7. 在什么情况下你会想要使用完全强度的 ATAM,在什么情况下你会想要使用轻量级架构评估(LAE)?
第22章 记录架构
文档是你写给未来自己的一封情书。
——达米安·康威(Damian Conway)
创建一个架构是不够的。必须以一种能让其利益相关者正确使用它来完成工作的方式进行传达。如果你不辞辛苦地创建了一个强大的架构,一个你期望经得起时间考验的架构,那么你就 必须 不辞辛苦地对其进行足够详细、毫无歧义的描述,并进行组织,以便其他人能够快速找到和更新所需的信息。
文档代表了架构师发言。它在当下为架构师发言,此时架构师应当在做其他事情,而非回答关于架构的成百上千个问题。它也在未来为架构师发言,那时架构师已经忘记了架构所包含的细节,或者当那个人已经离开了项目,而其他人现在成为了架构师。
最优秀的架构师会制作出良好的文档,不是因为这是“要求”,而是因为他们明白这对于当前的事情至关重要——以可预测的方式生产出高质量的产品,并且尽量减少返工。他们将直接的利益相关者视为在这项工作中关系最密切的人:开发人员、部署人员、测试人员、分析人员。
但是架构师也认为文档对他们自身有价值。文档充当着在重大设计决策得到确认时容纳其结果的容器。一个经过深思熟虑的文档方案可以使设计过程更加顺利和系统。无论是在为期六个月的设计阶段还是为期六天的敏捷冲刺中,在架构设计进行的过程中,文档都有助于架构师对架构设计进行推理和交流。
请注意,“文档”并不一定意味着生成一个实体的、印刷的、像书一样的制品。诸如维基这样的在线文档,以能够引发讨论、利益相关者反馈和搜索的方式托管,是架构文档的理想论坛。另外,不要认为文档是与设计不同且在设计之后的一个步骤。你用来向他人解释架构的语言可以在你进行设计工作时为你所用。理想情况下,设计和文档是同一项工作。
22.1 架构文档的用途和受众
架构文档必须服务于多种目的。它应该具有足够的透明度和易访问性,以便新员工能够快速理解。它应该足够具体,以作为构建或取证的蓝图。它应该包含足够的信息,以作为分析的基础。
架构文档可以被视为既具有规定性又具有描述性。对于某些受众来说,它规定了什么“应该”是正确的,对尚未做出的决策施加限制。对于其他受众来说,它描述了什么“是”正确的,叙述关于系统设计已经做出的决策。
许多不同类型的人会对架构文档感兴趣。他们希望并期待这份文档能帮助他们完成各自的工作。理解架构文档的用途至关重要,因为这些用途决定了要记录的重要信息。
从根本上说,架构文档有四个用途。
-
架构文档作为一种教育手段。教育用途包括向人们介绍系统。这些人可能是团队的新成员、外部分析师,甚至是新的架构师。在很多情况下,这个 “新” 人是客户,你首次向他们展示你的解决方案 —— 你希望这个展示能带来资金或批准继续进行。
-
架构文档作为利益相关者之间沟通的主要工具。它作为沟通工具的具体用途取决于哪些利益相关者在进行沟通。
也许架构文档最热心的消费者之一正是项目未来的架构师。这可能是同一个人(如本章开头的引文中所述),也可能是接替者,但无论哪种情况,未来的架构师肯定在文档中有巨大的利益。新架构师有兴趣了解他们的前辈是如何解决系统中的难题的,以及为什么做出特定的决策。即使未来的架构师是同一个人,他或她也会将文档用作思想的宝库、设计决策的仓库,这些决策数量众多且错综复杂,仅凭记忆永远无法重现。
我们在 [第 22.8 节][ch22sec08] 中列举了架构及其文档的利益相关者。
-
架构文档作为系统分析和构建的基础。架构告诉实现者要实现哪些模块以及这些模块如何连接在一起。这些依赖关系决定了模块开发团队必须与之沟通的其他团队。
对于那些对设计满足系统质量目标的能力感兴趣的人来说,架构文档是评估的素材。它必须包含评估各种属性所需的信息,如安全性、性能、可用性、可维护性和可修改性。
-
架构文档在发生事件时作为取证的基础。当事件发生时,有人负责追查事件的直接原因和根本原因。事件发生前的控制流信息将提供 “实际执行” 的架构。例如,接口规范数据库将为控制流提供上下文,组件描述将指出在事件跟踪中每个组件应该发生的情况。
为了使文档随着时间的推移继续提供价值,它需要保持更新。
22.2 符号表示
用于记录视图的符号在形式化程度上有很大差异。大致来说,有三种主要的符号类别:
- 非正式符号:视图可以使用通用的绘图和编辑工具以及为手头系统选择的视觉惯例来描绘(通常是图形化的)。你可能见过的大多数方框和线条图都属于这一类 —— 想想 PowerPoint 或类似的东西,或者在白板上手绘的草图。描述的语义用自然语言来表征,不能进行形式化分析。
- 半正式符号:视图可以用一种标准化的符号来表达,这种符号规定了图形元素和构造规则,但没有对这些元素的意义提供完整的语义处理。可以进行基本分析以确定一个描述是否满足语法属性。UML 及其系统工程辅助工具 SysML 在这个意义上是半正式符号。大多数广泛使用的商业建模工具都采用这一类符号。
- 正式符号:视图可以用一种具有精确(通常基于数学)语义的符号来描述。可以对语法和语义进行形式化分析。有多种用于软件架构的正式符号。通常被称为架构描述语言(ADL),它们通常为架构表示提供图形词汇和底层语义。在某些情况下,这些符号专门用于特定的架构视图。在其他情况下,它们允许有许多视图,甚至提供正式定义新视图的能力。ADL 的有用性在于它们能够通过相关工具支持自动化 —— 自动化以对架构进行有用的分析,或协助代码生成。在实践中,正式符号的使用很少见。
通常,更正式的符号需要更多的时间和精力来创建和理解,但以减少的歧义性和更多的分析机会来回报这种努力。相反,更非正式的符号更容易创建,但提供的保证较少。
无论形式化程度如何,始终记住不同的符号在表达不同种类的信息时更好(或更差)。除了形式化之外,没有 UML 类图会帮助你推理可调度性,序列图也不会告诉你很多关于系统按时交付的可能性。在选择符号和表示语言时,你应该牢记你需要捕捉和推理的重要问题。
22.3 视图
也许与软件架构文档相关的最重要概念是 “视图”。软件架构是一个复杂的实体,不能以简单的一维方式进行描述。视图是一组系统元素及其之间关系的表示 —— 不是所有的系统元素,而是特定类型的那些元素。例如,系统的分层视图将显示 “层” 类型的元素;也就是说,它将展示系统分解为层以及这些层之间的关系。然而,纯粹的分层视图不会显示系统的服务、客户端和服务器、数据模型或任何其他类型的元素。
因此,视图让我们将软件架构这个多维实体划分为若干个(我们希望是)有趣且易于管理的系统表示。“视图” 的概念引出了架构文档的一个基本原则:
记录一个架构就是记录相关视图,然后添加适用于多个视图的文档。
哪些是相关视图呢?这完全取决于你的目标。如我们之前所见,架构文档可以有很多用途:作为实现者的任务说明、分析的基础、自动代码生成的规范、系统理解和逆向工程的起点,或者项目评估和规划的蓝图。
不同的视图也在不同程度上揭示不同的质量属性。反过来,你和系统开发中的其他利益相关者最关心的质量属性将影响你选择记录哪些视图。例如,一个 “模块视图” 将让你推理系统的可维护性,一个 “部署视图” 将让你推理系统的性能和可靠性等等。
因为不同的视图支持不同的目标和用途,我们不提倡使用任何特定的视图或视图集合。你应该记录的视图取决于你期望对文档的使用。不同的视图将突出不同的系统元素和关系。要表示多少不同的视图是一个成本 / 效益决策的结果。每个视图都有成本和收益,你应该确保创建和维护特定视图的预期收益超过其成本。
视图的选择是由记录设计中的特定模式的需求驱动的。有些模式由模块组成,有些由组件和连接器组成,还有一些有部署方面的考虑。模块视图、组件和连接器(C&C)视图以及分配视图分别是表示这些考虑的适当机制。这些视图类别当然对应于 [第 1 章][ch01] 中描述的三种架构结构类别。(回想一下 [第 1 章][ch01],结构是元素、关系和属性的集合,而视图是一个或多个架构结构的表示。)
在本节中,我们探讨这三类基于结构的视图,然后介绍一个新的类别:质量视图。
模块视图
模块是一个提供一组一致职责的实现单元。模块可以采用类、类的集合、层、方面或实现单元的任何分解形式。模块视图的示例有分解视图、使用视图和层视图。每个模块视图都有一组分配给它的属性。这些属性表达了与每个模块以及模块之间的关系相关的重要信息,还有对模块的约束。示例属性包括职责、可见性信息(其他哪些模块可以使用它)以及修订历史。模块之间的关系包括: “是…… 的一部分”、“依赖于” 和 “是一种”。
系统软件分解为可管理单元的方式仍然是系统结构的重要形式之一。至少,它决定了系统的源代码如何分解为单元,每个单元可以对其他单元提供的服务做出何种假设,以及这些单元如何聚合成更大的集合。它还包括影响多个单元以及受多个单元影响的共享数据结构。模块结构通常决定了对系统某一部分的更改可能如何影响其他部分,从而决定了系统支持可修改性、可移植性和可重用性的能力。
如果没有至少一个模块视图,任何软件架构的文档都不太可能是完整的。[表 22.1][ch22tab01] 总结了模块视图的特征。
| 元素 | 模块是软件的实现单元,提供一组一致的职责。 |
|---|---|
| 关系 | |
| 约束 | 不同的模块视图可能会施加拓扑约束,例如对模块之间可见性的限制。 |
| 用法 |
模块的有助于指导实现或作为分析输入的属性,应作为模块视图支持文档的一部分进行记录。属性列表可能会有所不同,但可能包括以下内容:
- 名称:模块的名称当然是引用它的主要方式。模块的名称通常暗示其在系统中的角色。此外,模块的名称可能反映其在分解层次结构中的位置;例如,名称 A.B.C 指的是模块 C,它是模块 B 的子模块,而模块 B 本身又是 A 的子模块。
- 职责:模块的职责属性是一种确定其在整个系统中的角色的方式,并在名称之外为其建立一个标识。虽然模块的名称可能暗示其角色,但职责说明能更确定地确立该角色。职责应描述得足够详细,以便让读者清楚每个模块的作用。如果有项目需求规格说明,模块的职责通常通过追溯到该规格说明来捕获。
-
实现信息:模块是实现的单元。因此,从管理其开发和构建包含它们的系统的角度记录与其实现相关的信息是很有用的。这可能包括:
-
映射到源代码单元:这确定了构成模块实现的文件。例如,如果一个名为
Account的模块用 Java 实现,可能有几个文件构成其实现:IAccount.java(一个接口)、AccountImpl.java(账户功能的实现),甚至可能还有一个单元测试AccountTest.java。 - 测试信息:模块的测试计划、测试用例、测试框架和测试数据很重要,需要记录下来。此信息可能只是指向这些工件位置的指引。
- 管理信息:经理可能需要有关模块的预计进度和预算的信息。此信息可能只是指向这些工件位置的指针。
- 实现约束:在许多情况下,架构师会对模块有一个实现策略,或者可能知道实现必须遵循的约束。
- 修订历史:了解模块的历史,包括其作者和特定的变更,在进行维护活动时可能会对你有帮助。
-
映射到源代码单元:这确定了构成模块实现的文件。例如,如果一个名为
模块视图可用于向不熟悉系统的人解释系统的功能。模块分解的不同粒度级别提供了系统职责的自上而下的呈现,因此可以指导学习过程。对于已经有实现的系统,如果模块视图保持更新,它们会很有帮助,因为它们向团队中的新开发人员解释了代码库的结构。
相反,很难使用模块视图来推断运行时行为,因为这些视图只是软件功能的静态划分。因此,模块视图通常不用于性能、可靠性和许多其他运行时质量的分析。对于这些目的,我们依赖于组件和连接器视图以及分配视图。
组件和连接器视图
组件和连接器(C&C)视图展示具有某些运行时存在的元素,例如进程、服务、对象、客户端、服务器和数据存储。这些元素被称为 “组件”。此外,C&C 视图将交互路径(如通信链路和协议、信息流以及对共享存储的访问)作为元素包含在内。在 C&C 视图中,这样的交互被表示为 “连接器”。C&C 视图的示例包括客户端 - 服务器、微服务和通信进程。
C&C 视图中的一个组件可能代表一个复杂的子系统,其本身可以被描述为一个 C&C 子架构。一个组件的子架构可能采用与该组件出现的模式不同的模式。
简单的连接器示例包括服务调用、异步消息队列、支持发布 - 订阅交互的事件多播以及表示异步、保持顺序的数据流的管道。连接器通常代表更复杂的交互形式,例如数据库服务器和客户端之间的面向事务的通信通道,或者调解服务用户和提供者集合之间交互的企业服务总线。
连接器不一定是二元的;也就是说,它们不一定恰好有两个与之交互的组件。例如,一个发布 - 订阅连接器可能有任意数量的发布者和订阅者。即使连接器最终是使用二元连接器(如过程调用)实现的,在 C&C 视图中采用 n 元连接器表示也可能很有用。连接器体现了一种交互协议。当两个或更多组件交互时,它们必须遵守关于交互顺序、控制位置以及错误情况和超时处理的约定。交互协议应被记录下来。
C&C 视图中的主要关系是连接。“连接” 表示哪些连接器连接到哪些组件,从而将系统定义为组件和连接器的图。兼容性通常根据信息类型和协议来定义。例如,如果 Web 服务器期望通过 HTTPS 进行加密通信,那么客户端必须执行加密。
C&C 视图的一个元素(组件或连接器)将有与其相关联的各种属性。具体来说,每个元素都应该有一个名称和类型,其附加属性取决于组件或连接器的类型。作为架构师,你应该为支持特定 C&C 视图的预期分析的属性定义值。以下是一些典型属性及其用途的示例:
- 可靠性:给定组件或连接器的故障可能性是多少?此属性可用于帮助确定整个系统的可用性。
- 性能:在何种负载下组件将提供什么样的响应时间?对于给定的连接器,可以预期什么样的带宽、延迟或抖动?此属性可与其他属性一起用于确定系统范围的属性,如响应时间、吞吐量和缓冲需求。
- 资源需求:组件或连接器的处理和存储需求是什么?如果相关,它消耗多少能源?此属性可用于确定提议的硬件配置是否足够。
- 功能:一个元素执行什么功能?此属性可用于推理系统执行的端到端计算。
- 安全性:组件或连接器是否实施或提供安全功能,如加密、审计跟踪或身份验证?此属性可用于确定潜在的系统安全漏洞。
- 并发性:此组件是否作为单独的进程或线程执行?此属性有助于分析或模拟并发组件的性能,并识别可能的死锁和瓶颈。
- 运行时可扩展性:消息结构是否支持不断发展的数据交换?连接器是否可以适应处理那些新的消息类型?
C&C 视图通常用于向开发人员和其他利益相关者展示系统的工作方式:可以 “动画化” 或跟踪 C&C 视图,展示端到端的活动线程。C&C 视图也用于推理运行时系统质量属性,如性能和可用性。特别是,一个有良好文档记录的视图允许架构师在给定各个元素及其交互的属性的估计或测量值的情况下预测整体系统属性,如延迟或可靠性。
[表 22.2][ch22tab02] 总结了 C&C 视图的特征。
| 元素 | |
|---|---|
| 关系 | |
| 约束 | 组件只能连接到连接器,而连接器只能连接到组件。 |
| 用法 | 展示系统如何工作。 |
C&C 视图的符号表示
一如既往,方框和线条图可用于表示组件和连接器(C&C)视图。虽然非正式符号在它们能够传达的语义方面受到限制,但遵循一些简单的准则可以为描述增添严谨性和深度。主要准则很简单:为每种组件类型和每种连接器类型分配一个单独的符号,并在一个图例部分列出每种类型。
UML 组件与 C&C 组件在语义上非常匹配,因为它们允许直观地记录重要信息,如接口、属性和行为描述。UML 组件还区分组件类型和组件实例,这在定义特定于视图的组件类型时很有用。
分配视图
分配视图描述了软件单元到软件被开发或执行的环境元素的映射。这种视图中的环境各不相同;它可能是硬件、软件执行的操作环境、支持开发或部署的文件系统,或者开发组织。
[表 22.3][ch22tab03] 总结了分配视图的特征。这些视图由软件元素和环境元素组成。环境元素的示例有处理器、磁盘阵列、文件或文件夹,或者一组开发人员。软件元素来自模块视图或组件和连接器(C&C)视图。
| 元素 | 软件元素和环境元素。 软件元素具有对环境的 “要求” 属性。 环境元素具有向软件 “提供” 的属性。 |
|---|---|
| 关系 | “被分配到”:一个软件元素被映射(分配到)一个环境元素。 |
| 约束 | 因视图而异。 |
| 用法 | 用于推理性能、可用性、安全性和可靠性。用于推理分布式开发以及向团队分配工作。用于推理对软件版本的并发访问。用于推理系统安装的形式和机制。 |
分配视图中的关系是 “被分配到”。我们通常在从软件元素到环境元素的映射方面谈论分配视图,尽管反向映射也可能是相关且潜在有趣的。单个软件元素可以被分配到多个环境元素,并且多个软件元素可以被分配到单个环境元素。如果这些分配在系统执行期间随时间变化,那么就说该架构在该分配方面是动态的。例如,进程可能从一个处理器或虚拟机迁移到另一个。
在分配视图中,软件元素和环境元素具有属性。分配视图的一个目标是将软件元素所需的属性与环境元素提供的属性进行比较,以确定分配是否会成功。例如,为了确保其 “所需” 的响应时间,一个组件必须在(被分配到)一个提供足够快处理能力的处理器上执行。再举一个例子,一个计算平台可能不允许一个任务使用超过 10 千字节的虚拟内存;可以使用所讨论的软件元素的执行模型来确定所需的虚拟内存使用情况。类似地,如果您将一个模块从一个团队迁移到另一个团队,您可能希望确保新团队具有处理该模块的适当技能和背景知识。
分配视图可以描绘静态或动态视图。静态视图展示了环境中资源的固定分配。动态视图显示了资源分配发生变化的条件和触发因素。例如,一些系统在负载增加时提供并利用新资源。一个例子是负载均衡系统,其中在另一台机器上创建新的进程或线程。在这种视图中,需要记录分配视图发生变化的条件、运行时软件的分配以及动态分配机制。
回想一下 [第 1 章][ch01],分配结构之一是工作分配结构,它将模块分配给团队进行开发。该分配也可以根据 “负载” 进行更改 —— 在这种情况下,是已经在工作的开发团队的负载。
质量视图
模块视图、组件和连接器视图以及分配视图都是结构视图:它们主要展示架构师在架构中设计的结构,以满足功能和质量属性要求。
这些视图是指导和约束下游开发人员的绝佳选择,下游开发人员的主要工作是实现这些结构。然而,在某些质量属性(或者就此而言,任何利益相关者关注的问题)特别重要且普遍存在的系统中,结构视图可能不是呈现满足这些需求的架构解决方案的最佳方式。原因是解决方案可能分布在多个结构中,而这些结构组合起来很麻烦(例如,因为每个结构中显示的元素类型不同)。
另一种视图,我们称之为 “质量视图”,可以针对特定的利益相关者或解决特定的关注点进行定制。质量视图是通过提取结构视图的相关部分并将它们组合在一起形成的。这里有五个例子:
- “安全视图” 可以展示为提供安全性而采取的所有架构措施。它将描绘具有某种安全角色或责任的组件、这些组件如何通信、安全信息的任何数据存储库以及具有安全利益的存储库。该视图的属性将包括系统环境中的其他安全措施(例如,物理安全)。安全视图还将展示安全协议的操作以及人类与安全元素交互的位置和方式。最后,它将捕获系统如何响应特定的威胁和漏洞。
- “通信视图” 对于全球分散且异构的系统可能特别有帮助。此视图将展示所有组件到组件的通道、各种网络通道、服务质量参数值以及并发区域。这样的视图可用于分析某些类型的性能和可靠性,例如死锁或竞争条件检测。此外,它可以展示(例如)网络带宽如何动态分配。
- “异常” 或 “错误处理视图” 可以帮助阐明并引起对错误报告和解决机制的关注。这样的视图将展示组件如何检测、报告和解决故障或错误。它将帮助架构师确定错误的来源,并为每个错误指定适当的纠正措施。最后,它将在这些情况下促进根本原因分析。
- “可靠性视图” 将对诸如复制和切换等可靠性机制进行建模。它还将描绘时间问题和事务完整性。
- “性能视图” 将包括架构中对推断系统性能有用的那些方面。这样的视图可能会显示网络流量模型、操作的最大延迟等等。
这些和其他质量视图反映了 ISO/IEC/IEEE 标准 42010:2011 的文档编制理念,该标准规定创建由架构利益相关者的关注点驱动的视图。
22.4 组合视图
将架构记录为一组独立视图的基本原理为记录任务带来了分而治之的优势。当然,如果这些视图完全不同且彼此之间没有关联,那么就没有人能够理解整个系统。然而,因为架构中的所有结构都是同一架构的一部分并且存在是为了实现一个共同的目的,所以它们中的许多都彼此有很强的关联。管理架构结构如何关联是架构师工作的重要部分,无论这些结构是否有任何文档存在。
有时,展示两个视图之间强关联的最方便方法是将它们合并为一个 “组合视图”。组合视图包含来自两个或更多其他视图的元素和关系。只要你不尝试在其中加载过多的映射,这样的视图就会非常有用。
合并视图的最简单方法是创建一个 “叠加层”,它结合了原本会出现在两个单独视图中的信息。如果两个视图之间的关系紧密 —— 也就是说,如果一个视图中的元素与另一个视图中的元素之间有很强的关联,那么这种方法效果很好。在叠加层中,元素和关系保留其在组成视图中定义的类型。
以下视图组合通常很自然地出现:
- “不同的组件和连接器(C&C)视图相互组合”。因为所有 C&C 视图都显示了各种类型的组件和连接器之间的运行时关系,所以它们往往结合得很好。不同的(单独的)C&C 视图倾向于显示系统的不同部分,或者倾向于显示其他视图中组件的分解细化。结果通常是一组可以很容易组合的视图。
- “部署视图与任何显示进程的 C&C 视图组合”。进程是部署到处理器、虚拟机或容器上的组件。因此,这些视图中的元素之间有很强的关联。
- “分解视图与任何工作分配、实现、使用或分层视图组合”。分解后的模块构成了工作、开发和使用的单位。此外,这些模块填充了各个层
[图 22.1][ch22fig01] 显示了一个组合视图的示例,它是客户端 - 服务器、多层和部署视图的叠加。

22.5 记录行为
记录架构需要行为文档,通过描述架构元素如何相互交互来补充结构视图。对诸如系统发生死锁的可能性、系统在期望时间内完成任务的能力或最大内存消耗等特性进行推理,要求架构描述提供关于单个元素的特性及其资源消耗的信息,以及它们之间的交互模式 —— 即它们彼此之间的行为方式。在本节中,我们将为您提供关于为获得这些好处需要记录哪些类型内容的指导。
有两种用于记录行为的符号表示:面向跟踪的和综合的。
“跟踪” 是活动或交互的序列,描述系统在特定状态下对特定触发事件的响应。跟踪描述了系统结构元素之间的一系列活动或交互。虽然可以想象描述所有可能的跟踪以生成相当于全面行为模型的结果,但面向跟踪的文档并不会真的试图这样做。在这里,我们描述了四种用于记录跟踪的符号表示:用例图、序列图、通信图和活动图。虽然还有其他符号表示可用(如消息序列图、时序图和业务流程执行语言),但我们选择了这四种作为面向跟踪符号表示的代表性示例。
-
“用例图” 描述了参与者如何使用系统来实现他们的目标;它们经常被用于捕获系统的功能需求。UML 为用例图提供了图形符号,但没有指定用例的文本应如何编写。UML 用例图是提供参与者和系统行为概述的好方法。其描述是文本形式的,应包括以下项目:用例名称和简要描述、启动用例的参与者(主要参与者)、参与用例的其他参与者(次要参与者)、事件流、替代流和不成功的情况。
-
UML “序列图” 显示了从结构文档中提取的元素实例之间的交互序列。在设计系统时,它对于确定需要定义接口的位置很有用。序列图仅显示参与正在记录的场景的实例。它有两个维度:垂直方向表示时间,水平方向表示各种实例。交互按时间顺序从上到下排列。[图 22.2][ch22fig02] 是一个序列图的简单示例,说明了基本的 UML 符号表示。序列图没有明确显示并发性。如果这是你的目标,请使用活动图代替。

如图 [图 22.2][ch22fig02] 所示,对象(即元素实例)有一条生命线,在时间轴上绘制为垂直虚线。序列通常由最左边的参与者启动。实例通过发送消息进行交互,消息显示为水平箭头。消息可以是通过网络发送的消息、函数调用或通过队列发送的事件。消息通常映射到接收者实例接口中的资源(操作)。实线上的填充箭头表示同步消息,而开放箭头表示异步消息。虚线箭头是返回消息。生命线上的执行出现条表示实例正在处理或被阻塞等待返回。
-
UML “通信图” 显示了交互元素的图,并为每个交互标注一个表示其顺序的数字。与序列图类似,通信图中显示的实例是伴随的结构文档中描述的元素。通信图在验证架构是否能够满足功能需求的任务中很有用。当理解并发动作很重要时,如进行性能分析时,这种图就没有用了。
-
UML “活动图” 类似于流程图。它们将业务流程显示为一系列步骤(称为动作),并包括表示条件分支和并发性的符号,以及显示发送和接收事件。动作之间的箭头表示控制流。可选地,活动图可以指示执行动作的架构元素或参与者。值得注意的是,活动图可以表示并发性。分叉节点(描绘为与流箭头垂直的粗条)将流分成两个或更多个并发的动作流。这些并发流可以稍后通过连接节点(也描绘为垂直条)同步为单个流。连接节点在继续之前等待所有输入流完成。
与序列图和通信图不同,活动图不显示在特定对象上执行的实际操作。因此,这些图对于广泛描述特定工作流中的步骤很有用。条件分支(用菱形符号表示)允许单个图表示多个跟踪,尽管活动图通常不会尝试显示所有可能的跟踪或系统(或其一部分)的完整行为。[图 22.3][ch22fig03] 显示了一个活动图。

与跟踪符号表示相反,“综合的” 符号表示显示结构元素的完整行为。有了这种类型的文档,就可以推断从初始状态到最终状态的所有可能路径。状态机是许多全面符号表示使用的一种形式体系。这种形式体系表示架构元素的行为,因为每个状态都是可能导致该状态的所有历史的抽象。状态机语言允许您用对交互的约束以及对内部和环境刺激的定时反应来补充系统元素的结构描述。
UML 状态机图允许您在给定特定输入的情况下跟踪系统的行为。这样的图使用方框表示状态,使用箭头表示状态之间的转换。因此,它对架构的元素进行建模,并有助于说明它们的运行时交互。[图 22.4][ch22fig04] 是一个状态机图的示例,显示了汽车音响系统的状态。

状态机图中的每个转换都用引起转换的事件进行标注。例如,在 [图 22.4][ch22fig04] 中,转换对应于驾驶员可以按下的按钮或影响巡航控制系统的驾驶动作。可选地,转换可以指定一个保护条件,它括在括号中。当与转换对应的事件发生时,评估保护条件,并且只有在此时保护条件为真时转换才被启用。转换也可以有结果,称为动作或效果,用斜线表示。当存在动作时,它表示当转换发生时,斜线后面的行为将被执行。状态也可以指定进入和退出动作。
22.6 视图之外
除了视图和行为之外,关于架构的综合信息将包括以下内容:
-
视图之间的映射。由于架构的所有视图都描述同一个系统,所以任何两个视图有很多共同之处是合理的。组合视图(如 [第 22.4 节][ch22sec04] 所述)会产生一组视图。阐明这些视图之间的关联可以帮助读者深入了解架构作为一个统一的概念整体是如何工作的。
架构中跨视图的元素之间的关联通常是多对多的。例如,每个模块可能映射到多个运行时元素,每个运行时元素可能映射到多个模块。
视图到视图的关联可以方便地用表格捕获。要创建这样的表格,以某种方便的查找顺序列出第一个视图的元素。表格本身应该用注释或介绍来解释它所描绘的关联,即两个视图中的元素之间的对应关系。例如,从组件和连接器视图映射到模块视图的 “由…… 实现”,从模块视图映射到组件和连接器视图的 “实现”,从分解视图映射到分层视图的 “包含在…… 中” 等等。
-
记录模式。如果你在设计中使用模式,如 [第 20 章][ch20] 所建议的,这些模式应该在文档中被识别出来。首先,记录正在使用给定模式这一事实。然后说明为什么选择这种解决方案方法 —— 为什么该模式适合手头的问题。使用模式涉及做出连续的设计决策,最终导致该模式的实例化。这些设计决策可能表现为新实例化的元素以及它们之间的关系,而这些反过来又应该在结构视图中被记录下来。
-
一个或多个上下文图。上下文图展示了系统或系统的一部分如何与其环境相关联。这个图的目的是描绘一个视图的范围。这里的 “上下文” 是指系统(的一部分)与之交互的环境。环境中的实体可以是人类、其他计算机系统或物理对象,如传感器或受控设备。可以为每个视图创建一个上下文图,每个图展示不同类型的元素如何与系统的环境交互。上下文图对于展示系统或子系统如何与其环境交互的初始画面很有用。
-
可变性指南。可变性指南展示了如何运用此视图中所示架构的任何可变点。
-
基本原理。基本原理解释了视图中反映的设计为何会如此。这一部分的目标是解释设计为何具有目前的形式,并提供一个令人信服的论据说明它是合理的。[第 22.7 节][ch22sec07] 更详细地描述了记录基本原理的方法。
-
词汇表和首字母缩略词列表。你的架构可能包含许多专业术语和首字母缩略词。为你的读者解码这些内容将确保所有的利益相关者都在说同一种语言,可以这么说。
-
文档控制信息。列出发布组织、当前版本号、发布日期和状态、变更历史以及向文档提交变更请求的程序。通常,这些信息会在文档的前言部分被捕获。变更控制工具可以提供很多这样的信息。
22.7 记录基本原理
在设计时,你会做出重要的设计决策以实现每次迭代的目标。这些设计决策包括:
- 从几个备选方案中选择一个设计概念;
- 通过实例化所选设计概念来创建结构;
- 建立元素之间的关系并定义接口;
- 分配资源(例如,人员、硬件、计算资源)。
当你研究代表架构的图表时,你看到的是一个思考过程的最终产物,但并不总是能轻易理解为实现这个结果而做出的决策。记录超出所选元素、关系和属性表示的设计决策对于理解你如何得出这个结果至关重要;换句话说,它列出了设计的基本原理。
当你的迭代目标涉及满足一个重要的质量属性场景时,你所做的一些决策将在实现场景响应度量方面发挥重要作用。因此,你应该非常小心地记录这些决策:它们对于促进对你所创建的设计进行分析、促进实现以及在更晚的时候帮助理解架构(例如在维护期间)是必不可少的。鉴于大多数设计决策都是 “足够好” 的,很少是最优的,你还需要证明所做决策的合理性,并记录与你的决策相关的风险,以便可以对其进行审查并可能重新审视。
你可能会觉得记录设计决策是一项繁琐的任务。然而,根据正在开发的系统的关键程度,你可以调整记录的信息量。例如,要记录最少的信息,你可以使用一个简单的表格,如 [表 22.4][ch22tab04]。如果你决定记录比这个最小值更多的信息,以下信息可能会很有用:
- 产生了什么证据来证明决策的合理性?
- 谁做了什么?
- 为什么采取捷径?
- 为什么要做出权衡?
- 你做了哪些假设?
| 设计决策和定位 | 推理和假设(包括被舍弃的备选方案) |
|---|---|
| 在 TimeServerConnector(时间服务器连接器)和 FaultDetectionService(故障检测服务)中引入“并发”(策略)。 | 应该引入并发机制,以便能够同时接收和处理多个事件(陷阱)。 |
| 在通信层中通过引入消息队列来使用“消息传递”模式。 | 虽然使用消息队列会带来性能损失,但选择消息队列是因为某些实现具有高性能,而且这将有助于支持质量属性场景 QA-3。 |
| . . . | . . . |
就像我们建议你在确定元素时记录职责一样,你也应该在做出设计决策时记录下来。如果你等到以后再做,你就不会记得你为什么要做这些事情。
22.8 架构的利益相关者
在 [第 2 章][ch02] 中,我们说过架构的关键目的之一是实现利益相关者之间的沟通。在本章中,我们提到架构文档是为架构利益相关者服务而产生的。那么他们是谁呢?
利益相关者的集合会因组织和项目而异。本节中的利益相关者列表仅为示意,并非完整无缺。作为架构师,你的主要职责之一是确定项目的真正利益相关者。同样,我们在此为每个利益相关者列出的文档需求是典型的,但并非确定不变的。你需要将以下讨论作为起点,并根据项目需求进行调整。
架构的主要利益相关者包括:
-
项目经理:关心进度、资源分配,可能还有出于商业原因发布系统子集的应急计划。为了制定进度计划,项目经理需要了解要实现的模块以及实现的顺序,还有一些关于模块复杂性的信息,例如职责列表,以及它们对其他模块的依赖关系。这些依赖关系可能暗示了实现的特定顺序。项目经理对任何元素的设计细节或确切接口并不感兴趣,除非知道这些任务是否已完成。然而,这个人对系统的总体目标和约束、与其他系统的交互(这可能暗示项目经理必须建立的组织间接口)以及必须获取的硬件环境感兴趣。项目经理可能会创建或协助创建工作分配视图,在这种情况下,他或她将需要一个分解视图来完成。因此,项目经理可能对以下视图感兴趣:
- 模块视图:分解视图、使用视图和 / 或分层视图。
- 分配视图:部署和工作分配。
- 其他:显示交互系统以及系统概述和目的的顶级上下文图。
-
开发团队成员:架构为他们提供了行动指南,对他们的工作方式进行了约束。有时开发人员会被分配负责一个他们没有实现的元素,例如一个现成的商业产品或一个遗留元素。仍然需要有人对该元素负责,以确保它按预期执行并在必要时进行定制。这个人会想知道以下信息:
-
系统背后的总体思路。虽然该信息属于需求领域而非架构领域,但一个顶级上下文图或系统概述可以在很大程度上提供必要的信息。
-
开发人员被分配实现哪些元素,即功能应在哪里实现。
-
分配元素的详细信息,包括其必须操作的数据模型。
-
分配部分与之交互的元素以及这些接口是什么。
-
开发人员可以利用的代码资产。
-
必须满足的约束,例如质量属性、遗留系统接口和预算(资源或财务)。
因此,开发人员可能希望看到以下内容:
-
模块视图:分解、使用和 / 或分层视图以及泛化视图。
-
组件和连接器(C&C)视图:各种视图,显示开发人员被分配的组件以及他们与之交互的组件。
-
分配视图:部署、实现和安装视图。
-
其他:系统概述;包含开发人员被分配的模块的上下文图;开发人员的元素的接口文档以及他们与之交互的元素的接口文档;实现所需可变性的可变性指南;以及基本原理和约束。
-
-
测试人员和集成人员:对于他们来说,架构指定了必须组合在一起的各个部分的正确黑盒行为。黑盒测试人员需要访问元素的接口文档。集成人员和系统测试人员需要查看接口集合、行为规范和使用视图,以便他们可以处理增量子集。因此,测试人员和集成人员可能希望看到以下视图:
- 模块视图:分解视图、使用视图和数据模型。
- C&C 视图:所有视图。
- 分配视图:部署视图;安装视图;以及实现视图,以找出构建模块的资产在哪里。
- 其他:显示要测试或集成的模块的上下文图;模块的接口文档和行为规范以及他们与之交互的元素的接口文档。
测试人员和集成人员值得特别关注,因为一个项目在测试上花费大约一半的总工作量并不罕见。确保一个顺利、自动化且无错误的测试过程将对项目的总体成本产生重大积极影响。
-
必须与该系统互操作的其他系统的设计人员也是利益相关者。对于这些人来说,架构定义了提供和要求的操作集以及它们的操作协议。这些利益相关者可能希望看到以下工件:
- 与他们的系统将交互的那些元素的接口文档,如在模块视图和 / 或 C&C 视图中找到的。
- 与他们的系统将交互的系统的数据模型。
- 来自各种视图的显示交互的顶级上下文图。
-
维护人员将架构作为维护活动的起点,揭示潜在变更将影响的区域。维护人员希望看到与开发人员相同的信息,因为两者都必须在相同的约束下进行更改。但是维护人员还希望看到一个分解视图,以便他们能够确定需要进行更改的位置,也许还需要一个使用视图来帮助他们构建影响分析,以充分了解变更的影响。此外,他们希望看到设计基本原理,这将使他们能够从架构师的原始思考中受益,并通过识别已经被舍弃的设计备选方案来节省时间。因此,维护人员可能希望看到与系统开发人员相同的视图。
-
最终用户不需要看到架构,毕竟架构在很大程度上对他们是不可见的。然而,通过检查架构,他们通常可以对系统、系统的功能以及如何有效地使用它获得有用的见解。如果最终用户或他们的代表审查你的架构,你可能能够发现否则直到部署才会被注意到的设计差异。为了达到这个目的,最终用户可能对以下视图感兴趣:
- C&C 视图:强调控制流和数据转换的视图,以查看输入如何转换为输出;处理感兴趣属性(如性能或可靠性)的分析结果。
- 分配视图:部署视图,以了解功能如何分配到用户与之交互的平台上。
- 其他:上下文图。
-
分析师对设计是否满足系统的质量目标感兴趣。架构为架构评估方法提供素材,并且必须提供评估质量属性所需的信息。例如,架构包括驱动诸如速率单调实时可调度性分析、可靠性框图、仿真和仿真生成器、定理证明器和模型检查器等分析工具的模型。这些工具需要有关资源消耗、调度策略、依赖关系、组件故障率等信息。由于分析可以涵盖几乎任何主题领域,分析师可能需要访问架构文档任何部分中记录的信息。
-
基础设施支持人员设置和维护支持系统的开发、集成、暂存和生产环境的基础设施。可变性指南对于帮助设置软件配置管理环境特别有用。基础设施支持人员可能希望看到以下视图:
- 模块视图:分解视图和使用视图。
- C&C 视图:各种视图,以查看将在基础设施上运行的内容。
- 分配视图:部署视图和安装视图,以查看软件(包括基础设施)将在哪里运行;实现视图。
- 其他:可变性指南。
-
将来的架构师是架构文档最热心的读者,对所有内容都有既得利益。一段时间后,你或者你的继任者(当你得到晋升并被分配到一个更复杂的项目时)将想知道所有关键设计决策以及做出这些决策的原因。将来的架构师对所有内容都感兴趣,但他们将特别渴望获得全面而坦诚的基本原理和设计信息。并且,请记住,那个将来的架构师可能就是你!不要期望记住你现在正在做出的所有这些微小的设计决策。记住,架构文档是你写给未来自己的一封情书。
22.9 实际考虑
到目前为止,本章一直关注架构文档应包含的信息。然而,除了架构文档的内容之外,还有一些涉及文档形式、分发和演变的问题。在本节中,我们将讨论其中的一些问题。
建模工具
有许多商业上可用的建模工具可用于以定义的符号规范架构构造;SysML 是一种广泛使用的选择。许多这些工具提供针对工业环境中实际大规模使用的功能:支持多用户的界面、版本控制、模型的语法和语义一致性检查、支持模型与需求或模型与测试之间的跟踪链接,在某些情况下,还支持自动生成实现模型的可执行源代码。在许多项目中,这些都是必备功能,因此工具的购买价格(在某些情况下并非微不足道)应与项目自行实现这些功能的成本进行评估。
在线文档、超文本和维基
系统的文档可以构建为链接的网页。面向网络的文档通常由一些短页面(创建为适合一个屏幕)组成,具有更深的结构。一个页面通常提供一些概述信息,并具有指向更详细信息的链接。
使用维基等工具,可以创建一个许多利益相关者都可以贡献的 “共享” 文档。托管组织需要决定它希望给予各种利益相关者哪些权限;所使用的工具必须支持所选的权限策略。对于架构文档,我们希望选定的利益相关者对架构进行评论并添加澄清信息,但我们只希望选定的团队人员能够实际更改它。
遵循发布策略
项目的开发计划应指定保持重要文档(包括架构文档)为最新状态的流程。文档工件应像任何其他重要项目工件一样进行版本控制。架构师应计划发布文档版本以支持主要项目里程碑,这通常意味着在里程碑之前足够早地发布,以便开发人员有时间将架构投入使用。例如,可以在每次迭代或冲刺结束时或随着每个增量发布向开发团队提供修订后的文档。
记录动态变化的架构
当你的网络浏览器遇到一种它从未见过的文件类型时,很可能它会上网搜索并下载适当的插件来处理该文件,安装它,并重新配置自身以使用它。无需关闭,更不用说经历编码 - 集成 - 测试的开发周期,浏览器就能够通过添加新组件来改变自己的架构。
利用动态服务发现和绑定的面向服务的系统也具有这些特性。更具挑战性的高度动态、自组织和具有反射性(意味着自我意识)的系统已经存在。在这些情况下,在任何静态架构文档中都无法确定相互交互的组件的身份,更不用说它们的交互了。
从文档角度来看同样具有挑战性的另一种架构动态性存在于以极快速度重建和重新部署的系统中。一些开发机构,例如负责商业网站的那些,每天多次构建他们的系统并使其 “上线”。
无论是在运行时发生变化还是由于高频发布和部署周期而发生变化,所有动态架构在文档方面都有一个共同点:它们的变化速度比文档周期快得多。在任何一种情况下,在新的架构文档生成、审查和发布之前,没有人会拖延事情。
即便如此,了解这些不断变化的系统的架构与了解遵循更传统生命周期的系统的架构一样重要,甚至可以说更重要。如果你是高度动态环境中的架构师,你可以这样做:
- 记录关于你的系统所有版本的真实情况。你的网络浏览器在需要新插件时不会随便抓取任何软件;插件必须具有特定的属性和特定的接口。并且那个新软件也不是随便插在任何地方,而是插在架构中的预定位置。记录这些不变量。这个过程可能会使你记录的架构更像是对任何符合要求的系统版本都必须遵循的约束或指南的描述。这没关系。
- 记录架构被允许改变的方式。在前面提到的例子中,这通常意味着添加新组件并用新实现替换组件。在 [第 22.6 节][ch22sec06] 中讨论的可变性指南中进行此操作。
- 自动生成接口文档。如果你使用明确的接口机制,如协议缓冲区(在 [第 15 章][ch15] 中描述),那么总是有组件接口的最新定义;否则,系统将无法工作。将这些接口定义合并到数据库中,以便有修订历史记录可用,并且可以搜索接口以确定哪些信息在哪些组件中使用。
可追溯性
当然,架构并非孤立存在,而是存在于关于正在开发的系统的信息环境中,其中包括需求、代码、测试、预算和进度等等。每个这些领域的提供者都必须问自己:“我的部分正确吗?我怎么知道?” 这个问题在不同的领域有不同的具体形式;例如,测试人员会问:“我在测试正确的东西吗?” 正如我们在 [第 19 章][ch19] 中看到的,架构是对需求和业务目标的响应,它的 “我的部分正确吗?” 问题的版本是确保这些需求和目标得到满足。可追溯性意味着将特定的设计决策与导致这些决策的特定需求或业务目标相链接,并且这些链接应在文档中捕获。如果在一天结束时,所有架构需求陈述(ASR)都在架构的跟踪链接中得到说明(“覆盖”),那么我们就可以确定架构部分是正确的。跟踪链接可以非正式地表示(例如,一个表格),也可以在项目的工具环境中以技术方式支持。在任何一种情况下,跟踪链接都应该是架构文档的一部分。
22.10 小结
编写架构文档与其他类型的写作很相似。黄金法则是:了解你的读者。你必须理解文档的用途以及文档的受众。架构文档作为各种利益相关者之间沟通的手段:向上到管理层,向下到开发人员,横向到同行。
架构是一个复杂的产物,最好通过聚焦于特定的视角(称为视图)来表达,这些视角取决于要传达的信息。你必须选择要记录的视图,并选择用于记录这些视图的符号。这可能涉及组合有很大重叠的各种视图。你不仅要记录架构的结构,还要记录其行为。
此外,你应该在文档中记录视图之间的关系、你使用的模式、系统的上下文、架构中内置的任何可变性机制以及你的主要设计决策的基本原理。
在创建、维护和分发文档方面还有其他实际考虑因素,例如选择发布策略、选择像维基这样的传播工具以及为动态变化的架构创建文档。
22.11 扩展阅读
《记录软件架构:视图及其他》[[Clements 10a][ref_63]] 对本章中描述的架构文档方法进行了全面阐述。它详细介绍了众多不同的视图及其表示法。还描述了如何将文档整理成一个连贯的整体。附录 A 涵盖了使用统一建模语言(UML)来记录架构和架构信息。
ISO/IEC/IEEE 42010:2011(简称 “eye-so-forty-two-oh-ten”)是 ISO(和 IEEE)标准《系统与软件工程:架构描述》。该标准围绕两个关键理念:架构描述的概念框架,以及关于在任何符合 ISO/IEC/IEEE 42010 的架构描述中必须包含哪些信息的说明,使用由利益相关者关注点驱动的多个观点。
AADL(addl.info)是一种架构描述语言,已成为记录架构的 SAE 标准。SAE 是航空航天、汽车和商用车行业工程专业人员的组织。
SysML 是一种通用系统建模语言,旨在支持系统工程应用的广泛分析和设计活动。它的定义使得可以指定足够的细节以支持各种自动化分析和设计工具。SysML 标准由对象管理组织(OMG)维护;这种语言是 OMG 与国际系统工程委员会(INCOSE)合作开发的。SysML 是作为 UML 的一个概要开发的,这意味着它重用了很多 UML,但也提供了满足系统工程师需求所需的扩展。关于 SysML 的大量信息可在网上获取,但 [[Clements 10a][ref_63]] 的附录 C 讨论了如何使用 SysML 来记录架构。在本书付印时,SysML 2.0 正在开发中。
在 [[Cervantes 16][ref_54]] 中可以找到一个在设计时记录架构决策的扩展示例。
22.12 问题讨论
1. 访问你最喜欢的开源系统的网站并查找其架构文档。有什么内容?缺少什么?这将如何影响你为这个项目贡献代码的能力?
2. 银行对安全性有充分的理由保持谨慎。草拟一份自动取款机(ATM)所需的文档,以便对其安全架构进行推理。
3. 如果你正在设计一个基于微服务的架构,你需要记录哪些元素、关系和属性才能对端到端延迟或吞吐量进行推理?
4. 假设你的公司刚刚收购了另一家公司,你被赋予将你公司的一个系统与另一家公司的类似系统进行合并的任务。你希望看到另一个系统架构的哪些视图?为什么?你会要求两个系统都有相同的视图吗?
5. 你会在什么时候选择使用跟踪表示法来记录行为,什么时候会使用综合表示法?每种方法你能获得什么价值?又需要付出什么努力?
6. 你会将项目预算的多少用于软件架构文档编制?为什么?你如何衡量成本和收益?如果你的项目是一个安全关键系统或高安全性系统,这会如何变化?
第23章 管理架构债务
与 Yuanfang Cai 合作
有些债务在你欠下的时候是有趣的,但当你着手偿还它们的时候,就没有一个是有趣的了。
—— 奥格登·纳什(Ogden Nash)
如果不加以仔细关注并投入的努力,设计会随着时间的推移变得越来越难以维护和演进。我们将这种形式的熵称为 “架构债务”,它是一种重要且代价高昂的技术债务形式。十多年来,技术债务这一广泛领域得到了深入研究 —— 主要聚焦于代码债务。架构债务通常比代码债务更难检测且更难消除,因为它涉及非局部性的问题。那些对发现代码债务很有效的工具和方法(代码审查、代码质量检查器等等)通常在检测架构债务方面效果不佳。
当然,并非所有债务都是负担,也并非所有债务都是不良债务。有时在进行有价值的权衡时也会违反原则 —— 例如,为了提高运行时性能或缩短上市时间而牺牲低耦合或高内聚。
本章介绍一种分析现有系统中架构债务的流程。这个流程为架构师提供了识别和管理这种债务的知识和工具。它通过识别具有问题设计关系的架构上相互关联的元素,并分析它们的维护成本模型来起作用。如果该模型表明存在问题,通常由异常高的变更量和错误数量所指示,这就意味着存在架构债务的区域。
一旦确定了架构债务,如果情况足够糟糕,就应该通过重构来消除它。如果没有定量的回报证据,通常很难让项目利益相关者同意这一步骤。没有架构债务分析的业务案例是这样的:“我将用三个月的时间重构这个系统,并且不给你任何新功能。” 哪个经理会同意呢?然而,有了我们在这里介绍的这些分析方法,你可以向你的经理提出一个完全不同的方案,一个用投资回报率和提高生产力来表述的方案,能够在短时间内回报重构的努力,甚至更多。
我们所倡导的流程需要三种类型的信息:
- 源代码。这用于确定结构依赖关系。
- 从项目的版本控制系统中提取的修订历史。这用于确定代码单元的共同演进。
- 从问题控制系统中提取的问题信息。这用于确定变更的原因。
用于分析债务的模型识别架构中错误率和变动率(提交的代码行数)异常高的区域,并试图将这些症状与设计缺陷联系起来。
23.1 确定你是否存在架构债务问题
在我们管理架构债务的流程中,我们将关注架构元素的物理表现形式,即存储其源代码的文件。我们如何确定一组文件在架构上是否相互关联呢?一种方法是确定项目中文件之间的静态依赖关系 —— 例如,这个方法调用那个方法。你可以使用静态代码分析工具找到这些依赖关系。第二种方法是捕获项目中文件之间的演化依赖关系。当两个文件一起发生变化时,就会出现一个 “演化依赖关系”,你可以从你的版本控制系统中提取此信息。
我们可以使用一种特殊的邻接矩阵,称为设计结构矩阵(DSM)来表示文件依赖关系。虽然其他表示方式当然也是可能的,但 DSM 在工程设计中已经使用了几十年,并且目前有许多工业工具支持它。在 DSM 中,感兴趣的实体(在我们的例子中是文件)既放置在矩阵的行上,又以相同的顺序放置在列上。矩阵的单元格被标注以指示依赖关系的类型。
我们可以用信息标注 DSM 单元格,以表明行上的文件继承自列上的文件,或者它调用列上的文件,或者它与列上的文件共同变化。前两种标注是结构性的,而第三种是演化(或历史)依赖关系。
重复一下:DSM 中的每一行代表一个文件。一行上的条目显示了这个文件对系统中其他文件的依赖关系。如果系统具有低耦合性,你会期望 DSM 是稀疏的;也就是说,任何给定的文件将只依赖于少量的其他文件。此外,你会希望 DSM 是下三角矩阵;也就是说,所有的条目都出现在对角线以下。这意味着一个文件只依赖于较低层次的文件,而不依赖于较高层次的文件,并且在你的系统中没有循环依赖关系。
[图 23.1][ch23fig01] 展示了来自 Apache Camel 项目(一个开源集成框架)的 11 个文件及其结构依赖关系(分别用 “dp”、“im” 和 “ex” 表示依赖、实现和扩展)。例如,[图 23.1][ch23fig01] 中第 9 行的文件 “MethodCallExpression.java” 依赖并扩展了第 1 列的文件 “ExpressionDefinition.java”,第 11 行的文件 “AssertionClause.java” 依赖于第 10 列的文件 “MockEndpoint.java”。这些静态依赖关系是通过对源代码进行逆向工程提取出来的。

图 23.1 Apache Camel 的设计结构矩阵(DSM)展示结构依赖关系
[图 23.1][ch23fig01] 中所示的矩阵非常稀疏。这意味着这些文件在结构上彼此没有高度耦合,因此,你可能会期望相对容易独立地更改这些文件。换句话说,这个系统似乎架构债务相对较少。
现在考虑 [图 23.2][ch23fig02],它在图 23.1 的基础上覆盖了历史共同变化信息。历史共同变化信息是从版本控制系统中提取的。这表明两个文件在提交中一起变化的频率。

图 23.2 带有演化依赖关系覆盖的 Apache Camel 的设计结构矩阵
[图 23.2][ch23fig02] 展示了关于 Camel 项目的一幅非常不同的画面。例如,第 8 行第 3 列的单元格标有 “4”:这意味着 “BeanExpression.java” 和 “MethodNotFoundException.java” 之间没有结构关系,但在修订历史中发现它们一起变化了四次。一个既有数字又有文本的单元格表示这一对文件既有结构耦合关系又有演化耦合关系。例如,第 22 行第 1 列的单元格标有 “dp, 3”:这意味着 “XMLTokenizerExpression.java” 依赖于 “ExpressionDefinition.java”,并且它们一起变化了三次。
[图 23.2][ch23fig02] 中的矩阵相当密集。虽然这些文件通常在结构上彼此没有耦合,但它们在演化上有很强的耦合。此外,我们在矩阵对角线上方的单元格中看到很多标注。因此,耦合不仅是从高层次文件到低层次文件,而是在各个方向上都存在。
实际上,这个项目存在高架构债务。架构师们证实了这一点。他们报告说,项目中的几乎每一次变更都既昂贵又复杂,并且预测新功能何时准备好或错误何时被修复是具有挑战性的。
虽然这种定性分析本身对架构师或分析师可能有价值,但我们可以做得更好:我们实际上可以量化我们的代码库已经承载的债务的成本和影响,并且我们可以完全自动地做到这一点。为此,我们使用 “热点” 的概念 —— 具有设计缺陷的架构区域,有时也被称为架构反模式或架构缺陷。
23.2 发现热点
如果你怀疑你的代码库存在架构债务 —— 也许错误率在上升,功能开发速度在下降 —— 你需要确定造成这种债务的具体文件及其有缺陷的关系。
与基于代码的技术债务相比,架构债务通常更难识别,因为其根本原因分布在几个文件及其相互关系中。如果你的系统中存在循环依赖,并且依赖循环涉及六个文件,那么你的组织中不太可能有人完全理解这个循环,而且它也不容易被观察到。对于这些复杂的情况,我们需要自动化形式的帮助来识别架构债务。
我们将对系统维护成本做出巨大贡献的元素集合称为 “热点”。架构债务由于高耦合和低内聚而导致高维护成本。因此,为了识别热点,我们寻找导致高耦合和低内聚的反模式。这里强调了六种常见的反模式 —— 几乎在每个系统中都会出现:
- 不稳定接口。一个有影响力的文件 —— 代表系统中的一个重要服务、资源或抽象 —— 与其依赖文件频繁一起变化,这在修订历史中有所记录。“接口” 文件是其他系统元素使用该服务或资源的入口点。由于内部原因、其 API 的变化或两者兼而有之,它经常被修改。要识别这种反模式,寻找一个有大量依赖文件且经常与其他文件一起被修改的文件。
- 违反模块化。结构上解耦的模块经常一起变化。要识别这种反模式,寻找两个或更多结构上独立的文件 —— 即彼此之间没有结构依赖关系的文件 —— 但它们经常一起变化。
-
不健康的继承。一个基类依赖于它的子类,或者一个客户端类同时依赖于基类和它的一个或多个子类。要确定不健康的继承实例,在设计结构矩阵(DSM)中寻找以下两组关系之一:
- 在继承层次结构中,父类依赖于它的子类。
- 在继承层次结构中,类层次结构的一个客户端同时依赖于父类和它的一个或多个子类。
- 循环依赖或聚集。一组文件紧密连接。要识别这种反模式,寻找形成强连通图的文件集合,其中图的任何两个元素之间都存在结构依赖路径。
- 包循环依赖。两个或更多包相互依赖,而不是像它们应该的那样形成层次结构。检测这种反模式与检测聚集类似:通过发现形成强连通图的包来确定包循环。
- 交叉依赖。一个文件既具有大量的依赖文件,又有大量它所依赖的文件,并且它与其依赖文件和它所依赖的文件频繁一起变化。要确定交叉依赖中心的文件,寻找一个与其他文件既有高入度又有高出度并且与这些其他文件有大量共同变化关系的文件。
并非热点中的每个文件都会与其他每个文件紧密耦合。相反,一组文件可能彼此紧密耦合,而与其他文件解耦。每个这样的集合都是一个潜在的热点,并且是通过重构去除债务的潜在候选。
[图 23.3][ch23fig03] 是基于 Apache Cassandra(一个广泛使用的 NoSQL 数据库)中的文件的设计结构矩阵。它展示了一个聚集(依赖循环)的例子。在这个 DSM 中,你可以看到第 8 行的文件(“locator.AbstractReplicationStrategy”)依赖于文件 4(“service.WriteResponseHandler”)并聚合文件 5(“locator.TokenMetadata”)。文件 4 和 5 反过来又依赖于文件 8,从而形成一个聚集。

Cassandra 的第二个示例展示了不健康的继承反模式。[图 23.4][ch23fig04] 中的依赖结构矩阵(DSM)显示了io.sstable.SSTableReader类(第 14 行)继承自io.sstable.SSTable(第 12 行)。在 DSM 中,继承关系通过 “ih” 标记表示。然而,请注意,io.sstable.SSTable依赖于io.sstable.SSTableReader,如单元格(12,14)中的 “dp” 注释所示。这种依赖关系是一种调用关系,这意味着父类调用子类。请注意,单元格(12,14)和(14,12)都标有数字 68。根据项目的修订历史,这表示io.sstable.SSTable和io.sstable.SSTableReader共同提交变更的次数。这种过高的共同变更次数是一种债务形式。可以通过重构来消除这种债务,即将一些功能从子类移到父类中。

图 23.4 Apache Cassandra 中的架构反模式
问题跟踪系统中的大多数问题可以分为两大类:错误修复和功能增强。错误修复以及与错误相关和与变更相关的代码变动都与反模式和热点高度相关。换句话说,那些参与反模式并需要频繁进行错误修复或频繁变更的文件很可能是热点。
对于每个文件,我们确定错误修复和变更的总数,以及该文件经历的代码变动总量。接下来,我们将每个反模式中文件经历的错误修复、变更和代码变动相加。这就根据每个反模式对架构债务的贡献为其赋予了一个权重。通过这种方式,可以识别所有背负债务的文件以及它们的所有关系,并对其债务进行量化。
基于这个过程,一个减少债务的策略(通常通过重构实现)很简单。了解与债务有关的文件以及它们有缺陷的关系(由已确定的反模式决定),使架构师能够制定并证明重构计划的合理性。例如,如果存在一个团,就需要移除或反转一个依赖关系,以打破依赖循环。如果存在不健康的继承,就需要移动一些功能,通常是从子类移动到父类。如果发现违反了模块性,文件之间共享的未封装的 “秘密” 就需要封装为它自己的抽象。等等。
23.3 示例
我们用一个案例研究来说明这个过程,我们将其称为 SS1,是与跨国软件外包公司 SoftServe 一起进行的。在进行分析时,SS1 系统包含 797 个源文件,我们记录了它在两年期间的修订历史和问题。SS1 由六名全职开发人员和更多的临时贡献者维护。
识别热点
在我们研究 SS1 的期间,在其 Jira 问题跟踪器中记录了 2756 个问题(其中 1079 个是bug),在 Git 版本控制存储库中记录了 3262 次提交。
我们使用刚才描述的过程来识别热点。最后,确定了三个在架构上相关的文件集群,它们包含了项目中最有害的反模式,因此也是项目中债务最多的。这三个集群的债务总共代表了 291 个文件,在整个项目的 797 个文件中,略多于项目文件的三分之一。与这三个集群相关的缺陷数量占项目总缺陷的 89%(265 个)。
项目的首席架构师同意这些集群存在问题,但难以解释原因。当看到这个分析时,他承认这些是真正的设计问题,违反了多个设计规则。然后,架构师制定了一些重构方案,重点是修复热点中确定的文件之间有缺陷的关系。这些重构是基于消除热点中的反模式,因此架构师在如何进行重构方面有很多指导。
但是进行这种重构是否 “值得” 呢?毕竟,并非所有的债务都值得偿还。这是下一节的主题。
量化架构债务
由于分析建议的修复措施非常具体,架构师可以很容易地根据热点中的反模式确定的每个重构所需的人月数进行估计。成本效益方程的另一面是重构带来的好处。为了估计节省的成本,我们做一个假设:重构后的文件在未来的错误修复数量将与过去平均文件的错误修复数量大致相同。这实际上是一个非常保守的假设,因为过去的平均错误修复数量被已确定热点中的那些文件夸大了。此外,这个计算没有考虑错误的其他重大成本,如声誉损失、销售损失以及额外的质量保证和调试工作。
我们根据为错误修复提交的代码行数来计算这些债务的成本。这个信息可以从项目的修订控制和问题跟踪系统中检索到。
对于 SS1,我们进行的债务计算如下:
- 架构师估计重构三个热点所需的工作量为 14 个人月。
- 我们计算了整个项目每年每个文件的平均错误修复次数为 0.33。
- 我们计算了热点文件每年的平均错误修复次数为 237.8。
- 根据这些结果,我们估计重构后热点文件的每年错误修复次数将为 96。
- 热点文件实际的代码变动量与重构后预期的代码变动量之间的差异就是预期的节省。
重构文件的估计年度节省(使用公司平均生产率数字)为 41.35 个人月。考虑步骤 1 至 5 的计算,我们可以看到,花费 14 个人月的成本,项目每年可以预期节省超过 41 个人月。
在一个又一个案例中,我们看到了这样的投资回报。一旦确定了架构债务,就可以偿还它们,并且项目在功能速度和错误修复时间方面会明显变得更好,其方式足以弥补所涉及的努力。
23.4 自动化
这种形式的架构分析可以完全自动化。在 [第 23.2 节][ch23sec02] 中介绍的每一种反模式都可以以自动化的方式识别出来,并且可以将工具集成到持续集成工具套件中,以便持续监测架构债务。这个分析过程需要以下工具:
- 一个从问题跟踪器中提取一组问题的工具。
- 一个从修订控制系统中提取日志的工具。
- 一个对代码库进行逆向工程的工具,以确定文件之间的语法依赖关系。
- 一个从提取的信息中构建依赖结构矩阵(DSM)并遍历 DSM 以寻找反模式的工具。
- 一个计算每个热点相关债务的工具。
这个过程所需的唯一专门工具是构建 DSM 和分析 DSM 的工具。项目可能已经有了问题跟踪系统和修订历史,并且有很多逆向工程工具可用,包括开源选项。
23.5 小结
本章介绍了一种在项目中识别和量化架构债务的流程。架构债务是一种重要且代价高昂的技术债务形式。与基于代码的技术债务相比,架构债务通常更难识别,因为其根本原因分布在多个文件及其相互关系中。
本章概述的流程包括从项目的问题跟踪器、版本控制系统和源代码本身收集信息。利用这些信息,可以识别架构反模式并将其分组为热点,并且可以量化这些热点的影响。
这种架构债务监测流程可以自动化并集成到系统的持续集成工具套件中。一旦识别出架构债务,如果情况足够糟糕,就应该通过重构来消除它。这个流程的输出提供了向项目管理提出重构业务案例所需的定量数据。
23.6 扩展阅读
目前,技术债务领域有丰富的研究文献。“技术债务” 一词由沃德・坎宁安(Ward Cunningham)在 1992 年创造(尽管当时他只是简单地称之为 “债务”[[Cunningham 92][ref_73]])。这个想法被许多其他人完善和阐述,其中最著名的是马丁・福勒(Martin Fowler) [[Fowler 09][ref_91]] 和史蒂夫・麦康奈尔(Steve McConnell) [[McConnell 07][ref_177]]。乔治・费尔班克斯(George Fairbanks)在他的《IEEE Software》文章 “Ur-Technical Debt”[[Fairbanks 20][ref_86]] 中描述了债务的迭代性质。在 [[Kruchten 19][ref_154]] 中可以找到对管理技术债务问题的全面研究。
本章中使用的架构债务定义借鉴自 [[Xiao 16][ref_260]]。SoftServe 案例研究发表在 [[Kazman 15][ref_142]] 中。
一些用于创建和分析依赖结构矩阵(DSMs)的工具在 [[Xiao 14][ref_259]] 中进行了描述。用于检测架构缺陷的工具在 [[Mo 15][ref_180]] 中进行了介绍。
架构缺陷的影响在包括 [[Feng 16][ref_88]] 和 [[Mo 18][ref_182]] 在内的几篇论文中进行了讨论和实证研究。
23.7 问题讨论
1. 你如何区分有架构债务的项目和一个正在实施大量功能的 “忙碌” 项目?
2. 找到经历过重大重构的项目的例子。用于激励或证明这些重构的证据是什么?
3. 在什么情况下积累债务是一种合理的策略?你如何知道你已经达到了债务过多的地步?
4. 架构债务与其他类型的债务(如代码债务、文档债务或测试债务)相比,是更有害还是危害较小?
5. 与第 21 章中讨论的方法相比,讨论这种架构分析的优缺点。
第五部分 架构和组织
第24章 架构师在项目中的角色
我不知道为什么人们雇了架构师,然后又告诉他们该做什么。
——弗兰克·盖里(Frank Gehry)
任何在课堂之外进行的架构实践都发生在一个更大的开发项目背景下,而开发项目是由一个或多个组织中的人员进行规划和执行的。尽管架构非常重要,但它只是实现更大目标的手段。在本章中,我们将讨论架构的各个方面以及源于开发项目实际情况的架构师职责。
我们首先讨论一个关键的项目角色,即项目经理,作为架构师的你很可能与这个角色有着密切的工作关系。
24.1 架构师和项目经理
团队中最重要的关系之一是软件架构师和项目经理之间的关系。项目经理负责项目的整体绩效 —— 通常是确保项目在预算内、按时进行,并配备合适的人员从事合适的工作。为了履行这些职责,项目经理经常会向项目架构师寻求支持。
可以将项目经理主要视为对项目的面向外部的方面负责,而软件架构师则对项目的内部技术方面负责。外部视图需要准确反映内部情况,内部活动需要准确反映外部利益相关者的期望。也就是说,项目经理应该了解并向上级管理层反映项目中的进展和风险,而软件架构师应该了解并向开发人员反映外部利益相关者的关注点。项目经理和软件架构师之间的关系对项目的成功有很大影响。他们应该保持良好的工作关系,并注意他们所扮演的角色以及这些角色的界限。
《项目管理知识体系指南》(PMBOK)列出了项目经理的一些知识领域。在这些领域中,项目经理可能会向架构师寻求意见。[表 24.1][ch24tab01] 确定了 PMBOK 所描述的知识领域以及软件架构师在该领域中的角色。
| PMBOK知识领域 | 描述 | 软件架构师角色 |
|---|---|---|
| 项目集成管理 | 确保项目的各个要素得到恰当的协调 | 进行设计并围绕设计组织团队;管理依赖关系。实现指标的收集。协调变更请求。 |
| 项目范围管理 | 确保项目包含所有必需的工作,并且只包含必需的工作 | 引出、协商并审查运行时需求,并生成开发需求。估计满足需求相关的成本、进度和风险。 |
| 项目时间管理 | 确保项目及时完成 | 帮助定义工作分解结构。定义跟踪措施。建议向软件开发团队分配资源。 |
| 项目成本管理 | 确保项目在所需预算内完成 | 从各个团队收集成本信息;就构建/购买决策以及资源分配提出建议。 |
| 项目质量管理 | 确保项目能够满足其开展的需求 | 为质量进行设计,并根据设计对系统进行跟踪。定义质量指标。 |
| 项目人力资源管理 | 确保项目充分有效地利用参与项目的人员。 | 定义所需的技术技能组合。为开发人员提供职业发展路径方面的指导。推荐培训。面试候选人。 |
| 项目沟通管理 | 确保项目信息及时、适当地生成、收集、传播、存储和处置 | 确保开发人员之间的沟通和协调。征求关于进度、问题和风险的反馈。监督文档工作。 |
| 项目风险管理 | 识别、分析和应对项目风险 | 识别并量化风险;调整架构和流程以减轻风险。 |
| 项目采购管理 | 从组织外部获取商品和服务 | 确定技术需求;推荐技术、培训和工具。 |
给架构师的建议
与项目经理保持良好的工作关系。了解项目经理的任务和关注点,以及作为架构师可能会被要求如何支持这些任务和关注点。
24.2 增量式架构与利益相关者
敏捷方法建立在增量开发的支柱之上,每个增量都为客户或用户提供价值。我们将在单独的章节中讨论敏捷与架构,但即使项目不是敏捷项目,仍然应该期望按照支持项目自身测试和发布计划的节奏增量式地开发和发布架构。
那么,增量式架构就是关于增量式地发布架构。具体来说,这意味着增量式地发布架构文档(如 [第 22 章][ch22] 所述)。这反过来又需要决定发布哪些视图(从计划的集合中)以及发布到何种深度。使用我们在 [第 1 章][ch01] 中概述的结构,可以考虑将以下内容作为你的第一个增量的候选:
- 模块分解结构。这将为开发项目提供团队结构信息,使项目组织得以形成。可以定义团队、配备人员、制定预算和进行培训。团队结构将是项目规划和预算的基础,因此这个技术结构定义了项目的管理结构。
- 模块 “使用” 结构。这将允许规划增量,这在任何希望增量式发布软件的项目中都至关重要。正如我们在 [第 1 章][ch01] 中所说,使用结构用于设计可以扩展以添加功能的系统,或者可以从中提取有用的功能子集。如果你不计划增量具体是什么,那么尝试创建一个特意支持增量开发的系统是有问题的。
- 最能传达整体解决方案方法的组件和连接器(C&C)结构。
- 一个大致的部署结构,至少要解决主要问题,例如系统是否将部署在移动设备上、在云基础设施上等。
在此之后,在制定后续版本的内容时,以架构的利益相关者的需求为指导。
给架构师的建议
首先也是最重要的,确保你知道你的利益相关者是谁以及他们的需求是什么,这样你就可以设计适当的解决方案和文档。此外:
- 与项目的利益相关者合作,确定发布节奏和每个项目增量的内容。
- 你的第一个架构增量应该包括模块分解和使用视图,以及一个初步的 C&C 视图。
- 利用你的影响力确保早期发布处理系统最具挑战性的质量属性要求,从而确保在开发周期后期不会出现令人不快的架构意外。
- 分阶段发布你的架构以支持那些项目增量,并在开发利益相关者处理每个增量时满足他们的需求。
24.3 架构与敏捷开发
敏捷开发最初是对一些开发方法的反抗,其中包括在流程方面僵化且重量级、在所需文档方面过于苛刻、专注于前期规划和设计,并最终以一次性交付为高潮,而每个人都希望这个交付结果一开始就与客户真正想要的东西相似。敏捷主义者主张将原本可能花费在流程和文档上的资源分配到弄清楚客户真正想要什么,并以小的、可测试的交付增量尽早提供。
关键问题是:在需求分析、风险缓解和架构设计方面,一个项目应该进行多少前期工作?这个问题没有单一的正确答案,但可以为任何给定的项目找到一个 “最佳点”。“正确” 的项目工作量取决于几个因素,其中最主要的是项目规模,但其他重要因素包括复杂的功能需求、高度苛刻的质量属性要求、易变的需求(与领域的 “先例性” 或新颖性相关)以及开发的分布程度。
那么架构师如何实现适当的敏捷性呢?[图 24.1][ch24fig01] 展示了你的选择。可以选择瀑布式的 “前期大设计”(BDUF),如 [图 24.1 (a)][ch24fig01] 所示。或者可以完全不顾架构的谨慎性,相信敏捷主义者所说的 “涌现” 方法,即最终架构随着程序员交付他们的增量而出现,如 [图 24.1 (b)][ch24fig01] 所示。这种方法可能适用于小而简单的项目,这些项目可以迅速转向并根据需要进行简单的重构,但我们从未见过它在大型、复杂的项目中起作用。

毫不奇怪,我们推荐的方法介于这两个极端之间:这是 “迭代 0” 方法,如 [图 24.1 (c)][ch24fig01] 所示。在你对需求有一定了解的项目中,你应该考虑通过执行几次属性驱动设计(ADD;在 [第 20 章][ch20] 中描述)的迭代来开始。这些设计迭代可以专注于选择主要的架构模式(包括一个参考架构,如果合适的话)、框架和组件。以如 [24.2 节][ch24sec02] 中所建议的方式,以有助于架构的利益相关者的方式支持项目的增量。早期,这将帮助你构建项目结构、定义工作分配和团队组建,并解决最关键的质量属性。如果需求发生变化 —— 特别是如果这些变化驱动质量属性需求 —— 采用敏捷实验的实践,其中使用 “尖峰” 来解决新需求。“尖峰” 是一个有时间限制的任务,创建它是为了回答技术问题或收集信息;它不旨在产生一个成品。尖峰在一个单独的代码分支中开发,如果成功,则合并到代码的主分支中。通过这种方式,可以从容应对新出现的需求并进行管理,而不会对整体开发过程造成太大干扰。
敏捷编程和架构并不总是处于最佳状态。2001 年的《敏捷宣言》,即敏捷运动的 “首要指令”,暗示架构是涌现的,不需要预先进行规划或设计。
过去(现在仍然)很容易找到已发表的关于敏捷的论述,宣称如果你没有交付可工作的软件,那么你就没有做任何有价值的事情。由此可以推断,如果你正在从事架构工作,那么你就是在从编程中拿走资源,因此,你在做 “毫无价值的事情”—— 架构,哼!写代码,架构就会自然涌现。
对于中型到大型系统,在严酷的经验压力下,这种观点不可避免地崩溃了。质量属性需求的解决方案不能简单地在开发的任意后期阶段 “附加” 到现有系统上。安全、高性能、安全性以及更多关注点的解决方案必须从一开始就设计到系统架构中,即使计划的前 20 个增量交付不涉及这些能力。是的,你可以开始编码,是的,架构会涌现 —— 但它会是错误的架构。
简而言之,对于敏捷和架构之间的任何结合,《敏捷宣言》都是一个相当糟糕的婚前协议。然而,伴随《宣言》的是 12 条敏捷原则,如果善意地解读,这些原则暗示了两个阵营之间的中间立场。[表 24.2][ch24tab02] 列出了这些原则,并对每个原则提供了以架构为中心的评论。
| 敏捷原则 | 以架构为中心的视角 |
|---|---|
| 我们的首要任务是通过尽早且持续地交付有价值的软件来满足客户需求。 | 绝对地 |
| 欢迎需求变更,即使在开发后期也如此。敏捷流程利用变更为客户带来竞争优势。 | 绝对地。这一原则可通过提供高度可修改性([第 8 章][ch08])和可部署性([第 5 章][ch05])的架构来实现。 |
| 频繁地交付可工作的软件,时间间隔从几周到几个月不等,倾向于更短的时间尺度。 | 绝对地,只要这一原则不被视为排除经过深思熟虑的架构。DevOps 在这方面可以发挥很大的作用,并且正如我们在 [第 5 章][ch05] 中所看到的,架构可以如何支持 DevOps。 |
| 业务人员和开发人员必须在整个项目期间每天一起工作。 | 正如我们在 [第 19 章][ch19] 中所讨论的,业务目标会导致质量属性需求,而架构的首要职责就是满足这些需求。 |
| 围绕积极主动的个人来构建项目。为他们提供所需的环境和支持,并相信他们能够完成工作。 | 虽然我们原则上同意,但许多开发人员缺乏经验。因此,一定要包括一位技术娴熟、经验丰富且积极主动的架构师来帮助指导这些人。 |
| 在开发团队中以及向开发团队传达信息的最有效方法是面对面交谈。 | 对于重要的系统来说,这是无稽之谈。人类发明书写是因为我们的大脑无法记住我们需要记住的所有事情。接口、协议、架构结构等等都需要被记录下来,反复指导的低效率和无效性以及由此产生的误解错误都证明了这一原则是错误的。按照这种说法,没有人应该制作用户手册,而应该只公布开发人员的电话号码,并公开邀请随时拨打。对于任何有维护阶段的系统(几乎是每个系统)来说,这也是无稽之谈,因为在维护阶段,最初的团队无处可寻。你要和谁进行面对面的交谈来了解重要的细节呢?关于这个问题,请参阅 [第 22 章][ch22] 以获取我们的指导。 |
| 可工作的软件是进度的主要衡量标准。 | 是的,只要“主要”不被理解为“唯一”,并且只要这一原则不被用作除了编码之外取消所有工作的借口。 |
| 敏捷流程促进可持续发展。发起者、开发人员和用户应该能够无限期地保持稳定的节奏。 | 绝对地 |
| 持续关注技术卓越性和良好的设计能增强敏捷性。 | 绝对地 |
| 简洁(即最大化未完成工作的艺术)至关重要。 | 是的,当然,只要理解我们未做的工作实际上可以安全地舍弃而不会对正在交付的系统造成损害。 |
| 最好的架构、需求和设计来自于自组织团队。 | 不,并非如此。最好的架构是由熟练、有才华、经过训练且经验丰富的架构师有意识地设计出来的,正如我们在 [第 20 章][ch20] 中所描述的那样。 |
| 团队会定期反思如何变得更有效率,然后相应地调整和改变其行为。 | 绝对地 |
所以有六个 “绝对地” 的赞同,四个一般性的赞同,以及两个强烈的反对。
敏捷,在其最初被编纂时,似乎在构建小型产品的小型组织中效果最佳。中等规模到大型规模的组织希望将敏捷应用于大型项目时,很快发现协调大量小型敏捷团队是一个巨大的挑战。在敏捷中,小团队在短时间内完成小部分工作。一个挑战是确保这些众多(几十个到几百个)小团队适当地划分了工作,以便没有工作被遗漏,也没有工作被重复做。另一个挑战是对团队的众多任务进行排序,以便他们的结果可以频繁且快速地合并,以产生一个合理运行的系统的下一个小增量。
在企业规模上应用敏捷的一个方法示例是规模化敏捷框架(SAFe),它大约在 2007 年出现,并且从那时起一直在不断完善。SAFe 提供了一个工作流、角色和流程的参考模型,在这个模型下,大型组织可以协调许多团队的活动,每个团队都以经典的敏捷方式运作,以系统地、成功地生产出一个大规模的系统。
SAFe 承认架构的作用。它接纳 “有意架构”,其定义会引起本书读者的共鸣。有意架构 “定义了一组有目的、有计划的架构策略和举措,这些策略和举措增强了解决方案的设计、性能和可用性,并为团队间的设计和实现同步提供指导。” 但 SAFe 也强烈建议一种称为 “涌现式设计” 的平衡力量,它 “为完全进化和增量式实现方法提供技术基础”(scaledagileframework.com)。我们认为,这些品质也会从有意架构中涌现出来,因为如果没有仔细的预先思考,快速进化的能力和支持增量式实现的能力是不会出现的。实际上,本书中涵盖了实现这些的方法。
24.4 架构和分布式开发
如今,大多数重要项目都是由分布式团队开发的,这里的 “分布式” 可能意味着分布在一栋建筑的不同楼层、一个工业园区的不同建筑、处于一两个不同时区的不同园区,或者分布在全球各地的不同部门或分包商之间。
分布式开发既有好处也有挑战:
- 成本:劳动力成本因地点而异,有一种观点认为将部分开发工作转移到低成本地区必然会降低项目的总体成本。实际上,经验表明,对于软件开发来说,从长远来看可能会节省成本。然而,在低成本地区的开发人员具备足够的领域专业知识之前,以及在管理实践适应分布式开发的困难之前,必须进行大量的返工,从而削减甚至可能抵消工资成本节省下来的资金。
- 技能组合和劳动力可用性:组织可能无法在一个地点雇佣到所有开发人员:搬迁成本可能很高,开发人员库的规模可能很小,或者所需的技能组合可能很专业且在一个地点不可用。以分布式方式开发系统允许工作转移到有工人的地方,而不是强迫工人转移到工作地点,尽管这会以增加沟通和协调成本为代价。
- 对当地市场的了解:开发针对其所在市场销售的系统变体的开发人员对合适的功能类型以及可能出现的文化问题类型有更多的了解。
分布式开发在项目中是如何进行的呢?假设模块 A 使用模块 B 的接口。随着时间的推移,情况发生变化,这个接口可能需要修改。因此,负责模块 B 的团队必须与负责模块 A 的团队进行协调,如 [图 24.2][ch24fig02] 所示。如果这种协调只涉及在共用自动售货机旁的简短交谈,那很容易,但如果涉及在其中一个团队处于半夜的时候预先计划的网络会议,那就不那么容易了。

更广泛地说,协调方法包括以下选项:
- 非正式接触:只有当团队在同一地点时,才可能在咖啡室或走廊中碰面等非正式接触。
- 文档:如果文档编写良好、组织有序且得到恰当传播,那么无论团队是否在同一地点,都可以将其用作协调团队的一种手段。
- 会议:团队可以举行预定的或临时的、面对面的或远程的会议,以帮助团队团结起来并提高对问题的认识。
- 异步电子通信:各种形式的异步电子通信可以用作协调机制,例如电子邮件、新闻组、博客和维基。
协调方法的选择取决于许多因素,包括组织的基础设施、企业文化、语言技能、涉及的时区以及依赖特定模块的团队数量。在组织建立起分布式团队之间的协调工作方法之前,团队之间的误解很可能会导致项目延迟,在某些情况下还会导致严重缺陷。
这对架构和架构师意味着什么呢?这意味着在分布式开发中,向团队分配职责比在集中式开发中更为重要,在集中式开发中,所有开发人员都在一个办公室,或者至少距离很近。这也意味着对模块依赖关系的关注比其在可修改性和性能等质量属性中的通常作用更加重要:由全球分布式团队所拥有的模块之间的依赖关系更有可能出现问题,应尽可能将其最小化。
此外,在分布式开发中,文档尤其重要。在同一地点的团队有各种非正式的协调可能性,例如去隔壁办公室,或在咖啡室或走廊中碰面。远程团队没有这些非正式机制可用,因此他们必须依赖更正式的机制,如文档,并且当出现疑问时,团队成员必须主动相互交谈。
在本书准备出版之际,由于新冠疫情危机,世界各地的公司都在学习应对远程参与和居家办公的做法。现在就确定这场大流行对商业世界的长期影响还为时过早,但似乎很可能会使分布式开发成为常态。一起工作的人们现在都通过电话会议进行;不再有走廊交谈或在自动售货机旁的会议。为了让工作能够继续进行,每个人都在学习适应分布式开发模式。看看这是否会带来任何新的架构趋势将是很有趣的事情。
24.5 小结
软件架构师在某种类型的开发项目背景下开展工作。因此,他们需要从这个角度理解自己的角色和职责。
项目经理和软件架构师可以被视为扮演互补的角色:经理从管理角度运行项目,架构师从技术解决方案角度运行项目。这两个角色在各种方面相互交叉,架构师可以支持经理以提高项目成功的机会。
在一个项目中,架构并非完全成形于宙斯的额头,而是以对利益相关者有用的增量方式发布。因此,架构师需要很好地理解架构的利益相关者及其信息需求。
敏捷方法专注于增量式开发。随着时间的推移,架构和敏捷(尽管它们一开始相处得并不融洽)已成为不可或缺的伙伴。
全球开发创造了对明确协调策略的需求,这种策略基于比集中式开发所需的更正式的策略。
24.6 扩展阅读
丹・保利什(Dan Paulish)写了一本关于在以架构为中心的环境中进行管理的优秀书籍 ——《以架构为中心的软件项目管理:实用指南》,本章中关于分布式开发的内容改编自他的书 [[Paulish 02][ref_209]]。
你可以在scaledagileframework.com上了解 SAFe。在 SAFe 之前,敏捷社区的一些成员已经独立得出了一种中等量级的管理流程,该流程提倡前期架构。有关架构在敏捷项目中的作用,请参阅 [[Coplein 10][ref_70]]。
项目管理的基本概念在 IEEE 指南《采用项目管理协会(PMI)标准:项目管理知识体系指南(第六版)》[[IEEE 17][ref_121]] 中有所涵盖。
软件架构度量指标通常在项目中属于架构师的职责范围。库林(Coulin)等人的一篇论文提供了关于这个主题的文献的有用概述,并在此过程中对度量指标本身进行了分类 [[Coulin 19][ref_71]]。
架构师在组织中占据独特的位置。他们被期望在系统的整个生命周期(从摇篮到坟墓)的所有阶段都能熟练应对。在项目的所有成员中,他们是对项目和系统的所有利益相关者的需求最敏感的人。他们通常被选为架构师的部分原因是他们具有高于平均水平的沟通技巧。《软件架构师电梯:重新定义数字企业中架构师的角色》[[Hohpe 20][ref_116]] 描述了架构师在组织内外与各级人员互动的这种独特能力。
24.7 问题讨论
1. 将 “适合全球分布式开发” 视为一个可以通过架构设计决策增加或减少的质量属性,就像本书 [第二部分][part02] 中概述的其他质量属性一样。为其构建一个通用场景,并列出有助于实现它的策略。哦,还要为它想出一个好名字。
2. 通用项目管理实践通常提倡创建工作分解结构作为项目产生的第一个工件。从架构角度来看,这种做法有什么问题?
3. 如果你正在管理一个全球分布式团队,你会首先创建哪些架构文档工件?
4. 如果你正在管理一个全球分布式团队,项目管理的哪些方面必须改变以考虑文化差异?
5. 架构评估如何用于帮助指导和管理项目?
6. 在 [第 1 章][ch01] 中,我们描述了软件架构的工作分配结构,它可以被记录为工作分配视图。讨论为你的架构记录工作分配视图如何为软件架构师和经理提供一种合作工具来为项目配备人员。工作分配视图中架构师应该提供的部分和经理应该提供的部分之间的分界线在哪里?
第25章 架构能力
人生苦短,学海无涯。
—— 杰弗里・乔叟(Geoffrey Chaucer)
如果软件架构值得去做,那肯定值得做好。大多数关于架构的文献都集中在技术方面。这并不奇怪,因为它是一门深奥的技术学科。但架构是由在充满真实人类的组织中工作的“架构师”创建的。与这些人打交道绝对是非技术性的工作。怎样做才能帮助架构师,尤其是正在接受培训的架构师,在工作的这一重要方面做得更好?怎样做才能帮助组织更好地鼓励其架构师做出最佳工作成果?
这一章是关于架构师个人的能力以及希望产生高质量架构的组织的。
由于一个组织的架构能力在一定程度上取决于架构师的能力,我们首先要问的是,期望架构师做什么、知道什么以及擅长什么。然后,我们将探讨组织能够而且应该做些什么来帮助其架构师构建更好的架构。个人和组织的能力是相互交织的。只了解其中之一是不行的。
25.1 个人能力:架构师的职责、技能和知识
架构师所从事的活动远不止直接构建架构。这些活动,我们称之为“职责”,构成了个人架构能力的主干。关于架构师的著述也会提及“技能”和“知识”。例如,清晰地传达想法和有效协商的能力,就是通常认为有能力的架构师所具备的技能。此外,架构师需要掌握关于模式、技术、标准、质量属性以及众多其他主题的最新知识。
职责、技能和知识构成了个人架构能力所依赖的一个三元组。这三者之间的关系如 [图 25.1][ch25fig01] 所示——即技能和知识支持履行所需职责的能力。如果(无论出于何种原因)无限有才华的架构师不能履行该职位所需的职责,那么他们是无用的;我们不会说他们是有能力的。

为了给出这些概念的示例:
- “设计架构” 是一项职责。
- “抽象思考的能力” 是一项技能。
- “模式和策略” 构成知识。
这些示例特意说明了技能和知识对于有效履行职责的能力的支持是重要的(且仅止于此)。再举个例子,“为架构编写文档”是一项职责,“清晰写作的能力”是一项技能,“ISO 标准 42010”是相关知识体系的一部分。当然,一个技能或知识领域可以支持多项职责。
了解架构师的职责、技能和知识(或者更确切地说,是在特定组织环境中架构师所需要的职责、技能和知识)有助于为个体架构师制定衡量和改进策略。如果您想提高个人的架构能力,应该采取以下步骤:
- 积累履行职责的经验。学徒制是获取经验的有效途径。仅仅接受教育是不够的,因为没有在职应用的教育仅仅是增加了知识。
- 提高您的非技术技能。这方面的改进包括参加专业发展课程,例如领导力或时间管理方面的课程。有些人可能永远不会成为真正伟大的领导者或沟通者,但我们都可以在这些技能上有所提高。
- 掌握知识体系。有能力的架构师必须做的最重要的事情之一是掌握知识体系并保持其更新。为了强调跟上该领域最新进展的重要性,想想仅仅在过去几年中出现的架构师所需知识的进步。例如,几年前支持云计算的架构([第 17 章][ch17])并不重要。参加课程、获得认证、阅读书籍和期刊、访问网站、阅读博客、参加面向架构的会议、加入专业协会以及与其他架构师会面,都是提高知识水平的有用方法。
职责
本节总结了各种各样的架构师职责。并非每个组织中的每个架构师在每个项目中都会履行所有这些职责。然而,有能力的架构师如果发现自己参与到这里列出的任何活动中,都不应感到惊讶。我们将这些职责分为技术职责([表 25.1][ch25tab01])和非技术职责([表 25.2][ch25tab02])。您应该立即注意到的一点是,非技术职责的数量众多。对于那些希望成为架构师的人来说,一个明显的含义是,您必须充分关注您的教育和职业活动中的非技术方面。
| 一般职责范围 | 具体职责范围 | 职责示例 |
|---|---|---|
| 架构 | 创建架构 | 设计或选择一种架构。创建软件架构设计计划。构建产品线或产品架构。做出设计决策。扩展细节并完善设计以收敛到最终设计。确定模式和策略,并阐明架构的原则和关键机制。对系统进行分区。定义组件如何组合在一起以及如何相互作用。创建原型。 |
| 评估和分析架构 | 评估一个架构(针对您当前的系统或其他系统),以确定用例和质量属性场景的满足程度。创建原型。参与设计评审。审查初级工程师设计的组件的设计。审查设计是否符合架构。比较软件架构评估技术。为替代方案建模。进行权衡分析。 | |
| 记录架构 | 准备对利益相关者有用的架构文档和演示文稿。记录或自动记录软件接口的文档。制定文档标准或指南。记录可变性和动态行为。 | |
| 处理和改进现有系统 | 维护和改进现有系统及其架构。衡量架构债务。将现有系统迁移到新技术和新平台。重构现有架构以降低风险。检查错误、事件报告和其他问题,以确定对现有架构的修订。 | |
| 履行架构师的其他职责 | 推销愿景。保持愿景的活力。参加产品设计会议。在架构、设计和开发方面提供技术建议。为软件设计活动提供架构指南。领导架构改进活动。参与软件过程的定义和改进。对软件开发活动进行架构监督。 | |
| 与架构工作之外的生命周期活动相关的职责 | 管理需求 | 分析功能性和质量属性的软件需求。理解业务、组织和客户的需求,并确保这些需求得到满足。倾听并理解项目的范围。了解客户的关键设计需求和期望。就软件设计选择和需求选择之间的权衡提供建议。 |
| 评估未来的技术 | 分析当前的 IT 环境,并针对不足之处推荐解决方案。与供应商合作,代表组织的需求并影响未来的产品。编写并展示技术白皮书。 | |
| 选择工具和技术 | 管理新软件解决方案的引入。对新技术和新架构进行技术可行性研究。从架构的角度评估商业工具和软件组件。制定内部技术标准,并为外部技术标准的制定做出贡献。 |
| 一般职责范围 | 具体职责范围 | 职责示例 |
|---|---|---|
| 管理 | 支持项目管理 | 提供关于项目的适当性和难度的反馈。协助预算编制和规划。遵循预算限制。管理资源。进行规模估算和评估。进行迁移规划和风险评估。负责或监督配置控制。制定开发进度表。使用指标衡量结果,并提高个人成果和团队的生产力。确定并安排架构发布。充当技术团队和项目经理之间的“桥梁”。 |
| 管理架构师团队中的人员 | 建立“值得信赖的顾问”关系。协调。激励。倡导。培训。担任主管。分配职责。 | |
| 组织和业务相关职责 | 支持组织 | 在组织内培养架构评估能力。审查并为研发工作做出贡献。参与团队的招聘流程。协助产品营销。建立具有成本效益且合适的软件架构设计审查制度。帮助开发知识产权。 |
| 支持业务 | 理解和评估业务流程。将业务战略转化为技术战略。影响业务战略。理解并传达软件架构的业务价值。帮助组织实现其业务目标。了解客户和市场趋势。 | |
| 领导力和团队建设 | 提供技术领导力 | 成为思想领袖。制作技术趋势分析或路线图。指导其他架构师。 |
| 建设团队 | 组建开发团队,并使其与架构愿景保持一致。指导开发人员和初级架构师。对团队进行架构使用方面的培训。促进团队成员的职业发展。指导软件设计工程师团队进行规划、跟踪,并在商定的计划内完成工作。指导和培训员工使用软件技术。保持架构组内外的士气。监控和管理团队动态。 |
架构师通常还会履行许多其他职责,例如领导代码审查或参与测试规划。在许多项目中,架构师会在关键领域协助实际的实现和测试。虽然这些很重要,但严格来说,它们并非架构师的职责。
技能
鉴于上一节中列举的广泛职责,架构师需要具备哪些技能?关于架构师在项目中的特殊领导角色,已经有很多论述;理想的架构师是有效的沟通者、管理者、团队建设者、有远见者和导师。一些证书或认证项目强调非技术技能。这些认证项目的常见评估领域包括领导力、组织动态和沟通。
[表 25.3][ch25tab03] 列举了对架构师最有用的一组技能。
| 一般技能领域 | 特定技能领域 | 示例技能 |
|---|---|---|
| 沟通技巧 | 对外沟通(团队之外) | 具备口头和书面沟通及展示的能力。具备向不同受众展示和解释技术信息的能力。具备知识传递的能力。具备说服的能力。具备从多个观点看问题和推销的能力。 |
| 对内沟通(团队内部) | 倾听、访谈、咨询和协商的能力。理解和表达复杂主题的能力。 | |
| 人际交往能力 | 团队关系 | 作为团队成员的能力。与上级、下级、同事和客户有效合作的能力。保持建设性工作关系的能力。在多元化团队环境中工作的能力。激发创造性协作的能力。建立共识的能力。具备外交手腕和尊重他人的能力。指导他人的能力。处理和解决冲突的能力。 |
| 工作技能 | 领导力 | 做出决策的能力。采取主动和创新的能力。展现独立判断、具有影响力和令人尊敬的能力。 |
| 工作负载管理 | 在压力下良好工作、规划、管理时间和进行估算的能力。支持广泛的问题并同时处理多个复杂任务的能力。在高压环境中有效确定任务优先级并执行任务的能力。 | |
| 在企业环境中出类拔萃的技能 | 具备战略思考的能力。能够在一般监督和限制条件下工作的能力。组织工作流程的能力。能够察觉组织中权力所在以及权力如何流动的能力。想尽办法完成工作的能力。具备创业精神、自信而不具攻击性以及接受建设性批评的能力。 | |
| 处理信息的技能 | 能够注重细节,同时保持整体视野和重点。能够纵览全局。 | |
| 处理意外情况的技能 | 容忍模糊性的能力。承担和管理风险的能力。解决问题的能力。具有适应能力、灵活性、开放性思维和韧性的能力。 | |
| 抽象思考的能力 | 能够观察不同的事物,并找到一种方法来发现它们实际上只是同一件事的不同实例。这可能是架构师所拥有的最重要的技能之一。 |
知识
一位称职的架构师对架构知识体系有着深入的了解。[表 25.4][ch25tab04] 给出了架构师的一系列知识领域。
| 一般知识领域 | 特定知识领域 | 特定知识示例 |
|---|---|---|
| 计算机科学知识 | 架构概念的知识 | 对架构框架、架构模式、策略、结构和视图、参考架构、与系统和企业架构的关系、新兴技术、架构评估模型和方法以及质量属性的了解。 |
| 软件工程知识 | 对软件开发知识领域的了解,包括需求、设计、构建、维护、配置管理、工程管理和软件工程过程。对系统工程的了解。 | |
| 计算机科学知识 | 设计知识 | 对工具以及设计和分析技术的了解。对如何设计复杂的多产品系统的了解。对面向对象的分析和设计以及 UML 和 SysML 图表的了解。 |
| 编程知识 | 对编程语言和编程语言模型的了解。对安全、实时、安全等方面的专门编程技术的了解。 | |
| 对技术和平台的了解 | 特定的技术和平台 | 对硬件/软件接口、基于网络的应用程序和互联网技术的了解。对特定软件/操作系统的了解。 |
| 技术和平台的一般知识 | 了解 IT 行业的未来发展方向以及基础设施对应用程序的影响方式。 | |
| 关于组织的背景和管理的知识 | 领域知识 | 对最相关领域和特定领域技术的了解 |
| 行业知识 | 对行业最佳实践和行业标准的了解。了解如何在在岸/离岸团队环境中工作。 | |
| 业务知识 | 对公司的业务实践、其竞争对手的产品、策略和流程的了解。对商业和技术战略以及业务流程再造原则和流程的了解。对战略规划、财务模型和预算编制的了解。 | |
| 领导和管理技术 | 如何指导、辅导和培训软件团队成员的知识。项目管理的知识。项目工程的知识。 |
那经验方面呢?
阿尔伯特·爱因斯坦说过:“知识的唯一来源是经验。” 几乎每个人都说经验是最好的老师。我们同意。然而,经验并非唯一的老师——你也可以从真正的老师那里获取知识。我们多么幸运,不必都通过烫伤自己来获得“触摸热炉子不是个好主意”这一知识。
我们将经验视为能增加架构师知识储备的东西,这就是为什么我们不单独对待它。随着您职业生涯的发展,您将积累自己丰富的经验,并将其作为知识存储起来。
正如那个老笑话所说,在纽约,一位行人拦住一位路人问道:“不好意思。您能告诉我怎么去卡内基音乐厅吗?”碰巧是音乐家的这位路人长叹一口气回答道:“练习,练习,练习。”
的确如此。
25.2 软件架构组织的能力
组织通过其实践和结构,推动或者是阻碍架构师履行职责。例如,如果一个组织为架构师设有职业发展路径,这将激励员工成为架构师。如果一个组织设有常设的架构审查委员会,那么项目架构师将知道如何以及与谁安排审查。如果没有这些实践和结构,这意味着架构师必须与组织斗争,或者在没有内部指导的情况下确定如何进行审查。因此,询问特定组织在架构方面是否有能力,并开发旨在衡量组织架构能力的工具是有意义的。组织的架构能力是本节的主题。以下是我们的定义:
一个组织的架构能力是指该组织在个人、团队和组织层面上培养、运用和维持有效开展以架构为中心的实践所需的技能和知识的能力,以产生成本可接受的架构,从而形成与组织业务目标相一致的系统。
组织对于架构有职责、技能和知识,就像个体架构师一样。例如,为架构工作提供充足的资金是一项组织职责,有效地利用现有的架构人力(通过适当的团队组建和其他方式)也是如此。这些是组织职责,因为它们不受个体架构师的控制。组织层面的技能可能是应用于架构师的有效的知识管理或人力资源管理。组织的知识的一个例子是软件项目可能采用的基于架构的生命周期模型的构成。
以下是组织为帮助提高其架构工作的成功率可以履行的一些事项(职责):
-
人员相关:
- 招聘有才华的架构师。
- 为架构师建立职业发展路径。
- 通过知名度、奖励和声望使架构师的职位备受尊重。
- 让架构师加入专业组织。
- 建立架构师认证计划。
- 为架构师建立导师指导计划。
- 建立架构培训和教育计划。
- 评估架构师的绩效。
- 让架构师获得外部架构师认证。
- 根据项目的成功或失败对架构师进行奖励或惩罚。
-
流程相关:
- 建立整个组织范围内的架构实践。
- 为架构师明确职责和权力声明。
- 为架构师建立交流和分享信息及经验的论坛。
- 建立架构审查委员会。
- 在项目计划中包含架构里程碑。
- 让架构师为产品定义提供输入。
- 举办整个组织范围内的架构会议。
- 测量和跟踪所产生架构的质量。
- 引入外部架构专家顾问。
- 让架构师为开发团队结构提供建议。
- 让架构师在整个项目生命周期中发挥影响力。
-
技术相关:
- 建立并维护可复用架构和基于架构的工件的存储库。
- 创建并维护设计概念的存储库。
- 提供集中式资源来分析和协助架构工具。
如果您正在一个组织中面试架构师的职位,您可能会有一系列问题来决定是否愿意在那里工作。对于那个问题清单,您可以添加从前面的列表中提取的问题,以帮助您确定该组织的架构能力水平。
25.3 成为更优秀的架构师
架构师如何成为优秀的架构师,优秀的架构师又如何成为杰出的架构师?在本章的结尾,我们提出一个建议,那就是:接受指导,并指导他人。
接受指导
虽然经验可能是最好的老师,但在一生中,我们大多数人都没有足够的幸运能够亲自获得使我们成为杰出建筑师所需的所有经验。但我们可以间接获取经验。找一位您尊敬的熟练建筑师,并与他/她建立联系。了解您所在的组织是否有您可以参加的指导计划。或者建立一种非正式的指导关系——找借口互动、提问或主动提供帮助(例如,主动提出担任评审员)。
您的导师不一定非得是同事。您还可以加入专业社团,在那里您可以与其他成员建立导师关系。有聚会。有专业的社交网络。不要把自己局限于所在的组织。
指导他人
你也应该愿意指导他人,以此作为回报或传递那些丰富了你职业生涯的善意的一种方式。但指导他人也有一个自私的原因:我们发现,教授一个概念是检验我们是否深刻理解该概念的试金石。如果我们无法教授它,很可能我们并没有真正理解它——所以这可以成为你在该行业中教导和指导他人的目标的一部分。优秀的教师几乎总是会表示他们很高兴能从学生身上学到很多,以及学生的探索性问题和令人惊讶的见解如何加深了教师对该学科的更深入理解。
25.4 小结
当我们想到软件架构师时,通常首先想到的是他们所产出的技术工作。但是,正如架构远不止是系统的技术“蓝图”一样,架构师也远不止是架构的设计者。这促使我们以更全面的方式去理解,架构师和以架构为中心的组织要想成功必须做些什么。架构师必须履行职责、磨炼技能,并持续获取成功所需的知识。
成为一名优秀乃至更出色的架构师的关键在于持续学习、指导他人以及接受他人的指导。
25.5 扩展阅读
探究一个组织能力的问题可以在技术说明“评估和提高架构能力的模型”中找到,sei.cmu.edu/library/abstracts/reports/08tr006.cfm 。
开放组织有一个针对信息技术、业务和企业架构师的技能、知识和经验进行资格认证的项目,这与衡量和认证个体架构师的能力有关。
信息技术架构知识体系(ITABoK)是一个“免费的公共知识库,包含了从全球最大的信息技术架构专业组织 Iasa 的个人和企业成员的经验中发展而来的信息技术架构最佳实践、技能和知识”(https://itabok.iasaglobal.org/itabok/)。
Bredemeyer 咨询公司(bredemeyer.com)提供了大量关于信息技术、软件和企业架构师及其角色的资料。
约瑟夫·因杰诺(Joseph Ingeno)在《软件架构师手册》中专门用了一章来论述“软件架构师的软技能”,还用了另一章来论述“成为更优秀的软件架构师” [[Ingeno 18][ref_128]] 。
25.6 问题讨论
1. 在本章所讨论的技能和知识中,你认为自己可能最缺乏哪些?您将如何减少这些不足?
2. 你认为对于个体架构师而言,改进哪些职责、技能或知识是最重要或最具成本效益的?请说明您的理由。
3. 增加三项我们的列表中没有的职责、三项技能和三个知识领域。
4. 你将如何衡量项目中特定架构职责的价值?你将如何区分这些职责所增加的价值与诸如质量保证或配置管理等其他活动所增加的价值?
5. 你将如何衡量某人的沟通技能?
6. 本章列出了一个具备架构能力的组织的许多实践。根据预期收益超过预期成本的情况对该列表进行优先级排序。
7. 假设你负责为公司的一个重要系统招聘一名架构师。你将如何进行?在面试中您会问候选人什么问题?你会要求他们提供任何东西吗?如果是,是什么?你会让他们参加某种测试吗?如果是,是什么?你公司里会让谁来面试他们?为什么?
8. 假设你是被招聘的架构师。关于你正在面试的公司,你会问哪些与 [第 25.2 节][ch25sec02] 中列出的领域相关的问题?尝试从职业生涯早期的架构师的角度回答这个问题,然后再从具有多年经验的高技能架构师的角度回答。
9. 搜索架构师的认证项目。对于每个项目,尝试描述它分别在职责、技能和知识方面的涉及程度。
第六部分 总结
第26章 未来一瞥:量子计算
[可以将量子计算机与]1903年莱特兄弟在基蒂霍克试飞的飞机相提并论。莱特飞行器勉强离开地面,但它预示着一场革命。
在影响软件架构实践的发展方面,未来会带来什么呢?人类在预测长期未来方面出了名地差劲,但我们还是不断尝试,因为,嗯,这很有趣。在本书的结尾,我们选择聚焦于一个完全属于未来但又似乎近在咫尺的特定方面:量子计算。
量子计算机在未来五到十年内可能会变得实用。考虑一下你目前正在开发的系统可能会有几十年(是复数哦)的使用寿命。20 世纪 60 年代和 70 年代编写的代码在今天仍在日常使用。如果你的系统有这样的使用寿命,那么当量子计算机变得实用时,你可能需要对其进行转换以利用量子计算机的能力。
量子计算机引起了高度关注,因为它们有潜力以远远超过最强大的传统计算机的速度进行计算。2019 年,谷歌宣布其量子计算机在 200 秒内完成了一项复杂计算。谷歌称,同样的计算即使是最强大的超级计算机也需要大约 10000 年才能完成。并不是说量子计算机只是比传统计算机快很多地做传统计算机所做的事情;而是它们利用量子物理学的超凡特性做传统计算机做不到的事情。
量子计算机并不会在解决所有问题上都比传统计算机更好。例如,对于许多最常见的面向事务的数据处理任务,它们可能无关紧要。它们擅长解决涉及组合数学且对传统计算机来说计算困难的问题。然而,量子计算机不太可能为你的手机或手表供电,也不太可能放在你的办公桌上。
理解量子计算机的理论基础需要深入了解物理学,包括量子物理学,而这远远超出了我们的范围。作为背景,在 20 世纪 40 年代传统计算机被发明时也是如此。随着时间的推移,由于引入了有用的抽象概念,如高级编程语言,对理解 CPU 和内存如何工作的要求已经消失了。同样的事情也会发生在量子计算机上。在本章中,我们在不涉及底层物理学(我们已经知道这会让人头大)的情况下介绍量子计算的基本概念。
26.1 单量子比特
量子计算机中的基本计算单位是一个称为 “量子比特”(稍后会详细介绍)的量子信息单位。量子计算机的简单定义是一种操纵量子比特的处理器。在本书出版时,现存最好的量子计算机包含几百个量子比特。
“量子处理单元”(QPU)将以与如今图形处理单元与中央处理器交互相同的方式与经典中央处理器交互。换句话说,中央处理器将把量子处理单元视为一种提供某些输入并产生某些输出的服务。中央处理器与量子处理单元之间的通信将以经典比特为单位。量子处理单元利用输入产生输出的具体方式不在中央处理器的考虑范围内。
经典计算机中的一个比特的值要么是 0 要么是 1,并且在正常运行时,它所呈现的值是明确的。此外,经典计算机中的比特具有非破坏性读出。也就是说,测量该值将给出 0 或 1,并且该比特将保留在读取操作开始时所具有的值。
量子比特在这两个特性上有所不同。量子比特由三个数字来表征。其中两个数字是概率:测量得到 1 的概率和测量得到 0 的概率。第三个数字称为相位,描述了量子比特的旋转。对量子比特的测量将返回 0 或 1(具有指定的概率),并且将破坏量子比特的当前值并用它所返回的值替换。对于 0 和 1 都具有非零概率的量子比特被称为处于叠加态。
通过使概率成为复数来管理相位。幅度(概率)被表示为 |α|² 和 |β|²。如果 |α|² 是 40%,|β|² 是 60%,那么 10 次测量中有 4 次会是 0,10 次测量中有 6 次会是 1。这些幅度会受到一定的测量误差概率影响,降低这种误差概率是构建量子计算机的工程挑战之一。
这个定义有两个结果:
- |α|² + |β|² = 1。因为 |α|² 和 |β|² 分别是测量得到 0 或 1 的概率,并且因为测量将给出其中一个值,所以概率之和必须是 1。
- 不能复制量子比特。从经典比特 A 复制到经典比特 B 是读取比特 A,然后将该值存储到 B 中。对量子比特 A 的测量(即读取)将破坏 A 并给出 0 或 1 的值。因此,存储到量子比特 B 中的将是 0 或 1,并且不会包含 A 中嵌入的概率或相位。
相位值是 0 到 2π 弧度之间的一个角度。它不影响叠加态的概率,但为操纵量子比特提供了另一个杠杆。一些量子算法通过操纵某些量子比特的相位来标记它们。
对量子比特的操作
一些单量子比特操作类似于经典比特操作,而另一些则是量子比特特有的。大多数量子操作的一个特点是它们是可逆的;也就是说,给定一个操作的结果,有可能恢复该操作的输入。可逆性是经典比特操作和量子比特操作的另一个区别。可逆性的一个例外是读取(READ)操作:由于测量是破坏性的,所以读取操作的结果不能恢复原始量子比特。量子比特操作的例子包括以下内容:
- 读取操作将一个单量子比特作为输入,并以由输入量子比特的幅度决定的概率产生 0 或 1 作为输出。输入量子比特的值坍缩为 0 或 1。
- 非(NOT)操作将处于叠加态的量子比特的幅度翻转。也就是说,结果量子比特为 0 的概率是其原本为 1 的概率,反之亦然。
- Z 操作将量子比特的相位增加 Π(以 2Π 为模)。
- 哈达玛(HAD,Hadamard 的缩写)操作创建一个等概率叠加态,这意味着值为 0 和 1 的量子比特的幅度分别相等。0 输入值产生 0 弧度的相位,1 输入值产生 Π 弧度的相位。
可以将多个操作链接在一起以产生更复杂的功能单元。
一些操作符对多个量子比特起作用。主要的双量子比特操作符是控制非(CNOT)。第一个量子比特是控制位。如果它是 1,那么该操作对第二个量子比特执行非操作。如果第一个量子比特是 0,那么第二个量子比特保持不变。
纠缠
纠缠是量子计算的关键要素之一。它在经典计算中没有类似物,赋予了量子计算一些非常奇特和奇妙的特性,使其能够做到经典计算机无法做到的事情。
如果对两个量子比特进行测量时,第二个量子比特的测量结果与第一个量子比特的测量结果相匹配,那么这两个量子比特就被称为 “纠缠”。无论两次测量之间的时间间隔有多长,或者量子比特之间的物理距离有多远,纠缠都可能发生。这就引出了所谓的量子隐形传态。系好安全带吧。
26.2 量子隐形传态
回想一下,不可能直接将一个量子比特复制到另一个量子比特。因此,如果我们想将一个量子比特复制到另一个量子比特,我们必须使用间接手段。此外,我们必须接受原始量子比特状态的破坏。接收量子比特将具有与被破坏的原始量子比特相同的状态。这种状态的复制被称为 “量子隐形传态”。原始量子比特和接收量子比特之间不需要有任何物理关系,它们之间的距离也没有限制。因此,在物理实现的量子比特之间,即使相隔数百或数千公里,也可以远距离传输信息。
量子比特的隐形传态取决于纠缠。回想一下,纠缠意味着对一个纠缠量子比特的测量将保证对第二个量子比特的测量具有相同的值。隐形传态利用三个量子比特。量子比特 A 和 B 纠缠在一起,然后量子比特 ψ 与量子比特 A 纠缠。量子比特 ψ 被隐形传态到量子比特 B 的位置,并且其状态变为量子比特 B 的状态。大致来说,隐形传态通过以下四个步骤进行:
- 使量子比特 A 和 B 纠缠。我们在上一节中讨论了这意味着什么。A 和 B 的位置可以在物理上是分开的。
- 准备 “有效载荷”。有效载荷量子比特将具有要被隐形传态的状态。有效载荷,即量子比特 ψ,在 A 的位置准备好。
- 传播有效载荷。传播涉及两个经典比特,它们被传输到 B 的位置。传播还涉及测量 A 和 ψ,这会破坏这两个量子比特的状态。
- 在 B 中重新创建 ψ 的状态。
我们省略了很多关键细节,但关键是:量子隐形传态是量子通信的一个重要组成部分。它依赖于通过传统通信信道传输两个比特。它本质上是安全的,因为窃听者所能确定的只是通过传统信道发送的两个比特。因为 A 和 B 通过纠缠进行通信,它们不会在物理上通过通信线路发送。美国国家标准与技术研究院(NIST)正在考虑各种不同的基于量子的通信协议,作为一种称为 HTTPQ 的传输协议的基础,该协议旨在替代 HTTPS。考虑到用一种通信协议替代另一种通信协议需要几十年的时间,目标是在能够破解 HTTPS 的量子计算机可用之前采用 HTTPQ。
26.3 量子计算与加密
量子计算机在计算函数的逆方面极为擅长 —— 特别是哈希函数的逆。在很多情况下,这种计算会非常有用,在解密密码方面尤其如此。密码几乎从不直接存储;相反,存储的是它们的哈希值。只存储哈希值背后的假设是,计算哈希函数的逆在计算上很困难,使用传统计算机的话可能需要数百年甚至数千年的时间。然而,量子计算机改变了这种计算。
格罗弗算法(Grover’s algorithm)是一种计算函数逆的概率算法的例子。基于 256 位哈希计算其逆大约需要 2 的 128 次方次迭代。这表示相对于传统计算算法有二次方的加速,意味着量子算法的时间大约是传统算法时间的平方根。这使得大量以前被认为是安全的受密码保护的材料变得相当脆弱。
现代安全加密算法基于分解两个大素数乘积的难度。设 p 和 q 是两个不同的素数,每个素数的大小都大于 128 位。这两个素数的乘积 pq 的大小约为 256 位。给定 p 和 q,计算这个乘积相对容易。然而,在传统计算机上分解乘积 pq 并恢复 p 和 q 在计算上非常困难:它属于 NP 困难类别。
这意味着,给定一个基于素数 p 和 q 加密的消息,如果你知道 p 和 q,解密这个消息相对容易,但如果你不知道( 至少在传统计算机上)实际上是不可能的。然而,量子计算机可以比传统计算机更有效地分解 pq。肖尔算法(Shor’s algorithm)是一种量子算法,可以在大约以 p 和 q 的位数的对数为量级的运行时间内分解 pq。
26.4 其他算法
量子计算在许多应用中也具有类似的变革性潜力。在这里,我们通过介绍一个必要但目前尚不存在的硬件 —— 量子随机存取存储器(QRAM)来开始我们的讨论。
量子随机存取存储器(QRAM)
量子随机存取存储器(QRAM)是实现和应用许多量子算法的关键要素。QRAM 或类似的东西对于高效访问大量数据(如机器学习应用中使用的数据)是必要的。目前,还没有 QRAM 的实现,但有几个研究小组正在探索这样的实现如何工作。
传统的随机存取存储器由一个硬件设备组成,它以一个存储位置作为输入,并以该存储位置的内容作为输出。QRAM 在概念上类似:它以一个存储位置(可能是存储位置的叠加态)作为输入,并以这些存储位置的叠加态内容作为输出。其内容被返回的存储位置是以传统方式写入的 —— 也就是说,每个比特有一个值。这些值以叠加态返回,并且幅度由要返回的存储位置的规范确定。因为原始值是传统写入的,所以它们可以以非破坏性的方式复制。
所提出的 QRAM 的一个问题是,所需的物理资源数量与检索的比特数呈线性比例。因此,对于非常大的检索量,构建 QRAM 可能并不实际。与关于量子计算机的许多讨论一样,QRAM 处于理论讨论阶段而非工程阶段。请继续关注。
我们讨论的其余算法假定存在一种机制,例如像 QRAM 那样,用于高效访问算法所操作的数据。
矩阵求逆
矩阵求逆是许多科学问题的基础。例如,机器学习需要有求逆大型矩阵的能力。在这种情况下,量子计算机有望加速矩阵求逆。哈罗(Harrow)、哈西迪姆(Hassidim)和劳埃德(Lloyd)的 HHL 算法将在一些约束条件下求线性矩阵的逆。一般问题是求解方程 Ax = b,其中 A 是一个 N×N 矩阵,x 是一组 N 个未知数,b 是一组 N 个已知值。你在初等代数中了解过最简单的情况(N = 2)。然而,随着 N 的增长,矩阵求逆成为求解方程组的标准技术。
在用量子计算机解决这个问题时,有以下约束条件:
- b 的值必须能够快速访问。这是 QRAM 应该解决的问题。
- 矩阵 A 必须满足某些条件。如果它是一个稀疏矩阵,那么它很可能在量子计算机上高效处理。矩阵也必须是良态的;也就是说,矩阵的行列式必须非零或接近零。在传统计算机上求逆矩阵时,行列式小会引起问题,所以这不是量子计算机特有的问题。
- 应用 HHL 算法的结果是 x 的值以叠加态出现。因此,需要一种机制来有效地从叠加态中分离出实际值。
实际的算法太复杂,我们无法在此介绍。然而,一个值得注意的元素是,它依赖于基于使用相位的幅度放大技术。
26.5 潜在应用
量子计算机预计将对各种各样的应用领域产生影响。例如,国际商业机器公司(IBM)正专注于网络安全、药物开发、金融建模、更好的电池、更清洁的肥料、交通优化、天气预报与气候变化以及人工智能和机器学习等,这里仅列举几个领域。
到目前为止,除了网络安全领域,这份潜在量子计算应用的列表在很大程度上仍只是推测。有几种网络安全算法已被证明比传统算法有显著的改进,但其余的应用领域迄今为止仍是大量热烈研究的主题。然而,到目前为止,这些努力都还没有产生公开的成果。
正如本章开头的引语所暗示的,量子计算机正处于莱特兄弟时代飞机所处的阶段。前景广阔,但要将这一前景变为现实,还需要做大量的工作。
26.6 最后的思考
量子计算机目前还处于起步阶段。此类计算机的应用目前主要还是推测,特别是那些需要大量数据的应用。尽管如此,就实际存在的量子比特数量而言,进展正在迅速发生。看起来摩尔定律很可能会适用于量子计算机,就像它在传统计算中那样。如果是这样,那么可用的量子比特数量将随着时间呈指数增长。
在 [26.2 节][ch26sec02] 中讨论的量子比特操作适合一种编程风格,即操作被链接在一起以执行有用的功能。这很可能会遵循与传统计算机的机器语言相同的发展轨迹。机器语言仍然存在,但已成为只有少数程序员涉足的领域。大多数程序员使用各种各样的高级语言。我们应该期待在量子计算机编程中看到同样的发展。量子计算语言设计的努力正在进行中,但仍处于初级阶段。
编程语言只是冰山一角。那么本书中我们讨论过的其他主题呢?是否有与量子计算机相关的新质量属性、新的架构模式、额外的架构视图呢?几乎可以肯定是有的。
量子计算机网络会是什么样子呢?量子计算机和传统计算机的混合网络会变得广泛吗?所有这些都是量子计算几乎肯定会最终发展的潜在领域。
在此期间,架构师能做什么呢?首先,关注突破性的发展。如果你今天正在开发的系统涉及量子计算可能会影响的领域(或者更有可能是完全颠覆的领域),将系统的这些部分隔离出来,以便在量子计算最终出现时尽量减少干扰。特别是对于安全系统,关注这个领域,以便在你的传统加密算法变得毫无价值时知道该怎么做。
但你的准备工作不一定要全是防御性的。想象一下,有一个无论节点之间的物理距离有多远都能瞬间传输信息的通信网络,你能做什么呢?如果这听起来牵强 —— 好吧,曾经飞行器也被认为是牵强的。
一如既往,我们满怀期待地等待未来。
26.7 扩展阅读
概述:
- 埃里克・约翰斯顿(Eric Johnston)、尼克・哈里根(Nic Harrigan)和梅赛德斯・希梅诺 - 塞戈维亚(Mercedes Gimeno-Segovia)所著的《量子计算机编程》在不涉及物理学或线性代数的情况下讨论了量子计算 [[Johnston 19][ref_131]]。
- 《量子计算:进展与前景》[[NASEM 19][ref_188]] 提供了量子计算当前状态的概述以及制造真正的量子计算机所必须克服的挑战。
- 量子计算机不仅与传统计算机相比能提供更快的解决方案,还能解决一些只有量子计算机才能解决的问题。这个强大的理论结果在 2018 年 5 月出现:quantamagazine.org/finally-a-problem-that-only-quantum-computers-will-ever-be-able-to-solve-20180621/。