Go-软件工程实用指南-全-
Go 软件工程实用指南(全)
原文:
zh.annas-archive.org/md5/ceefdd89e585c59c20db6a7760dc11f1
译者:飞龙
前言
在过去的几年里,Go 语言逐渐成为构建可扩展和分布式系统行业中最受欢迎的语言之一。该语言的具有倾向性的设计和内置的并发特性使得工程师能够编写出高效利用所有可用 CPU 核心的代码。
本书提炼了行业编写精简且易于测试和维护的 Go 代码的最佳实践,并通过从头创建一个名为 'Links 'R' Us' 的多层应用程序来探讨其实际应用。你将指导完成设计、实现、测试、部署和扩展应用程序的所有步骤。你将从单体架构开始,逐步将项目转变为支持高效离核处理大型链接图的面向服务的架构(SOA)。你将了解各种高级和前沿的软件工程技术,如构建可扩展的数据处理管道、使用 gRPC 设计 API 以及大规模运行分布式图处理算法。最后,你将学习如何使用 Docker 编译和打包你的 Go 服务,并自动化它们到 Kubernetes 集群的部署。
在本书结束时,你将开始像一位专业开发者/工程师那样思考,通过编写精简且高效的 Go 代码将理论付诸实践。
这本书面向的对象
这本书是为那些对有效使用 Go 语言设计和构建可扩展分布式系统感兴趣的开发商和软件工程师而写的。这本书对那些渴望成为专业软件工程师的业余到中级水平的开发者来说也将非常有用。
本书涵盖的内容
第一章,软件工程鸟瞰,解释了软件工程与编程之间的区别,概述了你在小型、中型和大型组织中可能遇到的不同工程角色。更重要的是,本章总结了每个软件工程师(SWE)都应该了解的基本软件设计生命周期模型。
第二章,编写清晰和可维护的 Go 代码的最佳实践,解释了 SOLID 设计原则如何应用于 Go 项目,并提供了组织你的 Go 代码包以及编写易于维护和测试的代码的有用技巧。
第三章,依赖管理,强调了版本控制 Go 包的重要性,并讨论了用于管理项目依赖项的工具和策略。
第四章,测试的艺术,提倡使用诸如存根、模拟、间谍和假对象等原语来编写代码的全面单元测试。此外,本章列举了不同类型测试的优缺点(例如,黑盒与白盒、集成与功能测试),并以关于高级测试技术(如冒烟测试和混沌测试)的有趣讨论结束。
第五章,Links 'R' Us 项目,介绍了我们将在接下来的章节中从头开始构建的实战项目。
第六章,构建持久化层,专注于为 Links 'R' Us 项目的两个组件:链接图和文本索引器设计并实现数据访问层。
第七章,数据处理管道,探讨了数据处理管道背后的基本原理,并实现了一个使用 Go 原语(如通道、上下文和 goroutines)构建通用、并发安全和可重用管道的框架。然后,使用该框架开发 Links 'R' Us 项目的爬虫组件。
第八章,基于图的数据处理,解释了计算批量同步并行(BSP)模型的原理,并从头开始实现了一个用于在图上执行并行算法的框架。作为一个概念验证,我们将使用这个框架来研究流行的基于图的算法(如最短路径和图着色)的并行版本,我们的努力最终将完成 PageRank 算法的完整实现,这是 Links 'R' Us 项目的一个关键组件。
第九章,与外界沟通,概述了与路由、安全性和版本化等主题相关的 RESTful 和基于 gRPC 的 API 之间的关键区别。在本章中,我们还将定义 gRPC API,以便通过网络访问 Links 'R' Us 项目的链接图和文本索引器数据存储。
第十章,构建、打包和部署软件,列举了将 Go 应用程序 docker 化并优化其大小的最佳实践。此外,本章探讨了 Kubernetes 集群的解剖结构,并列出了我们可以使用的 Kubernetes 资源的基本列表。作为一个概念验证,我们将创建 Links 'R' Us 项目的单体版本,并将其部署到你在本地机器上启动的 Kubernetes 集群中。
第十一章,将单体拆分为微服务,解释了 SOA 模式,并讨论了一些你应该注意的常见反模式和从单体设计切换到微服务时想要避免的陷阱。为了测试本章中的想法,我们将把 Links 'R' Us 项目的单体版本拆分为微服务,并将它们部署到 Kubernetes。
第十二章,构建分布式图处理系统,结合了前几章的知识,创建了一个基于图的分布式数据处理框架的版本,可用于不适合内存的大规模图(离核处理)。
第十三章,指标收集与可视化,列举了收集和索引应用指标最流行的解决方案,重点关注 Prometheus。在讨论了如何对 Go 代码进行仪器化以捕获和导出 Prometheus 指标的方法之后,我们将深入探讨使用 Grafana 等工具进行指标可视化和基于收集指标聚合值的警报管理器设置警报。
第十四章,结语,提供了通过扩展我们在本书各章节中构建的动手项目来进一步理解材料的建议。
为了充分利用本书
为了充分利用本书并实验配套代码,您需要对 Go 编程有相当好的理解,以及足够使用 Go 生态系统所包含的各种工具的经验。
此外,本书假设您对基本网络理论有扎实的掌握。
最后,本书中的一些更技术性的章节使用了 Docker 和 Kubernetes 等技术。虽然对这些技术的先验知识不是严格必需的,但使用这些(或等效)系统的前期经验无疑将有助于更好地理解这些章节中讨论的主题。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择支持选项卡。
-
点击代码下载。
-
在搜索框中输入书名,并遵循屏幕上的说明。
一旦文件下载完成,请确保使用最新版本的以下软件解压或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
上找到。查看它们吧!
代码实战
要查看代码实战,请访问以下链接:bit.ly/37QWeR2
。
下载彩色图像
我们还提供包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781838554491_ColorImages.pdf
。
使用的约定
本书使用了多种文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在以下代码中,您可以看到为即将推出的游戏定义的通用Sword
类型的定义。”
代码块设置如下:
type Sword struct {
name string // Important tip for RPG players: always name your swords!
}
// Damage returns the damage dealt by this sword.
func (Sword) Damage() int {
return 2
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
type Sword struct {
name string // Important tip for RPG players: always name your swords!
}
// Damage returns the damage dealt by this sword.
func (Sword) Damage() int {
return 2
}
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“以下摘录是收集系统的一部分。”
将性能指标发布到键值存储。
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并通过customercare@packtpub.com
给我们发送邮件。
勘误表:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一点。请访问www.packtpub.com/support/errata,选择您的书,点击勘误表提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packt.com
与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问authors.packtpub.com。
评论
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问packt.com.
第一部分:软件工程与软件开发生命周期
第一部分的目标是使您熟悉软件工程的概念、软件开发生命周期的各个阶段以及软件工程师的多种角色。
本节包含以下章节:
- 第一章,软件工程的鸟瞰
第一章:软件工程的鸟瞰图
“雇佣人们编写代码来销售与雇佣人们设计和构建耐用、可用、可靠的软件是不同的。”
- 拉里·康斯坦丁 ^([6])
在我职业生涯的各个阶段,我遇到了几个知道如何编码的人;他们的技能水平从初学者到有些人称之为的大师。所有这些人都有不同的背景,在初创公司和大型组织中工作。对于一些人来说,编码被视为从他们的计算机科学研究中自然发展的过程,而其他人则将编码作为职业转变决策的一部分。
不论这些差异如何,他们都有一个共同点:当被要求描述他们当前的角色时,所有的人都使用了“软件工程师”这个术语。求职者在简历中使用这个术语作为将自己与全球分布的软件开发者群体区分开来的手段,这是一种相当常见的做法。对在线发布的职位描述进行快速随机抽样显示,许多公司——尤其是知名度高的初创公司——似乎也认同这种思维方式,正如他们寻找专业人士填补软件工程师职位所证明的那样。实际上,正如我们在本章中将要看到的,软件工程师这个术语更像是一个涵盖广泛定制角色的总称,每个角色都结合了不同水平的软件开发专业知识以及与系统设计、测试、构建工具和运营管理等相关领域的专业技能。
那么,软件工程是什么,它与编程有何不同?软件工程师应该具备哪些技能组合,他们可以依赖哪些模型、方法和框架来促进复杂软件组件的交付?这些问题将是本章要回答的一些问题。
本章涵盖了以下主题:
-
软件工程的定义
-
你可能在当代组织中遇到的软件工程角色的类型
-
对流行的软件开发模型进行概述,以及根据项目类型和需求选择哪种模型
什么是软件工程?
在我们深入探讨本章内容之前,我们需要对软件工程周围的一些基本术语和概念有一个理解。首先,我们如何定义软件工程,它与软件开发和编程在哪些方面有所不同?为了开始回答这个问题,我们将从检查软件工程的正式定义开始,该定义发表在《IEEE 软件工程术语标准词汇表》中 ^([7]):
“软件工程被定义为对软件开发、运营和维护应用系统化、规范、可量化的方法。”
从这个定义中得出的主要启示是,编写代码只是软件工程众多方面之一。最终,任何有能力的程序员都可以将一个定义良好的规范转换成一个完全功能化的程序,而无需考虑产生干净和可维护代码的需要。另一方面,一个有纪律的软件工程师会采取更系统的方法,通过应用常见的模式来确保产生的软件是可扩展的、易于测试的,并且在将来有其他工程师或工程团队接管时有良好的文档记录。
除了编写高质量代码的明显要求外,软件工程师还负责考虑将要构建的系统中的其他方面。软件工程师必须能够回答的一些问题包括以下内容:
-
软件需要支持哪些业务用例?
-
系统由哪些组件组成,它们之间如何相互作用?
-
将使用哪些技术来实现各种系统组件?
-
软件将如何测试以确保其行为符合客户的期望?
-
负载如何影响系统的性能,以及系统扩展的计划是什么?
要能够回答这些问题,软件工程师需要一套特殊的技能,正如你可能意识到的,这些技能超出了编程的范畴。这些额外的责任和所需技能是区分软件工程师和软件开发者的主要因素。
软件工程角色的类型
正如我们在上一节中讨论的,软件工程是一个本质上复杂、多阶段的过程。为了管理这种复杂性,世界各地的组织多年来投入了大量时间和精力,将这个过程分解成一系列定义良好的阶段,并培训他们的工程人员高效地处理每个阶段。
一些软件工程师努力在整个软件开发生命周期(SDLC)的各个阶段工作,而其他人则选择专注于并精通 SDLC 的某个特定阶段。这导致了各种软件工程角色的出现,每个角色都有不同的责任和所需技能集合。让我们简要地看看在为小型和大型组织工作时可能会遇到的最常见的软件工程角色。
软件工程师(SWE)的角色
软件工程师(SWE)是你在任何组织中都必然会与之互动的最常见角色,无论其规模大小。软件工程师不仅在设计和新软件构建中扮演关键角色,而且在运营和维护现有和遗留系统中也发挥着重要作用。
根据他们的经验水平和技术专长,SWEs 被分为三个类别:
-
初级工程师:初级工程师是那些刚刚开始软件开发职业生涯的人,他们缺乏构建和部署生产级软件的必要经验。公司通常热衷于招聘初级工程师,因为这可以使他们的招聘成本保持低廉。此外,公司经常将有潜力的初级工程师与资深工程师配对,试图将他们培养成中级工程师,并延长他们的服务时间。
-
中级工程师:典型的中级工程师至少有三年软件开发经验。中级工程师应具备对软件开发生命周期各个方面的扎实掌握,并且是那些可以对特定项目产生的代码量产生重大影响的人。为此,他们不仅贡献代码,还审查并给出对其他团队成员贡献的代码的反馈。
-
资深工程师:这类工程师精通各种不同的技术;他们的知识广度使他们成为组建和管理软件工程团队、以及作为资历较低工程师的导师和教练的理想人选。凭借多年的经验,资深工程师对特定业务领域有深入的理解。这种特质使他们能够作为团队与其他技术或非技术业务利益相关者之间的联络人。
另一种对软件工程师进行分类的方法是考察他们工作的主要焦点:
-
前端工程师专门从事客户交互的软件。前端工作的例子包括桌面应用程序的 UI、为软件即服务(SaaS)提供的单页网页应用程序,以及运行在手机或其他智能设备上的移动应用程序。
-
后端工程师专注于构建实现实际业务逻辑的部分,并处理数据建模、验证、存储和检索。
-
全栈工程师是指那些对前端和后端技术都有良好理解,并且对做前端或后端工作没有特别偏好的开发者。这类开发者更加灵活,因为他们可以根据项目需求轻松地在团队间转换。
软件开发测试工程师(SDET)的角色
软件开发测试工程师(SDET)的角色起源于微软的工程团队。简而言之,SDETs 就像他们的 SWE 同行一样参与软件开发,但他们的主要焦点在于软件测试和性能。
SDET 的主要职责是确保开发团队能够生产出无缺陷的高质量软件。实现这一目标的前提是了解软件测试的不同类型的方法,包括但不限于单元测试、集成测试、白盒/黑盒测试、端到端/验收测试和混沌测试。我们将在接下来的章节中更详细地讨论所有这些测试方法。
SDETs 用来实现目标的主要工具是测试自动化。当设置了持续集成(CI)管道以自动测试不同设备和 CPU 架构上的更改时,开发团队能够更快地迭代。除了设置 CI 管道的基础设施并将其与团队使用的源代码仓库系统集成外,SDETs 通常还负责编写和维护一组单独的测试。这些测试分为以下两类:
-
验收测试:一组脚本化的端到端测试,以确保在为新版本发布绿灯之前,整个系统符合所有客户业务需求。
-
性能回归测试:另一组质量控制测试,它监控构建过程中的多个性能指标,并在某个指标超过特定阈值时发出警报。这类测试在签订了服务级别协议(SLA)的情况下证明是非常宝贵的,因为即使所有单元测试都通过,看似无害的代码更改(例如,切换到不同的数据结构实现)也可能触发 SLA 违约。
最后,SDETs 与支持团队合作,将收到的支持工单转换为开发团队能够处理的错误报告。软件开发和调试技能的结合,以及 SDET 对正在开发的系统的熟悉,使他们能够独特地追踪生产代码中错误的位置,并提出示例案例(例如,特定的数据输入或一系列操作),使开发者能够重现触发每个错误的精确条件集。
网站可靠性工程师(SRE)的角色
2016 年,当谷歌出版了一本关于网站可靠性工程(Site Reliability Engineering)主题的书时,网站可靠性工程师(SRE)的角色受到了关注。这本书概述了谷歌内部用于运行其生产系统的最佳实践和策略,并自那以后,大多数在 SaaS 领域运营的公司都广泛采用了这一角色。
这个术语最初是在 2003 年左右由谷歌站点可靠性团队的创始人本·特雷诺尔(Ben Treynor)提出的。站点可靠性工程师(SRE)是一位拥有强大技术背景的软件工程师,同时专注于部署和运行生产级服务的运营方面。
根据原始的角色定义,SRE 们大约花费 50%的时间开发软件,其余 50%处理与运营相关的方面,例如以下内容:
-
处理支持工单或响应警报
-
值班
-
运行手动任务(例如,升级系统或运行灾难恢复场景)
对于 SRE 来说,提高他们运营的服务稳定性和可靠性是最符合他们利益的。毕竟,没有人愿意在凌晨 2 点被叫醒,因为服务因突发请求量激增而崩溃。最终目标始终是提供高度可用和自我修复的服务;这些服务能够自动从各种故障中恢复,无需人工干预。
SRE 的基本座右铭是通过自动化重复性任务来消除潜在的人为错误来源。这一哲学的一个例子是使用持续部署(CD)管道来最小化将软件更改部署到生产环境所需的时间。当发现影响生产的重大问题并必须尽快部署修复方案时,这种类型自动化的好处就显现出来了。
最终,软件是由人类设计和构建的,所以无疑会有错误存在。与其依赖严格的验证过程来防止缺陷被部署到生产环境中,SRE 们在这样一个非完美的世界中运作,认为系统会崩溃,有缺陷的软件最终会被部署到生产环境中。为了检测有缺陷的软件部署并减轻其对最终用户的影响,SRE 们设置了监控系统,跟踪每个部署服务的各种健康相关指标,并在部署导致服务错误率增加时触发自动回滚。
发布工程师(RE)的角色
在一个复杂、单体系统被分解成多个微服务,持续交付成为新常态的世界里,调试仍在野外部署的旧软件版本成为软件工程师的一个主要痛点。
为了理解这为什么会成为痛点,让我们来看一个小例子:你在一个阳光明媚的周一早上到达办公室,却发现你的一位主要客户已经对你团队负责的基于微服务的软件提交了一个错误报告。更糟糕的是,这位特定的客户正在运行软件的长期支持(LTS)版本,这意味着运行在客户机器上的某些,如果不是所有微服务,都是基于至少落后当前开发状态几百次提交的代码。那么,你实际上如何才能提供一个错误复现器并检查错误是否已经被上游修复?
这就是可重复构建概念发挥作用的地方。通过可重复构建,我们指的是在任何时间点,我们都应该能够编译所有系统组件的特定版本,其中生成的工件与客户部署的工件在位对位上相匹配。
发布工程师(RE)实际上是一位与所有工程团队合作,定义和记录构建和发布代码到生产所需的全部步骤和流程的软件工程师。发布工程师的先决条件是拥有所有编译、版本控制、测试和打包软件所需的工具和流程的深入知识。RE 的典型任务包括以下内容:
-
编写 makefile
-
实施软件工件容器化的工作流程(例如,作为 Docker 或
.rkt
镜像) -
确保所有团队使用完全相同的构建工具(编译器、链接器等)版本和标志
-
确保构建既可重复又密封:在同一软件版本的构建之间,外部依赖(例如,第三方库)的变化不应影响每个构建产生的工件
系统架构师的角色
在本节中我们将讨论的最后一个角色,也是你可能在处理大型项目或与大型组织合作时才会遇到的角色,就是系统架构师。虽然软件工程团队专注于构建系统的各个组件,但架构师是唯一一个能看到全局的人:系统由哪些组件组成,每个组件必须如何实现,以及所有组件如何相互配合和交互。
在较小的公司中,架构师的角色通常由一位资深工程师担任。在较大的公司中,架构师是一个独立的角色,由既有扎实的技术背景又具备强大的分析和沟通能力的人担任。
除了为系统制定一个高级的、基于组件的设计外,架构师还负责在开发过程中做出有关将使用哪些技术的决策,并设定所有开发团队必须遵守的标准。
尽管架构师有技术背景,但他们很少有机会编写代码。事实上,架构师往往花费大量时间与各种内部或外部利益相关者开会,编写设计文档或为软件工程团队提供技术指导。
一份所有工程师都应该了解的软件开发模型列表
上一节中的软件工程定义暗示了软件工程是一个复杂、多阶段的过程。为了提供这些阶段的正式描述,学术界提出了 SDLC(软件开发生命周期)的概念。
SDLC 是一个系统化的过程,用于构建符合最终用户或客户期望的高质量软件,同时确保项目的成本保持在合理的范围内。
近年来,出现了大量促进软件开发的替代模型建议。以下图表是一个时间线,展示了一些最流行的 SDLC 模型被引入的年份:
图 1:本章将展示的软件开发模型的时间线
在接下来的章节中,我们将更详细地探讨上述每个模型。
瀑布模型
水晶模型可能是实施 SDLC 最广为人知的模型。它在 1970 年由温斯顿·罗伊斯提出,定义了一系列必须按特定顺序顺序完成的步骤。每个阶段都会产生一定的输出,例如文档或某些工件,然后这些输出反过来被后续步骤所消费。
以下图表概述了瀑布模型引入的基本步骤:
-
需求收集:在这个阶段,客户的需求数据被捕获和分析,并生成一份需求文档。
-
设计:根据需求文档的内容,分析师将规划系统的架构。这一步通常分为两个子步骤:逻辑系统设计,将系统建模为一系列高级组件,以及物理系统设计,选择适当的技术和硬件组件。
-
实施:实施阶段是将前一步的设计文档转化为实际代码的过程。
-
验证:验证阶段紧随实施阶段之后,确保已实施的软件实际上满足在需求收集阶段收集的客户需求集合。
-
维护:在瀑布模型中,这是软件开发完成后由客户部署和运行的最后阶段:
图 2:瀑布模型定义的步骤
需要记住的一点是,瀑布模型基于这样的假设:所有客户需求都可以在项目实施阶段开始之前尽早收集到,尤其是项目实施阶段开始之前。将所有需求作为用例集提供,使得对交付项目所需时间和相关开发成本的准确估计变得更加容易。由此产生的结果是,软件工程师可以提前获得所有预期的用例和系统交互,这使得测试和验证系统变得更加简单。
瀑布模型存在一些注意事项,使得在构建软件系统时不太适合使用。一个潜在的注意事项是,该模型以抽象、高层次的方式描述每个阶段,并没有提供对构成每个步骤的过程的详细视图,甚至没有处理通常期望在模型的各个步骤中并行执行的跨切面过程(例如,项目管理或质量控制)。
虽然这种模型适用于中小型项目,但至少在我看来,它对于大型组织或政府机构委托的项目来说可能并不那么高效。首先,该模型假设分析师总是能够从客户那里提取出正确的需求集。这并不总是如此,因为,很多时候,客户无法准确描述他们的需求,或者倾向于在项目交付前识别出额外的需求。此外,该模型的顺序性质意味着在收集初始需求和实际实施之间可能会有一段很长的时间。在这段时间里——在软件工程术语中,有些人可能会称之为永恒——客户的需求可能会发生变化。需求的变化需要额外的开发工作,这直接导致交付成果的成本增加。
迭代增强
下图所示的迭代增强模型是在 1975 年由 Basili 和 Victor 提出的,旨在改进瀑布模型的一些注意事项。通过认识到长期项目的需求可能会发生变化,该模型主张执行一系列的演变周期或迭代,每个迭代都从项目的时间预算中分配一定的时间:
图 3:交互增强模型的步骤
与从完整的规格说明开始不同,每个周期都专注于构建最终交付成果的部分,并从上一个周期中细化需求集。这允许开发团队能够充分利用在那个特定时间点可用的任何信息,并确保任何需求变更都能及早发现并采取行动。
在应用迭代模型时,一个重要的规则是每个周期的输出必须是一个可用的软件片段。最后一个迭代是最重要的,因为它的输出产生了最终的软件交付成果。正如我们将在接下来的章节中看到的那样,迭代模型在大多数当代软件开发模型的演变中产生了相当大的影响。
螺旋
螺旋开发模型由 Barry Boehm 于 1986 年引入,作为一种在开发与重大开发成本相关的大型项目时最小化风险的方法。
在软件工程的背景下,风险被定义为任何可能导致项目无法实现其目标的情况或事件序列。各种程度的失败例子包括以下内容:
-
迟交交付期限
-
超过项目预算
-
根据尚未可用的硬件按时交付软件
如以下图所示,螺旋模型结合了瀑布模型和迭代模型的思想和概念,以及风险评估和分析过程。正如 Boehm 所指出的,那些对模型不熟悉的人在第一次看到这个图时通常会犯的一个非常常见的错误是,假设螺旋模型只是每个周期必须遵循的顺序的增量瀑布步骤序列。为了消除这种误解,Boehm 为螺旋模型提供了以下定义:
“螺旋开发模型是一种以风险为驱动的流程模型生成器,它采用循环的方法逐步扩大项目范围,同时降低风险程度。”
根据这个定义,风险是帮助项目利益相关者回答以下问题的首要因素:
-
我们接下来应该遵循哪些步骤?
-
在我们需要重新评估风险之前,我们应该继续遵循这些步骤多长时间?
图 4:1986 年由 Boehm 发表的原始螺旋模型
在每个周期的开始,所有潜在的风险来源都被识别,并提出了缓解计划以解决任何风险关注点。然后,根据重要性对这些风险进行排序,例如对项目的影响和发生的可能性,并作为利益相关者在规划下一个螺旋周期步骤时的输入。
关于螺旋模型的一个常见误解是,开发方向是单向的,只能螺旋向外扩展,即不允许回溯到先前的螺旋周期。这通常不是情况:利益相关者总是试图根据他们在特定时间点可用的信息做出明智的决定。随着项目开发的进展,情况可能会发生变化:可能会引入新的需求,或者可能会出现之前未知的信息。在新的信息面前,利益相关者可能会选择重新评估先前的决策,在某些情况下,可能会回滚开发到先前的螺旋迭代。
敏捷
当我们谈论敏捷开发时,我们通常指的是 90 年代初最初提出的一组更广泛的软件开发模型。敏捷是一种涵盖了一组框架以及相当长的软件开发最佳实践列表的伞形术语。如果我们必须为敏捷提供一个更具体的定义,我们可能会这样定义它:
“敏捷开发倡导通过在多个、尽管相对较短、的周期中迭代来增量式地构建软件。利用自我组织和跨职能团队,它通过促进团队内部协作来演进项目需求和解决方案。”
随着敏捷开发和敏捷框架,尤其是 2001 年发布的敏捷软件开发宣言的出版,其普及率急剧上升^([3])。在撰写本书时,敏捷开发实践已成为软件行业的事实标准,尤其是在初创公司领域。
在接下来的章节中,我们将深入探讨敏捷家族中最受欢迎的一些模型和框架。虽然对每个模型进行深入研究超出了本书的范围,但如果您想了解更多关于以下模型的信息,本章末尾将提供一些额外的资源。
精益
精益软件开发是敏捷软件开发模型家族中最早的成员之一。它在 2003 年由玛丽和汤姆·波普迪克引入^([10])。其根源可以追溯到 20 世纪 70 年代丰田生产系统引入的精益制造技术。当应用于软件开发时,该模型倡导七个关键原则。
消除浪费
这是精益开发模型的关键哲学之一。任何不直接增加最终交付成果价值的东西都被视为障碍,必须移除。
该模型被描述为浪费的典型情况如下:
-
在开发过程中引入非必需功能,即那些“想要”的功能。
-
过于复杂的决策过程迫使开发团队在等待某个功能被批准时保持闲置——换句话说:官僚主义!
-
各个项目利益相关者和开发团队之间不必要的沟通。这会干扰开发团队的专注力,并阻碍他们的开发速度。
创建知识
开发团队永远不应该假设客户的需求是静态的。相反,应该始终假设它们是动态的,并且可能会随时间变化。因此,对于开发团队来说,制定适当的策略以确保他们的世界观始终与客户保持一致是至关重要的。
实现这一目标的一种方法是通过借鉴和实施其他模型的一些方面,例如我们在上一节中讨论的迭代模型,或者相应地调整他们的工作流程,以确保交付成果始终以增量方式构建,并且始终使用客户要求的最新版本。
当然,外部获取的知识只是方程的一半;开发团队本身也是知识来源之一。当团队协作交付软件时,他们会发现某些方法和实践比其他方法更有效。特别是,某些方法可以加速团队的开发速度,而另一些则阻碍它。因此,对于团队来说,捕捉这一部分隐性知识,将其内化,并使其在未来对其他团队可用,是非常重要的。实现这一目标的一种方法是为团队创建另一个团队,以便它们可以同步、反思工作流程,并讨论任何潜在问题。
暂缓承诺
与敏捷家族中的所有模型一样,精益模型没有试图强迫项目利益相关者在项目开始时做出所有必要的决定。背后的理由相当简单:当人们没有已经承诺采取特定的一组行动时,他们更有可能相信需要改变。
精益模型积极鼓励利益相关者在项目的后期阶段再推迟所有重要且可能不可逆转的决定。
构建质量
项目延误的主要原因无疑是缺陷的累积。缺陷对开发团队的进度有直接影响,因为成员们经常需要暂停他们当前的工作,去追踪和修复由前一个开发迭代引入的潜在现场关键错误。
精益模型促使工程团队积极关注以下敏捷实践,如测试或行为驱动的开发(TDD/BDD),以期生产出精益、经过良好测试且缺陷更少的代码。这一建议的好处也得到了图尔汉和其他研究人员进行的调查研究的证实 ^([13])。
快速交付
每个工程团队都可能同意,他们最希望的是尽可能快地将他们目前正在开发的软件交付给客户或最终用户。阻碍团队快速交付软件的最常见因素如下:
-
过度分析业务需求
-
过度设计解决方案以满足那些需求
-
过度负担开发团队
符合精益开发的哲学,团队必须快速迭代,也就是说,他们必须尽可能简单构建解决方案,尽可能早地向目标客户展示,并收集有用的反馈,用于在后续迭代中逐步改进解决方案。
尊重并赋权人员
精益开发致力于通过过滤掉增加工程师认知负担的不必要干扰源,来改善开发团队的办公环境,并最终可能导致燃尽。
更重要的是,通过反对微观管理并鼓励团队自我组织,团队成员可以感到更有动力和权力。波彭迪克夫妇认为,参与感和赋权的人员可以更有效率;因此,他们可以为团队带来更多价值,进而为他们的公司带来更多价值。
查看并优化整体
在《精益软件开发:敏捷工具包》一书中,玛丽和汤姆·波彭迪克使用基于流的类比来描述软件开发过程。根据这个定义,开发过程的每个阶段都可以被视为业务潜在的价值生成器(一个价值流)。波彭迪克夫妇声称,为了最大化通过开发各个阶段流动的价值,组织必须将开发过程视为一系列相互关联的活动,并将它们作为一个整体进行优化。
这是组织在尝试应用精益思维概念时最常陷入的陷阱之一。你可能听说过那句古老的谚语“只见树木,不见森林”。许多组织在其他精益模型原则的影响下,如快速交付,将所有精力都集中在优化其开发过程中的某个特定方面。对于外部的旁观者来说,这种做法似乎在短期内是有效的。然而,从长远来看,团队容易受到次优化的负面影响。
要理解次优化如何从长远影响团队的表现,让我们考察一个假设的场景:为了加快迭代速度,开发团队采取了一些捷径,也就是说,他们推送了不够出色的代码或未经过充分测试的代码。虽然代码确实可以工作,并且满足了客户的需求,但它也增加了代码库的复杂性,不可避免地产生了副作用,即更多的缺陷开始悄悄地出现在交付给客户的代码中。现在,开发团队面临着更大的压力,需要在保持之前开发速度的同时修复引入的 bug。正如你可能推断的那样,到了这个阶段,开发团队已经陷入了恶性循环,并且肯定是一个不容易摆脱的循环。
在另一端,应用整体系统优化概念的流行且成功的例子是 Spotify 的squad-based框架。Spotify 小队是精简的、跨职能的、多学科的、自我组织的团队,将所有需要将一个特性从其构思到最终产品交付的所有开发阶段的人聚集在一起。
Scrum
Scrum 无疑是敏捷家族中最广为人知的框架,也是许多公司(尤其是那些在开发新产品或积极寻求优化其软件开发流程的公司)的首选解决方案。事实上,Scrum 已经变得如此流行,以至于现在有几个组织提供 Scrum 认证课程。它是由 Ken Schwaber 和 Jeff Sutherland 共同创建的,最初于 1995 年在 ACM 面向对象编程、系统、语言和应用会议中提出。
作为一种流程框架,Scrum 旨在由跨职能团队应用于可以分解为更小块工作的大型项目,其中每个小块通常需要两到四周的时间来完成——在 Scrum 术语中也称为sprint。
与我们之前讨论的其他软件开发模型不同,Scrum 并没有明确提倡特定的设计过程或方法。相反,它提倡一种经验主义、反馈循环类型的做法:最初,团队基于当时可用的信息提出一个如何进行下去的想法。然后,这个想法在下一个 sprint 周期中进行测试,并收集反馈。团队随后反思这些反馈,进一步细化方法,并将其应用于下一个 sprint。
随着越来越多的 sprint 周期过去,团队学会了自我组织,以便更有效地应对手头的任务。通过提高团队成员之间的沟通质量,同时减少干扰,团队通常会观察到团队产出(在敏捷术语中也称为团队速度)的提升。
需要记住的一个重要事项是,虽然本章从软件工程师的角度审视 Scrum,但 Scrum 流程和原则也可以应用于涉及软件开发的其他类型的项目。例如,Scrum 也可以用于运行营销活动、招聘人员,甚至处理建设项目。
Scrum 角色
当将 Scrum 框架应用于软件开发团队时,每个成员都可以映射到以下三个角色之一:
-
产品负责人 (PO)
-
开发团队成员
-
Scrum Master (SM)
官方的 Scrum 指南^([12]),可在网上免费下载,超过 30 种语言,将产品负责人(PO)定义为项目中的关键利益相关者,即最大化开发团队工作成果的产品价值的人。
产品负责人的主要职责是管理项目待办事项。待办事项只是指特定项目需要完成的任务列表的一种正式方式,包括即将到来的开发周期的新功能、增强功能或错误修复。
产品负责人(PO)必须始终确保所有待办事项条目都描述得清晰、一致且无歧义。此外,待办事项的内容永远不应被视为静态,而应始终假设为动态:在开发过程中,可能会引入新的任务,同时可能删除现有任务,以方便对项目需求进行更改。这给产品负责人的角色增加了额外的责任:他们需要能够应对此类变化并相应地重新排序待办事项。
开发团队由一组实施从待办事项中选出的任务的个人组成。根据 Scrum 的基本原则,团队应如下所示:
-
应该是跨职能的,将来自不同学科和不同技能水平的人聚集在一起
-
不应关注其成员的职位名称,而应关注所执行的工作
-
应该与单一目标保持一致:完成每个冲刺开始时团队承诺的任务集
最后但同样重要的是 Scrum 角色,即Scrum Master(SM)。SM 通过确保每个人都对团队目标以及各种 Scrum 流程有清晰的理解来支持产品负责人(PO)和开发团队成员。SM 还负责根据需要组织和运行适当的 Scrum 活动(仪式)。
重要的 Scrum 事件
Scrum 规定了一系列专门设计来帮助团队变得更加敏捷并提高其性能的事件。让我们简要地看一下软件开发的 Scrum 基本事件的列表。
我们将要考察的第一个 Scrum 事件是规划会议。在规划期间,团队检查待办事项中的项目,并承诺在下一个冲刺期间团队将工作的任务集合。
如你所料,团队需要定期同步,以确保所有团队成员对其他团队成员当前正在处理的任务保持一致。这通过每日站立会议得以实现,这是一个时间限制的会议,通常不超过 30 分钟。每个团队成员轮流发言,简要回答以下问题:
-
我昨天在做什么?
-
我今天将做什么?
-
完成特定任务是否有阻碍因素?
如果未解决,阻碍因素可能会危及团队在冲刺中的目标。因此,尽早检测阻碍因素并让团队成员参与找出绕过或解决它们的方法至关重要。
在冲刺结束时,团队通常会举行一个回顾会议,团队成员公开讨论在冲刺期间做得好的事情以及做得不好的事情。对于遇到的每个问题,团队试图确定其根本原因并提出一系列补救措施。选定的措施将在下一个冲刺周期中应用,并在下一个回顾中重新评估其效果。
Kanban
Kanban,其名称从日语中大致翻译为视觉信号或公告板,是另一种非常流行的敏捷框架,据报道自 2004 年以来在微软使用。Kanban 模型的一个标志性特征当然是看板板,这是 David Anderson 在 2010 年书籍中概述的概念,介绍了这个特定模型背后的理念。
�看板板允许团队成员可视化正在进行的任务集合及其当前状态。该板由一系列垂直方向的工作通道或列组成。每个通道都有自己的标签和与之关联的项目或任务列表。随着项目或任务的处理,它们会在板的各个列之间过渡,直到最终到达一个表示其完成的列。完成的项目通常从板上移除并存档以供将来参考。
软件开发的标准化通道配置至少包括以下集合:
-
待办事项:团队近期要处理的任务集合
-
进行中:正在进行的任务
-
审查中:其他团队成员提交审查的工作
-
完成:已完成的项目
每个团队根据其特定的开发工作流程定制车道配置是合乎逻辑的。例如,一些团队可能包括一个测试中列来跟踪其他团队进行的 QA 检查的项目,一个已部署列来跟踪已部署到生产中的项目,甚至一个阻塞列来指定需要团队采取某种行动才能继续的任务。
我相信你们中的大多数人可能已经熟悉了 Kanban 板物理实现的模样:办公室墙上一个专门的位置,上面贴满了五彩缤纷的便利贴。虽然本地团队喜欢在墙上放置这样的板子,因为它使得查看每个人正在做什么或通过板子识别阻塞点变得非常容易,但这种方法显然无法支持部分或完全远程的团队。对于这些用例,一些公司正在提供在线、数字化的 Kanban 板等价物,可以用来替代。
DevOps
DevOps 是我们将在本章中考察的最后一个软件开发模型。如今,越来越多的组织努力通过从单体架构过渡到面向服务的架构(SoA)来扩展其系统。DevOps 模型背后的基本前提是每个工程团队拥有他们所构建的服务。这是通过将开发和运维融合在一起实现的,也就是说,一旦服务部署到生产环境中,就涉及到部署、扩展和监控服务的各个方面。
DevOps 模型与其他敏捷模型并行发展,并受到了精益开发模型提出的原则的强烈影响。虽然没有推荐实施 DevOps 的方法(这也是谷歌最初提出 SRE 的原因之一),但 DevOps 倡导者往往倾向于两种不同的模型:
-
文化、自动化、测量和共享(CAMS)
-
三种方法模型
CAMS 模型
CAMS 最初是由达蒙·爱德华兹和约翰·威利斯发明的。让我们更详细地探讨一下这些术语。
与其他敏捷模型一样,企业文化是 DevOps 方法论的有机组成部分。为此,爱德华兹和威利斯建议工程团队扩展使用诸如 Scrum 和 Kanban 等实践来管理开发和运维。在文化方面,爱德华兹和威利斯提供的一条极其重要的建议是,每家公司都必须内部发展适合其独特需求的文化和价值观体系,而不是简单地从其他组织中复制它们,因为它们似乎在特定环境中运作得很好。后者可能导致所谓的货船文化效应,最终创造出一个有毒的工作环境,这可能导致员工留存问题。
CAMS 模型的第二个原则是自动化。正如我们在前面的部分所讨论的,自动化主要是关于在执行乏味、重复的任务时消除潜在的人为错误来源。在 DevOps 的背景下,这通常通过以下方式实现:
-
部署 CI/CD 系统以确保所有更改在推送到生产之前都经过彻底测试
-
将基础设施视为代码并按此方式管理,即将其存储在版本控制系统(VCS)中,让工程师审查和审计基础设施变更,最后通过 Chef (
www.chef.io/
)、puppet (puppet.com/
)、Ansible (www.ansible.com/
)和 Terraform (www.terraform.io/
)等工具进行部署。
CAMS 模型中的字母M代表测量。能够不仅捕捉服务操作指标,还能对其采取行动,为工程团队提供了两个显著的优势。首先,团队可以始终了解他们所管理的服务的健康状况。当服务出现异常时,指标监控系统将触发警报,并且通常会有团队成员被通知。在这种情况下,拥有丰富的指标集可以让团队快速评估情况并尝试解决问题。
当然,监控并不是测量的唯一用例:由 DevOps 团队管理的服务在大多数情况下都是长期存在的,因此随着时间的推移必然会发展和扩展;从逻辑上讲,团队将需要改进和优化他们所管理的服务。高级性能指标有助于识别需要扩展的高负载服务,而低级性能指标将指示需要优化的缓慢代码路径。在这两种情况下,测量都可以用作反馈循环,帮助团队决定下一步的工作内容。
CAMS 模型中的最后一个字母代表共享。关键思想如下:
-
为了在整个组织中提高可见性
-
鼓励和促进团队间的知识共享
可见性对于所有利益相关者都至关重要。首先,它使组织中的所有成员都能持续了解其他团队目前正在做什么。其次,它为工程师提供了一个清晰的视角,了解每个团队的进展如何有助于组织的长期战略目标。实现这一目标的一种方法是将团队的 Kanban 板对组织中的其他团队开放。
模型发明者鼓励团队对其内部流程保持透明。通过允许信息在团队间自由流动,可以防止信息孤岛的形成。例如,高级团队最终会发展出自己的一套简化部署流程。通过将这一知识提供给其他不那么高级的团队,他们可以直接利用经验更丰富的团队的学习成果,而无需重新发明轮子。除此之外,团队通常会使用一套内部仪表板来监控他们管理的服务。将这些仪表板公开给其他团队,特别是那些作为这些服务上游消费者的团队,无疑是有益的。
在这一点上,重要的是要注意,在许多情况下,透明度超出了公司的界限。许多公司通过设置状态页面,将它们操作指标的一部分提供给客户,而其他公司则更进一步,发布详细的故障后分析。
三种方式模型
三种方式模型基于 Gene Kim、Kevin Behr、George Spafford^([8])和其他精益思想家如 Michael Orzen^([9])的想法。该模型将 DevOps 的概念提炼为三个主要原则,或方式:
-
系统思维和工作流程优化
-
放大反馈循环
-
持续实验和学习的文化
系统思维意味着开发团队对软件采取整体方法:除了解决软件开发问题外,团队还负责操作/管理软件部署到的系统,并建立目标系统的行为基准,以及依赖它的其他系统的预期行为基准:
图 5:将开发视为一个端到端系统,其中工作从业务流向客户/最终用户
上述图表将这种方法表示为工程团队执行的单向步骤序列,以向最终用户或客户交付一个工作的功能,而不会对现有服务造成任何干扰。在这一阶段,团队的主要焦点是通过识别和消除任何阻碍工作流程在各个步骤之间流动的瓶颈来优化端到端交付过程。
在第一原则下,团队试图减少流向下游的缺陷数量。然而,缺陷偶尔还是会漏网。这就是第二原则发挥作用的地方。它引入了反馈循环,使得信息能够反向流动,如图所示,即从右向左。然而,仅仅反馈循环是不够的;它们还必须作为放大点,确保团队成员能够及时地对传入的信息采取行动。例如,一个传入的警报(反馈循环)将触发团队中值班的人被呼叫(放大),以便解决影响生产的某个问题:
图 6:利用反馈循环允许信息反向流动
最后一个原则,也是大多数敏捷模型所蕴含的原则,与培养一种公司文化有关,这种文化允许人们追求可能或可能不会最终成功的实验和改进想法,只要他们与同事分享他们所学的知识。同样的心态也适用于处理对生产系统产生不利影响的意外事件。例如,通过举行无责后事分析,团队成员可以概述停电的根本原因,而不会对导致停电的同事施加压力,同时,传播解决该问题所获得的一系列步骤和知识。
摘要
在本章的讨论过程中,我们简要介绍了在与其他各种规模的公司合作时可能遇到的不同类型角色,以及每个角色所依赖的特殊技能集。
我们首先检查了一系列流行的模型、方法和框架,这些模型和框架用于交付软件,从主张自上而下方法的传统模型(瀑布、迭代增强)到更适合当代组织快速变化环境的敏捷模型。
到达本章的结尾,你应该已经熟悉了每种模型的优缺点以及每种模型应该应用的情况。我真诚地希望这些知识将在你需要决定为下一个项目选择哪种软件开发模型时派上用场。
最后但同样重要的是,我们应该始终牢记,软件工程过程的基础是实际生产高质量的软件!在下一章中,我们将戴上工程师的帽子,讨论使用 Go 编写干净、有组织且易于维护的代码的方法。
问题
-
软件工程的定义是什么?
-
每个软件工程师都应该能够回答哪些问题?
-
比较 SWE 和 SRE 的角色。这两个角色之间有哪些关键区别?
-
列举瀑布模型的一些缺陷。解释迭代增强模型如何试图解决这些缺陷。
-
根据精益开发模型,最常见的浪费来源是什么?
-
提供一个例子,说明将所有优化努力集中在开发过程的单个步骤上可能会对端到端过程的效率产生负面影响。
-
在 Scrum 框架中,产品负责人(PO)和敏捷大师(SM)的关键职责是什么?
-
在 Scrum 中,回顾的作用是什么?团队应该讨论哪些主题,以及每个回顾会议的预期结果是什么?
-
在遵循 DevOps 模型时,为什么自动化和度量很重要?
-
你在 ACME 游戏系统公司工作,该公司愿景是颠覆已经成熟和高度竞争的游戏机市场。为此,公司已与知名图形芯片制造商高级 GPU 设备公司合作,原型设计一种新的 GPU,该设计应该使即将推出的游戏机在竞争对手的游戏机上脱颖而出。作为项目的首席工程师,你的任务是设计和构建将驱动新控制台软件。你会选择哪种软件开发模型?解释你做出决定的原因。
进一步阅读
-
安德森,大卫:看板:在您的技术业务中实现成功的进化变革:蓝洞出版社,2010 — ISBN 0984521402 (
www.worldcat.org/title/kanban-successful-evolutionary-change-in-your-technology-business/oclc/693773272
). -
巴西利,R.;特纳,J.:迭代增强:软件开发的一种实用技术。在:IEEE 软件工程 Transactions 第 1 卷 (1975),第 390–396 页。
-
贝克,肯特;比德尔,迈克;本内库姆,阿 rie van;科克本,阿利斯泰尔;坎宁安,沃德;福勒,马丁;格林宁,詹姆斯;海史密斯,吉姆;等:敏捷软件开发宣言。
-
比耶,贝齐;琼斯,克里斯;佩托夫,詹妮弗;墨菲,尼尔·理查德:站点可靠性工程:谷歌如何运行生产系统。(
landing.google.com/sre/sre-book/toc/index.html
) (www.worldcat.org/title/site-reliability-engineering/oclc/1112558638
). -
博伊姆,B:软件开发与增强的螺旋模型。在:SIGSOFT 软件工程笔记 第 11 卷。纽约,纽约,美国,ACM (1986),第 4 期,第 14–24 页。
-
康斯坦丁,L.:超越混沌:在管理软件开发中的专家优势:Addison-Wesley 专业出版社,2001 — ISBN 9780201719604 (
www.worldcat.org/title/beyond-chaos-the-expert-edge-in-managing-software-development/oclc/46713128
). -
IEEE:IEEE 软件工程术语标准词汇表:IEEE;IEEE,1990 年。
-
Kim, G.; Behr, K.; Spafford, G.: 《凤凰项目:关于 IT、DevOps 和帮助您的企业获胜的小说》:IT 革命出版社,2018 — ISBN 9781942788294 (
www.worldcat.org/title/phoenix-project-a-novel-about-it-devops-and-helping-your-business-win/oclc/1035062278
). -
Orzen, M. A.; Paider, T. A.: 《精益 IT 领域指南:您的转型路线图》:泰勒弗朗西斯出版社,2015 — ISBN 9781498730389 (
www.worldcat.org/title/lean-it-field-guide/oclc/1019734287
). -
Poppendieck, Mary; Poppendieck, Tom: 《精益软件开发:敏捷工具箱》。波士顿,马萨诸塞州,美国:阿迪生-韦斯利朗曼出版社,2003 — ISBN 0321150783 (
www.worldcat.org/title/lean-software-development-an-agile-toolkit/oclc/868260760
)。 -
Royce, W.: 《管理大型软件开发:概念和技术》。在:IEEE WESTCON 进程,洛杉矶(1970 年),第 1–9 页。— 在 1987 年 3 月第九届国际软件工程会议的《论文集》中重印,第 328–338 页。
-
Schwaber, Ken; Sutherland, Jeff: 《敏捷指南》(2014 年)。
-
Turhan, Burak; Layman, Lucak; Diep, Madeline; Shull, Forrst; Erdogmus, Hakan: 《测试驱动开发的有效性**?》 在:Wilson, G.; Orham, A. (出版社):《制作软件:真正有效的原因以及我们为什么相信它》:ISBN 978-0596808327 (
www.worldcat.org/title/making-software-what-really-works-and-why-we-believe-it/oclc/836148043
),第 207–219 页。
第二部分:可维护和可测试的 Go 代码的最佳实践
第二部分的目标是让您了解行业最佳实践,以便生产出清晰、可测试的代码,简化维护并防止技术债务的积累。
本节包括以下章节:
-
第二章,编写清晰且可维护的 Go 代码的最佳实践
-
第三章,依赖管理
-
第四章,测试的艺术
第二章:编写干净且易于维护的 Go 代码的最佳实践
"任何傻瓜都能写出计算机能理解的代码。优秀的程序员写出人类能理解的代码。"
- Martin Fowler ^([8])
编写易于测试和维护的干净代码比乍看之下要困难得多。幸运的是,作为编程语言,Go 非常具有意见性,并自带一套最佳实践。
如果你查看一些学习 Go 的可用材料(例如,Effective Go ^([6]))或观看核心 Go 团队杰出成员(如 Rob Pike)的演讲,就会很明显,软件工程师在处理自己的 Go 项目时会被温和地 引导 应用这些原则。从我的视角和经验来看,这些最佳实践往往对代码质量指标有可衡量的积极影响,同时也有助于最小化技术债务的积累。
在本章中,我们将涵盖以下主题:
-
通过 Go 工程师的眼镜理解面向对象设计的 SOLID 原则
-
在包级别组织源代码
-
在 Go 中编写精简且易于维护的代码的有用技巧和工具
面向对象设计的 SOLID 原则
SOLID 原则本质上是一套规则,旨在帮助你编写干净且易于维护的面向对象代码。让我们回顾一下这些首字母代表什么:
-
单一职责
-
开闭原则
-
里氏替换原则
-
接口隔离原则
-
依赖倒置原则
但等等!Go 是面向对象的编程语言,还是一种带有一些语法糖的功能性编程语言?
与其他传统面向对象编程语言(如 C++ 或 Java)不同,Go 没有内置对类的支持。然而,它 确实 支持接口和结构体的概念。结构体允许你将对象定义为字段的集合和相关方法。尽管对象和接口可以组合在一起,但根据设计,没有对经典面向对象继承的支持。
考虑到这些观察结果,我们应该将 Go 作为一个 基于对象 的编程语言来引用,并且因此,以下原则仍然有效。让我们从 Go 软件工程师的角度更详细地审视每个原则。
单一职责
单一职责原则(SRP)是由 Robert Martin ^([23])描述的,他是一位经验丰富的软件工程师,以 Uncle Bob 的昵称提供关于软件开发最佳实践的指导。SRP 表述如下:
"在任何设计良好的系统中,对象应该只具有单一职责。"
简而言之,对象实现应该专注于做好一件事情,并且以高效的方式进行。为了理解这个原则是如何工作的,让我们检查一段违反该原则的代码。在以下虚构的场景中,我们为 ACME 无人机公司工作,我们正在使用 Go 构建基于无人机的货物配送系统。
以下代码片段展示了我们为Drone
类型定义一组方法的一个初始尝试:
// NavigateTo applies any required changes to the drone's speed
// vector so that its eventual position matches dst.
func (d *Drone) NavigateTo(dst Vec3) error { //... }
// Position returns the current drone position vector.
func (d *Drone) Position() Vec3 { //... }
// Position returns the current drone speed vector.
func (d *Drone) Speed() Vec3 { //... }
// DetectTargets captures an image of the drone's field of view (FoV) using
// the on-board camera and feeds it to a pre-trained SSD MobileNet V1 neural
// network to detect and classify interesting nearby targets. For more info
// on this model see:
// https://github.com/tensorflow/models/tree/master/research/object_detection
func (d *Drone) DetectTargets() ([]*Target, error) { //... }
上述代码通过混淆两个不同的责任违反了 SRP:
-
驾驶无人机
-
在无人机附近检测目标
在某些情况下,这是一个有效、可行的解决方案。然而,引入的额外耦合使得实现更难维护和扩展。例如,如果我们想评估不同的神经网络模型进行物体识别,怎么办?如果我们想将相同的物体识别代码用于不同的Drone
类型,怎么办?
那么,我们如何应用单一职责原则(SRP)来改进我们的设计呢?首先,在假设所有无人机都配备有摄像头的情况下,我们可以在Drone
对象上公开一个方法来捕获并返回使用摄像头拍摄的照片。此时,你可能正在想:等等,图像捕获难道不是与导航不同的责任吗?答案是:这完全取决于视角!描述和分配对象的责任本身就是一门艺术,而且相当主观。相反,我们也可以反驳说,导航需要访问各种传感器数据源,而摄像头就是其中之一。从这个意义上说,所提出的重构并没有违反 SRP。
在第二个重构步骤中,我们可以将目标检测代码提取到一个独立的、独立的对象中,这样我们就可以在不修改Drone
类型中的任何代码的情况下继续进行对象识别模型评估。实现的第二次迭代可能看起来像这样:
// NavigateTo applies any required changes to the drone's speed vector
// so that its eventual position matches dst.
func (d *Drone) NavigateTo(dst Vec3) error { //... }
// Position returns the current drone position vector.
func (d *Drone) Position() Vec3 { //... }
// Position returns the current drone speed vector.
func (d *Drone) Speed() Vec3 { //... }
// CaptureImage records and returns an image of the drone's field of
// view using the on-board drone camera.
func (d *Drone) CaptureImage() (*image.RGBA, error) { //... }
在一个单独的文件中(可能也在不同的包中),我们将定义MobileNet
类型,其中包含我们的目标检测器的实现:
// MobileNet performs target detection for drones using the
// SSD MobileNet V1 NN.
// For more info on this model see:
// https://github.com/tensorflow/models/tree/master/research/object_detection
type MobileNet {
// various attributes...
}
// DetectTargets captures an image of the drone's field of view and feeds
// it to a neural network to detect and classify interesting nearby
// targets.
func (mn *MobileNet) DetectTargets(d *drone.Drone) ([]*Target, error){
//...
}
成功!我们已经将原始实现拆分成了两个独立的对象,每个对象都只有一个单一的责任。
开放/封闭原则
开放/封闭原则是由伯特兰·迈耶提出的^([24]),他提出了以下观点:
“一个软件模块应该对扩展开放,但对修改封闭。”
几乎所有的 Go 程序都会导入并使用来自众多其他包的类型,其中一些是 Go 标准库的一部分,而其他包则由第三方提供。任何将包导入其代码库的软件工程师都应该始终安全地假设该包导出的所有类型都遵循一个保证不可变的契约。换句话说,一个包不应该能够修改由 其他 包导出的类型的行怍。虽然一些编程语言允许这种修改(通过一种俗称为 猴子补丁 的技术),但 Go 设计者已经设置了安全机制来确保这种类型的修改是严格禁止的。否则,Go 程序将能够违反 封闭 原则,对部署到生产环境的代码产生不可预见的后果。
到目前为止,你可能想知道:封闭原则也适用于包 内部 的代码吗?此外,Go 是如何实现 开放 原则的?根据 Meyer 的定义,我们应该能够使用面向对象的原则,如继承或组合,以扩展现有代码并添加额外的功能,而无需修改原始代码单元。正如我们在本章开头讨论的那样,Go 不支持继承;这留下了 组合 作为扩展现有代码的唯一可行方法。
让我们通过一个简单的例子来考察这些原则是如何交织在一起的。在短暂的无人机设计经历之后,我们决定转换行业,专注于构建角色扮演游戏。在下面的代码中,你可以看到为即将推出的游戏定义的泛型 Sword
类型:
type Sword struct {
name string // Important tip for RPG players: always name your swords!
}
// Damage returns the damage dealt by this sword.
func (Sword) Damage() int {
return 2
}
// String implements fmt.Stringer for the Sword type.
func (s Sword) String() string {
return fmt.Sprintf(
"%s is a sword that can deal %d points of damage to opponents",
s.name, s.Damage(),
)
}
我们的设计要求之一是我们需要支持魔法物品,例如,一把附魔剑。由于我们的附魔剑仅仅是一个通用的剑,造成不同的伤害量,我们将应用 开放 原则,并使用 组合 来创建一个新的类型,该类型嵌入 Sword
类型并覆盖 Damage
方法的实现:
type EnchantedSword struct {
// Embed the Sword type
Sword
}
// Damage returns the damage dealt by the enchanted sword.
func (EnchantedSword) Damage() int {
return 42
}
然而,如果没有编写一些基于表格的测试,我们的实现就不可能完整!我们将创建的第一个测试函数被命名为 TestSwordDamage
,根据其名称,你可以猜到其目的是检查调用我们迄今为止定义的类型上的 Damage
是否产生预期的结果。以下是我们将如何以表格驱动的方式定义我们的期望:
specs := []struct {
sword interface {
Damage() int
}
exp int
}{
{
sword: Sword{name: "Silver Saber"},
exp: 2,
},
{
sword: EnchantedSword{Sword{name: "Dragon's Greatsword"}},
exp: 42,
},
}
TestSwordDamage
的实现只是遍历定义的期望并验证每个期望是否得到满足:
func TestSwordDamage(t *testing.T) {
specs := ... // see above code snippet for the spec definitions
for specIndex, spec := range specs {
if got := spec.sword.Damage(); got != spec.exp {
t.Errorf("[spec %d] expected to get damage %d; got %d", specIndex, spec.exp, got)
}
}
}
我们的第二个测试附带其自己的期望列表。这次的目标是确保我们之前定义的类型上的 String
方法的输出是正确的:
specs := []struct {
sword fmt.Stringer
exp string
}{
{
sword: Sword{name: "Silver Saber"},
exp: "Silver Saber is a sword that can deal 2 points of damage to opponents",
},
{
sword: EnchantedSword{Sword{name: "Dragon's Greatsword"}},
exp: "Dragon's Greatsword is a sword that can deal 42 points of
damage to opponents",
},
}
这是 TestSwordToString
的实现,它与 TestSwordDamage
几乎相同;这里没有惊喜:
func TestSwordToString(t *testing.T) {
specs := ... // see above code snippet for the spec definitions
for specIndex, spec := range specs {
if got := spec.sword.String(); got != spec.exp {
t.Errorf("[spec %d] expected to get\n%q\ngot:\n%q",
specIndex, spec.exp, got)
}
}
}
现在,我们可以运行go test
。然而,我们的测试中有一个失败了:
$ go test -v
=== RUN TestSwordDamage
--- PASS: TestSwordDamage (0.00s)
=== RUN TestSwordToString
--- FAIL: TestSwordToString (0.00s)
sword_test.go:55: [spec 1] expected to get
"Dragon's Greatsword is a sword that can deal 42 points of
damage to opponents"
got:
"Dragon's Greatsword is a sword that can deal 2 points of
damage to opponents"
那么,是什么导致了第二个测试失败?为了揭示失败测试背后的原因,我们需要深入挖掘 Go 方法在底层是如何工作的。Go 方法不过是调用一个以对象实例作为参数的函数(也称为接收器)的语法糖。在前面的代码片段中,String
总是以“剑”接收器调用,因此对Damage
方法的调用总是被调度到由“剑”类型定义的实现。
这是一个封闭原则在行动中的典型例子:“剑”不知道任何可能包含它的类型,并且它的方法集不能被它所嵌入的对象所改变。重要的是要指出,尽管“魔法剑”类型不能修改在嵌入的“剑”实例上定义的方法的实现,但它仍然可以访问和修改它定义的任何字段(如果两种类型都在同一个包中定义,包括私有字段)。
里氏替换
我们将要探索的 SOLID 的第三个原则是里氏替换原则(LSP)。它是由 Barbara Liskov 在 1987 年在面向对象编程系统、语言和应用(OOPSLA)会议上的主题演讲中提出的。LSP 的正式定义如下:
如果对于每个类型为S
的对象O1
,存在一个类型为T
的对象O2
,并且对于所有以T
定义的程序P
,当用O1
替换O2
时,程序P
的行为保持不变,那么S
是T
的子类型。
用通俗的话来说,如果两种类型的展现行为完全遵循相同的契约,那么它们是可替换的,因此调用者无法区分它们。从纯面向对象的角度思考,这可能是抽象类和具体类的典型用例。正如我们在前面的部分提到的,Go 不支持类或继承的概念,而是依靠接口作为促进类型替换的手段。
Go 语言的一个有趣特性,至少对于来自 Java 或 C++ 背景的人来说是这样,就是 Go 接口是隐式的。每个 Go 类型定义了一个隐式的接口,该接口由它实现的所有方法组成。这个设计决策允许 Go 编译器在决定一个对象实例是否可以作为函数或方法参数的替代品时,执行一个编译时的鸭子类型变体(这个术语的正式名称是结构化类型)。
鸭子类型这个术语的根源在于一个古老的谚语,被称为鸭子测试:
“如果它看起来像一只鸭子,并且它像鸭子一样嘎嘎叫,那么它就是一只鸭子。”
实质上,当给定一个对象和一个接口时,如果该对象的方法集包含与接口定义的方法名称和签名匹配的方法,则该对象可以用作接口的替代。无需显式指出类型实现了哪些接口是一个非常方便的特性。它帮助我们解耦对象的定义(可能是外部或第三方包)与接口定义和/或使用的地方。
在以下代码片段中,我们定义了Adder
接口和一个简单的名为PrintSum
的函数,该函数使用满足此接口的任何类型将两个数字相加:
package main
import "fmt"
// Adder is implemented by objects that can add two integers together.
type Adder interface {
Add(int, int) int
}
func PrintSum(a, b int, adder Adder) {
fmt.Printf("%d + %d = %d", a, b, adder.Add(a, b))
}
adder
包包括Int
类型,它满足Adder
接口,还有一个名为Double
的类型,它不满足;尽管它定义了一个名为Add
的函数,但你将注意到参数类型是不同的:
package adder
// Int adds two integer values.
type Int struct{}
// Add returns the sum a+b.
func (Int) Add(a, b int) int { return a + b }
// Double adds two double values.
type Double struct{}
// Add returns the sum a+b.
func (Double) Add(a, b float64) float64 { return a + b }
以下代码片段说明了编译时接口替换检查是如何工作的。我们可以安全地将Int
实例传递给PrintSum
,因为Int
隐式满足Adder
接口。然而,尝试将Double
实例传递给PrintSum
将触发编译时错误:
package main
import "github.com/foo/adder"
func main() {
PrintSum(1, 2, adder.Int{}) // prints: "1 + 2 = 3"
// This line will trigger a compile-time error:
// cannot use adder.Double literal (type adder.Double) as type Adder
// in argument to PrintSum: adder.Double does not implement Adder
// (wrong type for Add method)
// have Add(float64, float64) float64
// want Add(int, int) int
PrintSum(1, 2, adder.Double{})
}
在对象要替换的类型在编译时未知的情况下,编译器将自动生成代码以在运行时执行检查:
var placeholder interface{}
// Cast to io.Reader works; os.Stdin implements io.Reader
placeholder = os.Stdin
_ = placeholder.(io.Reader)
// Cast to io.Reader triggers a run-time panic:
// "panic: interface conversion: string is not io.Reader: missing method Read"
placeholder = "cast check"
_ = placeholder.(io.Reader)
// Cast to io.Reader fails and isReader is set to false
placeholder = "cast check"
if _, isReader := placeholder.(io.Reader); !isReader {
fmt.Printf("%T does not implement io.Reader\n", placeholder)
}
当你不确定类型实例或interface{}
在运行时是否可以转换为另一个类型或接口时,通常是一个好习惯使用双重返回值的类型转换操作符(前述代码样本中的最后一个情况)来避免程序执行时的潜在恐慌。
接口隔离
与 SRP 类似,接口隔离原则(ISP)也是由罗伯特·马丁提出的。根据这个原则,客户端不应该被迫依赖于它们不使用的接口。
这个原则非常重要,因为它构成了应用我们之前讨论的其他原则的基础。回到我们之前的 RPG 示例,假设我们已经给我们的Sword
对象添加了一些更有趣的方法:
// Sharpen increases the damage dealt by this sword using a whetstone.
func (Sword) Sharpen() {
//...
}
// MakeBlunt decreases the damage dealt by this sword due to constant use.
func (Sword) MakeBlunt(){
//...
}
// Drop places the sword on the ground allowing others to pick it up.
func (Sword) Drop(){
//...
}
那么,我们如何在游戏中使用武器呢?显然,我们需要引入一些怪物供玩家攻击!这可能是一个Attack
函数的潜在签名:
// Attack deals damage to a monster using a sword.
func Attack(m *Monster, s *Sword) {
//...
}
然而,前述定义存在一些问题。
隐式(参见上一节)的Sword
接口非常开放,也就是说,它包含了一堆我们的Attack
实现不需要的其他方法。实际上,实现Attack
的软件工程师可能会倾向于包括一些额外的业务逻辑规则,这些规则依赖于那些方法的可用性:
-
在攻击了一定次数后使剑变钝
-
如果怪物使用一些特殊装甲,导致玩家丢弃剑
沿着这条道路走下去将违反 SRP,并可能使代码的单元测试变得更加困难。此外,提出的Attack
定义与Sword
类型或嵌入它的其他类型的对象产生了强烈的耦合。
这两个观察结果完全证明了以下著名的 Go 谚语的合理性,该谚语最初归功于 Rob Pike:
"接口越大,抽象越弱。"
虽然 Go 的隐式接口(见上一节)允许我们传递任何嵌入Sword
(例如,可能是我们之前示例中的EnchantedSword
)的类型,但我们的要求无疑会声明Attack
必须能够与其他类型的武器一起工作,例如,投射物或魔法咒语。
另一方面,Attack
期望其第一个参数是一个Monster
实例。从逻辑上讲,玩家应该能够使用武器对非怪物实体造成伤害,例如,破坏螺栓固定的门或切断悬挂在天花板上的吊灯的绳子。此外,理想情况下,我们希望当怪物攻击玩家时能够重用相同的实现。
这些都是应用 ISP 的绝佳用例。假设我们的Attack
实现只需要以下内容:
-
为了确定武器造成的伤害量
-
一种将伤害应用于特定目标的机制
基于上述观察,我们可以将Attack
函数的签名更改为接受两个显式接口作为参数:
// DamageReceiver is implemented by objects that can receive weapon damage.
type DamageReceiver interface {
ApplyDamage(int)
}
// Damager is implemented by objects that can be used as weapons.
type Damager interface {
Damage(int)
}
// Attack deals weapon damage to target.
func Attack(target DamageReceiver, weapon Damager) {
//...
}
通过这个相当简单的改动,我们一举两得。首先,我们的代码更加抽象,通过提供我们自己的实现所需接口的测试类型,可以更容易地测试其行为。其次,我们的接口实际上是最小的;这一事实不仅使得 SRP 的应用成为可能,而且也暗示了一个更简单的实现。正如对 Go 标准库的快速浏览所证实的,单方法接口(例如,io
包中的Reader
和Writer
接口)是 Go 作者之间相当普遍的惯用法。
依赖倒置
另一个由罗伯特·马丁(Robert Martin)提出的原理是依赖倒置原则(DIP)。它稍微有点冗长,定义如下:
"高级模块不应依赖于低级模块。两者都应依赖于抽象。抽象不应依赖于细节。细节应依赖于抽象。"
DIP 实质上总结了我们迄今为止讨论的所有其他原则。如果你已经将 SOLID 原则的其余部分应用于你的代码库,你会发现它已经符合前面的定义!
接口的引入和使用有助于解耦高级和低级模块。开放/封闭原则确保接口本身是不可变的,但这并不妨碍我们提出任何数量的替代实现(前述定义中的细节部分),以满足隐式或显式的接口。同时,LSP 保证我们可以在依赖既定抽象的同时,也拥有在编译时甚至运行时灵活替换底层实现的能力,而不用担心破坏我们的应用程序。
应用 SOLID 原则
如果你决定将这些原则应用到自己的项目中,你将在设计、连接和测试软件组件的方式上获得更大的灵活性,并且在未来扩展代码库时所需的时间会更少。
然而,有一点需要记住的是,没有免费的午餐。你在灵活性方面获得的收益,你会在代码库的增大中失去;这可能会对项目的复杂度指标产生不利影响。
在我看来,这种权衡并不一定是坏事。通过遵循测试代码的最佳实践(将在后续章节中详细探讨),你可以驯服任何潜在的代码复杂度增加。同时,在编写测试时遇到困难通常是代码可能违反一个或多个 SOLID 原则并需要重构的好迹象。
最后,我想强调的是,尽管我们是通过 Go 工程师的视角来分析 SOLID 原则,但原则本身具有更广泛的适用范围,也可以应用于系统设计。例如,在基于微服务的部署中,你应该旨在构建和部署具有单一目的(SRP)的服务,并通过明确定义的合同和边界进行通信(ISP)。
将代码组织成包
如前节所述,应用 SOLID 原则可以作为指导,将我们的代码库拆分成更小的包,其中每个包实现特定的功能,其接口在构建更大系统时作为连接包的粘合剂。
在本节中,我们将探讨命名包的 Go 语言惯用方法,以及你在编写依赖于复杂包依赖图的代码时可能遇到的一些常见潜在陷阱。
Go 包的命名约定
假设你遇到一个名为 server
的包。根据前面的建议,这个名字好吗?嗯,显然,我们可以猜测它是一种服务器,但那是什么类型的服务器呢?是 HTTP 服务器,比如基于 TCP 实现基于文本的线协议的服务器,或者可能是一个基于 UDP 的在线游戏服务器?有些人可能会争论,这个包可能导出一种类型或函数,暗示了包的目的(例如,NewHTTPServer
)。这确实消除了歧义,但也引入了一点重复:在这个特定的情况下,server 文字既出现在包名中,也出现在它暴露的函数中。正如我们将在 使用 linter 提高代码质量指标 部分中看到的那样,这种做法被认为是一种反模式,可能会导致 linter 警告。
Go 包名应简短、简洁,并为包的 预期 用户提供对其用途的明确指示。
通过浏览 Go 标准库的代码,我们可以找到许多这种清晰包命名哲学的特征性例子:
-
net
包提供了创建各种类型网络监听器的机制(TCP、UDP、Unix 域套接字等)。 -
net/http
包提供了许多功能,其中包括 HTTP 服务器实现:http.Server
类型名称在用途上相当明确。
尽管应该保持包名简短,但你应避免提出可能与其他代码中常用变量名冲突的包名。否则,包用户将不得不使用别名(即导入 blah path-to-package)来导入包。在这种情况下,通常最好(如果可能的话)缩写包名。Go 标准库中的典型例子包括 fmt
和 bufio
包。更具体地说,bufio
包之所以命名为如此,是为了避免与 buf
这个变量名发生冲突,你很可能在处理使用缓冲区的代码时会遇到这个变量名。
最后,与其他标准库通常附带实用库或具有通用名称(如 common 或 util)的包的编程语言相比,Go 对这种做法持 反对 的态度。这实际上是从 SOLID 原则的角度来看是有道理的,因为这些包更有可能违反 SRP,而恰当地命名的包则通过其名称强制内容具有逻辑边界。此外,随着发布的 Go 包数量随着时间的推移而增长,搜索和定位具有通用名称的包将变得越来越困难。
循环依赖
要使 Go 程序结构良好,其导入图必须是无环的;换句话说,它不能包含任何循环。任何违反此谓词的行为都会导致 Go 编译器发出错误。随着你构建的系统复杂性增加,最终遇到令人讨厌的“检测到导入循环”错误的概率也会增加。
通常,导入循环是软件解决方案设计中存在缺陷的迹象。幸运的是,在许多情况下,我们可以重构我们的代码,并绕过大多数导入循环。让我们更仔细地看看循环依赖通常发生的一些常见情况以及处理它们的策略。
通过隐式接口打破循环依赖
在以下虚构的场景中,我们为一家初创公司工作,该公司正在构建负责控制全自动仓库的软件。配备抓取臂和激光的自主机器人(可能出什么问题?)正忙于在仓库地板上移动,定位并从货架上取订单物品,并将它们放入随后被运送给客户的纸箱中。
这是仓库 Robot
的一个临时定义:
package warehouse
import "context"
// Robot navigates the warehouse floor and fetches items for packing.
type Robot struct {
// various fields
}
// AcquireRobot blocks until a Robot becomes available or until the
// context expires.
func AcquireRobot(ctx context.Context) *Robot { //... }
// Pack instructs the robot to pick up an item from its shelf and place
// it into a box that will be shipped to the customer.
func (r *Robot) Pack(item *entity.Item, to *entity.Box) error { //... }
在前面的代码片段中,Item
和 Box
类型位于一个名为 entity
的外部包中。一切顺利,直到有一天有人试图向 Box
类型引入一个新的辅助方法,不幸的是,这引入了一个导入循环:
package entity
// Box contains a list of items that are shipped to the customer.
type Box struct {
// various fields
}
// Pack qty items of type i into the box.
func (b *Box) Pack(i *Item, qty int) error {
robot := warehouse.Acquire() // **compile error: import cycle detected**
// ...
}
从技术上来说,这是一个糟糕的设计决策:箱子和物品实际上不应该知道机器人的存在。然而,为了这个论点,我们将忽略这个设计缺陷,并尝试使用 Go 对隐式接口的支持来解决这个问题。第一步是在 entity
包内定义一个 Packer
接口。其次,我们需要提供一个获取 Packer
实例的抽象,如下面的代码片段所示:
package entity
import "context"
// Packer is implemented by objects that can pack an Item into a Box.
type Packer interface {
Pack(*Item, *Box) error
}
// AcquirePacker returns a Packer instance.
var AcquirePacker func(context.Context) Packer
在这两个机制到位的情况下,辅助方法可以在不需要导入 warehouse
包的情况下工作:无需 导入 warehouse
包。
// Pack qty items of type i into the box.
func (b *Box) Pack(i *Item, qty int) error {
p := AcquirePacker(context.Background())
for j := 0; j < qty; j++ {
if err := p.Pack(i, b); err != nil {
return err
}
}
return nil
}
我们需要解决的最后一个难题是如何在不导入 warehouse
包的情况下初始化 AcquirePacker
。我们唯一能这样做的方式是通过一个导入 warehouse
和 entity
包的 第三个 包:
package main
import "github.com/achilleasa/logistics/entity"
import "github.com/achilleasa/logistics/warehouse"
func wireComponents() {
entity.AcquirePacker = func(ctx context.Context) entity.Packer {
return warehouse.AcquireRobot(ctx)
}
}
在前面的代码片段中,wireComponents
函数确保 warehouse
和 entity
包被连接在一起,而不会触发任何循环依赖错误。
有时候,代码重复并不是一个坏主意!
你可能之前听说过 不要重复自己 (DRY)原则。DRY 的主要思想是通过编写可重用的代码来避免代码重复,这些代码可以在需要的地方包含。但 DRY 是否 总是 一个好主意?
Go 包作为组织代码到模块化和可重用单元的不错抽象。但,一般来说,编写 Go 程序的良好实践是尽量保持你的导入依赖图浅而宽;考虑到这可能是 DRY 原则所倡导的“包含而非重复”的相反,这听起来可能有些反直觉。
当依赖图变得更深时,循环依赖的可能性也会增加,这次是由于传递性依赖,即你代码导入的包的依赖。在以下示例中,我们有三个包:x
、y
和z
。
包y
定义了一个名为IsPrime
的辅助函数,正如你可能从其名称中猜测到的,它返回一个布尔值,指示其输入是否为素数。同一个包导入并使用来自包z
的一些类型:
package y
import "z"
func IsPrime(v uint64) bool {
// ...
}
// Other functions referencing types exported from package z
包z
从包x
导入了一些类型:
package z
import "x"
// functions referencing types exported from package x
到目前为止,一切顺利。几天后,我们决定向包x
添加一个新的辅助函数,名为IsValid
。该函数需要进行素性测试,由于包y
已经提供了IsPrime
,我们决定遵循 DRY 原则,将y
导入到我们的代码中,从而造成循环依赖:
package x
import "y" // circular dependency: x imports y, y imports z and z imports x
func IsValid(v uint64) bool {
return v != 0 && y.IsPrime(v)
}
在这种情况下,如果我们需要的包含包中的代码足够小,我们可以直接复制它(包括其测试)并避免触发循环依赖的额外导入。正如一句流行的 Go 谚语所说:
“一点复制胜过一点依赖。”
编写精简且易于维护的 Go 代码的技巧和工具
在接下来的章节中,我们将介绍一些技术、工具和最佳实践,这些可以帮助你编写更简洁、更易于测试的代码,同时也能帮助你从同事和代码审查员那里获得一些赞誉。
我们将要讨论的大部分主题都是特定于 Go 语言的,但其中的一些原则可以推广并应用于其他编程语言和软件工程领域。
优化函数实现以提高可读性
在我大学早期,我的计算机科学教授们会坚决主张保持函数块短小精悍。他们的建议如下:
“如果一个函数实现无法适应单个屏幕,那么它必须被拆分成更小的函数。”
请记住,这些指南的根源在于一个时代,那时人们通过“屏幕”来指代能够适应 80×25 字符终端的代码量!快进到今天,情况已经发生了变化:软件工程师可以访问高分辨率的显示器、编辑器和预装了广泛复杂分析和重构工具的定制 IDE。尽管如此,对于编写易于他人审查、扩展和维护的代码,这些建议依然同样重要。
在单一职责部分,我们讨论了 SRP 的优点。不出所料,同样的原则也适用于函数块,这是你在编码时需要牢记在心的事情。
通过将复杂函数分解为更小的函数,代码变得更加易于阅读和推理。这起初可能看起来并不重要,但想想这种情况:你几个月没有接触代码,然后需要重新深入其中,同时试图追踪一个错误。作为额外的好处,独立的逻辑块也更容易进行测试,特别是如果你遵循编写表格驱动测试的实践。
自然地,同样的方法也可以应用于现有代码。如果你发现自己正在导航一个包含深层嵌套的if
/else
块、重复的代码块或其实施处理几个看似不相关的关注点的长函数,那么应用一些即兴重构并提取任何潜在的独立逻辑块到单独的函数中将会是一个极好的机会。
此外,在创建新函数或将现有函数拆分为更小的函数时,一个好的主意是将函数排列得在它们定义的文件中按调用顺序出现,也就是说,如果A()
调用B()
和C()
,那么B()
和C()
都必须出现在下面,但不一定紧接在A()
之后。这使得其他工程师(或只是好奇想了解某物是如何工作的普通人)浏览现有代码变得更加容易。
每条规则都有例外,这条规则也不例外。除非编译器非常擅长内联函数,否则将业务逻辑分散到多个函数中有时会对性能造成影响。尽管在许多情况下性能损失微不足道,但当最终目标是生成包含紧密内循环或预期高频调用的代码时,将实现整齐地封装在单个函数中可能是一个好主意,以避免在调用函数时产生的额外 Go 运行时开销(例如,将参数推送到栈上,检查调用者是否有足够的栈空间,以及函数调用返回时从栈上弹出东西)。
代码的可读性和性能之间总是存在权衡。当处理复杂系统时,可读性通常更受欢迎,但最终,这取决于你和你的团队来确定哪种可读性和性能的混合最适合你的特定用例。
变量命名约定
关于 Go 程序中变量和类型名称的理想长度,存在持续的争论。一方面,有支持所有变量都应该有清晰且自我描述性名称的信仰者。这对于在 Java 生态系统编写过代码的人来说是一种相当常见的哲学。另一方面,我们有简约主义者,即那些主张使用较短标识符名称的人,他们认为较长的标识符太冗长。
Go 语言作者显然属于后一种阵营。以两个最受欢迎的 Go 接口io.Reader
和io.Writer
的定义为例:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
同样的短标识符模式在 Go 标准库代码库中被广泛使用。我认为,只要未来的工程师能够轻松理解它们在各自作用域内的用途,使用较短但仍然描述性的变量名是好事。
这种方法的常见例子是为嵌套循环命名索引变量,通常使用单个字母变量,如i
、j
等。然而,在以下代码片段中,索引变量被用来访问多维切片s
的元素:
for i := 0; i < len(s); i++ {
for j := 0; j < len(s[i]); j++ {
value := s[i][j]
// ...
}
}
如果有人不熟悉这段代码,被分配去审查包含前面循环的拉取请求,他们可能会发现自己难以弄清楚s
是什么以及每个索引级别代表什么!由于缩写的变量名几乎不提供关于它们真正用途的信息,为了回答这些问题,审查者将不得不在代码库中四处寻找线索:查找s
的类型,然后转到其定义,等等。现在,将前面的代码块与以下一个执行完全相同功能的代码块进行对比,它使用了略微更长的变量名。在我看来,第二种方法具有更高的信息量,同时避免了过于冗长:
for dateIdx := 0; dateIdx < len(tickers); dateIdx++ {
for stockIdx := 0; stockIdx < len(tickers[dateIdx]); stockIdx++ {
value := tickers[dateIdx][stockIdx]
// ...
}
}
最后,每个工程师都有自己的首选变量命名方法和哲学。在决定采用哪种方法时,试着花几分钟考虑一下你的变量命名选择如何影响与你共同在共享代码库上协作的其他工程师。
高效使用 Go 接口
"接受接口,返回结构体。"
- 杰克·林达穆德
将代码组织成包背后的关键点是,通过提供一个干净、文档齐全的 API 界面,使代码可重用,并以无摩擦的方式供外部消费者使用。当编写接受具体类型作为参数的函数或方法时,我们对我们实现的有用性施加了一个人为的限制:它只能与特定类型的实例一起工作。
虽然这不一定总是问题,但在某些情况下,要求具体的类型实例可能会使测试变得复杂且缓慢,尤其是如果构建此类实例是一个昂贵的操作。以下摘录是关于一个收集并将性能指标发布到键值存储的系统的一部分。
键值存储的实现如下所示:
package kv
// Store implements a key-value store which stores data to disk.
type Store struct { // ... }
func Open(path string) (*Store, error) { // Open path, load and verify data, replay pending transactions etc. }
// Put persists (key, value) to the store.
func (s *Store) Put(key string, value interface{}) error { // ... }
// Get looks up the value associated with key.
func (s *Store) Get(key string) (interface{}, error) { // ... }
// Close waits for any pending transactions to complete and then
// cleanly shuts down the KV store.
func (s *Store) Close() error { // ... }
在 metrics
包中,我们可以找到 ReportMetrics
函数的定义。它接收一个 kv.Store
实例作为参数,并将收集到的指标持久化到其中:
package metrics
// ReportMetrics writes the collected metrics to a KV store instance.
func (c *Collector) ReportMetrics(s *kv.Store) error {
// for each metric call s.Put(k, v)
}
// Observe records a value for a particular metric.
func (c *Collector) Observe(metric string, value interface{}) {
// ...
}
根据之前关于 SOLID 原则的讨论,你应该已经发现了这段代码的问题:它只与特定的键值存储实现一起工作!如果我们想将指标发布到网络套接字、写入 CSV 文件,或者可能记录到控制台会怎样呢?
然而,这段代码还有一个问题:测试它需要相当多的努力。为了理解原因,让我们设身处地地考虑一下这个包的消费者。作为我们集成测试套件的一部分,我们想要确保所有收集到的指标实际上都写入了键值存储实例。
首先,我们的测试代码必须创建一个键值存储的实例。由于 Open
方法需要一个文件,而我们可能会同时运行多个测试,因此我们需要创建一个临时的唯一文件,并将其作为参数传递给 Open
。当然,我们不应该在测试运行完成后留下临时文件,因此我们需要确保我们的测试会在自己完成后进行清理:
// Generate a random file for the KV store
tmpfile, err := ioutil.TempFile("", "metrics")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.Remove(tmpfile.Name()) }() // clean up when we are
// done
_ = tmpfile.Close()
// Create KV store
s, err := kv.Open(tmpfile.Name())
if err != nil {
t.Fatal(err)
}
defer func() { _ = s.Close() }()
这就带我们来到了测试的核心:创建一个指标收集器,用大量测量值填充它,将捕获的指标报告给键值存储,并验证一切是否已正确写入存储:
c := metrics.NewCollector()
for i := 0; i < 100; i++ {
c.Observe(fmt.Sprintf("metric_%d", i), i)
}
if err = c.ReportMetrics(s); err != nil {
t.Fatal(err)
}
// Ensure that all metrics have been written to the store
// ...
}
这个测试相当直接,但设置它需要相当多的样板代码。此外,kv.Open
看起来是一个相当昂贵的调用;想象一下,如果我们的测试套件由数百个测试组成,每个测试都需要一个真实的 kv.Store
实例,那么涉及的额外开销有多大。另一方面,如果 ReportMetrics
接收一个接口作为参数,我们就可以在测试时传递一个内存模拟,同时保留将指标报告到满足该特定接口的任何目的地的灵活性。因此,我们可以通过引入一个接口来改进前面的代码:
package metrics
// Sink is implemented by objects that metrics can be reported to.
type Sink interface {
Put(k string, v interface{}) error
}
// ReportMetrics writes the collected metrics to a Sink.
func (c *Collector) ReportMetrics(s Sink) error {
// for each metric call s.Put(k, v)
}
这个小小的改动让测试变得轻而易举!我们可以单独测试 kv.Store
代码,并切换到内存存储来运行所有单元测试:
func TestReportMetrics(t *testing.T) {
// Use in-memory store defined inside the test package
s := new(inMemStore)
// Create collector and populate some metrics
c := metrics.NewCollector()
for i := 0; i < 100; i++ {
c.Observe(fmt.Sprintf("metric_%d", i), i)
}
if err = c.ReportMetrics(s); err != nil {
t.Fatal(err)
}
// Ensure that all metrics have been written to the store...
}
林达穆德给出的另一条建议是,我们应该始终尝试返回具体类型而不是接口。这条建议实际上是有道理的:作为一个包的消费者,如果我在调用创建类型Foo
的函数,我可能对调用该类型特定的一个或多个方法感兴趣。如果NewFoo
函数返回一个接口,客户端代码将不得不手动将其转换为Foo
,以便调用Foo
特定的方法;这会违背最初返回接口的目的。
还很重要的一点是指出,在大多数情况下,实现将创建一个具体类型的实例;我们最初选择返回接口的主要原因是为了确保我们的具体类型始终满足特定的接口。本质上,我们正在给代码添加一个编译时检查!然而,有更简单的方法引入这样的编译时检查,同时仍然保留构造函数返回具体实例的能力:
package metrics
import "fmt"
// Compile-time checks for ensuring a type implements a particular
// interface.
var (
// Works but allocates a dummy Foo instance on the heap.
_ fmt.Stringer = &Foo{}
// Preferred way that does not allocate anything on the heap.
_ fmt.Stringer = (*Foo)(nil)
)
type Foo struct { }
func (*Foo) String() string { return "Foo" }
上述代码片段概述了两种相当常见的方法,通过定义一对使用保留的空白标识符作为提示的全球变量来实现编译时检查,该提示告知编译器它们实际上没有被使用。
零值是你的朋友
Go 提供的一个很棒的功能是,每个类型在实例化时都会自动分配其零值。Go 及其标准库中的一些有趣示例如下:
-
Go 通道;空通道会无限期阻塞尝试从中读取的 goroutine
-
Go 切片的零值;这是一个可以添加内容的空切片
-
sync.Mutex
类型,其零值表示互斥锁处于解锁状态 -
bytes.Buffer
类型,其零值表示一个空缓冲区
通过在设计新类型时依赖零值,我们可以提供开箱即用的实现,无需显式调用构造函数或其他初始化方法。以下代码片段定义了一个简单、线程安全的映射:
package main
import (
"fmt"
"sync"
)
// SyncMap implements a thread-safe map. The zero SyncMap value is ready
// to use.
type SyncMap struct {
mu sync.RWMutex
data map[string]interface{}
}
实际上用于存储SyncMap
数据的 Go 映射实例将在我们尝试向映射中添加项时懒加载。在处理底层映射之前获取一个写者互斥锁确保映射的初始化和项的插入以原子方式发生:
// Put inserts a key-value pair into the map.
func (sm *SyncMap) Put(key string, value interface{}) {
sm.mu.Lock()
defer sm.mu.Unlock()
if sm.data == nil {
sm.data = make(map[string]interface{})
}
sm.data[key] = value
}
查找实现相当直接。与Put
实现的明显不同之处在于,Get
在执行查找之前会获取一个读者互斥锁。使用读者/写者互斥锁提供了对多个读者的并发访问,同时只允许单个写者修改映射的内容:
// Get returns the value associate by key and a boolean value indicating
// whether key is present in the map.
func (sm *SyncMap) Get(key string) (interface{}, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
if sm.data == nil {
return nil, false
}
return sm.data[key]
}
与需要通过调用make
显式初始化的内置 Go 映射类型不同,SyncMap
的零值可以直接安全使用:
func main() {
var sm SyncMap // we are using the zero value of the map
sm.Put("foo", "bar")
fmt.Println(sm.Get("foo")) // Prints: bar true
}
更重要的是,我们可以将前面的SyncMap
实现嵌入到遵循相同零值模式的其它类型中,以提供需要无初始化的复杂类型:
type Foo struct {
bar Bar
}
type Bar struct {
sm SyncMap
}
func main() {
var foo Foo // still using a zero value
foo.bar.sm.Put("answer", 42) // storing into the embedded map also
// works.
}
在前面的代码片段中,SyncMap
实例已经准备好可以使用,并且可以通过Foo
实例直接访问,而无需编写任何额外的代码来设置它。
使用工具分析和操作 Go 程序
Go 程序本身很容易解析。事实上,Go 库提供了内置的包,可以将 Go 程序解析为抽象语法树(ASTs),这些树可以被遍历、修改,并转换回 Go 代码。让我们通过一个简单的例子来看看使用这些包有多容易。
首先,我们需要一个辅助函数将 Go 程序转换为 AST 表示。下面的parse
函数正是这样做的:
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
)
// parse a Go program into an AST representation.
func parse(program string) (*token.FileSet, *ast.File, error) {
fs := token.NewFileSet()
tree, err := parser.ParseFile(fs, "example.go", program, 0)
if err != nil {
return nil, nil, err
}
return fs, tree, nil
}
ast
包提供了一些辅助函数,实现了访问者模式,并为 AST 中的每个节点调用用户定义的回调。对于这个特定的例子,我们将定义一个名为inspectVariables
的函数,该函数将访问 AST 中的每个节点,寻找对应于标识符(包、常量、类型、变量、函数或标签)的节点。对于每个发现的标识符,该函数将检查其Kind
属性,如果标识符代表变量,则打印其名称:
// inspectVariables visits each AST node and prints any encountered Go variable.
func inspectVariables(fs *token.FileSet, tree *ast.File) {
ast.Inspect(tree, func(n ast.Node) bool {
ident, ok := n.(*ast.Ident)
if !ok || ident.Obj == nil || ident.Obj.Kind != ast.Var {
return true
}
fmt.Printf("%s:\tvariable %q\n", fs.Position(n.Pos()), ident)
return true
})
}
为了完成我们的示例,我们需要提供一个main
函数,该函数将解析一个简单的程序,并在生成的 AST 上调用inspectVariables
:
func main() {
fs, tree, err := parse(`
package foo
var global = "foo"
func main(){ x := 42 }
`)
if err != nil {
fmt.Printf("ERROR: %v\n", err)
return
}
inspectVariables(fs, tree)
}
运行前面的程序会产生以下输出:
$ go run print_vars.go
example.go:4:7: variable "global"
example.go:6:16: variable "x"
Go 生态系统包含大量工具,这些工具建立在解析基础设施之上,并为软件工程师提供分析、代码修改和生成服务。在接下来的章节中,我们将考察一些这样的工具,它们可以使您的软件开发生活更加轻松。
注意格式化和导入(gofmt,goimports)
制表符还是空格?开括号前是否应该有换行符?所有工程师在需要为新项目或刚刚继承的项目选择特定的代码格式化风格时,最终都会面临这个困境。
源代码格式化风格一直是团队成员之间长期、有时激烈的争论的主题。与其它编程语言相反,Go 在设计上对特定的格式化风格有很强的意见,并附带工具来帮助强制执行该特定风格。这个设计决策是有意义的,因为 Go 最初是为了被成千上万的谷歌雇佣的工程师使用而创建的。在这个庞大的开发规模下,作者代码的一致性不仅仅是一种优雅,实际上对于确保代码可以在开发团队之间传递是至关重要的。
gofmt
工具^([11])作为标准 Go 发行版的一部分提供。您提供它一个或多个文件的路径,它可以执行以下任务:
-
按照推荐的标准格式化代码
-
简化代码 (
gofmt -s example.go
) -
执行简单的重写 (
gofmt -r 'a[b:len(a)] -> a[len(a):b]' example.go
)
默认情况下,gofmt
将格式化后的程序输出到标准输出。然而,用户可以通过传递 -w
标志来强制 gofmt
将其输出写回到它刚刚处理的源文件。
goimports
工具^([12]) 是 gofmt
的一个直接替代品,可以通过运行 go get golang.org/x/tools/cmd/goimports
来安装。除了提供与 gofmt
输出匹配的代码格式化功能外,goimports
还管理 Go 的导入行:它可以填充缺失的导入并删除未被处理文件引用的导入。更重要的是,goimports
还确保包按字母顺序排序并分组,这取决于它们是否属于标准库或第三方包。
以下是一个展示了一些问题的 Go 程序示例:缺失的导入、未使用的导入、多余的空白字符和错误的缩进:
package main
import (
"net" // Mixed stdlib and third-party packages
"github.com/achilleasa/kv"
"fmt" // Unused package
)
type Server struct {
ctx context.Context // missing referenced import
socket net.Conn
store *kv.Store // Incorrectly indented field definition
}
func foo(){} // Redundant line-breaks above foo()
这些工具的典型用法是将它们作为你最喜欢的编辑器或 IDE 的后保存钩子来执行。如果我们对前面的代码片段运行 goimports
,我们将得到一个整洁的格式化输出:
package main
import (
"context"
"net"
"github.com/achilleasa/kv"
)
type Server struct {
ctx context.Context
socket net.Conn
store *kv.Store
}
func foo() {}
如你所见,导入语句已经被清理和排序,缺失的包已经被导入,未使用的包已经被删除。除此之外,代码现在已经被正确缩进,并且所有多余的空白字符都被移除了。
在包之间重构代码(gorename, gomvpkg, fix)
有时,你可能会在函数中遇到一个具有奇怪或非描述性名称的变量,你非常想重命名它。执行此类重命名操作非常简单;只需选择函数块并运行查找和替换操作。简单得就像做饼一样!
但如果你想要重命名一个公共结构体字段或从你的包中导出的函数呢?这绝对不是一个简单任务,因为你需要追踪所有对要重命名的对象(列表可能还包括其他包)的引用,并将它们更新为使用新名称。这种重命名操作将我们带入代码重构的领域;幸运的是,我们有工具可以自动化这个繁琐的任务:gorename
^([16])。可以通过运行 go get golang.org/x/tools/cmd/gorenam*e*
来安装它。
gorename
的一个有趣特性,除了它可以在包之间工作之外,还在于它是 类型感知的。由于它在应用任何重命名操作之前依赖于解析程序,因此它足够智能,可以区分名为 Foo
的函数和具有相同名称的结构体字段。此外,它还增加了一层额外的安全性,即它只会应用重命名操作,只要最终结果是能够无错误编译的代码片段。
有时,你可能需要重命名一个 Go 包,甚至将其移动到同一项目或不同项目中的不同位置。gomvpkg
工具 ^([15]) 可以在此方面提供帮助,同时还会追踪依赖于重命名/移动包的包,并更新它们的导入语句以指向新的包位置。可以通过运行 go get golang.org/x/tools/cmd/gomvpkg
来安装它。移动包就像运行以下命令一样简单:
# Rename foo to bar and update all imports for packages depending on foo.
$ gomvpkg -from foo -to bar
自 2011 年首次稳定版 Go 版本发布以来,Go 标准库在多年中发生了很大变化。在引入新 API 的同时,其他 API 被弃用并最终被移除。在某些情况下,现有的 API 被修改,通常是非向后兼容的方式,而在其他情况下,外部或实验性包最终被接受纳入标准库。
一个相关的例子是 context
包。在 Go 1.7 之前,这个包位于 golang.org/x/net/context
,并且许多 Go 程序都在积极使用它。但随着 Go 1.7 的发布,该包成为了标准库的一部分,并移动到了独立的 context
包。一旦工程师切换到上下文包的新导入路径,他们的代码就会立即与仍在使用旧导入路径的代码不兼容。因此,有人必须承担审查现有代码库并重写现有导入以指向上下文包新位置的艰巨任务!
Go 设计者预见到了这些问题,并创建了一个基于规则的工具来检测依赖于旧、已弃用的 API 或包的代码,并自动将其重写为使用新 API。这个工具被恰当地命名为 fix
(golang.org/cmd/fix/
),它随着每个新的 Go 版本一起发布,每次切换到新版本的 Go 时都可以通过运行 go tool fix $path
来调用它。重要的是要指出,所有应用到的修复都是 幂等的;因此,可以安全地多次运行该工具,而不会使代码库损坏。
使用 linters 提高代码质量指标
Linters 是专门用于解析 Go 文件并尝试检测、标记和报告以下情况的静态分析工具:
-
代码不符合标准格式化风格指南;例如,它包含多余的空白,缩进不正确,或包含有拼写错误的注释
-
程序中可能存在逻辑错误;例如,变量声明 覆盖 前一个具有相同名称的变量声明,以错误的参数数量或与格式字符串类型不匹配的参数调用函数,如
fmt.Printf
,将值赋给变量但实际上没有使用它们,没有检查函数调用返回的错误,等等 -
代码可能包含安全漏洞;例如,它包含硬编码的安全凭证或指向可能使用不安全的随机数源或密码学上损坏的哈希原语(DES、RC4、MD5 或 SHA1)进行 SQL 注入的位置
-
代码表现出高复杂度(例如,深度嵌套的
if
/else
块)或包含不必要的类型转换、未使用的局部或全局变量,或从未被调用的代码路径
以下表格总结了您可以使用以检查您的程序并提高您编写的代码质量指标的最受欢迎的 Go 代码检查工具:
类别 | 代码检查工具 | 描述 |
---|---|---|
逻辑错误 | bodyclose ^([2]) |
检查 http.Response 主体是否始终关闭。 |
逻辑错误 | errcheck ^([7]) |
识别未检查返回的错误的情况。 |
逻辑错误 | gosumcheck ^([19]) |
确保正确处理类型切换的所有可能情况。 |
逻辑错误 | go vet (golang.org/cmd/vet/ ) ^([20]) |
报告可疑的结构,例如,使用错误的参数调用 fmt.Printf 。 |
逻辑错误 | ineffassign ^([21]) |
检测未被使用的变量赋值。 |
代码异味 | deadcode (github.com/tsenart/deadcode ) ^([4]) |
报告未使用的代码块。 |
代码异味 | dupl (github.com/mibk/dupl ) ^([5]) |
报告可能重复的代码块。 |
代码异味 | goconst (github.com/jgautheron/goconst ) ^([9]) |
标记可以替换为常量的重复字符串。 |
代码异味 | structcheck (gitlab.com/opennota/check ) ^([30]) |
识别未使用的结构体字段。 |
代码异味 | unconvert (github.com/mdempsky/unconvert ) ^([31]) |
检测不必要的类型转换。 |
代码异味 | unparam (github.com/mvdan/unparam ) ^([33]) |
检测未使用的函数参数。 |
代码异味 | varcheck ^([34]) |
检测未使用的变量和常量。 |
性能 | aligncheck ^([1]) |
识别由于填充而占用更多空间的低效打包的结构体。 |
性能 | copyfighter (github.com/jmhodges/copyfighter ) ^([3]) |
报告通过值传递大型结构体的函数;这种模式触发内存分配并增加垃圾收集器的压力。 |
性能 | prealloc ^([26]) |
识别可以预先分配的切片声明。 |
复杂度 | gocyclo ^([10]) |
计算 Go 函数的圈复杂度。 |
复杂度 | gosimple ^([18]) |
报告可以简化的代码。 |
复杂度 | splint ^([29]) |
识别过长或接收过多参数的函数。 |
安全 | gosec (github.com/securego/gosec ) ^([17]) |
扫描源代码以查找潜在的安全问题。 |
安全 | safesql (github.com/stripe/safesql )^( [28]) |
检查潜在的 SQL 注入点。 |
风格 | gofmt -s (golang.org/cmd/gofmt/ ) ^([11]) |
确保文件格式符合 gofmt 规则。 |
风格 | golint (github.com/golang/lint ) ^([14]) |
报告与《Effective Go》中概述的建议不符的样式偏差。 |
风格 | misspell (github.com/client9/misspell ) ^([25]) |
使用字典来识别注释中的拼写错误。 |
风格 | unindent (github.com/mvdan/unindent ) ^([32]) |
识别未正确缩进的代码。 |
在您的项目中使用上述 linters 伴随着一些需要注意的问题。首先,每个 linter 都使用自己的输出格式来报告检测到的问题。当您尝试将 linters 与您首选的编辑器或 IDE 工作流程集成时,缺乏标准化的报告问题方式成为一个问题(例如,跳转到检测到问题的代码位置)。其次,每个 linter 都不知道其他 linter 的存在。因此,每次运行时,每个 linter 都需要重新解析所有包。当您处理小型代码库时,这通常不是问题,但当您处理大型项目时,这会变得令人烦恼,因为所有 linters 的完整运行可能需要几分钟才能完成。
要解决上述问题,您可以使用元 linter(也称为linter 输出聚合器)工具,例如 golangci-lint
^([13])(现在已弃用的 gometalinter 的替代品)或 revive ^([27])。这些工具旨在并行执行可配置的 linter 列表,标准化它们的输出,消除重复警告,甚至根据正则表达式抑制警告(当您的项目包含由其他工具自动生成的文件时,这是一个非常实用的功能)。更重要的是,它们还无缝集成到工程师使用的绝大多数编辑器中。调用这些元 linter 工具的一个简单方法是向您的项目 makefile 中添加一个目标:
lint:
golangci-lint run \
--no-config --issues-exit-code=0 --deadline=30m \
--disable-all --enable=deadcode --enable=gocyclo --enable=golint \
--enable=varcheck --enable=structcheck --enable=errcheck \
--enable=dupl --enable=ineffassign \
--enable=unconvert --enable=goconst --enable=gosec
为 linting 创建一个 makefile 规则,使得将 linters 作为常规 CI 流程的一部分运行变得容易,并且直到 lint 错误得到解决,阻止拉取请求合并。同时,它还提供了在您在代码库上工作时本地运行 linters 的灵活性。
工程师在创建拉取请求之前跳过运行 linters 是很常见的情况,这需要额外的提交来处理 lint 错误。您可以通过利用大多数版本控制系统(Git 是一个例子)支持某种预提交或预推送钩子,并让您的 VCS 自动为您运行 linters 来避免这种情况。
摘要
在本章的第一节“面向对象设计的 SOLID 原则”中,我们深入探讨了每个 SOLID 原则及其如何应用于编写干净的 Go 代码:
-
SRP:根据目的将结构体和函数分组,并将它们组织到具有清晰逻辑边界的包中。
-
开放/封闭原则:使用简单类型的组合和嵌入来构建更复杂类型,同时仍然保留它们所包含类型的相同隐式接口。
-
LSP:通过使用接口而不是具体类型来定义包之间的合同,避免不必要的耦合。
-
ISP:确保您的函数或方法签名只依赖于它们需要的操作,而不需要更多;使用尽可能小的接口来描述函数/方法参数,并避免与具体类型的实现细节耦合。
-
DIP:在设计代码时使用适当的抽象级别,以解耦高级和低级模块,同时确保实现细节依赖于抽象而不是相反。
在本章的中间部分,我们讨论了将代码组织到包中的主题,确定了您应该避免的常见包命名陷阱,并讨论了导入循环的概念,包括其成因。然后,我们概述了缓解循环依赖问题的策略。
最后,我们讨论了一些有用的技巧和工具,您可以使用它们来帮助编写易于推理和您的软件工程同事审查和维护的干净代码。
随着您的 Go 项目规模的增长,您无疑会注意到代码库中包导入语句数量的增加。这是相当正常的,坦白说,如果您在创建包时应用 SOLID 原则,这是预期的。然而,导入数量的增加,尤其是如果它们是由您无法控制的第三方编写的,这也需要某种过程来确保即使外部依赖项发生变化,您的程序仍然可以按预期编译。这是下一章的主要内容。
问题
-
SOLID 首字母缩略词代表什么?
-
为什么以下代码片段违反了 SRP?您会如何重构它以确保它不违反 SRP?
import (
"crypto/ecdsa"
)
type Document struct { //... }
// Append adds a line to the end of the document.
func (d *Document) Append(line string) { //... }
// Content returns the document contents as a string.
func (d *Document) Content() string { //... }
// Sign calculates a hash for the document contents, signs it with the
// provided private key and returns back the result.
func (d *Document) Sign(pk *ecdsa.PrivateKey) (string, error) { //... }
- ISP 背后的主要概念是什么?讨论您将如何应用它来改进以下函数签名:
// write a set of lines to a file.
func write(lines []string, f *os.File) error {
//...
}
-
解释为什么util被认为是一个不太理想的 Go 包名称。
-
为什么导入循环是 Go 程序的问题?
-
列举使用零值设计新 Go 类型的优点。
进一步阅读
-
aligncheck
: 识别效率低下的结构体打包。URL:gitlab.com/opennota/check
. -
bodyclose
: 一个静态分析工具,用于检查res.Body
是否正确关闭。URL:github.com/timakin/bodyclose
. -
copyfighter
: 静态分析 Go 代码并在通过值传递大结构体时报告函数。URL:github.com/jmhodges/copyfighter
. -
deadcode
: 报告未使用的代码块。URL:github.com/tsenart/deadcode
. -
dupl
: 报告潜在的代码块重复。URL:github.com/mibk/dupl
. -
Effective Go: 编写清晰、惯用的 Go 代码的技巧。
-
errcheck
: 确保返回的错误被检查。URL:github.com/kisielk/errcheck
. -
佛勒,马丁:重构:现有代码的设计改进. 波士顿,马萨诸塞州,美国:Addison-Wesley,1999 — ISBN 0-201-48567-2 (
www.worldcat.org/title/refactoring-improving-the-design-of-existing-code/oclc/863697997
). -
goconst
: 标记可以替换为常量的重复字符串。URL:github.com/jgautheron/goconst
. -
gocyclo
: 计算代码的圈复杂度。URL:github.com/alecthomas/gocyclo
. -
gofmt
: 格式化 Go 程序或检查它们是否格式正确。URL:golang.org/cmd/gofmt/
. -
goimports
: 通过添加缺失的导入行和删除未引用的导入行来更新 Go 导入行。URL:godoc.org/golang.org/x/tools/cmd/goimports
. -
golangci-lint
: 检查器运行器。URL:github.com/golangci/golangci-lint
. -
golint
: 报告 Go 程序中的样式问题。URL:github.com/golang/lint
. -
gomvpkg
: 移动 Go 包并更新导入声明。URL:godoc.org/golang.org/x/tools/cmd/gomvpkg
. -
gorename
: 在 Go 源代码中执行精确的类型安全重命名。URL:godoc.org/golang.org/x/tools/cmd/gorename
. -
gosec
: 扫描源代码以查找潜在的安全问题。URL:github.com/securego/gosec
. -
gosimple
: 报告可以潜在简化的代码。URL:github.com/dominikh/go-tools/tree/master/cmd/gosimple
. -
gosumcheck
:确保类型切换中所有可能的类型都得到适当处理。URL:github.com/haya14busa/gosum
。 -
go vet
:检查 Go 源代码并报告可疑结构,例如参数与格式字符串不匹配的printf
调用或被遮蔽的变量。URL:golang.org/cmd/vet/
。 -
ineffassign
:检测未被使用的变量赋值。URL:github.com/gordonklaus/ineffassign
。 -
Liskov, Barbara: 主题演讲 - 数据抽象和层次结构。在:《面向对象编程系统、语言和应用(增补)会议论文集》,OOPSLA '87。纽约,纽约,美国:ACM,1987 — ISBN 0-89791-266-7 (
www.worldcat.org/title/oopsla-87-addendum-to-the-proceedings-object-oriented-programming-systems-languages-and-applications-october-4-8-1987-orlando-florida/oclc/220450625
),第 17-34 页。 -
Martin, Robert C.:《整洁架构:软件结构和设计的工匠指南》,罗伯特·C·马丁系列。波士顿,马萨诸塞州:普伦蒂斯·霍尔,2017 — ISBN 978-0-13-449416-6 (
www.worldcat.org/title/clean-architecture-a-craftsmans-guide-to-software-structure-and-design-first-edition/oclc/1105785924
)。 -
Meyer, Bertrand:面向对象软件构造。第 1 版。上萨德尔河,新泽西州,美国:普伦蒂斯-霍尔公司,1988 — ISBN 0136290493 (
www.worldcat.org/title/object-oriented-software-construction/oclc/1134860513
)。 -
misspell
:检查源代码中的拼写错误。URL:github.com/client9/misspell
。 -
prealloc
:识别可以预先分配的切片声明。URL:github.com/alexkohler/prealloc
。 -
revive
:golint 的更严格、可配置、可扩展且美观的替代品。URL:github.com/mgechev/revive
。 -
safesql
:检查代码中潜在的 SQL 注入点。URL:github.com/stripe/safesql
。 -
splint
:识别过长或接收过多参数的函数。URL:github.com/stathat/splint
。 -
structcheck
:识别未使用的结构体字段。URL:gitlab.com/opennota/check
。 -
unconvert
:检测不必要的类型转换。URL:github.com/mdempsky/unconvert
。 -
unindent
: 识别错误缩进的代码。URL:github.com/mvdan/unindent
. -
unparam
: 检测未使用的函数参数。URL:github.com/mvdan/unparam
. -
varcheck
: 检测未使用的变量和常量。URL:github.com/opennota/check
.
第三章:依赖项管理
“如果一开始没有成功,那就叫它 1.0 版本。”
- Pat Rice
作为对前一章中讨论的 SOLID 原则的坚定信仰者,Go 社区中的几位知名人士强烈建议软件工程师将他们的代码组织成自包含和可重用的包。
当我们的代码导入外部包时,其依赖图不仅增加了导入的包,还增加了其传递依赖集——即我们导入的包所需的任何其他包(及其依赖)。随着我们的项目规模不断扩大,有必要有效地管理所有依赖项的版本,以确保上游传递依赖项的变化不会对我们自己的程序造成意外的副作用(崩溃、行为变化等)。
在本章中,我们将关注以下主题:
-
软件版本化的重要性
-
为 Go 包应用语义版本化的方法
-
管理多版本包的源代码的策略以及允许您从代码中导入特定包版本的工具
-
依赖项版本管理的优缺点以及如何用它来促进可重复构建
-
最受欢迎的 Go 包版本管理方法和工具
关于软件版本化,为什么会有这么多的争议?
版本化的概念已经深入到我们周围的每一件事中。世界各地的人们习惯于每天使用各种形式的版本化。请注意,我这里不仅仅是在谈论软件。你使用的绝大多数物理产品都与某种版本化方案相关联。版本化的用途从你的电脑 CPU 到你的手机,从你书架上的算法书修订版到你的最爱超级英雄(或光剑挥舞的反叛者)电影。
当我们进入软件领域时,版本化的概念变得更加重要。如今,随着越来越多的软件工程师信奉“快速发布”的箴言,建立一个合理的版本化系统使得以下操作成为可能:
-
验证特定的软件可以作为我们生产系统中使用的老版本软件的安全替代品。这在安全方面尤为重要,因为所有软件,除非经过正式验证,都可能包含潜在的漏洞,这些漏洞可能在任何时间被发现——甚至是在部署到生产后数周或数年后。因此,我们能够通过在修复可用时尽快升级到新版本来减轻此类问题至关重要。
-
将我们应用程序的每个依赖项固定到特定的软件包版本。这是设置 CI 管道以实现可重复构建概念的关键前提条件。能够访问可重复构建使得在任何时候都可以重新编译客户在生产中运行的软件的确切副本,并在调查错误报告时将其用作参考。
在以下章节中,我们将深入了解语义版本背后的细节,这是一种非常流行的方法,不仅用于管理软件包的版本,而且还用于通知依赖它们的用户即将到来的和可能破坏性的更改。
语义版本化
语义版本化 ^([11]) 是一个广泛流行的系统,用于以使目标软件用户能够轻松地确定哪些版本可以升级,哪些版本包含破坏性 API 变更,因此在升级时需要开发工作和时间。
语义版本号格式如下:
MAJOR.MINOR.PATCH
根据使用情况,可以可选地附加额外的后缀来表示预发布版本(例如,alpha、beta 或 RC(或发布候选))或传达其他与构建相关的信息(例如,用于构建发布的分支的 Git SHA 或构建工件生成的日期和时间戳)。
当与 Go 软件包一起工作时,语义版本使用的三部分方法使得软件包作者能够让软件包的用户知道每个发布包含的类型的更改。例如,每当对代码应用向后兼容的错误修复时,PATCH
字段就会增加。相反,当向软件包添加新功能时,MINOR
字段会增加,但最重要的是,只有当这种新功能以确保新版本保持向后兼容旧软件包版本的方式添加时。当然,随着软件包随时间发展,在某个时候引入一些破坏性更改是不可避免的。例如,可能需要更改现有的函数签名以支持额外的用例。对于这类场景,版本字符串的 MAJOR
组件需要增加。
比较语义版本
如果我们给定两个语义版本,a.b.c
和 x.y.z
,我们如何判断哪个更新?要比较两个语义版本,我们需要从左到右比较它们的各个组成部分。以下是一段简短的代码示例,展示了我们如何比较两个语义版本:
// SemVer contains the major, minor, patch components of a semantic version
// string.
type SemVer [3]int
// GreaterThan returns true if the receiver version is greater than other.
func (sv SemVer) GreaterThan(other SemVer) bool {
for i, v := range sv {
if v != other[i] {
return v > other[i]
}
}
return false
}
比较或排序语义版本对人类来说是一项相当容易的任务,但正如前述代码片段所示,当由机器执行时,这需要额外的努力。
如果你曾经亲自遇到过这个问题,那么你可能曾经编写过检查特定库最低版本的 makefile 规则,或者尝试使用标准命令行工具对遵循此版本控制方案的文件夹列表进行排序。这被认为是与基于单调递增的构建号或YYYMMDD
格式的构建日期的版本控制方案相比,语义版本控制的一个注意事项。
将语义版本控制应用于 Go 包
我们在上一节中讨论的语义版本控制定义留下了一些未回答的问题。首先,新包的初始版本号应该是什么?更重要的是,作为包的外部用户,我们如何知道包 API 已经足够稳定,可以在我们的代码中安全使用?
没有比用一个小的例子更有助于回答这些问题的方法了。让我们考虑以下来自一个尚未发布的包的代码片段,该包处理天气预测:
package weather
// Prediction describes a weather prediction.
type Prediction uint8
// The supported weather prediction types.
const (
Sunny Prediction = iota
Rain
Overcast
Snow
Unknown
)
// predictAtCoords returns a weather prediction for the specified GPS coordinates.
func predictAtCoords(lat, long float64) (Prediction, error) { // ... }
由于我们正在讨论一个全新的包,我们需要决定一个初始的版本字符串。鉴于该包没有暴露任何公共接口,我们可以从0.1.0作为我们的初始版本号开始。主版本组件中的0值作为对潜在用户的警告,表明该包仍在开发中,包的实现可能会频繁地以可能破坏的方式更改。换句话说:使用该包请自行承担风险。
经过几次迭代和包代码的广泛重构(每次都提升包的次要版本),我们最终达到了版本0.9.0。在这个时候,我们决定该包可以在我们的生产系统内部安全使用。为此,我们需要公开一个公共 API,以便我们的现有 Go 包可以与新的包进行接口交互。这通过一个简单的重命名操作实现——将predictAtCoords
改为PredictAtCoords
(当然,还需要更新所有相关的单元测试),如下面的代码块所示:
// PredictAtCoords returns a weather prediction for the specified GPS coordinates.
func PredictAtCoords(lat, long float64) (Prediction, error) {
// ...
}
在成功推广到生产环境之后,我们应该足够自信地将这个包公开,以便其他人可以导入和使用它。该包以版本1.0.0发布,并最终在天气预报社区中取得了巨大成功!
也就是说,直到有一天,当包的用户在 GitHub 上打开一个带有错误的 issue 时:传递某些经纬度参数的组合会导致 PredictAtCoords
崩溃。我们重新审视代码,创建了一个用于复现错误的程序,并在一番调查后,我们发现错误的根本原因:缺乏适当的检查导致发生了除以零的情况。修复非常简单,并且以任何方式都没有改变包的功能,因此我们提升了包的补丁版本,并发布了1.0.1。
随着越来越多的人开始依赖我们发布的包,我们开始收到添加新功能的请求,例如:预测由加码识别的位置的天气^([10])。为了实现这个新功能,我们向包中引入了一个新的公共函数:
// PredictAtPlusCode the weather at the location specified by a plus code.
func PredictAtPlusCode(code string) (Prediction, error) {
// ...
}
这次更改向包中引入了新功能,但包本身仍然与旧版本向后兼容。因此,我们现在需要将次要版本组件提升,并发布包的版本1.1.1。同样,我们添加了预测城市或特定地址天气的功能。每次添加后,我们都要确保提升包的次要版本。
到目前为止,一切顺利。然而,在仔细检查我们最新包版本的代码后,我们发现当前实现涉及相当多的重复——公共 API 由一组执行更多或更少相同任务的功能组成:预测位置的天气。唯一的区别是每个函数期望位置以特定的方式编码(即,作为 GPS 坐标、加码或地址)。
在尝试简化包 API 并应用我们在第二章中讨论的接口隔离原则(ISP),即《编写干净且可维护的 Go 代码的最佳实践》中,我们决定引入一系列破坏性API 更改。首先,我们定义了Locator
接口,它为将位置转换为一系列 GPS 坐标提供了必要的抽象。其次,我们用一个新的函数Predict
替换了包中的各种PredictAtXYZ
函数,该函数接收一个Locator
实例作为其参数:
package weather
// Locator is implemented by objects that can represent a location as a
// pair of GPS coordinates.
type Locator interface {
Coords() (float64, float64, error)
}
// Predict the weather at the specified location.
func Predict(loc Locator) (Prediction, error) {
coords, err := loc.Coords()
if err != nil {
return Unknown, err
}
// ...
}
如前述代码所示,通过重构Predict
,我们现在可以将用于表示位置的各类类型提取到它们自己的独立包中,这个包恰当地命名为location
:
package location
// GPSCoords holds a lat/long coordinate pair.
type GPSCoords [2]float64
// PlusCode encodes a location using a plus code.
type PlusCode string
// Address encapsulates the components of an address.
type Address struct {
Street string
City string
PostCode string
Country string
}
多亏了隐式接口的魔力,我们使用这些新类型与Predict
函数一起使用时,只需添加满足weather
包中Locator
接口的方法:
func (g GPSCoords) Coords() (float64, float64, error) {
return g[0], g[1], nil
}
func (pc PlusCode) Coords() (float64, float64, error) {
// Decode plus code to gps coordinates...
}
func (a Address) Coords() (float64, float64, error) {
// Use an external geocoding service to convert the address into a set
// of GPS coordinates...
}
这次更改无疑提高了天气包的质量,但代价是破坏了向后兼容性。为了向包的用户表明这一点,我们将主版本组件提升,并发布包的版本2.0.0。
通过为我们的包采用语义版本控制,我们不仅允许包用户选择他们想要使用的 API 版本,还提供了他们根据自己的节奏升级到新包版本的灵活性,而不会对现有的生产系统造成任何风险。
管理多个包版本的源代码
上一个部分可能让您感到奇怪的一点是,尽管我一直在谈论发布天气包的版本x.y.z
,但该节内容本身没有任何关于实际发布包的过程的信息。
在这一点上,您可能也会问自己一个问题,如果我们发布了多个主要版本的包,我们如何管理每个发布版本的源代码? 因为毕竟,作为包作者,我们可以选择并行维护和支持几个主要版本或主要/次要组合的包版本,每个版本可以潜在地遵循其自己的发布周期。例如,我们可以在继续修复1.x.x线的错误或应用安全补丁的同时,为2.x.x线扩展 API。那么,最终用户应该如何导入特定的 Go 包版本呢?
为了回答所有这些问题,我们需要深入探讨几种替代方法来对 Go 包进行版本控制。
单一存储库与版本化文件夹
使用带有版本化文件夹的单一存储库要求我们在单一存储库中维护所有支持版本的源代码。实现这一点的最简单方法是在存储库的根目录下为每个版本创建一个文件夹,并将所有版本特定的文件和子包复制进去。
让我们回顾一下上一节中的weather
包示例。假设我们使用 Git 作为我们的版本控制系统,并且我们将包托管在 GitHub 上的weather-as-a-service
账户下的名为weather
的存储库中。以下流程图说明了使用这种方法文件夹布局将如何看起来:
图 1:在单一存储库中管理包的多个版本
需要指出的是,尽管weather.go
文件位于v1
和v2
文件夹下,但两者都声明了一个名为weather
的包。这个技巧允许包的用户明确选择他们想要导入的包版本,并使用weather
选择器来引用其内容,如下面的代码块所示:
import (
"fmt"
"github.com/weather-as-a-service/weather/v2"
"github.com/weather-as-a-service/weather/v2/location"
)
func makePrediction() error{
loc := location.PlusCode("9C3XGV00+")
pred, err := weather.Predict(loc)
if err != nil {
return err
}
fmt.Printf("The weather prediction for London is: %v", pred)
}
这种方法对包作者和包的预期最终用户都有一些好处:
-
使用单一存储库来存储所有版本使得维护更加容易,因为包作者可以在隔离的情况下对包的每个版本进行工作。
-
存储库始终包含每个包版本的最新发布版。包的最终用户可以使用单个命令来获取/更新所有版本的包(例如,
go get -u github.com/weather-as-a-service/weather
)。 -
作为该包的最终用户,您有选择(尽管可能应该避免)在同一个代码库中导入和使用不同版本的同一包的选项。
另一方面,这种方法也有一些需要注意的事项:
-
代码重复!每个版本化的文件夹都包含软件包实现的完整副本,也可能包括一个或多个子包。这可能对软件包作者构成挑战,特别是如果发现了一个需要在不同文件夹中修补相同代码的安全问题。
-
作为该软件包的最终用户,您如何知道某个特定的软件包是否使用了这种特定的版本控制方案,或者可以使用哪些版本?要回答这些问题,您很可能会需要访问 GitHub 上的存储库页面并检查文件夹结构。
单个存储库 – 多个分支
一个更好的方法仍然是使用单个存储库,但为每个主要软件包版本、额外功能或正在进行的工作的开发分支维护不同的分支(在 Git 术语中)。如果我们将这种方法应用于我们之前讨论的天气软件包案例,我们的存储库通常将包含以下分支:
-
v1
:这是存放天气软件包已发布1.x.y线的分支。 -
v2
:另一个分支用于天气软件包的2.x.y发布。 -
develop
:开发分支中的代码通常被认为是正在进行的工作,因此不稳定,不适合使用。最终,一旦代码稳定,它将被合并回一个或多个稳定发布分支。
与版本化文件夹方法类似,多分支方法也确保每个发布分支的尖端或头部包含软件包的最新发布版本;然而,有时能够引用软件包的较旧语义版本也是有用的。一个典型的用例是可重复构建,我们总是希望针对软件包的特定版本进行编译,而不是从特定软件包系列中获取的尽管稳定但最新的版本。
为了满足上述要求,我们可以利用版本控制系统(VCS)的标记功能,以便我们可以在未来轻松地找到它,而无需扫描提交历史。在这里,我以 Git 为例,因为我更喜欢 Git,但像标记这样的概念也适用于其他 VCS(如 SVN 的标签、Perforce 的标签等)。
这引出了另一个问题:如果每个版本都有自己的分支,我们如何从我们的代码中导入它?如果我们谈论的是托管在 GitHub 上的公共软件包,答案是,我们需要使用重定向服务,例如gopkg.in
^([7])。
gopkg.in
服务作为 Go 工具重定向到对应 Go 包特定版本的代理。该服务通过公开一系列版本化URL 来实现,当通过go get
访问时,会自动解析到存储库中托管该包的特定分支或标签。
这种约定不仅会产生更干净、更短的包 URL,更重要的是,它还确保依赖包可以干净地编译使用它们依赖的包的最新次要版本,即使这些包发布了新的主要版本。
此外,当通过网页浏览器访问相同的 URL 时,用户将看到一个整洁的着陆页,该页提供了有关包用途和获取或导入所需命令的附加信息。同一页还包含到包源和文档的链接。
例如,当您使用浏览器访问特定包的gopkg.in
URL,例如流行的logrus
日志包时,您将看到一个类似于以下截图的页面。页面的左侧显示我们需要为所选版本的包使用的import
命令。页面的右侧面板声明了可用的包版本(在本例中:v1 和 v0)以及它们解析到的实际分支或标签:
图 2:gopkg.in
页面的流行logrus
包
让我们回到上一节中的天气示例,并更新导入以使用gopkg.in
URL:
import (
"fmt"
"gopkg.in/weather-as-a-service/weather.v2"
"gopkg.in/weather-as-a-service/weather.v2/location"
)
如果我们在前面示例所在的文件夹中运行go get -u ...
,它将始终拉取天气包的最新 v2版本。您可能想知道gopkg.in
如何知道哪个是最新版本以及该版本在哪里。为了正确解析版本请求,gopkg.in
首先解析项目的可用分支和标签列表。根据版本选择器后缀(本例中的.v2
),gopkg.in
将始终尝试返回与请求选择器匹配的包的最高匹配版本,其主版本组件与请求的选择器相匹配。这意味着该服务不仅能够处理我们之前简要提到的其他版本控制方案(例如,单调递增的构建或版本号、时间戳等),而且它足够智能,可以解析和比较包的语义版本。
例如,假设天气包仓库包含以下标签和分支的混合:
名称 | 类型 | 说明 |
---|---|---|
v1.0.10 | 标签 | |
v1.1.9 | 标签 | |
v1 | 分支 | 内容与 v1.1.0 标签匹配 |
v2.0 | 标签 | |
v3~dev | 分支 | 即将发布的 v3 的开发分支 |
这是gopkg.in
根据版本选择器后缀的值解析前面导入的方式:
选择器 | 解析为 |
---|---|
v1 | v.1.1.9 (标签) |
v2 | v2.0 (标签) |
v3 | v3~dev (分支) |
要使项目与gopkg.in
服务兼容,您需要确保您的分支或标签与gopkg.in
寻找的预期模式相匹配:vx
、vx.y
、vx.y.z
等。
由于大多数软件工程团队在选择开发流程(例如,Git flow 与 GitHub flow)或分支命名约定时都有很强的观点,因此我的个人建议是坚持使用标签来标记包版本,格式符合 gopkg.in
的期望。
供应商模式——好的、坏的、丑的
技术上讲,服务如 gopkg.in
总是重定向 go get
工具到给定版本选择器的最新可用主要版本,这对于努力设置保证可重复构建的开发管道的工程团队来说是一个障碍。典型的 CI 管道将在构建最终输出工件之前,通过如 go get -t -u ...
这样的命令拉取编译和测试依赖项。因此,即使你的代码在构建之间没有变化,由于拉入的依赖项发生变化,你的服务或应用程序的二进制文件可能也会不同。
然而,如果我说实际上有一种方法可以保留延迟包解析的好处,同时又能灵活地为每个构建“锁定”包版本,你会怎么想?将帮助我们处理这个问题的机制被称为供应商模式。
在 Go 编程的上下文中,我们将供应商模式称为创建 Go 应用程序导入图所有节点的不可变快照(也称为供应商依赖项)的过程。当编译 Go 应用程序时,使用供应商依赖项而不是原始导入的包。
在接下来的几节中,我们将看到创建依赖项快照的几种不同方法:
-
对包含每个导入依赖项的存储库进行分支,并更新代码库中的导入语句以指向分支资源。
-
创建一个清单,其中包含每个导入的包及其传递依赖项当前的提交标识符(例如,Git SHA)。该清单是一个小型、可读的基于 YAML 或 JSON 的文件,通常提交到版本控制系统(VCS)中,并在调用编译器之前用于获取每个依赖项的适当版本。
-
在本地缓存导入的依赖项(通常在一个名为
vendor
的文件夹中)并将它们与项目文件一起提交到 VCS。与前面的方法相反,本地缓存使我们能够在不首先获取任何依赖项的情况下立即检出我们的项目并编译它。
在深入探讨这些方法的每一个之前,让我们花几分钟时间讨论依赖项供应商的优缺点。
供应商依赖项的好处
首先,vendoring 的关键承诺不过是运行可重复构建的能力。许多客户,尤其是大型企业,往往坚持使用稳定的或 LTS 版本部署软件,除非绝对必要,否则不会升级他们的系统。能够检出客户使用的确切软件版本,并为测试环境生成位对位的相同二进制文件,对于任何试图诊断和重现客户面临的 bug 的现场工程师来说,这是一件无价之宝。
vendoring 的另一个好处是,它作为一个安全网,以防万一上游依赖突然从其托管的地方(例如,GitHub 或 GitLab 仓库)消失,从而破坏依赖于它的软件的构建。如果你认为这是一个极不可能的场景,让我带你回到 2016 年,分享一个来自 Node.js 世界的有趣工程恐怖故事!
你可能听说过现在臭名昭著的left-pad包。如果你还没有听说过,它只是一个单功能包,正如你可能会从它的名字中猜到的,它提供了一个函数,可以将字符串填充到特定的长度,并用特定的字符填充。到目前为止,并没有什么真正可怕的事情...但是,这个小型包是超过 500 个包的直接依赖,而这些包又是其他几个包的临时依赖,以此类推。一切都很顺利,直到有一天,left-pad 包的维护者收到了他其他包的一封停止侵权通知,并决定作为一种抗议形式,撤下他所有的包,包括 left-pad。
现在,想象一下人们的 CI 构建开始一个接一个地崩溃所引发的混乱。但是,那些明智地 vendoring 其依赖项的工程团队根本未受此问题影响。
vendoring 总是好主意吗?
前一个部分不遗余力地赞扬了 vendoring 的优点。但是,vendoring 真的是所有依赖项管理问题的万能药吗?本节试图深入探讨与 vendoring 相关的一些注意事项。
工程团队之间一个常见的问题是,尽管工程师们热衷于 vendoring 他们的依赖项,但他们经常忘记定期刷新它们。正如我在前一个部分所论证的,所有代码都可能包含潜在的安全漏洞。因此,一些安全漏洞(可能是导入包的传递依赖)最终可能会出现在生产环境中。
无论与安全相关与否,当向包维护者报告错误时,通常会迅速发布修复程序并相应地增加包版本(即,如果包使用语义版本控制)。由于大型项目倾向于导入大量包,因此无法实际监控每个导入包的仓库以查找安全修复。即使这可能实现,我们也无法现实地对其传递依赖关系进行此操作。因此,即使受影响的上游包已经修复,生产代码也可能长时间未打补丁。
供应商依赖关系的策略和工具
最初,Go 不支持供应商包。在当时,这很有意义,因为作为 Go 的主要用户,谷歌会将所有包依赖项托管在单个仓库中(通常称为单仓库)。
然而,随着 Go 社区的不断发展以及越来越多的公司将代码库迁移到 Go,依赖关系管理成为一个问题。随着 Go 1.5 的发布,Go 团队添加了对vendoring 文件夹的实验性支持。用户可以通过定义名为GO15VENDOREXPERIMENT
.的环境变量来启用此功能。
当此功能启用时,每次 Go 编译器尝试解析导入时,它将首先检查导入的包是否存在于供应商文件夹中,如果找到则使用它;否则,它将像往常一样继续扫描$GOPATH
中的每个条目以查找该包。
一旦这项功能可用,多个第三方就带头推出了利用这项功能的工具。以下是一些现在已弃用的处理依赖关系的工具列表,但并不详尽,包括godep
^([5])、govendor
^([9])、glide
^([3])和gvt
^([4])。
现在,围绕供应商的工具链已经变得更加流畅。以下章节探讨了撰写本文时推荐的供应商 Go 包的方法:
-
dep
工具 -
Go 模块
-
依赖关系的手动分叉
dep
工具
Go 团队——深知拥有多个用于管理依赖关系的竞争性工具可能会导致 Go 生态系统的碎片化并阻碍 Go 社区的成长——决定组建一个委员会并制定一份官方规范文档,详细说明了关于 Go 包依赖关系管理的未来发展方式。dep
工具^([2])是第一个符合已发布规范的工具。它大约在 2017 年开始,作为一个官方实验提供给升级到 Go 1.9 的用户。
dep 工具为各种操作系统提供了预编译的二进制文件;然而,通过运行 go get -u github.com/golang/dep/cmd/dep
从源代码构建它可能更容易一些。当你第一次想要在你的项目中使用 dep 工具时,你需要在项目的根目录下运行 dep init
来初始化 dep 工具的状态。除非你的导入图浅且规模小,否则这一步会花费一些时间,因为 dep 会执行以下操作:
-
识别所有导入的包、它们的传递依赖以及它们是否也使用 dep。
-
选择依赖图中的每个节点的最高可能版本。
-
将选定的包下载到位于项目根目录中的
vendor
文件夹。Dep 还会在本地缓存下载的包在$GOPATH/pkg/dep/sources
,以加快其他可能也使用 dep 的项目的依赖项查找。
如果没有依赖项使用 dep,则所选版本简单地是每个依赖项在 $GOPATH
中出现的当前版本。当一些(或所有)导入的依赖项也使用 dep 时,事情会变得更有趣。在这种情况下,dep 将每个 dep 启用包请求的版本视为约束,然后将这些约束输入到与 dep 工具捆绑的约束求解器引擎中。
约束求解器,如 dep 工具所使用的,将输入约束列表转换为布尔可满足性问题(SAT)并尝试识别一个解决方案(如果存在)。SAT 问题通常表示为复杂的布尔表达式;求解器的任务是找到表达式变量的正确组合,以便表达式评估为 TRUE
。
例如,给定表达式 ((A and B) or C) and not D
,以下是 SAT 可能建议的总解决方案的一个子集:
Solution | A | B | C | D |
---|---|---|---|---|
1 | TRUE | TRUE | FALSE | FALSE |
2 | FALSE | FALSE | TRUE | FALSE |
3 | TRUE | FALSE | TRUE | FALSE |
SAT 求解是第一个被证明为 NP 完全的问题之一。多年来,已经提出了几个算法,可以扩展到更大的 SAT 问题并在合理的时间内提供解决方案。dep 工具使用的特定 SAT 求解器实现基于 conflict-driven clause learning(CDCL)算法的一个变体,该算法已被调整以适用于 Go 包管理用例。如果你对此感兴趣,可以查看其实现,该实现位于 github.com/golang/dep/gps 包中。
dep 约束求解器的输出是所有依赖项中最高可能支持的版本。dep 工具在项目的根目录中创建两个基于文本的文件,用户必须将其提交到他们的版本控制系统中:Gopkg.toml
和Gopkg.lock
。为了加快 CI 构建,用户也可以可选地将填充的vendor
文件夹提交到版本控制。或者,假设Gopkg.toml
和Gopkg.lock
都可用,可以通过运行dep ensure -vendor-only
来动态填充供应商文件夹。
Gopkg.toml 文件
Gopkg.toml
文件作为控制 dep 工具行为的清单。dep init
调用将分析项目的导入图并生成一个包含初始约束集的Gopkg.toml
文件。从那时起,每当需要更新约束(通常是为了提高最低支持的版本)时,用户需要手动修改生成的Gopkg.toml
文件。
那么Gopkg.toml
文件的内容是什么样的呢?Gopkg.toml
文件由一系列块或节组成。每个节包含 dep 支持的规则类型之一。最常用的规则类型如下:
-
约束,指定兼容依赖项版本的范围
-
覆盖,当 dep 工具无法自动找到满足多个
Gopkg.toml
文件指定的聚合约束集的版本时,可以强制使用特定的包版本
对于 dep 工具识别的支持的规则类型完整列表,您可以参考Gopkg.toml
格式规范文档^([8])。以下示例定义了一个约束,指示 go dep 从包的 GitHub 存储库的master
分支获取包源代码:
[[constraint]]
name = "github.com/sirupsen/logrus"
branch = "master"
或者,除了branch
之外,约束规则可以包括以下两个关键字之一:revision
或version
。
revision
关键字允许将包依赖项固定到特定的提交标识符(例如,Git SHA)。它存在是为了兼容性目的,强烈建议 dep 用户除非没有更好的方式来描述版本,否则避免使用它。
另一方面,version
关键字在允许我们针对特定的 VCS 标签或语义版本范围方面更加灵活。以下表格列出了 dep 在处理基于版本的约束时理解的运算符。如果版本字符串不包含运算符,dep 工具将像使用了连字符(^
)运算符一样工作。例如,dep 将版本1.2.5
解释为如果指定了以下约束:>= 1.2.5 and < 1.3
。
运算符 | 描述 | 示例 | 约束解释 |
---|---|---|---|
= | 等于 | "=1.2.4" | 选择版本 1.2.4 |
!= | 不等于 | "!=0.1" | 排除版本 0.1 |
> | 大于 | ">1.2" | 新于 1.3.0 的版本 |
< | 小于 | "<2.0" | 低于 2.0.0 的版本 |
- | 文字范围 | "1.2-1.4" | 版本 >= 1.2 且 <= 1.4 |
~ | 小范围 | "~1.2.5" | 版本 >= 1.2.5 且 < 1.3 |
^ | 主范围 | "~1.2.5" | 版本 >= 1.2.5 且 < 2 |
在处理 Gopkg.toml
文件时,你可能会遇到另一个非常有用的关键字,那就是 source
关键字。dep 工具的默认行为是从与约束中指定的包名匹配的仓库中获取包源。
然而,在某些情况下,我们可能希望从不同的位置拉取包。这种情况可能发生在我们分叉了导入的包,推送了一些实验性更改,并想在导入原始包的代码库中尝试这些更改。为了演示这一点,让我们编辑前一个示例中的 Gopkg.toml
文件,并让它从 github.com/achilleasa/logrus
而不是 github.com/sirupsen/logrus
拉取 master 分支:
[[constraint]]
name = "github.com/sirupsen/logrus"
branch = "master"
# Pull the package sources from this alternative repository
source = "github.com/achilleasa/logrus"
如本节开头所述,Gopkg.toml
文件仅是一个用户可以随意更改的清单。为了使更改生效,我们需要运行 dep ensure
来执行以下操作:
-
检查代码中是否有任何新的依赖项
-
调用约束求解器计算每个依赖项所需的版本
-
确定哪个
vendor
文件夹中的包已过时并更新它们 -
更新
Gopkg.lock
文件
Gopkg.lock 文件
当运行 dep init
或 dep ensure
时,dep 工具生成的第二个文件称为 Gopkg.lock
。正如你可能从其扩展名中猜到的,它不是用户打算修改的东西。
Gopkg.lock
文件存储了 dep 工具的约束求解器输出的文本表示。更具体地说,它包括编译项目源代码所需的完整依赖项列表,包括直接和临时依赖项。每个依赖项都被固定到特定的提交标识符(例如,Git SHA),根据求解器的判断,该标识符满足 dep 工具提供的所有约束。
通过将 Gopkg.lock
文件提交到版本控制系统(VCS),Go 1.9+ 中的 dep 支持确保我们可以生成可重复构建,当然前提是所有引用的依赖项仍然可用。
Go 模块 – 前进之路
dep 工具的一个限制是它不允许我们在项目中使用一个包的多个主要版本,因为每个导入包的路径必须是唯一的。以下图表说明了 A 和 B 包依赖于相同版本 C 包的简单场景:
图 3:两个导入相同版本 C 包的包
假设现在我们想要测试驱动 C 包的新主要版本v2.0.0。这里的目的是逐步更新导入 C 的包,以便评估一切是否按预期工作。因此,我们将 B 中的Gopkg.toml
文件更新为引用 C 的新主要版本。我们的依赖关系树现在如下所示:
图 4:每个包导入 C 包的不同版本
这个更改对包 A 和 B 没有问题,因为它们的导入图是分离的;每个包引用 C 的不同版本。然后,我们决定引入一个新的包,比如 D,到这个场景中,它导入两者(如图所示)。现在我们遇到了问题!因为这两个包不能使用相同的导入路径,所以当我们尝试构建 D 时,Go 编译器会因错误而退出:
图 5:包 D 同时导入 A 和 B,它们依赖于 C 的不同主要版本。这导致冲突,阻止我们构建 D
要使前面的用例与 dep 工具一起工作,唯一的办法是将所有包(在这个例子中是 A 和 B)的约束(Gopkg.toml
)文件改为依赖于 C 的v2.0.0版本。不用说,这不是一个可以扩展到导入大量包的项目解决方案。考虑到这一点,Go 团队领导了一项倡议,旨在提出一个官方的 vendoring 解决方案,以支持上述场景。
Go 模块是在 Go 1.11 中作为实验性功能引入的,用户可以通过GO111MODULE
环境变量(例如,export GO111MODULE=on
)来启用它。在撰写本文时,当前的 Go 版本是 1.12.5,预计 Go 模块将在 Go 1.13 发布时最终确定。与 dep 工具相比,Go 模块的主要区别如下:
-
Go 模块完全集成了各种命令,如
go get
、go build
和go test
。 -
虽然 dep 工具选择一个包的最高公共版本,但 Go 模块选择最小可行的版本。
-
Go 模块支持多版本依赖。
-
Go 模块取消了 dep 工具使用的
vendor
文件夹。出于向后兼容的目的,Go 模块提供了一个额外的命令来填充vendor
文件夹:go mod vendor
。
以下简单的示例使用流行的go-yaml
包从标准输入读取 YAML 流并将其输出为 Go 映射:
package main
import (
"fmt"
"os"
"github.com/go-yaml/yaml"
)
func main() {
var data map[string]interface{}
if err := yaml.NewDecoder(os.Stdin).Decode(&data); err == nil {
fmt.Printf("%v\n", data)
}
}
要开始使用 Go 模块,我们首先需要在包含前面示例的文件夹中运行go mod init parser
来声明一个新的 Go 模块。这将生成一个名为go.mod
的文件。它的初始内容看起来相当无聊:
module parser
go 1.12
当我们尝试运行go build
等命令时,真正的魔法发生了:
$ go build
go: finding github.com/go-yaml/yaml v2.1.0+incompatible
go: downloading github.com/go-yaml/yaml v2.1.0+incompatible
go: extracting github.com/go-yaml/yaml v2.1.0+incompatible
正如你所见,Go 意识到我们需要获取一个新的依赖项,因此它试图确定go-yaml
包的当前版本,并将其解析为v2.1.0
。然后,它继续下载该包并将其缓存到$GOPATH/pkg/mod
目录下。
如果你列出项目的文件夹内容,你会注意到一个名为go.sum
的新文件。此文件存储已下载依赖项的加密哈希值,并在确保包内容在构建之间未被修改(即,包维护者强制推送了一些更改,覆盖了先前的版本)方面提供保护;这是一个在追求可重复构建时的非常有用的功能。
go.mod
和go.sum
文件的作用与 dep 工具使用的Gopkg.toml
和Gopkg.lock
文件相同,并且它们也需要提交到你的版本控制系统。
每当添加一个新的依赖项时,都会在go.mod
文件中添加一行。在这种情况下,添加的行是require github.com/go-yaml/yaml v2.1.0+incompatible
。go.mod
文件中的每一行require
定义了特定依赖项的最低支持版本。因此,从我们的模块角度来看,go-yaml/yaml
包的v2.1.0
是构建模块的最低版本要求。即使有新版本可用,Go 也始终使用这个特定的版本,除非我们运行以下命令之一:
-
go get -u
:升级到最新的次要或补丁版本 -
go get -u=patch
:升级到最新的补丁版本 -
go get package-name@version
:强制指定包的指定版本
现在我们已经对 Go 模块的工作原理有了基本的了解,让我们回顾一下我们的初始用例:我们如何在代码库中使用同一包的两个不同主要版本?正如我之前提到的,Go 导入路径必须是唯一的;这是不可更改的,不能被覆盖。
require
行中的+incompatible
后缀表示,尽管这个包定义了一个有效的语义版本,但它并没有通过定义自己的go.mod
文件来主动选择使用 Go 模块。然而,如果将来出现一个新版本(比如 v4)并提供了一个go.mod
文件,Go 模块将允许我们通过称为语义导入版本控制的机制导入它。用简单的话来说,语义导入路径只是带有附加版本后缀的常规导入路径。后缀的添加为包创建了一个唯一的路径,并有效地允许我们在同一文件中导入和使用多个版本的包:
import (
"github.com/go-yaml/yaml" // V2.1.0
v4 "github.com/go-yaml/yaml/v4" // The V4 version of the package.
)
这就结束了我们对 Go 模块的简要游览。关于 Go 模块扩展支持的所有操作和模式的深入探讨超出了本书的范围;然而,如果你对学习更多关于使用 Go 模块的信息感兴趣,你可以在 Golang 博客上浏览相关的文章^([6])。
分叉包
在我们拥有 dep 工具和 Go modules 的情况下,我们为什么还需要手动分支我们依赖的任何包呢?在我们回答这个问题之前,让我首先详细说明这个过程是如何工作的。
首先,我们需要分支我们感兴趣的依赖项。如果包源代码在 GitHub、GitLab 或 BitBucket 等平台上可用,那么分支包就像访问存储库页面并点击一个按钮一样简单(参见以下截图);否则,我们就需要依赖我们首选的 VCS 提供的功能,将依赖项的副本持久化到我们控制的位置:
图 6:在 GitHub、GitLab 或 BitBucket 等平台上分支包存储库就像点击一个按钮一样简单
在分支存储库之后,我们需要扫描代码库并替换原始包的导入,使其指向我们的分支版本。当然,一个更好的选择是使用 dep 工具提供的逃生舱口来覆盖包依赖项的源。在后一种情况下,我们就不需要修改代码中的任何导入语句。
这又带我们回到了最初的问题:为什么一开始就要进行分支?当为处理敏感数据的公司工作,例如在金融科技或医疗保健领域运营的公司时,拥有一个内部安全团队是相当常见的,这个团队必须审计每个导入的依赖项,以检查潜在的安全漏洞,然后工程团队才能在他们的代码中使用它。
对一个包进行全面的安全审计是一个相当耗时的过程;从逻辑上讲,每次新版本发布时都从头开始审计每个包既不可行也不经济。因此,安全团队通过分支包、进行全面审计,然后验证和选择任何上游更改来分摊初始审计成本。
摘要
在本章中,我们讨论了为什么需要使用版本控制,这不仅包括我们代码导入的包,还包括我们作为软件工程师编写的代码本身。然后我们定义了语义版本控制的概念以及何时需要增加语义版本中的每个组件。
本章的重点是介绍作为确保项目可重复构建的主要机制的 vendoring。在详细阐述了 vendoring 作为一种流程的优缺点之后,我们考察了 Go 生态系统中的 vendoring 现状,并简要介绍了工程师应使用的最先进工具(dep 和 Go modules)来管理他们的包依赖。
当然,随着我们的代码库的发展以及我们导入的版本要求随时间变化,我们很可能在某个时候,我们依赖的某个包的新版本会破坏我们的代码。显然,我们希望尽可能早地捕捉到这样的回归。实现这一目标的一种方法,也是下一章的中心主题,就是建立一个坚实的测试基础设施。
问题
-
软件版本控制为什么很重要?
-
语义版本看起来是什么样的,以及它的各个组成部分何时会增加?
-
在以下情况下,您会增加一个包的语义版本中的哪个组件?
-
引入了一个新的 API。
-
修改了现有的 API,并添加了一个新的、必需的参数。
-
提交了一个安全漏洞的修复。
-
-
除了语义版本控制之外,我们还可以使用哪些替代版本控制方案?
-
vendoring 的优缺点是什么?
-
列举一些 dep 工具和 Go 模块之间的不同之处。
进一步阅读
-
Cook, Stephen A.,《定理证明过程复杂性》,第三届年度 ACM 理论计算研讨会论文集,STOC '71。纽约,纽约,美国,ACM,1971 年,第 151-158 页
-
dep:Go 的依赖管理工具:
github.com/golang/dep
-
godep:
github.com/tools/godep
-
Golang 博客:使用 Go 模块:
blog.golang.org/using-go-modules
-
Gopkg.in
:Go 语言的稳定 API:labix.org/gopkg.in
-
Gopkg.toml
格式规范:golang.github.io/dep/docs/Gopkg.toml.html
-
govendor:
github.com/kardianos/govendor
-
Plus codes:为没有自己的街道地址的位置提供的简短代码:
plus.codes/
-
语义版本控制 2.0.0:
semver.org/
-
Silva, João P. Marques;Lynce, Inês;Malik, Sharad,Biere, A.;Heule, M.;Maaren, H. van;Walsh, T.(主编),《冲突驱动的子句学习 SAT 求解器》,可满足性手册,人工智能应用前沿,第 185 卷:IOS Press,2009 年,ISBN 978-1-58603-929-5 (
www.worldcat.org/title/handbook-of-satisfiability/oclc/840409693
),第 131-153 页
第四章:测试的艺术
"程序测试可以用来显示错误的存在,但永远不能显示它们的缺失!"
- 艾德加·迪杰斯特拉
软件系统注定会随着时间的推移而增长和演变。开源或闭源软件项目有一个共同点:随着在代码库上工作的工程师数量的增加,它们的复杂性似乎呈上升趋势。因此,拥有一个全面的代码库测试集至关重要。本章深入探讨了可以应用于 Go 项目的不同类型的测试。
本章将涵盖以下主题:
-
识别在编写单元测试时可以用作测试代码内部使用对象的替代品的高级原语,如桩(stubs)、模拟(mocks)、间谍(spies)和伪造对象(fake objects)
-
比较黑盒和白盒测试:两者的区别以及为什么两者都是编写全面测试套件所必需的
-
集成测试和功能(端到端)测试之间的区别
-
高级测试概念:烟雾测试,以及我个人最喜欢的一种测试 – 混乱测试!
-
在 Go 中编写干净测试的技巧和窍门以及需要避免的陷阱
技术要求
本章讨论的主题的完整代码已发布到本书的 GitHub 仓库中的Chapter04
文件夹下。
您可以通过访问github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
来获取本书的 GitHub 仓库。
为了让您尽快开始,每个示例项目都包含一个 makefile,它定义了以下目标集:
Makefile 目标 | 描述 |
---|---|
deps |
安装任何必需的依赖项 |
test |
运行所有测试并报告覆盖率 |
lint |
检查 lint 错误 |
与本书的所有章节一样,您需要一个相当新的 Go 版本,您可以在golang.org/dl/
下载它。
单元测试
根据定义,单元是我们能测试的最小可能的代码块。在 Go 编程的上下文中,这通常是一个单个函数。然而,根据我们在前几章中探讨的 SOLID 设计原则,每个Go 包也可以被视为一个独立的单元,并以此进行测试。
术语单元测试指的是对应用程序的每个单元进行隔离测试的过程,以验证其行为是否符合特定的规范集。
在本节中,我们将深入了解我们可用的不同单元测试方法(黑盒与白盒测试)。我们还将检查使我们的代码更容易进行单元测试的策略,以及内置的 Go 测试包以及旨在使编写测试更加流畅的第三方包。
模拟、桩、伪造和间谍 – 共同点和不同点
在深入探讨单元测试背后的概念之前,我们需要讨论和澄清我们将在接下来的部分中使用的某些术语。虽然这些术语已经存在多年,但软件工程师在编写测试时偶尔会将它们混淆。一个很好的例子是,当工程师交替使用mock和stub这两个术语时,这种混淆变得明显。
为了建立一些共同的基础,以便进行富有成效的讨论,并消除关于这个术语的任何混淆,让我们根据 Gerard Meszaros 在《XUnit Test Patterns: Refactoring Test Code》一书中对测试模式的概述,来检查每个术语的定义。
stubs 和 spies!
stub是我们可以在测试中使用的最简单的测试模式。stubs 通常实现特定的接口,不包含任何实际逻辑;它们只是提供固定答案以响应测试过程中进行的调用。
让我们剖析一个简短的代码示例,说明我们如何有效地使用 stub 的概念进行测试。Chapter04/captcha
包实现了 CAPTCHA 测试背后的验证逻辑。
CAPTCHA 是一种相当直接的方法,可以确定系统是在与人类用户还是另一个程序交互。这是通过显示一个随机、通常带有噪声的图像,其中包含扭曲的字母和数字序列,然后提示用户输入图像的文本内容来实现的。
作为 SOLID 原则的大粉丝,我选择定义两个接口,Challenger
和Prompter
,以抽象 CAPTCHA 图像生成和用户提示的实现。毕竟,有大量的不同方法可以生成 CAPTCHA 图像:我们可以从一组固定的图像中随机选择一个,使用神经网络生成,或者甚至调用第三方图像生成服务。同样,我们实际上提示用户回答的方式也是如此。以下是这两个接口的定义:
// Challenger is implemented by objects that can generate CAPTCHA image
// challenges.
type Challenger interface {
Challenge() (img image.Image, imgText string)
}
// Prompter is implemented by objects that display a CAPTCHA image to the
// user, ask them to type their contents and return back their response.
type Prompter interface {
Prompt(img image.Image) string
}
最后,实际的业务逻辑并不真正关心 CAPTCHA 图像或用户的答案是如何获得的。我们所需做的就是获取一个挑战,提示用户,然后执行一个简单的字符串比较操作,如下所示:
func ChallengeUser(c Challenger, p Prompter) bool {
img, expAnswer := c.Challenge()
userAnswer := p.Prompt(img)
if subtle.ConstantTimeEq(int32(len(expAnswer)), int32(len(userAnswer)))
== 0 {
return false
}
return subtle.ConstantTimeCompare([]byte(userAnswer), []byte(expAnswer)) == 1
}
上述代码的一个有趣之处,至少在我看来,是它使用常数时间字符串比较,而不是使用内置的相等运算符来比较预期的答案和用户的响应。
常数时间比较检查是安全相关代码中的一种常见模式,因为它可以防止信息泄露,而信息泄露可能会被对手利用来执行时间侧信道攻击。当执行时间攻击时,攻击者向系统提供可变长度的输入,然后通过统计分析收集关于系统实现的信息,这些信息基于执行特定操作所需的时间。
想象一下,在前面的 CAPTCHA 场景中,如果我们使用了一个简单的字符串比较,本质上是比较每个字符,并在第一个不匹配时返回 false,攻击者会如何通过时间攻击缓慢地暴力破解答案:
-
首先按照
$a
模式提供答案,并测量获取响应所需的时间。$
符号是所有可能的字母数字字符的占位符。本质上,我们尝试组合,如aa
、ba
等。 -
一旦我们确定了一个比其他操作耗时更长的操作,我们就可以假设那个特定的
$
值(比如,4
)是 CAPTCHA 答案的预期第一个字符!这个操作耗时较长的原因是字符串比较代码匹配了第一个字符,然后尝试匹配下一个字符,而不是立即返回,就像在出现不匹配时那样。 -
继续提供答案的过程,但这次使用
4$a
模式,并不断扩展模式,直到可以恢复预期的 CAPTCHA 答案。
为了测试 ChallengeUser
函数,我们需要为它的每个参数创建一个存根。这将使我们能够完全控制比较业务逻辑的输入。以下是一些可能看起来像的存根:
type stubChallenger string
func (c stubChallenger) Challenge() (image.Image, string) {
return image.NewRGBA(image.Rect(0, 0, 100, 100)), string(c)
}
type stubPrompter string
func (p stubPrompter) Prompt(_ image.Image) string {
return string(p)
}
很简单,对吧?正如你所见,存根没有任何逻辑;它们只是返回一个预设的答案。有了这两个存根,我们可以编写两个测试函数来测试匹配/不匹配代码路径:
func TestChallengeUserSuccess(t *testing.T) {
got := captcha.ChallengeUser(stubChallenger("42"), stubPrompter("42"))
if got != true {
t.Fatal("expected ChallengeUser to return true")
}
}
func TestChallengeUserFail(t *testing.T) {
got := captcha.ChallengeUser(stubChallenger("lorem ipsum"), stubPrompter("42"))
if got != false {
t.Fatal("expected ChallengeUser to return false")
}
}
既然我们已经对存根的工作原理有了大致的了解,让我们看看另一种有用的测试模式:间谍!一个间谍不过是一个记录所有对其调用的方法的详细日志的存根。对于每次方法调用,间谍记录调用者提供的参数,并使它们可供测试代码检查。
当然,当谈到 Go 语言时,最受欢迎的间谍实现是来自 net/http/httptest
包的古老而值得尊敬的 ResponseRecorder
类型。ResponseRecorder
实现了 http.ResponseWriter
接口,可以用来测试 HTTP 请求处理代码,而无需启动实际的 HTTP 服务器。然而,HTTP 服务器测试并不那么有趣;让我们看看一个稍微更有趣的例子。Chapter04/chat
包含一个简单的聊天室实现,非常适合应用间谍测试模式。以下是对 Room
类型及其构造函数的定义:
// Publisher is implemented by objects that can send a message to a user.
type Publisher interface {
Publish(userID, message string) error
}
type Room struct {
pub Publisher
mu sync.RWMutex
users []string
}
// NewRoom creates a new chat root instance that used pub to broadcast
// messages.
func NewRoom(pub Publisher) *Room {
return &Room{pub: pub}
}
正如你所见,Room
包含一个由 NewRoom
构造函数传入的值初始化的 Publisher
实例。Room
类型公开的其他有趣的方法(这里未展示,但在本书的 GitHub 仓库中可用)包括 AddUser
和 Broadcast
。第一个方法向房间添加新用户,而后者可以用来向房间内所有当前用户广播特定消息。
在我们编写实际的测试代码之前,让我们创建一个实现Publisher
接口的 spy 实例,并记录任何发布的消息:
type entry struct {
user string
message string
}
type spyPublisher struct {
published []entry
}
func (p *spyPublisher) Publish(user, message string) error {
p.published = append(p.published, entry{user: user, message: message})
return nil
}
在先前的 spy 实现中,每次调用Publish
方法时,stub 都会将一个{user, message}
元组追加到published
切片中。当我们的 spy 准备好使用时,编写实际的测试就变得轻而易举:
func TestChatRoomBroadcast(t *testing.T) {
pub := new(spyPublisher)
room := chat.NewRoom(pub)
room.AddUser("bob")
room.AddUser("alice")
_ = room.Broadcast("hi")
exp := []entry{
{user: "bob", message: "hi"},
{user: "alice", message: "hi"},
}
if got := pub.published; !reflect.DeepEqual(got, exp) {
t.Fatalf("expected the following messages:\n%#+v\ngot:\n%#+v", exp, got)
}
}
此测试场景涉及创建一个新的房间,向其中添加一些用户,并向所有加入房间的用户广播消息。测试运行器的任务是验证对Broadcast
的调用实际上确实向所有用户广播了消息。我们可以通过检查我们的注入 spy 记录的消息列表来实现这一点。
Mocks
你可以将mocks视为增强版的 stubs!与 stub 展示的固定行为相反,mocks 允许我们以声明性的方式指定 mock 期望接收的调用列表,以及它们的顺序和期望的参数值。此外,mocks 允许我们根据方法调用者提供的参数元组为每个方法调用指定不同的返回值。
考虑到所有因素,mocks 是我们可用的非常强大的原始工具,用于编写高级测试。然而,为测试中想要替换的每个对象从头开始构建 mock 是一个相当繁琐的任务。这就是为什么通常更好的做法是使用外部工具和代码生成来自动化创建测试所需的 mock。
介绍 gomock
在本节中,我们将介绍 gomock
^([4]),这是一个非常流行的 Go 语言 mocking 框架,它利用反射和代码生成自动根据 Go 接口定义创建 mock。
该框架及其支持工具可以通过运行以下命令进行安装:
$ go get github.com/golang/mock/gomock
$ go install github.com/golang/mock/mockgen
mockgen
工具负责分析单个 Go 文件或整个包,并为其中定义的所有(或特定)接口生成 mock。它支持两种操作模式:
-
源代码扫描:我们将 Gi 文件传递给
mockgen
,然后对其进行解析以检测接口定义。 -
反射辅助模式:我们将一个包和接口列表传递给
mockgen
。该工具使用 Go 反射包分析每个接口的结构。
gomock
为通过mockgen
工具创建的 mock 实例指定预期行为提供了一个简单且简洁的 API。要访问此 API,你需要创建一个 mock 的新实例并调用其奇特的EXPECT
方法。EXPECT
返回一个特殊对象(在gomock
术语中称为recorder),为我们提供了声明针对 mock 执行的方法调用行为的方式。
要注册新的期望,我们需要执行以下操作:
-
声明我们期望被调用的方法及其参数。
-
指定当模拟调用带有指定参数集的方法时,模拟应该返回给调用者的返回值(或值)。
-
可选地,我们需要指定调用者预期调用的方法次数。
为了进一步简化测试的创建,mockgen
将返回的记录器实例填充了与我们要模拟的接口名称匹配的方法。我们只需要在记录器对象上调用这些方法,并将模拟期望从调用者接收到的参数作为interface{}
值的可变列表指定。当定义预期的参数集时,你基本上有两个选择:
-
指定一个与方法签名中类型匹配的值(例如,如果参数是
string
类型,则为foo
)。gomock
只有在输入参数、值严格等于预期中指定的值时,才会匹配到期望的调用。 -
提供一个实现了
gomock.Matcher
接口的值。在这种情况下,gomock
将委托比较给匹配器本身。这个强大的功能让我们能够模拟任何可以想到的自定义测试谓词。gomock
已经定义了一些方便的内置匹配器,我们可以在测试中使用:Any
、AssignableToTypeOf
、Nil
和Not
。
在指定了预期的方法调用及其参数后,gomock
将返回一个期望对象,该对象提供辅助方法,以便我们可以进一步配置预期的行为。例如,我们可以使用期望对象的Return
方法来定义一旦期望匹配,将返回给调用者的值集。还重要的是要注意,除非我们明确地指定期望的模拟方法调用次数,否则gomock
将假设该方法只能调用一次,如果方法根本未调用或多次调用,将触发测试失败。如果您需要更精细地控制期望调用次数,返回的期望对象提供了以下一组辅助方法:Times
、MinTimes
和MaxTimes
。
在接下来的两节中,我们将分析一个示例项目,并逐步讲解如何为它编写一个完整的、基于模拟的单元测试。
探索我们想要编写测试的项目细节
为了演示在我们的代码中创建和使用模拟,我们将使用来自Chapter04/dependency
包的示例代码。这个包定义了一个Collector
类型,其目的是为给定的项目 ID 组装一组直接和间接(传递)依赖。为了使事情更有趣,让我们假设每个依赖项可以属于以下两个类别之一:
-
我们需要包含(例如,一个图像文件)或预留(例如,一块内存或磁盘空间)的资源
-
另一个拥有其自己依赖集的项目
要获取直接依赖及其相应类型的列表,Collector
依赖将执行一系列调用到外部服务的操作。为了确保实现更容易测试,我们不会与外部服务的具体客户端实例一起工作。相反,我们将定义一个接口,其中包含访问服务所需的方法集,并在我们的测试代码中注入一个满足该接口的模拟。以下是对API
接口的以下定义:
type API interface {
// ListDependencies returns the list of direct dependency IDs for a
// particular project ID or an error if a non-project ID argument is
// provided.
ListDependencies(projectID string) ([]string, error)
// DependencyType returns the type of a particular dependency.
DependencyType(dependencyID string) (DepType, error)
}
要创建一个新的Collector
实例,我们需要调用NewCollector
构造函数(未显示)并提供一个 API 实例作为参数。然后,可以通过调用AllDependencies
方法来获取特定项目 ID 的独特依赖集。这是一个相当简短的方法,其完整实现如下:
func (c *Collector) AllDependencies(projectID string) ([]string, error) {
ctx := newDepContext(projectID)
for ctx.HasUncheckedDeps() {
projectID = ctx.NextUncheckedDep()
projectDeps, err := c.api.ListDependencies(projectID)
if err != nil {
return nil, xerrors.Errorf("unable to list dependencies for project %q: %w", projectID, err)
}
if err = c.scanProjectDependencies(ctx, projectDeps); err != nil {
return nil, err
}
}
return ctx.depList, nil
}
上述代码块实际上是一个伪装的广度优先搜索(BFS)算法!ctx
变量存储一个辅助结构,其中包含以下内容:
-
一个队列,其条目对应于我们尚未访问的依赖集(资源或项目)。当我们访问项目依赖图中的节点时,任何新发现的依赖将被追加到队列的末尾,以便在未来的搜索循环迭代中访问。
-
一旦队列中的所有条目都已被处理,就会返回给调用者一组独特的已发现依赖 ID。
为了初始化搜索,最初,我们将传递给方法作为参数的projectID
值填充到队列中。在每次循环迭代中,我们取出一个未检查的依赖 ID,并调用ListDependencies
API 调用以获取其所有直接依赖的列表。然后,获取到的依赖 ID 列表被传递给scanProjectDependencies
方法,该方法的角色是检查依赖列表并更新ctx
变量的内容。该方法实现相当直接:
func (c *Collector) scanProjectDependencies(ctx *depCtx, depList []string) error {
for _, depID := range depList {
if ctx.AlreadyChecked(depID) {
continue
}
ctx.AddToDepList(depID)
depType, err := c.api.DependencyType(depID)
if err != nil {
return xerrors.Errorf("unable to get dependency type for id %q: %w", depID, err)
}
if depType == DepTypeProject {
ctx.AddToUncheckedList(depID)
}
}
return nil
}
在迭代依赖列表时,实现会自动跳过任何已经访问过的依赖。另一方面,新的依赖 ID 通过调用AddToDepList
方法被追加到ctx
变量跟踪的唯一依赖集。
正如我们之前提到的,如果依赖对应于另一个项目,我们需要递归地访问其自己的依赖并将它们作为传递性依赖添加到我们的集合中。API
接口中的DependencyType
方法为我们提供了通过其 ID 查询依赖类型的方式。如果依赖确实指向一个项目,我们通过调用AddToUncheckedList
方法将其追加到未访问依赖队列的末尾。最后一步保证了依赖最终将由AllDependencies
方法内部的搜索循环处理。
利用 gomock 编写应用程序的单元测试
既然我们已经了解了示例项目的实现细节,我们可以继续编写一个简单的基于模拟的单元测试。在我们开始之前,我们需要为API
接口创建一个模拟。这可以通过使用以下选项调用mockgen
工具来实现:
$ mockgen \
-destination mock/dependency.go \
github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang/Chapter04/dependency \
API
前一个命令执行以下操作:
-
在
dependency
包中创建一个mock
文件夹 -
生成一个名为
dependency.go
的文件,其中包含模拟API
接口的适当代码,并将其放置在mock
文件夹中
为了避免您手动输入前面的命令,Chapter04/dependency
文件夹中的Makefile
包含了一个预定义的目标,用于重建在此示例中使用的模拟。您需要做的只是切换到包含示例代码的文件夹,并运行make mocks
。
到目前为止,一切顺利。那么我们如何在测试中使用模拟呢?首先,我们需要创建一个gomock
控制器并将其与 Go 标准库通过测试函数传递给我们的testing.T
实例关联起来。控制器实例定义了一个Finish
方法,我们的代码必须在返回测试之前始终运行此方法(例如,通过defer
语句)。此方法检查每个模拟对象上注册的期望,如果未满足,则自动失败测试。以下是我们的测试函数前缀可能的样子:
// Create a controller to manage all our mock objects and make sure
// that all expectations were met before completing the test
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// Obtain a mock instance that implements API and associate it with the controller.
api := mock_dependency.NewMockAPI(ctrl)
此特定单元测试的目的是验证对AllDependencies
方法的特定输入调用是否产生预期的依赖项 ID 列表。正如我们在上一节中看到的,AllDependencies
方法的实现使用外部提供的API
实例来检索每个依赖项的信息。鉴于我们的测试将向Collector
依赖项注入模拟的 API 实例,我们的测试代码必须声明对模拟的预期调用集。考虑以下代码块:
gomock.InOrder(
api.EXPECT().
ListDependencies("proj0").Return([]string{"proj1", "res1"}, nil),
api.EXPECT().
DependencyType("proj1").Return(dependency.DepTypeProject, nil),
api.EXPECT().
DependencyType("res1").Return(dependency.DepTypeResource, nil),
api.EXPECT().
ListDependencies("proj1").Return([]string{"res1", "res2"}, nil),
api.EXPECT().
DependencyType("res2").Return(dependency.DepTypeResource, nil),
)
在正常情况下,gomock
只会检查方法调用期望是否满足,而不管它们被调用的顺序如何。然而,如果一个测试依赖于一系列方法调用以特定顺序执行,它可以指定这一点给gomock
,通过使用带有有序期望列表的gomock.InOrder
辅助函数。这种特定模式可以在前面的代码片段中看到。
在设置好模拟期望后,我们可以通过引入必要的逻辑来连接所有组件,调用AllDependencies
方法,传入模拟所期望的输入(proj0
),并验证返回的输出是否与预定义的值("proj1", "res1", "res2"
)匹配,来完成我们的单元测试:
collector := dependency.NewCollector(api)
depList, err := collector.AllDependencies("proj0")
if err != nil {
t.Fatal(err)
}
if exp := []string{"proj1", "res1", "res2"}; !reflect.DeepEqual(depList, exp) {
t.Fatalf("expected dependency list to be:\n%v\ngot:\n%v", exp, depList)
}
这就结束了我们关于使用 gomock
加速基于模拟的测试编写的简短示例。作为一个有趣的学习活动,你可以尝试更改前面测试的预期输出,以便测试失败。然后,你可以逆向工作并尝试找出如何调整模拟期望,以便再次使测试通过。
伪造对象
与我们之前讨论的其他测试模式类似,伪造对象 也遵循一个特定的接口,这允许我们将它们注入到被测试的主题中。主要区别在于,伪造对象实际上包含一个完全工作的实现,其行为与它们打算替代的对象相匹配。
那么,有什么问题吗?伪造对象实现通常是针对运行测试进行优化的,因此它们并不打算在生产环境中使用。例如,我们可以为我们的测试提供一个内存中的键值存储实现,但我们的生产部署需要更好的可用性保证。
为了更好地理解伪造对象的工作原理,让我们看看 Chapter04/compute
包的内容。这个包导出一个名为 SumOfSquares
的函数,它对一个 32 位浮点数值的切片进行操作。该函数对切片中的每个元素进行平方,将结果相加,并返回它们的总和。请注意,我们在这里使用单个函数纯粹是为了演示目的;在现实世界的场景中,我们会将这个函数与其他类似函数组合,形成一个计算图,然后我们的实现将对其进行评估。
为了故意给这个特定的场景增加一些额外的复杂性,让我们假设传递给这个函数的输入切片通常包含一个非常大的数值数量。当然,仍然可以使用 CPU 来计算结果。不幸的是,依赖于这个功能的实际生产服务有一个相当严格的时间预算,因此使用 CPU 并不是一个选择。为此,我们决定通过将工作卸载到 GPU 来实现一个矢量化解决方案。
Device
接口描述了可以卸载到 GPU 的操作集:
type Device interface {
Square([]float32) []float32
Sum([]float32) float32
}
给定一个实现了 Device
的对象实例,我们可以定义 SumOfSquares
函数如下:
func SumOfSquares(c Device, in []float32) float32 {
sq := c.Square(in)
return c.Sum(sq)
}
这里没有什么太复杂的...唉,直到我们开始编写测试,我们才意识到,虽然我们通常运行生产代码的计算节点确实提供了强大的 GPU,但同样的话并不能适用于我们工程师本地使用的每台机器,或者每次创建新的拉取请求时运行我们的测试的 CI 环境。
显然,尽管我们的实际工作负载处理的是长输入,但我们的测试中并没有严格的相同要求;正如我们将在以下部分看到的那样,这是端到端测试的工作。因此,如果我们的测试运行时没有 GPU 可用,我们可以回退到 CPU 实现。这是一个很好的例子,说明模拟对象如何帮助我们。那么,让我们首先定义一个使用 CPU 进行所有计算的Device
实现:
type cpuComputeDevice struct{}
func (d cpuComputeDevice) Square(in []float32) []float32 {
for i := 0; i < len(in); i++ {
in[i] *= in[i]
}
return in
}
func (d cpuComputeDevice) Sum(in []float32) (sum float32) {
for _, v := range in {
sum += v
}
return sum
}
然后,我们的测试代码可以在 GPU 或 CPU 基于的实现之间动态切换,可能通过检查环境变量的值或作为测试参数传递的某些命令行标志:
func TestSumOfSquares(t *testing.T) {
var dev compute.Device
if os.Getenv("USE_GPU") != "" {
t.Log("using GPU device")
dev = gpu.NewDevice()
} else {
t.Log("using CPU device")
dev = cpuComputeDevice{}
}
// Generate deterministic sample data and return the expected sum
in, expSum := genTestData(1024)
if gotSum := compute.SumOfSquares(dev, in); gotSum != expSum {
t.Fatalf("expected SumOfSquares to return %f; got %f", expSum, gotSum)
}
}
通过使用一个模拟对象,我们可以在仍然提供这种能力的同时运行我们的测试,让那些有本地访问 GPU 的工程师使用基于 GPU 的实现来运行测试。成功!
黑盒测试与白盒测试在 Go 包中的应用——一个示例
黑盒测试和白盒测试是编写单元测试的两种不同方法。每种方法都有其自身的优点和目标。因此,我们不应将它们视为相互竞争的方法,而应将它们视为相互补充。那么,这两种测试类型之间主要的区别是什么?
黑盒测试是在假设我们测试的包的底层实现细节(也称为被测试对象(SUT))对我们,即测试者,是完全透明的(因此得名黑盒)的情况下进行的。结果,我们只能测试特定包的公共接口或行为,并确保它遵守其宣传的合同。
另一方面,白盒测试假设我们事先了解特定包的实现细节。这允许测试者要么为每个测试构建,以便在包内执行特定的代码路径,要么直接测试包的内部实现。
为了理解这两种方法之间的区别,让我们看看一个简短的例子。Chapter04/retail
包实现了一个名为PriceCalculator
的外观。
外观是一种软件设计模式,它通过一个简单的接口抽象了一个或多个软件组件的复杂性。
在基于微服务的设计背景下,外观模式允许我们透明地组合或聚合多个、专业的微服务中的数据,同时为外观客户端提供一个简单的 API 来访问它。
在这个特定的场景中,外观接收一个表示项目的 UUID 和一个表示我们感兴趣的日期的日期作为输入。然后,它与两个后端微服务通信,以检索有关项目价格和特定日期应用的增值税率的详细信息。最后,它将包含增值税的项目价格返回给外观的客户端。
外观背后的服务
在我们深入探讨价格计算器的内部工作原理之前,让我们花点时间检查这两个微服务依赖项是如何工作的;毕竟,我们将需要这些信息来编写我们的测试。
price
微服务提供了一个用于检索特定日期某项商品发布价格的 REST 端点。该服务以类似以下的 JSON 有效负载响应:
{
"price": 10.0,
"currency": "GBP"
}
在本例中的第二个微服务被称为 vat
,它也是 RESTful 的。它提供了一个用于检索特定日期适用的增值税率的端点。该服务以以下 JSON 有效负载响应:
{
"vat_rate": 0.29
}
如您所见,返回的 JSON 有效负载非常简单,因此我们的测试代码模拟它将是微不足道的。
编写黑盒测试
为了编写我们的黑盒测试,我们将首先检查 retail
包的 公共 接口。快速浏览 retail.go
文件揭示了 NewPriceCalculator
函数,该函数接收 price
和 vat
服务的 URL 作为参数,并返回一个 PriceCalculator
实例。计算器实例可以通过调用 PriceForItem
方法并传递商品的 UUID 作为参数来获取商品的增值税包含价格。另一方面,如果我们对获取过去特定日期的增值税包含商品价格感兴趣,我们可以调用 PriceForItemAtDate
方法,该方法也接受一个时间段参数。
黑盒测试将存在于名为 retail_test
的单独包中。$PACKAGE_test
命名约定,或多或少,是进行黑盒测试的标准方式,因为其名称本身暗示了正在测试的包,同时防止我们的测试代码访问正在测试的包的内部。
黑盒测试的一个缺点是我们需要模拟/存根任何测试代码所依赖的外部对象和/或服务。在这种情况下,我们需要为 price
和 vat
服务提供存根。幸运的是,与 Go 标准库一起提供的 net/http/httptest
包提供了一个方便的辅助函数,用于使用随机、未使用的端口启动本地 HTTPS 服务器。由于我们需要为我们的测试启动两个服务器,让我们创建一个小的辅助函数来完成这项工作:
func spinUpTestServer(t *testing.T, res map[string]interface{}) *httptest.Server {
encResponse, err := json.Marshal(res)
if err != nil {
t.Fatal(err)
}
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json")
if _, wErr := w.Write(encResponse); wErr != nil {
t.Fatal(wErr)
}
}))
}
这里没有什么太复杂的;spinUpTestServer
函数接收一个包含预期响应内容的映射,并返回一个服务器(我们的测试代码需要显式关闭),该服务器始终以 JSON 格式响应有效负载。有了这个辅助函数,设置我们服务的存根变得非常容易:
// t is a testing.T instance
priceSvc := spinUpTestServer(t, map[string]interface{}{
"price": 10.0,
})
defer priceSvc.Close()
vatSvc := spinUpTestServer(t, map[string]interface{}{
"vat_rate": 0.29,
})
defer vatSvc.Close()
因此,我们现在需要做的就是调用NewPriceCalculator
构造函数并传递两个假服务器的地址。等等!如果这些服务器总是监听随机端口,我们如何知道要将哪些地址传递给构造函数?httptest
包提供的Server
实现的一个特别方便的特性是它通过一个名为URL
的公共属性公开服务器监听传入连接的端点。以下是我们的黑盒测试其余部分的样子:
pc := retail.NewPriceCalculator(priceSvc.URL, vatSvc.URL)
got, err := pc.PriceForItem("1b6f8e0f-bbda-4f4e-ade5-aa1abcc99586")
if err != nil {
t.Fatal(err)
}
if exp := 12.9; got != exp {
t.Fatalf("expected calculated retail price to be %f; got %f", exp, got)
}
如我们之前提到的,前面的代码片段位于不同的包中,因此我们的测试必须导入测试包并使用retail
选择器访问其公共内容。
我们可以添加一些额外的测试,例如,验证当其中一个或两个服务返回错误时PriceForItem
的行为,但仅使用黑盒测试我们就无法测试到这里!让我们运行我们的测试并看看我们能获得什么样的覆盖率:
图 1:仅运行黑盒测试
这点不错!然而,如果我们需要进一步提高测试覆盖率指标,我们需要投入一些时间并想出一些白盒测试。
通过白盒测试提高代码覆盖率
与我们在上一节中编写的测试相比,一个主要的不同之处在于新的一组测试将位于我们正在测试的包的同一包中。为了区分我们之前编写的黑盒测试并提示其他工程师查看测试代码,我们将新测试放在一个名为retail_internal_test.go
的文件中。
现在,是时候揭开retail
包的实现细节了!包的公共 API 始终是我们探索工作的好起点。一个有效的策略是识别每个导出的函数,然后(在心中)跟随其调用图来定位其他候选函数/方法,我们可以通过我们的白盒测试来执行它们。在不太可能的情况下,如果包没有导出任何函数,我们可以将注意力转移到其他导出符号上,例如结构体或接口。例如,以下是retail
包中PriceCalculator
结构体的定义:
type PriceCalculator struct {
priceSvc svcCaller
vatSvc svcCaller
}
如我们所见,该结构体包含两个svcCaller
类型的私有字段,其名称清楚地表明它们以某种方式与门面需要调用的两个服务相关联。如果我们继续浏览代码,我们将发现svcCaller
实际上是一个接口:
type svcCaller interface {
Call(req map[string]interface{}) (io.ReadCloser, error)
}
Call
方法接收一个请求参数映射并返回一个响应流作为io.ReadCloser
。从测试编写者的角度来看,这种抽象的使用应该让我们感到非常高兴,因为它为我们提供了轻松模拟对两个实际服务调用的一条途径!
如我们在上一节中看到的,PriceCalculator
类型公开的公共 API 由两个方法组成:
-
PriceForItem
,它返回特定时间点的物品价格 -
PriceForItemAtDate
,它返回特定时间点的物品价格
由于PriceForItem
方法是一个简单的包装器,它使用当前日期/时间作为参数调用PriceForItemAtDate
,我们将重点关注后者。PriceForItemAtDate
的实现如下所示:
func (pc *PriceCalculator) PriceForItemAtDate(itemUUID string, date time.Time) (float64, error) {
priceRes := struct {
Price float64 `json:"price"`
}{}
vatRes := struct {
Rate float64 `json:"vat_rate"`
}{}
req := map[string]interface{}{"item": itemUUID, "period": date}
if err := pc.callService(pc.priceSvc, req, &priceRes); err != nil {
return 0, xerrors.Errorf("unable to retrieve item price: %w", err)
}
req = map[string]interface{}{"period": date}
if err := pc.callService(pc.vatSvc, req, &vatRes); err != nil {
return 0, xerrors.Errorf("unable to retrieve vat percent: %w", err)
}
return vatInclusivePrice(priceRes.Price, vatRes.Rate), nil
}
上一段代码块使用了名为callService
的辅助函数来向price
和vat
服务发送请求,并将它们的响应解包到priceRes
和vatRes
变量中。为了更清楚地了解底层发生了什么,让我们快速查看callService
的实现:
func (pc *PriceCalculator) callService(svc svcCaller, req map[string]interface{}, res interface{}) error {
svcRes, err := svc.Call(req)
if err != nil {
return xerrors.Errorf("call to remote service failed: %w", err)
}
defer drainAndClose(svcRes)
if err = json.NewDecoder(svcRes).Decode(res); err != nil {
return xerrors.Errorf("unable to decode remote service response: %w", err)
}
return nil
}
callService
方法实现相当简单。它所做的只是调用提供的svcCaller
实例上的Call
方法,将返回的输出作为 JSON 流处理,并尝试将其反序列化到调用者提供的res
参数中。
现在,让我们回到PriceForItemAtDate
方法的实现。假设在联系远程服务时没有发生错误,它们的个别响应作为参数传递给vatInclusivePrice
辅助函数。
如您可能从其名称中看出,它实现了将增值税率应用于价格的业务逻辑。将业务逻辑与负责与其他服务通信的代码分开不仅是一个良好设计的良好指标,而且也使我们的测试编写工作更容易。让我们添加一个小型的表格驱动测试来验证业务逻辑:
func TestVatInclusivePrice(t *testing.T) {
specs := []struct {
price float64
vatRate float64
exp float64
}{
{42.0, 0.1, 46.2},
{10.0, 0, 10.0},
}
for specIndex, spec := range specs {
if got := vatInclusivePrice(spec.price, spec.vatRate); got != spec.exp {
t.Errorf("[spec %d] expected to get: %f; got: %f", specIndex, spec.exp, got)
}
}
}
在这个测试到位后,接下来我们想要测试的是PriceForItem
。为了做到这一点,我们需要以某种方式控制对外部服务的访问。虽然我们将使用存根来简化,但我们也可以使用之前章节中讨论的任何其他测试模式。这里有一个存根,它实现了与我们的黑盒测试中的测试服务器相同的策略,但无需实际启动服务器!
type stubSvcCaller map[string]interface{}
func (c stubSvcCaller) Call(map[string]interface{}) (io.ReadCloser, error) {
data, err := json.Marshal(c)
if err != nil {
return nil, err
}
return ioutil.NopCloser(bytes.NewReader(data)), nil
}
使用前面的存根定义,让我们为PriceForItem
方法的正常路径添加一个测试:
func TestPriceForItem(t *testing.T) {
pc := &PriceCalculator{
priceSvc: stubSvcCaller{ "price": 42.0, },
vatSvc: stubSvcCaller{ "vat_rate": 0.10, },
}
got, err := pc.PriceForItem("foo")
if err != nil {
t.Fatal(err)
}
if exp := 46.2; got != exp {
t.Fatalf("expected calculated retail price to be %f; got %f", exp, got)
}
}
当然,如果没有明确测试当必需的依赖项失败时会发生什么,我们的测试就不会真正完整!为此,我们需要另一个存根,它总是返回一个错误:
type stubErrCaller struct {
err error
}
func (c stubErrCaller) Call(map[string]interface{}) (io.ReadCloser, error) {
return nil, c.err
}
使用这个存根实现,我们可以测试当特定错误类别发生时PriceCalculator
方法的行为。例如,这里有一个模拟vat
服务返回 404 响应的测试,以向调用者指示在指定时间段内没有可用的增值税率数据:
func TestVatSvcErrorHandling(t *testing.T) {
pc := &PriceCalculator{
priceSvc: stubSvcCaller{ "price": 42.0, },
vatSvc: stubErrCaller{
err: errors.New("unexpected response status code: 404"),
},
}
expErr := "unable to retrieve vat percent: call to remote service failed: unexpected response status code: 404"
_, err := pc.PriceForItem("foo")
if err == nil || err.Error() != expErr {
t.Fatalf("expected to get error:\n %s\ngot:\n %v", expErr, err)
}
}
让我们一起运行黑盒和白盒测试,以检查在引入新测试后总覆盖率如何变化:
图 2:运行黑盒和白盒测试
虽然在 Go 标准库的源代码中,白盒测试和黑盒测试的比例似乎强烈倾向于白盒测试,但这并不意味着你不应该编写黑盒测试!黑盒测试当然有自己的位置,当你试图复制触发特定错误的特定条件和输入集时,它们非常有用。更重要的是,正如我们将在接下来的章节中看到的,黑盒测试通常可以作为构建另一类测试的模板,这类测试通常被称为集成测试。
表格驱动测试与子测试
在本节中,我们将比较两种不同的方法,用于分组和执行多个测试用例。这两种方法,即表格驱动测试和子测试,可以很容易地使用 Go 内置的testing
包提供的基本原语来实现。对于每种方法,我们将讨论其优缺点,并最终概述一种策略,将两种方法结合起来,以便我们可以取长补短。
表格驱动测试
表格驱动测试是一种相当紧凑且相当简洁的方式来高效地测试特定代码片段在不同场景下的行为。典型的表格驱动测试的格式由两个不同的部分组成:测试用例定义和测试运行代码。
为了演示这一点,让我们考察一个著名的FizzBuzz
测试的实现:给定一个数字 N,FizzBuzz
实现预期在数字能被 3 整除时返回Fizz
,在数字能被 5 整除时返回Buzz
,在数字能被 3 和 5 同时整除时返回FizzBuzz
,在其他所有情况下返回该数字本身。以下是从Chapter04/table-driven/fizzbuzz.go
文件中的列表,其中包含我们将要使用到的实现:
func Evaluate(n int) string {
if n != 0 {
switch {
case n%3 == 0 && n%5 == 0:
return "FizzBuzz"
case n%3 == 0:
return "Fizz"
case n%5 == 0:
return "Buzz"
}
}
return fmt.Sprint(n)
}
在大多数情况下,测试场景将只被一个测试函数访问。考虑到这一点,一个很好的策略是使用 Go 的一个非常酷的特性:匿名结构体,将场景列表封装在测试函数中。以下是如何使用单块代码定义包含场景和场景列表的结构体的方法:
specs := []struct {
descr string
input int
exp string
}{
{descr: "evenly divisible by 3", input: 9, exp: "Fizz"},
{descr: "evenly divisible by 5", input: 25, exp: "Buzz"},
{descr: "evenly divisible by 3 and 5", input: 15, exp: "FizzBuzz"},
// The following case is intentionally wrong to trigger a test failure!
{descr: "example of incorrect expectation", input: 0, exp: "FizzBuzz"},
{descr: "edge case", input: 0, exp: "0"},
}
在前面的代码片段中,你可能已经注意到我为每个测试用例都包含了一个描述。这更多的是一种个人偏好,但在我看来,这使测试代码看起来更令人愉悦,更重要的是,它帮助我们轻松地定位失败的测试用例的规格,而不是在视觉上扫描整个列表寻找与失败的 N^(th)场景相对应的场景。当然,对于前面那个每个测试用例都整齐地放在一行中的例子,任何一种方法都会很有效,但想想如果每个规格块都包含嵌套对象,并且每个规格都使用不同数量的行来定义,事情会变得多么困难。
一旦我们写下了我们的规范,确保我们也包括了我们可以想到的所有任何边缘案例,就是时候运行测试了。这实际上是容易的部分!我们只需要遍历规范列表,使用每个规范提供的输入调用被测试的主题,并验证输出是否符合预期值:
for specIndex, spec := range specs {
if got := fizzbuzz.Evaluate(spec.input); got != spec.exp {
t.Errorf("[spec %d: %s] expected to get %q; got %q", specIndex, spec.descr, spec.exp, got)
}
}
前述测试运行器实现的一个重要方面是,即使测试案例失败,我们也不会通过调用任何t.Fail/FailNow
或t.Fatal/f
辅助函数来立即终止测试,而是耗尽我们的测试案例列表。这是故意的,因为它允许我们一次性查看所有失败的案例。如果我们运行前面的代码,我们会得到以下输出:
图 3:表格驱动测试中失败案例的示例
这种方法的一个不幸的缺点是我们不能请求go test
命令明确地针对一个特定的测试案例。我们可以始终请求go test
只独立运行一个特定的测试函数(例如,go test -run TestFizzBuzzTableDriven
),但不能只运行该测试函数中的失败测试案例编号 3;我们需要每次都顺序测试所有案例!如果我们的测试运行器代码复杂,每个测试案例执行时间较长,能够针对特定的测试案例将节省大量时间。
子测试
随着 Go 1.7 的发布,内置的 testing 包增加了运行子测试的支持。子测试不过是一系列按顺序执行的测试函数的层次结构。这种测试代码的层次结构类似于你在其他编程语言中可能接触到的测试套件的概念。
那么,它是如何工作的呢?testing.T
类型已经增加了一个名为Run
的新方法,它具有以下签名:
Run(description string, func(t *testing.T))
这种新方法提供了一种生成子测试的新机制,这些子测试将在独立运行的同时,仍然保留使用父测试函数执行任何必需的设置和清理步骤的能力。
如你所预期,由于每个子测试函数都接收自己的testing.T
实例参数,它可以反过来生成附加的子测试,这些子测试位于其下方。以下是按照这种方法的一个典型测试的示例:
func TestXYZ(t *testing.T){
// Run suite setup code...
t.Run("test1", func(t *testing.T){
// test1 code
})
t.Run("test2", func(t *testing.T){
// test2 code
})
// Run suite tear-down code...
}
更重要的是,每个子测试都有自己的唯一名称,该名称通过连接所有祖先测试函数的名称和传递给Run
调用的描述字符串生成。这使得通过在调用go test
时指定名称,可以轻松地针对特定层次结构树中的任何子测试。例如,在前面代码片段中,我们可以通过运行go test -run TestXYZ/test2
来针对test2
。
与其测试驱动的兄弟相比,子测试的一个缺点是它们定义得更加冗长。如果我们需要定义大量测试场景,这可能会带来一些挑战。
两者之最佳
最后,没有什么阻止我们将这两种方法结合起来,形成一个混合方法,它结合了两种方法的优点:表格驱动测试的简洁性和子测试的选择性定位。
为了实现这一点,我们需要定义我们的表格驱动规范,就像我们之前做的那样。然后,我们遍历规范列表,并为每个测试案例启动一个子测试。以下是如何调整我们的FizzBuzz
测试以遵循此模式的方法:
func TestFizzBuzzTableDrivenSubtests(t *testing.T) {
specs := []struct {
descr, exp string
input int
}{
{descr: "evenly divisible by 3", input: 9, exp: "Fizz"},
{descr: "evenly divisible by 3 and 5", input: 15, exp: "FizzBuzz"},
{descr: "edge case", input: 0, exp: "0"},
}
for specIndex, spec := range specs {
t.Run(spec.descr, func(t *testing.T) {
if got := fizzbuzz.Evaluate(spec.input); got != spec.exp {
t.Errorf("[spec %d: %s] expected to get %q; got %q", specIndex, spec.descr, spec.exp, got)
}
})
}
}
假设我们只想运行第二个测试案例。我们可以通过在运行go test
时将它的完全限定名称作为-run
标志的值来轻松实现这一点:
go test -run TestFizzBuzzTableDrivenSubtests/evenly_divisible_by_3_and_5
使用第三方测试框架
测试 Go 代码的一个好处是,该语言本身自带电池:它附带了一个内置的、尽管是简约的、用于编写和运行测试的框架。
从纯粹主义者的角度来看,这就是你需要的一切来启动运行!内置的testing
包提供了运行、跳过或失败测试所需的所有机制。软件工程师需要做的只是设置所需的测试依赖项,并为每个测试编写适当的谓词。使用testing
包的一个缺点是它不提供你从 Java、Ruby 或 Python 背景可能习惯的更复杂的测试原语,如断言或模拟。当然,没有什么阻止你自己实现这些!
或者,如果你不反对导入额外的测试依赖项,你可以使用几个现成的第三方包之一,这些包提供了所有这些缺失的功能。由于本书的范围不包括所有第三方测试包的完整、详细列表,我们将关注其中最受欢迎的测试框架包之一:gocheck
。
gocheck
包^([3])可以通过运行go get gopkg.in/check.v1
来安装。它建立在标准的 Go testing
包之上,并为将测试组织成测试套件提供支持。每个套件都是使用一个常规的 Go 结构定义的,你也可以利用它来存储测试可能需要的任何额外信息。
为了将每个测试套件作为测试的一部分运行,你需要将其注册到gocheck
中,并将gocheck
钩子连接到 Go 测试包。以下是一个简短的示例,说明如何做到这一点:
import (
"testing"
"gopkg.in/check.v1"
)
type MySuite struct{}
// Register suite with go check
var _ = check.Suite(new(MySuite))
// Hook up gocheck into the "go test" runner.
func Test(t *testing.T) { check.TestingT(t) }
正如你所期望的,任何支持测试套件的框架都允许你通过在套件类型上定义以下任何方法,为套件和每个测试可选地指定设置和清理方法:
-
SetUpSuite(c *check.C)
-
SetUpTest(c *check.C)
-
TearDownTest(c *check.C)
-
TearDownSuite(c *check.C)
同样,任何匹配 TestXYZ(c *check.C)
模式的 suite 方法都将被视为测试,并在 suite 运行时执行。check.C
类型提供了访问一些有用方法的能力,例如以下内容:
-
Log/Logf
: 将消息打印到测试日志 -
MkDir
: 创建一个在 suite 运行完成后自动删除的临时文件夹 -
Succeed/SucceedNow/Fail/FailNow/Fatal/Fatalf
: 控制正在运行的测试的结果 -
Assert
: 如果指定的谓词条件不满足,则测试失败
默认情况下,gocheck
缓存所有输出,仅在测试失败时才发出。虽然这有助于减少噪音并加快嘈杂测试的执行速度,但您可能希望看到所有输出。幸运的是,gocheck
支持两个级别的详细程度,可以通过传递给 go test
调用的命令行标志来控制。
要强制 gocheck
为所有测试输出缓存的调试日志,无论其通过/失败状态如何,您可以在 go test
中使用 -check.v
参数。当您试图找出为什么某个测试挂起时,gocheck
倾向于缓存所有日志输出并不是最佳选择。在这种情况下,您可以通过使用 -check.vv
参数来提高详细程度并禁用缓存。最后,如果您希望从测试套件中运行特定的测试(类似于 go test -run XYZ
),您可以使用 -check.f XYZ
运行 gocheck
,其中 XYZ
是匹配您希望运行的测试(s)名称的正则表达式。
虽然我们提到了 check.C
对象提供了一个 Assert
方法,但我们并没有深入探讨其工作原理或断言谓词是如何定义的。Assert
方法的签名如下:
Assert(obtained interface{}, checker Checker, args ...interface{})
下表包含由 gocheck
提供的实用 Checker
实现列表,您可以使用它来编写测试断言。
Checker | 描述 | 示例 |
---|---|---|
Equals |
检查相等性 | c.Assert(res, check.Equals, 42) |
DeepEquals |
检查接口、切片和其他对象是否相等 | c.Assert(res, check.DeepEquals, []string{"hello", "world"}) |
IsNil |
检查值是否为 nil | c.Assert(err, check.IsNil) |
HasLen |
检查切片/映射/通道/字符串的长度 | c.Assert(list, check.HasLen, 2) |
Matches |
检查字符串是否与正则表达式匹配 | c.Assert(val, check.Matches, ".*hi.*") |
ErrorMatches |
检查错误消息是否与正则表达式匹配 | c.Assert(err, check.Matches, ".*not found") |
FitsTypeOf |
检查参数是否被分配给具有给定类型的变量 | c.Assert(impl, check.FitsTypeOf, os.Error(nil)) |
Not |
反转检查结果 | c.Assert(val, check.Not(check.Equals)), 42) |
当然,如果您的测试需要比 gocheck
内置的谓词更复杂的谓词,您始终可以通过实现 Checker
接口来自定义。
这就结束了我们对gocheck
的浏览。如果您对在项目中使用它感兴趣,我肯定会推荐您访问包主页^([3])并阅读其优秀的文档。如果您已经使用gocheck
但想探索其他流行的 Go 测试框架,我建议您看看stretchr/testify
包^([7]),它提供了类似于gocheck
的功能(测试套件、断言等),但也包括对更高级测试原语(如模拟)的支持。
集成测试与功能测试
在本节中,我们将尝试消除对两种非常重要且有用的测试类型定义之间的任何混淆:集成测试和功能测试。
集成测试
集成测试从单元测试结束的地方开始。而单元测试确保系统中的每个单独单元在隔离状态下正确工作,集成测试确保不同的单元(或微服务架构中的服务)正确交互。
让我们考虑一个假设的场景,即我们正在构建一个电子商务应用程序。遵循 SOLID 设计原则,我们将后端实现拆分成了多个微服务。每个微服务都附带其自己的单元测试集,并且按照设计,提供了一个遵守所有工程团队达成的协议的 API。为了本演示的目的,并且为了保持简单,我们希望将精力集中在编写以下两个微服务的集成测试上:
-
商品微服务执行以下功能:
-
它提供了一个用于操作和查询商品元数据的机制;例如,添加或删除产品,返回有关商品价格、描述等信息
-
它提供了一个通知机制,其他服务可以订阅元数据更改
-
-
篮子微服务存储客户已选商品列表。当新商品被插入客户的篮子时,篮子服务查询商品服务以获取商品元数据并更新篮子的价格摘要。同时,它订阅了商品服务的变更流,并在商品元数据更新时更新篮子的内容。
需要注意的一个重要实现方面是,每个微服务都使用自己的专用数据存储。但请记住,这种方法并不一定意味着数据存储在物理上是分离的。也许我们正在使用单个数据库服务器,并且每个微服务都在该服务器上拥有自己的数据库。
这两个服务的集成测试将位于一个单独的 Go 测试文件中,可能带有_integration_test.go
后缀,这样我们只需查看文件名就能立即知道其目的。测试的设置阶段期望所需的数据库实例已经由外部准备就绪。正如我们将在本章后面看到的那样,通过使用环境变量提供数据库连接设置是一种简单的方法。测试将继续启动我们想要测试的服务,然后运行以下集成场景:
-
调用产品服务 API 将新产品插入目录。然后,它会使用购物车服务 API 将产品添加到客户购物车中,并验证用于购物车服务的数据库中包含具有正确产品元数据的条目。
-
将产品添加到客户购物车中。然后,它会使用产品服务 API 修改项目描述,并验证相关的购物车数据库条目是否正确更新。
集成测试的一个注意事项是我们需要在各个测试之间保持严格的隔离。因此,在运行每个测试场景之前,我们必须确保每个服务的内部状态已正确重置。通常,这意味着我们需要清除每个服务使用的数据库,也许还需要重启服务,以防它们还维护任何额外的内存状态。
显然,设置、连接和准备每个集成测试所需的各种组件所需的努力使得编写此类测试变得相当繁琐。尽管不降低集成测试的重要性,但我相信工程师可以通过编写大量单元测试和少量集成测试来更好地利用他们的时间。
功能测试
功能测试或端到端测试将系统测试提升到了全新的水平。功能测试的主要目的是确保整个系统按预期工作。为此,功能测试旨在模拟涉及多个系统组件的复杂交互场景。功能测试的一个非常常见的用例是通过模拟用户在系统中的旅程来验证端到端正确性。
例如,对一个在线音乐流媒体服务的功能测试将模拟为新用户订阅服务,搜索特定歌曲,将其添加到他们的播放列表中,并在播放完成后可能为该歌曲提交评分。
重要的是要明确,所有前面的交互都旨在通过网页进行。这是一个需要求助于可脚本化的浏览器自动化框架,如 Selenium ^([6]),以便准确模拟我们期望真实用户在使用系统时执行的所有所需按钮点击的明确案例。
虽然你可能能找到一个提供 Selenium Go 绑定的包,但事实是,Go 并不是编写功能测试的最佳工具。与存在于 Go 文件中的单元测试和集成测试相反,功能测试通常是用 Python、JavaScript 或 Ruby 等语言编写的。另一个重要的区别是,由于它们的复杂性增加,功能测试的运行时间通常显著更长。
虽然软件工程师在开发特定功能时也提供功能测试套件并不罕见,但在大多数情况下,编写功能测试的任务是质量保证(QA)团队的主要职责之一。事实上,功能测试是 QA 工程师在为新版本发出绿灯之前遵循的预发布工作流程的前沿和中心部分。
功能测试通常不针对生产系统;你肯定不希望用虚拟用户账户填满你的生产数据库,对吧?相反,功能测试针对的是预发布环境,这些环境是隔离的,通常是缩小规模的沙盒,它们反映了实际生产环境的设置。这包括系统运行所需的所有服务和资源(数据库、消息队列等)。一个例外是,访问外部第三方服务,如支付网关或电子邮件提供商,通常会被模拟,除非特定的功能测试有其他要求。
功能测试之二——在生产环境中测试!
这并不是说你实际上不能在实时生产环境中运行你的功能测试!当然,这究竟是好是坏是一个有争议的问题,但如果你决定走这条路,你可以应用一些模式来以安全和可控的方式实现这一点。
要开始行动,你可以从修订你的数据库模式开始,使它们包括一个字段,指示每行是否包含真实数据或测试运行的一部分。然后,每个服务在处理实时流量时可以默默地忽略任何测试记录。
如果你正在使用微服务架构,你可以设计你的服务,使它们不直接与其他服务通信,而是通过本地代理进行通信,该代理作为边车进程与每个服务一起部署。这种模式被称为大使模式,并开辟了实现一系列真正酷炫技巧的可能性,正如我们将在本章后面看到的。
由于所有代理最初都配置为与已部署的服务通信,因此没有任何东西阻止我们部署特定服务的较新版本,并使其与现有版本并行运行。由于新部署的服务无法接收流量,因此通常使用术语暗启动来指代这种部署方式。
一旦我们需要的测试服务的版本成功部署,每个功能测试都可以重新配置本地代理,将测试流量(可能通过 HTTP 头或其他类型的标签识别)重定向到新部署的服务。这可以在以下图中看到:
图 4:使用大使模式在生产中进行测试
这个巧妙的方法允许我们在生产环境中运行测试,而不干扰实时流量。正如你所知,与沙盒测试相比,实时测试需要更多的准备工作。这可能是 QA 团队似乎更喜欢使用预发布环境的原因之一。
在我看来,如果你的系统构建得可以轻松引入这些模式之一以方便实时测试,你绝对应该这样做。毕竟,在运行在一个负载和流量配置文件与生产系统不真正一致的隔离环境中时,你能收集到的数据量是有限的。
烟雾测试
烟雾测试或构建验收测试构成了一类特殊的测试,这些测试传统上被 QA 团队用作早期合理性检查。
使用单词smoke指的是古老的谚语“哪里有烟,哪里就有火”。这些检查明确设计用来识别早期预警信号,表明可能存在问题。不言而喻,任何烟雾测试中发现的任何问题都被 QA 团队视为一个阻止器;如果烟雾测试失败,则不再进行进一步测试。QA 团队向开发团队报告其发现,并等待提交修订的候选版本以进行测试。
一旦烟雾测试成功通过,QA 团队将继续运行他们的功能测试套件,然后在发布前给出绿灯。以下图总结了为 QA 目的运行烟雾测试的过程:
图 5:将烟雾测试作为 QA 过程的一部分运行
当谈到执行时,烟雾测试是功能测试的完全对立面。虽然功能测试可以长时间执行,但烟雾测试必须尽可能快地执行。因此,烟雾测试被精心设计,以测试系统用户界面部分中特定、尽管有限的流程,这些流程被认为是系统操作的关键。例如,社交网络应用的烟雾测试将验证以下内容:
-
用户可以使用有效的用户名和密码登录
-
点击帖子上的点赞按钮会增加该帖子的点赞计数器
-
删除联系人会将他们从用户的联系人列表中删除
-
点击注销按钮将用户从服务中注销
编写、演进和维护烟雾测试的责任通常落在 QA 团队身上。因此,QA 团队在单独的、专用的存储库中维护烟雾测试是有意义的,他们拥有并控制这个存储库。这里有一个有趣的问题:QA 团队会选择手动执行烟雾测试,还是投入时间和精力来自动化这个过程?逻辑上,尽管有点陈词滥调,答案是:这取决于...
最终,决策取决于 QA 团队的大小、团队成员的个人偏好,以及可供团队使用的测试基础设施。不用说,自动化烟雾测试无疑是首选方案,因为 QA 团队能够在短时间内高效地验证大量场景。另一方面,如果构建发布的频率较低,那么手动进行烟雾测试可能成本更低,并且能更好地利用 QA 团队的时间和资源。
混沌测试——以有趣和有趣的方式破坏你的系统!
让我从一个问题开始这个部分!你对当前软件栈的质量有多自信?如果你的回答是“我真的不知道,直到我让它失败”,那么我们完全一致!如果不是这样,让我向你介绍混沌测试的概念。
混沌测试是一个最初由 Netflix 工程团队提出的术语。混沌测试背后的关键点是评估系统在各个组件表现出不同类型的故障时的行为。那么,我们在这里讨论的是哪些类型的故障呢?以下是一些按相对严重程度(从低到高)排序的有趣示例:
-
一个服务无法到达它所依赖的另一个服务
-
服务之间的调用表现出高延迟/抖动
-
网络链路出现数据包丢失
-
数据库节点故障
-
我们失去了一块关键的存储设备
-
我们云服务提供商在整个可用区发生故障
Netflix 工程师指出,我们不应该害怕失败,而应该拥抱它,尽可能多地了解它。所有这些学习都可以应用于微调我们系统的设计,使它们在对抗失败方面变得更加稳健和有弹性。
这些类型的故障发生的可能性很低。尽管如此,如果我们能准备好在它们实际发生时减轻它们的影响,那就更好了。毕竟,从系统稳定性的角度来看,预防性地操作总是优于在出现故障时(通常是在巨大的压力下)试图做出反应。
你可能会想:但是,如果某些故障在统计上不太可能发生,我们最初如何触发它们呢? 唯一的方法是以一种方式设计我们的系统,以便可以根据需要注入故障。在功能测试第二部分 – 生产中的测试部分,我们讨论了大使模式,它可以帮助我们实现这一点。
大使模式将服务发现和通信与实际服务实现解耦。这是通过每个服务部署的边车进程实现的,该进程充当代理。
边车代理服务可用于其他用途,例如根据标签或头部条件路由流量,充当断路器,将流量分支以执行 A/B 测试,记录请求,强制执行安全规则,或者向系统中注入人工故障。
从混沌工程的角度来看,边车代理是引入故障的简单途径。让我们看看一些我们可以利用代理将故障注入系统的例子:
-
指示代理延迟发送请求或等待在返回上游响应给发起请求的服务之前。这是一种模拟延迟的有效方法。如果我们选择不使用固定间隔,而是随机化它们,我们可以在服务间通信中注入抖动。
-
配置代理以以概率P丢弃出站请求。这模拟了降级网络连接。
-
配置代理以使单个服务丢弃发送到另一个服务的所有出站流量。同时,所有其他服务代理都设置为按常规转发流量。这模拟了网络分区。
这还不是全部。如果我们运行在提供 API 的云服务提供商上,该 API 可以帮助我们破坏更多东西,我们可以将混沌测试进一步推进!例如,我们可以使用这样的 API 随机终止节点或关闭一个或所有负载均衡器,并检查我们的系统是否可以自动恢复。在混沌测试中,唯一的限制是你的想象力!
编写测试的技巧和窍门
在本节中,我将介绍一些可以帮助提升你日常测试工作流程的有趣想法。更重要的是,我们还将探索一些你可以在测试中使用的巧妙技巧,以隔离测试、模拟对系统二进制的调用以及控制测试中的时间。
使用环境变量设置或跳过测试
在任何规模的项目中,你最终都会遇到一系列依赖于外部资源的测试,这些资源以临时方式创建或配置。
这样的用例的一个典型例子是一个与数据库通信的测试套件。作为在代码库上本地工作的工程师,我们可能会启动一个具有或多或少可预测端点的本地数据库实例,并使用它进行测试。然而,当在 CI 下运行时,我们可能需要使用某些云提供商上已经配置好的数据库实例,或者更常见的是,CI 设置阶段可能需要在 Docker 容器中启动数据库,这个过程会产生一个不可预测的端点来连接。
为了支持此类场景,我们必须避免在测试中硬编码资源端点的位置,并推迟它们的发现和配置,直到测试运行时。为此,一个解决方案是使用一组环境变量来向我们的测试提供这些信息。以下是从Chapter04/db
包中的一个简单测试示例,说明了如何实现这一点:
func TestDBConnection(t *testing.T) {
host, port, dbName, user, pass := os.Getenv("DB_HOST"), os.Getenv("DB_PORT"),
os.Getenv("DB_NAME"), os.Getenv("DB_USER"), os.Getenv("DB_PASS")
db, err := sql.Open("postgres", makeDSN(user, pass, dbName, host, port))
if err != nil {
t.Fatal(err)
}
_ = db.Close()
t.Log("Connection to DB succeeded")
}
上述示例使得无论我们在本地还是 CI 环境中运行测试,测试都变得非常简单。但如果我们需要运行本地难以启动的专用数据库,情况会怎样呢?可能我们需要一个在集群配置下运行的数据库,或者内存需求超过我们开发机上可用内存的数据库。如果我们可以简单地跳过本地运行时的这个测试,那岂不是很好?
事实上,使用与我们配置数据库端点相同的机制也可以很容易地实现这一点。更准确地说,所需配置设置的缺失可以作为测试需要跳过的提示。在先前的示例中,我们可以在获取数据库配置的环境值后添加一个简单的if
块来实现这一点:
if host == "" {
t.Skip("Skipping test as DB connection info is not present")
}
太好了!现在,如果我们没有在运行测试之前导出DB_HOST
环境变量,这个特定的测试将被跳过。
加速本地开发中的测试
在本节中,我们将介绍一些在本地工作时加速测试的方法。为了澄清,我假设您已经建立了一个适当的持续集成(CI)基础设施;无论我们在这里采取什么捷径,CI 都会始终运行所有测试。
我们议程上的第一项是慢速测试与快速测试的比较。为了辩论的目的,假设我们发现自己处于编写一个完整的、纯 CPU 的 Go 语言射线追踪器实现的情况。为了确保正确性并避免在调整实现时出现回归,我们引入了一个测试套件,该套件渲染一系列示例场景,并将射线追踪器的输出与一系列预渲染的参考图像进行比较。
由于这是一个纯 CPU 实现,并且我们的测试以全高清分辨率渲染,因此可以想象,运行每个测试都会花费相当多的时间。在 CI 上运行时这不是问题,但在本地工作时可能会成为障碍。
更糟糕的是,go test
会尝试运行所有测试,即使其中一个失败了。此外,它还会自动失败运行时间过长的测试(超过 10 分钟)。幸运的是,go test
命令支持一些非常有用的标志,我们可以使用这些标志来解决这些问题。
首先,我们可以通过将-short
标志传递给go test
调用,通知长时间运行的测试尝试缩短它们的运行时间。这个标志通过testing
包的Short
辅助函数暴露出来,当定义了-short
标志时,它返回true
。那么,我们如何使用这个标志来使我们的光线追踪器测试运行得更快呢?
一种方法就是简单地跳过已知运行时间非常长的测试。一个更好的替代方案是检测到-short
标志的存在,并将光线追踪器的输出分辨率降低,例如,降低到原始分辨率的四分之一。这种改变仍然允许我们在本地测试时验证渲染输出,同时将我们测试的总运行时间限制在可接受的水平。
回到go test
运行所有测试的问题,即使其中一个失败了,我们实际上可以指示go test
在检测到失败的测试时立即终止,通过传递-failfast
命令行标志。此外,我们可以使用-timeout
标志来调整每个测试的最大执行时间。它接受任何可以被time.Duration
类型解析的字符串(例如,*1h*
),但如果你的测试运行时间不可预测,你也可以传递一个*0*
的超时值来禁用超时。
通过构建标志排除测试类别
到目前为止,我们已经讨论了白盒测试、黑盒测试、集成测试和端到端测试。通过在我们的项目中包含所有这些类别的测试,我们可以确信代码库将在多种不同的场景中按预期行为。
现在,假设我们正在处理一个特定的功能,我们只想运行单元测试。或者,我们可能只需要运行集成测试来确保我们的更改不会引入其他包的回归。我们该如何做到这一点?
相对简单的方法是为每个测试类别维护单独的文件夹,但这会偏离被认为是 Go 语言习惯的做法。另一个替代方案是在我们的测试中添加类别名称作为前缀或后缀,并使用带有-run
标志的go test
(如果我们使用第三方包如gocheck
则使用-check.f
标志)来仅运行名称与特定正则表达式匹配的测试。从这个角度来看,虽然这种方法可行,但它很容易出错;对于大型代码库,我们需要编写复杂的正则表达式,这些表达式可能无法匹配所有需要运行的测试。
一个更聪明的解决方案是利用 Go 对条件编译的支持,并将其重新用于满足我们的需求。这是一个很好的时机来解释什么是条件编译,以及最重要的是,它在底层是如何工作的。
当一个包正在构建时,go build
命令会扫描每个 Go 文件内的注释,寻找可以解释为编译器指令的特殊关键字。构建标签 是这类注释的一个例子。go build
使用构建标签来决定一个包中的特定 Go 文件是否应该传递给 Go 编译器。构建标签的一般语法如下:
// +build tag1 ... tagN
package some_package
为了被 go build
正确识别,所有的构建标签都必须作为注释出现在 Go 文件的 顶部。虽然你可以定义多个构建标签,但非常重要的一点是,最后一个 构建标签必须与包名声明之间有一个空白(非注释)行分隔。否则,go build
将假设构建标签是包级注释的一部分,并简单地忽略它。对于刚开始接触 Go 构建标签的软件工程师来说,偶尔会陷入这个陷阱,所以如果你发现自己感到困惑,不知道为什么构建标签没有被识别,那么构建标签后面缺少空白行可能是最有可能的原因。
让我们更详细地看看标签语法的复杂性,并阐述 go build
如何应用规则来解释 +build
关键字后面的标签列表:
-
由 空白字符 分隔的标签被视为一系列 OR 条件。
-
由 逗号 分隔的标签被视为一系列 AND 条件。
-
以
!
开头的标签被视为 NOT 条件。 -
如果定义了多个
+build
行,它们将被作为一个 AND 条件合并在一起。
go build
命令识别针对目标操作系统(例如,linux, windows, darwin
)、CPU 架构(例如,amd64, 386, arm64
)以及 Go 编译器版本(例如,go1.10
以指定 Go 1.10 及以后的版本)的几个预定义标签。以下表格展示了使用标签来建模复杂构建约束的一些示例。
构建目标场景 | 构建标签 |
---|---|
仅当目标是 Linux | linux |
Linux 或 macOS | linux darwin |
x64 目标,但仅当 Go 编译器 >= 1.10 | amd64,go1.10 |
32 位 Linux 或 64 位所有平台 除了 OS X | linux,386 amd64,!darwin |
到现在为止,你应该对构建标签的工作方式有了更好的理解。但是,所有这些信息如何应用到我们的特定用例中呢?首先,让我强调一点,测试文件也是常规的 Go 文件,因此它们也会被扫描以查找构建标签的存在!其次,我们不仅限于内置的标签,我们还可以定义自己的自定义标签,并通过 -tags
命令行标志将它们传递给 go build
或 go test
。
你可能已经看到了我要去哪里了……我们可以从为每个测试系列定义一个构建标签开始,例如,integration_tests
、unit_tests
和 e2e_tests
。此外,我们将定义一个 all_tests
标签,因为我们需要保留一起运行所有测试的能力。最后,我们将编辑我们的测试文件并添加以下构建标签注释:
-
+build unit_tests all_tests
添加到包含单元测试的文件中 -
+build integration_tests all_tests
添加到包含集成测试的文件中 -
+build e2e_tests all_tests
添加到包含端到端测试的文件中
如果你希望尝试前面的示例,你可以查看 Chapter04/buildtags
包的内容。
这不是你想要的输出 – 模拟对外部二进制文件的调用
你在尝试测试调用外部进程并使用其输出作为实现业务逻辑一部分的代码时是否遇到过困难?在某些情况下,可能可以使用我们之前讨论的一些技巧来装饰我们的代码,以便测试可以使用钩子来模拟执行命令的输出。不幸的是,有时这将是不可行的。例如,被测试的代码可能导入了一个第三方包,而这个包实际上负责执行某些外部命令。
Chapter04/pinger
包导出一个名为 RoundtripTime
的函数。其任务是计算到达远程主机的往返时间。在底层,它调用 ping
命令并解析其输出。这是它的实现方式:
func RoundtripTime(host string) (time.Duration, error) {
var argList = []string{host}
if runtime.GOOS == "windows" {
argList = append(argList, "-n", "1", "-l", "32")
} else {
argList = append(argList, "-c", "1", "-s", "32")
}
out, err := exec.Command("ping", argList...).Output()
if err != nil {
return 0, xerrors.Errorf("command execution failed: %w", err)
}
return extractRTT(string(out))
}
由于 Unix-like 系统和 Windows 之间的 ping
命令标志名称略有不同,代码依赖于操作系统嗅探来选择适当的标志集,以便 ping
发送一个带有 32 字节有效载荷的单个请求。extractRTT
辅助函数只是应用正则表达式来提取时间信息并将其转换为 time.Duration
值。
为了演示的目的,让我们假设我们正在运营一个视频流媒体服务,我们的业务逻辑(位于另一个 Go 包中)使用 RoundtripTime
结果将我们的客户重定向到离他们最近的边缘服务器。我们被分配编写该服务的 端到端 测试,因此,不幸的是,我们不允许模拟对 RoundtripTime
函数的任何调用;我们的测试实际上需要调用 ping
命令!
如果你发现自己处于类似的情况,让我建议一个很好的技巧,你可以用它来模拟对外部进程的调用。当我第一次加入 Canonical 公司并开始处理 juju 代码库时,我遇到了这个概念。事后看来,这个想法相当简单。然而,实现并不是一目了然的,需要一些平台特定的调整,所以向提出这个想法的工程师致敬。
这种方法利用了这样一个事实:当你尝试执行一个二进制文件(例如,使用os/exec
包中的Command
函数)时,操作系统会在当前工作目录中查找该二进制文件,如果失败,它将按顺序扫描系统PATH
环境变量中的每个条目,尝试找到它。对我们有利的是,类 Unix 系统和 Windows 遵循相同的逻辑。另一个有趣的观察是,当你要求 Windows 执行名为foo
的命令时,它会搜索名为foo.exe
的可执行文件或名为foo.bat
的批处理文件。
为了模拟外部进程,我们需要提供两块信息:预期的进程输出和适当的状态码;状态码为零表示进程成功完成。因此,如果我们能够创建一个可执行的 shell 脚本,在退出时以特定的状态码打印出预期的输出,并将它的路径添加到系统PATH
变量的前面,我们就可以欺骗操作系统执行我们的脚本而不是真正的二进制文件!
到目前为止,我们正在进入特定于操作系统的代码领域。这种做法可能会被一些工程师所不齿,他们认为 Go 程序应该是跨操作系统和 CPU 架构可移植的。然而,在这种情况下,我们只需要处理两个操作系统家族,所以我们可能可以这样做。让我们看看我们的测试代码将要注入的 Unix 和 Windows shell 脚本的模板。以下是 Unix 的模板:
#!/bin/bash
cat <<!!!EOF!!! | perl -pe 'chomp if eof'
%s
!!!EOF!!!
exit %d
该脚本使用 here 文档语法^([1])
来输出两个!!!EOF!!!
标签之间的文本。由于 here 文档包含一个额外的、尾随的换行符,我们将输出通过 Perl 单行命令管道传递,以去除它。%s
占位符将被我们希望命令输出的文本(可能跨越多行)替换。最后,%d
占位符将被命令返回的退出码替换。
Windows 版本要简单得多,因为在这里文档不支持内置的 shell 解释器(cmd.exe
)。因此,我选择将输出写入文件,并让 shell 脚本将其打印到标准输出。下面是这个过程的示例:
@echo off
type %s
exit /B %d
在这种情况下,%s
占位符将被替换为包含模拟命令输出的外部文件的路径,并且,像之前一样,%d
占位符将被替换为命令的退出码。
在我们的测试文件中,我们将定义一个名为mockCmdOutput
的辅助函数。由于空间限制,我不会在这里列出函数的完整代码,而是简要说明其工作原理(完整实现,您可以查看Chapter04/pinger
源代码)。简而言之,mockCmdOutput
执行以下操作:
-
创建一个临时文件夹,测试完成后将自动删除
-
根据操作系统选择合适的 shell 脚本模板
-
将 shell 脚本写入临时文件夹,并更改其权限以便使其可执行(对于类 Unix 系统来说很重要)
-
将临时文件夹添加到当前运行进程(
go test
)的PATH
环境变量开头
由于mockCmdOutput
修改了系统路径,我们必须确保在每个测试运行之前将其重置到原始值。我们可以通过将我们的测试分组到一个gocheck
测试套件中,并提供一个保存原始PATH
值的测试设置函数以及一个从保存值中恢复的测试清理函数来轻松实现这一点。在所有管道就绪后,以下是我们可以编写模拟ping
输出的测试函数的方法:
func (s *PingerSuite) TestFakePing(c *check.C) {
mock := "32 bytes from 127.0.0.1: icmp_seq=0 ttl=32 time=42000 ms"
mockCmdOutput(c, "ping", mock, 0)
got, err := pinger.RoundtripTime("127.0.0.1")
c.Assert(err, check.IsNil)
c.Assert(got, check.Equals, 42*time.Second)
}
为了确保命令被正确模拟,我们设置测试以对 localhost 进行往返测量(通常需要 1 毫秒或更少的时间)并模拟ping
命令返回一个荒谬的高数值(42 秒)。尝试在 OS X、Linux 或 Windows 上运行测试;你将始终得到一致的结果。
当你有无限的时间时,测试超时变得非常简单!
我很确定,在某个时候,你编写过一些依赖于标准库time
包提供的时间管理函数的代码。可能是一些定期轮询远程端点的代码——使用time.NewTicker
是一个很好的案例——或者你可能正在使用time.After
在等待事件发生的 goroutine 中实现超时机制。在稍微不同的场景中,使用time.NewTimer
为你的服务器代码提供足够的时间在关闭前清空所有连接也是一个绝妙的主意。
然而,测试使用这些模式中的任何一种的代码并不是一件简单的事情。例如,假设你正在尝试测试一段代码,该代码会阻塞直到接收到事件或在没有接收到事件的情况下经过特定的时间。在后一种情况下,它会向调用者返回某种超时错误。为了验证超时逻辑按预期工作,并避免如果阻塞代码永远不会返回而导致测试运行器锁定,典型的做法是启动一个 goroutine 来运行阻塞代码,并在返回预期的错误时通过(例如,通过一个 channel)发出信号。启动 goroutine 的测试函数将使用select
块等待 goroutine 的成功信号或固定时间的流逝,之后它会自动失败测试。
如果我们采用这种方法,这样的测试在放弃之前应该等待多长时间?如果已知阻塞代码的最大等待时间(例如,定义为常量),那么事情相对简单;我们的测试至少需要等待那么长时间,加上一些额外时间来考虑在不同环境中运行测试时的速度差异(例如,本地与 CI)。未能考虑到这些差异可能导致测试不稳定——这些测试可能会随机失败,让你的 CI 系统激烈地抱怨。
如果超时是可配置的,或者至少指定为一个全局变量,我们的测试可以在执行时修补它,事情就会容易得多。然而,如果测试时间被指定为一个常量,但其值仅为几秒钟的量级,显然,有多个测试运行了那么长时间实际上只是在等待,这是适得其反的。
类似地,在某些情况下,超时可能通过包含随机因子的公式来计算。这将使得在没有求助于诸如将随机数生成器的种子设置为特定值之类的技巧的情况下,以确定性的方式预测超时变得更加困难。当然,在这种情况下,如果另一个工程师稍微调整了用于计算超时的公式,我们的测试就会直接失败。
Chapter04/dialer
包是一个值得进一步研究的有趣案例,因为它展示了我在这里描述的两个问题:通过公式计算的长等待时间!这个包提供了一个拨号包装器,它将指数退避重试机制叠加在网络拨号函数(例如,net.Dial
)之上。
要创建一个新的重试拨号器,我们需要调用NewRetryingDialer
构造函数:
func NewRetryingDialer(ctx context.Context, dialFunc DialFunc, maxAttempts int) *RetryingDialer {
if maxAttempts > 31 {
panic("maxAttempts cannot exceed 31")
}
return &RetryingDialer{
ctx: ctx,
dialFunc: dialFunc,
maxAttempts: maxAttempts,
}
}
调用者提供了一个context.Context
实例,它可以用来在例如,应用程序收到关闭信号时取消挂起的拨号尝试。现在,让我们继续探讨拨号器实现的要点——Dial
调用:
func (d *RetryingDialer) Dial(network, address string) (conn net.Conn, err error) {
for attempt := 1; attempt <= d.maxAttempts; attempt++ {
if conn, err = d.dialFunc(network, address); err == nil {
return conn, nil
}
log.Printf("dial %q: attempt %d failed; retrying after %s", address, attempt, expBackoff(attempt))
select {
case <-time.After(expBackoff(attempt)): // Try again
case <-d.ctx.Done():
return nil, d.ctx.Err()
}
}
return nil, ErrMaxRetriesExceeded
}
这是一个相当直接的实现:每次拨号尝试失败时,我们调用expBackoff
辅助函数来计算下一次尝试的等待时间。然后,我们阻塞直到等待时间结束或上下文被取消。最后,如果我们意外地超过了配置的最大重试次数,代码将自动退出并返回错误给调用者。写一个简短的测试来验证前面的代码是否按预期处理超时如何?这将是它的样子:
func TestRetryingDialerWithRealClock(t *testing.T) {
log.SetFlags(0)
// Dial a random local port that nothing is listening on.
d := dialer.NewRetryingDialer(context.Background(), net.Dial, 20)
_, err := d.Dial("tcp", "127.0.0.1:65000")
if err != {
t.Fatal(err)
}
}
运行前面的测试会产生以下输出:
图 6:使用真实时钟测试重试拨号器
成功!测试通过了。但是等等;看看测试的运行时间!9 秒!!!我们难道不能做得更好吗?如果我们可以像为其他编程语言编写测试时那样在 Go 中模拟时间,那岂不是太棒了?实际上,借助像jonboulle/clockwork
和juju/clock
这样的包,这是可能的[2]。我们将使用后者包进行测试,因为它还支持模拟计时器。
juju/clock
包暴露了一个Clock
接口,其方法签名与内置time
包导出的函数相匹配。更重要的是,它提供了一个真实的时钟实现(juju.WallClock
),我们应该将其注入到生产代码中,以及一个我们可以操纵于测试中的模拟时钟实现。
如果我们能够将一个clock.Clock
实例注入到RetryingDialer
结构体中,我们就可以用它来替换重试代码中的time.After
调用。这很简单:只需修改 dialer 构造函数参数列表,使其包括一个时钟实例。
现在,让我们创建一个之前测试的副本,但这次将一个模拟时钟注入到 dialer 中。为了控制时间,我们将启动一个 go-routine,通过固定的时间间隔不断推进时钟,直到测试完成。为了简洁,以下列表仅包括控制时钟的代码;除此之外,测试的其余设置及其期望与之前完全相同:
doneCh := make(chan struct{})
defer close(doneCh)
clk := testclock.NewClock(time.Now())
go func() {
for {
select {
case <-doneCh: // test completed; exit go-routine
return
default:
clk.Advance(1 * time.Minute)
}
}
}()
如预期的那样,我们的新测试也成功通过了。然而,与之前的测试运行相比,新的测试只用了极短的时间——仅为 0.010 秒:
图 7:使用模拟时钟测试重试 dialer
个人而言,模拟时钟是我最喜欢的测试原语之一。如果你在测试中没有使用模拟时钟,我强烈建议你至少尝试一下。我相信你也会得出结论,模拟时钟是编写任何处理时间方面的代码的良好行为的测试的绝佳工具。此外,通过在现有的代码库中引入时钟进行少量重构来提高测试套件的稳定性,这是一个合理的权衡。
摘要
如同古老的谚语所说:没有良好的基础就无法建造房屋。同样的原则也适用于软件工程。拥有一个稳固的测试基础设施对于工程师在开发新功能的同时,有信心他们的更改不会破坏现有代码,起到了至关重要的作用。
在本章中,我们深入探讨了在开发中到大型系统时需要了解的不同类型的测试。首先,我们讨论了单元测试的概念,这是所有项目的基本测试类型,无论大小,其主要作用是确保代码的各个单元在独立情况下按预期工作。然后,我们处理了更复杂的模式,如集成和功能测试,这些测试验证单元以及由此扩展的整个系统能够和谐地一起工作。本章的最后部分致力于探索高级测试概念,如冒烟测试和混沌测试,并以一系列编写测试的实用技巧和窍门结束。
现在,是时候戴上你的软件工程帽子,将你迄今为止所获得的所有知识运用到实践中了。为此,在接下来的章节中,我们将从头开始,使用 Go 语言构建一个完整的端到端系统。这个系统将作为我们在本书其余部分介绍的所有概念的实践探索的沙盒。
问题
-
模拟和存根之间有什么区别?
-
解释一下模拟对象是如何工作的,并描述一个场景,说明你为什么选择使用模拟对象而不是模拟。
-
表驱动测试的主要组成部分是什么?
-
单元测试和集成测试之间有什么区别?
-
集成测试和功能测试之间有什么区别?
-
描述大使模式以及如何利用它安全地在生产环境中运行测试。
进一步阅读
-
Bash 手册: 这里文档:
www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Here-Documents
. -
clockwork
:golang
的模拟时钟:github.com/jonboulle/clockwork
. -
gocheck
: Go 语言的丰富测试:labix.org/gocheck
. -
gomock
: Go 编程语言的模拟框架:github.com/golang/mock
. -
Meszaros, Gerard: 《XUnit 测试模式:重构测试代码》。Upper Saddle River, NJ, USA:Prentice Hall PTR,2006 – ISBN 0131495054 (
www.worldcat.org/title/xunit-test-patterns-refactoring-test-code/oclc/935197390
). -
Selenium: 浏览器自动化:
www.seleniumhq.org
. -
testify
: 一个具有常见断言和模拟的工具包,与标准库配合良好:github.com/stretchr/testify
. -
juju/clock
: 时钟定义和测试时钟:github.com/juju/clock
.
第三部分:从头开始设计和构建多层系统
第三部分的目的在于指导您通过使用 Go 语言设计、构建和部署复杂系统的各个阶段。
本节包含以下章节:
-
第五章,“链接即用途”项目
-
第六章,构建持久层
-
第七章,数据处理管道
-
第八章,基于图的数据处理
-
第九章,与外界沟通
-
第十章,构建、打包和部署软件
第五章:Links 'R' Us 项目
“软件任务中最困难的部分是达到完整和一致的需求规格,而构建程序的本质实际上是对规格的调试。”
- 弗雷德里克·P·布鲁克斯 ^([3])
在本章中,我们将讨论 Links 'R' Us,这是一个我们将从本书剩余章节开始从头构建的 Go 项目。这个项目被特别设计用来结合您迄今为止所学的一切,以及我们在以下章节中将要涉及的一些更技术性的主题:数据库、管道、图处理、gRPC、仪表化和监控。
本章将涵盖以下主题:
-
我们将要构建的系统及其主要功能的简要概述
-
为项目选择合适的软件开发生命周期(SDLC)模型
-
功能性和非功能性需求分析
-
Links 'R' Us 服务的基于组件的建模
-
为项目选择合适的架构(单体与微服务)
系统概述 – 我们将要构建什么?
在接下来的章节中,我们将逐步组装我们的搜索引擎。与所有项目一样,我们需要为它想出一个听起来很酷的名字。让我向您介绍Links 'R' Us!
那么,Links 'R' Us 项目的核心功能是什么?主要且相当明显的一个功能是能够搜索内容。然而,在我们能够将我们的搜索引擎向公众开放之前,我们首先需要用内容来填充它。为此,我们需要为用户提供提交 URL 到我们的搜索引擎的手段。搜索引擎随后会爬取这些链接,索引其内容,并将任何新遇到的链接添加到其数据库中以便进一步爬取。
我们需要为 Links 'R' Us 的启动做所有这些吗?简短的答案是:不!虽然用户搜索会返回包含用户搜索查询关键词的结果,但我们缺乏以有意义的方式对这些结果进行排序的能力,尤其是如果结果数量达到数千个。
因此,我们需要在我们的系统中引入某种链接或内容质量指标,并按此对返回的结果进行排序。为了避免重蹈覆辙,我们将站在搜索引擎巨头(即谷歌)的肩膀上,并实现一个经过实战考验的算法,称为 PageRank
。
PageRank
算法是由一篇如今非常流行且被广泛引用的论文《The PageRank Citation Ranking: Bringing Order to the Web》提出的。这篇原始论文于 1998 年由拉里·佩奇、谢尔盖·布林、拉杰夫·莫特瓦尼和特里·温格拉德撰写,并在多年以来一直作为谷歌搜索引擎实现的基础。
给定一个包含网页之间链接的图,PageRank
算法会根据指向它的链接数量及其相对重要性分数,为图中的每个链接分配一个重要性分数。
虽然PageRank
最初被引入作为一种组织网络内容工具,但其通用形式适用于任何类型的链接图。在过去的几年里,人们一直在研究将PageRank
理念应用于众多领域,从生物化学([5])到交通优化([10])。
我们将在第八章基于图的数据处理和第十二章构建分布式图处理系统中更详细地探讨PageRank
算法,作为围绕我们可以采用的多种方法以促进单个节点或节点集群(离核图处理)上大型图处理的大讨论的一部分。
为我们的项目选择 SDLC 模型
在深入探讨“链接之用”项目细节之前,我们需要考虑我们在第一章软件工程的鸟瞰中讨论的 SDLC 模型,并选择一个更适合此类项目的模型。选择合适的模型至关重要:它将作为我们捕捉项目需求、定义组件及其接口合同、以及合理划分独立构建和测试的工作块(逻辑块)的指南。
在本节中,我们将概述选择敏捷框架作为我们项目的主要理由,并详细阐述一套使用称为大象生鱼片的技术来加快我们开发速度的有趣方法。
使用敏捷框架加速迭代
首先,从所有目的来看,Links 'R' Us 是一个典型的绿色领域项目类型。由于没有紧迫的项目交付截止日期,我们绝对应该花时间探索我们可用于实现系统各个组件的任何替代技术的优缺点。
例如,当涉及到索引和搜索我们系统将要抓取的文档时,在决定使用哪一个之前,我们需要评估几个相互竞争的产品/服务。此外,如果我们决定使用 Docker 等工具来容器化我们的项目,那么有几个编排框架(例如,Kubernetes^([6])、Apache Mesos^([2])或 Docker Swarm^([11])可用于将我们的服务部署到我们的预发布和生产环境中。
就软件开发的速度而言,在接下来的几章中,我们将逐步和分阶段地构建“链接之用”的各个组件。鉴于我们正在开发的是一个面向用户的产品,必须以小迭代的方式进行工作,以便我们尽可能早地将原型版本提供给用户焦点小组。这将使我们能够收集宝贵的反馈,有助于我们在开发过程中对产品进行微调和抛光。
由于所有上述原因,我认为采用敏捷方法来开发“链接之用”是明智的。我个人的偏好是使用 Scrum。由于我们实际上没有支持项目开发的真实开发团队,站立会议、计划会议和回顾会议等概念不适用于我们的具体情况。相反,我们需要妥协,并在我们自己的敏捷工作流程中采用 Scrum 背后的某些想法。
为了达到这个目的,在需求分析部分,我们将专注于创建用户故事。一旦这个过程完成,我们将使用这些故事作为输入,推断出我们需要构建的一组高级组件,以及它们之间预期的交互方式。最后,当实施每个用户故事的时候,我们将扮演“产品负责人”的角色,将每个故事分解成一系列卡片,然后我们将这些卡片安排在看板板上。
但在我们开始处理用户故事之前,我想介绍一个非常有用且有帮助的技术,可以帮助你更快地迭代自己的项目:“大象生鱼片”。
“大象生鱼片”——如何更快地迭代!
这个名字独特的技巧的存在归功于阿利斯泰尔·科克本博士发明的一项练习。这个练习的目的是帮助人们(无论是工程师还是非工程师)练习和学习如何将复杂的用户故事卡片(即“大象”)拆分成非常薄的垂直切片,这样团队就可以经常并行处理。
可能会让人觉得奇怪,但我发现我在过去参与的项目中最有帮助的切片大小仅仅是“一天的工作量”。一天分割的合理性是每天(在功能标志后面)交付总工作量的小部分,这种方法与敏捷开发倡导的“快速发布”座右铭是一致的。
说到将卡片拆分为一天的工作量,这当然不是一件简单的事情。这需要一点练习和耐心,以使你的大脑从长期任务转移到分解和优化更短时间的工作负载。另一方面,这种方法允许工程团队尽早识别和解决潜在的阻碍因素;不言而喻,我们当然更希望在大冲刺的早期而不是中期,甚至更糟糕的是,在大冲刺周期的接近结束时发现阻碍因素!
这种技术的另一个优点,至少从 Go 工程师的角度来看,是它让我们更加仔细地思考如何组织我们的代码库,以确保在每天结束时,我们总能有一块可以干净编译和部署的软件。这种约束迫使我们养成按照我们在第二章中探讨的 SOLID 设计原则来思考代码的习惯,即最佳实践编写清晰且可维护的 Go 代码。
需求分析
要对“Links 'R' Us”项目进行详细的需求分析,我们基本上需要回答两个关键问题:我们需要构建什么,以及我们的设计建议如何与一系列目标相匹配?
要回答什么的问题,我们需要列出我们系统预期实现的所有核心功能,以及描述各种参与者如何与之交互。这形成了我们分析中的功能需求(FRs)。
要回答后面的问题,我们必须声明我们解决方案的非功能需求(NFRs)。通常,非功能需求列表包括诸如服务级别目标(SLOs)和容量和可扩展性要求,以及与我们项目相关的安全考虑因素。
功能需求
既然我们已经决定利用敏捷模型来实施我们的项目,那么定义我们的功能需求列表的下一步逻辑步骤就是建立用户故事。
用户故事的概念涉及到从与系统交互的参与者角度表达软件需求的需要。在许多类型的项目中,参与者通常被认为是系统的最终用户。然而,在一般情况下,其他系统(例如,后端服务)也可能扮演参与者的角色。
每个用户故事都以一个 succinct 的需求规范开始。重要的是要注意,规范本身必须 始终 从受其影响的行为者的视角来表述。此外,在创建用户故事时,我们应该始终努力捕捉每个需求的背后 业务价值,也称为 真正的原因。更重要的是,敏捷开发的核心理念之一是所谓的 完成定义。在编写故事时,我们需要包括一个 验收标准 列表,该列表将用作验证工具,以确保每个故事目标都已被成功实现。
为了定义 Links 'R' Us 的功能需求,我们将使用以下相对标准化的敏捷模板:
作为 [行为者]
,
我需要能够 [简短需求]
,
为了[原因/业务价值]。
此用户故事的验收标准如下:
[标准]列表
我还想指出最后一件事,虽然每个故事都会记录特定功能的 需求,但它们都将完全缺乏任何形式的实现细节。这是故意的,并且与使用任何敏捷框架时的推荐实践一致。正如我们在第一章中讨论的,软件工程的鸟瞰图,我们的目标是将任何技术实现决策推迟到最后时刻。如果我们一开始就决定如何实现每个用户故事,我们就会对我们的开发过程施加不必要的限制,从而限制我们的灵活性和在特定时间预算内可以完成的工作量。
现在让我们应用前面的模板来捕捉 Links 'R' Us 项目的功能需求集合,作为一个用户故事列表,这些故事将在接下来的章节中逐一解决。
用户故事 – 链接提交
作为 最终用户
,
我需要能够 提交新的链接到 Links 'R' Us
,
为了更新链接图并使其内容可搜索。
此用户故事的验收标准如下:
-
提供前端或 API 端点,以方便最终用户提交链接。
-
提交的链接有以下标准:
-
必须添加到图中
-
必须被系统爬取并添加到其索引中
-
-
已提交的链接应由后端接受,但不得两次插入到图中。
用户故事 – 搜索
作为 最终用户
,
我需要能够 提交全文搜索查询
,
为了从 Links 'R' Us 索引的内容中检索相关匹配结果列表。
此用户故事的验收标准如下:
-
为用户提供前端或 API 端点以提交全文查询。
-
如果查询匹配多个项目,它们将以列表形式返回,最终用户可以分页浏览。
-
结果列表中的每个条目都必须包含以下项目:标题或链接描述、指向内容的链接,以及表示链接上次爬取时间的戳。如果可行,链接还可以包含一个表示为百分比的关联度分数。
-
当查询不匹配任何项目时,应向最终用户返回适当的响应。
用户故事 – 爬取链接图
作为 爬虫后端系统
,
我需要能够 从链接图中获取一个经过清理的链接列表
,
以便 在同时获取和索引其内容的同时,通过新发现的链接扩展链接图
。
这个用户故事的验收标准如下:
-
爬虫可以查询链接图并接收需要爬取的过时链接列表。
-
爬虫接收到的链接是从远程主机检索的,除非远程服务器提供了爬虫之前已经看到的
ETag
或Last Modified
头信息。 -
获取的内容被扫描以查找链接,并且链接图得到更新。
-
获取的内容被索引并添加到搜索语料库中。
用户故事 – 计算 PageRank 分数
作为 PageRank 计算器后端系统
,
我需要能够 访问链接图
,
以便 计算并持久化每个链接的 PageRank 分数
。
这个用户故事的验收标准如下:
-
PageRank 计算器可以获取整个链接图的不可变快照。
-
图中的每个链接都被分配一个 PageRank 分数。
-
搜索语料库条目被标注为更新的 PageRank 分数。
用户故事 – 监控 Links 'R' Us 健康状况
作为 Links 'R' Us 网站可靠性工程 (SRE) 团队的成员
,
我需要能够 监控所有 Links 'R' Us 服务的健康状况
,
以便 检测和解决导致服务性能下降的问题
。
这个用户故事的验收标准如下:
-
所有 Links 'R' Us 服务应定期向集中式指标收集系统提交健康和性能相关的指标。
-
为每个服务创建一个监控仪表板。
-
一个高级监控仪表板跟踪整体系统健康状况。
-
基于指标的警报被定义并链接到分页服务。每个警报都附带一个自己的剧本,其中包含需要由值班 SRE 团队成员执行的一系列步骤。
非功能性需求
在本节中,我们将讨论 Links 'R' Us 项目的非功能性需求列表。请记住,这个列表并不详尽。由于这不是一个真实世界的项目,我选择只描述一小部分从我们将要在下一章中构建的组件的角度来看有意义的可能非功能性需求。
服务级别目标
从 站点可靠性工程 (SRE) 的角度来看,我们需要制定一个 SLO 列表,这些 SLO 将作为衡量 Links 'R' Us 项目性能的标尺。理想情况下,我们应该为我们的每个服务定义单独的 SLO。在设计阶段这可能不是立即可行的,但至少我们需要为系统面向用户的部分制定一个现实的 SLO。
SLO 由三个部分组成:我们正在测量的东西的描述、以百分比表示的预期服务级别,以及测量发生的周期。以下表格列出了 Links 'R' Us 的一些初始和相当标准的 SLO:
指标 | 预期 | 测量周期 | 备注 |
---|---|---|---|
Links 'R' Us 可用性 | 99% 的正常运行时间 | 每年 | 每年可容忍 3 天 15 小时 39 分钟的停机时间 |
索引服务可用性 | 99.9% 的正常运行时间 | 每年 | 每年可容忍 8 小时 45 分钟的停机时间 |
PageRank 计算器服务可用性 | 70% 的正常运行时间 | 每年 | 不是我们系统面向用户的部分;该服务可以承受更长时间的停机 |
搜索响应时间 | 30% 的请求在 0.5 秒内得到响应 | 每月 | |
搜索响应时间 | 70% 的请求在 1.2 秒内得到响应 | 每月 | |
搜索响应时间 | 99% 的请求在 2.0 秒内得到响应 | 每月 | |
PageRank 计算器服务的 CPU 利用率 | 90% | 每周 | 我们不应该为闲置的计算节点付费 |
SRE 团队事件响应时间 | 90% 的工单在 8 小时内得到解决 | 每月 |
请记住,在这个阶段,我们实际上并没有任何先前数据可用;我们更多或更少地使用 猜测 来确定我们希望实现的服务级别。一旦完整系统部署到生产环境,并且我们的 SRE 团队更好地理解了系统的独特之处,我们将重新审视并更新 SLO,以更好地反映现实情况。
作为快速提醒,本书的第十三章 第十三章,指标收集与可视化,专门关注在生产服务中涉及到的 SRE 方面。在该章中,我们将详细阐述我们可以使用的流行工具,用于捕获、可视化和警报我们的服务级别相关指标。
安全考虑
正如我们所知,当涉及到在线服务时,安全 是一个可以决定特定产品成败的因素。为此,我们需要讨论在构建像 Links 'R' Us 这样的项目时可能出现的潜在安全问题,并制定应对这些问题的策略。
我们的分析基于这样一个前提,你永远不应该信任客户端,在这种情况下,与 Links 'R' Us 互动的用户。如果我们的项目取得成功,它无意中会吸引恶意行为者的注意,他们会在某个时候试图定位并利用我们系统中的安全漏洞。
我之前提出的一个用例涉及用户提交一个服务最终会爬取并添加到其搜索索引中的 URL。你可能想知道一个只爬取用户提交的 URL 的服务可能会出什么问题?以下是一些有趣的例子。
大多数云服务提供商运行一个内部元数据服务,每个计算节点都可以查询以获取自身的信息。此服务通常通过一个链路本地地址(例如169.254.169.254
)访问,节点可以通过简单的 HTTP GET 请求检索它们感兴趣的信息。
链路本地地址是由互联网工程任务组(IETF)保留的特殊地址块。该块中 IPv4 地址的范围用 CIDR 表示法描述为169.254.0.0/16
(65,536 个唯一地址)。类似地,以下地址块已被保留用于 IPv6:fe80::/10
。
这些地址是特殊的,因为它们只在特定的网络段内有效,并且不能超出该网络段进行路由;也就是说,路由器将拒绝将它们转发到其他网络。因此,链路本地地址在内部使用是安全的。
假设我们已经将“链接‘R’我们”项目部署到 Amazon EC2。EC2 元数据服务的文档页面^([1])引用了许多恶意对手可能会使用的链路本地端点。以下是两个从攻击者角度来看更有趣的例子:
-
http://169.254.169.254/latest/meta-data/iam/info
返回有关调用来源的计算节点关联的角色信息。 -
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>
返回与特定角色关联的一组临时安全凭证。
在潜在的攻击场景中,恶意用户将第一个 URL 提交给爬虫。如果爬虫只是简单地抓取链接并将响应添加到搜索索引中,攻击者就可以执行有针对性的搜索并获取与部署爬虫服务的节点相关的安全角色的名称。利用这些信息,攻击者随后将第二个 URL 提交给爬虫,希望再次走运并等待链接被索引后,通过执行第二次有针对性的搜索查询来检索一组有效凭证。这样,对手就可以未经授权访问项目内部使用的另一个服务(例如,一个存储服务如 S3)。
如果你对此感到好奇,谷歌云和微软 Azure 通过要求计算节点在联系其元数据服务时必须存在一个特殊的 HTTP 头来缓解这种信息泄露漏洞。然而,这并不意味着我们不应该在我们的爬取操作中排除其他IP 范围。首先,我们应该始终排除私有网络地址。毕竟,我们可能选择使用的一些服务(例如 Elasticsearch)可能会暴露未经身份验证的 RESTful API,这些 API 可以通过运行爬虫代码的计算节点访问。显然,我们不希望我们的后端服务信息出现在我们的搜索索引中!以下表格列出了我们应该绝对避免爬取的一些特殊 IPv4 地址范围:
IP 块(CIDR 表示法) | 描述 |
---|---|
10.0.0.0/8 | 私有网络 |
172.16.0.0/12 | 私有网络 |
192.168.0.0/16 | 私有网络 |
169.254.0.0/16 | 链路本地地址 |
127.0.0.1 | 环回 IP 地址 |
0.0.0.0/8 | 本地机器上的所有 IP 地址 |
255.255.255.255/32 | 当前网络的广播地址 |
前面的表格中的列表并不完整。实际上,你可能需要排除一些额外的 IP 块,例如为运营商级流量、多播和测试网络预留的 IP 块。更重要的是,如果我们云提供商的网络堆栈支持 IPv6,我们还应该排除等效的 IPv6 地址范围。如果你对这个主题感兴趣,你可以在 MASSCAN 项目的 GitHub 仓库中找到一个全面的 IPv4 黑名单^([8])。
最后,你可能知道也可能不知道的是,许多 URL 爬取库支持除了http/https
之外的其他方案。这些方案中的一个例子是file
,除非禁用,否则可能会允许攻击者欺骗爬虫读取和索引爬虫执行节点上的本地文件内容(例如,/etc/passwd
)。
如果你之前没有使用过文件协议方案,请尝试在你的浏览器中输入以下地址:file:///
。
成为好网民
虽然我们的最终目标是能够爬取和索引整个互联网,但事实是,我们检索和索引的链接指向的是属于他人的内容。可能发生的情况是,第三方反对我们索引其控制下的域的一些或所有链接。
幸运的是,网站管理员有一种标准化的方式来通知爬虫,不仅关于它们可以爬取哪些链接以及哪些链接不允许爬取,还可以指定一个可接受的爬取速度,以避免对远程主机造成高负载。这一切都是通过编写一个robots.txt
文件并将其放置在每个域的根目录下实现的。该文件包含一系列如下所示的指令:
-
User-Agent:以下指令适用的爬虫(用户代理字符串)的名称
-
Disallow:一个正则表达式,用于排除任何匹配的 URL 被爬取
-
Crawl-Delay:爬虫在爬取该域名后续链接之前等待的秒数
-
Sitemap:一个指向 XML 文件的链接,该文件定义了域名内的所有链接,并提供爬虫可以用来优化其链接访问模式的元数据,如最后更新时间戳
要成为好网民,我们需要确保我们的爬虫实现尊重它遇到的任何robots.txt
文件的内容。最后但同样重要的是,我们的解析器应该能够正确处理远程主机返回的各种状态码,并在检测到远程主机问题或远程主机决定限制我们时降低爬取速度。
系统组件建模
在映射项目架构的第一步,我们将开始创建一个 UML 组件图。这里的主要目标是识别和描述构成我们系统的各种组件之间的结构连接。
组件被定义为封装的独立单元,它是系统或子系统的组成部分。组件通过暴露和消费一个或多个接口相互通信。
基于组件设计的要点之一是,组件应始终被视为抽象的逻辑实体,它们暴露特定的行为。这种设计方法与 SOLID 原则紧密一致,并为我们提供了在项目开发过程中的任何时刻自由更改或甚至交换组件实现的灵活性。
下面的图表将“链接之用”项目分解为高级组件,并直观地展示了每个组件暴露和使用的接口:
图 1:“链接之用”项目的 UML 组件图
如果你对这种图表所使用的符号不熟悉,这里有一个快速说明,解释每个符号代表什么:
-
两侧带有两个类似端口符号的框表示组件。
-
组件也可以嵌套在其他组件内部。在这种情况下,每个子组件都被封装在其父组件表示的框内。在前面的图表中,链接过滤器是爬虫的子组件。
-
完整的圆圈表示特定组件实现的接口。例如,搜索是内容索引器组件实现的接口之一。
-
半圆表示一个组件需要特定的接口。例如,链接提取器组件需要由链接图组件实现的插入链接接口。
现在我们已经绘制出了构建我们项目所需系统组件的高级视图,我们需要花些时间更详细地检查每一个。
爬虫
爬虫组件实际上是搜索引擎的核心。它在一个链接集上运行,这些链接要么被播种到系统中,要么在爬取先前链接集时发现。正如你在前面的组件模型图中看到的,爬虫本身实际上是一个封装了其他几个子组件的包,这些子组件以类似管道的配置运行。让我们更详细地检查每个子组件的作用。
链接过滤器
一个简单的爬虫实现会尝试检索作为输入提供的任何链接。但正如我们所知,网络上有各种内容,从文本或 HTML 文档到图像、音乐、视频以及各种其他类型的二进制数据(例如,归档、ISO、可执行文件等)。
你可能会同意,尝试下载搜索引擎无法处理的物品不仅会浪费资源,而且还会给“链接 R 我们”服务的运营商(也就是我们)带来额外的运行成本。因此,从爬虫中排除此类内容将是一种有益的成本降低策略。
这就是链接过滤器组件发挥作用的地方。在我们尝试获取远程链接之前,链接过滤器将首先尝试识别另一侧的内容,并丢弃任何看起来不指向我们可以处理的内容的链接。
链接获取器
所有通过链接过滤器筛选存活的链接都将被链接获取器组件消耗。正如其名称所暗示的,这个组件负责与每个链接目标建立 HTTP 连接,并检索服务器另一端返回的任何内容。
获取器会仔细处理远程服务器返回的 HTTP 状态码和任何 HTTP 头信息。如果返回的状态码表明内容已移动到不同的位置(即 301 或 302),获取器将自动跟随重定向,直到到达内容的最终目的地。从逻辑上讲,我们不希望我们的获取器陷入无限重定向循环,试图爬取配置错误(或恶意)的远程主机。为此,爬虫需要维护一个重定向跳数计数器,并在超过特定值时终止爬取尝试。
获取器还非常关注另一个重要的 HTTP 头信息,即Content-Type
头信息。这个头信息由远程服务器填充,并标识服务器返回的数据类型(也称为 MIME 类型)。如果远程服务器回复一个不支持的 MIME 类型头信息(例如,指示图像或 JavaScript 文件),获取器应自动丢弃链接,并防止其进入爬取管道的下一阶段,即内容提取器和内容索引器。
内容提取器
内容提取器试图从从远程服务器下载的文档中识别并提取所有文本。例如,如果链接指向一个纯文本文档,那么提取器会原样输出文档内容。另一方面,如果链接指向一个 HTML 文档,提取器会移除任何 HTML 元素,并输出文档的纯文本部分。
输出的内容被发送到内容索引器组件,以便对其进行分词并更新 Links 'R' Us 全文搜索索引。
链接提取器
我们将要检查的最后一个爬虫组件是链接提取器。它扫描检索到的 HTML 文档,并尝试识别和提取其中所有的链接。
链接提取是一个不幸的简单任务。虽然确实大多数链接可以通过一系列正则表达式提取,但还有一些边缘情况需要我们额外的逻辑处理,如下面的例子所示:
-
相对链接需要转换为绝对链接。
-
如果文档的
<head>
部分包含<base href="xxx">
标签,我们需要解析它并使用其内容来重写相对链接。 -
我们可能会遇到没有指定协议的链接。这些特殊链接以
//
开头,通常用于引用 CDN 中的内容或在包含来自非 HTTPS 源静态资源的 HTTPS 页面中(例如,购物车结账页面中的图片)。当网络浏览器遇到这样的链接时,它会自动使用当前 URL 的协议来获取这些链接。
链接提取器会将所有新发现的链接传输到链接图组件,以便更新现有图连接并创建新的连接。
内容索引器
内容索引器是 Links 'R' Us 项目中的另一个非常重要的组件。该组件执行两个不同的功能。
首先,该组件维护一个全文索引,用于爬虫检索到的所有文档。任何由内容提取器组件输出的新或更新文档都会传播到内容索引器,以便更新索引。
逻辑上讲,如果一个索引没有搜索手段,那么它的实用性将大大降低。为此,内容索引器公开了允许其他组件对索引执行全文搜索并按检索日期和/或 PageRank 分数排序的机制。
链接提供者
链接提供者组件定期清理链接图,并收集新爬取遍历的候选链接列表。候选链接包括以下内容:
-
最近发现的尚未被爬取的链接
-
最近爬取尝试失败的链接(例如,爬虫从远程服务器收到了 404/NOT-FOUND 响应)
-
爬虫过去成功处理但需要重新访问的链接,以防指向的内容已更改
由于万维网由令人难以置信数量的页面组成(截至 2020 年 1 月,大约有 61.6 亿个页面),因此假设随着时间的推移,我们发现的链接图增长,我们最终会达到一个点,即我们需要抓取的链接集将超过我们计算节点的可用内存容量!这就是为什么链接提供者组件采用 流式 方法:当链接清洗过程正在执行时,任何选定的链接候选者将立即传递给 捕收器 组件以进行进一步处理。
链接图
链接图 负责跟踪不仅包括捕收器迄今为止发现的全部链接,还包括它们的连接方式。它为其他组件提供了添加或从图中删除链接的接口,当然,查询图。
几个其他系统组件依赖于链接图组件公开的接口:
-
链接提供者 会查询链接图来决定哪些链接应该被接下来抓取。
-
捕收器的 链接提取器 子组件会将新发现的链接添加到图中。
-
PageRank 计算器 组件需要访问整个图的连接信息,以便计算每个链接的 PageRank 分数。
注意,我并不是在谈论一个 单一 接口,而是在使用复数形式:接口。这是故意的,因为链接图组件是实施 命令查询责任分离(CQRS)模式的理想候选者。
CQRS 模式属于架构模式家族。CQRS 背后的关键思想是将特定组件公开的写入和读取模型分离,以便它们可以独立优化。命令 指的是改变模型状态的操作,而 查询 是检索和返回当前模型状态。
这种分离使我们能够为读取和写入执行不同的业务逻辑路径,并且实际上使我们能够实现复杂的访问模式。例如,写入可以是同步过程,而读取可能是异步的,并且可以提供对数据的有限视图。
作为另一个例子,该组件可以为写入和读取使用不同的数据存储。写入最终会缓慢地流入读取存储,但也许读取存储的数据也可以通过从其他下游组件获取的外部数据来增强。
PageRank 计算器
PageRank 计算器实现了异步、周期性的过程,用于重新评估 Links 'R' Us 图中每个链接的 PageRank 分数。
在开始新的计算遍历之前,PageRank 组件将首先使用链接图组件公开的接口来获取当前图状态的快照。这包括图顶点(链接目的地)和连接它们的边(链接)。
一旦每个链接的 PageRank 值被计算出来,PageRank 组件将联系文本索引器组件,并使用更新的 PageRank 分数标注每个索引文档。这是一个异步过程,并且不会干扰“链接之用”用户执行的任何搜索。
指标存储
由于“链接之用”项目由多个组件组成,因此对我们来说部署监控基础设施是有意义的,这样我们就可以跟踪每个组件的健康状况。这样,我们可以识别出那些表现出高错误率或负载过高的组件,并需要扩展。
这是指标存储组件的主要作用。正如您在组件图中可以看到的,我们设计中的所有组件都将指标传输到指标收集器,因此依赖于它。当然,这并不是一个硬依赖:我们的系统设计应该假设指标收集器在任何时候都可能离线,并确保在生产中发生这种情况时,其他组件不会受到影响。
前端
前端组件的目的是渲染一个简单、静态的基于 HTML 的用户界面,这将使用户能够方便地与项目交互。更具体地说,前端组件的设计将使用户能够执行以下一系列功能:
-
直接提交新的 URL 进行索引。
-
输入一个关键词或基于短语搜索查询。
-
分页特定查询的搜索结果。
重要的是要注意,在我们的当前设计中,前端组件作为我们项目对外部世界可访问的入口点!鉴于其他项目组件不能直接被最终用户访问,我们可以认为前端还充当了一个API 网关,其中每个传入的 API 请求都映射到内部系统组件的一个或多个调用。除了将我们的内部组件与外界隔离的明显安全优势之外,API 网关模式还提供了以下一系列额外的好处:
-
如果一些内部调用需要异步执行,网关可以并行执行它们,并在聚合它们的响应并同步返回给用户之前等待它们完成。
-
它使我们能够将内部组件之间通信的方式与外部世界用于与我们的系统接口的机制解耦。这意味着我们可以向外部世界公开 RESTful API,同时仍然保留选择每个内部组件最合适的传输方式(例如,REST、gRPC 或可能是一个消息队列)的灵活性。
集成式或微服务?终极问题
在开始开发“链接之用”服务之前,我们需要决定我们的系统组件是作为大型单体服务的一部分开发,还是我们将直接实施面向服务的架构。
虽然从外面看,使用微服务的概念确实很有吸引力,但它也带来了很多运营开销。除了构建和连接所有组件所需的精神努力外,我们还需要担心以下问题:
-
每个服务是如何部署的?我们是进行滚动部署吗?那么,关于暗黑或测试版本呢?当出现问题需要回滚时,回滚到之前的部署有多容易?
-
我们是否会使用像 Kubernetes 这样的容器编排层?服务之间的流量是如何路由的?我们需要使用像 Istio 或 Linkerd 这样的服务网格吗?
-
我们如何监控我们服务的健康状态?此外,我们如何收集所有服务的日志?
-
我们将如何处理服务中断?我们需要实现断路器来防止有问题的服务破坏依赖于它的上游服务吗?
当然,我们所有人都知道单体设计的缺点,但另一方面,我们没有任何可用数据来证明从项目开始就拆分组件成微服务的额外成本是合理的。
考虑到每种方法的优缺点,看起来最好的行动方案是采取混合方法!我们最初将使用单体设计来开发我们的组件。然而,这里有个转折,每个组件都将定义一个接口,其他组件将使用该接口与其通信。
为了在不引入它们具体实现之间的任何耦合的情况下连接组件,我们将使用代理设计模式。最初,我们将提供模拟代理实现,以促进同一进程内组件之间的通信。这当然在功能上等同于我们通常在单体设计中直接连接组件。
随着我们的系统增长和演变,我们最终会达到一个需要将一个或多个组件提取为独立服务的地方。使用前面的模式,我们只需要更新我们的代理以使用适当的传输(例如,REST、gRPC 和消息队列)来连接组件,而无需修改任何现有的组件实现。
概述
这标志着“链接之用”项目的介绍结束。我希望到这一点,你已经对接下来几章将要构建的内容有了大致的了解。如果你对项目组件的技术实现细节感到好奇,那是非常正常的。本章的主要目的是介绍项目的高级概述。我们将在接下来的页面中详细分析每个组件的构建!
为了使以下章节的概念和代码更容易理解,我们将把每一章分成两个核心部分:
-
在每一章的前半部分,我们将深入探讨一个特定的技术主题,例如,对流行数据库类型(关系型、NoSQL 等)的调查,如何在 Go 中创建管道,如何大规模运行图操作,什么是 gRPC 以及如何使用它,等等。
-
在本章的后半部分,我们将把前半部分的概念应用到构建“链接之用”项目的一个或多个组件上。
在下一章中,我们将集中精力构建“链接之用”项目的关键组件之一:一个完整功能的数据持久层,用于存储爬虫发现的链接和索引爬虫检索到的每个网页的内容。
问题
-
功能性需求与非功能性需求之间的区别是什么?
-
描述用户故事的主要组成部分。
-
如果我们盲目地爬取用户提交给系统的任何链接,在“链接之用”场景中可能会出现哪些问题?
-
列出 SLO 的关键组成部分。
-
UML 组件图的目的是什么?
进一步阅读
-
亚马逊弹性计算云:实例元数据和用户数据:
docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
-
Apache Mesos: 将数据中心当作单一资源池来编程:
mesos.apache.org
-
布鲁克斯,弗雷德里克·P.,小:《人月神话(周年纪念版)》。波士顿,马萨诸塞州,美国:Addison-Wesley Longman 出版公司,1995 —
www.worldcat.org/title/mythical-man-month/oclc/961280727
-
Istio: 连接、安全、控制和观察服务:
istio.io
-
Ivn,Gbor 和 Grolmusz,Vince:当网络遇到细胞:使用个性化 PageRank 分析蛋白质相互作用网络。
-
Kubernetes: 生产级容器编排:
kubernetes.io
-
Linkerd: 适用于 Kubernetes 及更广泛的超轻量级服务网格:
linkerd.io
-
MASSCAN: 大规模 IP 端口扫描器;保留 IP 排除列表:
github.com/robertdavidgraham/masscan/blob/master/data/exclude.conf
-
Page, L.; Brin, S.; Motwani, R.; and Winograd, T.: PageRank 引文排名:为网络带来秩序. In: 第七届国际万维网会议论文集. 澳大利亚布里斯班,1998 年,第 161–172 页
-
Pop, Florin ; Dobre, Ciprian: 一种高效的 PageRank 方法用于城市交通优化.
-
Swarm: 一个 Docker 原生集群系统:
github.com/docker/swarm
第六章:构建持久化层
“数据库模式通常是易变的、非常具体且高度依赖的。这是为什么面向对象的应用程序和数据库之间的接口如此难以管理,以及为什么模式更新通常很痛苦的一个原因。”
- 罗伯特·C·马丁 ^([14])
在本章中,我们将专注于设计和实现 Links 'R' Us 组件中的两个数据访问层:链接图和文本索引器。更具体地说,在接下来的页面中,我们将执行以下操作:
-
讨论和比较不同类型的数据库技术
-
识别和理解需要创建数据访问层作为底层数据库层的抽象的主要原因
-
分析链接图组件的实体、关系和查询需求,定义数据层的 Go 接口,并从头开始构建两个替代数据层实现:一个简单的内存存储,我们可以用于测试目的,以及一个由 CockroachDB 支持的生产就绪存储
-
提出一个用于索引和搜索网页内容的文档模型,并实现一个基于流行的 bleve Go 包的内存索引器,以及一个基于 Elasticsearch 的水平可扩展变体
-
概述创建可跨不同数据层实现共享和重用测试套件的策略
技术要求
本章将要讨论的主题的完整代码已发布在本书的 GitHub 仓库的 Chapter06
文件夹下。
您可以通过 github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
访问本书的 GitHub 仓库。
为了让您尽快开始,每个示例项目都包含一个 makefile,它定义了以下目标集:
Makefile 目标 | 描述 |
---|---|
deps |
安装任何必需的依赖项。 |
test |
运行所有测试并报告覆盖率。 |
lint |
检查 lint 错误。 |
与本书中的所有其他章节一样,您需要一个相当新的 Go 版本,您可以从 golang.org/dl
. 下载。
运行需要 CockroachDB 的测试
要运行使用 CockroachDB 作为后端的链接图测试,您需要从 www.cockroachlabs.com/get-cockroachdb
下载 CockroachDB 的最新版本(v19.1.2 或更高版本)。
在下载并解压 CockroachDB 存档后,您可以通过切换到存档提取的文件夹并运行以下命令集来为测试启动一个 CockroachDB 实例:
cockroach start --insecure --advertise-addr 127.0.0.1:26257.
cockroach sql --insecure -e 'CREATE DATABASE linkgraph;'
针对 CockroachDB 后端的链接图测试将通过查找访问 CockroachDB 实例的有效数据源名称(DSN)来检查CDB_DSN
环境变量的内容。如果环境变量为空或未定义,所有 CockroachDB 测试将自动跳过。
假设您已经按照前面的说明启动了本地的 CockroachDB 实例,您可以通过执行以下命令来定义一个合适的 DSN,在运行 CockroachDB 测试套件之前:
export CDB_DSN='postgresql://root@localhost:26257/linkgraph?sslmode=disable'
最后,需要注意的是,所有测试都假设数据库模式已经预先设置。如果您刚刚创建了数据库,可以通过切换到这本书源代码仓库的本地检出副本并运行make run-cdb-migrations
来应用所需的 DB 迁移集。
运行需要 Elasticsearch 的测试
要运行使用 Elasticsearch 作为后端的链接图测试,您需要从www.elastic.co/downloads/elasticsearch
.下载一个较新的 Elasticsearch 版本(v7.2.0 或更高版本)。
下载并解压 Elasticsearch 存档后,您可以切换到提取文件的目录,并通过运行以下命令启动本地 Elasticsearch 实例(使用合理的默认配置选项):
bin/elasticsearch
Elasticsearch 测试通过检查ES_NODES
环境变量的内容来获取要连接的 Elasticsearch 集群端点列表。假设您已经按照上述说明启动了本地 Elasticsearch 实例,您可以如下定义ES_NODES
:
export ES_NODES='http://localhost:9200'
如我们将在以下章节中看到的,Elasticsearch 索引器将被设计成一旦成功连接到 Elasticsearch 集群,就能自动为索引文档定义模式。因此,在运行 Elasticsearch 测试套件之前不需要单独的迁移步骤。
探索数据库系统的分类
在以下章节中,我们将列出最受欢迎的数据库技术,并分析每种技术的优缺点。根据我们的分析,我们将选择最适合实现 Links 'R' Us 的链接图和文本索引组件的数据库类型。
键值存储
我们将要考察的第一种数据库技术是键值存储。正如其名所示,键值存储数据库将数据持久化为键值对的集合,其中键作为访问特定集合中存储数据的唯一标识符。根据这个定义,键值存储在功能上等同于哈希表数据结构。流行的键值存储实现包括 memcached^([15])、AWS DynamoDB([8])、LevelDB([13])和针对 SSD 优化的 RocksDB^([20])。
键值存储支持的基本操作集包括插入、删除和查找。然而,一些流行的键值存储实现还提供了对范围查询的支持,允许客户端迭代两个特定键之间的有序键值对列表。就键和值而言,大多数键值存储实现对其内容不施加任何约束。这意味着任何类型的数据(例如,字符串、整数或甚至是二进制大对象)都可以用作键。
键值存储使用的数据访问模式使得在多个节点之间进行数据分区比其他数据库技术要容易得多。这种属性使得键值存储能够水平扩展,以适应增加的流量需求。
让我们来看看一些常见用例,在这些用例中,键值存储通常被认为是一个非常好的选择:
-
缓存!我们可以将键值存储用作通用缓存,用于各种事物。例如,我们可以为 CDN 服务缓存网页或存储常用数据库查询的结果,以减少 Web 应用的响应时间。
-
用于会话数据的分布式存储:想象一下,如果我们运营一个高流量的网站。为了处理流量,我们通常会启动一些后端服务器并将它们放置在负载均衡器后面。除非我们的负载均衡器内置了对粘性会话(总是将来自同一用户的请求发送到同一后端服务器)的支持,否则每个请求都会由不同的后端服务器处理。这可能会对有状态应用程序造成问题,因为它们需要访问与每个用户关联的会话数据。如果我们给每个用户请求标记一个唯一的用户 ID,我们就可以使用它作为键,从键值存储中检索会话数据。
-
数据库系统的存储层。键值存储的特性使它们成为实现更复杂类型数据库的非常吸引人的底层原语。例如,CockroachDB ^([5])等关系型数据库和 Apache Cassandra ^([2])等 NoSQL 数据库是建立在键值存储之上的系统的典型例子。
键值存储的主要缺点是,我们无法在不引入某种辅助数据结构以促进索引作用的情况下,有效地在存储的数据中进行搜索。
关系型数据库
关系型数据库的概念是由 E. F. Codd 在 1970 年提出的 ^([6])。关系型数据库中数据组织的主要单元被称为表。每个表都与一个模式相关联,该模式定义了每个表列的名称和数据类型。
在表中,每个数据记录由一个行表示,该行反过来由一个主键标识,这是一个列值的元组,必须在所有表行中是唯一的。表列也可以引用其他表中存在的记录。这类列通常被称为外键。
访问和查询关系型数据库的标准方式是通过使用类似英语的*结构化查询语言(SQL),实际上它是各种领域特定语言的子集:
-
一种数据定义语言,包括用于管理数据库模式(例如,创建、修改或删除表、索引和约束)的命令
-
一种数据操作语言,支持一系列灵活的命令,用于插入、删除以及当然,查询数据库内容
-
一种数据控制语言,提供了一种简化的方式来控制单个用户对数据库的访问级别
-
一种事务控制语言,允许数据库用户启动、提交或中止数据库事务
关系型数据库最重要的特性之一是事务的概念。可以将事务视为一系列 SQL 语句的包装器,确保要么全部应用这些语句,要么全部不应用。为了确保在出现错误或故障(例如,断电或网络连接丢失)的情况下事务能够可靠地工作,并且当多个事务并发执行时,其结果始终是确定的,关系型数据库必须符合一组通常用缩写ACID表示的属性。让我们来看看ACID代表什么:
-
原子性:事务要么完全应用,要么完全不应用。
-
一致性:不允许事务的内容将数据库带入无效状态。这意味着数据库系统必须验证事务中包含的每个语句,与即将修改的表上定义的约束(例如,主键、外键或唯一键)进行验证。
-
隔离性:每个事务必须与其他事务完全隔离执行。如果多个事务正在并发执行,最终结果应该等同于依次运行每个事务。
-
耐用性:一旦事务被提交,它将保持提交状态,即使数据库系统重启或运行其上的节点发生断电。
在性能方面,像 PostgreSQL^([18])和 MySQL^([17])这样的关系型数据库通常很容易进行垂直扩展。将更强大的 CPU 和/或更多内存添加到您的数据库服务器上,基本上是一种标准操作程序,用于增加数据库可以处理的每秒查询数(QPS)或每秒事务数(TPS)。另一方面,水平扩展关系型数据库要困难得多,通常取决于您的工作负载类型。
对于写密集型的工作负载,我们通常求助于数据分片等技术。数据分片允许我们将一个或多个表的内容分割成多个数据库节点。这种分区是通过每行的分片键来实现的,它决定了哪个节点负责存储表的每一行。这种方法的一个缺点是它在查询时引入了额外的复杂性。虽然写操作相当高效,但读操作并不简单,因为数据库可能需要查询每个单独的节点,然后将结果汇总在一起,以便回答甚至像SELECT COUNT(*) FROM X
这样的简单查询。
另一方面,如果我们的工作负载是读密集型的,水平扩展通常是通过启动读副本来实现的,这些副本会镜像一个或多个主节点的更新。写操作总是路由到主节点,而读操作由读副本(理想情况下)或如果读副本无法访问,甚至由主节点来处理。
虽然关系型数据库非常适合事务型工作负载和复杂查询,但它们并不是查询具有任意嵌套的层次数据或建模图状结构的最佳工具。此外,随着存储数据的量超过特定阈值,查询的运行时间会越来越长。最终,会达到一个点,以前实时执行的报告查询只能作为离线批处理作业来处理。因此,对大量数据处理有需求的公司已经逐渐将他们的重点转向 NoSQL 数据库。
NoSQL 数据库
在过去几年中,NoSQL 数据库的受欢迎程度急剧上升。它们的关键价值主张如下:
-
它们非常适合处理大量数据。
-
按设计,NoSQL 数据库系统可以轻松地进行垂直和水平扩展。事实上,大多数 NoSQL 数据库系统承诺随着数据库集群中节点数量的增加,性能将线性增长。
-
更高级的 NoSQL 解决方案甚至可以跨数据中心进行扩展,并包括自动将客户端请求路由到最近数据中心的支持。
然而,众所周知,没有免费的午餐。为了实现这种性能提升,NoSQL 数据库必须做出一些牺牲!作为分布式系统,NoSQL 数据库必须遵守CAP 定理的规则。
CAP 定理是由埃里克·布赖尔在 2000 年提出的^([4]),是支配分布式系统操作的几个基本定理之一。它表明,网络共享数据系统只能保证以下两个属性:
-
一致性:系统中的每个节点对存储的数据都有相同的视图。这意味着对数据的一次读取操作将始终返回最后一次执行写入操作时的值。
-
可用性:即使某些节点离线,系统仍然可以在合理的时间内处理读取和写入请求。
-
分区容错性:如果发生网络分割,一些集群节点将变得孤立,因此无法与集群中剩余的节点交换消息。然而,系统应该保持运行,并且当分割的节点重新加入集群时,集群应该能够达到一致状态。
如以下图所示,如果我们把 CAP 定理的三个基本属性中的两个配对,我们可以获得一些有趣的分布式系统配置:
图 1:CAP 定理三个属性的交集
让我们简要分析一下,在出现错误的情况下,这些配置是如何反应的:
-
一致性 – 分区(CP)容错性:这类分布式系统通常使用投票协议来确保大多数节点都同意它们拥有存储数据的最新版本;换句话说,它们达到法定人数。这允许系统从网络分区事件中恢复。然而,如果可供达到法定人数的节点不足,系统将向客户端返回错误,因为数据一致性比可用性更重要。
-
可用性 – 分区(AP)容错性:这类分布式系统更倾向于可用性而不是一致性。即使在网络分割的情况下,AP 系统也会尝试处理读取请求,尽管可能会向客户端返回过时的数据。
-
一致性 – 可用性(CA):在实践中,所有分布式系统在某种程度上都会受到网络分区的影响。因此,除非我们谈论的是单节点系统,否则纯 CA 类型的系统实际上并不可行。我们可能将传统关系型数据库的单节点部署归类为 CA 系统。
最后,选择合适的 NoSQL 解决方案在很大程度上取决于您的特定用例。那么,如果用例需要这三个属性中的所有三个,我们是不是就没有运气了?
幸运的是,多年来,几个 NoSQL 解决方案(例如,Cassandra ^([2])) 已经发展了对现在被称为 可调一致性 的支持。可调一致性允许客户端根据每个查询指定他们期望的一致性级别。例如,在创建新的用户账户时,我们通常会选择强一致性语义。另一方面,在查询热门视频的观看次数时,我们可以降低期望的一致性级别,并满足于一个近似、最终一致的价值。
文档数据库
文档数据库是专门化的 NoSQL 数据库,用于存储、索引和查询复杂且可能深度嵌套的 文档-like 对象。所有文档都存储在一个 集合 中,这相当于关系数据库中的表。使文档数据库独特的关键区别在于,它们不强制执行特定的模式(即它们是无模式的),而是从存储的数据中 推断 模式。这种设计决策允许我们在同一个集合中存储 不同 类型的文档。更重要的是,每个单独的文档的模式和内容都可以随着时间的推移而演变,而不会对数据库的查询性能产生明显的影响。
与标准化了 SQL 的关系数据库相反,文档数据库通常实现自己的 领域特定语言 (DSL) 用于查询数据。然而,它们也提供了高级原语(例如,支持 map-reduce),用于在集合中的多个文档上计算复杂的聚合。这使得文档数据库非常适合生成 商业智能 (BI) 和其他类型的分析报告。
文档数据库系统的列表相当长,所以我只会列出一些我认为更受欢迎的实现:MongoDB ^([16])、CouchDB ^([3]) 和 Elasticsearch ^([9])。
理解数据层抽象的需求
在我们深入探讨链接图和文本索引器组件的数据层建模之前,我们需要花一些时间讨论引入数据层抽象背后的原因。
首先,数据层的主要目的是将我们的代码与底层数据存储实现解耦。通过针对一个定义良好且数据存储无关的接口进行编程,我们确保我们的代码保持清洁、模块化,并且完全不了解访问每个数据存储的细微差别。
这种方法的额外好处是,它为我们提供了在决定为我们的生产系统使用哪种数据存储技术之前,对不同数据存储技术进行 A/B 测试的灵活性。更重要的是,即使我们的原始决策在长期内证明不够出色(例如,服务流量超过存储的垂直/水平扩展能力),我们也可以轻松切换到不同的系统。这可以通过连接一个新的数据存储适配器实现,而无需修改我们服务实现中的任何高级部分。
这种抽象层的最终优势与测试有关。通过为每个我们感兴趣支持的数据存储提供单独的 Go 包,我们不仅可以封装特定存储的逻辑,还可以编写全面的测试套件来测试每个存储的行为,而无需从其余代码库中完全隔离。一旦我们确信实现符合预期,我们可以使用我们在第四章《测试的艺术》中概述的任何测试机制(例如,模拟、存根和假对象)来测试需要访问数据存储的其他高级组件,而实际上并不需要配置真实的数据存储实例。
初始时,这可能看起来不是什么大好处。然而,对于产生多个包的大型 Go 项目来说,在测试之间设置、用固定值填充以及最终清理数据库的成本可能相当高。与使用内存数据存储实现相比,针对真实数据库的测试不仅运行时间更长,而且可能证明相当不可靠。
你在过去可能遇到的一个常见问题是,对于属于不同包但尝试并发访问和/或填充相同数据库实例的测试,可能存在潜在的数据库访问竞态条件。结果,一些与数据库相关的测试可能会以非确定性的方式随机开始失败。当然,根据墨菲定律,这种问题很少在本地测试时出现,而是在你提交的拉取请求进行持续集成系统测试时才倾向于显现出来!
如果你的代码库中的多个包由于go test
命令默认会并发运行属于不同包的测试,因此与底层数据库有很强的耦合,那么最终陷入这种混乱的情况是很常见的。作为一个临时的解决方案,你可以通过提供-parallel 1
命令行标志来强制go test
序列化所有测试的执行。然而,这个选项会严重增加测试套件的总体执行时间,对于大型项目来说可能是过度杀鸡用牛刀。将需要真实数据库存储实例的测试封装到一个单独的包中,并在其他地方使用模拟,是缓解此类问题的干净且优雅的解决方案。
设计链接图组件的数据层。
在以下章节中,我们将对用于链接图组件操作所需的数据模型进行扩展分析。我们将通过为组成数据访问层的实体创建一个实体-关系(ER)图来启动我们的分析。然后,我们将定义一个接口,该接口完全描述了数据访问层必须支持的操作集。
最后,我们将设计和构建两个替代的数据访问层实现(内存和 CockroachDB 支持的),这两个实现都满足上述接口。为了确保两种实现的行为完全相同,我们还将创建一个全面的、存储无关的测试套件,并安排我们的测试代码为每个单独的存储实现调用它。
我们将在以下章节中讨论的所有代码都可以在本书的 GitHub 仓库的Chapter06/linkgraph
文件夹中找到。
为链接图存储创建 ER 图。
以下图展示了链接图数据访问层的 ER 图。鉴于爬虫检索网页链接并发现网站之间的连接,对我们来说使用基于图的表现形式来对系统建模是有意义的。正如您所看到的,ER 图由两个模型组成:链接和边:
图 2:链接图组件的 ER 图
链接模型实例代表由爬虫组件处理或发现的网页集合。其属性集包括一个用于唯一标识每个链接的 ID 值,与之关联的 URL,以及一个表示爬虫最后一次检索它的时间戳值。上述列表构成了为“链接‘R’Us”项目建模链接图所需的最小属性集。在实际实现中,我们可能会希望用以下附加元数据来增强我们的链接模型:
-
URL 内容的 MIME 类型(由远程服务器指示)及其字节数长度。
-
最后一次爬取尝试的 HTTP 状态码。这对于重试失败的尝试或从我们的图中删除死链接非常有用。
-
执行未来爬取请求的(按域名或按链接)首选时间窗口。由于网络爬虫在从远程服务器获取链接时往往会引起显著的流量峰值,因此这些信息可以由我们的爬虫用于在非高峰时段安排其更新周期,从而最小化其对远程服务器的影响。
图中的每个网页可能包含零个或多个指向其他网页的出站链接。Edge 模型实例表示图中两个链接之间的单向连接。如图所示,Edge 模型的属性集包括边本身的唯一 ID,以及源链接和目标链接的 ID。这种建模方法还可以支持网页之间的双向链接(也称为反向链接),但有一个小的限制,即它们需要表示为两个独立的边条目。
此外,边属性集还包含一个时间戳值,用于跟踪爬虫上次访问边的时间。对于结构变化非常快的图,如 WWW,一个常见的挑战是如何有效地检测边相关变化:新的边可能会在任何时候出现,而其他边可能会消失。处理边添加是一个简单任务;我们只需要为爬虫检测到的每个出站边创建一个 Edge 模型实例。另一方面,处理边删除则稍微复杂一些。
我们将为爬虫组件采用的方法将利用最后更新时间戳作为检测现有边是否过时并需要删除的手段。每次爬虫处理图中的链接时,它将执行以下操作:
-
为每个出站链接创建一个 Link 模型条目。
-
为每个唯一的出站链接创建一个 Edge 模型,其中包含以下操作:
-
origin
始终设置为当前正在处理的链接。 -
destination
是每个检测到的出站链接。 -
updatedAt
时间戳是当前系统时间。
-
通过遵循这些步骤,任何具有相同(source, destination)
元组的链接将刷新其UpdatedAt
字段,而过时的旧链接将保留其之前的UpdatedAt
值。如果我们安排爬虫记录它开始爬取特定页面的确切时间,我们只需删除所有源是刚刚爬取的链接且UpdatedAt
值早于记录的时间戳的边。
列出数据访问层所需的一组操作
遵循我们在前几章中讨论的 SOLID 设计原则,我们将开始设计链接图数据访问层,首先列出它需要执行的操作(在 SOLID 术语中称为责任),然后通过 Go 接口正式描述它们。
对于我们的特定用例,链接图访问层必须支持以下一组操作:
-
当爬虫发现其内容已更改时,将链接插入图或更新现有链接。
-
通过 ID 查找链接。
-
遍历图中存在的所有链接。这是链接图组件必须向其他组件(例如,爬虫和
PageRank
计算器)提供的主要服务,这些组件构成了“链接的 R'Us”项目。 -
向图中插入一条边或刷新现有边的
UpdatedAt
值。 -
遍历图中的边列表。这个功能是
PageRank
计算组件所必需的。 -
删除来自特定链接且在最后一次爬虫遍历期间未更新的陈旧链接。
定义链接图的 Go 接口
为了满足上一节中列出的操作列表,我们将定义Graph
接口如下:
type Graph interface {
UpsertLink(link *Link) error
FindLink(id uuid.UUID) (*Link, error)
UpsertEdge(edge *Edge) error
RemoveStaleEdges(fromID uuid.UUID, updatedBefore time.Time) error
Links(fromID, toID uuid.UUID, retrievedBefore time.Time) (LinkIterator, error)
Edges(fromID, toID uuid.UUID, updatedBefore time.Time) (EdgeIterator, error)
}
前两个方法允许我们在知道其 ID 的情况下更新Link
模型并从后端存储中检索它。在下面的代码中,你可以看到Link
类型的定义,其字段与 ER 图中的字段相匹配:
type Link struct {
ID uuid.UUID
URL string RetrievedAt time.Time
}
每个链接都被分配了一个唯一的 ID(确切地说是一个 V4 UUID)并且包含两个字段:访问网页的 URL 和一个时间戳字段,用于跟踪链接内容最后被爬虫检索的时间。
接下来的两个方法来自Graph
接口,允许我们操作图的边。让我们首先检查Edge
类型的定义:
type Edge struct {
ID uuid.UUID
Src uuid.UUID
Dst uuid.UUID
UpdatedAt time.Time
}
与链接类似,边也被分配了自己的唯一 ID(也是一个 V4 UUID)。此外,Edge
模型跟踪以下内容:
-
构成边的源链接和目标链接的 ID
-
最后更新的时间戳
对链接和边进行分区以并行处理图
如您可能已经注意到的,Links
和Edges
方法的签名表明,它们被设计为返回一个迭代器,以便它们可以访问图顶点和边的筛选子集。更具体地说,它们执行以下操作:
-
Links
方法返回一组链接,其 ID 属于fromID, toID)
范围,并且它们的最后检索时间早于提供的时间戳。 -
Edges
方法返回一组边,其起点 ID属于[fromID, toID)
范围,并且它们的最后更新时间早于提供的时间戳。
在这一点上,我们需要花一些时间来详细阐述这些方法设计背后的推理。我们可以争论,在某个时刻,链接图将足够大,以至于为了高效地处理它,我们最终必须将其分割成块,并并行处理每个块。为此,我们的设计必须预见这一需求,并包括一种机制,根据它们的个别 ID 将链接和边分组到分区中。给定一个[fromID, toID)
范围,所有图实现都将使用以下逻辑来选择通过迭代器返回哪些链接和边模型实例:
-
返回 ID 在
[fromID, toID)
范围内的链接。 -
返回 ID 在
[fromID, toID)
范围内的边。换句话说,边始终属于与它们的源链接相同的分区。
重要的是要注意,尽管前面的方法签名接受一个 UUID 范围作为输入,但实现一个合适的分区方案来计算 UUID 范围本身的责任在于调用者。只要有效,Links
和Edges
方法将乐意接受调用者提供的任何 UUID 范围。
在[第十章“构建、打包和部署软件”中,我们将探讨使用math/big
包来简化 UUID 空间的分割,以便将其输入到上述存储方法中。
迭代链接和边
由于调用Links
和Edges
方法可能返回的链接或边的数量没有上限,我们将实现迭代器设计模式,并按需懒加载 Link 和 Edge 模型。这些方法返回的LinkIterator
和EdgeIterator
类型本身就是接口。这是故意的,因为它们的内部实现细节显然将取决于我们为链接图持久化层选择的数据库技术。以下是它们的定义:
// LinkIterator is implemented by objects that can iterate the graph links.
type LinkIterator interface {
Iterator
// Link returns the currently fetched link object.
Link() *Link
}
// EdgeIterator is implemented by objects that can iterate the graph edges.
type EdgeIterator interface {
Iterator
// Edge returns the currently fetched edge objects.
Edge() *Edge
}
前面的两个接口都定义了一个获取器方法来检索迭代器当前指向的Link
或Edge
实例。两个迭代器之间的共同逻辑已被提取到一个单独的接口中,称为Iterator
,这两个接口都包含了这个接口。Iterator
接口的定义如下:
type Iterator interface {
// Next advances the iterator. If no more items are available or an
// error occurs, calls to Next() return false.
Next() bool
// Error returns the last error encountered by the iterator.
Error() error
// Close releases any resources associated with an iterator.
Close() error
}
要迭代边或链接的列表,我们必须从图中获取一个迭代器,并在for
循环中运行我们的业务逻辑:
// 'linkIt' is a link iterator
for linkIt.Next(){
link := linkIt.Link()
// Do something with link...
}
if err := linkIt.Error(); err != nil {
// Handle error...
}
调用linkIt.Next()
时,如果发生以下情况将返回 false:
-
我们已经迭代了所有可用的链接
-
发生错误(例如,我们失去了与数据库的连接)
因此,我们不需要在循环内部检查是否发生了错误 - 我们只需要在退出for
循环后检查一次。这种模式产生的代码看起来更干净,实际上在 Go 标准库的多个地方都有使用,例如bufio
包中的Scanner
类型。
使用共享测试套件验证图实现
如前几节所述,我们将构建Graph
接口的内存和数据库支持实现。为此,我们需要制定一套全面的测试来确保这两种实现的行为完全相同。
实现这一目标的一种方法是为第一个实现编写测试,然后为未来可能引入的每个额外实现重复它们。然而,这种方法实际上并不容易扩展:如果我们未来修改 Graph
接口怎么办?我们需要追踪并更新可能散布在不同包中的大量测试。
一个更好、更干净的方法是提出一个共享的、与实现无关的测试套件,然后将其连接到每个底层图实现。我选择了这种方法,因为它减少了所需的维护量,同时允许我们对所有实现运行 完全相同的测试集:当我们更改我们的实现之一时,这是一种相当有效的方法来检测回归。
但是,如果测试套件是共享的,它应该放在哪里,以便我们可以将其包含在所有特定实现的测试套件中?答案是将其封装到自己的专用测试包中,这样我们的常规测试代码就可以在需要的地方导入和使用。
SuiteBase
的定义位于 Chapter06/linkgraph/graph/graphtest
包中,并且依赖于我们在 第四章 《测试的艺术》中介绍的 gocheck
^([11]) 框架。该测试套件包括以下测试组:
-
链接/边更新测试:这些测试旨在验证我们可以将新的边/链接插入到图中,并且它们被分配了一个有效、唯一的 ID。
-
并发链接/边迭代器支持:这些测试确保在代码通过多个迭代器实例并发访问图内容时不会发生数据竞争。
-
分区迭代器测试:这些测试验证如果我们把我们的图分成 N 个分区,并为每个分区分配一个迭代器,每个迭代器将接收一组唯一的链接/边(即,没有项目会在多个分区中列出)以及所有迭代器都将处理图中存在的全部链接/边集合。此外,边迭代器测试确保每个边与其源链接出现在同一个分区中。
-
链接查找测试:一组简单的测试,用于验证图实现查找现有或未知链接 ID 时的行为。
-
过时边删除测试:一组测试,用于验证我们可以使用
updated-before-X
断言成功从图中删除过时的边。
要为一个新的图实现创建测试套件,我们只需定义一个新的测试套件,该套件执行以下操作:
-
集成
SuiteBase
-
提供一个套件设置辅助工具,该工具创建适当的图实例并调用
SuiteBase
提供的SetGraph
方法,这样我们就可以在运行任何前面的测试之前将其连接到基本测试套件。
实现内存图存储
内存图实现将作为编写完整图存储实现的温和介绍。由于在内存中维护图,这种实现简单、自包含且对并发访问安全。这使得它成为编写需要访问链接图组件的单元测试的理想候选。
让我们看看它的实现,从InMemoryGraph
类型的定义开始:
type edgeList []uuid.UUID
type InMemoryGraph struct {
mu sync.RWMutex
links map[uuid.UUID]*graph.Link
edges map[uuid.UUID]*graph.Edge
linkURLIndex map[string]*graph.Link
linkEdgeMap map[uuid.UUID]edgeList
}
InMemoryGraph
结构定义了两个映射(links
和edges
),它们维护已插入图中的Link
和Edge
模型集合。为了加速基于 ID 的查找,这两个映射都使用模型 ID 作为它们的键。
回到我们的 ER 图,我们可以看到链接 URL 也应该是唯一的。为此,内存图还维护一个辅助映射(linkURLIndex
),其中键是添加到图中的 URL,值是指向链接模型的指针。当我们检查下一节中UpsertLink
方法的实现时,我们将详细介绍这个特定映射的使用细节。
为了实现Edges
和RemoveStaleEdges
方法,我们还应该能够高效地回答另一种类型的查询:查找从特定链接起源的边列表。这是通过定义另一个辅助映射linkEdgeMap
来实现的。此映射将链接 ID 与对应于从它起源的边的 ID 片段相关联。
最后,为了确保我们的实现对并发访问安全,结构定义包括一个sync.RWMutex
字段。与提供单一读者/写者语义的常规sync.Mutex
不同,sync.RWMutex
支持多个并发读者,因此为读密集型工作负载提供了更好的吞吐量保证。
插入链接
让我们通过查看UpsertLink
方法的实现来开始我们对内存图实现的探索。由于更新操作将始终修改图,因此该方法将获取一个写锁,以便我们可以以原子方式应用任何修改。该方法包含两个不同的代码路径。
如果要插入的链接没有指定 ID,我们将其视为插入尝试除非我们已经添加了另一个具有相同 URL 的链接。在后一种情况下,我们将静默地将插入转换为更新操作,同时确保我们始终保留最新的RetrievedAt
时间戳:
if link.ID == uuid.Nil {
link.ID = existing.ID
origTs := existing.RetrievedAt
*existing = *link
if origTs.After(existing.RetrievedAt) {
existing.RetrievedAt = origTs
}
return nil
}
// Omitted: insert new link into the graph (see next block of code)...
一旦我们验证我们需要为链接创建一个新条目,我们就可以在将其插入图之前为其分配一个唯一的 ID。这是通过一个小型循环来实现的,我们不断生成新的 UUID 值,直到我们获得一个唯一的值。由于我们为我们的实现使用 V4(随机)UUID,我们基本上可以保证在第一次尝试中获得一个唯一的值。循环的存在保证了我们的代码即使在 UUID 冲突这种极不可能发生的情况下也能正确运行:
// Insert new link into the graph
// Assign new ID and insert link
for {
link.ID = uuid.New()
if s.links[link.ID] == nil {
break
}
}
lCopy := new(graph.Link)
*lCopy = *link
s.linkURLIndex[lCopy.URL] = lCopy
s.links[lCopy.ID] = lCopy
return nil
一旦我们为链接生成了一个 ID,我们就可以制作一个由调用者提供的链接的副本,以确保我们的实现之外的任何代码都不能修改图数据。然后,我们将链接插入适当的映射结构中。
更新边
在UpsertEdge
中的边更新逻辑与我们在上一节中检查的UpsertLink
实现有很多共同之处。我们首先需要做的是获取写锁并验证边的源和目的链接确实存在:
s.mu.Lock()
defer s.mu.Unlock()
_, srcExists := s.links[edge.Src]
_, dstExists := s.links[edge.Dst]
if !srcExists || !dstExists {
return xerrors.Errorf("upsert edge: %w", graph.ErrUnknownEdgeLinks)
}
接下来,我们扫描从指定源链接出发的边集,并检查我们是否可以找到一个到相同目的地的现有边。如果确实如此,我们只需更新条目的UpdatedAt
字段,并将其内容复制回提供的edge
指针。这确保了调用者提供的entry
值中的ID
和UpdatedAt
与存储中包含的值同步:
// Scan edge list from source
for _, edgeID := range s.linkEdgeMap[edge.Src] {
existingEdge := s.edges[edgeID]
if existingEdge.Src == edge.Src && existingEdge.Dst == edge.Dst {
existingEdge.UpdatedAt = time.Now()
*edge = *existingEdge
return nil
}
}
如果前面的循环没有产生匹配项,我们将在存储中创建并插入一个新的边。正如以下代码片段所示,我们遵循与链接插入相同的策略。首先,我们为边分配一个新的、唯一的 ID,并填充其UpdatedAt
值。然后,我们创建提供的Edge
对象的副本并将其插入存储的edges
映射中:
for {
edge.ID = uuid.New()
if s.edges[edge.ID] == nil {
break
}
}
edge.UpdatedAt = time.Now()
eCopy := new(graph.Edge)
*eCopy = *edge
s.edges[eCopy.ID] = eCopy
// Append the edge ID to the list of edges originating from the edge's source link.
s.linkEdgeMap[edge.Src] = append(s.linkEdgeMap[edge.Src], eCopy.ID)
return nil
最后,在返回之前,我们还需要做一些最后的记录工作:我们需要将新链接添加到从指定源链接出发的边列表中。为此,我们使用源链接 ID 作为键索引linkEdgeMap
,并将新插入的边 ID 追加到相应的边列表中。
查找链接
查找链接是一个相当简单的操作。我们所需做的就是获取一个读锁,通过 ID 查找链接,并执行以下操作之一:
-
将链接返回给调用者
-
如果未找到提供的 ID 的链接,则返回错误
链接查找逻辑概述在以下代码片段中:
func (s *InMemoryGraph) FindLink(id uuid.UUID) (*graph.Link, error) {
s.mu.RLock()
defer s.mu.RUnlock()
link := s.links[id]
if link == nil {
return nil, xerrors.Errorf("find link: %w", graph.ErrNotFound)
}
lCopy := new(graph.Link)
*lCopy = *link
return lCopy, nil
}
由于我们想要确保没有外部代码可以在不调用UpsertLink
方法的情况下修改图的内容,因此FindLink
实现总是返回存储在图中的链接的副本。
遍历链接/边
要获取图链接或边的迭代器,用户需要调用Links
或Edges
方法。让我们看看Links
方法是如何实现的:
func (s *InMemoryGraph) Links(fromID, toID uuid.UUID, retrievedBefore time.Time) (graph.LinkIterator, error) {
from, to := fromID.String(), toID.String()
s.mu.RLock()
var list []*graph.Link
for linkID, link := range s.links {
if id := linkID.String(); id >= from && id < to && link.RetrievedAt.Before(retrievedBefore) {
list = append(list, link)
}
}
s.mu.RUnlock()
return &linkIterator{s: s, links: list}, nil
}
在前面的实现中,我们获取一个读锁,然后继续迭代图中的所有链接,寻找属于[fromID, toID)
分区范围并且其RetrievedAt
值小于指定的retrievedBefore
值的链接。任何满足此谓词的链接都将追加到list
变量中。
为了确定一个链接 ID 是否属于指定的分区范围,我们将其转换为字符串,然后依赖于字符串比较来验证它是否等于fromID
或位于分区范围的两端之间。显然,执行字符串转换和比较不如直接比较 UUID 值的底层字节表示那么高效。然而,由于这个特定的实现仅用于调试目的,我们可以专注于保持代码简单,而不是担心其性能。
一旦我们迭代完所有链接,我们就创建一个新的linkIterator
实例并将其返回给用户。现在,让我们来看看迭代器的实现,从其类型定义开始:
type linkIterator struct {
s *InMemoryGraph
links []*graph.Link
curIndex int
}
如您所见,迭代器存储了对内存中图的指针、要迭代的Link
模型列表以及一个用于跟踪迭代器在列表中偏移量的索引。
迭代器的Next
方法的实现相当简单:
func (i *edgeIterator) Next() bool {
if i.curIndex >= len(i.links) {
return false
}
i.curIndex++
return true
}
除非我们已经到达了链接列表的末尾,我们才会前进curIndex
并返回 true,以表示通过调用Link
方法还有更多数据可供检索,其实现如下:
func (i *linkIterator) Link() *graph.Link {
i.s.mu.RLock()
link := new(graph.Link)
*link = *i.links[i.curIndex-1]
i.s.mu.RUnlock()
return link
}
请记住,与这个迭代器关联的Link
模型实例由内存中的图维护,并且可能与其他迭代器实例共享。因此,当某个 goroutine 正在从迭代器中消耗链接时,另一个 goroutine 可能正在修改它们的内容。为了避免数据竞争,每当用户调用迭代器的Link
方法时,我们都会在链接图上获得一个读锁。在持有锁的同时,我们可以安全地获取下一个链接并创建一个副本,然后将其返回给调用者。
最后,让我们看看Edges
方法的实现。逻辑与Links
非常相似,但在填充属于请求分区的边列表的方式上有一个细微的差别:
func (s *InMemoryGraph) Edges(fromID, toID uuid.UUID, updatedBefore time.Time) (graph.EdgeIterator, error) {
from, to := fromID.String(), toID.String()
s.mu.RLock()
var list []*graph.Edge
for linkID := range s.links {
if id := linkID.String(); id < from || id >= to {
continue
}
for _, edgeID := range s.linkEdgeMap[linkID] {
if edge := s.edges[edgeID]; edge.UpdatedAt.Before(updatedBefore) {
list = append(list, edge)
}
}
}
s.mu.RUnlock()
return &edgeIterator{s: s, edges: list}, nil
}
正如我们在并行处理图中的链接和边分区部分中提到的,每条边都属于其起源的相同分区。因此,在前面的实现中,我们首先遍历图中的链接集合,并跳过不属于所需分区的那些链接。一旦我们找到了属于请求分区范围的链接,我们就遍历从它起源的边列表(通过linkEdgeMap
字段),并将满足更新前-X谓词的任何边追加到list
变量中。
然后,list
变量中的内容被用来创建一个新的edgeIterator
实例,然后将其返回给调用者。edgeIterator
的实现方式与linkIterator
大致相同,因此我们在这里将省略其完整实现。您可以通过访问这本书的 GitHub 仓库轻松查找。
移除过时的边
我们需要探索的最后一点功能是RemoveStaleEdges
方法。调用者使用链接(源)的 ID 和updatedBefore
值来调用它:
func (s *InMemoryGraph) RemoveStaleEdges(fromID uuid.UUID, updatedBefore time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
var newEdgeList edgeList
for _, edgeID := range s.linkEdgeMap[fromID] {
edge := s.edges[edgeID]
if edge.UpdatedAt.Before(updatedBefore) {
delete(s.edges, edgeID)
continue
}
newEdgeList = append(newEdgeList, edgeID)
}
s.linkEdgeMap[fromID] = newEdgeList
return nil
}
与其他会修改图内容的其他操作一样,我们需要获取一个写锁。然后,我们遍历从指定源链接出发的边列表,忽略那些UpdatedAt
值小于指定updatedBefore
参数的边。任何幸存下来的边都会添加到newEdgeList
中,这将成为指定源链接的新出边列表。
为图实现设置测试套件
在我们结束对内存图实现的巡礼之前,我们需要花些时间编写一个测试套件,该套件将对刚刚创建的存储实现执行共享验证套件。这只需要几行代码,如下所示:
var _ = gc.Suite(new(InMemoryGraphTestSuite))
type InMemoryGraphTestSuite struct {
graphtest.SuiteBase
}
func (s *InMemoryGraphTestSuite) SetUpTest(c *gc.C) {
s.SetGraph(NewInMemoryGraph())
}
// Register our test-suite with go test.
func Test(t *testing.T) { gc.TestingT(t) }
由于我们正在使用一个纯内存实现,我们可以在运行每个测试之前通过提供一个SetUpTest
方法来欺骗性地重新创建图,该方法是gocheck
框架在运行测试套件时自动为我们调用的。
使用 CockroachDB 支持的图实现进行横向扩展
虽然内存图实现对于运行我们的单元测试或甚至为演示或端到端测试目的启动 Links 'R' Us 系统的小实例来说确实是一个很好的资产,但它并不是我们真正想在生产级系统中使用的。
首先,内存存储中的数据在服务重启后不会持久化。即使我们能够以某种方式解决这个问题(例如,通过定期将图快照到磁盘),我们最好的办法也是扩展我们的图:例如,我们可以在具有更快 CPU 和/或更多内存的机器上运行链接图服务。但仅此而已;鉴于我们预计图的大小最终会超过单个节点的存储容量,我们需要想出一个更有效的解决方案,该解决方案可以跨多台机器进行扩展。
为了达到这个目的,以下章节将探讨第二个图实现,该实现利用一个可以支持我们的扩展需求的数据库系统。虽然无疑有许多 DBMS 可以满足我们的需求,但我已经决定基于以下原因将图实现建立在 CockroachDB 上^([5]):
-
它可以通过简单地增加集群中可用的节点数量来轻松地进行横向扩展。CockroachDB 集群可以在节点出现或下线时自动重新平衡和自我修复。这种特性使其非常适合我们的用例!
-
CockroachDB 完全符合 ACID 规范,并支持分布式 SQL 事务。
-
CockroachDB 支持的 SQL 方言与 PostgreSQL 语法兼容,许多人都应该已经熟悉。
-
CockroachDB 实现了 PostgreSQL 网络协议;这意味着我们不需要专门的驱动程序包来连接到数据库,而可以直接使用经过实战检验的纯 Go Postgres^([19])包来连接到数据库。
处理数据库迁移
在创建对 DBMS 的依赖时,我们需要引入一个外部机制来帮助我们管理我们将要运行的查询的表的架构。
遵循推荐的行业最佳实践,我们需要在小型、增量步骤中对数据库模式进行更改,以便在部署我们软件的新版本到生产环境中应用,或者在发现错误后决定回滚部署时撤销更改。
对于这个特定的项目,我们将借助gomigrate
工具来管理我们的数据库模式^([7])。这个工具可以与大多数流行的数据库系统(包括 CockroachDB)一起工作,并提供一个方便的命令行工具,我们可以用它来应用或撤销数据库模式更改。gomigrate
期望数据库迁移被指定为两个独立的文件:一个包含应用迁移的 SQL 命令(up路径)的文件,另一个包含撤销迁移的文件(down路径)。迁移文件名的标准格式使用以下模式:
timestamp-description-{up/down}.sql
添加时间戳组件确保gomigrate
始终按正确的顺序获取并应用更改。
要执行任何必需的迁移,我们需要调用gomigrate
CLI 工具并为其提供以下信息:
-
目标数据库的数据源名称(DSN)URL。
-
迁移文件所在位置的路径。该工具不仅支持本地路径,还可以从 GitHub、GitLab、AWS S3 和 Google Cloud Storage 拉取迁移。
-
迁移方向命令。这通常是
up
来应用迁移或down
来撤销它们。
你可能会想知道:gomigrate
是如何确保迁移只执行一次的?答案是:通过维护状态!那么,这个状态存储在哪里呢?当你第一次在数据库上运行gomigrate
工具时,它将创建两个额外的表,这些表被工具用来跟踪它已经应用了哪些迁移。这使得工具可以在多次运行时保持安全(例如,每次我们部署软件的新版本到生产环境时)。
链接图项目的所有必需迁移都位于Chapter06/linkgraph/store/cdb/migrations
文件夹中。更重要的是,顶层 makefile 包括一个run-cdb-migrations
目标,该目标将安装(如果缺失)gomigrate
工具并自动运行任何挂起的迁移。实际上,这个命令被链接到本书 GitHub 存储库的 CI 系统用来在运行 CockroachDB 测试之前启动测试数据库。
CockroachDB 实现的数据库模式概述
设置 CockroachDB 图实现所需的表是一个相当直接的过程。以下是我们运行包含的数据库迁移时将应用的所有 SQL 语句的合并列表:
CREATE TABLE IF NOT EXISTS links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
url STRING UNIQUE,
retrieved_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS edges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
src UUID NOT NULL REFERENCES links(id) ON DELETE CASCADE,
dst UUID NOT NULL REFERENCES links(id) ON DELETE CASCADE,
updated_at TIMESTAMP,
CONSTRAINT edge_links UNIQUE(src,dst)
);
你可能已经注意到,在构建内存图实现时,我们必须手动强制执行一些约束。例如,我们必须检查以下内容:
-
链接和边 ID 是唯一的
-
URL 是唯一的
-
边的源和目标链接 ID 指向现有链接
-
边的
(source, destination)
元组是唯一的
对于 CockroachDB 实现,我们可以在定义表模式时通过引入唯一性和外键约束,简单地委托这些检查到数据库本身。这种方法的微小缺点是,当 SQL 语句执行尝试返回错误时,我们需要检查其内容以检测是否发生了约束验证。如果确实如此,我们可以向调用者返回一个更有意义、类型化的错误,例如graph.ErrUnknownEdgeLinks
,以匹配内存实现的行为。
插入链接
要将链接插入到 CockroachDB 存储,我们将使用一个类似于 upsert 的 SQL 查询,该查询利用数据库在发生冲突时指定要应用的操作的支持:
INSERT INTO links (url, retrieved_at) VALUES ($1, $2)
ON CONFLICT (url) DO UPDATE SET retrieved_at=GREATEST(links.retrieved_at, $2)
RETURNING id, retrieved_at
基本上,如果我们尝试插入一个与现有链接具有相同url
的链接,前面的冲突解决操作将确保我们只需将retrieved_at
列更新为原始值和调用者指定的值中的最大值。无论是否发生冲突,查询总是会返回行的id
(现有或由数据库分配)以及retrieved_at
列的值。相关的UpsertLink
方法实现如下:
func (c *CockroachDBGraph) UpsertLink(link *graph.Link) error {
row := c.db.QueryRow(upsertLinkQuery, link.URL, link.RetrievedAt.UTC())
if err := row.Scan(&link.ID, &link.RetrievedAt); err != nil {
return xerrors.Errorf("upsert link: %w", err)
}
link.RetrievedAt = link.RetrievedAt.UTC()
return nil
}
此方法将提供的模型中的字段绑定到upsertLinkQuery
,然后继续执行它。然后,它将查询返回的id
和retrieved_at
值扫描到适当的模型字段中。
插入边
要插入边,我们将使用以下查询:
INSERT INTO edges (src, dst, updated_at) VALUES ($1, $2, NOW())
ON CONFLICT (src,dst) DO UPDATE SET updated_at=NOW()
RETURNING id, updated_at
如您所见,查询包括一个冲突解决步骤,用于尝试插入具有相同(src, dst)
元组的边的情况。如果发生这种情况,我们只需将updated_at
列的值更改为当前时间戳。
毫不奇怪,将边插入到 CockroachDB 存储的代码看起来与链接插入代码非常相似:
func (c *CockroachDBGraph) UpsertEdge(edge *graph.Edge) error {
row := c.db.QueryRow(upsertEdgeQuery, edge.Src, edge.Dst)
if err := row.Scan(&edge.ID, &edge.UpdatedAt); err != nil {
if isForeignKeyViolationError(err) {
err = graph.ErrUnknownEdgeLinks
}
return xerrors.Errorf("upsert edge: %w", err)
}
edge.UpdatedAt = edge.UpdatedAt.UTC()
return nil
}
再次,我们将相关字段绑定到一个我们将继续执行的查询,并使用查询返回的id
和updated_at
字段更新提供的边模型。
前面的代码有一个小变化!当我们定义边表的架构时,我们还为 src
和 dst
字段指定了一个 外键 约束。因此,如果我们尝试插入一个未知源和/或目标 ID 的边,我们将得到一个错误。为了检查错误是否实际上是由外键违规引起的,我们可以使用以下辅助工具:
func isForeignKeyViolationError(err error) bool {
pqErr, valid := err.(*pq.Error)
if !valid {
return false
}
return pqErr.Code.Name() == "foreign_key_violation"
}
为了匹配内存存储实现的行为,如果错误指向外键违规,我们返回更用户友好的 graph.ErrUnknownEdgeLinks
错误。
查找链接
要通过其 ID 查找链接,我们将使用以下标准 SQL 选择查询:
SELECT url, retrieved_at FROM links WHERE id=$1"
FindLink
方法的实现如下:
func (c *CockroachDBGraph) FindLink(id uuid.UUID) (*graph.Link, error) {
row := c.db.QueryRow(findLinkQuery, id)
link := &graph.Link{ID: id}
if err := row.Scan(&link.URL, &link.RetrievedAt); err != nil {
if err == sql.ErrNoRows {
return nil, xerrors.Errorf("find link: %w", graph.ErrNotFound)
}
return nil, xerrors.Errorf("find link: %w", err)
}
link.RetrievedAt = link.RetrievedAt.UTC()
return link, nil
}
执行查询后,我们创建一个新的 Link
模型实例,并用返回的链接字段填充它。如果选择查询没有匹配任何链接,SQL 驱动程序将返回 sql.ErrNoRows
错误。前面的代码检查此错误,并向调用者返回用户友好的 graph.ErrNotFound
错误。
迭代链接/边
要选择与特定分区对应且检索时间戳早于提供值的链接,我们将使用以下查询:
SELECT id, url, retrieved_at FROM links WHERE id >= $1 AND id < $2 AND retrieved_at < $3
Links
方法的实现如下所示:
func (c *CockroachDBGraph) Links(fromID, toID uuid.UUID, accessedBefore time.Time) (graph.LinkIterator, error) {
rows, err := c.db.Query(linksInPartitionQuery, fromID, toID, accessedBefore.UTC())
if err != nil {
return nil, xerrors.Errorf("links: %w", err)
}
return &linkIterator{rows: rows}, nil
}
如您所见,该方法使用指定的参数执行查询,并返回一个 linkIterator
以消费返回的结果集。CockroachDB 迭代器的实现只是 SQL 查询返回的 sql.Rows
值的包装。以下是 Next
方法实现的示例:
func (i *linkIterator) Next() bool {
if i.lastErr != nil || !i.rows.Next() {
return false
}
l := new(graph.Link)
i.lastErr = i.rows.Scan(&l.ID, &l.URL, &l.RetrievedAt)
if i.lastErr != nil {
return false
}
l.RetrievedAt = l.RetrievedAt.UTC()
i.latchedLink = l
return true
}
Edges
方法使用以下查询,它产生的结果集与内存实现完全相同:
SELECT id, src, dst, updated_at FROM edges WHERE src >= $1 AND src < $2 AND updated_at < $3"
下面是 Edges
实现的示例:
func (c *CockroachDBGraph) Edges(fromID, toID uuid.UUID, updatedBefore time.Time) (graph.EdgeIterator, error) {
rows, err := c.db.Query(edgesInPartitionQuery, fromID, toID, updatedBefore.UTC())
if err != nil {
return nil, xerrors.Errorf("edges: %w", err)
}
return &edgeIterator{rows: rows}, nil
}
edgeIterator
的实现与 linkIterator
非常相似,所以我们将节省一些空间并省略它。您可以通过检查位于本书 GitHub 仓库 Chapter06/linkgraph/store/cdb
包中的 iterator.go
文件中的源代码来查看完整的迭代器实现。
移除过时边
我们将要检查的最后一个功能部分是 RemoveStaleEdges
方法,它使用以下查询来删除在特定时间点之后未更新的边:
DELETE FROM edges WHERE src=$1 AND updated_at < $2
让我们看看 RemoveStaleEdges
方法的实现:
func (c *CockroachDBGraph) RemoveStaleEdges(fromID uuid.UUID, updatedBefore time.Time) error {
_, err := c.db.Exec(removeStaleEdgesQuery, fromID, updatedBefore.UTC())
if err != nil {
return xerrors.Errorf("remove stale edges: %w", err)
}
return nil
}
这里没有什么异常之处;前一个代码片段中的代码只是将参数绑定到删除查询并执行它。
为 CockroachDB 实现设置测试套件
要创建和连接 CockroachDB 实现的测试套件,我们将严格按照内存实现所采取的步骤进行。第一步是定义一个包含共享 graphtest.SuiteBase
类型的测试套件,并将其注册到 go test
:
var _ = gc.Suite(new(CockroachDBGraphTestSuite))
type CockroachDBGraphTestSuite struct {
graphtest.SuiteBase
db *sql.DB
}
// Register our test-suite with go test.
func Test(t *testing.T) { gc.TestingT(t) }
然后,我们需要为测试套件提供一个设置方法,该方法将创建一个新的 CockroachDB 图实例并将其连接到基本套件。遵循我们在第四章《测试的艺术》中讨论的测试范式,我们的测试套件依赖于一个环境变量,该变量应包含连接到 CockroachDB 实例的 DSN。如果环境变量未定义,整个测试套件将自动跳过:
func (s *CockroachDBGraphTestSuite) SetUpSuite(c *gc.C) {
dsn := os.Getenv("CDB_DSN")
if dsn == "" {
c.Skip("Missing CDB_DSN envvar; skipping cockroachdb-backed graph test suite")
}
g, err := NewCockroachDBGraph(dsn)
c.Assert(err, gc.IsNil)
s.SetGraph(g)
// keep track of the sql.DB instance so we can execute SQL statements
// to reset the DB between tests!
s.db = g.db
}
为了确保所有测试都能按预期工作,我们的一个要求是测试套件中的每个测试都提供一个干净的数据库实例。为此,我们需要定义一个针对每个测试的设置方法,该方法将清空所有数据库表:
func (s *CockroachDBGraphTestSuite) SetUpTest(c *gc.C) { s.flushDB(c) }
func (s *CockroachDBGraphTestSuite) flushDB(c *gc.C) {
_, err := s.db.Exec("DELETE FROM links")
c.Assert(err, gc.IsNil)
_, err = s.db.Exec("DELETE FROM edges")
c.Assert(err, gc.IsNil)
}
最后,我们需要为测试套件提供一个清理方法。一旦测试套件执行完毕,我们将再次截断数据库表并释放数据库连接:
func (s *CockroachDBGraphTestSuite) TearDownSuite(c *gc.C) {
if s.db != nil {
s.flushDB(c)
c.Assert(s.db.Close(), gc.IsNil)
}
}
注意,在清理过程中刷新数据库内容不是强制性的。在我看来,始终这样做是一个好习惯,以防其他包的测试集使用相同的数据库实例,但期望它最初为空。
为文本索引器组件设计数据层
在接下来的章节中,我们将对文本索引器组件进行深入分析。我们将确定文本索引器组件必须能够支持的操作集,并将它们正式编码为一个名为Indexer
的 Go 接口。
类似于链接图分析的方式,我们将构建两个具体的Indexer
接口实现:一个基于流行的 bleve ^([1]) 包的内存实现,以及一个使用 Elasticsearch ^([9]) 实现的水平扩展实现。
索引文档的模型
在我们分析索引器组件的第一步中,我们将首先描述Indexer
实现将索引和搜索的文档模型:
type Document struct {
LinkID uuid.UUID
URL string
Title string
Content string
IndexedAt time.Time
PageRank float64
}
所有文档都必须包含一个非空的属性称为LinkID
。该属性是一个 UUID 值,它将文档与从链接图中获得的链接连接起来。除了链接 ID 之外,每个文档还存储了索引文档的 URL,使我们不仅能够将其作为搜索结果的一部分显示,而且还可以在未来实现更高级的搜索模式(例如,针对特定域的约束搜索)。
Title
和Content
属性对应于链接指向 HTML 页面时的<title>
元素的值,而Content
属性存储了爬虫在处理链接时提取的文本块。这两个属性都将被索引并可供搜索。
IndexedAt
属性包含一个时间戳,指示特定文档最后一次索引的时间,而PageRank
属性则跟踪PageRank
计算器组件将为每个文档分配的PageRank
分数。由于PageRank
分数可以被视为每个链接的质量指标,因此文本索引器实现将尝试通过按其与输入查询的相关性和PageRank
分数对搜索匹配进行排序来优化返回的结果集。
列出文本索引器需要支持的操作集
对于文本索引器组件用例,我们需要能够执行以下操作集:
-
当文档内容发生变化时,向索引中添加文档或重新索引现有文档。此操作通常由爬虫组件调用。
-
通过其 ID 查找文档。
-
执行全文查询并获取一个可迭代的结果列表。当用户点击搜索按钮时,我们的项目前端组件将调用此操作,并消费返回的迭代器以向最终用户展示分页结果列表。
-
更新特定文档的
PageRank
分数。当需要更新特定链接的PageRank
分数时,PageRank
计算器组件将调用此操作。
定义 Indexer 接口
类似于我们在建模链接图组件时采用的方法,我们将把前面的操作列表封装到一个名为Indexer
的 Go 接口中:
type Indexer interface {
Index(doc *Document) error
FindByID(linkID uuid.UUID) (*Document, error)
Search(query Query) (Iterator, error)
UpdateScore(linkID uuid.UUID, score float64) error
}
Search
方法期望输入参数为Query
类型,而不是简单的字符串值。这是设计上的考虑;它为我们提供了灵活性,以便在将来进一步扩展索引器的查询功能,以支持更丰富的查询语义,而无需修改Search
方法的签名。以下是Query
类型的定义:
type Query struct {
Type QueryType
Expression string
Offset uint64
}
type QueryType uint8
const (
QueryTypeMatch QueryType = iota
QueryTypePhrase
)
Expression
字段存储由最终用户输入的搜索查询。然而,索引器组件的解释会根据Type
属性值的不同而变化。作为概念验证,我们只将实现两种最常见的搜索类型:
-
按任意顺序搜索一组关键词
-
搜索精确短语匹配
在未来,我们可以选择添加对其他类型查询的支持,例如布尔、日期或基于域的查询。
执行搜索查询后,文本索引器将返回一个Iterator
接口实例,该实例提供了一个简单的 API 来消费搜索结果。这是Iterator
接口的定义:
type Iterator interface {
// Close the iterator and release any allocated resources.
Close() error
// Next loads the next document matching the search query.
// It returns false if no more documents are available.
Next() bool
// Error returns the last error encountered by the iterator.
Error() error
// Document returns the current document from the result set.
Document() *Document
// TotalCount returns the approximate number of search results.
TotalCount() uint64
}
获取迭代器实例后,我们可以使用简单的for
循环来消费每个搜索结果:
// 'docIt' is a search iterator
for docIt.Next() {
doc := docIt.Document()
// Do something with doc...
}
if err := docIt.Error(); err != nil {
// Handle error...
}
对docIt.Next()
的调用将在我们迭代完所有结果或发生错误时返回 false。与我们在前几节中检查的链接图迭代器类似,我们只需要在退出迭代循环后检查一次错误的存在。
使用共享测试套件验证索引器实现
在接下来的几页中,我们将构建两个完全不同的索引器实现。与链接图组件类似,我们还将设计一个共享测试套件,以帮助我们验证这两个实现的行为完全相同。
我们共享的索引器测试的SuiteBase
定义可以在Chapter06/textindexer/index/indextest
包中找到,它依赖于我们在第四章中介绍的gocheck
^([11])框架,即《测试的艺术》。该套件定义了以下索引操作组的测试:
-
文档索引测试:这些测试旨在验证索引器组件成功处理有效文档,并拒绝任何未定义所需文档属性集的文档(例如,它包含一个空的链接 ID)。
-
文档查找测试:这些测试验证我们可以通过其链接 ID 查找先前索引的文档,并且返回的文档模型与传递和索引的文档相同。
-
关键词搜索测试:一系列旨在验证关键词搜索产生正确文档集的测试。
-
精确短语搜索测试:另一系列旨在验证精确短语搜索产生正确文档集的测试。
-
PageRank
分数更新测试:这些测试执行PageRank
分数更新代码路径,并验证索引文档的分数值更改反映在返回的搜索结果顺序中。
要为实际的索引器实现创建一个测试套件,我们只需做以下几步:
-
定义一个新的测试套件,该套件嵌入
SuiteBase
-
提供一个套件设置辅助函数,该函数创建适当的索引器实例,然后调用
SuiteBase
公开的SetIndexer
方法,将索引器连接到基本测试套件
使用 bleve 的内存索引器实现
我们第一次尝试实现内存索引器将基于一个流行的 Go 全文搜索包 bleve^([1])。虽然 bleve 主要设计用于在磁盘上存储其索引,但它也支持内存索引。这使得它成为在隔离或演示目的下运行单元测试的绝佳候选者,如果我们不想启动一个资源消耗更大的选项,如 Elasticsearch。
基于 bleve 的索引器实现的完整源代码可在本书 GitHub 仓库的Chapter06/textindexer/store/memory
包中找到。InMemoryBleveIndexer
类型的定义相当简单:
type InMemoryBleveIndexer struct {
mu sync.RWMutex
docs map[string]*index.Document
idx bleve.Index
}
idx
字段存储了对 bleve 索引的引用。为了加快索引速度,我们不会将完整的Document
模型传递给 bleve,而是使用一个更轻量级的表示,它只包含我们执行搜索所需的三个字段:标题、内容和PageRank
分数。
这种方法的明显缺点是,由于 bleve 存储了文档数据的部分视图,我们无法从 bleve 执行搜索查询后返回的结果列表中重新创建原始文档。为了解决这个问题,内存中的索引器维护一个映射,其中键是文档链接 ID,值是索引器处理的文档的不可变副本。在处理结果列表时,返回的文档 ID 用于索引映射和恢复原始文档。为了确保内存中的索引器可以安全地并发使用,对映射的访问由读写互斥锁保护。
索引文档
内存索引器的Index
方法实现概述如下:
func (i *InMemoryBleveIndexer) Index(doc *index.Document) error {
if doc.LinkID == uuid.Nil {
return xerrors.Errorf("index: %w", index.ErrMissingLinkID)
}
doc.IndexedAt = time.Now()
dcopy := copyDoc(doc)
key := dcopy.LinkID.String()
i.mu.Lock()
if orig, exists := i.docs[key]; exists {
dcopy.PageRank = orig.PageRank
}
if err := i.idx.Index(key, makeBleveDoc(dcopy)); err != nil {
return xerrors.Errorf("index: %w", err)
}
i.docs[key] = dcopy
i.mu.Unlock()
return nil
}
为了保证唯一修改已索引文档的方式是通过重新索引操作,索引器被设计为与传递给Index
方法的文档的不可变副本一起工作。copyDoc
辅助函数创建原始文档的副本,我们可以安全地将其存储在内部文档映射中。
要将新文档添加到索引或重新索引现有文档,我们需要向 bleve 提供两个参数:基于字符串的文档 ID 和要索引的文档。makeBleveDoc
辅助函数返回原始文档的部分、轻量级视图,正如我们在上一节中提到的,它只包含我们用作搜索查询一部分的字段。
当更新现有文档时,我们不希望索引操作修改已分配给文档的PageRank
分数,因为这会干扰搜索结果的排序方式。为此,如果文档已存在,我们需要修补传递给 bleve 的轻量级文档,使其反映正确的PageRank
值。
查找文档和更新它们的 PageRank 分数
如果我们知道文档的链接 ID,我们可以调用FindByID
方法来查找索引中的文档。实现相当直接;我们只需获取一个读锁,并在索引器维护的内部映射中查找指定的 ID。如果存在匹配的条目,我们创建一个副本并将其返回给调用者:
func (i *InMemoryBleveIndexer) FindByID(linkID uuid.UUID) (*index.Document, error) {
return i.findByID(linkID.String())
}
func (i *InMemoryBleveIndexer) findByID(linkID string) (*index.Document, error) {
i.mu.RLock()
defer i.mu.RUnlock()
if d, found := i.docs[linkID]; found {
return copyDoc(d), nil
}
return nil, xerrors.Errorf("find by ID: %w", index.ErrNotFound)
}
你可能想知道为什么FindByID
实现将输入 UUID 转换为字符串并将实际的文档查找委托给未导出的findByID
方法。在上一节中,我们看到当我们请求 bleve 索引文档时,我们需要提供一个基于字符串的文档 ID。bleve 将在文档通过搜索查询匹配时将此 ID 返回给我们。正如以下部分将变得明显,通过提供一个接受 linkID 作为字符串的findByID
方法,我们可以在迭代搜索结果时重用文档查找代码。
要更新现有文档的PageRank
分数,客户端调用UpdateScore
方法,该方法期望一个文档的链接 ID 和更新的PageRank
分数:
func (i *InMemoryBleveIndexer) UpdateScore(linkID uuid.UUID, score float64) error {
i.mu.Lock()
defer i.mu.Unlock()
key := linkID.String()
doc, found := i.docs[key]
if !found {
doc = &index.Document{LinkID: linkID}
i.docs[key] = doc
}
doc.PageRank = score
if err := i.idx.Index(key, makeBleveDoc(doc)); err != nil {
return xerrors.Errorf("update score: %w", err)
}
return nil
}
更新任何可搜索文档属性都需要重新索引操作。因此,UpdateScore
实现将获取一个写锁,并在内部文档映射中查找文档。如果找到文档,其PageRank
分数将就地更新,并将文档传递给 bleve 进行索引。
搜索索引
内存索引器的客户端通过调用Search
方法提交搜索查询。此方法的实现如下:
func (i *InMemoryBleveIndexer) Search(q index.Query) (index.Iterator, error) {
var bq query.Query
switch q.Type {
case index.QueryTypePhrase:
bq = bleve.NewMatchPhraseQuery(q.Expression)
default:
bq = bleve.NewMatchQuery(q.Expression)
}
searchReq := bleve.NewSearchRequest(bq)
searchReq.SortBy([]string{"-PageRank", "-_score"})
searchReq.Size = batchSize
searchReq.From = q.Offset
rs, err := i.idx.Search(searchReq)
if err != nil {
return nil, xerrors.Errorf("search: %w", err)
}
return &bleveIterator{idx: i, searchReq: searchReq, rs: rs, cumIdx: q.Offset}, nil
}
我们实现需要做的第一件事是检查调用者要求我们执行哪种类型的查询,然后调用适当的 bleve 辅助函数从调用者提供的表达式构建查询。
接下来,生成的查询被转换为一个新搜索请求,我们要求 bleve 按PageRank
和相关性降序排序结果。Bleve 搜索结果总是分页的。因此,除了任何排序偏好外,我们还必须指定我们希望 bleve 返回的每页结果数(批处理大小)。搜索请求对象还允许我们通过指定其From
字段的值来控制结果列表中的偏移量。
下一步是将搜索请求提交给 bleve 并检查是否存在错误。如果一切按计划进行且没有返回错误,实现将创建一个新的迭代器实例,调用者可以使用它来消费匹配的文档。
遍历搜索结果列表
bleveIterator
类型实现了indexer.Iterator
接口,并定义如下:
type bleveIterator struct {
idx *InMemoryBleveIndexer
searchReq *bleve.SearchRequest
cumIdx uint64
rsIdx int
rs *bleve.SearchResult
latchedDoc *index.Document
lastErr error
}
迭代器实现跟踪两个指针:
-
指向内存索引器实例的指针,允许迭代器在迭代器前进时访问存储的文档
-
指向已执行搜索请求的指针,迭代器使用它来触发新的 bleve 搜索,一旦当前页的结果已被消耗
为了跟踪分页搜索结果列表中的位置,迭代器还维护两个计数器:
-
一个累积计数器(
cumIdx
),它跟踪全局结果列表中的绝对位置 -
一个计数器(
rsIdx
),它跟踪当前页结果中的位置
bleve 查询返回的bleve.SearchResult
对象提供了有关匹配结果总数和当前结果页中文档数量的信息。迭代器的Next
方法利用此信息来决定迭代器是否可以前进。
当调用迭代器的Next
方法时,实现会快速检查是否发生错误或我们已经迭代了全部结果集。如果是这种情况,Next
将返回false
以指示没有更多项目可用。后者的检查是通过比较 bleve 报告的总结果计数与迭代器在其内部状态中跟踪的cumIdx
值来实现的:
if it.lastErr != nil || it.rs == nil || it.cumIdx >= it.rs.Total {
return false
}
我们下一步的行动是检查我们是否已经耗尽了当前页的结果。这通过比较当前结果页中的文档数量与rsIdx
计数器的值来实现。如果当前结果页中的所有文档都已消耗,并且没有更多的结果页可用,则方法返回false
以通知调用者。
否则,实现会自动通过以下方式获取下一页的结果:
-
更新存储的搜索请求,以便结果偏移量指向下一页的开始位置
-
执行一个新的 bleve 搜索请求以获取下一页的结果
-
重置
rsIdx
计数器,以便我们可以处理新检索到的页面的第一个结果
以下代码片段概述了前面的步骤:
if it.rsIdx >= it.rs.Hits.Len() {
it.searchReq.From += it.searchReq.Size
if it.rs, it.lastErr = it.idx.idx.Search(it.searchReq); it.lastErr != nil {
return false
}
it.rsIdx = 0
}
nextID := it.rs.Hits[it.rsIdx].ID
if it.latchedDoc, it.lastErr = it.idx.findByID(nextID); it.lastErr != nil {
return false
}
it.cumIdx++
it.rsIdx++
return true
要锁定结果集中的下一个文档,我们从 bleve 结果中提取其 ID,并通过在内存索引上调用findByID
方法来查找完整的文档。正如我们在前面的部分中看到的,文档查找代码始终返回索引文档的副本,我们可以在迭代器中安全地缓存它。最后,两个位置跟踪计数器都会增加,并返回一个true
值给调用者,以指示迭代器已成功前进,并且可以通过调用迭代器的Document
方法检索下一个文档。
为内存索引器设置测试套件
内存索引器实现的测试套件嵌入我们在“使用共享测试套件验证索引器实现”部分中概述的共享测试套件。由于该套件依赖于gocheck
框架,我们需要添加一些额外的代码来将套件注册到go test
框架中:
var _ = gc.Suite(new(InMemoryBleveTestSuite))
type InMemoryBleveTestSuite struct {
indextest.SuiteBase
idx *InMemoryBleveIndexer
}
// Register our test-suite with go test.
func Test(t *testing.T) { gc.TestingT(t) }
为了确保每个测试都使用一个干净的索引实例,该套件提供了一个针对每个测试的设置方法,在运行每个测试之前重新创建索引:
func (s *InMemoryBleveTestSuite) SetUpTest(c *gc.C) {
idx, err := NewInMemoryBleveIndexer()
c.Assert(err, gc.IsNil)
s.SetIndexer(idx) // Keep track of the concrete indexer implementation so we can clean up
// when tearing down the test
s.idx = idx }
func (s *InMemoryBleveTestSuite) TearDownTest(c *gc.C) { c.Assert(s.idx.Close(), gc.IsNil) }
由于 bleve 索引实例存储在内存中,我们还需要定义一个针对每个测试的清理方法,以确保在每次测试完成后关闭索引并释放任何获取的资源。
在 Elasticsearch 索引器实现中进行扩展
内存中 bleve 索引器实现的一个注意事项是我们或多或少被限制在单个节点上运行索引。这不仅给我们的整体系统设计引入了一个单点故障,而且也限制了我们的服务可以处理的大量搜索流量。
我们确实可以争论我们尝试水平扩展我们的实现。在撰写本文时,bleve 没有提供任何内置机制来运行在分布式模式下;我们需要从头开始推出一个自定义解决方案。一种方法就是创建一个多主设置。这里的想法是启动多个索引服务实例,并将它们放置在允许客户端通过 API 访问索引的 网关服务 后面。当客户端提供要索引的文档时,网关将要求 所有 索引实例处理该文档,并且只有在所有实例都成功索引了文档后,才会向调用者返回。另一方面,网关可以将传入的搜索请求委派给池中的任何随机索引实例。鉴于搜索是一种读密集型的工作负载,上述方法 可能 会很好地工作。我说可能是因为在这种实现中可能会有很多问题发生。
构建分布式系统很困难;弄清楚它们在发生故障时的行为甚至更困难。我们肯定更倾向于使用经过大规模生产系统实战检验的现成解决方案;最好是那些其故障模式(通过像 Jepsen ^([12]) 这样的框架发现)已知且理解良好的解决方案。为此,我们将基于 Elasticsearch ^([9]) 构建我们的第二个索引器实现。以下是使用 Elasticsearch 的一些好处:
-
我们可以在自己的基础设施上运行 Elasticsearch,或者使用商业上可用的托管 Elasticsearch SaaS 服务之一。
-
Elasticsearch 内置了对集群的支持,并且可以水平扩展。
-
它公开了一个 REST API,并为大多数流行的编程语言提供了客户端。客户端列表包括官方的 Go 客户端 ^([21]),我们将使用它来实现我们的索引器。
创建一个新的 Elasticsearch 索引器实例
要创建一个新的 Elasticsearch 搜索索引器,客户端需要调用 NewElasticSearchIndexer
构造函数并提供要连接的弹性搜索节点列表。我们的实现将使用官方的 Go 客户端库 Elasticsearch,该库由 go-elastic
包提供 ^([21]):
func NewElasticSearchIndexer(esNodes []string) (*ElasticSearchIndexer, error) {
cfg := elasticsearch.Config{
Addresses: esNodes,
}
es, err := elasticsearch.NewClient(cfg)
if err != nil {
return nil, err
}
if err = ensureIndex(es); err != nil {
return nil, err
}
return &ElasticSearchIndexer{
es: es,
}, nil
}
在创建新的 go-elastic 客户端之后,构造函数会调用 ensureIndex
辅助函数,该函数检查我们将用于存储文档的 Elasticsearch 索引(在数据库术语中相当于表)是否已经存在。如果没有,辅助函数将自动为我们创建它,使用以下字段映射集(在数据库术语中相当于表模式):
{
"mappings" : {
"properties": {
"LinkID": {"type": "keyword"},
"URL": {"type": "keyword"},
"Content": {"type": "text"},
"Title": {"type": "text"},
"IndexedAt": {"type": "date"},
"PageRank": {"type": "double"}
}
}
}
提供字段映射不是 Elasticsearch 的严格要求!实际上,索引引擎完全能够通过分析其内容简单地推断每个文档字段的类型。然而,如果我们明确在我们的端提供字段映射,我们不仅迫使 Elasticsearch 为每个字段类型使用一个特定的索引器实现,我们还可以单独配置和微调每个字段索引器的行为。
前面的 JSON 文档定义了以下映射集:
-
LinkID
和URL
字段指定了keyword
字段类型。此类型指示 Elasticsearch 将其索引为文本块,适用于如查找 LinkID 为 X 的文档
之类的查询。 -
Content
和Title
字段指定了text
字段类型。Elasticsearch 将使用一个特殊的索引器,允许我们对这些字段执行全文搜索。 -
IndexedAt
和PageRank
字段被解析并存储为日期和双精度值。
索引和查找文档
要将文档更新到索引中,我们需要向 Elasticsearch 集群提交一个更新操作。更新请求的内容使用以下代码块填充:
esDoc := makeEsDoc(doc)
update := map[string]interface{}{
"doc": esDoc,
"doc_as_upsert": true,
}
makeEsDoc
辅助函数将输入的indexer.Document
实例转换为 Elasticsearch 可以处理的表现形式。需要注意的是,映射的文档不包括PageRank
分数值,即使原始文档中存在该值。这是故意的,因为我们只允许通过调用UpdateScore
来修改PageRank
分数。doc_as_upsert
标志作为提示,告诉 Elasticsearch 如果文档不存在,则应创建该文档,即它应将更新请求视为 upsert 操作。
在填充更新文档后,我们只需将其序列化为 JSON,执行一个synchronous
更新,并检查任何报告的错误:
var buf bytes.Buffer
err := json.NewEncoder(&buf).Encode(doc)
if err != nil {
return xerrors.Errorf("index: %w", err)
}
res, err := i.es.Update(indexName, esDoc.LinkID, &buf, i.es.Update.WithRefresh("true"))
if err != nil {
return xerrors.Errorf("index: %w", err)
}
var updateRes esUpdateRes
if err = unmarshalResponse(res, &updateRes); err != nil {
return xerrors.Errorf("index: %w", err)
}
当使用 go-elastic 客户端对 Elasticsearch 执行任何 API 调用时,错误可以通过两种不同的方式报告:
-
客户端返回错误和
nil
响应值。这种情况可能发生,例如,如果 Elasticsearch 节点的 DNS 解析失败,或者客户端无法连接到提供的任何节点地址。 -
Elasticsearch 发送一个包含结构化错误的 JSON 响应作为其有效载荷。
为了处理后者的情况,我们可以使用方便的unmarshalResponse
辅助函数,该函数检查响应中是否存在错误,并将它们作为常规 Go 错误值返回。
关于文档查找?这个操作被建模为一个搜索查询,我们尝试匹配一个具有特定链接 ID 值的单个文档。像对 Elasticsearch 集群的任何其他请求一样,搜索查询被指定为 JSON 文档,通过 HTTP POST 请求发送到集群。FindByID
实现通过定义嵌套的map[string]interface{}
项块来内联创建搜索查询,然后通过 JSON 编码器实例序列化:
var buf bytes.Buffer
query := map[string]interface{}{
"query": map[string]interface{}{
"match": map[string]interface{}{
"LinkID": linkID.String(),
},
},
"from": 0,
"size": 1,
}
if err := json.NewEncoder(&buf).Encode(query); err != nil {
return nil, xerrors.Errorf("find by ID: %w", err)
}
在这一点上,我想指出,我仅选择使用内联、无类型的简单方法来定义搜索查询。理想情况下,您会为查询的每一部分定义嵌套结构,而不是使用映射。除了与类型值一起工作的明显好处之外,与结构一起工作的另一个重要好处是,我们可以切换到一个更高效的 JSON 编码器实现,该实现不需要使用反射。一个这样的例子是 easyjson ^([10]),它利用代码生成来创建高效的 JSON 编码器/解码器,并承诺比 Go 标准库中提供的 JSON 编码器实现快 4 倍到 5 倍。
在我们的查询成功序列化为 JSON 之后,我们调用runSearch
辅助函数,将查询提交给 Elasticsearch。辅助函数将获得的响应反序列化为嵌套结构,同时检查是否存在错误:
searchRes, err := runSearch(i.es, query)
if err != nil {
return nil, xerrors.Errorf("find by ID: %w", err)
}
if len(searchRes.Hits.HitList) != 1 {
return nil, xerrors.Errorf("find by ID: %w", index.ErrNotFound)
}
doc := mapEsDoc(&searchRes.Hits.HitList[0].DocSource)
如果一切按计划进行,我们将收到单个结果。然后,该结果被传递给mapEsDoc
辅助函数,将其转换回一个Document
模型实例,如下所示:
func mapEsDoc(d *esDoc) *index.Document {
return &index.Document{
LinkID: uuid.MustParse(d.LinkID),
URL: d.URL,
Title: d.Title,
Content: d.Content,
IndexedAt: d.IndexedAt.UTC(),
PageRank: d.PageRank,
}
}
如前所述的代码片段所示,大多数字段只是复制到文档中,除了LinkID
字段,它必须首先从字符串表示形式解析为 UUID 值。然后,转换后的文档被返回给FindByID
方法的调用者。
执行分页搜索
如您所预期的那样,作为一个主要工作是在文档内进行搜索的产品,Elasticsearch 支持多种不同的查询类型,从基于关键词的搜索到复杂的地理空间或基于时间的查询。不幸的是,指定查询的语法略有不同,这取决于我们希望执行查询的类型。
结果表明,对于我们的特定用例,我们可以使用相同的查询语法来处理基于关键词和短语查询。我们所需做的只是将调用者提供的QueryType
转换为 Elasticsearch 特定的值,然后将其插入到预定义的搜索模板中。为了实现这一点,索引器实现使用switch块将传入的查询类型转换为 Elasticsearch 可以识别和解释的值:
var qtype string
switch q.Type {
case index.QueryTypePhrase:
qtype = "phrase"
default:
qtype = "best_fields"
}
我们可以继续使用一系列嵌套的map[string]interface{}
值,以(相当冗长)的格式组装我们的搜索查询,该格式是 Elasticsearch 所期望的,如下所示:
query := map[string]interface{}{
"query": map[string]interface{}{
"function_score": map[string]interface{}{
"query": map[string]interface{}{
"multi_match": map[string]interface{}{
"type": qtype,
"query": q.Expression,
"fields": []string{"Title", "Content"},
},
},
"script_score": map[string]interface{}{
"script": map[string]interface{}{
"source": "_score + doc['PageRank'].value",
},
},
},
},
"from": q.Offset,
"size": batchSize,
}
为了处理匹配结果的分页,查询通过from
和size
查询字段指定了页面偏移量和页面大小。
上述查询模板展示了另一个非常实用的 Elasticsearch 功能:分数提升。默认情况下,Elasticsearch 根据提交查询的相关性对返回的文档进行排序。对于某些类型的查询,默认的内置相关性分数计算算法可能不会产生有意义的排序值(例如,所有文档都包含搜索关键字并被分配相同的 relevance 分数)。为此,Elasticsearch 提供了用于操作或甚至完全覆盖匹配文档的相关性分数的辅助工具。
我们特定的查询模板指定了一个自定义脚本,该脚本通过聚合匹配文档的 PageRank 分数和由 Elasticsearch(通过_score
字段公开)计算的查询相关性分数来计算有效相关性分数。这个小技巧确保了具有更高PageRank
分数的文档始终在结果集中排序得更高。
就像我们对FindByID
实现所做的那样,我们再次调用runSearch
辅助函数向 Elasticsearch 提交搜索请求并反序列化返回结果的第一页。如果操作成功,将创建一个新的esIterator
实例并将其返回给调用者,以便可以消费搜索查询的结果:
searchRes, err := runSearch(i.es, query)
if err != nil {
return nil, xerrors.Errorf("search: %w", err)
}
return &esIterator{es: i.es, searchReq: query, rs: searchRes, cumIdx: q.Offset}, nil
与其内存中的兄弟类似,esIterator
实现维护自己的全局和每页计数器集合,以跟踪其在 Elasticsearch 返回的结果集中的位置。每次调用迭代器的Next
方法时,迭代器都会检查是否发生错误或是否已消耗所有搜索结果。如果发生这种情况,则Next
调用返回false
以通知调用者没有更多结果可用。
如果迭代器尚未耗尽当前结果页,它会执行以下操作:
-
两个内部位置跟踪计数器都会增加
-
下一个可用的结果通过调用
mapEsDoc
辅助函数(见上一节)转换为Document
模型,并在迭代器对象内部锁定 -
向调用者返回一个
true
值,以指示可以通过调用迭代器的Document
方法检索到下一个结果
否则,如果已到达当前结果页的末尾并且还有更多结果可用,迭代器会调整最后搜索查询的偏移字段,并发送新的搜索请求以获取下一页的结果。
为了简洁起见,我们在此不会列出esIterator
实现的源代码,因为它几乎与我们已检查的内存索引实现相同。您可以通过打开此Chapter06/textindexer/store/es
包中的iterator.go
文件来查看迭代器的完整文档化源代码,该文件可在本书的 GitHub 仓库中找到。
更新文档的 PageRank 分数
要更新现有文档的 PageRank
分数,我们需要构建一个更新请求负载,该负载将由 go-elastic 客户端通过 HTTP POST 请求提交到 Elasticsearch 集群。更新负载包括一个包含需要更新的字段名称和值的映射。
为了方便文档更新,go-elastic 客户端公开了一个 Update
方法,该方法期望以下一组参数:
-
包含要更新文档的索引名称
-
要更新的文档的 ID
-
以 JSON 编码的文档更新负载
以下代码片段说明了如何组装更新请求并将其传递给 Update
方法:
var buf bytes.Buffer
update := map[string]interface{}{
"doc": map[string]interface{}{
"LinkID": linkID.String(),
"PageRank": score,
},
"doc_as_upsert": true,
}
if err := json.NewEncoder(&buf).Encode(update); err != nil {
return xerrors.Errorf("update score: %w", err)
}
如果调用 UpdateScore
方法的调用者提供了一个不存在的文档链接 ID,我们希望能够创建一个包含仅 LinkID
和 PageRank
分数的占位符文档。这可以通过在我们的更新负载中包含 doc_as_upsert
标志来实现。
为 Elasticsearch 索引器设置测试套件
基于 Elasticsearch 的索引器实现定义了自己的 go-check 测试套件,该套件嵌入共享的索引器测试套件,并提供针对 Elasticsearch 实现特定的设置和清理方法。
套件中的每个测试都使用相同的 ElasticSearchIndexer
实例,该实例通过以下套件设置方法初始化一次:
func (s *ElasticSearchTestSuite) SetUpSuite(c *gc.C) {
nodeList := os.Getenv("ES_NODES")
if nodeList == "" {
c.Skip("Missing ES_NODES envvar; skipping elasticsearch-backed index test suite")
}
idx, err := NewElasticSearchIndexer(strings.Split(nodeList, ","))
c.Assert(err, gc.IsNil)
s.SetIndexer(idx)
// Keep track of the concrete indexer implementation so we can access
// its internals when setting up the test
s.idx = idx
}
由于 Elasticsearch 是一个资源密集型应用程序,因此你可能在本地开发机器上不会运行它。为此,套件设置代码将检查 ES_NODES
环境变量的存在,该变量包含要连接的 Elasticsearch 节点的逗号分隔列表。如果没有定义该变量,则整个测试套件将自动跳过。
为了保证测试之间不相互干扰,为每个测试提供一个空白的 Elasticsearch 索引非常重要。为此,在每次测试运行之前,一个针对每个测试的设置方法会删除 Elasticsearch 索引,以及之前测试运行中添加到索引中的任何文档:
func (s *ElasticSearchTestSuite) SetUpTest(c *gc.C) {
if s.idx.es != nil {
_, err := s.idx.es.Indices.Delete([]string{indexName})
c.Assert(err, gc.IsNil)
}
}
测试套件代码的其余部分负责将套件注册到 go-check 框架中,并添加适当的钩子,以便在调用 go test
时运行套件。
摘要
在本章中,我们通过定义链接图和文本索引器组件的数据层抽象来为 Links 'R' Us 系统奠定基础。此外,为了证明我们的抽象层确实使得替换底层实现变得容易,我们为每个组件提供了两个兼容且完全可测试的实现。
在下一章中,我们将讨论使用 Go 构建高效数据处理管道的策略和模式,并实现 Links 'R' Us 项目的网络爬虫组件。
问题
-
关系型数据库和无 SQL 数据库之间的关键区别是什么?请提供一个示例用例,说明在哪种情况下关系型数据库比无 SQL 数据库更适合,反之亦然。
-
你会如何扩展一个关系型数据库系统以适应读密集型和写密集型的工作负载?
-
CAP 定理是什么?在选择使用哪种 NoSQL 实现时,它是否很重要?
-
为什么在业务逻辑和底层数据库之间提供一个抽象层很重要?
-
你会如何在上一章最后一部分讨论的
Indexer
接口中添加一个新方法?
进一步阅读
-
Go 语言的现代文本索引库。可在以下网址找到:
github.com/blevesearch/bleve
. -
Apache Cassandra:快速管理大量数据,无需担忧。可在以下网址找到:
cassandra.apache.org
. -
Apache CouchDB。可在以下网址找到:
couchdb.apache.org
. -
埃里克·A.布勒尔:《走向健壮的分布式系统》。在:分布式计算原理(PODC),2000 年。
-
CockroachDB:为全球业务提供超可靠 SQL。可在以下网址找到:
www.cockroachlabs.com
. -
科德,E. F.:《大型共享数据银行的关系模型》。在:ACM 通讯 Bd. 13. 纽约,纽约,美国,ACM(1970),第 6 期,第 377-387 页。
-
数据库迁移。命令行界面和 Golang 库。可在以下网址找到:
github.com/golang-migrate/migrate
. -
DynamoDB:适用于任何规模的快速灵活 NoSQL 数据库服务。可在以下网址找到:
aws.amazon.com/dynamodb
. -
Elasticsearch:开源搜索和分析。可在以下网址找到:
www.elastic.co/
. -
golang 的快速 JSON 序列化器。可在以下网址找到:
github.com/mailru/easyjson
. -
gocheck:Go 语言的丰富测试。可在以下网址找到:
labix.org/gocheck
. -
Jepsen:打破分布式系统,让你无需担忧。可在以下网址找到:
github.com/jepsen-io/jepsen
. -
LevelDB:一个由谷歌编写的快速键值存储库,它提供从字符串键到字符串值的有序映射。可在以下网址找到:
github.com/google/leveldb
. -
马丁,罗伯特·C.:《整洁架构:软件结构和设计的工匠指南》,罗伯特·C.马丁系列。波士顿,马萨诸塞州:普伦蒂斯·霍尔,2017 年——ISBN 978-0-13-449416-6.
-
memcached:一个分布式内存对象缓存系统。可在以下网址找到:
memcached.org
. -
MongoDB:现代应用中最受欢迎的数据库。可在以下网址找到:
www.mongodb.com
. -
MySQL:世界上最受欢迎的开源数据库。可在以下网址找到:
www.mysql.com
. -
PostgreSQL:世界上最先进的开源关系型数据库。可在以下网址获取:
www.postgresql.org
。 -
纯 Go 编写的 Postgres 数据库/SQL 驱动程序。可在以下网址获取:
github.com/lib/pq
。 -
RocksDB:一个可嵌入的持久化键值存储,用于快速存储。可在以下网址获取:
rocksdb.org
。 -
Elasticsearch 的官方 Go 客户端。可在以下网址获取:
github.com/elastic/go-elasticsearch
。
第七章:数据处理管道
“每个编写良好的大型程序内部都包含一个编写良好的小型程序。”
- 托尼·霍尔
管道是将数据处理分割成多个阶段的一种相当标准和常用的方式。在本章中,我们将探讨数据处理管道的基本原理,并展示使用 Go 原语(如通道、上下文和 goroutines)实现通用、并发安全和可重用管道的蓝图。
在本章中,你将学习以下内容:
-
使用 Go 原语从头开始设计通用处理管道
-
以通用方式对管道有效负载进行建模的方法
-
处理管道执行过程中可能出现的错误策略
-
同步和异步管道设计的优缺点
-
将管道设计概念应用于构建 Links 'R' Us 爬虫组件
技术要求
本章讨论的主题的完整代码已发布到本书的 GitHub 仓库中的Chapter07
文件夹下。
你可以通过访问github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
来访问包含每个章节代码和所有必需资源的 GitHub 仓库。
为了让你尽快开始,每个示例项目都包含一个 makefile,它定义了以下目标集:
Makefile 目标 | 描述 |
---|---|
deps |
安装所有必需的依赖项 |
test |
运行所有测试并报告覆盖率 |
lint |
检查代码风格错误 |
与本书的所有其他章节一样,你需要一个相当新的 Go 版本,你可以在golang.org/dl
.下载。
在 Go 中构建通用数据处理管道
下图展示了我们将在这章的前半部分构建的管道的高级设计:
图 1:一个通用的多阶段管道
请记住,这绝对不是实现数据处理管道的唯一或最佳方式。管道本质上是与应用程序相关的,因此并没有一个适用于所有情况的构建高效管道的指南。
话虽如此,所提出的设计适用于广泛的用例,包括但不限于 Links 'R' Us 项目的爬虫组件。让我们更详细地审视前面的图,并确定管道包含的基本组件:
-
输入源:输入本质上充当数据源,将数据泵入管道。从现在开始,我们将使用术语有效载荷来指代这组数据。在底层,输入促进了适配器的作用,读取外部系统(如数据库或消息队列)中通常可用的数据,并将其转换为管道可以消费的格式。
-
一个或多个处理阶段:管道的每个阶段接收一个有效载荷作为其输入,对其应用处理函数,然后将结果传递给下一个阶段。
-
输出汇:在经过管道的每个阶段之后,有效载荷最终达到输出汇。与输入源类似,汇也充当适配器,只是这次转换是反向的!有效载荷被转换为可以由外部系统消费的格式。
-
错误总线:错误总线提供了一个方便的抽象,允许管道组件在管道执行过程中报告任何发生的错误。
管道的完整源代码和测试可以在书籍的 GitHub 仓库的Chapter07/pipeline
文件夹下找到。
管道包的设计目标
让我们快速列举一下我们将要构建的pipeline
包的设计目标。我们将作为设计决策指南的关键原则是:简单性、可扩展性和通用性。
首先也是最重要的,我们的设计应该能够适应不同类型的有效载荷。请记住,有效载荷格式在大多数情况下是由管道包的最终用户决定的。因此,管道内部不应假设通过各个管道阶段的任何有效载荷的内部实现细节。
其次,数据处理管道的主要作用是促进有效载荷在源和汇之间的流动。与有效载荷类似,管道的端点也由最终用户提供。因此,管道包需要定义适当的抽象和接口,以便最终用户注册他们自己的源和汇实现。
此外,管道包不应仅允许最终用户为每个阶段指定处理函数。用户还应该能够根据每个阶段选择管道用于将有效载荷传递给处理函数的策略。从逻辑上讲,该包应包含“内置电池”-也就是说,提供内置的实现,用于最常见的有效载荷传递策略;然而,如果内置的策略不足以满足特定用例,用户应能够定义自己的自定义策略。
最后,我们的实现必须提供简单直观的 API 来创建、组装和执行复杂的管道。此外,处理管道执行的 API 不仅应该为用户提供取消长时间运行的管道的手段,还应该提供一种机制来捕获和报告在管道忙于处理有效载荷时可能发生的任何错误。
模型管道有效载荷
在我们开始工作于管道包实现之前,我们需要回答的第一个也是最重要的问题是:我们如何使用 Go 以一种通用方式描述管道有效载荷?
对于这个问题的明显答案是将有效载荷定义为空接口值(在 Go 术语中为 interface{}
)。支持这种方法的论据是,管道内部实际上并不真正关心有效载荷本身;管道需要做的只是在不同管道阶段之间传递有效载荷。
对有效载荷内容的解释(例如,通过将输入转换为已知类型)应该是每个阶段执行的处理函数的唯一责任。鉴于处理函数是由管道的最终用户指定的,这种方法可能非常适合我们的特定需求。
然而,正如 Rob Pike 在他著名的 Go 谚语中所相当巧妙地表达的,interface{}
什么也没说。这个陈述中有很多真理。空接口传达了关于底层类型没有任何有用的信息。事实上,如果我们遵循空接口的方法,我们实际上会有效地禁用 Go 编译器对我们代码库中某些部分进行静态类型检查的能力!
一方面,在 Go 社区中,通常认为使用空接口是一种反模式,因此这是我们理想情况下想要避免的做法。另一方面,Go 没有对泛型提供支持,这使得编写能够处理预先未知类型的对象的代码变得更加困难。因此,与其试图找到这个问题的银弹解决方案,不如我们尝试妥协一下:我们是否可以尝试强制实施一套所有有效载荷类型都必须支持的通用操作,并创建一个 Payload
接口来描述它们?这样,我们就能在保持管道处理函数能够将传入的有效载荷转换为它们期望的类型的同时,增加一层额外的类型安全性。以下是 Payload
接口的一个可能定义:
// Payload is implemented by values that can be sent through a pipeline.
type Payload interface {
Clone() Payload
MarkAsProcessed()
}
如您所见,我们期望无论有效载荷是如何定义的,它都必须能够执行至少两个简单(而且相当常见)的操作:
-
执行自身的深度复制:正如我们将在接下来的某个部分中看到的那样,这个操作将用于避免当多个处理器同时操作同一有效载荷时发生数据竞争。
-
标记为已处理:当有效负载达到管道的末端(汇点)或在中途的管道阶段被丢弃时,它们被认为是已处理的。当有效负载退出管道时调用此方法对于我们需要收集每个有效负载的度量(总处理时间、进入管道前的排队时间等)的场景非常有用。
多阶段处理
管道背后的关键概念是将一个复杂的处理任务分解成一系列较小的步骤或阶段,这些步骤可以独立于彼此执行,并且按照预定义的顺序执行。作为一种理念,多阶段处理似乎也与我们在第二章中讨论的单一职责原则非常契合,即编写干净且可维护的 Go 代码的最佳实践。
当组装一个多阶段管道时,预期最终用户会提供一组函数,或处理器,这些函数将应用于传入的有效负载,当它们通过管道的每个阶段流动时。我将使用符号F[i]来指代这些函数,其中i对应于阶段号。
在正常情况下,每个阶段的输出将被用作下一个阶段的输入——也就是说Output[i] = Fi。然而,我们确实可以想象出一些场景,我们实际上希望丢弃一个有效负载并阻止它到达任何后续的管道阶段。
例如,假设我们正在构建一个管道来读取和汇总 CSV 文件中的数据。不幸的是,该文件包含一些垃圾数据,我们必须将其排除在我们的计算之外。为了处理这种情况,我们可以在管道中添加一个过滤阶段来检查每行的内容,并丢弃包含格式错误数据的那些行。
考虑到前面的情况,我们可以将Processor
接口描述如下:
type Processor interface {
// Process operates on the input payload and returns back a new payload
// to be forwarded to the next pipeline stage. Processors may also opt
// to prevent the payload from reaching the rest of the pipeline by
// returning a nil payload value instead.
Process(context.Context, Payload) (Payload, error)
}
在先前的定义中存在一个小问题,使得它在实际应用中略显繁琐。由于我们谈论的是一个接口,它需要通过像 Go struct 这样的类型来实现;然而,有人可能会争论,在许多情况下,我们真正需要的只是能够使用一个简单的函数,或者一个闭包作为我们的处理器。
由于我们正在设计一个通用的管道包,我们的目标应该是使其 API 尽可能方便最终用户。为此,我们还将定义一个辅助类型ProcessorFunc
,它充当函数适配器的角色:
type ProcessorFunc func(context.Context, Payload) (Payload, error)
// Process calls f(ctx, p).
func (f ProcessorFunc) Process(ctx context.Context, p Payload) (Payload, error) {
return f(ctx, p)
}
如果我们有一个具有适当签名的函数,我们可以将其转换为ProcessorFunc
并自动获得一个实现了Processor
接口的类型!如果你觉得这个技巧有点熟悉,那么很可能你已经在使用它了,如果你编写过任何导入http
包并注册 HTTP 处理器的代码。http
包中的HandlerFunc
类型正是使用这个想法将用户定义的函数转换为有效的 HTTP Handler
实例。
无阶段的管道——这是否可能?
管道定义是否应该包含一个最小数量的阶段,才能被视为有效?更具体地说,我们是否应该允许定义一个没有阶段的管道?在我看来,阶段应该被视为管道定义的可选部分。记住,为了使管道工作,它至少需要一个输入源和一个输出接收器。
如果我们直接将输入连接到输出并执行管道,我们会得到与执行只有一个阶段的管道相同的结果,该阶段的Processor
是一个恒等函数——即总是输出传递给它的输入值的函数。我们可以很容易地使用上一节中的ProcessorFunc
辅助函数定义这样的函数:
identityFn := ProcessorFunc( func(_ context.Context, p Payload) (Payload, error) { return p, nil },)
这种类型的管道在现实世界中有没有实际的应用?答案是肯定的!这种管道有助于作为适配器连接两个可能不兼容的系统,并在它们之间传输数据。例如,我们可以使用这种方法从消息队列中读取事件并将它们持久化到 noSQL 数据库以进行进一步处理。
处理错误的策略
当管道执行时,构成它的每个组件都可能遇到错误。因此,在实现我们的管道包的内部机制之前,我们需要制定一个检测、收集和处理错误的策略。
在接下来的章节中,我们将探讨一些处理错误的替代策略。
累积并返回所有错误
我们可以采用的 simplest 策略之一是引入一种机制,在管道执行过程中收集和累积管道组件发出的所有错误。一旦管道检测到错误,它会自动丢弃触发错误的负载,但将捕获到的错误追加到收集到的错误列表中。管道将使用下一个负载继续执行,直到所有负载都已被处理。
在管道执行完成后,任何收集到的错误都会返回给用户。在这个时候,我们有两种选择:要么返回 Go 错误值的切片,要么使用辅助包,例如hashicorp/go-multierror
^([6]),它允许我们将 Go 错误值列表聚合到一个实现了error
接口的容器值中。
对于这种类型的错误处理,一个很好的候选者是那些处理器实现尽力而为语义的管道。例如,如果我们正在构建一个以火速和忘记的方式泵送事件的管道,我们不希望管道因为某个事件无法发布而停止。
使用死信队列
在某些情况下,管道包的用户可能对获取所有由于错误而无法被管道处理的负载的列表感兴趣。
以下点根据应用需求适用:
-
关于每个错误以及每个失败负载的详细信息可以记录下来以供进一步分析
-
失败的负载可以持久化到外部系统(例如,通过消息队列),以便它们可以被人工检查和纠正(当可行时)由人工操作员。
-
我们可以启动一个新的管道运行来处理上一次运行中失败的负载
存储失败项以供将来处理的概念在事件驱动架构中非常普遍,通常被称为死信队列。
如果发生错误则终止管道的执行
之前策略的一个重要注意事项是它们不能应用于长时间运行的管道。即使发生错误,我们也不会在管道完成之前发现它。这可能需要数小时、数天,甚至在管道的输入数据永远不会耗尽的情况下永远如此。后者的一个例子是,管道的输入连接到一个消息队列,并在等待新消息到达时阻塞。
为了处理这类场景,当发生错误时,我们可以立即终止管道的执行并将错误返回给用户。实际上,这是我们将在管道实现中使用的错误处理策略。
初看起来,你可能会认为这种方法与其他我们之前讨论的策略相比相当有限;然而,如果我们深入挖掘,我们会发现这种方法更适合更多的用例,因为它足够灵活,可以模拟其他两种错误处理策略的行为。
为了更好地理解如何实现这一点,我们首先需要谈谈在管道执行过程中可能发生的错误性质。根据错误是否致命,我们可以将它们分为两类:
-
非暂时性错误:这类错误被认为是致命的,应用程序无法真正从中恢复。一个非暂时性错误的例子是在写入文件时耗尽磁盘空间。
-
瞬时错误:应用程序可以,并且应该始终尝试从这种错误中恢复,尽管这并不总是可能的。这通常是通过某种重试机制实现的。例如,如果应用程序失去了与远程服务器的连接,它可以尝试使用指数退避策略重新连接。如果达到最大重试次数,那么这将成为一个非瞬时错误。
以下是一个简单示例,说明用户如何应用装饰器设计模式来封装 Processor
函数,并实现一个可以区分瞬时和非瞬时错误的重试机制:
func retryingProcessor(proc Processor, isTransient func(error) bool, maxRetries int) Processor {
return ProcessorFunc(func(ctx context.Context, p Payload) (Payload, error) {
var out Payload
var err error
for i := 0; i < maxRetries; i++ {
if out, err = proc.Process(ctx, p); err != nil && !isTransient(err) {
return nil, err
}
}
return nil, err
})
}
retryingProcessor
函数封装了一个现有的 Processor
,以提供在出现错误时自动重试的支持。每次发生错误时,该函数都会咨询 isTransient
辅助函数,以决定获取到的错误是否是瞬时的,以及是否可以执行处理负载的另一次尝试。非瞬时错误被认为是不可恢复的,在这种情况下,函数将返回错误以导致管道终止。最后,如果超过最大重试次数,该函数将错误视为非瞬时并退出。
同步与异步管道
将影响我们实现管道核心方式的一个关键决策是它将以同步还是异步的方式运行。让我们快速了解一下这两种操作模式,并讨论每种模式的优缺点。
同步管道
同步管道实际上一次处理一个负载。我们可以通过创建一个执行以下操作的 for
循环来实现这样的管道:
-
从输入源弹出一个下一个负载或退出循环,如果没有更多的负载可用
-
遍历管道阶段的列表,并对每个阶段调用
Processor
实例 -
将生成的负载入队到输出源
同步管道非常适合那些必须始终以 先进先出(FIFO)方式处理负载的工作负载,这对于事件驱动架构来说是一个相当常见的情况,大多数情况下,它们假设事件总是按照特定的顺序进行处理。
例如,假设我们正在尝试构建一个用于消费来自订单处理系统的事件流、通过查询外部系统丰富一些传入事件并最终将丰富的事件转换为适合持久化到关系型数据库的格式的 ETL(代表 提取、转换和加载)管道。对于此用例的管道可以使用以下两个阶段来组装:
-
第一阶段检查事件类型,并通过查询外部服务添加适当的信息来丰富它
-
第二阶段将每个丰富的事件转换为更新一个或多个数据库表的 SQL 查询序列
按设计,我们的处理代码期望一个AccountCreated
事件必须始终先于一个OrderPlaced
事件发生,该事件包括一个指向下订单的客户的账户的引用(一个 UUID)。如果事件被错误地处理,系统可能会发现自己试图在数据库中的客户记录被创建之前处理OrderPlaced
事件。虽然当然可以绕过这个限制进行编码,但这会使处理代码变得更加复杂,并且当出现问题难以调试。同步管道将强制执行有序处理语义,并使这个问题成为非问题。
那么使用同步管道时有什么问题呢?与同步管道相关的主要问题是低吞吐量。如果我们的管道由N个阶段组成,并且每个阶段需要1 个时间单位来完成,那么我们的管道将需要N 个时间单位来处理和发出每个有效载荷。进一步来说,每次一个阶段正在处理一个有效载荷时,其余的N-1个阶段都在闲置。
异步管道
在异步管道设计中,一旦一个阶段处理了传入的有效载荷并将其发送到下一个阶段,它就可以立即开始处理下一个可用的有效载荷,而无需等待当前处理的有效载荷退出管道,正如在同步管道设计中那样。这种方法确保所有阶段都持续忙碌于处理有效载荷,而不是闲置。
需要注意的是,异步管道通常需要某种形式的并发。一个常见的模式是将每个阶段在一个单独的 goroutine 中运行。当然,这给组合带来了额外的复杂性,因为我们需要做以下事情:
-
管理每个 goroutine 的生命周期
-
利用并发原语,如锁,来避免数据竞争
尽管如此,与同步管道相比,异步管道具有更好的吞吐量特性。这正是我们将在本章构建的管道包将采用异步管道实现的主要原因……但有一点不同!尽管所有管道组件(输入、输出和阶段)都将异步运行,但最终用户将通过一个同步的 API 与管道进行交互。
快速浏览目前最流行的 Go 软件开发工具包(SDKs),会发现普遍倾向于暴露同步 API。从 API 消费者的角度来看,同步 API 确实更容易使用,因为最终用户不需要担心管理资源,例如 Go 通道,或者编写复杂的select
语句来协调通道之间的读写操作。将这种方法与异步 API 进行对比,在异步 API 中,每次用户想要执行管道运行时,都必须处理输入、输出和错误通道!
如前所述,管道内部将异步执行。在 Go 中实现这一点的典型方法是为每个管道组件启动一个 goroutine,并通过 Go 通道将各个 goroutine 连接起来。管道实现将负责完全管理它启动的任何 goroutine 的生命周期,这对管道包的最终用户来说是完全透明的。
当与 goroutine 一起工作时,我们必须始终关注它们的各自生命周期。一条明智的建议是,除非你知道 goroutine 何时退出以及需要满足哪些条件才能退出,否则不要启动 goroutine。
忽视这条建议可能会导致在长时间运行的应用程序中引入 goroutine 泄漏,通常需要花费相当多的时间和精力来追踪。
为管道包公开一个同步 API 还有一个我们尚未提到的好处。对于管道包的最终用户来说,将同步 API 包装在 goroutine 中以使其异步是非常简单的。goroutine 将简单地调用阻塞代码,并通过通道向应用程序代码发出信号,以表明管道执行已完成。
实现执行负载处理器的阶段工作器
管道包的一个目标是为最终用户提供一种指定每个阶段的调度策略,以便将传入的负载调度到已注册的处理器函数。为了能够以干净和可扩展的方式支持不同的调度策略,我们将引入另一个抽象,即StageRunner
接口:
type StageRunner interface {
Run(context.Context, StageParams)
}
具体的StageRunner
实现提供了一个Run
方法,该方法实现了管道单个阶段的负载处理循环。典型的处理循环包括以下步骤:
-
从上一个阶段或输入源接收下一个负载,如果这恰好是管道的第一个阶段。如果上游数据源表示数据已耗尽,或者外部提供的
context.Context
被取消,则Run
方法应自动返回。 -
将负载调度到用户定义的阶段处理器函数。正如我们将在以下章节中看到的,这一步骤的实现取决于
StageRunner
实现所使用的调度策略。 -
如果错误处理器返回一个错误,将错误入队到共享错误总线并返回。
-
将成功处理的负载推送到下一个管道阶段,或者如果这是管道的最后一个阶段,则推送到输出汇。
前面的步骤清楚地表明Run
是一个阻塞调用。管道实现将为管道的每个阶段启动一个 goroutine,调用每个已注册StageRunner
实例的Run
方法,并等待其返回。由于我们正在使用 goroutine,连接它们的适当机制是使用 Go 通道。由于 goroutine 和通道的生命周期都由管道内部管理,我们需要一种方法来配置每个StageRunner
,使其与将要工作的通道集。此信息通过Run
方法的第二个参数提供。以下是StageParams
接口的定义:
type StageParams interface {
StageIndex() int
Input() <-chan Payload
Output() chan<- Payload
Error() chan<- error
}
Input
方法返回一个工人将监视的只读通道,该通道用于接收传入的有效载荷。通道将被关闭以指示没有更多数据可供处理。Output
方法返回一个只写通道,其中StageRunner
应在成功处理输入有效载荷后发布。另一方面,如果在处理传入有效载荷时发生错误,Error
通道返回一个只写通道,其中可以发布错误。最后,StageIndex
方法返回管道中阶段的位置,StageRunner
实现可以可选地使用它来注释错误。
在接下来的几节中,我们将更详细地探讨三种非常常见的有效载荷调度策略的实施,这些策略我们将与管道包捆绑在一起:FIFO、固定/动态工作池和广播。
FIFO
如其名所示,当阶段以 FIFO 模式运行时,它按顺序处理有效载荷,从而保持它们的顺序。通过创建一个所有阶段都使用 FIFO 调度的管道,我们可以强制执行数据处理的同步语义,同时仍然保留异步管道相关的高吞吐量优势。
fifo
类型在pipeline
包内部是私有的,但可以通过调用FIFO
函数来实例化,该函数概述如下:
type fifo struct {
proc Processor
}
// FIFO returns a StageRunner that processes incoming payloads in a
// first-in first-out fashion. Each input is passed to the specified
// processor and its output is emitted to the next stage.
func FIFO(proc Processor) StageRunner {
return fifo{proc: proc}
}
现在我们来看看fifo
类型的Run
方法实现:
func (r fifo) Run(ctx context.Context, params StageParams) {
for {
select {
case <-ctx.Done():
return // Asked to cleanly shut down
case payloadIn, ok := <-params.Input():
if !ok {
return // No more data available.
}
// Process payload, handle errors etc.
// (see following listing)
}
}
}
如您所见,Run
按设计是一个阻塞调用;它运行一个无限循环,其中包含一个select
语句。在select
块中,代码执行以下操作:
-
监视提供的上下文是否取消,并在上下文被取消(例如,如果用户取消它或其超时过期)时退出主循环。
-
尝试从输入通道检索下一个有效载荷。如果输入通道关闭,代码将退出主循环。
接收到新的输入有效载荷后,FIFO 运行器执行以下代码块:
payloadOut, err := r.proc.Process(ctx, payloadIn)
if err != nil {
wrappedErr := xerrors.Errorf("pipeline stage %d: %w", params.StageIndex(), err)
maybeEmitError(wrappedErr, params.Error())
return
}
if payloadOut == nil {
payloadIn.MarkAsProcessed()
continue
}
select {
case params.Output() <- payloadOut:
case <-ctx.Done():
return // Asked to cleanly shut down
}
输入有效载荷首先传递给用户定义的Processor
实例。如果处理器返回错误,代码将使用当前阶段号对其进行注释,并在退出工作进程之前通过调用maybeEmitError
辅助函数将其入队到提供的错误通道:
// maybeEmitError attempts to queue err to a buffered error channel. If the
// channel is full, the error is dropped.
func maybeEmitError(err error, errCh chan<- error) {
select {
case errCh <- err: // error emitted.
default: // error channel is full with other errors.
}
}
如果有效负载没有错误地处理,那么我们需要检查处理器是否返回了一个我们需要转发的有效负载,或者是一个nil有效负载来指示应该丢弃输入负载。在丢弃负载之前,代码在其MarkAsProcessed
方法开始新的主循环迭代之前调用它。
另一方面,如果处理器返回一个有效的有效负载,我们尝试使用select
语句将其入队到输出通道,该语句会阻塞,直到有效负载被写入输出通道或上下文被取消。在后一种情况下,工作者终止,有效负载被丢弃。
固定和动态工作池
通常,处理器函数可能需要相当长的时间才能返回。这可能是因为实际的负载处理涉及 CPU 密集型计算,或者仅仅是因为函数正在等待 I/O 操作完成(例如,处理器函数执行了对远程服务器的 HTTP 请求,并正在等待响应)。
如果所有阶段都使用 FIFO 调度策略连接,那么执行缓慢的处理器的存在可能会导致流水线停滞。如果乱序处理有效负载不是问题,我们可以通过引入工作池来更好地利用可用的系统资源。工作池是一种模式,可以通过允许阶段并行处理多个有效负载来显著提高流水线的吞吐量。
我们将要实现的第一种工作池模式是固定工作池。此类池启动预配置数量的工作者,并将传入的有效负载在他们之间分配。池中的每个工作者实现与 FIFO StageRunner
相同的循环。如下代码所示,我们的实现积极利用这一观察结果,通过为池中的每个工作者创建一个 FIFO 实例来避免重复主循环代码:
type fixedWorkerPool struct {
fifos []StageRunner
}
func FixedWorkerPool(proc Processor, numWorkers int) StageRunner {
if numWorkers <= 0 {
panic("FixedWorkerPool: numWorkers must be > 0")
}
fifos := make([]StageRunner, numWorkers)
for i := 0; i < numWorkers; i++ {
fifos[i] = FIFO(proc)
}
return &fixedWorkerPool{fifos: fifos}
}
以下代码中所示的Run
方法启动单个池工作者,执行它们的Run
方法,并使用sync.WaitGroup
来防止它返回,直到所有生成的工作者 goroutines 终止:
func (p *fixedWorkerPool) Run(ctx context.Context, params StageParams) {
var wg sync.WaitGroup
// Spin up each worker in the pool and wait for them to exit
for i := 0; i < len(p.fifos); i++ {
wg.Add(1)
go func(fifoIndex int) {
p.fifos[fifoIndex].Run(ctx, params)
wg.Done()
}(i)
}
wg.Wait()
}
在布线方面,这里的事情相当简单。我们只需要将传入的参数原封不动地传递给每个 FIFO 实例。这种布线的效果如下:
-
所有 FIFOs 都设置为从相同的输入通道读取传入的有效负载,该通道连接到前一个流水线阶段(或输入源)。这种方法有效地充当负载均衡器,用于将有效负载分配给空闲的 FIFOs。
-
所有 FIFOs 将处理后的有效负载输出到相同的输出通道,该通道连接到下一个流水线阶段(或输出汇)。
固定工作池的设置相当简单,但有一个注意事项:必须提前指定工作进程的数量!在某些情况下,确定工作进程数量的合理值非常容易。例如,如果我们知道处理器将执行 CPU 密集型计算,我们可以通过将工作进程的数量设置为runtime.NumCPU()
调用的结果来确保我们的管道充分利用所有可用的 CPU 核心。有时,为工作进程数量提供一个合理的估计并不那么容易。一个潜在的解决方案是切换到动态工作池。
静态工作池和动态工作池之间的关键区别在于,后者中工作进程的数量不是固定的,而是随时间变化。这种基本差异使我们能够通过允许动态池自动调整工作进程的数量来适应先前阶段吞吐量的变化,从而更好地利用可用资源。
不言而喻,我们应该始终为动态池可以产生的最大工作进程数量设置一个上限。如果没有这样的限制,管道生成的 goroutine 数量可能会失控,导致程序要么停止运行,更糟糕的是,程序崩溃!为了避免这个问题,以下代码中展示的动态工作池实现使用了一个称为令牌池的原始机制:
type dynamicWorkerPool struct {
proc Processor
tokenPool chan struct{}
}
func DynamicWorkerPool(proc Processor, maxWorkers int) StageRunner {
if maxWorkers <= 0 {
panic("DynamicWorkerPool: maxWorkers must be > 0")
}
tokenPool := make(chan struct{}, maxWorkers)
for i := 0; i < maxWorkers; i++ {
tokenPool <- struct{}{}
}
return &dynamicWorkerPool{proc: proc, tokenPool: tokenPool}
}
令牌池被建模为一个缓冲的chan struct{}
,它预先填充了与我们希望允许的最大并发工作进程数量相等的令牌。让我们看看这个原始的并发控制机制是如何通过将动态池的Run
方法实现分解成逻辑块来使用的:
func (p *dynamicWorkerPool) Run(ctx context.Context, params StageParams) {
stop:
for {
select {
case <-ctx.Done():
break stop // Asked to cleanly shut down
case payloadIn, ok := <-params.Input():
if !ok { break stop }
// Process payload... (see listings below)
}
}
for i := 0; i < cap(p.tokenPool); i++ { // wait for all workers to exit
<-p.tokenPool
}
}
与 FIFO 实现类似,动态池执行一个包含select
语句的无限循环;然而,在这个实现中处理有效载荷的代码相当不同。我们不会直接调用有效载荷处理器代码,而是会启动一个 goroutine 来在后台为我们处理这个任务,同时主循环尝试处理下一个到达的有效载荷。
在启动新的工作进程之前,我们必须首先从池中获取一个令牌。这是通过以下代码块实现的,该代码块会阻塞,直到可以从通道中读取令牌或提供的上下文被取消:
var token struct{}
select {
case token = <-p.tokenPool:
case <-ctx.Done():
break stop
}
上述代码块作为限制并发工作进程数量的瓶颈。一旦池中的所有令牌耗尽,尝试从通道中读取将会被阻塞,直到令牌返回到池中。那么令牌是如何返回到池中的呢?为了回答这个问题,我们需要看看我们成功从池中读取令牌之后发生了什么:
go func(payloadIn Payload, token struct{}) {
defer func() { p.tokenPool <- token }()
payloadOut, err := p.proc.Process(ctx, payloadIn)
if err != nil {
wrappedErr := xerrors.Errorf("pipeline stage %d: %w", params.StageIndex(), err)
maybeEmitError(wrappedErr, params.Error())
return
}
if payloadOut == nil {
payloadIn.MarkAsProcessed()
return // Discard payload
}
select {
case params.Output() <- payloadOut:
case <-ctx.Done():
}
}(payloadIn, token)
这段代码与 FIFO 实现大致相同,但有两大小的区别:
-
它在 goroutine 内部执行。
-
它包括一个
defer
语句,以确保 goroutine 完成后将令牌返回到池中。这很重要,因为它使得令牌可以重复使用。
我们需要讨论的最后一段代码是Run
方法末尾的 for 循环。为了保证动态池不会泄漏任何 goroutine,我们需要确保在Run
返回之前,在方法运行期间创建的所有 goroutine 都已终止。我们不需要使用sync.WaitGroup
,可以通过简单地排空令牌池来达到相同的效果。正如我们所知,工作者只能在持有令牌的情况下运行;一旦 for 循环从池中提取了所有令牌,我们就可以安全地返回,知道所有工作者已经完成了他们的工作,并且他们的 goroutine 已经终止。
1 到 N 的广播
1 到N的广播模式允许我们支持每个传入的有效载荷必须由N个不同的处理器并行处理的用例,每个处理器都实现了类似 FIFO 的语义。
以下代码是broadcast
类型的定义以及作为其构造函数的Broadcast
辅助函数:
type broadcast struct {
fifos []StageRunner
}
func Broadcast(procs ...Processor) StageRunner {
if len(procs) == 0 {
panic("Broadcast: at least one processor must be specified")
}
fifos := make([]StageRunner, len(procs))
for i, p := range procs {
fifos[i] = FIFO(p)
}
return &broadcast{fifos: fifos}
}
如您所见,可变参数的Broadcast
函数接收一个Processor
实例列表作为参数,并为每个实例创建一个 FIFO 实例。这些 FIFO 实例存储在返回的broadcast
实例中,并在其Run
方法实现中使用,以下将对其进行剖析:
var wg sync.WaitGroup
var inCh = make([]chan Payload, len(b.fifos))
for i := 0; i < len(b.fifos); i++ {
wg.Add(1)
inCh[i] = make(chan Payload)
go func(fifoIndex int) {
fifoParams := &workerParams{
stage: params.StageIndex(),
inCh: inCh[fifoIndex],
outCh: params.Output(),
errCh: params.Error(),
}
b.fifos[fifoIndex].Run(ctx, fifoParams)
wg.Done()
}(i)
}
与我们在上一节中检查的固定工作者池实现类似,我们在Run
内部做的第一件事是为每个 FIFO StageRunner
实例启动一个 goroutine。sync.WaitGroup
允许我们在Run
返回之前等待所有工作者退出。
为了避免数据竞争,广播阶段的实现必须拦截每个传入的有效载荷,克隆它,并将副本发送给生成的每个 FIFO 处理器。因此,生成的 FIFO 处理器实例不能直接连接到该阶段的输入通道,而必须配置一个专用的输入通道进行读取。为此,前面的代码块为每个 FIFO 实例生成一个新的workerParams
值(pipeline
包实现StageParams
接口的内部类型),并将其作为参数传递给其Run
方法。请注意,尽管每个 FIFO 实例配置了单独的输入通道,但它们都共享相同的输出和错误通道。
Run
方法实现的下一部分是现在熟悉的,主循环,其中我们等待下一个传入的有效载荷出现:
done:
for {
// Read incoming payloads and pass them to each FIFO
select {
case <-ctx.Done():
break done
case payload, ok := <-params.Input():
if !ok {
break done
}
// Clone payload and dispatch to each FIFO worker...
// (see following listing)
}
}
一旦接收到新的有效载荷,实现会为每个 FIFO 实例写入有效载荷的副本,但第一个接收的是原始的传入有效载荷:
for i := len(b.fifos) - 1; i >= 0; i-- {
var fifoPayload = payload
if i != 0 {
fifoPayload = payload.Clone()
}
select {
case <-ctx.Done():
break done
case inCh[i] <- fifoPayload:
// payload sent to i_th FIFO
}
}
在将有效载荷发布到所有 FIFO 实例之后,主循环的新迭代开始。主循环会一直执行,直到输入通道关闭或上下文被取消。在退出主循环之后,在 Run
返回之前执行以下哨兵代码块:
// Close input channels and wait for all FIFOs to exit
for _, ch := range inCh {
close(ch)
}
wg.Wait()
在前面的代码片段中,我们通过关闭每个 FIFO 工作者的专用输入通道来通知每个 FIFO 工作者关闭。然后我们调用 Wait
方法等待所有 FIFO 工作者终止。
实现输入源工作器
为了开始一个新的管道运行,用户应提供生成应用程序特定有效载荷的输入源,这些有效载荷驱动管道。所有用户定义的输入源都必须实现 Source
接口,其定义如下:
type Source interface {
Next(context.Context) bool
Payload() Payload
Error() error
}
Source
接口包含您期望的任何支持迭代的常规数据源的标准方法:
-
Next
尝试前进迭代器。如果没有更多数据可用或发生错误,则返回false
。 -
Payload
返回在成功调用迭代器的Next
方法后创建的新Payload
实例。 -
Error
返回输入遇到的最后一个错误。
为了便于对输入源的异步轮询,管道包将在 goroutine 中运行以下 sourceWorker
函数。其主要任务是迭代数据源并将每个传入的有效载荷发布到指定的通道:
func sourceWorker(ctx context.Context, source Source, outCh chan<- Payload, errCh chan<- error) {
for source.Next(ctx) {
payload := source.Payload()
select {
case outCh <- payload:
case <-ctx.Done():
return // Asked to shutdown
}
}
// Check for errors
if err := source.Error(); err != nil {
wrappedErr := xerrors.Errorf("pipeline source: %w", err)
maybeEmitError(wrappedErr, errCh)
}
}
sourceWorker
函数会一直运行,直到对源 Next
方法的调用返回 false
。在返回之前,工作器实现将检查输入源报告的任何错误并将它们发布到提供的错误通道。
实现输出接收器工作器
当然,如果没有输出接收器,我们的管道就不完整!毕竟,一旦有效载荷通过管道,它们不会消失在空气中;它们必须最终到达某个地方。因此,与输入源一起,用户应提供实现 Sink
接口的输出接收器:
type Sink interface {
// Consume processes a Payload instance that has been emitted out of
// a Pipeline instance.
Consume(context.Context, Payload) error
}
为了将处理过的有效载荷传递到接收器,管道包将启动一个新的 goroutine 并执行 sinkWorker
函数,其实现如下:
func sinkWorker(ctx context.Context, sink Sink, inCh <-chan Payload, errCh chan<- error) {
for {
select {
case payload, ok := <-inCh:
if !ok { return }
if err := sink.Consume(ctx, payload); err != nil {
wrappedErr := xerrors.Errorf("pipeline sink: %w", err)
maybeEmitError(wrappedErr, errCh)
return
}
payload.MarkAsProcessed()
case <-ctx.Done():
return // Asked to shutdown
}
}
}
sinkWorker
循环从提供的输入通道读取有效载荷并尝试将它们发布到提供的 Sink
实例。如果 sink
实现在消费有效载荷时报告错误,则 sinkWorker
函数将在返回之前将其发布到提供的错误通道。
整合一切 – 管道 API
在详细描述每个单独的管道组件的来龙去脉之后,现在是时候将一切整合起来并实现一个最终用户将依赖的 API,以便组装和执行他们的管道。
可以通过调用pipeline
包中的可变参数New
函数来创建一个新的管道实例。正如你在以下代码列表中可以看到的,构建函数期望一个StageRunner
实例列表作为参数,其中列表的每个元素对应于管道的一个阶段:
type Pipeline struct {
stages []StageRunner
}
// New returns a new pipeline instance where input payloads will traverse
// each one of the specified stages.
func New(stages ...StageRunner) *Pipeline {
return &Pipeline{
stages: stages,
}
}
用户可以选择使用我们在上一节中概述的StageRunner
实现(FIFO、FixedWorkerPool
、DynamicWorkerPool
或Broadcast
),这些实现由pipeline
包提供,或者,作为替代,提供满足单方法StageRunner
接口的应用特定变体。
在构建一个新的管道实例并创建兼容的输入源/输出汇之后,用户可以通过在获得的管道实例上调用Process
方法来执行管道:
func (p *Pipeline) Process(ctx context.Context, source Source, sink Sink) error {
// ...
}
Process
的第一个参数是一个可以由用户取消以强制管道终止的上下文实例。对Process
方法的调用将被阻塞,直到满足以下条件之一:
-
上下文被取消。
-
源数据耗尽,所有有效载荷都已处理或丢弃。
-
在管道组件或用户定义的处理函数中发生错误。在后一种情况下,错误将返回给调用者。
让我们看看Process
方法的实现细节:
var wg sync.WaitGroup
pCtx, ctxCancelFn := context.WithCancel(ctx)
// Allocate channels for wiring together the source, the pipeline stages
// and the output sink.
stageCh := make([]chan Payload, len(p.stages)+1)
errCh := make(chan error, len(p.stages)+2)
for i := 0; i < len(stageCh); i++ {
stageCh[i] = make(chan Payload)
}
首先,我们创建一个新的上下文(pCtx
),它包装用户定义的上下文,但同时也允许我们手动取消它。包装的上下文将被传递给所有管道组件,使我们能够轻松地拆除整个管道,如果我们检测到任何错误。
在设置我们的上下文之后,我们继续分配和初始化我们需要连接即将启动的各种工作者的通道。如果我们有总共N个阶段,那么我们需要N+1 个通道来连接一切(包括源和汇工作者)。例如,如果在创建管道时没有指定任何阶段,我们仍然需要一个通道将源连接到汇。
错误通道充当一个共享错误总线。在前面的代码片段中,你可以看到我们正在创建一个具有N+2 个槽位的缓冲错误通道。这为每个管道组件(N个阶段和源/汇工作者)的潜在错误提供了足够的空间。
在以下代码块中,我们启动一个 goroutine,其主体调用与管道每个阶段关联的StageRunner
实例的Run
方法:
// Start a worker for each stage
for i := 0; i < len(p.stages); i++ {
wg.Add(1)
go func(stageIndex int) {
p.stages[stageIndex].Run(pCtx, &workerParams{
stage: stageIndex,
inCh: stageCh[stageIndex],
outCh: stageCh[stageIndex+1],
errCh: errCh,
})
close(stageCh[stageIndex+1])
wg.Done()
}(i)
}
如你很可能注意到的,第n个工作者的输出通道被用作第n+1 个工作者的输入通道。一旦第n个工作者的Run
方法返回,它将关闭其输出通道,向管道的下一阶段发出没有更多数据可用的信号。
在启动阶段工作者之后,我们需要启动两个额外的工作者:一个用于输入源,一个用于输出汇:
wg.Add(2)
go func() {
sourceWorker(pCtx, source, stageCh[0], errCh)
close(stageCh[0])
wg.Done()
}()
go func() {
sinkWorker(pCtx, sink, stageCh[len(stageCh)-1], errCh)
wg.Done()
}()
到目前为止,我们的管道实现已经启动了相当多的 goroutines。到这一点,你可能想知道:我们如何确保所有这些 goroutines 实际上都会终止?
一旦源工作器耗尽数据,对sourceWorker
的调用将返回,我们继续关闭stageCh[0]
通道。这触发了连锁反应,导致每个阶段工作器干净地终止。当第i个工作器检测到其输入通道已被关闭时,它假设没有更多数据可用,并在终止之前关闭自己的输出通道(这也恰好是i+1工作器的输入)。最后一个输出通道连接到接收工作器。因此,一旦最后一个阶段工作器关闭其输出,接收工作器也将终止。
这将我们带到了Process
方法实现的最后部分:
go func() {
wg.Wait()
close(errCh)
ctxCancelFn()
}()
// Collect any emitted errors and wrap them in a multi-error.
var err error
for pErr := range errCh {
err = multierror.Append(err, pErr)
ctxCancelFn()
}
return err
正如你在前面的代码片段中所见,我们启动了一个最终的 worker,它充当监控器的角色:它等待所有其他工作器完成,然后关闭共享错误通道并取消包装的上下文。
当所有工作器都在愉快地运行时,Process
方法正在使用range
关键字迭代错误通道的内容。如果任何错误被发布到共享错误通道,它将借助hashicorp/multierror
包^([6])附加到err
值上,并且包装的上下文将被取消以触发整个管道的关闭。
另一方面,如果没有发生错误,前面的 for 循环将无限期地阻塞,直到监控工作器关闭通道。由于错误通道只有在所有其他管道工作器都已终止后才会关闭,因此相同的 range 循环会阻止Process
函数的调用返回,直到管道执行完成,无论是否有错误发生。
为“链接'R' Us”项目构建爬虫管道
在接下来的章节中,我们将通过使用它来构建“链接'R' Us”项目的爬虫管道来测试我们构建的通用管道包!
遵循单一职责原则,我们将爬取任务分解成一系列较小的子任务,并组装以下图中所示的管道。将任务分解成较小的子任务的好处还包括,每个阶段处理器可以在完全隔离的情况下进行测试,而无需创建管道实例:
图 2:我们将构建的爬虫管道的阶段
爬虫及其测试的完整代码可以在Chapter07/crawler
包中找到,该包位于本书的 GitHub 仓库中。
定义爬虫的有效负载
首先,我们需要定义将在管道每个阶段之间共享的有效负载:
type crawlerPayload struct {
LinkID uuid.UUID
URL string
RetrievedAt time.Time
RawContent bytes.Buffer
// NoFollowLinks are still added to the graph but no outgoing edges
// will be created from this link to them.
NoFollowLinks []string
Links []string
Title string
TextContent string
}
前三个字段LinkID
、URL
和RetrievedAt
将由输入源填充。其余字段将由各种爬虫阶段填充:
-
RawContent
由链接获取器填充 -
NoFollowLinks
和Links
由链接提取器填充 -
Title
和TextContent
由文本提取器填充
当然,为了能够使用这个有效载荷定义与管道包一起使用,它需要实现 pipeline.Payload
接口:
type Payload interface {
Clone() Payload
MarkAsProcessed()
}
在我们着手在我们的有效载荷类型上实现这两种方法之前,让我们短暂休息一下,花些时间了解我们应用程序特定管道的内存分配模式。鉴于我们的计划是让爬虫作为一个长时间运行的过程执行,并暂定处理大量链接,我们需要考虑内存分配是否会影响爬虫的性能。
在管道执行期间,输入源将为进入管道的每个新链接分配一个新的有效载荷。此外,正如我们在 图 2 中所看到的,在有效载荷被发送到图更新器和文本索引阶段时,在分叉点将制作一个额外的副本。有效载荷可以在早期被丢弃(例如,链接获取器可以使用黑名单文件扩展名列表过滤链接)或者最终到达输出汇。
因此,我们将生成大量需要由 Go 运行时在某个时候进行垃圾回收的小对象。在相对较短的时间内进行大量分配会增加 Go 垃圾收集器 (GC) 的压力,并触发更频繁的 GC 停顿,这会影响我们管道的延迟特性。
验证我们理论的最佳方式是使用 runtime/pprof
包捕获运行中的爬虫管道的内存分配配置文件,并使用 pprof
工具进行分析。使用 pprof
^([8]) 超出了本书的范围,因此这一步留给好奇的读者作为练习。
现在我们对我们爬虫预期的分配模式有了更好的理解,下一个问题是:我们能做些什么?幸运的是,Go 标准库中的 sync
包包括 Pool
类型 ^([4]),它正是为此用途而设计的!
Pool
类型试图通过在多个客户端之间分摊分配对象的成本来减轻垃圾收集器的压力。这是通过维护一个已分配但未使用的实例缓存来实现的。当客户端从池中请求一个新的对象时,他们可以收到一个缓存的实例,或者如果池为空,则收到一个新分配的实例。一旦客户端完成使用他们获取的对象,他们必须将其返回到池中,以便其他客户端可以重用。请注意,任何在池中未由客户端使用的对象都是垃圾收集器的目标,并且可以在任何时候回收。
这里是我们将用于回收有效载荷实例的池的定义:
var (
payloadPool = sync.Pool{
New: func() interface{} {
return new(crawlerPayload)
},
}
)
新的New
方法将在底层池实现用尽缓存项后自动被调用,以处理传入的客户端请求。由于Payload
类型的零值已经是一个有效的有效载荷,我们所需做的只是分配并返回一个新的Payload
实例。让我们看看我们如何使用刚刚定义的池来实现有效载荷的Clone
方法:
func (p *crawlerPayload) Clone() pipeline.Payload {
newP := payloadPool.Get().(*Payload)
newP.LinkID = p.LinkID
newP.URL = p.URL
newP.RetrievedAt = p.RetrievedAt
newP.NoFollowLinks = append([]string(nil), p.NoFollowLinks...)
newP.Links = append([]string(nil), p.Links...)
newP.Title = p.Title
newP.TextContent = p.TextContent
_, err := io.Copy(&newP.RawContent, &p.RawContent)
if err != nil {
panic(fmt.Sprintf("[BUG] error cloning payload raw content: %v", err))
}
return newP
}
如您所见,从池中分配了一个新的有效载荷实例,在将其返回给调用者之前,将复制原始有效载荷的所有字段。最后,让我们看一下MarkAsProcessed
方法实现:
func (p *crawlerPayload) MarkAsProcessed() {
p.URL = p.URL[:0]
p.RawContent.Reset()
p.NoFollowLinks = p.NoFollowLinks[:0]
p.Links = p.Links[:0]
p.Title = p.Title[:0]
p.TextContent = p.TextContent[:0]
payloadPool.Put(p)
}
当调用MarkAsProcessed
时,我们需要在将其返回到池中之前清除有效载荷内容,以便它可以安全地被下一个检索它的客户端使用。
另一件事需要注意的是,我们还采用了一个小的优化技巧来减少在管道执行期间执行的总分配次数。我们将两个切片和字节数组的长度设置为zero
,而不修改它们的原始容量。下一次回收的有效载荷通过管道发送时,任何尝试写入字节数组或向其中一个有效载荷切片追加操作都将重用已分配的空间,并且只有在需要额外空间时才会触发新的内存分配。
实现爬虫的源和汇
执行爬虫管道的前提是提供一个符合pipeline.Source
接口的输入源和一个实现pipeline.Sink
的输出汇。我们已经在前面的章节中讨论了这两个接口,但为了参考,我将它们的定义复制如下:
type Source interface {
Next(context.Context) bool
Payload() Payload
Error() error
}
type Sink interface {
Consume(context.Context, Payload) error
}
在第六章,构建持久化层中,我们汇集了链接图组件的接口,并提出了两种替代的具体实现。在这一点上,graph.Graph
接口的一个特别有趣的方法是Links
。Links
方法返回一个graph.LinkIterator
,它允许我们遍历图的一个部分(分区)内的链接列表,甚至整个图。作为一个快速回顾,以下是包含在graph.LinkIterator
接口中的方法列表:
type LinkIterator interface {
Next() bool
Error() error
Close() error
Link() *Link
}
如您所见,LinkIterator
和Source
接口彼此非常相似。实际上,我们可以应用装饰器设计模式(如下面的代码所示),将graph.LinkIterator
包装起来,使其成为与我们的管道兼容的输入源!
type linkSource struct {
linkIt graph.LinkIterator
}
func (ls *linkSource) Error() error { return ls.linkIt.Error() }
func (ls *linkSource) Next(context.Context) bool { return ls.linkIt.Next() }
func (ls *linkSource) Payload() pipeline.Payload {
link := ls.linkIt.Link()
p := payloadPool.Get().(*crawlerPayload)
p.LinkID = link.ID
p.URL = link.URL
p.RetrievedAt = link.RetrievedAt
return p
}
Error
和Next
方法仅仅是底层迭代器对象的代理。Payload
方法从池中获取一个Payload
实例,并从通过迭代器获得的graph.Link
实例中填充其字段。
对于输出汇而言,事情要简单得多。在每个有效负载经过链接更新器和文本索引器阶段之后,我们就不再需要它了!因此,我们所需做的就是提供一个汇实现,它作为一个黑洞来工作:
type nopSink struct{}
func (nopSink) Consume(context.Context, pipeline.Payload) error {
return nil
}
Consume
方法简单地忽略有效负载,并始终返回一个 nil
错误。一旦 Consume
调用返回,管道工作器会自动在有效负载上调用 MarkAsProcessed
方法,正如我们在上一节中看到的,这确保了有效负载被返回到池中,以便将来可以重用。
获取图链接的内容
链接获取器是爬虫管道的第一阶段。它处理输入源发出的 Payload
值,并通过发送 HTTP GET 请求尝试检索每个链接的内容。检索到的链接网页内容存储在有效负载的 RawContent
字段中,并供管道的后续阶段使用。
现在我们来看看 linkFetcher
类型及其相关方法的定义:
type linkFetcher struct {
urlGetter URLGetter
netDetector PrivateNetworkDetector
}
func newLinkFetcher(urlGetter URLGetter, netDetector PrivateNetworkDetector) *linkFetcher {
return &linkFetcher{
urlGetter: urlGetter,
netDetector: netDetector,
}
}
func (lf *linkFetcher) Process(ctx context.Context, p pipeline.Payload) (pipeline.Payload, error) {
//...
}
虽然 Go 标准库提供了我们可以直接使用的 http
包来获取链接内容,但通常允许代码的预期用户插入他们首选的实现来执行 HTTP 调用是一种良好的实践。由于链接获取器只关注发送 GET 请求,我们将应用接口隔离原则,并定义一个 URLGetter
接口:
// URLGetter is implemented by objects that can perform HTTP GET requests.
type URLGetter interface {
Get(url string) (*http.Response, error)
}
这种方法带来了一些重要的好处。首先,它允许我们在不需要启动专用测试服务器的情况下测试链接获取器代码。虽然使用 httptest.NewServer
方法创建用于测试的服务器相当常见,但为测试服务器安排返回正确的有效负载和/或状态码需要额外的努力。
此外,在预期 Get
调用返回错误和 nil http.Response
的场景中,拥有一个测试服务器实际上并没有太大的帮助。这可以非常有用,用于评估我们的代码在 DNS 查询失败或 TLS 验证错误存在时的行为。通过引入基于接口的抽象,我们可以使用像 gomock
这样的包来为我们的测试生成兼容的模拟,正如我们在第四章 The Art of Testing 中所展示的。
除了测试之外,这种方法使我们的实现变得更加灵活!爬虫的最终用户现在可以选择传递 http.DefaultClient
,如果他们更喜欢使用合理的默认值,或者提供他们自己的定制 http.Client
实现来处理重试、代理等。
在 第五章,“The Links 'R' Us 项目”中,我们讨论了与通过第三方资源(这些资源超出了我们的控制)自动爬取链接相关的潜在安全问题。那次讨论的关键结论是,我们的爬虫永远不应该尝试获取属于私有网络地址的链接,因为这可能导致敏感数据最终出现在我们的搜索索引中!为此,newLinkFetcher
函数也期望一个实现 PrivateNetworkDetector
接口的参数:
// PrivateNetworkDetector is implemented by objects that can detect whether a
// host resolves to a private network address.
type PrivateNetworkDetector interface {
IsPrivate(host string) (bool, error)
}
Chapter07/crawler/privnet
包含了一个简单的私有网络检测器实现,它首先将主机解析为 IP 地址,然后检查该 IP 地址是否属于 RFC1918 定义的任何私有网络范围^([7])。
现在我们已经涵盖了创建新的 linkFetcher
实例周围的所有重要细节,让我们来看看它的内部结构。正如我们希望包含在管道中的任何组件一样,linkFetcher
遵循 pipeline.Processor
接口。让我们将 Process
方法分解成更小的部分,以便我们可以进一步分析它:
payload := p.(*crawlerPayload)
if exclusionRegex.MatchString(payload.URL) {
return nil, nil // Skip URLs that point to files that cannot contain
// html content.
}
if isPrivate, err := lf.isPrivate(payload.URL); err != nil || isPrivate {
return nil, nil // Never crawl links in private networks
}
res, err := lf.urlGetter.Get(payload.URL)
if err != nil {
return nil, nil
}
第一步是将传入的 pipeline.Payload
值转换为输入源注入到管道中的具体 *crawlerPayload
实例。接下来,我们检查 URL 是否与一个不区分大小写的正则表达式(其定义将在下一节中展示)匹配,该正则表达式旨在匹配已知包含二进制数据(例如,图像)或文本内容(例如,可加载脚本、JSON 数据等)的文件扩展名,这些内容爬虫应该忽略。如果找到匹配项,链接获取器指示管道通过返回 nil, nil
的值丢弃有效载荷。第二个也是最后的预检查确保爬虫始终忽略解析为私有网络地址的 URL。最后,我们调用提供的 URLGetter
来检索链接的内容。
现在我们来看看调用 URLGetter
返回后会发生什么:
_, err = io.Copy(&payload.RawContent, res.Body)
_ = res.Body.Close()
if err != nil {
return nil, err
}
if res.StatusCode < 200 || res.StatusCode > 299 {
return nil, nil
}
if contentType := res.Header.Get("Content-Type"); !strings.Contains(contentType, "html") {
return nil, nil
}
return payload, nil
对于完成无错误的 GET 请求,我们将响应体复制到有效载荷的 RawContent
字段中,然后关闭体以避免内存泄漏。在允许有效载荷继续到下一个管道阶段之前,我们执行两个额外的合理性检查:
-
响应状态码应该在 2xx 范围内。如果不是,我们丢弃有效载荷而不是返回错误,因为后者会导致管道终止。不处理链接并不是一个大问题;爬虫将定期运行,所以爬虫将在未来重新访问有问题的链接。
-
Content-Type
标头应该表明响应包含一个 HTML 文档;否则,进一步处理响应就没有意义,我们可以简单地丢弃它。
从检索到的网页中提取出站链接
链接提取器的任务是扫描每个检索到的 HTML 文档的主体,并从中提取包含在其内的唯一链接集。网页中的每个统一资源定位符(URL)可以被分类为以下类别之一:
-
带有网络路径引用的 URL^([1]):这种类型的链接很容易识别,因为它不包含URL 方案(例如,
<img src="img/banner.png"/>
)。当网络浏览器(或在我们的情况下是爬虫)需要访问链接时,它将用访问包含该链接的网页所使用的协议来替换该链接。因此,如果父页面是通过 HTTPS 访问的,那么浏览器也将通过 HTTPS 请求横幅图像。 -
绝对链接:这些链接是完全限定的,通常用于指向托管在不同域上的资源。
-
相对链接:正如其名所示,这些链接是相对于当前页面 URL 解析的。还应注意,网页可以选择通过在
<head>
部分指定<base href="XXX">
标签来覆盖用于解析相对链接的 URL。
按照设计,链接图组件仅存储完全限定的链接。因此,链接提取器的一个关键职责是将所有相对链接解析为绝对 URL。这是通过resolveURL
辅助函数实现的,如下所示:
func resolveURL(relTo *url.URL, target string) *url.URL {
tLen := len(target)
if tLen == 0 {
return nil
} else if tLen >= 1 && target[0] == '/' {
if tLen >= 2 && target[1] == '/' {
target = relTo.Scheme + ":" + target
}
}
if targetURL, err := url.Parse(target); err == nil {
return relTo.ResolveReference(targetURL)
}
return nil
}
resolveURL
函数使用解析后的url.URL
和一个相对于它的目标路径来调用。由于 RFC 3986 中指定的规则数量众多,解析相对路径不是一个简单的过程。幸运的是,URL
类型提供了一个方便的ResolveReference
方法,它为我们处理了所有复杂性。在将目标传递给ResolveReference
方法之前,代码会进行额外的检查以检测网络路径引用。如果目标以//
前缀开始,实现将重写目标链接,并在前面添加提供的relTo
值中的方案。
在我们检查链接提取器的实现之前,我们需要定义一些有用的正则表达式,这些正则表达式将在代码中使用:
var (
exclusionRegex = regexp.MustCompile(`(?i)\.(?:jpg|jpeg|png|gif|ico|css|js)$`)
baseHrefRegex = regexp.MustCompile(`(?i)<base.*?href\s*?=\s*?"(.*?)\s*?"`)
findLinkRegex = regexp.MustCompile(`(?i)<a.*?href\s*?=\s*?"\s*?(.*?)\s*?".*?>`)
nofollowRegex = regexp.MustCompile(`(?i)rel\s*?=\s*?"?nofollow"?`)
)
我们将使用前面的不区分大小写的正则表达式来完成以下操作:
-
跳过指向非 HTML 内容的提取链接。请注意,这个特定的正则表达式实例是在这个阶段和链接提取器阶段之间共享的。
-
定位
<base href="XXX">
标签并捕获href
属性中的值。 -
从 HTML 内容中提取链接。第二个正则表达式旨在定位
<a href="XXX">
元素并捕获href
属性中的值。 -
识别应插入到图中但不应在计算指向它们的页面的
PageRank
分数时考虑的链接。网站管理员可以通过向<a>
标签添加具有nofollow
值的rel
属性来指示此类链接。例如,论坛管理员可以向发布的消息中的链接添加nofollow
标签,以防止用户通过在多个论坛中交叉发布链接来人为地增加其网站的PageRank
分数。
下面的列表显示了 linkExtractor
类的定义。与 linkFetcher
类类似,linkExtractor
也需要一个 PrivateNetworkDetector
实例来进行进一步过滤提取的链接:
type linkExtractor struct {
netDetector PrivateNetworkDetector
}
func newLinkExtractor(netDetector PrivateNetworkDetector) *linkExtractor {
return &linkExtractor{
netDetector: netDetector,
}
}
链接提取器的业务逻辑封装在其 Process
方法中。由于实现相对较长,我们将再次将其分成更小的块,并分别讨论每个块。考虑以下代码块:
payload := p.(*crawlerPayload)
relTo, err := url.Parse(payload.URL)
if err != nil {
return nil, err
}
// Search page content for a <base> tag and resolve it to an abs URL.
content := payload.RawContent.String()
if baseMatch := baseHrefRegex.FindStringSubmatch(content); len(baseMatch) == 2 {
if base := resolveURL(relTo, ensureHasTrailingSlash(baseMatch[1])); base != nil {
relTo = base
}
}
为了能够解析我们可能遇到的任何相对链接,我们需要一个完全限定的链接作为基础。默认情况下,这将是我们代码解析为 url.URL
值的传入链接 URL。正如我们之前提到的,如果页面包含有效的 <base href="XXX">
标签,我们必须使用 那个 来解析相对链接。
为了检测 <base>
标签的存在,我们将 baseHrefRegex
正则表达式应用于页面内容。如果我们获得有效的匹配,baseMatch
^([1]) 将包含标签的 href
属性值。然后,捕获的值被传递给 resolveURL
辅助函数,如果解析的 URL 有效,则用于覆盖 relTo
变量。
下面的代码块概述了链接提取和去重步骤:
seenMap := make(map[string]struct{})
for _, match := range findLinkRegex.FindAllStringSubmatch(content, -1) {
link := resolveURL(relTo, match[1])
if link == nil || !le.retainLink(relTo.Hostname(), link) {
continue
}
link.Fragment = "" // Truncate anchors
linkStr := link.String()
if _, seen := seenMap[linkStr]; seen || exclusionRegex.MatchString(linkStr) {
continue // skip already seen links and links that do not contain HTML
}
seenMap[linkStr] = struct{}{}
if nofollowRegex.MatchString(match[0]) {
payload.NoFollowLinks = append(payload.NoFollowLinks, linkStr)
} else {
payload.Links = append(payload.Links, linkStr)
}
}
FindAllStringSubmatch
方法返回特定正则表达式的连续匹配列表。FindAllStringSubmatch
的第二个参数控制要返回的最大匹配数。因此,通过传递 -1
作为参数,我们实际上要求正则表达式引擎返回所有 <a>
匹配。然后我们遍历每个匹配的链接,将其解析为绝对 URL。捕获的 <a>
标签内容和解析后的链接被传递给 retainLink
断言,如果链接必须跳过,则返回 false
。
处理循环的最后一步涉及对页面内链接的去重。为了实现这一点,我们将使用一个映射,其中链接 URL 用作键。在检查映射以查找重复条目之前,我们确保删除每个链接的片段部分(也称为 HTML 锚点);毕竟,从我们的爬虫的角度来看,http://example.com/index.html#foo
和 http://example.com/index.html
都引用了相同的链接。对于每个通过is-duplicate
检查的链接,我们将扫描其<a>
标签是否存在rel="nofollow"
属性。根据检查结果,链接将被附加到 payload 实例的NoFollowLinks
或Links
切片中,并可供管道的后续阶段使用。
我们需要探索的最后一段代码是retainLink
方法的实现:
func (le *linkExtractor) retainLink(srcHost string, link *url.URL) bool {
if link == nil {
return false // Skip links that could not be resolved
}
if link.Scheme != "http" && link.Scheme != "https" {
return false // Skip links with non http(s) schemes
}
if link.Hostname() == srcHost {
return true // No need to check for private network
}
if isPrivate, err := le.netDetector.IsPrivate(link.Host); err != nil || isPrivate {
return false // Skip links that resolve to private networks
}
return true
}
如您从前面的代码中看到的,我们在决定是否保留或跳过链接之前执行了两种类型的检查:
-
应该跳过方案不是 HTTP 或 HTTPS 的链接。允许其他方案类型可能存在潜在的安全风险!恶意用户可能会提交包含使用
file://
URL 的链接的网页,这可能会诱使爬虫读取(并索引)本地文件系统中的文件。 -
我们已经列举了允许爬虫访问位于私有网络地址的资源的安全影响。因此,任何指向私有网络的链接都会自动跳过。
从检索到的网页中提取标题和文本
管道的下一阶段负责提取一个索引友好的、纯文本版本的网页内容和标题。实现这一点的最简单方法是在页面主体中删除任何 HTML 标签,并将连续的空白字符替换为单个空格。
一种相当直接的方法是提出一系列用于匹配和删除 HTML 标签的正则表达式。不幸的是,由于 HTML 语法相当宽容(也就是说,你可以打开一个标签而永远不会关闭它),这使得仅使用正则表达式正确清理 HTML 文档变得臭名昭著地困难。说实话,为了覆盖所有可能的边缘情况,我们需要使用一个理解 HTML 文档结构的解析器。
我们不会重新发明轮子,而是将依靠 bluemonday ^([2]) Go 包来满足我们的 HTML 清理需求。该包提供了一套可配置的过滤策略,可以应用于 HTML 文档。针对我们的特定用例,我们将使用一个严格的策略(通过调用bluemonday.StrictPolicy
辅助函数获得),该策略有效地从输入文档中移除所有 HTML 标签。
一个小的注意事项是,bluemonday 策略维护自己的内部状态,因此不适合并发使用。因此,为了避免每次处理有效载荷时都分配一个新的策略,我们将使用一个sync.Pool
实例来回收 bluemonday 策略实例。当创建一个新的textExtractor
实例时,池将被初始化,如下所示:
type textExtractor struct {
policyPool sync.Pool
}
func newTextExtractor() *textExtractor {
return &textExtractor{
policyPool: sync.Pool{
New: func() interface{} {
return bluemonday.StrictPolicy()
},
},
}
}
让我们更仔细地看看文本提取器的Process
方法实现:
func (te *textExtractor) Process(ctx context.Context, p pipeline.Payload) (pipeline.Payload, error) {
payload := p.(*crawlerPayload)
policy := te.policyPool.Get().(*bluemonday.Policy)
if titleMatch := titleRegex.FindStringSubmatch(payload.RawContent.String()); len(titleMatch) == 2 {
payload.Title = strings.TrimSpace(html.UnescapeString(repeatedSpaceRegex.ReplaceAllString(
policy.Sanitize(titleMatch[1]), " ",
)))
}
payload.TextContent = strings.TrimSpace(html.UnescapeString(repeatedSpaceRegex.ReplaceAllString(
policy.SanitizeReader(&payload.RawContent).String(), " ",
)))
te.policyPool.Put(policy)
return payload, nil
}
从池中获取一个新的 bluemonday 策略后,我们执行一个正则表达式来检测 HTML 文档是否包含<title>
标签。如果找到匹配项,其内容将被清理并保存到有效载荷的Title
属性中。相同的策略也应用于网页内容,但这次,清理后的结果存储在有效载荷的TextContent
属性中。
将发现的出站链接插入到图中
我们将要检查的下一个爬虫管道阶段是图更新器。其主要目的是将新发现的链接插入到链接图中,并创建将它们与检索到的网页连接的边。让我们看看graphUpdater
类型的定义及其构造函数:
type graphUpdater struct {
updater Graph
}
func newGraphUpdater(updater Graph) *graphUpdater {
return &graphUpdater{
updater: updater,
}
}
构造函数期望一个Graph
类型的参数,这不过是一个描述图更新器与链接图组件通信所需方法的接口:
type Graph interface {
UpsertLink(link *graph.Link) error
UpsertEdge(edge *graph.Edge) error
RemoveStaleEdges(fromID uuid.UUID, updatedBefore time.Time) error
}
聪明的读者可能会注意到,前面的接口定义包括了与graph
包中同名接口的子集。这是将接口分离原则应用于提炼现有、更开放的接口,使其成为我们代码所需的最小接口的一个典型例子。接下来,我们将查看图更新器的Process
方法实现:
payload := p.(*crawlerPayload)
src := &graph.Link{
ID: payload.LinkID,
URL: payload.URL,
RetrievedAt: time.Now(),
}
if err := u.updater.UpsertLink(src); err != nil {
return nil, err
}
在我们遍历发现的链接列表之前,我们首先尝试通过创建一个新的graph.Link
对象并调用图的UpsertLink
方法来更新有效载荷中的原始链接到图中。原始链接已经在图中存在,所以前面的更新调用所做的只是更新RetrievedAt
字段的戳记。
下一步包括将发现的任何具有 no-follow rel
属性的链接添加到图中:
for _, dstLink := range payload.NoFollowLinks {
dst := &graph.Link{URL: dstLink}
if err := u.updater.UpsertLink(dst); err != nil {
return nil, err
}
}
在处理完所有 no-follow 链接后,图更新器遍历常规链接的切片,并将每个链接连同从原始链接到每个出站链接的有向边一起添加到链接图中:
removeEdgesOlderThan := time.Now()
for _, dstLink := range payload.Links {
dst := &graph.Link{URL: dstLink}
if err := u.updater.UpsertLink(dst); err != nil {
return nil, err
}
if err := u.updater.UpsertEdge(&graph.Edge{Src: src.ID, Dst: dst.ID}); err != nil {
return nil, err
}
}
在这次遍历中创建或更新的所有边都将被分配一个UpdatedAt
值,该值大于或等于我们在进入循环之前捕获的removeEdgesOlderThan
值。然后我们可以使用以下代码块来删除任何之前循环未触及的现有边:
if err := u.updater.RemoveStaleEdges(src.ID, removeEdgesOlderThan); err != nil {
return nil, err
}
为了理解上述过程是如何工作的,让我们通过一个简单的例子来了解一下。假设在时间t[0],爬虫处理了位于https://example.com
的网页。在那个特定的时间点,该页面包含到http://foo.com
和https://bar.com
的出站链接。一旦爬虫完成第一次遍历,链接图将包含以下边条目集合:
源 | 目标 | 更新时间 |
---|---|---|
https://example.com |
http://foo.com |
t[0] |
https://example.com |
https://bar.com |
t[0] |
接下来,爬虫进行新的遍历,这次是在时间t[1](t[1] > t[0]);然而,位于https://example.com
的页面内容已经发生变化:到http://foo.com
的链接现在已消失,页面作者引入了一个新的链接到https://baz.com
。
在我们更新了边列表并在修剪任何过时边之前,链接图中的边条目将如下所示:
源 | 目标 | 更新时间 |
---|---|---|
https://example.com |
http://foo.com |
t[0] |
https://example.com |
https://bar.com |
t[1] |
https://example.com |
https://baz.com |
t[1] |
修剪步骤删除了所有在t[1]之前最后更新的来自https://**example.com的边。因此,一旦爬虫完成第二次遍历,最终的边条目集合将如下所示:
源 | 目标 | 更新时间 |
---|---|---|
https://example.com |
bar.com |
t[1] |
https://example.com |
baz.com |
t[1] |
索引检索到的网页内容
我们管道中的最后一个组件是文本索引器。正如其名所示,文本索引器负责通过重新索引每个爬取的网页内容来保持搜索索引的更新。
与图更新阶段类似,我们应用单一职责原则,并定义了Indexer
接口,该接口通过构造函数传递给文本索引器组件:
// Indexer is implemented by objects that can index the contents of webpages retrieved by the crawler pipeline.
type Indexer interface {
Index(doc *index.Document) error
}
type textIndexer struct {
indexer Indexer
}
func newTextIndexer(indexer Indexer) *textIndexer {
return &textIndexer{
indexer: indexer,
}
}
下面的代码列表概述了textIndexer
类型的Process
方法实现:
func (i *textIndexer) Process(ctx context.Context, p pipeline.Payload) (pipeline.Payload, error) {
payload := p.(*crawlerPayload)
doc := &index.Document{
LinkID: payload.LinkID,
URL: payload.URL,
Title: payload.Title,
Content: payload.TextContent,
IndexedAt: time.Now(),
}
if err := i.indexer.Index(doc); err != nil {
return nil, err
}
return p, nil
}
上述代码片段中没有什么异常之处:我们创建一个新的index.Document
实例,并用管道文本提取阶段的标题和内容值填充它。然后,通过在提供的Indexer
实例上调用Index
方法,将文档插入到搜索索引中。
组装和运行管道
恭喜你走到了这一步!我们终于实现了构建我们爬虫服务管道所需的所有单个组件。剩下的只是添加一点粘合代码,将单个爬虫阶段组装成管道,并提供一个简单的 API 来运行完整的爬虫遍历。所有这些粘合逻辑都被封装在Crawler
类型中,其定义和构造函数细节如下:
type Crawler struct {
p *pipeline.Pipeline
}
// NewCrawler returns a new crawler instance.
func NewCrawler(cfg Config) *Crawler {
return &Crawler{
p: assembleCrawlerPipeline(cfg),
}
}
Config
类型包含创建新的爬虫管道所需的所有配置选项:
// Config encapsulates the configuration options for creating a new Crawler.
type Config struct {
PrivateNetworkDetector PrivateNetworkDetector
URLGetter URLGetter
Graph Graph
Indexer Indexer
FetchWorkers int
}
爬虫构造函数的调用者应提供以下配置选项:
-
实现了
PrivateNetworkDetector
接口的对象,该接口将被链接获取器和链接提取组件用于过滤掉解析为私有网络地址的链接 -
实现了
URLGetter
接口的对象(例如,http.DefaultClient
),链接获取器将使用它来执行 HTTP GET 请求 -
实现了
Graph
接口的对象(例如,前一章中的任何链接图实现),该接口将被图更新组件用于将发现的链接上载到链接图中 -
实现了
Indexer
接口的对象(例如,前一章中的任何索引器实现),文本索引器组件将使用它来保持搜索索引同步 -
执行管道链接获取阶段的线程池大小
构造函数代码调用assembleCrawlerPipeline
辅助函数,该函数负责使用适当的配置选项实例化管道的每个阶段,并调用pipeline.New
来创建一个新的管道实例:
func assembleCrawlerPipeline(cfg Config) *pipeline.Pipeline {
return pipeline.New(
pipeline.FixedWorkerPool(
newLinkFetcher(cfg.URLGetter, cfg.PrivateNetworkDetector),
cfg.FetchWorkers,
),
pipeline.FIFO(newLinkExtractor(cfg.PrivateNetworkDetector)),
pipeline.FIFO(newTextExtractor()),
pipeline.Broadcast(
newGraphUpdater(cfg.Graph),
newTextIndexer(cfg.Indexer),
),
)
}
如图 2所示,爬虫管道的第一阶段使用固定大小的线程池来执行链接获取器处理器。该阶段的输出被管道到两个依次连接的 FIFO 阶段,这些阶段执行链接提取器和文本提取器处理器。最后,这些 FIFO 阶段的输出并行复制并广播到图更新器和文本索引器组件。
最后一块拼图是Crawl
方法的实现,它构成了从其他包使用爬虫的 API:
func (c *Crawler) Crawl(ctx context.Context, linkIt graph.LinkIterator) (int, error) {
sink := new(countingSink)
err := c.p.Process(ctx, &linkSource{linkIt: linkIt}, sink)
return sink.getCount(), err
}
该方法接受一个上下文值,调用者可以在任何时候取消它以强制爬虫管道终止,以及一个迭代器,它提供要由管道爬取的链接集合。它返回到达管道汇点的总链接数。
顺便提一下,Crawl
在每次调用时都会创建新的源和汇实例,加上观察到的爬虫的各个阶段都没有维护任何内部状态,这使得Crawl
可以安全地并发调用!
摘要
在本章中,我们从头开始构建了我们自己的通用、可扩展的管道包,仅使用基本的 Go 原语。我们分析了并实现了不同的策略(FIFO、固定/动态工作池和广播)来处理管道的各个阶段的数据。在章节的最后部分,我们将到目前为止所学的一切应用于实现 Links 'R' Us 项目的多阶段爬虫管道。
总结来说,管道为将复杂的数据处理任务分解成更小、更容易测试的步骤提供了一种优雅的解决方案,这些步骤可以并行执行,以更好地利用你可用的计算资源。在下一章中,我们将探讨一种不同的数据处理范式,该范式以图的形式组织数据。
问题
-
为什么将
interface{}
值用作函数和方法的参数被认为是一种反模式? -
你正在尝试设计和构建一个需要大量计算能力(例如,人脸识别、音频转录或类似)的复杂数据处理管道。然而,当你尝试在本地机器上运行它时,你意识到某些阶段的资源需求超过了当前本地可用的资源。描述你如何修改当前的管道设置,以便你仍然可以在你的机器上运行管道,但安排部分管道在由你控制的远程服务器上执行。
-
描述你将如何应用装饰器模式来记录附加到管道的处理器函数返回的错误。
-
同步和异步管道实现之间的关键区别是什么?
-
解释死信队列是如何工作的,以及为什么你可能在应用程序中使用它。
-
固定大小工作池和动态池之间有什么区别?
-
描述你将如何修改'Links 'R' Us'爬虫的有效负载,以便你可以跟踪每个有效负载在管道内花费的时间。
进一步阅读
-
Berners-Lee, T. ; Fielding, R. ; Masinter, L.,RFC 3986,统一资源标识符(URI):通用语法。
-
bluemonday:一个快速的 golang HTML 净化器(受 OWASP Java HTML 净化器启发),用于清除用户生成内容中的 XSS:
github.com/microcosm-cc/bluemonday
-
Go pprof 包的文档:
golang.org/pkg/runtime/pprof
-
同步包中 Pool 类型的文档:
golang.org/pkg/sync/#Pool
-
gomock:Go 编程语言的模拟框架:
github.com/golang/mock
-
go-multierror:一个 Go(golang)包,用于将错误列表表示为单个错误:
github.com/hashicorp/go-multierror
-
Moskowitz, Robert ; Karrenberg, Daniel ; Rekhter, Yakov ; Lear, Eliot ; Groot, Geert Jan de: 私有互联网的地址分配。
-
Go 博客:Go 程序性能分析:
blog.golang.org/profiling-go-programs
第八章:基于图的数据处理
“大数据是今天所有重大趋势的基础,从社交到移动,再到云和游戏。”
- 克里斯·林奇
询问任何非常成功的企业,它们都会毫不犹豫地同意数据是一种宝贵的商品。公司使用数据不仅是为了做出影响日常运营的明智的短期决策,而且也是为了塑造其长期战略的指南。实际上,在某些行业(如广告)中,数据就是产品!
现在,随着廉价存储解决方案的出现,与过去几年相比,数据的收集量呈指数级增长。此外,存储需求增长的速度预计将继续遵循指数曲线,甚至在未来很长一段时间内也是如此。
尽管有许多处理结构化数据(如支持 map-reduce 操作的系统)的解决方案,但当要处理的数据以图的形式组织时,它们就不够用了。针对大规模图运行专用算法是物流领域或运营社交网络的公司中相当常见的用例。
在本章中,我们将重点关注那些大规模处理图的系统。更具体地说,以下主题将被涵盖:
-
理解批量同步并行(BSP)模型以在多个节点间分配计算
-
将 BSP 模型原则应用于创建我们自己的 Go 语言图处理系统
-
使用图系统作为解决基于图的问题(如最短路径和图着色)的平台
-
为 Links 'R' Us 项目实现 PageRank 算法的迭代版本
技术要求
本章中将要讨论的主题的完整代码已发布在本书的 GitHub 仓库的Chapter08
文件夹下。
您可以通过访问以下 URL 来访问本书的 GitHub 仓库:github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
。
为了让您尽快开始,每个示例项目都包含一个 Makefile,它定义了以下一组目标:
Makefile 目标 | 描述 |
---|---|
deps |
安装所需的任何依赖项 |
test |
运行所有测试并报告覆盖率 |
lint |
检查 lint 错误 |
与本书中的所有其他章节一样,您需要一个相当新的 Go 语言版本,您可以在golang.org/dl
.下载。
探索批量同步并行模型
我们如何高效地在大型图上运行图算法?为了回答这个问题,我们需要明确我们所说的大型是什么意思。一个包含 100 万个节点的图被认为是大型吗?10 百万、100 百万,甚至 10 亿个节点呢?我们应该问自己的真正问题是图是否实际上可以适应内存。如果答案是肯定的,那么我们只需购买(或从云服务提供商那里租用)一台配备强大 CPU 的服务器,将安装的内存量最大化,并在单个节点上执行我们的图处理代码。
另一方面,当回答前面的问题是否定的...恭喜你;你现在可以宣称你在处理大数据!在这种情况下,传统的计算模型显然是不够的;我们需要开始探索专门为离核处理设计的替代应用。
BSP 模型是构建能够通过将计算分配到处理节点集群来处理大量数据集的系统中最受欢迎的模型之一。它在 1990 年由 Leslie Valiant 提出,作为一种新颖而优雅的方法,用于将并行硬件和软件连接起来。
BSP 模型的核心是BSP 计算机。如图所示,BSP 计算机是一个由一组,可能异构的,通过计算机网络相互连接的处理机构成的抽象计算机模型:
图 1:构成 BSP 计算机模型的组件
BSP 模型本身并不特别关注网络实现细节。实际上,网络被视为一个黑盒;只要网络提供在处理器之间路由消息的机制,模型就可以支持任何类型的网络。
处理器不仅可以访问它们自己的本地内存,还可以使用网络链路与其他处理器交换数据。为此,BSP 计算机实际上是一个分布式内存计算机,可以并行执行计算。然而,这种功能也有一个代价!虽然访问本地内存很快,但访问远程处理器的内存要慢得多,因为它涉及到通过网络链路交换消息。因此,BSP 计算机也可以被描述为一种非统一内存访问(NUMA)架构。
那么,我们可以在 BSP 计算机上运行哪些类型的程序呢?可以表示为迭代步骤序列的算法或数据处理操作通常非常适合 BSP 模型。BSP 模型使用术语超级步骤来指代用户定义程序的单次迭代执行。
BSP 模型与其他并发编程模型的一个区别是,BSP 通过使用称为 单程序多数据(SPMD)的技术来实现并行性。对为 BSP 计算机编写程序感兴趣的软件工程师可以像为单核机器编写程序一样进行操作。程序简单地接收一组数据作为输入,对其应用处理函数,并输出一些结果。换句话说,软件工程师对个别处理器及其连接的网络的存在一无所知。
在开始执行用户的程序之前,BSP 计算机透明地将程序上传到每个处理器,将需要处理的数据分割成一组分区,并将每个分区分配给一个可用的处理器。该模型采用了一种相当巧妙的策略来减少计算延迟:它将每个超步骤分解为两个阶段或子步骤:一个 计算 步骤和一个 通信 步骤。在计算步骤中,每个处理器使用分配给处理器的数据作为输入,执行用户程序的单一迭代。一旦 所有 处理器完成它们的个别计算,它们可以通过网络进行通信,并根据用例比较、交换或汇总它们个别计算的结果。
由于每个处理器可以并行且独立于其他处理器执行计算工作,BSP 模型利用 阻塞屏障 来同步处理器。以下图表总结了 BSP 计算机模型执行程序的方式,即作为一系列通过写屏障相互隔离的超步骤:
图 2:BSP 计算机模型通过写屏障将程序作为一系列相互隔离的超步骤执行
在 Go 术语中,阻塞屏障相当于 sync.WaitGroup
;BSP 计算机等待所有处理器达到屏障后,才分配给它们下一块工作。
在过去几年中,对 BSP 等模型的需求激增。这很大程度上归功于谷歌,它是(不包括由政府资助的三字母机构)全球大数据处理领域当之无愧的领导者。谷歌工程师将 BSP 模型的几个概念融入了 Pregel,这是一个用于离核图处理的内部解决方案。2010 年,谷歌发表了一篇论文^([7]),详细介绍了 Pregel 的设计决策和架构。这篇论文为创建开源替代品铺平了道路,例如斯坦福的 GPS^([4]) 和 Apache Giraph^([1])。后者目前被 Facebook 用于分析由网络用户及其连接形成的社会图。
在 Go 中构建图处理系统
没有比从头开始构建我们自己的可扩展 Pregel-like 图处理系统更好的方法来深入理解 BSP 模型原理。
下面是我们将要构建的系统的一些设计要求:
-
图将被表示为一组顶点和有向边。每个顶点将被分配一个唯一的 ID。此外,顶点和边可以可选地存储用户定义的值。
-
在每个超级步骤中,系统都会为图中的每个顶点执行一个用户定义的计算函数。
-
计算函数允许检查和修改它们被调用时的顶点的内部状态。它们还可以遍历输出边列表,与其他顶点交换消息。
-
在超级步骤期间产生的任何输出消息都将被缓冲,并在下一个超级步骤中交付给预期的接收者。
-
系统必须能够支持单节点和多节点(分布式)图拓扑。在多节点拓扑中,每个节点负责管理图顶点及其输出边的一个子集。在多节点配置下操作时,系统应提供一种机制,用于在节点之间中继顶点消息(例如,通过网络链接)。
在接下来的章节中,我们将更详细地分析这些要求,并阐述如何在 Go 语言中实现它们。您可以从本书 GitHub 仓库的Chapter08/bspgraph
文件夹中找到图处理系统的完整文档源代码和测试套件。
队列和交付消息
BSP 模型的一个核心思想是图组件通过交换消息相互通信。每个图顶点可能接收多个消息的事实,要求引入某种抽象来存储或排队传入的消息,直到它们准备好被预期的接收者处理。
在接下来的三个部分中,我们将通过定义建模消息和队列所需的接口来启动我们的设计讨论。然后,我们将尝试实现一个简单、线程安全的内存队列。
消息接口
从逻辑上讲,交换顶点之间消息的内容在很大程度上取决于我们试图执行的应用或图算法。因此,为了避免传递简单的interface{}
值,我们需要想出一个合理的接口来以通用方式描述消息。位于Chapter08/bspgraph/message
包中的Message
接口正是为此目的而设计的:
type Message interface {
// Type returns the type of this Message.
Type() string
}
到目前为止,你可能对在这个接口上有一个Type
方法的有效性表示怀疑。这真的会比简单地使用interface{}
更好吗?
如果你还记得我们关于 BSP 计算机模型的讨论,处理器通过网络链路相互通信。在消息可以通过网络传输之前,发送者必须将其序列化为字节流。在接收端,字节流被反序列化为消息并交付给预期的接收者。
Type
方法对于支持发送者和接收者可以在同一通道上交换不同类型消息的场景非常有用(例如,TCP 套接字)。在序列化时,发送者查询消息的类型,并将此信息作为附加元数据附加到序列化负载中。接收者然后可以解码元数据并将负载的字节流反序列化回适当的消息类型。
队列和消息迭代器
队列作为存储传入消息并使其可供计算函数消费的缓冲区。图处理系统的用户可以使用内置的内存队列(参见下一节)或注入符合Queue
接口的应用特定队列实现,只要它遵循以下定义:
type Queue interface {
// Cleanly shutdown the queue.
Close() error
// Enqueue inserts a message to the end of the queue.
Enqueue(msg Message) error
// PendingMessages returns true if the queue contains any messages.
PendingMessages() bool
// Flush drops all pending messages from the queue.
DiscardMessages() error
// Messages returns an iterator for accessing the queued messages.
Messages() Iterator
}
Queue
接口上的方法对于任何类型的队列系统来说都很标准。调用PendingMessages
可以揭示队列当前是否为空,而调用DiscardMessages
可以用来清除任何存储的消息。Enqueue
方法可以用来将新的Message
追加到队列中,而Messages
方法返回一个用于访问已入队消息列表的Iterator
。由于迭代器实现通常与底层队列系统耦合,因此Iterator
也被定义为接口:
type Iterator interface {
// Next advances the iterator so that the next message can be retrieved
// via a call to Message(). If no more messages are available or an
// error occurs, Next() returns false.
Next() bool
// Message returns the message currently pointed to by the iterator.
Message() Message
// Error returns the last error that the iterator encountered.
Error() error
}
此接口遵循与之前章节中你应该熟悉的相同迭代器模式。调用Next
会移动迭代器并返回一个布尔值以指示是否有更多消息可用。在成功调用Next
之后,可以通过调用Message
来检索当前消息。
实现内存、线程安全的队列
对于大多数应用来说,使用如本文中展示的内存队列实现应该足够。实现对其他类型队列系统的支持(例如,Kafka、nats-streaming,甚至是普通文件)则留作你的练习。
让我们先定义inMemoryQueue
类型及其构造函数:
type inMemoryQueue struct {
mu sync.Mutex
msgs []Message
latchedMsg Message
}
func NewInMemoryQueue() Queue {
return new(inMemoryQueue)
}
如你所见,内存队列不过是一系列Message
实例的切片——一个用于存储当前由迭代器指向的消息的槽位,以及一个sync.Mutex
用于序列化对消息列表的访问。
接下来,我们将查看Enqueue
和PendingMessages
的实现:
func (q *inMemoryQueue) Enqueue(msg Message) error {
q.mu.Lock()
q.msgs = append(q.msgs, msg)
q.mu.Unlock()
return nil
}
func (q *inMemoryQueue) PendingMessages() bool {
q.mu.Lock()
pending := len(q.msgs) != 0
q.mu.Unlock()
return pending
}
要入队一个新消息,我们获取锁并将消息追加到列表中。以类似的方式,检查待处理消息可以通过获取锁并检查消息列表是否为空来实现。
我们需要实现最后一组函数,以便类型满足Queue
接口,具体如下:
func (q *inMemoryQueue) DiscardMessages() error {
q.mu.Lock()
q.msgs = q.msgs[:0]
q.mu.Unlock()
return nil
}
func (*inMemoryQueue) Close() error { return nil }
func (q *inMemoryQueue) Messages() Iterator { return q }
如前一个代码块所示,DiscardMessages
方法的实现使用了一个巧妙的技巧:通过切片操作清空消息列表,同时保留已分配的切片容量,但将其长度重置为零。这使我们能够减少需要执行的内存分配次数,从而减轻 Go 垃圾收集器的压力。
此外,Messages
方法体本身也非常有趣,因为返回值暗示inMemoryQueue
类型也必须实现Iterator
接口!以下代码显示了满足Iterator
接口的相关方法的实现:
func (q *inMemoryQueue) Next() bool {
q.mu.Lock()
qLen := len(q.msgs)
if qLen == 0 {
q.mu.Unlock()
return false
}
q.latchedMsg = q.msgs[qLen-1] // Dequeue message from the tail of the queue.
q.msgs = q.msgs[:qLen-1]
q.mu.Unlock()
return true
}
func (q *inMemoryQueue) Message() Message {
q.mu.Lock()
msg := q.latchedMsg
q.mu.Unlock()
return msg
}
尽管大多数队列实现使用 FIFO 语义,正如您可以通过Message
方法的实现轻松看出,内存队列遵循后进先出(LIFO)语义。这是故意的;如果我们从列表的头部出队(例如,q.msgs = q.msgs[1:]
),其容量将减少,我们将来将无法重用已分配的内存来追加新的消息。
由于我们构建的图系统不需要提供关于传入消息顺序的任何保证,因此我们可以直接使用内存队列实现,而不会出现任何问题。现在我们已经有了存储消息的解决方案,我们可以继续定义表示图顶点和边的必要结构。
建模图的顶点和边
正如我们在讨论图处理系统需求时提到的,我们需要提出一个描述构成图的顶点和边的模型。此外,我们还需要提供一个 API,我们可以使用它将新的顶点和边插入到图中。
定义顶点和边类型
Vertex
类型封装了Graph
实例中每个顶点的状态:
type Vertex struct {
id string
value interface{}
active bool
msgQueue [2]message.Queue
edges []*Edge
}
关于Vertex
类型定义的一个有趣的事实是,我们实际上需要维护两个message.Queue
实例。在执行超级步骤时,由计算函数调用产生的任何消息都必须被缓冲,以便它们可以在下一个超级步骤中交付给预期的接收者。为此,我们的实现将采用双缓冲方案。我们将使用一个队列来保存当前超级步骤的消息,另一个队列来缓冲下一个超级步骤的消息。在每个超级步骤结束时,我们将交换队列,使上一个超级步骤的输出队列成为下一个超级步骤的输入队列,反之亦然。为了避免在每个图的顶点上物理交换队列指针,我们将依靠模运算来根据当前超级步骤编号选择输入和输出队列:
-
索引为
super_step%2
的队列持有当前超级步骤应消费的消息 -
索引为
(super_step+1)%2
的队列缓冲了下一个超级步骤的消息
接下来,我们不应该允许 bspgraph
包的用户直接修改顶点的内部状态。因此,Vertex
字段没有在 bspgraph
包外部导出。相反,我们将定义以下一组辅助方法,以便我们可以访问和/或安全地操作顶点实例的状态:
func (v *Vertex) ID() string { return v.id }
func (v *Vertex) Value() interface{} { return v.value }
func (v *Vertex) SetValue(val interface{}) { v.value = val } func (v *Vertex) Freeze() { v.active = false } func (v *Vertex) Edges() []*Edge { return v.edges }
每个顶点都由一个基于字符串的 ID 唯一标识,可以通过调用 ID
方法来查询。此外,顶点可以选择存储一个用户定义的值,计算函数可以通过 Value
和 SetValue
方法读取或写入该值。
更重要的是,顶点可以处于以下两种状态之一:活动或非活动状态。所有顶点最初都被标记为 活动。为了节省计算资源,图框架将只对活动顶点调用计算函数。如果计算方法实现决定某个特定顶点已达到终止状态且不再需要进一步计算,它可以选择通过调用其 Freeze
方法显式地将顶点标记为非活动。然而,如果一个非活动顶点在超级步骤期间收到一条新消息,图框架将在下一个超级步骤自动将其标记为活动。
最后,Edges
方法返回一个与特定顶点出发的出边、有向边对应的 Edge
对象的切片。以下代码展示了 Edge
类型的定义及其辅助方法:
type Edge struct {
value interface{}
dstID string
}
func (e *Edge) DstID() string { return e.dstID }
func (e *Edge) Value() interface{} { return e.value }
func (e *Edge) SetValue(val interface{}) { e.value = val }
与 Vertex
类型类似,边也可以存储一个可选的用户定义值,可以通过 Value
和 SetValue
方法读取/写入。每个边都有一个目标顶点,其 ID 可以通过调用 DstID
方法获得。正如我们将在 发送和接收消息 部分中看到的那样,顶点 ID 是我们为了向特定顶点发送消息而需要了解的唯一信息。
将顶点和边插入到图中
Graph
类型通过一个键为顶点 ID、值为 Vertex
实例的映射来跟踪构成图的所有顶点。除了顶点映射允许我们快速通过 ID 查找顶点——这对于传递传入消息非常重要——它还提供了一个高效的机制(与使用切片相比),如果我们希望允许用户在超级步骤之间修改图拓扑,我们可以通过它来删除顶点。
新顶点可以通过 AddVertex
方法插入到图中。它期望两个参数:
-
一个唯一的顶点 ID
-
一个初始值(也可能为
nil
):
func (g *Graph) AddVertex(id string, initValue interface{}) {
v := g.vertices[id]
if v == nil {
v = &Vertex{
id: id,
msgQueue: [2]message.Queue{
g.queueFactory(),
g.queueFactory(),
},
active: true,
}
g.vertices[id] = v
}
v.SetValue(initValue)
}
如果已经存在具有相同 ID 的顶点,我们只需覆盖其存储的初始值。否则,必须分配一个新的Vertex
实例。代码填充其 ID 字段,将顶点状态设置为活动状态,并调用配置的(在图构建时)队列工厂来实例化我们需要的两个队列,以便存储当前和下一个超级步骤的传入消息。最后,将新的顶点实例插入到映射中。
同样,AddEdge
方法在两个顶点之间创建一个新的有向边:
func (g *Graph) AddEdge(srcID, dstID string, initValue interface{}) error {
srcVert := g.vertices[srcID]
if srcVert == nil {
return xerrors.Errorf("create edge from %q to %q: %w", srcID, dstID, ErrUnknownEdgeSource)
}
srcVert.edges = append(srcVert.edges, &Edge{
dstID: dstID,
value: initValue,
})
return nil
}
如我们在定义顶点和边类型部分中提到的,边由它们起源的顶点拥有。因此,AddEdge
实现必须检查srcID
是否可以解析为现有顶点。如果找不到源顶点,则向调用者返回错误。否则,创建一个新的边并将其附加到源顶点的边列表中。
注意,虽然我们期望边的源顶点在本地是已知的,但对于目标顶点不能做出相同的假设。例如,如果图分布在两个节点上,源顶点可能由第一个节点管理,而目标顶点可能由第二个节点管理。
通过数据聚合共享全局图状态
聚合器是实现依赖于顶点之间共享全局状态的几个基于图的算法的关键组件。它们是并发安全的原语,将聚合运算符应用于一组值,并在下一个超级步骤中将结果提供给所有顶点。
只要运算符是交换的和结合的,就可以使用任何类型的运算符来创建聚合器。聚合器通常用于实现计数器、累加器或跟踪某些数量的最小值和/或最大值。
在接下来的章节中,我们将做以下事情:
-
定义一个通用的聚合器接口
-
通过注册和按名称查找
Aggregator
实例的辅助方法增强我们的Graph
类型 -
构建一个累加
float64
值的示例聚合器
定义聚合器接口
Aggregator
接口描述了 Go 类型必须实现的方法集,以便它们可以用于我们的图处理框架进行数据聚合:
type Aggregator interface {
// Type returns the type of this aggregator.
Type() string
// Set the aggregator to the specified value.
Set(val interface{})
// Get the current aggregator value.
Get() interface{}
// Aggregate updates the aggregator's value based on the provided
// value.
Aggregate(val interface{})
// Delta returns the change in the aggregator's value since the last
// call to Delta.
Delta() interface{}
}
我的一个小烦恼是,前面接口定义中的方法使用了interface{}
值。不幸的是,这是少数几个我们实际上无法避免使用interface{}
的情况之一,因为可以聚合的值的类型是特定于实现的。
每当我们想要将聚合操作应用于新值时,可以通过调用 Aggregate
方法来实现。此外,可以通过调用 Get
方法检索当前值。另一方面,如果我们想要将聚合器设置为 特定 的值(例如,将计数器重置为零),我们可以调用 Set
方法。Type
方法提供了聚合器类型的标识符,可用于序列化目的(例如,如果我们想要对图的状态进行快照)。
Delta
方法返回自上次调用 Delta
或 Set
以来聚合器值的 变化。此方法旨在在分布式图计算场景中使用(参见第十二章,构建分布式图处理系统),将单个局部聚合器的值减少到单个全局聚合值。
要了解 Delta
方法的使用方法,让我们设想一个场景,其中我们部署了三个节点:一个主节点和两个工作节点。我们的目标是创建一个分布式计数器,其值在执行新的超级步骤之前与所有节点同步。为此,每个节点(包括主节点)定义了一个 本地 聚合器实例,该实例实现了一个简单的计数器。在执行超级步骤时,计算函数只能访问它们正在执行的工作节点的本地计数器。主节点没有分配任何顶点。相反,它负责收集每个工作节点的部分 delta,将其聚合到自己的计数器中,并将新的总数 广播 回到工作节点。然后,工作节点使用 Set
方法更新它们的本地计数器到新的总数。
注册和查找聚合器
为了便于基于名称的聚合器查找,Graph
实例将聚合器存储在一个映射中,其中聚合器名称用作键。可以通过 RegisterAggregator
方法将新的聚合器实例链接到 Graph
实例:
func (g *Graph) RegisterAggregator(name string, aggr Aggregator) {
g.aggregators[name] = aggr
}
需要访问特定聚合器的计算函数可以调用 Aggregator
方法,通过名称查找已注册的聚合器实例:
func (g *Graph) Aggregator(name string) Aggregator {
return g.aggregators[name]
}
为了使客户端更容易创建图状态的快照,我们还将提供辅助的 Aggregators
方法,该方法仅返回包含已注册聚合器实例完整集合的映射的副本。
实现一个无锁的 float64 值累加器
在 Chapter08/bspgraph/aggregator
包中,您可以找到两个并发安全的累加器实现,这些实现旨在与 int64
和 float64
值一起工作,也可以用作分布式计数器。
而不是使用互斥锁来保证并发访问,这两个累加器都是通过比较和交换指令实现的。基于 int64 的版本相当直接,并且可以很容易地利用 sync/atomic
包提供的函数来实现。我们将在这里剖析的基于 float64 的版本更具挑战性(并且更有趣!),因为 sync/atomic
包没有提供处理浮点值的支持。为了克服这一限制,我们将导入 unsafe
包,并使用一些 创造性的值转换技巧 来创建我们自己的原子函数集,这些函数可以与 float64
值一起工作!
让我们先定义 Float64Accumulator
类型:
type Float64Accumulator struct {
prevSum float64
curSum float64
}
Float64Accumulator
类型跟踪两个 float64
值:第一个存储当前总和,而后者跟踪通过调用 Delta
方法报告的最后一个值。
现在,让我们定义满足 Accumulator
接口所需的一组方法。我们将定义的第一个方法是 Get
:
func (a *Float64Accumulator) Get() interface{} {
return loadFloat64(&a.curSum)
}
func loadFloat64(v *float64) float64 {
return math.Float64frombits(
atomic.LoadUint64((*uint64)(unsafe.Pointer(v))),
)
}
在这里,loadFloat64
辅助函数是所有魔法发生的地方。我们将使用的一个技巧是基于观察,即 float64
值在内存中占据的空间(8 字节)与 uint64
值相同。借助 unsafe
包,我们可以将读取 float64
值的指针转换为 *uint64
值,并使用 atomic.LoadUint64
函数以原始 uint64
值的形式原子地读取它。然后,我们可以使用内置 math
包中的方便的 Float64frombits
函数将原始 uint64
值 解释 为 float64
。
接下来,让我们检查 Aggregate
方法的实现:
func (a *Float64Accumulator) Aggregate(v interface{}) {
for v64 := v.(float64); ; {
oldV := loadFloat64(&a.curSum)
newV := oldV + v64
if atomic.CompareAndSwapUint64(
(*uint64)(unsafe.Pointer(&a.curSum)),
math.Float64bits(oldV),
math.Float64bits(newV),
) {
return
}
}
}
正如你在前面的代码片段中看到的,我们进入一个无限 for
循环,在其中获取当前的聚合器值,添加传递给方法的 float64
值,并不断尝试执行比较和交换操作,直到成功。像之前一样,我们利用观察到的 float64
值在内存中占据与 uint64
相同的空间,并使用 atomic.CompareAndSwapUint64
来执行交换。这个函数期望 uint64
值作为参数,所以这次,我们利用 math.Float64bits
函数将我们正在处理的 float64
值转换为用于比较和交换操作的原始 uint64
值。
我们可以应用完全相同的方法来实现 Delta
方法,如下所示:
func (a *Float64Accumulator) Delta() interface{} {
for {
curSum := loadFloat64(&a.curSum)
prevSum := loadFloat64(&a.prevSum)
if atomic.CompareAndSwapUint64(
(*uint64)(unsafe.Pointer(&a.prevSum)),
math.Float64bits(prevSum),
math.Float64bits(curSum),
) {
return curSum - prevSum
}
}
}
再次进入一个无限 for
循环,在其中捕获当前和前一个值,然后使用比较和交换操作将 curSum
复制到 prevSum
。一旦交换成功,我们就从捕获的两个值中减去,并将结果返回给调用者。
为了完成实现我们的累加器的方法集,我们还需要提供一个 Set
方法的实现,正如你在下面的代码列表中看到的,这稍微复杂一些:
func (a *Float64Accumulator) Set(v interface{}) {
for v64 := v.(float64); ; {
oldCur := loadFloat64(&a.curSum)
oldPrev := loadFloat64(&a.prevSum)
swappedCur := atomic.CompareAndSwapUint64(
(*uint64)(unsafe.Pointer(&a.curSum)),
math.Float64bits(oldCur),
math.Float64bits(v64),
)
swappedPrev := atomic.CompareAndSwapUint64(
(*uint64)(unsafe.Pointer(&a.prevSum)),
math.Float64bits(oldPrev),
math.Float64bits(v64),
)
if swappedCur && swappedPrev {
return
}
}
}
额外的复杂性源于我们需要执行两个连续的比较和交换操作,这两个操作都必须成功,我们才能退出 for
循环。
发送和接收消息
正如我们之前提到的,顶点通过交换消息相互通信。向特定顶点的所有直接邻居发送相同的消息是多个图算法中经常出现的模式。让我们定义一个方便的方法来处理这种相当常见的用例:
func (g *Graph) BroadcastToNeighbors(v *Vertex, msg message.Message) error {
for _, e := range v.edges {
if err := g.SendMessage(e.dstID, msg); err != nil {
return err
}
}
return nil
}
BroadcastToNeighbors
简单地迭代特定顶点的边列表,并尝试使用 SendMessage
方法将消息发送给每个邻居。借助 SendMessage
,计算函数可以向图中的任何顶点发送消息,前提是它们知道其 ID(例如,通过使用八卦协议发现)。
让我们看看 SendMessage
的实现:
func (g *Graph) SendMessage(dstID string, msg message.Message) error {
dstVert := g.vertices[dstID]
if dstVert != nil {
queueIndex := (g.superstep + 1) % 2
return dstVert.msgQueue[queueIndex].Enqueue(msg)
}
if g.relayer != nil {
if err := g.relayer.Relay(dstID, msg); !xerrors.Is(err, ErrDestinationIsLocal) {
return err
}
}
return xerrors.Errorf("message cannot be delivered to %q: %w", dstID, ErrInvalidMessageDestination)
}
首先,我们需要在图顶点映射中查找目标顶点。如果查找返回有效的 Vertex
实例,那么我们可以将消息入队,以便在下一次超级步骤中将其传递给顶点。
当顶点查找失败时,事情变得更有趣… 查找失败可能有两个原因:
-
我们正在以分布式模式运行,并且顶点由一个 远程 图实例管理
-
顶点根本不存在
处理可能托管在远程的顶点,Graph
类型允许 bspgraph
包的用户注册一个助手,该助手可以在远程图实例之间传递消息。更具体地说,这些助手:
-
了解分布式图的拓扑结构(即集群中每个节点管理的顶点 ID 范围)
-
提供在集群节点之间穿梭消息的机制
用户定义的中继助手必须实现 Relayer
接口,并且可以通过 RegisterRelayer
方法与图实例注册:
type Relayer interface {
// Relay a message to a vertex that is not known locally. Calls
// to Relay must return ErrDestinationIsLocal if the provided dst value
// is not a valid remote destination.
Relay(dst string, msg message.Message) error
}
func (g *Graph) RegisterRelayer(relayer Relayer) {
g.relayer = relayer
}
为了使用户更容易提供合适的 Relayer
实现的函数或闭包,让我们也定义 RelayerFunc
适配器,它将具有适当签名的函数转换为 Relayer
:
type RelayerFunc func(string, message.Message) error
// Relay calls f(dst, msg).
func (f RelayerFunc) Relay(dst string, msg message.Message) error {
return f(dst, msg)
}
如果图无法定位目标顶点 ID,并且用户已注册 Relayer
实例,则 SendMessage
将调用其 Relay
方法并检查响应中的错误。如果我们得到除 ErrDestinationLocal
之外的错误,我们将错误原样返回给调用者。
如果中继助手检测到目标顶点 ID 实际上应由本地图实例管理,它将使用类型为 ErrDestinationIsLocal
的错误失败,以指示这一点。在这种情况下,我们假设顶点 ID 无效,并将类型为 ErrInvalidMessageDestination
的错误返回给调用者。
使用计算函数实现基于图的算法
为了使计算函数与 bspgraph
包一起使用,它必须遵循以下签名:
type ComputeFunc func(g *Graph, v *Vertex, msgIt message.Iterator) error
计算函数的第一个参数是指向 Graph
实例本身的指针。这允许计算函数使用图 API 来查询当前超级步骤编号、查找聚合器并向顶点发送消息。第二个参数是指向计算函数正在操作的 Vertex
实例的指针,而第三个和最后一个参数是 message.Iterator
,用于消费在 上一个 超级步骤期间发送给顶点的消息。
重要的是要注意,系统在假设计算函数可以安全地并发执行的前提下运行。系统提供的唯一运行时保证是,在每个超级步骤中,计算函数将针对每个顶点 恰好执行一次。因此,计算函数实现可以使用任何 Vertex
方法,而无需担心数据竞争和同步问题。鉴于顶点实际上 拥有 从它们起源的边,相同的数据访问原则也适用于通过在顶点上调用 Edges
方法获得的任何 Edge
实例。
通过并行执行计算函数实现垂直扩展
接下来,我们将关注用于执行计算函数的机制。一个相当简单的方法是使用 for
循环结构迭代图中的顶点,并以顺序方式对每个顶点调用计算函数。虽然这种方法无疑会按预期工作,但它会非常低效地使用我们可用的计算资源。按顺序运行计算函数只会使用单个 CPU 核心;考虑到大多数云提供商都可以轻松获得多达 64 核心的机器,这将是相当浪费的。
一个更好的替代方案是将计算函数的执行扇出到工作者池中。这样,计算函数可以并行运行并充分利用所有可用的 CPU 核心。图构造器通过调用 startWorkers
方法初始化工作者池,其实现如下:
func (g *Graph) startWorkers(numWorkers int) {
g.vertexCh = make(chan *Vertex)
g.errCh = make(chan error, 1)
g.stepCompletedCh = make(chan struct{})
g.wg.Add(numWorkers)
for i := 0; i < numWorkers; i++ {
go g.stepWorker()
}
}
startWorkers
的第一件事是创建一组用于与池中的工作者进行通信的通道。让我们简要地谈谈每个通道的作用:
-
vertexCh
是一个由工作者轮询的通道,用于获取下一个要处理的顶点。 -
errCh
是一个缓冲通道,工作者在此通道中发布在调用计算函数时可能发生的任何错误。图处理系统实现将所有错误视为 致命的。因此,我们只需要有足够的空间来存储单个错误值。当工作者检测到错误时,它将尝试将其入队到errCh
;如果通道已满,则已写入另一个致命错误,因此新的错误可以安全地忽略。 -
由于我们使用工作池并行执行计算函数,我们需要引入某种同步机制来检测所有顶点是否已被处理。
stepCompletedCh
通道允许工人在最后一个入队的顶点被处理时发出信号。
startWorkers
方法的其余部分相当简单:我们为每个工人启动一个 goroutine,并使用sync.WaitGroup
来跟踪它们的完成状态。
如以下代码所示,step
方法负责执行单个超级步。如果超级步在没有错误的情况下完成,step
将返回超级步期间活跃的顶点数量:
func (g *Graph) step() (activeInStep int, err error) {
g.activeInStep, g.pendingInStep = 0, int64(len(g.vertices))
if g.pendingInStep == 0 {
return 0, nil // no work required
}
for _, v := range g.vertices {
g.vertexCh <- v
}
<-g.stepCompletedCh
select {
case err = <-g.errCh: // dequeued
default: // no error available
}
return int(g.activeInStep), err
}
上述代码块应该是自解释的。首先,我们将activeInStep
计数器重置为零,并将pendingInStep
计数器加载为图中顶点的数量。然后,迭代持有图Vertex
实例集合的映射,并将每个顶点值写入vertexCh
,以便它可以被空闲的工人拾取和处理。
一旦所有顶点都已入队,step
将通过在stepCompletedCh
上执行阻塞读取来等待所有顶点被工作池处理。在返回之前,代码检查是否将错误入队到错误通道。如果发生这种情况,错误将被出队并返回给调用者。
现在,让我们看看stepWorker
方法的实现:
for v := range g.vertexCh {
buffer := g.superstep % 2
if v.active || v.msgQueue[buffer].PendingMessages() {
_ = atomic.AddInt64(&g.activeInStep, 1)
v.active = true
if err := g.computeFn(g, v, v.msgQueue[buffer].Messages()); err != nil {
tryEmitError(g.errCh, xerrors.Errorf("running compute function for vertex %q failed: %w", v.ID(), err))
} else if err := v.msgQueue[buffer].DiscardMessages(); err != nil {
tryEmitError(g.errCh, xerrors.Errorf("discarding unprocessed messages for vertex %q failed: %w", v.ID(), err))
}
}
if atomic.AddInt64(&g.pendingInStep, -1) == 0 {
g.stepCompletedCh <- struct{}{}
}
}
g.wg.Done()
通道的range
语句确保我们的工人将继续执行,直到vertexCh
被关闭。在从vertexCh
中出队下一个顶点之后,工人使用模运算来选择包含当前超级步期间应由计算函数消费的消息的消息队列。
顶点被认为是活跃的,如果它们的active
标志被设置,或者如果它们的输入消息队列包含任何未投递的消息。对于任何被认为是活跃的顶点,我们将它的active
标志设置为true
,并原子性地增加activeInStep
计数器。正如我们将在以下章节中看到的,几个图算法使用超级步中活跃顶点的数量作为判断算法是否完成的条件。
接下来,我们调用注册的计算函数并检查是否有错误。如果发生错误,我们调用tryEmitError
辅助函数将错误入队到errCh
:
func tryEmitError(errCh chan<- error, err error) {
select {
case errCh <- err: // queued error
default: // channel already contains another error
}
}
在stepWorker
方法中我们需要做的最后一点家务是调用队列的DiscardMessages
方法,并刷新任何在前一步执行的计算函数中没有消费的消息。这确保队列始终为空,并准备好存储superstep+2
的传入消息。
无论顶点是否活跃,工作进程都会调用atomic.AddInt64
函数来递减pendingInStep
计数器,并检查它是否已达到零。当这种情况发生时,当前超级步骤的所有顶点都已处理完毕,工作进程将一个空的struct{}
值写入stepCompletedCh
,以解除step
方法的阻塞并允许其返回。
协调超级步骤的执行
在上一节中,我们对Graph
类型执行单个超级步骤所使用的机制进行了详细分析。然而,图算法通常涉及多个超级步骤。为了让bspgraph
包的用户能够使用我们正在构建的系统运行通用图算法,他们需要对一系列超级步骤的执行有更细粒度的控制。
在执行一个超级步骤之前,我们需要重置一个或多个聚合器的值。同样,在超级步骤完成后,我们可能对检查或修改聚合器的最终值感兴趣。此外,每个算法都定义了自己的终止条件。例如,一个算法可能在以下情况下终止:
-
在固定数量的步骤之后
-
当图中的所有顶点都变为不活跃状态时
-
当某些聚合器的值超过阈值时
为了满足这样的需求,我们需要引入一个高级 API,该 API 提供了一层协调层来管理一系列超级步骤的执行。这个 API 由Executor
类型提供,其定义如下:
type Executor struct {
g *Graph
cb ExecutorCallbacks
}
Executor
封装了一个Graph
实例,并使用一组用户定义的ExecutorCallbacks
进行参数化:
type ExecutorCallbacks struct {
PreStep func(ctx context.Context, g *Graph) error
PostStep func(ctx context.Context, g *Graph, activeInStep int) error
PostStepKeepRunning func(ctx context.Context, g *Graph, activeInStep int) (bool, error)
}
PreStep
和PostStep
回调在执行新超级步骤之前和之后被调用(如果已定义)。如果定义了PostStepKeepRunning
回调,它将在PostStep
之后自动由Executor
调用。回调负责检查算法的终止条件是否已满足,并在不需要执行更多超级步骤时返回false
。
NewExecutor
函数作为创建新Executor
实例的构造函数:
func NewExecutor(g *Graph, cb ExecutorCallbacks) *Executor {
patchEmptyCallbacks(&cb)
g.superstep = 0
return &Executor{
g: g,
cb: cb,
}
}
为了避免在尝试调用未定义的回调时发生空指针解引用错误,构造函数使用以下辅助函数来用模拟的空操作存根修补缺失的回调:
func patchEmptyCallbacks(cb *ExecutorCallbacks) {
if cb.PreStep == nil {
cb.PreStep = func(context.Context, *Graph) error { return nil }
}
if cb.PostStep == nil {
cb.PostStep = func(context.Context, *Graph, int) error { return nil }
}
if cb.PostStepKeepRunning == nil {
cb.PostStepKeepRunning = func(context.Context, *Graph, int) (bool, error) { return true, nil }
}
}
Executor
公开的高级接口包括以下一组方法:
func (ex *Executor) Graph() *Graph { return ex.g }
func (ex *Executor) Superstep() int { return ex.g.Superstep() }
func (ex *Executor) RunSteps(ctx context.Context, numSteps int) error {
return ex.run(ctx, numSteps)
}
func (ex *Executor) RunToCompletion(ctx context.Context) error {
return ex.run(ctx, -1)
}
Graph
方法提供了访问与Executor
关联的Graph
实例的权限,而Superstep
报告了已执行的最后一个超级步骤。RunSteps
和RunToCompletion
方法会重复执行超级步骤,直到满足以下条件之一:
-
上下文过期
-
发生错误
-
PostStepKeepRunning
回调返回 false -
执行了最大数量的
numSteps
(仅适用于RunSteps
)
这两个函数仅仅是run
方法的代理,其实现如下:
func (ex *Executor) run(ctx context.Context, maxSteps int) error {
var activeInStep int
var err error
var keepRunning bool
var cb = ex.cb
for ; maxSteps != 0; ex.g.superstep, maxSteps = ex.g.superstep+1, maxSteps-1 {
if err = ensureContextNotExpired(ctx); err != nil {
break
} else if err = cb.PreStep(ctx, ex.g); err != nil {
break
} else if activeInStep, err = ex.g.step(); err != nil {
break
} else if err = cb.PostStep(ctx, ex.g, activeInStep); err != nil {
break
} else if keepRunning, err = cb.PostStepKeepRunning(ctx, ex.g, activeInStep); !keepRunning || err != nil {
break
}
}
return err
}
run
方法进入一个for
循环,直到调用者提供的maxSteps
值等于零时才会停止运行。在每个迭代的末尾,maxSteps
递减,而图的超步骤计数器递增。然而,如果调用者在调用run
时指定了*负值*
作为maxSteps
,那么前面的循环在功能上等同于一个无限循环。
Executor
通过检查提供的上下文是否已取消开始新的迭代,然后继续调用PreStep
回调。然后,它通过调用包装的Graph
实例的step
方法执行一个新的超级步骤。随后,它调用PostStep
和PostStepKeepRunning
回调。如果任何回调或step
方法返回错误,则退出循环并将错误返回给调用者。
创建和管理Graph
实例
我们的图处理系统几乎完成了!为了最终实现,我们需要定义一个构造函数,该构造函数将创建新的Graph
实例,以及一些辅助方法来管理图的生命周期。
正如我们在前面的章节中看到的,有相当多的旋钮可以配置Graph
实例。将每个单独的配置选项作为参数传递给图的构造函数被认为是一种反模式——更不用说,每次我们想要添加一个新的配置选项时,我们都需要提高我们包的主版本号;更改构造函数的签名正是破坏性更改的定义!
一个更好的解决方案是定义一个类型化的配置对象,并将其作为参数传递给构造函数:
type GraphConfig struct {
QueueFactory message.QueueFactory
ComputeFn ComputeFunc
ComputeWorkers int
}
QueueFactory
将由AddVertex
方法使用,为每个被添加到图中的新顶点创建所需的消息队列实例。ComputeFn
设置用于指定每个超级步骤将执行的计算函数。最后,ComputeWorkers
选项允许包的最终用户微调工作池的大小,以便他们可以执行提供的计算函数。
在前面的配置选项列表中,只有ComputeFn
是必需的。让我们创建一个验证辅助程序来检查GraphConfig
对象,并用合理的默认值填充缺失的字段:
func (g *GraphConfig) validate() error {
var err error
if g.QueueFactory == nil {
g.QueueFactory = message.NewInMemoryQueue
}
if g.ComputeWorkers <= 0 {
g.ComputeWorkers = 1
}
if g.ComputeFn == nil {
err = multierror.Append(err, xerrors.New("compute function not specified"))
}
return err
}
如果调用者提供了一个 nil 的QueueFactory
实例,验证代码将使用内存实现作为合理的默认值。此外,如果指定的计算工作员数量无效,验证程序将回退到使用单个工作员。当然,这样做将有效地将每个超级步骤处理图顶点变成一个顺序操作。尽管如此,当最终用户希望调试行为不当的计算函数时,这可能会证明是一个有用的功能。
可以通过NewGraph
构造函数创建一个新的Graph
实例,如下所示:
func NewGraph(cfg GraphConfig) (*Graph, error) {
if err := cfg.validate(); err != nil {
return nil, xerrors.Errorf("graph config validation failed: %w", err)
}
g := &Graph{
computeFn: cfg.ComputeFn,
queueFactory: cfg.QueueFactory,
aggregators: make(map[string]Aggregator),
vertices: make(map[string]*Vertex),
}
g.startWorkers(cfg.ComputeWorkers)
return g, nil
}
构造函数需要做的第一件事是对提供的配置选项进行验证检查。有了有效的配置,代码将创建 Graph
实例,连接提供的配置选项,并分配所需的映射来存储图的 Vertex
和 Aggregator
实例。在将新的 Graph
实例返回给调用者之前,构造函数需要做的最后一件事是通过调用 startWorkers
来初始化工作池。
在创建一个新的 Graph
实例后,用户可以继续填充图顶点和边,注册聚合器,并使用 Executor
来编排特定图算法的执行。然而,在完成运行后,用户可能希望重用 Graph
实例以再次运行相同的算法,但这次使用不同的图布局。让我们为他们提供一个 Reset
方法来重置图的内部状态:
func (g *Graph) Reset() error {
g.superstep = 0
for _, v := range g.vertices {
for i := 0; i < 2; i++ {
if err := v.msgQueue[i].Close(); err != nil {
return xerrors.Errorf("closing message queue #%d for vertex %v: %w", i, v.ID(), err)
}
}
}
g.vertices = make(map[string]*Vertex)
g.aggregators = make(map[string]Aggregator)
return nil
}
如您所知,每个顶点都与两个消息队列实例相关联。message.Queue
接口定义了一个 Close
方法,我们必须调用它来释放底层队列实现使用的任何资源(例如,文件句柄、套接字等)。最后,为了完全重置图的内部状态,我们可以简单地将图的超级步计数器重置为零,并重新创建存储图顶点和聚合器实例的映射。
由于工作池由许多长时间运行的 go-routines 组成,我们必须提供一个机制来管理它们的生命周期,更重要的是,在我们完成图操作后关闭它们。我们将在 Graph
类型上定义的最后一个方法是 Close
:
func (g *Graph) Close() error {
close(g.vertexCh)
g.wg.Wait()
return g.Reset()
}
为了强制工作池干净地关闭并且所有工作进程都退出,Close
方法的实现关闭了 vertexCh
,这是每个工作进程轮询以获取传入的顶点处理作业的通道。然后代码在等待组上阻塞,直到所有工作进程都已退出。在返回之前,我们通过调用 Reset
方法进行尾调用,以确保每个顶点的队列实例被正确关闭。
这标志着允许我们使用 BSP 计算模型执行基于图的算法的框架的开发完成。接下来,我们将探讨如何利用这个框架来解决一些涉及图的现实世界问题!
解决有趣的图问题
在本节中,我们将检查三个非常流行的基于图的难题,这些难题非常适合我们刚刚构建的图处理系统。
在详细描述每个问题并列出其潜在的实际应用之后,我们将通过展示一个可以用来解决该问题的 顺序 算法来继续我们的讨论。随后,我们将提出相同算法的等效并行版本,并将其编码为一个可以与 bspgraph
包一起使用的计算函数。
在图中搜索最短路径
如果我们环顾四周,我们肯定会遇到许多相当具有挑战性的问题,这些问题本质上归结为在图中找到一个或一组路径以最小化特定的成本函数。路径查找有众多现实世界的应用案例,从构建高效的计算机网络到物流甚至游戏!
适合的成本函数的定义及其解释通常是特定于应用的。
例如,在地图服务的背景下,图边相关的成本可能反映了两点之间的距离或由于交通拥堵而从一个点到另一个点驾驶所需的时间。另一方面,如果我们谈论数据包路由应用,成本可能代表网络运营商为了使用与其他提供商的互连而需要支付的费用。
为了简单起见,本节将仅关注在图中寻找最短路径。不言而喻,我们将讨论的原则和算法可以应用于任何类型的成本函数,只要图中边的较低成本值表示通过图的一条更好的路径。
根据我们如何定义路径的起点和终点,我们可以将最短路径查询分为三个一般类别:
-
点对点:在一个点对点的搜索查询中,我们感兴趣的是找到连接两个点的最短路径。这类搜索的一个有趣例子是实时策略游戏,用户选择一个单位(路径起点)并随后点击地图上他们希望单位移动到的位置(路径终点)。游戏引擎在两点之间搜索最短的无障碍路径(通常通过实现如 A*算法等算法)并沿着路径导航单位。
-
点对多点:一个点对多点的搜索查询涉及找到连接单个图顶点到多个顶点的最短路径。例如,地图应用的用户可能希望获得一个列表,列出位于他们当前位置特定半径内的咖啡店,并按距离排序。如果我们反转这个查询,我们可以识别出更多有趣的用例。例如,叫车应用了解用户和司机的位置。多点对点查询允许应用调度最近的司机到用户的位置,并减少接单时间。这些类型的查询可以使用迪杰斯特拉算法(Dijkstra's algorithm)等最广为人知的图算法有效地回答。
-
多点到多点:第三个,也是最复杂的路径查找查询类别包括多点到多点查询,其中我们实际上是在寻找从每个顶点到图中所有其他顶点的最短路径。可以说,我们可以通过在图中每个顶点上运行 Dijkstra 算法来回答这类查询,但这将付出更长的总运行时间,尤其是如果图中包含大量顶点。在性能方面,使用动态规划算法(如 Floyd-Warshall ^([3]))来回答这类查询会是一个更好的替代方案。
让我们尝试使用我们在本章前半部分开发的图处理框架来实现 Dijkstra 算法。虽然 Dijkstra 算法的原始版本旨在找到两点之间的最短路径,但我们将要使用的是设计用来定位最短路径树的变体,即从一点到图中所有其他点的最短路径。
顺序 Dijkstra 算法
在我们将 Dijkstra 算法适配到我们的图处理系统之前,我们需要清楚地了解原始算法的工作原理。以下代码片段概述了顺序版本 Dijkstra 算法的伪代码实现:
function Dikstra(Graph):
for each vertex v in Graph:
min_cost_via[v] = infinity
prev[v] = nil
min_cost_via[src] = 0
Q = set of all vertices in graph
while Q is not empty:
u = entry from Q with smallest min_cost_via[]
remove u from Q
for each neighbor v from u:
cost_via_u = min_cost_via[u] + cost(u, v)
if cost_via_u < min_cost_via[v]:
min_cost_via[v] = cost_via_u
prev[v] = u
前一个实现维护了两个数组,每个数组的长度等于图中顶点的数量:
-
第一个数组
min_cost_via
跟踪到达图中第i个顶点的源顶点的最小成本(距离)。 -
prev
数组跟踪从源顶点到图中第i个顶点的最优路径中的前驱顶点。
在初始化时,我们将prev
数组中的所有条目设置为nil
。此外,min_cost_via
数组中的所有条目都初始化为一个大数,除了源顶点的条目,其条目设置为0
。如果我们用 Go 语言实现这个算法,并且路径成本是uint64
值,我们会将初始值设置为math.MaxUint64
。
Dijkstra 算法通过将所有图顶点放入一个名为Q
的集合中来进行初始化。然后,算法执行与图中顶点数量相等的迭代次数。在每次迭代中,我们从Q
中选择具有最低min_cost_via
值的顶点u
,并将其从集合中移除。
然后,算法检查所选顶点u
的每个邻居v
。如果可以通过u
构建从v
到源顶点的更低成本路径,则我们更新v
的min_cost_via
条目,并将u
作为到达v
的最优路径的前驱。
当Q
集合中的所有顶点都被处理完毕时,算法完成。可以通过从目标顶点开始,并跟随prev
数组条目直到我们到达源顶点,来重建从源顶点到图中任何其他顶点的最短路径。
更重要的是,我们可以稍微调整前面的算法以获得原始的变体,该变体可以回答点对点查询。我们所需做的只是在我们查询的目标顶点处理完毕后终止算法。那些熟悉或过去实现过 A算法的人肯定会注意到这两个算法之间有很多相似之处。实际上,Dijkstra 算法是没有使用距离启发式函数的 A算法的一个特例。
利用 Gossip 协议并行运行 Dijkstra 算法
Dijkstra 算法的实现相当直接,并且可以通过引入用于选择每次迭代下一个顶点的专用数据结构(例如,最小堆或斐波那契堆)来显著加快其运行时间。让我们看看我们如何利用我们构建的图处理系统来并行执行 Dijkstra 算法。
为了打破原始算法的顺序性,我们将交换下一个顶点选择步骤,并用一个Gossip 协议来替换它。每当顶点通过另一个顶点识别出一条更好的路径时,它将通过向它们发送PathCostMessage
来将此信息广播给所有邻居。然后,邻居将在下一个超级步骤中处理这些消息,更新自己的最小距离估计,并在找到更好的路径时将其广播给自己的邻居。这里的关键概念是在整个图中触发路径更新的波前,每个顶点可以并行处理。
我们需要做的第一件事是为以下内容定义类型:
-
顶点之间交换的消息
-
存储每个顶点的状态
考虑以下代码片段:
type PathCostMessage struct {
// The ID of the vertex this cost announcement originates from.
FromID string
// The cost of the path from this vertex to the source vertex via
// FromID.
Cost int
}
func (pc PathCostMessage) Type() string { return "cost" }
type pathState struct {
minDist int
prevInPath string
}
pathState
结构体编码了与算法的顺序版本中的min_cost_via
和prev
数组相同的信息。唯一的区别是每个顶点维护自己的pathState
实例,该实例作为顶点值存储。
接下来,让我们尝试为图定义一个计算函数。如您从前面的章节中回忆的那样,计算函数接收以下输入参数:指向图的指针、当前处理的顶点以及在上一个超级步骤期间发送到顶点的消息迭代器。在超级步骤 0 中,每个顶点使用最大可能距离值初始化其自身的内部状态:
if g.Superstep() == 0 {
v.SetValue(&pathState{ minDist: int(math.MaxInt64) })
}
然后,每个顶点处理其邻居的任何路径公告,并跟踪具有最低成本的路径公告:
minDist := int(math.MaxInt64)
if v.ID() == c.srcID { // min cost from source to source is always 0
minDist = 0
}
var via string
for msgIt.Next() {
m := msgIt.Message().(*PathCostMessage)
if m.Cost < minDist {
minDist = m.Cost
via = m.FromID
}
}
在处理完所有消息后,我们比较从所有公告中得出的最佳路径的成本与该顶点迄今为止看到的最佳路径的成本。如果顶点已经知道一条成本更低的更好路径,我们实际上不需要做任何事情。否则,我们将更新局部顶点状态以反映新的最佳路径,并向我们的每个邻居发送一条消息:
st := v.Value().(*pathState)
if minDist < st.minDist {
st.minDist = minDist
st.prevInPath = via
for _, e := range v.Edges() {
costMsg := &PathCostMessage{
FromID: v.ID(),
Cost: minDist + e.Value().(int),
}
if err := g.SendMessage(e.DstID(), costMsg); err != nil {
return err
}
}
}
v.Freeze()
每个输出的 PathCostMessage
包含通过当前顶点到达每个邻居的成本,并且通过将下一跳的成本(与输出边关联的值)添加到到达当前顶点的新最小成本来计算。
无论到顶点的最佳路径是否更新,我们都会在每一个顶点上调用 Freeze
方法,并将其标记为已处理。这意味着除非顶点从其邻居那里收到消息,否则顶点将在未来的超级步骤中不会被重新激活。最终,所有顶点都会找到到源顶点的最优路径,并停止向邻居广播成本更新。当这种情况发生时,所有顶点最终都会进入冻结状态,算法将终止。
我们可以肯定地说,与传统的顺序版本相比,这种方法需要更多的努力。然而,与算法的顺序版本相反,并行版本可以在可以潜在地分布在多个计算节点上的大规模图上高效运行。
本节中提到的最短路径计算器的完整源代码和测试可以在本书的 GitHub 仓库的 Chapter08/shortestpath
文件夹中找到。
图着色
我们将尝试使用我们的图处理系统解决的下一个基于图的问题是 图着色。图着色的思想是为图中的每个顶点分配一个颜色,使得相邻的顶点没有相同的颜色。以下图示了一个示例图,其顶点已经用最优(可能的最小)数量的颜色着色:
图 3:具有最优着色的图
图着色有众多实际应用:
-
它经常被编译器用于执行寄存器分配 ^([2])。
-
移动运营商使用图着色作为解决频率分配问题的方法 ^([9]),其目标是将有限的频率池中的频率分配给一组通信链路,以避免同一区域内的链路之间产生干扰。
-
许多流行的谜题游戏,如数独,可以建模为图,并使用图着色变体来解决,其中允许的颜色集是固定的(k-着色图着色)。
用于着色无向图的顺序贪婪算法
计算最优图着色已知是 NP-hard 的。因此,研究人员提出了贪婪算法,这些算法可以产生足够好但不一定是最优的解决方案。这里列出的顺序贪婪算法适用于无向图,并保证在图中所有顶点的最大出度(出边数)为 d 的情况下,颜色数量不超过 d+1:
function assignColors(Graph):
C holds the assigned color for each vertex
for each vertex u in Graph:
C[u] = 0
for each vertex u in Graph:
already_in_use is a map where keys indicate colors that are currently in use
for each neighbor v of u:
already_in_use[ C[v] ] = true
assigned_color = 1
while already_in_use[ color ] is true:
assigned_color = assigned_color + 1
C[u] = assigned_color
算法维护一个名为 C
的数组,该数组保存图中每个顶点分配的颜色。在初始化期间,我们将 C
数组的每个条目设置为值 0,以表示尚未为图中的任何顶点分配颜色。
对于图中的每个顶点 u
,算法迭代其邻居列表,并使用颜色值作为键将已分配给每个邻居的颜色插入到一个映射中。接下来,将 assigned_color
变量设置为最低可能的颜色值(在这种情况下,1),并咨询 already_in_use
映射以检查该颜色是否当前正在使用。如果确实如此,我们增加 assigned_color
变量并重复相同的步骤,直到最终找到一个未使用的颜色值。未使用的颜色值被分配给顶点 u
,然后过程继续,直到所有顶点都被着色。
关于前面算法的一个有趣的事实是,我们可以稍作调整以添加对处理预着色图的支持。在这种类型的图中,顶点的一个子集已经被分配了颜色值,目标是给剩余的顶点分配非冲突的颜色。我们只需要做以下事情:
-
在初始化期间将
C[u]
设置为已分配的颜色而不是 0 -
当迭代图顶点时,跳过那些已经被着色的顶点
利用并行性进行无向图着色
并行图着色算法基于观察,如果我们把图分成多个 独立的顶点集,我们可以在并行中着色它们而不会引入任何冲突。
独立集被定义为顶点的集合,其中没有任何两个顶点共享边。
为了开发从上一节中顺序贪婪图着色算法的并行化版本,我们将依赖于 Jones 和 Plassmann 提出的一种简单而有效的算法 ^([6])。在深入实现细节之前,让我们花几分钟时间解释该算法如何生成独立集,以及我们如何保证我们的计算函数在访问图时避免数据竞争。
在初始化时间,每个顶点被分配一个随机令牌。在每次超级步骤中,每个尚未着色的顶点将其自己的令牌值与每个 未着色 邻居的值进行比较。在极不可能的情况下,两个相邻顶点被分配了相同的随机令牌,我们可以使用顶点 ID 作为额外的比较谓词来打破平局。具有最高令牌值的顶点有权使用与顺序算法相同的步骤选择下一个颜色,而相邻顶点则保持空闲,等待他们的轮次。
使用令牌来强制着色顺序的概念保证了在每一个超级步骤中,我们只从每个独立集中着色一个顶点。同时,由于连接的顶点在它们可以挑选颜色之前需要等待它们的轮次,因此不会发生数据竞争。
就像我们在最短路径实现中所做的那样,我们首先定义一个类型来保存每个顶点的状态,以及一个描述相邻顶点之间交换的消息的类型:
type vertexState struct {
token int
color int
usedColors map[int]bool
}
type VertexStateMessage struct {
ID string
Token int
Color int
}
func (m *VertexStateMessage) Type() string { return "vertexStateMessage" }
vertexState
结构体跟踪分配给顶点的令牌和颜色。此外,usedColors
映射跟踪已经分配给顶点邻居的颜色。顶点通过交换 VertexStateMessage
实例向每个邻居广播其状态。除了令牌和颜色值之外,这些消息还包括一个顶点 ID。正如我们之前提到的,顶点 ID 在比较令牌值时用于打破平局。
现在,让我们将这个算法的 compute 函数分解成小块,并更详细地检查每个块:
v.Freeze()
state := v.Value().(*vertexState)
if g.Superstep() == 0 {
if state.color == 0 && len(v.Edges()) == 0 {
state.color = 1
return nil
}
state.token = random.Int()
state.usedColors = make(map[int]bool)
return g.BroadcastToNeighbors(v, state.asMessage(v.ID()))
}
首先,每个超级步骤迭代开始时,将所有顶点标记为非活动状态。这样,顶点只有在后续的超级步骤中,当一个邻居选择一个颜色并广播其选择时,才会被重新激活。因此,一旦最后一个剩余的顶点被着色,就不会再交换任何消息,所有顶点都将被标记为非活动状态。这个观察结果将作为算法的终止条件。
超级步骤 0 作为初始化步骤。在这个步骤中,我们为所有顶点分配随机令牌,并让它们向邻居宣布它们的初始状态。如果任何顶点预先着色,它们分配的颜色也将包含在广播的状态更新消息中。vertexState
类型定义了一个方便的辅助方法 asMessage
,该方法生成一个 VertexStateMessage
,可以通过图的 BroadcastToNeighbors
方法发送给邻居:
func (s *vertexState) asMessage(id string) *VertexStateMessage {
return &VertexStateMessage{
ID: id,
Token: s.token,
Color: s.color,
}
}
当然,输入图可能包含没有邻居的顶点。如果这些顶点尚未预先着色,我们只需将第一个可用的颜色分配给它们,在我们的特定实现中是颜色 1。
下一段代码处理来自顶点邻居的状态公告。在迭代每个状态消息之前,每个顶点将其本地的 pickNextColor
变量设置为 true
。然后,迭代消息列表,发生以下情况:
-
如果一个邻居已经被分配了颜色,我们将它插入到本地顶点的
usedColors
映射中。 -
如果任何邻居具有更高的令牌值,或者具有相同的令牌值但它们的 ID 字符串值大于本地顶点,它们在挑选下一个颜色时具有更高的优先级。因此,
pickNextColor
变量将被设置为false
以供本地顶点使用。
一旦处理完所有状态公告,我们检查pickNextColor
变量的值。如果顶点不允许选择下一个颜色,它只需广播其当前状态并等待下一个超级步骤,如下所示:
pickNextColor := true
myID := v.ID()
for msgIt.Next() {
m := msgIt.Message().(*vertexStateMessage)
if m.Color != 0 {
state.usedColors[m.Color] = true
} else if state.token < m.Token || (state.token == m.Token && myID < m.ID) {
pickNextColor = false
}
}
if !pickNextColor {
return g.BroadcastToNeighbors(v, state.asMessage(v.ID()))
}
否则,顶点可以选择分配给它的下一个颜色:
for nextColor := 1; ; nextColor++ {
if state.usedColors[nextColor] {
continue
}
state.color = nextColor
return g.BroadcastToNeighbors(v, state.asMessage(myID))
}
由于图中的一些顶点可能已经被着色,我们的目标是选择这个顶点尚未使用的最小颜色。为此,我们用一个最小的允许值初始化一个计数器并进入循环:在每一步,我们检查usedColors
是否包含nextColor
值的条目。如果是这样,我们增加计数器并再次尝试。如果不是,我们将nextColor
分配给顶点并将我们的更新状态广播给邻居。
如果你担心跟踪每个顶点使用的颜色的空间需求,实际上如果我们不需要支持可能预先着色的图,我们可以做得更好。如果这种情况发生,每个顶点只需要跟踪分配给其邻居的最大颜色值。在颜色选择时间,顶点选择maxColor + 1
作为自己的颜色。
本节中图着色实现的完整源代码和测试可以在本书的 GitHub 仓库的Chapter08/color
文件夹中找到。
计算 PageRank 分数
每当有人听到 Google 这个名字,首先想到的肯定是,当然,那个在 1997 年左右出现并自那以后一直能够超越所有其他搜索引擎竞争的广受欢迎的搜索引擎。
Google 搜索引擎技术的核心无疑是专利的 PageRank 算法,该算法由 Google 联合创始人拉里·佩奇和谢尔盖·布林在 1999 年的论文中发表^([8])。幸运的是,该算法的专利在 2019 年 6 月到期;这是一个非常好的消息,因为它允许我们为 Links 'R' Us 项目自由实现它!
PageRank 算法将所有索引的网页视为一个巨大的、有向图。每个页面在图中表示为一个顶点,而每个页面的出链表示为有向边。图中的所有页面都被分配了一个被称为PageRank 分数的值。PageRank 分数表达了每个页面相对于图中其他页面的重要性(排名)。这个算法的关键前提是,如果我们按照基于关键词的搜索结果的关键词匹配相关性和它们的 PageRank 分数进行排序,我们就能提高返回给执行搜索的用户的结果质量。
在接下来的几节中,我们将探讨计算 PageRank 分数的公式,并使用我们在本章开头开发的图处理框架实现我们自己的 PageRank 计算器。
随机游走模型
为了计算图中每个顶点的分数,PageRank 算法利用了 随机游走者 的模型。在这个模型下,用户进行一次初始搜索并从图中某个页面开始。从那时起,用户随机选择以下两种选项之一:
-
他们可以点击当前页面上的任何出链并导航到新页面。用户选择这个选项的概率是预定义的,我们将用术语 damping factor 来指代。
-
或者,他们可以决定运行一个新的搜索查询。这个决定的效果是将用户 teleporting 到图中随机的一个页面。
PageRank 算法在假设前述步骤无限重复的情况下工作。因此,该模型等同于执行网页图上的随机游走。PageRank 分数值反映了用户落在特定页面的 probability。根据这个定义,我们期望以下情况发生:
-
每个 PageRank 分数应该是一个在 [0, 1] 范围内的值
-
所有分配的 PageRank 分数之和应正好等于 1
PageRank 分数计算的迭代方法
为了从图中估计网页 P 的 PageRank 分数,我们需要考虑两个因素:
-
指向 P 的链接数量
-
指向 P 的页面的质量,如它们自己的 PageRank 分数所示
如果我们只考虑链接数量,我们就会允许恶意用户通过创建指向特定目标页面的大量链接来操纵系统并人为地提高其分数。例如,可以通过在在线论坛上交叉发布相同的链接来实现这一点。另一方面,如果我们使用源页面的 PageRank 分数来 weight 目标页面的入链贡献,那么只有少量来自信誉良好的来源(例如,主要新闻机构)的入链的页面会比链接数量更多但来源不那么流行的页面获得更好的分数。
为了确定特定页面的分数,我们需要知道链接到它的每个页面的分数。更糟糕的是,页面也可能链接 back 到一些或所有链接到它们的页面。这听起来有点像“先有鸡还是先有蛋”的问题!所以,计算是如何工作的?实际上,我们可以使用一个迭代算法来计算 PageRank 分数,该算法使用以下公式来计算页面 P 的 PageRank 分数:
在步骤 0 中,图中所有顶点都被分配了一个初始的 PageRank 分数为 1/N,其中 N 是图中顶点的数量。对于 第 i 步,我们通过取两个项的加权总和来计算 PageRank 分数:
-
第一个项编码了由于跳转操作而从图中随机页面贡献的 PageRank 分数。根据随机漫游模型,用户可以决定停止点击当前访问页面的出站链接,而是运行一个新的查询,将他们带到页面 P。这相当于创建了一个到 P 的 一次性 连接。因此,它将 1/N 单位的 PageRank 转移到 P。
-
第二个项编码了图中每个有指向 P 的出站链接的页面的分数贡献。在先前的方程中,LT(P) 代表链接到页面 P 的页面集合。对于该集合中的每个页面 J,我们通过将其在第 i-1 步积累的 PageRank 分数除以出站链接的数量来计算对 P 的 PageRank 贡献。本质上,在每一步,每个页面都会 均匀分配 其 PageRank 分数到所有出站链接。
由于这两个项指的是具有特定概率发生的互斥事件,我们需要根据各自的概率权衡每个项,以确保所有 PageRank 分数加起来为 1.0。鉴于用户以等于阻尼因子 d 的概率点击出站链接,我们需要将第一个项乘以 1-d,将第二个项乘以 d。
达到收敛 – 我们应该在何时停止迭代?
由于我们使用迭代公式来计算 PageRank 分数,我们执行的步骤越多,结果就越准确。这引发了一个有趣的问题:我们需要执行多少步骤才能达到计算分数所需达到的精度水平?
为了回答这个问题,我们必须提出一个合适的指标,这将允许我们衡量我们距离达到收敛有多近。为此,我们将使用以下公式计算两次连续迭代之间 PageRank 分数的绝对差之和(SAD):
这个度量背后的直觉是,虽然前几次迭代会在绝对意义上对 PageRank 分数造成重大变化,但随着我们接近收敛,每次后续变化的大小将不断减小,并在迭代次数达到无穷大时变为零。显然,对于我们的特定用例,我们需要执行有限数量的步骤。因此,我们必须决定一个合适的阈值值(例如,10^(-3))并持续迭代,直到 SAD 分数低于目标阈值。
现实世界中的 Web 图 – 处理死胡同
计算 PageRank 分数的前一个公式假设所有页面都至少链接到一个页面。在现实生活中,这并不总是如此!让我们考虑以下图中所示的图。在这里,所有顶点都相互连接,除了顶点 D,它有入站链接但没有出站链接。换句话说,D 是一个死胡同!
图 4:一个顶点 D 是死胡同的示例图
输入图中存在死胡同会导致我们的 PageRank 评分计算出现问题吗?到目前为止,我们知道在 PageRank 算法的每次迭代中,页面会将它们目前累积的 PageRank 评分平均分配给所有出站链接。在前面图表中所示的那种死胡同场景中,顶点D会持续从图中每个其他顶点接收 PageRank 评分,但由于它没有出站链接,所以永远不会分配它自己的累积评分。因此,D将最终获得相对较高的 PageRank 评分,而所有其他顶点将最终获得显著较低的评分。
缓解此问题的策略之一是在预处理图中,识别死胡同顶点,并将它们排除在我们的 PageRank 计算之外。然而,消除死胡同远非一项简单任务:移除现有的死胡同可能会使一些曾经链接到它的顶点变成也需要移除的死胡同,依此类推。因此,实现此解决方案并将其扩展到可以与大型图一起工作,在计算能力方面将相当昂贵。
一个更好的替代方案,也是我们将用于我们的 Go 实现的方法,是将死胡同视为与图中所有其他顶点有一个隐含的连接。这种方法通过将每个死胡同累积的 PageRank 评分重新分配回图中的所有顶点来减轻评分偏斜的问题。
使用我们的图处理系统实现此策略的简单方法是将每个死胡同节点广播一条消息,将等于PR/N的数量传递给图中的每个其他顶点。显然,这个想法对于大型图来说永远不会扩展,因此我们需要想出更好的方法……如果我们不遵循基于推送的方法,而是转向基于拉取的方法会怎样?事实上,这实际上是一个利用图处理系统对聚合器支持的绝佳用例!而不是让每个没有出站链接的顶点通过消息广播将它们的 PageRank 分配给图中的每个其他顶点,它们可以简单地将其每节点贡献(PR/N数量)添加到一个累加器中。然后我们可以通过添加一个额外的项来扩展 PageRank 公式,在执行计算时考虑这个剩余的 PageRank:
在先前的公式中,ResPR(i)
返回执行第i步时累积的残差 PageRank。鉴于我们将死胡同视为指向图中每个其他节点的出链,并且冲浪者以等于阻尼因子d的概率点击出链,我们需要将残差 PageRank 乘以阻尼因子。这产生了先前的方程,它将成为我们 PageRank 评分计算器的基础。
定义 PageRank 计算器的 API
现在,让我们讨论如何在我们构建的图处理框架功能之上实现 PageRank 计算器。PageRank 计算器的完整源代码和测试可以在本书的 GitHub 仓库中找到,位于Chapter08/pagerank
文件夹。
Calculator
类型不过是一个bspgraph.Graph
实例和一些配置选项的容器:
type Calculator struct {
g *bspgraph.Graph
cfg Config
executorFactory bspgraph.ExecutorFactory
}
executorFactory
指向将用于创建新Executor
实例的执行器工厂。默认情况下,Calculator
构造函数将使用bspgraph.NewExecutor
作为工厂实现,但用户可以通过SetExecutorFactory
辅助方法来覆盖它:
func (c *Calculator) SetExecutorFactory(factory bspgraph.ExecutorFactory) {
c.executorFactory = factory
}
你可能好奇为什么我们允许用户为创建图执行器提供自定义工厂。能够指定自定义工厂的好处是,它允许我们在将ExecutorCallbacks
对象传递给默认工厂之前拦截、检查和修改它。在第十二章《构建分布式图处理系统》中,我们将利用这一功能来构建 PageRank 计算器的分布式版本。我们将做到这一切,而无需在计算器实现中更改任何一行代码...听起来不可能?继续阅读,所有的一切都将逐步揭晓!
以下是对Config
类型的定义,它封装了执行 PageRank 算法所需的所有配置选项:
type Config struct {
// DampingFactor is the probability that a random surfer will click on
// one of the outgoing links on the page they are currently visiting
// instead of visiting (teleporting to) a random page in the graph.
DampingFactor float64
// The algorithm will keep executing until the aggregated SAD for all
// vertices becomes less than MinSADForConvergence.
MinSADForConvergence float64
// The number of workers to spin up for computing PageRank scores.
ComputeWorkers int
}
在Config
类型上定义的validate
方法检查每个配置参数的有效性,并将空参数填充为合理的默认值:
func (c *Config) validate() error {
var err error
if c.DampingFactor < 0 || c.DampingFactor > 1.0 {
err = multierror.Append(err, xerrors.New("DampingFactor must be in the range (0, 1]"))
} else if c.DampingFactor == 0 {
c.DampingFactor = 0.85
}
if c.MinSADForConvergence < 0 || c.MinSADForConvergence >= 1.0 {
err = multierror.Append(err, xerrors.New("MinSADForConvergence must be in the range (0, 1)"))
} else if c.MinSADForConvergence == 0 {
c.MinSADForConvergence = 0.001
}
if c.ComputeWorkers <= 0 {
c.ComputeWorkers = 1
}
if c.ExecutorFactory == nil {
c.ExecutorFactory = bspgraph.DefaultExecutor
}
return err
}
要创建新的Calculator
实例,客户端填充一个调用NewCalculator
构造函数的Config
对象:
func NewCalculator(cfg Config) (*Calculator, error) {
if err := cfg.validate(); err != nil {
return nil, xerrors.Errorf("PageRank calculator config validation failed: %w", err)
}
g, err := bspgraph.NewGraph(bspgraph.GraphConfig{
ComputeWorkers: cfg.ComputeWorkers,
ComputeFn: makeComputeFunc(cfg.DampingFactor),
})
if err != nil {
return nil, err
}
return &Calculator{cfg: cfg, g: g, executorFactory: bspgraph.NewExecutor}, nil
}
在继续之前,构造函数必须验证提供的配置选项集。在配置对象成功验证后,下一步是创建一个新的bspgraph.Graph
实例,并将其存储在分配的新Calculator
实例中。要实例化图,我们需要提供一个计算函数,该函数由提供的DampingFactor
值参数化。这是通过makeComputeFunc
辅助函数实现的,它封装了dampingFactor
参数,并通过返回的闭包使其可访问:
func makeComputeFunc(dampingFactor float64) bspgraph.ComputeFunc {
return func(g *bspgraph.Graph, v *bspgraph.Vertex, msgIt message.Iterator) error {
// ....
}
}
由于底层的 bspgraph.Graph
实例被封装在 Calculator
类型中,我们还需要提供一组便利方法,以便我们可以向图中添加顶点或边,并访问原始的 bspgraph.Graph
实例:
func (c *Calculator) AddVertex(id string) {
c.g.AddVertex(id, 0.0)
}
func (c *Calculator) AddEdge(src, dst string) error {
// Don't allow self-links
if src == dst {
return nil
}
return c.g.AddEdge(src, dst, nil)
}
func (c *Calculator) Graph() *bspgraph.Graph {
return c.g
}
当然,一旦 PageRank 算法收敛,用户应该能够查询分配给图中每个顶点的 PageRank 分数。这通过调用 Scores
方法来实现。该方法实现将用户定义的访问函数调用到图中的每个顶点,顶点 ID 和分配的 PageRank 分数作为参数:
func (c *Calculator) Scores(visitFn func(id string, score float64) error) error {
for id, v := range c.g.Vertices() {
if err := visitFn(id, v.Value().(float64)); err != nil {
return err
}
}
return nil
}
在创建一个新的 Calculator
实例并通过调用 AddVertex
和 AddEdge
指定图布局之后,我们就准备好执行 PageRank 算法了。为此,我们需要通过调用计算器的 Executor
方法来获取 bspgraph.Executor
实例:
func (c *Calculator) Executor() bspgraph.Executor {
c.registerAggregators()
cb := bspgraph.ExecutorCallbacks{
PreStep: func(_ context.Context, g *bspgraph.Graph) error {
// Reset sum of abs differences and residual aggregators for next step.
g.Aggregator("SAD").Set(0.0)
g.Aggregator(residualOutputAccumName(g.Superstep())).Set(0.0)
return nil
},
PostStepKeepRunning: func(_ context.Context, g *bspgraph.Graph, _ int) (bool, error) {
// Super-steps 0 and 1 are part of the algorithm initialization; predicate should only be evaluated for super-steps >1
sad := c.g.Aggregator("SAD").Get().(float64)
return !(g.Superstep() > 1 && sad < c.cfg.MinSADForConvergence), nil
},
}
return c.executorFactory(c.g, cb)
}
Executor
方法的第一个任务是调用 registerAggregators
辅助函数。此辅助函数的实现概述在以下代码中,它负责注册一组将被 PageRank 计算函数和我们将定义的执行器回调所使用的聚合器:
func (c *Calculator) registerAggregators() {
c.g.RegisterAggregator("page_count", new(aggregator.IntAccumulator))
c.g.RegisterAggregator("residual_0", new(aggregator.Float64Accumulator))
c.g.RegisterAggregator("residual_1", new(aggregator.Float64Accumulator))
c.g.RegisterAggregator("SAD", new(aggregator.Float64Accumulator))
}
让我们更仔细地看看这些聚合器各自的作用:
-
link_count
跟踪图中顶点的总数。 -
residual_0
和residual_1
累加偶数和奇数超步骤的残差 PageRank 数量。如果你想知道为什么我们需要两个累加器,那是因为,在计算第 i 步的 PageRank 分数时,我们需要添加来自 前一个 步骤的残差 PageRank,同时还要累加下一个步骤的残差 PageRank。在执行第 i 步时,计算函数将从索引i%2
的累加器中读取,并将写入索引(i+1)%2
的累加器。 -
SAD
是另一个跟踪两个连续超步骤之间绝对 PageRank 分数差异总和的累加器。算法将在累加器的值大于MinSADForConvergence
配置选项时继续执行。
Executor
方法的第二个职责是定义执行 PageRank 算法所需的一组回调,并调用配置的 ExecutorFactory
以获取一个新的 bspgraph.Executor
实例,然后将其返回给调用者。
PreStep
回调确保在执行新步骤之前,每个所需的累加器都被设置为零值。实用的 residualOutputAccName
辅助函数返回将存储用于下一个 超步骤 输入的残差 PageRank 分数的累加器名称,如下所示:
func residualOutputAccName(superstep int) string {
if superstep%2 == 0 {
return "residual_0"
}
return "residual_1"
}
一旦执行器成功为图中每个顶点运行计算函数,它将调用PreStepKeepRunning
回调,其目的是决定是否需要执行新的超级步骤。注册的回调查找SAD
聚合器的值,将其与配置的阈值进行比较,一旦值小于阈值,就终止算法的执行。
实现一个计算函数以计算 PageRank 分数
现在我们已经简要了解了Calculator
API,是时候将我们的重点转移到实现的最重要部分:计算函数。
在每个超级步骤结束时,顶点预计将它们的 PageRank 分数均匀分布到它们的邻居。在我们的图处理模型中,这项任务通过广播一条消息来简化。IncomingScoreMessage
类型描述了交换消息的有效负载:
type IncomingScoreMessage struct {
Score float64
}
func (pr IncomingScoreMessage) Type() string { return "score" }
为了启动计算器,我们需要将图中每个顶点的初始 PageRank 分数设置为1/N,其中N是图中顶点(页面)的数量。计算N的一个简单方法是直接访问图并计算顶点的数量(例如,len(g.Vertices())
)。然而,请记住,最终目标是以分布式方式运行算法。在分布式模式下,每个工作节点只能访问图顶点的子集。因此,简单地计算本地图实例中的顶点数量不会产生正确的结果。
另一方面,聚合器为我们提供了一个优雅的解决方案,该解决方案适用于单节点和多节点场景。超级步骤0作为我们的初始化步骤:每次计算函数调用都会增加page_count
聚合器的值。在超级步骤结束时,计数器将包含图中顶点的总数:
superstep := g.Superstep()
pageCountAgg := g.Aggregator("page_count")
if superstep == 0 {
pageCountAgg.Aggregate(1)
return nil
}
对于每个其他超级步骤,我们应用 PageRank 公式来估计当前顶点的新的 PageRank 分数:
pageCount := float64(pageCountAgg.Get().(int))
var newScore float64
switch superstep {
case 1:
newScore = 1.0 / pageCount
default:
// Process incoming messages and calculate new score.
dampingFactor := c.cfg.DampingFactor
newScore = (1.0 - dampingFactor) / pageCount
for msgIt.Next() {
score := msgIt.Message().(IncomingScoreMessage).Score
newScore += dampingFactor * score
}
// Add accumulated residual page rank from any dead-ends encountered during the previous step.
resAggr := g.Aggregator(residualInputAccName(superstep))
newScore += dampingFactor * resAggr.Get().(float64)
}
在存储当前顶点的新的 PageRank 估计之前,我们计算与上一个值的绝对差异并将其添加到SAD
聚合器中,该聚合器的角色是跟踪当前超级步骤的绝对分数差异的总和:
absDelta := math.Abs(v.Value().(float64) - newScore)
g.Aggregator("SAD").Aggregate(absDelta)
v.SetValue(newScore)
如果顶点没有邻居(即,它是一个死胡同),我们的模型假设它隐式地连接到图中每个其他节点。为了确保顶点的 PageRank 分数均匀分布到图中每个其他顶点,我们在以下超级步骤中将用作输入的残差 PageRank 聚合器中添加一个等于newScore
/pageCount
的量。否则,我们需要将计算出的 PageRank 分数均匀分布到现有的顶点邻居。为了实现这一点,我们在下一个超级步骤向每个邻居发送一系列IncomingScore
消息,这些消息贡献一个等于newScore
/numOutLinks
的量:
// Check if this is a dead-end
numOutLinks := float64(len(v.Edges()))
if numOutLinks == 0.0 {
g.Aggregator(residualOutputAccName(superstep)).Aggregate(newScore / pageCount)
return nil
}
// Otherwise, evenly distribute this node's score to all its neighbors.
return g.BroadcastToNeighbors(v, IncomingScoreMessage{newScore / numOutLinks})
基本上就是这样!我们开发的图处理系统使得构建一个完全功能且垂直可扩展的 PageRank 计算器变得相当容易,它可以正确处理死胡同。剩下的只是将其连接到我们在第六章,构建持久层中创建的链接图和文本索引器组件,我们就可以开始工作了!
摘要
我们本章开始时介绍了用于支持大规模数据集离核处理的 BSP 模型。然后,我们应用了 BSP 模型的关键原则,以便我们可以创建自己的图处理系统,该系统可以在并行执行的同时利用所有可用的 CPU 核心,为图中的每个顶点执行用户定义的计算函数。
在本章的后半部分,我们探讨了各种与图相关的问题,并提出了可以高效执行任何大小图的并行算法。在本章的最后部分,我们描述了 Google 的 PageRank 算法背后的理论,并概述了以迭代方式计算 PageRank 分数的公式。我们利用图处理系统构建了一个完整的 PageRank 计算器,这将成为实现 Links 'R' Us 项目的 PageRank 组件的基础。
随着我们越来越接近完成项目所需的组件,我们需要提前规划并设计一些 API,以便我们的组件之间可以交换信息。这是下一章的主要关注点。
进一步阅读
-
Apache Giraph:一个为高可扩展性而构建的迭代图处理系统。URL:
giraph.apache.org/
。 -
Chaitin, G. J.: 通过图着色进行寄存器分配与溢出。 发表于:1982 年 SIGPLAN 编译器构造研讨会论文集,SIGPLAN '82. 纽约,纽约,美国:ACM,1982 — ISBN 0-89791-074-5,第 98–105 页。
-
Floyd, Robert W.: 算法 97:最短路径。 发表于:Commun. ACM Bd. 5. 纽约,纽约,美国,ACM (1962), 第 6 期, 第 345 页。
-
GPS:一个图处理系统。URL:
infolab.stanford.edu/gps
。 -
Hart, P. E. ; Nilsson, N. J. ; Raphael, B.: 《基于启发式确定最小成本路径的正式基础》. 发表于:IEEE Transactions on Systems Science and Cybernetics Bd. 4 (1968), 第 2 期, 第 100–107 页。
-
Jones, Mark T. ; Plassmann, Paul E.: 《并行图着色启发式算法》. 发表于:SIAM J. Sci. Comput. Bd. 14. 费城,宾夕法尼亚州,美国,工业与应用数学学会 (1993), 第 3 期, 第 654–669 页。
-
Malewicz, Grzegorz ; Austern, Matthew H. ; Bik, Aart J. C ; Dehnert, James C. ; Horn, Ilan ; Leiser, Naty ; Czajkowski, Grzegorz: Pregel: 一个用于大规模图处理的系统。 In: 2010 年 ACM SIGMOD 国际数据管理会议论文集,SIGMOD '10. 纽约,NY,美国 : ACM,2010 — ISBN 978-1-4503-0032-2, S. 135–146.
-
Page, Lawrence ; Brin, Sergey ; Motwani, Rajeev ; Winograd, Terry: 《网页排序引文排名:为网络带来秩序》。(技术报告 No. 1999-66) : 斯坦福信息实验室; 斯坦福信息实验室,1999. – 前一编号 = SIDL-WP-1999-0120.
-
Park, Taehoon ; Lee, Chae Y.: 图着色算法在频率分配问题中的应用。 In: 日本运筹学协会杂志 Bd. 39 (1996), Nr. 2, S. 258–265.
-
Valiant, Leslie G.: 并行计算的桥梁模型。 In: ACM 通讯 Bd. 33. 纽约,NY,美国,ACM (1990), Nr. 8, S. 103–111.
第九章:与外部世界通信
"一个不可理解的 API 是不可用的。"
- James Gosling
所有软件系统最终都需要与外部世界交换数据。在许多情况下,这是通过 API 实现的。本章提供了 REST 和 RPC 模式构建 API 的比较,并讨论了一些常见的 API 问题,如身份验证、版本控制和安全性。本章的其余部分将深入探讨 gRPC 生态系统,并以 Links 'R' Us 项目的 gRPC 基础 API 实现作为结尾。
本章将涵盖以下主题:
-
RESTful API 的基本原理
-
保护 API 的策略和应避免的陷阱
-
API 版本化的方法
-
gRPC 作为构建高性能服务的替代方案
-
使用协议缓冲定义语言描述消息和 RPC 服务
-
不同的 RPC 模式(单一、客户端、服务器流和双向流)
-
锁定 gRPC API
技术要求
本章讨论的主题的完整代码已发布到本书的 GitHub 仓库中的 Chapter09
文件夹。
您可以通过将网络浏览器指向以下 URL 来访问本书的 GitHub 仓库,其中包含本书各章节的代码和所有必需的资源:github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
。
为了让您尽快开始,每个示例项目都包含一个 Makefile,它定义了以下目标集:
Makefile 目标 | 描述 |
---|---|
deps | 安装所有必需的依赖项 |
test | 运行所有测试并报告覆盖率 |
lint | 检查 lint 错误 |
与本书中的其他所有章节一样,您需要一个相当新的 Go 版本,您可以在 golang.org/dl
下载。
设计健壮、安全且向后兼容的 REST API
每当工程师听到 API 一词时,REST(表示性状态转移的缩写)无疑是首先想到的词语之一。确实,人们日常使用的绝大多数在线服务和应用程序都是使用 REST API 与后端服务器进行通信。
我们通常所说的 RESTful API 的普及并非偶然。REST 作为一种用于构建 Web 应用的架构风格,相较于如 简单对象访问协议(SOAP)等替代方案,提供了许多诱人的优势:
-
交互简便性:只需一个网络浏览器或
curl
这样的命令行工具即可与 REST 端点进行交互 -
大多数编程语言都内置了对执行 HTTP 请求的支持
-
拦截 HTTP 请求(例如,通过代理)并提供用于测试的预定义响应非常容易
-
由于 RESTful API 建立在 HTTP 之上,客户端(例如,网页浏览器)可以选择在本地缓存大型 HTTP GET 响应,查询远程服务器以确定缓存的数据是否已过时,需要刷新。
REST API 是围绕访问和修改资源的概念构建的。资源代表任何客户端可以操作的应用数据(例如,产品、用户、订单、文档集合等)。一个典型的 RESTful API 公开了一组端点,允许客户端创建、读取、更新和删除特定类型的资源。这些操作中的每一个都映射到一个 HTTP 动词,如下所示:
-
可以通过 POST 请求创建新资源
-
可以通过 GET 请求检索现有资源
-
可以通过 PUT 或 PATCH 请求完全或部分更新资源
-
可以通过 DELETE 请求删除资源
虽然 REST 架构没有规定用于向客户端交付数据的数据格式,但如今,JSON 已成为实现 REST API 的事实标准。这主要归因于它轻量级、易于阅读和压缩。尽管如此,您仍然可以在一些组织中找到(例如银行和支付网关是典型例子)提供 RESTful API 的组织,这些 API 期望并生成 XML 有效负载。
使用人类可读的路径为 RESTful 资源命名
其中一个关键思想,并且是客户端在与 RESTful API 打交道时通常会期望的,是每个资源实例都可以通过统一资源标识符(URI)单独寻址。由于 URI 的格式在向将使用该 API 的客户端传达 API 的资源模型方面起着重要作用,因此软件工程师在设计新 API 或向现有 API 引入新资源类型时,应始终努力制定一致的 URI 命名方案。
以下是一组关于资源命名的有见地的约定,可以帮助您设计出对最终用户来说更容易理解和操作的 API:
-
资源名称必须始终是名词,而不是动词或动词类表达。动词可以用作后缀,以指示对特定资源要执行的操作。例如,
/basket/checkout
触发了当前用户购物车的结账流程。 -
作为对之前提到的指南的例外,与 CRUD 操作相关的动词不应包含在资源 URI 中;它们可以通过执行请求时使用的 HTTP 动词推断出来。换句话说,与其使用如
/users/123/delete
这样的 URI 来删除用户,客户端应通过向/users/123
发送 HTTP DELETE 请求来代替。 -
当通过名称指代特定资源实例时,必须使用单数名词。例如,
/user/account
返回当前登录用户的账户详情。虽然使用单数名词模式来指代集合中的特定项(例如,/user/123
)可能很有吸引力,但建议避免这种做法,因为它往往会为 CRUD 操作创建不一致的路径。 -
当指代资源集合或集合中的特定资源实例时,必须使用复数名词。例如,
order/123/items
会返回 ID 为123
的订单中的项目列表,而/users/789
会返回 ID 为789
的用户的信息。 -
避免将尾部斜杠 (/) 添加到 URI 的末尾。这样做不会向客户端提供任何额外信息,并可能导致混淆;也就是说,该 URI 是完整的还是缺少其路径的一部分?
-
RFC3986 ^([6]) 定义 URIs 为大小写敏感。因此,为了保持一致性,坚持使用小写字母作为 URI 路径是一个好习惯。更重要的是,使用连字符 (-) 分隔长路径段往往可以使路径更容易阅读。可以说,
/archived-resource
比较容易阅读,而/archivedresource
则不然。
以下表格总结了执行 CRUD 操作的 HTTP 动词和 URI 模式组合,针对产品集合。与 products
资源一起工作的 HTTP 动词和资源路径如下:
HTTP 动词 | 路径 | 期望 (JSON) | 返回 (JSON) | HTTP 状态 | 描述 |
---|---|---|---|---|---|
POST | /products |
产品条目 | 包含其 ID 的新产品条目 | 200 (成功) 或 201 (已创建) | 创建新产品 |
GET | /products |
无 | 包含产品条目的数组 | 200 (成功) | 获取产品列表 |
GET | /products/:id |
无 | 指定 ID 的产品 | 200 (成功) 或 404 (未找到) | 通过 ID 获取产品 |
PUT | /products/:id |
产品条目 | 更新的产品条目 | 200 (成功) 或 404 (未找到) | 通过 ID 更新产品 |
PATCH | /products/:id |
部分产品条目 | 更新的产品条目 | 200 (成功) 或 404 (未找到) | 通过 ID 更新产品单个字段 |
DELETE | /products/:id |
无 | 无 | 200 (成功) 或 404 (未找到) | 通过 ID 删除产品 |
如您可能推测的那样,上述模式也可以用于处理形成层次结构的资源。例如,要检索分配给 ID 为 123
的用户在 ID 为 789
的安全组中的权限集合,可以使用 /security-groups/789/users/123/permissions
作为路径。在这个例子中,使用正斜杠分隔安全组和用户资源表明它们之间存在层次关系。
控制对 API 端点的访问
在定义了用于引用资源的端点之后,下一步合乎逻辑的步骤是实现强制访问控制的机制。例如,虽然/orders/123
和orders/789
都是有效的资源路径,但它们可能属于不同的用户;显然,我们期望每个用户只能访问他们自己的订单。
在不同的场景中,用户可能能够通过向/security-groups/123/users
执行 GET 请求来列出属于特定安全组的用户,但只有管理员才能添加或从该组中删除用户(例如,通过向同一端点执行 POST 和 DELETE 请求)。实现这种细粒度资源访问的相当常见模式是基于角色的访问控制(RBAC)。
为了应用这个模式,我们需要定义一个角色列表(例如,普通用户、管理员等),并将每个角色与一组访问权限相关联。系统中的每个用户都被分配到一个或多个角色,系统在考虑是否应该授予特定资源的访问权限时会参考这些角色。
在我们继续实施 RBAC 之前,我们需要建立一个机制来在用户尝试访问非公开 API 端点之前进行用户认证。
许多人倾向于混淆“认证”和“授权”这两个术语,实际上它们不能互换使用。
为了避免任何混淆,让我们花点时间正确地定义这两个术语:
-
认证:这证明了特定实体(例如,发起 API 请求的客户端)通过提供某种形式的凭证来证明其身份。这就像在机场安检时出示护照一样。
-
授权:这证明了实体有权访问特定资源。例如,地铁票让你无需透露身份就能进入火车站台。
在接下来的两个部分中,我们将探讨两种处理认证的流行方法,即通过 TLS 进行的基本 HTTP 认证和通过OAuth2向外部服务提供商进行授权。
基本 HTTP 认证
基本 HTTP 认证可能是为任何 API 实现认证层最简单和最直接的方法。每个客户端都提供用户名和密码元组或 API 密钥。后者通常更受欢迎,因为它允许应用程序开发者生成多个与同一用户账户相关联的访问密钥,这些密钥可以独立管理、计量,甚至在需要时可以撤销。
当客户端需要执行认证的 API 请求时,他们必须对他们的访问凭证进行编码,并通过标准 HTTP 授权头将其附加到发出的请求中。客户端按照以下方式构建头字段的内容:
-
使用冒号分隔符连接用户名和密码。因此,如果用户名是
foo
,密码是bar
,则连接的结果将是foo:bar
。另一方面,如果客户端只提供了一个 API 密钥,他们需要将其用作用户名,并与一个空白密码连接。换句话说,如果 API 密钥是abcdefg
,则连接的结果将是abcdefg:
。 -
连接的凭据随后被 base64 编码。对于之前提到的用户名和密码场景,
foo:bar
的编码输出为Zm9vOmJhcg==
。 -
将授权方法(基本)后跟一个空格字符添加到编码凭据之前,以生成最终的标头值,即
Authorization: Basic Zm9vOmJhcg==
。
这种方法的明显缺点是客户端的凭据以明文形式通过网络传输。因此,为了避免凭据泄露,API 请求需要通过安全通道传输。原则上,这是通过在客户端和服务器之间建立 TLS 会话来实现的。
保护 TLS 连接免受窃听
还需要注意的是,虽然 TLS 会话确实为数据交换提供了一个安全通道,但 TLS 加密并不是万能的;恶意对手仍然可以通过使用代理执行中间人(MITM)攻击来拦截和解码 TLS 流量:
图 1:使用中间人攻击拦截 TLS 流量
上述图示说明了爱丽丝使用她的手机上的银行应用程序查询其银行账户余额的场景。爱娃是一个试图拦截运行在爱丽丝手机上的应用程序和银行后端服务器之间 API 调用的恶意行为者。
为了实现这一点,爱娃需要安装一个中间人代理,该代理将拦截并记录从爱丽丝手机发出的连接请求,并将它们代理到目标服务器或返回虚假响应。然而,正如我们之前提到的,银行的服务器使用基于 TLS 的加密,因此银行应用程序不会完成 TLS 握手步骤,除非服务器为银行的域名提供了有效的 TLS 证书。
为了使中间人攻击成功,代理服务器需要能够向爱丽丝提供伪造的 TLS 证书,这些证书不仅与银行的域名匹配,而且是由预安装在爱丽丝手机上的全球受信任的证书授权机构(CA)之一签发的。
由于爱娃无法访问任何全球 CA 的私钥,伪造证书的前提是爱娃需要在爱丽丝的手机上安装一个自定义证书授权机构。这可以通过利用社会工程学中的安全漏洞或简单地强迫爱丽丝这样做来实现,如果爱娃是一个国家行为者的话。
在爱娃的 CA 证书就位的情况下,拦截过程如下:
-
爱丽丝试图连接到一个网站,例如,
https://www.bank.com
。 -
伊芙拦截请求并与爱丽丝建立 TCP 套接字。
-
爱丽丝启动 TLS 握手。握手头包括一个服务器名称指示(SNI)条目,该条目指示它试图到达的域名。
-
伊芙打开到真实
https://www.bank.com
服务器的连接,并启动 TLS 握手,确保传递与爱丽丝相同的 SNI 条目。 -
银行服务器响应其 TLS 证书,该证书还包括有关服务器通用名称(CN)的信息,在这种情况下,通常会是
www.bank.com
或bank.com
。证书还可能包括一个主题备用名称(SAN)条目,该条目列出了由同一证书保护的其他域名。 -
伊芙伪造一个新的 TLS 证书,该证书与银行的 TLS 证书中的信息相匹配,并使用安装在爱丽丝手机上的自定义 CA 证书的私钥进行签名。伪造的证书返回给爱丽丝。
-
爱丽丝成功验证了伪造的 TLS 证书,即它具有正确的 SNI,并且其父证书链可以完全追溯到受信任的根 CA。此时,爱丽丝完成 TLS 握手,并向银行 API 发送 API 请求,其中包含她的访问凭证。
-
爱丽丝的请求使用伪造的 TLS 证书进行加密。伊芙解密请求并记录下来。作为代理,她打开到真实银行服务器的连接,并将爱丽丝的请求发送过去。
-
伊芙记录来自银行服务器的响应并将其发送回爱丽丝。
既然我们已经完全了解中间人攻击可能造成的潜在损害程度,我们实际上可以采取哪些步骤来使我们的 API 更具抗攻击性?缓解伪造 TLS 证书问题的一种方法是通过使用称为公钥固定(public key pinning)的技术。
每次我们为我们的应用程序发布新的客户端时,我们都会嵌入与用于保护 API 网关的 TLS 证书相对应的公钥指纹。完成 TLS 握手后,客户端计算由服务器提供的证书的公钥指纹,并将其与嵌入的值进行比较。如果检测到不匹配,客户端将立即终止连接尝试,并通知用户可能正在进行中间人攻击。
现在,让我们看看我们如何在 Go 应用程序中实现公钥固定。以下示例的完整源代码可在本书 GitHub 存储库的 Chapter09/pincert/dialer
包中找到。Go 的 http.Transport
类型是一个低级原语,由 http.Client
用于执行 HTTP 和 HTTPS 请求。在创建新的 http.Transport
实例时,我们可以用自定义函数覆盖其 DialTLS
字段,该函数将在需要建立新的 TLS 连接时被调用。这似乎是实施公钥指纹验证逻辑的完美位置。
如下代码所示,WithPinnedCertVerification
辅助函数返回一个拨号器函数,该函数可以被分配给 http.Transport
的 DialTLS
字段:
func WithPinnedCertVerification(pkFingerprint []byte, tlsConfig *tls.Config) TLSDialer {
return func(network, addr string) (net.Conn, error) {
conn, err := tls.Dial(network, addr, tlsConfig)
if err != nil {
return nil, err
}
if err := verifyPinnedCert(pkFingerprint, conn.ConnectionState().PeerCertificates); err != nil { _ = conn.Close()
return nil, err
}
return conn, nil
}
}
返回的拨号器通过调用 tls.Dial
函数并传入调用者提供的网络、目标地址和 tls.Config
参数作为参数来尝试建立 TLS 连接。请注意,tls.Dial
调用还将自动为我们处理远程服务器提供的 TLS 证书链的验证。在成功建立 TLS 连接后,拨号器将验证固定证书的任务委托给 verifyPinnedCert
辅助函数,如下面的代码片段所示:
func verifyPinnedCert(pkFingerprint []byte, peerCerts []*x509.Certificate) error {
for _, cert := range peerCerts {
certDER, err := x509.MarshalPKIXPublicKey(cert.PublicKey)
if err != nil {
return xerrors.Errorf("unable to serialize certificate public key: %w", err)
}
fingerprint := sha256.Sum256(certDER)
// Matched cert PK fingerprint to the one provided.
if bytes.Equal(fingerprint[:], pkFingerprint) {
return nil
}
}
return xerrors.Errorf("remote server presented a certificate which does not match the provided fingerprint")
}
verifyPinnedCert
实现会遍历远程服务器提供的 X509 证书列表,并为每个证书的公钥计算 SHA256 哈希。然后,每个计算出的指纹与固定证书的指纹进行比较。如果找到匹配项,则 verifyPinnedCert
无错误返回,并且可以安全地使用 TLS 连接进行 API 调用。另一方面,如果没有找到匹配项,将返回错误。在后一种情况下,拨号器将终止连接并将错误传播回调用者。
使用此拨号器提高您的 API 客户端的安全性相当简单。您需要做的就是创建自己的 http.Client
实例,如下所示:
client := &http.Client{
Transport: &http.Transport{
DialTLS: dialer.WithPinnedCertVerification(
fingerprint,
new(tls.Config),
),
},
}
您现在可以使用此客户端实例执行 HTTPS 请求到您的后端服务器,就像您通常所做的那样,但增加了额外的优势,即您的代码现在可以检测到中间人攻击尝试。使用此拨号器执行公钥固定的一个完整端到端示例可以在本书 GitHub 存储库的 Chapter09/pincert
包中找到。
使用 OAuth2 认证到外部服务提供商
OAuth 是一个开放标准,用于授权,最初作为替代我们在上一节中检查的基本身份验证模式的提议。
OAuth 被设计用来解决以下问题:假设我们有两个服务,A 和 B,它们通常彼此无关。作为服务 A 的终端用户,我们希望授予它访问由服务 B 托管的一些个人数据的权限。然而,我们不想泄露我们的凭证,以便我们可以从服务 A 访问服务 B。
使用 OAuth 的常见用例如下:
-
使用第三方服务作为单点登录(SSO)提供商,而不是为每个我们感兴趣使用的服务创建单独的账户。当你尝试登录在线服务时,常见的带有X按钮的登录方式就是这种模式的例子。此外,SSO 提供商通常提供一个仪表板,用户可以检查他们已授予访问权限的服务列表,并在任何时间点撤销他们的访问权限。
-
允许一个服务代表特定用户使用另一个服务的 API。例如,用户可以使用 GitHub 账户登录到持续集成(CI)服务,并允许 CI 服务使用 GitHub 的 API 查询用户的仓库或设置 webhooks,当创建 pull 请求时触发 CI 运行。
那么,在底层它是如何工作的,我们如何将 OAuth 框架集成到我们的 Go 应用程序中呢?在本节的剩余部分,我们将重点关注三腿 OAuth2 流程,它可以促进应用程序之间在无需共享用户凭证的情况下进行数据交换。
三腿 OAuth2 流程涉及以下四个方面:
-
资源所有者:这是想要将托管在服务 B 上的数据访问权限授予服务 A 的用户,而不共享他们的凭证。
-
OAuth 客户端:在我们的场景中,服务 A 想要利用服务 B 提供的 API 来获取用户数据或代表用户执行某些操作。
-
资源服务器:在我们的场景中,服务 B 托管了服务 A 试图访问的用户数据。
-
授权服务器:这是服务 B 的一部分,并作为此特定 OAuth 流程中的关键组件。它生成适当的访问令牌集,允许服务 A 访问服务 B 托管的特定子集的用户数据。
以下图表说明了三腿 OAuth2 流程:
图 2:三腿 OAuth2 流程的步骤
为了使服务 A 能够触发三腿 OAuth2 流程,它需要在服务 B 的授权服务器上进行注册。注册后,服务 A 将被分配一个唯一的客户端 ID 和客户端密钥令牌。客户端 ID 是一个公共令牌,允许授权服务器识别需要访问的应用程序。另一方面,客户端密钥是私有的,并在 OAuth 客户端需要联系授权服务器时用于验证 OAuth 客户端。
让我们来看看三腿 OAuth2 流程中发生了什么:
-
用户访问服务 A 的网站并点击“使用 B 登录”按钮。
-
服务 A 的后端服务器配置了服务 B 的授权服务器的 API 端点。它向用户返回一个包含以下信息的授权 URL:
-
授权服务器用于识别请求访问的服务客户端 ID 令牌
-
需要授予服务 A 的一组细粒度访问权限(授权)
-
一个由服务 A 托管并将在用户同意提供访问权限后由授权服务器重定向到该 URL 的 URL
-
一个 nonce 值,它将用作授权请求的唯一标识符
-
-
用户使用他们的网络浏览器访问授权 URL。
-
授权服务器渲染一个同意页面,提供有关需要访问用户数据的应用程序的详细信息(名称、作者等),以及可以请求的授权类型的描述。
以下截图显示了授权访问用户 GitHub 账户的示例同意页面:
图 3:授予访问用户 GitHub 账户的示例同意页面
-
一旦用户审查并授权服务 A 请求的权限列表并授权它们,他们的网络浏览器将被重定向到在步骤 2 中生成的授权请求 URL 中包含的 URL。授权服务器向该 URL 附加两个额外的值——授权请求中的 nonce 值和访问代码。
-
在收到访问代码并将传入的 nonce 值与授权请求中包含的值匹配后,OAuth 客户端联系服务器并尝试用获得的访问代码交换访问令牌。
-
授权服务器返回两个令牌:一个短期的访问令牌,可用于访问资源服务器上的数据,以及一个长期的刷新令牌,OAuth 客户端可以使用它来刷新过期的访问令牌。
-
OAuth 客户端联系资源服务器并使用访问令牌获取所需数据。
与我们之前讨论的基本认证机制类似,所有发送到资源服务器的请求都包含一个 HTTP 授权头,客户端用获得的访问令牌填充该头。唯一的区别是,这次客户端指定bearer
作为授权方法,即传输的头部看起来像Authorization: Bearer ACCESS_TOKEN
。
幸运的是,我们应用中实现三脚 OAuth 流程所需的大部分管道代码已经由golang.org/x/oauth2
包提供。我们所需做的只是实现我们在第 5 步中描述的重定向处理逻辑。让我们首先创建一个名为Flow
的新类型来封装我们的 OAuth 实现逻辑。type
定义位于Chapter09/oauthflow/auth
包中,并包含以下一系列字段:
type Flow struct {
cfg oauth2.Config
mu sync.Mutex
srvListener net.Listener
pendingRequests map[string]chan Result
}
cfg
字段持有oauth2.Config
值,它描述了 OAuth 提供者的端点:
-
授权请求
-
获取访问令牌
-
当访问令牌过期时刷新访问令牌
srvListener
字段存储了net.Listener
实例,这是我们实现将监听 OAuth 重定向的地址,而pendingRequests
映射跟踪所有当前正在进行的授权尝试。一个sync.Mutex
保护对这两个变量的访问,并确保我们的实现是线程安全的。
要使用我们的包,用户必须通过调用NewOAuthFlow
构造函数来创建一个新的Flow
实例,其实现如下代码片段所示:
func NewOAuthFlow(cfg oauth2.Config, callbackListenAddr, redirectHost string) (*Flow, error) {
if callbackListenAddr == "" {
callbackListenAddr = "127.0.0.1:8080"
}
l, err := net.Listen("tcp", callbackListenAddr)
if err != nil {
return nil, xerrors.Errorf("cannot create listener for handling OAuth redirects: %w", err)
}
if redirectHost == "" {
redirectHost = l.Addr().String()
}
cfg.RedirectURL = fmt.Sprintf("http://%s/oauth/redirect", redirectHost)
f := &Flow{srvListener: l, cfg: cfg, pendingRequests: make(map[string]chan Result)}
mux := http.NewServeMux()
mux.HandleFunc(redirectPath, f.handleAuthRedirect)
go func() { _ = http.Serve(l, mux) }()
return f, nil
}
构造函数期望三个参数:
-
用户希望对其进行身份验证的提供者的
oauth2.Config
实例。 -
一个本地地址,以便它可以监听传入的重定向请求。如果没有指定,实现将绑定到默认地址,
127.0.0.1:8080
。 -
一个作为三脚 OAuth 流程一部分发送到远程服务器的重定向 URL。如果没有指定,实现将使用监听器绑定的地址。
你可能想知道为什么用户需要指定监听地址和重定向 URL。当我们在本地上测试我们的应用程序时,这两个值始终相同。事实上,我们可以将这两个参数都留空,我们的应用程序将使用默认值正常工作!
在这个场景中,一旦我们登录到远程服务,OAuth 服务器将重定向我们的浏览器到一个回环地址,只要它运行在与我们的 OAuth 重定向监听器相同的机器上,浏览器就可以成功连接。
在生产部署中,我们的代码将在由云提供商托管的独立虚拟机上运行。虽然我们的 OAuth 处理代码仍然会监听回环地址,但用户的浏览器只能通过具有公共 IP 地址的负载均衡器连接到它。在这种情况下,使三脚 OAuth 流程正确工作的唯一方法是提供一个重定向 URL,其 DNS 记录解析为外部负载均衡器的 IP 地址。
回到构造函数实现,我们首先需要做的是将一个新的 net.Listener
绑定到请求的地址,并填充 redirectHost
参数的值(如果尚未指定)。接下来,我们覆盖用户提供的 OAuth 配置对象的 RedirectURL
字段,将其替换为通过连接 redirectHost
参数值和一个已知的静态路径(在这个例子中是 /oauth/redirect
)生成的 URL。在返回新分配的 Flow
实例之前,代码启动一个 go-routine 并启动一个 HTTP 服务器,用于处理来自远程授权服务器的传入重定向。
获得一个新的 Flow
实例后,用户可以通过调用其 Authenticate
方法来触发一个三脚 OAuth 流程,其源代码如下所示:
func (f *Flow) Authenticate() (string, <-chan Result, error) {
nonce, err := genNonce(16)
if err != nil {
return "", nil, err
}
authURL := f.cfg.AuthCodeURL(nonce, oauth2.AccessTypeOffline)
resCh := make(chan Result, 1)
f.mu.Lock()
f.pendingRequests[nonce] = resCh
f.mu.Unlock()
return authURL, resCh, nil
}
从前面的代码片段中,为了区分并发认证请求,Authenticate
方法生成一个唯一的 nonce 值,并将其与每个挂起的请求关联起来。然后,生成的 nonce 被传递到 OAuth 配置的 AuthCodeURL
方法,以生成一个授权 URL(指向远程服务),在那里最终用户可以使用他们的网络浏览器登录并同意我们应用程序请求的授权。
OAuth 流程的剩余步骤是异步进行的。为此,代码分配了一个缓冲的 Result
通道,将其追加到 pendingRequests
映射中,同时使用生成的 nonce 作为键,并将授权 URL 和结果通道返回给调用者。然后,应用程序必须将最终用户的浏览器重定向到生成的 URL,并阻塞,直到可以从返回的通道中读取到 Result
实例。
Result
类型封装了授权尝试的结果,并定义如下:
type Result struct {
authErr error
authCode string
cfg *oauth2.Config
}
一旦用户完成授权过程,远程 OAuth 服务器将他们的浏览器重定向到我们在 Flow
类型构造函数中启动的 HTTP 服务器。接下来,我们将检查 handleAuthRedirect
HTTP 处理器的实现。
在以下代码片段中,r
变量指的是一个 http.Request
实例,而 w
变量指的是一个 http.ResponseWriter
实例。
HTTP 处理器的第一个任务是解析和验证授权服务器通过以下代码块发送给我们的参数:
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
nonce := r.FormValue("state")
code := r.FormValue("code")
http.Request
对象的 ParseForm
方法非常灵活,因为它能够从 URL(如果这是一个 GET 请求)和 HTTP 请求体(如果这是一个 POST 请求)中解码参数。如果没有错误返回,我们使用方便的 FormValue
方法提取 state
参数,它包含我们嵌入初始授权请求 URL 中的 nonce 值,以及 code
值,它包含授权服务器返回的访问代码。
接下来,处理器获取锁并使用提供的 nonce 值索引pendingRequests
映射,试图查找待处理请求的结果通道。如果没有找到匹配项,我们将打印出一个简单的警告信息,该信息将被显示在用户的浏览器中,并退出处理器。否则,我们将从映射中移除待处理的结果通道,并向其发布一个Result
实例。以下代码块展示了上述步骤是如何实现的:
f.mu.Lock()
resCh, exists := f.pendingRequests[nonce]
if !exists {
f.mu.Unlock()
_, _ = fmt.Fprint(w, unknownNonce)
return
}
delete(f.pendingRequests, nonce)
f.mu.Unlock()
resCh <- Result{ authCode: code, cfg: &f.cfg }
close(resCh)
_, _ = fmt.Fprint(w, successMsg)
一旦 HTTP 处理器将Result
值写入通道,等待该通道的应用程序将解除阻塞,并可以调用结果对象的Client
方法以获取一个http.Client
实例,从而可以对远程服务进行认证调用。返回的http.Client
实例被特别配置为自动将获取的访问令牌注入所有发出的请求,并在其过期时透明地刷新它。此方法的完整实现概述在以下代码片段中:
func (ar *Result) Client(ctx context.Context) (*http.Client, error) {
if ar.authErr != nil {
return nil, ar.authErr
}
token, err := ar.cfg.Exchange(ctx, ar.authCode)
if err != nil {
return nil, xerrors.Errorf("unable to exchange authentication code with OAuth token: %w", err)
}
return ar.cfg.Client(ctx, token), nil
}
如前述代码片段所示,我们通过将授权服务器发送回的短期认证代码与长期访问令牌进行交换来完成三重认证流程。最后,我们将OAuth
访问令牌传递给存储的oauth2.Config
值的同名Client
方法,以创建一个具有令牌意识的http.Client
实例,然后将其返回给调用者。
要了解拼图中所有部件是如何组合在一起的,您可以查看本书 GitHub 仓库中的Chapter09/oauthflow
包。它包含了一个使用本节代码获取对 GitHub API 的访问权限并打印出用户登录名的简单 CLI 应用程序的完整、端到端示例。
处理 API 版本
一旦某个特定服务的公共 API 发布并第三方开始使用它,API 开发者需要非常小心,以避免引入任何可能导致第三方应用程序停止工作的更改。
假设我们正在构建一个类似于 PayPal、Stripe 或 Adyen 的支付处理器。此类服务的核心业务价值主张是提供一个稳固且易于使用的 API 来处理支付。为此,我们预计会有数百或数千个应用程序实例(电子商务网站、定期订阅服务等)紧密耦合到我们的支付处理器的公共 API。
引入新的 API 端点将是一个相对简单的工作;毕竟,依赖于该服务 API 的所有应用程序都不会使用新的端点,所以我们实际上无法破坏任何东西。另一方面,更改现有或删除旧的 API 端点不能在没有提前通知我们 API 的所有用户的情况下完成。
由于每个应用程序集成商的进度不同,这个问题变得更加复杂;有些人可能在相对较短的时间内更新他们的应用程序,而其他人可能需要数月时间才能推出更新。同样,应用程序集成商也可能倒闭,导致最终用户没有渠道接收已部署应用程序实例的更新。
那么,如果我们完全控制服务器和客户端会怎样?如果我们正在构建一个移动应用程序并选择使用专有 API 与后端服务器通信,那么引入破坏性更改是否会更简单?答案是仍然不会!为了弄清楚为什么是这样,让我们假设我们是负责运行叫车应用程序的运营商。
我们可以利用的一种策略是将我们的移动应用程序与内置的强制更新机制一起发布。当应用程序启动时,它可以联系我们的 API 服务器并检查在继续之前是否必须安装更新。如果确实如此,应用程序可以不断提醒用户,直到他们同意更新。这肯定有效...除非,当然,我们的用户在周六晚上站在倾盆大雨中,拼命地想要叫一辆出租车。
在这种情况下,显示“请升级应用程序以继续”的消息无疑是糟糕的用户体验的标志,可能会触发许多用户立即切换到竞争对手的应用程序。此外,我们的一些用户可能是较老手机型号的拥有者,无法升级到我们应用程序的新版本,因为它们运行在较旧的硬件上,或者因为手机制造商撤销了用于签名不再支持的操作系统版本的密钥。
事后看来,API 的演变是不可避免的。因此,我们需要为 RESTful API 制定某种版本控制机制,这样我们就可以在引入破坏性更改的同时,仍然能够处理来自旧版 API 客户端的请求。
将 API 版本作为路由前缀包含在内
实施 API 版本控制最流行的方法是客户端将请求的 API 版本作为请求 API 端点路径的一部分。例如,/v1/account
和/v2/account
是用于检索用户账户详情的版本化端点。然而,当/account
端点在v2
前缀下挂载时,可能返回的负载与在v1
前缀下挂载时的负载完全不同。
版本命名方案的选择完全是任意的,由 API 设计者决定。常见的版本控制方案包括以下几种:
-
数字值;例如,
v4
-
API 发布日期;例如,
20200101
-
与新 API 发布同步的季节名称;例如,
spring2020
重要的是要意识到,这种特定的版本化方法违反了 URI 应引用唯一资源的原则。显然,在前面的例子中,/v1/account
和/v2/account
都引用了同一资源。更重要的是,这种方法的一个局限性是我们无法对单个 API 端点进行版本控制。
通过 HTTP Accept 头协商 API 版本
如果我们假设 API 服务器始终支持最新的 API 版本,那么作为路由部分版本的这种方法是可行的。在这种情况下,客户端可以简单地选择它可以工作的最高版本,而无需担心 API 托管的服务器。如果这个假设不成立怎么办?
假设我们正在开发一个用户可以下载并部署到他们自己的基础设施上的聊天服务器。除了聊天服务器包之外,我们还开发和维护官方客户端以连接到聊天服务器。聊天服务器公开了一个具有/messages/:channel
路径的 API 端点,客户端可以调用它以获取特定通道的消息列表。
在 API 的版本 1 中,每个返回的消息包含两个字段:发送消息的用户名称和消息本身。在 API 的版本 2 中,消息有效负载增加了两个额外的字段,即时间戳和用户头像图片的链接。
由于最终部署的服务器版本由终端用户控制,连接到服务器的客户端无法知道它们可以安全使用哪个 API 版本。诚然,我们可以提供一个专门的 API 端点,客户端可以使用它来查询服务器版本,然后根据服务器的响应选择 API 版本。然而,这种方法并不真正优雅,如果我们只想对特定端点进行版本控制而不是整个 API,那么这种方法实际上是无法扩展的。
显然,我们需要引入一种协商协议,允许客户端和服务器选择双方都理解的最大共同支持的 API 版本。事实上,HTTP 协议已经内置了这样的功能。
客户端在调用/messages/:channel
端点时,可以使用 HTTP Accept 头指定它支持的 API 版本。Accept 头的内 容必须遵循 RFC6838 中定义的媒体类型规范格式^([9])。对于基于 JSON 的 API,通常使用application/vnd.apiVersion+json
模板。
vnd
部分表示一个供应商特定的媒体类型。apiVersion
部分用于指定支持的版本号,而 +json
部分表示客户端期望服务器返回一个格式良好的 JSON 文档。媒体类型语法还允许客户端以逗号分隔的列表形式指定多个媒体类型。对于我们目前正在讨论的场景,一个支持两个 API 版本但更喜欢使用 API 版本 2 的客户端将使用值 application/vnd.v2+json,application/vnd.v1+json
来填充头信息。
服务器解析头信息值,定位最高支持的 API 版本,并将请求路由到该版本或在没有支持任何提议的客户端 API 版本时返回错误。当向客户端发送有效载荷时,服务器将 Content-Type
头的值设置为指示实际用于处理请求的 API 版本。客户端解析这些信息并使用它来正确地反序列化和处理响应有效载荷。
在 Go 中构建 RESTful API
现在,在 Go 中构建 RESTful API 是一个相当流畅的过程。如果你不介意一点点的辛苦(例如,使用正则表达式手动从请求路径中提取参数),你可以利用 Go 标准库中提供的 http.Mux
组件的功能来构建自己的 HTTP 路由器。
虽然从头开始构建自己的路由器无疑会是一个极好的学习经历,但你可能应该节省相当多的时间(和精力),并简单地使用一些流行的、经过实战考验的路由器包,例如 gorilla-mux ^([5]) 或 HttpRouter ^([3])。
另一方面,如果你喜欢完整的 Web 框架(将路由器、中间件以及可能是一个 ORM 合并到一个包中),你将会非常惊讶地发现有很多选择!基于 GitHub 上星标数量的一个指示性列表,流行的 Web 框架包肯定包括 buffalo ^([1])、revel ^([4]) 和 gin-gonic ^([10])。
所有这些包都有一个共同点:它们都是建立在 net/http 包之上的。如果你碰巧正在构建可能接收大量(即,每个服务器超过一百万个请求)并发请求的 API,你可能会发现 net/http 包实际上成为了一个瓶颈,限制了你的 API 的吞吐量。
如果你发现自己处于这种困境中,并且不介意针对与 net/http 包提供的 API 稍有不同的一些 API 进行编程,你应该看看 fasthttp ^([8]) 包。
在 gRPC 的帮助下构建 RPC 基础的 API
gRPC^([2]) 是一个由 Google 创建的现代开源框架,旨在协助实现基于 远程过程调用(RPC)范式的 API 的过程。与更适合连接基于 Web 的客户端(如浏览器)到后端服务的 REST 架构相比,gRPC 被提出作为一种跨平台和跨语言的替代方案,用于构建低延迟和高可扩展的分布式系统。
你知道 gRPC 中的字母 g 代表什么吗?许多人自然认为它代表 Google,这是一个合理的假设,因为 gRPC 最初是由 Google 发布的。其他人认为 gRPC 是一个递归缩写词,即 gRPC Remote Procedure Calls。
有趣的是,这两种解释都是错误的!根据 GitHub 上的 gRPC 文档,随着每个新的 gRPC 版本的发布,字母 g 的含义都会发生变化^([11])。
虽然 gRPC 构建高性能 API 以连接微服务是其核心功能,正如我们将在以下章节中看到的,它也可以作为现有 REST API 的替代品使用。
将 gRPC 与 REST 进行比较
虽然 REST 作为构建 API 的架构提供了许多好处,但也附带了一些需要注意的事项。让我们更详细地探讨这些注意事项。
REST API 通常是在 HTTP/1.x 协议之上实现的,该协议缺乏对管理和重用连接的适当支持。因此,客户端每次想要调用 API 端点时,都必须与后端服务器建立一个新的 TCP 连接,并执行完整的 TLS 握手。这一要求不仅给 API 调用带来了额外的延迟,还增加了后端服务器(或者在边缘进行 TLS 终止的情况下,是负载均衡器)的负载,因为 TLS 握手伴随着相当大的计算成本。
为了减轻这一问题,HTTP/1.1 引入了 HTTP 管道化模型。在这种连接管理模式下,客户端向服务器打开一个单一的 TCP 套接字,并通过它发送一系列连续的请求。服务器处理这一系列请求,并发送回一系列与客户端发送的请求顺序相匹配的响应。这种模型的一个局限性是它只能应用于幂等请求(HEAD、GET、PUT 和 DELETE)。此外,它还容易受到首部阻塞的影响,即一个执行时间较长的请求将延迟同一批次中后续请求的处理。
另一方面,gRPC 是建立在 HTTP/2 之上的,它定义了一种新的连接管理模型,即多路复用流。使用这种模型,gRPC 可以支持在单个 TCP 连接上交错和传输的双向流。这种方法完全避免了首部阻塞问题,并允许服务器向客户端发送推送通知。
HTTP/1.x 协议的文本性质以及将 JSON 作为请求和响应的主要序列化格式,使得 RESTful API 在旨在最大化吞吐量的用例中过于冗长。虽然 JSON 负载确实可以被压缩(例如,使用 gzip),但我们无法达到与协议缓冲区相同的效率,协议缓冲区是 gRPC 用于紧凑地编码客户端和服务器之间交换的消息的二进制格式。
最后,RESTful API 不强制要求请求和响应负载具有特定的结构。正确解包 JSON 负载并将负载值强制转换为它们所写语言的正确类型取决于客户端和服务器。这种方法可能会导致错误,甚至更糟,数据损坏。
例如,如果服务器尝试将 64 位整数解包到 32 位整数变量中,如果原始值无法强制转换为 32 位,则值可能会被截断。另一方面,gRPC 使用强类型消息,无论客户端或服务器使用的编程语言如何,它总是将消息解包到正确的类型。
使用协议缓冲区定义消息
协议缓冲区是语言和平台中立的机制,以非常高效的方式序列化结构化数据。为了实现语言中立性,协议缓冲区使用高级 接口定义语言(IDL)描述消息和 RPC 服务。
要开始使用协议缓冲区,我们需要为我们的开发环境安装 protoc 编译器。您可以通过从源代码编译或从github.com/protocolbuffers/protobuf/releases
为您的平台安装预构建的二进制发布版来完成此操作。
此外,您还需要通过执行以下命令来安装 protoc 编译器的 Go 输出生成器以及用于与协议缓冲区和 gRPC 框架一起工作的 Go 包:
go get -u google.golang.org/grpc
go get -u github.com/golang/protobuf/protoc-gen-go
协议缓冲区消息定义通常位于具有 .proto
扩展名的文件中。它们由专门的工具处理,这些工具将定义编译成特定语言类型,我们可以使用这些类型来构建我们的应用程序。对于 Go,protoc 编译器通常如下调用:
protoc --go_out=plugins=grpc:. -I. some-file.proto
--go_out
参数指示 protoc 编译器启用 Go 输出生成器。它期望一个以冒号字符结尾的逗号分隔的选项列表。在上面的例子中,选项列表包括一个用于启用 gRPC 插件的插件选项。冒号字符后面的参数指定了由编译器生成的任何文件的存储位置。在这里,我们将其设置为当前工作目录。
-I
参数可以用来指定编译器在解析包含指令时扫描的附加包含路径。在这个例子中,我们将当前工作目录添加到包含路径中。
最后,protoc
编译器的最后一个参数是要编译的 .proto
文件名。
定义消息
那么,协议缓冲消息定义看起来是什么样子呢?这里有一个简短的例子:
syntax = "proto3";
package geocoding;
message Address {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
在前面的定义中,第一行宣布了文件其余部分将要使用的协议缓冲格式版本,通知编译器。在这个例子中,我们使用的是版本 3,这是最新版本,也是任何新项目应该使用的版本。第二行定义了包名,它将被用作生成的协议缓冲定义的容器。正如你可能猜到的,使用包可以避免定义具有相同名称的消息的项目之间的冲突。
消息定义以 message
关键字开始,后面跟着消息名称和一系列(字段类型,字段名称)元组。协议缓冲编译器识别以下内置类型集 ^([7]):
.proto 类型 | 等效 Go 类型 | 注意事项 |
---|---|---|
double |
float64 |
|
float |
float32 |
|
int32 |
int32 |
使用可变长度编码 |
int64 |
int64 |
使用可变长度编码 |
uint32 |
uint32 |
使用可变长度编码 |
uint64 |
uint64 |
使用可变长度编码 |
sint32 |
int32 |
比使用 int32 更高效地存储负整数值 |
sint64 |
int64 |
比使用 int64 更高效地存储负整数值 |
fixed32 |
uint32 |
总是 4 个字节;对于大于 2²⁸ 的值,比 uint32 更高效 |
fixed64 |
uint64 |
总是 8 个字节;对于大于 2⁵⁶ 的值,比 uint64 更高效 |
sfixed32 |
int32 |
总是 4 个字节 |
sfixed64 |
int64 |
总是 8 个字节 |
bool |
bool |
|
string |
string |
|
bytes |
[]byte |
正如我们在前面的章节中提到的,协议缓冲尝试将消息编码成一个紧凑且空间高效的格式。为此,整数通常使用可变长度编码进行序列化。由于这种方法对于负值来说效果不佳,协议缓冲还定义了用于(主要是)负值的辅助类型(例如,sint32
和 sint64
),它们以不同且更高效的方式进行编码。
当然,我们不仅限于使用内置类型。我们还可以将已定义的消息类型用作字段类型!实际上,这些定义甚至可能存在于一个单独的 .proto
文件中,我们可以通过以下方式包含它:
import "google/protobuf/timestamp.proto";
message Record {
bytes data = 1;
google.protobuf.Timestamp created_at = 2;
}
协议缓冲的另一个有趣特性是枚举,它允许我们定义只能从固定、预定义值列表中分配值的字段。以下代码扩展了 Address
消息定义,以便它包括一个 type
字段,帮助我们识别地址类型:
message Address {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
AddressType type = 4; // We can only assign an address type value to this field.
}
enum AddressType {
UNKNOWN = 0;
HOME = 1;
BUSINESS = 2;
}
enum
块定义了可以分配给新引入的类型字段的常量列表。需要记住的一个重要事项是,每个枚举列表必须包括一个映射到零值的常量作为其第一个元素。这作为字段的默认值。
版本化消息定义
协议缓冲区消息中的每个字段都被分配了一个唯一的 ID。最常见的模式是从 1 开始按增量分配 ID。当一个字段被序列化为线格式时,序列化器会发出一个包含有关字段类型、其大小(对于可变大小的字段)和其 ID 的小头信息。
接收器扫描头信息并检查具有该 ID 的字段是否存在于其本地消息定义中。如果是这样,字段值将从流反序列化到适当的字段。否则,接收器使用头中的信息来跳过它不认识的任何字段。
此功能非常重要,因为它构成了消息定义版本化的基础。由于消息定义会随着时间的推移而演变,可以添加新字段或重新排序现有字段,而不会破坏使用从较旧的 .proto
文件编译的消息的现有消费者。
表示集合
更重要的是,协议缓冲区还可以模拟两种类型的集合,即列表和映射。要创建一个项目列表,我们只需要将repeated
关键字作为字段类型的开头即可。另一方面,映射使用特殊符号定义,即map<K, V>
,其中K
和V
代表映射键和值的类型。以下是一个定义集合的示例片段:
message User {
string id = 1;
string name = 2;
}
message Users {
repeated User user_list = 1;
map<string, User> user_by_id = 2;
}
当编译为 Go 代码时,Users
消息的字段将被映射到[]User
类型和map[string]User
类型。
模型字段联合
许多类型的应用程序的一个常见需求是能够模拟联合。联合是一种特殊类型的值,它可以有多种表示形式,所有这些表示形式都指向内存中的同一位置。使用共享内存意味着每次我们将值写入特定的联合字段时,尝试读取其他联合字段之一都将导致数据混乱。
联合的概念很好地扩展到了协议缓冲区。如果你正在处理包含多个字段的消息,其中在任何给定时间最多只能设置一个字段,你可以通过将这些字段组合成一个联合来减少所需的内存量。
联合定义以oneof
关键字开始,后跟字段名和组成联合的字段列表。以下是一个简单示例,演示了一个非常常见的 API 用例:
message CreateAccountResponse {
string correlation_id = 1;
oneof payload {
Account account = 2;
Error error = 3;
}
}
在这个示例中,所有响应都有一个相关的关联 ID 值。然而,根据 API 调用调用的结果,响应负载将包含一个Account
或一个Error
。
在编译上述消息定义之后,protoc 编译器将生成两个特殊类型,即 CreateAccountResponse_Account
和 CreateAccountResponse_Error
,这些类型可以分配给 CreateAccountResponse
类型的 Payload
字段:
type CreateAccountResponse_Account struct {
Account *Account `protobuf:"bytes,1,opt,name=account,proto3,oneof"`
}
type CreateAccountResponse_Error struct {
Error *Error `protobuf:"bytes,2,opt,name=error,proto3,oneof"`
}
为了防止其他类型分配给 Payload
字段,protoc 编译器使用了一个有趣的技巧:它定义了一个具有未导出虚拟方法的私有接口,并安排它只让前两个类型定义实现它:
type isCreateAccountResponse_Payload interface {
isCreateAccountResponse_Payload()
}
func (*CreateAccountResponse_Account) isCreateAccountResponse_Payload() {}
func (*CreateAccountResponse_Error) isCreateAccountResponse_Payload() {}
Protoc 编译器将前面代码片段中提到的接口指定为 Payload
字段的类型,因此将分配任何其他类型的字段视为编译时错误。此外,为了便于检索联合值,编译器还会在 CreateAccountResponse
类型上生成 GetAccount
和 GetError
辅助函数。这些辅助函数会查看 Payload
字段的内容,并返回分配的值(Account
或 Error
),如果没有分配该类型的值,则返回 nil
。
Any
类型
在构建事件驱动系统时,一个常见的模式是定义一个顶层消息,该消息作为不同事件负载的封装。由于新的事件类型可能在任何时候添加(或删除),使用联合体显然是不够的。此外,以下是从事件消费者的角度出发的:
-
消费者可能使用比事件生产者更旧的
.proto
版本。他们遇到不知道如何解码的事件负载是完全可能的。 -
一些消费者可能只对处理事件的子集感兴趣。在这种情况下,消费者应该只解码他们关心的消息,并跳过所有其他消息。
为了应对这种情况,我们可以使用 Any
类型来定义我们的信封消息:
import "google/protobuf/any.proto";
message Envelope {
string id = 1;
google.protobuf.Any payload = 2;
}
如其名所示,Any
类型可以存储任何协议缓冲区消息。内部,这是通过存储消息的序列化版本以及描述存储在其中的消息类型的字符串标识符来实现的。类型标识符的形式为 URL,它通过将 type.googleapis.com/
与消息名称连接起来构建。ptypes
包(您可以在 www.github.com/golang/protobuf/ptypes 找到它)为处理 Any
消息提供了几个有用的辅助函数。
以下代码是填充 Envelope
实例的示例:
func wrapInEnvelope(id string, payload proto.Message) (*Envelope, error) {
any, err := ptypes.MarshalAny(payload)
if err != nil {
return nil, err
}
return &Envelope{
Id: id,
Payload: any,
}, nil
}
MarshalAny
辅助函数接受任何实现 proto.Message
接口的价值并将其序列化为 Any
消息,然后我们将其分配给 Envelope
类型的 Payload
字段。
在消费者端,我们可以使用以下代码块来处理传入的信封:
func handleEnvelope(env *Envelope) error {
if env.Payload == nil {
return nil
}
switch env.Payload.GetTypeUrl() {
case "type.googleapis.com/Record":
var rec *Record
if err := ptypes.UnmarshalAny(env.Payload, &rec); err != nil {
return err
}
return handleRecord(rec)
default:
return ErrUnknownMessageType
}
}
实质上,处理程序根据消息类型进行切换,并使用UnmarshalAny
辅助函数来反序列化和处理受支持的消息。另一方面,如果消息类型是未知的或不是消费者感兴趣的,他们可以选择跳过它或返回一个错误,这正是前面代码中发生的情况。
在使用协议缓冲定义语言定义了我们想要在应用程序中使用的消息集之后,下一步逻辑步骤是创建使用它们的 RPC!在以下部分,我们将探讨如何启用*grpc*
插件,并让 protoc 编译器自动为我们生成所需的 RPC 代码存根。
实现 RPC 服务
gRPC 框架利用了HTTP/2
的流多路复用能力,以便它可以处理同步和异步 RPC。当使用启用了*grpc*
插件的 protoc 编译器调用时,将生成以下内容:
-
编译后的
.proto
文件中每个 RPC 服务定义的客户端和服务器接口。对于名为Foo
的服务,编译器将生成一个FooServer
和一个FooClient
接口。这是一个非常有用的功能,因为它允许我们在测试时将模拟客户端注入到我们的代码中。 -
每个服务都遵循生成的客户端接口的完整客户端实现。
-
一个辅助函数,用于将我们的服务实现注册到 gRPC 服务器实例。再次强调,对于名为
Foo
的服务,编译器将生成一个签名类似于RegisterFooServer(*grpc.Server, FooServer)
的函数。
以下简短的代码片段演示了我们可以如何创建一个新的 gRPC 服务器,注册我们的Foo
服务实现,并开始服务传入的 RPC:
func serve(addr string, serverImpl FooServer) error {
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
grpcServer := grpc.NewServer()
RegisterFooServer(grpcServer, serverImpl)
return grpcServer.Serve(l)
}
在以下四个部分中,我们将检查 gRPC 框架支持的 RPC 模式的不同类型。
单一 RPC
单一 RPC 等同于在传统 RESTful API 中使用的请求-响应模型。在以下示例中,我们定义了一个名为AccountService
的服务,它公开了一个CreateAccount
方法,该方法接收CreateAccountRequest
作为输入,并返回一个CreateAccountResponse
给调用者:
message CreateAccountRequest {
string user_name = 1;
string password = 2;
string email = 3;
}
message CreateAccountResponse {
string account_id = 1;
}
service AccountService {
rpc CreateAccount (CreateAccountRequest) returns (CreateAccountResponse);
}
定义服务器处理程序与常规 RESTful API 也非常相似。考虑以下代码:
var _ AccountServiceServer = (*server)(nil)
func (*server) CreateAccount(_ context.Context, req *CreateAccountRequest) (*CreateAccountResponse, error) {
accountID, err := createAccount(req)
if err != nil {
return nil, err
}
return &CreateAccountResponse{AccountId: accountID}, nil
}
为了能够将我们的服务器实现注册到 gRPC,我们需要实现AccountServiceServer
接口。在列表中给出的服务器端实现(在前面代码片段中给出)接收一个CreateAccountRequest
消息作为参数。它调用createAccount
辅助函数,验证请求,创建新的账户记录,并返回其 ID。然后,创建一个新的CreateAccountResponse
实例并返回给客户端。
在客户端,事情也同样简单。以下代码展示了accountAPI
类型仅仅提供了一个友好的 API 来抽象对服务器的 RPC 调用:
func (a *accountAPI) CreateAccount(account model.Account) (string, error) {
req := makeCreateAccountRequest(account)
res, err := a.accountCli.CreateAccount(context.Background(), req)
if err != nil {
return "", err
}
return res.AccountId, nil
}
该方法接收一个描述要创建的账户的模型实例,将其转换为CreateAccountInstance
,并通过在构造时注入的AccountServiceClient
实例将其发送到服务器。收到响应后,客户端提取分配给新账户的 ID,并将其返回给调用者。
服务器流式 RPC
在服务器流式 RPC 场景中,客户端在服务器上发起 RPC 调用,并接收一系列响应。客户端在流上阻塞读取,直到有新数据可用或服务器关闭流。在后一种情况下,客户端读取请求将返回一个io.EOF
错误,以指示没有更多数据可用。
在以下示例中,我们定义了一个服务,该服务流式传输特定加密货币的价格更新:
message CryptoPriceRequest {
string crypto_type = 1;
}
message CryptoPrice {
double price = 1;
}
service PriceService {
rpc StreamCryptoPrice (CryptoPriceRequest) returns (stream CryptoPrice);
}
以下代码显示了StreamCryptoPrice
RPC 的服务器端实现:
var _ PriceServiceServer = (*server)(nil)
func (*server) StreamCryptoPrice(req *CryptoPriceRequest, resSrv PriceService_StreamCryptoPriceServer) error {
for price := range priceStreamFor(req.CryptoType) {
if err := resSrv.Send(&CryptoPrice{Price: price}); err != nil {
return err
}
}
return nil
}
在前面的代码片段中显示的StreamCryptoPrice
签名与我们在上一节中检查的单例 RPC 签名不同。除了传入的请求外,处理程序还接收一个由 protoc 编译器为我们创建的辅助类型,以处理此特定 RPC 调用的流式传输方面。
服务器处理程序调用priceStreamFor
辅助函数(实现省略)以获取一个通道,其中发布了对请求货币类型的价格更新。一旦收到新的价格,服务器代码就会在提供的流辅助程序上调用Send
方法,向客户端流式传输新的响应。一旦服务器处理程序返回(例如,当价格流通道关闭时),gRPC 将自动关闭流并向客户端发送一个io.EOF
错误,其实现如下代码块所示:
func (a *priceAPI) ListPrices(cryptoType string) error {
stream, err := a.priceCli.StreamCryptoPrice(context.Background(), &CryptoPriceRequest{CryptoType: cryptoType})
if err != nil {
return err
}
for {
res, err := stream.Recv()
if err != nil {
if err == io.EOF {
return nil
}
return err
}
updateListing(cryptoType, res.Price)
}
}
客户端 API 包装器使用注入的PriceServiceClient
实例启动 RPC 调用,并获取一个可以从中读取价格更新的流。然后客户端进入一个无限for
循环,在其中它阻塞在流的Recv
方法上,直到接收到新的价格更新(或错误)。
客户端流式 RPC
客户端流式 RPC 与服务器流式 RPC 相反。在这种情况下,客户端向服务器流式传输数据。一旦服务器收到所有数据,它就会回复一个单一响应。
在以下示例中,我们定义了一个服务,该服务从客户端接收一系列度量值,并返回整个批次的聚合统计信息(count
、min
、max
和avg
值):
syntax = "proto3";
message Observation {
double value = 1;
}
message StatsResponse {
int32 count = 1;
double min = 2;
double max = 3;
double avg = 4;
}
service StatsService {
rpc CalculateStats (stream Observation) returns (StatsResponse);
}
以下代码块构建在为该服务提供的 RPC 客户端功能之上,并公开了一个 API,用于计算从调用者提供的 Go 通道读取的值流统计信息:
func (a *statsAPI) GetStats(valueCh <-chan float32) (*Stats, error) {
stream, err := a.statsCli.CalculateStats(context.Background())
if err != nil {
return nil, err
}
for val := range valueCh {
if err := stream.Send(&Observation{Value: val}); err != nil {
return nil, err
}
}
res, err := stream.CloseAndRecv()
if err != nil {
return nil, err
}
return makeStats(res), err
}
如前述代码片段所示,GetStats
实现最初调用底层 RPC 客户端的CalculateStats
方法,并获取一个(客户端)流辅助工具。借助range
循环,将提供的valueCh
中的每个值包装成一个新的Observation
消息,并将其传输到服务器进行处理。一旦客户端已将所有观察到的值发送到服务器,它将调用CloseAndRecv
方法,该方法执行两个任务:
-
它通知服务器没有更多数据可用
-
它会阻塞,直到服务器返回一个
StatsResponse
接下来,我们将查看上述 RPC 的服务器端实现:
var _ StatsServiceServer = (*server)(nil)
func (*server) CalculateStats(statsSrv StatsService_CalculateStatsServer) error {
var observations []*Observation
for {
stat, err := statsSrv.Recv()
if err == nil {
if err == io.EOF {
return statsSrv.SendAndClose(calcStats(observations))
}
return err
}
observations = append(observations, stat)
}
}
服务器从传递给CalculateStats
的流中读取传入的Observation
实例,并将它们追加到一个切片中。一旦服务器检测到(通过io.EOF
错误的 presence)客户端已传输所有数据,它将收集到的观察结果切片传递给calcStats
辅助工具,该工具计算批次的统计数据,并以StatsResponse
消息的形式返回,服务器将此消息转发给客户端。
双向流 RPC
我们将要探索的最后一种 RPC 模式是双向流。这种模式结合了客户端和服务器端流,为我们提供了两个独立的通道,客户端和服务器可以在其中异步发布和消费消息。
为了了解这种模式的工作原理,让我们检查异步Echo
服务的定义:
message EchoMessage {
string message = 1;
}
service EchoService {
rpc Echo (stream EchoMessage) returns (stream EchoMessage);
}
回声服务的服务器端逻辑并不那么有趣。如下面的代码片段所示,服务器运行一个for
循环,从中读取客户端的下一个消息并将其回显。服务器的for
循环会一直执行,直到客户端终止 RPC:
func (*server) Echo(echoSrv EchoService_EchoServer) error {
for {
msg, err := echoSrv.Recv()
if err != nil {
if err == io.EOF {
return nil
}
return err
}
if err := echoSrv.Send(msg); err != nil {
return err
}
}
}
现在,让我们看一下客户端实现,它似乎要复杂一些,因为我们需要处理两个异步流。在典型的双向 RPC 实现中,我们会启动一个 goroutine 来处理每个流的端点。然而,为了使这个例子尽可能简单,我们将只使用一个 goroutine 来处理来自服务器的回声响应,如下面的代码片段所示:
func (a *echoAPI) Echo(msgCount int) error {
stream, err := a.echoCLI.Echo(context.Background())
if err != nil {
return err
}
errCh := make(chan error, 1)
go processEcho(stream, errCh)
if err := sendEcho(stream, msgCount); err != nil {
return err
}
for err := range errCh {
return err
}
return nil
}
如前述代码片段所示,客户端在 echo 服务客户端上调用Echo
方法,并获取一个辅助对象(分配给名为stream
的变量),以帮助我们向服务器发送和接收流数据。然后我们启动一个 goroutine 来执行processEcho
,这是负责处理传入回声响应的函数。该函数接收我们作为参数获得的stream
对象和一个缓冲错误通道,用于报告接收到的错误。
以下代码显示了processEcho
的实现:
func processEcho(stream EchoService_EchoClient, errCh chan<- error) {
defer close(errCh)
for {
msg, err := stream.Recv()
if err != nil {
if err != io.EOF {
errCh <- err
}
return
}
fmt.Printf("Received echo for: %q\n", msg)
}
}
接收端与服务器端实现几乎相同。我们持续从流中读取回声消息,直到我们得到一个错误。如果Recv
方法返回的错误不是io.EOF
,我们在返回之前将其写入错误通道。
注意,在前面的代码片段中,当函数返回时,错误通道总是关闭的。Echo
方法利用这一行为,直到processEcho
返回并使用for
循环遍历errCh
来出队发出的错误。
当processEcho
函数在后台运行时,代码会调用sendEcho
,这是一个同步函数,它会发送msgCount
个回声请求,然后返回:
func sendEcho(stream EchoService_EchoClient, msgCount int) error {
for i := 0; i < msgCount; i++ {
if err := stream.Send(&EchoMessage{Message: fmt.Sprint(i)}); err != nil {
return err
}
}
return stream.CloseSend()
}
那么,我们如何终止这个 RPC?调用CloseSend
方法终止到服务器的上游通道,并导致服务器端代码中的Recv
方法返回一个io.EOF
错误。这触发了服务器处理器的退出,随后关闭其到客户端的下游通道。
sendEcho
函数返回到Echo
,然后等待processEcho
退出。一旦服务器终止了下游通道,processEcho
中的Recv
调用也会返回一个io.EOF
错误,并导致processEcho
协程返回。这一步解除了Echo
调用的阻塞,现在它可以返回到其调用者。
gRPC API 的安全考虑
protoc 编译器为您生成的每个 RPC 客户端构造函数都期望一个grpc.Connection
参数。这是故意的,因为单个远程服务器可能公开多个 RPC 服务。鉴于 HTTP/2 支持请求多路复用,实例化一个连接到服务器并共享给各个 RPC 客户端是有意义的。
那么,我们如何获取一个grpc.Connection
实例?grpc
包提供了一个名为Dial
的便利助手,它处理建立到 gRPC 服务器的连接的所有底层细节。Dial
函数期望我们想要连接的 gRPC 服务器的地址和一个grpc.DialOption
值的可变列表。
在这一点上,需要注意的是,gRPC 拨号器假定远程服务器将使用 TLS 进行加密,如果情况不是这样,它将无法建立连接。我们肯定可以想出一些不需要使用 TLS 的场景:
-
我们可能会在我们的开发机器上运行一个本地 gRPC 服务器
-
我们可能会在测试中启动一个 gRPC 服务器
-
我们所有的后端服务可能都在一个无法从互联网访问的私有子网中运行
为了应对此类用例,我们可以通过向Dial
函数提供grpc.WithInsecure()
拨号选项来强制 gRPC 与非 TLS 服务器建立连接。
如果你选择推荐的方法并在所有地方使用 TLS,你将惊喜地发现,我们在本章开头讨论的用于保护 RESTful API 的方法也可以应用于 gRPC!gRPC 框架允许你在两个不同的级别配置安全性,即连接级别和应用程序级别。
在连接级别,gRPC 允许我们使用grpc.WithTransportCredentials
拨号选项手动配置 TLS 握手的选项,该选项接受一个credentials.TransportCredentials
参数。grpc/credentials
包包含从证书(如果您希望通过配置 TLS 证书实现客户端身份验证)和tls.Config
实例(用于实现服务器证书固定)生成TransportCredentials
的辅助工具。
就应用程序级别的安全性而言,gRPC 提供了grpc.WithPerRPCCredentials
拨号选项。此选项接受一个credentials.PerRPCCredentials
实例,并允许 gRPC 客户端自动将提供的凭据集注入到每个出站 RPC 中。grpc/credentials/oauth
包提供了处理不同授权机制的辅助工具。例如,oauth.NewOauthAccess
函数允许我们使用通过三脚 OAuth2 流程从我们的 RPC 中获得的oauth2.Token
实例。
在另一端,服务器使用专门的中间件(gRPC 将中间件称为请求拦截器)来访问客户端提供的凭据并控制对 RPC 方法的访问。
将“Links 'R' Us”组件与底层数据存储解耦
我们在第七章,“数据处理管道”中创建的链接爬虫组件,以及我们在第八章,“基于图的数据处理”中构建的 PageRank 计算器组件,都被设计成可以与第六章,“构建持久层”中提到的数据存储实现之一一起工作。
因此,在配置这些组件时,我们预计需要提供满足graph.Graph
和index.Indexer
接口的合适具体数据存储实现。如果我们正在构建一个单体应用,我们通常会在这个main
包内部执行这部分初始化,如下所示:
-
导入我们希望在应用程序中使用的具有数据存储驱动程序的包(例如,由CockroachDB支持的链接图和由Elasticsearch支持的文本索引器)。
-
创建新的驱动程序实例,并根据静态或外部提供的特定于驱动程序的设置集进行相应配置(例如,CockroachDB 或 Elasticsearch 集群的端点)。
-
使用我们刚刚创建的数据存储实例初始化链接爬虫和 PageRank 计算器组件。这可以开箱即用,因为第六章,构建持久化层中所有数据存储实现都满足上述接口,可以直接分配给作为组件构造函数参数传递的配置对象。
正如我们将在下一章中看到的,通过让我们的代码导入所有支持的链接图和文本索引器提供者实现包,并在运行时根据命令行标志的值动态实例化其中之一,我们可以使我们的应用程序更加灵活。
这种方法的一个问题是它引入了对特定数据存储实现的强耦合。如果我们设计要求涉及创建多个都需要使用相同数据存储提供者的应用程序,那会怎样?
要应用上述步骤,我们需要在所有我们的应用程序中重复相同的初始化逻辑。这将违反不要重复自己(DRY)原则,并使我们的代码库更难维护。此外,考虑如果我们被要求添加对新数据存储实现的支持,所需的努力量。我们实际上需要修改并重新编译所有我们的应用程序!
鉴于应用程序和数据存储之间强耦合相关的问题列表,作为软件工程师,我们在设计新系统时有哪些选项来减少或理想情况下消除这种耦合?一个优雅的解决方案是创建一个独立的代理服务,通过 REST 或(更理想的是)基于 gRPC 的 API 提供对特定数据存储实现的访问。这种模式允许我们在任何时间点有效地切换到不同的数据存储,而无需重新编译任何使用 API 的应用程序。
在本章的最后部分,我们将应用我们迄今为止所学的内容,构建基于 gRPC 的 API,以便我们可以通过网络访问链接图和文本索引器组件。为了尽可能保持一致性,RPC 名称以及客户端和服务器之间交换的消息的字段列表将模仿由graph.Graph
和index.Indexer
接口定义的方法签名。
根据前几节的说明,我们将使用协议缓冲定义语言来指定我们 API 的 RPCs。
定义用于访问远程链接图实例的 RPCs
我们将要设计的第一个 API 将允许我们的项目应用程序通过网络链接访问满足graph.Graph
接口的任何具体链接图实现。以下代码片段概述了我们需要的 RPC 端点的协议缓冲定义:
syntax="proto3";
package proto;
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
service LinkGraph {
rpc UpsertLink(Link) returns (Link);
rpc UpsertEdge(Edge) returns (Edge);
rpc RemoveStaleEdges(RemoveStaleEdgesQuery) returns (google.protobuf.Empty);
rpc Links(Range) returns (stream Link);
rpc Edges(Range) returns (stream Edge);
}
UpsertLink
调用向图中插入新的链接或更新现有链接的详细信息。该调用接收并返回一个 Link
消息,其定义如下所示:
message Link {
bytes uuid = 1;
string url = 2;
google.protobuf.Timestamp retrieved_at = 3;
}
Link
消息包含以下信息:
-
链接的 UUID。鉴于协议缓冲区不提供用于存储 UUID(16 字节值)的本地类型,我们将它们表示为 字节切片。
-
链接的 URL。
-
爬虫上次检索链接的时间戳。
UpsertEdge
调用向图中插入新的边或更新现有边的详细信息。该调用接收并返回一个具有以下定义的 Edge
消息:
message Edge {
bytes uuid = 1;
bytes src_uuid = 2;
bytes dst_uuid = 3;
google.protobuf.Timestamp updated_at = 4;
}
每个 Edge
消息包含以下信息:
-
边的 UUID
-
源点和目标顶点的 UUID
-
一个时间戳,指示爬虫上次更新边的时间
我们列表中的下一个调用是 RemoveStaleEdges
。如您从第七章 [51dcc0d4-2ba3-4db9-83f7-fcf73a33aa74.xhtml] 的 数据处理管道 中回忆的那样,这个调用是由网络爬虫组件在每次检索链接图中网页的最新内容时丢弃缺失(过时)边所必需的。
这个特定的 RPC 有趣之处在于,虽然它接受 RemoveStaleEdgesQuery
消息作为输入,但它实际上不需要向调用者返回结果。然而,由于 gRPC 强制要求所有 RPC 都必须向调用者返回某些消息,我们将使用 google.protobuf.Empty
(一个用于空/空消息的占位符类型)作为 RPC 的返回类型。
让我们快速看一下 RemoveStaleEdgesQuery
消息的定义:
message RemoveStaleEdgesQuery {
bytes from_uuid = 1;
google.protobuf.Timestamp updated_before = 2;
}
我们 RPC 列表中的最后两个方法是 Links
和 Edges
。这两个调用都期望客户端提供 Range
消息作为输入。此消息允许客户端指定服务器将通过底层具体链接图实现的同名方法传递的参数集,即选择要返回的实体(链接或边)的 UUID 范围以及用于过滤具有更晚检索/更新值的实体的截止时间戳。
下面的片段概述了 Range
消息的定义:
message Range {
bytes from_uuid = 1;
bytes to_uuid = 2;
// Return results before this filter timestamp.
google.protobuf.Timestamp filter = 3;
}
到目前为止,我们检查的所有 RPC 都是单参数的。然而,Links
和 Edges
调用不同之处在于它们被声明为 服务器流式 RPC。流的使用允许客户端更有效地处理返回的链接和边列表。
在下一节中,我们将检查访问文本索引器的 RPC 定义。
定义用于访问文本索引器实例的 RPC
第二个我们将要设计的 API 将允许我们的项目应用程序通过网络链接访问满足 index.Indexer
接口的任何具体链接图实现。下面的片段概述了我们将需要的 RPC 端点协议缓冲区定义:
syntax="proto3";
package proto;
import "google/protobuf/timestamp.proto";
import "google/protobuf/empty.proto";
service TextIndexer {
rpc Index(Document) returns (Document);
rpc UpdateScore(UpdateScoreRequest) returns (google.protobuf.Empty);
rpc Search(Query) returns (stream QueryResult);
}
Index
方法将文档插入到搜索索引中,或者如果文档已存在,则触发重新索引操作。正如您从其方法定义中看到的那样,调用期望并返回一个Document
消息,如下面的代码片段所示:
message Document {
bytes link_id = 1;
string url = 2;
string title = 3;
string content = 4;
google.protobuf.Timestamp indexed_at = 5;
}
成功调用Index
将返回与输入相同的Document
。然而,远程服务器还会填充/更新文档的indexed_at
字段。
我们将要检查的下一个调用是UpdateScore
。此调用将由 PageRank 计算器组件用来设置特定文档的 PageRank 分数。该调用接受一个UpdateScoreRequest
消息并返回空(因此使用了google.protobuf.Empty
占位符消息):
message UpdateScoreRequest {
bytes link_id = 1;
double page_rank_score = 2;
}
我们将要讨论的最后一个,也是更有趣的 RPC 方法是Search
。对Search
的调用接受一个Query
消息作为输入,并返回一系列QueryResult
响应的流:
message Query {
Type type = 1;
string expression = 2;
uint64 offset = 3;
enum Type {
MATCH = 0;
PHRASE = 1;
}
}
message QueryResult {
oneof result {
uint64 doc_count = 1;
Document doc = 2;
}
}
如您所见,Query
和QueryResult
的消息定义要复杂一些。首先,Query
消息定义了一个嵌套枚举来指定要执行的查询类型。默认情况下,查询表达式被视为基于关键字的常规搜索(MATCH
是type
字段的默认值)。
然而,调用者也可以通过将PHRASE
指定为type
字段的值来请求基于短语的搜索。此外,调用者还被允许指定一个偏移量,并指示服务器跳过返回结果集顶部的一定数量的结果。这种机制可以由客户端用来实现分页。
QueryResult
消息使用了协议缓冲区的one-of特性。此消息可以包含一个uint64
值,该值描述了查询匹配的总文档数或结果集的下一个Document
。我们的服务器实现将使用以下简单的协议将结果流式传输到客户端:
-
结果流中的第一个消息将始终描述搜索的总结果数。如果没有文档匹配搜索查询,服务器将通过将
doc_count
字段设置为值0
来表示这一点。 -
每个后续的消息都将推送与客户端匹配的
Document
。
通过 gRPC 访问数据存储的高级客户端
protoc
编译器,给定前两个部分中的 RPC 定义作为输入,将为数据存储代理服务生成客户端和所需的服务器存根。
从 API 服务器的角度来看,每个 RPC 方法不过是调用底层具体存储实现中同名方法的包装器。更具体地说,为了实现名为X的 RPC 方法,我们执行以下步骤:
-
将 RPC 输入消息的字段(如有必要)转换为包装方法X期望的值。
-
调用 X 时,注意任何错误。
-
将 X 的输出转换为适当的 RPC 返回消息。
-
将生成的响应返回给客户端。
如您可能已经猜到的,我们的服务器实现将主要是由一些无聊的样板代码组成,这些代码使用我们刚才描述的模板。为了节省空间,我们将从本书中省略实现细节。然而,您可以通过检查本书 GitHub 仓库中Chapter09/linksrus/linkgraphapi
和Chapter09/linksrus/textindexerapi
包中的server.go
文件来查看两个 API 服务器的完整源代码。
在放置好 RPC 服务器之后,我们的应用程序可以与其建立连接,并使用protoc编译器为我们生成的 gRPC 客户端来访问另一端的链接图和文本索引器组件。我们当前实现的一个不幸的副作用是,由于自动生成的 gRPC 客户端没有实现graph.Graph
和index.Indexer
接口,我们无法将它们用作配置爬虫和 PageRank 计算器组件的即插即用替代品。
幸运的是,有一种优雅的方法可以绕过这种不便之处!每个 API 的包也需要定义一个高级客户端,该客户端封装了我们由 protoc 编译器生成的 gRPC 客户端,并根据 API 的不同,实现graph.Graph
接口或index.Indexer
接口。
在幕后,高级客户端将透明地处理与远程 gRPC 服务器所有的交互。虽然这种方法确实需要额外的开发工作,但它使得高级客户端看起来就像是我们可以注入到Links 'R' Us 组件中的另一个图或索引器实现,而无需任何代码更改。在第十一章“将单体拆分为微服务”中,我们将利用这个技巧将 Links 'R' Us 项目拆分为一组微服务!
与服务器实现类似,高级客户端也包含相当多的重复样板代码,因此为了简洁起见,我们也将省略其列表。两个高级客户端的完整源代码可以在名为client.go
的文件中找到,该文件位于服务器实现相同的目录中。
摘要
在本章的第一部分,我们讨论了 RESTful API 背后的关键原则。我们关注了处理诸如安全性和版本控制等热点问题的有效策略。然后,我们分析了与 gRPC 框架使用的基于 RPC 的范式相比,RESTful API 的优缺点,并突出了使 gRPC 更适合构建高性能服务的关键差异。
现在你已经到达本章的结尾,你应该熟悉协议缓冲定义语言,并知道如何利用 gRPC 框架支持的各项特性来构建基于 RPC 模式的高性能安全 API。
在下一章中,我们将了解如何执行我们软件的密封构建,将其打包为容器镜像,并在 Kubernetes 集群上部署。
问题
-
描述用户实体的 CRUD 端点。
-
解释基本认证在 TLS 中如何帮助我们保护 API。
-
TLS 连接是否免疫于窃听?
-
描述三脚 OAuth2 流程的步骤。
-
与 JSON 相比,使用协议缓冲作为请求/响应负载有什么好处?
-
描述 gRPC 支持的不同 RPC 模式。
进一步阅读
-
Go 语言 Web 开发生态系统,旨在使您的生活更轻松; 更多信息请参考以下链接:
github.com/gobuffalo/buffalo
。 -
一个高性能、开源的通用 RPC 框架; 更多信息请参考以下链接:
www.grpc.io
。 -
一个高性能且扩展性良好的 HTTP 请求路由器; 更多信息请参考以下链接:
github.com/julienschmidt/httprouter
。 -
一个针对 Go 语言的,高效率的全栈 Web 框架; 更多信息请参考以下链接:
github.com/revel/revel
。 -
一个强大的 HTTP 路由器和 URL 匹配器,用于构建 Go 语言 Web 服务器; 更多信息请参考以下链接:
github.com/gorilla/mux
。 -
Berners-Lee, T.; Fielding, R.; Masinter, L.: RFC 3986, 统一资源标识符(URI):通用语法。
-
协议缓冲 v3 的开发者指南; 更多信息请参考以下链接:
developers.google.com/protocol-buffers/docs/proto3
。 -
为 Go 语言优化的快速 HTTP 包。在热点路径上零内存分配。比 net/http 快 10 倍以上; 更多信息请参考以下链接:
github.com/valyala/fasthttp
。 -
媒体类型规范和注册程序; 更多信息请参考以下链接:
tools.ietf.org/html/rfc6838
。 -
Go 语言最快的全功能 Web 框架; 更多信息请参考以下链接:
github.com/gin-gonic/gin
。 -
gRPC 中字母 g 的含义; 更多信息请参考以下链接:
github.com/grpc/grpc/blob/master/doc/g_stands_for.md
。
第十章:构建、打包和部署软件
"Kubernetes 是分布式系统的 Linux。"
– Kelsey Hightower
本章将指导您完成将 Go 程序 docker 化的步骤,并迭代构建您应用程序可能的最小容器镜像的最佳实践。在此之后,本章将专注于 Kubernetes。
我们将开始对 Kubernetes 的探索之旅,通过比较构成 Kubernetes 集群的节点类型,并更深入地了解构成 Kubernetes 控制平面的各种服务的功能。接下来,我们将描述如何在您的本地开发机器上设置 Kubernetes 集群的逐步指南。本章的最后一部分是对您迄今为止所学内容的实际应用。我们将把之前章节中创建的所有组件集中起来,与一个完全功能的前端连接,并创建一个单一代码库的 Links 'R' Us 版本,然后将其部署到 Kubernetes 上。
本章将涵盖以下主题:
-
使用中间构建容器为您的 Go 应用程序编译静态二进制文件
-
使用正确的链接器标志以确保 Go 可执行文件编译成尽可能小的尺寸
-
构成 Kubernetes 集群的组件的解剖结构
-
Kubernetes 支持的不同类型的资源类型及其应用
-
在您的本地工作站上启动 Kubernetes 集群
-
使用我们在前几章中开发的组件构建 Links 'R' Us 的单一代码库版本,并在 Kubernetes 上部署它
技术要求
本章将讨论的主题的完整代码已发布到本书的 GitHub 仓库中的 Chapter10
文件夹下。
您可以通过将网络浏览器指向以下 URL 来访问本书的 GitHub 仓库,其中包含本书各章节的代码和所有必需的资源:github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
。
为了让您尽可能快地上手,每个示例项目都包含一个 Makefile,它定义了以下目标集:
Makefile 目标 | 描述 |
---|---|
deps |
安装任何必需的依赖项。 |
test |
运行所有测试并报告覆盖率。 |
lint |
检查 lint 错误。 |
与本书中的其他所有章节一样,您需要一个相当新的 Go 版本,您可以在 golang.org/dl
. 下载。
要运行本章中的一些代码,您需要在您的机器上有一个工作的 Docker^([5])安装。此外,一些示例已被设计为可以在 Kubernetes^([8])上运行。如果您没有访问 Kubernetes 集群进行测试,您可以简单地遵循以下章节中概述的说明,在您的笔记本电脑或工作站上设置一个小型集群。
使用 Docker 构建和打包 Go 服务
在过去几年中,越来越多的软件工程师开始使用 Docker 等系统来容器化他们的应用程序。容器提供了一个简单且干净的方式来执行应用程序,无需担心底层硬件或操作系统。换句话说,相同的容器镜像可以在您的本地开发机器上运行,在云上的虚拟机上运行,甚至可以在您公司数据中心中的裸机服务器上运行。
容器化的好处
除了便携性之外,容器化还提供了从软件工程和 DevOps 角度出发的几个更重要的好处。首先,容器使得部署软件的新版本变得容易,如果出现问题,可以轻松回滚部署。其次,容器化引入了额外的安全层;每个应用程序不仅与其他应用程序完全隔离,而且与底层主机本身也完全隔离。
每当构建一个新的容器镜像(例如,作为持续集成管道的一部分)时,目标应用程序都会打包一个运行所需的全部依赖项的不可变副本。因此,当工程师运行特定的容器时,他们可以保证运行与他们其他同事完全相同的二进制文件,而本地编译和运行应用程序可能会产生不同的结果,这取决于开发机器上安装的编译器版本或系统库。
为了更进一步,除了容器化我们的应用程序之外,我们还可以容器化用于构建它们的工具。这使我们能够创建密封的构建,并为支持可重复的构建铺平道路,这些好处我们在第三章,依赖管理中已经列举过。
当执行密封构建时,生成的二进制工件不受构建机器上安装的任何软件或系统库的影响。相反,构建过程使用固定的编译器和依赖项版本,以确保编译相同的代码库快照(例如,特定的 git SHA)将始终产生相同的、逐位相同的二进制文件。
在下一节中,我们将深入了解为 Go 应用程序构建 Docker 容器的过程,并探讨一系列针对优化容器大小的最佳实践。
Docker 化 Go 应用程序的最佳实践
Go 语言自带生成独立、静态二进制文件的支持,这使得它成为容器化的理想选择!让我们来看看为您的 Go 应用程序构建 Docker 容器的最佳实践。
由于静态 Go 二进制文件通常相当大,我们必须采取额外措施确保我们构建的容器不包含任何在构建时使用的构建工具(例如,Go 编译器)。除非您使用的是非常旧的 Docker 版本,否则您当前安装的版本很可能支持一个名为 build containers 的功能。
构建容器包含编译我们的 Go 应用程序所需的所有工具:Go 编译器和 Go 标准库、git、编译协议缓冲定义的工具等等。我们将使用构建编译器作为一个 中间 容器来编译和链接我们的应用程序。然后,我们将创建一个 新 容器,将编译的二进制文件复制过来,并丢弃构建容器。
为了了解这个过程是如何工作的,让我们检查构建 Links 'R' Us 应用程序的 Dockerfile,我们将在本章的最后部分构建它。您可以在本书 GitHub 仓库的 Chapter10/linksrus
文件夹中找到 Dockerfile:
FROM golang:1.13 AS builder
WORKDIR $GOPATH/src/github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
COPY . .
RUN make deps
RUN GIT_SHA=$(git rev-parse --short HEAD) && \
CGO_ENABLED=0 GOARCH=amd64 GOOS=linux \
go build -a \
-ldflags "-extldflags '-static' -w -s -X main.appSha=$GIT_SHA" \
-o /go/bin/linksrus-monolith \
github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang/Chapter10/linksrus
第一行指定了我们用作构建容器基础的容器。我们可以在 Dockerfile 中使用 builder
别名来引用此容器。前一个 Dockerfile 中的其余命令执行以下操作:
-
应用程序源文件从主机复制到构建容器中。请注意,我们将整个书籍仓库复制到容器中,以确保
make deps
命令可以从这本书的仓库中解析所有包导入,而不是尝试从 GitHub 下载它们。 -
make deps
命令被调用以获取任何外部包依赖项。 -
最后,调用 Go 编译器来编译应用程序并将生成的二进制文件放置在已知位置(在这种情况下,
/go/bin/linksrus-monolith
)。
让我们放大并解释当执行 go build
命令时实际上会发生什么:
-
GIT_SHA
环境变量被设置为当前提交的短 git SHA。-X main.appSha=$GIT_SHA
链接器标志覆盖了主包中名为appSha
的占位符变量的值。我们将输出appSha
变量的值到应用程序日志中,以便操作员可以通过查看日志的尾部来轻松地确定当前部署的应用程序版本。 -
CGO_ENABLED=0
环境变量通知 Go 编译器我们不会从我们的程序中调用任何 C 代码,并允许它从最终二进制文件中优化掉相当多的代码。 -
-static
标志指示编译器生成一个静态二进制文件。 -
最后,
-w
和-s
标志指示 Go 链接器从最终二进制文件中删除调试符号(更具体地说,是 DWARF 部分和符号信息)。这仍然允许你在发生 panic 时获取完整的堆栈跟踪,但防止你将调试器(例如,delve)附加到二进制文件上。从积极的一面来看,这些标志将显著减少最终二进制文件的总大小!
Dockerfile 的下一部分包含构建最终容器的步骤:
FROM alpine:3.10
RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
COPY --from=builder /go/bin/linksrus-monolith /go/bin/linksrus-monolith
ENTRYPOINT ["/go/bin/linksrus-monolith"]
由于我们知道 Links 'R' Us 应用程序很可能会建立 TLS 连接,我们需要确保最终的容器镜像包含全球受信任机构的 CA 证书。这是通过安装ca-certificates
包来实现的。为了完成构建,我们需要将编译的二进制文件从构建容器复制到最终容器中。
选择适合您应用程序的基础容器
在前面的例子中,我选择使用Alpine作为应用程序的基础容器。那么,为什么选择 alpine 而不是更广为人知的,如 Ubuntu?答案是大小!
Alpine Linux^([1])容器是你可以找到的最小的基础容器之一。它包含一个小的 libc 实现(musl)并使用 busybox 作为其 shell。因此,alpine 容器的总大小仅为 5 M,这使得它非常适合托管我们的 Go 静态二进制文件。此外,它还包括自己的包管理器(apk),允许你在构建最终容器时安装额外的包,如 ca-certificates 或网络工具。
如果我们不需要这个额外的功能,那么能否制作出一个更小的应用程序容器呢?答案是肯定的!我们可以使用特殊的scratch容器作为我们的基础容器。正如其名所示,scratch 容器实际上是空的...它没有根文件系统,只包含我们的应用程序二进制文件。然而,它也有一些注意事项:
-
它不包含任何 CA 证书,也没有其他方式可以安装它们,除了从中间构建容器复制它们。然而,如果你的应用程序或微服务将仅使用非 TLS 连接与私有子网中的服务通信,这可能不是问题。
-
该容器不包含 shell。这使得实际上无法 SSH 到正在运行的容器进行调试(例如,检查 DNS 解析是否工作或 grep 日志文件)。
我的建议是始终使用像 alpine 或类似的小型容器,而不是使用 scratch 容器。
到目前为止,你应该能够应用我们在前几节中概述的最佳实践,并为你的 Go 应用程序创建空间高效的容器镜像。那么,接下来是什么?下一步当然是部署和扩展你的应用程序。正如你可能猜到的,我们不会手动进行!相反,我们将利用现有的、工业级的解决方案来管理大规模容器:Kubernetes。
Kubernetes 的温和介绍
Kubernetes^([8])是一个开源平台,用于管理容器化工作负载,它从一开始就考虑了未来的可扩展性。它最初由 Google 在 2014 年发布,包含了他们在运行大规模、生产级应用程序方面的见解和最佳实践。如今,它已经超越了最受欢迎的云提供商的托管容器服务,并正在成为在本地和云中部署应用程序的事实标准。
详细描述 Kubernetes 超出了本书的范围。相反,以下章节的目标是为你提供一个 Kubernetes 的简要介绍,并将一些基本概念提炼成易于消化的格式,以便你能够启动一个测试集群并将“链接即服务”项目部署到其中。
漏洞探查
好的,我们已经提到 Kubernetes 将承担繁重的工作并为你管理不同类型的容器化工作负载。但是,它内部是如何工作的呢?以下图表展示了构成 Kubernetes 集群的基本组件:
图 1:Kubernetes 集群的高级概述
Kubernetes 集群由两种类型的节点组成:master 节点和worker 节点。这些可以是物理机或虚拟机。master 节点实现了集群的控制平面,而 worker 节点将它们的资源(CPU、内存、磁盘,甚至是 GPU)集中起来,并执行由 master 分配给它们的工作负载。
每个 master 节点运行以下进程:
-
kube-api-server。你可以将其视为一个 API 网关,允许工作节点和集群管理员访问集群的控制平面。
-
etcd 实现了一个键值存储,其中持久化了集群的当前状态。它还提供了一个方便的 API,允许客户端监视特定的键或一组键,并在它们的值发生变化时接收通知。
-
scheduler 监控集群状态以处理即将到来的工作负载,并确保每个工作负载都被分配给可用的某个工作节点。如果工作负载需求无法由任何工作节点满足,调度器可能会选择将现有工作负载重新调度到不同的工作节点,以便为即将到来的工作负载腾出空间。
-
云控制器管理器处理与集群宿主云底层的所有必要交互。此类交互的例子包括提供云特定服务,如存储或负载均衡器,以及创建或操作诸如路由表和 DNS 记录等资源。
一个生产级别的 Kubernetes 集群通常配置有多个主节点;控制平面管理集群状态,因此它必须高度可用。在这种情况下,数据将在主节点之间自动复制,并使用基于 DNS 的负载均衡来访问 kube-api-server 网关。
现在,让我们看看工作节点的内部结构。鉴于 Kubernetes 管理容器,每个工作节点提供合适的容器运行时是一个关键要求。正如你可能猜到的,最常用的运行时是 Docker;然而,Kubernetes 也会高兴地与其他类型的容器运行时接口一起工作,例如 containerd ^([4]) 或 rkt ^([12])。
每个在特定工作节点上安排的工作负载都在其容器运行时中独立执行。在 Kubernetes 中,最小的作业单位被称为pod。Pod 包含一个或多个在同一工作实例上执行的容器镜像。虽然单容器 pod 是最常见的,但多容器 pod 也非常有用。例如,我们可以部署一个包含 nginx 和一个侧车容器的 pod,该容器监控外部配置源并在需要时重新生成 nginx 配置。通过创建额外的 pod 实例,可以水平扩展应用程序。
工作节点还运行以下进程:
-
kubelet代理连接到主机的api-server,并监视分配给其运行的工作节点的作业。它通过在容器意外死亡时自动重启它们来确保所需的容器始终处于运行状态。
-
kube-proxy像网络代理一样工作。它维护一组规则,控制内部(集群)或外部流量路由到当前在工作节点上执行的任务的 pod。
总结最常用的 Kubernetes 资源类型
运营商通过创建、删除或以类似 CRUD 的接口操纵不同类型的资源来与 Kubernetes 集群交互。让我们简要地看看一些最常用的 Kubernetes 资源类型。
很少会遇到不需要任何配置的应用程序。虽然我们可以在创建我们的 Pod 时直接硬编码配置设置,但这通常被认为是不良的做法,而且坦白说,当我们需要更改共享多个应用程序的配置设置(例如,数据库的端点)时,这会变成一个主要的挫折来源。为了缓解这个问题,Kubernetes 提供了配置映射资源。配置映射是一系列键值对的集合,可以注入到 Pod 中作为环境变量,或者以纯文本文件的形式挂载。这种方法允许我们在单个位置管理配置设置,并在创建应用程序的 Pod 时避免硬编码它们。Kubernetes 还提供了秘密资源,它的工作方式与配置映射类似,但旨在用于在 Pod 之间共享敏感信息,如证书密钥和服务凭证。
命名空间资源作为一个虚拟容器,用于逻辑上分组其他 Kubernetes 资源并控制对它们的访问。如果多个团队使用同一个集群进行部署,这是一个非常实用的功能。在这种情况下,每个团队通常被分配对其自己的命名空间的完全访问权限,这样他们就不能干扰其他团队部署的资源,除非他们被授予明确的访问权限。
一旦 Pod 死亡,其任何容器中存储的数据都将丢失。为了支持我们希望在 Pod 重启之间持久化数据或我们希望在多个 Pod 实例之间共享相同数据集(例如,由 Web 服务器提供的页面)的使用案例,Kubernetes 提供了持久卷(PV)和持久卷声明(PVC)资源。持久卷不过是一块可供集群使用的块存储。根据底层基础结构,它可以是集群管理员手动配置的,也可以是由底层基础结构按需动态分配的(例如,在 AWS 上运行时,可以是 EBS 卷)。另一方面,持久卷声明代表操作员对具有特定属性(例如,大小、IOPS 和旋转磁盘或 SSD)的存储块的需求。Kubernetes 控制平面试图将可用的卷与操作员指定的声明相匹配,并将卷挂载到引用每个声明的 Pod 上。
要在 Kubernetes 上部署无状态应用程序,推荐的方法是创建一个 deployment 资源。deployment 资源指定了一个用于实例化应用程序单个 Pod 的模板和所需的副本数量。Kubernetes 持续监控每个 deployment 的状态,并通过创建新的 Pod(使用模板)或删除超出请求副本数量的现有 Pod 来尝试将集群状态与所需状态同步。在 deployment 中的每个 Pod 都会被 Kubernetes 分配一个随机主机名,并且与每个其他 Pod 共享 相同的 PVC。
许多类型的工作负载,如数据库或消息队列,需要一种有状态的部署方式,其中 Pod 被分配稳定且可预测的主机名,并且每个单独的 Pod 都有自己的 PVC。更重要的是,这些应用程序通常以集群配置运行,并期望节点以特定的顺序部署、升级和扩展。在 Kubernetes 中,这种类型的部署是通过创建一个 StatefulSet 来实现的。类似于 deployment 资源,StatefulSet 也定义了一个 Pod 模板和副本数量。每个副本都会被分配一个主机名,该主机名是通过连接 StatefulSet 的名称和集合中每个 Pod 的索引来构建的(例如,web-0 和 web-1)。
能够根据需要上下调整已部署 Pod 的数量是一个很好的特性,但如果没有其他集群资源能够连接到它们,那么这个特性就不是很实用!为此,Kubernetes 支持另一种类型的资源,称为 服务。服务有两种类型:
-
一个服务可以位于一组 Pod 的前面,充当一个 负载均衡器。在这种情况下,该服务会自动分配一个 IP 地址和 DNS 记录,以帮助客户端发现它。如果你在疑惑,这种功能是通过在每个工作节点上运行的 kube-proxy 组件实现的。
-
一个 无头服务允许你实现自定义的服务发现机制。这些服务不会被分配集群 IP 地址,并且完全被 kube-proxy 忽略。然而,这些服务为服务创建 DNS 记录,并解析到服务背后每个 Pod 的地址。
我们将要检查的最后一个 Kubernetes 资源是 ingress。根据其配置,ingress 会公开 HTTP 或 HTTPS 端点,用于将集群外部的流量路由到集群内部特定的服务。大多数 ingress 控制器实现支持的常见功能包括 TLS 终止、基于名称的虚拟主机和入站请求的 URL 重写。
这就结束了我们对最常见的 Kubernetes 资源类型的概述。请记住,这仅仅是冰山一角!Kubernetes 支持许多其他资源类型(例如,cron 作业),甚至提供了允许操作员定义他们自己的自定义资源的 API。如果您想了解更多关于 Kubernetes 资源的信息,我强烈建议您浏览在线上可用的相当广泛的 Kubernetes 文档集 [8]。
接下来,您将学习如何轻松地在您的笔记本电脑或工作站上设置自己的 Kubernetes 集群。
在您的笔记本电脑上运行 Kubernetes 集群!
几年前,对 Kubernetes 的实验基本上仅限于那些被授予访问测试或开发集群权限的工程师,或者他们拥有在云上启动和运行自己集群所需资源和知识。如今,事情要简单得多... 事实上,您甚至可以在几分钟内就在您的笔记本电脑上启动一个完全可操作的 Kubernetes 集群!
让我们来看看一些最流行的、对开发者友好的 Kubernetes 发行版,您可以在您的开发机器上部署它们:
-
K3S [7] 是一个微型(实际上是一个 50 M 的二进制文件!)发行版,允许您在资源受限的设备上运行 Kubernetes。它为多个架构提供了二进制文件,包括 ARM64/ARMv7。这使得它成为在 Raspberry Pi 上运行 Kubernetes 的理想选择。
-
Microk8s [9] 是 Canonical 的一个项目,承诺零操作 Kubernetes 集群设置。在 Linux 上将 Kubernetes 集群启动并运行就像运行
snap install microk8s
一样简单。在其他平台上,安装 microk8s 的推荐方法是使用 Multipass [11] 这样的应用程序启动一个虚拟机,并在其中运行上述命令。 -
Minikube [10] 是另一个发行版,这次是由 Kubernetes 作者提供的。它可以与不同类型的虚拟机管理程序一起工作(例如,VirtualBox、Hyperkit、Parallels、VMware Fusion 或 Hyper-V),甚至可以在裸金属(仅限 Linux)上部署。
为了尽可能简化您在您喜欢的操作系统上设置自己的 Kubernetes 集群并运行即将在下一节中展示的示例,我们将仅使用 Minikube,并使用 VirtualBox 作为我们的虚拟机管理程序。
在我们开始之前,请确保您已下载并安装以下软件:
-
Docker [5]。
-
VirtualBox [13]。
-
您平台上的 kubectl 二进制文件。
-
您平台上的 Helm [6] 二进制文件。Helm 是 Kubernetes 的包管理器,我们将使用它来部署 Links 'R' Us 项目的 CockroachDB 和 Elasticsearch 实例。
-
您平台上的最新 Minikube 版本。
在所有前置依赖都准备就绪的情况下,我们可以使用以下代码启动我们的 Kubernetes 集群:
minikube start --kubernetes-version=v1.15.3 \
--memory=4g \
--network-plugin=cni
这个命令将在一个拥有 4 GB 内存的虚拟机上创建 Kubernetes 1.15.3,并部署到该虚拟机上。它还将更新本地 kubectl 配置,以便自动连接到我们刚刚配置的集群。更重要的是,它将为集群启用 容器网络接口(CNI)插件。在下一章中,我们将利用这一功能安装像 Calico ^([2]) 或 Cilium ^([3]) 这样的网络安全解决方案,并定义细粒度的网络策略来锁定我们的集群。
由于我们部署的服务将运行在 Minikube 的虚拟机中,从 主机 访问它们的唯一方法是通过配置一个 ingress 资源。幸运的是,Minikube 提供了一个合适的 ingress 实现作为插件,我们可以通过运行 minikube addons enable ingress
来激活它。更重要的是,为了我们的测试,我们希望使用一个私有 Docker 仓库来推送我们将构建的 Docker 镜像。Minikube 随带一个私有仓库插件,我们可以通过运行 minikube addons enable registry
来启用它。
然而,默认情况下,Minikube 的私有仓库以不安全模式运行。当使用不安全仓库时,我们需要明确配置我们的本地 Docker 守护进程以允许连接到它们;否则,我们无法推送我们的镜像。该仓库在 Minikube 使用的 IP 地址上暴露在端口 5000
。
您可以通过运行 minikube ip
来找到 Minikube 的 IP 地址。
在 Linux 上,您可以编辑 /etc/docker/daemon.json
,合并以下 JSON 块(将 $MINIKUBE_IP
替换为使用 minikube ip
命令获得的 IP),然后按照以下方式重启 Docker 守护进程:
{
"insecure-registries" : [
"$MINIKUBE_IP:5000"
]
}
在 OS X 和 Windows 上,您只需在 Docker for desktop 上右键单击,选择首选项,然后点击“守护进程”选项卡,即可访问受信任的不安全仓库列表。
我们需要做的最后一件事是安装所需的集群资源,以便我们可以使用 Helm 包管理器。我们可以通过运行 helm init
来完成这项任务。
为了节省您的时间,我已经将所有前面的步骤编码到一个 Makefile 中,您可以在本书 GitHub 仓库的 Chapter10/k8s
文件夹中找到它。
要启动集群,安装所有必需的插件,并配置 Helm,你只需输入 make bootstrap-minikube
。
就这么简单!我们现在已经拥有了一个完全可用的 Kubernetes 集群。现在,我们准备构建和部署 Links 'R' Us 项目的单体版本。
构建 和部署 Links 'R' Us 的单体版本
这是检验的时刻!在接下来的章节中,我们将利用本章学到的所有知识,将我们在前几章中开发的 Links 'R' Us 组件组装成一个单体应用程序,然后我们将继续在 Kubernetes 上部署它。
根据 第五章 中 Links 'R' Us 项目 的用户故事,为了使我们的应用程序满足我们的设计目标,它应该提供以下服务:
-
一个定期运行的多遍爬虫,用于扫描链接图,检索索引链接,并在未来的遍历中增加新发现的链接以进行爬取
-
另一个定期运行的服务,用于重新计算并持久化不断扩展的链接图的 PageRank 分数
-
为我们的最终用户提供前端,以便执行搜索查询并提交网站 URL 以进行索引
到目前为止,我们还没有真正讨论前端。别担心;我们将在接下来的某个部分为我们的应用程序构建一个完整的前端。
如你可能猜到的,由于涉及的组件数量,最终的应用程序无疑将包含相当多的样板代码。鉴于在本章中不可能包含完整的源代码,我们只会关注最有趣的部分。尽管如此,你可以在本书 GitHub 仓库的 Chapter10/linksrus
文件夹中找到整个应用程序的文档化源代码。
在应用程序实例之间分配计算
预计 Links 'R' Us 项目一夜之间取得成功并吸引大量流量,尤其是在在 Hacker News 和 Slashdot 等网站上发布链接之后,我们需要制定一个合理的扩展计划。尽管我们目前正在处理一个单体应用程序,但我们总是可以通过启动额外的实例来水平扩展。此外,随着我们的链接图规模的增长,我们无疑需要为我们的网络爬虫和 PageRank 计算器提供额外的计算资源。
使用像 Kubernetes 这样的容器编排平台的一个关键好处是我们可以轻松地扩展(或缩减)任何已部署的应用程序。正如我们在本章开头所看到的,连接到 Ingress
的 Service
资源可以充当负载均衡器,并将 传入 流量分配给我们的应用程序。这透明地处理了我们的前端扩展问题,而无需我们进行额外的开发工作。
另一方面,确保 每个 应用程序实例爬取图的具体 子集 并不简单,因为这需要应用程序实例之间进行协调。这意味着我们需要在各个实例之间建立一个通信通道。或者,这是否意味着我们不需要这样做?
将 UUID 空间划分为不重叠的分区
在 第六章 中,我们提到了 构建持久层,我们提到,由链接图组件公开的 Links
和 Edges
方法的调用者负责实现一个合适的分区方案,并将适当的 UUID 范围作为参数提供给这些方法。那么,我们该如何实现这样的分区方案呢?
我们的方法利用了观察到的链接(和边)ID 实际上是 V4(随机)UUID,并且因此预计在庞大的(2¹²⁸)UUID 空间中分布得或多或少均匀。让我们假设我们可用的总工作器数量(即分区数量)是N。目前,我们将工作器数量视为固定且已知优先级。在下一节中,我们将学习如何利用 Kubernetes 基础设施自动发现这些信息。
为了确定M[th](其中 0 <= M < N)工作器需要提供给图Links
和Edges
方法的 UUID 范围,我们需要进行一些计算。首先,我们需要将 128 位 UUID 空间细分为N个大小相等的部分;本质上,每个部分将包含C = 2¹²⁸ / N个 UUID。因此,为了计算M[th]工作器的 UUID 范围,我们可以使用以下公式:
如果工作器数量(N)是奇数,那么我们将无法均匀地分割 UUID 空间;因此,最后的(N-1)部分将以特殊方式处理:它始终扩展到 UUID 空间的末尾(UUID 值ffffffff-ffff-ffff-ffff-ffffffffffff
)。这确保了我们始终覆盖整个 UUID 空间,无论N是奇数还是偶数!
这种分割方式的理由如下:
-
大多数现代数据库系统倾向于在内存中缓存主键索引
-
它们包含用于在主键范围内执行范围扫描的特殊优化代码路径
前两个特性的组合使得这种解决方案对于爬虫和 PageRank 计算组件执行的读密集型工作负载非常有吸引力。一个小麻烦是 UUID 是 128 位值,Go 没有提供用于执行 128 位算术的标量类型。幸运的是,标准库提供了math/big
包,它可以执行任意精度的算术运算!
让我们继续创建一个辅助函数,它将为我们处理所有这些计算。Range
辅助函数的实现将位于一个名为range.go
的文件中,它是Chapter10/linksrus/partition
包的一部分(参见本书的 GitHub 仓库)。其类型定义如下:
type Range struct {
start uuid.UUID
rangeSplits []uuid.UUID
}
对于我们的特定应用,我们将提供两个构造函数来创建范围。第一个构造函数创建一个覆盖整个 UUID 空间的Range
,并将其分割成numPartitions
:
func NewFullRange(numPartitions int) (Range, error) {
return NewRange(
uuid.Nil,
uuid.MustParse("ffffffff-ffff-ffff-ffff-ffffffffffff"),
numPartitions,
)
}
如您所见,构造函数将范围的创建委托给NewRange
辅助函数,其实现已被分解成更小的片段:
if bytes.Compare(start[:], end[:]) >= 0 {
return Range{}, xerrors.Errorf("range start UUID must be less than the end UUID")
} else if numPartitions <= 0 {
return Range{}, xerrors.Errorf("number of partitions must be at least equal to 1")
}
// Calculate the size of each partition as: ((end - start + 1) / numPartitions)
tokenRange := big.NewInt(0)
partSize := big.NewInt(0)
partSize = partSize.Sub(big.NewInt(0).SetBytes(end[:]), big.NewInt(0).SetBytes(start[:]))
partSize = partSize.Div(partSize.Add(partSize, big.NewInt(1)), big.NewInt(int64(numPartitions)))
在继续之前,代码会验证提供的 UUID 范围是否有效,确保起始 UUID 小于结束 UUID。为此,我们使用方便的bytes.Compare
函数,该函数比较两个字节切片,如果两个字节切片相等或第一个字节切片大于第二个字节切片,则返回大于或等于零的值。这里的一个注意事项是,UUID 类型定义为[16]byte
,而bytes.Compare
函数期望字节切片。然而,我们可以通过使用便利操作符[:]
轻松地将每个 UUID 转换为字节切片。
在初步的参数验证之后,我们创建一个空的big.Integer
值,并使用math/big
包的繁琐 API 通过(end - start) + 1
表达式加载结果。一旦值被加载,我们就将其除以调用者作为函数参数提供的分区数量。这得到了我们在上一节中看到的公式中的C
值。
以下代码块使用一个for
循环来计算并存储我们正在创建的范围内每个分区所对应的结束UUID:
var to uuid.UUID
var err error
var ranges = make([]uuid.UUID, numPartitions)
for partition := 0; partition < numPartitions; partition++ {
if partition == numPartitions-1 {
to = end
} else {
tokenRange.Mul(partSize, big.NewInt(int64(partition+1)))
if to, err = uuid.FromBytes(tokenRange.Bytes()); err != nil {
return nil, xerrors.Errorf("partition range: %w", err)
}
}
ranges[partition] = to
}
return &Range{start: start, rangeSplits: ranges}, nil
正如我们在上一节中提到的,最后一个分区的结束 UUID 总是最大的可能 UUID 值。对于所有其他分区,我们通过将每个分区的尺寸乘以分区号再加一来计算结束值。一旦所有计算完成,就为调用者分配并返回一个新的Range
对象。除了计算出的结束范围之外,我们还跟踪范围的起始 UUID。
现在,为了使Range
类型更容易在爬虫服务代码中使用,让我们定义两个辅助方法:
func (r *Range) Extents() (uuid.UUID, uuid.UUID) {
return r.start, r.rangeSplits[len(r.rangeSplits)-1]
}
func (r *Range) PartitionExtents(partition int) (uuid.UUID, uuid.UUID, error) {
if partition < 0 || partition >= len(r.rangeSplits) {
return uuid.Nil, uuid.Nil, xerrors.Errorf("invalid partition index")
}
if partition == 0 {
return r.start, r.rangeSplits[0], nil
}
return r.rangeSplits[partition-1], r.rangeSplits[partition], nil
}
Extends
方法返回整个范围的起始(包含)和结束(排除)UUID 值。另一方面,PartitionExtents
函数返回范围内特定分区的起始和结束 UUID 值。
为每个 Pod 分配分区范围
在上一节中介绍的Range
类型帮助下,我们现在有了查询分配给每个单独分区的 UUID 范围的方法。对于我们的特定用例,分区的数量等于我们启动的 Pod 数量。然而,我们缺少的一个重要信息是分配给每个已启动 Pod 的分区号!因此,我们现在有两个问题需要解决:
-
单个 Pod 的分区号是多少?
-
总共有多少个 Pod?
如果我们将我们的应用程序作为具有N个副本的 StatefulSet 部署,该集中每个 Pod 都会被分配一个遵循模式SET_NAME-INDEX
的主机名,其中INDEX
是从0到N-1的数字,表示 Pod 在集中的索引。我们只需要从我们的应用程序中读取 Pod 的主机名,解析数字后缀,并将其用作分区号。
回答第二个问题的方法之一是查询 Kubernetes 服务器 API。然而,这需要额外的努力来设置(例如,创建服务帐户、RBAC 记录)——更不用说这实际上将我们锁定在 Kubernetes 上了!幸运的是,有一个更简单的方法...
如果我们要为我们的应用程序创建一个无头服务,它将自动生成一组 SRV 记录,我们可以查询并获取属于该服务的每个单独 Pod 的主机。以下图显示了在 Kubernetes 集群中的一个 Pod 内部运行 SRV 查询的结果:
图 2:linksrus-headless
服务与四个 Pod 相关联,其主机名在右侧可见
根据前面截图显示的信息,我们可以编写一个辅助程序来找出运行应用程序实例的分区数和总分区数,如下所示:
func (det FromSRVRecords) PartitionInfo() (int, int, error) {
hostname, err := os.Hostname()
if err != nil {
return -1, -1, xerrors.Errorf("partition detector: unable to detect host name: %w", err)
}
tokens := strings.Split(hostname, "-")
partition, err := strconv.ParseInt(tokens[len(tokens)-1], 10, 32)
if err != nil {
return -1, -1, xerrors.Errorf("partition detector: unable to extract partition number from host name suffix")
}
_, addrs, err := net.LookupSRV("", "", det.srvName)
if err != nil {
return -1, -1, ErrNoPartitionDataAvailableYet
}
return int(partition), len(addrs), nil
}
要获取主机名,我们调用由os
包提供的Hostname
函数。然后,我们在破折号分隔符上分割,提取主机名的最右边部分,并使用ParseInt
将其转换为数字。
接下来,为了获取 SRV 记录,我们使用net
包中的LookupSRV
函数,并将服务名作为最后一个参数传递。然后,我们计算结果的数量以确定集合中的总 Pod 数量。需要注意的一个重要事项是,SRV 记录的创建不是瞬时的!当 StatefulSet 最初部署时,SRV 记录变得可用需要一些时间。为此,如果 SRV 查找没有返回任何结果,代码将返回一个类型错误,让调用者知道他们应该稍后再试。
为应用程序服务构建包装器
到目前为止,我们故意设计了各种 Link 'R' Us 组件,使它们与其输入源或多或少解耦。例如,来自第七章,数据处理管道的爬虫组件期望一个迭代器,该迭代器产生要爬取的链接集合,而来自第八章,基于图的数据处理的 PageRank 计算器组件只为创建 PageRank 算法使用的图节点和边提供便利方法。
要将这些组件集成到更大的应用程序中,我们需要提供一个薄层来实现两个关键功能:
-
它通过一个合适的链接图将每个组件与来自第六章,构建持久层的文本索引器数据存储实现连接起来
-
它管理每个组件的刷新周期(例如,触发新的爬虫遍历或重新计算 PageRank 分数)
每个服务都将从“链接‘R’我们”应用程序的主包中启动,并独立于其他服务执行。如果任何服务由于错误而退出,我们希望我们的应用程序能够干净地关闭,记录错误,并以适当的退出状态码退出。这需要引入一个管理每个服务执行的监督机制。在我们到达那里之前,让我们首先定义一个接口,我们的应用程序中的每个服务都需要实现:
type Service interface {
Name() string
Run(context.Context) error
}
这并不令人惊讶... Name
方法返回服务的名称,我们可以用它来记录日志。正如你可能猜到的,Run
方法实现了服务的业务逻辑。对 Run
的调用预期会阻塞,直到提供的上下文过期或发生错误。
爬虫服务
爬虫服务的业务逻辑相当简单。服务使用计时器休眠,直到下一个更新间隔到期,然后执行以下步骤:
-
首先,它查询关于分区分配的最新信息。这包括 pod 的分区号和分区总数(pod 计数)。
-
使用上一步骤中的分区计数信息,创建一个新的完整
Range
,并计算当前分配的分区号的范围(UUID 范围)。 -
最后,该服务获取了计算出的 UUID 范围的链接迭代器,并将其用作数据源来驱动我们在第七章,“数据处理管道”中构建的爬虫组件。
服务构造函数期望一个配置对象,它不仅包括所需的配置选项,还包括服务所依赖的一组接口。这种方法允许我们通过注入满足这些接口的模拟对象来完全隔离地测试服务。以下是爬虫服务的Config
类型:
type Config struct {
GraphAPI GraphAPI
IndexAPI IndexAPI
PrivateNetworkDetector crawler_pipeline.PrivateNetworkDetector
URLGetter crawler_pipeline.URLGetter
PartitionDetector partition.Detector
Clock clock.Clock
Fand so onhWorkers int
UpdateInterval time.Duration
ReIndexThreshold time.Duration
Logger *logrus.Entry
}
你可能想知道为什么我选择在这个包内重新定义GraphAPI
和IndexAPI
接口,而不是简单地导入并使用来自graph
或index
包的原始接口。这实际上是对接口分离原则的应用!原始接口包含比我们实际需要的更多方法。例如,以下是为访问链接图和索引文档而需要的爬虫所需的方法集:
type GraphAPI interface {
UpsertLink(link *graph.Link) error
UpsertEdge(edge *graph.Edge) error
RemoveStaleEdges(fromID uuid.UUID, updatedBefore time.Time) error
Links(fromID, toID uuid.UUID, retrievedBefore time.Time) (graph.LinkIterator, error)
}
type IndexAPI interface {
Index(doc *index.Document) error
}
使用尽可能小的图和索引 API 接口定义的一个非常实用的副作用是,这些最小接口也恰好与我们在上一章中创建的 gRPC 客户端兼容。我们将在下一章利用这一观察结果,以便将我们的单体应用程序拆分为微服务!现在,让我们看看其他配置字段:
-
PartitionDetector
将由服务查询以获取其分区信息。当在 Kubernetes 上运行时,检测器将使用上一节中的代码来发现可用的分区。或者,可以注入一个总是报告单个分区的分区检测器,以便我们可以在我们的开发机器上以独立二进制文件的形式运行应用程序。 -
Clock
允许我们在测试中注入一个假的时钟实例。正如我们在第四章,测试的艺术中所做的那样,我们将使用juju/clock
包来模拟测试中的时间相关操作。 -
Fand so onhWorkers
控制爬虫组件用于检索链接的工作线程数量。 -
UpdateInterval
指定爬虫应该多久执行一次新的遍历。 -
ReIndexThreshold
在下一轮爬虫遍历中选择要爬取的链接集合时用作过滤器。当链接的最后检索时间比time.Now() - ReIndexThreshold
更早时,将考虑该链接进行爬取。 -
Logger
指定用于日志消息的可选日志实例。我们将在下一章中更多地讨论结构化日志。
PageRank 计算器服务
与爬虫服务类似,PageRank 服务也会定期唤醒以重新计算图中每个链接的 PageRank 分数。在底层,它使用我们在第八章,基于图的数据处理中构建的 PageRank 计算器组件来执行完整的 PageRank 算法遍历。服务层负责填充计算器组件使用的内部图表示,调用它来计算更新的 PageRank 分数,并更新每个索引文档的 PageRank 分数。
服务构造函数还接受一个类似这样的Config
对象:
type Config struct {
GraphAPI GraphAPI
IndexAPI IndexAPI
PartitionDetector partition.Detector
Clock clock.Clock
ComputeWorkers int
UpdateInterval time.Duration
Logger *logrus.Entry
}
pagerank
服务包定义了自己的GraphAPI
和IndexAPI
类型版本。如下面的代码所示,这些接口的方法列表与我们之前在爬虫服务中使用的不同:
type GraphAPI interface {
Links(fromID, toID uuid.UUID, retrievedBefore time.Time) (graph.LinkIterator, error)
Edges(fromID, toID uuid.UUID, updatedBefore time.Time) (graph.EdgeIterator, error)
}
type IndexAPI interface {
UpdateScore(linkID uuid.UUID, score float64) error
}
ComputeWorkers
参数传递给 PageRank 计算器组件,并控制用于执行 PageRank 算法的工作线程数量。另一方面,UpdateInterval
参数控制分数刷新频率。
不幸的是,目前无法以分区模式运行 PageRank 服务。正如我们在第八章图基于数据处理中看到的,计算器实现是在以下假设下进行的:图中的每个节点都可以向图中的每个其他节点发送消息,并且所有顶点都可以访问共享的全局状态(聚合器)。目前,我们将使用检测到的分区信息作为约束,在单个Pod 上执行服务(更具体地说,分配给分区 0 的那个)。不过,不用担心!在第十二章构建分布式图处理系统中,我们将重新审视这个实现并纠正所有上述问题。
为用户提供一个完全功能的用户界面
当然,我们的这个小项目如果没有为用户提供适当的用户界面是无法完成的!为了构建一个,我们将利用 Go 标准库对 HTML 模板的支持(text/template
和html/template
包)来为 Links 'R' Us 设计一个完全功能的静态网站。为了简单起见,所有的 HTML 模板都将作为字符串嵌入到我们的应用程序中,并在应用程序启动时解析为text.Template
。在功能方面,我们的前端必须实现许多功能。
首先,它必须实现一个索引/着陆页,用户可以在其中输入搜索查询。查询可以是基于关键字或短语的。索引页面还应包括一个链接,可以导航用户到另一个页面,他们可以在那里提交网站进行索引。以下截图显示了索引页面模板的渲染:
图 3:Links 'R' Us 的着陆页
接下来,我们需要一个页面,网站管理员可以手动提交他们的网站进行索引。正如我们之前提到的,索引/着陆页将包括一个链接到网站提交页面。以下截图显示了索引网站提交页面的渲染效果:
图 4:手动提交网站进行索引的表单
我们整个应用程序中最后,并且显然是最重要的页面是搜索结果页面。如图所示,结果页面显示与用户搜索查询匹配的网站的分页列表。页面标题包括一个搜索文本框,显示当前搜索的术语,并允许用户在不离开页面的情况下更改搜索术语:
图 5:搜索结果分页列表
生成单个搜索结果块模板的模板,如前一个截图所示,包括三个部分:
-
一个指向网页的链接。链接文本将显示匹配页面的标题或页面本身的链接,具体取决于爬虫是否能够提取其标题。
-
以较小字体显示匹配网页的 URL。
-
包含突出显示匹配关键词的页面内容摘要。
现在我们已经为渲染构成 Links 'R' Us 前端页面的所有必要模板定义了所有必要的模板,我们需要注册一系列 HTTP 路由,以便我们的最终用户可以访问我们的服务。
指定前端应用的端点
以下表格列出了我们前端服务需要处理的 HTTP 请求类型和端点,以便实现我们之前描述的所有功能:
请求类型 | 路径 | 描述 |
---|---|---|
GET | / |
显示索引页面 |
GET | /search?q=TERM |
显示 TERM 的第一页结果 |
GET | /search?q=TERM&offset=X |
显示从特定偏移量开始的 TERM 的结果 |
GET | /submit/site |
显示网站提交表单 |
POST | /submit/site |
处理网站提交 |
ANY | 任何其他路径 | 显示 404 页面 |
为了让我们的工作更简单,我们将使用 gorilla/mux
作为首选的路由器。创建路由器并注册端点处理程序就像使用以下代码一样简单:
svc := &Service{
router: mux.NewRouter(),
cfg: cfg,
}
svc.router.HandleFunc(indexEndpoint, svc.renderIndexPage).Methods("GET")
svc.router.HandleFunc(searchEndpoint, svc.renderSearchResults).Methods("GET")
svc.router.HandleFunc(submitLinkEndpoint, svc.submitLink).Methods("GET", "POST")
svc.router.NotFoundHandler = http.HandlerFunc(svc.render404Page)
为了使前端服务更容易测试,Service
类型存储了对路由器的引用。这样,我们可以使用 httptest
包的原始程序直接在 mux 上执行 HTTP 请求,而无需启动任何服务器。
执行搜索和分页结果
搜索和分页结果对于前端服务来说基本上是一个直接的任务。我们服务需要做的只是解析搜索词、请求查询字符串中的偏移量,并调用在服务实例化时作为配置选项传递的文本索引存储库的 Query
方法。
然后,服务消费结果迭代器,直到它已经处理了足够的结果以填充结果页面或迭代器达到结果集的末尾。考虑以下代码:
for resCount := 0; resultIt.Next() && resCount < svc.cfg.ResultsPerPage; resCount++ {
doc := resultIt.Document()
matchedDocs = append(matchedDocs, matchedDoc{
doc: doc,
summary: highlighter.Highlight(
template.HTMLEscapeString(
summarizer.MatchSummary(doc.Content),
),
),
})
}
服务为每个结果创建了一个装饰模型,该模型提供了一些方便的方法,这些方法将由模板内的 Go 代码块调用。此外,matchedDoc
类型包含一个 summary
字段,其中包含匹配页面内容的简短摘录,并突出显示搜索词。
为了在文本摘要中突出显示搜索词,关键词突出显示器将每个术语包裹在一个<em>
标签中。然而,这种方法要求结果页面模板以原始 HTML格式渲染摘要。因此,我们必须非常小心,不要允许我们的结果摘要中包含任何其他 HTML 标签,因为这会使我们的应用程序容易受到跨站脚本(XSS)攻击。虽然爬虫组件从爬取的页面中删除了所有 HTML 标签,但在将生成的摘要传递给关键词突出显示器之前,逃避任何 HTML 字符也不无裨益。
为了能够渲染导航标题和页脚,我们需要向页面模板提供有关当前分页状态的信息。以下代码显示了如何用所需的片段信息填充paginationDetails
类型:
pagination := &paginationDetails{
From: int(offset + 1),
To: int(offset) + len(matchedDocs),
Total: int(resultIt.TotalCount()),
}
if offset > 0 {
pagination.PrevLink = fmt.Sprintf("%s?q=%s", searchEndpoint, searchTerms)
if prevOffset := int(offset) - svc.cfg.ResultsPerPage; prevOffset > 0 {
pagination.PrevLink += fmt.Sprintf("&offset=%d", prevOffset)
}
}
if nextPageOffset := int(offset) + len(matchedDocs); nextPageOffset < pagination.Total {
pagination.NextLink = fmt.Sprintf("%s?q=%s&offset=%d", searchEndpoint, searchTerms, nextPageOffset)
}
当当前结果偏移量大于 0 时,将始终渲染一个上一个结果页面链接。除非我们正在返回到第一个结果页面,否则链接将始终包含一个偏移量参数。同样,只要我们没有达到结果集的末尾,就会渲染下一个结果页面链接。
生成搜索结果的令人信服的摘要
生成一个描述性的简短摘要,向用户传达足够的信息,关于匹配他们查询的网页内容,这是一个相当难以解决的问题。事实上,自动摘要自然语言处理和机器学习的一个活跃的研究领域。
建立这样的系统可能超出了本书的范围。相反,我们将实现一个更简单的算法,该算法生成的摘要应该是足够好的,适用于我们的特定用例。以下是算法步骤的概述:
-
将匹配页面的内容拆分成句子。
-
对于每个句子,计算匹配关键词与总词数的比例。这将作为我们选择和优先考虑要包含在摘要中的句子集的质量指标。
-
跳过任何匹配率为零的句子;也就是说,它们不包含任何搜索关键词。这些句子对我们摘要的实际用途并没有真正的作用。
-
将剩余的句子添加到一个列表中,其中每个条目都是一个包含
{序号,文本,匹配率}
的元组。序号值指的是句子在文本中的位置。 -
按照匹配率降序对列表进行排序。
-
初始化一个用于摘要的句子片段的第二个列表和一个变量,用于跟踪摘要中剩余的字符数。
-
遍历排序后的列表;对于每个条目,执行以下操作:如果其长度小于剩余的摘要字符数,则将条目原样追加到第二个列表中,并从剩余字符变量中减去其长度。如果其长度大于剩余的摘要字符数,则截断句子文本到剩余的摘要字符数,将其追加到第二个列表中,并终止迭代。
-
按照升序顺序对摘要片段列表进行排序。这确保了句子片段以与文本相同的顺序出现。
-
按照以下方式遍历排序后的片段列表并连接条目:
-
如果当前句子的序号比上一句的序号多一个,它们应该用单个句号连接,就像它们在原始文本中连接在一起一样。
-
否则,句子应该用省略号连接,因为它们属于文本的不同部分。
-
上述算法的完整 Go 实现太长,无法在此列出,但如果您感兴趣,可以通过访问本书的 GitHub 仓库并浏览Chapter10/linksrus/service/frontend
文件夹下的summarizer.go
文件来查看它。
突出显示搜索关键词
一旦我们为匹配的文档生成了摘要,我们需要识别并突出显示其中存在的所有搜索关键词。为此任务,我们将创建一个名为matchHighlighter
的辅助类型,它构建一组正则表达式以匹配每个搜索关键词,并用一个特殊的 HTML 标签将其包裹起来,我们的前端模板使用突出显示样式渲染。
前端通过调用newMatchHighlighter
函数创建一个用于整个结果集的单一matchHighlighter
实例,该函数在以下代码中列出:
func newMatchHighlighter(searchTerms string) *matchHighlighter {
var regexes []*regexp.Regexp
for _, token := range strings.Fields(strings.Trim(searchTerms, `"`)) {
re, err := regexp.Compile(
fmt.Sprintf(`(?i)%s`, regexp.QuoteMeta(token)),
)
if err != nil {
continue
}
regexes = append(regexes, re)
}
return &matchHighlighter{regexes: regexes}
}
构造函数接收用户的搜索词作为输入,并将它们分割成一个单词列表。请注意,如果用户正在搜索一个精确短语,搜索词将被引号包围。因此,在将术语字符串传递给strings.Fields
之前,我们需要删除输入字符串开头和结尾的任何引号。
然后,对于每个单独的术语,我们编译一个不区分大小写的正则表达式,该表达式将由Highlight
方法使用。具体如下:
func (h *matchHighlighter) Highlight(sentence string) string {
for _, re := range h.regexes {
sentence = re.ReplaceAllStringFunc(sentence, func(match string) string {
return "<em>" + match + "</em>"
})
}
return sentence
}
Highlight
方法简单地遍历正则表达式列表,并将每个匹配项包裹在一个<em>
标签中,我们的结果页面模板可以使用 CSS 规则来样式化。
协调单个服务的执行
到目前为止,我们已经为我们的单体应用创建了三个服务,它们都实现了Service
接口。现在,我们需要引入一个协调它们执行并确保在它们中的任何一个报告错误时都能干净地终止的监督器。让我们定义一个新的类型,以便我们可以模拟一组服务,并添加一个辅助Run
方法来管理它们的执行生命周期:
type Group []Service
func (g Group) Run(ctx context.Context) error {...}
现在,让我们将 Run
方法的实现分解成更小的部分,并逐一分析:
if ctx == nil {
ctx = context.Background()
}
runCtx, cancelFn := context.WithCancel(ctx)
defer cancelFn()
如您所见,首先,我们创建了一个新的可取消上下文,该上下文包装了由 Run
方法调用者外部提供给我们的一个上下文。包装上下文将作为参数传递给每个单独服务的 Run
方法,从而确保所有服务都可以通过两种方式之一取消:
-
如果调用者,例如,提供的上下文被取消或过期
-
如果任何服务引发错误,则由管理器处理
接下来,我们将为组中的每个服务启动一个 goroutine 并执行其 Run
方法,如下所示:
var wg sync.WaitGroup
errCh := make(chan error, len(g))
wg.Add(len(g))
for _, s := range g {
go func(s Service) {
defer wg.Done()
if err := s.Run(runCtx); err != nil {
errCh <- xerrors.Errorf("%s: %w", s.Name(), err)
cancelFn()
}
}(s)
}
如果发生错误,goroutine 将使用服务名称对其进行注释,并将其写入缓冲错误通道,然后在调用包装上下文的取消函数之前。因此,如果任何服务失败,所有其他服务将自动被指示关闭。
sync.WaitGroup
帮助我们跟踪当前运行的 goroutine。如前所述,我们正在处理长时间运行的服务,其 Run
方法仅在上下文被取消或发生错误时返回。在任何情况下,包装上下文都将过期,这样我们的服务运行器就可以等待此事件发生,然后调用等待组的 Wait
方法,以确保在继续之前所有派生的 goroutine 都已终止。以下代码演示了如何实现这一点:
<-runCtx.Done()
wg.Wait()
在返回之前,我们必须检查错误的存在。为此,我们关闭错误通道,以便可以使用 range
语句迭代它。关闭通道是安全的,因为所有可能写入它的 goroutine 都已经终止。考虑以下代码:
var err error
close(errCh)
for srvErr := range errCh {
err = multierror.Append(err, srvErr)
}
return err
如前文代码片段所示,在关闭通道后,代码将取消队列并汇总任何报告的错误,并将它们返回给调用者。注意,如果没有发生错误,将返回一个 nil 错误值。
将所有内容整合在一起
主包作为我们应用程序的入口点。它将各种服务的配置选项作为命令行标志暴露出来,并负责以下事项:
-
为链接图(内存或 CockroachDB)和文本索引器(内存或 Elasticsearch)实例化适当的数据存储实现
-
使用正确的配置选项实例化各种应用程序服务
runMain
方法实现了应用程序的主循环:
func runMain(logger *logrus.Entry) error {
svcGroup, err := setupServices(logger)
if err != nil {
return err
}
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
return svcGroup.Run(ctx)
}
如前文代码所示,第一行实例化了所有必需的服务并将它们添加到 Group
中。然后,创建了一个新的可取消上下文,并用于调用组的(阻塞)Run
方法。
以干净的方式终止应用程序
到目前为止,你可能想知道:应用程序是如何终止的?答案是应用程序从操作系统接收信号。Go 标准库中的signal
包提供了一个Notify
函数,允许应用程序注册并接收当应用程序接收特定信号类型时的通知。常见的信号类型包括以下:
-
SIGINT
,当用户按下Ctrl + C时通常发送到前台应用程序。 -
SIGHUP
,许多应用程序(例如,HTTP 服务器)挂钩并用作重新加载其配置的触发器。 -
SIGKILL
,在操作系统杀死应用程序之前发送给应用程序。这个特定的信号无法捕获。 -
SIGQUIT
,当用户按下 Ctrl+ _ 时发送到前台应用程序。Go 运行时挂钩此信号,以便在终止应用程序之前打印每个正在运行的 goroutine 的堆栈。
由于我们的应用程序将以 Docker 容器的形式运行,我们只对处理SIGINT
(由 Kubernetes 在 Pod 即将关闭时发送)和SIGHUP
(用于调试目的)感兴趣。由于前面的代码块在组的Run
方法上,我们需要使用 goroutine 来监视传入的信号:
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGHUP)
select {
case s := <-sigCh:
cancelFn()
case <-ctx.Done():
}
}()
在收到指定的信号之一后,我们立即调用上下文的取消函数并返回。这一动作将导致组中的所有服务干净地关闭,并且svcGroup.Run
调用返回,从而允许runMain
也返回,并使应用程序终止。
将单体应用 Docker 化并启动单个实例
Chapter10/linksrus
包附带一个 Dockerfile,其中包含了构建单体应用的 Docker 化版本所需的步骤,然后你可以运行它,或者根据下一节中的指南将其部署到 Kubernetes。
要创建用于测试目的的 Docker 镜像,你只需在包目录中输入make dockerize
。或者,如果你希望构建并将生成的镜像推送到 Docker 注册表,你可以输入make dockerize-and-push
。Makefile 目标假设你正在运行 Minikube,并且已经根据前几节的说明启用了私有注册表插件。
由这个 Makefile 创建的所有 Docker 镜像的标签都将包括私有注册表的 URL 作为前缀。例如,如果当前 Minikube 使用的 IP 是192.168.99.100
,生成的镜像将被标记如下:
-
192.168.99.100/linksrus-monolith:latest
-
192.168.99.100/linksrus-monolith:$GIT_SHA
如果你想要使用不同的私有注册表(例如,如果你使用 microk8s,则localhost:32000
),你可以运行make PRIVATE_REGISTRY=localhost:32000 dockerize-and-push
代替。
另一方面,如果您想将镜像推送到公共Docker 注册库,您可以通过使用带有make PRIVATE_REGISTRY=dockerize-and-push
的空PRIVATE_REGISTRY
环境变量来调用命令。
为了让那些不想启动 Kubernetes 集群来测试单体应用程序的人更容易,应用程序默认的命令行值将以独立模式启动应用程序:
-
将使用内存存储来存储链接图和文本索引器
-
每隔 5 分钟将触发一个新的爬虫遍历,每小时将进行一次 PageRank 重新计算。
-
前端暴露在端口
8080
默认的递减设置使得通过运行命令(如go run main.go
)在本地启动应用程序或通过运行docker run -it --rm -p 8080:8080 $(minikube ip):5000/linksrus-monolith:latest
在 Docker 容器内启动应用程序变得容易。
在 Kubernetes 上部署和扩展单体
在本章的最后部分,我们将部署 Links 'R' Us 单体应用程序到 Kubernetes 上,并通过水平扩展我们的部署来测试分区逻辑。
以下图示说明了我们的最终设置将是什么样子。正如您所看到的,我们将使用 Kubernetes 命名空间来逻辑上分割部署的各种组件:
图 6:在 Kubernetes 上部署 Links 'R' Us 的单体版本
从前面的图中,我们可以看到linksrus-data
命名空间将托管我们的数据存储,这些存储将以高可用模式配置。CockroachDB 集群由多个节点组成,这些节点隐藏在名为cdb-cockroachdb-public
的 Kubernetes 服务资源后面。我们的应用程序可以通过服务的 DNS 条目cdb-cockroachdb-public.linksrus-data
访问 DB 集群。Elasticsearch 集群遵循完全相同的模式;它也提供了一个服务,我们可以通过连接到elasticsearch-master.linksrus-data:9200
来访问主节点。
另一方面,linksrus
命名空间是我们将应用程序作为由四个副本组成的 StatefulSet 部署的地方。副本数量的选择是任意的,并且可以在任何时间点通过重新配置 StatefulSet 轻松向上或向下调整。
要查询所有 Pod 在 StatefulSet 中的 SRV 记录,我们将创建一个无头Kubernetes 服务。这个服务使我们能够使用我们在爬虫服务部分描述的分区发现代码。在我们将前端暴露给外部世界之前,我们需要创建另一个 Kubernetes 服务,该服务将作为负载均衡器,将传入流量分发到我们 StatefulSet 中的 Pod。
我们部署配方中的最后一个成分是一个 Ingress 资源,它将允许我们的最终用户通过互联网访问前端服务。
在以下章节中,我们将要处理的每个 Kubernetes 清单都可在本书 GitHub 存储库的Chapter10/k8s
文件夹中找到。在同一个文件夹中,你可以找到一个包含以下便捷目标的 Makefile:
-
bootstrap-minikube
: 使用 Minikube 引导 Kubernetes 集群并安装部署“Links 'R' Us”所需的所有附加组件 -
deploy
: 部署“Links 'R' Us”项目的所有组件,包括数据存储 -
purge
: 删除通过make deploy
安装的所有组件 -
dockerize-and-push
: 为“Links 'R' Us”项目构建并推送所有必需的容器镜像
设置所需的命名空间
要创建部署所需的命名空间,你需要切换到Chapter10/k8s
文件夹,并运行以下命令来应用01-namespaces.yaml
清单:
kubectl apply -f 01-namespaces.yaml
应用清单后,当你运行kubectl get namespaces
时,应该会显示新的命名空间。以下截图显示了 Kubernetes 集群命名空间列表:
图 7:列出 Kubernetes 集群命名空间
以下步骤包括部署我们的数据库服务,然后是部署单体“Links 'R' Us”应用程序。
使用 Helm 部署 CockroachDB 和 Elasticsearch
设置 CockroachDB 和 Elasticsearch 集群相当繁琐,涉及到应用相当多的清单。我们实际上会采取作弊的方式,使用helm
工具来部署这两个数据存储!
对于 CockroachDB,我们可以运行以下命令来部署一个三节点集群:
helm install --namespace=linksrus-data --name cdb \
--values chart-settings/cdb-settings.yaml \
--set ImageTag=v19.1.5 \
stable/cockroachdb
前面命令引用的cdb-settings.yaml
文件包含对默认图表值的覆盖,限制生成的数据库实例使用 512 M 的 RAM 和 100 M 的磁盘空间。
Elasticsearch 的helm
图表目前维护在外部存储库中,在我们可以继续安装之前,必须使用helm
注册。类似于 CockroachDB,还提供了一个设置覆盖文件,限制 Elasticsearch 主节点使用 512 M 的 RAM 和 300 M 的磁盘空间。以下命令将负责 Elasticsearch 的部署:
helm repo add elastic https://helm.elastic.co
helm install --namespace=linksrus-data --name es \
--values chart-settings/es-settings.yaml \
--set imageTag=7.4.0 \
elastic/elasticsearch
在运行所有前面的命令后,你应该能够输入kubectl -n linksrus-data get pods
并看到以下类似的输出:
图 8:列出 linksrus-data 命名空间中的 Pod
一旦所有数据存储 Pod 都显示为运行中,我们就可以部署“Links 'R' Us”了!
部署“Links 'R' Us”
在我们能够创建 Links 'R' Us StatefulSet 之前,还有一个方面需要我们注意:CockroachDB 实例并不知道链接图的架构。不用担心……我们可以通过启动一个一次性容器来解决这个问题,该容器将为链接图创建数据库并应用来自第六章,构建持久层的架构迁移。
你可以在Chapter10/cdb-schema
文件夹中找到这个容器的源代码和 Dockerfile。假设你目前使用 Minikube 作为你的集群,你可以在上一个文件夹中运行以下命令来创建 Docker 镜像并将其推送到 Minikube 公开的私有仓库:
make dockerize-and-push
回到Chapter10/k8s
文件夹中的清单,你可以应用02-cdb-schema.yaml
清单来创建一个一次性 Kubernetes Job
,该 Job 等待 DB 集群变得可用,确保链接图数据库和架构是最新的,然后退出。以下是这个 YAML 文件的内容:
apiVersion: batch/v1
kind: Job
metadata:
name: cdb-ensure-schema
namespace: linksrus-data
spec:
template:
spec:
containers:
- name: cdb-schema
imagePullPolicy: Always
image: localhost:5000/cdb-schema:latest
args:
- "linkgraph"
- "cdb-cockroachdb-public.linksrus-data"
restartPolicy: Never
最后,我们可以通过应用03-linksrus-monolith.yaml
清单来部署剩余的 Links 'R' Us 资源。如果你还没有这样做,确保在应用清单之前,在Chapter10/linksrus
文件夹中运行make dockerize-and-push
,以确保 Kubernetes 可以找到引用的容器镜像。
k8s
文件夹中的 Makefile 还定义了一个dockerize-and-push
目标,它可以构建并推送运行 Links 'R' Us 演示所需的所有容器镜像,只需一个命令即可。
几秒钟后,你可以输入kubectl -n linksrus get pods,statefulsets,services,ingresses
来获取我们刚刚部署的所有资源的列表。以下截图显示了此命令的预期输出:
图 9:列出 linksrus 命名空间中的所有资源
成功!我们的单体应用已经部署并连接到了linksrus-data
命名空间中的数据存储。你可以通过将浏览器指向你的 ingress IP 地址来访问前端服务。在上面的输出中,我正在 VM 中使用 Minikube,因此显示的 ingress 地址无法从主机访问。然而,你可以通过运行minikube ip
并指向它来轻松地找到 Minikube 使用的公共 IP。
你可以使用kubectl -n linksrus logs linksrus-monolith-instance-X -f
命令跟踪 StatefulSet 中每个单独 pods 的日志,其中X是集合中的 pods 编号。
此外,你还可以使用kubectl -n linksrus logs -lapp=linksrus-monolith-instance -f
命令来跟踪集中所有 pods 的日志。
摘要
在本章中,我们学习了如何以产生尽可能小尺寸容器镜像的方式对 Go 应用程序进行 docker 化。然后,我们讨论了 Kubernetes 背后的设计哲学和总体架构,并详细说明了您可以在 Kubernetes 集群上创建和管理的不同类型资源。在本章的最后部分,我们将 Links 'R' Us 项目的第一个完全功能版本拼凑起来,并将其作为单个单体应用程序部署到 Kubernetes 上。
在下一章中,我们将讨论在切换到微服务架构时可能遇到的挑战和潜在问题。
问题
-
列举一些容器化的好处。
-
Kubernetes 集群中主节点和工作节点之间的区别是什么?
-
正常服务和无头服务之间的区别是什么?
-
您会使用哪种 Kubernetes 资源来与您的前端共享 OAuth2 客户端 ID 和密钥?
-
解释部署和 StatefulSet 之间的区别。
进一步阅读
-
Alpine Linux:基于 musl libc 和 busybox 的安全导向、轻量级 Linux 发行版。
alpinelinux.org
. -
Calico:云原生时代的网络安全。
www.projectcalico.org
. -
Cilium:API 感知的网络和安全。
cilium.io
. -
Containerd:一个以简洁、健壮和可移植性为重点的行业标准容器运行时。
containerd.io
. -
Docker:企业级容器平台。
www.docker.com
. -
Helm:Kubernetes 的包管理器。
helm.sh
. -
K3S:轻量级 Kubernetes。
k3s.io/
. -
Kubernetes:生产级容器编排。
www.kubernetes.io
. -
Microk8s:工作站和边缘/物联网的零操作 Kubernetes。
microk8s.io
. -
Minikube:专注于应用程序开发和教育的本地 Kubernetes。
minikube.sigs.k8s.io
. -
Multipass:编排虚拟 Ubuntu 实例。
multipass.run/
. -
rkt:一个以安全性和标准为基础的容器引擎。
coreos.com/rkt
. -
VirtualBox:适用于企业及家庭使用的强大 x86 和 AMD64/Intel64 虚拟化产品。
www.virtualbox.org
.
第四部分:扩展以处理用户数量的增长
本书本节评估了不同方法以实现分布式系统的水平扩展和监控。
本节包含以下章节:
-
第十一章, 将单体应用拆分为微服务
-
第十二章, 构建分布式图处理系统
-
第十三章, 指标收集和可视化
-
第十四章, 结语
第十一章:将单体分解为微服务
“如果组件不能干净地组合(在迁移到微服务时),那么你所做的只是将复杂性从组件内部转移到组件之间的连接。这不仅只是移动复杂性;它将复杂性移动到一个不太明确且更难控制的地方。”
– 马丁·福勒和詹姆斯·刘易斯
本章介绍了面向服务架构(SOA)的概念,并将其与传统单体设计模式进行比较。这将帮助我们讨论微服务面临的各项挑战,如日志记录、跟踪和服务自省,并提供减少迁移到 SOA 时的痛点的建议。
在本章的末尾,我们将把上一章中的单体“链接之我行”实现分解为几个微服务,并将它们部署到 Kubernetes。
本章将涵盖以下主题:
-
何时从单体设计切换到基于微服务的架构是合适的?
-
微服务实现的常见反模式和如何规避它们
-
通过分布式系统跟踪请求
-
日志记录的最佳实践和要避免的陷阱
-
对实时 Go 服务的自省
-
将“链接之我行”单体分解为微服务并将它们部署到 Kubernetes
-
使用 Kubernetes 网络策略锁定对微服务的访问
通过利用本章所获得的知识,你将能够水平扩展自己的项目,以更好地处理 incoming traffic 的峰值。
技术要求
本章将要讨论的主题的完整代码已发布到本书的 GitHub 仓库中的Chapter11
文件夹下。
你可以通过将网络浏览器指向以下 URL 来访问本书的 GitHub 仓库,该仓库包含本书每个章节的代码和所有必需的资源:github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
。
为了让你尽快开始,每个示例项目都包含一个 Makefile,它定义了以下目标集:
Makefile 目标 | 描述 |
---|---|
deps |
安装任何必需的依赖项 |
test |
运行所有测试并报告覆盖率 |
lint |
检查 lint 错误 |
与本书中的所有其他章节一样,你需要一个相当新的 Go 版本,你可以在golang.org/dl/
下载它。
要运行本章的一些代码示例,你需要在你的机器上有一个工作的 Docker 安装。
此外,一些示例被设计为在 Kubernetes 上运行。如果你没有访问 Kubernetes 集群进行测试,你可以简单地遵循以下章节中的说明,在你的笔记本电脑或工作站上设置一个小型集群。
单体架构与面向服务的架构
在过去几年中,越来越多的组织,尤其是在初创企业领域,已经开始积极采用 SOA 范式,无论是构建新系统还是对现有遗留系统进行现代化改造。
SOA 是一种创建系统的架构方法,这些系统由可能用不同编程语言编写的自主服务组成,并通过网络链接进行通信。
在接下来的章节中,我们将更详细地研究这种架构模式,并突出一些从单体应用程序迁移到微服务的最佳实践。同时,我们将探讨一些可能阻碍向基于微服务的架构过渡的常见反模式。
单体架构本身有什么固有的问题吗?
在您决定跳入水并转换您的单体应用程序为 SOA 之前,您应该稍作停顿,问问自己:基于微服务的架构设计是否是当前这个时间点适合我的应用程序的正确模型?
不要被围绕微服务的炒作所影响!尽管这种模型在谷歌、Netflix 或 Twitter 等公司的大规模应用中效果显著,但这并不意味着它也适用于您的特定用例。
单体系统设计已经存在很长时间,并且在支持业务关键系统方面一次又一次地证明了自己的价值。正如俗话所说:如果它对银行和航空公司足够好,那么它可能也适合您的下一个创业想法!
在许多情况下,转向基于微服务的架构的决定纯粹是由必要性驱动的;将大型单体系统扩展以应对流量不规则激增可能会非常昂贵,并且往往会导致我们可利用的资源利用率低下。这是一个很好的例子,说明转向微服务可能会产生可观察和可衡量的效果。
另一方面,如果您正在构建一个新产品或最小可行产品(MVP),从单体设计开始并从一开始引入正确的抽象,以便更容易地过渡到微服务,如果需要的话,这总是更容易。
许多新成立的初创公司陷入了这样的思维定式,认为微服务是继切片面包之后的最佳选择,而忘记了这种架构的潜在成本:复杂性增加,这直接转化为对 DevOps 需求的增加。因此,工程团队往往会在调试通信问题或设置复杂的监控微服务方案上花费大量开发时间,而不是将精力集中在构建和开发核心产品上。
微服务反模式及其处理方法
现在,让我们看看在处理基于微服务的项目时可能会遇到的反模式,并探讨处理它们的替代方法。
共享数据库可能是工程师在第一次尝试将单体应用拆分为微服务时犯的最大错误。一般来说,每个微服务都必须配备其自己的、私有的数据存储(假设它需要的话),并公开一个 API,以便其他微服务可以访问它。这种模式为我们提供了灵活性,可以根据每个特定微服务的需求选择最合适的技术(例如,NoSQL、关系型)。
微服务之间的通信可能会因为各种原因(例如,服务崩溃、网络分区或数据包丢失)而失败。正确的微服务实现应该基于这样的假设:出站调用可能在任何时候失败。当事情出错时,微服务不应该立即因为错误而退出,而应该始终实现某种形式的重试逻辑。
前述陈述的一个推论是,当连接到远程微服务在收到回复之前断开时,客户端无法确定远程服务器是否实际上成功处理了请求。根据前面的建议,客户端通常会重试调用。因此,每个公开 API 的微服务都必须以这种方式编写,使得请求始终是幂等的。
另一个常见的反模式是允许一个服务成为整个系统的单点故障。想象一下这样一个场景:你有三个服务,它们都依赖于由第四个下游服务公开的数据。如果后者服务配置不足,对三个上游服务的突发流量请求可能会导致对下游服务的请求超时。然后,上游服务会重试它们的请求,进一步增加下游服务的负载,直到它变得无响应或崩溃。结果,上游服务现在开始经历更高的错误率,这影响了其他上游服务对它们的调用,等等。
为了避免这种情况,微服务可以实现断路器模式:当特定下游服务的错误数量超过特定阈值时,断路器被触发,所有未来的请求都会自动以错误失败。定期地,断路器会允许一些请求通过,并在收到一定数量的成功响应后,断路器切换回开启位置,允许所有请求通过。
通过将此模式应用于您的微服务,我们允许下游服务从负载峰值或崩溃中恢复。此外,当下游服务不可用时,一些服务可能能够使用缓存的数据进行响应,从而确保系统即使在出现问题时也能保持功能正常。
正如我们已经解释过的,基于微服务的架构本质上很复杂,因为它们由大量移动部件组成。我们可能犯的最大错误是在没有为收集每个微服务的日志输出和监控其健康状态建立必要的基础设施之前就切换到这种架构。如果没有这种基础设施,我们实际上是在盲目飞行。在下一节中,我们将探讨几种不同的微服务仪表化和监控方法。
监控您的微服务状态
在以下章节中,我们将分析一系列用于监控微服务部署状态的不同的方法:
-
请求跟踪
-
日志收集和聚合
-
使用
pprof
对实时 Go 服务进行内省
通过分布式系统跟踪请求
在您可能拥有数百或数千个微服务运行的分布式系统中,请求跟踪是确定瓶颈、理解各个服务之间的依赖关系以及找出影响生产系统问题的根本原因的无价工具。
跟踪背后的想法是为传入的(通常是外部的)请求添加一个唯一的标识符,并在它通过系统传播时跟踪它,从一个微服务跳到另一个微服务,直到它最终退出系统。
分布式跟踪系统的概念绝对不是新的。事实上,像 Google 的 Dapper^([17])和 Twitter 的 Zipkin^([16])这样的系统已经存在了近十年。那么,为什么不是每个人都跳上这辆马车,为自己的代码库实现它呢?原因很简单:直到现在,更新整个代码库以支持请求跟踪曾经是一项艰巨的任务。
想象一个系统,其中组件通过不同类型的传输相互通信,也就是说,一些微服务使用 REST,其他使用 gRPC,还有一些可能通过 WebSockets 交换事件。确保请求 ID 被注入到所有发出的请求中,并在接收端反序列化,需要在所有微服务中实施相当多的努力。更重要的是,如果您选择走这条路,您将需要做一些研究,选择一个要使用的跟踪供应商,并最终将其(通常是专有的)API 集成,这将有效地将您锁定在其产品中。
一定要有更好的方法来实现请求跟踪!
OpenTracing 项目
OpenTracing ^([18]) 项目是为了解决我们在上一节中概述的 exactly set of problems 而创建的。它提供了一个标准化的、供应商中立的 API,软件工程师可以使用它来对其代码库进行配置,以启用对请求跟踪的支持。此外,OpenTracing 不仅规定了跨服务边界传输跟踪上下文的适当编码,而且还提供了 API 以促进通过 REST 和 gRPC 传输交换跟踪上下文。
在我们继续之前,让我们花些时间解释一下我们将在以下部分中大量使用的术语。请求跟踪由一系列 跨度 组成。跨度表示在微服务内部执行的时间单位的工作。在典型场景中,当服务收到请求时开始一个新的跨度,当服务返回响应时结束。
此外,跨度也可以嵌套。如果服务 A 在发送响应之前需要联系下游服务 B 和 C 以获取额外的数据,那么 B 和 C 的跨度可以作为 A 的跨度的子跨度添加。因此,请求跟踪可以被视为一个 跨度树,其根是接收初始请求的服务。
演示分布式跟踪示例
为了理解分布式跟踪是如何工作的,让我们构建一个小型演示应用程序,该应用程序模拟从多个供应商收集特定 SKU 的价格报价的系统。您可以在本书 GitHub 存储库的 Chapter11/tracing
文件夹中找到此演示的完整源代码。
我们的系统将具有三种类型的服务,所有这些服务都将建立在 gRPC 之上:
-
provider 服务返回单个供应商的价格报价。在我们的示例场景中,我们将启动多个 provider 实例来模拟不同的供应商系统。
-
一个 aggregator 服务,它将传入的查询发送到一系列下游服务(提供者或其他聚合器),收集响应并返回聚合结果。
-
一个 API 网关 服务,它将作为捕获的请求跟踪的根。在现实世界中,API 网关将处理来自用户浏览器上运行的前端应用程序的请求。
让我们先列出服务的协议缓冲区和 RPC 定义:
message QuotesRequest {
string SKU = 1;
}
message QuotesResponse {
repeated Quote quotes = 1;
}
message Quote {
string vendor = 1;
double price = 2;
}
service QuoteService {
rpc GetQuote(QuotesRequest) returns (QuotesResponse);
}
如您所见,我们定义了一个名为 GetQuote
的单一 RPC,它接收一个 QuotesRequest
并返回一个 QuotesResponse
。响应只是一个包含 Quote
对象的集合,每个对象都包含一个 vendor
字段和一个 price
字段。
提供者服务
首先也是最简单的服务是 Provider
。以下是对 Provider
类型及其构造函数的定义:
type Provider struct {
vendorID string
}
func NewProvider(vendorID string) *Provider {
return &Provider{ vendorID: vendorID }
}
接下来,我们将实现 GetQuote
方法,如前述协议缓冲定义中指定。为了使我们的示例尽可能简单,我们将提供一个模拟实现,它返回一个具有随机价格值和作为 NewProvider
构造函数参数传递的 vendorID
值的单个报价:
func (p *Provider) GetQuote(ctx context.Context, req *proto.QuotesRequest) (*proto.QuotesResponse, error) {
return &proto.QuotesResponse{
Quotes: []*proto.Quote{
&proto.Quote{ Vendor: p.vendorID, Price: 100.0 * rand.Float64() },
},
}, nil
}
为了模拟微服务架构,我们的主文件将启动多个此服务的实例。每个服务实例将创建自己的 gRPC 服务器并将其绑定到一个随机端口。让我们为 Provider
类型实现此功能:
func (p *Provider) Serve(ctx context.Context) (string, error) {
return doServe(ctx, p, tracer.MustGetTracer(p.vendorID))
}
tracer
包封装了创建满足 opentracing.Tracer
接口的跟踪实例所需的逻辑。获得的跟踪器将用于我们创建的每个服务,以便它可以收集和报告跨度。在接下来的章节中,我们将探讨在为我们的示例选择合适的跟踪提供者时,该包的实现。
在获取跟踪器后,Serve
方法调用 doServe
,其任务是向一个随机可用的端口公开 gRPC 服务器并返回其监听地址。以下代码块中列出的 doServe
代码已被有意提取,因为我们将会使用它来实现聚合服务:
func doServe(ctx context.Context, srv proto.QuoteServiceServer, tracer opentracing.Tracer) (string, error) {
l, err := net.Listen("tcp", ":0")
if err != nil {
return "", err
}
tracerOpt := grpc.UnaryInterceptor(otgrpc.OpenTracingServerInterceptor(tracer))
gsrv := grpc.NewServer(tracerOpt)
proto.RegisterQuoteServiceServer(gsrv, srv)
go func() {
go func() { _ = gsrv.Serve(l) }()
<-ctx.Done()
gsrv.Stop()
_ = l.Close()
}()
return l.Addr().String(), nil
}
在前面的函数中,前几行代码请求 net
包通过传递 :0
作为监听地址来监听一个随机空闲端口。下一行是真正发生魔法的地方!grpc-opentracing
^([10]) 包提供了 gRPC 拦截器,可以从传入的 gRPC 请求中解码跟踪相关信息并将它们 嵌入 到传递给 RPC 方法实现的请求上下文中。
gRPC 拦截器是一种中间件,它包装 RPC 调用并提供额外的功能。根据被包装的调用类型,拦截器被分类为单一或流式。
此外,拦截器可以在服务器端或客户端应用。在服务器端,拦截器通常用于实现诸如身份验证、日志记录和指标收集等功能。客户端拦截器可以用于实现如断路器或重试等模式。
由于我们的服务仅定义了单一 RPC,我们需要创建一个单一拦截器并将其传递给 grpc.NewServer
函数。然后,我们将 RPC 实现注册到服务器并启动一个 goroutine,以便我们可以开始服务请求直到提供的上下文过期。在 goroutine 运行期间,函数返回服务器监听器的地址。
聚合服务
我们接下来要实现的服务是 Aggregator
类型。如下代码片段所示,它存储了一个供应商 ID、要查询的提供者地址列表以及这些地址的 gRPC 客户端列表:
type Aggregator struct {
vendorID string
providerAddrs []string
clients []proto.QuoteServiceClient
}
func NewAggregator(vendorID string, providerAddrs []string) *Aggregator {
return &Aggregator{
vendorID: vendorID,
providerAddrs: providerAddrs,
}
}
当调用 Serve
方法时,gRPC 客户端是延迟创建的:
func (a *Aggregator) Serve(ctx context.Context) (string, error) {
tracer := tracer.MustGetTracer(a.vendorID)
tracerClientOpt := grpc.WithUnaryInterceptor(otgrpc.OpenTracingClientInterceptor(tracer))
for _, addr := range a.providerAddrs {
conn, err := grpc.Dial(addr, grpc.WithInsecure(), tracerClientOpt)
if err != nil {
return "", xerrors.Errorf("dialing provider at %s: %w", addr, err)
}
a.clients = append(a.clients, proto.NewQuoteServiceClient(conn))
}
return doServe(ctx, a, tracer)
}
这次,我们创建了一个客户端单例拦截器,并将其作为选项传递给每个我们拨打的客户端连接。然后,我们调用之前章节中检查的doServe
辅助函数,以便我们可以启动我们的服务器。服务器和客户端都使用拦截器确保我们从传入请求中接收到的跟踪上下文信息会自动注入到任何出去的 gRPC 请求中,而无需我们做任何事情。
最后,让我们检查Aggregator
类型的GetQuote
方法是如何实现的:
func (a *Aggregator) GetQuote(ctx context.Context, req *proto.QuotesRequest) (*proto.QuotesResponse, error) {
// Run requests in parallel and aggregate results
aggRes := new(proto.QuotesResponse)
for quotes := range a.sendRequests(ctx, req) {
aggRes.Quotes = append(aggRes.Quotes, quotes...)
}
return aggRes, nil
}
这个方法相当直接。它所做的只是分配一个新的QuotesResponse
,调用sendRequests
辅助函数,将结果展平到一个列表中,并将其返回给调用者。sendRequests
方法并行查询下游提供者,并返回一个发布报价的通道:
func (a *Aggregator) sendRequests(ctx context.Context, req *proto.QuotesRequest) <-chan []*proto.Quote {
var wg sync.WaitGroup
wg.Add(len(a.clients))
resCh := make(chan []*proto.Quote, len(a.clients))
for _, client := range a.clients {
go func(client proto.QuoteServiceClient) {
defer wg.Done()
if res, err := client.GetQuote(ctx, req); err == nil {
resCh <- res.Quotes
}
}(client)
}
go func() {
wg.Wait()
close(resCh)
}()
return resCh
}
注意从GetQuote
传递到client.GetQuote
调用中的请求上下文参数。这就是我们将此服务的跨度与下游服务的跨度关联所需做的全部工作。简单,对吧?
网关
网关服务不过是在 gRPC 客户端之上的包装。其实现中有趣的部分是CollectQuotes
方法,这是我们的主包将调用来开始一个新的跟踪:
func (gw *Gateway) CollectQuotes(ctx context.Context, SKU string) (map[string]float64, error) {
span, ctx := opentracing.StartSpanFromContext(ctx, "CollectQuotes")
defer span.Finish()
res, err := gw.client.GetQuote(ctx, &proto.QuotesRequest{SKU: SKU})
if err != nil {
return nil, err
}
quoteMap := make(map[string]float64, len(res.Quotes))
for _, quote := range res.Quotes {
quoteMap[quote.Vendor] = quote.Price
}
return quoteMap, nil
}
这里,我们使用StartSpanFromContext
来创建一个新的命名跨度,并将其跟踪详细信息嵌入到一个新的上下文中,该上下文包装了作为方法参数提供的上下文。
代码的其余部分相当直观:我们在嵌入的客户端实例上调用GetQuote
方法,收集响应,并将它们放入一个映射中,然后将其返回给调用者。
将所有内容整合在一起
主要文件通过调用deployServices
辅助函数来准备一个微服务部署环境。这里的想法是将服务以这种方式连接起来,以便通过系统跟踪请求将产生一个有趣的跟踪图。让我们看看这是如何实现的。
首先,辅助函数启动三个Provider
实例,并跟踪它们的地址:
var err error
providerAddrs := make([]string, 3)
for i := 0; i < len(providerAddrs); i++ {
provider := service.NewProvider(fmt.Sprintf("vendor-%d", i))
if providerAddrs[i], err = provider.Serve(ctx); err != nil {
return nil, err
}
}
然后,它启动一个Aggregator
实例,并将其设置为连接到前面列表中的提供者1和2:
aggr1 := service.NewAggregator("aggr-1", providerAddrs[1:])
aggr1Addr, err := aggr1.Serve(ctx)
if err != nil {
return nil, err
}
随后,它实例化另一个Aggregator
类型,并将其连接到提供者0和刚刚创建的聚合器:
aggr0 := service.NewAggregator("aggr-0", []string{providerAddrs[0], aggr1Addr})
aggr0Addr, err := aggr0.Serve(ctx)
if err != nil {
return nil, err
}
最后,创建一个以先前聚合器为目标并返回给调用者的Gateway
实例:
return service.NewGateway("api-gateway", aggr0Addr)
由deployServices
函数返回的Gateway
实例被runMain
用于触发一个标记新请求跟踪开始的报价查询的执行:
func runMain(ctx context.Context) error {
gw, err := deployServices(ctx)
if err != nil {
return err
}
defer func() { _ = gw.Close() }()
res, err := gw.CollectQuotes(ctx, "example")
if err != nil {
return err
}
fmt.Printf("Collected the following quotes:\n")
for k, v := range res {
fmt.Printf(" %q: %3.2f\n", k, v)
}
return nil
}
在下一节中,我们将把一个跟踪器实现连接到我们的代码中,以便我们可以捕获和可视化我们的代码生成的请求跟踪。
使用 Jaeger 捕获和可视化跟踪
在前面的章节中,我们看到了 OpenTracing 如何允许我们在微服务边界之间创建和传播跨度信息。但是,如何 以及更重要的是,在哪里 收集和处理这些信息?毕竟,如果没有切片和切块的手段,仅仅有这些信息将大大降低其价值。
正如我们之前提到的,OpenTracing 框架的一个关键设计目标是避免供应商锁定。为此,在跨度收集和可视化的方面,您可以选择一个开源解决方案,如 Uber 的 Jaeger ^([11]) 或 Elastic 的 APM ^([5]),您自己托管。或者,您可以使用几种可用的 软件即服务 (SaaS) 解决方案 ^([19])。
就我们的开放跟踪示例而言,我们将使用 Jaeger ^([11]) 作为我们的跟踪器实现。Jaeger 安装简单,易于与我们迄今为止编写的代码集成。它是用 Go 编写的,也可以用作 Zipkin ^([16]) 的直接替换。Jaeger 部署通常由两个组件组成:
-
本地跨度收集代理通常作为侧车容器与您的应用程序一起部署。它收集应用程序通过 UDP 发布的跨度,应用
*可配置*
的概率采样,以便它可以选择要发送到上游的跨度子集,并将它们传输到 Jaeger 收集器服务。 -
收集器服务聚合由各种 Jaeger 代理实例传输的跨度,并将它们持久化到数据存储中。根据新产生的跨度速率,收集器可以配置为在
*直接到存储*
模式下工作,其中它们直接与数据库接口,或者在*流式*
模式下工作,其中 Kafka 实例用作收集器和另一个进程之间的缓冲,该进程消费、索引并将数据存储在数据库中。
为了我们的测试目的,我们将使用官方的集成 Docker 镜像,该镜像包括一个代理和收集器实例(由内存存储支持),以及 Jaeger UI。我们可以使用以下命令启动容器:
docker run -d --name jaeger \
-p 6831:6831/udp \
-p 16686:16686 \
jaegertracing/all-in-one:1.14
端口 6831
是 Jaeger 代理监听我们的仪器化服务通过 UDP 发布的跨度的地方。另一方面,端口 16686
提供了 Jaeger UI,我们可以在这里浏览、搜索和可视化捕获的请求跟踪。
正如我们在前面的章节中提到的,tracer
包将封装实例化新的 Jaeger 跟踪器实例的逻辑。
让我们来看看 GetTracer
函数的实现。我们的服务调用的 MustGetTracer
函数调用 GetTracer
并在出错时崩溃,如下所示:
func GetTracer(serviceName string) (opentracing.Tracer, error) {
cfg, err := jaegercfg.FromEnv()
if err != nil {
return nil, err
}
cfg.ServiceName = serviceName
cfg.Sampler = &jaegercfg.SamplerConfig{
Type: jaeger.SamplerTypeConst,
Param: 1, // Sample all traces generated by our demo
}
tracer, closer, err := cfg.NewTracer()
if err == nil {
// Omitted: keep track of closer so we can close all tracers when exiting
return tracer, nil
}
return err
}
Jaeger 的 Go 客户端提供了创建新跟踪器的一些便利助手。我们在这里选择的方法是从配置对象实例化一个跟踪器,我们可以通过 FromEnv
助手获得这个配置对象。FromEnv
使用一组合理的默认值初始化配置对象,然后检查环境以查找覆盖默认值的 Jaeger 特定变量。例如,可以使用 JAEGER_AGENT_HOST
和 JAEGER_AGENT_PORT
来指定 Jaeger 代理正在监听传入跟踪的地址。默认情况下,代理预计将在 localhost:6831
上监听,这与我们刚刚启动的 Docker 容器公开的端口相匹配。
接下来,我们需要为跟踪器配置采样策略。如果我们正在运行一个具有非常高吞吐量的部署,那么我们不一定希望跟踪每个请求,因为这会产生大量需要存储和索引的数据。为此,Jaeger 允许我们根据特定的应用程序需求配置不同的采样策略:
-
恒定采样器对每个跟踪总是做出相同的决定。这是我们用于示例的策略,以确保每次运行我们的演示时跟踪总是被持久化。
-
概率采样器以特定的概率(例如,10%的跟踪)保留跟踪。
-
速率限制采样器确保以特定的速率(例如,每秒 10 个跟踪)采样跟踪。
以下截图显示了由运行我们刚刚构建的示例应用程序生成的捕获跟踪的详细视图:
图 1:在 Jaeger 的 UI 中可视化请求跟踪
第一行表示在 api-gateway
中等待对出站报价请求的响应所花费的总时间。其下方的行包含对应于并行执行的其他请求的嵌套时间段。以下是发生事件的简要概述:
-
网关向 aggr-0 发出请求并阻塞等待响应。
-
aggr-0 并行发出两个请求:一个发送给 vendor-0,另一个发送给 aggr-1。然后,它在返回给网关响应之前阻塞等待下游响应。
-
aggr-1 并行发出两个请求:一个发送给 vendor-1,另一个发送给 vendor-2。它在返回给 aggr-0 响应之前阻塞等待下游响应。
Jaeger UI 的另一个非常酷的功能是它可以以有向无环图(DAG)的形式显示服务之间的依赖关系。以下截图显示了我们的示例微服务部署的 DAG,它与前面的事件序列相匹配:
图 2:可视化服务之间的依赖关系
总结来说,请求跟踪是深入了解现代复杂基于微服务的系统内部结构的强大工具。我强烈建议您在您的下一个大型项目中考虑使用它。
将日志作为您的可靠盟友
一次又一次地,日志始终证明是调查当代计算机软件问题根本原因的无价资源。然而,在基于微服务的架构中,由于请求跨越服务边界,能够收集、关联和搜索每个单独服务发出的日志条目至关重要。
在接下来的章节中,我们将关注编写简洁、易于搜索的日志条目的最佳实践,并强调一些您绝对想要避免的常见陷阱。此外,我们将讨论从您的 Kubernetes pods(或 docker 化服务)收集和传输日志到中央位置以进行索引的解决方案。
日志最佳实践
我们最佳实践清单上的第一项是 分级日志。当使用分级日志时,您需要考虑两个方面:
-
选择为每条消息使用适当的日志级别:Go 应用程序的大多数日志包至少支持 DEBUG、INFO 和 ERROR 级别。然而,您首选的日志解决方案也可能支持更细粒度的日志级别,例如 TRACE、DEBUG 和 WARNING。
-
决定实际输出哪些日志级别:例如,您可能希望应用程序仅在 INFO 和 ERROR 级别输出消息,以减少产生的日志量。
在调试应用程序时,在日志中包含 DEBUG 或 TRACE 消息也是有意义的,这样您可以更好地了解正在发生的事情。从逻辑上讲,您不应该需要重新编译和重新部署应用程序,只是为了更改其日志级别!为此,实现某种类型的钩子以允许您在应用程序执行时动态更改其活动日志级别是一种良好的做法。以下是一些您可以尝试的建议:
-
当应用程序接收到特定信号(例如,SIGHUP)时,在配置的日志级别和 DEBUG 级别之间切换。如果您的应用程序从配置文件中读取初始日志级别,您可以使用基于信号的相同方法强制重新加载配置文件。
-
暴露一个 HTTP 端点来更改日志级别。
-
将每个应用程序的日志级别设置存储在分布式键值存储中,如 etcd ^([6]),这允许客户端监视键的变化。如果您正在运行应用程序的多个实例,这是一种有效的方法,可以在单步中更改所有实例的日志级别。
如果你还没有这样做,你可以通过切换到结构化日志来提升你的日志记录水平。虽然使用正则表达式从日志中提取时间戳和消息的老方法确实有效,但将你的应用程序更新为输出服务可以轻松解析的日志格式,对于增加每单位时间内可以处理的日志量大有裨益。因此,应用程序日志可以实时或接近实时地搜索,让你能够更快地诊断问题。现在有很多 Go 包实现了结构化日志记录器。如果我们必须挑选一些,我们的列表中肯定会包括sirupsen/logrus
([14])、`uber-go/zap`([20])、rs/zerolog
([21])和`gokit/log`([9])。
那么,结构化日志条目是什么样的呢?以下截图展示了sirupsen/logrus
包如何使用其内置的两种文本格式器格式和打印同一组日志。顶部的终端使用的是更适合在开发机器上运行应用程序的基于文本的格式化器,而底部的终端显示的是相同的 JSON 输出。正如你所见,每个日志条目至少包含一个级别、一个时间戳和一个消息。此外,日志条目可以包含可变数量的键值对:
图 3:由 logrus 生成的示例日志输出
当日志摄入平台消耗这样的日志条目时,它也会索引键值对并将它们提供给搜索。因此,你可以通过多个属性(例如,客户 ID、服务名称和数据中心位置)对日志条目进行切片和切块来编写高度针对性的查询。
总是要确保你的日志条目的消息部分从不包括变量部分。为了解释为什么不遵循这条建议可能会导致问题,让我们看看一个服务产生的两个等效错误消息,该服务的任务是重定向用户:
-
level=error message="无法连接到服务器: dial tcp4: lookup invalid.host: no such host" service=redirector customer-id=42
-
level=error message="无法连接到服务器" err="dial tcp4: lookup invalid.host: no such host" host=invalid.host service=redirector customer-id=42
第一条消息将 Go dialer 返回的错误嵌入到日志消息中。鉴于没有这样的主机错误很可能会随着每个请求而变化,将其添加到日志消息中引入了一个可变组件,这使得搜索变得更加困难。如果我们想找到所有失败的连接尝试的日志怎么办?唯一的方法是使用正则表达式,这将相当慢,因为日志搜索引擎需要对整个表进行全表扫描并将正则表达式应用于每个条目。
另一方面,第二条消息使用特定类别的错误常量消息,并包含错误详情和主机名作为键值对。搜索此类错误要容易得多:日志搜索引擎可能能够快速有效地使用索引来回答此类查询。更重要的是,我们可以进一步切片数据,例如,按主机计数失败的尝试。对于第一条消息,回答此类查询几乎是不可能的!
我们已经讨论过结构化日志记录的有用性。到目前为止,您可能想知道是否有一个应该始终包含在日志消息中的字段列表。我肯定会推荐至少包括以下信息:
-
应用程序/服务的名称。有这个值可以回答最常见的问题之一:“显示应用程序foo的日志”。
-
应用程序正在执行时的主机名。当您的日志存储是集中式时,如果需要确定哪个机器(或如果您使用 Kubernetes,则容器)产生了日志,这个字段非常有用。
-
用于编译应用程序二进制的 git(或您首选的 VCS)分支的SHA。如果您的组织是“频繁发布”格言的粉丝,将 SHA 添加到日志中可以轻松地将错误链接到代码库的特定快照。
想象一下,违背您的判断,您决定忽略“周五永不部署”的规则,并将一系列看似无害的更改推送到生产中的某些微服务;毕竟,代码经过了彻底的审查,所有测试都通过了。可能出什么问题呢,对吧?您周一回到工作岗位,您的邮箱里充满了由支持团队打开的工单。根据工单,几位用户在将产品添加到购物车时遇到了问题。为了帮助您追踪问题,支持团队在工单中包含了受影响用户 ID 以及他们访问服务的近似时间。
您启动日志搜索工具,输入时间戳和用户详情,然后收到 API 网关的日志列表,这是用户从他们的网络浏览器请求某物时遇到的第一个微服务。网关服务调用下游服务,不幸的是,这些服务没有访问用户 ID,因此不会出现在日志中...祝您好运,找到问题的原因!
为了避免这种棘手的情况,在日志条目中也包含一个关联 ID是一个好的做法。在这个特定的场景中,API 网关会为传入的请求生成一个唯一的关联 ID,并将其注入到对下游服务的请求中(然后它们将其包含在自己的日志条目中),等等。这种方法与请求跟踪非常相似,但不同的是,它不是跟踪跨度和时间相关的请求细节,而是允许我们在服务边界之间关联日志。
魔鬼在于(日志的)细节
当使用结构化日志时,很容易陷入一个误区,试图将尽可能多的信息塞入键值对中。不幸的是,这往往在安全方面证明是危险的!看看以下代码片段,它从我们提供的 URL 中检索用户的数据:
func fetchUserData(url *url.URL) (*user.Data, error) {
tick := time.Now()
res, err := http.Get(url.String())
if err != nil {
return nil, err
}
defer func() { _ = res.Body.Close() }()
logrus.WithFields(logrus.Fields{
"url": url,
"time": time.Since(tick).String(),
}).Info("retrieved user data")
// omitted: read and unmarshal user data
}
每当我们成功获取数据时,我们都会记录一个包含 URL 和检索所需时间的INFO消息。这段代码看起来很无辜,对吧?错了!以下截图显示了该函数的日志输出:
图 4:忘记正确清理日志输出可能导致凭证泄露
哎呀!我们不小心把用户的凭证散布到了日志中... 我们必须非常小心,不要将任何凭证或其他敏感信息(例如,信用卡、银行账户或 SSN 号码)泄露到日志中。但是,我们如何在不需要审计代码库中的每一行日志的情况下实现这一点?大多数日志框架允许你提供一个io.Writer
实例来接收日志输出。你可以利用这个功能来实现一个过滤机制,该机制使用一组正则表达式来屏蔽或删除符合特定模式的敏感信息。
你必须注意的另一个陷阱是同步日志。这里的黄金法则是将日志始终视为一个辅助功能,并且永远不应该干扰你服务的正常操作。如果你使用的是同步日志记录器,并且输出流阻塞(即,它无法跟上生成的日志量),你的服务也会阻塞,并导致上游服务出现明显的延迟。尽可能使用异步日志记录器实现 - 理想情况下,使用漏桶抽象来在无法跟上负载时丢弃消息。
在 Kubernetes 中传输和索引日志
如果你一直在遵循前几节的指南,你的应用程序现在将输出干净简洁的日志,这种格式适合被日志聚合系统摄取。唯一缺失的拼图是如何收集在 Kubernetes 集群上运行的每个应用程序实例的单独日志并将它们发送到自托管或 SaaS 日志索引解决方案。
在每个 Kubernetes 节点上运行日志收集器
此选项使用 Kubernetes DaemonSet在每个 Kubernetes 集群的每个节点上安装日志收集守护进程。除了相对容易实现之外,这种特定方法的优点是它对运行中的应用程序是完全透明的。
DaemonSet 是一种特殊的 Kubernetes 资源,确保所有集群节点都运行特定 Pod 的一个副本。使用 daemon sets 是一种非常常见的模式,原因如下:
-
运行集群存储守护进程(例如,ceph 或 glusterd)
-
收集和发送日志
-
监控节点并传输节点特定的指标(例如,负载、内存或磁盘空间使用情况)
当 Pod 执行时,Kubernetes 将捕获其标准输出和错误流并将它们重定向到一对日志文件。当你运行kubectl logs
命令时,运行在工作节点上的kubelet
实例通过读取这些文件来流式传输日志。
下一个图中显示的日志收集器 Pod 将每个正在执行的 Pod 捕获的日志文件进行消化,如果需要,将它们转换为日志索引服务期望的格式,并可选择地添加额外的信息,例如 Kubernetes 命名空间和容器主机名。根据使用的日志摄取解决方案,日志可以直接上传进行摄取或写入消息队列,例如 Kafka:
图 5:使用 DaemonSet 运行日志收集器
目前最流行的两个日志收集守护进程是 Fluent Bit^([8]),它用 C 编写,以及 Logstash^([15]),它用 Ruby 编写。通常,日志会被发送到 Elasticsearch 集群进行索引,并使用 Kibana^([12])这样的前端来浏览和搜索日志。这种类型的设置通常被称为 EFK 或 ELK 堆栈,具体取决于使用的日志收集守护进程。
使用边车容器收集日志
收集日志的第二种选择是为每个应用程序运行一个边车容器,如下面的图所示:
图 6:将日志收集器作为边车容器运行
与使用 Kubernetes 已内置的基础设施相比,这种方法可能感觉有点繁琐,但在以下情况发生时可以工作得相当好:
-
应用程序写入多个日志文件。例如,一个像 Apache 或 Nginx 这样的 Web 服务器可能被配置为将错误日志写入与访问日志不同的位置。在这种情况下,您将为每个要抓取的日志文件添加一个边车容器。
-
应用程序使用非标准日志格式,需要根据每个应用程序进行转换。
直接从应用程序发送日志
我们将要检查的最后一种日志传输策略是将日志传输逻辑实际嵌入到每个应用程序中。如图所示,应用程序正在直接将其日志发送到日志存储库:
图 7:从应用程序内部直接传输日志
这种策略的一个有趣用例是与需要应用程序导入和使用特定供应商软件开发套件的外部第三方 SaaS 产品集成。
检查实时 Go 服务
在从单体应用程序过渡到基于微服务的应用程序的漫长旅程之后,你已经达到了一个点,即所有你新而闪亮的服务都在生产中愉快地运行。在这个阶段,你可能会越来越好奇它们长期运行的状况:
-
它们使用了多少内存?
-
当前有多少 goroutines 正在运行?
-
是否有任何泄漏(内存或 goroutines)最终会迫使服务崩溃?
-
Go 垃圾收集器多久运行一次,每次运行实际上需要多少时间?
如果你使用容器编排框架,如 Kubernetes,崩溃的服务通常不是一个大问题;只需增加实例数量,Kubernetes 就会在它们崩溃时负责重启它们。然而,如果崩溃的根本原因是内存泄漏或运行中 goroutine 数量的无界增长,随着系统流量的增加,崩溃将变得越来越频繁。这种模式不可避免地会导致服务中断。
幸运的是,Go 运行时公开了大量信息,我们可以使用这些信息来回答这些问题。我们只需要提取、导出和汇总这些信息。在第十三章“指标收集和可视化”中,我们将讨论操作微服务架构的 SRE 方面,我们将探讨如 Prometheus 这样的指标收集系统,它不仅允许我们收集这些信息,还可以通过创建警报来采取行动,这些警报最终可能变成 SRE 团队的页面调用。
然而,在某些情况下,我们可能想要深入挖掘...以下是一些有趣的例子:
-
一个服务突然变得无响应,但 Go 死锁检测器并没有抱怨任何死锁。该服务仍然接受请求,但从未发送任何回复,我们需要找出原因。
-
我们的指标仪表板显示,服务 Y 的实例 X 正在使用相当多的堆内存。但我们如何检查堆的内容,看看是什么占用了所有这些内存?也许我们泄漏了文件句柄(例如,忘记关闭我们正在进行的 HTTP 调用的响应体)或者维护了对对象的未必要引用,这阻止了 Go 垃圾收集器释放它们。
-
一个数据摄取服务意外地 peg 了 CPU,但仅在运行在生产环境中时才会发生!到目前为止,你还没有能在本地开发机器上重现这个问题。
要调试如第一个示例中描述的问题,我们可以 SSH 进入服务执行所在的容器,并发送一个 SIGQUIT 信号。这将强制 Go 运行时为每个正在运行的 goroutine 输出堆栈跟踪并退出。然后,我们可以检查日志流,找出服务确切卡在何处。
如果你的 Go 应用程序似乎卡住了,正在前台运行,而不是寻找它的 pid 以发送一个 SIGQUIT 信号,你可以通过按 *CTRL+* 来强制它为每个正在执行的 goroutine 输出堆栈跟踪。
注意,在幕后,这个键组合实际上向运行中的进程发送了一个 SIGQUIT,并会导致它退出。
然而,这个技巧的明显缺点是,我们的应用程序或服务实际上会崩溃。更重要的是,它并不真正允许我们检查其内部状态,这对于处理诸如其他示例中提到的情况是或多或少必需的。
幸运的是,Go 允许我们将 pprof
包嵌入到我们的服务中,并通过 HTTP 暴露其前端。你可能担心,将一个采样分析器与你的代码一起分发无疑会使它运行得更慢。原则上,这并不是真的,因为你可以要求 pprof
根据需要捕获各种类型的配置文件,从而使其在需要时才介入。如下代码片段所示,你所需要做的只是导入 net/http/pprof
包并启动一个 HTTP 服务器:
import (
"log"
"net/http"
_ "net/http/pprof" // import for side-effects
)
func exposeProfile() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
}
net/http/pprof
函数定义了一个 init
函数,该函数将各种与 pprof 相关的路由处理程序注册到默认的 http.ServeMux
。因此,通常只为了副作用而导入该包。启动 HTTP 服务器后,你只需将你的网络浏览器指向 http://localhost:6060/debug/pprof
,就可以访问一个用于捕获 pprof
配置文件并使其可供下载的简约前端,以便可以通过 pprof
工具离线处理。
以下截图显示了暴露 pprof
前端以检查运行中的应用程序:
图 8:暴露 pprof 前端以检查运行中的应用程序
如前述截图所示,pprof
用户界面允许你按需捕获以下配置文件类型:
-
allocs:所有过去内存分配的样本
-
block:导致在同步原语上阻塞的堆栈跟踪
-
cmdline:当前程序的命令行调用
-
goroutine:所有当前 goroutine 的堆栈跟踪
-
heap:活动对象的内存分配样本
-
mutex:竞争互斥锁持有者的堆栈跟踪
-
profile:CPU 配置文件
-
threadcreate:导致创建新操作系统线程的堆栈跟踪
-
trace:当前程序执行的跟踪
如果您的应用程序/服务已经启动了自己的 HTTP 服务器,该服务器可能被暴露给外界(例如,通过 Kubernetes ingress),请确保将其路由绑定到新的mux实例,并通过运行在不同(内部)端口上的第二个 HTTP 服务器使用默认 mux来公开pprof
路由。
这样做,您就不会有允许未经授权访问您的内省端点的风险。
我强烈建议您使用本节中讨论的方法为您的生产服务启用pprof
支持。这只需要您稍微努力一点来设置,但如果您需要调试实时应用程序,这将证明是一个巨大的资产。
构建“Links 'R' Us”的基于微服务的版本
在本章的最后部分,我们将使用我们在上一章中构建和部署的单体应用“Links 'R' Us”,并将我们迄今为止所学的一切应用到将其分解为一组微服务。以下图表展示了我们在做出所有必要的更改后集群的预期状态:
图 9:将“Links 'R' Us”单体分解为微服务
我们将为“Links 'R' Us”的基于微服务的版本使用 Kubernetes manifest 文件,这些文件位于本书 GitHub 仓库的Chapter11/k8s
文件夹下。
如果您还没有设置 Minikube 集群并将其私有仓库列入白名单,您可以选择短暂休息并手动按照第十章“构建、打包和部署软件”中的逐步说明进行操作,或者简单地运行make bootstrap-minikube
,这将为您处理一切。另一方面,如果您已经从上一章部署了“Links 'R' Us”的单体版本,在继续之前请确保运行kubectl delete namespace linksrus
。通过删除**linksrus**
命名空间,Kubernetes 将移除所有针对“Links 'R' Us”的 pods、services 和 ingresses,但保留数据存储(位于**linksrus-data**
命名空间中)。
为了部署我们在以下章节中定义的“Links 'R' Us”的各种组件,您需要构建和推送一些 Docker 镜像。为了节省您的时间,Chapter11/k8s
文件夹中的 Makefile 提供了两个方便的构建目标,以尽可能快地让您开始运行:
-
make dockerize-and-push
将构建所有必需的 Docker 镜像并将它们推送到 Minikube 的私有仓库 -
make deploy
将确保所有必要的数据存储都已配置,并一次性应用部署基于微服务的“Links 'R' Us”版本的 manifests
在我们开始将单体应用拆分为微服务之前,我们首先需要处理一个小任务:移除我们的服务与底层数据存储之间的耦合。下一节将探讨如何利用我们在第九章,《与外界通信》中获得的知识来实现这一点。
解耦对数据存储的访问
上一章单体实现的一个基本问题是我们的应用程序直接与 Elasticsearch 和 CockroachDB 集群通信。我们实际上在应用程序和数据存储实现之间引入了紧密耦合。
现在是时候创建 Links 'R' Us 的基于微服务版本了,我们需要采取一些步骤来纠正这个问题。为此,我们将创建的前两个服务将作为重构工作中的一种代理,以促进对底层数据存储的访问。文本索引器和链接图服务将在linksrus-data命名空间中部署,并允许其他服务通过我们在第九章,《与外界通信》中定义的基于 gRPC 的 API 与数据存储进行交互。
在服务和数据存储之间引入间接层的一个重要好处是,我们能够随时更改数据存储实现,而无需更改、更新或以其他方式重新配置任何其他 Links 'R' Us 服务。
在服务实现方面,事情出奇地简单。每个服务二进制文件接收作为参数的数据存储连接 URI。然后,它在端口8080
上创建一个 gRPC 服务器,并在端口 6060 上暴露pprof
调试端点。所有样板代码都完美地整合到一个主文件中,您可以在本书 GitHub 仓库的Chapter11/linksrus/linkgraph
和Chapter11/linksrus/textindexer
文件夹中找到。
将单体应用拆分为独立的服务
从 Links 'R' Us 单体中提取单个服务,并为每个服务构建一个独立的服务二进制文件。这也是您可能会意识到我们自本书开始以来一直在宣扬的干净、基于接口的设计最终开始产生效益的时刻。
结果表明,我们可以从第十章,《构建、打包和部署软件》中提取特定服务的代码,稍作修改后即可直接使用。对于每个服务,我们将创建一个执行以下一系列任务的main
包:
-
为每个服务创建一个日志实例
-
在可配置端口上暴露
pprof
调试端点 -
实例化用于访问链接图和文本索引器的 gRPC 客户端
-
为每个服务填充适当的配置对象
-
运行服务主循环,并在收到信号时干净地关闭应用程序
所有这些样板代码对于每个服务来说都大致相同,因此为了简洁起见,我们将省略它。然而,如果您愿意,可以将公共部分提取到单独的包中,并使每个服务的main.go
文件更加精简。
每个服务的配置与上一章中单体实现的配置略有不同。我们希望我们的服务通过命令行标志或环境变量进行配置。后者为我们提供了在共享的 Kubernetes ConfigMap 中定义所有配置选项的灵活性,并将它们注入到我们的服务清单中。由于内置的flags
包不支持此类功能,我们将切换到urfave/cli
^([1])包来满足我们的标志解析需求。此包支持优雅地定义类型化标志,这还允许我们(可选地)指定可以设置为覆盖每个标志值的变量名:
app.Flags = []cli.Flag{
cli.StringFlag{
Name: "link-graph-api",
EnvVar: "LINK_GRAPH_API",
Usage: "The gRPC endpoint for connecting to the link graph",
},
cli.StringFlag{
Name: "text-indexer-api",
EnvVar: "TEXT_INDEXER_API",
Usage: "The gRPC endpoint for connecting to the text indexer",
},
// omitted: additional flags
}
我们的所有新服务都需要访问链接图和文本索引器数据存储。这两个存储都通过我们之前章节中描述的两个服务通过 gRPC 暴露。以下代码片段显示了如何为两个服务中的每一个获取一个高级(见第九章,与外界通信)客户端实例:
// Obtain high-level client for link graph.
dialCtx, cancelFn := context.WithTimeout(ctx, 5*time.Second)
defer cancelFn()
linkGraphConn, err := grpc.DialContext(dialCtx, linkGraphAPI, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
return nil, nil, xerrors.Errorf("could not connect to link graph API: %w", err)
}
graphCli := linkgraphapi.NewLinkGraphClient(ctx, linkgraphproto.NewLinkGraphClient(linkGraphConn))
// Obtain high-level client for text-indexer.
dialCtx, cancelFn := context.WithTimeout(ctx, 5*time.Second)
defer cancelFn()
indexerConn, err := grpc.DialContext(dialCtx, textIndexerAPI, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
return nil, nil, xerrors.Errorf("could not connect to text indexer API: %w", err)
}
indexerCli := textindexerapi.NewTextIndexerClient(ctx, textindexerproto.NewTextIndexerClient(indexerConn))
如您所忆,在第九章,与外界通信中,我们精心设计了高级客户端,以便它们实现graph.Graph
和index.Indexer
接口的子集。这使得可以使用客户端作为直接替换用于单体“链接之用”版本中使用的具体图和索引存储实现。这是将 SOLID 设计原则应用于使我们的代码更加模块化和易于接口的益处的证明。
部署构成“链接之用”项目的微服务
现在我们已经为每个新的“链接之用”服务构建了独立的二进制文件,是时候将它们部署到 Kubernetes 上了!让我们快速了解一下部署每个单独服务所需的 Kubernetes 资源。
部署链接图和文本索引器 API 服务
对于链接图和文本索引器 API 服务,我们将在linksrus-data命名空间中为每个服务启动两个副本,我们将使用 Kubernetes 部署资源。
为了允许来自linksrus命名空间的客户端访问 API,我们将创建一个 Kubernetes 服务来负载均衡到我们将启动的 Pods 的流量。
客户端可以通过将他们的 gRPC 客户端连接到以下端点来访问数据存储:
-
linksrus-textindexer.linksrus-data:8080
-
linksrus-linkgraph.linksrus-data:8080
部署网络爬虫
爬虫服务部署将使用与上一章中单体实现相同的分区检测逻辑。因此,我们将作为 Kubernetes 状态集部署两个爬虫服务实例,这保证了每个 pod 都会被分配一个包含集合中 pod 序号的可预测主机名。
此外,我们还将创建一个无头的 Kubernetes 服务,该服务将为爬虫 pod 的 SRV 记录填充数据,并允许分区检测代码查询可用的 pod 总数。
部署 PageRank 服务
PageRank 服务仍然受到我们在上一章中讨论的相同约束和限制。因此,我们将通过创建一个副本计数设置为一个的 Kubernetes 部署来运行服务的单个实例。
部署前端服务
我们将要部署的最后一个服务是前端。正如我们集群中的大多数其他服务一样,我们将为前端创建一个具有所需副本数的 Kubernetes 部署。
正如我们在上一章中所做的那样,我们将定义一个 Kubernetes 服务来负载均衡流量到前端 pod,然后借助 Kubernetes ingress 资源将其暴露在集群外部。
使用网络策略锁定对我们的 Kubernetes 集群的访问
随着微服务数量的增加,可能是一个开始更积极地考虑安全性的好时机。我们真的希望集群中的每个 pod 都能访问所有命名空间中的其他 pod 吗?说实话,对于我们的当前部署来说,这并不那么重要。然而,对于更大的项目来说,这确实是一个你需要回答的问题。
Kubernetes 提供了一种特殊类型的资源,称为网络策略,以帮助我们创建细粒度的规则来管理对命名空间和 pod 的访问。创建和实施网络策略的先决条件是您的集群运行时启用了cni网络插件,并使用符合容器网络接口(CNI)的网络提供者实现。此类提供者的例子包括 Calico ^([2])、Cilium ^([3]) 和 Flannel ^([7])。
如果你已经使用 Makefile 中的 make bootstrap-minikube
目标在 Chapter10/k8s
或 Chapter11/k8s
文件夹中初始化了一个 Minikube 集群,Calico 已经为你安装好了。
或者,您可以通过运行以下命令手动将 Calico 安装到您的测试集群中:
kubectl apply -f https://docs.projectcalico.org/v3.10/manifests/calico.yaml
安装可能需要一些时间。你可以通过运行 kubectl -n kube-system get pods -lk8s-app=calico-node -w
来监控部署状态,等待 pod 状态显示为运行。
对于我们的 Links 'R' Us 部署,一个好的网络策略示例会是什么?由于现在期望 linksrus 命名空间中的各种 pod 通过 gRPC 访问数据存储,因此指定一个阻止来自其他命名空间的 pod 访问 linksrus-data 命名空间中任何其他 pod 的网络策略将是良好的实践。
Kubernetes 网络策略的一个真正酷的特性是我们可以将多个策略组合起来构建更复杂的策略。对于我们的用例,我们将从一个 DENY ALL 策略开始:
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
namespace: linksrus-data
name: deny-from-other-namespaces
spec:
podSelector:
matchLabels:
ingress:
- from:
- podSelector: {}
每个策略有两个部分:
-
目标是我们指定想要控制访问的 pod 集合。在这个例子中,我们使用一个
podSelector
块和一个空的matchLabels
选择器来匹配命名空间中的所有 pod。 -
交通源,即我们在尝试访问目标列表中的 pod 时,指定受策略影响的 pod 集合的位置。
因此,前面的策略可以解释为拒绝来自另一个命名空间中任何 pod 对 linksrus-data 命名空间中任何 pod 的访问。继续前进,我们将定义第二个网络策略,该策略将明确地将我们想要授予访问权限的 pod 列为白名单:
kind: NetworkPolicy
apiVersion: networking.k8s.io/v1
metadata:
namespace: linksrus-data
name: allow-access-to-data-apis
spec:
podSelector:
matchLabels:
role: data-api
ingress:
- from:
- namespaceSelector:
matchLabels:
role: linksrus-components
两个 gRPC 服务的 pod 已经被标记为 role: data-api
标签,该标签被前面的策略用来明确地针对我们感兴趣的 pod。另一方面,在 linksrus 命名空间中运行的 pod 已经被标记为 role: linksrus-components
标签,这允许我们将它们指定为入口选择器的一部分。此规则被解释为允许所有带有 linksrus-components 角色的 pod 从 linksrus-data 命名空间中的 pod 访问具有 data-api 角色的 pod*。
让我们通过运行 kubectl apply -f 08-net-policy.yaml
来应用这些规则,并通过连接到两个命名空间中的 pod 并运行 nc 命令来检查我们是否被允许连接到由两个网络策略保护的 pod 来验证它们是否按预期工作。以下屏幕截图显示了如何验证我们的网络策略按预期工作:
图 10:验证我们的网络策略按预期工作
成功!如前一个屏幕截图所示,尝试从 linksrus 命名空间中的一个爬虫 pod 连接到 CockroachDB 集群时超时,而尝试连接到文本索引器 API 的尝试则成功。另一方面,当在 linksrus-data 命名空间中运行的 pod 内执行相同命令时,连接到 CockroachDB 的尝试成功。
当然,对 Kubernetes 网络策略的简要介绍只是触及了表面。Kubernetes 文档中包含其他一些匹配规则,您可以使用这些规则为您的特定用例制定适当的网络策略。对于那些有兴趣进一步探索可以实施的不同类型策略的人,我强烈建议查看Kubernetes 网络策略食谱^([13])GitHub 仓库。
摘要
在本章中,我们专注于将单体应用程序拆分为一系列微服务的过程。我们确定了构建微服务的常见反模式,并详细阐述了绕过它们的方法。
在本章的第二部分,我们探讨了通过分布式系统跟踪请求的一些有趣方法,以及收集、聚合和搜索日志。在本章的最后部分,我们将上一章中的单体 Links 'R' Us 项目拆分为一系列微服务,并将它们部署到我们的测试 Kubernetes 集群中。
在下一章中,我们将讨论构建容错系统,并使用主/从模式构建 PageRank 计算器的分布式版本。
问题
-
解释为什么使用微服务模式进行 MVP 或概念验证(PoC)项目通常被认为是一个糟糕的想法。
-
描述断路器模式是如何工作的。
-
列出能够跟踪请求穿越系统时的某些好处。
-
为什么清理日志输出很重要?
-
简要描述从运行在 Kubernetes 集群内部的 Pod 中收集日志的三个策略。
进一步阅读
-
一个简单、快速且有趣的 Go 语言命令行应用程序构建包:
github.com/urfave/cli
-
Calico:云原生时代的安全网络:
www.projectcalico.org
-
Cilium:API 感知网络和安全:
cilium.io
-
Docker:企业级容器平台:
www.docker.com
-
Elastic APM:开源应用程序性能监控:
www.elastic.co/products/apm
-
etcd:分布式、可靠的键值存储,用于分布式系统中最关键的数据:
etcd.io
-
Flannel:为 Kubernetes 设计的容器网络布线:
github.com/coreos/flannel
-
Fluent Bit:云原生日志转发器:
fluentbit.io
-
gokit/log:服务中结构化日志的最小接口:
github.com/go-kit/kit/tree/master/log
-
grpc-opentracing: 通过 OpenTracing 项目在 gRPC 客户端启用分布式跟踪的包:
github.com/grpc-ecosystem/grpc-opentracing
-
Jaeger: 为开源,端到端分布式跟踪:
jaegertracing.io
-
kibana: 您进入 Elastic Stack 的窗口:
www.elastic.co/products/kibana
-
Kubernetes 网络策略菜谱:
github.com/ahmetb/kubernetes-network-policy-recipes
-
logrus: 为 Go 提供结构化、可插拔的日志记录:
github.com/sirupsen/logrus
-
Logstash: 集中、转换和存储您的数据:
www.elastic.co/products/logstash
-
OpenZipkin: 分布式跟踪系统:
zipkin.io
-
Sigelman, Benjamin H.; Barroso, Luiz André; Burrows, Mike; Stephenson, Pat; Plakal, Manoj; Beaver, Donald; Jaspan, Saul; Shanbhag, Chandan: Dapper, a Large-Scale Distributed Systems Tracing Infrastructure. Google, Inc., 2010.
-
The OpenTracing project:
opentracing.io
-
The OpenTracing project: Supported tracers:
opentracing.io/docs/supported-tracers
-
zap: Go 中的闪电般快速、结构化、分层日志记录:
github.com/uber-go/zap
-
zerolog: 零分配 JSON 记录器:
github.com/rs/zerolog
第十二章:构建分布式图处理系统
"一个分布式系统是这样的一个系统,其中你甚至不知道存在的计算机的故障可以使你自己的计算机无法使用。"
- 莱斯利·兰波特
主/工作模式是构建容错、分布式系统的一种流行方法。本章的第一部分深入探讨了这种模式,重点关注分布式系统的一些更具挑战性的方面,如节点发现和错误处理。
在本章的第二部分,我们将应用主/工作模式从头开始构建一个分布式图处理系统,该系统能够处理大小超过大多数现代计算节点内存容量的大规模图。最后,在本章的最后部分,我们将应用到目前为止所学的一切来创建 Links 'R' Us 项目的 PageRank 计算服务的分布式版本。
本章将涵盖以下主题:
-
分布式计算中应用主/工作模型
-
发现主节点和工作节点的策略
-
处理错误的策略
-
使用主/工作模型以分布式方式执行第八章中基于图的算法,基于图的数据处理
-
创建 Links 'R' Us PageRank 计算服务的分布式版本并将其部署到 Kubernetes
技术要求
本章讨论的所有主题的完整代码已发布到本书的 GitHub 仓库的Chapter12
文件夹中。
您可以通过将您的网络浏览器指向以下 URL 来访问包含本书每个章节代码和所有必需资源的 GitHub 仓库:github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
。
本章的每个示例项目都包含一个通用的 Makefile,它定义了以下一组目标:
Makefile 目标 | 描述 |
---|---|
deps |
安装任何必需的依赖项。 |
test |
运行所有测试并报告覆盖率。 |
lint |
检查 lint 错误。 |
与本书的所有其他章节一样,您需要一个相当新的 Go 版本,您可以在golang.org/dl/
下载。
要运行本章中的一些代码,您需要在您的机器上安装一个工作的 Docker ^([2])安装。此外,对于本章的最后部分,您需要访问一个 Kubernetes 集群。如果您没有访问 Kubernetes 集群进行测试,您可以简单地遵循以下部分中概述的说明,在您的笔记本电脑或工作站上设置一个小型集群。
介绍主/工作模型
主/工作模型是构建分布式系统的常用模式,这种模式已经存在了很长时间。当使用此模型构建集群时,节点可以分为两个不同的组,即主节点和工人节点。
工作节点的主要责任是执行以下计算密集型任务:
-
视频转码
-
使用数百万参数训练大规模神经网络
-
计算在线分析处理(OLAP)查询
-
运行持续集成(CI)管道
-
在大规模数据集上执行 map-reduce 操作
另一方面,主节点通常被分配协调者的角色。为此,它们负责以下任务:
-
发现并跟踪可用的工人节点
-
将工作分解成更小的任务并将它们分配给每个连接的工人
-
协调作业的执行并确保任何错误都得到适当的检测和处理
确保主节点的高可用性
在使用主/工作模型构建的系统中,由于崩溃或网络分区而丢失一个或多个工作节点并不是一个大问题。主节点可以检测到这一点,并通过将工作负载重新分配给剩余的工人来解决这个问题。
设计分布式系统时的一条重要建议是确保你的系统不包含单点故障(SPoFs)。
另一方面,主节点的丢失很可能会使整个系统离线!幸运的是,我们有几种不同的方法可以确保主节点的高可用性,我们将在下一节中介绍。
领导者-跟随者配置
领导者-跟随者配置通过向集群引入多个主节点来实现高可用性。主节点实现了一个领导者选举算法,经过几轮投票后,它们将集群领导者的角色分配给主节点中的一个。
从那时起,领导者负责协调任何未来作业的执行,每个工作节点都被指示连接到它。
非领导者主节点(跟随者)使用心跳机制来持续监控活动领导者的健康状态。如果领导者未能确认一定数量的连续心跳请求,其他主节点将假定领导者已死亡,并自动进行新一轮选举,以选择集群的新领导者。
同时,工人尝试重新连接到主节点,并最终与新选出的集群领导者建立连接。
多主配置
在多主配置中,我们仍然启动多个主节点实例。然而,正如其名称所暗示的,集群实际上并没有指定的领导者。在多主集群中,我们不需要为工作节点提供确定哪个节点是领导者的机制;它们可以自由连接到任何主节点。
虽然这种配置的吞吐量特性比等效的领导者-跟随者配置要好得多,但它有一个重要的注意事项,即所有主节点必须始终共享集群状态的相同视图。
因此,主节点需要实现某种分布式一致性算法,如 Paxos ^([3]) 或 Raft ^([5]),以确保对集群状态的更改由所有主节点以相同的顺序处理。
发现节点的策略
为了让工作节点能够连接到主节点,它们首先需要知道其存在!根据我们的特定用例,可以使用以下发现策略:
-
连接到引导节点: 这种发现策略假设其中一个主节点(通常称为引导节点)可以在事先已知的 IP 地址上访问。主节点和工作节点都尝试与引导节点建立初始连接,并使用八卦协议获取有关集群中其他节点信息。
-
使用外部发现服务: 这种策略依赖于存在一个外部发现服务,我们可以查询它以获取有关集群内运行的所有服务的信息。Consul ^([1]) 是实现这种特定模式的一个非常流行的解决方案。
-
使用 DNS 记录定位节点: 如果我们的系统部署在一个允许我们创建和操作本地 DNS 记录的环境中(例如,Kubernetes),我们可以生成指向集群领导者的A 记录。工作节点可以通过简单的 DNS 查询查找领导者。
从错误中恢复
分布式系统本质上是复杂的。在主/工作节点设置中执行任务时,可能会出现许多问题,例如,进程可能耗尽内存并崩溃或简单地变得无响应,网络数据包可能丢失,或网络设备可能故障,从而导致网络分裂。在构建分布式系统时,我们不仅要预见错误的存在,还应该制定在错误发生时的应对策略。
在本节中,我们将讨论在主/工作节点系统中从错误中恢复的以下方法:
-
错误重试: 这种策略更适合那些计算幂等的负载。一旦检测到致命错误,主节点会要求所有工作节点终止当前任务,并从零开始重新启动工作负载。
-
将工作负载重新分配给健康的工作者:这种策略对于在作业执行过程中可以动态更改分配的工作负载的系统非常有效。如果有任何工作者离线,主节点可以将分配给它的工作负载重新分配给剩余的工作者。
-
使用检查点机制:这种策略最适合涉及非幂等计算的长时间运行的工作负载。在作业执行期间,主节点会定期要求工作者创建一个检查点,即他们当前内部状态的快照。如果发生错误,而不是从头开始重新启动作业,主节点要求工作者从特定的检查点恢复他们的状态并继续执行作业。
离核分布式图处理
回到第八章,基于图的数据处理,我们设计和构建了我们自己的系统,用于基于批量同步并行(BSP)模型实现图算法。诚然,我们的最终实现受到了谷歌论文中描述的 Pregel ^([4])的想法的很大影响,这是一个最初由谷歌工程师构建的系统,用于处理大规模的图计算。
虽然第八章中的bspgraph
包,基于图的数据处理,能够自动在多个工作者之间分配图计算负载,但它仍然局限于在单个计算节点上运行。随着我们的 Links 'R' Us 爬虫不断为链接索引增加越来越多的链接,我们最终会达到一个点,此时 PageRank 的计算将变得过于耗时。更新整个图的 PageRank 分数可能需要一天,甚至更糟,几天时间!
我们可以通过扩展规模来为自己争取一些时间,换句话说,在我们的云提供商那里运行我们能够获取到的最强大的(从 CPU 的角度来看)机器上的 PageRank 计算器服务。这将给我们一些喘息的空间,直到图变得太大而无法装入内存!一旦我们达到这个点,我们唯一的可行选择就是扩展规模,或者启动多个计算节点,并将现在庞大的图的一部分分配给每个节点。
在接下来的章节中,我们将(非常直接地!)应用到目前为止所学的所有知识,从头开始构建bspgraph
包的分布式版本,它将位于Chapter12/dbspgraph
文件夹中,您可以在本书的 GitHub 仓库中浏览。
正如我们在前面的章节中所做的那样,我们再次将 SOLID 原则应用于我们的设计,以尽可能多地重用代码。为此,新的包将仅仅是一个复杂的包装器,它透明地将分布式计算超级能力注入任何现有的bspgraph.Graph
实例中!
这实际上意味着我们可以在单个机器上使用第八章,基于图的数据处理中的bspgraph
框架设计和测试我们的算法,一旦对它们的输出满意,就可以切换到dsbpgraph
进行离核处理。
正如我们所意识到的,构建分布式系统是一项艰巨的任务。为了尽量减少我们创建的系统的复杂性并使代码更容易理解,我们将把实现拆分成许多更小、更独立的组件,并为每个组件的实现分配一个部分。不过,不用担心——在本章结束时,你将对所有这些组件如何组合在一起有一个清晰的理解!
描述系统架构、需求和限制
本章的标题暗示了我们将为分布式图处理框架使用的架构类型;不出所料,它将基于主/工作模式。
为了更好地理解我们在设计中的主节点和工作节点的作用,我们首先需要快速回顾一下第八章,基于图的数据处理中bspgraph
包的工作原理。如果你还没有阅读第八章,基于图的数据处理,我建议在继续之前先阅读。
bspgraph
包使用批量同步模型(BSP)执行图算法。为此,所选算法基本上是按顺序步骤(超级步骤)执行的。在每个超级步骤中,框架并行调用用户定义的计算函数,针对图中的每个顶点。
计算函数可以访问本地顶点状态和全局图状态(模型计数器、最小/最大跟踪器等聚合实例)。顶点通过交换消息相互通信。在超级步骤期间发布的任何消息都会排队,并在下一个超级步骤中交付给预期的接收者。最后,在开始执行下一个超级步骤之前,框架等待所有计算函数返回,并将任何在途消息排队以交付。这反映了 BSP 模型的同步行为。
那么,要如何以分布式方式实现相同的过程呢?让我们看看:
-
首先,主节点和工作节点都需要运行完全相同的计算函数。这很容易做到,因为我们首先将使用
bspgraph
包开发我们的算法,然后使用dbspgraph
包在主节点或工作节点上执行它。 -
其次,为了强制执行 BSP 模型的同步方面,我们必须引入某种并发原语以确保所有工人在同步执行超级步骤。这个原语,我们将称之为步骤屏障,将由主节点实现。
如你所猜,主节点实际上不会进行任何计算工作;它将扮演图算法执行的协调者角色。更具体地说,主节点将负责以下工作:
-
为工人提供一个连接并等待工作分配的端点。
-
计算并广播每个工人的分区分配。
-
在屏障原语的帮助下协调每个超级步骤的执行。
-
跟踪当前正在执行的图算法的全局状态。这包括不仅当前的超级步骤,还包括全局聚合器值。主节点必须从每个工人收集部分聚合器值,更新其状态,并将新的全局状态广播到所有工人。
-
在工人之间中继消息。主节点了解每个工人的分区分配,并且可以通过查询目的地 ID 来路由消息。
-
监控每个工人的状态,并在发生错误或任何工人崩溃时广播作业中止请求。
另一方面,工人的角色要简单得多。每个工人连接到主节点并等待工作分配。一旦接收到新的工作,工人就会使用分配给它的通用唯一标识符(UUID)范围内的顶点和边初始化其本地图。然后,通过与主节点(通过屏障)的协调,工人在与其他工人保持同步的情况下执行图算法,直到满足算法的用户定义的终止条件。任何目的地不是本地图顶点的输出消息将通过主节点自动中继。
为了能够正确分区图并在工人之间中继消息,我们唯一的先决条件是顶点 ID 始终是有效的 UUID。如果底层图表示使用不同类型的 ID(例如,一个整数值),最终用户需要在图初始化步骤中将它们手动重新映射到 UUID。
建立用于执行图计算的有限状态机模型
为了执行图算法,bspgraph
包提供了Executor
类型,这是一个方便的辅助工具,它协调执行单个超级步骤,并允许最终用户定义一组可选的回调函数,如果定义了,执行器将在各种计算阶段调用这些回调函数。这组可选回调函数包括以下内容:
-
PRE_STEP
回调:这个回调在执行超级步骤之前调用。这个钩子允许最终用户在下一个超级步骤之前执行任何所需的算法特定初始化步骤。例如,某些算法可能在每个超级步骤之前需要重置存储在一个或多个聚合器中的值。 -
POST_STEP
回调:这个回调在执行超级步骤后调用。这个钩子的典型用例是在超级步骤中执行额外的计算并更新全局聚合器值。例如,为了计算平均值,我们可以设置两个聚合器,一个计数器和一个小计,它们在超级步骤期间通过计算函数调用进行更新。然后,在POST_STEP
回调中,我们可以简单地获取它们的值,计算平均值,并将其记录在另一个聚合器中。 -
POST_STEP_KEEP_RUNNING
回调:这个回调在POST_STEP
之后调用,其作用是决定算法是否已完成其执行或需要额外的超级步骤。以下是一些典型的停止条件示例:-
达到特定的超级步骤编号。
-
没有更多的顶点处于活动状态(例如,来自第八章,基于图的数据处理)。
-
聚合器值达到阈值(例如,PageRank 计算器)。
-
如果我们将这些回调视为状态机模型中的状态,其状态图将如下所示:
图 1:bspgraph 包的状态图
当我们在单个节点上运行时,前面的模型运行得相当好,但当图以分布式方式执行时,这还不够。为什么是这样呢?记住,在分布式版本中,每个工作器都在图的子集上操作。因此,算法执行结束时,每个工作器都将能够访问解决方案的子集。
状态机是计算的一个流行数学模型。该模型定义了一组计算状态、从一个状态转换到另一个状态的规则以及执行特定计算任务的抽象机器。
在任何时刻,机器只能处于允许的一个状态。每当机器执行计算步骤时,都会咨询转换规则以选择下一个要过渡到的阶段。
我们不能真正地说算法已经完成,除非所有工作者的结果都已被成功持久化!因此,对于分布式情况,我们需要扩展我们的状态图,使其看起来如下所示:
图 2:dbspgraph 包的状态图
让我们快速看一下在刚刚引入到状态机中的三个新状态内部会发生什么:
-
一旦
POST_STEP_KEEP_RUNNING
回调决定图算法执行的终止条件已经满足,我们就进入EXECUTED_GRAPH
步骤,其中每个工作节点都尝试持久化其本地计算结果。 -
工作者在成功将本地计算结果持久化到后端存储后,会达到
PERSISTED_RESULTS
状态。 -
最后,工作节点达到
JOB_COMPLETED
状态。在这个状态下,它们可以自由地重置内部状态并等待新的作业。
在工作节点和主节点之间建立通信协议
实施任何类型的分布式系统的关键前提是引入一个协议,允许各种系统组件相互通信。同样的要求也适用于我们在本章中构建的分布式图处理系统。
由于工作节点和主节点通过网络链路进行通信,我们将应用在第九章,“与外界通信”中学到的概念,并使用 gRPC 作为我们的传输层。
以下章节中的消息和 RPC 定义可以在本书 GitHub 仓库的Chapter12/dbspgraph/api
文件夹中找到。
定义一个作业队列 RPC 服务
我们将采取一种稍微非传统的做法,首先定义我们唯一的 RPC。这样做的原因是,RPC 类型的选择(单一与流式)将极大地影响我们定义各种有效载荷的方式。
例如,如果我们选择使用流式 RPC,我们需要定义一种可以表示主节点和工作节点之间交换的不同类型消息的封装消息。另一方面,如果我们决定采用单一 RPC,我们可能定义多个方法,从而避免需要封装消息。
不再赘述,让我们来看看我们作业队列的 RPC 定义:
service JobQueue {
rpc JobStream(stream WorkerPayload) returns (stream MasterPayload);
}
如您所见,我们实际上将使用一种双向流式RPC!这带来了一定的成本;我们需要定义两个封装消息,一个用于工作节点,一个用于主节点。那么,是什么决定因素驱使我们选择了看似更复杂的双向流式解决方案?
答案与 gRPC 调度消息交付的方式有关。如果您仔细检查 gRPC 规范,您会注意到只有流式 RPC 保证了消息将按照它们发布的顺序交付。
这个事实对我们特定的用例至关重要,也就是说,如果我们不能强制执行按顺序的消息传递,等待在屏障上的工作节点可能会在退出屏障之前处理一条消息。结果,工作节点不仅会以非确定性的方式行为(调试起来很幸运!),算法还会产生错误的结果。
基于流的方法的另一个好处是,我们可以利用 gRPC 内建的心跳机制,有效地检测工作节点与主节点的连接是否被切断。
为工作节点负载建立协议缓冲区定义
正如我们在上一节中看到的,我们需要为工作节点负载定义一个信封消息:
message WorkerPayload {
oneof payload {
Step step = 1;
RelayMessage relay_message = 2;
}
}
通过oneof
类型,我们可以模拟消息联合。WorkerPayload
可以包含Step
消息或RelayMessage
消息。Step
消息更有趣,因此我们将首先检查其定义:
message Step {
Type type = 1;
map<string, google.protobuf.Any> aggregator_values = 2;
int64 activeInStep = 3;
enum Type {
INVALID = 0;
PRE = 1;
POST = 2;
POST_KEEP_RUNNING = 3;
EXECUTED_GRAPH = 4;
PESISTED_RESULTS = 5;
COMPLETED_JOB = 6;
}
}
Step
消息将由工作节点发送,以进入主节点的特定执行步骤的屏障。屏障类型由type
字段指示,该字段可以采用任何嵌套的Type
值。这些值对应于我们之前看到的州图中的步骤。根据步骤类型,工作节点将在以下情况下将其本地状态传输给主节点:
-
当进入
POST
步骤的屏障时,工作节点将检索部分本地聚合器(在第八章,基于图的数据处理,我们称它们为delta)值,将它们序列化到Any
消息中,并将它们添加到一个映射中,其中键对应于聚合器名称。 -
当进入
POST_KEEP_RUNNING
步骤的屏障时,工作节点将使用在步骤中活跃的本地顶点的数量填充activeInStep
字段。
工作节点可以发送的另一种消息类型是RelayMessage
。此消息请求主节点将消息中继给负责处理其目标 ID 的工作节点。定义相当简单,如下所示:
message RelayMessage {
string destination = 1;
google.protobuf.Any message = 2;
}
destination
字段编码了目标 ID(一个 UUID),而message
字段包含实际的消息内容,序列化为Any
值。
为主节点负载建立协议缓冲区定义
现在,让我们看一下主节点发送给单个工作节点的负载的协议缓冲区定义:
message MasterPayload {
oneof payload {
JobDetails job_details = 1;
Step step = 2;
RelayMessage relay_message = 3;
}
}
当工作节点连接到作业队列时,它将阻塞,直到主节点通过发送JobDetails
消息分配给它一个新的作业:
message JobDetails {
string job_id = 1;
google.protobuf.Timestamp created_at = 2;
// The from, to) UUID range assigned to the worker. Note that from is
// inclusive and to is exclusive.
bytes partition_from_uuid = 3;
bytes partition_to_uuid = 4;
}
job_id
字段包含将要执行的任务的唯一 ID,而created_at
字段则编码了任务的创建时间戳。partition_from_uuid
和partition_to_uuid
字段定义了由主节点分配给该工作节点的 UUID 范围的边界。工作节点应使用这些信息来加载内存中适当的图部分。
要进入特定步骤的屏障,工作节点将向主节点发送Step
消息。一旦所有工作节点达到相同的屏障,主节点将通过发送带有相同步骤类型的Step
消息来广播退出屏障的通知。
然而,当Step
消息来自主节点时,两个状态相关字段用于将新的全局状态推送到每个工作节点:
-
当退出
POST
步骤的屏障时,主节点将发送回由每个 worker 发送的 delta 计算出的新的全局聚合器值。预期 worker 将使用从主节点接收到的值覆盖其本地的聚合器值。 -
当退出
POST_KEEP_RUNNING
步骤的屏障时,主节点将发送回上一步中活跃的全局顶点数。预期 worker 将使用这个全局值来测试算法的停止条件。
最后,如果主节点收到一个中继请求,它会检查其目的地以选择负责处理该请求的 worker,并简单地通过 gRPC 流转发消息。
定义用于处理双向 gRPC 流的抽象
如我们在[第九章,“与外部世界通信”中看到的,双向 gRPC 流是全双工的;接收和发送通道独立于彼此操作。然而,从 gRPC 流中读取是一个阻塞操作。因此,为了处理流的两侧,我们需要启动一些 goroutine。
gRPC 流的另一个重要注意事项是,虽然我们可以从不同的 goroutine 调用Recv
和Send
,但不同 goroutine 中并发调用这些方法是不安全的,可能会导致数据丢失!因此,我们需要一个机制来对 gRPC 流上的发送和接收操作进行序列化。对于这种类型的任务,Go 的明显原始原语是通道。
为了让我们的工作更加轻松,并使我们的代码其余部分免于处理底层的 gRPC 流,我们将引入一组抽象来封装 gRPC 流,并提供一个干净、基于通道的接口,用于从/向流中读取和写入。
远程 worker 流
remoteWorkerStream
,其定义如下所示,被主节点用于封装传入的 worker 连接:
type remoteWorkerStream struct {
stream proto.JobQueue_JobStreamServer
recvMsgCh chan *proto.WorkerPayload
sendMsgCh chan *proto.MasterPayload
sendErrCh chan error
mu sync.Mutex
onDisconnectFn func()
disconnected bool
}
如前述代码所示,remoteWorkerStream
定义了三个通道以与流交互:
-
recvMsgCh
用于接收 worker 发送的数据负载。 -
sendMsgCh
用于从主节点向 worker 发送数据负载。 -
sendErrCh
允许主节点带或不带错误代码地断开 worker 连接。
与远程 worker 流交互的代码可以使用以下方法来获取读取和写入的适当通道实例,以及关闭流的操作:
func (s *remoteWorkerStream) RecvFromWorkerChan() <-chan *proto.WorkerPayload {
return s.recvMsgCh
}
func (s *remoteWorkerStream) SendToWorkerChan() chan<- *proto.MasterPayload {
return s.sendMsgCh
}
func (s *remoteWorkerStream) Close(err error) {
if err != nil {
s.sendErrCh <- err
}
close(s.sendErrCh)
}
remoteWorkerStream
结构还包括两个字段(由互斥锁保护),用于跟踪远程 worker 的连接状态。在主节点协调作业执行时,它必须监控每个单独的 worker 的健康状况,并在任何 worker 突然断开连接时终止作业。为此,主节点可以通过以下方法注册断开回调:
func (s *remoteWorkerStream) SetDisconnectCallback(cb func()) {
s.mu.Lock()
s.onDisconnectFn = cb
if s.disconnected {
s.onDisconnectFn()
}
s.mu.Unlock()
}
由于 SetDisconnectCallback
可能在工作流已经断开连接之后被调用,因此流使用布尔 disconnected
字段来跟踪此事件,并在需要时自动调用提供的回调。
要创建一个新的 remoteWorkerStream
实例,我们只需调用其构造函数并将 gRPC 流作为参数传递。构造函数实现(如下所示)将初始化与流交互所需的各个缓冲通道:
func newRemoteWorkerStream(stream proto.JobQueue_JobStreamServer) *remoteWorkerStream {
return &remoteWorkerStream{
stream: stream,
recvMsgCh: make(chan *proto.WorkerPayload, 1),
sendMsgCh: make(chan *proto.MasterPayload, 1),
sendErrCh: make(chan error, 1),
}
}
HandleSendRecv
方法实现了与底层流交互所需逻辑。正如您在下面的代码片段中可以看到的,它首先创建了一个可取消的上下文,该方法返回时上下文总是被取消。然后,它启动一个 goroutine 来异步处理流的接收端。该方法随后进入一个无限 for
循环,在该循环中处理流的发送端,直到流优雅地关闭或发生错误:
func (s *remoteWorkerStream) HandleSendRecv() error {
ctx, cancelFn := context.WithCancel(context.Background())
defer cancelFn()
go s.handleRecv(ctx, cancelFn)
for {
select {
case mPayload := <-s.sendMsgCh:
if err := s.stream.Send(mPayload); err != nil {
return err
}
case err, ok := <-s.sendErrCh:
if !ok { // signalled to close without an error
return nil
}
return status.Errorf(codes.Aborted, err.Error())
case <-ctx.Done():
return status.Errorf(codes.Aborted, errJobAborted.Error())
}
}
}
在发送实现方面,前面的代码使用 select
块等待以下事件之一:
-
通过
sendMsgCh
发射一个有效载荷。在这种情况下,我们尝试通过流发送它,并将任何错误返回给调用者。 -
如果通过
sendErrCh
发射错误或通道关闭(请参阅上面几行的Close
方法实现),则通过grpc/status
包将特定的codes.Aborted
错误代码标记为错误,并将错误返回给调用者。如果没有发生错误,则方法返回一个nil
错误。否则,我们使用grpc/status
包将特定的codes.Aborted
错误代码标记为错误,并将错误返回给调用者。 -
最后,如果上下文被
handleRecv
goroutine 取消,我们将以一个类型化的errJobAborted
错误消息退出。
现在,让我们更详细地看看 handleRecv
方法的实现:
func (s *remoteWorkerStream) handleRecv(ctx context.Context, cancelFn func()) {
for {
wPayload, err := s.stream.Recv()
if err != nil {
s.handleDisconnect()
cancelFn()
return
}
select {
case s.recvMsgCh <- wPayload:
case <-ctx.Done():
return
}
}
}
调用流的 Recv
方法会阻塞,直到有消息可用或远程连接断开。如果我们从工作进程接收到传入的消息,将使用 select
块将消息入队到 recvMsgCh
或在上下文被取消时退出 goroutine(例如,HandleSendRecv
由于错误而退出)。
另一方面,如果我们检测到错误,我们总是假设客户端断开连接,在取消上下文和退出 goroutine 之前调用 handleDisconnect
辅助方法:
func (s *remoteWorkerStream) handleDisconnect() {
s.mu.Lock()
if s.onDisconnectFn != nil {
s.onDisconnectFn()
}
s.disconnected = true
s.mu.Unlock()
}
前面的实现相当直接。获取 mu
锁并检查是否指定了断开连接回调。如果是这样,则调用回调并将 disconnected
标志设置为 true
以跟踪断开事件。
远程主流
接下来,我们将转向工作端,并检查处理与主节点连接的等效流辅助方法。remoteMasterStream
类型的定义与 remoteWorkerStream
几乎相同,如下所示:
type remoteMasterStream struct {
stream proto.JobQueue_JobStreamClient
recvMsgCh chan *proto.MasterPayload
sendMsgCh chan *proto.WorkerPayload
ctx context.Context
cancelFn func()
mu sync.Mutex
onDisconnectFn func()
disconnected bool
}
一旦工作节点连接到主节点并接收到一个作业分配,它将调用 newRemoteMasterStream
函数来使用 remoteMasterStream
实例包装获得的流连接:
func newRemoteMasterStream(stream proto.JobQueue_JobStreamClient) *remoteMasterStream {
ctx, cancelFn := context.WithCancel(context.Background())
return &remoteMasterStream{
ctx: ctx,
cancelFn: cancelFn,
stream: stream,
recvMsgCh: make(chan *proto.MasterPayload, 1),
sendMsgCh: make(chan *proto.WorkerPayload, 1),
}
}
如您在前面的代码片段中所见,构造函数创建了一个可取消的上下文,并为与流进行接口交互分配了一对通道。
正如我们在 remoteWorkerStream
实现中所做的那样,我们将定义一对便利方法来访问这些通道,如下所示:
func (s *remoteMasterStream) RecvFromMasterChan() <-chan *proto.MasterPayload {
return s.recvMsgCh
}
func (s *remoteMasterStream) SendToMasterChan() chan<- *proto.WorkerPayload {
return s.sendMsgCh
}
HandleSendRecv
方法负责接收来自主节点的传入消息,以及从工作节点发送出去的消息。
如您在下面的代码块中所见,实现方式与 remoteWorkerStream
实现大致相同,但有两大小的区别。你能找到它们吗?看看:
func (s *remoteMasterStream) HandleSendRecv() error {
defer func() {
s.cancelFn()
_ = s.stream.CloseSend()
}()
go s.handleRecv()
for {
select {
case wPayload := <-s.sendMsgCh:
if err := s.stream.Send(wPayload); err != nil && !xerrors.Is(err, io.EOF) {
return err
}
case <-s.ctx.Done():
return nil
}
}
}
第一个区别在于我们处理流 Send
方法返回的错误的方式。如果工作节点在尝试将有效载荷发送到主节点的前一个代码块中关闭发送流,Send
将返回一个 io.EOF
错误,以让我们知道我们无法通过该流发送任何更多消息。由于工作节点是控制发送流的一方,我们将 io.EOF
错误视为预期并忽略它们。
其次,由于工作节点是 RPC 的发起者,它不允许像我们在主节点流实现中那样,使用特定的错误代码来终止发送流。因此,对于这种实现,没有必要维护(并轮询)一个专用的错误通道。
另一方面,以下接收端代码的实现方式与 remoteMasterStream
完全相同:
func (s *remoteMasterStream) handleRecv() {
for {
mPayload, err := s.stream.Recv()
if err != nil {
s.handleDisconnect()
s.cancelFn()
return
}
select {
case s.recvMsgCh <- mPayload:
case <-s.ctx.Done():
return
}
}
}
要实际关闭流并使 HandleSendRecv
方法退出,工作节点可以调用 remoteMasterStream
的 Close
方法:
func (s *remoteMasterStream) Close() {
s.cancelFn()
}
Close
方法首先取消接收和发送代码中由 select
块监控的上下文。正如我们在几行之前讨论的那样,后者的操作将导致任何挂起的 Send
调用因 io.EOF
错误而失败,并允许 HandleSendRecv
方法返回。此外,上下文的取消使 handleRecv
协程也能返回,从而确保我们的实现没有泄漏任何协程。
为图执行步骤创建分布式屏障
屏障可以被视为一组进程的会合点。一旦一个进程进入屏障,它将阻止它继续进行任何进展,直到所有其他预期的进程也进入屏障。
在 Go 中,我们可以使用 sync.WaitGroup
原语来帮助建模一个屏障,如下所示:
func barrier(numWorkers int) {
var wg sync.WaitGroup
wg.Add(numWorkers)
for i := 0; i < numWorkers; i++ {
go func() {
wg.Done()
fmt.Printf("Entered the barrier; waiting for other goroutines to join")
wg.Wait()
fmt.Printf("Exited the barrier")
}()
}
wg.Wait()
}
为了确保每个工作器与其他工作器以锁步的方式执行图状态机的各个阶段,我们必须实现一个类似的屏障原语。然而,就我们的特定应用程序而言,我们感兴趣同步的 goroutines 在不同的主机上执行。这显然使事情变得复杂,因为我们现在需要提出一个分布式屏障实现!
正如我们在上一节中提到的,主节点将作为分布式屏障的协调者。为了使代码更容易理解,在接下来的小节中,我们将把我们的分布式屏障实现分为工作器端和主节点端实现,并分别检查它们。
为单个工作器实现步骤屏障
workerStepBarrier
类型封装了使工作器能够进入特定图执行步骤并等待主节点通知工作器可以现在退出屏障所需的逻辑。
workerStepBarrier
类型定义如下:
type workerStepBarrier struct {
ctx context.Context
stream *remoteMasterStream
waitCh map[proto.Step_Type]chan *proto.Step
}
要了解这些字段是如何初始化的,让我们看看新屏障实例的构造函数:
func newWorkerStepBarrier(ctx context.Context, stream *remoteMasterStream) *workerStepBarrier {
waitCh := make(map[proto.Step_Type]chan *proto.Step)
for stepType := range proto.Step_Type_name {
if proto.Step_Type(stepType) == proto.Step_INVALID {
continue
}
waitCh[proto.Step_Type(stepType)] = make(chan *proto.Step)
}
return &workerStepBarrier{
ctx: ctx,
stream: stream,
waitCh: waitCh,
}
}
如您所见,构造函数接受一个上下文和一个remoteMasterStream
实例作为参数。上下文允许屏障代码阻塞,直到主节点收到通知或上下文被取消(例如,因为工作器正在关闭)。
为了允许工作器在接收到主节点的通知之前阻塞,构造函数将为我们要为创建屏障的每种步骤类型分配一个单独的通道。当 protoc 将我们的协议缓冲定义编译成 Go 代码时,它还将提供一个方便的Step_Type
映射,该映射通常用于获取步骤类型的字符串表示(协议缓冲将enum
类型建模为int32
值)。构造函数利用这个映射,通过一个简单的for
循环块自动生成所需数量的通道。
当工作器想要进入特定步骤的屏障时,它会创建一个新的Step
消息,其中包含它想要与主节点共享的本地状态,并调用阻塞的Wait
方法,如下所示:
func (b *workerStepBarrier) Wait(step *proto.Step) (*proto.Step, error) {
ch, exists := b.waitCh[step.Type]
if !exists {
return nil, xerrors.Errorf("unsupported step type %q", proto.Step_Type_name[int32(step.Type)])
}
select {
case b.stream.SendToMasterChan() <- &proto.WorkerPayload{Payload: &proto.WorkerPayload_Step{Step: step}}:
case <-b.ctx.Done():
return nil, errJobAborted
}
select {
case step = <-ch:
return step, nil
case <-b.ctx.Done():
return nil, errJobAborted
}
}
Wait
方法由两个基本部分组成。在验证步骤类型后,实现尝试将新的WorkerPayload
推入remoteMasterStream
,以便通过 gRPC 流发送给主节点。
一旦有效加载了负载,工作器就会在适当的通道上等待指定的步骤类型,而主节点向所有工作器广播一个Step
消息,告知它们可以退出屏障。一旦收到该消息,它就会返回给调用者,然后调用者就可以自由地执行实现此特定图计算步骤所需的工作块。
到目前为止,你可能想知道谁负责将主节点的广播步骤发布到Wait
方法尝试读取的通道。为了强制执行关注点的清晰分离(并使测试更容易),屏障实现不关心从主节点读取响应的低级细节。相反,它提供了一个Notify
方法,一旦主节点收到步骤消息,另一个组件(作业协调器)将调用此方法:
func (b *workerStepBarrier) Notify(step *proto.Step) error {
ch, exists := b.waitCh[step.Type]
if !exists {
return xerrors.Errorf("unsupported step type %q", proto.Step_Type_name[int32(step.Type)])
}
select {
case ch <- step:
return nil
case <-b.ctx.Done():
return errJobAborted
}
}
Notify
方法实现中的代码检查步骤类型字段,并使用它来选择发布Step
响应的通道。
现在,让我们继续检查主节点侧的等效步骤屏障实现。
为主节点实现步骤屏障
现在,让我们看看运行在主节点上的屏障实现逻辑的另一部分。masterStepBarrier
类型,其定义如下,确实更有趣,因为它包含了实际的屏障同步逻辑:
type masterStepBarrier struct {
ctx context.Context
numWorkers int
waitCh map[proto.Step_Type]chan *proto.Step
notifyCh map[proto.Step_Type]chan *proto.Step
}
一个关键的区别是,masterStepBarrier
类型定义了两种类型的通道:
-
等待通道:这是一个屏障监控来自工作者
Step
消息的通道。 -
通知通道:这是一个远程工作流将阻塞等待主节点广播
Step
消息的通道。
通过浏览创建主屏障的构造函数逻辑,我们可以看到我们自动通过迭代 protoc 为在编译协议缓冲定义时使用而生成的Step_Type
变量来创建所需的通道集。
更重要的是,当创建一个新的屏障时,调用者还应该提供一个参数,即预期将加入屏障的工作者数量:
func newMasterStepBarrier(ctx context.Context, numWorkers int) *masterStepBarrier {
waitCh := make(map[proto.Step_Type]chan *proto.Step)
notifyCh := make(map[proto.Step_Type]chan *proto.Step)
for stepType := range proto.Step_Type_name {
if proto.Step_Type(stepType) == proto.Step_INVALID {
continue
}
waitCh[proto.Step_Type(stepType)] = make(chan *proto.Step)
notifyCh[proto.Step_Type(stepType)] = make(chan *proto.Step)
}
return &masterStepBarrier{
ctx: ctx,
numWorkers: numWorkers,
waitCh: waitCh,
notifyCh: notifyCh,
}
}
在上一节中,我们看到了当工作者在workerStepBarrier
上调用Wait
方法时,通过remoteMasterStream
发布了一个Step
消息。现在,我们将检查接收端发生了什么。一旦接收到发布的Step
消息,主节点就会在masterStepBarrier
上调用Wait
方法。
从原则上讲,这不过是在 gRPC 流上实现的一个古老的单一 RPC!以下是主节点的Wait
方法内部发生的情况:
func (b *masterStepBarrier) Wait(step *proto.Step) (*proto.Step, error) {
waitCh, exists := b.waitCh[step.Type]
if !exists {
return nil, xerrors.Errorf("unsupported step type %q", proto.Step_Type_name[int32(step.Type)])
}
select {
case waitCh <- step:
case <-b.ctx.Done():
return nil, errJobAborted
}
select {
case step = <-b.notifyCh[step.Type]:
return step, nil
case <-b.ctx.Done():
return nil, errJobAborted
}
}
实现首先尝试将传入的Step
消息发布到负责处理由Step
消息的type
字段所宣布的步骤的wait
通道。这段代码将阻塞,直到主节点准备好进入相同的屏障(或由于主节点关闭而超时)。
在成功写入wait
通道之后,代码将再次阻塞,等待来自主节点的通知发布到适当的notify通道,以便为步骤类型提供。一旦从主节点接收到的Step
响应从队列中取出,Wait
将解除阻塞并返回Step
给调用者。然后,调用者负责将Step
消息传回工人,其中它将作为工人屏障的Notify
方法的参数提供。
当主节点准备好进入特定步骤的屏障时,它将提供步骤类型作为参数调用阻塞的WaitForWorkers
方法。此方法,其实现如下所示,与工人侧的Wait
方法等效:
func (b *masterStepBarrier) WaitForWorkers(stepType proto.Step_Type) ([]*proto.Step, error) {
waitCh, exists := b.waitCh[stepType]
if !exists {
return nil, xerrors.Errorf("unsupported step type %q", proto.Step_Type_name[int32(stepType)])
}
collectedSteps := make([]*proto.Step, b.numWorkers)
for i := 0; i < b.numWorkers; i++ {
select {
case step := <-waitCh:
collectedSteps[i] = step
case <-b.ctx.Done():
return nil, errJobAborted
}
}
return collectedSteps, nil
}
前述方法的目的是在预期的工人数量通过Wait
方法加入特定步骤类型的屏障之前等待,并收集每个工人发布的单个Step
消息。为此,代码首先初始化一个足够容纳传入消息的切片,并从适当的wait通道为步骤执行numWorkers
次读取。
一旦所有工人加入屏障,WaitForWorkers
解除阻塞并返回Step
消息的切片给调用者。在此点,尽管所有工人仍然阻塞,但主节点现在处于所谓的关键部分中,它可以以原子方式自由实现所需的任何操作。例如,在POST_STEP
的关键部分内部,主节点将迭代工人的步骤消息并将每个工人的部分聚合器增量应用到其自己的全局聚合器状态中。
然后,一旦主节点准备好退出其关键部分,它将使用要广播给当前在屏障上阻塞的工人的Step
消息调用NotifyWorkers
方法:
func (b *masterStepBarrier) NotifyWorkers(step *proto.Step) error {
notifyCh, exists := b.notifyCh[step.Type]
if !exists {
return xerrors.Errorf("unsupported step type %q", proto.Step_Type_name[int32(step.Type)])
}
for i := 0; i < b.numWorkers; i++ {
select {
case notifyCh <- step:
case <-b.ctx.Done():
return errJobAborted
}
}
return nil
}
NotifyWorkers
需要做的只是将主节点的Step
消息的numWorkers
个副本推送到屏障步骤的适当通知通道。写入通知通道将解除Wait
方法的调用者,并允许步骤消息传播回工人。
这一切看起来对你来说是否很困惑?以下图表可视化了主节点与服务器之间所有与屏障相关的交互,并希望帮助你连接这些点:
图 3:主节点与工人之间屏障交互的端到端示意图
下面是对前面图表中发生的事情的简要总结:
-
主节点对
POST
步骤调用WaitForWorkers
并阻塞。 -
工人对其本地屏障实例上的
POST
步骤调用Wait
并阻塞。 -
通过
remoteMasterStream
发布Step
消息。 -
主侧处理传入的工人消息的代码块接收工人的
Step
消息,并在主屏障上调用Wait
并阻塞。 -
随着所需的工作者数量(本例中为一个)已加入屏障,主节点的
WaitForWorkers
调用解除阻塞,允许主节点进入一个关键部分,在那里主节点执行其特定步骤的逻辑。 -
然后,主节点调用
NotifyWorkers
并传递一个新的Step
消息用于POST
步骤。 -
主节点上的
Wait
方法现在解除阻塞,并且主节点刚刚广播的Step
消息通过流发送回工作者。 -
当从主节点收到
Step
响应时,工作者的Wait
方法解除阻塞,工作者现在可以自由地执行其自己的特定步骤逻辑。
为包装现有图实例创建自定义执行器工厂
在第八章,基于图的数据处理中,我们探讨了使用 bspgraph
包来实现一些流行的基于图的算法,如迪杰斯特拉最短路径、图着色和 PageRank。为了协调上述算法的端到端执行,我们依赖于包提供的 Executor
类型的 API。然而,我们不是直接让我们的算法实现调用 Executor
类型的构造函数,而是允许最终用户可选地指定一个自定义执行器工厂来获取 Executor
实例。
任何满足以下签名的 Go 函数都可以有效地用作新 Executor
构造函数的默认构造函数:
type ExecutorFactory func(*bspgraph.Graph, bspgraph.ExecutorCallbacks) *bspgraph.Executor
这种方法的关键好处是执行器工厂可以完全访问特定算法的回调,用于计算的各个阶段。在本章中,我们将利用这种机制来拦截并装饰用户定义的回调,以必要的粘合逻辑与我们在上一节中构建的屏障原语进行接口。然后,修补后的回调将被传递给原始 Executor
构造函数,并将结果返回给调用者。
这个小技巧,虽然对原始算法实现完全透明,但我们确实需要确保所有回调都与其他所有工作者同步执行。
工人的执行器工厂
要为工作者创建合适的执行器工厂,我们可以使用以下辅助函数:
func newWorkerExecutorFactory(serializer Serializer, barrier *workerStepBarrier) bspgraph.ExecutorFactory {
f := &workerExecutorFactory{ serializer: serializer, barrier: barrier }
return func(g *bspgraph.Graph, cb bspgraph.ExecutorCallbacks) *bspgraph.Executor {
f.origCallbacks = cb
patchedCb := bspgraph.ExecutorCallbacks{
PreStep: f.preStepCallback,
PostStep: f.postStepCallback,
PostStepKeepRunning: f.postStepKeepRunningCallback,
}
return bspgraph.NewExecutor(g, patchedCb)
}
}
newWorkerExecutorFactory
函数期望两个参数,即一个 Serializer
实例和一个初始化的 workerStepBarrier
对象。序列化器实例负责将聚合值序列化和反序列化到 any.Any
协议缓冲消息中,这些消息是工作者在进入或退出各种步骤屏障时与主节点交换的。在下面的代码中,你可以看到 Serializer
接口的定义:
type Serializer interface {
Serialize(interface{}) (*any.Any, error)
Unserialize(*any.Any) (interface{}, error)
}
如您在前面的代码片段中看到的,newWorkerExecutorFactory
函数分配一个新的workerExecutorFactory
值,并返回一个满足ExecutorFactory
签名的闭包。当生成的工厂函数被调用时,其实现捕获原始回调并使用一组修补后的回调调用实际的执行器构造函数。
让我们看看修补后的回调中每个发生的事情,从负责处理PRE
步骤的那个开始:
func (f *workerExecutorFactory) preStepCallback(ctx context.Context, g *bspgraph.Graph) error {
if _, err := f.barrier.Wait(&proto.Step{Type: proto.Step_PRE}); err != nil {
return err
}
if f.origCallbacks.PreStep != nil {
return f.origCallbacks.PreStep(ctx, g)
}
return nil
}
如您所见,回调立即加入屏障,一旦被指示退出,它将调用原始(如果已定义)的PRE
步骤回调。下面的代码显示了我们的列表中的下一个回调,在执行图超级步骤后立即调用:
func (f *workerExecutorFactory) postStepCallback(ctx context.Context, g *bspgraph.Graph, activeInStep int) error {
aggrDeltas, err := serializeAggregatorDeltas(g, f.serializer)
if err != nil {
return xerrors.Errorf("unable to serialize aggregator deltas")
}
stepUpdateMsg, err := f.barrier.Wait(&proto.Step{
Type: proto.Step_POST,
AggregatorValues: aggrDeltas,
})
if err != nil {
return err
} else if err = setAggregatorValues(g, stepUpdateMsg.AggregatorValues, f.serializer); err != nil {
return err
} else if f.origCallbacks.PostStep != nil {
return f.origCallbacks.PostStep(ctx, g, activeInStep)
}
return nil
}
我们之前提到,在POST
步骤中,当工作者进入POST
步骤屏障时,必须将他们的部分聚合器增量传输给主节点。这正是前一个代码片段中发生的事情。
serializeAggregatorDeltas
辅助函数遍历图上定义的聚合器列表,并使用提供的Serializer
实例将它们转换为map[string]*any.Any
。然后,带有序列化增量的映射附加到Step
消息上,并通过屏障的Wait
方法发送给主节点。
主节点汇总每个工作者的增量,并广播一个新的Step
消息,其中包含更新后的全局聚合器值集。一旦我们收到更新消息,我们就调用setAggregatorValues
辅助函数,该函数反序列化传入的map[string]*any.Any
映射条目,并覆盖本地图实例的聚合器值。在返回之前,回调包装器如果实际定义了,将调用原始用户定义的POST
步骤回调。
我们将要检查的最后一个是用于POST_KEEP_RUNNING
步骤的回调包装器实现,如下所示:
func (f *workerExecutorFactory) postStepKeepRunningCallback(ctx context.Context, g *bspgraph.Graph, activeInStep int) (bool, error) {
stepUpdateMsg, err := f.barrier.Wait(&proto.Step{
Type: proto.Step_POST_KEEP_RUNNING,
ActiveInStep: int64(activeInStep),
})
if err != nil {
return false, err
}
if f.origCallbacks.PostStepKeepRunning != nil {
return f.origCallbacks.PostStepKeepRunning(ctx, g, int(stepUpdateMsg.ActiveInStep))
}
return true, nil
}
与其他每个回调包装器实现一样,我们首先进入当前步骤类型的屏障。请注意,出去的Step
消息包括此步骤中活动的本地顶点数。我们从主节点收到的响应包括全局的活动顶点数,这是必须传递给用户定义回调的实际值。
主节点的执行器工厂
生成主节点执行器工厂的代码相当相似;为了避免再次重复相同的代码块,我们只列出每个单独回调包装器的实现,从preStepCallback
开始:
func (f *masterExecutorFactory) preStepCallback(ctx context.Context, g *bspgraph.Graph) error {
if _, err := f.barrier.WaitForWorkers(proto.Step_PRE); err != nil {
return err
} else if err := f.barrier.NotifyWorkers(&proto.Step{Type: proto.Step_PRE}); err != nil {
return err
}
if f.origCallbacks.PreStep != nil {
return f.origCallbacks.PreStep(ctx, g)
}
return nil
}
与工作者端实现相比,主节点的行为略有不同。首先,主节点会等待所有工作者进入屏障。然后,借助masterStepBarrier
原语,它广播一个通知消息,解除工作者的阻塞,并允许主节点和工作者执行相同的用户定义回调步骤。
让我们看看POST
步骤的回调覆盖内部发生了什么:
func (f *masterExecutorFactory) postStepCallback(ctx context.Context, g *bspgraph.Graph, activeInStep int) error {
workerSteps, err := f.barrier.WaitForWorkers(proto.Step_POST)
if err != nil {
return err
}
for _, workerStep := range workerSteps {
if err = mergeWorkerAggregatorDeltas(g, workerStep.AggregatorValues, f.serializer); err != nil {
return xerrors.Errorf("unable to merge aggregator deltas into global state: %w", err)
}
}
globalAggrValues, err := serializeAggregatorValues(g, f.serializer, false)
if err != nil {
return xerrors.Errorf("unable to serialize global aggregator values: %w", err)
} else if err := f.barrier.NotifyWorkers(&proto.Step{ Type: proto.Step_POST, AggregatorValues: globalAggrValues }); err != nil {
return err
} else if f.origCallbacks.PostStep != nil {
return f.origCallbacks.PostStep(ctx, g, activeInStep)
}
return nil
}
再次,主节点等待所有工作者进入屏障,但这次,它收集每个个别工作者发送的Step
消息。然后,主节点进入其关键部分,迭代收集的Step
消息列表并将其部分增量应用到自己的聚合器中。最后,通过调用serializeAggregatorValues
辅助函数将新的全局聚合器值序列化,并广播回每个工作者。
如预期的那样,POST_STEP_KEEP_RUNNING
步骤的回调包装器遵循完全相同的模式:
func (f *masterExecutorFactory) postStepKeepRunningCallback(ctx context.Context, g *bspgraph.Graph, activeInStep int) (bool, error) {
workerSteps, err := f.barrier.WaitForWorkers(proto.Step_POST_KEEP_RUNNING)
if err != nil {
return false, err
}
for _, workerStep := range workerSteps {
activeInStep += int(workerStep.ActiveInStep)
}
if err := f.barrier.NotifyWorkers(&proto.Step{ Type: proto.Step_POST_KEEP_RUNNING, ActiveInStep: int64(activeInStep) }); err != nil {
return false, err
} else if f.origCallbacks.PostStepKeepRunning != nil {
return f.origCallbacks.PostStepKeepRunning(ctx, g, activeInStep)
}
return true, nil
}
在主节点的关键部分中,每个工作者报告的ActiveInStep
计数被汇总,并将结果广播回每个工作者。退出屏障后,主节点调用用户定义的步骤回调。
协调图作业的执行
到目前为止,我们已经创建了从工作者和主节点之间建立的双向流中读取和写入所需的抽象。更重要的是,我们已经实现了一个分布式屏障原语,它作为工作者和主节点节点异步执行的各个图计算步骤的会合点。
最后,我们定义了一组自定义执行器工厂,使我们能够将任何使用bspgraph
包构建的现有算法包装起来,并透明地允许它使用屏障原语以确保图计算在所有工作者上同步执行。
我们应该记住的一件事是,将图计算算法运行到完成并不是将分布式计算作业视为完成的充分条件!我们仍然必须确保计算结果在没有错误的情况下持久化到稳定存储。后一项任务绝非易事;在工作者尝试保存进度时,可能会出现许多问题,因为工作者可能会崩溃,存储可能不可达,或者可能发生各种随机的、与网络相关的故障。
正如古语所说——构建分布式系统是困难的!为此,我们需要引入一个编排层——换句话说,一个将我们迄今为止构建的所有组件组合在一起并包含协调分布式计算作业端到端执行所需的所有逻辑的机制。如果发生任何错误(在工作者、主节点或两者中),协调器应检测到它并向所有工作者发出终止作业的信号。
简化与 dbspgraph 包的最终用户交互
本章详细探讨了分布式作业运行器实现的各种组件。尽管如此,我们更愿意将所有内部细节隐藏给dbspgraph
包的预期用户。
实际上,我们需要想出一个简化的 API,让最终用户能够与我们的包交互。结果证明,这相当容易做到。假设最终用户已经在bspgraph
包的帮助下创建(并测试)了他们的图算法,他们只需要提供一个简单的适配器来与算法实现交互。所需的方法封装在Runner
接口定义中,概述如下:
type Runner interface {
StartJob(Details, bspgraph.ExecutorFactory) (*bspgraph.Executor, error)
CompleteJob(Details) error
AbortJob(Details)
}
每个Runner
方法的第一个参数是一个包含当前执行作业元数据的结构。Details
类型反映了JobDetails
协议缓冲消息的字段,该消息由主节点广播给每个工作节点,并定义如下:
type Details struct {
JobID string
CreatedAt time.Time
PartitionFromID uuid.UUID
PartitionToID uuid.UUID
}
StartJob
方法提供了一个钩子,允许最终用户初始化一个bspgraph.Graph
实例,加载适当的数据集(顶点和边),并使用提供的ExecutorFactory
参数创建一个新的Executor
实例,StartJob
将此实例返回给调用者。正如你可能猜到的,我们的代码将根据代码是在工作节点还是主节点上执行,使用适当的自定义执行工厂调用StartJob
。
一旦主节点和工作节点都完成了图的执行,我们将安排调用CompleteJob
方法。这是最终用户从图中提取计算的应用特定结果并将其持久化到稳定存储的地方。
另一方面,如果在运行算法或尝试持久化结果时发生错误,我们的作业协调器将调用AbortJob
方法来通知最终用户,并让他们适当地清理或采取任何必要的操作以回滚已持久化到磁盘的任何更改。
工作节点作业协调器
我们将首先检查工作节点端执行的协调器逻辑。让我们快速看一下workerJobCoordinator
类型的构造函数:
type workerJobCoordinatorConfig struct {
jobDetails job.Details
masterStream *remoteMasterStream
jobRunner job.Runner
serializer Serializer
}
func newWorkerJobCoordinator(ctx context.Context, cfg workerJobCoordinatorConfig) *workerJobCoordinator {
jobCtx, cancelJobCtx := context.WithCancel(ctx)
return &workerJobCoordinator{
jobCtx: jobCtx, cancelJobCtx: cancelJobCtx,
barrier: newWorkerStepBarrier(jobCtx, cfg.masterStream),
cfg: cfg,
}
}
构造函数期望一个外部上下文作为参数,以及一个包含以下内容的配置对象:
-
作业元数据
-
一个
remoteMasterStream
实例,我们将用它来与主节点交互 -
用户提供的作业
Runner
实现 -
一个用户提供的
Serializer
实例,将被执行器工厂(序列化聚合值)和用于序列化需要通过主节点中继的输出图消息使用
在继续之前,构造函数创建一个新的可取消的上下文(jobCtx
),它封装了调用者提供的上下文。然后,jobCtx
实例被用作创建workerStepBarrier
实例的参数。这种方法允许协调器完全控制屏障的生命周期。
如果发生错误,协调器可以简单地调用cancelJobCtx
函数,并自动关闭屏障。当然,如果外部上下文意外过期,同样的拆除语义也适用。
运行一个新作业
一旦工作者从主节点接收到新的作业分配,它将调用协调器的构造函数,然后调用其RunJob
方法,该方法会阻塞,直到作业完成或发生错误:
func (c *workerJobCoordinator) RunJob() error {
// ...
}
让我们将RunJob
实现分解成更小的块,并逐一分析:
execFactory := newWorkerExecutorFactory(c.cfg.serializer, c.barrier)
executor, err := c.cfg.jobRunner.StartJob(c.cfg.jobDetails, execFactory)
if err != nil {
c.cancelJobCtx()
return xerrors.Errorf("unable to start job on worker: %w", err)
}
graph := executor.Graph()
graph.RegisterRelayer(bspgraph.RelayerFunc(c.relayNonLocalMessage))
RunJob
首先做的事情是使用配置的序列化器和构造函数已经设置好的屏障实例创建一个workerExecutor
工厂。然后,调用用户提供的job.Runner
的StartJob
方法来初始化图,并返回一个我们可以使用的Executor
值。注意,到目前为止,我们的代码对用户定义的算法是如何工作的完全一无所知!
下一步包括从返回的Executor
实例中提取bspgraph.Graph
实例,并注册一个bspgraph.Relayer
辅助器,当顶点尝试发送一个本地图实例无法识别的 ID 的消息时,图会自动调用该辅助器。我们将在后续章节中详细讨论消息中继的概念时,更详细地查看relayNonLocalMessage
方法的实现。这完成了所有必要的初始化步骤。我们现在可以开始执行图计算作业了!
为了不仅监控与主节点的连接健康,还异步处理任何传入的有效载荷,我们将启动一个 goroutine:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
c.cfg.masterStream.SetDisconnectCallback(c.handleMasterDisconnect)
c.handleMasterPayloads(graph)
}()
当我们的 goroutine 正忙于处理传入的有效载荷时,RunJob
会调用runJobToCompletion
辅助方法,该方法会遍历图执行状态机的各个阶段。如果发生错误,我们会调用用户的AbortJob
方法,然后继续检查错误的原因。
如果作业执行失败是由于上下文取消,我们将错误替换为更有意义、类型化的errJobAborted
错误。另一方面,如果handleMasterPayloads
方法报告了一个更有趣的错误,我们将覆盖返回的错误值以报告的错误:
if err = c.runJobToCompletion(executor); err != nil {
c.cfg.jobRunner.AbortJob(c.cfg.jobDetails)
if xerrors.Is(err, context.Canceled) {
err = errJobAborted
}
if c.asyncWorkerErr != nil {
err = c.asyncWorkerErr
}
}
c.cancelJobCtx()
wg.Wait() // wait for any spawned goroutines to exit before returning.
return err
在返回之前,我们取消作业上下文以触发不仅屏障的拆除,还包括生成的有效载荷处理 goroutine,并在等待组上Wait
,直到 goroutine 退出。
通过图的状态机阶段进行转换
runJobToCompletion
方法的作用是执行图状态机的所有阶段,直到作业完成或发生错误。
如以下代码片段所示,我们请求执行器实例运行图算法,直到满足其终止条件。然后,工作者通过加入EXECUTED_GRAPH
步骤的屏障向主节点报告其成功。
一旦所有其他工作者都到达了屏障,主节点将解除对我们的阻塞,然后我们继续在用户提供的job.Runner
实例上调用CompleteJob
方法。然后,我们通过加入PERSISTED_RESULTS
步骤的屏障来通知主节点计算结果已经被存储。
在主节点最后一次解除对我们的阻塞后,我们通过加入COMPLETED_JOB
步骤的屏障来通知主节点我们已经到达了状态机的最终阶段:
func (c *workerJobCoordinator) runJobToCompletion(executor *bspgraph.Executor) error {
if err := executor.RunToCompletion(c.jobCtx); err != nil {
return err
} else if _, err := c.barrier.Wait(&proto.Step{Type: proto.Step_EXECUTED_GRAPH}); err != nil {
return errJobAborted
} else if err := c.cfg.jobRunner.CompleteJob(c.cfg.jobDetails); err != nil {
return err
} else if _, err = c.barrier.Wait(&proto.Step{Type: proto.Step_PESISTED_RESULTS}); err != nil {
return errJobAborted
}
_, _ = c.barrier.Wait(&proto.Step{Type: proto.Step_COMPLETED_JOB})
return nil
}
当所有工作者都达到COMPLETED_JOB
步骤时,主节点将使用grpc.OK
代码终止连接的作业流。由于 gRPC 安排消息传输的方式,无法保证代码会在实际断开流之前被工作者接收(在后一种情况下,我们可能会收到io.EOF
错误)。
然而,请记住,主节点只有在所有工作者都到达最后一个屏障并报告他们已成功持久化本地结果后才会与我们断开连接。这就是为什么我们可以在最后的barrier.Wait
调用中安全地省略错误检查。
处理来自主节点的传入有效载荷
正如我们在上一节中看到的,处理有效载荷的 goroutine 的主体首先在主节点流上注册一个断开回调,然后将有效载荷处理委托给辅助的handleMasterPayloads
方法。
这样,如果我们突然失去了与主节点的连接,我们只需取消作业上下文并导致作业因错误而中止。以下断开回调实现相当简单:
func (c *workerJobCoordinator) handleMasterDisconnect() {
select {
case <-c.jobCtx.Done(): // job already aborted or completed
default:
c.cancelJobCtx()
}
}
handleMasterPayloads
方法实现了一个长时间运行的事件处理循环。一个select
块监视传入的有效载荷或作业上下文的取消。
如果上下文被取消或masterStream
关闭了我们当前正在读取的通道,该方法将返回:
func (c *workerJobCoordinator) handleMasterPayloads(graph *bspgraph.Graph) {
defer c.cancelJobCtx()
var mPayload *proto.MasterPayload
for {
select {
case mPayload = <-c.cfg.masterStream.RecvFromMasterChan():
case <-c.jobCtx.Done():
return
}
if mPayload == nil {
return
}
// omitted: process payload depending on its type
}
}
一旦从主节点接收到有效的有效载荷,我们就检查其内容,并根据有效载荷类型执行相应的操作:
if relayMsg := mPayload.GetRelayMessage(); relayMsg != nil {
if err := c.deliverGraphMessage(graph, relayMsg); err != nil {
c.mu.Lock()
c.asyncWorkerErr = err
c.mu.Unlock()
c.cancelJobCtx()
return
}
} else if stepMsg := mPayload.GetStep(); stepMsg != nil {
if err := c.barrier.Notify(stepMsg); err != nil {
return
}
}
如果主节点向我们转发了消息,处理程序将调用deliverGraphMessage
方法(见下一节),尝试将消息传递给预期的接收者。如果消息传递尝试失败,错误将被记录在asyncWorkerErr
变量中,并在返回之前取消作业上下文。
我们可以从主节点接收到的另一种有效载荷是Step
消息,主节点通过广播此消息来通知工作者他们可以退出他们当前正在等待的屏障。我们所需做的只是使用获取到的Step
消息作为参数调用屏障的Notify
方法。
将主节点用作出站消息中继
正如我们在RunJob
方法初始化块中看到的,一旦我们获得了图执行器实例,我们就注册一个bspgraph.Replayer
实例,该实例作为中继消息的逃生门,这些消息是针对由不同图实例管理的顶点的。
这就是relayNonLocalMessage
辅助方法是如何实现的:
func (c *workerJobCoordinator) relayNonLocalMessage(dst string, msg message.Message) error {
serializedMsg, err := c.cfg.serializer.Serialize(msg)
if err != nil {
return xerrors.Errorf("unable to serialize message: %w", err)
}
wMsg := &proto.WorkerPayload{Payload: &proto.WorkerPayload_RelayMessage{
RelayMessage: &proto.RelayMessage{
Destination: dst,
Message: serializedMsg,
},
}}
select {
case c.cfg.masterStream.SendToMasterChan() <- wMsg:
return nil
case <-c.jobCtx.Done():
return errJobAborted
}
}
我们调用用户定义的序列化器将应用程序特定的图消息序列化为any.Any
协议缓冲消息,并将其附加到新的WorkerPayload
实例作为RelayMessage
。然后实现将阻塞,直到消息成功入队到masterStream
输出负载通道或作业上下文被取消。
另一方面,当主节点将传入的图消息转发给这个工作节点时,协调器的handleMasterPayloads
方法将调用deliverGraphMessage
方法,其列表如下所示:
func (c *workerJobCoordinator) deliverGraphMessage(graph *bspgraph.Graph, relayMsg *proto.RelayMessage) error {
payload, err := c.cfg.serializer.Unserialize(relayMsg.Message)
if err != nil {
return xerrors.Errorf("unable to decode relayed message: %w", err)
}
graphMsg, ok := payload.(message.Message)
if !ok {
return xerrors.Errorf("unable to relay message payloads that do not implement message.Message")
}
return graph.SendMessage(relayMsg.Destination, graphMsg)
}
这次,序列化器被用来将传入的any.Any
消息解包回与message.Message
接口兼容的类型,这是图SendMessage
方法所期望的。由于预期的接收者是本地顶点,我们只需要假装自己是本地图顶点,并简单地使用适当的目的地 ID 和消息负载调用图的SendMessage
方法。
主作业协调器
在本节中,我们将探讨负责在主节点上协调执行分布式图计算作业的作业协调器组件的实现。
与实现工作节点作业协调器的方式类似,我们首先定义一个配置结构体来保存创建新协调器实例所需的所有必要细节,然后继续定义masterJobCoordinator
类型:
type masterJobCoordinatorConfig struct {
jobDetails job.Details
workers []*remoteWorkerStream
jobRunner job.Runner
serializer Serializer
logger *logrus.Entry
}
type masterJobCoordinator struct {
jobCtx context.Context
cancelJobCtx func()
barrier *masterStepBarrier
partRange *partition.Range
cfg masterJobCoordinatorConfig
}
如您所见,主协调器的配置选项与工作节点变体几乎相同,唯一的区别是主协调器还额外提供了一部分remoteWorkerStream
实例。这对应于主节点分配给这个特定作业的工作节点。在masterJobCoordinator
定义的字段集中,两种作业协调器类型之间的相同对称模式也非常明显。
一旦主节点收集到足够的工人来运行新的作业,它将调用newMasterJobCoordinator
构造函数,其实现如下所示:
func newMasterJobCoordinator(ctx context.Context, cfg masterJobCoordinatorConfig) (*masterJobCoordinator, error) {
partRange, err := partition.NewRange(cfg.jobDetails.PartitionFromID, cfg.jobDetails.PartitionToID, len(cfg.workers))
if err != nil {
return nil, err
}
jobCtx, cancelJobCtx := context.WithCancel(ctx)
return &masterJobCoordinator{
jobCtx: jobCtx,
cancelJobCtx: cancelJobCtx,
barrier: newMasterStepBarrier(jobCtx, len(cfg.workers)),
partRange: partRange,
cfg: cfg,
}, nil
}
主协调器的一个重要职责是将 UUID 空间均匀分割成块,并将每个块分配给一个工作节点。为此,在分配新的协调器实例之前,构造函数将首先使用调用者通过job.Details
参数提供的范围创建一个新的分区范围(有关Range
类型的详细信息,请参阅第十章,构建、打包和部署软件)。
由于我们提出的集群配置使用单个主节点和多个工作节点,作业详情参数的范围将始终覆盖整个 UUID 空间。
运行新的作业
一旦主节点创建一个新的masterJobCoordinator
实例,它将调用其RunJob
方法以启动作业的执行。由于该方法有点长,我们将将其分解为一系列较小的块:
execFactory := newMasterExecutorFactory(c.cfg.serializer, c.barrier)
executor, err := c.cfg.jobRunner.StartJob(c.cfg.jobDetails, execFactory)
if err != nil {
c.cancelJobCtx()
return xerrors.Errorf("unable to start job on master: %w", err)
}
for assignedPartition, w := range c.cfg.workers {
w.SetDisconnectCallback(c.handleWorkerDisconnect)
if err := c.publishJobDetails(w, assignedPartition); err != nil {
c.cfg.jobRunner.AbortJob(c.cfg.jobDetails)
c.cancelJobCtx()
return err
}
}
上一段代码的前两行应该看起来有些熟悉。我们遵循与工作者协调器实现完全相同的初始化模式,即我们首先创建我们的自定义执行器工厂,并调用用户提供的StartJob
方法以获取图算法的执行器。然后,我们遍历工作者流列表,并调用publishJobDetails
辅助函数来构造并发送JobDetails
负载到每个已连接的工作者。
但是publishJobDetails
方法实际上是如何确定每个发出的JobDetails
消息中应包含哪个 UUID 范围的?如果您还记得第十章,构建、打包和部署软件,Range
类型提供了PartitionExtents
便利方法,它给出一个在0, numPartitions)
范围内的分区号。它返回对应于请求分区开始和结束的 UUID 值。因此,我们在这里需要做的只是将工作者在工作者列表中的索引视为分配给工作者的分区号!
一旦JobDetails
负载由主进程广播并由工作者接收,每个工作者将创建自己的本地作业协调器并开始执行作业,就像我们在上一节中看到的那样。
由于主进程正在处理多个工作流,我们需要为每个工作流启动一个 goroutine 来处理来自每个工作者的传入负载。为了确保在RunJob
返回之前所有 goroutine 都正确退出,我们将使用sync.WaitGroup
:
var wg sync.WaitGroup
wg.Add(len(c.cfg.workers))
graph := executor.Graph()
for workerIndex, worker := range c.cfg.workers {
go func(workerIndex int, worker *remoteWorkerStream) {
defer wg.Done()
c.handleWorkerPayloads(workerIndex, worker, graph)
}(workerIndex, worker)
}
当我们的 goroutine 忙于处理传入的负载时,主进程执行图状态机的各个阶段:
if err = c.runJobToCompletion(executor); err != nil {
c.cfg.jobRunner.AbortJob(c.cfg.jobDetails)
if xerrors.Is(err, context.Canceled) {
err = errJobAborted
}
}
c.cancelJobCtx()
wg.Wait() // wait for any spawned goroutines to exit before returning.
return err
}
一旦作业执行完成(无论是否有错误),作业上下文将被取消以发送停止信号给任何仍在运行的负载处理 goroutine。然后RunJob
方法会阻塞,直到所有 goroutine 退出,然后返回。
通过图状态机的阶段进行转换
主作业协调器的runJobToCompletion
实现几乎与工作者使用的相同:
func (c *masterJobCoordinator) runJobToCompletion(executor *bspgraph.Executor) error {
if err := executor.RunToCompletion(c.jobCtx); err != nil {
return err
} else if _, err := c.barrier.WaitForWorkers(proto.Step_EXECUTED_GRAPH); err != nil {
return err
} else if err := c.barrier.NotifyWorkers(&proto.Step{Type: proto.Step_EXECUTED_GRAPH}); err != nil {
return err
} else if err := c.cfg.jobRunner.CompleteJob(c.cfg.jobDetails); err != nil {
return err
} else if _, err := c.barrier.WaitForWorkers(proto.Step_PESISTED_RESULTS); err != nil {
return err
} else if err := c.barrier.NotifyWorkers(&proto.Step{Type: proto.Step_PESISTED_RESULTS}); err != nil {
return err
} else if _, err := c.barrier.WaitForWorkers(proto.Step_COMPLETED_JOB); err != nil {
return err
}
return nil
}
再次,用户定义的算法会一直执行,直到满足终止条件。假设没有发生错误,主进程只需等待所有工作者过渡到图执行状态机的剩余步骤(EXECUTED_GRAPH
、PERSISTED_RESULTS
和COMPLETED_JOB
)。
注意,在前面的实现中,主进程没有在COMPLETED_JOB
步骤的屏障上调用NotifyWorkers
。这是故意的;一旦所有工作者达到这个阶段,就没有进一步的操作需要执行。我们可以简单地继续关闭每个工作者的作业流。
处理传入的工作者负载
handleWorkerPayloads
方法负责处理来自特定工作负载的进入负载。该方法阻塞,等待出现新的进入负载或作业上下文被取消:
func (c *masterJobCoordinator) handleWorkerPayloads(workerIndex int, worker *remoteWorkerStream, graph *bspgraph.Graph) {
var wPayload *proto.WorkerPayload
for {
select {
case wPayload = <-worker.RecvFromWorkerChan():
case <-c.jobCtx.Done():
return
}
if relayMsg := wPayload.GetRelayMessage(); relayMsg != nil {
c.relayMessageToWorker(workerIndex, relayMsg)
} else if stepMsg := wPayload.GetStep(); stepMsg != nil {
updatedStep, err := c.barrier.Wait(stepMsg)
if err != nil {
c.cancelJobCtx()
return
}
c.sendToWorker(worker, &proto.MasterPayload{
Payload: &proto.MasterPayload_Step{Step: updatedStep},
})
}
}
}
进入的负载包含消息中继请求或 Step
消息,工作负载通过发送请求加入特定类型的屏障来发送这些消息。
在后一种情况下,工作负载的 Step
消息被传递给主屏障的 Wait
方法。正如我们在前面的部分中解释的,Wait
方法会阻塞,直到主调用 NotifyWorkers
方法并传递其自己的 Step
消息。
一旦发生这种情况,新的步骤消息被封装在 MasterPayload
中,并通过流传输给工作负载。
在工作负载之间中继消息
为了主能够在中继工作负载之间传递消息,它需要能够 高效地 回答以下问题:“给定一个目标 ID,它属于哪个分区?”
这听起来确实像 Range
类型应该能够回答的查询!为了唤起你的记忆,这是 [第十章,构建、打包和部署软件 中 Range
类型定义的样子:
type Range struct {
start uuid.UUID
rangeSplits []uuid.UUID
}
start
字段跟踪范围的起始 UUID,而 rangeSplits[p]
跟踪第 p 个分区的 结束 UUID 值。因此,分区 p 的 UUID 范围可以按以下方式计算:
在我们检查 UUID 到分区号查询的实际实现之前,尝试作为一个简单的思维练习来思考回答这种查询的算法(不要偷看!)。
实现这一目标的一种方法是通过迭代 rangeSplits
切片来定位包含指定 ID 的范围。虽然这种原始方法会得到正确答案,但不幸的是,在可能有数百个工作负载相互交换消息的场景中,它将无法扩展。
我们能做得更好吗?答案是肯定的。我们可以利用观察到的 rangeSplits
字段中的值是按顺序存储的,并使用 Go sort
包中的 Search
函数来执行二分查找。
这里是这种类型查询的一个更高效的实现:
func (r *Range) PartitionForID(id uuid.UUID) (int, error) {
partIndex := sort.Search(len(r.rangeSplits), func(n int) bool {
return bytes.Compare(id[:], r.rangeSplits[n][:]) < 0
})
if bytes.Compare(id[:], r.start[:]) < 0 || partIndex >= len(r.rangeSplits) {
return -1, xerrors.Errorf("unable to detect partition for ID %q", id)
}
return partIndex, nil
}
sort.Search
函数在切片上执行二分查找,并返回用户定义的谓词函数返回 true 的 最小 索引。我们的谓词函数检查提供的 ID 值是否严格小于当前正在扫描的分区末端的 UUID。
既然我们已经有了高效回答 UUID 到分区查询的手段,让我们看看 relayMessageToWorker
方法的实现,该方法由工作负载处理程序在消息中继请求时调用:
func (c *masterJobCoordinator) relayMessageToWorker(srcWorkerIndex int, relayMsg *proto.RelayMessage) {
dstUUID, err := uuid.Parse(relayMsg.Destination)
if err != nil {
c.cancelJobCtx()
return
}
partIndex, err := c.partRange.PartitionForID(dstUUID)
if err != nil || partIndex == srcWorkerIndex {
c.cancelJobCtx()
return
}
c.sendToWorker(c.cfg.workers[partIndex], &proto.MasterPayload{
Payload: &proto.MasterPayload_RelayMessage{RelayMessage: relayMsg},
})
}
我们需要做的第一件事是解析目标 ID 并确保它确实包含一个有效的 UUID 值。
然后,我们调用 PartitionForID
辅助函数来查找目标 ID 所属的分区索引,并将消息转发给分配给该分区的工作者。
如果最初要求我们转发的消息的工作者也是我们需要转发消息的工作者,会怎样?在这种情况下,我们将目标 ID 视为无效,并带有错误终止作业。这个决定的理由是,如果本地图知道那个特定的目标,它将简单地在本地上将消息排队以供发送,而不是尝试通过主节点转发它。
定义用于与主节点和工作节点一起工作的包级 API
到目前为止,我们已经实现了运行主节点和服务器节点所需的所有内部组件。我们现在需要定义必要的 API,以便最终用户能够创建和操作新的工作者和主实例。
实例化和操作工作者节点
要创建一个新的工作者,包的用户调用 NewWorker
构造函数,该函数返回一个新的 Worker
实例。Worker
类型的定义如下所示:
type Worker struct {
masterConn *grpc.ClientConn
masterCli proto.JobQueueClient
cfg WorkerConfig
}
Worker
类型存储以下内容:
-
客户端 gRPC 连接到主节点
-
由 protoc 编译器自动为我们从作业队列的 RPC 定义生成的
JobQueueClient
实例 -
与用户基于 bspgraph 的算法实现(即,用于图消息和聚合值的作业
Runner
和Serializer
)交互所需的组件
在获取一个新的 Worker
实例后,用户必须通过调用工作者的 Dial
方法来连接到主节点:
func (w *Worker) Dial(masterEndpoint string, dialTimeout time.Duration) error {
var dialCtx context.Context
if dialTimeout != 0 {
var cancelFn func()
dialCtx, cancelFn = context.WithTimeout(context.Background(), dialTimeout)
defer cancelFn()
}
conn, err := grpc.DialContext(dialCtx, masterEndpoint, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
return xerrors.Errorf("unable to dial master: %w", err)
}
w.masterConn = conn
w.masterCli = proto.NewJobQueueClient(conn)
return nil
}
一旦成功建立与主节点的连接,用户可以通过调用工作者的 RunJob
方法请求工作者从主节点获取并执行下一个作业。让我们看看这个方法内部发生了什么:
stream, err := w.masterCli.JobStream(ctx)
if err != nil {
return err
}
w.cfg.Logger.Info("waiting for next job")
jobDetails, err := w.waitForJob(stream)
if err != nil {
return err
}
首先,工作者向作业队列发起 RPC 调用,并获取一个 gRPC 流。然后,工作者调用 waitForJob
辅助函数,在流上执行阻塞的 Recv
操作,并等待主节点发布作业详情有效负载。在获取有效负载后,其内容得到验证并解包到 job.Details
实例中,该实例被返回给 RunJob
方法:
masterStream := newRemoteMasterStream(stream)
jobLogger := w.cfg.Logger.WithField("job_id", jobDetails.JobID)
coordinator := newWorkerJobCoordinator(ctx, workerJobCoordinatorConfig{
jobDetails: jobDetails,
masterStream: masterStream,
jobRunner: w.cfg.JobRunner,
serializer: w.cfg.Serializer,
logger: jobLogger,
})
接下来,工作者初始化执行作业所需的所有组件。正如您在前面的代码中所看到的,我们为流创建了一个包装器,并将其作为参数传递给作业协调器构造函数。
现在我们已经准备好将作业执行委托给协调器!然而,在我们这样做之前,我们还需要做最后一件事,那就是我们需要启动一个专门的 goroutine 来处理包装流的发送和接收端:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
if err := masterStream.HandleSendRecv(); err != nil {
coordinator.cancelJobCtx()
}
}()
最后,我们调用协调器的 RunJob
方法,并发出一条日志行,根据作业是否成功或失败:
if err = coordinator.RunJob(); err != nil {
jobLogger.WithField("err", err).Error("job execution failed")
} else {
jobLogger.Info("job completed successfully")
}
masterStream.Close()
wg.Wait()
return err
就像我们之前处理所有启动 goroutines 的代码块一样,在从RunJob
方法返回之前,我们终止了 RPC 流(但保留了客户端连接以供下一次 RPC 调用使用),并等待直到处理流的 goroutines 干净地退出。
让我们继续定义创建新主机实例所需的 API。
实例化和操作主节点
正如你所猜测的,Master
类型将封装创建和操作主节点实现细节。让我们快速看一下其构造函数:
func NewMaster(cfg MasterConfig) (*Master, error) {
if err := cfg.Validate(); err != nil {
return nil, xerrors.Errorf("master config validation failed: %w", err)
}
return &Master{
cfg: cfg,
workerPool: newWorkerPool(),
}, nil
}
构造函数期望一个MasterConfig
对象作为参数,该对象定义了以下内容:
-
它定义了主节点将监听传入连接的地址。
-
它定义了用于与用户定义的图算法接口的
job.Runner
实例。 -
它定义了用于序列化和反序列化聚合值的
Serializer
。请注意,与工人实现不同,主机只在工人之间中继消息,永远不需要查看实际消息内容。因此,主机需要一个更简单的序列化实现。
除了分配一个新的Master
对象外,构造函数还创建并附加了一个工作池。我们在这章中并没有真正提到工作池的概念,所以现在你可能想知道它的用途。
工作池作为连接的工人等待区,直到最终用户要求主机开始执行新作业。新工人可以在任何时候连接到(或从)主机。按照设计,工人不允许加入正在执行的作业。相反,他们总是被添加到池中,在那里他们等待下一次作业运行。
当最终用户从主机请求新的作业执行时,从池中提取所需的工人数量,并将作业详情广播给他们。
工作池的实现包含相当多的样板代码,为了简洁起见,这里省略了。然而,如果你对深入了解感兴趣,可以通过检查本书 GitHub 仓库中Chapter12/dbspgraph
包中的worker_pool.go
文件来探索其源代码。
处理传入的 gRPC 连接
构造函数返回一个新的配置好的Master
实例,但它不会自动启动主机的 gRPC 服务器。相反,这项任务留给了最终用户,用户必须手动调用主机的Start
方法:
func (m *Master) Start() error {
var err error
if m.srvListener, err = net.Listen("tcp", m.cfg.ListenAddress); err != nil {
return xerrors.Errorf("cannot start server: %w", err)
}
gSrv := grpc.NewServer()
proto.RegisterJobQueueServer(gSrv, &masterRPCHandler{
workerPool: m.workerPool,
logger: m.cfg.Logger,
})
m.cfg.Logger.WithField("addr", m.srvListener.Addr().String()).Info("listening for worker connections")
go func(l net.Listener) { _ = gSrv.Serve(l) }(m.srvListener)
return nil
}
在 Go 中启动 gRPC 服务器时,惯例是首先创建一个新的net.Listener
实例,然后创建 gRPC 服务器实例,并在我们刚刚创建的监听器上提供服务。当然,在调用服务器上的Serve
方法之前,我们需要注册一个处理传入 RPC 的处理器,该处理器遵循 protoc 为我们生成的接口。
为了避免将 RPC 方法签名污染Master
类型的公共 API,我们采用了一个小技巧——我们定义了一个未导出的适配器,该适配器实现了所需接口,并将其注册到我们的 gRPC 服务器上。
JobStream
RPC 处理器的实现只有几行代码:
func (h *masterRPCHandler) JobStream(stream proto.JobQueue_JobStreamServer) error {
extraFields := make(logrus.Fields)
if peerDetails, ok := peer.FromContext(stream.Context()); ok {
extraFields["peer_addr"] = peerDetails.Addr.String()
}
h.logger.WithFields(extraFields).Info("worker connected")
workerStream := newRemoteWorkerStream(stream)
h.workerPool.AddWorker(workerStream)
return workerStream.HandleSendRecv()
}
为了使调试更容易,RPC 处理器将检查它是否可以访问连接的工作流的任何相关 peer 信息,并将它们包含在日志消息中。接下来,传入的流被包装在remoteWorkerStream
中并添加到池中,它将等待直到有新的作业准备运行。
处理流式 RPC 的 gRPC 语义规定,一旦 RPC 处理器返回,流将自动关闭。因此,我们希望我们的 RPC 处理器阻塞,直到作业完成或发生错误。实现这一点的简单方法是同步调用包装流的HandleSendRecv
方法。
运行新的作业
在最终用户启动主 gRPC 服务器后,他们可以通过调用主服务器的RunJob
方法来请求新的作业执行,该方法的签名如下:
func (m *Master) RunJob(ctx context.Context, minWorkers int, workerAcquireTimeout time.Duration) error {
// implementation omitted
}
由于工作流需求通常取决于要执行的算法,最终用户必须提前指定作业所需的最低工作流数量以及获取所需工作流的超时时间。
如果从用户的角度来看工作流数量不重要,他们可以为minWorkers
参数指定零值。这样做作为对主服务器的提示,要么选择池中当前所有可用的工人,要么阻塞直到满足以下条件之一:
-
至少有一个工作流加入池中。
-
指定的获取超时(如果非零)到期。
让我们将RunJob
方法分解成几个部分,从从池中获取所需工作流的代码开始:
var acquireCtx = ctx
if workerAcquireTimeout != 0 {
var cancelFn func()
acquireCtx, cancelFn = context.WithTimeout(ctx, workerAcquireTimeout)
defer cancelFn()
}
workers, err := m.workerPool.ReserveWorkers(acquireCtx, minWorkers)
if err != nil {
return ErrUnableToReserveWorkers
}
如果指定了workerAcquireTimeout
,前面的代码片段将自动将外部提供的上下文包装在一个在指定超时后过期的上下文中,并将其传递给池的ReserveWorkers
方法。
在手头有所需数量的工作流后,下一步包括为作业分配一个 UUID 以及创建一个新的job.Details
实例,该实例具有覆盖整个 UUID 空间的分区分配:
jobID := uuid.New().String()
createdAt := time.Now().UTC().Truncate(time.Millisecond)
jobDetails := job.Details{
JobID: jobID,
CreatedAt: createdAt,
PartitionFromID: minUUID, // 00000000-00000000-00000000-00000000
PartitionToID: maxUUID, // ffffffff-ffffffff-ffffffff-ffffffff
}
在开始执行作业之前,我们需要创建一个新的作业协调器实例:
coordinator, err := newMasterJobCoordinator(ctx, masterJobCoordinatorConfig{
jobDetails: jobDetails,
workers: workers,
jobRunner: m.cfg.JobRunner,
serializer: m.cfg.Serializer,
logger: logger,
})
if err != nil {
err = xerrors.Errorf("unable to create job coordinator: %w", err)
for _, w := range workers {
w.Close(err)
}
return err
}
在此初始化步骤之后,我们可以调用RunJob
方法并运行作业直到完成:
if err = coordinator.RunJob(); err != nil {
for _, w := range workers {
w.Close(err)
}
return err
}
for _, w := range workers {
w.Close(nil)
}
return nil
}
如果作业执行失败,我们将对每个工作流调用Close
方法,并将协调器RunJob
方法返回的错误传递过去。在remoteWorkerStream
上调用Close
允许 RPC 处理器的HandleSendRecv
调用返回一个错误,gRPC 会自动将此错误传播回工作流。
另一方面,如果工作没有出现任何错误,我们将使用一个nil
错误值调用Close
。这个操作具有完全相同的效果(即,终止 RPC),但在后一种情况下,不会向工作者返回任何错误。
部署 Links 'R' Us PageRank 计算器的分布式版本
PageRank 计算器是 Links 'R' Us 项目中我们尚未能够在 Kubernetes 上水平扩展的唯一组件。在第八章,“基于图的数据处理”中,我们使用bspgraph
包来实现 PageRank 算法,我承诺在接下来的几章中,我们将对 PageRank 计算器代码进行操作,无需任何代码修改,使其能够在分布式模式下运行。
完成本章后,我强烈建议作为一个有趣的学习练习,查看使用dbspgraph
包构建第八章,“基于图的数据处理”中的图着色或最短路径算法的分布式版本。
在本节中,我们将利用本章迄今为止所做的一切工作来实现这一目标!我想指出的是,尽管本节将专门关注 PageRank 计算器服务,但我们在这里讨论的所有内容也可以应用于我们在第八章,“基于图的数据处理”中实现的任何其他图算法。
将主节点和工作节点的能力集成到 PageRank 计算器服务中
从逻辑上讲,我们不想从头开始实现一个新的 PageRank 服务,尤其是考虑到我们已经在上一章中创建了一个独立(尽管不是分布式)的该服务版本。
我们实际上要做的就是复制第十一章,“将单体拆分为微服务”中的独立 PageRank 计算器服务,并使其适应使用本章中dbspgraph
包公开的 API。由于我们的副本将与原始服务共享大部分代码,我们将省略所有共享的实现细节,仅突出需要更改的部分。一如既往,如果您想更仔细地查看,服务的完整源代码可在本书 GitHub 仓库的Chapter12/linksrus/pagerank
包中找到。
在我们继续之前,我们需要决定是否为主节点和工作节点创建单独的二进制文件。考虑到主节点和工作节点之间有相当大的代码共享部分,我们可能最好生成一个单一的二进制文件,并引入一个命令行标志(我们将称之为mode
)来选择主节点或工作节点模式。
根据所选模式,服务将执行以下操作:
-
当处于 worker 模式时:它创建一个
dbspgraph.Worker
对象,调用其Dial
方法,并最终调用RunJob
方法等待主节点发布新的作业。 -
当处于 master 模式时:它创建一个
dbspgraph.Master
对象,调用其Start
方法,并定期调用RunJob
方法以触发 PageRank 分数刷新作业。
序列化 PageRank 消息和聚合值
创建新的 dbspgraph.Master
实例或 dbspgraph.Worker
实例的前提是提供适合的、特定于应用程序的序列化器,用于聚合值和任何可能在不同图节点之间交换的消息。对于这个特定的应用程序,图顶点通过交换 IncomingScore
消息将它们累积的 PageRank 分数分配给它们的邻居:
type IncomingScoreMessage struct {
Score float64
}
此外,正如以下代码片段所示,该片段来自 PageRank 计算器实现,我们的序列化器实现还需要能够正确处理计算器聚合实例使用的 int
和 float64
:
// need to run the PageRank calculation algorithm.
func (c *Calculator) registerAggregators() {
c.g.RegisterAggregator("page_count", new(aggregator.IntAccumulator))
c.g.RegisterAggregator("residual_0", new(aggregator.Float64Accumulator))
c.g.RegisterAggregator("residual_1", new(aggregator.Float64Accumulator))
c.g.RegisterAggregator("SAD", new(aggregator.Float64Accumulator))
}
控制主节点和工作者节点使用的序列化器的最大好处是我们能够为特定的用例选择合适的序列化格式。在正常情况下,协议缓冲区将是最佳选择。
然而,鉴于我们实际上只需要支持 int
和 float64
类型的序列化,使用协议缓冲区可能有些过度。相反,我们将实现一个更简单的序列化协议。
首先,让我们看看 Serialize
方法的实现:
func (serializer) Serialize(v interface{}) (*any.Any, error) {
scratchBuf := make([]byte, binary.MaxVarintLen64)
switch val := v.(type) {
case int:
nBytes := binary.PutVarint(scratchBuf, int64(val))
return &any.Any{TypeUrl: "i", Value: scratchBuf[:nBytes]}, nil
case float64:
nBytes := binary.PutUvarint(scratchBuf, math.Float64bits(val))
return &any.Any{TypeUrl: "f", Value: scratchBuf[:nBytes]}, nil
case pr.IncomingScoreMessage:
nBytes := binary.PutUvarint(scratchBuf, math.Float64bits(val.Score))
return &any.Any{TypeUrl: "m", Value: scratchBuf[:nBytes]}, nil
default:
return nil, xerrors.Errorf("serialize: unknown type %#+T", val)
}
}
上述实现使用类型切换来检测传递给 Serialize
方法的参数的类型。该方法将 TypeUrl
字段设置为单个字符值,该值对应于编码值的类型:
-
"i"
:这指定了一个整数值 -
"f"
:这指定了一个float64
值 -
"m"
:这指定了来自IncomingScoreMessage
的float64
值
值被编码为变长整数,这是通过 Go 标准库中 binary
包提供的 PutVarint
和 PutUvarint
函数实现的。
注意,浮点值不能直接编码到 Varint
中;我们必须首先通过 math.Float64bits
将其转换为等效的 uint64
表示。编码的值存储在字节数组缓冲区中,并将其作为有效载荷附加到返回给调用者的 any.Any
消息中。
Unserialize
方法,其实现如下,简单地反转了编码步骤:
func (serializer) Unserialize(v *any.Any) (interface{}, error) {
switch v.TypeUrl {
case "i":
val, _ := binary.Varint(v.Value)
return int(val), nil
case "f":
val, _ := binary.Uvarint(v.Value)
return math.Float64frombits(val), nil
case "m":
val, _ := binary.Uvarint(v.Value)
return pr.IncomingScoreMessage{
Score: math.Float64frombits(val),
}, nil
default:
return nil, xerrors.Errorf("unserialize: unknown type %q", v.TypeUrl)
}
}
为了反序列化 any.Any
消息中包含的值,我们检查 TypeUrl
字段的值,并根据编码数据的类型,使用 Varint
或 Uvarint
方法解码其变长整数表示。
对于浮点值,我们使用math.Float64frombits
辅助函数将解码后的无符号Varint
表示的浮点数转换回float64
值。最后,如果any.Any
值编码了IncomingScoreMessage
,我们创建并返回一个新的消息实例,该实例包含我们刚刚解码的浮点分数值。
定义主节点和工作节点的工作运行器
完成“链接'R'我们”页面排名计算服务的分布式版本的一步是提供一个job.Runner
实现,这将允许dbspgraph
包与包括我们想要执行的基于图的算法在内的页面排名计算组件进行接口交互。
作为提醒,这是我们需要的实现接口:
type Runner interface {
StartJob(Details, bspgraph.ExecutorFactory) (*bspgraph.Executor, error)
CompleteJob(Details) error
AbortJob(Details)
}
主节点和工作节点的粘合逻辑有不同的要求。例如,主节点除了处理工作者发送的聚合器增量之外,不会执行任何与图相关的计算。
因此,主节点不需要将任何图数据加载到内存中。另一方面,工作者不仅需要加载图数据的一个子集,而且在工作执行完成后,他们还需要持久化计算结果。
因此,我们需要提供两个job.Runner
实现——一个用于主节点,一个用于工作者。
实现主节点的工作运行器
让我们先来检查一下主节点相对简单的StartJob
方法实现:
func (n *MasterNode) StartJob(_ job.Details, execFactory bspgraph.ExecutorFactory) (*bspgraph.Executor, error) {
if err := n.calculator.Graph().Reset(); err != nil {
return nil, err
}
n.jobStartedAt = n.cfg.Clock.Now()
n.calculator.SetExecutorFactory(execFactory)
return n.calculator.Executor(), nil
}
StartJob
方法记录了工作开始的时间,并执行以下三个任务:
-
它重置了图的内部状态。这很重要,因为计算器组件实例在后续的工作运行中会被重复使用。
-
它覆盖了计算器组件的执行器工厂,使用
dbspgraph
包提供的版本。 -
它调用计算器的
Executor
方法,该方法使用安装的工厂创建并返回一个新的bspgraph.Executor
实例。
接下来,我们将检查AbortJob
和CompleteJob
方法的实现:
func (n *MasterNode) AbortJob(_ job.Details) {}
func (n *MasterNode) CompleteJob(_ job.Details) error {
n.cfg.Logger.WithFields(logrus.Fields{
"total_link_count": n.calculator.Graph().Aggregator("page_count").Get(),
"total_pass_time": n.cfg.Clock.Now().Sub(n.jobStartedAt).String(),
}).Info("completed PageRank update pass")
return nil
}
关于AbortJob
方法,当工作失败时,我们实际上不需要做任何特别的事情。因此,我们只是为它提供了一个空的存根。
CompleteJob
方法所做的只是记录工作的运行时间和总处理的页面链接数。正如你可能注意到的,后者的值是通过直接查询全局page_count
聚合器的值获得的,该聚合器是在计算器组件设置其内部状态时注册的。
工作者工作运行器
工作者StartJob
的实现稍微复杂一些,因为我们需要加载由主节点分配给我们的 UUID 范围对应的顶点和边。幸运的是,我们已经在第十一章,“将单体拆分为微服务”中编写了所有必需的代码片段,因此我们可以直接使用适当的参数调用加载函数:
func (n *WorkerNode) StartJob(jobDetails job.Details, execFactory bspgraph.ExecutorFactory) (*bspgraph.Executor, error) {
n.jobStartedAt = time.Now()
if err := n.calculator.Graph().Reset(); err != nil {
return nil, err
} else if err := n.loadLinks(jobDetails.PartitionFromID, jobDetails.PartitionToID, jobDetails.CreatedAt); err != nil {
return nil, err
} else if err := n.loadEdges(jobDetails.PartitionFromID, jobDetails.PartitionToID, jobDetails.CreatedAt); err != nil {
return nil, err
}
n.graphPopulateTime = time.Since(n.jobStartedAt)
n.scoreCalculationStartedAt = time.Now()
n.calculator.SetExecutorFactory(execFactory)
return n.calculator.Executor(), nil
}
CompleteJob
方法包含将我们刚刚计算的全新 PageRank 分数更新到 Links 'R' Us 文档索引所需的逻辑。让我们看看它的实现:
func (n *WorkerNode) CompleteJob(_ job.Details) error {
scoreCalculationTime := time.Since(n.scoreCalculationStartedAt)
tick := time.Now()
if err := n.calculator.Scores(n.persistScore); err != nil {
return err
}
scorePersistTime := time.Since(tick)
n.cfg.Logger.WithFields(logrus.Fields{
"local_link_count": len(n.calculator.Graph().Vertices()),
"total_link_count": n.calculator.Graph().Aggregator("page_count").Get(),
"graph_populate_time": n.graphPopulateTime.String(),
"score_calculation_time": scoreCalculationTime.String(),
"score_persist_time": scorePersistTime.String(),
"total_pass_time": time.Since(n.jobStartedAt).String(),
}).Info("completed PageRank update pass")
return nil
}
上述用于持久化计算结果的代码块应该对你来说很熟悉,因为它是从第十一章 将单体拆分为微服务直接复制过来的。Scores
便利方法遍历图顶点,并使用顶点 ID 和 PageRank 分数作为参数调用 persistScore
回调。
persistScore
回调(如下所示)是一个简单的包装器,用于将顶点 ID 映射到 UUID 值,并调用 Links 'R' Us 文档索引组件的 UpdateScore
方法:
func (n *WorkerNode) persistScore(vertexID string, score float64) error {
linkID, err := uuid.Parse(vertexID)
if err != nil {
return err
}
return n.cfg.IndexAPI.UpdateScore(linkID, score)
}
与主作业运行器实现类似,工作器的 AbortJob
方法也是一个空的存根。为了使我们的实现尽可能精简,我们不会在本地工作器完成作业后,如果其他工作器失败,回滚任何已持久化的分数更改。由于 PageRank 分数是定期重新计算的,我们预计它们最终会是一致的。
将最终的 Links 'R' Us 版本部署到 Kubernetes
我们终于达到了 Links 'R' Us 项目的最终目标——我们构建了一个功能齐全、基于微服务的系统,其中 所有 组件都可以部署到 Kubernetes 并单独进行扩展或缩减。
我们最后需要做的是更新我们的 Kubernetes 清单,以便我们可以部署 PageRank 计算器的分布式版本,而不是第十一章 将单体拆分为微服务中的单容器版本。
为了这个目的,我们将创建两个独立的 Kubernetes Deployment
资源。第一个部署提供一个 单个 容器,在主节点上执行 PageRank 服务,而第二个部署将提供 多个 容器,以工作模式执行该服务。为了帮助工作器发现主节点,我们将主节点放在 Kubernetes 服务后面,并将工作器指向该服务的 DNS 条目。
应用所提出的更改后,我们的 Kubernetes 集群将如下所示:
图 4:完全分布式 Links 'R' Us 版本的组件
你可以通过查看本书的 GitHub 仓库并检查 Chapter12/k8s
文件夹的内容来查看 Links 'R' Us 最终版本的完整 Kubernetes 清单。
如果你还没有设置一个 Minikube 集群并将其私有仓库列入白名单,你可以稍作休息,手动按照第十章 构建、打包和部署软件的步骤逐一操作,或者简单地运行 make bootstrap-minikube
,这将为你处理一切。
另一方面,如果您已经部署了之前章节中的任何 Links 'R' Us 版本(无论是单体还是微服务版本),在继续之前请确保运行kubectl delete namespace linksrus
。通过删除linksrus
命名空间,Kubernetes 将移除 Links 'R' Us 的所有 Pod、服务和入口,但保留数据存储(它们位于linksrus-data
命名空间中)。
要部署 Links 'R' Us 的所有必需组件,您需要构建并推送一些 Docker 镜像。为了节省您的时间,Chapter12/k8s
文件夹中的 Makefile 提供了两个方便的构建目标,以便您尽可能快地开始运行:
-
make dockerize-and-push
:这将构建所有必需的 Docker 镜像并将它们推送到 Minikube 的私有仓库。 -
make deploy
:这将确保所有所需的数据存储已配置,并一次性应用所有部署 Links 'R' Us 最终、基于微服务的版本的清单。
是时候给自己鼓掌了!我们刚刚完成了 Links 'R' Us 项目的最终版本的开发。在花几分钟时间思考我们已经取得的成就之后,将您的浏览器指向前端索引页面,享受乐趣吧!
摘要
在这个相当长的章节中,我们深入研究了创建一个分布式图处理系统的所有方面,该系统能够将使用第八章中bspgraph
包创建的任何基于图的算法自动分布到一组工作节点。
此外,作为本章所学知识的实际应用,我们修改了上一章中的 Links 'R' Us PageRank 计算服务,使其现在可以以分布式模式运行。通过这样做,我们实现了本书的主要目标——构建和部署一个复杂的 Go 项目,其中每个组件都可以独立地进行水平扩展。
下一章和最后一章将重点关注我们刚刚构建的系统的可靠性方面。我们将探讨收集、聚合和可视化指标的方法,这些指标将帮助我们监控 Links 'R' Us 项目的健康和性能。
问题
-
描述领导者-跟随者和多主集群配置之间的区别。
-
解释如何使用检查点策略来恢复错误。
-
我们在本章构建的离核图处理系统中,分布式屏障的目的是什么?
-
假设我们提供了一个我们想要以分布式方式运行的图算法。您认为算法终止时计算作业就完成了吗?
进一步阅读
-
Consul:安全服务网络。
consul.io
-
Docker:企业级容器平台。
www.docker.com
-
Lamport, Leslie: Paxos Made Simple. 在 ACM SIGACT 新闻(分布式计算专栏)第 32 卷第 4 期(总第 121 期,2001 年 12 月)(2001 年),第 51–58 页
-
Malewicz, Grzegorz; Austern, Matthew H.; Bik, Aart J. C; Dehnert, James C.; Horn, Ilan; Leiser, Naty; Czajkowski, Grzegorz: Pregel: 一个用于大规模图处理系统。在 2010 年 ACM SIGMOD 国际数据管理会议论文集,SIGMOD '10。纽约,纽约,美国:ACM,2010 — ISBN 978-1-4503-0032-2,第 135–146 页
-
Ongaro, Diego; Ousterhout, John: 寻找一个可理解的共识算法。在 2014 年 USENIX 年度技术会议论文集,USENIX ATC'14。加利福尼亚州伯克利,美国:USENIX 协会,2014 — ISBN 978-1-931971-10-2,第 305–320 页
第十三章:指标收集和可视化
"衡量的是改进的。"
- 彼得·德鲁克
在前面的章节中,我们将我们的初始单体应用程序转换为一组微服务,这些服务现在正在我们的 Kubernetes 集群内部运行。这种范式转变向我们的项目需求列表中引入了一个新项目:作为系统操作员,我们必须能够监控每个单独服务的健康状况,并在出现问题时收到通知。
我们将本章从比较流行的指标捕获和聚合系统的优缺点开始。然后我们将关注 Prometheus,这是一个完全用 Go 编写的流行指标收集系统。我们将探讨为我们的代码添加仪表的方法,以促进指标的高效收集和导出。在本章的最后部分,我们将研究使用 Grafana 可视化我们的指标以及使用 Alertmanager 处理、分组、去重并将传入警报路由到一组通知系统集成。
本章将涵盖以下主题:
-
解释 SRE 术语(如 SLIs、SLOs 和 SLAs)之间的区别
-
指标收集基于推送和拉取的系统比较以及每种方法的优缺点分析
-
设置 Prometheus 并学习如何为收集和导出指标仪表化您的 Go 应用程序
-
运行 Grafana 作为我们的指标的可视化前端
-
使用 Prometheus 生态系统工具来定义和处理警报
技术要求
本章将要讨论的主题的完整代码已发布在本书的 GitHub 仓库中的 Chapter13
文件夹下。
您可以通过将您的网络浏览器指向以下 URL 来访问本书的 GitHub 仓库,该仓库包含本书各章节的所有代码和所需资源:github.com/PacktPublishing/Hands-On-Software-Engineering-with-Golang
。
为了让您尽快开始,每个示例项目都包含一个 Makefile,它定义了以下目标集:
Makefile 目标 | 描述 |
---|---|
deps |
安装所需的任何依赖项 |
test |
运行所有测试并报告覆盖率 |
lint |
检查 lint 错误 |
与本书中的其他章节一样,您需要一个相当新的 Go 版本,您可以从 golang.org/dl
. 下载。
要运行本章中的一些代码,您需要在您的机器上安装一个可工作的 Docker ^([3]) 安装。
从站点可靠性工程师的角度进行监控
正如我们在第一章“软件工程鸟瞰”中看到的,监控软件系统的状态和性能是与站点可靠性工程师(SRE)角色相关联的关键职责之一。在我们深入探讨监控和警报的主题之前,我们可能需要花几分钟时间澄清以下章节中将要使用的某些 SRE 相关术语。
服务级别指标(SLIs)
SLI 是一种指标类型,允许我们从最终用户的角度量化服务的感知质量。让我们看看一些可以应用于基于云服务的常见 SLI 类型:
-
可用性定义为两个数量的比率:服务可供最终用户/客户使用的时间和服务的总部署时间(包括任何停机时间)。例如,如果我们运营的服务在过去一年中因维护而离线约53分钟,我们可以说该服务在同一期间具有99.99%的可用性。
-
吞吐量定义为在给定时间段内服务处理请求数量(例如,每秒请求数)。
-
延迟是另一个有趣的 SLI,定义为服务器处理传入请求并返回响应给客户端所需的时间。
服务级别目标(SLOs)
在第五章“链接之用”项目中,我们首次介绍了链接之用项目,我们简要讨论了服务级别目标(SLOs)的概念,并为我们将要工作的系统提供了一些示例 SLOs。
SLO 被定义为 SLI 的值范围,它允许我们向最终用户或客户提供特定水平的服务。
根据基础服务级别指标(SLI),SLOs 可以是下限(SLI >= 目标),上限(SLI <= 目标),或者两者(下限 <= SLI >= 上限)。
SLO 定义通常包括三个部分:我们正在测量的东西的描述(SLI)、以百分比表示的预期服务级别,以及测量发生的期间。让我们快速看一下一些 SLO 示例:
-
当在单月期间测量时,系统的正常运行时间必须至少为 99%。
-
当在一年期间内测量时,对 X 的 95%服务请求的响应时间不得超过 100 毫秒。
-
当在一天期间测量时,数据库的 CPU 利用率必须在[40%,70%]范围内。
服务级别协议(SLAs)
SLA 是服务提供商与一个或多个服务消费者之间的一种隐式或显式合同。SLA 概述了必须满足的一组 SLOs 以及满足和未能满足这些 SLOs 的后果。
注意,根据提供的服务类型,消费者的角色可以由外部第三方或内部公司利益相关者承担。在前一种情况下,SLA 通常会定义一个因未能达到协议中的 SLO 而应受的财务处罚清单。在后一种情况下,SLA 条款可能不那么严格,但无论如何,在编写其他下游服务的 SLA 时必须考虑这一点。
理解了这些与 SRE 相关的术语后,让我们继续讨论指标。
探索收集和汇总指标的选择
现代基于微服务的系统固有的复杂性和定制化水平,导致了专门工具的发展,以促进指标的收集和汇总。
在本节中,我们将简要讨论一些用于完成此任务的流行软件。
比较推送与拉取系统
根据启动数据收集的实体,可以将监控和指标汇总系统分为两大类:
-
在基于推送的系统中,客户端(例如,运行在节点上的应用程序或数据收集服务)负责将指标数据传输到指标汇总系统。此类系统的例子包括 StatsD ^([11])、Graphite ^([5]) 和 InfluxDB ^([6])。
-
在基于拉取的系统中,指标收集是指标汇总系统的责任。在通常被称为抓取的操作中,指标系统启动与指标生产者的连接,并检索可用的指标集。此类系统的例子包括 Nagios ^([7]) 和 Prometheus ^([10])。在下一节中,我们将更详细地探讨 Prometheus。
基于推送和拉取的系统各有其优缺点。从软件工程师的角度来看,推送系统通常被认为更容易接口。上述所有推送系统实现都支持基于文本的协议来提交指标。您只需打开一个套接字(TCP 或 UDP)连接到指标收集器,并开始提交指标值。实际上,如果我们使用 StatsD 或 Graphite 并想增加一个名为requests
的计数器,我们可以仅使用标准的 Unix 命令行工具来完成,如下所示:
# Incrementing a statsd counter
echo "requests:1|c" | nc statsd.local 8125
# Incrementing a graphite counter
echo "requests 1 `date +%s`" | nc graphite.local 2003
缺乏适当的流量控制机制是推送系统相关的一个注意事项。如果指标生产的速率突然激增,超出了收集器处理、汇总和/或索引传入指标的能力,那么收集器最终可能变得不可用,或者以严重的延迟响应查询。
另一方面,在基于拉取的系统中,指标的摄取速率由收集器控制。收集器可以通过调整其抓取速率来补偿,从而对指标生产速率的突然激增做出反应。
拉取式系统通常被认为比基于推送的系统更具可扩展性。
关于如何将 Prometheus 这样的系统扩展以支持抓取大量节点的一些轶事证据,我强烈建议查看 Mathew Campbell 关于 DigitalOcean 如何收集大规模指标的某些策略的引人入胜的演讲^([1])。
当然,拉取式系统也有其自身的缺点。首先,在拉取式系统中,收集器需要提供要抓取的端点列表!这意味着需要操作员手动配置这些端点,或者暗示存在某种发现机制来自动化此过程。
其次,这种模型假设收集器可以始终与各种端点建立连接。然而,这并不总是可能的!考虑这样一个场景,我们想要抓取部署到私有子网的服务。那个特定的子网几乎被完全锁定,不允许来自收集器部署的子网的入站流量。在这种情况下,我们唯一的选项就是使用基于推送的机制来获取指标(当入站流量被阻止时,出站流量通常是被允许的)。
使用 Prometheus 捕获指标
Prometheus 是一个在 SoundCloud 创建的基于拉取的指标收集系统,随后作为开源软件发布。以下插图(摘自官方 Prometheus 文档)描述了构成 Prometheus 生态系统的基本组件:
图 1:Prometheus 架构
简要描述一下前面图中展示的每个组件的作用:
-
Prometheus 服务器是 Prometheus 的核心组件。其主要职责是定期抓取配置的目标集,并将收集到的任何指标持久化到时间序列数据库中。作为次要任务,服务器评估操作员定义的警报规则列表,并在满足任何这些规则时发出警报事件。
-
Alertmanager组件接收 Prometheus 服务器发出的任何警报,并通过一个或多个通信渠道(例如,电子邮件、Slack 或第三方呼叫服务)发送通知。
-
服务发现层允许 Prometheus 通过查询外部服务(例如,Consul^([2]))或 API(例如,由 Kubernetes 等容器编排层提供的 API)来动态更新要抓取的端点列表。
-
Pushgateway 组件模拟了一个基于推送的系统,用于从无法刮取的来源收集指标。这包括 Prometheus 无法直接访问的服务(例如,由于严格的网络策略),以及短暂的批量作业。这些服务可以将它们的指标流推送到一个网关,该网关充当 Prometheus 可以像其他端点一样刮取的中间缓冲区。
-
客户端通过提交以称为 PromQL 的定制查询语言编写的查询从 Prometheus 获取数据。此类客户端的一个例子是 Grafana ^([4]),这是一个开源的查询和可视化指标的解决方案。
我们将在接下来的章节中更详细地探讨这些组件。
支持的指标类型
当涉及到像 Prometheus 这样的复杂指标收集系统时,您通常会期望支持广泛的指标类型。除非您有使用 Prometheus 的先前经验,否则您可能会惊讶地发现它只支持四种类型的指标。然而,在实践中,当这些指标类型与 PromQL 语言的表述性相结合时,这些就是我们需要来模拟任何我们可以想到的 SLI 的所有内容!以下是 Prometheus 支持的指标列表:
-
计数器:计数器是一个累积指标,其值随时间单调递增。计数器可以用来跟踪对服务的请求数量、应用程序的下载次数等。
-
仪表:仪表跟踪一个可以上升或下降的单个值。仪表的常见用例是记录服务器节点关于使用情况(例如,CPU、内存和负载)的统计数据,以及特定服务的当前连接用户总数等指标。
-
直方图:直方图对观测值进行采样,并将它们分配到预配置的桶中。同时,它跟踪所有桶中的项目总数,从而使得计算直方图内容的分位数和/或聚合成为可能。直方图可以用来回答诸如“在过去一小时中,为 90% 的请求提供服务的时间是多少?”等问题。
-
摘要:摘要与直方图类似,因为这两种指标类型都支持分桶和计算分位数。然而,摘要直接在客户端执行分位数计算,可以用作减少服务器查询负载的替代方案。
自动检测刮擦目标
当涉及到配置要刮取的端点集时,Prometheus 的灵活性表现得尤为出色。在本节中,我们将检查用于静态或动态配置 Prometheus 从中提取指标的一组端点的指示性选项列表。有关支持的发现选项的完整列表,您可以参考 Prometheus 文档 ^([8])。
静态和基于文件的刮擦目标配置
静态抓取配置被认为是向 Prometheus 提供抓取目标的标准方式。操作员在 Prometheus 配置文件中包含一个或多个静态配置块,定义要抓取的目标主机列表以及应用于抓取指标的标签集。您可以在以下代码中看到一个这样的示例块:
static_configs:
- targets:
- "host1"
- "host2"
labels:
service: "my-service"
静态配置方法的一个问题是,在更新 Prometheus 配置文件后,我们需要重新启动 Prometheus,以便它可以获取更改。
一个更好的选择是将静态配置块提取到外部文件中,然后通过file_sd_config
选项从 Prometheus 配置中引用该文件:
file_sd_configs:
- files:
- config.yaml
refresh_interval: "5m"
当启用基于文件的发现时,Prometheus 将监视指定的文件集以查找更改,并在检测到更改后自动重新加载其内容。
查询底层云提供商
默认情况下,Prometheus 可以被配置为利用云提供商(如 AWS、GCE、Azure 和 OpenStack)提供的本地 API,以检测已配置的计算节点实例,并将它们作为抓取目标提供。
Prometheus 发现的每个节点都会自动注解一系列特定提供者的元标签。然后,这些标签可以通过操作员定义的匹配规则引用,以过滤掉操作员不感兴趣的任何节点。
例如,假设我们只想抓取带有名为scrape
且值为true
的标签的 EC2 实例。我们可以使用如下配置块来实现这一点:
ec2_sd_configs:
# omitted: EC2 access keys (see prometheus documentation)
relabel_configs:
- source_labels: ["__meta_ec2_tag_scrape"]
regex: "true"
action: "keep"
当 Prometheus 发现新的 EC2 实例时,它将自动迭代其标签集,并生成名称遵循模式__meta_ec2_tag_<tagkey>
的标签,并将它们的值设置为观察到的标签值。前面的片段中的过滤规则将丢弃任何__meta_ec2_tag_scrape
标签的值不匹配提供的正则表达式的节点。
利用 Kubernetes 公开的 API
在本章中,我们将讨论的最后一种抓取目标发现方法,强烈推荐用于在 Kubernetes 之上运行的工作负载,例如“链接‘R’我们”项目。一旦启用,Prometheus 将调用 Kubernetes 公开的 API 端点,以获取操作员感兴趣抓取的资源类型信息。
Prometheus 可以被配置为为以下类型的 Kubernetes 资源创建抓取目标:
资源类型 | 描述 |
---|---|
节点 | 为 Kubernetes 集群中的每个节点创建一个抓取目标,并允许我们收集可以通过运行工具(如 node-exporter ^([9])导出的机器级指标。 |
服务 | 扫描 Kubernetes 的 Service 资源并为每个暴露的端口创建一个抓取目标。Prometheus 将随后尝试通过在服务 IP 地址上对每个暴露的端口执行定期的 HTTP GET 请求来拉取由服务背后的 pod 暴露的任何指标。这种方法依赖于 Service 资源通过将每个传入请求委派给不同的 pod 来充当负载均衡器的事实,并且可能比同时从所有 pod 拉取指标有更好的性能。 |
Pod | 发现所有 Kubernetes 的 Pod 资源并为它们的每个容器创建一个抓取目标。然后 Prometheus 将并行执行定期的 HTTP GET 请求以从每个单独的容器中拉取指标。 |
入口 | 为 Ingress 资源上的每个路径创建一个目标。 |
与云感知发现实现类似,Prometheus 将使用特定于资源的元标签集注释已发现的集合目标。基于前面的示例,你能猜出以下配置块做了什么?
kubernetes_sd_configs:
# omitted: credentials and endpoints for accessing k8s (see prometheus documentation)
- role: endpoints
relabel_configs:
- source_labels: ["__meta_kubernetes_service_annotation_**prometheus_scrape**"]
action: "keep"
regex: "true"
由于我们指定了 role
等于 endpoints
,Prometheus 将获取与该服务关联的 pod 列表。如果 – 仅当 – 它们的父 服务 包含名为 prometheus_scrape
且值为 true
的注释时,Prometheus 将为每个 pod 创建一个抓取目标。这个技巧使得通过编辑 Kubernetes 清单来启用我们集群中任何服务的自动抓取变得非常简单。
量化 Go 代码
为了使 Prometheus 能够从我们的部署服务中抓取指标,我们需要执行以下步骤序列:
-
定义我们感兴趣跟踪的指标。
-
量化我们的代码库,以便在适当的位置更新上述指标值。
-
收集指标数据并使其可通过 HTTP 进行抓取。
基于微服务架构的关键好处之一是软件工程师在构建他们的服务时不再受限于使用单一编程语言。看到用 Go 编写的微服务与其他用 Rust 或 Java 编写的服务进行通信是非常常见的。尽管如此,全面监控服务的需求仍然普遍存在。
为了让软件工程师尽可能容易地与 Prometheus 集成,其作者为不同的编程语言提供了客户端库。所有这些客户端都有一个共同点:它们处理在注册和导出 Prometheus 指标中涉及的所有底层细节。
下文示例依赖于官方 Prometheus Go 客户端包。您可以通过执行以下命令进行安装:
go get -u github.com/prometheus/client_golang/prometheus/...
在 Prometheus 中注册指标
promauto
是 Prometheus 客户端的一个子包,它定义了一组方便的辅助函数,用于以尽可能少的代码创建和注册度量。promauto
包中的每个构造函数都返回一个 Prometheus 度量实例,我们可以在代码中立即使用。
让我们快速看一下注册和填充 Prometheus 支持的一些最常见度量类型是多么容易。我们将实例化的第一个度量类型是一个简单的计数器:
numReqs := promauto.NewCounter(prometheus.CounterOpts{
Name: "app_reqs_total",
Help: "The total number of incoming requests",
})
// Increment the counter.
numReqs.Inc()
// Add a value to the counter.
numReqs.Add(42)
每个 Prometheus 度量都必须分配一个唯一的名称。如果我们尝试注册具有相同名称的度量两次,我们将得到一个错误。更重要的是,在注册新度量时,我们可以可选地指定一个帮助信息,该信息提供了有关度量目的的附加信息。
如前述代码片段所示,一旦我们获得一个计数器实例,我们可以使用Inc
方法来增加其值,使用Add
方法向计数器添加任意正数值。
我们将要实例化的下一类度量是一个仪表。仪表与计数器非常相似,除了它们的值可以上升或下降。除了Inc
和Add
方法外,仪表实例还提供了Dec
和Sub
方法。以下代码块定义了一个仪表度量,用于跟踪队列中挂起的项的数量:
queueLen := promauto.NewGauge(prometheus.GaugeOpts{
Name: "app_queue_len_total",
Help: "Total number of items in the queue.",
})
// Add items to the queue
queueLen.Inc()
queueLen.Add(42)
// Remove items from the queue
queueLen.Sub(42)
queueLen.Dec()
为了总结我们对不同类型 Prometheus 度量的实验,我们将创建一个直方图度量。NewHistogram
构造函数期望调用者指定一个严格递增的float64
值列表,这些值描述了直方图中每个桶的宽度。
以下示例使用prometheus
包中的LinearBuckets
辅助函数生成20
个具有100
单位宽度的不同桶。最左侧直方图桶的下限将被设置为值0
:
reqTimes := promauto.NewHistogram(prometheus.HistogramOpts{
Name: "app_response_times",
Help: "Distribution of application response times.",
Buckets: prometheus.LinearBuckets(0, 100, 20),
})
// Record a response time of 100ms
reqTimes.Observe(100)
向直方图实例添加值非常简单。我们只需要简单地调用其Observe
方法,并将我们希望跟踪的值作为参数传递。
基于向量的度量
Prometheus 的一个更有趣的特性是它支持在维度(在 Prometheus 术语中称为labels)之间分区收集的样本。如果我们选择使用此功能,而不是有一个单个的度量实例,我们可以与一个向量的度量值一起工作。
在以下示例中,我们刚刚启动了一个针对新网站布局的 A/B 测试,并且我们感兴趣的是跟踪我们正在积极试验的每个页面布局的用户注册数量:
regCountVec := promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "app_registrations_total",
Help: "Total number of registrations by A/B test layout.",
},
[]string{"layout"},
)
regCountVec.WithLabelValues("a").Inc()
这次,我们将不再使用单个计数器,而是创建一个计数器向量,其中每个采样值都将自动标记为一个名为layout
的标签。
要增加或添加到此指标的值,我们需要通过在 regCountVec
变量上调用变长 WithLabelValues
方法来获取正确的计数器。此方法期望为每个定义的维度提供一个字符串值,并返回与提供的标签值相对应的计数器实例。
导出用于抓取的指标
在将我们的指标注册到 Prometheus 并对代码进行配置以在需要时更新它们之后,我们唯一需要做的事情就是通过 HTTP 暴露收集到的值,以便 Prometheus 可以抓取它们。
Prometheus 客户端包中的 promhttp
子包提供了一个名为 Handler
的便利辅助函数,该函数返回一个 http.Handler
实例,该实例封装了导出收集到的指标所需的所有逻辑,这些指标符合 Prometheus 期望的格式。
导出的数据不仅包括开发人员注册的指标,还包括与 Go 运行时相关的广泛指标列表。以下是一些此类指标的示例:
-
活跃的 goroutine 数量
-
关于栈和堆分配的信息
-
Go 垃圾收集器的性能统计
以下示例演示了一个最小、自包含的 hello-world 类型的应用程序,该应用程序定义了一个计数器指标并公开了两个 HTTP 路由:/ping
和 /metrics
。第一个路由的处理程序增加计数器,而后者导出收集到的 Prometheus 指标:
func main() {
// Create a prometheus counter to keep track of ping requests.
numPings := promauto.NewCounter(prometheus.CounterOpts{
Name: "pingapp_pings_total",
Help: "The total number of incoming ping requests",
})
http.Handle("/metrics", promhttp.Handler())
http.Handle("/ping", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
numPings.Inc()
w.Write([]byte("pong!\n"))
}))
log.Fatal(http.ListenAndServe(":8080", nil))
}
尝试编译并运行前面的示例。您可以在本书 GitHub 仓库的 Chapter13/prom_http
文件夹中找到其源代码。当示例运行时,切换到另一个终端并执行几个 curl localhost:8080/ping
命令来增加 pingapp_pings_total
计数器。
然后,执行一个 curl localhost:8080/metrics
命令并检查导出的指标列表。以下截图显示了执行前面命令后的最后几行输出:
图 2:我们的示例 Go 应用程序导出的一组指标
如您所见,输出不仅包括 pingapp_pings_total
计数器的当前值,还包括 Prometheus 客户端为我们自动捕获的几个其他重要指标。
使用 Grafana 可视化收集到的指标
到目前为止,您应该已经为您的应用程序选择了一个合适的指标收集解决方案,并配置了您的代码库以发出您感兴趣跟踪的指标。为了使收集到的数据有意义并对其进行分析,我们需要对其进行可视化。
对于这个任务,我们将使用 Grafana ^([4]) 作为我们的首选工具。Grafana 提供了一个方便的端到端解决方案,可以用于从各种不同的数据源检索指标并为它们构建仪表板进行可视化。支持的数据源列表包括 Prometheus、InfluxDB、Graphite、Google Stackdriver、AWS CloudWatch、Azure Monitor、SQL 数据库(MySQL、Postgres 和 SQL Server)以及 Elasticsearch。
如果你已经设置了一个前面的数据源并且想要评估 Grafana,最简单的方法是使用以下命令启动一个 Docker 容器:
docker run -d \
-p 3000:3000 \
--name=grafana \
-e "GF_SECURITY_ADMIN_USER=admin" \
-e "GF_SECURITY_ADMIN_PASSWORD=secret" \
grafana/grafana
然后,你可以将你的浏览器指向 http://localhost:3000
,使用前面的凭据登录,并遵循 Grafana 网站上提供的几个综合指南之一来配置你的第一个仪表板。
在支持的可视化小部件方面,标准的 Grafana 安装支持以下 widget 类型:
-
图形:一个灵活的可视化组件,可以绘制单系列和多系列线图或条形图。此外,图形小部件可以配置为以重叠或堆叠模式显示多个系列。
-
日志面板:一个日志条目列表,这些条目是通过兼容的数据源(例如,Elasticsearch)获取的,其内容与另一个小部件显示的信息相关联。
-
单值统计:一个通过应用聚合函数(例如,min、max、avg 等)将系列压缩为单个值的组件。此组件可以配置为显示折线图或作为仪表渲染。
-
热图:一个专门组件,用于渲染直方图值随时间的变化。如图所示,热图由一系列垂直切片组成,其中每个切片描述特定时间点的直方图值。与典型的直方图图不同,直方图的高度表示特定桶中项目的数量,而热图则使用颜色图来可视化每个垂直切片中项目的频率。
-
表格:一个最适合以表格格式渲染系列组件。
以下截图展示了内置的 Grafana 小部件,它们将如何在示例仪表板中显示:
图 3:使用 Grafana 构建的示例仪表板
除了默认的内置小部件外,操作员可以通过利用 Grafana 的插件机制安装额外的 widget 类型。此类小部件的示例包括世界地图、雷达图、饼图和气泡图。
使用 Prometheus 作为端到端警报解决方案
通过对应用程序进行监控并部署必要的指标抓取基础设施,我们现在有了评估我们每个服务 SLIs(Service Level Indicators)的手段。一旦我们为每个 SLI 定义了一套合适的 SLOs(Service Level Objectives),我们清单上的下一项就是部署一个警报系统,这样我们就可以在 SLOs 不再满足时自动收到通知。
一个典型的警报规范看起来像这样:
当指标值X超过阈值Y,并且持续Z个时间单位时,执行操作a1, a2, a[n]
当你听到火灾警报响起时,你首先想到的是什么?大多数人可能会回答类似“附近可能发生了火灾”的话。人们天生就会假设警报总是与必须立即解决的问题在时间上相关联。
当涉及到监控生产系统的健康状态时,一旦触发就需要人工操作员立即干预的警报已经几乎成为标准操作程序。然而,这并不是 SRE 在处理此类系统时可能遇到的唯一类型的警报。很多时候,能够主动检测并解决在失控并成为生产系统稳定性风险之前的问题,是平静的夜晚和可怕的凌晨 2 点被叫醒之间的唯一区别。
这里有一个主动警报的例子:SRE 设置了一个警报,一旦数据库节点的磁盘使用率超过可用存储容量的 80%,就会触发。请注意,当警报确实触发时,数据库仍在正常工作,没有任何问题。然而,在这种情况下,SRE 有足够的时间来计划和执行所需的一系列步骤(例如,安排维护窗口来调整分配给数据库的磁盘大小),以尽可能减少对数据库服务的干扰来解决问题。
将前一个案例与一个不同的情况进行对比,在这种情况下,SRE(Site Reliability Engineer)被呼叫是因为数据库确实已经没有空间了,因此,现在有多个依赖于数据库的服务已经离线。对于 SRE 来说,这是一个特别紧张的情况,因为系统已经处于停机状态。
使用 Prometheus 作为警报事件的源
为了将 Prometheus 用作警报生成源,操作员必须定义一组 Prometheus 应该监控的警报规则。警报规则定义存储在外部 YAML 文件中,这些文件通过rule_files
块被主 Prometheus 配置文件导入,如下所示:
# prometheus.yml
global:
scrape_interval: 15s
rule_files:
- 'alerts/*.yml'
Prometheus 将多个警报规则组织成组。组内的规则总是顺序评估,而每个组是并行评估的。
让我们看看一个简单的警报定义的结构。以下代码片段定义了一个名为example
的警报组,其中包含一个警报定义:
groups:
- name: example
rules:
- alert: InstanceDown
expr: up == 0
for: 5m
labels:
severity: page
annotations:
playbook: "https://sre.linkrus.com/playbooks/instance-down"
每个警报块都必须始终分配一个唯一的名称,以及 Prometheus 将每次评估警报规则时重新计算的一个PromQL(即Prometheus 查询语言)表达式。在前面的例子中,规则表达式在up
指标的值变为零时得到满足。
可选的for
子句可以用来延迟警报的触发,直到经过特定的时间段,在此期间警报表达式必须始终满足。在这个例子中,只有当up
指标至少保持 5 分钟为零时,警报才会触发。
labels
块允许我们给警报附加一个或多个标签。在这种情况下,我们给警报添加一个severity: page
注释,以通知负责处理警报的组件,它应该呼叫当前值班的服务可靠性工程师(SRE)。
最后,annotations
块允许操作员存储额外的信息,例如警报的详细描述或指向处理此类警报的 playbook 的 URL。
playbook 是一个简明的文档,总结了解决特定问题的最佳实践。这些文档提前编写,通常附加到由于警报触发的所有发出的通知。
当服务可靠性工程师(SRE)被呼叫时,能够访问与特定警报关联的 playbook 是无价资产,可以快速诊断问题的根本原因并减少平均修复时间(MTTR)。
处理警报事件
Prometheus 将定期评估配置的警报规则集,并在满足规则的前提条件时发出警报事件。按照设计,Prometheus 服务器只负责发出警报事件;它不包含任何处理警报的逻辑。
实际处理发出的警报事件由 Alertmanager 组件负责。Alertmanager 消费由 Prometheus 发出的警报事件,并负责将每个警报分组、去重并将警报路由到适当的通知集成(在 Alertmanager 术语中称为接收器)。
我们将简要介绍 Alertmanager 组件,详细说明操作员如何使用其内置的分组和过滤功能来管理传入的警报事件。接下来,我们将了解定义警报接收器和配置路由规则的基本知识,以确保警报始终被发送到正确的接收者。
将警报分组
处理同时触发的大量警报,从服务可靠性工程师(SRE)的角度来看,确实是一项艰巨的任务。为了过滤掉噪音,Alertmanager 允许操作员指定一组规则,根据 Prometheus 为每个警报事件分配的标签内容来分组警报。
要了解警报分组是如何工作的,让我们设想一个场景,其中 100 个微服务都在尝试连接当前不可用的 Kafka 队列。每个服务都会触发一个高优先级警报,这反过来又会导致向当前值班的服务可靠性工程师(SRE)发送新的页面通知。结果,SRE 将收到数百条关于确切相同问题的页面通知!
为了避免这种情况,一个更好的解决方案是编辑 Prometheus 警报规则定义,并确保所有针对队列服务的警报事件都带有特定的标签,例如,component=kafka
。然后,我们可以指示 Alertmanager 根据标签component
的值对警报进行分组,并将所有与 Kafka 相关的警报合并为一条单一页面通知
。
选择性静音警报
另一个你应该注意的 Alertmanager 功能是警报抑制。此功能允许操作员在特定警报正在触发时,对一组警报的提醒进行静音。
当 Alertmanager 加载其配置文件时,它会在顶级inhibit_rules
键下查找警报抑制规则的列表。每个规则条目都必须遵循以下模式:
source_match:
[ <labelname>: <labelvalue>, ... ]
source_match_re:
[ <labelname>: <regex>, ... ]
target_match:
[ <labelname>: <labelvalue>, ... ]
target_match_re:
[ <labelname>: <regex>, ... ]
[ equal: '[' <labelname>, ... ']' ]
source_match
和source_match_re
块作为激活抑制规则的警报的选择器。这两个块之间的区别在于,source_match
尝试进行精确匹配,而source_math_re
将传入警报的标签值与正则表达式进行匹配。
target_match
和target_match_re
块用于选择在抑制规则激活期间将被抑制的警报集合。
最后,equal
块防止抑制规则在没有源和目标规则具有指定标签相同值的情况下激活。
为了防止警报抑制自身,匹配规则源和目标侧的警报不允许被抑制。
作为一个概念验证,让我们尝试定义一条规则,以抑制周末期间触发的任何警报。设置此规则的前提是创建一个仅在周末触发的 Prometheus 警报。然后,我们可以在 Alertmanager 的配置文件中添加以下块:
inhibit_rules:
- source_match:
alertname: Weekend
target_match_re:
alertname: '*'
当周末
警报正在触发时,任何其他警报(不包括自身)将被自动静音!
配置警报接收器
接收器不过是一个高级别的术语,指的是可以发送警报通过各种渠道的通知集成集合。开箱即用,Alertmanager 支持以下集成:
-
电子邮件:发送包含警报详情的电子邮件
-
Slack/Hipchat/WeChat:将警报详情发布到聊天服务
-
PagerDuty/Opsgenie/VictorOps:向当前值班的 SRE 发送页面通知
-
WebHooks:实现自定义集成的逃生舱
当 Alertmanager 加载其配置文件时,它会在顶级receivers
键下查找接收者定义列表。每个接收者块必须遵循以下架构:
name: <string>
email_configs:
[ - <email_config>, ... ]
pagerduty_configs:
[ - <pagerduty_config>, ... ]
slack_configs:
[ - <slack_config>, ... ]
opsgenie_configs:
[ - <opsgenie_config>, ... ]
webhook_configs:
[ - <webhook_config>, ... ]
# omitted for brevity: configs for additional integrations
每个接收者都必须分配一个唯一的名称,正如我们将在下一节中看到的,该名称可以被一个或多个路由规则引用。然后操作员必须为每个在警报达到接收者时应激活的通知机制指定配置。
然而,如果操作员没有提供任何配置块,接收者表现得像一个黑洞:任何到达它的警报都会被简单地丢弃。
将警报路由到接收者
现在,让我们更深入地看看 Alertmanager 用于将传入警报路由到特定接收者的基于树的机制。Alertmanager 配置文件的最高级部分必须始终定义一个route
块。该块代表树的根节点,可以包含以下一组字段:
-
match
:指定一组标签值,这些值必须与传入警报的值匹配,才能将当前路由节点视为匹配。 -
match_re
:与match
类似,但标签值是匹配正则表达式。 -
receiver
:如果警报匹配当前路由,将传入警报交付给接收者的接收者名称。 -
group_by
:一组用于按标签分组传入警报的标签名称。 -
routes
:一组子route
块。如果警报不匹配任何子路由,它将根据当前路由的配置参数进行处理。
要了解基于树的路由在实际中是如何工作的,让我们通过一个简单的例子来逐步分析。为了这个例子,Alertmanager 的配置文件包含以下路由配置:
route:
receiver: 'default'
# All alerts that do not match the following child routes
# will remain at the root node and be dispatched to 'default-receiver'.
routes:
- receiver: 'page-SRE-on-call'
match_re:
service: cockroachdb|cassandra
- receiver: 'notify-ops-channel-on-slack'
group_by: [environment]
match:
team: backend
receivers:
# omitted: receiver definitions
让我们通过检查它们的标签注释来看看 Alertmanager 是如何确定各种传入警报的适当接收者的:
-
如果传入的警报包含一个值为
cockroachdb
或cassandra
的service
标签,Alertmanager 将把警报发送到page-SRE-on-call
接收者。 -
另一方面,如果警报包含一个值为
backend
的team
标签,Alertmanager 将把它发送到notify-ops-channel-on-slack
接收者。 -
任何其他不匹配两个子路由的传入警报将默认发送到
default
接收者。
这就完成了我们对 Alertmanager 工具的浏览。诚然,为您的应用程序配置警报规则可能一开始看起来是一项艰巨的任务。希望您通过阅读本章获得的知识将使您能够开始尝试使用 Prometheus 并设置一些基本的 Alertmanager 测试规则。经过一点实践,一旦您掌握了规则语法,您会发现为监控生产应用程序编写更复杂的规则将变得轻而易举!
摘要
在本章的开头,我们讨论了使用 Prometheus 这样的指标收集系统从我们的部署应用以及基础设施(例如,Kubernetes 主/工作节点)中抓取和聚合指标数据的优缺点。
然后,我们学习了如何利用官方的 Prometheus 客户端包为 Go 编写代码进行仪表化,并通过 HTTP 导出收集到的指标,以便 Prometheus 可以抓取它们。接下来,我们赞扬了使用 Grafana 通过从异构源拉取指标来构建仪表板的优点。在本章的最后部分,我们学习了如何在 Prometheus 中定义警报规则,并深入理解了使用 Alertmanager 工具对 Prometheus 发出的警报事件进行分组、去重和路由。
通过利用本章获得的知识,你将能够对你的 Go 代码库进行仪表化,并确保可以收集、聚合和可视化应用程序状态和性能的重要指标。此外,如果你的当前角色还包括 SRE 职责,你可以随后将这些指标输入到警报系统中,并在你的服务 SLA 和 SLO 未满足时接收实时通知。
接下来,我们将介绍一些有趣的想法,以扩展本书中构建的内容,从而进一步加深对材料的理解。
问题
-
SLI 和 SLO 之间的区别是什么?
-
解释 SLA 是如何工作的。
-
基于推送和拉取的指标收集系统之间的区别是什么?
-
你会使用基于推送或拉取的系统从严格锁定(即无入站)的子网中抓取数据吗?
-
Prometheus 计数器和度量指标之间的区别是什么?
-
为什么页面上通知需要附带一个 playbook 的链接?
进一步阅读
-
坎贝尔,马修:使用 Prometheus 扩展到百万台机器(PromCon 2016):
promcon.io/2016-berlin/talks/scaling-to-a-million-machines-with-prometheus
-
Consul: 安全服务网络:
consul.io
-
Docker: 企业级容器平台:
www.docker.com
-
Grafana: 开源的可观察性平台:
grafana.com/
-
Graphite: 一款适用于企业的监控工具,在廉价硬件或云基础设施上运行同样出色:
graphiteapp.org/
-
InfluxDB: 一种设计用于处理高写入和查询负载的时间序列数据库:
www.influxdata.com/products/influxdb-overview
-
Nagios: IT 基础设施监控的行业标准:
www.nagios.org
-
Prometheus: 配置选项:
prometheus.io/docs/prometheus/latest/configuration/configuration
-
Prometheus: 机器指标的导出器:
github.com/prometheus/node_exporter
-
Prometheus: 监控系统和时序数据库:
prometheus.io
-
StatsD: 用于轻松但强大的统计聚合的守护进程:
github.com/statsd/statsd
第十四章:结语
在这一点上,我想祝贺你完成这本书,并对你抽出时间阅读到最后一章表示衷心的感谢。我真诚地希望你在阅读这本书的过程中和我写作这本书一样感到快乐,并且你可以将我们在这几页讨论的一些原则和概念应用到你的当前和未来的 Go 项目中。
话虽如此,我有一个小小的请求。如果你在这本书的内容或配套代码中找到任何错误,请不要犹豫,与我联系。更重要的是,我非常乐意听到你对本书中讨论的主题的看法!你可以通过 Packt Publishing 或通过这本书的 GitHub 仓库联系我。
如果你愿意接受挑战,这里有一份包含一些有趣想法的列表,你可以尝试这些想法来进一步加深你对本书材料和其配套代码的理解:
-
支持爬取使用 JavaScript 框架(如 React、Vue 等)渲染内容的动态页面。添加一个无头浏览器,以便爬虫可以执行 JavaScript 代码并处理渲染页面的内容。
-
减少爬虫组件对远程服务器的影响。修改前端链接提交页面,以便网站管理员可以指定爬取特定域的首选时间窗口。然后,更新爬虫实现,使其在安排每个单独链接的刷新间隔时考虑这一信息。
-
更新爬虫以在决定是否爬取特定域的链接时识别并尊重
robots.txt
文件的内容。 -
利用第八章中提到的
bspgraph
包,基于图的数据处理,来实现其他流行的基于图的算法,然后通过切换代码使其使用第十二章中提到的dbspgraph
包,构建分布式图处理系统,以分布式方式执行它们。如果你不确定从哪里开始,可以尝试实现以下算法之一:-
检查一个图是否是二分图。
-
确定一个图中是否包含哈密顿回路。
-
检测一个有向图是否包含环。
-
-
将对
dbspgraph
包的实现添加对检查点的支持。在可配置的超步数之后,主节点应要求每个工作节点创建其当前状态的检查点。如果一个工作节点崩溃,主节点应随后指示工作节点从最后一个已知检查点加载其状态,重新分配 UUID 范围给剩余的工作节点,并继续执行计算作业。
第十五章:评估
第一章
-
软件工程被定义为将系统化、规范化和可量化的方法应用于软件开发、运行和维护。
-
一些软件工程师必须能够回答的关键问题如下:
-
-
软件需要支持哪些业务用例?
-
系统由哪些组件组成,它们之间是如何相互作用的?
-
将用于实现各种系统组件的技术是什么?
-
如何测试软件以确保其行为符合客户的期望?
-
负载如何影响系统的性能,以及系统扩展的计划是什么?
-
-
SRE 大约一半的时间用于与操作相关的任务,例如处理支持票证、待命、自动化流程以消除人为错误等。
-
水晶模型并没有提供每个模型步骤所包含过程的详细视图。此外,它似乎也不支持与水晶步骤并行运行的跨切面过程,例如项目管理或质量控制。水晶模型的一个显著缺点是它基于所有客户需求都提前已知的假设。迭代增强模型试图通过执行小的增量水晶迭代来纠正这些问题,从而使开发团队能够适应需求的变化。
-
根据精益开发模型,最常见的浪费来源如下:
-
-
在开发过程中引入非必要的变化
-
对于签署新功能过于复杂的决策过程
-
各个项目利益相关者和开发团队之间不必要的沟通
-
-
团队决定以牺牲代码质量为代价,专注于快速交付。结果,代码库变得更加复杂,缺陷开始累积。现在,团队必须将部分开发时间用于修复错误,而不是开发请求的功能。因此,实施阶段成为瓶颈,降低了整个开发过程的效率。
-
产品负责人的主要职责是管理项目的待办事项。另一方面,Scrum Master 负责组织和运行各种 Scrum 活动。
-
反思会议作为反馈循环,用于在冲刺期间逐步提高团队的表现。团队成员应该讨论在上一个冲刺中做得好的事情以及做得不好的事情。反思会议的结果应该是一份纠正措施清单,以解决在冲刺期间遇到的问题。
-
自动化很重要,因为它减少了人为错误的可能性。此外,它减少了测试和部署更改到生产所需的时间。测量同样重要,因为它允许 DevOps 工程师监控生产服务,并在其行为偏离预期规范时接收警报。
-
公司预计将在一个高风险环境中运营。一方面,新的游戏机依赖于一种尚未可用且由第三方开发的技术。更重要的是,市场已经饱和:其他,规模更大的竞争对手也可能正在开发他们自己的下一代游戏机系统。ACME 游戏系统预期的竞争优势可能在他们的新系统发布时变得过时。这又是另一个风险来源。鉴于涉及的高风险,具有风险评估和原型制作过程的螺旋模型将是开发将驱动新控制台软件的最明智选择。
第二章
-
以下就是 SOLID 缩写代表的含义:
-
单一职责
-
开放/封闭
-
Liskov 替换
-
接口隔离
-
依赖反转
-
-
代码混淆了两个职责:检索/修改文档的状态和为文档内容创建签名。此外,所提出的实现是不灵活的,因为它强制使用特定的签名算法。为了解决这个问题,我们可以从
Document
类型中移除Sign
方法,并提供一个外部辅助工具,它可以对Document
的实例以及任何可以将内容导出为字符串的类型进行签名:
type ContentProvider interface {
Content() string
}
type ECDADocumentSigner struct {//...}
func (s ECDADocumentSigner) Sign(pk *ecdsa.PrivateKey, contentProvider ContentProvider) (string, error) { //... }
-
接口隔离原则背后的思想是向客户提供尽可能小的接口,以满足他们的需求,从而避免依赖于实际上不会使用的接口。在提供的示例中,写入方法接收一个
*os.File
实例作为参数。然而,由于函数实现可能只需要能够将数据写入文件,我们可以通过传递一个io.Writer
来代替*os.File
实例,达到相同的结果。除了打破对*os.File
具体类型的依赖之外,这个变化还将允许我们为任何实现了io.Writer
的类型(例如,套接字、日志记录器或其他类型)重用实现。 -
由于以下原因,将
util
用作包名不是一个推荐的做法:-
它提供的上下文很少,无法说明包的目的和内容。
-
它可能成为各种杂项、可能无关的类型和方法的家,这无疑会违反单一职责原则。
-
-
导入周期会导致 Go 编译器在尝试编译和/或运行代码时发出编译时错误。
-
使用零值定义新的 Go 类型的一些优点如下:
-
不需要显式构造函数,因为 Go 在对象分配时会自动将零值赋给其字段。
-
类型可以嵌入到其他类型中,并且无需任何进一步初始化即可直接使用(例如,将
sync.Mutex
嵌入到结构体中)。
-
第三章
-
软件版本化的目的是双重的。首先,它允许软件工程师验证外部依赖项是否可以安全升级,而不会引入生产系统的问题。其次,能够通过它们的版本显式引用所需的软件依赖项是实现可重复构建概念的前提条件。
-
语义版本是一个满足以下格式的字符串:
MAJOR.MINOR.PATCH
:-
当软件中引入破坏性更改时,会递增主要组件。
-
当以向后兼容的方式向软件中引入新功能时,会递增次要组件。
-
当对代码应用向后兼容的修复时,会递增补丁版本。
-
-
在第一种情况下,我们会递增次要版本,因为新的 API 不会破坏向后兼容性。在第二种情况下,我们会递增主版本,因为新的必需参数与 API 的旧版本不兼容。最后,在第三种情况下,我们会递增补丁版本。
-
一种方法是为每个构建标记一个唯一的、单调递增的数字。或者,我们可以在构建工件上标注一个时间戳,以指示它们创建的时间。
-
vendoring 的优点如下:
-
能够为当前或旧版本的软件运行可重复构建
-
能够访问本地所需的依赖项,即使它们从最初托管的地方消失了。
-
vendoring 的缺点如下:
-
-
工程师应监控其依赖项的变更日志,并在安全修复可用时手动升级它们。
-
如果 vendored 依赖项的作者没有为其包遵循语义版本,则升级依赖项可能会引入破坏性更改,这些更改必须在代码能够编译之前解决。
-
-
dep 工具和 Go modules 之间的一些区别如下:
-
Go modules 完全集成了各种命令,例如
go get
、go build
和go test
。 -
虽然 dep 工具选择一个包的最高公共版本,但 Go modules 选择可用的最低版本。
-
Go modules 支持多版本依赖。
-
Go modules 废弃了 dep 工具使用的 vendor 文件夹。
-
第四章
-
模板满足特定的接口,并为其实现的每个方法调用返回预定义的答案。模拟允许我们以声明性方式指定以下内容:
-
预期方法调用集的顺序和参数
-
对于每个输入组合要返回的值集
-
-
伪造对象提供了一个完全工作的实现,其行为与它们打算替代的对象相匹配。例如,我们可能不是让我们的测试与真实的键值(KV)存储进行通信,而是注入一个伪造对象,该对象提供了一个兼容的内存实现,用于 KV 存储的 API。
-
表驱动的测试由三个主要组件组成:
-
一种封装运行测试及其预期结果的参数的类型。在 Go 程序中,这通常通过匿名结构体来实现。
-
一组用于评估的测试用例。
-
测试运行器。在这里,一个循环遍历测试用例列表,使用正确的参数集调用被测试代码,并验证获得的结果是否符合每个测试用例的预期。
-
-
单元测试的目的是确保特定的代码单元(一个函数、方法或包)在隔离状态下运行时,其行为符合一组规范。为此,单元测试通常会使用诸如存根、模拟或伪造对象等机制来替换被测试代码的任何外部依赖。另一方面,集成测试旨在同时测试多个单元,以验证它们是否正确交互。
-
集成测试旨在同时测试多个单元,以验证它们是否正确交互。与单元测试类似,集成测试通常会使用诸如存根、模拟或伪造对象等机制作为外部组件的替代(例如,数据库、Web 服务器等)。另一方面,功能测试不使用任何模拟机制,因为其主要目的是测试完整系统的行为。
-
代理模式在应用程序与其依赖的服务之间注入一个代理。代理通常作为与应用程序并行的边车进程运行,并公开 API 以执行以下操作:
-
将出站服务调用重定向到服务的不同版本
-
对出站服务调用的模拟响应
-
为测试目的向请求或响应中注入故障
-
第五章
-
功能需求概述了系统将实现的核心功能列表,以及系统与任何外部参与者之间的交互集合。另一方面,非功能需求列出了我们可以用来确定所提出的设计是否适合解决特定问题的机制和指标。
-
用户故事由以下两个关键组件组成:
-
需求规范必须始终从与系统交互的参与者的视角来表述
-
一组验收标准(也称为完成定义),用于评估故事目标是否已成功实现
-
-
攻击者可以提交一个精心设计的带有本地链接地址的链接,诱使爬虫调用托管我们项目的云提供商提供的元数据 API,并随后将响应缓存到搜索索引中。此外,攻击者可以将
file
作为其协议类型提交 URL,导致爬虫从机器上读取本地文件并将其内容泄露到搜索索引中。 -
服务级别目标(SLO)由以下部分组成:
-
对被测量的对象的描述
-
预期的服务级别,以百分比指定
-
测量发生的时间段
-
-
一个 UML 组件图提供了对构成系统的核心组件的高级视图,并从实现和所需接口的角度可视化它们的依赖关系。
第六章
- 关系型数据库更适合事务型工作负载和执行复杂查询。它们可以通过数据分片等机制进行水平扩展,但代价是需要额外的协调来执行查询。另一方面,NoSQL 数据库最适合处理大量非规范化数据。按设计,NoSQL 数据库可以高效地进行水平扩展(甚至跨数据中心),许多 NoSQL 产品承诺随着集群中节点数量的增加,查询性能呈线性增长。NoSQL 数据库的主要缺点是它们只能满足 CAP 定理(一致性、可用性和分区容错性)的两个方面。
对于执行大量并发事务的系统,如银行中可能遇到的系统,关系型数据库将是一个很好的选择。另一方面,需要处理大量事件以进行数据分析的系统可能更多地受益于 NoSQL 解决方案。
-
为了将数据库管理系统(DBMS)扩展以应对读密集型工作负载,我们会部署多个只读副本,并更新我们的应用程序以将只读查询发送到副本,将写查询发送到主节点。对于写密集型工作负载,我们会部署多个主节点并启用数据分片,以便将写操作高效地分布在主节点之间。
-
根据 CAP 定理,分布式系统只能满足以下三个属性中的两个:一致性、可用性和分区容错性。在决定使用哪种 NoSQL 解决方案时,我们必须确定对我们特定的应用程序来说,CAP 术语中的哪两个是最重要的(CP、AP 或 CA),然后限制我们的搜索范围,只考虑满足我们选择的 CAP 要求的 NoSQL 产品。
-
拥有一个抽象层允许我们将业务逻辑与底层数据库系统解耦。这使得在未来切换到不同的 DBMS 变得容易得多,而无需更新我们的业务逻辑。此外,由于我们可以使用诸如存根、模拟或伪造对象等机制来避免针对实际数据库实例运行测试,因此测试我们的业务逻辑代码也变得更容易、更快。
-
首先,您需要将新方法添加到
Indexer
接口中。然后,按照测试驱动的方法,您需要在indextest
包中的SuiteBase
类型中添加对新方法预期行为的测试。最后,您需要访问所有遵循Indexer
接口的类型(在本例中为 bleve 和 Elasticsearch 索引器),并为新方法添加实现。
第七章
-
Go 的
interface{}
类型没有关于底层类型的有用信息。如果我们用它来表示函数或方法的参数,我们实际上绕过了编译器在编译时对函数/方法参数进行静态检查的能力,而必须手动检查输入是否可以安全地转换为支持的已知类型。 -
我们可以将计算密集型阶段迁移到具有足够计算资源的远程机器上,而不是在本地运行。然后,相应的本地阶段可以用一个代理来替换,该代理通过远程过程调用(RPC)将本地有效负载数据传输到远程机器,等待结果,并将结果推送到下一个本地阶段。以下图表概述了提出的解决方案:
- 每个处理器函数都必须满足
Processor
接口,其定义如下:
type Processor interface {
Process(context.Context, Payload) (Payload, error)
}
此外,我们还定义了ProcessorFunc
类型,它充当将具有兼容签名的函数转换为实现Processor
接口的类型的适配器。
对于这个特定的用例,我们可以定义一个函数,该函数接收一个Processor
和一个日志记录器(例如,来自logrus
包)的实例,并返回一个新的Processor
,该Processor
在调用Process
方法时添加额外的逻辑,如果发生错误则发出日志条目。makeErrorLoggingProcessor
函数展示了实现此模式的一种可能方法:
func makeErrorLoggingProcessor(proc Processor, logger *logrus.Logger) Processor {
return ProcessorFunc(func(ctx context.Context, p Payload) (Payload, error) {
out, err := proc.Process(ctx, p)
if err != nil {
logger.Error(err)
}
return out, err
})
}
-
同步管道按先进先出(FIFO)顺序一次处理一个有效负载,并在处理下一个可用有效负载之前等待它退出管道。因此,如果单个有效负载处理时间较长,它实际上会延迟处理其后排队等待的有效负载。在异步管道中,每个阶段异步操作,可以在当前有效负载被发送到下一阶段后立即开始处理下一个有效负载。
-
死信队列是一种将管道有效载荷的错误处理延迟到以后的机制的机制。当管道在处理有效载荷时遇到错误,它会将有效载荷及其发生的错误附加到死信队列中。然后应用程序可以检查死信队列的内容,并根据其业务逻辑决定如何处理每个错误(例如,重试失败的有效载荷、记录或忽略错误等)。
-
一个固定大小的工作池包含在池创建时同时创建的预定数量的工作者。动态池配置了较低的工人限制和较高的工人限制,可以根据需求自动扩展或缩小,以适应传入有效载荷速率的变化。
-
为了测量每个有效载荷在管道中花费的总时间,我们将修改
pipeline.Payload
结构体并添加一个名为processStartedAt
的新*private*
字段,字段类型为time.Time
。这个新字段将用于记录有效载荷进入管道的时间戳。接下来,我们将修改linkSource
实现,在它发出新的Payload
时填充processStartedAt
。最后,我们将更新nopSink
的(目前为空的)Consume
方法,通过调用time.Since
来计算经过的时间。
第八章
-
BSP 计算机是由一组可能异构的处理器组成的抽象计算机模型,这些处理器通过计算机网络相互连接。处理器不仅可以访问自己的本地内存,还可以使用网络链路与其他处理器交换数据。换句话说,BSP 计算机实际上是一个分布式内存计算机,可以并行执行计算。
-
单程序多数据(SPMD)技术将分布式数据处理任务建模为在单核机器上运行的独立软件组件。程序接收一组数据作为输入,对其应用处理函数,并输出一些结果。然后通过将数据集拆分为批次,启动多个相同程序的实例并行处理每个批次,并合并结果来实现并行性。
-
超步骤被分解为两个阶段,或子步骤:
-
计算步骤,其中每个处理器使用分配给处理器的数据作为输入,并行执行用户程序的单一迭代。
-
在所有处理器完成计算步骤之后运行的通信步骤。在此步骤中,处理器通过网络进行通信,比较、交换或汇总各自计算的结果。
-
-
以下代码块演示了我们可以如何创建一个聚合器来跟踪迄今为止看到的最低
int64
值。使用int64
指针允许我们检测是否已经看到了任何值(否则,指针将是nil
),如果是,则通过Aggregate
方法看到的最低值。通过使用sync.Mutex
强制执行对int64
值的原子访问:
type MinInt64Aggregator struct {
mu sync.Mutex
minValue *int64
}
func (a *MinInt64Aggregator) Aggregate(v interface{}) {
a.mu.Lock()
if intV := v.(int64); a.minValue == nil || intV < *a.minValue {
a.minValue = &intV
}
a.mu.Unlock()
}
func (a *MinInt64Aggregator) Set(v interface{}) {
a.mu.Lock()
intV := v.(int64)
a.minValue = &intV
a.mu.Unlock()
}
func (a *MinInt64Aggregator) Get() interface{} {
a.mu.Lock()
defer a.mu.Unlock()
if a.minValue == nil {
return nil
}
return *a.minValue
}
func (a *MinInt64Aggregator) Delta() interface{} { return a.Get() }
func (a *MinInt64Aggregator) Type() string { return "MinInt64Aggregator" }
-
在随机冲浪者模型下,用户执行初始搜索并从链接图中随机选择一个页面。从那时起,用户随机选择以下两种选项之一:
-
他们可以点击当前页面的任何出站链接并导航到新页面
-
或者,他们可以选择运行一个新的搜索查询
-
前面的步骤会持续进行。
-
PageRank 分数反映了随机冲浪者随机访问特定网页的概率。换句话说,该分数表达了相对于互联网上每个其他网页的每个网页的重要性(排名)。
-
在 PageRank 算法的每个步骤中,每个链接都会将其累积的 PageRank 分数分配给其出站链接。死胡同从链接到它们的页面接收 PageRank 分数,但它们不会重新分配,因为它们没有出站链接。如果我们不采取措施处理这些问题情况,图中的死胡同最终会得到一个显著更高(且不正确)的 PageRank 分数,与图中的常规页面相比。
第九章
- 下表总结了用户实体的 CRUD 端点:
HTTP 动词 | 路径 | 期望(JSON) | 返回(JSON) | HTTP 状态 | 描述 |
---|---|---|---|---|---|
POST | /users |
用户条目 | 新的用户条目及其 ID | 200(成功)或 201(已创建) | 创建新用户 |
GET | /users |
无 | 包含用户条目的数组 | 200(成功) | 获取用户列表 |
GET | /users/:id |
无 | 指定 ID 的用户 | 200(成功)或 404(未找到) | 通过 ID 获取用户 |
PUT | /users/:id |
用户条目 | 更新的用户条目 | 200(成功)或 404(未找到) | 通过 ID 更新用户 |
PATCH | /users/:id |
一个部分用户条目 | 更新的用户条目 | 200(成功)或 404(未找到) | 通过 ID 更新用户的单个字段 |
DELETE | /users/:id |
无 | 无 | 200(成功)或 404(未找到) | 通过 ID 删除用户 |
-
基本认证头以明文形式传输。通过确保此信息通过 TLS 加密通道传输,我们防止恶意行为者拦截用户凭据。
-
如果恶意对手成功在其目标受信任的证书存储中安装自己的证书颁发机构(CA),他们可以发起中间人攻击(MitM)并窃听目标与任何第三方之间的 TLS 流量。
-
在三重 OAuth2 流程中,以下情况发生:
-
用户访问服务 A 并尝试通过服务 B 登录。
-
A 的后端服务器为服务 B 生成一个授权 URL,并将用户的浏览器重定向到该 URL。生成的 URL 包含 A 请求的权限集,以及 B 一旦同意授权后应将用户重定向到的 URL。
-
用户被重定向到服务 B,并同意服务 A 请求的权限。
-
用户的浏览器被重定向到包含嵌入 URL 中的访问代码的服务 A。
-
服务 A 的后端服务器联系服务 B,并使用访问令牌交换访问代码。
-
服务 A 使用令牌代表用户访问服务 B 上的某些资源(例如,用户详情)。
-
-
协议缓冲区在以下原因上优于 JSON 用于请求/响应有效负载:
-
它们使用更紧凑的二进制格式来序列化有效负载。
-
协议缓冲区消息是严格类型的,并支持版本控制。
-
Protoc 编译器可用于生成用于在多种编程语言中处理协议缓冲区消息所需的代码。
-
-
gRPC 支持以下 RPC 模式:
-
单例 RPC:客户端执行请求并接收响应。
-
服务器流式 RPC:客户端初始化与服务器的 RPC 连接,并从服务器接收一系列响应。
-
客户端流式 RPC:客户端初始化与服务器的 RPC 连接,并通过打开的连接发送一系列请求。服务器处理请求,并发送单个响应。
-
双向流式 RPC:客户端和服务器共享一个双向通道,每端都可以异步发送和接收消息。
-
第十章
-
容器化的某些好处如下:
-
同一个容器镜像可以在本地开发机器或云实例上运行。
-
部署软件的新版本非常简单,如果出现问题,还可以执行回滚。
-
它引入了一个额外的安全层,因为应用程序既与主机又与其他应用程序隔离。
-
-
主节点实现了 Kubernetes 集群的 控制平面。工作节点将它们的资源(CPU、内存、磁盘、GPU 等)汇总,并执行由主节点分配给它们的工作负载。
-
一个常规的 Kubernetes 服务充当负载均衡器,将传入流量分发到一组 Pod。常规服务可以通过 Kubernetes 分配给它们的集群 IP 地址访问。无头服务提供了实现自定义服务发现机制的途径。它没有分配集群 IP 地址,对该服务的 DNS 查询返回该服务背后的所有 Pod 的完整列表。
-
由于 OAuth2 客户端 ID 和密钥都是敏感信息,因此建议的 Kubernetes 方法是将它们与前端共享,即创建一个密钥资源。
-
Kubernetes 部署会创建具有不可预测 ID 的 pod,而有状态集则分配可预测的名称,这些名称是通过连接有状态集名称和 pod 序号(例如,web-0、web-1 等等)来构建的。另一个区别是,虽然 Kubernetes 部署会并行启动所需数量的 pod,但有状态集则是顺序启动 pod。
第十一章
-
基于微服务的架构为系统带来了许多好处。然而,同时,它也给系统增加了许多复杂性,并需要额外的努力来使其能够抵御网络问题、监控其内部状态以及在出现问题时进行调试。因此,选择这种模式用于 MVP 或 PoC 通常被认为是一种过早优化,可能会引入比解决的问题更多的问题。
-
当特定下游服务的错误数量超过特定阈值时,断路器会被触发,所有未来的请求都会自动失败并显示错误。定期地,断路器会允许一些请求通过,并在收到一定数量的成功响应后,断路器会切换回开启位置,从而允许所有请求通过。
-
能够追踪请求在系统中的传输过程,使我们能够做以下事情:
-
确定请求在每个服务中花费的时间,并识别潜在的瓶颈
-
理解并映射服务之间的依赖关系
-
确定影响生产系统的问题的根本原因
-
-
日志条目可能包含敏感信息,例如信用卡号码、安全凭证、客户姓名、地址或社会保障号码。除非我们主动清理这些条目,否则这些信息最终会出现在日志中,并可能被未经授权访问此类信息的实体(员工或第三方)看到。
-
要收集在 Kubernetes 集群中运行的 pod 的日志,我们可以使用以下策略之一:
-
使用守护集在每个 Kubernetes 节点上运行日志收集器。日志收集器会处理节点上运行的每个 pod 的日志文件,并将它们发送到集中式日志存储位置。
-
在我们想要收集日志的应用程序所在的同一个 pod 中部署一个边车容器。边车容器会处理应用程序日志(可能是一个文件或多个文件)并将它们发送到集中式日志存储位置。
-
直接从应用程序内部发送日志。
-
第十二章
-
在领导者-跟随者配置中,节点进行选举并为集群选择一个领导者。所有读取和写入操作都通过集群领导者进行,而其他节点监控领导者,如果领导者变得不可用,则自动进行新的选举。正如其名所示,在多主配置中,集群有多个主节点,每个主节点都可以处理读取和写入请求。主节点实现某种形式的分布式一致性算法(Raft、Paxos 等),以确保它们始终共享集群状态的相同视图。
-
在实现检查点策略时,主节点会定期要求工作节点将它们当前的状态持久化到持久存储中。如果此操作成功,则创建一个新的检查点。如果工作节点崩溃或变得不可用,主节点将要求剩余的健康工作节点从最后一个已知的检查点加载它们的状态,并从该点继续执行计算作业。
-
分布式屏障是一种同步原语,当所有工作节点都达到相同的执行点时,它会通知主节点。这个原语是执行基于 BSP 模型(见第八章,基于图的数据处理)的计算作业的先决条件,该模型要求所有处理器以同步方式执行每个超级步骤。
-
尽管算法本身没有错误地完成,但如果一个或多个工作节点尝试将它们的计算结果持久化到持久存储中,可能会出现问题。因此,只有在所有工作节点都持久化了计算结果之后,才能真正认为计算作业已经完成。
第十三章
-
服务级别指标(SLI)是一种指标类型,它允许我们从最终用户的视角量化服务的感知质量(例如,可用性、吞吐量和延迟等指标)。服务级别目标(SLO)是一组 SLI 的值范围,它允许我们向最终用户或客户提供特定水平的服务。
-
服务级别协议(SLA)是服务提供商和一位或多位服务消费者之间的一种隐式或显式合同。SLA 概述了一系列必须满足的 SLO 以及满足和未能满足它们的后果(财务或非财务)。
-
在基于推的指标收集系统中,指标生成客户端通过 TCP 或 UDP 连接连接到指标收集和聚合服务并发布它们的指标。在基于拉的系统中,指标收集系统在其空闲时连接到每个客户端并收集(抓取)任何新的指标。
-
由于现有的网络安全策略,指标收集服务无法与锁定子网中的任何指标生产者建立连接。然而,在该子网上运行的应用程序仍然应该能够访问其他子网,包括指标收集服务运行的子网。因此,在这种情况下,逻辑选择是使用基于推送的系统。
-
Prometheus 计数器的值只能增加,而 Prometheus 仪表的值既可以增加也可以减少。
-
操场剧本是一份简短的文档,总结了解决特定类型问题的最佳实践。能够访问与特定警报关联的操场剧本可以减少平均修复时间(MTTR),因为 SRE 可以遵循操场剧本的指示,快速诊断问题的根本原因,并应用推荐的步骤来修复它。