C++ 软件架构(一)
原文:
zh.annas-archive.org/md5/FF4E2693BC25818CA0990A2CB63D13B8
译者:飞龙
前言
现代 C++允许您在高级语言中编写高性能应用程序,而不会牺牲可读性和可维护性。不过,软件架构不仅仅是语言。我们将向您展示如何设计和构建健壮且可扩展且性能良好的应用程序。
本书包括基本概念的逐步解释、实际示例和自我评估问题,您将首先了解架构的重要性,看看一个实际应用程序的案例研究。
您将学习如何在单个应用程序级别使用已建立的设计模式,探索如何使您的应用程序健壮、安全、高性能和可维护。然后,您将构建连接多个单个应用程序的更高级别服务,使用诸如面向服务的架构、微服务、容器和无服务器技术等模式。
通过本书,您将能够使用现代 C++和相关工具构建分布式服务,以提供客户推荐的解决方案。
您是否有兴趣成为软件架构师或者想要了解现代架构的更多趋势?如果是的话,这本书应该会帮助您!
本书适合对象
使用现代 C++的开发人员将能够通过本实用指南将他们的知识付诸实践。本书采用了实践方法,涉及实施和相关方法论,将让您迅速上手并提高工作效率。
本书涵盖内容
第一章《软件架构的重要性和优秀设计原则》探讨了我们首先为什么设计软件。
第二章《架构风格》涵盖了在架构方面可以采取的不同方法。
第三章《功能和非功能需求》探讨了理解客户需求。
第四章《架构和系统设计》是关于创建有效的软件解决方案。
第五章《利用 C++语言特性》让您能够流利地使用 C++。
第六章《设计模式和 C++》专注于现代 C++习语和有用的代码构造。
第七章《构建和打包》是关于将代码部署到生产环境中。
第八章《可测试代码编写》教会您如何在客户发现之前找到错误。
第九章《持续集成和持续部署》介绍了自动化软件发布的现代方式。
第十章《代码和部署中的安全性》是您将学习如何确保系统难以被破坏的地方。
第十一章《性能》关注性能(当然!)。C++应该快速-它能更快吗?
第十二章《面向服务的架构》让您基于服务构建系统。
第十三章《设计微服务》专注于只做一件事情-设计微服务。
第十四章《容器》为您提供了一个统一的界面来构建、打包和运行应用程序。
第十五章《云原生设计》超越了传统基础设施,探索了云原生设计。
充分利用本书
本书中的代码示例大多是为 GCC 10 编写的。它们也应该适用于 Clang 或 Microsoft Visual C++,尽管在较旧版本的编译器中可能缺少 C++20 的某些功能。为了尽可能接近作者的开发环境,我们建议您在类似 Linux 的环境中使用 Nix (nixos.org/download.html
)和 direnv (direnv.net/
)。如果您在包含示例的目录中运行direnv allow
,这两个工具应该会为您配置编译器和支持包。
如果没有 Nix 和 direnv,我们无法保证示例将正常工作。如果您使用的是 macOS,Nix 应该可以正常工作。如果您使用的是 Windows,Windows 子系统适用于 Linux 2 是一个很好的方式,可以使用 Nix 创建一个 Linux 开发环境。
要安装这两个工具,您必须运行以下命令:
# Install Nix
curl -L https://nixos.org/nix/install | sh
# Configure Nix in the current shell
. $HOME/.nix-profile/etc/profile.d/nix.sh
# Install direnv
nix-env -i direnv
# Download the code examples
git clone https://github.com/PacktPublishing/Hands-On-Software-Architecture-with-Cpp.git
# Change directory to the one with examples
cd Hands-On-Software-Architecture-with-Cpp
# Allow direnv and Nix to manage your development environment
direnv allow
执行前面的命令后,Nix 应该下载并安装所有必要的依赖项。这可能需要一些时间,但它有助于确保我们使用的工具完全相同。
如果您使用本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库(链接在下一节中提供)访问代码。这样做将有助于避免与复制和粘贴代码相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 上下载本书的示例代码文件,网址为github.com/PacktPublishing/Software-Architecture-with-Cpp
。如果代码有更新,它将在现有的 GitHub 存储库中更新。
我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在**github.com/PacktPublishing/
**上找到!快来看看吧!
下载彩色图片
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781838554590_ColorImages.pdf
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这里有一个例子:“前两个字段(openapi
和info
)是描述文档的元数据。”
代码块设置如下:
using namespace CppUnit;
using namespace std;
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这里有一个例子:“从管理面板中选择系统信息。”
警告或重要说明会出现在这样的地方。
提示和技巧会出现在这样的地方。
第一部分:软件架构的概念和组件
本节介绍了软件架构的基础知识,展示了其设计和文档编制的有效方法。
本节包括以下章节:
-
第一章,软件架构的重要性和优秀设计原则
-
第二章,架构风格
-
第三章,功能和非功能需求
第一章:软件架构的重要性和优秀设计原则
这个介绍性章节的目的是展示软件架构在软件开发中的作用。它将专注于设计 C++解决方案架构时需要牢记的关键方面。我们将讨论如何设计具有方便和功能性接口的高效代码。我们还将介绍一个面向领域的方法,用于代码和架构。
在本章中,我们将涵盖以下主题:
-
理解软件架构
-
学习适当架构的重要性
-
探索良好架构的基本原理
-
使用敏捷原则开发架构
-
C++的哲学
-
遵循 SOLID 和 DRY 原则
-
领域驱动设计
-
耦合和内聚
技术要求
要运行本章中的代码,您需要以下内容:
-
一个 Git 客户端,用于检出即将给出的存储库。
-
一个符合 C++20 标准的编译器来编译所有的代码片段。其中大部分是用 C++11/14/17 编写的,但是需要概念支持来实验一下涉及该主题的少数代码片段。
-
代码片段的 GitHub 链接:
github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter01
。 -
GSL 的 GitHub 链接:
github.com/Microsoft/GSL
理解软件架构
让我们从定义软件架构的实际含义开始。当您创建一个应用程序、库或任何软件组件时,您需要考虑您编写的元素将如何呈现以及它们将如何相互交互。换句话说,您正在设计它们及其与周围环境的关系。就像城市建筑一样,重要的是要考虑整体情况,以免最终陷入杂乱状态。在小范围内,每栋建筑看起来都还不错,但它们无法合理地组合在一起,它们只是无法很好地结合在一起。这就是所谓的偶然架构,这是您要避免的结果之一。但是,请记住,无论您是否在思考,编写软件都是在创建架构。
那么,如果您想要有意识地定义解决方案的架构,您应该创建什么?软件工程研究所有这样说:
系统的软件架构是理解系统所需的结构集合,包括软件元素、它们之间的关系以及两者的属性。
这意味着为了彻底定义架构,我们应该从几个角度来考虑它,而不是只是匆忙地编写代码。
观察架构的不同方式
有几个范围可以用来观察架构:
-
企业架构涉及整个公司甚至一组公司。它采用全面的方法,关注整个企业的战略。在考虑企业架构时,您应该关注公司中所有系统的行为以及它们如何相互合作。它关注业务和 IT 之间的对齐。
-
解决方案架构比企业架构更具体。它处于企业架构和软件架构之间的中间位置。通常,解决方案架构关注的是一个特定系统以及它与周围环境的交互方式。解决方案架构师需要想出一种方式来满足特定的业务需求,通常是通过设计整个软件系统或修改现有系统。
-
软件架构甚至比解决方案架构更具体。它集中在一个特定项目、它使用的技术以及它与其他项目的交互方式。软件架构师对项目组件的内部感兴趣。
-
基础架构架构如其名称所示,关注软件将使用的基础架构。它定义了部署环境和策略,应用程序的扩展方式,故障处理,站点可靠性以及其他基础架构导向的方面。
解决方案架构基于软件和基础架构架构,以满足业务需求。在接下来的章节中,我们将讨论这两个方面,为您准备好小规模和大规模架构设计。在我们深入讨论之前,让我们也回答一个基本问题:为什么架构很重要?
学习正确架构的重要性
实际上,一个更好的问题应该是:为什么关心您的架构很重要?正如我们之前提到的,无论您是否有意识地努力构建它,您最终都会得到某种类型的架构。如果在数月甚至数年的开发之后,您仍希望您的软件保持其品质,您需要在过程的早期采取一些步骤。如果您不考虑您的架构,那么它可能永远不会呈现所需的品质。
因此,为了使您的产品满足业务需求和性能、可维护性、可扩展性或其他属性,您需要关注其架构,并且最好在过程的早期就这样做。现在让我们讨论每个优秀架构师都想保护其项目免受的两件事。
软件腐败
即使在您进行了最初的工作并有特定的架构构想之后,您仍需要不断监测系统的演变以及它是否仍然符合用户的需求,因为这些需求在软件的开发和生命周期中也可能发生变化。软件腐败,有时也称为侵蚀,发生在实现决策与计划的架构不相符时。所有这些差异都应被视为技术债务。
意外架构
未能跟踪开发是否遵循所选择的架构,或者未能有意地规划架构的外观,通常会导致所谓的意外架构,这可能会发生,无论在其他领域应用最佳实践,如测试或具有任何特定的开发文化。
有几种反模式表明您的架构是意外的。类似一团泥的代码是最明显的一个。拥有上帝对象是另一个重要的迹象。一般来说,如果您的软件变得紧密耦合,可能存在循环依赖,但最初并非如此,这是一个重要的信号,需要更多有意识的努力来规划架构的外观。
现在让我们描述一下架构师必须了解以交付可行解决方案的内容。
探索良好架构的基本原理
重要的是要知道如何识别好的架构和坏的架构,但这并不是一件容易的事。识别反模式是其中的一个重要方面,但要使架构良好,首先它必须支持交付软件所期望的内容,无论是功能要求、解决方案的属性,还是处理来自各个方面的约束。其中许多可以很容易地从架构背景中得出。
架构背景
上下文是架构师在设计坚实解决方案时考虑的因素。它包括需求、假设和约束,这些可以来自利益相关者,以及业务和技术环境。它还影响利益相关者和环境,例如,通过允许公司进入新的市场细分。
利益相关者
利益相关者是所有与产品有关的人。这些可以是你的客户,系统的用户,或者管理层。沟通是每个架构师的关键技能,妥善管理利益相关者的需求对于交付他们期望的东西以他们想要的方式至关重要。
不同的利益相关者群体对不同的事情都很重要,所以尽量收集所有这些群体的意见。
你的客户可能会关心编写和运行软件的成本、软件提供的功能、其寿命、上市时间以及你的解决方案的质量。
系统的用户可以分为两组:最终用户和管理员。前者通常关心的是软件的可用性、用户体验和性能。对于后者来说,更重要的方面是用户管理、系统配置、安全性、备份和恢复。
最后,对于管理工作中的利益相关者来说,重要的事情包括降低开发成本,实现业务目标,按照开发进度表进行,以及保持产品质量。
商业和技术环境
架构可以受到公司业务方面的影响。重要的相关方面包括上市时间、发布计划、组织结构、劳动力利用率以及对现有资产的投资。
技术环境指的是公司已经使用的技术或因某种原因需要成为解决方案一部分的技术。我们还需要集成的其他系统也是技术环境的重要组成部分。此外,可用软件工程师的技术专长在这里也很重要:架构师所做的技术决策可能会影响项目的人员配备,初级开发人员与高级开发人员的比例可能会影响项目的治理方式。良好的架构应该考虑到所有这些。
现在,凭借所有这些知识,让我们讨论一个可能会在你日常工作中遇到的颇具争议的话题。
使用敏捷原则开发架构
表面上,架构和敏捷开发方法之间存在对抗性关系,并且围绕这个话题有许多神话。有一些简单的原则你应该遵循,以便以敏捷方式开发产品,同时关注其架构。
敏捷本质上是迭代和增量的。这意味着在敏捷架构方法中,准备大量的预先设计不是一个选择。相反,应该提出一个小型但仍合理的预先设计。最好附带有每个决定的理由。这样,如果产品愿景发生变化,架构也可以随之发展。为了支持频繁的发布交付,预先设计应该逐步更新。以这种方式开发的架构被称为演进式架构。
管理架构并不意味着保留大量文档。事实上,文档应该只涵盖必要的内容,这样更容易保持其最新。它应该简单,只涵盖系统的相关视图。
还有一个关于架构师作为唯一真理来源和最终决策者的神话。在敏捷环境中,是团队在做决定。话虽如此,利益相关者对决策过程的贡献至关重要 - 毕竟,他们的观点决定了解决方案应该是什么样子。
架构师应该始终是开发团队的一部分,因为他们通常会为团队带来强大的技术专业知识和多年的经验。他们还应该参与估算,并在每次迭代之前计划所需的架构变更。
为了使您的团队保持敏捷,您应该考虑如何高效地工作,只专注于重要的事情。实现这些目标的一个好主意是领域驱动设计。
领域驱动设计
领域驱动设计,简称 DDD,是埃里克·埃文斯在他的同名书中引入的一个术语。本质上,它是关于改善业务和工程之间的沟通,并将开发人员的注意力引向领域模型。基于该模型的实现通常会导致更容易理解并随着模型变化而发展的设计。
DDD 与敏捷有什么关系?让我们回顾一下敏捷宣言的一部分:
个人和互动优于流程和工具
工作软件优于全面文档
客户协作优于合同谈判
响应变化优于遵循计划
- 敏捷宣言
为了做出正确的设计决策,您必须首先了解领域。为此,您需要经常与人交谈,并鼓励您的开发团队缩小他们与业务人员之间的差距。代码中的概念应该以普遍语言中的实体命名。这基本上是业务专家术语和技术专家术语的共同部分。由于每个群体使用另一方不同理解的术语,可能会导致无数误解,从而导致业务逻辑实现中的缺陷和常常微妙的错误。谨慎命名事物并使用双方同意的术语可能对项目意味着幸福。在团队中有业务分析师或其他业务领域专家可以在很大程度上帮助。
如果您正在建模一个更大的系统,可能很难使所有术语对不同团队意味着相同的事情。这是因为这些团队中的每一个实际上都在不同的上下文中操作。DDD 建议使用有界上下文来处理这个问题。例如,如果您正在建模一个电子商务系统,您可能只想从购物的角度考虑这些术语,但仔细观察后,您可能会发现库存、交付和会计团队实际上都有自己的模型和术语。
这些每一个都是您电子商务领域的不同子领域。理想情况下,每个子领域都可以映射到自己的有界上下文——系统的一部分,具有自己的词汇。在将解决方案拆分为较小的模块时,设置这些上下文的清晰边界非常重要。就像它的上下文一样,每个模块都有明确的责任、自己的数据库架构和自己的代码库。为了在更大的系统中在团队之间进行沟通,您可能希望引入一个上下文地图,它将显示不同上下文的术语之间的关系:
图 1.1-两个有界上下文及其之间匹配术语的映射(来自 Martin Fowler 关于 DDD 的一篇文章:martinfowler.com/bliki/Bound…
现在您已经了解了一些重要的项目管理主题,我们可以转向一些更技术性的话题。
C++的哲学
现在让我们更接近我们将在本书中大部分时间使用的编程语言。C++是一种多范式语言,已经存在了几十年。自其诞生以来,它发生了很大变化。当 C++11 发布时,语言的创造者 Bjarne Stroustrup 说感觉像是一种全新的语言。C++20 的发布标志着这个怪物演变的又一个里程碑,带来了类似的革命,改变了我们编写代码的方式。然而,在这些年里有一件事始终没有改变:语言的哲学。
简而言之,它可以总结为三条规则:
-
C++之下不应该有其他语言(除了汇编)。
-
你只为你使用的东西付费。
-
以低成本提供高级抽象(有强烈的零成本目标)。
不为你不使用的东西付费意味着,例如,如果你想在堆栈上创建数据成员,你可以。许多语言在堆上分配它们的对象,但对于 C++来说并不是必要的。在堆上分配是有一些成本的 - 可能你的分配器将不得不为此锁定互斥锁,在某些类型的应用程序中可能是一个很大的负担。好处是你可以很容易地分配变量而不需要每次动态分配内存。
高级抽象是区分 C++与低级语言(如 C 或汇编)的特点。它们允许直接在源代码中表达想法和意图,这与语言的类型安全非常契合。考虑以下代码片段:
struct Duration {
int millis_;
};
void example() {
auto d = Duration{};
d.millis_ = 100;
auto timeout = 1; // second
d.millis_ = timeout; // ouch, we meant 1000 millis but assigned just 1
}
一个更好的主意是利用语言提供的类型安全特性:
#include <chrono>
using namespace std::literals::chrono_literals;
struct Duration {
std::chrono::milliseconds millis_;
};
void example() {
auto d = Duration{};
// d.millis_ = 100; // compilation error, as 100 could mean anything
d.millis_ = 100ms; // okay
auto timeout = 1s; // or std::chrono::seconds(1);
d.millis_ =
timeout; // okay, converted automatically to milliseconds
}
前面的抽象可以帮助我们避免错误,并且在这样做时不会花费我们任何东西;生成的汇编代码与第一个示例相同。这就是为什么它被称为零成本抽象。有时 C++允许我们使用抽象,实际上会导致比不使用更好的代码。一个例子是 C++20 中的协程,当使用时通常会产生这样的好处。
标准库提供的另一组很棒的抽象是算法。以下哪个代码片段你认为更容易阅读和更容易证明没有错误?哪个更好地表达了意图?
// Approach #1
int count_dots(const char *str, std::size_t len) {
int count = 0;
for (std::size_t i = 0; i < len; ++i) {
if (str[i] == '.') count++;
}
return count;
}
// Approach #2
int count_dots(std::string_view str) {
return std::count(std::begin(str), std::end(str), '.');
}
好吧,第二个函数有一个不同的接口,但即使它保持不变,我们也可以从指针和长度创建std::string_view
。由于它是一种轻量级类型,它应该被编译器优化掉。
使用高级抽象会导致更简单、更易维护的代码。C++语言自其诞生以来一直致力于提供零成本的抽象,因此应该建立在此基础上,而不是使用更低级别的抽象重新设计轮子。
说到简单和易维护的代码,接下来的部分介绍了一些在编写这种代码的过程中非常宝贵的规则和启发式方法。
遵循 SOLID 和 DRY 原则
在编写代码时要牢记许多原则。在编写面向对象的代码时,你应该熟悉抽象、封装、继承和多态的四个要素。无论你是以大部分面向对象编程的方式编写 C++代码,还是以其他方式编写,你都应该牢记这两个首字母缩略词背后的原则:SOLID 和 DRY。
SOLID 是一组实践,可以帮助你编写更清洁、更少错误的软件。它是由其背后的五个概念的首字母组成的首字母缩略词:
-
单一职责原则
-
开闭原则
-
里斯科夫替换原则
-
接口隔离
-
依赖反转
我们假设你已经了解了这些原则与面向对象编程的关系,但由于 C++并不总是面向对象的,让我们看看它们如何适用于不同的领域。
一些示例使用了动态多态性,但同样适用于静态多态性。如果你正在编写性能导向的代码(如果你选择了 C++,你可能是这样做的),你应该知道使用动态多态性在性能方面可能是一个坏主意,特别是在热路径上。在本书的后面,你将学习如何使用奇怪的递归模板模式(CRTP)编写静态多态类。
单一职责原则
简而言之,单一职责原则(SRP)意味着每个代码单元应该只有一个责任。这意味着编写只做一件事的函数,创建负责一件事的类型,以及创建专注于一个方面的高级组件。
这意味着如果你的类管理某种资源,比如文件句柄,它应该只做这个,例如,将解析留给另一种类型。
通常,如果你看到一个函数的名字中有“和”字,它就违反了 SRP,应该进行重构。另一个标志是当一个函数有注释指示函数的每个部分(译注:原文中的“section”可能是笔误,应为“function”)做什么。每个这样的部分可能最好作为一个独立的函数。
一个相关的主题是最少知识原则。本质上,它说任何对象都不应该知道关于其他对象的比必要更多的东西,因此它不依赖于它们的内部,例如。应用它会导致更易于维护的代码,组件之间的相互依赖更少。
开闭原则
开闭原则(OCP)意味着代码应该对扩展开放但对修改关闭。对扩展开放意味着我们可以轻松地扩展代码支持的类型列表。对修改关闭意味着现有的代码不应该改变,因为这往往会在系统的其他地方引起错误。C++中展示这一原则的一个很好的特性是ostream
的operator<<
。要扩展它以支持你的自定义类,你只需要编写类似以下的代码:
std::ostream &operator<<(std::ostream &stream, const MyPair<int, int>
&mp) {
stream << mp.firstMember() << ", ";
stream << mp.secondMember();
return stream;
}
请注意,我们对operator<<
的实现是一个自由(非成员)函数。如果可能的话,你应该更喜欢这些而不是成员函数,因为它实际上有助于封装。有关此更多详细信息,请参阅本章末尾的进一步阅读部分中 Scott Meyers 的文章。如果你不想为希望打印到ostream
的某个字段提供公共访问权限,你可以将operator<<
设置为友元函数,就像这样:
class MyPair {
// ...
friend std::ostream &operator<<(std::ostream &stream,
const MyPair &mp);
};
std::ostream &operator<<(std::ostream &stream, const MyPair &mp) {
stream << mp.first_ << ", ";
stream << mp.second_ << ", ";
stream << mp.secretThirdMember_;
return stream;
}
请注意,这个 OCP 的定义与与多态性相关的更常见的定义略有不同。后者是关于创建不能被修改但可以被继承的基类,但对其他类开放。
说到多态性,让我们继续讨论下一个原则,因为它完全是关于正确使用它。
里氏替换原则
实质上,里氏替换原则(LSP)规定,如果一个函数使用基对象的指针或引用,它也必须使用任何派生对象的指针或引用。这个规则有时会被打破,因为我们在源代码中应用的技术并不总是适用于现实世界的抽象。
一个著名的例子是正方形和矩形。从数学上讲,前者是后者的一个特例,所以从一个到另一个有一个“是一个”关系。这引诱我们创建一个从Rectangle
类继承的Square
类。因此,我们可能会得到以下代码:
class Rectangle {
public:
virtual ~Rectangle() = default;
virtual double area() { return width_ * height_; }
virtual void setWidth(double width) { width_ = width; }
virtual void setHeight(double height) { height_ = height; }
private:
double width_;
double height_;
};
class Square : public Rectangle {
public:
double area() override;
void setWidth(double width) override;
void setHeight(double height) override;
};
我们应该如何实现Square
类的成员?如果我们想遵循 LSP 并避免这些类的用户受到意外的影响,我们不能:如果我们调用setWidth
,我们的正方形将停止成为正方形。我们可以停止拥有一个正方形(无法使用前面的代码表达)或者修改高度,从而使正方形看起来与矩形不同。
如果你的代码违反了 LSP,很可能你正在使用一个不正确的抽象。在我们的情况下,Square
毕竟不应该从Rectangle
继承。一个更好的方法可能是让这两个实现一个GeometricFigure
接口。
既然我们正在讨论接口,让我们继续讨论下一个与之相关的项目。
接口隔离原则
接口隔离原则就是其名字所暗示的。它的表述如下:
没有客户端应该被强迫依赖它不使用的方法。
这听起来非常明显,但它有一些不那么明显的内涵。首先,你应该更喜欢更多但更小的接口而不是一个大的接口。其次,当你添加一个派生类或扩展现有类的功能时,你应该在扩展类实现的接口之前考虑一下。
让我们以违反这一原则的一个例子来展示这一点,从以下接口开始:
class IFoodProcessor {
public:
virtual ~IFoodProcessor() = default;
virtual void blend() = 0;
};
我们可以有一个简单的实现它的类:
class Blender : public IFoodProcessor {
public:
void blend() override;
};
到目前为止还不错。现在假设我们想要模拟另一个更高级的食品加工器,并且我们鲁莽地尝试向我们的接口添加更多方法:
class IFoodProcessor {
public:
virtual ~IFoodProcessor() = default;
virtual void blend() = 0;
virtual void slice() = 0;
virtual void dice() = 0;
};
class AnotherFoodProcessor : public IFoodProcessor {
public:
void blend() override;
void slice() override;
void dice() override;
};
现在我们有一个问题,Blender
类不支持这个新接口 - 没有适当的方法来实现它。我们可以尝试通过一些变通方法或抛出std::logic_error
来解决,但更好的解决方案是将接口分成两个,每个负责不同的功能:
class IBlender {
public:
virtual ~IBlender() = default;
virtual void blend() = 0;
};
class ICutter {
public:
virtual ~ICutter() = default;
virtual void slice() = 0;
virtual void dice() = 0;
};
现在我们的AnotherFoodProcessor
可以实现两个接口,我们不需要更改现有食品加工器的实现。
我们还剩下最后一个 SOLID 原则,所以现在让我们学习一下。
依赖反转原则
依赖反转是一个有用的解耦原则。实质上,它意味着高级模块不应该依赖于低级模块。相反,两者都应该依赖于抽象。
C++允许两种方式来反转类之间的依赖关系。第一种是常规的多态方法,第二种使用模板。让我们看看如何在实践中应用这两种方法。
假设您正在建模一个软件开发项目,该项目应该有前端和后端开发人员。一个简单的方法是这样写:
class FrontEndDeveloper {
public:
void developFrontEnd();
};
class BackEndDeveloper {
public:
void developBackEnd();
};
class Project {
public:
void deliver() {
fed_.developFrontEnd();
bed_.developBackEnd();
}
private:
FrontEndDeveloper fed_;
BackEndDeveloper bed_;
};
每个开发人员都由Project
类构建。然而,这种方法并不理想,因为现在更高级的概念Project
依赖于更低级的模块 - 个别开发人员的模块。让我们看看如何应用多态来应用依赖反转。我们可以定义我们的开发人员依赖于一个接口,如下所示:
class Developer {
public:
virtual ~Developer() = default;
virtual void develop() = 0;
};
class FrontEndDeveloper : public Developer {
public:
void develop() override { developFrontEnd(); }
private:
void developFrontEnd();
};
class BackEndDeveloper : public Developer {
public:
void develop() override { developBackEnd(); }
private:
void developBackEnd();
};
现在,Project
类不再需要知道开发人员的实现。因此,它必须将它们作为构造函数参数接受:
class Project {
public:
using Developers = std::vector<std::unique_ptr<Developer>>;
explicit Project(Developers developers)
: developers_{std::move(developers)} {}
void deliver() {
for (auto &developer : developers_) {
developer->develop();
}
}
private:
Developers developers_;
};
在这种方法中,Project
与具体实现解耦,而是仅依赖于名为Developer
的多态接口。"低级"具体类也依赖于这个接口。这可以帮助您缩短构建时间,并且可以更容易进行单元测试 - 现在您可以在测试代码中轻松地传递模拟对象作为参数。
使用依赖反转和虚拟调度是有成本的,因为现在我们要处理内存分配,动态调度本身也有开销。有时,C++编译器可以检测到对于给定接口只使用了一个实现,并通过执行去虚拟化来消除开销(通常需要将函数标记为final
才能实现这一点)。然而,在这里,使用了两个实现,因此必须支付动态调度的成本(通常实现为跳转到虚拟方法表或vtables)。
还有另一种反转依赖的方式,它没有这些缺点。让我们看看如何使用可变模板、C++14 的通用 lambda 和variant
(C++17 或第三方库,如 Abseil 或 Boost)来实现这一点。首先是开发人员类:
class FrontEndDeveloper {
public:
void develop() { developFrontEnd(); }
private:
void developFrontEnd();
};
class BackEndDeveloper {
public:
void develop() { developBackEnd(); }
private:
void developBackEnd();
};
现在我们不再依赖于接口,因此不会进行虚拟调度。Project
类仍然将接受一个Developers
的向量:
template <typename... Devs>
class Project {
public:
using Developers = std::vector<std::variant<Devs...>>;
explicit Project(Developers developers)
: developers_{std::move(developers)} {}
void deliver() {
for (auto &developer : developers_) {
std::visit([](auto &dev) { dev.develop(); }, developer);
}
}
private:
Developers developers_;
};
如果您不熟悉variant
,它只是一个可以容纳模板参数传递的任何类型的类。因为我们使用了可变模板,我们可以传递任意多个类型。要在 variant 中存储的对象上调用函数,我们可以使用std::get
来提取它,或者使用std::visit
和一个可调用对象 - 在我们的例子中,是通用 lambda。它展示了鸭子类型在实践中的样子。由于我们所有的开发人员类都实现了develop
函数,所以代码将编译并运行。如果您的开发人员类有不同的方法,您可以创建一个函数对象,该对象具有不同类型的operator()
重载。
因为Project
现在是一个模板,我们必须每次创建它时要么指定类型列表,要么提供一个类型别名。您可以像这样使用最终类:
using MyProject = Project<FrontEndDeveloper, BackEndDeveloper>;
auto alice = FrontEndDeveloper{};
auto bob = BackEndDeveloper{};
auto new_project = MyProject{{alice, bob}};
new_project.deliver();
这种方法保证不为每个开发人员分配单独的内存,也不使用虚拟表。然而,在某些情况下,这种方法会导致较少的可扩展性,因为一旦声明了变体,就无法向其添加另一种类型。
关于依赖倒置的最后一点要提到的是,还有一个名为依赖注入的类似概念,我们甚至在我们的例子中使用过。它是关于通过构造函数或设置器注入依赖项,这对于代码的可测试性可能是有益的(例如,考虑注入模拟对象)。甚至有整个框架用于在整个应用程序中注入依赖项,比如 Boost.DI。这两个概念是相关的,经常一起使用。
DRY 原则
DRY 是“不要重复自己”的缩写。这意味着在可能的情况下应避免代码重复和重用。这意味着当您的代码多次重复类似操作时,应提取函数或函数模板。此外,您应该考虑编写模板,而不是创建几个类似的类型。
当不必要时,重复造轮子是不重要的,也就是说,不要重复他人的工作。如今有数十个编写高质量软件的成熟库,可以帮助您更快地编写高质量的软件。我们想特别提到其中的一些:
-
Boost C++ Libraries(
www.boost.org/
) -
Facebook 的 Folly(
github.com/facebook/folly
) -
Electronic Arts 的 EASTL(
github.com/electronicarts/EASTL
) -
Bloomberg 的 BDE(
github.com/bloomberg/bde
) -
Google 的 Abseil(
abseil.io/
) -
Awesome Cpp 列表(
github.com/fffaraz/awesome-cpp
)还有数十个
然而,有时复制代码也有其好处。一个这样的情景是开发微服务。当然,在单个微服务内遵循 DRY 原则总是一个好主意,但是允许在多个服务中重复代码实际上是值得的。无论是模型实体还是逻辑,允许代码重复可以更容易地维护多个服务。
想象一下,有多个微服务重用同一实体的相同代码。突然其中一个需要修改一个字段。所有其他服务现在也必须进行修改。对于任何公共代码的依赖也是如此。有数十个或更多微服务因为与它们无关的更改而必须进行修改,通常更容易进行维护,只需复制代码。
既然我们在谈论依赖和维护,让我们继续下一节,讨论一个密切相关的主题。
耦合和内聚
耦合和内聚是软件中相互关联的两个术语。让我们看看它们各自的含义以及它们如何相互关联。
耦合
耦合是衡量一个软件单元对其他单元依赖程度的指标。具有高耦合的单元依赖于许多其他单元。耦合越低,越好。
例如,如果一个类依赖于另一个类的私有成员,这意味着它们耦合紧密。第二个类的更改可能意味着第一个类也需要进行更改,这就是为什么这不是一种理想的情况。
为了减弱前面情景中的耦合,我们可以考虑为成员函数添加参数,而不是直接访问其他类的私有成员。
另一个耦合紧密的类的例子是依赖倒置部分中的Project
和开发人员类的第一个实现。让我们看看如果我们要添加另一种开发人员类型会发生什么:
class MiddlewareDeveloper {
public:
void developMiddleware() {}
};
class Project {
public:
void deliver() {
fed_.developFrontEnd();
med_.developMiddleware();
bed_.developBackEnd();
}
private:
FrontEndDeveloper fed_;
MiddlewareDeveloper med_;
BackEndDeveloper bed_;
};
看起来,我们不仅仅是添加了MiddlewareDeveloper
类,而是必须修改了Project
类的公共接口。这意味着它们耦合度高,并且Project
类的这种实现实际上违反了 OCP。为了对比,现在让我们看看如何将相同的修改应用于使用依赖反转的实现:
class MiddlewareDeveloper {
public:
void develop() { developMiddleware(); }
private:
void developMiddleware();
};
Project
类不需要进行任何更改,所以现在这些类是松耦合的。我们需要做的只是添加MiddlewareDeveloper
类。以这种方式构建我们的代码可以实现更小的重建、更快的开发和更容易的测试,而且代码更少且更易于维护。要使用我们的新类,我们只需要修改调用代码:
using MyProject = Project<FrontEndDeveloper, MiddlewareDeveloper, BackEndDeveloper>;
auto alice = FrontEndDeveloper{};
auto bob = BackEndDeveloper{};
auto charlie = MiddlewareDeveloper{};
auto new_project = MyProject{{alice, charlie, bob}};
new_project.deliver();
这显示了类级别的耦合。在更大的范围内,例如两个服务之间,通过引入诸如消息队列等技术可以实现低耦合。这样服务就不会直接依赖于彼此,而只依赖于消息格式。如果您使用微服务架构,一个常见的错误是让多个服务使用相同的数据库。这会导致这些服务之间的耦合,因为您不能自由修改数据库架构而不影响使用它的所有微服务。
现在让我们转向内聚性。
内聚性
内聚性是衡量软件单元元素之间关系强度的指标。在高度内聚的系统中,同一模块中组件提供的功能是强相关的。感觉这样的组件就像是天生一对。
在类级别上,方法操作的字段越多,它对类的内聚性就越高。这意味着最常见的低内聚数据类型是那些庞大的单体类型。当一个类中发生太多事情时,它很可能不是内聚的,也会违反 SRP。这使得这样的类难以维护且容易出错。
较小的类也可能缺乏内聚性。考虑以下例子。这可能看起来微不足道,但发布现实生活中的场景,通常有数百甚至数千行长,是不切实际的:
class CachingProcessor {
public:
Result process(WorkItem work);
Results processBatch(WorkBatch batch);
void addListener(const Listener &listener);
void removeListener(const Listener &listener);
private:
void addToCache(const WorkItem &work, const Result &result);
void findInCache(const WorkItem &work);
void limitCacheSize(std::size_t size);
void notifyListeners(const Result &result);
// ...
};
我们可以看到我们的处理器实际上做了三种工作:实际工作、结果的缓存和管理监听器。在这种情况下增加内聚性的常见方法是提取一个类,甚至多个类:
class WorkResultsCache {
public:
void addToCache(const WorkItem &work, const Result &result);
void findInCache(const WorkItem &work);
void limitCacheSize(std::size_t size);
private:
// ...
};
class ResultNotifier {
public:
void addListener(const Listener &listener);
void removeListener(const Listener &listener);
void notify(const Result &result);
private:
// ...
};
class CachingProcessor {
public:
explicit CachingProcessor(ResultNotifier ¬ifier);
Result process(WorkItem work);
Results processBatch(WorkBatch batch);
private:
WorkResultsCache cache_;
ResultNotifier notifier_;
// ...
};
现在每个部分都由一个单独的内聚实体完成。现在可以轻松地重用它们。甚至将它们制作成模板类应该也需要很少的工作。最后但并非最不重要的,测试这样的类应该也更容易。
将这个概念应用到组件或系统级别很简单 - 您设计的每个组件、服务和系统都应该简洁,并专注于做一件事并做到完美:
图 1.2 - 耦合与内聚
低内聚和高耦合通常与难以测试、重用、维护甚至理解的软件相关联,因此它缺乏许多通常在软件中期望具有的质量属性。
这些术语经常一起出现,因为通常一个特征会影响另一个特征,无论我们谈论的单元是函数、类、库、服务,甚至是整个系统。举个例子,通常来说,单体系统耦合度高,内聚性低,而分布式服务往往处于光谱的另一端。
这就结束了我们的介绍性章节。现在让我们总结一下我们学到的东西。
总结
在本章中,我们讨论了软件架构是什么,以及为什么值得关注它。我们展示了当架构没有随着需求和实现的变化而更新时会发生什么,以及如何在敏捷环境中处理架构。然后我们转向了 C++语言的一些核心原则。
我们了解到,许多软件开发术语在 C++中可能有不同的理解,因为 C++允许编写面向对象的代码以外的内容。最后,我们讨论了耦合和内聚等术语。
现在,您应该能够在代码审查中指出许多设计缺陷,并重构您的解决方案以获得更好的可维护性,以及作为开发人员更少容易出现错误。您现在可以设计健壮、自解释和完整的类接口。
在下一章中,我们将学习不同的架构方法或风格。我们还将学习如何以及何时可以使用它们来获得更好的结果。
问题
-
为什么要关心软件架构?
-
在敏捷团队中,架构师应该是最终的决策者吗?
-
SRP 与内聚性有什么关系?
-
项目的生命周期的哪些阶段可以从有架构师受益?
-
遵循 SRP 有什么好处?
进一步阅读
-
埃里克·埃文斯,《领域驱动设计:应对软件核心的复杂性》
-
斯科特·迈尔斯,《非成员函数如何改善封装》,
www.drdobbs.com/cpp/how-non-member-functions-improve-encapsu/184401197
第二章:架构风格
本章介绍了不同的架构方法或风格。每个部分都将讨论设计软件的不同方法及其优缺点,并描述何时以及如何应用它以获得其好处。我们将从比较有状态和无状态架构开始本章。接下来,我们将从单体系统,通过各种类型的面向服务的设计,一直到微服务。然后,我们将开始从不同角度描述架构风格,包括事件驱动系统、分层系统,最后是模块化设计。
完成本章后,您将熟悉以下主题:
-
在有状态和无状态之间做出决定
-
理解单体系统——为什么应该避免它们,并识别例外情况
-
理解服务和微服务
-
探索基于事件的架构
-
理解分层架构
-
学习模块化架构
技术要求
您需要知道软件服务是什么,并且能够阅读 C++11 中的代码。
本章的代码可以在以下 GitHub 页面找到:github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter02
。
在有状态和无状态之间做出决定
有状态和无状态是编写软件的两种相反的方式,各有其优缺点。
正如其名称所示,有状态软件的行为取决于其内部状态。让我们以网络服务为例。如果它记住了自己的状态,服务的消费者可以在每个请求中发送更少的数据,因为服务记住了这些请求的上下文。然而,在请求大小和带宽上节省的同时,网络服务的一方面隐藏了成本。如果用户同时发送多个请求,服务现在必须同步其工作。由于多个请求可能同时更改状态,没有同步可能导致数据竞争。
然而,如果服务是无状态的,那么每个发送到它的请求都需要包含处理它所需的所有数据。这意味着请求会变得更大,使用更多的带宽,但另一方面,它将允许更好的性能和服务的扩展。如果您熟悉函数式编程,您可能会发现无状态服务很直观。处理每个请求可以被理解为对纯函数的调用。事实上,无状态编程提供的许多优势都源于其函数式编程的根基。可变状态是并发代码的敌人。函数式编程依赖于不可变的值,即使这意味着复制而不是修改现有对象。由于这个原因,每个线程可以独立工作,不可能发生数据竞争。
由于没有竞争条件,也不需要锁,这在性能方面可以带来巨大的提升。没有锁也意味着您将不再需要处理死锁。拥有纯函数意味着您的代码也更容易调试,因为您没有任何副作用。没有副作用反过来也有助于编译器,因为优化没有副作用的代码是一个更容易的任务,可以更积极地执行。以函数式方式编写代码的另一个好处是,您编写的源代码往往更简洁和表达力强,特别是与严重依赖四人帮(GoF)设计模式的代码相比。
这并不一定意味着如果带宽不是问题,您应该总是选择无状态。这些决定可以在许多层面上进行,从单个类或函数到整个应用程序。
以类为例。如果您正在建模,比如一个Consultant
,那么类中包含诸如顾问的姓名、联系方式、小时费率、当前和过去的项目等字段是有意义的。它是自然而然地有状态的。现在,想象一下,您需要计算他们的工作所得。您应该创建一个PaymentCalculator
类吗?您应该添加一个成员函数还是自由函数来计算这个?如果您选择类的方法,您应该将Consultant
作为构造函数参数还是方法参数传递?类是否应该具有津贴等属性?
添加一个成员函数来计算薪水会违反单一责任原则(SRP),因为现在该类将有两个责任:计算薪水和存储顾问的数据(状态)。这意味着引入一个自由函数或一个单独的类来实现这个目的应该优于拥有这样的混合类。
这样的类首先应该有状态吗?让我们讨论一下我们的PaymentCalculator
类的不同方法。
一种方法是公开用于计算目的所需的属性:
class PaymentCalculator;
{
public:
double calculate() const;
void setHours(double hours);
void setHourlyRate(double rate);
void setTaxPercentage(double tax);
private:
double hours_;
double netHourlyRate_;
double taxPercentage_;
};
这种方法有两个缺点。第一个是它不是线程安全的;这样的PaymentCalculator
类的单个实例在没有锁的情况下不能在多个线程中使用。第二个是一旦我们的计算变得更加复杂,该类可能会开始从我们的Consultant
类中复制更多字段。
为了消除重复,我们可以重新设计我们的类来存储一个Consultant
实例,就像这样:
class PaymentCalculator {
public:
double calculate() const;
void setConsultant(const Consultant &c);
void setTaxPercentage(double tax);
private:
gsl::not_null<const Consultant *> consultant_;
double taxPercentage_;
};
请注意,由于我们不能轻松地重新绑定引用,我们正在使用指南支持库(GSL)中的一个辅助类来存储可重新绑定的指针,该辅助类会自动确保我们不存储空值。
这种方法仍然有不是线程安全的缺点。我们能做得更好吗?事实证明,我们可以通过使其无状态来使类线程安全:
class PaymentCalculator {
public:
static double calculate(const Consultant &c, double taxPercentage);
};
如果没有状态需要管理,那么您决定创建自由函数(可能在不同的命名空间中)还是将它们作为类的静态函数分组,就不是很重要,就像我们在前面的片段中所做的那样。在类方面,有必要区分值(实体)类型和操作类型,因为混合它们可能导致 SRP 违规。
无状态和有状态的服务
我们讨论类的相同原则可以映射到更高级别的概念,例如微服务。
有状态服务是什么样的?让我们以 FTP 为例。如果不是匿名的,它需要用户传递用户名和密码来创建会话。服务器存储这些数据以识别用户仍然连接,因此它不断地存储状态。每次用户更改工作目录时,状态都会更新。用户所做的每个更改都会反映为状态的更改,即使他们断开连接也是如此。拥有有状态的服务意味着根据状态,对于两个看起来相同的GET
请求,可以返回不同的结果。如果服务器丢失状态,您的请求甚至可能停止正确处理。
有状态的服务也可能存在不完整的会话或未完成的事务以及增加的复杂性。会话应该保持多久?如何验证客户端是否崩溃或断开连接?我们何时应该回滚任何更改?虽然您可以回答这些问题,但通常更容易依赖服务的消费者以一种动态的“智能”方式与其进行通信。由于他们将自己维护某种状态,因此拥有一个也维护状态的服务不仅是不必要的,而且通常是浪费的。
无状态服务,如本书后面描述的 REST 服务,采用相反的方法。每个请求必须包含处理请求所需的所有数据,以便成功处理,因此两个相同的幂等请求(如GET
)将导致相同的回复。这是在假设服务器上存储的数据不会改变的情况下,但数据不一定等同于状态。重要的是每个请求都是自包含的。
无状态性在现代互联网服务中是基本的。HTTP 协议是无状态的,而许多服务 API,例如 Twitter 的,也是无状态的。REST,Twitter 的 API 依赖的协议,旨在实现功能上的无状态。这个缩写背后的整个概念,表现层状态转移(REST),传达了请求处理所需的所有状态必须在其中传输的概念。如果不是这种情况,你就不能说你有一个符合 REST 原则的服务。然而,由于实际需求,也有一些例外情况。
如果你正在构建一个在线商店,你可能希望存储与客户相关的信息,如他们的订单历史和送货地址。客户端可能会存储身份验证 cookie,而服务器可能会在数据库中存储一些用户数据。cookie 取代了我们在有状态服务中管理会话的需求。
将会话保留在服务器端是服务的不良方法,原因有几个:它们增加了许多可以避免的复杂性,使错误更难复制,最重要的是,它们不具备可伸缩性。如果你想将负载分布到另一台服务器上,很可能会遇到在负载和服务器之间复制会话以及同步它们的困难。所有会话信息都应该保存在客户端。
这意味着如果你希望有一个有状态的架构,你需要有一个很好的理由。以 FTP 协议为例。它必须在客户端和服务器端复制更改。用户只需要对一个特定的服务器进行身份验证,以执行单状态数据传输。将其与 Dropbox 等服务进行比较,其中数据通常在用户之间共享,并且文件访问是通过 API 抽象的,就可以看出无状态模型更适合这种情况。
理解单体应用——为什么应该避免,并识别例外情况
你可以开发应用程序的最简单的架构风格是单体架构。这就是为什么许多项目都是以这种风格开始的原因。单体应用程序只是一个大块,这意味着应用程序的功能可区分部分(如处理 I/O、数据处理和用户界面)都是交织在一起,而不是在单独的架构组件中。
部署这样的单体应用可能比多组件应用更容易,因为只需要部署一个东西。测试也可能更容易,因为端到端测试只需要启动一个单一组件。集成也更容易,因为除了扩展解决方案,你只需要在负载均衡器后面添加更多实例。尽管有这些优势,为什么会有人对这种架构风格感到畏惧呢?事实证明,尽管有这些优势,也存在许多缺点。
理论上提供的可伸缩性听起来不错,但如果你的应用程序具有不同资源需求的模块怎么办?如果只需要扩展应用程序中的一个模块怎么办?单体系统固有的缺乏模块化是与这种架构相关的许多缺陷的根源。
此外,你开发单片应用程序的时间越长,你在维护它时遇到的问题就越多。保持这样一个应用程序内部的松耦合是一个挑战,因为很容易在其模块之间添加另一个依赖。随着这样一个应用程序的增长,理解它变得越来越困难,因此由于增加的复杂性,开发过程很可能会随着时间的推移而变得越来越慢。在开发单片应用程序时,也很难维护设计驱动开发(DDD)的边界上下文。
拥有一个大型应用程序在部署和执行方面也有缺点。启动这样的应用程序所需的时间比启动更多、更小的服务要长得多。而且无论你在应用程序中做了什么改变,你可能不喜欢它强迫你一次性重新部署整个应用程序。现在,想象一下,你的一个开发人员在应用程序中引入了一个资源泄漏。如果泄漏的代码一遍又一遍地执行,它不仅会使应用程序的功能部分崩溃,还可能使整个应用程序崩溃。
如果你喜欢在项目中使用尖端技术,单片式风格也不会带来什么好消息。因为现在你需要一次性迁移整个应用程序,所以更难更新任何库或框架。
前面的解释表明,单片架构只适用于简单和小型应用程序。然而,还有一种情况,它实际上可能是一个好主意。如果你关心性能,单片架构有时可以帮助你在延迟或吞吐量方面比微服务更充分地利用你的应用程序。进程间通信总会带来一些开销,而单片应用程序则不需要支付。如果你对测量感兴趣,请参阅本章的进一步阅读部分中列出的论文。
理解服务和微服务
由于单片架构的缺点,其他方法已经出现。一个常见的想法是将解决方案分成多个相互通信的服务。然后,你可以将开发分配给不同的团队,每个团队负责一个单独的服务。每个团队的工作边界是清晰的,不像单片架构风格。
面向服务的架构,简称SOA,意味着业务功能被模块化,并作为独立的服务呈现给消费者应用程序使用。每个服务应该有一个自我描述的接口,并隐藏任何实现细节,比如内部架构、技术或所使用的编程语言。这允许多个团队以他们喜欢的方式开发服务,这意味着在幕后,每个团队可以使用最适合他们需求的东西。如果你有两个开发团队,一个精通 C#,一个精通 C++,他们可以开发两个可以轻松相互通信的服务。
SOA 的支持者提出了一项优先考虑以下内容的宣言:
-
业务价值优于技术策略
-
战略目标优于项目特定的利益
-
内在互操作性优于自定义集成
-
共享服务优于特定目的的实现
-
灵活性优于优化
-
演进式改进优于追求初始完美
尽管这份宣言不限制你使用特定的技术栈、实现或服务类型,但最常见的两种服务类型是 SOAP 和 REST。除此之外,最近,还有一种第三种类型的服务正在日益流行:基于 gRPC 的服务。你可以在关于面向服务的架构和微服务的章节中了解更多关于这些的信息。
微服务
顾名思义,微服务是一种软件开发模式,其中应用程序被拆分为使用轻量级协议进行通信的松耦合服务集合。微服务模式类似于 UNIX 哲学,即一个程序应该只有一个目的。根据 UNIX 哲学,复杂的问题可以通过将这些程序组合成 UNIX 管道来解决。同样,基于微服务的系统由许多微服务和支持服务组成。
让我们先来概述这种架构风格的优缺点。
微服务的优势和劣势
微服务架构中服务的小尺寸意味着它们开发、部署和理解起来更快。由于服务是彼此独立构建的,编译它们的新版本所需的时间可以大大缩短。由此,使用这种架构风格处理时更容易进行快速原型设计和开发。这反过来使得缩短交付时间成为可能,这意味着业务需求可以更快地引入和评估。
微服务方法的其他优点包括以下内容:
-
模块化是这种架构风格的固有特性。
-
更好的可测试性。
-
替换系统部分(如单个服务、数据库、消息代理或云提供商)的灵活性。
-
与传统系统集成:无需迁移整个应用程序,只需迁移需要当前开发的部分。
-
启用分布式开发:独立的开发团队可以并行地开发多个微服务。
-
可伸缩性:微服务可以独立扩展。
另一方面,以下是微服务的一些缺点:
-
它们需要成熟的 DevOps 方法和依赖于 CI/CD 自动化。
-
它们更难调试,需要更好的监控和分布式跟踪。
-
额外的开销(辅助服务方面)可能会超过较小应用程序的好处。
现在让我们讨论一下以这种架构风格编写的服务的特点。
微服务的特点
由于微服务风格相对较新,因此没有单一的微服务定义。根据 Martin Fowler 的说法,微服务具有几个基本特征,接下来我们将描述这些特征:
-
每个服务都应该是一个可以独立替换和升级的组件。这与组件作为单块应用程序库的紧耦合相对,后者在替换一个库时通常需要重新部署整个应用程序。
-
每个服务都应该由一个专注于特定业务能力的跨职能团队开发。听说过康威定律吗?
"任何设计系统(广义上定义)的组织都会产生一个结构,其结构是组织的沟通结构的复制。"**– Melvyn Conway, 1967
如果没有跨职能团队,你最终会陷入软件孤岛。与之伴随的沟通缺失将使您不断地跨越障碍才能成功交付。
-
每个服务都应该是一个产品,由开发团队在其整个生命周期内拥有。这与项目思维形成鲜明对比,项目思维中你只是开发软件然后交给其他人去维护。
-
服务应该具有智能端点并使用哑管道,而不是相反。这与传统服务形成对比,传统服务通常依赖于企业服务总线(ESB)的逻辑,ESB 通常管理消息的路由并根据业务规则进行转换。在微服务中,通过将逻辑存储在服务中并避免与消息组件耦合,可以实现内聚性。使用"哑"消息队列,如 ZeroMQ,可以帮助实现这一目标。
-
服务应该以分散的方式进行管理。单体通常使用特定的技术堆栈编写。当它们被拆分为微服务时,每个微服务可以选择最适合自己特定需求的技术。确保每个微服务 24/7 运行的管理工作由负责该特定服务的团队负责,而不是由一个中央部门负责。亚马逊、Netflix 和 Facebook 等公司遵循这种方法,并观察到让开发人员对其服务在生产中的无缺陷执行负责有助于确保高质量。
-
服务应该以分散的方式管理它们的数据。每个微服务可以选择最符合其需求的数据库,而不是为它们所有选择一个数据库。分散的数据可能会导致一些处理更新的挑战,但可以实现更好的扩展。这就是为什么微服务通常以无事务的方式协调并提供最终一致性。
-
服务使用的基础设施应该是自动管理的。为了有效地处理数十个微服务,您需要进行持续集成和持续交付,否则,部署您的服务将是一场噩梦。自动运行所有测试将为您节省大量时间和麻烦。在此基础上实施持续部署将缩短反馈周期,也能让您的客户更快地使用您的新功能。
-
微服务应该能够应对它们所依赖的其他服务的故障。在分布式部署环境中,由于有太多的运行部件,一些部件偶尔出现故障是正常的。您的服务应该能够优雅地处理这些故障。诸如断路器或舱壁(在本书后面有描述)的模式可以帮助实现这一点。为了使您的架构具有弹性,能够有效地将失败的服务重新启动甚至提前知道它们将崩溃也是至关重要的。实时监控延迟、吞吐量和资源使用情况对此至关重要。了解 Netflix 的 Simian Army 工具包,因为它对创建具有弹性的架构非常宝贵。
-
基于微服务的架构应该准备不断演进。您应该以一种允许轻松替换单个微服务甚至一组微服务的方式设计微服务和它们之间的合作。正确设计服务是有技巧的,特别是因为曾经存在于一个更大模块的代码中的一些复杂性现在可能存在于服务之间的复杂通信方案中,这更难以管理——所谓的意大利面集成。这意味着架构师的经验和技能比传统服务或单片式方法更加重要。
除此之外,许多(但并非全部)微服务共享的其他特征包括:
-
使用通过网络协议进行通信的独立进程
-
使用技术无关的协议(如 HTTP 和 JSON)
-
保持服务小型且运行时开销低
现在,您应该对基于微服务的系统的特征有了很好的理解,让我们看看这种方法与其他架构风格相比如何。
微服务和其他架构风格
微服务可以作为一种独立的架构模式使用。然而,它们通常与其他架构选择结合使用,例如云原生计算、无服务器应用程序,以及大多数轻量级应用容器。
面向服务的架构带来了松散耦合和高内聚。当正确应用时,微服务也可以做到。然而,这可能有些具有挑战性,因为需要很好的直觉来将系统划分为通常庞大数量的微服务。
微服务和它们的大型兄弟之间有更多的相似之处,它们也可以使用基于 SOAP、REST 或 gRPC 的消息传递,并使用诸如消息队列之类的技术来进行事件驱动。它们也有众所周知的模式来帮助实现所需的质量属性,例如容错(例如通过隔离故障组件),但为了拥有高效的架构,您必须决定您对诸如 API 网关、服务注册表、负载平衡、容错、监控、配置管理以及当然要使用的技术栈等元素的方法。
微服务的扩展
微服务与单片应用的扩展方式不同。在单片应用中,整个功能由单个进程处理。扩展应用程序意味着在不同的机器上复制此进程。这种扩展并不考虑哪些功能被大量使用,哪些不需要额外资源。
对于微服务,每个功能元素都作为一个单独的服务处理,这意味着一个单独的进程。为了扩展基于微服务的应用程序,只需复制需要更多资源的部分到不同的机器上。这种方法使得更容易更好地利用可用资源。
过渡到微服务
大多数公司都有某种现有的单片代码,他们不想立即使用微服务进行重写,但仍希望过渡到这种架构。在这种情况下,可以通过逐步添加越来越多与单片交互的服务来逐步适应微服务。您可以将新功能创建为微服务,或者只是剪切单片的一些部分并将其创建为微服务。
有关微服务的更多详细信息,包括如何从头开始构建自己的微服务,请参阅第十三章,设计微服务。
探索基于事件的架构
基于事件的系统是围绕处理事件的架构。有产生事件的组件,事件传播的通道,以及对其做出反应的监听器,可能还会触发新的事件。这是一种促进异步和松耦合的风格,这使得它成为提高性能和可伸缩性的好方法,也是一种易于部署的解决方案。
除了这些优势之外,还有一些需要解决的挑战。其中之一是创建这种类型系统的复杂性。所有队列必须具有容错能力,以便在处理过程中不会丢失任何事件。以分布式方式处理事务也是一个挑战。使用相关 ID 模式跟踪进程之间的事件,以及监控技术,可以节省您数小时的调试和苦思冥想。
基于事件的系统的示例包括流处理器和数据集成,以及旨在实现低延迟或高可伸缩性的系统。
让我们现在讨论在这种系统中常用的拓扑结构。
常见的基于事件的拓扑结构
基于事件的架构的两种主要拓扑结构是基于代理的和基于中介者的。这些拓扑结构在事件如何在系统中流动方面有所不同。
当处理需要执行多个独立任务或步骤的事件时,中介者拓扑结构最适用。最初产生的所有事件都会进入中介者的事件队列。中介者知道如何处理事件,但它不执行逻辑,而是通过每个处理器的事件通道将事件分派给适当的事件处理器。
如果这让您想起了业务流程是如何流动的,那么您的直觉很准确。您可以在业务流程管理(BPM)或业务流程执行语言(BPEL)中实现这种拓扑结构。然而,您也可以使用诸如 Apache Camel、Mule ESB 等技术来实现它:
图 2.1 - 中介者拓扑结构
另一方面,经纪人是一个轻量级组件,包含所有队列,不编排事件的处理。它可以要求接收者订阅特定类型的事件,然后简单地转发所有对他们有兴趣的事件。许多消息队列依赖于经纪人,例如 ZeroMQ,它是用 C++编写的,旨在实现零浪费和低延迟:
图 2.2 - 经纪人拓扑结构
现在您已经了解了基于事件的系统中使用的两种常见拓扑结构,让我们了解一种以事件为核心的强大架构模式。
事件溯源
您可以将事件视为包含额外数据的通知,供通知的服务处理。然而,还有另一种思考方式:状态的改变。想象一下,如果您能够知道应用逻辑在出现错误时所处的状态以及对其请求了什么改变,那么调试问题将会变得多么容易。这就是事件溯源的一个好处。实质上,它通过简单记录事件发生的顺序来捕获系统中发生的所有变化。
通常,您会发现服务不再需要在数据库中持久化其状态,因为在系统的其他地方存储事件就足够了。即使需要,也可以异步完成。从事件溯源中获得的另一个好处是免费的完整审计日志:
图 2.3 - 事件溯源架构。提供应用程序状态的统一视图可以允许消费它并创建定期快照以实现更快的恢复
由于减少了数据同步的需求,基于事件的系统通常具有低延迟,这使它们非常适合交易系统和活动跟踪器等应用。
现在让我们了解另一种流行的架构风格。
理解分层架构
如果您的架构开始看起来像意大利面条,或者您只是想要防止这种情况发生,那么将组件结构化为层可能会有所帮助。还记得模型-视图-控制器吗?或者类似的模式,如模型-视图-视图模型或实体-控制-边界?这些都是分层架构的典型例子(如果层是物理上相互分离的,则也称为 N 层架构)。您可以将代码结构化为层,可以创建微服务层,或者将此模式应用于您认为可以带来好处的其他领域。分层提供了抽象和关注点的分离,这是引入它的主要原因。然而,它还可以帮助减少复杂性,同时提高解决方案的模块化、可重用性和可维护性。
一个现实世界的例子是自动驾驶汽车,其中层可以用于分层地做出决策:最低层将处理汽车的传感器,然后另一层将处理消耗传感器数据的单个功能,再上面可能会有另一层来确保所有功能都能产生安全行为。当传感器在汽车的另一个型号中被替换时,只需要替换最低层。
分层架构通常很容易实现,因为大多数开发人员已经了解层的概念 - 他们只需要开发几个层并像下图中那样堆叠它们:
图 2.4 - 使用文本界面的 3 层架构示例
创建高效的分层架构的挑战在于规定层之间的稳定、明确定义的接口。通常,你可以在一个层之上有几个层。例如,如果你有一个领域逻辑层,它可以作为呈现层和向其他服务提供 API 的基础层。
这并不意味着分层始终是一件好事。在微服务中,有两种主要情况下会出现分层。第一种是当你想要将一组服务与另一组服务分开时。例如,你可以有一个快速变化的层与你的业务合作伙伴进行交互,内容经常变化,还有一个面向业务能力的层。后者的变化速度不那么快,使用的技术也比较稳定。分开这两个层是有意义的。还有一个概念是不太稳定的组件应该依赖于更稳定的组件,因此很容易看出你可以在这里有两个层,其中面向客户的层依赖于业务能力。
另一种情况是创建层以反映组织的通信结构(再见,康威定律)。这可能会减少团队之间的沟通,从而导致创新减少,因为现在团队不会那么了解彼此的内部或想法。
现在让我们讨论另一个经常与微服务一起使用的分层架构的例子——面向前端的后端。
面向前端的后端
看到许多前端依赖于相同的后端并不罕见。假设你有一个移动应用和一个 web 应用,两者都使用相同的后端。起初这可能是一个不错的设计选择。然而,一旦这两个应用的需求和使用场景开始分歧,你的后端就需要越来越多的改变,只为其中一个前端提供服务。这可能导致后端需要支持竞争性的需求,比如两种不同的更新数据存储的方式或者提供数据的不同场景。同时,前端开始需要更多的带宽来与后端进行正确的通信,这也导致移动应用的电池使用更多。在这一点上,你应该考虑为每个前端引入一个单独的后端。
这样,你可以将用户界面应用程序视为一个具有两个层的单个实体:前端和后端。后端可以依赖于另一层,包括下游服务。参考以下图表:
图 2.5 - 面向前端的后端模式
使用面向前端的后端(BFFs)的缺点是一些代码必须重复。只要这加快了开发并且从长远来看不是负担,那就没问题。但这也意味着你应该留意聚合重复逻辑到下游服务的可能性。有时,引入一个服务来聚合类似的调用可以帮助解决重复问题。通常,如果你有许多前端,一些前端仍然可以共享一个后端,而不会导致它有竞争性的需求。例如,如果你为 iOS 和 Android 创建移动应用,你可以考虑重用相同的后端,并为 web 和/或桌面应用程序单独创建后端。
基于学习模块的架构
在本节中,通过模块,我们指的是可以在运行时加载和卸载的软件组件。有关 C++20 模块,请参阅第五章,利用 C++ 语言特性。
如果您曾经需要尽可能少地运行一个组件,但由于任何原因无法应用通常的容错模式,例如服务的冗余副本,那么将该组件基于模块化可能会挽救您的一天。或者您可能只是被一个具有所有模块版本化的模块化系统的愿景所吸引,可以轻松查找所有可用服务,以及模块化系统可能引起的解耦、可测试性和增强团队合作。这就是为什么开放服务网关倡议(OSGi)模块被创建用于 Java,并在多个框架中被移植到 C++中。使用模块的架构示例包括诸如 Eclipse 的 IDE、软件定义网络(SDN)项目,如 OpenDaylight,或家庭自动化软件,如 OpenHAB。
OSGi 还允许模块之间的自动依赖管理,控制它们的初始化和卸载,以及控制它们的发现。由于它是面向服务的,您可以将使用 OSGi 服务视为在一个“容器”中拥有微小(微?)服务。这就是为什么 C++实现之一被命名为 C++ Micro Services。要看到它们的实际效果,请参考进一步阅读部分的入门指南。
C++ Micro Services 框架采用的一个有趣的概念是一种处理单例的新方法。GetInstance()
静态函数将不再只传递静态实例对象,而是返回从捆绑上下文中获取的服务引用。因此,单例对象将被您可以配置的服务所取代。它还可以避免静态去初始化的困境,其中相互依赖的多个单例必须按特定顺序卸载。
摘要
在本章中,我们讨论了您可以在实际中遇到并应用于您的软件的各种架构风格。我们讨论了单体架构,通过面向服务的架构,转向了微服务,并讨论了它们可以提供外部接口并相互交互的各种方式。您学会了如何编写 RESTful 服务,以及如何创建一个弹性且易于维护的微服务架构。
我们还展示了如何创建简单的客户端来消费同样简单的服务。随后,我们讨论了架构的各种其他方法:事件驱动的方法,运行时基于模块的方法,并展示了分层可以被发现的地方以及原因。您现在知道如何实现事件溯源,并知道何时使用 BFFs。此外,您现在知道架构风格如何帮助您实现多个质量属性以及这可能带来的挑战。
在下一章中,您将学习如何知道在给定系统中哪些属性是重要的。
问题
1. RESTful 服务的特征是什么?
2. 您可以使用哪些工具包来帮助您创建弹性的分布式架构?
3. 您应该为您的微服务使用集中式存储吗?为什么/为什么不?
4. 何时应该编写有状态服务而不是无状态服务?
5. 经纪人和中介之间有何不同?
6. N 层和 N 层架构有什么区别?
7. 您应该如何处理用微服务架构替换单体架构?
进一步阅读
-
Flygare, R., and Holmqvist, A. (2017). 单体架构和基于微服务的系统之间的性能特征(论文)。取自
urn.kb.se/resolve?urn=urn:nbn:se:bth-14888
-
Engelen, Robert. (2008). 使用 C 和 C++ Web 服务组件进行面向服务的计算的框架。ACM Trans. Internet Techn. 8. 10.1145/1361186.1361188
-
Fowler, Martin. 微服务-对这个新的架构术语的定义。来源:
martinfowler.com/articles/microservices.html#MicroservicesAndSoa
-
入门-C++ 微服务文档。来源:
docs.cppmicroservices.org/en/stable/doc/src/getting_started.html
第三章:功能和非功能需求
作为架构师,重要的是要认识到哪些需求对架构有重要意义,以及为什么。本章将教你关于解决方案的各种需求——功能和非功能。功能需求告诉您您的解决方案应该做什么。另一方面,非功能需求告诉您您的解决方案应该如何*。*
在本章中,我们将涵盖以下主题:
-
理解需求的类型
-
识别具有架构重要性的需求
-
从各种来源收集需求
-
记录需求
-
记录架构
-
选择正确的视图来记录
-
生成文档
在本章结束时,您将学会如何识别和分类这两种类型的需求,并如何创建清晰描述它们的文档。
来自源的技术需求文档,您必须安装
要从源生成文档,必须安装 CMake、Doxygen、Sphinx、m2r2 和 Breathe。我们正在使用 ReadTheDocs Sphinx 主题,所以也请安装它。请随意使用提到的工具的最新版本。
您可以在github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter03
找到相关代码。
理解需求的类型
在创建软件系统时,您应该不断地问自己您正在做的是否是您的客户需要的。很多时候,他们甚至不知道哪个需求最能满足他们的需求。成功的架构师的角色是发现产品的需求,并确保它们得到满足。有三种不同类型的需求需要考虑:功能需求、质量属性和约束。让我们来看看每一种。
功能需求
第一组是功能需求。这些是定义您的系统应该做什么或应该提供什么功能的需求。
请记住,功能并不总是影响架构,因此您必须密切关注这些需求中哪些实际上会决定您的解决方案的外观。
通常,如果功能需求具有必须满足的某些特性,它可能会变得具有架构重要性。考虑一个为多米尼加展会的商家和游客设计的应用程序,这是一年一度的活动,包括音乐、各种艺术和商店,发生在格但斯克市。对它的一些功能需求的几个例子可能是以下内容:
-
作为店主,我想要筛选包含特定产品的订单。
-
单击订阅按钮会将客户添加到所选商家的通知观察者列表中。
这些要求中的第一个告诉我们,我们将需要一个用于跟踪订单和产品并具有搜索功能的组件。根据 UI 的具体外观和我们的应用程序的规模,我们可以只向我们的应用程序添加一个简单的页面,或者它可能需要诸如 Lucene 或 Elasticsearch 之类的功能。这意味着我们可能正在考虑一个对架构有影响的架构上重要的需求(ASR)。
第二个例子更加直接;现在我们知道我们需要一个用于订阅和发送通知的服务。这绝对是一个具有架构重要性的功能需求。现在让我们看一些非功能需求(NFRs),它们也可以是 ASRs。
顺便说一句,第一个要求实际上是以用户故事的形式给出的。用户故事是以以下格式给出的需求:“作为<角色>,我可以/想要<能力>,以便<好处>。”这是表达需求的常见方式,可以帮助利益相关者和开发人员找到共同点并更好地沟通。
非功能需求
非功能性需求不是关注你的系统应该具有什么功能,而是关注系统应该在多好的条件下执行这些功能。这一组主要包括两个子组:质量属性(QAs)和约束。
质量属性
质量 属性(QAs)是你的解决方案的特征,如性能、可维护性和用户友好性。你的软件可能有数十个,甚至数百个不同的质量。在选择你的软件应该具有哪些质量时,试着只关注重要的质量,而不是列出你脑海中出现的所有质量。质量属性需求的例子包括以下内容:
-
系统在通常负载下对 99.9%的请求在 500 毫秒内做出响应(不要忘记指定通常负载是什么或将是什么)。
-
网站不会存储在支付过程中使用的客户信用卡数据(保密性的一个例子)。
-
在更新系统时,如果更新任何组件失败,系统将被回滚到更新之前的状态(生存能力)。
-
作为 Windows、macOS 和 Android 的用户,我希望能够从所有这些系统中使用系统(可移植性;尝试了解是否需要支持桌面、移动和/或网络等平台)。
虽然在积压中捕捉功能性需求非常简单,但我们不能说同样的话适用于质量属性需求。幸运的是,有几种方法可以解决这个问题:
-
其中一些可以在你的任务、故事和发布的完成定义或验收标准中表达。
-
其他可以直接表达为用户故事,如前面的最后一个例子所示。
-
你也可以在设计和代码审查中检查它们,并为其中一些创建自动化测试。
约束
约束是在交付项目时必须遵循的不可协商的决定。这些可以是设计决策、技术决策,甚至是政治(涉及人员或组织事务)的决定。另外两个常见的约束是时间和预算。约束的例子可能如下:
-
团队的人数永远不会超过四名开发人员、一名质量保证工程师和一名系统管理员。
-
由于我们公司在所有当前产品中都使用 Oracle DB,新产品也必须使用它,这样我们就可以充分利用我们的专业知识。
非功能性需求总是会影响你的架构。不要过度规定它们,因为出现假阳性将会是产品开发过程中的一个不断的负担。同样重要的是不要过度规定它们,因为这可能会在错过销售机会或未能遵守监管机构的要求时出现。
在下一节中,你将学会如何在这两个极端之间取得平衡,只关注那些在你的具体情况下真正重要的需求。
识别具有架构重要性的需求
在设计软件系统时,通常要处理数十个甚至数百个不同的需求。为了理解它们并提出一个好的设计,你需要知道哪些是重要的,哪些可以不考虑你的设计决策而实现,甚至可以被忽略。你应该学会如何识别最重要的需求,这样你就可以首先专注于它们,并在最短的时间内提供最大的价值。
你应该使用两个指标来优先考虑需求:业务价值和对架构的影响。那些在两个方面都很重要的需求是最重要的,应该优先处理。如果你提出了太多这样的需求,你应该重新审视你的优先考虑方案。如果这没有帮助,可能是系统根本无法实现。
ASR 是对系统架构产生可衡量影响的要求。它们可以是功能性的,也可以是非功能性的。你如何确定哪些才是真正重要的?如果某个特定要求的缺失会允许你创建不同的架构,那么你就在考虑一个 ASR。对这类要求的迟发现通常会花费你大量的时间和金钱,因为你需要重新设计系统的某些部分,甚至整个解决方案。你只能希望这不会给你的声誉和其他资源带来损失。
从架构工作的一开始就将具体技术应用于你的架构是一个常见的错误。我们强烈建议你首先收集所有的要求,专注于对架构重要的要求,然后再决定使用什么技术和技术栈来构建你的项目。
由于识别 ASR 如此重要,让我们谈谈一些可以帮助你的模式。
架构重要性的指标
如果你有要求与任何外部系统集成,这很可能会影响你的架构。让我们来看看一些常见的指标,表明一个要求是 ASR:
-
需要创建一个软件组件来处理它:例如发送电子邮件、推送通知、与公司的 SAP 服务器交换数据,或者使用特定的数据存储。
-
对系统产生重大影响:核心功能通常定义了你的系统应该是什么样子。诸如授权、可审计性或事务行为等横切关注点都是其他很好的例子。
-
难以实现:低延迟就是一个很好的例子:除非你在开发初期就考虑到它,否则要实现它可能是一场漫长的战斗,特别是当你突然意识到在热路径上不能承受垃圾回收时。
-
在满足某些架构时强制进行权衡:也许你的设计决策甚至需要在成本过高的情况下牺牲一些要求,以支持其他更重要的要求。将这类决策记录在某个地方,并注意到你正在处理 ASR 是一个很好的做法。如果任何要求限制了你或以任何方式限制了产品,那很可能对架构来说是重要的。如果你想在许多权衡中提出最佳架构,那么一定要阅读一下架构权衡分析方法(ATAM),你可以在进一步阅读部分的链接中找到相关内容。
约束和应用程序将运行的环境也可能会影响你的架构。嵌入式应用程序需要以不同的方式设计,以适应在云中运行的应用程序,而由经验不足的开发人员开发的应用程序可能应该使用简单且安全的框架,而不是使用学习曲线陡峭的框架或开发他们自己的框架。
识别 ASR 的障碍以及如何处理它们
与直觉相反,许多架构上重要的要求一开始很难发现。这是由两个因素造成的:它们很难定义,即使描述了,也可能模糊不清。你的客户可能还不清楚他们需要什么,但你仍然应该积极提出问题,以避免任何假设。如果你的系统要发送通知,你必须知道这些是实时的还是每天的电子邮件就足够了,因为前者可能需要你创建一个发布-订阅的架构。
在大多数情况下,你需要做一些假设,因为并非所有事情都能事先知道。如果你发现一个要求挑战了你的假设,那可能就是一个 ASR。如果你假设你可以在凌晨 3 点到 4 点之间维护你的服务,然后你意识到来自不同时区的客户仍然需要使用它,那就会挑战你的假设,很可能改变产品的架构。
此外,人们在项目的早期阶段,特别是经验较少或技术水平较低的人,往往倾向于模糊地对待质量属性。另一方面,这是解决这些 ASR 的最佳时机,因为在系统中实施它们的成本最低。
然而值得注意的是,许多人在指定需求时喜欢使用模糊的短语,而实际上并没有仔细考虑。如果您正在设计类似 Uber 的服务,一些例子可能是:当接收到 DriverSearchRequest 时,系统必须快速回复 AvailableDrivers 消息,或者系统必须全天候可用。
询问问题后,通常会发现 99.9%的月可用性是完全可以接受的,而快速实际上是几秒钟。这些短语总是需要澄清,了解背后的原因通常也是有价值的。也许这只是某人的主观看法,没有任何数据或业务需求支持。此外,请注意,在请求和响应的情况下,质量属性隐藏在另一个需求中,这使得更难以捕捉。
最后,对于一个系统而言,具有架构重要性的需求并不一定对另一个系统具有相同的重要性,即使这些系统提供类似的目的。一些需求会随着时间的推移变得更加重要,一旦系统增长并开始与越来越多的其他系统进行通信。其他需求可能在产品需求发生变化时变得重要。这就是为什么没有一种确定哪些需求将成为 ASR,哪些不会的银弹。
掌握了如何区分重要需求和其他需求的所有知识,您知道要寻找什么。现在让我们谈谈在哪里寻找。
从各种来源收集需求
现在您知道要关注哪些需求,让我们讨论一下收集这些需求的一些技术。
了解背景
在挖掘需求时,您应该考虑更广泛的背景。您必须确定未来可能对产品产生负面影响的潜在问题。这些风险通常来自外部。让我们重新审视我们类似 Uber 的服务场景。您的服务的一个例子风险可能是法律的潜在变化:您应该意识到一些国家可能会试图改变法律以将您从市场中移除。Uber 减轻这些风险的方式是与当地合作伙伴应对地区限制。
除了未来的风险之外,您还必须了解当前的问题,比如公司中缺乏主题专家,或市场上的激烈竞争。您可以做以下事情:
-
要注意并记录任何假设。最好有一个专门的文档来跟踪这些假设。
-
尽可能提出问题以澄清或排除您的假设。
-
您需要考虑项目内部的依赖关系,因为它们可能会影响开发进度。其他有用的领域是塑造公司日常行为的业务规则,因为您的产品可能需要遵守并可能增强这些规则。
-
此外,如果有足够的与用户或业务相关的数据,您应该尝试挖掘它以获取见解,并找到有用的模式,可以帮助做出关于未来产品及其架构的决策。如果您已经有一些用户,但无法挖掘数据,观察他们的行为通常也是有用的。
理想情况下,您可以在他们使用当前部署的系统执行日常任务时记录下来。这样,您不仅可以自动化他们工作的部分,还可以完全改变他们的工作流程为更高效的工作流程。然而,请记住,用户不喜欢改变他们的习惯,因此在可能的情况下逐渐引入变化更好。
了解现有文档
现有文件可以是信息的重要来源,尽管它们也可能存在问题。您应该至少留出一些时间来熟悉与您的工作相关的所有现有文件。很可能其中隐藏着一些需求。另一方面,要记住,文档永远不会完美;很可能会缺少一些重要信息。您还应该做好文档可能已过时的准备。在架构方面,从来没有一个真正的信息来源,因此除了阅读文档,您还应该与相关人员进行大量讨论。尽管如此,阅读文件可以是为此类讨论做好准备的好方法。
了解你的利益相关者
要成为成功的架构师,你必须学会与商业人士沟通,因为需求直接或间接来自他们。无论是来自你的公司还是客户,你都应该了解他们业务的背景。例如,你必须了解以下内容:
-
是什么推动业务?
-
公司有什么目标?
-
你的产品将帮助实现什么具体目标?
一旦你意识到这一点,与许多来自管理或高管的人建立共同基础,以及收集有关软件的更具体要求,将会更容易。例如,如果公司关心用户的隐私,它可以要求尽可能少地存储有关用户的数据,并使用仅存储在用户设备上的密钥进行加密。通常,如果这些要求来自公司文化,对一些员工来说,甚至表达这些要求都太明显了。了解业务背景可以帮助你提出适当的问题,并帮助公司回报。
话虽如此,请记住,您的利益相关者可能会有需求,这些需求不一定直接反映在公司的目标中。他们可能会有自己的功能提供或软件应该实现的指标的想法。也许一个经理承诺让他的员工有机会学习一种新技术或与特定技术一起工作。如果这个项目对他们的职业发展很重要,他们可能会成为强有力的盟友,甚至说服其他人支持你的决定。
另一个重要的利益相关者群体是负责部署您的软件的人。他们可能会提出自己的需求子组,称为过渡需求。这些需求的例子包括用户和数据库迁移、基础设施过渡或数据转换,因此不要忘记与他们联系以收集这些需求。
从利益相关者那里收集需求
在这一点上,你应该有一个利益相关者列表,包括他们的角色和联系信息。现在是时候利用它了:一定要抽出时间与每个利益相关者谈论他们对系统的需求以及他们对系统的设想。你可以进行面谈,如一对一会议或小组会议。与利益相关者交谈时,帮助他们做出明智的决定——展示他们的答案对最终产品的潜在影响。
利益相关者常常说他们所有的需求都同等重要。试着说服他们根据需求对他们的业务价值进行优先排序。当然,会有一些使命关键的需求,但很可能,如果一堆其他需求没有被交付,项目也不会失败,更不用说任何愿望清单上的附加需求了。
除了采访外,您还可以为他们组织研讨会,这可以像头脑风暴会议一样起作用。在这样的研讨会上,一旦建立了共同基础,每个人都知道他们为什么参与这样的冒险,您可以开始要求每个人尽可能多地提出使用场景。一旦这些场景确定下来,您可以开始整合相似的场景,然后应该对其进行优先排序,最后完善所有的故事。研讨会不仅仅是关于功能需求;每个使用场景也可以分配一个质量属性。在完善之后,所有的质量属性都应该是可衡量的。最后需要注意的是:您不需要将所有利益相关者都带到这样的活动中,因为它们有时可能需要超过一天的时间,这取决于系统的规模。
现在您知道如何使用各种技术和来源挖掘需求,让我们讨论如何将您的发现倾注到精心制作的文档中。
记录需求
一旦您完成了前面描述的步骤,就该将您收集到的所有需求整理并精炼到一个文档中了。文档的形式和管理方式并不重要。重要的是您有一个文档,让所有利益相关者对产品的要求和每个需求带来的价值有一个共同的认识。
需求由所有利益相关者产生和消耗,他们中的广泛一部分将需要阅读您的文档。这意味着您应该以一种能够为各种技术技能的人带来价值的方式来撰写它,从客户、销售人员和营销人员,到设计师和项目经理,再到软件架构师、开发人员和测试人员。
有时,准备文档的两个版本是有意义的,一个是针对项目业务方面的人员,另一个是更技术性的,针对开发团队。然而,通常,只需撰写一个文档,使每个人都能理解,其中包括用于涵盖更多技术细节的部分(有时是单独的段落)或整章。
现在让我们来看看您的需求文档可能包含哪些部分。
记录上下文
需求文档应该作为项目的一个入口点之一:它应该概述产品的目的,谁将使用它,以及如何使用它。在设计和开发之前,产品团队成员应该阅读它,以清楚地了解他们将实际工作的内容。
上下文部分应该提供系统的概述-为什么要构建它,它试图实现什么业务目标,以及它将提供什么关键功能。
您可以描述一些典型的用户角色,比如CTO 约翰,或者司机安,以便读者更有机会考虑系统的用户是实际的人类,并知道可以从他们那里期望什么。
了解上下文部分中描述的所有事情也应该作为上下文部分的一部分进行总结,有时甚至在文档中单独给出部分。上下文和范围部分应该提供大多数非项目利益相关者所需的所有信息。它们应该简洁而准确。
对于您可能想要研究并稍后决定的任何悬而未决的问题也是如此。对于您做出的每个决定,最好记录以下内容:
-
决策本身是什么
-
是谁做的,什么时候做的
-
背后的理由是什么
现在您知道如何记录项目的上下文,让我们学习如何正确描述其范围。
记录范围
这一部分应该定义项目的范围,以及超出范围的内容。您应该解释为什么以特定方式定义范围的理由,特别是在写关于不会被采纳的事情时。
这一部分还应该涵盖高级功能和非功能性要求,但详细信息应该放在文档的后续部分。如果您熟悉敏捷实践,只需在这里描述史诗和更大的用户故事。
如果您或您的利益相关者对范围有任何假设,您应该在这里提到。如果范围由于任何问题或风险而可能发生变化,您也应该写一些相关内容,同样适用于您不得不做出的任何权衡。
记录功能性要求
每个要求都应该是精确和可测试的。考虑这个例子:"系统将为司机建立一个排名系统。"您会如何对其进行测试?最好为排名系统创建一个部分,并在那里指定精确的要求。
考虑另一个例子:如果有一名空闲司机靠近乘客,他们应该被通知即将到来的乘车请求。如果有多名可用司机呢?我们仍然可以描述为“靠近”的最大距离是多少呢?
这个要求既不精确,也缺乏业务逻辑的部分。我们只能希望没有空闲司机的情况已经被另一个要求覆盖了。
2009 年,劳斯莱斯开发了其Easy Approach to Requirements Syntax(EARS),以帮助应对这一问题。在 EARS 中,有五种基本类型的要求,它们应该以不同的方式编写并服务于不同的目的。它们可以后来组合成更复杂的要求。这些基本要求如下:
-
普遍要求:"
$SYSTEM
应该$REQUIREMENT
",例如,应用程序将使用 C++开发。 -
事件驱动:"当
$TRIGGER $OPTIONAL\_PRECONDITION
时,$SYSTEM
应该$REQUIREMENT
",例如,"当订单到达时,网关将产生一个 NewOrderEvent。 -
不需要的行为:"如果
$CONDITION
,那么$SYSTEM
应该$REQUIREMENT
",例如,如果请求的处理时间超过 1 秒,工具将显示一个进度条。 -
状态驱动:"当
$STATE
时,$SYSTEM
应该$REQUIREMENT
",例如,当乘车进行时,应用程序将显示地图以帮助司机导航到目的地。 -
可选功能:"在
$FEATURE
的情况下,$SYSTEM
应该$REQUIREMENT
",例如,如果有空调,应用程序将允许用户通过移动应用程序设置温度。
一个更复杂的要求的例子是:在使用双服务器设置时,如果备份服务器在 5 秒内没有收到主服务器的消息,它应该尝试注册自己为新的主服务器。
您不需要使用 EARS,但如果您在处理模糊、含糊、过于复杂、不可测试、遗漏或者用词不当的要求时遇到困难,它可能会有所帮助。无论您选择哪种方式或措辞,都要确保使用基于常用语法并使用预定义关键字的简洁模型。为您列出的每个要求分配一个标识符也是一个好的做法,这样您就可以轻松地引用它们。
当涉及到更详细的要求格式时,它应该具有以下字段:
-
ID 或索引:方便识别特定要求。
-
标题:您可以在这里使用 EARS 模板。
-
详细描述:您可以在这里放置您认为相关的任何信息,例如用户故事。
-
所有者:这个要求是为谁服务的。可以是产品所有者、销售团队、法律部门、IT 等等。
-
优先级:相当不言自明。
-
交付日期:如果这个要求需要在任何关键日期之前完成,您可以在这里记录。
现在我们知道如何记录功能性要求,让我们讨论一下您应该如何记录非功能性要求。
记录非功能性要求
每个质量属性,比如性能或可扩展性,都应该在你的文档中有自己的部分,列出具体的、可测试的要求。大多数质量属性都是可衡量的,所以具体的度量标准可以在解决未来问题时起到很大作用。你也可以有一个关于项目约束的单独部分。
关于措辞,你可以使用相同的 EARS 模板来记录你的 NFRs。或者,你也可以使用在本章节中定义的角色来将它们指定为用户故事。
管理文档的版本历史
你可以采取以下两种方法之一:要么在文档内部创建一个版本日志,要么使用外部版本控制工具。两者都有各自的优缺点,但我们建议选择后者的方法。就像你为代码使用版本控制系统一样,你也可以用它来管理你的文档。我们并不是说你必须使用存储在 Git 仓库中的 Markdown 文档,但只要你也生成了一个业务人员可读的版本,比如网页或 PDF 文件,这也是一个完全有效的方法。或者,你也可以使用在线工具,比如 RedmineWikis,或 Confluence 页面,它们允许你在每次发布编辑时放置一个有意义的评论来描述所做的更改,并查看版本之间的差异。
如果你决定采用修订日志的方法,通常是一个包括以下字段的表格:
-
修订:标识引入变化的文档迭代的编号。如果你愿意,你也可以为特殊的修订添加标签,比如第一稿。
-
更新者:谁做出了更改。
-
审查人:谁审查了这个变化。
-
更改描述:这个修订的提交消息。它说明了发生了什么变化。
在敏捷项目中记录需求
许多敏捷的支持者会声称记录所有需求只是浪费时间,因为它们可能会发生变化。然而,一个好的方法是将它们类似地对待你的待办事项中的项目:在即将到来的冲刺中将会开发的项目应该比你希望以后实施的项目更详细地定义。就像在必要之前你不会将史诗故事拆分成故事和任务一样,你可以只粗略地描述、不那么细粒度地定义需求,直到你确定需要它们被实施。
注意是谁或什么是给定需求的来源,这样你就会知道谁可以为你提供未来完善它所需的输入。
让我们以我们的多米尼加集市为例。比如说在下一个冲刺中,我们将为访客构建商店页面,然后在下一个冲刺中,我们将添加一个订阅机制。我们的需求可能看起来像下面这样:
ID | 优先级 | 描述 | 利益相关者 |
---|---|---|---|
DF-42 | P1 | 商店页面必须显示商店的库存,每件物品都有照片和价格。 | 乔什,瑞克 |
DF-43 | P2 | 商店页面必须包含商店位置的地图。 | 乔什,坎迪斯 |
DF-44 | P2 | 客户必须能够订阅商店。 | 史蒂文 |
正如你所看到的,前两项与我们接下来要做的功能有关,所以它们被描述得更详细。谁知道,也许在下一个冲刺之前,关于订阅的需求就会被取消,所以考虑每个细节就没有意义了。
另一方面,有些情况可能仍需要你列出完整的需求清单。如果你需要与外部监管机构或内部团队(如审计、法律或合规性)打交道,他们可能仍然需要你提供一份完整的书面文件。有时,只需向他们提供一份包含从你的待办事项中提取的工作项的文件就可以了。最好像对待其他利益相关者一样与这些利益相关者沟通:了解他们的期望,以了解满足他们需求的最低可行文档。
记录需求的重要之处在于你和提出具体需求的各方之间有一个共识。如何实现这一点?一旦你准备好了草稿,你应该向他们展示你的文档并收集反馈。这样,你就会知道哪些地方含糊不清、不清楚或遗漏了。即使需要几次迭代,这也将帮助你与利益相关者达成共识,从而更有信心地确保你正在构建正确的东西。
其他部分
在网站中设置一个链接和资源部分是个好主意,你可以在这里指向问题跟踪板、工件、持续集成、源代码库以及其他你觉得有用的东西。架构、营销和其他类型的文档也可以在这里列出。
如果需要,你也可以包括一个术语表。
现在你知道如何记录你的需求和相关信息了。现在让我们简要谈谈如何记录设计的系统。
记录架构
就像你应该记录你的需求一样,你也应该记录新兴的架构。这当然不仅仅是为了有文档:它应该帮助项目中的每个人更加高效,让他们更好地理解他们需要做什么以及最终产品需要什么。你制作的并不是所有图表都对每个人都有用,但你应该从未来读者的角度来创建它们。
有很多框架可以用来记录你的愿景,其中许多框架专门服务于特定领域、项目类型或架构范围。如果你有兴趣记录企业架构,比如说,你可能会对 TOGAF 感兴趣。这是“开放组织架构框架”的缩写。它依赖于四个领域,即以下内容:
-
业务架构(战略、组织、关键流程和治理)
-
数据架构(逻辑和物理数据管理)
-
应用架构(单个系统的蓝图)
-
技术架构(硬件、软件和网络基础设施)
如果你在整个公司范围内或者更广泛的范围内记录你的软件,这种分组是有用的。其他类似规模的框架包括英国国防部(MODAF)和美国国防部(DoDAF)开发的框架。
如果你不是在记录企业架构,尤其是如果你刚开始自己的架构自我发展之路,你可能会对其他框架更感兴趣,比如 4+1 和 C4 模型。
理解 4+1 模型
4+1 视图模型是由 Philippe Kruchten 于 1995 年创建的。作者当时声称它旨在“描述基于多个并发视图使用的软件密集型系统的架构”。它的名称来源于它包含的视图。
这个模型因为在市场上存在已久并且发挥了作用而广为人知。它非常适合大型项目,虽然也可以用于中小型项目,但对于它们的需求来说可能过于复杂(特别是如果它们是以敏捷方式编写的)。如果你的情况是这样,你应该尝试下一节中描述的 C4 模型。
4+1 模型的一个缺点是它使用了一组固定的视图,而实际上,对架构进行文档化的务实方法应该是根据项目的具体情况选择视图(稍后详述)。
另一方面,一个很好的优势是视图之间的链接,特别是在场景方面。同时,每个利益相关者都可以轻松地获得与他们相关的模型部分。这将引出模型的外观:
图 3.1 - 4+1 模型概述
前面图中的参与者是最感兴趣的各自视图。所有视图都可以用不同类型的统一建模语言(UML)图表来表示。现在让我们讨论每个视图:
- 逻辑视图显示如何向用户提供功能。它显示系统的组件(对象)以及它们之间的交互。通常,它由类和状态图组成。如果你有成千上万的类,或者只是想更好地展示它们之间的交互,你还应该有通信或序列图,它们都是我们下一个视图的一部分:
图 3.2 - 类图可用于显示我们计划拥有的类型及其关系
- 过程视图围绕系统的运行时行为展开。它显示进程、它们之间的通信以及与外部系统的交互。它由活动和交互图表示。该视图涉及许多 NFR,包括并发性、性能、可用性和可扩展性。
图 3.3 - 活动图是工作流程和流程的图形表示
- 开发视图用于将系统分解为子系统,并围绕软件组织展开。重用、工具约束、分层、模块化、打包、执行环境 - 该视图可以通过显示系统的构建块分解来表示它们。它通过使用组件和包图来实现:
图 3.4 - 包图可以从更高的角度显示系统的部分,以及特定组件之间的依赖或关系
- 物理视图用于使用部署图将软件映射到硬件。针对系统工程师,它可以涵盖与硬件相关的一部分 NFR,例如通信:
图 3.5 - 部署图展示每个软件组件将在哪些硬件上运行。它还可以用于传递有关网络的信息
- 场景将所有其他视图粘合在一起。通过用例图表示,这对所有利益相关者都有用。该视图显示系统是否按照应有的方式运行,并且是一致的。当所有其他视图完成时,场景视图可能会变得多余。然而,没有使用场景,其他视图就不可能存在。该视图从高层次显示系统,而其他视图则进入细节:
图 3.6 - 用例图显示特定参与者如何与系统交互以及这些交互之间的关系
这些视图中的每一个都与其他视图相互关联,通常它们必须共存以展示完整的画面。让我们考虑表达并发性。仅使用逻辑视图无法完成,因为将其映射到任务和流程更具表现力;我们需要过程视图。另一方面,流程将被映射到物理的、通常是分布式的节点。这意味着我们需要有效地在三个视图中记录它,每个视图对特定的利益相关者都是相关的。视图之间的其他连接包括以下内容:
-
逻辑视图和过程视图在分析和设计中用于概念化产品。
-
开发和部署结合描述了软件的打包以及每个软件包的部署时间。
-
逻辑和开发视图显示功能如何在源代码中反映出来。
-
流程和部署视图旨在共同描述非功能需求。
现在您已经熟悉了 4+1 模型,让我们讨论另一个简单但极其有效的模型:C4 模型。我们希望使用它会很有趣(双关语)。
理解 C4 模型
C4 模型非常适合中小型项目。它易于应用,因为它非常简单,不依赖于任何预定义的符号。如果您想要开始使用它进行图表绘制,可以尝试 Tobias Shochguertel 的 c4-draw.io 插件(github.com/tobiashochguertel/c4-draw.io
)用于免费在线绘图工具 draw.io(www.draw.io/
)。
在 C4 模型中,有四种主要类型的图表,即以下:
-
上下文
-
容器
-
组件
-
代码
就像使用地图放大和缩小一样,您可以使用这四种类型来显示特定代码区域的更多细节,或者“缩小”以显示有关特定模块甚至整个系统的更多交互和周围环境的信息。
系统上下文是查看架构的绝佳起点,因为它显示了系统作为一个整体,周围是其用户和与之交互的其他系统。您可以在这里查看一个 C4 上下文图的示例:
图 3.7 - C4 上下文图
正如您所看到的,它显示了“大局”,因此不应该专注于特定的技术或协议。相反,将其视为一张图表,也可以展示给非技术利益相关者。仅仅通过看图,应该清楚地看到有一个参与者(客户的人形描述),他与我们解决方案的一个组件进行交互,即客户服务系统。另一方面,这个系统与另外两个系统进行交互,每个交互都有箭头描述。
我们描述的上下文图用于提供系统的概览,但没有太多细节。现在让我们逐个查看其他图表:
-
容器图:这个图表用于显示系统内部的概览。如果您的系统使用数据库、提供服务,或者只是由某些应用程序组成,这个图表会显示出来。它还可以显示容器的主要技术选择。请注意,容器并不意味着 Docker 容器;尽管每个容器都是一个可以单独运行和部署的单元,但这种图表类型并不涉及部署场景。容器视图是为技术人员准备的,但并不仅限于开发团队。架构师、运维和支持人员也是预期的受众。
-
组件图表:如果您想要了解特定容器的更多细节,那么组件图表就派上用场了。它显示了所选容器内部组件之间的交互,以及与容器外部的元素和参与者的交互。通过查看这个图表,您可以了解每个组件的责任以及它所使用的技术。组件图表的目标受众主要集中在特定容器周围,包括开发团队和架构师。
-
代码图表:最后我们来到了代码图表,当您放大到特定组件时,这些图表就会出现。这个视图主要由 UML 图表组成,包括类、实体关系等,理想情况下应该由独立工具和集成开发环境自动创建。您绝对不应该为系统中的每个组件制作这样的图表;相反,应该专注于为最重要的组件制作图表,以便它们真正告诉读者您想要传达的信息。这意味着在这样的图表中,少即是多,因此您应该省略代码图表中的不必要元素。在许多系统中,特别是较小的系统中,这类图表是被省略的。目标受众与组件图表的情况相同。
您可能会发现 C4 模型缺少一些特定的视图。例如,如果您想知道如何展示系统的部署,那么您可能会对除了主要图表之外的一些补充图表感兴趣。其中之一是部署图,您可以在下面看到。它展示了系统中的容器如何映射到基础设施中的节点。总的来说,这是 UML 部署图的一个简化版本:
图 3.8 - C4 部署图
谈到 C4 模型的 UML 图,您可能会想知道为什么它对呈现系统的用例付出如此少的努力。如果是这种情况,那么您应该考虑用 UML 的用例图或者考虑引入一些序列图来补充前面的模型。
在记录架构时,更重要的是您记录了什么知识和分享了什么,而不是遵循特定的硬性规则。选择最适合您需求的工具。
在敏捷项目中记录架构
在敏捷环境中,您记录架构的方法应该与记录需求的方法类似。首先,考虑谁将阅读您准备的材料,以确保您以正确的方式描述了正确的事物。您的文档不需要是冗长的 Word 文档。您可以使用演示文稿、维基页面、单个图表,甚至是会议记录,当有人描述架构时。
重要的是收集有关记录架构的反馈。同样,与记录需求一样,与利益相关者重复文档是重要的,以了解在哪里改进它们。即使这看起来可能会浪费时间,但如果做得当,它应该能节省您一些交付产品的时间。足够好的文档应该帮助新手更快地开始工作,并引导更熟悉的利益相关者走下去。如果您只是在一些会议上讨论架构,很可能在一个季度后,没有人会记得您为什么做出这些决定,以及它们是否在不断变化的敏捷环境中仍然有效。
在创建文档时,重复是重要的,因为很可能会有一些重要细节的误解。其他时候,您或您的利益相关者会获得更多知识并决定改变事物。在文档被认为成熟和完成之前,准备至少多次审查文档。通常,通过即时通讯、电话或面对面的几次对话将帮助您更快地完成,并解决可能出现的任何后续问题,因此更倾向于这些方式而不是电子邮件或其他异步的沟通方式。
选择正确的视图来记录
架构是一个太复杂的主题,无法用一个大图来描述。想象一下您是一栋建筑的建筑师。为了设计整个建筑,您需要为不同方面制作单独的图表:一个用于管道,另一个用于电力和其他电缆,依此类推。这些图表中的每一个都会展示项目的不同视图。软件架构也是如此:您需要从不同的角度呈现软件,以满足不同的利益相关者。
此外,如果您正在建造一个智能房屋,很可能会绘制一些设备放置的计划。尽管并非所有项目都需要这样的视图,但由于它在您的项目中起着作用,可能值得添加。对于架构也是同样的方法:如果您发现不同的视图对文档有价值,那么您应该这样做。那么,您如何知道哪些视图可能有价值呢?您可以尝试执行以下步骤:
-
从 4+1 模型或 C4 模型开始选择视图。
-
询问您的利益相关者对他们来说什么是必要的,并考虑修改您的视图集。
-
选择能帮助您评估架构是否达到其目标并且所有 ASR 是否满足的视图。阅读下一节中每个视图的第一段,以检查它们是否符合您的需求。
如果您仍然不确定要记录哪些视图,以下是一些提示:
尽量只选择最重要的视图,因为如果太多,架构将变得难以跟踪。一个好的视图集不仅应该展示架构,还应该暴露项目的技术风险。
在选择要在文档中描述的视图时,有一些事情您应该考虑。我们将在这里简要描述它们,但如果您感兴趣,可以查看罗赞斯基和伍兹在进一步阅读部分提到的书籍。
功能视图
如果您的软件是作为更大系统的一部分开发的,特别是与不经常交流的团队,您应该包括一个功能视图(如 4+1 模型)。
文档化架构的一个重要且经常被忽视的方面是定义您提供的接口,尽管这是描述的最重要的事情之一。无论是两个组件之间的接口还是外部世界的入口点,您都应该花时间清楚地记录它,描述对象和调用的语义,以及使用示例(有时可以重复使用为测试)。
在文档中包括功能视图的另一个重要好处是澄清系统各组件之间的责任。开发系统的每个团队都应该了解边界在哪里,以及谁负责开发哪些功能。所有需求都应明确映射到组件,以消除差距和重复工作。
这里需要注意的一点是避免过度加载功能视图。如果变得混乱,没有人会想要阅读它。如果您开始在其中描述基础设施,请考虑添加部署视图。如果您的模型中出现了“上帝对象”,请尝试重新思考设计并将其拆分为更小、更具凝聚力的部分。
关于功能视图的最后一个重要说明是,尽量保持每个包含的图表在一个抽象级别上。另一方面,不要选择过于抽象的级别使其变得太模糊;确保每个元素都得到了相关方的明确定义和理解。
信息视图
如果您的系统在信息、处理流程、管理流程或存储方面有非直接的需求,也许包括这种视图是个好主意。
选择最重要的、数据丰富的实体,并展示它们如何在系统中流动,谁拥有它们,生产者和消费者是谁。标记某些数据保持“新鲜”的时间以及何时可以安全丢弃,预期到达系统某些点的延迟是多少,或者在分布式环境中如何处理标识符可能会有用。如果您的系统管理交易,这个过程以及任何回滚也应该清晰地展示给利益相关者。转换、发送和持久化数据的技术对其中一些人也可能很重要。如果您在金融领域工作或者必须处理个人数据,您很可能必须遵守一些法规,因此描述您的系统计划如何应对这些是很重要的。
您的数据结构可以使用 UML 类模型进行图表化。请记住要清楚地描述您的数据格式,特别是如果它在两个不同的系统之间流动。美国宇航局与洛克希德·马丁合作开发的火星气候轨道飞行器价值 1.25 亿美元的失误,就是因为他们无意中使用了不同的单位,因此要注意系统之间的数据不一致性。
您的数据处理流程可以使用 UML 的活动模型,并且可以使用状态图来展示信息的生命周期。
并发视图
如果运行许多并发执行单元是产品的一个重要方面,请考虑添加并发视图。它可以显示您可能遇到的问题和瓶颈(除非听起来太详细)。包括它的其他好理由是依赖进程间通信,具有非直观的任务结构,并发状态管理,同步或任务失败处理逻辑。
对于这个视图,您可以使用任何您想要的符号,只要它能捕捉到执行单元及其通信。如果需要,为您的进程和线程分配优先级,然后分析任何潜在的问题,比如死锁或争用。您可以使用状态图来显示重要执行单元的可能状态及其转换(等待查询、执行查询、分发结果等)。
如果您不确定是否需要向系统引入并发,一个好的经验法则是不要。如果必须引入,并且力求简单的设计。调试并发问题从来都不容易,而且总是很耗时,所以如果可能的话,尝试优化您现有的东西,而不是仅仅向问题投入更多的线程。
如果通过查看您的图表,您担心资源争用,请尝试用更多、但更细粒度的锁替换大对象上的锁,使用轻量级同步(有时原子操作就足够了),引入乐观锁定,或减少共享的内容(在线程中创建一份数据的额外副本并处理它可能比共享对唯一副本的访问更快)。
开发视图
如果您正在构建一个具有许多模块的大型系统,并且需要结构化您的代码,具有系统范围的设计约束,或者如果您希望在系统的各个部分之间共享一些共同的方面,那么从开发的角度呈现解决方案应该对您有益,以及软件开发人员和测试人员。
开发视图的包图可以很方便地显示系统中不同模块的位置,它们的依赖关系以及其他相关模块(例如,位于相同的软件层)。它不需要是 UML 图表 - 即使是方框和线条也可以。如果您计划将一个模块替换掉,这种类型的图表可以显示哪些其他软件包可能会受到影响。
增加系统中重用的策略,例如为组件创建自己的运行时框架,或者增加系统的一致性的策略,例如对认证、日志记录、国际化或其他类型的处理采用共同的方法,都属于开发视图的一部分。如果您发现系统中有共同的部分,请确保所有开发人员也能看到它们。
代码组织、构建和配置管理的常见方法也应该包括在文档的这一部分。如果所有这些听起来都需要大量文档记录,那么重点关注最重要的部分,如果可能的话,简要涵盖其余部分。
部署和运行视图
如果您有一个非标准或复杂的部署环境,例如硬件、第三方软件或网络需求方面的特定需求,请考虑在一个单独的部署部分中记录它,针对系统管理员、开发人员和测试人员。
如果必要,涵盖以下内容:
-
所需的内存量
-
CPU 线程数(是否启用超线程)
-
关于 NUMA 节点的固定和亲和性
-
专业的网络设备要求,例如标记数据包以以黑盒方式测量延迟和吞吐量的交换机
-
网络拓扑
-
所需的估计带宽
-
应用程序的存储需求
-
您计划使用的任何第三方软件
一旦您有了需求,您可以将它们映射到特定的硬件,并将其放入运行时平台模型中。如果您需要正式建模,可以使用 UML 部署图与构造型。这应该显示您的处理节点和客户节点、在线和离线存储、网络链接、专用硬件(如防火墙或 FPGA 或 ASIC 设备)以及功能元素与它们将在其上运行的节点之间的映射。
如果您有非直接的网络需求,可以添加另一个图表,显示网络节点和它们之间的连接。
如果您依赖于特定的技术(包括特定版本的软件),列出它们是个好主意,以查看您使用的软件之间是否存在兼容性问题。有时,两个第三方组件将需要相同的依赖项,但是版本不同。
如果您在脑海中有一个特定的安装和升级计划,写几句话可能是个好主意。诸如 A/B 测试、蓝绿部署或者您的解决方案将依赖的任何特定容器技术都应该对所有相关人员清晰可见。如果需要,还应该包括数据迁移计划,包括迁移可能需要多长时间以及何时可以安排迁移。
任何配置管理、性能监控、运行监控和控制以及备份策略的计划都值得描述。您可能希望创建几个组,识别每个组的依赖关系,并为每个组定义方法。如果您能想到可能发生的任何错误,应该有一个检测和恢复计划。
一些支持团队的注意事项也可以放在这一部分:哪些利益相关者组需要支持,您计划拥有哪些类别的事件,如何升级,以及每个支持级别将负责什么。
最好尽早与运营人员进行沟通,并为他们专门创建图表,以保持他们的参与。
现在我们已经讨论了如何手动创建关于您的系统及其需求的文档,让我们转而以自动化的方式记录您的 API。
生成文档
作为工程师,我们不喜欢手工劳动。这就是为什么,如果某些事情可以自动化并节省我们的工作,它很可能会被实现。在努力创建足够好的文档的过程中,至少部分工作可以自动化,这实际上可能是一种幸福。
生成需求文档
如果您从零开始创建一个项目,很难凭空生成文档。然而,有时候,如果您只有适当的工具和需求,生成文档是可能的。例如,如果您使用 JIRA,一个起点就是从问题导航器视图中导出所有项目。您可以使用任何您喜欢的过滤器,并为这些项目获取打印输出。如果您不喜欢默认的字段集,或者觉得这不是您要找的东西,您可以尝试使用 JIRA 的需求管理插件之一。它们不仅允许您导出需求,例如R4J(Jira 的需求)还允许您创建整个需求层次结构,跟踪它们,管理变更并将其传播到整个项目中,执行任何需求变更的影响分析,当然,还可以使用用户定义的模板进行导出。许多这样的工具还可以帮助您创建与您的需求相关的测试套件,但我们看到的没有一个是免费的。
从代码生成图表
如果您想了解代码结构,但又不想深入研究源代码,您可能会对从代码生成图表的工具感兴趣。
其中一个工具是 CppDepend。它使你能够在源代码的不同部分之间创建各种依赖关系图。更重要的是,它允许你根据各种参数查询和过滤代码。无论你是想了解代码的结构,发现不同软件组件之间的依赖关系以及它们之间的紧密程度,还是想快速定位具有最多技术债务的部分,你可能会对这个工具感兴趣。它是专有的,但提供了一个完全功能的试用版。
一些绘图工具允许你从类图中创建代码,并从代码中创建类图。Enterprise Architect 可以让你从类和接口图生成多种语言的代码。C++就是其中之一,并允许直接从源代码生成 UML 类图。另一个可以做到这一点的工具是 Visual Paradigm。
从代码生成(API)文档
为了帮助其他人浏览你现有的代码并使用你提供的 API,一个好主意是提供从代码注释中生成的文档。没有比将这样的文档放在描述函数和数据类型的旁边更好的地方了,这在保持它们同步方面有很大帮助。
用于编写这种文档的事实上的标准工具是 Doxygen。它的优点是它很快(特别是对于大型项目和 HTML 文档生成),生成器具有一些内置的正确性检查(例如对函数中部分记录的参数的检查 - 这是检查文档是否仍然是最新的一个好标记),并允许导航类和文件层次结构。它的缺点包括不能进行全文搜索,PDF 生成不够理想,以及一些人可能觉得繁琐的界面。
幸运的是,这些可用性缺陷可以通过使用另一个流行的文档工具来补救。如果你曾经阅读过任何 Python 文档,你可能会遇到 Sphinx。它具有清新的界面和可用性,并使用 reStructuredText 作为标记语言。好消息是这两者之间有一个桥梁,所以你可以使用 Doxygen 生成的 XML 在 Sphinx 中使用。这个桥接软件叫做 Breathe。
现在让我们看看如何在你的项目中设置它。假设我们将源代码保存在src
中,公共头文件保存在include
中,文档保存在doc
中。首先,让我们创建一个CMakeLists.txt
文件:
cmake_minimum_required(VERSION 3.10)
project("Breathe Demo" VERSION 0.0.1 LANGUAGES CXX)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake")
add_subdirectory(src)
add_subdirectory(doc)
我们已经对我们的项目支持的 CMake 版本设置了要求,指定了它的名称、版本和使用的语言(在我们的情况下,只是 C++),并将cmake
目录添加到 CMake 查找其包含文件的路径下。
在cmake
子目录中,我们将创建一个名为FindSphinx.cmake
的文件,我们将按照名称使用它,因为 Sphinx 并没有提供这样的文件:
find_program(
SPHINX_EXECUTABLE
NAMES sphinx-build
DOC "Path to sphinx-build executable")
# handle REQUIRED and QUIET arguments, set SPHINX_FOUND variable
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(
Sphinx "Unable to locate sphinx-build executable" SPHINX_EXECUTABLE)
现在,CMake 将寻找我们的 Sphinx 构建工具,如果找到,将设置适当的 CMake 变量来标记 Sphinx 包已找到。接下来,让我们创建我们的源代码来生成文档。让我们有一个include/breathe_demo/demo.h
文件:
#pragma once
// the @file annotation is needed for Doxygen to document the free
// functions in this file
/**
* @file
* @brief The main entry points of our demo
*/
/**
* A unit of performable work
*/
struct Payload {
/**
* The actual amount of work to perform
*/
int amount;
};
/**
@brief Performs really important work
@param payload the descriptor of work to be performed
*/
void perform_work(struct Payload payload);
注意注释语法。Doxygen 在解析我们的头文件时会识别它,以便知道在生成的文档中放入什么。
现在,让我们为我们的头文件添加一个相应的src/demo.cpp
实现:
#include "breathe_demo/demo.h"
#include <chrono>
#include <thread>
void perform_work(Payload payload) {
std::this_thread::sleep_for(std::chrono::seconds(payload.amount));
}
这里没有 Doxygen 注释。我们更喜欢在头文件中记录我们的类型和函数,因为它们是我们库的接口。源文件只是实现,它们对接口没有任何新的东西。
除了前面的文件,我们还需要在src
中添加一个简单的CMakeLists.txt
文件:
add_library(BreatheDemo demo.cpp)
target_include_directories(BreatheDemo PUBLIC
${PROJECT_SOURCE_DIR}/include)
target_compile_features(BreatheDemo PUBLIC cxx_std_11)
在这里,我们为我们的目标指定了源文件,为它指定了头文件目录,并指定了编译所需的 C++标准。
现在,让我们转到doc
文件夹,这里是魔法发生的地方;首先是它的CMakeLists.txt
文件,从检查是否有 Doxygen 可用并在这种情况下省略生成开始:
find_package(Doxygen)
if (NOT DOXYGEN_FOUND)
return()
endif()
如果 Doxygen 没有安装,我们将跳过文档生成。还要注意return()
调用,它将退出当前的 CMake 列表文件,这是一个不太广为人知,但仍然有用的技巧。
接下来,假设找到了 Doxygen,我们需要设置一些变量来引导生成。我们只想要 Breathe 的 XML 输出,所以让我们设置以下变量:
set(DOXYGEN_GENERATE_HTML NO)
set(DOXYGEN_GENERATE_XML YES)
为了强制使用相对路径,使用set(DOXYGEN_STRIP_FROM_PATH ${PROJECT_SOURCE_DIR}/include)
。如果你有任何要隐藏的实现细节,你可以使用set(DOXYGEN_EXCLUDE_PATTERNS "*/detail/*")
。好了,既然所有变量都设置好了,现在让我们生成:
# Note: Use doxygen_add_docs(doxygen-doc ALL ...) if you want your
# documentation to be created by default each time you build. Without the # keyword you need to explicitly invoke building of the 'doc' target.
doxygen_add_docs(doxygen-doc ${PROJECT_SOURCE_DIR}/include COMMENT
"Generating API documentation with Doxygen")
在这里,我们调用了一个专门用于使用 Doxygen 的 CMake 函数。我们定义了一个目标,doxygen-doc
,我们需要明确调用它以根据需要生成我们的文档,就像注释中所说的那样。
现在我们需要创建一个 Breathe 目标来消耗我们从 Doxygen 得到的东西。我们可以使用我们的FindSphinx
模块来实现这一点:
find_package(Sphinx REQUIRED)
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/conf.py.in
${CMAKE_CURRENT_BINARY_DIR}/conf.py @ONLY)
add_custom_target(
sphinx-doc ALL
COMMAND ${SPHINX_EXECUTABLE} -b html -c ${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}
WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}
COMMENT "Generating API documentation with Sphinx"
VERBATIM)
首先,我们调用我们的模块。然后,我们用我们项目的变量填写一个 Python 配置文件,供 Sphinx 使用。我们创建一个sphinx-doc
目标,它将生成 HTML 文件作为其输出,并在构建输出时打印一行。
最后,让我们强制 CMake 在每次生成 Sphinx 文档时调用 Doxygen:add_dependencies(sphinx-doc doxygen-doc)
。
如果你希望有更多的文档目标,引入一些 CMake 函数来处理与文档相关的目标可能会很有用。
现在让我们看看我们的conf.py.in
文件中有什么,用于引导我们的猫工具。让我们创建它,并让它指向 Sphinx 到 Breathe:
extensions = [ "breathe", "m2r2" ]
breathe_projects = { "BreatheDemo": "@CMAKE_CURRENT_BINARY_DIR@/xml" }
breathe_default_project = "BreatheDemo"
project = "Breathe Demo"
author = "Breathe Demo Authors"
copyright = "2021, Breathe Demo Authors"
version = "@PROJECT_VERSION@"
release = "@PROJECT_VERSION@
html_theme = 'sphinx_rtd_theme'
正如前面的列表所示,我们设置了 Sphinx 要使用的扩展名,文档化项目的名称以及其他一些相关变量。注意@NOTATION@
,它被 CMake 用来用适当的 CMake 变量的值填充输出文件。最后,我们告诉 Sphinx 使用我们的 ReadTheDocs 主题(sphinx_rtd_theme
)。
拼图的最后一块是 reStructuredText 文件,它们定义了文档中包含什么。首先,让我们创建一个index.rst
文件,其中包含目录和一些链接:
Breathe Demo
============
Welcome to the Breathe Demo documentation!
.. toctree::
:maxdepth: 2
:caption: Contents:
Introduction <self>
readme
api_reference
第一个链接指向这个页面,所以我们可以从其他页面返回到它。我们将显示Introduction
作为标签。其他名称指向具有.rst
扩展名的其他文件。由于我们包含了 M2R2 Sphinx 扩展,我们可以在文档中包含我们的README.md
文件,这可以节省一些重复。readme.rst
文件的内容只是.. mdinclude:: ../README.md
。现在是最后一部分:合并 Doxygen 的输出。这是在api_reference.rst
文件中使用以下命令完成的:
API Reference
=============
.. doxygenindex::
因此,我们只是按照自己的喜好命名了参考页面,并指定了 Doxygen 生成的文档应该在这里列出,就是这样!只需构建sphinx-doc
目标,你就会得到一个看起来像这样的页面:
图 3.9 - 我们文档的主页,整合了生成的部分和手动编写的部分
当我们查看 API 文档页面时,它应该是这样的:
图 3.10 - 自动生成的 API 文档
正如你所看到的,文档已经自动生成了我们的Payload
类型及其每个成员,以及自由的perform_work
函数,包括每个参数,并根据定义它们的文件进行了分组。整洁!
摘要
在这一章中,您了解了有关需求和文档的所有基本知识。您学会了如何成功收集需求以及如何识别最重要的需求。您现在可以准备精简而有用的文档,以一种面向视图的方式只展示重要内容。您能够区分不同类型和风格的图表,并使用最适合您需求的那种。最后,但同样重要的是,您现在能够自动生成美观的文档。
在下一章中,您将了解有用的架构设计模式,这将帮助您满足系统的需求。我们将讨论各种模式以及如何应用它们来提供许多重要的质量属性,无论是在分布式系统中的单个组件规模上。
问题
-
什么是质量属性?
-
在收集需求时应使用哪些来源?
-
您如何判断一个需求是否具有架构上的重要性?
-
开发视图文档在什么时候有用?
-
您如何自动检查您的代码的 API 文档是否过时?
-
您如何在图表上指示给定的过程是由系统的不同组件处理的?
进一步阅读
-
使用 ATAM 评估软件架构,JC Olamendy,博客文章:
johnolamendy.wordpress.com/2011/08/12/evaluate-the-software-architecture-using-atam/
-
EARS:需求语法的简易方法,John Terzakis,英特尔公司,来自 ICCGI 会议的会议演讲:
www.iaria.org/conferences2013/filesICCGI13/ICCGI_2013_Tutorial_Terzakis.pdf
-
Eoin Woods 和 Nick Rozanski,软件系统架构:使用观点和透视与利益相关者合作
第二部分:C++软件的设计和开发
本节介绍了使用 C++创建有效软件解决方案的技术。它演示了解决常见挑战和避免在设计、开发和构建 C++代码时的陷阱的技术。这些技术来自 C++语言本身,以及设计模式、工具和构建系统。
本节包括以下章节:
-
第四章,架构和系统设计
-
第五章,利用 C++语言特性
-
第六章,设计模式和 C++
-
第七章,构建和打包
第四章:架构和系统设计
模式帮助我们处理复杂性。在单个软件组件的级别上,您可以使用软件模式,例如由该书的四位作者描述的模式(更为人所知的是四人帮)设计模式:可重用面向对象软件的元素。当我们向上移动并开始查看不同组件之间的架构时,知道何时以及如何应用架构模式可以大有裨益。
有无数这样的模式适用于不同的场景。实际上,要了解所有这些模式,您需要阅读不止一本书。话虽如此,我们为本书选择了几种模式,适用于实现各种架构目标。
在本章中,我们将向您介绍与架构设计相关的一些概念和谬论;我们将展示何时使用上述模式以及如何设计易于部署的高质量组件。
本章将涵盖以下主题:
-
不同的服务模型以及何时使用它们
-
如何避免分布式计算的谬论
-
CAP 定理的结果以及如何实现最终一致性
-
使您的系统具有容错性和可用性
-
集成您的系统
-
实现规模的性能
-
部署您的系统
-
管理您的 API
通过本章结束时,您将了解如何设计您的架构以提供几个重要的特性,例如容错性、可伸缩性和可部署性。在那之前,让我们首先了解分布式架构的两个固有方面。
技术要求
本章的代码需要以下工具来构建和运行:
-
Docker
-
Docker Compose
本章的源代码片段可以在github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter04
找到。
了解分布式系统的特殊性
有许多不同类型的软件系统,每种系统都适用于不同的场景,为不同的需求构建,并使用不同的假设集。编写和部署经典的独立桌面应用程序与编写和部署需要通过网络与许多其他应用程序通信的微服务完全不同。
在本节中,我们将介绍您可以用来部署软件的各种模型,人们在创建分布式系统时应避免的常见错误,以及人们需要做出的一些妥协,以成功创建这样的系统。
不同的服务模型以及何时使用它们
让我们首先从服务模型开始。在设计一个更大的系统时,您需要决定您将管理多少基础设施,而不是您可以建立在现有构建块之上。有时,您可能希望利用现有软件,而无需手动部署应用程序或备份数据,例如,通过其 API 使用 Google Drive 作为应用程序的存储。其他时候,您可以依赖于现有的云平台,例如 Google 的 App Engine,以部署您的解决方案,而无需担心提供语言运行时或数据库。如果您可以决定以自己的方式部署所有内容,您可以利用云提供商的基础设施,或者使用您公司的基础设施。
让我们讨论不同的模型以及每种模型在哪里可以有用。
本地模型
经典的方式,也是在云之前唯一可用的方式,就是在自己的场地部署一切。您需要购买所有所需的硬件和软件,并确保它能够满足您的需求。如果您在一家初创公司工作,这可能是一个很大的前期成本。随着用户群的增长,您需要购买和设置更多的资源,以便您的服务甚至可以处理偶尔的负载峰值。所有这些意味着您需要预测解决方案的增长并积极行动,因为没有办法根据当前负载自动扩展。
即使在云时代,本地部署仍然是有用的,并且经常在实际中发现。有时您处理的数据不应该,甚至不能离开公司的场地,要么是由于数据隐私问题,要么是合规问题。其他时候,您需要尽可能少的延迟,并且您需要自己的数据中心来实现。有时您可能会计算成本并决定在您的情况下,本地部署将比云解决方案更便宜。最后,但同样重要的是,您的公司可能已经拥有现有的数据中心,您可以使用。
在本地部署并不意味着您需要拥有一个单体系统。通常,公司在本地部署自己的私有云。这有助于通过更好地利用可用基础设施来降低成本。您还可以将私有云解决方案与其他服务模型相结合,这在您需要不时的额外容量时可能很有用。这被称为混合部署,并且所有主要的云提供商都提供了这种服务,同时 OpenStack 的 Omni 项目也提供了这种服务。
基础设施即服务(IaaS)模型
说到其他模型,最基本的云服务模型被称为基础设施即服务(IaaS)。它也与本地部署最相似:你可以将 IaaS 看作是虚拟数据中心的一种方式。正如其名称所示,云提供商为您提供了他们托管的基础设施的一部分,其中包括三种类型的资源:
-
计算,例如虚拟机、容器或裸金属机器(不包括操作系统)
-
网络,除了网络本身外,还包括 DNS 服务器、路由和防火墙
-
存储,包括备份和恢复功能
您仍然需要提供所有软件:操作系统、中间件和您的应用程序。
IaaS 可用于从托管网站(可能比传统的网站托管更便宜)到存储(例如,亚马逊的 S3 和 Glacier 服务),再到高性能计算和大数据分析(需要大量计算能力)的场景。一些公司使用它来快速建立和清除测试和开发环境。
使用 IaaS 而不是本地基础设施可以是测试新想法的廉价方式,同时节省配置所需的时间。
如果您的服务观察到使用量的增加,例如在周末,您可能希望利用云的自动扩展能力:在需要时扩展,稍后缩小规模以节省资金。
所有流行的云服务提供商都提供 IaaS 解决方案。
一个类似的概念,有时被认为是 IaaS 的子集,是容器即服务(CaaS)。在 CaaS 中,服务不是提供裸金属系统和虚拟机,而是提供容器和编排功能,您可以使用它们构建自己的容器集群。CaaS 的服务可以在谷歌云平台和 AWS 等地方找到。
平台即服务(PaaS)模型
如果基础设施本身不足以满足您的需求,您可以使用平台即服务(PaaS)模型。在这种模型中,云服务提供商不仅管理基础设施(就像在 IaaS 中一样),还管理操作系统、任何所需的中间件和运行时 - 您将在其上部署软件的平台。
通常,PaaS 解决方案将为您提供应用程序版本控制功能、服务监控和发现、数据库管理、业务智能,甚至开发工具。
使用 PaaS,您可以在整个开发流程中得到覆盖:从构建和测试到部署、更新和管理您的服务。然而,PaaS 解决方案比 IaaS 提供的更昂贵。另一方面,由于整个平台已经提供,您可以削减成本和时间来开发软件的部分,并且可以轻松为遍布全球的开发团队提供相同的设置。
所有主要的云提供商都有自己的产品,例如 Google App Engine 或 Azure App Service。还有一些独立的产品,比如 Heroku。
除了更通用的 PaaS 之外,还有通信平台即服务(CPaaS),其中您将获得整个通信后端,包括音频和视频,您可以将其集成到您的解决方案中。这项技术使您能够轻松提供支持视频的帮助台,或者只是将实时聊天集成到您的应用程序中。
软件即服务(SaaS)模型
有时,您可能不想自己开发软件组件,只想使用现有的组件。软件即服务(SaaS)基本上为您提供了托管的应用程序。使用 SaaS,您无需担心基础架构或基于其上构建的平台,甚至不用担心软件本身。提供者负责安装、运行、更新和维护整个软件堆栈,以及备份、许可和扩展。
在 SaaS 模型中,您可以获得各种各样的软件。示例包括办公套件,如 Office 365 和 Google Docs,以及消息软件,如 Slack,还有客户关系管理(CRM)系统,甚至涵盖云游戏服务,允许您在云上玩耗费大量资源的视频游戏。
通常,要访问这些服务,您只需要一个浏览器,因此这可以是为您的员工提供远程工作能力的重要一步。
您可以创建自己的 SaaS 应用程序,并通过部署它们的方式或通过 AWS Marketplace 等方式向用户提供。
**函数即服务(FaaS)模型和无服务器架构
随着云原生的出现,另一种日益流行的模型是函数即服务(FaaS)。如果您想实现无服务器架构,这可能会有所帮助。使用 FaaS,您可以获得一个平台(类似于 PaaS),在该平台上可以运行短暂的应用程序或函数。
使用 PaaS,通常您始终需要至少运行一个实例的服务,而在 FaaS 中,您只能在实际需要时运行它们。运行您的函数可能会使处理请求的时间变长(以秒为单位;毕竟您需要启动函数)。然而,一些请求可以被缓存以减少延迟和成本。说到成本,如果您长时间运行函数,FaaS 可能会比 PaaS 更昂贵,因此在设计系统时必须进行计算。
如果使用正确,FaaS 可以将服务器从开发人员那里抽象出来,可以降低成本,并且可以提供更好的可伸缩性,因为它可以基于事件而不是资源。这种模型通常用于运行预定或手动触发的任务,处理批处理或数据流,以及处理不太紧急的请求。一些流行的 FaaS 提供者包括 AWS Lambda、Azure Functions 和 Google Cloud Functions。
现在我们已经涵盖了云中常见的服务模型,让我们讨论一些人们在设计分布式系统时常犯的错误假设。
避免分布式计算的谬论
当刚接触分布式计算的人开始设计这样的系统时,他们往往会忘记或忽视这些系统的一些方面。尽管这些问题在 90 年代就被注意到,但它们至今仍然存在。
这些谬误将在以下子部分中讨论。让我们快速浏览一下每个谬误。
网络是可靠的
网络设备设计用于长时间无故障运行。尽管如此,许多事情仍然可能导致数据包丢失,从停电到无线网络信号差,配置错误,有人绊倒电缆,甚至动物咬断电线。例如,谷歌不得不用凯夫拉保护他们的水下电缆,因为它们被鲨鱼咬断(是的,真的)。您应该始终假设数据可能在网络中的某个地方丢失。即使这种情况没有发生,软件问题仍可能发生在电线的另一端。
为了抵御这些问题,请确保您有一个自动重试失败的网络请求的策略,并且有一种处理常见网络问题的方法。在重试时,尽量不要过载对方,并且不要多次提交相同的事务。您可以使用消息队列来存储和重试发送。
诸如断路器之类的模式,我们稍后将在本章中展示,也可以帮助。哦,并且请确保不要无限等待,每次失败的请求都会占用资源。
延迟为零
网络和您运行的服务在正常情况下都需要一些时间来响应。偶尔它们可能需要更长的时间,特别是在承受比平均负载更大的情况下。有时,您的请求完成可能需要几秒钟,而不是几毫秒。
尽量设计系统,使其不要等待太多细粒度的远程调用,因为每个这样的调用都会增加总处理时间。即使在本地网络中,1 条记录的 10,000 个请求也比 10,000 条记录的 1 个请求慢得多。为了减少网络延迟,考虑批量发送和处理请求。您还可以尝试在等待其结果时执行其他处理任务,以隐藏小调用的成本。
处理延迟的其他方法是引入缓存,以发布者-订阅者模型推送数据,而不是等待请求,或者部署更接近客户,例如使用内容传送网络(CDN)。
带宽是无限的
在向架构中添加新服务时,请确保注意它将使用多少流量。有时,您可能希望通过压缩数据或引入限流策略来减少带宽。
这个谬误也与移动设备有关。如果信号弱,网络通常会成为瓶颈。这意味着移动应用程序使用的数据量通常应保持较低。使用本章中描述的“用于前端的后端”模式,通常可以帮助节省宝贵的带宽。
如果您的后端需要在某些组件之间传输大量数据,请确保这些组件彼此靠近:不要将它们运行在不同的数据中心。对于数据库来说,这通常归结为更好的复制。诸如 CQRS(本章后面将讨论)之类的模式也很有用。
网络是安全的
这是一个危险的谬误。一条链只有其最薄弱的一环那么强大,不幸的是,分布式系统中有许多环节。以下是使这些环节更强大的一些方法:
-
请务必始终对您使用的每个组件,您的基础设施,操作系统和其他组件应用安全补丁。
-
培训您的人员,并尽量保护系统免受人为因素的影响;有时候是一个流氓员工危害了系统。
-
如果您的系统在线,它将受到攻击,并且可能会发生一次违规事件。请确保制定如何应对此类事件的书面计划。
-
你可能听说过深度防御原则。它归结为对系统的不同部分(基础设施、应用程序等)进行不同的检查,以便在发生违规时,其范围和相关损害将受到限制。
-
使用防火墙、证书、加密和适当的身份验证。
有关安全性的更多信息,请参阅第十章,代码和部署中的安全性。
拓扑结构不会改变
这在微服务时代尤为真实。自动扩展和管理基础设施的牛群而非宠物方法的出现意味着拓扑结构将不断变化。这可能会影响延迟和带宽,因此这种谬误的一些结果与前面描述的结果相同。
幸运的是,上述方法还附带了如何有效管理服务器群的指南。依赖主机名和 DNS 而不是硬编码 IP 是朝着正确方向迈出的一步,而服务发现,本书后面描述的另一步。第三步,甚至更大的一步,是始终假设你的实例可能会失败并自动应对这种情况。Netflix 的混沌猴工具也可以帮助你测试你的准备情况。
有一个管理员
由于分布式系统的性质,对分布式系统的知识通常也是分布的。不同的人负责开发、配置、部署和管理这些系统及其基础设施。不同的组件通常由不同的人升级,不一定同步进行。还有所谓的公交因素,简而言之,就是关键项目成员被公交车撞击的风险因素。
我们如何应对所有这些?答案包括几个部分。其中之一是 DevOps 文化。通过促进开发和运营之间的紧密合作,人们分享关于系统的知识,从而减少公交因素。引入持续交付可以帮助升级项目并使其始终保持更新。
尽量将系统模块化并且向后兼容,这样组件的升级不需要其他组件也进行升级。一种简单的解耦方法是在它们之间引入消息传递,因此考虑添加一个或两个队列。这也将有助于在升级期间减少停机时间。
最后,尽量监控系统并将日志收集到一个集中的位置。系统的去中心化不应意味着你现在需要手动查看十几台不同的机器上的日志。ELK(Elasticsearch,Logstash,Kibana)堆栈对此非常宝贵。Grafana、Prometheus、Loki 和 Jaeger 也非常受欢迎,特别是在 Kubernetes 中。如果你正在寻找比 Logstash 更轻量级的东西,可以考虑 Fluentd 和 Filebeat,特别是如果你正在处理容器。
传输成本为零
这种谬误对规划项目和预算很重要。为分布式系统构建和维护网络无论是在本地部署还是在云端部署,都需要时间和金钱成本,只是支付成本的时间不同。尽量估算设备成本、数据传输成本(云提供商会收费)和所需的人力成本。
如果你依赖压缩,请注意,虽然这可以减少网络成本,但可能会增加计算成本。一般来说,使用基于 gRPC 的二进制 API 会比基于 JSON 的 API 更便宜(而且更快),而这些又比 XML 更便宜。如果你发送图像、音频或视频,估算一下这将给你带来多少成本是必要的。
网络是同质的
即使您计划在网络上运行什么硬件和软件,也很容易最终出现至少一些异构性。一些机器上略有不同的配置,需要与之集成的传统系统使用不同的通信协议,或者不同的手机向您的系统发送请求,这些都是其中的一些例子。另一个例子是通过使用云中的额外工作人员来扩展您的本地解决方案。
尽量限制使用的协议和格式的数量,努力使用标准的协议,并避免供应商锁定,以确保您的系统在这种异构环境中仍然可以正常通信。异构性也可能意味着弹性上的差异。尝试使用断路器模式以及重试来处理这个问题。
现在我们已经讨论了所有的谬误,让我们讨论一下分布式架构的另一个非常重要的方面。
CAP 定理和最终一致性
要设计成功的跨多个节点的系统,您需要了解并使用某些原则。其中之一就是CAP 定理。它涉及到设计分布式系统时需要做出的最重要选择之一,并且得名于分布式系统可以具有的三个属性。它们如下:
-
一致性:每次读取都会得到最近一次写入之后的数据(或错误)。
-
可用性:每个请求都将获得非错误响应(但不能保证您将获得最新数据)。
-
分区容忍性:即使两个节点之间发生网络故障,整个系统仍将继续工作。
实质上,该定理表明您可以选择分布式系统的这三个属性中的最多两个。
只要系统正常运行,看起来所有三个属性都可以得到满足。然而,正如我们从谬误中所知道的,网络是不可靠的,因此会发生分区。在这种情况下,分布式系统应该仍然可以正常运行。这意味着定理实际上让您在提供分区容忍性和一致性(即 CP)之间做出选择,或者提供分区容忍性和可用性(即 AP)之间做出选择。通常,后者是更好的选择。如果您想选择 CA,您必须完全删除网络,并留下一个单节点系统。
如果在分区下,您决定提供一致性,您将不得不返回错误或者在等待数据一致性时冒风险超时。如果您选择可用性而不是一致性,您将面临返回陈旧数据的风险-最新的写入可能无法在分区间传播。
这两种方法都适用于不同的需求。例如,如果您的系统需要原子读写,因为客户可能会丢失他们的钱,那么选择 CP。如果您的系统必须在分区下继续运行,或者可以允许最终一致性,那么选择 AP。
好吧,但是什么是最终一致性?让我们讨论一下不同的一致性级别来理解这一点。
在提供强一致性的系统中,每次写入都是同步传播的。这意味着所有读取将始终看到最新的写入,即使以更高的延迟或更低的可用性为代价。这是关系型数据库管理系统提供的类型(基于 ACID 保证),最适合需要事务的系统。
另一方面,在提供最终一致性的系统中,您只保证在写入后,读取最终会看到更改。通常,“最终”意味着在几毫秒内。这是由于这些系统中数据复制的异步性质,与前一段中的同步传播相反。与提供 ACID 保证的关系型数据库管理系统不同,这里我们有 BASE 语义,通常由 NoSQL 数据库提供。
对于异步和最终一致的系统(通常是 AP 系统),需要有一种解决状态冲突的方法。解决这个问题的常见方法是在实例之间交换更新,并选择第一个或最后一个写入作为接受的写入。
现在让我们讨论两种相关模式,可以帮助实现最终一致性。
Sagas 和补偿事务
当您需要执行分布式事务时,saga 模式非常有用。
在微服务时代之前,如果您有一个主机和一个数据库,您可以依赖数据库引擎为您执行事务。在一个主机上有多个数据库时,您可以使用两阶段提交(2PCs)来执行。使用 2PCs,您将有一个协调者,它首先会告诉所有数据库准备好,一旦它们都报告准备就绪,它会告诉它们所有提交事务。
现在,由于每个微服务可能都有自己的数据库(如果您想要可伸缩性,它应该有),并且它们遍布在整个基础设施上,您不能再依赖简单的事务和 2PCs(失去这种能力通常意味着您不再需要关系型数据库,因为 NoSQL 数据库可能会快得多)。
相反,您可以使用 saga 模式。让我们通过一个例子来演示。
想象一下,您想创建一个在线仓库,跟踪其供应量并允许使用信用卡付款。为了处理订单,除了其他服务,您需要三个:一个用于处理订单,一个用于预留供应,一个用于刷卡。
现在,saga 模式可以通过两种方式实现:编排式(也称为基于事件)和指挥式(也称为基于命令)。
基于编排的 saga
在第一种情况下,saga 的第一部分将是订单处理服务向供应服务发送一个事件。供应服务将完成其部分并向支付服务发送另一个事件。然后支付服务将向订单服务发送另一个事件。这将完成事务(saga),订单现在可以愉快地发货了。
如果订单服务想要跟踪事务的状态,它只需要监听所有这些事件即可。
当然,有时订单可能无法完成,需要进行回滚。在这种情况下,saga 的每个步骤都需要单独和谨慎地回滚,因为其他事务可能会并行运行,例如修改供应状态。这样的回滚被称为补偿事务。
实现 saga 模式的这种方式非常直接,但如果涉及服务之间有很多依赖关系,最好使用编排方法。说到这一点,现在让我们谈谈 saga 的第二种方法。
基于编排的 saga
在这种情况下,我们将需要一个消息代理来处理我们服务之间的通信,并且需要一个编排者来协调 saga。我们的订单服务将向编排者发送一个请求,然后编排者将向供应和支付服务发送命令。每个服务然后会完成其部分并通过代理的回复通道将回复发送回编排者。
在这种情况下,编排者拥有所有必要的逻辑来,嗯,编排事务,而服务本身不需要知道参与 saga 的任何其他服务。
如果编排者收到消息表明其中一个服务失败,例如,如果信用卡已过期,那么它将需要开始回滚。在我们的情况下,它将再次使用代理向特定服务发送适当的回滚命令。
好了,现在关于最终一致性的内容就说到这里。现在让我们转到与可用性相关的其他主题。
使系统容错和可用
可用性和容错能力是每种架构至少有些重要的软件质量。如果系统无法访问,那么创建软件系统有什么意义呢?在本节中,我们将了解这些术语的确切含义以及提供解决方案的一些技术。
计算系统的可用性
可用性是系统正常运行、功能正常且可访问的时间百分比。崩溃、网络故障或极高的负载(例如来自 DDoS 攻击)都可能影响其可用性。
通常,努力实现尽可能高的可用性是一个好主意。您可能会遇到术语计算九,因为可用性通常被规定为 99%(两个九)、99.9%(三个九)等。每增加一个九都更难获得,因此在做出承诺时要小心。看一下下表,看看如果您以每月的方式指定它,您可以承受多少停机时间:
每月停机时间 | 正常运行时间 |
---|---|
7 小时 18 分钟 | 99%(“两个九”) |
43 分钟 48 秒 | 99.9%(“三个九”) |
4 分钟 22.8 秒 | 99.99%(“四个九”) |
26.28 秒 | 99.999%(“五个九”) |
2.628 秒 | 99.9999%(“六个九”) |
262.8 毫秒 | 99.99999%(“七个九”) |
26.28 毫秒 | 99.999999%(“八个九”) |
2.628 毫秒 | 99.9999999%(“九个九”) |
云应用的常见做法是提供服务级别协议(SLA),它规定了在一定时间内(例如一年)可以发生多少停机时间。您的云服务的 SLA 将严重依赖于您构建的云服务的 SLA。
要计算两个需要合作的服务之间的复合可用性,您只需将它们的正常运行时间相乘。这意味着如果您有两个可用性为 99.99%的服务,它们的复合可用性将是 99.99% * 99.99% = 99.98%。要计算冗余服务(例如两个独立区域)的可用性,您应该将它们的不可用性相乘。例如,如果两个区域的可用性为 99.99%,它们的总不可用性将是(100% - 99.99%)*(100% - 99.99%)= 0.01% * 0.01% = 0.0001%,因此它们的复合可用性为 99.9999%。
不幸的是,提供 100%的可用性是不可能的。故障偶尔会发生,因此让我们学习如何使您的系统能够容忍它们。
构建容错系统
容错能力是系统检测此类故障并优雅处理它们的能力。您的基于云的服务具有弹性是至关重要的,因为由于云的性质,许多不同的事情可能会突然出现问题。良好的容错能力可以帮助您的服务可用性。
不同类型的问题需要不同的处理:从预防、检测到最小化影响。让我们从避免单点故障的常见方法开始。
冗余
最基本的预防措施之一是引入冗余。类似于您可以为汽车备用轮胎一样,您可以有一个备用服务,在主服务器宕机时接管。这种接管也被称为故障转移。
备用服务器如何知道何时接管?实现这一点的一种方法是使用检测故障部分中描述的心跳机制。
为了加快切换速度,您可以将所有进入主服务器的消息也发送到备用服务器。这被称为热备用,与冷备用(从零开始初始化)相对。在这种情况下的一个好主意是保持一条消息落后,因此如果一条有毒消息使主服务器崩溃,备用服务器可以简单地拒绝它。
上述机制称为主-被动(或主-从)故障切换,因为备份服务器不处理传入流量。如果处理了传入流量,我们将有主-主(或主-主)故障切换。有关主-主架构的更多信息,请参阅进一步阅读部分中的最后一个链接。
确保故障切换发生时不会丢失任何数据。使用具有后备存储的消息队列可能有助于解决这个问题。
领导者选举
对于两台服务器来说,它们知道自己是哪一个也很重要-如果两者都开始表现为主要实例,您可能会遇到麻烦。选择主服务器称为领导者选举模式。有几种方法可以做到这一点,例如通过引入第三方仲裁者,通过竞争独占共享资源的所有权,通过选择排名最低的实例,或者使用算法,如霸凌选举或令牌环选举。
领导者选举也是下一个相关概念的重要部分:实现共识。
共识
如果您希望系统即使在发生网络分区或服务的某些实例出现故障时也能运行,您需要让实例达成共识的方法。它们必须就要提交的值以及通常的顺序达成一致意见。一个简单的方法是允许每个实例对正确的状态进行投票。然而,在某些情况下,这并不足以正确或根本达成共识。另一种方法是选举一个领导者并让其传播其值。由于手动实现这样的算法并不容易,我们建议使用经过行业验证的共识协议,如 Paxos 和 Raft。后者因为更简单和更容易理解而日益受到欢迎。
现在让我们讨论另一种防止系统故障的方法。
复制
这种方法在数据库中特别受欢迎,并且也有助于扩展它们。复制意味着您将并行运行几个实例的服务,并且处理传入流量的所有重复数据。
不要将复制与分片混淆。后者不需要任何数据冗余,但通常可以在规模上带来很好的性能。如果您使用的是 Postgres,我们建议您尝试 Citus (www.citusdata.com
)。
在数据库方面,您可以有两种复制方式。
主-从复制
在这种情况下,所有服务器都能执行只读操作,但只有一个主服务器可以进行写入。数据从主服务器通过从服务器复制,可以采用一对多拓扑结构或使用树拓扑结构。如果主服务器失败,系统可以继续以只读模式运行,直到此故障得到纠正。
多主复制
您还可以拥有多个主服务器的系统。如果有两台服务器,您将拥有主-主复制方案。如果其中一台服务器死机,其他服务器仍然可以正常运行。但是,现在您需要同步写入或提供更宽松的一致性保证。此外,您需要提供负载均衡器。
此类复制的示例包括 Microsoft 的 Active Directory,OpenLDAP,Apache 的 CouchDB 或 Postgres-XL。
现在让我们讨论两种防止由负载过高引起的故障的方法。
基于队列的负载平衡
这种策略旨在减少系统负载突然增加的影响。向服务发送大量请求可能会导致性能问题,可靠性问题,甚至丢弃有效请求。再次,队列可以拯救一天。
要实现这种模式,我们只需要为传入的请求引入一个队列,以异步方式添加。您可以使用亚马逊的 SQS,Azure 的 Service Bus,Apache Kafka,ZeroMQ 或其他队列来实现。
现在,不再有传入请求的高峰,负载将得到平均。我们的服务可以从该队列中获取请求并处理它们,甚至不知道负载已经增加。就是这么简单。
如果您的队列性能良好,并且您的任务可以并行处理,那么这种模式的一个附加好处将是更好的可扩展性。
此外,如果您的服务不可用,请求仍将被添加到队列中,以便在其恢复时处理,因此这可能是帮助提高可用性的一种方式。
如果请求不频繁,请考虑将您的服务实现为仅在队列中有项目时才运行的函数,以节省成本。
请记住,使用这种模式时,由于队列的添加,总体延迟会增加。Apache Kafka 和 ZeroMQ 应该具有低延迟,但如果这是一个无法接受的问题,还有另一种处理增加负载的方法。
背压
如果负载保持高水平,很可能您将有更多的任务而无法处理。这可能会导致缓存未命中和交换,如果请求不再适合内存,则会丢弃请求和其他不好的事情。如果您预计会有大量负载,施加背压可能是处理它的一个很好的方法。
实质上,背压意味着我们不会在每个传入请求中给我们的服务增加更多的压力,而是将压力推回给调用者,因此需要处理这种情况。有几种不同的方法可以做到这一点。
例如,我们可以阻塞接收网络数据包的线程。调用者将看到无法将请求推送到我们的服务 - 相反,我们将压力推向上游。
另一种方法是识别更大的负载并简单地返回一个错误代码,例如 503。您可以设计您的架构,让另一个服务为您完成这项工作。Envoy 代理(envoyproxy.io
)就是这样的服务,它在许多其他场合也非常有用。
Envoy 可以根据预定义的配额施加背压,因此您的服务实际上永远不会过载。它还可以测量处理请求所需的时间,并且仅在超过某个阈值时施加背压。还有许多其他情况,其中将返回各种错误代码。希望调用者有计划对付压力回到他们身上时该怎么办。
既然我们知道如何预防故障,现在让我们学习一下一旦发生故障如何检测它们。
检测故障
正确和快速的故障检测可以为您节省很多麻烦,通常也可以节省金钱。有许多针对不同需求量身定制的故障检测方法。让我们来看看其中的一些。
边车设计模式
由于我们正在讨论 Envoy,值得一提的是它是“边车设计模式”的一个例子。这种模式不仅在错误预防和检测方面非常有用,Envoy 也是这方面的一个很好的例子。
总的来说,边车允许您为您的服务添加许多功能,而无需编写额外的代码。同样,就像物理边车可以连接到摩托车上一样,软件边车也可以连接到您的服务上 - 在这两种情况下都扩展了提供的功能。
边车如何帮助检测故障?首先,通过提供健康检查功能。当涉及到被动健康检查时,Envoy 可以检测服务集群中的任何实例是否开始表现不佳。这被称为“异常检测”。Envoy 可以寻找连续的 5XX 错误代码,网关故障等。除了检测此类故障实例,它还可以将它们排除,以便整个集群保持健康。
Envoy 还提供主动健康检查,这意味着它可以探测服务本身,而不仅仅是观察其对传入流量的反应。
在本章中,我们将展示边车模式在一般情况下以及 Envoy 在特定情况下的一些其他用途。现在让我们讨论下一个故障检测机制。
心跳机制
故障检测最常见的方式之一是通过“心跳机制”。 “心跳”是在两个服务之间定期发送的信号或消息(通常是几秒钟)。
如果连续几个心跳丢失,接收服务可以认为发送服务已经“死亡”。在之前的几个部分中,我们的主备服务对可能导致故障转移。
在实现心跳机制时,请确保它是可靠的。虚假警报可能会带来麻烦,因为服务可能会感到困惑,例如,不知道哪一个应该成为新的主服务。一个好主意可能是为心跳提供一个单独的端点,这样它就不会受到常规端点上的流量的影响。
漏桶计数器
另一种检测故障的方法是添加所谓的“漏桶”计数器。每次出现错误,计数器都会增加,当达到一定阈值时(桶满了),就会发出故障信号并进行处理。在规则的时间间隔内,计数器会减少(因此是漏桶)。这样,只有在短时间内发生许多错误时,才会被认为是故障。
如果在您的情况下有时出现错误是正常的,那么这种模式可能会很有用,例如,如果您正在处理网络问题。
现在我们知道如何检测故障,让我们学习一旦发生故障该怎么办。
减少故障的影响
检测正在发生的故障需要时间,解决它需要更多的宝贵资源。这就是为什么您应该努力减少故障的影响。以下是一些可以帮助的方法。
重试调用
当您的应用程序调用另一个服务时,有时调用会失败。对于这种情况最简单的补救方法就是重试调用。如果故障是瞬时的,而您不重试,那么故障很可能会通过您的系统传播,造成比应有的更多的损害。实现自动重试此类调用可以为您节省大量麻烦。
还记得我们的边车代理 Envoy 吗?事实证明它可以代表您执行自动重试,使您免于对源代码进行任何更改。
例如,看看这个可以添加到 Envoy 路由中的重试策略的示例配置:
retry_policy:
retry_on: "5xx"
num_retries: 3
per_try_timeout: 2s
这将使 Envoy 在返回诸如 503 HTTP 代码或映射到 5XX 代码的 gRPC 错误等错误时重试调用。将进行三次重试,如果在 2 秒内未完成则被视为失败。
避免级联故障
我们提到,如果没有重试,错误将会传播,导致整个系统中出现一系列故障。现在让我们展示更多的方法来防止这种情况发生。
断路器
断路器模式对此非常有用。它允许我们快速发现服务无法处理请求,因此可以将对其的调用短路。这既可以发生在被调用方附近(Envoy 提供了这样的功能),也可以发生在调用方(还可以从调用中节省时间)。在 Envoy 的情况下,只需将以下内容添加到您的配置中即可:
circuit_breakers:
thresholds:
- priority: DEFAULT
max_connections: 1000
max_requests: 1000
max_pending_requests: 1000
在这两种情况下,对服务调用造成的负载可能会下降,在某些情况下可以帮助服务恢复正常运行。
我们如何在调用方实现断路器?一旦您进行了几次调用,并且,比如说,您的漏桶溢出了,您可以停止在指定的时间段内进行新的调用(例如,直到漏桶不再溢出)。简单而有效。
隔离
另一种限制故障传播的方法直接来自肉类加工厂。在建造船只时,通常不希望船只在船体破裂时充满水。为了限制这种破洞的损害,您可以将船体分成隔舱,每个隔舱都很容易隔离。在这种情况下,只有受损的隔舱会充满水。
相同的原则适用于限制软件架构中的故障影响。您可以将实例分成组,也可以将它们使用的资源分配到组中。设置配额也可以被视为这种模式的一个例子。
可以为不同用户组创建单独的隔离舱,如果您需要对它们进行优先处理或为您的关键消费者提供不同级别的服务,则这可能很有用。
地理节点
我们将展示的最后一种方式称为地理节点。这个名字来自地理节点。当您的服务部署在多个地区时,可以使用它。
如果一个地区发生故障,您可以将流量重定向到其他未受影响的地区。当然,这将使延迟比如果您在同一数据中心的其他节点上进行调用要高得多,但通常将不太关键的用户重定向到远程地区比完全失败他们的调用要好得多。
现在您知道如何通过系统架构提供可用性和容错性,让我们讨论如何将其组件集成在一起。
集成您的系统
分布式系统不仅仅是您的应用程序的孤立实例,它们不知道现有的世界。它们不断地相互通信,并且必须被适当地集成在一起,以提供最大的价值。
关于集成的话题已经说了很多,因此在本节中,我们将尝试展示一些有效集成全新系统以及需要与其他现有部分共存的新系统部分的模式。
为了不让这一章成为一本独立的书,让我们从推荐一本现有的书开始这一部分。如果您对集成模式感兴趣,特别是专注于消息传递,那么 Gregor Hohpe 和 Bobby Woolf 的《企业集成模式》是您必读的书籍。
让我们简要介绍一下本书涵盖的两种模式。
管道和过滤器模式
我们将讨论的第一个集成模式称为管道和过滤器。它的目的是将一个大的处理任务分解为一系列较小、独立的任务(称为过滤器),然后您可以将它们连接在一起(使用管道,如消息队列)。这种方法可以为您提供可伸缩性、性能和可重用性。
假设您需要接收和处理一个传入的订单。您可以在一个大模块中完成,这样您就不需要额外的通信,但是这样一个模块的不同功能将很难测试,并且很难很好地扩展它们。
相反,您可以将订单处理分为单独的步骤,每个步骤由不同的组件处理:一个用于解码,一个用于验证,另一个用于实际处理订单,然后另一个用于将其存储在某个地方。通过这种方法,您现在可以独立执行每个步骤,根据需要轻松替换或禁用它们,并重用它们来处理不同类型的输入消息。
如果您想同时处理多个订单,还可以对处理进行流水线处理:一个线程验证一个消息,另一个线程解码下一个消息,依此类推。
缺点是您需要使用同步队列作为管道,这会引入一些开销。
要扩展处理的一步,您可能希望将此模式与我们列表中的下一个模式一起使用。
竞争消费者
竞争消费者的想法很简单:您有一个输入队列(或消息通道)和几个消费者实例,它们同时从队列中获取和处理项目。每个消费者都可以处理消息,因此它们彼此竞争成为接收者。
这样,您就可以获得可伸缩性、免费负载平衡和弹性。通过添加队列,您现在还可以使用基于队列的负载平衡模式。
这种模式可以轻松地与优先队列集成,如果您需要从请求中削减延迟,或者只是希望将提交到队列的特定任务以更紧急的方式执行。
如果顺序很重要,这种模式可能会变得棘手。您的消费者接收和完成处理消息的顺序可能会有所不同,因此请确保这不会影响您的系统,或者找到一种方法以后重新排序结果。如果需要按顺序处理消息,则可能无法使用此模式。
现在让我们看看更多的模式,这次是为了帮助我们与现有系统集成。
从传统系统过渡
从头开始开发系统可能是一种愉快的体验。开发而不是维护以及使用尖端技术堆栈的可能性 - 有什么不喜欢的呢?不幸的是,当开始与现有的传统系统集成时,这种幸福往往会结束。幸运的是,有一些方法可以缓解这种痛苦。
反腐层
引入反腐层可以帮助您的解决方案与具有不同语义的传统系统无痛集成。这个额外的层负责这两个方面之间的通信。
这样的组件可以使您的解决方案更灵活地设计 - 而无需 compromis 您的技术堆栈或架构决策。要实现这一点,只需要对传统系统进行一组最小的更改(或者如果传统系统不需要调用新系统,则不需要进行任何更改)。
例如,如果您的解决方案基于微服务,传统系统可以直接与反腐层通信,而不是直接定位和到达每个微服务。任何翻译(例如,由于过时的协议版本)也在附加层中完成。
请记住,添加这样的层可能会引入延迟,并且必须满足解决方案的质量属性,例如可伸缩性。
窒息模式
窒息模式允许逐步从传统系统迁移到新系统。虽然我们刚刚看到的反腐层对于两个系统之间的通信很有用,但窒息模式旨在为两个系统向外界提供服务。
在迁移过程的早期,窒息外观将大部分请求路由到传统系统中。在迁移过程中,越来越多的调用可以转发到新系统中,同时越来越多地窒息传统系统,限制其提供的功能。作为迁移的最后一步,窒息器以及传统系统可以被淘汰 - 新系统现在将提供所有功能:
图 4.1 - 单体的窒息。在迁移后,窒息仍然可以用作传统请求的入口点或适配器
这种模式对于小型系统来说可能过于复杂,如果数据存储应该是共享的或者用于事件源系统,则可能会变得棘手。在将其添加到您的解决方案时,请确保计划实现适当的性能和可伸缩性。
说到这两个属性,现在让我们讨论一些有助于实现它们的事情。
实现规模的性能
在设计 C++应用程序时,性能通常是一个关键因素。虽然使用该语言可以在单个应用程序的范围内走得更远,但适当的高级设计对于实现最佳的延迟和吞吐量也是至关重要的。让我们讨论一些关键的模式和方面。
CQRS 和事件源
计算的扩展方式有很多种,但访问数据的扩展可能会很棘手。但是,当您的用户群增长时,通常是必要的。命令查询职责分离(CQRS)是一种可以在这里帮助的模式。
命令查询职责分离
在传统的 CRUD 系统中,读取和写入都是使用相同的数据模型进行的,并且数据流程也是相同的。标题上的分离基本上意味着以两种不同的方式处理查询(读取)和命令(写入)。
许多应用程序的读取与写入的比例非常偏向读取 - 在典型的应用程序中,通常从数据库中读取的次数要比更新它多得多。这意味着尽可能快地进行读取可以提高性能:现在可以分别优化和扩展读取和写入。除此之外,引入 CQRS 可以帮助解决许多写入相互竞争的问题,或者需要维护所有写入的轨迹,或者您的 API 用户组中的一部分需要只读访问。
为读取和写入操作使用不同的模型可以允许不同的团队在两侧工作。在读取方面工作的开发人员不需要对领域有深入的了解,这是执行更新所需的。当他们发出请求时,他们可以从一个薄的读取层中以一个简单的调用获取数据传输对象(DTO),而不是通过领域模型。
如果您不知道 DTO 是什么,请想象一下从数据库返回物品数据。如果调用者要求列出物品,您可以提供一个ItemOverview
对象,其中只包含物品的名称和缩略图。另一方面,如果他们想要特定商店的物品,您还可以提供一个StoreItem
对象,其中包含名称、更多图片、描述和价格。ItemOverview
和StoreItem
都是 DTO,从数据库中的相同Item
对象中获取数据。
读取层可以位于用于写入的数据存储的顶部,也可以是通过事件更新的不同数据存储,如下图所示:
图 4.2 - 带事件源的 CQRS
使用这里展示的方法,您可以创建任意数量的不同命令,每个命令都有自己的处理程序。通常,命令是异步的,并且不会向调用者返回任何值。每个处理程序都使用领域对象并持久化所做的更改。在这样做之后,会发布事件,事件处理程序可以使用这些事件来更新读取操作使用的存储。继续我们的最后一个例子,物品数据查询将从由ItemAdded
或ItemPriceChanged
等事件更新的数据库中获取信息,这些事件可以由AddItem
或ModifyItem
等命令触发。
使用 CQRS 允许我们为读取和写入操作拥有不同的数据模型。例如,您可以创建存储过程和物化视图来加速读取。对读取和领域存储使用不同类型的存储(SQL 和 NoSQL)也可能是有益的:持久化数据的一种有效方式是使用 Apache Cassandra 集群,而使用 Elasticsearch 是快速搜索存储数据的好方法。
除了前面的优点,CQRS 也有其缺点。由于它引入的复杂性,通常不适合小型或需求较少的架构。通常只有在系统的部分中应用它会带来最大的好处。您还应该注意,在更新领域存储后更新读取存储意味着现在我们有了最终一致性,而不是强一致性。
命令查询分离
CQRS 实际上是基于 Eiffel 编程语言(引入合同的同一个语言)很久以前引入的一个更简单的概念。命令查询分离(CQS)是一个原则,旨在将 API 调用分为命令和查询 - 就像在 CQRS 中一样,但不考虑规模。它在面向对象编程和一般命令式编程中表现得非常好。
如果您的函数名称以has、is、can或类似的单词开头,它应该只是一个查询,而不修改底层状态或具有任何副作用。这带来了两个巨大的好处:
-
更容易推理代码:很明显,这样的函数在语义上只是读取,从不写入。这在调试时可以更容易地查找状态变化。
-
减少 Heisenbugs:如果您曾经不得不调试一个在发布版本中出现的错误,但在调试版本中没有出现(或者反过来),那么您已经处理过 Heisenbug。这很少令人愉快。许多此类错误可能是由修改状态的断言调用引起的。遵循 CQS 可以消除这样的错误。
与断言类似,如果您想要有合同(前置条件和后置条件),只使用查询是非常重要的。否则,禁用某些合同检查也可能导致 Heisenbugs,更不用说这将是多么令人费解了。
现在让我们再多说几句关于事件溯源。
事件溯源
正如在第二章中介绍的,架构风格,事件溯源意味着,与始终存储应用程序的整个状态并可能在更新期间处理冲突相比,您可以只存储应用程序状态发生的更改。使用事件溯源可以通过消除并发更新并允许所有相关方逐渐更改其状态来提高应用程序的性能。保存已执行的操作的历史记录(例如,市场交易)可以使调试更容易(稍后重放它们)和审计。这也为灵活性和可扩展性带来了更多的可能性。一些领域模型在引入事件溯源后可能变得简单得多。
事件溯源的一个成本是最终一致性。另一个是减慢应用程序的启动速度-除非您定期对状态进行快照,或者可以像在前一节中讨论的 CQRS 中使用只读存储。
好了,够了关于 CQRS 和相关模式的内容。现在让我们转向另一个热门话题,即性能方面的缓存。
缓存
正确使用缓存可以提高性能,降低延迟,减少服务器负载(从而降低在云中运行的成本),并有助于可伸缩性问题(需要更少的服务器)-有什么不喜欢的呢?
如果您想了解有关 CPU 缓存的技巧,可以在第十一章中找到,性能。
缓存是一个大话题,所以我们在这里只涵盖了一些方面。
缓存的工作原理很简单,只需将最常读取的数据存储在非持久性存储中,以实现快速访问。有许多不同类型的缓存:
-
客户端缓存:用于专门为特定客户存储数据,通常放置在客户端的机器或浏览器上。
-
Web 服务器缓存:用于加快从网页读取的速度,例如通过 HTTP 加速器(如 Varnish)可以缓存 Web 服务器响应。
-
数据库缓存:许多数据库引擎都具有内置的可调整缓存。
-
应用程序缓存:用于加快应用程序的速度,现在可以从缓存中读取数据,而不是直接访问数据库。
-
CDN 也可以被视为缓存:用于从靠近用户的位置提供内容,以减少延迟。
某些类型的缓存可以被复制或部署在集群中,以提供规模化的性能。另一种选择也可以是对它们进行分片:类似于您对数据库进行分片的方式,您可以为数据的不同部分使用不同的缓存实例。
现在让我们来看看更新缓存中数据的不同方法。毕竟,没有人喜欢被提供陈旧的数据。
更新缓存
有几种方法可以保持缓存数据的新鲜。无论是您决定如何更新缓存项,还是其他公司,了解它们都是值得的。在本节中,我们将讨论它们的优缺点。
写入缓存的方法
如果您需要强一致性,同步更新数据库和缓存是有效的方法。这种方法可以保护您免受数据丢失的影响:如果数据对用户可见,这意味着它已经写入数据库。写入缓存的一个缺点是更新的延迟比其他方法更大。
写后方式
另一种替代方法,也称为写回,是为用户提供对缓存的访问。当用户执行更新时,缓存将排队接收传入的更新,然后异步执行,从而更新数据库。显而易见的缺点是,如果出现问题,数据将无法写入。它也不像其他方法那样容易实现。然而,优点是用户看到的最低延迟。
缓存旁路
这种最后一种方法,也称为惰性加载,是按需填充缓存。在这种情况下,数据访问如下所示:
-
调用缓存以检查值是否已存在。如果是,就返回它。
-
到达提供价值的主数据存储或服务。
-
将值存储在缓存中并返回给用户。
这种类型的缓存通常使用 Memcached 或 Redis 进行。它可以非常快速和高效 - 缓存只包含被请求的数据。
然而,如果经常请求缓存中不存在的数据,前面三个调用可能会显着增加延迟。为了减轻这种情况,可以在缓存重新启动时,使用持久存储中的选定数据来初始化缓存。
缓存中的项目也可能变得过时,因此最好为每个条目设置生存时间。如果数据需要更新,可以通过以写入方式删除缓存中的记录并更新数据库来进行。在使用仅基于时间的更新策略(例如 DNS 缓存)的多级缓存时要小心。这可能导致长时间使用过时数据。
我们已经讨论了缓存的类型和更新策略,所以现在关于缓存的内容就足够了。让我们继续讨论提供可扩展架构的不同方面。
部署您的系统
尽管部署服务听起来很容易,但如果仔细看,有很多事情需要考虑。本节将描述如何执行高效的部署,安装后配置您的服务,检查它们在部署后保持健康,并在最小化停机时间的同时执行所有这些操作。
边车模式
还记得本章前面提到的 Envoy 吗?它是一种非常有用的工具,用于高效的应用程序开发。您可以将 Envoy 代理与您的应用程序一起部署,而不是将基础设施服务(如日志记录、监视或网络)嵌入到您的应用程序中,就像边车会部署在摩托车旁边一样。它们一起可以比没有这种模式的应用程序做得更多。
使用边车可以加快开发速度,因为它带来的许多功能需要独立开发每个微服务。由于它与您的应用程序分开,边车可以使用您认为最适合工作的任何编程语言进行开发。边车及其提供的所有功能可以由独立的开发团队维护,并可以独立于您的主服务进行更新。
因为边车就在它增强的应用程序旁边,它们可以使用本地的进程间通信手段。通常,这足够快,比从另一个主机通信要快得多,但请记住,有时它可能是一个太大的负担。
即使您部署了第三方服务,将您选择的边车部署在其旁边仍然可以提供价值:您可以监视主机和服务的资源使用情况和状态,以及跟踪分布式系统中的请求。有时还可以根据其状态动态重新配置服务,通过编辑配置文件或 Web 界面。
使用 Envoy 部署具有跟踪和反向代理的服务
现在让我们将 Envoy 用作部署的前置代理。首先创建 Envoy 的配置文件,在我们的情况下命名为envoy-front_proxy.yaml
,并设置代理的地址:
static_resources:
listeners:
- address:
socket_address:
address: 0.0.0.0
port_value: 8080
traffic_direction: INBOUND
我们已经指定 Envoy 将在端口8080
上监听传入流量。稍后在配置中,我们将将其路由到我们的服务。现在,让我们指定我们希望使用我们的一组服务实例处理 HTTP 请求,并在其上添加一些跟踪功能。首先,让我们添加一个 HTTP 端点:
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
现在,让我们指定请求应该被分配 ID,并由分布式跟踪系统 Jaeger 跟踪:
generate_request_id: true
tracing:
provider:
name: envoy.tracers.dynamic_ot
typed_config:
"@type": type.googleapis.com/envoy.config.trace.v3.DynamicOtConfig
library: /usr/local/lib/libjaegertracing_plugin.so
config:
service_name: front_proxy
sampler:
type: const
param: 1
reporter:
localAgentHostPort: jaeger:6831
headers:
jaegerDebugHeader: jaeger-debug-id
jaegerBaggageHeader: jaeger-baggage
traceBaggageHeaderPrefix: uberctx-
baggage_restrictions:
denyBaggageOnInitializationFailure: false
hostPort: ""
我们将为请求创建 ID,并使用 OpenTracing 标准(DynamicOtConfig
)与本机 Jaeger 插件。该插件将报告给在指定地址下运行的 Jaeger 实例,并添加指定的标头。
我们还需要指定所有流量(参见match
部分)从所有域都应该路由到我们的服务集群中:
codec_type: auto
stat_prefix: ingress_http
route_config:
name: example_route
virtual_hosts:
- name: front_proxy
domains:
- "*"
routes:
- match:
prefix: "/"
route:
cluster: example_service
decorator:
operation: example_operation
我们将在下一步中定义我们的example_service
集群。请注意,来到集群的每个请求都将由预定义的操作装饰器标记。我们还需要指定要使用的路由器地址:
http_filters:
- name: envoy.filters.http.router
typed_config: {}
use_remote_address: true
现在我们知道如何处理和跟踪请求,剩下的就是定义我们使用的集群。让我们从我们服务的集群开始:
clusters:
- name: example_service
connect_timeout: 0.250s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: example_service
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: example_service
port_value: 5678
每个集群可以有多个我们服务的实例(端点)。在这里,如果我们决定添加更多端点,传入的请求将使用轮询策略进行负载平衡。
让我们也添加一个管理界面:
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address:
address: 0.0.0.0
port_value: 9901
现在让我们将配置放在一个容器中,该容器将使用一个名为Dockerfile-front_proxy
的 Dockerfile 运行 Envoy:
FROM envoyproxy/envoy:v1.17-latest
RUN apt-get update && \
apt-get install -y curl && \
rm -rf /var/lib/apt/lists/*
RUN curl -Lo - https://github.com/tetratelabs/getenvoy-package/files/3518103/getenvoy-centos-jaegertracing-plugin.tar.gz | tar -xz && mv libjaegertracing.so.0.4.2 /usr/local/lib/libjaegertracing_plugin.so
COPY envoy-front_proxy.yaml /etc/envoy/envoy.yaml
我们还下载了我们在 Envoy 配置中使用的 Jaeger 本机插件。
现在让我们指定如何在使用 Docker Compose 运行我们的代码时在多个容器中运行。创建一个docker-compose.yaml
文件,从前置代理服务定义开始:
version: "3.7"
services:
front_proxy:
build:
context: .
dockerfile: Dockerfile-front_proxy
networks:
- example_network
ports:
- 12345:12345
- 9901:9901
我们在这里使用我们的 Dockerfile,一个简单的网络,并且我们从容器中向主机公开了两个端口:我们的服务和管理界面。现在让我们添加我们的代理将要指向的服务:
example_service:
image: hashicorp/http-echo
networks:
- example_network
command: -text "It works!"
在我们的情况下,服务将在一个简单的 Web 服务器中显示预定义的字符串。
现在,让我们在另一个容器中运行 Jaeger,并将其端口暴露给外部世界:
jaeger:
image: jaegertracing/all-in-one
environment:
- COLLECTOR_ZIPKIN_HTTP_PORT=9411
networks:
- example_network
ports:
- 16686:16686
最后一步是定义我们的网络:
networks:
example_network: {}
然后就完成了。您现在可以使用docker-compose up --build
运行服务,并将浏览器指向我们指定的端点。
使用边车代理还有一个好处:即使您的服务将停止,边车通常仍然存活,并且可以在主服务停止时响应外部请求。当您的服务重新部署时,例如因为更新,也是一样。说到这一点,让我们学习如何最小化相关的停机时间。
零停机部署
在部署过程中,有两种常见的方式可以最小化停机风险:蓝绿部署和金丝雀发布。在引入这两种方式时,您可以使用 Envoy 边车。
蓝绿部署
蓝绿部署可以帮助您最小化部署应用程序时的停机时间和风险。为此,您需要两个相同的生产环境,称为蓝色和绿色。当绿色为客户提供服务时,您可以在蓝色环境中执行更新。一旦更新完成,服务经过测试,一切看起来稳定,您可以切换流量,使其现在流向更新的(蓝色)环境。
如果在切换后在蓝色环境中发现任何问题,绿色环境仍然存在-您可以将它们切换回来。用户可能甚至不会注意到任何变化,因为两个环境都在运行,切换期间不应该出现任何停机时间。只需确保在切换期间不会丢失任何数据(例如,在新环境中进行的交易)。
金丝雀发布
通常,避免在更新后所有服务实例都失败的最简单方法是,嗯,不要一次更新所有服务实例。这就是增量蓝绿部署的关键思想,也称为金丝雀发布。
在 Envoy 中,您可以将以下内容放入配置的routes
部分:
- match:
prefix: "/"
route:
weighted_clusters:
clusters:
- name: new_version
weight: 5
- name: old_version
weight: 95
您还应该记住从前面的片段中定义的两个集群,第一个集群使用旧版本的服务:
clusters:
- name: old_version
connect_timeout: 0.250s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: old_version
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: old_version
port_value: 5678
第二个集群将运行新版本:
- name: new_version
connect_timeout: 0.250s
type: strict_dns
lb_policy: round_robin
load_assignment:
cluster_name: new_version
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: new_version
port_value: 5678
当更新部署时,服务的新版本只会被一小部分用户(这里是 5%)看到和使用。如果更新的实例保持稳定,没有检查和验证失败,您可以逐步在几个步骤中更新更多主机,直到所有主机都切换到新版本。您可以通过手动更新配置文件或使用管理端点来完成。大功告成!
现在让我们转向我们将在这里介绍的最后一个部署模式。
外部配置存储
如果您部署的是一个简单的应用程序,将其配置与应用程序一起部署可能是可以接受的。然而,当您希望进行更复杂的部署,并拥有许多应用程序实例时,重新部署应用程序的新版本以重新配置它可能很快就会成为一个负担。同时,如果您希望将服务视为牲畜而不是宠物,手动配置更改是不可取的。引入外部配置存储可以是克服这些障碍的一种优雅方式。
实质上,您的应用程序可以从该存储中获取其配置,而不仅仅依赖于其本地配置文件。这使您可以为多个实例提供共同的设置,并为其中一些实例调整参数,同时可以轻松集中地监视所有配置。如果您希望仲裁者决定哪些节点将成为主节点,哪些将作为备份节点,外部配置存储可以为实例提供此类信息。实施配置更新过程也很有用,以便在操作期间可以轻松重新配置您的实例。您可以使用诸如 Firebase Remote Config 之类的现成解决方案,利用基于 Java 的 Netflix Archaius,或者编写自己的配置存储,利用云存储和更改通知。
现在我们已经学习了一些有用的部署模式,让我们转向另一个重要的主题,当涉及到高级设计时:API。
管理您的 API
适当的 API 对于您的开发团队和产品的成功至关重要。我们可以将这个主题分为两个较小的主题:系统级 API 和组件级 API。在本节中,我们将讨论如何处理第一级别的 API,而下一章将为您提供有关第二级别的提示。
除了管理对象,您还希望管理整个 API。如果您想要引入关于 API 使用的策略,控制对该 API 的访问,收集性能指标和其他分析数据,或者根据客户对接口的使用收费,API 管理(APIM)是您正在寻找的解决方案。
典型的 APIM 工具集由以下组件组成:
-
API 网关:API 所有用户的单一入口点。在下一节中详细介绍。
-
报告和分析:监视 API 的性能和延迟,消耗的资源或发送的数据。这些工具可以用于检测使用趋势,了解 API 的哪些部分以及其背后的哪些组件是性能瓶颈,或者可以提供合理的 SLA 以及如何改进它们。
-
开发者门户:帮助他们快速了解您的 API,并随时订阅您的 API。
-
管理员门户:用于管理策略、用户,并将 API 打包成可销售的产品。
-
货币化:根据客户对 API 的使用收费,并帮助相关的业务流程。
APIM 工具由云提供商和独立方提供,例如,NGINX 的 Controller 或 Tyk。
在为特定云设计 API 时,了解云提供商通常记录的良好实践。例如,您可以在进一步阅读部分中找到 Google Cloud Platform 的常见设计模式。在他们的情况下,许多实践都围绕使用 Protobufs。
选择正确的方式来使用 API 可以让您走得更远。向服务器发送请求的最简单方式是直接连接到服务。虽然易于设置并且对于小型应用程序来说还可以,但是这样做可能会导致性能问题。API 使用者可能需要调用几个不同的服务,导致高延迟。使用这种方法也无法实现适当的可扩展性。
使用 API 网关是一个更好的方法。这样的网关通常是 APIM 解决方案的一个重要部分,但也可以单独使用。
API 网关
API 网关是客户端想要使用您的 API 的入口点。然后,它可以将传入的请求路由到特定的服务实例或集群。这可以简化您的客户端代码,因为它不再需要知道所有的后端节点,或者它们如何相互合作。客户端需要知道的只是 API 网关的地址——网关将处理其余的事情。通过隐藏客户端的后端架构,可以轻松地重新设计它,甚至不用触及客户端的代码。
网关可以将系统 API 的多个部分聚合成一个,并使用层 7 路由(例如,基于 URL)到系统的适当部分。层 7 路由由云提供商自己提供,以及诸如 Envoy 之类的工具也提供。
与本章中描述的许多模式一样,始终要考虑是否值得通过引入另一种模式来增加更多的复杂性来添加到您的架构中。考虑一下如果添加它会如何影响您的可用性、容错性和性能。毕竟,网关通常只是一个单一节点,所以尽量不要让它成为瓶颈或单点故障。
我们在前面的几章中提到的前端后端模式可以被认为是 API 网关模式的一种变体。在前端后端的情况下,每个前端都连接到自己的网关。
既然您知道系统设计与 API 设计的关系,让我们总结一下我们在最后几节中讨论的内容。
总结
在本章中,我们学到了很多东西。您现在知道何时应用哪种服务模型,以及如何避免设计分布式系统的常见陷阱。您已经了解了 CAP 定理以及它对分布式架构的实际影响。您现在可以成功地在这样的系统中运行事务,减少它们的停机时间,预防问题,并从错误中优雅地恢复。处理异常高负载不再是黑魔法。甚至将系统的部分,甚至是传统的部分,与您新设计的部分集成起来,也是您能够执行的。您现在也有一些诀窍来提高系统的性能和可扩展性。部署和负载平衡您的系统也已经被揭秘,所以您现在可以有效地执行它们。最后但同样重要的是,发现服务、设计和管理它们的 API 都是您现在已经学会的事情。不错!
在下一章中,我们将学习如何使用特定的 C++特性以更愉快和高效的方式走上卓越架构之路。
问题
-
什么是事件溯源?
-
CAP 定理的实际后果是什么?
-
您可以使用 Netflix 的 Chaos Monkey 做什么?
-
缓存可以应用在哪里?
-
当整个数据中心宕机时,如何防止您的应用程序崩溃?
-
为什么要使用 API 网关?
-
Envoy 如何帮助您实现各种架构目标?
进一步阅读
-
Microsoft Azure 云设计模式:
docs.microsoft.com/en-us/azure/architecture/patterns/
-
谷歌云 API 的常见设计模式:
cloud.google.com/apis/design/design_patterns
-
微软 REST API 指南:
github.com/microsoft/api-guidelines/raw/vNext/Guidelines.md
-
Envoy 代理的入门页面:
www.envoyproxy.io/docs/envoy/latest/start/start
-
使用 MongoDB 的主动-主动应用架构:
developer.mongodb.com/article/active-active-application-architectures