C---软件架构-全-

C++ 软件架构(全)

原文:zh.annas-archive.org/md5/FF4E2693BC25818CA0990A2CB63D13B8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

现代 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 句柄。这里有一个例子:“前两个字段(openapiinfo)是描述文档的元数据。”

代码块设置如下:

using namespace CppUnit;
using namespace std;

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这里有一个例子:“从管理面板中选择系统信息。”

警告或重要说明会出现在这样的地方。

提示和技巧会出现在这样的地方。

第一部分:软件架构的概念和组件

本节介绍了软件架构的基础知识,展示了其设计和文档编制的有效方法。

本节包括以下章节:

  • 第一章,软件架构的重要性和优秀设计原则

  • 第二章,架构风格

  • 第三章,功能和非功能需求

第一章:软件架构的重要性和优秀设计原则

这个介绍性章节的目的是展示软件架构在软件开发中的作用。它将专注于设计 C++解决方案架构时需要牢记的关键方面。我们将讨论如何设计具有方便和功能性接口的高效代码。我们还将介绍一个面向领域的方法,用于代码和架构。

在本章中,我们将涵盖以下主题:

  • 理解软件架构

  • 学习适当架构的重要性

  • 探索良好架构的基本原理

  • 使用敏捷原则开发架构

  • C++的哲学

  • 遵循 SOLID 和 DRY 原则

  • 领域驱动设计

  • 耦合和内聚

技术要求

要运行本章中的代码,您需要以下内容:

理解软件架构

让我们从定义软件架构的实际含义开始。当您创建一个应用程序、库或任何软件组件时,您需要考虑您编写的元素将如何呈现以及它们将如何相互交互。换句话说,您正在设计它们及其与周围环境的关系。就像城市建筑一样,重要的是要考虑整体情况,以免最终陷入杂乱状态。在小范围内,每栋建筑看起来都还不错,但它们无法合理地组合在一起,它们只是无法很好地结合在一起。这就是所谓的偶然架构,这是您要避免的结果之一。但是,请记住,无论您是否在思考,编写软件都是在创建架构。

那么,如果您想要有意识地定义解决方案的架构,您应该创建什么?软件工程研究所有这样说:

系统的软件架构是理解系统所需的结构集合,包括软件元素、它们之间的关系以及两者的属性。

这意味着为了彻底定义架构,我们应该从几个角度来考虑它,而不是只是匆忙地编写代码。

观察架构的不同方式

有几个范围可以用来观察架构:

  • 企业架构涉及整个公司甚至一组公司。它采用全面的方法,关注整个企业的战略。在考虑企业架构时,您应该关注公司中所有系统的行为以及它们如何相互合作。它关注业务和 IT 之间的对齐。

  • 解决方案架构比企业架构更具体。它处于企业架构和软件架构之间的中间位置。通常,解决方案架构关注的是一个特定系统以及它与周围环境的交互方式。解决方案架构师需要想出一种方式来满足特定的业务需求,通常是通过设计整个软件系统或修改现有系统。

  • 软件架构甚至比解决方案架构更具体。它集中在一个特定项目、它使用的技术以及它与其他项目的交互方式。软件架构师对项目组件的内部感兴趣。

  • 基础架构架构如其名称所示,关注软件将使用的基础架构。它定义了部署环境和策略,应用程序的扩展方式,故障处理,站点可靠性以及其他基础架构导向的方面。

解决方案架构基于软件和基础架构架构,以满足业务需求。在接下来的章节中,我们将讨论这两个方面,为您准备好小规模和大规模架构设计。在我们深入讨论之前,让我们也回答一个基本问题:为什么架构很重要?

学习正确架构的重要性

实际上,一个更好的问题应该是:为什么关心您的架构很重要?正如我们之前提到的,无论您是否有意识地努力构建它,您最终都会得到某种类型的架构。如果在数月甚至数年的开发之后,您仍希望您的软件保持其品质,您需要在过程的早期采取一些步骤。如果您不考虑您的架构,那么它可能永远不会呈现所需的品质。

因此,为了使您的产品满足业务需求和性能、可维护性、可扩展性或其他属性,您需要关注其架构,并且最好在过程的早期就这样做。现在让我们讨论每个优秀架构师都想保护其项目免受的两件事。

软件腐败

即使在您进行了最初的工作并有特定的架构构想之后,您仍需要不断监测系统的演变以及它是否仍然符合用户的需求,因为这些需求在软件的开发和生命周期中也可能发生变化。软件腐败,有时也称为侵蚀,发生在实现决策与计划的架构不相符时。所有这些差异都应被视为技术债务。

意外架构

未能跟踪开发是否遵循所选择的架构,或者未能有意地规划架构的外观,通常会导致所谓的意外架构,这可能会发生,无论在其他领域应用最佳实践,如测试或具有任何特定的开发文化。

有几种反模式表明您的架构是意外的。类似一团泥的代码是最明显的一个。拥有上帝对象是另一个重要的迹象。一般来说,如果您的软件变得紧密耦合,可能存在循环依赖,但最初并非如此,这是一个重要的信号,需要更多有意识的努力来规划架构的外观。

现在让我们描述一下架构师必须了解以交付可行解决方案的内容。

探索良好架构的基本原理

重要的是要知道如何识别好的架构和坏的架构,但这并不是一件容易的事。识别反模式是其中的一个重要方面,但要使架构良好,首先它必须支持交付软件所期望的内容,无论是功能要求、解决方案的属性,还是处理来自各个方面的约束。其中许多可以很容易地从架构背景中得出。

架构背景

上下文是架构师在设计坚实解决方案时考虑的因素。它包括需求、假设和约束,这些可以来自利益相关者,以及业务和技术环境。它还影响利益相关者和环境,例如,通过允许公司进入新的市场细分。

利益相关者

利益相关者是所有与产品有关的人。这些可以是你的客户,系统的用户,或者管理层。沟通是每个架构师的关键技能,妥善管理利益相关者的需求对于交付他们期望的东西以他们想要的方式至关重要。

不同的利益相关者群体对不同的事情都很重要,所以尽量收集所有这些群体的意见。

你的客户可能会关心编写和运行软件的成本、软件提供的功能、其寿命、上市时间以及你的解决方案的质量。

系统的用户可以分为两组:最终用户和管理员。前者通常关心的是软件的可用性、用户体验和性能。对于后者来说,更重要的方面是用户管理、系统配置、安全性、备份和恢复。

最后,对于管理工作中的利益相关者来说,重要的事情包括降低开发成本,实现业务目标,按照开发进度表进行,以及保持产品质量。

商业和技术环境

架构可以受到公司业务方面的影响。重要的相关方面包括上市时间、发布计划、组织结构、劳动力利用率以及对现有资产的投资。

技术环境指的是公司已经使用的技术或因某种原因需要成为解决方案一部分的技术。我们还需要集成的其他系统也是技术环境的重要组成部分。此外,可用软件工程师的技术专长在这里也很重要:架构师所做的技术决策可能会影响项目的人员配备,初级开发人员与高级开发人员的比例可能会影响项目的治理方式。良好的架构应该考虑到所有这些。

现在,凭借所有这些知识,让我们讨论一个可能会在你日常工作中遇到的颇具争议的话题。

使用敏捷原则开发架构

表面上,架构和敏捷开发方法之间存在对抗性关系,并且围绕这个话题有许多神话。有一些简单的原则你应该遵循,以便以敏捷方式开发产品,同时关注其架构。

敏捷本质上是迭代和增量的。这意味着在敏捷架构方法中,准备大量的预先设计不是一个选择。相反,应该提出一个小型但仍合理的预先设计。最好附带有每个决定的理由。这样,如果产品愿景发生变化,架构也可以随之发展。为了支持频繁的发布交付,预先设计应该逐步更新。以这种方式开发的架构被称为演进式架构。

管理架构并不意味着保留大量文档。事实上,文档应该只涵盖必要的内容,这样更容易保持其最新。它应该简单,只涵盖系统的相关视图。

还有一个关于架构师作为唯一真理来源和最终决策者的神话。在敏捷环境中,是团队在做决定。话虽如此,利益相关者对决策过程的贡献至关重要 - 毕竟,他们的观点决定了解决方案应该是什么样子。

架构师应该始终是开发团队的一部分,因为他们通常会为团队带来强大的技术专业知识和多年的经验。他们还应该参与估算,并在每次迭代之前计划所需的架构变更。

为了使您的团队保持敏捷,您应该考虑如何高效地工作,只专注于重要的事情。实现这些目标的一个好主意是领域驱动设计。

领域驱动设计

领域驱动设计,简称 DDD,是埃里克·埃文斯在他的同名书中引入的一个术语。本质上,它是关于改善业务和工程之间的沟通,并将开发人员的注意力引向领域模型。基于该模型的实现通常会导致更容易理解并随着模型变化而发展的设计。

DDD 与敏捷有什么关系?让我们回顾一下敏捷宣言的一部分:

个人和互动优于流程和工具

工作软件优于全面文档

客户协作优于合同谈判

响应变化优于遵循计划

  • 敏捷宣言

为了做出正确的设计决策,您必须首先了解领域。为此,您需要经常与人交谈,并鼓励您的开发团队缩小他们与业务人员之间的差距。代码中的概念应该以普遍语言中的实体命名。这基本上是业务专家术语和技术专家术语的共同部分。由于每个群体使用另一方不同理解的术语,可能会导致无数误解,从而导致业务逻辑实现中的缺陷和常常微妙的错误。谨慎命名事物并使用双方同意的术语可能对项目意味着幸福。在团队中有业务分析师或其他业务领域专家可以在很大程度上帮助。

如果您正在建模一个更大的系统,可能很难使所有术语对不同团队意味着相同的事情。这是因为这些团队中的每一个实际上都在不同的上下文中操作。DDD 建议使用有界上下文来处理这个问题。例如,如果您正在建模一个电子商务系统,您可能只想从购物的角度考虑这些术语,但仔细观察后,您可能会发现库存、交付和会计团队实际上都有自己的模型和术语。

这些每一个都是您电子商务领域的不同子领域。理想情况下,每个子领域都可以映射到自己的有界上下文——系统的一部分,具有自己的词汇。在将解决方案拆分为较小的模块时,设置这些上下文的清晰边界非常重要。就像它的上下文一样,每个模块都有明确的责任、自己的数据库架构和自己的代码库。为了在更大的系统中在团队之间进行沟通,您可能希望引入一个上下文地图,它将显示不同上下文的术语之间的关系:

图 1.1-两个有界上下文及其之间匹配术语的映射(来自 Martin Fowler 关于 DDD 的一篇文章:https://martinfowler.com/bliki/BoundedContext.html)

现在您已经了解了一些重要的项目管理主题,我们可以转向一些更技术性的话题。

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++中展示这一原则的一个很好的特性是ostreamoperator<<。要扩展它以支持你的自定义类,你只需要编写类似以下的代码:

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 是“不要重复自己”的缩写。这意味着在可能的情况下应避免代码重复和重用。这意味着当您的代码多次重复类似操作时,应提取函数或函数模板。此外,您应该考虑编写模板,而不是创建几个类似的类型。

当不必要时,重复造轮子是不重要的,也就是说,不要重复他人的工作。如今有数十个编写高质量软件的成熟库,可以帮助您更快地编写高质量的软件。我们想特别提到其中的一些:

然而,有时复制代码也有其好处。一个这样的情景是开发微服务。当然,在单个微服务内遵循 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 &notifier);
  Result process(WorkItem work);
  Results processBatch(WorkBatch batch);
 private:
  WorkResultsCache cache_;
  ResultNotifier notifier_;
  // ...
};

现在每个部分都由一个单独的内聚实体完成。现在可以轻松地重用它们。甚至将它们制作成模板类应该也需要很少的工作。最后但并非最不重要的,测试这样的类应该也更容易。

将这个概念应用到组件或系统级别很简单 - 您设计的每个组件、服务和系统都应该简洁,并专注于做一件事并做到完美:

图 1.2 - 耦合与内聚

低内聚和高耦合通常与难以测试、重用、维护甚至理解的软件相关联,因此它缺乏许多通常在软件中期望具有的质量属性。

这些术语经常一起出现,因为通常一个特征会影响另一个特征,无论我们谈论的单元是函数、类、库、服务,甚至是整个系统。举个例子,通常来说,单体系统耦合度高,内聚性低,而分布式服务往往处于光谱的另一端。

这就结束了我们的介绍性章节。现在让我们总结一下我们学到的东西。

总结

在本章中,我们讨论了软件架构是什么,以及为什么值得关注它。我们展示了当架构没有随着需求和实现的变化而更新时会发生什么,以及如何在敏捷环境中处理架构。然后我们转向了 C++语言的一些核心原则。

我们了解到,许多软件开发术语在 C++中可能有不同的理解,因为 C++允许编写面向对象的代码以外的内容。最后,我们讨论了耦合和内聚等术语。

现在,您应该能够在代码审查中指出许多设计缺陷,并重构您的解决方案以获得更好的可维护性,以及作为开发人员更少容易出现错误。您现在可以设计健壮、自解释和完整的类接口。

在下一章中,我们将学习不同的架构方法或风格。我们还将学习如何以及何时可以使用它们来获得更好的结果。

问题

  1. 为什么要关心软件架构?

  2. 在敏捷团队中,架构师应该是最终的决策者吗?

  3. SRP 与内聚性有什么关系?

  4. 项目的生命周期的哪些阶段可以从有架构师受益?

  5. 遵循 SRP 有什么好处?

进一步阅读

  1. 埃里克·埃文斯,《领域驱动设计:应对软件核心的复杂性》

  2. 斯科特·迈尔斯,《非成员函数如何改善封装》,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. 您应该如何处理用微服务架构替换单体架构?

进一步阅读

第三章:功能和非功能需求

作为架构师,重要的是要认识到哪些需求对架构有重要意义,以及为什么。本章将教你关于解决方案的各种需求——功能和非功能。功能需求告诉您您的解决方案应该做什么。另一方面,非功能需求告诉您您的解决方案应该如何

在本章中,我们将涵盖以下主题:

  • 理解需求的类型

  • 识别具有架构重要性的需求

  • 从各种来源收集需求

  • 记录需求

  • 记录架构

  • 选择正确的视图来记录

  • 生成文档

在本章结束时,您将学会如何识别和分类这两种类型的需求,并如何创建清晰描述它们的文档。

来自源的技术需求文档,您必须安装

要从源生成文档,必须安装 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 SyntaxEARS),以帮助应对这一问题。在 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 文档。您可以使用演示文稿、维基页面、单个图表,甚至是会议记录,当有人描述架构时。

重要的是收集有关记录架构的反馈。同样,与记录需求一样,与利益相关者重复文档是重要的,以了解在哪里改进它们。即使这看起来可能会浪费时间,但如果做得当,它应该能节省您一些交付产品的时间。足够好的文档应该帮助新手更快地开始工作,并引导更熟悉的利益相关者走下去。如果您只是在一些会议上讨论架构,很可能在一个季度后,没有人会记得您为什么做出这些决定,以及它们是否在不断变化的敏捷环境中仍然有效。

在创建文档时,重复是重要的,因为很可能会有一些重要细节的误解。其他时候,您或您的利益相关者会获得更多知识并决定改变事物。在文档被认为成熟和完成之前,准备至少多次审查文档。通常,通过即时通讯、电话或面对面的几次对话将帮助您更快地完成,并解决可能出现的任何后续问题,因此更倾向于这些方式而不是电子邮件或其他异步的沟通方式。

选择正确的视图来记录

架构是一个太复杂的主题,无法用一个大图来描述。想象一下您是一栋建筑的建筑师。为了设计整个建筑,您需要为不同方面制作单独的图表:一个用于管道,另一个用于电力和其他电缆,依此类推。这些图表中的每一个都会展示项目的不同视图。软件架构也是如此:您需要从不同的角度呈现软件,以满足不同的利益相关者。

此外,如果您正在建造一个智能房屋,很可能会绘制一些设备放置的计划。尽管并非所有项目都需要这样的视图,但由于它在您的项目中起着作用,可能值得添加。对于架构也是同样的方法:如果您发现不同的视图对文档有价值,那么您应该这样做。那么,您如何知道哪些视图可能有价值呢?您可以尝试执行以下步骤:

  1. 从 4+1 模型或 C4 模型开始选择视图。

  2. 询问您的利益相关者对他们来说什么是必要的,并考虑修改您的视图集。

  3. 选择能帮助您评估架构是否达到其目标并且所有 ASR 是否满足的视图。阅读下一节中每个视图的第一段,以检查它们是否符合您的需求。

如果您仍然不确定要记录哪些视图,以下是一些提示:

尽量只选择最重要的视图,因为如果太多,架构将变得难以跟踪。一个好的视图集不仅应该展示架构,还应该暴露项目的技术风险。

在选择要在文档中描述的视图时,有一些事情您应该考虑。我们将在这里简要描述它们,但如果您感兴趣,可以查看罗赞斯基和伍兹进一步阅读部分提到的书籍。

功能视图

如果您的软件是作为更大系统的一部分开发的,特别是与不经常交流的团队,您应该包括一个功能视图(如 4+1 模型)。

文档化架构的一个重要且经常被忽视的方面是定义您提供的接口,尽管这是描述的最重要的事情之一。无论是两个组件之间的接口还是外部世界的入口点,您都应该花时间清楚地记录它,描述对象和调用的语义,以及使用示例(有时可以重复使用为测试)。

在文档中包括功能视图的另一个重要好处是澄清系统各组件之间的责任。开发系统的每个团队都应该了解边界在哪里,以及谁负责开发哪些功能。所有需求都应明确映射到组件,以消除差距和重复工作。

这里需要注意的一点是避免过度加载功能视图。如果变得混乱,没有人会想要阅读它。如果您开始在其中描述基础设施,请考虑添加部署视图。如果您的模型中出现了“上帝对象”,请尝试重新思考设计并将其拆分为更小、更具凝聚力的部分。

关于功能视图的最后一个重要说明是,尽量保持每个包含的图表在一个抽象级别上。另一方面,不要选择过于抽象的级别使其变得太模糊;确保每个元素都得到了相关方的明确定义和理解。

信息视图

如果您的系统在信息、处理流程、管理流程或存储方面有非直接的需求,也许包括这种视图是个好主意。

选择最重要的、数据丰富的实体,并展示它们如何在系统中流动,谁拥有它们,生产者和消费者是谁。标记某些数据保持“新鲜”的时间以及何时可以安全丢弃,预期到达系统某些点的延迟是多少,或者在分布式环境中如何处理标识符可能会有用。如果您的系统管理交易,这个过程以及任何回滚也应该清晰地展示给利益相关者。转换、发送和持久化数据的技术对其中一些人也可能很重要。如果您在金融领域工作或者必须处理个人数据,您很可能必须遵守一些法规,因此描述您的系统计划如何应对这些是很重要的。

您的数据结构可以使用 UML 类模型进行图表化。请记住要清楚地描述您的数据格式,特别是如果它在两个不同的系统之间流动。美国宇航局与洛克希德·马丁合作开发的火星气候轨道飞行器价值 1.25 亿美元的失误,就是因为他们无意中使用了不同的单位,因此要注意系统之间的数据不一致性。

您的数据处理流程可以使用 UML 的活动模型,并且可以使用状态图来展示信息的生命周期。

并发视图

如果运行许多并发执行单元是产品的一个重要方面,请考虑添加并发视图。它可以显示您可能遇到的问题和瓶颈(除非听起来太详细)。包括它的其他好理由是依赖进程间通信,具有非直观的任务结构,并发状态管理,同步或任务失败处理逻辑。

对于这个视图,您可以使用任何您想要的符号,只要它能捕捉到执行单元及其通信。如果需要,为您的进程和线程分配优先级,然后分析任何潜在的问题,比如死锁或争用。您可以使用状态图来显示重要执行单元的可能状态及其转换(等待查询、执行查询、分发结果等)。

如果您不确定是否需要向系统引入并发,一个好的经验法则是不要。如果必须引入,并且力求简单的设计。调试并发问题从来都不容易,而且总是很耗时,所以如果可能的话,尝试优化您现有的东西,而不是仅仅向问题投入更多的线程。

如果通过查看您的图表,您担心资源争用,请尝试用更多、但更细粒度的锁替换大对象上的锁,使用轻量级同步(有时原子操作就足够了),引入乐观锁定,或减少共享的内容(在线程中创建一份数据的额外副本并处理它可能比共享对唯一副本的访问更快)。

开发视图

如果您正在构建一个具有许多模块的大型系统,并且需要结构化您的代码,具有系统范围的设计约束,或者如果您希望在系统的各个部分之间共享一些共同的方面,那么从开发的角度呈现解决方案应该对您有益,以及软件开发人员和测试人员。

开发视图的包图可以很方便地显示系统中不同模块的位置,它们的依赖关系以及其他相关模块(例如,位于相同的软件层)。它不需要是 UML 图表 - 即使是方框和线条也可以。如果您计划将一个模块替换掉,这种类型的图表可以显示哪些其他软件包可能会受到影响。

增加系统中重用的策略,例如为组件创建自己的运行时框架,或者增加系统的一致性的策略,例如对认证、日志记录、国际化或其他类型的处理采用共同的方法,都属于开发视图的一部分。如果您发现系统中有共同的部分,请确保所有开发人员也能看到它们。

代码组织、构建和配置管理的常见方法也应该包括在文档的这一部分。如果所有这些听起来都需要大量文档记录,那么重点关注最重要的部分,如果可能的话,简要涵盖其余部分。

部署和运行视图

如果您有一个非标准或复杂的部署环境,例如硬件、第三方软件或网络需求方面的特定需求,请考虑在一个单独的部署部分中记录它,针对系统管理员、开发人员和测试人员。

如果必要,涵盖以下内容:

  • 所需的内存量

  • CPU 线程数(是否启用超线程)

  • 关于 NUMA 节点的固定和亲和性

  • 专业的网络设备要求,例如标记数据包以以黑盒方式测量延迟和吞吐量的交换机

  • 网络拓扑

  • 所需的估计带宽

  • 应用程序的存储需求

  • 您计划使用的任何第三方软件

一旦您有了需求,您可以将它们映射到特定的硬件,并将其放入运行时平台模型中。如果您需要正式建模,可以使用 UML 部署图与构造型。这应该显示您的处理节点和客户节点、在线和离线存储、网络链接、专用硬件(如防火墙或 FPGA 或 ASIC 设备)以及功能元素与它们将在其上运行的节点之间的映射。

如果您有非直接的网络需求,可以添加另一个图表,显示网络节点和它们之间的连接。

如果您依赖于特定的技术(包括特定版本的软件),列出它们是个好主意,以查看您使用的软件之间是否存在兼容性问题。有时,两个第三方组件将需要相同的依赖项,但是版本不同。

如果您在脑海中有一个特定的安装和升级计划,写几句话可能是个好主意。诸如 A/B 测试、蓝绿部署或者您的解决方案将依赖的任何特定容器技术都应该对所有相关人员清晰可见。如果需要,还应该包括数据迁移计划,包括迁移可能需要多长时间以及何时可以安排迁移。

任何配置管理、性能监控、运行监控和控制以及备份策略的计划都值得描述。您可能希望创建几个组,识别每个组的依赖关系,并为每个组定义方法。如果您能想到可能发生的任何错误,应该有一个检测和恢复计划。

一些支持团队的注意事项也可以放在这一部分:哪些利益相关者组需要支持,您计划拥有哪些类别的事件,如何升级,以及每个支持级别将负责什么。

最好尽早与运营人员进行沟通,并为他们专门创建图表,以保持他们的参与。

现在我们已经讨论了如何手动创建关于您的系统及其需求的文档,让我们转而以自动化的方式记录您的 API。

生成文档

作为工程师,我们不喜欢手工劳动。这就是为什么,如果某些事情可以自动化并节省我们的工作,它很可能会被实现。在努力创建足够好的文档的过程中,至少部分工作可以自动化,这实际上可能是一种幸福。

生成需求文档

如果您从零开始创建一个项目,很难凭空生成文档。然而,有时候,如果您只有适当的工具和需求,生成文档是可能的。例如,如果您使用 JIRA,一个起点就是从问题导航器视图中导出所有项目。您可以使用任何您喜欢的过滤器,并为这些项目获取打印输出。如果您不喜欢默认的字段集,或者觉得这不是您要找的东西,您可以尝试使用 JIRA 的需求管理插件之一。它们不仅允许您导出需求,例如R4JJira 的需求)还允许您创建整个需求层次结构,跟踪它们,管理变更并将其传播到整个项目中,执行任何需求变更的影响分析,当然,还可以使用用户定义的模板进行导出。许多这样的工具还可以帮助您创建与您的需求相关的测试套件,但我们看到的没有一个是免费的。

从代码生成图表

如果您想了解代码结构,但又不想深入研究源代码,您可能会对从代码生成图表的工具感兴趣。

其中一个工具是 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函数,包括每个参数,并根据定义它们的文件进行了分组。整洁!

摘要

在这一章中,您了解了有关需求和文档的所有基本知识。您学会了如何成功收集需求以及如何识别最重要的需求。您现在可以准备精简而有用的文档,以一种面向视图的方式只展示重要内容。您能够区分不同类型和风格的图表,并使用最适合您需求的那种。最后,但同样重要的是,您现在能够自动生成美观的文档。

在下一章中,您将了解有用的架构设计模式,这将帮助您满足系统的需求。我们将讨论各种模式以及如何应用它们来提供许多重要的质量属性,无论是在分布式系统中的单个组件规模上。

问题

  1. 什么是质量属性?

  2. 在收集需求时应使用哪些来源?

  3. 您如何判断一个需求是否具有架构上的重要性?

  4. 开发视图文档在什么时候有用?

  5. 您如何自动检查您的代码的 API 文档是否过时?

  6. 您如何在图表上指示给定的过程是由系统的不同组件处理的?

进一步阅读

  1. 使用 ATAM 评估软件架构,JC Olamendy,博客文章:johnolamendy.wordpress.com/2011/08/12/evaluate-the-software-architecture-using-atam/

  2. EARS需求语法的简易方法,John Terzakis,英特尔公司,来自 ICCGI 会议的会议演讲:www.iaria.org/conferences2013/filesICCGI13/ICCGI_2013_Tutorial_Terzakis.pdf

  3. 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 文化。通过促进开发和运营之间的紧密合作,人们分享关于系统的知识,从而减少公交因素。引入持续交付可以帮助升级项目并使其始终保持更新。

尽量将系统模块化并且向后兼容,这样组件的升级不需要其他组件也进行升级。一种简单的解耦方法是在它们之间引入消息传递,因此考虑添加一个或两个队列。这也将有助于在升级期间减少停机时间。

最后,尽量监控系统并将日志收集到一个集中的位置。系统的去中心化不应意味着你现在需要手动查看十几台不同的机器上的日志。ELKElasticsearch,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对象,其中包含名称、更多图片、描述和价格。ItemOverviewStoreItem都是 DTO,从数据库中的相同Item对象中获取数据。

读取层可以位于用于写入的数据存储的顶部,也可以是通过事件更新的不同数据存储,如下图所示:

图 4.2 - 带事件源的 CQRS

使用这里展示的方法,您可以创建任意数量的不同命令,每个命令都有自己的处理程序。通常,命令是异步的,并且不会向调用者返回任何值。每个处理程序都使用领域对象并持久化所做的更改。在这样做之后,会发布事件,事件处理程序可以使用这些事件来更新读取操作使用的存储。继续我们的最后一个例子,物品数据查询将从由ItemAddedItemPriceChanged等事件更新的数据库中获取信息,这些事件可以由AddItemModifyItem等命令触发。

使用 CQRS 允许我们为读取和写入操作拥有不同的数据模型。例如,您可以创建存储过程和物化视图来加速读取。对读取和领域存储使用不同类型的存储(SQL 和 NoSQL)也可能是有益的:持久化数据的一种有效方式是使用 Apache Cassandra 集群,而使用 Elasticsearch 是快速搜索存储数据的好方法。

除了前面的优点,CQRS 也有其缺点。由于它引入的复杂性,通常不适合小型或需求较少的架构。通常只有在系统的部分中应用它会带来最大的好处。您还应该注意,在更新领域存储后更新读取存储意味着现在我们有了最终一致性,而不是强一致性。

命令查询分离

CQRS 实际上是基于 Eiffel 编程语言(引入合同的同一个语言)很久以前引入的一个更简单的概念。命令查询分离CQS)是一个原则,旨在将 API 调用分为命令和查询 - 就像在 CQRS 中一样,但不考虑规模。它在面向对象编程和一般命令式编程中表现得非常好。

如果您的函数名称以hasiscan或类似的单词开头,它应该只是一个查询,而不修改底层状态或具有任何副作用。这带来了两个巨大的好处:

  • 更容易推理代码:很明显,这样的函数在语义上只是读取,从不写入。这在调试时可以更容易地查找状态变化。

  • 减少 Heisenbugs:如果您曾经不得不调试一个在发布版本中出现的错误,但在调试版本中没有出现(或者反过来),那么您已经处理过 Heisenbug。这很少令人愉快。许多此类错误可能是由修改状态的断言调用引起的。遵循 CQS 可以消除这样的错误。

与断言类似,如果您想要有合同(前置条件和后置条件),只使用查询是非常重要的。否则,禁用某些合同检查也可能导致 Heisenbugs,更不用说这将是多么令人费解了。

现在让我们再多说几句关于事件溯源。

事件溯源

正如在第二章中介绍的,架构风格,事件溯源意味着,与始终存储应用程序的整个状态并可能在更新期间处理冲突相比,您可以只存储应用程序状态发生的更改。使用事件溯源可以通过消除并发更新并允许所有相关方逐渐更改其状态来提高应用程序的性能。保存已执行的操作的历史记录(例如,市场交易)可以使调试更容易(稍后重放它们)和审计。这也为灵活性和可扩展性带来了更多的可能性。一些领域模型在引入事件溯源后可能变得简单得多。

事件溯源的一个成本是最终一致性。另一个是减慢应用程序的启动速度-除非您定期对状态进行快照,或者可以像在前一节中讨论的 CQRS 中使用只读存储。

好了,够了关于 CQRS 和相关模式的内容。现在让我们转向另一个热门话题,即性能方面的缓存。

缓存

正确使用缓存可以提高性能,降低延迟,减少服务器负载(从而降低在云中运行的成本),并有助于可伸缩性问题(需要更少的服务器)-有什么不喜欢的呢?

如果您想了解有关 CPU 缓存的技巧,可以在第十一章中找到,性能

缓存是一个大话题,所以我们在这里只涵盖了一些方面。

缓存的工作原理很简单,只需将最常读取的数据存储在非持久性存储中,以实现快速访问。有许多不同类型的缓存:

  • 客户端缓存:用于专门为特定客户存储数据,通常放置在客户端的机器或浏览器上。

  • Web 服务器缓存:用于加快从网页读取的速度,例如通过 HTTP 加速器(如 Varnish)可以缓存 Web 服务器响应。

  • 数据库缓存:许多数据库引擎都具有内置的可调整缓存。

  • 应用程序缓存:用于加快应用程序的速度,现在可以从缓存中读取数据,而不是直接访问数据库。

  • CDN 也可以被视为缓存:用于从靠近用户的位置提供内容,以减少延迟。

某些类型的缓存可以被复制或部署在集群中,以提供规模化的性能。另一种选择也可以是对它们进行分片:类似于您对数据库进行分片的方式,您可以为数据的不同部分使用不同的缓存实例。

现在让我们来看看更新缓存中数据的不同方法。毕竟,没有人喜欢被提供陈旧的数据。

更新缓存

有几种方法可以保持缓存数据的新鲜。无论是您决定如何更新缓存项,还是其他公司,了解它们都是值得的。在本节中,我们将讨论它们的优缺点。

写入缓存的方法

如果您需要强一致性,同步更新数据库和缓存是有效的方法。这种方法可以保护您免受数据丢失的影响:如果数据对用户可见,这意味着它已经写入数据库。写入缓存的一个缺点是更新的延迟比其他方法更大。

写后方式

另一种替代方法,也称为写回,是为用户提供对缓存的访问。当用户执行更新时,缓存将排队接收传入的更新,然后异步执行,从而更新数据库。显而易见的缺点是,如果出现问题,数据将无法写入。它也不像其他方法那样容易实现。然而,优点是用户看到的最低延迟。

缓存旁路

这种最后一种方法,也称为惰性加载,是按需填充缓存。在这种情况下,数据访问如下所示:

  1. 调用缓存以检查值是否已存在。如果是,就返回它。

  2. 到达提供价值的主数据存储或服务。

  3. 将值存储在缓存中并返回给用户。

这种类型的缓存通常使用 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++特性以更愉快和高效的方式走上卓越架构之路。

问题

  1. 什么是事件溯源?

  2. CAP 定理的实际后果是什么?

  3. 您可以使用 Netflix 的 Chaos Monkey 做什么?

  4. 缓存可以应用在哪里?

  5. 当整个数据中心宕机时,如何防止您的应用程序崩溃?

  6. 为什么要使用 API 网关?

  7. Envoy 如何帮助您实现各种架构目标?

进一步阅读

第五章:利用 C++语言特性

C++语言是一种独特的语言。它被用于各种情况,从创建固件和操作系统,桌面和移动应用程序,到服务器软件,框架和服务。C++代码在各种硬件上运行,在计算云上大规模部署,并且甚至可以在外太空中找到。如果没有这种多范式语言具有的广泛功能集,这样的成功是不可能的。

本章描述了如何利用 C++语言提供的内容,以便我们可以实现安全和高性能的解决方案。我们将展示类型安全的最佳行业实践,避免内存问题,并以同样高效的方式创建高效的代码。我们还将教您在设计 API 时如何使用某些语言特性。

在本章中,我们将涵盖以下主题:

  • 管理资源和避免泄漏

  • 将计算从运行时移动到编译时

  • 利用安全类型的能力

  • 创建易于阅读和高性能的代码

  • 将代码分成模块

在这段旅程中,您将了解各种 C++标准中可用的功能和技术,从 C++98 到 C++20。这将包括声明式编程,RAII,constexpr,模板,概念和模块。话不多说,让我们开始这段旅程吧。

技术要求

您需要以下工具来构建本章中的代码:

  • 支持 C++20 的编译器(建议使用 GCC 11+)

  • CMake 3.15+

本章的源代码可以在github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter05找到。

设计出色的 APIs

尽管 C++允许您使用您可能熟悉的基于对象的 API,但它还有一些其他技巧。我们将在本节中提到其中一些。

利用 RAII

C API 和 C++ API 之间的主要区别是什么?通常,这与多态性或具有类本身无关,而是与一种称为 RAII 的习惯有关。

RAII代表资源获取即初始化,但实际上更多的是关于释放资源而不是获取资源。让我们看一下在 C 和 C++中编写的类似 API,以展示这个特性的作用:

struct Resource;

// C API
Resource* acquireResource();
void releaseResource(Resource *resource);

// C++ API
using ResourceRaii = std::unique_ptr<Resource, decltype(&releaseResource)>;
ResourceRaii acquireResourceRaii();

C++ API 基于 C API,但这并不总是必须的。重要的是,在 C++ API 中,不需要单独的函数来释放我们宝贵的资源。由于 RAII 习惯,一旦ResourceRaii对象超出范围,它就会自动完成。这减轻了用户手动管理资源的负担,最重要的是,它不需要额外的成本。

而且,我们不需要编写任何我们自己的类 - 我们只是重用了标准库的unique_ptr,它是一个轻量级指针。它确保它管理的对象将始终被释放,并且将始终被精确释放一次。

由于我们管理一些特殊类型的资源而不是内存,我们必须使用自定义的删除器类型。我们的acquireResourceRaii函数需要将实际指针传递给releaseResource函数。如果您只想从 C++中使用它,C API 本身不需要暴露给用户。

这里需要注意的一点是,RAII 不仅用于管理内存:您可以使用它轻松处理任何资源的所有权,例如锁,文件句柄,数据库连接,以及任何应该在其 RAII 包装器超出范围时释放的其他资源。

指定 C++中容器的接口

标准库的实现是搜索惯用和高性能 C++代码的好地方。例如,如果你想阅读一些非常有趣的模板代码,你应该尝试一下std::chrono,因为它演示了一些有用的技术,并对此有了新的方法。在进一步阅读部分可以找到 libstdc++的实现链接。

当涉及到库的其他地方时,即使快速查看其容器也会发现它们的接口往往与其他编程语言中的对应物不同。为了展示这一点,让我们来看一下标准库中一个非常直接的类std::array,并逐个分析它:

template <class T, size_t N>
struct array {
 // types:
 typedef T& reference;
 typedef const T& const_reference;
 typedef /*implementation-defined*/ iterator;
 typedef /*implementation-defined*/ const_iterator;
 typedef size_t size_type;
 typedef ptrdiff_t difference_type;
 typedef T value_type;
 typedef T* pointer;
 typedef const T* const_pointer;
 typedef reverse_iterator<iterator> reverse_iterator;
 typedef reverse_iterator<const_iterator> const_reverse_iterator;

当你开始阅读类定义时,你会看到的第一件事是它为一些类型创建了别名。这在标准容器中很常见,这些别名的名称在许多容器中都是相同的。这是由于几个原因。其中之一是最少惊讶原则 - 以这种方式减少开发人员花在思考你的意思以及特定别名的命名方式上的时间。另一个原因是你的类的用户和库编写者在编写他们自己的代码时经常依赖这样的类型特征。如果你的容器不提供这样的别名,它将使得使用它与一些标准工具或类型特征更加困难,因此你的 API 的用户将不得不解决这个问题,甚至使用完全不同的类。

即使在模板中没有使用这些类型别名,拥有这样的类型别名也是有用的。在函数参数和类成员字段中依赖于这些类型是很常见的,所以如果你正在编写一个其他人可能会使用的类,一定要记得提供它们。例如,如果你正在编写一个分配器,它的许多使用者将依赖于特定的类型别名存在。

让我们看看数组类将给我们带来什么:

 // no explicit construct/copy/destroy for aggregate type

因此,关于std::array的另一个有趣之处是它没有定义构造函数,包括复制/移动构造函数;赋值运算符;或析构函数。这仅仅是因为拥有这些成员不会增加任何价值。通常,在不必要的情况下添加这些成员实际上对性能有害。有了非默认构造函数(T() {}已经是非默认的,与T() = default;相反),你的类不再是平凡的,也不再是平凡可构造的,这将阻止编译器对其进行优化。

让我们看看我们的类还有哪些其他声明:

 constexpr void fill(const T& u);
 constexpr void swap(array<T, N>&) noexcept(is_nothrow_swappable_v<T&>);

现在,我们可以看到两个成员函数,包括一个成员交换。通常,不依赖于std::swap的默认行为并提供我们自己的交换函数是有利的。例如,在std::vector的情况下,底层存储被整体交换,而不是每个元素被交换。当你编写一个成员交换函数时,一定要引入一个名为swap的自由函数,以便通过参数相关查找ADL)来检测它。它可以调用你的成员swap函数。

关于值得一提的交换函数的另一件事是它是有条件的noexcept。如果存储的类型可以在不抛出异常的情况下交换,那么数组的交换也将是noexcept的。具有不抛出异常的交换可以帮助你在存储我们类型的成员的类的复制操作中实现强异常安全性保证。

如下面的代码块所示,现在出现了一大堆函数,它们向我们展示了许多类的另一个重要方面 - 它们的迭代器:

 // iterators:
 constexpr iterator begin() noexcept;
 constexpr const_iterator begin() const noexcept;
 constexpr iterator end() noexcept;
 constexpr const_iterator end() const noexcept;

 constexpr reverse_iterator rbegin() noexcept;
 constexpr const_reverse_iterator rbegin() const noexcept;
 constexpr reverse_iterator rend() noexcept;
 constexpr const_reverse_iterator rend() const noexcept;

 constexpr const_iterator cbegin() const noexcept;
 constexpr const_iterator cend() const noexcept;
 constexpr const_reverse_iterator crbegin() const noexcept;
 constexpr const_reverse_iterator crend() const noexcept;

迭代器对于每个容器都是至关重要的。如果您的类没有提供迭代器访问权限,您将无法在基于范围的循环中使用它,并且它将与标准库中的所有有用算法不兼容。这并不意味着您需要编写自己的迭代器类型 - 如果您的存储是连续的,您可以只使用简单的指针。提供const迭代器可以帮助您以不可变的方式使用类,并且提供反向迭代器可以帮助扩展容器的更多用例。

让我们看看接下来会发生什么:

 // capacity:
 constexpr size_type size() const noexcept;
 constexpr size_type max_size() const noexcept;
 constexpr bool empty() const noexcept;

 // element access:
 constexpr reference operator[](size_type n);
 constexpr const_reference operator[](size_type n) const;
 constexpr const_reference at(size_type n) const;
 constexpr reference at(size_type n);
 constexpr reference front();
 constexpr const_reference front() const;
 constexpr reference back();
 constexpr const_reference back() const;

 constexpr T * data() noexcept;
 constexpr const T * data() const noexcept;
private:
 // the actual storage, like T elements[N];
};

在迭代器之后,我们有一些检查和修改容器数据的方法。在array的情况下,所有这些方法都是constexpr的。这意味着如果我们要编写一些编译时代码,我们可以使用我们的数组类。我们将在本章的在编译时移动计算部分中更详细地讨论这一点。

最后,我们完成了对array的整个定义。然而,它的接口并不仅限于此。从 C++17 开始,在类型定义之后,您可以看到类似以下的行:

template<class T, class... U>
  array(T, U...) -> array<T, 1 + sizeof...(U)>;

这些语句被称为推导指南。它们是类模板参数推导CTAD)功能的一部分,该功能在 C++17 中引入。它允许您在声明变量时省略模板参数。对于array来说,这很方便,因为现在您可以只写以下内容:

auto ints = std::array{1, 2, 3};

但是,对于更复杂的类型,例如映射,它可能更方便,如下所示:

auto legCount = std::unordered_map{ std::pair{"cat", 4}, {"human", 2}, {"mushroom", 1} };

然而,这里有一个问题:当我们传递第一个参数时,我们需要指定我们正在传递键值对(请注意,我们还为其使用了推导指南)。

既然我们谈到了接口,让我们指出其中的一些其他方面。

在接口中使用指针

您在接口中使用的类型非常重要。即使有文档,一个良好的 API 在一瞥之间仍应该是直观的。让我们看看不同的传递资源参数给函数的方法如何向 API 使用者暗示不同的事情。

考虑以下函数声明:

void A(Resource*); 
void B(Resource&); 
void C(std::unique_ptr<Resource>); 
void D(std::unique_ptr<Resource>&);
void E(std::shared_ptr<Resource>); 
void F(std::shared_ptr<Resource>&);

您应该在何时使用这些函数?

由于智能指针现在是处理资源的标准方式,AB应该留给简单的参数传递,并且如果您不对传递的对象的所有权做任何操作,则不应使用它们。A应该仅用于单个资源。例如,如果您想要传递多个实例,可以使用容器,例如std::span。如果您知道要传递的对象不为空,最好通过引用传递,例如 const 引用。如果对象不太大,也可以考虑通过值传递。

关于函数CF的一个很好的经验法则是,如果您想要操纵指针本身,那么只应传递智能指针作为参数;例如,用于所有权转移。

函数C通过值接受unique_ptr。这意味着它是一个资源接收器。换句话说,它会消耗然后释放资源。请注意,通过选择特定类型,接口清晰地表达了其意图。

函数D应该仅在您想要传递包含一个资源的unique_ptr并在同一个unique_ptr中作为输出参数接收另一个资源时使用。对于简单地传递资源来说,拥有这样的函数并不是一个好主意,因为调用者需要将其专门存储在unique_ptr中。换句话说,如果您考虑传递const unique_ptr<Resource>&,只需传递Resource*(或Resource&)即可。

函数E用于与调用方共享资源所有权。通过值传递shared_ptr可能相对昂贵,因为需要增加其引用计数。然而,在这种情况下,通过值传递shared_ptr是可以的,因为如果调用方真的想成为共享所有者,那么必须在某个地方进行复制。

F函数类似于D,只有在你想要操作shared_ptr实例并通过这个输入/输出参数传播更改时才应该使用。如果你不确定函数是否应该拥有所有权,考虑传递一个const shared_ptr&

指定前置条件和后置条件

一个函数通常会对其参数有一些要求是很常见的。每个要求都应该被陈述为一个前置条件。如果一个函数保证其结果具有某些属性——例如,它是非负的——那么函数也应该清楚地表明这一点。一些开发人员会使用注释来通知其他人,但这并不真正以任何方式强制要求。放置if语句会更好一些,但会隐藏检查的原因。目前,C++标准仍然没有提供处理这个问题的方法(合同最初被投票纳入 C++20 标准,后来被移除)。幸运的是,像微软的指南支持库GSL)这样的库提供了它们自己的检查。

假设出于某种原因,我们正在编写自己的队列实现。push 成员函数可能如下所示:

template<typename T>
T& Queue::push(T&& val) {
 gsl::Expects(!this->full());
 // push the element
 gsl::Ensures(!this->empty());
}

请注意,用户甚至不需要访问实现就可以确保某些检查已经就位。代码也是自我描述的,因为清楚地表明了函数需要什么以及结果将是什么。

利用内联命名空间

在系统编程中,通常情况下,你并不总是只是针对 API 编写代码;通常情况下,你还需要关心 ABI 兼容性。当 GCC 发布其第五个版本时,发生了一个著名的 ABI 破坏,其中一个主要变化是改变了std::string的类布局。这意味着使用旧版 GCC 版本的库(或者在较新的 GCC 版本中仍然使用新的 ABI,这在最近的 GCC 版本中仍然存在)将无法与使用较新 ABI 编写的代码一起工作。在发生 ABI 破坏的情况下,如果收到链接器错误,你可以算自己幸运。在某些情况下,例如将NDEBUG代码与调试代码混合使用,如果一个类只有在一种配置中可用的成员,你可能会遇到内存损坏;例如,为了更好地进行调试而添加特殊成员。

一些内存损坏,通常很难调试,可以很容易地通过使用 C++11 的内联命名空间转换为链接器错误。考虑以下代码:

#ifdef NDEBUG

inline namespace release {

#else 

inline namespace debug {

#endif


struct EasilyDebuggable {

// ...

#ifndef NDEBUG

// fields helping with debugging

#endif

};


} // end namespace

由于前面的代码使用了内联命名空间,当你声明这个类的对象时,用户在两种构建类型之间看不到任何区别:内联命名空间中的所有声明在周围范围内都是可见的。然而,链接器最终会得到不同的符号名称,这将导致链接器在尝试链接不兼容的库时失败,给我们提供了我们正在寻找的 ABI 安全性和一个提到内联命名空间的良好错误消息。

有关提供安全和优雅的 ABI 的更多提示,请参阅Arvid NorbergC++Now 2019 年的The ABI Challenge演讲,链接在进一步阅读部分中。

利用 std::optional

从 ABI 转回 API,让我们提到在本书早期设计伟大的 API 时遗漏的另一种类型。本节的英雄可以在函数的可选参数方面挽救局面,因为它可以帮助你的类型具有可能包含值的组件,也可以用于设计清晰的接口或作为指针的替代。这个英雄被称为std::optional,并在 C++17 中标准化。如果你不能使用 C++17,你仍然可以在 Abseil(absl::optional)中找到它,或者在 Boost(boost::optional)中找到一个非常相似的版本。使用这些类的一个重要优点是它们非常清晰地表达了意图,有助于编写清晰和自我描述的接口。让我们看看它的作用。

可选函数参数

我们将从向可能但不一定持有值的函数传递参数开始。你是否曾经遇到过类似以下的函数签名?

void calculate(int param); // If param equals -1 it means "no value"


void calculate(int param = -1);

有时,当你不想在param在代码的其他地方计算时,却很容易错误地传递一个-1——也许在那里它甚至是一个有效的值。以下签名怎么样?

void calculate(std::optional<int> param);

这一次,如果你不想传递一个value,该怎么做就清楚多了:只需传递一个空的 optional。意图明确,而且-1仍然可以作为一个有效的值,而不需要以一种类型不安全的方式赋予它任何特殊含义。

这只是我们 optional 模板的一个用法。让我们看看其他一些用法。

可选的函数返回值

就像接受特殊值来表示参数的无值一样,有时函数可能返回无值。你更喜欢以下哪种?

int try_parse(std::string_view maybe_number);
bool try_parse(std::string_view maybe_number, int &parsed_number);
int *try_parse(std::string_view maybe_number);
std::optional<int> try_parse(std::string_view maybe_number);

你怎么知道第一个函数在出现错误时会返回什么值?或者它会抛出异常而不是返回一个魔术值?接下来看第二个签名,看起来如果出现错误会返回false,但是很容易忘记检查它,直接读取parsed_number,可能会引起麻烦。在第三种情况下,虽然可以相对安全地假设在出现错误时会返回一个nullptr,在成功的情况下会返回一个整数,但现在不清楚返回的int是否应该被释放。

通过最后一个签名,仅仅通过看它就清楚了,在出现错误的情况下将返回一个空值,而且没有其他需要做的事情。这简单、易懂、优雅。

可选的返回值也可以用来标记无值的返回,而不一定是发生了错误。说到这里,让我们继续讨论 optionals 的最后一个用例。

Optional 类成员

在一个类状态中实现一致性并不总是一件容易的事情。例如,有时你希望有一个或两个成员可以简单地不设置。而不是为这种情况创建另一个类(增加代码复杂性)或保留一个特殊值(很容易被忽视),你可以使用一个 optional 类成员。考虑以下类型:

struct UserProfile {
  std::string nickname;
  std::optional <std::string> full_name;
  std::optional <std::string> address;
  std::optional <PhoneNumber> phone;
};

在这里,我们可以看到哪些字段是必要的,哪些不需要填充。相同的数据可以使用空字符串存储,但这并不会清晰地从结构的定义中看出。另一种选择是使用std::unique_ptr,但这样我们会失去数据的局部性,这对性能通常是至关重要的。对于这种情况,std::optional可以有很大的价值。当你想要设计干净和直观的 API 时,它绝对应该是你的工具箱的一部分。

这些知识可以帮助你提供高质量和直观的 API。还有一件事可以进一步改进它们,这也将帮助你默认情况下编写更少的错误代码。我们将在下一节讨论这个问题。

编写声明式代码

你熟悉命令式与声明式编码风格吗?前者是当你的代码一步一步地告诉机器如何实现你想要的。后者是当你只告诉机器想要实现什么。某些编程语言更偏向于其中一种。例如,C 是命令式的,而 SQL 是声明式的,就像许多函数式语言一样。有些语言允许你混合这些风格——想想 C#中的 LINQ。

C++是一个灵活的语言,允许你以两种方式编写代码。你应该更倾向于哪一种呢?事实证明,当你编写声明式代码时,通常会保持更高的抽象级别,这会导致更少的错误和更容易发现的错误。那么,我们如何声明式地编写 C++呢?有两种主要的策略可以应用。

第一种是编写函数式风格的 C++,这是你在可能的情况下更倾向于纯函数式风格(函数没有副作用)。你应该尝试使用标准库算法,而不是手动编写循环。考虑以下代码:

auto temperatures = std::vector<double>{ -3., 2., 0., 8., -10., -7\. };

// ...

for (std::size_t i = 0; i < temperatures.size() - 1; ++i) {

    for (std::size_t j = i + 1; j < temperatures.size(); ++j) {

        if (std::abs(temperatures[i] - temperatures[j]) > 5) 
            return std::optional{i};

    }

}

return std::nullopt;

现在,将前面的代码与执行相同操作的以下片段进行比较:

auto it = std::ranges::adjacent_find(temperatures, 
                                     [](double first, double second) {
    return std::abs(first - second) > 5);
});
if (it != std::end(temperatures)) 
    return std::optional{std::distance(std::begin(temperatures), it)};

return std::nullopt);

这两个片段都返回了最后一个具有相对稳定温度的日子。你更愿意阅读哪一个?哪一个更容易理解?即使你现在对 C++算法不太熟悉,但在代码中遇到几次后,它们就会感觉比手工编写的循环更简单、更安全、更清晰。因为它们通常就是这样。

在 C++中编写声明性代码的第二种策略在前面的片段中已经有所体现。您应该优先使用声明性 API,比如来自 ranges 库的 API。虽然我们的片段中没有使用范围视图,但它们可以产生很大的不同。考虑以下片段:

using namespace std::ranges;

auto is_even = [](auto x) { return x % 2 == 0; };

auto to_string = [](auto x) { return std::to_string(x); };

auto my_range = views::iota(1)

    | views::filter(is_even)

    | views::take(2)
    | views::reverse

    | views::transform(to_string);

std::cout << std::accumulate(begin(my_range), end(my_range), ""s) << '\n';

这是声明性编码的一个很好的例子:你只需指定应该发生什么,而不是如何。前面的代码获取了前两个偶数,颠倒它们的顺序,并将它们打印为一个字符串,从而打印出了生活、宇宙和一切的著名答案:42。所有这些都是以一种直观和易于修改的方式完成的。

展示特色项目画廊

不过,玩具示例就到此为止。还记得我们在第三章中的多米尼加展会应用程序吗,功能和非功能需求?让我们编写一个组件,它将从客户保存为收藏夹的商店中选择并显示一些特色项目。例如,当我们编写移动应用程序时,这可能非常方便。

让我们从一个主要是 C++17 实现开始,然后在本章中将其更新为 C++20。这将包括添加对范围的支持。

首先,让我们从获取有关当前用户的信息的一些代码开始:

using CustomerId = int;


CustomerId get_current_customer_id() { return 42; }

现在,让我们添加商店所有者:

struct Merchant {

  int id;

};

商店也需要有商品:

struct Item {

  std::string name;

  std::optional<std::string> photo_url;

  std::string description;

  std::optional<float> price;

  time_point<system_clock> date_added{};

  bool featured{};

};

有些项目可能没有照片或价格,这就是为什么我们为这些字段使用了std::optional

接下来,让我们添加一些描述我们商品的代码:

std::ostream &operator<<(std::ostream &os, const Item &item) {

  auto stringify_optional = [](const auto &optional) {

    using optional_value_type =

        typename std::remove_cvref_t<decltype(optional)>::value_type;

    if constexpr (std::is_same_v<optional_value_type, std::string>) {

      return optional ? *optional : "missing";

    } else {

      return optional ? std::to_string(*optional) : "missing";

    }

  };


  auto time_added = system_clock::to_time_t(item.date_added);


  os << "name: " << item.name

     << ", photo_url: " << stringify_optional(item.photo_url)

     << ", description: " << item.description

     << ", price: " << std::setprecision(2) 
     << stringify_optional(item.price)

     << ", date_added: " 
     << std::put_time(std::localtime(&time_added), "%c %Z")

     << ", featured: " << item.featured;

  return os;

}

首先,我们创建了一个帮助 lambda,用于将我们的optionals转换为字符串。因为我们只想在我们的<<运算符中使用它,所以我们在其中定义了它。

请注意,我们使用了 C++14 的通用 lambda(auto 参数),以及 C++17 的constexpris_same_v类型特征,这样当我们处理可选的<string>时,我们就有了不同的实现。在 C++17 之前实现相同的功能需要编写带有重载的模板,导致代码更加复杂:

enum class Category {

  Food,

  Antiques,

  Books,

  Music,

  Photography,

  Handicraft,

  Artist,

};

最后,我们可以定义存储本身:

struct Store {

  gsl::not_null<const Merchant *> owner;

  std::vector<Item> items;

  std::vector<Category> categories;

};

这里值得注意的是使用了指南支持库中的gsl::not_null模板,这表明所有者将始终被设置。为什么不只使用一个普通的引用?因为我们可能希望我们的存储是可移动和可复制的。使用引用会妨碍这一点。

现在我们有了这些构建模块,让我们定义如何获取客户的收藏夹商店。为简单起见,让我们假设我们正在处理硬编码的商店和商家,而不是创建用于处理外部数据存储的代码。

首先,让我们为商店定义一个类型别名,并开始我们的函数定义:

using Stores = std::vector<gsl::not_null<const Store *>>;


Stores get_favorite_stores_for(const CustomerId &customer_id) {

接下来,让我们硬编码一些商家,如下所示:

  static const auto merchants = std::vector<Merchant>{{17}, {29}};

现在,让我们添加一个带有一些项目的商店,如下所示:

  static const auto stores = std::vector<Store>{

      {.owner = &merchants[0],

       .items =

           {

               {.name = "Honey",

                .photo_url = {},

                .description = "Straight outta Compton's apiary",

                .price = 9.99f,

                .date_added = system_clock::now(),

                .featured = false},

               {.name = "Oscypek",

                .photo_url = {},

                .description = "Tasty smoked cheese from the Tatra 
                                mountains",

                .price = 1.23f,

                .date_added = system_clock::now() - 1h,

                .featured = true},

           },

       .categories = {Category::Food}},

      // more stores can be found in the complete code on GitHub

  };

在这里,我们介绍了我们的第一个 C++20 特性。你可能不熟悉.field = value;的语法,除非你在 C99 或更新的版本中编码过。从 C++20 开始,您可以使用这种表示法(官方称为指定初始化器)来初始化聚合类型。它比 C99 更受限制,因为顺序很重要,尽管它还有一些其他较小的差异。没有这些初始化器,很难理解哪个值初始化了哪个字段。有了它们,代码更冗长,但即使对于不熟悉编程的人来说,更容易理解。

一旦我们定义了我们的商店,我们就可以编写函数的最后部分,这部分将执行实际的查找:

  static auto favorite_stores_by_customer =

      std::unordered_map<CustomerId, Stores>{{42, {&stores[0], &stores[1]}}};

  return favorite_stores_by_customer[customer_id];

}

现在我们有了我们的商店,让我们编写一些代码来获取这些商店的特色物品:

using Items = std::vector<gsl::not_null<const Item *>>;


Items get_featured_items_for_store(const Store &store) {

  auto featured = Items{};

  const auto &items = store.items;

  for (const auto &item : items) {

    if (item.featured) {

      featured.emplace_back(&item);

    }

  }

  return featured;

}

前面的代码是为了从一个商店获取物品。让我们还编写一个函数,从所有给定的商店获取物品:

Items get_all_featured_items(const Stores &stores) {

  auto all_featured = Items{};

  for (const auto &store : stores) {

    const auto featured_in_store = get_featured_items_for_store(*store);

    all_featured.reserve(all_featured.size() + featured_in_store.size());

    std::copy(std::begin(featured_in_store), std::end(featured_in_store),

              std::back_inserter(all_featured));

  }

  return all_featured;

}

前面的代码使用std::copy将元素插入向量,预先分配内存由保留调用。

现在我们有了一种获取有趣物品的方法,让我们按“新鲜度”对它们进行排序,以便最近添加的物品将首先显示:

void order_items_by_date_added(Items &items) {

  auto date_comparator = [](const auto &left, const auto &right) {

    return left->date_added > right->date_added;

  };

  std::sort(std::begin(items), std::end(items), date_comparator);

}

如您所见,我们利用了带有自定义比较器的std::sort。如果愿意,您也可以强制leftright的类型相同。为了以通用方式执行此操作,让我们使用另一个 C++20 特性:模板 lambda。让我们将它们应用于前面的代码:

void order_items_by_date_added(Items &items) {

  auto date_comparator = []<typename T>(const T &left, const T &right) {

    return left->date_added > right->date_added;

  };

  std::sort(std::begin(items), std::end(items), date_comparator);

}

lambda 的T类型将被推断,就像对于任何其他模板一样。

还缺少的最后两部分是实际的渲染代码和将所有内容粘合在一起的主函数。在我们的示例中,渲染将简单地打印到一个ostream

void render_item_gallery(const Items &items) {

  std::copy(

      std::begin(items), std::end(items),

      std::ostream_iterator<gsl::not_null<const Item *>>(std::cout, "\n"));

}

在我们的情况下,我们只是将每个元素复制到标准输出,并在元素之间插入一个换行符。使用copyostream_iterator允许您自己处理元素的分隔符。在某些情况下,这可能很方便;例如,如果您不希望在最后一个元素之后有逗号(或者在我们的情况下是换行符)。

最后,我们的主要函数将如下所示:

int main() {

  auto fav_stores = get_favorite_stores_for(get_current_customer_id());


  auto selected_items = get_all_featured_items(fav_stores);


  order_items_by_date_added(selected_items);


  render_item_gallery(selected_items);

}

看吧!随时运行代码,看看它如何打印我们的特色物品:

name: Handmade painted ceramic bowls, photo_url: http://example.com/beautiful_bowl.png, description: Hand-crafted and hand-decorated bowls made of fired clay, price: missing, date_added: Sun Jan  3 12:54:38 2021 CET, featured: 1

name: Oscypek, photo_url: missing, description: Tasty smoked cheese from the Tatra mountains, price: 1.230000, date_added: Sun Jan  3 12:06:38 2021 CET, featured: 1

现在我们已经完成了我们的基本实现,让我们看看如何通过使用 C++20 的一些新语言特性来改进它。

介绍标准范围

我们的第一个添加将是范围库。您可能还记得,它可以帮助我们实现优雅、简单和声明性的代码。为了简洁起见,首先,我们将引入ranges命名空间:

#include <ranges>


using namespace std::ranges;

我们将保留定义商家、物品和商店的代码不变。让我们通过使用get_featured_items_for_store函数开始我们的修改:

Items get_featured_items_for_store(const Store &store) {

  auto items = store.items | views::filter(&Item::featured) |

               views::transform([](const auto &item) {

                 return gsl::not_null<const Item *>(&item);

               });

  return Items(std::begin(items), std::end(items));

}

如您所见,将容器转换为范围很简单:只需将其传递给管道运算符。我们可以使用views::filter表达式而不是手工筛选特色元素的循环,将成员指针作为谓词传递。由于底层的std::invoke的魔法,这将正确地过滤掉所有具有我们的布尔数据成员设置为false的项目。

接下来,我们需要将每个项目转换为gsl::not_null指针,以便我们可以避免不必要的项目复制。最后,我们返回一个这样的指针向量,与我们的基本代码相同。

现在,让我们看看如何使用前面的函数来获取所有商店的特色物品:

Items get_all_featured_items(const Stores &stores) {

  auto all_featured = stores | views::transform([](auto elem) {

                        return get_featured_items_for_store(*elem);

                      });


  auto ret = Items{};

  for_each(all_featured, & {

    ret.reserve(ret.size() + elem.size());

    copy(elem, std::back_inserter(ret));

  });

  return ret;

}

在这里,我们从所有商店创建了一个范围,并使用我们在上一步中创建的函数进行了转换。因为我们需要先解引用每个元素,所以我们使用了一个辅助 lambda。视图是惰性评估的,因此每次转换只有在即将被消耗时才会执行。这有时可以节省大量的时间和计算:假设您只想要前 N 个项目,您可以跳过对get_featured_items_for_store的不必要调用。

一旦我们有了惰性视图,类似于我们的基本实现,我们可以在向量中保留空间,并从all_featured视图中的每个嵌套向量中将项目复制到那里。如果您使用整个容器,范围算法更简洁。看看copy不需要我们编写std::begin(elem)std::end(elem)

现在我们有了我们的物品,让我们通过使用范围来简化我们的排序代码来处理它们:

void order_items_by_date_added(Items &items) {

  sort(items, greater{}, &Item::date_added);

}

再次,您可以看到范围如何帮助您编写更简洁的代码。前面的复制和排序都是范围算法,而不是视图。它们是急切的,并允许您使用投影。在我们的情况下,我们只是传递了我们物品类的另一个成员,以便在排序时可以使用它进行比较。实际上,每个项目将被投影为其date_added,然后使用greater{}进行比较。

但等等 - 我们的 items 实际上是指向Itemgsl::not_null指针。这是如何工作的?事实证明,由于std::invoke的巧妙之处,我们的投影将首先解引用gsl::not_null指针。很巧妙!

我们可以进行的最后一个更改是在我们的“渲染”代码中:

void render_item_gallery([[maybe_unused]] const Items &items) {

  copy(items,

       std::ostream_iterator<gsl::not_null<const Item *>>(std::cout, "\n"));

}

在这里,范围只是帮助我们删除一些样板代码。

当您运行我们更新版本的代码时,您应该得到与基本情况相同的输出。

如果您期望范围比简洁的代码更多,那么有好消息:它们在我们的情况下甚至可以更有效地使用。

使用范围减少内存开销并提高性能

您已经知道,在std::ranges::views中使用惰性求值可以通过消除不必要的计算来提高性能。事实证明,我们还可以使用范围来减少我们示例中的内存开销。让我们重新审视一下从商店获取特色商品的代码。它可以缩短为以下内容:

auto get_featured_items_for_store(const Store &store) {

  return store.items | views::filter(&Item::featured) |

         views::transform(

             [](const auto &item) { return gsl::not_null(&item); });

}

请注意,我们的函数不再返回 items,而是依赖于 C++14 的自动返回类型推导。在我们的情况下,我们的代码将返回一个惰性视图,而不是返回一个向量。

让我们学习如何为所有商店使用这个:

Items get_all_featured_items(const Stores &stores) {

  auto all_featured = stores | views::transform([](auto elem) {

                        return get_featured_items_for_store(*elem);

                      }) |

                      views::join;

  auto as_items = Items{};

  as_items.reserve(distance(all_featured));

  copy(all_featured, std::back_inserter(as_items));

  return as_items;

}

现在,因为我们之前的函数返回的是一个视图而不是向量,在调用transform之后,我们得到了一个视图的视图。这意味着我们可以使用另一个标准视图,称为 join,将我们的嵌套视图合并成一个统一的视图。

接下来,我们使用std::ranges::distance在目标向量中预先分配空间,然后进行复制。有些范围是有大小的,这种情况下您可以调用std::ranges::size。最终的代码只调用了一次reserve,这应该给我们带来良好的性能提升。

这结束了我们对代码引入范围的介绍。由于我们在这一部分结束时提到了与性能相关的内容,让我们谈谈 C++编程这一方面的另一个重要主题。

将计算移动到编译时

从 21 世纪初现代 C++的出现开始,C++编程变得更多地关于在编译期间计算事物,而不是将它们推迟到运行时。在编译期间检测错误要比以后调试错误要便宜得多。同样,在程序启动之前准备好结果要比以后计算结果要快得多。

起初,有模板元编程,但是从 C++11 开始,每个新标准都为编译时计算带来了额外的功能:无论是类型特征、诸如std::enable_ifstd::void_t的构造,还是 C++20 的consteval用于仅在编译时计算的内容。

多年来改进的一个功能是constexpr关键字及其相关代码。C++20 真正改进并扩展了constexpr。现在,您不仅可以编写常规的简单constexpr函数,还可以在其中使用动态分配和异常,更不用说std::vectorstd::string了!

还有更多:甚至虚函数现在也可以是constexpr:重载分辨率照常进行,但如果给定的函数是constexpr,它可以在编译时调用。

标准算法也进行了另一个改进。它们的非并行版本都已准备好供您在编译时代码中使用。考虑以下示例,它可以用于检查容器中是否存在给定的商家:

#include <algorithm>

#include <array>


struct Merchant { int id; };


bool has_merchant(const Merchant &selected) {

  auto merchants = std::array{Merchant{1}, Merchant{2}, Merchant{3},

                              Merchant{4}, Merchant{5}};

  return std::binary_search(merchants.begin(), merchants.end(), selected,

                            [](auto a, auto b) { return a.id < b.id; });

}

正如您所看到的,我们正在对商家数组进行二进制搜索,按其 ID 排序。

为了深入了解代码及其性能,我们建议您快速查看此代码生成的汇编代码。随着编译时计算和性能追求的到来,开发的一个无价的工具之一是godbolt.org网站。它可以用于快速测试代码,以查看不同架构、编译器、标志、库版本和实现如何影响生成的汇编代码。

我们使用 GCC trunk(在 GCC 11 正式发布之前)进行了上述代码的测试,使用了-O3--std=c++2a。在我们的情况下,我们使用以下代码检查了生成的汇编代码:

int main() { return has_merchant({4}); }

您可以通过以下 Godbolt 查看几十行汇编代码:godbolt.org/z/PYMTYx

但是 - 您可能会说汇编中有一个函数调用,所以也许我们可以内联它,这样它可以更好地优化? 这是一个有效的观点。通常,这会有很大帮助,尽管现在,我们只是将汇编内联(参见:godbolt.org/z/hPadxd)。

现在,尝试将签名更改为以下内容:

constexpr bool has_merchant(const Merchant &selected) 

constexpr函数隐式地是内联的,因此我们删除了该关键字。如果我们查看汇编代码,我们会发现发生了一些魔法:搜索被优化掉了!正如您在godbolt.org/z/v3hj3E中所看到的,剩下的所有汇编代码如下:

main:

        mov     eax, 1

        ret

编译器优化了我们的代码,以便只剩下我们预先计算的结果被返回。这相当令人印象深刻,不是吗?

通过使用 const 来帮助编译器帮助您

编译器可以进行相当好的优化,即使您没有给它们inlineconstexpr关键字,就像前面的例子一样。帮助它们为您实现性能的一件事是将变量和函数标记为const。也许更重要的是,它还可以帮助您避免在代码中犯错误。许多语言默认具有不可变变量,这可以减少错误,使代码更易于理解,并且通常可以获得更快的多线程性能。

尽管 C++默认具有可变变量,并且您需要明确地输入const,但我们鼓励您这样做。它确实可以帮助您停止犯与修改变量有关的棘手拼写错误。

使用const(或constexpr)代码是类型安全哲学的一部分。让我们谈谈它。

利用安全类型的力量

C++在很大程度上依赖于帮助您编写类型安全代码的机制。诸如显式构造函数和转换运算符之类的语言构造已经被内置到语言中很长时间了。越来越多的安全类型被引入到标准库中。有optional可以帮助您避免引用空值,string_view可以帮助您避免超出范围,any作为任何类型的安全包装器,只是其中之一。此外,通过其零成本抽象,建议您创建自己的类型,这些类型既有用又难以被误用。

通常,使用 C 风格的结构可能会导致不安全的代码。一个例子是 C 风格的转换。它们可以解析为const_cast, static_cast, reinterpret_cast,或者这两者之一与const_cast的组合。意外写入const对象,这是const_cast是未定义行为。如果从reinterpret_cast<T>返回的内存读取,如果 T 不是对象的原始类型(C++20 的std::bit_cast可以在这里帮助),也是如此。如果使用 C++转换,这两种情况都更容易避免。

当涉及类型时,C 可能过于宽松。幸运的是,C++引入了许多类型安全的替代方案来解决问题 C 构造。有流和std::format代替printf等,有std::copy和其他类似的算法代替不安全的memcpy。最后,有模板代替接受void *的函数(并在性能方面付出代价)。在 C++中,通过一种叫做概念的特性,模板甚至可以获得更多的类型安全。让我们看看如何通过使用它们来改进我们的代码。

约束模板参数

概念可以改进代码的第一种方式是使其更加通用。你还记得那些需要在一个地方改变容器类型,导致其他地方也需要改变的情况吗?如果你没有改变容器到一个完全不同语义的容器,并且你需要以不同的方式使用它,那么你的代码可能不够通用。

另一方面,你是否曾经写过一个模板或在代码中使用auto,然后想知道如果有人改变了底层类型,你的代码是否会出错?

概念的关键在于对你正在操作的类型施加正确级别的约束。它们约束了你的模板可以匹配的类型,并且在编译时进行检查。例如,假设你写了以下代码:

template<typename T>

void foo(T& t) {...}

现在,你可以这样写:

void foo(std::swappable auto& t) {...}

在这里,foo()必须传递一个支持std::swap的类型才能工作。

你还记得有些模板匹配了太多类型的情况吗?以前,你可以使用std::enable_ifstd::void_tif constexpr来约束它们。然而,编写enable_if语句有点麻烦,可能会减慢编译时间。在这里,概念再次拯救了我们,因为它们的简洁性和清晰表达了它们的意图。

C++20 中有几十个标准概念。其中大部分位于<concepts>头文件中,可以分为四类:

  • 核心语言概念,比如derived_fromintegralswappablemove_constructible

  • 比较概念,比如boolean-testableequality_comparable_withtotally_ordered

  • 对象概念,比如movablecopyablesemiregularregular

  • 可调用的概念,比如invokablepredicatestrict_weak_order

其他的概念在<iterator>头文件中定义。这些可以分为以下几类:

  • 间接可调用的概念,比如indirect_binary_predicateindirectly_unary_invocable

  • 常见算法要求,比如indirectly_swappablepermutablemergeablesortable

最后,还有一些在<ranges>头文件中可以找到。例如range(duh)、contiguous_rangeview

如果这对你的需求还不够,你可以像标准定义我们刚刚涵盖的那些概念一样声明自己的概念。例如,movable概念的实现如下:

template <class T>
concept movable = std::is_object_v<T> && std::move_constructible<T> && std::assignable_from<T&, T> && std::swappable<T>;

此外,如果你查看std::swappable,你会看到以下内容:

template<class T>
concept swappable = requires(T& a, T& b) { ranges::swap(a, b); };

这意味着类型T如果ranges::swap(a, b)对这种类型的两个引用进行编译,则类型T将是swappable

在定义自己的概念时,一定要确保你满足了它们的语义要求。在定义接口时指定和使用概念是对接口的消费者做出的承诺。

通常,你可以在声明中使用所谓的简写符号以缩短代码:

void sink(std::movable auto& resource);

为了可读性和类型安全,建议你在约束类型时使用auto和概念一起使用,让你的读者知道他们正在处理的对象的类型。以这种方式编写的代码将保留类似于 auto 的通用性。你可以在常规函数和 lambda 中都使用这种方式。

使用概念的一个巨大优势是更短的错误消息。将几十行关于一个编译错误的代码减少到几行并不罕见。另一个好处是你可以在概念上进行重载。

现在,让我们回到我们的多米尼加展示的例子。这一次,我们将添加一些概念,看看它们如何改进我们的实现。

首先,让我们让get_all_featured_items只返回一系列项目。我们可以通过将概念添加到返回类型中来实现这一点,如下所示:

range auto get_all_featured_items(const Stores &stores);

到目前为止,一切都很顺利。现在,让我们为这种类型添加另一个要求,这个要求将在调用order_items_by_date_added时得到执行:我们的范围必须是可排序的。std::sortable已经为范围迭代器定义了,但为了方便起见,让我们定义一个名为sortable_range的新概念:

template <typename Range, typename Comp, typename Proj>

concept sortable_range =

    random_access_range<Range> &&std::sortable<iterator_t<Range>, Comp, Proj>;

与其标准库对应的是,我们可以接受一个比较器和一个投影(我们在范围中引入了它)。我们的概念由满足random_access_range概念的类型满足,以及具有满足前述可排序概念的迭代器。就是这么简单。

在定义概念时,您还可以使用requires子句来指定额外的约束。例如,如果您希望我们的范围仅存储具有date_added成员的元素,您可以编写以下内容:

template <typename Range, typename Comp>

concept sortable_indirectly_dated_range =

    random_access_range<Range> &&std::sortable<iterator_t<Range>, Comp> && requires(range_value_t<Range> v) { { v->date_added }; };

然而,在我们的情况下,我们不需要那么多地约束类型,因为当您使用概念并定义它们时,应该留下一些灵活性,这样重用它们才有意义。

这里重要的是,您可以使用requires子句来指定满足概念要求的类型上应该有效调用的代码。如果您愿意,您可以指定对每个子表达式返回的类型的约束;例如,要定义可递增的内容,您可以使用以下内容:

requires(I i) {

  { i++ } -> std::same_as<I>;

}

既然我们有了概念,让我们重新定义order_items_by_date_added函数:

void order_items_by_date_added(

    sortable_range<greater, decltype(&Item::date_added)> auto &items) {

  sort(items, greater{}, &Item::date_added);

}

现在,我们的编译器将检查我们传递给它的任何范围是否是可排序的,并且包含一个可以使用std::ranges::greater{}进行排序的date_added成员。

如果我们在这里使用更受限制的概念,函数将如下所示:

void order_items_by_date_added(

    sortable_indirectly_dated_range<greater> auto &items) {

  sort(items, greater{}, &Item::date_added);

}

最后,让我们重新设计我们的渲染函数:

template <input_range Container>

requires std::is_same_v<typename Container::value_type,

                        gsl::not_null<const Item *>> void

render_item_gallery(const Container &items) {

  copy(items,

       std::ostream_iterator<typename Container::value_type>(std::cout, "\n"));

}

在这里,您可以看到概念名称可以在模板声明中使用,而不是typename关键字。在这一行的下面,您可以看到requires关键字也可以用来根据其特征进一步约束适当的类型。如果您不想指定一个新的概念,这可能会很方便。

概念就是这样。现在,让我们写一些模块化的 C++代码。

编写模块化的 C++

我们将在本章中讨论的 C++的最后一个重要特性是模块。它们是 C++20 的又一个重要补充,对构建和分区代码有很大影响。

C++现在已经使用#include很长时间了。然而,这种文本形式的依赖包含有其缺陷,如下所列:

  • 由于需要处理大量文本(即使在预处理后,Hello World也有大约 50 万行代码),这很慢。这导致一次定义规则ODR)的违反。

  • 您的includes的顺序很重要,但不应该重要。这个问题比前一个问题严重了一倍,因为它还会导致循环依赖。

  • 最后,很难封装那些只需要在头文件中的东西。即使您将一些东西放在一个详细的命名空间中,也会有人使用它,正如海伦姆定律所预测的那样。

幸运的是,这就是模块进入游戏的时候。它们应该解决前面提到的缺陷,为构建时间带来巨大的加速,并在构建时提高 C++的可扩展性。使用模块,您只导出您想要导出的内容,这会带来良好的封装。依赖包含的特定顺序也不再是问题,因为导入的顺序不重要。

不幸的是,在撰写本文时,模块的编译器支持仍然只是部分完成。这就是为什么我们决定只展示 GCC 11 中已经可用的内容。遗憾的是,这意味着诸如模块分区之类的内容在这里不会涉及。

每个模块在编译后都将被编译成对象文件和模块接口文件。这意味着编译器不需要解析所有依赖项的文件,就可以快速知道给定模块包含的类型和函数。您只需要输入以下内容:

import my_module;

一旦my_module被编译并可用,您就可以使用它。模块本身应该在一个.cppm文件中定义,但目前 CMake 还不支持这一点。您最好暂时将它们命名为.cpp

话不多说,让我们回到我们多米尼加展会的例子,展示如何在实践中使用它们。

首先,让我们为客户代码创建我们的第一个模块,从以下指令开始:

module;

这个语句表示从这一点开始,这个模块中的所有内容都将是私有的。这是一个很好的放置包含和其他不会被导出的内容的地方。

接下来,我们必须指定导出模块的名称:

export module customer;

这将是我们稍后导入模块时要使用的名称。这行必须出现在导出的内容之前。现在,让我们指定我们的模块实际上将导出什么,使用export关键字给定义加上前缀:

export using CustomerId = int;


export CustomerId get_current_customer_id() { return 42; }

搞定了!我们的第一个模块已经准备好可以使用了。让我们为商家创建另一个模块:

module;


export module merchant;


export struct Merchant {

  int id;

};

与我们的第一个模块非常相似,这里我们指定了要导出的名称和类型(与第一个模块的类型别名和函数相反)。您也可以导出其他定义,比如模板。不过,对于宏来说会有些棘手,因为您需要导入<header_file>才能看到它们。

顺便说一句,模块的一个很大优势是它们不允许宏传播到导入的模块。这意味着当您编写以下代码时,模块不会定义MY_MACRO

#define MY_MACRO

import my_module;

模块中的确定性有助于保护您免受其他模块中代码的破坏。

现在,让我们为我们的商店和商品定义第三个模块。我们不会讨论导出其他函数、枚举和其他类型,因为这与前两个模块没有区别。有趣的是模块文件的开始方式。首先,让我们在私有模块部分包含我们需要的内容:

module;


#include <chrono>

#include <iomanip>

#include <optional>

#include <string>

#include <vector>

在 C++20 中,标准库头文件还不是模块,但这很可能会在不久的将来发生改变。

现在,让我们看看接下来会发生什么:

export module store;


export import merchant;

这是有趣的部分。我们的商店模块导入了我们之前定义的商家模块,然后将其重新导出为商店的接口的一部分。如果您的模块是其他模块的外观,这可能会很方便,比如在不久的将来的模块分区中(也是 C++20 的一部分)。一旦可用,您将能够将模块分割成多个文件。其中一个文件可以包含以下内容:

export module my_module:foo;


export template<typename T> foo() {}

正如我们之前讨论的,然后它将由您的模块的主文件导出如下:

export module my_module;


export import :foo;

这结束了模块和我们在本章计划的 C++的重要特性。让我们总结一下我们学到了什么。

总结

在本章中,我们学习了许多 C++特性及其对编写简洁、表达力强和高性能的 C++代码的影响。我们学习了如何提供适当的 C++组件接口。您现在可以应用诸如 RAII 之类的原则,编写优雅的、没有资源泄漏的代码。您还知道如何利用std::optional等类型在接口中更好地表达您的意图。

接下来,我们演示了如何使用通用和模板 lambda,以及if constexpr来编写能够适用于许多类型的少量代码。现在,您还可以使用指定的初始化程序以清晰的方式定义对象。

之后,您学会了如何使用标准范围以声明式风格编写简单的代码,如何编写可以在编译时和运行时执行的代码,以及如何使用概念编写更受限制的模板代码。

最后,我们演示了如何使用 C++模块编写模块化代码。在下一章中,我们将讨论如何设计 C++代码,以便我们可以建立在可用的习惯用法和模式之上。

问题

  1. 我们如何确保我们的代码将打开的每个文件在不再使用时都会关闭?

  2. 在 C++代码中何时应该使用“裸”指针?

  3. 什么是推导指南?

  4. 何时应该使用std::optionalgsl::not_null

  5. 范围算法与视图有何不同?

  6. 在定义函数时,除了指定概念的名称之外,如何通过其他方式约束类型?

  7. import Ximport <X>有何不同?

进一步阅读

第六章:设计模式和 C++

C++不仅仅是一种面向对象的语言,它不仅仅提供动态多态性,因此在 C++中设计不仅仅是关于四人帮的模式。在本章中,你将学习关于常用的 C++习语和设计模式以及它们的使用场景。

本章将涵盖以下主题:

  • 编写习惯用法的 C++

  • 奇异递归模板模式

  • 创建对象

  • 跟踪状态和访问对象在 C++中

  • 高效处理内存

这是一个相当长的列表!让我们不浪费时间,直接开始吧。

技术要求

本章的代码需要以下工具来构建和运行:

  • 支持 C++20 的编译器

  • CMake 3.15+

本章的源代码片段可以在github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter06找到。

编写习惯用法的 C++

如果你熟悉面向对象的编程语言,你一定听说过四人帮的设计模式。虽然它们可以在 C++中实现(而且经常被实现),但这种多范式语言通常采用不同的方法来实现相同的目标。如果你想要超越 Java 或 C#等所谓的基于咖啡的语言的性能,有时付出虚拟调度的代价太大了。在许多情况下,你会提前知道你将处理的类型。如果发生这种情况,你通常可以使用语言和标准库中提供的工具编写更高性能的代码。其中有一个我们将从本章开始的一组 - 语言习语。让我们通过查看其中一些来开始我们的旅程。

根据定义,习语是在特定语言中反复出现的构造,是特定于该语言的表达。C++的“母语者”应该凭直觉知道它的习语。我们已经提到智能指针,这是最常见的之一。现在让我们讨论一个类似的。

使用 RAII 保护自动化作用域退出操作

C++中最强大的表达之一是用于关闭作用域的大括号。这是析构函数被调用和 RAII 魔术发生的地方。为了驯服这个咒语,你不需要使用智能指针。你只需要一个 RAII 保护 - 一个对象,当构造时,将记住它在销毁时需要做什么。这样,无论作用域是正常退出还是由异常退出,工作都会自动发生。

最好的部分 - 你甚至不需要从头开始编写一个 RAII 保护。经过充分测试的实现已经存在于各种库中。如果你使用我们在上一章中提到的 GSL,你可以使用gsl::finally()。考虑以下例子:

using namespace std::chrono;


void self_measuring_function() {

  auto timestamp_begin = high_resolution_clock::now();


  auto cleanup = gsl::finally([timestamp_begin] {

    auto timestamp_end = high_resolution_clock::now();

    std::cout << "Execution took: " << duration_cast<microseconds>(timestamp_end - timestamp_begin).count() << " us";

  });
  // perform work

  // throw std::runtime_error{"Unexpected fault"};

}

在这里,我们在函数开始时取一个时间戳,然后在结束时再取一个。尝试运行这个例子,看看取消注释throw语句如何影响执行。在这两种情况下,我们的 RAII 保护将正确打印执行时间(假设异常在某处被捕获)。

现在让我们讨论一些更流行的 C++习语。

管理可复制性和可移动性

在 C++中设计新类型时,重要的是决定它是否可以复制和移动。更重要的是正确实现类的语义。现在让我们讨论这些问题。

实现不可复制类型

有些情况下,你不希望你的类被复制。非常昂贵的复制类是一个例子。另一个例子是由于切片而导致错误的类。过去,防止这些对象复制的常见方法是使用不可复制的习语:

struct Noncopyable {

  Noncopyable() = default;

  Noncopyable(const Noncopyable&) = delete;

  Noncopyable& operator=(const Noncopyable&) = delete;

};


class MyType : NonCopyable {};

然而,请注意,这样的类也是不可移动的,尽管在阅读类定义时很容易忽略这一点。更好的方法是明确地添加两个缺失的成员(移动构造函数和移动赋值运算符)。作为一个经验法则,当声明这样的特殊成员函数时,总是声明所有这些函数。这意味着从 C++11 开始,首选的方法是编写以下内容:

struct MyTypeV2 {

  MyTypeV2() = default;

  MyTypeV2(const MyTypeV2 &) = delete;

  MyTypeV2 & operator=(const MyTypeV2 &) = delete;

  MyTypeV2(MyTypeV2 &&) = delete;

  MyTypeV2 & operator=(MyTypeV2 &&) = delete;

};

这一次,成员是直接在目标类型中定义的,而没有辅助的NonCopyable类型。

遵循三和五法则

在讨论特殊成员函数时,还有一件事需要提到:如果您不删除它们并提供自己的实现,很可能需要定义所有这些函数,包括析构函数。在 C++98 中,这被称为三法则(由于需要定义三个函数:复制构造函数、复制赋值运算符和析构函数),自 C++11 的移动操作以来,它现在被五法则取代(另外两个是移动构造函数和移动赋值运算符)。应用这些规则可以帮助您避免资源管理问题。

遵循零法则

另一方面,如果您只使用所有特殊成员函数的默认实现,那么根本不要声明它们。这清楚地表明您想要默认行为。这也是最不令人困惑的。考虑以下类型:

class PotentiallyMisleading {

public:

  PotentiallyMisleading() = default;

  PotentiallyMisleading(const PotentiallyMisleading &) = default;

  PotentiallyMisleading &operator=(const PotentiallyMisleading &) = default;

  PotentiallyMisleading(PotentiallyMisleading &&) = default;

  PotentiallyMisleading &operator=(PotentiallyMisleading &&) = default;

  ~PotentiallyMisleading() = default;


private:

  std::unique_ptr<int> int_;

};

尽管我们默认了所有成员,但这个类仍然是不可复制的。这是因为它有一个unique_ptr成员,它本身是不可复制的。幸运的是,Clang 会警告您,但 GCC 默认情况下不会。更好的方法是应用零规则,而不是写以下内容:

class RuleOfZero {

  std::unique_ptr<int> int_;

};

现在我们有了更少的样板代码,并且通过查看成员,更容易注意到它不支持复制。

在讨论复制时,还有一个重要的习惯用法需要了解,您将在一分钟内了解到。在此之前,我们将涉及另一个习惯用法,可以(并且应该)用于实现第一个习惯用法。

使用隐藏友元

实质上,隐藏的友元是在声明它们为友元的类型的主体中定义的非成员函数。这使得这样的函数无法通过其他方式调用,而只能通过参数相关查找ADL)来调用,有效地使它们隐藏起来。因为它们减少了编译器考虑的重载数量,它们也加快了编译速度。这样做的额外好处是,它们提供比其替代品更短的错误消息。它们的最后一个有趣的特性是,如果应该首先发生隐式转换,它们就不能被调用。这可以帮助您避免这种意外转换。

尽管在 C++中通常不建议使用友元,但对于隐藏的友元,情况看起来不同;如果前面段落中的优势不能说服您,您还应该知道,它们应该是实现定制点的首选方式。现在,您可能想知道这些定制点是什么。简而言之,它们是库代码使用的可调用对象,用户可以为其类型进行专门化。标准库为这些保留了相当多的名称,例如beginend及其反向和const变体,swap(s)size(c)data和许多运算符,等等。如果您决定为任何这些定制点提供自己的实现,最好是符合标准库的期望。

好了,现在理论够了。让我们看看如何在实践中使用隐藏的友元来提供定制点专门化。例如,让我们创建一个过于简化的类来管理类型的数组:

template <typename T> class Array {

public:

  Array(T *array, int size) : array_{array}, size_{size} {}


  ~Array() { delete[] array_; }


  T &operator[](int index) { return array_[index]; }

  int size() const { return size_; }

  friend void swap(Array &left, Array &right) noexcept {
    using std::swap;
    swap(left.array_, right.array_);
    swap(left.size_, right.size_);
  }


private:

  T *array_;

  int size_;

};

正如您所看到的,我们定义了一个析构函数,这意味着我们还应该提供其他特殊成员函数。我们将在下一节中使用我们隐藏的友元swap来实现它们。请注意,尽管在我们的Array类的主体中声明,但这个swap函数仍然是一个非成员函数。它接受两个Array实例,并且没有访问权限。

使用std::swap行使编译器首先在交换成员的命名空间中查找swap函数。如果找不到,它将退回到std::swap。这被称为“两步 ADL 和回退惯用语”,或简称为“两步”,因为我们首先使std::swap可见,然后调用swapnoexcept关键字告诉编译器我们的swap函数不会抛出异常,这允许它在某些情况下生成更快的代码。除了swap,出于同样的原因,始终使用这个关键字标记默认和移动构造函数。

既然我们有一个swap函数,让我们使用它来应用另一个惯用语到我们的Array类。

使用复制和交换惯用语提供异常安全性

正如我们在上一节中提到的,因为我们的Array类定义了一个析构函数,根据五法则,它还应该定义其他特殊成员函数。在本节中,您将了解一种惯用语,让我们可以在没有样板文件的情况下做到这一点,同时还额外提供强异常安全性。

如果您不熟悉异常安全级别,这里是您的函数和类型可以提供的级别的快速回顾:

  • 无保证:这是最基本的级别。在对象在使用时抛出异常后,不对其状态做任何保证。

  • 基本异常安全性:可能会有副作用,但您的对象不会泄漏任何资源,将处于有效状态,并且将包含有效数据(不一定与操作之前相同)。您的类型应该至少提供这个级别。

  • 强异常安全性:不会发生任何副作用。对象的状态将与操作之前相同。

  • 无抛出保证:操作将始终成功。如果在操作期间抛出异常,它将被内部捕获和处理,因此操作不会在外部抛出异常。此类操作可以标记为noexcept

那么,我们如何一举两得地写出无样板文件的特殊成员,并提供强异常安全性呢?实际上很容易。由于我们有我们的swap函数,让我们使用它来实现赋值运算符:

  Array &operator=(Array other) noexcept {

    swap(*this, other);

    return *this;

  }

在我们的情况下,一个运算符就足够了,既适用于复制赋值,也适用于移动赋值。在复制的情况下,我们通过值来获取参数,因此这是临时复制正在进行的地方。然后,我们所需要做的就是交换成员。我们不仅实现了强异常安全性,而且还能够在赋值运算符的主体中不抛出异常。然而,在函数被调用之前,当复制发生时,仍然可能抛出异常。在移动赋值的情况下,不会进行复制,因为通过值获取将只获取移动的对象。

现在,让我们定义复制构造函数:

  Array(const Array &other) : array_{new T[other.size_]}, size_{other.size_} {

    std::copy_n(other.array_, size_, array_);

  }

这个函数可以根据T和分配内存而抛出异常。现在,让我们也定义移动构造函数:

  Array(Array &&other) noexcept

      : array_{std::exchange(other.array_, nullptr)}, size_{std::exchange(other.size_, 0)} {}

在这里,我们使用std::exchange来初始化我们的成员,并在初始化列表上清理other的成员。构造函数声明为noexcept是出于性能原因。例如,如果std::vector只在移动构造时是noexcept可移动的,否则将进行复制。

就是这样。我们创建了一个提供强异常安全性的array类,而且几乎没有代码重复。

现在,让我们来解决另一个 C++惯用语,它可以在标准库的几个地方找到。

编写 niebloids

Niebloids,以 Eric Niebler 的名字命名,是 C++17 及以后标准使用的一种函数对象类型,用于定制点。随着标准范围的引入,它们的流行度开始增长,但它们最早是在 2014 年由 Niebler 提出的。它们的目的是在不需要时禁用 ADL,因此编译器不会考虑来自其他命名空间的重载。还记得前面章节中的两步法吗?由于它不方便且容易忘记,所以引入了定制点对象的概念。本质上,这些是为您执行两步法的函数对象。

如果您的库应该提供定制点,最好使用 niebloids 来实现它们。C++17 及以后引入的标准库中的所有定制点都是出于某种原因以这种方式实现的。即使您只需要创建一个函数对象,仍然要考虑使用 niebloids。它们提供了 ADL 的所有优点,同时减少了缺点。它们允许特化,并且与概念一起,它们为您提供了一种定制可调用函数重载集合的方法。它们还允许更好地定制算法,只是写的代码比通常多一点。

在这一部分,我们将创建一个简单的范围算法,我们将其实现为 niebloid。让我们称之为contains,因为它将简单地返回一个布尔值,表示范围中是否找到了给定的元素。首先,让我们创建函数对象本身,从其基于迭代器的调用操作符的声明开始:

namespace detail {

struct contains_fn final {

  template <std::input_iterator It, std::sentinel_for<It> Sent, typename T,

            typename Proj = std::identity>

  requires std::indirect_binary_predicate<

      std::ranges::equal_to, std::projected<It, Proj>, const T *> constexpr bool

  operator()(It first, Sent last, const T &value, Proj projection = {}) const {

看起来冗长,但所有这些代码都有其目的。我们使我们的结构final以帮助编译器生成更高效的代码。如果您查看模板参数,您会看到迭代器和哨兵 - 每个标准范围的基本构建块。哨兵通常是一个迭代器,但它可以是任何可以与迭代器比较的半正则类型(半正则类型是可复制和默认可初始化的)。接下来,T是要搜索的元素类型,而Proj表示投影 - 在比较之前对每个范围元素应用的操作(std::identity的默认值只是将其输入作为输出传递)。

在模板参数之后,有它们的要求;操作符要求我们可以比较投影值和搜索值是否相等。在这些约束之后,我们只需指定函数参数。

现在让我们看看它是如何实现的:

    while (first != last && std::invoke(projection, *first) != value)

      ++first;

    return first != last;

  }

在这里,我们只是遍历元素,对每个元素调用投影并将其与搜索值进行比较。如果找到则返回true,否则返回false(当first == last时)。

即使我们没有使用标准范围,前面的函数也可以工作;我们还需要为范围重载。它的声明可以如下所示:

  template <std::ranges::input_range Range, typename T,

            typename Proj = std::identity>

  requires std::indirect_binary_predicate<

      std::ranges::equal_to,

      std::projected<std::ranges::iterator_t<Range>, Proj>,

      const T *> constexpr bool

  operator()(Range &&range, const T &value, Proj projection = {}) const {

这一次,我们使用满足input_range概念的类型,元素值和投影类型作为模板参数。我们要求在调用投影后,范围的迭代器可以与类型为T的对象进行比较,与之前类似。最后,我们使用范围、值和投影作为我们重载的参数。

这个操作符的主体也会非常简单:

    return (*this)(std::ranges::begin(range), std::ranges::end(range), value,

                   std::move(projection));

  }

};

}  // namespace detail

我们只需使用给定范围的迭代器和哨兵调用先前的重载,同时传递值和我们的投影不变。现在,对于最后一部分,我们需要提供一个contains niebloid,而不仅仅是contains_fn可调用:

inline constexpr detail::contains_fn contains{};

通过声明一个名为contains的内联变量,类型为contains_fn,我们允许任何人使用变量名调用我们的 niebloid。现在,让我们自己调用它看看它是否有效:

int main() {

  auto ints = std::ranges::views::iota(0) | std::ranges::views::take(5);


  return contains(ints, 42);

}

就是这样。我们的抑制 ADL 的函数符合预期工作。

如果你认为所有这些都有点啰嗦,那么你可能会对tag_invoke感兴趣,它可能会在将来的某个时候成为标准的一部分。请参考进一步阅读部分,了解有关这个主题的论文和 YouTube 视频,其中详细解释了 ADL、niebloids、隐藏的友元和tag_invoke

现在让我们转向另一个有用的 C++习惯用法。

基于策略的设计模式

基于策略的设计最初是由 Andrei Alexandrescu 在他出色的现代 C++设计书中引入的。尽管该书于 2001 年出版,但其中许多想法今天仍在使用。我们建议阅读它;你可以在本章末尾的进一步阅读部分找到它的链接。策略习惯用法基本上是 Gang of Four 的策略模式的编译时等价物。如果您需要编写一个具有可定制行为的类,您可以将其作为模板与适当的策略作为模板参数。这在实际中的一个例子可能是标准分配器,作为最后一个模板参数传递给许多 C++容器作为策略。

让我们回到我们的Array类,并为调试打印添加一个策略:

template <typename T, typename DebugPrintingPolicy = NullPrintingPolicy>

class Array {

如你所见,我们可以使用一个不会打印任何东西的默认策略。NullPrintingPolicy可以实现如下:

struct NullPrintingPolicy {

  template <typename... Args> void operator()(Args...) {}

};

如你所见,无论给定什么参数,它都不会做任何事情。编译器会完全优化它,因此在不使用调试打印功能时不会产生任何开销。

如果我们希望我们的类更加冗长,我们可以使用不同的策略:

struct CoutPrintingPolicy {

  void operator()(std::string_view text) { std::cout << text << std::endl; }

};

这次,我们只需将传递给策略的文本打印到cout。我们还需要修改我们的类来实际使用我们的策略:

  Array(T *array, int size) : array_{array}, size_{size} {

    DebugPrintingPolicy{}("constructor");

  }


  Array(const Array &other) : array_{new T[other.size_]}, size_{other.size_} {

    DebugPrintingPolicy{}("copy constructor");

    std::copy_n(other.array_, size_, array_);

  }


  // ... other members ... 

我们只需调用策略的operator(),将要打印的文本传递进去。由于我们的策略是无状态的,我们可以在需要使用它时每次实例化它,而不会产生额外的成本。另一种选择也可以是直接从中调用静态函数。

现在,我们只需要用所需的策略实例化我们的Array类并使用它:

Array<T, CoutPrintingPolicy>(new T[size], size);

使用编译时策略的一个缺点是使用不同策略的模板实例化是不同类型的。这意味着需要更多的工作,例如从常规的Array类分配到具有CoutPrintingPolicy的类。为此,您需要将策略作为模板参数实现赋值运算符作为模板函数。

有时,使用特征作为使用策略的替代方案。例如,std::iterator_traits可以用于在编写使用迭代器的算法时使用有关迭代器的各种信息。例如,std::iterator_traits<T>::value_type可以适用于定义了value_type成员的自定义迭代器,以及简单的迭代器,比如指针(在这种情况下,value_type将指向被指向的类型)。

关于基于策略的设计就说这么多。接下来我们要讨论的是一个可以应用于多种情景的强大习惯用法。

奇异递归模板模式

尽管它的名字中有模式一词,奇异递归模板模式CRTP)是 C++中的一种习惯用法。它可以用于实现其他习惯用法和设计模式,并应用静态多态性,等等。让我们从最后一个开始,因为我们稍后会涵盖其他内容。

了解何时使用动态多态性与静态多态性

在提到多态性时,许多程序员会想到动态多态性,其中执行函数调用所需的信息在运行时收集。与此相反,静态多态性是关于在编译时确定调用的。前者的优势在于你可以在运行时修改类型列表,允许通过插件和库扩展你的类层次结构。后者的优势在于,如果你提前知道类型,它可以获得更好的性能。当然,在第一种情况下,你有时可以期望编译器去虚拟化你的调用,但你不能总是指望它这样做。然而,在第二种情况下,你可以获得更长的编译时间。

看起来你不能在所有情况下都赢。不过,为你的类型选择正确的多态类型可以走很远。如果性能受到影响,我们强烈建议你考虑静态多态性。CRTP 是一种可以用来应用它的习惯用法。

许多设计模式可以以一种或另一种方式实现。由于动态多态性的成本并不总是值得的,四人帮设计模式在 C++中通常不是最好的解决方案。如果你的类型层次结构应该在运行时扩展,或者编译时间对你来说比性能更重要(而且你不打算很快使用模块),那么四人帮模式的经典实现可能是一个很好的选择。否则,你可以尝试使用静态多态性来实现它们,或者通过应用更简单的面向 C++的解决方案,其中我们在本章中描述了一些。关键是选择最适合工作的工具。

实现静态多态性

现在让我们实现我们的静态多态类层次结构。我们需要一个基本模板类:

template <typename ConcreteItem> class GlamorousItem {

public:

  void appear_in_full_glory() {

    static_cast<ConcreteItem *>(this)->appear_in_full_glory();

  }

};

基类的模板参数是派生类。这一开始可能看起来很奇怪,但它允许我们在我们的接口函数中static_cast到正确的类型,这种情况下,命名为appear_in_full_glory。然后我们在派生类中调用这个函数的实现。派生类可以这样实现:

class PinkHeels : public GlamorousItem<PinkHeels> {

public:

  void appear_in_full_glory() {

    std::cout << "Pink high heels suddenly appeared in all their beauty\n";

  }

};


class GoldenWatch : public GlamorousItem<GoldenWatch> {

public:

  void appear_in_full_glory() {

    std::cout << "Everyone wanted to watch this watch\n";

  }

};

这些类中的每一个都使用自身作为模板参数从我们的GlamorousItem基类派生。每个也实现了所需的函数。

请注意,与动态多态性相反,CRTP 中的基类是一个模板,因此你将为你的派生类得到不同的基本类型。这意味着你不能轻松地创建一个GlamorousItem基类的容器。然而,你可以做一些事情:

  • 将它们存储在一个元组中。

  • 创建你的派生类的std::variant

  • 添加一个通用类来包装所有Base的实例化。你也可以为这个使用一个变体。

在第一种情况下,我们可以按照以下方式使用该类。首先,创建base实例的元组:

template <typename... Args>

using PreciousItems = std::tuple<GlamorousItem<Args>...>;


auto glamorous_items = PreciousItems<PinkHeels, GoldenWatch>{};

我们的类型别名元组将能够存储任何迷人的物品。现在,我们需要做的就是调用有趣的函数:

  std::apply(

      []<typename... T>(GlamorousItem<T>... items) {    
          (items.appear_in_full_glory(), ...); },

      glamorous_items);

因为我们试图迭代一个元组,最简单的方法是调用std::apply,它在给定元组的所有元素上调用给定的可调用对象。在我们的情况下,可调用对象是一个只接受GlamorousItem基类的 lambda。我们使用 C++17 引入的折叠表达式来确保我们的函数将被所有元素调用。

如果我们要使用变体而不是元组,我们需要使用std::visit,就像这样:

  using GlamorousVariant = std::variant<PinkHeels, GoldenWatch>;

  auto glamorous_items = std::array{GlamorousVariant{PinkHeels{}}, GlamorousVariant{GoldenWatch{}}};

  for (auto& elem : glamorous_items) {

    std::visit([]<typename T>(GlamorousItem<T> item){ item.appear_in_full_glory(); }, elem);

  }

std::visit函数基本上接受变体并在其中存储的对象上调用传递的 lambda。在这里,我们创建了一个我们迷人变体的数组,所以我们可以像对待任何其他容器一样迭代它,用适当的 lambda 访问每个变体。

如果你觉得从接口用户的角度来写不直观,考虑下一种方法,将变体包装到另一个类中,我们这里称为CommonGlamorousItem

class CommonGlamorousItem {

public:

  template <typename T> requires std::is_base_of_v<GlamorousItem<T>, T>

  explicit CommonGlamorousItem(T &&item)

      : item_{std::forward<T>(item)} {}

private:

  GlamorousVariant item_;

};

为了构造我们的包装器,我们使用了一个转发构造函数(templated T&&是它的参数)。然后我们转发而不是移动来创建item_包装变体,因为这样我们只移动了右值输入。我们还约束了模板参数,因此一方面,我们只包装GlamorousItem基类,另一方面,我们的模板不会被用作移动或复制构造函数。

我们还需要包装我们的成员函数:

  void appear_in_full_glory() {

    std::visit(

        []<typename T>(GlamorousItem<T> item) { 
            item.appear_in_full_glory(); },

        item_);

  }

这次,std::visit调用是一个实现细节。用户可以以以下方式使用这个包装器类:

auto glamorous_items = std::array{CommonGlamorousItem{PinkHeels{}},

                                  CommonGlamorousItem{GoldenWatch{}}};

    for (auto& elem : glamorous_items) {

      elem.appear_in_full_glory();

    }

这种方法让类的使用者编写易于理解的代码,但仍然保持了静态多态性的性能。

为了提供类似的用户体验,尽管性能较差,您也可以使用一种称为类型擦除的技术,我们将在下面讨论。

插曲-使用类型擦除

尽管类型擦除与 CRTP 无关,但它与我们当前的示例非常契合,这就是为什么我们在这里展示它的原因。

类型擦除习惯是关于在多态接口下隐藏具体类型。这种方法的一个很好的例子可以在 Sean Parent 的演讲Inheritance Is The Base Class of Evil中找到,这是GoingNative 2013会议上的一个很好的例子。我们强烈建议您在空闲时间观看它;您可以在进一步阅读部分找到它的链接。在标准库中,您可以在std::functionstd::shared_ptr的删除器或std::any等中找到它。

使用方便和灵活性是有代价的-这种习惯用法需要使用指针和虚拟分发,这使得标准库中提到的实用程序在性能导向的用例中使用起来不好。小心。

为了将类型擦除引入我们的示例中,我们不再需要 CRTP。这次,我们的GlamorousItem类将使用智能指针来包装动态多态对象。

class GlamorousItem {

public:

  template <typename T>

  explicit GlamorousItem(T t)

      : item_{std::make_unique<TypeErasedItem<T>>(std::move(t))} {}


  void appear_in_full_glory() { item_->appear_in_full_glory_impl(); }


private:

  std::unique_ptr<TypeErasedItemBase> item_;

};  

这次,我们存储了一个指向基类(TypeErasedItemBase)的指针,它将指向我们项目的派生包装器(TypeErasedItem<T>)。基类可以定义如下:

  struct TypeErasedItemBase {

    virtual ~TypeErasedItemBase() = default;

    virtual void appear_in_full_glory_impl() = 0;

  };

每个派生的包装器也需要实现这个接口:

  template <typename T> class TypeErasedItem final : public TypeErasedItemBase {

  public:

    explicit TypeErasedItem(T t) : t_{std::move(t)} {}

    void appear_in_full_glory_impl() override { t_.appear_in_full_glory(); }


  private:

    T t_;

  };

通过调用包装对象的函数来实现基类的接口。请注意,这种习惯用法被称为“类型擦除”,因为GlamorousItem类不知道它实际包装的是什么T。当项目被构造时,information类型被擦除了,但这一切都能正常工作,因为T实现了所需的方法。

具体的项目可以以更简单的方式实现,如下所示:

class PinkHeels {

public:

  void appear_in_full_glory() {

    std::cout << "Pink high heels suddenly appeared in all their beauty\n";

  }

};


class GoldenWatch {

public:

  void appear_in_full_glory() {

    std::cout << "Everyone wanted to watch this watch\n";

  }

};

这次,它们不需要继承任何基类。我们只需要鸭子类型-如果它像鸭子一样嘎嘎叫,那么它可能是一只鸭子。如果它可以以全荣耀出现,那么它可能是迷人的。

我们的类型擦除 API 可以如下使用:

  auto glamorous_items =

      std::array{GlamorousItem{PinkHeels{}}, GlamorousItem{GoldenWatch{}}};

  for (auto &item : glamorous_items) {

    item.appear_in_full_glory();

  }

我们只需创建一个包装器数组,并对其进行迭代,所有这些都使用简单的基于值的语义。我们发现这是最愉快的使用方式,因为多态性对调用者来说是作为实现细节隐藏的。

然而,这种方法的一个很大的缺点是,正如我们之前提到的,性能较差。类型擦除是有代价的,因此应该谨慎使用,绝对不要在热路径中使用。

现在我们已经描述了如何包装和擦除类型,让我们转而讨论如何创建它们。

创建对象

在本节中,我们将讨论与对象创建相关的常见问题的解决方案。我们将讨论各种类型的对象工厂,通过构建者,并涉及组合和原型。然而,我们将采用与四人帮在描述他们的解决方案时略有不同的方法。他们提出了复杂的、动态多态的类层次结构作为他们模式的适当实现。在 C++世界中,许多模式可以应用于现实世界的问题,而不引入太多的类和动态分派的开销。这就是为什么在我们的情况下,实现将是不同的,在许多情况下更简单或更高效(尽管在四人帮的意义上更专业化和不那么“通用”)。让我们马上开始。

使用工厂

我们将在这里讨论的第一种创建模式是工厂。当对象的构造可以在单个步骤中完成时(如果不能在工厂之后立即完成的模式很有用),但构造函数本身并不够好时,它们是有用的。有三种类型的工厂-工厂方法、工厂函数和工厂类。让我们依次介绍它们。

使用工厂方法

工厂方法,也称为“命名构造函数惯用法”,基本上是调用私有构造函数的成员函数。我们什么时候使用它们?以下是一些情况:

  • 当有许多不同的方法来构造一个对象,这可能会导致错误。例如,想象一下构造一个用于存储给定像素的不同颜色通道的类;每个通道由一个字节值表示。仅使用构造函数会使得很容易传递错误的通道顺序,或者值是为完全不同的调色板而设计的。此外,切换像素的颜色内部表示会变得非常棘手。你可以说我们应该有不同类型来表示这些不同格式的颜色,但通常,使用工厂方法也是一个有效的方法。

  • 当你想要强制对象在堆上或在另一个特定的内存区域中创建。如果你的对象在堆栈上占用大量空间,而你担心会用尽堆栈内存,使用工厂方法是一个解决方案。如果你要求所有实例都在设备上的某个内存区域中创建,也是一样。

  • 当构造对象可能失败,但你不能抛出异常。你应该使用异常而不是其他错误处理方法。当使用正确时,它们可以产生更清洁和性能更好的代码。然而,一些项目或环境要求禁用异常。在这种情况下,使用工厂方法将允许您报告在构造过程中发生的错误。

我们描述的第一种情况的工厂方法可能如下所示:

class Pixel {

public:

  static Pixel fromRgba(char r, char b, char g, char a) {

    return Pixel{r, g, b, a};

  }

  static Pixel fromBgra(char b, char g, char r, char a) {

    return Pixel{r, g, b, a};

  }


  // other members


private:

  Pixel(char r, char g, char b, char a) : r_(r), g_(g), b_(b), a_(a) {}

  char r_, g_, b_, a_;

}

这个类有两个工厂方法(实际上,C++标准不承认术语“方法”,而是称它们为“成员函数”):fromRgbafromBgra。现在更难出错并以错误的顺序初始化通道。

请注意,拥有私有构造函数实际上会阻止任何类从您的类型继承,因为没有访问其构造函数,就无法创建实例。然而,如果这是您的目标而不是副作用,您应该更喜欢将您的类标记为最终。

使用工厂函数

与使用工厂成员函数相反,我们也可以使用非成员函数来实现它们。这样,我们可以提供更好的封装,正如 Scott Meyers 在他的文章中所描述的。

在我们的Pixel的情况下,我们也可以创建一个自由函数来制造它的实例。这样,我们的类型可以有更简单的代码:

struct Pixel {

  char r, g, b, a;

};


Pixel makePixelFromRgba(char r, char b, char g, char a) {

  return Pixel{r, g, b, a};

}


Pixel makePixelFromBgra(char b, char g, char r, char a) {

  return Pixel{r, g, b, a};

}

使用这种方法使我们的设计符合第一章,软件架构的重要性和优秀设计原则中描述的开闭原则。可以很容易地添加更多的工厂函数来处理其他颜色调色板,而无需修改Pixel结构本身。

这种Pixel的实现允许用户手动初始化它,而不是使用我们提供的函数之一。如果我们希望,可以通过更改类声明来禁止这种行为。修复后的样子如下:

struct Pixel {

  char r, g, b, a;


private:

  Pixel(char r, char g, char b, char a) : r(r), g(g), b(b), a(a) {}

  friend Pixel makePixelFromRgba(char r, char g, char b, char a);

  friend Pixel makePixelFromBgra(char b, char g, char r, char a);

};

这一次,我们的工厂函数是我们类的朋友。然而,类型不再是一个聚合,所以我们不能再使用聚合初始化(Pixel{}),包括指定的初始化器。此外,我们放弃了开闭原则。这两种方法提供了不同的权衡,所以要明智选择。

选择工厂的返回类型

在实现对象工厂时,您还应该选择它应该返回的实际类型。让我们讨论各种方法。

对于Pixel这种值类型而不是多态类型的情况,最简单的方法效果最好——我们只需返回值。如果您生成多态类型,请使用智能指针返回它(永远不要使用裸指针,因为这将在某个时候导致内存泄漏)。如果调用者应该拥有创建的对象,通常将其返回到基类的unique_ptr中是最好的方法。在不太常见的情况下,您的工厂和调用者都必须拥有对象时,使用shared_ptr或其他引用计数的替代方案。有时,工厂跟踪对象但不存储它就足够了。在这种情况下,在工厂内部存储weak_ptr,在外部返回shared_ptr

一些 C++程序员会认为您应该使用输出参数返回特定类型,但在大多数情况下,这不是最佳方法。在性能方面,按值返回通常是最佳选择,因为编译器不会对对象进行额外的复制。如果问题是类型不可复制,从 C++17 开始,标准规定了复制省略是强制性的,因此通常按值返回这些类型不是问题。如果您的函数返回多个对象,请使用 pair、tuple、struct 或容器。

如果在构建过程中出现问题,您有几种选择:

  • 如果不需要向调用者提供错误消息,则返回您的类型的std::optional

  • 如果在构建过程中出现错误很少且应该传播,则抛出异常。

  • 如果在构建过程中出现错误很常见(请参阅 Abseil 文档中的模板),则返回您的类型的absl::StatusOr(请参阅进一步阅读部分)。

现在您知道应该返回什么了,让我们讨论我们最后一种工厂类型。

使用工厂类

工厂类是可以为我们制造对象的类型。它们可以帮助解耦多态对象类型与其调用者。它们可以允许使用对象池(其中可重用的对象被保留,这样您就不需要不断分配和释放它们)或其他分配方案。这些只是它们可以有用的一些例子。让我们更仔细地看看另一个例子。想象一下,您需要根据输入参数创建不同的多态类型。在某些情况下,像下面显示的多态工厂函数一样的多态工厂函数是不够的:

std::unique_ptr<IDocument> open(std::string_view path) {

    if (path.ends_with(".pdf")) return std::make_unique<PdfDocument>();

    if (name == ".html") return std::make_unique<HtmlDocument>();


    return nullptr;

}

如果我们还想打开其他类型的文档,比如 OpenDocument 文本文件,可能会讽刺地发现前面的打开工厂不适用于扩展。如果我们拥有代码库,这可能不是一个大问题,但如果我们库的消费者需要注册自己的类型,这可能是一个问题。为了解决这个问题,让我们使用一个工厂类,允许注册函数来打开不同类型的文档,如下所示:

class DocumentOpener {

public:

  using DocumentType = std::unique_ptr<IDocument>;

  using ConcreteOpener = DocumentType (*)(std::string_view);


private:

  std::unordered_map<std::string_view, ConcreteOpener> openerByExtension;

};

这个类目前还没有做太多事情,但它有一个从扩展到应该调用以打开给定类型文件的函数的映射。现在我们将添加两个公共成员函数。第一个将注册新的文件类型:

  void Register(std::string_view extension, ConcreteOpener opener) {

    openerByExtension.emplace(extension, opener);

  }

现在我们有了填充映射的方法。第二个新的公共函数将使用适当的打开者打开文档:

  DocumentType open(std::string_view path) {

    if (auto last_dot = path.find_last_of('.');

        last_dot != std::string_view::npos) {

      auto extension = path.substr(last_dot + 1);

      return openerByExtension.at(extension)(path);

    } else {

      throw std::invalid_argument{"Trying to open a file with no extension"};

    }

  }

基本上,我们从文件路径中提取扩展名,如果为空则抛出异常,如果不为空,则在我们的映射中寻找打开者。如果找到,我们使用它来打开给定的文件,如果没有,映射将为我们抛出另一个异常。

现在我们可以实例化我们的工厂并注册自定义文件类型,比如 OpenDocument 文本格式:

auto document_opener = DocumentOpener{};


document_opener.Register(

    "odt", [](auto path) -> DocumentOpener::DocumentType {

      return std::make_unique<OdtDocument>(path);

    });

请注意,我们注册了一个 lambda,因为它可以转换为我们的ConcreteOpener类型,这是一个函数指针。但是,如果我们的 lambda 有状态,情况就不同了。在这种情况下,我们需要使用一些东西来包装我们。这样的东西可能是std::function,但这样做的缺点是每次运行函数时都需要付出类型擦除的代价。在打开文件的情况下,这可能没问题。但是,如果你需要更好的性能,考虑使用function_ref这样的类型。

提议的这个实用程序的示例实现(尚未被接受)可以在 Sy Brand 的 GitHub 存储库中找到,该存储库在进一步阅读部分中有引用。

好了,现在我们在工厂中注册了我们的打开者,让我们使用它来打开一个文件并提取一些文本出来:

  auto document = document_opener.open("file.odt");

  std::cout << document->extract_text().front();

就是这样!如果你想为你的库的消费者提供一种注册他们自己类型的方式,他们必须在运行时访问你的映射。你可以提供一个 API 让他们访问它,或者将工厂设为静态,并允许他们从代码的任何地方注册。

这就是工厂和在单一步骤中构建对象的全部内容。让我们讨论另一个流行的模式,如果工厂不合适的话可以使用。

使用构建者

构建者类似于工厂,是来自四人帮的一种创建模式。与工厂不同,它们可以帮助你构建更复杂的对象:那些无法在单一步骤中构建的对象,例如由许多单独部分组装而成的类型。它们还为你提供了一种自定义对象构建的方式。在我们的例子中,我们将跳过设计复杂的构建者层次结构。相反,我们将展示构建者如何帮助。我们将把实现层次结构的工作留给你作为练习。

当一个对象无法在单一步骤中产生时,就需要构建者,但如果单一步骤不是微不足道的话,具有流畅接口只会让它们更加愉快。让我们使用 CRTP 来演示创建流畅的构建者层次结构。

在我们的情况下,我们将创建一个 CRTP,GenericItemBuilder,它将作为我们的基本构建者,以及FetchingItemBuilder,它将是一个更专业的构建者,可以使用远程地址获取数据(如果支持的话)。这样的专业化甚至可以存在于不同的库中,例如,使用可能在构建时可用或不可用的不同 API。

为了演示目的,我们将从第五章,利用 C++语言特性构建我们的Item结构的实例:

struct Item {

  std::string name;

  std::optional<std::string> photo_url;

  std::string description;

  std::optional<float> price;

  time_point<system_clock> date_added{};

  bool featured{};

};

如果你愿意,你可以通过将默认构造函数设为私有并使构建者成为友元来强制使用构建者构建Item实例。

  template <typename ConcreteBuilder> friend class GenericItemBuilder;

我们的构建者实现可以从以下开始:

template <typename ConcreteBuilder> class GenericItemBuilder {

public:

  explicit GenericItemBuilder(std::string name)

      : item_{.name = std::move(name)} {}

protected:

  Item item_;

尽管通常不建议创建受保护的成员,但我们希望我们的后代构建者能够访问我们的项目。另一种方法是在派生类中只使用基本构建器的公共方法。

我们在构建器的构造函数中接受名称,因为它是来自用户的单个输入,在创建项目时需要设置。这样,我们确保它将被设置。另一种选择是在建造的最后阶段检查它是否可以,当对象被释放给用户时。在我们的情况下,构建步骤可以实现如下:

  Item build() && {

    item_.date_added = system_clock::now();

    return std::move(item_);

  }

我们强制要求在调用此方法时“消耗”构建器;它必须是一个 r 值。这意味着我们可以在一行中使用构建器,或者在最后一步将其移动以标记其工作结束。然后我们设置我们的项目的创建时间并将其移出构建器。

我们的构建器 API 可以提供以下功能:

  ConcreteBuilder &&with_description(std::string description) {

    item_.description = std::move(description);

    return static_cast<ConcreteBuilder &&>(*this);

  }


  ConcreteBuilder &&marked_as_featured() {

    item_.featured = true;

    return static_cast<ConcreteBuilder &&>(*this);

  }

它们中的每一个都将具体(派生)构建器对象作为 r 值引用返回。也许出乎意料的是,这次应该优先返回此返回类型,而不是按值返回。这是为了避免在构建时不必要地复制item_。另一方面,通过 l 值引用返回可能导致悬空引用,并且会使调用build()变得更加困难,因为返回的 l 值引用将无法匹配预期的 r 值引用。

最终的构建器类型可能如下所示:

class ItemBuilder final : public GenericItemBuilder<ItemBuilder> {

  using GenericItemBuilder<ItemBuilder>::GenericItemBuilder;

};

它只是一个重用我们通用构建器的构造函数的类。可以如下使用:

  auto directly_loaded_item = ItemBuilder{"Pot"}

                                  .with_description("A decent one")

                                  .with_price(100)

                                  .build();

正如您所看到的,最终的接口可以使用函数链接调用,并且方法名称使整个调用流畅易读,因此称为流畅接口

如果我们不直接加载每个项目,而是使用一个更专门的构建器,可以从远程端点加载数据的部分,会怎么样?我们可以定义如下:

class FetchingItemBuilder final

    : public GenericItemBuilder<FetchingItemBuilder> {

public:

  explicit FetchingItemBuilder(std::string name)

      : GenericItemBuilder(std::move(name)) {}


  FetchingItemBuilder&& using_data_from(std::string_view url) && {

    item_ = fetch_item(url);

    return std::move(*this);

  }

};

我们还使用 CRTP 从我们的通用构建器继承,并强制要求给我们一个名称。然而,这一次,我们用我们自己的函数扩展基本构建器,以获取内容并将其放入我们正在构建的项目中。由于 CRTP,当我们从基本构建器调用函数时,我们将得到派生的返回,这使得接口更容易使用。可以以以下方式调用:

  auto fetched_item =

      FetchingItemBuilder{"Linen blouse"}

          .using_data_from("https://example.com/items/linen_blouse")

          .marked_as_featured()

          .build();

一切都很好!

如果您需要始终创建不可变对象,构建器也很有用。由于构建器可以访问类的私有成员,它可以修改它们,即使类没有为它们提供任何设置器。当然,这并不是您可以从使用它们中受益的唯一情况。

使用复合和原型构建

您需要使用构建器的情况是创建复合体。复合体是一种设计模式,其中一组对象被视为一个对象,所有对象共享相同的接口(或相同的基本类型)。一个例子是图形,您可以将其组合成子图形,或者文档,它可以嵌套其他文档。当您在这样的对象上调用print()时,所有子对象都会按顺序调用它们的print()函数以打印整个复合体。构建器模式对于创建每个子对象并将它们全部组合在一起非常有用。

原型是另一种可以用于对象构建的模式。如果您的类型创建成本很高,或者您只想要一个基本对象来构建,您可能想要使用这种模式。它归结为提供一种克隆对象的方法,您稍后可以单独使用它,或者修改它以使其成为应该成为的样子。在多态层次结构的情况下,只需添加clone(),如下所示:

class Map {

public:

    virtual std::unique_ptr<Map> clone() const;

    // ... other members ...

};


class MapWithPointsOfInterests {

public:

    std::unique_ptr<Map> clone() override const;

    // ... other members ...

private:

    std::vector<PointOfInterest> pois_;

};

我们的MapWithPointsOfInterests对象也可以克隆这些点,这样我们就不需要手动重新添加每一个。这样,当用户创建自己的地图时,我们可以为其提供一些默认值。还要注意,在某些情况下,简单的复制构造函数就足够了,而不是使用原型。

我们现在已经涵盖了对象创建。我们沿途提到了变体,那么为什么不重新访问它们(双关语)以看看它们如何帮助我们?

在 C++中跟踪状态和访问对象

状态是一种设计模式,旨在在对象的内部状态发生变化时帮助改变对象的行为。不同状态的行为应该彼此独立,以便添加新状态不会影响当前状态。在状态对象中实现所有行为的简单方法不具有可扩展性。使用状态模式,可以通过引入新的状态类并定义它们之间的转换来添加新行为。在本节中,我们将展示一种使用std::variant和静态多态双重分派来实现状态和状态机的方法。

首先,让我们定义我们的状态。在我们的示例中,让我们模拟商店中产品的状态。它们可以如下所示:

namespace state {


struct Depleted {};


struct Available {

  int count;

};


struct Discontinued {};

} // namespace state

我们的状态可以有自己的属性,比如剩余物品的数量。与动态多态性相反,它们不需要从一个共同的基类继承。相反,它们都存储在一个变体中,如下所示:

using State = std::variant<state::Depleted, state::Available, state::Discontinued>;

除了状态,我们还需要用于状态转换的事件。检查以下代码:

namespace event {


struct DeliveryArrived {

  int count;

};


struct Purchased {

  int count;

};


struct Discontinued {};


} // namespace event

如您所见,我们的事件也可以有属性,并且不需要从一个共同的基类继承。现在,我们需要实现状态之间的转换。可以按以下方式完成:

State on_event(state::Available available, event::DeliveryArrived delivered) {

  available.count += delivered.count;

  return available;

}


State on_event(state::Available available, event::Purchased purchased) {

  available.count -= purchased.count;

  if (available.count > 0)

    return available;

  return state::Depleted{};

}

如果进行购买,状态可以改变,但也可以保持不变。我们还可以使用模板一次处理多个状态:

template <typename S> State on_event(S, event::Discontinued) {

  return state::Discontinued{};

}

如果商品停产,无论它处于什么状态都无所谓。好的,现在让我们实现最后一个受支持的转换:

State on_event(state::Depleted depleted, event::DeliveryArrived delivered) {

  return state::Available{delivered.count};

}

我们需要的下一个拼图是一种定义多个调用运算符的方式,以便可以调用最佳匹配的重载。我们稍后将使用它来调用我们刚刚定义的转换。我们的辅助程序可以如下所示:

template<class... Ts> struct overload : Ts... { using Ts::operator()...; };

template<class... Ts> overload(Ts...) -> overload<Ts...>;

我们创建了一个overload结构,它将在构造期间提供所有传递给它的调用运算符,使用可变模板、折叠表达式和类模板参数推导指南。有关此的更深入解释,以及实现访问的另一种替代方式,请参阅 Bartłomiej Filipek 在进一步阅读部分中的博客文章。

现在我们可以开始实现状态机本身:

class ItemStateMachine {

public:

  template <typename Event> void process_event(Event &&event) {

    state_ = std::visit(overload{

        & requires std::is_same_v<

            decltype(on_event(state, std::forward<Event>(event))), State> {

          return on_event(state, std::forward<Event>(event));

        },

        [](const auto &unsupported_state) -> State {

          throw std::logic_error{"Unsupported state transition"};

        }

      },

      state_);

  }


private:

  State state_;

};

我们的process_event函数将接受我们定义的任何事件。它将使用当前状态和传递的事件调用适当的on_event函数,并切换到新状态。如果找到给定状态和事件的on_event重载,将调用第一个 lambda。否则,约束条件将不满足,并将调用第二个更通用的重载。这意味着如果存在不受支持的状态转换,我们将抛出异常。

现在,让我们提供一种报告当前状态的方法:

      std::string report_current_state() {

        return std::visit(

            overload{[](const state::Available &state) -> std::string {

                       return std::to_string(state.count) + 
                       " items available";

                     },

                     [](const state::Depleted) -> std::string {

                       return "Item is temporarily out of stock";

                     },

                     [](const state::Discontinued) -> std::string {

                       return "Item has been discontinued";

                     }},

            state_);

      }

在这里,我们使用我们的重载来传递三个 lambda,每个 lambda 返回一个通过访问我们的状态对象生成的报告字符串。

现在我们可以调用我们的解决方案:

        auto fsm = ItemStateMachine{};

        std::cout << fsm.report_current_state() << '\n';

        fsm.process_event(event::DeliveryArrived{3});

        std::cout << fsm.report_current_state() << '\n';

        fsm.process_event(event::Purchased{2});

        std::cout << fsm.report_current_state() << '\n';

        fsm.process_event(event::DeliveryArrived{2});

        std::cout << fsm.report_current_state() << '\n';

        fsm.process_event(event::Purchased{3});

        std::cout << fsm.report_current_state() << '\n';

        fsm.process_event(event::Discontinued{});

        std::cout << fsm.report_current_state() << '\n';

        // fsm.process_event(event::DeliveryArrived{1});

运行后,将产生以下输出:

Item is temporarily out of stock

3 items available

1 items available

3 items available

Item is temporarily out of stock

Item has been discontinued

也就是说,除非您取消注释具有不受支持的转换的最后一行,否则在最后将抛出异常。

我们的解决方案比基于动态多态性的解决方案要高效得多,尽管受支持的状态和事件列表受限于编译时提供的状态。有关状态、变体和各种访问方式的更多信息,请参阅 Mateusz Pusz 在 CppCon 2018 的演讲,也列在进一步阅读部分中。

在我们结束本章之前,我们想让您了解的最后一件事是处理内存。让我们开始我们的最后一节。

高效处理内存

即使您没有非常有限的内存,查看您如何使用它也是一个好主意。通常,内存吞吐量是现代系统的性能瓶颈,因此始终重要的是充分利用它。执行太多的动态分配可能会减慢程序速度并导致内存碎片化。让我们学习一些减轻这些问题的方法。

使用 SSO/SOO 减少动态分配

动态分配有时会给您带来麻烦,不仅在构造对象时抛出异常,而且还会花费 CPU 周期并导致内存碎片化。幸运的是,有一种方法可以防范这种情况。如果您曾经使用过std::string(GCC 5.0 之后),您很可能使用了一种称为小字符串优化SSO)的优化。这是小对象优化SSO)的一个更普遍的优化的例子,可以在 Abseil 的 InlinedVector 等类型中找到。其主要思想非常简单:如果动态分配的对象足够小,它应该存储在拥有它的类内部,而不是动态分配。在std::string的情况下,通常有容量、长度和实际要存储的字符串。如果字符串足够短(在 GCC 的情况下,在 64 位平台上,它是 15 个字节),它将存储在其中的某些成员中。

将对象存储在原地而不是在其他地方分配并仅存储指针还有一个好处:减少指针追踪。每次需要访问指针后面存储的数据时,都会增加 CPU 缓存的压力,并有可能需要从主内存中获取数据。如果这是一个常见的模式,它可能会影响您的应用程序的整体性能,特别是如果 CPU 的预取器没有猜测到指向的地址。使用 SSO 和 SOO 等技术在减少这些问题方面是非常宝贵的。

通过管理 COW 来节省内存

如果您在 GCC 5.0 之前使用过 GCC 的std::string,您可能使用了一种称为写时复制COW)的不同优化。当使用相同的基础字符数组创建多个实例时,COW 字符串实现实际上共享相同的内存地址。当字符串被写入时,基础存储被复制,因此得名。

这种技术有助于节省内存并保持高速缓存热度,并且通常在单线程上提供了可靠的性能。但要注意在多线程环境中使用它。使用锁可能会严重影响性能。与任何与性能相关的主题一样,最好的方法是测量在您的情况下是否是最佳工具。

现在让我们讨论一下 C++17 的一个功能,它可以帮助您实现动态分配的良好性能。

利用多态分配器

我们正在讨论的功能是多态分配器。具体来说,是std::pmr::polymorphic_allocator和多态std::pmr::memory_resource类,分配器使用它来分配内存。

本质上,它允许您轻松地链接内存资源,以充分利用您的内存。链可以简单到一个资源保留一个大块并分配它,然后退回到另一个资源,如果它耗尽内存,就简单地调用newdelete。它们也可以更复杂:您可以构建一个长链的内存资源,处理不同大小的池,仅在需要时提供线程安全性,绕过堆直接使用系统内存,返回您最后释放的内存块以提供高速缓存,以及执行其他花哨的操作。并非所有这些功能都由标准多态内存资源提供,但由于它们的设计,很容易扩展它们。

让我们首先讨论内存区域的主题。

使用内存区域

内存区域,也称为区域,只是存在有限时间的大块内存。您可以使用它来分配您在区域的生命周期内使用的较小对象。区域中的对象可以像往常一样被释放,或者在称为闪烁的过程中一次性擦除。我们稍后会描述它。

区域相对于通常的分配和释放具有几个巨大的优势-它们提高了性能,因为它们限制了需要获取上游资源的内存分配。它们还减少了内存的碎片化,因为任何碎片化都将发生在区域内。一旦释放了区域的内存,碎片化也就消失了。一个很好的主意是为每个线程创建单独的区域。如果只有一个线程使用区域,它就不需要使用任何锁定或其他线程安全机制,减少了线程争用,并为您提供了良好的性能提升。

如果您的程序是单线程的,提高其性能的低成本解决方案可能如下:

  auto single_threaded_pool = std::pmr::unsynchronized_pool_resource();

  std::pmr::set_default_resource(&single_threaded_pool);

如果您不明确设置任何资源,那么默认资源将是new_delete_resource,它每次调用newdelete,就像常规的std::allocator一样,并且具有它提供的所有线程安全性(和成本)。

如果您使用前面的代码片段,那么使用pmr分配器进行的所有分配都将不使用锁。但是,您仍然需要实际使用pmr类型。例如,要在标准容器中使用,您只需将std::pmr::polymorphic_allocator<T>作为分配器模板参数传递。许多标准容器都有启用pmr的类型别名。接下来创建的两个变量是相同类型,并且都将使用默认内存资源:

  auto ints = std::vector<int, std::pmr::polymorphic_allocator<int>>(std::pmr::get_default_resource());

  auto also_ints = std::pmr::vector<int>{};

第一个显式传递资源。现在让我们来看看pmr中可用的资源。

使用单调内存资源

我们将讨论的第一个是std::pmr::monotonic_buffer_resource。它是一个只分配内存而不在释放时执行任何操作的资源。它只会在资源被销毁或显式调用release()时释放内存。这种类型与无线程安全连接,使其极其高效。如果您的应用程序偶尔需要在给定线程上执行大量分配的任务,然后随后一次性释放所有使用的对象,使用单调资源将带来巨大的收益。它也是链式资源的一个很好的基本构建块。

使用池资源

两种资源的常见组合是在单调缓冲区资源之上使用池资源。标准池资源创建不同大小块的池。在std::pmr中有两种类型,unsynchronized_pool_resource用于仅有一个线程从中分配和释放的情况,synchronized_pool_resource用于多线程使用。与全局分配器相比,两者都应该提供更好的性能,特别是在使用单调缓冲区作为上游资源时。如果您想知道如何链接它们,下面是方法:

  auto buffer = std::array<std::byte, 1 * 1024 * 1024>{};

  auto monotonic_resource =

      std::pmr::monotonic_buffer_resource{buffer.data(), buffer.size()};

  auto pool_options = std::pmr::pool_options{.max_blocks_per_chunk = 0,

      .largest_required_pool_block = 512};

  auto arena =

      std::pmr::unsynchronized_pool_resource{pool_options, &monotonic_resource};

我们为区域创建了一个 1 MB 的缓冲区以供重复使用。我们将其传递给单调资源,然后传递给不同步的池资源,从而创建一个简单而有效的分配器链,直到使用完所有初始缓冲区之前都不会调用 new。

您可以将std::pmr::pool_options对象传递给两种池类型,以限制给定大小的块的最大数量(max_blocks_per_chunk)或最大块的大小(largest_required_pool_block)。传递 0 会导致使用实现的默认值。在 GCC 库的情况下,实际块的数量取决于块的大小。如果超过最大大小,池资源将直接从其上游资源分配。如果初始内存耗尽,它也会转向上游资源。在这种情况下,它会分配几何增长的内存块。

编写自己的内存资源

如果标准内存资源不适合您的所有需求,您总是可以相当简单地创建自定义资源。例如,不是所有标准库实现都提供的一个很好的优化是跟踪已释放的给定大小的最后一块块,并在下一个给定大小的分配上将它们返回。这个最近使用缓存可以帮助您增加数据缓存的热度,这应该有助于您的应用程序性能。您可以将其视为一组用于块的 LIFO 队列。

有时,您可能还希望调试分配和释放。在下面的代码片段中,我编写了一个简单的资源,可以帮助您完成这项任务:

class verbose_resource : public std::pmr::memory_resource {

  std::pmr::memory_resource *upstream_resource_;

public:

  explicit verbose_resource(std::pmr::memory_resource *upstream_resource)

      : upstream_resource_(upstream_resource) {}

我们的冗长资源继承自多态基础资源。它还接受一个上游资源,它将用于实际分配。它必须实现三个私有函数 - 一个用于分配,一个用于释放,一个用于比较资源实例。这是第一个:

private:

  void *do_allocate(size_t bytes, size_t alignment) override {

    std::cout << "Allocating " << bytes << " bytes\n";

    return upstream_resource_->allocate(bytes, alignment);

  }

它只是在标准输出上打印分配大小,然后使用上游资源来分配内存。下一个将类似:

  void do_deallocate(void *p, size_t bytes, size_t alignment) override {

    std::cout << "Deallocating " << bytes << " bytes\n";

    upstream_resource_->deallocate(p, bytes, alignment);

  }

我们记录我们释放多少内存并使用上游执行任务。现在下一个所需的最后一个函数被陈述如下:

  [[nodiscard]] bool

  do_is_equal(const memory_resource &other) const noexcept override {

    return this == &other;

  }

我们只需比较实例的地址,以知道它们是否相等。[[nodiscard]]属性可以帮助我们确保调用者实际上消耗了返回的值,这可以帮助我们避免意外滥用我们的函数。

就是这样。对于pmr分配器这样一个强大的功能,API 现在并不那么复杂,是吗?

除了跟踪分配之外,我们还可以使用pmr来防止在不应该分配时进行分配。

确保没有意外的分配

特殊的std::pmr::null_memory_resource()将在任何人尝试使用它分配内存时抛出异常。您可以通过将其设置为默认资源来防止使用pmr执行任何分配,如下所示:

std::pmr::set_default_resource(null_memory_resource());

您还可以使用它来限制在不应该发生时从上游分配。检查以下代码:

  auto buffer = std::array<std::byte, 640 * 1024>{}; // 640K ought to be enough for anybody

  auto resource = std::pmr::monotonic_buffer_resource{

      buffer.data(), buffer.size(), std::pmr::null_memory_resource()};

如果有人尝试分配超过我们设置的缓冲区大小,将抛出std::bad_alloc

让我们继续进行本章的最后一项任务。

眨眼内存

有时不需要释放内存,就像单调缓冲资源一样,对性能来说还不够。一种称为眨眼的特殊技术可以在这里帮助。眨眼对象意味着它们不仅不是逐个释放,而且它们的构造函数也不会被调用。对象只是蒸发,节省了通常用于调用每个对象及其成员(和它们的成员...)的析构函数所需的时间。

注意:这是一个高级主题。在使用这种技术时要小心,并且只有在可能的收益值得时才使用它。

这种技术可以节省您宝贵的 CPU 周期,但并非总是可能使用它。如果您的对象处理的资源不仅仅是内存,避免眨眼内存。否则,您将会出现资源泄漏。如果您依赖于对象的析构函数可能产生的任何副作用,情况也是如此。

现在让我们看看眨眼的实际效果:

  auto verbose = verbose_resource(std::pmr::get_default_resource());

  auto monotonic = std::pmr::monotonic_buffer_resource(&verbose);

  std::pmr::set_default_resource(&monotonic);


  auto alloc = std::pmr::polymorphic_allocator{};

  auto *vector = alloc.new_object<std::pmr::vector<std::pmr::string>>();

  vector->push_back("first one");

  vector->emplace_back("long second one that must allocate");

在这里,我们手工创建了一个多态分配器,它将使用我们的默认资源——一个每次到达上游时都会记录的单调资源。为了创建对象,我们将使用 C++20 中对pmr的新增功能new_object函数。我们创建了一个字符串向量。我们可以使用push_back传递第一个字符串,因为它足够小,可以适应我们由于 SSO 而拥有的小字符串缓冲区。第二个字符串需要使用默认资源分配一个字符串,然后才能将其传递给我们的向量,如果我们使用push_back。将其置于内部会导致字符串在调用之前(而不是之前)在向量的函数内部构造,因此它将使用向量的分配器。最后,我们没有在任何地方调用分配对象的析构函数,只有在退出作用域时才释放所有内容。这应该给我们带来难以超越的性能。

这是本章的最后一项内容。让我们总结一下我们学到的东西。

总结

在本章中,我们介绍了 C++世界中使用的各种习语和模式。现在你应该能够流利地编写 C++。我们已经揭开了如何执行自动清理的神秘面纱。您现在可以编写更安全的类型,正确地移动、复制和交换。您学会了如何利用 ADL 来优化编译时间和编写定制点。我们讨论了如何在静态和动态多态性之间进行选择。我们还学会了如何向类型引入策略,何时使用类型擦除,何时不使用。

此外,我们还讨论了如何使用工厂和流畅构建器创建对象。此外,使用内存区域也不再是神秘的魔法。使用诸如变体之类的工具编写状态机也是如此。

我们还触及了一些额外的话题。哦!我们旅程的下一站将是关于构建软件和打包的内容。

问题

  1. 三、五和零的规则是什么?

  2. 我们何时使用 niebloids 而不是隐藏的友元?

  3. 如何改进数组接口以使其更适合生产?

  4. 折叠表达式是什么?

  5. 何时不应该使用静态多态性?

  6. 在眨眼示例中,我们如何节省一次额外的分配?

进一步阅读

  1. tag_invoke:支持可定制函数的通用模式,Lewis Baker,Eric Niebler,Kirk Shoop,ISO C++提案,wg21.link/p1895

  2. tag_invoke :: niebloids 进化,Gašper Ažman 为 Core C++会议制作的 YouTube 视频,www.youtube.com/watch?v=oQ26YL0J6DU

  3. 继承是邪恶的基类,Sean Parent 为 GoingNative 2013 会议制作的 Channel9 视频,channel9.msdn.com/Events/GoingNative/2013/Inheritance-Is-The-Base-Class-of-Evil

  4. 现代 C++设计,Andrei Alexandrescu,Addison-Wesley,2001

  5. 非成员函数如何改进封装,Scott Meyers,Dr. Dobbs 文章,www.drdobbs.com/cpp/how-non-member-functions-improve-encapsu/184401197

  6. 返回状态或值,Status 用户指南,Abseil 文档,abseil.io/docs/cpp/guides/status#returning-a-status-or-a-value

  7. function_ref,GitHub 存储库,github.com/TartanLlama/function_ref

  8. 如何使用 std::visit 处理多个变体,Bartłomiej Filipek,Bartek 的编码博客文章,www.bfilipek.com/2018/09/visit-variants.html

  9. CppCon 2018:Mateusz Pusz,使用 std::variant 有效替代动态多态性,YouTube 视频,www.youtube.com/watch?v=gKbORJtnVu8

第七章:构建和打包

作为架构师,您需要了解构建过程的所有要素。本章将解释构建过程的所有要素。从编译器标志到自动化脚本等,我们将指导您到每个可能的模块、服务和构件都被版本化并存储在一个中央位置,准备部署。我们将主要关注 CMake。

在本章中,您将了解以下内容:

  • 您应该考虑使用哪些编译器标志

  • 如何基于现代 CMake 创建构建系统

  • 如何构建可重用的组件

  • 如何在 CMake 中清洁地使用外部代码

  • 如何使用 CPack 创建 DEB 和 RPM 软件包,以及 NSIS 安装程序

  • 如何使用 Conan 软件包管理器来安装您的依赖项并创建您自己的软件包

阅读完本章后,您将了解如何编写最先进的代码来构建和打包您的项目。

技术要求

要复制本章中的示例,您应安装最新版本的GCCClangCMake 3.15或更高版本,ConanBoost 1.69

本章的源代码片段可以在github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter07找到。

充分利用编译器

编译器是每个程序员工作室中最重要的工具之一。这就是为什么充分了解它们可以在许多不同的场合帮助您的原因。在本节中,我们将描述一些有效使用它们的技巧。这只是冰山一角,因为整本书都可以写关于这些工具及其广泛的可用标志、优化、功能和其他具体内容。GCC 甚至有一个关于编译器书籍的维基页面!您可以在本章末尾的进一步阅读部分找到它。

使用多个编译器

在构建过程中应考虑的一件事是使用多个编译器而不仅仅是一个,原因是它带来的几个好处。其中之一是它们可以检测代码中的不同问题。例如,MSVC 默认启用了符号检查。使用多个编译器可以帮助您解决将来可能遇到的潜在可移植性问题,特别是当决定在不同操作系统上编译代码时,例如从 Linux 迁移到 Windows 或反之。为了使这样的努力不花费任何成本,您应该努力编写可移植的、符合 ISO C++标准的代码。Clang的一个好处是它比 GCC 更注重符合 C++标准。如果您使用MSVC,请尝试添加/permissive-选项(自 Visual Studio 17 起可用;对于使用版本 15.5+创建的项目,默认启用)。对于GCC,在为代码选择 C++标准时,尽量不要使用 GNU 变体(例如,更喜欢-std=c++17而不是-std=gnu++17)。如果性能是您的目标,能够使用多种编译器构建软件还将使您能够选择为特定用例提供最快二进制文件的编译器。

无论您选择哪个编译器进行发布构建,都应考虑在开发中使用 Clang。它可以在 macOS、Linux 和 Windows 上运行,支持与 GCC 相同的一组标志,并旨在提供最快的构建时间和简洁的编译错误。

如果您使用 CMake,有两种常见的方法可以添加另一个编译器。一种是在调用 CMake 时传递适当的编译器,如下所示:

mkdir build-release-gcc
cd build-release-gcc
cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=/usr/bin/gcc -DCMAKE_CXX_COMPILER=/usr/bin/g++ 

也可以在调用 CMake 之前设置 CC 和 CXX,但这些变量并非在所有平台上都受到尊重(例如 macOS)。

另一种方法是使用工具链文件。如果你只需要使用不同的编译器,这可能有点过度,但当你想要交叉编译时,这是一个常用的解决方案。要使用工具链文件,你应该将其作为 CMake 参数传递:-DCMAKE_TOOLCHAIN_FILE=toolchain.cmake

减少构建时间

每年,程序员们花费无数时间等待他们的构建完成。减少构建时间是提高整个团队生产力的简单方法,所以让我们讨论一下几种方法来做到这一点。

使用一个快速编译器

有时使构建更快的最简单方法之一是升级你的编译器。例如,通过将 Clang 升级到 7.0.0,你可以减少高达 30%的构建时间,使用预编译头PCH)文件。自 Clang 9 以来,它已经获得了-ftime-trace选项,它可以为你提供有关它处理的所有文件的编译时间的信息。其他编译器也有类似的开关:比如查看 GCC 的-ftime-report或 MSVC 的/Bt/d2cgsummary。通常情况下,通过切换编译器可以获得更快的编译速度,这在你的开发机器上尤其有用;例如,Clang 通常比 GCC 更快地编译代码。

一旦你有了一个快速的编译器,让我们看看它需要编译什么。

重新思考模板

编译过程的不同部分需要不同的时间来完成。这对于编译时构造尤为重要。Odin Holmes 的一个实习生 Chiel Douwes 基于对各种模板操作的编译时成本进行基准测试,创造了所谓的 Chiel 规则。这个规则以及其他基于类型的模板元编程技巧可以在 Odin Holmes 的基于类型的模板元编程并没有死讲座中看到。从最快到最慢,它们如下:

  • 查找一个记忆化类型(例如,一个模板实例化)

  • 向别名调用添加一个参数

  • 添加一个参数到一个类型

  • 调用一个别名

  • 实例化一个类型

  • 实例化一个函数模板

  • 使用SFINAE替换失败不是错误

为了证明这个规则,考虑以下代码:

template<bool>
 struct conditional {
     template<typename T, typename F>
     using type = F;
 };

 template<>
 struct conditional<true> {
     template<typename T, typename F>
     using type = T;
 };

 template<bool B, typename T, typename F>
 using conditional_t = conditional<B>::template type<T, F>;

它定义了一个conditional模板别名,它存储一个类型,如果条件B为真,则解析为T,否则解析为F。编写这样一个实用程序的传统方式如下:

template<bool B, class T, class F>
 struct conditional {
     using type = T;
 };

 template<class T, class F>
 struct conditional<false, T, F> {
     using type = F;
 };

 template<bool B, class T, class F>
 using conditional_t = conditional<B,T,F>::type;

然而,这第二种方法比第一种编译速度慢,因为它依赖于创建模板实例而不是类型别名。

现在让我们看看你可以使用哪些工具及其特性来保持编译时间低。

利用工具

一个常见的技术,可以使你的构建更快,就是使用单一编译单元构建,或者统一构建。它不会加速每个项目,但如果你的头文件中有大量代码,这可能值得一试。统一构建通过将所有.cpp文件包含在一个翻译单元中来工作。另一个类似的想法是使用预编译头文件。像 CMake 的 Cotire 这样的插件将为你处理这两种技术。CMake 3.16 还增加了对统一构建的本机支持,你可以通过为一个目标启用它,set_target_properties(<target> PROPERTIES UNITY_BUILD ON,或者通过将CMAKE_UNITY_BUILD设置为true来全局启用。如果你只想要 PCHs,你可能需要查看 CMake 3.16 的target_precompile_headers

如果你觉得你在 C++文件中包含了太多内容,考虑使用一个名为include-what-you-use的工具来整理它们。更倾向于前向声明类型和函数而不是包含头文件也可以在减少编译时间方面走得更远。

如果您的项目链接需要很长时间,也有一些应对方法。使用不同的链接器,例如 LLVM 的 LLD 或 GNU 的 Gold,可以帮助很多,特别是因为它们允许多线程链接。如果您负担不起使用不同的链接器,您可以尝试使用诸如-fvisibility-hidden-fvisibility-inlines-hidden等标志,并在源代码中仅标记您希望在共享库中可见的函数。这样,链接器将有更少的工作要做。如果您正在使用链接时优化,尝试仅对性能关键的构建进行优化:计划进行性能分析和用于生产的构建。否则,您可能只会浪费开发人员的时间。

如果您正在使用 CMake 并且没有绑定到特定的生成器(例如,CLion 需要使用Code::Blocks生成器),您可以用更快的生成器替换默认的 Make 生成器。Ninja是一个很好的选择,因为它是专门用于减少构建时间而创建的。要使用它,只需在调用 CMake 时传递-G Ninja

还有两个很棒的工具,肯定会给您带来帮助。其中一个是Ccache。它是一个运行其 C 和 C++编译输出缓存的工具。如果您尝试两次构建相同的东西,它将从缓存中获取结果,而不是运行编译。它保留统计信息,如缓存命中和未命中,可以记住在编译特定文件时应发出的警告,并具有许多配置选项,可以存储在~/.ccache/ccache.conf文件中。要获取其统计信息,只需运行ccache --show-stats

第二个工具是IceCC(或 Icecream)。这是 distcc 的一个分支,本质上是一个工具,可以在多台主机上分发您的构建。使用 IceCC,更容易使用自定义工具链。它在每台主机上运行 iceccd 守护程序和一个管理整个集群的 icecc-scheduler 服务。调度程序与 distcc 不同,它确保仅使用每台机器上的空闲周期,因此您不会过载其他人的工作站。

要在 CMake 构建中同时使用 IceCC 和 Ccache,只需在 CMake 调用中添加-DCMAKE_C_COMPILER_LAUNCHER="ccache;icecc" -DCMAKE_CXX_COMPILER_LAUNCHER="ccache;icecc"。如果您在 Windows 上编译,您可以使用 clcache 和 Incredibuild,或者寻找其他替代方案,而不是最后两个工具。

现在您知道如何快速构建,让我们继续另一个重要的主题。

查找潜在的代码问题

即使最快的构建也不值得,如果你的代码有错误。有数十个标志可以警告您代码中的潜在问题。本节将尝试回答您应该考虑启用哪些标志。

首先,让我们从一个略有不同的问题开始:如何避免收到来自其他库代码的问题警告。收到无法真正修复的问题警告是没有用的。幸运的是,有编译器开关可以禁用此类警告。例如,在 GCC 中,您有两种类型的include文件:常规文件(使用-I传递)和系统文件(使用-isystem传递)。如果您使用后者指定一个目录,您将不会收到它包含的头文件的警告。MSVC 有一个等效于-isystem的选项:/external:I。此外,它还有其他用于处理外部包含的标志,例如/external:anglebrackets,告诉编译器将使用尖括号包含的所有文件视为外部文件,从而禁用对它们的警告。您可以为外部文件指定警告级别。您还可以保留由您的代码引起的模板实例化产生的警告,使用/external:templates-。如果您正在寻找一种将include路径标记为系统/外部路径的便携方式,并且正在使用 CMake,您可以在target_include_directories指令中添加SYSTEM关键字。

谈到可移植性,如果您想符合 C++标准(您应该这样做),请考虑为 GCC 或 Clang 的编译选项添加-pedantic,或者为 MSVC 添加/permissive-选项。这样,您将得到关于您可能正在使用的每个非标准扩展的信息。如果您使用 CMake,请为每个目标添加以下行,set_target_properties( PROPERTIES CXX_EXTENSIONS OFF),以禁用特定于编译器的扩展。

如果您正在使用 MSVC,请努力使用/W4 编译代码,因为它启用了大部分重要的警告。对于 GCC 和 Clang,请尝试使用-Wall -Wextra -Wconversion -Wsign-conversion。第一个尽管名字是这样,但只启用了一些常见的警告。然而,第二个添加了另一堆警告。第三个基于 Scott Meyers 的一本名为《Effective C++》的好书中的建议(这是一组很好的警告,但请检查它是否对您的需求太吵闹)。最后两个是关于类型转换和符号转换的。所有这些标志一起创建了一个理智的安全网,但您当然可以寻找更多要启用的标志。Clang 有一个-Weverything 标志。尝试定期使用它运行构建,以发现可能值得在您的代码库中启用的新的潜在警告。您可能会对使用此标志获得多少消息感到惊讶,尽管启用一些警告标志可能不值得麻烦。MSVC 的替代方案名为/Wall。看一下以下表格,看看之前未启用的其他一些有趣的选项:

GCC/Clang:

Flag 意义
-Wduplicated-cond 当在 if 和 else-if 块中使用相同条件时发出警告。
-Wduplicated-branches 如果两个分支包含相同的源代码,则发出警告。
-Wlogical-op 当逻辑操作中的操作数相同时发出警告,并且应使用位操作符时发出警告。
-Wnon-virtual-dtor 当一个类有虚函数但没有虚析构函数时发出警告。
-Wnull-dereference 警告空指针解引用。此检查可能在未经优化的构建中处于非活动状态。
-Wuseless-cast 当转换为相同类型时发出警告。
-Wshadow 一系列关于声明遮蔽其他先前声明的警告。

MSVC:

Flag 意义
/w44640 警告非线程安全的静态成员初始化。

最后值得一提的是一个问题:是否使用-Werror(或 MSVC 上的/WX)?这实际上取决于您的个人偏好,因为发出错误而不是警告有其利弊。好的一面是,您不会让任何已启用的警告溜走。您的 CI 构建将失败,您的代码将无法编译。在运行多线程构建时,您不会在快速通过的编译消息中丢失任何警告。然而,也有一些坏处。如果编译器启用了任何新的警告或只是检测到更多问题,您将无法升级编译器。对于依赖项也是一样,它们可能会废弃一些提供的函数。如果您的代码被项目的其他部分使用,您将无法废弃其中的任何内容。幸运的是,您总是可以使用混合解决方案:努力使用-Werror 进行编译,但在需要执行它所禁止的操作时将其禁用。这需要纪律,因为如果有任何新的警告滑入,您可能会很难消除它们。

使用以编译器为中心的工具

现在,编译器允许您做的事情比几年前多得多。这归功于 LLVM 和 Clang 的引入。通过提供 API 和模块化架构,使得诸如消毒剂、自动重构或代码完成引擎等工具得以蓬勃发展。您应该考虑利用这个编译器基础设施所提供的优势。使用 clang-format 确保代码库中的所有代码符合给定的标准。考虑使用 pre-commit 工具添加预提交挂钩,在提交之前重新格式化新代码。您还可以将 Python 和 CMake 格式化程序添加到其中。使用 clang-tidy 对代码进行静态分析——这是一个实际理解您的代码而不仅仅是推理的工具。这个工具可以为您执行大量不同的检查,所以一定要根据您的特定需求自定义列表和选项。您还可以在启用消毒剂的情况下每晚或每周运行软件测试。这样,您可以检测线程问题、未定义行为、内存访问、管理问题等。如果您的发布版本禁用了断言,使用调试版本运行测试也可能有价值。

如果您认为还可以做更多,您可以考虑使用 Clang 的基础设施编写自己的代码重构。如果您想看看如何创建一个基于 LLVM 的工具,已经有了一个clang-rename工具。对于 clang-tidy 的额外检查和修复也不难创建,它们可以为您节省数小时的手动劳动。

您可以将许多工具整合到您的构建过程中。现在让我们讨论这个过程的核心:构建系统。

摘要构建过程

在本节中,我们将深入研究 CMake 脚本,这是全球 C++项目中使用的事实标准构建系统生成器。

介绍 CMake

CMake 是构建系统生成器而不是构建系统本身意味着什么?简单地说,CMake 可以用来生成各种类型的构建系统。您可以使用它来生成 Visual Studio 项目、Makefile 项目、基于 Ninja 的项目、Sublime、Eclipse 和其他一些项目。

CMake 还配备了一系列其他工具,如用于执行测试的 CTest 和用于打包和创建安装程序的 CPack。CMake 本身也允许导出和安装目标。

CMake 的生成器可以是单配置的,比如 Make 或 NMAKE,也可以是多配置的,比如 Visual Studio。对于单配置的生成器,在首次在文件夹中运行生成时,应传递CMAKE_BUILD_TYPE标志。例如,要配置调试构建,您可以运行cmake <project_directory> -DCMAKE_BUILD_TYPE=Debug。其他预定义的配置有ReleaseRelWithDebInfo(带有调试符号的发布)和MinSizeRel(最小二进制大小的发布优化)。为了保持源目录清洁,始终创建一个单独的构建文件夹,并从那里运行 CMake 生成。

虽然可以添加自己的构建类型,但您真的应该尽量避免这样做,因为这会使一些 IDE 的使用变得更加困难,而且不具有可扩展性。一个更好的选择是使用option

CMake 文件可以以两种风格编写:一种是基于变量的过时风格,另一种是基于目标的现代 CMake 风格。我们这里只关注后者。尽量遏制通过全局变量设置事物,因为这会在您想要重用目标时引起问题。

创建 CMake 项目

每个 CMake 项目的顶层CMakeLists.txt文件中应包含以下行:

cmake_minimum_required(VERSION 3.15...3.19)

project(
   Customer
   VERSION 0.0.1
   LANGUAGES CXX)

设置最低和最大支持的版本很重要,因为它会影响 CMake 的行为,通过设置策略。如果需要,您也可以手动设置它们。

我们项目的定义指定了它的名称、版本(将用于填充一些变量)和 CMake 将用于构建项目的编程语言(这将填充更多变量并找到所需的工具)。

一个典型的 C++项目有以下目录:

  • cmake:用于 CMake 脚本

  • include:用于公共头文件,通常带有一个项目名称的子文件夹

  • src:用于源文件和私有头文件

  • test:用于测试

你可以使用 CMake 目录来存储你的自定义 CMake 模块。为了方便从这个目录访问脚本,你可以将它添加到 CMake 的include()搜索路径中,就像这样:

list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake"

在包含 CMake 模块时,你可以省略.cmake后缀。这意味着include(CommonCompileFlags.cmake)等同于include(CommonCompileFlags)

区分 CMake 目录变量

在 CMake 中浏览目录有一个常见的陷阱,不是每个人都意识到。在编写 CMake 脚本时,尝试区分以下内置变量:

  • PROJECT_SOURCE_DIRproject命令最后一次从 CMake 脚本中调用的目录。

  • PROJECT_BINARY_DIR:与前一个相同,但用于构建目录树。

  • CMAKE_SOURCE_DIR:顶层源目录(这可能是另一个项目,只是将我们作为依赖项/子目录添加进来)。

  • CMAKE_BINARY_DIR:与CMAKE_SOURCE_DIR相同,但用于构建目录树。

  • CMAKE_CURRENT_SOURCE_DIR:对应于当前处理的CMakeLists.txt文件的源目录。

  • CMAKE_CURRENT_BINARY_DIR:与CMAKE_CURRENT_SOURCE_DIR匹配的二进制(构建)目录。

  • CMAKE_CURRENT_LIST_DIRCMAKE_CURRENT_LIST_FILE的目录。如果当前的 CMake 脚本是从另一个脚本中包含的(对于被包含的 CMake 模块来说很常见),它可能与当前源目录不同。

搞清楚了这一点,现在让我们开始浏览这些目录。

在你的顶层CMakeLists.txt文件中,你可能想要调用add_subdirectory(src),这样 CMake 将处理那个目录。

指定 CMake 目标

src目录中,你应该有另一个CMakeLists.txt文件,这次可能定义了一个或两个目标。让我们为我们之前在书中提到的多米尼加展会系统添加一个客户微服务的可执行文件:

add_executable(customer main.cpp)

源文件可以像前面的代码行那样指定,也可以稍后使用target_sources添加。

一个常见的 CMake 反模式是使用通配符来指定源文件。使用它们的一个很大的缺点是,CMake 不会知道文件是否被添加,直到重新运行生成。这样做的一个常见后果是,如果你从存储库中拉取更改然后简单地构建,你可能会错过编译和运行新的单元测试或其他代码。即使你使用了CONFIGURE_DEPENDS和通配符,构建时间也会变长,因为通配符必须作为每次构建的一部分进行检查。此外,该标志可能无法可靠地与所有生成器一起使用。即使 CMake 的作者也不鼓励使用它,而是更倾向于明确声明源文件。

好的,我们定义了我们的源代码。现在让我们指定我们的目标需要编译器支持 C++17:

target_compile_features(customer PRIVATE cxx_std_17)

PRIVATE关键字指定这是一个内部要求,即只对这个特定目标可见,而不对依赖于它的任何目标可见。如果你正在编写一个提供用户 C++17 API 的库,你可以使用INTERFACE关键字。要同时指定接口和内部要求,你可以使用PUBLIC关键字。当使用者链接到我们的目标时,CMake 将自动要求它也支持 C++17。如果你正在编写一个不被构建的目标(即一个仅包含头文件的库或一个导入的目标),通常使用INTERFACE关键字就足够了。

你还应该注意,指定我们的目标要使用 C++17 特性并不强制执行 C++标准或禁止编译器扩展。要这样做,你应该调用以下命令:

set_target_properties(customer PROPERTIES
     CXX_STANDARD 17
     CXX_STANDARD_REQUIRED YES
     CXX_EXTENSIONS NO
 )

如果你想要一组编译器标志传递给每个目标,你可以将它们存储在一个变量中,并在想要创建一个具有这些标志设置为INTERFACE的目标时调用以下命令,并且没有任何源并且使用这个目标在target_link_libraries中:

target_compile_options(customer PRIVATE ${BASE_COMPILE_FLAGS})

该命令会自动传播包含目录、选项、宏和其他属性,而不仅仅是添加链接器标志。说到链接,让我们创建一个库,我们将与之链接:

add_library(libcustomer lib.cpp)
add_library(domifair::libcustomer ALIAS libcustomer)
set_target_properties(libcustomer PROPERTIES OUTPUT_NAME customer)
# ...
target_link_libraries(customer PRIVATE libcustomer)

add_library可用于创建静态、共享、对象和接口(考虑头文件)库,以及定义任何导入的库。

它的ALIAS版本创建了一个命名空间目标,有助于调试许多 CMake 问题,是一种推荐的现代 CMake 实践。

因为我们已经给我们的目标添加了lib前缀,所以我们将输出名称设置为libcustomer.a而不是liblibcustomer.a

最后,我们将我们的可执行文件与添加的库链接起来。尽量始终为target_link_libraries命令指定PUBLICPRIVATEINTERFACE关键字,因为这对于 CMake 有效地管理目标依赖关系的传递性至关重要。

指定输出目录

一旦您使用cmake --build .等命令构建代码,您可能想知道在哪里找到构建产物。默认情况下,CMake 会将它们创建在与它们定义的源目录匹配的目录中。例如,如果您有一个带有add_executable指令的src/CMakeLists.txt文件,那么二进制文件将默认放在构建目录的src子目录中。我们可以使用以下代码来覆盖这一点:

set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/bin) 
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/lib)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_BINARY_DIR}/lib)

这样,二进制文件和 DLL 文件将放在项目构建目录的bin子目录中,而静态和共享 Linux 库将放在lib子目录中。

使用生成器表达式

以一种既支持单配置生成器又支持多配置生成器的方式设置编译标志可能会很棘手,因为 CMake 在配置时间执行if语句和许多其他结构,而不是在构建/安装时间执行。

这意味着以下是 CMake 的反模式:

if(CMAKE_BUILD_TYPE STREQUAL Release)
   target_compile_definitions(libcustomer PRIVATE RUN_FAST)
endif()

相反,生成器表达式是实现相同目标的正确方式,因为它们在稍后的时间被处理。让我们看一个实际使用它们的例子。假设您想为您的Release配置添加一个预处理器定义,您可以编写以下内容:

target_compile_definitions(libcustomer PRIVATE "$<$<CONFIG:Release>:RUN_FAST>")

这将仅在构建所选的配置时解析为RUN_FAST。对于其他配置,它将解析为空值。它适用于单配置和多配置生成器。然而,这并不是生成器表达式的唯一用例。

在构建期间由我们的项目使用时,我们的目标的某些方面可能会有所不同,并且在安装目标时由其他项目使用时也会有所不同。一个很好的例子是包含目录。在 CMake 中处理这个问题的常见方法如下:

target_include_directories(
   libcustomer PUBLIC $<INSTALL_INTERFACE:include>
                      $<BUILD_INTERFACE:${PROJECT_SOURCE_DIR}/include>)

在这种情况下,我们有两个生成器表达式。第一个告诉我们,当安装时,可以在include目录中找到包含文件,相对于安装前缀(安装的根目录)。如果我们不安装,这个表达式将变为空。这就是为什么我们有另一个用于构建的表达式。这将解析为上次使用project()找到的目录的include子目录。

不要在模块之外的路径上使用target_include_directories。如果这样做,您就是别人的头文件,而不是明确声明库/目标依赖关系。这是 CMake 的反模式。

CMake 定义了许多生成器表达式,您可以使用这些表达式来查询编译器和平台,以及目标(例如完整名称、对象文件列表、任何属性值等)。除此之外,还有运行布尔操作、if 语句、字符串比较等表达式。

现在,举一个更复杂的例子,假设您想要有一组编译标志,您可以在所有目标上使用,并且这些标志取决于所使用的编译器,您可以定义如下:

list(
   APPEND
   BASE_COMPILE_FLAGS
   "$<$<OR:$<CXX_COMPILER_ID:Clang>,$<CXX_COMPILER_ID:AppleClang>,$<CXX_COMPILER_ID:GNU>>:-Wall;-Wextra;-pedantic;-Werror>"
   "$<$<CXX_COMPILER_ID:MSVC>:/W4;/WX>")

如果编译器是 Clang 或 AppleClang 或 GCC,则会附加一组标志,如果使用的是 MSVC,则会附加另一组标志。请注意,我们使用分号分隔标志,因为这是 CMake 在列表中分隔元素的方式。

现在让我们看看如何为我们的项目添加外部代码供其使用。

使用外部模块

有几种方法可以获取您所依赖的外部项目。例如,您可以将它们添加为 Conan 依赖项,使用 CMake 的find_package来查找操作系统提供的版本或以其他方式安装的版本,或者自行获取和编译依赖项。

本节的关键信息是:如果可以的话,应该使用 Conan。这样,您将最终使用与您的项目及其依赖项要求相匹配的依赖项版本。

如果您的目标是支持多个平台,甚至是同一发行版的多个版本,使用 Conan 或自行编译都是可行的方法。这样,无论您在哪个操作系统上编译,都将使用相同的依赖项版本。

让我们讨论一下 CMake 本身提供的几种抓取依赖项的方法,然后转而使用名为 Conan 的多平台包管理器。

获取依赖项

使用 CMake 内置的FetchContent模块从源代码准备依赖项的一种可能的方法是。它将为您下载依赖项,然后像常规目标一样构建它们。

该功能在 CMake 3.11 中推出。它是ExternalProject模块的替代品,后者有许多缺陷。其中之一是它在构建时克隆了外部存储库,因此 CMake 无法理解外部项目定义的目标,以及它们的依赖关系。这使得许多项目不得不手动定义这些外部目标的include目录和库路径,并完全忽略它们所需的接口编译标志和依赖关系。FetchContent没有这样的问题,因此建议您使用它。

在展示如何使用之前,您必须知道FetchContentExternalProject(以及使用 Git 子模块和类似方法)都有一个重要的缺陷。如果您有许多依赖项使用同一个第三方库,您可能最终会得到同一项目的多个版本,例如几个版本的 Boost。使用 Conan 等包管理器可以帮助您避免这种问题。

举个例子,让我们演示如何使用上述的FetchContent功能将GTest集成到您的项目中。首先,创建一个FetchGTest.cmake文件,并将其放在我们源代码树中的cmake目录中。我们的FetchGTest脚本将定义如下:

include(FetchContent)

 FetchContent_Declare(
   googletest
   GIT_REPOSITORY https://github.com/google/googletest.git
   GIT_TAG dcc92d0ab6c4ce022162a23566d44f673251eee4)

 FetchContent_GetProperties(googletest)
 if(NOT googletest_POPULATED)
   FetchContent_Populate(googletest)
   add_subdirectory(${googletest_SOURCE_DIR} ${googletest_BINARY_DIR}
                    EXCLUDE_FROM_ALL)
 endif()

 message(STATUS "GTest binaries are present at ${googletest_BINARY_DIR}")

首先,我们包含内置的FetchContent模块。一旦加载了该模块,我们就可以使用FetchContent_Declare来声明依赖项。现在,让我们命名我们的依赖项,并指定 CMake 将克隆的存储库以及它将检出的修订版本。

现在,我们可以读取我们外部库的属性并填充(即检出)它(如果尚未完成)。一旦我们有了源代码,我们可以使用add_subdirectory来处理它们。EXCLUDE_FROM_ALL选项将告诉 CMake 在运行诸如make all这样的命令时,如果其他目标不需要它们,就不要构建这些目标。在成功处理目录后,我们的脚本将打印一条消息,指示 GTests 库在构建后将位于哪个目录中。

如果您不喜欢将依赖项与项目一起构建,也许下一种集成依赖项的方式更适合您。

使用查找脚本

假设你的依赖项在主机的某个地方可用,你可以调用find_package来尝试搜索它。如果你的依赖项提供了配置或目标文件(稍后会详细介绍),那么只需编写这一个简单的命令就足够了。当然,前提是依赖项已经在你的机器上可用。如果没有,你需要在运行 CMake 之前安装它们。

要创建前面的文件,你的依赖项需要使用 CMake,但这并不总是情况。那么,你该如何处理那些不使用 CMake 的库呢?如果这个库很受欢迎,很可能已经有人为你创建了一个查找脚本。版本早于 1.70 的 Boost 库就是这种方法的一个常见例子。CMake 自带一个FindBoost模块,你可以通过运行find_package(Boost)来执行它。

要使用前面的模块找到 Boost,你首先需要在系统上安装它。之后,在你的 CMake 列表中,你应该设置任何你认为合理的选项。例如,要使用动态和多线程 Boost 库,而不是静态链接到 C++运行时,指定如下:

set(Boost_USE_STATIC_LIBS OFF)
set(Boost_USE_MULTITHREADED ON)
set(Boost_USE_STATIC_RUNTIME OFF)

然后,你需要实际搜索库,如下所示:

find_package(Boost 1.69 EXACT REQUIRED COMPONENTS Beast)

在这里,我们指定我们只想使用 Beast,这是 Boost 的一部分,一个很棒的网络库。一旦找到,你可以将它链接到你的目标,如下所示:

target_link_libraries(MyTarget PUBLIC Boost::Beast)

现在你知道如何正确使用查找脚本了,让我们学习如何自己编写一个。

编写查找脚本

如果你的依赖项既没有提供配置和目标文件,也没有人为其编写查找模块,你总是可以自己编写这样的模块。

这不是你经常做的事情,所以我们会尽量简要地介绍一下这个主题。如果你想深入了解,你还应该阅读官方 CMake 文档中的指南(在进一步阅读部分中链接),或者查看 CMake 安装的一些查找模块(通常在 Unix 系统的/usr/share/cmake-3.17/Modules等目录中)。为简单起见,我们假设你只想找到你的依赖项的一个配置,但也可以分别找到ReleaseDebug二进制文件。这将导致设置不同的目标和相关变量。

脚本名称决定了你将传递给find_package的参数;例如,如果你希望最终得到find_package(Foo),那么你的脚本应该命名为FindFoo.cmake

良好的做法是从一个reStructuredText部分开始编写脚本,描述你的脚本实际要做什么,它将设置哪些变量等等。这样的描述示例可能如下:

 #.rst:
 # FindMyDep
 # ----------
 #
 # Find my favourite external dependency (MyDep).
 #
 # Imported targets
 # ^^^^^^^^^^^^^^^^
 #
 # This module defines the following :prop_tgt:`IMPORTED` target:
 #
 # ``MyDep::MyDep``
 #   The MyDep library, if found.
 #

通常,你还会想描述一下你的脚本将设置的变量:

 # Result variables
 # ^^^^^^^^^^^^^^^^
 #
 # This module will set the following variables in your project:
 #
 # ``MyDep_FOUND``
 #   whether MyDep was found or not
 # ``MyDep_VERSION_STRING``
 #   the found version of MyDep

如果MyDep本身有任何依赖项,现在就是找到它们的时候了:

find_package(Boost REQUIRED)

现在我们可以开始搜索库了。一个常见的方法是使用pkg-config

find_package(PkgConfig)
pkg_check_modules(PC_MyDep QUIET MyDep)

如果pkg-config有关于我们的依赖项的信息,它将设置一些我们可以用来找到它的变量。

一个好主意可能是让我们的脚本用户设置一个变量,指向库的位置。按照 CMake 的约定,它应该被命名为MyDep_ROOT_DIR。用户可以通过在构建目录中调用-DMyDep_ROOT_DIR=some/path来提供这个变量给 CMake,修改CMakeCache.txt中的变量,或者使用ccmakecmake-gui程序。

现在,我们可以使用前面提到的路径实际搜索我们的依赖项的头文件和库:

find_path(MyDep_INCLUDE_DIR
   NAMES MyDep.h
   PATHS "${MyDep_ROOT_DIR}/include" "${PC_MyDep_INCLUDE_DIRS}"
   PATH_SUFFIXES MyDep
 )

 find_library(MyDep_LIBRARY
   NAMES mydep
   PATHS "${MyDep_ROOT_DIR}/lib" "${PC_MyDep_LIBRARY_DIRS}"
 )

然后,我们还需要设置找到的版本,就像我们在脚本头部承诺的那样。要使用从pkg-config找到的版本,我们可以编写如下内容:

set(MyDep_VERSION ${PC_MyDep_VERSION})

或者,我们可以手动从头文件的内容、库路径的组件或使用其他任何方法中提取版本。完成后,让我们利用 CMake 的内置脚本来决定库是否成功找到,同时处理find_package调用的所有可能参数:

include(FindPackageHandleStandardArgs)

find_package_handle_standard_args(MyDep
         FOUND_VAR MyDep_FOUND
         REQUIRED_VARS
         MyDep_LIBRARY
         MyDep_INCLUDE_DIR
         VERSION_VAR MyDep_VERSION
         )

由于我们决定提供一个目标而不仅仅是一堆变量,现在是定义它的时候了:

if(MyDep_FOUND AND NOT TARGET MyDep::MyDep)
     add_library(MyDep::MyDep UNKNOWN IMPORTED)
     set_target_properties(MyDep::MyDep PROPERTIES
             IMPORTED_LOCATION "${MyDep_LIBRARY}"
             INTERFACE_COMPILE_OPTIONS "${PC_MyDep_CFLAGS_OTHER}"
             INTERFACE_INCLUDE_DIRECTORIES "${MyDep_INCLUDE_DIR}"
             INTERFACE_LINK_LIBRARIES Boost::boost
             )
endif()

最后,让我们隐藏我们内部使用的变量,以免让不想处理它们的用户看到:

mark_as_advanced(
 MyDep_INCLUDE_DIR
 MyDep_LIBRARY
 )

现在,我们有了一个完整的查找模块,我们可以按以下方式使用它:

find_package(MyDep REQUIRED)
target_link_libraries(MyTarget PRIVATE MyDep::MyDep)

这就是您可以自己编写查找模块的方法。

不要为您自己的包编写Find\*.cmake模块。这些模块是为不支持 CMake 的包而设计的。相反,编写一个Config\*.cmake模块(如本章后面所述)。

现在让我们展示如何使用一个合适的包管理器,而不是自己来处理繁重的工作。

使用 Conan 包管理器

Conan 是一个开源的、去中心化的本地包管理器。它支持多个平台和编译器。它还可以与多个构建系统集成。

如果某个包在您的环境中尚未构建,Conan 将在您的计算机上处理构建它,而不是下载已构建的版本。构建完成后,您可以将其上传到公共存储库、您自己的conan_server实例,或者 Artifactory 服务器。

准备 Conan 配置文件

如果这是您第一次运行 Conan,它将根据您的环境创建一个默认配置文件。您可能希望通过创建新配置文件或更新默认配置文件来修改其中的一些设置。假设我们正在使用 Linux,并且希望使用 GCC 9.x 编译所有内容,我们可以运行以下命令:

 conan profile new hosacpp
 conan profile update settings.compiler=gcc hosacpp
 conan profile update settings.compiler.libcxx=libstdc++11 hosacpp
 conan profile update settings.compiler.version=10 hosacpp
 conan profile update settings.arch=x86_64 hosacpp
 conan profile update settings.os=Linux hosacpp

如果我们的依赖来自于默认存储库之外的其他存储库,我们可以使用conan remote add <repo> <repo_url>来添加它们。例如,您可能希望使用这个来配置您公司的存储库。

现在我们已经设置好了 Conan,让我们展示如何使用 Conan 获取我们的依赖,并将所有这些集成到我们的 CMake 脚本中。

指定 Conan 依赖

我们的项目依赖于 C++ REST SDK。为了告诉 Conan 这一点,我们需要创建一个名为conanfile.txt的文件。在我们的情况下,它将包含以下内容:

 [requires]
 cpprestsdk/2.10.18

 [generators]
 CMakeDeps

您可以在这里指定尽可能多的依赖。每个依赖可以有一个固定的版本、一系列固定版本,或者像latest这样的标签。在@符号之后,您可以找到拥有该包的公司以及允许您选择特定变体的通道(通常是稳定和测试)。

生成器部分是您指定要使用的构建系统的地方。对于 CMake 项目,您应该使用CMakeDeps。您还可以生成许多其他生成器,包括用于生成编译器参数、CMake 工具链文件、Python 虚拟环境等等。

在我们的情况下,我们没有指定任何其他选项,但您可以轻松添加此部分,并为您的包和它们的依赖项配置变量。例如,要将我们的依赖项编译为静态库,我们可以编写以下内容:

 [options]
 cpprestsdk:shared=False

一旦我们放置了conanfile.txt,让我们告诉 Conan 使用它。

安装 Conan 依赖

要在 CMake 代码中使用我们的 Conan 包,我们必须先安装它们。在 Conan 中,这意味着下载源代码并构建它们,或者下载预构建的二进制文件,并创建我们将在 CMake 中使用的配置文件。在我们创建了构建目录后,让 Conan 在我们之后处理这些,我们应该cd进入它,然后简单地运行以下命令:

conan install path/to/directory/containing/conanfile.txt --build=missing -s build_type=Release -pr=hosacpp

默认情况下,Conan 希望下载所有依赖项作为预构建的二进制文件。如果服务器没有预构建它们,Conan 将构建它们,而不是像我们传递了--build=missing标志那样退出。我们告诉它抓取使用与我们配置文件中相同的编译器和环境构建的发布版本。您可以通过简单地使用build_type设置为其他 CMake 构建类型的另一个命令来为多个构建类型安装软件包。如果需要,这可以帮助您快速切换。如果要使用默认配置文件(Conan 可以自动检测到的配置文件),只需不传递-pr标志。

如果我们计划使用的 CMake 生成器没有在conanfile.txt中指定,我们可以将其附加到前面的命令中。例如,要使用compiler_args生成器,我们应该附加--generator compiler_args。稍后,您可以通过将@conanbuildinfo.args传递给编译器调用来使用它生成的内容。

使用 CMake 中的 Conan 目标

一旦 Conan 完成下载、构建和配置我们的依赖关系,我们需要告诉 CMake 使用它们。

如果您正在使用带有CMakeDeps生成器的 Conan,请确保指定CMAKE_BUILD_TYPE值。否则,CMake 将无法使用 Conan 配置的软件包。例如调用(从您运行 Conan 的相同目录)可能如下所示:

cmake path/to/directory/containing/CMakeLists.txt -DCMAKE_BUILD_TYPE=Release

这样,我们将以发布模式构建我们的项目;我们必须使用 Conan 安装的类型之一。要找到我们的依赖关系,我们可以使用 CMake 的find_package

list(APPEND CMAKE_PREFIX_PATH "${CMAKE_BINARY_DIR}")
find_package(cpprestsdk CONFIG REQUIRED)

首先,我们将根构建目录添加到 CMake 将尝试在其中查找软件包配置文件的路径中。然后,我们找到 Conan 生成的软件包配置文件。

要将 Conan 定义的目标作为我们目标的依赖项传递,最好使用命名空间目标名称:

 target_link_libraries(libcustomer PUBLIC cpprestsdk::cpprest)

这样,当找不到包时,我们将在 CMake 的配置期间收到错误。如果没有别名,我们在尝试链接时会收到错误。

现在我们已经按照我们想要的方式编译和链接了我们的目标,是时候进行测试了。

添加测试

CMake 有自己的测试驱动程序,名为CTest。很容易从您的CMakeLists中添加新的测试套件,无论是自己还是使用测试框架提供的许多集成。在本书的后面,我们将深入讨论测试,但首先让我们展示如何快速而干净地基于 GoogleTest 或 GTest 测试框架添加单元测试。

通常,要在 CMake 中定义您的测试,您会想要编写以下内容:

 if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME)
   include(CTest)
   if(BUILD_TESTING)
     add_subdirectory(test)
   endif()
 endif()

前面的片段将首先检查我们是否是正在构建的主项目。通常,您只想为您的项目运行测试,并且甚至不想为您使用的任何第三方组件构建测试。这就是为什么项目名称是checked

如果我们要运行我们的测试,我们包括CTest模块。这将加载 CTest 提供的整个测试基础设施,定义其附加目标,并调用一个名为enable_testing的 CMake 函数,该函数将在其他事项中启用BUILD_TESTING标志。此标志是缓存的,因此您可以通过在生成构建系统时简单地传递-DBUILD_TESTING=OFF参数来禁用所有测试来构建您的项目。

所有这些缓存变量实际上都存储在名为CMakeCache.txt的文本文件中,位于您的构建目录中。随意修改那里的变量以更改 CMake 的操作;直到您删除该文件,它才不会覆盖那里的设置。您可以使用ccmakecmake-gui,或者手动进行修改。

如果BUILD_TESTING为 true,我们只需处理我们测试目录中的CMakeLists.txt文件。可能看起来像这样:

 include(FetchGTest)
 include(GoogleTest)

 add_subdirectory(customer)

第一个 include 调用了我们之前描述的提供 GTest 的脚本。在获取了 GTest 之后,我们当前的CMakeLists.txt通过调用include(GoogleTest)加载了 GoogleTest CMake 模块中定义的一些辅助函数。这将使我们更容易地将我们的测试集成到 CTest 中。最后,让我们告诉 CMake 进入一个包含一些测试的目录,通过调用add_subdirectory(customer)

test/customer/CMakeLists.txt文件将简单地添加一个使用我们预定义的标志编译的带有测试的可执行文件,并链接到被测试的模块和 GTest。然后,我们调用 CTest 辅助函数来发现已定义的测试。所有这些只是四行 CMake 代码:

 add_executable(unittests unit.cpp)
 target_compile_options(unittests PRIVATE ${BASE_COMPILE_FLAGS})
 target_link_libraries(unittests PRIVATE domifair::libcustomer gtest_main)
 gtest_discover_tests(unittests)

大功告成!

现在,您可以通过简单地转到build目录并调用以下命令来构建和执行您的测试:

 cmake --build . --target unittests
 ctest # or cmake --build . --target test

您可以为 CTest 传递一个-j标志。它的工作方式与 Make 或 Ninja 调用相同-并行化测试执行。如果您想要一个更短的构建命令,只需运行您的构建系统,也就是通过调用make

在脚本中,通常最好使用命令的较长形式;这将使您的脚本独立于所使用的构建系统。

一旦您的测试通过了,现在我们可以考虑向更广泛的受众提供它们。

重用优质代码

CMake 具有内置的实用程序,当涉及到分发构建结果时,这些实用程序可以走得更远。本节将描述安装和导出实用程序以及它们之间的区别。后续章节将向您展示如何使用 CPack 打包您的代码,以及如何使用 Conan 进行打包。

安装和导出对于微服务本身并不那么重要,但如果您要为其他人提供库以供重用,这将非常有用。

安装

如果您编写或使用过 Makefiles,您很可能在某个时候调用了make install,并看到项目的交付成果被安装在操作系统目录或您选择的其他目录中。如果您正在使用make与 CMake,使用本节的步骤将使您能够以相同的方式安装交付成果。如果没有,您仍然可以调用安装目标。除此之外,在这两种情况下,您将有一个简单的方法来利用 CPack 来创建基于您的安装命令的软件包。

如果您在 Linux 上,预设一些基于操作系统约定的安装目录可能是一个不错的主意,通过调用以下命令:

include(GNUInstallDirs)

这将使安装程序使用由binlib和其他类似目录组成的目录结构。这些目录也可以使用一些 CMake 变量手动设置。

创建安装目标包括一些更多的步骤。首先,首要的是定义我们要安装的目标,这在我们的情况下将是以下内容:

install(
   TARGETS libcustomer customer
   EXPORT CustomerTargets
   LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
   ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
   RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR})

这告诉 CMake 使用我们在本章前面定义的库和可执行文件作为CustomerTargets公开,使用我们之前设置的目录。

如果您计划将您的库的不同配置安装到不同的文件夹中,您可以使用前面命令的几次调用,就像这样:

 install(TARGETS libcustomer customer
         CONFIGURATIONS Debug
         # destinations for other components go here...
         RUNTIME DESTINATION Debug/bin)
 install(TARGETS libcustomer customer
         CONFIGURATIONS Release
         # destinations for other components go here...
         RUNTIME DESTINATION Release/bin)

您可以注意到我们为可执行文件和库指定了目录,但没有包含文件。我们需要在另一个命令中提供它们,就像这样:

 install(DIRECTORY ${PROJECT_SOURCE_DIR}/include/
         DESTINATION include)

这意味着顶层包含目录的内容将被安装在安装根目录下的包含目录中。第一个路径后面的斜杠修复了一些路径问题,所以请注意使用它。

所以,我们有了一组目标;现在我们需要生成一个文件,另一个 CMake 项目可以读取以了解我们的目标。可以通过以下方式完成:

 install(
     EXPORT CustomerTargets
     FILE CustomerTargets.cmake
     NAMESPACE domifair::
     DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Customer)

此命令将获取我们的目标集并创建一个CustomerTargets.cmake文件,其中将包含有关我们的目标及其要求的所有信息。我们的每个目标都将使用命名空间进行前缀处理;例如,customer将变成domifair::customer。生成的文件将安装在我们安装树中库文件夹的子目录中。

为了允许依赖项目使用 CMake 的find_package命令找到我们的目标,我们需要提供一个CustomerConfig.cmake文件。如果您的目标没有任何依赖项,您可以直接将前面的目标导出到该文件中,而不是targets文件。否则,您应该编写自己的配置文件,其中将包括前面的targets文件。

在我们的情况下,我们想要重用一些 CMake 变量,因此我们需要创建一个模板,并使用configure_file命令来填充它:

  configure_file(${PROJECT_SOURCE_DIR}/cmake/CustomerConfig.cmake.in
                  CustomerConfig.cmake @ONLY)

我们的CustomerConfig.cmake.in文件将首先处理我们的依赖项:

 include(CMakeFindDependencyMacro)

 find_dependency(cpprestsdk 2.10.18 REQUIRED)

find_dependency宏是find_package的包装器,旨在在配置文件中使用。尽管我们依赖 Conan 在conanfile.txt中定义的 C++ REST SDK 2.10.18,但在这里我们需要再次指定依赖关系。我们的软件包可以在另一台机器上使用,因此我们要求我们的依赖项也在那里安装。如果您想在目标机器上使用 Conan,可以按以下方式安装 C++ REST SDK:

conan install cpprestsdk/2.10.18

处理完依赖项后,我们的配置文件模板将包括我们之前创建的targets文件:

if(NOT TARGET domifair::@PROJECT_NAME@)
   include("${CMAKE_CURRENT_LIST_DIR}/@PROJECT_NAME@Targets.cmake")
endif()

configure_file执行时,它将用项目中定义的${VARIABLES}的内容替换所有这些@VARIABLES@。这样,基于我们的CustomerConfig.cmake.in文件模板,CMake 将创建一个CustomerConfig.cmake文件。

在使用find_package查找依赖项时,通常需要指定要查找的软件包的版本。为了在我们的软件包中支持这一点,我们必须创建一个CustomerConfigVersion.cmake文件。CMake 为我们提供了一个辅助函数,可以为我们创建此文件。让我们按照以下方式使用它:

 include(CMakePackageConfigHelpers)
 write_basic_package_version_file(
   CustomerConfigVersion.cmake
   VERSION ${PACKAGE_VERSION}
   COMPATIBILITY AnyNewerVersion)

PACKAGE_VERSION变量将根据我们在调用顶层CMakeLists.txt文件顶部的project时传递的VERSION参数进行填充。

AnyNewerVersion COMPATIBILITY表示如果我们的软件包比请求的版本更新或相同,它将被任何软件包搜索接受。其他选项包括SameMajorVersionSameMinorVersionExactVersion

一旦我们创建了我们的配置和配置版本文件,让我们告诉 CMake 它们应该与二进制文件和我们的目标文件一起安装:

install(FILES ${CMAKE_CURRENT_BINARY_DIR}/CustomerConfig.cmake
               ${CMAKE_CURRENT_BINARY_DIR}/CustomerConfigVersion.cmake
         DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Customer)

我们应该安装的最后一件事是我们项目的许可证。我们将利用 CMake 的安装文件的命令将它们放在我们的文档目录中:

install(
   FILES ${PROJECT_SOURCE_DIR}/LICENSE
   DESTINATION ${CMAKE_INSTALL_DOCDIR})

这就是您成功在操作系统根目录中创建安装目标所需了解的全部内容。您可能会问如何将软件包安装到另一个目录,比如仅供当前用户使用。要这样做,您需要设置CMAKE_INSTALL_PREFIX变量,例如,在生成构建系统时。

请注意,如果我们不安装到 Unix 树的根目录,我们将不得不为依赖项目提供安装目录的路径,例如通过设置CMAKE_PREFIX_PATH

现在让我们看看另一种您可以重用刚刚构建的东西的方法。

导出

导出是一种将您在本地构建的软件包的信息添加到 CMake 的软件包注册表中的技术。当您希望您的目标可以直接从它们的构建目录中看到,即使没有安装时,这将非常有用。导出的常见用途是当您在开发机器上检出了几个项目并在本地构建它们时。

从您的CMakeLists.txt文件中添加对此机制的支持非常容易。在我们的情况下,可以这样做:

export(
   TARGETS libcustomer customer
   NAMESPACE domifair::
   FILE CustomerTargets.cmake)

set(CMAKE_EXPORT_PACKAGE_REGISTRY ON)
export(PACKAGE domifair)

这样,CMake 将创建一个类似于Installing部分中的目标文件,定义我们在提供的命名空间中的库和可执行目标。从 CMake 3.15 开始,默认情况下禁用软件包注册表,因此我们需要通过设置适当的前置变量来启用它。然后,通过导出我们的软件包,我们可以将有关我们的目标的信息直接放入注册表中。

请注意,现在我们有一个没有匹配配置文件的targets文件。这意味着如果我们的目标依赖于任何外部库,它们必须在我们的软件包被找到之前被找到。在我们的情况下,调用必须按照以下方式排序:

 find_package(cpprestsdk 2.10.18)
 find_package(domifair)

首先,我们找到 C++ REST SDK,然后再寻找依赖于它的软件包。这就是你需要知道的一切,就可以开始导出你的目标了。比安装它们要容易得多,不是吗?

现在让我们继续介绍第三种将您的目标暴露给外部世界的方法。

使用 CPack

在本节中,我们将描述如何使用 CMake 附带的打包工具 CPack。

CPack 允许您轻松创建各种格式的软件包,从 ZIP 和 TGZ 存档到 DEB 和 RPM 软件包,甚至安装向导,如 NSIS 或一些特定于 OS X 的软件包。一旦您安装逻辑就位,集成工具并不难。让我们展示如何使用 CPack 来打包我们的项目。

首先,我们需要指定 CPack 在创建软件包时将使用的变量:

 set(CPACK_PACKAGE_VENDOR "Authors")
 set(CPACK_PACKAGE_CONTACT "author@example.com")
 set(CPACK_PACKAGE_DESCRIPTION_SUMMARY
     "Library and app for the Customer microservice")

我们需要手动提供一些信息,但是一些变量可以根据我们在定义项目时指定的项目版本来填充。CPack 变量还有很多,您可以在本章末尾的进一步阅读部分的 CPack 链接中阅读所有这些变量。其中一些对所有软件包生成器都是通用的,而另一些则特定于其中的一些。例如,如果您计划使用安装程序,您可以设置以下两个:

set(CPACK_RESOURCE_FILE_LICENSE "${PROJECT_SOURCE_DIR}/LICENSE")

set(CPACK_RESOURCE_FILE_README "${PROJECT_SOURCE_DIR}/README.md")

一旦您设置了所有有趣的变量,就该选择 CPack 要使用的生成器了。让我们从在CPACK_GENERATOR中放置一些基本的生成器开始,这是 CPack 依赖的一个变量:

list(APPEND CPACK_GENERATOR TGZ ZIP)

这将导致 CPack 基于我们在本章前面定义的安装步骤生成这两种类型的存档。

你可以根据许多因素选择不同的软件包生成器,例如,正在运行的机器上可用的工具。例如,在 Windows 上构建时创建 Windows 安装程序,在 Linux 上构建时使用适当的工具安装 DEB 或 RPM 软件包。例如,如果你正在运行 Linux,你可以检查是否安装了dpkg,如果是,则创建 DEB 软件包:

 if(UNIX)
   find_program(DPKG_PROGRAM dpkg)
   if(DPKG_PROGRAM)
     list(APPEND CPACK_GENERATOR DEB)
     set(CPACK_DEBIAN_PACKAGE_DEPENDS "${CPACK_DEBIAN_PACKAGE_DEPENDS} libcpprest2.10 (>= 2.10.2-6)")
     set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
   else()
     message(STATUS "dpkg not found - won't be able to create DEB packages")
   endif()

我们使用了CPACK_DEBIAN_PACKAGE_DEPENDS变量,使 DEB 软件包要求首先安装 C++ REST SDK。

对于 RPM 软件包,您可以手动检查rpmbuild

 find_program(RPMBUILD_PROGRAM rpmbuild)
   if(RPMBUILD_PROGRAM)
     list(APPEND CPACK_GENERATOR RPM)
     set(CPACK_RPM_PACKAGE_REQUIRES "${CPACK_RPM_PACKAGE_REQUIRES} cpprest >= 2.10.2-6")
   else()
     message(STATUS "rpmbuild not found - won't be able to create RPM packages")
   endif()
 endif()

很巧妙,对吧?

这些生成器提供了大量其他有用的变量,所以如果您需要比这里描述的基本需求更多的东西,请随时查看 CMake 的文档。

当涉及到变量时,最后一件事是,您也可以使用它们来避免意外打包不需要的文件。这可以通过以下方式完成:

set(CPACK_SOURCE_IGNORE_FILES /.git /dist /.*build.* /\\\\.DS_Store)

一旦我们把所有这些都放在位子上,我们可以从我们的 CMake 列表中包含 CPack 本身:

include(CPack)

记住,始终将此作为最后一步进行,因为 CMake 不会将您稍后使用的任何变量传播给 CPack。

要运行它,直接调用cpack或更长的形式,它还会检查是否需要首先重新构建任何内容:cmake --build . --target package。您可以轻松地通过-G标志覆盖生成器,例如,-G DEB只需构建 DEB 软件包,-G WIX -C Release打包一个发布的 MSI 可执行文件,或-G DragNDrop获取 DMG 安装程序。

现在让我们讨论一种更原始的构建软件包的方法。

使用 Conan 打包

我们已经展示了如何使用 Conan 安装我们的依赖项。现在,让我们深入了解如何创建我们自己的 Conan 软件包。

让我们在我们的项目中创建一个新的顶级目录,简单地命名为conan,在那里我们将使用这个工具打包所需的文件:一个用于构建我们的软件包的脚本和一个用于测试的环境。

创建 conanfile.py 脚本

所有 Conan 软件包所需的最重要的文件是conanfile.py。在我们的情况下,我们将使用 CMake 变量填写一些细节,所以我们将创建一个conanfile.py.in文件。我们将使用它来通过将以下内容添加到我们的CMakeLists.txt文件来创建前一个文件:

configure_file(${PROJECT_SOURCE_DIR}/conan/conanfile.py.in
                ${CMAKE_CURRENT_BINARY_DIR}/conan/conanfile.py @ONLY)

我们的文件将以一些无聊的 Python 导入开始,例如 Conan 对于 CMake 项目所需的导入:

 import os
 from conans import ConanFile, CMake

现在我们需要创建一个定义我们软件包的类:

class CustomerConan(ConanFile):
     name = "customer"
     version = "@PROJECT_VERSION@"
     license = "MIT"
     author = "Authors"
     description = "Library and app for the Customer microservice"
     topics = ("Customer", "domifair")

首先,我们从我们的 CMake 代码中获取一堆通用变量。通常,描述将是一个多行字符串。主题对于在 JFrog 的 Artifactory 等网站上找到我们的库非常有用,并且可以告诉读者我们的软件包是关于什么的。现在让我们浏览其他变量:

     homepage = "https://example.com"
     url = "https://github.com/PacktPublishing/Hands-On-Software-Architecture-with-Cpp/"

homepage应该指向项目的主页:文档、教程、常见问题解答等内容的所在地。另一方面,url是软件包存储库的位置。许多开源库将其代码放在一个存储库中,将打包代码放在另一个存储库中。一个常见情况是软件包由中央 Conan 软件包服务器构建。在这种情况下,url应该指向https://github.com/conan-io/conan-center-index

接下来,我们现在可以指定我们的软件包是如何构建的:

     settings = "os", "compiler", "build_type", "arch"
     options = {"shared": [True, False], "fPIC": [True, False]}
     default_options = {"shared": False, "fPIC": True}
     generators = "CMakeDeps"
     keep_imports = True  # useful for repackaging, e.g. of licenses

settings将确定软件包是否需要构建,还是可以下载已构建的版本。

optionsdefault_options的值可以是任何你喜欢的。sharedfPIC是大多数软件包提供的两个选项,所以让我们遵循这个约定。

现在我们已经定义了我们的变量,让我们开始编写 Conan 将用于打包我们软件的方法。首先,我们指定我们的库,消费我们软件包的人应该链接到:

    def package_info(self):
         self.cpp_info.libs = ["customer"]

self.cpp_info对象允许设置更多内容,但这是最低限度。请随意查看 Conan 文档中的其他属性。

接下来,让我们指定其他需要的软件包:

    def requirements(self):
         self.requires.add('cpprestsdk/2.10.18')

这一次,我们直接从 Conan 中获取 C++ REST SDK,而不是指定 OS 的软件包管理器应该依赖哪些软件包。现在,让我们指定 CMake 应该如何(以及在哪里)生成我们的构建系统:

    def _configure_cmake(self):
         cmake = CMake(self)
         cmake.configure(source_folder="@CMAKE_SOURCE_DIR@")
         return cmake

在我们的情况下,我们只需将其指向源目录。一旦配置了构建系统,我们将需要实际构建我们的项目:

    def build(self):
         cmake = self._configure_cmake()
         cmake.build()

Conan 还支持非基于 CMake 的构建系统。构建我们的软件包之后,就是打包时间,这需要我们提供另一种方法:

    def package(self):
         cmake = self._configure_cmake()
         cmake.install()
         self.copy("license*", ignore_case=True, keep_path=True)

请注意,我们正在使用相同的_configure_cmake()函数来构建和打包我们的项目。除了安装二进制文件之外,我们还指定许可证应该部署的位置。最后,让我们告诉 Conan 在安装我们的软件包时应该复制什么:

    def imports(self):
         self.copy("license*", dst="licenses", folder=True, ignore_case=True)

         # Use the following for the cmake_multi generator on Windows and/or Mac OS to copy libs to the right directory.
         # Invoke Conan like so:
         #   conan install . -e CONAN_IMPORT_PATH=Release -g cmake_multi
         dest = os.getenv("CONAN_IMPORT_PATH", "bin")
         self.copy("*.dll", dst=dest, src="img/bin")
         self.copy("*.dylib*", dst=dest, src="img/lib")

前面的代码指定了在安装库时解压许可文件、库和可执行文件的位置。

现在我们知道如何构建一个 Conan 软件包,让我们也看看如何测试它是否按预期工作。

测试我们的 Conan 软件包

一旦 Conan 构建我们的包,它应该测试它是否被正确构建。为了做到这一点,让我们首先在我们的conan目录中创建一个test_package子目录。

它还将包含一个conanfile.py脚本,但这次是一个更短的脚本。它应该从以下内容开始:

import os

from conans import ConanFile, CMake, tools

class CustomerTestConan(ConanFile):
     settings = "os", "compiler", "build_type", "arch"
     generators = "CMakeDeps"

这里没有太多花哨的东西。现在,我们应该提供构建测试包的逻辑:

    def build(self):
        cmake = CMake(self)
        # Current dir is "test_package/build/<build_id>" and 
        # CMakeLists.txt is in "test_package"
        cmake.configure()
        cmake.build()

我们将在一秒钟内编写我们的CMakeLists.txt文件。但首先,让我们写两件事:imports方法和test方法。imports方法可以编写如下:

    def imports(self):
        self.copy("*.dll", dst="bin", src="img/bin")
        self.copy("*.dylib*", dst="bin", src="img/lib")
        self.copy('*.so*', dst='bin', src='lib')

然后我们有我们的包测试逻辑的核心 - test方法:

    def test(self):
         if not tools.cross_building(self.settings):
             self.run(".%sexample" % os.sep)

我们只希望在为本机架构构建时运行它。否则,我们很可能无法运行已编译的可执行文件。

现在让我们定义我们的CMakeLists.txt文件:

 cmake_minimum_required(VERSION 3.12)
 project(PackageTest CXX)

 list(APPEND CMAKE_PREFIX_PATH "${CMAKE_BINARY_DIR}")

 find_package(customer CONFIG REQUIRED)

 add_executable(example example.cpp)
 target_link_libraries(example customer::customer)

 # CTest tests can be added here

就这么简单。我们链接到所有提供的 Conan 库(在我们的情况下,只有我们的 Customer 库)。

最后,让我们编写我们的example.cpp文件,其中包含足够的逻辑来检查包是否成功创建:

 #include <customer/customer.h>

 int main() { responder{}.prepare_response("Conan"); }

在我们开始运行所有这些之前,我们需要在我们的 CMake 列表的主树中进行一些小的更改。现在让我们看看如何正确从我们的 CMake 文件中导出 Conan 目标。

将 Conan 打包代码添加到我们的 CMakeLists

记得我们在重用优质代码部分编写的安装逻辑吗?如果您依赖 Conan 进行打包,您可能不需要运行裸的 CMake 导出和安装逻辑。假设您只想在不使用 Conan 时导出和安装,您需要修改您的CMakeLists中的安装子部分,使其类似于以下内容:

if(NOT CONAN_EXPORTED)
   install(
     EXPORT CustomerTargets
     FILE CustomerTargets.cmake
     NAMESPACE domifair::
     DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Customer)

   configure_file(${PROJECT_SOURCE_DIR}/cmake/CustomerConfig.cmake.in
                  CustomerConfig.cmake @ONLY)

   include(CMakePackageConfigHelpers)
   write_basic_package_version_file(
     CustomerConfigVersion.cmake
     VERSION ${PACKAGE_VERSION}
     COMPATIBILITY AnyNewerVersion)

   install(FILES ${CMAKE_CURRENT_BINARY_DIR}/CustomerConfig.cmake
                 ${CMAKE_CURRENT_BINARY_DIR}/CustomerConfigVersion.cmake
           DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/Customer)
 endif()

 install(
   FILES ${PROJECT_SOURCE_DIR}/LICENSE
   DESTINATION $<IF:$<BOOL:${CONAN_EXPORTED}>,licenses,${CMAKE_INSTALL_DOCDIR}>)

添加 if 语句和生成器表达式是为了获得干净的包,这就是我们需要做的一切。

最后一件事是让我们的生活变得更轻松 - 一个我们可以构建以创建 Conan 包的目标。我们可以定义如下:

add_custom_target(
   conan
   COMMAND
     ${CMAKE_COMMAND} -E copy_directory ${PROJECT_SOURCE_DIR}/conan/test_package/
     ${CMAKE_CURRENT_BINARY_DIR}/conan/test_package
   COMMAND conan create . customer/testing -s build_type=$<CONFIG>
   WORKING_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/conan
   VERBATIM)

现在,当我们运行cmake --build . --target conan(或者如果我们使用该生成器并且想要一个简短的调用,则为ninja conan),CMake 将把我们的test_package目录复制到build文件夹中,构建我们的 Conan 包,并使用复制的文件进行测试。

全部完成!

这是冰山一角,关于创建 Conan 包的更多信息,请参考 Conan 的文档。您可以在进一步阅读部分找到链接。

总结

在本章中,您已经学到了很多关于构建和打包代码的知识。您现在能够编写更快构建的模板代码,知道如何选择工具来更快地编译代码(您将在下一章中了解更多关于工具的知识),并知道何时使用前向声明而不是#include指令。

除此之外,您现在可以使用现代 CMake 定义构建目标和测试套件,使用查找模块和FetchContent管理外部依赖项,以各种格式创建包和安装程序,最重要的是,使用 Conan 安装依赖项并创建自己的构件。

在下一章中,我们将看看如何编写易于测试的代码。持续集成和持续部署只有在有很好的测试覆盖率时才有用。没有全面测试的持续部署将使您更快地向生产中引入新的错误。当我们设计软件架构时,这不是我们的目标。

问题

  1. 在 CMake 中安装和导出目标有什么区别?

  2. 如何使您的模板代码编译更快?

  3. 如何在 Conan 中使用多个编译器?

  4. 如果您想使用预 C++11 GCC ABI 编译您的 Conan 依赖项,该怎么办?

  5. 如何确保在 CMake 中强制使用特定的 C++标准?

  6. 如何在 CMake 中构建文档并将其与您的 RPM 包一起发布?

进一步阅读

第三部分:架构质量属性

本节更专注于一起使软件项目成功的高层概念。在可能的情况下,我们还将展示有助于保持我们想要实现的高质量的工具。

本节包括以下章节:

  • 第八章,可测试代码编写

  • 第九章,持续集成和持续部署

  • 第十章,代码和部署中的安全性

  • 第十一章,性能

第八章:编写可测试的代码

代码测试的能力是任何软件产品最重要的质量。没有适当的测试,重构代码或改进其安全性、可扩展性或性能等其他部分将成本高昂。在本章中,我们将学习如何设计和管理自动化测试,以及在必要时如何正确使用伪造和模拟。

本章将涵盖以下主题:

  • 为什么要测试代码?

  • 引入测试框架

  • 理解模拟和伪造

  • 测试驱动的类设计

  • 自动化测试以实现持续集成/持续部署

技术要求

本章的示例代码可以在github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter08找到。

本章示例中将使用的软件如下:

  • GTest 1.10+

  • Catch2 2.10+

  • CppUnit 1.14+

  • Doctest 2.3+

  • Serverspec 2.41+

  • Testinfra 3.2+

  • Goss 0.3+

  • CMake 3.15+

  • Autoconf

  • Automake

  • Libtool

为什么要测试代码?

软件工程和软件架构是非常复杂的问题,应对不确定性的自然方式是对潜在风险进行保险。我们一直在做人寿保险、健康保险和汽车保险。然而,当涉及软件开发时,我们往往忘记了所有的安全预防措施,只是希望有一个乐观的结果。

知道事情不仅可能而且一定会出错,测试软件的话题仍然是一个有争议的话题,这是令人难以置信的。无论是因为缺乏技能还是缺乏预算,仍然有一些项目甚至缺乏一些最基本的测试。当客户决定更改需求时,简单的更正可能导致无休止的重做和火拼。

由于没有实施适当的测试而节省的时间将在第一次重做时丢失。如果您认为这次重做不会很快发生,那么您很可能是大错特错。在我们现在生活的敏捷环境中,重做是我们日常生活的一部分。我们对世界和客户的了解意味着需求会发生变化,随之而来的是对我们代码的更改。

因此,测试的主要目的是在项目后期保护您宝贵的时间。当您不得不实施各种测试而不是仅专注于功能时,这当然是一个早期的投资,但这是一个您不会后悔的投资。就像保险政策一样,当事情按计划进行时,测试会从您的预算中少扣一点,但当事情变糟时,您将获得丰厚的回报。

测试金字塔

在设计或实施软件系统时,您可能会遇到不同类型的测试。每个类别都有稍微不同的目的。它们可以归类如下:

  • 单元测试:代码

  • 集成测试:设计

  • 系统测试:需求

  • 验收测试(端到端或 E2E):客户需求

这种区分是任意的,您可能经常看到金字塔的其他层,如下所示:

  • 单元测试

  • 服务测试

  • UI 测试(端到端或 E2E)

在这里,单元测试指的是与前面示例中相同的层。服务测试指的是集成测试和系统测试的组合。另一方面,UI 测试指的是验收测试。以下图显示了测试金字塔:

图 8.1 - 测试金字塔

值得注意的是,单元测试不仅是最便宜的构建方式,而且执行速度相当快,通常可以并行运行。这意味着它们非常适合作为持续集成的门控机制。不仅如此,它们通常也提供有关系统健康状况的最佳反馈。高级别测试不仅更难正确编写,而且可能不够健壮。这可能导致测试结果闪烁,每隔一段时间就会有一次测试运行失败。如果高级别测试的失败与单元测试级别的任何失败都没有关联,那么问题很可能出在测试本身而不是被测试系统上。

我们不想说高级别测试完全没有用,也不是说您应该只专注于编写单元测试。情况并非如此。金字塔之所以呈现这种形状,是因为应该有由单元测试覆盖的坚实基础。然而,在这个基础上,您还应该以适当的比例拥有所有高级别测试。毕竟,很容易想象出一个系统,其中所有单元测试都通过了,但系统本身对客户没有任何价值。一个极端的例子是一个完全正常工作的后端,没有任何用户界面(无论是图形界面还是 API 形式)。当然,它通过了所有的单元测试,但这并不是借口!

正如您所想象的那样,测试金字塔的相反称为冰锥,这是一种反模式。违反测试金字塔通常会导致脆弱的代码和难以追踪的错误。这使得调试成本更高,也不会在测试开发中节省成本。

非功能性测试

我们已经涵盖的是所谓的功能测试。它们的目的是检查被测试系统是否满足功能要求。但除了功能要求之外,还有其他类型的要求我们可能想要控制。其中一些如下:

  • 性能:您的应用程序可能在功能方面符合要求,但由于性能不佳,对最终用户来说仍然无法使用。我们将在第十一章中更多关注性能改进。

  • 耐久性:即使您的系统可能表现得非常出色,也并不意味着它能够承受持续的高负载。即使能够承受,它能够承受组件的一些故障吗?当我们接受这样一个观念,即每一款软件都是脆弱的,可能在任何时刻都会出现故障,我们开始设计可以抵御故障的系统。这是艾林生态系统所采纳的概念,但这个概念本身并不局限于该环境。在第十三章中,设计微服务,以及第十五章中,云原生设计,我们将更多地提到设计具有容错能力的系统以及混沌工程的作用。

  • 安全性:现在,应该没有必要重复强调安全性的重要性。但由于安全性仍未得到应有的重视,我们将再次强调这一点。与网络连接的每个系统都可能被破解。在开发早期进行安全性测试可以带来与其他类型测试相同的好处:您可以在问题变得过于昂贵之前发现问题。

  • 可用性:性能不佳可能会阻止最终用户使用您的产品,而可用性不佳可能会阻止他们甚至访问该产品。虽然可用性问题可能是由于性能过载引起的,但也有其他导致可用性丧失的原因。

  • 完整性:您的客户数据不仅应该受到外部攻击者的保护,还应该免受由于软件故障而导致的任何更改或损失。防止完整性损失的方法包括防止位腐败、快照和备份。通过将当前版本与先前记录的快照进行比较,您可以确保差异仅由采取的操作引起,还是由错误引起。

  • 可用性:即使产品符合以前提到的所有要求,如果它具有笨拙的界面和不直观的交互,对用户来说仍然可能不尽人意。可用性测试大多是手动执行的。每次 UI 或系统工作流程发生变化时,执行可用性评估非常重要。

回归测试

回归测试通常是端到端测试,应该防止您再次犯同样的错误。当您(或您的质量保证团队或客户)在生产系统中发现错误时,仅仅应用热修复并忘记所有这些是不够的。

您需要做的一件事是编写一个回归测试,以防止相同的错误再次进入生产系统。良好的回归测试甚至可以防止相同的错误再次进入生产。毕竟,一旦您知道自己做错了什么,您就可以想象其他搞砸事情的方式。另一件事是执行根本原因分析。

根本原因分析

根本原因分析是一个过程,它帮助您发现问题的根本原因,而不仅仅是其表现形式。执行根本原因分析的最常见方法是使用“5 个为什么”的方法,这一方法是由丰田公司所著名的。这种方法包括剥离问题表现的所有表面层,以揭示隐藏在其下的根本原因。您可以通过在每一层询问“为什么”来做到这一点,直到找到您正在寻找的根本原因。

让我们看一个这种方法在实际中的例子。

问题:我们没有收到一些交易的付款:

  1. 为什么?系统没有向客户发送适当的电子邮件。

  2. 为什么?邮件发送系统不支持客户姓名中的特殊字符。

  3. 为什么?邮件发送系统没有得到适当测试。

  4. 为什么?由于需要开发新功能,没有时间进行适当的测试。

  5. 为什么?我们对功能的时间估计不正确。

在这个例子中,对功能的时间估计问题可能是在生产系统中发现的错误的根本原因。但它也可能是另一个需要剥离的层。该框架为您提供了一个应该在大多数情况下有效的启发式方法,但如果您并不完全确定您得到的是否就是您要找的,您可以继续剥离额外的层,直到找到导致所有麻烦的原因。

鉴于许多错误都是由完全相同且经常可重复的根本原因导致的,找到根本原因是非常有益的,因为您可以在未来多个不同的层面上保护自己免受相同错误的影响。这是深度防御原则在软件测试和问题解决中的应用。

进一步改进的基础

对代码进行测试可以保护您免受意外错误的影响。但它也开启了不同的可能性。当您的代码由测试用例覆盖时,您就不必担心重构。重构是将完成其工作的代码转换为功能上类似但内部组织更好的代码的过程。您可能会想知道为什么需要更改代码的组织。这样做有几个原因。

首先,你的代码可能已经不再可读,这意味着每次修改都需要太多时间。其次,修复一个你即将修复的错误会导致一些其他功能表现不正确,因为随着时间的推移,代码中积累了太多的变通和特殊情况。这两个原因都可以归结为提高生产力。它们将使维护成本长期更加便宜。

但除了生产力之外,您可能还希望提高性能。这可能意味着运行时性能(应用程序在生产中的行为)或编译时性能(基本上是另一种形式的生产力改进)。

您可以通过用更高效的算法替换当前的次优算法或通过更改正在重构的模块中使用的数据结构来进行运行时性能重构。

编译时性能重构通常包括将代码的部分移动到不同的编译单元,重新组织头文件或减少依赖关系。

无论您的最终目标是什么,重构通常是一项风险很大的工作。您拿到的是大部分正确工作的东西,最终可能会得到一个更好的版本,也可能会得到一个更糟糕的版本。您怎么知道哪种情况是您的?在这里,测试就派上了用场。

如果当前的功能集已经得到充分覆盖,并且您想修复最近发现的错误,您需要做的就是添加另一个在那时会失败的测试用例。当您的整个测试套件再次开始通过时,意味着您的重构工作是成功的。

最坏的情况是,如果您无法在指定的时间范围内满足所有测试用例,您将不得不中止重构过程。如果您想要提高性能,您将进行类似的过程,但是不是针对单元测试(或端到端测试),而是专注于性能测试。

随着自动化工具的崛起,这些工具可以帮助重构(例如 ReSharper C++:www.jetbrains.com/resharper-cpp/features/)和代码维护,您甚至可以将部分编码外包给外部软件服务。像 Renovate(renovatebot.com/)、Dependabot(dependabot.com)和 Greenkeeper(greenkeeper.io/)这样的服务可能很快就会支持 C++依赖项。拥有坚实的测试覆盖率将使您能够在依赖项更新期间使用它们,而不用担心破坏应用程序。

由于始终要考虑保持依赖项的安全漏洞最新状态,这样的服务可以显著减轻负担。因此,测试不仅可以保护您免受错误,还可以减少引入新功能所需的工作量。它还可以帮助您改进代码库并保持其稳定和安全!

既然我们了解了测试的必要性,我们想要开始编写我们自己的测试。可以在没有任何外部依赖项的情况下编写测试。但是,我们只想专注于测试逻辑。我们对管理测试结果和报告的细节不感兴趣。因此,我们将选择一个测试框架来为我们处理这项繁琐的工作。在下一节中,我们将介绍一些最受欢迎的测试框架。

引入测试框架

至于框架,当前的事实标准是 Google 的 GTest。与其配对的 GMock 一起,它们形成了一套小型工具,使您能够遵循 C++中的最佳测试实践。

GTest/GMock 二人组的其他热门替代方案包括 Catch2、CppUnit 和 Doctest。CppUnit 已经存在很长时间了,但由于缺乏最近的发布,我们不建议将其用于新项目。Catch2 和 Doctest 都支持现代 C++标准-特别是 C++14、C++17 和 C++20。

为了比较这些测试框架,我们将使用相同的代码库来进行测试。基于此,我们将在每个框架中实现测试。

GTest 示例

这是一个使用 GTest 编写的客户库的示例测试:

#include "customer/customer.h"

#include <gtest/gtest.h>

TEST(basic_responses, given_name_when_prepare_responses_then_greets_friendly) {
  auto name = "Bob";
  auto code_and_string = responder{}.prepare_response(name);
  ASSERT_EQ(code_and_string.first, web::http::status_codes::OK);
  ASSERT_EQ(code_and_string.second, web::json::value("Hello, Bob!"));
}

大多数在测试期间通常完成的任务已经被抽象化了。我们主要关注提供我们想要测试的操作(prepare_response)和期望的行为(两个ASSERT_EQ行)。

Catch2 示例

这是一个使用 Catch2 编写的客户库的示例测试:

#include "customer/customer.h"

#define CATCH_CONFIG_MAIN // This tells Catch to provide a main() - only do
                           // this in one cpp file
#include "catch2/catch.hpp"

TEST_CASE("Basic responses",
          "Given Name When Prepare Responses Then Greets Friendly") {
  auto name = "Bob";
  auto code_and_string = responder{}.prepare_response(name);
  REQUIRE(code_and_string.first == web::http::status_codes::OK);
  REQUIRE(code_and_string.second == web::json::value("Hello, Bob!"));
}

它看起来与前一个非常相似。一些关键字不同(TESTTEST_CASE),并且检查结果的方式略有不同(REQUIRE(a == b)而不是ASSERT_EQ(a,b))。无论如何,两者都非常简洁和易读。

CppUnit 示例

这是一个使用 CppUnit 编写的客户库的示例测试。我们将其拆分为几个片段。

以下代码块准备我们使用 CppUnit 库中的构造:

#include <cppunit/BriefTestProgressListener.h>
#include <cppunit/CompilerOutputter.h>
#include <cppunit/TestCase.h>
#include <cppunit/TestFixture.h>
#include <cppunit/TestResult.h>
#include <cppunit/TestResultCollector.h>
#include <cppunit/TestRunner.h>
#include <cppunit/XmlOutputter.h>
#include <cppunit/extensions/HelperMacros.h>
#include <cppunit/extensions/TestFactoryRegistry.h>
#include <cppunit/ui/text/TextTestRunner.h>

#include "customer/customer.h"

using namespace CppUnit;
using namespace std;

接下来,我们必须定义测试类并实现将执行我们的测试用例的方法。之后,我们必须注册类,以便我们可以在我们的测试运行器中使用它:

class TestBasicResponses : public CppUnit::TestFixture {
  CPPUNIT_TEST_SUITE(TestBasicResponses);
  CPPUNIT_TEST(testBob);
  CPPUNIT_TEST_SUITE_END();

 protected:
  void testBob();
};

void TestBasicResponses::testBob() {
  auto name = "Bob";
  auto code_and_string = responder{}.prepare_response(name);
  CPPUNIT_ASSERT(code_and_string.first == web::http::status_codes::OK);
  CPPUNIT_ASSERT(code_and_string.second == web::json::value("Hello, Bob!"));
}

CPPUNIT_TEST_SUITE_REGISTRATION(TestBasicResponses);

最后,我们必须提供我们测试运行器的行为:

int main() {
  CPPUNIT_NS::TestResult testresult;

  CPPUNIT_NS::TestResultCollector collectedresults;
  testresult.addListener(&collectedresults);

  CPPUNIT_NS::BriefTestProgressListener progress;
  testresult.addListener(&progress);

  CPPUNIT_NS::TestRunner testrunner;
  testrunner.addTest(CPPUNIT_NS::TestFactoryRegistry::getRegistry().makeTest());
  testrunner.run(testresult);

  CPPUNIT_NS::CompilerOutputter compileroutputter(&collectedresults, std::cerr);
  compileroutputter.write();

  ofstream xmlFileOut("cppTestBasicResponsesResults.xml");
  XmlOutputter xmlOut(&collectedresults, xmlFileOut);
  xmlOut.write();

  return collectedresults.wasSuccessful() ? 0 : 1;
}

与前两个示例相比,这里有很多样板代码。然而,测试本身看起来与前一个示例非常相似。

Doctest 示例

这是一个使用 Doctest 编写的客户库的示例测试:

#include "customer/customer.h"

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>

TEST_CASE("Basic responses") {
  auto name = "Bob";
  auto code_and_string = responder{}.prepare_response(name);
  REQUIRE(code_and_string.first == web::http::status_codes::OK);
  REQUIRE(code_and_string.second == web::json::value("Hello, Bob!"));
}

再次,它非常干净且易于理解。Doctest 的主要卖点是,与其他类似功能的替代品相比,它在编译时和运行时都是最快的。

测试编译时代码

模板元编程允许我们编写在编译时执行的 C++代码,而不是通常的执行时间。在 C++11 中添加的constexpr关键字允许我们使用更多的编译时代码,而 C++20 中的consteval关键字旨在让我们更好地控制代码的评估方式。

编译时编程的问题之一是没有简单的方法来测试它。虽然执行时间代码的单元测试框架很丰富(正如我们刚才看到的),但关于编译时编程的资源并不那么丰富。部分原因可能是编译时编程仍然被认为是复杂的,只针对专家。

仅仅因为某些事情不容易并不意味着它是不可能的。就像执行时间测试依赖于运行时检查断言一样,您可以使用static_assert来检查您的编译时代码的正确行为,这是在 C++11 中与constexpr一起引入的。

以下是使用static_assert的一个简单示例:

#include <string_view>

constexpr int generate_lucky_number(std::string_view name) {
  if (name == "Bob") {
    number = number * 7 + static_cast<int>(letter);
  }
  return number;
}

static_assert(generate_lucky_number("Bob") == 808);

由于我们可以在编译时计算这里测试的每个值,我们可以有效地使用编译器作为我们的测试框架。

理解模拟对象和伪造对象

只要您测试的函数与外部世界的交互不太多,事情就会变得相当容易。当您测试的单元与数据库、HTTP 连接和特定文件等第三方组件进行接口时,问题就开始了。

一方面,您希望看到您的代码在各种情况下的行为。另一方面,您不希望等待数据库启动,而且您绝对不希望有几个包含不同数据版本的数据库,以便您可以检查所有必要的条件。

我们如何处理这种情况?这个想法不是执行触发所有这些副作用的实际代码,而是使用测试替身。测试替身是代码中模仿实际 API 的构造,除了它们不执行模仿函数或对象的操作。

最常见的测试替身是模拟对象、伪造对象和存根。许多人往往会将它们误认为是相同的,尽管它们并不相同。

不同的测试替身

模拟是注册所有接收到的调用但不做其他任何事情的测试替身。它们不返回任何值,也不以任何方式改变状态。当我们有一个应该调用我们代码的第三方框架时,使用模拟是有用的。通过使用模拟,我们可以观察所有调用,因此能够验证框架的行为是否符合预期。

当涉及到存根的实现时,它们会更加复杂。它们返回值,但这些值是预定义的。也许令人惊讶的是,StubRandom.randomInteger()方法总是返回相同的值(例如3),但当我们测试返回值的类型或者它是否返回值时,这可能是一个足够的存根实现。确切的值可能并不那么重要。

最后,伪装是具有工作实现并且行为大部分像实际生产实现的对象。主要区别在于伪装可能采取各种捷径,比如避免调用生产数据库或文件系统。

在实现命令查询分离CQS)设计模式时,通常会使用存根来替代查询,使用模拟来替代命令。

测试替身的其他用途

伪装也可以在测试之外的有限范围内使用。在内存中处理数据而不依赖数据库访问也可以用于原型设计或者当您遇到性能瓶颈时。

编写测试替身

编写测试替身时,我们通常会使用外部库,就像我们在单元测试中所做的那样。一些最受欢迎的解决方案如下:

以下部分的代码将向您展示如何同时使用 GoogleMock 和 Trompeloeil。

GoogleMock 示例

由于 GoogleMock 是 GoogleTest 的一部分,我们将它们一起介绍:

#include "merchants/reviews.h"

#include <gmock/gmock.h>

#include <merchants/visited_merchant_history.h>

#include "fake_customer_review_store.h"

namespace {

class mock_visited_merchant : public i_visited_merchant {
 public:
  explicit mock_visited_merchant(fake_customer_review_store &store,
                                 merchant_id_t id)
      : review_store_{store},
        review_{store.get_review_for_merchant(id).value()} {
    ON_CALL(*this, post_rating).WillByDefault(this {
      review_.rating = s;
      review_store_.post_review(review_);
    });
    ON_CALL(*this, get_rating).WillByDefault([this] { return review_.rating; });
  }

  MOCK_METHOD(stars, get_rating, (), (override));
  MOCK_METHOD(void, post_rating, (stars s), (override));

 private:
  fake_customer_review_store &review_store_;
  review review_;
};

} // namespace

class history_with_one_rated_merchant : public ::testing::Test {
 public:
  static constexpr std::size_t CUSTOMER_ID = 7777;
  static constexpr std::size_t MERCHANT_ID = 1234;
  static constexpr const char *REVIEW_TEXT = "Very nice!";
  static constexpr stars RATING = stars{5.f};

 protected:
  void SetUp() final {
    fake_review_store_.post_review(
        {CUSTOMER_ID, MERCHANT_ID, REVIEW_TEXT, RATING});

    // nice mock will not warn on "uninteresting" call to get_rating
    auto mocked_merchant =
        std::make_unique<::testing::NiceMock<mock_visited_merchant>>(
            fake_review_store_, MERCHANT_ID);

    merchant_index_ = history_.add(std::move(mocked_merchant));
  }

  fake_customer_review_store fake_review_store_{CUSTOMER_ID};
  history_of_visited_merchants history_{};
  std::size_t merchant_index_{};
};

TEST_F(history_with_one_rated_merchant,
       when_user_changes_rating_then_the_review_is_updated_in_store) {
  const auto &mocked_merchant = dynamic_cast<const mock_visited_merchant &>(
      history_.get_merchant(merchant_index_));
  EXPECT_CALL(mocked_merchant, post_rating);

  constexpr auto new_rating = stars{4};
  static_assert(RATING != new_rating);
  history_.rate(merchant_index_, stars{new_rating});
}

TEST_F(history_with_one_rated_merchant,
       when_user_selects_same_rating_then_the_review_is_not_updated_in_store) {
  const auto &mocked_merchant = dynamic_cast<const mock_visited_merchant &>(
      history_.get_merchant(merchant_index_));
  EXPECT_CALL(mocked_merchant, post_rating).Times(0);

  history_.rate(merchant_index_, stars{RATING});
}

在撰写本书时,GTest 是最受欢迎的 C++测试框架。它与 GMock 的集成意味着 GMock 可能已经在您的项目中可用。如果您已经在使用 GTest,这种组合使用起来直观且功能齐全,因此没有理由寻找其他替代方案。

Trompeloeil 示例

与前一个示例相比,这次我们使用 Trompeloeil 作为测试替身,Catch2 作为测试框架:

#include "merchants/reviews.h"

#include "fake_customer_review_store.h"

// order is important
#define CATCH_CONFIG_MAIN
#include <catch2/catch.hpp>
#include <catch2/trompeloeil.hpp>

#include <memory>

#include <merchants/visited_merchant_history.h>

using trompeloeil::_;

class mock_visited_merchant : public i_visited_merchant {
 public:
  MAKE_MOCK0(get_rating, stars(), override);
  MAKE_MOCK1(post_rating, void(stars s), override);
};

SCENARIO("merchant history keeps store up to date", "[mobile app]") {
  GIVEN("a history with one rated merchant") {
    static constexpr std::size_t CUSTOMER_ID = 7777;
    static constexpr std::size_t MERCHANT_ID = 1234;
    static constexpr const char *REVIEW_TEXT = "Very nice!";
    static constexpr stars RATING = stars{5.f};

    auto fake_review_store_ = fake_customer_review_store{CUSTOMER_ID};
    fake_review_store_.post_review(
        {CUSTOMER_ID, MERCHANT_ID, REVIEW_TEXT, RATING});

    auto history_ = history_of_visited_merchants{};
    const auto merchant_index_ =
        history_.add(std::make_unique<mock_visited_merchant>());

    auto &mocked_merchant = const_cast<mock_visited_merchant &>(
        dynamic_cast<const mock_visited_merchant &>(
            history_.get_merchant(merchant_index_)));

    auto review_ = review{CUSTOMER_ID, MERCHANT_ID, REVIEW_TEXT, RATING};
    ALLOW_CALL(mocked_merchant, post_rating(_))
        .LR_SIDE_EFFECT(review_.rating = _1;
                        fake_review_store_.post_review(review_););
    ALLOW_CALL(mocked_merchant, get_rating()).LR_RETURN(review_.rating);

    WHEN("a user changes rating") {
      constexpr auto new_rating = stars{4};
      static_assert(RATING != new_rating);

      THEN("the review is updated in store") {
        REQUIRE_CALL(mocked_merchant, post_rating(_));
        history_.rate(merchant_index_, stars{new_rating});
      }
    }

    WHEN("a user selects same rating") {
      THEN("the review is not updated in store") {
        FORBID_CALL(mocked_merchant, post_rating(_));
        history_.rate(merchant_index_, stars{RATING});
      }
    }
  }
}

Catch2 的一个很棒的特性是它可以轻松编写行为驱动开发风格的测试,就像这里展示的一样。如果您喜欢这种风格,那么 Catch2 与 Trompeloeil 将是一个很好的选择,因为它们集成得非常好。

测试驱动的类设计

区分不同类型的测试并学习特定的测试框架(或多个框架)是不够的。当您开始测试实际代码时,很快就会注意到并非所有类都能轻松测试。有时,您可能需要访问私有属性或方法。如果您想保持良好架构原则,请抵制这种冲动!相反,考虑测试通过类型的公共 API 可用的业务需求,或者重构类型,以便有另一个可以测试的代码单元。

当测试和类设计发生冲突时

您可能面临的问题并不是测试框架不足够。通常,您遇到的问题是类设计不当。即使您的类可能行为正确并且看起来正确,除非它们允许测试,否则它们并没有正确设计。

然而,这是个好消息。这意味着你可以在问题变得不方便之前修复它。当你开始基于它构建类层次结构时,类设计可能会在以后困扰你。在测试实现过程中修复设计将简单地减少可能的技术债务。

防御性编程

与其名字可能暗示的不同,防御性编程并不是一个安全功能。它的名字来自于保护你的类和函数不被用于与它们最初意图相反的方式。它与测试没有直接关系,但是它是一个很好的设计模式,因为它提高了你代码的质量,使你的项目具有未来的可靠性。

防御性编程始于静态类型。如果你创建一个处理自定义定义类型的函数作为参数,你必须确保没有人会用一些意外的值来调用它。用户将不得不有意识地检查函数的期望并相应地准备输入。

在 C++中,当我们编写模板代码时,我们也可以利用类型安全特性。当我们为我们客户的评论创建一个容器时,我们可以接受任何类型的列表并从中复制。为了得到更好的错误和精心设计的检查,我们可以编写以下内容:

class CustomerReviewStore : public i_customer_review_store {
 public:
  CustomerReviewStore() = default;
  explicit CustomerReviewStore(const std::ranges::range auto &initial_reviews) {
    static_assert(is_range_of_reviews_v<decltype(initial_reviews)>,
                  "Must pass in a collection of reviews");
    std::ranges::copy(begin(initial_reviews), end(initial_reviews),
                      begin(reviews_));
  }
 // ...
 private:
  std::vector<review> reviews_;
};

explicit关键字保护我们免受不必要的隐式转换。通过指定我们的输入参数满足range概念,我们确保只会与有效的容器一起编译。通过使用概念,我们可以从我们对无效使用的防御中获得更清晰的错误消息。在我们的代码中使用static_assert也是一个很好的防御措施,因为它允许我们在需要时提供一个好的错误消息。我们的is_range_of_reviews检查可以实现如下:

template <typename T>
constexpr bool is_range_of_reviews_v =
    std::is_same_v<std::ranges::range_value_t<T>, review>;

这样,我们确保得到的范围实际上包含我们想要的类型的评论。

静态类型不会阻止无效的运行时值被传递给函数。这就是防御性编程的下一个形式,检查前置条件。这样,你的代码将在问题的第一个迹象出现时失败,这总是比返回一个无效值传播到系统的其他部分要好。在 C++中,直到我们有合同,我们可以使用我们在前几章中提到的 GSL 库来检查我们代码的前置条件和后置条件:

void post_review(review review) final {
  Expects(review.merchant);
  Expects(review.customer);
  Ensures(!reviews_.empty());

  reviews_.push_back(std::move(review));
}

在这里,通过使用Expects宏,我们检查我们传入的评论实际上是否设置了商家和评论者的 ID。除了它不设置的情况,我们还在使用Ensures后置条件宏时防范了将评论添加到我们的存储失败的情况。

当涉及到运行时检查时,首先想到的是检查一个或多个属性是否不是nullptr。防范自己免受这个问题的最佳方法是区分可空资源(可以取nullptr作为值的资源)和不可空资源。有一个很好的工具可以用于这个问题,并且在 C++17 的标准库中可用:std::optional。如果可以的话,在你设计的所有 API 中都要使用它。

无聊的重复——先写你的测试

这已经说了很多次,但很多人倾向于“忘记”这个规则。当你实际编写你的测试时,你必须做的第一件事是减少创建难以测试的类的风险。你从 API 的使用开始,需要调整实现以最好地服务 API。这样,你通常会得到更愉快使用和更容易测试的 API。当你实施测试驱动开发TDD)或在编写代码之前编写测试时,你也会实施依赖注入,这意味着你的类可以更松散地耦合。

反过来做(先编写你的类,然后再为它们添加单元测试)可能意味着你会得到更容易编写但更难测试的代码。当测试变得更难时,你可能会感到诱惑跳过它。

自动化持续集成/持续部署的测试

在下一章中,我们将专注于持续集成和持续部署(CI/CD)。要使 CI/CD 流水线正常工作,您需要一组测试来捕捉错误,以防它们进入生产环境。要确保所有业务需求都被适当地表达为测试,这取决于您和您的团队。

测试在几个层面上都很有用。在行为驱动开发中,我们在前一节中提到,业务需求是自动化测试的基础。但是您正在构建的系统不仅仅由业务需求组成。您希望确保所有第三方集成都按预期工作。您希望确保所有子组件(如微服务)实际上可以相互接口。最后,您希望确保您构建的函数和类没有您可以想象到的任何错误。

您可以自动化的每个测试都是 CI/CD 流水线的候选项。它们每一个也都在这个流水线的某个地方有其位置。例如,端到端测试在部署后作为验收测试是最有意义的。另一方面,单元测试在编译后直接执行时是最有意义的。毕竟,我们的目标是一旦发现与规范可能有任何分歧,就尽快中断电路。

每次运行 CI/CD 流水线时,您不必运行所有自动化的测试。最好是每个流水线的运行时间相对较短。理想情况下,应该在提交后的几分钟内完成。如果我们希望保持运行时间最短,那么如何确保一切都经过了适当的测试呢?

一个答案是为不同目的准备不同套件的测试。例如,您可以为提交到功能分支的最小测试。由于每天有许多提交到功能分支,这意味着它们只会被简要测试,并且答案将很快可用。然后,将功能分支合并到共享开发分支需要稍大一些的测试用例集。这样,我们可以确保我们没有破坏其他团队成员将使用的任何内容。最后,对于合并到生产分支的测试将运行更广泛的用例。毕竟,我们希望对生产分支进行彻底测试,即使测试需要很长时间。

另一个答案是为 CI/CD 目的使用精简的测试用例集,并进行额外的持续测试过程。此过程定期运行,并对特定环境的当前状态进行深入检查。测试可以进行到安全测试和性能测试,因此可能评估环境是否有资格进行推广。

当我们选择一个环境并确认该环境具备成为更成熟环境的所有特质时,就会发生推广。例如,开发环境可以成为下一个暂存环境,或者暂存环境可以成为下一个生产环境。如果此推广是自动进行的,还有一个好的做法是在新推广的环境不再通过测试(例如域名或流量方面的微小差异)时提供自动回滚。

这也提出了另一个重要的做法:始终在生产环境上运行测试。当然,这些测试必须是最不具侵入性的,但它们应该告诉您系统在任何给定时间都在正确执行。

测试基础设施

如果您希望将配置管理、基础设施即代码或不可变部署的概念纳入应用程序的软件架构中,您还应该考虑测试基础设施本身。有几种工具可以用来做到这一点,包括 Serverspec、Testinfra、Goss 和 Terratest,它们是一些比较流行的工具之一。

这些工具在范围上略有不同,如下所述:

  • Serverspec 和 Testinfra 更专注于测试通过配置管理(如 Salt、Ansible、Puppet 和 Chef)配置的服务器的实际状态。它们分别用 Ruby 和 Python 编写,并插入到这些语言的测试引擎中。这意味着 Serverspec 使用 RSPec,而 Testinfra 使用 Pytest。

  • Goss 在范围和形式上都有些不同。除了测试服务器,您还可以使用 Goss 通过 dgoss 包装器来测试项目中使用的容器。至于其形式,它不使用您在 Serverspec 或 Testinfra 中看到的命令式代码。与 Ansible 或 Salt 类似,它使用 YAML 文件来描述我们要检查的期望状态。如果您已经使用声明性的配置管理方法(如前面提到的 Ansible 或 Salt),Goss 可能更直观,因此更适合测试。

  • 最后,Terratest 是一种工具,允许您测试基础设施即代码工具(如 Packer 和 Terraform)的输出(因此得名)。就像 Serverspec 和 Testinfra 使用它们的语言测试引擎为服务器编写测试一样,Terratest 利用 Go 的测试包来编写适当的测试用例。

让我们看看如何使用这些工具来验证部署是否按计划进行(至少从基础设施的角度来看)。

使用 Serverspec 进行测试

以下是一个检查特定版本中 Git 的可用性和 Let's Encrypt 配置文件的 Serverspec 测试的示例:

# We want to have git 1:2.1.4 installed if we're running Debian
describe package('git'), :if => os[:family] == 'debian' do

  it { should be_installed.with_version('1:2.1.4') }

end
# We want the file /etc/letsencrypt/config/example.com.conf to:

describe file('/etc/letsencrypt/config/example.com.conf') do

  it { should be_file } # be a regular file

  it { should be_owned_by 'letsencrypt' } # owned by the letsencrypt user

  it { should be_mode 600 } # access mode 0600

  it { should contain('example.com') } # contain the text example.com 
                                       # in the content
end

Ruby 的 DSL 语法应该即使对于不经常使用 Ruby 的人来说也是可读的。您可能需要习惯编写代码。

使用 Testinfra 进行测试

以下是一个检查特定版本中 Git 的可用性和 Let's Encrypt 配置文件的 Testinfra 测试的示例:

# We want Git installed on our host
def test_git_is_installed(host):
    git = host.package("git")
    # we test if the package is installed
    assert git.is_installed
    # and if it matches version 1:2.1.4 (using Debian versioning)
    assert git.version.startswith("1:2.1.4")
# We want the file /etc/letsencrypt/config/example.com.conf to:
def test_letsencrypt_file(host):
    le = host.file("/etc/letsencrypt/config/example.com.conf")
    assert le.user == "letsencrypt" # be owned by the letsencrypt user
    assert le.mode == 0o600 # access mode 0600
    assert le.contains("example.com") # contain the text example.com in the contents

Testinfra 使用纯 Python 语法。它应该是可读的,但就像 Serverspec 一样,您可能需要一些训练来自信地编写测试。

使用 Goss 进行测试

以下是一个检查特定版本中 Git 的可用性和 Let's Encrypt 配置文件的 Goss YAML 文件的示例:

# We want Git installed on our host
package:
  git:
    installed: true # we test if the package is installed
  versions:
  - 1:2.1.4 # and if it matches version 1:2.1.4 (using Debian versioning)
file:
  # We want the file /etc/letsencrypt/config/example.com.conf to:
  /etc/letsencrypt/config/example.com.conf:
    exists: true
  filetype: file # be a regular file
  owner: letsencrypt # be owned by the letsencrypt user
  mode: "0600" # access mode 0600
  contains:
  - "example.com" # contain the text example.com in the contents

YAML 的语法可能需要最少的准备来阅读和编写。但是,如果您的项目已经使用 Ruby 或 Python,当涉及编写更复杂的测试时,您可能希望坚持使用 Serverspec 或 Testinfra。

总结

本章既关注软件不同部分的架构和技术方面的测试。我们查看了测试金字塔,以了解不同类型的测试如何对软件项目的整体健康和稳定性做出贡献。由于测试既可以是功能性的,也可以是非功能性的,我们看到了这两种类型的一些示例。

从本章中最重要的事情之一是要记住测试不是最终阶段。我们希望进行测试不是因为它们带来了即时价值,而是因为我们可以使用它们来检查已知的回归、重构或更改系统现有部分的行为时。当我们想要进行根本原因分析时,测试也可以证明有用,因为它们可以快速验证不同的假设。

在建立了理论要求之后,我们展示了可以用来编写测试替身的不同测试框架和库的示例。尽管先编写测试,后实现它们需要一些实践,但它有一个重要的好处。这个好处就是更好的类设计。

最后,为了突出现代架构不仅仅是软件代码,我们还看了一些用于测试基础设施和部署的工具。在下一章中,我们将看到持续集成和持续部署如何为您设计的应用程序带来更好的服务质量和稳健性。

问题

  1. 测试金字塔的基础层是什么?

  2. 非功能性测试有哪些类型?

  3. 著名的根本原因分析方法的名称是什么?

  4. 在 C++中是否可能测试编译时代码?

  5. 在编写具有外部依赖的代码的单元测试时应该使用什么?

  6. 单元测试在持续集成/持续部署中的作用是什么?

  7. 有哪些工具可以让您测试基础架构代码?

  8. 在单元测试中访问类的私有属性和方法是一个好主意吗?

进一步阅读

测试 C++代码:www.packtpub.com/application-development/modern-c-programming-cookbook

测试替身:martinfowler.com/articles/mocksArentStubs.html

持续集成/持续部署:www.packtpub.com/virtualization-and-cloud/hands-continuous-integration-and-deliverywww.packtpub.com/virtualization-and-cloud/cloud-native-continuous-integration-and-delivery

第九章:持续集成和持续部署

在之前的一章中,我们学习了关于不同构建系统和不同打包系统的知识,我们的应用程序可以使用。持续集成(CI)和持续部署(CD)允许我们利用构建和打包的知识来提高服务质量和我们正在开发的应用程序的健壮性。

CI 和 CD 都依赖于良好的测试覆盖率。CI 主要使用单元测试和集成测试,而 CD 更依赖于冒烟测试和端到端测试。您在《第八章》《编写可测试的代码》中了解了测试的不同方面。有了这些知识,您就可以构建 CI/CD 流水线了。

在本章中,我们将涵盖以下主题:

  • 理解 CI

  • 审查代码更改

  • 探索测试驱动的自动化

  • 将部署管理为代码

  • 构建部署代码

  • 构建 CD 流水线

  • 使用不可变基础设施

技术要求

本章的示例代码可以在github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter09找到。

要理解本章中解释的概念,您需要进行以下安装:

  • 免费的 GitLab 账户

  • Ansible 版本 2.8+

  • Terraform 版本 0.12+

  • Packer 版本 1.4+

理解 CI

CI 是缩短集成周期的过程。在传统软件中,许多不同的功能可能是分开开发的,只有在发布之前才进行集成,而在 CI 项目中,集成可以每天发生多次。通常,开发人员进行的每个更改都会在提交到中央代码库时进行测试和集成。

由于测试发生在开发之后,反馈循环要快得多。这使得开发人员更容易修复错误(因为他们通常还记得做了什么改动)。与传统的在发布之前进行测试的方法相比,CI 节省了大量工作,并提高了软件的质量。

尽早发布,经常发布

您是否听说过“尽早发布,经常发布”的说法?这是一种强调短周期发布的软件开发理念。而短周期的发布循环则在规划、开发和验证之间提供了更短的反馈循环。当出现问题时,应该尽早出现,以便修复问题的成本相对较小。

这一理念是由埃里克·S·雷蒙德(也被称为 ESR)在他 1997 年的文章《大教堂与集市》中推广的。还有一本同名的书,其中包含了作者的这篇文章和其他文章。考虑到 ESR 在开源运动中的活动,"尽早发布,经常发布"的口号成为了开源项目运作方式的代名词。

几年后,同样的原则不仅仅适用于开源项目。随着对敏捷方法学(如 Scrum)日益增长的兴趣,“尽早发布,经常发布”的口号成为了以产品增量结束的开发冲刺的代名词。当然,这个增量是软件发布,但通常在冲刺期间会有许多其他发布。

如何实现这样的短周期发布循环?一个答案是尽可能依赖自动化。理想情况下,代码库的每次提交都应该以发布结束。这个发布是否面向客户是另一回事。重要的是,每次代码变更都可能导致可用的产品。

当然,为每个提交构建和发布到公共环境对于任何开发人员来说都是一项繁琐的工作。即使一切都是脚本化的,这也会给通常的琐事增加不必要的开销。这就是为什么您希望设置一个 CI 系统来自动化您和您的开发团队的发布。

CI 的优点

CI 是将几个开发人员的工作至少每天集成在一起的概念。正如已经讨论过的,有时它可能意味着每天几次。进入存储库的每个提交都是单独集成和验证的。构建系统检查代码是否可以无错误地构建。打包系统可以创建一个准备保存为工件的软件包,甚至在使用 CD 时稍后部署。最后,自动化测试检查是否与更改相关的已知回归没有发生。现在让我们详细看看它的优点:

  • CI 允许快速解决问题。如果其中一个开发人员在行末忘记了一个分号,CI 系统上的编译器将立即捕捉到这个错误,这样错误的代码就不会传播给其他开发人员,从而阻碍他们的工作。当然,开发人员在提交代码之前应该构建更改并对其进行测试,但是在开发人员的机器上可能会忽略一些小错误,并且这些错误可能会进入共享存储库。

  • 使用 CI 的另一个好处是,它可以防止常见的“在我的机器上可以运行”的借口。如果开发人员忘记提交必要的文件,CI 系统将无法构建更改,再次阻止它们进一步传播并对整个团队造成麻烦。一个开发人员环境的特殊配置也不再是问题。如果一个更改在两台机器上构建,即开发人员的计算机和 CI 系统,我们可以安全地假设它也应该在其他机器上构建。

门控机制

如果我们希望 CI 能够为我们带来价值,而不仅仅是为我们构建软件包,我们需要一个门控机制。这个门控机制将允许我们区分好的代码更改和坏的代码更改,从而使我们的应用程序免受使其无用的修改。为了实现这一点,我们需要一个全面的测试套件。这样的套件使我们能够自动识别何时更改有问题,并且我们能够迅速做到这一点。

对于单个组件,单元测试起到了门控机制的作用。CI 系统可以丢弃任何未通过单元测试的更改,或者任何未达到一定代码覆盖率阈值的更改。在构建单个组件时,CI 系统还可以使用集成测试来进一步确保更改是稳定的,不仅仅是它们自己,而且它们在一起的表现也是正常的。

使用 GitLab 实施流水线

在本章中,我们将使用流行的开源工具构建一个完整的 CI/CD 流水线,其中包括门控机制、自动部署,并展示基础设施自动化的概念。

第一个这样的工具是 GitLab。您可能听说过它作为一个 Git 托管解决方案,但实际上,它远不止于此。GitLab 有几个版本,即以下版本:

  • 一种开源解决方案,您可以在自己的设施上托管

  • 提供额外功能的自托管付费版本,超过开源社区版

  • 最后,一个软件即服务SaaS)托管在gitlab.com下的托管服务

对于本书的要求,每个版本都具备所有必要的功能。因此,我们将专注于 SaaS 版本,因为这需要最少的准备工作。

尽管gitlab.com主要针对开源项目,但如果您不想与整个世界分享您的工作,您也可以创建私有项目和存储库。这使我们能够在 GitLab 中创建一个新的私有项目,并用我们已经在第七章中演示的代码填充它,构建和打包

许多现代 CI/CD 工具可以代替 GitLab CI/CD。例如 GitHub Actions、Travis CI、CircleCI 和 Jenkins。我们选择了 GitLab,因为它既可以作为 SaaS 形式使用,也可以在自己的设施上使用,因此应该适应许多不同的用例。

然后,我们将使用之前的构建系统在 GitLab 中创建一个简单的 CI 流水线。这些流水线在 YAML 文件中被描述为一系列步骤和元数据。一个构建所有要求的示例流水线,以及来自第七章的示例项目,构建和打包,将如下所示:

# We want to cache the conan data and CMake build directory
cache:
  key: all
  paths:
    - .conan
    - build

# We're using conanio/gcc10 as the base image for all the subsequent commands
default:
  image: conanio/gcc10

stages:
  - prerequisites
  - build

before_script:
  - export CONAN_USER_HOME="$CI_PROJECT_DIR"

# Configure conan
prerequisites:
  stage: prerequisites
  script:
    - pip install conan==1.34.1
    - conan profile new default || true
    - conan profile update settings.compiler=gcc default
    - conan profile update settings.compiler.libcxx=libstdc++11 default
    - conan profile update settings.compiler.version=10 default
    - conan profile update settings.arch=x86_64 default
    - conan profile update settings.build_type=Release default
    - conan profile update settings.os=Linux default
    - conan remote add trompeloeil https://api.bintray.com/conan/trompeloeil/trompeloeil || true

# Build the project
build:
  stage: build
  script:
    - sudo apt-get update && sudo apt-get install -y docker.io
    - mkdir -p build
    - cd build
    - conan install ../ch08 --build=missing
    - cmake -DBUILD_TESTING=1 -DCMAKE_BUILD_TYPE=Release ../ch08/customer
    - cmake --build .

将上述文件保存为.gitlab-ci.yml,放在 Git 存储库的根目录中,将自动在 GitLab 中启用 CI,并在每次提交时运行流水线。

审查代码更改

代码审查可以在有 CI 系统和没有 CI 系统的情况下使用。它们的主要目的是对引入代码的每个更改进行双重检查,以确保其正确性,符合应用程序的架构,并遵循项目的指南和最佳实践。

当没有 CI 系统时,通常是审阅者的任务手动测试更改并验证其是否按预期工作。CI 减轻了这一负担,让软件开发人员专注于代码的逻辑结构。

自动化的门控机制

自动化测试只是门控机制的一个例子。当它们的质量足够高时,它们可以保证代码按照设计工作。但正确工作的代码和好的代码之间仍然存在差异。从本书到目前为止,您已经了解到,如果代码满足了几个价值观,那么它可以被认为是好的。功能上的正确性只是其中之一。

还有其他工具可以帮助实现代码基准的期望标准。其中一些在前几章中已经涵盖,所以我们不会详细介绍。请记住,在 CI/CD 流水线中使用代码检查器、代码格式化程序和静态分析是一个很好的做法。虽然静态分析可以作为一个门控机制,但你可以将代码检查和格式化应用到进入中央存储库的每个提交,以使其与代码库的其余部分保持一致。附录中会有更多关于代码检查器和格式化程序的内容。

理想情况下,这个机制只需要检查代码是否已经被格式化,因为在将代码推送到存储库之前,开发人员应该完成格式化步骤。当使用 Git 作为版本控制系统时,Git Hooks 机制可以防止在没有运行必要工具的情况下提交代码。

但自动化分析只能帮你解决一部分问题。你可以检查代码是否功能完整,是否没有已知的错误和漏洞,并且是否符合编码标准。这就是手动检查的作用。

代码审查-手动门控机制

对代码更改的手动检查通常被称为代码审查。代码审查的目的是识别问题,包括特定子系统的实现以及对应用程序整体架构的遵循。自动化性能测试可能会或可能不会发现给定功能的潜在问题。另一方面,人眼通常可以发现问题的次优解决方案。无论是错误的数据结构还是计算复杂度过高的算法,一个好的架构师应该能够找出问题所在。

但执行代码审查并不仅仅是架构师的角色。同行审查,也就是由作者的同行进行的代码审查,在开发过程中也有其作用。这样的审查之所以有价值,不仅因为它们允许同事发现彼此代码中的错误。更重要的方面是许多队友突然意识到其他人正在做什么。这样,当团队中有人缺席(无论是因为长时间会议、度假还是工作轮换),另一名团队成员可以替补缺席者。即使他们不是该主题的专家,每个成员至少知道有趣的代码位于何处,每个人都应该能够记住代码的最后更改。这意味着它们发生的时间、范围和内容。

随着更多人意识到应用程序内部的情况,他们更有可能发现一个组件最近的变化和一个新发现的错误之间的关联。即使团队中的每个人可能有不同的经验,但当每个人都非常了解代码时,他们可以共享资源。

因此,代码审查可以检查更改是否符合所需的架构,以及其实现是否正确。我们称这样的代码审查为架构审查或专家审查。

另一种类型的代码审查,同行审查,不仅有助于发现错误,还提高了团队对其他成员正在做什么的意识。如果需要,您还可以在处理与外部服务集成的更改时执行不同类型的专家审查。

由于每个接口都是潜在问题的源头,接近接口级别的更改应被视为特别危险。我们建议您将通常的同行审查与来自接口另一侧的专家的审查相结合。例如,如果您正在编写生产者的代码,请向消费者请求审查。这样,您可以确保不会错过一些您可能认为非常不太可能的重要用例,但另一方却经常使用。

代码审查的不同方法

您通常会进行异步代码审查。这意味着正在审查的更改的作者和审阅者之间的通信不是实时发生的。相反,每个参与者都可以在任何时间发表他们的评论和建议。一旦没有更多的评论,作者会重新修改原始更改,然后再次进行审查。这可能需要多轮,直到每个人都同意不需要进一步的更正为止。

当一个更改特别有争议并且异步代码审查需要太长时间时,进行同步代码审查是有益的。这意味着举行一次会议(面对面或远程),解决对未来方向的任何相反意见。这将在特定情况下发生,当一个更改与最初的决定之一相矛盾,因为在实施更改时获得了新的知识。

有一些专门针对代码审查的工具。更常见的是,您会希望使用内置到存储库服务器中的工具,其中包括以下服务:

  • GitHub

  • Bitbucket

  • GitLab

  • Gerrit

所有这些都提供 Git 托管和代码审查。其中一些甚至提供整个 CI/CD 流水线、问题管理、wiki 等等。

当您使用代码托管和代码审查的综合包时,默认工作流程是将更改推送为单独的分支,然后要求项目所有者合并更改,这个过程称为拉取请求(或合并请求)。尽管名字很花哨,但拉取请求或合并请求通知项目所有者,您有代码希望与主分支合并。这意味着审阅者应该审查您的更改,以确保一切都井井有条。

使用拉取请求(合并请求)进行代码审查

使用 GitLab 等系统创建拉取请求或合并请求非常容易。首先,当我们从命令行推送新分支到中央存储库时,我们可以观察到以下消息:

remote:
remote: To create a merge request for fix-ci-cd, visit:
remote:   https://gitlab.com/hosacpp/continuous-integration/merge_requests/new?merge_request%5Bsource_branch%5D=fix-ci-cd
remote:                         

如果您之前已启用 CI(通过添加.gitlab-ci.yml文件),您还会看到新推送的分支已经经过了 CI 流程。这甚至发生在您打开合并请求之前,这意味着您可以在从 CI 获得每个自动检查都通过的信息之前推迟通知同事。

打开合并请求的两种主要方式如下:

  • 通过按照推送消息中提到的链接

  • 通过在 GitLab UI 中导航到合并请求并选择“创建合并请求”按钮或“新合并请求”按钮

当您提交合并请求并填写完所有相关字段时,您会看到 CI 流水线的状态也是可见的。如果流水线失败,将无法合并更改。

探索测试驱动的自动化

CI 主要侧重于集成部分。这意味着构建不同子系统的代码并确保它们可以一起工作。虽然测试不是严格要求实现此目的,但在没有测试的情况下运行 CI 似乎是一种浪费。没有自动化测试的 CI 使得更容易向代码引入微妙的错误,同时给人一种虚假的安全感。

这就是为什么 CI 经常与持续测试紧密结合的原因之一,我们将在下一节中介绍。

行为驱动开发

到目前为止,我们已经设立了一个可以称之为持续构建的流水线。我们对代码所做的每一次更改最终都会被编译,但我们不会进一步测试它。现在是时候引入持续测试的实践了。在低级别进行测试也将作为一个门控机制,自动拒绝所有不满足要求的更改。

您如何检查给定的更改是否满足要求?最好的方法是根据这些要求编写测试。其中一种方法是遵循行为驱动开发BDD)。BDD 的概念是鼓励敏捷项目中不同参与者之间更深入的协作。

与传统方法不同,传统方法要么由开发人员编写测试,要么由 QA 团队编写测试,而 BDD 中,测试是由以下个人共同创建的:

  • 开发人员

  • QA 工程师

  • 业务代表。

指定 BDD 测试的最常见方式是使用 Cucumber 框架,该框架使用简单的英语短语来描述系统的任何部分的期望行为。这些句子遵循特定的模式,然后可以转换为可工作的代码,与所选的测试框架集成。

Cucumber 框架中有对 C++的官方支持,它基于 CMake、Boost、GTest 和 GMock。在以 cucumber 格式指定所需行为(使用称为 Gherkin 的领域特定语言)之后,我们还需要提供所谓的步骤定义。步骤定义是与 cucumber 规范中描述的操作相对应的实际代码。例如,考虑以下以 Gherkin 表达的行为:

# language: en
Feature: Summing
In order to see how much we earn,
Sum must be able to add two numbers together

Scenario: Regular numbers
  Given I have entered 3 and 2 as parameters
  When I add them
  Then the result should be 5

我们可以将其保存为sum.feature文件。为了生成带有测试的有效 C++代码,我们将使用适当的步骤定义:

#include <gtest/gtest.h>
#include <cucumber-cpp/autodetect.hpp>

#include <Sum.h>

using cucumber::ScenarioScope;

struct SumCtx {
  Sum sum;
  int a;
  int b;
  int result;
};

GIVEN("^I have entered (\\d+) and (\\d+) as parameters$", (const int a, const int b)) {
    ScenarioScope<SumCtx> context;

    context->a = a;
    context->b = b;
}

WHEN("^I add them") {
    ScenarioScope<SumCtx> context;

    context->result = context->sum.sum(context->a, context->b);
}

THEN("^the result should be (.*)$", (const int expected)) {
    ScenarioScope<SumCtx> context;

    EXPECT_EQ(expected, context->result);
}

在从头开始构建应用程序时,遵循 BDD 模式是一个好主意。本书旨在展示您可以在这样的绿地项目中使用的最佳实践。但这并不意味着您不能在现有项目中尝试我们的示例。在项目的生命周期中的任何时间都可以添加 CI 和 CD。由于尽可能经常运行测试总是一个好主意,因此几乎总是一个好主意仅出于持续测试目的使用 CI 系统。

如果你没有行为测试,你不需要担心。你可以稍后添加它们,目前只需专注于你已经有的那些测试。无论是单元测试还是端到端测试,任何有助于评估你的应用程序状态的东西都是一个很好的门控机制的候选者。

为 CI 编写测试

对于 CI 来说,最好专注于单元测试和集成测试。它们在可能的最低级别上工作,这意味着它们通常执行速度快,要求最小。理想情况下,所有单元测试应该是自包含的(没有像工作数据库这样的外部依赖)并且能够并行运行。这样,当问题出现在单元测试能够捕捉到的级别时,有问题的代码将在几秒钟内被标记出来。

有些人说单元测试只在解释性语言或动态类型语言中才有意义。论点是 C++已经通过类型系统和编译器检查内置了测试。虽然类型检查可以捕捉一些在动态类型语言中需要单独测试的错误,但这不应该成为不编写单元测试的借口。毕竟,单元测试的目的不是验证代码能够无问题地执行。我们编写单元测试是为了确保我们的代码不仅执行,而且还满足我们所有的业务需求。

作为一个极端的例子,看一下以下两个函数。它们都在语法上是正确的,并且使用了适当的类型。然而,仅仅通过看它们,你可能就能猜出哪一个是正确的,哪一个是错误的。单元测试有助于捕捉这种行为不当:

int sum (int a, int b) {
 return a+b;
}

前面的函数返回提供的两个参数的总和。下一个函数只返回第一个参数的值:

int sum (int a, int b) {
  return a;
}

即使类型匹配,编译器不会抱怨,这段代码也不能执行其任务。为了区分有用的代码和错误的代码,我们使用测试和断言。

持续测试

已经建立了一个简单的 CI 流水线,非常容易通过测试来扩展它。由于我们已经在构建和测试过程中使用 CMake 和 CTest,我们所需要做的就是在我们的流水线中添加另一个步骤来执行测试。这一步可能看起来像这样:

# Run the unit tests with ctest
test:
  stage: test
  script:
    - cd build
    - ctest .

因此,整个流水线将如下所示:

cache:
  key: all
  paths:
    - .conan
    - build

default:
  image: conanio/gcc9

stages:
  - prerequisites
  - build
 - test # We add another stage that tuns the tests

before_script:
  - export CONAN_USER_HOME="$CI_PROJECT_DIR"

prerequisites:
  stage: prerequisites
  script:
    - pip install conan==1.34.1
    - conan profile new default || true
    - conan profile update settings.compiler=gcc default
    - conan profile update settings.compiler.libcxx=libstdc++11 default
    - conan profile update settings.compiler.version=10 default
    - conan profile update settings.arch=x86_64 default
    - conan profile update settings.build_type=Release default
    - conan profile update settings.os=Linux default
    - conan remote add trompeloeil https://api.bintray.com/conan/trompeloeil/trompeloeil || true

build:
  stage: build
  script:
    - sudo apt-get update && sudo apt-get install -y docker.io
    - mkdir -p build
    - cd build
    - conan install ../ch08 --build=missing
    - cmake -DBUILD_TESTING=1 -DCMAKE_BUILD_TYPE=Release ../ch08/customer
    - cmake --build .

# Run the unit tests with ctest
test:
 stage: test
 script:
 - cd build
 - ctest .

这样,每个提交不仅会经历构建过程,还会经历测试。如果其中一个步骤失败,我们将收到通知,知道是哪一个步骤导致了失败,并且可以在仪表板上看到哪些步骤成功了。

管理部署作为代码

经过测试和批准的更改,现在是将它们部署到一个操作环境的时候了。

有许多工具可以帮助部署。我们决定提供 Ansible 的示例,因为这不需要在目标机器上进行任何设置,除了一个功能齐全的 Python 安装(大多数 UNIX 系统已经有了)。为什么选择 Ansible?它在配置管理领域非常流行,并且由一个值得信赖的开源公司(红帽)支持。

使用 Ansible

为什么不使用已经可用的东西,比如 Bourne shell 脚本或 PowerShell?对于简单的部署,shell 脚本可能是一个更好的方法。但是随着我们的部署过程变得更加复杂,使用 shell 的条件语句来处理每种可能的初始状态就变得更加困难。

处理初始状态之间的差异实际上是 Ansible 特别擅长的。与使用命令式形式(移动这个文件,编辑那个文件,运行特定命令)的传统 shell 脚本不同,Ansible playbook(它们被称为)使用声明式形式(确保文件在这个路径上可用,确保文件包含指定的行,确保程序正在运行,确保程序成功完成)。

这种声明性的方法也有助于实现幂等性。幂等性是函数的一个特性,意味着多次应用该函数将产生与单次应用完全相同的结果。如果 Ansible playbook 的第一次运行引入了对配置的一些更改,每次后续运行都将从所需状态开始。这可以防止 Ansible 执行任何额外的更改。

换句话说,当您调用 Ansible 时,它将首先评估您希望配置的所有机器的当前状态:

  • 如果其中任何一个需要进行任何更改,Ansible 将只运行所需的任务以实现所需的状态。

  • 如果没有必要修改特定的内容,Ansible 将不会触及它。只有当所需状态和实际状态不同时,您才会看到 Ansible 采取行动将实际状态收敛到 playbook 内容描述的所需状态。

Ansible 如何与 CI/CD 流水线配合

Ansible 的幂等性使其成为 CI/CD 流水线中的一个很好的目标。毕竟,即使两次运行之间没有任何更改,多次运行相同的 Ansible playbook 也没有风险。如果您将 Ansible 用于部署代码,创建 CD 只是准备适当的验收测试(例如冒烟测试或端到端测试)的问题。

声明性方法可能需要改变您对部署的看法,但收益是非常值得的。除了运行 playbooks,您还可以使用 Ansible 在远程机器上执行一次性命令,但我们不会涵盖这种用例,因为它实际上对部署没有帮助。

您可以使用 Ansible 的shell模块执行与 shell 相同的操作。这是因为在 playbooks 中,您编写指定使用哪些模块及其各自参数的任务。其中一个模块就是前面提到的shell模块,它只是在远程机器上执行提供的参数。但是,使 Ansible 不仅方便而且跨平台(至少在涉及不同的 UNIX 发行版时)的是可以操作常见概念的模块的可用性,例如用户管理、软件包管理和类似实例。

使用组件创建部署代码

除了标准库中提供的常规模块外,还有第三方组件允许代码重用。您可以单独测试这些组件,这也使您的部署代码更加健壮。这些组件称为角色。它们包含一组任务,使机器适合承担特定角色,例如webserverdbdocker。虽然一些角色准备机器提供特定服务,其他角色可能更抽象,例如流行的ansible-hardening角色。这是由 OpenStack 团队创建的,它使使用该角色保护的机器更难被入侵。

当您开始理解 Ansible 使用的语言时,所有的 playbooks 都不再只是脚本。反过来,它们将成为部署过程的文档。您可以通过运行 Ansible 直接使用它们,或者您可以阅读描述的任务并手动执行所有操作,例如在离线机器上。

使用 Ansible 进行团队部署的一个风险是,一旦开始使用,您必须确保团队中的每个人都能够使用它并修改相关的任务。DevOps 是整个团队必须遵循的一种实践;它不能只部分实施。当应用程序的代码发生相当大的变化,需要在部署方面进行适当的更改时,负责应用程序更改的人也应提供部署代码的更改。当然,这是您的测试可以验证的内容,因此门控机制可以拒绝不完整的更改。

Ansible 的一个值得注意的方面是它可以在推送和拉取模型中运行:

  • 推送模型是当您在自己的机器上或在 CI 系统中运行 Ansible 时。然后,Ansible 连接到目标机器,例如通过 SSH 连接,并在目标机器上执行必要的步骤。

  • 在拉模型中,整个过程由目标机器发起。Ansible 的组件ansible-pull直接在目标机器上运行,并检查代码存储库以确定特定分支是否有任何更新。刷新本地 playbook 后,Ansible 像往常一样执行所有步骤。这一次,控制组件和实际执行都发生在同一台机器上。大多数情况下,您会希望定期运行ansible-pull,例如,从 cron 作业中运行。

构建部署代码

在其最简单的形式中,使用 Ansible 进行部署可能包括将单个二进制文件复制到目标机器,然后运行该二进制文件。我们可以使用以下 Ansible 代码来实现这一点:

tasks:
  # Each Ansible task is written as a YAML object
  # This uses a copy module
  - name: Copy the binaries to the target machine
    copy:
      src: our_application
      dest: /opt/app/bin/our_application
  # This tasks invokes the shell module. The text after the `shell:` key
  # will run in a shell on target machine
  - name: start our application in detached mode
    shell: cd /opt/app/bin; nohup ./our_application </dev/null >/dev/null 2>&1 &

每个任务都以连字符开头。对于每个任务,您需要指定它使用的模块(例如copy模块或shell模块),以及它的参数(如果适用)。任务还可以有一个name参数,这样可以更容易地单独引用任务。

构建 CD 管道

我们已经达到了可以安全地使用本章学到的工具构建 CD 管道的地步。我们已经知道 CI 是如何运作的,以及它如何帮助拒绝不适合发布的更改。测试自动化部分介绍了使拒绝过程更加健壮的不同方法。拥有冒烟测试或端到端测试使我们能够超越 CI,并检查整个部署的服务是否满足要求。并且有了部署代码,我们不仅可以自动化部署过程,还可以在我们的测试开始失败时准备回滚。

持续部署和持续交付

出于有趣的巧合,CD 的缩写可以有两种不同的含义。持续交付和持续部署的概念非常相似,但它们有一些细微的差异。在整本书中,我们专注于持续部署的概念。这是一个自动化的过程,当一个人将更改推送到中央存储库时开始,并在更改成功部署到生产环境并通过所有测试时结束。因此,我们可以说这是一个端到端的过程,因为开发人员的工作可以在没有手动干预的情况下一直传递到客户那里(当然,要经过代码审查)。您可能听说过 GitOps 这个术语来描述这种方法。由于所有操作都是自动化的,将更改推送到 Git 中的指定分支会触发部署脚本。

持续交付并不会走得那么远。与 CD 一样,它具有能够发布最终产品并对其进行测试的管道,但最终产品永远不会自动交付给客户。它可以首先交付给 QA 或用于内部业务。理想情况下,交付的构件准备好在内部客户接受后立即部署到生产环境中。

构建一个示例 CD 管道

让我们再次将所有这些技能结合起来,以 GitLab CI 作为示例来构建我们的管道。在测试步骤之后,我们将添加另外两个步骤,一个用于创建包,另一个用于使用 Ansible 部署此包。

我们打包步骤所需的全部内容如下:

# Package the application and publish the artifact
package:
  stage: package
  # Use cpack for packaging
  script:
    - cd build
    - cpack .
  # Save the deb package artifact
  artifacts:
    paths:
      - build/Customer*.deb

当我们添加包含构件定义的包步骤时,我们将能够从仪表板下载它们。

有了这个,我们可以将 Ansible 作为部署步骤的一部分来调用:

# Deploy using Ansible
deploy:
  stage: deploy
  script:
    - cd build
    - ansible-playbook -i localhost, ansible.yml

最终的管道将如下所示:

cache:
  key: all
  paths:
    - .conan
    - build

default:
  image: conanio/gcc9

stages:
  - prerequisites
  - build
  - test
 - package
 - deploy

before_script:
  - export CONAN_USER_HOME="$CI_PROJECT_DIR"

prerequisites:
  stage: prerequisites
  script:
    - pip install conan==1.34.1
    - conan profile new default || true
    - conan profile update settings.compiler=gcc default
    - conan profile update settings.compiler.libcxx=libstdc++11 default
    - conan profile update settings.compiler.version=10 default
    - conan profile update settings.arch=x86_64 default
    - conan profile update settings.build_type=Release default
    - conan profile update settings.os=Linux default
    - conan remote add trompeloeil https://api.bintray.com/conan/trompeloeil/trompeloeil || true

build:
  stage: build
  script:
    - sudo apt-get update && sudo apt-get install -y docker.io
    - mkdir -p build
    - cd build
    - conan install ../ch08 --build=missing
    - cmake -DBUILD_TESTING=1 -DCMAKE_BUILD_TYPE=Release ../ch08/customer
    - cmake --build .

test:
  stage: test
  script:
    - cd build
    - ctest .

# Package the application and publish the artifact
package:
 stage: package
 # Use cpack for packaging
 script:
 - cd build
 - cpack .
 # Save the deb package artifact
 artifacts:
 paths:
 - build/Customer*.deb

# Deploy using Ansible
deploy:
 stage: deploy
 script:
 - cd build
 - ansible-playbook -i localhost, ansible.yml

要查看整个示例,请转到原始来源的技术要求部分的存储库。

使用不可变基础设施

如果您对 CI/CD 流水线足够自信,您可以再走一步。您可以部署系统的构件,而不是应用程序的构件。有什么区别?我们将在以下部分了解到。

什么是不可变基础设施?

以前,我们关注的是如何使应用程序的代码可以部署到目标基础设施上。CI 系统创建软件包(如容器),然后 CD 流程部署这些软件包。每次流水线运行时,基础设施保持不变,但软件不同。

关键是,如果您使用云计算,您可以将基础设施视为任何其他构件。例如,您可以部署整个虚拟机VM),作为 AWS EC2 实例的构件,而不是部署容器。您可以预先构建这样的 VM 镜像作为 CI 流程的另一个构件。这样,版本化的 VM 镜像以及部署它们所需的代码成为您的构件,而不是容器本身。

有两个工具,都由 HashiCorp 编写,处理这种情况。Packer 帮助以可重复的方式创建 VM 镜像,将所有指令存储为代码,通常以 JSON 文件的形式。Terraform 是一个基础设施即代码工具,这意味着它用于提供所有必要的基础设施资源。我们将使用 Packer 的输出作为 Terraform 的输入。这样,Terraform 将创建一个包含以下内容的整个系统:

  • 实例组

  • 负载均衡器

  • VPC

  • 其他云元素,同时使用包含我们自己代码的 VM

这一部分的标题可能会让您感到困惑。为什么它被称为不可变基础设施,而我们明显是在提倡在每次提交后更改整个基础设施?如果您学过函数式语言,不可变性的概念可能对您更清晰。

可变对象是其状态可以改变的对象。在基础设施中,这很容易理解:您可以登录到虚拟机并下载更近期的代码。状态不再与您干预之前相同。

不可变对象是其状态我们无法改变的对象。这意味着我们无法登录到机器上并更改东西。一旦我们从镜像部署了虚拟机,它就会保持不变,直到我们销毁它。这听起来可能非常麻烦,但实际上,它解决了软件维护的一些问题。

不可变基础设施的好处

首先,不可变基础设施使配置漂移的概念过时。没有配置管理,因此也不会有漂移。升级也更安全,因为我们不会陷入一个半成品状态。这是既不是上一个版本也不是下一个版本,而是介于两者之间的状态。部署过程提供了二进制信息:机器要么被创建并运行,要么没有。没有其他方式。

为了使不可变基础设施在不影响正常运行时间的情况下工作,您还需要以下内容:

  • 负载均衡

  • 一定程度的冗余

毕竟,升级过程包括关闭整个实例。您不能依赖于这台机器的地址或任何特定于该机器的东西。相反,您需要至少有第二个机器来处理工作负载,同时用更近期的版本替换另一个机器。当您完成升级一个机器后,您可以重复相同的过程。这样,您将有两个升级的实例而不会丢失服务。这种策略被称为滚动升级。

从这个过程中,您可以意识到,当处理无状态服务时,不可变基础架构效果最佳。当您的服务具有某种持久性时,正确实施变得更加困难。在这种情况下,通常需要将持久性级别拆分为一个单独的对象,例如,包含所有应用程序数据的 NFS 卷。这些卷可以在实例组中的所有机器之间共享,并且每个新机器上线时都可以访问之前运行应用程序留下的共同状态。

使用 Packer 构建实例镜像

考虑到我们的示例应用程序已经是无状态的,我们可以继续在其上构建一个不可变的基础架构。由于 Packer 生成的工件是 VM 镜像,我们必须决定要使用的格式和构建器。

让我们专注于 Amazon Web Services 的示例,同时牢记类似的方法也适用于其他支持的提供者。一个简单的 Packer 模板可能如下所示:

{
  "variables": {
    "aws_access_key": "",
    "aws_secret_key": ""
  },
  "builders": [{
    "type": "amazon-ebs",
    "access_key": "{{user `aws_access_key`}}",
    "secret_key": "{{user `aws_secret_key`}}",
    "region": "eu-central-1",
    "source_ami": "ami-0f1026b68319bad6c",
    "instance_type": "t2.micro",
    "ssh_username": "admin",
    "ami_name": "Project's Base Image {{timestamp}}"
  }],
  "provisioners": [{
    "type": "shell",
    "inline": [
      "sudo apt-get update",
      "sudo apt-get install -y nginx"
    ]
  }]
}

上述代码将使用 EBS 构建器为 Amazon Web Services 构建一个镜像。该镜像将驻留在eu-central-1地区,并将基于ami-5900cc36,这是一个 Debian Jessie 镜像。我们希望构建器是一个t2.micro实例(这是 AWS 中的 VM 大小)。为了准备我们的镜像,我们运行两个apt-get命令。

我们还可以重用先前定义的 Ansible 代码,而不是使用 Packer 来配置我们的应用程序,我们可以将 Ansible 替换为 provisioner。我们的代码将如下所示:

{
  "variables": {
    "aws_access_key": "",
    "aws_secret_key": ""
  },
  "builders": [{
    "type": "amazon-ebs",
    "access_key": "{{user `aws_access_key`}}",
    "secret_key": "{{user `aws_secret_key`}}",
    "region": "eu-central-1",
    "source_ami": "ami-0f1026b68319bad6c",
    "instance_type": "t2.micro",
    "ssh_username": "admin",
    "ami_name": "Project's Base Image {{timestamp}}"
  }],
  "provisioners": [{
 "type": "ansible",
 "playbook_file": "./provision.yml",
 "user": "admin",
 "host_alias": "baseimage"
 }],
 "post-processors": [{
 "type": "manifest",
 "output": "manifest.json",
 "strip_path": true
 }]
}

更改在provisioners块中,还添加了一个新的块post-processors。这一次,我们不再使用 shell 命令,而是使用一个运行 Ansible 的不同的 provisioner。后处理器用于以机器可读的格式生成构建结果。一旦 Packer 完成构建所需的工件,它会返回其 ID,并将其保存在manifest.json中。对于 AWS 来说,这意味着一个 AMI ID,然后我们可以将其提供给 Terraform。

使用 Terraform 编排基础架构

使用 Packer 创建镜像是第一步。之后,我们希望部署该镜像以使用它。我们可以使用 Terraform 基于我们的 Packer 模板中的镜像构建一个 AWS EC2 实例。

示例 Terraform 代码如下所示:

# Configure the AWS provider
provider "aws" {
  region = var.region
  version = "~> 2.7"
}

# Input variable pointing to an SSH key we want to associate with the 
# newly created machine
variable "public_key_path" {
  description = <<DESCRIPTION
Path to the SSH public key to be used for authentication.
Ensure this keypair is added to your local SSH agent so provisioners can
connect.
Example: ~/.ssh/terraform.pub
DESCRIPTION

  default = "~/.ssh/id_rsa.pub"
}

# Input variable with a name to attach to the SSH key
variable "aws_key_name" {
  description = "Desired name of AWS key pair"
  default = "terraformer"
}

# An ID from our previous Packer run that points to the custom base image
variable "packer_ami" {
}

variable "env" {
  default = "development"
}

variable "region" {
}

# Create a new AWS key pair cotaining the public key set as the input 
# variable
resource "aws_key_pair" "deployer" {
  key_name = var.aws_key_name

  public_key = file(var.public_key_path)
}

# Create a VM instance from the custom base image that uses the previously created key
# The VM size is t2.xlarge, it uses a persistent storage volume of 60GiB,
# and is tagged for easier filtering
resource "aws_instance" "project" {
  ami = var.packer_ami

  instance_type = "t2.xlarge"

  key_name = aws_key_pair.deployer.key_name

  root_block_device {
    volume_type = "gp2"
    volume_size = 60
  }

  tags = {
    Provider = "terraform"
    Env = var.env
    Name = "main-instance"
  }
}

这将创建一个密钥对和一个使用此密钥对的 EC2 实例。EC2 实例基于作为变量提供的 AMI。在调用 Terraform 时,我们将设置此变量指向 Packer 生成的镜像。

总结

到目前为止,您应该已经了解到,在项目开始阶段实施 CI 如何帮助您节省长期时间。尤其是与 CD 配对时,它还可以减少工作进展。在本章中,我们介绍了一些有用的工具,可以帮助您实施这两个过程。

我们已经展示了 GitLab CI 如何让我们在 YAML 文件中编写流水线。我们已经讨论了代码审查的重要性,并解释了各种形式的代码审查之间的区别。我们介绍了 Ansible,它有助于配置管理和部署代码的创建。最后,我们尝试了 Packer 和 Terraform,将我们的重点从创建应用程序转移到创建系统。

本章中的知识并不局限于 C++语言。您可以在使用任何技术编写的任何语言的项目中使用它。您应该牢记的重要事情是:所有应用程序都需要测试。编译器或静态分析器不足以验证您的软件。作为架构师,您还必须考虑的不仅是您的项目(应用程序本身),还有产品(您的应用程序将在其中运行的系统)。仅交付可工作的代码已不再足够。了解基础架构和部署过程至关重要,因为它们是现代系统的新构建模块。

下一章将专注于软件的安全性。我们将涵盖源代码本身、操作系统级别以及与外部服务和最终用户的可能交互。

问题

  1. CI 在开发过程中如何节省时间?

  2. 您是否需要单独的工具来实施 CI 和 CD?

  3. 在会议中进行代码审查有何意义?

  4. 在 CI 期间,您可以使用哪些工具来评估代码的质量?

  5. 谁参与指定 BDD 场景?

  6. 在什么情况下会考虑使用不可变基础设施?在什么情况下会排除它?

  7. 您如何描述 Ansible、Packer 和 Terraform 之间的区别?

进一步阅读

  • 持续集成/持续部署/持续交付:

www.packtpub.com/virtualization-and-cloud/hands-continuous-integration-and-delivery

www.packtpub.com/virtualization-and-cloud/cloud-native-continuous-integration-and-delivery

  • Ansible:

www.packtpub.com/virtualization-and-cloud/mastering-ansible-third-edition

www.packtpub.com/application-development/hands-infrastructure-automation-ansible-video

  • Terraform:

www.packtpub.com/networking-and-servers/getting-started-terraform-second-edition

www.packtpub.com/big-data-and-business-intelligence/hands-infrastructure-automation-terraform-aws-video

  • 黄瓜:

www.packtpub.com/web-development/cucumber-cookbook

  • GitLab:

www.packtpub.com/virtualization-and-cloud/gitlab-quick-start-guide

www.packtpub.com/application-development/hands-auto-devops-gitlab-ci-video

第十章:代码和部署中的安全性

在建立适当的测试之后,有必要进行安全审计,以确保我们的应用程序不会被用于恶意目的。本章描述了如何评估代码库的安全性,包括内部开发的软件和第三方模块。它还将展示如何在代码级别和操作系统级别改进现有软件。

您将学习如何在每个级别上设计重点放在安全性上的应用程序,从代码开始,通过依赖关系、架构和部署。

本章将涵盖以下主题:

  • 检查代码安全性

  • 检查依赖项是否安全

  • 加固您的代码

  • 加固您的环境

技术要求

本章中使用的一些示例需要具有以下最低版本的编译器:

  • GCC 10+

  • Clang 3.1+

本章中的代码已经放在 GitHub 上github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter10

检查代码安全性

在本章中,我们提供了有关如何检查您的代码、依赖项和环境是否存在潜在威胁的信息。但请记住,遵循本章中概述的每个步骤不一定会保护您免受所有可能的问题。我们的目标是向您展示一些可能的危险以及处理它们的方法。鉴于此,您应始终意识到系统的安全性,并使审计成为例行事件。

在互联网变得无处不在之前,软件作者并不太关心他们设计的安全性。毕竟,如果用户提供了格式不正确的数据,用户最多只能使自己的计算机崩溃。为了利用软件漏洞访问受保护的数据,攻击者必须获得物理访问权限到保存数据的机器。

即使是设计用于网络内部使用的软件,安全性也经常被忽视。以超文本传输协议HTTP)为例。尽管它允许对某些资产进行密码保护,但所有数据都是以明文传输的。这意味着在同一网络上的每个人都可以窃听正在传输的数据。

今天,我们应该从设计的最初阶段就开始重视安全,并在软件开发、运营和维护的每个阶段都牢记安全性。我们每天生产的大部分软件都意味着以某种方式与其他现有系统连接。

通过省略安全措施,我们不仅使自己暴露于潜在的攻击、数据泄漏和最终诉讼的风险中,还使我们的合作伙伴暴露于潜在的攻击、数据泄漏和最终诉讼的风险中。请记住,未能保护个人数据可能会导致数百万美元的罚款。

注重安全的设计

我们如何为安全性设计架构?这样做的最佳方式是像潜在的攻击者一样思考。有许多方法可以打开一个盒子,但通常,您会寻找不同元素连接的裂缝。(在盒子的情况下,这可能是盒子的盖子和底部之间。)

在软件架构中,元素之间的连接称为接口。由于它们的主要作用是与外部世界进行交互,它们是整个系统中最容易受到攻击的部分。确保您的接口受到保护、直观和稳健将解决软件可能被破坏的最明显的方式。

使接口易于使用且难以滥用

为了设计接口既易于使用又难以滥用,考虑以下练习。想象一下你是接口的客户。您希望实现一个使用您的支付网关的电子商务商店,或者您希望实现一个连接本书中始终使用的示例系统的客户 API 的 VR 应用程序。

作为关于接口设计的一般规则,避免以下特征:

  • 传递给函数/方法的参数太多

  • 参数名称模糊

  • 使用输出参数

  • 参数取决于其他参数

为什么这些特征被认为是有问题的?

  • 第一个特征不仅使参数的含义难以记忆,而且使参数的顺序也难以记忆。这可能导致使用错误,进而可能导致崩溃和安全问题。

  • 第二个特征与第一个特征有类似的后果。通过使接口使用起来不太直观,您使用户更容易犯错误。

  • 第三个特征是第二个特征的一个变体,但有一个额外的转折。用户不仅需要记住哪些参数是输入,哪些是输出,还需要记住如何处理输出。谁管理资源的创建和删除?这是如何实现的?背后的内存管理模型是什么?

使用现代 C++,返回包含所有必要数据的值比以往任何时候都更容易。通过对成对、元组和向量的使用,没有理由使用输出参数。此外,返回值有助于接受不修改对象状态的做法。这反过来又减少了与并发相关的问题。

  • 最后一个特征引入了不必要的认知负荷,就像前面的例子一样,可能导致错误,最终导致失败。这样的代码也更难测试和维护,因为每次引入的更改都必须考虑到已经存在的所有可能的组合。未能正确处理任何组合都是对系统的潜在威胁。

接口的前述规则适用于接口的外部部分。您还应该通过验证输入、确保值正确和合理,并防止接口提供的服务被不必要地使用来对内部部分应用类似的措施。

启用自动资源管理

系统不稳定也可能是由于内存泄漏、数据竞争和死锁引起的。所有这些症状都是资源管理不善的表现。尽管资源管理是一个难题,但有一种机制可以帮助您减少问题的数量。这样的机制之一是自动资源管理。

在这种情况下,资源是通过操作系统获得访问权限的东西,您必须确保正确使用它。这可能意味着使用动态分配的内存、打开文件、套接字、进程或线程。当您获取它们和释放它们时,所有这些都需要采取特定的操作。其中一些在其生命周期内还需要特定的操作。在正确的时间释放这些资源失败会导致泄漏。由于资源通常是有限的,从长远来看,泄漏将导致无法创建新资源时出现意外行为。

资源管理在 C++中非常重要,因为与许多其他高级语言不同,C++中没有垃圾回收,软件开发人员负责资源的生命周期。了解这种生命周期有助于创建安全稳定的系统。

资源管理最常见的习惯用法是资源获取即初始化RAII)。尽管它起源于 C++,但它也被用于其他语言,如 Vala 和 Rust。这种习惯用法使用对象的构造函数和析构函数来分配和释放资源。这样,我们可以保证在持有资源的对象超出范围时,资源将被正确释放。

在标准库中使用此习惯用法的一些示例是std::unique_ptrstd::shared_ptr智能指针类型。其他示例包括互斥锁-std::lock_guardstd::unique_lockstd:shared_lock-或文件-std::ifstreamstd::ofstream

指南支持库GSL),我们将很快讨论,还实现了一项特别有用的自动资源管理指南。通过在我们的代码中使用gsl::finally()函数,我们创建了一个附有一些代码的gsl::final_action()对象。当对象的析构函数被调用时,这些代码将被执行。这意味着该代码将在成功从函数返回时执行,以及在发生异常期间进行堆栈展开时执行。

这种方法不应该经常使用,因为通常最好在设计类时考虑 RAII。但如果您正在与第三方模块进行接口,并且希望确保包装器的安全性,finally()可以帮助您实现这一点。

举个例子,假设我们有一个支付操作员,每个账户只允许一个并发登录。如果我们不想阻止用户进行未来的支付,我们应该在完成交易处理后立即注销。当一切按照我们的设计进行时,这并不是一个问题。但在发生异常时,我们也希望安全地释放资源。以下是我们可以使用gsl::finally()来实现的方式:

TransactionStatus processTransaction(AccountName account, ServiceToken token,

Amount amount)

{

  payment::login(account, token);

  auto _ = gsl::finally([] { payment::logout(); });

  payment::process(amount); // We assume this can lead to exception


  return TransactionStatus::TransactionSuccessful;

}

无论在调用payment::process()期间发生了什么,我们至少可以保证在退出processTransaction()的范围时注销用户。

简而言之,使用 RAII 使您在类设计阶段更多地考虑资源管理,同时在您完全控制代码并且在您使用接口时不再那么清晰时,您不再那么考虑。

并发的缺点及如何处理

虽然并发可以提高性能和资源利用率,但也使您的代码更难设计和调试。这是因为,与单线程流程不同,操作的时间无法提前确定。在单线程代码中,您要么写入资源,要么从中读取,但您总是知道操作的顺序,因此可以预测对象的状态。

并发时,多个线程或进程可以同时从对象中读取或修改。如果修改不是原子的,我们可能会遇到常见更新问题的变体之一。考虑以下代码:

TransactionStatus chargeTheAccount(AccountNumber acountNumber, Amount amount)

{

  Amount accountBalance = getAcountBalance(accountNumber);

  if (accountBalance > amount)

  {

    setAccountBalance(accountNumber, accountBalance - amount);

    return TransactionStatus::TransactionSuccessful;

  }

  return TransactionStatus::InsufficientFunds;

}

调用chargeTheAccount函数时,从非并发代码中,一切都会顺利进行。我们的程序将检查账户余额,并在可能的情况下进行扣款。然而,并发执行可能会导致负余额。这是因为两个线程可以依次调用getAccountBalance(),它将返回相同的金额,比如20。在执行完该调用后,两个线程都会检查当前余额是否高于可用金额。最后,在检查后,它们修改账户余额。假设两个交易金额都为10,每个线程都会将余额设置为 20-10=10。在两个操作之后,账户的余额为 10,尽管它应该是 0!

为了减轻类似问题,我们可以使用诸如互斥锁和临界区、CPU 提供的原子操作或并发安全数据结构等解决方案。

互斥锁、临界区和其他类似的并发设计模式可以防止多个线程修改(或读取)数据。尽管它们在设计并发应用程序时很有用,但与之相关的是一种权衡。它们有效地使您的代码的某些部分变成单线程。这是因为由互斥锁保护的代码只允许一个线程执行;其他所有线程都必须等待,直到互斥锁被释放。由于我们引入了等待,即使我们最初的目标是使代码更具性能,我们也可能使代码的性能下降。

原子操作意味着使用单个 CPU 指令来获得期望的效果。这个术语可以指任何将高级操作转换为单个 CPU 指令的操作。当单个指令实现的效果超出通常可能的范围时,它们特别有趣。例如,比较和交换CAS)是一种指令,它将内存位置与给定值进行比较,并仅在比较成功时将该位置的内容修改为新值。自 C++11 以来,有一个<std::atomic>头文件可用,其中包含几种原子数据类型和操作。例如,CAS 被实现为一组compare_and_exchange_*函数。

最后,并发安全的数据结构(也称为并发数据结构)为数据结构提供了安全的抽象,否则这些数据结构将需要某种形式的同步。例如,Boost.Lockfree(www.boost.org/doc/libs/1_66_0/doc/html/lockfree.html)库提供了用于多个生产者和多个消费者的并发队列和栈。libcds(github.com/khizmax/libcds)还提供了有序列表、集合和映射,但截至撰写本书时,已经有几年没有更新了。

在设计并发处理时要牢记的有用规则如下:

  • 首先考虑是否需要并发。

  • 通过值传递数据,而不是通过指针或引用。这可以防止其他线程在读取数据时修改该值。

  • 如果数据的大小使得按值共享变得不切实际,可以使用shared_ptr。这样,更容易避免资源泄漏。

安全编码、指南和 GSL

标准 C++基金会发布了一套指南,记录了构建 C++系统的最佳实践。这是一个在 GitHub 上发布的 Markdown 文档,网址为github.com/isocpp/CppCoreGuidelines。这是一个不断发展的文档,没有发布计划(不像 C++标准本身)。这些指南针对的是现代 C++,基本上意味着实现了至少 C++11 特性的代码库。

指南中提出的许多规则涵盖了我们在本章中介绍的主题。例如,有关接口设计、资源管理和并发的规则。指南的编辑是 Bjarne Stroustrup 和 Herb Sutter,他们都是 C++社区中受尊敬的成员。

我们不会详细描述这些指南。我们鼓励您自己阅读。本书受到其中许多规则的启发,并在我们的示例中遵循这些规则。

为了方便在各种代码库中使用这些规则,微软发布了指南支持库GSL)作为一个开源项目,托管在github.com/microsoft/GSL上。这是一个仅包含头文件的库,您可以将其包含在项目中以使用定义的类型。您可以包含整个 GSL,也可以选择性地仅使用您计划使用的一些类型。

该库的另一个有趣之处在于它使用 CMake 进行构建,Travis 进行持续集成,以及 Catch 进行单元测试。因此,它是我们在第七章、构建和打包,第八章、可测试代码编写和第九章、持续集成和持续部署中涵盖的主题的一个很好的例子。

防御性编码,验证一切

在前一章中,我们提到了防御性编程的方法。尽管这种方法并不严格属于安全功能,但它确实有助于创建健壮的接口。这样的接口反过来又增加了系统的整体安全性。

作为一个很好的启发式方法,您可以将所有外部数据视为不安全。我们所说的外部数据是通过某个接口(编程接口或用户界面)进入系统的每个输入。为了表示这一点,您可以在适当的类型前加上Unsafe前缀,如下所示:

RegistrationResult registerUser(UnsafeUsername username, PasswordHash passwordHash)

{

  SafeUsername safeUsername = username.sanitize();

  try

  {

    std::unique_ptr<User> user = std::make_unique<User>(safeUsername, passwordHash);

    CommitResult result = user->commit();

    if (result == CommitResult::CommitSuccessful)

    {

      return RegistrationResult::RegistrationSuccessful;

    }

    else

    {

      return RegistrationResult::RegistrationUnsuccessful;

    }

  }

  catch (UserExistsException _)

  {

    return RegistrationResult::UserExists;

  }

}

如果您已经阅读了指南,您将知道通常应避免直接使用 C API。C API 中的一些函数可能以不安全的方式使用,并需要特别小心地防御性使用它们。最好使用 C++中相应的概念,以确保更好的类型安全性和保护(例如,防止缓冲区溢出)。

防御性编程的另一个方面是智能地重用现有代码。每次尝试实现某种技术时,请确保没有其他人在您之前实现过它。当您学习一种新的编程语言时,自己编写排序算法可能是一种有趣的挑战,但对于生产代码,最好使用标准库中提供的排序算法。对于密码哈希也是一样。毫无疑问,您可以找到一些聪明的方法来计算密码哈希并将其存储在数据库中,但通常更明智的做法是使用经过验证的bcrypt。请记住,智能的代码重用假设您以与您自己的代码一样的尽职调查检查和审计第三方解决方案。我们将在下一节“我的依赖项安全吗?”中深入探讨这个话题。

值得注意的是,防御性编程不应该变成偏执的编程。检查用户输入是明智的做法,而在初始化变量后立即断言初始化变量是否仍然等于原始值则有些过分。您希望控制数据和算法的完整性以及第三方解决方案的完整性。您不希望通过采用语言特性来验证编译器的正确性。

简而言之,从安全性和可读性的角度来看,使用 C++核心指南中提出的Expects()Ensures()以及通过类型和转换区分不安全和安全数据是一个好主意。

最常见的漏洞

要检查您的代码是否安全防范最常见的漏洞,您应首先了解这些漏洞。毕竟,只有当您知道攻击是什么样子时,防御才有可能。开放式网络应用安全项目OWASP)已经对最常见的漏洞进行了分类,并在www.owasp.org/index.php/Category:OWASP_Top_Ten_Project上发布了它们。在撰写本书时,这些漏洞如下:

  • 注入:通常称为 SQL 注入。这不仅限于 SQL;当不受信任的数据直接传递给解释器(如 SQL 数据库、NoSQL 数据库、shell 或 eval 函数)时,就会出现这种漏洞。攻击者可能以这种方式访问应该受到保护的系统部分。

  • 破坏的身份验证:如果身份验证实施不当,攻击者可能利用漏洞来获取秘密数据或冒充其他用户。

  • 敏感数据暴露:缺乏加密和适当的访问权限可能导致敏感数据被公开。

  • XML 外部实体XXE):一些 XML 处理器可能会泄露服务器文件系统的内容或允许远程代码执行。

  • 破坏的访问控制:当访问控制未正确执行时,攻击者可能会访问应受限制的文件或数据。

  • 安全配置错误:使用不安全的默认值和不正确的配置是最常见的漏洞来源。

  • 跨站脚本攻击XSS):包括并执行不受信任的外部数据,特别是使用 JavaScript,这允许控制用户的网络浏览器。

  • 不安全的反序列化:一些有缺陷的解析器可能会成为拒绝服务攻击或远程代码执行的牺牲品。

  • 使用已知漏洞的组件:现代应用程序中的许多代码都是第三方组件。这些组件应该定期进行审计和更新,因为单个依赖中已知的安全漏洞可能导致整个应用程序和数据被攻击。幸运的是,有一些工具可以帮助自动化这一过程。

  • 日志和监控不足:如果你的系统受到攻击,而你的日志和监控不够彻底,攻击者可能会获得更深入的访问权限,而你却没有察觉。

我们不会详细介绍每个提到的漏洞。我们想要强调的是,通过将所有外部数据视为不安全,你可以首先通过删除所有不安全的内容来对其进行净化,然后再开始实际处理。

当涉及到日志和监控不足时,我们将在第十五章中详细介绍云原生设计。在那里,我们将介绍一些可能的可观察性方法,包括日志记录、监控和分布式跟踪。

检查依赖是否安全

计算机早期,所有程序都是单体结构,没有任何外部依赖。自操作系统诞生以来,任何非平凡的软件很少能摆脱依赖。这些依赖可以分为两种形式:外部依赖和内部依赖。

  • 外部依赖是我们运行应用程序时应该存在的环境。例如,前面提到的操作系统、动态链接库和其他应用程序(如数据库)。

  • 内部依赖是我们想要重用的模块,因此通常是静态库或仅包含头文件的库。

两种依赖都提供潜在的安全风险。随着每一行代码增加漏洞的风险,你拥有的组件越多,你的系统可能受到攻击的机会就越高。在接下来的章节中,我们将看到如何检查你的软件是否确实容易受到已知的漏洞攻击。

通用漏洞和暴露

检查软件中已知的安全问题的第一个地方是通用漏洞和暴露CVE)列表,可在cve.mitre.org/上找到。该列表由几个被称为CVE 编号机构CNAs)的机构不断更新。这些机构包括供应商和项目、漏洞研究人员、国家和行业 CERT 以及漏洞赏金计划。

该网站还提供了一个搜索引擎。通过这个,你可以使用几种方法了解漏洞:

  • 你可以输入漏洞编号。这些编号以CVE为前缀,例如 CVE-2014-6271,臭名昭著的 ShellShock,或者 CVE-2017-5715,也被称为 Spectre。

  • 你可以输入漏洞的通用名称,比如前面提到的 ShellShock 或 Spectre。

  • 你可以输入你想审计的软件名称,比如 Bash 或 Boost。

对于每个搜索结果,你可以看到描述以及其他 bug 跟踪器和相关资源的参考列表。描述通常列出受漏洞影响的版本,因此你可以检查你计划使用的依赖是否已经修补。

自动化扫描器

有一些工具可以帮助您审计依赖项列表。其中一个工具是 OWASP Dependency-Check (www.owasp.org/index.php/OWASP_Dependency_Check)。尽管它只正式支持 Java 和.NET,但它对 Python、Ruby、Node.js 和 C++(与 CMake 或autoconf一起使用时)有实验性支持。除了作为独立工具使用外,它还可以与 Jenkins、SonarQube 和 CircleCI 等持续集成/持续部署CI/CD)软件集成。

另一个允许检查已知漏洞的依赖项的工具是 Snyk。这是一个商业产品,有几个支持级别。与 OWASP Dependency-Check 相比,它还可以执行更多操作,因为 Snyk 还可以审计容器映像和许可合规性问题。它还提供了更多与第三方解决方案的集成。

自动化依赖项升级管理

监视依赖项的漏洞只是确保项目安全的第一步。之后,您需要采取行动并手动更新受损的依赖项。正如您可能已经预料到的那样,也有专门的自动化解决方案。其中之一是 Dependabot,它会扫描您的源代码存储库,并在有安全相关更新可用时发布拉取请求。在撰写本书时,Dependabot 尚不支持 C++。但是,它可以与您的应用程序可能使用的其他语言一起使用。除此之外,它还可以扫描 Docker 容器,查找基础映像中发现的漏洞。

自动化依赖项管理需要成熟的测试支持。在没有测试的情况下切换依赖项版本可能会导致不稳定和错误。防止与依赖项升级相关的问题的一种保护措施是使用包装器与第三方代码进行接口。这样的包装器可能有自己的一套测试,可以在升级期间立即告诉我们接口何时被破坏。

加固您的代码

通过使用现代 C++构造而不是较旧的 C 等效构造,可以减少自己代码中常见的安全漏洞数量。然而,即使更安全的抽象也可能存在漏洞。仅仅选择更安全的实现并认为自己已经尽了最大努力是不够的。大多数情况下,都有方法可以进一步加固您的代码。

但是什么是代码加固?根据定义,这是减少系统漏洞表面的过程。通常,这意味着关闭您不会使用的功能,并追求一个简单的系统而不是一个复杂的系统。这也可能意味着使用工具来增加已有功能的健壮性。

这些工具可能意味着在操作系统级别应用内核补丁、防火墙和入侵检测系统IDSes)。在应用程序级别,这可能意味着使用各种缓冲区溢出和下溢保护机制,使用容器和虚拟机VMs)进行特权分离和进程隔离,或者强制执行加密通信和存储。

在本节中,我们将重点介绍应用程序级别的一些示例,而下一节将重点介绍操作系统级别。

面向安全的内存分配器

如果您认真保护应用程序免受与堆相关的攻击,例如堆溢出、释放后使用或双重释放,您可能会考虑用面向安全的版本替换标准内存分配器。可能感兴趣的两个项目如下:

FreeGuard 于 2017 年发布,自那时以来除了零星的错误修复外,没有太多变化。另一方面,hardened_malloc正在积极开发。这两个分配器都旨在作为标准malloc()的替代品。您可以通过设置LD_PRELOAD环境变量或将库添加到/etc/preload.so配置文件中,而无需修改应用程序即可使用它们。虽然 FreeGuard 针对的是 64 位 x86 系统上的 Linux 与 Clang 编译器,hardened_malloc旨在更广泛的兼容性,尽管目前主要支持 Android 的 Bionic,muslglibchardened_malloc也基于 OpenBSD 的alloc,而 OpenBSD 本身是一个以安全为重点的项目。

不要替换内存分配器,可以替换你用于更安全的集合。 SaferCPlusPlus(duneroadrunner.github.io/SaferCPlusPlus/)项目提供了std::vector<>std::array<>std::string的替代品,可以作为现有代码中的替代品。该项目还包括用于保护未初始化使用或符号不匹配的基本类型的替代品,并发数据类型的替代品,以及指针和引用的替代品。

自动化检查

有一些工具可以特别有助于确保正在构建的系统的安全。我们将在下一节中介绍它们。

编译器警告

虽然编译器警告本身不一定是一个工具,但可以使用和调整编译器警告,以实现更好的输出,从而使每个 C++开发人员都将使用的 C++编译器获得更好的输出。

由于编译器已经可以进行一些比标准要求更深入的检查,建议利用这种可能性。当使用诸如 GCC 或 Clang 之类的编译器时,推荐的设置包括-Wall -Wextra标志。这将生成更多的诊断,并在代码不遵循诊断时产生警告。如果您想要非常严格,还可以启用-Werror,这将把所有警告转换为错误,并阻止不能通过增强诊断的代码的编译。如果您想严格遵循标准,还有-pedantic-pedantic-errors标志,将检查是否符合标准。

在使用 CMake 进行构建时,您可以使用以下函数在编译期间启用这些标志:

add_library(customer ${SOURCES_GO_HERE})

target_include_directories(customer PUBLIC include)

target_compile_options(customer PRIVATE -Werror -Wall -Wextra)

这样,除非您修复编译器报告的所有警告(转换为错误),否则编译将失败。

您还可以在 OWASP(www.owasp.org/index.php/C-Based_Toolchain_Hardening)和 Red Hat(developers.redhat.com/blog/2018/03/21/compiler-and-linker-flags-gcc/)的文章中找到工具链加固的建议设置。

静态分析

一类可以帮助使您的代码更安全的工具是所谓的静态应用安全测试SAST)工具。它们是专注于安全方面的静态分析工具的变体。

SAST 工具很好地集成到 CI/CD 管道中,因为它们只是读取您的源代码。输出通常也适用于 CI/CD,因为它突出显示了源代码中特定位置发现的问题。另一方面,静态分析可能会忽略许多类型的问题,这些问题无法自动发现,或者仅通过静态分析无法发现。这些工具也对与配置相关的问题视而不见,因为配置文件并未在源代码本身中表示。

C++ SAST 工具的示例包括以下开源解决方案:

还有商业解决方案可用:

动态分析

就像静态分析是在源代码上执行的一样,动态分析是在生成的二进制文件上执行的。名称中的“动态”指的是观察代码在处理实际数据时的行为。当专注于安全性时,这类工具也可以被称为动态应用安全性测试DAST)。

它们相对于 SAST 工具的主要优势在于,它们可以发现许多从源代码分析角度看不到的流程。当然,这也带来了一个缺点,即您必须运行应用程序才能进行分析。而且我们知道,运行应用程序可能既耗时又耗内存。

DAST 工具通常专注于与 Web 相关的漏洞,如 XSS、SQL(和其他)注入或泄露敏感信息。我们将在下一小节中更多地关注一个更通用的动态分析工具 Valgrind。

Valgrind 和 Application Verifier

Valgrind 主要以内存泄漏调试工具而闻名。实际上,它是一个帮助构建与内存问题无关的动态分析工具的仪器框架。除了内存错误检测器外,该套工具目前还包括线程错误检测器、缓存和分支预测分析器以及堆分析器。它在类 Unix 操作系统(包括 Android)上支持各种平台。

基本上,Valgrind 充当虚拟机,首先将二进制文件转换为称为中间表示的简化形式。它不是在实际处理器上运行程序,而是在这个虚拟机下执行,以便分析和验证每个调用。

如果您在 Windows 上开发,可以使用Application VerifierAppVerifier)代替 Valgrind。AppVerifier 可以帮助您检测稳定性和安全性问题。它可以监视运行中的应用程序和用户模式驱动程序,以查找内存问题,如泄漏和堆破坏,线程和锁定问题,句柄的无效使用等。

消毒剂

消毒剂是基于代码的编译时仪器的动态测试工具。它们可以帮助提高系统的整体稳定性和安全性,避免未定义的行为。在github.com/google/sanitizers,您可以找到 LLVM(Clang 基于此)和 GCC 的实现。它们解决了内存访问、内存泄漏、数据竞争和死锁、未初始化内存使用以及未定义行为的问题。

AddressSanitizerASan)可保护您的代码免受与内存寻址相关的问题,如全局缓冲区溢出,释放后使用或返回后使用堆栈。尽管它是同类解决方案中最快的之一,但仍会使进程减速约两倍。最好在运行测试和进行开发时使用它,但在生产构建中关闭它。您可以通过向 Clang 添加-fsanitize=address标志来为您的构建启用它。

AddressSanitizerLeakSanitizerLSan)与 ASan 集成以查找内存泄漏。它在 x86_64 Linux 和 x86_64 macOS 上默认启用。它需要设置一个环境变量,ASAN_OPTIONS=detect_leaks=1。LSan 在进程结束时执行泄漏检测。LSan 也可以作为一个独立库使用,而不需要 AddressSanitizer,但这种模式测试较少。

ThreadSanitizerTSan),正如我们之前提到的,可以检测并发问题,如数据竞争和死锁。您可以使用-fsanitize=thread标志启用它到 Clang。

MemorySanitizerMSan)专注于与对未初始化内存的访问相关的错误。它实现了我们在前一小节中介绍的 Valgrind 的一些功能。MSan 支持 64 位 x86、ARM、PowerPC 和 MIPS 平台。您可以通过向 Clang 添加-fsanitize=memory -fPIE -pie标志来启用它(这也会打开位置无关可执行文件,这是我们稍后将讨论的概念)。

硬件辅助地址消毒剂HWASAN)类似于常规 ASan。主要区别在于尽可能使用硬件辅助。目前,此功能仅适用于 64 位 ARM 架构。

UndefinedBehaviorSanitizerUBSan)寻找未定义行为的其他可能原因,如整数溢出、除以零或不正确的位移操作。您可以通过向 Clang 添加-fsanitize=undefined标志来启用它。

尽管消毒剂可以帮助您发现许多潜在问题,但它们只有在您对其进行测试时才有效。在使用消毒剂时,请记住保持测试的代码覆盖率高,否则您可能会产生一种虚假的安全感。

模糊测试

作为 DAST 工具的一个子类,模糊测试检查应用程序在面对无效、意外、随机或恶意形成的数据时的行为。在针对跨越信任边界的接口(如最终用户文件上传表单或输入)时,此类检查尤其有用。

此类别中的一些有趣工具包括以下内容:

进程隔离和沙箱

如果您想在自己的环境中运行未经验证的软件,您可能希望将其与系统的其余部分隔离开来。通过虚拟机、容器或 AWS Lambda 使用的 Firecracker(firecracker-microvm.github.io/)等微型虚拟机,可以对执行的代码进行沙盒化。

这样,一个应用程序的崩溃、泄漏和安全问题不会传播到整个系统,使其变得无用或者受到威胁。由于每个进程都有自己的沙盒,最坏的情况就是只丢失这一个服务。

对于 C 和 C++代码,还有一个由谷歌领导的开源项目Sandboxed APISAPIgithub.com/google/sandboxed-api),它允许构建沙盒不是为整个进程,而是为库。它被谷歌自己的 Chrome 和 Chromium 网页浏览器等使用。

即使虚拟机和容器可以成为进程隔离策略的一部分,也不要将它们与微服务混淆,后者通常使用类似的构建模块。微服务是一种架构设计模式,它们并不自动等同于更好的安全性。

加固您的环境

即使您采取了必要的预防措施,确保您的依赖项和代码没有已知的漏洞,仍然存在一个可能会危及您的安全策略的领域。所有应用程序都需要一个执行环境,这可能意味着容器、虚拟机或操作系统。有时,这也可能意味着底层基础设施。

当运行应用程序的操作系统具有开放访问权限时,仅仅使应用程序达到最大程度的硬化是不够的。这样,攻击者可以从系统或基础设施级别直接获取未经授权的数据,而不是针对您的应用程序。

本节将重点介绍一些硬化技术,您可以在执行的最低级别应用这些技术。

静态与动态链接

链接是在编译后发生的过程,当您编写的代码与其各种依赖项(如标准库)结合在一起时。链接可以在构建时、加载时(操作系统执行二进制文件时)或运行时发生,如插件和其他动态依赖项的情况。最后两种用例只可能发生在动态链接中。

那么,动态链接和静态链接有什么区别呢?使用静态链接,所有依赖项的内容都会被复制到生成的二进制文件中。当程序加载时,操作系统将这个单一的二进制文件放入内存并执行它。静态链接是由称为链接器的程序在构建过程的最后一步执行的。

由于每个可执行文件都必须包含所有的依赖项,静态链接的程序往往体积较大。这也有其好处;因为执行所需的一切都已经在一个地方可用,所以执行速度可能会更快,并且加载程序到内存中所需的时间总是相同的。对依赖项的任何更改都需要重新编译和重新链接;没有办法升级一个依赖项而不改变生成的二进制文件。

在动态链接中,生成的二进制文件包含您编写的代码,但是依赖项的内容被替换为需要单独加载的实际库的引用。在加载时,动态加载器的任务是找到适当的库并将它们加载到内存中与您的二进制文件一起。当多个应用程序同时运行并且它们每个都使用类似的依赖项(例如 JSON 解析库或 JPEG 处理库)时,动态链接的二进制文件将导致较低的内存使用率。这是因为只有一个给定库的副本可以加载到内存中。相比之下,使用静态链接的二进制文件中相同的库会作为结果的一部分一遍又一遍地加载。当您需要升级其中一个依赖项时,您可以在不触及系统的任何其他组件的情况下进行。下次加载应用程序到内存时,它将自动引用新升级的组件。

静态和动态链接也具有安全性影响。更容易未经授权地访问动态链接的应用程序。这可以通过在常规库的位置替换受损的动态库或在每次新执行的进程中预加载某些库来实现。

当您将静态链接与容器结合使用时(在后面的章节中详细解释),您将获得小型、安全、沙箱化的执行环境。您甚至可以进一步使用这些容器与基于微内核的虚拟机,从而大大减少攻击面。

地址空间布局随机化

地址空间布局随机化ASLR)是一种用于防止基于内存的攻击的技术。它通过用随机化的内存布局替换程序和数据的标准布局来工作。这意味着攻击者无法可靠地跳转到在没有 ASLR 的系统上本来存在的特定函数。

当与不执行NX)位支持结合使用时,这种技术可以变得更加有效。NX 位标记内存中的某些页面,例如堆和栈,只包含不能执行的数据。大多数主流操作系统都已实现了 NX 位支持,并且可以在硬件支持时使用。

DevSecOps

为了按可预测的方式交付软件增量,最好采用 DevOps 理念。简而言之,DevOps 意味着打破传统模式,鼓励业务、软件开发、软件运营、质量保证和客户之间的沟通。DevSecOps 是 DevOps 的一种形式,它还强调了在每个步骤中考虑安全性的必要性。

这意味着您正在构建的应用程序从一开始就具有内置的可观察性,利用 CI/CD 流水线,并定期扫描漏洞。DevSecOps 使开发人员在基础架构设计中发挥作用,并使运营专家在构成应用程序的软件包设计中发挥作用。由于每个增量代表一个可工作的系统(尽管不是完全功能的),因此安全审计定期进行,所需时间比正常情况下少。这导致更快速和更安全的发布,并允许更快地对安全事件做出反应。

总结

在本章中,我们讨论了安全系统的不同方面。由于安全性是一个复杂的主题,您不能仅从自己的应用程序的角度来处理它。现在所有的应用程序都在某种环境中运行,要么控制这个环境并根据您的要求塑造它,要么通过沙箱化和隔离代码来保护自己免受环境的影响。

阅读完本章后,您现在可以开始搜索依赖项和自己代码中的漏洞。您知道如何设计增强安全性的系统以及使用哪些工具来发现可能的缺陷。保持安全是一个持续的过程,但良好的设计可以减少未来的工作量。

下一章将讨论可扩展性以及在系统扩展时可能面临的各种挑战。

问题

  1. 为什么安全在现代系统中很重要?

  2. 并发的一些挑战是什么?

  3. C++核心指南是什么?

  4. 安全编码和防御性编码有什么区别?

  5. 您如何检查您的软件是否包含已知的漏洞?

  6. 静态分析和动态分析有什么区别?

  7. 静态链接和动态链接有什么区别?

  8. 您如何使用编译器来解决安全问题?

  9. 您如何在 CI 流程中实施安全意识?

进一步阅读

一般的网络安全

并发

操作系统加固

第十一章:性能

选择 C++作为项目的关键编程语言的最常见原因之一是出于性能要求。在性能方面,C++比竞争对手明显更有优势,但要取得最佳结果需要理解相关问题。本章重点介绍如何提高 C++软件的性能。我们将首先向您展示用于测量性能的工具。我们将向您展示一些增加单线程计算速度的技术。然后我们将讨论如何利用并行计算。最后,我们将展示如何使用 C++20 的协程进行非抢占式多任务处理。

本章将涵盖以下主题:

  • 性能测量

  • 帮助编译器生成高性能代码

  • 并行计算

  • 使用协程

首先,让我们指定在本章中运行示例所需的内容。

技术要求

要复制本章中的示例,您应安装以下内容:

  • CMake 3.15+

  • 支持 C++20 的范围和协程的编译器,例如 GCC 10+

本章的源代码片段可以在github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter11找到。

性能测量

要有效地提高代码的性能,您必须首先测量其性能。如果不知道实际瓶颈在哪里,最终会优化错误的地方,浪费时间,并且会对您的辛勤工作几乎没有收获感到惊讶和沮丧。在本节中,我们将展示如何使用基准测试正确测量性能,如何成功地对代码进行分析,并如何深入了解分布式系统的性能。

进行准确和有意义的测量

为了获得准确和可重复的测量结果,您可能还希望将计算机置于性能模式,而不是通常的默认节能模式。如果您需要系统低延迟,您可能希望永久禁用两台机器上的节能模式,并在生产环境中禁用节能模式。许多时候,这可能意味着进入 BIOS 并正确配置服务器。请注意,如果您使用公共云提供商,则可能无法做到这一点。如果您在计算机上拥有 root/admin 权限,操作系统通常也可以调整一些设置。例如,您可以通过在 Linux 系统上运行以下命令来强制 CPU 以最大频率运行:

sudo cpupower frequency-set --governor performance

此外,为了获得有意义的结果,您可能希望在尽可能接近生产环境的系统上进行测量。除了配置之外,诸如 RAM 的不同速度、CPU 缓存的数量和 CPU 的微体系结构等方面也可能扭曲您的结果,并导致您得出错误的结论。硬盘设置、甚至网络拓扑和使用的硬件也是如此。您构建的软件也起着至关重要的作用:从固件使用,通过操作系统和内核,一直到软件堆栈和依赖项。最好有一个与您的生产环境相同的第二个环境,并使用相同的工具和脚本进行管理。

既然我们已经有了一个稳固的测量环境,让我们看看我们实际上可以测量些什么。

利用不同类型的测量工具

有几种测量性能的方法,每种方法都侧重于不同的范围。让我们逐一看看它们。

基准测试可用于测试系统在预先制定的测试中的速度。通常,它们会导致完成时间或每秒处理的订单等性能指标。有几种类型的基准测试:

  • 微基准测试,您可以用它来测量小代码片段的执行。我们将在下一节中介绍它们。

  • 模拟,这是对较大规模的人工数据进行的合成测试。如果您无法访问目标数据或目标硬件,它们可能会很有用。例如,当您计划检查您正在开发的硬件的性能,但它尚不存在,或者当您计划处理传入的流量,但只能假设流量的情况时。

  • 重放,这是一种非常准确的衡量真实工作负载下性能的方法。其思想是记录进入生产系统的所有请求或工作负载,通常带有时间戳。然后可以将这些转储“重放”到基准系统中,尊重它们之间的时间差异,以检查其性能。这样的基准测试可以很好地看到代码或环境的潜在变化如何影响系统的延迟和吞吐量。

  • 行业标准,这是一个很好的方法,可以看到我们的产品与竞争对手相比的表现。此类基准测试的示例包括用于 CPU 的 SuperPi,用于图形卡的 3D Mark 以及用于人工智能处理器的 ResNet-50。

除了基准测试之外,另一种在衡量性能时非常宝贵的工具是性能分析器。性能分析器不仅可以为您提供整体性能指标,还可以让您检查代码的执行情况并寻找瓶颈。它们对于捕捉减慢系统速度的意外情况非常有用。我们将在本章后面更详细地介绍它们。

掌握系统性能的最后一种方法是追踪。追踪本质上是在执行过程中记录系统行为的一种方式。通过监视请求完成各个处理步骤所需的时间(例如由不同类型的微服务处理),您可以洞察系统哪些部分需要改进性能,或者您的系统如何处理不同类型的请求:无论是不同类型的请求还是被接受或拒绝的请求。我们将在本章后面介绍追踪 - 就在性能分析之后。

现在让我们再多说几句关于微基准。

使用微基准测试

微基准测试用于衡量“微”代码片段的执行速度。如果您想知道如何实现特定功能,或者不同的第三方库如何处理相同的任务的速度,那么它们是完成此任务的完美工具。虽然它们不能代表真实环境,但它们非常适合执行这样的小型实验。

让我们展示如何使用 C++中最常用的框架之一来运行这样的实验:Google Benchmark。

设置 Google Benchmark

让我们首先通过 Conan 将库引入我们的代码中。将以下内容放入您的conanfile.txt中:

[requires]

benchmark/1.5.2


[generators]

CMakeDeps

我们将使用 CMakeDeps 生成器,因为它是 Conan 2.0 中推荐的 CMake 生成器。它依赖于 CMake 的find_package功能来使用我们的原始依赖管理器安装的软件包。要安装它们的发布版本的依赖项,请运行以下命令:

cd <build_directory>

conan install <source_directory> --build=missing -s build_type=Release

如果您正在使用自定义的 Conan 配置文件,请记得在这里也添加它。

从您的CMakeLists.txt文件中使用它也非常简单,如下所示:

list(APPEND CMAKE_PREFIX_PATH "${CMAKE_BINARY_DIR}")

find_package(benchmark REQUIRED)

首先,我们将我们的构建目录添加到CMAKE_PREFIX_PATH中,以便 CMake 可以找到 Conan 生成的配置文件和/或目标文件。接下来,我们只需使用它们来找到我们的依赖项。

由于我们将创建多个微基准测试,我们可以使用一个 CMake 函数来帮助我们定义它们:

function(add_benchmark NAME SOURCE)

  add_executable(${NAME} ${SOURCE})

  target_compile_features(${NAME} PRIVATE cxx_std_20)

  target_link_libraries(${NAME} PRIVATE benchmark::benchmark)

endfunction()

该函数将能够创建单一翻译单元的微基准测试,每个测试都使用 C++20 并链接到 Google Benchmark 库。现在让我们使用它来创建我们的第一个微基准测试可执行文件:

add_benchmark(microbenchmark_1 microbenchmarking/main_1.cpp)

现在我们准备在源文件中放入一些代码。

编写您的第一个微基准测试

我们将尝试基准测试使用二分法在排序向量中进行查找时需要多快,与仅线性查找相比。让我们从创建排序向量的代码开始:

using namespace std::ranges;


template <typename T>

auto make_sorted_vector(std::size_t size) {

  auto sorted = std::vector<T>{};

  sorted.reserve(size);


  auto sorted_view = views::iota(T{0}) | views::take(size);

  std::ranges::copy(sorted_view, std::back_inserter(sorted));

  return sorted;

}

我们的向量将包含大小元素,所有数字从 0 到大小-1 按升序排列。现在让我们指定我们要查找的元素和容器的大小:

constexpr auto MAX_HAYSTACK_SIZE = std::size_t{10'000'000};

constexpr auto NEEDLE = 2137;

如您所见,我们将基准测试在干草垛中查找针需要多长时间。简单的线性搜索可以实现如下:

void linear_search_in_sorted_vector(benchmark::State &state) {

  auto haystack = make_sorted_vector<int>(MAX_HAYSTACK_SIZE);

  for (auto _ : state) {

    benchmark::DoNotOptimize(find(haystack, NEEDLE));

  }

}

在这里,我们可以看到 Google Benchmark 的第一次使用。每个微基准测试应该接受State作为参数。这种特殊类型执行以下操作:

  • 包含执行的迭代和测量计算所花费的时间的信息

  • 计算所处理的字节数

  • 如果需要,可以返回其他状态信息,例如需要进一步运行(通过KeepRunning()成员函数)

  • 可以用于暂停和恢复迭代的计时(分别通过PauseTiming()ResumeTiming()成员函数)

我们的循环中的代码将被测量,根据允许运行此特定基准测试的总时间进行所需的迭代。我们的干草垛的创建在循环外部,不会被测量。

在循环内部,有一个名为DoNotOptimize的辅助函数。它的目的是确保编译器不会摆脱我们的计算,因为它可以证明它们在这个范围之外是无关紧要的。在我们的情况下,它将标记std::find的结果是必要的,所以实际用于查找目标的代码不会被优化掉。使用诸如 objdump 或诸如 Godbolt 和 QuickBench 的网站等工具,可以查看您想要运行的代码是否已被优化掉。QuickBench 的额外优势在于在云中运行您的基准测试并在线共享其结果。

回到我们手头的任务,我们有一个线性搜索的微基准测试,所以现在让我们在另一个微基准测试中计时二进制搜索:

void binary_search_in_sorted_vector(benchmark::State &state) {

  auto haystack = make_sorted_vector<int>(MAX_HAYSTACK_SIZE);

  for (auto _ : state) {

    benchmark::DoNotOptimize(lower_bound(haystack, NEEDLE));

  }

}

我们的新基准测试非常相似。它只在使用的函数上有所不同:lower_bound将执行二进制搜索。请注意,与我们的基本示例类似,我们甚至不检查迭代器返回的指向向量中的有效元素,还是指向其末尾。在lower_bound的情况下,我们可以检查迭代器下的元素是否实际上是我们要查找的元素。

现在我们有了微基准测试函数,让我们通过添加以下内容将它们创建为实际的基准测试:

BENCHMARK(binary_search_in_sorted_vector);

BENCHMARK(linear_search_in_sorted_vector);

如果默认的基准测试设置对您来说没问题,那么您只需要通过。作为最后一步,让我们添加一个main()函数:

BENCHMARK_MAIN();

就是这么简单!或者,您也可以链接我们的程序而不是benchmark_main。使用 Google Benchmark 的main()函数的优点是提供了一些默认选项。如果编译我们的基准测试并在传递--help作为参数运行它,您将看到以下内容:

benchmark [--benchmark_list_tests={true|false}]

          [--benchmark_filter=<regex>]

          [--benchmark_min_time=<min_time>]

          [--benchmark_repetitions=<num_repetitions>]

          [--benchmark_report_aggregates_only={true|false}]

          [--benchmark_display_aggregates_only={true|false}]

          [--benchmark_format=<console|json|csv>]

          [--benchmark_out=<filename>]

          [--benchmark_out_format=<json|console|csv>]

          [--benchmark_color={auto|true|false}]

          [--benchmark_counters_tabular={true|false}]

          [--v=<verbosity>]

这是一组很好的功能。例如,在设计实验时,您可以使用benchmark_format开关获取 CSV 输出,以便更容易绘制图表。

现在让我们通过在编译后的可执行文件上不带命令行参数来看看我们的基准测试的运行情况。运行./microbenchmark_1的可能输出如下:

2021-02-28T16:19:28+01:00

Running ./microbenchmark_1

Run on (8 X 2601 MHz CPU s)

Load Average: 0.52, 0.58, 0.59

-------------------------------------------------------------------------

Benchmark                               Time             CPU   Iterations

-------------------------------------------------------------------------

linear_search_in_sorted_vector        984 ns          984 ns       746667

binary_search_in_sorted_vector       18.9 ns         18.6 ns     34461538

从运行环境的一些数据开始(基准测试的时间、可执行文件名称、服务器的 CPU 和当前负载),我们得到了我们定义的每个基准测试的结果。对于每个基准测试,我们得到每次迭代的平均墙时间、每次迭代的平均 CPU 时间以及基准测试运行的迭代次数。默认情况下,单次迭代时间越长,迭代次数就越少。运行更多的迭代可以确保您获得更稳定的结果。

将任意参数传递给微基准测试

如果我们要测试处理手头问题的更多方法,我们可以寻找一种重用基准代码并将其传递给执行查找的函数的方法。Google Benchmark 具有一个我们可以使用的功能。该框架实际上允许我们通过将它们作为函数签名的附加参数来传递任何参数给基准。

让我们看看使用此功能的我们的基准的统一签名会是什么样子:

void search_in_sorted_vector(benchmark::State &state, auto finder) {

  auto haystack = make_sorted_vector<int>(MAX_HAYSTACK_SIZE);

  for (auto _ : state) {

    benchmark::DoNotOptimize(finder(haystack, NEEDLE));

  }

}

您可以注意到函数的新finder参数,它用于我们之前调用findlower_bound的位置。现在我们可以使用与上次不同的宏来创建我们的两个微基准测试:

BENCHMARK_CAPTURE(search_in_sorted_vector, binary, lower_bound);

BENCHMARK_CAPTURE(search_in_sorted_vector, linear, find);

BENCHMARK_CAPTURE宏接受函数、名称后缀和任意数量的参数。如果我们需要更多,我们可以在这里传递它们。我们的基准函数可以是常规函数或模板-两者都受支持。现在让我们看看在运行代码时我们会得到什么:

-------------------------------------------------------------------------

Benchmark                               Time             CPU   Iterations

-------------------------------------------------------------------------

search_in_sorted_vector/binary       19.0 ns         18.5 ns     28000000

search_in_sorted_vector/linear        959 ns          952 ns       640000

如您所见,传递给函数的参数不是名称的一部分,而是函数名称和我们的后缀。

现在让我们看看如何进一步定制我们的基准测试。

将数值参数传递给微基准测试

设计类似我们的实验时的一个常见需求是在不同大小的参数上进行检查。在 Google Benchmark 中可以通过多种方式来满足这些需求。最简单的方法就是在BENCHMARK宏返回的对象上添加一个调用Args()。这样,我们可以传递一组值来在给定的微基准测试中使用。要使用传递的值,我们需要将我们的基准函数更改如下:

void search_in_sorted_vector(benchmark::State &state, auto finder) {

  const auto haystack = make_sorted_vector<int>(state.range(0));

  const auto needle = 2137;

  for (auto _ : state) {

    benchmark::DoNotOptimize(finder(haystack, needle));

  }

}

state.range(0)的调用将读取传递的第 0 个参数。可以支持任意数量。在我们的情况下,它用于参数化干草堆的大小。如果我们想要传递一系列值集合呢?这样,我们可以更容易地看到改变大小如何影响性能。我们可以调用Range而不是Args来进行基准测试:

constexpr auto MIN_HAYSTACK_SIZE = std::size_t{1'000};

constexpr auto MAX_HAYSTACK_SIZE = std::size_t{10'000'000};


BENCHMARK_CAPTURE(search_in_sorted_vector, binary, lower_bound)

    ->RangeMultiplier(10)

    ->Range(MIN_HAYSTACK_SIZE, MAX_HAYSTACK_SIZE);

BENCHMARK_CAPTURE(search_in_sorted_vector, linear, find)

    ->RangeMultiplier(10)

    ->Range(MIN_HAYSTACK_SIZE, MAX_HAYSTACK_SIZE);

我们使用预定义的最小值和最大值来指定范围边界。然后我们告诉基准测试工具通过乘以 10 来创建范围,而不是使用默认值。当我们运行这样的基准测试时,可能会得到以下结果:

-------------------------------------------------------------------------

Benchmark                                 Time        CPU     Iterations

-------------------------------------------------------------------------

search_in_sorted_vector/binary/1000      0.2 ns    19.9 ns     34461538

search_in_sorted_vector/binary/10000     24.8 ns   24.9 ns     26352941

search_in_sorted_vector/binary/100000    26.1 ns   26.1 ns     26352941

search_in_sorted_vector/binary/1000000   29.6 ns   29.5 ns     24888889

search_in_sorted_vector/binary/10000000  25.9 ns   25.7 ns     24888889

search_in_sorted_vector/linear/1000      482 ns     474 ns      1120000

search_in_sorted_vector/linear/10000     997 ns    1001 ns       640000

search_in_sorted_vector/linear/100000    1005 ns   1001 ns       640000

search_in_sorted_vector/linear/1000000   1013 ns   1004 ns       746667

search_in_sorted_vector/linear/10000000  990 ns    1004 ns       746667

在分析这些结果时,您可能会想知道为什么线性搜索没有显示出线性增长。这是因为我们寻找一个可以在恒定位置被发现的针的恒定值。如果干草堆中包含我们的针,我们需要相同数量的操作来找到它,无论干草堆的大小如何,因此执行时间停止增长(但仍可能受到小波动的影响)。

为什么不也尝试一下针的位置呢?

以编程方式生成传递的参数

在一个简单的函数中生成干草堆大小和针位置可能是最简单的。Google Benchmark 允许这样的场景,让我们看看它们在实践中是如何工作的。

让我们首先重写我们的基准函数,以便在每次迭代中传递两个参数:

void search_in_sorted_vector(benchmark::State &state, auto finder) {

  const auto needle = state.range(0);

  const auto haystack = make_sorted_vector<int>(state.range(1));

  for (auto _ : state) {

    benchmark::DoNotOptimize(finder(haystack, needle));

  }

}

如您所见,state.range(0)将标记我们的针位置,而state.range(1)将是干草堆的大小。这意味着我们需要每次传递两个值。让我们创建一个生成它们的函数:

void generate_sizes(benchmark::internal::Benchmark *b) {

  for (long haystack = MIN_HAYSTACK_SIZE; haystack <= MAX_HAYSTACK_SIZE;

       haystack *= 100) {

    for (auto needle :

         {haystack / 8, haystack / 2, haystack - 1, haystack + 1}) {

      b->Args({needle, haystack});

    }

  }

}

我们不使用RangeRangeMultiplier,而是编写一个循环来生成干草堆的大小,这次每次增加 100。至于针,我们使用干草堆的成比例位置中的三个位置和一个落在干草堆之外的位置。我们在每次循环迭代中调用Args,传递生成的值。

现在,让我们将我们的生成函数应用于我们定义的基准测试:

BENCHMARK_CAPTURE(search_in_sorted_vector, binary, lower_bound)->Apply(generate_sizes);

BENCHMARK_CAPTURE(search_in_sorted_vector, linear, find)->Apply(generate_sizes);

使用这样的函数可以轻松地将相同的生成器传递给许多基准测试。这样的基准测试可能的结果如下:

-------------------------------------------------------------------------

Benchmark                                        Time     CPU  Iterations

-------------------------------------------------------------------------

search_in_sorted_vector/binary/125/1000       20.0 ns  20.1 ns   37333333

search_in_sorted_vector/binary/500/1000       19.3 ns  19.0 ns   34461538

search_in_sorted_vector/binary/999/1000       20.1 ns  19.9 ns   34461538

search_in_sorted_vector/binary/1001/1000      18.1 ns  18.0 ns   40727273

search_in_sorted_vector/binary/12500/100000   35.0 ns  34.5 ns   20363636

search_in_sorted_vector/binary/50000/100000   28.9 ns  28.9 ns   24888889

search_in_sorted_vector/binary/99999/100000   31.0 ns  31.1 ns   23578947

search_in_sorted_vector/binary/100001/100000  29.1 ns  29.2 ns   23578947

// et cetera

现在我们有了一个非常明确定义的实验来执行搜索。作为练习,在您自己的机器上运行实验,以查看完整的结果,并尝试从结果中得出一些结论。

选择微基准测试和优化的对象

进行这样的实验可能是有教育意义的,甚至会让人上瘾。但请记住,微基准测试不应该是项目中唯一的性能测试类型。正如唐纳德·克努斯所说:

我们应该忘记小的效率,大约 97%的时间:过早的优化是万恶之源

这意味着您应该只对重要的代码进行微基准测试,特别是您的热路径上的代码。较大的基准测试,以及跟踪和探测,可以用来查看何时何地进行优化,而不是猜测和过早优化。首先,了解您的软件是如何执行的。

注意:关于上面的引用,我们还想再提一个观点。这并不意味着您应该允许过早的恶化。数据结构或算法的选择不佳,甚至是散布在所有代码中的小的低效率,有时可能会影响系统的整体性能。例如,执行不必要的动态分配,虽然一开始看起来可能不那么糟糕,但随着时间的推移可能会导致堆碎片化,并在应用程序长时间运行时给您带来严重的麻烦。过度使用基于节点的容器也可能导致更多的缓存未命中。长话短说,如果编写高效代码而不是低效代码不需要花费太多精力,那就去做吧。

现在让我们学习一下,如果您的项目有需要长期保持良好性能的地方,应该怎么做。

使用基准测试创建性能测试

与精确测试的单元测试和代码正确性的大规模功能测试类似,您可以使用微基准测试和较大的基准测试来测试代码的性能。

如果对某些代码路径的执行时间有严格的限制,那么确保达到限制的测试可能非常有用。即使没有这样具体的限制,您可能也对监视性能在代码更改时如何变化感兴趣。如果在更改后,您的代码运行比以前慢了一定的阈值,测试可能会被标记为失败。

尽管也是一个有用的工具,但请记住,这样的测试容易受到渐渐降低性能的影响:随着时间的推移,性能的下降可能会不被注意,因此请确保偶尔监视执行时间。在您的 CI 中引入性能测试时,确保始终在相同的环境中运行,以获得稳定的结果。

现在让我们讨论性能工具箱中的下一类工具。

探测

虽然基准测试和跟踪可以为给定范围提供概述和具体数字,但探测器可以帮助您分析这些数字的来源。如果您需要深入了解性能并进行改进,它们是必不可少的工具。

选择要使用的探测器类型

有两种类型的探测器可用:仪器探测器和采样探测器。较为知名的仪器探测器之一是 Callgrind,它是 Valgrind 套件的一部分。仪器探测器有很大的开销,因为它们需要对您的代码进行仪器化,以查看您调用了哪些函数以及每个函数的执行时间。这样,它们产生的结果甚至包含最小的函数,但执行时间可能会受到这种开销的影响。它还有一个缺点,就是不总是能捕捉到输入/输出的缓慢和抖动。它会减慢执行速度,因此,虽然它可以告诉您调用特定函数的频率,但它不会告诉您缓慢是由于等待磁盘读取完成而引起的。

由于仪器探测器的缺陷,通常最好使用采样探测器。两个值得一提的是开源的 perf 用于在 Linux 系统上进行性能分析,以及英特尔的专有工具 VTune(对于开源项目是免费的)。虽然它们有时会由于采样的性质而错过关键事件,但通常应该能够更好地展示代码的时间分配情况。

如果你决定使用 perf,你应该知道你可以通过调用perf stat来使用它,这会给你一个关于 CPU 缓存使用等统计数据的快速概览,或者使用perf record -gperf report -g来捕获和分析性能分析结果。

如果你想要对 perf 有一个扎实的概述,请观看 Chandler Carruth 的视频,其中展示了工具的可能性以及如何使用它,或者查看它的教程。这两者都在进一步阅读部分中链接。

准备分析器和处理结果

在分析性能分析结果时,你可能经常需要进行一些准备、清理和处理。例如,如果你的代码大部分时间都在忙碌,你可能希望将其过滤掉。在开始使用分析器之前,一定要编译或下载尽可能多的调试符号,包括你的代码、你的依赖项,甚至操作系统库和内核。此外,禁用帧指针优化也是必要的。在 GCC 和 Clang 上,你可以通过传递-fno-omit-frame-pointer标志来实现。这不会对性能产生太大影响,但会为你提供更多关于代码执行的数据。在结果的后处理方面,使用 perf 时,通常最好从结果中创建火焰图。Brendan Gregg 在进一步阅读部分中的工具非常适合这个用途。火焰图是一个简单而有效的工具,可以看出执行花费了太多时间的地方,因为图表上每个项目的宽度对应着资源使用情况。你可以得到 CPU 使用情况的火焰图,以及资源使用情况、分配和页面错误等方面的火焰图,或者代码在不执行时花费的时间,比如在系统调用期间保持阻塞、在互斥锁上、I/O 操作等。还有一些方法可以对生成的火焰图进行差异分析。

分析结果

请记住,并非所有性能问题都会在这样的图表上显示出来,也不是所有问题都可以通过性能分析器找到。尽管有了一些经验,你可能会发现你可以从为线程设置亲和性或更改线程在特定 NUMA 节点上执行的方式中受益,但并不总是那么明显地看出你忘记了禁用节能功能或者从启用或禁用超线程中受益。关于你运行的硬件的信息也是有用的。有时你可能会看到 CPU 的 SIMD 寄存器被使用,但代码仍然无法以最快的速度运行:你可能使用了 SSE 指令而不是 AVX 指令,AVX 而不是 AVX2,或者 AVX2 而不是 AVX512。当你分析性能分析结果时,了解你的 CPU 能够运行哪些具体指令可能是非常有价值的。

解决性能问题也需要一些经验。另一方面,有时经验可能会导致你做出错误的假设。例如,在许多情况下,使用动态多态性会影响性能;但也有一些情况下,它不会减慢你的代码。在得出结论之前,值得对代码进行性能分析,并了解编译器优化代码的各种方式以及这些技术的限制。具体来说,关于虚拟化,当你不希望其他类型继承和重写你的虚拟成员函数时,将你的虚拟成员函数的类标记为 final 通常会帮助编译器在许多情况下。

编译器也可以更好地优化,如果它们“看到”对象的类型:如果你在作用域中创建一个类型并调用它的虚拟成员函数,编译器应该能够推断出应该调用哪个函数。GCC 倾向于比其他编译器更好地进行去虚拟化。关于这一点的更多信息,你可以参考进一步阅读部分中 Arthur O'Dwyer 的博客文章。

与本节中介绍的其他类型的工具一样,尽量不要只依赖于您的分析器。性能分析结果的改进并不意味着您的系统变得更快。一个看起来更好的性能分析图仍然不能告诉您整个故事。一个组件的更好性能并不一定意味着整个系统的性能都得到了改善。这就是我们最后一种类型的工具可以派上用场的地方。

跟踪

我们将在本节中讨论的最后一种技术是针对分布式系统的。在查看整个系统时,通常部署在云中,在一个盒子上对软件进行性能分析并不能告诉您整个故事。在这种范围内,您最好的选择是跟踪请求和响应在系统中的流动。

跟踪是记录代码执行的一种方式。当一个请求(有时还有其响应)必须流经系统的许多部分时,通常会使用它。通常情况下,这样的消息会沿着路线被跟踪,并在执行的有趣点添加时间戳。

相关 ID

时间戳的一个常见补充是相关 ID。基本上,它们是分配给每个被跟踪消息的唯一标识符。它们的目的是在处理相同的传入请求期间(有时也是由此引起的事件),相关 ID 可以将系统的不同组件(如不同的微服务)产生的日志相关联起来。这样的 ID 应该随着消息一起传递,例如通过附加到其 HTTP 标头。即使原始请求已经消失,您也可以将其相关 ID 添加到每个响应中。

通过使用相关 ID,您可以跟踪给定请求的消息如何在系统中传播,以及系统的不同部分处理它所花费的时间。通常情况下,您还希望在途中收集额外的数据,例如用于执行计算的线程,为给定请求产生的响应的类型和数量,或者它经过的机器的名称。

像 Jaeger 和 Zipkin(或其他 OpenTracing 替代方案)这样的工具可以帮助您快速为系统添加跟踪支持。

现在让我们来处理一个不同的主题,并谈谈代码生成。

帮助编译器生成高性能代码

有许多方法可以帮助编译器为您生成高效的代码。有些方法归结为正确引导编译器,而其他方法则需要以对编译器友好的方式编写代码。

了解您在关键路径上需要做什么,并有效地设计它也很重要。例如,尽量避免虚拟分派(除非您可以证明它已被去虚拟化),并尽量不要在其中分配新内存。通常情况下,一切可能会降低性能的东西都应该保持在热路径之外。使指令和数据缓存都保持热度确实会产生回报。甚至像[[likely]][[unlikely]]这样的属性,可以提示编译器应该期望执行哪个分支,有时也会产生很大的变化。

优化整个程序

增加许多 C++项目性能的一个有趣方法是启用链接时优化LTO)。在编译过程中,您的编译器不知道代码将如何与其他目标文件或库链接。许多优化的机会只有在这一点上才会出现:在链接时,您的工具可以看到程序的各个部分如何相互交互的整体情况。通过启用 LTO,您有时可以在几乎没有成本的情况下获得显著的性能改进。在 CMake 项目中,您可以通过设置全局的CMAKE_INTERPROCEDURAL_OPTIMIZATION标志或在目标上设置INTERPROCEDURAL_OPTIMIZATION属性来启用 LTO。

使用 LTO 的一个缺点是它使构建过程变得更长。有时会长很多。为了减少开发人员的成本,您可能只想为需要性能测试或发布的构建启用此优化。

基于实际使用模式进行优化

优化代码的另一种有趣方法是使用基于配置文件的优化PGO)。这种优化实际上是一个两步过程。在第一步中,您需要使用额外的标志编译代码,导致可执行文件在运行时收集特殊的分析信息。然后,您应该在预期的生产负载下执行它。完成后,您可以使用收集的数据第二次编译可执行文件,这次传递不同的标志,指示编译器使用收集的数据生成更适合您的配置文件的代码。这样,您将得到一个准备好并调整到您特定工作负载的二进制文件。

编写友好缓存的代码

这两种优化技术都可以派上用场,但在处理高性能系统时,还有一件重要的事情需要牢记:缓存友好性。使用平面数据结构而不是基于节点的数据结构意味着您在运行时需要执行更少的指针追踪,这有助于提高性能。无论是向前还是向后读取,使用内存中连续的数据意味着您的 CPU 内存预取器可以在使用之前加载它,这通常会产生巨大的差异。基于节点的数据结构和上述指针追踪会导致随机内存访问模式,这可能会“混淆”预取器,并使其无法预取正确的数据。

如果您想查看一些性能结果,请参考* C++容器基准测试*中的链接。它比较了std::vectorstd::liststd::dequeplf::colony的各种使用场景。如果你不知道最后一个,它是一个有趣的“袋”类型容器,具有快速插入和删除大数据的功能。

在选择关联容器时,您通常会希望使用“平面”实现而不是基于节点的实现。这意味着您可能想尝试tsl::hopscotch_map或 Abseil 的flat_hash_mapflat_hash_set,而不是使用std::unordered_mapstd::unordered_set

诸如将较冷的指令(例如异常处理代码)放在非内联函数中的技术可以帮助增加指令缓存的热度。这样,用于处理罕见情况的冗长代码将不会加载到指令缓存中,为应该在那里的更多代码留出空间,这也可以提高性能。

以数据为中心设计您的代码

如果要帮助缓存,另一种有用的技术是数据导向设计。通常,将更频繁使用的成员存储在内存中靠近彼此的位置是一个好主意。较冷的数据通常可以放在另一个结构中,并通过 ID 或指针与较热的数据连接。

有时,与更常见的对象数组不同,使用数组对象可以获得更好的性能。不要以面向对象的方式编写代码,而是将对象的数据成员分布在几个数组中,每个数组包含多个对象的数据。换句话说,采用以下代码:

struct Widget {

    Foo foo;

    Bar bar;

    Baz baz;

};


auto widgets = std::vector<Widget>{};

并考虑用以下内容替换它:

struct Widgets {

    std::vector<Foo> foos;

    std::vector<Bar> bars;

    std::vector<Baz> bazs;

};

这样,当处理一组特定的数据点与一些对象时,缓存热度增加,性能也会提高。如果你不知道这是否会提高代码的性能,请进行测量。

有时候,甚至重新排列类型的成员也可以带来更好的性能。您应该考虑数据成员类型的对齐。如果性能很重要,通常最好的做法是对它们进行排序,以便编译器不需要在成员之间插入太多填充。由于这样,您的数据类型的大小可以更小,因此许多这样的对象可以适应一个缓存行。考虑以下示例(假设我们正在为 x86_64 架构编译):

struct TwoSizesAndTwoChars {
    std::size_t first_size;
    char first_char;
    std::size_t second_size;
    char second_char;
};
static_assert(sizeof(TwoSizesAndTwoChars) == 32);

尽管每个大小都是 8 字节,每个字符只有 1 字节,但我们最终总共得到 32 字节!这是因为second_size必须从 8 字节对齐地址开始,所以在first_char之后,我们得到 7 字节的填充。对于second_char也是一样,因为类型需要相对于它们最大的数据类型成员进行对齐。

我们能做得更好吗?让我们尝试交换成员的顺序:

struct TwoSizesAndTwoChars {
    std::size_t first_size;
    std::size_t second_size;
    char first_char;
    char second_char;
};
static_assert(sizeof(TwoSizesAndTwoChars) == 24);

通过简单地将最大的成员放在最前面,我们能够将结构的大小减小 8 字节,这占其大小的 25%。对于这样一个微不足道的改变来说,效果不错。如果您的目标是将许多这样的结构打包到连续的内存块中并对它们进行迭代,您可能会看到代码片段的性能大幅提升。

现在让我们谈谈另一种提高性能的方法。

并行计算

在这一部分,我们将讨论几种不同的并行计算方法。我们将从线程和进程之间的比较开始,然后向您展示 C++标准中可用的工具,最后但并非最不重要的是,我们将简要介绍 OpenMP 和 MPI 框架。

在我们开始之前,让我们简要介绍一下如何估计您可以从并行化代码中获得的最大可能收益。有两个定律可以帮助我们。第一个是 Amdahl 定律。它指出,如果我们想通过增加核心数来加速我们的程序,那么必须保持顺序执行的代码部分(无法并行化)将限制我们的可伸缩性。例如,如果您的代码有 90%是可并行化的,那么即使有无限的核心,您最多只能获得 10 倍的加速。即使我们将执行该 90%的时间缩短到零,这 10%的代码仍将始终存在。

第二定律是 Gustafson 定律。它指出,每个足够大的任务都可以有效地并行化。这意味着通过增加问题的规模,我们可以获得更好的并行化(假设我们有空闲的计算资源可用)。换句话说,有时候最好的方法是在相同的时间框架内增加更多的功能,而不是试图减少现有代码的执行时间。如果您可以通过将核心数量翻倍来将任务的时间减少一半,那么在某个时刻,再次翻倍核心数量将会带来递减的回报,因此它们的处理能力可以更好地用在其他地方。

了解线程和进程之间的区别

要有效地并行计算,您还需要了解何时使用进程执行计算,何时线程是更好的工具。长话短说,如果您的唯一目标是实际并行化工作,那么最好是从增加额外线程开始,直到它们不带来额外的好处为止。在这一点上,在您的网络中的其他机器上添加更多进程,每个进程也有多个线程。

为什么呢?因为进程比线程更加笨重。生成一个进程和在它们之间切换所需的时间比创建和在线程之间切换所需的时间更长。每个进程都需要自己的内存空间,而同一进程内的线程共享它们的内存。此外,进程间通信比在线程之间传递变量要慢。使用线程比使用进程更容易,因此开发速度也会更快。

然而,在单个应用程序范围内,进程也有其用途。它们非常适合隔离可以独立运行和崩溃而不会将整个应用程序一起崩溃的组件。拥有单独的内存也意味着一个进程无法窥视另一个进程的内存,这在您需要运行可能是恶意的第三方代码时非常有用。这两个原因是它们在 Web 浏览器等应用程序中使用的原因。除此之外,还可以以不同的操作系统权限或特权运行不同的进程,这是无法通过多个线程实现的。

现在让我们讨论一种在单台机器范围内并行化工作的简单方法。

使用标准并行算法

如果您执行的计算可以并行化,有两种方法可以利用这一点。一种是用可并行化的标准库算法替换您对常规调用。如果您不熟悉并行算法,它们是在 C++17 中添加的,在本质上是相同的算法,但您可以向每个算法传递执行策略。有三种执行策略:

  • std::execution::seq:用于以非并行化方式执行算法的顺序策略。这个我们也太熟悉了。

  • std::execution::par:一个并行策略,表示执行可能是并行的,通常在底层使用线程池。

  • std::execution::par_unseq:一个并行策略,表示执行可能是并行化和矢量化的。

  • std::execution::unseq:C++20 添加到该系列的一个策略。该策略表示执行可以矢量化,但不能并行化。

如果前面的策略对您来说还不够,标准库实现可能会提供其他策略。可能的未来添加可能包括用于 CUDA、SyCL、OpenCL 甚至人工智能处理器的策略。

现在让我们看看并行算法的实际应用。例如,要以并行方式对向量进行排序,您可以编写以下内容:

std::sort(std::execution::par, v.begin(), v.end());

简单又容易。虽然在许多情况下这将产生更好的性能,但在某些情况下,您最好以传统方式执行算法。为什么?因为在更多线程上调度工作需要额外的工作和同步。此外,根据您的应用程序架构,它可能会影响其他已经存在的线程的性能并刷新它们的核心数据缓存。一如既往,先进行测量。

使用 OpenMP 和 MPI 并行化计算

使用标准并行算法的替代方法是利用 OpenMP 的编译指示。它们是一种通过添加几行代码轻松并行化许多类型计算的方法。如果您想要在集群上分发代码,您可能想看看 MPI 能为您做些什么。这两者也可以结合在一起。

使用 OpenMP,您可以使用各种编译指示轻松并行化代码。例如,您可以在for循环之前写#pragma openmp parallel for以使用并行线程执行它。该库还可以执行更多操作,例如在 GPU 和其他加速器上执行计算。

将 MPI 集成到您的项目中比只添加适当的编译指示更难。在这里,您需要在代码库中使用 MPI API 在进程之间发送或接收数据(使用诸如MPI_SendMPI_Recv的调用),或执行各种聚合和减少操作(调用MPI_BcastMPI_Reduce等此类函数)。通信可以通过点对点或使用称为通信器的对象到所有集群进行。

根据您的算法实现,MPI 节点可以全部执行相同的代码,或者在需要时可以变化。节点将根据其等级知道它应该如何行为:在计算开始时分配的唯一编号。说到这一点,要使用 MPI 启动进程,您应该通过包装器运行它,如下所示:

$ mpirun --hostfile my_hostfile -np 4 my_command --with some ./args

这将逐个从文件中读取主机,连接到每个主机,并在每个主机上运行四个my_command实例,传递参数。

MPI 有许多实现。其中最值得注意的是 OpenMPI(不要将其与 OpenMP 混淆)。在一些有用的功能中,它提供了容错能力。毕竟,节点宕机并不罕见。

我们想在本节中提到的最后一个工具是 GNU Parallel,如果您想要轻松地生成并行进程来执行工作,那么您可能会发现它很有用。它既可以在单台机器上使用,也可以跨计算集群使用。

说到执行代码的不同方式,现在让我们讨论 C++20 中的另一个重要主题:协程。

使用协程

协程是可以暂停其执行并稍后恢复的函数。它们允许以非常类似于编写同步代码的方式编写异步代码。与使用std::async编写异步代码相比,这允许编写更清晰、更易于理解和维护的代码。不再需要编写回调函数,也不需要处理std::async的冗长性与 promise 和 future。

除此之外,它们通常还可以为您提供更好的性能。基于std::async的代码通常在切换线程和等待方面有更多的开销。协程可以非常廉价地恢复和暂停,甚至与调用函数的开销相比,这意味着它们可以提供更好的延迟和吞吐量。此外,它们的设计目标之一是高度可扩展,甚至可以扩展到数十亿个并发协程。

图 11.1 - 调用和执行协程与使用常规函数不同,因为它们可以被暂停和恢复

C++协程是无栈的,这意味着它们的状态不存储在调用线程的堆栈上。这使它们具有一个有趣的特性:几个不同的线程可以接管协程的执行。换句话说,即使看起来协程函数体将按顺序执行,其中的部分也可以在不同的线程中执行。这使得可以将函数的部分留给专用线程来执行。例如,I/O 操作可以在专用的 I/O 线程中完成。

要检查一个函数是否是 C++协程,需要在其主体中查找以下关键字之一:

  • co_await,暂停协程。

  • co_yield,将一个值返回给调用者并暂停协程。类似于 Python 中生成器中使用的yield关键字。允许惰性生成值。

  • co_return,返回一个值并结束执行协程。这是return关键字的协程等价物。

每当函数主体具有这些关键字之一时,该函数自动成为协程。虽然这意味着这是一个实现细节,但还有一个提示可以使用:协程返回类型必须满足某些要求,我们将在后面讨论。

协程在 C++世界中是一等公民。这意味着你可以获得它们的地址,将它们用作函数参数,从函数中返回它们,并将它们存储在对象中。

在 C++中,即使在 C++20 之前,你也可以编写协程。这得益于诸如 Boost.Coroutine2 或 Bloomberg 的 Quantum 等库。后者甚至被用来实现 CoroKafka - 一个使用协程高效处理 Kafka 流的库。随着标准 C++协程的出现,新的库开始出现。现在,我们将向您展示其中之一。

区分 cppcoro 实用程序

从头开始编写基于协程的代码很困难。C++20 只提供了编写协程的基本实用程序,因此在编写自己的协程时,我们需要一组原语来使用。由 Lewis Baker 创建的 cppcoro 库是 C++中最常用的协程框架之一。在本节中,我们将展示该库,并演示在编写基于协程的代码时如何使用它。

让我们从库提供的协程类型概述开始:

  • 任务<>:用于安排稍后执行的工作-当它被co_awaited时开始执行。

  • shared_task<>:多个协程可以等待的任务。它可以被复制,以便多个协程引用相同的结果。本身不提供任何线程安全性。

  • generator:惰性和同步地产生一系列 Ts。它实际上是一个std::range:它有一个返回迭代器的begin()和一个返回哨兵的end()

  • recursive_generator:类似于generator<T>,但可以产生 T 或recursive_generator<T>。有一些额外的开销。

  • async_generator:类似于generator<T>,但值可以异步产生。这意味着与生成器相反,异步生成器可以在其主体中使用co_await

您应该将这些类型用作协程的返回类型。通常,在您的生成器(返回前述生成器类型之一的协程)中,您希望使用co_yield返回值(类似于 Python 生成器)。但是,在您的任务中,通常,您将希望使用co_await安排工作。

该库实际上提供了许多编程抽象,不仅仅是前述的协程类型。它还提供以下类型:

  • 可等待对象可以在其上co_await的类型,例如协程风格的事件和同步原语:互斥锁、闩锁、屏障等。

  • 与取消相关的实用程序,基本上允许您取消协程的执行。

  • 调度程序-允许您通过它们安排工作的对象,例如static_thread_pool,或者用于在特定线程上安排工作的对象。

  • I/O 和网络实用程序,允许您从文件和 IP 套接字中读取和写入。

  • 元函数和概念,例如awaitable_traitsAwaitableAwaiter

除了前述的实用程序之外,cppcoro 还为我们提供了函数-用于使用其他类和引导执行的实用程序,例如以下内容:

  • sync_wait:阻塞,直到传递的可等待对象完成。

  • when_all, when_all_ready:返回一个可等待对象,当所有传递的可等待对象完成时完成。这两者之间的区别在于处理子可等待对象的失败。when_all_ready将在发生故障时完成,调用者可以检查每个结果,而when_all将重新抛出异常,如果任何子可等待对象抛出异常(尽管不可能知道哪个子对象抛出异常)。它还将取消任何未完成的任务。

  • fmap:类似于函数式编程,将函数应用于可等待对象。您可以将其视为将一种类型的任务转换为另一种类型的任务。例如,您可以通过调用fmap(serialize, my_coroutine())序列化由您的协程返回的类型。

  • resume_on:指示协程在完成某些工作后继续执行时使用哪个调度程序。这使您能够在特定的执行上下文中执行某些工作,例如在专用 I/O 线程上运行 I/O 相关的任务。请注意,这意味着单个 C++函数(协程)可以在不同的线程上执行其部分。可以类似于std::ranges一样与计算一起“管道化”。

  • schedule_on:指示协程使用哪个调度程序开始一些工作。通常用作auto foo = co_await schedule_on(scheduler, do_work());

在我们开始一起使用这些实用程序之前,让我们再说几句关于可等待对象。

查看可等待对象和协程的内部工作原理

除了 cppcoro 之外,标准库还提供了另外两个简单的可等待对象:suspend_neversuspend_always。通过查看它们,我们可以看到在需要时如何实现我们自己的可等待对象:

struct suspend_never {

    constexpr bool await_ready() const noexcept { return true; }

    constexpr void await_suspend(coroutine_handle<>) const noexcept {}

    constexpr void await_resume() const noexcept {}

};


struct suspend_always {

    constexpr bool await_ready() const noexcept { return false; }

    constexpr void await_suspend(coroutine_handle<>) const noexcept {}

    constexpr void await_resume() const noexcept {}

};

当输入co_await时,您告诉编译器首先调用等待器的await_ready()。如果它通过返回 true 表示等待器已准备就绪,将调用await_resume()await_resume()的返回类型应该是等待器实际产生的类型。如果等待器没有准备好,程序将执行await_suspend()。完成后,我们有三种情况:

  • await_suspend返回void:执行后总是会暂停。

  • await_suspend返回bool:根据返回的值,执行将暂停或不暂停。

  • await_suspend返回std::coroutine_handle<PromiseType>:另一个协程将被恢复。

协程底层有更多的东西。即使协程不使用return关键字,编译器也会在底层生成代码使它们编译和工作。当使用co_yield等关键字时,它会将它们重写为对应的辅助类型的成员函数的调用。例如,对co_yield x的调用等效于co_await promise.yield_value(x)。如果您想了解更多关于发生了什么并编写自己的协程类型,请参考进一步阅读部分的Your First Coroutine文章。

好的,现在让我们利用所有这些知识来编写我们自己的协程。我们将创建一个简单的应用程序,模拟进行有意义的工作。它将使用线程池来填充一个向量中的一些数字。

我们的 CMake 目标将如下所示:

add_executable(coroutines_1 coroutines/main_1.cpp)

target_link_libraries(coroutines_1 PRIVATE cppcoro fmt::fmt Threads::Threads)

target_compile_features(coroutines_1 PRIVATE cxx_std_20)

我们将链接到 cppcoro 库。在我们的情况下,我们使用 Andreas Buhr 的 cppcoro 分支,因为它是 Lewis Baker 存储库的一个维护良好的分支,并支持 CMake。

我们还将链接到优秀的{fmt}库进行文本格式化。如果您的标准库提供了 C++20 的字符串格式化,您也可以使用它。

最后但同样重要的是,我们需要一个线程库 - 毕竟,我们想要在池中使用多个线程。

让我们从一些常量和一个main函数开始我们的实现:

inline constexpr auto WORK_ITEMS = 5;


int main() {

  auto thread_pool = cppcoro::static_thread_pool{3};

我们希望使用三个池化线程生成五个项目。cppcoro 的线程池是一种很好的调度工作的方式。默认情况下,它会创建与您的机器硬件线程一样多的线程。继续前进,我们需要指定我们的工作:

  fmt::print("Thread {}: preparing work\n", std::this_thread::get_id());

  auto work = do_routine_work(thread_pool);


  fmt::print("Thread {}: starting work\n", std::this_thread::get_id());

  const auto ints = cppcoro::sync_wait(work);

我们将在代码中添加日志消息,以便您更好地了解在哪个线程中发生了什么。这将帮助我们更好地理解协程的工作原理。我们通过调用名为do_routine_work的协程来创建工作。它返回给我们协程,我们使用sync_wait阻塞函数来运行它。协程在实际被等待之前不会开始执行。这意味着我们的实际工作将在这个函数调用内开始执行。

一旦我们有了结果,让我们记录它们:

  fmt::print("Thread {}: work done. Produced ints are: ",

             std::this_thread::get_id());

  for (auto i : ints) {

    fmt::print("{}, ", i);

  }

  fmt::print("\n");

这里没有巫术魔法。让我们定义我们的do_routine_work协程:

cppcoro::task<std::vector<int>>

do_routine_work(cppcoro::static_thread_pool &thread_pool) {


  auto mutex = cppcoro::async_mutex{};

  auto ints = std::vector<int>{};

  ints.reserve(WORK_ITEMS);

它返回一个任务,产生一些整数。因为我们将使用线程池,让我们使用 cppcoro 的async_mutex来同步线程。现在让我们开始使用池:

  fmt::print("Thread {}: passing execution to the pool\n",

             std::this_thread::get_id());


  co_await thread_pool.schedule();

您可能会感到惊讶,schedule()调用没有传入任何可调用对象来执行。在协程的情况下,我们实际上是让当前线程挂起协程并开始执行其调用者。这意味着它现在将等待协程完成(在sync_wait调用中的某个地方)。

与此同时,来自我们池中的一个线程将恢复协程 - 简单地继续执行其主体。这是我们为它准备的:

  fmt::print("Thread {}: running first pooled job\n",

             std::this_thread::get_id());


  std::vector<cppcoro::task<>> tasks;

  for (int i = 0; i < WORK_ITEMS; ++i) {

    tasks.emplace_back(

        cppcoro::schedule_on(thread_pool, fill_number(i, ints, mutex)));

  }

  co_await cppcoro::when_all_ready(std::move(tasks));
  co_return ints;

我们创建一个要执行的任务向量。每个任务在互斥锁下填充ints中的一个数字。schedule_on调用使用我们池中的另一个线程运行填充协程。最后,我们等待所有的结果。此时,我们的任务开始执行。最后,由于我们的协程是一个任务,我们使用co_return

不要忘记使用co_return返回生成的值。如果我们从示例中删除了co_return ints;这一行,我们将简单地返回一个默认构造的向量。程序将运行,愉快地打印空向量,并以代码 0 退出。

我们的最后一步是实现一个将产生一个数字的协程:

cppcoro::task<> fill_number(int i, std::vector<int> &ints,

                            cppcoro::async_mutex &mutex) {

  fmt::print("Thread {}: producing {}\n", std::this_thread::get_id(), i);

  std::this_thread::sleep_for(

      std::chrono::milliseconds((WORK_ITEMS - i) * 200));

这是一个不返回任何值的任务。相反,它将其添加到我们的向量中。它的辛苦工作实际上是通过打盹一定数量的毫秒来完成的。醒来后,协程将继续进行更有成效的努力:

  {

    auto lock = co_await mutex.scoped_lock_async();

    ints.emplace_back(i);

  }

它将锁定互斥锁。在我们的情况下,它只是一个await。当互斥锁被锁定时,它将向我们的向量添加一个数字 - 与调用它的相同的数字。

注意:记得使用co_await。如果你忘记了,而你的可等待对象允许这样做(也许是因为可以不消耗每个可等待对象),那么你可能会跳过一些重要的计算。在我们的示例中,这可能意味着不锁定互斥锁。

让我们现在完成协程的实现:

  fmt::print("Thread {}: produced {}\n", std::this_thread::get_id(), i);

  co_return;

只是一个简单的status print和一个co_return来标记协程为完成。一旦返回,协程帧就可以被销毁,释放其占用的内存。

就这些了。现在让我们运行我们的代码,看看会发生什么:

Thread 140471890347840: preparing work

Thread 140471890347840: starting work

Thread 140471890347840: passing execution to the pool

Thread 140471890282240: running first pooled job

Thread 140471890282240: producing 4

Thread 140471881828096: producing 1

Thread 140471873373952: producing 0

Thread 140471890282240: produced 4

Thread 140471890282240: producing 3

Thread 140471890282240: produced 3

Thread 140471890282240: producing 2

Thread 140471881828096: produced 1

Thread 140471873373952: produced 0

Thread 140471890282240: produced 2

Thread 140471890347840: work done. Produced ints are: 4, 3, 1, 0, 2, 

我们的主线程用于在线程池上启动工作,然后等待结果。然后,我们的线程池中的三个线程正在生成数字。最后安排的任务实际上是第一个运行的任务,生成数字 4。这是因为它一直在执行do_routine_work:首先,在线程池上安排了所有其他任务,然后在调用when_all_ready时开始执行第一个任务。随后,执行继续进行,第一个空闲线程接管线程池上安排的下一个任务,直到整个向量填满。最后,执行返回到我们的主线程。

这就结束了我们的简短示例。随之而来的是本章的最后一节。现在让我们总结一下我们学到的东西。

总结

在本章中,我们学习了什么类型的工具可以帮助我们提高代码的性能。我们学习了如何进行实验,编写性能测试,并寻找性能瓶颈。您现在可以使用 Google Benchmark 编写微基准测试。此外,我们讨论了如何对代码进行性能分析,以及如何(以及为什么)实现系统的分布式跟踪。我们还讨论了如何使用标准库工具和外部解决方案并行化计算。最后但同样重要的是,我们向您介绍了协程。您现在知道 C++20 为协程带来了什么,以及您可以在 cppcoro 库中找到什么。您还学会了如何编写自己的协程。

本章最重要的教训是:在性能方面,先进行测量,后进行优化。这将帮助您最大限度地发挥您的工作影响。

这就是性能 - 我们想在书中讨论的最后一个质量属性。在下一章中,我们将开始进入服务和云的世界。我们将首先讨论面向服务的架构。

问题

  1. 我们从本章微基准测试的性能结果中可以学到什么?

  2. 遍历多维数组对性能重要吗?为什么/为什么不?

  3. 在我们的协程示例中,为什么不能在do_routine_work函数内创建线程池?

  4. 我们如何重新设计我们的协程示例,使其使用生成器而不仅仅是任务?

进一步阅读

第四部分:云原生设计原则

本节重点介绍了起源于分布式系统和云环境的现代架构风格。它展示了诸如面向服务的架构、包括容器在内的微服务,以及各种消息系统等概念。

本节包含以下章节:

  • [第十二章],面向服务的架构

  • [第十三章],设计微服务

  • [第十四章],容器

  • [第十五章],云原生设计

第十二章:面向服务的架构

分布式系统的一个非常常见的架构是面向服务的架构SOA)。这不是一个新的发明,因为这种架构风格几乎和计算机网络一样古老。SOA 有许多方面,从企业服务总线ESB)到云原生微服务。

如果您的应用程序包括 Web、移动或物联网IoT)接口,本章将帮助您了解如何以模块化和可维护性为重点构建它们。由于大多数当前系统以客户端-服务器(或其他网络拓扑)方式工作,学习 SOA 原则将帮助您设计和改进这样的系统。

本章将涵盖以下主题:

  • 理解 SOA

  • 采用消息传递原则

  • 使用 Web 服务

  • 利用托管服务和云提供商

技术要求

本章中提出的大多数示例不需要任何特定的软件。对于 AWS API 示例,您将需要AWS SDK for C++,可以在aws.amazon.com/sdk-for-cpp/找到。

本章中的代码已放在 GitHub 上,网址为github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter12

理解面向服务的架构

面向服务的架构是一个特征松散耦合的组件提供服务给彼此的软件设计的例子。这些组件使用共享的通信协议,通常是通过网络。在这种设计中,服务意味着可以在原始组件之外访问的功能单元。一个组件的例子可能是一个提供地理坐标响应区域地图的映射服务。

根据定义,服务具有四个属性:

  • 它是业务活动的一种表现,具有明确定义的结果。

  • 它是自包含的。

  • 它对用户是不透明的。

  • 它可能由其他服务组成。

实施方法

面向服务的架构并不规定如何处理服务定位。这是一个可以应用于许多不同实现的术语。关于一些方法是否应该被视为面向服务的架构存在讨论。我们不想参与这些讨论,只是强调一些经常被提及为 SOA 方法的方法。

让我们比较一些。

企业服务总线

当有人提到面向服务的架构时,ESB 往往是第一个联想到的。这是实现 SOA 的最古老方法之一。

ESB 从计算机硬件架构中得到类比。硬件架构使用计算机总线,如 PCI,以实现模块化。这样,第三方提供商可以独立于主板制造商实现模块(如图形卡、声卡或 I/O 接口),只要每个人都遵守总线所需的标准。

与 PCI 类似,ESB 架构旨在构建一种标准的通用方式,以允许松散耦合服务的交互。这些服务预计将独立开发和部署。还应该可以组合异构服务。

与 SOA 本身一样,ESB 没有由任何全局标准定义。要实现 ESB,需要在系统中建立一个额外的组件。这个组件就是总线本身。ESB 上的通信是事件驱动的,通常是通过消息导向中间件和消息队列实现的,我们将在后面的章节中讨论。

企业服务总线组件扮演以下角色:

  • 控制服务的部署和版本控制

  • 维护服务冗余

  • 在服务之间路由消息

  • 监控和控制消息交换

  • 解决组件之间的争执

  • 提供常见服务,如事件处理、加密或消息队列

  • 强制服务质量(QOS

既有专有商业产品,也有实现企业服务总线功能的开源产品。一些最受欢迎的开源产品如下:

  • Apache Camel

  • Apache ServiceMix

  • Apache Synapse

  • JBoss ESB

  • OpenESB

  • Red Hat Fuse(基于 Apache Camel)

  • Spring 集成

最受欢迎的商业产品如下:

  • IBM 集成总线(取代 IBM WebSphere ESB)

  • Microsoft Azure 服务总线

  • Microsoft BizTalk Server

  • Oracle 企业服务总线

  • SAP 过程集成

与本书中介绍的所有模式和产品一样,您在决定采用特定架构之前,必须考虑其优势和劣势。引入企业服务总线的一些好处如下:

  • 更好的服务可扩展性

  • 分布式工作负载

  • 可以专注于配置而不是在服务中实现自定义集成

  • 设计松散耦合服务的更简单方法

  • 服务是可替换的

  • 内置冗余能力

另一方面,缺点主要围绕以下方面:

  • 单点故障-ESB 组件的故障意味着整个系统的故障。

  • 配置更复杂,影响维护。

  • 消息队列、消息转换以及 ESB 提供的其他服务可能会降低性能甚至成为瓶颈。

Web 服务

Web 服务是面向服务的架构的另一种流行实现。根据其定义,Web 服务是一台机器向另一台机器(或操作者)提供的服务,通信是通过万维网协议进行的。尽管万维网的管理机构 W3C 允许使用 FTP 或 SMTP 等其他协议,但 Web 服务通常使用 HTTP 作为传输协议。

虽然可以使用专有解决方案实现 Web 服务,但大多数实现都基于开放协议和标准。尽管许多方法通常被称为 Web 服务,但它们在本质上是不同的。稍后在本章中,我们将详细描述各种方法。现在,让我们专注于它们的共同特点。

Web 服务的优缺点

Web 服务的好处如下:

  • 使用流行的 Web 标准

  • 大量的工具

  • 可扩展性

以下是缺点:

  • 大量开销。

  • 一些实现过于复杂(例如 SOAP/WSDL/UDDI 规范)。

消息和流

在介绍企业服务总线架构时,我们已经提到了消息队列和消息代理。除了作为 ESB 实现的一部分外,消息系统也可以作为独立的架构元素。

消息队列

消息队列是用于进程间通信IPC)的组件。顾名思义,它们使用队列数据结构在不同进程之间传递消息。通常,消息队列是面向消息的中间件MOM)设计的一部分。

在最低级别上,消息队列在 UNIX 规范中都有,包括 System V 和 POSIX。虽然它们在单台机器上实现 IPC 时很有趣,但我们想要专注于适用于分布式计算的消息队列。

目前在开源软件中有三种与消息队列相关的标准:

  1. 高级消息队列协议AMQP),一种在 7 层 OSI 模型的应用层上运行的二进制协议。流行的实现包括以下内容:
  • Apache Qpid

  • Apache ActiveMQ

  • RabbitMQ

  • Azure 事件中心

  • Azure 服务总线

  1. 流文本定向消息协议STOMP),一种类似于 HTTP 的基于文本的协议(使用诸如CONNECTSENDSUBSCRIBE等动词)。流行的实现包括以下内容:
  • Apache ActiveMQ

  • RabbitMQ

  • syslog-ng

  1. MQTT,一个面向嵌入式设备的轻量级协议。流行的实现包括以下家庭自动化解决方案:
  • OpenHAB

  • Adafruit IO

  • IoT Guru

  • Node-RED

  • Home Assistant

  • Pimatic

  • AWS IoT

  • Azure IoT Hub

消息代理

消息代理处理消息系统中消息的翻译、验证和路由。与消息队列一样,它们是 MOM 的一部分。

使用消息代理,您可以最大程度地减少应用程序对系统其他部分的感知。这导致设计松散耦合的系统,因为消息代理承担了与消息上的常见操作相关的所有负担。这被称为发布-订阅PubSub)设计模式。

代理通常管理接收者的消息队列,但也能执行其他功能,例如以下功能:

  • 将消息从一种表示形式转换为另一种

  • 验证消息发送者、接收者或内容

  • 将消息路由到一个或多个目的地

  • 聚合、分解和重组传输中的消息

  • 从外部服务检索数据

  • 通过与外部服务的交互增强和丰富消息

  • 处理和响应错误和其他事件

  • 提供不同的路由模式,如发布-订阅

消息代理的流行实现包括以下内容:

  • Apache ActiveMQ

  • Apache Kafka

  • Apache Qpid

  • Eclipse Mosquitto MQTT Broker

  • NATS

  • RabbitMQ

  • Redis

  • AWS ActiveMQ

  • AWS Kinesis

  • Azure Service Bus

云计算

云计算是一个广义的术语,有很多不同的含义。最初,这个术语指的是架构不应该过于担心的抽象层。例如,这可能意味着由专门的运维团队管理的服务器和网络基础设施。后来,服务提供商开始将云计算这个术语应用到他们自己的产品上,这些产品通过抽象底层基础设施及其所有复杂性。不必单独配置每个基础设施部分,只需使用简单的应用程序编程接口API)即可设置所有必要的资源。

如今,云计算已经发展到包括许多新颖的应用架构方法。它可能包括以下内容:

  • 托管服务,如数据库、缓存层和消息队列

  • 可扩展的工作负载编排

  • 容器部署和编排平台

  • 无服务器计算平台

考虑云采用时最重要的一点是,将应用程序托管在云中需要专门为云设计的架构。通常还意味着专门为特定云提供商设计的架构。

这意味着选择云提供商不仅仅是在某一时刻做出一个选择是否比另一个更好的决定。这意味着未来切换提供商的成本可能太大,不值得搬迁。在提供商之间迁移需要架构变更,对于一个正常运行的应用程序来说,这可能会超过迁移带来的预期节省。

云架构设计还有另一个后果。对于传统应用程序来说,这意味着为了利用云的好处,应用程序首先必须重新设计和重写。迁移到云并不仅仅是将二进制和配置文件从本地托管复制到由云提供商管理的虚拟机。这种方法只会意味着浪费金钱,因为只有当您的应用程序是可扩展的并且具备云意识时,云计算才是划算的。

云计算并不一定意味着使用外部服务并从第三方提供商租用机器。还有一些解决方案,比如运行在本地的 OpenStack,它允许您利用已经拥有的服务器来享受云计算的好处。

我们将在本章后面讨论托管服务。容器、云原生设计和无服务器架构将在本书的后面有专门的章节。

微服务

关于微服务是否属于 SOA 存在一些争议。大多数情况下,SOA 这个术语几乎等同于 ESB 设计。在许多方面,微服务与 ESB 相反。这导致了微服务是 SOA 的一个独特模式的观点,是软件架构演进的下一步。

我们认为,实际上,这些是一种现代的 SOA 方法,旨在消除 ESB 中出现的一些问题。毕竟,微服务非常符合面向服务的架构的定义。

微服务是下一章的主题。

面向服务的架构的好处

将系统的功能分割到多个服务中有几个好处。首先,每个服务可以单独维护和部署。这有助于团队专注于特定任务,而无需了解系统内的每种可能的交互。它还实现了敏捷开发,因为测试只需覆盖特定服务,而不是整个系统。

第二个好处是服务的模块化有助于创建分布式系统。通过网络(通常基于互联网协议)作为通信手段,服务可以分布在不同的机器之间,以提供可伸缩性、冗余性和更好的资源利用率。

当每个服务有许多生产者和许多消费者时,实施新功能和维护现有软件是一项困难的任务。这就是为什么 SOA 鼓励使用文档化和版本化的 API。

另一种使服务生产者和消费者更容易互动的方法是使用已建立的协议,描述如何在不同服务之间传递数据和元数据。这些协议可能包括 SOAP、REST 或 gRPC。

使用 API 和标准协议可以轻松创建提供超出现有服务的附加值的新服务。考虑到我们有一个返回地理位置的服务 A,另一个服务 B,它提供给定位置的当前温度,我们可以调用 A 并在请求 B 中使用其响应。这样,我们就可以获得当前位置的当前温度,而无需自己实现整个逻辑。

我们对这两个服务的所有复杂性和实现细节一无所知,并将它们视为黑匣子。这两个服务的维护者也可以引入新功能并发布新版本的服务,而无需通知我们。

测试和实验面向服务的架构也比单片应用更容易。单个地方的小改变不需要重新编译整个代码库。通常可以使用客户端工具以临时方式调用服务。

让我们回到我们的天气和地理位置服务的例子。如果这两个服务都暴露了 REST API,我们可以仅使用 cURL 客户端手动发送适当的请求来构建原型。当我们确认响应令人满意时,我们可以开始编写代码,自动化整个操作,并可能将结果公开为另一个服务。

要获得 SOA 的好处,我们需要记住所有服务都必须松散耦合。如果服务依赖于彼此的实现,这意味着它们不再是松散耦合,而是紧密耦合。理想情况下,任何给定的服务都应该可以被不同的类似服务替换,而不会影响整个系统的运行。

在我们的天气和位置示例中,这意味着在不同语言中重新实现位置服务(比如,从 Go 切换到 C++)不应影响该服务的下游消费者,只要他们使用已建立的 API。

通过发布新的 API 版本仍然可能引入 API 的破坏性变化。连接到 1.0 版本的客户端将观察到传统行为,而连接到 2.0 版本的客户端将受益于错误修复,更好的性能和其他改进,这些改进是以兼容性为代价的。

对于依赖 HTTP 的服务,API 版本通常发生在 URI 级别。因此,当调用service.local/v1/customer时,可以访问 1.0、1.1 或 1.2 版本的 API,而 2.0 版本的 API 位于service.local/v2/customer。然后,API 网关、HTTP 代理或负载均衡器能够将请求路由到适当的服务。

SOA 的挑战

引入抽象层总是有成本的。同样的规则适用于面向服务的体系结构。当看到企业服务总线、Web 服务或消息队列和代理时,可以很容易地看到抽象成本。可能不太明显的是微服务也有成本。它们的成本与它们使用的远程过程调用(RPC)框架以及与服务冗余和功能重复相关的资源消耗有关。

与 SOA 相关的另一个批评目标是缺乏统一的测试框架。开发应用程序服务的个人团队可能使用其他团队不熟悉的工具。与测试相关的其他问题是组件的异构性和可互换性意味着有大量的组合需要测试。一些组合可能会引入通常不会观察到的边缘情况。

由于关于特定服务的知识大多集中在一个团队中,因此要理解整个应用程序的工作方式要困难得多。

当应用程序的 SOA 平台在应用程序的生命周期内开发时,可能会引入所有服务更新其版本以针对最新平台开发的需求。这意味着开发人员不再是引入新功能,而是专注于确保他们的应用程序在对平台进行更改后能够正确运行。在极端情况下,对于那些没有看到新版本并且不断修补以符合平台要求的服务,维护成本可能会急剧上升。

面向服务的体系结构遵循康威定律,详见第二章,架构风格

采用消息传递原则

正如我们在本章前面提到的,消息传递有许多不同的用例,从物联网和传感器网络到在云中运行的基于微服务的分布式应用程序。

消息传递的好处之一是它是一种连接使用不同技术实现的服务的中立方式。在开发 SOA 时,每个服务通常由专门的团队开发和维护。团队可以选择他们感觉舒适的工具。这适用于编程语言、第三方库和构建系统。

维护统一的工具集可能会适得其反,因为不同的服务可能有不同的需求。例如,一个自助应用可能需要一个像 Qt 这样的图形用户界面(GUI)库。作为同一应用程序的一部分的硬件控制器将有其他要求,可能链接到硬件制造商的第三方组件。这些依赖关系可能会对不能同时满足两个组件的一些限制(例如,GUI 应用程序可能需要一个较新的编译器,而硬件对应可能被固定在一个较旧的编译器上)。使用消息系统来解耦这些组件让它们有单独的生命周期。

消息系统的一些用例包括以下内容:

  • 金融业务

  • 车队监控

  • 物流捕捉

  • 处理传感器

  • 数据订单履行

  • 任务排队

以下部分重点介绍了为低开销和使用经纪人的消息系统设计的部分。

低开销的消息系统

低开销的消息系统通常用于需要小占地面积或低延迟的环境。这些通常是传感器网络、嵌入式解决方案和物联网设备。它们在基于云的和分布式服务中较少见,但仍然可以在这些解决方案中使用。

MQTT

MQTT代表消息队列遥测传输。它是 OASIS 和 ISO 下的开放标准。MQTT 通常使用 PubSub 模型,通常在 TCP/IP 上运行,但也可以与其他传输协议一起工作。

正如其名称所示,MQTT 的设计目标是低代码占用和在低带宽位置运行的可能性。还有一个名为MQTT-SN的单独规范,代表传感器网络的 MQTT。它专注于没有 TCP/IP 堆栈的电池供电的嵌入式设备。

MQTT 使用消息经纪人接收来自客户端的所有消息,并将这些消息路由到它们的目的地。QoS 提供了三个级别:

  • 至多一次交付(无保证)

  • 至少一次交付(已确认交付)

  • 确保交付一次(已确认交付)

MQTT 特别受到各种物联网应用的欢迎并不奇怪。它受 OpenHAB、Node-RED、Pimatic、Microsoft Azure IoT Hub 和 Amazon IoT 的支持。它在即时通讯中也很受欢迎,在 ejabberd 和 Facebook Messanger 中使用。其他用例包括共享汽车平台、物流和运输。

支持此标准的两个最流行的 C++库是 Eclipse Paho 和基于 C++14 和 Boost.Asio 的 mqtt_cpp。对于 Qt 应用程序,还有 qmqtt。

ZeroMQ

ZeroMQ 是一种无经纪人的消息队列。它支持常见的消息模式,如 PubSub、客户端/服务器和其他几种。它独立于特定的传输,并可以与 TCP、WebSockets 或 IPC 一起使用。

ZeroMQ 的主要思想是,它需要零经纪人和零管理。它也被宣传为提供零延迟,这意味着来自经纪人存在的延迟为零。

低级库是用 C 编写的,并且有各种流行编程语言的实现,包括 C++。C++的最受欢迎的实现是 cppzmq,这是一个针对 C++11 的仅头文件库。

经纪人消息系统

两个最受欢迎的不专注于低开销的消息系统是基于 AMQP 的 RabbitMQ 和 Apache Kafka。它们都是成熟的解决方案,在许多不同的设计中都非常受欢迎。许多文章都集中在 RabbitMQ 或 Apache Kafka 在特定领域的优越性上。

这是一个略微不正确的观点,因为这两种消息系统基于不同的范例。Apache Kafka 专注于流式传输大量数据并将流式存储在持久内存中,以允许将来重播。另一方面,RabbitMQ 通常用作不同微服务之间的消息经纪人或用于处理后台作业的任务队列。因此,在 RabbitMQ 中的路由比 Apache Kafka 中的路由更先进。Kafka 的主要用例是数据分析和实时处理。

虽然 RabbitMQ 使用 AMQP 协议(并且还支持其他协议,如 MQTT 和 STOMP),Kafka 使用基于 TCP/IP 的自己的协议。这意味着 RabbitMQ 与基于这些支持的协议的其他现有解决方案是可互操作的。如果您编写一个使用 AMQP 与 RabbitMQ 交互的应用程序,应该可以将其稍后迁移到使用 Apache Qpid、Apache ActiveMQ 或来自 AWS 或 Microsoft Azure 的托管解决方案。

扩展问题也可能会驱使选择一个消息代理而不是另一个。Apache Kafka 的架构允许轻松进行水平扩展,这意味着向现有工作机群添加更多机器。另一方面,RabbitMQ 的设计考虑了垂直扩展,这意味着向现有机器添加更多资源,而不是添加更多相似大小的机器。

使用 Web 服务

正如本章前面提到的,Web 服务的共同特点是它们基于标准的 Web 技术。大多数情况下,这将意味着超文本传输协议HTTP),这是我们将重点关注的技术。尽管可能实现基于不同协议的 Web 服务,但这类服务非常罕见,因此超出了我们的范围。

用于调试 Web 服务的工具

使用 HTTP 作为传输的一个主要好处是工具的广泛可用性。在大多数情况下,测试和调试 Web 服务可以使用的工具不仅仅是 Web 浏览器。除此之外,还有许多其他程序可能有助于自动化。这些包括以下内容:

  • 标准的 Unix 文件下载器wget

  • 现代 HTTP 客户端curl

  • 流行的开源库,如 libcurl、curlpp、C++ REST SDK、cpr(C++ HTTP 请求库)和 NFHTTP

  • 测试框架,如 Selenium 或 Robot Framework

  • 浏览器扩展,如 Boomerang

  • 独立解决方案,如 Postman 和 Postwoman

  • 专用测试软件,包括 SoapUI 和 Katalon Studio

基于 HTTP 的 Web 服务通过返回 HTTP 响应来处理使用适当的 HTTP 动词(如 GET、POST 和 PUT)的 HTTP 请求。请求和响应的语义以及它们应传达的数据在不同的实现中有所不同。

大多数实现属于两类:基于 XML 的 Web 服务和基于 JSON 的 Web 服务。基于 JSON 的 Web 服务目前正在取代基于 XML 的 Web 服务,但仍然常见到使用 XML 格式的服务。

对于处理使用 JSON 或 XML 编码的数据,可能需要额外的工具,如 xmllint、xmlstarlet、jq 和 libxml2。

基于 XML 的 Web 服务

最初获得关注的第一个 Web 服务主要基于 XML。XML可扩展标记语言当时是分布式计算和 Web 环境中的交换格式选择。有几种不同的方法来设计带有 XML 有效负载的服务。

您可能希望与已经存在的基于 XML 的 Web 服务进行交互,这些服务可能是在您的组织内部开发的,也可能是外部开发的。但是,我们建议您使用更轻量级的方法来实现新的 Web 服务,例如基于 JSON 的 Web 服务、RESTful Web 服务或 gRPC。

XML-RPC

最早出现的标准之一被称为 XML-RPC。该项目的理念是提供一种与当时盛行的公共对象模型COM)和 CORBA 竞争的 RPC 技术。其目标是使用 HTTP 作为传输协议,并使格式既可读又可写,并且可解析为机器。为了实现这一点,选择了 XML 作为数据编码格式。

在使用 XML-RPC 时,想要执行远程过程调用的客户端向服务器发送 HTTP 请求。请求可能有多个参数。服务器以单个响应回答。XML-RPC 协议为参数和结果定义了几种数据类型。

尽管 SOAP 具有类似的数据类型,但它使用 XML 模式定义,这使得消息比 XML-RPC 中的消息不可读得多。

与 SOAP 的关系

由于 XML-RPC 不再得到积极维护,因此没有现代的 C++实现标准。如果您想从现代代码与 XML-RPC Web 服务进行交互,最好的方法可能是使用支持 XML-RPC 和其他 XML Web 服务标准的 gSOAP 工具包。

XML-RPC 的主要批评是它在使消息显着变大的同时,没有比发送纯 XML 请求和响应提供更多价值。

随着标准的发展,它成为了 SOAP。作为 SOAP,它构成了 W3C Web 服务协议栈的基础。

SOAP

SOAP的原始缩写是Simple Object Access Protocol。该缩写在标准的 1.2 版本中被取消。它是 XML-RPC 标准的演变。

SOAP 由三部分组成:

  • SOAP 信封,定义消息的结构和处理规则

  • SOAP 头规定应用程序特定数据类型的规则(可选)

  • SOAP 主体,携带远程过程调用和响应

这是一个使用 HTTP 作为传输的 SOAP 消息示例:

POST /FindMerchants HTTP/1.1
Host: www.domifair.org
Content-Type: application/soap+xml; charset=utf-8
Content-Length: 345
SOAPAction: "http://www.w3.org/2003/05/soap-envelope"

<?xml version="1.0"?>
<soap:Envelope >
 <soap:Header>
 </soap:Header>
 <soap:Body >
    <m:FindMerchants>
      <m:Lat>54.350989</m:Lat>
      <m:Long>18.6548168</m:Long>
      <m:Distance>200</m:Distance>
    </m:FindMerchants>
  </soap:Body>
</soap:Envelope>

该示例使用标准的 HTTP 头和 POST 方法来调用远程过程。SOAP 特有的一个头是SOAPAction。它指向标识操作意图的 URI。由客户端决定如何解释此 URI。

soap:Header是可选的,所以我们将其留空。与soap:Body一起,它包含在soap:Envelope中。主要的过程调用发生在soap:Body中。我们引入了一个特定于多米尼加展会应用程序的 XML 命名空间。该命名空间指向我们域的根。我们调用的过程是FindMerchants,并提供三个参数:纬度、经度和距离。

由于 SOAP 被设计为可扩展、传输中立和独立于编程模型,它也导致了其他相关标准的产生。这意味着通常需要在使用 SOAP 之前学习所有相关的标准和协议。

如果您的应用程序广泛使用 XML,并且您的开发团队熟悉所有术语和规范,那么这不是问题。但是,如果您只是想为第三方公开 API,一个更简单的方法是构建 REST API,因为它对生产者和消费者来说更容易学习。

WSDL

Web 服务描述语言WSDL)提供了服务如何被调用以及消息应该如何形成的机器可读描述。与其他 W3C Web 服务标准一样,它以 XML 编码。

它通常与 SOAP 一起使用,以定义 Web 服务提供的接口及其使用方式。

一旦在 WSDL 中定义了 API,您可以(而且应该!)使用自动化工具来帮助您从中创建代码。对于 C++,具有此类工具的一个框架是 gSOAP。它配备了一个名为wsdl2h的工具,它将根据定义生成一个头文件。然后您可以使用另一个工具soapcpp2,将接口定义生成到您的实现中。

不幸的是,由于消息的冗长,SOAP 服务的大小和带宽要求通常非常巨大。如果这不是问题,那么 SOAP 可以有其用途。它允许同步和异步调用,以及有状态和无状态操作。如果您需要严格、正式的通信手段,SOAP 可以提供。只需确保使用协议的 1.2 版本,因为它引入了许多改进。其中之一是服务的增强安全性。另一个是服务本身的改进定义,有助于互操作性,或者正式定义传输手段(允许使用消息队列)等,仅举几例。

UDDI

在记录 Web 服务接口之后的下一步是服务发现,它允许应用程序找到并连接到其他方实现的服务。

通用描述、发现和集成UDDI)是用于 WSDL 文件的注册表,可以手动或自动搜索。与本节讨论的其他技术一样,UDDI 使用 XML 格式。

UDDI 注册表可以通过 SOAP 消息查询自动服务发现。尽管 UDDI 提供了 WSDL 的逻辑扩展,但其在开放中的采用令人失望。仍然可能会发现公司内部使用 UDDI 系统。

SOAP 库

SOAP 最流行的两个库是Apache AxisgSOAP

Apache Axis 适用于实现 SOAP(包括 WSDL)和 REST Web 服务。值得注意的是,该库在过去十年中没有发布新版本。

gSOAP 是一个工具包,允许创建和与基于 XML 的 Web 服务进行交互,重点是 SOAP。它处理数据绑定、SOAP 和 WSDL 支持、JSON 和 RSS 解析、UDDI API 等其他相关的 Web 服务标准。尽管它不使用现代 C++特性,但它仍在积极维护。

基于 JSON 的 Web 服务

JSON代表JavaScript 对象表示法。与名称所暗示的相反,它不仅限于 JavaScript。它是与语言无关的。大多数编程语言都有 JSON 的解析器和序列化器。JSON 比 XML 更紧凑。

它的语法源自 JavaScript,因为它是基于 JavaScript 子集的。

JSON 支持的数据类型如下:

  • 数字:确切的格式可能因实现而异;在 JavaScript 中默认为双精度浮点数。

  • 字符串:Unicode 编码。

  • 布尔值:使用truefalse值。

  • 数组:可能为空。

  • 对象:具有键值对的映射。

  • null:表示空值。

在第九章中介绍的Packer配置,即持续集成/持续部署,是 JSON 文档的一个示例:

{
  "variables": {
    "aws_access_key": "",
    "aws_secret_key": ""
  },
  "builders": [{
    "type": "amazon-ebs",
    "access_key": "{{user `aws_access_key`}}",
    "secret_key": "{{user `aws_secret_key`}}",
    "region": "eu-central-1",
    "source_ami": "ami-5900cc36",
    "instance_type": "t2.micro",
    "ssh_username": "admin",
    "ami_name": "Project's Base Image {{timestamp}}"
  }],
  "provisioners": [{
    "type": "ansible",
    "playbook_file": "./provision.yml",
    "user": "admin",
    "host_alias": "baseimage"
  }],
  "post-processors": [{
    "type": "manifest",
    "output": "manifest.json",
    "strip_path": true
  }]
}

使用 JSON 作为格式的标准之一是 JSON-RPC 协议。

JSON-RPC

JSON-RPC 是一种基于 JSON 编码的远程过程调用协议,类似于 XML-RPC 和 SOAP。与其 XML 前身不同,它需要很少的开销。它也非常简单,同时保持了 XML-RPC 的人类可读性。

这是我们之前的示例在 SOAP 调用中使用 JSON-RPC 2.0 的样子:

{
  "jsonrpc": "2.0",
  "method": "FindMerchants",
  "params": {
    "lat": "54.350989",
    "long": "18.6548168",
    "distance": 200
  },
  "id": 1
}

这个 JSON 文档仍然需要适当的 HTTP 标头,但即使有标头,它仍然比 XML 对应物要小得多。唯一存在的元数据是带有 JSON-RPC 版本和请求 ID 的文件。methodparams字段几乎是不言自明的。SOAP 并非总是如此。

尽管该协议轻量级、易于实现和使用,但与 SOAP 和 REST Web 服务相比,它并没有得到广泛的采用。它发布得比 SOAP 晚得多,大约与 REST 服务开始流行的时间相同。虽然 REST 迅速取得成功(可能是因为其灵活性),但 JSON-RPC 未能获得类似的推动力。

C++的两个有用的实现是 libjson-rpc-cpp 和 json-rpc-cxx。json-rpc-cxx 是先前库的现代重新实现。

表述性状态转移(REST)

Web 服务的另一种替代方法是表述性状态转移(REST)。符合这种架构风格的服务通常被称为 RESTful 服务。REST 与 SOAP 或 JSON-RPC 的主要区别在于 REST 几乎完全基于 HTTP 和 URI 语义。

REST 是一种在实现 Web 服务时定义一组约束的架构风格。符合这种风格的服务称为 RESTful。这些约束如下:

  • 必须使用客户端-服务器模型。

  • 无状态性(客户端和服务器都不需要存储与它们的通信相关的状态)。

  • 可缓存性(响应应定义为可缓存或不可缓存,以从标准 Web 缓存中获益,以提高可伸缩性和性能)。

  • 分层系统(代理和负载均衡器绝对不能影响客户端和服务器之间的通信)。

REST 使用 HTTP 作为传输协议,URI 表示资源,HTTP 动词操作资源或调用操作。关于每个 HTTP 方法应如何行为没有标准,但最常同意的语义是以下内容:

  • POST - 创建新资源。

  • GET - 检索现有资源。

  • PATCH - 更新现有资源。

  • DELETE - 删除现有资源。

  • PUT - 替换现有资源。

由于依赖于 Web 标准,RESTful Web 服务可以重用现有组件,如代理、负载均衡器和缓存。由于开销低,这样的服务也非常高效和有效。

描述语言

就像基于 XML 的 Web 服务一样,RESTful 服务可以以机器和人可读的方式描述。有几种竞争标准可用,其中 OpenAPI 是最受欢迎的。

OpenAPI

OpenAPI 是由 Linux Foundation 的 OpenAPI 计划监督的规范。它以前被称为 Swagger 规范,因为它曾经是 Swagger 框架的一部分。

该规范与语言无关。它使用 JSON 或 YAML 输入来生成方法、参数和模型的文档。这样,使用 OpenAPI 有助于保持文档和源代码的最新状态。

有许多与 OpenAPI 兼容的工具可供选择,例如代码生成器、编辑器、用户界面和模拟服务器。OpenAPI 生成器可以使用 cpp-restsdk 或 Qt 5 生成 C++代码进行客户端实现。它还可以使用 Pistache、Restbed 或 Qt 5 QHTTPEngine 生成服务器代码。还有一个方便的在线 OpenAPI 编辑器可用:editor.swagger.io/

使用 OpenAPI 记录的 API 将如下所示:

{
  "openapi": "3.0.0",
  "info": {
    "title": "Items API overview",
    "version": "2.0.0"
  },
  "paths": {
    "/item/{itemId}": {
      "get": {
        "operationId": "getItem",
        "summary": "get item details",
        "parameters": [
          "name": "itemId",
          "description": "Item ID",
          "required": true,
          "schema": {
            "type": "string"
          }
        ],
        "responses": {
          "200": {
            "description": "200 response",
            "content": {
              "application/json": {
                "example": {
                  "itemId": 8,
                  "name", "Kürtőskalács",
                  "locationId": 5
                }
              }
            }
          }
        }
      }
    }
  }
}

前两个字段(openapiinfo)是描述文档的元数据。paths字段包含与 REST 接口的资源和方法对应的所有可能路径。在上面的示例中,我们只记录了一个路径(/item)和一个方法(GET)。此方法将itemId作为必需参数。我们提供了一个可能的响应代码,即200。200 响应包含一个 JSON 文档作为其本身的主体。与example键相关联的值是成功响应的示例有效负载。

RAML

一种竞争规范,RAML,代表 RESTful API 建模语言。它使用 YAML 进行描述,并实现了发现、代码重用和模式共享。

建立 RAML 的理念是,虽然 OpenAPI 是一个很好的工具来记录现有的 API,但在当时,它并不是设计新 API 的最佳方式。目前,该规范正在考虑成为 OpenAPI 计划的一部分。

RAML 文档可以转换为 OpenAPI 以利用可用的工具。

以下是使用 RAML 记录的 API 的示例:

#%RAML 1.0

title: Items API overview
version: 2.0.0

annotationTypes:
  oas-summary:
    type: string
    allowedTargets: Method

/item:
  get:
    displayName: getItem
    queryParameters:
      itemId:
        type: string
    responses:
      '200':
        body:
          application/json:
            example: |
              {
                "itemId": 8,
                "name", "Kürtőskalács",
                "locationId": 5
              }
        description: 200 response
    (oas-summary): get item details

此示例描述了先前使用 OpenAPI 记录的相同接口。当以 YAML 序列化时,OpenAPI 3.0 和 RAML 2.0 看起来非常相似。主要区别在于,OpenAPI 3.0 要求使用 JSON 模式来记录结构。使用 RAML 2.0,可以重用现有的 XML 模式定义(XSD),这样更容易从基于 XML 的 Web 服务迁移或包含外部资源。

API Blueprint

API Blueprint 提出了与前两个规范不同的方法。它不依赖于 JSON 或 YAML,而是使用 Markdown 来记录数据结构和端点。

其方法类似于测试驱动的开发方法论,因为它鼓励在实施功能之前设计合同。这样,更容易测试实现是否真正履行了合同。

就像 RAML 一样,可以将 API Blueprint 规范转换为 OpenAPI,反之亦然。还有一个命令行界面和一个用于解析 API Blueprint 的 C++库,名为 Drafter,您可以在您的代码中使用。

使用 API Blueprint 记录的简单 API 示例如下:

FORMAT: 1A

# Items API overview

# /item/{itemId}

## GET

+ Response 200 (application/json)

        {
            "itemId": 8,
            "name": "Kürtőskalács",
            "locationId": 5
        }

在上文中,我们看到针对/item端点的GET方法应该产生一个200的响应代码。在下面是我们的服务通常会返回的 JSON 消息。

API Blueprint 允许更自然的文档编写。主要缺点是它是迄今为止描述的格式中最不受欢迎的。这意味着文档和工具都远远不及 OpenAPI 的质量。

RSDL

类似于 WSDL,RSDL(或RESTful Service Description Language)是用于 Web 服务的 XML 描述。它与语言无关,旨在既适合人类阅读又适合机器阅读。

它比之前介绍的替代方案要不受欢迎得多。而且,它也要难得多,特别是与 API Blueprint 或 RAML 相比。

超媒体作为应用状态的引擎

尽管提供诸如基于gRPC的二进制接口可以提供出色的性能,但在许多情况下,您仍然希望拥有 RESTful 接口的简单性。如果您想要一个直观的基于 REST 的 API,超媒体作为应用状态的引擎HATEOAS)可能是一个有用的原则。

就像您打开网页并根据显示的超媒体导航一样,您可以使用 HATEOAS 编写您的服务来实现相同的功能。这促进了服务器和客户端代码的解耦,并允许客户端快速了解哪些请求是有效的,这通常不适用于二进制 API。发现是动态的,并且基于提供的超媒体。

如果您使用典型的 RESTful 服务,在执行操作时,您会得到包含对象状态等数据的 JSON。除此之外,除此之外,您还会得到一个显示您可以在该对象上运行的有效操作的链接(URL)列表。这些链接(超媒体)是应用的引擎。换句话说,可用的操作由资源的状态确定。虽然在这种情况下,超媒体这个术语可能听起来很奇怪,但它基本上意味着链接到资源,包括文本、图像和视频。

例如,如果我们有一个 REST 方法允许我们使用 PUT 方法添加一个项目,我们可以添加一个返回参数,该参数链接到以这种方式创建的资源。如果我们使用 JSON 进行序列化,这可能采用以下形式:

{
    "itemId": 8,
    "name": "Kürtőskalács",
    "locationId": 5,
    "links": [
        {
            "href": "item/8",
            "rel": "items",
            "type" : "GET"
        }
    ]
}

没有普遍接受的 HATEOAS 超媒体序列化方法。一方面,这样做可以更容易地实现,而不受服务器实现的影响。另一方面,客户端需要知道如何解析响应以找到相关的遍历数据。

HATEOAS 的好处之一是,它使得可以在服务器端实现 API 更改,而不一定会破坏客户端代码。当一个端点被重命名时,新的端点会在随后的响应中被引用,因此客户端会被告知在哪里发送进一步的请求。

相同的机制可能提供诸如分页或者使得发现给定对象可用方法变得容易的功能。回到我们的项目示例,这是我们在进行GET请求后可能收到的一个可能的响应:

{
    "itemId": 8,
    "name": "Kürtőskalács",
    "locationId": 5,
    "stock": 8,
    "links": [
        {
            "href": "item/8",
            "rel": "items",
            "type" : "GET"
        },
        {
            "href": "item/8",
            "rel": "items",
            "type" : "POST"
        },
        {
            "href": "item/8/increaseStock",
            "rel": "increaseStock",
            "type" : "POST"
        }, 
        {
            "href": "item/8/decreaseStock",
            "rel": "decreaseStock",
            "type" : "POST"
        }
    ]
}

在这里,我们得到了两个负责修改库存的方法的链接。如果库存不再可用,我们的响应将如下所示(请注意,其中一个方法不再被广告):

{
    "itemId": 8,
    "name": "Kürtőskalács",
    "locationId": 5,
    "stock": 0,
    "links": [
        {
            "href": "items/8",
            "rel": "items",
            "type" : "GET"
        },
        {
            "href": "items/8",
            "rel": "items",
            "type" : "POST"
        },
        {
            "href": "items/8/increaseStock",
            "rel": "increaseStock",
            "type" : "POST"
        }
    ]
}

与 HATEOAS 相关的一个重要问题是,这两个设计原则似乎相互矛盾。如果遍历超媒体总是以相同的格式呈现,那么它将更容易消费。这里的表达自由使得编写不了解服务器实现的客户端变得更加困难。

并非所有的 RESTful API 都能从引入这一原则中受益-通过引入 HATEOAS,您承诺以特定方式编写客户端,以便它们能够从这种 API 风格中受益。

C++中的 REST

Microsoft 的 C++ REST SDK 目前是在 C++应用程序中实现 RESTful API 的最佳方法之一。也被称为 cpp-restsdk,这是我们在本书中使用的库,用于说明各种示例。

GraphQL

REST Web 服务的一个最新替代品是 GraphQL。名称中的QL代表查询语言。GraphQL 客户端直接查询和操作数据,而不是依赖服务器来序列化和呈现必要的数据。除了责任的逆转,GraphQL 还具有使数据处理更容易的机制。类型、静态验证、内省和模式都是规范的一部分。

有许多语言的 GraphQL 服务器实现,包括 C++。其中一种流行的实现是来自 Microsoft 的 cppgraphqlgen。还有许多工具可帮助开发和调试。有趣的是,由于 Hasura 或 PostGraphile 等产品在 Postgres 数据库上添加了 GraphQL API,您可以使用 GraphQL 直接查询数据库。

利用托管服务和云提供商

面向服务的架构可以延伸到当前的云计算趋势。虽然企业服务总线通常具有内部开发的服务,但使用云计算可以使用一个或多个云提供商提供的服务。

在为云计算设计应用程序架构时,您应该在实施任何替代方案之前始终考虑提供商提供的托管服务。例如,在决定是否要使用自己选择的插件托管自己的 PostgreSQL 数据库之前,确保您了解与提供商提供的托管数据库托管相比的权衡和成本。

当前的云计算环境提供了许多旨在处理流行用例的服务,例如以下内容:

  • 存储

  • 关系数据库

  • 文档(NoSQL)数据库

  • 内存缓存

  • 电子邮件

  • 消息队列

  • 容器编排

  • 计算机视觉

  • 自然语言处理

  • 文本转语音和语音转文本

  • 监控、日志记录和跟踪

  • 大数据

  • 内容传送网络

  • 数据分析

  • 任务管理和调度

  • 身份管理

  • 密钥和秘钥管理

由于可用的第三方服务选择很多,很明显云计算如何适用于面向服务的架构。

云计算作为 SOA 的延伸

云计算是虚拟机托管的延伸。区别云计算提供商和传统 VPS 提供商的是两个东西:

  • 云计算通过 API 可用,这使其成为一个服务本身。

  • 除了虚拟机实例,云计算还提供额外的服务,如存储、托管数据库、可编程网络等。所有这些服务也都可以通过 API 获得。

有几种方式可以使用云提供商的 API 在您的应用程序中使用,我们将在下面介绍。

直接使用 API 调用

如果您的云提供商提供了您选择的语言可访问的 API,您可以直接从应用程序与云资源交互。

例如:您有一个允许用户上传自己图片的应用程序。该应用程序使用云 API 为每个新注册用户创建存储桶:

#include <aws/core/Aws.h>
#include <aws/s3/S3Client.h>
#include <aws/s3/model/CreateBucketRequest.h>

#include <spdlog/spdlog.h>

const Aws::S3::Model::BucketLocationConstraint region =
    Aws::S3::Model::BucketLocationConstraint::eu_central_1;

bool create_user_bucket(const std::string &username) {
  Aws::S3::Model::CreateBucketRequest request;

  Aws::String bucket_name("userbucket_" + username);
  request.SetBucket(bucket_name);

  Aws::S3::Model::CreateBucketConfiguration bucket_config;
  bucket_config.SetLocationConstraint(region);
  request.SetCreateBucketConfiguration(bucket_config);

  Aws::S3::S3Client s3_client;
  auto outcome = s3_client.CreateBucket(request);

  if (!outcome.IsSuccess()) {
    auto err = outcome.GetError();
    spdlog::error("ERROR: CreateBucket: {}: {}", 
                  err.GetExceptionName(),
                  err.GetMessage());
    return false;
  }

  return true;
}

在这个例子中,我们有一个 C++函数,它创建一个名为提供参数中的用户名的 AWS S3 存储桶。该存储桶配置为驻留在特定区域。如果操作失败,我们希望获取错误消息并使用spdlog记录。

通过 CLI 工具使用 API 调用

有些操作不必在应用程序运行时执行。它们通常在部署期间运行,因此可以在 shell 脚本中自动化。一个这样的用例是调用 CLI 工具来创建一个新的 VPC:

gcloud compute networks create database --description "A VPC to access the database from private instances"

我们使用 Google Cloud Platform 的 gcloud CLI 工具创建一个名为database的网络,该网络将用于处理来自私有实例到数据库的流量。

使用与云 API 交互的第三方工具

让我们看一个例子,运行 HashiCorp Packer 来构建一个预先配置了你的应用程序的虚拟机实例镜像:

{
   variables : {
     do_api_token : {{env `DIGITALOCEAN_ACCESS_TOKEN`}} ,
     region : fra1 ,
     packages : "customer"
     version : 1.0.3
  },
   builders : [
    {
       type : digitalocean ,
       api_token : {{user `do_api_token`}} ,
       image : ubuntu-20-04-x64 ,
       region : {{user `region`}} ,
       size : 512mb ,
       ssh_username : root
    }
  ],
  provisioners: [
    {
       type : file ,
       source : ./{{user `package`}}-{{user `version`}}.deb ,
       destination : /home/ubuntu/
    },
    {
       type : shell ,
       inline :[
         dpkg -i /home/ubuntu/{{user `package`}}-{{user `version`}}.deb
      ]
    }
  ]
}

在前面的代码中,我们提供了所需的凭据和区域,并使用构建器为我们准备了一个来自 Ubuntu 镜像的实例。我们感兴趣的实例需要有 512MB 的 RAM。然后,我们首先通过发送一个.deb包给它来提供实例,然后通过执行一个 shell 命令来安装这个包。

访问云 API

通过 API 访问云计算资源是区别于传统托管的最重要特性之一。使用 API 意味着你能够随意创建和删除实例,而无需操作员的干预。这样,就可以非常容易地实现基于负载的自动扩展、高级部署(金丝雀发布或蓝绿发布)以及应用程序的自动开发和测试环境。

云提供商通常将他们的 API 公开为 RESTful 服务。此外,他们通常还为几种编程语言提供客户端库。虽然三个最受欢迎的提供商都支持 C++作为客户端库,但来自较小供应商的支持可能有所不同。

如果你考虑将你的 C++应用程序部署到云上,并计划使用云 API,请确保你的提供商已发布了 C++ 软件开发工具包SDK)。也可以在没有官方 SDK 的情况下使用云 API,例如使用 CPP REST SDK 库,但请记住,这将需要更多的工作来实现。

要访问Cloud SDK,你还需要访问控制。通常,你的应用程序可以通过两种方式进行云 API 的身份验证:

  • 通过提供 API 令牌

API 令牌应该是秘密的,永远不要存储在版本控制系统的一部分或编译后的二进制文件中。为了防止被盗,它也应该在静态时加密。

将 API 令牌安全地传递给应用程序的一种方法是通过像 HashiCorp Vault 这样的安全框架。它是可编程的秘密存储,内置租赁时间管理和密钥轮换。

  • 通过托管在具有适当访问权限的实例上

许多云提供商允许给予特定虚拟机实例访问权限。这样,托管在这样一个实例上的应用程序就不必使用单独的令牌进行身份验证。访问控制是基于云 API 请求的实例。

这种方法更容易实现,因为它不必考虑秘密管理的需求。缺点是,当实例被入侵时,访问权限将对所有在那里运行的应用程序可用,而不仅仅是你部署的应用程序。

使用云 CLI

云 CLI 通常由人类操作员用于与云 API 交互。或者,它可以用于脚本编写或使用官方不支持的语言与云 API 交互。

例如,以下 Bourne Shell 脚本在 Microsoft Azure 云中创建一个资源组,然后创建属于该资源组的虚拟机:

#!/bin/sh
RESOURCE_GROUP=dominicanfair
VM_NAME=dominic
REGION=germanynorth

az group create --name $RESOURCE_GROUP --location $REGION

az vm create --resource-group $RESOURCE_GROUP --name $VM_NAME --image UbuntuLTS --ssh-key-values dominic_key.pub

当寻找如何管理云资源的文档时,你会遇到很多使用云 CLI 的例子。即使你通常不使用 CLI,而更喜欢像 Terraform 这样的解决方案,有云 CLI 在手可能会帮助你调试基础设施问题。

使用与云 API 交互的工具

您已经了解了在使用云提供商的产品时出现供应商锁定的危险。通常,每个云提供商都会为所有其他提供商提供不同的 API 和不同的 CLI。也有一些较小的提供商提供抽象层,允许通过类似于知名提供商的 API 访问其产品。这种方法旨在帮助将应用程序从一个平台迁移到另一个平台。

尽管这样的情况很少见,但通常用于与一个提供商的服务进行交互的工具与另一个提供商的工具不兼容。当您考虑从一个平台迁移到另一个平台时,这不仅是一个问题。如果您想在多个提供商上托管应用程序,这也可能会成为一个问题。

为此,有一套新的工具,统称为基础设施即代码IaC)工具,它们在不同提供商的顶部提供了一个抽象层。这些工具不一定仅限于云提供商。它们通常是通用的,并有助于自动化应用程序架构的许多不同层。

第九章持续集成和持续部署,我们简要介绍了其中一些。

云原生架构

新工具使架构师和开发人员能够更加抽象地构建基础架构,首先并且主要是考虑云。流行的解决方案,如 Kubernetes 和 OpenShift,正在推动这一趋势,但该领域还包括许多较小的参与者。本书的最后一章专门讨论了云原生设计,并描述了这种构建应用程序的现代方法。

总结

在本章中,我们了解了实施面向服务的体系结构的不同方法。由于服务可能以不同的方式与其环境交互,因此有许多可供选择的架构模式。我们了解了最流行的架构模式的优缺点。

我们专注于一些广受欢迎的方法的架构和实施方面:消息队列,包括 REST 的 Web 服务,以及使用托管服务和云平台。我们将在独立章节中更深入地介绍其他方法,例如微服务和容器。

在下一章中,我们将研究微服务。

问题

  1. 面向服务的体系结构中服务的属性是什么?

  2. Web 服务的一些好处是什么?

  3. 何时微服务不是一个好选择?

  4. 消息队列的一些用例是什么?

  5. 选择 JSON 而不是 XML 有哪些好处?

  6. REST 如何建立在 Web 标准之上?

  7. 云平台与传统托管有何不同?

进一步阅读

第十三章:设计微服务

随着微服务的日益流行,我们希望在本书的一个整章中专门讨论它们。在讨论架构时,你可能会听到,“我们应该使用微服务吗?”本章将向您展示如何将现有应用程序迁移到微服务架构,以及如何构建利用微服务的新应用程序。

本章将涵盖以下主题:

  • 深入微服务

  • 构建微服务

  • 观察微服务

  • 连接微服务

  • 扩展微服务

技术要求

本章中介绍的大多数示例不需要任何特定的软件。对于redis-cpp库,请查看github.com/tdv/redis-cpp

本章中的代码已放置在 GitHub 上github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter13

深入微服务

虽然微服务不受任何特定的编程语言或技术的限制,但在实现微服务时常见的选择是 Go 语言。这并不意味着其他语言不适合微服务开发-恰恰相反。C++的低计算和内存开销使其成为微服务的理想选择。

但首先,我们将从微服务的一些优缺点的详细视图开始。之后,我们将专注于通常与微服务相关的设计模式(而不是第四章中涵盖的一般设计模式,架构和系统设计)。

微服务的好处

你可能经常听到有关微服务的赞美之词。它们确实带来了一些好处,以下是其中一些。

模块化

由于整个应用程序被分割成许多相对较小的模块,更容易理解每个微服务的功能。这种理解的自然结果是,测试单个微服务也更容易。测试也受到每个微服务通常具有有限范围的事实的帮助。毕竟,测试日历应用程序比测试整个个人信息管理PIM)套件更容易。

然而,这种模块化也是有代价的。你的团队可能对单个微服务有更好的理解,但同时可能会发现更难理解整个应用程序是如何组成的。虽然不应该需要了解构成应用程序的微服务的所有内部细节,但组件之间的关系数量之多构成了认知挑战。在使用这种架构方法时,使用微服务契约是一个良好的实践。

可扩展性

更容易扩展范围有限的应用程序。其中一个原因是潜在的瓶颈较少。

缩放工作流程的较小部分也更具成本效益。想象一下,一个负责管理贸易展会的单片应用程序。一旦系统开始出现性能问题,唯一的扩展方式就是为单体引入更大的机器来运行。这被称为垂直扩展。

使用微服务,第一个优势是你可以水平扩展,也就是说,引入更多的机器而不是更大的机器(通常更便宜)。第二个优势来自于你只需要扩展那些出现性能问题的应用程序部分。这也有助于节省基础设施成本。

灵活性

当正确设计时,微服务不太容易受到供应商锁定的影响。当您决定要更换第三方组件中的一个时,您不必一次性进行整个痛苦的迁移。微服务设计考虑到您需要使用接口,因此唯一需要修改的部分是您的微服务与第三方组件之间的接口。

组件也可以逐个迁移,有些仍在使用旧提供商的软件。这意味着您可以将在多个地方引入破坏性变化的风险分开。而且,您可以将这与金丝雀部署模式结合起来,以更精细地管理风险。

这种灵活性不仅仅与单个服务有关。它也可能意味着不同的数据库、不同的排队和消息传递解决方案,甚至完全不同的云平台。虽然不同的云平台通常提供不同的服务和 API 来使用它们,但是在微服务架构中,您可以逐步开始迁移工作负载,并在新平台上独立测试它。

当由于性能问题、可扩展性或可用依赖性而需要重写时,重写微服务要比重写单体应用程序快得多。

与传统系统集成

微服务不一定是一刀切的方法。如果您的应用程序经过了充分测试,并且迁移到微服务可能会带来很多风险,那么就没有必要完全拆除正在运行的解决方案。最好只拆分需要进一步开发的部分,并将它们作为原始单体应用程序将使用的微服务引入。

通过遵循这种方法,您将获得与微服务相关的敏捷发布周期的好处,同时避免从头开始创建新架构并基本上重建整个应用程序。如果某些东西已经运行良好,最好专注于如何在不破坏良好部分的情况下添加新功能,而不是从头开始。在这里要小心,因为从头开始通常被用作自我提升!

分布式开发

开发团队规模小且共同办公的时代已经一去不复返。远程工作和分布式开发即使在传统的办公公司中也是事实。像 IBM、微软和英特尔这样的巨头公司有来自不同地点的人们一起在一个项目上工作。

微服务允许更小更灵活的团队,这使得分布式开发变得更加容易。当不再需要促进 20 人或更多人之间的沟通时,也更容易构建需要较少外部管理的自组织团队。

微服务的缺点

即使您认为由于其好处,您可能需要微服务,也要记住它们也有一些严重的缺点。简而言之,它们绝对不适合每个人。大公司通常可以抵消这些缺点,但较小的公司通常没有这种奢侈。

依赖成熟的 DevOps 方法

构建和测试微服务应该比在大型单片应用上执行类似操作要快得多。但为了实现敏捷开发,这种构建和测试需要更频繁地进行。

虽然在处理单体应用程序时手动部署应用程序可能是明智的,但是如果应用于微服务,同样的方法将导致许多问题。

为了在开发中采用微服务,您必须确保您的团队具有 DevOps 思维,并了解构建和运行微服务的要求。仅仅将代码交给其他人然后忘记它是不够的。

DevOps 思维将帮助您的团队尽可能自动化。在软件架构中,开发微服务而没有持续集成/持续交付流水线可能是最糟糕的想法之一。这种方法将带来微服务的所有其他缺点,而又无法实现大部分的好处。

调试更困难

微服务需要引入可观察性。没有它,当出现问题时,你永远不确定从哪里开始寻找潜在的根本原因。可观察性是一种推断应用程序状态的方式,而无需运行调试器或记录工作负载所在的机器。

日志聚合、应用程序指标、监控和分布式跟踪的组合是管理基于微服务的架构的先决条件。一旦考虑到自动扩展和自愈,甚至可能阻止您访问个别服务(如果它们开始崩溃),这一点尤其重要。

额外开销

微服务应该是精益和敏捷的。通常情况下是这样的。然而,基于微服务的架构通常需要额外的开销。首层开销与微服务通信使用的额外接口有关。RPC 库和 API 提供者和消费者不仅要按微服务的数量增加,还要按其副本的数量增加。然后还有辅助服务,如数据库、消息队列等。这些服务还包括通常由存储设施和收集数据的个体收集器组成的可观察性设施。

通过更好的扩展优化的成本可能会被运行整个服务群所需的成本所抵消,而这些服务并没有带来即时的业务价值。而且,你可能很难向利益相关者证明这些成本(无论是基础设施还是开发开销)。

微服务的设计模式

许多通用设计模式也适用于微服务。还有一些设计模式通常与微服务相关联。这里介绍的模式对于绿地项目和从单块应用程序迁移都很有用。

分解模式

这些模式涉及微服务的分解方式。我们希望确保架构稳定,服务之间松耦合。我们还希望确保服务具有内聚性和可测试性。最后,我们希望自治团队完全拥有一个或多个服务。

按业务能力分解

其中一种分解模式要求按业务能力进行分解。业务能力涉及业务为产生价值而做的事情。业务能力的例子包括商家管理和客户管理。业务能力通常以层次结构组织。

应用这种模式的主要挑战是正确识别业务能力。这需要对业务本身有一定的了解,并可能受益于与业务分析师的合作。

按子域分解

另一种分解模式与领域驱动设计DDD)方法有关。要定义服务,需要识别 DDD 子域。就像业务能力一样,识别子域需要了解业务背景。

这两种方法的主要区别在于,按业务能力分解更多关注业务的组织(其结构),而按子域分解更关注业务试图解决的问题。

每个服务一个数据库模式

存储和处理数据在每种软件架构中都是一个复杂的问题。错误的选择可能会影响可伸缩性、性能或维护成本。对于微服务来说,由于我们希望微服务之间松耦合,这增加了额外的复杂性。

这导致了一种设计模式,每个微服务连接到自己的数据库,因此独立于其他服务引入的任何更改。虽然这种模式增加了一些开销,但其额外的好处是你可以为每个微服务单独优化架构和索引。

由于数据库往往是相当庞大的基础设施,这种方法可能不可行,因此在微服务之间共享数据库是可以理解的权衡。

部署策略

当多个主机上运行微服务时,您可能会想知道分配资源的更好方式是哪种。让我们比较两种可能的方法。

每个主机单个服务

使用这种模式,我们允许每个主机只为特定类型的微服务提供服务。主要好处是你可以调整机器以更好地适应所需的工作负载,并且服务是良好隔离的。当你提供额外大的内存或快速存储时,你可以确保它只用于需要它的微服务。服务也无法消耗比所分配的资源更多的资源。

这种方法的缺点是一些主机可能被低效利用。一个可能的解决方法是在必要时使用尽可能小的机器来满足微服务的要求,并在必要时对其进行扩展。然而,这种解决方法并不能解决主机本身的额外开销问题。

每个主机多个服务

相反的方法是在一个主机上托管多个服务。这有助于优化机器的利用率,但也带来一些缺点。首先,不同的微服务可能需要不同的优化,因此在单个主机上托管它们仍然是不可能的。此外,使用这种方法,您失去了对主机分配的控制,因此一个微服务中的问题可能会导致共存的另一个微服务中断,即使后者在其他情况下不受影响。

另一个问题是微服务之间的依赖冲突。当微服务彼此不隔离时,部署必须考虑不同的可能依赖关系。这种模型也不太安全。

可观察性模式

在前面的部分中,我们提到微服务是有代价的。这个代价包括引入可观察性的要求,否则就会失去调试应用程序的能力。以下是一些与可观察性相关的模式。

日志聚合

微服务像单片应用程序一样使用日志记录。日志不是存储在本地,而是被聚合并转发到一个中央设施。这样,即使服务本身宕机,日志也是可用的。以集中的方式存储日志还有助于关联来自不同微服务的数据。

应用程序指标

要基于数据做出决策,首先需要一些数据来采取行动。收集应用程序指标有助于了解实际用户使用的应用程序行为,而不是合成测试中的行为。收集这些指标的方法有推送(应用程序主动调用性能监控服务)和拉取(性能监控服务定期检查配置的端点)。

分布式跟踪

分布式跟踪不仅有助于调查性能问题,还有助于更好地了解应用程序在真实流量下的行为。与日志记录不同,日志跟踪关注的是单个事务的整个生命周期,从它起源于用户操作的地方开始。

健康检查 API

由于微服务经常是自动化的目标,它们需要能够传达其内部状态。即使进程存在于系统中,也不意味着应用程序正在运行。对于开放的网络端口也是如此;应用程序可能正在监听,但还不能响应。健康检查 API 提供了一种外部服务确定应用程序是否准备处理工作负载的方法。自愈和自动扩展使用健康检查来确定何时需要干预。基本前提是给定的端点(例如/health)在应用程序表现如预期时返回 HTTP 代码200,如果发现任何问题,则返回不同的代码(或根本不返回)。

现在你已经了解了所有的优缺点和模式,我们将向你展示如何将单片应用程序分割并逐步转换为微服务。所提出的方法不仅限于微服务;它们在其他情况下也可能有用,包括单片应用程序。

构建微服务

关于单片应用程序有很多不同的观点。一些架构师认为单片应用程序本质上是邪恶的,因为它们不易扩展,耦合度高,难以维护。还有一些人声称,单片应用程序带来的性能优势可以抵消它们的缺点。紧密耦合的组件在网络、处理能力和内存方面需要的开销要少得多。

由于每个应用程序都有独特的业务需求,并在利益相关者的独特环境中运行,因此没有关于哪种方法更适合的通用规则。更令人困惑的是,在从单片应用程序迁移到微服务后,一些公司开始将微服务合并成宏服务。这是因为维护成千上万个单独的软件实例的负担太大,无法处理。

选择一种架构而不是另一种架构应该始终来自业务需求和对不同替代方案的仔细分析。将意识形态置于实用主义之前通常会导致组织内的大量浪费。当一个团队试图不顾一切地坚持某种方法,而不考虑不同的解决方案或不同的外部意见时,该团队就不再履行为工作提供正确工具的义务。

如果你正在开发或维护一个单片应用程序,你可能会考虑提高其可扩展性。本节介绍的技术旨在解决这个问题,同时使您的应用程序更容易迁移到微服务,如果您决定这样做的话。

瓶颈的三个主要原因如下:

  • 内存

  • 存储

  • 计算

我们将向您展示如何处理每个问题,以开发基于微服务的可扩展解决方案。

外包内存管理

帮助微服务扩展的一种方法是外包它们的一些任务。可能会妨碍扩展努力的一个任务是内存管理和缓存数据。

对于单个单片应用程序,直接将缓存数据存储在进程内存中并不是问题,因为进程将是唯一访问缓存的进程。但是,对于一个进程的多个副本,这种方法开始显示一些问题。

如果一个副本已经计算了一部分工作负载并将其存储在本地缓存中,另一个副本并不知道这一事实,必须重新计算。这样,您的应用程序既浪费了计算时间(因为同样的任务必须执行多次),又浪费了内存(因为结果也分别存储在每个副本中)。

为了缓解这些挑战,考虑切换到外部内存存储,而不是在应用程序内部管理缓存。使用外部解决方案的另一个好处是,缓存的生命周期不再与应用程序的生命周期绑定。您可以重新启动和部署应用程序的新版本,缓存中已经存储的值将被保留。

这可能还会导致启动时间更短,因为您的应用程序在启动时不再需要执行计算。内存缓存的两种流行解决方案是 Memcached 和 Redis。

Memcached

Memcached 于 2003 年发布,是这两者中较老的产品。它是一个通用的、分布式的键值存储。该项目的最初目标是通过将缓存值存储在内存中来卸载 Web 应用程序中使用的数据库。Memcached 是通过设计进行分布的。自版本 1.5.18 以来,可以在不丢失缓存内容的情况下重新启动 Memcached 服务器。这是通过使用 RAM 磁盘作为临时存储空间实现的。

它使用一个简单的 API,可以通过 telnet 或 netcat 操作,也可以使用许多流行的编程语言的绑定。虽然没有专门针对 C++的绑定,但可以使用 C/C++的libmemcached库。

Redis

Redis 是比 Memcached 更新的项目,最初版本于 2009 年发布。自那时起,Redis 已经在许多情况下取代了 Memcached 的使用。与 Memcached 一样,它是一个分布式的、通用的、内存中的键值存储。

与 Memcached 不同,Redis 还具有可选的数据持久性。虽然 Memcached 操作的是简单字符串的键和值,但 Redis 还支持其他数据类型,例如以下内容:

  • 字符串列表

  • 字符串集

  • 字符串的排序集

  • 键和值都是字符串的哈希表

  • 地理空间数据(自 Redis 3.2 起)

  • HyperLogLogs

Redis 的设计使其成为缓存会话数据、缓存网页和实现排行榜的绝佳选择。除此之外,它还可以用于消息队列。Python 的流行分布式任务队列库 Celery 使用 Redis 作为可能的代理之一,还有 RabbitMQ 和 Apache SQS。

微软、亚马逊、谷歌和阿里巴巴都在其云平台中提供基于 Redis 的托管服务。

C++中有许多 Redis 客户端的实现。两个有趣的实现是使用 C++17 编写的redis-cpp库(github.com/tdv/redis-cpp)和使用 Qt 工具包的 QRedisClient(github.com/uglide/qredisclient)。

以下是从官方文档中摘取的redis-cpp用法示例,说明了如何在存储中设置和获取一些数据:

#include <cstdlib>
#include <iostream>

#include <redis-cpp/execute.h>
#include <redis-cpp/stream.h>

int main() {
  try {
    auto stream = rediscpp::make_stream("localhost", "6379");

    auto const key = "my_key";

    auto response = rediscpp::execute(*stream, "set", key,
                                      "Some value for 'my_key'", "ex", 
                                      "60");

    std::cout << "Set key '" << key << "': " 
              << response.as<std::string>()
              << std::endl;

    response = rediscpp::execute(*stream, "get", key);
    std::cout << "Get key '" << key << "': " 
              << response.as<std::string>()
              << std::endl;
  } catch (std::exception const &e) {
    std::cerr << "Error: " << e.what() << std::endl;
    return EXIT_FAILURE;
  }
  return EXIT_SUCCESS;
}

正如您所看到的,该库处理不同数据类型的处理。该示例将值设置为字符串列表。

哪种内存缓存更好?

对于大多数应用程序,Redis 现在可能是一个更好的选择。它拥有更好的用户社区、许多不同的实现,并得到了良好的支持。除此之外,它还具有快照、复制、事务和发布/订阅模型。可以在 Redis 中嵌入 Lua 脚本,并且对地理空间数据的支持使其成为地理启用的 Web 和移动应用程序的绝佳选择。

然而,如果您的主要目标是在 Web 应用程序中缓存数据库查询的结果,那么 Memcached 是一个更简单的解决方案,开销更小。这意味着它应该更好地利用资源,因为它不必存储类型元数据或在不同类型之间执行转换。

外包存储

引入和扩展微服务时的另一个可能的限制是存储。传统上,本地块设备用于存储不属于数据库的对象(如静态 PDF 文件、文档或图像)。即使在今天,块存储仍然非常受欢迎,包括本地块设备和网络文件系统,如 NFS 或 CIFS。

虽然 NFS 和 CIFS 属于网络附加存储(NAS)的领域,但也有与在不同级别上运行的概念相关的协议:存储区域网络(SAN)。一些流行的协议包括 iSCSI、网络块设备(NBD)、以太网上的 ATA、光纤通道协议和以太网上的光纤通道。

另一种方法是针对分布式计算设计的集群文件系统:GlusterFS、CephFS 或 Lustre。然而,所有这些都作为块设备运行,向用户公开相同的 POSIX 文件 API。

作为亚马逊网络服务的一部分,提出了存储的新观点。亚马逊简单存储服务(S3)是对象存储。API 提供对存储在存储桶中的对象的访问。这与传统文件系统不同,因为文件、目录或索引节点之间没有区别。有存储桶和指向对象的键,对象是由服务存储的二进制数据。

外包计算

微服务的原则之一是一个进程只负责执行工作流的一部分。从单体架构迁移到微服务的一个自然步骤将是定义可能的长时间运行的任务,并将它们拆分为单独的进程。

这是任务队列背后的概念。任务队列处理管理任务的整个生命周期。与自己实现线程或多进程不同,使用任务队列,您将任务委托给异步处理任务的任务队列。任务可能在与发起进程相同的机器上执行,但也可能在具有专门要求的机器上运行。

任务及其结果是异步的,因此在主进程中没有阻塞。Web 开发中流行的任务队列示例包括 Python 的 Celery、Ruby 的 Sidekiq、Node.js 的 Kue 和 Go 的 Machinery。所有这些都可以与 Redis 一起使用作为代理。不幸的是,对于 C++,目前没有类似成熟的解决方案。

如果您认真考虑采用这种方法,一个可能的方法是直接在 Redis 中实现任务队列。Redis 及其 API 提供了支持这种行为所需的基本操作。另一种可能的方法是使用现有的任务队列之一,例如 Celery,并通过直接调用 Redis 来调用它们。然而,这并不被建议,因为它依赖于任务队列的实现细节,而不是文档化的公共 API。另一种方法是使用 SWIG 或类似方法提供的绑定来接口任务队列。

观察微服务

您构建的每个微服务都需要遵循一般的架构设计模式。微服务和传统应用程序之间的主要区别在于前者需要实现可观察性。

本节重点介绍了一些可观察性的方法。我们在这里描述了几种开源解决方案,当您设计系统时可能会发现有用。

记录

记录是一个即使您从未设计过微服务也应该熟悉的主题。日志(或日志文件)存储有关系统中发生事件的信息。系统可能指的是您的应用程序、您的应用程序运行的操作系统,或者您用于部署的云平台。这些组件中的每一个都可能提供日志。

日志被存储为单独的文件,因为它们提供了所有事件的永久记录。当系统变得无响应时,我们希望查询日志,并找出停机的可能根本原因。

这意味着日志也提供审计跟踪。因为事件是按时间顺序记录的,我们能够通过检查记录的历史状态来了解系统的状态。

为了帮助调试,日志通常是人类可读的。虽然日志也有二进制格式,但在使用文件存储日志时,这样的格式相当罕见。

使用微服务记录

这种日志记录方法本身与传统方法并没有太大区别。微服务通常不使用文本文件来存储日志,而是通常将日志打印到stdout。然后使用统一的日志层来检索和处理日志。要实现日志记录,您需要一个日志库,可以根据您的需求进行配置。

使用 spdlog 在 C++中记录日志

C++中一种流行且快速的日志库是spdlog。它使用 C++11 构建,可以作为仅头文件库或静态库使用(可减少编译时间)。

spdlog的一些有趣特性包括以下内容:

  • 格式化

  • 多个输出端:

  • 轮换文件

  • 控制台

  • Syslog

  • 自定义(实现为单个函数)

  • 多线程和单线程版本

  • 可选的异步模式

spdlog可能缺少的一个功能是直接支持 Logstash 或 Fluentd。如果要使用这些聚合器之一,仍然可以配置spdlog以使用文件输出,并使用 Filebeat 或 Fluent Bit 将文件内容转发到适当的聚合器。

统一日志层

大多数情况下,我们无法控制所有使用的微服务。其中一些将使用一个日志库,而其他人将使用不同的日志库。更糟糕的是,格式将完全不同,它们的轮换策略也将不同。更糟糕的是,我们仍然希望将操作系统事件与应用程序事件相关联。这就是统一日志层发挥作用的地方。

统一日志层的目的之一是从不同来源收集日志。这种统一日志层工具提供了许多集成,并理解不同的日志格式和传输方式(如文件、HTTP 和 TCP)。

统一日志层还能够过滤日志。我们可能需要过滤以满足合规性,匿名化客户的个人信息,或保护我们服务的实现细节。

为了更容易在以后查询日志,统一日志层还可以在不同格式之间进行转换。即使您使用的不同服务将日志存储为 JSON、CSV 和 Apache 格式,统一的日志层解决方案也能够将它们全部转换为 JSON 以赋予它们结构。

统一日志层的最终任务是将日志转发到它们的下一个目的地。根据系统的复杂性,下一个目的地可能是存储设施或另一个过滤、转换和转发设施。

以下是一些有趣的组件,可以帮助您构建统一的日志层。

Logstash

Logstash 是最受欢迎的统一日志层解决方案之一。目前,它由 Elastic 公司拥有,该公司是 Elasticsearch 背后的公司。如果您听说过 ELK 堆栈(现在称为 Elastic Stack),Logstash 是该首字母缩写中的“L”。

Logstash 最初是用 Ruby 编写的,然后被移植到了 JRuby。不幸的是,这意味着它需要相当多的资源。因此,不建议在每台机器上运行 Logstash。相反,它主要用作轻量级的日志转发器,每台机器部署轻量级的 Filebeat 来执行收集。

Filebeat

Filebeat 是 Beats 系列产品的一部分。它的目标是提供一个轻量级的 Logstash 替代方案,可以直接与应用程序一起使用。

这样,Beats 提供了低开销,而集中式 Logstash 安装执行所有繁重的工作,包括转换、过滤和转发。

除了 Filebeat 之外,Beats 系列的其他产品如下:

  • 用于性能的 Metricbeat

  • 用于网络数据的 Packetbeat

  • 用于审计数据的 Auditbeat

  • 用于运行时间监控的心跳

Fluentd

Fluentd 是 Logstash 的主要竞争对手。它也是一些云提供商的首选工具。

由于其使用插件的模块化方法,您可以找到用于数据源(如 Ruby 应用程序、Docker 容器、SNMP 或 MQTT 协议)、数据输出(如 Elastic Stack、SQL 数据库、Sentry、Datadog 或 Slack)以及其他各种过滤器和中间件的插件。

Fluentd 应该比 Logstash 占用更少的资源,但仍然不是一个适合大规模运行的完美解决方案。与与 Fluentd 配合使用的 Filebeat 的对应物称为 Fluent Bit。

Fluent Bit

Fluent Bit 是用 C 编写的,提供了一个更快、更轻的解决方案,可以插入到 Fluentd 中。作为日志处理器和转发器,它还具有许多输入和输出的集成。

除了日志收集,Fluent Bit 还可以监视 Linux 系统上的 CPU 和内存指标。它可以与 Fluentd 一起使用,也可以直接转发到 Elasticsearch 或 InfluxDB。

Vector

虽然 Logstash 和 Fluentd 是稳定、成熟和经过验证的解决方案,但在统一日志层空间中也有一些更新的提议。

其中之一是 Vector,旨在通过单一工具处理所有可观察性数据。为了与竞争对手区分,它专注于性能和正确性。这也体现在技术选择上。Vector 使用 Rust 作为引擎,Lua 作为脚本语言(而不是 Logstash 和 Fluentd 使用的自定义领域特定语言)。

在撰写本文时,它尚未达到稳定的 1.0 版本,因此在这一点上,不应将其视为生产就绪。

日志聚合

日志聚合解决了由于过多数据而产生的另一个问题:如何存储和访问日志。统一的日志层使日志即使在机器故障时也可用,而日志聚合的任务是帮助我们快速找到我们正在寻找的信息。

允许存储、索引和查询大量数据的两种可能产品是 Elasticsearch 和 Loki。

Elasticsearch

Elasticsearch 是自托管日志聚合的最流行解决方案。这是(以前的)ELK Stack 中的“E”。它具有基于 Apache Lucene 的出色搜索引擎。

作为其领域的事实标准,Elasticsearch 具有许多集成,并且在社区和商业服务方面得到了很好的支持。一些云提供商提供 Elasticsearch 作为托管服务,这使得在应用程序中引入 Elasticsearch 变得更容易。除此之外,制造 Elasticsearch 的 Elastic 公司还提供了一个不与任何特定云提供商绑定的托管解决方案。

Loki

Loki 旨在解决 Elasticsearch 中发现的一些缺点。Loki 的重点领域是水平扩展性和高可用性。它是从头开始构建的云原生解决方案。

Loki 的设计选择受到 Prometheus 和 Grafana 的启发。这并不奇怪,因为它是由负责 Grafana 的团队开发的。

虽然 Loki 应该是一个稳定的解决方案,但它并不像 Elasticsearch 那样受欢迎,这意味着可能会缺少一些集成,文档和社区支持也不会像 Elasticsearch 那样。Fluentd 和 Vector 都有支持 Loki 进行日志聚合的插件。

日志可视化

我们想考虑的日志堆栈的最后一部分是日志可视化。这有助于我们查询和分析日志。它以一种易于访问的方式呈现数据,因此所有感兴趣的方都可以检查,如运营商、开发人员、QA 或业务。

日志可视化工具使我们能够创建仪表板,使我们更容易阅读我们感兴趣的数据。有了这个,我们能够探索事件,寻找相关性,并从简单的用户界面中找到异常数据。

有两个专门用于日志可视化的主要产品。

Kibana

Kibana 是 ELK Stack 的最后一个元素。它在 Elasticsearch 之上提供了一个更简单的查询语言。尽管您可以使用 Kibana 查询和可视化不同类型的数据,但它主要专注于日志。

与 ELK Stack 的其他部分一样,它目前是可视化日志的事实标准。

Grafana

Grafana 是另一个数据可视化工具。直到最近,它主要专注于性能指标的时间序列数据。然而,随着 Loki 的引入,它现在也可以用于日志。

它的一个优点是它是以可插拔后端为目标构建的,因此很容易切换存储以适应您的需求。

监控

监控是从系统中收集与性能相关的指标的过程。与警报配对时,监控帮助我们了解系统何时表现如预期,以及何时发生故障。

我们最感兴趣的三种类型的指标如下:

  • 可用性,让我们知道我们的资源中哪些是正常运行的,哪些已经崩溃或变得无响应。

  • 资源利用率让我们了解工作负载如何适应系统。

  • 性能,它向我们展示了在哪里以及���何改进服务质量。

监控的两种模型是推送和拉取。在前者中,每个受监视的对象(机器、应用程序和网络设备)定期将数据推送到中心点。在后者中,对象在配置的端点呈现数据,监控代理定期抓取数据。

拉取模型使得扩展更容易。这样,多个对象不会阻塞监控代理连接。相反,多个代理可以在准备好时收集数据,从而更好地利用可用资源。

两个具有 C++客户端库的监控解决方案是 Prometheus 和 InfluxDB。Prometheus 是一个拉取模型的例子,它专注于收集和存储时间序列数据。InfluxDB 默认使用推送模型。除了监控,它还在物联网、传感器网络和家庭自动化方面很受欢迎。

Prometheus 和 InfluxDB 通常与 Grafana 一起用于可视化数据和管理仪表板。两者都内置了警报功能,但也可以通过 Grafana 与外部警报系统集成。

跟踪

跟踪提供的信息通常比事件日志更低级。另一个重要的区别是,跟踪存储每个事务的 ID,因此很容易可视化整个工作流程。这个 ID 通常被称为跟踪 ID、事务 ID 或相关 ID。

与事件日志不同,跟踪不是为了人类可读。它们由跟踪器处理。在实施跟踪时,有必要使用一个能够与系统的所有可能元素集成的跟踪解决方案:前端应用程序、后端应用程序和数据库。这样,跟踪有助于准确定位性能滞后的确切原因。

OpenTracing

分布式跟踪中的一个标准是 OpenTracing。这个标准是由 Jaeger 的作者提出的,Jaeger 是一个开源的跟踪器。

OpenTracing 支持许多不同的跟踪器,除了 Jaeger,它还支持许多不同的编程语言。最重要的包括以下内容:

  • Go

  • C++

  • C#

  • Java

  • JavaScript

  • Objective-C

  • PHP

  • Python

  • Ruby

OpenTracing 最重要的特性是它是供应商中立的。这意味着一旦我们对应用程序进行了仪器化,我们就不需要修改整个代码库来切换到不同的跟踪器。这样,它可以防止供应商锁定。

Jaeger

Jaeger 是一个跟踪器,可以与包括 Elasticsearch、Cassandra 和 Kafka 在内的各种后端一起使用。

它与 OpenTracing 兼容,这并不奇怪。由于它是一个 Cloud Native Computing Foundation 毕业的项目,它有很好的社区支持,这也意味着它与其他服务和框架的集成很好。

OpenZipkin

OpenZipkin 是 Jaeger 的主要竞争对手。它已经在市场上存在了更长的时间。尽���这应该意味着它是一个更成熟的解决方案,但与 Jaeger 相比,它的受欢迎程度正在下降。特别是,OpenZipkin 中的 C++并没有得到积极的维护,这可能会导致未来的维护问题。

集成的可观察性解决方案

如果您不想自己构建可观察性层,那么有一些受欢迎的商业解决方案可能会考虑。它们都以软件即服务模式运行。我们不会在这里进行详细的比较,因为它们的提供可能在本书编写后发生重大变化。

这些服务如下:

  • Datadog

  • Splunk

  • Honeycomb

在本节中,您已经看到了在微服务中实现可观察性。接下来,我们将继续学习如何连接微服务。

连接微服务

微服务非常有用,因为它们可以以许多不同的方式与其他服务连接,从而创造新的价值。然而,由于微服务没有标准,因此连接它们的方法也没有统一的方式。

这意味着大多数情况下,当我们想要使用特定的微服务时,我们必须学会如何与其交互。好消息是,尽管在微服务中可以实现任何通信方法,但有一些流行的方法是大多数微服务遵循的。

在设计围绕微服务的架构时,如何连接微服务只是一个相关问题之一。另一个问题是连接到什么以及在哪里连接。这就是服务发现发挥作用的地方。通过服务发现,我们让微服务使用自动化手段发现和连接应用程序中的其他服务。

这三个问题,如何、什么和在哪里,将是我们接下来的话题。我们将介绍一些现代微服务使用的最流行的通信和发现方法。

应用程序编程接口(API)

就像软件库一样,微服务通常会暴露 API。这些 API 使得与微服务进行通信成为可能。由于典型的通信方式利用计算机网络,API 的最流行形式是 Web API。

在上一章中,我们已经涵盖了一些可能的网络服务方法。如今,微服务通常使用基于表述状态转移REST)的网络服务。

远程过程调用

虽然诸如 REST 之类的 Web API 允许轻松调试和良好的互操作性,但与数据转换和使用 HTTP 进行传输相关的开销很大。

这种开销对一些微服务来说可能太大了,这就是轻量级远程过程调用RPCs)的原因。

Apache Thrift

Apache Thrift 是一种接口描述语言和二进制通信协议。它用作一种 RPC 方法,允许创建用多种语言构建的分布式和可扩展服务。

它支持多种二进制协议和传输方法。每种编程语言都使用本机数据类型,因此即使在现有代码库中也很容易引入。

gRPC

如果您真的关心性能,通常会发现基于文本的解决方案不适合您。然而,REST 虽然优雅且易于理解,但可能对您的需求来说太慢了。如果是这种情况,您应该尝试围绕二进制协议构建您的 API。其中一种日益流行的协议是 gRPC。

gRPC,顾名思义,是最初由 Google 开发的 RPC 系统。它使用 HTTP/2 进行传输,并使用协议缓冲区作为多种编程语言之间的接口描述语言IDL)以及数据序列化的可互操作性。也可以使用替代技术,例如 FlatBuffers。gRPC 可以同步和异步使用,并允许创建简单服务和流式服务。

假设您已决定使用protobufs,我们的 Greeter 服务定义可以如下所示:

service Greeter {
 rpc Greet(GreetRequest) returns (GreetResponse);
}

message GreetRequest {
 string name = 1;
}

message GreetResponse {
 string reply = 1;
}

使用protoc编译器,您可以从此定义创建数据访问代码。假设您想为我们的 Greeter 创建一个同步服务器,可以按以下方式创建服务:

class Greeter : public Greeter::Service {
  Status sendRequest(ServerContext *context, const GreetRequest 
*request,
                     GreetReply *reply) override {
    auto name = request->name();
    if (name.empty()) return Status::INVALID_ARGUMENT;
    reply->set_result("Hello " + name);
    return Status::OK;
  }
};

然后,您必须构建并运行服务器:

int main() {
  Greeter service;
  ServerBuilder builder;
  builder.AddListeningPort("localhost", grpc::InsecureServerCredentials());
  builder.RegisterService(&service);

  auto server(builder.BuildAndStart());
  server->Wait();
}

就是这么简单。现在让我们来看一个用于消费此服务的客户端:

  #include <grpcpp/grpcpp.h>

  #include <string>

  #include "grpc/service.grpc.pb.h"

  using grpc::ClientContext;
  using grpc::Status;

  int main() {
    std::string address("localhost:50000");
    auto channel =
        grpc::CreateChannel(address, grpc::InsecureChannelCredentials());
    auto stub = Greeter::NewStub(channel);

    GreetRequest request;
    request.set_name("World");

    GreetResponse reply;
    ClientContext context;
    Status status = stub->Greet(&context, request, &reply);

    if (status.ok()) {
      std::cout << reply.reply() << '\n';
    } else {
      std::cerr << "Error: " << status.error_code() << '\n';
    }
  }

这是一个简单的同步示例。要使其异步工作,您需要添加标签和CompletionQueue,如 gRPC 网站上所述。

gRPC 的一个有趣特性是它适用于 Android 和 iOS 上的移动应用程序。这意味着如果您在内部使用 gRPC,则无需提供额外的服务器来转换来自移动应用程序的流量。

在本节中,您了解了微服务使用的最流行的通信和发现方法。接下来,我们将看到如何扩展微服务。

扩展微服务

微服务的一个重要好处是它们比单体应用程序更有效地扩展。在相同的硬件基础设施下,您理论上可以从微服务中获得比单体应用程序更高的性能。

在实践中,好处并不那么直接。微服务及其相关辅助工具也会提供开销,对于规模较小的应用程序,可能不如最佳单体应用程序高效。

请记住,即使某些东西在“纸上”看起来不错,也不意味着它会成功。如果您想基于可扩展性或性能做出架构决策,最好准备计算和实验。这样,您将根据数据而不仅仅是情感行事。

每个主机部署单个服务的扩展

对于每个主机部署的单个服务,扩展微服务需要添加或删除承载微服务的额外机器。如果您的应用程序在云架构(公共或私有)上运行,许多提供商提供称为自动缩放组的概念。

自动缩放组定义了将在所有分组实例上运行的基本虚拟机映像。每当达到临界阈值(例如 80%的 CPU 使用)时,将创建一个新实例并将其添加到组中。由于自动缩放组在负载均衡器后运行,因此增加的流量将在现有实例和新实例之间分配,从而降低每个实例的平均负载。当流量激增后,扩展控制器会关闭多余的机器,以保持成本低廉。

不同的指标可以作为扩展事件的触发器。CPU 负载是最容易使用的指标之一,但可能不是最准确的指标。其他指标,例如队列中的消息数量,可能更适合您的应用程序。

以下是一个用于缩放策略的 Terraform 配置摘录:

autoscaling_policy {
    max_replicas = 5
    min_replicas = 3

    cooldown_period = 60

    cpu_utilization {
      target = 0.8
    }
}

这意味着在任何给定时间,至少会有三个实例运行,最多为五个实例。一旦 CPU 负载达到所有组实例的平均 80%,扩展器将触发。发生这种情况时,将会启动一个新实例。新机器的指标只有在其运行至少 60 秒后才会被收集(冷却期)。

每个主机部署多个服务的扩展

这种扩展模式也适用于每个主机部署多个服务。您可能可以想象,这并不是最有效的方法。仅基于单个服务的减少吞吐量来扩展整套服务类似于扩展单体应用程序。

如果您使用此模式,扩展微服务的更好方法是使用编排器。如果您不想使用容器,Nomad 是一个与许多不同执行驱动程序兼容的绝佳选择。对于容器化工作负载,Docker Swarm 或 Kubernetes 都会帮助您。编排器是我们将在接下来的两章中回顾的一个主题。

总结

微服务是软件架构中的一个伟大新趋势。只要确保您了解危险并为其做好准备,它们可能会很合适。本章解释了帮助引入微服务的常见设计和迁移模式。我们还涵盖了诸如可观察性和连接性之类的高级主题,在建立基于微服务的架构时至关重要。

到目前为止,您应该能够将应用程序设计和分解为单独的微服务。然后,每个微服务都能够处理一部分工作负载。

虽然微服务本身是有效的,但它们在与容器结合使用时尤其受欢迎。容器是下一章的主题。

问题

  1. 为什么微服务能帮助您更好地利用系统资源?

  2. 微服务和单体架构如何共存(在不断发展的系统中)?

  3. 哪种类型的团队最能从微服务中受益?

  4. 引入微服务时为什么需要成熟的 DevOps 方法?

  5. 统一的日志记录层是什么?

  6. 日志记录和跟踪有何不同?

  7. 为什么 REST 可能不是连接微服务的最佳选择?

  8. 微服务的部署策略是什么?每种策略的好处是什么?

进一步阅读

第十四章:容器

从开发到生产的过渡一直是一个痛苦的过程。它涉及大量文档、交接、安装和配置。由于每种编程语言产生的软件行为略有不同,异构应用程序的部署总是困难的。

其中一些问题已经通过容器得到缓解。使用容器,安装和配置大多是标准化的。处理分发的方式有几种,但这个问题也有一些标准可遵循。这使得容器成为那些希望增加开发和运维之间合作的组织的绝佳选择。

本章将涵盖以下主题:

  • 构建容器

  • 测试和集成容器

  • 理解容器编排

技术要求

本章列出的示例需要以下内容:

本章中的代码已放在 GitHub 上,网址为github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter14

重新介绍容器

最近容器引起了很多关注。有人可能认为它们是一种以前不可用的全新技术。然而,事实并非如此。在 Docker 和 Kubernetes 崛起之前,这两者目前在行业中占主导地位,已经有了诸如 LXC 之类的解决方案,它们提供了许多类似的功能。

我们可以追溯到自 1979 年 UNIX 系统中可用的 chroot 机制,将一个执行环境与另一个分离的起源。类似的概念也在 FreeBSD jails 和 Solaris Zones 中使用过。

容器的主要任务是将一个执行环境与另一个隔离开。这个隔离的环境可以有自己的配置、不同的应用程序,甚至不同的用户帐户,与主机环境不同。

尽管容器与主机隔离,它们通常共享相同的操作系统内核。这是与虚拟化环境的主要区别。虚拟机有专用的虚拟资源,这意味着它们在硬件级别上是分离的。容器在进程级别上是分离的,这意味着运行它们的开销更小。

容器的一个强大优势是能够打包和运行另一个已经针对运行您的应用程序进行了优化和配置的操作系统。没有容器,构建和部署过程通常包括几个步骤:

  1. 应用已构建。

  2. 提供示例配置文件。

  3. 准备安装脚本和相关文档。

  4. 应用程序已打包为目标操作系统(如 Debian 或 Red Hat)。

  5. 软件包部署到目标平台。

  6. 安装脚本为应用程序运行准备了基础。

  7. 配置必须进行调整以适应现有系统。

当您切换到容器时,就不再需要强大的安装脚本。应用程序只会针对一个众所周知的操作系统进行目标设置——容器中存在的操作系统。配置也是一样:应用程序预先配置为目标操作系统并与其一起分发,而不是准备许多可配置选项。部署过程只包括解压容器镜像并在其中运行应用程序进程。

虽然容器和微服务经常被认为是同一件事,但它们并不是。此外,容器可能意味着应用容器或操作系统容器,只有应用容器与微服务配合得很好。接下来的章节将告诉您原因。我们将描述您可能遇到的不同容器类型,向您展示它们与微服务的关系,并解释何时最好使用它们(以及何时避免使用它们)。

探索容器类型

到目前为止描述的容器中,操作系统容器与由 Docker、Kubernetes 和 LXD 领导的当前容器趋势有根本的不同。应用容器专注于在容器内运行单个进程-即应用程序,而不是专注于重新创建具有诸如 syslog 和 cron 等服务的整个操作系统。

专有解决方案替换了所有通常的操作系统级服务。这些解决方案提供了一种统一的方式来管理容器内的应用程序。例如,不使用 syslog 来处理日志,而是将 PID 1 的进程的标准输出视为应用程序日志。不使用init.d或 systemd 等机制,而是由运行时应用程序处理应用容器的生命周期。

由于 Docker 目前是应用容器的主要解决方案,我们将在本书中大多数情况下使用它作为示例。为了使画面完整,我们将提出可行的替代方案,因为它们可能更适合您的需求。由于项目和规范是开源的,这些替代方案与 Docker 兼容,并且可以用作替代品。

在本章的后面,我们将解释如何使用 Docker 来构建、部署、运行和管理应用容器。

微服务的兴起

Docker 的成功与微服务的采用增长同时出现并不奇怪,因为微服务和应用容器自然地结合在一起。

没有应用容器,没有一种简单而统一的方式来打包、部署和维护微服务。尽管一些公司开发了一些解决这些问题的解决方案,但没有一种解决方案足够流行,可以成为行业标准。

没有微服务,应用容器的功能相当有限。软件架构专注于构建专门为给定的服务集合明确配置的整个系统。用另一个服务替换一个服务需要改变架构。

应用容器提供了一种标准的分发微服务的方式。每个微服务都带有其自己的嵌入式配置,因此诸如自动扩展或自愈等操作不再需要了解底层应用程序。

您仍然可以在没有应用容器的情况下使用微服务,也可以在应用容器中托管微服务。例如,尽管 PostgreSQL 数据库和 Nginx Web 服务器都不是设计为微服务,但它们通常在应用容器中使用。

选择何时使用容器

容器方法有几个好处。操作系统容器和应用容器在其优势所在的一些不同用例中也有所不同。

容器的好处

与隔离环境的另一种流行方式虚拟机相比,容器在运行时需要更少的开销。与虚拟机不同,不需要运行一个单独的操作系统内核版本,并使用硬件或软件虚拟化技术。应用容器也不运行通常在虚拟机中找到的其他操作系统服务,如 syslog、cron 或 init。此外,应用容器提供更小的镜像,因为它们通常不必携带整个操作系统副本。在极端情况下,应用容器可以由单个静态链接的二进制文件组成。

此时,你可能会想,如果里面只有一个单一的二进制文件,为什么还要费心使用容器呢?拥有统一和标准化的构建和运行容器的方式有一个特定的好处。由于容器必须遵循特定的约定,因此比起常规的二进制文件,对它们进行编排更容易,后者可能对日志记录、配置、打开端口等有不同的期望。

另一件事是,容器提供了内置的隔离手段。每个容器都有自己的进程命名空间和用户帐户命名空间,等等。这意味着一个容器中的进程(或进程)对主机上的进程或其他容器中的进程没有概念。沙盒化甚至可以进一步进行,因为你可以为你的容器分配内存和 CPU 配额,使用相同的标准用户界面(无论是 Docker、Kubernetes 还是其他什么)。

标准化的运行时也意味着更高的可移植性。一旦容器构建完成,通常可以在不同的操作系统上运行,而无需修改。这也意味着在运行的东西与开发中运行的东西非常接近或相同。问题的再现更加轻松,调试也更加轻松。

容器的缺点

由于现在有很大的压力要将工作负载迁移到容器中,作为架构师,你需要了解与这种迁移相关的所有风险。利益无处不在,你可能已经理解了它们。

容器采用的主要障碍是,并非所有应用程序都能轻松迁移到容器中。特别是那些以微服务为设计目标的应用程序容器。如果你的应用程序不是基于微服务架构的,将其放入容器中可能会带来更多问题。

如果你的应用程序已经很好地扩展,使用基于 TCP/IP 的 IPC,并且大部分是无状态的,那么转移到容器应该不会有挑战。否则,这些方面中的每一个都将带来挑战,并促使重新思考现有的设计。

与容器相关的另一个问题是持久存储。理想情况下,容器不应该有自己的持久存储。这样可以利用快速启动、轻松扩展和灵活的调度。问题在于提供业务价值的应用程序不能没有持久存储���

这个缺点通常可以通过使大多数容器无状态,并依赖于一个外部的非容器化组件来存储数据和状态来减轻。这样的外部组件可以是传统的自托管数据库,也可以是来自云提供商的托管数据库。无论选择哪个方向,都需要重新考虑架构并相应地进行修改。

由于应用程序容器遵循特定的约定,应用程序必须修改以遵循这些约定。对于一些应用程序来说,这将是一个低成本的任务。对于其他一些应用程序,比如使用内存 IPC 的多进程组件,这将是复杂的。

经常被忽略的一点是,只要容器内的应用程序是本地 Linux 应用程序,应用程序容器就能很好地工作。虽然支持 Windows 容器,但它们既不方便也不像它们的 Linux 对应物那样受支持。它们还需要运行作为主机的经过许可的 Windows 机器。

如果你从头开始构建一个新的应用程序,并且可以基于这项技术设计,那么很容易享受应用程序容器的好处。将现有的应用程序移植到应用程序容器中,特别是如果它很复杂,将需要更多的工作,可能还需要对整个架构进行改造。在这种情况下,我们建议您特别仔细地考虑所有的利弊。做出错误的决定可能会损害产品的交付时间、可用性和预算。

构建容器

应用程序容器是本节的重点。虽然操作系统容器大多遵循系统编程原则,但应用程序容器带来了新的挑战和模式。它们还提供了专门的构建工具来处理这些挑战。我们将考虑的主要工具是 Docker,因为它是当前构建和运行应用程序容器的事实标准。我们还将介绍一些构建应用程序容器的替代方法。

除非另有说明,从现在开始,当我们使用“容器”这个词时,它指的是“应用程序容器”。

在这一部分,我们将专注于使用 Docker 构建和部署容器的不同方法。

解释容器镜像

在我们描述容器镜像及如何构建它们之前,了解容器和容器镜像之间的区别至关重要。这两个术语经常会引起混淆,尤其是在非正式的对话中。

容器和容器镜像之间的区别与运行中的进程和可执行文件之间的区别相同。

容器镜像是静态的:它们是特定文件系统的快照和相关的元数据。元数据描述了在运行时设置了哪些环境变量,或者在创建容器时运行哪个程序,等等。

容器是动态的:它们运行在容器镜像内的一个进程。我们可以从容器镜像创建容器,也可以通过对运行中的容器进行快照来创建容器镜像。事实上,容器镜像构建过程包括创建多个容器,执行其中的命令,并在命令完成后对它们进行快照。

为了区分容器镜像引入的数据和运行时生成的数据,Docker 使用联合挂载文件系统来创建不同的文件系统层。这些层也存在于容器镜像中。通常,容器镜像的每个构建步骤对应于结果容器镜像中的一个新层。

使用 Dockerfiles 构建应用程序

使用 Docker 构建应用程序容器镜像的最常见方法是使用 Dockerfile。Dockerfile 是一种描述生成结果镜像所需操作的命令式语言。一些操作会创建新的文件系统层,而其他操作则会操作元数据。

我们不会详细介绍和具体涉及 Dockerfiles。相反,我们将展示不同的方法来将 C++应用程序容器化。为此,我们需要介绍一些与 Dockerfiles 相关的语法和概念。

这是一个非常简单的 Dockerfile 的示例:

FROM ubuntu:bionic

RUN apt-get update && apt-get -y install build-essentials gcc

CMD /usr/bin/gcc

通常,我们可以将 Dockerfile 分为三个部分:

  • 导入基本镜像(FROM指令)

  • 在容器内执行操作,将导致容器镜像(RUN指令)

  • 运行时使用的元数据(CMD命令)

后两部分可能会交错进行,每个部分可能包含一个或多个指令。也可以省略任何后续部分,因为只有基本镜像是必需的。这并不意味着你不能从空文件系统开始。有一个名为scratch的特殊基本镜像就是为了这个目的。在否则空的文件系统中添加一个单独的静态链接二进制文件可能看起来像下面这样:

FROM scratch

COPY customer /bin/customer

CMD /bin/customer

在第一个 Dockerfile 中,我们采取的步骤如下:

  1. 导入基本的 Ubuntu Bionic 镜像。

  2. 在容器内运行命令。命令的结果将在目标镜像内创建一个新的文件系统层。这意味着使用apt-get安装的软件包将在所有基于此镜像的容器中可用。

  3. 设置运行时元数据。在基于此镜像创建容器时,我们希望将GCC作为默认进程运行。

要从 Dockerfile 构建镜像,您将使用docker build命令。它需要一个必需的参数,即包含构建上下文的目录,这意味着 Dockerfile 本身和您想要复制到容器内的其他文件。要从当前目录构建 Dockerfile,请使用docker build

这将构建一个匿名镜像,这并不是很有用��大多数情况下,您希望使用命名的镜像。在命名容器镜像时有一个惯例要遵循,我们将在下一节中介绍。

命名和分发镜像

Docker 中的每个容器镜像都有一个独特的名称,由三个元素组成:注册表的名称,镜像的名称,一个标签。容器注册表是保存容器镜像的对象仓库。Docker 的默认容器注册表是docker.io。当从这个注册表中拉取镜像时,我们可以省略注册表的名称。

我们之前的例子中,ubuntu:bionic的完整名称是docker.io/ubuntu:bionic。在这个例子中,ubuntu是镜像的名称,而bionic是代表镜像特定版本的标签。

在基于容器的应用程序构建时,您将有兴趣存储所有的注册表镜像。可以搭建自己的私有注册表并在那里保存镜像,或者使用托管解决方案。流行的托管解决方案包括以下内容:

  • Docker Hub

  • quay.io

  • GitHub

  • 云提供商(如 AWS、GCP 或 Azure)

Docker Hub 仍然是最受欢迎的,尽管一些公共镜像正在迁移到 quay.io。两者都是通用的,允许存储公共和私有镜像。如果您已经在使用特定平台并希望将镜像保持接近 CI 流水线或部署目标,GitHub 或云提供商对您来说可能更具吸引力。如果您希望减少使用的个别服务数量,这也是有帮助的。

如果以上解决方案都不适合您,那么搭建自己的本地注册表也非常简单,只需要运行一个容器。

要构建一个命名的镜像,您需要向docker build命令传递-t参数。例如,要构建一个名为dominicanfair/merchant:v2.0.3的镜像,您将使用docker build -t dominicanfair/merchant:v2.0.3 .

已编译的应用程序和容器

对于解释性语言(如 Python 或 JavaScript)的应用程序构建容器镜像,方法基本上是相同的:

  1. 安装依赖项。

  2. 将源文件复制到容器镜像中。

  3. 复制必要的配置。

  4. 设置运行时命令。

然而,对于已编译的应用程序,还有一个额外的步骤是首先编译应用程序。有几种可能的方法来实现这一步骤,每种方法都有其优缺点。

最明显的方法是首先安装所有的依赖项,复制源文件,然后编译应用程序作为容器构建步骤之一。主要的好处是我们可以准确控制工具链的内容和配置,因此有一种便携的方式来构建应用程序。然而,缺点是太大而无法忽视:生成的容器镜像包含了许多不必要的文件。毕竟,在运行时我们既不需要源代码也不需要工具链。由于叠加文件系统的工作方式,无法在引入到先前层中的文件之后删除这些文件。而且,如果攻击者设法侵入容器,容器中的源代码可能会构成安全风险。

它可以看起来像这样:

FROM ubuntu:bionic

RUN apt-get update && apt-get -y install build-essentials gcc cmake

ADD . /usr/src

WORKDIR /usr/src

RUN mkdir build && \
    cd build && \
    cmake .. -DCMAKE_BUILD_TYPE=Release && \
    cmake --build . && \
    cmake --install .

CMD /usr/local/bin/customer

另一种明显的方法,也是我们之前讨论过的方法,是在主机上构建应用程序,然后只将生成的二进制文件复制到容器映像中。当已经建立了一个构建过程时,这需要对当前构建过程进行较少的更改。主要的缺点是您必须在构建机器上与容器中使用相同的库集。例如,如果您的主机操作系统是 Ubuntu 20.04,那么您的容器也必须基于 Ubuntu 20.04。否则,您会面临不兼容性的风险。使用这种方法,还需要独立配置工具链而不是容器。

就像这样:

FROM scratch

COPY customer /bin/customer

CMD /bin/customer

一种稍微复杂的方法是采用多阶段构建。使用多阶段构建,一个阶段可能专门用于设置工具链和编译项目,而另一个阶段则将生成的二进制文件复制到目标容器映像中。这比以前的解决方案有几个好处。首先,Dockerfile 现在控制工具链和运行时环境,因此构建的每一步都有详细记录。其次,可以使用带有工具链的映像来确保开发和持续集成/持续部署(CI/CD)流水线之间的兼容性。这种方式还使得更容易分发工具链本身的升级和修复。主要的缺点是容器化的工具链可能不像本机工具链那样方便使用。此外,构建工具并不特别适合应用容器,后者要求每个容器只运行一个进程。这可能导致一些进程崩溃或被强制停止时出现意外行为。

前面示例的多阶段版本如下所示:

FROM ubuntu:bionic AS builder

RUN apt-get update && apt-get -y install build-essentials gcc cmake

ADD . /usr/src

WORKDIR /usr/src

RUN mkdir build && \
    cd build && \
    cmake .. -DCMAKE_BUILD_TYPE=Release && \
    cmake --build .

FROM ubuntu:bionic

COPY --from=builder /usr/src/build/bin/customer /bin/customer

CMD /bin/customer

从第一个 FROM 命令开始的第一个阶段设置了构建器,添加了源代码并构建了二进制文件。然后,从第二个 FROM 命令开始的第二阶段,复制了上一阶段的结果二进制文件,而没有复制工具链或源代码。

通过清单定位多个架构

使用 Docker 的应用容器通常在 x86_64(也称为 AMD64)机器上使用。如果您只针对这个平台,那就没什么好担心的。但是,如果您正在开发物联网、嵌入式或边缘应用程序,您可能对多架构映像感兴趣。

由于 Docker 可用于许多不同的 CPU 架构,有多种方法可以处理多平台上的映像管理。

处理为不同目标构建的映像的一种方法是使用映像标签来描述特定平台。例如,我们可以使用 merchant:v2.0.3-aarch64 而不是 merchant:v2.0.3。尽管这种方法可能看起来最容易实现,但实际上有点问题。

不仅需要更改构建过程以在标记过程中包含架构。在拉取映像以运行它们时,还必须手动在所有地方添加预期的后缀。如果使用编排器,将无法以直接的方式在��同平台之间共享清单,因为标签将是特定于平台的。

一种更好的方法,不需要修改部署步骤,是使用 manifest-toolhttps://github.com/estesp/manifest-tool)。首先,构建过程看起来与之前建议的类似。映像在所有支持的架构上分别构建,并带有标签中的平台后缀推送到注册表。在所有映像都推送后,manifest-tool 合并映像以提供单个多架构映像。这样,每个支持的平台都能使用完全相同的标签。

这里提供了 manifest-tool 的示例配置:

image: hosacpp/merchant:v2.0.3
manifests:
  - image: hosacpp/merchant:v2.0.3-amd64
    platform:
      architecture: amd64
      os: linux
  - image: hosacpp/merchant:v2.0.3-arm32
    platform:
      architecture: arm
      os: linux
  - image: hosacpp/merchant:v2.0.3-arm64
    platform:
      architecture: arm64
      os: linux

在这里,我们有三个支持的平台,每个平台都有其相应的后缀(hosacpp/merchant:v2.0.3-amd64hosacpp/merchant:v2.0.3-arm32hosacpp/merchant:v2.0.3-arm64)。Manifest-tool将为每个平台构建的镜像合并,并生成一个hosacpp/merchant:v2.0.3镜像,我们可以在任何地方使用。

另一种可能性是使用 Docker 内置的名为 Buildx 的功能。使用 Buildx,你可以附加多个构建器实例,每个实例针对所需的架构。有趣的是,你不需要本机机器来运行构建;你还可以在多阶段构建中使用 QEMU 模拟或交叉编译。尽管它比之前的方法更强大,但 Buildx 也相当复杂。在撰写本文时,它需要 Docker 实验模式和 Linux 内核 4.8 或更高版本。你需要设置和管理构建器,并且并非所有功能都以直观的方式运行。它可能会在不久的将来改进并变得更加稳定。

准备构建环境并构建多平台镜像的示例代码可能如下所示:

# create two build contexts running on different machines
docker context create \
    --docker host=ssh://docker-user@host1.domifair.org \
    --description="Remote engine amd64" \
    node-amd64
docker context create \
    --docker host=ssh://docker-user@host2.domifair.org \
    --description="Remote engine arm64" \
    node-arm64

# use the contexts
docker buildx create --use --name mybuild node-amd64
docker buildx create --append --name mybuild node-arm64

# build an image
docker buildx build --platform linux/amd64,linux/arm64 .

正如你所看到的,如果你习惯于常规的docker build命令,这可能会有点令人困惑。

构建应用程序容器的替代方法

使用 Docker 构建容器镜像需要 Docker 守护程序运行。Docker 守护程序需要 root 权限,在某些设置中可能会带来安全问题。即使进行构建的 Docker 客户端可能由非特权用户运行,但在构建环境中安装 Docker 守护程序并非总是可行。

Buildah

Buildah 是一个替代工具,可以配置为在没有 root 访问权限的情况下运行。Buildah 可以使用常规的 Dockerfile,我们之前讨论过。它还提供了自己的命令行界面,你可以在 shell 脚本或其他更直观的自动化中使用。将之前的 Dockerfile 重写为使用 buildah 接口的 shell 脚本之一将如下所示:

#!/bin/sh

ctr=$(buildah from ubuntu:bionic)

buildah run $ctr -- /bin/sh -c 'apt-get update && apt-get install -y build-essential gcc'

buildah config --cmd '/usr/bin/gcc' "$ctr"

buildah commit "$ctr" hosacpp-gcc

buildah rm "$ctr"

Buildah 的一个有趣特性是它允许你将容器镜像文件系统挂载到主机文件系统中。这样,你可以使用主机的命令与镜像的内容进行交互。如果你有一些不想(或者由于许可限制而无法)放入容器中的软件,使用 Buildah 时仍然可以在容器外部调用它。

Ansible-bender

Ansible-bender 使用 Ansible playbooks 和 Buildah 来构建容器镜像。所有配置,包括基本镜像和元数据,都作为 playbook 中的变量传递。以下是我们之前的示例转换为 Ansible 语法的示例:

---
- name: Container image with ansible-bender
  hosts: all
  vars:
    ansible_bender:
      base_image: python:3-buster

      target_image:
        name: hosacpp-gcc
        cmd: /usr/bin/gcc
  tasks:
  - name: Install Apt packages
    apt:
      pkg:
        - build-essential
        - gcc

正如你所看到的,ansible_bender变量负责所有与容器特定配置相关的内容。下面呈现的任务在基于base_image的容器内执行。

需要注意的一点是,Ansible 需要基本镜像中存在 Python 解释器。这就是为什么我们不得不将在之前的示例中使用的ubuntu:bionic更改为python:3-busterubuntu:bionic是一个没有预安装 Python 解释器的 Ubuntu 镜像。

其他

还有其他构建容器镜像的方法。你可以使用 Nix 创建文件系统镜像,然后使用 Dockerfile 的COPY指令将其放入镜像中,例如。更进一步,你可以通过任何其他方式准备文件系统镜像,然后使用docker import将其导入为基本容器镜像。

选择符合你特定需求的解决方案。请记住,使用docker build使用 Dockerfile 进行构建是最流行的方法,因此它是最有文档支持的。使用 Buildah 更加灵活,可以更好地将创建容器镜像融入到构建过程中。最后,如果你已经在 Ansible 中投入了大量精力,并且想要重用已有的模块,ansible-bender可能是一个不错的解决方案。

将容器与 CMake 集成

在这一部分,我们将演示如何通过使用 CMake 来创建 Docker 镜像。

使用 CMake 配置 Dockerfile

首先,我们需要一个 Dockerfile。让我们使用另一个 CMake 输入文件来实现这一点:

configure_file(${CMAKE_CURRENT_SOURCE_DIR}/Dockerfile.in
                ${PROJECT_BINARY_DIR}/Dockerfile @ONLY)

请注意,我们使用PROJECT_BINARY_DIR来避免覆盖源树中其他项目创建的 Dockerfile,如果我们的项目是更大项目的一部分。

我们的Dockerfile.in文件将如下所示:

FROM ubuntu:latest
ADD Customer-@PROJECT_VERSION@-Linux.deb .
RUN apt-get update && \
    apt-get -y --no-install-recommends install ./Customer-@PROJECT_VERSION@-Linux.deb && \
    apt-get autoremove -y && \
    apt-get clean && \
    rm -r /var/lib/apt/lists/* Customer-@PROJECT_VERSION@-Linux.deb
ENTRYPOINT ["/usr/bin/customer"]
EXPOSE 8080

首先,我们指定我们将使用最新的 Ubuntu 镜像,在其中安装我们的 DEB 包及其依赖项,然后进行整理。在安装软件包的同时更新软件包管理器缓存是很重要的,以避免由于 Docker 层的工作方式而导致的旧缓存问题。清理也作为相同的RUN命令的一部分进行(在同一层),以使层大小更小。安装软件包后,我们让我们的镜像在启动时运行customer微服务。最后,我们告诉 Docker 暴露它将监听的端口。

现在,回到我们的CMakeLists.txt文件。

将容器与 CMake 集成

对于基于 CMake 的项目,可以包含一个负责构建容器的构建步骤。为此,我们需要告诉 CMake 找到 Docker 可执行文件,并在找不到时退出。我们可以使用以下方法来实现:

find_program(Docker_EXECUTABLE docker)
 if(NOT Docker_EXECUTABLE)
   message(FATAL_ERROR "Docker not found")
 endif()

让我们重新访问第七章中的一个示例,构建和打包。在那里,我们为客户应用程序构建了一个二进制文件和一个 Conan 软件包。现在,我们希望将这个应用程序打包为一个 Debian 存档,并构建一个预安装软件包的 Debian 容器镜像,用于客户应用程序。

为了创建我们的 DEB 软件包,我们需要一个辅助目标。让我们使用 CMake 的add_custom_target功能来实现这一点:

add_custom_target(
   customer-deb
   COMMENT "Creating Customer DEB package"
   COMMAND ${CMAKE_CPACK_COMMAND} -G DEB
   WORKING_DIRECTORY ${PROJECT_BINARY_DIR}
   VERBATIM)
 add_dependencies(customer-deb libcustomer)

我们的目标调用 CPack 来创建我们感兴趣的一个软件包,并省略其余的软件包。我们希望软件包在与 Dockerfile 相同的目录中创建,以方便起见。推荐使用VERBATIM关键字,因为使用它,CMake 将转义有问题的字符。如果未指定,您的脚本的行为可能会因不同平台而异。

add_dependencies调用将确保在 CMake 构建customer-deb目标之前,libcustomer已经构建。现在我们有了辅助目标,让我们在创建容器镜像时使用它:

add_custom_target(
   docker
   COMMENT "Preparing Docker image"
   COMMAND ${Docker_EXECUTABLE} build ${PROJECT_BINARY_DIR}
           -t dominicanfair/customer:${PROJECT_VERSION} -t dominicanfair/customer:latest
   VERBATIM)
 add_dependencies(docker customer-deb)

如您所见,我们调用了我们之前在包含我们的 Dockerfile 和 DEB 软件包的目录中找到的 Docker 可执行文件,以创建一个镜像。我们还告诉 Docker 将我们的镜像标记为最新版本和我们项目的版本。最后,我们确保在调用我们的 Docker 目标时将构建 DEB 软件包。

如果您选择的生成器是make,那么构建镜像就像make docker一样简单。如果您更喜欢完整的 CMake 命令(例如,为了创建与生成器无关的脚本),那么调用是cmake --build . --target docker

测试和集成容器

容器非常适合 CI/CD 流水线。由于它们大多数情况下除了容器运行时本身不需要其他依赖项,因此它们可以很容易地进行测试。工作机器不必被配置以满足测试需求,因此添加更多节点更容易。而且,它们���是通用的,因此它们可以充当构建者、测试运行者,甚至是部署执行者,而无需任何先前的配置。

CI/CD中使用容器的另一个巨大好处是它们彼此隔离。这意味着在同一台机器上运行的多个副本不应该相互干扰。这是真的,除非测试需要一些来自主机操作系统的资源,例如端口转发或卷挂载。因此最好设计测试,使这些资源不是必需的(或者至少它们不会发生冲突)。端口随机化是一种有用的技术,可以避免冲突,例如。

容器内的运行时库

容器的选择可能会影响工具链的选择,因此也会影响应用程序可用的 C++语言特性。由于容器通常基于 Linux,可用的系统编译器通常是带有 glibc 标准库的 GNU GCC。然而,一些流行的用于容器的 Linux 发行版,如 Alpine Linux,基于不同的标准库 musl。

如果你的目标是这样的发行版,确保你将要使用的代码,无论是内部开发的还是来自第三方提供者,都与 musl 兼容。musl 和 Alpine Linux 的主要优势是它们可以生成更小的容器镜像。例如,为 Debian Buster 构建的 Python 镜像约为 330MB,精简版的 Debian 版本约为 40MB,而 Alpine 版本仅约为 16MB。更小的镜像意味着更少的带宽浪费(用于上传和下载)和更快的更新。

Alpine 可能也会引入一些不需要的特性,比如更长的构建时间、隐晦的错误或性能降低。如果你想使用它来减小大小,务必进行适当的测试,确保应用程序没有问题。

为了进一步减小镜像的大小,你可以考虑放弃底层操作系统。这里所说的操作系统是指通常存在于容器中的所有用户空间工具,如 shell、包管理器和共享库。毕竟,如果你的应用是唯一要运行的东西,其他一切都是不必要的。

Go 或 Rust 应用程序通常提供一个自包含的静态构建,可以形成一个容器镜像。虽然在 C++中可能不那么直接,但也值得考虑。

减小镜像大小也有一些缺点。首先,如果你决定使用 Alpine Linux,请记住它不像 Ubuntu、Debian 或 CentOS 那样受欢迎。尽管它经常是容器开发者的首选平台,但对于其他用途来说非常不寻常。

这意味着可能会出现新的兼容性问题,主要源自它不是基于事实上的标准 glibc 实现。如果你依赖第三方组件,提供者可能不会为这个平台提供支持。

如果你决定采用容器镜像中的单个静态链接二进制文件路线,也有一些挑战需要考虑。首先,你不建议静态链接 glibc,因为它内部使用 dlopen 来处理Name Service Switch(NSS)和 iconv。如果你的软件依赖于 DNS 解析或字符集转换,你仍然需要提供 glibc 和相关库的副本。

另一个需要考虑的问题是,通常会使用 shell 和包管理器来调试行为异常的容器。当你的某个容器表现出奇怪的行为时,你可以在容器内启动另一个进程,并通过使用诸如pslscat等标准 UNIX 工具来弄清楚容器内部发生了什么。要在容器内运行这样的应用程序,它必须首先存在于容器镜像中。一些解决方法允许操作员在运行的容器内注入调试二进制文件,但目前没有一个得到很好的支持。

替代容器运行时

Docker 是构建和运行容器的最流行方式,但由于容器标准是开放的,也有其他可供选择的运行时。用于替代 Docker 并提供类似用户体验的主要工具是 Podman。与前一节中描述的 Buildah 一起,它们是旨在完全取代 Docker 的工具。

它们的另一个好处是不需要在主机上运行额外的守护程序,就像 Docker 一样。它们两者也都支持(尽管尚不成熟)无根操作,这使它们更适合安全关键操作。Podman 接受您期望 Docker CLI 执行的所有命令,因此您可以简单地将其用作别名。

另一种旨在提供更好安全性的容器方法是Kata Containers倡议。Kata Containers 使用轻量级虚拟机来利用硬件虚拟化,以在容器和主机操作系统之间提供额外的隔离级别。

Cri-O 和 containerd 也是 Kubernetes 使用的流行运行时。

理解容器编排

一些容器的好处只有在使用容器编排器来管理它们时才会显现出来。编排器会跟踪将运行您的工作负载的所有节点,并监视这些节点上分布的容器的健康和状态。

例如,高可用性等更高级的功能需要正确设置编排器,通常意味着至少要为控制平面专门分配三台机器,另外还需要为工作节点分配三台机器。节点的自动缩放,以及容器的自动缩放,还需要编排器具有能够控制底层基础设施的驱动程序(例如,通过使用云提供商的 API)。

在这里,我们将介绍一些最受欢迎的编排器,您可以选择其中一个作为系统的基础。您将在下一章Kubernetes中找到更多关于 Kubernetes 的实用信息,云原生设计。在这里,我们给您一个可能的选择概述。

所提供的编排器操作类似的对象(服务、容器、批处理作业),尽管每个对象的行为可能不同。可用的功能和操作原则在它们之间也有所不同。它们的共同之处在于,通常您会编写一个配置文件,以声明方式描述所需的资源,然后使用专用的 CLI 工具应用此配置。为了说明工具之间的差异,我们提供了一个示例配置,指定了之前介绍的一个 Web 应用程序(商家服务)和一个流行的 Web 服务器 Nginx 作为代理。

自托管解决方案

无论您是在本地运行应用程序,还是在私有云或公共云中运行,您可能希望对所选择的编排器有严格的控制。以下是这个领域中的一些自托管解决方案。请记住,它们中的大多数也可以作为托管服务提供。但是,选择自托管可以帮助您防止供应商锁定,这可能对您的组织是可取的。

Kubernetes

Kubernetes 可能是我们在这里提到的所有编排器中最为人所知的。它很普遍,这意味着如果您决定实施它,将会有很多文档和社区支持。

尽管 Kubernetes 使用与 Docker 相同的应用程序容器格式,但基本上这就是所有相似之处的结束。不可能使用标准的 Docker 工具直接与 Kubernetes 集群和资源进行交互。在使用 Kubernetes 时,需要学习一套新的工具和概念。

与 Docker 不同,容器是您将操作的主要对象,而在 Kubernetes 中,运行时的最小单元称为 Pod。Pod 可能由一个或多个共享挂载点和网络资源的容器组成。Pod 本身很少引起兴趣,因为 Kubernetes 还具有更高级的概念,如复制控制器、部署控制器或守护进程集。它们的作用是跟踪 Pod 并确保节点上运行所需数量的副本。

Kubernetes 中的网络模型也与 Docker 非常不同。在 Docker 中,您可以将容器的端口转发,使其可以从不同的机器访问。在 Kubernetes 中,如果要访问一个 pod,通常会创建一个 Service 资源,它可以作为负载均衡器来处理指向服务后端的流量。服务可以用于 pod 之间的通信,也可以暴露给互联网。在内部,Kubernetes 资源使用 DNS 名称执行服务发现。

Kubernetes 是声明性的,最终一致的。这意味着您不必直接创建和分配资源,只需提供所需最终状态的描述,Kubernetes 将完成将集群带到所需状态所需的工作。资源通常使用 YAML 描述。

由于 Kubernetes 具有高度的可扩展性,因此在Cloud Native Computing FoundationCNCF)下开发了许多相关项目,将 Kubernetes 转变为一个与提供商无关的云开发平台。我们将在下一章第十五章中更详细地介绍 Kubernetes,云原生设计

以下是使用 YAML(merchant.yaml)在 Kubernetes 中的资源定义方式:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: dominican-front
  name: dominican-front
spec:
  selector:
    matchLabels:
      app: dominican-front
  template:
    metadata:
      labels:
        app: dominican-front
    spec:
      containers:
        - name: webserver
          imagePullPolicy: Always
          image: nginx
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: dominican-front
  name: dominican-front
spec:
  ports:
    - port: 80
      protocol: TCP
      targetPort: 80
  selector:
    app: dominican-front
  type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: dominican-merchant
  name: merchant
spec:
  selector:
    matchLabels:
      app: dominican-merchant
  replicas: 3
  template:
    metadata:
      labels:
        app: dominican-merchant
    spec:
      containers:
        - name: merchant
          imagePullPolicy: Always
          image: hosacpp/merchant:v2.0.3
          ports:
            - name: http
              containerPort: 8000
              protocol: TCP
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: dominican-merchant
  name: merchant
spec:
  ports:
    - port: 80
      protocol: TCP
      targetPort: 8000
  selector:
    app: dominican-merchant
    type: ClusterIP

要应用此配置并编排容器,请使用kubectl apply -f merchant.yaml

Docker Swarm

Docker 引擎,也需要构建和运行 Docker 容器,预装有自己的编排器。这个编排器是 Docker Swarm,其主要特点是通过使用 Docker API 与现有的 Docker 工具高度兼容。

Docker Swarm 使用服务的概念来管理健康检查和自动扩展。它原生支持服务的滚动升级。服务能够发布它们的端口,然后由 Swarm 的负载均衡器提供服务。它支持将配置存储为对象以进行运行时自定义,并内置了基本的秘密管理。

Docker Swarm 比 Kubernetes 简单得多,可扩展性较差。如果您不想了解 Kubernetes 的所有细节,这可能是一个优势。然而,主要的缺点是缺乏流行度,这意味着更难找到有关 Docker Swarm 的相关材料。

使用 Docker Swarm 的好处之一是您不必学习新的命令。如果您已经习惯了 Docker 和 Docker Compose,Swarm 可以使用相同的资源。它允许特定选项扩展 Docker 以处理部署。

使用 Swarm 编排的两个服务看起来像这样(docker-compose.yml):

version: "3.8"
services:
  web:
    image: nginx
    ports:
      - "80:80"
    depends_on:
      - merchant
  merchant:
    image: hosacpp/merchant:v2.0.3
    deploy:
      replicas: 3
    ports:
      - "8000"

应用配置时,您可以运行docker stack deploy --compose-file docker-compose.yml dominican

Nomad

Nomad 与前两种解决方案不同,因为它不仅专注于容器。它是一个通用的编排器,支持 Docker、Podman、Qemu 虚拟机、隔离的 fork/exec 和其他几种任务驱动程序。如果您想获得容器编排的一些优势而不将应用迁移到容器中,那么了解 Nomad 是值得的。

它相对容易设置,并且与其他 HashiCorp 产品(如 Consul 用于服务发现和 Vault 用于秘密管理)很好地集成。与 Docker 或 Kubernetes 一样,Nomad 客户端可以在本地运行,并连接到负责管理集群的服务器。

Nomad 有三种作业类型可用:

  • 服务:一个不应该在没有手动干预的情况下退出的长期任务(例如,Web 服务器或数据库)。

  • 批处理:一个较短寿命的任务,可以在几分钟内完成。如果批处理作业返回指示错误的退出代码,则根据配置重新启动或重新调度。

  • 系统:必须在集群中的每个节点上运行的任务(例如,日志代理)。

与其他编排器相比,Nomad 在安装和维护方面相对容易。在任务驱动程序或设备插件(用于访问专用硬件,如 GPU 或 FPGA)方面也是可扩展的。与 Kubernetes 相比,Nomad 在社区支持和第三方集成方面欠缺。Nomad 不需要您重新设计应用程序的架构以获得提供的好处,而这在 Kubernetes 中经常发生。

要使用 Nomad 配置这两个服务,我们需要两个配置文件。第一个是nginx.nomad

job "web" {
  datacenters = ["dc1"]
  type = "service"
  group "nginx" {
    task "nginx" {
      driver = "docker"
      config {
        image = "nginx"
        port_map {
          http = 80
        }
      }
      resources {
        network {
          port "http" {
              static = 80
          }
        }
      }
      service {
        name = "nginx"
        tags = [ "dominican-front", "web", "nginx" ]
        port = "http"
        check {
          type = "tcp"
          interval = "10s"
          timeout = "2s"
        }
      }
    }
  }
}

第二个描述了商户应用程序,因此被称为merchant.nomad

job "merchant" {
  datacenters = ["dc1"]
  type = "service"
  group "merchant" {
    count = 3
    task "merchant" {
      driver = "docker"
      config {
        image = "hosacpp/merchant:v2.0.3"
        port_map {
          http = 8000
        }
      }
      resources {
        network {
          port "http" {
              static = 8000
          }
        }
      }
      service {
        name = "merchant"
        tags = [ "dominican-front", "merchant" ]
        port = "http"
        check {
          type = "tcp"
          interval = "10s"
          timeout = "2s"
        }
      }
    }
  }
}

要应用配置,您需要运行nomad job run merchant.nomad && nomad job run nginx.nomad

OpenShift

OpenShift 是红帽的基于 Kubernetes 构建的商业容器平台。它包括许多在 Kubernetes 集群的日常运营中有用的附加组件。您将获得一个容器注册表,一个类似 Jenkins 的构建工具,用于监控的 Prometheus,用于服务网格的 Istio 和用于跟踪的 Jaeger。它与 Kubernetes 不完全兼容,因此不应将其视为可直接替换的产品。

它是建立在现有的红帽技术之上,如 CoreOS 和红帽企业 Linux。您可以在本地使用它,在红帽云中使用它,在受支持的公共云提供商之一(包括 AWS、GCP、IBM 和 Microsoft Azure)中使用它,或者作为混合云使用。

还有一个名为 OKD 的开源社区支持项目,它是红帽 OpenShift 的基础。如果您不需要商业支持和 OpenShift 的其他好处,仍然可以在 Kubernetes 工作流程中使用 OKD。

托管服务

如前所述,一些前述的编排器也可以作为托管服务提供。例如,Kubernetes 可以作为多个公共云提供商的托管解决方案。本节将向您展示一些不基于上述任何解决方案的容器编排的不同方法。

AWS ECS

在 Kubernetes 发布其 1.0 版本之前,亚马逊网络服务提出了自己的容器编排技术,称为弹性容器服务(ECS)。ECS 提供了一个编排器,可以在需要时监视、扩展和重新启动您的服务。

要在 ECS 中运行容器,您需要提供工作负载将运行的 EC2 实例。您不需要为编排器的使用付费,但您需要为通常使用的所有 AWS 服务付费(例如底层的 EC2 实例或 RDS 数据库)。

ECS 的一个重要优势是其与 AWS 生态系统的出色集成。如果您已经熟悉 AWS 服务并投资于该平台,您将更容易理解和管理 ECS。

如果您不需要许多 Kubernetes 高级功能和其扩展功能,ECS 可能是更好的选择,因为它更直接,更容易学习。

AWS Fargate

AWS 还提供了另一个托管的编排器 Fargate。与 ECS 不同,它不需要您为底层的 EC2 实例进行配置和付费。您需要关注的唯一组件是容器、与其连接的网络接口和 IAM 权限。

与其他解决方案相比,Fargate 需要的维护量最少,也是最容易学习的。由于现有的 AWS 产品在这一领域已经提供了自动扩展和负载平衡功能。

这里的主要缺点是与 ECS 相比,您为托管服务支付的高额费用。直接比较是不可能的,因为 ECS 需要支付 EC2 实例的费用,而 Fargate 需要独立支付内存和 CPU 使用费用。对集群缺乏直接控制可能会导致一旦服务开始自动扩展就会产生高昂的成本。

Azure Service Fabric

所有先前解决方案的问题在于它们大多针对首先是 Linux 中心的 Docker 容器。另一方面,Azure Service Fabric 是由微软支持的首先是 Windows 的产品。它可以在不修改的情况下运行传统的 Windows 应用程序,这可能有助于您迁移应用程序,如果它依赖于这些服务。

与 Kubernetes 一样,Azure Service Fabric 本身并不是一个容器编排器,而是一个平台,您可以在其上构建应用程序。其中一个构建块恰好是容器,因此它作为编排器运行良好。

随着 Azure Kubernetes Service 的最新推出,这是 Azure 云中的托管 Kubernetes 平台,使用 Service Fabric 的需求减少了。

总结

当您是现代软件的架构师时,您必须考虑现代技术。考虑它们并不意味着盲目地追随潮流;它意味着能够客观地评估特定建议是否在您的情况下有意义。

在前几章中介绍的微服务和本章介绍的容器都值得考虑和理解。它们是否值得实施?这在很大程度上取决于您正在设计的产品类型。如果您已经读到这里,那么您已经准备好自己做出决定了。

下一章专门讨论云原生设计。这是一个非常有趣但也复杂的主题,涉及面向服务的架构、CI/CD、微服务、容器和云服务。事实证明,C++的出色性能是一些云原生构建块的受欢迎特性。

问题

  1. 应用程序容器与操作系统容器有何不同?

  2. UNIX 系统中一些早期的沙盒环境示例是什么?

  3. 为什么容器非常适合微服务?

  4. 容器和虚拟机之间的主要区别是什么?

  5. 应用程序容器何时不是一个好选择?

  6. 有哪些构建多平台容器映像的工具?

  7. 除了 Docker,还有哪些其他容器运行时?

  8. 一些流行的编排器是什么?

进一步阅读

第十五章:云原生设计

正如其名称所示,云原生设计描述了首先建立在云中运行的应用程序架构。它不是由单一技术或语言定义的,而是充分利用现代云平台所提供的一切。

这可能意味着在必要时结合使用平台即服务PaaS),多云部署,边缘计算,函数即服务FaaS),静态文件托管,微服务和托管服务。它超越了传统操作系统的边界。云原生开发人员不再针对 POSIX API 和类 UNIX 操作系统,而是使用诸如 boto3、Pulumi 或 Kubernetes 等库和框架构建更高级别的概念。

本章将涵盖以下主题:

  • 理解云原生

  • 使用 Kubernetes 编排云原生工作负载

  • 使用服务网格连接服务

  • 分布式系统中的可观察性

  • 采用 GitOps

通过本章结束时,您将对如何在应用程序中使用软件架构的现代趋势有很好的理解。

技术要求

本章中的一些示例需要 Kubernetes 1.18。

本章中的代码已放置在 GitHub 上github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter15

理解云原生

虽然可以将现有应用程序迁移到云中运行,但这种迁移不会使应用程序成为云原生。它可能在云中运行,但架构选择仍然基于本地模型。

简而言之,云原生应用程序通常是分布式的,松散耦合的,并且可扩展的。它们不受特定的物理基础设施约束,也不需要开发人员考虑特定的基础设施。这类应用程序通常是面向 Web 的。

在本章中,我们将介绍一些云原生构建模块的示例,并描述一些云原生模式。

云原生计算基金会

云原生设计的支持者之一是Cloud Native Computing FoundationCNCF),它托管了 Kubernetes 项目。CNCF 拥有各种技术,使得更容易构建与云供应商无关的云原生应用程序。此类技术的示例包括以下内容:

  • Fluentd,统一的日志记录层

  • Jaeger,用于分布式跟踪

  • Prometheus,用于监控

  • CoreDNS,用于服务发现

云原生应用程序通常使用应用程序容器构建,通常在 Kubernetes 平台上运行。但这不是必需的,完全可以在 Kubernetes 和容器之外使用许多 CNCF 框架。

云作为操作系统

云原生设计的主要特点是将各种云资源视为应用程序的构建模块。在云原生设计中,很少使用单独的虚拟机VMs)。与针对在某些实例上运行的特定操作系统相反,在云原生方法中,您要么直接针对云 API(例如使用 FaaS),要么针对 Kubernetes 等中间解决方案。在这种意义上,云成为您的操作系统,因为 POSIX API 不再限制您。

随着容器改变了构建和分发软件的方式,现在可以摆脱对基础硬件基础设施的思考。您的软件并非在孤立运行,因此仍然需要连接不同的服务,监视它们,控制它们的生命周期,存储数据或传递秘密。这是 Kubernetes 提供的功能之一,也是它变得如此受欢迎的原因之一。

您可能可以想象,云原生应用程序是面向 Web 和移动设备的。桌面应用程序也可以从具有一些云原生组件中受益,但这是一个不太常见的用例。

在云原生应用程序中仍然可以使用硬件和其他低级访问。如果您的工作负载需要使用 GPU,这不应该阻止您进行云原生。而且,如果您想要访问其他地方无法获得的自定义硬件,云原生应用程序也可以在本地构建。这个术语不仅限于公共云,而是一种思考不同资源的方式。

负载平衡和服务发现

负载平衡是分布式应用程序的重要组成部分。它不仅可以将传入的请求分散到一组服务中,这对于扩展至关重要,还可以帮助应用程序的响应性和可用性。智能负载均衡器可以收集指标以应对传入流量的模式,监视其集群中服务器的状态,并将请求转发到负载较轻和响应更快的节点,避免当前不健康的节点。

负载平衡带来更高的吞吐量和更少的停机时间。通过将请求转发到多个服务器,消除了单点故障,尤其是如果使用多个负载均衡器,例如在主备方案中。

负载均衡器可以在架构的任何地方使用:您可以平衡来自 Web 的请求,Web 服务器对其他服务的请求,对缓存或数据库服务器的请求,以及其他满足您需求的请求。

在引入负载平衡时有一些事项需要记住。其中之一是会话持久性——确保来自同一客户的所有请求都发送到同一服务器,这样精心选择的粉红色高跟鞋就不会从他们的购物篮中消失在您的电子商务网站上。负载均衡可能会让会话变得棘手:要特别小心,不要混淆会话,这样客户就不会突然开始被登录到彼此的个人资料中——许多公司在这方面出现过错误,尤其是在添加缓存时。将两者结合起来是一个好主意;只要确保它是正确的方式。

反向代理

即使您只想部署一个服务器实例,将另一个服务添加到负载均衡器之前,而不是负载均衡器,即反向代理,可能是一个好主意。虽然代理通常代表客户端发送一些请求,但反向代理代表处理这些请求的服务器,因此得名。

你问为什么要使用它?有几个原因和用途可以使用这样的代理:

  • 安全性:您的服务器地址现在被隐藏,服务器可以受到代理的 DDoS 防护能力的保护。

  • 灵活性和可扩展性:您可以以任何您想要的方式和时间修改代理后面隐藏的基础设施。

  • 缓���:如果您已经知道服务器将给出什么答案,为什么还要打扰服务器呢?

  • 压缩:压缩数据将减少所需的带宽,这对于连接质量差的移动用户可能特别有用。它还可以降低您的网络成本(但可能会消耗计算能力)。

  • SSL 终止:通过接管加密和解密网络流量的负担,减轻后端服务器的负载。

反向代理的一个例子是NGINX。它还提供负载平衡能力、A/B 测试等等。它的另一个能力是服务发现。让我们看看它如何有帮助。

服务发现

正如其名称所示,服务发现SD)允许自动检测计算机网络中特定服务的实例。调用者不必硬编码服务应该托管的域名或 IP,而只需指向服务注册表。使用这种方法,您的架构变得更加灵活,因为现在您使用的所有服务都可以很容易地找到。如果您设计了基于微服务的架构,引入 SD 确实可以大有作为。

有几种 SD 的方法。在客户端发现中,调用者直接联系 SD 实例。每个服务实例都有一个注册表客户端,用于注册和注销实例,处理心跳等。虽然相当直接,但在这种方法中,每个客户端都必须实现服务发现逻辑。Netflix Eureka 是在这种方法中常用的服务注册表的一个例子。

另一种方法是使用服务器端发现。在这种情况下,服务注册表也存在,并且每个服务实例中都有注册表客户端。但是,调用者不直接联系它。相反,他们连接到负载均衡器,例如 AWS 弹性负载均衡器,然后再调用服务注册表或使用其内置服务注册表,然后将客户端调用分派到特定实例。除了 AWS ELB,还可以使用 NGINX 和 Consul 来提供服务器端 SD 功能。

我们现在知道如何高效地找到和使用我们的服务,那么让我们学习如何最好地部署它们。

使用 Kubernetes 编排云原生工作负载

Kubernetes 是一个可扩展的开源平台,用于自动化和管理容器应用程序。有时被称为 k8s,因为它以'k'开头,以's'结尾,在中间有八个字母。

其设计基于 Borg,这是 Google 内部使用的系统。Kubernetes 中的一些功能包括:

  • 应用程序的自动扩展

  • 可配置的网络

  • 批处理作业执行

  • 应用程序的统一升级

  • 在其上运行高可用性应用程序的能力

  • 声明性配置

在组织中运行 Kubernetes 有不同的方式。选择其中一种需要您分析与其相关的额外成本和收益。

Kubernetes 结构

虽然可以在单台机器上运行 Kubernetes(例如使用 minikube、k3s 或 k3d),但不建议在生产环境中这样做。单机集群功能有限,没有故障转移机制。Kubernetes 集群的典型大小是六台或更多。其中三台机器组成控制平面。另外三台是工作节点。

三台机器的最低要求来自于这是提供高可用性的最小数量。控制平面节点也可以作为工作节点可用,尽管这并不被鼓励。

控制平面

在 Kubernetes 中,您很少与单个工作节点进行交互。相反,所有 API 请求都发送到控制平面。然后,控制平面根据请求决定要采取的操作,然后与工作节点通信。

与控制平面的交互可以采取多种形式:

  • 使用 kubectl CLI

  • 使用 Web 仪表板

  • 从应用程序内部使用 Kubernetes API 而不是 kubectl

控制平面节点通常运行 API 服务器、调度器、配置存储(etcd)以及可能处理特定需求的其他一些附加进程。例如,在 Google Cloud Platform 等公共云中部署的 Kubernetes 集群上,控制平面节点上运行云控制器。云控制器与云提供商的 API 交互,以替换失败的机器、提供负载均衡器或分配外部 IP 地址。

工作节点

构成控制平面和工作节点池的节点是实际运行工作负载的机器。它们可以是你在本地托管的物理服务器,私有托管的 VM,或者来自你的云提供商的 VM。

集群中的每个节点至少运行以下三个程序:

  • 容器运行时(例如 Docker Engine 或 cri-o),允许机器处理应用程序容器

  • kubelet,负责接收来自控制平面的请求,并根据这些请求管理单个容器

  • kube-proxy,负责节点级别的网络和负载平衡

部署 Kubernetes 的可能方法

正如你从阅读前一节中所了解的,部署 Kubernetes 有不同的可能方式。

其中一种方法是将其部署到本地托管的裸金属服务器上。其中一个好处是对于大规模应用程序来说,这可能比云提供商提供的更便宜。这种方法有一个主要缺点——当需要时,你将需要操作员提供额外的节点。

为了缓解这个问题,你可以在裸金属服务器上运行一个虚拟化设备。这样就可以使用 Kubernetes 内置的云控制器自动提供必要的资源。你仍然可以控制成本,但手动工作会减少。虚拟化会增加一些开销,但在大多数情况下,这应该是一个公平的权衡。

如果你不想自己托管服务器,你可以部署 Kubernetes 在云提供商的 VM 上运行。通过选择这种方式,你可以使用一些现有的模板进行最佳设置。在流行的云平台上有 Terraform 和 Ansible 模块可用于构建集群。

最后,主要云服务提供商提供的托管服务。在其中一些服务中,你只需要为工作节点付费,而控制平面是免费的。

在公共云中运行时,为什么会选择自托管的 Kubernetes 而不是托管服务?其中一个原因可能是你需要的特定版本的 Kubernetes。当涉及到引入更新时,云提供商通常会有些慢。

理解 Kubernetes 的概念

Kubernetes 引入了一些概念,如果你第一次听到它们可能会感到陌生或困惑。当你了解它们的目的时,就会更容易理解 Kubernetes 的特殊之处。以下是一些最常见的 Kubernetes 对象:

  • 容器,特别是应用容器,是一种分发和运行单个应用程序的方法。它包含了在任何地方运行未经修改的应用程序所需的代码和配置。

  • Pod是基本的 Kubernetes 构建块。它是原子的,由一个或多个容器组成。Pod 中的所有容器共享相同的网络接口、卷(如持久存储或秘密)和资源(CPU 和内存)。

  • 部署是一个描述工作负载及其生命周期特性的高级对象。它通常管理一组 pod 副本,允许滚动升级,并在失败时管理回滚。这使得扩展和管理 Kubernetes 应用程序的生命周期变得容易。

  • DaemonSet是一个类似于部署的控制器,它管理 pod 的分布位置。部署关注保持给定数量的副本,而 DaemonSets 将 pod 分布在所有工作节点上。主要用例是在每个节点上运行系统级服务,比如监控或日志代理。

  • Jobs设计用于一次性任务。部署中的 pod 在其中的容器终止时会自动重新启动。它们适用于所有始终开启的服务,以便监听网络端口的请求。但是,部署不适用于批处理作业,例如缩略图生成,您只希望在需要时运行。作业创建一个或多个 pod,并监视它们直到完成给定的任务。当特定数量的成功 pod 终止时,作业被视为完成。

  • CronJobs,顾名思义,是定期在集群中运行的作业。

  • 服务代表集群中执行的特定功能。它们有与之关联的网络端点(通常是负载平衡)。服务可以由一个或多个 pod 执行。服务的生命周期独立于许多 pod 的生命周期。由于 pod 是瞬时的,它们可以随时创建和销毁。服务将个体 pod 抽象出来,以实现高可用性。服务具有自己的 IP 地址和 DNS 名称,以便使用。

声明性方法

我们在第九章中已经介绍了声明性和命令式方法之间的区别,持续集成/持续部署。Kubernetes 采用声明性方法。您不是提供关于需要采取的步骤的指示,而是提供描述集群所需状态的资源。由控制平面来分配内部资源,以满足您的需求。

可以直接使用命令行添加资源。这对于测试可能很快,但大多数时候您希望有您创建的资源的记录。因此,大多数人使用清单文件,这些文件提供所需资源的编码描述。清单通常是 YAML 文件,但也可以使用 JSON。

这是一个具有单个 Pod 的示例 YAML 清单:

apiVersion: v1

kind: Pod

metadata:

  name: simple-server

  labels:

    app: dominican-front

spec:

  containers:

    - name: webserver

      image: nginx

      ports:

        - name: http

          containerPort: 80

          protocol: TCP

第一行是必需的,它告诉清单中将使用哪个 API 版本。某些资源仅在扩展中可用,因此这是解析器如何行为的信息。

第二行描述了我们正在创建的资源。接下来是元数据和资源的规范。

元数据中的名称是必需的,因为这是区分一个资源和另一个资源的方式。如果我们想要创建另一个具有相同名称的 pod,我们将收到一个错误,指出已经存在这样的资源。标签是可选的,在编写选择器时非常有用。例如,如果我们想要创建一个允许连接到 pod 的服务,我们将使用一个匹配标签应用程序的选择器,其值等于dominican-front

规范也是必需的部分,因为它描述了资源的实际内容。在我们的示例中,我们列出了在 pod 内运行的所有容器。准确地说,一个名为webserver的容器,使用来自 Docker Hub 的图像nginx。由于我们希望从外部连接到 Nginx web 服务器,我们还公开了服务器正在侦听的容器端口80。端口描述中的名称是可选的。

Kubernetes 网络

Kubernetes 允许可插拔的网络架构。根据要求,可以使用几种驱动程序。无论选择哪个驱动程序,一些概念是通用的。以下是典型的网络场景。

容器与容器之间的通信

单个 pod 可以托管多个不同的容器。由于网络接口绑定到 pod 而不是容器,每个容器在相同的网络命名空间中运行。这意味着各种容器可以使用本地主机网络相互通信。

Pod 与 Pod 之间的通信

每个 pod 都有一个分配的内部集群本地 IP 地址。一旦 pod 被删除,该地址就不会持久存在。当一个 pod 知道另一个 pod 的地址时,它可以连接到另一个 pod 的暴露端口,因为它们共享相同的扁平网络。就这种通信模型而言,您可以将 pod 视为托管容器的 VM。这很少被使用,因为首选方法是 pod 到服务的通信。

pod 到服务的通信

pod 到服务的通信是集群内通信的最常见用例。每个服务都有一个分配的 IP 地址和 DNS 名称。当一个 pod 连接到一个服务时,连接被代理到服务选择的组中的一个 pod。代理是早期描述的 kube-proxy 工具的任务。

外部到内部的通信

外部流量通常通过负载均衡器进入集群。这些负载均衡器要么与特定服务绑定,要么由特定服务处理。当外部暴露的服务处理流量时,它的行为类似于 pod 到服务的通信。通过 Ingress 控制器,您可以使用其他功能,如路由、可观察性或高级负载平衡。

使用 Kubernetes 是一个好主意吗?

在组织内引入 Kubernetes 需要一些投资。Kubernetes 提供了许多好处,如自动扩展、自动化或部署方案。然而,这些好处可能无法证明必要的投资。

这项投资涉及几个领域:

  • 基础设施成本:运行控制平面和工作节点所需的成本可能相对较高。此外,如果您想使用各种 Kubernetes 扩展,如 GitOps 或服务网格(稍后描述),成本可能会上升。它们还需要额外的资源来运行,并在您的应用程序常规服务的基础上提供更多的开销。除了节点本身,您还应考虑其他成本。一些 Kubernetes 功能在部署到支持的云提供商时效果最佳。这意味着为了从这些功能中受益,您必须选���以下路线之一:

a. 将您的工作负载移至特定支持的云。

b. 为您选择的云提供商实现自己的驱动程序。

c. 将您的本地基础架构迁移到虚拟化的 API 启用环境,如 VMware vSphere 或 OpenStack。

  • 运营成本:Kubernetes 集群和相关服务需要维护。尽管您的应用程序需要较少的维护,但这一好处略微被保持集群运行的成本所抵消。

  • 教育成本:您的整个产品团队都必须学习新概念。即使您有一个专门的平台团队为开发人员提供易于使用的工具,开发人员仍需要基本了解他们的工作如何影响整个系统以及他们应该使用哪个 API。

在决定引入 Kubernetes 之前,首先考虑一下您是否能负担起它所需的初始投资。

分布式系统中的可观察性

诸如云原生架构之类的分布式系统提出了一些独特的挑战。在任何给定时间,不同服务的数量使得调查组件的性能变得非常不方便。

在单片系统中,通常日志记录和性能监控就足够了。在分布式系统中,即使日志记录也需要设计选择。不同的组件产生不同的日志格式。这些日志必须存储在某个地方。将它们与提供它们的服务放在一起,将使在停机情况下获得整体情况变得具有挑战性。此外,由于微服务可能存在时间很短,您将希望将日志的生命周期与提供它们的服务或托管服务的机器的生命周期解耦。

在第十三章中,设计微服务,我们描述了统一的日志记录层如何帮助管理日志。但日志只显示系统中特定点发生的情况。要从单个事务的角度看到整个图片,您需要采用不同的方法。

这就是追踪的作用。

追踪与日志记录的不同之处

追踪是日志记录的一种专门形式。它提供的信息比日志更低级。这可能包括所有函数调用、它��的参数、大小和执行时间。它们还包含正在处理的事务的唯一 ID。这些细节使得重新组装它们并查看给定事务在系统中的生命周期成为可能。

追踪中的性能信息可帮助您发现系统中的瓶颈和次优组件。

尽管日志通常由操作员和开发人员阅读,但它们往往是人类可读的。对于追踪没有这样的要求。要查看跟踪,您将使用专用的可视化程序。这意味着即使跟踪更详细,它们可能也比日志占用更少的空间。

以下图表是单个跟踪的概述:

图 15.1 - 单个跟踪

两个服务通过网络通信。在Service A中,我们有一个包含子跨度和单个日志的父跨度。子跨度通常对应于更深层的函数调用。日志代表最小的信息片段。它们中的每一个都被计时,并可能包含其他信息。

Service B的网络调用保留了跨度上下文。即使Service B在另一台机器上的不同进程中执行,所有信息也可以稍后重新组装,因为事务 ID 得以保留。

我们从重新组装跟踪中获得的额外信息是我们分布式系统中服务之间的���赖关系图。由于跟踪包含整个调用链,因此可以可视化此信息并检查意外的依赖关系。

选择追踪解决方案

在实施追踪时,有几种可能的解决方案可供选择。正如您可能想象的那样,您可以使用自托管工具和托管工具来为您的应用程序进行仪器化。我们将简要描述托管工具,并重点关注自托管工具。

Jaeger 和 OpenTracing

分布式跟踪的标准之一是 Jaeger 的作者提出的 OpenTracing。Jaeger 是为云原生应用程序构建的跟踪器。它解决了监视分布式事务和传播跟踪上下文的问题。它对以下目的很有用:

  • 性能或延迟优化

  • 执行根本原因分析

  • 分析服务之间的依赖关系

OpenTracing 是一个开放标准,提供了一个独立于使用的跟踪器的 API。这意味着当您的应用程序使用 OpenTracing 进行仪器化时,您避免了对特定供应商的锁定。如果在某个时候,您决定从 Jaeger 切换到 Zipkin、DataDog 或任何其他兼容的跟踪器,您不必修改整个仪器化代码。

有许多与 OpenTracing 兼容的客户端库。您还可以找到许多资源,包括教程和文章,解释如何根据您的需求实现 API。OpenTracing 官方支持以下语言:

  • Go

  • JavaScript

  • Java

  • Python

  • Ruby

  • PHP

  • Objective-C

  • C++

  • C#

还有一些非官方的库可用,特定应用程序也可以导出 OpenTracing 数据。这包括 Nginx 和 Envoy,两个流行的 Web 代理。

Jaeger 还接受 Zipkin 格式的样本。我们将在下一节中介绍 Zipkin。这意味着如果您(或您的任何依赖项)已经使用 Zipkin,您就不必将仪器从一种格式重写为另一种格式。对于所有新应用程序,OpenTracing 是推荐的方法。

Jaeger 的扩展性很好。您可以将其作为单个二进制文件或单个应用程序容器来运行以进行评估。您可以配置 Jaeger 以在生产中使用其自己的后端或支持的外部后端,如 Elasticsearch、Cassandra 或 Kafka。

Jaeger 是一个 CNCF 毕业项目。这意味着它已经达到了与 Kubernetes、Prometheus 或 Fluentd 类似的成熟水平。因此,我们期望它在其他 CNCF 应用程序中获得更多支持。

Zipkin

Jaeger 的主要竞争对手是 Zipkin。这是一个更老的项目,这意味着它更加成熟。通常,更老的项目也会得到更好的支持,但在这种情况下,CNCF 的认可对 Jaeger 更有利。

Zipkin 使用其专有协议来处理跟踪。它支持 OpenTracing,但可能不具有与本机 Jaeger 协议相同的成熟度和支持水平。正如我们之前提到的,还可以配置 Jaeger 以收集 Zipkin 格式的跟踪。这意味着这两者至少在某种程度上是可以互换的。

该项目由 Apache 基金会托管,但不被视为 CNCF 项目。在开发云原生应用程序时,Jaeger 是一个更好的选择。如果您正在寻找一个多用途的跟踪解决方案,那么考虑 Zipkin 也是值得的。

Zipkin 没有支持的 C++实现是一个缺点。有非官方的库,但似乎支持不够好。使用 C++ OpenTracing 库是仪表化 C++代码的首选方式。

使用 OpenTracing 为应用程序添加仪表

本节将说明如何向现有应用程序添加 Jaeger 和 OpenTracing 的仪表。我们将使用opentracing-cppjaeger-client-cpp库。

首先,我们要设置跟踪器:

#include <jaegertracing/Tracer.h>


void setUpTracer()

{

    // We want to read the sampling server configuration from the 
    // environment variables

    auto config = jaegertracing::Config;
    config.fromEnv();

    // Jaeger provides us with ConsoleLogger and NullLogger

    auto tracer = jaegertracing::Tracer::make(

        "customer", config, jaegertracing::logging::consoleLogger());

    opentracing::Tracer::InitGlobal(

        std::static_pointer_cast<opentracing::Tracer>(tracer));

}

配置采样服务器的两种首选方法要么使用环境变量,就像我们做的那样,要么使用 YAML 配置文件。当使用环境变量时,我们必须在运行应用程序之前设置它们。最重要的是以下几个:

  • JAEGER_AGENT_HOST:Jaeger 代理所在的主机名

  • JAEGER_AGENT_POR:Jaeger 代理正在监听的端口

  • JAEGER_SERVICE_NAME:我们应用程序的名称

接下来,我们配置跟踪器并提供日志记录实现。如果可用的ConsoleLogger不够,可以实现自定义日志记录解决方案。对于基于容器的应用程序,统一的日志记录层,ConsoleLogger应该足够了。

当我们设置好跟踪器后,我们希望在要仪表化的函数中添加 span。以下代码就是这样做的:

auto responder::respond(const http_request &request, status_code status,

                        const json::value &response) -> void {

  auto span = opentracing::Tracer::Global()->StartSpan("respond");

  // ...

}

这个 span 可以在以后用来在给定函数内创建子 span。它也可以作为参数传播到更深的函数调用中。它的使用方式如下:

auto responder::prepare_response(const std::string &name, const std::unique_ptr<opentracing::Span>& parentSpan)

    -> std::pair<status_code, json::value> {

  auto span = opentracing::Tracer::Global()->StartSpan(

        "prepare_response", { opentracing::ChildOf(&parentSpan->context()) });

  return {status_codes::OK,

          json::value::string(string_t("Hello, ") + name + "!")};

}


auto responder::respond(const http_request &request, status_code status)

    -> void {

  auto span = opentracing::Tracer::Global()->StartSpan("respond");

  // ...

  auto response = this->prepare_response("Dominic", span);

  // ...

}

当我们调用opentracing::ChildOf函数时,上下文传播就会发生。我们还可以使用inject()extract()调用通过网络调用传递上下文。

使用服务网格连接服务

微服务和云原生设计带来了一系列问题。不同服务之间的通信、可观察性、调试、速率限制、身份验证、访问控制和 A/B 测试可能会具有挑战性,即使服务数量有限。随着服务数量的增加,上述要求的复杂性也会增加。

这就是服务网格介入的地方。简而言之,服务网格通过一些资源(运行控制平面和边车所需的资源)来交换自动化和集中控制的解决方案,以解决上述挑战。

引入服务网格

我们在本章介绍中提到的所有要求以前都是在应用程序内部编码的。事实证明,许多要求可以被抽象化,因为它们在许多不同的应用程序中共享。当您的应用程序由许多服务组成时,向所有这些服务添加新功能开始变得昂贵。通过服务网格,您可以从一个单一点控制这些功能。

由于容器化工作流已经抽象化了一些运行时和一些网络,服务网格将抽象化提升到了另一个层次。这样,容器中的应用程序只知道 OSI 网络模型的应用程序级别发生了什么。服务网格处理更低级别的内容。

设置服务网格允许您以一种新的方式控制所有网络流量,并更好地了解这些流量。依赖关系变得可见,流动、形状和流量量也变得可见。

服务网格不仅处理流量的流动。其他流行的模式,如断路器、速率限制或重试,不必由每个应用程序单独实现和配置。这也是可以外包给服务网格的功能。同样,A/B 测试或金丝雀部署是服务网格能够实现的用例。

正如之前提到的,服务网格的一个好处是更大的控制权。其架构通常包括一个可管理的外部流量边缘代理和通常部署为旁路的内部代理。这样,网络策略可以被编写为代码,并存储在一个地方与所有其他配置一起。与为要连接的两个服务打开双向 TLS 加密相比,您只需在服务网格配置中启用一次该功能。

接下来,我们将介绍一些服务网格解决方案。

服务网格解决方案

这里描述的所有解决方案都是自托管的。

Istio

Istio 是一组强大的服务网格工具。它允许您通过部署 Envoy 代理作为旁路容器来连接微服务。由于 Envoy 是可编程的,Istio 控制平面的配置更改会被传达给所有代理,然后代理会相应地重新配置自己。

Envoy 代理除了其他功能外,还负责处理加密和身份验证。使用 Istio,为服务之间启用双向 TLS 通常只需要在配置中进行一次切换。如果您不希望所有服务之间都使用 mTLS,您还可以选择那些需要此额外保护的服务,同时允许其他所有服务之间的未加密流量。

Istio 还有助于可观察性。首先,Envoy 代理导出与 Prometheus 兼容的代理级别指标。 Istio 还导出服务级别指标和控制平面指标。接下来,有描述网格内流量流动的分布式跟踪。Istio 可以将跟踪提供给不同的后端:Zipkin、Jaeger、Lightstep 和 Datadog。最后,还有 Envoy 访问日志,以类似 Nginx 的格式显示每个调用。

使用 Kiali 可以可视化您的网格,这是一个交互式的 Web 界面。这样,您可以看到服务的图表,包括加密是否已启用,不同服务之间流量的大小,以及每个服务的健康检查状态。

Istio 的作者声称,这种服务网格应该与不同的技术兼容。在撰写本文时,最好的文档化、最好的集成和最好的测试是与 Kubernetes 的集成。其他支持的环境包括本地环境、通用云、Mesos 和带有 Consul 的 Nomad。

如果您在关注合规性的行业工作(如金融机构),那么 Istio 可以在这些方面提供帮助。

Envoy

虽然 Envoy 本身不是服务网格,但由于其在 Istio 中的使用,它值得在本节中提及。

Envoy 是一个类似于 Nginx 或 HAProxy 的服务代理。主要区别在于它可以动态重新配置。这是通过 API 以编程方式实现的,不需要更改配置文件然后重新加载守护程序。

关于 Envoy 的有趣事实是其性能和流行度。根据 SolarWinds 进行的测试,Envoy 在作为服务代理时击败了竞争对手。这些竞争对手包括 HAProxy、Nginx、Traefik 和 AWS 应用负载均衡器。Envoy 比 Nginx、HAProxy、Apache 和 Microsoft IIS 等这个领域的老牌领导者要年轻得多,但这并没有阻止 Envoy 进入 Netcraft 最常用的前 10 名网页服务器列表。

Linkerd

在 Istio 成为服务网格的代名词之前,这个领域由 Linkerd 代表。关于命名存在一些混淆,因为最初的 Linkerd 项目旨在是平台无关的,并且针对 Java 虚拟机。这意味着它资源密集且经常运行缓慢。更新的版本 Linkerd2 已经重写以解决这些问题。与最初的 Linkerd 相反,Linkerd2 只专注于 Kubernetes。

Linkerd 和 Linkerd2 都使用自己的代理解决方案,而不是依赖于 Envoy 等现有项目。这样做的理由是,专用代理(而不是通用的 Envoy)提供了更好的安全性和性能。Linkerd2 的一个有趣特性是,开发它的公司也提供付费支持。

Consul 服务网格

服务网格领域的最新增加是 Consul 服务网格。这是 HashiCorp 的产品,这家知名的云公司以 Terraform、Vault、Packer、Nomad 和 Consul 等工具而闻名。

就像其他解决方案一样,它具有 mTLS 和流量管理。它被宣传为一个多云、多数据中心和多地区的网格。它与不同的平台、数据平面产品和可观察性提供者集成。在撰写本文时,现实情况要逊色一些,因为主要支持的平台是 Nomad 和 Kubernetes,而支持的代理要么是内置代理,要么是 Envoy。

如果您考虑使用 Nomad 来部署应用程序,那么 Consul 服务网格可能是一个很好的选择,因为两者都是 HashiCorp 产品。

GitOps 进行中

本章我们想要讨论的最后一个话题是 GitOps。尽管这个术语听起来很新潮,但其背后的理念并不是全新的。它是持续集成/持续部署CI/CD)模式的延伸。或许延伸并不是一个很好的描述。

虽然 CI/CD 系统通常旨在非常灵活,但 GitOps 旨在最小化可能的集成数量。两个主要的常量是 Git 和 Kubernetes。Git 用于版本控制、发布管理和环境分离。Kubernetes 用作标准化和可编程的部署平台。

这样,CI/CD 流水线几乎变得透明。这与处理构建的所有阶段的命令式代码的方法相反。为了允许这种抽象级别,通常需要以下内容:

  • 基础设施即代码,以允许所有必要环境的自动化部署

  • 具有功能分支和拉取请求或合并请求的 Git 工作流

  • 声明性工作流配置,这在 Kubernetes 中已经��用

GitOps 的原则

由于 GitOps 是已建立的 CI/CD 模式的延伸,可能很难清楚地区分两者。以下是一些 GitOps 原则,它们将这种方法与通用的 CI/CD 区分开来。

声明性描述

经典 CI/CD 系统和 GitOps 之间的主要区别在于操作模式。大多数 CI/CD 系统是命令式的:它们由一系列必须按顺序执行的步骤组成,以使管道成功。

即使管道的概念是必要的,因为它意味着一个具有入口、一组连接和一个接收器的对象。一些步骤可以并行执行,但是每当存在依赖关系时,进程必须停止并等待依赖步骤完成。

在 GitOps 中,配置是声明性的。这指的是系统的整个状态 - 应用程序、它们的配置、监控和仪表板。所有这些都被视为代码,具有与常规应用程序代码相同的特性。

系统的状态在 Git 中进行版本控制

由于系统的状态是以代码编写的,您从中获得了一些好处。诸如更容易的审计、代码审查和版本控制等功能现在不仅适用于应用程序代码。其结果是,如果出现任何问题,恢复���工作状态只需要一个git revert命令。

您可以利用 Git 的签名提交、SSH 和 GPG 密钥来控制不同环境。通过添加一个门控机制,确保只有符合要求标准的提交才能推送到存储库,您还可以消除许多可能由手动运行sshkubectl命令而导致的意外错误。

可审计

您存储在版本控制系统中的所有内容都可以进行审计。在引入新代码之前,您进行代码审查。当您注意到错误时,您可以撤消引入错误的更改,或者返回到上一个工作版本。您的存储库成为关于整个系统的唯一真相。

将其应用于应用程序代码时已经很有用。然而,将审计配置、辅助服务、指标、仪表板甚至部署策略的能力扩展,使其变得更加强大。您不再需要问自己,“好吧,为什么这个配置最终进入了生产环境?”您只需要检查 Git 日志。

与已建立的组件集成

大多数 CI/CD 工具引入了专有的配置语法。Jenkins 使用 Jenkins DSL。每个流行的 SaaS 解决方案都使用 YAML,但这些 YAML 文件彼此不兼容。您无法在 Travis 和 CircleCI 之间切换,也无法在 CircleCI 和 GitLab CI 之间切换,而无需重写您的管道。

这有两个缺点。一个是明显的供应商锁定。另一个是需要学习配置语法以使用给定的工具。即使您的大部分管道已在其他地方定义(shell 脚本、Dockerfile 或 Kubernetes 清单),您仍然需要编写一些粘合代码来指示 CI/CD 工具使用它。

GitOps 与之不同。在这里,您不编写显式指令或使用专有语法。相反,您可以重用其他常见标准,例如 Helm 或 Kustomize。需要学习的内容更少,迁移过程更加轻松。此外,GitOps 工具通常与 CNCF 生态系统中的其他组件很好地集成,因此您可以将部署指标存储在 Prometheus 中,并使用 Grafana 进行审计。

配置漂移预防

配置漂移发生在给定系统的当前状态与存储库中描述的期望状态不同时。多种原因导致了配置漂移。

例如,让我们考虑一个具有基于 VM 的工作负载的配置管理工具。所有 VM 都以相同的状态启动。当 CM 第一次运行时,它将机器带到期望的状态。但是,如果默认情况下在这些机器上运行自动更新代理,该代理可能会自行更新一些软件包,而不考虑 CM 中的期望状态。此外,由于网络连接可能不稳定,一些机器可能会更新到软件包的新版本,而其他机器则不会。

在极端情况下,更新的软件包可能与您的应用程序所需的固定软件包不兼容。这种情况将破坏整个 CM 工作流程,并使您的机器处于不可用状态。

使用 GitOps,系统内始终运行着一个代理,用于跟踪系统的当前状态和期望状态。如果当前状态突然与期望状态不同,代理可以修复它或发出有关配置漂移的警报。

防止配置漂移为您的系统增加了另一层自愈。如果您正在运行 Kubernetes,您已经在 Pod 级别上具有自愈能力。每当一个 Pod 失败时,另一个将在其位置重新创建。如果您在其下使用可编程基础设施(例如云提供商或本地 OpenStack),您还具有节点的自愈能力。通过 GitOps,您可以获得工作负载及其配置的自愈。

GitOps 的好处

正如您可以想象的那样,GitOps 的描述功能提供了几个好处。以下是其中一些。

提高生产力

CI/CD 流水线已经自动化了许多常规任务。它们通过帮助进行更多部署来减少交付时间。GitOps 增加了一个反馈循环,可以防止配置漂移并允许自愈。这意味着您的团队可以更快地交付,并且不必担心引入潜在问题,因为它们很容易恢复。这反过来意味着开发吞吐量增加,您可以更快地引入新功能并更有信心。

更好的开发人员体验

使用 GitOps,开发人员不必担心构建容器���使用 kubectl 来控制集群。部署新功能只需要使用 Git,这在大多数环境中已经是一个熟悉的工具。

这也意味着入职速度更快,因为新员工不必学习很多新工具才能提高工作效率。GitOps 使用标准和一致的组件,因此对运营方面的更改不应影响开发人员。

更高的稳定性和可靠性

使用 Git 来存储系统状态意味着您可以访问审计日志。该日志包含所有引入的更改的描述。如果您的任务跟踪系统与 Git 集成(这是一个好习惯),通常可以确定与系统更改相关的业务功能。

使用 GitOps,不需要手动访问节点或整个集群的需求减少了,这减少了因运行无效命令而产生意外错误的机会。通过使用 Git 强大的还原功能,可以轻松修复系统中出现的随机错误。

从严重灾难(如失去整个控制平面)中恢复也更容易。所需的只是设置一个新的干净集群,在那里安装一个 GitOps 运算符,并将其指向具有您配置的存储库。不久之后,您将获得与以前的生产系统完全相同的副本,而无需手动干预。

提高安全性

减少对集群和节点的访问需求意味着提高了安全性。在丢失或盗窃密钥方面可以少操心。您避免了这样一种情况:即使某人不再在团队(或公司)工作,但仍然保留对生产环境的访问权限。

当涉及对系统的访问时,Git 存储库处理单一真相。即使恶意行为者决定在系统中引入后门,所需的更改也将经过代码审查。当您的存储库使用具有强验证的 GPG 签名提交时,冒充其他开发人员也更具挑战性。

到目前为止,我们主要讨论了从开发和运营角度的好处。但是 GitOps 也有利于业务。它为系统提供了业务可观察性,这是以前很难实现的。

很容易跟踪给定发布中存在的功能,因为它们都存储在 Git 中。由于 Git 提交了一个任务跟踪器的链接,业务人员可以获得预览链接,以查看应用程序在各种开发阶段的外观。

它还提供了清晰度,可以回答以下常见问题:

  • 生产环境中运行什么?

  • 上一个发布解决了哪些问题?

  • 哪个更改可能导致服务降级?

所有这些答案的问题甚至可以在友好的仪表板中呈现。当然,仪表板本身也可以存储在 Git 中。

GitOps 工具

GitOps 空间是一个新兴的领域。已经有一些可以被认为是稳定和成熟的工具。以下是一些最受欢迎的工具。

FluxCD

FluxCD 是 Kubernetes 的一个主观的 GitOps 操作者。选择的集成提供核心功能。它使用 Helm 图表和 Kustomize 来描述资源。

它与 Prometheus 的集成为部署过程增加了可观察性。为了帮助维护,FluxCD 具有 CLI。

ArgoCD

与 FluxCD 不同,它提供了更广泛的工具选择。如果您已经在配置中使用 Jsonnet 或 Ksonnet,这可能会很有用。与 FluxCD 一样,它与 Prometheus 集成,并具有 CLI。

在撰写本书时,ArgoCD 比 FluxCD 更受欢迎。

Jenkins X

与名称可能暗示的相反,Jenkins X 与著名的 Jenkins CI 系统没有太多共同之处。它由同一家公司支持,但 Jenkins 和 Jenkins X 的整个概念完全不同。

虽然其他两个工具都是特意小而自包含的,但 Jenkins X 是一个复杂的解决方案,具有许多集成和更广泛的范围。它支持触发自定义构建任务,使其看起来像是经典 CI/CD 系统和 GitOps 之间的桥梁。

总结

恭喜您完成了本章的阅读!使用现代 C++不仅仅是理解最近添加的语言特性。您的应用程序将在生产环境中运行。作为架构师,您还可以选择确保运行时环境符合要求。在前几章中,我们描述了分布式应用程序中的一些流行趋势。我们希��这些知识将帮助您决定哪种最适合您的产品。

进行云原生转型带来了许多好处,并且可以自动化大部分工作流程。将定制工具切换到行业标准可以使您的软件更具弹性并更易于更新。在本章中,我们已经涵盖了流行的云原生解决方案的优缺点和用例。

一些工具,如 Jaeger 的分布式跟踪,对大多数项目都带来了即时的好处。其他工具,如 Istio 或 Kubernetes,在大规模操作中表现最佳。阅读本章后,您应该具有足够的知识来决定是否值得为您的应用程序引入云原生设计。

问题

  1. 在云中运行应用程序和使其成为云原生之间有什么区别?

  2. 如何在本地运行云原生应用程序?

  3. Kubernetes 的最小高可用集群大小是多少?

  4. 哪个 Kubernetes 对象代表允许网络连接的微服务?

  5. 为什么在分布式系统中日志记录不足够?

  6. 服务网格如何帮助构建安全系统?

  7. GitOps 如何提高生产力?

  8. 监控的标准 CNCF 项目是什么?

进一步阅读

附录 A

感谢您在软件架构的旅程中走到这一步。我们撰写这本书的目标是帮助您在设计应用程序和系统时做出明智的决策。到目前为止,当决定选择 IaaS、PaaS、SaaS 或 FaaS 时,您应该感到自信。

在这本书中,我们还有很多事情没有涉及,因为它们非常广泛,超出了书的范围。要么是我们对给定主题的经验太少,要么是我们认为它太小众。还有一些领域,我们觉得非常重要,但我们在章节中找不到合适的位置。您将在这个附录中找到它们。

设计数据存储

现在让我们讨论一下应用程序的存储。首先让我们决定您应该选择 SQL、NoSQL 还是其他什么。

一个很好的经验法则是根据数据库的大小来决定技术。对于小型数据库,比如那些大小永远不会增长到千兆字节区域的数据库,选择 SQL 是一个有效的方法。如果您有一个非常小的数据库或想创建一个内存缓存,您可以尝试 SQLite。如果您计划进入单一的千兆字节,再次保证大小永远不会超过这个范围,您最好选择 NoSQL。在某些情况下,仍然可以坚持使用 SQL 数据库,但由于硬件成本的原因,这很快就会变得昂贵,因为您需要一台强大的服务器作为主节点。即使这不是问题,您也应该衡量性能是否足够满足您的需求,并准备好长时间的维护窗口。在某些情况下,只需运行一组 SQL 机器,使用诸如 Citus 之类的技术,这也可能适合您,Citus 本质上是一个分片的 PostgreSQL。然而,通常在这种情况下,选择 NoSQL 更便宜、更简单。如果您的数据库大小超过 10 TB,或者需要实时摄入数据,考虑使用数据仓库而不是 NoSQL。

我应该使用哪种 NoSQL 技术?

这个问题的答案取决于几个因素。以下是其中的一些:

  • 如果您想存储时间序列(在小的、定期的间隔内保存增量),那么最好的选择是使用 InfluxDB 或 VictoriaMetrics。

  • 如果您需要类似 SQL 但可以不使用连接,或者换句话说,如果您计划将数据存储在列中,您可以尝试使用 Apache Cassandra、AWS DynamoDB 或 Google 的 BigTable。

  • 如果不是这种情况,那么您应该考虑您的数据是否是没有模式的文档,比如 JSON 或某种应用程序日志。如果是这种情况,您可以选择 Elasticsearch,它非常适合这种灵活的数据,并提供 RESTful API。您也可以尝试使用 MongoDB,它以二进制 JSONBSON)格式存储数据,并允许 MapReduce。

好吧,但如果您不想存储文档怎么办?那么您可以选择对象存储,特别是如果您的数据很大的话。在这种情况下,通常选择云提供商是可以的,这意味着使用 Amazon 的 S3 或 Google 的 Cloud Storage 或 Microsoft 的 Blob 存储应该有助于您的情况。如果您想选择本地存储,您可以使用 OpenStack 的 Swift 或部署 Ceph。

如果文件存储也不是你要找的,那么也许你的情况只是简单的键值数据。使用这种存储有它的好处,因为它很快。这就是为什么许多分布式缓存都是使用它构建的。值得注意的技术包括 Riak、Redis 和 Memcached(最后一个不适合持久化数据)。

除了前面提到的选项,您还可以考虑使用基于树的数据库,比如 BerkeleyDB。这些数据库基本上是专门的键值存储,具有类似路径的访问。如果��对您的情况太过限制,您可能会对面向图的数据库感兴趣,比如 Neo4j 或 OrientDB。

无服务器架构

虽然与云原生设计相关,无服务器架构本身是一个热门话题。自引入 FaaS 或 CaaS 产品(如 AWS Lambda、AWS Fargate、Google Cloud Run 和 Azure Functions)以来,它变得非常受欢迎。

无服务器主要是 PaaS 产品(如 Heroku)的演进。它抽象了底层基础架构,使开发人员可以专注于应用程序,而不是基础架构选择。

与旧的 PaaS 解决方案相比,无服务器的另一个好处是您不必为您不使用的部分付费。与支付给定服务级别的费用不同,您通常会根据部署的工作负载的实际执行时间付费。如果您只想每天运行一段代码,您不需要为基础服务器支付月费。

虽然我们没有详细讨论无服务器,但它很少与 C++一起使用。在 FaaS 方面,目前只有 AWS Lambda 支持 C++作为可能的语言。由于容器是语言无关的,您可以使用 C++应用程序和函数与 AWS Fargate、Azure 容器实例或 Google Cloud Run 等 CaaS 产品一起使用。

如果您想要运行与您的 C++应用程序一起使用的非 C++辅助代码,无服务器函数可能仍然与您相关。维护任务和定期作业非常适合无服务器,它们通常不需要 C++二进制文件的性能或效率。

通信和文化

本书的重点是软件架构。那么为什么我们要在围绕软件的书中提到沟通和文化呢?如果您仔细想想,所有的软件都是由人编写的,供人使用的。人的因素是主要的,但我们经常不愿承认它。

作为架构师,您的角色不仅是找出解决特定问题的最佳方法。您还必须将您提出的解决方案与团队成员沟通。通常,您所做的选择将是基于先前的对话。

这些是沟通和团队文化在软件架构中也发挥作用的原因。

在早期的章节中,我们提到了康威定律。这个定律指出软件系统的架构反映了正在处理它的组织。这意味着构建优秀的产品需要构建优秀的团队并理解心理学。

如果您想成为一名优秀的架构师,学习人际关系技能可能与学习技术技能一样重要。

DevOps

我们在本书中多次使用了 DevOps(和 DevSecOps)这个术语。在我们看来,这个主题值得额外的空间。DevOps 是一种构建软件产品的方法,它打破了传统的基于独立开发的模式。

在瀑布模型中,团队独��地操作单个工作方面。开发团队编写代码,QA 测试和验证代码,然后才是安全和合规性。最终,运维团队将负责维护。团队很少进行沟通,即使进行沟通,通常也是一个非常正式的过程。

关于特定专业领域的知识以前只能由负责特定工作流程的团队获得。开发人员对 QA 知之甚少,对运维几乎一无所知。虽然这种设置非常方便,但现代环境需要比瀑布模型提供更多的灵活性。

这就是为什么提出了一种新的工作模式,鼓励更多的协作,更好的沟通,并在软件产品的不同利益相关者之间进行大量的知识共享。虽然 DevOps 指的是将开发人员和运维团队聚集在一起,但它的意思是让每个人更加接近。

开发人员在编写第一行代码之前就开始与 QA 和安全合作。运维工程师对代码库更熟悉。企业可以轻松跟踪特定工单的进展,并且在某些情况下,甚至可以自助进行部署预览。

DevOps 已经成为使用特定工具如 Terraform 或 Kubernetes 的代名词。但 DevOps 并不意味着使用任何特定的工具。你的组织可以遵循 DevOps 原则而不使用 Terraform 或 Kubernetes,也可以在不实践 DevOps 的情况下使用 Terraform 和 Kubernetes。

DevOps 的原则之一是鼓励产品利益相关者之间改善信息流动。有了这一点,就有可能实现另一个原则:减少不为最终产品带来价值的浪费活动。

当你构建现代系统时,值得使用现代方法。将现有组织迁移到 DevOps 可能需要进行大规模的思维转变,因此并非总是可能的。在启动一个你可以控制的全新项目时值得追求。

第十六章:评估

第一章

  1. 为什么您应该关心软件架构?
  • 架构允许您实现和维护软件的必要特性。关注和关心它可以防止项目出现意外的架构,从而失去质量,并且可以防止软件腐败。
  1. 在敏捷团队中,架构师应该是最终的决策者吗?
  • 不。敏捷是关于赋予整个团队权力。架构师将他们的经验和知识带到了桌面上,但如果一个决定必须得到整个团队的接受,那么团队应该拥有它,而不仅仅是架构师。考虑利益相关者的需求在这里也非常重要。
  1. 单一责任原则(SRP)与内聚性有何关系?
  • 遵循 SRP 会导致更好的内聚性。如果一个组件开始具有多个责任,通常它的内聚性会降低。在这种情况下,最好将其重构为多个组件,每个组件都具有单一责任。这样,我们增加了内聚性,使代码更容易理解,开发和维护。
  1. 在项目的生命周期的哪些阶段可以从拥有架构师中获得好处?
  • 架构师可以从项目开始直到进入维护阶段为项目带来价值。最大的价值可以在项目开发的早期阶段实现,因为这是关于项目外观应该如何的关键决定。然而,这并不意味着架构师在开发过程中没有价值。他们可以确保项目走上正确的道路并保持在轨道上。通过协助决策和监督项目,他们确保代码不会出现意外的架构,并且不会受到软件腐败的影响。
  1. 遵循 SRP 的好处是什么?
  • 遵循 SRP 的代码更容易理解和维护。这也意味着它有更少的错误。

第二章

  1. RESTful 服务的特点是什么?
  • 显然,使用 REST API。

  • 无状态性-每个请求包含其处理所需的所有数据。请记住,这并不意味着 RESTful 服务不能使用数据库,恰恰相反。

  • 使用 Cookie 而不是保持会话。

  1. 您可以使用哪些工具包来帮助您创建弹性分布式架构?
  • Netflix 的 Simian Army。
  1. 在微服务中应该使用集中式存储吗?为什么/为什么不?
  • 微服务应该使用分散式存储。每个微服务应该选择最适合自己的存储类型,因为这会提高效率和可扩展性。
  1. 何时应该编写有状态的服务而不是无状态的服务?
  • 只有在没有理由使用无状态服务并且您不需要扩展时才需要有状态服务。例如,当客户端和服务必须保持它们的状态同步或者要发送的状态非常庞大时。
  1. 经纪人和中介者有何不同?
  • 中介者在服务之间“调解”,因此需要知道如何处理每个请求。经纪人只知道将每个请求发送到哪里,因此它是一个轻量级组件。它可以用来创建发布-订阅(pub-sub)架构。
  1. N 层架构和 N 层架构之间有什么区别?
  • 层次是逻辑的,指定了代码的组织方式。层是物理的,指定了代码的运行方式。每个层必须与其他层分离,可以通过在不同的进程中运行,甚至在不同的机器上运行来实现。
  1. 您应该如何处理用微服务架构替换单体架构?
  • 逐步进行。从单体架构中剥离出小的微服务。您可以使用《第四章,架构和系统设计》中描述的窒息者模式来帮助您完成这项工作。

第三章

  1. 质量属性是什么?
  • 系统可能具有的特征或特质。通常被称为“ilities”,因为它们的名称中许多都有这个后缀,例如可移植性。
  1. 在收集需求时应该使用哪些来源?
  • 系统的上下文,现有文档和系统的利益相关者。
  1. 当您定义一个函数时,如何能够判断一个需求是否具有架构重要性?
  • 架构重要需求ASRs)通常需要一个单独的软件组件,影响系统的大部分,难以实现,和/或迫使您做出权衡。
  1. 您应该如何以图形方式记录各方可能对系统的功能需求?
  • 准备一个用例图。
  1. 开发视图文档何时有用?
  • 在您开发具有许多模块并且需要将全局约束和常见设计选择传达给所有软件团队的大型系统的情况下。
  1. 如何自动检查您的代码 API 文档是否过时?
  • Doxygen 具有内置检查,比如警告您关于函数签名和注释中的参数之间不匹配的检查。
  1. 您应该如何在图表上显示给定操作是由系统的不同组件处理的?
  • 为此目的使用 UML 交互图之一。序列图是一个不错的选择,尽管在某些情况下通信图也可以很好地完成。

第四章

  1. 什么是事件溯源?
  • 这是一种架构模式,依赖于跟踪改变系统状态的事件,而不是跟踪状态本身。它带来了诸如较低的延迟、免费审计日志和可调试性等好处。
  1. CAP 定理的实际后果是什么?
  • 随着网络分区的发生,如果您想要一个分布式系统,您需要在一致性和可用性之间做出选择。在分区的情况下,您可以返回陈旧的数据、错误,或者冒风险超时。
  1. 您可以如何使用 Netflix 的 Chaos Monkey?
  • 它可以帮助您为服务的意外停机做好准备。
  1. 缓存可以应用在哪里?
  • 可以在客户端的一侧,在 Web 服务器、数据库或应用程序的前面,或者在靠近潜在客户端的主机上,具体取决于您的需求。
  1. 您应该如何防止应用程序在整个数据中心崩溃时?
  • 通过使用地理。
  1. 为什么应该使用 API 网关?
  • 为了简化客户端代码,因为它不需要硬编码服务实例的地址。
  1. Envoy 如何帮助您实现各种架构目标?
  • 它通过提供背压、断路器、自动重试和异常检测来帮助系统的容错性。

  • 它通过允许金丝雀发布和蓝绿部署来帮助部署能力。

  • 它还提供负载平衡、跟踪、监控和度量。

第五章

  1. 当不再使用时,如何确保我们代码的每个打开的文件都会关闭?
  • 通过使用 RAII 习惯用法;例如,通过使用std::unique_ptr,它将在其析构函数中关闭它。
  1. 在 C++代码中什么时候应该使用“裸”指针?
  • 仅用于传递可选(可空)引用。
  1. 什么是推断指南?
  • 一种告诉编译器应该为模板推断哪些参数���方法。它们可以是隐式的或用户定义的。
  1. 何时应该使用std::optional,何时应该使用gsl::not_null
  • 前者是用于我们想要传递包含的值的情况。后者只是传递指向它的指针。此外,前者可以为空,而后者总是指向一个对象。
  1. 范围算法与视图有何不同?
  • 算法是急切的,而视图是懒惰的。算法还允许使用投影。
  1. 在定义函数时,如何能够比仅指定概念名称更多地限制您的类型?
  • 通过使用requires子句。
  1. import Ximport <X>有何不同?
  • 后者允许从导入的X头文件中可见宏。

第六章

  1. 三、五和零的规则是什么?
  • 编写具有可预测语义和更少错误的类型的最佳实践。
  1. 何时应该使用 niebloids 而不是隐藏的朋友?
  • Niebloids“禁用”ADL,而隐藏的友元依赖于它被找到。因此,前者可以加快编译速度(考虑的重载更少),而后者可以帮助您实现定制点。
  1. 如何改进Array接口以更适合生产?
  • 应该添加beginend及其常量和反向等效项,以便它可以像一个适当的容器一样使用。例如,value_typepointeriterator等特性可以在通用代码中重用。在成员中添加constexprnoexcept可以增加安全性和性能。operator[]const重载也是缺失的。
  1. 折叠表达式是什么?
  • 折叠表达式是指将参数包在二元函数器上折叠或减少的表达式。换句话说,这些语句将给定操作应用于所有传递的可变模板参数,以便产生单个值(或void)。
  1. 何时不应该使用静态多态性?
  • 当您需要为您的代码的消费者提供一种在运行时添加更多类型的方法时。
  1. 在眨眼示例中如何节省一次额外的分配?
  • 通过避免在添加元素时调整向量的大小。

第七章

  1. 在 CMake 中安装和导出目标有什么区别?
  • 出口意味着目标将对其他试图找到我们的软件包的项目可用,即使我们的代码没有安装。 CMake 的软件包注册表可用于存储有关导出目标位置的数据。二进制文件永远不会离开构建目录。安装需要将目标复制到某个地方,并且如果不是系统目录,则设置路径到配置文件或目标本身。
  1. 如何使您的模板代码编译更快?
  • 遵循 Chiel 的规则。
  1. 如何在 Conan 中使用多个编译器?
  • 使用 Conan 配置文件。
  1. 如果您想使用先前的 C++11 GCC ABI 编译您的 Conan 依赖项,应该怎么做?
  • compiler.libcxx设置为libstdc++而不是libstdc++11
  1. 在 CMake 中如何确保强制使用特定的 C++标准?
  • 通过调用set_target_properties(our_target PROPERTIES CXX_STANDARD our_required_cxx_standard CXX_STANDARD_REQUIRED YES CXX_EXTENSIONS NO)
  1. 在 CMake 中如何构建文档并将其与您的 RPM 软件包一起发布?
  • 创建一个目标以生成文档,如第三章中所述,“功能和非功能需求”,将其安装到CMAKE_INSTALL_DOCDIR,然后确保路径未在CPACK_RPM_EXCLUDE_FROM_AUTO_FILELIST变量中指定。

��8 章

  1. 测试金字塔的基础层是什么?
  • 单元测试。
  1. 有哪些非功能测试?
  • 性能,耐久性,安全性,可用性,完整性和可用性。
  1. 有一个著名的根本原因分析方法的名字是什么?
  • 5 个为什么
  1. 在 C++中是否可能测试编译时代码?
  • 是的,例如使用static_assert
  1. 在为具有外部依赖项的代码编写单元测试时应该使用什么?
  • 测试替身,比如模拟和伪造。
  1. 单元测试在持续集成/持续部署中的作用是什么?
  • 它们是一个门控机制的基础,并作为一个早期警告功能。
  1. 列举一些允许测试基础设施代码的工具。
  • Serverspec,Testinfra,Goss。
  1. 在单元测试中访问类的私有属性和方法是一个好主意吗?
  • 您应该设计类,以便您永远不必直接访问它们的私有属性。

第九章

  1. 持续集成在开发过程中如何节省时间?
  • 它允许您在进入生产之前捕获错误并修复它们。
  1. 你需要单独的工具来实现持续集成和持续部署吗?
  • 流水线通常使用单个工具编写;实际测试和部署使用多个工具。
  1. 在会议中进行代码审查何时是有意义的?
  • 当异步代码审查时间太长时。
  1. 在持续集成期间,您可以使用哪些工具来评估代码的质量?
  • 测试,静态分析。
  1. 谁参与指定 BDD 场景?
  • 开发人员、QA、业务。
  1. 何时应考虑使用不可变基础设施?何时应该排除它?
  • 它最适用于无状态服务或可以使用数据库或网络存储外包存储的服务。不适用于有状态服务。
  1. 您如何描述 Ansible、Packer 和 Terraform 之间的区别?
  • Ansible 旨在对现有 VM 的配置管理,Packer 用于构建云 VM 镜像,Terraform 用于构建云基础设施(如网络、VM 和负载均衡器)。

第十章

  1. 为什么安全在现代系统中很重要?
  • 现代系统通常连接到网络,因此可能容易受到外部攻击。
  1. 并发性带来的一些挑战是什么?
  • 代码更难设计和调试。可能会出现更新问题。
  1. C++核心指南是什么?
  • 记录如何构建 C++系统的最佳实践。
  1. 安全编码和防御性编码之间有什么区别?
  • 安全编码为最终用户提供了健壮性,而防御性编码为接口消费者提供了健壮性。
  1. 您应该如何检查您的软件是否包含已知的漏洞?
  • 通过使用 CVE 数据库或自动扫描程序,如 OWASP Dependency-Check 或 Snyk。
  1. 静态分析和动态分析之间有什么区别?
  • 静态分析是在不执行源代码的情况下执行的。动态分析需要执行。
  1. 静态链接和动态链接之间有什么区别?
  • 使用静态链接,可执行文件包含运行应用程序所需的所有代码。使用动态链接,一些代码部分(动态库)在不同的可执行文件之间共享。
  1. 您如何使用编译器来解决安全问题?
  • 现代编译器包括检查某些缺陷的消毒剂。
  1. 您如何在持续集成流程中实施安全意识?
  • 通过使用扫描漏洞并执行各种静态和动态分析的自动化工具。

第十一章

  1. 我们可以从本章微基准测试的性能结果中学到什么?
  • 二分搜索比线性搜索快得多,即使要检查的元素数量并不那么多。这意味着计算复杂度(也称为大 O)很重要。可能在您的机器上,即使对于二分搜索的最大数据集上的最长搜索也比线性搜索的最短搜索还要快!

  • 根据缓存大小,您可能还注意到当数据无法适应特定缓存级别时,增加所需内存会导致减速。

  1. 我们如何遍历多维数组对性能重要吗?为什么/为什么不?
  • 这是至关重要的,因为我们可能会在内存中线性访问数据,CPU 预取器会喜欢并奖励我们更好的性能,或者跳过内存,从而阻碍我们的性能。
  1. 在我们的协程示例中,为什么我们不能在do_routine_work函数内创建我们的线程池?
  • 因为寿命问题。
  1. 我们如何重新设计我们的协程示例,以便它使用生成器而不仅仅是任务?
  • 生成器的主体需要co_yield。此外,我们池中的线程需要同步,可能需要使用原子操作。

第十二章

  1. 面向服务的架构中服务的属性是什么?
  • 它是业务活动的代表,具有明确定义的结果。

  • 它是自包含的。

  • 它对用户是不透明的。

  • 它可能由其他服务组成

  1. Web 服务的一些好处是什么?
  • 它们易于使用常见工具进行调试,与防火墙配合良好,并且可以利用现有基础设施,如负载平衡、缓存和 CDN。
  1. 微服务不是一个好选择的时候是什么时候?
  • 当 RPC 和冗余成本超过收益时。
  1. 消息队列的一些用例是什么?
  • IPC,事务服务,物联网。
  1. 选择 JSON 而不是 XML 的一些好处是什么?
  • JSON 需要更低的开销,正在取代 XML 的流行,并且应该更容易被人类阅读。
  1. REST 如何建立在 Web 标准之上?
  • 它使用 HTTP 动词和 URL 作为构建块。
  1. 云平台与传统托管有何不同?
  • 云平台提供易于使用的 API,这意味着可以编程资源。

第十三章

  1. 微服务如何帮助您更好地利用系统资源?
  • 仅扩展缺乏的资源比整个系统更容易。
  1. 微服务和单体应用程序如何共存(在不断发展的系统中)?
  • 新功能可以开发为微服务,而某些功能可以从单体中拆分和外包。
  1. 哪些类型的团队最能从微服务中受益?
  • 遵循 DevOps 原则的跨功能自主团队。
  1. 在引入微服务时为什么需要成熟的 DevOps 方法?
  • 通过单独的团队手动测试和部署大量微服务几乎是不可能的。
  1. 统一的日志层是什么?
  • 这是一个可配置的收集、处理和存储日志的设施。
  1. 日志和跟踪有何不同?
  • 日志通常是人类可读的,侧重于操作,而跟踪通常是机器可读的,侧重于调试。
  1. 为什么 REST 可能不是连接微服务的最佳选择?
  • 与 gRPC 相比,它可能提供更大的开销。
  1. 微服务的部署策略是什么?每种策略的好处是什么?
  • 每个主机一个服务-更容易调整机器以适应工作负载。

  • 每个主机多个服务-更好地利用资源。

第十四章

  1. 应用程序容器与操作系统容器有何不同?
  • 应用程序容器旨在托管单个进程,而操作系统容器通常运行 Unix 系统中通常可用的所有进程。
  1. Unix 系统中沙盒环境的一些早期示例是什么?
  • chroot,BSD Jails,Solaris Zones。
  1. 为什么容器非常适合微服务?
  • 它们提供了一个统一的接口,可以运行应用程序,而不受基础技术的影响。
  1. 容器和虚拟机之间的主要区别是什么?
  • 容器更轻量级,因为它们不需要虚拟化程序、操作系统内核的副本,或者辅助进程,比如 init 系统或 syslog。
  1. 应用程序容器何时不是一个好选择?
  • 当您想将多进程应用程序放入单个容器中时。
  1. 构建多平台容器映像的一些工具是什么?
  • manifest-tool,docker buildx。
  1. 除了 Docker,还有哪些其他容器运行时?
  • Podman,containerd,CRI-O。
  1. 一些流行的编排器是什么?
  • Kubernetes,Docker Swarm,Nomad。

第十五章

  1. 在云中运行应用程序和使它们成为云原生应用程序有什么区别?
  • 云原生设计包括现代技术,如容器和无服务器,打破了对虚拟机的依赖。
  1. 您可以在本地运行云原生应用程序吗?
  • 是的,例如可以使用 OpenStack 等解决方案。
  1. Kubernetes 的最小高可用HA)集群大小是多少?
  • 最小的 HA 集群需要控制平面中的三个节点和三个工作节点。
  1. 哪个 Kubernetes 对象代表允许网络连接的微服务?
  • 服务。
  1. 为什么在分布式系统中日志不足?
  • 在分布式系统中收集日志并查找它们之间的关联是有问题的。分布式跟踪更适合某些用例。
  1. 服务网格如何帮助构建安全系统?
  • 服务网格抽象了不同系统之间的连接,从而可以应用加密和审计。
  1. GitOps 如何提高生产力?
  • 它使用一个熟悉的工具 Git 来处理 CI/CD,而无需编写专门的流水线。
  1. 监控的标准 CNCF 项目是什么?
  • Prometheus。
posted @ 2024-05-15 15:26  绝不原创的飞龙  阅读(76)  评论(0编辑  收藏  举报