Go-网络自动化指南-全-

Go 网络自动化指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书探讨了网络自动化,这是一门旨在生成一致和可重复的过程以提高网络操作效率和可靠性的学科。随着你通过各章节的学习,你将学习 Go 语言的基础知识,并通过编写常见的日常网络流程来将其应用于实践,以启动你的网络自动化之旅。

本书面向对象

本书旨在为所有希望了解网络自动化是什么以及 Go 编程语言如何帮助我们开发网络自动化解决方案的网络工程师、管理员和其他网络从业者设计。由于本书的第一部分提供了 Go 主要功能的全面概述,因此本书适合对编程基础知识有扎实掌握的初学者。

本书涵盖内容

第一章, 引言,探讨了网络和 Go,Go 的优势以及它与 Python 的区别。

第二章, Go 基础, 定义了 Go 及其指导原则。它介绍了 Go 源代码文件结构,并展示了如何编译 Go 程序。

第三章, 开始使用 Go,涵盖了与网络自动化相关的 Go 的不同特性,如控制流、输入输出操作、解码编码和并发。

第四章, 使用 Go 进行网络(TCP/IP),专注于 Go 在 TCP/IP 模型每一层的实际用例。

第五章, 网络自动化, 讨论了网络自动化的定义、其对网络操作的影响以及对企业的好处。它还讨论了如何将单个用例扩展到网络自动化系统中。

第六章, 配置管理,通过使用 Go 与不同网络供应商的网络设备进行交互的实例,展示了如何通过 SSH 和 HTTP 配置和收集其操作状态以验证任何更改。

第七章, 自动化框架,描述了某些自动化框架如何与 Go 集成,重点介绍了 Ansible 和 Terraform。

第八章, 网络 API,探讨了用于管理网络设备以实现网络自动化的机器到机器接口,从 RESTCONF 和 OpenAPI 到 gRPC。

第九章, OpenConfig,探讨了如何使用 OpenConfig gRPC 服务执行常见操作任务,例如配置设备、订阅遥测流以及执行如 traceroute 之类的操作。

第十章网络监控,从不同的角度使用 Go 深入网络监控的世界;捕获网络数据包、处理数据平面遥测、运行主动探测以测量网络性能,以及可视化指标。

第十一章专家见解,由那些在网络安全自动化方面有实际动手经验的人士,或正在使用 Go 进行与网络相关的任务和活动的人士组成,他们与我们分享他们的观点。

第十二章**,附录构建测试环境,记录了构建测试环境的过程,包括兼容的 Containerlab 和其他相关依赖项的版本,以确保您在本书的任何章节中运行示例时都能获得无缝体验。

要充分利用本书

本书假设您对网络和编程基础知识有基本的了解。您需要熟悉 Linux 操作系统,以便安装软件包并运行和解释提供的命令的结果。大多数动手练习都是在容器环境中执行的,因此对容器的基本了解将帮助您探索和修改示例程序。

本书包含的示例可以在大多数 Linux 环境中重现。所有软件要求和依赖关系都在附录中详细说明。

本书中涵盖的软件/硬件 操作系统要求
Go 1.18.1 Linux (Ubuntu 22.04, Fedora 35), Windows Subsystem for Linux (WSL2) 或 macOS
Containerlab 0.28.1 Linux (Ubuntu 22.04, Fedora 35), Windows Subsystem for Linux (WSL2) 或 macOS
Docker 20.10.14 Linux (Ubuntu 22.04, Fedora 35), Windows Subsystem for Linux (WSL2) 或 macOS

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/Network-Automation-with-Go)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还从丰富的图书和视频目录中提供了其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/hOgov

使用的约定

本书使用了多种文本约定。

文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“您可以从ch03/type-definition/main.go测试此代码。”

代码块设置如下:

func main() {
	a := -1
	var b uint32
	b = 4294967295
	var c float32 = 42.1
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

func main() {
    a := 4294967295

    b := uint32(a)

    c := float32(b)
}

小贴士或重要注意事项

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果你对本书的任何方面有疑问,请通过电子邮件发送给我们至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误表:尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果你在互联网上以任何形式遇到我们作品的非法副本,我们将不胜感激,如果你能向我们提供位置地址或网站名称。请通过 copyright@packt.com 与我们联系,并提供材料的链接。

如果你有兴趣成为作者:如果你在某个领域有专业知识,并且你感兴趣的是撰写或为书籍做出贡献,请访问authors.packtpub.com

分享你的想法

一旦你阅读了《使用 Go 进行网络自动化》,我们很乐意听到你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

你的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

你喜欢在旅途中阅读,但无法随身携带你的印刷书籍吗?

你的电子书购买是否与你的选择设备不兼容?

别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠不会就此停止,你还可以获得独家折扣、时事通讯和每天收件箱中的优质免费内容。

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接!二维码图片

packt.link/free-ebook/978-1-80056-092-5

  1. 提交你的购买证明

  2. 就这样!我们将直接将你的免费 PDF 和其他优惠发送到你的电子邮件。

第一部分:Go 编程语言

这部分提供了本书的介绍,我们将涵盖的主题,以及如何在本书的整个过程中运行提供的代码示例。你可以选择使用你的个人电脑或虚拟机来完成这项任务。

此外,它还为 Go 语言提供了一个坚实的基础。到那时,你将能够安装和运行 Go 程序。你还将学习如何使用 Go 操作网络数据,例如 IP 地址和 XML/YAML/JSON 文档,并使用 Go 运行网络事务/协议。

本书这部分包括以下章节:

  • 第一章**,简介

  • 第二章**,Go 基础

  • 第三章**,Go 入门

  • 第四章**,使用 Go 进行网络(TCP/IP)编程

第一章:简介

Go 已经成为根据 Stack Overflow Developer Survey 2021进一步阅读)调查中排名前三的最受欢迎的编程语言之一,并且已成为编写云原生应用程序(如 KubernetesDockerIstioPrometheusGrafana)的首选语言。

尽管如此,我们仍然没有看到这一趋势在网络工程社区中体现出来,根据 NetDevOps 2020 调查(进一步阅读),不到 20% 的网络工程师表示他们目前使用 Go 进行网络自动化项目,尽管在 Go Developer Survey 2020 Results进一步阅读)中,41% 的 Go 开发者表示他们使用 Go 进行网络编程。

本书旨在通过为希望使用 Go 语言来演进网络管理和操作的网络安全工程师以及希望进入网络基础设施自动化领域的软件工程师提供实用的 Go 语言和网络自动化入门,来弥补这一差距。我们也希望这本书对目前使用 Python 但希望用不同的编程语言扩展技能集的网络自动化工程师有所帮助。

我们首先从不同角度讨论 Go 的优势以及它们如何应用于网络领域。到本章结束时,您应该对 Go 的主要方面以及如何将 Go 安装到您的计算机上以跟随代码示例有一个很好的理解。

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

  • 网络和 Go

  • 为什么选择 Go?

  • Go 的未来

  • Go 与 Python 的比较

  • 在您的计算机上安装 Go

技术要求

我们假设您对命令行、Git 和 GitHub 有基本的熟悉度。您可以在本书的 GitHub 仓库中找到本章的代码示例(github.com/PacktPublishing/Network-Automation-with-Go),在 ch01 文件夹下。

要运行示例,请按照以下步骤操作:

  1. 为您的操作系统安装 Go 1.17 或更高版本。您可以根据本章的 在您的计算机上安装 Go 指令进行操作,或访问 https://go.dev/doc/install。

  2. 使用 git 命令克隆本书的 GitHub 仓库 clone https://github.com/PacktPublishing/Network-Automation-with-Go.git

  3. 将目录更改为示例文件夹,使用 cd Network-Automation-with-Go/ch01/concurrency

  4. 执行 go run main.go

网络和 Go

Go 在通用基础设施软件中得到广泛应用——从工作负载编排(Docker 和 Kubernetes),到遥测和监控(Prometheus 和 Grafana),再到自动化工具(Terraform 和 Vagrant)。

网络也不例外——一些使用 Go 的知名网络项目包括容器网络接口CNI)插件,如CiliumCalico,路由协议守护进程如GoBGPBio-RD虚拟专用网络VPN)软件如Tailscale,以及OpenConfig的大部分生态系统,包括gRPC 网络管理接口gNMI)和goyang等项目。

其他用例包括云和网络服务、命令行界面CLIs)、Web 开发、开发运维DevOps)和站点可靠性。

Go 是 Go 的创始人创建的一种编程语言,旨在从第一天起解决现代挑战,如多核处理、分布式系统和大规模软件开发。

Go 内置的一等并发机制使其成为长期低带宽输入/输出I/O)操作的理想选择,这是网络自动化和网络操作应用的典型需求。

是什么让 Go 语言对软件开发者如此有吸引力?为什么在所有编程语言中,你应该投入时间去学习 Go?这是我们将在下一节中讨论的内容。

为什么选择 Go?

当选择学习下一门编程语言时,大多数人主要关注技术原因。我们认为这个选择可以更加细致,因此我们尝试从不同的角度来探讨这个问题。我们首先从非技术角度出发,这是常常被忽视但我们认为很重要,并且可以对学习过程和日常使用产生重大影响的东西。在此之后,我们将探讨一些通用的技术论点,这些论点有助于 Go 在现代编程语言的激烈竞争中脱颖而出。我们通过探讨 Go 的各个方面,这些方面可以造福人们,特别是在网络和网络自动化领域,来结束本节。

非技术原因

无论你是新手还是对语言有一些经验,你都可以访问社区中愿意帮助你学习更多关于语言的开发者。我们包括一些社区资源的指针,并讨论 Go 的采用和流行。

最后但同样重要的是,我们想要讨论语言的成熟度,它是否仍在开发中,以及 Go 未来的发展方向。

社区

一个健康的社区几乎总是成功项目的特征。Go 编程语言也不例外,它拥有一个欢迎且不断增长的 Go 开发者社区——Gophers,根据 Russ Cox 的文章《有多少 Go 开发者?》(进一步阅读),全世界大约有 200 万 Gophers。您可以在以下位置看到 Renée French 的Go Gopher吉祥物:

图 1.1 – Go Gopher,由 Renée French 绘制

图 1.1 – Go Gopher,由 Renée French 绘制

Go 用户社区有几个地方,新来者可以提问并获得经验丰富的 Go 开发者的帮助,如下所示:

  • golang-nuts邮件列表(进一步阅读)——Google Groups 上的通用语言讨论邮件列表

  • Go 论坛 (进一步阅读)——一个独立的用于技术讨论、发布公告和社区更新的论坛

  • Go 语言集体 (进一步阅读)——Stack Overflow 上的官方问答(Q&A)频道

  • Gophers Slack 频道(进一步阅读)——一个用于通用和特定主题讨论的地方,包括专门的社交网络频道

如果你想有更多的现场互动,这里也有一些选项,如下所述:

  • 通过Go 开发者网络GDN)(进一步阅读),有很多面对面聚会可供选择。

  • Go 社区中的一个主要活动是定期在世界不同地区举办的GopherCon

  • GitHub 上官方的 Go 语言页面跟踪了所有未来的和过去的 Go 语言会议及重大事件(进一步阅读)。

流行度

自 2000 年代末成立以来,Go 语言因其背后的开发者而受到了开发社区的广泛关注。由谷歌雇佣的一些最优秀的计算机科学家开发,Go 语言易于理解,且几乎与前辈语言一样高效,用于解决 C/C++的问题。它成熟需要了几年的时间,但很快成为了新的热门创业语言,许多新兴软件公司如 Docker 和 HashiCorp 都采用了它。

最近,Stack Overflow Developer Survey 2021 (进一步阅读)将 Go 语言评为开发者最想要的三大编程语言之一。来自其母公司的持续支持以及 Kubernetes 的成功,使它成为编写云原生应用程序的事实上的标准语言,如 Istio、CoreDNS、Prometheus 和 Grafana 等知名项目。随着越来越多的用户采用这些应用程序,很难想象 Go 语言的流行度在未来会减弱。

这里有一些支持 Go 语言日益增长的流行度的额外数据点,值得提及:

  • 根据 CNCF DevStats工具集的报告,291 个项目中,有 225 个使用 Go 语言,(进一步阅读)。

  • 根据 GitHut 2.0 的数据,Go 语言在 GitHub 上拥有最多的星标,排名第三(进一步阅读(https://github.com/PacktPublishing/Network-Automation-with-Go/blob/main/ch04/trie/main.go )))。

  • Go 语言背后支持了四个最受欢迎的开发工具中的三个(Docker、Kubernetes 和 Terraform)(进一步阅读)。

  • Go 在Stack Overflow Developer Survey 2021的顶级薪酬技术排名中位列前十(进一步阅读)。

成熟度

虽然 Go 团队不久前(2012 年 3 月)发布了 Go(版本 1),但自那时起 Go 语言一直在进行一些小的改动。语言设计者坚持一个严格立场,反对添加可能引起功能膨胀的不必要特性。在 GopherCon 2014 的开幕式主题演讲中,Rob Pike 明确表示:“语言已经完成。”Russ Cox 在他的文章 Go, Open Source, Community (进一步阅读) 中也提到了这一点,特别指的是 Go 1。

这并不意味着 Go 没有自己的痛点。例如,依赖管理是 Go 团队最近通过引入 Go 模块 来解决的一个问题,以更好地组织你一起发布的 Go 包。还有一个 泛型 支持的缺乏,这是一个 Go 团队现在在 Go 1.18 中引入的特性,可能是自 Go(版本 1)发布以来最重大的变化。现在,用户可以使用泛型类型来表示函数和数据结构,这促进了代码的重用。这解决了社区的一个主要请求,正如 Go 开发者调查 2020 结果 所示 (进一步阅读)。

尽管如此,这些少数改动非常具有选择性,旨在显著提高开发者的生产力。可以安全地假设,我们不会每年都要学习新的语言概念和习惯用法,并且需要重写代码以保持向前兼容性。Go 1 和 Go 程序的未来 (进一步阅读) 中关于 Go 1 兼容性的保证如下:

目的是编写符合 Go 1 规范的程序将在该规范的生命周期内继续编译和正确运行,保持不变。...在 Go 1.2 下运行的代码应该与 Go 1.2.1、Go 1.3、Go 1.4 等版本兼容。

Go 语言受益于从其他编程语言中学到的经验。Pascal、Oberon、C 和 Newsqueak 是影响 Go 的一些语言。我们在 第二章Go 基础 中探讨了它们的影响。

Go 遵循 6 个月的发布周期 (进一步阅读)。在每个 Go 版本的发布说明中 (进一步阅读),顶部都有一个部分描述了语言的变化,通常非常简短或为空。在过去的几年里,他们只报告了四种对语言的微小增强,这是一个成熟的良好迹象。

未来 Go 语言将会有多大变化是我们将在下一节讨论的内容。

Go 的未来

Go 1 版本的成功吸引了大量开发者,其中大多数人在其他语言中积累了经验,这些经验帮助他们塑造了对编程语言应该提供什么功能的思维和期望。Go 团队定义了一个过程来提出、记录和实施对 Go 的更改(进一步阅读),为这些新贡献者提供一个表达意见和影响语言设计的方式。他们会将任何违反前述章节中描述的语言兼容性保证的提案标记为 Go 2。

Go 团队在GopherCon 2017上宣布了开发 Go 2 版本的过程,并在博客文章Go 2,我们来了!进一步阅读)中进行了宣布。目的是确保语言能够继续使程序员能够开发大规模系统,并扩展到大型代码库,这些代码库是大型团队同时工作的。在Toward Go 2进一步阅读)中,Russ Cox 说:

我们对 Go 2 的目标是修复 Go 无法扩展的最重要方式。

任何语言更改提案都需要遵循 Go 2 语言更改模板(进一步阅读)。他们正在将所有向后兼容的 Go 2 功能增量地引入 Go 1。完成之后,他们可以在 Go 2.0 中引入向后不兼容的更改(参见 Go 2 提案:进一步阅读),如果它们提供了显著的好处。

支持泛型数据类型是 Go 2 草案设计文档的一部分(进一步阅读),包括改进的错误处理和错误值语义。泛型的第一个实现已经进入 Go 1。列表中的其他项目仍在评估中,这推动了 2.0 版本的发布进一步推迟。

技术原因

根据根据Go 开发者调查 2020 结果进一步阅读),Go 的构建速度是 Go 最令人满意的特点之一。它紧跟其后的是 Go 的可靠性,位居第二。

我们可以强调的技术方面有很多,但除了构建速度和可靠性之外,我们还涵盖了性能、交叉编译、可读性和 Go 的工具。

类型安全

大多数编程语言可以被广泛地分为两类:静态类型,当变量类型在编译时进行检查;或者动态类型,当这种检查在程序执行(运行时)期间发生。Go 属于第一类,要求程序显式声明所有变量类型。一些初学者或者有动态类型语言背景的人可能会觉得这是一个缺点。

类型声明会增加你需要编写的代码量,但作为回报,你不仅获得性能上的好处,还能在运行时避免类型错误,这些错误可能是许多微妙且难以调试的 bug 的来源。例如,考虑下一个代码示例中的程序,见github.com/PacktPublishing/Network-Automation-with-Go/blob/main/ch01/type-safety/main.go

func process(s string) string {
    return "Hello " + s
}
func main() {
    result := process(42)
}

一个process函数接受一个string数据类型作为输入,并返回另一个将Hello和输入字符串的值连接起来的string。如果一个动态类型程序接收到与string类型不同的值,例如整数,那么它可能会崩溃。

这些错误非常常见,尤其是在处理可以表示网络配置或状态的复杂数据结构时。Go 的静态类型检查防止编译器生成产生以下错误的可工作二进制文件:

cannot use 42 (type untyped int) as type string in argument to process

Go 的静态类型也提高了可读性。当从零开始编写代码时,开发者可能能够将整个数据模型牢记于心,但随着新用户加入项目,代码的可读性变得至关重要,有助于他们理解逻辑以进行所需的代码更改。他们不再需要猜测变量存储的值类型——所有内容都由程序显式定义。这个特性如此有价值,以至于一些动态类型语言放弃了它们简洁性的好处,以引入对类型注解的支持(例如 Python 类型:进一步阅读),唯一的目标是帮助集成开发环境IDE)和静态代码检查器捕获明显的类型错误。

Go 构建速度快

Go 是一种编译型语言,可以在几秒钟或最多几分钟内创建小型的二进制文件。初始构建时间可能稍长,主要是因为下载依赖项、生成额外代码和进行其他日常活动所需的时间。后续构建运行的时间只是其中的一小部分。例如,下一个截图显示,重新构建一个 120-兆字节MB)的 Kubernetes 应用程序编程接口(API)服务器二进制文件不超过 10 秒:

$ time make kube-apiserver
+++ [0914 21:46:32] Building go targets for linux/amd64:
    cmd/kube-apiserver
> static build CGO_ENABLED=0: k8s.io/kubernetes/cmd/kube-apiserver
make kube-apiserver  10.26s user 2.25s system 155% cpu 8.041 total

这让你能够快速迭代开发过程,并保持专注,无需花费几分钟等待代码重新编译。一些开发者生产力工具,如 Tilt,会采取进一步措施优化开发工作流程,使得从开发者的 IDE 到本地预发布环境的变化只需几秒钟。

可靠性

让我们将这个术语定义为编程语言的一系列属性,这些属性有助于开发者编写更不容易因 bug 和其他故障条件而失败的程序,正如卡内基梅隆大学(CMU)的 Jiantao Pan 在软件可靠性进一步阅读)中所描述的那样。这是 Go 的核心原则之一,正如其网站(进一步阅读)所强调的:

大规模快速、可靠和高效地构建软件。

根据 2020 年 Go 开发者调查结果进一步阅读),Go 开发者也表示可靠性是他们最满意的 Go 的第二个方面,仅次于构建速度。

更可靠的软件意味着花费在追踪错误上的时间更少,更多的时间投入到额外功能的设计和开发中。我们试图汇集一组我们认为有助于提高程序可靠性的功能。但这并不是一个最终列表,因为对这些功能的解释和归因可能非常主观。以下是包含的功能:

  • 代码复杂性—Go 语言设计上是一种简约语言。这转化为更简单、错误更少的代码。

  • 语言稳定性—Go 语言提供了强大的兼容性保证,设计团队试图限制新添加的功能的数量和影响。

  • 内存安全—Go 语言防止不安全的内存访问,这是在具有指针运算的语言(如 C 和 C++)中常见的错误和漏洞来源。

  • 静态类型—编译时类型安全性检查捕获了许多在动态类型语言中否则可能被忽视的常见错误。

  • go vet

性能

Go 是一种高性能的语言。计算机语言基准测试游戏进一步阅读)显示,其性能与手动内存管理的语言(如 C/C++ 和 Rust)相似,并且它提供了比动态类型语言(如 Python 和 Ruby)更好的性能。

它原生支持多核多线程 中央处理器CPU)架构,允许其扩展到单个线程以上,并优化 CPU 缓存的利用。

Go 的内置 垃圾回收器帮助您保持程序的低内存占用,Go 的显式类型声明优化了内存管理和值的存储。

Go 运行时为您提供分析数据,您可以使用 pprof 进行可视化,以帮助您查找程序中的内存泄漏或瓶颈,并微调您的代码以实现更好的性能和优化资源利用。

关于这个主题的更多细节,我们建议查看 Dave Cheney 的 五个使 Go 运行快的因素 博客文章(进一步阅读)。

跨平台编译

Go 可以原生为不同的目标架构和操作系统生成二进制文件。在撰写本文时,go tool dist list 命令返回了 45 种独特的组合,操作系统范围从 Android 到 Windows,指令集从 PowerPCARM。您可以使用 GOOSGOARCH 环境变量更改从底层操作系统和架构继承的默认值。

无论您目前使用的是哪个操作系统,您都可以构建一个用 Go 编写的、操作系统本地的版本,如下面的代码片段所示:

ch01/hello-world$ GOOS=windows GOARCH=amd64 go build
ch01/hello-world$ ls hello-world*
hello-world.exe

上述输出显示了在 Linux 机器上创建 Windows 可执行文件的示例。

可读性

这可以说是与 C 或 C++ 等其他高性能语言相比,Go 的最佳特性之一。Go 编程语言规范(进一步阅读)相对较短,大约有 90 页(而其他语言的规范可能超过 1,000 页)。它只包含 25 个关键字,只有一个用于循环(for)。功能数量有意降低,以帮助代码清晰,并防止人们开发出过多的语言习惯或最佳实践。

代码格式化在其他语言中是一个活跃的战场,而 Go 通过将自动的、有偏见的格式化作为 go 命令的一部分来防止了这个问题。对任何未格式化(但语法正确)的代码运行一次 go fmt,就会用正确数量的缩进和换行更新源文件。这样,所有 Go 程序都有相似的外观,通过减少代码中的个人风格偏好数量来提高可读性。

有些人可能会说,仅显式类型声明就能提高代码可读性,但 Go 通过将注释作为代码文档的组成部分进一步推进了这一点。任何函数、类型或变量声明之前的所有注释行都会被 go doc 工具网站(进一步阅读)或 IDE 解析,以自动生成代码文档,如下面的截图所示:

图 1.2 – 自动代码文档

图 1.2 – 自动代码文档

大多数现代集成开发环境(IDE)都提供了插件,不仅支持文档,还支持使用 go fmt 进行自动代码格式化、代码检查和自动补全、调试以及语言服务器——一个允许开发者通过在类型、变量和函数声明及其引用之间来回导航来遍历代码的工具(gopls,Go 语言服务器:进一步阅读)。这个最后的功能不仅允许你无需手动解决导入语句或搜索文本中的字符串模式就能导航任何复杂性的代码库,而且在你编译程序之前即时突出显示任何类型的不一致性。

工具集

当设置新环境时,一个典型的开发者会做的第一件事就是下载并安装他们最喜欢的语言工具和库,以帮助进行测试、格式化、依赖管理等等。Go 默认包含了所有这些实用工具,它们是 go 命令的一部分。以下表格总结了某些 Go 内置工具及其用途:

表 1.1 – Go 工具

表 1.1 – Go 工具

这些只是与 Go 二进制文件一起提供的最流行的工具中的一小部分。这无疑减少了工具生态系统中的创造性空间,因为它为开发者提供了一个足够好的默认选择,适用于大多数平均用例。这种人为稀缺的另一个好处是,每次在不同 Go 项目之间切换时,无需重新安装和重新学习一套新工具。

Go 语言用于网络

一些网络自动化流程可以触发数百甚至数千个同时连接到网络设备。能够大规模地协调这些操作是 Go 语言使我们能够做到的事情之一。

你可以在以下屏幕截图中看到 Egon Elbre 的 网络松鼠 图标:

图 1.3 – 网络松鼠,由 Egon Elbre 创作

图 1.3 – 网络松鼠,由 Egon Elbre 创作

Go 语言自带强大的网络包,提供了创建网络连接的所有构造,用于从流行格式编码和解码数据的包,以及用于处理位和字节的原始数据类型。

并发

Go 语言通过 Go 运行时管理的轻量级线程(称为 goroutines)提供了第一级的并发支持。这种语言结构使得将异步函数嵌入到其他情况下顺序执行的程序中成为可能。

任何以 go 关键字开头的前缀函数调用都在一个单独的 goroutine 中运行——与主应用程序 goroutine 不同——它不会阻塞调用程序的执行。

Channels 是另一种语言特性,允许 goroutines 之间的通信。你可以将其视为一个 先进先出FIFO)队列,发送和接收端存在于两个不同的 goroutines 中。

这两个强大的语言结构共同提供了一种以安全且统一的方式编写并发代码的方法,允许你同时连接到各种网络设备,而无需为每个设备运行一个操作系统线程。例如,考虑以下代码示例中的程序(github.com/PacktPublishing/Network-Automation-with-Go/blob/main/ch01/concurrency/main.go),该程序模拟与远程网络设备的交互:

func main() {
    devices := []string{"leaf01", "leaf02", "spine01"}
    resultCh := make(chan string, len(devices))
    go connect(devices, resultCh)
    fmt.Println("Continuing execution") 
    for msg := range resultCh {
        fmt.Println(msg)
    }
}

连接到远程设备可能需要很长时间,通常这会阻塞程序其他部分的执行。通过在 goroutine 中运行 connect 函数,如以下代码片段所示,我们的程序可以继续执行,我们可以在未来的任何时刻返回并收集响应:

ch01/concurrency$  go run main.go 
Continuing execution
Connected to device "leaf01"
Connected to device "spine01"
Connected to device "leaf02"

当远程设备处理请求并返回响应时,我们的程序开始按照接收到的顺序打印响应。

强大的标准库

Go 拥有一个功能丰富的标准库,涵盖了可能适用于网络的不同领域——从密码学到数据编码,从字符串操作到 netencoding 提供了客户端和服务器端网络交互的接口,包括以下内容:

  • 互联网协议 (IP) 前缀解析和比较函数

  • IP、传输控制协议/用户数据报协议 (TCP/UDP) 和 超文本传输协议 (HTTP) 连接的客户端和服务器实现

  • 域名系统 (DNS) 查找函数

  • 统一资源定位符 (URL) 解析和操作

  • 将数据格式序列化为 可扩展标记语言 (XML)、二进制和 JavaScript 对象表示法 (JSON) 以进行存储或传输

除非你有独特的性能要求,例如,大多数 Go 开发者不建议使用外部库来实现可以用标准库本地实现的逻辑。所有标准包在每个版本中都经过彻底测试,并在多个大型项目中广泛使用。所有这些为新用户提供更好的学习体验,因为最常用的数据结构和函数已经存在。

数据流

网络服务通常都是 I/O 密集型的——它们从网络读取或写入字节。这种操作模式是 Go 中数据流式传输的工作方式,这使得它对熟悉网络协议解析字节处理的网络工程师具有吸引力。

Go 中的 I/O 操作遵循一种模型,其中 Reader 从源读取数据,该数据可以作为一个字节数组流式传输到 Writer,而 Writer 将数据写入目标。以下图表应该能更清晰地展示这层含义:

图 1.4 – 从网络连接到文件流式传输示例

图 1.4 – 从网络连接到文件流式传输示例

Reader 是一个接口,可以从文件、加密、shell 命令或网络连接等读取。然后,您可以将捕获到的数据流式传输到 Writer 接口,该接口也可以是文件或大多数其他 Reader 示例。

Go 标准库提供了这些流式接口,例如 net.Conn,在这种情况下,允许您从网络连接中读取和写入,在接口之间传输数据,并在需要时转换这些数据。我们将在 第三章Go 入门 中更详细地介绍这个主题。

当选择编程语言时,除了考虑公司目前使用的是哪种语言或哪种语言让你感到更舒适等变量外,我们的目标是为你提供所有资源,让你了解是什么让 Go 对大规模系统开发者如此有吸引力。如果你想从熟悉的地方开始,我们将接下来比较和对比 Go 与 Python。Python 是目前用于网络自动化最受欢迎的编程语言。

Go 与 Python 的比较

比较编程语言的议题可以迅速演变成一场激烈的辩论。我们相信所有语言都有其优点,我们不想鼓吹一种语言比另一种更好。然而,我们确实承认,大多数拥有网络自动化背景的人都会知道并使用 Python,因此,展示两种语言之间的某种形式的比较,并突出它们最显著的特点是有意义的。

代码执行

影响开发者体验的最大差异之一是如何分发和执行你的代码。

Python 程序需要在目标机器上运行解释器,并访问所有库依赖项。虽然有一些像 Nuitka 这样的项目可以将 Python 编译成,但你需要商业支持来混淆你的源代码,例如。拥有所有源代码可以让你在开发功能或调试错误时快速进行更改和迭代。

Go 程序不需要解释器,因为你可以将它们作为编译的二进制文件分发。将代码编译成机器代码可能看起来像是一个不必要的障碍,但编译只需要几秒钟,生成的二进制文件包含所有所需的依赖项,因此它是唯一需要在目标系统上存在的文件。

类型系统

Go 要求所有变量类型都必须在静态定义,只有在初始变量声明期间才允许类型推断。

虽然泛型正在进入 Go,但它们并不像 Python 类型系统那样提供相同的自由度。缺乏显式类型声明使得 Python 对于初学者以及开发速度比代码健壮性更重要的用例来说是一个更易接近的语言。然而,随着 Python 项目的日益成熟,它们必须通过更多地关注测试来弥补这些初始的收益。

性能

与 Python 相比,Go 程序在广泛的用例中表现更好(参见The Computer Language Benchmarks Game进一步阅读)。这在一定程度上是我们已经在本节中提到的观点的结果,但也是 Go 团队投入优化语言的成果。

尽管 goroutines 和类型定义为 Go 开发者提供了足够的工具来编写高性能代码,但每个 Go 版本都会带来新的内存管理和编译器优化改进,使代码在后台执行更快。

易用性

Python 是一种设计用于教学和原型设计的语言。同时,它足够灵活和强大,可以编写复杂的程序,例如网络服务器(Flask、Django)、机器学习ML)框架(PyTorch、TensorFlow)和基础设施软件(RabbitMQ、Ansible)。

随着你参与的 Python 项目的数量增加,维护不同的虚拟环境以进行依赖和环境管理可能会变得麻烦。这是 Go 闪耀的地方,它拥有自托管的依赖管理器和静态链接的二进制文件。

尽管如此,Python 仍然保持着作为最具亲和力的语言的主导地位,拥有庞大的开源社区,并且不太可能在短期内放弃这一地位。

内存管理

两种语言都使用动态内存管理,并具有自动垃圾回收功能。大多数时候,你不需要也不建议更改任何默认设置,尽管两种语言都公开了一些阈值变量,如果需要可以对其进行微调。

最大的区别来自于 Go 根据一组更精确的数据类型来分配内存,并且它在编译时在栈中为 goroutines 和函数进行静态内存分配,只有一小部分变量逃逸到堆中。相比之下,Python 将所有内容都视为对象,即使是像 intstring 这样最原始的类型,也相当大,它们在运行时(在堆中)动态分配内存。

访问堆中的内存不仅速度较慢,还需要进行垃圾回收,这给程序执行增加了开销。

语法

Python 语法非常轻量级,使用缩进来区分不同的代码块。没有尾随的分号和过多的花括号使得代码易于理解,但如果没有使用集成开发环境(IDE)——它会自动管理缩进——编写代码可能会是一个挑战。

Go 从未考虑空白字符用于缩进,因为语言设计者不相信让语义依赖于不可见字符是一个好主意。当然,这取决于个人偏好;例如,YAML Ain't Markup LanguageYAML)这样的格式也使用空格来结构化数据。

Go 从其内置的格式化工具中受益,该工具自动缩进代码,并通过在特定位置自动插入空白行使代码看起来整洁。此外,Go 开发者使用空白行来逻辑上分隔函数中的一组行,这使得最终程序更稀疏且易于阅读。

故障处理

另一个很大的区别在于错误处理。Python 通过依赖异常作为惯例来使用隐式错误处理,这些异常可以在你预期它们发生的代码部分被小心捕获。这符合 Python 的可读性和易用性。Go 使用显式错误检查,并且大多数函数将错误作为最后一个位置返回值。这通常会导致代码看起来像这样:

config, err := buildConfig(deviceName)
if err != nil {
   return err
}

d, err := connect(deviceName)
if err != nil {
   return err
}

if err := configure(d, config); err != nil {
   return err
}

尽管这通过迫使开发者始终考虑返回的错误并在发生时立即采取行动,使程序更加健壮,但这确实会产生大量的视觉噪音,人类大脑很快就会学会忽略它。这是 Go 社区反复讨论的话题之一,也是 Go 2 版本重点关注的领域之一。Go 2 错误处理草案设计文档详细介绍了问题和建议(进一步阅读)。

并发

并发不仅自 Go 诞生以来就是 Go 的一个特性,也是 Go 创造背后的关键驱动力之一。Go 有足够的顶级语言结构来处理大多数常见的并发挑战,例如进程间的通信和对共享资源的访问。

与之相反,你无法同时运行两个或更多的 Python 线程,因为 全局解释器锁 (GIL) 阻止了这一点,Python 语言设计者在早期就将它作为语言的一部分。除非你设计你的程序来使用线程库。GIL 对单线程程序有性能上的好处,而移除它一直是 Python 社区反复讨论的话题。

为了实现并发,Python 让你运行多个进程来利用你拥有的所有 CPU(多进程或并发池)。随着时间的推移,不同的库试图改进性能和 asyncio

尽管如此,更好的并发性和并行性是 Python 开发者调查 2020 年结果中排名前三的最希望添加的功能之一(进一步阅读)。大多数 Python 开发者不喜欢当前的实现,因为在 Python 中编写并发代码可能具有挑战性,并且需要使用兼容的库。

社区

作为两种语言中更受欢迎的一种,Python 拥有一个更大的社区,拥有大量的开源库和框架。尽管其主要用例是数据分析、Web 开发和机器学习(2020 年 Python 开发者调查结果进一步阅读),但今天你可以找到处理从游戏开发到桌面插件等任何内容的库。

最重要的是,Python 是网络自动化的最流行语言,积累了大量用于与网络设备一起工作的库和框架。Go 更注重系统和性能,所以我们看不到太多的网络库和工具。尽管如此,网络工程社区中 Go 的一个主要用户是 OpenConfig 生态系统,它今天包括几乎十多个用 Go 编写的不同项目。

Go 正在被 Web 规模的公司迅速采用,这意味着我们很可能会在未来看到更多与网络相关的项目出现。

我们希望这能给你一个对 Go 语言特性的视角和欣赏。下一步是在你的计算机上安装 Go。

在你的计算机上安装 Go

Go 的下载和安装说明(golang.org/doc/install#install)要求你从go.dev/下载一个文件并遵循一些说明。我们在此包括Go 版本 17.7的步骤,这是撰写时的最新版本。Go 1 的新版本应该继续工作。

Windows

要在 Windows 上安装 Go,请按照以下步骤操作:

  1. 下载 golang.org/dl/go1.17.7.windows-amd64.msi.

  2. 执行go1.17.7.windows-amd64.msi文件并按照说明操作。

  3. 打开命令提示符(cmd)并运行go version以验证安装。

Mac

如果你已经安装了 Homebrew,你可以运行brew install go。否则,你可以按照以下步骤操作:

  1. 下载 golang.org/dl/go1.17.7.darwin-amd64.pkg

  2. 执行go1.17.7.darwin-amd64.pkg文件并按照说明操作。

  3. 打开终端并运行go version以验证安装。

Linux

Go 通常作为 Linux 发行版中的系统包提供,但通常是较旧版本。按照以下步骤安装较新版本:

  1. 下载 golang.org/dl/go1.17.7.linux-amd64.tar.gz.

  2. 使用rm -rf /usr/local/go删除任何现有的 Go 安装。

  3. 使用tar -C /usr/local -xzf go1.17.7.linux-amd64.tar.gz将下载的存档解压到/usr/local

  4. 使用export PATH=$PATH:/usr/local/go/bin/usr/local/go/bin添加到PATH环境变量中。为了使其持久化,请将此行也添加到$HOME/.bash_profile中。最后一部分对bash有效,但如果你使用不同的 shell,你可能需要做类似的事情。

  5. 运行go version以验证安装

就这样!你现在可以在系统中下载和安装 Go 而无需任何麻烦。要安装不同版本,只需将说明中的17.7替换为你选择的版本号。

摘要

在本章中,我们回顾了为什么 Go 对于网络和网络自动化很重要。我们探讨了 Go 的各个方面,使其成为数百万开发者的首选选择。我们还探讨了如何在你的计算机上安装它。在下一章中,我们将更深入地探讨 Go 编程语言、其源文件及其工具。

进一步阅读

你可以参考以下资源进行进一步阅读:

第二章:Go 基础

在如此多的编程语言中,公平地怀疑为什么有人必须发明另一种语言是有道理的。Go 背后的人的背景以及他们试图用这种新语言解决的问题是我们将在本章中讨论的一些事项。

这些主题为我们提供了对大型软件开发对软件开发者今天所面临的挑战的某些看法,以及为什么现代技术,如编程语言,始终在不断发展。

到本章结束时,你应该对 Go 的来源及其在多核处理器上运行的分布式系统开发中的作用有更好的理解,并且在我们通过以下区域时,应该熟悉 Go 的源代码结构:

  • 什么是 Go?

  • Go 的指导原则

  • Go 源代码文件结构

  • Go 包和模块

  • 编译 Go 程序

  • 在线运行 Go 程序

  • 探索 Go 工具以管理 Go 源代码

技术要求

我们假设您对命令行、Git 和 GitHub 有基本的了解。您可以在本书的 GitHub 仓库中找到本章的代码示例,github.com/PacktPublishing/Network-Automation-with-Go,在 ch02 文件夹中。

要运行示例,请按照以下步骤操作:

  1. 为您的操作系统安装 Go 1.17 或更高版本。您可以在计算机上的 安装 Go 部分的 第一章(B16971_01.xhtml#_idTextAnchor015)简介中的说明中找到,或者访问 go.dev/doc/install

  2. 使用 git clonegithub.com/PacktPublishing/Network-Automation-with-Go.git 上克隆本书的 GitHub 仓库。

  3. 将目录更改为示例文件夹 – cd Network-Automation-with-Go/ch02/pong

  4. 执行 go run main.go

什么是 Go?

在 2007 年下半年,罗伯特·格里泽默罗布·派克肯·汤普森 开始讨论设计一种新的编程语言,这种语言将解决他们在 Google 编写软件时遇到的一些问题,例如使用某些语言的复杂性增加、长的代码编译时间以及无法在多处理器计算机上高效编程。

罗布·派克 正在尝试将一些并发和通信通道的想法带入 C++,这是基于他在 1988 年对 Newsqueak 语言早期工作的成果,正如他在 Go: 十年攀登进一步阅读)和 少即是多进一步阅读)中所描述的。这证明太难实现了。他将与 罗伯特·格里泽默肯·汤普森 在同一个办公室工作。肯曾与罗布·派克合作创建字符编码 UTF-8,而 肯·汤普森 设计并实现了 Unix 操作系统,并发明了 B 编程语言(C 编程语言的前身)。

他们选择Go这个名字给这种新的编程语言,因为它简短,但go.com的 DNS 条目不可用,所以 Go 的网站最终在golang.org。因此,“golang”成为了 Go 的昵称。虽然 golang 对搜索查询来说很方便,但它并不是语言的名字(语言的名字是 Go):

图 2.1 – 初始 Go 讨论邮件线程

图 2.1 – 初始 Go 讨论邮件线程

尽管他们最初认为 C/C++是起点,但他们最终从头开始定义了一种更具有表现力的语言,尽管与前辈相比有很多简化。Go 从 C 继承了一些东西,例如,但不仅限于基本数据类型、表达式语法、指针和编译成机器代码,但它没有以下这些:

  • 头文件

  • 异常

  • 指针算术

  • 子类型继承(没有子类)

  • 方法中的this

  • 向超类提升(它使用嵌入代替)

  • 循环依赖

Pascal、Oberon 和 Newsqueak 是影响 Go 的编程语言之一。特别是,它的并发模型来自托尼·霍尔通信顺序过程CSPs)(进一步阅读)白皮书,以及 CSP 在罗布·派克的解析语言 Newsqueak 中的实现,以及后来的 Phil Winterbottom 的类似 C 的编译版本,Alef。下一张图显示了 Go 的家族树:

图 2.2 – Go 的祖先

图 2.2 – Go 的祖先

与 Go 的创始人预期的相比,来到 Go 的 C++程序员数量只是寥寥无几。大多数 Go 程序员实际上来自 Python 和 Ruby 等语言。

Go 于 2009 年 11 月 10 日成为开源项目。他们把 Go 的源代码托管在go.googlesource.com/go,并在github.com/golang/go上保留代码镜像,你可以在这里提交 pull 请求。虽然 Go 是一种开源编程语言,但它实际上由 Google 支持。

他们最初用 C 编写了第一个 Go 编译器,但后来将其转换为 Go。Russ Cox 在 Go 1.3+编译器重整(进一步阅读)中详细描述了这一点。虽然听起来可能令人难以置信,但今天的 Go 源代码是用 Go 编写的。

他们于 2012 年 3 月 28 日发布了 Go 1。在下一张图中的 Go 时间线总结版本中,我们强调了自那时以来语言的一些显著变化:

图 2.3 – Go 的简要时间线

图 2.3 – Go 的简要时间线

Go 是一种稳定的语言,其语义不应该改变,除非发生 Go 2。截至目前,Go 团队唯一确认的改变是在 2022 年初(Go 1.18)添加了泛型编程,使用类型参数,如类型参数提案(进一步阅读)所述。

Go 是一种编程语言,它试图将动态类型语言的编程便捷性与静态类型语言的效率和安全性结合起来。它可以在几秒钟内构建可执行文件,并且由于 Go 对并发的一流支持,我们可以充分利用多核 CPU。

在我们深入 Go 代码之前,我们通过 Go 谚语来介绍一些指导原则,这些原则使 Go 独具特色。

Go 谚语

Rob Pike2015 年Gopherfest 上介绍了 Go 语言谚语,以哲学的角度解释或教授 Go。这些是 Go 开发者倾向于遵循的一般性指导原则。这些谚语中的大多数都是良好的实践——但并非强制——传达了语言的精神。

我们在这里只包括我们最喜欢的谚语。你可以查看完整的列表,在 Go 谚语 (进一步阅读):

  • Gofmt 的风格并不是每个人都喜欢的,但 gofmt 是每个人的最爱。当你用 Go 编写代码时,你不必担心空格与制表符的争论,或者在哪里放置括号或花括号。Gofmt(gofmt)使用规定性的风格指南格式化你的代码,所以所有的 Go 代码看起来都一样。这样,当你编写或阅读 Go 代码时,你不必去考虑它:

  • 清晰胜于巧妙:Go 倾向于清晰易懂的代码,而不是难以分析或描述的巧妙代码。编写其他人可以阅读且能理解的代码。

  • 错误是值:在 Go 中,错误不是一个异常。它是在你的程序逻辑中可以使用的值——例如,作为一个变量。

  • 不仅要检查错误,还要优雅地处理它们:Go 鼓励你思考是否应该对错误进行处理,而不仅仅是返回它并忘记它。根据错误,你可能可以触发不同的执行路径,添加更多信息,或者为以后保存它。

  • 少量复制胜于少量依赖:如果你只需要从库中获取几行代码,也许你可以直接复制这些行,而不是导入整个库来控制你的依赖树,并使你的代码更加紧凑。这样,你的程序不仅编译得更快,而且更易于管理,也更易于理解。

  • 不要通过共享内存来通信;通过通信来共享内存:这描述了 Go 中并发进程之间如何相互协调。在其他语言中,并发进程通过共享内存来通信,你必须使用锁来保护它,以防止当这些进程尝试并发访问内存位置时发生数据竞争条件。相比之下,Go 使用通道在进程之间传递数据的引用,因此一次只有一个进程可以访问数据。

  • 并发不是并行:并发是对独立进程执行的架构,其指令不一定按顺序执行。这些指令是否并行运行取决于不同 CPU 核心或硬件线程的可用性。Rob Pike并发不是并行进一步阅读)演讲是 Go 开发者的必读。

Go 谚语涵盖了 Go 的不同方面,从格式化 Go 代码到 Go 如何实现并发。

现在,让我们卷起袖子,开始查看 Go 源代码文件。

Go 源代码文件

虽然 Go 源代码文件没有文件名约定,但它们的文件名通常是单词,全部小写,如果包含多个单词,则包含一个下划线。它以 .go 后缀结尾。

每个文件有三个部分:

  • 包声明:这定义了文件所属的包名。

  • 进口声明:这是一个需要导入的包列表。

  • constvartypefunc):

// package clause
package main
// import declaration
import "fmt"
// top level declaration
const s = "Hello, 世界"
func main() {
    fmt.Println(s)
}

代码示例显示了 main 包的包声明在顶部。它遵循导入声明,其中我们指定在这个文件中使用 fmt 包。然后,我们包括代码中的所有声明——在这种情况下,一个 s 常量和 main 函数。

包是同一文件夹中的一个或多个 .go 文件,它声明了相关的常量、类型、变量和函数。这些声明对同一包中的每个文件都是可访问的,因此将代码分解到不同的文件是可选的。这更多的是个人偏好,如何更好地组织代码。

在标准库中,他们将代码分成单独的文件,用于更大的包。encoding/base64 包有一个 .go 文件(除了测试和示例文件),例如以下内容:

$ ls -1 /usr/local/go/src/encoding/base64/ | grep -v _test.go
base64.go

相比之下,encoding/json 包有九个 .go 源代码文件:

$ ls -1 /usr/local/go/src/encoding/json/ | grep -v _test.go
decode.go
encode.go
fold.go
fuzz.go
indent.go
scanner.go
stream.go
tables.go
tags.go

包名简短且具有意义(没有下划线)。包的用户在从它导入内容时引用包名——例如,Decode 方法存在于 jsonxml 包中。用户可以使用 json.Decodexml.Decode 分别调用这些方法。

有一个特殊的包是 main。这是任何导入其他包的程序入口点。此包必须有一个不带参数且不返回任何值的 main 函数,例如本节开头的代码示例。

Go 模块

Go 模块在 Go 1.16 中成为发布包的默认方式。它们最初在 2018 年的 Go 1.11 中引入,目的是改善 Go 的依赖管理。它允许你定义包或包集合的导入路径和依赖项。

让我们定义一个名为 ping 的小包,它有一个返回包含单词 pong 的字符串的 Send 函数:

package ping
func Send() string {
    return "pong"
}

这是书中 GitHub 仓库中的 github.com/PacktPublishing/Network-Automation-with-Go/blob/main/ch02/ping/code.go 文件。您可以使用 go mod init 命令在此示例的根目录(ch02/ping)中为该包创建一个模块。此命令的参数应该是模块位置,用户可以通过此位置访问它。结果是包含导入路径和外部包依赖列表的 go.mod 文件:

ch02/ping$ go mod init github.com/PacktPublishing/Network-Automation-with-Go/ch02/ping
go: creating new go.mod: module github.com/PacktPublishing/Network-Automation-with-Go/ch02/ping

这样,任何人现在都可以导入这个包。以下程序将其导入到 pong 输出中:

package main
import (
    "fmt"
    "github.com/PacktPublishing/Network-Automation-with-Go/ch02/ping"
)
func main() {
    s := ping.Send()
    fmt.Println(s)
}

你可以从 Go Playground(进一步阅读)运行此程序,它导入了我们刚刚创建的模块。这也是过渡到下一节关于包导入的绝佳方式,也是对我们将在接下来的几页中涵盖的 Go Playground 部分的预览。

导入包

import 关键字列出了源文件中要导入的包。导入路径是模块路径,后跟包在模块中的文件夹位置,除非包位于标准库中,在这种情况下,你只需要引用目录。让我们检查每种情况的一个示例。

以为例,google.golang.org/grpc 模块在 credentials 文件夹中有一个包。你会用 google.golang.org/grpc/credentials 来导入它。路径的最后一部分是你如何为包类型和函数添加前缀,在下一个代码示例中分别是 credentials.TransportCredentialscredentials.NewClientTLSFromFile

Go 的标准库(进一步阅读)位于 go/src,是 std 模块的一系列包集合。fmt 文件夹包含实现输入输出格式化功能的包。导入此包的路径仅为 fmt

package xrgrpc
import (
    "fmt"
    /* ... <omitted for brevity > ... */
    "google.golang.org/grpc/credentials"
)
func newClientTLS(c client) (credentials.TransportCredentials, error) {
    if c.Cert != "" {
                return credentials.NewClientTLSFromFile(...)
    }
    /* ... <omitted for brevity > ... */
    fmt.Printf("%s", 'test')
    /* ... <omitted for brevity > ... */
}

包并不存储在像 mavenpipnpm 这样的中央仓库中。你可以通过将其上传到版本控制系统来共享你的代码,并通过共享其位置来分发它。用户可以使用 go 命令(go installgo get)下载它。

为了开发和测试目的,你可以在 go.mod 文件中通过指向它们的本地路径来引用本地包:

module github.com/PacktPublishing/Network-Automation-with-Go/ch02/pong

go 1.17

require github.com/PacktPublishing/Network-Automation-with-Go/ch02/ping v0.0.0-20220223180011-2e4e63479343

replace github.com/PacktPublishing/Network-Automation-with-Go/ch02/ping v1.0.0 => ../ping

ch02/pong 示例中,Go 工具为我们自动创建了 go.mod 文件的前三行,引用了来自书中 GitHub 仓库的 ping 模块(进一步阅读)。我们后来添加了第四行来替换该模块,用其本地版本的内容(../ping)。

注释

Go 中的代码注释扮演着关键角色,因为它们成为了你的包文档。go doc 工具将你在一个包中导出的类型、常量、函数或方法之前的注释作为该声明的文档字符串,生成一个工具以网页形式展示的 HTML 文件。

以为例,所有公共 Go 包(进一步阅读)都显示此自动生成的文档。

Go 提供了两种创建注释的方法:

  • C++ 风格的 // 行注释,这是最常见的形式:

    // IsPrivate reports whether ip is a private address, according to
    
    // RFC 1918 (IPv4 addresses) and RFC 4193 (IPv6 addresses).
    
    func (ip IP) IsPrivate() bool {
    
        if ip4 := ip.To4(); ip4 != nil {
    
            return ip4[0] == 10 ||
    
                (ip4[0] == 172 && ip4[1]&0xf0 == 16) ||
    
                (ip4[0] == 192 && ip4[1] == 168)
    
        }
    
        return len(ip) == IPv6len && ip[0]&0xfe == 0xfc
    
    }
    
  • C 风格的 /* */ 块注释,主要用于包描述或大块格式化/缩进的代码:

    /*
    
    Copyright 2014 The Kubernetes Authors.
    
    Licensed under the Apache License, Version 2.0 (the "License");
    
    ...
    
    See the License for the specific language governing permissions and
    
    limitations under the License.
    
    */
    
    package kubectl
    

Practical Go: Real-world advice for writing maintainable Go programs (进一步阅读) 一书中,Dave Cheney 建议,代码注释应该解释这三者之一 - 并且仅解释一个:

  • 它做了什么

  • 事物是如何做到它的

  • 为什么是为什么

一个好的做法是对描述其内容的变量进行注释,而不是其目的。您可以使用变量的名称来描述其目的。这让我们想到了命名风格。

名称

在 Go 中声明名称的约定是使用驼峰式命名法(MixedCaps 或 mixedCaps),而不是使用破折号或下划线,例如,当您使用多个单词作为函数或变量的名称时。规则的一个例外是具有一致大写的缩写,例如 ServeHTTP 而不是 ServeHttp

package net
// IsMulticast reports whether ip is a multicast address.
func (ip IP) IsMulticast() bool {
     if ip4 := ip.To4(); ip4 != nil {
         return ip4[0]&0xf0 == 0xe0
     }
     return len(ip) == IPv6len && ip[0] == 0xff
}

名称的第一个字母决定了包是否导出这个顶级声明。包导出以大写字母开头的名称。这些名称是外部用户在导入包时可以引用的唯一名称 - 例如,您可以在另一个包中引用前面的代码示例中的 IsMulticast,作为 net.IsMulticast

package net
func allFF(b []byte) bool {
     for _, c := range b {
          if c != 0xff {
                 return false
          }
     }
     return true
}

如果第一个字母是小写的,则没有其他包可以访问此资源。包可以有仅用于内部消费的声明。最后代码示例中的 allFF 函数来自 net 包。这意味着只有 net 包中的函数可以调用 allFF 函数。

例如,Java 和 C++ 等语言有 publicprivate 等显式关键字来控制对类型和方法的访问。Python 遵循使用单个下划线前缀为内部使用变量或方法命名的约定。在 Go 中,您可以从包内的任何源代码文件访问以小写字母开头的任何变量或方法,但不能从另一个包访问。

执行您的 Go 代码

Go 编译器将 Go 程序转换为机器代码,生成二进制文件。除了您的程序外,二进制文件还包括 Go 运行时,它提供垃圾回收和并发等服务。能够访问适用于不同平台的二进制文件使得 Go 程序非常便携。

让我们使用 go build 命令编译书籍 GitHub 仓库中的 github.com/PacktPublishing/Network-Automation-with-Go/blob/main/ch02/pong/code.go 文件。您也可以使用 time 命令来计时此操作,看看 Go 编译器实际上有多快:

ch02/pong$ time go build
real  0m0.154s
user  0m0.190s
sys   0m0.070s

现在,您可以执行二进制文件。默认文件名是包名,pong。您可以使用 go build 命令的 -o 选项更改文件名。关于这一点,将在 Go 工具 部分进行更多介绍:

ch02/pong$ ./pong
pong

如果您不想生成二进制或可执行文件,只想运行代码,您可以使用 go run 命令:

ch02/pong$ go run main.go 
pong

任何一种选择都行,这可能取决于个人偏好或者您是否打算与他人共享编译后的工件或将它部署到服务器上。

Go 文件有三个主要部分,并且被组织成包和模块。

安装 Go 后,您可以在您的电脑上运行所有示例,或者像我们在下一节讨论的那样在线运行它们。

在线运行 Go 程序

有时候,您需要快速测试一些代码或者只是想与可能没有在电脑上安装 Go 的人分享一个代码示例。在这些情况下,至少有三个网站您可以免费运行和分享 Go 代码:

  • Go 操场

  • Go 操场

  • Gotip 操场

它们共享后端基础设施,但有一些细微的差别。

Go 操场

Go 团队运行 Go 操场 (play.golang.org/) 在 golang.org 的服务器上。他们在文章 Go 操场的内部 (进一步阅读) 中分享了一些见解和其架构,但最近,布拉德·菲茨帕特里克 分享了最新版本的 Go 操场的历史和实现细节 (进一步阅读)。

此服务接收您的程序,在沙盒中运行它,并返回其输出。如果您在手机上,例如,并想验证函数或其它内容的语法,这非常方便。

图 2.4 – Go 操场

图 2.4 – Go 操场

如果您对如何构建此服务感兴趣或者想在您的环境中本地运行它,请确保查看 Playground 源代码 (进一步阅读)。

Go 操场空间

如果您不能没有语法高亮,请访问 Go 操场 (进一步阅读)。这是一个实验性的替代 Go 操场前端。它们将代码执行代理到官方的 Go 操场,以便程序工作相同。它们还存储共享片段在 golang.org 服务器上:

图 2.5 – Go 操场

图 2.5 – Go 操场

图 2.5 展示了 Go 操场除了语法高亮之外的一些额外功能,例如自动关闭括号、访问文档和不同的 UI 主题。

图 2.6 – 在 Go 操场中建造房屋

图 2.6 – 在 Go 操场中建造房屋

我们不能忽略它还有一个海龟图形模式来帮助您以娱乐的方式可视化算法,例如展示在 图 2.6 中,一只 gopher 建造房屋。

展望未来

Gotip 操场也在 golang.org 的服务器上运行。这个 Go 操场的实例运行 Go 的最新开发分支。您可以使用它来测试正在积极开发中的即将推出的功能,例如在类型参数提案(进一步阅读)中描述的语法或新的 net/netip 包,而无需在您的系统上安装多个 Go 版本。

图 2.7 – Gotip 操场

图 2.7 – Gotip 操场

您可以通过 gotipplay.golang.org/ 或通过在 go.dev/play/ 选择 Go 开发分支 下拉菜单来访问 Gotip 操场。

这些都是您可以在不花费任何费用的情况下在线运行 Go 程序的绝佳选择。在下一节中,我们将回到命令行工作,探索用于管理 Go 源代码的 Go 工具。

Go 工具

Go 作为一种编程语言的一个便利之处是,有一个单独的工具可以处理与源代码的所有交互和操作。当安装 Go 时,请确保 go 工具位于可搜索的操作系统路径中,这样您就可以从任何命令行终端调用它。用户体验,无论操作系统或平台架构如何,都是统一的,并且在不同机器之间移动时不需要任何定制。

集成开发环境(IDEs)也使用 go 工具来构建和运行代码,报告错误,并自动格式化 Go 源代码。go 可执行文件接受一个作为第一个参数的 动词,该动词确定将应用哪个 go 工具功能到 Go 源文件:

$ go 
Go is a tool for managing Go source code.
Usage:
     go <command> [arguments]
The commands are:
     bug         start a bug report
     build       compile packages and dependencies
     ...       
     mod         module maintenance
     run         compile and run Go program
     test        test packages
     tool        run specified go tool
     version     print Go version
     vet         report likely mistakes in packages

在本节中,我们只探索了 Go 工具功能的一个子集。您可以在 Go cmd 文档(进一步阅读)中找到完整的列表以及每个功能的详细信息。我们涵盖的命令如下:

  • build

  • run

  • mod

  • get

  • install

  • fmt

  • test

  • env

这些命令帮助您构建和运行 Go 程序,管理它们的依赖项,以及格式化和测试您的代码。

构建

我们使用 go build 命令来编译 Go 程序并生成可执行二进制文件。如果您尚未使用 Go 模块,该命令期望一个作为参数的 Go 源文件列表以进行编译。它生成一个与第一个源文件同名的二进制文件(不带 .go 后缀)。在本书 GitHub 仓库的 ch02/hello 文件夹(进一步阅读)中,我们有 main.govars.go 文件。

您可以使用 go build 命令为这些文件中的程序构建可执行文件:

ch02/hello$ go build *.go
ch02/hello$ ./main
Hello World

打包编译后的二进制文件是分发 Go 程序的常见方式,因为它允许程序的用户跳过编译阶段,将安装过程简化为仅几个命令(downloadunzip)。但是,你只能在具有相同架构和操作系统的机器上运行此二进制文件。要为其他系统生成二进制文件,你可以交叉编译到广泛的操作系统和 CPU 架构。例如,以下表格显示了一些受支持的 CPU 指令集:

表 2.1 – 一些支持的 CPU 架构

表 2.1 – 一些支持的 CPU 架构

在长长的支持操作系统列表中,下一张表格显示了最受欢迎的选项:

表 2.2 – 一些支持的 OSs

表 2.2 – 一些支持的 OSs

GOOSGOARCH环境变量允许你为任何其他支持的系统生成交叉编译的二进制文件。如果你在 Windows 机器上,你可以使用以下命令生成在 64 位 Intel 处理器上运行的 macOS 的二进制文件:

ch02/hello$ GOOS=darwin GOARCH=amd64 go build *.go

go tool dist list命令显示了 Go 编译器支持的操作系统和架构的唯一组合的完整集合:

$ go tool dist list
...
darwin/amd64
darwin/arm64
...
linux/386
linux/amd64
linux/arm
linux/arm64
...
windows/386
windows/amd64

go build命令支持不同的标志来改变其默认行为。两个最受欢迎的标志是-o-ldflags

你可以使用-o来用你偏好的名称覆盖默认的二进制文件名。在示例中,我们选择了another_name

ch02/hello$ go build -o another_name *.go
ch02/hello$ ./another_name
Hello World

要在编译时将环境数据注入到你的程序中,使用-ldflags并引用变量及其值。这样,你可以在程序执行期间访问构建信息,例如编译程序的日期或你编译它所用的源代码版本(git commit):

ch02/hello$ go build -ldflags='-X main.Version=1.0 -X main.GitCommit=600a82c442' *.go
ch02/hello$ ./main
Version: "1.0"
Git Commit: "600a82c442"
Hello World

最后一个示例是版本标记 Go 二进制文件的一种非常常见的方式。这种方法的优点是不需要修改源代码,并且你可以在持续交付管道中自动化整个流程。

Run

运行 Go 程序的另一种方式是使用go run命令。它接受与go build相同的标志,但有两个区别:

  • 它不会产生二进制文件。

  • 它在编译后立即运行程序。

go run命令最常见的使用场景是本地调试和故障排除,其中单个命令结合了编译和执行的过程:

ch02/hello$ go run {main,vars}.go
Hello World

在示例中,我们在main.govars.go文件中运行程序,这会产生Hello World输出。

Mod

随着 Go 模块的引入,go工具获得了一个额外的命令来与之交互 – go mod。为了描述其功能,让我们回顾一个典型的 Go 程序开发工作流程:

  1. 你在文件夹中创建一个新的项目,并使用go mod init命令初始化 Go 模块,引用模块名称 – go mod init example.com/my-project。这会创建一对文件,go.modgo.sum,它们跟踪你的项目依赖项。

下一个输出显示了真实项目中这两个文件的大小。go.mod 列出了所有依赖项,与包含所有依赖项校验和的 go.sum 相比,它的大小相对较小:

$ ls -1hs go.*
4.0K go.mod
 92K go.sum

如果您计划与他人共享此项目,模块的名称应该是互联网上可访问的路径。它通常指向您的源代码仓库,例如 github.com/username/my-project。一个现实生活中的例子是 github.com/gohugoio/hugo/

  1. 随着您开发代码并添加越来越多的依赖项,每当您运行 go buildgo run 命令时,go 工具会自动更新 go.modgo.sum 文件。

  2. 当您添加一个依赖项时,go 工具会在 go.mod 文件中锁定其版本,以防止意外破坏代码。如果您决定要更新到较新的次要版本,您可以使用 go get -u package@version 命令。

  3. 如果您移除了一个依赖项,您可以通过运行 go mod tidy 来清理 go.mod 文件。

  4. 这两个 go.* 文件包含了一个完整的依赖项列表,包括那些在您的代码中没有直接引用的依赖项,即间接或链式/传递依赖项。如果您想知道为什么某个特定的依赖项出现在您的 go.mod 文件中,您可以使用 go mod why packagego mod graph 命令在屏幕上打印依赖树:

    hugo$ go mod why go.opencensus.io/internal
    
    # go.opencensus.io/internal
    
    github.com/gohugoio/hugo/deploy
    
    gocloud.dev/blob
    
    gocloud.dev/internal/oc
    
    go.opencensus.io/trace
    
    go.opencensus.io/internal
    

go list 命令也可以提供帮助。它会列出所有模块依赖项:

hugo$ go list -m all | grep ^go.opencensus.io
go.opencensus.io v0.23.0

它还列出了实际的包依赖项:

hugo$ go list all | grep ^go.opencensus.io
go.opencensus.io
go.opencensus.io/internal
go.opencensus.io/internal/tagencoding
go.opencensus.io/metric/metricdata
go.opencensus.io/metric/metricproducer
go.opencensus.io/plugin/ocgrpc
...
go.opencensus.io/trace/propagation
go.opencensus.io/trace/tracestate

如果您更喜欢视觉表示,有一些项目,如 Spaghetti(进一步阅读),是一个用于 Go 包的依赖项分析工具,它可以以用户友好的界面展示这些信息,如图 图 2**.8 所示:

图 2.8 – Hugo 依赖分析

图 2.8 – Hugo 依赖分析

有一个重要的事情需要提及,那就是 Go 模块使用语义版本控制。如果您需要导入一个属于主要版本 2 或更高版本的模块中的包,您需要在它们的导入路径中包含该主要版本后缀(例如,github.com/username/my-project/v2 v2.0.0)。

在我们转到下一个命令之前,让我们为书籍 GitHub 仓库中 ch02/hello 文件夹中的示例创建一个 go.mod 文件(进一步阅读):

ch02/hello$ go mod init hello
go: creating new go.mod: module hello
go: to add module requirements and sums:
go mod tidy
ch02/hello$ go mod tidy
ch02/hello$ go build
ch02/hello$ ./hello
Hello World

现在,您可以使用 go build 命令构建程序的二进制文件,而无需引用文件夹中所有的 Go 文件(*.go)。

获取

在 Go 1.11 版本发布之前,您可以使用 go get 命令下载和安装 Go 程序。这种遗留行为从 Go 1.17 开始已被完全弃用,因此我们在这里不会涉及它。从现在起,此命令的唯一作用是管理 go.mod 文件中的依赖项,以将它们更新到较新的次要版本。

安装

编译和安装 Go 二进制文件的最简单方法是不显式下载源代码,使用go install [packages]命令。在后台,如果需要,go工具仍然会下载代码,运行go build,并将二进制文件复制到GOBIN目录,但go工具会隐藏所有这些对最终用户来说:

$ go install example.com/cmd@v1.2.3
$ go install example.com/cmd@latest

go install命令接受一个可选的版本后缀 – 例如,@latest – 如果版本缺失,则回退到本地的go.mod文件。因此,当运行go install时,建议始终指定一个版本标签,以避免如果go工具找不到本地的go.mod文件时出现错误。

格式化

Go 通过提供可以调用go fmt命令的格式化工具,将大部分代码格式化工作从开发者手中拿走,该命令指向你的 Go 源代码 – 例如,go fmt source.go

第一章引言,介绍了如何通过使所有 Go 代码看起来相似来提高代码的可读性。大多数带有 Go 插件的 IDE 在每次保存时都会自动格式化你的代码,这使得开发者少了一个需要担心的问题。

测试

在测试方面,Go 也有自己的观点。它代表开发者做出一些关于最佳代码测试组织的决定,以统一用户体验并阻止使用第三方框架:

  1. 当你运行go test命令时,它会自动执行所有文件名带有_test.go后缀的文件。此命令接受一个可选的参数,用于指定要测试的包、路径或源文件。

  2. Go 标准库包括一个特殊的testing包,它与go test命令一起工作。除了单元测试支持外,此包还提供全面的覆盖率报告和基准测试。

为了将此方法付诸实践,我们在 Go 模块部分描述的ping包中包含了一个测试程序。ping包有一个Send函数,当被调用时返回pong字符串。我们进行的测试应该验证这一点。在测试程序中,我们首先定义一个包含我们期望的值(pong)的字符串,然后将其与ping函数的结果进行比较。与ping包在同一文件夹中的code_test.go文件([github.com/PacktPublishing/Network-Automation-with-Go/blob/main/ch02/ping/code_test.go](https://sdg-tracker.org/))展示了如何用 Go 代码实现这一点:

package ping_test
import (
    "github.com/PacktPublishing/Network-Automation-with-Go/ch02/ping" 
    "testing"
)
func TestSend(t *testing.T) {
    want := "pong"
    result := ping.Send()
    if result != want {
        t.Fatalf("[%s] is incorrect, we want [%s]", result, want)
    }
}

所有测试函数都具有TestXxx(t *testing.T)签名,并且它们是否可以访问同一包中定义的任何其他函数和变量取决于你如何命名该包:

  • ping:这让你可以访问包中的所有内容。

  • _test 后缀) 可以与你要测试的包位于同一文件夹中,但它无法访问原始包的变量和方法,因此你必须像其他用户一样导入它。这是在测试包的同时记录如何使用包的有效方法。在示例中,我们使用 ping.Send 函数而不是直接使用 Send,因为我们正在导入包。

这确保了 Send 函数始终以相同的方式执行,即使他们以后必须优化代码。现在,每次你更改代码时,你都可以运行 go test 命令来验证代码是否仍然以你期望的方式运行。默认情况下,当你运行 go test 时,它会打印出它找到的每个测试函数的结果以及执行它们的时间:

ch02/ping$ go test
PASS
ok github.com/PacktPublishing/Network-Automation-with-Go/ch02/ping 0.001s

如果有人对代码进行了修改,改变了程序的行为,以至于它无法通过测试用例,那么我们面前就存在一个潜在的 bug。你可以使用 go test 命令主动识别软件问题。假设他们把 Send 函数的返回值更改为 p1ong

func Send() string {
    return "p1ong"
}

当你在下一次持续集成管道运行测试用例时,go test 命令会生成一个错误:

ch02/ping$ go test
--- FAIL: TestSend (0.00s)
  code_test.go:12: [p1ong] is incorrect, we want [pong]
FAIL
exit status 1
FAIL github.com/PacktPublishing/Network-Automation-with-Go/ch02/ping 0.001s

现在,你知道你不能将这段代码提升到生产环境。测试的好处是你可以减少用户可能遇到的软件 bug 的数量,因为你可以提前捕捉到它们。

Env

go env 命令显示 go 命令用于配置的环境变量。go 工具可以以纯文本或使用 -json 标志以 JSON 格式打印这些变量:

$ go env -json
{
    ...
    "GOPROXY": "https://proxy.golang.org,direct",
    "GOROOT": "/usr/local/go",
    ...
    "GOVERSION": "go1.17",
    "PKG_CONFIG": "pkg-config"
}

你可以使用 go env -w <NAME>=<VALUE> 来更改变量的值。下表描述了一些这些配置环境变量:

表 2.3 – 一些配置环境变量

表 2.3 – 一些配置环境变量

当你更改一个变量时,go 工具将其新值存储在由 GOENV 变量指定的路径中,默认为 ~/.config/go

$ go env -w GOBIN=$(go env GOPATH)/bin
$ cat ~/.config/go/env
GOBIN=/home/username/go/bin

上述输出示例展示了如何显式设置 GOBIN 目录以及如何验证它。

Go 提供了一个命令行工具,可以帮助你管理你的源代码,从格式化代码到执行依赖项管理。

摘要

在本章中,我们回顾了 Go 的起源和其指导原则,以及你应该如何结构化 Go 源代码文件以及如何与依赖项一起工作以运行你的 Go 程序。

在下一章中,我们将深入探讨 Go 语言的语义、变量类型、数学逻辑、控制流、函数,当然还有并发。

进一步阅读

第三章:开始使用 Go

在本章中,我们将深入了解 Go 的基础知识及其与动态类型语言可比的特性,但具有静态类型、编译语言的效率和安全性。

我们还探讨了不同的 Go 包,用于以不同格式处理数据,以及如何使用 Go 的并发模型来扩展程序。在自动化网络时,能够有效地处理数据并充分利用运行多核处理器的系统的所有资源是关键要素。

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

  • Go 变量类型

  • Go 的算术、比较和逻辑运算符

  • 控制流程

  • Go 中的函数

  • Go 中的接口

  • 输入和输出操作

  • 使用 Go 进行解码和编码

  • 并发

技术要求

我们假设您对命令行、Git 和 GitHub 有基本的了解。您可以在本书的 GitHub 仓库中找到本章的代码示例,网址为 github.com/PacktPublishing/Network-Automation-with-Go,在 ch03 文件夹下。

要运行示例,请执行以下步骤:

  1. 为您的操作系统安装 Go 1.17 或更高版本。您可以在 安装 Go 部分的 第一章 中找到说明,或访问 go.dev/doc/install

  2. 使用 git clone [github.com/PacktPublishing/Network-Automation-with-Go.git](https://github.com/PacktPublishing/Network-Automation-with-Go.git) 克隆本书的 GitHub 仓库。

  3. 将目录更改为示例文件夹:

    cd Network-Automation-with-Go/ch03/json.
    
  4. 执行 go run main.go

Go 的类型系统

Go 是一种静态类型语言,这意味着编译器必须知道所有变量的类型才能构建程序。编译器寻找特殊的变量声明签名,并分配足够的内存来存储其值:

func main() {
    var n int
    n = 42
}

默认情况下,Go 使用与其类型对应的零值初始化内存。在上面的示例中,我们声明了 n,其初始值为 0。后来我们将其赋值为 42

表 3.1 – 零值

表 3.1 – 零值

如其名所示,变量可以改变其值,但前提是它的类型保持不变。如果您尝试分配不同类型的值或重新声明变量,编译器会显示适当的错误信息。

如果我们在最后一个代码示例中添加一行 n = "Hello",程序将无法编译,并返回以下错误信息:cannot use "Hello" (type untyped string) as type int in assignment

您可以使用类型推断作为变量声明的快捷方式。在这种情况下,您在声明中省略了显式的类型参数。只需记住,Go 在函数内部对类型推断的支持有限。

你可以不显式为每个变量定义类型,而是使用一个特殊的短赋值符号:=,让编译器根据其值猜测变量类型,如下一个示例所示,编译器假设变量n的类型是int

func main() {
    n := 42
}

就像变量一样,编译器也可以推断常量的类型。常量的值在整个程序中不能改变,我们通常使用它们来表示现实世界的值,例如数字ππ)、对象的静态名称或地点等:

const Book = "Network Automation with Go"

现在,让我们更详细地看看 Go 中可用的不同类型及其常见用例。

基本类型

根据 Go 的语言规范,有四组基本或原始类型在全局范围内预声明,并且默认对所有 Go 程序可用:

  • 数值

  • 字符串

  • 布尔

  • 错误

数值

Go 定义了几个数值类型来存储不同大小的整数和实数。类型名称通常包含有关它们的符号和值大小(以位为单位)的信息。唯一的例外是intuint类型,它们的值取决于机器,通常对于 32 位 CPU 默认为 32 位,对于 64 位 CPU 架构默认为 64 位:

表 3.2 – 数值类型变量

表 3.2 – 数值类型变量

这里有一些如何实例化数值类型变量的示例。这些都是有效选项,你可以根据需要存储或生成的值范围选择最合适的选项。你可以从ch03/type-definition/main.go(在进一步阅读部分)测试这段代码。注意我们为a使用了类型推断,因此它的类型是int,在 64 位机器上大小为 8 字节。第二个变量(b)是一个无符号 32 位整数(4 字节)。最后一个变量(c)是一个浮点数(4 字节):

func main() {
    a := -1
    var b uint32
    b = 4294967295
    var c float32 = 42.1
}

你也可以使用表达式T(v)v值转换为T类型,如下一个示例所示。在这里,b是通过将整数a转换为无符号 32 位整数得到的,最后c是通过将b转换为浮点数得到的:

func main() {
    a := 4294967295
    b := uint32(a)
    c := float32(b)
}

一旦为变量定义了类型,任何新的操作都必须在赋值运算符(=)的两侧匹配此类型。你无法在前面示例的末尾添加b = int64(c),因为b将是uint32类型。

在 Go 中,类型转换总是显式的,与其他可能隐式执行此操作并有时称为类型转换的编程语言不同。

字符串

Go 支持两种字符串字面量风格:你可以用双引号括起来使其成为一个解释字面量,或者使用反引号用于原始字符串字面量,如下一个示例所示:

func main() {
    d := "interpreted\nliteral"
    e := `raw
literal`
    fmt.Println(d)
    fmt.Println(e)
}

注意d中的转义序列。Go 将其解释为在字符串中生成一个新行字符。以下是这个程序的输出,你可以在ch03/string-literals/main.go(在进一步阅读部分)找到它:

ch03/string-literals$ go run main.go
interpreted
literal
raw
literal

你可以使用 ==!= 操作符来比较字符串。你可以使用 ++= 操作符来连接字符串。ch03/string-concatenate/main.go 中的示例(在 进一步阅读 部分)展示了这些操作符的使用:

func main() {
    s1 := "Net"
    s2 := `work`
    if s1 != s2 {
        fmt.Println(s1 + s2 + " Automation")
    }
}

到目前为止,这似乎与其他编程语言没有太大区别。但在 Go 语言中,字符串实际上是一个字节数组的切片,或者更准确地说,是一个 UTF-8 Unicode 点的序列。在内存中,Go 语言将其表示为一个包含指向字符串数据和其长度的指针的两个字的结构。

让我们在 ch03/string-memory/main.go(在 进一步阅读 部分)中定义一个新的字符串 n,使用 Network Automation 字面量。我们可以使用 UTF-8 可变宽度字符编码将每个字符存储为一个或多个字节。对于英语,我们每个字符使用一个字节,所以在这种情况下,字符串字面量是 18 字节长:

func main() {
    n := "Network Automation"
    fmt.Println(len(n))
    w := n[3:7]
    fmt.Println(w)
}

我们可以将另一个字符串定义为另一个字符串的子集。为此,我们指定原始字符串中的下界和上界。索引计数从零开始,生成的字符串不包括上界索引中的字符。对于 n[3:7],我们将边界设置为字符 “w” 和 ““. 程序打印以下内容:

ch03/string-memory$ go run main.go
18
work

虽然 nw 变量引用的字符串长度不同,但它们的大小变量是相同的,就像任何其他字符串变量一样。字符串变量是一个两个字的结构。一个字通常是 32 或 64 位,这取决于 CPU 架构。两个 64 位字是 16 字节(2 x 8 字节),所以对于 64 位平台,字符串是一个 16 字节的数据结构。在这 16 字节中,8 字节是指向实际字符串数据(切片)的指针,剩下的 8 字节用于存储字符串切片的长度。图 3.1 展示了它在内存中的样子:

图 3.1 – 字符串在内存中的样子

图 3.1 – 字符串在内存中的样子

如果多个字符串引用相同的底层切片,这是可以的,因为这个切片是不可变的,意味着你不能改变其内容。虽然切片存储字符串数据,但你不能通过引用切片的索引来改变字符串中的字符,因为它是不可变的。

相比之下,如果你想改变字符串变量的值,比如说你需要给它分配不同的文本,Go 语言会将字符串数据结构指向一个新的底层切片,该切片包含你提供的新的字符串内容。所有这些都在幕后发生,所以你不需要担心这个问题。

布尔值

bool 数据类型使用一个字节的内存,并存储 truefalse 的值。与其他编程语言一样,你可以在条件语句中使用 bool 类型的变量来改变程序的流程控制。if 条件语句明确要求 bool 类型:

func main() {
    condition := true
    if condition {
        fmt.Printf("Type: %T, Value: %t \n",
                    condition, condition)
    }
}

如果你在这个程序 ch03/boolean/main.go(在 进一步阅读 部分)中运行,你会得到以下输出:

ch03/boolean$ go run main.go
Type: bool, Value: true

因为条件是 true,我们打印 condition 变量的类型和值。

错误

Go 对错误处理有独特的方法,并定义了一个特殊的 error 类型来表示失败条件。你可以生成错误、更改它们、在屏幕上打印它们,或使用它们来改变程序的流程控制。下面的代码示例显示了生成 Error 类型新变量的两种最常见方式:

func main() {
    // Creates a variable of 'error' type
    err1 := errors.New("This is a new error")
    // string formatting when building an error message
    msg := "another error message"
    err2 := fmt.Errorf("This is %s", msg)
}

你可以将任何用户定义的类型变成错误,只要它实现了一个特殊的 Error() 方法,该方法返回一个 string。我们将在本章后面的 接口 部分更详细地讨论实现方法。

错误处理的一种常见方式是允许它冒泡到程序中的某个点,在那里你可以决定如何响应它——是失败并停止执行,还是记录并重试。无论如何,错误在 Go 中无处不在,所有可能失败的功能都将错误作为它们的最后一个参数返回,因此以下模式在 Go 程序中非常常见:

func main() {
    result, err := myFunction()
    if err != nil {
        fmt.Printf("Received an error: %s", err)
        return err
    }
}

myFunction 函数返回两个值。在前面示例的外部函数中,我们将 myFunction 的第一个返回值存储在一个名为 result 的变量中,第二个返回值存储在 err 变量中,以存储 myFunction 内部可能出现的任何错误值,现在这些错误值会暴露给调用函数。

根据程序的逻辑,你需要决定如何处理错误。在这里,如果错误不为空(nil),我们打印错误信息并结束函数的执行(return)。我们也可以只是记录它,并允许程序继续运行。

容器类型

从原始类型向上提升一级的是容器类型。这些仍然是任何 Go 程序都可以使用的标准类型,无需任何显式的导入语句。但是,它们代表的不仅仅是单个值。我们在 Go 中使用容器类型来存储相同类型的多个值;以下是一些包括的类型:

  • 数组

  • 切片

  • 映射

在以下章节中,我们将讨论这些三种类型的用例和实现细节。

数组

任何程序员在掌握了处理原始类型的能力之后,首先需要的是存储这些类型值集合的能力。例如,网络库存可能存储设备主机名或 IP 地址的列表。解决这个问题的最常见方法是使用一种称为 array 的数据结构。Go 的 array 类型具有 [n]T 的签名,其中 n 是数组的长度,T 是你存储在数组中的值类型。

下面是一个示例,说明你如何在 Go 中使用字符串数组。我们故意混合不同的语义方式来定义数组,这样你可以选择你喜欢的风格。我们首先在单行上定义 hostnames 数组,然后在多行语句中定义 ips 数组:

func main() {
    hostnames := [2]string{"router1.example.com",
                        "router2.example.com"}
    ips := [3]string{
        "192.0.2.1/32",
        "198.51.100.1/32",
        "203.0.113.1/32",
    }
    // Prints router2.example.com
    fmt.Println(hostnames[1])
    // Prints 203.0.113.1/32
    fmt.Println(ips[2])
}

对于网络工程师来说,当处理字节数组时,这会变得更加有趣。看看下一个示例,Go 如何读取输入的十进制数字(例如 127),二进制数据就在你的指尖。这两个数组示例都可以在 ch03/arrays/main.go(见 进一步阅读 部分)中找到:

func main() {
    // ipv4 is [0000 0000, 0000 0000, 0000 0000, 0000 0000]
    var ipAddr [4]byte
    // ipv4 is [1111 1111, 0000 0000, 0000 0000, 0000 0001]
    var localhost = [4]byte{127, 0, 0, 1}
    // prints 4
    fmt.Println(len(localhost))
    // prints [1111111 0 0 1]
    fmt.Printf("%b\n", localhost)
    // prints false
    fmt.Println(ipAddr == localhost)
}

Go 数组有许多优点。它们非常节省内存,因为它们按顺序存储值,并且没有额外的元数据开销。它们也是可比较的,这意味着你可以检查两个数组是否相等,前提是它们的值具有可比较的类型。

但是,由于它们的固定大小,我们很少在 Go 中直接使用数组。唯一的例外是当你事先知道数据集的大小。考虑到这一点,在网络中,我们处理大量的固定大小数据集;它们构成了大多数网络协议头,因此数组对于这些以及诸如 IP 和 MAC 地址、端口号或序列号以及各种 VPN 标签等都是方便的。

切片

根据定义,数组具有不可变的结构(固定大小)。虽然你可以改变数组中的值,但它们不能随着存储数据的尺寸变化而增长或缩小。但是,在实现上,这从来不是问题。许多语言将数组实现为在幕后改变大小的动态数据结构。

当然,在增长数组时会有一些性能损失,但通过一些巧妙的算法,可以减少更改次数,并尽可能使最终用户的使用体验无摩擦。在 Go 中,切片扮演着这个角色;它们是 Go 中最广泛使用的类似数组的结构。

在创建切片时提供长度是可选的。在幕后,Go 创建一个后备数组,该数组定义了切片可以增长到的上限。这个上限就是我们所说的切片的容量。一般来说,容量等于切片的长度,但并不总是这样。如果切片需要超出其容量,Go 会创建一个新的更大的后备数组,并将原始数组的内容复制过来。下一个示例展示了创建切片的三种方法以及每个切片的容量和长度:

func main() {
    empty := []string{}
    words := []string{"zero", "one", "two", "three",
                    "four", "five", "six"}
    three := make([]string, 3)
    fmt.Printf("empty: length: %d, capacity: %d, %v\n",
                     len(empty), cap(empty), empty)
    fmt.Printf("words: length: %d, capacity: %d, %v\n",
                    len(words), cap(words), words)
    fmt.Printf("three: length: %d, capacity: %d, %v\n",
                    len(three), cap(three), three)
    /* ... <continues next > ... */
}

这个程序,你可以在 ch03/slices/main.go(见 进一步阅读 部分)中找到,打印以下内容:

ch03/slices$ go run main.go
empty: length: 0, capacity: 0, []
words: length: 7, capacity: 7, [zero one two three four five six]
three: length: 3, capacity: 3, [  ]

就像字符串一样,你可以对切片进行切片,这会创建对相同后备数组中某个部分的新的引用。例如,如果你根据前面的示例中的切片 words 使用 words[1:3] 创建一个新的切片,你最终会得到一个包含 onetwo 元素的切片,因此这个切片的长度是两个。然而,它的容量是六。为什么是六?后备数组是相同的,但新的切片从索引一开始,后备数组的最后一个索引是七。图 3**.2 展示了它在内存中的样子:

图 3.2 – 切片在内存中的样子

图 3.2 – 切片在内存中的样子

要向切片的末尾添加元素,你可以使用内置的 append 函数。让我们从我们刚才引用的切片开始,称它为 mySlice

func main() {
    /* ... <continues from before > ... */
    mySlice := words[1:3]
    fmt.Printf(" mySlice: length: %d, capacity: %d, %v\n",
            len(mySlice), cap(mySlice), mySlice)
    mySlice = append(mySlice, "seven")
    fmt.Printf(" mySlice: length: %d, capacity: %d, %v\n",
            len(mySlice), cap(mySlice), mySlice)
    mySlice = append(mySlice, "eight", "nine", "ten",
                    "eleven")
    fmt.Printf(" mySlice: length: %d, capacity: %d, %v\n",
            len(mySlice), cap(mySlice), mySlice)
}

如果我们从 ch03/slices/main.go 运行这个程序(参见 进一步阅读 部分),我们可以看到当 Go 需要额外的容量时,它是如何分配一个新的后备数组的。当它已经有了三个元素,并且我们要求向一个容量为六的切片中添加另外四个元素时,Go 自动分配了一个容量为 12 的新后备数组来支持额外的元素和未来的增长:

ch03/slices$ go run main.go
...
 mySlice: length: 2, capacity: 6, [one two]
 mySlice: length: 3, capacity: 6, [one two seven]
 mySlice: length: 7, capacity: 12, [one two seven eight nine ten eleven]

重要的是,虽然这听起来可能难以理解,但所有这些都在幕后发生。我们想要留给你的关于切片的是,它们是一种三词数据结构,在大多数计算机上现在通常是 24 字节。

映射

映射是一种容器类型,它使得将一种类型(例如,字符串或整数)作为键存储为另一个类型(作为值)的映射成为可能。映射的形式为 map[KeyType]ValueType,其中 KeyType 是任何可比较的类型,ValueType 可以是任何类型。一个例子是 map[int]string

初始化映射的一种方法是在下一个示例中使用内置的 make 函数,其中我们创建了一个以 string 为键和值的映射。你可以向映射中添加新的值,通过引用你想要关联该值的键。在示例中,我们将 spine 映射到 192.168.100.1

func main() {
    dc := make(map[string]string)
    dc["spine"] = "192.168.100.1"
    ip := dc["spine"]
    ip, exists := dc["spine"]
    if exists {
        fmt.Println(ip)
    }
}

要检索一个值并将其分配给一个变量,你可以像添加值时一样引用键,但这次在等号右侧,就像前面的示例中我们分配 dc["spine"] 的值到 ip 变量一样。

你还可以进行成员测试,以检查某个键是否在映射中。一个双值赋值用于测试键的存在,例如在 ip, exists := dc["spine"] 中,其中 exists 是一个布尔值,只有当 dc["spine"] 存在时才为 true

初始化映射的另一种方法是使用数据,如下一个示例所示。要删除元素,你可以使用内置的 delete 函数:

func main() {
    inv := map[string]string{
        "router1.example.com": "192.0.2.1/32",
        "router2.example.com": "198.51.100.1/32",
    }
    fmt.Printf("inventory: length: %d, %v\n", len(inv),
                inv)
    delete(inv, "router1.example.com")
    fmt.Printf("inventory: length: %d, %v\n", len(inv),
                inv)
}

这个程序打印以下内容:

ch03/maps$ go run main.go
inventory: length: 2, map[router1.example.com:192.0.2.1/32 router2.example.com:198.51.100.1/32]
inventory: length: 1, map[router2.example.com:198.51.100.1/32]

本节的完整代码可在 ch03/maps/main.go 中找到(参见 进一步 阅读 部分)。

用户定义的类型

与我们之前讨论的类型不同,用户定义的类型,正如其名所示,是你定义的类型。在这个类别中,我们有以下几种:

  • 结构体

  • 接口

接口是 Go 中唯一的抽象类型,并为具体类型(如结构体)定义了一个合同。它们描述行为,而不是实现细节,这有助于我们将程序的商务逻辑分解成带有接口的构建块。我们将在本章后面的接口专用部分详细讨论它们。

结构体

结构体是一种表示一组字段及其数据类型的复杂数据结构。结构体看起来有点像映射,但这里的键是固定的。它们成为变量名的扩展。

让我们定义一个具有四个string字段和一个bool字段的Router

type Router struct {
    Hostname  string
    Platform  string
    Username  string
    Password  string
    StrictKey bool
}

现在,这个新类型也可以是另一个用户定义类型的一部分,如下面的Inventory类型,它包含我们刚才定义的这些路由器的切片:

type Inventory struct {
    Routers []Router
}

下面是一些创建结构体实例并为其字段赋值的示例:

func main() {
    var r1 Router
    r1.Hostname = "router1.example.com"
    r2 := new(Router)
    r2.Hostname = "router2.example.com"
    r3 := Router{
        Hostname:  "router3.example.com",
        Platform:  "cisco_iosxr",
        Username:  "user",
        Password:  "secret",
        StrictKey: false,
    }
    /* ... <continues next > ... */
}

注意的是,r2现在实际上是指向Router的指针(这就是new的工作方式),但这不是我们现在需要担心的事情。让我们将所有路由器放入一个Inventory类型的变量中:

func main() {
    /* ... <continues from before > ... */
    inv := Inventory{
        Routers: []Router{r1, *r2, r3},
    }
    fmt.Printf("Inventory: %+v\n", inv)
}

现在,我们已经在变量中方便地放置了所有路由器。我们尚未分配值的所有字段都是零值("",或字符串的空值):

ch03/structs$ go run main.go
Inventory: {Routers:[{Hostname:router1.example.com Platform: Username: Password: StrictKey:false} {Hostname:router2.example.com Platform: Username: Password: StrictKey:false} {Hostname:router3.example.com Platform:cisco_iosxr Username:user Password:secret StrictKey:false}]}

本例中的代码可在ch03/structs/main.go中找到(见进一步 阅读部分)。

到目前为止,我们还没有讨论其他变量类型,如指针、通道和函数。我们将在本章的其他部分介绍这些内容。请耐心等待。在下一节中,我们将介绍一些数学和逻辑运算符,这些运算符允许我们在程序中执行不同的操作。

算术、比较和逻辑运算符

运算符是执行特定数学、逻辑或关系计算的特定符号。在本节中,我们涵盖了以下三种类型的运算符:

  • 算术运算符

  • 逻辑运算符

  • 比较运算符

虽然我们没有涵盖所有角落案例和类型的排列组合,但我们想关注一些在网络自动化环境中可能有趣的运算符。

算术运算符

这些运算符使用数值执行数学计算。结果值取决于操作数的顺序和类型:

表 3.3 – 算术运算符

表 3.3 – 算术运算符

它们遵循大多数编程语言中实现的标准化数学逻辑:

func main() {
    // sum s == 42
    s := 40 + 2
    // difference d == 0.14
    d := 3.14 - 3
    // product p == 9.42
    p := 3 * 3.14
    // quotient q == 0
    q := 3.0 / 5
    // remainder r == 2
    r :=  5 % 3
}

字符串是唯一可以使用的算术运算符的非数值类型。您可以使用+进行字符串连接,将两个或多个文本字符串链接成一个字符串:

func main() {
    // s == "Hello, World"
    s := "Hello" + ", " + "World"
}

算术运算的一个最有趣的应用是与二进制数据交互,这是许多网络工程师所熟悉的。

网络协议具有确定的结构,这些结构以一组头部形式表达,包含转发信息和封装有效载荷的事实。

您可以使用算术运算符位移和位运算(ORANDXOR)从网络头部创建或提取数据。

为了看到这个功能在实际中的应用,让我们处理一个 20 字节长的传输控制协议TCP)头部,它包含以下信息:

  • 源端口地址 – 2 字节

  • 目标端口地址 – 2 字节

  • 序列号 – 4 字节

  • 确认号 – 4 字节

  • 头部长度和保留位 – 1 字节

  • 控制标志 – 1 字节:

    • CWR拥塞窗口 减少标志

    • ECE显式拥塞通知ECN)-回声标志

    • URG:紧急指针

    • ACK: 确认号有效

    • PSH: 请求推送

    • RST: 重置连接

    • SYN: 同步序列号

    • FIN: 终止连接

  • 窗口大小 – 2 字节

  • 校验和 – 2 字节

  • 紧急指针 – 2 字节

图 3**.3展示了包括我们刚刚列出的所有必填字段的 TCP 头结构:

图 3.3 – TCP 头结构

图 3.3 – TCP 头结构

在下一个代码示例中,我们从空的字节切片构建一个 TCP 头。我们在字节 13 的前四个位中写入其长度,然后在 TCP 头的字节 14 中设置SYN标志。

TCP 头中的头部长度字段表示 TCP 头包含的 32 位字的数量。你可以将其视为其中的行数,如图 3**.3所示。在这里,长度是五个字。

以下代码片段(完整版本在ch03/tcp-header/main.go中,见进一步阅读部分)展示了如何使用算术运算在 TCP 头上设置此长度:

func main() {
    // Header length (measured in 32-bit words) is 5
    var headerWords uint8 = 5
    // Header length in bytes is 20
    headerLen := headerWords * 32 / 8
    // Build a slice of 20 bytes to store the TCP header
    b := make([]byte, headerLen)
    // Shift header words bits to the left to fit
    // the Header Length field of the TCP header
    s := headerWords << 4
    // OR operation on byte 13 and the store new value
    b[13] = b[13] | s
    // Print the 13 byte of the TCP header -> [01010000]
    fmt.Printf("%08b\n", b[13])
    /* ... <continues next > ... */
}

图 3**.4展示了如何将与单个字节大小兼容的headerWords 8 位无符号整数变量左移位,以适应头字段中适当的位置。

左移操作将原始位移动,丢弃右边的溢出位,并用零替换左边的位。按位OR运算符将结果值与现有字节组合。这是一个常见的模式,以确保你之前配置的任何位都不会丢失,因为按位OR运算符始终保留操作数中存在的1位:

图 3.4 – 构建 TCP 头,第一部分

图 3.4 – 构建 TCP 头,第一部分

要设置一个标志,我们可以做类似的事情,其中我们设置一个位并将其左移,使其位于第二个位置以表示SYN

func main() {
    /* ... <continues from before > ... */
    // assume that this is the initial TCP SYN message
    var tcpSyn uint8 = 1
    // SYN flag is the second bit from the right so
    // we shift it by 1 position
    f := tcpSyn << 1
    // OR operation on byte 14 and store the new value
    b[14] = b[14] | f
    // Print the 14 byte of the TCP header -> [00000010]
    fmt.Printf("%08b\n", b[14])
    /* ... <continues next > ... */
}

图 3**.5展示了前一个代码示例中的位操作:

图 3.5 – 构建 TCP 头,第二部分

图 3.5 – 构建 TCP 头,第二部分

现在,让我们看看接收端解析这两个字节的反向过程可能是什么样的:

func main() {
    /* ... <continues from before > ... */
    // only interested if a TCP SYN flag has been set
    tcpSynFlag := (b[14] & 0x02) != 0
    // Shift header length right, drop any low-order bits
    parsedHeaderWords := b[13] >> 4
    // prints "TCP Flag is set: true"
    fmt.Printf("TCP Flag is set: %t\n", tcpSynFlag)
    // prints "TCP header words: 5"
    fmt.Printf("TCP header words: %d\n", parsedHeaderWords)
}

这次,我们使用相反的位操作集。右移将所有位从左向右移动,丢弃右边的位,并在左边添加零:

图 3.6 – 解析 TCP 头,第一部分

图 3.6 – 解析 TCP 头,第一部分

按位AND运算符的行为与网络掩码相同。它保留设置为1的位,并将其他所有位重置为零,有效地隐藏了非重要位。在我们的例子中,我们使用0x02掩码值或二进制的0000 0010,隐藏了其他所有位,只留下了从右数第二个位。然后我们可以将这个位向右移动并检查其值:

图 3.7 – 解析 TCP 头,第二部分

图 3.7 – 解析 TCP 头,第二部分

能够在位和字节级别上进行工作是一种强大的编程能力。

逻辑运算符

逻辑运算符是一组基本的布尔运算,遵循布尔代数的规则——合取、析取和否定:

表 3.4 – 逻辑运算符

表 3.4 – 逻辑运算符

在 Go 语言中实现这些逻辑运算符并没有什么令人惊讶的,唯一值得记住的是,它们没有语法糖,所以唯一可接受的是&&用于AND||用于OR,以及!用于NOT

比较运算符

我们使用等于和不等于(==!=)运算符来比较一对可比较的值,并返回布尔值(true|false)。你可以将大于和小于运算符(<<=>,和>=)应用于有序值:

表 3.5 – 比较运算符

表 3.5 – 比较运算符

这里有一个比较运算符在行动中的简要示例,以及它们最常见类型:

func main() {
    // all strings are comparable
    fmt.Println("hello" == "hello")
    // strings are ordered alphabetically
    fmt.Println("hello" < "world")
    // integers are comparable and ordered
    fmt.Println(1 < 10)
    // floating point numbers are also comparable
    fmt.Println(10.0 >= 1.1)
}

在前面的示例中,所有语句都会评估并打印true。你可以在 Go 语言规范的比较运算符部分找到其他 Go 类型(如指针、通道和数组)的完整可比较和有序属性列表(见进一步阅读)。

这就结束了我们对 Go 数据类型和用于执行日常操作的不同运算符的介绍。现在,随着我们深入 Go 的控制流和函数,是时候将我们程序的第一个构建块组合起来。

控制流

控制流结构是任何计算机程序的关键构建块,因为它们允许你通过条件和迭代表达复杂的行为。Go 对控制流的支持反映了其简约设计,这也是为什么在整个语言规范中你会看到条件语句的几种变体和一种循环版本。这可能会让人感到惊讶,但这也使得 Go 更容易阅读,因为它强制所有程序采用相同的设计模式。让我们从最简单和最常见的数据流块开始。

for循环

在其最简单形式中,for循环允许你在每次迭代中执行一些工作的同时遍历一系列整数。例如,这是打印从04的所有数字的方法:

func main() {
    for i := 0; i < 5; i++ {
        fmt.Println(i)
    }
}

第一行包含init语句,i := 0,条件语句,i < 5,以及post(每次迭代)语句,i++,由分号(;)分隔。代码会继续评估条件语句和for循环的post语句,直到条件不再为true,即直到i >= 5

这种循环类型(for)有许多变体,其中最常见的一种是遍历容器类型。以下有两个示例:

  • 这是一个遍历切片的示例:

    func main() {
    
        slice := []string{"r1", "r2", "r3"}
    
        for i, v := range slice {
    
            fmt.Printf("index %d: value: %s\n", i, v)
    
        }
    
    }
    
  • 这是一个遍历 map 的示例:

    func main() {
    
        hashMap := map[int]string{
    
            1: "r1",
    
            2: "r2",
    
            3: "r3",
    
        }
    
        for i, v := range hashMap {
    
            fmt.Printf("key %d: value: %s\n", i, v)
    
        }
    
    }
    

特殊的range关键字遍历切片或映射的所有值,为每次迭代创建当前项的新键/值变量副本(在示例中为iv)。你还可以使用range遍历数组和字符串。对于通道,这个关键字有特殊的行为,我们将在后面的并发部分介绍。

这种循环结构的另一种常见变体是无限循环。当你不知道迭代次数时,可以使用它,但你知道何时停止:

func main() {
    for {
        time.Sleep(time.Second)
        break
    }
}

这里的关键区别在于循环定义中没有任何条件,这相当于true的缩写;也就是说,条件语句始终评估为true,循环无限迭代。停止这种循环的唯一方法是使用break关键字。

Go 没有循环的while关键字,这在许多其他编程语言中都可以找到。但是,你可以通过像下一个代码示例那样省略initpost语句,使 Go 的for循环表现得像while一样:

func main() {
    i := 0
    for i < 5 {
        fmt.Println(i)
        i++
    }
}

在这个上下文中,另一个值得提到的特殊关键字是continue,它跳过循环当前迭代的剩余部分。以下示例打印从04的所有数字,但只有当它们是偶数时:

funcmain() {
    // prints 0 2 4
    for i := 0; i < 5; i++ {
        if i % 2 != 0 {
            continue
        }
        fmt.Println(i)
    }
}

在这个例子中,我们使用if i % 2 != 0子句跳过了除以二后余数非零的数字。这是一个条件语句,这是下一节的主题。

条件语句

控制结构帮助你定义程序可以遵循的不同执行路径时的行为或方向。

让我们从双向条件语句开始。我们尝试连接到一个网站(www.tkng.io/),然后打印如果连接成功我们收到的响应,或者在HTTP GET操作失败时返回错误信息。如果错误不为空(err != nil),则返回。否则(else),我们打印信息(fmt.Printf):

func main() {
    resp, err := http.Get("https://www.tkng.io/")
    if err != nil {
            log.Fatalf("Could not connect: %v", err)
    } else {
            fmt.Printf("Received response: %v",
                        resp.Status)
    }
}

提高前面示例的可读性的方法之一是将程序成功执行路径左对齐,这意味着如果if条件的某个分支以终止语句结束,就像我们这里的return一样,你可以删除整个else子句,并将代码重写如下:

func main() {
    resp, err := http.Get("https://www.tkng.io/")
    if err != nil {
            log.Fatalf("Could not connect: %v", err)
    }
    fmt.Printf("Received response: %v", resp.Status)
}

与任何典型的if-then-else结构一样,Go 的条件语句可以使用多个if-else语句编码多路条件。但是,Go 开发者通常更喜欢在这种情况下使用switch语句,因为它是一种更简洁、更易读的多阶段if-then-else的形式。

考虑以下示例,它发送一个HTTP GET请求并根据返回的状态码打印一条消息。完整的代码在ch03/switch/main.go中(见进一步阅读):

func main() {
    resp, err := http.Get("http://httpstat.us/304")
    if err != nil {
        log.Fatalf("Could not connect: %v", err)
    }
    switch {
    case resp.StatusCode >= 600:
        fmt.Println("Unknown")
    case resp.StatusCode >= 500:
        fmt.Println("Server Error")
    case resp.StatusCode >= 400:
        fmt.Println("Client Error")
    case resp.StatusCode >= 300:
        fmt.Println("Redirect")
    case resp.StatusCode >= 200:
        fmt.Println("Success")
    case resp.StatusCode >= 100:
        fmt.Println("Informational")
    default:
        fmt.Println("Incorrect")
    }
}

你也可以将这个例子写成一系列的if-then-else语句,但使用switch可以使你的代码更简洁,许多 Go 开发者认为在这种情况下这是一种良好的实践。

goto语句

另一种将控制权从程序的一个部分转移到另一个部分的方法是使用goto语句。

你可以使用goto语句跳出嵌套或无限循环,或者实现逻辑。

在前面的代码示例的基础上,让我们看看如何使用goto语句实现从函数中退出的各种出口点。你可以在这个示例的完整代码在ch03/goto/main.go中找到(见进一步阅读):

func main() {
    resp, err := http.Get("http://httpstat.us/304")
    if err != nil {
        log.Fatalf("Could not connect: %v", err)
    }
    switch {
    case resp.StatusCode >= 600:
        fmt.Println("Unknown")
        goto exception
    case resp.StatusCode >= 500:
        fmt.Println("Server Error")
        goto failure
    case resp.StatusCode >= 400:
        fmt.Println("Client Error")
        goto failure
    case resp.StatusCode >= 300:
        fmt.Println("Redirect")
        goto exit
    case resp.StatusCode >= 200:
        fmt.Println("Success")
        goto exit
    case resp.StatusCode >= 100:
        fmt.Println("Informational")
        goto exit
    default:
        fmt.Println("Incorrect")
        goto exception
    }
   exception:
    panic("Unexpected response")
   failure:
    log.Fatalf("Failed to connect: %v", err)
   exit:
    fmt.Println("Connection successful")
}

由于goto语句能够打破程序的流程,在大多数编程语言中,它们都有一种有点邪恶的名声,这通常会使代码更难阅读,许多著名的计算机科学家都警告不要随意使用它们。尽管如此,这些语句确实有其位置,你可以在许多项目中找到它们,甚至在 Go 标准库中也能找到。

循环、条件语句以及类似goto的东西可以帮助你定义 Go 程序的控制流程。我们还没有涵盖一些与通道类型一起使用的额外控制流程结构和边缘情况。我们将在本章的并发部分中稍后讨论它们,但在到达那里之前,我们首先需要讨论代码组织的另一个重要领域:函数。

函数

表面上,Go 函数与任何其他编程语言中的函数完全相同:一段代码,设计用来执行特定任务,并分组到一个可重用的容器中。由于语言的静态特性,所有函数都有一个签名,它定义了可接受输入参数的数量和类型以及输出值。

考虑以下函数(generateName),它根据一对输入字符串(basesuffix)生成一个新名称。你可以在这个示例的完整代码在ch03/functions1/main.go中找到(见进一步阅读):

func generateName(base string, suffix string) string {
    parts := []string{base, suffix}
    return strings.Join(parts, "-")
}
func main() {
    s := generateName("device", "01")
    // prints "device-01"
    fmt.Println(s)
}

这个函数的签名是func (string, string) string,这意味着它接受两个字符串类型的参数,并返回另一个字符串。你可以将返回值赋给一个变量,或者直接将其作为参数传递给另一个函数。

Go 的函数是值,这意味着你可以将它们作为输入参数传递,甚至可以从另一个函数中返回它们。

为了说明这一点,我们定义了一个名为processDevice的新函数,它接受两个参数,一个具有func (string, string) string签名的函数和一个string。在这个函数的主体中,有两个相关的字符串:base,它被静态设置为device,以及ip,它是函数接收的第二个参数:

func processDevice(getName func (string, string) string, ip string) {
    base := "device"
    name := getName(base, ip)
    fmt.Println(name)
}

这个函数最有趣的部分是其主体中的第二行,它调用了getName函数。这个函数是processDevice接收到的参数,它可以是一个任何函数,只要它接受两个字符串作为参数并返回一个字符串。这正是我们为早期示例定义的generateName函数的情况,这意味着我们可以将generateName作为参数传递给processDevice来构建一个唯一的设备名称。让我们看看这将是什么样子。此示例的代码可在ch03/functions1/main.go中找到(参见进一步阅读):

func main() {
    // prints "device-192.0.2.1"
    processDevice(generateName, "192.0.2.1")
}

这种方法的优点是第一个参数的可插拔性。如果我们决定在某个时刻,另一个函数(例如generateName2)更适合,因为它使用不同的格式来连接字符串或其他原因,或者你可能想以不同的方式创建设备名称,但又不想修改generateName函数以防你需要快速回滚更改,那么你可以使用一个具有不同名称的临时clone函数来进行调整。

函数参数

在 Go 中,我们通过值传递函数参数,这意味着 Go 会为每个输入变量创建一个副本,并将该副本传递给被调用的函数。Go 将新的函数作用域变量存储在栈内存中,只要编译器在编译时知道它们的生命周期和内存占用。栈是内存中一个非常高效的区域,用于存储不需要垃圾回收的变量,因为它在函数返回时自动分配或释放内存。需要垃圾回收的内存会移动到内存中的另一个位置,称为堆。

考虑以下一个尝试修改输入字符串的函数的例子。你可以在此处找到下一个示例的代码ch03/functions2/main.go(参见进一步阅读):

type Device struct {
    name string
}
func mutate(input Device) {
    input.name += "-suffix"
}
func main() {
    d := Device{name: "myname"}
    mutate(d)
    // prints "myname"
    fmt.Println(d.name)
}

由于 Go 在将Device作为值传递给mutate函数时创建了一个副本,因此在函数体内对该Device所做的任何更改都不会在函数外部可见,因此它不会影响原始变量d。这就是为什么d.name打印的是myname而不是myname-suffix

在 Go 中,我们可以使用两种类型的数据:值和这些值的内存地址(指针)。考虑到这一点,在将值传递给函数时,有两种方法可以实现所需的(修改)行为:

  • 将函数修改为返回修改后的值并将其分配给一个变量。然而,这实际上并没有修改原始值,而是生成了一个新的值。

将函数修改为接受一个存储Device变量的指针。在这种情况下,我们的程序将如下所示:

type Device struct {
    name string
}
func mutate(input *Device) {
    input.name += "-suffix"
}
func main() {
    d := Device{name: "myname"}
    mutate(&d)
    // prints "myname-suffix"
    fmt.Println(d.name)
}

在 Go 中,指针是跨程序边界共享数据的一种常见方式,例如函数调用。在这种情况下,我们仍然通过值传递输入参数(&d),但这次,我们复制并传递的是指向内存地址的指针,而不是 d 变量的实际内容。现在,当你改变这个内存地址所指向的内容时,你正在修改原始 d 变量的值:

图 3.8 – 值和指针

图 3.8 – 值和指针

Go 的指针是一个强大的概念。你需要了解的关键操作如下:

  • 使用 & 操作符获取变量的地址

  • 解引用指针,即使用 * 操作符获取引用值的地址

每当你需要改变一个变量的值,或者当变量足够大以至于复制它变得低效时,你需要确保通过指针传递它。这个规则适用于所有原始类型——整数字符串布尔值等等。

Go 中有一些类型并不持有实际值,而是指向它的内存地址。虽然这些都是内部实现细节,但这是值得记住的。例如,通道和映射是两种实际上是指向内部数据结构(运行时类型)的指针类型。这意味着即使你通过值传递它们,你最终也会修改通道或映射的内容。顺便说一句,这也适用于函数。

请看以下示例,其中我们将映射(m)作为值传递给函数(fn)。这个函数向映射中添加一个新的键值对,这个值外部的函数(main)也可以访问:

func fn(m map[int]int) {
    m[1] = 11
}
func main() {
    m := make(map[int]int)
    fn(m)
    // prints 11
    fmt.Println(m[1])
}

在本章的 Go 的类型系统 部分,我们了解到切片是 Go 中的一种类型,它存储有关底层数据的元数据以及指向它的指针。你可能想当然地认为你可以作为值传递这种数据类型并能够修改它。但是,尽管这个数据结构中有一个指针,你也会创建其余元数据值(长度和容量)的副本,从而在调用函数和被调用函数之间的切片之间造成断开。

因此,切片中的修改可能产生不可预测的结果。就地更改可能是可见的,但追加可能不可见。这就是为什么他们总是建议传递指针以避免像以下这样的微妙错误:

func mutateV(input []string) {
    input [0] = "r03"
    input  = append(input , "r04")
}
func main() {
    d1 := []string{"r01", "r02"}
    mutateV(d1)
    // prints "[r03 r02]"
    fmt.Printf("%v\n", d1)
}

如果你使用指针,就可以避免这个错误,在这种情况下,所有对底层切片的更改都会在外部上下文中反映出来:

func mutateP(input *[]string) {
    (*input)[0] = "r03"
    *input = append(*input, "r04")
}
func main() {
    d2 := []string{"r01", "r02"}
    mutateP(&d2)
    // prints "[r03 r02 r04]"
    fmt.Printf("%v\n", d2)
}

这两个示例的完整代码在 ch03/mutate-slice/main.go 中(见 进一步阅读)。

错误处理

在 Go 中,错误不是你必须在其他代码部分处理的异常。我们按顺序处理它们。错误可能要求你立即停止程序的执行,或者你可能可以继续运行程序并将错误传播到程序的另一部分或用户,以便他们可以就如何处理此错误做出明智的决定。记住,不要只是检查错误,要优雅地处理 它们

当涉及到编写函数时,一个经验法则是,如果一个函数可能会遇到错误,它必须将错误返回给调用者:

func makeCall(url string) (*http.Response, error) {
    resp, err := http.Get("example.com")
    if err != nil {
        return nil, fmt.Errorf("error in makeCall: %w",
                                err)
    }
    return resp, nil
}

错误信息应该是有意义的,并且提供足够的信息给用户,以便他们能够识别错误的起因以及它在代码中发生的位置。决定如何处理此错误的责任在于调用此函数的人,以下是一些可能的操作:

  • 记录错误并继续。

  • 忽略它。

  • 中断执行并引发恐慌。

  • 将其传递给外部函数。

方法

方法是向用户定义的类型添加行为的一种方式,默认情况下,这些类型只能存储值。如果你想这些类型能够执行操作,你可以添加一个特殊函数,该函数将包含关联数据类型的名称(方法接收器),位于func关键字和函数名称之间,例如下一个示例中的GetFullName

type Device struct {
    name string
}
func (d Device) GetFullName() string {
    return d.name
}
func main() {
    d1 := Device{name: "r1"}
    // prints "r1"
    fmt.Println(d1.GetFullName())
}

在所有方面,方法就像函数一样——它们接受零个或多个参数,并返回零个或多个值。最大的区别是方法还可以访问它们的接收器,至少可以读取其字段,就像你在前面的示例中看到的那样。

也可以通过在指针上定义来创建一个会修改接收类型的函数:

type Device struct {
    name string
}
func (d *Device) GenerateName() {
    d.name = "device-" + d.name
}
func (d Device) GetFullName() string {
    return d.name
}
func main() {
    d2 := Device{name: "r2"}
    d2.GenerateName()
    // prints "device-r2"
    fmt.Println(d2.GetFullName())
}

在这种情况下,我们在指针接收器上定义了GenerateName方法,因此可以安全地设置、删除或更改其值——所有这些更改都在外部作用域中可见。

方法代码示例的完整代码可在ch03/methods/main.go中找到(参见进一步阅读)。

可变函数

到目前为止,我们只看到了使用严格预定义数量参数的函数示例。但在 Go 中,这并非唯一的选择;只要满足以下条件,你实际上可以向函数传递任意数量的参数:

  • 所有额外的参数都是同一类型。

  • 它们总是函数的最后一个参数。

函数签名看起来略有不同。所有额外的参数都会自动组合成一个切片,你可以在它们类型之前用三个点(...)来表示:

func printOctets(octets ...string) {
    fmt.Println(strings.Join(octets, "."))
}
func main() {
    // prints "127.1"
    printOctets("127", "1")
    ip := []string{"192", "0", "2", "1"}
    // prints "192.0.2.1"
    printOctets(ip...)
}

与将它们声明为切片参数相比,可变参数的一个优点是灵活性;在调用函数之前,你不需要创建一个切片,如果不需要任何尾随参数,你也可以完全省略它们,同时仍然满足函数的签名。

变量参数代码示例的完整代码可在ch03/variadic/main.go中找到(参见进一步阅读)。

闭包

Go 中的函数具有不同的属性。它们是值,因此一个函数可以接受另一个函数作为其参数。

另一个有趣的属性是,当一个函数(外部)返回另一个函数(内部)时,内部函数会记住,并且它可以完全访问外部函数作用域内定义的所有变量。

这就是所谓的 func() string 签名每次调用都会修改 suffixGenerator 外部函数中定义的 i 变量:

func suffixGenerator() func() string {
    i := 0
    return func() string {
        i++
        return fmt.Sprintf("%02d", i)
    }
}
func main() {
    generator1 := suffixGenerator()
    // prints "device-01"
    fmt.Printf("%s-%s\n", "device", generator1())
    // prints "device-02"
    fmt.Printf("%s-%s\n", "device", generator1())
    generator2 := suffixGenerator()
    // prints "device-01"
    fmt.Printf("%s-%s\n", "device", generator2())
}

每次我们调用 suffixGenerator 时,都会将外部函数返回的匿名函数的新实例分配给一个变量。generator1generator2 现在是跟踪我们调用每个函数次数的函数。

闭包是一种流行的技术,用于创建周围的环境(环境)。例如,中间件软件中的 API 调用函数使用闭包在每次调用时执行日志记录和遥测数据收集,而无需 API 调用者关心这些细节。

延迟

当编写一个打开远程网络连接或本地文件的程序时,重要的是要尽快关闭它们,以防止资源泄露 - 所有操作系统都对打开的文件或连接的数量有限制。

Go 处理这类问题的惯用方法是尽可能早地使用 defer 语句来处理。你应该将这个语句放在 open/connect 函数调用旁边。Go 只有在函数返回时才会评估这个语句。

在以下示例中,两个 defer 语句仅在函数的最终语句之后运行:

func main() {
    resp, err := http.Get("http://example.com")
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
    defer fmt.Println("Deferred cleanup")
    fmt.Println("Response status:", resp.Status)
}

你可以将多个 defer 语句堆叠起来以执行分阶段的清理。它们按照后进先出的顺序执行 - Println("Deferred cleanup")resp.Body.Close() 之前运行。当你运行这个程序时,你会看到以下内容:

ch03/defer$ go run main.go
Response status: 200 OK
Deferred cleanup

此代码示例的完整代码可在 ch03/defer/main.go 中找到(参见 进一步阅读)。

现在我们已经涵盖了 Go 函数的基础知识,是时候进入下一层抽象了,它通过一组独特的方法描述对象行为:接口。

接口

接口是 Go 中最强大的构造之一,因此理解它们的作用以及何时可以使用它们非常重要。从纯粹理论的角度来看,接口是一种抽象类型。它们不包含实现细节,但通过方法签名定义了一组行为。

如果一个 Go 类型定义了一个接口中声明的所有方法签名,那么这个 Go 类型就隐式地实现了该接口,而不需要显式声明。这是 Go 处理多个类型所表现出的常见行为的方式,而其他语言通常通过对象继承来表达这一点。

网络自动化示例

为了引入这个想法,我们使用了一个虚构的网络自动化示例。假设我们正在开发一个 Go 包来处理不同网络设备的常见任务。我们将 Cisco IOS XE 设备建模为一个 CiscoIOS 类型,它有两个字段——一个用于标识设备的主机名(Hostname),另一个用于标识底层硬件平台(Platform)。对于这个 CiscoIOS 类型,我们定义了一个方法来获取设备的运行时间(getUptime)作为整数。最后,我们定义了一个函数来比较两个设备,找出哪个设备在没有重启的情况下运行时间更长:

type CiscoIOS struct {
    Hostname string
    Platform string
}
func (r CiscoIOS) getUptime() int {
    /* ... <omitted for brevity > ... */
}
func LastToReboot(r1, r2 CiscoIOS) bool {
    return r1.getUptime() < r2.getUptime()
}

一切都运行得很好,直到我们添加了另一个平台。假设我们现在还有一个 CiscoNXOS 类型,它有 HostnamePlatform 字段,但它还有一个布尔值 ACI 字段来显示这个交换机是否启用了 ACI。与 CiscoIOS 类型一样,我们定义了一个返回 CiscoNXOS 设备运行时间的方法:

type CiscoNXOS struct {
    Hostname string
    Platform string
    ACI      bool
}
func (s CiscoNXOS) getUptime() int {
    /* ... <omitted for brevity > ... */
}

现在的挑战是比较 CiscoNXOS 设备类型和 CiscoIOS 设备类型的运行时间。LastToReboot 函数签名告诉我们它只接受 CiscoIOS 类型的变量作为参数,因此我们不能将 CiscoNXOS 类型的元素传递给它。

你可以通过创建一个接口来解决这个问题。通过这样做,你抽象出了设备的实现细节,只关注通过 getUptime 函数将设备运行时间作为整数呈现的需求。让我们称这个接口为 NetworkDevice

type NetworkDevice interface {
    getUptime() int
}

下一步是将 LastToReboot 函数更改为接受 NetworkDevice 类型而不是 CiscoIOS,如下代码片段所示:

func LastToReboot(r1, r2 NetworkDevice) bool {
    return r1.getUptime() < r2.getUptime()
}

因为 CiscoIOSCiscoNXOS 都有一个 getUptime() int 方法,它们隐式地满足 NetworkDevice 接口,因此你可以将它们中的任何一个作为参数传递给 LastToReboot 函数。一个使用这些定义来比较这两种设备类型运行时间的示例程序(见进一步阅读)如下所示:

func main() {
    ios := CiscoIOS{}
    nexus := CiscoNXOS{}
    if LastToReboot(ios, nexus) {
        fmt.Println("IOS-XE has been running for less time, so it was the last to be rebooted")
        os.Exit(0)
    }
    fmt.Println("NXOS was the last one to reboot")
}

接口可以帮助你扩展你的程序。NetworkDevice 接口使我们能够添加任何数量的设备类型。它不仅是一个优秀的代码设计资源,而且可以明确地设定在 API 中数据应该做什么,无论数据是什么。在示例中,我们不在乎设备运行的是哪种操作系统,只关心我们有一个方法可以获取其作为整数的运行时间。

标准库示例

为了更贴近现实世界的例子,让我们将注意力转向标准库中的 net 包,它有一个表示网络连接(Conn)的接口。接口字段通常是描述行为的动词,而不是状态(例如,Conn 接口的 SetDeadline)。相比之下,RemoteAddr 方法的更具描述性的名称可能是 getRemoteAddr

// src/net/net.go
// Conn is a generic stream-oriented network connection.
type Conn interface {
    /* ... <omitted for brevity > ... */
    // LocalAddr returns the local network address.
    LocalAddr() Addr
    // RemoteAddr returns the remote network address.
    RemoteAddr() Addr
    SetDeadline(t time.Time) error
    SetReadDeadline(t time.Time) error
    SetWriteDeadline(t time.Time) error
}

标准库包括此接口的几个实现。其中之一是在 crypto/ssh 库中,通过 chanConn 具体类型。具体类型是任何非接口类型,它存储自己的数据,在这种情况下,chanConn 存储了 Secure Shell ProtocolSSH)连接的本地(laddr)和远程(raddr)地址。

此类型还定义了方法,例如 LocalAddr() net.AddrSetReadDeadline(deadline time.Time) error。实际上,它具有 net.Conn 接口的所有方法,因此它满足接口:

// ssh/tcpip.go
// chanConn fulfills the net.Conn interface without
// the tcpChan having to hold laddr or raddr directly.
type chanConn struct {
    /* ... <omitted for brevity > ... */
    laddr, raddr net.Addr
}
// LocalAddr returns the local network address.
func (t *chanConn) LocalAddr() net.Addr {
    return t.laddr
}
// RemoteAddr returns the remote network address.
func (t *chanConn) RemoteAddr() net.Addr {
    return t.raddr
}
func (t *chanConn) SetDeadline(deadline time.Time) error {
    if err := t.SetReadDeadline(deadline); err != nil {
        return err
    }
    return t.SetWriteDeadline(deadline)
}
func (t *chanConn) SetReadDeadline(deadline time.Time) error {
    return errors.New("ssh: tcpChan: deadline not supported")
}
func (t *chanConn) SetWriteDeadline(deadline time.Time) error {
    return errors.New("ssh: tcpChan: deadline not supported")
}

现在,任何接受 net.Conn 作为输入的函数也可以接受 chanConn。反之亦然,如果一个函数返回 net.Conn,它也可以返回 chanConn,就像下面这个来自同一源代码文件的例子:

// ssh/tcpip.go
// Dial initiates a conn to the addr from remote host.
// Resulting conn has a zero LocalAddr() and RemoteAddr().
func (c *Client) Dial(n, addr string) (net.Conn, error) {
    var ch Channel
    switch n {
    case "tcp", "tcp4", "tcp6":
    // Parse the address into host and numeric port.
    host, portString, err := net.SplitHostPort(addr)
    if err != nil {
        return nil, err
    }
    /* ... <omitted for brevity > ... */
    return &chanConn{
        Channel: ch,
        laddr:   zeroAddr,
        raddr:   zeroAddr,
    }, nil
    /* ... <omitted for brevity > ... */
}

如果这些代码片段看起来令你感到困惑,请不要担心。这些来自 Go 标准库的实际 SSH 包,所以这是最复杂的部分。

接口作为合同

接口是一种无值的类型;它们只定义方法签名。你可以定义一个接口类型的变量,但你只能将这个接口的具体实现作为这个变量的值赋值。

在下一个代码示例中,r 变量是 io.Reader 类型,这是一个接口。在那个时刻,我们对此变量一无所知,但我们知道,无论我们给这个变量赋什么值,都必须满足 io.Reader 接口,以便编译器接受它。

在这种情况下,我们使用 strings.NewReader("text"),它实现了 io.Reader 接口,用于从作为参数传递的 string 值中读取:

func main() {
    var r io.Reader
    r = strings.NewReader("a random text")
    io.Copy(os.Stdout, r)
}

代码的最后一行将读取的内容复制到标准输出(Stdout)或用户的屏幕上。io.Copy 函数从 io.Readerr)复制到 io.Writeros.Stdout 满足此接口),因此我们可以从字符串复制到终端。

虽然这看起来比只用 fmt.Println 打印字符串要复杂一些,但接口使我们的代码更加灵活,允许你在不费太多力气的情况下替换示例中的数据源或目的地。这是因为 io.Readerio.Writer 接口作为 io.Copy() 消费者和 strings.NewReader 以及 os.Stdout 提供者之间的合同,确保它们都符合此接口定义的规则。

接口允许你定义程序不同模块之间的清晰划分,并提供一个用户可以定义实现细节的 API。在下一节中,我们将详细探讨 io.Readerio.Writer 接口及其在 输入/输出I/O)操作中的作用。

输入和输出操作

在程序中,移动数据并重新格式化数据是一个常见的操作。例如,您可以打开一个文件,将其内容加载到内存中,将其编码为不同的格式,比如jpeg,然后将其写入磁盘上的文件。这就是io.Readerio.Writer接口在 Go 的 I/O 模型中扮演关键角色的地方,因为它们允许您通过传输缓冲区将数据从源流式传输到目的地。这意味着您不需要将整个文件加载到内存中,以便对其进行编码并将其写入目的地,这使得整个过程更高效。

io.Reader 接口

标准库中的io包定义了 Go 中最受欢迎的接口之一,即io.Reader接口,它可以读取一个字节流(p)。它返回读取的字节数(n)和遇到的任何错误(err):

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

任何具有此签名的Read方法的具体类型都实现了io.Reader接口。您不需要做任何事情:

图 3.9 – io.Reader 接口

图 3.9 – io.Reader 接口

strings.Reader类型(在标准库的strings包中)有一个具有Read(p []byte) (n int, err error)签名的Read方法,因此它满足io.Reader接口。strings包还提供了一个方便的NewReader函数,返回指向新strings.Reader实例的指针。以下是从strings包源代码的实际代码片段:

// src/strings/reader.go
// A Reader implements the io.Reader, ...
// from a string.
type Reader struct {
    s        string
    i        int64 // current reading index
    prevRune int   // index of previous rune; or < 0
}
// Read implements the io.Reader interface.
func (r *Reader) Read(b []byte) (n int, err error) {
    if r.i >= int64(len(r.s)) {
        return 0, io.EOF
    }
    r.prevRune = -1
    n = copy(b, r.s[r.i:])
    r.i += int64(n)
    return
}
// NewReader returns a new Reader reading from s.
func NewReader(s string) *Reader { return &Reader{s, 0, -1} }

前面的代码还显示了一个具有Read方法的实际Reader实现(具有数据字段)。

io.Writer 接口

io包还指定了io.Reader接口,可以将len(p)个字节写入底层数据流。它返回写入的字节数(n)以及任何导致写入提前停止的错误(err):

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

任何具有此签名的Write方法的具体类型都实现了io.Writer接口:

图 3.10 – io.Writer 接口

图 3.10 – io.Writer 接口

一个例子是标准库os包中的os.File。它有一个具有Write(p []byte) (n int, err error)签名的Write方法,因此它满足io.Writer接口:

// src/os/types.go
// File represents an open file descriptor.
type File struct {
    *file // os specific
}
// Read reads up to len(b) bytes from the File.
// It returns the number of bytes read and any error.
// At end of file, Read returns 0, io.EOF.
func (f *File) Read(b []byte) (n int, err error) {
    if err := f.checkValid("read"); err != nil {
        return 0, err
    }
    n, e := f.read(b)
    return n, f.wrapErr("read", e)
}
func Create(name string) (*File, error) {
    return OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666)
}

os包还提供了一个方便的Create函数,可以从文件位置返回一个指向os.File的指针。前面的代码片段来自os包的源代码。

io.Copy 函数

io.Copy函数允许您从源复制数据到目的地,正如我们在接口部分结束时讨论的那样。尽管您向此函数传递具体类型的数据,但io.Copy实际上并不关心数据是什么,因为它接受接口类型作为参数,因此它对数据能做什么更感兴趣。它需要一个可读的源和一个可写的目的地:

// src/io/io.go
// Copy copies from src to dst until either EOF is reached
// on src or an error occurs.
func Copy(dst Writer, src Reader) (written int64, err error) {
    return copyBuffer(dst, src, nil)
}

图 3**.11所示,io.Copy使用 32 KB 传输缓冲区从源流式传输数据到目的地:

图 3.11 – io.Copy 函数

图 3.11 – io.Copy 函数

让我们测试一下。我们可以从使用 strings.NewReader 构建的字符串中获取一个 io.Reader,而 os.Create 给我们一个 io.Writer,它将数据写入磁盘上的文件。你可以通过查看 ch03/io-interface1/main.go(见 进一步阅读)中的代码来跟进:

func main() {
    src := strings.NewReader("The text")
    dst, err := os.Create("./file.txt")
    if err != nil {
        panic(err)
    }
    defer dst.Close()
    io.Copy(dst, src)
}

而在这个例子中,我们选择了一个字符串和一个文件组合,你可以使用相同的 io.Copy 函数从网络读取并打印到终端,例如。现在,让我们检查我们刚刚生成的文件:

ch03/io-interface1$ go run main.go
ch03/io-interface1$ cat file.txt
The text

让我们考察一个与网络相关的例子。net/http 包中的 Get 函数接受一个 URL(string)并返回一个指向 http.Response 的指针,该指针有一个满足 io.Reader 接口的字段(Body),而 os.Stdout 终端满足 io.Writer 接口。这为我们提供了另一个尝试的组合。让我们看看它的实际效果。代码与我们之前运行的很相似,可以在 ch03/io-interface2/main.go(见 进一步阅读)中找到:

func main() {
    res, err := http.Get("https://www.tkng.io/")
    if err != nil {
        panic(err)
    }
    src := res.Body
    defer src.Close()
    dst := os.Stdout
    io.Copy(dst, src)
}

现在相同的 io.Copy 函数允许我们从 URL 中获取内容并将其打印到终端:

ch03/io-interface2$ go run main.go
<!doctype html><html lang=en class="js csstransforms3d"><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><meta name=generator content="Hugo 0.74.3"><meta name=description content="The Kubernetes Networking Guide">...

使用 io.Copy,我们将数据从一个点移动到另一个点。现在,我们需要添加另一个拼图块来在流式传输数据时转换数据。

组合

在流式传输数据时转换数据的一种方法是将一个结构体类型嵌入到另一个结构体类型中,我们称之为 io.Readerio.Writer 接口,以执行一个或多个操作,而不仅仅是将数据从源复制到目的地。

跟随这种模式的优点是编写可重用的代码段,在这种情况下,你可以将其用于任何 io.Readerio.Writer 接口。让我们看看 ch03/reader/main.go(见 进一步阅读)中的示例:

type myReader struct {
    src io.Reader
}
func (r *myReader) Read(buf []byte) (int, error) {
    tmp := make([]byte, len(buf))
    n, err := r.src.Read(tmp)
    copy(buf[:n], bytes.Title(tmp[:n]))
    return n, err
}
func NewMyReader(r io.Reader) io.Reader {
    return &myReader{src: r}
}

我们定义一个新的 myReader 类型,它有一个 io.Reader 类型的单个 src 字段。在 Go 中,当我们嵌入一个类型时,该类型的所有方法都成为外部类型的方法,因此 myReader 现在有一个来自 srcRead 方法。

但是,我们想要改变行为并对数据进行一些操作。因此,我们定义了一个新的 Read 方法,它优先于类型中任何更深嵌套的其他方法。

在这个 Read 方法中,我们从缓冲区读取并使用 bytes.Title 将其转换为标题大小写,假设我们正在处理字符串。最后但同样重要的是,NewMyReader 是将现有读取器与这个新读取器粘合在一起的东西,连接了两段代码之间的点。让我们看看它的实际效果:

func main() {
    r1 := strings.NewReader("network automation with go")
    r2 := NewMyReader(r1)
    io.Copy(os.Stdout, r2)
}

我们从字符串创建一个读取器 r1,然后将其用作 r2myReader 的输入:

ch03/reader$ go run main.go
Network Automation With Go

当我们现在从 r2 复制到 os.Stdout 时,我们不仅从字符串中读取,还在将其写入终端之前将内容转换为标题大小写。

几乎每个 Go 库都包含输入和输出原语。下一节也不例外。在 Go 中,编码和解码充分利用了 io.Readerio.Writer 接口。

解码和编码

网络自动化中最常见的任务之一是结构化数据的摄取和处理。您可以从中检索数据或发送到远程位置,甚至将其存储在本地磁盘上。无论其位置如何,您都必须将其转换为适当的格式。编码,或称为序列化,是将 Go 数据结构中的字节转换为结构化文本表示的过程。解码,或称为反序列化,是从外部数据源填充 Go 值的过程的反向操作。

一些结构化数据编码方案的示例包括 YAML、JSON、XML 和 Protocol Buffers。Go 的标准库包括实现这些流行格式编码和解码的包,并且它们都利用了我们上节中了解到的io.Readerio.Writer接口原语。

在本节中,我们将介绍 Go 如何处理以下任务:

  • 使用标签注释 Go 结构体以帮助库编码和解码结构化数据

  • 使用空接口解析结构化数据

  • 使用第三方库执行深度嵌套的集合和查找操作

解码

我们从解码开始概述,因为这在网络自动化流程中通常是第一步之一。假设我们正在构建一个需要与各种远程网络设备交互的程序。我们将这些设备的信息存储在一个本地磁盘上保存的清单文件中。

解码 JSON

在第一个示例中,我们看到如何处理 JSON 清单(input.json)。本部分的全部输出都可在本书仓库的ch03/json文件夹中找到(见进一步阅读):

{
  "router": [
    {
      "hostname": "router1.example.com",
      "ip": "192.0.2.1",
      "asn": 64512
    },
    {
      "hostname": "router2.example.com",
      "ip": "198.51.100.1",
      "asn": 65535
    }
  ]
}

ch03/json/main.go的第一个代码示例中(见进一步阅读),我们定义了几个 Go 结构体,可以在内存中保存前面输出中的 JSON 输入数据。我们称第一个类型为Router,它具有HostnameIPASN字段。另一个类型是Inventory,它存储了一组路由器。Router类型中的字段具有可选的标签,如json:"key",以表示原始 JSON 结构中的替代键名:

type Router struct {
    Hostname string `json:"hostname"`
    IP       string `json:"ip"`
    ASN      uint16 `json:"asn"`
}
type Inventory struct {
    Routers []Router `json:"router"`
}

要从文件中读取,我们使用os.Open从输入文件创建一个io.Reader类型(file):

func main() {
    file, err := os.Open("input.json")
    // process error
    defer file.Close()
    /* ... <continues next > ... */
}

现在,json库以及任何其他编码库都有一个函数,允许您将io.Reader类型作为参数传递以从中提取数据。这意味着它可以从文件、字符串、网络连接或任何其他实现io.Reader接口的东西中解码。

func main() {
    /* ... <continues from before > ... */
    d := json.NewDecoder(file)
    /* ... <continues next > ... */
}

一旦创建了解码器,您就可以使用Decode方法将 JSON 文件的内容读取并解析到Inventory类型的变量(inv)中。记住,要修改数据结构,您需要将其作为指针传递:

func main() {
    /* ... <continues from before > ... */
    var inv Inventory
    err = d.Decode(&inv)
    // process error
    fmt.Printf("%+v\n", inv)
}

如果现在打印inv变量,您会看到它填充了来自清单 JSON 文件的数据:

ch03/json$ go run main.go
{Routers:[{Hostname:router1.example.com IP:192.0.2.1 ASN:64512} {Hostname:router2.example.com IP:198.51.100.1 ASN:65535}]}

解码到空接口

我们刚才看到的字段标签是在编码和解码过程中映射数据的一种非常方便的方式。在编码前预先定义所有 Go 类型的条件提供了类型安全,但与此同时,如果你来自另一种语言,其中解码过程不需要这种类型安全,那么这也可以看作是一个主要的缺点。

但是,在 Go 中,你也可以跳过这一步,但有一些我们在后面会讨论的注意事项。为了展示它是如何工作的,我们使用一个稍早的例子的一个不同版本。这个新版本可以在 ch03/json-interface 文件夹中找到(见 进一步阅读)。我们不是定义所有的 Go 结构体,而是使用一个特殊的 map[string]interface{} 类型的变量,并将其作为参数传递给 Decode 方法调用:

func main() {
    /* ... <omitted for brevity > ... */
    var empty map[string]interface{}
    err = d.Decode(&empty)
    // process error
    // prints map[router:[map[asn:64512 hostname:router1.example.com
    // ip:192.0.2.1] map[asn:65535 hostname:router2.example.com
    // ip:198.51.100.1]]]
    fmt.Printf("%v\n", empty)
    /* ... <continues next > ... */
}

一个 空接口,或 interface{},没有定义任何方法,这意味着它可以持有任何值——整数字符串浮点或用户定义的。唯一的缺点是,由于 Go 是一种静态类型语言,这些值在执行显式类型转换之前仍然是空接口,也就是说,直到我们告诉 Go 我们期望看到什么类型。

从我们之前示例中解码的 JSON 内容的 map[string]interface{} 类型的空变量的输出中,我们看到我们打印的映射值是一个数组。为了解析这些值并单独打印它们,我们必须告诉 Go 将它们视为未知值的切片,这可以表示为 []interface{}

func main() {
    /* ... <continues from before > ... */
    for _, r := range empty["router"].([]interface{}) {
        fmt.Printf("%v\n", r)
    }
}

这些打印语句的输出是两个 map[string]interface{} 映射的字符串表示形式,这意味着我们只解析了键(作为字符串),但值仍然是未定义的:

ch03/json-interface $ go run main.go
...
map[asn:64512 hostname:router1.example.com ip:192.0.2.1]
map[asn:65535 hostname:router2.example.com ip:198.51.100.1]

我们可以继续这个过程,直到我们找到这个对象所有值的正确类型,但这个过程显然相当繁琐。这就是为什么我们主要在编码库中看到这种方法,或者作为快速检查潜在未知输入数据结构的故障排除步骤。

对于 JSON 数据的快速操作,另一个选项是外部 Go 包,你可以使用这些包来执行深度 JSON 查找(GJSON)和设置(SJSON)操作,而无需为整个对象构建结构体。在两种情况下,解析仍然在幕后进行,但用户只看到他们的数据或错误,如果键不存在。我们在 第八章 的 gRPC 示例中使用了 GJSON(见 进一步阅读),网络 API

解码 XML

虽然 XML 输入文件看起来不同,但数据是相同的,Go 程序变化不大。下一个例子在书的存储库的 ch03/xml 文件夹中(见 进一步阅读):

<?xml version="1.0" encoding="UTF-8" ?>
<routers>
  <router>
    <hostname>router1.example.com</hostname>
    <ip>192.0.2.1</ip>
    <asn>64512</asn>
  </router>
  <router>
    <hostname>router2.example.com</hostname>
    <ip>198.51.100.1</ip>
    <asn>65535</asn>
  </router>
</routers>

如果我们将最终的程序与我们为 JSON 所做的程序进行比较,我们会注意到四个变化:

  • 我们导入 encoding/xml 而不是 encoding/json

  • 我们使用 XML 标签 xml:"hostname" 而不是结构体字段的 JSON 等效项。

  • 输入文件是一个 .xml 文件。

  • 我们使用来自 xml 库的 NewDecoder 函数。

代码的其他部分保持完全不变。接下来的代码输出突出显示实际改变的行;我们省略了其他行,因为它们与 JSON 示例中的相同:

package main
import (
    "os"
    "encoding/xml"
)
type Router struct {
    Hostname string `xml:"hostname"`
    IP       string `xml:"ip"`
    ASN      uint16 `xml:"asn"`
}
type Inventory struct {
    Routers []Router `xml:"router"`
}
func main() {
    file, err := os.Open("input.xml")
    /* ... <omitted for brevity > ... */
    d := xml.NewDecoder(file)
    /* ... <omitted for brevity > ... */
}

就像 JSON 一样,XML 也有自己的外部库,可以帮助你处理复杂输入数据,而无需构建 Go 类型的层次结构。其中之一是 xmlquery 包(见 进一步阅读),它允许你从 Go 中执行 XML Path Language(XPath)查询。

YAML

现在,让我们看看如何解析 YAML 库存。你可以在书籍仓库的 ch03/yaml 目录中找到这个示例(见 进一步阅读):

router:
  - hostname: "router1.example.com"
    ip: "192.0.2.1"
    asn: 64512
  - hostname: "router2.example.com"
    ip: "198.51.100.1"
    asn: 65535

到现在为止,你可能已经猜到了,从 JSON 示例中改变的数量和性质的事物与 XML 相同,也就是说,并不多。下面的代码片段仅突出显示改变的代码行,你可以在 ch03/yaml/main.go(见 进一步阅读)中找到完整的代码示例:

package main
import (
    "os"
    "gopkg.in/yaml.v2"
)
type Router struct {
    Hostname string `yaml:"hostname"`
    IP       string `yaml:"ip"`
    ASN      uint16 `yaml:"asn"`
}
type Inventory struct {
    Routers []Router `yaml:"router"`
}
func main() {
    /* ... <omitted for brevity > ... */
    d := yaml.NewDecoder(file)
    /* ... <omitted for brevity > ... */
}

这个 Go 程序产生的结果与 JSON 和 XML 示例相同,但在我们运行它之前,我们需要先获取外部 YAML 库依赖项(gopkg.in/yaml.v2):

ch03/yaml$ go get gopkg.in/yaml.v2
go get: added gopkg.in/yaml.v2 v2.4.0
ch03/yaml$ go run main.go
{Routers:[{Hostname:router1.example.com IP:192.0.2.1 ASN:64512} {Hostname:router2.example.com IP:198.51.100.1 ASN:65535}]}

也有可能在不预先定义数据结构的情况下解析和查询 YAML 文档。一个能够做到这一点的工具是 yq(见 进一步阅读),它以 jq(JSON 数据的 sed)的风格在 Go 中实现了一个 shell CLI 工具。你可以在你的 Go 程序中通过其内置的 yqlib 包使用 yq

编码

能够从源解码数据同样重要的是,在相反的方向处理数据,基于内存中的数据模型生成结构化数据文档。在下一个示例中,我们继续在 解码 部分留下的地方,并使用从 JSON 输入文件中获取的内存数据输出相应的 XML 文档。

我们在代码中必须做的第一件事是更新结构体标签,添加一个额外的键值对用于 XML。尽管这并非绝对必要,因为 XML 库可以回退到使用字段名,但通常认为显式注释所有相关的编码字段是一种最佳实践:

type Router struct {
    Hostname string `json:"hostname" xml:"hostname"`
    IP       string `json:"ip" xml:"ip"`
    ASN      uint16 `json:"asn" xml:"asn"`
}
type Inventory struct {
    Routers []Router `json:"router" xml:"router"`
}

该示例的完整代码可在书籍仓库的 ch03/json-xml 目录中找到(见 进一步阅读),因此为了简洁起见,我们只包括我们添加的额外代码,用于将 inv 变量编码成 XML 文档:

func main() {
    /* ... <omitted for brevity > ... */
    var dest strings.Builder
    e := xml.NewEncoder(&dest)
    err = e.Encode(&inv)
    // process error
    fmt.Printf("%+v\n", dest.String())
}

为了生成字符串输出,我们使用了 strings.Builder 类型,它实现了 Encode 方法所需的 io.Writer 接口。这突出了接口的力量,因为我们本可以将网络连接传递进去,并将 XML 数据发送到远程主机,几乎不需要相同的程序。下一个片段显示了程序的输出:

ch03/json-xml$ go run main.go
<Inventory><router><hostname>router1.example.com</hostname><ip>192.0.2.1</ip><asn>64512</asn></router><router><hostname>router2.example.com</hostname><ip>198.51.100.1</ip><asn>65535</asn></router></Inventory>

我们还没有介绍的一种编码格式是 Protocol Buffers,它是 第八章(B16971_08.xhtml#_idTextAnchor182)中 gRPC 部分的一部分。

到目前为止,我们已经涵盖了足够的 Go 语言理论,可以编写有效的程序来交互和自动化网络设备。我们剩下的唯一一点,也是语言最显著的特征之一,就是并发性。

并发

如果有一个特性可以用来在其它流行编程语言中区分 Go,那将是并发性。Go 内置的并发原语(goroutines 和 channels)是我们所知的编写高效代码的最佳抽象之一,可以同时运行多个任务。

您的程序从主 goroutine 开始,但任何时候,您都可以启动其他并发 goroutine 并在它们之间创建通信通道。与其它编程语言相比,这需要付出更少的努力和更少的代码,从而提高了开发体验和代码的支持性:

图 3.12 – Go 的并发

图 3.12 – Go 的并发

在本节中,我们将介绍以下并发原语:

  • Goroutines 和用于它们协调的sync包的使用

  • 我们如何使用 channels 在 goroutines 之间发送和接收数据

  • 在不同 goroutines 之间共享数据时使用互斥锁

Goroutines

将 Goroutines 视为 Go 运行时管理的用户空间线程是一种思考方式。它们在创建和管理上计算成本低廉,因此可以在普通机器上扩展到数十万,内存是主要的限制因素。

通常,我们会为可能阻塞主函数执行的任务创建 goroutines。您可以想象这为什么在网络自动化环境中特别有帮助,在那里我们必须处理远程网络调用并等待网络设备执行命令。

我们通过构建另一个网络自动化示例来介绍基本的 goroutine 理论。在前一节中,我们学习了如何加载和解析设备清单。在本节中,我们继续之前的讨论,看看如何与这些网络设备交互。

首先,我们使用一个包含单个设备的清单文件(input.yml)。这个文件位于书的存储库的ch03/single文件夹中(见进一步阅读):

router:
- hostname: sandbox-iosxe-latest-1.cisco.com
  platform: cisco_iosxe
  strictkey: false
  username: developer
  password: C1sco12345

为了存储这个清单,我们定义了一个类似于我们在编码/解码部分中使用的类型层次结构。代码示例输出仅显示了一些字段以节省篇幅:

type Router struct {
    Hostname  string `yaml:"hostname"`
    /* ... <omitted for brevity > ... */
}
type Inventory struct {
    Routers []Router `yaml:"router"`
}

我们定义了另一个名为getVersion的函数,它接受Router类型的参数,连接并检索软件和硬件版本信息,并在屏幕上打印出来。这个函数的确切实现并不重要,我们在这个章节中还没有关注它,但您可以在ch03/single/main.go(见进一步阅读)中看到完整的代码示例:

func getVersion(r Router) {
    /* ... <omitted for brevity > ... */
}
func main() {
    src, err := os.Open("input.yml")
    //process error
    defer src.Close()
    d := yaml.NewDecoder(src)
    var inv Inventory
    err = d.Decode(&inv)
    // process error
    getVersion(inv.Routers[0])
}

由于我们只有一个设备在清单中,我们可以直接使用切片索引来访问它。这个程序的执行时间略少于 2 秒:

ch03/single$ go run main.go
Hostname: sandbox-iosxe-latest-1.cisco.com
Hardware: [CSR1000V]
SW Version: 17.3.1a
Uptime: 5 hours, 1 minute
This process took 1.779684183s

现在,让我们看看一个类似的例子,它存储在ch03/sequential目录中(见进一步阅读),我们在库存中添加了两个额外的设备:

router:
- hostname: sandbox-iosxe-latest-1.cisco.com
  platform: cisco_iosxe
  ...
- hostname: sandbox-nxos-1.cisco.com
  platform: cisco_nxos
  ...
- hostname: sandbox-iosxr-1.cisco.com
  platform: cisco_iosxr
  ...

正如在控制流部分讨论的那样,我们可以使用for循环的range形式遍历数组和切片。在这里,我们遍历inv.Routers中的每个Router,将其分配给每次迭代的v变量。我们通过将其分配给空白标识符(写作_,下划线)来忽略索引的值。最后,我们为v路由器调用getVersion函数:

func main() {
    /* ... <omitted for brevity > ... */
    for _, r := range inv.Routers {
        getVersion(v)
    }
}

它大约需要 7 秒来执行,因为它会依次连接到每个设备:

ch03/sequential$ go run main.go
Hostname: sandbox-iosxe-latest-1.cisco.com
Hardware: [CSR1000V]
SW Version: 17.3.1a
Uptime: 5 hours, 25 minutes
Hostname: sandbox-nxos-1.cisco.com
Hardware: C9300v
SW Version: 9.3(3)
Uptime: 0 day(s), 3 hour(s), 2 minute(s), 18 second(s)
Hostname: sandbox-iosxr-1.cisco.com
Hardware: IOS-XRv 9000
SW Version: 6.5.3
Uptime: 2 weeks 8 hours 23 minutes
This process took 6.984502353s

这是我们可以通过使用 goroutines 来优化的代码的一个典型例子。我们最初需要做的只是在我们需要在 goroutine 中运行的语句之前添加一个go关键字:

func main() {
    /* ... <omitted for brevity > ... */
    for _, r := range inv.Routers {
        go getVersion(v)
    }
}

在代码示例中,我们为每个getVersionv)语句的调用生成一个单独的 goroutine。所有事情都在后台发生;在生成的 goroutine 中的任何阻塞语句都不会影响其他 goroutine,所以现在所有三个函数调用,加上主 goroutine,都是并发运行的。

这些生成的 goroutines 的默认行为是立即释放控制权,所以在这个例子中,代码遍历所有三个设备然后返回。它实际上并没有等待生成的 goroutines 完成。

但是,在我们的情况下,我们希望在退出程序之前看到所有三个函数调用的结果。这就是我们可以使用一个特殊的sync.WaitGroup类型的地方,它会阻塞主 goroutine,直到所有生成的 goroutines 完成。它是通过保持一个跟踪所有当前活动 goroutines 的计数器来做到这一点的,并且会阻塞直到该计数器降到零。

这就是我们如何在我们的代码示例中引入这个想法的方法:

  • 我们创建了一个新的wg变量,其类型为sync.WaitGroup

  • 当遍历我们的库存时,我们通过wg.Add(1)WaitGroup计数器增加一。

  • 每个生成的 goroutine 都包含一个运行getVersion的匿名函数,但在最后也通过defer语句调用wg.Done,以将WaitGroup计数器减一。

  • 主 goroutine 在wg.Wait上阻塞,直到WaitGroup计数器变为零。这发生在所有getVersion函数的生成实例返回之后。

你可以在ch03/concurrency/main.go(见进一步阅读)中找到这个示例的完整代码:

func main() {
    /* ... <omitted for brevity > ... */
    var wg sync.WaitGroup
    for _, v := range inv.Routers {
        wg.Add(1)
        go func(r Router) {
            defer wg.Done()
            getVersion(r)
        }(v)
    }
    wg.Wait()
}

现在,让我们看看这些更改对程序执行时间的影响:

ch03/concurrency$ go run main.go
Hostname: sandbox-iosxe-latest-1.cisco.com
Hardware: [CSR1000V]
SW Version: 17.3.1a
Uptime: 5 hours, 26 minutes
Hostname: sandbox-iosxr-1.cisco.com
Hardware: IOS-XRv 9000
SW Version: 6.5.3
Uptime: 2 weeks 8 hours 25 minutes
Hostname: sandbox-nxos-1.cisco.com
Hardware: C9300v
SW Version: 9.3(3)
Uptime: 0 day(s), 3 hour(s), 4 minute(s), 11 second(s)
This process took 2.746996304s

我们已经将时间缩短到大约 3 秒,这是与库存中最慢的设备通信所需的时间。考虑到我们不需要更改任何工作函数(在这种情况下是getVersion),这是一个相当大的胜利。你可能可以将相同的重构应用到许多其他类似的程序上,只需对它们的现有代码库进行最小改动。

这种方法与原生同步函数配合得很好,你可以运行它,无论是否有 goroutine。但是,如果我们知道某个函数始终在 goroutine 中运行,那么从一开始就让它具有 goroutine 意识是完全可能的。例如,这就是我们如何重构getVersion函数以接受额外的WaitGroup参数,并将wg.Done调用作为函数的一部分:

func getVersion(r Router, wg *sync.WaitGroup) {
    defer wg.Done()
    /* ... <omitted for brevity > ... */
}

有这样一个函数将简化主函数的代码,因为我们不再需要将所有内容包裹在一个匿名函数中,只是为了调用wg.Done

func main() {
    /* ... <omitted for brevity > ... */
    for _, v := range inv.Routers {
        wg.Add(1)
        go getVersion(v, &wg)
    }
    wg.Wait()
}

本例的完整代码位于ch03/concurrency2目录中(见进一步阅读)。

Channels

一旦任何人熟悉了 goroutines,他们通常想要做的下一件事就是在它们之间交换数据。Go channels 允许 goroutines 相互通信。用现实世界的类比来描述 Go channels,它们就像先进先出(first-in-first-out)的管道——它们有固定的吞吐量,并允许你在两个方向上发送数据。

你可以使用 channels 进行 goroutine 同步(一种用于工作协调的信号形式)和通用数据交换。

我们使用make关键字创建 channels,它初始化它们并使它们准备好使用。make接受的两个参数是 channel 类型,它定义了你可以通过 channel 交换的数据类型,以及一个可选的容量。channel 容量决定了在它开始阻塞发送者之前可以存储多少未接收到的值,此时它充当一个缓冲区。

以下代码片段展示了我们如何在 channel 上发送和接收一个整数。在这里,send是我们想要发送到我们创建的chchannel 的值。<-操作符允许我们向 channel 发送数据。接下来,我们声明一个receive变量,其值来自chchannel:

func main() {
    ch := make(chan int, 1)
    send := 1
    ch <- send
    receive := <-ch
    // prints 1
    fmt.Println(receive)
}

但是,在单个 goroutine 中发送和接收数据不是这里的最终目标。让我们检查另一个使用 channels 在不同 goroutines 之间进行通信的例子。我们拾取本节中使用的例子,并引入另一个worker函数,其任务是打印getVersion函数产生的结果。

新的printer函数使用for循环从inchannel 接收值,并在终端上打印它们:

func printer(in chan data {
    for out := range in {
        fmt.Printf("Hostname: %s\nHW: %s\nSW Version: %s\nUptime: %s\n\n", out.host, out.hw, out.version, out.uptime)
    }
}

在我们启动任何 goroutine 之前,我们在主 goroutine 中创建chchannel,并将其作为参数传递给getVersionprinter函数。我们首先启动的额外 goroutine 是printer函数的一个实例,它监听来自设备的消息,通过chchannel:

funcmain() {
    /* ... <omitted for brevity > ... */
    ch := make(chan data)
    go printer(ch)
    var wg sync.WaitGroup
    for _, v := range inv.Routers {
        wg.Add(1)
        go getVersion(v, ch, &wg)
    }
    wg.Wait()
    close(ch)
}

下一步是启动一个 goroutine 来处理清单中的每个网络设备,捕获我们需要的输出,并使用getVersion函数将其通过 channel 发送。在收集并打印数据后,我们关闭 channel 并结束程序:

ch03/concurrency3$ gorun main.go
Hostname: sandbox-iosxe-latest-1.cisco.com
HW: [CSR1000V]
SW Version: 17.3.1a
Uptime: 1 day, 12 hours, 42 minutes
Hostname: sandbox-iosxr-1.cisco.com
HW: IOS-XRv 9000
SW Version: 7.3.2
Uptime: 1 day 2 hours 57 minutes
Hostname: sandbox-nxos-1.cisco.com
HW: C9300v
SW Version: 9.3(3)
Uptime: 5 day(s), 6 hour(s), 25 minute(s), 44 second(s)

本例的完整代码位于ch03/concurrency3 (Further reading) 目录中。

Channels and Timers

在前几个例子中,我们没有考虑到的场景是网络设备不可达,或者与它的连接中断,或者设备可能永远无法返回我们需要的输出。在这些情况下,我们需要设置超时,这样我们就不必永远等待,并且可以优雅地结束程序。

您可以在连接级别处理此问题,但通道还提供了一些资源,通过这些计时器类型来跟踪时间:

  • 定时器 — 等待一定时间

  • 计时器 — 在某个间隔内重复执行操作

定时器

Timer可以帮助您为程序定义超时。为了说明这一点,我们可以重写我们一直在工作的示例,在主函数中打印来自ch通道的所有消息,而不是调用一个单独的函数(printer)。

在无限循环中的select语句是这样处理的。与switch语句不同,当我们不需要选择选项时,我们使用select与通道一起使用。对于每次迭代,我们要么等待来自ch通道的消息,要么如果 5 秒已过(time.After(5 * time.Second)),我们关闭通道并退出程序:

func main() {
    /* ... <omitted for brevity > ... */
    for {
        select {
        case out := <-ch:
            fmt.Printf(
    "Hostname: %s\nHW: %s\nSW Version: %s\nUptime:%s\n\n",
            out.host, out.hw, out.version, out.uptime)
        case <-time.After(5 * time.Second):
            close(ch)
            fmt.Println("Timeout: 5 seconds")
            return
        }
    }
}

这迫使运行时始终为 5 秒,即使不是所有任务都已完成。这不是解决这个问题的最有效方法,但它展示了如何超时而不引入标准库中的context包,您也可以在这个场景中使用该包。

本例的完整代码位于书籍仓库的ch03/concurrency5目录中(参见进一步阅读)。

计时器

计时器的一个常见用途是在需要执行周期性任务的情况下。在下一个代码示例中,我们创建了一个每半秒运行ticker,我们将其用作触发器,向终端打印消息。我们还创建了一个done通道,仅用于在 2 秒和 100 毫秒后停止程序的执行:

func main() {
    ticker := time.NewTicker(500 * time.Millisecond)
    done := make(chan bool)
    go repeat(done, ticker.C)
    time.Sleep(2100 * time.Millisecond)
    ticker.Stop()
    done <- true
}

time包中的计时器有一个C通道,它们使用该通道在每个间隔内发出信号。我们将此通道和done通道传递给我们在 goroutine 中执行的repeat函数:

func repeat(d chan bool, c <-chan time.Time) {
    for {
        select {
        case <-d:
            return
        case t := <-c:
            fmt.Println("Run at", t.Local())
        }
    }
}

此函数运行一个无限循环,等待来自tickerdone通道的信号以结束执行。输出如下:

ch03/ticker$ gorun main.go
Tick at 2021-11-17 23:19:33.914906389 -0500 EST
Tick at 2021-11-17 23:19:34.414279709 -0500 EST
Tick at 2021-11-17 23:19:34.915058301 -0500 EST

本例的完整代码位于ch03/ticker目录中(参见进一步阅读)。

共享数据访问

Channels 是线程安全的,因此始终使用它们作为 goroutines 之间数据通信的默认选项是个好主意。但有时,您可能仍然需要访问和更改多个 goroutine 都可以访问的数据。

并发数据访问的问题在于,当许多 goroutine 尝试更改相同的字段或从其他人可能正在更改的字段中读取时,可能会导致数据损坏。Go 的sync包包括三种辅助类型,您可以使用它们来序列化这些类型的操作:

  • sync.Mutex类型是一种通用互斥锁,有两个状态——锁定和解锁。

  • sync.RWMutex类型是专门用于读写操作的互斥锁,其中只有写操作是互斥的,但同时的读操作是安全的。

  • sync.Map互斥锁覆盖了几个映射边缘情况,我们在这本书中没有深入探讨。sync.Map 文档中提到了它们(参见进一步阅读)。

现在,让我们看看如何使用sync.RWMutexto来保护并发映射访问的示例。以本节中使用的示例主题为基准,让我们添加另一个变量来记录我们是否能够成功连接到远程设备。我们称这个变量为isAlive,并将其作为参数传递给getVersion函数:

func main() {
    /* ... <omitted for brevity > ... */
    isAlive := make(map[string]bool)
    /* ... <omitted for brevity > ... */
    for _, v := range inv.Routers {
        wg.Add(1)
        go getVersion(v, ch, &wg, isAlive)
    }
    /* ... <omitted for brevity > ... */
}

我们将m互斥锁定义为包级全局变量,以确保所有函数都使用相同的互斥锁进行同步。我们在更改isAlive映射之前锁定这个互斥锁,并在getVersion函数中更改后立即解锁:

var m sync.RWMutex = sync.RWMutex{}
func getVersion(r Router, out chan data, wg *sync.WaitGroup, isAlive map[string]bool) {
    defer wg.Done()
    /* ... <omitted for brevity > ... */
    rs, err := d.SendCommand("show version")
    if err != nil {
        fmt.Printf("fail to send cmd for %s: %+v\n",
                    r.Hostname, err)
        m.Lock()
        isAlive[r.Hostname] = false
        m.Unlock()
        return
    }
    m.Lock()
    isAlive[r.Hostname] = true
    m.Unlock()
}

最后,我们在主函数中的循环中添加了另一个互斥锁,该锁在迭代映射时使用只读锁,以防止在过程中意外修改:

func main() {
    /* ... <omitted for brevity > ... */
    m.RLock()
    for name, v := range isAlive {
        fmt.Printf("Router %s is alive: %t\n", name, v)
    }
    m.RUnlock()
    /* ... <omitted for brevity > ... */
}

你可以在ch03/concurrency4目录中查看完整的代码(参见进一步阅读)。下一个输出显示了该程序产生的结果:

ch03/concurrency4$ go run main.go
Hostname: sandbox-iosxe-latest-1.cisco.com
Hardware: [CSR1000V]
SW Version: 17.3.1a
Uptime: 8 hours, 27 minutes
Hostname: sandbox-iosxr-1.cisco.com
Hardware: IOS-XRv 9000
SW Version: 7.3.2
Uptime: 1 day 11 hours 43 minutes
Hostname: sandbox-nxos-1.cisco.com
Hardware: C9300v
SW Version: 9.3(3)
Uptime: 5 day(s), 15 hour(s), 11 minute(s), 42 second(s)
Router sandbox-iosxe-latest-1.cisco.com is alive: true
Router sandbox-iosxr-1.cisco.com is alive: true
Router sandbox-nxos-1.cisco.com is alive: true
This process took 3.129440011s

有时,你可能会忘记使用互斥锁,特别是对于非平凡的用户定义数据类型,或者当你意外地在 goroutines 之间泄漏变量时。在这些情况下,你可以使用go工具内置的数据竞争检测器。将-race标志添加到任何 go test/run/build命令中,以检查并获取对共享内存的任何未受保护访问请求的报告。

为了了解它是如何工作的,让我们关注我们在getVersion函数的不同实例上并发操作的isAlive映射。之前,我们用互斥锁包围了它,现在我们在ch03/race/main.go中移除了它(参见进一步阅读):

func getVersion(r Router, out chan map[string]interface{}, wg *sync.WaitGroup, isAlive map[string]bool) {
    defer wg.Done()
    /* ... <omitted for brevity > ... */
    // m.Lock()
    isAlive[r.Hostname] = true
    // m.Unlock()
    out <- "test"
}

当你使用额外的-race标志运行程序时,Go 会突出显示它检测到的数据竞争条件:

ch03/race$ gorun -race main.go
MESSAGE: test
MESSAGE: test
==================
WARNING: DATA RACE
Write at 0x00c00011c6f0 by goroutine 9:
  runtime.mapassign_faststr()
      /usr/local/go/src/runtime/map_faststr.go:202 +0x0
  main.getVersion()
      ~/Network-Automation-with-Go/ch03/race/main.go:35 +0xeb
  main.main·dwrap·5()
      ~/Network-Automation-with-Go/ch03/race/main.go:74 +0x110
...
==================
MESSAGE: test
Router sandbox-iosxe-latest-1.cisco.com is alive: true
Router sandbox-iosxr-1.cisco.com is alive: true
Router sandbox-nxos-1.cisco.com is alive: true
This process took 1.918348ms
Found 1 data race(s)
exit status 66

Go 的内置数据竞争检测器减轻了调试数据竞争的任务,数据竞争是并发系统中最难调试的 bug 之一。

并发注意事项

并发是一个强大的工具。你甚至可以在代码的每个地方使用 goroutines,并遵循如工作池等设计模式,将工作分配给不同的 goroutines,以获得相对较小的复杂性增加的初始速度提升。

但是,重要的是要考虑并发不是并行(见进一步阅读),在协调 goroutines 并将它们映射到操作系统线程的过程中,始终存在一些开销。我们也不应该忘记,底层硬件资源是有限的,因此并发性能的提升也是有限的,因为它们不可避免地会在某个点上趋于平稳(见进一步阅读部分的在 Go 中模拟真实世界系统)。

最后,并发编程很难;编写安全的代码很难,当它出错时,推理和调试也很困难。不要过度使用 goroutines 来设计你的代码,只在真正需要时使用它们,衡量你的收益并检测竞争条件,尽可能避免内存共享,并通过通道进行通信。

摘要

本章总结了 Go 作为编程语言的理论介绍。我们从 Go 变量类型及其操作开始,到回顾 Go 程序的关键构建块,以及如何利用 Go 标准库中最著名的包来帮助你构建可扩展的应用程序。

从下一章开始,我们将注意力转向更适用于现实场景的网络特定任务。我们仍然在整本书中介绍一些理论概念,但大部分内容都是关于具体用例,而不是抽象理论。

进一步阅读

第四章:使用 Go 进行网络(TCP/IP)

每个网络工程师都曾在某个时候学习过开放系统互连OSI)模型的七层。它的一个更简洁的版本,只有四层,就是 TCP/IP 模型,这是控制互联网通信的架构模型。

每一层定义了一个功能,每一层的数据通信协议执行一个。这些层堆叠在一起,所以我们通常称这个协议集合为协议栈。数据包必须通过协议栈的每一层才能到达目标主机。

Go 有几个包可以用于 TCP/IP 模型每一层的协议。这使得我们能够为各种用例构建解决方案——从 IP 地址管理到通过网络运行应用程序事务,甚至实现网络协议:

图 4.1 – TCP/IP 模型

图 4.1 – TCP/IP 模型

在本章中,我们关注 TCP/IP 模型每一层的应用场景:

  • 链接

  • 互联网

  • 传输

  • 应用程序

技术要求

我们假设你对命令行、Git 和 GitHub 有基本的了解。你可以在此章的 GitHub 仓库中找到此章的代码示例:github.com/PacktPublishing/Network-Automation-with-Go,在ch04文件夹下。

要运行示例,你需要执行以下操作:

  1. 为你的操作系统安装 Go 1.17 或更高版本。你可以遵循第一章中“介绍”部分的安装 Go部分的说明,或者访问go.dev/doc/install。本章中的两个示例,特别是针对 net/netip 包的示例,需要 Go 1.18 或更高版本。

  2. 使用git clone https://github.com/PacktPublishing/Network-Automation-with-Go.git克隆本书的 GitHub 仓库。

  3. 将目录更改为示例文件夹:cd Network-Automation-with-Go/ch04/trie

  4. 执行go run main.go

链路层

我们从 TCP/IP 模型的底层开始,该层发送和接收链路层数据帧。在本节中,我们将涵盖以下主题:

  • 网络接口管理

  • 以太网的基本操作

网络接口

随着我们越来越多地看到基于 Linux 的网络操作系统,了解 Go 如何帮助我们在这个环境中与网络接口交互是有意义的。

Linux 通过一个名为 Netlink 的内核接口暴露其网络内部结构。这个接口允许用户空间的应用程序,如 Go,通过标准套接字 API 与内核通信。最常见的是,TCP/UDP 库使用 Netlink 套接字发送和接收数据,但它们也可以与大多数 Linux 网络结构一起工作,从接口到路由和 nftables。

幸运的是,你不需要了解或理解低级的 Netlink API,因为有许多 Go 包提供了高级抽象,这使得工作变得更加容易。一些值得注意的 Netlink 包包括以下内容:

  • Go 标准库中的syscall包(进一步阅读)包括一些通常由高级包使用的高级原语。

  • 第三方 Go 包vishvananda/netlink进一步阅读)是高级 Netlink 包的早期实现之一,被 Docker、Istio 和 Kubernetes CNI 插件等众多开源项目广泛使用。

  • 基于mdlayher/netlink进一步阅读)包的插件生态系统是一组相对较新的项目,它们在共同的基础上以更符合语言习惯和可维护的方式实现。

这些 Netlink 包具有不同的功能覆盖范围,你选择的包通常取决于你的应用程序需求。为了演示,我们展示了如何切换接口的管理状态,为此,我们从mdlayher/netlink生态系统(进一步阅读)中选择了一个 rtnetlink 包。

让我们分三个阶段来分解和回顾这个示例。首先,我们导入 Netlink 包rtnetlink/rtnl,这是围绕mdlayher/netlink包开发的松散相关包之一,使用Dial方法与 Netlink 套接字建立连接,然后通过连接使用Links方法检索所有本地接口的列表:

func main() {
    conn, err := rtnl.Dial(nil)
    // process error
    defer conn.Close()
    links, err := conn.Links()
    /* ... <continues next > ... */
}

这段代码与我们为所有远程连接在 Go 中执行的操作相似,这也是为什么 Go 开发者认为这个包更符合语言习惯。一旦我们有了变量links中所有接口的列表,我们就可以遍历它们以找到任何感兴趣的接口。

假设我们想要切换系统中存在的lo接口。我们遍历变量links中的所有接口,如果在其中找到lo接口,我们就打印出该接口的数据,并将接口值存储在我们称之为loopback的变量中,这样我们就可以使用LinkDown将其关闭,稍后使用LinkUp将其重新启用:

func main() {
    /* ... <continues from before > ... */
    var loopback *net.Interface
    for _, l := range links {
        if l.Name == "lo" {
            loopback = l
            log.Printf("Name: %s, Flags:%s\n", 
                        l.Name, l.Flags)
        }
    }
    /* ... <continues next > ... */
}

在运行LinkDownLinkUp之后,你可以通过在每次更改后从 Netlink 检索接口设置来验证更改是否产生了预期的效果。我们更新loopback变量以实现统一的打印语句:

func main() {
    /* ... <continues from before > ... */
    conn.LinkDown(loopback)
    loopback, _ = conn.LinkByIndex(loopback.Index)
    log.Printf("Name: %s, Flags:%s\n", 
                loopback.Name, loopback.Flags)
    conn.LinkUp(loopback)
    loopback, _ = conn.LinkByIndex(loopback.Index)
    log.Printf("Name: %s, Flags:%s\n", 
                loopback.Name, loopback.Flags)
}

你可以在ch04/netlink进一步阅读)中找到这个示例的完整内容,你必须使用CAP_NET_ADMIN能力(进一步阅读)或以 root 用户身份运行:

ch04/netlink $ sudo go run main.go
2021/11/24 20:55:29 Name: lo, Flags:up|loopback
2021/11/24 20:55:29 Name: lo, Flags:loopback
2021/11/24 20:55:29 Name: lo, Flags:up|loopback

我们刚刚触及了 Netlink API 的表面,因为它的能力远远超出了本书的范围。今天,你可以使用 Netlink 进行 IP 路由管理、访问列表、服务质量QoS)策略以及扩展伯克利包过滤器eBPF)程序附加等操作。希望这一节提供了足够的信息,让你对 Netlink API 交互所涉及的内容有一个大致的了解,因为现在我们必须转到下一个主题,探索 Go 如何处理今天最广泛使用的链路层协议:以太网。

以太网

与以太网一起工作可能涉及各种活动,从低级协议解码、操作和编码到与设备 API 交互以收集以太网硬件信息。Go 有一系列广泛的包可以帮助你处理各种与以太网相关的任务:

  • 最广泛使用的包处理包之一是google/gopacket进一步阅读),你可以用它进行数据包捕获和协议解码。它不仅限于以太网,我们将在第十章 网络监控中更详细地介绍它。

  • 我们刚才提到的 Netlink API 包可以查询基于 Linux 操作系统的链路层硬件信息。

  • 另一个广泛使用的以太网编码和解码包mdlayher/ethernet进一步阅读)允许你将帧在二进制线格式和静态 Go 类型表示之间进行转换。

在下一个示例中,我们将介绍一个虚拟 IPVIP)功能的基本实现。我们这个实现是基于kube-vip进一步阅读)包的,这是一个 Kubernetes 控制平面 VIP 控制器。它的工作方式是一个两步过程:

  1. 它将一个新的VIP分配给本地网络接口之一。

  2. 它定期发送无用的地址解析协议ARP)数据包,让本地广播域中的每个人都知道这个 VIP。

让我们从第一步开始回顾,看看我们是如何将一个 VIP 分配给接口的。我们将使用与网络接口部分相同的包来与 Netlink 交互(rtnetlink/rtnl),只是这次我们使用AddrAdd方法将 IP 前缀分配给指定的接口。

在程序中,我们通过flag包使用 CLI 传递我们想要分配给这个 VIP 地址的接口名称,并将这个值存储在intfStr变量中。有了这个信息,我们使用mdlayher/packet包通过Listen函数在这个接口上发送和接收 ARP 数据包:

func main() {
    intfStr := flag.String("intf", "", "VIP interface")
    flag.Parse()
    conn, err := rtnl.Dial(nil)
    // process error
    defer conn.Close()
    netIntf, err := net.InterfaceByName(*intfStr)
    ethSocket, err := packet.Listen(netIntf,
                                packet.Raw, 0, nil)
    // process error
    defer ethSocket.Close()
    /* ... <continues next > ... */
}

要实际将 VIP 地址分配给接口,我们创建了一个vip结构体类型,它允许我们保存所有需要传递给AddrAdd以实现这一功能的信息,如下一个输出所示:

const VIP1 = "198.51.100.1/32"
type vip struct {
    IP      string
    netlink *rtnl.Conn
    intf    *net.Interface
    l2Sock  *raw.Conn
}
func (c *vip) addVIP() error {
    err := c.netlink.AddrAdd(c.intf,
                        rtnl.MustParseAddr(c.IP))
    // process error
    return nil
}
func main() {
    /* ... <continues from before > ... */
    v := &vip{
        IP:      VIP1,
        intf:    netIntf,
        netlink: rtnl,
        l2Sock:  *packet.Conn,
    }
    err = v.addVIP()
     /* ... <continues next > ... */
}

一旦我们分配了新的 VIP,我们就可以开始发送for循环,该循环暂停 3 秒钟然后再次运行。在这个循环中,我们包括一个带有初始化(err := v.sendGARP())和条件(err != nil)语句的if语句。Go 在评估条件表达式之前执行初始化语句:

func main() {
    /* ... <continues from before > ... */
    for {
        select {
        /* ... <omitted for brevity > ... */
        case <-timer.C:
            if err := v.sendGARP(); err != nil {
                log.Printf("fail send GARP %s",
                                err)
                cancel()
            }
        }
    }
}

sendGARP方法中,我们可以找到大部分与以太网相关的代码。在这里,我们使用两个包来帮助我们构建 GARP。

我们首先需要构建 GARP 有效载荷,并用本地接口的 MAC 地址和 VIP 的 IP 地址填充它。为此,我们利用mdlayher/arp进一步阅读)包:

func (c *vip) sendGARP() error {
    /* ... <omitted for brevity > ... */
    arpPayload, err := arp.NewPacket(
        arp.OperationReply,  // op
        c.intf.HardwareAddr, // srcHW
        ip,                  // srcIP
        c.intf.HardwareAddr, // dstHW
        ip,                  // dstIP
    )
    // process error

    arpBinary, err := arpPayload.MarshalBinary()
    /* ... <continues next > ... */
}

然后,我们需要使用mdlayher/ethernet进一步阅读)包将 GARP 有效载荷包裹在以太网帧中,并设置正确的以太网头部:

func (c *vip) sendGARP() error {
    /* ... <continues from before > ... */
    ethFrame := &ethernet.Frame{
        Destination: ethernet.Broadcast,
        Source:      c.intf.HardwareAddr,
        EtherType:   ethernet.EtherTypeARP,
        Payload:     arpBinary,
    }

    return c.emitFrame(ethFrame)
}

最后一步是发送一个二进制帧,为此,我们使用实现 Linux 数据包套接字接口的mdlayher/packet进一步阅读)包,该接口允许我们在设备驱动程序(链路层)级别发送和接收数据包。我们已经使用Listen(如前所述)打开了一个原始套接字ethSocket,因此现在我们可以将我们的二进制帧写入其中(vip结构体的l2Sock字段):

func (c *vip) emitFrame(frame *ethernet.Frame) error {
    b, err := frame.MarshalBinary()
    // process error

    addr := &packet.Addr{
                HardwareAddr:ethernet.Broadcast}
    if _, err := c.l2Sock.WriteTo(b, addr); err != nil {
        return fmt.Errorf("emitFrame failed: %s", err)
    }

    log.Println("GARP sent")
    return nil
}

您可以在ch04/vip进一步阅读)中找到完整的示例。您需要以提升的权限运行它,才能更改网络接口。生成的输出将类似于以下内容:

ch04/vip $ sudo go run main.go -intf eth0
2021/11/25 18:47:51 GARP sent
2021/11/25 18:47:54 GARP sent
^C2021/11/25 18:47:56 Received syscall: interrupt
2021/11/25 18:47:57 Cleanup complete

到目前为止,任何在本地网络段上有重叠 IP 子网的主机都应该能够 ping 198.51.100.1地址(如果它们接受 GARPs)。要结束程序,您可以按Ctrl + C,程序将清理 VIP 接口。

网络工程师或开发者直接与以太网交互的情况很少见,但了解使用 Go 进行“谈论以太网”的感觉仍然很有价值。在下一节中,我们将向上移动一层,并介绍网络层包和示例。

网络层

在 OSI 模型中,网络层或互联网层负责传输可变长度的网络数据包,并通过一个或多个网络将数据从源传输到目的。

在这个层中,目前占主导地位的协议是互联网协议IP),它可以是两个版本中的任何一个:版本 4(IPv4)或版本 6(IPv6)。网络层还包括诊断协议,如互联网控制消息协议ICMP),一个安全的网络协议套件,如互联网协议安全IPsec),以及包括开放最短路径优先OSPF)在内的路由协议。

IP 通过从头部和有效载荷构建的 IP 数据报交换信息,该数据报由链路层作为帧通过特定的网络硬件(如以太网)传输。IP 头部携带数据包的 IP 源地址和目的地址,用于通过互联网路由该数据包。

在本节中,我们回顾以下内容:

  • 如何使用 net 包解析和执行与 IP 地址相关的常见任务

  • 新的 net/netip 包及其为 Go 标准库带来的特性

  • 与 IP 地址一起工作的真实 Go 项目的示例

net 包

标准库中的 net 包(进一步阅读)包括了一系列用于网络连接的工具和资源,对于本节来说最重要的是,它定义了与 IP 地址一起工作的类型和接口。其中一种类型是 IP,表示为一个字节的切片。此类型适用于 4 字节(IPv4)或 16 字节(IPv6)的切片:

type IP []byte

让我们先探索如何从 IPv4 地址的十进制表示 192.0.2.1 创建一个 IP 类型变量:

图 4.2 – 一个 IPv4 地址

图 4.2 – 一个 IPv4 地址

将 IPv4 地址转换为 IP 类型的一种方法是通过使用 net 包中的 ParseIP 函数,该函数接受一个字符串作为参数,并返回一个 IP 值:

func main() {
    ipv4 := net.ParseIP("192.0.2.1")
    fmt.Println(ipv4)
}

IPv6 地址对我们眼睛来说处理起来稍微困难一些,但对于 Go 来说,它们只是像 IPv4 一样的一块块位:

图 4.3 – 一个 IPv6 地址

图 4.3 – 一个 IPv6 地址

ParseIP 函数还可以解析 IPv6 的字符串表示形式,以返回 IP 类型的变量:

func main() {
    ipv6 := net.ParseIP("FC02:F00D::1")
    fmt.Println(ipv6)
}

IP 类型代表一个 IP 地址,因此您可以使用相同的 IP 方法来处理 IPv4 或 IPv6 地址。假设您想检查一个 IP 地址是否在私有地址范围内。

net 包中的 IsPrivate 方法根据 RFC 1918(私有互联网地址分配)和 RFC 4193(唯一的本地 IPv6 单播地址)自动为 IPv4 和 IPv6 提供答案:

func main() {
    // prints false
    fmt.Println(ipv4.IsPrivate())
    // prints true
    fmt.Println(ipv6.IsPrivate())
}

另一个有趣的数据类型是 IPNet,它描述了一个 IP 前缀或 IP 网络,因此它将 IPMask 添加到 IP 中以表示其掩码:

type IPNet struct {
    IP   IP     // network number
    Mask IPMask // network mask
}

net 包中的掩码也是一个字节的切片,以下使用 CIDRMask 函数的示例可以更好地解释。onesbits 参数都是整数,如函数签名所示。第一个参数 onesIPMask 中的 1 的数量,其余位都设置为 0。掩码的总长度以 bits 为单位衡量:

type IPMask []byte
func CIDRMask(ones, bits int) IPMask

让我们看看 IPv4 的一个示例,使用 32 位掩码:

func main() {
    // This mask corresponds to a /31 subnet for IPv4.
    // prints [11111111 11111111 11111111 11111110]
    fmt.Printf("%b\n",net.CIDRMask(31, 32))
}

IPv6 的工作方式类似,但期望掩码长度为 128:

func main() {
    // This mask corresponds to a /64 subnet for IPv6.
    // prints ffffffffffffffff0000000000000000
    fmt.Printf("%s\n",net.CIDRMask(64, 128))
}

要从字符串中解析前缀或网络,您可以使用 net 包中的 ParseCIDR 函数。您将获得三个值——一个 IP 类型的网络地址,一个 IPnet 类型的 IP 前缀,以及一个错误:

func main() {
    ipv4Addr, ipv4Net, err := net.ParseCIDR("192.0.2.1/24")
    // process error
    // prints 192.0.2.1
    fmt.Println(ipv4Addr)
    // prints 192.0.2.0/24
    fmt.Println(ipv4Net)
}

下一个示例展示了使用与 IPv4 相同的函数来使用 ParseCIDR 对 IPv6 进行解析:

func main() {
    ipv6Addr, ipv6Net, err :=  net.ParseCIDR(
                                "2001:db8:a0b:12f0::1/32")
    // process error
    // prints 2001:db8:a0b:12f0::1
    fmt.Println(ipv6Addr)
    // prints 2001:db8::/32
    fmt.Println(ipv6Net)
}

这些示例的代码可在 ch04/net/main.go 中找到(进一步阅读)。

这是 Go 中进行基本 IP 地址操作的标准方式。然而,不久前,有人试图通过一个我们接下来要审查的包将一个新的 IP 地址类型添加到标准库中。

新的 netip 包

为了改进 Go 中 net.IP 数据结构在 IP 地址方面的不足之处,一组 Go 开发者提出了一种新的 IP 地址类型。这是一个迭代过程,他们在博客文章 netaddr.IP: Go 的新 IP 地址类型 中进行了记录(进一步阅读)。此包现在在 Go 1.18 中作为 net/netip 提供。

net/netip 包定义了一个新的类型 Addr,该类型将 IPv4 和 IPv6 地址存储为一个大端 128 位数字。此类型还有一个特殊的哨兵字段 z,它可以具有以下任何值:

  • nil 表示无效的 IP 地址(对于零 Addr)。

  • z4 表示 IPv4 地址。

  • z6noz 表示没有区域的 IPv6 地址。

  • 否则,它是 IPv6 区域名称字符串。

Go 中的数据结构如下所示:

type Addr struct {
    addr uint128
    z *intern.Value
}

与传统的 net.IP 相比,这种新的 Addr 类型有以下主要优点:

  • 它占用的内存更少。

  • 它是不可变的,因此可以安全地传递。

  • 它支持 == 操作,因此您可以用作映射键。

让我们看看如何从字符串中解析 IP 地址以获取 Addr 类型,并使用该包中的一些方法。在第一个例子中,我们解析一个 IPv4 地址,并使用 IsMulticast 方法检查它是否在 RFC 1112 224.0.0.0/4 组播范围内。第二个 IPv6 的例子展示了如何使用相同的函数 ParseAddr 从字符串中解析 IP 地址,并使用 IsLinkLocalUnicast 方法根据 RFC 4291 检查 IPv6 是否是链路本地地址或网络 FE80::/10 的一部分:

func main() {
    IPv4, err := netip.ParseAddr("224.0.0.1")
    // process error
    // prints IPv4 address is Multicast
    if IPv4.IsMulticast() {
        fmt.Println("IPv4 address is Multicast")
    }
    IPv6, err := netip.ParseAddr("FE80:F00D::1")
    // process error
    // prints IPv6 address is Link Local Unicast
    if IPv6.IsLinkLocalUnicast() {
        fmt.Println("IPv6 address is Link Local Unicast")
    }
}

现在,如果您有一个使用 net.IP 的现有程序,您也可以将该类型作为 netip 的输入。对于 IPv4 和 IPv6,它使用 AddrFromSlice 函数解析 net.IP 类型。IsX 方法告诉我们这是一个 IPv4 还是 IPv6 地址:

func main() {
    ipv4 := net.ParseIP("192.0.2.1")
    IPv4s, _ := netip.AddrFromSlice(ipv4)
    fmt.Println(IPv4s.String())
    fmt.Println(IPv4s.Unmap().Is4())
}

此示例的代码可在 ch04/parseip 找到(进一步阅读):

ch04/parseip$ go run main.go
::ffff:192.0.2.1
true

为了表示 IP 前缀(CIDR),net/netip 定义了一个名为 Prefix 的类型,该类型有一个 Addr 和一个整数,用于在 bits 字段中指定前缀长度(从 0 到 128):

type Prefix struct {
    ip Addr
    bits int16
}

要从字符串中解析前缀,您可以使用 ParsePrefix 函数或 MustParsePrefix,后者调用 ParsePrefix 并在出错时引发恐慌,这意味着您不需要在代码中检查返回的错误。让我们看看使用 MustParsePrefix 生成前缀并检查一些 IP 地址是否在该前缀地址范围内的程序:

func main() {
    addr1 := "192.0.2.18"
    addr2 := "198.51.100.3"
    network4 := "192.0.2.0/24"
    pf := netip.MustParsePrefix(network4)
    fmt.Printf(
        "Prefix address: %v, length: %v\n", 
        pf.Addr(), pf.Bits())
    ip1 := netip.MustParseAddr(addr1)
    if pf.Contains(ip1) {
        fmt.Println(addr1, " is in ", network4)
    }
    ip2 := netip.MustParseAddr(addr2)
    if pf.Contains(ip2) {
        fmt.Println(addr2, " is in ", network4)
    }
}

我们从 network4 字符串 192.0.2.0/24 定义前缀 pf。然后,我们检查地址 192.0.2.18198.51.100.3 是否在此网络中,如果它们是,则打印一条消息。此程序打印以下内容:

ch04/parseprefix$ go run main.go
Prefix address: 192.0.2.0, length: 24
192.0.2.18  is in  192.0.2.0/24

此示例的代码可在 ch04/parseprefix 找到(进一步阅读)。

使用 IP 地址进行操作

解析 IP 地址后,您只需再走一步就可以将几个实际应用场景付诸实践。我们在这里仅介绍几个示例:

  • 路由查找

  • 地理 IP 数据

  • 额外的 IP 地址功能

路由查找

执行路由查找或找到 IP 地址的最长前缀匹配的一种方法是使用 trie 数据结构(前缀树)。Trie 在内存和速度上都非常高效,这就是为什么我们使用它们进行 IP 前缀查找。在 Go 中,您可以使用可用的包之一来完成此操作。在这种情况下,我们使用cidranger进一步阅读)。

我们首先定义一个新的路径压缩前缀 trie,并添加从IPs变量解析的 IP 地址列表:

func main() {
    ranger := cidranger.NewPCTrieRanger()
    IPs := []string{
        "100.64.0.0/16",
        "127.0.0.0/8",
        "172.16.0.0/16",
        "192.0.2.0/24",
        "192.0.2.0/24",
        "192.0.2.0/25",
        "192.0.2.127/25",
    }
    for _, prefix := range IPs {
        ipv4Addr, ipv4Net, err := net.ParseCIDR(prefix)
        // process error
        ranger.Insert(
                cidranger.NewBasicRangerEntry(*ipv4Net))
    }
    /* ... <continues next > ... */
}

现在,我们可以检查是否有任何 IP 地址在定义的 IP 地址范围列表中。在这里,我们发现127.0.0.1至少在一个 IP 前缀列表中:

func main() {
    /* ... <continues from before > ... */
    checkIP := "127.0.0.1"
    ok, err := ranger.Contains(net.ParseIP(checkIP))
    // process error
    // prints Does the range contain 127.0.0.1?: true
    fmt.Printf("Does the range contain %s?: %v\n",
                    checkIP, ok)
    /* ... <continues next > ... */
}

您还可以请求包含 IP 地址的网络列表,例如本例中的192.0.2.18

func main() {
    /* ... <continues from before > ... */
    netIP := "192.0.2.18"
    nets, err := ranger.ContainingNetworks(
                            net.ParseIP(netIP))
    // process error
    fmt.Printf(
    "\nNetworks that contain IP address %s ->\n", netIP)
    for _, e := range nets {
        n := e.Network()
        fmt.Println("\t", n.String())
    }
}

这将返回192.0.2.0/24192.0.2.0/25

ch04/trie$ go run main.go
Networks that contain IP address 192.0.2.18 ->
     192.0.2.0/24
     192.0.2.0/25

此示例的代码可在ch04/trie/main.go中找到(进一步阅读)。

地理 IP 数据

另一个有趣的用例是获取与公共 IP 地址关联的地理位置。为此查询,您需要访问一个数据库,您可以免费从 GeoLite2 免费地理位置数据下载(进一步阅读)或直接使用书中 repo 中包含的样本文件,该文件支持有限数量的 IP 地址,但足以运行示例。

我们打开数据库文件,并对切片中的每个 IP 地址进行查询,然后将查询到的任何可用信息打印到终端:

func main() {
    db, err := geoip2.Open("GeoIP2-City-Test.mmdb")
    // process error 
    defer db.Close()
    IPs := []string{
        "81.2.69.143",
        /* ... <omitted for brevity > ... */
    }
    fmt.Println("Find information for each prefix:")
    for _, prefix := range IPs {
        ip := net.ParseIP(prefix)
        record, err := db.City(ip)
        // process error

        fmt.Printf("\nAddress: %v\n", prefix)
        fmt.Printf("City name: %v\n",
                        record.City.Names["en"])
        /* ... <omitted for brevity > ... */
    }
}

一个输出示例如下:

ch04/geo$ go run main.go
Find information for each prefix:
...
Address: 81.2.69.143
City name: Norwich
Country name: United Kingdom
ISO country code: GB
Time zone: Europe/London
Coordinates: 52.6259, 1.3032

此示例的代码可在ch04/geo/main.go中找到(进一步阅读)。

额外的 IP 地址函数

如果您来自像 Python 这样的其他编程语言,您可能熟悉用于操作 IP 地址和网络的ipaddress库。iplib包(进一步阅读)是尝试将这些功能带到 Go 的努力。

在下一个示例中,我们看到一个用于将 IP 地址增加 1 的函数(NextIP)和另一个用于将 IP 地址增加任意数量的函数(IncrementIPBy)。然后我们使用DeltaIP函数计算原始 IP 地址和这两个增量后的结果之间的差异,以找出中间的 IP 地址数量。

示例的最后一条语句使用CompareIPs函数比较两个 IP 地址。如果ab是输入,如果a == b则返回0,如果a < b则返回-1,如果a > b则返回1

func main() {
    IP := net.ParseIP("192.0.2.1")
    nextIP := iplib.NextIP(IP)
    incrIP := iplib.IncrementIPBy(nextIP, 19)
    // prints 20
    fmt.Println(iplib.DeltaIP(IP, incrIP))
    // prints -1
    fmt.Println(iplib.CompareIPs(IP, incrIP))
}

由于iplib包允许您比较 IP 地址,这意味着您可以使用sort包对net.IP地址列表进行排序,如下一个示例所示,使用我们刚刚创建的地址:

func main() {
    iplist := []net.IP{incrIP, nextIP, IP}
    // prints [192.0.2.21 192.0.2.2 192.0.2.1]
    fmt.Println(iplist)
    sort.Sort(iplib.ByIP(iplist))
    // prints [192.0.2.1 192.0.2.2 192.0.2.21]
    fmt.Println(iplist) 
}

您也可以使用Enumerate方法从一个网络中生成一个 IP 地址数组,从任意 IP 地址开始。在下一个示例中,我们选取网络198.51.100.0/24,使用Count方法计算其中的总可用地址数,然后使用Enumerate方法生成一个大小为 3 的数组,从网络的第一个可用 IP 地址(索引 0)开始:

func main() {
    n4 := iplib.NewNet4(net.ParseIP("198.51.100.0"), 24)
    fmt.Println("Total IP addresses: ", n4.Count())
    fmt.Println("First three IPs: ", n4.Enumerate(3, 0))
    fmt.Println("First IP: ", n4.FirstAddress())
    fmt.Println("Last IP: ", n4.LastAddress())
}

此程序将产生以下输出:

ch04/ipaddr$ go run main.go
...
Total IP addresses:  254
First three IPs:  [198.51.100.1 198.51.100.2 198.51.100.3]
First IP:  198.51.100.1
Last IP:  198.51.100.254

本例的代码可在 ch04/ipaddr/main.go 找到(进一步阅读)。

IP 是互联网的基本协议,在过去 40 年中,尽管过去几十年技术发展迅速,但它仍然没有发生重大变化,继续支持其发展。与传输层协议一起,IP 允许应用程序从同轴电缆、光纤和 Wi-Fi 等硬件技术中解耦。说到传输层,在下一节中,我们将探讨 Go 如何帮助您导航 TCP/IP 模型的这一层。

传输层

传输层协议是 IP 之上的下一个 OSI 层,提供通信通道抽象。目前最常用的两种协议是 TCP,它提供面向连接的通信通道,以及 UDP,一种无连接协议。

在 Go 中,您与这两种协议的交互方式相似,尽管底层的包交换可能完全不同。在较高层次上,当处理 TCP 或 UDP 时,您只需要记住以下几点:

  • 每个 TCP 或 UDP 应用程序都与一个相应的连接一起工作,该连接分别由具体的 TCPConnUDPConn 类型表示。

  • Go 有其他具有重叠功能的连接类型,如 PacketConn,它处理无连接协议(UDP 和 IP);Conn,它涵盖 IP、TCP 和 UDP;以及 UnixConn 用于 Unix 域套接字的连接。我们本节只关注 TCPConnUDPConn

  • 客户端使用 net.DialTCPnet.DialUDP 打开到远程地址的套接字。

  • 服务器使用 net.ListenUDPnet.ListenTCP 打开一个监听套接字,以接受来自不同客户端的连接。

  • 客户端和服务器可以从它们各自的连接中读取和写入字节。

  • 完成后,客户端和服务器都需要关闭它们的连接以清理底层的文件描述符。

下图展示了典型 UDP 客户端-服务器通信中不同类型之间的交互:

图 4.4 – Go 中的 UDP 通信

图 4.4 – Go 中的 UDP 通信

图 4**.4 展示了一个 UDP 客户端一次发送一个字节,尽管在现实中,有效负载可以有更多字节。这可能是 DNS 请求或 RTP 数据包。所有网络连接类型都实现了 io.Readerio.Writer 接口,因此无论底层使用什么协议,读取和写入都是相似的。

UDP 客户端使用 net.DialUDP 创建一个 UDP 连接,然后向它写入(Write)一个字节,就像您向网络发出请求一样。在服务器端,您从您之前使用 net.ListenUDP 创建的连接中读取(Read)。

现在,让我们转向一些更具体的内容,看看一个真实的 UDP 应用程序可能是什么样子。

UDP ping 应用程序

Ping 是检查远程连接和端到端延迟最传统的方法之一。就像传统的 ping 一样,UDP ping 使用回声响应来计算延迟和丢包率,但它们被封装在 UDP 数据包中而不是 ICMP/NDP 中。许多监控应用使用这种方法,因为它允许它们在网络中检测和监控具有 5 元组哈希功能的设备上的各种等价成本路径。其中一个这样的应用是 Cloudprober(进一步阅读),它是下一个示例的灵感来源,因为作者是用 Go 编写的。

让我们遍历 UDP ping 应用的代码,重点关注连接建立和数据交换。您可以在书籍仓库的ch04/udp-ping进一步阅读)文件夹中找到完整的代码。从高层次来看,我们的 UDP ping 应用由两部分组成:

  1. 服务器端监听 UDP 端口,并将从其客户端接收到的任何数据包镜像回传。

  2. 发送 UDP 探测到服务器的客户端接收一串返回的镜像数据包,以计算丢包率和端到端延迟:

图 4.5 – UDP ping 应用

图 4.5 – UDP ping 应用

让我们从应用的服务器端开始概述这个应用。程序首先构建一个UDPAddr变量,它描述了一个 UDP 套接字。然后我们将这个变量传递给net.ListenUDP以创建一个 UDP 套接字并开始监听传入的数据包。ListenUDP函数的第一个参数是udp,它指定了双栈行为(RFC6724 和 RFC6555)。您也可以使用udp4udp6将程序分别固定在 IPv4 或 IPv6 上:

func main() {
    listenAddr     = "0.0.0.0"
    listenPort     = 32767
    listenSoc := &net.UDPAddr{
        IP:   net.ParseIP(listenAddr),
        Port: listenPort,
    }
    udpConn, err := net.ListenUDP("udp", listenSoc)
    // process error
    defer udpConn.Close()
    /* ... <continues next > ... */
}

一旦我们有一个监听 UDP 套接字,我们就可以开始主处理循环,它使用ReadFromUDP将传入的数据包读取到一个字节切片中,并使用WriteToUDP将整个数据包写回发送者。

由于ReadFromUDP是一个阻塞函数,大多数服务器实现都会添加一个额外的SetReadDeadline超时,以确保在需要时程序可以优雅地终止。在这种情况下,它直接导致下一个循环迭代,多亏了ReadFromUDP之后的continue语句:

func main() {
    /* ... <continues from before > ... */
    for {
        maxReadBuffer  = 425984
        bytes := make([]byte, maxReadBuffer)

        retryTimeout   = time.Second * 5
        if err := udpConn.SetReadDeadline(
                        time.Now().Add(retryTimeout))
        // process error
        len, raddr, err := udpConn.ReadFromUDP(bytes)
        if err != nil {
            log.Printf("failed to ReadFromUDP: %s", err)
            continue
        }
        log.Printf("Received a probe from %s:%d",
                        raddr.IP.String(), raddr.Port)
        n, err := udpConn.WriteToUDP(bytes[:len], raddr)
        // process error
    }
}

客户端实现开始的方式类似,通过构建一个UDPAddr变量并将其传递给net.DialUDP函数。在 TCP 的情况下,net.DialTCP函数将触发 TCP 三次握手,但在 UDP 的情况下,底层操作系统会打开一个网络套接字而无需交换任何数据包:

func main() {
    rAddr := &net.UDPAddr{
        IP:   net.ParseIP("127.0.0.1"),
        Port: "32767",
    }
    udpConn, err := net.DialUDP("udp", nil, rAddr)
    // process error
    defer udpConn.Close()
    /* ... <continues next > ... */
}

在这个阶段,程序分为两个方向。逻辑上的第一步是数据包发送例程,在这个例子中,它运行在程序的主 goroutine 中。在后台,我们还启动了一个 goroutine 来运行receive函数,我们将在接下来的几段中讨论它。

在我们发送的每个探测数据包中,我们嵌入一个单调递增的序列号和当前时间戳的值。我们将探测数据包序列化为一个二进制切片p,并使用binary.Write函数将它们写入 UDP 连接udpConn

func main() {
    /* ... <continues from before > ... */
    go receive(*udpConn)
    var seq uint8
    for {
        log.Printf("Sending probe %d", seq)
        p := &probe{
            SeqNum: seq,
            SendTS: time.Now().UnixMilli(),
        }
        if err := binary.Write(udpConn,
                        binary.BigEndian, p)
        // process error
        seq++
    }
}

现在我们来更仔细地看看receive函数,这个函数在上一个代码片段中的发送循环之前启动。在这个函数内部,我们还有一个循环,它执行以下一系列操作:

  1. 它接收一个镜像数据包,并使用binary.Read函数将其反序列化为probe类型的p变量。

  2. 它检查接收到的数据包的SeqNum序列号,以确定它是否顺序错误。

  3. 它通过从SendTS探测中接收的时间减去当前时间time.Now来计算延迟。

在 Go 代码中,它看起来是这样的:

func receive(udpConn net.UDPConn) {
    var nextSeq uint8
    var lost int
    for {
        p := &probe{}
        if err := binary.Read(&udpConn,
                                binary.BigEndian, p)
        // process error
        if p.SeqNum < nextSeq {
            log.Printf("Out of order packet seq: %d/%d",
                                p.SeqNum, nextSeq)
            lost -= 1
        } else if p.SeqNum > nextSeq {
            log.Printf("Out of order packet seq: %d/%d",
                                p.SeqNum, nextSeq)
            lost += int(p.SeqNum - nextSeq)
            nextSeq = p.SeqNum
        }
        latency := time.Now().UnixMilli() - p.SendTS
        log.Printf("E2E latency: %d ms", latency)
        log.Printf("Lost packets: %d", lost)
        nextSeq++
    }
}

在这个例子中,我们使用了binary.Readbinary.Write来在内存数据类型和二进制切片之间进行转换。这是由于探测数据包固定大小的原因。但是,如果探测数据包是可变大小的,我们只能使用相同的函数来预解析头部固定大小的部分,并且必须手动读取和解析可变大小的有效负载。

实际的 UDP ping 应用程序在ch04/udp-ping进一步阅读)中有一点点更多的代码,以处理更多的错误条件和优雅的程序终止。让我们看看运行客户端代码对远程 UDP ping 服务器的示例,其中对于每次迭代,我们可以看到丢失的总数据包数和最新的计算延迟:

ch04/udp-ping/client$ sudo go run main.go
2021/12/10 15:10:31 Starting UDP ping client
2021/12/10 15:10:31 Starting UDP ping receive loop
2021/12/10 15:10:32 Sending probe 0
2021/12/10 15:10:32 Received probe 0
2021/12/10 15:10:32 E2E latency: 9 ms
2021/12/10 15:10:32 Lost packets: 0
2021/12/10 15:10:33 Sending probe 1
2021/12/10 15:10:33 Received probe 1
2021/12/10 15:10:33 E2E latency: 8 ms
2021/12/10 15:10:33 Lost packets: 0
2021/12/10 15:10:34 Sending probe 2
2021/12/10 15:10:34 Received probe 2
2021/12/10 15:10:34 E2E latency: 9 ms
2021/12/10 15:10:34 Lost packets: 0
...

服务器端不进行任何测量,只为每个接收到的 UDP 探测记录客户端 IP 地址:

ch04/udp-ping/server$ sudo go run main.go
2021/12/10 15:10:28 Starting the UDP ping server
2021/12/10 15:10:32 Received a probe from 198.51.100.173:59761
2021/12/10 15:10:33 Received a probe from 198.51.100.173:59761
2021/12/10 15:10:34 Received a probe from 198.51.100.173:59761
...

您刚刚看到了一个使用单个消息交换信息和计算网络指标的基于二进制 UDP 协议的例子。虽然我们认为了解如何在 Go 中处理传输层协议很重要,但直接在 TCP 或 UDP 之上实现自己的应用程序并不常见;唯一值得注意的例外包括像 Kafka、NATS 和 AMQP 这样的高性能消息协议。如今,大多数通信都是通过更高级的协议 HTTP 进行的。有了它,我们得到了广泛的包和 SDK 支持,一个庞大的通信标准生态系统,包括 REST、GRPC 和 GraphQL,以及来自网络中间件(如代理和入侵检测系统)的标准支持。在下一节中,我们将展示如何使用 Go 编写一个示例 HTTP 客户端-服务器应用程序。

应用层

在上一节中,我们探讨了如何使用我们迄今为止所学的 Go 低级网络原语在两个节点之间建立 TCP 或 UDP 连接,以在网络中传输字节。现在我们关注 TCP/IP 模型的最顶层,并深入了解 Go 标准库中包含的应用层构造,以实现 HTTP 客户端和服务器。

为了说明这一点,我们通过构建一个客户端-服务器应用程序的步骤来返回请求者的 MAC 地址供应商、IP 地址所有者或详细的域名信息。在客户端,我们需要构建一个封装了查询服务器地址的 HTTP 请求。在服务器端,我们需要监听请求并实现处理这些请求的逻辑,并回复接收到的信息。

使用 HTTP 客户端

在客户端,我们首先需要组合我们要发送请求的 URL。在我们的例子中,URL 有三个组成部分:

  • 服务器地址(IP 地址和端口)

  • 要执行查找的类型(MAC、IP 或域名)

  • 一个参数,这是我们想要查询的值

net/url包帮助我们在这个案例中,将输入解析为 URL 结构。在书中,我们为示例硬编码了值,但您可以在运行ch04/http/client/main.go代码时通过标志输入任何您想要的值(进一步阅读)。

我们使用net/url包中的Parse方法来形成 URL 的第一部分:http://localhost:8080/lookup。示例的第二部分添加了查询。我们利用Add方法来实现这一点,它接受一个键值对作为参数。在这个例子中,lookup是键,而值来自argument变量。完整的 URL 看起来像这样:http://localhost:8080/lookup?domain=tkng.io

func main() {
    server := "localhost:8080"
    // One of: mac, ip, domain
    lookup := "domain"
    // Examples: 68b5.99fc.d1df, 1.1.1.1, tkng.io
    argument := "tkng.io"
    path := "/lookup"
    addr, err := url.Parse("http://" + server + path)
    // process error
    params := url.Values{}
    params.Add(lookup, argument)
    addr.RawQuery = params.Encode()
    /* ... <continues next > ... */
}

为了向服务器发送实际请求,我们利用了net/http包。这个包有一个Client类型,它指定了发起 HTTP 请求的机制。在这个例子中,我们不需要指定任何客户端的详细信息,所以我们只展示这个类型以供参考:

type Client struct {
    Transport RoundTripper
    CheckRedirect func(req *Request, via []*Request) error
    Jar CookieJar
    Timeout time.Duration
}

如果您没有偏好,您可以选择一个使用DefaultTransportDefaultClient。这个客户端有预定义的超时和代理设置,这对于不同 goroutine 的并发使用是安全的,所以我们不需要调整 Go 标准库中以下代码片段显示的任何参数,该代码片段还描述了客户端 HTTP 传输设置,以防您想要微调连接的行为:

var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
    }).DialContext,
    ForceAttemptHTTP2:     true,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}

在这个例子中,DefaultClient允许我们使用 HTTP GET、HEAD 和 POST 方法。这里,我们使用net/http包中的Get方法通过addr地址来执行 HTTP GET:

func main() {
    /* ... <continues from before > ... */
    res, err := http.DefaultClient.Get(addr.String())
    if err != nil {
        log.Fatal(err)
    }
    defer res.Body.Close()
    io.Copy(os.Stdout, res.Body)
}

最后一步是将我们从服务器收到的响应打印到终端。您可以使用 CLI 的标志在运行客户端应用程序时提交不同的查询以执行这些操作:

  • 健康检查:

    ch04/http/client$ go run main.go -check
    
    OK
    
  • MAC 地址供应商查找:

    ch04/http/client$ go run main.go -lookup mac 68b5.99fc.d1df
    
    Hewlett Packard
    
  • 域名查找:

    ch04/http/client$ go run main.go -lookup domain tkng.io
    
    Domain Name: tkng.io
    
    Registry Domain ID: 5cdbf549b56144f5afe00b62ccd8d6e9-DONUTS
    
    Registrar WHOIS Server: whois.namecheap.com
    
    Registrar URL: https://www.namecheap.com/
    
    Updated Date: 2021-09-24T20:39:04Z
    
    Creation Date: 2021-07-26T19:08:34Z
    
    Registry Expiry Date: 2022-07-26T19:08:34Z
    
    Registrar: NameCheap, Inc.
    
    Registrar IANA ID: 1068
    
  • IP 地址查找:

    ch04/http/client$ go run main.go -lookup ip 1.1.1.1
    
    ...
    
    inetnum:        1.1.1.0 - 1.1.1.255
    
    netname:        APNIC-LABS
    
    descr:          APNIC and Cloudflare DNS Resolver project
    
    descr:          Routed globally by AS13335/Cloudflare
    
    descr:          Research prefix for APNIC Labs
    
    country:        AU
    

要获取这些响应,我们首先需要一个正在运行的服务器来处理请求。让我们构建它。

使用 HTTP(服务器)

为了处理请求和响应,net/http包公开了一个Server类型和一个Handler接口。Server是运行 HTTP 服务器参数的数据结构:

type Server struct {
    Addr string
    Handler Handler
    TLSConfig *tls.Config
    ReadTimeout time.Duration
    ReadHeaderTimeout time.Duration
    /* ... <omitted for brevity > ... */
}

让我们定义一个 srv 变量,其类型为 ServerServer 的零值是一个有效的配置,但在这个例子中,我们将 Addr 定义为 0.0.0.0:8080,以便监听任何接口和特定的端口 8080

Server 类型有一个 ListenAndServe 方法来监听 Server 实例的 TCP 网络地址 Addr(例如,示例中的 srv.Addr0.0.0.0:8080)。然后它调用 Serve 方法来接受传入的连接并处理请求。对于每个请求,它创建一个新的服务 goroutine 来读取请求,然后调用 Server 实例的 Handler(示例中的 srv.Handlernil),以回复它们:

func main() {
    /* ... <omitted for brevity > ... */
    log.Println("Starting web server at 0.0.0.0:8080")
    srv := http.Server{Addr: "0.0.0.0:8080"}
    // ListenAndServe always returns a non-nil error.
    log.Fatal(srv.ListenAndServe())
}

这带我们来到了我们最初提到的 net/http 包中的第二种类型,即 Handler 接口。Handler 的作用是对 HTTP 请求做出响应:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Handler 通过其 ServeHTTP 方法响应 HTTP 请求,该方法接受两个参数:

  • 一个 ResponseWriter 接口,您可以使用它来构建 HTTP 头部和有效载荷以回复请求,然后返回:

    type ResponseWriter interface {
    
        Header() Header
    
        // Write writes the data to the connection 
    
        // as part of an HTTP reply.
    
        Write([]byte) (int, error)
    
        // WriteHeader sends an HTTP response header
    
        // with the provided status code.
    
        WriteHeader(statusCode int)
    
    }
    
  • 一个 HTTP Request,它包含服务器收到的 HTTP 请求。它也可以是您想从客户端发送的请求:

    type Request struct {
    
        // Method specifies the HTTP method
    
        // (GET, POST, PUT, etc.).
    
        Method string
    
        // URL specifies either the URI being requested
    
        // (for server requests) or the URL to access 
    
        // (for client requests).
    
        URL *url.URL
    
        Header Header
    
        Body io.ReadCloser
    
        /* ... <omitted for brevity > ... */
    
    }
    

现在,如果我们回顾我们的示例,我们没有指定我们的 Handler,所以当我们调用 ListenAndServe 时,我们的处理函数实际上是空的(nil)。在这种情况下,ListenAndServe 默认使用 DefaultServeMux 来处理传入的请求。DefaultServeMuxnet/http 包包含的一个 HTTP 请求多路复用器,它根据已注册的 URL 模式列表将请求路由到最合适的处理函数。

示例中的下一步是注册一个给定模式的处理函数。我们使用 HandleFunc 函数来做这件事,该函数接受一个字符串模式和一个 func(ResponseWriter, *Request) 签名的处理函数作为参数。现在,当我们收到一个与该模式匹配的 URL 的传入请求时,指定的处理函数将生成响应。

回到示例,在我们展示的主函数的第一个代码片段中,我们故意省略了前两行代码,实际上这两行代码注册了两个要匹配的 URL 模式 /lookup/check

func main() {
    http.HandleFunc("/lookup", lookup)
    http.HandleFunc("/check", check)
    /* ... <omitted for brevity > ... */
}

正常查询遵循 /lookup 路由,但我们还包含了一个 /check 选项,以便我们可以运行快速的健康检查并验证服务器是否正在响应用户请求。每个模式都有一个对应的处理函数,该函数以 func(ResponseWriter, *Request) 签名作为参数。我们方便地将这些函数命名为 lookupcheck图 4**.6 展示了 DefaultServeMux 如何逻辑上确定处理用户请求的 Handler

图 4.6 – 处理 HTTP 请求

图 4.6 – 处理 HTTP 请求

现在,让我们检查 lookup 处理函数。有几个亮点:

  • 我们通过第一个参数w将响应写入请求,w是一个满足io.Writer接口的http.ResponseWriter。这意味着你可以使用任何接受io.Writer接口的机制来写入它。在这里,我们使用fmt.Sprintf

  • 我们通过第二个参数req访问用户的请求。在这里,我们通过req.URL.Query从请求中提取目标 URL 并在示例中打印出来。同时,我们获取查询值以根据其类型进一步处理请求,无论是 MAC 地址、IP 地址还是域名:

    func lookup(w http.ResponseWriter, req *http.Request) {
    
        log.Printf("Incoming %+v", req.URL.Query())
    
        var response string
    
        for k, v := range req.URL.Query() {
    
            switch k {
    
            case "ip":
    
                response = getWhois(v)
    
            case "mac":
    
                response = getMAC(v)
    
            case "domain":
    
                response = getWhois(v)
    
            default:
    
                response = fmt.Sprintf(
    
                            "query %q not recognized", k)
    
            }
    
        }
    
        fmt.Fprintf(w, response)
    
    }
    

在服务器端运行此代码时,我们需要包含文件夹中的所有.go文件,而不仅仅是main.go,因此你想要运行go run *.go以获得下一个片段中的输出:

ch04/http/server$ go run *.go
2021/12/13 02:02:39 macDB initialized
2021/12/13 02:02:39 Starting web server at 0.0.0.0:8080
2021/12/13 02:02:56 Incoming map[mac:[68b5.99fc.d1df]]
2021/12/13 02:03:19 Incoming map[domain:[tkng.io]]
2021/12/13 02:03:19 whoisLookup tkng.io@whois.iana.org
2021/12/13 02:03:19 whoisLookup tkng.io@whois.nic.io
2021/12/13 02:05:09 Incoming map[ip:[1.1.1.1]]
2021/12/13 02:05:09 whoisLookup 1.1.1.1@whois.iana.org
2021/12/13 02:05:09 whoisLookup 1.1.1.1@whois.apnic.net

要运行此示例,你需要打开两个标签页。首先,从ch04/http/server进一步阅读)运行go run *.go,然后从另一个标签页,你可以从ch04/http/client进一步阅读)进行客户端查询,使用本节客户端部分的输出中的标志。

摘要

在本章中,我们回顾了 TCP/IP 模型的各个层次以及 Go 在每一层的适用性。我们从改变 Linux 系统上网络接口的状态到处理 IP 地址,一直到最后构建一个 Web 应用程序原型。

现在,你已经准备好开始网络自动化之旅,并将迄今为止所学的一切应用到使网络更高效、更可靠和更一致。这是我们开始探讨的内容,第五章,网络自动化

进一步阅读

第二部分:常用工具和框架

这一部分描述了网络自动化现有的挑战和目标。您将了解组织如何应对这一重大任务以及我们将何去何从。

本书本部分包括以下章节:

  • 第五章**,网络自动化

  • 第六章**,配置管理

  • 第七章**,自动化框架

第五章:网络自动化

到目前为止,我们已经介绍了一些执行常见网络相关活动所需的 Go 基础知识。现在,是时候关注本书的主要主题——网络自动化了。在我们回顾解决方案、工具和代码库之前,让我们退一步,将网络自动化作为一个学科来审视。在本章中,我们旨在回答以下问题:

  • 什么是网络自动化,为什么它通常被认为是一种与网络工程等不同的专门技能?

  • 它对网络运营的影响以及对企业的好处是什么?

  • 你可以单独解决哪些常见的自动化用例?

  • 你如何将这些单独的用例组合成一个更大的网络自动化系统,以及为什么有人会想要那样做?

本章代码较少,文字较多,可能包含并非所有人都可能同意的论点。作为本书的作者,我们试图尽可能客观地表达我们的观点,但我们的观点最终是基于我们在职业生涯中经历的主观经验。尽管如此,我们已尽力避免最具争议的话题,例如自动化减少对人工操作员的需求,并在可能的情况下提供了支持我们论点的证据。

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

  • 什么是网络自动化?

  • 自动化网络操作任务

  • 系统方法

技术要求

你可以在本书的 GitHub 仓库(在进一步阅读部分指定)的ch05文件夹中找到本章的代码示例。

什么是网络自动化?

作为一门相对较新的学科,看到范围和目标各不相同的广泛网络自动化定义并不罕见。网络自动化并不是关于特定用例或技术,而是关于在你的环境中能提供帮助并使你的业务受益的东西。

一些工程师可能会争论,路由协议已经自动化了网络,CLI 是基于意图的 API,将单个网络命令转换为动态网络状态。我们并不试图与这种观点争论,因为这些陈述中确实有一些真理,但它在行业中肯定不是最受欢迎的定义。

相反,让我们将网络自动化定义为一系列过程,用于自动化网络操作员执行的常见手动工作流程,例如提供服务、执行软件升级或遥测处理。这包括网络工程师通常必须通过点击操作的任务,以及运行一系列 CLI 命令。

更复杂的网络自动化解决方案可能涉及通过调整网络配置、应用流量工程策略或甚至强制某些设计约束来对操作事件做出反应。所有这些活动的共同特征是能够通过一系列具体的步骤来描述期望的行为,从而实现预期的结果。这可能排除了某些迭代活动,如网络故障排除或创造性活动,如网络设计,尽管它们在这些领域取得了相当大的进展,例如静态配置分析(Batfish:进一步阅读)和数学网络建模(Forward:进一步阅读),因此我们最终可以借鉴软件世界的概念,如测试驱动开发TDD)来自动化网络设计配置模板的开发和测试(整个网络的质量保证QA)和回归测试)。

为什么网络自动化存在

可能一个更有趣的问题是要回答为什么网络自动化作为一个学科存在,而像系统管理这样的领域,已经演变成站点可靠性工程,现在不仅包括基础设施的提供,还包括可观察性、自动化,甚至系统软件开发。

在过去几十年中,我们运行和操作网络的方式变化很小。尽管广泛接受 CLI 驱动的操作容易出错且不可扩展,但网络管理仍然主要关注执行 CLI 命令和与无结构数据工作。这通常导致缺乏标准化,使得网络工程师大部分工作日都在进行手动流程,使得网络难以扩展、支持和安全。

网络自动化作为对此的回应而出现,旨在提高效率并减少日常任务的冗余。目标是产生更可靠和可重复的过程,从而提高生产力。这也帮助使网络更加一致,操作更加简单,同时降低故障的可能性,从而最小化停机时间。

尽管如此,并非所有网络工程师都开始了网络自动化的旅程。我们认为这可能是以下原因之一:

  • 缺乏标准和供应商无关的网络管理 API,这些 API 返回结构化数据。网络供应商通常提供专有的配置语法或 CLI,这些主要是为人类交互设计的。

  • 自动化需要一套全新的技能,由于网络工程师通常没有计算机科学背景,编程仍然是一个很大的技能差距。

  • 学习自动化需要时间,并不是每个雇主都愿意将员工的一部分时间投入到没有立即收益的事情上。

  • 自动化速度也可能迅速传播失败,这可能在早期不会有助于建立对自动化的信任。创建可靠、安全并提供足够可见性的系统需要时间。

  • 由于有大量具有重叠范围的网络自动化工具、库和框架,为特定任务选择正确的一个可能具有挑战性,并引入了过度投资于可能最终是错误选择的风险。

  • 从“一直都是这样做的”转变为困难。有时,我们遵循阻力最小的路径,因此不愿意改变。

将网络自动化引入您的环境会带来不同的收益和风险,这取决于您的观点。因此,让我们尝试分析这对操作网络的工程师和运营业务的上级管理层意味着什么,对于他们来说,网络可能是一个成本中心或利润中心。

自下而上的视角

一些非常适合自动化的网络操作活动包括配置更改、运行审计或合规性检查、软件和设备生命周期管理等等。一些组织有剧本或要求变更管理表格,记录这些流程的每个操作步骤。许多公司已经在高级工程师准备变更而初级工程师随后执行时使用某种形式的自动化。

这些活动通常有一套非常明确的输入,例如设备清单、要执行的命令列表、一组定义明确的输出,可能还有填写好的电子表格或运行在设备上的新软件版本。这些属性使这些活动成为自动化的理想候选者。

自动化的一个常被引用的好处是其扩展能力——对单个设备进行更改的成本与对数千个设备进行更改的成本相对相同,或者对数百个设备进行数百次更改的成本相同。尽管规模和速度很重要,但它们可能不是流程自动化的最有价值的成果。

对于一些具有相对较小规模网络或较低变更率的网络团队,网络自动化可能带来其他好处,例如以下内容:

  • 一致性:由于计算机执行这些更改,您可以期望它们每次都能产生相同的结果。此外,您可以在各个元素上强制执行相同的配置、模板或策略。

  • 可靠性:指令是代码,计算机可以明确地解释。您还可以添加自动检查来验证输入或结果。

  • 可见性:网络中所有未来和过去的变化都可以由团队的所有成员查看,以接受同行评审并简化故障排除。

  • 普遍性:相同的工具被不同团队使用,这简化了交互并提高了知识共享。

在向您的同事介绍网络自动化时,重要的是强调这不仅仅是一个单一的产品或解决方案,而是一次旅程——一个没有固定目的地的新的方向向量。

请记住,并非所有手动流程都可以完全自动化,开发新实践和更新现有程序可能需要数年时间。这就是为什么让您的组织管理层参与进来也非常重要。

自上而下的视角

网络工程师可以理解上述技术要点,并可以自行判断网络自动化项目是否值得他们投入时间和精力部署。

相比之下,如果您不关注更大的图景(业务),相同的论点可能不足以说服管理层。这可能是网络自动化倡议可能失败的主要原因之一。如果业务收益不明确,管理层可能会决定不值得投入时间。但反过来也是真的——当网络自动化倡议在组织的管理结构中得到支持时,它更有可能成功。

这里有一份业务价值列表,您可以用作与管理层讨论的起点。根据公司情况,网络可以是成本中心或利润中心,因此请调整或重新排序以适应您的具体情况:

  • 成本管理:通过资源优化产生成本节约。您通过减少人为错误、无需手动编制审计报告或无需加班更改来降低网络的运营成本。

  • 交付速度:提高配置和验证网络更改的速度,使您能够更快地提供客户服务,甚至按需提供。

  • 风险管理:在每次操作中始终如一地执行安全策略以降低风险。减少影响服务和因此影响您收入的事故数量。

  • 业务能力:根据您的组织如何定义价值,网络自动化可以帮助发现机会。提高可见性可能有助于改善容量规划或发现未使用的容量或热点。由于自动化系统的接口、输入和输出定义良好,新的服务或业务能力可能是跨团队互动简化的结果。

尽管人们对网络自动化益处的认识有所提高,但有些人仍然犹豫不决,不愿将其作为内部组织实践接受,因此从他们那里获得支持可能需要额外努力。每种情况都是独特的,因此可能需要略微不同的论点集。最终,网络自动化正在成为网络工程的重要组成部分,其在行业中的相关性持续增加。

现在我们已经定义了什么是网络自动化以及为什么我们需要它,是时候深入挖掘并开始查看具体的应用案例和领域,您可以在传统的网络工程学科中应用它。

自动化网络操作任务

本节介绍了一些常见的网络操作任务和用例,在这些用例中,你可以引入自动化而不会对现有工具和流程造成太多摩擦。我们的目标是采取一系列通常由人工操作员执行的手动步骤,并探讨如何将它们转换为代码,以便计算机可以为您执行它们,同时保持原始输入和输出不变。我们将本节分为三个类别:

  • 配置管理

  • 网络状态分析

  • 网络审计和报告

让我们开始吧。

配置管理

这是网络工程学科最受欢迎的领域,它超越了网络操作,通常包括设计和架构阶段。大多数人认为这是测试的最低目标,或者开始使用网络自动化的起点。让我们看看一些属于这个类别的常见用例。

配置生成

在我们可以对网络设备进行任何更改之前,我们需要为该目标设备制定所需的配置。传统上,我们会在文本编辑器中手动完成此操作,这涉及到大量的复制/粘贴和搜索/替换操作。

你可以使用以下 Go 包来自动化此过程,并根据一组输入生成网络设备配置:

  • text/template:标准库中的一个包,使用特殊的 Go 模板语言根据输入程序变量生成无结构的文本文档。我们将在第六章通过 SSH 与网络设备交互部分,配置管理中使用此包。

  • flosch/pongo2:一个类似于 Django 语法的模板语言,适用于更熟悉 Jinja2(gonja分支)的用户。

  • encoding:此包包括 YAML 和 JSON 的编码器和解码器,用于解析和生成可以与结构化网络 API(例如,YANG 或 OpenAPI)一起使用的文档。我们将在第六章通过 HTTP 从其他系统获取配置输入部分,配置管理中使用此包。

  • regexp:另一个标准库包,实现了高效的正则表达式模式匹配和字符串操作。我们将在本章末尾的示例中使用此包。

一旦你整理好配置细节,你就可以将此配置发送到目标设备,这引出了下一组用例。

配置更改、备份和恢复

与设备配置一起工作可能涉及备份和替换整个设备配置,或进行范围更改以提供新服务或更新现有配置片段。进行这些更改通常需要单独登录到每个设备并按顺序执行一系列厂商特定的命令。

以下 Go 包可以帮助处理不同网络供应商之间常见的传输抽象,以简化更改、备份或恢复您的网络配置的步骤:

  • crypto/ssh:一个实现基本 SSH 连接的标准库包。我们将在第六章通过 SSH 与网络设备交互部分使用此包,配置管理

  • scrapli/scrapligo:一个基于crypto/SSH构建的第三方包,提供了各种方便的辅助函数,用于处理主要网络供应商的不同 CLI 提示和命令。您还可以将此包用作 NETCONF 客户端。我们将在第六章自动化常规 SSH 任务部分使用此包,配置管理

  • net/http:一个标准库包,您可以使用它来与基于 HTTP 的 API(如 RESTCONF 或 OpenAPI)通信。我们将在第六章通过 HTTP 从其他系统获取配置输入部分使用此包,配置管理

前面的列表绝不是排他的,还有更多第三方包可供选择,包括一些专门设计用于与RESTCONF(进一步阅读)或NETCONF(进一步阅读)一起工作的包,但它们在活动水平或对外部贡献的开放程度方面各不相同。

总是环顾四周总会有帮助,尤其是在选择外部包时,以确保它符合您的需求并且拥有一个健康的贡献者社区。

配置差异和合规性检查

在您应用了所需的配置之后,您可能需要定期运行合规性检查,以确保某些不变量保持不变,或者检测任何配置漂移。这些用例依赖于字符串搜索、模式匹配和计算差异。您可以使用以下 Go 包来实现这一目的:

  • strings:一个来自标准库的包,可以使用CompareContains函数提供基本的字符串比较和模式匹配。我们将在本章末尾的示例中使用此包。

  • sergi/go-diff:一个第三方包,可以比较、匹配或修补纯文本(google/diff-match-patch包的 Go 端口)。

  • homeport/dyff:另一个第三方包和命令行工具,您可以使用它来比较结构化文档,例如 JSON 或 YAML。

虽然保持您的设备配置在控制之下至关重要,但您不能从它们中推导出网络中发生的所有事情。这就是为什么我们需要用我们从网络中收集的操作数据来补充我们的分析。

网络状态分析

由应用配置产生的操作状态通常难以预测。你可能需要花费大量时间微调监控并从网络收集信息。但由于这些用例风险较低,它们通常是网络自动化的良好起点,因此它们提供了使用 Go 的非常有吸引力的机会。

收集操作状态

根据?rev=operational的目标,表示返回的数据应来自操作数据存储。相比之下,对于以人为首的 NOS,这可能需要额外的步骤来解析从它获得的 CLI 输出。你可以在 Go 中以几种不同的方式做这件事:

  • regexp:使用正则表达式是将非结构化文本解析到变量中的最经得起考验和最知名的方法。请记住,编写健壮的正则表达式和调试它们可能是一个挑战。我们将在本章末尾的示例中使用这个软件包。

  • sirikothe/gotextfsm:这个软件包在regexp软件包之上提供了一个高级抽象,旨在解析半格式化文本,即具有视觉结构的文本,如表格,但表示为单个字符串。我们将在第六章检查路由信息部分间接使用这个软件包,配置管理

  • scrapli/scrapligo:这个软件包集成了textfsm软件包,并允许你使用TextFsmParse(template string)函数解析从网络设备获得的响应。我们将在第六章检查路由信息部分使用这个软件包,配置管理

你可以在维护窗口前后获取网络的操作状态并将其解析到内存数据结构中,例如,比较它们并审查在此期间完成的工作的成功情况。这就是我们将要讨论的下一个问题。

状态快照和验证

验证操作状态,确保我们收到的值是我们预期看到的,这是网络工程师在配置网络设备、运行故障排除会话、提供服务、执行软件升级以及执行其他日常活动作为其工作分配的一部分时所做的事情。

随着我们自动收集这些数据并且我们通常可以预先计算预期的状态,下一步是检查状态是否符合预期,并确保这种状态随时间持续。例如,BGP 邻居应该处于已建立状态,所有连接的接口都应该开启。当我们从网络收集新的数据时,我们将它记录在结构化格式中,以便与预期状态进行比较,并在发现差异时触发另一个操作。

比较任意数据通常需要编写一些自定义代码来遍历这些数据结构并查看重要的值。但有一些软件包可以简化这项任务:

  • reflect.DeepEqual:这个包是 Go 标准库的一部分,可以使用运行时反射来比较相同类型的值。

  • mitchellh/hashstructure:一个第三方包,可以从任意 Go 值计算出一个唯一的哈希值,你可以用它来快速回答操作状态是否与预期的状态匹配。我们将在本章末尾的示例中使用这个包。

  • r3labs/diff:另一个支持多个标准 Go 类型并依赖于运行时反射来生成两个 Go 结构体或值之间所有差异的详细日志的第三方包。

我们不能将所有操作状态都归类为预期的。一些值更动态,它们的改变并不总是可操作的。一个例子是 MAC 和 IP 地址表——它们的值会随时间波动,长期波动是正常的。

在日常维护期间,如软件升级时跟踪网络的动态状态可能会有所帮助,这时你可以对网络状态进行快照,并可以快速比较变更前后的值以发现任何不一致之处。从程序的角度来看,这就像通用的状态验证用例。你使用相同的一组工具和库,但将这些快照作为结构化文档保存在磁盘或数据库中,随着时间的推移。

网络审计和报告

网络审计的范围可能很大,从试图识别过时的硬件或生命终结的软件到衡量服务质量或控制平面更新的速率。通常,目标是收集和处理大量设备的状态信息,并生成一些人类可读的输出。

我们在上一节讨论了状态收集和验证任务,你可以使用 goroutines 将此过程扩展到针对数百或数千个网络设备,这在第三章,“Go 入门”,中有介绍。我们尚未讨论的缺失部分是报告生成。在这里,Go 也提供了几个你可以用来生成人类可读输出的资源:

  • text/tabwriter:如果你想要将信息发送到标准输出,这是一个你应该考虑的标准库包。你可以使用这个包来打印制表符数据。标准库之外还有其他功能丰富的选项,其中之一是jedib0t/go-pretty/v6包,你可以用它来着色文本或打印表格、列表和进度条。

  • unidoc/unioffice:如果你想要生成电子表格,这个包或qax-os/excelize是不错的选择。你也可以使用unidoc/unioffice来处理 Word、Excel 或 PowerPoint 文档。

  • html/templatetext/template:这是两个最常见的模板库。例如,流行的静态站点生成器 Hugo 使用htmltext模板包。

  • go:embed:这是一个 Go 指令,你可以用它来允许模板嵌入到编译后的 Go 二进制文件中,从而简化代码分发。

我们在本节中介绍的使用案例都是相对独立的。一旦你自动化它们,它们可以成为所谓的自动化孤岛,最初完全相互隔离,但一旦它们的数量增加,它们可能会合并成更复杂的多阶段工作流程,甚至完整的闭环系统。这就是我们将在下一节中探讨的内容。

系统方法

当你开始以增量方式自动化不同的任务时,你可能想象出一个路径,将这些建自动化任务的一部分串联起来,以编排一个工作流程。

你也可以从不同的角度看待这个问题。你最初将现有的手动流程分解成可以独立自动化的更小的工作块,这样你就不需要等到整个端到端流程自动化才开始利用自动化,同时你也要关注大局。

在这种情况下,你开始将不同的构建块相互连接,这些构建块最终成为交付业务成果的更大系统的一部分,而最初可能涉及多个团队的人类干预最终可能不再需要。这就是我们所说的系统方法。

一个常见的例子是当你混合配置网络服务和从网络收集操作数据的过程,这就是我们接下来要讨论的。

闭环自动化

每个网络工程师在配置网络设备上的任何内容后,首先要做的事情就是检查通过 CLI 命令配置的服务、协议或资源的状态。如果一个自动化系统执行此配置,网络工程师仍然需要登录到网络设备或一组网络设备来执行命令,或者可能去网页门户检查显示网络设备统计信息的日志或图表。这个耗时、重复且容易出错的过程对于人类来说非常适合自动化。

现在,你不仅将配置或指令推送到网络,还从网络中摄取实时操作数据,你可以对这些数据进行处理,以确定它是否与网络预期的状态相匹配。

如果我们抽象出网络设备的细节,闭环应用程序将消耗一个接口上的网络智能,并将意图推送到网络。我们可以大致定义如下:

  • 意图:这将是你在网络(拓扑、库存、协议等)上下文中期望的操作状态或可测量的结果的声明性定义,而不需要你指定达到它的确切步骤(这些是实现细节)。

  • 网络智能:这将是从网络中获取的,经过一定程度的处理后可操作的数据。请记住,事件、指标、统计数据或警报并不一定转化为可操作智能。网络操作员接收到如此多的警报,以至于很难知道什么是真实的,什么是噪音。因此,网络智能是通过关联这些数据、运行分析或任何其他有助于将其与所需意图联系起来的过程产生的。

下图是闭环应用程序的高级示意图:

图 5.1 – 闭环自动化 – 10,000 英尺视角

图 5.1 – 闭环自动化 – 10,000 英尺视角

意图转化为配置语法或程序性指令,这些指令针对网络设备是特定的。我们可以根据从网络获得的反馈调整这些指令,使我们能够关闭循环并自动化网络服务的生命周期。

你可以将闭环系统想象成一个从网络中学习并适应它们的连续循环。这可以替代在任意时间和任意增量下进行的预和后快照检查。但今天我们在网络中看到的是某种更接近仅在它们提供服务的窗口时间内对网络反馈做出反应的系统的系统。这就是我们将在以下示例中复制的。

演示应用程序

对于演示应用程序,我们既可以构建一个分布式系统,其中所有不同的组件通过网络消息进行通信和协调,也可以在单个应用程序的一个节点上运行一切。因为目标是说明闭环自动化的概念,而不是展示分布式系统的工作方式,所以我们将保持应用程序简单,并将所有组件作为单体应用程序的功能运行,如下面的图所示:

图 5.2 – 闭环自动化示例应用

图 5.2 – 闭环自动化示例应用

应用程序首先从用户那里读取输入数据。本例中,它从文件input.yml中读取目标设备信息,如下面的代码片段所示。我们在代码中硬编码服务的参数以配置一个变量(intent)。在这种情况下,我们想要配置的服务是57777,并启用TLS

# input.yml
router:
- hostname: sandbox-iosxr-1.cisco.com
  platform: cisco_iosxr
  strictkey: false
  username: admin
  password: C1sco12345

我们将服务信息封装在一个Service定义中,它比网络设备配置所表示的抽象层次更高,这转化为本例中的意图。我们还计算了这个值的哈希值,以便我们可以稍后将其与从网络收到的操作信息进行比较:

func main() {
    /* ... <omitted for brevity > ... */
    intent := Service{
        Name:     "grpc",
        Port:     "57777",
        AF:       "ipv4",
        Insecure: false,
        CLI:      "show grpc status",
    }
    intentHash, err := hashstructure.Hash(intent,
        hashstructure.FormatV2, nil)
    /* ... <omitted for brevity > ... */
}

在应用程序配置服务之前,我们有机会执行一系列预防性维护任务,例如运行网络审计来报告服务是否已经存在,因此你可能不需要配置它。另一个好主意是备份网络设备的配置,以防我们需要回滚更改。

在这个例子中,我们必须使用getConfig方法对目标设备进行配置备份,然后使用save方法将其保存到一个文件夹中:

func main() {
    /* ... <omitted for brevity > ... */
    config, err := iosxr.getConfig()
    // process error
    err = config.save()
    /* ... <omitted for brevity > ... */
}

在完成预工作后,应用程序进入一个持续执行的循环,在这个例子中,每 30 秒运行一次。在循环内部,应用程序使用getOper方法收集服务的操作状态。此方法向目标设备发送 CLI 命令,以收集我们需要的服务的操作细节:

func (r Router) getOper(s Service) (o DeviceInfo, err error) {
    rs, err := r.Conn.SendCommand(s.CLI)
    // process error
    o = DeviceInfo{
        Device:    r.Hostname,
        Output:    rs.Result,
        Timestamp: time.Now(),
    }
    return o, nil
}

一旦我们收到响应,我们使用正则表达式解析信息,同时使用regexp包生成一个新的Service值,该值捕获服务是否启用了TLS,例如,以及Service的其他属性。然后,我们为这个Service类型实例计算一个新的哈希值,并将其与我们拥有的原始哈希值进行比较,以验证服务的操作状态是否与意图匹配:

    if oprHash == intentHash {
        continue
    }

如果这些值匹配,我们可以继续到下一个迭代(continue)。否则,我们需要配置路由器,将服务带到所需的状态。然后,循环重新开始。我们通过使用genConfig方法和text/template包中的模板来获取目标设备上的服务配置,然后使用sendConfig函数将其发送到目标设备:

func (r Router) sendConfig(conf string) error {
    c, err := cfg.NewCfgDriver(r.Conn, r.Platform)
    // process error
    err = c.Prepare()
    // process error
    _, err = c.LoadConfig(conf, false)
    // process error
    _, err = c.CommitConfig()
    // process error
    return nil
}

如果你想看到这个例子在实际中的运行,你可以从ch05/closed-loop文件夹中运行代码。当它运行时,在另一个终端窗口中打开一个 SSH 会话到目标 Cisco DevNet 设备,使用sshpass -p "C1sco12345" ssh admin@sandbox-iosxr-1.cisco.com,并执行以下命令来禁用TLS

conf
grpc no-tls 
commit

在程序的输出中,你会看到它最终捕捉到这个差异,因此它将继续通过重新配置 TLS 来修复它。这个例子的代码可以在ch05/closed-loop/main.go中找到(进一步阅读):

ch05/closed-loop$ go run main.go 
Entering to continuous loop ====>
 Loop at 15:31:22
  Operational state from device:
   service: grpc
   addr-family: ipv4
   port: 57777
   TLS: true
 Loop at 15:31:52
  Operational state from device:
   service: grpc
   addr-family: ipv4
   port: 57777
   TLS: false
Configuring device ====>
 Loop at 15:32:22
  Operational state from device:
   service: grpc
   addr-family: ipv4
   port: 57777
   TLS: true
...

在这种情况下,智能仅考虑布尔结果,而不对网络情况进行定性评估。你还可以探索如何获取对从网络中检索的数据的更深入评估,以便创建一个超越简单网络修复是或否的决策树。

同样,与意图一样,我们只覆盖意图和所需配置之间的直接预定关系。实际部署可能涉及更多移动部件和关于你需要哪些部件的决策。

摘要

在本章中,我们讨论了网络自动化是什么,它对网络运营的影响,以及它对企业的益处。我们讨论了不同的用例,从配置管理和网络状态分析到运行网络审计和报告,最后探讨如何将不同的部分组合起来,创建一个闭环系统,以帮助您强制执行网络所需意图。

在下一章中,我们将详细探讨配置管理,这是网络自动化中较为常见的用例之一,并导航 Go 为我们提供的自动化选项。

进一步阅读

关于本章所涉及主题的更多信息,请参阅以下资源:

第六章:配置管理

配置管理是一个帮助我们强制在 IT 系统上实施所需配置状态的过程。在我们的上下文中,这是一种确保网络设备在推出新设置时按预期运行的方法。由于这成为我们反复执行的一项日常任务,因此网络配置管理是 NetDevOps 2020 调查(进一步阅读)中最常见的网络自动化用例也就不足为奇了。

在上一章中,我们讨论了常见的配置管理任务,以及一些有用的工具和库,这些工具和库可以帮助您编写程序以 Go 语言自动化这些任务。在本章中,我们将关注几个具体的例子,更深入地了解 Go 如何帮助我们使用标准协议连接和交互来自不同网络供应商的网络设备。本章我们将涵盖以下四个方面:

  • 在介绍任何新示例之前,我们将定义一个由三个节点组成的多供应商虚拟网络实验室,以测试本章和本书后续章节中的代码示例。

  • 接下来,我们将探讨如何使用 Go 和 SSH 与网络设备交互。

  • 然后,我们将按照与 SSH 相同的程序结构重复练习,但使用 HTTP 来对比这些不同的选项。

  • 最后,我们将提取并解析生成的操作状态,以验证我们的配置更改是否成功。

注意,我们在这里故意没有讨论基于 YANG 的 API,因为我们将在这本书的最后几章中详细介绍它们。

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

  • 环境设置

  • 通过 SSH 与网络设备交互

  • 通过 HTTP 与网络设备交互

  • 状态验证

技术要求

您可以在本书的 GitHub 仓库中找到本章的代码示例:github.com/PacktPublishing/Network-Automation-with-Go,在ch06文件夹下。

重要提示

我们建议您在虚拟实验室环境中执行本章的 Go 程序。请参阅附录以获取先决条件和构建它的说明。

环境设置

学习和实验网络自动化最简单、最安全的方法之一是构建一个实验室环境。多亏了过去十年我们所取得的进步,今天,我们可以访问来自不同网络供应商的虚拟化和容器化网络设备,以及大量可以帮助我们构建虚拟拓扑的工具。

在本书中,我们将使用这些工具之一:Containerlab。这个用 Go 编写的工具允许您从容器镜像中构建任意网络拓扑。您可以在几秒钟内创建和运行基于纯 YAML 文件的拓扑,这使得它成为快速测试的一个强有力的选择。请参阅附录以获取安装说明和主机操作系统的推荐。

创建拓扑

在本书的其余部分,我们将使用一个基础网络拓扑,该拓扑由三个运行不同网络操作系统(NOSes)的容器化网络设备组成:

  • srl:运行诺基亚的服务路由器 LinuxSR Linux

  • cvx:运行 NVIDIA 的 Cumulus Linux

  • ceos:运行 Arista 的 EOS

以下图表展示了设备之间的连接。它们都使用默认(空白)配置启动:

图 6.1 – 测试拓扑

图 6.1 – 测试拓扑

我们可以使用以下 YAML 文件描述此拓扑,这是Containerlab可以解释并将其转换为运行拓扑的表示:

name: netgo
topology:
  nodes:
    srl:
      kind: srl
      image: ghcr.io/nokia/srlinux:21.6.4
    ceos:
      kind: ceos
      image: ceos:4.26.4M
    cvx:
      kind: cvx
      image: networkop/cx:5.0.0
      runtime: docker
  links:
    - endpoints: ["srl:e1-1", "ceos:eth1"]
    - endpoints: ["cvx:swp1", "ceos:eth2"]

您可以在本书的 GitHub 仓库中找到这个 YAML 文件,就像其他代码示例一样,具体位于topo-base目录中。如果您通过附录学习更多关于 Containerlab 的内容,或者您已经运行了它,您可以使用以下命令启动整个实验室:

topo-base$ sudo containerlab deploy -t topo.yml --reconfigure

一旦实验室启动,您可以使用以下表中显示的凭证通过设备的主机名访问每个设备:

设备 用户名 密码
clab-netgo-srl admin admin
clab-netgo-ceos admin admin
clab-netgo-cvx cumulus cumulus

表 6.1 – Containerlab 访问凭证

例如,要通过 SSH 访问 NVIDIA 的设备,您将执行ssh cumulus@clab-netgo-cvx

⇨  ssh cumulus@clab-netgo-cvx
cumulus@clab-netgo-cvx's password: cumulus
Linux cvx 5.14.10-300.fc35.x86_64 #1 SMP Thu Oct 7 20:48:44 UTC 2021 x86_64
Welcome to NVIDIA Cumulus (R) Linux (R)
cumulus@cvx:mgmt:~$ exit

如果您想了解更多关于 Containerlab 的信息,或者想在云中运行此实验室设置,请查看本书附录中的说明。

通过 SSH 与网络设备交互

安全外壳协议SSH)是网络工程师用来通过命令行界面CLI)安全访问和配置网络设备的主要协议,该界面传输非结构化数据以显示给最终用户。该界面模拟计算机终端,因此我们传统上用它来进行人类交互。

网络工程师在开始自动化日常任务之旅时采取的第一个步骤之一是创建脚本,这些脚本会按顺序运行一系列 CLI 命令以实现结果。否则,他们将通过 SSH 伪终端交互式地运行这些命令。

虽然这为我们提供了速度,但这并不是网络自动化的唯一好处。随着我们在本书的其余部分介绍不同的技术,其他好处,如可靠性、可重复性和一致性等,成为了一个常见主题。现在,我们将从在 Go 中创建一个到网络设备的 SSH 连接开始,逐行发送配置命令,然后利用 Go 中更高层次的包,该包抽象了不同网络供应商的连接细节,使网络工程师的开发体验更加简单。

描述网络设备配置

我们想用 Go 做的第一个任务是配置我们在上一节中定义的三个节点拓扑中的每个设备。作为一个学习练习,我们将创建三个不同的 Go 程序来独立配置每个设备,以便您可以对比不同的方法。虽然每个程序都是独特的,但它们都遵循相同的设计结构。一个程序使用 SSH 连接和配置设备,另一个使用 Scrapligo,最后一个使用 HTTP,我们将在下一节中介绍。

为了使代码示例有意义,同时又不至于过于复杂,我们已将设备配置限制应用于以下部分:

  • 每个中继链路上都有一个唯一的 IPv4 地址

  • 在这些 IP 之间建立了一个边界网关协议BGP)对等连接

  • 一个唯一的环回地址,也被重新分配到 BGP 中

这些设置的目的是在所有三个环回接口之间建立可达性。

在现实生活中的自动化系统中,开发人员努力寻找一个通用的数据模型,您可以使用它来表示任何供应商的设备配置。这个例子中的两个主要例子是 IETF 和 OpenConfig YANG 模型。我们将在这个案例中做同样的事情,通过定义我们将用于所有三个网络设备的标准输入数据模式,但直接使用 Go 来定义数据结构而不是 YANG 建模语言。此模式包含足够的信息以满足建立端到端可达性的目标:

type Model struct {
    Uplinks  []Link `yaml:"uplinks"`
    Peers    []Peer `yaml:"peers"`
    ASN      int    `yaml:"asn"`
    Loopback Addr   `yaml:"loopback"`
}
type Link struct {
    Name   string `yaml:"name"`
    Prefix string `yaml:"prefix"`
}
type Peer struct {
    IP  string `yaml:"ip"`
    ASN int    `yaml:"asn"`
}
type Addr struct {
    IP string `yaml:"ip"`
}

在每个程序中,我们通过input.yml文件(该文件位于程序文件夹中)向数据模型提供参数,以生成设备的配置。对于第一个示例,此文件如下所示:

# input.yml
asn: 65000
loopback: 
  ip: "198.51.100.0"
uplinks:
  - name: "ethernet-1/1"
    prefix: "192.0.2.0/31"
peers:
  - ip: "192.0.2.1"
    asn: 65001

在我们打开此文件进行读取后,我们使用Decode方法将此信息反序列化为一个Model类型的实例 – 这代表数据模型。以下输出表示这些步骤:

func main() {
    src, err := os.Open("input.yml")
    // process error
    defer src.Close()
    d := yaml.NewDecoder(src)
    var input Model
    err = d.Decode(&input)
    // process error
}

然后,我们将输入变量(Model类型)传递给配置生成函数(devConfig),该函数将此信息转换为目标设备可以理解的语法。这种转换的结果是特定供应商的配置序列化为字节,您可以将其传输到远程设备。

传输库使用默认凭据与远程设备建立连接,您可以通过命令行标志来覆盖这些凭据。我们创建的会话有一个io.Writer元素,我们可以使用它将配置发送到远程设备:

图 6.2 – 程序结构

图 6.2 – 程序结构

现在我们已经熟悉了程序的结构,让我们探索不同的实现来了解更多关于可用于与网络设备通信的 Go 包,从 SSH 和 Scrapligo 开始。

使用 Go 的 SSH 包访问网络设备

我们要配置的第一个设备是从拓扑中配置的容器化诺基亚text/template模板包。

Go 的 SSH 包 golang.org/x/crypto/ssh 属于一组仍然是 Go 项目的一部分但开发在主 Go 树之外、兼容性要求更宽松的包。尽管这并非唯一的 SSH Go 客户端,但其他包倾向于重用此包的部分,因此它们成为更高层次的抽象。

如一般程序设计所述,我们使用 Model 数据结构来保存设备配置输入,并将它们与 srlTemplate 模板合并,以生成有效的设备配置作为字节数据缓冲区:

const srlTemplate = `
enter candidate
{{- range $uplink := .Uplinks }}
set / interface {{ $uplink.Name }} subinterface 0 ipv4 address {{ $uplink.Prefix }}
set / network-instance default interface {{ $uplink.Name }}.0
{{- end }}
...
`

srlTemplate 常量有一个模板,它首先通过(使用 range 关键字)遍历 Model 实例的上联。对于每个 Link,它取其 NamePrefix 属性来创建我们可以放置在缓冲区中的几个 CLI 命令。在下面的代码中,我们正在运行 Execute 方法,通过 in 变量传递输入,并将交互式 CLI 命令的二进制表示放在 b 上,我们稍后预计将其发送到远程设备(cfg):

func devConfig(in Model)(b bytes.Buffer, err error){
    t, err := template.New("config").Parse(srlTemplate)
    // process error
    err = t.Execute(&b, in)
    // process error
    return b, nil
}
func main() {
    /* ... <omitted for brevity > ... */
    var input Model
    err = d.Decode(&input)
    // process error
    cfg, err := devConfig(input)
    /* ... <continues next > ... */
}

我们已经将认证凭据硬编码为正确的值以适应实验室,但如有必要,您可以覆盖它们。我们使用这些参数与 srl 网络设备建立初始连接:

func main() {
    /* ... <continues from before > ... */
    settings := &ssh.ClientConfig{
        User: *username,
        Auth: []ssh.AuthMethod{
            ssh.Password(*password),
        },
        HostKeyCallback: ssh.InsecureIgnoreHostKey(),
    }
    conn, err := ssh.Dial(
        "tcp",
        fmt.Sprintf("%s:%d", *hostname, sshPort),
        settings,
    )
    // process error
    defer conn.Close()
    /* ... <continues next > ... */
}

如果认证凭据正确且没有连接问题,ssh.Dial 函数返回一个连接处理器(conn),代表一个单一的 SSH 连接。此连接作为可能各种通道的单个传输。其中一个通道是用于与远程设备进行交互式通信的伪终端会话,但它也可能包括额外的通道,您可以使用它们进行端口转发。

下面的代码片段启动一个新的终端会话并设置预期的终端参数,例如终端高度、宽度和 ssh.Session 类型提供的函数用于检索连接到远程终端的标准输入和标准输出管道:

func main() {
    /* ... <continues from before > ... */
    session, err := conn.NewSession()
    // process error
    defer session.Close()
    modes := ssh.TerminalModes{
        ssh.ECHO:          1,
        ssh.TTY_OP_ISPEED: 115200,
        ssh.TTY_OP_OSPEED: 115200,
    }
    if err := session.RequestPty("xterm", 40, 80, modes); err != nil {
        log.Fatal("request for pseudo terminal failed: ", err)
    }
    stdin, err := session.StdinPipe()
    // process error
    stdout, err := session.StdoutPipe()
    // process error
    session.Shell()
    /* ... <continues next > ... */
}

与 Go 包的其他部分一致,标准输入和标准输出管道分别实现了 io.Writerio.Reader 接口。这意味着您可以使用它们向远程网络设备写入数据并从其读取输出。我们将回到带有 CLI 配置的 cfg 缓冲区,并使用 WriteTo 方法将此配置发送到目标节点:

func main() {
    /* ... <continues from before > ... */
    log.Print("connected. configuring...")
    cfg.WriteTo(stdin)
}

这是此程序的预期输出:

ch06/ssh$ go run main.go 
go: downloading golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce
go: downloading gopkg.in/yaml.v2 v2.4.0
2022/02/07 21:11:44 connected. configuring...
2022/02/07 21:11:44 disconnected. dumping output...
enter candidate
set / interface ethernet-1/1 subinterface 0 ipv4 address 192.0.2.0/31
set / network-instance default interface ethernet-1/1.0
...
set / network-instance default protocols bgp ipv4-unicast admin-state enable
commit now
quit
Using configuration file(s): []
Welcome to the srlinux CLI.
Type 'help' (and press <ENTER>) if you need any help using this.
--{ running }--[  ]--                                                           
A:srl#                                                                          
--{ running }--[  ]--                                                           
A:srl# enter candidate                                                          
--{ candidate shared default }--[  ]--                                          
A:srl# set / interface ethernet-1/1 subinterface 0 ipv4 address 192.0.2.0/31    
--{ * candidate shared default }--[  ]-- 
.......                                
--{ * candidate shared default }--[  ]--                                        
A:srl# commit now                                                               
All changes have been committed. Leaving candidate mode.
--{ + running }--[  ]--                                                         
A:srl# quit

您可以在 ch06/ssh 文件夹中找到完整的示例(进一步阅读)。

自动化常规 SSH 任务

常见的网络元素,如路由器和交换机,通过 CLI 向人们显示数据而不是计算机。我们依赖于屏幕抓取让我们的程序消费这种可读数据。一个流行的屏幕抓取 Python 库,其名称来自 scrape cli,是 Scrapli。

Scrapli 有一个 Go 版本,我们将在下面的示例中探索,称为 Scrapligo。这个包的目标是在 SSH 之上提供下一层的抽象,同时隐藏一些传输复杂性,并提供几个方便的功能,并支持不同网络供应商的 CLI 风格。

为了展示 scrapligo 的实际应用,我们将在拓扑中配置另一个网络设备:Arista 的 cEOS (ceos)。就像我们使用 srl 一样,我们将使用一系列 CLI 命令来推送所需的网络状态,这样解析和从模板实例化字符串的初始步骤就相同了。变化的是模板,它使用了 Arista EOS 的语法:

const ceosTemplate = `
...
!
router bgp {{ .ASN }}
  router-id {{ .Loopback.IP }}
{{- range $peer := .Peers }}  
  neighbor {{ $peer.IP }} remote-as {{ $peer.ASN }}
{{- end }}
  redistribute connected
!
`

差异开始于我们到达 SSH 连接设置时。我们创建一个设备驱动程序 (GetNetworkDriver) 来连接到远程设备,使用设备主机名和认证凭证。平台定义来自 scrapligoplatform 包。从那时起,只需对这个驱动程序进行一次方法调用即可打开到远程设备的 SSH 连接:

func main() {
    /* ... <omitted for brevity > ... */
    conn, err := platform.NewPlatform(
        *nos,
        *hostname,
        options.WithAuthNoStrictKey(),
        options.WithAuthUsername(*username),
        options.WithAuthPassword(*password),
    )
    // process error  
    driver, err := conn.GetNetworkDriver()
    // process error  

    err = driver.Open()
    // process error  
    defer driver.Close()
    /* ... <continues next > ... */
}

scrapli 提供的一个额外功能是 cscrapligocfg 包,它定义了一个高级 API 来与远程网络设备的配置一起工作。这个 API 理解不同的 CLI 风格,它可以在发送到设备之前清理配置,并为我们生成配置差异。但最重要的是,这个包允许通过单个函数调用将整个设备配置作为字符串加载,处理诸如权限提升和配置合并或替换等问题。我们将使用 LoadConfig 方法来完成这项工作:

func main() {
    /* ... <continues from before > ... */
    conf, err := cfg.NewCfg(driver, *nos)
    // process error

    // sanitize config by removing keywords like "!" and "end"
    err = conf.Prepare()
    // process error

    response, err = conf.LoadConfig(config.String(), false)
    // process error
}

这些都是在这种情况下配置设备所需的步骤。运行程序后使用 go run,你可以使用 ssh 连接到设备以检查配置是否已经存在:

ch06/scrapli$ go run main.go 
2022/02/14 17:06:16 Generated config: 
!
configure
!
ip routing
!
interface Ethernet1
  no switchport
  ip address 192.0.2.1/31
!
...

通常,为了从设备获取响应,我们需要仔细读取响应缓冲区,直到我们看到命令行提示符,因为它通常以 scrapligo 可以为我们完成这项工作,通过读取接收到的缓冲区并将响应转换为字符串。

另一个流行的 Go SSH 包,提供执行大量命令的高级 API 是 yahoo/vssh。这里我们不会涉及它,但你可以在本书仓库的 ch06/vssh 目录中找到一个示例(进一步阅读),以配置拓扑中的网络设备。

通过 HTTP 与网络设备交互

在过去十年中,网络设备供应商开始包括 应用程序编程接口 (API) 来管理他们的设备,作为 CLI 的补充。发现具有强大 RESTful API 的网络设备并不罕见,这些 API 可以提供对该设备的读写访问。

RESTful API 是一种无状态的客户端-服务器通信架构,它运行在 HTTP 之上。请求和响应通常传输结构化数据(JSON、XML 等),但它们也可以携带纯文本。这使得 RESTful API 更适合机器之间的交互。

使用 Go 的 HTTP 包访问网络设备

剩下要配置的设备是 NVIDIA 的 Cumulus Linux(cvx)。我们将使用其基于 OpenAPI 的 RESTful API 来配置它。我们将配置编码在一个 JSON 消息中,并通过 Go 的net/http包发送一个 HTTP 连接。

与 SSH 示例一样,我们通常使用devConfig函数加载输入数据并将其转换为目标设备期望的形状,但在这个案例中,它是一个 JSON 有效负载。正因为如此,我们不再需要模板来构建网络设备配置,因为我们现在可以使用 Go 中的数据结构来编码和解码来自 JSON 或其他任何编码格式的数据。

数据结构表示目标设备的配置数据模型。理想情况下,这个数据模型应该与我们之前定义的相匹配,这样我们就不需要定义其他任何内容。但在实际应用中,我们看到所有网络供应商都有自己的数据模型。好消息是,IETF 和 OpenConfig 都提供了供应商无关的模型;我们将在第八章“网络 API”中稍后探讨这些内容。现在,我们将使用以下数据结构为此设备的配置:

type router struct {
    Bgp
}
type bgp struct {
    ASN      int
    RouterID string
    AF       map[string]addressFamily
    Enabled  string
    Neighbor map[string]neighbor
}
type neighbor struct {
    RemoteAS int
    Type     string
}

在主函数内部,我们解析程序标志并使用它们将 HTTP 连接设置存储在一个数据结构中,该数据结构包含构建 HTTP 请求所需的所有详细信息,包括 HTTP 客户端的非默认传输设置。我们这样做完全是出于方便,因为我们想将这些详细信息传递给不同的函数:

type cvx struct {
    url   string
    token string
    httpC http.Client
}
func main() {
    /* ... <omitted for brevity > ... */
    device := cvx{
        url:   fmt.Sprintf("https://%s:%d", *hostname, defaultNVUEPort),
        token: base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", *username, *password))),
        httpC: http.Client{
            Transport: &http.Transport{
                TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
            },
        },
    }
    /* ... <continues next > ... */
}

现在,我们可以发送配置并将其作为目标设备上的候选配置。我们可以通过引用我们与所需配置关联的修订 ID 来在设备上应用此配置。让我们看看执行此操作的步骤,这些步骤展示了在处理 HTTP 时需要考虑的不同属性。

首先,我们将创建一个新的修订 ID,并将其作为查询参数(?rev=<revisionID>)包含在 URL 中,以便连接到设备 API。现在,addr是包含设备主机名修订 ID的目标设备 URL:

func main() {
    /* ... <continues from before > ... */
    // create a new candidate configuration revision
    revisionID, err := createRevision(device)
    // process error
    addr, err := url.Parse(device.url + "/nvue_v1/")
    // process error
    params := url.Values{}
    params.Add("rev", revisionID)
    addr.RawQuery = params.Encode()
    /* ... <continues next > ... */
}

通过链接到修订 ID 的 URL,我们组装了配置更改的 PATCH 请求。这指向addrcfg,即devConfig函数返回的 JSON 设备配置。我们还添加了一个带有编码的用户名和密码的 HTTP Authorization头,并指示有效负载是一个 JSON 消息:

func main() {
    /* ... <continues from before > ... */
    req, err := http.NewRequest("PATCH", addr.String(), &cfg)
    // process error
    req.Header.Add("Content-Type", "application/json")
    req.Header.Add("Authorization", "Basic "+device.token)
    /* ... <continues next > ... */
}

一旦我们构建了 HTTP 请求,我们就可以将其传递给设备 HTTP 客户端的 Do 方法,该方法将所有内容序列化为二进制格式,设置 TCP 会话,并通过它发送 HTTP 请求。

最后,为了应用候选配置更改,我们必须在 applyRevision 函数内部进行另一个 PATCH 请求:

func main() {
    /* ... <continues from before > ... */
    res, err := device.httpC.Do(req)
    // process error
    defer res.Body.Close()
    // Apply candidate revision
    if err := applyRevision(device, revisionID); err != nil {
        log.Fatal(err)
    }
}

您可以在本书 GitHub 存储库的 ch06/http 目录中找到这个例子的代码(进一步阅读)。运行此程序时您应该看到以下内容:

ch06/http$ go run main.go 
2022/02/14 16:42:26 generated config {
 "interface": {
  "lo": {
   "ip": {
    "address": {
     "198.51.100.2/32": {}
...
 "router": {
  "bgp": {
   "autonomous-system": 65002,
   "router-id": "198.51.100.2"
  }
 },
 "vrf": {
  "default": {
   "router": {
    "bgp": {
...
     "enable": "on",
     "neighbor": {
      "192.0.2.2": {
       "remote-as": 65001,
       "type": "numbered"
      },
      "203.0.113.4": {
       "remote-as": 65005,
       "type": "numbered"
      }
...
}
2022/02/14 16:42:27 Created revisionID: changeset/cumulus/2022-02-14_16.42.26_K4FJ
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}

就像使用 SSH 一样,我们很少在我们的程序中直接使用 net/http 来与 REST API 交互,通常使用更高级别的包。

通过 HTTP 从其他系统获取配置输入

到目前为止,生成特定设备配置的数据一直来自程序文件夹中存在的静态文件。这些值是网络设备厂商无关的。

在现实世界的网络自动化系统中,这些值可以来自其他系统。例如,一个 IP 地址管理IPAM)工具可以通过对特定设备的 REST API 调用动态分配 IP 地址,您可以使用这些地址来构建其配置。提供这些参数的系统集合成为一些人所说的 真相来源。Nautobot 是一个基础设施资源建模应用程序,属于这一类别。

这也突出了这样一个事实,为了自动化网络,我们不仅需要与网络设备交互,还需要与其他系统(如 Nautobot)集成。这就是为什么我们将这个例子专门用于探索如何使用 Go 与 Nautobot 的免费公共实例交互,该实例对任何人都可以在 demo.nautobot.com/ 上访问。

Nautobot 的 Go 客户端包是从其 OpenAPI 规范自动生成的,这意味着如果您已经与其他 OpenAPI 衍生的包合作过,其结构可能对您来说很熟悉,这是机器生成代码的一个优点。

在以下示例中,我们使用自动生成的 Nautobot Go 包来定义一个指向 demo.nautobot.com/ 的 Nautobot API 客户端,并使用 API 令牌:

func main() {
    token, err := NewSecurityProviderNautobotToken("...")
    // process error

    c, err := nb.NewClientWithResponses(
        "https://demo.nautobot.com/api/",
        nb.WithRequestEditorFn(token.Intercept),
    )
    /* ... <continues next > ... */
}

c 客户端使我们能够与远程 Nautobot 实例进行交互。在这个例子中,我们想要将实验室拓扑中的一个节点(ceos)添加到 device.json 文件中:

{
    "name": "ams01-ceos-02",
    "device_type": {
        "slug": "ceos"
    },
    "device_role": {
        "slug": "router"
    },
    "site": {
        "slug": "ams01"
    }
}

在我们能够将设备添加到 Nautobot 之前,我们必须确保在 device.json 文件中引用的设备类型、设备角色和站点名称已经在 Nautobot 中存在。createResources 函数负责这一点。然后,我们使用 getDeviceIDs 函数获取这些资源的 ID(设备类型、设备角色和站点),以便将新设备与其类型、角色和站点关联:

func main() {
    /* ... <continues from before > ... */
    err = createResources(c)
    // process error

    dev, err := os.Open("device.json")
    // process error
    defer dev.Close()

    d := json.NewDecoder(dev)

    var device nb.Device
    err = d.Decode(&device)
    // process error

    found, devWithIDs, err := getDeviceIDs(c, device)
    /* ... <continues next > ... */
}

如果设备尚未在 Nautobot 中,我们可以使用自动生成的 DcimDevicesCreateWithResponse 函数创建它:

func main() {
    /* ... <continues from before > ... */
    created, err := c.DcimDevicesCreateWithResponse(
        context.TODO(),
        nb.DcimDevicesCreateJSONRequestBody(*devWithIDs))
    check(err)
}

在从ch06/nautobot文件夹中运行go run nautobot程序后,你应该在 Nautobot 图形界面中看到以下内容:demo.nautobot.com/

图 6.3 – Nautobot 截图

图 6.3 – Nautobot 截图

我们传递给这些 Dcim 函数的数据最终会出现在 HTTP 请求中,就像我们在本章前面手动构建的那些一样。在这里,我们不需要直接处理 URL 查询、HTTP 路径或 JSON 有效负载,因为这个包为我们抽象了所有这些。这允许开发者更多地关注业务价值,而不是实现细节。这使得 API 更容易使用。

到目前为止,本章的重点更多地在于将配置推送到网络设备,而不是在操作之后读取网络的状态。虽然配置管理的重点是产生和部署正确格式的配置,但状态验证可以在验证配置更改是否成功中发挥关键作用。在下一节中,我们将学习如何从远程设备检索和解析操作数据。

状态验证

网络设备内部建模和存储其状态的方式通常与其配置数据模型不同。传统的以 CLI 为主的网络设备以表格格式向最终用户显示状态,这使得网络操作员更容易解释和推理。在具有 API 的网络操作系统上,它们可以以结构化格式呈现状态,使数据更适合自动化,但我们仍然需要准备正确的数据模型以进行反序列化。

在本节中,我们将通过一个代码示例来查看三种不同的方法,您可以使用这些方法从网络设备读取状态,该示例从本章前面几节中用crypto/sshnet/httpscrapligo配置的设备中收集操作数据。对于每个网络设备,我们将使用这些资源之一来获取我们所需格式的数据:

  • RESTful API 调用:用于从 HTTP 接口检索和解析数据

  • 正则表达式:用于解析通过 SSH 接收的纯文本

  • TextFSM 模板:用于简化解析表格数据

检查路由信息

到目前为止,你应该有一个三个节点的拓扑运行。每个网络设备都有一个回环地址,我们将它重新分配到 BGP 中。例如,Arista cEOS 的回环地址是198.51.100.1/32。下一个程序的目标是验证设置。我们从每个设备检索路由表信息,以检查是否所有三个 IPv4 回环地址都存在。这样,我们可以验证我们的配置意图——在所有设备之间建立端到端可达性。

程序有两个构建块:

  • GetRoutes:一个连接到网络设备、获取所需信息并将其放入通用格式的方法

  • checkRoutes:一个函数,它从GetRoutes读取路由,并将其与我们期望看到的环回地址列表(expectedRoutes)进行比较

一个需要注意的问题是,网络设备支持的 API 类型可能因传输协议和数据文本表示的格式而异。在我们的例子中,这转化为不同网络供应商的GetRoutes实现细节的不同。在这里,为了教育目的,我们将每个供应商的实现做得完全不同,以独立展示 REST API、正则表达式和 TextFSM:

图 6.4 – 检查路由信息

图 6.4 – 检查路由信息

每个网络设备都有自己的数据结构。例如,我们为 SR Linux 创建了 SRL。SRLCVXCEOS类型实现了Router接口,因为每个都有一个包含特定供应商实现细节的GetRoutes方法。

在主程序中,用户只需要用认证详情初始化设备,因此它创建了一个为我们创建的那种类型的变量。然后,它可以通过为每个设备启动一个运行设备类型GetRoutes方法的 goroutine 来并发运行路由收集任务。Router接口成功地隐藏了特定供应商的实现细节,因为调用始终是相同的router.GetRoutes

type Router interface {
    GetRoutes(wg *sync.WaitGroup)
}

func main() {
     cvx := CVX{
     Hostname: "clab-netgo-cvx",
      Authentication: Authentication{
      Username: "cumulus",
     Password: "cumulus",
     },
    }
    srl := SRL{
     Hostname: "clab-netgo-srl",
     Authentication: Authentication{
      Username: "admin",
      Password: "admin",
     },
    }
    ceos := CEOS{
     Hostname: "clab-netgo-ceos",
     Authentication: Authentication{
      Username: "admin",
      Password: "admin",
     },
    }

    log.Printf("Checking reachability...")

    devices := []Router{cvx, srl, ceos}

    var wg sync.WaitGroup
    for _, router := range devices {
        wg.Add(1)
        go router.GetRoutes(&wg)
    }
    wg.Wait()
}

由于所有GetRoutes实例都在各自的 goroutine 中后台运行,我们添加了一个wg等待组,以确保在收集和验证所有设备之前,我们不结束主 goroutine。在每个GetRoutes方法结束之前,我们调用expectedRoutes函数来处理从该设备获取的路由。

我们通过检查每个包含唯一一组环回地址的expectedRoutes是否存在于每个设备的路由表中来验证解析后的状态(路由)。对于收到的每个 IPv4 前缀,我们检查它是否存在于expectedRoutes中,并更改一个布尔标志来表示这一点。如果到最后,expectedRoutes中存在布尔值为false的前缀,这意味着它们没有出现在设备的路由表中,我们将创建一个日志消息:

func checkRoutes(device string, in []string, wg *sync.WaitGroup) {
    defer wg.Done()
    log.Printf("Checking %s routes", device)
    expectedRoutes := map[string]bool{
        "198.51.100.0/32": false,
        "198.51.100.1/32": false,
        "198.51.100.2/32": false,
    }
    for _, route := range in {
        if _, ok := expectedRoutes[route]; ok {
            log.Print("Route ", route,
                        " found on ", device)
            expectedRoutes[route] = true
        }
    }
    for route, found := range expectedRoutes {
        if !found {
            log.Print("! Route ", route, 
                        " NOT found on ", device)
        }
    }
}

在此之后,我们检查每个GetRoutes方法实现。与其它示例一样,你可以在本书 GitHub 仓库的ch06/state文件夹中找到完整的程序(进一步阅读)。

使用正则表达式解析命令输出

我们使用正则表达式解析和从非结构化数据中提取信息。Go 标准库包括regexp包,它理解 RE2 语法。这是一个以安全性作为其主要目标的正则表达式库。该决策的一个主要后果是缺乏回溯和前瞻操作,这些操作是不安全的,可能导致拒绝服务攻击。

在这种情况下,GetRoutes方法使用scrapligo连接并发送一个show命令,从本例中的 SRL 设备类型中提取路由表信息。解析这些信息的一种方法是一行一行地迭代输出,同时使用正则表达式匹配预期的模式,这接近我们在ch05/closed-loop示例中做的事情(进一步阅读):

func (r SRL) GetRoutes(wg *sync.WaitGroup) {
    lookupCmd := "show network-instance default route-table ipv4-unicast summary"

    conn, err := platform.NewPlatform(
        "nokia_srl",
        r.Hostname,
        options.WithAuthNoStrictKey(),
        options.WithAuthUsername(r.Username),
        options.WithAuthPassword(r.Password),
        options.WithTermWidth(176),
    )
    // process error

    driver, err := conn.GetNetworkDriver()
    // process error
    err = driver.Open()
    // process error 
    defer driver.Close()

    resp, err := driver.SendCommand(lookupCmd)
    // process error

    ipv4Prefix := regexp.
            MustCompile(`(\d{1,3}\.){3}\d{1,3}\/\d{1,2}`)

    out := []string{}
    for _, match := range ipv4Prefix.FindAll(
    resp.RawResult, -1) {
        out = append(out, string(match))
    }
    go checkRoutes(r.Hostname, out, wg)
}

为了使事情更简单一些,我们假设整个输出中与 IPv4 地址模式匹配的任何内容都是在路由表中安装的前缀。这样,我们就不需要读取和解析表格数据结构,而是告诉我们的程序找到所有匹配 IPv4 路由模式的文本出现,并将它们放在我们传递给checkRoutes函数以进行进一步处理的字符串切片(out)中。

使用模板解析半格式化的命令输出

使用正则表达式解析各种输出格式可能会很繁琐且容易出错。这就是为什么谷歌创建了TextFSM,最初作为一个 Python 库,以实现基于模板的半格式化文本解析。他们专门设计它来解析来自网络设备的信息,并且它有一个广泛的社区开发模板,维护在ntc-templates进一步阅读)中。

我们将使用这些社区模板之一来解析GetRoutes实现中 Arista cEOS 的ip路由命令的输出。Scrapligo 嵌入了一个 TextFSM 的 Go 端口,并可以使用TextFsmParse函数方便地解析响应:

func (r CEOS) GetRoutes(wg *sync.WaitGroup) {
    template := "https://raw.githubusercontent.com/networktocode/ntc-templates/master/ntc_templates/templates/arista_eos_show_ip_route.textfsm"
    lookupCmd := "sh ip route"
    conn, err := core.NewEOSDriver(
        r.Hostname,
        base.WithAuthStrictKey(false),
        base.WithAuthUsername(r.Username),
        base.WithAuthPassword(r.Password),
    )
    // process error
    err = conn.Open()
    // process error
    defer conn.Close()
    resp, err := conn.SendCommand(lookupCmd)
    // process error
    parsed, err := resp.TextFsmParse(template)
    // process error
    out := []string{}
    for _, match := range parsed {
        out = append(out, fmt.Sprintf(
                "%s/%s", match["NETWORK"], match["MASK"]))
    }
    go checkRoutes(r.Hostname, out, wg)
}

存储解析数据的parsed变量是一个包含map[string]interface{}值的切片,其中键对应于模板中定义的 TextFSM 值。因此,仅通过查看show ip route模板,我们就可以提取网络和掩码(前缀长度)信息,并将其追加到我们传递给checkRoutes函数以进行进一步处理的字符串切片(out)中。

使用 REST API 请求获取 JSON 格式化的数据

到目前为止,在本章中,我们看到了两种与 REST API 交互的不同方式——一种使用net/http包,另一种使用自动生成的面向高级的包(nautobot)。但您还有其他选择,例如go-resty,它建立在net/http之上,以提供与 REST API 端点交互时改进的用户体验。

在以下GetRoutes的实现中,我们利用go-resty构建所需的 HTTP 头以进行身份验证,通过查询参数扩展 URL,并将响应反序列化到用户定义的数据结构(routes)中:

Code Block 1:
func (r CVX) GetRoutes(wg *sync.WaitGroup) {
	client := resty.NewWithClient(&http.Client{
		Transport: &http.Transport{
			TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
		},
	})
	client.SetBaseURL("https://" + r.Hostname + ":8765" )
	client.SetBasicAuth(r.Username, r.Password)
	var routes map[string]interface{}
	_, err := client.R().
		SetResult(&routes).
		SetQueryParams(map[string]string{
			"rev": "operational",
		}).
		Get("/nvue_v1/vrf/default/router/rib/ipv4/route")
	// process error
	out := []string{}
	for route := range routes {
		out = append(out, route)
	}
	go checkRoutes(r.Hostname, out, wg)
}

我们已创建了一个 REST API 客户端,用于从目标设备(类型 CVX)请求路由表信息(...rib/ipv4/route)。我们使用路由表前缀作为键将 JSON 有效负载响应解码到map[string]interface{}类型的routes变量中。接下来,我们遍历routes,将所有键追加到一个字符串切片(out)中,我们可以将其传递给checkRoutes函数。

验证端到端可达性

您可以从ch06/state文件夹运行此程序来检查拓扑中的所有三个路由器是否可以从彼此访问(进一步阅读)。确保所有设备都具有从本章早期使用crypto/sshnet/httpscrapligo配置它们的示例配置。预期的输出应如下所示:

ch06/state$ go run main.go 
2022/03/10 17:06:30 Checking reachability...
2022/03/10 17:06:30 Collecting CEOS routes
2022/03/10 17:06:30 Collecting CVX routes
2022/03/10 17:06:30 Collecting SRL routes
2022/03/10 17:06:30 Checking clab-netgo-cvx routes
2022/03/10 17:06:30 Route 198.51.100.0/32 found on clab-netgo-cvx
2022/03/10 17:06:30 Route 198.51.100.1/32 found on clab-netgo-cvx
2022/03/10 17:06:30 Route 198.51.100.2/32 found on clab-netgo-cvx
2022/03/10 17:06:31 Checking clab-netgo-ceos routes
2022/03/10 17:06:31 Route 198.51.100.0/32 found on clab-netgo-ceos
2022/03/10 17:06:31 Route 198.51.100.1/32 found on clab-netgo-ceos
2022/03/10 17:06:31 Route 198.51.100.2/32 found on clab-netgo-ceos
2022/03/10 17:06:34 Checking clab-netgo-srl routes
2022/03/10 17:06:34 Route 198.51.100.0/32 found on clab-netgo-srl
2022/03/10 17:06:34 Route 198.51.100.1/32 found on clab-netgo-srl
2022/03/10 17:06:34 Route 198.51.100.2/32 found on clab-netgo-srl

如果任何路由在任何设备上不存在,我们会看到如下消息:

2022/03/10 15:59:55 ! Route 198.51.100.0/32 NOT found on clab-netgo-cvx
2022/03/10 15:59:55 ! Route 198.51.100.1/32 NOT found on clab-netgo-cvx

摘要

配置生成、部署、报告和合规性仍然是网络自动化操作中最受欢迎的操作。这就是引入自动化带来的即时效益最大、最明显的地方,使其成为进入自动化和 DevOps 世界的第一步逻辑步骤。配置管理是网络工程师花费大部分时间进行的那些重复性任务之一,因此它非常适合自动化。但是,将新的配置发送到设备只是更广泛流程的一部分,该流程应考虑故障处理,从配置中的语法错误到如何正确恢复与远程设备的连接中断。在这种情况下,您可以使用可重用代码抽象一些重复性任务,这些代码提供通用功能,以减少自动化用例的时间和努力。这正是自动化框架所提供的,我们将在下一章中讨论。

进一步阅读

要了解更多关于本章所涉及的主题,请查看以下资源:

第七章:自动化框架

大多数工程师通过编写小型临时脚本开始他们的自动化之旅。随着时间的推移,随着这些脚本的大小和数量增加,我们需要考虑我们创建的解决方案的运营模式以及我们建立的基础有多牢固。最终,我们必须协调不同团队之间的自动化实践,以实现规模化的业务成果。

为了减少自动化用例所花费的时间和精力,一些组织试图标准化他们的工具并在解决方案中重用通用组件,这通常会导致他们转向自动化框架。

自动化框架允许不同的团队在同一伞下合作,打破可能导致低效的隔阂,采用共同实践和代码重用性,并在各个领域强制执行政策,以使开发出的解决方案更加安全。

在选择最适合您环境和用例的框架时,请确保评估不同的自动化框架。在本章中,我们将回顾其中一些,并特别关注它们如何与 Go 集成。特别是,我们将查看以下内容:

  • Go 程序如何成为 Ansible 模块

  • 自定义 Terraform 提供器的开发

  • 其他知名基于 Go 的框架概述

我们通过审视行业中的当前趋势以及新一代自动化框架未来的可能发展来结束本章。

技术要求

您可以在本书的 GitHub 仓库(见“进一步阅读”部分)的 ch07 文件夹中找到本章的代码示例。

重要提示

我们建议您在虚拟实验室环境中执行本章中的 Go 程序。有关先决条件和构建它的说明,请参阅附录。

Ansible

Ansible 是一个开源项目、框架和自动化平台。其描述性自动化语言吸引了众多网络工程师的注意,他们认为它是进入网络自动化世界的入门途径,并且可以帮助他们相对快速地变得高效。

Ansible 采用无代理的推送式架构。它通过 SSH 连接到它管理的宿主机,并运行一系列任务。这些任务是我们称之为 Ansible 模块的小型程序,它们是 Ansible 从用户抽象出来的代码单元。用户只需提供输入参数,就可以依赖 Ansible 模块为他们完成所有繁重的工作。尽管抽象级别可能有所不同,但 Ansible 模块允许用户更多地关注其基础设施的期望状态,而不是实现该状态所需的单个命令。

Ansible 组件概述

Playbooks 是 Ansible 的核心。这些基于文本的声明性 YAML 文件定义了一组自动化任务,您可以将这些任务分组在不同的 Play 中。每个任务运行一个模块,该模块来自 Ansible 代码库或第三方内容集合:

图 7.1 – Ansible 高级图

图 7.1 – Ansible 高级图

我们使用 Ansible 清单来描述我们想要使用 Ansible 管理的主机或网络设备。图 7.1 提供了这些元素的高级概述。

清单

清单是一份您可以定义在文本文件中的静态托管主机列表,或者从外部系统动态提取。您可以使用组单独或集体管理主机。以下代码片段显示了 Ansible 清单文件:

[eos]
clab-netgo-ceos
[eos:vars]
ansible_user=admin
ansible_password=admin
ansible_connection=ansible.netcommon.network_cli

您还可以使用清单来定义组和主机级别的变量,这些变量将可用于 Ansible 演练。

演练、演练和任务

Ansible 演练是您使用基于 YAML 的 领域特定语言(DSL)编写的文件。一个演练可以有一个或多个演练。每个 Ansible 演练针对清单中的一个或多个主机执行一系列任务。以下代码输出显示了一个包含单个演练和两个任务的演练示例:

- name: First Play - Configure Routers
  hosts: routers
  gather_facts: true
  tasks:
    - name: Run Nokia Go module on local system with Go
      go_srl:
        host: "{{ inventory_hostname }}"
        user: "{{ ansible_user }}"
        password: "{{ ansible_password }}"
        input: "{{ hostvars[inventory_hostname] | string | b64encode }}"
      delegate_to: localhost
      when: ('srl' in group_names)
    - name: Run NVIDIA compiled Go module on remote system without Go
      go_cvx:
        host: localhost
        user: "{{ ansible_user }}"
        password: "{{ ansible_password }}"
        input: "{{ hostvars[inventory_hostname] | string | b64encode }}"
      when: ('cvx' in group_names)

最后一个示例是从本书 GitHub 存储库中 ch07/ansible 文件夹包含的更大演练(见 进一步阅读)的一个片段。该演练在两个不同的演练中分散了四个任务。我们使用该演练在本节中回顾不同的概念。

模块

每个任务执行一个 Ansible 模块。尽管实现可能不同,但 Ansible 模块的目标是幂等的,所以无论您多少次对同一组主机运行它,您总是得到相同的结果。

Ansible 随带提供了一些主要用 Python 编写的模块,但它不会阻止您使用其他编程语言,这正是我们在本节中要探讨的。

与 Ansible 模块一起工作

Ansible 模块的代码可以在远程节点上执行,例如 Linux 服务器,或者在本地上执行,在运行演练的节点上。后者是我们通常在托管节点是 API 服务或网络设备时所做的,因为它们两者都缺乏具有依赖项(如 Linux shell 和 Python)的执行环境。幸运的是,现代网络操作系统满足这些要求,这为我们提供了在本地或远程运行代码的两种选择。

如果您查看前面的演练片段,您可以看到我们如何实现这两个选项。第一个任务调用 go_srl 模块,该模块被委派到本地主机。这意味着它从运行 Ansible 的机器上运行,并针对在主机参数中提供的远程主机。第二个任务执行 go_cvx 模块,该模块没有被委派,因此它在远程节点上运行,将其 API 调用针对本地主机。

演练的其余部分使用本地和远程执行环境的组合,如下图中齿轮符号所示:

图 7.2 – 演练示例

图 7.2 – 演练示例

Ansible 剧本首先运行一个 Ansible 剧本来配置拓扑中的每个节点,并具有以下高级目标:

  • 使用我们在运行 Ansible 的机器上本地执行的编译后的 Go 代码配置 SR Linux 节点(srl

  • 使用我们在远程节点上执行的编译后的 Go 代码配置 NVIDIA Cumulus 节点(cvx

  • 使用在运行 Ansible 的机器上本地执行的编译后的 Go 代码配置 Arista EOS 节点(ceos

在前面的剧本中,选择本地或远程执行环境是随机的,只是为了展示两种不同的方法。由于我们所有的实验室设备都是基于 Linux 的,我们可以改变这种行为,而无需重写我们使用的 Ansible 模块。

第二个剧本有一个单一的任务,使用我们通过go run命令执行的未编译代码来验证所有三个设备上的配置状态。我们使用这个最后任务来展示一种使用 Go 原生原语而不是 Ansible 分叉来同时执行多个节点任务的并发替代方法。我们将在本节后面讨论这个问题。

开发 Ansible 模块

虽然 Ansible 开发者大多数使用 Python 编写 Ansible 模块,但编写模块为另一种编程语言有不同的原因:

  • 你的公司可能已经使用另一种编程语言。

  • 可能你知道或更习惯用另一种语言写作。

  • 代码已经可用,并且没有商业理由将其重写为另一种编程语言。

  • 你想利用 Python 中不可用的功能。

Ansible 的角色不是要替换你拥有的所有东西,尤其是如果它已经为你工作的话。为了说明这一点,我们将从其他章节中取出一组 Go 程序,并将它们转换为可以在剧本中执行的 Ansible 模块,以配置我们的实验室拓扑。

Ansible 模块接口

你可以通过添加自定义模块来扩展 Ansible。它们的实现代码应该放入library文件夹。当 Ansible 遇到一个未在系统中安装的模块的任务时,它会寻找一个与模块名称相同的文件在library文件夹中,并尝试将其作为模块运行,经过以下步骤:

  1. 它将所有模块参数保存到一个临时文件中,例如,/tmp/foo

  2. 它将那个模块作为一个子进程执行,传递文件名作为第一个也是唯一的参数,例如,./library/my_module /tmp/foo

  3. 它等待进程完成,并期望从其标准输出接收结构化响应。

虽然 Ansible 始终期望以 JSON 格式收到响应,但 Ansible 传递给模块的输入文件格式取决于模块是脚本还是二进制文件。所有二进制模块都从 JSON 文件获取输入参数,而脚本模块则接收 Bash 文件或只是一系列的键值对作为输入参数。

从 Go 代码的角度来看,为了使这种输入行为统一,我们在运行任何非编译的 Go 程序之前,将输入格式标准化为 JSON。我们使用一个包装 Bash 脚本来实现这一点,该脚本在调用go run命令之前将 Bash 输入转换为 JSON,正如您在本书 GitHub 仓库的ch07/ansible/library/go_state文件中所见(见进一步阅读)。

将您的 Go 代码适配以与 Ansible 交互

最终,一个自定义 Ansible 模块可以执行任何操作,只要它知道如何解析输入参数,并且知道如何返回预期的输出。我们需要将其他章节的 Go 程序修改为 Ansible 模块。但所需更改的数量是微不足道的。让我们来检查一下。

首先,为了这个例子,我们需要创建一个结构体来解析我们在输入 JSON 文件中接收到的模块参数。这些参数包括登录凭证和输入数据模型:

// ModuleArgs are the module inputs
type ModuleArgs struct {
  Host     string
  User     string
  Password string
  Input    string
}
func main() {
  if len(os.Args) != 2 {
    // generate error
  }
  argsFile := os.Args[1]
  text, err := os.ReadFile(argsFile)
  // check error
  var moduleArgs ModuleArgs
  err = json.Unmarshal(text, &moduleArgs)
  // check error
  /* ... <continues next > ... */

我们为 Ansible 使用的输入数据模型与其他章节中使用的相同。在这个例子中,这些数据位于ch07/ansible/host_vars目录。在 Ansible 中,这个数据模型变成了为每个主机定义的所有变量的一部分。我们将它,连同其他主机变量一起,作为 base64 编码的字符串传递。在我们的模块内部,我们解码输入字符串,并将其解码成我们之前使用的相同的Model结构体:

import (
  "encoding/base64"
  "gopkg.in/yaml.v2"
)
type Model struct {
  Uplinks  []Link `yaml:"uplinks"`
  Peers    []Peer `yaml:"peers"`
  ASN      int    `yaml:"asn"`
  Loopback Addr   `yaml:"loopback"`
}
func main() {
  /* ... <continues from before > ... */
  src, err :=
      base64.StdEncoding.DecodeString(moduleArgs.Input)
  // check error
  reader := bytes.NewReader(src)
  d := yaml.NewDecoder(reader)
  var input Model
  d.Decode(&input)
  /* ... <continues next > ... */

到目前为止,我们已经解析了足够的信息,让我们的 Go 程序能够配置网络设备。这部分 Go 代码不需要任何修改。您需要注意的唯一一点是,您现在需要将任何日志消息作为响应发送给 Ansible。

当所有工作完成后,我们需要为 Ansible 准备和打印响应对象。以下代码片段显示了所有更改都已通过时的正常路径

// Response is the values returned from the module
type Response struct {
  Msg     string `json:"msg"`
  Busy    bool   `json:"busy"`
  Changed bool   `json:"changed"`
  Failed  bool   `json:"failed"`
}
func main() {
  /* ... <continues from before > ... */
  var r Response
  r.Msg = "Device Configured Successfully"
  r.Changed = true
  r.Failed = false
  response, err = json.Marshal(r)
  // check error
  fmt.Println(string(response))
  os.Exit(0)
}

使用与我们刚才描述的类似模式,我们为三个实验室设备中的每一个创建了一个自定义模块,以及一个用于验证实验室拓扑状态的模块,就像我们在第六章“配置管理”中所做的那样。您可以在本书 GitHub 仓库的ch07/ansible/{srl|cvx|ceos|state}目录中找到这些模块(见进一步阅读)。

在我们继续执行之前,我们想展示一种我们可以利用 Go 的内置功能来加快和优化 Ansible 中并发任务执行的方法。

利用 Go 的并发性

Ansible 的默认行为是在继续执行下一个任务之前,先在所有主机上运行每个任务(线性策略)。当然,它并不是一次只在一个主机上运行一个任务;相反,它使用多个独立进程,尝试在您在 Ansible 配置中定义的 fork 数量所允许的主机数量上同时运行。这些进程是否并行运行取决于它们可用的硬件资源。

从资源利用的角度来看,一种更经济的方法是利用 Go 并发。这就是我们在 go_state Ansible 模块中所做的,我们针对清单中的单个节点,即隐含的本地主机,并将与远程节点的并发通信留给 Go。

对于以下模块,我们重用了来自 第六章 配置管理 部分的 状态验证 部分的代码示例,其中已经将访问细节嵌入到代码中,但你也可以将这些访问细节作为参数传递给模块以实现相同的结果:

  - name: Run Validate module on Systems with Go installed
    go_state:
      host: "{{ inventory_hostname }}"

这种方法的权衡是,我们获得了速度并更有效地利用资源,但失去了 Ansible 的清单管理方面。在尝试决定这是否适合你的用例时,请注意这一点。

运行剧本

你可以在 ch07/ansible 目录中找到涉及四个 Go Ansible 模块的完整示例。要运行它,首先确保从存储库的根目录运行 make lab-up 以启动实验室拓扑,然后使用 ansible-playbook 命令运行剧本:

ch07/ansible$ ansible-playbook playbook.yml 
# output omitted for brevity.
PLAY RECAP *********************************************************************************************************************************************************
clab-netgo-ceos            : ok=5    changed=0    unreachable=0    failed=0    skipped=4    rescued=0    ignored=0   
clab-netgo-cvx             : ok=2    changed=1    unreachable=0    failed=0    skipped=7    rescued=0    ignored=0   
clab-netgo-srl             : ok=2    changed=1    unreachable=0    failed=0    skipped=7    rescued=0    ignored=0   
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

现在我们已经介绍了 Go 程序如何与 Ansible 集成,我们将继续介绍另一个流行的自动化框架:Terraform。

Terraform

Terraform 是一个用于声明性基础设施管理的开源软件解决方案。它允许你使用代码表达和管理你基础设施的期望状态。它最初作为自动化公共云基础设施的框架而受到欢迎,但现在支持各种本地和公共云资源、平台、服务——几乎任何有 API 的东西。

Terraform 的一个关键区别在于它管理状态的方式。一旦它最初创建了一个远程资源,它就会将产生的状态保存到一个文件中,并依赖于这个状态在后续运行中存在。随着你更新和开发你的基础设施代码,状态文件使 Terraform 能够管理远程资源的整个生命周期,计算在状态之间转换的确切 API 调用序列。这种管理状态的能力、声明性配置语言以及无代理、API 首选的架构使得 Terraform 在云基础设施领域深深扎根,并成为 DevOps 和基础设施即代码工具链的关键部分。

如果我们查看 Terraform 注册表(见 进一步阅读),我们可以看到网络类别中有超过一百个提供者,从 SDN 设备和防火墙到各种云服务。这个数字正在上升的趋势中,因为越来越多的人采用声明性方法来管理他们的基础设施代码。这就是为什么我们认为对于网络自动化工程师来说,了解 Terraform 并能够使用 Go 扩展其功能非常重要。

Terraform 组件概述

整个 Terraform 生态系统是一系列 Go 包的集合。它们分发主要的 CLI 工具,通常称为 Terraform 核心工具,作为一个静态编译的二进制文件。这个二进制文件实现了命令行界面,并可以解析和评估用 Hashicorp 配置语言HCL)编写的指令。在每次调用时,它构建一个资源图并生成一个执行计划,以实现配置文件中描述的期望状态。主要二进制文件只包含少数插件,但可以发现和下载所需的依赖。

Terraform 插件也作为独立的二进制文件分发。Terraform 核心工具作为子进程启动和终止所需的插件,并使用基于 gRPC 的内部协议与它们交互。Terraform 定义了两种类型的插件:

  • 提供者:与远程基础设施提供者交互并实施所需更改

  • 配置器:实现一组命令式操作,作为一组终端命令声明,以引导提供者创建的资源

以下图表展示了我们所描述的内容,并显示了不同的 Terraform 组件如何在内部和外部进行通信:

图 7.3 – Terraform 高级图

图 7.3 – Terraform 高级图

大多数 Terraform 插件都是提供者,因为它们实现了声明式资源激活并与上游 API 通信。提供者定义了两种类型的对象,您可以使用它们与远程 API 交互:

  • 资源:表示实际管理的基础设施对象,例如虚拟机、防火墙策略和 DNS 记录

  • 数据源:提供一种查询不由 Terraform 管理的信息的方式,例如支持的云区域列表、虚拟机镜像或身份和访问管理IAM)角色

Terraform 提供者维护者决定要实现哪些资源和数据源,因此覆盖范围可能有所不同,尤其是在官方和社区支持的提供者之间。

使用 Terraform

一个典型的 Terraform 工作流程涉及几个需要按顺序发生的阶段。我们首先需要定义一个提供者,以确定我们将要管理的基础设施,然后使用资源和数据源的组合来描述我们的基础设施状态。我们将通过遵循本书 GitHub 仓库中创建的配置文件 ch07/terraform/main.tf 来逐步介绍这些阶段(见 进一步阅读)。

定义提供者

提供者定义了上游 API 的连接细节。它们可以指向公共 AWS API URL 或私有 vCenter 实例的地址。在下一个示例中,我们将展示如何管理运行在 demo.nautobot.com/ 的 Nautobot 示例实例。

Terraform 期望在当前工作目录中的一个文件中找到所需提供者的列表及其定义。为了简化,我们将这些详细信息包含在 main.tf 文件的顶部,并在同一文件中定义凭证。在生产环境中,这些详细信息可能位于单独的文件中,您应该从外部源获取凭证,例如,从环境变量中:

terraform {
  required_providers {
    nautobot = {
      version = "0.2.4"
      source  = "nleiva/nautobot"
    }
  }
}
provider "nautobot" {
  url = "https://demo.nautobot.com/api/"
  token = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}

定义了这些信息后,我们可以初始化 Terraform。以下命令指示 Terraform 执行插件发现并将任何依赖项下载到本地的 ./terraform 目录中:

ch07/terraform$ terraform init -upgrade
Initializing the backend...
Initializing provider plugins...
- Finding nleiva/nautobot versions matching "0.2.4"...
- Installing nleiva/nautobot v0.2.4...
- Installed nleiva/nautobot v0.2.4 (self-signed, key ID A33D26E300F155FF)

在此步骤结束时,Terraform 创建一个锁文件,.terraform.lock.hcl,以记录它刚刚做出的提供者选择。将此文件包含在版本控制存储库中,以便 Terraform 可以在您在不同机器上运行 terraform init 时默认做出相同的选择。

创建资源

要创建资源,我们在配置块中定义它,并使用零个或多个参数将值分配给资源字段。以下资源在 Nautobot 中创建了一个新的 Manufacturer 对象,具有指定的名称和描述:

resource "nautobot_manufacturer" "new" {
  description = "Created with Terraform"
  name        = "New Vendor"
}

现在,我们可以运行 terraform plan 来检查当前配置是否与现有状态匹配。如果不匹配,Terraform 将创建一个执行计划,其中包含使远程对象匹配当前配置的提议更改。我们可以跳过 terraform plan 命令,直接转到 terraform apply,它将生成计划并在一个步骤中执行:

ch07/terraform$ terraform apply --auto-approve
Terraform used the selected providers to generate the following execution plan. Resource actions
are indicated with the following symbols:
  + create
Terraform will perform the following actions:
  # nautobot_manufacturer.new will be created
  + resource "nautobot_manufacturer" "new" {
      + created             = (known after apply)
      + description         = "Created with Terraform"
      + devicetype_count    = (known after apply)
      + display             = (known after apply)
      + id                  = (known after apply)
      + inventoryitem_count = (known after apply)
      + last_updated        = (known after apply)
      + name                = "New Vendor"
      + platform_count      = (known after apply)
      + slug                = (known after apply)
      + url                 = (known after apply)
    }
Plan: 1 to add, 0 to change, 0 to destroy.

您可以在 Nautobot 的 Web UI 中查看此计划的运行结果,网址为 demo.nautobot.com/dcim/manufacturers/new-vendor/,或者您可以使用以下命令检查生成的状态:

ch07/terraform$ terraform state show 'nautobot_manufacturer.new'
# nautobot_manufacturer.new:
resource "nautobot_manufacturer" "new" {
    created             = "2022-05-04"
    description         = "Created with Terraform"
    devicetype_count    = 0
    display             = "New Vendor"
    id                  = "09219670-3e28-..."
    inventoryitem_count = 0
    last_updated        = "2022-05-04T18:29:06.241771Z"
    name                = "New Vendor"
    platform_count      = 0
    slug                = "new-vendor"
    url                 = "https://demo.nautobot.com/api/dcim/manufacturers/09219670-3e28-.../"
}

在撰写本文时,没有可用的 Terraform 提供者适用于 Nautobot,所以最后一个示例使用了我们为这本书专门创建的自定义提供者。创建新的提供者可以启用许多新的用例,并且它涉及编写 Go 代码,所以这就是我们接下来要讨论的内容。

开发 Terraform 提供者

最终,你可能会遇到功能有限或缺失的提供者,或者对于你的基础设施中的一部分平台,可能根本不存在提供者。这时,了解如何构建提供者可以起到关键作用,无论是扩展或修复提供者,还是构建全新的提供者。开始前的唯一先决条件是目标平台有可用的 Go SDK。例如,Nautobot 有一个 Go 客户端包,它可以从其 OpenAPI 模型自动生成,我们在 第六章通过 HTTP 从其他系统获取配置输入 部分已经使用过,配置管理,因此我们已经有了一切所需来开发其 Terraform 提供者。

创建新 Terraform 提供者的推荐方法是先从 terraform-provider-scaffolding 项目开始(见进一步阅读)。这个仓库提供了足够的样板代码,让你可以专注于内部逻辑,同时它提供了功能占位符并实现了远程过程调用RPC)集成。我们使用这个模板创建了 Nautobot 提供者,所以你可以将我们的最终结果与模板进行比较,看看我们做了哪些修改。

作为使用脚手架项目开发 Terraform 提供者的副产品,你可以在 Terraform 注册表中注册你的 Git 仓库,并享受自动渲染的提供者文档的好处(见进一步阅读)。

定义提供者

提供者内部代码(internal/provider/provider.go(见进一步阅读))从为提供者本身以及其管理的资源和数据源定义架构开始。在提供者的架构内部,我们定义了两个输入参数——urltoken。你可以通过添加更多的约束、默认值和验证函数来扩展每个架构结构:

func New(version string) func() *schema.Provider {
  return func() *schema.Provider {
    p := &schema.Provider{
      Schema: map[string]*schema.Schema{
        "url": {
          Type:         schema.TypeString,
          Required:     true,
          DefaultFunc:
          schema.EnvDefaultFunc("NAUTOBOT_URL", nil),
          ValidateFunc: validation.IsURLWithHTTPorHTTPS,
          Description:  "Nautobot API URL",
        },
        "token": {
          Type:        schema.TypeString,
          Required:    true,
          Sensitive:   true,
          DefaultFunc:
            schema.EnvDefaultFunc("NAUTOBOT_TOKEN", nil),
          Description: "Admin API token",
        },
      },
      DataSourcesMap: map[string]*schema.Resource{
        "nautobot_manufacturers":
            dataSourceManufacturers(),
      },
      ResourcesMap: map[string]*schema.Resource{
        "nautobot_manufacturer": resourceManufacturer(),
      },
    }
    p.ConfigureContextFunc = configure(version, p)
    return p
  }
}

定义了登录信息后,提供者可以为目标平台初始化一个 API 客户端。这发生在本地函数内部,其中urltoken被传递给 Nautobot 的 Go SDK,它创建了一个完全认证的 HTTP 客户端。我们将这个客户端保存在一个特殊的apiClient结构体中,它随后被传递给所有提供者资源,正如我们稍后所展示的:

import nb "github.com/nautobot/go-nautobot"
type apiClient struct {
  Client *nb.ClientWithResponses
  Server string
}
func configure(
  version string,
  p *schema.Provider,
) func(context.Context, *schema.ResourceData) (interface{}, diag.Diagnostics) {
  return func(ctx context.Context, d *schema.ResourceData) (interface{}, diag.Diagnostics) {
    serverURL := d.Get("url").(string)
    _, hasToken := d.GetOk("token")
    /* ... <omitted for brevity > ... */
    token, _ :=
        NewSecurityProviderNautobotToken(
          d.Get("token").(string))
    c, err := nb.NewClientWithResponses(
              serverURL,
              nb.WithRequestEditorFn(token.Intercept),
            )
    // process error
    return &apiClient{
      Client: c,
      Server: serverURL,
    }, diags
  }
}

现在我们已经准备了一个远程 API 客户端,我们可以开始编写我们管理资源的代码。

定义资源

正如我们为我们的提供者定义架构一样,我们现在需要为每个管理的资源和数据源定义一个架构。出于教育目的,我们只实现了一个资源类型,Manufacturer,以及一个相应的数据源,你可以用它来检索 Nautobot 中所有现有制造商的列表。

当我们定义架构时,我们的目标是尽可能接近上游 API。这应该会减少所需的数据转换数量,并使实现工作更加容易。让我们看看 Nautobot 的 Go SDK 代码:

type Manufacturer struct {
  Created       *openapi_types.Date
    `json:"created,omitempty"`
  CustomFields  *Manufacturer_CustomFields
    `json:"custom_fields,omitempty"`
  Description   *string `json:"description,omitempty"`
  /* ... <omitted for brevity > ... */
  Url           *string `json:"url,omitempty"`
}
type Manufacturer_CustomFields struct {
  AdditionalProperties map[string]interface{} `json:"-"`
}

我们在resource_manufacturer.go中为Manufacturer资源定义的架构紧密遵循前面输出中定义的字段和类型:

func resourceManufacturer() *schema.Resource {
  return &schema.Resource{
    Description: "This object manages a manufacturer",
    CreateContext: resourceManufacturerCreate,
    ReadContext:   resourceManufacturerRead,
    UpdateContext: resourceManufacturerUpdate,
    DeleteContext: resourceManufacturerDelete,
    Schema: map[string]*schema.Schema{
      "created": {
        Description: "Manufacturer's creation date.",
        Type:        schema.TypeString,
        Computed:    true,
      },
      "description": {
        Description: "Manufacturer's description.",
        Type:        schema.TypeString,
        Optional:    true,
      },
      "custom_fields": {
        Description: "Manufacturer custom fields.",
        Type:        schema.TypeMap,
        Optional:    true,
      },
      /* ... <omitted for brevity > ... */
      "url": {
        Description: "Manufacturer's URL.",
        Type:        schema.TypeString,
        Optional:    true,
        Computed:    true,
      },
    },
  }
}

一旦我们定义了所有具有约束、类型和描述的架构,我们就可以开始实现资源操作。脚手架项目为每个 CRUD 函数提供了占位符,所以我们只需要用代码填充它们。

创建操作

我们首先看看resourceManufacturerCreate函数,当 Terraform 确定必须创建一个新对象时,该函数会被调用。这个函数有两个非常重要的参数:

  • meta:存储我们之前创建的 API 客户端

  • d:存储在 HCL 配置文件中定义的所有资源参数

我们从 d 中提取用户定义的配置,并使用它从 Nautobot 的 SDK 中构建一个新的 nb.Manufacturer 对象。然后我们可以使用 API 客户端将此对象发送到 Nautobot 并保存返回的对象 ID:

func resourceManufacturerCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
    c := meta.(*apiClient).Client
    var m nb.Manufacturer
    name, ok := d.GetOk("name")
    n := name.(string)
    if ok {
        m.Name = n
    }
    /* ... <omitted for brevity > ... */
    rsp, err := c.DcimManufacturersCreateWithResponse(
        ctx,
        nb.DcimManufacturersCreateJSONRequestBody(m))
    // process error
    // process returned HTTP response
    d.SetId(id.String())
    return resourceManufacturerRead(ctx, d, meta)
}

通常,我们在创建新对象时不会定义所有可选字段。远程提供者在创建新对象时分配唯一的 ID 并初始化默认值。一些平台会返回新创建的对象,但没有任何保证。因此,在 Terraform 提供器实现中,在创建函数的末尾调用 read 函数以同步和更新本地状态是一种常见的模式。

读取操作

读取函数更新本地状态以反映上游资源的最新状态。我们在先前的示例中看到,创建函数在其执行结束时调用 read 函数以更新新创建对象的州。

但 read 的最重要用途是检测配置漂移。当你执行 terraform planterraform apply 时,read 是 Terraform 首先执行的操作,其目标是检索当前的上游状态并与状态文件进行比较。这使得 Terraform 能够理解用户是否手动更改了远程对象,因此它需要协调其状态,或者是否是最新的且不需要更新。

Read 函数与 CRUD 函数的签名相同,这意味着它以 *schema.ResourceData* 的形式获取托管资源的最新版本,并在 meta 中存储 API 客户端。在这个函数中,我们首先需要做的事情是获取上游对象:

import "github.com/deepmap/oapi-codegen/pkg/types"
func resourceManufacturerRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
    c := meta.(*apiClient).Client
    id := d.Get("id").(string)
    rsp, err := c.DcimManufacturersListWithResponse(
        ctx,
        &nb.DcimManufacturersListParams{
            IdIe: &[]types.UUID{types.UUID(id)},
        })
  /* ... <continues next > ... */
}

我们使用返回的数据来更新本地 Terraform 状态:

func resourceManufacturerRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
    /* ... <continues from before > ... */
    d.Set("name", item["name"].(string))
    d.Set("created", item["created"].(string))
    d.Set("description", item["description"].(string))
    d.Set("display", item["display"].(string))
    /* ... <omitted for brevity > ... */
    return diags
}

在这个阶段,我们的本地状态应该与上游同步,Terraform 可以决定是否需要任何更改。

剩余实现

在本章中,我们只涵盖了 Nautobot 提供器代码的一部分。我们需要实现的剩余部分包括以下内容:

  • 资源 更新删除 函数

  • 数据 实现

为了简洁起见,我们没有在书中包含此代码,但 Manufacturer 资源和数据源的完整实现可以在我们的演示 Nautobot 提供器存储库中找到(见 进一步阅读)。

网络提供器

编写提供器并保持其更新是一项重大任务。在本节的开头,我们提到 Terraform 在 Terraform 注册表的网络类别中有几个提供器(见 进一步阅读)。我们邀请您探索它们,并在实现自己的提供器之前始终检查是否存在现有的提供器。

Terraform 对声明性配置和状态管理的保证对试图采用 DevOps 和 GitOps 实践的网络工程师非常有吸引力。随着兴趣的增长,新的网络相关提供器的数量也在增加,以下是一些值得注意的最近新增的提供器:

  • JUNOS Terraform Automation Framework(参见 进一步阅读):允许您从 YANG 文件创建自定义的 JunOS Terraform 提供程序

  • Terraform Provider for Cisco IOS XE(参见 进一步阅读):管理 Cisco Catalyst IOS XE 设备的配置,包括交换机、路由器和无线局域网控制器

  • terraform-provider-junos(参见 进一步阅读):一个非官方的 Terraform 提供程序,用于支持 NETCONF 协议的 Junos OS 设备

  • terraform-provider-ciscoasa(参见 进一步阅读):DevNet 提供程序,用于配置 Cisco ASA 防火墙规则

这完成了对 Terraform 及其网络相关用例的概述。我们希望其采用率继续增加,网络提供商的数量也不断增加。在下一节中,我们将简要概述几个其他自动化框架。

其他自动化框架

我们行业还有许多其他自动化框架和解决方案,我们本希望在这一章中涵盖。我们所能做的最好的事情就是触及表面,将大部分探索留给您。同时,我们也不想让您认为除了 Ansible 和 Terraform 之外没有其他东西。本节为您概述了其他可以用于或适应在网络安全环境中使用的自动化框架和解决方案。

Gornir

Nornir(参见 进一步阅读)是一个流行的 Python 网络自动化框架,通过放弃 DSL 而选择 Python API 提供了纯编程体验。它具有可插拔的架构,您可以从库存到设备连接几乎替换或扩展框架的任何元素。它还提供了一种灵活的方式来并行化任务组,而无需直接处理 Python 的并发原语。

Gornir(参见 进一步阅读)是 Nornir 的 Go 语言实现。遵循相同的原理,它提供了诸如库存管理、任务并发执行和可插拔连接驱动程序等功能。Gornir 随附的最小驱动程序集,但其核心提供了 Go 接口,以改进和扩展此功能。如果您从 Python 转向 Go 并且熟悉 Nornir,Gornir 可能会通过熟悉的 API 和工作流程提供一个非常平滑的过渡。

Consul-Terraform-Sync

在上一节中,我们探讨了如何使用 Terraform 在远程目标上声明式地管理资源,并以 Nautobot 为例。与 Terraform 同属 Hashicorp 公司开发的另一项自动化解决方案在此基础上构建。它被称为 Consul-Terraform-Sync(参见 进一步阅读),通过结合 Terraform 和 Consul 并使用同步代理将它们链接在一起,实现了自动基础设施管理。

Consul 是一个分布式键/值存储,用于服务发现、负载均衡和访问控制。它通过设置一个使用 Raft 一致性协议的节点集群来工作,以保持对其内部状态的一致视图。服务器节点与其客户端通信并广播相关更新,以确保客户端拥有相关内部状态的相关部分的最新版本。所有这些都在幕后进行,配置最小化,这使得 Consul 成为服务发现和数据存储的一个非常受欢迎的选择。

Consul-Terraform-Sync 解决方案的主要思想是将 Consul 用作 Terraform 配置和状态的底层。同步代理连接到 Consul,等待更新,并在检测到任何更改时自动触发 Terraform 协调。

Consul-Terraform-Sync 允许您自动化任何这些提供程序的 Terraform 部署,并通过自动协调过程确保您的状态始终与您的意图相匹配。

mgmt

mgmt(见进一步阅读)是另一个完全用 Go 编写的基础设施自动化和管理框架。它有自己的 DSL,并使用内置的 etcd 集群同步其状态。它使用了一些有趣的想法,例如声明性和函数式 DSL、资源图和由闭环反馈触发的动态状态转换。就像 Gornir 一样,mgmt 随附一套插件,用户可以扩展,但没有任何插件是专门针对网络设备的,因为 mgmt 的主要用例是 Linux 服务器管理。

展望未来

在本章中,我们介绍了目前使用的流行网络自动化框架。所有这些框架都处于不同的开发阶段——一些已经达到顶峰,而另一些仍在跨越鸿沟(见进一步阅读)。但重要的是要记住,自动化框架不是一个已经解决的问题,有成熟的项目和已理解的流程。这个领域正在不断发展,新的自动化方法正在地平线上出现。

这些替代方法与我们之前所见的不同。最近我们看到的一个大趋势是离开命令式自动化范式,在这种范式中,人类操作员手动触发动作和任务。我们在第五章“网络自动化”中简要讨论了这一趋势,并希望在这里重新探讨,以展示闭环自动化方法如何改变基础设施管理系统格局。大多数现代自动化框架发展成具有以下一些或所有以下特征的系统:

  • 专注于系统的完整生命周期管理,而不是像引导、配置或退役这样的单个阶段。

  • 专用声明性状态定义和自动协调,或内部实现的自我修复。

  • 通过 GitOps 等实践将状态定义与平台管理分离。

  • 通过 API 提供云原生自助服务体验,减少手动和程序化消费这些服务的摩擦。

我们目前正处于这些系统和它们的构建块成为现实的时候,一些值得注意的例子包括 Crossplane、Nokia Edge Network Controller 和 Anthos Config Sync。它们将这些系统作为 Kubernetes 控制器构建,利用 Operator 模型,允许它们以标准方式公开它们的 API,这样其他系统就可以使用相同的工具与之通信。我们仍然不知道这些系统是否会成为主流并取代现有的框架,因为它们增加了复杂性,并引入了陡峭的学习曲线。无论如何,这是一个值得探索的领域,就像其他可能发展的潜在新趋势一样,因为基础设施管理远未得到解决。

摘要

选择 Ansible、Terraform 或编程语言来解决特定用例取决于许多变量。但不要陷入将其视为二元选择的陷阱。大多数时候,不同的技术相互补充,提供解决方案,正如我们在本章中展示的那样。在下一章中,我们将探索与网络设备和 Go 交互的新技术和更高级的技术。

进一步阅读

第三部分:与 API 交互

随着网络构建、部署和运营方式的演变,新的协议和接口已经出现,以促进机器与机器之间的通信,作为网络自动化的推动力。在这些章节中,我们将探讨一些这些新功能以及如何使用 Go 来利用它们。

这一部分的书包括以下章节:

  • 第八章, 网络 API

  • 第九章, OpenConfig

  • 第十章, 网络监控

  • 第十一章, 专家见解

  • 第十二章, 附录:构建测试环境

第八章:网络 API

随着我们构建、部署和运营网络的方式不断发展,新的协议和接口正在出现,以简化机器之间的通信——这是网络自动化的主要推动力。在本章和接下来的章节中,我们将探讨一些这些新功能,并探索如何在 Go 编程语言的环境中利用它们。

网络的命令行界面CLI)是我们,网络工程师,几十年来用于操作和管理网络设备的方式。随着我们朝着更程序化的网络管理方法发展,仅仅依赖于更快的 CLI 命令执行可能不足以大规模部署网络自动化解决方案。

没有强大基础解决方案是脆弱且不稳定的。因此,在可能的情况下,我们更倾向于基于结构化数据和机器友好的应用程序编程接口API)来构建网络自动化项目。这些接口的目标用例不是直接的人机交互,因此你可以依赖 Go 在远程 API 调用和本地用户界面之间进行转换。

当我们谈论 API 时,我们通常指的是构成 API 开发者体验的不同事物,这些是你评估 API 时需要考虑的:

  • 一组定义客户端和服务器之间交互规则的远程过程调用RPC)——至少包括创建、获取、更新和删除的标准操作集。

  • 交换的结构和数据类型——产品供应商可以使用 YANG 或 OpenAPI 等数据模型规范语言来定义这一点。

  • 包裹模型数据的底层协议,您可以将它序列化为标准格式之一,如 XML 或 JSON,并在客户端和服务器之间传输——这可能是 SSH,或者更常见的是 HTTP。

在网络领域,API 景观中还有一个维度决定了模型规范文档的来源。虽然每个网络供应商都可以自由编写自己的数据模型,但有两个供应商无关的模型来源——IETF 和 OpenConfig——努力提供一种供应商中立的配置和监控网络设备的方式。由于 API 生态系统的这种可变性,不可能涵盖所有协议和标准,因此在本章中,我们只涵盖了一小部分网络 API,这些 API 是根据可用性、实用性和有用性选择的:

  • 我们将从 OpenAPI 开始,它是更广泛的基础设施领域中最为普遍的 API 规范标准之一。

  • 接下来,我们将转向 JSON-RPC,它使用供应商特定的 YANG 模型。

  • 之后,我们将展示一个基于 RFC 标准的 HTTP 协议的示例,称为 RESTCONF。

  • 最后,我们将探讨如何利用协议缓冲区protobuf)和 gRPC 与网络设备进行交互并流式传输遥测数据。

在本章中,我们将仅关注这些网络 API,因为其他内容超出了范围。最引人注目的是缺席的网络配置协议NETCONF)——这是最古老的网络 API 之一,最初由 IETF 于 2006 年定义。我们跳过 NETCONF 主要是因为我们在本章使用的某些 Go 包中缺乏对 XML 的支持。尽管 NETCONF 今天仍在使用,并提供了相关的功能,如不同的配置数据存储、配置验证和网络范围内的配置事务,但在未来,它可能会被运行在 HTTP 和 TLS 之上的技术所取代,如 RESTCONF、gNMI 和各种专有网络 API。

技术要求

你可以在本书的 GitHub 仓库中找到本章的代码示例(参考进一步阅读部分),在ch08文件夹下。

重要提示

我们建议你在虚拟实验室环境中执行本章的 Go 程序。有关先决条件和构建它的说明,请参阅附录。

API 数据建模

在我们查看任何代码之前,让我们回顾一下什么是数据建模,它的关键组件是什么,以及它们之间的关系。虽然我们在这个解释中关注的是模型驱动 API 的配置管理方面,但类似的规则和假设也适用于涉及状态数据检索和验证的工作流程。

配置管理工作流程的主要目标是把一些输入转换成一个结构符合数据模型的序列化数据负载。这个输入通常是面向用户的数据,它有自己的结构,可能只包含配置值总数的一小部分。但这个输入与结果配置有一对一的关系,这意味着重新运行相同的流程应该会产生相同的一组 RPCs,具有相同的负载和相同的网络设备上的配置状态。

所有这一切的中心是一个数据模型——一个文本文档,描述了(配置)数据负载的分层结构和值类型。这份文档成为所有潜在客户的合同——只要他们以正确的格式发送数据,服务器就应该能够理解并解析它。这份合同是双向的,因此当客户端从服务器请求一些信息时,它可以期望以预定的格式接收它。

下面的图展示了模型驱动配置管理工作流程的主要组件及其关系:

图 8.1 – 数据建模概念

图 8.1 – 数据建模概念

到目前为止,我们已经讨论了一个模型、其输入以及产生的配置。直到现在,我们还没有提到的是 绑定。我们使用这个术语来指代一组广泛的工具和库,它们可以帮助我们以编程方式生成最终的配置数据负载,也就是说,不依赖于一系列文本模板或手动构建这些数据负载,这两者我们都认为在任何网络自动化工作流程中都是反模式。我们根据数据模型生成这些绑定,它们代表了模型的程序性视图。它们还可能包括几个辅助函数,用于将数据结构序列化和反序列化到预期的输出格式之一,例如 JSON 或 protobuf。我们将在本章的大部分内容中讨论和交互绑定,因为它们成为编程语言内部数据模型的主要接口。

现在我们已经介绍了一些理论,是时候将其付诸实践了。在接下来的部分,我们将探讨 OpenAPI 模型以及一种你可以实例化和验证它们的方法。

OpenAPI

在更广泛的基础设施景观中,HTTP 和 JSON 是机器到机器通信中常用的两种标准。大多数基于 Web 的服务,包括公共和私有云,都使用这些技术的组合来公开其外部 API。

OpenAPI 规范允许我们定义和消费 RESTful API。它让我们能够描述对应的负载的启用 HTTP 路径、响应和 JSON 架构。它作为 API 提供者和其客户端之间的合同,以允许更稳定和可靠的 API 消费者体验,并通过版本控制实现 API 的进化。

我们在网络上并不广泛使用 OpenAPI,可以说这是出于历史原因。YANG 及其协议生态系统早于 OpenAPI,网络操作系统的变化速度也没有你想象的那么快。但我们在网络设备中经常发现 OpenAPI 的支持——SDN 控制器、监控和配置系统或 域名系统 (DNS)、动态主机配置协议 (DHCP) 和 IP 地址管理 (IPAM) 产品。这使得 OpenAPI 对于任何网络自动化工程师来说是一项有价值的技能。

第六章第七章 中,我们通过一个示例了解了如何与 Nautobot 的外部基于 OpenAPI 的接口交互。我们使用了一个基于 Nautobot OpenAPI 规范的开源代码生成框架生产的 Go 包。在使用自动代码生成工具时,需要注意的一点是,它们依赖于 OpenAPI 规范的某个版本。如果你的 API 规范版本不同(今天有九个不同的 OpenAPI 版本;请参阅 进一步阅读 部分),工具可能不会生成 Go 代码。因此,我们想要探索一种替代方法。

在本节中,我们将配置 NVIDIA 的 Cumulus Linux 设备(cvx),该设备具有基于 OpenAPI 的 HTTP API,使用配置统一执行CUE;参见进一步阅读部分)——一个开源的领域特定语言DSL),用于定义、生成和验证结构化数据。

CUE 的主要用户界面是 CLI,但它也支持一流的 Go API,因此我们将专注于如何在 Go 代码中完全与之交互,并在适当的地方提供相应的 shell 命令。

下一个图展示了我们将讨论的 Go 程序的高级概述:

图 8.2 – 使用 OpenAPI 数据模型

图 8.2 – 使用 OpenAPI 数据模型

数据建模

从图表的顶部开始,我们首先需要生成可以用来生成配置网络设备的数据结构的 CUE 代码。

虽然 CUE 可以导入现有的结构化数据并生成 CUE 代码,但要达到代码组织最优化的点可能需要几次迭代。对于这里展示的示例,从头开始编写此代码要快得多。结果位于ch08/cue/template.cue文件中(参见进一步****阅读部分)。

重要提示

本书不会涵盖 CUE 语法或其任何核心概念和原则,而是将专注于其 Go API。有关该语言的更多详细信息,请参阅 CUE 的官方文档,该文档在进一步****阅读部分链接。

CUE 类似于 JSON,受到 Go 的强烈影响。它允许您通过引用定义数据结构并在不同的数据结构之间映射值。因此,CUE 中的数据生成成为一项数据转换练习,具有严格的值类型和模式验证。以下是前面提到的template.cue文件的一个片段,它定义了三个顶级对象,用于接口、路由和 VRF 配置:

package cvx
import "network.automation:input"
interface: _interfaces
router: bgp: {
    _global_bgp
}
vrf: _vrf
_global_bgp: {
    "autonomous-system": input.asn
    enable:              "on"
    "router-id":         input.loopback.ip
}
_interfaces: {
    lo: {
        ip: address: "\(input.LoopbackIP)": {}
        type: "loopback"
    }
    for intf in input.uplinks {
        "\(intf.name)": {
            type: "swp"
            ip: address: "\(intf.prefix)": {}
        }
    }
}
/* ... omitted for brevity ... */

重要提示

您可以参考 CUE 的引用和可见性教程(在进一步阅读部分链接),了解输出值、引用以及下划线使用的方法。

此文件引用了一个名为input的外部 CUE 包,它为前面输出中的数据模型提供了所需的数据输入。这种数据模板及其输入的分离允许您分别分发这些文件,并且它们可能来自不同的来源。CUE 提供保证,无论您遵循什么顺序组装这些文件,结果始终相同。

数据输入

现在,让我们看看我们如何定义和提供前面数据模型的输入。我们使用与第六章配置管理第七章自动化框架中使用的相同数据结构,在 YAML 文件(input.yaml)中,对于cvx实验室设备如下所示:

# input.yaml
asn: 65002
loopback: 
  ip: "198.51.100.2"
uplinks:
  - name: "swp1"
    prefix: "192.0.2.3/31"
peers:
  - ip: "192.0.2.2"
    asn: 65001

使用 CUE,我们可以通过构建相应的对象并引入约束来验证这些输入数据是否正确,例如,一个有效的 ASN 范围或 IPv4 前缀格式。CUE 允许你直接在模式定义中定义额外的值,无论是通过硬编码默认值(input.VRFs)还是引用同一上下文中的其他值(input.LoopbackIP):

package input
import (
    "net"
)
asn: <=65535 & >=64512
loopback: ip: net.IPv4 & string
uplinks: [...{
    name:   string
    prefix: net.IPCIDR & string
}]
peers: [...{
    ip:  net.IPv4 & string
    asn: <=65535 & >=64512
}]
LoopbackIP: "\(loopback.ip)/32"
VRFs: [{name: "default"}]

在示例程序的主函数中,我们使用importInput辅助函数读取输入 YAML 文件并生成相应的 CUE 文件:

import "cuelang.org/go/cue/load"
func main() {
    err := importInput()
    /* ... <continues next > ... */
}

程序将生成的文件保存为本地目录中的input.cue。这个函数的实现细节并不重要,因为你可以通过命令行使用cue import input.yaml -p input执行相同的操作。

在这个阶段,我们可以验证我们的输入是否符合前面显示的模式和约束。例如,如果我们把input.yaml中的asn值设置在预期范围之外,CUE 就会捕获并报告这个错误:

ch08/cue$ cue eval network.automation:input -c
asn: invalid value 10 (out of bound >=64512):
    ./schema.cue:7:16
    ./input.cue:3:6

设备配置

现在我们已经准备好配置我们的网络设备。我们通过将cvx包中定义的模板编译成具体的 CUE 值来生成最终的配置实例。我们分三步完成这项工作。

首先,我们加载本地目录中所有的 CUE 文件,指定包含模板的包名(cvx):

func main() {
    /* ... <continues from before > ... */
    bis := load.Instances([]string{"."}, &load.Config{
        Package: "cvx",
    })
    /* ... <continues next > ... */
}

第二步,我们将所有加载的文件编译成一个 CUE 值,这解决了所有导入并将输入与模板结合:

func main() {
    /* ... <continues from before > ... */
    ctx := cuecontext.New()
    i := ctx.BuildInstance(instances[0])
    if i.Err() != nil {
        msg := errors.Details(i.Err(), nil)
        fmt.Printf("Compile Error:\n%s\n", msg)
    }
    /* ... <continues next > ... */
}

最后,我们验证是否可以解析所有引用,并且输入提供了所有必需的字段:

func main() {
    /* ... <continues from before > ... */
    if err := i.Validate(
        cue.Final(),
        cue.Concrete(true),
    ); err != nil {
        msg := errors.Details(err, nil)
        fmt.Printf("Validate Error:\n%s\n", msg)
    }
    /* ... <continues next > ... */
}

一旦我们知道 CUE 值是具体的,我们就可以安全地将它序列化为 JSON 并发送到cvx设备。sendBytes函数的主体实现了我们在第六章“配置管理”中讨论的三阶段提交过程:

func main() {
    /* ... <continues from before > ... */
    data, err := e.MarshalJSON()
    // check error
    if err := sendBytes(data); err != nil {
        log.Fatal(err)
    }
    log.Printf("Successfully configured the device")
}

你可以在本书 GitHub 仓库的ch08/cue目录(参考进一步阅读部分)中找到完整的程序(参考进一步阅读部分)。该目录包括带有数据模板和输入模式的完整 CUE 文件以及输入 YAML 文件。该程序的正常执行应该产生如下输出:

ch08/cue$ go run main.go
main.go:140: Created revisionID: changeset/cumulus/2022-05-25_20.56.51_KF9A
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}
main.go:69: Successfully configured the device

请记住,尽管我们在这章中关注 CUE 的 Go API,但你也可以使用 CUE CLI(可执行二进制文件)执行相同的操作集。这甚至包括三阶段提交以提交和应用cvx配置。使用内置的 CUE 脚本语言,你可以定义任何任务序列,例如进行 HTTP 调用或检查和解析响应。你可以将这些操作或任务保存到特殊的工具文件中,它们将自动在cue二进制文件中可用。你可以在ch08/cue的 readme 文档中了解更多信息,并在该书的 GitHub 仓库的ch08/cue/cue_tool.cue文件中找到示例源代码(参考进一步阅读部分)。

CUE 在我们刚刚描述的应用场景之外还有许多用途,不同的开源项目如 Istiodagger.io(参考 进一步阅读 部分)已经采用了它并在其产品中使用。我们鼓励您探索本书涵盖之外的其他 CUE 用例,以及类似的配置语言如 JsonnetDhall(参考 进一步阅读 部分)。

我们已经介绍了几种与 OpenAPI 提供者交互的不同方式。在本章的剩余部分,我们将专注于基于 YANG 的 API。我们将介绍的第一个是诺基亚的 JSON-RPC 接口实现。

JSON-RPC

JSON-RPC 是一种轻量级协议,您可以使用它来在客户端和服务器之间交换结构化数据。它可以在不同的传输协议上工作,但我们将仅关注 HTTP。尽管 JSON-RPC 是一种标准,但它只定义了顶级 RPC 层,而有效载荷和操作则保持对每个实现的特定性。

在本节中,我们将展示如何使用诺基亚特定的 YANG 模型来配置我们实验室拓扑中的 srl 设备,因为 SR Linux 支持通过 JSON-RPC 发送和接收 YANG 有效载荷(参考 进一步阅读 部分)。

我们将尽量避免手动构建 YANG 数据有效载荷或依赖传统的文本模板方法。一些 YANG 模型的巨大规模,以及模型偏差和增强,使得手动构建有效载荷变得不可能。为了大规模地完成这项工作,我们需要依赖一种程序化的方法来构建配置实例和检索状态数据。这就是我们使用 openconfig/ygot(YANG Go 工具)(参考 进一步阅读 部分)的地方——一套从一组 YANG 模型自动生成代码的工具和 API。

在高层次上,示例程序的结构与 OpenAPI 部分中的类似。图 8.3 展示了本节中我们将要审查的程序的基本构建块:

图 8.3 – 使用 YANG 数据模型

图 8.3 – 使用 YANG 数据模型

我们将首先将自动生成的 Go 绑定与输入数据结合起来,构建一个配置实例来配置 srl 设备。

代码生成

从前面图表的顶部开始,第一步是从一组诺基亚的 YANG 模型生成相应的 Go 代码(参考 进一步阅读 部分)。我们将仅使用诺基亚 YANG 模型的一个子集来生成绑定,以配置我们所需要的,即 L3 接口、BGP 和路由重分发。这样,我们保持生成的 Go 包的大小小,并限制在我们的特定用例中。

很遗憾,除了阅读和理解 YANG 模型或从现有配置中逆向工程它们之外,没有通用的规则来确定您需要的模型列表。幸运的是,诺基亚开发了一个 YANG 浏览器(参考 进一步阅读 部分),它包括一个模式匹配搜索,可以突出显示相关的 XPaths,并帮助您找到正确的 YANG 模型集。

一旦我们确定了需要的模型,我们就可以使用 ygot 生成器工具根据它们构建一个 Go 包。我们不会描述这个工具的所有标志,因为 ygot 的官方文档(参考 进一步阅读 部分)涵盖了它们。不过,我们想强调我们将使用的重要选项:

  • generate_fakeroot: 这将所有生成的 Go 数据结构封装在一个名为 Device 的顶级 模拟 根数据结构中,以将所有模块连接到一个共同的层次结构中。因为没有 YANG 模型定义了一个适用于所有设备的通用根顶级容器,网络设备只需在根 (/) 处添加它们支持的 YANG 模块。ygot 通过这个 模拟 根容器表示根。

  • path: 此标志帮助 ygot 查找并解决任何 YANG 数据模型导入。

自动生成 srl 包并将其放置在我们使用的 ./pkg/srl/ 目录的完整命令如下:

ch08/json-rpc$ go run \
  github.com/openconfig/ygot/generator \
    -path=yang \
    -generate_fakeroot -fakeroot_name=device \
    -output_file=pkg/srl/srl.go \
    -package_name=srl \
    yang/srl_nokia/models/network-instance/srl_nokia-bgp.yang \
    yang/srl_nokia/models/routing-policy/srl_nokia-routing-policy.yang \
    yang/srl_nokia/models/network-instance/srl_nokia-ip-route-tables.yang

由于前面的命令有几个标志,可能需要记住它们的确切集合,以便将来可以重复构建。一个替代方案是将它们包含在一个代码构建工具中,例如 make。另一个更符合 Go 习惯的选项是使用 //go:generate 指令将其包含在源代码中,正如您在 ch08/json-rpc/main.go 文件中看到的那样(参考 进一步阅读 部分)。因此,您可以使用此命令反复生成相同的 srl

ch08/json-rpc$ go generate ./...

构建配置

现在我们已经构建了一个基于 YANG 的 Go 包,我们可以创建一个程序实例来表示我们想要的配置状态,并填充它。我们所有这些操作都在 Go 中完成,利用通用编程语言的全部灵活性。

例如,我们可以将配置程序设计为一组方法,输入模型作为接收器参数。在读取和解析输入数据后,我们创建一个空的 模拟 根设备,我们迭代地扩展它,直到构建包含所有我们想要配置的相关值的完整 YANG 实例。

使用根设备的好处是我们不需要担心单个路径。我们可以将有效载荷发送到 /,假设生成的 YANG 树层次结构从根开始:

import (
  api "json-rpc/pkg/srl"
)
// Input Data Model
type Model struct {
  Uplinks  []Link `yaml:"uplinks"`
  Peers    []Peer `yaml:"peers"`
  ASN      int    `yaml:"asn"`
  Loopback Addr   `yaml:"loopback"`
}
func main() {
  /* ... <omitted for brevity > ... */
  var input Model
  d.Decode(&input)
  device := &api.Device{}
  input.buildDefaultPolicy(device)
  input.buildL3Interfaces(device)
  input.buildNetworkInstance(device)
  /* ... <continues next (main) > ... */
}

上述代码在输入上调用三个方法。让我们聚焦于 buildNetworkInstance 方法,它负责 L3 路由配置。此方法是我们定义 网络实例 的地方,它是用于 VPN 路由和转发VRF)实例和 虚拟交换实例VSIs)的常用抽象。我们从顶级根设备创建一个新的网络实例,以确保我们将其附加到 YANG 树的顶部:

func (m *Model) buildNetworkInstance(dev *api.Device) error {
  ni, err := dev.NewNetworkInstance(defaultNetInst)
  /* ... <continues next (buildNetworkInstance) > ... */
}

在下一个代码片段中,我们将所有上行链路和环回接口移动到新创建的网络实例中,通过将每个子接口定义为默认网络实例的子项来实现:

func (m *Model) buildNetworkInstance(dev *api.Device) error {
  // ... <continues from before (buildNetworkInstance) > 
  links := m.Uplinks
  links = append(
    links,
    Link{
      Name:   srlLoopback,
      Prefix: fmt.Sprintf("%s/32", m.Loopback.IP),
    },
  )
  for _, link := range links {
    linkName := fmt.Sprintf("%s.%d", link.Name,
                            defaultSubIdx)
    ni.NewInterface(linkName)
  }
  /* ... <continues next (buildNetworkInstance) > ... */
}

接下来,我们通过手动填充 BGP 结构并将其附加到 default 网络实例的 Protocols.Bgp 字段来定义全局 BGP 设置:

func (m *Model) buildNetworkInstance(dev *api.Device) error {
  // ... <continues from before (buildNetworkInstance) > 
  ni.Protocols =
  &api.SrlNokiaNetworkInstance_NetworkInstance_Protocols{
    Bgp: 
    &api.
    SrlNokiaNetworkInstance_NetworkInstance_Protocols_Bgp{
      AutonomousSystem: ygot.Uint32(uint32(m.ASN)),
      RouterId:         ygot.String(m.Loopback.IP),
      Ipv4Unicast: 
      &api. 
SrlNokiaNetworkInstance_NetworkInstance_Protocols_Bgp_Ipv4Unicast{
        AdminState: api.SrlNokiaBgp_AdminState_enable,
      },
    },
  }
  /* ... <continues next (buildNetworkInstance) > ... */
}

配置的最后部分是 BGP 邻居。我们遍历输入数据模型中定义的对等体列表,并在我们之前设置的 BGP 结构下添加一个新的条目:

func (m *Model) buildNetworkInstance(dev *api.Device) error {
  // ... <continues from before (buildNetworkInstance) > 
  ni.Protocols.Bgp.NewGroup(defaultBGPGroup)
  for _, peer := range m.Peers {
    n, err := ni.Protocols.Bgp.NewNeighbor(peer.IP)
    // check error
    n.PeerAs = ygot.Uint32(uint32(peer.ASN))
    n.PeerGroup = ygot.String(defaultBGPGroup)
  }
  /* ... <continues next (buildNetworkInstance) > ... */
}

当我们完成填充 Go 结构时,我们确保所有提供的值都是正确的,并且符合 YANG 约束。我们可以通过在父容器上调用 Validate 方法来完成此操作:

func (m *Model) buildNetworkInstance(dev *api.Device) error {
    /* ... <continues from before (buildNetworkInstance) > ... */
    if err := ni.Validate(); err != nil {
        return err
    }
    return nil
}

设备配置

一旦我们用所有输入值填充了 YANG 模型实例,下一步就是将其发送到目标设备。我们通过几个步骤来完成此操作:

  1. 我们使用 ygot 辅助函数从当前的 YANG 实例生成一个映射。此映射已准备好根据 RFC7951 中定义的规则序列化为 JSON。

  2. 我们使用标准的 encoding/json 库构建一个单一的 JSON-RPC 请求,该请求使用我们的配置更改更新整个 YANG 树。

  3. 使用标准的 net/http 包,我们将此请求发送到 srl 设备:

    func main() {
    
        /* ... <continues from before (main) > ... */
    
        v, err := ygot.ConstructIETFJSON(device, nil)
    
        // check error
    
        value, err := json.Marshal(RpcRequest{
    
            Version: "2.0",
    
            ID:      0,
    
            Method:  "set",
    
            Params: Params{
    
                Commands: []*Command{
    
                    {
    
                        Action: "update",
    
                        Path:   "/",
    
                        Value:  v,
    
                    },
    
                },
    
            },
    
        })
    
        // check error
    
        req, err := http.NewRequest(
    
            "POST",
    
            hostname,
    
            bytes.NewBuffer(value),
    
        )
    
        resp, err := client.Do(req)
    
         // check error
    
        defer resp.Body.Close()
    
        if resp.StatusCode != http.StatusOK {
    
            log.Printf("Status: %s", resp.Status)
    
        }
    

您可以在本书 GitHub 仓库的 ch08/json-rpc 目录中找到配置 srl 设备的完整程序(参考 进一步阅读 部分)。要运行它,请 cd 到此文件夹并运行以下命令:

ch08/json-rpc$ go run main.go
2022/04/26 13:09:03 Successfully configured the device

此程序仅验证我们成功执行了 RPC;它尚未检查以确认它是否产生了预期的效果,我们将在本章后面讨论。与大多数基于 HTTP 的协议一样,单个 RPC 是一个单一的事务,因此您可以假设目标设备已应用了更改,只要您收到成功的响应。值得一提的是,一些 JSON-RPC 实现具有更多的会话控制功能,允许多阶段提交、回滚和其他功能。

在下一节中,我们将采取类似的方法配置网络设备,基于其 YANG 模型,但将引入一些变化以展示 OpenConfig 模型和 RESTCONF API。

RESTCONF

IETF 设计 RESTCONF 作为基于 HTTP 的 NETCONF 替代方案,它提供对包含 YANG 模型数据的概念数据存储的 创建、读取、更新和删除CRUD)操作。它可能缺少一些 NETCONF 功能,例如不同的数据存储、排他性配置锁定以及批量和回滚操作,但具体支持的和不支持的功能取决于实现和网络设备的功能。话虽如此,由于它使用 HTTP 方法并支持 JSON 编码,RESTCONF 减少了外部系统集成和与网络设备互操作入门的障碍。

RESTCONF 通过 HTTP 方法支持一组标准的 CRUD 操作:POST、PUT、PATCH、GET 和 DELETE。RESTCONF 使用 YANG XPath 转换为类似 REST 的 URI 来构建 HTTP 消息,并在消息体中传输有效负载。尽管 RESTCONF 支持 XML 和 JSON 编码,但我们将仅关注后者,其编码规则定义在 RFC7951 中。我们将使用 Arista 的 EOS 作为测试设备,在启动实验室拓扑时,其 RESTCONF API 已启用。

我们在本节中创建的程序结构与 图 8**.3 中所示的 JSON-RPC 示例相同。

代码生成

代码生成过程几乎与我们在 JSON-RPC 部分遵循的过程相同。我们使用 openconfig/ygot(请参阅 进一步阅读 部分)从 EOS 支持的一组 YANG 模型生成一个 Go 包。但在继续之前,有一些值得注意的差异需要提及:

  • 我们使用的是供应商中立的 OpenConfig 模型,而不是特定于供应商的 YANG 模型,这些模型 Arista EOS 支持。

  • 当使用 openconfig/ygot(请参阅 进一步阅读 部分)生成 Go 代码时,您可能会遇到在同一个命名空间中定义了多个模型的情况。在这种情况下,您可以使用 -exclude_modules 标志忽略特定的 YANG 模型,而无需从配置的搜索路径中删除其源文件。

  • 我们通过移除包含 list 节点的 YANG 容器来启用 OpenConfig 路径压缩,以优化生成的 Go 代码。有关更多详细信息,请参阅 ygen 库设计文档(进一步阅读)。

  • 我们还展示了另一种方法,其中我们不生成一个 的根设备。因此,我们无法在一个 RPC 中应用所有更改。相反,我们必须进行多个 HTTP 调用,每个调用都有自己的唯一 URI 路径。

在我们能够生成 Go 代码之前,我们需要确定支持的 Arista YANG 模型集(请参阅 进一步阅读 部分),并将它们复制到 yang 目录中。我们使用以下命令从该模型列表生成 eos Go 包:

ch08/restconf$ go run github.com/openconfig/ygot/generator \
  -path=yang \
  -output_file=pkg/eos/eos.go \
  -compress_paths=true \
  -exclude_modules=ietf-interfaces \
  -package_name=eos \
  yang/openconfig/public/release/models/bgp/openconfig-bgp.yang \
  yang/openconfig/public/release/models/interfaces/openconfig-if-ip.yang \
  yang/openconfig/public/release/models/network-instance/openconfig-network-instance.yang \
  yang/release/openconfig/models/interfaces/arista-intf-augments-min.yang

由于我们在 JSON-RPC 部分描述的原因,我们也可以使用以下命令将此命令嵌入到 Go 源代码中,以生成相同的 Go 包:

ch08/restconf$ go generate ./...

构建配置

在这个例子中,我们不会在一个单独的 HTTP 调用中应用所有更改,这样我们就可以向您展示如何更新 YANG 树的特定部分,而不会影响其他无关的部分。在前一节中,我们通过使用Update操作来解决这个问题,该操作将我们发送的配置与设备上现有的配置合并。

但在某些情况下,我们希望避免合并行为,并确保只有我们发送的配置存在于设备上(声明式管理)。为此,我们本可以导入所有现有的配置,并确定我们想要保留或替换的部分,然后再向目标设备发送新的配置版本。相反,我们通过一系列 RPC 创建一个针对 YANG 树特定部分的配置。

为了简化 RESTCONF API 调用,我们创建了一个特殊的restconfRequest类型,它包含一个 URI 路径和要发送到设备的相应有效载荷。main函数从解析数据模型的输入和准备一个变量以存储一组 RESTCONF RPC 开始:

type restconfRequest struct {
    path    string
    payload []byte
}
func main() {
    /* ... <omitted for brevity > ... */
    var input Model
    err = d.Decode(&input)
    // check error
    var cmds []*restconfRequest
    /* ... <continues next > ... */
}

与 JSON-RPC 示例一样,我们通过一系列方法调用构建所需的配置实例。这次,每个方法返回一个包含足够详细信息以构建 HTTP 请求的restConfRequest

func main() {
    /* ... <continues from before > ... */ 
    l3Intfs, err := input.buildL3Interfaces()
    // check error
    cmds = append(cmds, l3Intfs...)
    bgp, err := input.buildBGPConfig()
    // check error
    cmds = append(cmds, bgp)
    redistr, err := input.enableRedistribution()
    // check error
    cmds = append(cmds, redistr)
    /* ... <continues next > ... */
}

让我们检查这些方法之一,它可以从我们的输入创建一个 YANG 配置。enableRedistribution方法生成一个配置,以在直接连接的表和用于识别重分布源和目标的 BGP TableConnection结构之间启用重分布:

const defaultNetInst = "default"
func (m *Model) enableRedistribution() (*restconfRequest, error) {
    netInst := &api.NetworkInstance{
        Name: ygot.String(defaultNetInst),
    }
    _, err := netInst.NewTableConnection(
        api.OpenconfigPolicyTypes_INSTALL_PROTOCOL_TYPE_DIRECTLY_CONNECTED,
        api.OpenconfigPolicyTypes_INSTALL_PROTOCOL_TYPE_BGP,
        api.OpenconfigTypes_ADDRESS_FAMILY_IPV4,
    )

    /* ... <omitted for brevity > ... */
    value, err := ygot.Marshal7951(netInst)
    // check error
    return &restconfRequest{
        path: fmt.Sprintf(
            "/network-instances/network-instance=%s",
            defaultNetInst,
        ),
        payload: value,
    }, nil
}

图 8**.3 中的其余代码显示了本节中我们审查的程序的基本构建块。

设备配置

一旦我们准备好了所有必要的 RESTCONF RPC,我们就可以将它们发送到设备。我们遍历每个restconfRequest,并将其传递给一个辅助函数,捕获任何返回的错误。

restconfPost辅助函数有足够的代码来使用net/http包构建一个 HTTP 请求并将其发送到ceos设备:

const restconfPath = "/restconf/data"
func restconfPost(cmd *restconfRequest) error {
  baseURL, err := url.Parse(
    fmt.Sprintf(
      "https://%s:%d%s",
      ceosHostname,
      defaultRestconfPort,
      restconfPath,
    ),
  )
  // return error if not nil
  baseURL.Path = path.Join(restconfPath, cmd.path)
  req, err := http.NewRequest(
    "POST",
    baseURL.String(),
    bytes.NewBuffer(cmd.payload),
  )
  // return error if not nil
  req.Header.Add("Content-Type", "application/json")
  req.Header.Add(
    "Authorization",
    "Basic "+base64.StdEncoding.EncodeToString(
      []byte(
        fmt.Sprintf("%s:%s", ceosUsername, ceosPassword),
      ),
    ),
  )
  client := &http.Client{Transport: &http.Transport{
        TLSClientConfig: 
          &tls.Config{
            InsecureSkipVerify: true
          },
      }
  }
  resp, err := client.Do(req)
  /* ... <omitted for brevity > ... */
}

您可以在本书 GitHub 仓库的ch08/restconf目录中找到完整的程序(参考进一步阅读部分)。从运行实验室拓扑结构的宿主机上运行它应该会产生与这个类似的输出:

ch08/restconf$ go run main.go
2022/04/28 20:49:16 Successfully configured the device

在这个阶段,我们应该已经完全配置了我们的实验室拓扑结构中的所有三个节点。尽管如此,我们还没有确认我们所做的一切是否达到了预期的效果。在下一节中,我们将通过状态验证的过程,并展示如何使用网络 API 来完成这一过程。

状态验证

在本章的最后三个部分中,我们在验证配置更改是否产生预期效果之前就推动了设备配置。这是因为我们需要所有设备配置完毕后才能验证产生的收敛操作状态。现在,随着所有来自 OpenAPIJSON-RPCRESTCONF 部分的代码示例在实验室拓扑结构上执行,我们可以验证我们是否实现了配置意图——在所有三个设备的回环 IP 地址之间建立端到端可达性。

在本节中,我们将使用本章前面使用的相同协议和建模语言来验证每个实验室设备是否可以在其 GitHub 仓库的 ch08/state 目录(参考 进一步阅读 部分)中看到其他两个实验室设备的回环 IP 地址。接下来,我们将检查一个使用 Arista 的 cEOS (ceos) 实验室设备的单个示例。

操作状态建模

当我们谈论网络元素的操作状态时,需要注意 YANG 操作状态 IETF 草案(参考 进一步阅读 部分)中描述的已应用状态和导出状态之间的区别。前者指的是当前活动的设备配置,应该反映操作员已经应用的内容。后者是一组只读值,由设备的内部操作产生,例如 CPU 或内存利用率,以及与外部元素(如数据包计数器或 BGP 邻居状态)的交互。尽管在我们谈论操作状态时没有明确提及,但除非我们明确说明,否则假设我们指的是导出状态。

从历史上看,在 YANG 中对设备的操作状态进行建模的方法有很多:

  • 你可以选择将所有内容都包含在一个顶级容器中,或者从独立的 state 数据存储中读取,与用于配置管理的 config 容器/数据存储完全不同。

  • 另一种方法是为每个 YANG 子树创建一个独立的 state 容器,与 config 容器并列。这正是 YANG 操作状态 IETF 草案(参考 进一步阅读 部分)所描述的。

根据你使用的方法,你可能需要调整构建 RPC 请求的方式。例如,srl 设备需要一个对 state 数据存储的显式引用。我们在下一个代码示例中展示了另一种方法,即从 YANG 子树中检索一部分,并从中提取相关的状态信息。

值得注意的是,OpenAPI 对其模型的结构和组成要求不那么严格,状态可能来自树的不同部分,或者根据实现需要特定的查询参数来引用操作数据存储。

操作状态处理

配置管理工作流程通常涉及处理一些输入数据以生成特定于设备的配置。这是一个常见的流程,我们经常用它来展示 API 的功能。但还有一个同样重要的流程,涉及操作员从网络设备检索状态数据,然后处理和验证这些数据。在这种情况下,信息流的方向是从网络设备到客户端应用程序。

在本章的开头,我们讨论了配置管理工作流程,因此现在我们想要提供一个关于状态检索工作流程的高级概述:

  1. 我们首先查询一个远程 API 端点,该端点由一组 URL 和 HTTP 查询参数表示。

  2. 我们收到一个带有附加二进制有效载荷的 HTTP 响应。

  3. 我们将这个有效载荷反序列化到一个遵循设备数据模型的 Go 结构体中。

  4. 在这个结构体内部,我们查看可以提取和评估的状态的相关部分。

以下是从ch08/state程序(参考进一步阅读部分)中的代码片段,这是该工作流程的具体示例。程序结构遵循我们在第六章状态验证部分中描述的相同模式,即配置管理。因此,在本章中,我们只聚焦于最相关的部分——GetRoutes函数,该函数连接到ceos设备并检索其路由表的内容。

它首先使用设备特定的登录信息构建一个 HTTP 请求:

func (r CEOS) GetRoutes(wg *sync.WaitGroup) {
  client := resty.NewWithClient(&http.Client{
    Transport: &http.Transport{
      TLSClientConfig: &tls.Config{
        InsecureSkipVerify: true},
    },
  })
  client.SetBaseURL("https://" + r.Hostname + ":6020")
  client.SetBasicAuth(r.Username, r.Password)
  resp, err := client.R().
    SetHeader("Accept", "application/yang-data+json").
    Get(fmt.Sprintf("/restconf/data/network-instances/network-instance=%s/afts", "default"))
  /* ... <continues next > ... */
}

代码示例中的抽象转发表AFT)是 FIB(路由)表的 OpenConfig 表示,GET API 调用检索默认虚拟路由和转发VRF)路由表的 JSON 表示。

接下来,我们创建一个与查询的 YANG 树部分相对应的 Go 结构体实例,并将其传递给Unmarshal函数进行反序列化。结果 Go 结构体现在为默认 FIB 中的每个条目都有一个Ipv4Entry值,我们将这些前缀列表存储在out切片中:

import eosAPI "restconf/pkg/eos"
func (r CEOS) GetRoutes(wg *sync.WaitGroup) {
  /* ... <continues from before > ... */
  response := &eosAPI.NetworkInstance_Afts{}
  err := eosAPI.Unmarshal(resp.Body(), response)
  // process error
  out := []string{}
  for key := range response.Ipv4Entry {
    out = append(out, key)
  }
  /* ... <omitted for brevity > ... */
  go checkRoutes(r.Hostname, out, expectedRoutes, wg)
}

在这个例子中,我们导入了在本章RESTCONF部分自动生成的eos包(restconf/pkg/eos),它位于本程序根目录之外。为此,我们在本程序的go.mod文件(ch08/state/go.mod;参考进一步阅读部分)中添加了replace restconf => ../restconf/指令。

对于剩余的实验室设备,我们遵循类似的状态检索工作流程。唯一的区别在于我们用于反序列化的 YANG 路径和基于模型的 Go 结构体。您可以在本书 GitHub 仓库的ch08/state目录(参考进一步阅读部分)中找到完整的程序代码。

在本章中,我们介绍了基于 HTTP 版本 1.1 的网络 API,这些 API 使用常见的编码格式,如 JSON。尽管 HTTP 仍然非常流行,并且这种情况不太可能很快改变,但它有其自身的局限性,这些局限性可能在大型部署中显现出来。HTTP 1.1 是一种基于文本的协议,这意味着它在网络上的效率不高,其客户端-服务器起源使其难以适应双向流。该协议的下一个版本 HTTP/2,克服了这些缺点。HTTP/2 是 gRPC 框架的传输协议,我们将在下一节中对其进行研究。

gRPC

网络自动化打开了一扇直到最近似乎关闭或至少阻止网络工程师重用在其他领域取得成功的技术的门,例如微服务或云基础设施。

网络设备管理中最新的进展之一是引入了 gRPC。我们可以使用这个高性能 RPC 框架进行各种网络操作,从配置管理到状态流和软件管理。但性能并不是 gRPC 吸引人的唯一因素。就像 YANG 和 OpenAPI 应用程序一样,gRPC 在不同的编程语言中自动生成客户端和服务器存根,这使得我们能够围绕 API 创建一个工具生态系统。

在本节中,我们将介绍以下主题,以帮助您更好地理解 gRPC API:

  • Protobuf

  • gRPC 传输

  • 定义 gRPC 服务

  • 使用 gRPC 配置网络设备

  • 使用 gRPC 从网络设备中流式传输遥测数据

Protobuf

gRPC 使用 protobuf 作为其 接口定义语言 (IDL),允许您在可能用不同编程语言编写的远程软件组件之间共享结构化数据。

在使用 protobuf 时,第一步是创建一个 protobuf 文件来建模您要序列化的信息。此文件包含一个 消息 列表,定义了交换数据的结构和类型。

如果以本书中一直使用的输入数据模型为例,并将其编码在 .proto 文件中,它看起来可能像这样:

message Router {
  repeated Uplink uplinks = 1;
  repeated Peer peers = 2;
  int32 asn = 3;
  Addr loopback = 4; 
}
message Uplink {
    string name = 1;
    string prefix = 2;
}
message Peer {
    string ip = 1;
    int32 asn = 2;
}
message Addr {
  string ip = 1;
}

每个字段都有一个显式的类型和一个唯一的序列号,用于在包含的消息中标识它。

在工作流程的下一步,就像 OpenAPI 或 YANG 一样,是为 Go(或任何其他编程语言)生成绑定。为此,我们使用 protobuf 编译器 protoc,它生成包含数据结构和访问和验证不同字段的方法的源代码:

ch08/protobuf$ protoc --go_out=. model.proto

上述命令将绑定保存在单个文件中,pb/model.pb.go。您可以查看此文件的 内容,以了解您可以使用哪些结构和函数。例如,我们自动获得这个 Router 结构体,这是我们之前必须手动定义的:

type Router struct {
  Uplinks  []*Uplink 
  Peers    []*Peer   
  Asn      int32     
  Loopback *Addr
}

Protobuf 以类似于路由协议编码 类型-长度-值TLVs)的二进制格式编码一系列键值对。但它不是为每个字段发送键名和声明的类型,而是只发送字段编号作为键,并将其值附加到字节流的末尾。

与 TLVs 一样,Protobuf 需要知道每个值的长度才能成功编码和解码消息。为此,Protobuf 在 8 位键字段中编码了一个线类型,以及来自 .proto 文件的字段编号。以下表格显示了可用的线类型:

类型 含义 用途
0 可变长整型 int32、int64、uint32、uint64、sint32、sint64、bool、枚举
1 64 位 fixed64、sfixed64、double
2 长度限定 字符串、字节、嵌入的消息、打包的重复字段
5 32 位 fixed32、sfixed32、float

表 8.1 – Protobuf 线类型

这生成了一个密集的消息(输出小),CPU 可以比 JSON 或 XML 编码的消息更快地处理。缺点是生成的消息在其原生格式下不可读,并且只有当您有消息定义(proto 文件)以找出每个字段的名称和类型时才有意义。

线路上的 Protobuf

要查看 protobuf 在二进制格式中的样子,最简单的方法是将它保存到一个文件中。在我们的 GitHub 仓库中,我们在 ch08/protobuf/write 目录中有一个示例(参考 进一步阅读 部分),该示例读取一个示例 input.yaml 文件,并填充从我们之前讨论的 .proto 文件生成的数据结构。然后我们序列化并将结果保存到我们命名为 router.data 的文件中。您可以使用以下命令执行此示例:

ch08/protobuf/write$ go run protobuf

您可以通过使用 hexdump -C router.data 命令查看生成的 protobuf 消息的内容。如果我们为了方便将一些字节分组并参考 proto 定义文件,我们可以理解这些数据,如下所示:

图 8.4 – Protobuf 编码的消息

图 8.4 – Protobuf 编码的消息

为了让您了解 protobuf 编码的效率,我们包含了一些编码相同数据的 JSON 文件。router.json 文件是一个紧凑的(无空格)JSON 编码。第二个版本,称为 router_ident.json,具有相同的 JSON 有效负载,但缩进并添加了额外的空格,这可能会在从文本模板生成 JSON 或在网络传输数据之前使用 pretty print 函数时发生:

ch08/protobuf$ ls -ls router* | awk '{print $6, $10}'
108 router.data
454 router_indent.json
220 router.json

JSON 和 protobuf 之间的差异非常明显,并且在传输和编码/解码大型数据集时可能会变得非常重要。

现在我们已经了解了一些关于 gRPC 数据编码的基础知识,我们可以继续了解用于传输这些消息的协议。

gRPC 传输

除了高效的二进制编码和允许更简单的帧来序列化你的数据——与换行符分隔的纯文本相比——gRPC 框架还试图尽可能高效地在网络上交换这些消息。

虽然你一次只能处理一个请求/响应消息的 HTTP/1.1,但 gRPC 利用 HTTP/2 在相同的 TCP 连接上多路复用并行请求。HTTP/2 的另一个好处是它支持头部压缩。表 8.2显示了不同 API 使用的各种传输方法:

API 传输 RPC/方法
NETCONF SSH get-config,edit-config,commit,lock
RESTCONF HTTP GET,POST,DELETE,PUT
gRPC HTTP/2 单一请求,服务器流式传输,客户端流式传输,双向流式传输

表 8.2 – API 比较表

与较老的网络 API 相比,gRPC 不仅允许你进行单一或单个请求,还支持全双工流式传输。客户端和服务器可以同时流式传输数据,因此你不再需要绕过传统客户端-服务器交互模式的限制。

定义 gRPC 服务

gRPC 使用 Protobuf 在文件中定义静态类型的服务和消息,我们可以使用它来生成客户端和服务器应用程序的代码。gRPC 抽象了底层的传输和序列化细节,使开发者能够专注于其应用程序的业务逻辑。

一个 gRPC 服务是一组接受和返回 protobuf 消息的 RPC。在以下输出中,你可以看到 Cisco IOS XR 的 proto 文件ems_grpc.proto的片段(参见进一步阅读部分)。该文件定义了一个名为gRPCConfigOper的 gRPC 服务,具有几个 RPC 来执行一组标准的配置管理操作:

syntax = "proto3";
service gRPCConfigOper {
  rpc GetConfig(ConfigGetArgs) returns(stream ConfigGetReply) {};

  rpc MergeConfig(ConfigArgs) returns(ConfigReply) {};

  rpc DeleteConfig(ConfigArgs) returns(ConfigReply) {};

  rpc ReplaceConfig(ConfigArgs) returns(ConfigReply) {};
  /* ... <omitted for brevity > ... */
  rpc CreateSubs(CreateSubsArgs) returns(stream CreateSubsReply) {};
}

除了配置管理操作之外,这个 Cisco IOS XR protobuf 定义还包括一个流式遥测订阅(CreateSubs)RPC。请求和响应的消息格式也是ems_grpc.proto文件的一部分(参见进一步阅读部分)。例如,要调用遥测订阅 RPC,客户端必须发送一个ConfigArgs消息,服务器(路由器)应该回复一系列CreateSubsReply消息。

与 NETCONF 不同,其中telemetry.proto(参见进一步阅读部分):

syntax = "proto3";
service OpenConfigTelemetry {
  rpc telemetrySubscribe(SubscriptionRequest) returns (stream OpenConfigData) {}
  /* ... <omitted for brevity > ... */
  rpc getTelemetryOperationalState(GetOperationalStateRequest) returns(GetOperationalStateReply) {}
  rpc getDataEncodings(DataEncodingRequest) returns (DataEncodingReply) {}
}

这正是 OpenConfig 社区通过定义供应商无关的服务来解决的问题,例如 gNMI(gnmi.proto;参见进一步阅读部分),我们将在下一章中探讨:

service gNMI {
  rpc Capabilities(CapabilityRequest) returns (CapabilityResponse);
  rpc Get(GetRequest) returns (GetResponse);
  rpc Set(SetRequest) returns (SetResponse);
  rpc Subscribe(stream SubscribeRequest) returns (stream SubscribeResponse);
}

现在,让我们看看如何使用 Go 来使用这些 RPC。

使用 gRPC 配置网络设备

在我们的示例程序中,我们使用名为gRPCConfigOper的服务中的ReplaceConfig RPC 配置一个 IOS XR 设备。你可以在这个书的 GitHub 仓库的ch08/grpc目录中找到这个程序的完整源代码(参考进一步阅读部分)。你可以使用以下命令在 Cisco 的 DevNet 沙盒中对测试设备执行此程序:

ch08/grpc$ go run grpc

按照本章中使用的相同的配置管理工作流程,我们将首先为以下 gRPC 服务生成代码:

service gRPCConfigOper { 
  rpc ReplaceConfig(ConfigArgs) returns(ConfigReply) {};
}
message ConfigArgs {
  int64 ReqId = 1;
  string yangjson = 2;
  bool   Confirmed = 3;
  uint32  ConfirmTimeout = 4;
}

当与基于 gRPC 的网络 API 一起工作时,有一件事需要记住,它们可能不会原生地以 protobuf 模式定义完整的数据树。在先前的示例中,一个字段定义了一个名为yangjson的字符串,它期望一个基于 YANG 的 JSON 有效负载,并没有进一步探索那个“字符串”里面可能是什么。携带基于 YANG 的 JSON 有效负载是我们也在 JSON-RPC 和 RESTCONF 示例中做的事情。在某种意义上,gRPC 在这个示例中充当了一个薄的 RPC 包装器,与 JSON-RPC 没有太大区别。我们仍然在使用基于 YANG 的数据结构进行配置管理的工作。

由于我们现在同时使用 gRPC 和 YANG 模式,我们必须使用protocygot一起生成它们各自的绑定。我们运行protoc命令从ch08/grpc/proto(参考进一步阅读部分)中的 proto 定义生成代码,并使用ygot从一组 OpenConfig YANG 模型生成代码。你可以在ch08/grpc/generate_code文件中找到确切的命令集(参考进一步 阅读部分)。

在我们能够连接到目标设备之前,我们需要收集运行程序所需的所有信息,因此我们重用第六章配置管理部分的数据结构来存储这些数据:

type Authentication struct {
  Username string
  Password string
}
type IOSXR struct {
  Hostname string
  Authentication
}
type xrgrpc struct {
  IOSXR
  conn *grpc.ClientConn
  ctx  context.Context
}

我们通过填充访问凭证和处理设备配置输入来启动程序的main函数,就像书中其他示例中一样:

func main() {
  iosxr := xrgrpc{
    IOSXR: IOSXR{
      Hostname: "sandbox-iosxr-1.cisco.com",
      Authentication: Authentication{
        Username: "admin",
        Password: "C1sco12345",
      },
    },
  }
  src, err := os.Open("input.yml")
  // process error
  defer src.Close()
  d := yaml.NewDecoder(src)
  var input Model
  err = d.Decode(&input)
  /* ... <continues next > ... */
}

接下来,我们使用来自grpc/pkg/oc包的ygot Go 绑定来准备yangjson有效负载。我们以与本章JSON-RPC部分中展示的相同方式在buildNetworkInstance方法中构建 BGP 配置。一旦oc.Device结构体完全填充,我们就将其序列化为 JSON 字符串:

func main() {
  /* ... <continues from before > ... */
  device := &oc.Device{}
  input.buildNetworkInstance(device)
  payload, err := ygot.EmitJSON(device,
  &ygot.EmitJSONConfig{
    Format: ygot.RFC7951,
    Indent: "  ",
    RFC7951Config: &ygot.RFC7951JSONConfig{
      AppendModuleName: true,
    },
  })
  /* ... <continues next > ... */
}

为了简化与目标设备的交互,我们围绕 gRPC API 创建了一个薄的包装器。我们为xrgrpc类型定义了一些方法接收器,实现了诸如初始连接建立和删除或替换 RPC 等功能。这就是我们连接和替换目标设备配置的方式:

func main() {
  /* ... <continues from before > ... */
  iosxr.Connect()
  defer router.conn.Close()
  iosxr.ReplaceConfig(payload)
  /* ... <continues next > ... */ 
}

仔细查看ReplaceConfig方法,我们可以看到如何调用所需的 RPC。我们动态生成一个随机 ID,并用我们在几步之前用ygot生成的基于 YANG 的 JSON 有效负载填充ConfigArg消息。内部的ReplaceConfig方法是protoc命令为我们自动生成的:

func (x *xrgrpc) ReplaceConfig(json string) error {
  // Random int64 for id
  id := rand.Int63()
  // 'g' is the gRPC stub.
  g := xr.NewGRPCConfigOperClient(x.conn)
  // We send 'a' to the router via the stub.
  a := xr.ConfigArgs{ReqId: id, Yangjson: json}
  // 'r' is the result that comes back from the target.
  r, err := g.ReplaceConfig(x.ctx, &a)
  // process error
  return nil
}

在这种情况下,我们发送的配置有效负载是一个字符串 blob,但如果我们目标设备支持,我们也可以使用 protobuf 对内容字段进行编码。这就是我们将通过流式遥测示例来检查的内容。

使用 gRPC 从网络设备流式传输遥测数据

gRPC 流式传输功能允许网络设备通过持久的 TCP 连接连续(流式)或按需(轮询)发送数据。我们将继续使用我们之前开始的相同程序,并重用我们设置的相同连接来配置一个网络设备以订阅遥测流。

尽管我们初始化了与 Cisco IOS XR 设备的连接,但数据现在流向相反的方向。这意味着我们需要能够解码我们接收到的信息,并且有两种不同的方法来做这件事。

一旦我们配置了设备,我们就请求它流式传输所有 BGP 邻居的操作状态。在第一种场景中,我们将讨论你拥有 BGP 邻居协议定义以解码你收到的消息的情况。然后,我们将检查一个不太高效的选项,其中不需要协议定义。

使用 Protobuf 解码 YANG 定义的数据

我们使用CreateSubs RPC 来订阅遥测流。我们需要提交我们想要流式传输的订阅 ID,并在gpb(用于 protobuf)或gpbkv(我们将在本章末尾探讨的选项)之间选择一个编码选项。以下输出显示了此 RPC 及其消息类型的协议定义:

service gRPCConfigOper { 
  rpc CreateSubs(CreateSubsArgs) returns(stream CreateSubsReply) {};
}
message CreateSubsArgs {
  int64 ReqId = 1;
  int64 encode = 2;
  string subidstr = 3;
  QOSMarking qos = 4;
  repeated string Subscriptions = 5;
}
message CreateSubsReply {
  int64 ResReqId = 1;
  bytes data = 2;
  string errors = 3;
}

与程序配置部分类似,我们创建一个辅助函数来提交对路由器的请求。主要区别在于现在回复是一个数据流。我们将CreateSubs的结果存储在一个我们称为st的变量中。

对于数据流,gRPC 为我们提供了Recv方法,该方法会阻塞,直到它收到一条消息。为了在主线程中继续处理,我们在一个单独的 goroutine 中运行一个匿名函数,该函数调用自动生成的GetData方法。此方法返回我们收到的每条消息的data字段,并将其通过一个通道(b)发送回主 goroutine:

func (x *xrgrpc) GetSubscription(sub, enc string) (chan []byte, chan error, error) {
  /* ... <omitted for brevity > ... */

  // 'c' is the gRPC stub.
  c := xr.NewGRPCConfigOperClient(x.conn)
  // 'b' is the bytes channel where telemetry is sent.
  b := make(chan []byte)
  a := xr.CreateSubsArgs{
        ReqId: id, Encode: encoding, Subidstr: sub}
  // 'r' is the result that comes back from the target.
  st, err := c.CreateSubs(x.ctx, &a)
  // process error
  go func() {
    r, err := st.Recv()
    /* ... <omitted for brevity > ... */
    for {
      select {
      /* ... <omitted for brevity > ... */
      case b <- r.GetData():
      /* ... <omitted for brevity > ... */
      }
    }
  }()
  return b, e, err
}

data字段,以及我们在通道b中接收到的数据,由我们需要解码的字节数组组成。我们知道这是一个流式遥测消息,因此我们使用其协议生成的代码来解码其字段。图 8**.5展示了我们如何通过遵循协议文件定义来获取 BGP 状态信息的一个示例:

图 8.5 – Protobuf 遥测消息(protobuf)

图 8.5 – Protobuf 遥测消息(protobuf)

在主 goroutine 中,我们监听GetSubscription通道返回的内容,并对我们收到的每个消息进行迭代。我们将接收到的数据解包到Telemetry消息中。在这个时候,我们可以访问一般遥测数据,因此我们可以使用自动生成的函数来访问一些字段,例如时间戳和编码路径:

func main() {
  /* ... <omitted for brevity > ... */
  ch, errCh, err := router.GetSubscription("BGP", "gpb")
  // process error

  for msg := range ch {
    message := new(telemetry.Telemetry)
    proto.Unmarshal(msg, message)

    t := time.UnixMilli(int64(message.GetMsgTimestamp()))
    fmt.Printf(
      "Time: %v\nPath: %v\n\n",
      t.Format(time.ANSIC),
      message.GetEncodingPath(),
    )
    /* ... <continues next > ... */
  }
}

随后,我们提取data_bgp字段的内容以访问使用 protobuf 编码的 BGP 数据。Cisco IOS XR 按行列出项目,因此对于每一个,我们将内容解包到自动生成的BgpNbrBag数据结构中,从这里我们可以访问 BGP 邻居的所有操作信息。这样,我们获取了 BGP 对等方的连接状态和 IPv4 地址,并将其打印到屏幕上:

func main() {
  for msg := range ch {
    /* ... <continues from before > ... */  
    for _, row := range message.GetDataGpb().GetRow() {
      content := row.GetContent()
      nbr := new(bgp.BgpNbrBag)
      err = proto.Unmarshal(content, nbr)
      if err != nil {
        fmt.Printf("could decode Content: %v\n", err)
        return
      }
      state := nbr.GetConnectionState()
      addr := nbr.GetConnectionRemoteAddress().Ipv4Address
      fmt.Println("  Neighbor: ", addr)
      fmt.Println("  Connection state: ", state)
    }
  }
}

如果你无法访问 BGP 消息定义(proto 文件),gRPC 仍然可以使用 protobuf 表示字段,但必须为每个字段添加名称和值类型,以便接收端可以解析它们。这就是我们接下来要检查的内容。

Protobuf 自描述消息

虽然自描述消息在某种程度上抵消了 protobuf 的作用,因为它发送了不必要的数据,但我们在这里提供了一个示例,以对比在这种情况下如何解析消息:

图 8.6 – Protobuf 自描述遥测消息(JSON)

图 8.6 – Protobuf 自描述遥测消息(JSON)

遥测头是相同的,但是当你选择gpbkv作为编码格式时,Cisco IOS XR 会在data_bgpkv字段中发送数据:

func main() {
  for msg := range ch {
    message := new(telemetry.Telemetry)
    err := proto.Unmarshal(msg, message)
    /* ... <omitted for brevity > ... */
    b, err := json.Marshal(message.GetDataGpbkv())
    check(err)
    j := string(b)
    // https://go.dev/play/p/uyWenG-1Keu
    data := gjson.Get(
      j,
      "0.fields.0.fields.#(name==neighbor-address).ValueByType.StringValue",
    )
    fmt.Println("  Neighbor: ", data)
    data = gjson.Get(
      j,
      "0.fields.1.fields.#(name==connection-state).ValueByType.StringValue",
    )
    fmt.Println("  Connection state: ", data)
  }
}

到目前为止,你有一个大型的 JSON 文件,你可以使用你偏好的 Go 包来导航。在这里,我们使用了gjson。为了测试这个程序,你可以重新运行我们之前描述的相同程序,并添加一个额外的标志来启用自描述的键值消息:

ch08/grpc$ go run grpc -kvmode=true

虽然这种方法可能看起来不那么复杂,但你不仅会牺牲性能优势,而且由于事先不知道 Go 数据结构,它为错误和打字错误打开了空间,它阻止了你利用大多数 IDE 的自动完成功能,并且使你的代码不那么明确。所有这些都对代码开发和故障排除产生了负面影响。

摘要

在本章中,我们探讨了使用 API 和 RPC 与网络设备交互的不同方法。贯穿本章的一个共同主题是对于任何交换的数据都有一个模型。尽管网络社区已经接受 YANG 作为建模网络配置和操作状态数据的标准语言,但不同网络供应商之间的实现差异仍然阻碍了其广泛采用。

在下一章中,我们将探讨 OpenConfig 如何通过定义一组供应商中立的模型和协议来尝试增加声明性配置和模型驱动管理及操作的采用。

进一步阅读

第九章:OpenConfig

OpenConfig 是由一群网络运营商组成的团体(参见进一步阅读部分),他们有一个共同的目标,即简化我们管理和操作网络的方式。他们欢迎任何运营生产网络的成员加入,并且最近开始接受来自多个厂商的贡献,当这些厂商实现相同的功能(他们希望将其包含在 YANG 模型中)时。

他们的初始重点是创建一套基于现场常见操作用例和要求的厂商中立 YANG 数据模型。这后来扩展到包括用于在网络上配置、流式遥测、执行操作命令和操作转发条目的厂商中立远程过程调用RPCs)(参见进一步阅读)。在本章中,我们将主要关注 OpenConfig RPCs,因为我们已经在第八章网络 API中介绍了 YANG 数据模型。

与其他类似倡议相比,OpenConfig 的一个独特之处在于,它们不仅公开工作在规范上,还编写了实现这些规范的开放源代码,帮助您与符合 OpenConfig 的设备交互。他们大多数项目都是用 Go 编写的,包括但不限于 ygot、gNxI 工具、gNMI 收集器、gNMI CLI 实用程序、gNMI 测试框架、gRPC 隧道和 IS-IS LSDB 解析(参见进一步阅读)。我们鼓励您探索这些项目,特别是我们在这本书中没有涵盖的项目,因为它们针对广泛的网络相关应用。

在撰写本文时,OpenConfig 包括四个 gRPC 服务:

  • gRPC 网络管理接口gNMI):用于流式遥测和配置管理

  • gRPC 网络操作接口gNOI):用于在网络上执行操作命令

  • gRPC 路由信息库接口gRIBI):允许外部客户端在网络元素上注入路由条目

  • gRPC 网络安全接口gNSI):用于保护访问符合网络设备的底层基础设施服务

在以下章节中,我们将检查以下常见的操作任务:

  • 设备配置,使用 gNMI 的Set RPC,在实验室拓扑结构中的两个节点之间正确标记主备接口

  • 使用Subscribe RPC 进行流式遥测,其中 Go 程序对 gNMI 遥测流做出反应,以对网络进行更改

  • 网络操作,使用 gNOI 的Traceroute RPC 的traceroute示例,以检查网络中的所有转发路径是否按预期工作

技术要求

您可以在本书的 GitHub 存储库中找到本章的代码示例(参见进一步阅读),在ch09文件夹中。

重要提示

我们建议您在虚拟实验室环境中执行本章中的 Go 程序。请参阅附录以获取先决条件和构建完全配置的网络拓扑结构的说明。

在下一节中,我们将讨论的第一个示例是使用 Go 来配置网络设备 gNMI。

设备配置

第六章 配置管理中,我们讨论了在网络上应用所需的配置状态。网络工程师通常需要登录到网络设备以提供新服务、建立新连接或删除过时的配置。我们在同一章节中介绍了配置网络设备(如 SSH 或 HTTP)的不同传输选项,并在第八章 网络 API中添加了 gRPC 作为另一个选项。

我们简要介绍了使用数据建模语言(如 YANG)来建模网络设备配置,这样我们就可以从使用半结构化供应商特定 CLI 语法配置网络,转变为与网络交换结构化数据以改变其配置状态的模式。

OpenConfig 定义了一个专门用于配置管理的 gRPC 服务,称为 gNMI。它的目标是提供一个任何供应商都可以实现的通用 gRPC protobuf 定义,同时保留他们现有的专有 gRPC 服务。

gNMI 的 protobuf 定义如下:

service gNMI {
   rpc Capabilities(CapabilityRequest) returns (CapabilityResponse);
   rpc Get(GetRequest) returns (GetResponse);
   rpc Set(SetRequest) returns (SetResponse);
   rpc Subscribe(stream SubscribeRequest) returns (stream SubscribeResponse);
}

gNMI 特别通过Set RPC 提供配置管理功能,您可以使用它来对目标节点进行更改。gNMI 规范(见进一步阅读)对所有可用的 gNMI RPCs 有详细的文档。在本节中,我们将重点关注Set

Set RPC

Set RPC 允许您更改目标网络设备的状态。您通过发送一个编码了您想要进行的所有更改的SetRequest消息来实现这一点。

您可以使用SetRequest消息的专用字段在目标设备的单个事务中更新、替换或删除数据树中的值。这意味着除非目标可以应用所有指定的更改,否则它必须全部回滚并返回到其先前状态。以下 protobuf 定义显示了您在SetRequest消息中可用的选项:

message SetRequest {
   Path prefix = 1;
   repeated Path delete = 2;
   repeated Update replace = 3;
   repeated Update update = 4;
   repeated gnmi_ext.Extension extension = 5;
}

SetRequest中称为Path的字段编码了一个 YANG 数据树路径。值得注意的是,gNMI 不仅限于使用 OpenConfig YANG 模型;它同样适用于供应商定义的 YANG 模型。gNMI 将数据树路径描述为一系列PathElem(路径元素)。每一个都是具有名称的数据树节点,并且可能与之关联一个或多个属性(键):

message Path {
  string origin = 2;
  repeated PathElem elem = 3;
  string target = 4;
}
message PathElem {
  string name = 1;
  map<string, string> key = 2;
}

例如,/interfaces/interface[name=Ethernet2]/config/description 路径允许您在目标设备上的 Ethernet2 接口上设置描述。在这种情况下,唯一具有属性的节点是 interface,它需要一个 name。要配置该接口中本地 VLAN 的 IPv4 地址,您可以使用如下路径:/interfaces/interface[name=Ethernet2]/subinterfaces/subinterface[index=0]/ipv4/addresses/address[ip=192.0.2.2]。在这种情况下,您需要添加 subinterface 索引,因为接口可能在不同子接口上有 IP 地址。

一旦您已识别数据路径,您需要构建包含您要在目标设备上设置的新值的内容,这是一个 YANG 模式的数据实例。您只需要 replaceupdate。对于 delete,路径就足够告诉目标设备从配置中删除什么。

用于发送 replaceupdate 的值的 Update 消息包含一个 PathTypedValue 对。后者允许您以不同的格式编码内容:

message Update {
  Path path = 1;
  TypedValue val = 3;
  uint32 duplicates = 4;
}
message TypedValue {
  oneof value {
    string string_val = 1;
    int64 int_val = 2;
    uint64 uint_val = 3;
    bool bool_val = 4;
    bytes bytes_val = 5;
    double double_val = 14;
    ScalarArray leaflist_val = 8;
    google.protobuf.Any any_val = 9;
    bytes json_val = 10;
    bytes json_ietf_val = 11;
    string ascii_val = 12;
    bytes proto_bytes = 13;
  }
}

值可以是字符串,例如接口描述 PRIMARY: TO -> CVX:swp1,或者 JSON 值,用于描述接口的 IPv4 地址,例如 {"config":{"ip":"192.0.2.2","prefix-length":31}}

使用 gNMI 配置网络接口

本章的虚拟实验室拓扑,您可以通过从本书 GitHub 仓库的根目录运行 make lab-full 来启动它,其中 ceoscvx 之间有两个连接。它们已经配置了 IPv4 地址,但没有描述来让您识别这些接口的角色,即它们是主链路还是备份链路:

图 9.1 – ceos 和 cvx 之间的双链路

图 9.1 – ceos 和 cvx 之间的双链路

在下一个示例中,我们通过 gNMI 在 ceos 端的这些接口上添加描述。为此,我们使用 gNMIc 包(karimra/gnmic/api)。我们选择 gNMIc 而不是官方的 gNMI 包(openconfig/gnmi),因为它对开发者更友好,并且是更高层次的。它允许我们方便地将 gNMI 路径编码为字符串,而不是 Go 数据结构,正如 gNMIc 文档(见 进一步阅读)所述。您可以在本书 GitHub 仓库的 ch09/gnmi 目录中找到此示例的代码(见 进一步阅读)。

gNMIc 包含一个 NewTarget 函数,用于创建新的 gNMI 目标设备。在下面的示例中,我们将此函数封装在 createTarget 方法中:

func (r Router) createTarget() (*target.Target, error) {
      return api.NewTarget(
           api.Name("gnmi"),
           api.Address(r.Hostname+":"+r.Port),
           api.Username(r.Username),
           api.Password(r.Password),
           api.Insecure(r.Insecure),
      )
}

代码的第一步是从 YAML 文件(input.yml)中读取连接详情以创建此目标设备:

# input.yml
- hostname: clab-netgo-ceos
  port: 6030
  insecure: true
  username: admin
  password: admin

我们将所有目标设备存储在Routers数据结构中。在我们的案例中,我们只有一个设备(clab-netgo-ceos),但连接细节是一个列表,所以如果我们想的话,可以添加更多设备。现在,有了目标数据,我们使用CreateGNMIClient方法设置到目标设备(clab-netgo-ceos:6030)的底层 gRPC 连接:

func main() {
  /* ... <omitted for brevity > ... */
  for _, router := range inv.Routers {
    tg, err := router.createTarget()
    // process error

    ctx, cancel := context.WithCancel(
    context.Background())
    defer cancel()

    err = tg.CreateGNMIClient(ctx)
    // process error
    defer tg.Close()
  /* ... <continues next > ... */
}

连接建立后,我们现在可以发送Set请求。另一个 YAML 文件(api-ceos.yml)包含每个请求的参数列表:prefixencodingpathvalue。当你想要缩短路径长度时,可以添加prefix。在我们的 Go 程序中,我们将这个参数列表保存到info切片中:

# api-ceos.yml
- prefix: "/interfaces/interface[name=Ethernet2]"
  encoding: "json_ietf"
  path: '/subinterfaces/subinterface[index=0]/ipv4/addresses/address[ip=192.0.2.2]'
  value: '{"config":{"ip":"192.0.2.2","prefix-length":31}}'
- prefix: ""
  encoding: "json_ietf"
  path: '/interfaces/interface[name=Ethernet2]/config/description'
  value: 'PRIMARY: TO -> CVX:swp1''
## ... <omitted for brevity > ... ##

最后一步是遍历info切片,使用NewSetRequest函数构建一个Set请求,并使用Set方法将其发送到目标设备:

func main() {
  /* ... <continues from before > ... */
    for _, data := range info {
      setReq, err := api.NewSetRequest(
              api.Update(
                    api.Path(data.Prefix+data.Path),
                    api.Value(data.Value, data.Encoding)),
      )
      // process error

      configResp, err := tg.Set(ctx, setReq)
      // process error
      fmt.Println(prototext.Format(configResp))
    }
  }
}

在这里,NewSetRequest只有一个Update消息,但你可以在单个请求中包含多个消息。

运行此示例时,你会得到以下输出:

ch09/gnmi$ go run main.go 
response: {
  path: {
    elem: {
      name: "interfaces"
    }
    elem: {
      name: "interface"
      key: {
        key: "name"
        value: "Ethernet2"
      }
    }
    elem: {
      name: "subinterfaces"
    }
    elem: {
      name: "subinterface"
      key: {
        key: "index"
        value: "0"
      }
    }
    elem: {
      name: "ipv4"
    }
    elem: {
      name: "addresses"
    }
    elem: {
      name: "address"
      key: {
        key: "ip"
        value: "192.0.2.2"
      }
    }
  }
  op: UPDATE
}
timestamp: 1660148355191641746
response: {
  path: {
    elem: {
      name: "interfaces"
    }
    elem: {
      name: "interface"
      key: {
        key: "name"
        value: "Ethernet2"
      }
    }
    elem: {
      name: "config"
    }
    elem: {
      name: "description"
    }
  }
  op: UPDATE
}
timestamp: 1660148355192866023
## ... <omitted for brevity > ... ##

你在终端屏幕上看到的是SetResponse消息,包含操作的pathresponsetimestamp值:

message SetResponse {
  Path prefix = 1;
  repeated UpdateResult response = 2;
  int64 timestamp = 4;
  repeated gnmi_ext.Extension extension = 5;
}

如果你现在连接到ceos设备,你将看到其运行配置中的以下内容:

interface Ethernet2
   description PRIMARY: TO -> CVX:swp1
   no switchport
   ip address 192.0.2.2/31
!
interface Ethernet3
   description BACKUP: TO -> CVX:swp2
   no switchport
   ip address 192.0.2.4/31
!

配置网络设备是大多数网络工程师花费大量时间进行的一些重复性任务之一,因此自动化此过程具有很好的投资回报潜力。

OpenConfig 工作组多年的工作,该工作组发布了官方的 gNMI 包(openconfig/gnmi),为其他开源包和库(如 gNMIc (karimra/gnmic) 和 pyGNMI (akarneliuk/pygnmi))的出现设定了路径,围绕这些供应商中立的 gRPC 服务建立社区,以推动我们网络中一致的自动化实践。

在下一节中,我们将介绍另一个增强网络可见性能力的 OpenConfig gRPC 服务。

流式遥测

传统上,网络工程师依赖于简单网络管理协议SNMP)从网络设备收集状态信息。设备使用抽象语法表示法一ASN.1)的二进制格式编码此信息,并将其发送到接收器,通常是收集器或网络管理系统NMS)。后者会使用管理信息库MIBs)之一来解码接收到的信息并将其存储在本地以供进一步处理。

这是我们几十年来进行网络监控的方式,但这种方法仍有改进的空间:

  • 有限数量的供应商中立数据模型意味着即使是基本的东西也需要独特的 MIBs,你可能需要每次进行主要网络操作系统升级时都更新它们。

  • MIBs 使用 ASN.1 子集定义的符号,这不是结构值的最佳方式。它没有列表或键值对的概念。相反,您必须使用索引值和额外的查找表来实现这些。

  • SNMP 使用 UDP 作为其传输协议,以避免给收集器带来额外负担。这意味着您可能会完全错过一些事件,在遥测数据流中留下盲点。

  • 由于 SNMP 主要依赖于轮询,我们只能看到聚合值,可能会错过重要的状态转换。

  • SNMP 通常不记录值变化的时间戳。收集者只能根据收集时间推断时间。

gNMI 通过一个专门的Subscribe RPC 提供了一种新的网络监控方法。至少,它提供了与 SNMP 相同的性能,但更进一步,使协议更加功能丰富和灵活:

  • 最大的改进之一是遥测流。现在,您可以连续接收来自网络设备的操作 YANG 树中的任何值,这使您能够更好地了解所有状态转换及其时间戳。

  • 您可以选择只在有变化时接收遥测数据,而不是周期性传输。

  • 多亏了底层的 gRPC 传输,gNMI 支持拨入和拨出连接方法,并使用可靠的 HTTP/2 协议传递消息。

  • OpenConfig 定义了描述网络设备操作状态的供应商中立 YANG 模型,这使得客户端能够以标准管道解析和处理来自不同供应商的接收数据。

重要提示

即使有流式遥测,也不一定为每个计数器增量提供更新。网络设备有本地进程,它们定期轮询内部数据存储以获取最新的指标或统计信息,例如接口数据包计数器,然后将这些信息提供给它们的 gNMI 进程。因此,您接收到的数据的实时性不仅取决于您获取流消息的频率,还取决于内部轮询的频率。尽管如此,您仍然可能会看到最相关的系统事件,例如 BGP 状态转换,否则您可能会错过这些事件。

这些功能只是 gNMI 能力的一部分。gNMI 规范(见进一步阅读)可以作为所有 gNMI 协议功能的良好参考。接下来,我们将检查遥测服务的 gNMI protobuf 消息,以帮助您了解它是如何工作的。

订阅 RPC

gNMI 定义了一个单独的 RPC 来订阅遥测流。网络设备接收一个或多个SubscribeRequest消息,并以一系列SubscribeResponse消息进行响应:

service gNMI {
     rpc Subscribe(stream SubscribeRequest) returns (stream SubscribeResponse);
}

gNMI 客户端有多种选项来控制他们的遥测订阅。以下图显示了SubscribeRequest消息的组成,突出了这些选项之一:

图 9.2 – gNMI 订阅 protobuf 消息

图 9.2 – gNMI 订阅 protobuf 消息

控制遥测订阅的最基本方法是指定 PathSubscriptionMode

  • Path:引用您想要监控的 YANG 树的部分。您可以订阅任何内容,从整个设备状态到单个叶值。它遵循 gNMI 路径约定(见 进一步阅读)。

  • SubscriptionMode:确定是否在更改时发送遥测或定期发送:

    enum SubscriptionMode {
    
         TARGET_DEFINED = 0;
    
         ON_CHANGE      = 1;
    
         SAMPLE         = 2;
    
    }
    

作为回报,网络设备会向您发送包含以下信息的响应消息流:

  • TypedValue:最重要的字段,包含实际的遥测值

  • Path:值的完整 gNMI 路径,用于标识唯一的 YANG 叶节点

  • timestamp:帮助您按正确顺序安排和处理接收到的数据,或找出值最后一次更改的时间,对于那些不经常更改的值:

    message Notification {
    
         int64 timestamp = 1;
    
         Path prefix = 2;
    
         string alias = 3;
    
         repeated Update update = 4;
    
         repeated Path delete = 5;
    
         bool atomic = 6;
    
    }
    
    message Update {
    
         Path path = 1;
    
         TypedValue val = 3;
    
         uint32 duplicates = 4;
    
    }
    

我们只是触及了 Subscribe RPC 的表面。您可以通过查看 gnmi.proto 文件来查看完整的 protobuf 消息集,并阅读 gNMI 规范的遥测部分(见 进一步阅读)以更好地了解协议提供的功能和特性。以下是一些您可以了解的功能,我们在这本书中没有涉及:

  • gNMI 允许您轮询或获取遥测值的即时一次性(ONCE)快照。

  • 一些网络设备可以在单个 SubscribeResponse 中发送多个 Update 消息。这以降低时间戳准确性为代价,因为所有传输的值只有一个时间戳。

  • 如果您不希望看到每个单独的值,您可以允许网络设备聚合这些值。

  • 对于不同 YANG 模型定义的值,您可以指定您希望使用的定义。

重要提示

与 OpenConfig YANG 模型一样,具体实现的功能集因供应商而异。

使用 gNMI 的流式遥测处理管道

要从符合 gNMI 的网络设备接收或收集数据,您可以使用来自官方 gNMI 仓库的 Go gNMI 客户端实现(见 进一步阅读)。另一种选择是 gNMIc(见 进一步阅读),它建立在官方 gNMI 客户端之上,并提供了更多功能,例如数据转换和对北向接口的广泛支持。

gNMIc 可以作为网络设备和 时间序列数据库TSDB)或消息队列之间的链接,因为它可以将接收到的遥测数据转换为流行的开源项目(如 Prometheus、InfluxDB、NATS 和 Kafka)可以理解的格式。您可以将 gNMIc 作为命令行工具与网络设备交互,或作为守护进程,订阅遥测数据并将其发布到消息队列或数据库。

事件管理器示例程序

让我们通过一个原始事件管理器应用程序的实现来检查遥测处理管道的一个示例。该程序的目标是通过临时启用备份接口来重新分配传入流量,以应对增加的包速率。以下图显示了遥测处理管道的高级架构,并包括以下主要组件:

  • 作为守护进程运行的 gNMIC 进程,收集和处理网络遥测数据

  • 存储收集的遥测数据的 TSDB(Prometheus)

  • AlertManager(见 进一步阅读)处理从 Prometheus 收到的警报并触发外部事件

  • 一个实现事件管理器业务逻辑的 Go 程序:

图 9.3 – 事件管理器应用程序

图 9.3 – 事件管理器应用程序

你可以从本书 GitHub 仓库的根目录使用 make gnmic-start 启动这些组件(见 进一步阅读)。此命令启动 gNMIc 守护进程,并使用 docker-compose 启动 Prometheus、Grafana 和 AlertManager。这些应用程序现在与我们的测试实验室拓扑一起运行,并通过标准网络接口与之交互:

图 9.4 – 事件管理器拓扑

图 9.4 – 事件管理器拓扑

我们使用本书 GitHub 仓库(见 进一步阅读)中 topo-full/workdir/ 目录的一系列文件配置了这些应用程序(见 进一步阅读)。这些文件被挂载到各自的容器中,正如我们在 Containerlab 的配置文件(topo.yml – 见 进一步阅读)或 Docker Compose 的配置文件(docker-compose.yml – 见 进一步阅读)中定义的那样。以下是这些应用程序在我们设置中扮演的角色简要描述:

  • gNMIc 守护进程在测试拓扑的 Host-3 上运行。它订阅来自 cvx 设备的遥测数据,并将其作为 Prometheus 风格的指标暴露出来。我们在 gnmic.yaml 文件中管理这些设置,该文件看起来像这样:

    targets:
    
     "clab-netgo-cvx:9339":
    
        username: cumulus
    
        password: cumulus
    
    subscriptions:
    
      counters:
    
        target: netq
    
        paths:
    
          - /interfaces
    
        updates-only: true
    
    outputs:
    
      prom-output:
    
        type: prometheus
    
        listen: ":9313"
    
  • 你可以在 prometheus.yml 文件中找到 Prometheus 配置值。我们将其配置为每 2 秒抓取 gNMIc 端点,并将收集的数据存储在其 TSDB 中:

    scrape_configs:
    
      - job_name: 'event-trigger'
    
        scrape_interval: 2s
    
        static_configs:
    
          - targets: ['clab-netgo-host-3:9313']
    
  • 同一个配置文件还包括对警报定义文件(称为 alert.rules)和 AlertManager 连接细节的引用:

    rule_files:
    
      - 'alert.rules'
    
    alerting:
    
      alertmanagers:
    
      - scheme: http
    
        static_configs:
    
        - targets:
    
          - "alertmanager:9093"
    
  • alert.rules 文件中,我们定义了一个名为 HighLinkUtilization 的单个警报。每 10 秒,Prometheus 检查传入的包速率是否超过了每 30 秒间隔 50 个包的预定义阈值,如果是,则触发警报并将其发送到 AlertManager:

    groups:
    
    - name: thebook
    
      interval: 10s
    
      rules:
    
      - alert: HighLinkUtilization
    
        expr: rate(interfaces_interface_state_counters_in_pkts[30s]) > 50
    
        for: 0m
    
        labels:
    
          severity: warning
    
        annotations:
    
          summary: Transit link {{ $labels.interface_name }} is under high load
    
          description: "Transit link {{ $labels.interface_name }} is under high load LABELS = {{ $labels }}"
    
          value: '{{ $value }}'
    
  • AlertManager 有自己的配置文件,称为 alertmanager.yml,它控制如何从 Prometheus 聚合和路由传入的警报。在我们的案例中,我们只有一个警报类型,所以我们只需要一个路由。我们将默认的聚合计时器降低以实现更快的反应时间,并指定发送这些警报的 webhook URL:

    route:
    
      receiver: 'event-manager'
    
      group_wait: 5s
    
      group_interval: 10s
    
    receivers:
    
      - name: 'event-manager'
    
        webhook_configs:
    
        - url: http://clab-netgo-host-2:10000/alert
    
  • 事件管理程序解析警报并切换备份接口以重新平衡进入cvx设备的流量。其行为相当静态,因此我们不需要为其配置配置文件。

事件管理程序实现了一个标准的网络服务器,它监听传入的请求并将它们调度到处理函数。在这里,我们解码接收到的 Prometheus 警报并根据其状态调用toggleBackup函数:

func alertHandler(w http.ResponseWriter, req *http.Request) {
  log.Println("Incoming alert")
  var alerts Alerts
  err := json.NewDecoder(req.Body).Decode(&alerts)
  // process error

  for _, alert := range alerts.Alerts {
    if alert.Status == "firing" {
      if err := toggleBackup(alert.Labels.InterfaceName, "permit"); err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
      }
      continue
    }

    if err := toggleBackup(alert.Labels.InterfaceName, "deny"); err != nil {
      w.WriteHeader(http.StatusInternalServerError)
      return
    }
  }
  w.WriteHeader(http.StatusOK)
}

我们在cvxceos设备之间有两个上行链路,我们默认只使用其中一个。备份上行链路执行 BGP ASN 预置位,并且只有当我们宣布更具体或分解的前缀时才接收流量。toggleBackup函数通过在 IP 前缀列表(在cvx上)切换允许/拒绝语句来实现这一点,从而启用或禁用 BGP 分解行为:

var (
  backupRules = map[string][]int{
    "swp1": {10, 20},
  }
)

func toggleBackup(intf string, action string) error {
  log.Printf("%s needs to %s backup prefixes",
              intf, action)
  ruleIDs, ok := backupRules[intf]
  // process error

  var pl PrefixList
  pl.Rules = make(map[string]Rule)
  for _, ruleID := range ruleIDs {
    pl.Rules[strconv.Itoa(ruleID)] = Rule{
      Action: action,
    }
  }

  var payload nvue
  payload.Router.Policy.PrefixLists = map[string]PrefixList{
    plName: pl,
  }

  b, err := json.Marshal(payload)
  // process error

  return sendBytes(b)
}

最终的sendBytes函数使用我们在第六章“配置管理”中讨论的三阶段提交过程应用构建的配置。

可视化数据

您可以使用admin作为用户名/密码连接到运行在:3000的本地 Grafana 实例,以测试完整的由遥测驱动的管道。这个 Grafana 实例预先集成了 Prometheus 作为其数据源,并包含一个预构建的event-manager仪表板,该仪表板绘制了进入cvx链接到ceos的入包速率。

从本书 GitHub 仓库的根目录运行make traffic-start(见进一步阅读)以在实验室拓扑中生成流量。所有流量最初都应通过cvxceos之间的主连接(swp1)流动。

接下来,我们想要启动事件管理器应用程序,以便我们可以在两个连接之间进行流量负载均衡。为此,请在host-2容器内运行事件管理器 Go 应用程序。这相当于以下代码片段中我们执行的命令:

$ sudo ip netns exec clab-netgo-host-2 /usr/local/go/bin/go run ch09/event-manager/main.go
AlertManager event-triggered webhook
2022/08/01 21:51:13 Starting web server at 0.0.0.0:10000

打开一个新的终端窗口或标签页,再次运行make traffic-start,但使用DURATION变量增加流量生成周期,从默认的60s。例如,以下命令将生成 2 分钟的流量:

$ DURATION=2m make traffic-start

这可以帮助您看到流量重新平衡的长期影响。日志应显示流量速率已触发警报,并且应用程序已实施纠正措施:

$ sudo ip netns exec clab-netgo-host-2 /usr/local/go/bin/go run ch09/event-manager/main.go
AlertManager event-triggered webhook
2022/08/01 21:51:13 Starting web server at 0.0.0.0:10000
ch09/event-manager/main.go
2022/08/01 21:53:10 Incoming alert
2022/08/01 21:53:10 swp1 needs to permit backup prefixes
2022/08/01 21:53:10 Created revisionID: changeset/cumulus/2022-08-01_21.53.10_ASP0
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}
2022/08/01 21:54:00 Incoming alert
2022/08/01 21:54:00 swp1 needs to deny backup prefixes
2022/08/01 21:54:00 Created revisionID: changeset/cumulus/2022-08-01_21.54.00_ASP2
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}
2022/08/01 21:54:00 swp2 needs to permit backup prefixes
2022/08/01 21:54:00 Could not find a backup prefix for swp2
2022/08/01 21:54:20 Incoming alert
2022/08/01 21:54:20 swp2 needs to deny backup prefixes
2022/08/01 21:54:20 Could not find a backup prefix for swp2
2022/08/01 21:54:30 Incoming alert
2022/08/01 21:54:30 swp1 needs to permit backup prefixes
2022/08/01 21:54:30 Created revisionID: changeset/cumulus/2022-08-01_21.54.30_ASP4
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}
2022/08/01 21:55:20 Incoming alert
2022/08/01 21:55:20 swp1 needs to deny backup prefixes
2022/08/01 21:55:20 Created revisionID: changeset/cumulus/2022-08-01_21.55.20_ASP6
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}

我们进行的所有三个测试都应该得到一个看起来相似的图表:

图 9.5 – 事件管理器可视化

图 9.5 – 事件管理器可视化

流式遥测是一种强大的功能,您可以将其适应各种商业用例。然而,这些用例中的大多数都是特定于操作网络环境的,因此很难提出一套适用于每个网络的杀手级应用。因此,了解如何在代码中实现所需业务逻辑非常重要,这正是我们在本章中试图向您展示的。

在下一节中,我们将介绍另一个可以用于自动化操作任务的 OpenConfig gRPC 服务。

网络操作

在前面的章节中,我们探讨了 OpenConfig 管理接口如何处理两个常见的网络自动化用例:配置管理和操作状态收集。这两个任务本身就可以让您在网络自动化之旅中走得很远,但还有一些常见的操作任务不属于这两个类别。

要自动化网络操作的各个方面,我们需要执行诸如网络设备重启、软件生命周期管理和计数器及邻接重置等任务。通常,您会将这些活动作为交互式 CLI 工作流程的一部分来执行,其中包含假设有人工操作员参与过程的提示和警告。这使得这些任务的自动化成为一项重大任务,因为我们不得不求助于屏幕抓取,这增加了这些任务已经很高的风险。

为了应对这些挑战,OpenConfig 提出了一种新的 gRPC API,旨在抽象出交互式命令,并以标准、供应商中立的方式公开这些网络操作能力。

gNOI

gNOI 定义了一系列 gRPC 服务,这些服务涵盖了广泛的网络操作用例。每个服务代表一组操作和一系列动作,下表包含了一些示例,以帮助您了解 gNOI 试图解决的问题:

服务 描述 RPC 示例
OS NOS 包管理 安装、激活和验证
文件 文件操作 获取、传输、放置和删除
L2 L2 协议操作 清除邻居发现和清除 LLDP 接口
证书 证书管理 轮换、安装、生成 CSR 和撤销证书
系统 系统操作 Ping、Traceroute、重启和时间

表 9.1 – gNOI 用例示例

一些 RPC 是一次性的,有即时响应,一些同步响应直到完成或取消,还有一些是异步工作的。

gNOI GitHub 仓库(见进一步阅读)protobuf 文件包含每个服务的最新动作列表。在撰写本文时,这是system.proto文件的顶级定义(见进一步阅读):

service System {
     rpc Ping(PingRequest) returns (stream PingResponse) {}
     rpc Traceroute(TracerouteRequest) returns (stream TracerouteResponse) {}
     rpc Time(TimeRequest) returns (TimeResponse) {}
     rpc SetPackage(stream SetPackageRequest) returns (SetPackageResponse) {}
     rpc SwitchControlProcessor(SwitchControlProcessorRequest)
       returns (SwitchControlProcessorResponse) {}
     rpc Reboot(RebootRequest) returns (RebootResponse) {}
     rpc RebootStatus(RebootStatusRequest) returns (RebootStatusResponse) {}
     rpc CancelReboot(CancelRebootRequest) returns (CancelRebootResponse) {}
     rpc KillProcess(KillProcessRequest) returns (KillProcessResponse) {}
}

本书不涵盖所有 gNOI RPC。相反,我们只关注其中一个,并包括围绕它构建的一个示例程序。

Traceroute RPC

大多数,如果不是所有,网络工程师都熟悉traceroute命令。这是探索一对网络端点之间转发路径的常用方法。当您从网络设备的交互式 shell 中运行traceroute时,终端会在您的屏幕上打印结果。在 gNOI 中,traceroute是通过带有负载中的TracerouteRequest消息的 RPC 请求执行的操作,结果是TracerouteResponse消息的流(一个或多个):

service System {
     rpc Traceroute(TracerouteRequest) returns (stream TracerouteResponse) {}

traceroute命令行参数和标志一样,请求消息允许您指定选项,例如源地址、最大跳数以及是否执行反向 DNS 查找:

message TracerouteRequest {
     string source = 1;      // Source addr to ping from.
     String destination = 2; // Destination addr to ping.
     Uint32 initial_ttl = 3; // Initial TTL. (default=1)
     int32 max_ttl = 4;      // Maximum number of hops. 
     Int64 wait = 5;         // Response wait-time (ns).
     Bool do_not_fragment = 6;  
     bool do_not_resolve = 7;
     /* ... <omitted for brevity > ... */
}

每个响应消息包括单个测量周期的结果,包括跳数、往返时间和从探测回复中提取的响应地址:

message TracerouteResponse {
     /* ... <omitted for brevity > ... */
     int32 hop = 5;          // Hop number. required.
     string address = 6;     // Address of responding hop. 
     string name = 7;        // Name of responding hop.
     int64 rtt = 8;          // Round trip time in nanoseconds.
     /* ... <omitted for brevity > ... */
}

现在,让我们看看如何使用 Go 语言使用 gNOI 接口的示例。

路径验证应用程序

在本章的流式遥测部分,我们探讨了实现一个事件管理应用程序,该应用程序可以根据通过主接口的流量是否超过预定义阈值来启用或禁用备份链路。我们使用 Grafana 绘制了两个接口的流量速率,以确认应用程序按预期工作。

在涉及复杂工作流的实际自动化用例中,依赖于视觉线索并不总是正确的做法。理想情况下,我们需要一种程序化的方式来验证备份链路是否真正工作。我们在下一个代码示例中使用 gNOI Traceroute RPC 来检查这一点。目标是探索不同的网络路径,并确认我们正在通过备份接口转发一些流量流。您可以在本书 GitHub 仓库的ch09/gnoi-trace目录中找到本节的代码示例(见进一步阅读)。

我们首先设置一个与ceos虚拟网络设备的 gRPC 会话,并为 gNOI System服务创建一个新的 API 客户端:

var target = "clab-netgo-ceos:6030"
import (
     "google.golang.org/grpc"
     "github.com/openconfig/gnoi/system"
)
func main() {
     conn, err := grpc.Dial(target, grpc.WithInsecure())
     // process error
     defer conn.Close()
     sysSvc := system.NewSystemClient(conn)
     ctx, cancel := context.WithCancel(context.Background())
     defer cancel()
  /* ... <continues next > ... */
}

接下来,我们创建一个sync.WaitGroup来协调运行到不同目的地的 traceroute 的所有 goroutine。这些 goroutine 通过traceCh通道将收集到的结果发送回main goroutine。对于编码为string的每个 traceroute 目的地,traceroute 结果包括每个网络跳的响应 IP 地址列表。

为了使以下步骤中比较 IP 地址列表更容易,我们使用deckarep/golang-setmapset)第三方包将它们存储为集合,因为 Go 语言在标准库中没有原生实现集合。我们将跳数隐式地编码为[]mapset.Set数组中的索引:

var destinations = []string{
           "203.0.113.251",
           "203.0.113.252",
           "203.0.113.253",
}
func main() {
     /* ... <continues from before > ... */
     var wg sync.WaitGroup
     wg.Add(len(destinations))
     traceCh := make(chan map[string][]mapset.Set,
                            len(destinations))
  /* ... <continues next > ... */
}

每个 goroutine 运行一个 traceroute,我们只指定TracerouteRequest消息的源和目的字段,其余选项保留为默认值。当我们收到响应时,我们将结果存储在route切片中。当 traceroute 停止时,即当错误类型为io.EOF时,我们通过traceCh通道发送累积的响应并调用wg.Done

var source = "203.0.113.3"

func main() {
  /* ... <continues from before > ... */
  for _, dest := range destinations {
    go func(d string) {
      defer wg.Done()
      retryMax := 3
      retryCount := 0

    START:
      response, err := sysSvc.Traceroute(ctx,
                        &system.TracerouteRequest{
                                 Destination: d,
                                 Source: source,
      })
      // process error

      var route []mapset.Set
      for {
        resp, err := response.Recv()
        if errors.Is(err, io.EOF) {
        // end of stream, traceroute completed
          break
        }
        // process error

        // timed out, restarting the traceroute
        if int(resp.Hop) > len(route)+1 {
          if retryCount > retryMax-1 {
            goto FINISH
          }
          retryCount += 1
          goto START
        }

        // first response
        if len(route) < int(resp.Hop) {
          route = append(route, mapset.NewSet())
        }

        // subsequent responses
          route[resp.Hop-1].Add(resp.Address)
        }

    FINISH:
      traceCh <- map[string][]mapset.Set{
               d: route,
             }
    }(dest)
  }
  wg.Wait()
  close(traceCh)
  /* ... <continues next > ... */
}

由于网络设备具有默认的控制平面安全设置,这些设置可能会限制它们处理代码中的每个goto语句以重试 traceroute,以防我们对于任何一跳没有获取到任何信息。STARTFINISH是我们用来实现这种重试逻辑的两个标签,后者在我们尝试多次后没有获取到结果时作为回退情况。

一旦我们完成了所有 traceroute 请求,我们就可以处理和分析结果。为了简化代码逻辑,我们首先将数据转换为存储一个跳数与每个 traceroute 目标的一组 IP 地址之间的映射:

func main() {
  /* ... <continues from before > ... */
  routes := make(map[int]map[string]mapset.Set)

  for trace := range traceCh {
    for dest, paths := range trace {
      for hop, path := range paths {
        if _, ok := routes[hop]; !ok {
          routes[hop] = make(map[string]mapset.Set)
        }
        routes[hop][dest] = path
      }
    }
  }
  /* ... <continues next > ... */
}

最后,我们可以遍历每个跳数并检查不同 traceroute 目标的一组响应 IP 地址之间是否存在差异,这意味着数据包走过了不同的路径。如果我们检测到这种情况,我们将在屏幕上打印出来:

func main() {
  /* ... <continues from before > ... */
  for hop, route := range routes {
    if hop == len(routes)-1 {
      continue
    }
    found := make(map[string]string)
    for myDest, myPaths := range route {
      for otherDest, otherPaths := range route {
        if myDest == otherDest {
          continue
        }
        diff := myPaths.Difference(otherPaths)
        if diff.Cardinality() == 0 {
          continue
        }

        v, ok := found[myDest]
        if ok && v == otherDest {
          continue
        }

        log.Printf("Found different paths at hop %d", hop)
        log.Printf("Destination %s: %+v", myDest, myPaths)
        log.Printf(
                "Destination %s: %+v",
                        otherDest,
                        otherPaths,
                        )
        found[otherDest] = myDest
      }
    }
  }
  log.Println("Check complete")
}

您可以从ch09/gnoi-trace文件夹运行此程序。请确保lab-full首先启动并运行。您应该看到以下输出:

ch09/gnoi-trace$ go run main.go
2022/06/26 16:51:10 Checking if routes have different paths
2022/06/26 16:51:16 Missed at least one hop in 203.0.113.251
2022/06/26 16:51:16 retrying 203.0.113.251
2022/06/26 16:51:17 Check complete

使用make traffic-start生成流量,然后再次运行此程序。在另一个标签页中,同时从clab-netgo-host-2主机运行事件管理器应用程序以激活备份链路:

$ DURATION=2m make traffic-start
docker exec -d clab-netgo-cvx systemctl restart hsflowd
docker exec -d clab-netgo-host-3 ./ethr -s
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.253 -b 900K -d 2m -p udp -l 1KB
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.252 -b 600K -d 2m -p udp -l 1KB
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.251 -b 400K -d 2m -p udp -l 1KB
$ sudo ip netns exec clab-netgo-host-2 /usr/local/go/bin/go run ch09/event-manager/main.go
AlertManager event-triggered webhook
2022/09/14 21:02:57 Starting web server at 0.0.0.0:10000
2022/09/14 21:02:58 Incoming alert
2022/09/14 21:02:58 swp1 needs to permit backup prefixes
2022/09/14 21:02:58 Created revisionID: changeset/cumulus/2022-09-14_21.02.58_S4SQ
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}
2022/09/14 21:03:40 Incoming alert
2022/09/14 21:03:40 swp1 needs to deny backup prefixes
2022/09/14 21:03:40 Created revisionID: changeset/cumulus/2022-09-14_21.03.40_S4SS
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}
2022/09/14 21:03:40 swp2 needs to permit backup prefixes
2022/09/14 21:03:40 Could not find a backup prefix for swp2
2022/09/14 21:04:10 Incoming alert
2022/09/14 21:04:10 swp1 needs to permit backup prefixes
2022/09/14 21:04:10 Created revisionID: changeset/cumulus/2022-09-14_21.04.10_S4SV
{
  "state": "apply",
  "transition": {
    "issue": {},
    "progress": ""
  }
}
2022/09/14 21:04:10 swp2 needs to deny backup prefixes
2022/09/14 21:04:10 Could not find a backup prefix for swp2

程序的输出将如下所示:

ch09/gnoi-trace$ go run main.go
2022/09/14 21:03:29 Checking if routes have different paths
2022/09/14 21:03:34 Missed at least one hop in 203.0.113.253
2022/09/14 21:03:34 retrying 203.0.113.253
2022/09/14 21:03:34 Found different paths at hop 0
2022/09/14 21:03:34 Destination 203.0.113.252: Set{192.0.2.5}
2022/09/14 21:03:34 Destination 203.0.113.253: Set{192.0.2.3}
2022/09/14 21:03:34 Found different paths at hop 0
2022/09/14 21:03:34 Destination 203.0.113.251: Set{192.0.2.5}
2022/09/14 21:03:34 Destination 203.0.113.253: Set{192.0.2.3}
2022/09/14 21:03:34 Found different paths at hop 0
2022/09/14 21:03:34 Destination 203.0.113.253: Set{192.0.2.3}
2022/09/14 21:03:34 Destination 203.0.113.252: Set{192.0.2.5}
2022/09/14 21:03:34 Check complete

最后的输出显示,203.0.113.252/32203.0.113.251/32遵循的路径与203.0.113.253/32遵循的路径不同(主链路)。这是因为事件管理器将.252.251从主要的203.0.113.250/30前缀中分离出来。现在,我们知道备份链路正在按预期工作,因为它正在为这两个 IP 地址传输流量。

从历史上看,网络供应商没有动力创建供应商中立的 API 和数据模型,因为这不允许他们与竞争对手区分开来。虽然像互联网工程任务组(IETF)这样的标准机构为网络行业制定标准,但他们不能总是影响供应商实际实施的内容。此外,一些供应商可能仍然认为技术锁定是一种有效的保持现有客户群的方法。

与此相反,网络运营商的 OpenConfig 社区更有能力影响网络供应商采用供应商独立的数据模型和 API。OpenConfig 的采用率在模型和功能覆盖方面仍然相对较低,但是,只要 OC 参与者继续推动更多,覆盖范围将会增加,这将反过来推动更广泛的网络社区采用。

即使在今天,OpenConfig 也提供了一种供应商中立的网络任务执行方式,包括配置管理、监控和操作。在本章中,我们展示了两个最流行的接口,gNMI 和 gNOI,忽略了较少使用的 gRIBI,它超出了本书的范围。我们希望本章提供了足够的工具和工作流程示例,您可以使用 Go 来消费和与 OpenConfig 兼容的设备交互。

摘要

在本章中,通过介绍流式遥测,我们开始探索网络监控的世界,这对于企业来说是一项关键任务。观察网络整体状态以及收集和处理数据平面信息的能力,对于确定网络的健康状况都至关重要。在下一章中,我们将探讨一些具体的网络监控任务和用例,并学习 Go 语言如何帮助我们自动化这些任务。

进一步阅读

第十章:网络监控

尽管配置管理很受欢迎,但我们实际上花在监控网络上的时间比配置它们的时间更多。随着网络变得越来越复杂,新的封装层和 IP 地址转换,我们理解网络是否正确运行以使我们能够满足客户服务级别协议SLAs)的能力变得越来越困难。

在云基础设施领域工作的工程师提出了“可观察性”这个术语,指的是通过观察系统的外部输出来推理系统内部状态的能力。用网络术语来说,这可能包括通过日志和状态遥测收集的被动监控或使用分布式探测、数据处理和可视化的主动监控。

所有这些的最终目标是减少平均修复时间MTTR),遵守客户服务级别协议(SLAs),并转向主动问题解决。Go 语言是这类任务中非常受欢迎的选择语言,在本章中,我们将探讨一些可以帮助您进行网络监控的工具、包和平台。以下是本章的要点:

  • 我们将通过查看如何使用 Go 语言捕获和解析网络数据包来探索流量监控。

  • 接下来,我们将探讨如何处理和汇总数据平面遥测数据,以获取对当前网络行为的有意义见解。

  • 我们展示了如何使用主动探测来衡量网络性能,以及如何生成、收集和可视化性能指标。

我们将故意避免讨论基于 YANG 的遥测,因为我们已经在第八章“网络 API”和第九章“OpenConfig”中介绍过了。

在本章中,我们还没有涉及的一个领域,我们希望在本章中简要讨论的是开发者体验。随着我们编写更多的代码,维护现有软件成为我们日常运营的重要部分。我们将在本章的每个部分介绍一个工具,承认我们只是触及了表面,这个主题可能是一整本书的主题。最后,我们并不力求提供一个全面概述所有工具的概述,只是想给您一个在生产环境中开发 Go 代码可能感觉如何的印象。

技术要求

您可以在本书的 GitHub 仓库中找到本章的代码示例(见进一步阅读部分),在ch10文件夹下。

重要提示

我们建议您在虚拟实验室环境中执行本章中的 Go 程序。有关先决条件和如何构建完全配置的网络拓扑结构的说明,请参阅附录

在下一节中,我们将讨论的第一个示例将探讨 Go 中的数据包捕获和解析功能。

数据平面遥测处理

网络活动,如容量规划、计费或分布式拒绝服务DDoS)攻击监控,需要了解通过网络流过的流量。我们可以提供此类可见性的方法之一是部署数据包采样技术。前提是,以足够高的速率,只捕获随机采样的数据包子集,以建立一个对整体网络流量模式的良好理解。

虽然是硬件对数据包进行采样,但软件将它们聚合为流并导出。NetFlow、sFlow 和IP 流信息导出IPFIX)是我们用于此目的的三个主要协议,它们定义了有效负载的结构以及每个采样数据包应包含哪些元数据。

任何遥测处理流程的第一步是信息摄取。在我们的上下文中,这意味着接收和解析数据平面遥测数据包以提取和处理流记录。在本节中,我们将探讨如何借助google/gopacket包(见进一步阅读)捕获和处理数据包。

数据包捕获

第四章“使用 Go 的 TCP/IP 网络”,我们讨论了如何使用 Go 标准库中的net包构建 UDP ping 应用程序。虽然我们可能应该采取类似的方法来构建 sFlow 收集器,但我们将为下一个示例做些不同的事情。

我们没有设计一个数据平面遥测收集器,而是将我们的应用程序设计为接入现有的遥测数据包流,假设拓扑中的网络设备正在将它们发送到网络中某个位置的现有收集器。这允许你在不改变现有遥测服务配置的情况下,仍然能够捕获和处理遥测流量。当你需要一个可以直接在网络上运行、按需使用且持续时间短的透明工具时,你可以使用这样的程序。

在测试实验室拓扑中,cvx节点运行一个代理,使用 sFlow 协议导出采样指标。sFlow 流量流向host-2,在那里它被示例应用程序使用 tap 拦截:

图 10.1 – sFlow 应用程序

图 10.1 – sFlow 应用程序

为了向您展示google/gopacket包的数据包捕获功能,我们使用pcapgo拦截所有 sFlow 数据包——这是 Linux 中流量捕获 API 的本地 Go 实现。尽管它比其对应的pcappfring包功能较少,但pcapgo的好处是它不依赖于任何外部 C 库,并且可以在任何 Linux 发行版上本地运行。

在本书 GitHub 仓库的ch10/packet-capture文件夹中,您可以在packet-capture程序的第一部分中设置一个新的af_packet套接字处理程序,使用pcapgo.NewEthernetHandle函数,并传递要监控的接口名称:

import (
     "github.com/google/gopacket/pcapgo"
)
var (
     intf = flag.String("intf", "eth0", "interface")
)
func main() {
     handle, err := pcapgo.NewEthernetHandle(*intf)
     /* ... <continues next > ... */
}

在这一点上,handle为我们提供了对eth0接口上所有数据包的访问权限。

数据包过滤

尽管我们可以通过接口捕获所有数据包,但为了实验的目的,我们将包括一个如何在 Go 中使用伯克利数据包过滤器(BPF)程序过滤我们捕获的流量的示例。

首先,我们使用tcpdump命令的-d选项以可读格式生成一个编译后的包匹配代码,用于过滤 IP 和 UDP 数据包:

$ sudo tcpdump -p -ni eth0 -d "ip and udp"
(000) ldh      [12]
(001) jeq      #0x800           jt 2    jf 5
(002) ldb      [23]
(003) jeq      #0x11            jt 4    jf 5
(004) ret      #262144
(005) ret      #0

然后,我们将前面的每个指令转换为来自golang.org/x/net/bpf包的相应bpf.Instruction。我们将这些指令组装成一组[]bpf.RawInstruction,它们可以加载到 BPF 虚拟机中:

import (
  "golang.org/x/net/bpf"
)

func main() {
/* ... <continues from before > ... */

  rawInstructions, err := bpf.Assemble([]bpf.Instruction{
    // Load "EtherType" field from the ethernet header.
    bpf.LoadAbsolute{Off: 12, Size: 2},
    // Skip to last instruction if EtherType isn't IPv4.
    bpf.JumpIf{Cond: bpf.JumpNotEqual, Val: 0x800,
                    SkipTrue: 3},
    // Load "Protocol" field from the IPv4 header.
    bpf.LoadAbsolute{Off: 23, Size: 1},
    // Skip to the last instruction if Protocol is not UDP.
    bpf.JumpIf{Cond: bpf.JumpNotEqual, Val: 0x11,
                    SkipTrue: 1},
    // "send up to 4k of the packet to userspace."
    bpf.RetConstant{Val: 4096},
    // Verdict is "ignore packet and return to the stack."
    bpf.RetConstant{Val: 0},
  })

  handle.SetBPF(rawInstructions)
  /* ... <continues next > ... */
}

我们可以将结果附加到我们之前创建的EthernetHandle函数上,作为数据包过滤器,以减少应用程序接收到的数据包数量。

总结来说,我们将匹配0x800以太网类型和0x11IP 协议的所有数据包复制到用户空间进程,其中我们的 Go 程序运行,而所有其他数据包,包括我们匹配的数据包,将继续通过网络堆栈。这使得这个程序对任何现有的流量流都是完全透明的,并且你可以使用它而无需更改 sFlow 代理的配置。

数据包处理

内核发送到用户空间的所有数据包都通过PacketSource类型在 Go 应用程序中变得可用,我们通过将我们创建的EthernetHandle函数与一个以太网数据包解码器相结合来构建这个类型:

func main() {
  /* ... <continues from before > ... */
     packetSource := gopacket.NewPacketSource(
           handle,
           layers.LayerTypeEthernet,
     )
     /* ... <continues next > ... */
}

这个PacketSource结构通过 Go 通道发送每个接收和解码的数据包,这意味着我们可以使用for循环逐个迭代它们。在这个循环内部,我们使用gopacket来匹配数据包层并提取关于 L2、L3 和 L4 网络头部的信息,包括特定于协议的细节,如 sFlow 有效负载:

func main() {
  /* ... <continues from before > ... */
  for packet := range packetSource.Packets() {
    sflowLayer := packet.Layer(layers.LayerTypeSFlow)
    if sflowLayer != nil {
      sflow, ok := sflowLayer.(*layers.SFlowDatagram)
      if !ok {
        continue
      }

      for _, sample := range sflow.FlowSamples {
        for _, record := range sample.GetRecords() {
          p, ok := record.(layers.SFlowRawPacketFlowRecord)
          if !ok {
            log.Println("failed to decode sflow record")
            continue
          }

          srcIP, dstIP := p.Header.
            NetworkLayer().
            NetworkFlow().
            Endpoints()
          sPort, dPort := p.Header.
            TransportLayer().
            TransportFlow().
            Endpoints()
          log.Printf("flow record: %s:%s <-> %s:%s\n",
            srcIP,
            sPort,
            dstIP,
            dPort,
          )
        }
      }
     }
  }
}

使用gopacket专门进行 sFlow 解码的好处是它可以基于采样数据包的头部解析并创建另一个gopacket.Packet

生成流量

为了测试这个 Go 应用程序,我们需要在实验室拓扑中生成一些流量,以便cvx设备可以生成关于它的 sFlow 记录。在这里,我们使用microsoft/ethr – 一个基于 Go 的流量生成器,它提供与iperf相当的用户体验和功能。它可以生成和接收固定量的网络流量并测量带宽、延迟、丢失和抖动。在我们的情况下,我们只需要它生成一些低流量的流量流,通过实验室网络触发数据平面流量采样。

packet-capture应用程序从现有的 sFlow 流量中提取,解析和提取流记录,并在屏幕上打印这些信息。要测试程序,从本书 GitHub 存储库的根目录运行make capture-start(参见进一步阅读):

$ make capture-start
docker exec -d clab-netgo-cvx systemctl restart hsflowd
docker exec -d clab-netgo-host-3 ./ethr -s
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.253 -b 900K -d 60s -p udp -l 1KB
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.252 -b 600K -d 60s -p udp -l 1KB
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.251 -b 400K -d 60s -p udp -l 1KB
cd ch10/packet-capture; go build -o packet-capture main.go
docker exec -it clab-netgo-host-2 /workdir/packet-capture/packet-capture
2022/02/28 21:50:25  flow record: 203.0.113.0:60087 <-> 203.0.113.252:8888
2022/02/28 21:50:25  flow record: 203.0.113.0:60087 <-> 
203.0.113.252:8888
2022/02/28 21:50:27  flow record: 203.0.113.0:40986 <-> 203.0.113.252:8888
2022/02/28 21:50:29  flow record: 203.0.113.0:60087 <-> 203.0.113.252:8888
2022/02/28 21:50:29  flow record: 203.0.113.0:49138 <-> 203.0.113.251:8888
2022/02/28 21:50:30  flow record: 203.0.113.0:60087 <-> 203.0.113.252:8888
2022/02/28 21:50:30  flow record: 203.0.113.0:49138 <-> 203.0.113.251:8888

正如承诺的那样,在我们进入下一节之前,让我们回顾本章的第一个开发者体验工具。

Go 程序调试

阅读和推理现有代码库是一项费力的任务,随着程序的成熟和演变,这变得更加困难。这就是为什么在学习新语言时,至少对调试过程有一个基本理解非常重要。调试允许我们在预定义的位置停止程序的执行,并逐行通过代码,同时检查内存中的变量和数据结构。

在以下示例中,我们使用 Delve 调试我们刚刚运行的 packet-capture 程序。在您开始之前,您需要通过 make traffic-start 在实验室拓扑中生成一些流量:

$ make traffic-start
docker exec -d clab-netgo-cvx systemctl restart hsflowd
docker exec -d clab-netgo-host-3 ./ethr -s
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.253 -b 900K -d 60s -p udp -l 1KB
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.252 -b 600K -d 60s -p udp -l 1KB
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.251 -b 400K -d 60s -p udp -l 1KB

Delve 二进制文件已在 host 实验室容器中预先安装,因此您可以使用 docker exec -it 命令连接到 host-2 容器,并使用 dlv debug 命令启动 Delve shell:

$ docker exec -it clab-netgo-host-2 bash
root@host-2:/# cd workdir/ch10/packet-capture/
root@host-2:/workdir/packet-capture# dlv debug main.go

一旦进入 dlv 交互式 shell,您可以使用不同的内置命令来控制程序的执行(您可以使用 help 查看命令列表的完整列表)。在 main.go 的第 49 行设置断点,并运行程序直到我们收到第一个数据包:

(dlv) break main.go:49
Breakpoint 1 set at 0x5942ce for main.main() ./main.go:49
(dlv) continue
> main.main() ./main.go:49 (hits goroutine(1):1 total:1) (PC: 0x5942ce)
    44:    packetSource := gopacket.NewPacketSource(
    45:      handle,
    46:      layers.LayerTypeEthernet,
    47:    )
    48:    for packet := range packetSource.Packets() {
=>  49:      if l4 := packet.TransportLayer(); l4 == nil {
    50:        continue
    51:      }
    52:  
    53:      sflowLayer := packet.Layer(layers.LayerTypeSFlow)
    54:      if sflowLayer != nil {

当执行在断点处停止时,您可以使用 locals 命令检查局部变量:

(dlv) locals
err = error nil
handle = ("*github.com/google/gopacket/pcapgo.EthernetHandle")(0xc000162200)
rawInstructions = []golang.org/x/net/bpf.RawInstruction len: 6, cap: 6, [...]
packetSource = ("*github.com/google/gopacket.PacketSource")(0xc00009aab0)
packet = github.com/google/gopacket.Packet(*github.com/google/gopacket.eagerPacket) 0xc0000c3c08

您可以在屏幕上打印变量内容,如下面的 packet 变量示例所示:

(dlv) print packet
github.com/google/gopacket.Packet(*github.com/google/gopacket.eagerPacket) *{
  packet: github.com/google/gopacket.packet {
    data: []uint8 len: 758, cap: 758, [170,193,171,140,219,204,170,193,171,198,150,242,8,0,69,0,2,232,40,71,64,0,63,17,18,182,192,0,2,5,203,0,113,2,132,19,24,199,2,212,147,6,0,0,0,5,0,0,0,1,203,0,113,129,0,1,134,160,0,0,0,39,0,2,...+694 more],
    /* ... < omitted > ... */
    last: github.com/google/gopacket.Layer(*github.com/google/gopacket.DecodeFailure) ...,
    metadata: (*"github.com/google/gopacket.PacketMetadata")(0xc0000c6200),
    decodeOptions: (*"github.com/google/gopacket.DecodeOptions")(0xc0000c6250),
    link: github.com/google/gopacket.LinkLayer(*github.com/google/gopacket/layers.Ethernet) ...,
    network: github.com/google/gopacket.NetworkLayer(*github.com/google/gopacket/layers.IPv4) ...,
    transport: github.com/google/gopacket.TransportLayer(*github.com/google/gopacket/layers.UDP) ...,
    application: github.com/google/gopacket.ApplicationLayer nil,
    failure: github.com/google/gopacket.ErrorLayer(*github.com/google/gopacket.DecodeFailure) ...,},}

文本导航和输出的详细程度可能对初学者来说有些吓人,但幸运的是,我们有其他可视化选项。

从 IDE 进行调试

如果在控制台中调试不是您的首选选项,那么大多数流行的 集成开发环境IDE)都提供某种形式的 Go 调试支持。例如,Delve 与 Visual Studio CodeVSCode)集成,您也可以为其配置远程调试。

虽然您可以通过不同的方式设置 VSCode 的远程调试,但在本例中,我们在 headless 模式下手动在容器中运行 Delve,同时指定监听传入连接的端口:

$ docker exec -it clab-netgo-host-2 bash 
root@host-2:/# cd workdir/ch10/packet-capture/
root@host-2:/workdir/ch10/packet-capture#  dlv debug main.go --listen=:2345 --headless --api-version=2
API server listening at: [::]:2345

现在,我们需要告诉 VSCode 如何连接到远程 Delve 进程。您可以通过在 main.go 文件旁边的 .vscode 文件夹中包含一个 JSON 配置文件来实现这一点。以下是一个示例文件,您可以在本书 GitHub 仓库的 ch10/packet-capture/.vscode/launch.json 中找到:

{
	"version": "0.2.0",
	"configurations": [
        {
            "name": "Connect to server",
            "type": "go",
            "request": "attach",
            "mode": "remote",
            "remotePath": "/workdir/ch10/packet-capture",
            "port": 2345,
            "host": "ec2-3-224-127-79.compute-1.amazonaws.com",  
        },
    ]
}

您需要将 host 值替换为实验室运行的位置,然后从 Go 程序的根目录启动 VSCode 实例(code ch10/packet-capture):

图 10.2 – VSCode 开发环境

图 10.2 – VSCode 开发环境

在 VSCode 中,现在您可以点击左侧菜单中的调试图标,进入 运行和调试,在那里您应该看到读取前面 JSON 配置文件的 连接到服务器 选项。点击绿色箭头以连接到远程调试进程。

在此阶段,您可以在调试过程在容器内运行的同时,在 VSCode 用户界面UI)中导航并检查局部变量:

图 10.3 – VSCode 调试

图 10.3 – VSCode 调试

在下一节中,我们将探讨如何通过聚合收集和处理的网络平面遥测数据来增加其价值,并生成最高带宽消费者的报告。

网络平面遥测聚合

在收集和解析数据平面遥测数据后,我们需要考虑接下来如何处理这些数据。由于数据流数量庞大且缺乏任何有意义的上下文,直接查看原始数据并不总是有帮助。因此,遥测处理管道中的下一个逻辑步骤是数据增强和聚合。

遥测增强是指根据某些外部信息源,为每个数据流添加额外元数据的过程。例如,这些外部源可以提供公共 IP 与其来源国家或 BGP ASN 之间的关联,或者私有 IP 与其聚合子网或设备标识之间的关联。

另一种可以帮助我们解释和推理所收集遥测数据的技巧是聚合。我们可以根据 IP 前缀边界或流元数据(如 BGP ASN)将不同的流记录组合起来,以帮助网络操作员得出有意义的见解并创建数据的高级视图。

您可以使用开源组件构建整个遥测处理管道,并使用互联网上可用的现成示例(见进一步阅读),但迟早您可能需要编写一些代码以满足特定的业务需求。在下一节中,我们将处理一个需要聚合网络平面遥测数据以更好地了解网络流量模式的场景。

主要通信者

在没有长期遥测存储的情况下,获取最高带宽消费者的即时快照可能非常有帮助。我们将此应用程序称为主要通信者,它通过显示基于相对接口带宽利用率排序的网络流列表来工作。

让我们通过一个实现此功能的 Go 应用程序示例来了解一下。

探索遥测数据

在我们的top-talkers应用程序中,我们使用netsampler/goflow2收集 sFlow 记录,这是一个专门设计用于收集、增强和保存 sFlow、IPFIX 或 NetFlow 遥测数据的包。该包摄取原始协议数据并生成标准化(协议无关)的流记录。默认情况下,您可以将这些标准化记录保存到文件或发送到 Kafka 队列。在我们的案例中,我们将它们存储在内存中以便进一步处理。

为了在内存中存储流记录,我们将接收到的每个流记录的最相关字段保存到用户定义的数据结构中,我们称之为MyFlow

type MyFlow struct {
     Key         string
     SrcAddr     string `json:"SrcAddr,omitempty"`
     DstAddr     string `json:"DstAddr,omitempty"`
     SrcPort     int    `json:"SrcPort,omitempty"`
     DstPort     int    `json:"DstPort,omitempty"`
     Count       int    // times we've seen this flow sample
}

此外,我们创建一个流键,作为源和目标端口及 IP 地址的连接,以唯一标识每个流:

图 10.4 – 流量键

图 10.4 – 流量键

为了帮助我们计算最终结果,我们创建了一个我们称之为topTalker的另一个数据结构,它有两个字段:

  • flowMap:一个用于存储MyFlow类型流集合的映射。我们使用我们创建的键来索引它们。

  • Heap:一个辅助数据结构,用于跟踪最频繁出现的流量:

    type Heap []*MyFlow
    
    type topTalker struct {
    
         flowMap map[string]*MyFlow
    
         heap    Heap
    
    }
    

由于我们使用高级 sFlow 包(goflow2),我们不需要担心设置 UDP 监听器或接收和解码数据包,但我们需要告诉goflow2报告流量记录的格式(json)并指向一个自定义传输驱动程序(tt),该驱动程序确定在 sFlow 包标准化接收到的流量记录之后如何处理数据:

import (
  "github.com/netsampler/goflow2/format"
  "github.com/netsampler/goflow2/utils"
)
func main() {
     tt := topTalker{
           flowMap: make(map[string]*MyPacket),
           heap:    make(Heap, 0),
     }
     formatter, err := format.FindFormat(ctx, "json")
     // process error
     sSFlow := &utils.StateSFlow{
           Format:    formatter,
           Logger:    log.StandardLogger(),
           Transport: &tt,
     }
     go sSFlow.FlowRoutine(1, hostname, 6343, false)
}

前一个代码片段中的utils.StateSFlow类型的Transport字段接受任何实现了TransportInterface接口的类型。该接口期望一个方法(Send()),其中可以发生所有丰富和聚合操作:

type StateSFlow struct {
     Format    format.FormatInterface
     Transport transport.TransportInterface
     Logger    Logger
     /* ... < other fields > ... */
}
type TransportInterface interface {
     Send(key, data []byte) error
}

Send方法接受两个参数,一个表示 sFlow 数据报的源 IP,另一个包含实际的流量记录。

遥测处理

在我们的Send方法实现(以满足TransportInterface接口)中,我们首先解析输入的二进制数据,并将其反序列化为MyFlow数据结构:

func (c *topTalker) Send(key, data []byte) error {
     var myFlow MyFlow
     json.Unmarshal(data, &myFlow)
     /* ... <continues next > ... */
}

考虑到 sFlow 可以捕获双向的数据包,我们需要确保两个流都计入内存中的同一流量记录。这意味着创建一个特殊的流量键,满足以下两个条件:

  • 它必须与同一流的所有入站和出站数据包相同。

  • 它必须对所有双向流量是唯一的。

我们通过在构建双向流量键时对源和目标 IP 进行排序来完成这项工作,如下面的代码片段所示:

var flowMapKey = `%s:%d<->%s:%d`
func (c *topTalker) Send(key, data []byte) error {
  /* ... <continues from before > ... */
  ips := []string{myFlow.SrcAddr, myFlow.DstAddr}
  sort.Strings(ips)
  var mapKey string
  if ips[0] != myFlow.SrcAddr {
    mapKey = fmt.Sprintf(
      flowMapKey,
      myFlow.SrcAddr,
      myFlow.SrcPort,
      myFlow.DstAddr,
      myFlow.DstPort,
    )
  } else {
    mapKey = fmt.Sprintf(
      flowMapKey,
      myFlow.DstAddr,
      myFlow.DstPort,
      myFlow.SrcAddr,
      myFlow.SrcPort,
    )
  }
  /* ... <continues next > ... */
}

使用一个唯一键表示流的双向,我们可以在映射(flowMap)中保存它以存储在内存中。对于每个接收到的流量记录,Send方法执行以下检查:

  • 如果这是我们第一次看到这个流量,那么我们就把它保存在地图上,并将计数器设置为1

  • 否则,我们通过将计数器增加一个来更新流量:

func (c *topTalker) Send(key, data []byte) error {
  /* ... <continues from before > ... */
    myFlow.Key = mapKey
    foundFlow, ok := c.flowMap[mapKey]
    if !ok {
          myFlow.Count = 1
          c.flowMap[mapKey] = &myFlow
          heap.Push(&c.heap, &myFlow)
          return nil
    }
    c.heap.update(foundFlow)
    return nil
} 

现在,为了按顺序显示顶级对话者,我们需要对保存的流量记录进行排序。在这里,我们使用 Go 标准库中的container/heap包。它实现了一个排序算法,提供 O(log n)(对数)上界保证,这意味着它可以非常高效地进行数据添加和删除操作。

要使用此包,你只需要教它如何比较你的项目。当你添加、删除或更新元素时,它会自动对它们进行排序。在我们的例子中,我们想要对保存为MyFlow数据类型的流量记录进行排序。我们定义Heap为指向MyFlow记录的指针列表。Less()方法指示container/heap包根据存储我们已看到流量记录次数的Count字段比较两个MyFlow元素:

type Heap []*MyFlow
func (h Heap) Less(i, j int) bool {
     return h[i].Count > h[j].Count
}

现在,我们有一个内存中的流记录存储,其元素根据它们的Count进行排序。我们现在可以遍历Heap切片,并在屏幕上打印其元素。与之前的gopacket示例一样,我们使用ethr生成具有不同吞吐量的三个 UDP 流,以获得一致的排序输出。您可以使用make top-talkers-start在拓扑中触发这些流:

Network-Automation-with-Go $ make top-talkers-start
docker exec -d clab-netgo-cvx systemctl restart hsflowd
docker exec -d clab-netgo-host-3 ./ethr -s
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.253 -b 900K -d 60s -p udp -l 1KB
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.252 -b 600K -d 60s -p udp -l 1KB
docker exec -d clab-netgo-host-1 ./ethr -c 203.0.113.251 -b 400K -d 60s -p udp -l 1KB

然后,在host-2容器(clab-netgo-host-2)内部使用go run main.go运行 Top-talkers Go 应用程序,以获取实时 Top-talkers 表:

$ cd ch10/top-talkers; sudo ip netns exec clab-netgo-host-2 /usr/local/go/bin/go run main.go; cd ../../
Top Talkers
+---+-------------------+--------------------+------
| # | FROM              | TO                 | PROTO 
+---+-------------------+--------------------+------
| 1 | 203.0.113.253:8888 | 203.0.113.0:48494 | UDP | 
| 2 | 203.0.113.252:8888 | 203.0.113.0:42912 | UDP | 
| 3 | 203.0.113.251:8888 | 203.0.113.0:42882 | UDP | 
+---+-------------------+--------------------+------

注意,由于流量量低、随机数据包采样和测试持续时间有限,你的结果可能会有所不同,但在多次测试迭代后应该收敛到类似的分布。

测试 Go 程序

代码测试是任何生产软件开发过程中的一个重要部分。良好的测试覆盖率可以提高应用程序的可靠性,并增加对软件开发后期阶段引入的错误的容忍度。Go 通过其标准库中的testing包和内置的命令行工具go test提供了对测试的原生支持。由于 Go 工具中内置了测试覆盖率,因此很少看到用于测试 Go 代码的第三方包。

表格驱动测试是 Go 中最受欢迎的测试方法之一。其思想是将测试用例描述为自定义数据结构的切片,每个提供每个测试用例的输入和预期结果。将测试用例编写为表格使得创建新场景、考虑边界情况以及解释现有代码行为变得更加容易。

我们可以通过构建一组针对我们用于排序流记录的堆实现表格测试,来测试我们刚刚审查的top-talkers示例代码的一部分。

让我们创建一个包含单个测试函数的测试文件,main_test.go

package main
import (
     "container/heap"
     "testing"
)
func TestHeap(t *testing.T) {
  // code tests
}

_test.go文件名后缀和Test<Name>函数前缀都是命名约定,允许 Go 检测测试代码并在二进制编译期间排除它。

我们设计每个测试用例都包含所有相关信息,包括以下内容:

  • 用于错误消息的名称:

  • 一组由它们的起始计数和结果位置描述的唯一流:

    type testFlow struct {
    
         startCount   int
    
         timesSeen    int
    
         wantPosition int
    
         wantCount    int
    
    }
    
    type testCase struct {
    
         name  string
    
         flows map[string]testFlow
    
    }
    

根据前面的定义,我们创建一个针对不同输入和输出值组合的测试套件,以尽可能覆盖尽可能多的非重复场景:

 var testCases = []testCase{
  {
    name: "single packet",
    flows: map[string]testFlow{
      "1-1": {
        startCount:   1,
        timesSeen:    0,
        wantPosition: 0,
        wantCount:    1,
      },
    },
  },{
    name: "last packet wins",
    flows: map[string]testFlow{
      "2-1": {
        startCount:   1,
        timesSeen:    1,
        wantPosition: 1,
        wantCount:    2,
      },
      "2-2": {
        startCount:   2,
        timesSeen:    1,
        wantPosition: 0,
        wantCount:    3,
      },
    },
  },

我们在TestHeap函数的主体中将所有这些内容结合起来,在这个函数中,我们遍历所有测试用例。对于每个测试用例,我们设置其前提条件,将所有流推入堆中,并更新它们的timeSeen计数次数:

func TestHeap(t *testing.T) {
     for _, test := range testCases {
           h := make(Heap, 0)
           // pushing flow on the heap
           for key, f := range test.flows {
                      flow := &MyFlow{
                           Count: f.startCount,
                           Key:   key,
                      }
                      heap.Push(&h, flow)
                      // updating packet counts
                      for j := 0; j < f.timesSeen; j++ {
                           h.update(flow)
                      }
           }
     /* ... <continues next > ... */
}

一旦我们更新了所有流,我们就根据最高的计数,逐个从堆中移除它们,并检查结果位置和计数是否与我们描述的测试用例中的相符。如果出现不匹配,我们将使用测试包注入的*testing.T类型生成错误消息:

func TestHeap(t *testing.T) {
  /* ... < continues from before > ... */
  for i := 0; h.Len() > 0; i++ {
                f := heap.Pop(&h).(*MyFlow)
                tf := test.flows[f.Key]
                if tf.wantPosition != i {
                           t.Errorf(
                             "%s: unexpected position for packet key %s: got %d, want %d", test.name, f.Key, i, tf.wantPosition)
                }
                if tf.wantCount != f.Count {
                           t.Errorf(
                                 "%s: unexpected count for packet key %s: got %d, want %d", test.name, f.Key, f.Count, tf.wantCount)
                }
           }
}

到目前为止,我们只讨论了数据平面遥测,这是至关重要的,但不是网络监控的唯一元素。在下一节中,我们将通过构建一个完整的端到端遥测处理管道来探索网络控制平面遥测。

测量控制平面性能

大多数网络工程师熟悉pingtracerouteiperf等工具,用于验证网络数据平面的连通性、可达性和吞吐量。同时,控制平面性能通常是一个黑盒,我们只能假设我们的网络重新收敛需要多长时间。在本节中,我们旨在通过构建控制平面遥测解决方案来解决这个问题。

现代控制平面协议,如 BGP,从 IP 路由到 MAC 地址和流定义分发大量信息。随着我们网络规模的扩大,控制平面状态的变化率也在增加,用户、虚拟机和应用程序不断在不同位置和网络段之间移动。因此,了解我们的控制平面性能如何对于解决网络问题和采取任何预防措施至关重要。

下一个代码示例涵盖了我们所构建的遥测处理管道,用于监控实验室网络的控制平面性能。其核心是一个特殊的bgp-ping应用程序,允许我们测量 BGP 更新的往返时间。在这个解决方案中,我们利用以下 Go 包和应用程序的功能:

  • jwhited/corebgp:一个可插入的 BGP 有限状态机实现,允许您为不同的 BGP 状态运行任意操作。

  • osrg/gobgp:Go 中最受欢迎的 BGP 实现之一;我们用它来编码和解码 BGP 消息。

  • cloudprober/cloudprober:一个灵活的分布式探测和监控框架。

  • PrometheusGrafana:一个流行的监控和可视化软件栈。

图 10.5 – 遥测管道架构

图 10.5 – 遥测管道架构

要启动整个设置,您可以从本书 GitHub 仓库的根目录运行make bgp-ping-start(参见进一步阅读):

Network-Automation-with-Go $ make bgp-ping-start
cd ch10/bgp-ping; go build -o bgp-ping main.go
docker exec -d clab-netgo-host-3 /workdir/bgp-ping/bgp-ping -id host-3 -nlri 100.64.0.2 -laddr 203.0.113.254 -raddr 203.0.113.129 -las 65005 -ras 65002 -p
docker exec -d clab-netgo-host-1 /workdir/bgp-ping/bgp-ping -id host-1 -nlri 100.64.0.0 -laddr 203.0.113.0 -raddr 203.0.113.1 -las 65003 -ras 65000 -p
docker exec -d clab-netgo-host-2 /cloudprober -config_file /workdir/workdir/cloudprober.cfg
cd ch10/bgp-ping; docker-compose up -d; cd ../../
Creating prometheus ... done
Creating grafana    ... done
http://localhost:3000

前一个输出的最后一行显示了您可以使用它来访问部署的 Grafana 实例的 URL,其中usernamepassword都使用admin

图 10.6 – BGP ping 仪表板

图 10.6 – BGP ping 仪表板

此实例有一个预先创建的名为BGP-Ping的仪表板,该仪表板绘制了 BGP 往返时间的图表(以毫秒为单位)。

重要的是要注意,路由协议收敛和性能的问题远不止更新传播时间。其他重要因素可能包括由于暂时性事件引起的更新 churn 或 转发信息库FIB)编程时间。在这个例子中,我们关注单一维度的指标,但在现实中,您可能还想考虑其他性能指标。

测量 BGP 更新传播时间

与标准的ping类似,bgp-ping应用程序通过发送和接收探测消息工作。发送者将探测嵌入 BGP 更新消息并发送给其 BGP 邻居。我们将探测编码为自定义的 BGP 可选传递属性,这使得它可以在整个网络中透明地传播,直到它到达bgp-ping响应者之一。

bgp-ping响应者识别这个自定义传递属性并将其反射回发送者。这给发送者提供了一个度量网络中 BGP 更新传播延迟的量,然后将其报告给外部度量消费者或打印在屏幕上。

由于bgp-ping应用程序需要与真实的 BGP 堆栈进行互操作,至少它必须实现Open消息的初始交换来协商 BGP 会话功能,然后是周期性的Keepalive消息交换。我们还需要做以下事情:

  1. 由不同事件触发的发送 BGP 更新消息。

  2. 编码和解码自定义 BGP 属性。

让我们看看如何使用开源 Go 包和应用程序来实现这些要求。

事件驱动的 BGP 状态机

我们使用 CoreBGP (jwhited/corebgp) 与对等体建立 BGP 会话,并保持其活跃状态直到关闭。这为我们提供了刚刚讨论的OpenKeepalive消息。

受到流行的 DNS 服务器 CoreDNS 的启发,CoreBGP 是一个可以通过事件驱动插件扩展的最小化 BGP 服务器。

在实践中,你通过构建自定义的Plugin接口实现来扩展初始功能。该接口定义了可以在 BGP 有限状态机FSM)的某些点上实现用户定义行为的不同方法:

type Plugin interface {
     GetCapabilities(...) []Capability
     OnOpenMessage(...) *Notification
     OnEstablished(...) handleUpdate
     OnClose(...)
}

对于bpg-ping应用程序,我们只需要发送和接收 BGP 更新消息,因此我们专注于实现以下两种方法:

  • OnEstablished:发送 BGP 更新消息。

  • handleUpdate:我们使用这个函数来处理接收到的更新,识别 ping 请求,并发送响应消息。

以下图显示了该应用程序的主要功能模块:

图 10.7 – BGP Ping 设计

图 10.7 – BGP Ping 设计

让我们从检查 BGP 更新处理逻辑(handleUpdate)开始代码概述。由于我们的目标是解析和处理 BGP ping 探测,我们可以确保在代码早期就丢弃任何其他 BGP 更新。对于接收到的每个 BGP 更新消息,我们检查是否有任何 BGP 属性具有我们创建的用于表示探测或 ping 的自定义bgpPingType传递属性。我们使用continue语句静默忽略没有此属性的 BGP 更新:

import bgp "github.com/osrg/gobgp/v3/pkg/packet/bgp"
const (
     bgpPingType = 42
)
func (p *plugin) handleUpdate(
     peer corebgp.PeerConfig,
     update []byte,
) *corebgp.Notification {

     msg, err := bgp.ParseBGPBody(
           &bgp.BGPHeader{Type: bgp.BGP_MSG_UPDATE},
           update,
     )
     // process error
     for _, attr := range msg.Body.
                    (*bgp.BGPUpdate).PathAttributes {
           if attr.GetType() != bgpPingType {
                      continue
           }
     /* ... <continues next > ... */
}

一旦我们确定这是一条 BGP ping 消息,我们处理两种可能的情况:

  • 如果它是一个bgpPingType路径属性。

  • 如果是OnEstablished函数:

func (p *plugin) handleUpdate(
  peer corebgp.PeerConfig,
  update []byte,
) *corebgp.Notification {
    /* ... < continues from before > ... */
    source, dest, ts, err := parseType42(attr)
    // process error
    sourceHost := string(bytes.Trim(source, "\x00"))
    destHost := string(bytes.Trim(dest, "\x00"))
    /* ... <omitted for brevity > ... */

    // if src is us, may be a response. id = router-id
    if sourceHost == *id {
      rtt := time.Since(ts).Nanoseconds()
      metric := fmt.Sprintf(
        "bgp_ping_rtt_ms{device=%s} %f\n",
        destHost,
        float64(rtt)/1e6,
      )

    p.store = append(p.store, metric)
 return nil
    }

    p.pingCh <- ping{source: source, ts: ts.Unix()}
    return nil
}

发送 BGP 更新的事件驱动逻辑位于 OnEstablished() 方法中,该方法包含一个三路选择语句,用于监听 Go 通道上的触发器,代表 bgp-ping 应用程序的三个不同状态:

  • 响应由来自 handleUpdate 函数的请求触发的 ping 请求

  • 由外部信号触发的新的 ping 请求

  • 在探测周期结束时发送计划中的撤回消息:

func (p *plugin) OnEstablished(
  peer corebgp.PeerConfig,
  writer corebgp.UpdateMessageWriter,
) corebgp.UpdateMessageHandler {
  log.Println("peer established, starting main loop")
  go func() {
    for {
      select {
      case pingReq := <-p.pingCh:
        // Build the ping response payload
        bytes, err := p.buildUpdate(
                      type42PathAttr,
                      peer.LocalAddress,
                      peer.LocalAS,
        )
        // process error
        writer.WriteUpdate(bytes)
        /* ... < schedule a withdraw > ... */

      case <-p.probeCh:
        // Build the ping request payload
        bytes, err := p.buildUpdate(
                      type42PathAttr,
                      peer.LocalAddress,
                      peer.LocalAS,
        )
        // process error
        writer.WriteUpdate(bytes)
        /* ... < schedule a withdraw > ... */

      case <-withdraw.C:
        bytes, err := p.buildWithdraw()
        // process error
        writer.WriteUpdate(bytes)
      }
    }
  }()
  return p.handleUpdate
}

CoreBGP 的一个缺点是它不包含自己的 BGP 消息解析器或构建器。它发送任何可能混淆或甚至导致标准 BGP 堆栈崩溃的原始字节,因此始终要小心使用。

现在,我们需要一种解析和构建 BGP 消息的方法,这正是我们可以使用另一个名为 GoBGP 的 Go 库的地方。

编码和解码 BGP 消息

GoBGP 是一个完整的 BGP 堆栈,支持大多数 BGP 地址族和功能。然而,由于我们已经在使用 CoreBGP 进行 BGP 状态管理,所以我们限制 GoBGP 的使用仅限于消息编码和解码。

例如,每当我们需要构建一个 BGP 撤回更新消息时,我们调用一个辅助函数(buildWithdraw),该函数使用 GoBGP 构建消息。GoBGP 允许我们只包含相关信息,例如 网络层可达性信息NLRI)列表,同时它负责填充其余字段,如类型、长度,并构建一个语法正确的 BGP 消息:

func (p *plugin) buildWithdraw() ([]byte, error) {
     myNLRI := bgp.NewIPAddrPrefix(32, p.probe.String())
     withdrawnRoutes := []*bgp.IPAddrPrefix{myNLRI}
     msg := bgp.NewBGPUpdateMessage(
           withdrawnRoutes,
           []bgp.PathAttributeInterface{},
           nil,
     )
     return msg.Body.Serialize()
}

这里是使用 GoBGP 解析 CoreBGP 收到的消息的另一个示例。我们取一个字节数组,并使用 ParseBGPBody 函数将其反序列化为 GoBGP 的 BGPMessage 类型:

func (p *plugin) handleUpdate(
     peer corebgp.PeerConfig,
     update []byte,
) *corebgp.Notification {
     msg, err := bgp.ParseBGPBody(
           &bgp.BGPHeader{Type: bgp.BGP_MSG_UPDATE},
           update,
     )
     // process error
     if err := bgp.ValidateBGPMessage(msg); err != nil {
           log.Fatal("validate BGP message ", err)
     }

您现在可以进一步解析此 BGP 消息以提取各种路径属性和 NLRIs,正如我们在 handleUpdate 函数的早期概述中所看到的。

收集和公开指标

bgp-ping 应用程序可以作为独立进程运行,并在屏幕上打印结果。我们还想能够将我们的应用程序集成到更通用的系统监控解决方案中。为此,它需要以标准格式公开其测量结果,以便外部监控系统可以理解。

您可以通过添加一个网络服务器并发布您的指标以供外部消费者使用来实现此功能,或者您可以使用现有的工具来代表您的应用程序收集和公开指标。一个执行此操作的工具有 Cloudprober,它支持自动化和分布式探测和监控,并提供与多个外部探测的原生 Go 集成。

我们通过 serverutils 包将 bgp-ping 应用程序与 Cloudprober 集成,该包允许您通过 -c 标志在 bgp-ping 上交换探测请求和回复,它期望所有探测触发器都来自 Cloudprober,并在 ProbeReply 消息中发送其结果:

func main() {
  /* ... < continues from before > ... */
  probeCh := make(chan struct{})
  resultsCh := make(chan string)

  peerPlugin := &plugin{
              probeCh: probeCh,
            resultsCh: resultsCh,
  }

  if *cloudprober {
    go func() {
      serverutils.Serve(func(
        request *epb.ProbeRequest,
        reply *epb.ProbeReply,
      ) {
        probeCh <- struct{}{}
        reply.Payload = proto.String(<-resultsCh)
        if err != nil {
          reply.ErrorMessage = proto.String(err.Error())
        }
      })
    }()
  }
}

Cloudprober 应用程序本身作为一个预编译的二进制文件运行,并且需要最少的配置来告诉它关于 bgp-ping 应用程序及其运行时选项的信息:

probe {
  name: "bgp_ping"
  type: EXTERNAL
  targets { dummy_targets {} }
  timeout_msec: 11000
  interval_msec: 10000
  external_probe {
    mode: SERVER
    command: "/workdir/bgp-ping/bgp-ping -id host-2 -nlri 100.64.0.1 -laddr 203.0.113.2 -raddr 203.0.113.3 -las 65004 -ras 65001 -c true"
  }
}

所有测量结果都由 Cloudprober 自动以大多数流行的云监控系统可以理解的形式发布。

存储和可视化指标

在这个控制平面遥测处理管道的最后一个阶段是指标存储和可视化。Go 是这些系统的非常流行的选择,包括 Telegraf、InfluxDB、Prometheus 和 Grafana。

当前遥测处理示例包括 Prometheus 和 Grafana 及其相应的配置文件和预构建仪表板。以下配置片段将 Prometheus 指向本地 Cloudprober 实例,并告诉它每 10 秒抓取所有可用的指标:

scrape_configs:
  - job_name: 'bgp-ping'
    scrape_interval: 10s
    static_configs:
      - targets: ['clab-netgo-host-2:9313']

虽然我们在这里讨论的并不多,但构建有意义的仪表板和警报与进行测量一样重要。分布式系统可观察性是一个大话题,在现有的书籍和在线资源中得到了广泛覆盖。现在,我们将停止在 Grafana 仪表板上看到数据可视化,但不想暗示绝对值的连续线性图就足够了。很可能会想,为了做出任何合理的假设,你希望以聚合分布的形式呈现你的数据,并随着时间的推移监控其异常值,因为这会更好地表明系统压力的增加,并可能作为采取任何进一步行动的触发器。

开发分布式应用程序

构建一个分布式应用程序,例如 bgp-ping,可能是一项重大任务。单元测试和调试可以帮助发现和修复许多错误,但这些过程可能很耗时。在某些情况下,当应用程序有不同的组件时,迭代开发你的代码可能需要一些手动编排。例如,构建二进制文件和容器镜像、启动软件过程、启用日志记录和触发事件,现在是你需要同步和重复对所有包含你的应用程序的组件进行操作的事情。

本章我们将介绍的最后一种开发者体验工具是专门为解决上述问题而设计的。Tilt 帮助开发者自动化手动步骤,并且它与容器和编排平台(如 Kubernetes 或 Docker Compose)具有原生集成。你只需告诉它要监控哪些文件,它就会自动重建你的二进制文件,替换容器镜像,并重启现有进程,同时在一个屏幕上显示所有应用程序的输出日志。

它通过读取一个包含有关构建内容和如何构建的指令的特殊 Tiltfile 来工作。以下是一个 Tiltfile 的片段,它自动在一个宿主容器内启动一个 bgp-ping 进程,并在检测到 main.go 的更改时重启它:

local_resource('host-1',
  serve_cmd='ip netns exec clab-netgo-host-1 go run main.go -id host-1 -nlri 100.64.0.0 -laddr 203.0.113.0 -raddr 203.0.113.1 -las 65003 -ras 65000 -p',
  deps=['./main.go'])

完整的 Tiltfile 为我们实验室网络中的另外两个主机提供了两个更多资源。你可以使用 sudo tilt up 启动应用程序的所有三个部分:

Network-Automation-with-Go $ cd ch10/bgp-ping
Network-Automation-with-Go/ch10/bgp-ping $ sudo tilt up
Tilt started on http://localhost:10350/

Tilt 具有控制台(文本)和 Web UI,您可以使用它来查看所有资源的日志:

图 10.8 – Tilt

图 10.8 – Tilt

bgp-ping 应用程序的源代码的任何更改都会触发所有受影响资源的重启。通过自动化大量手动步骤和汇总日志,这个工具可以简化任何分布式应用程序的开发过程。

摘要

这就结束了关于网络监控的章节。我们只触及了几个选定的主题,并承认本章的主题过于广泛,无法在这本书中涵盖。然而,我们希望我们已经提供了足够多的资源、指南和想法,以便您继续探索网络监控,因为它是网络工程学科中最活跃和积极增长的一个领域。

进一步阅读

第十一章:专家见解

随着我们接近本书的结尾,我们想要做一些特别的事情。与其写一个更传统的总结章节,重复主要观点并试图展望未来,我们做了些不同的事情,希望对您来说更有趣。

我们联系了多位在网络安全自动化领域有实际操作经验的人,或者正在使用 Go 语言进行网络相关任务和活动的人,以便他们能与我们分享他们的观点。

我们希望他们的思考、经验教训、想法和观点能为你提供指导,并使你对网络行业自动化在角色和重要性方面的认识更加深刻,同时强调 Go 语言不是一种晦涩难懂、小众的语言,而是一种广泛用于各种网络相关用例的语言。

不再拖延,我们现在向您展示“专家见解”章节。

David Barroso

David 是一位在基础设施和软件工程交叉领域工作的资深工程师。他负责创建了许多开源项目,如 NAPALM、Nornir 和 Gornir。

传统上,网络空间一直非常稳定。大多数创新都是通过标准机构实现的,这些机构需要数年才能获得批准。此外,厂商还推广了具有明确和结构化学习指南和课程的认证。这意味着网络工程师有明确的职业发展路径,可以成为认证专家,而无需过多担心被误导,甚至无需费心去想下一步是什么;其他人已经为他们决定了。

然而,现在是 2022 年,我们的日常词汇已经从 MPLS-over-GRE-over-IPSec 这样的缩写词转变为 IaC、CI、PR 和 DevSecOps 等。我们那种由厂商驱动、缓慢变化、舒适的生活已经一去不复返,现在我们需要跟上最新的行业术语和我们所选择的框架/库的最新更新中的重大变化(幸运的是,目前我们不需要跟上 JavaScript 框架)。但不要绝望——选择红色药丸,准备好选择自己的道路。

对于如何跟上这个不断变化且疯狂的世界,我的建议如下:除非是工作硬性要求,否则尽量减少对厂商驱动的认证的依赖。相反,阅读像你现在正在读的这样的书籍。熟悉其中的思想和概念,不必过于担心细节。与其设置不可能的实验室场景,不如与开源项目合作,从社区中学习,学习项目开发和维护所使用的工具,流程,框架,思想等等。最后,不要感到不知所措。人们会不断提出新的术语,库,项目等等,但如果你专注于思想,你会很快注意到事情并没有他们所说的那样震撼,行业的发展也没有广告中所说的那么快。

Stuart Clark

斯图尔特是社区 AWS 的高级开发者倡导者,Cisco Press 的作者,以及 Cisco 认证的 DevNet 专家 #20220005。

公平地说,如果没有网络自动化,我现在不会处于这个位置。虽然我不是“自动化一切”这辆车的第一个乘客,但我完全承认我在 2014 年才加入这场游戏,或者至少我那时是这样感觉的。自从 2008 年开始从事网络工作以来,很多人都说他们可以自动化他们许多日常任务,但我的自负却说我仍然认为我的 CLI 更好。是什么阻止了我?主要是恐惧、失败和不知道从何开始。直到 2014 年的夏天,我才卷起袖子说:“我现在就要掌握它。”作为一个网络天才,我很容易做到这一点!不。这让我感到羞愧,我发现我不能像学习网络工程那样通过暴力学习编码。对我来说,需要一种更逻辑的方法。这最初只是每天早上大脑清醒时的一小时,那时我的客户网络问题和网络项目开始了。我经常发现我会被困在某个地方好几天。我可以完成实验室或复制示例,但理解概念我经常感到困难,所以我开始根据当天的任务制作小型项目。这通常是一个相同的脚本,但我每天都在添加和构建,添加更好的错误处理或验证。让有经验的人审阅你的工作并获得反馈也很好。过了一段时间,你的代码已经演变成整个团队现在都在使用的工具,这也启动了许多其他令人兴奋的新工作流程。这需要时间,但一年或两年后才会出现。

当有人问我关于职业、学习新事物或申请新角色的问题时,我总是问:你希望在两年后和五年后处于什么位置? 你的技能总是需要磨砺的,为此,你需要磨练你的技能并学习新事物。我们今天所准备的,不是今天,而是我们的未来,每一步都需要纪律和一致性。这就是你身上所有神奇之处所在。我不相信我们生来就拥有某种技能。当然,我们可能比其他人更快地学会某些东西。我相信我们可以成为我们想要成为的人,如果你有激情、有欲望并且愿意付出努力,你就能实现任何目标。

在你所做的一切中祝你好运。

克劳迪娅·德·卢纳

克劳迪娅在斯坦福大学毕业后,最初在 NASA JPL 工作,从事软件开发,后来转向企业网络。2006 年,她离开了 JPL,在多个领域工作,包括生物技术和金融。2015 年,在为最大的 Cisco VAR 之一工作时,她开始自动化网络工作流程。如今,她在一家精品咨询公司,企业基础设施加速公司,帮助企业 500 强公司快速部署网络和安全计划。

网络自动化真理...到目前为止...

1 – 自动化不会取代网络工程师

千万不要误会,网络工程这一学科是不会消失的。我们与设备的互动确实正处于革命之中,但关于 TCP 三次握手如何进行或路由协议如何工作这些知识,现在和将来都将是至关重要的。实际上,随着需要深入理解网络工作流程的脚本编写,这种知识的深度可能会增加。直到我编写了一个完整的数据中心 ACI 网络构建脚本,我才真正理解了思科的应用中心基础设施ACI)。

2 – 文本和版本控制的力量

这一点并不经常被提及(或从未被提及),但文本是无所不能的。它是最低的共同分母,也是传达书面语言(编程或其他)的丰富排版输出的输入。在制作一本格式丰富的书籍或计算机脚本时,你可以简单地记录文本随时间的演变。这样,你可以确切地知道每一次变化的本质。这就是版本控制。最初是为了跟踪代码变化而开发的,如今,正如网络自动化一样,你可以将配置、配置模板、状态、图表、文档和几乎所有东西都置于版本控制之下。在你开始脚本编写之前,花点时间学习 Git 和 GitHub 等版本控制系统。

当我们谈论文本时,请使用真正的文本编辑器!只有在没有其他选择的情况下(并且学习vi——见第九部分“Linux 和正则表达式”),记事本和 TextEdit 才方便。花时间熟悉更高级的文本编辑器,如 Sublime 或 Atom。

3 – 立刻开始

面对新鲜和不熟悉的事物可能会感到害怕。只需开始行动。如果你是编程新手,可以搜索有关基本编程概念或编程基础的资源。如果你不熟悉变量、作用域、运算符、控制结构和命名空间等概念,这是一个重要的步骤。

一旦你掌握了这些概念,写下你想要解决的问题,选择一种语言,然后立刻深入其中。对我来说,是生成配置。实际上,对于我学习的每一种新编程语言,我都会解决第一个问题。我只是处理文本,而不是实际设备,所以我不太可能陷入麻烦。如果你在工作中遇到一个小问题,你愿意解决,就从那里开始。明确定义问题,详细说明期望的结果,然后开始。记下具体步骤,并逐一解决。

假设你想要为 10 台设备配置相同的 VLAN 生成配置命令,为了简单起见,将每个设备上需要运行的必要命令输出到屏幕上。你的第一个脚本可能就像以下这样,简单地列出设备并打印出以下配置:

!Switch01 vlan 10
name Vlan10_on_Switch01

一旦你有了这些,你就会想要将输出保存到文本文件中。之后,你将想要为每个开关创建一个文件。之后,你将开始为每个开关进行定制。你明白了。每一次增强都会让你学到新的东西。每一个新特性都会扩展你的经验。

4 – 拥抱变化

更令人畏惧的是,你正在尝试学习新事物,但有很多东西要学!参见3 – J**ust start。从学习某事物然后放弃它以获得更好的事物中获得的经验是无价的。能够阐述你为什么喜欢一个解决方案而不是另一个,或者为什么你推荐特定的方法,将立即让你脱颖而出,并立即产生可信度。这使得你成为一个真正的工程师。

我认为尝试某事然后放弃它和尝试某事然后采纳它一样有价值。这个过程使你变得可信。它让你从那种说“你应该使用 X。为什么?嗯...因为...”的人转变为那种说“对于你试图做的事情,你应该使用 X,因为 X 具有这些特性或更容易在你的环境中得到支持或...”的人。培养阐述你为什么推荐某事物,以及为什么你不推荐某事物的能力。

那种经验,那种可信度,作为一个在男性主导的行业中的女性,对我大有裨益。我去面试或参加会议时,客户只和我的男性同事说话。那种可信度和基于事实的建议总是能赢得胜利。他们可能一开始只和我男性同事交谈,但最终他们会和我交谈。这始终是真理,而不仅仅是性别问题。

5 – 分享和打包

为自己编写代码很有诱惑力,但想想如果你赋予你的团队力量,你可以产生多大的影响。为此,当你编写脚本时,想想如果你不得不分享它们,你会如何编写它们。想想教一个没有任何编程或甚至 CLI 经验的同学执行你的一个脚本。这将让你思考如何打包你的脚本。有许多选择,包括如果你的受众是 Windows 用户,将你的脚本转换为 Windows 可执行文件,或者如果你的团队使用不同的操作系统,通过 GUI 或网页来前端你的脚本。

6 – 无限可能

在网络自动化中,很容易只关注基础设施的自动化。不要这样做!考虑一个环境,其中你的最终文档是由配置自动生成的。需要处理许多经常相似的变化控制票据?考虑一个环境,其中你的变化控制信息是由脚本生成的。关闭工作也是由脚本生成的。想要在你的文档中添加图表?考虑一个世界,其中你的图表是从新的拓扑自动生成的。需要与另一个团队接口并共享信息?如果他们只需要以一致格式共享所需信息,而不是让他们在邮件线程或令人沮丧的 Excel 电子表格中挣扎,他们会多么感激?

7 – 理解数据结构

你如何组合你的数据有着深远的影响。熟悉复杂的数据结构。这里所说的数据结构,是指列表、字典以及它们的任何组合。问问自己:如果我从字典列表中迭代或从键集合中选取数据,我的代码会更清晰吗?当这些数据结构高度嵌套时,要习惯提取所需的数据。关于这个话题的更多内容,请参阅我的文章《分解数据结构》(在“进一步阅读”部分)。

8 – 了解并使用 API

许多现代网络设备现在提供 API。这些 API 通常以结构化数据的形式返回查询结果(参见“7 – 理解数据结构”)。如果你不需要登录交换机、拉取配置或以半格式化文本显示命令,然后解析该文本,请不要这样做!使用 API。除了基础设施设备和网络设备提供的 API 之外,还有大量数据可用,通常带有开放和免费的 API。

需要查找 MAC 地址的供应商 OUI?有一个公开免费的 API 可以做到这一点。需要查找 IP 地址的物理位置?也有一个公开免费的 API 可以做到这一点。用 API 丰富你的数据、报告和信息。

9 – Linux 和正则表达式

我无法强调这一点的重要性。Unix 背景是无价的。许多基础设施设备最初都是以 Unix 或 Linux 为基础的。拥有这样的背景将使你与普通网络工程师区别开来。拥有一些 Linux 知识应包括对正则表达式的了解。因为网络自动化不可避免地需要一些解析,熟悉正则表达式将帮助你进行自己的解析,并帮助你与其它解析模块协同工作。更高级的文本编辑器理解正则表达式以方便你的搜索。

10 – 漫游和探索

最后,留出时间进行探索。我尽量每个月留出至少两个星期天早上,用来探索我听说、读到或看到的东西,或者我选择一个问题并研究解决方案。没有特定的目的地,我只是看看它将我带到哪里。一半的时间,我从一件事开始,最后基本上到了另一个星球。我打算参加一个关于 MongoDB 的 Udemy 课程,结果我试图创建一个尽可能好的正则表达式来匹配 IP 地址。我对完成这件事并不执着(至少在星期天是这样)。

亚历克斯·德·塔尔胡埃特

亚历克斯·德·塔尔胡埃特是一位热衷于网络自动化领域的专家,他总是试图通过参与开源社区来降低网络复杂性;他主要参与了由 Linux 基金会主办的 OpenDaylight (ODL) 和 Open Network Automation Platform (ONAP),在那里他担任技术指导委员会成员。

我最初是以 Java 开发者的身份开始我的职业生涯的,对网络有着极大的热情。起初,感觉非常奇怪,在并不真正理解网络的情况下构建自动化网络的系统。但经过多年的努力,我学会了在足够精通网络的基础上,围绕它构建自动化平台。这种知识可以通过构建实验室、参加研讨会,或者对于幸运的人来说,在网络运营中心工作一段时间来获得。

最让我印象深刻,并且至今仍然如此的是,如果你来自软件开发背景而不是网络工程背景,网络自动化的路径可以有多么不同。两者都有自己的缩写、流程、标准等等,然而,随着云原生、基础设施即代码、网络即代码、GitOps 等的兴起,我们看到了这两个世界都在采用类似的概念、方法和工具来进行初始配置和操作自动化的整个生命周期。因此,从高层次来看,如何进行自动化变得相当普遍,而要自动化什么仍然相对特定于领域。当我们开始这样的旅程时,我们应该真正利用这个生态系统来加速我们的自动化策略。

在我看来,网络自动化的基础是对配置进行设置,应用一个(黄金)模板,该模板具有定义良好的(类型化)参数,以及应用该配置所使用的协议。对于服务保证而言,另一个非常重要的元素是遥测的概念,用于检索运行状态并获取状态变化和状态的更新。

穿上我的开发者帽子,最重要的是网络设备/网络功能暴露的 API/合同;这些通常由设备 YANG 模型表示。主要问题是,鉴于网络是非同质化的,每个供应商都有自己的模型,并或多或少地暴露其功能。尽管在标准化网络设备的配置和监控(OpenConfig、OpenROADM 和 IETF)方面投入了大量努力,但这肯定还没有得到全面采用,因此仍然需要大量的一刀切处理。

网络自动化策略必须考虑到这一点,并相应地设计其平台以接受任何类型的网络自动化技术。当然,该平台试图抽象这种非同质化环境的程度越高,维护工作就越多,因为将设备原生 API 转换为高级业务 API 的适配层必须跟上设备升级和设备型号变化的步伐。

这提出了以下设计决策:你是否应该努力为整个网络实现一个抽象层,并维护一个与设备南向通信的适配层?

如果是的话,你最好配备一个开发团队来构建和维护这个抽象。

如果不是,我建议通过让网络工程师为每个网络服务构建黄金模板,并有一个平台来加载、版本控制和与之交互来解决该问题。这种交互可能是一个 shell 脚本、一个 Python 片段、一个 Go 程序、一个 Ansible 剧本等等:只要该平台能够暴露一个能够执行它的 REST API,对特定团队来说什么都可以。这样,网络团队就可以通过暴露 API 来自动化,并停止担心平台。保持这些黄金模板和脚本的责任就落在了他们身上。

另一个重要方面是拥有一个编排引擎,它能够定义一个使用这些特定领域 API 的工作流程。随着成熟度和治理的加强,强制执行预检查和后检查任务应成为这些工作流程的必备项。同时,始终考虑如果后检查不成功,如何回滚。在网络范围内的交易中应用和回滚配置可能会很棘手;考虑构建辅助函数以增加可重用性。

这些编排引擎可以是分布式的,也可以是集中的,但通常会有一个端到端的服务编排来消费这些公开的特定领域工作流程。

最后,需要牢记的一个关键组件是网络元素/功能的清单。一旦工作流程执行了某些操作,就非常重要地保持清单更新,以便服务保证工作流程可以正确地针对网络的活动和可用状态采取行动。

考虑到目前大多数网络自动化都是通过 NETCONF 或 gNxI 南向协议完成的,YANG 已经成为定义和表达设备配置的事实上的模型标准,围绕 YANG 的工具已经足够成熟,可以依赖 XML/JSON 作为黄金模板。无论使用什么技术,渲染这些模板都是一件容易做到的事情,即使是在强制执行 YANG 定义的类型的情况下。考虑到所有这些,当开始网络自动化之旅时,我不会提倡特定的编程/脚本语言,而是让每个团队自己管理。但我肯定会提倡尽可能标准化南向协议和交互。随着旅程的成熟,当你感觉到作为一个组织,你对某种特定技术有了更好的掌握时,那么你可以构建更多的助手,并开始提出一些公司范围内的自动化实践。

随着网络自动化领域的演变,基础设施配置也在演变。随着 Kubernetes 的兴起,最新的趋势是将 Kubernetes API 扩展以提供自定义资源定义CRD),抽象硬件和软件配置,并通过使用 Operator 支持它们的整个生命周期。Operator 将 CRD 公开为 K8S 原生 API,并包含管理 CRD 实例端到端生命周期的逻辑。这正在将操作责任转移到 Operator 提供商,并促进了以意图驱动的自动化。随着网络设备厂商采用这一概念,网络自动化将更加接近应用生命周期管理。随着这一趋势,被提出的主要编程语言之一是 Go。

值得一看的项目是 Nephio,这是 Linux 基金会的最新网络倡议,旨在通过 Kubernetes API 扩展提供网络控制器。

开心编码!

约翰·道克

约翰·道克是微软的高级软件工程师经理,前谷歌网络系统工程师(SRE),前 LucasArts/Lucasfilm 网络工程师。

在我询问 IT 总监我的下一步职业发展是什么之后,我在 LucasArts 开始了我的网络生涯。他立刻让我成为了一名网络工程师,并让我去买一本思科的书,配置我们刚刚获得的新的 T1 路由器。没有什么比在壁橱里盯着一个盒子,希望你放在头顶上的思科书通过渗透作用给你知识更令人印象深刻了。我在那里度过了接下来的几年,通过自动化我的工作(例如,重置网络 MAC 安全参数的门户,将端口移动到新的 VLAN,使用路由图自动平衡入站的 BGP 流量等等)。

我从那里转到谷歌,在那里我大部分时间都在自动化名为后端骨干B2)的供应商骨干。我编写了第一个自动化的服务,该服务可以编程各种路由器。然后,我和一些非常有才华的软件工程师(Sridhar Srinivasan、Benjamin Helsley 和 Wencheng Lu)一起建立了网络的第一套工作流程编排系统,然后我又继续构建下一个版本(因为你永远不会第一次就做对)。第一版和第二版之间最大的变化是从 Python 迁移到 Go。我们能够减少错误,将工作流程数量增加 10 倍,并使代码重构成为可能而不破坏一切。接下来几年,我将所有 NetOps 从 Python 迁移到 Go,并构建了每天配置网络的自动化(BGP 网状部署、LSP 度量、SRLG 配置部署、边缘路由器启动、BGP-LU 更新、ISIS 度量、LSP 优化等)。使这一过程可扩展的关键是我编写的一个服务,它允许发送一个 RPC 来配置我们支持的任何供应商路由器(例如配置 BGP 对等体)。

现在,我在微软工作,我不再从事网络工作,而是编写 Go SDK 并管理一个软件团队,该团队部署软件以验证数据、提供门控控制、审计数据源等。这包括运行 Kubernetes 集群、部署软件和构建运行这些系统的工具。

最后,我是《Go for DevOps》一书的作者。

如果我能给网络自动化提一条建议:使用中心化的工作流程编排系统。中心化工作流程系统的益处,包括允许了解网络中正在发生的事情、允许紧急控制和提供策略执行,已经被一次又一次地证明。

那么,我所说的中心化工作流程执行是什么意思呢?你想要一个存在的 RPC 服务,并且该服务有一系列可以执行的操作。你的工具提交一个描述操作集的 RPC,并从服务器监控其运行。

这意味着所有执行都在同一个地方运行。然后你可以构建紧急工具来停止有问题的网络执行(或者简单地暂停它们)。你可以强制限制在一段时间内可以接触多少网络设备。你可以在自动化运行之前提供网络健康检查。

中心化是控制网络自动化关键。当你在小团队中时,很容易了解正在发生的事情。当你的团队人数远远超过五人时,这开始变得不可能。

我在谷歌见证的最大两次故障中,都是由于工程师在他们的桌面电脑上运行脚本,在他们的时区外工作时改变了网络。要回溯到谁/什么导致了问题,需要扫描 TACACS 日志以找到罪魁祸首。如果脚本一直在进行持续更改,没有人能够在找到安全部门的人禁用他们的凭证之前停止它。那宝贵的时间可能意味着你的整个网络都会中断。

如果你想看看一个可以用于网络操作的基本工作流程系统,请参阅我在Go For DevOps书中的Designing for Chaos章节。

数据包必须流动!

Roman Dodin

Roman 是一位戴着诺基亚产品管理帽的网络自动化工程师。除了他的专业关系外,他还是网络自动化领域的知名开源领导者、维护者和贡献者。你可能认识他作为 Containerlab 项目的当前维护者,你将在本书提供的实践练习中遇到这个项目。

我假设你已经对 Go 有所了解,并想看看 Go 如何应用于网络自动化问题领域,或者你对为什么选择 Go 进行网络自动化感到好奇。让我分享一下我为什么曾经转向 Go,这次转变的主要驱动因素,以及为什么我认为现在是网络工程师开始关注 Go 的绝佳时机。

在深入研究 Go 之前,我使用 Python 进行所有网络自动化工作;这里没有太大的惊喜。在过去几十年里,通常的网络自动化工作流程围绕着编写/制作 CLI 命令模板,通过 SSH/Telnet 将它们发送到网络设备的 CLI 进程,解析回复并处理它们。当时,能拥有任何类型的供应商提供的 REST API 就已经很幸运了。因此,大多数自动化项目都使用了屏幕抓取库,并承受着以临时方式处理非结构化数据的痛苦。

同时,在 IT 领域,容器化、微分段和基础设施即代码(Infra-as-Code)范式的普及与 Go 语言的稳步发展相辅相成。该语言语法简洁,结合丰富的标准库、编译特性、一流的并发性和良好的性能,使得 Go 赢得了众多开发者的青睐。不久,我们见证了新生态系统的诞生——云原生计算基金会CNCF),它提出了一套新的要求,关于应用程序的部署、运行和相互交互的方式。因此,社区重新审视了网络层,以适应在以 API 优先、云原生环境下的新应用程序运行方式。

随着时间的推移,在 IT 海洋中掀起的波浪也触及了网络岛屿。如今,任何不错的网络操作系统都在其上运行一套管理 API,为任何人提供结构化和模型化的数据。现代自动化工作流程假设仅以并发、高性能和云原生的方式利用这些 API。正如你所猜想的:能够编写利用大量云原生工具和库的并发、高性能、易于部署的应用程序,这正是 Go 为网络自动化工程师提供的现成解决方案。

即使在网络领域我们拥有如此大的惯性,以网络为中心的项目生态系统也在快速发展。正如您自己会看到的,通过这本书的章节,已经为 Go 语言创建了典型的网络相关库。

网络自动化/管理领域的另一个关键参与者是 OpenConfig 联盟。由谷歌牵头,网络运营商参与,OpenConfig 构想了许多围绕 Go 语言的网络自动化项目,包括goyangygotkneondatrafeatureprofiles。那些想要了解这些项目能提供什么的人将不得不掌握 Go 语言。正如经常发生的那样,我们未来可能视为商品的工具和库正在由超大规模公司今天塑造。

总结来说,如果你的网络自动化活动具有以下任何特性,你可能需要考虑 Go 作为这项工作的工具:

  • 需要在规模上保持高性能。

  • 具有强大的并发执行用例。

  • 使用基于 YANG 模型生成的数据类。

  • 利用 Kubernetes 控制平面。

  • 与 CNCF 工具和项目集成。

  • 利用 OpenConfig 项目。

与他人一样,Go 不是终极答案或 Python/Java 等的替代品。然而,它是一种具有强大优势、庞大社区和繁荣生态系统的编程语言。在我看来,它在网络自动化领域有着光明的未来,这本书应该是对那些想要了解今天使用 Go 进行网络自动化实际方面的一个极好的辅助。

大卫·吉

大卫·吉是 Juniper Networks 的产品管理总监。他在 dave.dev(之前是 ipengineer.net)上写博客。他是 JUNOS Terraform Automation Framework(JTAF)的创造者,以及其他事物。Twitter: @davedotdev

如果你已经在网络领域建立了知识,那么你很可能已经购买了并吸收了来自Cisco Press书籍的知识。这些书籍大部分结构良好,提供像花朵一样展开的知识。对于那些想要建立自动化知识的人来说,难以找到多厂商友好的知识来源。该行业本身相对不成熟,网络工程师在网络安全域垂直发展软件技能时往往会做出非常可疑的决定。这不是网络自动化工程师的过错,而是由于行业存在的缺乏纪律。在普通的网络中,如果你配置 BGP 不当,会话可能无法建立。如果你不小心泄露了前缀,那么有人会很快纠正你的知识。下次你配置 BGP 时,你可能不会再犯同样的错误!

网络领域的软件纪律非常必要,许多组织仍处于网络自动化的初级阶段。在这个阶段的不良经历通常会对信心水平造成灾难性的影响,要么证实这太难了,要么为一次伟大的起飞铺平道路。仍然有很多人在参加训练营,多亏了 Udemy、Pluralsight 以及其他众多学习平台,今天进入软件领域比以往任何时候都要容易。这是一个有争议的话题,我在这里要小心谨慎,但软件并不是只是不断地向某个东西扔代码直到它在边缘上工作。它是一门学科,一种心态,需要严谨。

我走向十年 Go 之旅

Go 是一种伟大的语言,对许多人来说,它不仅是主要的编程语言,也是工具语言。Go 提供了一种“双重保险的方法”,即使编译器也会提醒你做正确的事情。当然,你可以写出杂乱的代码,但整个 Go 生态系统都是为了帮助你避免这样做而设计的。市场上的大多数 IDE 都拥有出色的 Go 工具,并将进一步检查和格式化你的代码,帮助你成为一个更好的开发者。Grafana Labs 的 Mat Ryer 和“Go Time”播客曾经说过:“由于 Go 工具,我可以阅读其他人的代码,感觉就像是我自己写的。”这要归功于 Go 社区将最佳实践融入工具链的方式。你可以免费获得这些。

为了娱乐,同时也是为了说明一个观点,我想分享我过去职业生涯中的一个时刻。当时我使用 C 语言(C99)编写代码,在 Microsoft Windows Notepad 中编写,将其链接,并使用单独的工具编译成二进制文件,然后需要将其烧录到嵌入式系统的 EPROM 上。我管理着数千行纯文本,当时写作时甚至没有一点线索能说明什么会起作用。测试设备有所帮助,但现实世界总是真理。有一天,我被叫到一个工业单位,我的一个系统导致一个水储备罐爆炸。在那一刻和压力之下,我设法找到了一个错误,因为我已经写下了算法,并在代码中留下了关键注释,这样我就能在压力下跟踪。优秀的工具和坚实的编码工程方法可以让你免于被解雇,甚至更糟糕的是,被起诉。如果所有的代码都是意大利面式的(其中一些确实是——我不是英雄),我可能已经被监禁了。从那时起,我们手头上有优秀的 IDE,Go 吸取了 C 语言(在我看来)的精华,为你提供了一个在其他地方找不到的开发之旅。甚至在风险生产运行之前,编译器就能告诉我关于竞争条件、指针问题以及我等待了几十年的一系列问题。

除了 IDE、编译器和 Go 工具链之外,Go 还因为诸如错误处理和期望的重复等因素,使得编写清晰、可读和可维护的代码变得容易。避免使用魔法是一个关键原则,你应该能够导入一个包,并在自己的代码中确定性地初始化它,因为 Go 社区内部有纪律。

Go 提供了如此多的开箱即用功能,新手往往容易陷入 Go 的“醉酒”状态。看到 goroutines 到处都是,以及在不必要的情况下使用 channels 是很常见的。Ardan Labs 的 Bill Kennedy 有一些关于这个主题的精彩材料,如果你认为你需要一个 goroutine,那么你很可能并不需要。在构建不需要的东西之前,使用 pprof 分析你的代码,并通过 Go 的测试能力进行一些基准测试是值得的。以最简单的形式,Go 可能会超越你的使用场景,而在早期就决定保持你的设计架构简单,将防止未来出现复杂的头痛问题。

Go 的类型系统

Go 的类型系统在处理时可能比较严格,但它提供了你绝对需要的严谨性和结构。网络操作系统通常基于结构化数据,例如 NETCONF 引擎具有从 YANG 模型化的 API 模式。通过消费.proto文件,在构建客户端代码时免费获得程序性合同对齐。同样的原则也适用于 XML、gRPC 和 GPB。有许多工具可用于构建数据结构,一些 IDE 具有从 JSON 到结构体的转换能力。在可用时使用这些工具,但永远不要放弃熵和漂移的机会。仅就这一点而言,版本控制非常重要。最后,关于数据编码和模式的一则说明,XML 丰富且程序化强大。JSON 可能是一种时尚的东西,但 XML 对于为 Junos 等平台生成配置来说非常好用。如果你对 XML 感到舒适,那么与 NETCONF 一起工作就只是一小步之遥。在用 Go 构建类型时,编码 XML 与 JSON 一样简单。以下是一个例子:

package main
import (
     "encoding/json"
     "encoding/xml"
     "fmt"
)
type DataEncodingExample struct {
     /*
           Example payload
           {
                "_key": "blah",
                "_value": "42",
                "_type": "string",
           },
     */
     Key   string `json:"_key",xml:"_key"`
     Value string `json:"_value",xml:"_value"`
     VType string `json:"_type",xml:"_type"`
}
func main() {
     dataInput := DataEncodingExample{
           Key:   "blah",
           Value: "42",
           VType: "string",
     }
     jsonEncoded, _ := json.Marshal(dataInput)
     xmlEncoded, _ := xml.Marshal(dataInput)
     // This is example code. What errors? :)
     fmt.Println("JSON Encoded: ", string(jsonEncoded))
     fmt.Println("XML Encoded: ", string(xmlEncoded))
}

输出如下:

JSON Encoded:  {"_key":"blah","_value":"42","_type":"string"}
XML Encoded:  <DataEncodingExample><Key>blah</Key><Value>42</Value><VType>string</VType></DataEncodingExample>

关于版本控制的一则说明

接下来是版本控制,它不仅对你的代码很重要,而且对 Go 的包管理系统也很重要。核心 Go 团队已经尝试了超过 10 次包管理,但截至1.13版本,Go 模块系统似乎终于做对了。如果你对go mod及其用法不熟悉,那么投入时间是很值得的。能够以正确的包确定性地重新构建 Go 程序至关重要,了解如何使用语义版本控制和go mod系统来加强你的开发习惯是值得的。在 DevOps 和 SRE 领域有许多著名的故事,讲述了一个补丁版本出错,代码变得完全不可预测。虽然这些故事在聚会时讲述起来很棒,但在当时并不有趣,而且可以通过锁定代码以使用特定版本,并相信在 CI/CD 管道或构建系统中,代码将以与开发时相同的方式重新组合来避免这种情况。

代码的增长

在进入网络领域并学习汇编语言和 C 语言之前,我是一名电子工程师,对此我感到非常感激。我发现,通过在串行端口中输入命令,我能比构建一个带有串行端口的系统赚到更多的钱。向前滚动二十年(哎呀),我的许多旧习惯仍然存在。如果我开始编写一个新的工具或软件服务,我会先构建想法的核心,而不考虑实现。这个工具使得在探索的早期阶段,无需进行大量的繁琐代码更改,就能进行实验和学习。算法似乎是自己成长的,随着时间的推移,我会嵌入到论坛和博客上找到的有用 API 代码或注释,等等:

package main
import (
     "context"
     "fmt"
     uuid2 "github.com/google/uuid"
     "github.com/sethvargo/go-envconfig"
     log "github.com/sirupsen/logrus"
)
const _VERSION = "0.0.1"
/*
This code logs into the auth service for X and then updates the remote status with the local status measurement.
It is triggered when the remote state is changed.
Each invocation generates a UUID which can be used by the ops team.
*/
type Config struct {
     APIUser string `env:"PROG1_API_USER_ID"`
     APIKey  string `env:"PROG1_API_USER_ID"`
}
// GetToken retrieves a JWT from the external auth service
func (c *Config) GetToken(URL, uuid string) (string, error) {
     // Initiate thing
     log.Info(fmt.Sprintf("system: updater, uuid: %v,
      message: logging into device with key %v\n", uuid,
      c.APIUser))
     // Imagine this is implemented!
     return "JWT 42.42.42", nil
}
func main() {
     // Set log level, normally this would be from config
     log.SetLevel(log.DebugLevel)
     // Get UUID for this instantiation
     uuid := uuid2.New().String()
     // Show the world what we are
     log.Info(fmt.Sprintf("system: updater, uuid: %v,
      version: %v, maintainer: davedotdev\n", uuid,
      _VERSION))
     ctx := context.Background()
     // Get the config from env vars
     var c Config
     if err := envconfig.Process(ctx, &c); err != nil {
           log.Fatal(err)
     }
     // GetToken will get a JWT from the thing upstream
     token, err := c.GetToken(
            "https://example.com/api/v1/auth", uuid)
     if err != nil {
           log.Fatal(err)
     }
      log.Debug(fmt.Sprintf(
      "TODO: Got token from external provider: %v\n",
      token))
     log.Debug("TODO: Got the local state")
      log.Debug(
      "TODO: Logged in to remote service with token and updated the state")
     log.Debug(
      "TODO: Update success: ID from remote update is: 42")
     log.Debug("TODO: Our work here is done.")
}

输出如下:

go build
./main
INFO[0000] system: updater, uuid: 6cb60c9b-<snip>, version: 0.0.1, maintainer: davedotdev 
INFO[0000] system: updater, uuid: 6cb60c9b-<snip>, message: logging into device with key testuser 
DEBU[0000] TODO: Got token from external provider: JWT 42.42.42 
DEBU[0000] TODO: Got the local state                    
DEBU[0000] TODO: Logged in to remote service with token and updated the state 
DEBU[0000] TODO: Update success: ID from remote update is: 42 
DEBU[0000] TODO: Our work here is done. Exit Go routines cleanly if there are any.

前面的代码中有几个项目值得提及。第一个提及是关于外部包的使用。我倾向于在一个给定的项目中标准化日志库和配置处理方法。这使得代码易于使用,在本质上具有可预测性。此外,优秀的库是持续给予的礼物。Logrus 就是这样一个很好的例子。想要 JSON?没问题。想要更改日志目标?简单。日志不仅在开发中很重要,而且在发布工具或将软件服务投入生产时也非常重要。对于低使用工具来说,有一个 UUID 系统可能看起来很愚蠢,但如果是一个每天有多次调用的软件服务,当运维告诉你跟踪你的创造物的行为是多么令人愉快时,你可以给我一个合适的礼物。

注释

注释的价值是一个古老的争论主题。请善待未来的自己或任何需要维护你代码的可怜人。如果注释只是指出显而易见的事情,那么它们就是无价值的,所以我写了一些注释风格的微小变化。他们说你写的时候要了解你的受众,对于阅读代码来说,所需的专长是对 Go 的基本理解,因此你不需要指出一个字符串就是一个字符串。以下是一些你可以包含的要点:

  • 未来提示:这是当存在一个已知瓶颈或问题可能在某个用户基础或请求速率下出现,但当时不值得解决的情况。

  • 待办事项:在探索问题空间时,留下一些心理钩子以便你可以重新定位你的思想是没有错的。随着时间的推移,随着算法变得更加具体,它们应该会减少,所以当你通过待办事项列表工作时,应该删除它们并在更大的注释块中改进解释。

  • 当事情变得复杂时,写出算法。这就像在阅读企业文档中的执行摘要。从技术备忘录的注释中理解代码试图做什么比阅读代码本身更容易,尤其是如果代码复杂且涉及递归等问题。总是值得留下一个日期,这样读者可以对照注释来核对版本。

被突然袭击

因为用 Go 编写代码会迫使你养成好习惯,它也可能让你措手不及。Go 非常强大,功能丰富,这些功能很快就被转化为看不见的防护栏。想象一下与用 Python 编写的 API 交互。想象一下,负载被编码到一个切片中,每个项目都是一个小的映射——像这样简单的东西:

[
     { 
           "key": "blah", 
           "val": 42
     }
]

立刻,我们可以看到如何进行序列化和反序列化,但一个常见的陷阱,尤其是在强类型语言和动态类型语言之间进行接口时,是数据类型管理纪律不佳。以下示例将在尝试在 Go 中序列化它时触发错误,因为类型系统,但不幸的是,这种情况很常见:

[
     {
           "key": "blah1",
           "key": 42
     },
     {
           "key": "blah2",
           "val": "42",
     },
]

一些软件工程师使用 TLV 风格的编码方式(见下文)来处理这些场景,但如果你遇到这个问题,可以使用 Go 的反射功能来检查数据,并在你的代码中以定制化的方式反序列化它。你可以使用前面的代码通过反射来实例化以下类型。这种方法已经多次救了我的命,并且在动态数据场景中特别有用,在这些场景中,像 Python 这样的语言使得操作变得非常容易。下划线用户通常是一个提示,表明这是一个 TLV 风格的数据实例,用于进程间通信:

/*
     {
           "_key": "blah",
           "_value": "42",
           "_type": "string",
     },
*/
type BadDataManagement struct {
     Key   string `json:"_key"`
     Value string `json:"_value"`
     VType string `json:"_type"`
}

Go 是一种优秀的语言,我强烈建议你在使用标准化接口(如 NETCONF、REST 和 gRPC)的同时,努力避免使用银弹式的网络 API风格包和中间件。像避免魔法这样的简单规则将在未来带来回报,而且我记忆力像筛子一样,我总是努力记住这一点。

写这个部分是一种荣幸,我相信这本书为你铺平了道路,让你能够为这个行业急需的纪律、严谨和技能发展自己的领域。如果没有提供学习路径的闪电战努力,我们将发现网络自动化领域在未来几年将严重碎片化,而这本书将极大地帮助这一旅程。非常感谢作者们让我分享这些想法。

丹尼尔·赫茨伯格

丹尼尔是 Arista Networks 的高级技术市场营销工程师。他在这个领域工作了十多年的时间,并且一直在这个网络和自动化/可编程性的门槛上。由于他在网络自动化、云原生技术和 OpenConfig 方面的成功,他每周多次在 Visual Studio Code 上编写 Go 语言。

我开始自动化时并不是从网络设备开始的,而是从网络叠加和网络安全(使用 VMware NSX)开始的。NSX 提供了太多的选项可以点击,以至于很容易破坏系统。就像一个网络人员可能会犯错并误操作交换机一样,这让我很容易在同一个网络中输入相同的 OSPF 路由器 ID...哎呀!这是一个使用 XML 编码的 REST API,使用 Python 请求与之通信。当时,大多数人使用 PowerShell 来完成这项工作,所以在这个社区中,Python 的使用也远远超出了正常范围。

快进几年后——我们开始看到很多使用供应商 API 的场景。鉴于有大量的“入门”示例,只需导入requests库并执行典型的 RESTful 操作(即发送请求并获取响应),我发现 Python 几乎无处不在。我发现使用所有正常的 Python 对象(如字典、列表、元组等)进行操作非常简单。

在每一次旅程中,你都会遇到扩展性问题,如果 Python 能满足你的需求,那么它就没有问题。我开始更多地参与到云原生项目中,比如 Kubernetes 和 OpenConfig。所有这些最终都使用了 Go。我感觉学习曲线比 Python 陡峭一些,因为网络社区对它的兴趣没有像对 Python 那样浓厚。然而,它的好处超过了我所知道的 Python 的任何东西:

  • 类型化系统

  • 编译系统

  • 并发

  • 模块(go mod非常棒,可以打开它并查看整个项目中使用了什么)

  • 无空格

  • 垃圾回收

我可能还能再补充一些,但这些都是我喜欢 Go 的原因。有了这本书的早期访问权和看到示例,我可以预见几代网络工程师会很容易地采用这本书,并用 Go 替换 Python。

Go 总体上极大地帮助了我的职业生涯,因为客户越来越多地要求用 Go 编写通用网络项目代码,包括 Kubernetes 操作员、网络自动化和 OpenConfig 流。祝你好运,网络 Goer 们!

马库斯·海因斯(Marcus Hines)

马库斯(Marcus)在其职业生涯中一直专注于网络设备测试、测试框架开发、测试自动化,以及通常询问为什么事情不能以不同的方式完成。他最初是一名网络工程师,现在他专注于其组织内的工程生产力。他帮助维护了 OpenConfig 组织的大部分仓库。

简而言之

我已经成为了 Go 在几个关键方面的强烈支持者:

  • 语言提供的工具的易用性

  • 加入项目工程师的快速上手速度

  • 编译速度和跨平台支持

  • 强类型语言用于静态分析,具有出色的构建时验证

自动化的推理

  • 测试和自动化基本上是同一件事。

测试和自动化可以简化为一系列有序的操作和验证,以将输入状态和意图转换为预期的输出状态。

  • 字节流不是一个 API。

包含供应商特定细节的 SSH 和 shell 脚本不适合异构环境。

  • 在 API 定义上的灵活性,它侧重于迭代版本和非破坏性更改。

Go 对 gRPC 有强大的第一类支持,这是一个丰富的序列化和 RPC 框架,支持大多数流行的编程语言。

  • 自动化应该始终只有一层模板和一层配置。其他所有东西都应该是代码。

  • 一个持续运行的自动化测试相当于 1,000 个手动测试。

  • 自动化系统本身需要生命周期管理。

为系统开发的第一个测试应该是如何以密封、可重复的方式安装、版本控制和拆除系统本身。

一旦拥有了这个生态系统,你就可以解锁你的开发团队,让他们快速迭代开发,同时信任他们不会使基础设施退化。

背景

我来到今天这个位置,经历了一条非常漫长和曲折的道路。

我从 TCL/Expect 和 Perl 开始我的网络自动化脚本编写。这两个生态系统至少允许一致的重复操作;然而,其他一切都是混乱的。Python 通过围绕库和版本系统添加了一个健壮的生态系统,以允许一个更封闭和可重复的世界。

然而,Python 代码库存在一些问题,这使得维护变得困难。代码本身的测试相当直接。然而,由于缺乏类型,我们经常不得不在代码中写入大量的类型验证,并且只能在运行时找到这些错误。此外,由于过分关注使用模拟来提高覆盖率数字,而没有对公共契约进行充分测试,导致测试相当脆弱,从长远来看,这减缓了开发速度。我不怪 Python 本身,但如果没有适当的工具来强制执行良好的实践,很容易陷入这种模式。

我是在 2014 年左右的一个项目上接触到 Go 语言的,对其强类型、内置工具和编译速度印象深刻。在此之前,我一直在为一个项目开发 C++测试框架。我总是对构建灵活的 C++代码感到沮丧,它已经变成了一个模板的元编程噩梦,以支持我们所有的用例。Go 通过为我们提供用例的接口定义来解决这些问题的大部分。

从那时起,我为不同的组织编写了三个基于 Go 的测试框架,它们都有不同的系统需求。第一个框架在解决方案测试方面代表了一些独特的挑战。它需要能够开源。它需要控制由四个不同团队编写的组件,这些团队使用三种不同的语言在两个不同的构建生态系统中开发代码。测试本身必须在 Linux 和 Windows 测试运行器上运行。Go 使我们能够仅使用标准的 Go 工具来编译这个生态系统。

下一个框架用于基于 Kubernetes 的云解决方案测试。由于工具和库对基于 k8s 的项目支持,我们能够迅速取得进展。我们可以利用基础设施进行集群启动、k8s 部署、操作员部署以及应用程序生命周期。

我目前参与的项目框架是 Ondatra(参见进一步阅读部分)。这个框架专注于为网络解决方案提供开源的功能性、集成性和解决方案测试框架。它目前通过功能配置文件(参见进一步阅读部分)被我的组织内部团队使用,以向供应商描述我们的网络设备需求。

影响行业的能力

我还想提到的一点是个体改变行业的能力。

这个行业长期以来一直被供应商和认为 IETF 会解决你问题的观念所主导。当涉及到自动化时,供应商缺乏帮助的动力。每个可以创建的特定于供应商的旋钮和 API 都会让操作员进一步陷入供应商解决方案,这最终转化为他们的采购订单POs)。

通过开始围绕软件自动化和 API 来塑造这个行业,我们正在将网络从一门艺术转变为计算机科学。我们正在走向网络设备不过是带有花哨网络接口卡的通用计算设备的地方。有了可以表达意图的通用 API,例如通过 gNMI 的 OpenConfig,操作员可以构建一个单一配置和遥测系统,它可以支持任意数量的供应商。有了围绕引导、安全、软件和文件管理的附加操作 API,操作员可以统一构建他们的基础设施。这变成了一层非常一致且可测试的层,然后可以用来在单元测试层单独测试北向服务和下游设备。构建一个强大的分层测试策略可以给你信心,并在你的开发周期中更快地发现故障。

不要等待他人解决你的需求;这不会发生。如果你想要某样东西,就向供应商提出要求。如果他们不做,就向标准机构提出要求。如果他们还不做,那就自己动手。不要假设你的想法是错误的,或者其他人比你更了解生态系统。进入开源世界,提出你的想法。软件开发和协作的模式在过去 20 年中发生了巨大变化,更不用说仅仅是过去 5 年了。网络自动化有许多机会开发出可以在操作员意图和网络设备状态之间进行最小转换的生态系统。

Sneha Inguva

Sneha 是 Fastly 公司网络控制和优化团队的软件工程师,同时也是 DigitalOcean 公司的前网络工程师。

我编写网络代码的旅程始于 DigitalOcean 的内部 Kubernetes 和可观察性团队,DigitalOcean 是一家云托管提供商。在我接触任何一行网络代码或配置逻辑之前,我就了解到,在一家全球规模的公司背后,存在着由数百甚至数千个服务组成的分布式系统,这些服务由许多工程师团队提供。构建和部署可维护的服务需要适当的 CI/CD 设置、监控和可操作的警报。当我过渡到在各个网络团队用 Go 编写底层网络代码时,我的经历也反映了这一点。当你编写的代码旨在部署到世界各地数千个虚拟机或服务器上,并且该代码控制着基本的数据包进出网络时,自动化是关键。在 Fastly,一个全球有多个节点的 CDN 提供商,我的这一经验得以延续。

不论是自研的网络软件还是第三方开源软件,如 BIRD 路由守护进程,我都了解到我们绝对需要能够轻松地向前或向后回滚更改。我也是可操作警报和运行手册的强烈支持者;从经验来看,与特定操作没有直接关联的嘈杂警报永远不应该被页码化。我也开始欣赏 Go 在编写网络代码时所提供的功能;与 C 语言等语言相比,使用 Go 快速迭代代码和为各种平台交叉编译应用程序要容易得多。Go 还有一个有用的网络标准库和不断增长的包生态系统,它简化了从第 2 层和包套接字到第 7 层使用 HTTP 编写代码的过程。

总结来说,如果我要给一个刚刚进入网络和 Go 软件工程领域的初学者提建议,我会说以下内容:

  • 在任何大型公司编写软件时,我的信条是保持其简单性。编写易于阅读、模块化、可扩展且文档良好的代码,这样即使一个对 Go 语言非常熟悉但对公司生态系统不熟悉的工程师也能轻松加入并做出贡献。我相信,优秀的文档和清晰、简单的代码总是优于巧妙的代码。

  • 当涉及到 CI/CD 和基础设施即代码时,有众多选项可供选择,这通常取决于具体用例。软件是否将在主机上作为二进制文件运行?能否进行容器化?我们是否在构建 Debian 软件包?无论你使用什么,确保可以轻松部署和回滚服务的版本。

  • 学习 Go 的特性和公司仓库的一些公认的最佳实践。

  • 虽然我绝对欣赏 Go 网络生态系统中的第三方包(如netaddrgobgp等),但我还是喜欢阅读代码并确认我对其功能的理解。这通常也允许我们找到错误和上游贡献。

  • 确保您已为您的服务配置了白盒监控和可操作的警报。

并且,带着这些提示,我鼓励每个人都拥抱 Gopher 生活!

安东尼奥·奥耶亚

安东尼奥·奥耶亚(Antonio Ojea)是红帽(Red Hat)的软件工程师,在那里他从事 Kubernetes 和其他开源项目的工作,主要关注云计算、网络和容器技术。他目前是 Kubernetes 和 KIND 项目的维护者和贡献者,并且过去曾为其他项目如 OpenStack 和 MidoNet 做出贡献。

在我作为专业人士的早期岁月里,我开始在一家电信公司的网络部门工作。我们负责内部网络及其服务(DNS、电子邮件、WWW 等)。当时,我们的自动化基本上包括以下内容:

  • 配置: 连接到网络设备以应用不同配置的 TCL/Expect 脚本

  • 监控: 通过 SNMP 轮询网络设备并存储数据的 Perl 脚本,存储在 轮询数据库RRD)文件中

  • cron

  • catgrepcutawksedsort 等命令,并将结果通过电子邮件发送

如果我们回顾过去,从后视镜来看,一切改善得多么令人难以置信,其演变多么有趣,尤其是在开源领域。

在 2000 年代初,开源软件正在积聚力量,Apache 许可证为 FOSS 和企业之间的互动开辟了新的途径,并且已经有几个稳定的 Linux 发行版提供了企业所需的支持、维护、安全和可靠性。

在 2000 年代,一些项目开始蓬勃发展,改进了现有的网络自动化。其中一些项目至今仍然存在:

  • 真正令人惊叹的新思科配置差异工具RANCID):监控设备配置,并使用版本控制后端,如 CVS、Subversion 或 Git 来维护更改的历史记录。

  • Nagios: 它曾是监控和警报的行业标准。

  • 仙人掌: 一个完整的网络绘图解决方案,旨在利用 RRDTool 的数据存储和绘图功能。

然而,直到 2000 年代末,开源才进入公众视野,关于免费软件许可证的规定更加明确,开源生态系统更加稳固和稳定。公司开始使用并贡献开源,被其增长和变革潜力以及与现有私有软件许可模式相比的经济效益所吸引。

在这个时期,由于企业和公司需要更加敏捷,基础设施变得更加灵活:虚拟机、容器、软件定义网络等等。所有这些变化都导致了行业的演变。这是云的开始,网络工程师开始能够通过 OpenFlow 等技术访问网络数据平面,或者通过 API 访问物理或虚拟设备配置。网络变得更加开放和可编程,为软件开发者创造了无限的机会。

我的职业生涯遵循着这一演变。我开始编写简单的脚本,并使用其他软件项目来帮助我自动化我的工作。然而,一旦你意识到你可以构建自己的工具,与他人合作添加你需要的功能,或者修复影响你的限制或错误,你就无法停止。这就是我成为 Kubernetes 贡献者和 SIG-Network 维护者的原因。没有秘密:学习、实践……重复。

现在,多亏了开源项目和协作工具的爆炸式增长,实践变得容易。每个项目都会欢迎愿意帮忙的人,或者你也可以创建自己的项目。总会有感兴趣的人。同样的事情也发生在学习上;有很多材料对每个人都是可访问的——视频、教程和博客——但我总是建议手头有一些关键书籍,不仅用于阅读,也用于咨询。好书永远不会过时。

记住,编程语言只是工具。没有一种语言可以统治所有。有些工具你可能会觉得更舒适,或者更适合某些工作或解决某些特定问题。Go 是容器生态系统的核心语言;像 Kubernetes、Docker 等主要项目都是使用 Go 构建的。如果你计划从事网络自动化和容器工作,Go 无疑是适合你的语言。

卡尔·蒙特纳里

卡尔将自己定义为前网络人士。他是一位 Python 和 Go 开发者,也是 Scrapli(go) 的创造者,这是一个在本书中使用的 Go 包。

当我最初开始参与网络自动化社区时,除了 Python 之外进行网络自动化的想法似乎有点疯狂。当然,有些人使用的是其他语言——也许他们有一些 Perl 或 Ruby,或者也许有些疯狂的人使用 C 或其他语言,但感觉 Python 通常是那个可以统治所有语言的“一统江山”。我倾向于 Python,就像许多人一样,我很快就爱上了它。Python 是一种非常棒的语言,对于像我这样没有任何编程或计算机科学背景的人来说,它为我提供了进入软件世界的惊人且相对温和的入门途径。

很长一段时间,我总觉得那些宣扬 Go 的网络自动化人士生活在幻想中!你还需要什么其他东西呢?当然,Python 的开发速度/便捷性超过了 Go 的一般速度。当然,Python 中庞大的网络自动化生态系统给了 Go 一个巨大的优势,Go 根本无法竞争!也许,我想,Go 网络自动化倡导者只有最新最酷的工具,可以 100%支持他们使用 RESTCONF 或 gRPC 所需的一切。他们可能只喝最好的手冲咖啡和啤酒,拥有令人羡慕的胡须和/或五彩斑斓、花哨的头发!

自然,这些想法都是愚蠢的,最终我开始留起一个花哨的胡须,学习 Go。开玩笑——我无法留胡须,至少不是一个令人羡慕的胡须,但我确实深入研究了 Go!

当然,我从未有过幻想 Python 真的是那个可以统治一切的“一环”,但学习一门语言已经足够困难,所以也许我只是保护我的理智,避免尝试学习另一门语言!我是否保持了理智还不清楚,但我确实觉得在过去的几年里我对 Go 学到了很多!对于像我一样正在旅途中并希望深入研究 Go 的人,以下是我会推荐的一些事情:

  • 深入 Python 的打字生态系统。mypy非常棒——你将发现你从未意识到的错误。你将学到很多关于类型的信息,而且最好的部分是:如果你的类型全部出错,你的程序仍然可以运行!作为一个狂热的类型提示爱好者,我觉得这在我转向 Go 时帮了我很多。

  • 抽出时间真正理解接口及其用法。起初,对我来说,它们只是有点笨拙的抽象基类,但当然,它们实际上远不止于此。当我们谈论接口时,确保你理解空接口以及如何使用和滥用它!

  • 停止试图继承所有事物!这对我来说(是吗?!)一直很困难——我非常喜欢继承(也许太过分了,也许现在这已经是一种禁忌了?),所以有时摆脱这种模式是一种挑战。当然,这里和那里嵌入一个结构体,但通常尽量摆脱这种继承风格的心态。

  • 让机器人(代码检查器)大声告诉你你的代码有多糟糕!我喜欢golangci-lint,这是一个运行大量代码检查器的代码检查器聚合器。获取大量错误,并通过搜索引擎工程师的方式理解错误存在的原因以及如何做得更好。虽然很烦人,但我从这种方式创建的所有错误中学到了很多!

我怀疑 Go 将继续在自动化社区中变得越来越普遍。这种语言的优点——速度、小体积、编译的二进制文件等等——很难忽视。此外,随着网络自动化生态系统的持续扩展和增长,我相信网络自动化角色将越来越以软件为中心,而不是以网络为中心,或者自动化/软件只是网络角色的附属品;随着这种情况的发生,Go 将因本书中阐述的所有原因而变得越来越重要!当然,就像 Python 不是“统治一切的那个戒指”一样,Go 也不会是,但两者都是你应该绝对拥有的工具……或者某些陈词滥调。祝 Gopher 们快乐!

布伦特·萨利文

布伦特是一位拥有 20 多年网络和计算经验的资深软件工程师。他从网络运营和架构开始,逐渐过渡到网络软件开发。他对年轻工程师进入网络行业的前景仍然充满信心。

我们见证了网络趋势的起伏,在互联网仍处于年轻的生命周期中,在几次创新周期繁荣与萧条期间,项目成功与失败。在这些重要的迭代过程中,一个将持久存在的范式转变是网络中 DevOps 实践的采用。DevOps 的核心组件是自动化。为了扩展网络自动化,重要的是要拥有既强大又不过于复杂,便于操作员使用的工具。作者们出色地阐述了为什么 Go 在过去的几年中,随着库的成熟,以及一些最大的开源项目是用 Go 编写的,已经成为基础设施编程的既定语言。

不论你是网络工程师还是经验丰富的开发者,人们常说一种特定的语言只是工具,我们不应该过分依赖某一种特定技术。虽然这个前提有一定的真实性,但在像 Go 这样的网络语言的具体情况下,我认为适合这项工作的正确工具至关重要。我们预计大量网络专业人士将转变为网络 DevOps 工程师。如果我们期待工程师技能集的重构,我们应该尽可能使这条路径变得容易。与同行语言相比,Go 的学习曲线、打包和基准性能都表现出色,使其成为新人和经验丰富的开发者进行编程和自动化的绝佳选择。

这里为那些刚开始网络编程和自动化旅程的人们提供一些建议:

  • 拥抱开源。

  • 学习 Linux 和 Linux 网络。

  • 选择 Go 等语言开始编程。

  • 熟悉开源自动化工具,如 Ansible 和 Jinja。

  • 学习如何使用 Git 及其对配置管理可能产生的影响。

  • 从一个只读项目开始,这样在你熟悉自动化和编码的同时,不会对网络造成损害。例如,网络监控/遥测或配置管理/备份是相对安全的起点。

  • 通过编程方式提高对网络状态的理解。停止使用后视镜驾驶!

  • 了解当前的开发者工具和部署机制(Kubernetes、容器、流行库等)。

  • 探索如何为您的网络创建 CI/CD 管道。

开始将您的网络配置视为代码。自动化的故障越来越多地成为一些最近高调故障的根源。利用您的运营经验,创建测试和保障措施,以防止那些没有网络背景的人在进行自动化时可能不会意识到的常见错误。网络工程师不是濒危物种;理解网络的工作原理以及如何大规模构建网络需要多年的时间。通过结合新的学科,如编程,它使您在连接今天日益复杂的网络环境方面更具价值。

最后,您的目标应该是确保网络不会成为业务速度的阻碍。需要几周时间才能实施的网络更改必须成为过去式。当然,说起来容易做起来难,因为网络正常运行时间是,并将始终是,网络团队将被评判的第一项指标。如果我看任何项目、部署或产品,成功的是那些我们简化了复杂性并使其变得稍微简单一些的项目。随着网络专业人士的不断发展,功能强大且易于使用的工具,如 Go,结合自动化项目将是关键推动力。最后,不要害怕失败。找到你的优势,克服你的弱点。网络是一艘大船,很难驾驭,但我坚信我们在自动化方面正朝着正确的方向前进。

马克西米利安·威尔海姆

马克西米利安(Max—Wilhelm)是一位整体(网络)自动化倡导者,试图将软件工程方法引入网络自动化,并帮助克服供应商锁定。

他早期就对网络、IPv6 和路由产生了兴趣,是一位热衷于开源的爱好者、共同创始人、维护者和贡献者,是开源和网络会议的常客,FrOSCon 网络轨道的创始人,以及 virtualNOG.net 会议的联合主持人。

他目前在 Cloudflare 担任网络自动化工程师,同时作为高级基础设施顾问兼职工作。他的第二职业是作为广泛自动化的 Freifunk Hochstift 社区网络的负责人,他在那里使用 ifupdown2 以及 ifupdown-ng、VXLAN、Linux VRFs、BGP 和 OSPF,以及使用 Salt Stack 进行基础设施自动化,并且自那时起就害怕商业 SDN 解决方案。

一点历史

由于我来自 Linux 管理员/系统工程师的背景,自从 2004 年初在帕德伯恩大学数学研究所的 IT 中心的第一份工作以来,我就习惯了拥有自制的自动化解决方案来管理大量——当时对我来说是大量——的服务器和客户端。

我们有一个本地开发的软件套件,叫做 SDeployment——如果我记得正确的话,是用 Shell 编写的——它负责将正确的软件包和期望的配置文件状态部署到基于 Linux 的服务器和客户端,并强制执行期望状态保持不变。

这甚至帮助检测到一个入侵者,他成功交换了sshd二进制文件,该二进制文件不支持 Kerberos,因此他需要更改sshd_config,这个配置在 1 小时后被覆盖,服务再也没有启动。

当时,这比 CFEngine 等解决方案具有巨大的优势,这些解决方案可以对配置文件进行增量更改,但不能整体维护;Puppet 那时还没有出现(根据维基百科)。

随着 Bcfg2、Puppet、Chef、Salt 和 Ansible 的兴起,我们看到整个行业从增量配置更改转向基于意图的配置管理,其中操作员描述了期望状态(意图),并编写模板来生成整个配置文件的内容,而配置管理解决方案的任务就是使这一状态成为现实并保持这种状态。

思维转变至整体自动化

系统工程/SRE 领域很早就经历了这种思维方式的转变,但感觉大多数网络自动化解决方案仍然遵循着对现有路由器和交换机进行增量更改的想法,同时,这些设备也可能被操作员手动管理,他们通过在 CLI 中输入(或复制)魔法咒语来操作。

这使得设备配置成为同步点,我们实际上没有检查设备的情况下,真的不知道这个配置将是什么样子。

我认为我们作为网络(自动化)工程师,需要效仿,进行整体方法的思维转变,让 Perl、Shell 和 Expect 脚本保持原样,并将软件工程方法引入网络自动化。这样,我们才能在抽象层面上解决手头的问题,构建可以推理、自行测试并且能够扩展到我们需求的解决方案(参见第五章网络自动化)。

对于配置管理的最大难题,这意味着将一些系统连接起来,构建一个生成并拥有完整设备配置的解决方案。

自动化可能需要依赖多个输入来获取拓扑结构、操作覆盖、订阅者和服务的全面知识,以及从所有这些中推导出配置的规则。

这是为了遵循总体目标,尽可能少地进行配置更改,并利用诸如 BGP 和 BMP 之类的协议在需要更多动态更改的地方提取/观察状态或操纵设备状态。

这是方法

拥有所有这些,你需要的唯一设备 API 是一个上传新完整配置的功能,并让设备确定从当前配置到新配置的路径。

处理整个舰队中的配置差异部分,仔细清理旧的配置 X 的方法,进行增量更改,并找出如何与平台 API、NETCONF 方言、YANG 等交互,这些都将成为过去——那不是很好吗?

我相信我们面前有一个光明的未来!

正是这本书和 Go 的伟大和鼓舞人心之处!

使用 Go,你有一个非常坚实的基础来构建可靠、可扩展、并且相对容易测试和观察的软件。Prometheus 集成触手可及。

这样,你可以构建工具来监控你的网络(例如通过 BMP 或流遥测),通过 BGP 注入路由,或者构建自己的整体网络配置生成器和部署管道,如前所述。

现有的开源套件,如 Bio-Routing,可以帮助你在第一部分(使用 BMP/RIS)中,并作为构建遵循您业务逻辑的路由注入器的基石。

你正在阅读这篇文章的事实表明,你正在考虑构建自己的自动化解决方案来解决你组织的需要——那太好了!

如果可以的话,请将其作为开源分享,并在您当地的 NOG 或 VirtualNOG 上进行展示,这样其他人也能从中受益和学习。祝你好运!

马特·奥斯沃特

马特是 Cloudflare 的系统工程师,在那里他从事代理和控制平面系统的工作。他在 https://oswalt.dev 上写博客,偶尔在 Twitter 上以@Mierdin 的身份发帖。

我很感激在我生命中大约同一时间接触到了软件开发以及像网络这样的基础设施技术。虽然我在高中时在我的 TI-82 计算器上玩弄了类似 BASIC 的语言(好吧,玩弄这个词有点夸张——我在几何学不及格的同时创建了一个基本的 Galaga 克隆),并且在上大学之前只学过一学期的 Visual Basic 编程,但直到大学我才第一次接触到 Linux、网络和现代编程环境。

在接下来的几年里,我在看似相当孤立的技术领域之间来回跳动。这样做往往让我觉得自己在每件事上都是新手,在每件事上都不是专家。我有过不少焦虑的时刻,担心我在职业生涯中做的事情不正确。然而,回顾起来,这可能是我能得到的最好的经验。它让我感到不舒服,在这种状态下,我磨练了我最珍视的技能,那就是学习能力。这种技能有滚雪球效应——有一个正式的学习体系让我有信心尝试新的、更具挑战性的事情,这通常迫使我在学习过程中更加严格和高效,等等。

这些天,有许多东西要学习,虽然可能很有诱惑力去学习所有这些,但我们无法做到。我仍在努力的是寻找那些真正影响我的职业和行业的技能。在我的经验中,那些具有持久力的技术和技能并不总是那些在社交媒体或 GitHub 上获得炒作或明星的——通常,这些是更基础的技术或思维方式,让你能更快地理解那些想法的最新表现形式。

如果你刚刚开始你的职业生涯,或者如果你觉得自己可能有点停滞不前,但不确定该往哪里去,希望以下建议对你有所帮助:

  • 保持好奇心。学习的工作永远不会结束。不要过于专注于获得认证 X 或能在简历上添加技术 Y——这些都是短暂的。相反,为建立一个持续改进的学习体系感到自豪,并磨练你高效获取新技能的能力。

  • 我们在生活中和职业生涯中往往紧紧抓住的东西,往往是令人痛苦的干扰。将关键的少数与琐碎的多数区分开来,专注于能让你做出最高贡献的事情。做好几件事情远比创造大量平庸的工作要好。

  • 有许多更多的高技能工程师正在构建高效、可扩展的系统,但你永远不会听说他们;然后,还有人在社交媒体上发布关于技术 X 的内容,并得到 所有点赞。社交媒体上大多数关于技术的热门观点都不值得用来传输它们的比特数。

  • 学习曲线最陡峭的技术技能往往(但并非总是)能带来最大的回报。务必非常小心,不要基于一项技术可能被采用/接近的程度做出限制你职业生涯的技术决策;通常,改变行业的创新在最初可能不会提供完美的用户体验,而对于那些不等待完美用户手册的人来说,机会要多得多。同时,也不要陷入认为越复杂或越难学,就一定越好的陷阱。就像生活中大多数事情一样,真相可能就在中间某个地方。

  • 没有任何技术是万能的;它们都是基于特定的权衡来设计的,包括 Go。如果你还没有找到这些权衡,那么你可能还没有足够深入地研究。作为工程师,你的工作是理解这些权衡,并选择一个最适合你当前情况所希望做出的权衡的技术。

快乐学习!

进一步阅读

附录:构建测试环境

本书每一章都包含 Go 代码示例,以说明我们在文本中提出的一些观点。您可以在本书的 GitHub 仓库中找到所有这些 Go 程序(见本章的进一步阅读部分)。虽然您不必执行所有这些程序,但我们相信手动运行代码并观察结果可能有助于巩固所学内容并解释更细微的细节。

本书的前一部分,第一章第五章,包括相对简短的代码示例,您可以在 Go Playground(进一步阅读)或任何已安装 Go 的计算机上运行。有关安装 Go 的说明,您可以参考第一章或遵循官方的下载和安装程序(进一步阅读)。

本书其余部分,从第六章开始,假设您能够与虚拟拓扑交互,我们通过containerlab进一步阅读)在容器中运行它。本附录记录了构建测试环境的过程,包括containerlab的兼容版本和其他相关依赖项,以确保您在本书的任何章节中运行示例时都能获得无缝体验。

什么是测试环境?

主要目标是构建一个具有正确硬件和软件组合的环境,以满足执行代码示例的最小要求。我们基于您部署虚拟机VM)的假设,因为我们意识到您可能不会在专用的裸机服务器上部署它。

当涉及到部署虚拟机进行测试(测试平台)时,您有两个选择,我们将在后面讨论:

  • 您可以在自托管环境中部署此虚拟机,例如 VMware 或基于内核的虚拟机KVM)。

  • 您可以使用云托管环境——例如,亚马逊网络服务AWS)。

从硬件角度来看,我们假设底层 CPU 架构是 64 位 x86,我们建议至少为虚拟机分配 2 个 vCPU 和 4GB 的 RAM,理想情况下加倍以提高速度。

我们在本书的 GitHub 仓库中描述了所有软件配置和配置(进一步阅读)。我们强烈建议您使用我们为您准备的自动化方法来安装所有依赖项,以便运行书中的代码示例。

您仍然可以在任何 Linux 发行版上安装这些软件包——例如,Windows Subsystem for Linux 版本 2WSL 2)。如果您想手动进行安装,我们在此处包含了一个完整的依赖项列表:

版本
Go 1.18.1
containerlab 0.25.1
Docker 20.10.14
ansible-core(仅适用于第七章 2.12.5
Terraform(仅适用于第七章 1.1.9

表 12.1 – 软件依赖

第 1 步 – 构建测试环境

在下一节中,我们描述了构建测试环境的两种自动化方法。如果您不确定哪个选项适合您,我们建议您选择第一个,因为它具有最少的依赖项,并且完全由云服务提供商管理。这也是我们(本书的作者)唯一可以测试和验证的选项,因此它应该为您提供最一致的经验。

选项 1 – 云托管

我们选择 AWS 作为云服务提供商,因为它在我们的行业中非常受欢迎,并且普遍熟悉。在本书的 GitHub 仓库 (进一步阅读) 中,我们包含了一个 Ansible 剧本,该剧本完全自动化了在 AWS 中创建虚拟机所需的所有任务。您可以使用任何其他云服务提供商,但您将不得不手动进行配置。

测试环境是一个在 AWS 运行的单个 Linux 虚拟机,使用 containerlab 创建基于容器的网络拓扑。下一张图展示了 AWS 环境的样子:

图 12.1 – 目标环境

图 12.1 – 目标环境

为了符合之前声明的硬件要求,我们建议您至少运行一个 t2.medium-大小的虚拟机,理想情况下运行一个 t2.large-大小的虚拟机(弹性计算云EC2)实例)。但是,AWS 免费层计划 (进一步阅读) 不包括这些实例类型,因此您应该预计会因虚拟机的运行而产生一些费用。我们假设您熟悉 AWS 的成本和计费结构,并在使用云托管环境时使用财务常识。

在您运行剧本之前,您需要确保您满足以下要求:

  1. 创建一个 AWS 账户(AWS 免费层 (进一步阅读))。

  2. 创建一个 AWS 访问密钥(AWS 程序化访问 (进一步阅读))。

  3. 一个具有以下软件包的 Linux 操作系统:

    • Git

    • Docker

    • GNU Make

在所有这些准备就绪后,您可以使用 git clone 命令继续克隆本书的 GitHub 仓库 (进一步阅读):

$ git clone https://github.com/PacktPublishing/Network-Automation-with-Go

在您克隆了仓库之后,切换到该目录。

输入变量

在您开始部署之前,您需要提供您的 AWS 账户凭证(AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY)。您可以通过导出一对包含密钥 ID 和密钥值的环境变量来完成此操作,如下所示。有关如何创建访问密钥的说明,请参阅 AWS 程序化访问 (进一步阅读):

$ export AWS_ACCESS_KEY_ID='…'
$ export AWS_SECRET_ACCESS_KEY='…'

除了这些必需的变量之外,还有其他三个可选输入变量,您可以调整这些变量以微调您的部署环境:

名称
AWS_DISTRO fedoraubuntu(默认:fedora
AWS_REGION AWS 区域之一(默认:us-east-1
VM_SIZE AWS 实例类型之一(默认:t2.large

表 12.2 – 测试虚拟机选项

如果您选择更改这些默认值,您可以像创建 AWS 访问密钥一样进行操作。以下是一个示例:

$ export AWS_DISTRO=ubuntu
$ export AWS_REGION=eu-west-2

在那种情况下,我们选择了 Ubuntu 作为虚拟机的 Linux 发行版,并将伦敦(eu-west-2)作为部署的 AWS 区域。

部署过程

一旦你设置了所有必需的输入变量,你可以部署测试环境。在书仓库目录内,运行 make env-build 命令,该命令将部署虚拟机并安装所有必需的软件包:

Network-Automation-with-Go$ make env-build
AWS_ACCESS_KEY_ID is AKIAVFPUEFZCFVFGXXXX
AWS_SECRET_ACCESS_KEY is **************************
Using /etc/ansible/ansible.cfg as config file
PLAY [Create EC2 instance] *************************************************************************************************************************************************************
TASK [Gathering Facts] *****************************************************************************************************************************************************************
ok: [localhost]
### ... <omitted for brevity > ... ###
TASK [Print out instance information for the user] *************************************************************************************************************************************
ok: [testbed] => {}
MSG:
['SSH: ssh -i lab-state/id_rsa fedora@ec2-54-86-51-96.compute-1.amazonaws.com\n', 'To upload cEOS image: scp -i lab-state/id_rsa ~/Downloads/cEOS64-lab-4.28.0F.tar fedora@ec2-54-86-51-96.compute-1.amazonaws.com:./network-automation-with-go\n']
PLAY RECAP *****************************************************************************************************************************************************************************
localhost                  : ok=28   changed=9    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0   
testbed                    : ok=36   changed=24   unreachable=0    failed=0    skipped=11   rescued=0    ignored=0

假设 Playbook 已成功完成,你可以在日志中看到虚拟机访问详情,如前面的输出所示。你还可以在部署环境后运行 make env-show 命令来查看连接详情:

Network-Automation-with-Go$ make env-show
fedora@ec2-54-86-51-96.compute-1.amazonaws.com

现在,你可以使用这些信息来连接到已配置的虚拟机。Playbook 生成一个 lab-state/id_rsa),所以请务必始终使用它进行 SSH 认证:

Network-Automation-with-Go$ ssh -i lab-state/id_rsa fedora@ec2-54-86-51-96.compute-1.amazonaws.com 
fedora@testbed:~$  go version
go version go1.18.1 linux/amd64
fedora@testbed:~$  ls network-automation-with-go/
LICENSE  Makefile  README.md  ch01  ch02  ch03  ch04  ch05  ch06  ch07  ch08  ch09  ch10  ch12  lab-state  topo-base  topo-full

你可以连接到虚拟机并检查已安装的 Go 版本,并查看书仓库的文件。

选项 2 – 自托管

另一个选项是在私有环境中创建虚拟机。这个环境可以是运行虚拟机管理程序(如 VirtualBox、ESXi 服务器、OpenStack 集群或其他任何可以分配虚拟机运行实验室拓扑所需的 CPU 和内存的设备)。虚拟机上的操作系统必须是 Ubuntu 22.04 或 Fedora 35。

一旦你构建了带有 SSH 服务的虚拟机,确保你可以通过虚拟机的 IP 地址 SSH 连接到它,并使用其凭证访问。然后,将个人电脑上此书 GitHub 仓库的 ch12/testbed 文件夹(进一步阅读)中的 Ansible inventory 文件(inventory)更改为指向你的虚拟机。它应该看起来像这样:

# inventory
[local-vm]
192.168.122.18
[local-vm:vars]
ansible_user=fedora
ansible_password=fedora
ansible_sudo_pass=fedora

至少包括到达虚拟机的 IP 地址(ansible_host),以及 ansible_useransible_passwordansible_ssh_private_key_file 用户凭证。

在相同的 ch12/testbed 文件夹(进一步阅读)中,有一个 Ansible Playbook 调用 configure_instance 角色。使用此 Playbook 自动配置你的虚拟机以运行书中的示例,如下所示:

# configure-local-vm.yml
- name: Configure Instance(s)
  hosts: local-vm
  gather_facts: true
  vars_files:
    - ./vars/go_inputs.yml
    - ./vars/clab_inputs.yml
    - ./vars/aws_common.yml
  roles:
    - {role: configure_instance, become: true}

Playbook 文件名为 configure-local-vm.yml,inventory 文件名为 inventory,因此从 ch12/testbed 文件夹(进一步阅读)中运行 ansible-playbook configure-local-vm.yml -i inventory -v 以准备虚拟机。

第 2 步 – 上传容器镜像

并非所有网络供应商都使访问基于容器的 网络操作系统(NOSes)变得简单。如果你不能直接从容器注册库(如 Docker Hub)拉取镜像,你可能需要从他们的网站下载镜像并将其上传到测试虚拟机。在撰写本书时,书中唯一无法从公共注册库拉取的容器镜像是我们无法拉取的 Arista 的 cEOS 镜像。在这里,我们描述了将此镜像上传到测试环境的过程。

你需要做的第一件事是从arista.com下载镜像(进一步阅读)。你应该从 4.28(F)系列中选择 64 位 cEOS 镜像——例如,cEOS64-lab-4.28.0F.tar。你可以使用生成的 SSH 私钥通过scp命令将镜像复制到测试虚拟机:

Network-Automation-with-Go$ scp -i lab-state/id_rsa ~/Downloads/cEOS64-lab-4.28.0F.tar fedora@ec2-54-86-51-96.compute-1.amazonaws.com:./network-automation-with-go
cEOS64-lab-4.28.0F.tar                        100%  434MB  26.6MB/s   00:16

然后,通过 SSH 连接到实例,并使用docker命令导入镜像:

Network-Automation-with-Go$ ssh -i lab-state/id_rsa fedora@ec2-54-86-51-96.compute-1.amazonaws.com
fedora@testbed:~$  cd network-automation-with-go 
fedora@testbed:~$  docker import cEOS64-lab-4.28.0F.tar ceos:4.28
sha256:dcdc721054804ed4ea92f970b5923d8501c28526ef175242cfab0d1 58ac0085c

你现在可以在拓扑文件中一个或多个路由器的image部分使用这个镜像(ceos:4.28)。

第 3 步 – 与测试环境交互

我们建议你在第六章第八章的开始处使用虚拟网络拓扑的新构建。为了编排拓扑,我们使用containerlab,它在测试虚拟机中可用。containerlab提供了一种快速运行基于它们在可读 YAML 文件中提供的定义的任意网络拓扑的方法。

重要提示

containerlab是用 Go 语言编写的,是一个交互式 CLI 程序的优秀示例,它可以编排本地容器资源。

你可以在本书 GitHub 仓库的topo-base目录中找到以下base拓扑定义文件(进一步阅读):

name: netgo
topology:
  nodes:
    srl:
      kind: srl
      image: ghcr.io/nokia/srlinux:21.6.4
    ceos:
      kind: ceos
      image: ceos:4.28.0F
      startup-config: ceos-startup
    cvx:
      kind: cvx
      image: networkop/cx:5.0.0
      runtime: docker
  links:
    - endpoints: ["srl:e1-1", "ceos:eth1"]
    - endpoints: ["cvx:swp1", "ceos:eth2"]

此 YAML 文件定义了一个三节点拓扑,如下一个图表所示。一个节点运行诺基亚 SR Linux,另一个运行 NVIDIA Cumulus Linux,最后一个运行 Arista cEOS。在这种情况下,所有网络设备都使用它们的默认启动配置,在每个章节中,我们都描述了如何在这三个设备之间建立完整的端到端可达性:

图 12.2 – “Base”网络拓扑

图 12.2 – “Base”网络拓扑

接下来的两个章节(第九章第十章)依赖于前面拓扑的略微不同版本。与base拓扑不同,full拓扑完全配置并包含一组额外的节点来模拟连接到网络设备的物理服务器:

图 12.3 – “Full”网络拓扑

图 12.3 – “Full”网络拓扑

这些终端主机运行不同的应用程序,它们与现有的网络拓扑进行交互。

启动虚拟网络拓扑

你可以使用containerlab二进制文件来部署测试拓扑。为了方便,我们包含了一些make目标,你可以使用:

  • make lab-base来创建在第六章第八章中使用的base拓扑。

  • 使用make lab-full创建在第九章第十章中使用的full拓扑。

以下是如何在测试虚拟机内部创建base拓扑的示例:

fedora@testbed network-automation-with-go$ make lab-base
...
+---+-----------------+--------------+--------------
| # | Name            | Container ID | Image
+---+-----------------+--------------+--------------
| 1 | clab-netgo-ceos | fe422727f351 | ceos:4.28.0F
| 2 | clab-netgo-cvx  | 85e5b9135e1b | cx:5.0.0
| 3 | clab-netgo-srl  | 00106bef1d4e |srlinux:21.6.4
+---+-----------------+--------------+--------------

现在,你已经准备好了clab-netgo-ceosclab-netgo-cvxclab-netgo-srl路由器。

连接到设备

containerlab使用 Docker 运行容器。这意味着我们可以使用标准的 Docker 功能来连接到设备——例如,你可以使用docker exec命令在容器内启动任何进程:

fedora@testbed:~$  docker exec -it clab-netgo-srl sr_cli
Welcome to the srlinux CLI.                      
A:srl# show version | grep Software
Software Version  : v21.6.4

在前面的示例中,sr_cli 是 SR Linux 设备的 CLI 进程。下表显示了每个虚拟网络设备的“默认 shell”进程:

NOS 命令
Cumulus Linux bashvtysh
SR Linux sr_cli
EOS Cli

表 12.3 – 设备默认 shell

您也可以使用 SSH 连接到默认 shell。下表提供了连接到每个设备的主机名和相应的凭证:

设备 用户名 密码
clab-netgo-srl admin admin
clab-netgo-ceos admin admin
clab-netgo-cvx cumulus cumulus

表 12.4 – 设备凭证

例如,这是您连接到 Arista cEOS 和 Cumulus Linux 的方法:

fedora@testbed:~$  ssh admin@clab-netgo-ceos
(admin@clab-netgo-ceos) Password: admin
ceos>en
ceos#exit
fedora@testbed:~$
fedora@testbed:~$  ssh cumulus@clab-netgo-cvx
cumulus@clab-netgo-cvx's password: cumulus
Welcome to NVIDIA Cumulus (R) Linux (R)
cumulus@cvx:mgmt:~$

一旦您完成本章,您可以销毁拓扑。

销毁网络拓扑

您可以使用 make cleanup 命令清理虚拟网络拓扑:

fedora@testbed:~/network-automation-with-go$ make cleanup

make cleanup 命令仅清理虚拟网络拓扑,而所有云资源仍在运行。

第 4 步 – 清理云托管环境

一旦您完成与云托管测试环境的工作,您可以清理它,这样您就不会为可能不再需要的东西付费。您可以使用另一个 Ansible playbook 来确保您之前创建的所有 AWS 资源现在都被清除:

etwork-Automation-with-Go$ make env-delete
AWS_ACCESS_KEY_ID is AKIAVFPUEFZCFVFGXXXX
AWS_SECRET_ACCESS_KEY is **************************
PLAY [Delete EC2 instance] *************************************************************************************************************************************************************
TASK [Gathering Facts] *****************************************************************************************************************************************************************
ok: [localhost]
### ... <omitted for brevity > ... ###
TASK [Cleanup state files] *************************************************************************************************************************************************************
changed: [localhost] => (item=.region)
changed: [localhost] => (item=.vm)
PLAY RECAP *****************************************************************************************************************************************************************************
localhost                  : ok=21   changed=8    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0

进一步阅读

posted @ 2025-09-06 13:43  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报