Go-云原生指南-全-

Go 云原生指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

对于技术人员来说,现在是一个神奇的时代。

我们有 Docker 来构建容器,有 Kubernetes 来编排它们。Prometheus 让我们可以监控它们。Consul 让我们可以发现它们。Jaeger 让我们可以追踪它们之间的关系。这些只是几个例子,但还有很多很多,都代表了一个新一代的技术:它们都是“云原生”的,而且全部都是用Go语言编写的。

“云原生”这个术语听起来模糊而充满噱头,但实际上它有一个相当具体的定义。根据著名 Linux 基金会的子基金会 Cloud Native Computing Foundation 的说法,云原生应用程序是指设计成能够在极其变化的负载面前实现可伸缩性,在环境不确定性面前具有弹性,在不断变化的需求面前易于管理。换句话说,云原生应用程序是为在这个残酷而不确定的宇宙中生存而设计的。

结合多年构建基于云的软件的经验,Go 语言大约十年前被创建为第一种专门设计用于开发云原生软件的主要语言。这主要是因为当时常见的服务器语言并不适合编写谷歌大量生产的分布式、进程密集型应用程序。

自那时以来,Go 语言已经成为云原生开发的通用语言,被广泛应用于从 Docker 到 Harbor、Kubernetes 到 Consul、InfluxDB 到 CockroachDB 的各种项目中。云原生计算基金会的十五个毕业项目中,有十个使用大部分或全部是 Go 语言编写的;总计六十二个项目中,有四十二个使用了 Go 语言。而且每天还有更多的项目加入。

适合阅读本书的人群

本书主要面向中高级开发人员,特别是 Web 应用程序工程师和 DevOps 专家/站点可靠性工程师。许多人已经使用 Go 构建 Web 服务,但可能对云原生开发的细微差别不熟悉,甚至对“云原生”是什么都不清楚,并且发现他们的服务难以管理、部署或监控。对于这些读者,本书将提供不仅如何构建云原生服务的坚实基础,还将展示为什么这些技术至关重要,并提供具体示例来理解这个有时抽象的主题。

预计很多读者可能更熟悉其他编程语言,但由于 Go 语言作为云原生开发语言的声誉而被吸引。对于这些读者,本书将介绍采用 Go 作为他们云原生开发语言的最佳实践,并帮助他们解决自己的云原生管理和部署问题。

为什么写这本书

应用程序的设计,构建和部署方式正在发生变化。规模的需求正在迫使开发人员将其服务的工作分布到大量服务器上:行业正在“云原生”转型。但这带来了一系列新问题:如何在十台服务器上开发、部署或管理服务?一百台?一千台?不幸的是,现有的“云原生”领域的书籍主要集中在抽象的设计原则上,并且几乎没有或根本没有提供这些问题的基本示例。本书旨在填补市场对复杂云原生设计原则实际演示的需求。

本书中使用的约定

本书使用以下排版约定:

斜体

指示新术语,URL,电子邮件地址,文件名和文件扩展名。

常量宽度

用于程序清单,以及在段落中引用程序元素,如变量或函数名,数据库,数据类型,环境变量,语句和关键字。

常量宽度加粗

显示用户应按原样键入的命令或其他文本。

常量宽度斜体

显示应该用用户提供的值替换或根据上下文确定的值的文本。

提示

这个元素表示一个提示或建议。

注意

这个元素表示一般性说明。

警告

这个元素表示一个警告或注意事项。

使用代码示例

补充材料(代码示例,练习等)可在https://github.com/cloud-native-go/examples下载。

本书旨在帮助您完成工作。一般情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您正在复制代码的大部分内容,否则无需征得我们的许可。例如,编写一个使用本书中几个代码块的程序不需要许可。销售或分发 O’Reilly 图书的示例需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书中大量的示例代码整合到您产品的文档中需要许可。

我们感谢但不需要署名。署名通常包括标题,作者,出版商和 ISBN。例如:“Cloud Native Go by Matthew A. Titmus (O’Reilly). Copyright 2021 Matthew A. Titmus, 978-1-492-07633-9.”

如果您认为您使用的代码示例超出了合理使用范围或上述许可的权限,请随时通过permissions@oreilly.com与我们联系。

O’Reilly 在线学习

注意

40 多年来,O’Reilly Media 提供技术和商业培训,知识和见解,帮助公司取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台让您随时访问现场培训课程、深入学习路径、交互式编码环境,以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。有关更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送至出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为本书建立了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/cloud-native-go

发送电子邮件至bookquestions@oreilly.com,以评论或询问有关本书的技术问题。

有关我们的书籍、课程、会议和新闻的更多信息,请访问我们的网站http://www.oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 关注我们:http://twitter.com/oreillymedia

在 YouTube 观看我们:http://www.youtube.com/oreillymedia

致谢

首先,我要感谢我的妻子和儿子。自从你们进入我的生活以来,你们是我做每件好事的动力,是让我保持方向真实、目光向天空的指南星。

致我们不久前失去的父亲。你是我所认识的最接近真正文艺复兴人的人,同时还能保持最善良、最谦逊。长大后,我仍想像你一样。

致 Mary。没有人比她更深刻地感受到他的缺席。你是家人,永远是家人,即使我没有经常给你打电话。爸爸会为你的坚韧和优雅感到骄傲。

致 Sarah。我总是对你的坚强和毅力感到惊讶。自从你能说话以来,你聪明的头脑使你成为我最坚定的盟友和最激烈的对手。别告诉 Nathan,但你是我最喜欢的兄弟姐妹。

致 Nathan。如果我们都继承了爸爸三分之一的天才,你得到了他的心。我经常不这么说,但我为你和你的成就感到非常骄傲。别告诉 Sarah,但你是我最喜欢的兄弟姐妹。

致妈妈。你坚强聪明,色彩斑斓而不拘一格。谢谢你教会我始终做实际需要的事情,不管别人怎么想。保持怪异,记得喂鸡。

致 Albert。你有一颗巨大的心和无穷的耐心。感谢你加入我们的家庭;因为有你,我们变得更好。

致我的其他家人们。我很少有机会见到你们,我非常想念你们,但每当我需要时,你们总是在那里支持我。感谢你们和我一起庆祝胜利,支持我度过挫折。

致 Walt 和 Alvaro,即使我换了工作也似乎摆脱不了你们。当我需要时,感谢你们的热情支持,以及当我需要现实主义时的坦率。你们让我成为一名更好的工程师。同时,感谢你们介绍我认识了 Will Wight 的 Cradle 系列,以及随之而来的沉迷。

致“Jeff Classic”,“New Jeff”,Alex,Markan,Priyanka,Sam,Owen,Matt M.,Marius,Peter,Rohit 以及我在 Flatiron Health 的所有朋友和同事。感谢你们不仅允许我专注于这个努力,还支持我和我的工作,充当我的咨询板,充当我的试读者和评论家,同时也鼓励我并成为我的推动者。

致所有在纽约和全球的 CoffeeOps 的朋友们。你们慷慨地让我向你们倾诉我的想法,并挑战你们,你们也反过来挑战了我。因为有了你们的贡献,这本书变得更好了。

致著名的可观察性专家和神谕 Liz Fong-Jones。你的指导、方向和代码示例是无价的,没有你的慷慨,这本书将会更难写,结果也会更差。

致我的技术审阅者 Lee Atchison,Alvaro Atienza,David Nicponski,Natalie Pistunovich 和 James Quigley。感谢你们有耐心地阅读我写的每一个字(甚至是脚注)。因为你们的敏锐目光和辛勤工作,这本书变得更好了。

最后,致 O’Reilly Media 辛勤工作的整个编辑和艺术团队,我有幸与你们共事,特别是 Amelia Blevins,Danny Elfanbaum 和 Zan McQuade。2020 年确实是一个非常有趣的一年,但是你们的善良、耐心和支持帮助我度过了难关。

¹ 包括 CNCF Sandbox、Incubating 和 Graduated 的基于代码的(非规范)项目,截至 2021 年 2 月。

第一部分: Going Cloud Native

第一章:什么是“云原生”应用程序?

语言中最危险的短语是:“我们一直都是这样做的。”¹

格雷斯·霍普,《计算机世界》(1976 年 1 月)

如果你正在阅读这本书,那么毫无疑问你至少听过云原生这个术语。更有可能的是,你可能已经看过一些由供应商撰写的充满兴奋和目光中有美元符号的无数文章。如果这是你迄今对该术语的主要经历,那么你可能会原谅认为这个术语是模糊和炒作的,只是另一个可能起源于有用内容但后来被试图向你销售某些东西的人接管的市场表达。参见:敏捷开发,DevOps。

基于类似的原因,搜索“云原生定义”的网络搜索可能会让你认为使一个应用程序成为云原生只需要使用“正确”的语言²或框架,或者使用“正确”的技术。当然,你选择的语言可以显著地简化或加大你的生活,但这既不是必要条件也不是充分条件来使一个应用程序成为云原生。

那么,云原生仅仅是应用程序运行的地点问题吗?术语云原生确实暗示了这一点。你只需将你那些笨拙的³老应用程序装入容器,并在 Kubernetes 中运行,现在你就是云原生了,对吗?不对。你所做的只是让你的应用程序更难部署,更难管理。⁴ 在 Kubernetes 中的笨拙应用程序依然是笨拙的。

那么,什么是云原生应用程序?在本章中,我们将确切地回答这个问题。首先,我们将审视计算服务范式的历史,特别是到目前为止,并讨论规模扩展的无情压力如何推动(并继续推动)提供高可靠性的技术的发展和采纳。最后,我们将确定与这类应用程序相关联的具体特征。

迄今为止的故事

网络应用程序的故事就是规模扩展的故事。

20 世纪 50 年代后期引入了大型机计算机。当时,每个程序和数据片段都存储在一台巨大的机器中,用户通过自身没有计算能力的哑终端访问。所有的逻辑和数据都聚集在一起,形成一个庞大而简单的单体。那时候是一个更简单的时代。

1980 年代随着廉价的网络连接 PC 的到来,一切都发生了变化。与哑终端不同,PC 能够进行一些计算,使得可以将一些应用程序逻辑卸载到它们身上。这种新的多层架构——将表示逻辑、业务逻辑和数据分开(图 1-1)——首次使得网络应用程序的各个组件可以独立修改或替换。

cngo 0101

图 1-1. 传统的三层架构,明确定义了表示层、业务逻辑层和数据层组件

在 1990 年代,全球网络的普及和随后的“点 com”黄金热潮向世界展示了软件即服务(SaaS)。整个行业建立在 SaaS 模型之上,推动了更复杂、资源需求更大的应用程序的发展,这些应用程序开发、维护和部署难度也因此增加。突然之间,经典的多层架构已经不再足够。作为回应,业务逻辑开始分解成可以独立开发、维护和部署的子组件,引领微服务时代的来临。

2006 年,亚马逊推出了亚马逊网络服务(AWS),其中包括弹性计算云(EC2)服务。尽管 AWS 并不是第一个基础设施即服务(IaaS)提供商,但它彻底改变了数据存储和计算资源的按需可用性,将云计算——以及快速扩展的能力——带给了大众,催生了大规模资源向“云端”的迁移。

不幸的是,组织很快就意识到在大规模运行中生活并不容易。糟糕的事情屡屡发生,当你处理数百甚至数千个资源时,糟糕的事情会频频发生。流量会急剧上升或下降,重要的硬件会故障,上游依赖突然变得不可预测且无法访问。即使一段时间内没有发生问题,你仍然需要部署和管理所有这些资源。在这种规模下,人类手动跟上所有这些问题是不可能的(或者至少是极其不切实际的)。

什么是云原生?

从根本上讲,一个真正的云原生应用程序融合了我们在过去 60 年中运行大规模网络应用程序所学到的一切。它们在面对急剧变化的负载时具备可伸缩性,在环境不确定性面前表现坚韧,并且在面对不断变化的需求时易于管理。换句话说,云原生应用程序是为了在残酷而不确定的宇宙中生存而构建的。

那么我们如何定义“云原生”这个术语呢?幸运的是,我们大家不必自己定义。云原生计算基金会——著名 Linux 基金会的一个分支机构,也是该领域的权威——已经为我们做好了这项工作。

云原生技术使组织能够在现代、动态的环境中(如公共、私有和混合云中)构建和运行可扩展的应用程序……

这些技术使得系统松耦合、具有弹性、易管理且可观测。结合强大的自动化,工程师可以频繁且可预测地进行高影响变更,而减少不必要的劳动。⁶

云原生计算基金会,CNCF 云原生定义 v1.0

根据此定义,云原生应用不仅仅是在云中运行的应用程序。它们还是可扩展松耦合具有弹性易管理可观测的。这些“云原生特性”共同构成了系统成为云原生的基础。

事实证明,这些词各自具有相当具体的含义,让我们来看看。

可扩展性

在云计算背景下,可扩展性可以定义为系统在面对需求显著上升或下降时继续按预期行为的能力。如果一个系统在需求急剧增加期间或之后无需重构即可执行其预期功能,则可以认为该系统具有可扩展性。

因为不可扩展的服务在初始条件下似乎可以完美运行,所以可扩展性并非在服务设计期间总是主要考虑因素。虽然这在短期内可能没问题,但那些无法远超其原始预期的服务也有其生命周期的限制价值。此外,对于可扩展性进行重构通常非常困难,因此考虑到可扩展性可以在长远节省时间和金钱。

服务可以按两种不同方式进行扩展,每种方式都有其自身的优缺点:

垂直扩展

一个系统可以通过调整已分配给它的硬件资源来进行垂直扩展(或升级)。例如,通过向运行在专用计算实例上的数据库添加内存或 CPU。垂直扩展的好处在于技术上相对直接,但任何给定实例只能升级到一定程度。

水平扩展

一个系统可以通过增加(或减少)服务实例来进行水平扩展(或扩展外部)。例如,通过增加负载均衡器后面的服务节点或 Kubernetes 中的容器数量,或其他容器编排系统中的容器。这种策略具有许多优点,包括冗余性和摆脱可用实例尺寸的限制。但更多的副本意味着更大的设计和管理复杂性,并非所有服务都可以进行水平扩展。

假设有两种服务扩展方式——纵向扩展横向扩展——这是否意味着任何可以进行硬件纵向扩展(并能够利用增加的硬件资源)的服务都是“可扩展”的?如果你想挑毛病,那当然,到一定程度是的。但它有多可扩展呢?纵向扩展在本质上受限于可用计算资源的大小,因此仅能进行纵向扩展的服务根本就不太可扩展。如果你想要实现十倍、百倍或千倍的扩展,你的服务真的必须是横向可扩展的。

那么,横向可扩展的服务与不可扩展的服务有什么区别呢?归根结底只有一点:状态。一个不维护任何应用状态的服务——或者已经非常谨慎地设计了将其状态分布在服务副本之间的服务——将相对容易进行横向扩展。对于任何其他应用程序来说,这将是困难的。就是这么简单。

可扩展性、状态和冗余的概念将在第七章中更详细地讨论。

松散耦合

松散耦合是一种系统属性和设计策略,其中系统的各个组件对其他组件的了解极少。当一个组件的变化通常不需要对另一个组件进行变更时,可以说两个系统是松散耦合的。

例如,Web 服务器和 Web 浏览器可以被认为是松散耦合的:服务器可以更新或甚至完全替换而不影响我们的浏览器。在它们的情况下,这是可能的,因为标准 Web 服务器已经约定它们将使用一组标准协议进行通信。⁷ 换句话说,它们提供了一个服务契约。想象一下,如果世界上所有的 Web 浏览器每次 NGINX 或 httpd 发布新版本时都必须更新会是多么混乱!⁸

可以说,“松散耦合”只是微服务架构整体目标的重申:将组件分区,使得一个组件的变化不一定影响另一个组件。这可能确实如此。然而,这一原则经常被忽视,需要重申。松散耦合的好处——以及忽视它可能带来的后果——不容小觑。很容易创建一个“最坏的情况”系统,即管理和复杂性开销与具有多个服务的依赖性和纠缠性相结合的系统:可怕的分布式单体

不幸的是,并没有什么魔法技术或协议可以防止你的服务紧密耦合。任何数据交换格式都可能被误用。然而,有几种方法可以帮助,比如使用声明式 API 和良好的版本控制实践,这些方法可以创建既松散耦合又可修改的服务。

这些技术和实践将在第八章中详细讨论和演示。

弹性

弹性(与容错性大致同义)是衡量系统如何抵御和恢复错误和故障的指标。如果系统能够继续正确运行——即使可能降低一定水平——而不是在系统的某个部分发生故障时完全失败,那么该系统可以被视为具有弹性

当我们讨论弹性(以及其他“云原生特性”,尤其是在讨论弹性时),我们经常使用“系统”这个词。系统(system)根据使用方式不同,可以指任何复杂的互联服务网(比如整个分布式应用程序),也可以指密切相关组件的集合(比如单个功能或服务实例的副本),或者指运行在单台机器上的单个进程。每个系统由多个子系统组成,而每个子系统又由子子系统组成,子子系统本身又由子子子系统组成。这是无限递归的。

在系统工程的语言中,任何系统都可能存在缺陷或故障,在软件世界中我们常称之为错误。我们都太熟悉了,在特定条件下,任何故障都可能导致错误,这是指系统的实际行为与其预期行为之间的任何差异。错误有可能导致系统无法执行其所需功能:即失败。但事情并不止于此:子系统或组件中的故障会成为更大系统中的故障;任何未被正确包含的故障都有可能向上级联,最终导致整个系统失败。

在理想情况下,每个系统都应精心设计,以防止任何故障的发生,但这是一个不现实的目标。你无法防止每一种可能的故障,试图这样做是低效且无益的。然而,通过假设系统的所有组件都必然会失败——事实上是这样——并设计它们以应对潜在故障并限制故障的影响,你可以创建一个功能健全的系统,即使其中某些组件发生故障也能正常运行。

有许多设计系统弹性的方法。部署冗余组件可能是最常见的方法,但这也假设一个故障不会影响同一类型的所有组件。电路断路器和重试逻辑可以用来防止故障在组件之间传播。甚至可以对故障组件进行排除或者故意失败,以造福更大的系统。

我们将在第九章中更深入地讨论所有这些方法(以及更多内容)。

可管理性

系统的可管理性是指能够多么容易(或难)地修改其行为,以保持其安全、平稳运行,并与变化的需求保持一致。如果能在不必修改其代码的情况下充分改变其行为,系统就可以被视为可管理的

作为一个系统属性,管理能力比一些更抢眼的属性如可扩展性或可观察性得到的关注要少。但在复杂的分布式系统中,它同样至关重要。

例如,想象一个假设系统,包括一个服务和一个数据库,服务通过一个 URL 引用数据库。如果需要将该服务更新为指向另一个数据库,如果 URL 是硬编码的,你可能需要更新代码并重新部署,这依赖于系统可能会很麻烦。当然,你可以更新 DNS 记录指向新位置,但如果需要重新部署开发版本的服务,其中包含自己的开发数据库,又该如何处理?

一个可管理的系统可以将这个值表示为一个可以轻松修改的环境变量;如果使用该服务部署在 Kubernetes 中,调整其行为可能只需更新 ConfigMap 中的一个值。更复杂的系统甚至可以提供一个声明性的 API,开发者可以用来告诉系统她期望的行为。没有一个单一的正确答案。¹⁰

管理能力不仅限于配置更改。它涵盖了系统行为的所有可能维度,无论是激活功能标志、轮换凭证或 TLS 证书,甚至(也许尤其是)部署或升级(或降级)系统组件。

可管理的系统设计用于适应性,并可以轻松调整以适应变化的功能、环境或安全需求。而不可管理的系统则往往更加脆弱,经常需要临时的,往往是手动的更改。管理此类系统所涉及的开销在根本上限制了其可扩展性、可用性和可靠性。

管理能力的概念及在 Go 中实施它们的一些首选实践将在第十章中更深入地讨论。

可观察性

系统的可观察性是衡量其内部状态如何能从其外部输出知识中推断出来的度量。当可以快速且一致地提出关于系统的新问题,而不需要大量先验知识或重新仪表化或构建新代码时,系统可以被认为是可观察的。

乍看之下,这听起来可能很简单:只需加入一些日志记录和几个仪表板,你的系统就可以被观察到了,对吗?几乎可以肯定不是这样的。尤其是在现代复杂系统中,几乎任何问题都是多个问题同时出现的体现。LAMP 堆栈时代已经结束了;现在的情况更加困难。

这并不是说度量、日志记录和跟踪不重要。相反,它们代表了观测性的基本组成部分。但仅仅存在它们还不够:数据不等于信息。它们需要以正确的方式使用。它们需要丰富。它们需要共同能够回答你甚至从未想过的问题。

检测和调试问题的能力是维护和发展强大系统的基本要求。但在分布式系统中,弄清楚问题出在哪里通常已经足够困难。复杂系统实在太……复杂。任何给定系统可能发生故障状态的数量与其每个组件的可能部分和完全故障状态数量的乘积成正比,而且不可能预测所有这些状态。仅仅关注我们预期会发生故障的事物的传统方法是不够的。

观测性中的新兴实践可以看作是监控演化的结果。多年来,我们在设计、构建和维护复杂系统的经验表明,传统的仪表化方法——包括但不限于仪表板、非结构化日志或对各种“已知未知”的警报——根本无法应对现代分布式系统所面临的挑战。

观测性是一个复杂而微妙的主题,但基本上归结为这样:充分仪表化你的系统,在真实的场景下,以便在未来能够回答你尚未想到的问题。

观测性的概念——以及一些实施建议——将在第十一章中更深入地讨论。

为什么云原生如此重要?

向“云原生”转变是环境压力和选择驱动的架构和技术适应的一个例子。这是进化的过程——适者生存。请跟我一起,我是一个生物学家出身。

亿万年前,在时间的黎明时代¹²,应用程序会构建和部署(通常手工操作)到一个或少数几个服务器上,在那里它们会被精心维护和培育。如果它们生病了,它们会被精心护理好。如果一个服务停止运行,你通常可以通过重新启动来修复它。观测性意味着登录到服务器运行top命令并查看日志。那是一个更简单的时代。

1997 年,工业化国家只有 11%的人口,全球仅有 2%的人口是常规互联网用户。随后的几年中,互联网接入和采用经历了指数级增长,到了 2017 年,工业化国家的比例飙升至 81%,全球也达到了 48%¹³,并持续增长。

所有这些用户及其资金对服务施加了压力,产生了显著的扩展激励。此外,随着用户的复杂程度和对 Web 服务依赖的增加,他们对其喜爱的 Web 应用程序既希望功能丰富又始终可用。

结果是,现在依然存在着对规模、复杂性和可靠性的显著进化压力。然而,这三个属性并不总是完美结合在一起,传统的方法简单地无法跟上。必须创造新的技术和实践方法。

幸运的是,公共云和 IaaS 的引入使得扩展基础设施相对简单化了。依赖性的缺陷通常可以通过大量来弥补。但这也带来了新的问题。如何管理一百台服务器?一千台?一万台?如何在它们上面安装或升级应用程序?当应用程序运行不正常时如何调试?你怎么知道它们的健康状态?在小规模时可能只是令人讨厌的问题,在大规模时往往变得非常棘手。

云原生之所以存在,是因为规模是我们所有问题的起因(和解决方案)。这不是魔术,也不是特别的。除了那些花哨的语言之外,云原生的技术和技术手段的存在,别无他用,只是为了能够利用“云”的好处(数量),同时弥补其缺陷(可靠性的不足)。

概要

在这一章中,我们谈到了计算历史的相当一部分,以及现在我们称之为“云原生”的东西并不是一种新现象,而是技术需求驱动创新,进而又驱动更多需求的必然结果。

但归根结底,所有这些花哨的话语都归结为一个核心问题:如今的应用程序必须能够可靠地为大量用户提供服务。我们称之为“云原生”的技术和技术手段,代表了构建具备可扩展性、适应性和弹性的服务的最佳现代实践。

那么这一切与 Go 语言有什么关系呢?事实证明,云原生基础设施需要云原生工具。在第二章中,我们将开始讨论这究竟意味着什么。

¹ Surden, Esther. “Privacy Laws May Usher in Defensive DP: Hopper.” Computerworld, 1976 年 1 月 26 日, 第 9 页。

² 那就是 Go 语言。别误会 —— 毕竟这本书还是关于 Go 语言的。

³ “拙劣解决方案”是指“一个笨拙或不优雅的解决方案”。这是一个迷人的词汇,背后有着迷人的历史。

⁴ 你是否曾经想过为什么那么多 Kubernetes 迁移失败?

⁵ 尤其是对我来说。我有机会写这本很酷的书。

⁶ 云原生计算基金会。《CNCF 云原生定义 v1.0》,GitHub,2020 年 12 月 7 日。https://oreil.ly/KJuTr

⁷ 那些记得 20 世纪 90 年代的浏览器大战的人会记得这并不总是严格正确的。

⁸ 或者如果每个网站都需要不同的浏览器。那会很糟糕,不是吗?

⁹ 如果你对完整的学术处理感兴趣,我强烈推荐由 Kishor S. Trivedi 和 Andrea Bobbio 撰写的可靠性与可用性工程

¹⁰ 有一些错误的。

¹¹ 而且,它们两个都以M开头。非常令人困惑。

¹² 那时候是 20 世纪 90 年代。

¹³ 国际电信联盟(ITU)。"1997 至 2007 年每百名居民的互联网用户"和"2005 至 2017 年每百名居民的互联网用户"。ICT 数据和统计(IDS)

第二章:为什么 Go 主导了云原生世界

任何愚蠢的人都可以让事物变得更大、更复杂和更暴力。反其道而行之需要一些天才的触觉——以及大量的勇气。¹

E.F. 舒马赫,《小即美》(1973 年 8 月)

Go 语言背后的动机

Go 的理念始于 2007 年 9 月的谷歌,这不可避免地是将一群聪明人聚在一起并且让他们感到沮丧的结果。

有关人士是罗伯特·格里塞默、罗布·派克和肯·汤普森;他们三人因各自在设计其他语言方面的工作而广受赞誉。他们集体关注的焦点是当时可用的整套编程语言,他们发现这些语言并不适合描述谷歌正在构建的分布式、可扩展和弹性服务的任务。²

本质上,当今普通的编程语言是在多处理器尚未普及、网络尚未如此普及的时代开发的。它们对多核处理和网络的支持——现代“云原生”服务的基本构建模块³——通常受到限制或需要非同寻常的努力来利用。简单来说,编程语言没有跟上现代软件开发的需求。

面向云原生世界的特性

他们的挫折很多,但所有这些挫折归结为一件事:他们正在使用的语言的不必要复杂性使得构建服务器软件变得更加困难。这些问题包括但不限于:⁴

程序的可理解性低

代码变得难以阅读。不必要的簿记和重复由功能重叠的特性加剧,这些特性往往鼓励聪明而不是清晰。

构建速度慢

语言的构建和多年的功能膨胀导致构建时间长达数分钟甚至数小时,即使在大型构建集群上也是如此。

效率低下

许多程序员对上述问题的反应是采用更灵活、动态的语言,有效地以表达能力换取效率和类型安全性。

更新成本高昂

甚至是语言的次要版本之间的不兼容性,以及它可能有的任何依赖项(及其传递依赖项!),经常使得更新成为一种令人沮丧的练习。

多年来,已经提出了多种——通常相当巧妙的——解决方案来解决这些问题,通常在过程中引入了额外的复杂性。显然,这些问题不能仅通过新的 API 或语言特性来解决。因此,Go 的设计师们设想了一种现代化的语言,这是第一种为云原生时代而构建的语言,支持现代网络化和多核计算,既富有表现力又易于理解,让用户能够专注于解决问题,而不是苦于语言的限制。

Go 语言的结果,与其具有的特性一样显著,还因其明确不具备的一些特性(和非特性)而闻名。这些特性(和非特性)及其背后的动机在以下章节中讨论。

组合和结构化类型

自 20 世纪 60 年代以来,基于“对象”和各种“类型”拥有各种属性的面向对象编程就已存在,但真正流行起来是在 20 世纪 90 年代初至中期,随着 Java 的发布和向 C++添加面向对象特性。从那时起,它已经成为主导的编程范式,甚至今天仍然如此。

面向对象编程的吸引力是令人着迷的,其背后的理论甚至具有某种直观上的意义。数据和行为可以与事物的类型相关联,这些可以被子类型继承。这些类型的实例可以被概念化为具有属性和行为的有形对象——是一个更大系统建模具体的、现实世界的概念的组成部分。

然而,在实践中,使用继承的面向对象编程通常要求认真考虑类型之间的关系并精心设计,并且必须忠实遵循特定的设计模式和实践。因此,正如在图 2-1 中所示,面向对象编程的趋势是将重点从开发算法转向开发和维护分类学和本体论。

cngo 0201

图 2-1。随着时间的推移,面向对象编程趋向于分类学

Go 语言并非不具备允许多态行为和代码重用的面向对象特性。它也有一种类似类型的概念,即structs,这些可以具有属性和行为。它所拒绝的是继承以及伴随其而来的复杂关系,而是选择通过embedding较简单的类型在其中来组装更复杂的类型:这种方法被称为composition

具体而言,继承围绕着在类之间扩展“is a”的关系(即,汽车“是一个”机动车),而组合允许使用“has a”关系构建类型,以定义它们可以做什么(即,汽车“有一个”发动机)。在实践中,这允许更大的设计灵活性,同时允许创建的业务域不太容易受到“家庭成员”特异性的干扰。

类似地,虽然 Go 使用接口来描述行为契约,但它没有“is a”概念,因此等价性是通过检查类型的定义而不是其血统来确定的。例如,给定一个定义了Area方法的Shape接口,任何具有Area方法的类型将隐式满足Shape接口,而无需显式声明自己为Shape

type Shape interface {                  // Any Shape must have an Area
    Area() float64
}

type Rectangle struct {                 // Rectangle doesn't explicitly
    width, height float64               // declare itself to be a Shape
}

func (Rectangle r) Area() float64 {     // Rectangle has an Area method; it
    return r.width * r.height           // satisfies the Shape interface
}

这种结构化类型机制,在编译时被描述为鸭子类型⁵,大大减少了繁琐的分类体系的维护,这些分类体系使得像 Java 和 C++这样的更传统的面向对象语言负担过重,从而解放程序员专注于数据结构和算法。

可理解性

像 C++和 Java 这样的服务语言经常因笨拙、难以使用和冗长而受到批评。它们需要大量重复和仔细的账务记录,使得项目不得不背负冗余的样板代码,这些代码会妨碍程序员专注于解决问题以外的事务,并在所有结果复杂性的重压下限制项目的可扩展性。

Go 是为大型项目和众多贡献者设计的。其简约的设计(只有 25 个关键字和 1 种循环类型)以及其编译器的强烈观点,强烈倾向于清晰而非巧妙。⁶ 这反过来鼓励简单和高效率,而非混乱和复杂性。由此产生的代码相对容易消化、审查和维护,并且减少了“陷阱”。

CSP 风格的并发

大多数主流语言提供了一些运行多个进程并发的方式,允许程序由独立执行的进程组成。正确使用并发可以非常有用,但它也引入了许多挑战,特别是关于事件排序、进程间通信和对共享资源访问的协调。

传统上,程序员通常通过允许进程共享一些内存来解决这些挑战,然后在此基础上用锁或互斥体包装以限制一次只能访问一个进程。但即使实施良好,这种策略也可能产生大量繁琐的账务开销。忘记锁定或解锁共享内存的可能性也很高,可能会引入竞态条件、死锁或并发修改。这类错误非常难以调试。

另一方面,Go 语言更倾向于基于一个称为“通信顺序进程(CSP)”的正式语言的策略,这个策略首次由托尼·霍尔在同名的影响力论文中描述⁷,该论文描述了通过通道进行消息传递来表达并发系统中的交互模式。

Go 通过像goroutineschannels这样的语言原语实现的并发模型,使得 Go 能够优雅地构建并发软件,而不完全依赖于锁定。它鼓励开发者限制共享内存,而是允许进程完全通过传递消息来互动。这个理念通常由 Go 的谚语总结:

不要通过共享内存进行通信。相反,通过通信来共享内存。

Go 谚语

快速构建

Go 语言的主要动机之一是当时某些语言的令人发狂的长构建时间,(10)即使在 Google 的大型编译集群上,也常常需要数分钟甚至数小时才能完成。这大大消耗了开发时间,削弱了开发者的生产力。鉴于 Go 的主要目的是增强而非阻碍开发者的生产力,长时间的构建过程必须消失。

本书范围之外,Go 编译器的具体细节(以及我的专业知识之外)。然而,简而言之,Go 语言的设计旨在提供一个自由于复杂关系的软件构建模型,极大简化了依赖分析,并消除了 C 风格的包含文件和库以及伴随其而来的开销。因此,即使在相对较低性能的硬件上,如 MacBook Pro,使用 2.4 GHz 8 核 Intel i9 处理器和 32 GB RAM,在 Kubernetes v1.20.2 上构建所有 180 万行 Go 代码也只需约 45 秒真实时间:(11)

mtitmus:~/workspace/kubernetes[MASTER]$ time make

real    0m45.309s
user    1m39.609s
sys     0m43.559s

当然,这并非没有妥协。对 Go 语言的任何建议性改动都在一定程度上考虑其对构建时间的可能影响;一些原本有前途的提案因增加构建时间而被拒绝。

语言稳定性

Go 1 于 2012 年 3 月发布,定义了语言规范和一组核心 API 的规范。这自然导致了 Go 设计团队对 Go 1 用户的明确承诺,即使用 Go 1 编写的程序将在整个 Go 1 规范的生命周期内继续编译和正确运行,无需修改。也就是说,今天能工作的 Go 程序可以预期在未来的“点”版本(如 Go 1.1、Go 1.2 等)中仍然能够工作。(12)

这与许多其他语言形成鲜明对比,后者有时会热情地添加新功能,逐渐增加语言和其中任何写入的复杂性,最终导致一度优雅的语言变成一个庞大的功能景观,往往难以掌握。(13)

Go 团队认为这种卓越的语言稳定性是 Go 的重要特性;它使用户能够信任 Go 并在其基础上构建。它允许库被轻松消费和构建,并显著降低了更新成本,尤其是对于大型项目和组织而言。重要的是,它还允许 Go 社区使用 Go 并从中学习;花时间用这种语言写作,而不是写这种语言。

这并不意味着 Go 不会发展壮大:API 和核心语言肯定可以获取新的包和功能¹⁴,而且确实有许多关于此的提案¹⁵,但不会以破坏现有 Go 1 代码的方式。

话虽如此,很可能¹⁶实际上永远不会有 Go 2。更有可能的是,Go 1 将持续无限期兼容;并且在引入破坏性变更的不太可能事件中,Go 将提供一个转换实用程序,就像在转向 Go 1 时使用的 go fix 命令一样。

内存安全

Go 的设计者们非常努力地确保这门语言不受直接内存访问带来的各种错误和安全漏洞(更不用说繁琐的簿记了)。指针是严格类型化的,并且总是初始化为某个值(即使该值是 nil),指针算术明确禁止。内置的引用类型如映射和通道,内部表示为指向可变结构的指针,由 make 函数初始化。简而言之,Go 既不需要也不允许像 C 和 C++ 那样的手动内存管理和操作,这些对于复杂性和内存安全的后果不可低估。

对于程序员来说,Go 是一种具有垃圾收集功能的语言,这消除了需要为每个分配的字节仔细跟踪和释放内存的需求,减轻了程序员的很大负担。没有 malloc 的生活是自由的。

更重要的是,通过消除手动内存管理和操作——甚至是指针算术——Go 的设计者们使其有效地免疫了一整类内存错误和它们可能引入的安全漏洞。没有内存泄漏,没有缓冲区溢出,没有地址空间布局随机化。什么都没有。

当然,这种简单和开发便利性是有一些折衷的,虽然 Go 的垃圾收集器非常复杂,但也带来了一些开销。因此,Go 在纯粹的原始执行速度上无法与 C++ 和 Rust 等语言竞争。尽管如此,正如我们在下一节看到的,Go 在这个领域仍然表现出色。

性能

面对像 C++ 和 Java 这样静态类型的编译语言的缓慢构建和繁琐的簿记,许多程序员转向更动态、更流畅的语言,如 Python。虽然这些语言在许多方面表现出色,但相对于 Go、C++ 和 Java 等编译语言来说,它们的效率也很低。

这在 表 2-1 的基准测试中已经很明显。当然,总体而言,基准测试应该带着一颗谨慎的心看待,但有些结果确实非常引人注目。

表 2-1. 常见服务语言的相对基准(秒)^(a)

C++ Go Java NodeJS Python3 Ruby Rust
Fannkuch-Redux 8.08 8.28 11.00 11.89 367.49 1255.50 7.28
FASTA 0.78 1.20 1.20 2.02 39.10 31.29 0.74
K-核苷酸 1.95 8.29 5.00 15.48 46.37 72.19 2.76
曼德布罗特集 0.84 3.75 4.11 4.03 172.58 259.25 0.93
N-体问题 4.09 6.38 6.75 8.36 586.17 253.50 3.31
谱范数 0.72 1.43 4.09 1.84 118.40 113.92 0.71
^(a) 戈伊,艾萨克。计算机语言基准测试游戏。2021 年 1 月 18 日。https://oreil.ly/bQFjc

在检查过程中,似乎可以将结果分为三类,分别对应生成它们所使用的语言类型:

  • 编译型、严格类型检查且手动内存管理(C++、Rust)

  • 编译型、严格类型检查且具有垃圾回收(Go、Java)

  • 解释型、动态类型语言(Python、Ruby)

这些结果表明,尽管带有垃圾回收的语言通常比手动内存管理的语言稍慢,但这些差异似乎不足以在除了最苛刻的需求之外造成影响。

然而,解释型和编译型语言之间的差异是显著的。至少在这些例子中,Python,这种典型的动态语言,基准测试速度约为编译型语言的十到一百倍慢。当然,可以争论说这对许多——如果不是大多数——用途来说仍然完全足够,但对于云原生应用程序来说则不是这样,这些应用程序经常需要在不依赖潜在昂贵的升级的情况下忍受需求的显著波动。

静态链接

默认情况下,Go 程序直接编译成本地、静态链接的可执行二进制文件,其中包括所有必需的 Go 库和 Go 运行时。这会产生稍大一些的文件(大约对于一个“hello world”来说是 2MB 左右),但生成的二进制文件没有外部语言运行时需要安装,¹⁷ 或者外部库依赖需要升级或冲突,¹⁸ 可以轻松地分发给用户或部署到主机,而不用担心依赖性或环境冲突。

当你在使用容器时,这种能力特别有用。因为 Go 二进制文件不需要外部语言运行时甚至不需要分发,它们可以被构建成“空白”镜像,这些镜像没有父镜像。结果是一个非常小(仅几兆字节)的镜像,具有最小的部署延迟和数据传输开销。这些特点在像 Kubernetes 这样的编排系统中非常有用,这些系统可能需要定期获取镜像。

静态类型

在 Go 设计的早期阶段,其作者们不得不做出选择:是否要像 C++或 Java 那样静态类型,需要在使用之前明确定义变量,或者像 Python 那样动态类型,允许程序员在不定义变量的情况下为其赋值,因此编码速度通常更快?这并不是一个特别困难的决定;也没花多少时间。静态类型显然是明智的选择,但并不是任意的或基于个人偏好。¹⁹

首先,静态类型语言的类型正确性可以在编译时评估,使其更加高效(见表 2-1)。

Go 的设计者们明白,开发中花费的时间仅仅是项目总生命周期的一小部分,而在动态类型语言中提高编码速度的任何收益都被调试和维护这类代码的难度所弥补。毕竟,有哪个 Python 程序员没有因为试图将字符串用作整数而使其代码崩溃?

举例来说,看看以下 Python 代码片段:

my_variable = 0

while my_variable < 10:
    my_varaible = my_variable + 1   # Typo! Infinite loop!

看到了吗?如果你还没看到,请继续尝试。这可能需要一秒钟。

任何程序员都可能犯这种微妙的拼写错误,这种错误恰好也能生成完全有效的可执行 Python 代码。这只是 Go 将在编译时捕获的整个错误类别中的两个微不足道的示例,而不是(天啊)在生产中,通常更接近引入它们的位置。毕竟,众所周知,在开发周期的早期捕获错误,修复起来更容易(更便宜)。

最后,我甚至会断言一些有些争议的事情:类型语言更易读。Python 通常被誉为尤其易读,因其宽容的特性和略带英语风格的语法,²⁰但如果给你看到以下 Python 函数签名,你会怎么做呢?

def send(message, recipient):

message是一个字符串吗?recipient是在其他地方描述的某个类的实例吗?是的,这可以通过一些文档和一些合理的默认值来改进,但我们中的许多人都必须维护足够的代码,以知道这是一个相当遥远的愿望。显式定义的类型可以通过自动跟踪信息来引导开发,并减轻编写代码的心理负担,否则程序员将不得不通过为程序员和所有必须维护其代码的人提供文档来手动跟踪。

总结

如果第一章专注于什么使得系统云原生,那么这一章可以说是专注于什么使得语言,特别是 Go,成为构建云原生服务的良好选择。

然而,尽管云原生系统需要具备可伸缩性、松耦合性、弹性、可管理性和可观察性,一个面向云原生时代的语言必须能够做到不仅仅是构建具备这些特性的系统。毕竟,几乎任何语言在技术上都可以被用来构建这样的系统。那么是什么使得 Go 如此特别呢?

可以说,本章介绍的所有特性直接或间接地促进了前一章关于云原生特性的讨论。例如,并发性和内存安全性可以说有助于服务的可伸缩性,结构化类型允许松耦合,等等。但虽然 Go 是我所知道的唯一一个将所有这些特性集于一身的主流语言,它们是否真的那么新颖呢?

可能 Go 最引人注目的特性之一是其内置而非外挂的并发功能,这使得程序员能够更充分、更安全地利用现代网络和多核硬件。当然,Goroutines 和 channels 非常奇妙,使得构建具有弹性、高并发网络服务变得更加容易,但从技术上讲,如果考虑到像 Clojure 或 Crystal 这样不那么常见的语言,它们并非独一无二。

我认为 Go 真正优秀的地方在于其忠实地坚持清晰胜于聪明的原则,这一理念源于对于源代码是由人类为其他人类编写的的理解。²¹ 它能编译成机器码几乎是无关紧要。

Go 的设计是为了支持人们实际上的工作方式:团队协作,有时团队成员会变动,并且这些成员还可能同时从事其他工作。在这种环境下,代码的清晰度、“部落知识”的最小化以及快速迭代的能力是至关重要的。Go 的简洁性经常被误解和未被重视,但它使得程序员可以专注于解决问题,而不是与语言斗争。

在第三章中,我们将详细回顾 Go 语言的许多具体特性,从而近距离地看到这种简单性。

¹ Schumacher, E.F. “Small Is Beautiful.” The Radical Humanist, 1973 年 8 月, p. 22.

² 在“云原生”这个术语被创造出来之前,这些已经是“云原生”服务了。

³ 当然,在当时它们并不被称为“云原生”,对于 Google 而言它们只是“服务”。

⁴ Pike, Rob. “Google 内部的 Go 语言设计与软件工程.” Google, Inc., 2012. https://oreil.ly/6V9T1.

⁵ 在使用鸭子类型的语言中,对象的类型不如其定义的方法重要。换句话说,“如果它像鸭子一样走路和嘎嘎叫,那么它一定是鸭子。”

⁶ Cheney, Dave. “清晰胜于巧妙。” The Acme of Foolishness, 2019 年 7 月 19 日。https://oreil.ly/vJs0X.

⁷ Hoare, C.A.R. “通信顺序进程。” Communications of the ACM, vol. 21, no. 8, 1978 年 8 月, pp. 666–77。https://oreil.ly/CHiLt.

⁸ 至少在“主流”语言中,无论这是什么意思。

⁹ Gerrand, Andrew. “并发不等于并行。” Go Blog, 2016 年 1 月 16 日。https://oreil.ly/WXf4g.

¹⁰ C++。我们在讨论 C++。

¹¹ 不包括注释;Openhub.net. “Kubernetes.” Open Hub, Black Duck Software, Inc., 2021 年 1 月 18 日。https://oreil.ly/y5Rty.

¹² Go Team. “Go 1 及 Go 程序的未来。” Go Documentation. https://oreil.ly/Mqn0I.

¹³ 还记得 Java 1.1 吗?我记得 Java 1.1。当时虽然没有泛型、自动装箱或增强的for循环,但我们很开心。是的,我告诉你,很开心。

¹⁴ 我支持泛型团队。加油,参数化多态!

¹⁵ Go Team. “Go 提议变更。” GitHub, 2019 年 8 月 7 日。https://oreil.ly/folYF.

¹⁶ Pike, Rob. “悉尼 Golang Meetup—Rob Pike—Go 2 草案规范”(视频)。 YouTube, 2018 年 11 月 13 日。https://oreil.ly/YmMAd.

¹⁷ 没法和 Java 比。

¹⁸ 没法和 Python 比。

¹⁹ 在编程中,很少有像静态与动态类型、或者伟大的制表符与空格辩论一样引发讽刺评论的争论,对于这些,Go 的非官方立场是“闭嘴,谁在乎?”

²⁰ 我也因宽容的天性和有些像英语的语法而受到赞扬。

²¹ 或者在同一个人几个月思考其他事情后。

第二部分:云原生 Go 构建

第三章:Go 语言基础

一种不影响编程思维方式的语言是不值得学习的。¹

Alan Perlis,ACM SIGPLAN Notices(1982 年 9 月)

没有一本编程书籍会完整,而不至少简短地回顾其所选择的语言,所以我们在这里!

本章与更初级的书籍略有不同,我们假设您至少熟悉常见的编码范例,但可能对 Go 语法的一些细节有些生疏。因此,本章将侧重于 Go 语言的微妙之处,以及其基础知识。如需更深入地了解后者,我建议阅读 Go 语言编程(Caleb Doxsey 著,O'Reilly 出版社)或 Go 程序设计语言(Alan A. A. Donovan 和 Brian W. Kernighan 著,Addison-Wesley Professional 出版社)。

如果您对该语言相对陌生,您肯定会希望继续阅读。即使您对 Go 语言有些了解,您可能也想浏览本章内容:这里会有一两个宝藏。如果您是该语言的老手,可以直接进入下一章(或以讽刺的方式阅读并评判我)。

基本数据类型

Go 的基本数据类型是更复杂类型的基础构件,可以分为三个子类别:

  • 布尔类型仅包含一位信息 — truefalse — 表示某种逻辑结论或状态。

  • 表示简单浮点和有符号/无符号整数或复数的数值类型。

  • 代表不可变的 Unicode 代码点序列的字符串。

布尔值

布尔数据类型代表两个逻辑真值,以某种形式存在²于每一种编程语言中。它由 bool 类型表示,是一种特殊的 1 位整数类型,具有两个可能的值:

  • true

  • false

Go 支持所有典型的逻辑操作:

and := true && false
fmt.Println(and)        // "false"

or := true || false
fmt.Println(or)         // "true"

not := !true
fmt.Println(not)        // "false"
注意

有趣的是,Go 语言不包含逻辑异或运算符。但是,存在 ^ 运算符,但它仅用于按位异或操作。

简单数值

Go 具有一小部分系统命名的、浮点、有符号和无符号整数:

有符号整数

int8int16int32int64

无符号整数

uint8uint16uint32uint64

浮点数

float32float64

系统化命名很好,但代码是由人类用软软的人类大脑编写的,因此 Go 设计者提供了两个可爱的便利。

首先,有两种“机器相关”类型,简称为 intuint,其大小根据可用硬件确定。如果您的数字的特定大小并不重要,这些类型非常方便。遗憾的是,并没有机器相关的浮点数类型。

其次,有两种整数类型具有助记别名:byte,是 uint8 的别名;rune,是 uint32 的别名。

提示

对于大多数用途,通常只需使用intfloat64就足够了。

复数

Go 提供两种大小的复数,如果你感觉有点富有想象力:³ complex64complex128。这些可以通过浮点数后紧接着的i来表示为虚数字面量

var x complex64 = 3.1415i
fmt.Println(x)                  // "(0+3.1415i)"

复数非常有趣,但实际上并不经常使用,所以我在这里不会深入讨论它们。如果你像我希望的那样着迷于它们,《Go 编程语言》(Donovan 和 Kernighan)为它们提供了应有的全面解释。

字符串

字符串代表 Unicode 代码点序列。Go 中的字符串是不可变的:一旦创建,就无法更改字符串的内容。

Go 支持两种风格的字符串字面量,双引号风格(或解释字面量)和反引号风格(或原始字符串字面量)。例如,以下两个字符串字面量是等效的:

// The interpreted form
"Hello\nworld!\n"

// The raw form
`Hello
world!`

在这个解释字符串字面量中,每个\n字符对将被转义为一个换行符字符,每个\"字符对将被转义为一个双引号字符。

在幕后,字符串实际上只是围绕 UTF-8 编码的byte值切片的包装器,因此可以对切片和数组应用的任何操作也可以应用于字符串。如果你对切片还不清楚,现在可以阅读到“切片”部分。

变量

变量可以通过使用var关键字将标识符与某些类型值配对,并且可以随时更新,其一般形式为:

var name type = expression

然而,变量声明有相当大的灵活性:

  • 带初始化:var foo int = 42

  • 多个变量的情况:var foo, bar int = 42, 1302

  • 带类型推断:var foo = 42

  • 混合多种类型的变量:var b, f, s = true, 2.3, "four"

  • 未初始化(参见“零值”):var s string

注意

Go 对于杂乱的事物非常有主张:它讨厌它们。如果在函数中声明了一个变量但没有使用它,你的程序将拒绝编译。

短变量声明

Go 提供了一些语法糖,允许在函数内部使用:=操作符同时声明和赋值变量,而不是使用具有隐式类型的var声明。

短变量声明的一般形式为:

name := expression

这些可以用来声明单个和多个赋值:

  • 带初始化:percent := rand.Float64() * 100.0

  • 一次性多个变量:x, y := 0, 2

在实践中,短变量声明是 Go 中声明和初始化变量的最常见方式;var通常仅用于需要显式类型的局部变量,或者声明稍后将赋值的变量。

警告

记住:=是一个声明,而=是一个赋值操作符。如果:=操作符仅尝试重新声明已存在的变量,将在编译时失败。

有趣的是(有时会令人困惑),如果短变量声明在其左侧同时包含新变量和现有变量,那么该短变量声明将像对现有变量进行赋值一样。

零值

当变量声明时没有明确的值时,它将被分配给其类型的零值

  • 整数:0

  • 浮点数:0.0

  • 布尔值:false

  • 字符串:""(空字符串)

例如,让我们定义四个不同类型的变量,而不进行显式初始化:

var i int
var f float64
var b bool
var s string

现在,如果我们要使用这些变量,我们会发现它们实际上已经初始化为它们的零值:

fmt.Printf("integer: %d\n", i)   // integer: 0
fmt.Printf("float: %f\n", f)     // float: 0.000000
fmt.Printf("boolean: %t\n", b)   // boolean: false
fmt.Printf("string: %q\n", s)    // string: ""

您会注意到使用fmt.Printf函数,它允许更好地控制输出格式。如果您对这个函数或 Go 语言的格式化字符串不熟悉,请参阅下面的侧边栏。

空白标识符

空白标识符_(下划线)运算符表示,充当匿名占位符。它可以像声明中的任何其他标识符一样使用,只是它不引入绑定。

它通常被用作在赋值中选择性地忽略不需要的值的一种方式,在支持多返回值且要求没有未使用变量的语言中,这非常有用。例如,如果您想处理fmt.Printf返回的任何潜在错误,但不关心它写入的字节数,⁴,您可以执行以下操作:

str := "world"

_, err := fmt.Printf("Hello %s\n", str)
if err != nil {
    // Do something
}

空白标识符也可以用于仅因其副作用而导入包:

import _ "github.com/lib/pq"

以这种方式导入的包将像正常加载和初始化一样运行,包括触发其init函数,但除此之外将被忽略且无需引用或直接使用。

常量

常量与变量非常相似,使用const关键字将标识符与某个类型化值关联。然而,常量在某些重要方面与变量不同。首先,最明显的是,试图修改一个常量将在编译时生成错误。其次,常量必须在声明时赋值:它们没有零值。

varconst均可在包级和函数级别使用,如下所示:

const language string = "Go"

var favorite bool = true

func main() {
    const text = "Does %s rule? %t!"
    var output = fmt.Sprintf(text, language, favorite)

    fmt.Println(output)   // "Does Go rule? true!"
}

为了演示它们行为的相似性,前面的代码片段任意混合了常量和变量的显式类型定义以及类型推断。

最后,选择fmt.Sprintf对于本例并不重要,但如果您对 Go 语言的格式化字符串不清楚,可以参考“在 Go 中格式化 I/O”。

容器类型:数组、切片和映射

Go 语言有三种一流的容器类型,用于存储元素值的集合:

数组数组

一个固定长度的特定类型的零个或多个元素的序列。

切片

一个围绕可以在运行时调整大小的数组的抽象。

映射

允许将不同键与值“映射”或配对的关联数据结构。

作为容器类型,所有这些都有一个length属性,反映存储在该容器中的元素数量。可以使用内置函数len来查找任何数组、切片(包括字符串)或映射的长度。

数组

在 Go 语言中,与大多数其他主流语言一样,数组 是特定类型的零个或多个元素的固定长度序列。

可以通过包含长度声明来声明数组。数组的零值是一个包含指定长度的元素全为零值的数组。单个数组元素从0N-1进行索引,并且可以使用熟悉的括号表示法访问:

var a [3]int                    // Zero-value array of type [3]int
fmt.Println(a)                  // "[0 0 0]"
fmt.Println(a[1])               // "0"

a[1] = 42                       // Update second index
fmt.Println(a)                  // "[0 42 0]"
fmt.Println(a[1])               // "42"

i := a[1]
fmt.Println(i)                  // "42"

可以使用数组文字初始化数组,如下所示:

b := [3]int{2, 4, 6}

您还可以让编译器为您计算数组元素的数量:

b := [...]int{2, 4, 6}

在这两种情况下,b的类型都是[3]int

与所有容器类型一样,可以使用len内置函数来发现数组的长度:

fmt.Println(len(b))             // "3"
fmt.Println(b[len(b)-1])        // "6"

实际上,数组并不经常直接使用。相反,更常见的是使用 切片,一种行为(在所有实际目的上)类似于可调整大小数组的数组抽象类型。

切片

切片是 Go 语言中的一种数据类型,它们在传统数组周围提供了一个强大的抽象,使得与切片的操作看起来和感觉起来非常类似于操作数组。与数组一样,切片通过熟悉的括号表示法提供对特定类型元素序列的访问,索引从0N-1。然而,数组是固定长度的,而切片可以在运行时调整大小。

如图 3-1 所示,切片实际上是一个轻量级数据结构,具有三个组件:

  • 指向表示切片第一个元素的支持数组中某个元素的指针(不一定是数组的第一个元素)

  • 一个长度,表示切片中的元素数量

  • 一个容量,表示长度的上限值

如果未另行指定,则容量值等于切片起始位置到支持数组末尾的元素数。内置函数lencap分别提供切片的长度和容量。

cngo 0301

图 3-1. 由同一数组支持的两个切片

使用切片

创建切片与创建数组有所不同:切片仅根据其元素类型而不是其数量进行类型化。可以使用make内置函数创建具有非零长度的切片,如下所示:

n := make([]int, 3)         // Create an int slice with 3 elements

fmt.Println(n)              // "[0 0 0]"
fmt.Println(len(n))         // "3"; len works for slices and arrays

n[0] = 8
n[1] = 16
n[2] = 32

fmt.Println(n)              // "[8 16 32]"

正如您所见,使用切片的感觉与使用数组的感觉非常相似。与数组一样,切片的零值是一个包含指定长度的零值元素的切片,切片中的元素像在数组中一样进行索引和访问。

切片文字与数组文字声明方式相同,只是省略了元素计数:

m := []int{1}               // A literal []int declaration
fmt.Println(m)              // "[1]"

可以使用append内置函数扩展切片,它返回一个包含一个或多个附加到原始切片中的新值的扩展切片:

m = append(m, 2)            // Append 2 to m
fmt.Println(m)              // "[1 2]"

append内置函数也是可变参数的,这意味着它除了要附加的切片之外还可以接受可变数量的参数。可变参数函数将在“可变参数函数”中更详细地介绍:

m = append(m, 2)            // Append to m from the previous snippet
fmt.Println(m)              // "[1 2]"

m = append(m, 3, 4)
fmt.Println(m)              // "[1 2 3 4]"

m = append(m, m...)         // Append m to itself
fmt.Println(m)              // "[1 2 3 4 1 2 3 4]"

注意append内置函数返回附加的切片,而不是在原地修改切片。其背后的原因是,如果目标具有足够的容量来容纳新元素,则从原始的底层数组构造一个新切片。如果没有,将自动分配一个新的底层数组。

警告

注意append 返回 附加的切片。未存储它是一个常见的错误。

切片操作符

数组和切片(包括字符串)支持切片操作符,其语法为s[i:j],其中ij在范围0 ≤ i ≤ j ≤ cap(s)内。

例如:

s0 := []int{0, 1, 2, 3, 4, 5, 6}    // A slice literal
fmt.Println(s0)                     // "[0 1 2 3 4 5 6]"

在前面的片段中,我们定义了一个切片字面量。请记住,它与数组字面量非常相似,只是它不指示大小。

如果切片操作符的值ij被省略,它们将默认为0len(s),分别:

s1 := s0[:4]
fmt.Println(s1)                     // "[0 1 2 3]"

s2 := s0[3:]
fmt.Println(s2)                     // "[3 4 5 6]"

切片操作符将生成一个新的切片,其长度为j - i,由相同的数组支持。对此切片的更改将反映在底层数组中,随后反映在所有从同一数组派生的切片中:

s0[3] = 42                          // Change reflected in all 3 slices
fmt.Println(s0)                     // "[0 1 2 42 4 5 6]"
fmt.Println(s1)                     // "[0 1 2 42]"
fmt.Println(s2)                     // "[42 4 5 6]"

此效果在图 3-1中有更详细的说明。

字符串作为切片

关于 Go 如何在幕后实现字符串的主题实际上比你可能期望的要复杂得多,涉及到诸如字节、字符和符文之间的差异;Unicode 与 UTF-8 编码之间的区别;以及字符串与字符串字面量之间的差异。

目前,了解 Go 字符串本质上只是字节的只读切片就足够了,通常(但不是必须)包含一系列表示 Unicode 代码点的 UTF-8 序列,称为符文。Go 甚至允许您将字符串转换为byterune数组:

s := "foö"          // Unicode: f=0x66 o=0x6F ö=0xC3B6
r := []rune(s)
b := []byte(s)

通过这种方式将字符串s强制转换,我们能够揭示它作为字节切片或符文切片的身份。我们可以通过使用fmt.Printf%T(类型)和%v(值)标志(我们在“在 Go 中格式化 I/O”中介绍过)来输出结果来说明这一点:

fmt.Printf("%7T %v\n", s, s)    // "string foö"
fmt.Printf("%7T %v\n", r, r)    // "[]int32 [102 111 246]"
fmt.Printf("%7T %v\n", b, b)    // "[]uint8 [102 111 195 182]"

注意字符串字面量foö的值包含一些字符的混合,其编码可以包含在一个字节中(fo,编码分别为102111),以及一个无法包含在一个字节中的字符(ö,编码为195 182)。

注意

请记住,byterune类型是uint8int32的助记别名。

每条语句打印传递给它的变量的类型和值。如预期的那样,字符串值foö被字面打印。然而,接下来的两行很有趣。uint8(字节)切片包含四个字节,这些字节表示字符串的 UTF-8 编码(两个 1 字节码点和一个 2 字节码点)。int32(rune)切片包含三个值,表示各个字符的码点。

关于 Go 中的字符串编码还有很多内容,但我们的空间有限。如果你有兴趣了解更多,请看 Rob Pike 的《Go 博客》中的“Go 中的字符串、字节、符文和字符”深入研究这个主题。

地图

Go 的map数据类型引用了哈希表:这是一种非常有用的关联数据结构,允许将不同的键任意地“映射”到值作为键值对。这种数据结构在当今主流的语言中很常见:如果你从这些语言中的一个转到 Go,则可能已经使用过它们,也许是 Python 的dict,Ruby 的Hash,或 Java 的HashMap的形式。

Go 中的地图类型写作map[K]V,其中KV分别是其键和值的类型。任何可以使用==运算符进行比较的类型都可以用作键,并且KV不需要是相同的类型。例如,可以将string键映射到float32值。

可以使用内置的make函数初始化地图,并且可以使用通常的name[key]语法引用其值。我们的老朋友len将返回地图中键/值对的数量;delete内置函数可以删除键/值对:

freezing := make(map[string]float32)    // Empty map of string to float32

freezing["celsius"] = 0.0
freezing["fahrenheit"] = 32.0
freezing["kelvin"] = 273.2

fmt.Println(freezing["kelvin"])         // "273.2"
fmt.Println(len(freezing))              // "3"

delete(freezing, "kelvin")              // Delete "kelvin"
fmt.Println(len(freezing))              // "2"

地图也可以用映射文字来初始化和填充:

freezing := map[string]float32{
    "celsius":    0.0,
    "fahrenheit": 32.0,
    "kelvin":     273.2,                // The trailing comma is required!
}

注意最后一行的尾随逗号。这不是可选的:如果缺少它,代码将拒绝编译。

地图成员测试

请求地图中不存在的键的值不会导致抛出异常(在 Go 中不存在异常),也不会返回某种null值。相反,它返回地图值类型的零值:

foo := freezing["no-such-key"]          // Get non-existent key
fmt.Println(foo)                        // "0" (float32 zero value)

这可以是一个非常有用的功能,因为在处理地图时可以减少很多样板成员测试,但当你的地图实际上包含零值时,可能会有些棘手。幸运的是,访问地图也可以返回第二个可选的bool,指示键是否存在于地图中:

newton, ok := freezing["newton"]        // What about the Newton scale?
fmt.Println(newton)                     // "0"
fmt.Println(ok)                         // "false"

在这个片段中,newton的值是0.0。但那真的是正确的值吗,⁵,还是只是没有匹配的键?幸运的是,由于ok也是false,我们知道是后者。

指针

好的。指针。全世界的本科生的梦魇和毁灭。如果你来自一个动态类型语言,指针的概念可能对你来说很陌生。虽然我们不会对这个主题深入探讨过度,但我们会尽力详细介绍,以便在这个主题上提供一些清晰度。

回到第一原则,"变量" 是内存中包含某个值的存储空间。通常,当你通过其名称引用变量(foo = 10)或通过表达式引用变量时(s[i] = "foo"),你直接读取或更新变量的值。

指针 存储变量的地址:存储值的内存位置。每个变量都有一个地址,使用指针允许我们间接读取或更新它们的变量的值(如 图 3-2 所示):

检索变量的地址

可以通过使用 & 操作符来检索命名变量的地址。例如,表达式 p := &a 将获取 a 的地址并将其赋给 p

指针类型

变量 p,你可以说它“指向” a,其类型为 *int,其中 * 表示它是一个指向 int 的指针类型。

解引用指针

要从 p 检索变量 a 的值,您可以使用在指针变量名称前加 * 的方法进行解引用,从而间接读取或更新 a

cngo 0302

图 3-2. 表达式 p := &a 获取 a 的地址并将其赋给 p

现在,将所有内容放在一起,看看以下内容:

var a int = 10

var p *int = &a         // p of type *int points to a
fmt.Println(p)          // "0x0001"
fmt.Println(*p)         // "10"

*p = 20                 // indirectly update a
fmt.Println(a)          // "20"

指针可以像任何其他变量一样声明,如果没有明确初始化,则零值为 nil。它们也是可比较的,只有当它们包含相同地址(即它们指向相同变量)或者它们都是 nil 时才相等:

var n *int
var x, y int

fmt.Println(n)              // "<nil>"
fmt.Println(n == nil)       // "true" (n is nil)

fmt.Println(x == y)         // "true" (x and y are both zero)
fmt.Println(&x == &x)       // "true" (*x is equal to itself)
fmt.Println(&x == &y)       // "false" (different vars)
fmt.Println(&x == nil)      // "false" (*x is not nil)

因为 n 从未初始化,其值为 nil,与 nil 比较返回 true。整数 xy 均具有值 0,因此比较它们的值返回 true,但它们仍然是不同的变量,比较它们的指针仍然评估为 false

控制结构

从另一种语言转到 Go 的任何程序员通常会发现其控制结构套件基本上是熟悉的,甚至对于那些受 C 语言强烈影响的人来说,在其实现和用法上有一些相当重要的偏差,这可能一开始看起来有些奇怪。

例如,控制结构语句不需要很多括号。好的。少一点混乱。没问题。

也只有一种循环类型。没有 while;只有 for。真的!尽管如此,它实际上非常酷。继续阅读,你就会明白我是什么意思。

有趣的 for

for 语句是 Go 的唯一循环构造,虽然没有显式的 while 循环,但 Go 的 for 可以提供所有其功能,有效地统一了您已习惯的所有入口控制循环类型。

Go 没有 do-while 的等价物。

通用的 for 语句

Go 语言中for循环的一般形式与其他 C 语言家族几乎完全相同,其中的三个语句——初始化语句、继续条件和后置语句——在传统风格下由分号分隔。在初始化语句中声明的任何变量将仅在for语句中作用域内有效:

sum := 0

for i := 0; i < 10; i++ {
    sum += 1
}

fmt.Println(sum)        // "10"

在这个例子中,i被初始化为 0。在每次迭代结束时,i增加 1,如果仍然小于 10,则重复此过程。

注意

与大多数 C 语言家族不同,for语句在其条件子句周围不需要括号,但必须使用大括号。

Go 语言中,与传统的 C 风格语言不同,for语句的初始化语句和后置语句都是完全可选的。正如下面的代码所示,这使得它更加灵活:

sum, i := 0, 0

for i < 10 {            // Equivalent to: for ; i < 10;
    sum += i
    i++
}

fmt.Println(i, sum)     // "10 45"

前面例子中的for语句没有初始化或后置语句,只有一个裸条件。这实际上是一个大问题,因为这意味着for可以填补传统上由while循环占据的角色。

最后,在for语句中省略所有三个子句将创建一个无限循环的块,就像传统的while (true)一样:

fmt.Println("For ever...")

for {
    fmt.Println("...and ever")
}

因为它缺少任何终止条件,所以前面片段中的循环将永远迭代下去。这是故意的。

遍历数组和切片

Go 提供了一个有用的关键字range,简化了对各种数据类型的循环遍历。

对于数组和切片,可以使用range结合for语句来获取每个元素的索引和值:

s := []int{2, 4, 8, 16, 32}     // A slice of ints

for i, v := range s {           // range gets each index/value
    fmt.Println(i, "->", v)     // Output index and its value
}

在前面的例子中,变量iv将在每次迭代中更新,分别包含切片s中每个元素的索引和值。因此,输出将类似于以下内容:

0 -> 2
1 -> 4
2 -> 8
3 -> 16
4 -> 32

但如果你不需要这两个值呢?毕竟,如果你声明它们,Go 编译器会要求你使用它们。幸运的是,在 Go 的其他地方一样,可以通过使用“下划线运算符”来丢弃不需要的值:

a := []int{0, 2, 4, 6, 8}
sum := 0

for _, v := range a {
    sum += v
}

fmt.Println(sum)    // "20"

与上一个例子类似,变量v将在每次迭代中更新,分别包含切片a中每个元素的值。但是,这次方便地忽略和丢弃了索引值,而 Go 编译器则满足了。

遍历映射

range关键字也可以与for语句一起用于遍历映射,每次迭代返回当前的键和值:

m := map[int]string{
    1: "January",
    2: "February",
    3: "March",
    4: "April",
}

for k, v := range m {
    fmt.Println(k, "->", v)
}

注意,Go 的映射不是有序的,因此输出也不会有序:

3 -> March
4 -> April
1 -> January
2 -> February

if语句

在 Go 语言中,if语句的典型应用与其他 C 风格语言一致,唯一的区别是条件子句周围不需要括号,但必须使用大括号:

if 7 % 2 == 0 {
    fmt.Println("7 is even")
} else {
    fmt.Println("7 is odd")
}
注意

与大多数 C 语言家族不同,if语句在其条件子句周围不需要括号,但必须使用大括号。

有趣的是,Go 语言允许在if语句中的条件子句之前放置一个初始化语句,这是一种特别有用的习惯用法。例如:

if _, err := os.Open("foo.ext"); err != nil {
    fmt.Println(err)
} else {
    fmt.Println("All is fine.")
}

注意err变量在检查其定义之前是如何被初始化的,这使得它与以下内容有些相似:

_, err := os.Open("foo.go")
if err != nil {
    fmt.Println(err)
} else {
    fmt.Println("All is fine.")
}

但是这两个结构并不完全等价:在第一个示例中,err的作用域仅限于if语句;而在第二个示例中,err对整个包含函数可见。

switch语句

与其他语言类似,Go 语言提供了一个switch语句,用于更简洁地表达一系列if-then-else条件语句。但是,它与传统实现有许多不同之处,使其更加灵活。

对于从 C 家族语言来的人来说,最明显的区别可能是默认情况下在情况之间没有fallthrough;可以通过使用fallthrough关键字显式添加此行为:

i := 0

switch i % 3 {
case 0:
    fmt.Println("Zero")
    fallthrough
case 1:
    fmt.Println("One")
case 2:
    fmt.Println("Two")
default:
    fmt.Println("Huh?")
}

在本例中,i % 3的值为0,与第一个情况匹配,导致输出单词Zero。在 Go 语言中,默认情况下switch语句的情况不会贯穿,但存在显式的fallthrough语句意味着随后的情况也会执行,并打印One。最后,该情况没有fallthrough,导致switch的解析完成。总之,打印如下内容:

Zero
One

Go 语言中的switch具有两个有趣的属性。首先,case表达式不需要是整数,甚至不必是常量:这些情况将从上到下进行评估,运行第一个其值等于条件表达式的情况。其次,如果switch表达式为空,它将被解释为true,并将匹配第一个其保护条件评估为true的情况。以下示例演示了这两个属性:

hour := time.Now().Hour()

switch {
case hour >= 5 && hour < 9:
    fmt.Println("I'm writing")
case hour >= 9 && hour < 18:
    fmt.Println("I'm working")
default:
    fmt.Println("I'm sleeping")
}

switch没有条件,因此它与使用switch true完全等效。因此,它将匹配第一个条件也评估为true的语句。在我的情况下,hour为 23,因此输出为“I’m sleeping.”⁶

最后,就像if语句一样,语句可以在switch的条件表达式之前出现,这种情况下任何定义的值都将作用于switch。例如,可以将前面的示例重写如下:

switch hour := time.Now().Hour(); {  // Empty expression means "true"
case hour >= 5 && hour < 9:
    fmt.Println("I'm writing")
case hour >= 9 && hour < 18:
    fmt.Println("I'm working")
default:
    fmt.Println("I'm sleeping")
}

注意末尾的分号:这个空表达式意味着true,因此该表达式等效于switch hour := time.Now().Hour(); true,并匹配第一个true条件。

错误处理

在 Go 语言中,错误被视为另一个值,由内置的error类型表示。这使得错误处理变得简单:符合 Go 语言习惯的函数可能在返回列表中包含一个error类型的值,如果不为nil,则表示存在错误状态,可以通过主要的执行路径来处理。例如,当os.Open函数无法打开文件时会返回一个非nil的错误值:

file, err := os.Open("somefile.ext")
if err != nil {
    log.Fatal(err)
    return err
}

error类型的实际实现非常简单:它只是一个普遍可见的接口,声明了一个单一方法:

type error interface {
    Error() string
}

这与许多语言中使用的异常非常不同,后者需要专门的异常捕获和处理系统,可能导致混乱和不直观的流程控制。

创建错误

有两种简单的方法来创建错误值,还有一种更复杂的方法。简单的方法是使用errors.Newfmt.Errorf函数;后者很方便,因为它还提供了字符串格式化功能:

e1 := errors.New("error 42")
e2 := fmt.Errorf("error %d", 42)

然而,error是一个接口的事实允许你实现自己的错误类型,如果需要的话。例如,一个常见的模式是允许错误嵌套在其他错误中:

type NestedError struct {
    Message string
    Err     error
}

func (e *NestedError) Error() string {
    return fmt.Sprintf("%s\n  contains: %s", e.Message, e.Err.Error())
}

关于错误的更多信息,以及在 Go 中进行错误处理的一些良好建议,请查看 Andrew Gerrand 在The Go Blog上的“错误处理与 Go”。

在函数中玩乐趣:可变参数和闭包

Go 中的函数工作方式与其他语言非常相似:它们接收参数,执行一些工作,并(可选地)返回一些内容。

但是 Go 函数的灵活性远超过许多主流语言,并且还可以做许多其他语言无法做到的事情,比如返回或接受多个值,或者用作一级类型或匿名函数。

函数

在 Go 中声明函数与大多数其他语言类似:它们有一个名称,一个类型化参数列表,一个可选的返回类型列表和一个函数体。然而,Go 函数声明与其他 C 系列语言有所不同,它使用了专用的func关键字;每个参数的类型跟在其名称后面;并且返回类型放在函数定义头的末尾,可以完全省略(没有void类型)。

带有返回类型列表的函数必须以return语句结尾,除非由于存在无限循环或终端panic而导致函数无法到达结尾:

func add(x int, y int) int {
    return x + y
}

func main() {
    sum := add(10, 5)
    fmt.Println(sum)        // "15"
}

另外,一点语法糖允许一系列参数或相同类型的返回值的类型只需写一次。例如,下面这些定义的func foo是等效的:

func foo(i int, j int, a string, b string) { /* ... */ }
func foo(i, j int, a, b string)            { /* ... */ }

多返回值

函数可以返回任意数量的值。例如,以下swap函数接受两个字符串,并返回两个字符串。多返回值的返回类型列表必须用括号括起来:

func swap(x, y string) (string, string) {
    return y, x
}

要从具有多个返回值的函数中接受多个值,可以使用多重赋值:

a, b := swap("foo", "bar")

当运行时,变量a的值将是“bar”,而b将是“foo”。

递归

Go 允许递归函数调用,即函数调用自身。如果使用正确,递归可以是一个非常强大的工具,可以应用于许多类型的问题。经典例子是计算正整数n的阶乘,即所有小于或等于n的正整数的乘积:

func factorial(n int) int {
    if n < 1 {
        return 1
    }
    return n * factorial(n-1)
}

func main() {
    fmt.Println(factorial(11))      // "39916800"
}

对于任何大于一的整数nfactorial将以n - 1作为参数调用自身。这可能会非常快速增加!

推迟执行

Go 语言的defer关键字可用于安排在包围函数返回之前执行函数调用,并且通常用于确保释放或清理资源。

例如,要推迟打印文本“cruel world”到函数调用的末尾,我们在其前面立即插入defer关键字:

func main() {
    defer fmt.Println("cruel world")

    fmt.Println("goodbye")
}

当运行前面的代码片段时,它会产生以下输出,推迟输出最后打印:

goodbye
cruel world

举一个不那么琐碎的例子,我们将创建一个空文件并尝试向其写入。提供了一个closeFile函数,在完成后关闭文件。但是,如果我们简单地在main的末尾调用它,可能会出现错误,导致closeFile永远不会被调用,文件仍处于打开状态。因此,我们使用defer确保在函数返回之前调用closeFile函数,无论它如何返回:

func main() {
    file, err := os.Create("/tmp/foo.txt")  // Create an empty file
    defer closeFile(file)                   // Ensure closeFile(file) is called
    if err != nil {
        return
    }

    _, err = fmt.Fprintln(file, "Your mother was a hamster")
    if err != nil {
        return
    }

    fmt.Println("File written to successfully")
}

func closeFile(f *os.File) {
    if err := f.Close(); err != nil {
        fmt.Println("Error closing file:", err.Error())
    } else {
        fmt.Println("File closed successfully")
    }
}

运行此代码时,您应该获得以下输出:

File written to successfully
File closed successfully

如果在函数中使用多个defer调用,每个都会被推送到堆栈上。当包围函数返回时,推迟的调用按照后进先出的顺序执行。例如:

func main() {
    defer fmt.Println("world")
    defer fmt.Println("cruel")
    defer fmt.Println("goodbye")
}

运行此函数时,将输出以下内容:

goodbye
cruel
world

推迟执行是确保资源清理的非常有用的功能。如果您正在处理外部资源,您会希望大量使用它们。

指针作为参数

指针的许多功能在与函数结合时变得显而易见。通常,函数参数是按值传递的:当调用函数时,它接收每个参数的副本,函数对副本的更改不会影响调用者。但是,指针包含对值的引用,而不是值本身,接收函数可以间接地修改传递给函数的值,从而可能影响函数调用者。

下面的函数演示了这两种情况:

func main() {
    x := 5

    zeroByValue(x)
    fmt.Println(x)              // "5"

    zeroByReference(&x)
    fmt.Println(x)              // "0"
}

func zeroByValue(x int) {
    x = 0
}

func zeroByReference(x *int) {
    *x = 0                      // Dereference x and set it to 0
}

这种行为并不局限于指针。实际上,在底层,几种数据类型实际上是对内存位置的引用,包括切片、映射、函数和通道。在函数中对这些引用类型进行的更改可能会影响调用者,而无需显式解引用它们:

func update(m map[string]int) {
    m["c"] = 2
}

func main() {
    m := map[string]int{ "a" : 0, "b" : 1}

    fmt.Println(m)                  // "map[a:0 b:1]"

    update(m)

    fmt.Println(m)                  // "map[a:0 b:1 c:2]"
}

在此示例中,当将映射m传递给update函数时,其长度为两个,它添加了一对{ "c" : 2 }。因为m是一个引用类型,它作为对底层数据结构的引用传递给update,因此在update函数返回后,main中的m中会反映出这个插入操作。

可变参数函数

可变参数函数是可以使用零个或多个尾随参数调用的函数。最熟悉的例子是fmt.Printf系列函数的成员,它们接受一个格式说明符字符串和任意数量的附加参数。

这是标准fmt.Printf函数的签名:

func Printf(format string, a ...interface{}) (n int, err error)

注意它接受一个字符串和零个或多个interface{}值。如果你对interface{}语法有点生疏,我们将在“接口”中进行复习,但你可以将interface{}解释为“某种任意类型的东西”。然而,最有趣的是,最后一个参数包含省略号(...)。这是可变参数运算符,表示该函数可以接受任意数量的此类型参数。例如,你可以使用格式和两个不同类型的参数调用fmt.Printf

const name, age = "Kim", 22
fmt.Printf("%s is %d years old.\n", name, age)

在可变参数函数内部,可变参数参数是参数类型的切片。在下面的例子中,product方法的可变参数factors[]int类型,可以相应地进行范围遍历:

func product(factors ...int) int {
    p := 1

    for _, n := range factors {
        p *= n
    }

    return p
}

func main() {
    fmt.Println(product(2, 2, 2))   // "8"
}

在这个例子中,从main调用product使用了三个参数(尽管它可以使用任意数量的参数)。在product函数中,这些参数被转换为一个[]int切片,其值为{2, 2, 2},这些值被迭代相乘以构建最终的返回值8

将切片作为可变参数值传递

如果你的值已经是切片形式,但仍想将其传递给可变参数函数怎么办?你需要将其拆分为多个单独的参数吗?当然不需要。

在这种情况下,在调用可变参数函数时,你可以在变量名后面应用可变参数运算符:

m := []int{3, 3, 3}
fmt.Println(product(m...))   // "27"

在这里,你有一个类型为[]int的变量m,你想将其传递给可变参数函数product。在调用product(m...)时使用可变参数运算符使这成为可能。

匿名函数和闭包

在 Go 语言中,函数是一等公民,可以像语言中的任何其他实体一样进行操作:它们有类型,可以赋值给变量,并且甚至可以被传递给其他函数并由其返回。

函数类型的零值是nil;调用nil函数值将导致恐慌:

func sum(x, y int) int     { return x + y }
func product(x, y int) int { return x * y }

func main() {
    var f func(int, int) int    // Function variables have types

    f = sum
    fmt.Println(f(3, 5))        // "8"

    f = product                 // Legal: product has same type as sum
    fmt.Println(f(3, 5))        // "15"
}

函数可以在其他函数内部创建为匿名函数,可以像任何其他函数一样调用、传递或以其他方式处理。Go 语言的一个特别强大的特性是匿名函数可以访问其父函数的状态,并保留该访问权限甚至在父函数执行后。事实上,这就是闭包的定义。

提示

闭包是一个嵌套函数,可以访问其父函数的变量,即使父函数已执行。

incrementor函数为例。这个函数具有状态,以变量i的形式存在,并返回一个在返回值之前递增该值的匿名函数。可以说返回的函数封闭了变量i,使其成为一个真正的(尽管微不足道的)闭包:

func incrementer() func() int {
    i := 0

    return func() int {    // Return an anonymous function
        i++                // "Closes over" parent function's i
        return i
    }
}

当我们调用 incrementor 时,它会创建其自己的新的本地值 i,并返回一个新的匿名函数,该函数将递增该值。对 incrementor 的后续调用将分别接收它们自己的 i 的副本。我们可以在以下示例中演示这一点:

func main() {
    increment := incrementer()
    fmt.Println(increment())       // "1"
    fmt.Println(increment())       // "2"
    fmt.Println(increment())       // "3"

    newIncrement := incrementer()
    fmt.Println(newIncrement())    // "1"
}

正如你所见,incrementer 提供了一个新的函数 increment;每次调用 increment 时,它的内部计数器都会增加一次。但是当再次调用 incrementer 时,它将创建并返回一个全新的函数,带有自己全新的计数器。这两个函数互不影响。

结构体、方法和接口

当人们初次接触 Go 语言时,他们可能需要做的最大心理转变之一是,Go 并不是传统的面向对象语言。不完全是。当然,Go 有带方法的类型,看起来有点像对象,但它们没有规定的继承层次结构。相反,Go 允许通过组合将组件组装成整体。

例如,更严格的面向对象语言可能会有一个 Car 类来扩展一个抽象的 Vehicle 类;也许它会实现 WheelsEngine。理论上这听起来很好,但是这些关系可能会变得复杂而难以管理。

另一方面,Go 的组合方法允许组件被“组合在一起”,而无需定义它们的本体关系。延续前面的例子,Go 可能有一个 Car 结构体,其中可以嵌入其各种部件,如 WheelsEngine。此外,Go 中的方法可以为任何类型的数据定义;它们不再仅限于结构体。

结构体

在 Go 中,struct 实际上只是作为单个实体聚合的零个或多个字段,其中每个字段都是任意类型的命名值。可以使用以下 type Name struct 语法定义结构体。结构体从不是 nil:而是结构体的零值是其所有字段的零值:

type Vertex struct {
    X, Y float64
}

func main() {
    var v Vertex            // Structs are never nil
    fmt.Println(v)          // "{0 0}"

    v = Vertex{}            // Explicitly define an empty struct
    fmt.Println(v)          // "{0 0}"

    v = Vertex{1.0, 2.0}    // Defining fields, in order
    fmt.Println(v)          // "{1 2}"

    v = Vertex{Y:2.5}       // Defining specific fields, by label
    fmt.Println(v)          // "{0 2.5}"
}

结构体字段可以使用标准点符号来访问:

func main() {
    v := Vertex{X: 1.0, Y: 3.0}
    fmt.Println(v)                  // "{1 3}"

    v.X *= 1.5
    v.Y *= 2.5

    fmt.Println(v)                  // "{1.5 7.5}"
}

结构体通常通过引用创建和操作,因此 Go 提供了一些语法糖:可以使用点符号从结构体的指针访问结构体的成员;指针会自动解引用:

func main() {
    var v *Vertex = &Vertex{1, 3}
    fmt.Println(v)                  // &{1 3}

    v.X, v.Y = v.Y, v.X
    fmt.Println(v)                  // &{3 1}
}

在这个示例中,v 是指向 Vertex 的指针,其 XY 成员值需要进行交换。如果你必须对指针进行解引用来执行此操作,你将不得不像这样做 (*v).X, (*v).Y = (*v).Y, (*v).X,显然这样做很糟糕。而自动指针解引用使你可以这样做 v.X, v.Y = v.Y, v.X,这要好得多。

方法

在 Go 语言中,方法 是附加到类型的函数,包括但不限于结构体。方法的声明语法与函数非常相似,只是在函数名之前加了一个额外的 接收器参数,指定方法附加到的类型。当调用方法时,实例可以通过接收器指定的名称访问。

例如,我们先前的 Vertex 类型可以通过附加一个 Square 方法来扩展,接收器名为 v,类型为 *Vertex

func (v *Vertex) Square() {    // Attach method to the *Vertex type
    v.X *= v.X
    v.Y *= v.Y
}

func main() {
    vert := &Vertex{3, 4}
    fmt.Println(vert)          // "&{3 4}"

    vert.Square()
    fmt.Println(vert)          // "&{9 16}"
}
警告

接收器是类型特定的:附加到指针类型的方法只能在该类型的指针上调用。

除了结构体之外,还可以声明标准的复合类型,如结构体、切片或映射,并将方法附加到它们上。例如,我们声明了一个新类型,MyMap,它只是一个标准的 map[string]int,并附加了一个 Length 方法:

type MyMap map[string]int

func (m MyMap) Length() int {
    return len(m)
}

func main() {
    mm := MyMap{"A":1, "B": 2}

    fmt.Println(mm)             // "map[A:1 B:2]"
    fmt.Println(mm["A"])        // "1"
    fmt.Println(mm.Length())    // "2"
}

结果是一个新类型 MyMap,它是(并且可以被用作)字符串到整数的映射 map[string]int,但同时具有返回映射长度的 Length 方法。

接口

在 Go 中,接口 只是一组方法签名。与其他具有接口概念的语言一样,它们用于描述其他类型的一般行为,而不与实现细节耦合。因此,接口可以被视为类型可能满足的 合约,为强大的抽象技术打开了大门。

例如,可以定义一个 Shape 接口,其中包含一个 Area 方法签名。任何想要成为 Shape 的类型必须有一个返回 float64Area 方法:

type Shape interface {
    Area() float64
}

现在我们定义了两种形状,CircleRectangle,它们通过为每个附加一个 Area 方法来满足 Shape 接口。注意,我们无需显式声明它们满足接口:如果一个类型具有接口的所有方法,它可以 隐式满足 接口。当你想设计被你不拥有或不控制的类型满足的接口时,这特别有用。

type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

因为 CircleRectangle 都隐式满足 Shape 接口,我们可以将它们传递给任何期望 Shape 的函数:

func PrintArea(s Shape) {
    fmt.Printf("%T's area is %0.2f\n", s, s.Area())
}

func main() {
    r := Rectangle{Width:5, Height:10}
    PrintArea(r)                         // "main.Rectangle's area is 50.00"

    c := Circle{Radius:5}
    PrintArea(c)                         // "main.Circle's area is 78.54"
}

类型断言

类型断言可以应用于接口值,以“断言”其作为具体类型的身份。语法的一般形式为 x.(T),其中 x 是接口的表达式,T 是所断言的类型。

参考我们之前使用的 Shape 接口和 Circle 结构体:

var s Shape
s = Circle{}                // s is an expression of Shape
c := s.(Circle)             // Assert that s is a Circle
fmt.Printf("%T\n", c)       // "main.Circle"

空接口

一个奇特的构造是 空接口interface{}。空接口不指定任何方法。它不携带任何信息;它什么也不说。⁷

类型为 interface{} 的变量可以保存任何类型的值,在处理需要处理任何类型值的代码时非常有用。fmt.Println 方法是一个使用这种策略的很好的例子。

然而,这也有其不利之处。与空接口一起工作需要做出某些假设,这些假设必须在运行时进行检查,并导致代码更加脆弱和效率更低。

使用类型嵌入进行组合

Go 不允许以传统面向对象的方式进行子类化或继承。相反,它允许类型在彼此之间嵌入,将嵌入类型的功能扩展到嵌入类型中。

这是 Go 的一个特别有用的功能,允许通过组合来重用功能——即通过结合现有类型的特性来创建新类型——而不是继承,从而消除了传统面向对象编程项目可能负担的复杂类型层次结构的需求。

接口嵌入

一个常见的接口嵌入示例来自于io包。具体来说,广泛使用的io.Readerio.Writer接口,其定义如下:

type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

但是如果您希望有一个同时具有io.Readerio.Writer方法的接口怎么办?嗯,您可以实现一个第三个接口,复制两者的方法,但随后必须保持所有接口的一致性。这不仅增加了不必要的维护开销:还会意外引入错误的好方法。

而不是走复制粘贴的路线,Go 允许您将两个现有接口嵌入到第三个接口中,以获取两者的特性。语法上,通过将嵌入接口作为匿名字段添加来实现,就像标准的io.ReadWriter接口所示:

type ReadWriter interface {
    Reader
    Writer
}

这种组合的结果是一个新的接口,该接口具有所有嵌入其内的接口的方法。

注意

只有接口可以嵌入到其他接口中。

结构体嵌入

嵌入不仅限于接口:结构体也可以嵌入到其他结构体中。

在上一节中io.Readerio.Writer示例的结构体等价物来自于bufio包。具体来说,bufio.Reader(实现了io.Reader)和bufio.Writer(实现了io.Writer)。类似地,bufio还提供了io.ReadWriter的实现,它只是现有bufio.Readerbufio.Writer类型的组合:

type ReadWriter struct {
    *Reader
    *Writer
}

如您所见,嵌入结构体的语法与嵌入接口的语法相同:将嵌入类型作为未命名字段添加。在上述情况中,bufio.ReadWriter嵌入了指针类型的bufio.Readerbufio.Writer

警告

就像任何指针一样,对结构体的嵌入指针具有nil的零值,在使用之前必须初始化为指向有效的结构体。

升级

那么,为什么你要使用组合而不是只添加一个结构字段呢?答案是,当类型被嵌入时,它的导出属性和方法被提升到包含类型中,允许直接调用它们。例如,bufio.ReaderRead方法可以直接从bufio.ReadWriter的实例中访问:

var rw *bufio.ReadWriter = GetReadWriter()
var bytes []byte = make([]byte, 1024)

n, err := rw.Read(bytes) {
    // Do something
}

你无需知道或关心Read方法实际上附加到嵌入的*bufio.Reader上。不过,重要的是要知道,当调用提升方法时,方法的接收器仍然是嵌入类型,因此rw.Read的接收器是ReadWriterReader字段,而不是ReadWriter本身。

直接访问嵌入字段

有时,您需要直接引用嵌入字段。为此,您可以使用字段的类型名称作为字段名。在以下(有些牵强的)示例中,UseReader函数需要一个*bufio.Reader,但您拥有的是一个*bufio.ReadWriter实例:

func UseReader(r *bufio.Reader) {
    fmt.Printf("We got a %T\n", r)      // "We got a *bufio.Reader"
}

func main() {
    var rw *bufio.ReadWriter = GetReadWriter()
    UseReader(rw.Reader)
}

正如您所见,此代码段使用您希望访问的字段的类型名称(Reader)作为字段名(rw.Reader)来从rw中检索*bufio.Reader。这对初始化也很方便:

rw := &bufio.ReadWriter{Reader: &bufio.Reader{}, Writer: &bufio.Writer{}}

如果我们只是将rw创建为&bufio.ReadWriter{},其嵌入字段将为nil,但这段代码生成了一个具有完全定义的*bufio.Reader*bufio.Writer字段的*bufio.ReadWriter。虽然您通常不会对&bufio.ReadWriter这样做,但在紧急情况下可以使用此方法提供有用的模拟。

亮点:并发

并发编程的复杂性很多,远超出本文的范围。不过,可以说推理并发性是困难的,通常处理并发的方式会使其更加困难。在大多数语言中,处理进程编排的常见方法是创建一些共享的内存块,然后用锁包装它们以限制一次只能访问一个进程,这经常引入令人非常难以调试的错误,如竞态条件或死锁。

另一方面,Go 倾向于另一种策略:它提供了两个并发原语——goroutine 和通道(channels),可以一起使用来优雅地构建并发软件,不太依赖于锁定。它鼓励开发者限制共享内存,而是通过传递消息来允许进程彼此交互。

Goroutines

Go 最强大的功能之一是go关键字。任何以go关键字前缀的函数调用都将像通常一样运行,但调用者可以在不等待函数返回的情况下继续进行。在底层,该函数作为轻量级的并发执行进程称为goroutine

语法非常简单:一个函数foo,如果要顺序执行可以作为foo(),如果要作为并发的 goroutine 执行只需添加go关键字:go foo()

foo()       // Call foo() and wait for it to return
go foo()    // Spawn a new goroutine that calls foo() concurrently

Goroutine 还可以用于调用函数字面量:

func Log(w io.Writer, message string) {
    go func() {
        fmt.Fprintln(w, message)
    }() // Don't forget the trailing parentheses!
}

通道

在 Go 语言中,通道 是一种类型化的原语,允许两个 goroutine 之间进行通信。它们充当管道,可以将值发送到另一端的 goroutine,然后接收。

可以使用 make 函数创建通道。每个通道可以传输特定类型的值,称为其元素类型。通道类型使用 chan 关键字后跟其元素类型编写。以下示例声明并分配了一个 int 通道:

var ch chan int = make(chan int)

通道支持的两个主要操作是发送接收,它们都使用 <- 运算符,箭头指示数据流的方向,如以下示例所示:

ch <- val     // Sending on a channel
val = <-ch    // Receiving on a channel and assigning it to val
<-ch          // Receiving on a channel and discarding the result

通道阻塞

默认情况下,通道是无缓冲的。无缓冲通道具有非常有用的属性:对它们的发送会阻塞,直到另一个 goroutine 接收该通道,而接收则会阻塞,直到另一个 goroutine 发送到该通道。可以利用这种行为来同步两个 goroutine,如以下示例所示:

func main() {
    ch := make(chan string)    // Allocate a string channel

    go func() {
       message := <-ch         // Blocking receive; assigns to message
       fmt.Println(message)    // "ping"
       ch <- "pong"            // Blocking send
    }()

    ch <- "ping"               // Send "ping"
    fmt.Println(<-ch)          // "pong"
}

虽然 main 函数和匿名 goroutine 可以并行运行,并且理论上可以以任何顺序运行,但无缓冲通道的阻塞行为保证输出始终是“ping”后跟“pong”。

通道缓冲

Go 语言的通道可以是缓冲的,在这种情况下,它们包含一个内部值队列,具有在初始化缓冲时指定的固定容量。对缓冲通道的发送仅在缓冲区满时阻塞;从通道接收仅在缓冲区为空时阻塞。在其他任何时间,发送和接收操作将写入或从缓冲区读取,并立即退出。

可以通过向 make 函数提供第二个参数来指定其容量来创建缓冲通道:

ch := make(chan string, 2)    // Buffered channel with capacity 2

ch <- "foo"                   // Two non-blocking sends
ch <- "bar"

fmt.Println(<-ch)             // Two non-blocking receives
fmt.Println(<-ch)

fmt.Println(<-ch)             // The third receive will block

关闭通道

第三种可用的通道操作是关闭,它设置一个标志以指示不会再在其上发送更多值。内置的 close 函数可用于关闭通道:close(ch)

提示

通道关闭操作仅是一个标志,告诉接收方不再期望收到更多的值。您不需要强制显式关闭通道。

尝试在已关闭的通道上发送将引发 panic。从已关闭的通道接收将检索发送到通道的任何值(在其关闭之前),任何后续的接收操作将立即返回通道元素类型的零值。接收方还可以通过为接收表达式分配第二个 bool 参数来测试通道是否已关闭(及其缓冲区是否为空):

ch := make(chan string, 10)

ch <- "foo"

close(ch)                          // One value left in the buffer

msg, ok := <-ch
fmt.Printf("%q, %v\n", msg, ok)    // "foo", true

msg, ok = <-ch
fmt.Printf("%q, %v\n", msg, ok)    // "", false
警告

虽然任何一方都可以关闭通道,但实际上只有发送方应该这样做。在已关闭的通道上无意地发送将会导致恐慌。

遍历通道

可以使用 range 关键字遍历打开或包含缓冲值的通道。该循环将阻塞,直到有值可供读取或通道被关闭。您可以在以下示例中看到其工作方式:

ch := make(chan string, 3)

ch <- "foo"                 // Send three (buffered) values to the channel
ch <- "bar"
ch <- "baz"

close(ch)                   // Close the channel

for s := range ch {         // Range will continue to the "closed" flag
    fmt.Println(s)
}

在这个例子中,创建了缓冲通道ch,并在关闭之前发送了三个值。由于三个值在通道关闭之前被发送,因此在循环遍历该通道时,将输出所有三行内容后终止。

如果通道没有关闭,循环将停止并等待下一个值被发送到通道,可能会无限期地等待。

选择

Go 语言的select语句类似于switch语句,提供了一个便捷的机制来多路复用与多个通道的通信。select的语法与switch非常相似,具有一些case语句,指定在成功发送或接收操作时执行的代码:

select {
case <-ch1:                         // Discard received value
    fmt.Println("Got something")

case x := <-ch2:                    // Assign received value to x
    fmt.Println(x)

case ch3 <- y:                      // Send y to channel
    fmt.Println(y)

default:
    fmt.Println("None of the above")
}

在前面的片段中,指定了三个主要情况,并具有三个不同的条件。如果通道ch1准备好读取,那么将读取(并丢弃)其值,并打印文本“得到了东西”。如果ch2准备好读取,则将读取其值并分配给变量x,然后打印x的值。最后,如果ch3准备好接收数据,则将值y发送到它之前打印y的值。

最后,如果没有case准备好,则将执行default语句。如果没有default,则select将阻塞,直到其一个case准备就绪,然后执行相关的通信并执行相关的语句。如果多个case都准备就绪,select将随机执行一个。

注意!

在使用select时,请记住,关闭的通道永远不会阻塞并且始终可读取。

实现通道超时

利用select在通道上进行多路复用的能力非常强大,可以使本来非常困难或乏味的任务变得简单。例如,在任意通道上实现超时,在某些语言中可能需要一些尴尬的线程工作,但使用select与调用time.After一起,后者返回一个在指定持续时间后发送消息的通道,可以轻松搞定:

var ch chan int

select {
case m := <-ch:                        // Read from ch; blocks forever
    fmt.Println(m)

case <-time.After(10 * time.Second):   // time.After returns a channel
    fmt.Println("Timed out")
}

由于没有default语句,这个select将阻塞,直到其一个case条件变为真。如果在time.After返回的通道发出消息之前,ch不可用于读取,则第二个case将激活并且语句将超时。

总结

本章涵盖的内容如果能深入探讨到主题真正值得的细节水平,可能轻易就能写成一本书。但空间和时间有限(并且那本书已经写好⁸),所以我只能满足于此章作为对 Go 语言广泛且浅显的概述(至少在第二版出版之前)。

但是仅仅学习 Go 的语法和语法并不能让你走得太远。在第四章中,我将介绍一些在“云原生”环境中经常出现的 Go 编程模式。所以,如果你觉得这一章很有趣,你会喜欢下一章的。

¹ Perlis, Alan. ACM SIGPLAN Notices 17(9),1982 年 9 月,第 7-13 页。

² 较早版本的 C、C++ 和 Python 缺乏本机的布尔类型,而是使用整数 0(表示 false)或 1(表示 true)来表示它们。一些语言如 Perl、Lua 和 Tcl 仍然使用类似的策略。

³ 看到我这样做了吗?

⁴ 你为什么要这样做?

⁵ 实际上,在牛顿标度上,水的冰点确实是 0.0,但这并不重要。

⁶ 显然,这段代码需要重新校准。

⁷ Pike, Rob. “Go Proverbs.” YouTube. 1 Dec. 2015. https://oreil.ly/g8Rid.

⁸ 最后一次提醒,如果你还没有阅读过,去读一下The Go Programming Language,作者是 Donovan 和 Kernighan(Addison-Wesley Professional)。

第四章:云原生模式

只有当我们训练自己去思考程序时不将其视为可执行代码的一部分时,进展才有可能。¹

Edsger W. Dijkstra,1979 年 8 月

1991 年,当时还在 Sun Microsystems 工作的 L·Peter·Deutsch²提出了分布式计算的谬误,列出了程序员在处理分布式应用程序时常常犯的一些错误假设:

  • 网络是可靠的:交换机会出故障,路由器会配置错误

  • 延迟是零:在网络中传输数据需要时间

  • 带宽是无限的:网络一次只能处理那么多数据

  • 网络是安全的:不要以明文方式共享秘密;一切都要加密

  • 拓扑不会改变:服务器和服务会来来去去

  • 只有一个管理员:多个管理员会导致异构解决方案

  • 传输成本为零:数据传输需要时间和金钱

  • 网络是同构的:每个网络都(有时非常)不同

如果我可以如此大胆,我还想再增加第九个:

  • 服务是可靠的:您依赖的服务随时可能会失败

在本章中,我将介绍一些习惯用法模式——经过测试、证明的开发范式——旨在解决 Deutsch 所述条件中的一个或多个,并展示如何在 Go 语言中实现它们。本书讨论的模式都不是本书独创的——一些模式存在已久,就像分布式应用程序的存在一样——但大多数模式在此前未曾集中发表。其中许多模式是 Go 语言独有的,或者相对于其他语言具有新颖的实现。

不幸的是,本书不涵盖像舱壁守门员等基础架构级别的模式。主要是因为我们专注于 Go 语言中的应用层开发,而这些模式尽管不可或缺,但其功能位于完全不同的抽象级别。如果您有兴趣了解更多信息,我推荐 Justin Garrison 和 Kris Nova(O’Reilly)的Cloud Native Infrastructure以及 Brendan Burns(O’Reilly)的Designing Distributed Systems

上下文包

本章大多数代码示例使用了context包,该包在 Go 1.7 中引入,提供了一种习惯用法,用于在进程间传递截止时间、取消信号和请求范围的值。它包含一个接口context.Context,其方法列在以下内容中:

type Context interface {
    // Done returns a channel that's closed when this Context is cancelled.
    Done() <-chan struct{}

    // Err indicates why this context was cancelled after the Done channel is
    // closed. If Done is not yet closed, Err returns nil.
    Err() error

    // Deadline returns the time when this Context should be cancelled; it
    // returns ok==false if no deadline is set.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with this context for key, or nil
    // if no value is associated with key. Use with care.
    Value(key interface{}) interface{}
}

这些方法中有三种可以用来了解Context值的取消状态或行为。第四种Value可以用来检索与任意键关联的值。在 Go 世界中,ContextValue方法是一些争议的焦点,将在“定义请求范围的值”中进一步讨论。

上下文能为你做什么

context.Context值通过直接将其传递给服务请求使用,可能进一步将其传递给一个或多个子请求。其有用之处在于,当Context被取消时,所有持有它(或派生Context;关于此更多内容请参见图 4-1、4-2 和 4-3)的函数都会收到信号,允许它们协调取消并减少浪费的工作量。

例如,用户向服务发出请求,后者再向数据库发出请求。在理想情况下,用户、应用程序和数据库请求可以像图 4-1 中所示的那样进行图示化。

cngo 0401

图 4-1. 用户成功请求,服务到数据库

但如果用户在请求完全完成之前终止请求怎么办?在大多数情况下,无视请求的整体上下文,进程将继续运行(图 4-2),消耗资源以提供永远不会使用的结果。

cngo 0402

图 4-2. 子进程不知道被取消的用户请求,仍将继续运行

然而,通过向每个后续请求共享Context,所有长时间运行的进程都可以收到同时发送的“完成”信号,允许取消信号在每个进程之间协调(图 4-3)。

cngo 0403

图 4-3. 通过共享上下文,可以在进程之间协调取消信号。

重要的是,Context值也是线程安全的,即它们可以安全地被多个并发执行的 goroutine 使用,而不必担心意外行为。

创建上下文

可以使用两个函数之一获得全新的context.Context

func Background() Context

返回一个从未被取消、没有值和没有截止时间的空Context。它通常由主函数、初始化和测试使用,并作为传入请求的顶层Context

func TODO() Context

同样提供一个空的Context,但它旨在用作未确定使用哪个Context或父Context尚不可用时的占位符。

定义上下文的截止时间和超时

context包还包括一些方法,用于创建派生Context值,允许您通过应用超时或显式触发取消的函数钩子来控制取消行为。

func WithDeadline(Context, time.Time) (Context, CancelFunc)

接受特定时间,在此时间Context将被取消并且Done通道将被关闭。

func WithTimeout(Context, time.Duration) (Context, CancelFunc)

接受一个持续时间,在此之后Context将被取消并且Done通道将被关闭。

func WithCancel(Context) (Context, CancelFunc)

与之前的函数不同,WithCancel 不接受任何参数,只返回一个可调用的函数,用于显式取消 Context

这三个函数都返回一个包含任何请求的修饰的派生 Context,以及一个 context.CancelFunc,一个零参数函数,可用于显式取消 Context 及其所有派生值。

提示

当一个 Context 被取消时,所有派生自它的 Context 也会被取消。它派生自的 Context 不会被取消。

定义请求范围的值

最后,context 包包括一个函数,可用于定义一个可以从返回的 Context—以及所有派生自它的 Context—中访问的任意请求范围键值对的函数。

func WithValue(parent Context, key, val interface{}) Context

WithValue 返回一个派生自 parentContext,其中 key 与值 val 关联。

使用一个 Context

当一个服务请求被发起时,无论是由传入请求还是由 main 函数触发,顶层进程将使用 Background 函数创建一个新的 Context 值,可能使用一个或多个 context.With* 函数对其进行修饰,然后将其传递给任何子请求。然后这些子请求只需要监视 Done 通道以获取取消信号。

例如,看一下以下的 Stream 函数:

func Stream(ctx context.Context, out chan<- Value) error {
    // Create a derived Context with a 10s timeout; dctx
    // will be cancelled upon timeout, but ctx will not.
    // cancel is a function that will explicitly cancel dctx.
    dctx, cancel := context.WithTimeout(ctx, time.Second * 10)

    // Release resources if SlowOperation completes before timeout
    defer cancel()

    res, err := SlowOperation(dctx)
    if err != nil {                     // True if dctx times out
        return err
    }

    for {
        select {
        case out <- res:                // Read from res; send to out

        case <-ctx.Done():              // Triggered if ctx is cancelled
            return ctx.Err()
        }
    }
}

Stream 接收一个 ctx Context 作为输入参数,它将其发送到 WithTimeout 来创建 dctx,一个带有 10 秒超时的派生 Context。由于这个修饰,SlowOperation(dctx) 调用可能在十秒后超时并返回错误。然而,使用原始 ctx 的函数不会有这个超时修饰,也不会超时。

更进一步,原始的 ctx 值在围绕 select 语句的 for 循环中被用于从 SlowOperation 函数提供的 res 通道中检索值。请注意 case <-ctx.Done() 语句,在 ctx.Done 通道关闭时执行以返回适当的错误值。

本章布局

本章中每个模式的一般展示松散地基于著名的“四人帮”设计模式书中使用的展示方式,但更简单和不那么正式。每个模式以其目的的非常简要描述开头,以及使用它的原因,然后跟着以下部分:

适用性

Context 和描述此模式可能适用的地方。

参与者

该模式的组件列表及其角色。

实现

解决方案及其实现的讨论。

示例代码

代码如何在 Go 中实现的演示。

稳定性模式

这里介绍的稳定性模式解决了分布式计算的谬误中所指出的一个或多个假设。它们通常旨在由分布式应用程序应用,以提高它们自身以及所属的更大系统的稳定性。

电路断路器

电路断路器自动在可能的故障响应中降低服务功能,通过消除重复错误并提供合理的错误响应,防止更大或级联故障的发生。

适用性

如果将分布式计算的谬误概括为一个要点,那就是对于分布式、云原生系统来说,错误和故障是无法否认的生活事实。服务可能配置错误,数据库可能崩溃,网络可能分区。我们无法阻止它;我们只能接受并考虑它。

如果不这样做,可能会造成一些相当不愉快的后果。我们都见过它们,它们并不美观。有些服务可能会继续徒劳地尝试执行它们的工作,并向客户端返回无意义的数据;其他服务可能会发生灾难性失败,甚至可能陷入崩溃/重新启动的恶性循环中。这并不重要,因为最终它们都在浪费资源,掩盖了原始故障的来源,并使级联故障更有可能发生。

另一方面,一个服务假设其依赖项随时可能失败,当它们失败时可以合理地做出响应。电路断路器允许服务检测到这些故障,并通过暂时停止执行请求来“打开电路”,而不是提供符合服务通信契约的错误消息给客户端。

例如,想象一个服务(理想情况下)从客户端接收请求,执行数据库查询并返回响应。如果数据库失败了怎么办?该服务可能会继续徒劳地尝试查询,向日志中发送错误消息,最终超时或返回无用的错误。这样的服务可以使用电路断路器在数据库失败时“打开电路”,防止服务继续进行注定失败的数据库请求(至少一段时间内),并立即向客户端提供有意义的通知。

参与者

此模式包括以下参与者:

电路

与服务交互的函数。

断路器

一个与Circuit相同函数签名的闭包。

实施

本质上,电路断路器只是一种专门的适配器模式,Breaker包装Circuit以添加一些额外的错误处理逻辑。

就像此模式的命名来源——电气开关一样,Breaker有两种可能的状态:closed(关闭)和open(打开)。在关闭状态下,一切正常运行。Breaker从客户端接收的所有请求都原样转发到Circuit,并且将Circuit返回的所有响应再转发回客户端。在打开状态下,Breaker不会将请求转发到Circuit,而是通过响应详细的错误消息实现“快速失败”。

Breaker在内部跟踪Circuit返回的错误;如果Circuit返回的连续错误数超过定义的阈值,Breaker触发并且其状态切换为open

大多数 Circuit Breaker 的实现都包括一些逻辑,以在一段时间后自动关闭电路。但请记住,向已经发生故障的服务大量重试可能会导致其自身的问题,因此通常会包含某种退避逻辑,即随时间减少重试率的逻辑。退避的主题实际上非常微妙,但将在《再试请求》中详细讨论。

在多节点服务中,此实现可以扩展为包括一些共享存储机制,例如 Memcached 或 Redis 网络缓存,以跟踪电路状态。

示例代码

我们首先创建一个Circuit类型,该类型指定了与数据库或其他上游服务交互的函数签名。在实践中,这可以采用适合功能的任何形式。然而,它的返回列表中应包含一个error

type Circuit func(context.Context) (string, error)

在本例中,Circuit是一个接受Context值的函数,详细说明见《上下文包》。您的实现可能会有所不同。

Breaker函数接受符合Circuit类型定义的任何函数,以及一个无符号整数,表示电路自动打开之前允许的连续故障次数。作为返回,它提供另一个符合Circuit类型定义的函数:

func Breaker(circuit Circuit, failureThreshold uint) Circuit {
    var consecutiveFailures int = 0
    var lastAttempt = time.Now()
    var m sync.RWMutex

    return func(ctx context.Context) (string, error) {
        m.RLock()                       // Establish a "read lock"

        d := consecutiveFailures - int(failureThreshold)

        if d >= 0 {
            shouldRetryAt := lastAttempt.Add(time.Second * 2 << d)
            if !time.Now().After(shouldRetryAt) {
                m.RUnlock()
                return "", errors.New("service unreachable")
            }
        }

        m.RUnlock()                     // Release read lock

        response, err := circuit(ctx)   // Issue request proper

        m.Lock()                        // Lock around shared resources
        defer m.Unlock()

        lastAttempt = time.Now()        // Record time of attempt

        if err != nil {                 // Circuit returned an error,
            consecutiveFailures++       // so we count the failure
            return response, err        // and return
        }

        consecutiveFailures = 0         // Reset failures counter

        return response, nil
    }
}

Breaker函数构造另一个函数,也是类型为Circuit的函数,它包装circuit以提供所需的功能。您可能会从《匿名函数和闭包》中认出这种形式:闭包是一个嵌套函数,可以访问其父函数的变量。正如您将看到的,本章实现的所有“稳定性”函数都是这样工作的。

闭包通过计算circuit返回的连续错误数来工作。如果该值达到故障阈值,则在不实际调用circuit的情况下返回错误“服务不可达”。对circuit的任何成功调用都会导致consecutiveFailures重置为 0,然后重新开始。

该闭包甚至包括一个自动重置机制,允许请求在数秒后再次调用circuit,并采用指数退避,即重试之间的延迟持续大致翻倍。尽管简单且相当普遍,实际上这并不是理想的退避算法。我们将在“退避算法”中详细讨论其原因。

Debounce

Debounce 限制函数调用的频率,以便只有调用集群中的第一个或最后一个实际执行。

适用性

Debounce 是我们第二个以电气电路主题命名的模式。具体来说,它是根据一种现象命名的,即开关在打开或关闭时其触点会“弹跳”,导致电路在稳定之前会有些波动。这通常不是什么大问题,但这种“接触弹跳”在逻辑电路中可能会成为一个真正的问题,因为一系列开/关脉冲可能被解释为数据流。消除接触弹跳的做法是只传输开放或关闭接触的一个信号,称为“去弹跳”。

在服务的世界中,我们有时会执行一系列可能缓慢或昂贵的操作,而只需执行一个即可。使用 Debounce 模式,紧密聚集在时间上的一系列相似调用被限制为只有一个调用,通常是批处理中的第一个或最后一个。

多年来,这种技术一直在 JavaScript 世界中使用,以限制可能减慢浏览器速度的操作,只接受一系列用户事件中的第一个或者延迟调用,直到用户准备好。您可能以前就见过这种技术的应用。我们都熟悉使用搜索栏时的体验,只有在您暂停输入后才显示自动完成弹出窗口,或者连续点击按钮后只有第一个点击被响应的情况。

我们这些专注于后端服务的人可以从多年来一直致力于解决分布式系统中可靠性、延迟和带宽问题的前端同事那里学到很多。例如,这种方法可以用于检索一些更新缓慢的远程资源,而不会陷入浪费客户端和服务器时间的情况。

这种模式类似于“节流”,限制函数被调用的频率。但是 Debounce 限制了调用集群,而节流仅根据时间段限制。有关 Debounce 和 Throttle 模式之间的区别,请参阅“节流与 Debounce 的区别是什么?”。

参与者

此模式包括以下参与者:

电路

调节函数。

Debounce

Circuit具有相同函数签名的闭包。

实施

实际上,Debounce 的实现与断路器的实现非常相似,因为它包装了Circuit以提供速率限制的逻辑。该逻辑实际上非常简单:在每次调用外部函数时——不论其结果如何——都会设置一个时间间隔。在该时间间隔到期之前进行的任何后续调用都将被忽略;在此之后进行的任何调用都将传递给内部函数。这种实现方式中,内部函数只调用一次,随后的调用将被忽略,被称为函数-首先,并且非常有用,因为它允许从内部函数获取的初始响应被缓存并返回。

函数-最后的实现会在一系列调用之后等待一段时间,然后才调用内部函数。当程序员希望某些输入后再进行函数调用时,例如搜索栏在输入暂停后进行自动完成时,这种变体在 JavaScript 世界中很常见。在后端服务中,函数-最后不太常见,因为它不能立即提供响应,但如果函数不需要立即结果,则它可能很有用。

示例代码

就像在断路器实现中一样,我们首先定义一个函数类型,其签名是我们想要限制的函数。与断路器类似,我们称之为Circuit;它与示例中声明的相同。同样,Circuit可以根据功能需求采取任何形式,但其返回值应包括一个error

type Circuit func(context.Context) (string, error)

与断路器实现的相似性是有意的:它们的兼容性使它们可以“链接”,如下所示:

func myFunction func(ctx context.Context) (string, error) { /* ... */ }

wrapped := Breaker(Debounce(myFunction))
response, err := wrapped(ctx)

Debounce的函数-首先实现—DebounceFirst—与函数-最后相比非常直接,因为它只需跟踪上次调用的时间,并且如果在d时间段后再次调用,则返回缓存的结果:

func DebounceFirst(circuit Circuit, d time.Duration) Circuit {
    var threshold time.Time
    var result string
    var err error
    var m sync.Mutex

    return func(ctx context.Context) (string, error) {
        m.Lock()

        defer func() {
            threshold = time.Now().Add(d)
            m.Unlock()
        }()

        if time.Now().Before(threshold) {
            return result, err
        }

        result, err = circuit(ctx)

        return result, err
    }
}

这个DebounceFirst的实现通过在互斥体中包装整个函数来确保线程安全。虽然这将强制在调用集群开始时重叠调用必须等待结果被缓存,但它也保证了circuit在集群开始时只被调用一次。defer确保了threshold的值,表示调用集群结束的时间(如果没有进一步的调用),在每次调用时重置。

我们的函数-最后实现有点笨拙,因为它涉及使用time.Ticker来确定自上次调用函数以来是否足够的时间已过,并且在需要时调用circuit。或者,我们可以在每次调用时创建一个新的time.Ticker,但如果频繁调用它,这可能会变得非常昂贵:

type Circuit func(context.Context) (string, error)

func DebounceLast(circuit Circuit, d time.Duration) Circuit {
    var threshold time.Time = time.Now()
    var ticker *time.Ticker
    var result string
    var err error
    var once sync.Once
    var m sync.Mutex

    return func(ctx context.Context) (string, error) {
        m.Lock()
        defer m.Unlock()

        threshold = time.Now().Add(d)

        once.Do(func() {
            ticker = time.NewTicker(time.Millisecond * 100)

            go func() {
                defer func() {
                    m.Lock()
                    ticker.Stop()
                    once = sync.Once{}
                    m.Unlock()
                }()

                for {
                    select {
                    case <-ticker.C:
                        m.Lock()
                        if time.Now().After(threshold) {
                            result, err = circuit(ctx)
                            m.Unlock()
                            return
                        }
                        m.Unlock()
                    case <-ctx.Done():
                        m.Lock()
                        result, err = "", ctx.Err()
                        m.Unlock()
                        return
                    }
                }
            }()
        })

        return result, err
    }
}

DebounceFirst类似,DebounceLast使用一个名为threshold的值来指示调用集群的结束(假设没有额外的调用)。然而,它们的相似性基本上就到此为止。

你会注意到几乎整个函数都在sync.Once值的Do方法内部运行,这确保(正如其名称所示)包含的函数仅执行一次。在这个块内部,使用time.Ticker来检查是否已经超过了threshold并调用circuit函数。最后,停止time.Ticker,重置sync.Once,循环准备重复执行。

重试

Retry通过透明地重试失败的操作来处理分布式系统中可能的瞬时故障。

适用性

当处理复杂的分布式系统时,瞬时错误是不可避免的。这些错误可能由各种(希望是临时的)条件引起,特别是如果下游服务或网络资源具有保护策略,如在高工作负载下暂时拒绝请求的节流,或者像自适应策略(例如根据需要增加容量的自动缩放)等。

这些故障通常会在一段时间后自行解决,因此在合理的延迟之后重复请求可能(但不保证)会成功。未考虑瞬时故障可能会导致系统变得过于脆弱。另一方面,实施自动重试策略可以显著提高服务的稳定性,从而使其及其上游消费者受益。

参与者

此模式包括以下参与者:

Effector

与服务交互的函数。

Retry

接受Effector并返回与Effector相同函数签名的闭包函数。

实现

这种模式与断路器或防抖动机制类似,其类型为Effector,定义了一个函数签名。该签名可以根据您的实现需求而灵活变化,但执行可能失败操作的函数必须符合Effector定义的签名。

Retry函数接受用户定义的Effector函数,并返回一个包装了用户定义函数以提供重试逻辑的Effector函数。除了用户定义的函数外,Retry还接受一个整数,描述最大重试次数,并且接受一个time.Duration,描述每次重试尝试之间的等待时间。如果retries参数为 0,则重试逻辑将失效。

注意

尽管本文未包含在内,重试逻辑通常会包括某种退避算法。

示例代码

Retry函数的函数参数签名为Effector。它与之前模式的函数类型完全相同:

type Effector func(context.Context) (string, error)

Retry函数本身相对直接,至少与我们迄今为止看到的函数相比是这样:

func Retry(effector Effector, retries int, delay time.Duration) Effector {
    return func(ctx context.Context) (string, error) {
        for r := 0; ; r++ {
            response, err := effector(ctx)
            if err == nil || r >= retries {
                return response, err
            }

            log.Printf("Attempt %d failed; retrying in %v", r + 1, delay)

            select {
            case <-time.After(delay):
            case <-ctx.Done():
                return "", ctx.Err()
            }
        }
    }
}

您可能已经注意到保持 Retry 函数如此简洁的原因是:虽然它返回一个函数,但该函数没有任何外部状态。这意味着我们不需要任何复杂的机制来支持并发。

要使用 Retry,我们可以实现执行可能失败操作的函数,并且其签名与 Effector 类型匹配;在以下示例中,EmulateTransientError 扮演这个角色:

var count int

func EmulateTransientError(ctx context.Context) (string, error) {
    count++

    if count <= 3 {
        return "intentional fail", errors.New("error")
    } else {
        return "success", nil
    }
}

func main() {
    r := Retry(EmulateTransientError, 5, 2*time.Second)

    res, err := r(context.Background())

    fmt.Println(res, err)
}

main 函数中,将 EmulateTransientError 函数传递给 Retry,提供函数变量 r。当调用 r 时,如果 EmulateTransientError 返回错误,则根据先前显示的重试逻辑再次调用,并在延迟后再次调用。最后,在第四次尝试后,EmulateTransientError 返回 nil 错误并退出。

速率限制

Throttle 将函数调用频率限制为单位时间内的某个最大调用次数。

适用性

Throttle 模式以用于管理液体流动的设备命名,例如调节汽车引擎中燃料的流量。像其名字来源的机制一样,Throttle 限制了在一段时间内可以调用函数的次数。例如:

  • 用户每秒只允许发起 10 次服务请求。

  • 客户端可能限制自身每 500 毫秒调用特定函数一次。

  • 某账户在 24 小时内只允许进行三次失败的登录尝试。

可能最常见的应用 Throttle 的原因是应对可能饱和系统的尖峰活动,这些活动可能对满足请求造成不合理的压力或导致服务降级甚至失败。虽然系统可能通过扩展来增加足够的容量以满足用户需求,但这需要时间,并且系统可能无法快速反应。

参与者

此模式包括以下参与者:

Effector

调整函数。

速率限制

接受 Effector 并返回一个与 Effector 具有相同函数签名的闭包函数。

实现

Throttle 模式与本章描述的许多其他模式类似:它作为一个接受执行函数的函数来实现,返回一个具有相同签名的 Throttle 闭包,提供速率限制逻辑。

实现速率限制行为最常见的算法是令牌桶,它使用一个类似于桶可以容纳某个最大令牌数量的比喻。当调用一个函数时,从桶中取出一个令牌,然后以某个固定速率重新填充。

当桶中的令牌不足以支付请求时,Throttle 处理请求的方式可以根据开发者的需求而变化。一些常见的策略包括:

返回错误

当您仅尝试限制不合理或潜在滥用的客户端请求数量时,这是最基本的策略。采用此策略的 RESTful 服务可能会以状态码429 (Too Many Requests)做出响应。

重播上次成功函数调用的响应

当服务或昂贵的函数调用可能在过早调用时提供相同结果时,此策略可能很有用。它在 JavaScript 世界中被广泛使用。

当可用令牌足够时,将请求排队以执行

当您希望最终处理所有请求时,此方法可能很有用,但它也更复杂,可能需要注意确保不会耗尽内存。

示例代码

以下示例实现了一个非常基本的“令牌桶”算法,使用了“错误”策略:

type Effector func(context.Context) (string, error)

func Throttle(e Effector, max uint, refill uint, d time.Duration) Effector {
    var tokens = max
    var once sync.Once

    return func(ctx context.Context) (string, error) {
        if ctx.Err() != nil {
            return "", ctx.Err()
        }

        once.Do(func() {
            ticker := time.NewTicker(d)

            go func() {
                defer ticker.Stop()

                for {
                    select {
                    case <-ctx.Done():
                        return

                    case <-ticker.C:
                        t := tokens + refill
                        if t > max {
                            t = max
                        }
                        tokens = t
                    }
                }
            }()
        })

        if tokens <= 0 {
            return "", fmt.Errorf("too many calls")
        }

        tokens--

        return e(ctx)
    }
}

Throttle实现与我们其他示例类似,它通过将效应函数e包装在包含速率限制逻辑的闭包中来实现。桶最初被分配max个令牌;每次触发闭包时,它都会检查是否有剩余令牌。如果有可用令牌,则将令牌计数减一并触发效应函数。如果没有,则返回错误。令牌的添加速率为每个duration d周期添加refill个令牌。

超时

一旦明确可能不会得到答案,超时允许一个进程停止等待答案。

适用性

分布式计算的谬误之一是“网络是可靠的”,而这个谬误之所以排在第一,是有原因的。交换机可能会失败,路由器和防火墙可能会配置错误;数据包可能会黑洞化。即使您的网络运行良好,也不能保证每个服务都能在发生故障时保证及时和有意义的响应——甚至可能根本不响应。

超时代表了对这一困境的常见解决方案,其简单至极几乎不足以称为一种模式:给定一个运行时间超长的服务请求或函数调用,调用者只需...停止等待。

但不要把“简单”或“常见”误认为是“无用的”。相反,超时策略的普遍性证明了其实用性。明智地使用超时可以提供一定程度的故障隔离,防止级联故障,并减少下游资源问题变成的问题的可能性。

参与者

此模式包括以下参与者:

客户端

想要执行SlowFunction的客户端。

SlowFunction

实现所需功能的长时间运行函数,由客户端调用。

超时

围绕SlowFunction实现超时逻辑的包装函数。

实施

Go 语言有几种实现超时的方法,但惯用的方式是使用context包提供的功能。有关更多信息,请参见“上下文包”。

在理想情况下,任何可能长时间运行的函数都会直接接受 context.Context 参数。如果是这样,你的工作就相当简单了:只需传递一个由 context.WithTimeout 函数装饰的 Context 值即可:

ctx := context.Background()
ctxt, cancel := context.WithTimeout(ctx, 10 * time.Second)
defer cancel()

result, err := SomeFunction(ctxt)

然而,并非总是这样,并且对于第三方库,你并不总能重构以接受 Context 值。在这些情况下,最佳做法可能是以一种尊重你的 Context 的方式包装函数调用。

例如,假设你有一个可能长时间运行的函数,它不仅不接受 Context 值,而且来自你无法控制的包。如果 Client 直接调用 SlowFunction,它将被迫等待函数完成,如果它确实完成的话。那么现在怎么办?

不直接调用 SlowFunction,而是在 goroutine 中调用它。这样一来,如果它在合理的时间内返回结果,你就可以捕获到它返回的结果。然而,这也允许你在没有返回时继续进行。

要做到这一点,我们可以利用我们之前见过的几个工具:context.Context 用于超时,通道用于通信结果,以及 select 来捕获先行动的那个。

示例代码

以下示例假设存在虚构函数 Slow,其执行可能在某个合理的时间内完成,也可能不会,并且其签名符合以下类型定义:

type SlowFunction func(string) (string, error)

与其直接调用 Slow,我们改为提供一个 Timeout 函数,该函数将提供的 SlowFunction 包装成一个闭包,并返回一个 WithContext 函数,该函数向 SlowFunction 的参数列表中添加了一个 context.Context

type WithContext func(context.Context, string) (string, error)

func Timeout(f SlowFunction) WithContext {
    return func(ctx context.Context, arg string) (string, error) {
        chres := make(chan string)
        cherr := make(chan error)

        go func() {
            res, err := f(arg)
            chres <- res
            cherr <- err
        }()

        select {
        case res := <-chres:
            return res, <-cherr
        case <-ctx.Done():
            return "", ctx.Err()
        }
    }
}

Timeout 构造的函数内部,Slow 在一个 goroutine 中运行,并将其返回值发送到为此目的构造的通道中,如果它在某个时候确实完成的话。

以下 goroutine 语句是一个 select 块,选择了两个通道:Slow 函数响应通道的第一个和 Context 值的 Done 通道。如果前者先完成,闭包将返回 Slow 函数的返回值;否则返回 Context 提供的错误。

使用 Timeout 函数并不比直接消耗 Slow 复杂多少,除了不是一个函数调用,而是两个:调用 Timeout 获取闭包,并调用闭包本身:

func main() {
    ctx := context.Background()
    ctxt, cancel := context.WithTimeout(ctx, 1*time.Second)
    defer cancel()

    timeout := Timeout(Slow)
    res, err := timeout(ctxt, "some input")

    fmt.Println(res, err)
}

最后,尽管通常首选使用 context.Context 实现服务超时,通道超时 也可以 使用 time.After 函数提供的通道来实现。参见 “实现通道超时” 了解如何实现的示例。

并发模式

云原生服务通常需要有效地处理多个进程并处理高(及高度变化的)负载水平,理想情况下无需扩展便能应对。因此,它需要高并发能力,并能够管理来自多个客户端的多个同时请求。虽然 Go 以其并发支持而闻名,但仍可能出现瓶颈。本文介绍了一些开发的模式来防止这些瓶颈。

Fan-In

将多个输入通道复用到一个输出通道的 Fan-in 模式。

适用性

具有一些生成输出的工作线程的服务可能会发现将所有工作线程的输出合并以作为单一统一流进行处理很有用。对于这些情况,我们使用了 fan-in 模式,它可以通过将多个输入通道复用到单个目标通道来读取。

参与者

此模式包括以下参与者:

Sources

一组一个或多个具有相同类型的输入通道。被 Funnel 接受。

Destination

Sources 相同类型的输出通道。由 Funnel 创建和提供。

Funnel

接受 Sources 并立即返回 Destination。来自 Sources 的任何输入都将由 Destination 输出。

实现

Funnel 实现为一个函数,接收零到 N 个输入通道 (Sources)。对于 Sources 中的每个输入通道,Funnel 函数启动一个单独的 goroutine 从其分配的通道读取值并将其转发到所有 goroutine 共享的单个输出通道 (Destination)。

示例代码

Funnel 函数是一个可变函数,接收 sources:零到 N 个某种类型的通道 (int 在下面的示例中):

func Funnel(sources ...<-chan int) <-chan int {
    dest := make(chan int)                  // The shared output channel

    var wg sync.WaitGroup                   // Used to automatically close dest
                                            // when all sources are closed

    wg.Add(len(sources))                    // Set size of the WaitGroup

    for _, ch := range sources {            // Start a goroutine for each source
        go func(c <-chan int) {
            defer wg.Done()                 // Notify WaitGroup when c closes

            for n := range c {
                dest <- n
            }
        }(ch)
    }

    go func() {                             // Start a goroutine to close dest
        wg.Wait()                           // after all sources close
        close(dest)
    }()

    return dest
}

对于 sources 列表中的每个通道,Funnel 启动一个专用的 goroutine 从其分配的通道读取值并将其转发到 dest,一个所有 goroutine 共享的单输出通道。

注意使用 sync.WaitGroup 来确保适当关闭目标通道。首先创建一个 WaitGroup 并将其设置为源通道的总数。如果通道关闭,其关联的 goroutine 退出,调用 wg.Done。当所有通道都关闭时,WaitGroup 的计数器达到零,wg.Wait 引入的锁释放,并关闭 dest 通道。

使用 Funnel 相对来说是比较简单的:给定 N 个源通道(或 N 个通道的切片),将这些通道传递给 Funnel。返回的目标通道可以按通常的方式读取,并且在所有源通道关闭时将关闭:

func main() {
    sources := make([]<-chan int, 0)        // Create an empty channel slice

    for i := 0; i < 3; i++ {
        ch := make(chan int)
        sources = append(sources, ch)       // Create a channel; add to sources

        go func() {                         // Run a toy goroutine for each
            defer close(ch)                 // Close ch when the routine ends

            for i := 1; i <= 5; i++ {
                ch <- i
                time.Sleep(time.Second)
            }
        }()
    }

    dest := Funnel(sources...)
    for d := range dest {
        fmt.Println(d)
    }
}

此示例创建了一个包含三个 int 通道的切片,在将值从 1 到 5 发送到这些通道后关闭它们。在一个单独的 goroutine 中,打印单个 dest 通道的输出。运行此示例将导致适当的 15 行打印,然后 dest 关闭并结束函数。

Fan-Out

Fan-out 均匀分发来自输入通道的消息到多个输出通道。

适用性

Fan-out 从输入通道接收消息,均匀分布到输出通道,并且是用于并行化 CPU 和 I/O 利用的有用模式。

例如,想象一下,您有一个输入源,比如输入流上的Reader,或者消息代理上的监听器,为某些资源密集型工作提供输入。与其将输入和计算过程耦合在一起,这会将工作限制在单一串行过程中,您可能更喜欢通过将工作负载分布到一些并发工作进程中来并行化工作负载。

Participants

此模式包括以下参与者:

Source

输入通道。被Split接受。

Destinations

Source相同类型的输出通道。由Split创建和提供。

Split

接受Source并立即返回Destinations的函数。来自Source的任何输入将输出到Destination

实现

Fan-out 在概念上可能相对简单,但细节中有魔鬼。

通常,fan-out 被实现为一个Split函数,该函数接受单个Source通道和表示所需Destination通道数的整数。Split函数创建Destination通道,并执行一些后台进程,从Source通道检索值并将其转发到Destinations之一。

转发逻辑的实现可以通过以下两种方式之一完成:

  • 使用单个 goroutine 按循环顺序从Source读取值并将其转发到Destinations。这具有只需要一个主 goroutine 的优点,但如果下一个通道尚未准备好读取,它将减慢整个过程。

  • 使用单独的 goroutine 处理每个Destination,它们竞争从Source读取下一个值并将其转发到各自的Destination。这需要稍多一些资源,但不太可能因单个运行缓慢的工作进程而陷入困境。

下一个示例采用后一种方法。

示例代码

在这个例子中,Split函数接受单个只接收通道source和描述要将输入拆分为的通道数n的整数。它返回一个与source类型相同的n个只发送通道的切片。

在内部,Split创建目标通道。对于每个创建的通道,它执行一个 goroutine,在source上的for循环中检索值,并将其转发到其分配的输出通道。实际上,每个 goroutine 竞争从source读取;如果有多个尝试读取,将随机确定“获胜者”。如果source被关闭,则所有 goroutine 终止,并且所有目标通道都关闭:

func Split(source <-chan int, n int) []<-chan int {
    dests := make([]<-chan int, 0)          // Create the dests slice

    for i := 0; i < n; i++ {                // Create n destination channels
        ch := make(chan int)
        dests = append(dests, ch)

        go func() {                         // Each channel gets a dedicated
            defer close(ch)                 // goroutine that competes for reads

            for val := range source {
                ch <- val
            }
        }()
    }

    return dests
}

给定某种特定类型的通道,Split 函数将返回若干个目标通道。通常情况下,每个通道都会被传递给一个单独的 goroutine,就像下面的示例中所演示的那样:

func main() {
    source := make(chan int)                // The input channel
    dests := Split(source, 5)               // Retrieve 5 output channels

    go func() {                             // Send the number 1..10 to source
        for i := 1; i <= 10; i++ {          // and close it when we're done
            source <- i
        }

        close(source)
    }()

    var wg sync.WaitGroup                   // Use WaitGroup to wait until
    wg.Add(len(dests))                      // the output channels all close

    for i, ch := range dests {
        go func(i int, d <-chan int) {
            defer wg.Done()

            for val := range d {
                fmt.Printf("#%d got %d\n", i, val)
            }
        }(i, ch)
    }

    wg.Wait()
}

此示例创建了一个输入通道source,将其传递给Split以接收其输出通道。同时,它在一个 goroutine 中将值 1 到 10 传递给source,同时在其他五个 goroutine 中从dests接收值。当输入完成时,关闭source通道,这将触发输出通道中的闭包,结束读取循环,每个读取 goroutine 调用wg.Done,释放wg.Wait上的锁,并允许函数结束。

未来

未来提供了一个值的占位符,这个值目前还不知道。

适用性

未来(也称为 Promises 或 Delays⁴)是一种同步构造,提供一个值的占位符,该值仍然由异步过程生成。

在 Go 语言中,这种模式不像其他一些语言那样经常使用,因为通道经常可以以类似的方式使用。例如,长时间运行的阻塞函数BlockingInverse(未显示)可以在返回结果的通道上执行 goroutine。ConcurrentInverse 函数正是这样做的,返回一个通道,可以在结果可用时读取:

func ConcurrentInverse(m Matrix) <-chan Matrix {
    out := make(chan Matrix)

    go func() {
        out <- BlockingInverse(m)
        close(out)
    }()

    return out
}

使用ConcurrentInverse,可以构建一个函数来计算两个矩阵的逆乘积:

func InverseProduct(a, b Matrix) Matrix {
    inva := ConcurrentInverse(a)
    invb := ConcurrentInverse(b)

    return Product(<-inva, <-invb)
}

看起来并不糟糕,但它带来了一些不利因素,使得它在像公共 API 这样的场景中并不理想。首先,调用者必须小心地在正确的时机调用ConcurrentInverse。要明白我的意思,请仔细看以下内容:

return Product(<-ConcurrentInverse(a), <-ConcurrentInverse(b))

看到问题了吗?由于计算直到实际调用ConcurrentInverse才开始,这种结构将有效地串行执行,需要两倍的运行时间。

更重要的是,当以这种方式使用通道时,具有多个返回值的函数通常会为返回列表的每个成员分配一个专用的通道,当返回列表增长或需要多个 goroutine 读取值时,这可能变得很麻烦。

未来模式通过将复杂性封装在一个 API 中,为消费者提供了一个简单的接口,其方法可以正常调用,阻塞所有调用的例程,直到其所有结果都被解决。该值满足的接口甚至不必为此特别构建;任何对消费者方便的接口都可以使用。

参与者

这种模式包括以下参与者:

未来

由消费者接收的接口,用于检索最终的结果。

SlowFunction

围绕一些异步执行的函数的包装函数;提供Future

InnerFuture

满足Future接口;包含一个附加方法,其中包含结果访问逻辑。

实现

提供给消费者的 API 非常简单明了:程序员调用 SlowFunction,它返回满足 Future 接口的值。 Future 可能是一个定制的接口,如下例所示,或者可能更像是一个可以传递给其自己函数的 io.Reader

实际上,当调用 SlowFunction 时,它将核心函数作为一个 goroutine 执行。在执行过程中,它定义了用于捕获核心函数输出的通道,并将其包装在 InnerFuture 中。

InnerFuture 具有一个或多个方法,满足 Future 接口,从通道中检索核心函数返回的值,缓存它们并返回。如果通道上没有可用的值,请求将阻塞。如果已经检索到值,将返回缓存的值。

示例代码

在这个示例中,我们使用一个 Future 接口,InnerFuture 将满足它:

type Future interface {
    Result() (string, error)
}

InnerFuture 结构体在内部用于提供并发功能。在这个示例中,它满足 Future 接口,但也可以选择满足类似于 io.Reader 的东西,通过附加 Read 方法,例如:

type InnerFuture struct {
    once sync.Once
    wg   sync.WaitGroup

    res   string
    err   error
    resCh <-chan string
    errCh <-chan error
}

func (f *InnerFuture) Result() (string, error) {
    f.once.Do(func() {
        f.wg.Add(1)
        defer f.wg.Done()
        f.res = <-f.resCh
        f.err = <-f.errCh
    })

    f.wg.Wait()

    return f.res, f.err
}

在这个实现中,结构体本身包含一个通道和每个由 Result 方法返回的值的变量。当首次调用 Result 时,它尝试从通道中读取结果并将它们发送回 InnerFuture 结构体,以便后续对 Result 的调用可以立即返回缓存的值。

注意使用 sync.Oncesync.WaitGroup。前者按照其字面意思执行:确保传递给它的函数只调用一次。WaitGroup 用于使这个函数调用线程安全:第一次调用后的任何调用都将在 wg.Wait 处阻塞,直到通道读取完成。

SlowFunction 是围绕你想要并发运行的核心功能的包装器。它负责创建结果通道、在 goroutine 中运行核心功能,并创建并返回 Future 实现 (InnerFuture 在本示例中):

func SlowFunction(ctx context.Context) Future {
    resCh := make(chan string)
    errCh := make(chan error)

    go func() {
        select {
        case <-time.After(time.Second * 2):
            resCh <- "I slept for 2 seconds"
            errCh <- nil
        case <-ctx.Done():
            resCh <- ""
            errCh <- ctx.Err()
        }
    }()

    return &InnerFuture{resCh: resCh, errCh: errCh}
}

要使用这种模式,只需调用 SlowFunction 并使用返回的 Future 就像使用任何其他值一样:

func main() {
    ctx := context.Background()
    future := SlowFunction(ctx)

    res, err := future.Result()
    if err != nil {
        fmt.Println("error:", err)
        return
    }

    fmt.Println(res)
}

这种方法提供了一个相当不错的用户体验。程序员可以创建一个 Future 并按其意愿访问它,甚至可以使用 Context 应用超时或截止时间。

分片

分片 将一个大数据结构分成多个分区,以局部化读/写锁的影响。

适用性

分片 一词通常在分布式状态的上下文中使用,用于描述在服务器实例之间分区的数据。这种 水平分片 常用于数据库和其他数据存储中,以分发负载并提供冗余。

有时可能会影响高度并发服务的稍有不同问题,这些服务具有用于保护共享数据结构免受冲突写入的锁定机制。在这种情况下,用于确保数据完整性的锁定也可能在进程开始花费更多时间等待锁定而不是执行其工作时创建瓶颈。这种不幸的现象被称为锁争用

虽然在某些情况下可以通过扩展实例数量来解决这个问题,但这也会增加复杂性和延迟,因为需要建立分布式锁,并确保写入一致性。在服务实例内部减少围绕共享数据结构的锁争用的另一种替代策略是垂直分片,其中一个大数据结构被分割成两个或更多结构,每个结构代表整体的一部分。使用这种策略,一次只需要锁定整体结构的一部分,从而降低整体锁争用。

参与者

此模式包括以下参与者:

ShardedMap

围绕一个或多个Shards提供读取和写入访问的抽象,就好像Shards是一个单一映射。

Shard

代表单个数据分区的可单独锁定的集合。

实现

虽然惯用的 Go 强烈倾向于使用通过通道进行内存共享来保护共享资源,而不是使用锁,⁵但这并非总是可能的。映射对于并发使用特别不安全,因此使用锁作为同步机制是一种必要的恶。幸运的是,Go 提供了sync.RWMutex,专门用于此目的。

RWMutex提供了建立读取和写入锁定的方法,如下所示。使用此方法,只要没有打开写入锁定,任意数量的进程就可以建立同时读取锁定;只有在没有现有读取或写入锁定时,进程才能建立写入锁定。尝试建立额外的锁定将被阻塞,直到其前面的任何锁定被释放:

var items = struct{                                 // Struct with a map and a
    sync.RWMutex                                    // composed sync.RWMutex
    m map[string]int
}{m: make(map[string]int)}

func ThreadSafeRead(key string) int {
    items.RLock()                                   // Establish read lock
    value := items.m[key]
    items.RUnlock()                                 // Release read lock
    return value
}

func ThreadSafeWrite(key string, value int) {
    items.Lock()                                    // Establish write lock
    items.m[key] = value
    items.Unlock()                                  // Release write lock
}

这种策略通常运行良好。然而,由于锁只允许一次访问一个进程,因此在读/写密集型应用程序中等待锁清除的平均时间可能会随着并发进程数量的增加而显著增加。由此产生的锁争用可能会潜在地成为关键功能的瓶颈。

垂直分片通过将底层数据结构(通常是一个映射)分割成几个可单独锁定的映射来减少锁争用。一个抽象层提供对底层分片的访问,就好像它们是一个单一结构(见图 4-5)。

cngo 0405

图 4-5. 按键哈希垂直分片映射

在内部,这通过在本质上是映射的映射周围创建一个抽象层来实现。每当在映射抽象中读取或写入值时,都会为键计算一个哈希值,然后通过分片数量取模以生成一个分片索引。这使得映射抽象能够将必要的锁定隔离到仅限于该索引处的分片。

示例代码

在以下示例中,我们使用标准的synccrypto/sha1包来实现一个基本的分片映射:ShardedMap

在内部,ShardedMap只是指向一些Shard值的切片指针,但我们将其定义为一种类型,以便我们可以附加方法。每个Shard包括一个包含该分片数据的map[string]interface{},以及一个组合的sync.RWMutex,以便可以单独锁定它:

type Shard struct {
    sync.RWMutex                            // Compose from sync.RWMutex
    m map[string]interface{}                // m contains the shard's data
}

type ShardedMap []*Shard                    // ShardedMap is a *Shards slice

Go 语言没有任何构造函数的概念,因此我们提供了一个NewShardedMap函数来获取一个新的ShardedMap

func NewShardedMap(nshards int) ShardedMap {
    shards := make([]*Shard, nshards)       // Initialize a *Shards slice

    for i := 0; i < nshards; i++ {
        shard := make(map[string]interface{})
        shards[i] = &Shard{m: shard}
    }

    return shards                           // A ShardedMap IS a *Shards slice!
}

ShardedMap有两个未导出方法,getShardIndexgetShard,分别用于计算键的分片索引和检索键的正确分片。这些方法可以很容易地合并成一个单一方法,但将它们分开这样做使它们更易于测试:

func (m ShardedMap) getShardIndex(key string) int {
    checksum := sha1.Sum([]byte(key))   // Use Sum from "crypto/sha1"
    hash := int(checksum[17])           // Pick an arbitrary byte as the hash
    return hash % len(m)                // Mod by len(m) to get index
}

func (m ShardedMap) getShard(key string) *Shard {
    index := m.getShardIndex(key)
    return m[index]
}

请注意,前面的示例有一个明显的缺陷:因为它实际上使用了一个字节大小的值作为哈希值,所以它只能处理多达 255 个分片。如果出于某种原因需要更多,您可以在其上进行一些二进制运算:hash := int(sum[13]) << 8 | int(sum[17])

最后,我们向ShardedMap添加了方法,允许用户读取和写入值。显然,这些并不展示映射可能需要的所有功能。然而,请随意在与本书关联的 GitHub 存储库中实现它们作为练习。删除和包含方法会很不错:

func (m ShardedMap) Get(key string) interface{} {
    shard := m.getShard(key)
    shard.RLock()
    defer shard.RUnlock()

    return shard.m[key]
}

func (m ShardedMap) Set(key string, value interface{}) {
    shard := m.getShard(key)
    shard.Lock()
    defer shard.Unlock()

    shard.m[key] = value
}

当您确实需要在所有表上建立锁时,通常最好同时进行。在下面的示例中,我们使用 goroutine 和我们的老朋友sync.WaitGroup来实现一个Keys函数:

func (m ShardedMap) Keys() []string {
    keys := make([]string, 0)               // Create an empty keys slice

    mutex := sync.Mutex{}                   // Mutex for write safety to keys

    wg := sync.WaitGroup{}                  // Create a wait group and add a
    wg.Add(len(m))                          // wait value for each slice

    for _, shard := range m {               // Run a goroutine for each slice
        go func(s *Shard) {
            s.RLock()                       // Establish a read lock on s

            for key := range s.m {          // Get the slice's keys
                mutex.Lock()
                keys = append(keys, key)
                mutex.Unlock()
            }

            s.RUnlock()                     // Release the read lock
            wg.Done()                       // Tell the WaitGroup it's done
        }(shard)
    }

    wg.Wait()                               // Block until all reads are done

    return keys                             // Return combined keys slice
}

使用ShardedMap并不像使用标准映射那样简单,但虽然不同,它也不更复杂:

func main() {
    shardedMap := NewShardedMap(5)

    shardedMap.Set("alpha", 1)
    shardedMap.Set("beta", 2)
    shardedMap.Set("gamma", 3)

    fmt.Println(shardedMap.Get("alpha"))
    fmt.Println(shardedMap.Get("beta"))
    fmt.Println(shardedMap.Get("gamma"))

    keys := shardedMap.Keys()
    for _, k := range keys {
        fmt.Println(k)
    }
}

除了复杂性以外,ShardedMap可能最大的缺点是与interface{}的使用相关联的类型安全丧失,以及随后需要进行类型断言。希望随着 Go 语言泛型的即将发布,这个问题很快就会(或者在您阅读此文时可能已经)成为历史!

总结

本章涵盖了相当多非常有趣且有用的惯用语。可能还有更多⁶,但这些是我认为最重要的,要么因为它们在实际应用中非常实用,要么因为它们展示了 Go 语言的一些有趣特性。通常是两者兼具。

在第五章中,我们将进入下一个层次,将我们在第三章和 4 章讨论的一些内容付诸实践,通过从头开始构建一个简单的键值存储来实现它们!

¹ 1979 年 8 月口述。由维奇·阿姆斯特鲁姆、托尼·霍尔、尼克劳斯·维尔特、威姆·费让和拉杰夫·乔西证实。《追求简单:纪念艾兹格·威尔布·迪克斯特教授的座谈会》,2000 年 5 月 12-13 日。

² L(是的,他的法定名字就是 L)是一个聪明而迷人的人类。有空时查一查他。

³ 埃里克·伽玛等人,《设计模式:可复用面向对象软件的基本原则》,第 1 版。Addison-Wesley Professional 出版,1994 年。

⁴ 虽然这些术语经常可以互换使用,但根据上下文的不同,它们的含义也会有所不同。我知道。请不要给我写任何愤怒的信件。

⁵ 请看《Go 博客》上的文章,“通过通信共享内存”。

⁶ 我漏掉了你最喜欢的内容吗?请告诉我,我会尽量在下一版中加入它!

第五章:构建云原生服务

二战前生活简单。之后,我们有了系统。¹

Grace Hopper,《OCLC Newsletter》(1987 年)

在本章中,我们的真正工作终于开始了。

我们将整合讨论过的许多材料,创建一个服务,作为本书其余部分的起点。随着我们的进行,我们将在这里开始的基础上进行迭代,每一章都添加功能层,直到最后,我们将拥有一个真正的云原生应用程序。

当然,它不会“生产就绪”——例如,它将缺少重要的安全功能——但它将为我们提供一个坚实的基础来构建。

那我们要构建什么?

让我们构建一个服务!

好的。所以。我们需要建立一些东西。

它应该在概念上简单,足够直接地以其最基本的形式实现,但是复杂且适合扩展和分布。我们可以在本书的其余部分逐步完善它。我对此进行了深思熟虑,考虑了我们的应用程序将是什么不同的想法,但最终答案显而易见。

我们将构建一个分布式键值存储。

什么是键值存储?

键值存储是一种非关系数据库,它将数据存储为一组键值对。它们与我们熟知和喜爱的更为知名的关系数据库(如 Microsoft SQL Server 或 PostgreSQL)非常不同。² 在关系数据库将数据结构化到固定表和定义良好的数据类型之中时,键值存储则简单得多,允许用户将唯一标识符(键)与任意值关联起来。

换句话说,在其核心,键值存储实际上只是具有服务端点的映射,如图 5-1 所示。它们是可能的最简单的数据库。

cngo 0501

图 5-1. 键值存储本质上是具有服务端点的映射

需求

到本章末尾,我们将建立一个简单的、非分布式的键值存储,它可以执行一个(单体)键值存储应该做的所有事情。

  • 它必须能够存储任意的键值对。

  • 它必须提供服务端点,允许用户放置、获取和删除键值对。

  • 它必须能够以某种方式持久地存储其数据。

最后,我们希望服务是幂等的。但是为什么?

什么是幂等性以及为什么它很重要?

幂等性的概念起源于代数,它描述了某些数学运算的特定属性。幸运的是,这不是一本数学书。我们不打算谈论这个(除了本节末尾的侧边栏)。

在编程世界中,操作(例如方法或服务调用)如果调用一次与多次调用产生相同效果,则称为幂等操作。例如,赋值操作 x=1 是幂等的,因为无论您赋值多少次,x 都将始终为 1。类似地,HTTP 的 PUT 方法是幂等的,因为多次 PUT 同一个资源到同一位置不会改变任何内容:第二次也不会多一个 PUT。³ 然而,操作 x+=1 不是幂等的,因为每次调用它时都会产生新的状态。

较少讨论,但同样重要的是空位性的相关属性,其中函数或操作根本没有任何副作用。例如,赋值 x=1 和 HTTP PUT 是幂等的但不是空位的,因为它们触发了状态更改。将值分配给自身,例如 x=x,是空位的,因为它没有导致任何状态的改变。同样地,仅仅读取数据,如 HTTP GET,通常没有副作用,因此它也是空位的。

当然,在理论上这一切听起来都很好,但在现实世界中为什么我们要关心呢?事实证明,设计服务方法为幂等提供了一些非常实际的好处:

Idempotent 操作更安全。

如果您向服务发送请求,但没有得到响应,您可能会再次尝试。但如果它第一次就听到了您呢?⁴ 如果服务方法是幂等的,那么没有任何伤害。但如果不是,可能会有问题。这种情况比您想象的更常见。网络不可靠。响应可能延迟;数据包可能丢失。

幂等操作通常更简单。

幂等操作更加自包含和易于实现。例如,简单地将键值对添加到后备数据存储的幂等 PUT 方法与类似但非幂等的 CREATE 方法进行比较,如果数据存储已经包含键,则 CREATE 方法将返回错误。PUT 逻辑很简单:接收请求,设置值。另一方面,CREATE 需要额外的错误检查和处理,可能需要在任何服务副本之间进行分布式锁定和协调,使其难以扩展。

Idempotent 操作更声明性。

构建一个幂等的 API 鼓励设计者专注于最终状态,鼓励生成更声明性的方法:它们允许用户告诉服务需要做什么,而不是告诉它如何做。这可能看起来微妙,但声明性方法——与命令式方法相对——使用户不必处理低级构造,可以专注于他们的目标并最小化潜在的副作用。

实际上,幂等性在云原生环境中提供了如此大的优势,以至于一些非常聪明的人甚至进一步断言它是“云原生”的同义词⁵。我认为这种说法有些言过其实,但我可以说,如果你的服务旨在成为云原生,接受不到幂等性将会招来麻烦。

最终目标

这些要求非常繁琐,但它们代表了我们的键值存储可用的绝对最低要求。稍后我们将添加一些重要的基本功能,比如支持多用户和数据传输加密。更重要的是,我们将引入使服务更具可伸缩性、弹性并且能够在一个残酷不确定的宇宙中生存和繁荣的技术和技术。

生成 0:核心功能

好的,让我们开始吧。首先要做的是。在不用担心用户请求和持久性的情况下,让我们首先构建核心函数,稍后可以从我们决定使用的任何 Web 框架调用它。

存储任意键-值对

目前,我们可以用一个简单的映射来实现这个,但是什么样的映射呢?为了简单起见,我们将限制键和值为简单字符串,尽管以后我们可能会选择允许任意类型。我们将使用一个简单的 map[string]string 作为我们的核心数据结构。

允许键-值对的放置、获取和删除

在这一初始迭代中,我们将创建一个简单的 Go API,我们可以调用它执行基本的修改操作。通过这种方式分区功能将使测试和将来迭代更新变得更加容易。

您的超级简单 API

我们需要做的第一件事是创建我们的映射。我们键值存储的核心:

var store = make(map[string]string)

它不是美吗?如此简单。别担心,稍后我们会让它变得更复杂。

我们将创建的第一个函数很恰当地是 PUT,它将用于向存储添加记录。它确实像其名称所示:接受 keyvalue 字符串,并将它们放入 store 中。 PUT 的函数签名包括一个 error 返回,稍后我们将需要它:

func Put(key string, value string) error {
    store[key] = value

    return nil
}

因为我们有意选择创建一个幂等服务,Put 操作不会检查是否正在覆盖现有的键-值对,因此如果需要的话,它将乐意这样做。使用相同参数多次执行 Put 操作将产生相同的结果,而不管当前状态如何。

现在我们已经建立了一个基本模式,编写 GetDelete 操作只是按照计划进行:

var ErrorNoSuchKey = errors.New("no such key")

func Get(key string) (string, error) {
    value, ok := store[key]

    if !ok {
        return "", ErrorNoSuchKey
    }

    return value, nil
}

func Delete(key string) error {
    delete(store, key)

    return nil
}

但请仔细观察:看到 Get 返回错误时,它没有使用 errors.New 吗?相反,它返回了预先构建的 ErrorNoSuchKey 错误值。但是为什么?这是一个哨兵错误的例子,它允许使用服务确定确切收到的错误类型,并相应地做出响应。例如,它可能做类似于以下操作:

if errors.Is(err, ErrorNoSuchKey) {
    http.Error(w, err.Error(), http.StatusNotFound)
    return
}

现在,你已经拥有了绝对最小的功能集(真的非常少),别忘了写测试。我们这里不会做这个,但如果你感到急于前进(或懒惰——懒惰也行),你可以从为本书创建的 GitHub 仓库获取代码。

第一代:单体应用

现在我们有了一个最小功能的键值 API,我们可以开始构建围绕它的服务。我们有几种不同的选项来做这件事。我们可以使用类似 GraphQL 的东西。市面上有一些不错的第三方包可供使用,但我们没有必要复杂的数据景观来需要它。我们还可以使用远程过程调用(RPC),它受到标准net/rpc包的支持,甚至是 gRPC,但这些需要额外的客户端开销,再次,我们的数据并不复杂到需要这么做。

这使我们只能选择表现状态转移(REST)。REST 并不是很多人的最爱,但很简单,对我们的需求来说完全足够。

使用 net/http 构建 HTTP 服务器

Go 语言没有像 Django 或 Flask 那样复杂或悠久历史的 Web 框架。然而,它拥有一套强大的标准库,完全能够满足 80%的使用场景。更好的是:它们设计为可扩展,因此确实有一些 Go Web 框架在其基础上扩展。

现在,让我们看一下在 Go 语言中的标准 HTTP 处理程序惯用法,以一个“Hello World”为例,使用net/http实现:

package main

import (
    "log"
    "net/http"
)

func helloGoHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello net/http!\n"))
}

func main() {
    http.HandleFunc("/", helloGoHandler)

    log.Fatal(http.ListenAndServe(":8080", nil))
}

在前面的示例中,我们定义了一个方法,helloGoHandler,它满足http.HandlerFunc的定义:

type HandlerFunc func(http.ResponseWriter, *http.Request)

http.ResponseWriter*http.Request参数可用于构造 HTTP 响应和检索请求。您可以使用http.HandleFunc函数将helloGoHandler注册为匹配给定模式的任何请求的处理函数(在本示例中为根路径)。

一旦注册了我们的处理程序,你就可以调用ListenAndServe,它监听地址addr。在我们的示例中,第二个参数设置为nil

注意到ListenAndServe也被包装在log.Fatal调用中。这是因为ListenAndServe始终会停止执行流程,在错误发生时才会返回。因此,它始终返回非 nil 的错误,我们总是希望将其记录下来。

前面的示例是一个完整的程序,可以使用go run编译并运行:

$ go run .

恭喜!你现在运行着世界上最小的 Web 服务。现在可以用curl或你喜欢的浏览器来测试它:

$ curl http://localhost:8080
Hello net/http!

使用 gorilla/mux 构建 HTTP 服务器

对于许多 web 服务来说,net/httpDefaultServeMux 将足够了。但是,有时你会需要第三方 web 工具包提供的额外功能。一个受欢迎的选择是 Gorilla,尽管它相对较新,开发和资源比不上像 Django 或 Flask 这样的框架,但它基于 Go 的标准 net/http 包来提供一些优秀的增强功能。

gorilla/mux 包是 Gorilla web 工具包提供的几个包之一,它提供了一个 HTTP 请求路由器和调度器,完全可以取代 DefaultServeMux,Go 的默认服务处理器,以增强请求路由和处理的几个非常有用的功能。尽管我们目前还没有使用这些特性,但它们将在以后派上用场。如果你感兴趣和/或心急,你可以查看 gorilla/mux 文档 获取更多信息。

创建一个最小化服务

一旦你这样做了,使用最小化的 gorilla/mux 路由器只需添加一个导入和一行代码:初始化一个新的路由器,可以传递给 ListenAndServehandler 参数:

package main

import (
    "log"
    "net/http"

    "github.com/gorilla/mux"
)

func helloMuxHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello gorilla/mux!\n"))
}

func main() {
    r := mux.NewRouter()

    r.HandleFunc("/", helloMuxHandler)

    log.Fatal(http.ListenAndServe(":8080", r))
}

所以现在你应该可以用 go run 直接运行了,对吧?试试看:

$ go run .
main.go:7:5: cannot find package "github.com/gorilla/mux" in any of:
        /go/1.15.8/libexec/src/github.com/gorilla/mux (from $GOROOT)
        /go/src/github.com/gorilla/mux (from $GOPATH)

结果证明你还不能这样做。因为现在你正在使用一个第三方包——一个不在标准库中的包——你必须使用 Go modules。

使用 Go modules 初始化你的项目

使用来自标准库之外的包需要使用 Go modules,这是在 Go 1.12 中引入的,以替换几乎不存在的依赖管理系统,改为使用明确且实际上相当轻松的系统。所有管理依赖项的操作都将使用一小组 go mod 命令之一。

首先,你需要初始化你的项目。创建一个新的空目录,cd 进入该目录,然后在那里创建(或移动)你的服务的 Go 文件。你的目录现在应该只包含一个单独的 Go 文件。

接下来,使用 go mod init 命令初始化项目。通常,如果一个项目将被其他项目导入,它将必须使用其导入路径进行初始化。不过,对于像我们这样的独立服务来说,这就不那么重要了,所以你可以在选择名称时放松一点。我将使用 example.com/gorilla;你可以使用任何你喜欢的名称:

$ go mod init example.com/gorilla
go: creating new go.mod: module example.com/gorilla

现在你的目录中将有一个(几乎)空的模块文件 go.mod

$ cat go.mod
module example.com/gorilla

go 1.15

接下来,我们将想要添加我们的依赖项,可以使用 go mod tidy 自动完成:

$ go mod tidy
go: finding module for package github.com/gorilla/mux
go: found github.com/gorilla/mux in github.com/gorilla/mux v1.8.0

如果你检查你的 go.mod 文件,你会看到依赖项(以及版本号)已经被添加了:

$ cat go.mod
module example.com/gorilla

go 1.15

require github.com/gorilla/mux v1.8.0

信不信由你,这就是你需要的全部。如果将来需要更改所需的依赖项,只需再次运行go mod tidy即可重新构建文件。现在再次尝试启动您的服务:

$ go run .

由于服务在前台运行,您的终端应该暂停。从另一个终端使用curl调用端点或在浏览器中浏览到它应该提供预期的响应:

$ curl http://localhost:8080
Hello gorilla/mux!

成功!但是您肯定希望您的服务不仅仅打印一个简单的字符串,对吧?当然希望。请继续阅读!

URI 路径中的变量

Gorilla Web 工具包比标准的net/http包提供了丰富的附加功能,但其中一个特性现在特别有趣:使用变量段创建路径的能力,甚至可以选择包含正则表达式模式。使用gorilla/mux包,程序员可以使用格式{name}{name:pattern}定义变量,如下所示:

r := mux.NewRouter()
r.HandleFunc("/products/{key}", ProductHandler)
r.HandleFunc("/articles/{category}/", ArticlesCategoryHandler)
r.HandleFunc("/articles/{category}/{id:[0-9]+}", ArticleHandler)

mux.Vars函数方便地允许处理程序函数作为map[string]string检索变量名和值:

vars := mux.Vars(request)
category := vars["category"]

在下一节中,我们将利用这种能力允许客户端对任意键执行操作。

如此多的匹配器

gorilla/mux提供的另一个功能是允许在路由中添加各种匹配器,以便程序员添加各种额外的匹配请求条件。这些条件包括(但不限于)特定的域或子域、路径前缀、方案、标头,甚至您自己创建的自定义匹配函数。

可以通过对 Gorilla 的HandleFunc实现返回的*Route值调用适当的函数来应用匹配器。每个匹配器函数都返回受影响的*Route,因此它们可以链接在一起。例如:

r := mux.NewRouter()

r.HandleFunc("/products", ProductsHandler).
    Host("www.example.com").                // Only match a specific domain
    Methods("GET", "PUT").                  // Only match GET+PUT methods
    Schemes("http")                         // Only match the http scheme

查看gorilla/mux 文档以获取可用匹配器函数的详尽列表。

构建 RESTful 服务

现在您已经知道如何使用 Go 的标准 HTTP 库,您可以使用它创建一个 RESTful 服务,客户端可以与之交互以执行在“您的超级简单 API”中构建的 API 调用。完成此操作后,您将实施绝对最小的可行键值存储。

您的 RESTful 方法

我们将尽力遵循 RESTful 约定,因此我们的 API 将考虑每个键值对作为一个独立的资源,具有可以使用各种 HTTP 方法操作的独特 URI。我们的三种基本操作——Put、Get 和 Delete——将使用不同的 HTTP 方法请求,我们在表 5-1 中进行了总结。

您的键值对资源的 URI 将采用/v1/key/{key}的形式,其中{key}是唯一的键字符串。v1段表示 API 版本。这种约定通常用于管理 API 更改,虽然这种做法并非必需或通用,但有助于管理可能会破坏现有客户端集成的未来更改的影响。

表 5-1. 您的 RESTful 方法

功能 方法 可能的状态码
将键值对放入存储中 PUT 201 (Created)
从存储中读取键值对 GET 200 (OK), 404 (Not Found)
删除键值对 DELETE 200 (OK)

在“URI 路径中的变量”中,我们讨论了如何使用gorilla/mux包注册包含变量段的路径,这将允许您定义一个处理所有键的单个变量路径,从而无需单独注册每个键。然后,在“如此多的匹配器”中,我们讨论了如何使用路由匹配器根据各种非路径标准将请求导向特定的处理程序函数,您可以使用它们为您支持的五种 HTTP 方法创建单独的处理程序函数。

实现创建函数

好的,现在你已经准备就绪了!那么,让我们继续实现创建键值对的处理程序函数。该函数必须确保满足几个要求:

  • 它必须仅匹配 /v1/key/{key}PUT 请求。

  • 它必须调用“您的超级简单 API”中的 Put 方法。

  • 当创建键值对时,必须响应 201 (Created)

  • 必须用 500 (Internal Server Error) 响应意外错误。

所有先前的要求都在 keyValuePutHandler 函数中实现。请注意如何从请求体中检索键的值:

// keyValuePutHandler expects to be called with a PUT request for
// the "/v1/key/{key}" resource.
func keyValuePutHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)                     // Retrieve "key" from the request
    key := vars["key"]

    value, err := io.ReadAll(r.Body)        // The request body has our value
    defer r.Body.Close()

    if err != nil {                         // If we have an error, report it
        http.Error(w,
            err.Error(),
            http.StatusInternalServerError)
        return
    }

    err = Put(key, string(value))           // Store the value as a string
    if err != nil {                         // If we have an error, report it
        http.Error(w,
            err.Error(),
            http.StatusInternalServerError)
        return
    }

    w.WriteHeader(http.StatusCreated)       // All good! Return StatusCreated
}

现在您已经有了“键值创建”处理程序函数,可以将其注册到 Gorilla 请求路由器以供所需的路径和方法使用:

func main() {
    r := mux.NewRouter()

    // Register keyValuePutHandler as the handler function for PUT
    // requests matching "/v1/{key}"
    r.HandleFunc("/v1/{key}", keyValuePutHandler).Methods("PUT")

    log.Fatal(http.ListenAndServe(":8080", r))
}

现在,您已经组装好了您的服务,可以在项目根目录下使用 go run . 运行它。现在就这样做,并发送一些请求来查看它的响应。

首先,使用我们的老朋友 curl 发送一个 PUT,其中包含一个短文本片段到 /v1/key-a 端点,创建一个名为 key-a 的键,其值为 Hello, key-value store!

$ curl -X PUT -d 'Hello, key-value store!' -v http://localhost:8080/v1/key-a

执行此命令将提供以下输出。完整输出非常冗长,因此我选择了可读性更好的相关部分:

> PUT /v1/key-a HTTP/1.1
< HTTP/1.1 201 Created

第一部分以大于符号(>)开头,显示了有关请求的一些详细信息。最后一部分以小于符号(<)开头,提供了关于服务器响应的详细信息。

在此输出中,您可以看到确实向 /v1/key-a 端点发送了 PUT 请求,并且服务器响应了 201 Created—如预期那样。

如果使用不支持的 GET 方法访问 /v1/key-a 端点会发生什么?假设匹配函数工作正常,您应该收到一个错误消息:

$ curl -X GET -v http://localhost:8080/v1/key-a
> GET /v1/key-a HTTP/1.1
< HTTP/1.1 405 Method Not Allowed

实际上,服务器响应了 405 Method Not Allowed 错误。一切似乎都运行正常。

实现读取函数

现在你的服务已经有了一个完全运作的Put方法,如果你能读取数据将会很好!接下来,我们将实现Get功能,它有以下要求:

  • 它只能匹配GET请求的/v1/key/{key}

  • 它必须调用来自“你的超简单 API”的Get方法。

  • 当请求的键不存在时,它必须响应404 (Not Found)

  • 如果键存在,则必须用请求的值和状态200作出响应。

  • 必须对意外错误作出500 (Internal Server Error)的响应。

所有之前的需求都已在keyValueGetHandler函数中实现。注意在从键-值 API 中检索到值后,它是如何写入w(处理程序函数的http.ResponseWriter参数)的:

func keyValueGetHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)                     // Retrieve "key" from the request
    key := vars["key"]

    value, err := Get(key)                  // Get value for key
    if errors.Is(err, ErrorNoSuchKey) {
        http.Error(w,err.Error(), http.StatusNotFound)
        return
    }
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Write([]byte(value))                  // Write the value to the response
}

现在你有了“get”处理函数,你可以将其与请求路由器一起注册,与“put”处理程序并列:

func main() {
    r := mux.NewRouter()

    r.HandleFunc("/v1/{key}", keyValuePutHandler).Methods("PUT")
    r.HandleFunc("/v1/{key}", keyValueGetHandler).Methods("GET")

    log.Fatal(http.ListenAndServe(":8080", r))
}

现在让我们启动你新改进的服务,看看它是否能正常工作:

$ curl -X PUT -d 'Hello, key-value store!' -v http://localhost:8080/v1/key-a
> PUT /v1/key-a HTTP/1.1
< HTTP/1.1 201 Created

$ curl -v http://localhost:8080/v1/key-a
> GET /v1/key-a HTTP/1.1
< HTTP/1.1 200 OK
Hello, key-value store!

它有效了!现在你可以取回你的值,也能够测试幂等性。让我们重复请求,确保你得到相同的结果:

$ curl -X PUT -d 'Hello, key-value store!' -v http://localhost:8080/v1/key-a
> PUT /v1/key-a HTTP/1.1
< HTTP/1.1 201 Created

$ curl -v http://localhost:8080/v1/key-a
> GET /v1/key-a HTTP/1.1
< HTTP/1.1 200 OK
Hello, key-value store!

成功了!但是如果你想用新值覆盖键会怎样呢?后续的GET请求会获取到新值吗?你可以通过稍微改变curl发送的值来测试:Hello, again, key-value store!

$ curl -X PUT -d 'Hello, again, key-value store!' \
    -v http://localhost:8080/v1/key-a
> PUT /v1/key-a HTTP/1.1
< HTTP/1.1 201 Created

$ curl -v http://localhost:8080/v1/key-a
> GET /v1/key-a HTTP/1.1
< HTTP/1.1 200 OK
Hello, again, key-value store!

预料之中,GET响应返回了状态200和你的新值。

最后,要完成你的方法集,你只需要为DELETE方法创建一个处理程序。尽管如此,这留作练习。祝你好运!

使你的数据结构具备并发安全性

Go 语言中的映射不是原子性的,也不适合并发使用。不幸的是,你现在拥有一个服务,设计用于处理并发请求,但却包裹了这样一个映射。

那么你该怎么办呢?嗯,通常情况下,当程序员有一个需要并发执行 goroutine 读取和写入的数据结构时,他们会使用像互斥锁(也称为锁)这样的东西作为同步机制。通过这种方式使用互斥锁,你可以确保只有一个进程可以独占地访问特定的资源。

幸运的是,你不需要自己实现这个功能:⁷ Go 语言的sync包正好提供了你所需的,以sync.RWMutex的形式。以下语句利用组合的神奇力量创建了一个匿名结构体,其中包含你的映射和一个嵌入的sync.RWMutex

var myMap = struct{
    sync.RWMutex
    m map[string]string
}{m: make(map[string]string)}

myMap结构体包含来自嵌入的sync.RWMutex的所有方法,允许你在想要写入myMap映射时使用Lock方法获取写锁定:

myMap.Lock()                                // Take a write lock
myMap.m["some_key"] = "some_value"
myMap.Unlock()                              // Release the write lock

如果另一个进程已经有读取或写入锁定,则Lock将会阻塞,直到该锁定被释放。

类似地,要从映射中读取,你使用RLock方法获取读锁定:

myMap.RLock()                               // Take a read lock
value := myMap.m["some_key"]
myMap.RUnlock()                             // Release the read lock

fmt.Println("some_key:", value)

读锁比写锁更不限制性,任意数量的进程可以同时获取读锁。但是,RLock会阻塞,直到任何打开的写锁被释放。

将读写互斥体集成到您的应用程序中

现在您知道如何使用sync.RWMutex来实现基本的读写互斥体,可以回头将其整合到您为“您的超简单 API”创建的代码中。

首先,您将需要重构store映射。您可以像myMap一样构建它,即作为一个匿名结构体,其中包含映射和一个嵌入的sync.RWMutex

var store = struct{
    sync.RWMutex
    m map[string]string
}{m: make(map[string]string)}

现在您有了store结构体,可以更新GetPut函数来建立适当的锁。因为Get只需要读取store映射,所以它只会使用RLock来获取读锁。另一方面,Put需要修改映射,因此需要使用Lock来获取写锁:

func Get(key string) (string, error) {
    store.RLock()
    value, ok := store.m[key]
    store.RUnlock()

    if !ok {
        return "", ErrorNoSuchKey
    }

    return value, nil
}

func Put(key string, value string) error {
    store.Lock()
    store.m[key] = value
    store.Unlock()

    return nil
}

这里的模式很明显:如果一个函数需要修改映射(PutDelete),它会使用Lock来获取写锁。如果只需要读取现有数据(Get),则会使用RLock来获取读锁。我们将Delete函数的创建留给读者作为练习。

警告

不要忘记释放锁,并确保释放正确的锁类型!

第二代:持久化资源状态

分布式云原生应用程序面临的最棘手的挑战之一是如何处理状态。

有多种技术可用于在多个服务实例之间分发应用程序资源的状态,但目前我们只关注最小可行产品,并考虑两种维护应用程序状态的方式:

  • 在“在事务日志文件中存储状态”中,您将使用基于文件的事务日志来记录每次修改资源的记录。如果服务崩溃、重新启动或以其他方式处于不一致状态,事务日志允许服务通过简单地重放事务来重建原始状态。

  • 在“在外部数据库中存储状态”中,您将使用外部数据库而不是文件来存储事务日志。鉴于您正在构建的应用程序的性质,使用数据库可能看起来多余,但将数据外部化到专门设计用于此目的的另一个服务中是共享服务副本之间状态和提供弹性的常见手段。

您可能会想知道为什么要使用事务日志策略来记录事件,而不是仅使用数据库来存储值本身。当您打算大部分时间将数据存储在内存中,只在后台和启动时访问持久性机制时,这是有道理的。

这也为你提供了另一个机会:鉴于你正在创建两种不同的实现类似功能的实现——一个事务日志同时写入文件和数据库——你可以使用一个接口来描述这个功能,使得两种实现都能够满足。这在需要根据需求选择实现的情况下非常方便。

什么是事务日志?

在其最简单的形式下,事务日志只是一个日志文件,记录了数据存储执行的变更历史。如果服务崩溃、重新启动或者处于不一致状态,事务日志可以重新播放事务,以恢复服务的功能状态。

数据库管理系统通常使用事务日志来提供对崩溃或硬件故障的数据恢复能力。然而,虽然这种技术可以变得非常复杂,但我们将保持简单直接。

你的事务日志格式

在进入代码之前,让我们决定事务日志应包含什么。

假设当服务重新启动或者需要恢复其状态时才会读取您的事务日志,并且它将按顺序从头到尾顺序重放每个事件。因此,您的事务日志将包含一个有序的变更事件列表。为了速度和简单起见,事务日志通常是追加写的,因此例如从键值存储中删除记录时,会在日志中记录一个delete

综合我们迄今讨论的所有内容,每个记录的事务事件都需要包含以下属性:

序列号

一个唯一的记录 ID,按单调递增顺序排列。

事件类型

描述所执行操作类型的描述;这可以是PUTDELETE

包含此事务影响的键的字符串。

如果事件是PUT,则是事务的值。

简单明了。希望我们能一直保持这种状态。

您的事务日志接口

我们要做的第一件事是定义一个TransactionLogger接口。目前,我们只会定义两个方法:WritePutWriteDelete,分别用于将PUTDELETE事件写入事务日志:

type TransactionLogger interface {
    WriteDelete(key string)
    WritePut(key, value string)
}

毫无疑问,您将希望稍后添加其他方法,但我们将在需要时再解决这个问题。现在,让我们专注于第一种实现,并在遇到其他方法时添加到接口中。

在事务日志文件中存储状态

我们将采用的第一种方法是使用最基本(也是最常见)的事务日志形式,即仅追加日志文件,记录数据存储执行的变更历史。这种基于文件的实现有一些诱人的优点,但也有一些显著的缺点:

优点:

没有下游依赖

没有依赖于可能失败或者失去访问权限的外部服务。

技术上很简单

逻辑并不是特别复杂。我们可以快速启动。

缺点:

扩展性较差

当您想要扩展时,您需要一些额外的方式来在节点之间分发您的状态。

无控制的增长

这些日志必须存储在磁盘上,因此不能让它们无限增长。您需要一些方式来压缩它们。

设计您的事务记录器原型

在我们开始编码之前,让我们做一些设计决策。首先,为了简单起见,日志将以纯文本形式编写;二进制压缩格式可能更加时间和空间有效,但我们可以稍后优化。其次,每个条目将单独写在一行上;这将使以后更容易阅读数据。

最后,每个事务将包括“您的事务日志格式”中列出的四个字段,由制表符分隔。再次强调,这些字段是:

序列号

一个唯一的记录 ID,按单调递增顺序。

事件类型

行动类型的描述符;这可以是PUTDELETE

包含此事务受影响的键的字符串。

如果事件是PUT,则事务的值。

现在我们已经建立了这些基础,让我们继续定义一个类型,FileTransactionLogger,它将隐式地实现“您的事务记录器接口”中描述的TransactionLogger接口,通过定义WritePutWriteDelete方法,分别用于写入PUTDELETE事件到事务日志中:

type FileTransactionLogger struct {
    // Something, something, fields
}

func (l *FileTransactionLogger) WritePut(key, value string) {
    // Something, something, logic
}

func (l *FileTransactionLogger) WriteDelete(key string) {
    // Something, something, logic
}

显然,这些方法细节有点少,但我们很快就会详细说明它们!

定义事件类型

提前考虑,我们可能希望WritePutWriteDelete方法以异步方式运行。您可以使用某种类型的events通道来实现这一点,某个并发的 goroutine 可以从中读取并执行日志写入操作。听起来是个好主意,但是如果您要这样做,您需要某种内部表示“事件”的方式。

这应该不会给你太多麻烦。将我们在“您的事务日志格式”中列出的所有字段合并,得到如下的Event结构体:

type Event struct {
    Sequence  uint64                // A unique record ID
    EventType EventType             // The action taken
    Key       string                // The key affected by this transaction
    Value     string                // The value of a PUT the transaction
}

看起来很简单,对吧?Sequence是序列号,KeyValue是不言自明的。但是... EventType是什么?好吧,它是我们说它是什么,我们将它定义为一个常量,我们可以用它来引用不同类型的事件,我们已经确定将包括一个PUT和一个DELETE事件。

这样做的一种方式可能是只分配一些常量byte值,如下所示:

const (
    EventDelete byte = 1
    EventPut    byte = 2
)

当然,这会起作用,但是 Go 实际上提供了一种更好(也更符合惯例)的方式:iotaiota是一个预定义的值,可以在常量声明中使用,以构建一系列相关的常量值。

使用iota技术,你不必手动为常量分配值。相反,你可以像下面这样做:

type EventType byte

const (
    _                     = iota         // iota == 0; ignore the zero value
    EventDelete EventType = iota         // iota == 1
    EventPut                             // iota == 2; implicitly repeat
)

当你只有像我们这里这样的两个常量时,这可能并不是什么大问题,但当你有一些相关的常量并且不想手动跟踪哪个值被分配给了什么时,这将非常方便。

警告

如果你在这里像我们这样将iota用作枚举的序列化方式,请务必仅追加到列表中,并且不要重新排序或在中间插入值,否则你将无法在后续进行反序列化。

现在我们已经有了TransactionLogger的大致想法,以及两个主要的写入方法。我们还定义了一个描述单个事件的结构体,并创建了一个新的EventType类型,并使用iota定义了其合法值。现在我们终于准备好开始了。

实现你的 FileTransactionLogger

我们取得了一些进展。我们知道我们想要一个有写入事件方法的TransactionLogger实现,并在代码中创建了事件的描述。但FileTransactionLogger本身呢?

服务需要跟踪事务日志的物理位置,因此有一个代表该位置的os.File属性是有意义的。它还需要记住上次分配的序列号,以便能够正确设置每个事件的序列号;这可以作为一个无符号 64 位整数属性来保存。这很棒,但FileTransactionLogger实际上如何写入事件呢?

一个可能的方法是保持一个io.Writer,使得WritePutWriteDelete方法可以直接操作它,但这将是单线程的方法,所以除非你显式地在 goroutine 中执行它们,否则可能会发现自己花费比你希望的更多时间在 I/O 上。或者,你可以从一个处理由独立 goroutine 处理的Event值切片的缓冲区创建一个缓冲区。绝对更加温暖,但也太复杂了。

毕竟,当我们可以使用标准的缓冲通道时,为什么还要经历那么多工作呢?遵循我们自己的建议,我们最终得到了一个FileTransactionLoggerWrite方法,它们看起来如下:

type FileTransactionLogger struct {
    events       chan<- Event       // Write-only channel for sending events
    errors       <-chan error       // Read-only channel for receiving errors
    lastSequence uint64             // The last used event sequence number
    file         *os.File           // The location of the transaction log
}

func (l *FileTransactionLogger) WritePut(key, value string) {
    l.events <- Event{EventType: EventPut, Key: key, Value: value}
}

func (l *FileTransactionLogger) WriteDelete(key string) {
    l.events <- Event{EventType: EventDelete, Key: key}
}

func (l *FileTransactionLogger) Err() <-chan error {
    return l.errors
}

现在你有了你的FileTransactionLogger,它有一个用于跟踪最近使用的事件序列号的uint64值,一个接收Event值的只写通道,以及将Event值发送到该通道的WritePutWriteDelete方法。

但看起来可能还有一部分遗留下来:那里有一个Err方法,返回一个只读错误通道。这是有充分理由的。我们已经提到,事务日志的写入将由从events通道接收事件的 goroutine 并发执行。虽然这使得写入更高效,但也意味着WritePutWriteDelete在遇到问题时不能简单地返回一个错误,因此我们提供了一个专用的错误通道来传达错误。

创建一个新的 FileTransactionLogger

如果你到目前为止一直在跟随,你可能已经注意到FileTransactionLogger中没有任何属性被初始化。如果你不修复这个问题,它将会导致一些问题。然而,Go 语言没有构造函数,所以为了解决这个问题,你需要定义一个构造函数,你可以称之为,缺乏更好名字的NewFileTransactionLogger

func NewFileTransactionLogger(filename string) (TransactionLogger, error) {
    file, err := os.OpenFile(filename, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0755)
    if err != nil {
        return nil, fmt.Errorf("cannot open transaction log file: %w", err)
    }

    return &FileTransactionLogger{file: file}, nil
}
警告

看看NewFileTransactionLogger如何返回一个指针类型,但其返回列表指定了明显不是指针的TransactionLogger接口类型?

这样做的原因很狡猾:虽然 Go 允许指针类型实现一个接口,但它不允许接口类型的指针。

NewFileTransactionLogger调用os.OpenFile函数打开由filename参数指定的文件。你会注意到它接受了几个通过二进制OR运算在一起设置行为的标志:

os.O_RDWR

以读/写模式打开文件。

os.O_APPEND

对这个文件的任何写入都会追加,而不是覆盖。

os.O_CREATE

如果文件不存在,则创建它。

除了我们在这里使用的三个标志外,还有很多其他标志。查看os 包的文档以获取完整列表。

现在我们有一个确保事务日志文件正确创建的构造函数。但是通道怎么办?我们可以NewFileTransactionLogger中创建通道并生成一个 goroutine,但这感觉像是我们添加了太多神秘的功能。相反,我们将创建一个Run方法。

追加条目到事务日志

到目前为止,还没有任何东西从events通道中读取,这不太理想。更糟糕的是,这些通道甚至还没有初始化。让我们通过创建一个Run方法来改变这一点,如下所示:

func (l *FileTransactionLogger) Run() {
    events := make(chan Event, 16)              // Make an events channel
    l.events = events

    errors := make(chan error, 1)               // Make an errors channel
    l.errors = errors

    go func() {
        for e := range events {                 // Retrieve the next Event

            l.lastSequence++                    // Increment sequence number

            _, err := fmt.Fprintf(              // Write the event to the log
                l.file,
                "%d\t%d\t%s\t%s\n",
                l.lastSequence, e.EventType, e.Key, e.Value)

            if err != nil {
                errors <- err
                return
            }
        }
    }()
}
注意

这个实现非常基础。它甚至不能正确处理带有空格或多行的条目!

Run函数执行了几个重要的步骤。

首先,它创建了一个带缓冲的events通道。在我们的TransactionLogger中使用带缓冲通道意味着只要缓冲区不满,调用WritePutWriteDelete不会被阻塞。这使得消费服务可以处理短时间内的事件突发而不会因为磁盘 I/O 而减慢。如果缓冲区满了,那么写入方法将会阻塞,直到日志写入的 goroutine 赶上来。

其次,它创建了一个errors通道,这也是带缓冲的,我们将用它来传递任何在并发写入事件到事务日志的 goroutine 中出现的错误。缓冲值为1允许我们以非阻塞方式发送错误。

最后,它启动了一个 goroutine,从我们的events通道中检索Event值,并使用fmt.Fprintf函数将它们写入事务日志。如果fmt.Fprintf返回一个error,goroutine 将错误发送到errors通道并停止。

使用 bufio.Scanner 回放文件事务日志

即使是最好的事务日志如果从不被读取也是无用的。¹⁰ 但是我们该如何做呢?

你需要从头开始阅读日志并解析每一行;io.ReadStringfmt.Sscanf 让你可以轻松做到这一点。

通道,我们可靠的朋友,将允许您的服务将结果流式传输给消费者,同时检索它们。这可能开始感觉 routine,但停下来欣赏一下。在大多数其他语言中,这里最简单的方法是读取整个文件,将其存储在数组中,最后循环遍历该数组以重放事件。Go 的便利并发原语使得将数据流式传输给消费者变得几乎轻而易举,而且更节省空间和内存。

ReadEvents 方法¹¹演示了这一点:

func (l *FileTransactionLogger) ReadEvents() (<-chan Event, <-chan error) {
    scanner := bufio.NewScanner(l.file)     // Create a Scanner for l.file
    outEvent := make(chan Event)            // An unbuffered Event channel
    outError := make(chan error, 1)         // A buffered error channel

    go func() {
        var e Event

        defer close(outEvent)               // Close the channels when the
        defer close(outError)               // goroutine ends

        for scanner.Scan() {
            line := scanner.Text()

            if err := fmt.Sscanf(line, "%d\t%d\t%s\t%s",
                &e.Sequence, &e.EventType, &e.Key, &e.Value); err != nil {

                outError <- fmt.Errorf("input parse error: %w", err)
                return
            }

            // Sanity check! Are the sequence numbers in increasing order?
            if l.lastSequence >= e.Sequence {
                outError <- fmt.Errorf("transaction numbers out of sequence")
                return
            }

            l.lastSequence = e.Sequence     // Update last used sequence #

            outEvent <- e                   // Send the event along
        }

        if err := scanner.Err(); err != nil {
            outError <- fmt.Errorf("transaction log read failure: %w", err)
            return
        }
    }()

    return outEvent, outError
}

ReadEvents 方法实际上可以说是两个功能合二为一:外部函数初始化文件读取器,并创建并返回事件和错误通道。内部函数并发运行,逐行摄取文件内容并将结果发送到通道。

有趣的是,TransactionLoggerfile 属性是 *os.File 类型,它具有满足 io.Reader 接口的 Read 方法。Read 是相当底层的,但是,如果你愿意,你实际上可以使用它来检索数据。然而,bufio 包给了我们一个更好的方法:Scanner 接口,它提供了一个方便的方式来读取以换行符分隔的文本行。我们可以通过将一个 io.Reader—在这种情况下是 os.File—传递给 bufio.NewScanner 来获得一个新的 Scanner 值。

每次调用 scanner.Scan 方法都会将其推进到下一行,如果没有更多行则返回 false。随后调用 scanner.Text 返回该行。

注意内部匿名 goroutine 中的 defer 语句。这些语句确保输出通道始终关闭。因为 defer 的作用域是它们声明的函数,所以它们在 goroutine 结束时被调用,而不是在 ReadEvents 中。

您可能还记得“在 Go 中进行 I/O 格式化”中提到的 fmt.Sscanf 函数提供了一种简单(但有时过于简单)的解析简单字符串的方法。与 fmt 包中的其他方法一样,预期的格式是使用包含各种“动词”的格式字符串指定的:两个数字(%d)和两个字符串(%s),由制表符(\t)分隔。方便的是,fmt.Sscanf 允许您传递指向每个动词目标值的指针,它可以直接更新。¹²

提示

Go 的格式化字符串有着悠久的历史,可以追溯到 C 的 printfscanf,但多年来已经被许多其他语言采纳,包括 C++、Java、Perl、PHP、Ruby 和 Scala。你可能已经对它们很熟悉了,但如果你还不了解,现在就看看 fmt 包的文档 吧。

每次循环结束时,最后使用的序列号将被更新为刚刚读取的值,并且事件将继续进行。一个小细节:注意每次迭代中重复使用相同的 Event 值,而不是创建新的值。这是因为 outEvent 通道发送的是结构体值,而不是指向结构体值的指针,因此它已经提供了我们发送到其中的任何值的副本。

最后,函数检查 Scanner 的错误。Scan 方法仅返回一个布尔值,这对于循环非常方便。当遇到错误时,Scan 返回 false 并通过 Err 方法暴露错误。

你的事务记录器接口(重定向)

现在你已经实现了一个完全功能的 FileTransactionLogger,是时候回顾一下,看看我们可以将哪些新方法用于集成到 TransactionLogger 接口中。实际上,有几个方法我们可能希望在任何实现中保留,这让我们得到了如下的 TransactionLogger 接口的最终形式:

type TransactionLogger interface {
    WriteDelete(key string)
    WritePut(key, value string)
    Err() <-chan error

    ReadEvents() (<-chan Event, <-chan error)

    Run()
}

现在一切都解决了,你终于可以开始将事务日志集成到你的键值服务中了。

在你的 Web 服务中初始化 FileTransactionLogger

FileTransactionLogger 现在已经完成!现在要做的就是将它集成到你的 Web 服务中。这一步的第一步是添加一个新的函数,可以创建一个新的 TransactionLogger 值,读取和重放任何现有事件,并调用 Run

首先,让我们在我们的 service.go 中添加一个 TransactionLogger 引用。你可以称之为 logger,因为命名很难:

var logger TransactionLogger

现在你已经处理了这个细节,你可以定义你的初始化方法了,看起来可能像下面这样:

func initializeTransactionLog() error {
    var err error

    logger, err = NewFileTransactionLogger("transaction.log")
    if err != nil {
        return fmt.Errorf("failed to create event logger: %w", err)
    }

    events, errors := logger.ReadEvents()
    e, ok := Event{}, true

    for ok && err == nil {
        select {
        case err, ok = <-errors:                // Retrieve any errors
        case e, ok = <-events:
            switch e.EventType {
            case EventDelete:                   // Got a DELETE event!
                err = Delete(e.Key)
            case EventPut:                      // Got a PUT event!
                err = Put(e.Key, e.Value)
            }
        }
    }

    logger.Run()

    return err
}

这个函数开始的方式正如你所预期的那样:它调用 NewFileTransactionLogger 并将其赋值给 logger

接下来更有趣:它调用 logger.ReadEvents,并根据从中接收到的 Event 值重放结果。这通过在 select 中循环使用 eventserrors 通道的情况来完成。注意 select 中的情况如何使用格式 case foo, ok = <-ch。通过这种方式读取通道返回的 bool 如果通道已关闭,ok 的值将为 false,设置 ok 的值并终止 for 循环。

如果我们从 events 通道获取了一个 Event 值,我们会适当地调用 DeletePut;如果我们从 errors 通道获取到了一个错误,err 将被设置为非 nil 值,并且 for 循环将被终止。

FileTransactionLogger 集成到你的 Web 服务中

现在初始化逻辑已经放在一起,完成TransactionLogger的集成只需在 Web 服务中添加三个函数调用。这相当简单,所以我们不会在这里详细介绍。简而言之,您需要添加以下内容:

  • initializeTransactionLog添加到main方法

  • logger.WriteDeletekeyValueDeleteHandler

  • logger.WritePutkeyValuePutHandler

我们将把实际的集成留给读者作为一个练习。¹³

未来的改进

我们可能已经完成了事务日志记录器的最小可行实现,但它仍然存在许多问题和改进的机会,例如:

  • 没有任何测试。

  • 没有Close方法以优雅地关闭文件。

  • 服务关闭时仍有事件在写缓冲区中:事件可能会丢失。

  • 事务日志中未对键和值进行编码:多行或空白将无法正确解析。

  • 键和值的大小是无限制的:可以添加巨大的键或值,填满磁盘。

  • 事务日志以纯文本形式写入:它将占用比其实际需求更多的磁盘空间。

  • 日志将永久保留删除值的记录:它将无限增长。

所有这些在生产中都将是障碍。我鼓励您花时间考虑或甚至实施解决这些问题的方案。

在外部数据库中存储状态

数据库和数据是许多商业和 Web 应用程序的核心,因此 Go 语言在其核心库中包含了一个标准的 SQL(或类 SQL)数据库接口是非常合理的 其核心库中

但是使用 SQL 数据库来支持我们的键值存储是否有意义呢?毕竟,依赖于另一个数据存储仅仅是多余的吗?是的,当然。但是将服务的数据外部化到专门设计用于此目的的另一个服务——数据库——是一种常见模式,允许状态在服务副本之间共享,并提供数据的弹性。此外,重点是展示您可能如何与数据库交互,而不是设计完美的应用程序。

在本节中,您将实现一个由外部数据库支持的事务日志,并满足TransactionLogger接口,就像您在“在事务日志文件中存储状态”中所做的那样。这肯定会起作用,并且正如前面提到的那样,甚至有一些好处,但它也有一些权衡之处:

优点:

外部化应用程序状态

减少对分布式状态的担忧,更接近“云原生”。

更容易扩展

不需要在副本之间共享数据使得扩展更容易(但并非容易)。

缺点:

引入一个瓶颈

如果您需要大规模扩展呢?如果所有副本都必须同时从数据库读取呢?

引入上游依赖

创建对可能失败的另一个资源的依赖。

需要初始化

如果Transactions表不存在怎么办?

增加了复杂性

另一个要管理和配置的事项。

在 Go 中操作数据库

数据库,特别是 SQL 和类 SQL 数据库,无处不在。你可以尝试避免它们,但如果你构建带有某种数据组件的应用程序,你最终将不得不与其交互。

幸运的是,Go 标准库的创建者提供了database/sql,它提供了一个习惯用法和轻量级的接口,用于处理 SQL(和类 SQL)数据库。在本节中,我们将简要演示如何使用此包,并指出一些需要注意的地方。

database/sql包中最普遍的成员之一是sql.DB:Go 的主要数据库抽象和创建语句、事务执行查询和获取结果的入口点。尽管它的名字可能暗示着它映射到数据库或模式的某个特定概念,但它确实为你做了很多事情,包括但不限于与数据库的连接协商和管理数据库连接池。

我们稍后会详细介绍如何创建你的sql.DB。但首先,我们必须讨论数据库驱动程序。

导入数据库驱动程序

虽然sql.DB类型为与 SQL 数据库交互提供了一个通用接口,但它依赖于数据库驱动程序来实现特定数据库类型的具体细节。在撰写本文时,Go 仓库中列出了 45 个驱动程序 链接

在接下来的部分中,我们将使用一个 Postgres 数据库,因此我们将使用第三方lib/pq Postgres 驱动实现

要加载数据库驱动程序,请通过将其包别名定位为_来匿名导入驱动程序包。这将触发包可能有的任何初始化器,并告知编译器你不打算直接使用它:

import (
    "database/sql"
    _ "github.com/lib/pq"       // Anonymously import the driver package
)

现在你已经完成了这一步,终于可以创建你的sql.DB值并访问数据库了。

实现你的 PostgresTransactionLogger

之前,我们介绍了TransactionLogger接口,它为通用事务日志定义了一个标准定义。你可能还记得它定义了用于启动记录器以及读取和写入日志事件的方法,如下所示:

type TransactionLogger interface {
    WriteDelete(key string)
    WritePut(key, value string)
    Err() <-chan error

    ReadEvents() (<-chan Event, <-chan error)

    Run()
}

我们的目标现在是创建一个支持数据库的TransactionLogger实现。幸运的是,我们已经为此做了很多工作。回顾一下“实现你的 FileTransactionLogger”,我们看起来可以使用非常类似的逻辑创建PostgresTransactionLogger

WritePutWriteDeleteErr方法开始,你可以像下面这样做:

type PostgresTransactionLogger struct {
    events       chan<- Event       // Write-only channel for sending events
    errors       <-chan error       // Read-only channel for receiving errors
    db           *sql.DB            // The database access interface
}

func (l *PostgresTransactionLogger) WritePut(key, value string) {
    l.events <- Event{EventType: EventPut, Key: key, Value: value}
}

func (l *PostgresTransactionLogger) WriteDelete(key string) {
    l.events <- Event{EventType: EventDelete, Key: key}
}

func (l *PostgresTransactionLogger) Err() <-chan error {
    return l.errors
}

如果你将其与FileTransactionLogger进行比较,可以清楚地看到代码几乎是相同的。我们实际上只做了以下更改:

  • 将类型显式更名为PostgresTransactionLogger

  • *os.File替换为*sql.DB

  • 删除lastSequence;可以让数据库处理排序。

创建一个新的 PostgresTransactionLogger

这都挺好的,但我们还没有讨论如何创建sql.DB。我知道你一定有这种感觉。悬念绝对折磨我,也折磨你。

就像我们在NewFileTransactionLogger函数中所做的那样,我们将为我们的PostgresTransactionLogger创建一个构造函数,我们将其称为(非常可预测的)NewPostgresTransactionLogger。然而,与NewFileTransactionLogger打开文件不同,它将建立与数据库的连接,如果失败则返回error

然而,有一点需要注意。即,建立与 Postgres 连接的设置需要大量的参数。在最基本的情况下,我们需要知道数据库所在的主机、数据库的名称,以及用户名和密码。处理这个问题的一种方法是创建如下所示的函数,它简单地接受一堆字符串参数:

func NewPostgresTransactionLogger(host, dbName, user, password string)
    (TransactionLogger, error) { ... }

然而,这种方法相当丑陋。另外,如果你需要额外的参数怎么办?你会将它追加到参数列表的末尾,这会打破已经使用此函数的任何代码吗?更糟糕的是,参数的顺序如果没有查看文档是不清楚的。

必须有更好的方法。因此,不要使用这种潜在的恐怖展示,你可以创建一个小的辅助结构体:

type PostgresDBParams struct {
    dbName   string
    host     string
    user     string
    password string
}

与大堆字符串方法不同,这个结构体很小,易读,并且可以轻松扩展。要使用它,你可以创建一个PostgresDBParams变量并将其传递给你的构造函数。看起来是这样的:

logger, err = NewPostgresTransactionLogger(PostgresDBParams{
    host:     "localhost",
    dbName:   "kvs",
    user:     "test",
    password: "hunter2"
})

新的构造函数看起来大致如下:

func NewPostgresTransactionLogger(config PostgresDBParams) (TransactionLogger,
    error) {

    connStr := fmt.Sprintf("host=%s dbname=%s user=%s password=%s",
        config.host, config.dbName, config.user, config.password)

    db, err := sql.Open("postgres", connStr)
    if err != nil {
        return nil, fmt.Errorf("failed to open db: %w", err)
    }

    err = db.Ping()                 // Test the database connection
    if err != nil {
        return nil, fmt.Errorf("failed to open db connection: %w", err)
    }

    logger := &PostgresTransactionLogger{db: db}

    exists, err := logger.verifyTableExists()
    if err != nil {
        return nil, fmt.Errorf("failed to verify table exists: %w", err)
    }
    if !exists {
        if err = logger.createTable(); err != nil {
            return nil, fmt.Errorf("failed to create table: %w", err)
        }
    }

    return logger, nil
}

这做了很多事情,但基本上与NewFileTransactionLogger并没有太大的不同。

它首先使用sql.Open来获取*sql.DB值。请注意,传递给sql.Open的连接字符串包含多个参数;lib/pq包支持的参数比这里列出的多得多。请参阅包文档获取完整列表。

许多驱动程序,包括lib/pq,实际上并不会立即创建到数据库的连接,因此它使用db.Ping来强制驱动程序建立和测试连接。

最后,它创建PostgresTransactionLogger并使用它来验证transactions表是否存在,如果需要则创建它。没有这一步,PostgresTransactionLogger将假设表已经存在,如果表不存在将会失败。

你可能已经注意到这里没有实现 verifyTableExistscreateTable 方法。这完全是有意为之。作为一项练习,建议你深入阅读 database/sql 文档,思考如何实现这些功能。如果你不愿意自己实现,可以在 GitHub 仓库 中找到一个实现。

现在你有了一个建立到数据库的连接并返回新创建的 TransactionLogger 的构造函数。但是,再次需要启动事务。为此,你需要实现 Run 方法,创建 eventserrors 通道,并生成事件摄取 goroutine。

使用 db.Exec 执行 SQL INSERT

对于 FileTransactionLogger,你实现了一个 Run 方法,初始化了通道并创建了负责写入事务日志的 goroutine。

PostgresTransactionLogger 非常类似。然而,与向文件追加一行不同,新日志记录器使用 db.Exec 执行 SQL INSERT 以实现相同的结果。

func (l *PostgresTransactionLogger) Run() {
    events := make(chan Event, 16)              // Make an events channel
    l.events = events

    errors := make(chan error, 1)               // Make an errors channel
    l.errors = errors

    go func() {                                 // The INSERT query
        query := `INSERT INTO transactions
 (event_type, key, value)
 VALUES ($1, $2, $3)`

        for e := range events {                 // Retrieve the next Event

            _, err := l.db.Exec(                // Execute the INSERT query
                query,
                e.EventType, e.Key, e.Value)

            if err != nil {
                errors <- err
            }
        }
    }()
}

Run 方法的实现几乎与其 FileTransactionLogger 相当:它创建了缓冲的 eventserrors 通道,并启动了一个 goroutine,从我们的 events 通道中检索 Event 值并将其写入事务日志。

与向文件追加不同,这个 goroutine 使用 db.Exec 执行 SQL 查询,向 transactions 表中添加一行。查询中的编号参数($1, $2, $3)是占位符查询参数,必须在调用 db.Exec 函数时满足。

使用 db.Query 回放 postgres 事务日志

在 “使用 bufio.Scanner 回放文件事务日志” 中,你使用 bufio.Scanner 读取以前编写的事务日志条目。

Postgres 实现可能不会像描述的那样直接,但其原理相同:你指向数据源的顶部并读取到底部。

func (l *PostgresTransactionLogger) ReadEvents() (<-chan Event, <-chan error) {
    outEvent := make(chan Event)                // An unbuffered events channel
    outError := make(chan error, 1)             // A buffered errors channel

    go func() {
        defer close(outEvent)                   // Close the channels when the
        defer close(outError)                   // goroutine ends

        query := `SELECT sequence, event_type, key, value FROM transactions
 ORDER BY sequence`

        rows, err := db.Query(query)            // Run query; get result set
        if err != nil {
            outError <- fmt.Errorf("sql query error: %w", err)
            return
        }

        defer rows.Close()                      // This is important!

        e := Event{}                            // Create an empty Event

        for rows.Next() {                       // Iterate over the rows

            err = rows.Scan(                    // Read the values from the
                &e.Sequence, &e.EventType,      // row into the Event.
                &e.Key, &e.Value)

            if err != nil {
                outError <- fmt.Errorf("error reading row: %w", err)
                return
            }

            outEvent <- e                       // Send e to the channel
        }

        err = rows.Err()
        if err != nil {
            outError <- fmt.Errorf("transaction log read failure: %w", err)
        }
    }()

    return outEvent, outError
}

所有有趣(或至少是新的)部分都发生在 goroutine 中。让我们来详细分析它们:

  • query 是一个包含 SQL 查询的字符串。此代码中的查询请求四列:sequenceevent_typekeyvalue

  • db.Query 发送 query 到数据库,并返回类型为 *sql.Rowserror 的值。

  • 我们推迟调用 rows.Close。未能这样做可能导致连接泄漏!

  • rows.Next 允许我们迭代行;如果没有更多行或出现错误,它将返回 false

  • rows.Scan 将当前行的列复制到调用中指定的值。

  • 我们将事件 e 发送到输出通道中。

  • Err 返回可能导致 rows.Next 返回 false 的错误(如果有的话)。

在你的 Web 服务中初始化 PostgresTransactionLogger

PostgresTransactionLogger几乎已经完成。现在让我们继续将其集成到 Web 服务中。

幸运的是,由于我们已经有了FileTransactionLogger,我们只需要改变一行代码:

logger, err = NewFileTransactionLogger("transaction.log")

这变成了…

logger, err = NewPostgresTransactionLogger("localhost")

是的。就是这样。真的。

因为这代表了TransactionLogger接口的完整实现,所以其他一切都保持不变。你可以像以前一样使用完全相同的方法与PostgresTransactionLogger交互。

未来的改进

FileTransactionLogger一样,PostgresTransactionLogger代表了事务记录器的最小可行实现,并且有很大的改进空间。一些改进的方向包括但不限于:

  • 我们假设数据库和表已存在,如果它们不存在,我们会收到错误。

  • 连接字符串是硬编码的。甚至包括密码。

  • 仍然没有Close方法来清理打开的连接。

  • 服务可能在仍然存在于写缓冲区中的事件的情况下关闭:事件可能会丢失。

  • 日志会永久保留已删除值的记录:它会无限增长。

所有这些都可能是生产中的(主要)障碍。我鼓励你花些时间考虑——甚至实施——解决这些问题的一个或多个方案。

第三代:实现传输层安全性

安全性。不管你喜欢与否,简单的事实是安全性是任何应用程序,无论是云原生还是其他类型的,都是至关重要的特性。不幸的是,安全性通常被视为事后处理,可能带来灾难性的后果。

传统环境中有丰富的工具和成熟的安全最佳实践,但对于云原生应用程序来说,情况要逊色一些。这些应用程序往往采用几个小型、通常是短暂的微服务形式。虽然这种架构提供了显著的灵活性和可扩展性优势,但也为潜在攻击者创造了明显的机会:每个服务之间的所有通信都通过网络传输,容易被窃听和篡改。

安全性的讨论可以占用一整本书的篇幅¹⁴,因此我们将专注于一种常见的技术:加密。对“传输中”的数据进行加密通常用于防止窃听和消息篡改,任何像 Go 这样的成熟语言都会使其相对容易实现。

传输层安全性

传输层安全性(TLS)是一种加密协议,旨在提供计算机网络上的通信安全性。它的使用广泛且普遍,适用于几乎所有的互联网通信。你很可能已经熟悉它(甚至正在使用它),它以 HTTPS 的形式广为人知——也称为 HTTP over TLS——它使用 TLS 来加密 HTTP 上的交换。

TLS 使用 公钥密码学 加密消息,其中双方各自拥有自己的 密钥对,包括一个可以公开的 公钥 和仅由所有者知晓的 私钥,如 Figure 5-2 所示。任何人都可以使用公钥加密消息,但只能用相应的私钥解密。利用这种协议,希望私密通信的两方可以交换它们的公钥,然后可以使用这些密钥来加密所有后续通信,这样只有拥有对应私钥的预期接收者的所有者才能阅读。

cngo 0502

图 5-2. 公钥交换的一半

证书、证书颁发机构和信任

如果 TLS 有座右铭,那就是“信任但要验证”。实际上,去掉信任这部分。验证一切。

对一个服务来说,仅提供公钥是不够的。¹⁶ 相反,每个公钥都关联有一个 数字证书,这是一种用来证明密钥所有权的电子文档。证书表明公钥的所有者确实是所命名的主体(所有者),并描述了密钥的使用方式。这使得接收者可以将证书与各种“信任”进行比较,以决定是否接受其为有效。

首先,证书必须由 证书颁发机构 进行数字签名和验证,这是一个受信任的实体,用于发布数字证书。

第二,证书的主体必须与客户端尝试连接到的服务的域名匹配。除其他事项外,这有助于确保您接收的证书是有效的,且没有被中间人替换。

只有这样,您的对话才会继续。

警告

Web 浏览器或其他工具通常会允许您选择是否继续,如果证书无法验证。例如,对于开发时使用自签名证书,这可能是有道理的。但一般来说,请注意警告。

私钥和证书文件

TLS(及其前身 SSL)已经存在了足够长的时间¹⁷,你可能会认为我们已经确定了一个单一的密钥容器格式,但你错了。搜索“密钥文件格式”将返回一大堆文件扩展名:.csr.key.pkcs12.der.pem 等等。

然而,在这些文件中,.pem 似乎是最常见的。它也恰好是 Go 的 net/http 包最容易支持的格式,因此我们将使用它。

隐私增强邮件(PEM)文件格式

隐私增强邮件(PEM)是一种常见的证书容器格式,通常存储在 .pem 文件中,但 .cer.crt(用于证书)和 .key(用于公钥或私钥)也很常见。方便的是,PEM 也是 base64 编码的,因此可以在文本编辑器中查看,甚至可以安全地粘贴到(例如)电子邮件消息的正文中。¹⁸

.pem 文件通常是成对出现的,代表一个完整的密钥对:

cert.pem

服务器证书(包括由 CA 签名的公钥)。

key.pem

一把不共享的私钥。

未来,我们假设您的密钥配置如此。如果您还没有任何密钥并且需要为开发目的生成一些密钥,则可以在多个在线位置找到说明。如果您已经有其他格式的密钥文件,那么转换它超出了本书的范围。然而,互联网是一个神奇的地方,有很多在线教程可以在常见的密钥格式之间进行转换。

通过 HTTPS 保护您的 Web 服务

所以,既然我们已经确认安全性应该受到严肃对待,并且通过 TLS 进行通信是保护通信的最低起步步骤,我们应该如何做到这一点呢?

一种方法可能是在我们的服务前面放置一个反向代理,该代理可以处理 HTTPS 请求并将它们作为 HTTP 转发到我们的键值服务,但除非两者位于同一服务器上,否则我们仍然会通过网络发送未加密的消息。此外,额外的服务会增加一些我们可能宁愿避免的架构复杂性。也许我们可以让我们的键值服务提供 HTTPS?

实际上,我们可以。回顾一下“使用 net/http 构建 HTTP 服务器”,你可能会记得net/http包中包含一个名为ListenAndServe的函数,在其最基本的形式中,看起来像下面这样:

func main() {
    http.HandleFunc("/", helloGoHandler)            // Add a root path handler

    http.ListenAndServe(":8080", nil)               // Start the HTTP server
}

在这个例子中,我们调用HandleFunc来为根路径添加一个处理函数,然后调用ListenAndServe来启动服务监听和服务。为了简单起见,我们忽略了ListenAndServe返回的任何错误。

这里没有太多的移动部分,这种设计有点好。为了贯彻这一理念,net/http的设计者们友好地提供了ListenAndServe函数的 TLS 启用变体,这是我们熟悉的:

func ListenAndServeTLS(addr, certFile, keyFile string, handler Handler) error

正如您所见,ListenAndServeTLS看起来和感觉几乎与ListenAndServe完全相同,只是它有两个额外的参数:certFilekeyFile。如果您碰巧有证书和私钥 PEM 文件,那么通过ListenAndServeTLS服务 HTTPS 加密连接只是将这些文件的名称传递给它的问题:

http.ListenAndServeTLS(":8080", "cert.pem", "key.pem", nil)

这看起来确实非常方便,但它有效吗?让我们启动我们的服务(使用自签名证书)并找出答案。

请打开我们的老朋友curl,让我们尝试插入一个键/值对。请注意,我们在 URL 中使用的是https方案,而不是http

$ curl -X PUT -d 'Hello, key-value store!' -v https://localhost:8080/v1/key-a
* SSL certificate problem: self signed certificate
curl: (60) SSL certificate problem: self signed certificate

哎呀,事情没有按计划进行。正如我们在“证书、证书颁发机构和信任”中提到的,TLS 希望任何证书都由证书颁发机构签名。它不喜欢自签名的证书。

幸运的是,我们可以在curl中通过名为--insecure的标志将此安全检查关闭:

$ curl -X PUT -d 'Hello, key-value store!' --insecure -v \
    https://localhost:8080/v1/key-a
* SSL certificate verify result: self signed certificate (18), continuing anyway.
> PUT /v1/key-a HTTP/2
< HTTP/2 201

我们收到了一封严厉的警告,但是它起作用了!

传输层摘要

我们在短短几页中涵盖了相当多的内容。安全性的主题是广泛的,我们不可能全面覆盖,但至少我们介绍了 TLS,以及它如何作为一个相对低成本、高回报的安全策略的组成部分。

我们还演示了如何在 Go 的net/http Web 服务中实现 TLS,并且看到只要有有效的证书,就可以轻松地保护服务的通信。

将您的键值存储容器化

容器是一种轻量级操作系统级虚拟化¹⁹抽象,为进程提供了一定程度的隔离,既与宿主机隔离,又与其他容器隔离。容器的概念至少可以追溯到 2000 年,但是在 2013 年引入 Docker 后,容器才变得普及,并将容器化带入了主流。

重要的是,容器不是虚拟机:²⁰它们不使用虚拟化程序,而是共享主机的内核,而不是携带自己的客户操作系统。它们的隔离是通过巧妙地应用几个 Linux 内核功能实现的,包括chroot、cgroups 和内核命名空间。事实上,可以合理地认为容器只是一个方便的抽象概念,并且实际上并不存在所谓的“容器”。

尽管它们不是虚拟机,²¹容器确实提供了一些类似虚拟机的好处。其中最明显的是,它们允许将应用程序、其依赖项以及大部分环境打包到一个单一可分发的构建物——容器镜像中,并且可以在任何适当的主机上执行。

然而,好处并不止于此。如果需要,这里还有一些额外的好处:

灵活性

与携带整个操作系统和巨大内存占用的虚拟机不同,容器的镜像大小在兆字节范围内,并且启动时间以毫秒计算。对于 Go 应用程序尤其如此,其二进制文件几乎没有依赖关系。

隔离

这早已暗示过,但需要重复一下。容器在操作系统级别虚拟化 CPU、内存、存储和网络资源,为开发人员提供了与其他应用逻辑上隔离的 OS 的沙盒视图。

标准化和生产力

容器让您将应用程序及其依赖项(如特定版本的语言运行时和库)打包为单个可分发的二进制文件,使得您的部署可重现、可预测和可版本化。

编排

像 Kubernetes 这样复杂的容器编排系统提供了大量的好处。通过将您的应用程序容器化,您迈出了利用这些好处的第一步。

这里只有四个(非常)激励人心的论据。²² 换句话说,容器化非常、非常有用。

对于本书,我们将使用 Docker 来构建我们的容器镜像。虽然存在替代的构建工具,但 Docker 是今天最常用的容器化工具,其构建文件的语法称为 Dockerfile,允许您使用熟悉的 shell 脚本命令和实用程序。

话虽如此,这不是一本关于 Docker 或容器化的书籍,因此我们的讨论将主要限于使用 Go 与 Docker 的基础知识。如果您有兴趣了解更多信息,我建议阅读 Docker: Up & Running: Shipping Reliable Containers in Production,作者是 Sean P. Kane 和 Karl Matthias(O’Reilly)。

Docker(绝对)基础知识

在继续之前,重要的是要区分容器镜像和容器本身。容器镜像 本质上是一个可执行二进制文件,其中包含您的应用程序运行时及其依赖项。当运行镜像时,生成的进程即为 容器。可以多次运行镜像以创建多个(基本上)相同的容器。

在接下来的几页中,我们将创建一个简单的 Dockerfile,并构建和执行一个镜像。如果您还没有,请花点时间并 安装 Docker 社区版 (CE)

Dockerfile

Dockerfile 本质上是构建文件,描述构建镜像所需的步骤。下面是一个非常简单但完整的示例:

# The parent image. At build time, this image will be pulled and
# subsequent instructions run against it.
FROM ubuntu:20.04

# Update apt cache and install nginx without an approval prompt.
RUN apt-get update && apt-get install --yes nginx

# Tell Docker this image's containers will use port 80.
EXPOSE 80

# Run Nginx in the foreground. This is important: without a
# foreground process the container will automatically stop.
CMD ["nginx", "-g", "daemon off;"]

正如您所见,这个 Dockerfile 包括四个不同的命令:

FROM

指定此构建将扩展的 基础镜像,通常是常见的 Linux 发行版,如 ubuntualpine。在构建时,将拉取并运行此镜像,并应用后续命令。

RUN

将在当前镜像的基础上执行任何命令。结果将用于 Dockerfile 中的下一步。

EXPOSE

告诉 Docker 容器将使用哪个端口(或端口)。有关暴露端口和发布端口的更多信息,请参见“暴露和发布端口之间有什么区别?”。

CMD

在容器执行时要执行的命令。Dockerfile 中只能有一个 CMD

这些是众多可用的 Dockerfile 指令中最常见的四个。完整列表请参阅 官方 Dockerfile 参考

正如您可能推测的那样,前面的示例以现有的 Linux 发行版镜像(Ubuntu 20.04)为基础,并安装了 Nginx,在启动容器时执行。

按照惯例,Dockerfile 的文件名是Dockerfile。继续创建一个名为Dockerfile的新文件,并将前面的示例粘贴到其中。

构建您的容器镜像

现在您有了一个简单的 Dockerfile,可以构建它!确保您在与 Dockerfile 相同的目录中,并输入以下内容:

$ docker build --tag my-nginx .

这将指示 Docker 开始构建过程。如果一切正常(为什么不会呢?),您将看到 Docker 下载父镜像并运行apt命令的输出。第一次运行可能需要一两分钟。

最后,您将看到类似以下内容的行:Successfully tagged my-nginx:latest

如果是这样,您可以使用docker images命令验证您的镜像现在是否存在。您应该会看到类似以下内容:

$ docker images
REPOSITORY      TAG         IMAGE ID           CREATED               SIZE
my-nginx        latest      64ea3e21a388       29 seconds ago        159MB
ubuntu          20.04       f63181f19b2f       3 weeks ago           72.9MB

如果一切按计划进行,您将看到至少列出了两个镜像:我们的父镜像ubuntu:20.04和您自己的my-nginx:latest镜像。下一步:运行服务容器!

运行您的容器镜像

现在您已经构建了您的镜像,可以运行它。为此,您将使用docker run命令:

$ docker run --detach --publish 8080:80 --name nginx my-nginx
61bb4d01017236f6261ede5749b421e4f65d43cb67e8e7aa8439dc0f06afe0f3

这将指示 Docker 使用您的my-nginx镜像运行一个容器。--detach标志将导致容器在后台运行。使用--publish 8080:80指示 Docker 将主机的 8080 端口发布到容器的 80 端口,因此任何连接到localhost:8080将被转发到容器的 80 端口。最后,--name nginx标志为容器指定一个名称;如果没有这个标志,将分配一个随机生成的名称。

运行此命令后,您会看到一行非常晦涩的内容,其中包含了 65 个十六进制字符,这就是容器 ID,可以用来代替其名称引用该容器。

运行您的容器镜像

要验证您的容器是否正在运行并且正在按预期执行操作,您可以使用docker ps命令列出所有正在运行的容器。这应该类似以下内容:

$ docker ps
CONTAINER ID    IMAGE       STATUS          PORTS                   NAMES
4cce9201f484    my-nginx    Up 4 minutes    0.0.0.0:8080->80/tcp    nginx

前面的输出已经为简洁起见进行了编辑(您可能注意到缺少了COMMANDCREATED列)。您的输出应包括七列:

CONTAINER ID

容器 ID 的前 12 个字符。您会注意到它与您的docker run的输出匹配。

IMAGE

此容器源镜像的名称(及标签,如果指定)。没有标签意味着latest

COMMAND(未显示)

容器内运行的命令。除非在docker run中被覆盖,否则这将与 Dockerfile 中的CMD指令相同。在我们的例子中,这将是nginx -g 'daemon off;'

CREATED(未显示)

容器创建的时间是多久之前。

STATUS

容器的当前状态(upexitedrestarting 等)及其停留在该状态的时间。如果状态发生变化,则时间将与 CREATED 不同。

PORTS

列出所有已暴露和已发布的端口(参见 “暴露和发布端口之间的区别是什么?”)。在我们的情况下,我们已在主机上发布了 0.0.0.0:8080 并将其映射到容器的 80 端口,因此所有对主机端口 8080 的请求都会转发到容器端口 80。

NAMES

容器的名称。如果未显式定义,Docker 将随机设置此名称。无论状态如何,不能在同一主机上同时存在两个具有相同名称的容器。要重用名称,您首先必须删除不需要的容器。

向发布的容器端口发出请求

如果您已经完成到这一步,则 docker ps 输出应显示一个名为 nginx 的容器,看起来已经发布了端口 8080 并将其转发到容器的端口 80。如果是这样,那么您现在可以向正在运行的容器发送请求。但是应该查询哪个端口呢?

好吧,Nginx 容器监听在端口 80 上。你能访问吗?实际上不能。因为在 docker run 期间它没有发布到任何网络接口上。任何尝试连接到未发布的容器端口都注定失败:

$ curl localhost:80
curl: (7) Failed to connect to localhost port 80: Connection refused

您没有发布到端口 80,但是您已经发布了端口 8080 并将其转发到容器的端口 80。您可以使用我们的老朋友 curl 或通过浏览 localhost:8080 来验证这一点。如果一切正常,您将看到熟悉的 Nginx “欢迎”页面,如图 5-3 所示。

cngo 0503

图 5-3. 欢迎来到 nginx!

运行多个容器

容器化的一个“杀手级特性”之一是:因为主机上的所有容器彼此隔离,所以可以在同一主机上运行大量容器,甚至包含不同技术和堆栈的容器,每个容器监听不同的已发布端口。例如,如果您想在已经运行的 my-nginx 容器旁边运行一个 httpd 容器,您完全可以做到这一点。

“但是”,你可能会说,“这两个容器都暴露了端口 80!它们不会冲突吗?”

很好的问题,答案是,幸运的是,不会。事实上,您可以拥有任意数量的容器暴露相同的端口,甚至同一镜像的多个实例,只要它们不尝试发布相同的端口到同一网络接口上。

例如,如果您想运行标准的 httpd 镜像,您可以再次使用 docker run 命令运行它,只要确保发布到不同的端口(在这种情况下是 8081):

$ docker run --detach --publish 8081:80 --name httpd httpd

如果一切顺利,这将在主机上生成一个监听端口 8081 的新容器。继续吧:使用 docker pscurl 进行测试:

$ curl localhost:8081
<html><body><h1>It works!</h1></body></html>

停止和删除您的容器

现在你已经成功运行了你的容器,如果你想再次使用相同名称运行新容器,你可能需要在某个时候停止并删除它。

要停止正在运行的容器,你可以使用 docker stop 命令,传递容器名称或容器 ID 的前几个字符(多少个字符无所谓,只要能唯一标识所需的容器)。使用容器 ID 停止我们的 nginx 容器看起来像这样:

$ docker stop 4cce      # "docker stop nginx" will work too
4cce

成功运行 docker stop 的输出只是我们传递给命令的名称或 ID。你可以使用 docker ps --all 来验证你的容器是否真正停止了,该命令会显示所有容器,而不仅仅是运行中的:

$ docker ps
CONTAINER ID    IMAGE       STATUS                      PORTS    NAMES
4cce9201f484    my-nginx    Exited (0) 3 minutes ago             nginx

如果你运行了 httpd 容器,它的状态也会显示为 Up。你可能也想停止它。

正如你所看到的,我们的 nginx 容器的状态已经变成 Exited,随后是它的退出代码——退出状态为 0 表示我们能够执行优雅关闭,并显示容器进入当前状态的时间。

现在你已经停止了容器,你可以自由地删除它。

提示

无法删除正在运行的容器或正在运行的容器使用的镜像。

为此,你可以使用 docker rm 命令(或更新的 docker container rm 命令)来删除你的容器,再次传递容器名称或容器 ID 的前几个字符:

$ docker rm 4cce            # "docker rm nginx" will work too
4cce

如前所述,输出的名称或 ID 表示成功。如果你继续运行 docker ps --all,你不应该再看到列出的容器了。

构建你的键值存储容器

现在你已经掌握了基础知识,可以开始将它们应用于容器化我们的键值服务。

幸运的是,Go 编译成静态链接二进制文件的能力使其特别适合容器化。而大多数其他语言必须构建到包含语言运行时的父镜像中,比如 Java 的 486MB 的 openjdk:15 或 Python 的 885MB 的 python:3.9[²³],Go 二进制文件根本不需要运行时。它们可以放入一个“scratch”镜像:一个完全没有父级的镜像。

迭代 1:将你的二进制文件添加到一个 FROM scratch 镜像中

为此,你需要一个 Dockerfile。下面的示例是一个典型的用于容器化 Go 二进制文件的 Dockerfile 示例:

# We use a "scratch" image, which contains no distribution files. The
# resulting image and containers will have only the service binary.
FROM scratch

# Copy the existing binary from the host.
COPY kvs .

# Copy in your PEM files.
COPY *.pem .

# Tell Docker we'll be using port 8080.
EXPOSE 8080

# Tell Docker to execute this command on a `docker run`.
CMD ["/kvs"]

这个 Dockerfile 与之前的相似,只是不再使用 apt 从仓库安装应用程序,而是使用 COPY 从正在构建的文件系统中检索编译好的二进制文件。在这种情况下,它假设存在一个名为 kvs 的二进制文件。为了让它工作,我们首先需要构建这个二进制文件。

为了使你的二进制文件能够在容器内可用,它必须满足几个标准:

  • 它必须为 Linux 编译(或交叉编译)。

  • 它必须是静态链接的。

  • 它必须命名为kvs(因为 Dockerfile 期望如此)。

我们可以使用以下命令完成所有这些事情:

$ CGO_ENABLED=0 GOOS=linux go build -a -o kvs

让我们来看看这些命令都做了什么:

  • CGO_ENABLED=0告诉编译器禁用cgo并静态链接任何 C 绑定。我们不会深入讨论这是什么,除了它强制静态链接,但我鼓励您查看cgo 文档以了解更多。

  • GOOS=linux指示编译器生成 Linux 二进制文件,必要时进行交叉编译。

  • -a强制编译器重新构建任何已经是最新版本的包。

  • -o kvs指定二进制文件将命名为kvs

执行该命令应该生成一个静态链接的 Linux 二进制文件。这可以通过使用file命令来验证:

$ file kvs
kvs: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked,
not stripped
注意

Linux 二进制文件将在 Linux 容器中运行,即使在运行在 Docker 的 MacOS 或 Windows 中的容器中,但在 MacOS 或 Windows 上将不会运行。

很棒!现在让我们构建容器镜像,看看结果如何:

$ docker build --tag kvs .
...output omitted.

$ docker images
REPOSITORY     TAG        IMAGE ID           CREATED               SIZE
kvs            latest     7b1fb6fa93e3       About a minute ago    6.88MB
node           15         ebcfbb59a4bd      7 days ago             936MB
python         3.9        2a93c239d591      8 days ago             885MB
openjdk        15         7666c92f41b0      2 weeks ago            486MB

小于 7MB!这大约比其他语言运行时的相对庞大的镜像小两个数量级。当您在大规模操作并且每天需要将镜像拉到数百个节点时,这会非常方便。

但它能运行吗?让我们看看:

$ docker run --detach --publish 8080:8080 kvs
4a05617539125f7f28357d3310759c2ef388f456b07ea0763350a78da661afd3

$ curl -X PUT -d 'Hello, key-value store!' -v http://localhost:8080/v1/key-a
> PUT /v1/key-a HTTP/1.1
< HTTP/1.1 201 Created

$ curl http://localhost:8080/v1/key-a
Hello, key-value store!

看起来好像运行成功了!

现在您有了一个漂亮简单的 Dockerfile,它使用预编译的二进制文件构建镜像。不幸的是,这意味着您必须确保每次 Docker 构建时重新生成二进制文件(或者您的 CI 系统)。这并不是糟糕,但这意味着您需要在构建工作机器上安装 Go。再次强调,这并不是太糟糕,但我们当然可以做得更好。

迭代 2:使用多阶段构建

在上一节中,您创建了一个简单的 Dockerfile,该文件将采用现有的 Linux 二进制文件并将其封装到基本的“scratch”镜像中。但如果您可以在 Docker 中执行整个镜像构建,包括 Go 编译呢?

一种方法可能是将golang镜像作为我们的父镜像。如果这样做,您的 Dockerfile 可以在部署时编译您的 Go 代码并运行生成的二进制文件。这可以在没有安装 Go 编译器的主机上构建,但生成的镜像将带有额外的 862MB(golang:1.16镜像的大小),完全不必要的构建机制。

另一种方法可能是使用两个 Dockerfile:一个用于构建二进制文件,另一个用于容器化第一个构建的输出。这更接近您希望的方式,但需要两个不同的 Dockerfile,需要按顺序构建或由单独的脚本管理。

引入多阶段 Docker 构建后,有了更好的方法,允许将多个不同的构建——甚至是完全不同的基础镜像——链接在一起,以便将一个阶段的工件选择性地复制到另一个阶段,留下你不希望在最终镜像中留下的所有东西。要使用这种方法,你定义一个具有两个阶段的构建:一个“构建”阶段生成 Go 二进制文件,一个“镜像”阶段使用该二进制文件生成最终镜像。

要做到这一点,你在我们的 Dockerfile 中使用多个FROM语句,每个定义一个新阶段的开始。每个阶段可以任意命名。例如,你可以将你的构建阶段命名为build,如下所示:

FROM golang:1.16 as build

一旦你有了带有名称的阶段,你可以在你的 Dockerfile 中使用COPY指令将任何工件从任何前期阶段复制到当前工作目录。你的最终阶段可能有如下指令,它将文件/src/kvsbuild阶段复制到当前工作目录:

COPY --from=build /src/kvs .

将这些东西放在一起可以得到一个完整的、两阶段的 Dockerfile:

# Stage 1: Compile the binary in a containerized Golang environment
#
FROM golang:1.16 as build

# Copy the source files from the host
COPY . /src

# Set the working directory to the same place we copied the code
WORKDIR /src

# Build the binary!
RUN CGO_ENABLED=0 GOOS=linux go build -o kvs

# Stage 2: Build the Key-Value Store image proper
#
# Use a "scratch" image, which contains no distribution files
FROM scratch

# Copy the binary from the build container
COPY --from=build /src/kvs .

# If you're using TLS, copy the .pem files too
COPY --from=build /src/*.pem .

# Tell Docker we'll be using port 8080
EXPOSE 8080

# Tell Docker to execute this command on a "docker run"
CMD ["/kvs"]

现在你有了完整的 Dockerfile,你可以以与之前完全相同的方式构建它。这次我们将它标记为multipart,这样你就可以比较这两个镜像:

$ docker build --tag kvs:multipart .
...output omitted.

$ docker images
REPOSITORY     TAG           IMAGE ID           CREATED               SIZE
kvs            latest        7b1fb6fa93e3       2 hours ago           6.88MB
kvs            multipart     b83b9e479ae7       4 minutes ago         6.56MB

现在,你已经有一个单独的 Dockerfile,可以编译你的 Go 代码——无论构建工作机器上是否安装了 Go 编译器——并将生成的静态链接可执行二进制文件放入一个FROM scratch基础镜像中,以生成一个非常非常小的镜像,其中仅包含你的键值存储服务。

但你不必止步于此。如果你愿意,你还可以添加其他阶段,比如在构建步骤之前运行任何单元测试的test阶段。不过,我们现在不会进行这项练习,因为这更多的是相同的事情,但我鼓励你自己尝试。

外部化容器数据

容器被设计为短暂的,任何容器都应该被设计和运行,以便理解它可以(而且将)随时被销毁并重新创建,带走所有的数据。清楚地说,这是一个特性,并且是非常有意义的,但有时你可能希望你的数据能比你的容器更长久存活。

例如,将外部管理的文件直接挂载到否则通用容器的文件系统中的能力可以解耦配置与镜像,这样你就不必在只需更改设置时就重新构建它们。这是一个非常强大的策略,可能是容器数据外部化的最常见用例。事实上,Kubernetes 甚至提供了一个资源类型—ConfigMap—专门用于此目的。

同样地,您可能希望在容器生成的数据超越容器生命周期。例如,在主机上存储数据可以成为加速缓存的优秀策略。然而,需要记住云原生基础设施的一个现实:没有什么是永久的,甚至服务器也不例外。不要在主机上存储您不希望永久丢失的任何内容。

幸运的是,“纯”Docker 将数据限制在直接外部化到本地磁盘,²⁴ 但像 Kubernetes 这样的容器编排系统提供了各种抽象,允许数据在主机丢失后幸存。

不幸的是,这本应该是关于 Go 的书籍,所以我们无法在这里详细讨论 Kubernetes。但如果您还没有,我强烈建议您仔细阅读优秀的 Kubernetes 文档,以及同样优秀的Kubernetes: Up and Running ,作者是布伦登·伯恩斯、乔·贝达和凯尔西·海塔华(O’Reilly)。

总结

这是一章很长的内容,我们涉及了许多不同的主题。考虑一下我们所取得的成就!

  • 从第一原则出发,我们设计并实现了一个简单的单体键值存储,使用net/httpgorilla/mux构建了围绕由一个小型、独立且易于测试的 Go 库提供的功能的 RESTful 服务。

  • 我们利用了 Go 语言强大的接口能力,实现了两种完全不同的事务记录器实现,一种基于本地文件并使用os.Filefmtbufio包;另一种由 Postgres 数据库支持,使用了database/sqlgithub.com/lib/pq的 Postgres 驱动包。

  • 我们讨论了安全性的重要性,涵盖了 TLS 的基础知识作为更大安全策略的一部分,并在我们的服务中实施了 HTTPS。

  • 最后,我们涵盖了容器化技术,这是核心的云原生技术之一,包括如何构建镜像以及如何运行和管理容器。我们甚至将我们的应用程序及其构建过程都容器化了。

未来,当我们引入新概念时,我们将以各种方式扩展我们的键值服务,所以请继续关注。事情将变得更加有趣。

¹ 施比尔,菲利普。“格蕾丝·霍珀的智慧和机智。” OCLC Newsletter,1987 年 3 月/4 月,第 167 期。

² 某种“爱”的定义。

³ 如果发生这种情况,说明有很大问题。

⁴ 或者,就像我的儿子一样,只是假装没有听见你。

⁵ “云原生不是微服务的同义词... 如果云原生必须是任何东西的同义词,那将是幂等,这明确需要一个同义词。” —霍利·卡明斯(Cloud Native London 2018)。

⁶ 这不令人兴奋吗?

⁷ 这也是好事。互斥锁的正确实现可能相当乏味!

⁸ 我没告诉过你我们会把它搞得更复杂吗?

⁹ 那是谎言。可能还有很多更好的名字。

¹⁰ 到底什么才是一个“好”的事务日志呢?

¹¹ 取名字真难。

¹² 经过这么长时间,我仍然认为这非常棒。

¹³ 不客气。

¹⁴ 最好由比我更了解安全的人编写。

¹⁵ 这是极端简化,但对我们的目的足够了。我鼓励你更深入了解并纠正我。

¹⁶ 你不知道那把钥匙去过哪些地方。

¹⁷ SSL 2.0 发布于 1995 年,TLS 1.0 发布于 1999 年。有趣的是,SSL 1.0 存在一些相当严重的安全漏洞,从未公开发布过。

¹⁸ 请仅使用公钥。

¹⁹ 容器不是虚拟机。它们虚拟化操作系统而不是硬件。

²⁰ 重复有意为之。这是一个重要的观点。

²¹ 是的。我说过。再说一遍。

²² 初稿中还有几个,但这一章已经相当冗长。

²³ 公平地说,这些镜像压缩后分别为“仅”240MB 和 337MB。

²⁴ 我故意忽略了像亚马逊的弹性块存储这样的解决方案,它们确实有帮助,但也有自己的问题。

第三部分:The Cloud Native Attributes

第六章:一切都关乎可靠性

一个程序最重要的特性是它是否实现了用户的意图。¹

C.A.R. 霍尔,《ACM 通讯》(1969 年 10 月)

查尔斯·安东尼·理查德(托尼)·霍尔教授是一个非常杰出的人。他发明了快速排序,创立了用于推理计算机程序正确性的霍尔逻辑,并创建了启发 Go 语言中喜爱的并发模型的形式化语言“通信顺序进程”(CSP)。哦,他还发明了空引用。尽管如此,请不要因此而抱怨他。他在 2009 年公开道歉³,称这是他的“十亿美元错误”。

托尼·霍尔(Tony Hoare)可以说是我们所知的编程之父。因此,当他说一个程序最重要的特性是它是否实现了用户的意图时,你可以信以为真。想想这个:霍尔明确地(而且完全正确地)指出,一个程序是否正确执行,取决于程序的用户的意图,而不是程序的创建者的意图。多么不方便啊,程序的用户的意图并不总是和其创建者的意图一致!

鉴于这一断言,一个用户对程序的第一个期望是“程序工作”。但程序何时才算“工作”呢?这实际上是一个相当大的问题,是云原生设计的核心所在。本章的第一个目标是探讨这个观念,并在过程中引入“可靠性”和“可信赖性”等概念,以更好地描述(并满足)用户的期望。最后,我们将简要回顾云原生开发中常用的一些实践,确保服务达到用户的期望。我们将在本书的其余部分深入讨论每一个实践。

云原生的意义何在?

在第一章中,我们花了几页的篇幅来定义“云原生”,从云原生计算基金会的定义开始,逐步深入讨论理想的云原生服务的特性。我们还花了几页时间讨论推动云原生成为一种事物的压力。

然而,我们没有花太多时间去讨论云原生的“为什么”。为什么会有云原生的概念?为什么我们希望系统成为云原生?它的目的是什么?它有什么特别之处?我为什么要关心它?

那么,为什么云原生存在?答案其实非常简单:一切都是为了可靠性。在本章的第一部分,我们将深入探讨可靠性的概念,它是什么,为什么如此重要,以及它如何贯穿我们称之为云原生的所有模式和技术。

一切关乎可靠性

IBM Garage 全球开发社区实践主管 Holly Cummins 曾经说过,“如果云原生必须成为任何东西的同义词,那它就应该是幂等性。”⁴ Cummins 非常聪明,她说了很多非常聪明的话,⁵ 但我认为她在这一点上只掌握了一半的真相。我认为幂等性非常重要——或许甚至是云原生的必要条件,但不足够。我会详细阐述。

软件的历史,特别是基于网络的软件,一直在努力满足日益复杂用户的期望。已经不再是那些服务可以在晚上“进行维护”的日子了。今天的用户对他们使用的服务依赖很重,并且期望这些服务可用并能迅速响应他们的请求。还记得你上次试图启动 Netflix 电影,花了你生命中最长的五秒钟吗?是的,就是那样。

用户并不关心你的服务需要维护。在你寻找那个神秘的延迟源时,他们不会耐心等待。他们只是想看完《绝命毒师》第二季。⁶

我们所关联的云原生的所有模式和技术——每一个——都存在的目的是允许服务在不可靠的环境中规模化部署、运行和维护,这是为了提供可靠的服务,让用户满意。

换句话说,如果“云原生”必须成为任何东西的同义词,那么它就是“可靠性”。

可靠性是什么,为什么这么重要?

我并非随意选择“可靠性”这个词。在系统工程领域中,这实际上是一个核心概念,这个领域充满了一些非常聪明的人,他们对复杂系统的设计和管理有很多聪明见解。在计算机环境中,可靠性的概念大约 35 年前由 Jean-Claude Laprie 严格定义过,⁷ 他根据用户的期望来定义系统的可靠性。多年来,Laprie 的原始定义已被各种作者调整和扩展,但这是我最喜欢的一种:

计算机系统的可靠性指的是其能够避免比用户可接受的更频繁或更严重的故障,以及停机时间比用户认可的更长的情况。⁸

计算机系统可靠性的基本概念(2001)

换句话说,一个可靠的系统始终如用户所期望地执行,并且在出现问题时能够迅速修复。

根据这个定义,系统只有在能够合理地被信任时才是可靠的。显然,如果系统的任何组件发生故障就无法被视为可靠,或者如果系统需要数小时才能从故障中恢复,也不能被视为可靠。即使系统连续运行数月而没有中断,一个不可靠的系统仍然可能在一个坏日子之前濒临灾难:侥幸并不可靠。

不幸的是,客观衡量“用户期望”是很困难的。因此,如图 6-1 所示,可靠性是一个包含多个更具体和可量化属性(可用性、可靠性和可维护性)的总体概念,所有这些属性都面临着类似的威胁,可以通过类似的手段来克服。

cngo 0601

图 6-1. 贡献于可靠性的系统属性和手段

因此,虽然“可靠性”这个概念本身可能有些模糊和主观,但其贡献的属性是定量和可测量的,足以为实际应用提供帮助:

可用性

系统在任意时刻执行其预期功能的能力。通常表达为系统接收请求成功的概率,定义为正常运行时间除以总时间。

可靠性

系统在给定时间间隔内执行其预期功能的能力。通常表达为平均无故障时间(MTBF:总时间除以故障次数)或故障率(故障次数除以总时间)。

可维护性

系统进行修改和维修的能力。可维护性有多种间接度量方法,从计算圈复杂度到跟踪改变系统行为所需的时间,以满足新要求或将其恢复到功能状态。

后来的作者扩展了拉普里对可靠性的定义,包括几个与安全相关的属性,包括安全性、机密性和完整性。我不情愿地省略了这些内容,不是因为安全不重要(安全非常重要!),而是为了简洁起见。讨论安全问题需要一整本书的篇幅。

可靠性:不仅仅是运维的事情了

自网络服务引入以来,开发人员的工作是构建服务,系统管理员(“运维”)的工作是将这些服务部署到服务器上并保持其运行。这在一段时间内运作得很好,但它不幸地导致开发人员为了功能开发而牺牲了稳定性和运维。

幸运的是,在过去的十年左右,与 DevOps 运动同时出现了一波新技术,这些技术有潜力彻底改变各种技术人员的工作方式。

在运维方面,随着基础设施和平台即服务(IaaS/PaaS)以及像 Terraform 和 Ansible 这样的工具的可用性,与基础设施的工作从未像现在这样像编写软件。

在开发方面,像容器和无服务器函数这样的技术的普及为开发人员提供了整套“操作类”能力,特别是在虚拟化和部署方面。

结果,软件和基础设施之间曾经明显的界限变得日益模糊。甚至可以说,随着虚拟化等基础设施抽象化技术、容器编排框架如 Kubernetes 和服务网格等软件定义行为的不断进步和采纳,它们甚至可能已经融合了。现在一切都是软件。

对服务可靠性的日益增长需求推动了一整代全新的云原生技术的诞生。这些新技术及其提供的能力产生了显著影响,传统的开发者和运维角色正在适应它们。终于,隔阂正在消失,越来越多的可靠、高质量服务的快速生成成为其所有设计者、实施者和维护者共同努力的结果。

实现可靠性

这正是实践的时刻。如果你已经走到这一步,恭喜你。

到目前为止,我们已经讨论了拉普里对“可靠性”的定义,可以(非常)松散地解释为“用户满意”,并且我们已经讨论了贡献于此的可用性、可靠性和可维护性属性。这一切都很好,但是如果没有关于如何实现可靠性的可行建议,整个讨论只是纯粹的学术性质。

拉普里也这样认为,并定义了四大类技术,可以共同用于提高系统的可靠性(或者由于它们的缺乏而降低可靠性):

故障预防

在系统构建过程中使用故障预防技术,以预防故障的发生或引入。

故障容忍

在系统设计和实施过程中使用故障容忍技术,以防止在存在故障时出现服务故障。

故障去除

使用故障去除技术来减少故障的数量和严重程度。

故障预测

使用故障预测技术来识别故障的存在、产生以及后果。

有趣的是,正如在图 6-2 中所示,这四个类别与我们在第 1 章中介绍的五个云原生属性非常相符。

cngo 0602

图 6-2. 实现可靠性的四种手段及其对应的云原生属性

故障预防和容错构成金字塔底部的两个层次,与可伸缩性、松耦合和弹性相对应。设计可伸缩系统可预防云原生应用程序中常见的各种故障,而弹性技术允许系统在故障不可避免地发生时容忍这些故障。松耦合的技术既可以说属于预防,也可以说属于增强服务容错性的技术。这些技术共同促成了 Laprie 所称的可靠性采购:这是系统被赋予执行其指定功能能力的手段。

技术和设计有助于可管理性,旨在生成一个可以轻松修改的系统,简化在识别故障时的排除过程。同样,可观察性自然有助于在系统中预测故障。排除故障和预测技术共同构成了 Laprie 所称的可靠性验证:这是获得对系统执行其指定功能能力的信心的手段。

考虑这种关系的影响:35 年前纯粹是学术练习的东西,现在本质上已经被重新发现——显然是独立地作为多年积累经验的自然结果,用于构建可靠的生产系统。可靠性已经走了一整圈。

在接下来的章节中,我们将更全面地探讨这些关系,并预览后续章节,在这些章节中我们将详细讨论这两个显然不同的系统如何实际上相对应得非常密切。

故障预防

在我们“可靠性手段”的金字塔底部是专注于预防故障发生或引入的技术。正如资深程序员可以证明的那样,许多——如果不是大多数——类别的错误和故障可以在开发的最早阶段预测和预防。因此,许多故障预防技术在服务的设计和实施过程中发挥作用。

良好的编程实践

故障预防是软件工程的主要目标之一,也是任何开发方法论的明确目标,从配对编程到测试驱动开发和代码审查实践。许多这样的技术实际上可以归为“良好的编程实践”,关于这些实践已经有无数优秀的书籍和文章写成,因此我们在这里不会明确涵盖它们。

语言特性

选择编程语言也极大地影响您预防或修复故障的能力。一些程序员有时期望的语言特性,如动态类型、指针算术、手动内存管理和抛出异常等,往往会引入意外行为,难以发现和修复,甚至可能被恶意利用。

这些功能强烈推动了 Go 的许多设计决策,最终形成了我们今天拥有的强类型垃圾回收语言。要了解为什么 Go 特别适合开发云原生服务,请回顾第二章。

可扩展性

我们在第一章中简要介绍了可扩展性的概念,将其定义为系统在需求显著变化的情况下继续提供正确服务的能力。

在那一节中,我们介绍了两种不同的扩展方法——通过调整现有资源来进行垂直扩展(向上扩展),以及通过添加(或移除)服务实例来进行水平扩展(向外扩展)——以及每种方法的优缺点。

我们将在第七章更深入地探讨这些问题,特别是其中的陷阱和缺点。我们还将大谈关于状态带来的问题。¹¹ 不过,现在,简单地说,需要扩展服务会增加相当多的开销,包括但不限于成本、复杂性和调试。

尽管扩展资源最终通常是不可避免的,但是抵制诱惑在问题上投入硬件通常更好(也更便宜!),并通过考虑运行时效率和算法扩展尽可能推迟扩展事件。因此,我们将介绍一些 Go 的功能和工具,这些功能和工具使我们能够识别和修复像内存泄漏和锁争用这样在规模化系统中常见的问题。

松耦合

松耦合,我们在“松耦合”中首次定义,是确保系统组件尽可能少地了解其他组件的系统属性和设计策略。服务之间的耦合程度对系统的扩展能力、隔离和容忍故障有巨大而又常常被低估的影响。

自从微服务出现以来,就有人持反对意见,认为基于微服务的系统部署和维护过于复杂,这证据表明这样的架构不可行。我不同意,但我理解他们的观点,考虑到构建分布式单块是多么容易。分布式单块的特征是其组件之间的紧密耦合,导致应用程序同时具有微服务的所有复杂性和典型单块的所有混乱依赖关系。如果您必须一起部署大部分服务,或者如果健康检查失败会导致整个系统发生级联故障,那么您可能有一个分布式单块。

构建松耦合的系统说起来容易,做起来不容易,但只要有些纪律和合理的边界是可能的。在第八章中,我们将介绍如何使用数据交换合同来建立这些边界,以及不同的同步和异步通信模型以及用于实现它们并避免可怕的分布式单块的架构模式和包。

容错性

容错性有许多同义词——自修复、自愈、弹性——它们都描述了系统检测错误并防止其向全面故障演变的能力。通常,这包括两个部分:错误检测,在正常服务过程中发现错误;以及恢复,将系统恢复到可以再次激活的状态。

提供弹性的最常见策略可能是冗余:关键组件的复制(具有多个服务副本)或功能(重试服务请求)。这是一个广泛且非常有趣的领域,涉及一些微妙的陷阱,我们将在第九章详细探讨。

故障去除

故障去除,作为四种可靠性手段之一,是在错误显现之前减少故障数量和严重性的过程,即潜在的软件缺陷可能导致错误。

即使在理想条件下,系统也可能出现多种错误或其他异常行为。它可能未能执行预期的操作,或者完全执行了错误的操作,可能还是恶意的。更复杂的是,条件并非总是——或者说往往不是——理想的。

许多故障可以通过测试来识别,这允许您验证系统(或至少其组件)在已知的测试条件下的行为是否符合预期。

但是未知条件呢?需求会变化,现实世界并不在乎你的测试条件。幸运的是,通过努力,可以设计出一个足够可管理的系统,其行为通常可以调整以保持安全运行,顺畅运行,并符合变化的要求。

我们稍后将简要讨论这些内容。

验证和测试

在你的代码中找到潜在软件缺陷的确切四种方法:测试、测试、测试和运气不佳。

是的,我在开玩笑,但这并非毫无道理:如果你找不到你的软件缺陷,你的用户会找到它们。如果你幸运的话。如果不幸的话,那么它们将被寻找到的恶意行为者利用。

开个玩笑,软件开发中发现软件缺陷的两种常见方法:

静态分析

静态分析是在不实际执行程序的情况下进行的自动化、基于规则的代码分析。静态分析有助于提供早期反馈,强制执行一致的实践,并在不依赖人类知识或努力的情况下找出常见错误和安全漏洞。

动态分析

通过在受控条件下执行系统或子系统并评估其行为来验证其正确性。更普遍地称为“测试”。

软件测试的关键在于设计为可测试性的软件,通过减少其组件的自由度——可能状态的范围——来实现。高度可测试的函数具有单一目的,具有明确定义的输入和输出,几乎没有或没有副作用;即它们不修改其作用域之外的变量。尽管有些宅男,但这种方法可以最小化每个函数的搜索空间——所有可能解决方案的集合。

测试是软件开发中至关重要的步骤,但往往被忽视。Go 的创作者们理解了这一点,并通过go test命令和测试包将单元测试和基准测试嵌入到语言本身中。不幸的是,深入探讨测试理论远超出了本书的范围,但我们会尽力浅尝其中在第九章中。

可管理性

当系统不按照需求行事时,存在缺陷。但是当这些需求改变时会发生什么呢?

设计可管理性,最早在“可管理性”中介绍,允许调整系统行为而无需更改代码。一个可管理的系统基本上有“旋钮”,允许实时控制,以确保系统安全、运行顺畅,并符合变化的需求。

可管理性可以采用多种形式,包括(但不限于!)调整和配置资源消耗,应用即时安全补救措施,可以打开或关闭功能的特性标志,甚至加载插件定义的行为。

显然,可管理性是一个广泛的主题。我们将在第十章中回顾 Go 提供的一些机制。

故障预测

在我们的“可依赖性手段”金字塔的顶端(图 6-2),故障预测建立在下面层次获得的知识和实施的解决方案基础之上,试图估计故障的当前数量、未来发生率及可能的后果。

这往往包括猜测和直觉,通常在起始假设不再成立时导致意外故障。更系统化的方法包括故障模式和影响分析和压力测试,这些方法对理解系统可能的故障模式非常有用。

在设计为可观察性的系统中,我们将在第十一章深入讨论,故障模式指标可以被跟踪,以便在它们显现为错误之前进行预测和修正。此外,当意外故障发生时——它们总会发生——可观测的系统允许快速识别、隔离和修正底层故障。

十二要素应用程序的持续相关性

在 2010 年代初,Heroku 的开发者意识到,他们看到的 Web 应用程序一遍又一遍地以同样的根本缺陷被开发。

受现代应用开发中系统性问题的驱使,他们起草了十二要素应用程序。这是一套包含十二条规则和指南的开发方法论,用于构建 Web 应用程序,并扩展到云原生应用程序(尽管当时“云原生”并不是一个常用的术语)。这一方法论是为了构建无需重大更改工具、架构或开发实践即可扩展的 Web 应用程序。

  • 使用声明性格式进行设置自动化,以减少新开发者加入项目所需的时间和成本。

  • 与底层操作系统有清晰的契约,提供在执行环境之间最大的可移植性。

  • 适合部署在现代云平台上,无需服务器和系统管理。

  • 最小化开发与生产环境之间的差异,实现最大灵活性的持续部署。

  • 能够在不显著改变工具、架构或开发实践的情况下进行扩展。

尽管在 2011 年首次发布时并未完全被重视,随着云原生开发复杂性变得更为广泛理解(和感受到),十二要素应用程序及其倡导的属性已开始被引用为任何服务成为云原生的最低标准。

I. 代码库

一个代码库,在版本控制中跟踪,多次部署。

十二要素应用程序

对于任何给定的服务,应有且仅有一个代码库用于生成多个不可变版本,以供部署到多个环境中。这些环境通常包括生产站点以及一个或多个暂存和开发站点。

将多个服务共享相同代码往往会导致模块之间的界限模糊,在时间上趋向于像单体应用,使得在不预期的方式下对服务的一个部分进行更改而影响另一个部分(或另一个服务!)变得更加困难。相反,共享代码应重构为可以单独进行版本控制并通过依赖管理器包含的库。

然而,将单个服务分布在多个代码库中几乎不可能自动应用服务生命周期中的构建和部署阶段。

II. 依赖关系

明确声明和隔离(代码)依赖项。

十二要素应用

对于代码库的任何给定版本,go buildgo testgo run应该是确定性的:无论如何运行它们,它们应该具有相同的结果,而且产品对相同的输入应该始终以相同的方式响应。

但是,如果依赖项——一个程序员无法控制的导入代码包或安装的系统工具——以某种方式发生变化,导致构建失败,引入错误,或与服务不兼容呢?

大多数编程语言都提供了用于分发支持库的打包系统,Go 也不例外。¹³ 通过使用Go 模块来完全准确地声明所有依赖关系,您可以确保导入的包不会在不被察觉地改变下破坏您的构建。

在某种程度上来说,服务通常应尽量避免使用os/exec包的Command函数来外部调用像 ImageMagick 或curl这样的工具。

是的,你的目标工具可能在所有(或大多数)系统上都可用,但无法保证它们在当前或未来的任何可能运行的地方都存在并且与服务完全兼容。理想情况下,如果您的服务需要外部工具,那么该工具应该通过将其包含在服务的代码库中来vendored

III. 配置

将配置存储在环境中。

十二要素应用

配置——在不同环境(测试、生产、开发环境等)之间可能变化的任何内容——应始终与代码清晰分离。在任何情况下,应用程序的配置都不应该被嵌入到代码中。

配置项可能包括但绝不限于:

  • 数据库或其他上游服务依赖的 URL 或其他资源句柄——即使它在不久的将来可能不会更改。

  • 任何类型的机密信息,如外部服务的密码或凭据。

  • 每个环境值,例如部署的规范主机名。

通过将配置从代码中 外部化 到某个配置文件(通常是 YAML¹⁴),可以从存储库中随代码一起提交,也可以不提交。这当然比在代码中硬编码配置要好,但也不是理想的解决方案。

首先,如果配置文件存储在存储库外部,很容易意外地检入。更重要的是,这些文件往往会蔓延,不同环境的不同版本存储在不同的位置,这样会使得难以以一致的方式查看和管理配置。

或者,你可以 在存储库中为每个环境拥有不同版本的配置,但这可能会很笨重,并且往往会导致一些尴尬的存储库操作。

而不是将配置作为代码或者外部配置,The Twelve Factor App 建议将配置存储为 环境变量。以这种方式使用环境变量实际上有很多优点:

  • 它们是标准的,并且在大多数操作系统和语言中都是通用的。

  • 在不修改任何代码的情况下轻松在部署之间进行更改。

  • 它们非常容易注入到容器中。

Go 有几个用于此目的的工具。

第一种——也是最基本的——是 os 包,它提供了 os.Getenv 函数用于此目的:

name := os.Getenv("NAME")
place := os.Getenv("CITY")

fmt.Printf("%s lives in %s.\n", name, place)

对于更复杂的配置选项,有几个优秀的包可供选择。其中,spf13/viper 似乎特别受欢迎。Viper 的示例代码可能如下所示:

viper.BindEnv("id")             // Will be uppercased automatically
viper.SetDefault("id", "13")    // Default value is "13"

id1 := viper.GetInt("id")
fmt.Println(id1)                // 13

os.Setenv("ID", "50")           // Typically done outside of the app!

id2 := viper.GetInt("id")
fmt.Println(id2)                // 50

此外,Viper 提供了许多标准包不具备的功能,例如默认值、类型化变量以及从命令行标志、各种格式的配置文件,甚至是像 etcd 和 Consul 这样的远程配置系统。

我们将在 第十章 深入探讨 Viper 和其他配置主题。

IV. 后备服务

将后备服务视为已附加的资源。

十二因素应用

后备服务是服务正常运行过程中通过网络消耗的任何下游依赖项(参见 “上游和下游依赖”)。服务不应区分相同类型的后备服务。无论是由同一组织管理的内部服务,还是由第三方管理的远程服务,都不应该有任何区别。

对于服务而言,每个不同的上游服务都应被视为另一个资源,每个都可以通过可配置的 URL 或其他资源句柄进行访问,如图 6-3 所示。所有资源都应被视为同样容易受到 分布式计算的谬论 的影响(如果需要,可以参考 第四章 进行复习)。

cngo 0603

图 6-3. 每个上游服务应被视为另一个资源,每个都可通过可配置的 URL 或其他资源句柄进行寻址,每个同样受到分布式计算谬误的影响

换句话说,你自己团队的系统管理员运行的 MySQL 数据库应该和 AWS 管理的 RDS 实例没有区别。对于任何上游服务,无论它是运行在另一个半球的数据中心还是在同一台服务器上的 Docker 容器中,都应如此对待。

通过更改配置值,能够随意将任何资源替换为同类资源(内部管理或其他方式)的服务,可以更轻松地部署到不同的环境中,更容易进行测试,更易于维护。

V. 构建、发布、运行

严格区分构建和运行阶段。

十二要素应用

每个(非开发)部署——特定版本的构建代码和配置的结合——应该是不可变的,并具有唯一的标签。如果有必要,应该能够精确地重新创建一个部署,如果(千万不得已的话)需要将部署回滚到较早版本。

通常,这通过三个明确的阶段完成,如图 6-4 所示,并在以下内容中描述:

构建

在构建阶段,自动化流程检索特定版本的代码,获取依赖项,并编译一个我们称为构建的可执行工件。每个构建应始终具有唯一标识符,通常是时间戳或递增的构建编号。

发布

在发布阶段,特定的构建与目标部署的配置结合在一起。生成的发布物可以立即在执行环境中执行。和构建一样,发布物也应该有一个唯一的标识符。重要的是,使用相同构建版本生成发布物不应该涉及重新构建代码:为了确保环境一致性,每个环境特定的配置应使用相同的构建工件。

运行

在运行阶段,发布物被交付到部署环境,并通过启动服务的进程来执行。

理想情况下,每当部署新代码时,都应自动生成一个新版本化的构建。

cngo 0604

图 6-4. 将代码库部署到(非开发)环境的过程应该在明确的构建、发布和运行阶段中执行

VI. 进程

将应用程序作为一个或多个无状态进程执行。

十二要素应用

服务过程应该是无状态的,并且彼此独立。任何需要持久化的数据都应存储在有状态的后端服务中,通常是数据库或外部缓存。

我们已经花了一些时间讨论无状态性——在下一章中我们会更多地讨论这一点,所以我们不会进一步深入这个问题。

然而,如果你有兴趣提前阅读,可以自由查看“状态与无状态”。

VII. 数据隔离

每个服务管理自己的数据。

云原生,数据隔离

每个服务应该是完全自包含的。也就是说,它应该管理自己的数据,并且只能通过专为此目的设计的 API 访问其数据。如果这听起来很熟悉,那很好!实际上,这是微服务的核心原则之一,我们将在“微服务系统架构”中进一步讨论。

很多时候,这将被实现为一种请求-响应服务,如 RESTful API 或 RPC 协议,通过监听来自某个端口的请求来导出。但这也可以采用异步、基于事件的服务形式,使用发布-订阅消息模式。这两种模式将在第八章中详细描述。

最后,尽管在 Go 世界中你不会看到这种情况,但某些语言和框架允许将应用服务器注入到执行环境中,以创建面向 Web 的服务。这种做法通过打破数据隔离和环境不可知性,限制了可测试性和可移植性,强烈不建议采用这种做法。

VIII. 可扩展性

通过进程模型进行扩展。

十二因素应用

服务应该能够通过增加更多实例来水平扩展。

我们在本书中多次谈到可扩展性。我们甚至将整个第七章专门讨论了这个问题。有充分的理由:可扩展性的重要性不可低估。

当然,仅仅加强运行服务的一个服务器确实很方便——在(非常)短期内这没问题,但在长期看来,纵向扩展是一种失败的策略。如果幸运的话,你最终会遇到一个无法再扩展的点。更有可能的是,你的单一服务器会在你无法扩展的速度下遭遇负载波动,或者突然死掉,而没有冗余的故障转移。¹⁶ 这两种情况都会导致大量不满的用户。我们会在第七章中进一步讨论可扩展性。

IX. 可处置性

通过快速启动和优雅关闭来最大化健壮性。

十二因素应用

云环境非常靠不住:已经配置好的服务器有时会在奇怪的时间消失。服务应该考虑到这一点,采用可处置的方式:服务实例应能够随时启动或停止,无论是否有意。

服务应该努力将启动时间最小化,以减少部署(或重新部署)服务的时间,从而实现弹性扩展。由于 Go 语言没有虚拟机或其他重大开销,因此在这方面表现特别出色。

容器提供快速的启动时间,并且在这方面也非常有用,但必须注意保持镜像大小小,以最小化每次部署新镜像时产生的数据传输开销。这是另一个 Go 语言的优点:它的自包含二进制文件通常可以安装到 SCRATCH 镜像中,无需外部语言运行时或其他外部依赖。我们在前一章中已经展示了这一点,在 “将您的键值存储容器化” 中。

服务还应当能够在收到 SIGTERM 信号时关闭,方法是保存所有需要保存的数据,关闭打开的网络连接,或完成未完成的工作,或将当前工作返回工作队列。

X. 开发/生产对等性

开发、演示和生产环境应尽可能保持一致。

十二要素应用

任何可能的开发与生产之间的差异都应尽可能小。当然,这包括代码差异,但远不止于此:

代码分歧

开发分支应该小而短命,应尽快进行测试并部署到生产环境中。这可以最小化环境之间的功能差异,并减少部署和回滚的风险。

堆栈分歧

而不是在开发和生产中使用不同的组件(例如,在 OS X 上使用 SQLite 而在 Linux 上使用 MySQL),环境应尽可能保持一致。轻量级容器是实现这一目标的绝佳工具。这可以最大程度地减少因几乎相同但又不完全相同的实现之间的不便差异而可能导致的问题。

人员分歧

以前,程序员编写代码,操作员部署代码很常见,但这种安排会造成冲突的激励机制和反生产的对抗性关系。让代码作者参与部署并对其在生产环境中的行为负责有助于打破开发与运维的隔阂,并且能够使稳定性与速度的激励机制保持一致。

这些方法共同作用,有助于缩小开发与生产之间的差距,从而促进快速、自动化和持续的部署。

XI. 日志

将日志视为事件流。

十二要素应用

日志——服务的永不停息的意识流——在分布式环境中尤为有用。通过提供对运行应用程序行为的可见性,良好的日志记录可以极大简化定位和诊断错误的任务。

传统上,服务将日志事件写入本地磁盘上的文件。然而,在云规模下,这只会使有价值的信息难以查找、访问不便且无法聚合。在像 Kubernetes 这样的动态、短暂的环境中,您的服务实例(及其日志文件)甚至可能在您查看它们之前就已经不存在了。

相反,云原生服务应将日志信息视为一系列事件流,直接将每个事件未经缓冲地写入stdout。它不应关注像路由或日志事件存储等实现细节,而是允许执行者决定如何处理它们。

尽管看似简单(甚至有些违反直觉),这一小改动却带来了极大的自由。

在本地开发中,程序员可以在终端中观察事件流以观察服务的行为。在部署中,执行环境可以捕获输出流并转发到一个或多个目的地,例如日志索引系统(如 Elasticsearch、Logstash 和 Kibana(ELK)或 Splunk)以进行审查和分析,或数据仓库以进行长期存储。

我们将在第十一章更详细地讨论日志和日志记录,以观测性为背景。

XII. 管理流程

将管理/管理任务作为一次性过程运行。

十二要素应用程序

在所有原始的十二要素中,这是唯一明显显露其年龄的要素。首先,它明确提倡进入环境手动执行任务。

明确一点:对服务器实例进行手动更改会创建特定的个体(即“特别的雪花”)。这是不好的。参见“特别的雪花”。

假设您拥有可以进入的环境,您应该假定它可以(并最终会)随时被销毁并重新创建。

暂时不考虑这一切,让我们将重点放在其最初的意图上:管理和管理任务应作为一次性过程运行。这可以有两种解释,每种都需要采用不同的方法:

  • 如果您的任务是一个类似于数据修复或数据库迁移的管理流程,应将其作为短暂的过程运行。容器和函数是这类目的的极好载体。

  • 如果您的更改是对服务或执行环境的更新,则应修改相应的服务或环境构建/配置脚本。

概要

在这一章中,我们考虑了“云原生的意义是什么?”这个问题。通常的答案是“在云中工作的计算机系统。”但“工作”可以指任何事情。我们当然可以做得更好。

因此,我们回到像托尼·霍尔和 J-C·拉普里这样的思想家,他们提供了答案的第一部分:可靠性。换句话说,计算机系统应该以用户可接受的方式运行,尽管处于基本不可靠的环境中。

显然,说起来容易,做起来难,所以我们审视了三种关于如何实现它的思考方法:

  • Laprie 学术中的“可靠性手段”,包括预防、容忍、排除和故障预测。

  • 亚当·威金斯的《十二要素应用》,它采用了更具规范性(在某些地方略显过时)的方法。

  • 我们自己的“云原生属性”,基于云原生计算基金会对“云原生”的定义,我们在第一章中介绍,并围绕整本书组织。

尽管本章本质上只是对理论的简短调查,但这里有许多重要的基础信息,描述了我们所称的“云原生”的动机和手段。

¹ C.A.R.霍尔。《计算机程序设计的公理基础》。ACM 通讯,1969 年 10 月,第 12 卷,第 10 期,第 576-583 页。https://oreil.ly/jOwO9.

² 当艾德斯格·W·迪科斯特拉(Edsger W. Dijkstra)创造出“GOTO 被认为有害”这个表达时,他正是在引用霍尔在结构化编程中的工作。

³ 托尼·霍尔。《空引用:十亿美元的错误》。InfoQ.com。2009 年 8 月 25 日。https://oreil.ly/4QWS8.

云原生关乎文化,而非容器。霍莉·卡明斯。云原生伦敦 2018 年。

⁵ 如果你有机会听她演讲,我强烈推荐你抓住机会。

⁶ 记得沃尔特那时对简的所作所为吗?真是太乱了。

⁷ J-C·拉普里。《可靠计算与容错:概念与术语》。第 15 届容错计算国际研讨会(FTCS-15),1985 年 6 月,第 2-11 页。https://oreil.ly/UZFFY.

⁸ A·阿维兹涅斯、J·拉普里和 B·兰德尔。《计算机系统可靠性基本概念》。LAAS-CNRS 研究报告 1145 号,2001 年 4 月。https://oreil.ly/4YXd1.

⁹ 如果你还没有,从Site Reliability Engineering: How Google Runs Production Systems开始。真的非常好。

¹⁰ 许多组织使用服务水平目标(SLO)来达到精确的目的。

¹¹ 应用状态很难,如果做得不对,对可伸缩性是一种毒药。

¹² 亚当·威金斯的《十二要素应用》。2011 年。https://12factor.net.

¹³ 尽管那已经太久了!

¹⁴ 世界上最糟糕的配置语言(除了其他所有的)。

¹⁵ 亚当·威金斯。“端口绑定。” 十二要素应用. 2011. https://oreil.ly/bp8lC.

¹⁶ 可能是在凌晨三点钟。

¹⁷ “烘焙”有时用来指代创建新容器或服务器镜像的过程。

第七章:扩展性

一些最好的编程是在纸上完成的,真的。将其输入到计算机中只是一个小细节。¹

马克斯·卡纳特-亚历山大,《代码简洁:软件基础知识》

2016 年夏天,我加入了一家小公司,该公司将各类表格和杂项文书数字化,这些文书通常是州和地方政府所熟悉和喜爱的。他们核心应用的状态相当典型于初创阶段的创业公司,因此我们着手工作,到了秋天,已经成功将其容器化,并用代码描述了其基础设施,并完全自动化了部署。

我们的一位客户是弗吉尼亚东南部的一个小型沿海城市,所以当第一个近十年来的大西洋第五级飓风“马修”预测将在附近登陆时,当地官员恪尽职守地宣布进入紧急状态,并使用我们的系统为市民创建必要的填写文书。然后他们将其发布到社交媒体上,同时有 50 万人同时登录。

当值班人员收到警报时,查看指标后发现服务器的聚合 CPU 使用率达到了 100%,并且数十万个请求超时。

所以,我们将所需的服务器数量增加了一个零,创建了一个“待办”任务来实现自动扩展,并继续我们的日常工作。在 24 小时内,高峰期已过,所以我们对服务器进行了扩展。

除了自动扩展的好处之外,我们从中学到了什么?²

首先,这凸显了一个事实,即如果没有扩展能力,我们的系统肯定会遭受长时间的停机。但是能够根据需求增加资源意味着即使在我们曾经预期的远远超出的负载下,我们也能为用户提供服务。作为额外的好处,如果任何一个服务器失败,其工作可以被分配给其他幸存者。

第二,拥有远多于所需的资源不仅是浪费的,而且是昂贵的。在需求减少时缩减我们的实例的能力意味着我们只需支付我们所需的资源费用。这对预算有限的初创公司来说是一个重大的优势。

不幸的是,因为不可扩展的服务在初始条件下似乎运行得很完美,所以在服务设计过程中,扩展性并不总是一个考虑因素。虽然这在短期内可能完全足够,但不能超出其最初预期的服务也意味着有限的生命周期价值。更重要的是,在服务难以重构以适应扩展性时,从一开始就考虑这一点可以在长远节省时间和金钱。

首先,这本书主要是一本 Go 语言书籍,或者至少更像是一本 Go 语言书籍,而不是基础设施或架构书籍。虽然我们会讨论可扩展的架构和消息传递模式等内容,但本章的大部分内容将集中展示如何使用 Go 语言来开发依赖于扩展性方程中的其他(非基础设施)部分的服务:效率。³

可扩展性是什么?

您可能还记得,可扩展性的概念最初是在第一章中首次介绍的,其中它被定义为系统在面对需求显著变化时继续提供正确服务的能力。按照这个定义,如果一个系统在负载急剧增加时无需重新设计即可执行其预期功能,那么可以认为该系统是可扩展的。

请注意,这个定义⁴ 实际上并没有说明任何增加物理资源的内容。相反,它强调了系统处理大幅需求波动的能力。这里“扩展”的对象是需求的大小。虽然增加资源是实现可扩展性的一种完全可接受的方法,但它并不完全等同于可扩展性。为了使事情变得更加混乱一点,术语“扩展”也可以应用于系统,这种情况下它确实意味着专用资源量的变化。

那么我们如何在不增加资源的情况下处理高需求呢?正如我们将在“推迟扩展:效率”中讨论的那样,考虑到效率的系统天生更具可扩展性,因为它们能够优雅地吸收高水平的需求,而不需要立即为每次剧烈的需求波动响应而添加硬件,也不需要因“以防万一”而大规模过度配置。

不同形式的扩展

不幸的是,即使是最有效的效率策略也有其极限,最终您会发现自己需要扩展服务以提供额外的资源。可以通过两种不同的方式来实现这一点(见图 7-1),每种方式都有其自身的优缺点:

垂直扩展

一个系统可以通过增加其资源分配来进行垂直扩展(或称为向上扩展)。在公共云中,通过更改实例大小,现有服务器可以相对容易地进行垂直扩展,但只能进行到没有更大的实例类型(或资金)为止。

水平扩展

一个系统可以通过复制系统或服务来进行水平扩展(或称为向外扩展),以减轻任何单个服务器的负担。使用这种策略的系统通常能够扩展以处理更大量的负载,但正如您将在“状态与无状态”中看到的那样,状态的存在可能会使某些系统难以或无法采用这种策略。

cngo 0701

图 7-1。垂直扩展可以是有效的短期解决方案;水平扩展在技术上更具挑战性,但可能是更好的长期策略。

这两个术语用于描述关于扩展的最常见的思考方式:拿整个系统,只是多做。然而,实际上还有许多其他的扩展策略在使用中。

可能其中最常见的是功能分区,如果你还不知道它的名字,你肯定已经对它非常熟悉了。功能分区涉及将复杂的系统分解为较小的功能单元,这些单元可以独立优化、管理和扩展。你可能会认出这是从基本程序设计到高级分布式系统设计的许多最佳实践的泛化。

另一种常见的方法是在具有大量数据的系统中 —— 特别是数据库中 —— 使用的分片。采用这种策略的系统通过将数据分割为称为分片的分区来分配负载,每个分片保存大数据集的特定子集。一个基本的例子在 “通过分片最小化锁定” 中展示。

四种常见的瓶颈

随着系统需求的增加,总会有一个点,其中一个资源无法跟上步伐,有效地阻碍了进一步扩展的努力。这个资源已经成为了瓶颈

通过识别和解决瓶颈来使系统恢复可操作的性能水平。这可以通过增加瓶颈组件 —— 垂直扩展 —— 来实现,例如增加内存或升级 CPU。正如你可能从 “不同形式的扩展” 讨论中记得的那样,这种方法并不总是可行(或具有成本效益),而且永远不能依赖它。

然而,通常可以通过增强或减少对受影响组件的负担来解决瓶颈,利用系统仍然丰富的另一个资源。例如,数据库可以通过在 RAM 中缓存数据来避免磁盘 I/O 瓶颈;相反,一个内存需求高的服务可以将数据分页到磁盘上。水平扩展并不会使系统免疫:增加更多实例可能意味着更多的通信开销,这会给网络增加额外的负担。即使是高并发系统在需求增加时也可能成为它们自身内部工作方式的受害者,例如锁争用等现象也会发挥作用。有效地使用资源通常意味着进行权衡。

当然,修复瓶颈需要首先识别受限组件,虽然有许多不同的资源可能成为扩展努力的目标 —— 无论是通过实际扩展资源还是更有效地使用它 —— 这些努力往往集中在仅有的四种资源上:

CPU

系统中央处理器每单位时间可以执行的操作数,对许多系统来说是常见的瓶颈。CPU 的扩展策略包括缓存昂贵确定性操作的结果(以内存为代价),或者简单地增加处理器的大小或数量(以扩展为代价)。

内存

可以存储在主存储器中的数据量。尽管今天的系统可以存储数十甚至数百吉字节的数据,但即使如此,对于依赖内存以规避磁盘 I/O 速度限制的数据密集型系统来说,这仍然可能不足够。扩展策略包括将数据从内存转移到磁盘(以磁盘 I/O 为代价)或外部专用缓存(以网络 I/O 为代价),或者简单地增加可用内存的数量。

磁盘 I/O

数据可以从硬盘或其他持久存储介质读取和写入的速度。磁盘 I/O 是高度并行系统的常见瓶颈,这些系统读写大量磁盘,例如数据库。扩展策略包括将数据缓存在 RAM 中(以内存为代价)或使用外部专用缓存(以网络 I/O 为代价)。

网络 I/O

数据可以从网络上的特定点或总体发送的速度。网络 I/O 直接转化为每单位时间传输的数据量。网络 I/O 的扩展策略通常有限,⁵ 但网络 I/O 尤其适合各种优化策略,我们将很快讨论这些策略。

随着系统需求的增加,几乎肯定会发现系统受到其中一个资源的瓶颈限制,虽然可以应用效率策略,但这些策略往往以牺牲一个或多个其他资源为代价,因此最终可能会发现系统再次受到另一个资源的瓶颈限制。

状态和无状态

我们在 “应用状态与资源状态” 中简要讨论了无状态性,其中我们描述了应用状态——关于应用程序或客户端使用方式的服务器端数据——应尽可能避免。但这次,让我们花点时间讨论一下状态是什么,为什么它可能会有问题,以及我们可以采取什么措施。

结果表明,“状态”在定义上有些难以捉摸,所以我将尽我所能自行解释。对于本书的目的,我将状态定义为应用程序变量的集合,如果变更会影响应用程序行为。⁶

应用状态与资源状态

大多数应用程序都具有某种形式的状态,但并非所有状态都是平等的。它有两种形式,其中一种远不如另一种理想。

首先,有应用状态,它存在于应用程序需要在本地记住事件的任何时候。每当有人谈论有状态的应用程序时,他们通常指的是一种设计为使用这种本地状态的应用程序。“本地”是一个关键词。

其次,有资源状态,对每个客户端都是相同的,与客户端的操作无关,比如存储在外部数据存储或由配置管理管理的数据。这可能会引起误解,但说一个应用程序是无状态并不意味着它没有任何数据,只是它被设计成没有任何本地持久数据。它唯一的状态是资源状态,通常因为它的所有状态都存储在某个外部数据存储中。

为了说明两者之间的区别,想象一个跟踪客户会话并将其与某个应用程序上下文相关联的应用程序。如果用户的会话数据由应用程序在本地维护,那就被视为“应用程序状态”。但如果数据存储在外部数据库中,则可以将其视为远程资源,并且它将被视为“资源状态”。

应用程序状态有点像“反可扩展性”。多个有状态服务的实例会因为接收到的不同输入而迅速发现它们的各自状态开始分歧。服务器亲和力通过确保每个客户端的请求都发送到同一台服务器来提供对此特定情况的解决方法,但这种策略会带来相当大的数据风险,因为任何单个服务器的故障可能会导致数据丢失。

无状态的优势

到目前为止,我们已经讨论了应用程序状态和资源状态之间的差异,甚至提出了——尽管没有太多证据(尚)——应用程序状态是不好的。然而,无状态性提供了一些非常明显的优势:

可扩展性

最明显和经常被引用的好处是,无状态应用程序可以独立处理每个请求或交互,与先前的请求无关。这意味着任何服务副本都可以处理任何请求,允许应用程序在不丢失处理任何正在进行的会话或请求所需数据的情况下增长、收缩或重新启动。这在自动缩放服务时尤为重要,因为托管服务的实例、节点或 Pod 可以(并且通常会)意外创建和销毁。

耐久性

存在于一个地方的数据(例如单个服务副本)可以(并且在某个时候)在该副本因任何原因而消失时丢失。请记住:所有东西在“云端”最终都会消失。

简单性

在没有任何应用程序状态的情况下,无状态服务免于… 嗯… 管理它们的状态。⁷ 不需要维护服务端状态同步、一致性和恢复逻辑⁸ 使得无状态 API 更简单,因此更易于设计、构建和维护。

可缓存性

无状态服务提供的 API 相对容易设计为可缓存。如果服务知道特定请求的结果无论何时谁发起都将始终相同,结果可以安全地存储以供以后轻松检索,从而提高效率并减少响应时间。

这些可能看起来是四种不同的事物,但在它们提供的内容上存在重叠。特别是无状态性使得服务更简单、更安全地构建、部署和维护。

延迟扩展:效率

在云计算的背景下,我们通常将可扩展性定义为系统增加网络和计算资源的能力。然而,经常被忽视的是在可扩展性中效率的角色。具体来说,系统处理需求变化时需要添加(或极度过度预留)专用资源的能力。

虽然可以争论大多数时候大多数人不关心程序效率,但随着对服务的需求增加,这种说法开始不那么正确。如果一种语言有相对较高的每进程并发开销——这在动态类型语言中经常发生——它将比轻量级语言更快消耗所有可用内存或计算资源,因此需要更多的资源和扩展事件来支持同样的需求。

这在 Go 语言并发模型的设计中是一个主要考虑因素,它的 goroutine 实际上不是线程,而是轻量级的协程多路复用到多个操作系统线程上。每个协程的成本几乎只是分配堆栈空间,允许可能同时执行数百万个协程。

因此,在本节中,我们将介绍一些 Go 语言的特性和工具,它们允许我们避免常见的扩展问题,如内存泄漏和锁争用,并在出现这些问题时识别和修复它们。

使用 LRU 缓存进行高效缓存

将数据缓存到内存是一种非常灵活的效率策略,可以用来减轻从 CPU 到磁盘 I/O 或网络 I/O 的任何压力,甚至只是减少与远程或其他运行缓慢操作相关的延迟。

缓存的概念看起来当然似乎很简单。你有一些你希望记住值的东西——比如昂贵(但确定性的)计算的结果——然后将其放入映射中以备将来使用。对吧?

嗯,你可以这样做,但很快就会开始遇到问题。随着核心数和 goroutine 数量的增加,会发生什么?由于你没有考虑并发性,很快就会发现你的修改互相干扰,导致一些不愉快的结果。而且,由于我们忘记从映射中移除任何内容,它将继续无限增长,直到消耗掉所有内存。

我们需要的是一个缓存,它:

  • 支持并发的读取、写入和删除操作

  • 随着核心数和 goroutine 数量的增加,性能表现良好

  • 不会无限增长以消耗所有可用内存

这个困境的一个常见解决方案是 LRU(最近最少使用)缓存:一种特别可爱的数据结构,跟踪每个键最近被“使用”(读取或写入)的时间。当添加一个值到缓存中导致超出预定义的容量时,缓存能够“淘汰”(删除)其最近最少使用的值。

讨论如何实现 LRU 缓存的详细内容超出了本书的范围,但我会说这个方法相当巧妙。如图 7-2 所示,LRU 缓存包含一个双向链表(实际上包含值)和一个将每个键关联到链表节点的映射。每当读取或写入一个键时,相应的节点就会移动到列表的底部,这样最近未使用的节点总是位于顶部。

有几种 Go 的 LRU 缓存实现可用,尽管目前核心库中没有(尚未)。可能最常见的是作为golang/groupcache库的一部分。然而,我更喜欢 HashiCorp 开源的扩展版本groupcachehashicorp/golang-lru,它有更好的文档,并包含用于并发安全的sync.RWMutexes

cngo 0702

图 7-2. LRU 缓存包含一个映射和一个双向链表,使其能够在超出容量时丢弃过时项目

HashiCorp 的库包含两个构造函数,每个函数返回类型为*Cache和一个error的指针:

// New creates an LRU cache with the given capacity.
func New(size int) (*Cache, error)

// NewWithEvict creates an LRU cache with the given capacity, and also accepts
// an "eviction callback" function that's called when an eviction occurs.
func NewWithEvict(size int,
    onEvicted func(key interface{}, value interface{})) (*Cache, error)

*Cache结构体有许多附加方法,其中最有用的是:

// Add adds a value to the cache and returns true if an eviction occurred.
func (c *Cache) Add(key, value interface{}) (evicted bool)

// Check if a key is in the cache (without updating the recent-ness).
func (c *Cache) Contains(key interface{}) bool

// Get looks up a key's value and returns (value, true) if it exists.
// If the value doesn't exist, it returns (nil, false).
func (c *Cache) Get(key interface{}) (value interface{}, ok bool)

// Len returns the number of items in the cache.
func (c *Cache) Len() int

// Remove removes the provided key from the cache.
func (c *Cache) Remove(key interface{}) (present bool)

还有其他几种方法。请查看GoDocs获取完整列表。

在下面的示例中,我们创建并使用了一个容量为两个的 LRU 缓存。为了更好地突出淘汰过程,我们包含了一个回调函数,每当发生淘汰时会向stdout打印一些输出。请注意,我们决定在init函数中初始化cache变量,这是一个特殊的函数,在变量声明在评估其初始值后自动调用main函数之前:

package main

import (
    "fmt"
    lru "github.com/hashicorp/golang-lru"
)

var cache *lru.Cache

func init() {
    cache, _ = lru.NewWithEvict(2,
        func(key interface{}, value interface{}) {
            fmt.Printf("Evicted: key=%v value=%v\n", key, value)
        },
    )
}

func main() {
    cache.Add(1, "a")           // adds 1
    cache.Add(2, "b")           // adds 2; cache is now at capacity

    fmt.Println(cache.Get(1))   // "a true"; 1 now most recently used

    cache.Add(3, "c")           // adds 3, evicts key 2

    fmt.Println(cache.Get(2))   // "<nil> false" (not found)
}

在上述程序中,我们创建了一个容量为两个的cache,这意味着添加第三个值将迫使淘汰最近最少使用的值。

在将 {1:"a"}{2:"b"} 的值添加到缓存后,我们调用 cache.Get(1),这使得 {1:"a"}{2:"b"} 更近期被使用。因此,在下一步中当我们添加 {3:"c"} 时,{2:"b"} 将被驱逐,所以下一个 cache.Get(2) 不应返回任何值。

如果我们运行此程序,我们将能够看到它的实际效果。我们期望以下输出:

$ go run lru.go
a true
Evicted: key=2 value=b
<nil> false

LRU 缓存是一个在大多数情况下作为全局缓存的优秀数据结构,但它也有一个局限性:在非常高的并发水平下——每秒数百万次操作——它将开始出现一些竞争问题。

不幸的是,在撰写本文时,Go 似乎仍然没有一个 非常 高吞吐量的缓存实现。⁹

高效的同步

Go 中一个常见的格言是 “不要通过共享内存来通信;通过通信来共享内存”。换句话说,通常情况下,通道优于共享数据结构。

这是一个非常强大的概念。毕竟,Go 的并发原语——goroutine 和通道——提供了一个强大而表达力强的同步机制,使得一组使用通道交换数据结构引用的 goroutine 通常可以完全不需要锁。

(如果您对通道和 goroutine 的细节有些模糊,不要紧张。花点时间翻回 “Goroutines”。没关系,我会等待的。)

话虽如此,Go 确实 提供了更传统的锁机制,例如 sync 包。但是,如果通道如此出色,为什么我们还要使用像 sync.Mutex 这样的东西?在什么情况下会使用它?

事实证明,通道确实非常有用,但它们并非所有问题的解决方案。当您处理许多离散值时,通道非常出色,并且在传递数据所有权、分发工作单元或通信异步结果时是更好的选择。而互斥锁则非常适合同步访问缓存或其他大型状态结构。

归根结底,没有工具能解决所有问题。最终,最好的选择是使用最具表达力和/或最简单的选项。

通过通信来共享内存

多线程简单;加锁困难。

在本节中,我们将使用一个经典示例——最初在 Andrew Gerrand 的经典 Go Blog 文章 “Share Memory By Communicating”¹⁰ 中提出——来演示这个真理,并展示 Go 通道如何使并发更安全、更易于理解。

假设有一个虚构的程序,通过发送 GET 请求并等待响应来轮询 URL 列表。问题在于,每个请求可能要花费相当长的时间等待服务响应:从毫秒到秒甚至更长,具体取决于服务情况。这种操作显然非常适合使用一些并发机制,不是吗?

在传统的线程环境中,依赖锁定进行同步,你可能会像以下这样结构化其数据:

type Resource struct {
    url        string
    polling    bool
    lastPolled int64
}

type Resources struct {
    data []*Resource
    lock *sync.Mutex
}

如你所见,我们不是拥有一堆 URL 字符串切片,而是有两个结构体——ResourceResources——每一个都已经装配了许多同步结构,超出了我们实际关心的 URL 字符串范畴。

要以传统方式多线程轮询过程,你可能会有一个像以下这样在多个线程中运行的Poller函数:

func Poller(res *Resources) {
    for {
        // Get the least recently polled Resource and mark it as being polled
        res.lock.Lock()

        var r *Resource

        for _, v := range res.data {
            if v.polling {
                continue
            }
            if r == nil || v.lastPolled < r.lastPolled {
                r = v
            }
        }

        if r != nil {
            r.polling = true
        }

        res.lock.Unlock()

        if r == nil {
            continue
        }

        // Poll the URL

        // Update the Resource's polling and lastPolled
        res.lock.Lock()
        r.polling = false
        r.lastPolled = time.Nanoseconds()
        res.lock.Unlock()
    }
}

这样做虽然能完成任务,但还有很大的改进空间。代码长达一页,难以阅读、难以推理,甚至不包括 URL 轮询逻辑或优雅地处理Resources池的耗尽。

现在让我们看看使用 Go 通道实现相同功能的示例。在这个例子中,Resource已经被简化为其基本组件(URL 字符串),而Poller是一个函数,它从输入通道接收Resource值,并在完成时将它们发送到输出通道:

type Resource string

func Poller(in, out chan *Resource) {
    for r := range in {
        // Poll the URL

        // Send the processed Resource to out
        out <- r
    }
}

简直就像……简单。我们完全摒弃了Poller中的机械式锁定逻辑,我们的Resource数据结构不再包含繁琐的数据记录。事实上,剩下的都是重要的部分。

但是,如果我们想要多个Poller进程怎么办?这不正是我们一开始尝试做的吗?答案再次简单而又光辉:goroutines。看看下面的例子:

for i := 0; i < numPollers; i++ {
    go Poller(in, out)
}

通过执行numPollers个 goroutines,我们创建了numPollers个并发进程,每个都从同一个通道读取和写入。

前面的示例中省略了很多内容,重点突出了相关部分。如果你想看一个完整、惯用的 Go 程序示例,使用这些思想,请参见“通过通信共享内存” 代码演示。

通过缓冲通道减少阻塞

在本章的某个地方,你可能会想:“当然,通道很棒,但是写入通道仍然会阻塞。”毕竟,每次在通道上的发送操作都会阻塞,直到有相应的接收,对吧?嗯,事实证明,这只是大体上是真的。至少,默认的非缓冲通道是这样的。

然而,正如我们在“通道缓冲”中首次描述的那样,可以创建具有内部消息缓冲区的通道。在这种缓冲通道上的发送操作仅在缓冲区满时阻塞,接收操作仅在缓冲区空时阻塞。

你可能还记得,缓冲通道可以通过将额外的容量参数传递给make函数来创建,以指定缓冲区的大小:

ch := make(chan type, capacity)

缓冲通道在处理“突发”负载时特别有用。事实上,我们在第五章已经使用了这种策略,当我们初始化我们的FileTransactionLogger时。从该章节中提炼一些分散的逻辑,可以得到如下内容:

type FileTransactionLogger struct {
    events       chan<- Event       // Write-only channel for sending events
    lastSequence uint64             // The last used event sequence number
}

func (l *FileTransactionLogger) WritePut(key, value string) {
    l.events <- Event{EventType: EventPut, Key: key, Value: value}
}

func (l *FileTransactionLogger) Run() {
    l.events = make(chan Event, 16)             // Make an events channel

    go func() {
        for e := range events {                 // Retrieve the next Event
            l.lastSequence++                    // Increment sequence number
        }
    }()
}

在这一段中,我们有一个 WritePut 函数,可以调用它向一个 events 通道发送消息,这个通道在 Run 函数中创建的匿名 goroutine 的 for 循环中接收。如果 events 是一个标准通道,每次发送都会阻塞,直到匿名 goroutine 完成接收操作。大部分情况下这可能没问题,但如果多个写入速度比 goroutine 处理它们的速度快,那么上游客户端将会被阻塞。

通过使用缓冲通道,我们使得这段代码能够处理最多 16 个紧密集中的写入请求。然而,第 17 次写入被阻塞。

还需要考虑使用这样的缓冲通道会导致数据丢失的风险,即使在任何消费 goroutine 能够清空缓冲区之前程序终止。

最小化分片锁定

正如我们在“高效同步”中提到的那样,尽管通道很好用,但并不解决所有问题。一个常见的例子是大型中央数据结构,例如缓存,它不能轻易地分解为离散的工作单元。¹¹

当共享数据结构需要并发访问时,通常会使用锁定机制,例如 sync 包提供的互斥锁,就像我们在“使您的数据结构具有并发安全性”中所做的那样。例如,我们可以创建一个包含映射和嵌入的 sync.RWMutex 的结构体:

var cache = struct {
    sync.RWMutex
    data map[string]string
}{data: make(map[string]string)}

当一个例程想要写入缓存时,它会小心地使用 cache.Lock 来建立写入锁,使用 cache.Unlock 在完成后释放锁。我们甚至可能希望将其包装在一个便利函数中,如下所示:

func ThreadSafeWrite(key, value string) {
    cache.Lock()                                    // Establish write lock
    cache.data[key] = value
    cache.Unlock()                                  // Release write lock
}

根据设计,这限制了写入访问权限,只有持有锁的例程才能访问。这种模式通常运行良好。然而,正如我们在第四章讨论的那样,随着并发处理数据的进程数量增加,进程等待锁释放的平均时间也会增加。你可能记得这种不幸的情况的名称:锁争用。

虽然在某些情况下,通过增加实例的数量可以解决这个问题,但这也会增加复杂性和延迟,因为需要建立分布式锁定,并确保写入一致性。在一个服务实例内部减少共享数据结构周围的锁争用的替代策略是垂直分片,其中一个大型数据结构被分成两个或更多部分,每个部分代表整体的一部分。使用这种策略,每次只需锁定整体结构的一部分,从而减少总体锁争用。

你可能还记得我们在 “分片” 中详细讨论过垂直分片。如果你对垂直分片的理论或实现不清楚,请随时回顾那一部分。

内存泄漏可能导致…致命错误:运行时:内存不足

内存泄漏是一类 bug,即使在不再需要内存时也没有被释放。这些 bug 可能非常隐晦,并且经常困扰像 C++ 这样需要手动管理内存的语言。尽管垃圾收集确实通过试图回收程序不再使用的对象所占用的内存来帮助,但像 Go 这样的垃圾收集语言并不免于内存泄漏。数据结构仍然可能无限增长,未解决的 goroutine 仍然可能累积,甚至未停止的 time.Ticker 值也可能失控。

在本节中,我们将回顾一些特定于 Go 语言的内存泄漏的常见原因,以及如何解决它们。

泄漏的 goroutine

我并不知道关于这个问题的任何实际数据,¹² 但基于我个人的经验,我非常怀疑 goroutine 是 Go 中内存泄漏的主要来源之一。

每当执行一个 goroutine 时,它最初会分配一个小的内存堆栈 —— 2048 字节,可以根据其运行时的需求动态调整大小。确切的最大堆栈大小取决于许多因素,¹³ 但它基本上反映了可用物理内存的量。

通常情况下,当 goroutine 返回时,其堆栈要么被释放,要么被设置为待回收状态。¹⁴ 然而,无论是设计上的还是意外的,实际上并不是每个 goroutine 都会返回。例如:

func leaky() {
    ch := make(chan string)

    go func() {
        s := <-ch
        fmt.Println("Message:", s)
    }()
}

在前面的示例中,leaky 函数创建一个通道并执行一个 goroutine 从该通道读取。leaky 函数返回时没有错误,但是如果你仔细观察,你会发现从未向 ch 发送任何值,因此该 goroutine 永远不会返回,其堆栈也永远不会被释放。甚至会有一些副作用:因为 goroutine 引用了 ch,垃圾收集器无法清理该值。

所以现在我们确实有一个真正的内存泄漏。如果这样的函数经常被调用,消耗的内存总量将会随着时间的推移而慢慢增加,直到完全耗尽。

这是一个人为构建的例子,但有很多原因使程序员可能希望创建长时间运行的 goroutine,因此通常很难知道这样的过程是否是故意创建的。

那么我们该怎么办?Dave Cheney 在这里提供了一些很好的建议:“在不知道 goroutine 如何结束时,你绝对不应该启动 goroutine…每次在程序中使用 go 关键字启动 goroutine 时,你必须知道该 goroutine 如何以及何时退出。如果你不知道答案,那就可能会导致内存泄漏。”¹⁵

这可能看起来像是显而易见的,甚至是琐碎的建议,但这非常重要。编写泄漏 goroutine 的函数非常容易,而这些泄漏可能很难识别和找出。

永远滴答作响的 Ticker

在你的 Go 代码中,经常会希望添加某种时间维度,以便将其在将来的某个时间点执行,或者以某个间隔重复执行。

time 包提供了两个有用的工具来为 Go 代码执行添加时间维度: time.Timer,它在将来的某个时间点触发;以及 time.Ticker,它在指定的间隔重复触发。

然而,time.Timer 有一个有限的有效生命周期,有明确定义的开始和结束;而 time.Ticker 则没有这样的限制。time.Ticker 可以永远存在。也许你可以看到这样的情况会导致什么结果。

定时器和 Ticker 使用了类似的机制:每个都提供一个通道,在每次触发时发送一个值。以下示例同时使用了两者:

func timely() {
    timer := time.NewTimer(5 * time.Second)
    ticker := time.NewTicker(1 * time.Second)

    done := make(chan bool)

    go func() {
        for {
            select {
            case <-ticker.C:
                fmt.Println("Tick!")
            case <-done:
                return
            }
        }
    }()

    <-timer.C
    fmt.Println("It's time!")
    close(done)
}

timely 函数执行一个 goroutine,通过监听来自 ticker 的信号——每秒发生一次——或者来自返回 goroutine 的 done 通道,定期循环。 <-timer.C 这一行将阻塞,直到 5 秒计时器触发,允许关闭 done,触发 case <-done 条件并结束循环。

timely 函数按预期完成,并且 goroutine 有一个定义的返回,因此你可能会以为一切都没问题。但这里有一个非常隐匿的 bug:运行 time.Ticker 的值包含一个无法清理的活动 goroutine。因为我们从未停止计时器,所以 timely 包含了一个内存泄漏。

解决方法:始终确保停止你的定时器。defer 对此目的非常有效:

func timelyFixed() {
    timer := time.NewTimer(5 * time.Second)
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()                         // Be sure to stop the ticker!

    done := make(chan bool)

    go func() {
        for {
            select {
            case <-ticker.C:
                fmt.Println("Tick!")
            case <-done:
                return
            }
        }
    }()

    <-timer.C
    fmt.Println("It's time!")
    close(done)
}

通过调用 ticker.Stop(),我们关闭了底层的 Ticker,使其可以被垃圾回收器回收,并防止泄漏。

在效率上

在本节中,我们涵盖了一些常见的方法,用于改进程序的效率,从使用 LRU 缓存而不是映射以限制缓存的内存占用,到有效同步进程的方法,再到防止内存泄漏的方法。虽然这些部分可能看起来并不特别密切相关,但它们对构建可扩展的程序都是重要的。

当然,还有无数其他的方法,我本想也想包括进来,但由于时间和空间的基本限制,未能如愿。

在下一节中,我们将再次改变主题,讨论一些常见的服务架构及其对可扩展性的影响。虽然这些可能与 Go 特别相关性不大,但在云原生环境中,它们对可扩展性的研究至关重要。

服务架构

微服务的概念首次出现在 2010 年代初期,作为对早期面向服务架构(SOA)的改进和简化,同时也是对当时最常见的服务器端应用程序的反应——即包含在单个大型可执行文件中的单体架构。¹⁶

当时,微服务架构的想法——一个由多个小服务组成的单一应用程序,每个服务在自己的进程中运行并通过轻量级机制进行通信——是革命性的。与单体架构不同,后者要求重新构建和部署整个应用程序以实现任何系统变更,微服务可以通过完全自动化的部署机制独立部署。这听起来可能微不足道,甚至琐碎,但其影响是(并且仍然是)巨大的。

如果你问大多数程序员来比较单体和微服务,你可能会得到的大多数答案是关于单体运行速度慢、笨重,而微服务则小巧、灵活且是新宠。然而,一概而论总是不准确的,因此让我们花点时间思考一下这是否正确,以及单体有时是否可能是正确的选择。

我们将首先定义我们所说的单体和微服务。

单体系统架构

单体架构中,一个服务的所有功能上的区别都耦合在一起。一个常见的例子是 Web 应用程序,其用户界面、数据层和业务逻辑通常混合在一起,往往在单个服务器上。

传统上,企业应用程序主要由三个部分构建,如图 7-3 所示:运行在用户机器上的客户端界面,包含所有应用数据的关系数据库,以及处理所有用户输入、执行所有业务逻辑并读写数据库数据的服务器端应用程序。

cngo 0703

图 7-3. 在单体架构中,一个服务的所有功能上的区别都耦合在一起。

当时,这种模式是合理的。所有业务逻辑在单一进程中运行,使开发更加简单,甚至可以通过在负载均衡器后运行更多的单体来扩展,通常使用粘性会话以维持服务器关联性。一切都非常好,多年来这一直是构建 Web 应用程序的最常见方式。

即使在今天,对于相对较小或简单的应用程序(对于“小”和“简单”的某些定义),这种方式也非常有效(尽管我仍然强烈推荐无状态性而不是服务器关联性)。

然而,随着单体架构中功能数量和复杂性的增加,开始出现了一些困难:

  • 单体应用通常作为单一的构件部署,因此即使是做出小的更改,通常也需要构建、测试和部署整个单体的新版本。

  • 即使有着最好的意图和努力,单体代码随着时间推移往往会降低模块化,使得在一个服务的一部分进行更改而不会以意外的方式影响另一部分变得更加困难。

  • 扩展应用程序意味着创建整个应用程序的副本,而不仅仅是需要扩展的部分。

单体应用程序越大越复杂,这些效果就越明显。到了 2000 年代早期到中期,这些问题是众所周知的,导致沮丧的程序员尝试将他们的大型复杂服务分解为更小、可以独立部署和可扩展的组件。到了 2012 年,这种模式甚至有了一个名字:微服务架构。

微服务系统架构

微服务架构的定义特征是将其功能组件划分为一组独立构建、测试、部署和扩展的离散子服务。

这在图 7-4 中有所说明,其中一个用户界面服务——可能是提供 HTML 的 Web 应用程序或公共 API——与客户端进行交互,但不是在本地处理业务逻辑,而是向一个或多个组件服务发出次要请求来处理某些特定功能。这些服务甚至可能会进一步请求更多的服务。

cngo 0704

图 7-4. 在微服务架构中,功能组件被划分为离散的子服务

尽管微服务架构相比单体应用具有许多优势,但也需要考虑重要的成本。一方面,微服务提供了一些显著的好处:

  • 明确定义的责任分离支持和强化了模块化,对于较大或多个团队非常有用。

  • 微服务应该能够独立部署,这使得它们更易管理,可以隔离错误和故障。

  • 在微服务系统中,不同的服务可以使用最适合其功能的技术——语言、开发框架、数据存储等。

不应低估这些好处:微服务的增强模块化和功能隔离通常会产生比具有相同功能的单体应用更易于维护的组件。由此产生的系统不仅更易于部署和管理,而且更易于理解、推理和扩展,适用于更多的程序员和团队。

警告

在理论上混合不同技术可能听起来很吸引人,但要慎重使用。每种技术都增加了对工具和专业知识的新要求。采纳新技术——任何新技术的利弊¹⁷——应始终经过仔细考虑。

微服务的离散性使得它们比单体应用程序更容易维护、部署和扩展。然而,虽然这些确实是可以带来真正回报的真正好处,但也有一些缺点:

  • 微服务的分布性质使它们容易受到分布式计算的谬误的影响(见第四章),这使得它们在编程和调试时变得更加困难。

  • 在服务之间共享任何形式的状态通常会非常困难。

  • 部署和管理多个服务可能非常复杂,并且往往需要高水平的运营成熟度。

因此,在这些因素的基础上,你会选择哪个?单体架构的相对简单性,还是微服务的灵活性和可扩展性?你可能已经注意到,大多数微服务的好处在于应用程序变得更大或者工作在其上的团队数量增加时才会显现出来。因此,许多作者建议先从单体架构开始,然后再进行分解。

个人观点上,我要提一下,我从未见过任何组织成功地拆分一个大型单体系统,但我见过很多尝试这样做的。这并不意味着不可能,只是很难。我不能告诉你应该从微服务开始构建你的系统,还是先用单体架构然后再进行拆分。如果我试图这么做,肯定会收到很多愤怒的邮件。但无论你做什么,请保持无状态。

无服务器架构

无服务器计算是 Web 应用程序架构中一个非常流行的话题,关于它已经消耗了大量的(数字)墨水。很多这种炒作是由主要的云提供商推动的,它们在无服务器方面投入了大量资源,但并非全部。

但无服务器计算,究竟是什么呢?

嗯,通常情况下,这要看你问的是谁。然而,就本书的目的而言,我们将其定义为一种实用计算形式,在这种形式中,由程序员编写的一些服务器端逻辑会在预定义的触发器下透明地在托管的短暂环境中执行。这有时也被称为“函数即服务”或“FaaS”。所有主要的云提供商都提供了 FaaS 实现,例如 AWS 的 Lambda 或 GCP 的 Cloud Functions。

这些函数非常灵活,可以有用地整合到许多架构中。事实上,正如我们将很快讨论的那样,甚至可以构建完全不使用传统服务,而完全由 FaaS 资源和第三方托管服务构建的无服务器架构

无服务器计算的利与弊

和任何其他架构决策一样,选择部分或完全无服务器架构应该仔细权衡所有可用选项。尽管无服务器提供了一些明显的好处(一些是显而易见的(无需管理服务器!),其他一些则不那么明显(成本和能源节约)),但它与传统架构非常不同,并且具有自己的一系列缺点。

话虽如此,让我们开始权衡一下。让我们从优势开始:

运营管理

无服务器架构最明显的好处可能就是减少了操作开销。¹⁹ 没有需要规划和维护的服务器,也不需要购买许可证或安装软件。

可扩展性

使用无服务器函数时,负责根据需求扩展容量的是提供商而不是用户。因此,实施者可以花费较少的时间和精力来考虑和实现扩展规则。

降低成本

FaaS 提供商通常采用“按需付费”的模式,仅在运行函数时分配时间和内存时收费。这比部署传统服务到(可能是低效利用的)服务器要节省成本得多。

生产力

在 FaaS 模型中,工作单位是事件驱动函数。这种模型倾向于鼓励“首先考虑函数”的思维方式,导致的代码通常更简单、更易读,也更容易测试。

然而,并不是一切都是如此美好。无服务器架构确实存在一些真正的缺点,也需要考虑到:

启动延迟

当首次调用函数时,它必须由云提供商“启动”。这通常需要不到一秒钟,但在某些情况下,可能会为初始请求增加 10 秒或更多的延迟。这被称为冷启动延迟。此外,如果函数在几分钟内没有被调用——不同的供应商有所不同——则会被提供商“关闭”,因此当再次调用时就必须经历另一次冷启动。如果您的函数没有足够的空闲时间而被关闭,则通常不会成为问题,但如果您的负载特别“突发”,这可能是一个重大问题。

可观察性

尽管大多数云供应商为其 FaaS 服务提供了一些基本的监控功能,但通常相当基础。虽然第三方提供商一直在努力填补这一空白,但你的临时函数提供的数据质量和数量通常都不如人意。

测试

对于无服务器函数来说,单元测试通常比较简单,但集成测试却相当困难。模拟无服务器环境往往是困难的,甚至不可能,而模拟的效果也很难保证。

成本

虽然在需求较低时,“按需付费”的模式可能会更加便宜,但在某一点上,这种说法就不再成立了。事实上,非常高的负载水平可能变得相当昂贵。

显然,双方都有很多需要考虑的问题,而且尽管目前对无服务器的炒作很多,我认为这在某种程度上是有道理的。然而,虽然无服务器承诺(并且在很大程度上实现了)可扩展性和降低成本,但它确实有很多陷阱,包括但不限于测试和调试的挑战。更不用说对可观察性的运维负担增加了!²⁰

最后,正如我们将在下一节中看到的那样,无服务器架构也需要比传统架构更多的前期规划。虽然有些人可能认为这是一个积极的特点,但它确实增加了显著的复杂性。

无服务器服务

正如前面提到的,函数即服务(FaaS)足够灵活,可以作为完全不使用传统服务的整个无服务器架构的基础,而是完全基于 FaaS 资源和第三方托管服务构建。

举个例子,让我们以熟悉的三层系统为例,在这个系统中,客户端向服务发出请求,服务与数据库交互。一个很好的例子是我们在第五章开始的键值存储,其(尽管原始)单片架构可能看起来像图 7-5 所示。

cngo 0705

图 7-5. 我们的原始键/值存储的单片架构

要将此单体架构转换为无服务器架构,我们需要使用API 网关:这是一个托管服务,配置为公开特定的 HTTP 端点,并将每个端点的请求定向到特定资源——通常是一个 FaaS 函数——该函数处理请求并发出响应。使用这种架构,我们的键/值存储可能看起来像图 7-6 所示。

cngo 0706

图 7-6. API 网关将 HTTP 调用路由到无服务器处理程序函数

在这个例子中,我们用 API 网关替换了单体架构,该网关支持三个端点:GET /v1/{key}, PUT /v1/{key}, 和 DELETE /v1/{key}{key}组件表示此路径将匹配任何字符串,并将其引用为key)。

API 网关被配置为将其三个端点的请求定向到不同的处理函数——getKeyputKeydeleteKey,分别执行处理该请求和与后备数据库交互的所有逻辑。

当然,这是一个非常简单的应用程序,并没有考虑到诸如身份验证之类的事情(可以由许多优秀的第三方服务提供,如 Auth0 或 Okta),但有些事情是显而易见的。

首先,有更多的移动部件需要你深入了解,这就需要相当多的前期规划和测试。例如,如果处理函数中出现错误会发生什么?请求会怎么样?它会被转发到其他目标,还是可能被发送到死信队列以供进一步处理?

不要低估这种复杂性增加的重要性!用分布式、完全托管的组件替换进程内交互,往往会引入各种问题和故障情况,在前者中根本不存在。你很可能已经把一个相对简单的问题变成了一个极其复杂的问题。复杂性会导致失败;简单性则能实现扩展。

其次,由于所有这些不同的组件,需要比单体或小型微服务系统更复杂的分布式监控。由于 FaaS 极大地依赖云提供商,这可能会有挑战,或者至少会显得尴尬。

最后,FaaS 的短暂性意味着所有状态,甚至像缓存这样的短期优化,都必须外部化到数据库、外部缓存(如 Redis)或网络文件/对象存储(如 S3)。同样,可以争论这是一件好事,但这确实增加了前期的复杂性。

摘要

写这一章非常困难,不是因为没有太多可以说的,而是因为可扩展性是一个如此庞大的主题,涉及到许多不同的内容我可以深入探讨。每一个这些内容在我的脑海中激烈地较量了好几个星期。

我甚至最终放弃了一些完全不错的架构内容,回过头来看,它们对本书来说简直不合适。幸运的是,我能够挽救一大块关于消息传递的工作,最终移入了第八章。我想它在那里更加合适。

在那几周里,我花了大量时间思考可扩展性的真正含义,以及效率在其中所起的作用。最终,我认为决定在解决扩展问题时花费大量时间在编程方案上——而不是基础设施方案上——是正确的。

总而言之,我认为最终的结果是不错的。我们确实涵盖了很多内容:

  • 我们审查了扩展的不同方向,以及扩展出去通常是最佳的长期策略。

  • 我们讨论了状态和无状态,以及应用状态为何是“反可扩展性”的本质。

  • 我们学到了一些高效的内存缓存策略,以及避免内存泄漏的方法。

  • 我们比较并对比了单片、微服务和无服务器架构。

那确实很多,虽然我希望能更详细地深入探讨一些问题,但我很高兴至少能触及到我所触及的内容。

¹ Kanat-Alexander, Max. Code Simplicity: The Science of Software Design. O’Reilly Media, 2012 年 3 月 23 日。

² 老实说,如果我们已经实现了自动缩放,我可能根本不会记得发生了这件事。

³ 如果你想了解更多关于云原生基础设施和架构的信息,已经有许多优秀的书籍写成。我特别推荐 Justin Garrison 和 Kris Nova 的 Cloud Native Infrastructure,以及 Pini Reznik、Jamie Dobson 和 Michelle Gienow 的 Cloud Native Transformation(均由 O’Reilly Media 出版)。

⁴ 这是我的定义。我承认它与其他常见的定义有所不同。

⁵ 一些云服务提供商对较小的实例施加了较低的网络 I/O 限制。在某些情况下,增加实例的大小可能会增加这些限制。

⁶ 如果你有更好的定义,请告诉我。我已经在考虑第二版了。

⁷ 我知道我在那里说了“状态”这个词很多次。写作真的很难。

⁸ 另见:幂等性。

⁹ 然而,如果你对在 Go 中实现高性能缓存感兴趣,请看 Manish Rai Jain 在 Dgraph Blog 上关于该主题的出色文章,“Go 缓存的现状”。

¹⁰ Gerrand, Andrew. “通过通信共享内存。” Go 博客, 2010 年 7 月 13 日。https://oreil.ly/GTURp 本节部分内容基于 Google 的作品进行了修改和 分享,并根据 Creative Commons 4.0 归属许可协议 使用。

¹¹ 你可能可以把通道硬塞到与缓存交互的解决方案中,但你可能会发现这比锁定更难以简化。

¹² 如果你有兴趣,告诉我!

¹³ Dave Cheney 写了一篇关于这个主题的优秀文章,题为 为什么 Goroutine 的堆栈是无限的?,如果你对 Goroutine 内存分配的动态性感兴趣,我建议你去看看。

¹⁴ 有一篇由 Vincent Blanchon 撰写的关于 Goroutine 回收的很好的文章,题为 Go 如何回收 Goroutines?

¹⁵ Cheney, Dave. “不要在不知道如何停止的情况下启动 Goroutine。” dave.cheney.net, 2016 年 12 月 22 日。https://oreil.ly/VUlrY

¹⁶ 并不是它们已经消失了。

¹⁷ 是的,即使是在 Go 中也是如此。

¹⁸ Bowers, Daniel 等人。“2019 年计算基础设施技术成熟周期。”Gartner,Gartner 研究,2019 年 7 月 26 日,https://oreil.ly/3gkJh

¹⁹ 这可是名副其实的名字!

²⁰ 对不起,不存在所谓的 NoOps。

第八章:松散耦合

我们建造计算机的方式,就像我们建造城市一样——随着时间的推移,没有计划,在废墟上建造。

Ellen Ullman,《程序员的愚蠢化》(1998 年 5 月)

耦合是一种在理论上看起来很简单,但在实践中却非常具有挑战性的迷人主题之一。正如我们将讨论的那样,系统中引入耦合的方式有很多,这意味着它也是一个重要的主题。你可能想象得到,这一章是一个雄心勃勃的章节,我们将涵盖很多内容。

首先,我们将介绍这个主题,深入探讨“耦合”的概念,并讨论“松散”与“紧”耦合的相对优点。我们将介绍一些最常见的耦合机制,以及某些类型的紧耦合如何导致可怕的“分布式单块”。

接下来,我们将讨论服务间通信,以及脆弱的交换协议是引入分布式系统紧耦合的常见方式之一。我们将介绍今天使用的一些常见协议,以尽量减少两个服务之间的耦合程度。

在第三部分中,我们将稍微改变方向,远离分布式系统,转向服务本身的实现。我们将讨论服务作为代码实体,由于实现混合和违反关注点分离而导致的耦合,并介绍插件作为动态添加实现的一种方法。

最后,我们将讨论六边形架构,这是一种架构模式,将松散耦合作为其设计哲学的核心支柱。

在整章中,我们将尽力平衡理论、架构和实施。大部分章节将花费在有趣的内容上:讨论各种管理耦合的策略,特别是(但不仅限于)在分布式环境中,并通过扩展我们的示例键/值存储来演示。

紧耦合

“耦合”是描述组件之间直接知识程度的一种浪漫术语。例如,向服务发送请求的客户端从定义上来说与该服务是耦合的。然而,耦合的程度可以差异很大,从两个极端之间的任何地方都有可能。

“紧耦合”组件对另一个组件有大量了解。也许两者需要相同版本的共享库才能进行通信,或者客户端需要了解服务器的架构或数据库架构。在短期优化时很容易构建紧耦合系统,但它们有一个巨大的缺点:两个组件之间的耦合越紧密,一个组件的更改就越可能需要对另一个组件进行相应的更改。因此,紧耦合系统失去了微服务架构的许多优势。

相比之下,“松散耦合”的组件对彼此的直接了解很少。它们相对独立,通常通过具有变更鲁棒性的抽象进行交互。设计为松散耦合的系统需要更多的前期规划,但可以更自由地升级、重新部署,甚至完全重写,而不会对依赖于它们的系统产生很大影响。

简而言之,如果想知道你的系统有多紧密地耦合,就问问某个组件可以进行多少种和什么类型的更改,而不会对其他组件产生不利影响。

注意

一定程度的耦合并不一定是坏事,特别是在系统早期的阶段。过度抽象和复杂化很容易陷入,但过早优化仍然是万恶之源。

Tight Coupling Takes Many Forms

在分布式系统中,组件之间的紧耦合表现形式多种多样。然而,它们都有一个根本性缺陷——依赖另一个组件的某种属性,错误地假设这种属性不会变化。大多数情况下,可以将它们归为几个广泛的类别,根据它们耦合的资源。

脆弱的交换协议

还记得 SOAP(简单对象访问协议)吗?统计数据显示,可能不太记得了²。SOAP 是在 1990 年代末开发的消息传递协议,旨在实现可扩展性和实现中立性。SOAP 服务提供了一个合同,客户端可以遵循该合同格式化他们的请求³。合同的概念在当时是一种突破,但 SOAP 的实现过于脆弱:如果合同以任何方式改变,客户端必须随之更新。这一要求意味着 SOAP 客户端与其服务紧密耦合。

人们很快意识到这是一个问题,并且 SOAP 迅速失去了它的光芒。自此以后,REST 在很大程度上取代了它,虽然有了显著的改进,但往往也会引入自己的紧耦合。2016 年,Google 发布了 gRPC(gRPC 远程过程调用⁴),这是一个开源框架,具有许多有用的特性,其中包括允许组件之间的松耦合关系,这一点尤为重要。

我们将在“服务之间的通信”中讨论一些更为当代的选项,看看如何使用 Go 的net/http包构建 REST/HTTP 客户端,并通过 gRPC 前端扩展我们的键/值存储。

共享依赖

2016 年,Facebook 的 Ben Christensen 在微服务从业者峰会上发表了演讲,讨论了另一种越来越常见的用于紧密耦合分布式服务的机制,并在过程中引入了“分布式单体”的术语。

本书中描述了一个反模式,即服务必须使用特定的库和库版本才能启动和相互交互。这些系统发现它们被一个全局依赖所累,因此更新这些共享库可能迫使所有服务同步升级。这种共享依赖紧密耦合了整个服务群。

共享的瞬时点

往往系统设计成这样,客户端期望服务立即响应。使用这种请求-响应消息模式的系统隐含地假设一个服务存在并随时准备好快速响应。但如果没有准备好,请求将失败。可以说它们在时间上耦合

时间耦合并不一定是不良实践,有时甚至是可取的,特别是当人类等待及时响应时。我们甚至详细说明了如何在“请求-响应消息”部分构建这样的客户端。

但如果响应不一定受时间限制,那么一个更安全的方法可能是将消息发送到一个中间队列,接收者可以在准备好时从中检索,这种消息模式通常称为发布-订阅消息(简称“pub-sub”)。

固定地址

微服务需要互相通信是其本质。但要做到这一点,它们首先必须找到彼此。在网络上定位服务的这一过程称为服务发现

传统上,服务位于相对固定、众所周知的网络位置,可以通过引用某些集中注册表来发现。最初这是通过手动维护的hosts.txt文件实现的,但随着网络规模的扩大,DNS 和 URL 的采用也在增加。

传统 DNS 适用于生命周期较长的服务,其网络位置变化不大,但短暂的基于微服务的应用程序的流行增加,服务实例的生命周期通常以秒或分钟计量,而不是以月或年计量。在这样动态的环境中,URL 和传统 DNS 变成了紧密耦合的另一种形式。

这种对动态、流动服务发现的需求推动了完全新策略的采用,比如服务网格,这是一个专门的层,用于促进分布式系统中资源之间的服务到服务通信。

注意

不幸的是,本书无法涵盖服务发现或服务网格等迷人且快速发展的主题。但服务网格领域非常丰富,有许多成熟的开源项目和活跃的社区,比如EnvoyLinkerdIstio,以及像Hashicorp’s Consul这样的商业产品。

服务之间的通信

在分布式系统中,通信和消息传递是关键功能,所有分布式系统都依赖于某种形式的消息传递来接收指令和方向,交换信息,并提供结果和更新。当然,如果接收者无法理解消息,则消息就毫无意义。

为了使服务能够通信,它们必须首先建立一个隐式或显式的契约,定义消息的结构方式。尽管这样的契约是必要的,但它也有效地耦合了依赖它的组件。

以这种方式引入紧耦合其实非常容易,其程度反映在协议安全更改的能力上。例如,是否允许向后和向前兼容的更改,如协议缓冲区和 gRPC,或者即使是契约的小改动也会有效地破坏通信,就像 SOAP 的情况一样?

当然,数据交换协议及其契约并非跨服务通信的唯一变量。事实上,消息传递模式可以大致分为两类:

请求-响应(同步)

双向消息交换是一种请求者(客户端)向接收者(服务)发出请求并等待响应的过程。一个经典的例子是 HTML。

发布-订阅(异步)

单向消息交换是指请求者(发布者)将消息发送到事件总线或消息交换中心,而不是直接发送给特定的接收者。消息可以异步检索,并由一个或多个服务(订阅者)处理。

每种模式都有各种实现和特定的用例,各自具有优缺点。虽然我们无法涵盖每一个可能的细微差别,但我们将尽力提供可用的调查结果以及它们在 Go 语言中的实现方向。

请求-响应消息传递

如其名称所示,使用请求-响应同步消息传递模式的系统通过协调的请求和响应进行通信,其中请求者(或客户端)向接收者(或服务)提交请求,并等待接收者响应(希望)所请求的数据或服务(见图 8-1)。

这种模式最明显的例子可能是 HTTP,它如此普遍和成熟,以至于已被扩展到超出其原始目的,并且现在支持常见的消息传递协议,如 REST 和 GraphQL。

cngo 0801

图 8-1. 使用请求-响应消息传递模式的系统通过一系列协调的请求和响应进行通信

请求-响应模式的优点在于相对容易理解和实现,长期以来被认为是默认的消息传递模式,特别适用于公共服务。但它也是“点对点”的,涉及精确的一个请求者和接收者,并要求请求过程暂停,直到收到响应。

总的来说,这些特性使得请求-响应模式成为两个端点之间进行简单交换的良好选择,其中可以合理预期在相对较短的时间内获得响应,但在消息必须发送给多个接收者或响应可能比请求者愿意等待的时间更长的情况下则不是最理想的选择。

常见的请求-响应实现方式

多年来,为了各种目的开发了大量定制的请求-响应协议。随着时间的推移,这种情况已经大体上稳定下来,为三个主要实现方式让路。

REST

你可能已经非常熟悉 REST,我们在 “使用 net/http 构建 HTTP 服务器” 中详细讨论过。REST 有一些优点。它易于阅读和实现,使其成为面向外部服务的良好选择(这也是我们在 第五章 中选择它的原因)。我们将在 “使用 net/http 发出 HTTP 请求” 中进一步讨论。

远程过程调用(RPC)

远程过程调用(RPC)框架允许程序在不同的地址空间执行过程,通常是在另一台计算机上。Go 提供了一个标准的 Go 特定的 RPC 实现,即 net/rpc。还有两个重要的语言无关的 RPC 框架:Apache Thrift 和 gRPC。虽然设计和使用目标相似,但 gRPC 在采用和社区支持方面似乎处于领先地位。我们将在 “使用 gRPC 进行远程过程调用” 中详细讨论 gRPC。

GraphQL

GraphQL 是一个相对较新的技术,是一种查询和操作语言,通常被认为是 REST 的替代方案,在处理复杂数据集时特别强大。我们在本书中没有详细讨论 GraphQL,但我鼓励你在下次设计面向外部的 API 时 深入了解

使用 net/http 发出 HTTP 请求

HTTP 可能是最常见的请求-响应协议,特别是针对面向公众服务的情况,支持流行的 API 格式,如 REST 和 GraphQL。如果你正在与 HTTP 服务进行交互,你需要一种方式来以编程方式发出请求并获取响应。

幸运的是,Go 标准库提供了出色的 HTTP 客户端和服务器实现,即 net/http 包。你可能还记得我们在 “使用 net/http 构建 HTTP 服务器” 中使用它构建了我们的键/值存储的第一次迭代。

net/http 包含了 GET、HEAD 和 POST 方法的便捷函数,以及其他内容。首先展示的是 http.Gethttp.Head 的签名:

// Get issues a GET to the specified URL
func Get(url string) (*http.Response, error)

// Head issues a HEAD to the specified URL
func Head(url string) (*http.Response, error)

前面的函数非常简单直接,两者的使用方式类似:每个都接受一个表示感兴趣的 URL 的string,并且每个都返回一个error值和一个指向http.Response结构体的指针。

http.Response结构体特别有用,因为它包含了有关服务对我们请求的响应的各种有用信息,包括返回的状态码和响应体。

http.Response结构体的一个小选择如下所示:

type Response struct {
    Status     string       // e.g. "200 OK"
    StatusCode int          // e.g. 200

    // Header maps header keys to values.
    Header Header

    // Body represents the response body.
    Body io.ReadCloser

    // ContentLength records the length of the associated content. The
    // value -1 indicates that the length is unknown.
    ContentLength int64

    // Request is the request that was sent to obtain this Response.
    Request *Request
}

里面有一些有用的东西!特别感兴趣的是Body字段,它提供对 HTTP 响应体的访问。它是一个ReadCloser接口,告诉我们两件事情:响应体在需要时按需流式传输,它有一个我们期望调用的Close方法。

在接下来的示例中,我们演示了几个事情:如何使用Get便捷函数,如何关闭响应体,并且如何使用io.ReadAll来读取整个响应体作为字符串(如果你对这种事情感兴趣的话):

package main

import (
    "fmt"
    "io"
    "net/http"
)

func main() {
    resp, err := http.Get("http://example.com")     // Send an HTTP GET
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()                         // Close your response!

    body, err := io.ReadAll(resp.Body)          // Read body as []byte
    if err != nil {
        panic(err)
    }

    fmt.Println(string(body))
}

在最后一个示例中,我们使用http.Get函数来发出对 URL http://example.com 的 GET 请求,它返回一个指向http.Response结构体的指针和一个error值。

正如我们之前提到的,通过resp.Body变量提供对 HTTP 响应体的访问,它实现了io.ReadCloser。注意我们如何使用defer延迟调用resp.Body.Close()。这非常重要:不关闭你的响应体有时可能会导致一些不幸的内存泄漏。

因为Body实现了io.Reader,我们有许多不同的标准方法来检索其数据。在这种情况下,我们使用非常可靠的io.ReadAll,它方便地将整个响应体作为[]byte切片返回,我们简单地打印出来。

警告

总是记得使用Close()来关闭你的响应体!

如果不这样做,可能会导致一些不幸的内存泄漏。

我们已经看过GetHead函数,但是我们如何发出 POST 请求呢?幸运的是,它们也有类似的便捷函数。实际上有两个:http.Posthttp.PostForm。它们各自的签名如下所示:

// Post issues a POST to the specified URL
func Post(url, contentType string, body io.Reader) (*Response, error)

// PostForm issues a POST to the specified URL, with data's keys
// and values URL-encoded as the request body
func PostForm(url string, data url.Values) (*Response, error)

其中第一个函数Post期望一个提供请求体的io.Reader,例如 JSON 对象的文件。我们展示了如何在以下代码中上传 JSON 文本作为 POST:

package main

import (
    "fmt"
    "io"
    "net/http"
    "strings"
)

const json = `{ "name":"Matt", "age":44 }`      // This is our JSON

func main() {
    in := strings.NewReader(json)               // Wrap JSON with an io.Reader

    // Issue HTTP POST, declaring our content-type as "text/json"
    resp, err := http.Post("http://example.com/upload", "text/json", in)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()                     // Close your response!

    message, err := io.ReadAll(resp.Body)
    if err != nil {
        panic(err)
    }

    fmt.Printf(string(message))
}

使用 gRPC 进行远程过程调用

gRPC 是一个高效的、多语言的数据交换框架,最初由 Google 开发,作为Stubby的继任者,Stubby 是一个通用的 RPC 框架,在 Google 内部使用了超过一个 decade。它在 2015 年以 gRPC 的名称开源,并于 2017 年被 Cloud Native Computing Foundation 接管。

与 REST 不同,后者本质上是一组不受强制执行的最佳实践,gRPC 是一个功能齐全的数据交换框架,类似于其他 RPC 框架(如 SOAP、Apache Thrift、Java RMI 和 CORBA 等),允许客户端执行在不同系统上实现的特定方法,就像它们是本地函数一样。

这种方法与 REST 相比有许多优势,包括但不限于:

简洁性

其消息更紧凑,消耗较少的网络 I/O。

速度

其二进制交换格式在编组和解组时速度更快。

强类型

gRPC 是原生强类型的,消除了许多样板代码,并消除了常见的错误源。

功能丰富

它具有许多内置功能,如身份验证、加密、超时和压缩(仅举几例),否则您将不得不自行实现。

这并不意味着 gRPC 总是最佳选择。与 REST 相比:

基于契约驱动

gRPC 的契约使其不太适合面向外部的服务。

二进制格式

gRPC 数据不是人类可读的,这使得检查和调试变得更加困难。

提示

gRPC 是一个非常庞大且丰富的主题,这个简短的部分无法完全涵盖。如果您有兴趣了解更多信息,我建议阅读官方的 “gRPC 简介” 和优秀的 gRPC 实战(由 Kasun Indrasiri 和 Danesh Kuruppu 撰写,O’Reilly Media 出版)。

使用协议缓冲定义接口

正如大多数 RPC 框架一样,gRPC 要求您定义一个 服务接口。默认情况下,gRPC 使用 协议缓冲 来实现这一目的,尽管您也可以选择使用其他接口定义语言(IDL),如 JSON。

要定义服务接口,作者使用协议缓冲模式描述可以由客户端远程调用的服务方法,在 .proto 文件中编译为特定语言的接口(在我们的例子中是 Go 代码)。

如图 图 8-2 所示,gRPC 服务器实现生成的源代码以处理客户端调用,而客户端则有一个存根(stub),提供与服务器相同的方法。

cngo 0802

图 8-2. 默认情况下,gRPC 使用协议缓冲作为其接口定义语言和底层消息交换格式;服务器和客户端可以使用 任何支持的语言 编写。

是的,现在看起来似乎非常抽象。继续阅读获取更多细节!

安装协议编译器

在我们继续之前,我们首先需要安装协议缓冲编译器 protoc 和 Go 协议缓冲插件。我们将使用这些工具将 .proto 文件编译成 Go 服务接口代码。

  1. 如果您使用的是 Linux 或 MacOS,安装 protoc 最简单和最容易的方法是使用包管理器。要在 Debian 衍生的 Linux 上安装它,可以使用 aptapt-get

    $ apt install -y protobuf-compiler
    $ protoc --version
    

    在 MacOS 上安装 protoc 的最简单方法是使用 Homebrew:

    $ brew install protobuf
    $ protoc --version
    
  2. 运行以下命令安装 Go protocol buffers 插件:

    $ go install google.golang.org/protobuf/cmd/protoc-gen-go
    

    编译器插件 protoc-gen-go 将被安装在 $GOBIN 中,默认为 $GOPATH/bin。必须将其添加到 $PATH 中,以便 protoc 能够找到它。

警告

本书使用 protocol buffers 版本 3。安装后,请确保检查你的 protoc 版本,确保它是版本 3 或更高。

如果你使用其他操作系统,你选择的包管理器有旧版本,或者你只是想确保拥有最新和最棒的版本,你可以在 gRPC 的Protocol Buffer Compiler Installation page找到安装预编译二进制文件的说明。

消息定义结构

Protocol buffers 是一种语言中立的机制,用于序列化结构化数据。你可以把它看作是 XML 的二进制版本。⁶ Protocol buffer 数据被结构化为消息,每个消息是一个小型信息记录,包含一系列名值对称为字段

使用 protocol buffers 的第一步是通过在 .proto 文件中定义消息结构来定义消息结构。以下是一个基本示例:

示例 8-1. 一个 .proto 文件示例。message 定义定义了远程过程负载。
syntax = "proto3";

option go_package = "github.com/cloud-native-go/ch08/point";

// Point represents a labeled position on a 2-dimensional surface
message Point {
  int32 x = 1;
  int32 y = 2;
  string label = 3;
}

// Line contains start and end Points
message Line {
  Point start = 1;
  Point end = 2;
  string label = 3;
}

// Polyline contains any number (including zero) of Points
message Polyline {
  repeated Point point = 1;
  string label = 2;
}

你可能已经注意到,protocol buffer 语法让人想起了 C/C++,完整的分号和注释语法。

文件的第一行指定你正在使用 proto3 语法:如果你不这样做,protocol buffer 编译器将假定你正在使用 proto2。这必须是文件的第一行,非空且非注释行。

第二行使用 option 关键字指定将包含生成代码的 Go 包的完整导入路径。

最后,我们有三个message定义,描述了有效负载消息的结构。在本示例中,我们有三个复杂度逐步增加的消息:

  • Point 包含 xy 整数值,以及一个 label 字符串

  • Line 包含两个 Point

  • Polyline 使用 repeated 关键字指示它可以包含任意数量的 Point

每个 message 包含零个或多个具有名称和类型的字段。注意,消息定义中的每个字段都有一个唯一的字段号,用于标识消息二进制格式中的字段,一旦消息类型在使用中就不应更改。

如果这在你的脑海中引起了“紧耦合”的红旗,那么你就因关注而获得了一颗金星。因此,protocol buffers 明确支持更新消息类型,包括标记字段为保留字段,以防止意外重用。

这个示例非常简单,但不要让它迷惑:协议缓冲能够进行一些非常复杂的编码。有关更多信息,请参见协议缓冲语言指南

键值消息结构

那么我们是如何利用协议缓冲和 gRPC 来扩展我们在第五章中开始的示例键值存储呢?

假设我们希望实现与我们已经通过 RESTful 方法公开的GetPutDelete函数的 gRPC 等价物。这些的消息格式可能看起来像下面的.proto文件:

示例 8-2。keyvalue.proto—将被传递给我们键值服务程序的消息
syntax = "proto3";

option go_package = "github.com/cloud-native-go/ch08/keyvalue";

// GetRequest represents a request to the key-value store for the
// value associated with a particular key
message GetRequest {
  string key = 1;
}

// GetResponse represents a response from the key-value store for a
// particular value
message GetResponse {
  string value = 1;
}

// PutRequest represents a request to the key-value store for the
// value associated with a particular key
message PutRequest {
  string key = 1;
  string value = 2;
}

// PutResponse represents a response from the key-value store for a
// Put action.
message PutResponse {}

// DeleteRequest represents a request to the key-value store to delete
// the record associated with a key
message DeleteRequest {
  string key = 1;
}

// DeleteResponse represents a response from the key-value store for a
// Delete action.
message DeleteResponse {}
提示

不要让消息定义的名称迷惑你:它们表示消息(名词),这些消息将被传递给我们在下一节中定义的函数(动词)。

在我们称之为keyvalue.proto.proto文件中,我们有三个Request消息定义,描述了将从客户端发送到服务器的消息,以及三个Response消息定义,描述了服务器的响应消息。

你可能已经注意到,我们不包括在消息响应定义中的errorstatus值。正如你在“实现 gRPC 客户端”中看到的,这些是不必要的,因为它们包含在 gRPC 客户端函数的返回值中。

定义我们的服务方法

现在我们已经完成了消息定义,我们将需要描述使用这些消息的方法。

为 为了实现这一点,我们扩展了我们的keyvalue.proto文件,使用rpc关键字来定义我们的服务接口。编译修改后的.proto文件将生成包含服务接口代码和客户端存根的 Go 代码。

示例 8-3。keyvalue.proto—我们键值服务的程序。
service KeyValue {
  rpc Get(GetRequest) returns (GetResponse);

  rpc Put(PutRequest) returns (PutResponse);

  rpc Delete(DeleteRequest) returns (DeleteResponse);
}
提示

与示例 8-2 中定义的消息相比,rpc定义表示将发送和接收消息的函数(动词)。

在这个例子中,我们为我们的服务添加了三个方法:

  • 获取,接受一个GetRequest并返回一个GetResponse

  • 放置,接受一个PutRequest并返回一个PutResponse

  • 删除,接受一个DeleteRequest并返回一个DeleteResponse

请注意,我们在这里实际上并未实现功能。我们稍后会实现。

先前的方法都是单一 RPC定义的示例,其中客户端向服务器发送一个请求并得到一个单一的响应。这是四种服务方法类型中最简单的。各种流模式也被支持,但这些超出了这个简单 primer 的范围。gRPC 文档详细讨论了这些内容。

编译你的协议缓冲

现在您已经有了一个包含消息和服务定义的 .proto 文件,接下来需要做的是生成您需要读写消息的类。为此,您需要在我们的 keyvalue.proto 上运行协议缓冲编译器 protoc

如果您尚未安装 protoc 编译器和 Go 协议缓冲插件,请按照 “安装协议编译器” 中的说明进行安装。

现在您可以运行编译器,指定源目录($SOURCE_DIR,默认为当前目录),目标目录($DEST_DIR,通常与 $SOURCE_DIR 相同),以及 keystore.proto 的路径。因为我们需要生成 Go 代码,所以使用 --go_out 选项。protoc 还提供了生成其他支持语言代码的等效选项。

在这种情况下,我们将调用:

$ protoc --proto_path=$SOURCE_DIR \
    --go_out=$DEST_DIR --go_opt=paths=source_relative \
    --go-grpc_out=$DEST_DIR --go-grpc_opt=paths=source_relative \
    $SOURCE_DIR/keyvalue.proto

使用 go_optgo-grpc_opt 标志告诉 protoc 将输出文件放置在与输入文件相同的相对目录中。我们的 keyvalue.proto 文件将生成两个文件,分别命名为 keyvalue.pb.gokeyvalue_grpc.pb.go

如果没有这些标志,输出文件将放置在一个以 Go 包的导入路径命名的目录中。例如,我们的 keyvalue.proto 文件将生成一个名为 github.com/cloud-native-go/ch08/keyvalue/keyvalue.pb.go 的文件。

实现 gRPC 服务

要实现我们的 gRPC 服务器,我们需要实现生成的服务接口,该接口定义了我们键值服务的服务器 API。它可以在 keyvalue_grpc.pb.go 中找到,命名为 KeyValueServer

type KeyValueServer interface {
    Get(context.Context, *GetRequest) (*GetResponse, error)
    Put(context.Context, *PutRequest) (*PutResponse, error)
    Delete(context.Context, *DeleteRequest) (*PutResponse, error)
}

如您所见,KeyValueServer 接口指定了我们的 GetPutDelete 方法:每个方法接受一个 context.Context 和一个请求指针,并返回一个响应指针和一个 error

提示

由于其简单性的副作用,很容易对 gRPC 服务器实现进行请求和响应的模拟。

要实现我们的服务器,我们将利用一个生成的结构体,该结构体提供了 KeyValueServer 接口的默认实现。在我们的情况下,它的名称是 UnimplementedKeyValueServer。它之所以被命名为“未实现”,是因为它包含了默认的“未实现”版本的所有客户端方法,看起来像以下内容:

type UnimplementedKeyValueServer struct {}

func (*UnimplementedKeyValueServer) Get(context.Context, *GetRequest)
        (*GetResponse, error) {

    return nil, status.Errorf(codes.Unimplemented, "method not implemented")
}

通过嵌入 UnimplementedKeyValueServer,我们能够实现我们的键值 gRPC 服务器。以下代码演示了如何实现 Get 方法。为简洁起见,PutDelete 方法被省略了。

package main

import (
    "context"
    "log"
    "net"

    pb "github.com/cloud-native-go/ch08/keyvalue"
    "google.golang.org/grpc"
)

// server is used to implement KeyValueServer. It MUST embed the generated
// struct pb.UnimplementedKeyValueServer
type server struct {
    pb.UnimplementedKeyValueServer
}

func (s *server) Get(ctx context.Context, r *pb.GetRequest)
        (*pb.GetResponse, error) {

    log.Printf("Received GET key=%v", r.Key)

    // The local Get function is implemented back in Chapter 5
    value, err := Get(r.Key)

    // Return expects a GetResponse pointer and an err
    return &pb.GetResponse{Value: value}, err
}

func main() {
    // Create a gRPC server and register our KeyValueServer with it
    s := grpc.NewServer()
    pb.RegisterKeyValueServer(s, &server{})

    // Open a listening port on 50051
    lis, err := net.Listen("tcp", ":50051")
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    // Start accepting connections on the listening port
    if err := s.Serve(lis); err != nil {
        log.Fatalf("failed to serve: %v", err)
    }
}

在前面的代码中,我们通过四个步骤来实现并启动我们的服务:

  1. 创建服务器结构体。我们的 server 结构体嵌入了 pb.UnimplementedKeyValueServer。这不是可选的:gRPC 要求您的服务器结构体类似地嵌入其生成的 UnimplementedXXXServer

  2. 实现服务方法。 我们实现了生成的 pb.KeyValueServer 接口中定义的服务方法。有趣的是,因为 pb.UnimplementedKeyValueServer 包含了所有这些服务方法的存根,我们不必立即实现它们。

  3. 注册我们的 gRPC 服务器。main 函数中,我们创建了一个 server 结构体的新实例,并将其注册到 gRPC 框架中。这与我们在 “使用 net/http 构建 HTTP 服务器” 中注册处理函数的方式类似,不同之处在于我们注册的是整个实例而不是单个函数。

  4. 开始接受连接。 最后,我们使用 net.Listen 打开一个监听端口⁷,将其通过 s.Serve 传递给 gRPC 框架以开始监听。

可以说 gRPC 提供了两全其美的功能,即提供了实现任何所需功能的自由,而无需担心构建通常与 RESTful 服务相关的许多测试和检查。

实现 gRPC 客户端

由于所有客户端代码都是生成的,使用 gRPC 客户端非常简单。

生成的客户端接口将命名为 XXXClient,在我们的情况下将是 KeyValueClient,如下所示:

type KeyValueClient interface {
    Get(ctx context.Context, in *GetRequest, opts ...grpc.CallOption)
        (*GetResponse, error)

    Put(ctx context.Context, in *PutRequest, opts ...grpc.CallOption)
        (*PutResponse, error)

    Delete(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption)
        (*PutResponse, error)
}

我们在源 .proto 文件中描述的所有方法在这里都有详细说明,每个方法接受一个请求类型指针,并返回一个响应类型指针和一个错误。

此外,每个方法都接受一个 context.Context(如果你对这是什么或者如何使用它感到生疏,可以看看 “Context 包”),以及零个或多个 grpc.CallOption 实例。CallOption 用于在客户端执行调用时修改其行为。可以在 gRPC API 文档 中找到更多详细信息。

我展示了如何在以下内容中创建和使用 gRPC 客户端:

package main

import (
    "context"
    "log"
    "os"
    "strings"
    "time"

    pb "github.com/cloud-native-go/ch08/keyvalue"
    "google.golang.org/grpc"
)

func main() {
    // Set up a connection to the gRPC server
    conn, err := grpc.Dial("localhost:50051",
        grpc.WithInsecure(), grpc.WithBlock(), grpc.WithTimeout(time.Second))
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()

    // Get a new instance of our client
    client := pb.NewKeyValueClient(conn)

    var action, key, value string

    // Expect something like "set foo bar"
    if len(os.Args) > 2 {
        action, key = os.Args[1], os.Args[2]
        value = strings.Join(os.Args[3:], " ")
    }

    // Use context to establish a 1-second timeout.
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    // Call client.Get() or client.Put() as appropriate.
    switch action {
    case "get":
        r, err := client.Get(ctx, &pb.GetRequest{Key: key})
        if err != nil {
            log.Fatalf("could not get value for key %s: %v\n", key, err)
        }
        log.Printf("Get %s returns: %s", key, r.Value)

    case "put":
        _, err := client.Put(ctx, &pb.PutRequest{Key: key, Value: value})
        if err != nil {
            log.Fatalf("could not put key %s: %v\n", key, err)
        }
        log.Printf("Put %s", key)

    default:
        log.Fatalf("Syntax: go run [get|put] KEY VALUE...")
    }
}

前面的示例解析命令行值以确定它应执行 Get 还是 Put 操作。

首先,它使用 grpc.Dial 函数与 gRPC 服务器建立连接,该函数接受一个目标地址字符串,以及一个或多个 grpc.DialOption 参数,用于配置连接的设置。在我们的情况下,我们使用:

  • WithInsecure,这会为此 ClientConn 禁用传输安全性。不要在生产环境中使用不安全的连接。

  • WithBlock,这会使 Dial 在建立连接之前阻塞,否则连接将在后台进行。

  • WithTimeout,如果指定的时间超过指定的时间,会使阻塞的 Dial 抛出错误。

接下来,它使用 NewKeyValueClient 获取一个新的 KeyValueClient,并获取各种命令行参数。

最后,基于 action 值,我们调用 client.Getclient.Put,两者都会返回适当的返回类型和一个错误。

再次强调,这些函数看起来和感觉上完全像本地函数调用。不需要检查状态码、手动构建自己的客户端或其他任何奇怪的操作。

使用插件实现本地资源的松耦合

乍一看,本地资源的松耦合——与远程或分布式资源相对——的主题似乎对“云原生”技术的讨论大多不相关。但你可能会惊讶地发现,这种模式经常会派上用场。

例如,构建能够接受来自不同类型输入源(如 REST 接口、gRPC 接口和聊天机器人接口)数据或生成不同类型输出(如生成不同类型日志或度量格式)的服务或工具通常非常有用。作为额外的奖励,支持这种模块化的设计也可以使为测试模拟资源变得非常简单。

正如我们将在“六边形架构”中看到的那样,甚至可以建立整个软件架构来围绕这个概念。

没有对插件技术的讨论可以完整无缺地进行而不回顾。

使用 plugin 包的进程内插件

Go 提供了一种原生的插件系统,以标准 plugin 的形式存在。该包用于打开和访问 Go 插件,但实际上并不需要用它来构建插件本身。

正如我们将在接下来的内容中演示的那样,构建和使用 Go 插件的要求非常简单。它甚至不必知道自己是插件,甚至不需要导入 plugin 包。一个 Go 插件有三个真正的要求:必须位于 main 包中,必须导出一个或多个函数或变量,并且必须使用 -buildmode=plugin 构建标志编译。就是这样。

插件词汇

在继续之前,我们需要定义一些与插件特定的术语。以下每个术语描述了特定的插件概念,并在 plugin 包中有相应的类型或函数实现。我们将在示例中更详细地介绍这些内容。

插件

插件 是一个使用 -buildmode=plugin 构建标志构建的 Go main 包,具有一个或多个导出函数和变量。在 plugin 包中,它由 Plugin 类型表示。

打开

打开 一个插件是将其加载到内存中、验证它并发现其暴露的符号的过程。可以使用 Open 函数打开文件系统中已知位置的插件,它返回一个 *Plugin 值:

func Open(path string) (*Plugin, error)

符号

插件 符号 是由插件包导出的任何变量或函数。可以通过“查找”来检索符号,它们在 plugin 包中由 Symbol 类型表示:

type Symbol interface{}

查找

查找 描述了搜索和检索由插件暴露的符号的过程。plugin 包的 Lookup 方法提供了该功能,并返回一个 Symbol 值:

func (p *Plugin) Lookup(symName string) (Symbol, error)

在下一节中,我们将呈现一个玩具示例,展示了这些资源如何被使用,并深入探讨了这个过程中的一些细节。

一个玩具插件示例

从 API 的审查中我们只能学到这么多,即使是像plugin包这样的极简包。因此,让我们建立一个玩具示例:一个程序,通过插件实现告诉您有关各种动物的信息。⁸

在本示例中,我们将创建三个独立的包,具有以下包结构:

~/cloud-native-go/ch08/go-plugin
├── duck
│   └── duck.go
├── frog
│   └── frog.go
└── main
    └── main.go

duck/duck.gofrog/frog.go 文件分别包含一个插件的源代码。main/main.go 文件包含我们示例中的main函数,该函数将加载并使用我们通过构建frog.goduck.go生成的插件。

此示例的完整源代码可在本书的附带 GitHub 仓库中找到。

Sayer 接口

为了使插件有用,访问它的函数需要知道要查找哪些符号,以及这些符号符合的合同是什么。

一个便捷的——但绝不是必须的——方法是使用一个接口,可以预期某个符号满足。在我们的具体实现中,我们的插件将仅公开一个名为Animal的符号,我们期望它符合以下Sayer接口:

type Sayer interface {
    Says() string
}

此接口仅描述一个名为Says的方法,该方法返回一个描述动物发出声音的字符串。

Go 插件代码

我们在duck/duck.gofrog/frog.go中拥有两个独立的插件源码。在接下来的代码片段中,第一个插件duck/duck.go被完整展示,并显示了插件实现的所有要求。

package main

type duck struct{}

func (d duck) Says() string {
    return "quack!"
}

// Animal is exported as a symbol.
var Animal duck

正如本节介绍中所述,Go 插件的要求非常简单:只需是一个main包,导出一个或多个变量或函数即可。

前面的插件代码描述并仅导出了一个特性——Animal——它满足先前的Sayer接口。请记住,导出的包变量和符号在插件上作为共享库符号暴露出来,稍后可以进行查找。在这种情况下,我们的代码将特别查找导出的Animal符号。

在这个例子中,我们只有一个符号,但我们可以有无数个符号,没有明确的限制。如果我们想要,我们可以导出更多功能。

我们不会在这里展示frog/frog.go文件,因为它基本上是相同的。但重要的是要知道,插件的内部实现并不重要,只要它满足其消费者的期望即可。这些期望是:

  • 插件公开一个名为Animal的符号。

  • Animal 符号遵循Sayer接口定义的约定。

构建插件

构建 Go 插件与构建任何其他 Go main包非常相似,只是您必须包含-buildmode=plugin的构建参数。

要构建我们的duck/duck.go插件代码,我们执行以下操作:

$ go build -buildmode=plugin -o duck/duck.so duck/duck.go

结果是一个 ELF(可执行链接格式)格式的共享对象(.so)文件:

$ file duck/duck.so
duck/duck.so: Mach-O 64-bit dynamically linked shared library x86_64

ELF 文件通常用于插件,因为一旦它们被内核加载到内存中,它们以一种允许轻松发现和访问符号的方式暴露符号。

使用我们的 Go 插件

现在我们已经构建了我们的插件,它们正静静地坐在那里,带有它们的 .so 扩展名,我们需要写一些代码来加载和使用它们。

请注意,即使我们已经完全构建并放置了我们的插件,我们还没有去使用 plugin 包。然而,现在我们想要实际使用我们的插件,我们可以改变这一点了。

寻找、打开和使用插件的过程需要几个步骤,接下来我将演示。

导入插件包

首先要做的事情是导入 plugin 包,这将为我们提供打开和访问插件所需的工具。

在这个例子中,我们引入了四个包:fmtlogos,以及与这个例子最相关的 plugin

import (
    "fmt"
    "log"
    "os"
    "plugin"
)

找到我们的插件

要加载一个插件,我们必须找到它的相对或绝对文件路径。因此,插件二进制文件通常根据某种模式命名,并放置在可以轻松发现的位置,如用户的命令路径或其他标准固定位置。

为了简单起见,我们的实现假设我们的插件与用户选择的动物具有相同的名称,并位于执行位置相对路径下:

if len(os.Args) != 2 {
    log.Fatal("usage: run main/main.go animal")
}

// Get the animal name, and build the path where we expect to
// find the corresponding shared object (.so) file.
name := os.Args[1]
module := fmt.Sprintf("./%s/%s.so", name, name)

重要的是,这种方法意味着我们的插件在编译时不需要被知道或甚至存在。通过这种方式,我们能够随时实现任何想要的插件,并根据需要动态加载和访问它们。

打开我们的插件

现在我们认为已经知道了插件的路径,我们可以使用 Open 函数来“打开”它,将其加载到内存中,并发现其可用的符号。Open 函数返回一个 *Plugin 值,然后可以用来查找插件暴露的任何符号:

// Open our plugin and get a *plugin.Plugin.
p, err := plugin.Open(module)
if err != nil {
    log.Fatal(err)
}

当使用 Open 函数首次打开插件时,会调用所有不在程序中的包的 init 函数。包的 main 函数不会运行。

当打开一个插件时,它的一个单一的规范化 *Plugin 值表示被加载到内存中。如果已经打开了特定路径,那么对 Open 的后续调用将返回相同的 *Plugin 值。

插件不能被加载超过一次,也不能被关闭。

查找您的符号

要检索由我们的包导出的变量或函数(因此作为插件符号公开),我们必须使用 Lookup 方法找到它。不幸的是,plugin 包并没有提供列出插件暴露的所有符号的方法,所以您必须提前知道我们符号的名称:

// Lookup searches for a symbol named "Animal" in plug-in p.
symbol, err := p.Lookup("Animal")
if err != nil {
    log.Fatal(err)
}

如果符号存在于插件 p 中,则 Lookup 返回一个 Symbol 值。如果符号在 p 中不存在,则返回一个非空的 error

断言并使用您的符号

现在我们有了我们的Symbol,我们可以将其转换为我们需要的形式,并根据需要使用它。为了使事情对我们来说更加简单,Symbol类型实质上是一个重新命名的interface{}值。来自plugin源代码:

type Symbol interface{}

这意味着只要我们知道我们符号的类型,我们可以使用类型断言将其强制转换为可以根据需要使用的具体类型值:

// Asserts that the symbol interface holds a Sayer.
animal, ok := symbol.(Sayer)
if !ok {
    log.Fatal("that's not a Sayer")
}

// Now we can use our loaded plug-in!
fmt.Printf("A %s says: %q\n", name, animal.Says())

在前面的代码中,我们断言symbol值是否满足Sayer接口。如果满足,则打印我们的动物说的内容。如果不满足,则我们能够优雅地退出。

执行我们的示例

现在我们已经编写了试图打开和访问插件的主要代码,我们可以像运行任何其他 Go main包一样运行它,将动物名称作为参数传递:

$ go run main/main.go duck
A duck says: "quack!"

$ go run main/main.go frog
A frog says: "ribbit!"

我们甚至可以稍后实现任意插件而无需更改我们的主源代码:

$ go run main/main.go fox
A fox says: "ring-ding-ding-ding-dingeringeding!"

HashiCorp 的 Go 插件系统通过 RPC

HashiCorp 的Go 插件系统自 2016 年起广泛应用于 HashiCorp 内部及其他地方,比 Go 标准plugin包发布早约一年。

与使用共享库的 Go 插件不同,HashiCorp 的插件是通过使用exec.Command执行的独立进程,这比共享库有一些明显的优势:

它们不能使主机进程崩溃

因为它们是独立的进程,插件中的panic并不会自动使插件消费者崩溃。

它们更具版本灵活性

Go 插件以版本特定而闻名。HashiCorp 插件则不那么严格,只期望插件遵守合同。它还支持显式的协议版本控制。

它们相对安全

与整个消费进程的内存空间相比,HashiCorp 插件只能访问传递给它们的接口和参数。

尽管如此,它们确实有一些缺点:

更加冗长

HashiCorp 插件需要比 Go 插件更多的样板代码。

性能较差

因为与 HashiCorp 插件的所有数据交换都发生在 RPC 上,因此与 Go 插件的通信通常更加高效。

话虽如此,让我们来看看如何组装一个简单的插件。

另一个玩具插件示例

因此,为了能够比较苹果与苹果,我们将通过一个玩具示例来完成与标准plugin包中的示例功能相同的示例:“一个玩具插件示例”(#section_ch08_standard_plugin_example):一个告诉您各种动物说什么的程序。

正如之前一样,我们将创建几个独立的包,其结构如下:

~/cloud-native-go/ch08/hashicorp-plugin
├── commons
│   └── commons.go
├── duck
│   └── duck.go
└── main
    └── main.go

正如之前一样,duck/duck.go文件包含了插件的源代码,而main/main.go文件包含了我们示例的main函数,用于加载和使用插件。因为这两者都是独立编译以生成可执行文件的,所以这两个文件都在main包中。

commons 包是新的。它包含了一些资源,这些资源被插件和消费者共享,包括服务接口和一些 RPC 样板代码。

和之前一样,本示例的完整源代码可在 本书的伴随 GitHub 仓库 中找到。

共享代码

commons 包含了一些资源,这些资源被插件和消费者共享,所以在我们的例子中,它被插件和客户端代码同时导入。

它包含了由底层的 net/rpc 机制使用的 RPC 存根,用于为主机定义服务抽象并允许插件构建它们的服务实现。

Sayer 接口

其中之一是 Sayer 接口。这是我们的服务接口,提供了插件服务实现必须符合的服务契约,也是主机可以期望的。

它与我们在 “Sayer 接口” 中使用的接口完全相同:

type Sayer interface {
    Says() string
}

Sayer 接口只描述了一个方法:Says。尽管此代码是共享的,只要这个接口不改变,共享的契约就会得到满足,并且耦合度保持相当低。

SayerPlugin 结构体

其中较为复杂的公共资源是 SayerPlugin 结构体,如下所示。它是 github.com/hashicorp/go-plugin 包中主要的插件接口 plugin.Plugin 的实现。

警告

github.com/hashicorp/go-plugin 存储库内的包声明是 plugin,而不是如其路径所示的 go-plugin。请相应地调整你的导入!

ClientServer 方法用于描述我们的服务,符合 Go 标准 net/rpc 包的期望。我们不会在本书中涵盖该包,但如果你感兴趣,可以在 Go 文档 中找到丰富的信息:

type SayerPlugin struct {
    Impl Sayer
}

func (SayerPlugin) Client(b *plugin.MuxBroker, c *rpc.Client)
        (interface{}, error) {

    return &SayerRPC{client: c}, nil
}

func (p *SayerPlugin) Server(*plugin.MuxBroker) (interface{}, error) {
    return &SayerRPCServer{Impl: p.Impl}, nil
}

这两个方法都接受一个 plugin.MuxBroker,用于在插件连接上创建多路复用流。虽然非常有用,但这是一个更高级的用例,我们在本书中没有时间涵盖。

SayerRPC 客户端的实现

SayerPluginClient 方法提供了一个 Sayer 接口的实现,通过 RPC 客户端进行通信,这个客户端被适当地命名为 SayerRPC 结构体,如下所示:

type SayerRPC struct{ client *rpc.Client }

func (g *SayerRPC) Says() string {
    var resp string

    err := g.client.Call("Plugin.Says", new(interface{}), &resp)
    if err != nil {
        panic(err)
    }

    return resp
}

SayerRPC 使用 Go 的 RPC 框架远程调用插件中实现的 Says 方法。它调用附加到 *rpc.ClientCall 方法,传递任何参数(Says 没有参数,所以我们传递空的 interface{}),并检索响应,将其放入 resp 字符串中。

握手配置

HandshakeConfig 被插件和主机都用来在主机和插件之间进行基本的握手。如果握手失败——例如插件使用了不同的协议版本编译——将显示一个用户友好的错误。这可以防止用户执行不良插件或直接执行插件。重要的是,这是一个用户体验特性,而不是安全特性:

var HandshakeConfig = plugin.HandshakeConfig{
    ProtocolVersion:  1,
    MagicCookieKey:   "BASIC_PLUGIN",
    MagicCookieValue: "hello",
}

SayerRPCServer 服务器实现

SayerPluginServer 方法提供了一个 RPC 服务器的定义——SayerRPCServer 结构,以一种与 net/rpc 一致的方式提供实际的方法:

type SayerRPCServer struct {
    Impl Sayer    // Impl contains our actual implementation
}

func (s *SayerRPCServer) Says(args interface{}, resp *string) error {
    *resp = s.Impl.Says()
    return nil
}

SayerRPCServer 并不实现 Sayer 服务。相反,它的 Says 方法调用一个我们将在构建插件时提供的 Sayer 实现——Impl

我们的插件实现

现在我们已经组装了主机和插件之间共同的代码——Sayer 接口和 RPC 存根,我们可以构建我们的插件代码。本节中的代码代表了我们的 main/main.go 文件的全部内容。

就像标准的 Go 插件一样,HashiCorp 插件被编译成独立的可执行二进制文件,因此它们必须位于 main 包中。实际上,每个 HashiCorp 插件都是一个小型的、自包含的 RPC 服务器:

package main

我们必须导入我们的 commons 包,以及 hashicorp/go-plugin 包,我们将引用它的内容作为 plugin

import (
    "github.com/cloud-native-go/ch08/hashicorp-plugin/commons"
    "github.com/hashicorp/go-plugin"
)

在我们的插件中,我们可以构建我们真正的实现。我们可以按照我们想要的方式构建它,只要它符合我们在 commons 包中定义的 Sayer 接口即可:

type Duck struct{}

func (g *Duck) Says() string {
    return "Quack!"
}

最后,我们来到我们的 main 函数。它有点“样板化”,但是它是必不可少的:

func main() {
    // Create and initialize our service implementation.
    sayer := &Duck{}

    // pluginMap is the map of plug-ins we can dispense.
    var pluginMap = map[string]plugin.Plugin{
        "sayer": &commons.SayerPlugin{Impl: sayer},
    }

    plugin.Serve(&plugin.ServeConfig{
        HandshakeConfig: handshakeConfig,
        Plugins:         pluginMap,
    })
}

main 函数做三件事。首先,在本例中,它创建并初始化我们的服务实现,一个 *Duck 值。

接下来,它将服务实现映射到pluginMap中的“sayer”名称。如果我们愿意,实际上我们可以实现几个插件,并在这里列出它们的不同名称。

最后,我们调用 plugin.Serve,这将启动 RPC 服务器,处理来自主机进程的任何连接,允许与主机的握手继续进行并根据主机的需要执行服务的方法。

我们的主机进程

现在我们有了我们的主机进程;主要命令充当客户端,查找、加载和执行插件进程。

正如你将看到的,使用 HashiCorp 插件与描述 Go 插件的步骤并没有太大的不同 “使用我们的 Go 插件”。

导入 hashicorp/go-plugincommons

像往常一样,我们从包声明和导入开始。导入主要是不那么有趣的,它们的必要性应该从代码的检查中清楚地体现出来。

其中两个是有趣的(但不奇怪的)是github.com/hashicorp/go-plugin,我们再次必须将其引用为plugin,以及我们的commons包,其中包含必须由主机和插件之间达成一致的接口和握手配置:

package main

import (
    "fmt"
    "log"
    "os"
    "os/exec"

    "github.com/cloud-native-go/ch08/hashicorp-plugin/commons"
    "github.com/hashicorp/go-plugin"
)

查找我们的插件

由于我们的插件是一个外部文件,我们必须找到它。同样,为了简单起见,我们的实现假设我们的插件与用户选择的动物名称相同,并位于相对于执行位置的路径中:

func main() {
    if len(os.Args) != 2 {
        log.Fatal("usage: run main/main.go animal")
    }

    // Get the animal name, and build the path where we expect to
    // find the corresponding executable file.
    name := os.Args[1]
    module := fmt.Sprintf("./%s/%s", name, name)

    // Does the file exist?
    _, err := os.Stat(module)
    if os.IsNotExist(err) {
        log.Fatal("can't find an animal named", name)
    }
}

值得重申的是,这种方法的价值在于我们的插件——及其实现——在编译时不需要被了解,甚至不需要存在。我们能够随时实现任何插件,并根据需要动态使用它们。

创建我们的插件客户端

HashiCorp RPC 插件与 Go 插件的第一个不同之处在于它检索实现的方式。在 Go 插件中,必须“打开”插件并“查找”其符号,而 HashiCorp 插件建立在 RPC 之上,因此需要一个 RPC 客户端。

实际上,这需要两个步骤和两个客户端:一个管理插件子进程生命周期的*plugin.Client,以及一个协议客户端——一个plugin.ClientProtocol实现,可以与插件子进程通信。

这个笨拙的 API 主要是历史遗留问题,但用于分离处理子进程管理的客户端和执行 RPC 管理的客户端:

// pluginMap is the map of plug-ins we can dispense.
var pluginMap = map[string]plugin.Plugin{
    "sayer": &commons.SayerPlugin{},
}

// Launch the plugin process!
client := plugin.NewClient(&plugin.ClientConfig{
    HandshakeConfig: commons.HandshakeConfig,
    Plugins:         pluginMap,
    Cmd:             exec.Command(module),
})
defer client.Kill()

// Connect to the plugin via RPC
rpcClient, err := client.Client()
if err != nil {
    log.Fatal(err)
}

大部分这段代码是以plugin.ClientConfig的形式定义我们想要的插件参数。可用客户端配置的完整列表很长。本示例仅使用了三个:

HandshakeConfig

握手配置。这必须与插件自身的握手配置匹配,否则我们将在下一步中出现错误。

Plugins

指定我们想要的插件名称和类型的映射。

Cmd

一个代表启动插件子进程的*exec.Cmd值。

在所有配置都完成之后,我们首先使用plugin.NewClient检索*plugin.Client值,我们称之为client

一旦我们完成这些步骤,我们可以使用client.Client请求协议客户端。我们称之为rpcClient,因为它知道如何使用 RPC 与插件子进程进行通信。

连接到我们的插件并分发我们的 Sayer

现在我们有了我们的协议客户端,我们可以使用它来分发我们的Sayer实现:

    // Request the plug-in from the client
    raw, err := rpcClient.Dispense("sayer")
    if err != nil {
        log.Fatal(err)
    }

    // We should have a Sayer now! This feels like a normal interface
    // implementation, but is actually over an RPC connection.
    sayer := raw.(commons.Sayer)

    // Now we can use our loaded plug-in!
    fmt.Printf("A %s says: %q\n", name, sayer.Says())
}

使用协议客户端的Dispense函数,我们最终能够检索到我们的Sayer实现作为一个interface{},我们可以断言为commons.Sayer值,并立即像使用本地值一样使用它。

在底层,我们的sayer实际上是一个SayerRPC值,调用其函数会触发在我们插件地址空间中执行的 RPC 调用。

在接下来的章节中,我们将介绍六边形架构,这是一种围绕松耦合概念构建的架构模式,通过使用易于交换的“端口和适配器”来连接其环境。

六边形架构

六边形架构,也被称为“端口和适配器”模式,是一种使用松耦合和控制反转作为其中心设计哲学的架构模式,以建立业务和外围逻辑之间的明确边界。

在六边形应用中,核心应用完全不知道外部世界的任何细节,完全通过松耦合的端口和技术特定的适配器进行操作。

此方法允许应用程序例如公开不同的 API(REST、gRPC、测试工具等)或使用不同的数据源(数据库、消息队列、本地文件等),而无需影响其核心逻辑或需要重大代码更改。

注意

让我尴尬地花费了很长时间才意识到“六边形架构”这个名字实际上并没有任何意义。阿利斯泰尔·考克本,六边形架构的作者,选择这个形状是因为它给了他足够的空间来说明设计。

架构

如图 8-3 所示,六边形架构由概念上排列在和周围中心六边形的三个组件组成:

核心应用程序

应用程序本身由六边形表示。其中包含所有业务逻辑,但不直接引用任何技术、框架或真实世界设备。业务逻辑不应依赖于是否暴露 REST 或 gRPC API,或者从数据库或.csv文件获取数据。它对世界的唯一视图应通过端口。

端口和适配器

端口和适配器被表示在六边形的边缘上。端口允许不同类型的参与者“插入”并与核心服务交互。适配器可以“插入”端口,并在核心应用与参与者之间传输信号。

例如,您的应用程序可能有一个“数据端口”,可以插入“数据适配器”。一个数据适配器可能写入数据库,而另一个可能使用内存数据存储或自动化测试工具。

参与者

参与者可以是与核心应用程序交互的环境中的任何东西(用户、上游服务等),或者核心应用程序与之交互的东西(存储设备、下游服务等)。它们存在于六边形之外。

cngo 0803

图 8-3. 六边形架构中的所有依赖关系均指向内部;六边形代表核心应用的领域和 API 层,而端口和适配器则表示为六边形边缘上的箭头,每个箭头与特定的参与者接口。

在传统的分层架构中,所有依赖关系都指向同一个方向,每个层次依赖于其下一层。

然而,在六边形架构中,所有的依赖关系都是向内指向的:核心业务逻辑不知道外部世界的任何细节,适配器知道如何在核心之间传递信息,并且外部世界的适配器知道如何与参与者交互。

实现一个六边形服务

为了说明这一点,我们将重构我们的老朋友键值存储。

如果您还记得在第五章中,我们的键值存储核心应用程序读写到内存映射中,可以通过 RESTful(或 gRPC)前端访问。在同一章节的后面,我们实现了一个事务记录器,它知道如何将所有事务写入某处并在系统重新启动时将它们全部读回来。

我们将在此处重现服务的重要片段,但如果您想复习我们所做的内容,请返回并进行复习。

到本书的这一点,我们已经积累了几种不同组件的不同实现,这些组件似乎是六边形架构中端口和适配器的良好候选者:

前端

回到“第 1 代:单片机”,我们实现了一个 REST 前端,然后在“使用 gRPC 进行远程过程调用”中实现了一个独立的 gRPC 前端。我们可以用一个“驱动”端口描述它们,可以将任一(或两者!)插入适配器中。

事务记录器

在“什么是事务日志?”中,我们创建了事务日志的两个实现。这些看起来是“驱动”端口和适配器的自然选择。

虽然所有这些逻辑已经存在,但我们需要进行一些重构,以使此架构变为“六边形”:

  1. 我们最初的核心应用程序——最初在“第 0 代:核心功能”中描述——仅使用公共函数。我们将这些重构为结构方法,以便在“端口和适配器”格式中更容易使用。

  2. RESTful 和 gRPC 前端已经与六边形架构一致,因为核心应用程序不知道或不关心它们,但它们在一个main函数中构建。我们将这些转换为FrontEnd适配器,可以将我们的核心应用程序传递进去。这种模式是“驱动”端口的典型代表。

  3. 事务记录器本身不需要进行大规模重构,但它们目前嵌入在前端逻辑中。在我们重构核心应用程序时,我们将添加一个事务记录器端口,以便可以将适配器传递到核心逻辑中。这种模式是“驱动”端口的典型代表。

在接下来的章节中,我们将开始按照六边形原则对现有组件进行重构。

我们重构后的组件

为了本示例,我们所有的组件都位于github.com/cloud-native-go/examples/ch08/hexarch包下:

~/cloud-native-go/ch08/hexarch/
├── core
│   └── core.go
├── frontend
│   ├── grpc.go
│   └── rest.go
├── main.go
└── transact
    ├── filelogger.go
    └── pglogger.go

core

核心键值应用程序逻辑。重要的是,它没有依赖于 Go 标准库之外的任何东西。

frontend

包含 REST 和 gRPC 前端驱动适配器。这些依赖于 core

transact

包含文件和 PostgreSQL 事务日志记录器驱动适配器。这些也依赖于 core

main.go

将核心应用程序实例化,将驱动组件传递给它,并将其传递给驱动适配器。

完整的源代码也可以在伴随的 GitHub 仓库中找到。

现在我们有了非常高层次的结构,让我们继续实现我们的第一个插件。

我们的第一个插件

也许你还记得,我们还实现了一个事务日志,用来记录每次资源修改的情况,以便如果我们的服务崩溃、重新启动或以其他方式处于不一致状态时,可以通过重播事务来重建其完整状态。

在“你的事务日志接口”中,我们用 TransactionLogger 表示了一个通用的事务日志记录器:

type TransactionLogger interface {
    WriteDelete(key string)
    WritePut(key, value string)
}

为简洁起见,我们仅定义了 WriteDeleteWritePut 方法。

“驱动”适配器的一个共同特点是核心逻辑作用于它们,因此核心应用程序必须了解端口。因此,此代码位于 core 包中。

我们的核心应用程序

在我们在“你的超级简单 API”的原始实现中,事务日志记录器被前端使用。在六边形架构中,我们将端口——以 TransactionLogger 接口的形式——移到核心应用程序中:

package core

import (
    "errors"
    "log"
    "sync"
)

type KeyValueStore struct {
    m        map[string]string
    transact TransactionLogger
}

func NewKeyValueStore(tl TransactionLogger) *KeyValueStore {
    return &KeyValueStore{
        m:        make(map[string]string),
        transact: tl,
    }
}

func (store *KeyValueStore) Delete(key string) error {
    delete(store.m, key)
    store.transact.WriteDelete(key)
    return nil
}

func (store *KeyValueStore) Put(key string, value string) error {
    store.m[key] = value
    store.transact.WritePut(key, value)
    return nil
}

将之前的代码与原始形式在“第 0 代:核心功能”进行比较,你会看到一些重大的变化。

首先,PutDelete 不再是纯函数:它们现在是一个新的 KeyValueStore 结构体的方法,该结构体还具有映射数据结构。我们还添加了一个 NewKeyValueStore 函数,该函数初始化并返回一个新的 KeyValueStore 指针值。

最后,KeyValueStore 现在有一个 TransactionLogger,它会适当地对 PutDelete 进行操作。这是我们的端口。

我们的 TransactionLogger 适配器

在第五章中,我们创建了两个 TransactionLogger 实现:

  • 在“实现你的 FileTransactionLogger”中,我们描述了一个基于文件的实现。

  • 在“实现你的 PostgresTransactionLogger”中,我们描述了一个基于 PostgreSQL 的实现。

这两者都已移至 transact 包。除了考虑到 TransactionLogger 接口和 Event 结构现在位于 core 包中这一事实外,它们几乎没有任何改变。

但是,我们如何确定要加载哪一个?好吧,Go 没有注解或任何高级的依赖注入功能,¹⁰但仍然有几种方法可以做到这一点。

第一种选择是使用某种插件(这实际上是 Go 插件的一个主要用例)。如果您希望更改适配器不需要任何代码更改,这可能是有意义的。

更常见的是,您会看到某种“工厂”函数¹¹,该函数由初始化函数使用。虽然这仍然需要更改代码以添加适配器,但它们仅限于一个易于修改的位置。一个更复杂的方法可能接受参数或配置值以选择要使用的适配器。

TransactionLogger工厂函数的示例可能如下所示:

func NewTransactionLogger(logger string) (core.TransactionLogger, error) {
    switch logger {
    case "file":
        return NewFileTransactionLogger(os.Getenv("TLOG_FILENAME"))

    case "postgres":
        return NewPostgresTransactionLogger(
            PostgresDbParams{
                dbName: os.Getenv("TLOG_DB_HOST"),
                host: os.Getenv("TLOG_DB_DATABASE"),
                user: os.Getenv("TLOG_DB_USERNAME"),
                password: os.Getenv("TLOG_DB_PASSWORD"),
            }
        )

    case "":
        return nil, fmt.Errorf("transaction logger type not defined")

    default:
        return nil, fmt.Errorf("no such transaction logger %s", s)
    }
}

在此示例中,NewTransactionLogger函数接受一个指定所需实现的字符串,返回我们的实现之一或一个error。我们使用os.Getenv函数从环境变量中检索适当的参数。

我们的前端端口

但是我们的前端怎么样?如果您还记得,现在我们有两个前端实现:

  • 在“第一代:单体应用”中,我们使用net/httpgorilla/mux构建了一个 RESTful 接口。

  • 在“使用 gRPC 进行远程过程调用”中,本章前面,我们使用 gRPC 构建了一个 RPC 接口。

这两个实现都包括一个main函数,其中我们配置并启动服务以侦听连接。

由于它们是“驱动程序”端口,我们需要将核心应用程序传递给它们,因此让我们根据以下接口将两个前端重构为结构体:

package frontend

type FrontEnd interface {
    Start(kv *core.KeyValueStore) error
}

FrontEnd接口充当我们的“前端端口”,所有前端实现都应满足该接口。Start方法接受以*core.KeyValueStore形式提供的核心应用程序 API,并且还包括先前位于main函数中的设置逻辑。

有了这个,我们现在可以重构两个前端,使它们符合FrontEnd接口,首先是 RESTful 前端。与往常一样,此书及 gRPC 服务重构的完整源代码可在此书的伴随 GitHub 仓库中找到:

package frontend

import (
    "net/http"

    "github.com/cloud-native-go/examples/ch08/hexarch/core"
    "github.com/gorilla/mux"
)

// restFrontEnd contains a reference to the core application logic,
// and complies with the contract defined by the FrontEnd interface.
type restFrontEnd struct {
    store *core.KeyValueStore
}

// keyValueDeleteHandler handles the logic for the DELETE HTTP method.
func (f *restFrontEnd) keyValueDeleteHandler(w http.ResponseWriter,
        r *http.Request) {

    vars := mux.Vars(r)
    key := vars["key"]

    err := f.store.Delete(key)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
}

// ...other handler functions omitted for brevity.

// Start includes the setup and start logic that previously
// lived in a main function.
func (f *restFrontEnd) Start(store *core.KeyValueStore) error {
    // Remember our core application reference.
    f.store = store

    r := mux.NewRouter()

    r.HandleFunc("/v1/{key}", f.keyValueGetHandler).Methods("GET")
    r.HandleFunc("/v1/{key}", f.keyValuePutHandler).Methods("PUT")
    r.HandleFunc("/v1/{key}", f.keyValueDeleteHandler).Methods("DELETE")

    return http.ListenAndServe(":8080", r)
}

将之前的代码与我们在“第一代:单体应用”中生成的代码进行比较,一些差异是显而易见的:

  • 现在,所有函数都是附加到restFrontEnd结构的方法。

  • 所有对核心应用程序的调用都通过restFrontEnd结构中的store值进行。

  • 创建路由器、定义处理程序并启动服务器现在都存在于Start方法中。

类似的更改将用于我们的 gRPC 前端实现,以使其与FrontEnd端口保持一致。

这种新的安排使得消费者更容易选择并插入“前端适配器”,如下所示。

把所有内容综合起来

在这里,我们有我们的 main 函数,我们在其中将所有组件插入我们的应用程序:

package main

import (
    "log"

    "github.com/cloud-native-go/examples/ch08/hexarch/core"
    "github.com/cloud-native-go/examples/ch08/hexarch/frontend"
    "github.com/cloud-native-go/examples/ch08/hexarch/transact"
)

func main() {
    // Create our TransactionLogger. This is an adapter that will plug
    // into the core application's TransactionLogger port.
    tl, err := transact.NewTransactionLogger(os.Getenv("TLOG_TYPE"))
    if err != nil {
        log.Fatal(err)
    }

    // Create Core and tell it which TransactionLogger to use.
    // This is an example of a "driven agent"
    store := core.NewKeyValueStore(tl)
    store.Restore()

    // Create the frontend.
    // This is an example of a "driving agent."
    fe, err := frontend.NewFrontEnd(os.Getenv("FRONTEND_TYPE"))
    if err != nil {
        log.Fatal(err)
    }

    log.Fatal(fe.Start(store))
}

首先,我们根据环境变量 TLOG_TYPE 创建事务记录器。我们首先这样做是因为“事务记录器端口”是“被驱动”的,所以我们需要将其提供给应用程序以便插入。

然后,我们创建我们的 KeyValueStore 值,它代表我们的核心应用程序功能并提供一个供端口与之交互的 API,并为其提供任何驱动适配器。

接下来,我们创建任何“驱动”适配器。由于这些适配器作用于核心应用程序 API,我们将 API 提供给适配器,而不是反过来,就像我们与“被驱动”适配器一样。这意味着,如果需要的话,我们也可以在这里创建多个前端,通过创建一个新的适配器并将其传递给暴露核心应用程序 API 的 KeyValueStore

最后,在我们的前端上调用 Start,指示它开始监听连接。终于,我们有了一个完整的六边形服务!

概要

我们在本章中涵盖了很多内容,但实际上只是浅尝辄止了组件可能发现自己紧密耦合的各种方式,以及管理每个紧密耦合组件的各种方式。

在本章的上半部分,我们专注于由服务通信方式可能导致的耦合问题。我们讨论了由脆弱的交换协议如 SOAP 引起的问题,并演示了 REST 和 gRPC,它们不那么脆弱,因为在一定程度上可以进行更改而不必强制客户端升级。我们还触及了“时间上”的耦合,其中一个服务隐含地期望另一个及时响应,以及如何使用发布-订阅消息传递来减轻这种情况。

在第二部分中,我们解决了一些系统可以最小化与本地资源耦合的方式。毕竟,即使是分布式服务也只是程序,受到与任何程序相同的架构和实现的限制。插件实现和六边形架构是通过强制关注点分离和控制反转来实现此目的的两种方式。

不幸的是,我们没有深入探讨一些其他引人入胜的主题,比如服务发现,但遗憾的是,在这个主题远离我之前,我必须在某个地方划一条线!

¹ 乌尔曼,埃伦。“编程的愚蠢化。” Salon,1998 年 5 月 12 日。https://oreil.ly/Eib3K

² 请离开我的草坪。

³ 在 XML 中,我们当时并不知道有更好的选择。

⁴ 在 Google,甚至缩写词也是递归的。

⁵ 实际上这是一个相当微妙的讨论。参见“服务架构”。

⁶ 如果你喜欢那种事情的话。

⁷ 如果你想要创意,这可以是一个FileListener,甚至是一个stdio流。

⁸ 是的,我知道这个动物的主题之前已经做过了。起诉我吧。

⁹ 所以,自然而然地,我们正在构建一个鸭子。显然。

¹⁰ 好了,终于摆脱了。

¹¹ 对不起。

第九章:弹性

分布式系统是这样一种系统,其中你甚至不知道的一台计算机的故障可以使你自己的计算机无法使用。¹

莱斯利·兰波特,DEC SRC 公告板(1987 年 5 月)

一个九月的深夜,正值两点多的时候,亚马逊内部网络的一部分悄然停止了运行。² 这个事件很短暂,也不是特别有趣,除非它碰巧影响了支持 DynamoDB 服务的大量服务器。

大多数情况下,这并不是什么大问题。任何受影响的服务器只需尝试从专用元数据服务中检索其成员数据,以重新连接到集群。如果失败了,它们将暂时自行下线并重试。

但这一次,当网络恢复时,一大群存储服务器同时请求从元数据服务获取其成员数据,压垮了它,以至于即使是以前未受影响的服务器的请求也开始超时。存储服务器们顺从地对超时做出响应,将自己脱机并重试(再次),进一步加重了元数据服务的压力,导致更多服务器脱机,如此循环。几分钟之内,故障扩散到整个集群。服务有效地停机,导致多个依赖服务也随之停机。

更糟糕的是,重试尝试的大量增加——一场“重试风暴”——给元数据服务带来了巨大压力,以至于甚至完全无法响应增加容量的请求。值班工程师不得不明确地阻止对元数据服务的请求,以减轻足够的压力,以允许他们手动扩展。

最终,在初次触发事件的网络小故障近五个小时后,正常运营恢复,结束了所有相关人员显然度过的漫长夜晚。

继续走下去:为什么弹性很重要

那么,亚马逊停机的根本原因是什么?是网络中断吗?是存储服务器的积极重试行为吗?是元数据服务的响应时间,或者可能是它有限的容量吗?

显然,那个清晨发生的事情并非单一根源所致。复杂系统的失败从未只有一个原因。³ 相反,系统失败了,就像复杂系统通常做的那样:一个子系统的故障引发了另一个子系统的潜在故障,导致失败,然后是另一个,再一个,直到最终整个系统崩溃。但有趣的是,如果我们故事中的任何组件——网络、存储服务器、元数据服务——能够隔离并从系统其他部分的故障中恢复,整个系统很可能会在无需人工干预的情况下恢复正常。

不幸的是,这只是一个常见模式的例子。复杂系统以复杂(且常常令人惊讶)的方式失败,但它们不会一次性失败:它们逐个子系统地失败。因此,复杂系统中的弹性模式采取的形式是防护墙和安全阀,这些防护墙和安全阀可以在组件边界上隔离故障。频繁地,遏制的失败就是避免的失败。

这一特性,即系统抵御和从错误和故障中恢复的能力,称为弹性。一个系统如果在某个子系统出现故障时仍然能够继续正确运行(可能是在降级状态下),就可以被认为是弹性的。

一个系统若失败,意味着什么?

一切因忽视了一枚钉而失去了鞋,

一切因忽视了一只鞋而失去了马;

一切因忽视了一匹马而失去了骑士;

一切因忽视一枚马蹄钉而失。

本杰明·富兰克林,《致富之道》(1758 年)

如果我们想知道系统失败意味着什么,首先必须问什么是“系统”。

这一点非常重要。请耐心听我解释。

根据定义,系统是一组组件共同工作以实现整体目标。到目前为止,一切顺利。但这里的关键是:系统的每个组件——子系统——也是一个完整的系统,它本身又由更小的子系统组成,如此循环。

以汽车为例。它的引擎是数十个子系统之一,但它——和其他所有的子系统一样——也是一个非常复杂的系统,具有自己的多个子系统,包括冷却子系统,其中包括恒温器,恒温器包括温度开关,依此类推。这只是成千上万个组件和子组件及其子子组件中的一部分。这足以令人头晕:有那么多东西可能会出错。但当它们出错时会发生什么?

正如我们之前提到的——并在第六章中深入讨论——复杂系统的失败不会一次性发生。它们会按可预测的步骤逐步展开:

  1. 所有系统都包含缺陷,在软件世界中我们常常称之为“bug”。比如汽车引擎中温度开关卡住的倾向就是一种缺陷。同样,元数据服务的容量有限以及 DynamoDB 案例中存储服务器的重试行为也算是缺陷。⁵ 在适当的条件下,缺陷可以被激发出来导致错误

  2. 错误是系统预期行为与实际行为之间的任何差异。许多错误可以被及时捕获和适当处理,但如果未能处理,它们单独或累积起来就会导致失败。例如汽车引擎中温度开关卡住的情况就是一个错误。

  3. 最后,一个系统在无法提供正确服务时可以说正在经历失败。一个不再响应高温的温度开关可以说是失败了。子系统级别的故障会变成系统级别的缺陷。

最后值得重申的是:子系统级别的故障变成了系统级别的故障。一个卡住的温度开关导致恒温器失效,阻止冷却剂通过散热器流动,提高了引擎温度,导致其熄火并使汽车停止。⁷

这就是系统失败的方式。它始于一个组件的故障——一个子系统——这导致与其互动的一个或多个组件出现错误,以及与此类似的组件,依此类推,逐层上升,直至整个系统失败。

这不仅仅是学术上的问题。了解复杂系统如何失败——一个组件一个组件地——使得抵抗故障的手段更加清晰:如果故障可以在传播到系统级别之前被限制,系统可能能够恢复(或至少以自身条件失败)。

构建韧性

在一个完美的世界中,消除系统中的每一个可能故障是可能的,但这并不现实,试图这样做是浪费和低效的。相反,假设所有组件最终都会出现故障——事实确实如此——并在其发生时设计它们能够优雅地响应错误,你就可以构建一个功能健全的系统,即使其中的某些组件出现问题也能正常运行。

有很多方法可以增强系统的韧性。冗余性,例如部署多个相同类型的组件,可能是最常见的方法。像断路器和请求限制器这样的专门逻辑可以用来隔离特定类型的错误,防止其传播。甚至可以删除有故障的组件——或者故意允许它们失败——以造福更大系统的健康。

韧性是一个特别丰富的主题。在本章的其余部分中,我们将探讨几种这样的方法——以及更多。

级联故障

DynamoDB 案例研究之所以如此适合,是因为它展示了大规模发生故障的多种不同方式。

例如,存储服务器组的故障导致元数据服务的请求超时,进而导致更多存储服务器故障,增加了对元数据服务的压力,如此类推。这是一个特定的——尤其常见的——故障模式的绝佳示例,被称为级联故障。一旦级联故障开始,往往会非常迅速地扩展,通常在几分钟之内。

级联故障的机制可能有所不同,但它们共享的一点是某种正反馈机制。系统的某一部分经历了局部故障——容量减少,延迟增加等——导致其他组件试图补偿失败组件的方式加剧了问题,最终导致整个系统的失败。

级联故障的经典原因是过载,在图 9-1 中有所说明。当一组节点中的一个或多个节点失败时,导致负载灾难性地重新分配给幸存节点。负载的增加使剩余节点超载,导致它们因资源耗尽而失败,从而使整个系统崩溃。

cngo 0901

图 9-1. 服务器过载是级联故障的常见原因;每台服务器每秒处理 600 个请求,因此当服务器 B 失败时,服务器 A 也会过载并失败

正反馈的性质通常使得通过增加容量来摆脱级联故障变得非常困难。新节点往往会在上线后迅速被压垮,通常会加剧导致系统崩溃的反馈。有时,唯一的解决方法是关闭整个服务——也许是通过明确阻止有问题的流量——以便恢复,然后慢慢重新引入负载。

但是,如何在一开始就防止级联故障呢?这将是下一节的主题(在某种程度上,也是本章的主题)。

防止过载

每个服务,无论设计和实现得多么完善,都有其功能上的限制。这在旨在处理和响应客户端请求的服务中尤为明显。⁸ 对于任何这样的服务,都存在某种请求频率,超过这个阈值就会开始出现问题。那么,我们如何防止大量请求意外(或故意!)导致我们的服务崩溃呢?

最终,处于这种情况的服务别无选择,只能拒绝一部分或全部请求。有两种主要策略可以做到这一点:

限流

限流是一种相对简单直接的策略,当请求以比预定频率更快的速度进来时启动,通常是通过拒绝处理这些请求。这通常被用作一种预防措施,以确保没有特定用户消耗比他们合理需要的资源更多。

负载抛弃

负载抛弃则更具适应性。使用这种策略的服务在接近过载条件时故意丢弃(“抛弃”)一部分负载,通过拒绝请求或陷入降级模式来实现。

这些策略并不是互斥的;一个服务可能根据自身需求选择使用其中一个或两个。

限流

正如我们在第四章中讨论的那样,限流模式的工作原理很像汽车的节流阀,只不过它不是限制进入引擎的燃料量,而是限制用户(无论是人类还是其他实体)在一定时间内向服务发出的请求数量。

我们在“节流”中提供的通用节流示例相对简单,并且在写作时实际上是全局有效的。然而,节流经常也会按用户基础应用,以提供类似使用配额的服务,这样任何一个调用者都不能消耗过多服务资源。

在接下来的内容中,我们展示了一个节流实现,虽然仍然使用令牌桶,⁹但在几个方面上相当不同。

首先,不再使用单个桶来控制所有传入请求,而是在以下实现中基于每个用户进行节流,返回一个接受“key”参数的函数,该参数用于表示用户名或其他唯一标识符。

其次,而不是在实施节流限制时尝试“重播”缓存值,返回的函数返回一个布尔值,指示何时施加了节流。请注意,当激活节流时,节流不返回error:节流不是错误条件,因此我们不将其视为错误。

最后,也许最有趣的是,它实际上并不使用定时器(time.Ticker)在某个常规时间段内显式地向桶中添加令牌。相反,它根据请求之间经过的时间来按需填充桶。这种策略意味着我们不必专门为填充桶而分配后台进程,这将更有效地扩展:

// Effector is the function that you want to subject to throttling.
type Effector func(context.Context) (string, error)

// Throttled wraps an Effector. It accepts the same parameters, plus a
// "UID" string that represents a caller identity. It returns the same,
// plus a bool that's true if the call is not throttled.
type Throttled func(context.Context, string) (bool, string, error)

// A bucket tracks the requests associated with a UID.
type bucket struct {
    tokens uint
    time   time.Time
}

// Throttle accepts an Effector function, and returns a Throttled
// function with a per-UID token bucket with a capacity of max
// that refills at a rate of refill tokens every d.
func Throttle(e Effector, max uint, refill uint, d time.Duration) Throttled {
    // buckets maps UIDs to specific buckets
    buckets := map[string]*bucket{}

    return func(ctx context.Context, uid string) (bool, string, error) {
        b := buckets[uid]

        // This is a new entry! It passes. Assumes that capacity >= 1.
        if b == nil {
            buckets[uid] = &bucket{tokens: max - 1, time: time.Now()}

            str, err := e(ctx)
            return true, str, err
        }

        // Calculate how many tokens we now have based on the time
        // passed since the previous request.
        refillInterval := uint(time.Since(b.time) / d)
        tokensAdded := refill * refillInterval
        currentTokens := b.tokens + tokensAdded

        // We don't have enough tokens. Return false.
        if currentTokens < 1 {
            return false, "", nil
        }

        // If we've refilled our bucket, we can restart the clock.
        // Otherwise, we figure out when the most recent tokens were added.
        if currentTokens > max {
            b.time = time.Now()
            b.tokens = max - 1
        } else {
            deltaTokens := currentTokens - b.tokens
            deltaRefills := deltaTokens / refill
            deltaTime := time.Duration(deltaRefills) * d

            b.time = b.time.Add(deltaTime)
            b.tokens = currentTokens - 1
        }

        str, err := e(ctx)

        return true, str, err
    }
}

像“节流”中的示例一样,这个Throttle函数接受一个符合Effector合约的函数文字,加上一些定义底层令牌桶大小和补充速率的值。

然而,它不返回另一个Effector,而是返回一个Throttled函数,该函数除了用节流逻辑包装效应器外,还添加了一个“key”输入参数,表示唯一的用户标识符,以及一个布尔返回值,指示函数是否被节流(因此未执行)。

尽管您可能(或可能不)发现Throttle代码有趣,但它仍未准备好投入生产。首先,它并非完全安全用于并发使用。生产实现可能需要在record值上加锁,可能还有bucket映射。其次,没有办法清除旧记录。在生产环境中,我们可能想要使用像我们在“使用 LRU 缓存实现高效缓存”中描述的 LRU 缓存之类的东西。

在接下来的内容中,我们展示了如何在 RESTful web 服务中使用Throttle的玩具示例:

var throttled = Throttle(getHostname, 1, 1, time.Second)

func getHostname(ctx context.Context) (string, error) {
    if ctx.Err() != nil {
        return "", ctx.Err()
    }

    return os.Hostname()
}

func throttledHandler(w http.ResponseWriter, r *http.Request) {
    ok, hostname, err := throttled(r.Context(), r.RemoteAddr)

    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    if !ok {
        http.Error(w, "Too many requests", http.StatusTooManyRequests)
        return
    }

    w.WriteHeader(http.StatusOK)
    w.Write([]byte(hostname))
}

func main() {
    r := mux.NewRouter()
    r.HandleFunc("/hostname", throttledHandler)
    log.Fatal(http.ListenAndServe(":8080", r))
}

前面的代码创建了一个小型 Web 服务,具有一个(有些牵强的)端点 /hostname,该端点返回服务的主机名。当程序运行时,throttled 变量通过将 getHostname 函数包装起来创建,后者提供实际的服务逻辑,然后将其传递给我们之前定义的 Throttle

当路由器接收到对 /hostname 端点的请求时,请求会转发到 throttledHandler 函数,该函数执行对 throttled 的调用,接收一个表示限流状态的 bool、主机名 string 和一个 error 值。如果出现定义的错误,我们返回 500 Internal Server Error,如果请求被限流,则返回 429 Too Many Requests。如果一切顺利,我们返回主机名和状态 200 OK

注意,存储桶值是本地存储的,因此这种实现实际上也不能算是可以投入生产的状态。如果您希望它扩展到更大规模,可能需要将记录值存储在某种外部缓存中,以便多个服务副本可以共享这些值。

负载放弃

作为服务器负载增加超出其处理能力时的不可避免事实,最终一定会出现某些问题。

负载放弃 是一种技术,用于预测服务器接近饱和点的时间,并通过受控方式丢弃部分流量以减轻饱和。理想情况下,这将防止服务器过载并导致健康检查失败、高延迟服务或者完全无法控制地崩溃。

与基于配额的限流不同,负载放弃是一种响应性措施,通常是在资源(如 CPU、内存或请求队列深度)耗尽时才会启动。

或许最直接的负载放弃形式是对任务进行的限流,在某些资源超过特定阈值时丢弃请求。例如,如果您的服务提供 RESTful 端点,您可能选择返回 HTTP 503(服务不可用)。我们发现在第五章中的“使用 gorilla/mux 构建 HTTP 服务器”节中非常有效的 gorilla/mux web 工具包,通过支持在每个请求上调用的“中间件”处理函数,使这一过程变得相对简单:

const MaxQueueDepth = 1000

// Middleware function, which will be called for each request.
// If queue depth is exceeded, it returns HTTP 503 (service unavailable).
func loadSheddingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // CurrentQueueDepth is fictional and for example purposes only.
        if CurrentQueueDepth() > MaxQueueDepth {
            log.Println("load shedding engaged")

            http.Error(w,
                err.Error(),
                http.StatusServiceUnavailable)
            return
        }

        next.ServeHTTP(w, r)
    })
}

func main() {
    r := mux.NewRouter()

    // Register middleware
    r.Use(loadSheddingMiddleware)

    log.Fatal(http.ListenAndServe(":8080", r))
}

每次请求都会调用 Gorilla Mux 中间件,每个中间件接收一个请求,对其进行处理,并将其传递给另一个中间件或最终处理程序。这使它们非常适合实现通用请求日志记录、头部操作、ResponseWriter 劫持,或者在我们的情况下,资源响应式负载放弃。

我们的中间件使用虚构的CurrentQueueDepth()(实际函数将取决于您的实现)来检查当前队列深度,如果值过高,则拒绝带有 HTTP 503(服务不可用)的请求。更复杂的实现甚至可以通过优先处理特别重要的请求来智能选择放弃哪些工作。

优雅的服务退化

资源敏感的负载抛弃效果很好,但在某些应用中,当服务接近超载时,通过显著降低响应质量,可以更加优雅地行事。这种优雅的退化将负载抛弃的概念推向更深层次,通过战略性地减少满足每个请求所需的工作量,而不仅仅是拒绝请求。

做这件事的方法多种多样,服务不能都以合理的方式退化,但常见的方法包括回退到缓存数据或使用更便宜的(虽然不那么精确的)算法。

重播:重试请求

当请求收到错误响应或根本没有收到响应时,应该再次尝试,对吧?嗯,有点像。重试是有道理的,但事情比这更复杂。

以这个片段为例,我在一个生产系统中找到了这样一个版本:

res, err := SendRequest()
for err != nil {
    res, err = SendRequest()
}

看起来很诱人和直接,不是吗?它重复失败的请求,但也确实会这样做。所以当这个逻辑部署到数百台服务器,并且向其中一个服务发出请求时失败时,整个系统都崩溃了。回顾服务指标,如图 9-2 所示。

cngo 0902

图 9-2. “重试风暴”的解剖学

看起来当下游服务失败时,我们的服务——每个实例——都进入了其重试循环,每秒发出成千上万的请求,严重使网络瘫痪,以至于我们不得不基本上重新启动整个系统。

这实际上是一种非常常见的级联故障,被称为重试风暴。在重试风暴中,本意是增加组件的弹性的逻辑,反而对更大的系统产生了不利影响。很多时候,即使导致下游服务停机的条件得到解决,它也无法重新启动,因为立即承受了过多的负载。

但是,重试是好事,对吧?

是的,但无论何时实现重试逻辑,都应始终包含退避算法,我们将在下一节方便地讨论它。

退避算法

当由于任何原因而导致对下游服务的请求失败时,“最佳”实践是重试该请求。但应等待多长时间呢?如果等待时间过长,可能会延迟重要工作。如果时间太短,则可能会使目标或网络过载,甚至两者兼而有之。

通常的解决方案是实现一个退避算法,介绍一个延迟以减少尝试的频率,使其保持在安全和可接受的速率。

有各种各样的退避算法可供选择,其中最简单的是在重试之间包含一个短暂的固定时长暂停,如下所示:

res, err := SendRequest()
for err != nil {
    time.Sleep(2 * time.Second)
    res, err = SendRequest()
}

在前面的代码片段中,SendRequest用于发出请求,返回字符串和错误值。然而,如果err不是nil,则代码进入循环,在收到非错误响应之前每两秒重试一次,无限重复。

在图 9-3 中,我们展示了使用这种方法模拟的 1,000 个实例生成的请求数量。¹⁰ 正如你所见,尽管固定延迟方法相较于没有任何退避的情况可以减少请求计数,但总体请求数量仍然相当高。

cngo 0903

图 9-3. 使用两秒重试延迟的 1,000 个模拟实例的每秒请求量

如果您只有极少量的重试实例,固定时长的退避延迟可能会工作得很好,但随着足够数量的请求者仍然有可能使网络不堪重负,这种方法并不适合扩展。

然而,我们不能总是假设任何给定服务的实例数量足够小,以至于不会因重试而使网络超负荷,也不能假设我们的服务是唯一进行重试的服务。因此,许多退避算法实现了指数退避,其中重试之间的延迟持续时间大致每次尝试时加倍,直到某个固定的最大值。

一个非常常见(但有缺陷,您很快就会看到)的指数退避实现可能看起来像以下代码片段:

res, err := SendRequest()
base, cap := time.Second, time.Minute

for backoff := base; err != nil; backoff <<= 1 {
    if backoff > cap {
        backoff = cap
    }
    time.Sleep(backoff)
    res, err = SendRequest()
}

在此代码片段中,我们指定了起始时长base和固定的最大时长cap。在循环中,backoff的值从base开始,每次迭代加倍,直到达到cap的最大值。

这种逻辑可能会让人觉得能够减轻网络负载和重试请求对下游服务的负担。然而,对 1,000 个节点进行模拟实现却讲述了另一个故事,详见图 9-4。

cngo 0904

图 9-4. 使用指数退避的 1,000 个模拟实例的每秒请求量

看起来即使有 1,000 个节点的重试计划完全相同也并不是最佳选择,因为重试现在可能会聚集,可能在过程中生成足够的负载以引起问题。因此,在实践中,纯指数退避并不一定会像我们希望的那样有所帮助。

看起来我们需要一种方法来分散这些峰值,以便重试以大致恒定的速率发生。解决方案是添加一个称为jitter的随机元素。将我们之前的退避函数加入 jitter 后,得到的代码片段如下所示:

res, err := SendRequest()
base, cap := time.Second, time.Minute

for backoff := base; err != nil; backoff <<= 1 {
    if backoff > cap {
        backoff = cap
    }

    jitter := rand.Int63n(int64(backoff * 3))
    sleep := base + time.Duration(jitter)
    time.Sleep(sleep)
    res, err = SendRequest()
}

在 1,000 个节点上运行此代码的模拟产生了 图 9-5 中呈现的模式。

cngo 0905

图 9-5. 使用指数退避和抖动的 1,000 个模拟实例的请求/秒
警告

rand 包的顶级函数每次程序运行时产生确定性的值序列。如果不使用 rand.Seed 函数提供一个新的种子值,它们的行为就像由 rand.Seed(1) 预先设定种子,并且总是产生相同的“随机”数序列。

当我们使用指数退避和抖动时,重试次数会在一个较短的间隔内减少,以免过度压力正在尝试上线的服务,并且会将它们分散在时间上,使其以大致恒定的速率发生。

谁会想到重试请求还有更多的内容呢?

断路器模式

我们在 第四章 中首次介绍了断路器模式,作为降低可能失败的方法调用的功能,以防止更大或级联的故障。该定义仍然有效,因为我们不打算过多扩展或更改它,所以我们不会在这里过于详细地讨论它。

总结一下,断路器模式跟踪对下游组件发出的连续失败请求的数量。如果失败计数超过某个阈值,则“打开”断路器,并且所有尝试发出额外请求的操作立即失败(或返回一些定义好的备用)。在等待一段时间后,断路器会自动“关闭”,恢复其正常状态,并允许正常发出请求。

提示

不是所有的弹性模式都是防御性的。

有时做一个好邻居是值得的。

正确应用的断路器模式可以使系统恢复和级联故障之间产生巨大差异。除了显而易见地节省资源或阻塞网络以免被注定的请求之外,一个断路器(特别是带有退避功能的断路器)可以为发生故障的服务提供足够的空间来恢复,使其能够重新上线并恢复正确的服务。

断路器模式在 第四章 中有详细介绍,因此在这里我们只会简单提一下。查看 “断路器” 获取更多背景和代码示例。将抖动添加到示例的退避函数中留给读者作为练习¹¹。

超时

并非总是能够充分认识到超时的重要性。然而,客户端能够识别出请求不太可能被满足的能力,允许客户端释放它可能持有的资源,以及它可能代表的任何上游请求者。对于一个服务来说,这同样适用,它可能会发现自己持有请求,直到客户端放弃之后很久。

例如,想象一个查询数据库的基本服务。如果该数据库突然变慢,导致查询需要几秒钟才能完成,那么服务的请求——每个请求保持一个数据库连接——可能会累积,最终耗尽连接池。如果数据库是共享的,甚至可能导致其他服务失败,从而导致级联失败。

如果服务超时而不是继续持有数据库,它可能会降级服务而不是直接失败。

换句话说,如果你认为你将会失败,那就快速失败。

使用上下文进行服务端超时控制

我们首次在第四章介绍了context.Context作为 Go 在进程之间传递截止日期和取消信号的惯用方式。¹² 如果你想要回顾一下,或者只是想让自己在继续之前进入正确的思维状态,请看看“上下文包”。

你可能还记得,同一章节的后面,在“超时”一节中,我们介绍了超时模式,它使用Context不仅允许一个进程在明确得出结果不会到来时停止等待答案,还通知其他函数使用衍生的Context停止工作并释放它们可能持有的任何资源。

这种不仅可以取消本地函数,还可以取消子函数的能力非常强大,以至于通常认为,如果函数可能运行的时间比调用者想要等待的时间长,那么接受Context值是一种良好的做法,这几乎总是正确的,如果调用穿越网络。

因此,在 Go 标准库中分散的许多优秀的Context接受函数示例。这些示例可以在包括sql包中找到,它包含了许多接受Context的函数的版本。例如,DB结构体的QueryRow方法有一个等效的QueryRowContext,接受一个Context值。

使用这种技术提供基于 ID 值的用户名称的函数可能看起来像以下的样子:

func UserName(ctx context.Context, id int) (string, error) {
    const query = "SELECT username FROM users WHERE id=?"

    dctx, cancel := context.WithTimeout(ctx, 15*time.Second)
    defer cancel()

    var username string
    err := db.QueryRowContext(dctx, query, id).Scan(&username)

    return username, err
}

UserName函数接受一个context.Context和一个整数id,但它还创建了自己的衍生Context,具有相当长的超时时间。这种方法提供了一个默认超时,自动在 15 秒后释放任何打开的连接——比许多客户端愿意等待的时间长——同时还能响应来自调用者的取消信号。

对外部取消信号的响应非常有用。http框架提供了另一个很好的例子,正如在以下的UserGetHandler HTTP 处理函数中演示的那样:

func UserGetHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id := vars["id"]

    // Get the request's context. This context is canceled when
    // the client's connection closes, the request is canceled
    // (with HTTP/2), or when the ServeHTTP method returns.
    rctx := r.Context()

    ctx, cancel := context.WithTimeout(rctx, 10*time.Second)
    defer cancel()

    username, err := UserName(ctx, id)

    switch {
    case errors.Is(err, sql.ErrNoRows):
        http.Error(w, "no such user", http.StatusNotFound)
    case errors.Is(err, context.DeadlineExceeded):
        http.Error(w, "database timeout", http.StatusGatewayTimeout)
    case err != nil:
        http.Error(w, err.Error(), http.StatusInternalServerError)
    default:
        w.Write([]byte(username))
    }
}

UserGetHandler中,我们首先通过其Context方法获取请求的Context。方便的是,当客户端连接关闭、请求被取消(使用 HTTP/2)或ServeHTTP方法返回时,这个Context就会被取消。

我们从这里创建一个衍生的上下文,应用我们自己的显式超时,这将在 10 秒后无论如何都会取消Context

由于衍生的上下文被传递给UserName函数,我们能够在关闭 HTTP 请求和关闭数据库连接之间划出直接因果关系:如果请求的Context关闭,所有衍生的Context也会关闭,最终以一种松耦合的方式确保所有打开的资源也被释放。

超时 HTTP/REST 客户端调用

回顾起“便利函数可能存在的陷阱”,我们曾经介绍过http“便利函数”如http.Gethttp.Post的一个陷阱:它们使用默认超时时间。不幸的是,默认的超时时间值为0,这在 Go 语言中被解释为“没有超时”。

我们在此前提到的为客户端方法设置超时机制的机制是创建一个带有非零超时值的自定义Client值,如下所示:

var client = &http.Client{
    Timeout: time.Second * 10,
}

response, err := client.Get(url)

这个方法运行得非常好,实际上,将会以与其Context被取消时完全相同的方式取消请求。但是如果您想要使用现有或衍生的Context值呢?为此,您需要访问底层的Context,可以通过使用http.NewRequestWithContext来获得,这是http.NewRequest接受Context的等效版本,允许程序员指定控制请求及其响应整个生命周期的Context

这并不像看上去的那么大的偏离。事实上,查看http.Client上的Get方法的源代码会发现,在底层它只是使用NewRequest

func (c *Client) Get(url string) (resp *Response, err error) {
    req, err := NewRequest("GET", url, nil)
    if err != nil {
        return nil, err
    }

    return c.Do(req)
}

正如您所见,标准的Get方法调用NewRequest创建一个*Request值,传递了方法名和 URL(最后一个参数接受可选的io.Reader作为请求体,但在这里我们不需要)Do函数执行实际的请求。

不计算错误检查和返回,整个方法只包含一个调用。看起来,如果我们想要实现类似的接受Context值的功能,我们可以轻松地做到。

实现这个的一种方法可能是实现一个接受Context值的GetContext函数:

type ClientContext struct {
    http.Client
}

func (c *ClientContext) GetContext(ctx context.Context, url string)
        (resp *http.Response, err error) {

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, err
    }

    return c.Do(req)
}

我们的新GetContext函数在功能上与规范的Get完全相同,只是它还接受一个Context值,用于调用http.NewRequestWithContext而不是http.NewRequest

使用我们的新ClientContext与使用标准的http.Client值非常相似,只不过我们不是调用client.Get,而是调用client.GetContext(当然要传递Context值):

func main() {
    client := &ClientContext{}
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    response, err := client.GetContext(ctx, "http://www.example.com")
    if err != nil {
        log.Fatal(err)
    }

    bytes, _ := ioutil.ReadAll(response.Body)
    fmt.Println(string(bytes))
}

但它有效吗?这不是一个 正确 的测试,因为没有测试库,但我们可以通过将截止日期设置为 0 并运行它来手动试探:

$ go run .
2020/08/25 14:03:16 Get "http://www.example.com": context deadline exceeded
exit status 1

看起来它确实能工作!太棒了。

gRPC 客户端调用超时

就像 http.Client 一样,gRPC 客户端默认为“无超时”,但也允许显式设置超时。

正如我们在 “实现 gRPC 客户端” 中看到的那样,gRPC 客户端通常使用 grpc.Dial 函数来建立与客户端的连接,并且可以通过像 grpc.WithInsecuregrpc.WithBlock 这样的函数构造 grpc.DialOption 值列表将其传递给它以配置连接的设置方式。

其中一种选项是 grpc.WithTimeout,可用于配置客户端拨号超时:

opts := []grpc.DialOption{
    grpc.WithInsecure(),
    grpc.WithBlock(),
    grpc.WithTimeout(5 * time.Second),
}
conn, err := grpc.Dial(serverAddr, opts...)

然而,虽然 grpc.WithTimeout 在表面上看起来可能很方便,但实际上它已经被弃用了相当长的时间,主要是因为其机制与首选的 Context 超时方法不一致(并且是多余的)。我们在这里展示它只是为了完整性的缘故。

警告

grpc.WithTimeout 选项已被弃用,并将最终移除。请改用 grpc.DialContextcontext.WithTimeout

相反,设置 gRPC 拨号超时的首选方法是非常方便的(对我们而言)grpc.DialContext 函数,它允许我们使用(或重用)context.Context 值。这实际上是双重有用的,因为 gRPC 服务方法本来就接受 Context 值,所以实际上根本没有额外的工作要做:

func TimeoutKeyValueGet() *pb.Response {
    // Use context to set a 5-second timeout.
    ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
    defer cancel()

    // We can still set other options as desired.
    opts := []grpc.DialOption{grpc.WithInsecure(), grpc.WithBlock()}

    conn, err := grpc.DialContext(ctx, serverAddr, opts...)
    if err != nil {
        grpclog.Fatalf(err)
    }
    defer conn.Close()

    client := pb.NewKeyValueClient(conn)

    // We can reuse the same Context in the client calls.
    response, err := client.Get(ctx, &pb.GetRequest{Key: key})
    if err != nil {
        grpclog.Fatalf(err)
    }

    return response
}

如广告所述,TimeoutKeyValueGet 使用 grpc.DialContext ——我们向其传递了一个带有 5 秒超时的 context.Context 值——而不是 grpc.Dial。除了显然不再包括 grpc.WithTimeout 外,opts 列表在其他方面是相同的。

请注意 client.Get 方法调用。如前所述,gRPC 服务方法接受 Context 参数,因此我们只需重用现有的 Context。重要的是,重用相同的 Context 值将在相同的超时计算下限制两个操作——Context 将超时,无论它如何使用——因此在计划超时值时务必考虑到这一点。

幂等性

正如我们在 第四章 顶部讨论的那样,云原生应用基本上存在于并受网络世界的所有特殊性影响。网络——所有网络——都是不可靠的,消息发送到达目的地的时间(或根本不到达)并不总是准时。

更重要的是,如果发送了消息却没有收到响应,那么你无法知道发生了什么。消息在传递到接收者时丢失了吗?接收者接收到消息了,但响应丢失了吗?也许一切都运行良好,只是往返时间比平常长一点?

在这种情况下,唯一的选择是重新发送消息。但仅仅指望运气是不够的。通过设计函数以实现幂等性,计划处理这种必然性非常重要。

你可能记得我们在 “什么是幂等性以及其重要性?” 简要介绍了幂等性的概念,其中我们将幂等操作定义为多次应用与单次应用具有相同效果的操作。正如 HTTP 的设计者所了解的那样,这也是任何云原生 API 的重要属性,保证任何通信可以安全重复执行(请参阅 “Web 上幂等性的起源” 以了解相关历史)。

实现幂等性的具体方法因服务而异,但在本节的其余部分我们将审查一些一致的模式。

如何使我的服务幂等?

幂等性并没有内建到任何特定框架的逻辑中。即使在 HTTP——以及由此推广的 REST 中——幂等性也是一种约定,而非明确强制执行的规范。如果真的希望,没有什么能阻止你实现一个非幂等的 GET 请求,不管是出于疏忽还是故意。¹⁶

幂等性有时如此棘手的原因之一是因为它依赖于内建到核心应用程序中的逻辑,而不是在 REST 或 gRPC API 层。例如,如果在第五章中,我们希望使我们的键值存储与传统的 CRUD(创建、读取、更新和删除)操作一致(因此是幂等的),我们可能会做如下操作:

var store = make(map[string]string)

func Create(key, value string) error {
    if _, ok := store[key]; ok {
        return errors.New("duplicate key")
    }

    store[key] = value
    return nil
}

func Update(key, value string) error {
    if _, ok := store[key]; !ok {
        return errors.New("no such key")
    }

    store[key] = value
    return nil
}

func Delete(key string) error {
    if _, ok := store[key]; ok {
        return errors.New("no such key")
    }

    delete(store, key)
    return nil
}

这种类似 CRUD 的服务实现可能完全出于善意,但如果这些方法中的任何一个必须重复执行,则会导致错误。更重要的是,在检查当前状态时涉及的逻辑量也相当大,在等效的幂等性实现中则是多余的,例如下面的实现:

var store = make(map[string]string)

func Set(key, value string) {
    store[key] = value
}

func Delete(key string) {
    delete(store, key)
}

这个版本要简单得多,不仅仅是一种方式。首先,我们不再需要单独的“创建”和“更新”操作,因此可以将它们合并为单个Set函数。此外,不再需要在每个操作中检查当前状态,这减少了每种方法中的逻辑,随着服务复杂度的增加,这一好处将继续产生回报。

最后,如果一个操作需要重复执行,这没有什么大不了的。对于SetDelete函数,多次相同的调用将产生相同的结果。它们是幂等的。

标量操作怎么样?

“因此”,你可能会说,“对于那些只能完成未完成的操作来说这都很好,但对于更复杂的操作怎么办?例如标量值的操作?”

这是一个公平的问题。毕竟,把一件东西放在一个地方是一回事:它要么已经被放置了,要么没有。你只需不返回重新放置的错误。好吧。

但是像“向帐户 12345 添加 $500”这样的操作呢?这样的请求可能携带一个 JSON 负载,看起来像以下内容:

{
    "credit":{
        "accountID": 12345,
        "amount": 500
    }
}

重复执行此操作将导致额外的 $500 存入帐户 12345,尽管帐户所有者可能不太介意,但银行可能会。

但是考虑一下,当我们向我们的 JSON 负载添加 transactionID 值时会发生什么:

{
    "credit":{
        "accountID": 12345,
        "amount": 500,
        "transactionID": 789
    }
}

这可能需要一些更多的簿记工作,但这种方法为我们的困境提供了可行的解决方案。通过跟踪 transactionID 值,接收方可以安全地识别并拒绝重复的交易。达到幂等性!

服务冗余

冗余——系统关键组件或功能的重复,旨在提高系统的可靠性——通常是面对故障时增加韧性的第一道防线。

我们已经讨论过一种特定类型的冗余——消息冗余,也称为“重试”——在 “再玩一次:重试请求” 中。然而,在本节中,我们将考虑复制关键系统组件的价值,以便如果任何一个组件失败,一个或多个其他组件可以接管其工作。

在公共云中,这意味着将您的组件部署到多个服务器实例,理想情况下跨多个区域甚至多个地区。在像 Kubernetes 这样的容器编排平台上,这甚至可能只是将副本数量设置为大于一的值。

尽管这个主题很有趣,我们实际上不会在其上花费太多时间。服务复制是一个已经在许多其他来源中彻底讨论过的架构主题。¹⁷ 毕竟,这应该是一本 Go 语言的书。但是,如果我们在关于韧性的整章中甚至不提到它,我们会感到遗憾的。

冗余设计

设计一个系统,使其功能能够在多个实例之间复制的努力可能会带来显著的回报。但具体来说有多少呢?嗯...很多。如果你对数学感兴趣,可以随意查看下面的内容,但如果你不感兴趣,你可以相信我。

自动缩放

通常情况下,服务所受的负载量随时间变化而变化。典型的例子是用户面向的 Web 服务,白天负载增加,夜间减少。如果这样的服务建立在处理高峰负载的基础上,晚上就会浪费时间和金钱。如果只建立在处理夜间负载的基础上,白天就会过度负荷。

自动缩放是一种建立在负载均衡思想基础上的技术,通过自动添加或删除资源——无论是云服务器实例还是 Kubernetes Pod——来动态调整容量,以满足当前需求的各种流量模式,无论是预期的还是意外的。

作为额外的奖励,将自动缩放应用到您的集群可以根据服务需求调整资源大小,从而节省成本。

所有主要的云提供商都提供了扩展服务器实例的机制,并且它们的大多数托管服务都隐含或显式支持自动缩放。像 Kubernetes 这样的容器编排平台也包括支持自动缩放的功能,无论是 Pod 数量(水平自动缩放)还是它们的 CPU 和内存限制(垂直自动缩放)。

自动缩放机制在云提供商和编排平台之间差异很大,因此详细讨论如何收集指标和配置预测性自动缩放等内容超出了本书的范围。然而,有几个关键点需要记住:

  • 设置合理的最大限制,以防止需求出现异常大的峰值(或者更糟的是,级联故障)完全超出预算。我们在“防止过载”中讨论的限流和负载放弃技术在这里也很有用。

  • 减少启动时间。如果您使用服务器实例,请预先制作机器镜像,以减少启动时的配置时间。这在 Kubernetes 上不是很严重的问题,但容器镜像仍应保持较小并且启动时间合理短。

  • 无论您的启动有多快,缩放都需要一定的时间。您的服务应该有一些余地,而不必进行缩放。

  • 正如我们在“延迟扩展:效率”中讨论的那样,最好的扩展是永远不需要发生的扩展。

健康的健康检查

在“服务冗余”中,我们简要讨论了冗余的价值——系统关键组件或功能的复制,旨在提高整体系统的可靠性——以及它对提高系统韧性的价值。

多个服务实例意味着需要负载均衡机制——服务网格或专用负载均衡器——但当服务实例出现问题时会发生什么?当然,我们不希望负载均衡器继续向其发送流量。那么我们该怎么办?

进入健康检查。在其最简单和最常见的形式中,健康检查被实现为一个 API 端点,客户端——负载均衡器,以及监控服务、服务注册表等——可以使用该端点询问服务实例是否活着且健康。

健康检查就像布隆过滤器。失败的健康检查意味着服务不可用,但通过的健康检查意味着服务 可能 是“健康的”。(引用:Cindy Sridharan²⁰)

拥有一个可以告诉客户端服务实例健康(或不健康)的端点听起来很棒,但这也引发了一个问题,那就是什么是一个实例“健康”的确切含义?

当服务实例失败时,通常是因为以下原因之一:

我们在服务和服务实例的上下文中使用“健康”一词,但是当我们说这个词时确切指的是什么呢?嗯,像通常情况一样,有简单的答案和复杂的答案。也可能有很多中间答案。

远程故障,如影响服务功能的某些依赖项——数据库或其他下游服务。

我们先从简单的答案开始。重新使用现有定义,当实例“可用”时,即能够提供正确的服务时,实例被认为是“健康的”。

提示

不幸的是,情况并非总是如此清晰。如果实例本身按预期运行,但下游依赖出现故障怎么办?健康检查是否应该区分这种情况?如果是,负载均衡器在每种情况下应该有不同的行为吗?如果所有服务副本都受到影响,实例是否应该被淘汰并替换?

不幸的是,对于这些问题并没有简单的答案,所以我不会提供答案,而是会提供次好的选择:讨论健康检查的三种最常见方法及其各自的优缺点。你的具体实现将取决于你的服务需求和负载平衡行为。

三种健康检查类型

实例何时被视为“健康”?

  • 例如,服务可能提供一个 HTTP 端点(/health/healthz 是常见的命名选择),如果副本健康则返回 200 OK,否则返回 503 Service Unavailable。更复杂的实现甚至可以针对不同的状态返回不同的状态码:HashiCorp 的 Consul 服务注册表将任何 2XX 状态视为成功,429 Too Many Requests 作为警告,其他任何状态视为失败。

  • 本地故障,如应用程序错误或资源(CPU、内存、数据库连接等)耗尽。

这两种广泛的故障类别导致了三种(是的,三种)健康检查策略的出现,每种都有其自身有趣的利弊。

存活检查 只返回“成功”信号。它们不会额外尝试确定服务的状态,并且除了表明服务正在监听和可达之外,没有其他信息。但有时候这已经足够了。我们将在 “存活检查” 中更多地讨论存活检查。

浅层健康检查 比活跃性检查更进一步,通过验证服务实例可能能够正常工作来确认。这些健康检查仅测试本地资源,因此在许多实例同时出现故障的情况下不太可能失败,但它们无法确定特定请求服务实例是否会成功。我们将在“浅层健康检查”中详细介绍浅层健康检查。

深层健康检查 提供了对实例健康状态更好的理解,因为它们实际上检查了服务实例执行其功能的能力,还会测试像数据库之类的下游资源。虽然全面,但它们可能很昂贵,并且容易出现虚假阳性。我们将在“深层健康检查”中深入探讨深层健康检查。

活跃性检查

活跃性端点始终返回“成功”值,无论如何。虽然这可能看似微不足道到毫无用处——毕竟,一个不提供关于健康状态的健康检查有何价值——但活跃性探针实际上可以通过确认以下方式提供一些有用信息:

  • 服务实例是否在预期端口上侦听并接受新连接

  • 实例是否可以通过网络访问

  • 任何防火墙、安全组或其他配置是否正确定义

当然,这种简单性是有可预见成本的。缺乏任何活跃健康检查逻辑使得活跃性检查在评估服务实例是否实际执行其功能方面的用途有限。

活跃性探针也很容易实现。使用 net/http 包,我们可以做到以下几点:

func healthLivenessHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
})

func main() {
    r := mux.NewRouter()
    http.HandleFunc("/healthz", healthLivenessHandler)
    log.Fatal(http.ListenAndServe(":8080", r))
}

前面的代码片段展示了进行活跃性检查时所需的少量工作。我们创建并注册了一个 /healthz 端点,它只返回一个 200 OK(以及为了完整起见的文本 OK)。

警告

如果您使用 gorilla/mux 包,则任何注册的中间件(例如来自“负载卸载”的负载卸载函数)都可能影响您的健康检查!

浅层健康检查

浅层健康检查比活跃性检查更进一步,通过验证服务实例可能能够正常运行来确认,但停止于不会对数据库或其他下游依赖进行任何实际测试。

浅层健康检查可以评估可能对服务产生不利影响的任何条件,包括(但不限于):

  • 关键本地资源(内存、CPU、数据库连接)的可用性

  • 能够读取或写入本地数据,检查磁盘空间、权限以及硬件故障,例如磁盘故障。

  • 支持进程的存在,如监控或更新程序

浅层健康检查比存活性检查更为明确,它们的特异性意味着任何故障不太可能立即影响整个机群。²¹ 但是,浅层检查容易产生误报:如果由于某些涉及外部资源的问题导致服务停止,浅层检查将无法发现。在特异性增加的同时,你也在灵敏性上做出了牺牲。

浅层健康检查可能看起来像以下示例,测试服务读写本地磁盘的能力:

func healthShallowHandler(w http.ResponseWriter, r *http.Request) {
    // Create our test file.
    // This will create a filename like /tmp/shallow-123456
    tmpFile, err := ioutil.TempFile(os.TempDir(), "shallow-")
    if err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }
    defer os.Remove(tmpFile.Name())

    // Make sure that we can write to the file.
    text := []byte("Check.")
    if _, err = tmpFile.Write(text); err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }

    // Make sure that we can close the file.
    if err := tmpFile.Close(); err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }

    w.WriteHeader(http.StatusOK)
}

func main() {
    r := mux.NewRouter()
    http.HandleFunc("/healthz", healthShallowHandler)
    log.Fatal(http.ListenAndServe(":8080", r))
}

这同时检查可用的磁盘空间、写入权限和故障硬件,这可能是一个非常有用的测试项目,特别是如果服务需要写入到磁盘缓存或其他临时文件时。

细心的读者可能会注意到,它写入了用于临时文件的默认目录。在 Linux 上,这是/tmp,实际上是一个 RAM 驱动器。这也许是一个有用的测试项目,但如果你想在 Linux 上测试写入磁盘的能力,你需要指定一个不同的目录,否则这将成为一个完全不同的测试。

深度健康检查

深度健康检查直接检查服务与其相邻系统交互的能力。这通过识别依赖关系的问题(如无效凭证、与数据存储的连接丢失或其他意外的网络问题),显著提升了实例健康状态的理解能力。

然而,尽管彻底,深度健康检查可能会非常昂贵。它们可能会花费很长时间,并对依赖项造成负担,特别是如果你运行了太多或者运行得太频繁的话。

小贴士

在健康检查中不要试图测试每一个依赖项,而是专注于服务操作所需的那些依赖项。

小贴士

在测试多个下游依赖项时,如果可能的话同时评估它们。

此外,由于依赖项的失败会被报告为实例的失败,深度检查尤其容易产生误报。再加上与浅层检查相比更低的特异性——依赖项问题会影响整个机群——可能导致级联故障的潜在风险。

如果你在使用深度健康检查,你应该利用像断路器(我们在“断路器”中介绍过的)这样的策略,并且尽可能让你的负载均衡器“失效开放”(我们将在“开放失败”中讨论)。

这里有一个可能的深度健康检查的简单示例,通过调用假设服务的GetUser函数来评估数据库:

func healthDeepHandler(w http.ResponseWriter, r *http.Request) {
    // Retrieve the context from the request and add a 5-second timeout
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    // service.GetUser is a hypothetical method on a service interface
    // that executes a database query
    if err := service.GetUser(ctx, 0); err != nil {
        http.Error(w, err.Error(), http.StatusServiceUnavailable)
        return
    }

    w.WriteHeader(http.StatusOK)
}

func main() {
    r := mux.NewRouter()
    http.HandleFunc("/healthz", healthDeepHandler)
    log.Fatal(http.ListenAndServe(":8080", r))
}

理想情况下,依赖项测试应执行实际的系统功能,同时在合理的程度上保持轻量级。例如,在这个示例中,GetUser 函数触发了一个数据库查询,满足了这两个标准。²²

“真实”的查询通常比仅仅对数据库进行 ping 操作更可取,原因有两点。首先,它们更能代表服务正在执行的操作。其次,它们允许利用端到端查询时间作为数据库健康状况的衡量标准。前面的示例确实做到了这一点——尽管方式非常二元化——通过使用 Context 设置一个硬性超时值,但您也可以选择包含更复杂的逻辑。

失败开放

如果您的所有实例同时决定它们都不健康了怎么办?如果您使用深度健康检查,这种情况实际上很容易发生(也许是定期发生)。根据负载均衡器的配置方式,您可能会发现自己的实例都不再提供流量服务,可能导致系统中的故障连锁反应。

幸运的是,一些负载均衡器通过“失败开放”策略处理这种情况非常巧妙。如果一个失败开放的负载均衡器没有健康的目标——即其所有目标的健康检查都失败了——它会将流量路由到所有目标上去。

这是一个略有违直觉的行为,但它使深度健康检查的使用变得更加安全,因为它允许流量继续流动,即使下游依赖可能出现故障。

总结

这是一个有趣的章节。关于弹性有很多值得说的内容,还有很多重要的支持运营背景。我不得不在哪些内容能进入书稿和哪些不能之间做出一些艰难的决定。尽管这一章约有 37 页,但实际上比我预期的长了一些,但我对结果非常满意。这是信息量不足和信息量过多之间的一个合理折中,也是运营背景和实际 Go 实现之间的一个折中。

我们审查了系统失败的含义,以及复杂系统如何失败(即逐个组件失败)。这自然引出了讨论一个特别恶性但常见的故障模式:级联故障。在级联故障中,系统试图恢复自身,却加速了其崩溃。我们涵盖了服务器端预防级联故障的常见措施:限流和负载调节。

在面对错误时进行重试可以极大地增强服务的弹性,但正如我们在 DynamoDB 案例研究中看到的那样,如果应用得不当,也可能导致级联故障。我们深入探讨了客户端可以采取的措施,包括断路器、超时和特别是指数退避算法。还涉及了几个漂亮的图表。我在图表上花费了很多时间。

所有这些都导致了关于服务冗余性的讨论,以及它如何影响可靠性(顺便加入了一点数学,增加乐趣),以及何时以及如何最佳地利用自动扩展。

当然,谈论自动扩展时不能不谈及资源“健康”问题。我们询问(并尽力回答)实例“健康”意味着什么,以及这如何转化为健康检查。我们涵盖了三种健康检查,并权衡了它们的优缺点,特别关注它们的相对灵敏度/特异性的权衡。

在第十章中,我们将暂时离开运营主题,深入探讨可管理性的主题:如何在行驶中的车辆上换轮胎的艺术和科学。

¹ Lamport, Leslie. DEC SRC 公告板,1987 年 5 月 28 日。https://oreil.ly/nD85V

² 美国东部地区亚马逊 DynamoDB 服务中断及相关影响总结。Amazon AWS,2015 年 9 月。https://oreil.ly/Y1P5S

³ Cook, Richard I. “复杂系统如何失败。” 1998 年。https://oreil.ly/WyJ4Q

⁴ 如果你对完整的学术研究感兴趣,我强烈推荐由 Kishor S. Trivedi 和 Andrea Bobbio 撰写的Reliability and Availability Engineering(剑桥大学出版社)。

⁵ 更重要的是,许多故障只有事后才能显现。

⁶ 看吧?我们最终做到了。

⁷ 继续吧,问我如何知道这一点。

⁸ 尤其是如果服务在公共互联网的开放下水道上可用。

⁹ Wikipedia 贡献者。“令牌桶。” 维基百科,自由的百科全书,2019 年 6 月 5 日。https://oreil.ly/vkOov

¹⁰ 本节中用于模拟所有数据的代码可以在关联的 GitHub 仓库中找到。

¹¹ 在这里这样做感觉有些多余,但我承认我可能有点懒。

¹² 严格来说,还有请求范围的值,但此功能的正确性有争议。

¹³ Fielding, R. 等人。“超文本传输协议 — HTTP/1.1”,建议标准,RFC 2068,1997 年 6 月。https://oreil.ly/28rcs

¹⁴ Berners-Lee, T. 等人。“超文本传输协议 — HTTP/1.0”,信息性,RFC 1945,1996 年 5 月。https://oreil.ly/zN7uo

¹⁵ Fielding, Roy Thomas. “体系结构风格与基于网络的软件架构设计。” 加州大学欧文分校,2000 年,第 76-106 页。https://oreil.ly/swjbd

¹⁶ 你这个怪物。

¹⁷ 构建安全可靠的系统:设计、实施和维护系统的最佳实践 由 Heather Adkins 及一众其他作者合著,是一个极好的例子。

¹⁸ 准备好了吗?我们要开始了。

¹⁹ 这假设组件的故障率绝对独立,这在现实世界中是非常不可能的。对待它就像你对待真空中的球形奶牛一样。

²⁰ Sridharan, Cindy (@copyconstruct)。"Health checks are like bloom filters…" 2018 年 8 月 5 日上午 3:21。推特。https://oreil.ly/Qpw3d

²¹ 尽管我见过这种情况发生。

²² 这是一个虚构的函数,所以我们就认为这是真的吧。

第十章:可管理性

每个人都知道调试比一开始编写程序要难两倍。所以,如果您在编写代码时尽可能聪明,您将如何调试它呢?¹

Brian Kernighan,《程序设计风格的要素》(1978 年)

在完美的世界里,您永远不需要部署新版本的服务,或者(天哪!)关闭整个系统来修复或修改以满足新的需求。

不过,在完美的世界里,独角兽会存在,五位牙医中会有四位建议我们早餐吃派。²

显然,我们不生活在一个完美的世界。但是尽管独角兽可能永远不存在,³ 您不必接受一个每次需要修改系统行为时都需要更新代码的世界。

虽然您可能总是需要进行代码更改来更新核心逻辑,但是有可能构建您的系统,以便您——或者关键时刻,其他人——可以在无需重新编码和重新部署的情况下改变多种行为。

您可能还记得我们在《“可管理性”》(ch01.xhtml#section_ch01_manageability)中介绍了这个云原生系统的重要属性,我们定义它为系统行为可以轻松修改以保持安全运行顺畅,并与变化的需求符合。

尽管听起来很简单,但可管理性实际上比您想象的要复杂得多。它远不止配置文件(尽管这当然是其中的一部分)。在本章中,我们将讨论什么是可管理的系统,以及一些技术和实现,使您能够构建一个几乎可以像其需求一样快速改变的系统。

什么是可管理性以及我为什么要关心它?

在考虑可管理性时,通常会从单个服务的角度思考。我的服务是否可以轻松配置?它是否拥有可能需要的所有旋钮和按钮?

然而,通过专注于组件而忽视系统,这忽略了更大的观点。可管理性不仅限于服务边界。要使系统可管理,必须考虑整个系统。

请花些时间考虑在复杂系统中重新考虑可管理性。它的行为是否可以轻松修改?它的组件是否可以相互独立地修改?如果必要,它们是否可以轻松替换?我们如何知道何时需要这样做?

可管理性包括系统行为的所有可能维度。可以说其功能可分为四个广泛的类别:⁵

配置和控制

设置和配置系统及其各个组件应该易于配置,以实现最佳的可用性和性能是非常重要的。一些系统需要定期或实时控制,因此拥有正确的“旋钮和杠杆”是绝对基础的。这将是本章大部分关注的焦点。

监控、日志和警报

这些功能跟踪系统执行其工作的能力,对于有效的系统管理至关重要。毕竟,没有它们,我们怎么知道我们的系统何时需要管理呢?尽管这些功能对可管理性至关重要,但我们不会在本章讨论它们。相反,它们将在第十一章,可观察性中专门讨论。

部署和更新

即使在没有代码更改的情况下,轻松部署、更新、回滚和扩展系统组件的能力也是非常有价值的,尤其是当需要管理许多系统时。显然,在初始部署期间这非常有用,但是在系统的整个生命周期中进行更新也同样重要。幸运的是,Go 语言在这方面表现出色,其缺乏外部运行时和单一的可执行构件使其非常优秀。

服务发现和库存

云原生系统的一个关键特性是其分布式性质。组件能够快速准确地检测彼此非常关键,这被称为服务发现功能。由于服务发现是一种架构特性而不是程序特性,我们在本书中不会深入探讨它。

因为这本书更像是一本关于 Go 语言而不是架构的书籍⁶,它主要集中于服务实现。正因如此——并非因为它更重要——本章的大部分内容将同样聚焦在服务级配置上。不幸的是,对这些内容的深入讨论超出了本书的范围⁷。

管理复杂的计算系统通常是困难且耗时的,其管理成本可能远远超过底层硬件和软件的成本。按定义,一个设计为可管理的系统可以更高效地进行管理,因此成本更低。即使不考虑管理成本,减少复杂性也可以极大地影响人为错误的发生概率,使其更容易和更快速地纠正。因此,可管理性直接影响可靠性、可用性和安全性,是系统可靠性的关键因素。

配置您的应用程序

可管理性的最基本功能是配置应用程序的能力。在一个理想的可配置应用程序中,任何在不同环境(如测试、生产、开发环境等)之间可能变化的内容都应该与代码清晰分离,并以某种外部可定义的方式定义。

您可能还记得 The Twelve-Factor App——我们在 第六章 中介绍的用于构建 Web 应用程序的十二条规则和指南——对这个主题有很多看法。事实上,它的十二条规则中的第三条——“III. 配置”——完全关注应用程序配置,关于这一点,它说:

在环境中存储配置。

正如 The Twelve-Factor App 所述,所有配置应存储在环境变量中。对此有很多不同看法,但自从其出版以来,行业似乎已达成普遍共识,即真正重要的是:

配置应严格与代码分离。

配置——任何可能在不同环境中有所不同的内容——应始终与代码清晰分离。虽然配置在不同部署中可能有很大差异,但代码则不然。配置不应该被硬编码到代码中。永远不要这样做。

配置应存储在版本控制中。

将配置存储在版本控制中——与代码分开——允许您在必要时快速回滚配置更改,并有助于系统的重建和恢复。一些部署框架,如 Kubernetes,通过提供像 ConfigMap 这样的配置原语,使这种区分自然且相对无缝。

如今,看到应用程序主要通过环境变量进行配置仍然很常见,但看到带有各种格式的命令行标志和配置文件也同样普遍。有时,一个应用程序甚至会支持多种这些选项。在接下来的章节中,我们将审查其中一些方法,它们的各种优缺点,以及如何在 Go 中实现它们。

配置良好实践

在构建应用程序时,您有很多选择来定义、实施和部署应用程序配置。然而,根据我的经验,我发现某些通用实践能够产生更好的长期和短期结果:

版本控制您的配置。

是的,我在重复,但这是值得重复的。在部署到系统之前,配置文件应存储在版本控制中。这使得在部署之前能够审查它们,在之后能够快速引用它们,并在必要时能够快速回滚更改。如果(以及当)需要重新创建和恢复系统时,这也是有帮助的。

不要自己发明格式。

使用 JSON、YAML 或 TOML 等标准格式编写配置文件。我们将在本章后面介绍其中一些。如果 必须 自己发明格式,请确保您对维护它的想法感到满意,并且迫使任何未来的维护人员永远处理它。

让零值有用。

不要无谓地使用非零默认值。总的来说,这是一个很好的规则;甚至有一个关于它的“Go 谚语”。⁸ 在可能的情况下,未定义配置所导致的行为应该是可接受的、合理的和不令人惊讶的。简单、最小化的配置可以减少错误的可能性。

使用环境变量进行配置

正如我们在 第六章 中讨论过,并且之前审查过的,使用环境变量定义配置值是《十二要素应用》中提倡的方法。这种偏好确实有其合理性:环境变量得到广泛支持,它们确保配置不会意外地被检入代码中,并且使用它们通常需要的代码比使用配置文件少。对于小型应用程序来说,它们也完全足够了。

另一方面,设置和传递环境变量的过程可能会显得丑陋、乏味和冗长。虽然一些应用程序支持在文件中定义环境变量,但这在很大程度上削弱了首次使用环境变量的初衷。

环境变量的隐式特性也可能带来一些挑战。由于无法通过查看现有配置文件或检查帮助输出轻松了解环境变量的存在和行为,依赖于它们的应用有时可能更难使用,并且其中的错误更难调试。

与大多数高级语言一样,Go 使得环境变量很容易访问。它通过标准的os包实现这一点,该包提供了os.Getenv函数用于此目的:

name := os.Getenv("NAME")
place := os.Getenv("CITY")

fmt.Printf("%s lives in %s.\n", name, place)

os.Getenv函数获取由键命名的环境变量的值,但如果变量不存在,则返回一个空字符串。如果需要区分空值和未设置值,Go 还提供了os.LookEnv函数,它返回值和一个布尔值,如果变量未设置则为false

if val, ok := os.LookupEnv(key); ok {
    fmt.Printf("%s=%s\n", key, val)
} else {
    fmt.Printf("%s not set\n", key)
}

这个功能非常基本,但对于许多(如果不是大多数)用途来说完全足够了。如果你需要更复杂的选项,如默认值或类型化变量,有几个出色的第三方包可以提供这些功能。 Viper(spf13/viper)——我们将在 “Viper: The Swiss Army Knife of Configuration Packages” 中讨论它——特别受欢迎。

使用命令行参数进行配置

作为配置方法,命令行参数绝对值得考虑,至少对于较小、不太复杂的应用程序来说是如此。毕竟,它们是显式的,并且它们的存在和用法细节通常可以通过--help选项获得。

标准标志包

Go 语言包含flag包,这是其标准库中的基本命令行解析包。虽然flag包功能不是特别丰富,但使用起来相当简单,并且——不像os.Getenv——支持开箱即用的类型。

举个例子,下面的程序使用flag实现了一个基本命令,读取并输出命令行标志的值:

package main

import (
    "flag"
    "fmt"
)

func main() {
    // Declare a string flag with a default value "foo"
    // and a short description. It returns a string pointer.
    strp := flag.String("string", "foo", "a string")

    // Declare number and Boolean flags, similar to the string flag.
    intp := flag.Int("number", 42, "an integer")
    boolp := flag.Bool("boolean", false, "a boolean")

    // Call flag.Parse() to execute command-line parsing.
    flag.Parse()

    // Print the parsed options and trailing positional arguments.
    fmt.Println("string:", *strp)
    fmt.Println("integer:", *intp)
    fmt.Println("boolean:", *boolp)
    fmt.Println("args:", flag.Args())
}

正如你从前面的代码中看到的,flag包允许你注册带有类型、默认值和简短描述的命令行标志,并将这些标志映射到变量。我们可以通过运行程序并传递-help标志来查看这些标志的摘要:

$ go run . -help
Usage of /var/folders/go-build618108403/exe/main:
  -boolean
        a boolean
  -number int
        an integer (default 42)
  -string string
        a string (default "foo")

帮助输出向我们展示了所有可用标志的列表。运行所有这些标志,结果如下:

$ go run . -boolean -number 27 -string "A string." Other things.
string: A string.
integer: 27
boolean: true
args: [Other things.]

它有效果!但是,flag包似乎有一些限制其实用性的问题。

首先,你可能已经注意到,生成的标志语法似乎有点不太标准。我们许多人已经习惯了命令行界面遵循GNU 参数标准,长名称选项由两个破折号(--version)前缀,而短的单字母等效项(-v)。

其次,flag的功能仅限于解析标志(虽然公平地说,它并没有声称做得更多),虽然这很好,但它的功能远不及它可能达到的水平。如果我们能够将命令映射到函数,那就太好了,不是吗?

Cobra 命令行解析器

如果你只需解析标志,flags包完全可以胜任,但如果你需要更强大的东西来构建命令行界面,你可能会考虑Cobra 包。Cobra 有许多功能,使它成为构建功能齐全的命令行界面的热门选择。它被用于许多知名项目,包括 Kubernetes、CockroachDB、Docker、Istio 和 Helm。

除了提供完全符合 POSIX 标准的标志(长短版本),Cobra 还支持嵌套子命令,并自动生成各种 Shell 的帮助(--help)输出和自动完成。它还与 Viper 集成,我们将在“Viper:配置包的瑞士军刀”中介绍它。

你可能会想到,Cobra 的主要缺点是相对于flags包来说相当复杂。使用 Cobra 来实现“标准标志包”中的程序看起来像这样:

package main

import (
    "fmt"
    "os"
    "github.com/spf13/cobra"
)

var strp string
var intp int
var boolp bool

var rootCmd = &cobra.Command{
    Use:  "flags",
    Long: "A simple flags experimentation command, built with Cobra.",
    Run:  flagsFunc,
}

func init() {
    rootCmd.Flags().StringVarP(&strp, "string", "s", "foo", "a string")
    rootCmd.Flags().IntVarP(&intp, "number", "n", 42, "an integer")
    rootCmd.Flags().BoolVarP(&boolp, "boolean", "b", false, "a boolean")
}

func flagsFunc(cmd *cobra.Command, args []string) {
    fmt.Println("string:", strp)
    fmt.Println("integer:", intp)
    fmt.Println("boolean:", boolp)
    fmt.Println("args:", args)
}

func main() {
    if err := rootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

flags包版本相比,后者基本上只是读取一些标志并打印结果,Cobra 程序具有更复杂的结构,包含多个不同的部分。

首先,我们使用包范围声明目标变量,而不是在函数内部局部声明。这是必需的,因为它们必须在init函数和实现命令逻辑的函数之间可访问。

接下来,我们创建一个cobra.Command结构体,rootCmd,代表根命令。一个单独的cobra.Command实例用于表示 CLI 提供的每个命令和子命令。Use字段说明命令的一行使用消息,Long是显示在帮助输出中的长消息。Run是一个类型为func(cmd *Command, args []string)的函数,用于实现命令执行时要执行的实际工作。

通常,命令是在init函数中构建的。在我们的情况下,我们将三个标志—stringnumberboolean—添加到我们的根命令中,以及它们的短标志、默认值和描述。

每个命令都有一个自动生成的帮助输出,我们可以使用--help标志来获取它:

$ go run . --help
A simple flags experimentation command, built with Cobra.

Usage:
  flags [flags]

Flags:
  -b, --boolean         a boolean
  -h, --help            help for flags
  -n, --number int      an integer (default 42)
  -s, --string string   a string (default "foo")

这是有道理的,而且也很漂亮!但它是否按我们的预期运行?执行命令(使用标准标志样式),会给我们带来以下输出:

$ go run . --boolean --number 27 --string "A string." Other things.
string: A string.
integer: 27
boolean: true
args: [Other things.]

输出是相同的;我们已经实现了一致性。但这只是一个单一的命令。Cobra 的一个好处是它还允许子命令

这意味着什么?以git命令为例。在这个例子中,git将是根命令。它本身不做太多事情,但它有一系列子命令—git clonegit initgit blame等—它们相关,但每个都是独立的操作。

Cobra 通过将命令视为树结构来提供此功能。每个命令和子命令(包括根命令)都由一个独立的cobra.Command值表示。它们使用(c *Command) AddCommand(cmds ...*Command)函数彼此连接。我们在以下示例中演示了这一点,通过将flags命令转换为新根命令的子命令,我们称之为cng(用于Cloud Native Go)。

要做到这一点,我们首先必须将原始的rootCmd重命名为flagsCmd。我们添加了一个Short属性来定义其在帮助输出中的简短描述,但它在其他方面是相同的。但现在我们需要一个新的根命令,因此我们也创建了它:

var flagsCmd = &cobra.Command{
    Use:   "flags",
    Short: "Experiment with flags",
    Long:  "A simple flags experimentation command, built with Cobra.",
    Run:   flagsFunc,
}

var rootCmd = &cobra.Command{
    Use:  "cng",
    Long: "A super simple command.",
}

现在我们有两个命令:根命令cng和一个单一的子命令flags。下一步是将flags子命令添加到根命令中,使其直接位于命令树的根下。这通常在init函数中完成,我们在这里演示:

func init() {
    flagsCmd.Flags().StringVarP(&strp, "string", "s", "foo", "a string")
    flagsCmd.Flags().IntVarP(&intp, "number", "n", 42, "an integer")
    flagsCmd.Flags().BoolVarP(&boolp, "boolean", "b", false, "a boolean")

    rootCmd.AddCommand(flagsCmd)
}

在前述的init函数中,我们保留了三个Flags方法,只是现在我们在flagsCmd上调用它们。

然而,新的是AddCommand方法,它允许我们将flagsCmd作为子命令添加到rootCmd中。我们可以多次重复AddCommand,使用多个Command值添加尽可能多的子命令(或子子命令,或子子子命令)。

现在我们已经告诉 Cobra 关于新的flags子命令,其信息反映在生成的帮助输出中:

$ go run . --help
A super simple command.

Usage:
  cng [command]

Available Commands:
  flags       Experiment with flags
  help        Help about any command

Flags:
  -h, --help   help for cng

Use "cng [command] --help" for more information about a command.

现在,根据此帮助输出,我们有一个顶级根命令名为cng,有两个可用的子命令:我们的flags命令,以及一个自动生成的help子命令,允许用户查看任何子命令的帮助。例如,help flags为我们提供了有关flags子命令的信息和说明:

$ go run . help flags
A simple flags experimentation command, built with Cobra.

Usage:
  cng flags [flags]

Flags:
  -b, --boolean         a boolean
  -h, --help            help for flags
  -n, --number int      an integer (default 42)
  -s, --string string   a string (default "foo")

相当不错,是吗?

这只是 Cobra 库能够完成的微小示例,但已经足以让我们构建一组强大的配置选项。如果您有兴趣了解更多关于 Cobra 及其如何用于构建强大命令行界面的信息,请查看其GitHub 仓库和其GoDoc 列表

使用文件配置

最后但同样重要的是,我们可能使用最广泛的配置选项:配置文件。

对于更复杂的应用程序,配置文件比环境变量具有许多优势。它们通常更为明确和可理解,通过允许将行为逻辑地分组和注释来实现。通常,理解如何使用配置文件只是查看其结构或其使用示例的问题。

配置文件在管理大量选项时特别有用,这是它们优于环境变量和命令行标志的优势。特别是命令行标志有时可能会导致一些冗长且构造繁琐的语句。

文件并非完美的解决方案。根据您的环境,在大规模分发文件并在集群中保持一致性可能会面临挑战。可以通过拥有单一的“真相源”来改善这种情况,例如像 etcd 或 HashiCorp Consul 这样的分布式键/值存储,或者从中部署自动提取其配置的中央源代码仓库,但这会增加复杂性并依赖于其他资源。

幸运的是,大多数编排平台提供专门的配置资源,例如 Kubernetes 的ConfigMap对象,大大缓解了分发问题。

近年来可能有数十种用于配置的文件格式,但特别是两种最为突出:JSON 和 YAML。在接下来的几节中,我们将更详细地讨论这两种格式以及如何在 Go 中使用它们。

我们的配置数据结构

在我们讨论文件格式及其如何解码之前,我们应该讨论配置可以解码的两种一般方式:

  • 配置键和值可以映射到特定结构类型中的相应字段。例如,包含属性host: localhost的配置可以解码为具有Host string字段的结构类型。

  • 配置数据可以解码和解组成一个或多个可能嵌套的map[string]interface{}类型的映射。当处理任意配置时这非常方便,但在操作时却有些棘手。

如果您预先知道您的配置可能是什么样子(通常是这样),那么解码配置的第一种方法,将它们映射到为此目的创建的数据结构中,显然是最简单的方法。尽管可以解码并处理任意配置模式,并做有用的工作,但这样做可能非常乏味,并且不适合大多数配置目的。

因此,在本节的其余部分,我们的示例配置将对应以下Config结构:

type Config struct {
    Host string
    Port uint16
    Tags map[string]string
}
警告

对于结构体字段要能被任何编码包编组或解组,它必须以大写字母开头,以指示它被包的导出。

对于我们的每一个例子,我们将从Config结构开始,偶尔会用格式特定的标签或其他装饰增强它。

使用 JSON

JSON(JavaScript 对象表示法)是在 21 世纪初发明的,是为了取代 XML 和当时使用的其他格式而需要的现代数据交换格式。它基于 JavaScript 脚本语言的一个子集,使其相对易于阅读,并对机器生成和解析都非常高效,同时提供了 XML 中缺少的列表和映射的语义。

尽管 JSON 非常常见和成功,但它确实有一些缺点。它通常被认为不如 YAML 用户友好。它的语法尤其严格,一个错位(或缺失)的逗号就可能轻易地破坏它,甚至它不支持注释。

然而,在本章介绍的格式中,它是 Go 标准库唯一支持的格式。

下面是一个关于将数据编码和解码到 JSON 的非常简要的介绍。想要更全面地了解,请参阅 Andrew Gerrand 在Go 博客上的“JSON 和 Go”。

JSON 编码

理解如何解码 JSON(或任何配置格式)的第一步是理解如何编码它。这可能看起来很奇怪,特别是在一个关于读取配置文件的部分,但编码对于 JSON 编码的一般主题非常重要,并提供了一个方便的方法来生成、测试和调试您的配置文件。⁹

Go 的标准库支持 JSON 编码和解码,提供了许多有用的辅助函数,用于编码、解码、格式化、验证和其他处理 JSON 数据。

其中之一是json.Marshal函数,它接受一个interface{}类型的值v,并返回一个包含v的 JSON 编码表示的[]byte数组:

func Marshal(v interface{}) ([]byte, error)

换句话说,一个值进去,JSON 出来。

这个函数确实像它看起来那样容易使用。例如,如果我们有一个Config的实例,我们可以将它传递给json.Marshal以获取其 JSON 编码:

c := Config{
    Host: "localhost",
    Port: 1313,
    Tags: map[string]string{"env": "dev"},
}

bytes, err := json.Marshal(c)

fmt.Println(string(bytes))

如果一切按预期进行,则err将为nil,并且bytes将是包含 JSON 的[]byte值。fmt.Println的输出将类似于以下内容:

{"Host":"localhost","Port":1313,"Tags":{"env":"dev"}}
提示

json.Marshal函数递归遍历v的值,因此任何内部结构都将被编码,以及嵌套的 JSON。

这相当轻松,但如果我们生成配置文件,如果文本格式化为人类可读,那将非常好。幸运的是,encoding/json还提供以下json.MarshalIndent函数,它返回“漂亮打印”的 JSON:

func MarshalIndent(v interface{}, prefix, indent string) ([]byte, error)

正如你所看到的,json.MarshalIndent的工作方式与json.Marshal非常相似,只是还接受prefixindent字符串,如此处所示:

bytes, err := json.MarshalIndent(c, "", "   ")
fmt.Println(string(bytes))

前面的片段打印出了我们希望看到的内容:

{
   "Host": "localhost",
   "Port": 1313,
   "Tags": {
      "env": "dev"
   }
}

结果是漂亮打印的 JSON,为像你和我这样的人类阅读格式化。这是一个非常有用的方法来引导配置文件!

解码 JSON

现在我们知道如何将数据结构编码为 JSON 后,让我们看看如何将 JSON 解码为现有的数据结构。

要实现这一点,我们使用方便命名的json.Unmarshal函数:

func Unmarshal(data []byte, v interface{}) error

json.Unmarshal函数解析data数组中包含的 JSON 编码文本,并将结果存储在v指向的值中。重要的是,如果vnil或不是指针,则json.Unmarshal将返回错误。

但是,v应该是什么类型呢?理想情况下,它应该是指向数据结构的指针,其字段恰好对应 JSON 结构。虽然可以将任意 JSON 解组为非结构化映射,正如我们将在“解码任意 JSON”中讨论的那样,但如果真的没有其他选择,就应该这样做。

正如我们将看到的那样,如果您有一个反映 JSON 结构的数据类型,那么json.Unmarshal能够直接更新它。要做到这一点,我们首先必须创建一个实例,用于存储我们解码后的数据:

c := Config{}

现在我们有了存储值,我们可以调用json.Unmarshal,将包含我们的 JSON 数据和指向c的指针的[]byte传递给它:

bytes := []byte(`{"Host":"127.0.0.1","Port":1234,"Tags":{"foo":"bar"}}`)
err := json.Unmarshal(bytes, &c)

如果bytes包含有效的 JSON,则err将为nil,并且来自bytes的数据将存储在结构体c中。现在打印c的值应该提供以下输出:

{127.0.0.1 1234 map[foo:bar]}

不错!但是当 JSON 的结构与 Go 类型不完全匹配时会发生什么呢?让我们找出来:

c := Config{}
bytes := []byte(`{"Host":"127.0.0.1", "Food":"Pizza"}`)
err := json.Unmarshal(bytes, &c)

有趣的是,这段代码片段并不像你预期的那样产生错误。相反,c现在包含以下值:

{127.0.0.1 0 map[]}

看起来Host的值已经设置,但Config结构中没有对应值的Food被忽略了。事实证明,json.Unmarshal只会解码目标类型中能找到的字段。如果你只想从一个大的 JSON 块中挑选几个特定的字段,这种行为实际上非常有用。

使用结构体字段标签进行字段格式化

在内部,编组通过使用反射来检查一个值并为其类型生成适当的 JSON 来工作。对于结构体,结构体的字段名直接用作默认的 JSON 键,而结构体的字段值成为 JSON 值。解组基本上以相同的方式工作,只是反向操作。

当您编组一个零值结构体时会发生什么?嗯,事实证明,当您编组一个例如Config{}值时,您得到的 JSON 如下:

{"Host":"","Port":0,"Tags":null}

这不太美观。或者高效。难道真的有必要输出所有空值吗?

同样地,结构体字段必须导出(因此大写)才能进行写入或读取。这是否意味着我们必须使用大写字段名?

幸运的是,这两个问题的答案都是“否”。

Go 支持使用结构体字段标签——出现在字段类型声明后的结构体中的短字符串,允许在特定结构字段上添加元数据。字段标签最常由编码包使用,以修改字段级别的编码和解码行为。

Go 结构字段标签是特殊字符串,包含一个或多个键/值对,这些键/值对在字段类型声明后的反引号中:

type User struct {
    Name string `example:"name"`
}

在这个例子中,结构体的Name字段被标记为example:"name"。这些标签可以通过运行时反射使用reflect包访问,但它们最常见的用例是提供编码和解码指令。

encoding/json包支持多种此类标签。一般格式使用结构体字段标签中的json键,并指定字段的名称,可能跟随以逗号分隔的选项列表。名称可以为空,以便在不覆盖默认字段名称的情况下指定选项。

encoding/json支持的可用选项如下所示:

自定义 JSON 键

默认情况下,结构体字段将大小写敏感地映射到与字段名完全相同的 JSON 键。通过设置标签的选项列表中的第一个(或唯一的)值,可以覆盖此默认名称。

示例:CustomKey string `json:"custom_key"`

忽略空值

默认情况下,即使字段为空,它也会出现在 JSON 中。使用omitempty选项将导致如果字段包含零值,则跳过它们。请注意omitempty前面的逗号!

示例:OmitEmpty string `json:",omitempty"`

忽略字段

使用-(破折号)选项的字段在编码和解码期间始终完全被忽略。

示例:IgnoredName string `json:"-"`

一个使用了所有前述标签的结构体可能如下所示:

type Tagged struct {
    // CustomKey will appear in JSON as the key "custom_key".
    CustomKey   string `json:"custom_key"`

    // OmitEmpty will appear in JSON as "OmitEmpty" (the default),
    // but will only be written if it contains a nonzero value.
    OmitEmpty   string `json:",omitempty"`

    // IgnoredName will always be ignored.
    IgnoredName string `json:"-"`

    // TwoThings will appear in JSON as the key "two_things",
    // but only if it isn't empty.
    TwoThings   string `json:"two_things,omitempty"`
}

欲了解有关json.Marshal如何编码数据的更多信息,请参阅golang.org 上该函数的文档

使用 YAML

YAML(YAML Ain’t Markup Language^(11)是一种可扩展的文件格式,在依赖于复杂分层配置的项目(如 Kubernetes)中很受欢迎。它非常表现力强,尽管其语法可能有点脆弱,并且随着规模扩大,使用它的配置可能开始遭遇可读性问题。

与最初作为数据交换格式创建的 JSON 不同,YAML 在本质上主要是一种配置语言。然而,有趣的是,YAML 1.2 是 JSON 的超集,这两种格式可以相互转换。不过,相比 JSON,YAML 确实有一些优势:它可以自引用,允许嵌入块文字,支持注释和复杂数据类型。

不像 JSON,Go 的核心库不支持 YAML。虽然有几个 YAML 包可供选择,但标准选择是Go-YAML。Go-YAML 的第一个版本始于 2014 年,是 Canonical 内部项目,旨在将著名的libyaml C 库移植到 Go。作为一个项目,它非常成熟且得到良好维护。其语法与encoding/json非常相似,非常方便。

编码 YAML

使用 Go-YAML 编码数据与编码 JSON 非常相似。实际上,两个包的Marshal函数的签名完全相同。与其encoding/json等效项一样,Go-YAML 的yaml.Marshal函数也接受interface{}值,并将其 YAML 编码作为[]byte值返回:

func Marshal(v interface{}) ([]byte, error)

正如我们在“编码 JSON”中所做的那样,我们通过创建Config的实例来演示其用法,然后将其传递给yaml.Marshal以获取其 YAML 编码:

c := Config{
    Host: "localhost",
    Port: 1313,
    Tags: map[string]string{"env": "dev"},
}

bytes, err := yaml.Marshal(c)

再次地,如果一切按预期工作,err将是nilbytes将是包含 YAML 的[]byte值。打印bytes的字符串值将提供如下内容:

host: localhost
port: 1313
tags:
  env: dev

此外,就像encoding/json提供的版本一样,Go-YAML 的Marshal函数会递归遍历值v。它发现的任何复合类型——数组、切片、映射和结构体——都将被适当编码,并作为嵌套的 YAML 元素出现在输出中。

解码 YAML

与我们已建立的主题相一致,与encoding/json和 Go-YAML 相似的Marshal函数之间也表现出了相同的一致性:

func Unmarshal(data []byte, v interface{}) error

再次,yaml.Unmarshal函数解析data数组中的 YAML 编码数据,并将结果存储在指向的v值中。如果vnil或不是指针,则yaml.Unmarshal会返回错误。如下所示,这些相似之处非常明显:

// Caution: Indent this YAML with spaces, not tabs.
bytes := []byte(`
host: 127.0.0.1
port: 1234
tags:
 foo: bar
`)

c := Config{}
err := yaml.Unmarshal(bytes, &c)

就像我们在“解码 JSON”中所做的那样,我们将一个指向Config实例的指针传递给yaml.Unmarshal,其字段与 YAML 中找到的字段对应。打印c的值应该(再次)会提供以下输出:

{127.0.0.1 1234 map[foo:bar]}

encoding/json和 Go-YAML 之间还有其他行为上的相似之处:

  • 两者都会忽略源文档中无法映射到Unmarshal函数的属性。同样,如果你忘记导出结构字段,Unmarshal将会默默地忽略它,从而导致其永远不会被设置。

  • 通过将interface{}值传递给Unmarshal,这两者都能够解码任意数据。然而,json.Unmarshal将提供一个map[string]interface{},而yaml.Unmarshal则会返回一个map[interface{}]interface{}。这是一个细微的差别,但可能会导致问题!

用于 YAML 的结构字段标签

除了“标准”结构字段标签外——自定义键,omitempty-(破折号)——在“使用结构字段标签进行字段格式化”中详细说明,Go-YAML 还支持两个特定于 YAML 编组格式的附加标签:

流样式

使用flow选项的字段将使用流样式进行编组,这对于结构体、序列和映射非常有用。

示例:Flow map[string]string `yaml:"flow"`

内联结构体和映射

inline选项会导致结构体或映射的所有字段或键都被处理为外部结构的一部分。对于映射,键必须与其他结构字段的键不冲突。

示例:Inline map[string]string `yaml:",inline"`

一个结构体可以同时使用这两个选项的例子如下:

type TaggedMore struct {
    // Flow will be marshalled using a "flow" style
    // (useful for structs, sequences and maps).
    Flow map[string]string `yaml:"flow"`

    // Inlines a struct or a map, causing all of its fields
    // or keys to be processed as if they were part of the outer
    // struct. For maps, keys must not conflict with the yaml
    // keys of other struct fields.
    Inline map[string]string `yaml:",inline"`
}

如你所见,标签语法也是一致的,只是不再使用json前缀,而是使用yaml前缀。

监视配置文件变化

在处理配置文件时,你将不可避免地面对一个情况:必须对正在运行的程序的配置进行更改。如果程序没有明确地监视并重新加载更改,那么通常必须重新启动以重新读取其配置,这可能在最好的情况下会带来不便,而在最坏的情况下则可能导致停机。

在某个时刻,你必须决定如何让你的程序对这些变化做出响应。

第一种(也是最简单的)选择是什么也不做,只是期望程序在配置更改时重新启动。这实际上是一个相当常见的选择,因为它确保了旧配置的任何痕迹都不存在。它还允许程序在配置文件中引入错误时“快速失败”:程序只需输出一条愤怒的错误消息并拒绝启动。

但是,你可能更喜欢在程序中添加逻辑来检测配置文件(或文件)的更改,并适当地重新加载它们。

使您的配置可重新加载

如果您希望在底层文件更改时重新加载内部配置表示形式,则需要事先进行一些规划。

首先,你需要有一个全局唯一的配置结构体实例。目前,我们将使用我们在“我们的配置数据结构”中介绍的Config实例。在稍大一点的项目中,你甚至可以将其放在一个config包中:

var config Config

经常会看到这样的代码,其中几乎每个方法和函数都会传递一个显式的config参数。我经常看到这种情况;足够多,以至于我知道这种特定的反模式只会让生活变得更加困难。此外,由于现在配置存在于多个地方而不是一个地方,这也倾向于使得配置重新加载变得更加复杂。

一旦我们有了我们的config值,我们将希望添加读取配置文件并将其加载到结构体中的逻辑。类似以下的loadConfiguration函数将完美地胜任:

func loadConfiguration(filepath string) (Config, error) {
    dat, err := ioutil.ReadFile(filepath)   // Ingest file as []byte
    if err != nil {
        return Config{}, err
    }

    config := Config{}

    err = yaml.Unmarshal(dat, &config)      // Do the unmarshal
    if err != nil {
        return Config{}, err
    }

    return config, nil
}

我们的loadConfiguration函数几乎与我们在“使用 YAML”中讨论过的方式相同,只是它使用了io/ioutil标准库中的ioutil.ReadFile函数来获取传递给yaml.Unmarshal的字节。在这里使用 YAML 的选择完全是任意的。¹² JSON 配置的语法几乎相同。

现在我们有了将配置文件加载到规范结构体中的逻辑,我们需要在文件发生更改时调用它。为此,我们有startListening,它监视一个updates通道:

func startListening(updates <-chan string, errors <-chan error) {
    for {
        select {
        case filepath := <-updates:
            c, err := loadConfiguration(filepath)
            if err != nil {
                log.Println("error loading config:", err)
                continue
            }
            config = c

        case err := <-errors:
            log.Println("error watching config:", err)
        }
    }
}

正如你所看到的,startListening接受两个通道:updates,当文件(假设是配置文件)更改时会发出文件名,以及一个errors通道。

它在一个无限循环的select中监视两个通道,以便如果配置文件发生更改,updates通道发送其名称,然后传递给loadConfiguration。如果loadConfiguration没有返回非nil错误,则其返回的Config值将替换当前值。

再退一步,我们有一个init函数,它从watchConfig函数中获取通道,并将它们传递给startListening,然后作为一个 goroutine 运行:

func init() {
    updates, errors, err := watchConfig("config.yaml")
    if err != nil {
        panic(err)
    }

    go startListening(updates, errors)
}

那么这个watchConfig函数是什么呢?嗯,我们还不太清楚细节。我们将在接下来的几节中弄清楚。我们知道它实现了一些配置监视逻辑,并且它有一个函数签名看起来像下面这样:

func watchConfig(filepath string) (<-chan string, <-chan error, error)

watchConfig函数,无论其实现如何,都会返回两个通道——一个string通道,发送更新的配置文件路径,以及一个error通道,通知无效配置——还有一个error值,报告启动时是否发生致命错误。

watchConfig的确切实现可以有几种不同的方式,每种方式都有其利弊。现在让我们看看其中两种最常见的方式。

轮询配置更改

轮询,您可以在一定的时间间隔内检查配置文件的更改,这是一种常见的观察配置文件的方式。标准的实现使用time.Ticker每隔几秒重新计算配置文件的哈希值,并在哈希值更改时重新加载。

Go 在其crypto包中提供了许多常见的哈希算法,每个算法都位于crypto的子包中,并满足crypto.Hashio.Writer接口。

例如,Go 的SHA256标准实现可以在crypto/sha256中找到。要使用它,您可以使用其sha256.New函数获取一个新的sha256.Hash值,然后像对待任何io.Writer一样将要计算哈希的数据写入其中。完成后,使用其Sum方法检索生成的哈希总和:

func calculateFileHash(filepath string) (string, error) {
    file, err := os.Open(filepath)  // Open the file for reading
    if err != nil {
        return "", err
    }
    defer file.Close()              // Be sure to close your file!

    hash := sha256.New()            // Use the Hash in crypto/sha256

    if _, err := io.Copy(hash, file); err != nil {
        return "", err
    }

    sum := fmt.Sprintf("%x", hash.Sum(nil))  // Get encoded hash sum

    return sum, nil
}

为配置生成哈希有三个明确的部分。首先,我们以io.Reader的形式获取一个[]byte源。在本例中,我们使用io.File。接下来,我们将这些字节从io.Reader复制到我们的sha256.Hash实例中,使用io.Copy方法完成。最后,我们使用Sum方法从hash中检索哈希总和。

现在我们有了我们的calculateFileHash函数,创建我们的watchConfig实现只是使用time.Ticker并发地在某个节奏上检查它,并将任何积极的结果(或错误)发送到适当的通道的问题:

func watchConfig(filepath string) (<-chan string, <-chan error, error) {
    errs := make(chan error)
    changes := make(chan string)
    hash := ""

    go func() {
        ticker := time.NewTicker(time.Second)

        for range ticker.C {
            newhash, err := calculateFileHash(filepath)
            if err != nil {
                errs <- err
                continue
            }

            if hash != newhash {
                hash = newhash
                changes <- filepath
            }
        }
    }()

    return changes, errs, nil
}

轮询方法有一些好处。它并不特别复杂,这总是一个大的优点,并且适用于任何操作系统。也许最有趣的是,因为哈希只关心配置的内容,它甚至可以泛化到检测像远程键/值存储这样的地方的更改,这些地方在技术上不是文件。

不幸的是,轮询方法可能会有些计算资源浪费,尤其是对于非常大或文件数量众多的情况。由其性质决定,它还会在文件更改后和检测到该更改之间存在短暂的延迟。如果您确实要处理本地文件,可能更高效的方法是监视操作系统级别的文件系统通知,我们将在下一节讨论。

监视操作系统文件系统通知

虽然轮询变化的方法效果足够好,但这种方法也有一些缺点。根据您的使用情况,您可能会发现监视操作系统级别的文件系统通知更为高效。

实际上,这样做的复杂性在于每个操作系统具有不同的通知机制。幸运的是,fsnotify 包提供了一个可行的抽象,支持大多数操作系统。

要使用此包监视一个或多个文件,您可以使用 fsnotify.NewWatcher 函数获取一个新的 fsnotify.Watcher 实例,并使用 Add 方法注册更多要监视的文件。Watcher 提供两个通道,EventsErrors,分别发送文件事件和错误的通知。

例如,如果我们想要监视我们的配置文件,我们可以像下面这样做:

func watchConfigNotify(filepath string) (<-chan string, <-chan error, error) {
    changes := make(chan string)

    watcher, err := fsnotify.NewWatcher()         // Get an fsnotify.Watcher
    if err != nil {
        return nil, nil, err
    }

    err = watcher.Add(filepath)                    // Tell watcher to watch
    if err != nil {                                // our config file
        return nil, nil, err
    }

    go func() {
        changes <- filepath                        // First is ALWAYS a change

        for event := range watcher.Events {        // Range over watcher events
            if event.Op&fsnotify.Write == fsnotify.Write {
                changes <- event.Name
            }
        }
    }()

    return changes, watcher.Errors, nil
}

注意语句 event.Op & fsnotify.Write == fsnotify.Write,它使用按位与 (&) 来筛选“写入”事件。我们这样做是因为 fsnotify.Event 可能包含多个操作,每个操作在无符号整数中表示为一个位。例如,同时发生 fsnotify.Write (2,二进制 0b00010) 和 fsnotify.Chmod (16,二进制 0b10000) 会导致 event.Op 值为 18 (二进制 0b10010)。因为 0b10010 & 0b00010 = 0b00010,按位与允许我们确保操作包括 fsnotify.Write

Viper:配置包的瑞士军刀

Viper (spf13/viper) 自称是 Go 应用程序的完整配置解决方案,这确实如此。除其他功能外,它允许通过多种机制和格式进行应用程序配置,包括优先顺序为:

明确设置值

这比所有其他方法优先,测试期间非常有用。

命令行标志

Viper 被设计为 Cobra 的伴侣,我们在 “Cobra 命令行解析器” 中介绍过。

环境变量

Viper 对环境变量有全面支持。重要的是,Viper 将环境变量视为区分大小写!

配置文件,支持多种文件格式

Viper 出厂支持 JSON 和 YAML 与我们之前介绍的包,以及 TOML、HCL、INI、envfile 和 Java Properties 文件。它还可以编写配置文件以帮助启动配置,并且可选择支持配置文件的实时监视和重新读取。

远程键值存储

Viper 可以访问诸如 etcd 或 Consul 之类的键值存储,并监视其变化。

它还支持诸如默认值和类型化变量等特性,这些通常标准包不提供。

但请记住,尽管 Viper 做了很多事情,但它也是一个引入许多依赖的大锤。如果您尝试构建一个精简、流畅的应用程序,Viper 可能会超出您的需求。

明确在 Viper 中设置值

Viper 允许您使用 viper.Set 函数从命令行标志或应用程序逻辑显式设置值。这在测试期间非常方便:

viper.Set("Verbose", true)
viper.Set("LogFile", LogFile)

明确设置的值具有最高优先级,并覆盖其他机制设置的值。

在 Viper 中处理命令行标志

Viper 的设计目标是成为 Cobra 库 的伴侣,我们在 “The Cobra command-line parser” 的构建命令行界面的上下文中简要讨论过。与 Cobra 的密切集成使得将命令行标志绑定到配置键变得非常简单。

Viper 提供了 viper.BindPFlag 函数,允许将单独的命令行标志绑定到命名键,并且 viper.BindPFlags 可以绑定完整的标志集,使用每个标志的长名称作为键。

因为只有在访问绑定时才设置配置值的实际值,而不是在调用时,所以可以在 init 函数中调用 viper.BindPFlag,正如我们在这里所做的:

var rootCmd = &cobra.Command{ /* omitted for brevity */ }

func init() {
    rootCmd.Flags().IntP("number", "n", 42, "an integer")
    viper.BindPFlag("number", rootCmd.Flags().Lookup("number"))
}

在上述代码片段中,我们声明了一个 &cobra.Command 并定义了一个名为“number”的整数标志。请注意,我们使用 IntP 方法而不是 IntVarP,因为在这种方式下,当使用 Cobra 时无需将标志的值存储在外部值中。然后,使用 viper.BindPFlag 函数,我们将“number”标志绑定到相同名称的配置键。

绑定后(和命令行标志解析后),可以通过使用 viper.GetInt 函数从 Viper 获取绑定键的值:

n := viper.GetInt("number")

在 Viper 中处理环境变量

Viper 提供了几个函数来处理环境变量作为配置源的情况。其中第一个是 viper.BindEnv,用于将配置键绑定到环境变量:

viper.BindEnv("id")                     // Bind "id" to var "ID"
viper.BindEnv("port", "SERVICE_PORT")   // Bind "port" to var "SERVICE_PORT"

id := viper.GetInt("id")
id := viper.GetInt("port")

如果仅提供了一个键,viper.BindEnv 将绑定到与键匹配的环境变量。可以提供更多参数来指定要绑定的一个或多个环境变量。在这两种情况下,Viper 自动假设环境变量的名称全部大写。

Viper 还提供了几个额外的辅助函数来处理环境变量。更多详细信息请参见 Viper GoDoc

在 Viper 中处理配置文件

Viper 默认支持使用我们之前介绍的包支持的 JSON 和 YAML,以及 TOML、HCL、INI、envfile 和 Java Properties 文件。它还可以编写配置文件来帮助启动配置,并且甚至可以选择支持实时观察和重新读取配置文件。

在云原生书籍中讨论本地配置文件可能看起来出乎意料,但文件在任何情境中仍然是一个常用的结构。毕竟,共享文件系统——无论是 Kubernetes ConfigMaps 还是 NFS 挂载——都很常见,甚至云原生服务也可以通过配置管理系统部署,该系统为所有服务副本安装一个只读本地文件副本以供读取。配置文件甚至可以以一种看起来——就容器化服务而言——与任何其他本地文件完全相同的方式被烘焙或挂载到容器镜像中。

读取配置文件

要从文件中读取配置,Viper 只需要知道文件的名称和位置。此外,如果无法从文件扩展名中推断出类型,则还需要知道其类型。viper.ReadInConfig 函数指示 Viper 查找并读取配置文件,如果出现问题,则可能返回一个 error 值。所有这些步骤在这里演示:

viper.SetConfigName("config")

// Optional if the config has a file extension
viper.SetConfigType("yaml")

viper.AddConfigPath("/etc/service/")
viper.AddConfigPath("$HOME/.service")
viper.AddConfigPath(".")

if err := viper.ReadInConfig(); err != nil {
    panic(fmt.Errorf("fatal error reading config: %w", err))
}

如您所见,Viper 可以搜索多个路径以查找配置文件。不幸的是,此时,单个 Viper 实例仅支持读取单个配置文件。

在 Viper 中监视和重新读取配置文件

Viper 本身允许您的应用程序监视配置文件的修改并在检测到更改时重新加载,这意味着配置可以在不必重新启动服务器的情况下进行更改以生效。

默认情况下,此功能处于关闭状态。viper.WatchConfig 函数可用于启用它。此外,viper.OnConfigChange 函数允许您指定在更新配置文件时调用的函数:

viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
    fmt.Println("Config file changed:", e.Name)
})
警告

确保在调用 viper.WatchConfig 之前进行任何对 viper.AddConfigPath 的调用。

有趣的是,Viper 实际上在幕后使用了 fsnotify/fsnotify 包,这是我们在 “监视配置文件更改” 中详细介绍的相同机制。

使用 Viper 与远程键/值存储

Viper 最有趣的功能之一可能是其能够从远程键/值存储中的路径读取以任何支持的格式编写的配置字符串,例如 etcdHashiCorp Consul。这些值优先于默认值,但会被从磁盘、命令行标志或环境变量中检索的配置值覆盖。

要在 Viper 中启用远程支持,首先必须对 viper/remote 包进行空白导入:

import _ "github.com/spf13/viper/remote"

然后可以使用 viper.AddRemoteProvider 方法注册远程键/值配置源,其签名如下:

func AddRemoteProvider(provider, endpoint, path string) error
  • provider 参数可以是 etcdconsulfirestore 中的一个。

  • endpoint 是远程资源的 URL。Viper 的一个奇怪之处是 etcd 提供程序要求 URL 包含方案 (http://ip:port),而 Consul 则要求没有方案 (ip:port)。

  • path是从键值存储中检索配置的路径。

例如,要从 etcd 服务中读取一个 JSON 格式的配置文件,你可以这样做:

viper.AddRemoteProvider("etcd", "http://127.0.0.1:4001","/config/service.json")
viper.SetConfigType("json")
err := viper.ReadRemoteConfig()

请注意,尽管配置路径包括文件扩展名,但我们还是使用viper.SetConfigType来明确定义配置类型。这是因为从 Viper 的角度来看,资源只是一串字节流,因此无法自动推断格式。¹³ 在撰写本文时,支持的格式包括 jsontomlyamlymlpropertiespropspropenvdotenv

可以添加多个提供程序,按添加的顺序进行搜索。

这只是对 Viper 在使用远程键值存储时的基本介绍。要了解更多关于如何使用 Viper 从 Consul 中读取、监视配置更改或读取加密配置的详细信息,请参阅Viper 的 README

在 Viper 中设置默认值

与本章中审查的所有其他包不同,Viper 可选地允许通过SetDefault函数为键定义默认值。

默认值有时可能很有用,但在使用此功能时需要小心。如在“配置良好实践”中提到的,有用的零值通常比隐式默认值更可取,因为后者可能会在不加思索地应用时导致意外行为。

在下面的示例中,我们展示了 Viper 中显示默认值的片段示例:

viper.BindEnv("id")             // Will be upper-cased automatically
viper.SetDefault("id", "13")    // Default value is "13"

id1 := viper.GetInt("id")
fmt.Println(id1)                // 13

os.Setenv("ID", "50")           // Explicitly set the envvar

id2 := viper.GetInt("id")
fmt.Println(id2)                // 50

默认值优先级最低,仅在未通过其他机制明确设置键时才会生效。

使用特性标志进行特性管理

特性标记(或特性切换^(14)是一种软件开发模式,旨在通过允许在运行时打开或关闭特定功能,而无需部署新代码,从而提高开发和交付新功能的速度和安全性。

特性标志本质上是您代码中的条件语句,根据某些外部条件(通常但并非总是配置设置)启用或禁用功能。通过设置不同的配置值,开发人员可以选择为测试启用不完整的功能,并对其他用户禁用它。

具备发布未完成特性的能力,带来了许多强大的好处。

首先,特性标志允许交付许多小的增量软件版本,而无需使用特性分支带来的分支和合并开销。换句话说,特性标志将特性的发布与其部署解耦。结合特性标志的本质,要求尽早集成代码更改,这既鼓励又促进了持续部署和交付。因此,开发人员对其代码获得更快速的反馈,从而允许更小、更快和更安全的迭代。

其次,特性标志不仅可以在准备发布之前更轻松地进行测试,而且还可以动态进行。例如,可以使用逻辑来构建反馈循环,可以与类似断路器的模式结合使用,在特定条件下自动启用或禁用标志。

最后,逻辑执行标志甚至可以用来针对特定用户子集推出特性。这种技术称为特性门控,可以用作金丝雀部署和分阶段或地理基础的推出的替代方案。结合可观察性技术,特性门控甚至可以让您更轻松地执行像 A/B 测试或有针对性的追踪这样的实验,仪器化特定用户基础或甚至单个客户的特定切片。

特性标志的演变

在本节中,我们将通过直接从我们在第五章构建的键值 REST 服务中取出的函数迭代实现一个特性标志。从基线函数开始,我们将逐步进入几个演变阶段,从无特性标志到为特定用户子集开启的动态特性标志。

在我们的场景中,我们决定要能够扩展我们的键值存储,因此我们希望更新逻辑,使其支持一种精密的分布式数据结构,而不是本地映射。

第一代:初始实现

对于我们的第一个迭代,我们将从“实现读函数”中的keyValueGetHandler函数开始。您可能还记得keyValueGetHandler是一个 HTTP 处理函数,满足net/http包中定义的HandlerFunc接口。如果您对这意味着什么有些生疏,您可能想回顾一下“使用 net/http 构建 HTTP 服务器”。

初始处理函数几乎直接从第五章复制过来(为简洁起见省略了部分错误处理),如下所示:

func keyValueGetHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)                     // Retrieve "key" from the request
    key := vars["key"]

    value, err := Get(key)                  // Get value for key
    if err != nil {                         // Unexpected error!
        http.Error(w,
            err.Error(),
            http.StatusInternalServerError)
        return
    }

    w.Write([]byte(value))                  // Write the value to the response
}

正如您所见,此函数没有特性切换逻辑(或确实没有任何切换的东西)。它所做的只是从请求变量中检索键,使用Get函数检索与该键关联的值,并将该值写入响应。

在我们的下一个实现中,我们将开始测试一个新功能:一个高级分布式数据结构,用于取代本地的map[string]string,这将允许服务扩展到不止一个实例。

第一代:硬编码特性标志

在这个实现中,我们假设我们已经构建了新的实验性分布式后端,并通过NewGet函数使其可访问。

我们首次尝试创建特性标志时引入了一个条件,允许我们使用简单的布尔值useNewStorage在两种实现之间进行切换:

// Set to true if you're working on the new storage backend
const useNewStorage bool = false;

func keyValueGetHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    key := vars["key"]

    var value string
    var err error

    if useNewStorage {
        value, err = NewGet(key)
    } else {
        value, err = Get(key)
    }

    if err != nil {
        http.Error(w,
            err.Error(),
            http.StatusInternalServerError)
        return
    }

    w.Write([]byte(value))
}

这个第一次迭代显示了一些进展,但离我们想要的还有很远。在代码中将标志条件作为硬编码值固定使得我们可以在本地测试中很好地切换实现,但在自动化和持续的测试中同时测试两者将不容易。

另外,每当你想要在已部署的实例中更改算法时,你都必须重新构建和部署服务,这在很大程度上抵消了一开始引入特性标志的好处。

小贴士

良好的特性标志使用规范很重要!如果你有一段时间没有更新特性标志,考虑移除它。

第二代:可配置标志

一段时间过去了,硬编码特性标志的缺点显而易见。首先,如果我们能够使用外部机制更改标志值以便在测试中同时测试两种算法,那将非常好。

在这个示例中,我们使用 Viper 绑定和读取一个环境变量,现在我们可以在运行时启用或禁用功能。配置机制的选择在这里并不重要。重要的是,我们能够在不重新构建代码的情况下外部更新标志:

func keyValueGetHandler(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    key := vars["key"]

    var value string
    var err error

    if FeatureEnabled("use-new-storage", r) {
        value, err = NewGet(key)
    } else {
        value, err = Get(key)
    }

    if err != nil {
        http.Error(w,
            err.Error(),
            http.StatusInternalServerError)
        return
    }

    w.Write([]byte(value))
}

func FeatureEnabled(flag string, r *http.Request) bool {
    return viper.GetBool(flag)
}

除了使用 Viper 读取设置use-new-storage标志的环境变量外,我们还引入了一个新函数:FeatureEnabled。目前,它的作用只是执行viper.GetBool(flag),但更重要的是,它还将标志读取逻辑集中在一个地方。我们将在下一次迭代中看到这样做的好处。

你可能会想为什么FeatureEnabled接受一个*http.Request。嗯,目前它还没有使用,但在下一个迭代中就会有意义。

第三代:动态特性标志

该特性现在已经部署,但在特性标志背后关闭。现在我们希望能够在用户基础的特定子集上在生产环境中测试它。显然,我们无法通过配置设置来实现这种标志。相反,我们将不得不构建可以自行判断是否应该设置的动态标志,这意味着将标志与函数关联起来。

动态标志作为函数

构建动态标志函数的第一步是决定函数的签名。虽然不是严格要求,但明确定义这一点,例如使用如下所示的函数类型,是很有帮助的:

type Enabled func(flag string, r *http.Request) (bool, error)

Enabled函数类型是所有我们动态特性标志函数的原型。其合同定义了一个接受标志名称作为string*http.Request的函数,并返回一个bool,如果请求的标志已启用,则返回true

实现动态标志函数

使用由Enabled类型提供的合同,我们现在可以实现一个函数,用于通过比较请求的远程地址与专门为私有网络分配的标准 IP 范围列表,确定请求是否来自私有网络:

// The list of CIDR ranges associated with internal networks.
var privateCIDRs []*net.IPNet

// We use an init function to load the privateCIDRs slice.
func init() {
    for _, cidr := range []string{
        "10.0.0.0/8",
        "172.16.0.0/12",
        "192.168.0.0/16",
    } {
        _, block, _ := net.ParseCIDR(cidr)
        privateCIDRs = append(privateCIDRs, block)
    }
}

// fromPrivateIP receives the flag name (which it ignores) and the
// request. If the request's remote IP is in a private range per
// RFC1918, it returns true.
func fromPrivateIP(flag string, r *http.Request) (bool, error) {
    // Grab the host portion of the request's remote address
    remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
    if err != nil {
        return false, err
    }

    // Turn the remote address string into a *net.IPNet
    ip := net.ParseIP(remoteIP)
    if ip == nil {
        return false, errors.New("couldn't parse ip")
    }

    // Loopbacks are considered "private."
    if ip.IsLoopback() {
        return true, nil
    }

    // Search the CIDRs list for the IP; return true if found.
    for _, block := range privateCIDRs {
        if block.Contains(ip) {
            return true, nil
        }
    }

    return false, nil
}

正如您所见,fromPrivateIP函数符合Enabled,通过接收string值(标志名称)和*http.Request(具体来说是与发起请求关联的实例),如果请求来自私有 IP 范围(由RFC 1918定义),则返回true

要做出这种确定,fromPrivateIP函数首先从*http.Request中检索包含发送请求的网络地址的远程地址。通过net.SplitHostPort解析主机 IP,然后使用net.ParseIP将其解析为*net.IP值,它将发起 IP 与privateCIDRs中包含的每个私有 CIDR 范围进行比较,如果找到匹配,则返回true

警告

如果请求正在通过负载均衡器或反向代理,则此函数还会返回true。生产级实现需要意识到这一点,并最好是代理协议感知的。

当然,这个函数只是一个例子。我之所以使用它,是因为它相对简单,但类似的技术可以用来为地理区域、用户固定百分比,甚至特定客户启用或禁用标志。

标志函数查找

现在我们有了fromPrivateIP形式的动态标志函数,我们必须实现一些机制来通过名称将标志与之关联起来。也许最直接的方法是使用标志名称字符串到Enabled函数的映射:

var enabledFunctions map[string]Enabled

func init() {
    enabledFunctions = map[string]Enabled{}
    enabledFunctions["use-new-storage"] = fromPrivateIP
}

以这种方式使用映射间接引用函数,为我们提供了很大的灵活性。如果需要,我们甚至可以将函数与多个标志关联起来。如果我们希望一组相关特性在相同条件下始终处于活动状态,这可能非常有用。

您可能已经注意到,我们正在使用一个init函数来填充enabledFunctions映射。但等等,我们不是已经有一个init函数了吗?

是的,我们确实这样做了,这没问题。init函数很特别:如果需要,您可以拥有多个init函数。

路由器功能

最后,我们将一切联系起来。

我们通过重构FeatureEnabled函数来查找适当的动态标志函数,并在找到时调用它并返回结果:

func FeatureEnabled(flag string, r *http.Request) bool {
    // Explicit flags take precedence
    if viper.IsSet(flag) {
        return viper.GetBool(flag)
    }

    // Retrieve the flag function, if any. If none exists,
    // return false
    enabledFunc, exists := enabledFunctions[flag]
    if !exists {
        return false
    }

    // We now have the flag function: call it and return
    // the result
    result, err := enabledFunc(flag, r)
    if err != nil {
        log.Println(err)
        return false
    }

    return result
}

此时,FeatureEnabled 已经成为一个完整的路由器函数,可以根据显式的特性标志设置和标志函数的输出动态控制哪些代码路径是活动的。在此实现中,显式设置的标志优先于其他一切。这允许自动化测试验证标记特性的两侧。

我们的实现使用简单的内存查找来确定特定标志的行为,但这也可以轻松地实现为数据库或其他数据源,甚至是像 LaunchDarkly 这样的复杂托管服务。不过,请记住,这些解决方案确实引入了新的依赖。

摘要

管理性可能不是云原生世界中最引人注目的主题——或者任何世界中的——但我仍然非常喜欢我们在这一章中详细处理细节的方式。

我们深入研究了各种配置样式的细节,包括环境变量、命令行标志和各种格式的文件。我们甚至讨论了一些检测配置更改以触发重新加载的策略。更不用说 Viper,它几乎做了所有这些事情及更多。

我觉得在某些事情上可能有很大的深入潜力,如果不是时间和空间的限制的话,我可能会有更多收获。例如,特性标志和特性管理是一个相当大的主题,我肯定希望能够更深入地探讨一下。有些主题,如部署和服务发现,我们甚至完全无法覆盖。我想我们在下一版中有一些值得期待的事情,对吧?

尽管我很喜欢这一章,但我对第十一章特别感兴趣,我们将深入探讨总体可观察性,特别是 OpenTelemetry。

最后,我给你一些建议:始终做自己,记住幸运来自努力工作。

¹ Kernighan, Brian W. 和 P. J. Plauger. 《程序设计风格的要素》。McGraw-Hill,1978 年。

² America’s Test Kitchen 的工作人员。《完美派:经典与现代派饼、馅饼、盖莱特及更多的终极指南》。America’s Test Kitchen,2019 年。https://oreil.ly/rl5TP

³ 他们在基因工程方面做了一些非常惊人的事情。不要停止相信。

⁴ “系统与软件工程:词汇”。ISO/IEC/IEEE 24765:2010(E),2010 年 12 月 15 日。https://oreil.ly/NInvC

⁵ Radle,Byron 等人。《什么是可管理性?》NI,国家仪器,2019 年 3 月 5 日。https://oreil.ly/U3d7Q

⁶ 我告诉过我的编辑们。嗨,阿米莉亚!嗨,赞!

⁷ 这让我很伤心。这些是重要的话题,但我们必须集中精力。

⁸ Pike,Rob。《Go Proverbs》。Gopherfest,2015 年 11 月 18 日,YouTube。https://oreil.ly/5bOxW

⁹ 很巧妙的技巧,对吧?

¹⁰ 嗯,就像你一样。

¹¹ 真的,那确实是它的含义。

¹² 而且,我真的非常喜欢 JSON so much

¹³ 或者那个特性还没有被实现。我不知道。

¹⁴ 我也见过“特性开关”、“特性翻转”、“条件特性”等术语。行业似乎正在选择“标志”和“切换”,可能是因为其他名称有点滑稽。

第十一章:可观测性

数据不是信息,信息不是知识,知识不是理解,理解不是智慧。¹

克利福德·斯托尔,《高科技异端:一位计算机反对派的反思》

“云原生”即使对于计算来说也是一个相当新的概念。就我所知,这个术语“云原生”是在 2015 年中期云原生计算基金会成立之后才开始进入我们的词汇表的。²

作为一个行业,我们仍在努力弄清楚“云原生”究竟意味着什么,而每个主要公共云提供商都定期推出新服务——每一个看起来都比上一个提供更多抽象层,即使是我们所达成的一点共识也在随着时间而变化。

不过有一点是明确的:网络和硬件层面的功能(和故障)越来越被抽象化并且被 API 调用和事件所替代。每天我们都在向一个全软件定义的一切的世界迈进。我们面临的所有问题都变成了软件问题。

虽然我们肯定会在我们的软件运行的平台上牺牲相当一部分控制权,但我们在整体可管理性和可靠性方面获得了巨大的胜利,³使我们能够将有限的时间和注意力集中在我们的软件上。然而,这也意味着我们大多数的失败现在都源自我们自己的服务及其之间的交互。任何花哨的框架或协议都无法解决糟糕软件的问题。就像我在第一章中所说的那样,Kubernetes 中的一个笨拙的应用仍然是笨拙的。

在这个全新的软件定义、高度分布式的世界里,事情变得复杂起来。软件复杂,平台复杂,它们在一起就真的很复杂,我们往往不知道发生了什么。了解我们服务的可见性比以往任何时候都更为重要,而我们唯一知道的是现有的监控工具和技术根本无法胜任这项任务。显然,我们需要一些新的东西。不仅仅是新技术,甚至不仅仅是一套新的技术,而是一种全新的思考我们如何理解我们的系统的方式。

什么是可观测性?

可观测性现在是一个非常热门的话题。这是一件大事。但是可观测性到底是什么?它与传统的监控、日志、度量和追踪有什么不同(又有什么相似)?最重要的是,我们如何“实现可观测性”?

可观测性不仅仅是营销炒作,尽管基于它吸引了如此多的关注很容易这样认为。

实际上,这相当简单。可观测性是一个系统属性,与弹性或可管理性没有什么不同,它反映了一个系统的内部状态能够从其外部输出的知识中推断出来的程度。当能够快速而一致地对其提出新问题,而无需大量先前知识或重新仪器化或编写新代码时,可以认为一个系统是可观测的。一个可观测的系统能让你提出你之前没想过的问题。

最终,可观测性远远超出了工具的范畴,尽管有些供应商可能试图告诉你(并向你出售)。你无法“购买可观测性”就像你无法“购买可靠性”一样。任何工具都不会仅仅因为你使用它就使你的系统可观测,就像仅仅用锤子不会使桥梁结构安全一样。工具可以帮助你部分实现目标,但如何正确应用它们则取决于你自己。

这当然比说起来容易得多。在复杂系统中构建可观测性要求我们超越寻找“已知未知”的阶段,并接受我们常常甚至无法完全理解其在特定时间点的状态的事实。在复杂系统中理解所有可能的故障(或非故障)状态几乎是不可能的。实现可观测性的第一步是停止寻找特定的、预期的故障模式——“已知未知”,好像这不是事实一样。

为什么我们需要可观测性?

可观测性是传统监控的自然演变,受到云原生架构引入的新挑战的驱动。

首先是现代许多云原生系统的纯粹规模,这些系统越来越复杂,对我们有限的人类大脑和有限的人类注意力范围来说,有太多东西是难以处理的。多个并发运行的互连系统生成的所有数据,提供了比我们可以合理监视的更多事物,比我们可以合理处理的更多数据,以及比我们可以合理进行的更多相关性。

然而,更重要的是,云原生系统的性质与不久前的传统架构根本不同。它们的环境和功能要求不同,它们的功能方式——以及它们的故障方式——也不同,它们需要提供的保证也不同。

在现代应用程序的短暂性和其所在环境的复杂性中,如何监控分布式系统?如何在高度分布式系统的复杂网络中定位单个组件的缺陷?这些都是“可观测性”试图解决的问题。

可观测性与“传统”监控有何不同?

表面上,监控和可观测性之间的界限似乎模糊不清。毕竟,两者都是关于能够询问系统的问题。不同之处在于可以和能够被问到的问题类型。

传统上,监控侧重于通过提问来识别或预测某些已知的或以前观察到的故障模式。换句话说,它集中在“已知未知”。假设系统按预期行为运行,因此预计会以特定且可预测的方式失败。当发现新的故障模式时(通常是通过艰难的方式),其症状会被添加到监控套件中,然后整个过程重新开始。

当系统比较简单时,这种方法效果还不错,但也存在一些问题。首先,要向系统提出新问题通常意味着编写和部署新代码。这种方法不够灵活,显然不可扩展,而且非常令人恼火。

其次,在系统复杂度达到一定水平时,“未知未知”的数量开始超过“已知未知”的数量。故障更难预测,更不容易预测,并且几乎总是由于多个问题同时出现而导致。实际上,对每种可能的故障模式进行监控变得几乎不可能。

监控是对系统执行的一种操作,以确定它是否工作。另一方面,可观测性技术强调通过允许您相关事件和行为来理解系统。可观测性是系统拥有的一种属性,使您能够询问为什么它不工作。

“可观测性的三大支柱”

可观测性的三大支柱是指可观测性工具包中最常见(也是最基础)的三种工具:日志记录、度量指标和追踪。我们将按照以下顺序依次讨论这三部分:

追踪

追踪(或分布式追踪)跟随请求在(通常是分布式的)系统中传播,允许重建整个端到端的请求流程作为一个称为追踪的有向无环图(DAG)。分析这些追踪可以提供关于系统组件如何相互交互的见解,从而能够精确定位故障和性能问题。

追踪将在“追踪”一节中详细讨论。

度量指标

度量指标涉及收集代表系统各个方面在特定时间点状态的数值数据点。收集的数据点,代表对同一主题在不同时间观察的观察结果,对于可视化和数学分析特别有用,并且可以用于突出趋势、识别异常并预测未来的行为。

我们将在“度量指标”一节中详细讨论度量指标。

日志记录

日志记录是将显著事件记录附加到不可变记录——日志——以供以后查看或分析的过程。日志可以采用多种形式,从磁盘上持续追加的文件到像Elasticsearch这样的全文搜索引擎。日志为应用程序特定进程发出的事件提供了宝贵的、上下文丰富的洞察力。然而,重要的是要正确结构化日志条目;不这样做会极大地限制它们的实用性。

我们将在“Logging”更详细地讨论日志记录。

尽管这些方法各自有用,但真正可观测的系统将它们交织在一起,以便每个方法都可以引用其他方法。例如,度量可能用于跟踪一组行为异常的跟踪,而这些跟踪可能会突显可以帮助找出行为背后原因的日志记录。

如果本章中只记住一件事,请记住可观测性仅仅是一个系统属性,就像弹性或可管理性一样,并且没有工具、框架或供应商能“赋予”你可观测性。所谓的“三大支柱”只是可用于构建这种属性的技术。

OpenTelemetry

截至撰写时,OpenTelemetry(或“OTel”,正如时髦的孩子们称呼它⁴)是云原生计算基金会“沙盒”成员项目中的大约四十几个项目之一,也可以说是整个 CNCF 项目目录中最有趣的项目之一。

与大多数 CNCF 项目不同,OpenTelemetry 并不是一个服务* per se*。相反,它是一种努力,旨在标准化遥测数据——跟踪、度量和(最终)日志的表达方式、收集方式和传输方式。其多个仓库包括一系列规范,以及各种语言的 API 和参考实现,包括 Go⁵。

仪表空间竞争激烈,多年来可能涌现出几十家供应商和工具,每家都有其独特的实现方式。OpenTelemetry 旨在统一这一领域——以及其中的所有供应商和工具——围绕一个单一的供应商中立规范,标准化遥测数据如何收集并发送到后端平台。此前也有过其他标准化尝试。事实上,OpenTelemetry 是两个早期项目的合并:OpenTracing 和 OpenCensus,它将它们统一并扩展为一个供应商中立标准集合。

在本章中,我们将回顾每个“三大支柱”,它们的核心概念,以及如何使用 OpenTelemetry 为您的代码添加仪表,并将结果遥测转发到您选择的后端。然而,重要的是要注意,OpenTelemetry 是一个涉及面广泛的主题,值得一本书来充分展示,但我会尽力提供足够的覆盖面,至少使其成为一个实用的介绍。在撰写本文时,并没有关于 OpenTelemetry 的全面资源,但我已从示例和少量文章(以及大量的源代码研究)中收集了我能获取的信息。

注意

在撰写本章时,我了解到 Charity Majors⁶ 和 Liz Fong-Jones 正在努力撰写 Observability Engineering,预计将由 O’Reilly Media 在 2022 年 1 月发布。

OpenTelemetry 组件

OpenTelemetry 扩展和统一了早期尝试创建遥测标准的努力,部分通过在 SDK 中包括抽象和扩展点,您可以插入自己的实现。例如,可以实现自定义导出器,与您选择的供应商进行接口。

为了实现这种模块化水平,OpenTelemetry 设计了以下核心组件:

规范

OpenTelemetry 规范描述了所有 OpenTelemetry API、SDK 和数据协议的需求和期望。

API

基于规范的特定语言接口和实现,可以用于将 OpenTelemetry 添加到应用程序中。

SDK

具体的 OpenTelemetry 实现,位于 API 和导出器之间,提供功能如状态跟踪和批处理数据以供传输。SDK 还提供了多种配置选项,如请求过滤和事务采样。

导出器

进程内 SDK 插件,能够将数据发送到特定的目的地,可以是本地(如日志文件或 stdout),也可以是远程(如 Jaeger,或商业解决方案如 HoneycombLightstep)。导出器将仪表化与后端解耦,使得可以更改目的地而无需重新仪表化代码。

收集器

一个可选但非常有用的与供应商无关的服务,可以在转发遥测数据到一个或多个目的地之前接收和处理遥测数据。它可以作为一个旁路进程与您的应用程序一起运行,也可以作为一个独立的代理在其他地方运行,从而为发送应用程序遥测提供更大的灵活性。这在企业常见的严格控制环境中特别有用。

您可能已经注意到了 OpenTelemetry 后端的缺失。嗯,并没有后端。OpenTelemetry 仅关注遥测数据的收集、处理和发送,并依赖于您提供遥测后端来接收和存储数据。

还有其他组件,但上述内容可以被认为是 OpenTelemetry 的核心组件。它们之间的关系在图 11-1 中有所说明。

cngo 1101

图 11-1. OpenTelemetry 用于数据仪器化(API)、处理(SDK)和导出(导出器和收集器)的核心组件的高级视图;您需要自行提供后端

最后,项目的核心目标是广泛的语言支持。截至本文撰写时,OpenTelemetry 为 Go、Python、Java、Ruby、Erlang、PHP、JavaScript、.NET、Rust、C++和 Swift 提供了 API 和 SDK。

追踪

在本书的整个过程中,我们花了大量时间讨论微服务架构和分布式系统的好处。但不幸的现实——正如您可能已经清楚的那样——是这些架构也引入了各种新的和“有趣”的问题。

有人说,在分布式系统中修复故障感觉就像解决一起谋杀案一样,这是一种轻率的说法,意思是当某件事不起作用时,系统的某个地方通常是一个挑战,因为您往往不知道从哪里开始查找问题的源头,然后才能找到并修复它。

这正是追踪被发明来解决的问题类型。通过跟踪请求在系统中的传播——甚至跨进程、网络和安全边界——追踪可以帮助您(例如)精确定位组件故障、识别性能瓶颈,并分析服务依赖关系。

提示

追踪通常是在分布式系统的背景下讨论的,但复杂的单体应用程序也可以通过追踪获益,特别是如果它与网络、磁盘或互斥等资源竞争。

在本节中,我们将深入探讨追踪(tracing),其核心概念以及如何使用 OpenTelemetry 来为您的代码进行仪器化,并将生成的遥测数据转发到您选择的后端。

不幸的是,时间和空间的限制只允许我们深入这个话题。但如果您想了解更多关于追踪的内容,您可能会对实际中的分布式跟踪(由 Austin Parker、Daniel Spoonhower、Jonathan Mace、Ben Sigelman 和 Rebecca Isaacs 编写,O’Reilly 出版)感兴趣。

追踪概念

在讨论追踪时,有两个基本概念您需要了解,spantrace

span

span 描述了在系统中执行的请求的工作单元,例如执行流程中的分支或跨网络的跳跃。每个 span 都有一个关联的名称、开始时间和持续时间。它们可以(并通常是)嵌套和有序以建模因果关系。

跟踪

一个追踪代表了系统中请求流经的所有事件——每个事件都以跨度的形式表示。一个追踪可以被视为跨度的有向无环图(DAG),或者更具体地说是一个“堆栈跟踪”,其中每个跨度代表一个组件执行的工作。

这种请求追踪与跨度之间的关系在图 11-2 中有所体现,我们可以看到同一请求在流经五个不同服务时的两种不同表示形式,生成了五个跨度。

cngo 1102

图 11-2. 显示请求的追踪在五个服务中穿越时的两种表示方式,产生了五个跨度;完整的追踪可视化为 DAG(左侧),并以时间轴为基础的条形图(右侧),显示了开始时间和持续时间。

当一个请求从第一个(边缘)服务开始时,它创建了第一个跨度——根跨度,它将形成跨度追踪中的第一个节点。根跨度会自动分配一个全局唯一的追踪 ID,该 ID 会随着请求生命周期中的每一次跳转一起传递。下一个仪器化点会使用提供的追踪 ID 创建一个新的跨度,也许会选择插入或以其他方式丰富与请求相关的元数据,然后再次发送带有追踪 ID 的请求。

沿着流程的每个跳转都表示为一个跨度。当执行流程到达这些服务中的一个仪器化点时,将发出一条记录并带有任何元数据。这些记录通常是异步记录到磁盘,然后以带外方式提交到收集器,收集器可以根据系统不同部分发出的不同记录重建执行流程。

图 11-2 展示了两种最常见的表示包含五个跨度的追踪的方式,这些跨度按照创建顺序标记为 A 到 E。左侧显示追踪以 DAG 形式表示;根跨度 A 从时间 0 开始,持续 350ms,直到最后一个服务 E 返回响应。右侧则以时间轴为基础的条形图形式呈现相同数据,其中条的位置和长度反映了开始时间和持续时间。

使用 OpenTelemetry 进行跟踪

使用 OpenTelemetry 对代码进行仪器化包括两个阶段:配置和仪器化。无论您是为跟踪还是指标进行仪器化(或两者都是),这都是真实的,尽管两者之间的具体细节略有不同。对于跟踪和度量仪器化,配置阶段在程序中只执行一次,通常在main函数中,并包括以下步骤:

  1. 第一步是检索并配置适合目标后端的适当导出器。跟踪导出器实现了SpanExporter接口(在 OpenTelemetry v0.17.0 中位于go.opentelemetry.io/otel/sdk/export/trace包中,通常别名为export)。正如我们将在“创建跟踪导出器”中讨论的那样,OpenTelemetry 包含了几种现成的导出器,但也存在用于许多遥测后端的自定义实现。

  2. 在为跟踪工具化您的代码之前,将导出器和任何其他适当的配置选项传递给 SDK,以创建“跟踪提供程序”。正如我们将在“创建跟踪提供程序”中展示的那样,它将作为您的程序生命周期中 OpenTelemetry 跟踪 API 的主要入口点。

  3. 创建了跟踪提供程序之后,将其设置为“全局”跟踪提供程序是一种良好的做法。正如我们将在“设置全局跟踪提供程序”中看到的那样,这使得它可以通过otel.GetTracerProvider函数发现,这样使用 OpenTelemetry API 的库和其他依赖项可以更轻松地发现 SDK 并发出遥测数据。

配置完成后,仅需几个简单的步骤即可为您的代码进行工具化:

  1. 在对操作进行工具化之前,首先必须从(通常是全局的)跟踪提供程序获取Tracer,它在跟踪和跨度信息的跟踪中起着核心作用。我们将在“获取跟踪器”中详细讨论这一点。

  2. 一旦您获得了您的Tracer句柄,您可以使用它创建和启动Span值,这是您用于工具化代码的实际值。我们将在“开始和结束跨度”中详细介绍这一点。

  3. 最后,您还可以选择为您的跨度添加元数据,包括人类可读的、时间戳的消息称为事件,以及称为属性的键/值对。我们将在“设置跨度元数据”中介绍跨度元数据。

创建跟踪导出器

当您使用 OpenTelemetry 时,首先必须做的事情是创建和配置您的导出器。跟踪导出器实现了SpanExporter接口,在 OpenTelemetry v0.17.0 中位于go.opentelemetry.io/otel/sdk/export/trace包中,通常别名为export以减少包命名冲突。

您可能还记得从“OpenTelemetry 组件”中,OpenTelemetry 导出器是知道如何转换指标或跟踪数据并将其发送到特定目的地的进程内插件。此目的地可以是本地(如stdout或日志文件)或远程(如 Jaeger 或像 Honeycomb 或 Lightstep 这样的商业解决方案)。

如果你想对收集到的仪器化数据进行有意义的操作,至少需要一个导出器。通常一个足够了,但如果需要的话,你可以定义任意多个。这些导出器在程序启动时配置并实例化一次,然后传递给 OpenTelemetry SDK。这一点将在“创建跟踪提供者”中详细讨论。

OpenTelemetry 包含了多个用于追踪和度量的内置导出器。以下演示了其中两个。

控制台导出器

OpenTelemetry 的控制台导出器允许将遥测数据以 JSON 格式写入标准输出。这在调试或写入日志文件时非常方便。控制台导出器还可以用于导出度量遥测数据,如我们将在“度量”中看到的那样。

创建控制台导出器的实例只需调用 stdout.NewExporter,在 OpenTelemetry v0.17.0 中,它位于 go.opentelemetry.io/otel/exporters/stdout 包中。

类似大多数导出器创建函数,stdout.NewExporter 也是一个可变函数,可以接受零个或多个配置选项。我们在这里展示了其中一个选项——“漂亮打印”其 JSON 输出的选项:

stdExporter, err := stdout.NewExporter(
    stdout.WithPrettyPrint(),
)

在上述代码片段中,我们使用了 stdout.NewExporter 函数,它返回导出器及一个 error 值。我们将在“将所有内容整合起来:跟踪”中查看运行示例时它的输出。

注意

欲了解更多关于控制台导出器的信息,请参阅相关的 OpenTelemetry 文档页面

Jaeger 导出器

控制台导出器可能对日志记录和调试很有用,但 OpenTelemetry 还包括了许多专门用于将数据转发到特定后端的导出器,例如 Jaeger 导出器。

Jaeger 导出器(如其名称所示)知道如何将跟踪遥测数据编码到 Jaeger 分布式跟踪系统。你可以使用 jaeger.NewRawExporter 函数检索导出器值,如下所示:

jaegerEndpoint := "http://localhost:14268/api/traces"
serviceName := "fibonacci"

jaegerExporter, err := jaeger.NewRawExporter(
    jaeger.WithCollectorEndpoint(jaegerEndpoint),
    jaeger.WithProcess(jaeger.Process{
        ServiceName: serviceName,
    }),
)

在 OpenTelemetry v0.17.0 中,Jaeger 导出器可以在 go.opentelemetry.io/otel/exporter/trace/jaeger 包中找到。

你可能注意到 jaeger.NewRawExporterstdout.NewExporter 非常相似,它们都是接受零个或多个配置选项的可变函数,返回一个 export.SpanExporter(Jaeger 导出器)和一个 error 值。

传递给 jaeger.NewRawExporter 的选项包括:

  • jaeger.WithCollectorEndpoint 用于定义指向目标 Jaeger 进程的 HTTP 收集器端点的 URL。

  • jaeger.WithProcess 允许你设置关于导出过程的信息,比如服务的名称。

还有很多其他配置选项可用,但为了简洁起见,仅使用两个。如果您有兴趣了解更多细节,请参阅相关 OpenTelemetry 文档中的页面。

创建跟踪提供程序

要生成跟踪数据,您首先需要创建和初始化一个跟踪提供程序,在 OpenTelemetry 中由TracerProvider类型表示。在 OpenTelemetry v0.17.0 中,它位于go.opentelemetry.io/otel/sdk/trace包中,通常被别名为sdktrace以避免命名冲突。

TracerProvider是一个有状态值,作为 OpenTelemetry 跟踪 API 的主要入口点,包括提供访问Tracer的能力,后者又用作新Span值的提供程序,我们将在下一节中看到。

要创建一个跟踪提供程序,我们使用sdktrace.NewTracerProvider函数:

tp := sdktrace.NewTracerProvider(
    sdktrace.WithSyncer(stdExporter),
    sdktrace.WithSyncer(jaegerExporter))

在本例中,我们在“创建跟踪导出器”中创建的两个导出器——stdExporterjaegerExporter——提供给sdktrace.NewTracerProvider,指示 SDK 使用它们来导出遥测数据。

还有一些其他选项可以提供给sdktrace.NewTracerProvider,包括定义BatcherSpanProcessor。这些(不情愿地)超出了本书的范围,但更多关于这些的信息可以在OpenTelemetry SDK 规范中找到。

设置全局跟踪提供程序

一旦创建了跟踪提供程序,通常最好通过SetTracerProvider函数将其设置为全局跟踪提供程序。在 OpenTelemetry v0.17.0 中,这和所有 OpenTelemetry 的全局选项位于go.opentelemetry.io/otel包中。

在这里,我们将全局跟踪提供程序设置为tp的值,我们在前一节中创建了它:

otel.SetTracerProvider(tp)

设置全局跟踪提供程序使其可以通过otel.GetTracerProvider函数发现。这允许使用 OpenTelemetry API 的库和其他依赖项更轻松地发现 SDK 并发出遥测数据:

gtp := otel.GetTracerProvider(tp)
警告

如果您没有显式设置全局跟踪提供程序,otel.GetTracerProvider将返回一个无操作的TracerProvider实现,它返回一个提供无操作Span值的无操作Tracer

获取跟踪器

在 OpenTelemetry 中,Tracer是一种专门的类型,用于跟踪和跨度信息,包括当前活动的跨度是什么。在您可以检测操作之前,必须首先使用(通常是全局的)跟踪提供程序的Tracer方法来获取一个trace.Tracer值:

tr := otel.GetTracerProvider().Tracer("fibonacci")

TracerProviderTracer方法接受一个字符串参数来设置其名称。按照惯例,Tracers 的命名通常是根据它们所检测的组件命名,通常是一个库或一个包。

现在您拥有了您的跟踪器,您的下一步将是使用它来创建和启动一个新的Span实例。

开始和结束 span

一旦你获取到 Tracer 的句柄,你可以使用它来创建和启动新的 Span 值,代表在被追踪的工作流中命名和计时的操作步骤。换句话说,Span 值表示堆栈跟踪中的一步。

在 OpenTelemetry v0.17.0 中,SpanTracer 接口都可以在 go.opentelemetry.io/otel/trace 中找到。通过快速审查 Tracer 的定义代码,可以推断出它们的关系:

type Tracer interface {
    Start(ctx context.Context, spanName string, opts ...trace.SpanOption)
        (context.Context, trace.Span)
}

是的,确实就是这样。Tracer 的唯一方法 Start 接受三个参数:一个 context.Context 值,这是 Tracer 用于跟踪跨度的机制;新跨度的名称,按照惯例通常是正在评估的函数或组件的名称;以及零个或多个跨度配置选项。

注意

不幸的是,本书的范围不包括对可用跨度配置的讨论,但如果您感兴趣,可以在相关的 Go 文档中找到更多细节。

重要的是,Start 不仅返回新的 Span,还返回一个 context.Context。这是一个新的 Context 实例,派生自传入的 Context。正如我们马上将看到的那样,这在我们想要创建子 Span 值时非常重要。

现在,所有的部件都就位了,您可以开始仪表化我们的代码。为此,您通过其 Start 方法从您的 Tracer 请求一个 Span 值,如下所示:

const serviceName = "foo"

func main() {
    // EXPORTER SETUP OMITTED FOR BREVITY

    // Retrieve the Tracer from the otel TracerProvider.
    tr := otel.GetTracerProvider().Tracer(serviceName)

    // Start the root span; receive a child context (which now
    // contains the trace ID), and a trace.Span.
    ctx, sp := tr.Start(context.Background(), "main")
    defer sp.End()     // End completes the span.

    SomeFunction(ctx)
}

在这个片段中,我们使用 TracerStart 方法来创建和启动一个新的 Span,返回一个派生的上下文和我们的 Span 值。重要的是要注意,我们确保通过在 defer 中调用它来结束 Span,以便 SomeFunction 完全被根 Span 捕获。

当然,我们还希望对 SomeFunction 进行仪表化。由于它接收从原始 Start 得到的派生上下文,现在它可以使用该 Context 来创建自己的子跨度:

func SomeFunction(ctx context.Context) {
    tr := otel.GetTracerProvider().Tracer(serviceName)
    _, sp := tr.Start(ctx, "SomeFunction")
    defer sp.End()

    // Do something MAGICAL here!
}

mainSomeFunction 之间的唯一区别在于跨度的名称和 Context 值。SomeFunction 使用从 main 中原始 Start 调用派生的 Context 值,这一点非常重要。

设置跨度元数据

现在您有了一个 Span,您该怎么处理它呢?

如果你什么都不做,那没关系。只要记得在函数中以 defer 语句结束你的 Span,就能收集到函数的最小时间线。

然而,通过添加两种类型的元数据,属性事件,可以增强您的跨度值。

属性

属性是与跨度相关联的键/值对。它们可以稍后用于聚合、过滤和分组跟踪。

如果事先已知,可以通过将它们作为选项参数传递给 tr.Start 方法并使用 WithAttributes 函数,在创建跨度时添加属性:

ctx, sp := tr.Start(ctx, "attributesAtCreation",
    trace.WithAttributes(
        label.String("hello", "world"), label.String("foo", "bar")))
defer sp.End()

在这里,我们调用 tr.Start 来启动一个新的 span,将其传递给我们的活动 context.Context 值和一个名称。但是 Start 也是一个可变函数,可以接受零个或多个选项,因此我们选择使用 WithAttributes 函数来传递两个字符串属性:hello=worldfoo=far

WithAttributes 函数接受来自 OpenTelemetry 的 go.opentelemetry.io/otel/label 包中的 label.KeyValue 类型。可以使用诸如 label.String 等各种类型方法创建此类型的值。对于所有 Go 类型(及更多),都存在方法。有关更多信息,请参见 标签包的文档

属性不必在创建 span 时添加。只要 span 尚未完成,它们也可以在 span 的生命周期后添加:

answer := LifeTheUniverseAndEverything()
span.SetAttributes(label.Int("answer", answer))

事件

事件 是 span 生命周期内发生的代表 某事 的时间戳和人类可读消息。

例如,如果您的函数需要独占访问一个在互斥体下的资源,那么当您获取和释放锁时添加事件可能会很有用:

span.AddEvent("Acquiring mutex lock")
mutex.Lock()

// Do something amazing.

span.AddEvent("Releasing mutex lock")
mutex.Unlock()

如果愿意,甚至可以向事件添加属性:

span.AddEvent("Canceled by external signal",
    label.Int("pid", 1234),
    label.String("signal", "SIGHUP"))

自动仪器化

自动仪器化广泛地指的是您未编写的仪器化代码。这是一个有用的功能,可以使您免受大量不必要的簿记工作的困扰。

OpenTelemetry 支持通过许多流行框架和库周围的各种包装器和辅助函数实现自动仪器化,包括我们在本书中涵盖的框架,如 net/httpgorilla/muxgrpc

尽管使用这些功能不能免除您在启动时配置 OpenTelemetry 的必要性,但它们确实减少了管理跟踪所需的一些工作。

自动仪器化 net/httpgorilla/mux

在 OpenTelemetry 0.17.0 中,对标准库 net/httpgorilla/mux 的自动仪器化支持,这两者在我们第一次涵盖它们时是在 第五章 中构建 RESTful Web 服务的上下文中,由 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp 包提供。

其使用方式令人耳目一新。例如,在 net/http 中注册处理函数到默认的 mux⁸ 并启动 HTTP 服务器的标准习语如下:

func main() {
    http.HandleFunc("/", helloGoHandler)
    log.Fatal(http.ListenAndServe(":3000", nil))
}

在 OpenTelemetry 中,通过将处理函数传递给 otelhttp.NewHandler 函数可以自动仪器化处理函数,其签名如下所示:

func NewHandler(handler http.Handler, operation string, opts ...Option)
    http.Handler

otelhttp.NewHandler 函数接受并返回一个处理函数。它通过将传递的处理函数包装在第二个处理函数中,该函数使用提供的名称和选项创建一个 span,以便原始处理函数在返回的 span 处理函数中充当中间件。

otelhttp.NewHandler 函数的典型应用如下所示:

func main() {
    http.Handle("/",
        otelhttp.NewHandler(http.HandlerFunc(helloGoHandler), "root"))
    log.Fatal(http.ListenAndServe(":3000", nil))
}

在将其传递给 otelhttp.NewHandler 之前,我们必须将处理函数强制转换为 http.HandlerFunc。这在之前是不必要的,因为 http.HandleFunc 在调用 http.Handle 之前会自动执行此操作。

如果您正在使用 gorilla/mux,则更改几乎相同,只是您使用 gorilla mux 而不是默认的 mux:

func main() {
    r := mux.NewRouter()
    r.Handle("/",
        otelhttp.NewHandler(http.HandlerFunc(helloGoHandler), "root"))
    log.Fatal(http.ListenAndServe(":3000", r))
}

您需要为要进行仪表化的每个处理程序函数重复此操作,但无论如何,仪表化整个服务所需的代码总量都非常少。

自动仪表化 gRPC

在 OpenTelemetry 0.17.0 中,我们在 第八章 中引入了 gRPC 的自动仪表化支持,用于松耦合数据交换的上下文中,由 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc 包提供。⁹

就像对于 net/http 的自动仪表化一样,gRPC 的自动仪表化非常简约,利用了 gRPC 拦截器。我们还没有详细讨论过 gRPC 拦截器,不幸的是,本书不涵盖完整的 gRPC 拦截器内容。它们可以被描述为与 gorilla/mux 中间件的 gRPC 等效物,我们在 “负载管理” 中使用它来实现自动负载管理。

正如它们的名称所暗示的那样,gRPC 拦截器可以拦截 gRPC 请求和响应,例如,在请求中注入信息,在将响应返回给客户端之前更新响应,或者实现像授权、日志记录或缓存等横切功能。

注意

如果您想进一步了解 gRPC 拦截器,gRPC 博客上的文章 “gRPC-Web 中的拦截器” 提供了一个很好的介绍。如果您希望深入了解,您可能需要投资购买 Kasun Indrasiri 和 Danesh Kuruppu(O’Reilly)的 gRPC: Up and Running

查看 “实现 gRPC 服务” 的原始服务代码片段,您可以看到两个操作函数:

s := grpc.NewServer()
pb.RegisterKeyValueServer(s, &server{})

在上面的片段中,我们创建了一个新的 gRPC 服务器,并将其传递给我们的自动生成的代码包来注册它。

拦截器可以使用 grpc.UnaryInterceptor 和/或 grpc.StreamInterceptor 添加到 gRPC 服务器,前者用于拦截一元(标准请求-响应)服务方法,后者用于拦截流方法。

要对您的 gRPC 服务器进行自动仪表化,您可以使用这两个函数中的一个或两个来添加一个或多个现成的 OpenTelemetry 拦截器,具体取决于您的服务处理的请求类型:

s := grpc.NewServer(
    grpc.UnaryInterceptor(otelgrpc.UnaryServerInterceptor()),
    grpc.StreamInterceptor(otelgrpc.StreamServerInterceptor()),
)

pb.RegisterKeyValueServer(s, &server{})

虽然我们在 第八章 中构建的服务仅使用单一方法,但前面的片段为演示目的添加了一元和流方法的拦截器。

从上下文中获取当前 span

如果您正在利用自动仪器化,每个请求将自动创建一个跟踪。虽然方便,但这也意味着您没有当前的Span立即可用来增强具有应用程序特定属性和事件元数据。那么,你该怎么办?

不用担心!由于您的应用程序框架已经方便地将跨度数据放入当前上下文中,数据可以轻松检索:

func printSpanHandler(w http.ResponseWriter, req *http.Request) {
    ctx := req.Context()                    // Get the request Context

    span := trace.SpanFromContext(ctx)      // Get the current span

    fmt.Printf("current span: %v\n", span)  // Why not print the span?
}

将所有内容整合起来:追踪

使用我们在本节讨论过的所有部分,现在让我们构建一个小型 Web 服务。因为我们将使用追踪来仪器化这个服务,理想的服务会产生大量的函数调用,但是代码量仍然很小。

我们将构建一个斐波那契服务。它的要求非常简单:它能够接受一个 HTTP GET 请求,请求第n个斐波那契数,使用n参数在 GET 查询字符串中。例如,要请求第六个斐波那契数,你可以像这样使用curl服务:http://localhost:3000?n=6

为此,我们将使用总共三个函数。从内向外依次是:

服务 API

这将通过递归调用自身来执行斐波那契计算——在服务处理程序的请求下——每次调用生成自己的 span。

服务处理程序

这是一个 HTTP 处理函数,由net/http包定义,将像在“使用 net/http 构建 HTTP 服务器”中一样使用,接收客户端请求,调用服务 API,并在响应中返回结果。

主函数

main函数中,创建并注册 OpenTelemetry 导出器,提供 HTTP 框架的服务处理函数,并启动 HTTP 服务器。

斐波那契服务 API

在服务的核心处的服务 API 是执行实际计算的地方。在这种情况下,它是一个并发实现的斐波那契方法,用于计算第n个斐波那契数。

就像任何一个优秀的服务 API 一样,这个函数不知道(也不关心)自己如何被使用,因此它对 HTTP 请求或响应一无所知:

func Fibonacci(ctx context.Context, n int) chan int {
    ch := make(chan int)

    go func() {
        tr := otel.GetTracerProvider().Tracer(serviceName)

        cctx, sp := tr.Start(ctx,
            fmt.Sprintf("Fibonacci(%d)", n),
            trace.WithAttributes(label.Int("n", n)))
        defer sp.End()

        result := 1
        if n > 1 {
            a := Fibonacci(cctx, n-1)
            b := Fibonacci(cctx, n-2)
            result = <-a + <-b
        }

        sp.SetAttributes(label.Int("result", result))

        ch <- result
    }()

    return ch
}

在本例中,Fibonacci函数不知道自己如何被使用,但是它确实知道 OpenTelemetry 包。自动仪器化只能跟踪它所包装的内容。API 内部的任何内容都需要自我仪器化。

此函数使用otel.GetTracerProvider确保它获取全局的TracerProvider,假设由消费者进行了配置。如果没有设置全局跟踪提供程序,这些调用将不起作用。

提示

如果想额外加分,可以花一分钟为Fibonacci函数添加支持Context取消功能。

斐波那契服务处理程序

这是由net/http包定义的 HTTP 处理函数。

它将会像“使用 net/http 构建 HTTP 服务器” 中一样在我们的服务中使用:接收客户端请求、调用服务 API,并在响应中返回结果。

func fibHandler(w http.ResponseWriter, req *http.Request) {
    var err error
    var n int

    if len(req.URL.Query()["n"]) != 1 {
        err = fmt.Errorf("wrong number of arguments")
    } else {
        n, err = strconv.Atoi(req.URL.Query()["n"][0])
    }

    if err != nil {
        http.Error(w, "couldn't parse index n", 400)
        return
    }

    // Retrieve the current context from the incoming request
    ctx := req.Context()

    // Call the child function, passing it the request context.
    result := <-Fibonacci(ctx, n)

    // Get the Span associated with the current context and
    // attach the parameter and result as attributes.
    if sp := trace.SpanFromContext(ctx); sp != nil {
        sp.SetAttributes(
            label.Int("parameter", n),
            label.Int("result", result))
    }

    // Finally, send the result back in the response.
    fmt.Fprintln(w, result)
}

请注意,它不必创建或结束一个Span;自动仪器化将为我们完成这些工作。

但是它确实在当前 span 上设置了一些属性。为此,它使用trace.SpanFromContext从请求上下文中检索当前 span。一旦获得了 span,就可以自由添加任何所需的元数据。

警告

如果无法在传递给它的上下文中找到与Span关联的Spantrace.SpanFromContext函数将返回nil

服务的主函数

到此为止,所有的工作都已经完成。我们唯一需要做的就是配置 OpenTelemetry,注册处理函数到默认的 HTTP 多路复用器,并启动服务:

const (
    jaegerEndpoint = "http://localhost:14268/api/traces"
    serviceName    = "fibonacci"
)

func main() {
    // Create and configure the console exporter
    stdExporter, err := stdout.NewExporter(
        stdout.WithPrettyPrint(),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Create and configure the Jaeger exporter
    jaegerExporter, err := jaeger.NewRawExporter(
        jaeger.WithCollectorEndpoint(jaegerEndpoint),
        jaeger.WithProcess(jaeger.Process{
            ServiceName: serviceName,
        }),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Create and configure the TracerProvider exporter using the
    // newly created exporters.
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithSyncer(stdExporter),
        sdktrace.WithSyncer(jaegerExporter))

    // Now we can register tp as the otel trace provider.
    otel.SetTracerProvider(tp)

    // Register the autoinstrumented service handler
    http.Handle("/",
        otelhttp.NewHandler(http.HandlerFunc(fibHandler), "root"))

    // Start the service listening on port 3000
    log.Fatal(http.ListenAndServe(":3000", nil))
}

正如你所见,主要方法的大部分内容都用于创建我们的(控制台和 Jaeger)导出器,并像我们在“创建跟踪导出器”中所做的那样配置跟踪器提供程序。请注意jaegerEndpoint的值,它假定您将有一个运行本地 Jaeger 服务。我们将在下一步中完成这一步骤。

最后两行用于自动仪器化和注册处理函数,并启动 HTTP 服务,就像我们在“自动仪器化”中所做的那样。

启动您的服务

在我们继续之前,我们需要启动一个 Jaeger 服务,以接收我们包含的 Jaeger 导出器提供的遥测数据。有关 Jaeger 的更多背景信息,请参阅“Jaeger 是什么?”。

如果已安装 Docker,您可以使用以下命令启动 Jaeger 服务:

$ docker run -d --name jaeger   \
  -p 16686:16686                \
  -p 14268:14268                \
  jaegertracing/all-in-one:1.21

一旦服务启动并运行,您可以通过浏览http://localhost:16686访问其 Web 界面。显然,那里还没有任何数据。

现在是有趣的部分:通过运行其主函数来启动您的服务:

$ go run .

您的终端应该会暂停。通常情况下,您可以通过 Ctrl-C 停止服务。

最后,在另一个终端中,您现在可以向服务发送请求了:

$ curl localhost:3000?n=6
13

稍作停顿后,您将得到一个结果。在这种情况下,是 13。

注意n的值。如果将n设得太大,服务可能会花费很长时间来响应,甚至可能崩溃。

控制台导出器输出

现在您已向您的服务发出了请求,请查看您用来启动服务的终端。您应该会看到几个类似以下的 JSON 块:

[
    {
        "SpanContext":{
            "TraceID":"4253c86eb68783546b8ae3b5e59b4a0c",
            "SpanID":"817822981fc2fb30",
            "TraceFlags":1
        },
        "ParentSpanID":"0000000000000000",
        "SpanKind":1,
        "Name":"main",
        "StartTime":"2020-11-27T13:50:29.739725-05:00",
        "EndTime":"2020-11-27T13:50:29.74044542-05:00",
        "Attributes":[
            {
                "Key":"n",
                "Value":{
                    "Type":"INT64",
                    "Value":6
                }
            },
            {
                "Key":"result",
                "Value":{
                    "Type":"INT64",
                    "Value":13
                }
            }
        ],
        "ChildSpanCount":1,
        "InstrumentationLibrary":{
            "Name":"fibonacci",
            "Version":""
        }
    }
]

这些 JSON 对象是控制台导出器的输出(请记住,我们已配置为漂亮打印)。每个 span 应该有一个,这是相当多的。

前面的示例(稍作修剪)来自根跨度。正如您所见,它包含了一些有趣的数据,包括其开始和结束时间,以及其跟踪和跨度 ID。甚至包括我们明确设置的两个属性:输入值n和查询结果。

在 Jaeger 中查看您的结果

现在您已经生成了您的跟踪并将其发送到 Jaeger,是时候将其可视化了。幸运的是,Jaeger 恰好提供了一个漂亮的 Web UI,专门用于此目的!

要查看它,请使用您喜爱的 Web 浏览器浏览http://localhost:16686。在服务下拉菜单中选择 Fibonacci,然后点击查找跟踪按钮。您应该会看到类似于图 11-3 中所示的输出。

可视化中的每个条形代表一个单独的跨度。您甚至可以通过单击它来查看特定跨度的数据,这将显示与您在“控制台导出器输出”中看到的相同数据(非常冗长)。

cngo 1103

图 11-3. Jaeger 接口的截图,显示并发 Fibonacci 调用的结果

指标

指标(Metrics)是关于组件、过程或活动的数值数据随时间变化的收集。潜在的指标来源众多,包括(但不限于)计算资源(CPU、内存使用、磁盘和网络 I/O)、基础设施(实例副本计数、自动扩展事件)、应用程序(请求计数、错误计数)和业务指标(收入、客户注册、跳出率、购物车放弃率)。当然,这些只是一些微不足道的例子。对于复杂系统来说,基数可能高达成千上万,甚至百万。

表示目标特定方面的一个观察值的指标数据点被称为样本。每个样本都有名称、值和毫秒精度的时间戳。此外——至少在像Prometheus这样的现代系统中——还有一组称为标签的键值对。

单个样本本身的用处有限,但一系列相同名称和标签的连续样本——时间序列——却非常有用。正如在图 11-4 中所示,将样本作为时间序列收集,通过在图表上绘制数据点可以轻松可视化指标,进而更容易看到趋势或观察异常或离群值。

cngo 1104

图 11-4. 将样本作为时间序列排列允许它们以图形方式进行可视化

在上述图中,我们展示了一个 AWS EC2 实例的aws.ec2.network_in指标的时间序列。时间位于 x 轴上(具体来说,跨越 2020 年 11 月至 12 月的一个月)。y 轴表示实例在特定时刻接收网络数据的即时速率。以这种方式可视化时间序列,很明显,流向该实例的流量每个工作日都会出现高峰。有趣的是,11 月 25 日至 27 日——美国感恩节前后的日子——是例外。

然而,度量的真正威力并不在于它能被人眼视觉化:而是其数值特性使其特别适合数学建模。例如,您可以使用趋势分析来检测异常或预测未来状态,进而影响决策或触发警报。

推送与拉取度量收集

在度量的宇宙中存在两种主要架构:推送型和拉取型(因监控组件与收集器后端之间的关系而得名)。

在推送型度量中,被监控组件将其数据“推送”到中央收集器后端。在拉取型度量中,情况则相反:收集器通过从被监控组件(或为此目的部署的旁路服务,也被混淆地称为“导出器”;参见“Prometheus 导出器”)暴露的 HTTP 端点“拉取”度量。这两种方法在图 11-5 中有所说明。

cngo 1105

图 11-5. 推送型度量(左侧)直接将遥测发送到中央收集器后端;拉取型度量(右侧)则由收集器主动从暴露的度量端点抓取

接下来是对这两种方法的简短描述,以及一些支持和反对每种方法的非常有限的论据列表。不幸的是,有很多争论,许多都相当微妙——远远超出了我们在此处深入探讨的范围——因此,我们将只能满足于一些常见的争论。

推送型度量收集

在推送型度量收集中,一个应用程序,可以是直接的,也可以通过并行代理进程,定期将数据发送到中央收集器后端。像 Ganglia、Graphite 和 StatsD 这样的推送实现往往是最常见的(甚至是默认)方法,可能部分原因是推送模型往往更容易理解。

推送消息通常是单向的,由被监控组件或监控代理发出,并发送到中央收集器。相对于(双向的)拉取模型,这对网络的负担稍轻,并且可以减少网络安全模型的复杂性,因为组件不必将度量端点对收集器开放。此外,使用推送模型更容易监控高度瞬时的组件,例如短暂存在的容器或无服务器函数。

尽管 推模型也有一些缺点。首先,你需要知道将请求发送到哪里。虽然有很多方法可以做到这一点,但每种方法都有其缺点,从硬编码地址(难以更改)到 DNS 查找或服务发现(可能会增加不可接受的延迟)。扩展有时也可能会成为问题,因为大量组件可能会有效地对您的收集器后端发起 DDoS 攻击。

基于拉取的度量收集

在基于拉取的收集模型中,收集器后端定期(按一定的可配置频率)从组件或专为此目的部署的代理公开的度量端点中获取数据。也许最著名的基于拉取的系统例子是Prometheus

拉取方法提供了一些显著的优势。公开度量端点将被观察的组件与收集器本身解耦,这提供了所有松耦合的好处。例如,更容易在开发过程中监视服务,甚至可以使用 Web 浏览器手动检查组件的健康状态。拉模型还能更容易地判断目标是否处于宕机状态。

然而,拉取方法本身也存在发现问题,即收集器必须以某种方式知道如何找到它应该监视的服务。这可能会有些挑战,特别是如果您的系统没有使用动态服务发现。负载均衡器在这里也帮不上什么忙,因为每个请求将被转发到一个随机实例,大大降低了有效的收集速率(因为每个 N 个实例接收 1/N 的拉取请求),并严重混淆了收集的数据(因为所有实例都倾向于看起来像一个单一的目标)。最后,基于拉取的收集方法可能会使监视短暂的像无服务器函数这样的瞬时事物变得有些困难,这需要像推送网关这样的解决方案。

但哪种方法更好呢?

由于推模型和拉模型看起来是彼此的极端对立面,因此人们常常会想知道哪种方法更好。¹¹ 这是一个很难的问题,就像经常比较技术方法时的情况一样,答案是“视情况而定”。

当然,这从来没有阻止过一个足够积极的程序员对一方或另一方进行激烈的辩论,但归根结底,“更好”的方法是满足您系统要求的方法。当然(并且相当令人不满意地

因此,我将以 Prometheus 的核心开发者 Brian Brazil 的话结束本节:

从工程角度来看,实际上,推模型与拉模型的选择大体上并不重要。无论哪种情况,都有其优势和劣势,并且通过工程努力,您可以解决这两种情况的问题。¹²

使用 OpenTelemetry 进行度量

在撰写本文时,OpenTelemetry 度量 API 仍处于 alpha 版本阶段,因此仍然存在一些需要解决的问题和与追踪 API 存在的一些不一致性。

话虽如此,OpenTelemetry 背后的私有和社区支持,以及其令人印象深刻的快速发展速度,使其不仅适合包含在本书中,而且作为未来几年内最有可能成为度量遥测的金标准的最佳候选。

大多数情况下,OpenTelemetry 度量与追踪工作方式类似,但有足够的差异可能会导致一些混淆。对于追踪和度量仪器,配置阶段在程序中仅执行一次,通常在main函数中,并包括以下步骤:

  1. 第一步是创建和配置适合目标后端的适当导出器。度量导出器实现了metric.Exporter接口,在 OpenTelemetry v0.17.0 中位于go.opentelemetry.io/otel/sdk/export/metric包中。正如我们将在“创建度量导出器”中讨论的那样,OpenTelemetry 包含了几个现成的导出器,但与追踪导出器不同的是,目前你只能同时使用一个度量导出器。

  2. 在为度量仪器的代码进行仪器化之前,导出器用于定义全局的“meter provider”,它将作为你的程序在其整个生命周期中访问 OpenTelemetry 度量 API 的主要入口点。正如我们将在“设置全局 meter provider”中看到的那样,这使得可以通过otel.GetMeterProvider函数发现 meter 导出器,从而使使用 OpenTelemetry API 的库和其他依赖更容易地访问 SDK 并发出遥测数据。

  3. 如果你的度量后端使用像 Prometheus 这样的拉取设计,你需要暴露一个度量端点供其拉取。你将在“暴露度量端点”中看到 Prometheus 导出器如何利用 Go 的标准http包来实现这一点。

配置完成后,仅需几个简单的步骤即可为你的代码进行度量仪器的操作:

  1. 在对操作进行度量之前,你首先需要获取一个Meter,它是配置和报告所有度量数据的结构,从 Meter 提供者处获取。我们将在“获取一个 meter”中详细讨论这一点。

  2. 最后,一旦你拥有了Meter,你就可以用它来监控你的代码。有两种方式可以实现这一点,要么显式记录测量数据,要么创建观察者,它们可以自主异步地收集数据。这两种方法都在“度量仪器”中有所涵盖。

创建度量导出器

与跟踪一样,使用 OpenTelemetry 进行度量时,您必须做的第一件事是创建和配置您的导出器。度量导出器实现了metric.Exporter接口,在 OpenTelemetry v0.17.0 中位于go.opentelemetry.io/otel/sdk/export/metric包中。

创建度量导出器的方式在不同实现之间略有不同,但在标准 OpenTelemetry 包中,度量导出器通常具有一个NewExportPipeline构建函数。

例如,要获取 Prometheus 导出器的实例,您可以使用go.opentelemetry.io/otel/exporters/metric/prometheus包中的NewExportPipeline函数:

prometheusExporter, err := prometheus.NewExportPipeline(prometheus.Config{})

上述片段创建了导出器,并根据传递的prometheus.Config值指定的方向进行配置。任何未被Config覆盖的行为将使用推荐选项。

prometheus.Config 参数还允许您指定各种自定义行为。不幸的是,这些具体细节超出了本书的范围,但如果您感兴趣,请参考导出器配置代码Prometheus Go 客户端的代码,它们相对直接。

设置全局计量提供程序

OpenTelemetry 跟踪具有提供Tracer值的“跟踪器提供程序”,而 OpenTelemetry 度量具有计量提供程序,它通过配置和报告所有度量收集的Meter值。

您可能还记得,在使用跟踪导出器时,定义全局跟踪器提供程序需要两个步骤:创建和配置跟踪器提供程序实例,然后将该实例设置为全局跟踪器提供程序。

计量提供程序的工作方式略有不同:与使用一个或多个导出器创建和定义提供程序(如TracerProvider的情况)不同,计量提供程序通常从度量导出器中检索,然后直接传递给otel.SetMeterProvider函数:

// Get the meter provider from the exporter.
mp := prometheusExporter.MeterProvider()

// Set it as the global meter provider.
otel.SetMeterProvider(mp)

这种设计的一个不幸后果是,您一次只能使用一个度量导出器,因为计量提供程序由导出器提供而不是相反。显然,这与跟踪 API 的工作方式显著不同,随着 OpenTracing 度量 API 进入 beta 阶段,我预计这种情况将会改变。

小贴士

还有一个prometheus.InstallNewPipeline便捷函数,可以代替显式调用prometheus.NewExportPipelineotel.SetMeterProvider函数。

暴露度量指标的端点

因为 Prometheus 是拉取式的,我们希望发送的任何遥测数据必须通过收集器可以抓取的 HTTP 端点公开。

为此,我们可以利用 Go 的标准http包,正如我们在本书中已经多次展示的那样,它需要最少的配置,使用起来非常直接。

要回顾我们在 “使用 net/http 构建 HTTP 服务器” 中首次介绍的内容,使用 Go 启动一个最小的 HTTP 服务器至少需要两个调用:

  • 使用 http.Handle 注册一个实现 http.Handler 接口的处理程序函数

  • 使用 http.ListenAndServe 开始服务器监听

但是 OpenTelemetry Prometheus 导出器有一个非常聪明的技巧:它实现了 http.Handler 接口,这使得它可以直接传递给 http.Handle 作为度量端点的处理函数!请参见以下内容:

// Register the exporter as the handler for the "/metrics" pattern.
http.Handle("/metrics", prometheusExporter)

// Start the HTTP server listening on port 3000.
log.Fatal(http.ListenAndServe(":3000", nil))

在这个示例中,我们直接将 Prometheus 导出器传递给 http.Handle,以将其注册为“/metrics”模式的处理程序。比这更方便的方式几乎找不到了。

注意

最终,您的度量标准端点的名称由您决定,但是

metrics 是最常见的选择。这也是 Prometheus 默认查找的地方。

获取一个仪表

在对操作进行仪表化之前,您必须首先从 MeterProvider 中获取 Meter 值。

正如您将在 “度量仪器” 中看到的那样,metric.Meter 类型位于 go.opentelemetry.io/otel/metric 包中,是配置和报告所有度量收集的方式,无论是作为同步测量的记录批次还是异步观察。

您可以按如下方式检索 Meter 值:

meter := otel.GetMeterProvider().Meter("fibonacci")

您可能已经注意到,这个片段几乎与用于获取 Tracer 的表达式完全相同,如 “获取跟踪器” 中所述。实际上,otel.GetMeterProviderotel.GetTracerProvider 完全相同,并且工作方式几乎相同。

otel.GetMeterProvider 函数返回已注册的全局仪表提供程序。如果未注册任何提供程序,则返回一个默认的仪表提供程序,将 Meter 接口转发到第一个注册的 Meter 值。

Meter 方法提供了 metric.Meter 类型的实例。它接受一个字符串参数,表示仪表名称,按照惯例命名为其所仪表化的库或包。

度量仪器

一旦获得了 Meter,就可以创建 仪器,用于进行测量并为代码进行仪表化。然而,就像有几种不同类型的度量标准一样,也有几种类型的仪器。您使用的仪器类型将取决于您正在进行的测量类型。

总而言之,有 12 种 种类 的仪器可用,每种都具有某些 同步性累积 行为和数据类型的组合。

这些属性中的第一个,同步性,确定仪表如何收集和传输数据:

  • 同步仪器 明确由用户调用以记录度量标准,正如我们将在 “同步仪器” 中看到的那样。

  • 异步仪器,也称为观察器,可以监视特定属性,并在收集期间由 SDK 异步调用。我们将在“异步仪器”中演示。

第二,每种仪器都有一个描述其如何跟踪新数据获取的累积行为:

  • 加法仪器用于跟踪可以任意上升或下降的总和,如计数器。它们通常用于测量值,如温度或当前内存使用情况,但也用于可以上下波动的“计数”,如并发请求的数量。

  • 加法单调仪器跟踪单调递增的值,这些值只能增加(或在重新启动时重置为零),如计数器。加法单调值通常用于度量指标,如已服务的请求数量、已完成的任务或错误。

  • 分组仪器旨在捕获分布,如直方图。分组仪器对观察进行采样(通常是像请求持续时间或响应大小这样的东西),并将它们计数在可配置的桶中。它还提供了所有观察值的总和。

最后,前述六种仪器中的每一种都有支持float64int64输入值的类型,共计 12 种仪器。每种仪器在go.opentelemetry.io/otel/metric包中都有一个关联类型,总结在表 11-1 中。

表 11-1. OpenTelemetry 度量仪器的 12 种类型,按同步性和累积行为分类。

同步 异步
加法 Float64UpDownCounter, Int64UpDownCounter Float64UpDownSumObserver, Int64UpDownSumObserver
加法,单调 Float64Counter, Int64Counter Float64SumObserver, Int64SumObserver
分组 Float64ValueRecorder, Int64ValueRecorder Float64ValueObserver, Int64ValueObserver

每种类型都在metric.Meter类型上具有关联的构造函数,具有类似的签名。例如,NewInt64Counter方法看起来如下所示:

func (m Meter) NewInt64Counter(name string, options ...InstrumentOption)
    (Int64Counter, error)

所有 12 种构造方法都接受作为string的指标名称,以及零个或多个metric.InstrumentOption值,就像NewInt64Counter方法一样。类似地,每种方法都返回具有给定名称和选项的适当类型的仪器值,并且如果名称为空或其他无效,则可能返回错误,或者如果仪器是重复注册的话也可能返回错误。

例如,使用NewInt64Counter方法从metric.Meter值获取新的metric.Int64Counter函数的示例如下所示:

// The requests counter instrument. As a synchronous instrument,
// we'll need to keep it so we can use it later to record data.
var requests metric.Int64Counter

func buildRequestsCounter() error {
    var err error

    // Retrieve the meter from the meter provider.
    meter := otel.GetMeterProvider().Meter(serviceName)

    // Get an Int64Counter for a metric called "fibonacci_requests_total".
    requests, err = meter.NewInt64Counter("fibonacci_requests_total",
        metric.WithDescription("Total number of Fibonacci requests."),
    )

    return err
}

请注意,我们以requests全局变量的形式保留对仪器的引用。基于我马上要讨论的原因,这通常是特定于同步仪器的。

但是,metric.Int64Counter恰好是一个同步工具,这里的要点是,同步和异步工具都是通过相应的Metric构造方法获得的。然而,它们的使用方式有很大的不同,我们将在接下来的部分中看到。

同步工具

使用同步工具的初始步骤——从度量提供程序中检索计量器并创建工具——在同步和异步工具中基本上是相同的。我们在前一节中已经看到了这些。

然而,使用同步工具与使用异步工具的不同之处在于,在记录指标时需要在代码逻辑中显式使用它们,这意味着你必须能够在创建后引用你的工具。这就是为什么上面的示例使用了全局的requests变量。

可能最常见的应用是在事件发生时通过增加计数器来记录单个事件。即使是增量工具也有一个用于此目的的Add方法。以下示例使用了我们在上一个示例中创建的requests值,通过向原始定义在“斐波那契服务 API”的 API 的Fibonacci函数添加对requests.Add的调用来展示:

// Define our labels here so that we can easily reuse them.
var labels = []label.KeyValue{
    label.Key("application").String(serviceName),
    label.Key("container_id").String(os.Getenv("HOSTNAME")),
}

func Fibonacci(ctx context.Context, n int) chan int {
    // Use the Add method on out metric.Int64Counter instance
    // to increment the counter value.
    requests.Add(ctx, 1, labels...)

    // The rest of the function...
}

正如你所看到的,requests.Add方法——对并发使用安全——接受三个参数:

  • 第一个参数是当前的上下文,以context.Context值的形式。这对于所有同步工具的方法都很普遍。

  • 第二个参数是要增加的数字。在这种情况下,每次调用Fibonacci都会将调用计数器增加一。

  • 第三个参数是零个或多个label.KeyValue值,表示要与数据点关联的标签。这增加了指标的基数,正如在“基数”中讨论的那样,这非常有用。

小贴士

数据标签是一个强大的工具,允许你描述超出哪个服务或实例发出它的数据。它们可以让你对数据提出之前未曾想到的问题。

还可以将多个指标分组并作为批处理报告。这与你在前面看到的Add方法略有不同。具体来说,对于批处理中的每个指标,你需要:

  1. 收集要记录的值或值。

  2. 将每个值传递给其相应工具的Measurement方法,该方法返回一个metric.Measurement值,该值包装了你的指标并提供了一些支持性元数据。

  3. 将所有的metric.Measurement值传递给meter.RecordBatch,该方法原子地记录整个测量批次。

这些步骤在下面的示例中演示,我们使用runtime包检索两个值——进程使用的内存量和 goroutine 的数量——并将它们发射到度量收集器中:

func updateMetrics(ctx context.Context) {
    // Retrieve the meter from the meter provider.
    meter := otel.GetMeterProvider().Meter(serviceName)

    // Create the instruments that we'll use to report memory
    // and goroutine values. Error values ignored for brevity.
    mem, _ := meter.NewInt64UpDownCounter("memory_usage_bytes",
        metric.WithDescription("Amount of memory used."),
    )
    goroutines, _ := meter.NewInt64UpDownCounter("num_goroutines",
        metric.WithDescription("Number of running goroutines."),
    )

    var m runtime.MemStats

    for {
        runtime.ReadMemStats(&m)

        // Report the values to the instruments, and receive
        // metric.Measurement values in return.
        mMem := mem.Measurement(int64(m.Sys))
        mGoroutines := goroutines.Measurement(int64(runtime.NumGoroutine()))

        // Provide the measurements (and teh context and
        // labels) to the meter.
        meter.RecordBatch(ctx, labels, mMem, mGoroutines)

        time.Sleep(5 * time.Second)
    }
}

当作 goroutine 运行时,updateMetrics函数分为两部分执行:初始设置和一个无限循环,在循环中生成和记录测量值。

在设置阶段,它检索Meter,定义一些指标标签,并创建仪器。所有这些值都只创建一次,并在循环中重复使用。请注意,除了类型之外,这些仪器还通过名称和描述来指示它们所测量的指标。

在循环内部,我们首先使用runtime.ReadMemStatsruntime.NumGoroutine函数来检索我们要记录的指标(内存使用量和运行 goroutine 的数量)。有了这些值,我们使用仪器的Measurement方法为每个指标生成metrics.Measurement值。

拿着我们的Measurement值,我们将它们传递给meter.RecordBatch方法——该方法还接受当前的context.Context和我们想要附加到指标上的任何标签——以正式记录它们。

异步仪器

异步仪器,或观察器,在设置期间创建和配置以测量特定属性,并在集合期间由 SDK 调用。当您有一个希望在不管理自己的后台记录过程时监视的值时,这尤为有用。

就像同步仪器一样,异步仪器是通过附加到metric.Meter实例的构造方法创建的。总共有六个这样的函数:每种积累行为都有一个float64int64版本。所有六个函数的签名非常相似,以下是其中代表性的一个:

func (m Meter) NewInt64UpDownSumObserver(name string,
    callback Int64ObserverFunc, opts ...InstrumentOption)
    (Int64UpDownSumObserver, error)

正如您所见,NewInt64UpDownSumObserver接受指标名称作为string,称为Int64ObserverFunc的东西,以及零个或多个仪器选项(如指标描述)。虽然它返回观察器值,但实际上并不经常使用,尽管如果名称为空、重复注册或其他无效,则可能返回非nil错误。

第二个参数——回调函数——是任何异步仪器的核心。SDK 在数据收集时异步调用回调函数。有两种类型,分别是int64float64,但它们看起来、感觉起来并且工作方式本质上是相同的:

type Int64ObserverFunc func(context.Context, metric.Int64ObserverResult)

当 SDK 调用回调函数时,回调函数接收当前的context.Contextmetric.Float64ObserverResult(对于float64观察器)或metric.Int64ObserverResult(对于int64观察器)。这两种结果类型都有一个Observe方法,用于报告您的结果。

这是很多小细节,但它们可以相对无缝地结合在一起。以下函数确实做到了这一点,定义了两个观察器:

func buildRuntimeObservers() {
    meter := otel.GetMeterProvider().Meter(serviceName)
    m := runtime.MemStats{}

    meter.NewInt64UpDownSumObserver("memory_usage_bytes",
        func(_ context.Context, result metric.Int64ObserverResult) {
            runtime.ReadMemStats(&m)
            result.Observe(int64(m.Sys), labels...)
        },
        metric.WithDescription("Amount of memory used."),
    )

    meter.NewInt64UpDownSumObserver("num_goroutines",
        func(_ context.Context, result metric.Int64ObserverResult) {
            result.Observe(int64(runtime.NumGoroutine()), labels...)
        },
        metric.WithDescription("Number of running goroutines."),
    )
}

main调用时,buildRuntimeObservers函数定义了两个异步工具——memory_usage_bytesnum_goroutines——每个都有一个回调函数,其工作方式与我们在“同步工具”中定义的updateMetrics函数中的数据收集完全相同。

然而,在updateMetrics中,我们使用了一个无限循环来同步报告数据。正如您所见,对于非事件数据使用异步方法不仅设置和管理工作较少,而且后续的移动部分较少,因为一旦定义了观察者(及其回调函数),SDK 接管后就没有其他事情可做了。

综合考虑:指标

现在我们已经知道要收集和如何收集的指标,我们可以使用它们来扩展我们在“综合考虑:追踪”中放在一起的斐波那契 Web 服务。

服务的功能将保持不变。与以前一样,它将能够接受 HTTP GET 请求,在 GET 查询字符串上使用参数n请求第 n 个斐波那契数。例如,要请求第六个斐波那契数,您可以通过curl服务:http://localhost:3000?n=6

我们将进行的具体更改和收集的指标如下:

  • 通过将buildRequestsCounter函数添加到main中并按照我们在“同步工具”中描述的方法来调整服务 API 中的Fibonacci函数,同步记录 API 请求计数的功能将保持不变。

  • 通过将buildRuntimeObservers添加到main函数中,异步记录进程的内存使用情况和活动 goroutine 数,详见“异步工具”。

启动您的服务

再次通过运行其主函数启动您的服务:

$ go run .

与以前一样,您的终端应该暂停。您可以使用 Ctrl-C 停止服务。

接下来,您将启动 Prometheus 服务器。但在执行之前,您需要为其创建一个最小的配置文件。Prometheus 有大量可用的配置选项,但以下内容应该足够了。将其复制并粘贴到名为prometheus.yml的文件中:

scrape_configs:
- job_name: fibonacci
  scrape_interval: 5s
  static_configs:
  - targets: ['host.docker.internal:3000']

此配置定义了一个名为fibonacci的单个目标,位于host.docker.internal:3000,每五秒进行一次抓取(从默认的每分钟降低)。

一旦你创建了文件prometheus.yml,你就可以启动 Prometheus。最简单的方法是使用 Docker 容器:

docker run -d --name prometheus                             \
  -p 9090:9090                                              \
  -v "${PWD}/prometheus.yml:/etc/prometheus/prometheus.yml" \
  prom/prometheus:v2.23.0
警告

如果您在 Linux 上进行开发,您需要向上述命令添加参数--add-host=host.docker.internal:host-gateway但不要在生产环境中使用

现在,两个服务都已经运行,你可以发送请求给服务:

$ curl localhost:3000?n=6
13

在幕后,OpenTelemetry 刚刚记录了其 Fibonacci 函数的请求(递归和其他方式)的数量值。

指标端点输出

现在您的服务正在运行,您可以通过向其 /metrics 端点发出标准的 curl 请求直接查看其公开的指标:

$ curl localhost:3000/metrics
# HELP fibonacci_requests_total Total number of Fibonacci requests.
# TYPE fibonacci_requests_total counter
fibonacci_requests_total{application="fibonacci",container_id="d35f0bef2ca0"} 25
# HELP memory_usage_bytes Amount of memory used.
# TYPE memory_usage_bytes gauge
memory_usage_bytes{application="fibonacci",container_id="d35f0bef2ca0"}
  7.5056128e+07
# HELP num_goroutines Number of running goroutines.
# TYPE num_goroutines gauge
num_goroutines{application="fibonacci",container_id="d35f0bef2ca0"} 6

如您所见,您正在记录的三个指标及其类型、描述、标签和值都在此列出。如果 container_id 的值为空,不要感到困惑:这只是意味着您没有在容器中运行。

在 Prometheus 中查看您的结果

现在您已经启动了服务、启动了 Prometheus,并运行了一两个查询以向服务提供一些数据,现在是在 Prometheus 中可视化您的工作的时候了。再次强调,Prometheus 不是一个全功能的图形解决方案(您可能需要使用类似 Grafana 的工具),但它提供了一个简单的界面来执行任意查询。

您可以通过浏览 localhost:9090 访问这个界面。您会看到一个极简的界面,带有一个搜索框。要查看您指标随时间的变化,只需在搜索框中输入指标名称,然后按回车键,点击“图形”选项卡。您会看到类似于 Figure 11-6 中的截图。

cngo 1106

图 11-6. Prometheus 界面截图,显示经过三次调用 Fibonacci 服务后 fibonacci_requests_total 指标的值。

现在您正在收集数据,花点时间运行几个查询,看看图形如何变化。甚至可以查看其他一些指标。尽情享受吧!

日志记录

日志 是应用程序随时间发出的值得记录的事件的不可变记录。传统上,日志存储为仅追加文件,但现在,日志很可能采用某种可搜索的数据存储形式。

那么,关于日志记录,除了它作为电子计算历史上一直存在的一个非常好的想法外,还有什么可以说的呢?它是可观察性方法中的 OG。

实际上,关于日志记录还有很多要说的,主要是因为以一种让您的生活比必要更加困难的方式进行日志记录非常容易。

在可观察性的三大支柱中,日志无疑是最容易生成的。因为在输出日志事件时没有初始处理涉及,最简单的形式就像在代码中添加一个 print 语句一样简单。这使得日志非常擅长提供大量关于组件正在做什么或经历了什么的上下文丰富的数据。

但是,日志记录的这种自由形式又两难。虽然可能(并且经常是诱人的)输出您认为可能有用的任何内容,但冗长、非结构化的日志很难从中提取可用信息,尤其是在大规模情况下。要最大化日志记录的效益,事件应该是结构化的,而这种结构并非免费获取,必须经过有意的考虑和实施。

另外,日志记录特别被低估的一个陷阱是,生成大量事件会给磁盘和/或网络 I/O 带来巨大压力。消耗半数或更多可用带宽的情况并不少见。更重要的是,这种压力往往随着负载线性扩展:每个用户执行M个操作会转化为发出N*M个日志事件,对可伸缩性可能造成灾难性后果。

最后,要使日志有意义并有用,必须以使它们易于访问的方式进行处理和存储。任何曾经在大规模管理日志的人都会告诉您,自我管理和自我托管日志是极具操作负担的,而让他人管理和托管则异常昂贵。

在本节的其余部分,我们将首先讨论大规模日志记录的一些高级实践,然后讨论如何在 Go 中实现它们。

更好的日志记录实践

尽管日志记录的行为表面上看起来很简单,但以一种使您和后续使用日志的任何人生活更加困难的方式进行日志记录也很容易。在小规模部署中可能很烦人的日志记录问题,例如导航非结构化日志或高于预期的资源消耗,会在大规模情况下成为主要障碍。

如你所见,基于这个原因以及其他原因,围绕日志记录的最佳实践往往侧重于最大化生成和保留的日志数据质量,同时最小化其数量。

警告

毋需说,您不应记录敏感的业务数据或个人可识别信息。

将日志视为事件流

有多少次您查看日志输出时被一大段难以理解的意识流所迎击?它有多有用?也许比没有好一点,但可能并不多。

日志不应被视为数据汇集点,写入后便被遗忘,直到出现实际火灾,而且绝对不应该成为您发送随机想法和观察的垃圾堆。

相反,正如我们在第六章中看到的那样,日志应被视为事件流,并应直接无缓冲地写入stdoutstderr。尽管看似简单(也许有些违反直觉),这种对视角的微小改变提供了很大的自由度。

通过将日志管理责任从应用程序代码中移出,使其不再关注如路由或存储其日志事件等实现细节,允许执行者决定处理这些事件的方式。

这种方法为您管理和消费日志提供了相当多的自由度。在开发中,您可以通过将它们直接发送到本地终端来监视服务的行为。在生产中,执行环境可以捕获和重定向日志事件到像 ELK 或 Splunk 这样的日志索引系统进行审查和分析,或者长期存储到数据仓库。

将日志视为事件流,并将每个事件直接、非缓冲地写入 stdoutstderr

结构化事件用于解析

日志记录,从其最简单和最原始的形式来看,技术上可以仅使用 fmt.Println 语句完成。然而,结果将是一组格式不一的字符串,其实用性令人质疑。

幸运的是,程序员更常用 Go 的标准 log 库,它方便易用,并生成有用的时间戳。但如果日志事件格式如下,那么这些日志的千兆字节或者更多用处大吗?

2020/11/09 02:15:10AM User 12345: GET /help in 23ms
2020/11/09 02:15:11AM Database error: connection reset by peer

当然,这比什么都不做要好,但你仍然面对着一个基本上是非结构化字符串的问题,尽管它带有时间戳。你仍然需要解析任意文本以提取有意义的部分。

将其与结构化记录器输出的等效消息进行比较:¹³

{"time":1604888110, "level":"info", "method":"GET", "path":"/help",
        "duration":23, "message":"Access"}
{"time":1604888111, "level":"error", "error":"connection reset by peer",
        "database":"user", "message":"Database error"}

上述日志结构将所有关键元素放入 JavaScript 对象的属性中,每个属性都有:

time

时间戳,这是一段关键的上下文信息,对于追踪和关联问题至关重要。请注意,JSON 示例也是一种易于解析的格式,远比第一个几乎没有结构化的示例更少地消耗计算资源来提取含义。当处理数十亿条日志事件时,细微之处至关重要。

level

日志级别,即标识日志事件重要性级别的标签。经常使用的级别包括 INFOWARNERROR。这些级别在生产环境中用于过滤掉可能不相关的低优先级消息。

一个或多个上下文元素

这些包含背景信息,提供了关于消息时应用程序状态的洞察。日志事件的整个目的就是表达这些上下文信息。

简而言之,结构化日志形式更容易、更快速、更便宜地提取含义,结果也更容易搜索、过滤和聚合。

结构化日志的目的是让计算机解析,而不是人类阅读。

少即是多

日志记录并非免费。事实上,它非常昂贵。

想象一下,你在 AWS 运行的服务器上部署了一个服务。并不复杂,只是一个标准的服务器,带有一个标准的通用磁盘,能够提供每秒 16 MiB 的持续吞吐量。

假设您的服务喜欢做事彻底,因此它会细致地记录每个请求、响应、数据库调用、计算状态以及各种其他信息事件,每个请求处理的总计 16 个 1024 字节的事件。到目前为止,这有点啰嗦,但并不太不寻常。

但这些事件会积累起来。在服务每秒处理 512 个请求的场景下(对于高度并发的服务来说,这是一个完全合理的数字),您的服务将产生 8192 个事件/秒。每个事件占用 16 KiB,这总共是 8 MiB/秒的日志事件,或者占用您磁盘 I/O 容量的一半。这真是一个很大的负担。

如果我们跳过向磁盘写入并直接将事件转发到日志托管服务,会怎么样?那么坏消息是我们需要传输和存储日志,这会很昂贵。如果您将数据发送到像 Splunk 或 Datadog 这样的日志提供商,您将不得不支付云服务提供商的数据传输费用。对于 AWS 而言,这相当于每 GB US$0.08,以平均 8 MiB/s 的速率——大约每天半 1 TiB——单个实例每年的数据传输费用将接近$250,000。五十个这样的实例仅仅数据传输费用就会超过 1200 万美元。

显然,这个示例没有考虑由于时间或星期日的小时变化而导致的负载波动。但它清楚地说明了日志记录很快会变得非常昂贵,因此只记录有用的内容,并确保通过使用严重性阈值在生产中限制日志生成。

动态采样

由于调试事件产生的事件类型往往是高容量且低保真度的,通过将日志级别设置为WARNING从生产输出中排除它们已经成为标准做法。但调试日志并不是毫无价值,对吧?¹⁴ 事实证明,在您试图追踪故障根本原因时,它们确实非常快速地变得非常有用,这意味着您必须在事故发生时浪费宝贵的时间将调试日志打开,以便找到问题。哦,不要忘记事后关闭它们。

然而,通过动态采样您的日志——记录一部分事件并丢弃其余部分——您仍然可以在生产环境中保留您的调试日志,这有助于在事故期间缩短恢复时间。

在生产环境中保留一些调试日志真的非常有用,特别是在事情变得一团糟时。

使用 Go 的标准日志包进行日志记录

Go 语言包含一个名为log的标准日志包,提供一些基本的日志功能。虽然它非常基础,但它仍然具有您组成基本日志策略所需的几乎一切。

除了导入log包之外,使用它不需要任何类型的设置。

它的最基本功能可以通过选择与您熟悉的各种fmt打印函数非常相似的函数来实现:

func Print(v ...interface{})
func Printf(format string, v ...interface{})
func Println(v ...interface{})

你可能已经注意到 log 包中最显著的遗漏之一:它不支持日志级别。然而,虽然在功能上存在不足,但在简易性和易用性方面却有所弥补。

下面是最基本的日志示例:

package main

import "log"

func main() {
    log.Print("Hello, World!")
}

运行时,它提供以下输出:

$ go run .
2020/11/10 09:15:39 Hello, World!

正如你所见,log.Print 函数——以及所有 log 日志函数——都会在消息中添加时间戳,无需任何额外配置。

特殊的日志函数

尽管 log 遗憾地不支持日志级别,但它提供了一些其他有趣的特性。比如,一类方便的函数,它们将输出日志事件与其他有用的操作耦合在一起。

其中第一个是 log.Fatal 函数。有三个这样的函数,每个对应不同的 log.PrintX 函数,并且每个等同于调用其对应的打印函数,然后调用 os.Exit(1)

func Fatal(v ...interface{})
func Fatalf(format string, v ...interface{})
func Fatalln(v ...interface{})

类似地,log 还提供了一系列 log.Panic 函数,它们等同于调用其相应的 log.PrintX,然后调用 panic

func Panic(v ...interface{})
func Panicf(format string, v ...interface{})
func Panicln(v ...interface{})

这两组函数都很有用,但通常仅在错误处理中使用得比较频繁,其中报告错误并停止是有意义的。

日志输出到自定义写入器

默认情况下,log 包打印到 stderr,但如果你想将输出重定向到其他地方怎么办?log.SetOutput 函数允许你通过指定自定义的 io.Writer 来实现这一点。

这使得你可以根据需要将日志发送到文件中。正如我们在“少即是多”中提到的,通常不建议将日志写入文件,但在某些情况下可能很有用。

以下示例演示了使用 os.OpenFile 打开目标文件,并使用 log.SetOutput 将其定义为日志写入器:

package main

import (
    "log"
    "os"
)

func main() {
    // O_APPEND = Append data to the file when writing
    // O_CREATE = Create a new file if none exists
    // O_WRONLY = Open the file write-only
    flags := os.O_APPEND | os.O_CREATE | os.O_WRONLY

    file, err := os.OpenFile("log.txt", flags, 0666)
    if err != nil {
        log.Fatal(err)
    }

    log.SetOutput(file)

    log.Println("Hello, World!")
}

运行时,以下内容将写入文件 log.txt

$ go run .; tail log.txt
2020/11/10 09:17:05 Hello, World!

log.SetOutput 接受接口的事实意味着可以通过满足 io.Writer 合约来支持各种目标。如果你愿意,甚至可以创建一个 io.Writer 实现,将日志转发到像 Logstash 或 Kafka 这样的日志处理器或消息代理。可能性是无限的。

日志标志

log 包还允许你使用常量来丰富日志消息,例如文件名、行号、日期和时间等附加上下文信息。

例如,将以下行添加到我们上面的“Hello, World”示例中:

log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)

将导致类似以下的日志输出:

2020/11/10 10:14:36 main.go:7: Hello, World!

如你所见,它包括本地时区中的日期(log.Ldate)、本地时区中的时间(log.Ltime)以及 log 调用的最终文件名元素和行号(log.Lshortfile)。

我们无法控制日志部分的顺序或它们呈现的格式,但如果你需要这种灵活性,可能需要使用其他日志框架,比如 Zap。

Zap 日志包

在可观察性的三大支柱中,日志记录是 OpenTelemetry 中最不受支持的,至少在撰写本文时是这样(尽管随着时间的推移会加入支持)。

所以,暂时不讨论 OpenTelemetry Logging API,我们将介绍另一个优秀的库:Zap,一个设计为尽可能少分配内存并尽可能少使用反射和字符串格式化的 JSON 格式记录器。

Zap 目前是两种最流行的 Go 日志记录包之一,与Logrus并列。Logrus 实际上稍微更受欢迎,但三个主要因素促使我选择本书中的 Zap。首先,Zap 以其速度和低内存影响而闻名(在规模上非常有用)。其次,它有一个“结构优先”的哲学,正如我在“为解析结构化事件”中所断言的那样,这是非常可取的。最后,Logrus 现在处于维护模式,并且不再引入任何新功能。

Zap 有多快?它真的很快。例如,Table 11-2 展示了几种常见结构化日志包之间的基准比较,不包括任何上下文或printf风格模板。

表 11-2。相对于没有上下文或printf风格模板的消息的结构化日志包基准测试。

Package Time Time % to Zap Objects Allocated
Zap 118 ns/op +0% 0 allocs/op
Zap (sugared) 191 ns/op +62% 2 allocs/op
Zerolog 93 ns/op -21% 0 allocs/op
Go-kit 280 ns/op +137% 11 allocs/op
标准库 499 ns/op +323% 2 allocs/op
Logrus 3129 ns/op +2552% 24 allocs/op
Log15 3887 ns/op +3194% 23 allocs/op

这些数据是使用Zap 自己的基准测试套件开发的,但我自己检查、更新和执行了这些基准测试。当然,像任何基准测试一样,需要保持审慎。两个显著的点是 Go 自带的标准log库,其运行时间约为 Zap 标准记录器的三倍,以及 Logrus,其时间非常显著地比 Zap 慢 25 倍。

但是我们应该使用上下文字段,不是吗?那么 Zap 看起来如何呢?那些结果甚至更引人注目。

表 11-3。具有 10 个上下文字段消息的结构化日志包相对基准测试。

Package Time Time % to Zap Objects Allocated
Zap 862 ns/op +0% 5 allocs/op
Zap (sugared) 1250 ns/op +45% 11 allocs/op
Zerolog 4021 ns/op +366% 76 allocs/op
Go-kit 4542 ns/op +427% 105 allocs/op
Logrus 29501 ns/op +3322% 125 allocs/op
Log15 29906 ns/op +3369% 122 allocs/op

Zap 领先于 Logrus 的优势扩展到了(非常令人印象深刻的)33 倍;标准的log库不包含在此表中,因为它甚至不支持上下文字段。

好了,那么我们如何使用它呢?

创建 Zap 日志记录器

使用 Zap 进行日志记录的第一步是创建一个zap.Logger值。

当然,在此之前,您首先需要导入 Zap 包,如下所示:

import "go.uber.org/zap"

一旦导入了 Zap,您就可以构建您的zap.Logger实例。Zap 允许您配置日志行为的几个方面,但构建zap.Logger最直接的方法是使用 Zap 的主观预设构造函数——zap.NewExamplezap.NewProductionzap.NewDevelopment——每个函数调用都会构建一个日志记录器:

logger, err := zap.NewProduction()
if err != nil {
    log.Fatalf("can't initialize zap logger: %v", err)
}

通常情况下,这将在init函数中完成,并且zap.Logger值会在全局维护。Zap 日志记录器支持并发使用是安全的。

三个可用的预设通常对小型项目足够好,但更大的项目和组织可能希望进行更多定制。Zap 为此目的提供了zap.Config结构体,虽然具体细节超出了本书的范围,但Zap 文档详细描述了其使用方式。

使用 Zap 编写日志

Zap 更为独特的一个方面是,每个日志记录器实际上有两种易于交换的形式——标准形式和“糖化”形式——它们在效率和可用性上略有不同。

标准的zap.Logger实现强调性能和类型安全。它比SugaredLogger稍快,并且分配更少的内存,但它仅支持结构化日志记录,这使得它有些不太容易使用:

logger, _ := zap.NewProduction()

// Structured context as strongly typed Field values.
logger.Info("failed to fetch URL",
    zap.String("url", url),
    zap.Int("attempt", 3),
    zap.Duration("backoff", time.Second),
)

其输出将类似于以下内容:

{"level":"info", "msg":"failed to fetch URL",
        "url":"http://example.com", "attempt":3, "backoff":"1s"}

在性能良好但不绝对关键的环境中(这可能是大多数情况),您可以使用SugaredLogger,它可以通过其Sugar方法轻松从标准日志记录器中获取。

SugaredLogger仍然提供结构化日志记录,但其用于此类操作的函数是松散类型的,与标准日志记录器的强上下文类型形成对比。尽管在幕后使用了运行时反射,但其性能仍然非常好。

SugaredLogger甚至包括printf风格的日志记录方法,为方便起见。(请记住,在日志记录方面,上下文至关重要。)

所有这些功能都在以下示例中演示:

logger, _ := zap.NewProduction()
sugar := logger.Sugar()

// Structured context as loosely typed key-value pairs.
sugar.Infow("failed to fetch URL",
    "url", url,
    "attempt", 3,
    "backoff", time.Second,
)

sugar.Infof("failed to fetch URL: %s", url)

其输出将类似于以下内容:

{"level":"info", "msg":"failed to fetch URL",
        "url":"http://example.com", "attempt":3, "backoff":"1s"}
{"level":"info", "msg":"failed to fetch URL: http://example.com"}
提示

不要为每个函数创建一个新的Logger。相反,创建一个全局实例,或者使用zap.Lzap.S函数来获取 Zap 的全局标准或糖化日志记录器。

在 Zap 中使用动态采样

您可能还记得从“动态采样”中,动态采样是一种技术,其中传入的日志条目通过将记录事件限制为每单位时间的最大数量来进行采样。

如果广泛使用,此技术可用于管理日志记录的 CPU 和 I/O 负载,同时保留事件的代表性子集。如果针对特定类别的高容量低保真事件,如调试日志,动态抽样可以确保在生产故障排除时保留它们而不会消耗过多存储空间。

Zap 支持动态抽样,可使用此处显示的zap.SamplingConfig结构进行配置。

type SamplingConfig struct {
    // Initial sets the cap on the number of events logged each second.
    Initial    int

    // Thereafter sets the proportion of events that are logged each second
    // after Initial is exceeded. A value of 3 indicates one event in every
    // 3 is logged.
    Thereafter int

    // Hook (if defined) is called after each "log/no log" decision.
    Hook       func(zapcore.Entry, zapcore.SamplingDecision)
}

使用zap.SamplingConfig允许您定义每秒允许的具有相同级别和消息的初始事件数(Initial),之后仅记录每第n个消息(Thereafter)。其余消息将被丢弃。

以下示例演示了如何使用预配置的zap.Config实例构建一个新的zap.Logger

package main

import (
    "fmt"

    "go.uber.org/zap"
    "go.uber.org/zap/zapcore"
)

func init() {
    cfg := zap.NewDevelopmentConfig()
    cfg.EncoderConfig.TimeKey = ""          // Turn off timestamp output

    cfg.Sampling = &zap.SamplingConfig{
        Initial:    3,                      // Allow first 3 events/second
        Thereafter: 3,                      // Allows 1 per 3 thereafter
        Hook: func(e zapcore.Entry, d zapcore.SamplingDecision) {
            if d == zapcore.LogDropped {
                fmt.Println("event dropped...")
            }
        },
    }

    logger, _ := cfg.Build()                // Constructs the new logger

    zap.ReplaceGlobals(logger)              // Replace Zap's global logger
}

上述示例创建了一个新的zap.Logger并将其设置为 Zap 的全局记录器。它通过几个步骤完成这个过程。

首先,示例创建了一个新的zap.Config结构。为方便起见,此示例使用预定义的zap.NewDevelopmentConfig函数,它提供一个zap.Config值,生成可读的输出以及DebugLevel及以上的阈值。

如果您愿意,zap.NewProductionConfig函数返回一个预配置的zap.Config值,阈值为InfoLevel,并将事件编码为 JSON。如果确实需要,甚至可以从头开始创建自己的zap.Config

接下来,示例在zap.Config上创建了一个新的zap.SamplingConfig,它指示 Zap 抽样器在给定秒内保留任何相似事件的前三个,并在此后每秒丢弃除每三个消息之外的所有消息。

注意

每次抽样决策后都会调用Hook函数。如果看到事件已被丢弃,示例将写入一条消息。

最后,示例使用ConfigBuild方法从Config构造了一个zap.Logger,并使用zap.ReplaceGlobals来替换 Zap 的全局 Logger。可以分别通过zap.Lsugared logger函数访问 Zap 的全局记录器和 sugared 记录器。

但它是否按照我们的期望工作呢?好吧,让我们看看:

func main() {
    for i := 1; i <= 10; i++ {
        zap.S().Infow(
            "Testing sampling",
            "index", i,
        )
    }
}

上述函数记录了 10 个事件,但使用我们的抽样配置,我们应该只看到前 3 个事件,然后每隔三个事件(6 和 9)。我们看到了吗?

$ go run .
INFO    zap/main.go:39    Testing sampling    {"index": 1}
INFO    zap/main.go:39    Testing sampling    {"index": 2}
INFO    zap/main.go:39    Testing sampling    {"index": 3}
event dropped...
event dropped...
INFO    zap/main.go:39    Testing sampling    {"index": 6}
event dropped...
event dropped...
INFO    zap/main.go:39    Testing sampling    {"index": 9}
event dropped...

输出与我们预期的完全一样。显然,日志抽样是一种非常强大的技术,当正确使用时,可以提供显著的价值。

总结

可观测性正在引起很多关注,因为它承诺显著缩短开发反馈周期,并在一般情况下使复杂性再次可管理,这是很容易理解的。

我在本章开头稍微谈到了可观察性及其承诺,以及关于可观察性 没有 完成的部分。不幸的是,如何实现可观察性是一个非常庞大的主题,时间和空间的限制意味着我无法像我本来想的那样多地谈论它。¹⁵ 幸运的是,有一些非常棒的书即将问世(尤其是《可观察性工程》由 Charity Majors 和 Liz Fong-Jones(O’Reilly)撰写),这个空白不会太久得到填补。

然而,本章大部分内容都花在依次讨论可观察性的三大支柱上,特别是如何在可能的情况下使用OpenTelemetry来实现它们。

总的来说,这是一章具有挑战性的内容。可观察性是一个广阔的主题,关于它的文献还不多,同样的情况也适用于 OpenTelemetry,因为它还比较新。即使是它自己的文档在某些部分也是有限和零散的。但积极的一面是,我有很多时间花在源代码上。

cngo 11in02

¹ Stoll, Clifford. 高科技异端:一个计算机反对派的反思。Random House,2000 年 9 月。

² 有趣的是,这也正好是 AWS 推出其 Lambda 函数作为服务(FaaS)的时候。巧合?也许。

³ 当然,前提是我们所有的网络和平台配置都正确!

⁴ 我不是那些时髦孩子中的一个。

⁵ 除了 Go 语言,Python、Java、JavaScript、.NET、C++、Rust、PHP、Erlang/Elixir、Ruby 和 Swift 也有相应的实现。

⁶ 如果你还没看过Charity Majors 的博客,我建议你立刻去看一下。它结合了天才的一部分和经验的一部分,还有彩虹、卡通独角兽,再加上大量粗鲁的用语。

⁷ Sigelman, Benjamin H., 等人。“Dapper,一个大规模分布式系统跟踪基础设施。”Google 技术报告,2010 年 4 月。https://oreil.ly/Vh7Ig

⁸ 请记住,“mux”这个名字是“HTTP 请求复用器”的缩写。

⁹ 这本书中的最长包名记录就是这样了。

¹⁰ 它也可以 同时 指两个数据库表之间的数值关系(即 一对一一对多多对多),但这个定义在这里可能不太相关。

¹¹ “更好”是什么意思?

¹² Kiran, Oliver. “与布莱恩·巴西尔一起探索 Prometheus 的使用案例。” The New Stack Makers,2016 年 10 月 30 日。https://oreil.ly/YDIek

¹³ 任何示例中的包装只是为了格式化演示而已。如果可能的话,在你的日志事件中不要使用换行。

¹⁴ 如果是这样,那你为什么还要生产它们呢?

¹⁵ 毕竟这是一本关于 Go 语言的书。至少这是我一直在告诉我非常耐心的编辑们的。

posted @ 2024-06-18 18:05  绝不原创的飞龙  阅读(14)  评论(0编辑  收藏  举报