Go-网络编程-全-
Go 网络编程(全)
原文:
zh.annas-archive.org/md5/ab21cd965e71667fc5a40d6bec8ad9ff译者:飞龙
前言

随着互联网的到来,对网络工程师和开发者的需求日益增加。如今,个人电脑、平板、手机、电视、手表、游戏系统、车辆、常见家居用品甚至门铃都可以通过互联网进行通信。网络编程使这一切成为可能。而安全的网络编程则使其值得信赖,促使越来越多的人采纳这些服务。本书将教你如何使用 Go 的异步功能编写现代网络软件。
Google 于 2007 年创建了 Go 编程语言,旨在提高开发者在处理大型代码库时的生产力。自那时以来,Go 已经赢得了作为一种快速、高效、安全的语言的声誉,被全球一些最大型公司用于软件的开发和部署。Go 易于学习,拥有丰富的标准库,特别适合利用多核和网络系统的优势。
本书详细介绍了网络编程的基础,重点是安全性。你将学习套接字级编程,包括 TCP、UDP 和 Unix 套接字,使用 HTTPS 和 HTTP/2 等应用层协议进行交互,使用 Gob、JSON、XML 和协议缓冲等格式进行数据序列化,为你的网络服务执行身份验证和授权,创建流和异步数据传输,编写 gRPC 微服务,执行结构化日志记录和仪表化,并将应用部署到云端。
在我们完成这次学习之旅后,你应该能够熟练使用 Go 语言及其标准库,并利用流行的第三方包设计和实现安全的网络应用和微服务。每一章都会使用最佳实践,并包含一些智慧的点滴,帮助你避免潜在的陷阱。
本书的读者群体
如果你想学习如何使用标准协议在网络上安全地共享数据,同时编写稳定、安全和高效的 Go 代码,那么这本书适合你。
目标读者是那些关注安全的开发者或系统管理员,他们希望深入学习网络编程,并且已经具备 Go 和 Go 模块支持的基础知识。尽管如此,前几章会介绍基本的网络概念,因此网络编程的新人也欢迎阅读。
在设计和开发网络应用时,跟上现代协议、标准和最佳实践可能会非常困难。这就是为什么在本书的学习过程中,你将承担越来越多的责任。同时,你也将接触到一些工具和技术,帮助你管理工作负载。
安装 Go
要跟随本书中的代码进行学习,请安装最新稳定版本的 Go,可以通过 golang.org/ 进行下载。对于本书中的大多数程序,你至少需要 Go 1.12。然而,本书中的某些程序仅与 Go 1.14 或更新版本兼容。书中会指出这些代码的使用。
请记住,你操作系统的包管理器中提供的 Go 版本可能比最新的稳定版本要滞后。
推荐的开发环境
本书中的代码示例大多数与 Windows 10、Windows 子系统 Linux、macOS Catalina 以及现代 Linux 发行版(如 Ubuntu 20.04、Fedora 32 和 Manjaro 20.1)兼容。书中会指出任何与这些操作系统不兼容的代码示例。
一些用于测试网络服务的命令行工具,例如curl或nmap,可能不是操作系统标准安装的一部分。你可能需要通过使用与你的操作系统兼容的包管理器来安装这些命令行工具,例如 macOS 的Homebrew或 Windows 10 的Chocolatey。现代 Linux 操作系统的包管理器应包含更新的二进制文件,允许你执行代码示例。
本书内容
本书分为四个部分。第一部分将教你一些在开始编写网络软件之前需要了解的基础网络知识。
-
第一章:网络系统概述介绍了计算机网络组织模型,以及带宽、延迟、网络层和数据封装的概念。
-
第二章:资源定位与流量路由教你如何通过人类可读的名称识别网络资源,设备如何通过地址定位网络资源,以及流量如何在网络节点之间路由。
本书的第二部分将帮助你将新学到的网络知识付诸实践,并教你如何编写使用 TCP、UDP 和 Unix 套接字进行通信的程序。这些协议允许不同的设备通过网络交换数据,并且是你将遇到或编写的大多数网络软件的基础。
-
第三章:可靠的 TCP 数据流深入探讨了传输控制协议(TCP)的握手过程、数据包序列号、确认、重传以及其他确保可靠数据传输的特性。你将使用 Go 来建立并通过 TCP 会话进行通信。
-
第四章:发送 TCP 数据详细介绍了几种通过 TCP 在网络上传输数据的编程技术,包括数据代理、网络流量监控以及避免常见的连接处理错误。
-
第五章:不可靠的 UDP 通信介绍了用户数据报协议(UDP),并将其与 TCP 进行对比。你将了解这两者之间的差异如何影响你的代码,以及在网络应用中何时使用 UDP。你将编写代码,与使用 UDP 的服务交换数据。
-
第六章:确保 UDP 可靠性通过一个实践示例,演示如何使用 UDP 在网络上传输可靠的数据。
-
第七章:Unix 域套接字 向您展示了如何通过基于文件的通信,在同一节点上高效地交换网络服务之间的数据。
本书的第三部分介绍了应用层协议,如 HTTP 和 HTTP/2。您将学习如何使用 TLS 安全地与服务器、客户端和 API 进行网络交互。
-
第八章:编写 HTTP 客户端 使用 Go 的优秀 HTTP 客户端发送请求并从服务器接收资源,进行万维网交互。
-
第九章:构建 HTTP 服务 演示了如何使用处理程序、中间件和复用器,用极少的代码构建功能强大的基于 HTTP 的应用程序。
-
第十章:Caddy:一款现代 Web 服务器 介绍了一个名为 Caddy 的现代 Web 服务器,它通过模块和配置适配器提供安全性、性能和可扩展性。
-
第十一章:使用 TLS 保障通信安全 提供了将身份验证和加密集成到应用程序中的工具,包括客户端与服务器之间的相互身份验证。
第四部分展示了如何将数据序列化为适合网络交换的格式;深入了解您的服务;并将代码部署到亚马逊 Web Services、谷歌云和微软 Azure。
-
第十二章:数据序列化 讨论了如何在使用不同平台和语言的应用程序之间交换数据。您将编写程序,使用 Gob、JSON 和协议缓冲区进行数据序列化和反序列化,并使用 gRPC 进行通信。
-
第十三章:日志记录与度量 介绍了提供服务运行状况洞察的工具,使您能够主动解决潜在问题并从故障中恢复。
-
第十四章:迁移到云端 讨论了如何在亚马逊 Web Services、谷歌云和微软 Azure 上开发和部署无服务器应用程序。
第一部分
网络架构
第一章:网络系统概述

在数字时代,越来越多的设备通过计算机网络进行通信。计算机网络是两个或更多设备(或节点)之间的连接,使每个节点能够共享数据。这些连接本身并不可靠或安全。幸运的是,Go 的标准库及其丰富的生态系统非常适合编写安全、可靠的网络应用程序。
本章将为你提供本书练习所需的基础知识。你将学习网络的结构以及网络如何使用协议进行通信。
选择网络拓扑
网络中节点的组织结构被称为其拓扑。网络的拓扑可以简单到两个节点之间的单一连接,也可以复杂到节点之间没有直接连接但仍能交换数据的布局。通常,这种情况适用于你计算机与互联网节点之间的连接。拓扑类型分为六大类:点对点、菊花链、总线、环形、星形和网状。
在最简单的网络中,点对点,两个节点共享一个连接(图 1-1)。这种类型的网络连接并不常见,但在需要两个节点之间直接通信时非常有用。

图 1-1:两个节点之间的直接连接
一系列点对点连接形成了菊花链。在 图 1-2 中的菊花链中,来自节点 C 的流量如果要传递到节点 F,必须经过节点 D 和 E。源节点和目标节点之间的中间节点通常称为跳点。在现代网络中,你不太可能遇到这种拓扑结构。

图 1-2:通过菊花链连接的点对点段
总线拓扑节点共享一个公共的网络链接。虽然有线总线网络不常见,但这种拓扑类型驱动了无线网络。处于有线网络中的节点可以看到所有的流量,并根据流量是否为它们所接收的内容,选择性地忽略或接受流量。当节点 H 在总线图 图 1-3 中向节点 L 发送流量时,节点 I、J、K 和 M 会接收到流量,但会忽略它。只有节点 L 会接受数据,因为它是预定的接收方。尽管无线客户端可以看到彼此的流量,但流量通常是加密的。

图 1-3:总线拓扑中连接的节点
环形拓扑曾在一些光纤网络部署中使用,它是一个封闭的环路,其中数据朝一个方向流动。例如,在图 1-4 中,节点 N 可以通过节点 O、P 和 Q 将信息发送到目标节点 R。节点 O、P 和 Q 会转发信息,直到它到达节点 R。如果节点 P 未能转发信息,它将永远无法到达目标。由于这种设计,最慢的节点会限制数据传输的速度。假设流量是顺时针流动的,而节点 Q 是最慢的,节点 Q 会减缓从节点 O 到节点 N 的数据流量。然而,从节点 N 到节点 O 的流量不受节点 Q 的慢速影响,因为这些流量并不经过节点 Q。

图 1-4:按环形排列的节点,数据朝一个方向流动
在星型拓扑中,一个中央节点与所有其他节点建立了单独的点对点连接。你可能会在有线网络中遇到这种拓扑。中央节点,如图 1-5 所示,通常是一个网络交换机,它接收来自源节点的数据,并将数据重新传输到目标节点,类似于邮政服务。增加节点只需将它们连接到交换机。数据在这种拓扑中只能进行一次跳跃。

图 1-5:与中央节点连接的节点,中央节点处理节点间的流量
每个完全连接的网状网络中的节点都与其他所有节点直接连接(图 1-6)。这种拓扑消除了单点故障的风险,因为单个节点的故障不会影响网络中其他节点之间的流量。另一方面,随着节点数量的增加,成本和复杂性也会增加,使得这种拓扑在大规模网络中不可行。这是另一种你可能只在较大无线网络中遇到的拓扑。

图 1-6:网状网络中相互连接的节点
你还可以通过将两种或更多基本拓扑结合起来创建混合网络拓扑。现实中的网络很少只由一种拓扑组成。相反,你很可能会遇到混合拓扑。图 1-7 展示了两个例子。星型环形混合网络是多个环形网络与一个中央节点相连。星型总线混合网络是通过结合总线和星型网络拓扑形成的分层拓扑。

图 1-7:星型环形与星型总线混合拓扑
混合拓扑旨在通过利用每种拓扑的优势,同时将每种拓扑的缺点限制在单独的网络段内,从而提高可靠性、可扩展性和灵活性。
例如,图 1-7 中星环混合网络中中央节点的故障只会影响环间通信。每个环形网络将继续正常运行,尽管它们与其他环形网络隔离。环形网络中单个节点的故障,在星环混合网络中的诊断要比单一大型环形网络更为容易。此外,故障只会影响整体网络的一个子集。
带宽与延迟
网络带宽是指我们在一段时间内能够通过网络连接发送的数据量。如果您的互联网连接被宣传为100Mbps 下载,这意味着理论上您的互联网连接应该能够每秒从互联网服务提供商(ISP)向调制解调器传输最多 100 兆位的数据。
ISP 通过大量广告向我们宣传他们提供的带宽,以至于我们很容易将带宽与连接速度等同起来。然而,速度更快并不总是意味着性能更好。尽管这似乎违背直觉,但低带宽的网络连接可能表现得比高带宽连接更好,原因在于一个特性:延迟。
网络延迟是指发送网络资源请求与接收响应之间的时间间隔。延迟的一个例子是点击网站上的链接和网站呈现结果页面之间的延迟。你可能曾经历过点击链接后页面无法加载,直到你的浏览器放弃等待服务器的回复。这种情况发生在延迟大于浏览器等待回复的最大时间时。
高延迟会对用户体验产生负面影响,导致攻击使您的服务无法被用户访问,并驱使用户远离您的软件或服务。网络软件中延迟管理的重要性常常被软件开发人员低估。不要陷入认为带宽是优化网络性能的唯一因素的陷阱。
网站的延迟来源于多个方面:客户端与服务器之间的网络延迟、从数据存储中获取数据所需的时间、在服务器端编译动态内容的时间,以及网页渲染所需的时间。如果用户点击了一个链接,而页面渲染的时间过长,用户很可能不会等待结果,延迟将导致流量流失。保持最低的延迟,同时编写网络软件,无论是 Web 应用程序还是应用程序接口,都将通过改善用户体验和提高应用程序在热门搜索引擎中的排名带来回报。
你可以通过几种方式解决常见的延迟问题。首先,你可以通过使用内容分发网络(CDN)或云基础设施,将你的服务放置在离用户更近的地方,从而减少用户和服务之间的距离和跳数。优化请求和响应的大小将进一步减少延迟。在网络应用程序中引入缓存策略能显著提高性能。最后,利用 Go 的并发性来最小化服务器端的响应阻塞也有帮助。我们将在本书的后续章节中重点讨论这一点。
开放系统互联参考模型
在 1970 年代,随着计算机网络日益复杂,研究人员创建了开放系统互联(OSI)参考模型,以标准化网络通信。OSI 参考模型作为协议开发和沟通的框架。协议是确定网络中数据传输格式和顺序的规则和程序。例如,使用传输控制协议(TCP)的通信要求消息接收方回复确认收到消息。否则,TCP 可能会重新传输该消息。
尽管 OSI 如今不再像以前那样重要,但熟悉它仍然很有价值,因为它能帮助你理解常见的概念,比如低层次的网络和路由,尤其是在硬件相关的内容上。
OSI 参考模型的分层结构
OSI 参考模型将所有网络活动分为一个严格的层级结构,由七层组成。OSI 参考模型的可视化表示,如图 1-8 所示,将这些层排列成一个堆栈,第 7 层位于顶部,第 1 层位于底部。

图 1-8:OSI 参考模型的七层
很容易将这些层的名称解读为独立的代码单元。实际上,它们描述的是我们归属给软件部分的抽象。例如,你无法将第 7 层库直接集成到软件中。但你可以说你编写的软件实现了一个第 7 层的服务。OSI 模型的七层如下:
-
第 7 层——应用层 你的网络应用程序和库最常与应用层交互,应用层负责识别主机并获取资源。Web 浏览器、Skype 和 BT 客户端是应用层的例子。
-
第 6 层——表示层 表示层在数据下行时为网络层准备数据,在数据上行时为应用层提供数据。加密、解密和数据编码是第 6 层功能的例子。
-
第五层—会话层 会话层管理网络中节点之间连接的生命周期。它负责建立连接、管理连接超时、协调操作模式并终止连接。某些第七层协议依赖于第 5 层提供的服务。
-
第四层—传输层 传输层控制并协调两个节点之间的数据传输,同时保持传输的可靠性。保持传输可靠性包括纠正错误、控制数据传输速度、分块或分段数据、重传丢失的数据以及确认接收到的数据。如果接收方没有确认接收到数据,本层协议通常会重传数据。
-
第三层—网络层 网络层负责在节点之间传输数据。它允许你将数据发送到一个网络地址,而不需要与远程节点建立直接的点对点连接。OSI 并不要求这一层的协议提供可靠的传输或报告传输错误给发送方。网络层包含了涉及路由、寻址、多播和流量控制的网络管理协议。下一章将讨论这些内容。
-
第二层—数据链路层 数据链路层处理两个直接连接的节点之间的数据传输。例如,数据链路层促进了计算机到交换机的传输,以及交换机到另一台计算机的传输。本层的协议会识别并尝试纠正物理层上的错误。
-
数据链路层的重传和流量控制功能取决于底层的物理介质。例如,以太网不会重传错误数据,而无线网络会重传。这是因为以太网网络上的比特错误较少,而无线网络中则较为常见。如果该层无法保证数据传输的可靠性,网络协议栈中更高层的协议通常可以确保传输的可靠性,尽管效率较低。
-
第一层—物理层 物理层将网络栈中的比特转换为适合底层物理介质的电信号、光信号或无线电信号,并将物理介质中的信号转换回比特。此层控制比特率。比特率是数据传输的速度限制。一千兆比特每秒的比特率意味着数据可以在源和目的地之间以每秒最多 10 亿比特的速度传输。
在讨论网络传输速率时,一个常见的误解是使用每秒字节数而不是每秒比特数。我们计算每秒可以传输的零和一,或者说是比特的数量。因此,网络传输速率是以每秒比特数来衡量的。讨论传输的数据量时,我们使用每秒字节数。
如果你的 ISP 宣传的下载速度是 100Mbps,这并不意味着你能在一秒钟内下载一个 100MB 的文件。实际上,在理想的网络条件下,可能需要接近八秒的时间。可以说,我们在 100Mbps 的连接上最多可以每秒传输 12.5MB。
使用数据封装发送流量
封装是一种隐藏实现细节或仅向接收方提供相关细节的方法。可以把封装想象成像通过邮政服务发送的包裹。我们可以说信封封装了其内容。在这个过程中,信封可能会包括目的地址或其他关键细节,这些信息会被用于包裹的下一段旅程。包裹的实际内容并不重要;只有包裹上的细节对于运输才是重要的。
当数据向下移动堆栈时,它会被下层封装。我们通常称沿着堆栈向下移动的数据为 有效负载,虽然你可能会看到它被称为 消息体。文献中使用的术语是 服务数据单元(SDU)。例如,传输层封装了来自会话层的有效负载,而会话层又封装了来自表示层的有效负载。当有效负载向上移动堆栈时,每一层都会去除来自上一层堆栈的头部信息。
即使是在单一 OSI 层中运行的协议也使用数据封装。例如,以 超文本传输协议(HTTP/1)第 1 版为例,这是一个第 7 层协议,客户端和服务器都使用它来交换网页内容。HTTP 定义了一个完整的消息,包括客户端从其第 7 层发送到服务器第 7 层的头部信息;网络堆栈将客户端的请求传递到 HTTP 服务器应用程序。HTTP 服务器应用程序发起响应,返回其网络堆栈,后者创建一个第 7 层有效负载并将其发送回客户端的第 7 层应用程序(图 1-9)。
客户端与服务器之间同一层的通信被称为 水平通信,这一术语让人感觉像是客户端上的单层协议直接与服务器上的对应层进行通信。实际上,在水平通信中,数据必须从客户端的堆栈一路向下,再返回服务器的堆栈。
例如,图 1-10 展示了一个 HTTP 请求如何遍历堆栈。
通常,一个有效负载会从客户端的网络栈向下传输,通过物理介质到达服务器,然后再向上传送至服务器的网络栈中的相应层。结果是,从源节点的某一层发送的数据最终到达目标节点的同一层。服务器的响应会沿相同的路径朝相反方向传输。在客户端一侧,第六层接收第七层的有效负载,然后使用头部封装有效负载,形成第六层的有效负载。第五层接收第六层的有效负载,添加自己的头部,并将其有效负载传递给第四层,在这里我们会介绍第一个传输协议:TCP。

图 1-9:客户端与服务器之间的横向通信

图 1-10:HTTP 请求从客户端的第七层到达服务器的第七层
TCP 是一个第四层协议,其有效负载也称为 段 或 数据报。TCP 接受第五层的有效负载,在发送段到第三层之前,会在其前面添加一个头部。第三层的 互联网协议**(IP) 接收到 TCP 段,并将其封装上一个头部,形成第三层的有效负载,即 数据包。第二层接收数据包,添加头部和尾部,生成其有效负载,称为 帧。第二层的头部将接收方的 IP 地址转换为 媒体访问控制**(MAC) 地址,这是分配给节点网络接口的唯一标识符。其尾部包含 帧校验序列**(FCS),这是一个校验和,用于辅助错误检测。第一层接收第二层的有效负载(以比特流的形式),并将比特流发送到服务器。
服务器的第一层接收比特流,将其转换为帧,并将帧发送到第二层。第二层去除帧的头部和尾部,将数据包传递给第三层。每一层的封装逆向过程一直持续到有效负载到达第七层。最后,HTTP 服务器从网络栈接收客户端的请求。
TCP/IP 模型
与此同时,正当研究人员在开发 OSI 参考模型时,美国国防部的国防高级研究计划局(DARPA)也在推动一项平行的工作,旨在开发协议。这一努力最终产生了一组我们现在称之为 TCP/IP 模型 的协议。该项目对美国军方,以及随后对全球通信的影响深远。TCP/IP 模型在 1990 年代初,微软将其纳入 Windows 95 时达到了关键的转折点。今天,TCP/IP 已在计算机网络中无处不在,它也是本书中我们将使用的协议栈。
TCP/IP——得名于传输控制协议(Transmission Control Protocol)和互联网协议(Internet Protocol)——促进了基于端到端原则设计的网络,其中每个网络段只包含足够的功能来正确传输和路由比特;所有其他功能都属于端点,即发送方和接收方的网络堆栈。与此相对的是现代蜂窝网络,在这些网络中,更多的网络功能必须由基站之间提供,以允许手机连接在基站之间跳转而不切断电话通话。TCP/IP 规范建议实现应具有鲁棒性;它们应发送格式良好的数据包,但应接受任何意图明确的数据包,无论该数据包是否遵循技术规范。
与 OSI 参考模型类似,TCP/IP 依赖于层封装来抽象功能。它由四个命名层组成:应用层、传输层、互联网层和链路层。TCP/IP 的应用层和链路层概括了它们在 OSI 中的对应层,如图 1-11 所示。

图 1-11:四层 TCP/IP 模型与七层 OSI 参考模型的比较
TCP/IP 模型将 OSI 的应用层、表示层和会话层简化为单一的应用层,主要因为 TCP/IP 的协议经常跨越 OSI 第 5 层到第 7 层的边界。同样,OSI 的数据链路层和物理层对应于 TCP/IP 的链路层。TCP/IP 和 OSI 的传输层及网络层有一一对应的关系。
这种简化的出现是因为研究人员首先开发了原型实现,然后正式标准化了最终的实现,导致了一个面向实际应用的模型。另一方面,委员会花费了大量时间制定 OSI 参考模型,以应对广泛的需求,然后才有人创建实现,这使得该模型的复杂性增加。
应用层
与 OSI 的应用层类似,TCP/IP 模型的应用层直接与软件应用程序交互。我们编写的大多数软件都使用这一层的协议,当你的网页浏览器检索网页时,它会读取堆栈中的这一层。
你会注意到,TCP/IP 的应用层涵盖了三个 OSI 层。这是因为 TCP/IP 没有定义具体的表示层或会话层功能。相反,具体的应用协议实现关注这些细节。如你所见,一些 TCP/IP 应用层协议很难完全符合 OSI 模型中的单一上层,因为它们具有跨越多个 OSI 层的功能。
常见的 TCP/IP 应用层协议包括 HTTP、文件传输协议 (FTP) 用于节点之间的文件传输,以及 简单邮件传输协议 (SMTP) 用于向邮件服务器发送电子邮件。动态主机配置协议 (DHCP) 和 域名系统 (DNS) 也在应用层中运行。DHCP 和 DNS 分别提供地址分配和名称解析服务,使其他应用层协议能够运行。HTTP、FTP 和 SMTP 是提供 TCP/IP 应用层中表示或会话功能的协议实现示例。我们将在后续章节中讨论这些协议。
传输层
传输层 协议处理两个节点之间的数据传输,类似于 OSI 的第 4 层。这些协议可以通过确保从源节点发送的所有数据完全且正确地到达目的地来帮助确保 数据完整性。请记住,数据完整性并不意味着目的地会收到我们通过传输层发送的所有分段。由于网络中丢包的原因太多,不能保证每个分段都会到达目的地。但这意味着 TCP 会特别确保目的地收到的数据是按正确顺序排列的,不会有重复数据或丢失数据。
本书中你将使用的主要传输层协议是 TCP 和 用户数据报协议 (UDP)。正如在第 10 页的《使用数据封装发送流量》一节中提到的,这一层处理的是分段或数据报。
我们的大多数网络应用程序依赖于传输层协议来处理每个分段的错误校正、流量控制、重传和传输确认。然而,TCP/IP 模型并不要求每个传输层协议都必须满足所有这些元素。UDP 就是一个例子。如果你的应用需要使用 UDP 以实现最大吞吐量,那么责任在你自己,必须实现某种错误检查或会话管理,因为 UDP 本身不提供这些功能。
互联网层
互联网层 负责将上层数据包路由从源节点传递到目的节点,通常跨越多个物理介质异构的网络。它的功能与 OSI 的第 3 层网络层相同。(有些资料可能将 TCP/IP 的互联网层称为 网络层。)
互联网协议版本 4 (IPv4)、互联网协议版本 6 (IPv6)、边界网关协议 (BGP)、互联网控制消息协议 (ICMP)、互联网组管理协议 (IGMP) 和 互联网协议安全 (IPsec) 套件等,提供主机标识和路由功能,作用于 TCP/IP 的互联网层。我们将在下一章讨论这些协议,届时我们将涉及主机寻址和路由。现在,请了解这一层在确保我们发送的数据能够到达目的地时起着至关重要的作用,不论源头和目的地之间的复杂性如何。
链路层
链路层对应于 OSI 参考模型的第 1 层和第 2 层,是核心 TCP/IP 协议与物理媒体之间的接口。
链路层的地址解析协议**(ARP)将节点的 IP 地址转换为其网络接口的 MAC 地址。链路层在将数据帧传递到物理网络之前,会将 MAC 地址嵌入每个数据帧的头部。我们将在下一章讨论 MAC 地址及其路由意义。
并非所有的 TCP/IP 实现都包括链路层协议。年长的读者可能还记得通过模拟调制解调器使用电话线路连接互联网的乐趣。模拟调制解调器通过串行连接到 ISP(互联网服务提供商)。这些串行连接并没有通过串行驱动程序或调制解调器实现链路层支持。相反,它们需要使用链路层协议,如串行线路互联网协议**(SLIP)或点对点协议**(PPP),来填补这一空白。不实现链路层的通常依赖于底层的网络硬件和设备驱动程序来承担这部分任务。本书中将使用的以以太网、无线网络和光纤网络为基础的 TCP/IP 实现依赖于设备驱动程序或网络硬件来完成 TCP/IP 协议栈中的链路层部分。
你所学到的
在本章中,你学习了常见的网络拓扑结构以及如何结合这些拓扑以最大化它们的优势并最小化它们的劣势。你还了解了 OSI 和 TCP/IP 参考模型、它们的层次结构以及数据封装。你应该已经能够熟练掌握每一层的顺序以及数据如何从一层传递到另一层。最后,你了解了每一层的功能以及它在网络节点之间发送和接收数据时所扮演的角色。
本章的目标是让你掌握足够的网络知识,以便理解下一章的内容。然而,深入探讨这些主题非常重要,因为对网络原理和架构的全面了解有助于你设计更好的算法。我将在本章涵盖的每个主要主题后推荐一些额外的阅读材料,以帮助你入门。我还建议在完成本书中的一些例子后,重新回顾本章内容。
OSI 参考模型可以在线阅读,链接地址为www.itu.int/rec/T-REC-X.200-199407-I/en/。有两篇《请求评论》(RFC)文献—旨在描述互联网技术的详细出版物—概述了 TCP/IP 参考模型:RFC 1122 和 RFC 1123(tools.ietf.org/html/rfc1122/ 和 tools.ietf.org/html/rfc1123/)。RFC 1122 涵盖了 TCP/IP 模型的前三层,而 RFC 1123 描述了应用层及支持协议,如 DNS。如果你想要更全面的 TCP/IP 模型参考,查尔斯·M·科泽罗克(Charles M. Kozierok)的《TCP/IP 指南》(No Starch Press,2005 年)无疑是一个不错的选择。
网络延迟困扰了无数网络应用,并催生了一个行业。一些 CDN 提供商在延迟话题以及改进服务时遇到的有趣问题上写了大量文章。提供有见地的 CDN 博客包括 Cloudflare 博客(blog.cloudflare.com/)、KeyCDN 博客(www.keycdn.com/blog/)和 Fastly 博客(www.fastly.com/blog/)。如果你纯粹是想了解更多关于延迟及其来源的信息,可以从维基百科上的“延迟(工程学)”([en.wikipedia.org/wiki/Latency_(engineering)](https://en.wikipedia.org/wiki/Latency_(engineering)))和 Cloudflare 的术语表(www.cloudflare.com/learning/performance/glossary/what-is-latency/)开始。
第二章:资源位置和流量路由

要编写有效的网络程序,您需要了解如何使用人类可读的名称来标识互联网节点,这些名称如何被转换成网络设备可用的地址,以及流量如何在互联网上的节点之间传递,即使它们位于地球的两端。本章将涵盖这些主题以及更多内容。
我们将首先看看 IP 地址如何标识网络上的主机。然后我们将讨论路由,即在没有直接连接的网络主机之间发送流量,并介绍一些常见的路由协议。最后,我们将讨论域名解析(将人类可读名称转换为 IP 地址的过程)、DNS 的潜在隐私影响以及克服这些隐私问题的解决方案。
您需要理解这些主题,以便提供全面的网络服务,并定位您的服务所使用的资源,例如第三方应用程序编程接口(APIs)。这些信息还应帮助您调试代码中可能遇到的网络中断或性能问题。例如,假设您提供一个服务,该服务集成了 Google Maps API 来提供互动地图和导航。您的服务需要正确定位 API 端点并将流量路由到该端点。或者,您的服务可能需要通过 Amazon S3 API 将档案存储在 Amazon Simple Storage Service (S3) 桶中。在每个示例中,名称解析和路由都发挥着至关重要的作用。
互联网协议
互联网协议(IP)是一组规则,规定了在网络上传输数据的格式——特别是互联网。IP 地址在 TCP/IP 协议栈的互联网层中标识网络上的节点,您可以使用它们来促进节点之间的通信。
IP 地址的功能与邮政地址相似;节点通过将数据包发送到目标节点的 IP 地址来将数据包发送到其他节点。就像邮政邮件中通常会包括一个回邮地址一样,数据包头也包括了源节点的 IP 地址。某些协议要求确认成功送达,目标节点可以使用源节点的 IP 地址来发送送达确认。
有两种版本的 IP 地址在公共使用中:IPv4 和 IPv6。本章将涵盖这两者。
IPv4 地址
IPv4 是第四版互联网协议。它是 1983 年在互联网的前身 ARPANET 上使用的第一个 IP 版本,也是今天最常用的版本。IPv4 地址是 32 位数字,按八位(称为八位字节)一组分为四组,并以十进制点分隔。
32 位数字的总范围限制了我们只能拥有略多于 40 亿个可能的 IPv4 地址。图 2-1 显示了 IPv4 地址的二进制和十进制表示。

图 2-1:以二进制和十进制格式表示的四个 8 位字节,构成一个 IPv4 地址
图 2-1 的第一行展示了一个 IPv4 地址的二进制形式。第二行是该 IPv4 地址的十进制等价。我们通常在展示 IPv4 地址时,或在代码中使用时,使用更易读的十进制格式。我们将在本节稍后讨论网络寻址时,使用它们的二进制表示。
网络 ID 和主机 ID
组成一个 IPv4 地址的 32 位表示两个组件:网络 ID 和主机 ID。网络 ID通知负责传送数据包到达目的地的网络设备关于下一跳的适当位置。这些设备被称为路由器。路由器就像网络中的邮件投递员,它们从设备接收数据,检查目的地址的网络 ID,并决定将数据发送到何处以到达目的地。你可以把网络 ID 想象成邮寄地址中的邮政编码。
一旦数据到达目标网络,路由器使用主机 ID将数据传递给特定的接收方。主机 ID 就像你的街道地址。换句话说,网络 ID 标识的是一组节点,其地址属于同一网络。我们将在本章稍后看到网络和主机 ID 的样子,但图 2-2 展示了共享相同网络 ID 的 IPv4 地址。

图 2-2:共享相同网络 ID 的节点组
图 2-3 展示了 32 位 IPv4 地址中常见的网络 ID 和主机 ID 大小的分解。

图 2-3:常见的网络 ID 和主机 ID 大小
IPv4 地址的网络 ID 部分总是从最左边的位开始,其大小由它所属的网络的大小决定。剩余的位指定主机 ID。例如,IPv4 地址的前 8 位代表一个 8 位网络中的网络 ID,而剩余的 24 位代表主机 ID。
图 2-4 展示了 IP 地址 192.168.156.97 被划分为网络 ID 和主机 ID。这一 IP 地址属于一个 16 位网络。这告诉我们,前 16 位构成网络 ID,剩余的 16 位构成主机 ID。

图 2-4:从 16 位网络中的 IPv4 地址推导出网络 ID 和主机 ID
为了推导出这个示例的网络 ID,你需要取前 16 位,并为剩余的位补充零,形成 32 位的网络 ID 192.168.0.0。然后,你将零填充到最后 16 位,得到 32 位的主机 ID 0.0.156.97。
IPv4 地址的子网划分
IPv4 的网络 ID 和主机 ID 允许您细分或划分超过 40 亿个 IPv4 地址到更小的组中,以保持网络的安全性并使其更易于管理。所有这些较小网络中的 IP 地址,称为子网,共享相同的网络 ID,但具有独特的主机 ID。网络的大小决定了主机 ID 的数量,因此也决定了网络中 IP 地址的数量。
确定单独的网络可以让您控制网络之间信息流动的方式。例如,您可以将网络分为一个用于公共服务的子网和一个用于私有服务的子网。然后,您可以允许外部流量访问公共服务,同时防止外部流量访问私有网络。另一个例子是,您的银行提供在线银行、客户支持和手机银行等服务。这些是公共服务,您在成功认证后可以与之交互。但您无法访问银行的内部网络,那里管理着电子转账、余额账本、内部邮件等系统。这些服务仅通过私有网络供银行员工访问。
使用 CIDR 分配网络
您可以使用一种叫做无类域间路由(CIDR)的方法来分配网络。在 CIDR 中,您通过将一个网络前缀附加到每个 IP 地址上,指示网络 ID 中有多少位,网络前缀由一个斜杠和一个整数组成。尽管它附加在 IP 地址的末尾,但它被称为前缀而不是后缀,因为它表示 IP 地址的前几个最重要的位,或者说是前缀位,构成了网络 ID。例如,您可以将图 2-4 中的 IP 地址 192.168.156.97 写作 CIDR 表示法中的 192.168.156.97/16,表示它属于一个 16 位网络,并且网络 ID 是 IP 地址的前 16 位。
从那里,您可以通过应用子网掩码推导出网络 IP 地址。子网掩码在其十进制表示中编码了 CIDR 网络前缀。它们通过位与运算(bitwise AND)应用于 IP 地址,以推导出网络 ID。
表 2-1 详细列出了最常见的 CIDR 网络前缀、对应的子网掩码、每个网络前缀的可用网络,以及每个网络中的可用主机数量。
表 2-1:CIDR 网络前缀长度及其对应的子网掩码
| CIDR 网络前缀长度 | 子网掩码 | 可用网络 | 每个网络的可用主机 |
|---|---|---|---|
| 8 | 255.0.0.0 | 1 | 16,777,214 |
| 9 | 255.128.0.0 | 2 | 8,388,606 |
| 10 | 255.192.0.0 | 4 | 4,194,302 |
| 11 | 255.224.0.0 | 8 | 2,097,150 |
| 12 | 255.240.0.0 | 16 | 1,048,574 |
| 13 | 255.248.0.0 | 32 | 524,286 |
| 14 | 255.252.0.0 | 64 | 262,142 |
| 15 | 255.254.0.0 | 128 | 131,070 |
| 16 | 255.255.0.0 | 256 | 65,534 |
| 17 | 255.255.128.0 | 512 | 32,766 |
| 18 | 255.255.192.0 | 1,024 | 16,382 |
| 19 | 255.255.224.0 | 2,048 | 8,190 |
| 20 | 255.255.240.0 | 4,096 | 4,094 |
| 21 | 255.255.248.0 | 8,192 | 2,046 |
| 22 | 255.255.252.0 | 16,384 | 1,022 |
| 23 | 255.255.254.0 | 32,768 | 510 |
| 24 | 255.255.255.0 | 65,536 | 254 |
| 25 | 255.255.255.128 | 131,072 | 126 |
| 26 | 255.255.255.192 | 262,144 | 62 |
| 27 | 255.255.255.224 | 524,288 | 30 |
| 28 | 255.255.255.240 | 1,048,576 | 14 |
| 29 | 255.255.255.248 | 2,097,152 | 6 |
| 30 | 255.255.255.252 | 4,194,304 | 2 |
你可能已经注意到,每行中的每个网络可用的主机数比预期少了两个,因为每个网络都有两个特殊地址。网络中的第一个 IP 地址是网络地址,最后一个 IP 地址是广播地址。(我们将在本章稍后介绍广播地址。)以 192.168.0.0/16 为例。网络中的第一个 IP 地址是 192.168.0.0,这就是网络地址。网络中的最后一个 IP 地址是 192.168.255.255,这是广播地址。目前,理解你不会将网络 IP 地址或广播 IP 地址分配给主机的网络接口。这些特殊的 IP 地址分别用于在网络之间路由数据和广播。
31 位和 32 位的网络前缀故意未包含在表 2-1 中,主要是因为它们超出了本书的范围。如果你对 31 位网络前缀感兴趣,可以查阅 RFC 3021 了解其应用。32 位网络前缀表示一个单主机网络。例如,192.168.1.1/32 表示一个只有地址为 192.168.1.1 的单一节点子网络。
分配不在字节边界断开的网络
有些网络前缀不会在字节边界处断开。例如,图 2-5 在 19 位网络中推导出 192.168.156.97 的网络 ID 和主机 ID。CIDR 表示法中的完整 IP 地址为 192.168.156.97/19。

图 2-5:从 IPv4 地址中推导出网络 ID 和主机 ID(在 19 位网络中)
在这种情况下,由于网络前缀不是 8 位的倍数,一个字节的位被分割到网络 ID 和主机 ID 之间。在图 2-5 中的 19 位网络示例中,网络 ID 为 192.168.128.0,主机 ID 为 0.0.28.97,其中网络 ID 从第三个字节借用了 3 位,剩下 13 位用于主机 ID。
将零化的主机 ID 附加到网络 ID 后,得到网络地址。以类似的方式,将所有位都是 1 的主机 ID 附加到网络 ID 后,得到广播地址。但第三个字节等于 156 可能有点令人困惑。我们只关注第三个字节。网络 ID 的第三个字节是 1000 0000。所有位都是 1 的主机 ID 的第三个字节是 0001 1111(前 3 位是网络 ID 的一部分,记住)。如果我们将网络 ID 的第三个字节附加到主机 ID 的第三个字节,结果是 1001 1111,即十进制的 156。
私有地址空间与本地主机
RFC 1918 详细说明了 10.0.0.0/8、172.16.0.0/12 和 192.168.0.0/16 的私有地址空间,供本地网络使用。大学、公司、政府和住宅网络可以使用这些子网进行本地寻址。
此外,每个主机都有 127.0.0.0/8 子网作为其本地子网。该子网中的地址是主机的本地地址,通常称为localhost。即使你的计算机不在网络上,它仍然应该有一个 127.0.0.0/8 子网中的地址,最有可能是 127.0.0.1。
端口与套接字地址
如果你的计算机只能与网络中的一个节点进行通信,这将不会提供高效或愉快的体验。如果每次你在网页浏览器中点击一个链接时,音乐流媒体就停止,因为浏览器需要中断流媒体以获取请求的网页,那将非常烦人。幸运的是,TCP 和 UDP 允许我们通过使用端口来实现数据传输的复用。
操作系统使用端口来唯一标识节点之间的数据传输,以实现对外发应用数据的复用和对进入数据的解复用。IP 地址和端口号的组合被称为套接字地址,通常以地址:端口的格式表示。
端口是 16 位无符号整数。端口号 0 至 1023 是由互联网号码分配局**(IANA) 分配给常见服务的著名端口。IANA 是一个美国非营利性私营组织,负责全球 IP 地址和端口号的分配。例如,HTTP 使用端口 80。端口 443 是 HTTPS 端口。SSH 服务器通常监听端口 22。(这些著名端口只是指南。HTTP 服务器可以监听任何端口,而不仅仅是端口 80。)
尽管这些端口是众所周知的,但服务使用哪些端口没有限制。例如,想要将服务隐藏在不同端口上的管理员,可以配置 SSH 服务器监听端口 22422,而不是默认的 22 端口。IANA 将端口 1024 至 49151 定义为半保留端口,用于较不常见的服务。端口 49152 至 65535 是短暂端口,用于客户端套接字地址,这是 IANA 的建议。(客户端套接字地址使用的端口范围依赖于操作系统。)
一个常见的端口使用示例是您的网页浏览器与网页服务器之间的交互。您的网页浏览器与操作系统打开一个套接字,操作系统为该套接字分配一个地址。您的网页浏览器通过该套接字向网页服务器的 80 端口发送请求。网页服务器将其响应发送到与您的网页浏览器监视的套接字对应的套接字地址。您的操作系统接收到响应并通过套接字将其传递给网页浏览器。您的网页浏览器的套接字地址和网页服务器的套接字地址(服务器 IP 和 80 端口)唯一标识此事务。这使得您的操作系统能够正确地进行响应的解复用,并将其传递给正确的应用程序(即您的网页浏览器)。
网络地址转换
四十亿个 IPv4 地址看起来可能很多,但考虑到根据 2020 年 6 月的爱立信移动报告(www.ericsson.com/en/mobility-report/reports/june-2020/iot-connections-outlook/),到 2025 年,预计将有 246 亿个物联网(IoT)设备,情况就不一样了。实际上,我们已经耗尽了未预留的 IPv4 地址。IANA 于 2011 年 1 月 31 日分配了最后一个 IPv4 地址块。
解决 IPv4 地址短缺的一种方式是使用网络地址转换(NAT),这是一种允许多个节点共享同一公共 IPv4 地址的过程。它需要一个设备,例如防火墙、负载均衡器或路由器,能够跟踪进出流量,并将传入的流量正确路由到正确的节点。
图 2-6 展示了私有网络与互联网之间的 NAT 过程。

图 2-6:私有网络与互联网之间的网络地址转换
在图 2-6 中,一个支持 NAT 的设备接收到来自客户端套接字地址 10.0.0.3:50926 的连接,目标是互联网上的一个主机。首先,NAT 设备使用其公共 IP 1.2.3.4 打开与目标主机的连接,同时保持客户端的套接字地址端口。此事务的套接字地址为 1.2.3.4:50926。如果客户端已经使用了端口 50926,NAT 设备会为其套接字地址选择一个随机端口。然后,NAT 设备将请求发送到目标主机,并在其 1.2.3.4:50926 套接字上接收响应。NAT 设备通过将其套接字地址转换为建立连接的客户端套接字地址,知道哪个客户端接收到响应。最后,客户端从 NAT 设备接收到目标主机的响应。
关于网络地址转换,重要的是要记住,位于 NAT 设备后面的节点的私有 IPv4 地址对网络地址转换后的网络段外的其他节点不可见或不可直接访问。如果你正在编写一个需要为客户端提供公共地址的服务,你可能无法依赖节点的私有 IPv4 地址,特别是在它位于 NAT 设备后面时。NAT 设备外的主机无法建立传入连接。只有私有网络中的客户端才能通过 NAT 设备建立连接。相反,你的服务必须依赖于 NAT 设备将端口从其公共 IP 正确转发到你节点的套接字地址。
单播、多播和广播
从一个 IP 地址发送数据包到另一个 IP 地址被称为单播寻址。但 TCP/IP 的互联网层支持 IP 多播,即将单条消息发送给一组节点。你可以将其视为一个自愿加入的邮件列表,例如报纸订阅。
从网络编程的角度来看,多播很简单。路由器和交换机通常为我们复制消息,如图 2-7 所示。我们将在本书后面讨论多播。

图 2-7:192.168.1.10 节点向一部分网络地址发送数据包
广播是将消息同时发送给网络中所有 IP 地址的能力。为此,网络上的节点将数据包发送到子网的广播地址。然后,网络交换机或路由器将数据包传播到子网中的所有 IPv4 地址(图 2-8)。

图 2-8:192.168.1.10 节点向其子网中的所有地址发送数据包
与多播不同,子网中的节点不需要首先选择接收广播消息。如果图 2-8 中的 192.168.1.10 节点向其子网的广播地址发送数据包,网络交换机会将该数据包的副本传递给同一子网中其他五个 IPv4 地址。
将 MAC 地址解析到物理网络连接
回想一下第一章提到的,每个网络接口都有一个 MAC 地址,唯一标识节点与网络的物理连接。MAC 地址只与本地网络相关,因此路由器无法使用 MAC 地址在网络边界之间路由数据。相反,它们可以使用 IPv4 地址跨越网络边界进行路由。一旦数据包到达目标节点的本地网络,路由器会将数据发送到目标节点的 MAC 地址,最终到达目标节点的物理网络连接。
地址解析协议(ARP),详见 RFC 826(tools.ietf.org/html/rfc826/),用于查找给定 IP 地址的适当 MAC 地址——这个过程被称为 解析 MAC 地址。节点维护 ARP 表,将 IPv4 地址映射到 MAC 地址。如果一个节点的 ARP 表中没有目标 IPv4 地址的条目,它将向本地网络的广播地址发送请求,询问:“谁拥有这个 IPv4 地址?请发送你的 MAC 地址。哦,这里是我的 MAC 地址。”目标节点将接收 ARP 请求并以 ARP 回复回应源节点。源节点随后会将数据发送到目标节点的 MAC 地址。网络中监听此对话的其他节点通常会更新它们的 ARP 表。
IPv6 地址分配
解决 IPv4 地址短缺的另一种方法是迁移到下一代 IP 地址,即 IPv6。IPv6 地址 是 128 位数字,按八个以冒号分隔的 16 位组排列,或称为 六元组。IPv6 地址的总数超过 340 万亿(2¹²⁸)个。
编写 IPv6 地址
在二进制形式下,IPv6 地址写起来有点荒谬。为了可读性和紧凑性,我们通常使用小写的十六进制值来表示 IPv6 地址。
一个十六进制(hex)数字代表 IPv6 地址的 4 位,或称为 四位组。例如,我们可以将两个四位组 1111 1111 转换为其十六进制等效值 ff。图 2-9 展示了相同的 IPv6 地址在二进制和十六进制中的表示。

图 2-9:相同 IPv6 地址的二进制和十六进制表示
尽管十六进制的 IPv6 地址比其二进制等效地址稍显简洁,但我们仍然有一些技术可以进一步简化它们。
简化 IPv6 地址
一个 IPv6 地址看起来像这样:fd00:4700:0010:0000:0000:0000:6814:d103。比起 IPv4 地址,它确实更难记住。幸运的是,你可以通过遵循一些规则来改善 IPv6 地址的表示,使其更易读。
首先,你可以去掉每个六元组中的所有前导零。这将简化你的地址,同时不会改变其值。现在它看起来像这样:fd00:4700:10:0:0:0:6814:d103。更简洁了,但还是比较长。
其次,你可以将最左边一组连续的零值六元组替换为双冒号,从而生成更短的地址 fd00:4700:10::6814:d103。如果你的地址中有多个连续零值六元组组,你只能去掉最左边的一组。否则,路由器将无法准确确定在重新生成完整地址时需要插入多少个六元组。例如,fd00:4700:0000:0000:ef81:0000:6814:d103 重写为 fd00:4700::ef81:0:6814:d103。对于第六个六元组,你能做的最好的优化就是去掉前导零。
IPv6 网络和主机地址
和 IPv4 地址一样,IPv6 地址也有网络地址和主机地址。IPv6 的主机地址通常被称为 接口 ID。网络地址和主机地址各占 64 位,如图 2-10 所示。网络地址的前 48 位被称为 全局路由前缀(GRP),网络地址的最后 16 位被称为 子网 ID。48 位的 GRP 用于全球细分 IPv6 地址空间,并在这些组之间路由流量。子网 ID 用于进一步细分每个 GRP 唯一的网络,形成特定站点的网络。如果你运营一个大型 ISP,你会被分配一个或多个 GRP 唯一的 IPv6 地址块。然后,你可以在每个网络中使用子网 ID 来进一步细分分配给客户的 IPv6 地址。

图 2-10:IPv6 全局路由前缀、子网 ID 和接口 ID
当你从互联网服务提供商(ISP)请求一块 IPv6 地址时,GRP 会为你自动确定。IANA 将 GRP 的第一个十六位组分配给一个区域互联网注册机构(负责全球区域地址分配的组织)。该区域互联网注册机构随后将 GRP 的第二个十六位组分配给一个 ISP。ISP 最后分配 GRP 的第三个十六位组,然后将一个 48 位子网的 IPv6 地址分配给你。
IPv6 地址的第一个十六位组能为你提供地址用途的线索。以前缀 2000::/3 开头的地址用于全球范围,意味着互联网上的每个节点都会有一个以 2 或 3 开头的 IPv6 地址。前缀 fc00::/7 指定了类似 IPv4 中的 127.0.0.0/8 子网的唯一本地地址。
假设你的 ISP 分配给你 2600:fe56:7891::/48 网络块。你的 16 位子网 ID 允许你将网络块进一步细分为最多 65,536 个子网(2¹⁶)。每个子网支持超过 18 亿亿个主机(2⁶⁴)。如果你将子网设置为 1,如图 2-10 所示,那么完整的网络地址将是 2600:fe56:7891:1::/64,删除前导零并压缩零值十六位组后,地址为 2600:fe56:7891:1::/64。进一步细分你的网络块可能如下所示:2600:fe56:7891:2::/64,2600:fe56:7891:3::/64,2600:fe56:7891:4::/64。
IPv6 地址类别
IPv6 地址分为三类:任播(anycast)、多播(multicast)和单播(unicast)。请注意,不像 IPv4 中那样有广播类型。在 IPv6 中,任播和多播地址承担了广播的角色。
单播地址
单播 IPv6 地址唯一地标识一个节点。如果源节点将消息发送到单播地址,只有拥有该地址的节点会收到该消息,如图 2-11 所示。

图 2-11:发送到单播地址
多播地址
多播地址表示一组节点。IPv4 广播地址会将消息传播到网络上的所有地址,而多播地址则会同时将消息发送给网络地址的一个子集,而不一定是所有地址,正如 图 2-12 所示。

图 2-12:发送到多播地址
多播地址使用前缀 ff00::/8。
任播地址
请记住,IPv4 地址在每个网络段必须是唯一的,否则会发生网络通信问题。但 IPv6 支持多个节点使用相同的网络地址。任播地址表示一组监听相同地址的节点。发送到任播地址的消息会传递给离该地址最近的节点。图 2-13 展示了一组监听相同地址的节点,其中离发送方最近的节点接收消息。发送方可以将消息发送给虚线所代表的任一节点,但会选择发送给离其最近的节点(实线所示)。

图 2-13:发送到任播地址
最近的节点并不总是物理上最接近的节点。由路由器决定哪个节点接收消息,通常是选择源和目的地之间延迟最小的节点。除了减少延迟,任播寻址还能增加冗余性,并可以进行地理定位服务。
全球范围内传输数据需要一定的时间,距离服务提供商服务器越近,性能越好。通过地理定位服务来确保服务器尽可能靠近用户,是优化全球用户性能的常见方法。例如,当你观看 Netflix 时,几乎不可能通过跨越大洋的服务器进行访问。相反,Netflix 会将服务器地理定位到离你更近的地方,从而确保你拥有最佳的观看体验。
IPv6 相较于 IPv4 的优势
除了极其庞大的地址空间,IPv6 在效率、自动配置和安全性等方面也相较于 IPv4 具有固有优势。
为了更高效的路由,IPv6 采用了简化的报头格式。
IPv6 报头相比 IPv4 报头有所改进。IPv4 报头包含了一些强制性的但很少使用的字段,而 IPv6 将这些字段设为可选。IPv6 报头是可扩展的,意味着可以在不破坏向后兼容性的情况下增加新功能。此外,IPv6 报头的设计更高效,且比 IPv4 报头更简化。
IPv6 还通过确保报头需要最小处理,从而减少了路由器和其他跳点的负载,避免了每个跳点都需要进行校验和计算。
无状态地址自动配置
管理员手动为网络上的每个节点分配 IPv4 地址,或依赖于某个服务动态分配地址。使用 IPv6 的节点可以通过无状态地址自动配置**(SLAAC)自动配置或推导其 IPv6 地址,从而减少管理开销。
当连接到一个 IPv6 网络时,一个节点可以使用邻居发现协议**(NDP)向路由器请求其网络地址参数。NDP 利用后续章节将讨论的互联网控制消息协议进行路由器请求。它执行与 IPv4 的 ARP 相同的功能。一旦节点收到路由器的回复并获得 64 位网络地址,节点可以使用分配给其网络接口的 48 位 MAC 地址自行推导出其 IPv6 地址的 64 位主机部分。节点将 16 位的十六进制 FFFE 附加到 MAC 地址的前三个八位字节,这三个字节称为最初唯一标识符(OUI)。接着,节点将 MAC 地址的剩余三个八位字节,即网络接口控制器(NIC)标识符,附加在后面。结果是一个独特的 64 位接口 ID,如图 2-14 所示。SLAAC 仅在存在可以响应路由器广告包的路由器时工作。路由器广告包包含客户端自动配置其 IPv6 地址所需的信息,包括 64 位网络地址。

图 2-14:从 MAC 地址推导接口 ID
如果你重视隐私,SLAAC 推导唯一接口 ID 的方法应该引起你的关注。无论你的设备在哪个网络上,SLAAC 都会确保你的 IPv6 地址的主机部分包含你的 NIC 的 MAC 地址。MAC 地址是一个独特的指纹,它揭示了你使用的硬件,并允许任何人追踪你的在线活动。幸运的是,许多人提出了这些隐私问题,SLAAC 获得了隐私扩展(tools.ietf.org/html/rfc4941/),该扩展可以随机化接口 ID。由于这种随机化,网络上的多个节点有可能生成相同的接口 ID。幸运的是,NDP 会自动检测并修复任何重复的接口 ID。
原生 IPsec 支持
IPv6 原生支持IPsec,这是一种允许多个节点之间动态创建安全连接的技术,确保流量加密。
互联网控制消息协议
互联网协议依赖于互联网控制消息协议**(ICMP)来反馈本地网络的信息。ICMP 可以告知网络问题、无法到达的节点或网络、本地网络配置、正确的流量路由和网络超时。IPv4 和 IPv6 都有各自的 ICMP 实现,分别被指定为 ICMPv4 和 ICMPv6。
网络事件通常会导致 ICMP 响应消息。例如,如果您试图向一个不可达节点发送数据,路由器通常会用 ICMP 目标不可达消息作出响应,通知您数据无法到达目标节点。如果节点耗尽资源或无法路由到节点,该节点可能会变得不可达。将节点从网络断开将立即使其不可访问。
路由器使用 ICMP 来帮助通知您更好的路由到达目标节点的方式。如果您向一个不适合或最佳的路由器发送数据,它可能会在将数据转发到正确的路由器后,用 ICMP 重定向消息回复。ICMP 重定向消息是路由器告诉您将来将数据发送到适当路由器的方式。
您可以通过使用 ICMP 的回显请求(也称为ping)来确定节点是否在线和可达。如果目标可达并接收到您的 ping,则会用自己的 ICMP 回显回复消息(也称为pong)进行回复。如果目标不可达,则路由器将用目标不可达消息作出响应。
ICMP 还可以在数据传递前通知您其寿命已到。每个 IP 数据包都有一个生存时间值,该值规定了数据包在其寿命到期之前可以经过的最大跳数。数据包的生存时间是一个计数器,每经过一个跳数就减一。如果您发送的数据包在其生存时间值达到零之前未能到达目的地,则会收到 ICMP 超时消息。
IPv6 的 NDP 依赖于 ICMP 路由器请求消息,以正确配置节点的网络接口控制器(NIC)。
互联网流量路由
现在您已经了解了一些关于互联网协议地址的知识,让我们探讨一下数据包如何通过互联网从一个节点到另一个节点,利用这些地址。在第一章中,我们讨论了数据如何沿着起始节点的网络堆栈传输,通过物理介质跨越,然后通过目标节点的堆栈上升。但是在大多数情况下,节点之间没有直接连接,因此它们必须利用中间节点来传输数据。图 2-15 展示了这个过程。
中间节点(图 2-15 中的节点 1 和 2)通常是路由器或防火墙,它们控制数据从一个节点到另一个节点的路径。防火墙主要用于控制网络中进出流量的流向,以保护网络安全。
无论它们是什么类型的节点,中间节点都有一个与每个网络接口关联的网络协议栈。在图 2-15 中,节点 1 在其接收网络接口上接收到数据。数据沿着协议栈上升到第 3 层,然后被交给发送网络接口的协议栈。接着,数据到达节点 2 的接收网络接口,最终被路由到服务器。
节点 1 和节点 2 的接收和发送网络接口可能会通过不同的媒介类型使用 IPv4 发送数据,因此它们必须使用封装来隔离每种媒介类型的实现细节与正在发送的数据。假设节点 1 从客户端通过无线网络接收数据,并通过以太网连接将数据发送到节点 2。节点 1 的接收第 1 层知道如何将无线网络的无线信号转换为比特。第 1 层将比特发送到第 2 层。第 2 层将比特转换为帧,并提取数据包,将其发送到第 3 层。

图 2-15:通过两跳路由数据包
无论是接收的还是发送的网络接口卡(NIC)的第 3 层都使用 IPv4,它将数据包在两个接口协议栈之间进行路由。发送的网络接口卡的第 2 层从其第 3 层接收数据包,并在将帧发送到第 1 层之前对其进行封装。发送的第 1 层将比特转换为适合通过以太网传输的电信号。尽管数据在到达目标服务器的过程中经过了多个节点和不同的媒介,但客户端的第 7 层中的数据在传输过程中始终没有发生变化。
路由协议
图 2-15 中的路由概览让这个过程看起来很简单,但路由过程依赖于一系列协议的协作,以确保每个数据包无论经过何种物理媒介或在途中是否出现网络故障,都能到达目标。路由协议有各自的标准来决定节点之间的最佳路径。有些协议基于跳数来决定路由的效率。有些可能使用带宽,而另一些则可能使用更复杂的方式来确定哪条路径最有效。
路由协议可以分为内部路由协议和外部路由协议,具体取决于它们是否在自治系统内部或外部路由数据包。自治系统是管理一个或多个网络的组织。互联网服务提供商(ISP)就是一个自治系统的例子。每个自治系统都会被分配一个自治系统编号(ASN),如 RFC 1930 中所述(tools.ietf.org/html/rfc1930/)。这个 ASN 用于通过外部路由协议向其他自治系统广播 ISP 的网络信息。外部路由协议在自治系统之间路由数据。我们将要讨论的唯一路由协议是 BGP,因为它是互联网的“胶水”,将所有分配了 ASN 的 ISP 连接在一起。你不需要深入理解 BGP,但熟悉它可以帮助你更好地调试与代码相关的网络问题,并提高代码的可靠性。
边界网关协议
边界网关协议(BGP)允许分配了 ASN 的 ISP 交换路由信息。BGP 依赖于 ISP 之间的信任。也就是说,如果一个 ISP 声明它管理某个特定的网络,并且所有指向该网络的流量应该发送到它,其他 ISP 会信任这一声明并相应地转发流量。因此,BGP 配置错误或路由泄漏往往会导致非常公开的网络故障。
2008 年,巴基斯坦电信公司通过 BGP 有效地让全球的 YouTube 服务中断,原因是巴基斯坦通信部要求该国封锁youtube.com,以抗议一段 YouTube 视频。巴基斯坦电信使用 BGP 将所有指向 YouTube 的请求发送到一个空路由,该路由会丢弃所有数据而不通知发送方。但巴基斯坦电信错误地将其 BGP 路由泄漏到了全球,而不是仅限于该国。其他 ISP 信任了这个更新,将来自其客户的 YouTube 请求都丢弃,使得youtube.com在全球范围内无法访问,持续了两个小时。
2012 年,Google 的服务在 27 分钟内被重新路由到印度尼西亚,当时 ISP Moratel 共享了一条 BGP 路由,将所有 Google 流量指向 Moratel 的网络,仿佛 Moratel 正在托管 Google 的网络基础设施。当时有人猜测路由泄漏是恶意的,但 Moratel 归咎于硬件故障。
BGP 通常只有在出现问题时才成为新闻焦点。其他时候,它默默地发挥着重要作用,在减轻分布式拒绝服务(DDOS)攻击中扮演着重要角色。在DDOS 攻击中,恶意行为者将来自成千上万个被攻陷节点的流量引导到受害者节点,目的是压垮受害者并消耗其所有带宽,实际上拒绝了合法客户的服务。专门从事 DDOS 攻击缓解的公司使用 BGP 将所有指向受害者节点的流量重新路由到他们的 AS 网络,过滤掉恶意流量与合法流量,最后将净化后的流量返回给受害者,从而抵消攻击的效果。
名称与地址解析
域名系统(DNS)是一种将 IP 地址与域名匹配的方式,域名是我们在地址栏中输入的名称,用于访问网站。虽然互联网协议使用 IP 地址来定位主机,但域名(如google.com)更容易被人类理解和记住。如果我给你一个 IP 地址 172.217.6.14,你可能不知道这个 IP 地址的所有者是谁,或者我让你访问的是哪个网站。但如果我给你google.com,你就完全知道我让你访问的是哪里。DNS 允许你记住主机名,而不是它的 IP 地址,就像你智能手机的联系人列表让你无需记住所有电话号码一样。
所有域名都是顶级域名的子域名,例如.com、.net、.org等。以nostarch.com为例。No Starch Press 在具有 IANA 注册.com域名授权的注册商处注册了nostarch域名。现在,No Starch Press 拥有独占权限来管理nostarch.com的 DNS 记录,并在其 DNS 服务器上发布记录。这包括 No Starch Press 发布子域名的能力——即一个域名的细分——在其域名下。例如,maps.google.com是google.com的一个子域名。更长的示例是sub3.sub2.sub1.domain.com,其中sub3是sub2.sub1.domain.com下的子域名,sub2是sub1.domain.com下的子域名,而sub1则是domain.com下的子域名。
如果你在网页浏览器中输入https://nostarch.com,你的计算机会查询其配置的域名解析器,这是一个知道如何检索你查询答案的服务器。解析器首先会向 13 个由 IANA 维护的根名称服务器之一询问nostarch.com的 IP 地址。根名称服务器会检查你请求的域的顶级域,并将.com名称服务器的地址提供给你的解析器。然后,解析器会向.com名称服务器请求nostarch.com的 IP 地址,后者会检查域名部分并指示解析器去询问 No Starch Press 的名称服务器。最后,解析器会向 No Starch Press 的名称服务器请求并获得与nostarch.com对应的 IP 地址。你的网页浏览器将与该 IP 地址建立连接,获取网页并呈现给你。这个域名解析的层级过程让你能够精确地定位到特定的网页服务器,而你只需要知道域名即可。No Starch Press 可以自由将服务器迁移到新的 ISP,并使用新的 IP 地址,但你仍然能够通过使用nostarch.com访问它的网站。
域名资源记录
域名服务器为它们所服务的域名维护资源记录。资源记录包含特定于域的相关信息,用于满足域名查询,比如 IP 地址、邮件服务器主机名、邮件处理规则和身份验证令牌。有很多种资源记录,但本节仅关注最常见的几种:地址记录、授权起始记录、名称服务器记录、规范名称记录、邮件交换记录、指针记录和文本记录。
我们对每个资源记录的探索将使用一个名为dig的工具来查询域名服务器。这个工具可能已经包含在你的操作系统中,但如果你没有安装 dig,可以在网页浏览器中使用 G Suite 工具箱中的 Dig 工具(toolbox.googleapps.com/apps/dig/),并获得类似的输出。你将看到的所有域名都是完全限定的,这意味着它们以句点结束,显示了域名从根区域开始的完整层级结构。根区域是顶级的 DNS 命名空间。
Dig 的默认输出包含一些与查询相关但与研究输出无关的数据。因此,我决定在接下来的每个示例中将 dig 输出中的头部和尾部信息剪切掉。请注意,本书中展示的具体输出是我执行每个查询时的快照。你执行这些命令时,输出可能会有所不同。
地址记录
地址 (A) 记录 是你最常查询的记录。A 记录将解析为一个或多个 IPv4 地址。当你的计算机请求其解析器获取nostarch.com的 IP 地址时,解析器最终会向域名服务器请求nostarch.com的地址(A)资源记录。清单 2-1 展示了你查询google.com A 记录时的提问和回答部分。
$ **dig google.com. a**
`-- snip --`
1 ;QUESTION
2 google.com. 3IN 4A
5 ;ANSWER
6 google.com. 7299 IN A 8172.217.4.46
`-- snip --`
清单 2-1:google.com A 资源记录的 DNS 响应
DNS 回复的每个部分都以头部 1 开始,前缀为分号,表示这一行是注释而不是需要处理的代码。在查询部分中,你向域名服务器请求域名google.com2,并使用类IN3,表示该记录与互联网相关。你还使用A专门请求 A 记录 4。
在响应部分 5 中,域名服务器将google.com的 A 记录解析为六个 IPv4 地址。每个返回行的第一个字段是你查询的域名 6。第二个字段是记录的 TTL 值 7。TTL 值 告诉域名解析器该记录应该缓存多长时间,或者记住该记录,它也告诉你缓存记录将过期的时间。当你请求 DNS 记录时,域名解析器会首先检查其缓存。如果答案已经在缓存中,它将直接返回缓存中的答案,而不是再次向域名服务器请求答案。这提高了对于不太可能频繁更改的记录的域名解析性能。在本例中,记录将在 299 秒后过期。最后一个字段是 IPv4 地址 8。你的网页浏览器可以使用这六个 IPv4 地址中的任何一个与google.com建立连接。
AAAA 资源记录是 A 记录的 IPv6 等效项。
授权起始记录
授权起始记录 (SOA) 包含有关域名的权威性和管理细节,如清单 2-2 所示。所有域名必须拥有一个 SOA 记录。
$ **dig google.com. soa**
`-- snip --`
;QUESTION
google.com. IN SOA
;ANSWER
google.com. 59 IN SOA 1ns1.google.com. 2dns-admin.google.com. 3248440550 900 900 1800 60
`-- snip --`
清单 2-2:google.com SOA 资源记录的 DNS 响应
SOA 记录的前四个字段与 A 记录中的字段相同。SOA 记录还包括主名称服务器 1、管理员的电子邮件地址 2 以及第 3 个字段,这是次级名称服务器在本书范围外使用的字段。域名服务器主要使用 SOA 记录。然而,如果你希望联系域名管理员,电子邮件地址非常有用。
名称服务器记录
名称服务器 (NS) 记录 返回该域名的权威名称服务器。权威名称服务器 是能够为域名提供解答的名称服务器。NS 记录将包括来自 SOA 记录的主名称服务器和任何为该域名回答 DNS 查询的次级名称服务器。清单 2-3 是google.com的 NS 记录示例。
$ **dig google.com. ns**
`-- snip --`
;QUESTION
google.com. IN NS
;ANSWER
google.com. 21599 IN NS 1ns1.google.com.
google.com. 21599 IN NS ns2.google.com.
google.com. 21599 IN NS ns3.google.com.
google.com. 21599 IN NS ns4.google.com.
`-- snip --`
清单 2-3:google.com NS 资源记录的 DNS 响应
与接下来讨论的 CNAME 记录类似,NS 记录将返回一个完全合格的域名 1,而不是 IP 地址。
规范名称记录
规范名称(CNAME)记录将一个域名指向另一个域名。列表 2-4 展示了 CNAME 记录的响应。CNAME 记录可以使管理工作变得更加简单。例如,你可以创建一个名为mail.yourdomain.com的域名,并将其指向 Gmail 的登录页面。这不仅使得你的用户更容易记住,而且你还可以在未来将 CNAME 指向另一个邮件提供商,而无需通知你的用户。
$ **dig mail.google.com. a**
`-- snip --`
;QUESTION
mail.google.com. IN A
;ANSWER
1 mail.google.com. 21599 IN CNAME 2googlemail.l.google.com.
googlemail.l.google.com. 299 IN A 172.217.3.229
`-- snip --`
列表 2-4:mail.google.com CNAME 资源记录的 DNS 回答
请注意,你向域名服务器请求子域名mail.google.com的 A 记录。但在这种情况下,你收到的是 CNAME 记录。这意味着googlemail.l.google.com2 是mail.google.com1 的规范名称。幸运的是,你收到了googlemail.l.google.com的 A 记录响应,这使得你不必再进行第二次查询。现在你知道目标 IP 地址是 172.217.3.229。Google 的域名服务器能够在同一回复中同时返回 CNAME 答案和相应的地址答案,因为它也对 CNAME 答案的域名有权威性。否则,你只会得到 CNAME 答案,然后需要进行第二次查询来解析 CNAME 答案的 IP 地址。
邮件交换记录
邮件交换(MX)记录指定了在向该域的收件人发送邮件时应联系的邮件服务器主机名。远程邮件服务器会查询该域部分的收件人电子邮件地址中的 MX 记录,以确定应将邮件发送到哪些服务器。列表 2-5 展示了邮件服务器会收到的响应。
$ **dig google.com. mx**
`-- snip --`
;QUESTION
google.com. IN MX
;ANSWER
google.com. 599 IN MX 110 aspmx.l.google.com.
google.com. 599 IN MX 50 alt4.aspmx.l.google.com.
google.com. 599 IN MX 30 alt2.aspmx.l.google.com.
google.com. 599 IN MX 20 alt1.aspmx.l.google.com.
google.com. 599 IN MX 40 alt3.aspmx.l.google.com.
`-- snip --`
列表 2-5:google.com MX 资源记录的 DNS 回答
除了域名、TTL 值和记录类型外,MX 记录还包含优先级字段1,用于表示每个邮件服务器的优先级。数字越低,邮件服务器的优先级越高。邮件服务器会尝试将邮件投递到优先级最高的服务器,如果需要,才会转向下一个优先级较高的服务器。如果多个邮件服务器共享相同的优先级,则会随机选择一个邮件服务器。
指针记录
指针(PTR)记录允许你通过提供 IP 地址并返回其对应的域名来执行反向查找。列表 2-6 展示了 8.8.4.4 的反向查找。
$ **dig 4.4.8.8.in-addr.arpa. ptr**
`-- snip --`
;QUESTION
1 4.4.8.8.in-addr.arpa. IN PTR
;ANSWER
4.4.8.8.in-addr.arpa. 21599 IN PTR 2google-public-dns-b.google.com.
`-- snip --`
列表 2-6:8.8.4.4 PTR 资源记录的 DNS 回答
要执行查询,你需要向域名服务器请求反向顺序的 IPv4 地址 1,并附加特殊域名 in-addr.arpa,因为反向 DNS 记录都位于 .arpa 顶级域下。例如,查询 IP 1.2.3.4 的指针记录意味着你需要请求 4.3.2.1.in-addr.arpa。列表 2-6 中的查询告诉你,IPv4 地址 8.8.4.4 会反向解析为域名 google-public-dns-b.google.com 2。如果你正在执行 IPv6 地址的反向查找,你需要像处理 IPv4 地址那样,将特殊域名 ip6.arpa 附加到反向的 IPv6 地址上。
文本记录
文本(TXT)记录 允许域名所有者返回任意文本。这些记录可以包含证明域名所有权的值、远程邮件服务器可以用来授权邮件的值,以及指定哪些 IP 地址可以代表该域名发送邮件的条目等用途。列表 2-7 显示了与 google.com 关联的文本记录。
$ **dig google.com. txt**
`-- snip --`
;QUESTION
google.com. IN TXT
;ANSWER
google.com. 299 IN TXT
1"facebook-domain-verification=22rm551cu4k0ab0bxsw536tlds4h95"
google.com. 299 IN TXT "docusign=05958488-4752-4ef2-95eb-aa7ba8a3bd0e"
google.com. 299 IN TXT 2"v=spf1 include:_spf.google.com ~all"
google.com. 299 IN TXT
"globalsign-smime-dv=CDYX+XFHUw2wml6/Gb8+59BsH31KzUr6c1l2BPvqKX8="
`-- snip --`
列表 2-7:google.com TXT 资源记录的 DNS 答复
域名查询和答案现在应该开始变得熟悉。TXT 记录中的最后一个字段是 TXT 记录值 1 的一串字符串。在这个示例中,该字段包含一个 Facebook 验证密钥,它向 Facebook 证明 Google 的企业 Facebook 账户就是他们所说的那个,并且有权对 Google 在 Facebook 上的内容进行修改。它还包含 发送者策略框架 规则 2,告知远程邮件服务器哪些 IP 地址可以代表 Google 发送电子邮件。
多播 DNS
多播 DNS(mDNS)是一种协议,能在没有 DNS 服务器的情况下,通过局域网(LAN)实现名称解析。当一个节点想要将域名解析为 IP 地址时,它会向一个 IP 多播组发送请求。监听该组的节点会接收查询,请求该域名的节点会向 IP 多播组返回其 IP 地址。你可能在上次搜索和配置网络打印机时使用过 mDNS。
DNS 查询的隐私和安全考虑
DNS 流量通常在穿越互联网时是未加密的。一个潜在的例外情况是,如果你连接到虚拟私人网络(VPN),并且确保所有的 DNS 流量都通过其加密隧道传输。由于 DNS 的未加密传输,某些不道德的互联网服务提供商(ISP)或中间提供商可能会获取你 DNS 查询中的敏感信息,并将这些细节与第三方共享。你可以专门访问仅支持 HTTPS 的网站,但你的 DNS 查询可能会暴露你本应安全的浏览习惯,从而使 DNS 服务器的管理员了解你访问的站点。
安全性在明文 DNS 流量中也是一个问题。攻击者可以通过插入对你的 DNS 查询的响应,诱使你的网页浏览器访问恶意网站。考虑到实施这种攻击的难度,这不是你可能会遇到的攻击,但无论如何还是令人担忧。由于 DNS 服务器通常会缓存响应,这种攻击通常发生在你的设备和它所配置使用的 DNS 服务器之间。RFC 7626(tools.ietf.org/html/rfc7626/)更详细地覆盖了这些话题。
域名系统安全扩展
通常,你可以通过两种方式确保通过网络发送数据的真实性:认证内容和认证通道。域名系统安全扩展**(DNSSEC)是一种通过使用数字签名来认证响应,从而防止在传输过程中对 DNS 响应进行隐秘修改的方法。DNSSEC 通过认证内容来确保数据的真实性。DNS 服务器对它们提供的资源记录进行加密签名,并将这些签名提供给你。然后,你可以根据这些签名验证来自权威 DNS 服务器的响应,以确保响应没有被篡改。
DNSSEC 没有解决隐私问题。DNSSEC 查询仍然是明文传输的,允许进行被动观察。
基于 TLS 的 DNS
基于 TLS 的 DNS(DoT),在 RFC 7858 中有详细说明(tools.ietf.org/html/rfc7858/),通过使用传输层安全协议**(TLS)在客户端和其 DNS 服务器之间建立加密连接,解决了安全性和隐私问题。TLS 是一种常用的协议,用于提供网络节点之间加密的安全通信。使用 TLS,DNS 请求和响应在传输过程中完全加密,使得攻击者无法窃听或篡改响应。DoT 通过认证通道来确保数据的真实性。它不需要像 DNSSEC 那样依赖加密签名,因为 DNS 服务器和客户端之间的整个对话都是加密的。
DoT 使用与常规 DNS 流量不同的网络端口。
基于 HTTPS 的 DNS
基于 HTTPS 的 DNS(DoH),在 RFC 8484 中有详细说明(tools.ietf.org/html/rfc8484/),旨在解决 DNS 安全性和隐私问题,同时使用一个被广泛使用的 TCP 端口。与 DoT 一样,DoH 通过加密连接传输数据,认证通道。DoH 使用一个常见的端口,并将 DNS 请求和响应映射到 HTTP 请求和响应。通过 HTTP 的查询可以利用所有 HTTP 特性,如缓存、压缩、代理和重定向。
你学到的内容
本章内容涉及广泛。你学习了 IP 地址分配,从 IPv4 的多播、广播、TCP 和 UDP 端口、套接字地址、网络地址转换到 ARP 等基础知识。然后,你了解了 IPv6、其地址类别以及相较于 IPv4 的优势。
你了解了主要的网络路由协议,ICMP 和 DNS。我再次推荐 Charles M. Kozierok 的 TCP/IP Guide(No Starch Press,2005),它对本章主题进行了广泛的覆盖。
第二部分
套接字级编程
第三章:可靠的 TCP 数据流

TCP 允许你在网络上可靠地流式传输数据。本章将深入探讨该协议,重点介绍直接受我们为建立 TCP 连接和通过这些连接传输数据的代码影响的方面。这些知识将帮助你调试程序中的网络相关问题。
我们将首先介绍 TCP 握手过程、序列号、确认、重传和其他特性。接下来,我们将使用 Go 实现 TCP 会话的步骤,从拨号、监听、接受到会话终止。然后,我们将讨论超时和临时错误,如何检测它们,以及如何利用它们让用户满意。最后,我们将讨论如何早期检测不可靠的网络连接。Go 的标准库使你能够编写健壮的基于 TCP 的网络应用程序。但它不会手把手地教你。如果你没有注意管理传入数据或正确关闭连接,你的程序中会出现隐蔽的 bug。
什么使得 TCP 可靠?
TCP 之所以可靠,是因为它克服了数据包丢失或接收乱序数据包的影响。数据包丢失发生在数据未能到达目标——通常是由于数据传输错误(如无线网络干扰)或网络拥塞。网络拥塞发生在节点试图通过网络连接发送超出连接处理能力的数据,导致节点丢弃多余的数据包。例如,你无法通过一个 10 兆比特每秒(Mbps)的连接以每秒 1 千兆比特(Gbps)的速率发送数据。10Mbps 的连接很快就会饱和,参与数据流的节点会丢弃超出的数据。
TCP 会调整其数据传输速率,确保尽可能快速地传输数据,同时将丢包量保持在最小,即使网络状况发生变化——例如,Wi-Fi 信号减弱,或者目标节点的数据过载。这一过程被称为流量控制,它尽力弥补底层网络媒体的不足。TCP 无法在糟糕的网络上发送良好的数据,它依赖于网络硬件的支持。
TCP 还会跟踪已接收的数据包,并根据需要重新传输未确认的数据包。如果数据在传输过程中被重新路由,接收方也可能会接收到乱序的数据包。记住在第二章提到的,路由协议使用度量来确定如何路由数据包。这些度量会随着网络状况的变化而变化。不能保证在整个 TCP 会话期间,所有发送的数据包都会采取相同的路由。幸运的是,TCP 会组织乱序的数据包,并按顺序处理它们。
配合流量控制和重传机制,这些特性使 TCP 能够克服数据包丢失并确保数据能够成功传输到接收方。因此,TCP 使你不必担心这些错误。你可以专注于发送和接收数据。
处理 TCP 会话
TCP 会话 使你能够将任何大小的数据流发送到接收方,并收到接收方已接收数据的确认。这避免了你发送大量数据时,网络传输完成后才发现接收方并未接收到数据的低效问题。
就像人们偶尔点头表示他们在听对方说话一样,流媒体传输允许你在传输过程中接收接收方的反馈,这样你可以实时纠正任何错误。实际上,你可以把 TCP 会话看作是两个节点之间的对话。它从问候开始,进入对话,最后以告别结束。
在讨论 TCP 的具体细节时,我希望你明白 Go 会为你处理实现细节。当你处理 TCP 连接时,你的代码将会利用 net 包的接口。
使用 TCP 三次握手建立会话
TCP 连接使用三次握手来引导客户端与服务器建立联系,同时也将服务器与客户端连接起来。三次握手创建了一个已建立的 TCP 会话,客户端与服务器可以通过该会话交换数据。图 3-1 展示了握手过程中发送的三条消息。

图 3-1:三次握手过程,最终建立 TCP 会话
在建立 TCP 会话之前,服务器必须监听来自客户端的连接请求。(在本章中,我将 服务器 和 客户端 用于分别表示监听节点和拨号节点。TCP 本身并没有客户端和服务器的概念,而是指两个节点之间通过一个已建立的会话,其中一个节点联系另一个节点以建立该会话。)
握手的第一步,客户端发送一个带有 同步(SYN)标志 的数据包给服务器。这个 SYN 数据包告诉服务器客户端的能力以及对话期间的窗口设置。稍后我们会讨论接收窗口。接下来,服务器回应自己的数据包,数据包中同时设置了 确认(ACK) 和 SYN 标志。ACK 标志告诉客户端服务器已收到客户端的 SYN 数据包。服务器的 SYN 数据包告知客户端它同意的会话设置。最后,客户端回复一个 ACK 数据包,确认服务器的 SYN 数据包,从而完成三次握手。
完成三次握手过程后,TCP 会话建立,节点可以开始交换数据。TCP 会话保持空闲状态,直到一方有数据要传输。未管理的长时间空闲 TCP 会话可能导致内存的浪费。我们将在本章后面讨论如何在代码中管理空闲连接的技巧。
当你在代码中发起连接时,Go 会返回一个连接对象或一个错误。如果你收到连接对象,则表示 TCP 握手成功。你无需自己管理握手过程。
使用数据包的序列号确认接收数据包
每个 TCP 数据包都包含一个序列号,接收方用它来确认接收到每个数据包,并正确地对数据包进行排序,以便展示给你的 Go 应用程序(图 3-2)。

图 3-2:客户端和服务器交换序列号
客户端的操作系统决定初始序列号(X,在图 3-2 中),并在握手过程中将其发送到服务器的 SYN 数据包中。服务器通过在其 ACK 数据包中包含该序列号来确认收到该数据包。同样,服务器在其 SYN 数据包中将生成的序列号 Y 发送给客户端。客户端则通过 ACK 数据包回复服务器。
ACK 数据包使用序列号告诉发送方:“我已接收到所有数据包,直到包括该序列号的包为止。”一个 ACK 数据包可以确认接收到一个或多个发送方的数据包。发送方使用 ACK 数据包中的序列号来确定是否需要重新传输任何数据包。例如,如果发送方传输了多个序列号为 1 到 100 的数据包,但接收到来自接收方的序列号为 90 的 ACK,发送方就知道它需要重新传输序列号为 91 到 100 的数据包。
在编写和调试网络程序时,经常需要查看你的代码发送和接收的流量。为了捕获和检查 TCP 数据包,我强烈建议你熟悉 Wireshark(www.wireshark.org/)。这个程序将极大帮助你理解你的代码如何影响网络上传输的数据。欲了解更多信息,请参阅 Chris Sanders 所著的《Practical Packet Analysis》(第三版,No Starch,2017)。
如果你在 Wireshark 中查看应用程序的网络流量,你可能会注意到选择性确认(SACKs)。这些是用于确认接收到部分发送数据包的 ACK 数据包。例如,假设发送方传输了 100 个数据包,但只有数据包 1 到 59 和 81 到 100 到达接收方。接收方可以发送 SACK 数据包,告知发送方它接收到的部分数据包。
在这里,Go 处理了底层细节。你的代码无需关心序列号和确认信息。
接收缓冲区和窗口大小
由于 TCP 允许单个 ACK 报文确认多个传入的数据包,因此接收方必须在发送确认之前,告知发送方其接收缓冲区还有多少可用空间。接收缓冲区是为网络连接上的传入数据保留的内存块。接收缓冲区允许节点在不要求应用程序立即读取数据的情况下,接受一定量的数据。客户端和服务器都维护着各自的每个连接的接收缓冲区。当你的 Go 代码从网络连接对象中读取数据时,它是从该连接的接收缓冲区读取数据。
ACK 报文包含一个特别重要的信息:窗口大小,即发送方可以在不需要确认的情况下发送给接收方的字节数。如果客户端向服务器发送一个窗口大小为 24,537 的 ACK 报文,服务器就知道它可以向客户端发送 24,537 字节的数据,而无需客户端发送另一个 ACK 报文。窗口大小为零表示接收方的缓冲区已满,无法再接收更多数据。我们将在本章稍后讨论这种情况。
客户端和服务器都跟踪彼此的窗口大小,并尽力填满彼此的接收缓冲区。这种方法——在 ACK 报文中接收窗口大小,发送数据,在下一个 ACK 中接收更新后的窗口大小,然后发送更多数据——被称为滑动窗口,如图 3-3 所示。连接的每一方都提供了一个可以在任何时刻接收的数据窗口。

图 3-3:客户端的 ACK 广告,表示它能接收的数据量
在这段通信片段中,客户端发送了一个确认报文(ACK),用于确认之前接收到的数据。这个 ACK 包含了一个窗口大小为 3,072 字节。服务器现在知道,在收到客户端的确认之前,它最多可以发送 3,072 字节的数据。服务器发送了三个数据包,每个数据包大小为 1,024 字节,用以填充客户端的接收缓冲区。然后,客户端发送了另一个 ACK,并更新了窗口大小为 2,048 字节。这意味着客户端运行的应用程序在发送确认报文之前,从接收缓冲区读取了 2,048 字节的数据。然后,服务器再发送两个 1,024 字节的数据包来填充客户端的接收缓冲区,并等待另一个 ACK。
在这里,你只需要关心的是在建立 TCP 连接时,Go 为你提供的连接对象的读写操作。如果发生了问题,Go 肯定会通过返回错误通知你。
优雅地终止 TCP 会话
像握手过程一样,优雅地终止 TCP 会话也需要交换一系列数据包。连接的任何一方都可以通过发送 finish (FIN) 数据包来启动终止序列。在图 3-4 中,客户端通过向服务器发送 FIN 数据包来发起终止。

图 3-4:客户端向服务器发起 TCP 会话终止。
客户端的连接状态从 ESTABLISHED 变为 FIN_WAIT_1,表示客户端正在从自身端拆除连接,并等待服务器的确认。服务器确认客户端的 FIN 后,将连接状态从 ESTABLISHED 变为 CLOSE_WAIT。服务器发送自己的 FIN 数据包,连接状态变为 LAST_ACK,表示它在等待客户端的最终确认。客户端确认服务器的 FIN 后进入 TIME_WAIT 状态,目的是让客户端的最后一个 ACK 数据包能够到达服务器。客户端等待最大报文段生存时间的两倍(根据 RFC 793,报文段生存时间默认是两分钟,但操作系统可能允许你调整此值),然后将连接状态改为 CLOSED,且不再需要服务器的任何进一步输入。最大报文段生存时间是指 TCP 报文段在传输中可以存在的最长时间,超过此时间发送方会认为它已经丢失。收到客户端最后一个 ACK 数据包后,服务器立即将连接状态改为 CLOSED,完全终止 TCP 会话。
像初始握手一样,当你关闭连接对象时,Go 会处理 TCP 连接拆除过程中的细节。
处理不太优雅的连接终止
并非所有连接都能礼貌地终止。在某些情况下,打开 TCP 连接的应用程序可能会崩溃或因为某种原因突然停止运行。这时,TCP 连接会立即关闭。来自原连接另一端的任何数据包都会触发关闭一端返回 reset (RST) 数据包。RST 数据包通知发送方,接收方的连接已关闭,不再接受数据。发送方应该关闭连接的一侧,知道接收方忽略了任何未确认的数据包。
中间节点,如防火墙,能够向连接中的每个节点发送 RST 数据包,从而在中间终止连接。
使用 Go 标准库建立 TCP 连接
Go 标准库中的net包提供了良好的支持,用于创建基于 TCP 的服务器和客户端,并能够连接到这些服务器。尽管如此,确保正确处理连接仍然是你的责任。你的软件应该时刻关注传入的数据,并始终努力优雅地关闭连接。让我们编写一个 TCP 服务器,能够监听传入的 TCP 连接,客户端发起连接,接受并异步处理每个连接,交换数据,并终止连接。
绑定、监听和接受连接
要创建一个能够监听传入连接的 TCP 服务器(称为监听器),使用net.Listen函数。这个函数将返回一个实现了net.Listener接口的对象。列表 3-1 展示了如何创建一个监听器。
package ch03
import (
"net"
"testing"
)
func TestListener(t *testing.T) {
1listener, err := net.Listen("2tcp", "3127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
4 defer func() { _ = listener.Close() }()
t.Logf("bound to %q", 5listener.Addr())
}
列表 3-1:使用随机端口在 127.0.0.1 上创建监听器 (listen_test.go)
net.Listen函数接受一个网络类型 2 和一个由冒号分隔的 IP 地址和端口 3。该函数返回一个net.Listener接口 1 和一个error接口。如果函数成功返回,监听器将绑定到指定的 IP 地址和端口。绑定意味着操作系统已将给定 IP 地址上的端口专门分配给监听器。操作系统不允许其他进程在绑定的端口上监听传入的流量。如果你尝试将监听器绑定到当前已绑定的端口,net.Listen将返回错误。
你可以选择将 IP 地址和端口参数留空。如果端口为零或为空,Go 将为你的监听器随机分配一个端口号。你可以通过调用其Addr方法 5 来检索监听器的地址。同样,如果省略 IP 地址,监听器将绑定到系统上的所有单播和任播 IP 地址。如果同时省略 IP 地址和端口,或者将冒号作为第二个参数传递给net.Listen,则会导致监听器绑定到所有单播和任播 IP 地址,并使用随机端口。
在大多数情况下,你应该将tcp作为net.Listener第一个参数的网络类型。你可以通过传入tcp4来限制监听器仅绑定到 IPv4 地址,或者通过传入tcp6来专门绑定到 IPv6 地址。
你应该始终小心优雅地关闭监听器,调用其Close方法 4,通常如果对你的代码有意义,可以在defer中进行。诚然,这是一个测试用例,Go 会在测试完成时拆除监听器,但这仍然是良好的实践。不关闭监听器可能会导致内存泄漏或死锁,因为对监听器的Accept方法的调用可能会无限期阻塞。立即关闭监听器会解除Accept方法的阻塞。
列表 3-2 演示了监听器如何接受传入的 TCP 连接。
1 for {
2conn, err := 3listener.Accept()
if err != nil {
return err
}
4 go func(c net.Conn) {
5 defer c.Close()
// Your code would handle the connection here.
}(conn)
}
列表 3-2:接受并处理传入的 TCP 连接请求
除非你只想接受单个传入连接,否则你需要使用 for 循环 1,这样你的服务器就会接受每个传入连接,并在 goroutine 中处理它,然后继续循环,准备接受下一个连接。串行接受连接是完全可以接受的,而且效率也不错,但超出这个范围后,你应该使用 goroutine 来处理每个连接。如果你的用例要求你可以写出串行化的代码来接受连接,但那样做会非常低效,并且无法充分发挥 Go 的优势。我们通过调用监听器的 Accept 方法 2 来启动 for 循环。这个方法会阻塞,直到监听器检测到传入的连接并完成客户端和服务器之间的 TCP 握手过程。该调用返回一个 net.Conn 接口 3 和一个 error。例如,如果握手失败或监听器关闭,错误接口将为非 nil。
连接接口的底层类型是指向 net.TCPConn 对象的指针,因为你正在接受 TCP 连接。连接接口代表服务器端的 TCP 连接。在大多数情况下,net.Conn 提供了与客户端进行一般交互所需的所有方法。然而,如果你需要更多的控制,net.TCPConn 对象提供了额外的功能,我们将在第四章讨论这些功能。
为了并发处理客户端连接,你会启动一个 goroutine 异步处理每个连接 4,这样监听器就可以为下一个传入的连接做好准备。然后,在 goroutine 退出之前,你调用连接的 Close 方法 5,以通过发送 FIN 数据包来优雅地终止与服务器的连接。
与服务器建立连接
从客户端的角度来看,Go 的标准库 net 包使得与服务器建立连接变得非常简单。清单 3-3 是一个示例,演示了如何发起与监听在 127.0.0.1 上的服务器的 TCP 连接,端口是随机选择的。
package ch03
import (
"io"
"net"
"testing"
)
func TestDial(t *testing.T) {
// Create a listener on a random port.
listener, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
done := make(chan struct{})
1 go func() {
defer func() { done <- struct{}{} }()
for {
conn, err := 2listener.Accept()
if err != nil {
t.Log(err)
return
}
3 go func(c net.Conn) {
defer func() {
c.Close()
done <- struct{}{}
}()
buf := make([]byte, 1024)
for {
n, err := 4c.Read(buf)
if err != nil {
if err != io.EOF {
t.Error(err)
}
return
}
t.Logf("received: %q", buf[:n])
}
}(conn)
}
}()
5conn, err := net.Dial("6tcp", 7listener.Addr().String())
if err != nil {
t.Fatal(err)
}
8 conn.Close()
<-done
9 listener.Close()
<-done
}
清单 3-3:建立与 127.0.0.1 的连接 (dial_test.go)
首先,你在 IP 地址 127.0.0.1 上创建一个监听器,客户端将连接到该地址。你完全省略了端口号,因此 Go 会随机选择一个可用的端口。然后,你将监听器放入一个 goroutine 1 中,这样你就可以在测试的后续部分处理客户端的连接。监听器的 goroutine 包含类似 清单 3-2 的代码,用于循环接受传入的 TCP 连接,并将每个连接放入自己的 goroutine 中。(我们通常称这个 goroutine 为 handler。我稍后会解释 handler 的实现细节,但它会一次从套接字读取最多 1024 字节,并记录它接收到的内容。)
标准库的net.Dial函数与net.Listen函数类似,都会接受一个网络协议(如tcp)以及一个 IP 地址和端口的组合——在此案例中,是目标监听器的 IP 地址和端口。你可以用主机名代替 IP 地址,用服务名(如http)代替端口号。如果主机名解析为多个 IP 地址,Go 会按顺序尝试每一个,直到成功连接或所有 IP 地址尝试完毕。由于 IPv6 地址包含冒号分隔符,因此必须将 IPv6 地址用方括号括起来。例如,"[2001:ed27::1]:https"表示 IPv6 地址 2001:ed27::1 的 443 端口。Dial返回一个连接对象和一个error接口值。
现在你已经成功连接到监听器,从客户端一侧启动优雅关闭连接。接收到 FIN 包后,Read方法会返回io.EOF错误,通知监听器代码你已经关闭了连接的一端。连接的处理程序会退出,并在退出时调用连接的Close方法。这将向你的连接发送一个 FIN 包,完成 TCP 会话的优雅终止。
最后,你关闭监听器。监听器的Accept方法会立即解除阻塞并返回一个错误。这个错误不一定是失败,所以你只需记录它并继续。这不会导致你的测试失败。监听器的 goroutine 退出,测试完成。
理解超时和临时错误
在理想的世界里,你的连接尝试会立即成功,所有的读写尝试永远不会失败。但你需要期望最好的同时准备最坏的情况。你需要一种方式来判断一个错误是否是临时的,或者是否需要完全终止连接。error接口提供的信息不足以做出这种判断。幸运的是,Go 的net包提供了更多的洞察,只要你知道如何使用它。
从net包中的函数和方法返回的错误通常实现了net.Error接口,其中包括两个重要的方法:Timeout和Temporary。当操作系统告诉 Go 资源暂时不可用、调用会被阻塞,或连接超时时,Timeout方法会返回true,在 Unix 系统和 Windows 中都是如此。稍后我们会讨论如何利用超时。Temporary方法会在错误的Timeout函数返回true、函数调用被中断,或者系统中打开的文件过多时返回true,通常是因为你超过了操作系统的资源限制。
由于net包中的函数和方法返回的是更通用的error接口,因此你会看到本章的代码使用类型断言来验证是否收到net.Error,如 Listing 3-4 所示。
if nErr, ok := err.(net.Error); ok && !nErr.Temporary() { return err }
Listing 3-4:断言net.Error以检查错误是否是临时的
稳健的网络代码不会仅仅依赖于error接口。相反,它会充分利用net.Error的方法,甚至进一步深入并断言底层的net.OpError结构体,该结构体包含有关连接的更多细节,如导致错误的操作、网络类型、源地址等。我鼓励你阅读net.OpError的文档(可以在golang.org/pkg/net/#OpError/找到),以了解net.Error接口之外的具体错误。
使用DialTimeout函数进行连接超时处理
使用Dial函数有一个潜在的问题:你只能依赖操作系统来超时每个连接尝试。例如,如果你在交互式应用程序中使用Dial函数,而你的操作系统在两个小时后超时连接尝试,那么应用程序的用户可能不想等这么久,更不用说给你的应用程序打五星好评了。
为了保持应用程序的可预测性并让用户满意,最好自己控制超时。例如,你可能希望连接到一个低延迟的服务,该服务在可用时会迅速响应。如果该服务没有响应,你将希望快速超时并切换到下一个服务。
一种解决方案是显式定义每个连接的超时时间,并改用DialTimeout函数。Listing 3-5 实现了这个解决方案。
package ch03
import (
"net"
"syscall"
"testing"
"time"
)
func 1DialTimeout(network, address string, timeout time.Duration,
) (net.Conn, error) {
d := net.Dialer{
2 Control: func(_, addr string, _ syscall.RawConn) error {
return &net.DNSError{
Err: "connection timed out",
Name: addr,
Server: "127.0.0.1",
IsTimeout: true,
IsTemporary: true,
}
},
Timeout: timeout,
}
return d.Dial(network, address)
}
func TestDialTimeout(t *testing.T) {
c, err := DialTimeout("tcp", "10.0.0.1:http", 35*time.Second)
if err == nil {
c.Close()
t.Fatal("connection did not time out")
}
nErr, ok := 4err.(net.Error)
if !ok {
t.Fatal(err)
}
if 5!nErr.Timeout() {
t.Fatal("error is not a timeout")
}
}
Listing 3-5:在发起 TCP 连接时指定超时持续时间(dial_timeout_test.go)
由于net.DialTimeout函数 1 没有让你控制其net.Dialer来模拟拨号器的输出,你正在使用我们自己实现的、符合签名的版本。你的DialTimeout函数重写了net.Dialer的Control函数 2 以返回一个错误。你在模拟 DNS 超时错误。
与net.Dial函数不同,DialTimeout函数包含一个额外的参数——超时持续时间 3。由于此处的超时持续时间为五秒,如果连接未在五秒内成功建立,则连接尝试将超时。在这个测试中,你拨号到 10.0.0.0,这是一个不可路由的 IP 地址,意味着你的连接尝试必然会超时。为了使测试通过,你需要首先使用类型断言来验证你已经收到net.Error4,然后才能检查它的Timeout方法 5。
如果你拨打一个解析到多个 IP 地址的主机,Go 会在每个 IP 地址之间启动连接竞争,给主要 IP 地址一个先发优势。第一个成功的连接将继续存在,其余的竞争者会取消连接尝试。如果所有连接都失败或超时,net.DialTimeout会返回一个错误。
使用带有截止时间的上下文来超时连接
一个更现代的解决方案是使用标准库的context包中的上下文来超时连接尝试。上下文是一个对象,你可以用它向异步进程发送取消信号。它还允许你在达到截止时间或计时器到期后发送取消信号。
所有可取消的上下文在实例化时都会返回一个相应的cancel函数。cancel函数提供了更大的灵活性,因为你可以选择在上下文达到截止时间之前取消上下文。你还可以将其cancel函数传递给其他部分的代码,以移交取消控制。例如,你可以监听操作系统发出的特定信号,例如在用户按下 ctrl-C 键组合时发送到应用程序的信号,以便在终止应用程序之前优雅地中止连接尝试并拆除现有连接。
示例 3-6 展示了一个测试,它使用context代替DialTimeout实现相同的功能。
package ch03
import (
"context"
"net"
"syscall"
"testing"
"time"
)
func TestDialContext(t *testing.T) {
1 dl := time.Now().Add(5 * time.Second)
2 ctx, cancel := context.WithDeadline(context.Background(), dl)
3 defer cancel()
var d net.Dialer // DialContext is a method on a Dialer
d.Control = 4func(_, _ string, _ syscall.RawConn) error {
// Sleep long enough to reach the context's deadline.
time.Sleep(5*time.Second + time.Millisecond)
return nil
}
conn, err := d.DialContext(5ctx, "tcp", "10.0.0.0:80")
if err == nil {
conn.Close()
t.Fatal("connection did not time out")
}
nErr, ok := err.(net.Error)
if !ok {
t.Error(err)
} else {
if !nErr.Timeout() {
t.Errorf("error is not a timeout: %v", err)
}
}
6 if ctx.Err() != context.DeadlineExceeded {
t.Errorf("expected deadline exceeded; actual: %v", ctx.Err())
}
}
示例 3-6:使用带有截止时间的上下文来超时连接尝试(dial_context_test.go)
在你进行连接尝试之前,你创建一个未来五秒的截止时间上下文 1,之后上下文会自动取消。接着,你使用context.WithDeadline函数 2 创建上下文及其cancel函数,并在过程中设置截止时间。良好的实践是延迟调用cancel函数 3,以确保上下文尽快被垃圾回收。然后,你重写拨号器的Control函数 4,稍微延迟连接,确保超过上下文的截止时间。最后,你将上下文作为第一个参数传递给DialContext函数 5。测试末尾的健全性检查 6 确保是达到截止时间取消了上下文,而不是错误地调用了cancel。
与DialTimeout一样,如果一个主机解析到多个 IP 地址,Go 会在每个 IP 地址之间启动连接竞争,给主要 IP 地址一个先发优势。第一个成功的连接将继续存在,其余的竞争者会取消连接尝试。如果所有连接都失败或上下文达到截止时间,net.Dialer.DialContext会返回一个错误。
通过取消上下文来中止连接
使用上下文的另一个好处是cancel函数本身。你可以使用它按需取消连接尝试,而无需指定截止时间,如示例 3-7 所示。
package ch03
import (
"context"
"net"
"syscall"
"testing"
"time"
)
func TestDialContextCancel(t *testing.T) {
ctx, cancel := 1context.WithCancel(context.Background())
sync := make(chan struct{})
2 go func() {
defer func() { sync <- struct{}{} }()
var d net.Dialer
d.Control = func(_, _ string, _ syscall.RawConn) error {
time.Sleep(time.Second)
return nil
}
conn, err := d.DialContext(ctx, "tcp", "10.0.0.1:80")
if err != nil {
t.Log(err)
return
}
conn.Close()
t.Error("connection did not time out")
}()
3 cancel()
<-sync
if ctx.Err() != 4context.Canceled {
t.Errorf("expected canceled context; actual: %q", ctx.Err())
}
}
示例 3-7:直接取消上下文以中止连接尝试
(dial_cancel_test.go)
你可以使用context.WithCancel来返回一个上下文和一个取消该上下文的函数,而不是创建一个带有截止日期的上下文并等待截止日期中止连接尝试 1。由于你是手动取消上下文,因此你需要创建一个闭包,并在一个 goroutine 中启动它来处理连接尝试 2。一旦拨号器尝试连接并与远程节点进行握手,你调用cancel函数 3 来取消上下文。这将导致DialContext方法立即返回一个非nil错误,从而退出 goroutine。你可以检查上下文的Err方法,以确保取消调用是导致上下文被取消的原因,而不是示例 3-6 中的截止日期。在这种情况下,上下文的Err方法应返回一个context.Canceled错误 4。
取消多个拨号器
你可以将相同的上下文传递给多个DialContext调用,并通过执行上下文的cancel函数同时取消所有调用。例如,假设你需要通过 TCP 检索一个位于多个服务器上的资源。你可以异步地拨打每个服务器,将相同的上下文传递给每个拨号器。然后,在收到其中一个服务器的响应后,你可以中止其余的拨号器。
在示例 3-8 中,你将相同的上下文传递给多个拨号器。当你收到第一个响应时,你取消上下文并中止剩余的拨号器。
package ch03
import (
"context"
"net"
"sync"
"testing"
"time"
)
func TestDialContextCancelFanOut(t *testing.T) {
1 ctx, cancel := context.WithDeadline(
context.Background(),
time.Now().Add(10*time.Second),
)
listener, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
2 go func() {
// Only accepting a single connection.
conn, err := listener.Accept()
if err == nil {
conn.Close()
}
}()
3 dial := func(ctx context.Context, address string, response chan int,
id int, wg *sync.WaitGroup) {
defer wg.Done()
var d net.Dialer
c, err := d.DialContext(ctx, "tcp", address)
if err != nil {
return
}
c.Close()
select {
case <-ctx.Done():
case response <- id:
}
}
res := make(chan int)
var wg sync.WaitGroup
4 for i := 0; i < 10; i++ {
wg.Add(1)
go dial(ctx, listener.Addr().String(), res, i+1, &wg)
}
5 response := <-res
cancel()
wg.Wait()
close(res)
if ctx.Err() != 6context.Canceled {
t.Errorf("expected canceled context; actual: %s",
ctx.Err(),
)
}
t.Logf("dialer %d retrieved the resource", response)
}
示例 3-8:在收到第一个响应后取消所有未完成的拨号器 (dial_fanout_test.go)
你使用context.WithDeadline1 创建一个上下文,因为你希望在检查上下文的Err方法时得到三种可能的结果:context.Canceled、context.DeadlineExceeded或nil。你预计Err将返回context.Canceled错误,因为你的测试通过调用cancel函数中止了拨号器。
首先,你需要一个监听器。这个监听器接受一个连接,并在成功握手后关闭该连接 2。接下来,你创建你的拨号器。由于你要启动多个拨号器,因此将拨号代码抽象到一个独立的函数中是合理的 3。这个匿名函数使用DialContext拨打给定的地址。如果成功,它会将拨号器的 ID 通过响应通道发送出去,前提是你还没有取消上下文。你通过在不同的 goroutine 中使用for循环调用dial来启动多个拨号器 4。如果dial在调用DialContext时被阻塞,因为另一个拨号器赢得了竞赛,你会取消上下文,无论是通过cancel函数还是截止日期,导致拨号函数提前退出。你使用一个等待组来确保在取消上下文后,测试不会继续,直到所有dial的 goroutine 终止。
一旦 goroutine 开始运行,其中一个将赢得比赛并成功连接到监听器。你会在res通道 5 上收到获胜拨号器的 ID,然后通过取消上下文来中止失败的拨号器。此时,wg.Wait的调用会阻塞,直到被中止的拨号器 goroutine 返回。最后,你确保是你调用的cancel导致了上下文的取消 6。调用cancel并不能保证Err返回context.Canceled。截止时间可以取消上下文,此时调用cancel将变为无操作,Err将返回context.DeadlineExceeded。在实际操作中,这个区别可能对你来说无关紧要,但如果你需要,它还是存在的。
实现截止时间
Go 的网络连接对象允许你为读取和写入操作设置截止时间。截止时间允许你控制网络连接在没有数据包传输的情况下能保持空闲多久。你可以通过在连接对象上使用SetReadDeadline方法来控制Read截止时间,使用SetWriteDeadline方法来控制Write截止时间,或者通过SetDeadline方法同时控制两者。当连接达到其读取截止时间时,所有当前阻塞的和未来对网络连接Read方法的调用会立即返回超时错误。同样,当连接达到写入截止时间时,网络连接的Write方法会返回超时错误。
Go 的网络连接默认不设置读取和写入操作的截止时间,这意味着你的网络连接可能会长时间保持空闲。这可能导致你无法及时发现网络故障,例如断开连接的电缆,因为当没有流量传输时,检测两个节点之间的网络问题会更加困难。
清单 3-9 中的服务器实现了其连接对象的截止时间。
package ch03
import (
"io"
"net"
"testing"
"time"
)
func TestDeadline(t *testing.T) {
sync := make(chan struct{})
listener, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
go func() {
conn, err := listener.Accept()
if err != nil {
t.Log(err)
return
}
defer func() {
conn.Close()
close(sync) // read from sync shouldn't block due to early return
}()
1 err = conn.SetDeadline(time.Now().Add(5 * time.Second))
if err != nil {
t.Error(err)
return
}
buf := make([]byte, 1)
_, err = conn.Read(buf) // blocked until remote node sends data
nErr, ok := err.(net.Error)
if !ok || 2!nErr.Timeout() {
t.Errorf("expected timeout error; actual: %v", err)
}
sync <- struct{}{}
3 err = conn.SetDeadline(time.Now().Add(5 * time.Second))
if err != nil {
t.Error(err)
return
}
_, err = conn.Read(buf)
if err != nil {
t.Error(err)
}
}()
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
<-sync
_, err = conn.Write([]byte("1"))
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 1)
_, err = conn.Read(buf) // blocked until remote node sends data
if err != 4io.EOF {
t.Errorf("expected server termination; actual: %v", err)
}
}
清单 3-9:服务器强制截止时间终止网络连接(deadline_test.go)。
一旦服务器接受客户端的 TCP 连接,你就为连接设置了读取截止时间 1。由于客户端不会发送数据,Read方法的调用会阻塞,直到连接超过读取截止时间。五秒钟后,Read返回一个错误,你验证它是一个超时错误 2。对连接对象的任何未来读取操作将立即导致另一个超时错误。然而,你可以通过再次将截止时间推迟来恢复连接对象的功能 3。这样做之后,第二次调用Read会成功。服务器关闭其网络连接的一端,这会启动与客户端的终止过程。客户端当前在Read调用上被阻塞,当网络连接关闭时,返回io.EOF4。
我们通常使用截止时间来提供一个窗口,在这个时间窗口内远程节点可以通过网络连接发送数据。当你从远程节点读取数据时,会将截止时间往后推。远程节点发送更多数据时,你再次将截止时间推后,以此类推。如果在规定的时间内没有收到远程节点的消息,你可以假设远程节点已消失且没有收到其 FIN,或者它处于空闲状态。
实现心跳
对于可能经历长时间空闲期的长期网络连接,明智的做法是在节点之间实现心跳,以便延长截止时间。这样可以让你迅速识别网络问题,并及时重新建立连接,而不是等到应用程序开始传输数据时才发现网络错误。通过这种方式,你可以确保应用程序在需要时始终拥有良好的网络连接。
对我们来说,心跳是向远程端发送的一条消息,目的是引发一个回复,我们可以利用这个回复来延长网络连接的截止时间。节点以定期的间隔发送这些消息,像心跳一样。这个方法不仅可以在各种操作系统上移植,而且还确保使用网络连接的应用程序正在响应,因为应用程序实现了心跳功能。此外,这种技术通常与可能阻塞 TCP 保活的防火墙兼容。我们将在第四章讨论保活技术。
首先,你需要一些代码,可以在 goroutine 中定期 ping。你不想在最近从远程节点收到数据时无谓地 ping 它,因此你需要一种重置 ping 定时器的方式。清单 3-10 是一个简单的实现,来自名为 ping.go 的文件,符合这些要求。
在我的心跳示例中,我使用了 ping 和 pong 消息,其中接收到 ping 消息—挑战—通知接收方应该回复 pong 消息—响应。挑战和响应消息是任意的。你可以在这里使用任何你想要的内容,只要远程节点知道你的目的是引发它的回复。
package ch03
import (
"context"
"io"
"time"
)
const defaultPingInterval = 30 * time.Second
func Pinger(ctx context.Context, w io.Writer, reset <-chan time.Duration) {
var interval time.Duration
select {
case <-ctx.Done():
return
1 case interval = <-reset: // pulled initial interval off reset channel
default:
}
if interval <= 0 {
interval = defaultPingInterval
}
2 timer := time.NewTimer(interval)
defer func() {
if !timer.Stop() {
<-timer.C
}
}()
for {
select {
3 case <-ctx.Done():
return
4 case newInterval := <-reset:
if !timer.Stop() {
<-timer.C
}
if newInterval > 0 {
interval = newInterval
}
5 case <-timer.C:
if _, err := w.Write([]byte("ping")); err != nil {
// track and act on consecutive timeouts here
return
}
}
6 _ = timer.Reset(interval)
}
}
清单 3-10:一个定期发送网络连接 ping 请求的函数(ping.go)
Pinger 函数定期向给定的写入器发送 ping 消息。因为它是为了在 goroutine 中运行的,Pinger 将上下文作为第一个参数,这样你就可以终止它并防止其泄漏。它的其余参数包括一个 io.Writer 接口和一个信号重置定时器的通道。你创建一个缓冲通道并设置一个持续时间,以确定定时器的初始间隔 1。如果间隔不大于零,则使用默认的 ping 间隔。
你将定时器初始化为间隔 2,并设置了一个延迟调用来清空定时器的通道,以避免必要时的泄漏。这个无尽的 for 循环包含一个 select 语句,在这里你会阻塞,直到发生以下三件事之一:上下文被取消、接收到重置定时器的信号,或定时器到期。如果上下文被取消 3,函数返回,并且不再发送任何 ping。如果代码选择了 reset 通道 4,你不应该发送 ping,定时器会在再次迭代 select 语句之前重置 6。
如果定时器到期 5,你将 ping 消息写入写入器,定时器会在下一次迭代前重置。如果你想的话,你可以使用这个 case 来跟踪在写入器写入过程中发生的任何连续超时。为此,你可以传入上下文的 cancel 函数,并在达到连续超时阈值时调用它。
清单 3-11 演示了如何使用 清单 3-10 中介绍的 Pinger 函数,给它一个写入器并在 goroutine 中运行。然后,你可以在预期的间隔内从读取器读取 ping 数据,并使用不同的间隔重置 ping 定时器。
package ch03
import (
"context"
"fmt"
"io"
"time"
)
func ExamplePinger() {
ctx, cancel := context.WithCancel(context.Background())
r, w := io.Pipe() // in lieu of net.Conn
done := make(chan struct{})
1 resetTimer := make(chan time.Duration, 1)
resetTimer <- time.Second // initial ping interval
go func() {
Pinger(ctx, w, resetTimer)
close(done)
}()
receivePing := func(d time.Duration, r io.Reader) {
if d >= 0 {
fmt.Printf("resetting timer (%s)\n", d)
resetTimer <- d
}
now := time.Now()
buf := make([]byte, 1024)
n, err := r.Read(buf)
if err != nil {
fmt.Println(err)
}
fmt.Printf("received %q (%s)\n",
buf[:n], time.Since(now).Round(100*time.Millisecond))
}
2 for i, v := range []int64{0, 200, 300, 0, -1, -1, -1} {
fmt.Printf("Run %d:\n", i+1)
receivePing(time.Duration(v)*time.Millisecond, r)
}
cancel()
<-done // ensures the pinger exits after canceling the context
// Output:
3 // Run 1:
// resetting timer (0s)
// received "ping" (1s)
4 // Run 2:
// resetting timer (200ms)
// received "ping" (200ms)
5 // Run 3:
// resetting timer (300ms)
// received "ping" (300ms)
6 // Run 4:
// resetting timer (0s)
// received "ping" (300ms)
7 // Run 5:
// received "ping" (300ms)
// Run 6:
// received "ping" (300ms)
// Run 7:
// received "ping" (300ms)
}
清单 3-11:测试 pinger 并重置其 ping 定时器间隔(ping_example_test.go)
在这个例子中,你创建了一个缓冲通道 1,用于发送重置 Pinger 定时器的信号。在将通道传递给 Pinger 函数之前,你先在 resetTimer 通道中设置了一个初始的 ping 间隔为一秒。你将使用这个持续时间来初始化 Pinger 的定时器,并决定何时将 ping 消息写入写入器。
你在循环 2 中运行一系列毫秒级的持续时间,将每个持续时间传递给 receivePing 函数。此函数将 ping 定时器重置为给定的持续时间,然后等待在给定的读取器上接收 ping 消息。最后,它会打印接收 ping 消息所花费的时间到标准输出。Go 会将标准输出与示例中的预期输出进行比较。
在第一次迭代 3 中,你传入了零的持续时间,这告诉 Pinger 使用先前的持续时间来重置定时器——在这个例子中为一秒。正如预期的那样,读取器在一秒后接收到 ping 消息。第二次迭代 4 将 ping 定时器重置为 200 毫秒。一旦这个时间到期,读取器将接收到 ping 消息。第三次运行将 ping 定时器重置为 300 毫秒 5,ping 消息会在 300 毫秒时到达。
你为运行 4 6 传递了一个零时长,从而保留了上次运行的 300 毫秒 ping 计时器。我发现使用零时长来表示“使用上一个计时器时长”这一技巧很有用,因为我不需要追踪初始的 ping 计时器时长。我可以简单地用我想要在 TCP 会话剩余部分使用的时长来初始化计时器,并且每次需要预 empt 传输下一个 ping 消息时,通过传递零时长来重置计时器。将来要更改 ping 计时器时长,只需修改一行,而不是每次发送resetTimer通道时都进行修改。
运行 5 到 7 次只需监听传入的 ping,而不重置 ping 计时器。如预期所示,读者在最后三次运行时每隔 300 毫秒接收一次 ping。
将清单 3-10 保存为名为ping.go的文件,并将清单 3-11 保存为名为ping_example_test.go的文件,你可以通过执行以下命令运行示例:
$ **go test ping.go ping_example_test.go**
通过使用心跳来提前截止时间
网络连接的每一方都可以使用Pinger来提前推送截止时间,如果另一方变得空闲,而之前的示例只展示了单方使用Pinger。当任一节点在网络连接中收到数据时,其 ping 计时器应重置,以防止发送不必要的 ping。清单 3-12 是一个新文件,名为ping_test.go,展示了如何使用传入消息来提前推送截止时间。
package ch03
import (
"context"
"io"
"net"
"testing"
"time"
)
func TestPingerAdvanceDeadline(t *testing.T) {
done := make(chan struct{})
listener, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
begin := time.Now()
go func() {
defer func() { close(done) }()
conn, err := listener.Accept()
if err != nil {
t.Log(err)
return
}
ctx, cancel := context.WithCancel(context.Background())
defer func() {
cancel()
conn.Close()
}()
resetTimer := make(chan time.Duration, 1)
resetTimer <- time.Second
go Pinger(ctx, conn, resetTimer)
err = conn.SetDeadline(time.Now().Add(15 * time.Second))
if err != nil {
t.Error(err)
return
}
buf := make([]byte, 1024)
for {
n, err := conn.Read(buf)
if err != nil {
return
}
t.Logf("[%s] %s",
time.Since(begin).Truncate(time.Second), buf[:n])
2 resetTimer <- 0
err = 3conn.SetDeadline(time.Now().Add(5 * time.Second))
if err != nil {
t.Error(err)
return
}
}
}()
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
buf := make([]byte, 1024)
4 for i := 0; i < 4; i++ { // read up to four pings
n, err := conn.Read(buf)
if err != nil {
t.Fatal(err)
}
t.Logf("[%s] %s", time.Since(begin).Truncate(time.Second), buf[:n])
}
_, err = 5conn.Write([]byte("PONG!!!")) // should reset the ping timer
if err != nil {
t.Fatal(err)
}
6 for i := 0; i < 4; i++ { // read up to four more pings
n, err := conn.Read(buf)
if err != nil {
if err != io.EOF {
t.Fatal(err)
}
break
}
t.Logf("[%s] %s", time.Since(begin).Truncate(time.Second), buf[:n])
}
<-done
end := time.Since(begin).Truncate(time.Second)
t.Logf("[%s] done", end)
if end != 79*time.Second {
t.Fatalf("expected EOF at 9 seconds; actual %s", end)
}
}
清单 3-12:接收数据会提前截止时间(ping_test.go)
你启动一个监听器,接受一个连接,启动一个每秒 ping 一次的Pinger,并将初始截止时间设置为五秒。 从客户端的角度来看,它会收到四个 ping 消息,然后当服务器达到其截止时间并终止连接时,会收到一个io.EOF。然而,客户端可以通过在服务器达到截止时间之前发送数据,提前推送服务器的截止时间。
如果服务器从连接中读取数据,它可以确认网络连接仍然良好。因此,它可以通知Pinger重置其计时器并将连接的截止时间推后。为了避免套接字终止,客户端会在发送明确的 pong 消息之前,监听服务器发出的四个 ping 消息。这应该能为客户端争取五秒钟,直到服务器达到其截止时间。客户端会再读取四个 ping 消息,然后等待不可避免的结果。你可以检查到服务器终止连接时,总共已经过去了九秒,表明客户端的 pong 消息成功触发了 ping 计时器的重置。
实际上,这种提前调整 ping 计时器的方法可以减少不必要的 ping 对带宽的消耗。如果你刚刚收到数据连接,通常不需要挑战网络连接的远程端。
字符串"ping"和"pong"是任意的。你可以使用更小的负载,比如一个字节,来达到相同的目的,只要网络连接的两端都达成共识,确定什么值表示 ping 和 pong。
你所学到的
本章内容涵盖了很多内容。我们从深入探讨 TCP 的握手、序列和确认、滑动窗口、以及连接终止开始。接着,我们介绍了使用 Go 的标准库建立 TCP 连接的过程。我们讨论了临时错误、超时、监听传入连接和拨打远程服务的情况。最后,我们讲解了帮助你检测和及时修正网络完整性问题的技术。
我强烈推荐阅读 Chris Sanders 的《实用数据包分析》(Practical Packet Analysis)(No Starch Press,2017)并安装 Wireshark。操控你的网络代码,看看它如何在 Wireshark 中影响 TCP 流量,是加深理解 TCP 和 Go 网络包的一个极好的方式。下一章将讲解如何通过 TCP 连接发送和接收数据。Wireshark 将帮助你深入理解你发送的数据,包括每个负载对滑动窗口的影响。现在熟悉 Wireshark,将为你带来丰厚的回报。
第四章:发送 TCP 数据

现在你已经了解了如何在 Go 中正确建立和优雅地终止 TCP 连接,是时候将这些知识应用到数据传输中。本章介绍了使用 TCP 进行数据发送和接收的各种技术。
我们将讨论从网络连接读取数据的最常见方法。你将创建一个简单的消息协议,允许你在节点之间传输动态大小的有效负载。接下来,你将探索 net.Conn 接口提供的网络可能性。本章最后将深入探讨 TCPConn 对象及 Go 开发者可能遇到的一些隐蔽的 TCP 网络问题。
使用 net.Conn 接口
本书中的大多数网络代码都尽可能使用 Go 的 net.Conn 接口,因为它为大多数情况提供了所需的功能。你可以使用 net.Conn 接口编写强大的网络代码,而无需断言其底层类型,从而确保你的代码在不同操作系统间兼容,并且可以编写更为健壮的测试。(你将在本章稍后学习如何访问 net.Conn 的底层类型,以便使用其更高级的方法。)net.Conn 上可用的方法涵盖了大多数使用场景。
net.Conn 最有用的两个方法是 Read 和 Write。这两个方法分别实现了 io.Reader 和 io.Writer 接口,这些接口在 Go 标准库和生态系统中广泛使用。因此,你可以利用为这些接口编写的大量代码,创建功能强大的网络应用程序。
你可以使用 net.Conn 的 Close 方法来关闭网络连接。如果连接成功关闭,该方法将返回 nil,否则返回错误。SetReadDeadline 和 SetWriteDeadline 方法接受一个 time.Time 对象,设置读取和写入网络连接时的绝对时间,超时后将返回错误。SetDeadline 方法则同时设置读取和写入的截止时间。如在第 62 页的“实现截止时间”部分所述,截止时间可以帮助你控制网络连接空闲的最长时间,并及时发现网络连接问题。
发送和接收数据
从网络连接读取数据和写入数据与读写文件对象没有区别,因为 net.Conn 实现了 io.ReadWriteCloser 接口,后者用于文件的读写。在这一节中,你将首先学习如何将数据读入固定大小的缓冲区。接下来,你将学习如何使用 bufio.Scanner 从网络连接中读取数据,直到遇到特定的分隔符。然后,你将探索 TLV(标签-长度-值)编码方法,它使你能够定义一种基本协议来动态分配适应不同有效负载大小的缓冲区。最后,你将了解如何在从网络连接读取和写入数据时处理错误。
将数据读取到固定缓冲区
Go 中的 TCP 连接实现了io.Reader接口,这允许你从网络连接中读取数据。要从网络连接中读取数据,你需要提供一个缓冲区供网络连接的Read方法填充。
如果连接的接收缓冲区中有足够的数据,Read方法会填充缓冲区至其容量。如果接收缓冲区中的字节少于你提供的缓冲区的容量,Read会用数据填充给定的缓冲区并返回,而不是等待更多数据到达。换句话说,Read并不保证在返回之前填充缓冲区至其容量。示例 4-1 演示了从网络连接读取数据到字节切片的过程。
package main
import (
"crypto/rand"
"io"
"net"
"testing"
)
func TestReadIntoBuffer(t *testing.T) {
1 payload := make([]byte, 1<<24) // 16 MB
_, err := rand.Read(payload) // generate a random payload
if err != nil {
t.Fatal(err)
}
listener, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
go func() {
conn, err := listener.Accept()
if err != nil {
t.Log(err)
return
}
defer conn.Close()
2 _, err = conn.Write(payload)
if err != nil {
t.Error(err)
}
}()
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 31<<19) // 512 KB
for {
4 n, err := conn.Read(buf)
if err != nil {
if err != io.EOF {
t.Error(err)
}
break
}
t.Logf("read %d bytes", n) // buf[:n] is the data read from conn
}
conn.Close()
}
示例 4-1:通过网络连接接收数据(read_test.go)
你需要给客户端提供一些可以读取的内容,所以你创建了一个 16MB 的随机数据负载——比客户端可以在其选择的 512KB 缓冲区大小中读取的数据要多,这样它至少会在for循环中执行几次。使用更大的缓冲区或更小的负载并一次性通过Read调用读取完整负载是完全可以接受的。Go 会正确处理数据,无论负载和接收缓冲区的大小如何。
然后你启动监听器并创建一个 goroutine 来监听传入连接。一旦接受到连接,服务器将把整个负载写入网络连接。客户端随后从连接中读取最多 512KB 的数据,然后继续执行循环。客户端继续每次读取最多 512KB,直到发生错误或客户端读取完整个 16MB 的负载。
使用扫描器进行分隔读取
使用我刚才展示的方法从网络连接中读取数据意味着你的代码需要理解它接收到的数据。由于 TCP 是面向流的协议,客户端可以通过多个数据包接收一个字节流。与句子不同,二进制数据不包含固有的标点符号来告诉你一条消息的开始和结束。
例如,如果你的代码正在从服务器读取一系列电子邮件消息,那么你的代码将需要检查每个字节,以查找分隔符,这些分隔符表示消息在字节流中的边界。或者,你的客户端与服务器之间可能有一个既定协议,服务器发送固定数量的字节来指示服务器接下来将发送的负载大小。你的代码可以使用这个大小来创建一个合适的负载缓冲区。你将在本章稍后看到这种技术的示例。
然而,如果你选择使用分隔符来指示一条消息的结束和另一条消息的开始,那么编写代码来处理边界情况就不那么简单了。例如,你可能从网络连接的一个Read中读取了 1KB 的数据,并发现其中包含了两个分隔符。这意味着你有两条完整的消息,但你没有足够的信息来判断第二个分隔符后面的数据块是否也是一条完整的消息。如果你再读取 1KB 的数据并且没有发现分隔符,你可以得出结论,整个数据块是上一条 1KB 消息的延续。但是,如果你读取了 1KB 的数据,而这些数据全是分隔符呢?
如果这开始听起来有点复杂,那是因为你必须考虑跨多个Read调用的数据,并在过程中处理任何错误。每当你想自己动手解决这样的问题时,检查标准库,看看是否已经有经过验证的实现。在这种情况下,bufio.Scanner做到了你需要的功能。
bufio.Scanner是 Go 标准库中一个方便的代码,它允许你读取带分隔符的数据。Scanner接受一个io.Reader作为输入。由于net.Conn有一个实现了io.Reader接口的Read方法,你可以使用Scanner轻松地从网络连接中读取带分隔符的数据。Listing 4-2 设置了一个监听器,以提供带分隔符的数据,供以后由bufio.Scanner解析。
package main
import (
"bufio"
"net"
"reflect"
"testing"
)
const 1payload = "The bigger the interface, the weaker the abstraction."
func TestScanner(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
go func() {
conn, err := listener.Accept()
if err != nil {
t.Error(err)
return
}
defer conn.Close()
_, err = conn.Write([]byte(payload))
if err != nil {
t.Error(err)
}
}()
`--snip--`
Listing 4-2:创建一个测试来提供一个常量有效负载(scanner_test.go)
这个监听器现在应该让你感到熟悉。它的目的就是提供有效负载。Listing 4-3 使用bufio.Scanner从网络中读取字符串,并通过空格拆分每个数据块。
`--snip--`
conn, err := net.Dial("tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer conn.Close()
1 scanner := bufio.NewScanner(conn)
scanner.Split(bufio.ScanWords)
var words []string
2 for scanner.Scan() {
words = append(words, 3scanner.Text())
}
err = scanner.Err()
if err != nil {
t.Error(err)
}
expected := []string{"The", "bigger", "the", "interface,", "the",
"weaker", "the", "abstraction."}
if !reflect.DeepEqual(words, expected) {
t.Fatal("inaccurate scanned word list")
}
4 t.Logf("Scanned words: %#v", words)
}
Listing 4-3:使用bufio.Scanner从网络中读取以空格分隔的文本(scanner_test.go)
既然你知道自己正在从服务器读取字符串,你可以通过创建一个从网络连接中读取数据的bufio.Scanner来开始。默认情况下,当扫描器在数据流中遇到换行符(\n)时,它会将读取的数据拆分。相反,你选择使用bufio.ScanWords来让扫描器在每个单词的结尾处拆分输入数据,这样它会在遇到单词边界时拆分数据,例如空格或句子结束符号。
你会继续从扫描器中读取数据,只要它告诉你它已经从连接中读取了数据。每次调用Scan时,可能会多次调用网络连接的Read方法,直到扫描器找到分隔符或从连接中读取到错误。它隐藏了跨一次或多次读取网络连接来寻找分隔符并返回结果消息的复杂性。
调用扫描器的Text方法会返回刚从网络连接中读取的那块数据作为字符串——在这个例子中是一个单词和相邻的标点符号。代码继续在for循环中迭代,直到扫描器从网络连接接收到io.EOF或其他错误。如果是后者,扫描器的Err方法将返回一个非nil的错误。你可以通过在go test命令中添加-v标志来查看扫描到的单词。
动态分配缓冲区大小
你可以从网络连接中读取变长数据,前提是发送方和接收方都已达成协议。类型-长度-值(TLV)编码方案是一个不错的选择。TLV 编码使用固定字节数表示数据的类型,使用固定字节数表示值的大小,并使用可变字节数表示值本身。我们的实现使用一个 5 字节的头部:1 字节表示类型,4 字节表示长度。TLV 编码方案允许你将类型作为字节序列发送到远程节点,并根据这些字节序列在远程节点上重构相同的类型。
示例 4-4 定义了我们的 TLV 编码协议将接受的类型。
package main
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
)
const (
1 BinaryType uint8 = iota + 1
2 StringType
3 MaxPayloadSize uint32 = 10 << 20 // 10 MB
)
var ErrMaxPayloadSize = errors.New("maximum payload size exceeded")
type 4Payload interface {
fmt.Stringer
io.ReaderFrom
io.WriterTo
Bytes() []byte
}
示例 4-4:消息结构体实现了一个简单的协议(types.go)。
你从创建常量开始,表示你将定义的每个类型。在这个例子中,你将创建一个BinaryType和一个StringType。在消化每种类型的实现细节后,你应该能够创建适合你需求的类型。出于安全考虑,我们稍后会讨论,你必须定义一个最大有效负载大小。
你还定义了一个名为Payload的接口,描述了每个类型必须实现的方法。每个类型必须具有以下方法:Bytes、String、ReadFrom 和 WriteTo。io.ReaderFrom 和 io.WriterTo 接口分别允许你的类型从读取器读取数据并写入到写入器。你在这方面有一些灵活性。你也可以让Payload实现encoding.BinaryMarshaler接口,将其自己编码为字节切片,并实现encoding.BinaryUnmarshaler接口,从字节切片解码。但字节切片是从网络连接中剥离的一个层次,所以你将保持现有的Payload接口。此外,你将在下一章中使用二进制编码接口。
You now have the foundation built to create TLV-based types. Listing 4-5 details the first type, `Binary`. ``` *--snip--* type 1Binary []byte func (m Binary) 2Bytes() []byte { return m } func (m Binary) 3String() string { return string(m) } func (m Binary) 4WriteTo(w io.Writer) (int64, error) { err := 5binary.Write(w, binary.BigEndian, BinaryType) // 1-byte type if err != nil { return 0, err } var n int64 = 1 err = 6binary.Write(w, binary.BigEndian, uint32(len(m))) // 4-byte size if err != nil { return n, err } n += 4 o, err := 7w.Write(m) // payload return n + int64(o), err } ``` Listing 4-5: Creating the `Binary` type (*types.go*) The `Binary` type 1 is a byte slice; therefore, its `Bytes` method 2 simply returns itself. Its `String` method 3 casts itself as a `string` before returning. The `WriteTo` method accepts an `io.Writer` and returns the number of bytes written to the writer and an `error` interface 4. The `WriteTo` method first writes the 1-byte type to the writer 5. It then writes the 4-byte length of the `Binary` to the writer 6. Finally, it writes the `Binary` value itself 7. Listing 4-6 rounds out the `Binary` type with its `ReadFrom` method. ``` `--snip--` func (m *Binary) ReadFrom(r io.Reader) (int64, error) { var typ uint8 err := 1binary.Read(r, binary.BigEndian, &typ) // 1-byte type if err != nil { return 0, err } var n int64 = 1 if typ != 2BinaryType { return n, errors.New("invalid Binary") } var size uint32 err = 3binary.Read(r, binary.BigEndian, &size) // 4-byte size if err != nil { return n, err } n += 4 4 if size > MaxPayloadSize { return n, ErrMaxPayloadSize } 5 *m = make([]byte, size) o, err := 6r.Read(*m) // payload return n + int64(o), err } ``` Listing 4-6: Completing the `Binary` type’s implementation (*types.go*) The `ReadFrom` method reads 1 1 byte from the reader into the `typ` variable. It next verifies 2 that the type is `BinaryType` before proceeding. Then it reads 3 the next 4 bytes into the `size` variable, which sizes the new `Binary` byte slice 5. Finally, it populates the `Binary` byte slice 6. Notice that you enforce a maximum payload size 4. This is because the 4-byte integer you use to designate the payload size has a maximum value of 4,294,967,295, indicating a payload of over 4GB. With such a large payload size, it would be easy for a malicious actor to perform a denial-of-service attack that exhausts all the available random access memory (RAM) on your computer. Keeping the maximum payload size reasonable makes memory exhaustion attacks harder to execute. Listing 4-7 introduces the `String` type, which, like `Binary`, implements the `Payload` interface. ``` `--snip--` type String string func (m String) 1Bytes() []byte { return []byte(m) } func (m String) 2String() string { return string(m) } func (m String) 3WriteTo(w io.Writer) (int64, error) { err := 4binary.Write(w, binary.BigEndian, StringType) // 1-byte type if err != nil { return 0, err } var n int64 = 1 err = binary.Write(w, binary.BigEndian, uint32(len(m))) // 4-byte size if err != nil { return n, err } n += 4 o, err := 5w.Write([]byte(m)) // payload return n + int64(o), err } ``` Listing 4-7: Creating the `String` type (*types.go*) The `String` implementation’s `Bytes` method 1 casts the `String` to a byte slice. The `String` method 2 casts the `String` type to its base type, `string`. The `String` type’s `WriteTo` method 3 is like `Binary`’s `WriteTo` method except the first byte written 4 is the `StringType` and it casts the `String` to a byte slice before writing it to the writer 5. Listing 4-8 finishes up the `String` type’s `Payload` implementation. ``` `--snip--` func (m *String) ReadFrom(r io.Reader) (int64, error) { var typ uint8 err := binary.Read(r, binary.BigEndian, &typ) // 1-byte type if err != nil { return 0, err } var n int64 = 1 if typ != 1StringType { return n, errors.New("invalid String") } var size uint32 err = binary.Read(r, binary.BigEndian, &size) // 4-byte size if err != nil { return n, err } n += 4 buf := make([]byte, size) o, err := r.Read(buf) // payload if err != nil { return n, err } 2 *m = String(buf) return n + int64(o), nil } ``` Listing 4-8: Completing the `String` type’s implementation (*types.go*) Here, too, `String`’s `ReadFrom` method is like `Binary`’s `ReadFrom` method, with two exceptions. First, the method compares the `typ` variable against the `StringType`1 before proceeding. Second, the method casts the value read from the reader to a `String`2. All that’s left to implement is a way to read arbitrary data from a network connection and use it to constitute one of our two types. For that, we turn to Listing 4-9. ``` `--snip--` func 1decode(r io.Reader) (Payload, error) { var typ uint8 err := 2binary.Read(r, binary.BigEndian, &typ) if err != nil { return nil, err } 3 var payload Payload switch 4typ { case BinaryType: payload = new(Binary) case StringType: payload = new(String) default: return nil, errors.New("unknown type") } _, err = payload.ReadFrom( 5 io.MultiReader(bytes.NewReader([]byte{typ}), r)) if err != nil { return nil, err } return payload, nil } ``` Listing 4-9: Decoding bytes from a reader into a `Binary` or `String` type (*types.go*) The `decode` function 1 accepts an `io.Reader` and returns a `Payload` interface and an `error` interface. If `decode` cannot decode the bytes read from the reader into a `Binary` or `String` type, it will return an error along with a nil `Payload`. You must first read a byte from the reader 2 to determine the type and create a `payload` variable 3 to hold the decoded type. If the type you read from the reader is an expected type constant 4, you assign the corresponding type to the payload variable. You now have enough information to finish decoding the binary data from the reader into the `payload` variable by using its `ReadFrom` method. But you have a problem here. You cannot simply pass the reader to the `ReadFrom` method. You’ve already read a byte from it corresponding to the type, yet the `ReadFrom` method expects the first byte it reads to be the type as well. Thankfully, the `io` package has a helpful function you can use: `MultiReader`. We cover `io.MultiReader` in more detail later in this chapter, but here you use it to concatenate the byte you’ve already read with the reader 5. From the `ReadFrom` method’s perspective, it will read the bytes in the sequence it expects. Although the use of `io.MultiReader` shows you how to inject bytes back into a reader, it isn’t optimal in this use case. The proper fix is to remove each type’s need to read the first byte in its `ReadFrom` method. Then, the `ReadFrom` method would read only the 4-byte size and the payload, eliminating the need to inject the type byte back into the reader before passing it on to `ReadFrom`. As an exercise, I recommend you refactor the code to eliminate the need for `io.MultiReader`. Let’s see the `decode` function in action in the form of a test. Listing 4-10 illustrates how you can send your two distinct types over a network connection and properly decode them back into their original type on the receiver’s end. ``` package main import ( "bytes" "encoding/binary" "net" "reflect" "testing" ) func TestPayloads(t *testing.T) { b1 := 1Binary("Clear is better than clever.") b2 := Binary("Don't panic.") s1 := 2String("Errors are values.") payloads := 3[]Payload{&b1, &s1, &b2} listener, err := net.Listen("tcp", "127.0.0.1:") if err != nil { t.Fatal(err) } go func() { conn, err := listener.Accept() if err != nil { t.Error(err) return } defer conn.Close() for _, p := range payloads { _, err = 4p.WriteTo(conn) if err != nil { t.Error(err) break } } }() `--snip--` ``` Listing 4-10: Creating the `TestPayloads` test (*types_test.go*) Your test should first create at least one of each type. You create two `Binary` types 1 and one `String` type 2. Next, you create a slice of `Payload` interfaces and add pointers to the `Binary` and `String` types you created 3. You then create a listener that will accept a connection and write each type in the `payloads` slice to it 4. This is a good start. Let’s finish up the client side of the test in Listing 4-11. ``` `--snip--` conn, err := 1net.Dial("tcp", listener.Addr().String()) if err != nil { t.Fatal(err) } defer conn.Close() for i := 0; i < len(payloads); i++ { actual, err := 2decode(conn) if err != nil { t.Fatal(err) } 3 if expected := payloads[i]; !reflect.DeepEqual(expected, actual) { t.Errorf("value mismatch: %v != %v", expected, actual) continue } 4 t.Logf("[%T] %[1]q", actual) } } ``` Listing 4-11: Completing the `TestPayloads` test (*types_test.go*) You know how many types to expect in the payloads slice, so you initiate a connection to the listener 1 and attempt to decode each one 2. Finally, your test compares the type you decoded with the type the server sent 3. If there’s any discrepancy with the variable type or its contents, the test fails. You can run the test with the `-v` flag to see the type and its value 4. Let’s make sure the `Binary` type enforces the maximum payload size in Listing 4-12. ``` `--snip--` func TestMaxPayloadSize(t *testing.T) { buf := new(bytes.Buffer) err := buf.WriteByte(BinaryType) if err != nil { t.Fatal(err) } err = binary.Write(buf, binary.BigEndian, 1uint32(1<<30)) // 1 GB if err != nil { t.Fatal(err) } var b Binary _, err = b.ReadFrom(buf) 2 if err != ErrMaxPayloadSize { t.Fatalf("expected ErrMaxPayloadSize; actual: %v", err) } } ``` Listing 4-12: Testing the maximum payload size (*types_test.go*) This test starts with the creation of a `bytes.Buffer` containing the `BinaryType` byte and a 4-byte, unsigned integer indicating the payload is 1GB 1. When this buffer is passed to the `Binary` type’s `ReadFrom` method, you receive the `ErrMaxPayloadSize` error in return 2. The test cases in Listings 4-10 and 4-11 should cover the use case of a payload that is less than the maximum size, but I encourage you to modify this test to make sure that’s the case. ### Handling Errors While Reading and Writing Data Unlike writing to file objects, writing to network connections can be unreliable, especially if your network connection is spotty. Files don’t often return errors while you’re writing to them, but the receiver on the other end of a network connection may abruptly disconnect before you write your entire payload. Not all errors returned when reading from or writing to a network connection are permanent. The connection can recover from some errors. For example, writing data to a network connection where adverse network conditions delay the receiver’s ACK packets, and where your connection times out while waiting to receive them, can result in a temporary error. This can occur if someone temporarily unplugs a network cable between you and the receiver. In that case, the network connection is still active, and you can either attempt to recover from the error or gracefully terminate your end of the connection. Listing 4-13 illustrates how to check for temporary errors while writing data to a network connection. ``` var ( err error n int i = 7 // maximum number of retries ) 1 for ; i > 0; i-- { n, err = 2conn.Write(3[]byte("hello world")) if err != nil { if nErr, ok := 4err.(net.Error); ok && 5nErr.Temporary() { log.Println("temporary error:", nErr) time.Sleep(10 * time.Second) continue } 6 return err } break } if i == 0 { return errors.New("temporary write failure threshold exceeded") } log.Printf("wrote %d bytes to %s\n", n, conn.RemoteAddr()) ``` Listing 4-13: Sending the string `"hello world"` over the connection Since you might receive a transient error when writing to a network connection, you might need to retry a write operation. One way to account for this is to encapsulate the code in a `for` loop 1. This makes it easy to retry the write operation, if necessary. To write to the connection, you pass a byte slice 3 to the connection’s `Write` method 2 as you would to any other `io.Writer`. This returns the number of bytes written and an `error` interface. If the `error` interface is not `nil`, you check whether the error implements the `net.Error` interface by using a type assertion 4 and check whether the error is temporary 5. If the `net.Error`’s `Temporary` method returns `true`, the code makes another write attempt by iterating around the `for` loop. If the error is permanent, the code returns the error 6. A successful write breaks out of the loop. ## Creating Robust Network Applications by Using the io Package In addition to interfaces common in Go code, such as `io.Reader` and `io.Writer`, the `io` package provides several useful functions and utilities that make the creation of robust network applications easy. In this section, you’ll learn how to use the `io.Copy`, `io.MultiWriter`, and `io.TeeReader` functions to proxy data between connections, log network traffic, and ping hosts when firewalls attempt to keep you from doing so. ### Proxying Data Between Connections One of the most useful functions from the `io` package, the `io.Copy` function reads data from an `io.Reader` and writes it to an `io.Writer`. This is useful for creating a *proxy*, which, in this context, is an intermediary that transfers data between two nodes. Since `net.Conn` includes both `io.Reader` and `io.Writer` interfaces, and `io.Copy` writes whatever it reads from an `io.Reader` to an `io.Writer`, you can easily create a proxy between network connections, such as the one you define in the `proxyConn` function in Listing 4-14. This function copies any data sent from the source node to the destination node, and vice versa. ``` package main import ( "io" "net" ) func proxyConn(source, destination string) error { connSource, err := 1net.Dial("tcp", source) if err != nil { return err } defer connSource.Close() connDestination, err := 2net.Dial("tcp", destination) if err != nil { return err } defer connDestination.Close() // connDestination replies to connSource 3 go func() { _, _ = io.Copy(connSource, connDestination) }() // connSource messages to connDestination 4 _, err = io.Copy(connDestination, connSource) return err } ``` Listing 4-14: Proxying data between two network connections (*proxy_conn.go*) The `io.Copy` function does all the heavy input/output (I/O) lifting for you. It takes an `io.Writer` as its first argument and an `io.Reader` as its second argument. It then writes, to the writer, everything it reads from the reader until the reader returns an `io.EOF`, or, alternately, either the reader or writer returns an error. The `io.Copy` function returns an error only if a non-`io.EOF` error occurred during the copy, because `io.EOF` means it has read all the data from the reader. You start by creating a connection to the `source` node 1 and a connection to the `destination` node 2. Next, you run `io.Copy` in a goroutine, reading from `connDestination` and writing to `connSource`3 to handle any replies. You don’t need to worry about leaking this goroutine, since `io.Copy` will return when either connection is closed. Then, you make another call to `io.Copy`, reading from `connSource` and writing to `connDestination`4. Once this call returns and the function returns, each connection’s `Close` method runs, which causes `io.Copy` to return, terminating its goroutine 3. As a result, the data is proxied between network connections as if they had a direct connection to one another. Listing 4-15 illustrates how to use a slight variation of the `proxyConn` function. Whereas Listing 4-14’s `proxyConn` function established network connections and proxied traffic between them, Listing 4-15’s `proxy` function proxies data between an `io.Reader` and an `io.Writer`, making it applicable to more than just network connections and much easier to test. ``` package main import ( "io" "net" "sync" "testing" ) 1 func proxy(from io.Reader, to io.Writer) error { fromWriter, fromIsWriter := from.(io.Writer) toReader, toIsReader := to.(io.Reader) if toIsReader && fromIsWriter { // Send replies since "from" and "to" implement the // necessary interfaces. go func() { _, _ = io.Copy(fromWriter, toReader) }() } _, err := io.Copy(to, from) return err } ``` Listing 4-15: Proxy data between a reader and writer (*proxy_test.go*) This `proxy` function 1 is a bit more useful in that it accepts the ubiquitous `io.Reader` and `io.Writer` interfaces instead of `net.Conn`. Because of this change, you could proxy data from a network connection to `os.Stdout`, `*bytes.Buffer`, `*os.File`, or any number of objects that implement the `io.Writer` interface. Likewise, you could read bytes from any object that implements the `io.Reader` interface and send them to the writer. This implementation of `proxy` supports replies if the *from* reader implements the `io.Writer` interface and the *to* writer implements the `io.Reader` interface. Listing 4-16 creates a test to make sure the proxy functions as you expect. ``` `--snip--` func TestProxy(t *testing.T) { var wg sync.WaitGroup // server listens for a "ping" message and responds with a // "pong" message. All other messages are echoed back to the client. 1 server, err := net.Listen("tcp", "127.0.0.1:") if err != nil { t.Fatal(err) } wg.Add(1) go func() { defer wg.Done() for { conn, err := server.Accept() if err != nil { return } go func(c net.Conn) { defer c.Close() for { buf := make([]byte, 1024) n, err := c.Read(buf) if err != nil { if err != io.EOF { t.Error(err) } return } switch msg := string(buf[:n]); msg { case "ping": _, err = c.Write([]byte("pong")) default: _, err = c.Write(buf[:n]) } if err != nil { if err != io.EOF { t.Error(err) } return } } }(conn) } }() `--snip--` ``` Listing 4-16: Creating the listener (*proxy_test.go*) You start by initializing a server 1 that listens for incoming connections. It reads bytes from each connection, replies with the string `"pong"` when it receives the string `"ping`,`"` and echoes any other message it receives. Listing 4-17 continues the test implementation. ``` `--snip--` // proxyServer proxies messages from client connections to the // destinationServer. Replies from the destinationServer are proxied // back to the clients. 1 proxyServer, err := net.Listen("tcp", "127.0.0.1:") if err != nil { t.Fatal(err) } wg.Add(1) go func() { defer wg.Done() for { conn, err := 2proxyServer.Accept() if err != nil { return } go func(from net.Conn) { defer from.Close() to, err := 3net.Dial("tcp", server.Addr().String()) if err != nil { t.Error(err) return } defer to.Close() err = 4proxy(from, to) if err != nil && err != io.EOF { t.Error(err) } }(conn) } }() `--snip--` ``` Listing 4-17: Set up the proxy between the client and server (*proxy_test.go*) You then set up a proxy server 1 that handles the message passing between the client and the destination server. The proxy server listens for incoming client connections. Once a client connection accepts 2, the proxy establishes a connection to the destination server 3 and starts proxying messages 4. Since the proxy server passes two `net.Conn` objects to `proxy`, and `net.Conn` implements the `io.ReadWriter` interface, the server proxies replies automatically. Then `io.Copy` writes to the `Write` method of the destination `net.Conn` everything it reads from the `Read` method of the origin `net.Conn`, and vice versa for replies from the destination to the origin. Listing 4-18 implements the client portion of the test. ``` `--snip--` conn, err := net.Dial("tcp", proxyServer.Addr().String()) if err != nil { t.Fatal(err) } 1 msgs := []struct{ Message, Reply string }{ {"ping", "pong"}, {"pong", "pong"}, {"echo", "echo"}, {"ping", "pong"}, } for i, m := range msgs { _, err = conn.Write([]byte(m.Message)) if err != nil { t.Fatal(err) } buf := make([]byte, 1024) n, err := conn.Read(buf) if err != nil { t.Fatal(err) } actual := string(buf[:n]) t.Logf("%q -> proxy -> %q", m.Message, actual) if actual != m.Reply { t.Errorf("%d: expected reply: %q; actual: %q", i, m.Reply, actual) } } _ = conn.Close() _ = proxyServer.Close() _ = server.Close() wg.Wait() } ``` Listing 4-18: Proxying data from an upstream server to a downstream server (*proxy_test.go*) You run the proxy through a series of tests 1 to verify that your ping messages result in pong replies and that the destination echoes anything else you send. The output should look like the following: ``` $ **go test** 1**-race -v proxy_test.go** === RUN TestProxy --- PASS: TestProxy (0.00s) proxy_test.go:138: "ping" -> proxy -> "pong" proxy_test.go:138: "pong" -> proxy -> "pong" proxy_test.go:138: "echo" -> proxy -> "echo" proxy_test.go:138: "ping" -> proxy -> "pong" PASS ok command-line-arguments 1.018s ``` I’m in the habit of running my tests with the `-race` flag 1 to enable the race detector. The race detector can help alert you to data races that need your attention. Although not necessary for this test, enabling it is a good habit. ### Monitoring a Network Connection The `io` package includes useful tools that allow you to do more with network data than just send and receive it using connection objects. For example, you could use `io.MultiWriter` to write a single payload to multiple network connections. You could also use `io.TeeReader` to log data read from a network connection. Listing 4-19 gives an example of using the `io.TeeReader` and `io.MultiWriter` to log all network traffic on a TCP listener. ``` package main import ( "io" "log" "net" "os" ) // Monitor embeds a log.Logger meant for logging network traffic. type Monitor struct { *log.Logger } // Write implements the io.Writer interface. func (m *Monitor) 1Write(p []byte) (int, error) { return len(p), m.Output(2, string(p)) } func ExampleMonitor() { 2 monitor := &Monitor{Logger: log.New(os.Stdout, "monitor: ", 0)} listener, err := net.Listen("tcp", "127.0.0.1:") if err != nil { monitor.Fatal(err) } done := make(chan struct{}) go func() { defer close(done) conn, err := listener.Accept() if err != nil { return } defer conn.Close() b := make([]byte, 1024) 3 r := io.TeeReader(conn, monitor) n, err := r.Read(b) if err != nil && err != io.EOF { monitor.Println(err) return } 4 w := io.MultiWriter(conn, monitor) _, err = w.Write(b[:n]) // echo the message if err != nil && err != io.EOF { monitor.Println(err) return } }() `--snip--` ``` Listing 4-19: Using `io.TeeReader` and `io.MultiWriter` to capture a network connection’s input and output (*monitor_test.go*) You create a new struct named `Monitor` that embeds a `log.Logger` for the purposes of logging the server’s network traffic. Since the `io.TeeReader` and the `io.MultiWriter` expect an `io.Writer`, the monitor implements the `io.Writer` interface 1. You start by creating an instance of `Monitor`2 that writes to `os.Stdout`. You use the monitor in conjunction with the connection object in an `io.TeeReader`3. This results in an `io.Reader` that will read from the network connection and write all input to the monitor before passing along the input to the caller. Likewise, you log server output by creating an `io.MultiWriter`4, writing to the network connection and the monitor. Listing 4-20 details the client portion of the example and its output. ``` `--snip--` conn, err := net.Dial("tcp", listener.Addr().String()) if err != nil { monitor.Fatal(err) } _, err = 1conn.Write([]byte("Test\n")) if err != nil { monitor.Fatal(err) } _ = conn.Close() <-done // 2Output: // monitor: Test // monitor: Test } ``` Listing 4-20: The client implementation and example output (*monitor_test.go*) When you send the message `Test\n`1, it’s logged to `os.Stdout` twice 2: once when you read the message from the connection, and again when you echo the message back to the client. If you want to get fancy, you could decorate the log entries to differentiate between incoming and outgoing data. One way to do this would be to create an object that implements the `io.Writer` interface and embeds the monitor. When its `Write` method is called, it prepends the data with the prefix before passing the data along to the monitor’s `Write` method. Although using the `io.TeeReader` and the `io.MultiWriter` in this fashion is powerful, it isn’t without a few caveats. First, both the `io.TeeReader` and the `io.MultiWriter` will block while writing to your writer. Your writer will add latency to the network connection, so be mindful not to block too long. Second, an error returned by your writer will cause the `io.TeeReader` or `io.MultiWriter` to return an error as well, halting the flow of network data. If you don’t want your use of these objects to potentially interrupt network data flow, I strongly recommend you implement a reader that always returns a `nil` error and logs its underlying error in a manner that’s actionable. For example, you can modify `Monitor`’s `Write` method to always return a `nil` error: ``` func (m *Monitor) Write(p []byte) (int, error) { err := m.Output(2, string(p)) if err != nil { log.Println(err) // use the log package’s default Logger } return len(p), nil } ``` The `Monitor` attempts to write the byte slice to its embedded logger. Failing that, it writes the error to the `log` package’s default logger and returns a `nil` error to `io.TeeReader` and `io.MultiWriter` in Listing 4-19 so as not to interrupt the flow of data. ### Pinging a Host in ICMP-Filtered Environments In “The Internet Control Message Protocol” on page 31, you learned that ICMP is a protocol that gives you feedback about local network conditions. One of its most common uses is to determine whether a host is online by issuing a ping request and receiving a pong reply from the host. Most operating systems have a built-in `ping` command that sends an ICMP echo request to a destination IP address. Once the host responds with an ICMP echo reply, `ping` prints the duration between sending the ping and receiving the pong. Unfortunately, many internet hosts filter or block ICMP echo replies. If a host filters `pongs`, the `ping` erroneously reports that the remote system is unavailable. One technique you can use instead is to establish a TCP connection with the remote host. If you know that the host listens for incoming TCP connections on a specific port, you can use this knowledge to confirm that the host is available, because you can establish a TCP connection only if the host is up and completes the handshake process. Listing 4-21 shows a small application that reports the time it takes to establish a TCP connection with a host on a specific port. ``` package main import ( "flag" "fmt" "net" "os" "time" ) 1 var ( count = flag.Int("c", 3, "number of pings: <= 0 means forever") interval = flag.Duration("i", time.Second, "interval between pings") timeout = flag.Duration("W", 5*time.Second, "time to wait for a reply") ) func init() { flag.Usage = func() { fmt.Printf("Usage: %s [options] host:port\nOptions:\n", os.Args[0]) flag.PrintDefaults() } } `--snip--` ``` Listing 4-21: The command line flags for the `ping` command (*ping.go*) This example starts by defining a few command line options 1 that mimic a subset of the functionality provided by the `ping` command on Linux. Listing 4-22 adds the `main` function. ``` `--snip--` func main() { flag.Parse() if flag.NArg() != 1 { fmt.Print("host:port is required\n\n") flag.Usage() os.Exit(1) } target := flag.Arg(0) fmt.Println("PING", target) if *count <= 0 { fmt.Println("CTRL+C to stop.") } msg := 0 for (*count <= 0) || (msg < *count) { msg++ fmt.Print(msg, " ") start := time.Now() 1 c, err := net.DialTimeout("tcp", target, *timeout) 2 dur := time.Since(start) if err != nil { fmt.Printf("fail in %s: %v\n", dur, err) if nErr, ok := err.(net.Error); !ok || 3!nErr.Temporary() { os.Exit(1) } } else { _ = c.Close() fmt.Println(dur) } time.Sleep(*interval) } } ``` Listing 4-22: Reporting the time to establish a TCP socket to a given host and port (*ping.go*) You attempt to establish a connection to a remote host’s TCP port 1, setting a reasonable time-out duration if the remote host doesn’t respond. You keep track of the time it takes to complete the TCP handshake and consider this duration 2 the ping interval between your host and the remote host. If you encounter a temporary error (for example, a time-out), you’ll continue trying, and you’ll exit if the error is permanent 3. This is handy if you restart a TCP service and want to monitor its progress in restarting. Initially, the code in Listing 4-22 will report time-out errors, but it will eventually start printing valid results when the service is again listening on the specific port. It’s important to understand that system admins could consider the code in Listing 4-22 abusive, especially if you specify a large ping count. That’s because you aren’t simply asking the remote host to send an echo reply using ICMP. Instead, you’re rapidly establishing and tearing down a TCP connection with every interval. Establishing a TCP connection has more overhead than an ICMP echo request and response. I recommend that you use this method only when intermediate firewalls filter ICMP echo messages and, even then, with the permission of the system admin. ## Exploring Go’s TCPConn Object For most use cases, the `net.Conn` interface will provide adequate functionality and the best cross-platform support for TCP sessions between nodes. But accessing the underlying `net.TCPConn` object allows fine-grained control over the TCP network connection should you need to do such things as modify the read and write buffers, enable keepalive messages, or change the behavior of pending data upon closing the connection. The `net.TCPConn` object is the concrete object that implements the `net.Conn` interface. Keep in mind that not all the following functionality may be available on your target operating system. The easiest way to retrieve the `net.TCPConn` object is by using a type assertion. This works for connections where the underlying network is TCP: ``` tcpConn, ok := conn.(*net.TCPConn) ``` On the server side, you can use the `AcceptTCP` method on a `net.TCPListener`, as shown in Listing 4-23, to retrieve the `net.TCPConn` object. ``` addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:") if err != nil { return err } listener, err := net.ListenTCP("tcp", addr) if err != nil { return err } tcpConn, err := listener.AcceptTCP() ``` Listing 4-23: Retrieving `net.TCPConn` from the listener On the client side, use the `net.DialTCP` function, as shown in Listing 4-24. ``` addr, err := net.ResolveTCPAddr("tcp", "www.google.com:http") if err != nil { return err } tcpConn, err := net.DialTCP("tcp", nil, addr) ``` Listing 4-24: Using `DialTCP` to retrieve a `net.TCPConn` object The next few sections cover useful methods on `net.TCPConn` that are unavailable on `net.Conn`. Some of these methods may not be available on your target operating system or may have hard limits imposed by the operating system. My advice is to use the following methods only when necessary. Altering these settings on the connection object from the operating system defaults may lead to network behavior that’s difficult to debug. For example, shrinking the read buffer on a network connection may lead to unexpected zero window issues unexplained by checking the operating system’s default read buffer value. ### Controlling Keepalive Messages A *keepalive* is a message sent over a network connection to check the connection’s integrity by prompting an acknowledgment of the message from the receiver. After an operating system–specified number of unacknowledged keepalive messages, the operating system will close the connection. The operating system configuration dictates whether a connection uses keepalives for TCP sessions by default. If you need to enable keepalives on a `net.TCPConn` object, pass `true` to its `SetKeepAlive` method: ``` err := tcpConn.SetKeepAlive(true) ``` You also have control over how often the connection sends keepalive messages using the `SetKeepAlivePeriod` method. This method accepts a `time.Duration` that dictates the keepalive message interval: ``` err := tcpConn.SetKeepAlivePeriod(time.Minute) ``` Using deadlines advanced by a heartbeat is usually the better method for detecting network problems. As mentioned earlier in this chapter, deadlines provide better cross-platform support, traverse firewalls better, and make sure your application is actively managing the network connection. ### Handling Pending Data on Close By default, if you’ve written data to `net.Conn` but the data has yet to be sent to or acknowledged by the receiver and you close the network connection, your operating system will complete the delivery in the background. If you don’t want this behavior, the `net.TCPConn` object’s `SetLinger` method allows you to tweak it: ``` err := tcpConn.SetLinger(-1) // anything < 0 uses the default behavior ``` With the linger disabled, it is possible that the server may receive the last portion of data you send along with your FIN when you close your connection. Since your call to `conn.Close` doesn’t block, you have no way of knowing whether the server received the data you just sent prior to your FIN. It’s possible the data sat in the server’s receive buffer and then the server crashed, taking your unacknowledged data and FIN with it. Lingering on the connection to give the server time to acknowledge the data may seem tempting. But this won’t solve your problem if the server crashes, as in the example. Also, some developers may argue that using linger for this purpose is a code smell. Your application should instead verify that the server received all data before tearing down its connection if this last bit of unacknowledged data is a concern. If you wish to abruptly discard all unsent data and ignore acknowledgments of sent data upon closing the network connection, set the connection’s linger to zero: ``` err := tcpConn.SetLinger(0) // immediately discard unsent data on close ``` Setting linger to zero will cause your connection to send an RST packet when your code calls your connection’s `Close` method, aborting the connection and bypassing the normal teardown procedures. If you’re looking for a happy medium and your operating system supports it, you can pass a positive integer *n* to `SetLinger`. Your operating system will attempt to complete delivery of all outstanding data up to *n* seconds, after which point your operating system will discard any unsent or unacknowledged data: ``` err := tcpConn.SetLinger(10) // discard unsent data after 10 seconds ``` If you feel compelled to modify your connection’s linger value, please read up on how your operating system handles lingering on network connections. When in doubt, use the default value. ### Overriding Default Receive and Send Buffers Your operating system assigns read and write buffers to each network connection you create in your code. For most cases, those values should be enough. But in the event you want greater control over the read or write buffer sizes, you can tweak their value, as demonstrated in Listing 4-25. ``` if err := tcpConn.SetReadBuffer(212992); err != nil { return err } if err := tcpConn.SetWriteBuffer(212992); err != nil { return err } ``` Listing 4-25: Setting read and write buffer sizes on a TCP connection The `SetReadBuffer` method accepts an integer representing the connection’s read buffer size in bytes. Likewise, the `SetWriteBuffer` method accepts an integer and sets the write buffer size in bytes on the connection. Keep in mind that you can’t exceed your operating system’s maximum value for either buffer size. ## Solving Common Go TCP Network Problems Go doesn’t hold your hand when working with TCP network connections. As such, it’s possible to introduce bugs in your code that manifest as network errors. This section presents two common TCP networking issues: zero window errors and sockets stuck in the CLOSE_WAIT state. ### Zero Window Errors We spent a bit of time in “Receive Buffers and Window Sizes” on page 48 discussing TCP’s sliding window and how the window size tells the sender how much data the receiver can accept before the next acknowledgment. A common workflow when reading from a network connection is to read some data from the connection, handle the data, read more data from the connection, handle it, and so on. But what happens if you don’t read data from a network connection quickly enough? Eventually, the sender may fill the receiver’s receive buffer, resulting in a zero-window state. The receiver will not be able to receive data until the application reads data from the buffer. This most often happens when the handling of data read from a network connection blocks and the code never makes its way around to reading from the socket again, as shown in Listing 4-26. ``` buf := make([]byte, 1024) for { 1 n, err := conn.Read(buf) if err != nil { return err } 2 handle(buf[:n]) // BLOCKS! } ``` Listing 4-26: Handling received data blocks preventing iteration around the loop Reading data from the network connection 1 frees up receive buffer space. If the code blocks for an appreciable amount of time while handling the received data 2, the receive buffer may fill up. A full receive buffer isn’t necessarily bad. Zeroing the window is a way to *throttle*, or slow, the flow of data from the sender by creating backpressure on the sender. But if it’s unintended or prolonged, a zero window may indicate a bug in your code. ### Sockets Stuck in the CLOSE_WAIT State In “Gracefully Terminating TCP Sessions” on page 50, I mentioned that the server side of a TCP network connection will enter the CLOSE_WAIT state after it receives and acknowledges the FIN packet from the client. If you see TCP sockets on your server that persist in the CLOSE_WAIT state, it’s likely your code is neglecting to properly call the `Close` method on its network connections, as in Listing 4-27. ``` for { conn, err := listener.Accept() if err != nil { return err } 1 go func(c net.Conn) { // we never call c.Close() before returning! buf := make([]byte, 1024) for { n, err := c.Read(buf) if err != nil { 2 return } handle(buf[:n]) } }(conn) } ``` Listing 4-27: Returning from a connection-handling goroutine without properly closing the connection The listener handles each connection in its own goroutine 1. However, the goroutine fails to call the connection’s `Close` method before fully returning from the goroutine 2. Even a temporary error will cause the goroutine to return. And because you never close the connection, this will leave the TCP socket in the CLOSE_WAIT state. If the server attempted to send anything other than a FIN packet to the client, the client would respond with an RST packet, abruptly tearing down the connection. The solution is to make sure to defer a call to the connection’s `Close` method soon after creating the goroutine 1. ## What You’ve Learned In this chapter, you first learned several methods of reading data from and writing data to a network connection, including the type-length-value encoding scheme. You built on this knowledge and learned an efficient way to proxy data between network connections. Next, you used a few `io` tools to monitor network traffic. Then, you used your knowledge of TCP handshakes to ping remote hosts in environments where ICMP echo requests and replies are filtered. Finally, the chapter wrapped up by covering the more platform-specific, yet powerful, methods provided by the `net.TCPConn` object and a few common connection-handling bugs.
第五章:不可靠的 UDP 通信

尽管大多数网络应用利用了 TCP 的可靠性和流量控制,但不那么流行的用户数据报协议(UDP)仍然是 TCP/IP 协议栈中的重要组成部分。UDP 是一个简单的协议,具有最小的功能。一些应用程序不需要 TCP 的特性和会话开销。像域名解析服务这样的应用选择使用 UDP。
本章首先通过将 UDP 与 TCP 进行比较,重点讨论在何种情况下 UDP 可能比 TCP 更合适。接下来,你将学习如何在 Go 中发送和接收 UDP 数据包。最后,你将了解为什么最好限制你在网络上传输的 UDP 数据包大小,以及如何确定最佳的数据包大小。
使用 UDP:简单且不可靠
UDP 是不可靠的,因为它不包含许多使 TCP 如此可靠的机制。它提供的内容仅仅是一个套接字地址(IP 地址和端口)。实际上,该协议如此简单,以至于 RFC 768 用大约三页内容就描述了整个协议。与 TCP 不同,UDP 不提供会话支持,甚至不确认目的地是否可访问;它只是尽力尝试发送数据包。接收方不会自动确认 UDP 数据包,因此 UDP 没有固有的交付确认。UDP 不进行拥塞管理、不控制数据流,也不重新传输数据包。最后,UDP 不能保证目的地接收数据包的顺序与它们的发送顺序一致。UDP 仅仅是应用程序与 IP 层之间的一个通道。正是这种简洁性使得 UDP 在某些应用中快速且具有吸引力。
UDP 相对于 TCP 有一些优势。TCP 必须在每个节点与组中的每个节点之间建立会话,才能开始传输数据,而 UDP 可以向一组节点发送单个数据包,而无需重复发送数据包,这个过程被称为组播。由于 UDP 不需要在每个节点之间建立会话,它还可以将数据包广播到子网中的所有成员。
当丢失的数据包对整体通信没有重大影响时,UDP 是理想的选择,因为最新接收到的数据包可以替代之前丢失的数据包。天气数据就是一个很好的例子。如果你正在通过流式天气数据追踪你所在地区的龙卷风,你并不太关心丢失的数据包表示两分钟前龙卷风的位置,只要你已经接收到表示龙卷风当前位置的数据包。
如果你的应用程序不需要 TCP 提供的所有功能,你应该考虑使用 UDP。对于大多数网络应用,TCP 是正确的协议选择。但如果 UDP 的速度和简洁性更适合你的使用场景,并且可靠性权衡不至于产生严重后果,UDP 也是一个可选方案。
UDP 的数据包结构包括一个 8 字节的头部和一个负载。头部包含 2 个字节表示源端口,2 个字节表示目标端口,2 个字节表示数据包长度(以字节为单位),以及 2 个字节的校验和。最小的数据包长度是 8 字节,用于表示头部和一个空的负载。图 5-1 展示了 UDP 数据包的组织结构。

图 5-1:UDP 数据包头部和负载
尽管最大数据包长度为 65,535 字节,但应用层协议通常会限制数据包长度,以避免数据包碎片化,具体内容请参见第 115 页的“避免碎片化”一节。
发送和接收 UDP 数据
在发送和接收数据时,UDP 相比 TCP 显得更加粗糙。例如,假设你的邻居烤了一个馅饼并想要给你。使用 TCP 进行通信就像是你的邻居从她的窗户(她的套接字地址)向你敞开的窗户(你的套接字地址)大声喊个问候。你听到她的问候并回应一个问候(TCP 握手)。然后你的邻居把馅饼送到你那里。你接受它并感激地确认收到馅饼(数据传输)。接着你们互道告别,各自继续忙自己的事(连接终止)。相比之下,使用 UDP 进行通信就像是你的邻居突然把馅饼扔向你的窗户,无论窗户开没开,也不等待你确认收到。
在第 74 页的“使用 net.Conn 接口”一节中,介绍了net.Conn接口,用于处理面向流的连接,如 TCP 连接,客户端和服务器之间。但这个接口不适合 UDP 连接,因为 UDP 不是面向流的协议。UDP 没有像 TCP 那样维持会话或进行握手过程。UDP 没有确认、重传或流控制的概念。
相反,UDP 主要依赖于面向数据包的net.PacketConn接口。本章稍后我们会讨论一个 UDP 与net.Conn结合使用的案例,但对于大多数 UDP 应用程序来说,net.PacketConn是更好的选择。
使用 UDP 回显服务器
发送和接收 UDP 数据包的过程几乎与发送和接收 TCP 数据包相同。但由于 UDP 不支持会话,你必须能够处理从连接对象读取数据时的附加返回值,即发送者的地址,正如列表 5-1 中 UDP 回显服务器实现所示。
package echo
import (
"context"
"net"
)
func echoServerUDP(1ctx context.Context, addr string) (net.Addr, error) {
s, err := 2net.ListenPacket("udp", addr)
if err != nil {
return nil, fmt.Errorf("binding to udp %s: %w", addr, err)
}
3 go func() {
go func() {
4 <-ctx.Done()
_ = s.Close()
}()
buf := make([]byte, 1024)
for {
n, 5clientAddr, err := 6s.ReadFrom(buf) // client to server
if err != nil {
return
}
_, err = 7s.WriteTo(buf[:n], 8clientAddr) // server to client
if err != nil {
return
}
}
}()
return s.LocalAddr(), nil
}
列表 5-1:一个简单的 UDP 回显服务器实现(echo.go)
这段代码允许你启动一个 UDP 服务器,能够将接收到的任何 UDP 数据包回显给发送者。在本章中,你会频繁使用这段代码,因此理解它的作用是很重要的。
该函数接收一个上下文 1,以便调用者取消回显服务器,以及一个熟悉的host:port格式的字符串地址。它返回一个net.Addr接口和一个error接口。调用者使用net.Addr接口向回显服务器发送消息。如果在实例化回显服务器时出现任何问题,返回的error接口将不为nil。
你通过调用net.ListenPacket2 为服务器创建一个 UDP 连接,该方法返回一个net.PacketConn接口和一个error接口。net.ListenPacket函数类似于你在第三章和第四章中用于创建 TCP 监听器的net.Listen函数,不同之处在于net.ListenPacket只返回一个net.PacketConn接口。
一个 goroutine 管理你回显服务器 3 的异步消息回显。第二个 goroutine 阻塞在上下文的Done通道 4 上。一旦调用者取消上下文,接收Done通道的操作会解除阻塞,服务器关闭,销毁此 goroutine 以及父 goroutine 3。
要从 UDP 连接中读取数据,你将一个字节切片传递给ReadFrom方法 6。该方法返回读取的字节数、发送者的地址以及一个错误接口。请注意,你的 UDP 连接没有像前面章节中的基于 TCP 的监听器那样具有Accept方法。这是因为 UDP 不使用握手过程。在这里,你只是创建一个监听 UDP 端口的 UDP 连接,并读取任何传入的消息。由于没有适当的介绍和建立会话,你依赖返回的地址 5 来确定是哪一个节点发送了消息。
要写入一个 UDP 数据包,你将一个字节切片和一个目标地址 8 传递给连接的WriteTo方法 7。WriteTo方法返回写入的字节数和一个错误接口。与读取数据时一样,你需要告诉WriteTo方法数据包的发送位置,因为你与远程节点没有建立会话。在清单 5-1 中,你将消息写入原始发送者。但你也可以轻松地将消息转发到另一个节点,使用你现有的 UDP 连接对象。你无需像使用 TCP 时那样建立一个新的 UDP 连接对象来转发消息。
从回显服务器接收数据
现在你已经熟悉了基于 UDP 的回显服务器,我们来看看一些与回显服务器交互的客户端代码。清单 5-2 展示了与回显服务器的简单交互。
package echo
import (
"bytes"
"context"
"net"
"testing"
)
func TestEchoServerUDP(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
1 serverAddr, err := echoServerUDP(ctx, "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
defer cancel()
2 client, err := net.ListenPacket("udp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
defer func() { _ = client.Close() }()
msg := []byte("ping")
_, err = 3client.WriteTo(msg, serverAddr)
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 1024)
n, 4addr, err := 5client.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if addr.String() != serverAddr.String() {
t.Fatalf("received reply from %q instead of %q", addr, serverAddr)
}
if !bytes.Equal(msg, buf[:n]) {
t.Errorf("expected reply %q; actual reply %q", msg, buf[:n])
}
}
清单 5-2:向回显服务器发送 UDP 数据包并接收回复(echo_test.go)
你将一个上下文和地址字符串传递给echoServer函数,并接收服务器的地址 1 对象。你推迟调用上下文的cancel函数,该函数会通知服务器退出并关闭其 goroutines。在实际应用中,使用上下文来取消长期运行的进程非常有用,确保你不会泄漏资源,如内存或不必要地保持文件打开。
你以与实例化回声服务器的net.PacketConn相同的方式实例化客户端的net.PacketConn 2。net.ListenPacket函数为客户端和服务器创建连接对象。在这里,你还需要告诉客户端每次调用其WriteTo方法 3 时该向哪里发送消息。在将消息发送给回声服务器后,客户端应该立即通过其ReadFrom方法 5 接收一条消息。你可以检查ReadFrom方法返回的地址 4 来确认回声服务器发送了该消息。
需要注意的是,列表 5-2 中的测试在某些情况下可能会失败。即使你正在从计算机的本地网络栈读取数据包并向其写入数据包,这些数据包仍然受到所有使 UDP 在节点间网络中不可靠的条件的影响。例如,发送或接收缓冲区已满,或可用 RAM 不足,都可能导致数据包丢失;较大的 UDP 数据包可能会受到分片的影响(在本章后面讨论);并且使用多个线程传递 UDP 数据包的操作系统可能会导致数据包乱序。
每个 UDP 连接都是一个监听器
回想一下第三章,Go 的net包区分了 TCP 连接对象(TCPConn)和 TCP 监听器(TCPListener)。TCP 监听器接受连接并返回一个表示监听器端连接的对象,以便监听器可以向客户端发送消息。
UDP 没有与TCPListener等价的东西,因为 UDP 缺乏会话。这意味着当接收数据包时,你的代码需要进行更多的管理。你需要验证发送方的地址,因为你不能再相信所有传入连接对象的数据包都是来自同一发送方。
接下来的几个列表是一个测试的一部分,测试单个 UDP 连接对象是否能够从多个发送方接收数据包。列表 5-3 启动了一个回声服务器和一个客户端进行测试。
package echo
import (
"bytes"
"context"
"net"
"testing"
)
func TestListenPacketUDP(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
1 serverAddr, err := echoServerUDP(ctx, "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
defer cancel()
2 client, err := net.ListenPacket("udp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
defer func() { _ = client.Close() }()
列表 5-3:创建回声服务器和客户端(listen_packet_test.go)
你首先创建回声服务器 1 和客户端连接 2。列表 5-4 添加了第二个网络连接来与客户端进行交互。
`--snip--`
1 interloper, err := net.ListenPacket("udp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
interrupt := []byte("pardon me")
2 n, err := interloper.WriteTo(interrupt, client.LocalAddr())
if err != nil {
t.Fatal(err)
}
_ = interloper.Close()
if l := len(interrupt); l != n {
t.Fatalf("wrote %d bytes of %d", n, l)
}
列表 5-4:添加一个中断者并用消息中断客户端(listen_packet_test.go)
然后,你创建一个新的 UDP 连接 1,用于在客户端和回声服务器之间插入并中断客户端 2。此消息应排队进入客户端的接收缓冲区。
客户端将其 ping 消息发送到回声服务器,并在清单 5-5 中对回复进行调解。
`--snip--`
ping := []byte("ping")
_, err = 1client.WriteTo(ping, serverAddr)
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 1024)
n, addr, err := 2client.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(3interrupt, buf[:n]) {
t.Errorf("expected reply %q; actual reply %q", interrupt, buf[:n])
}
if addr.String() != interloper.LocalAddr().String() {
t.Errorf("expected message from %q; actual sender is %q",
interloper.LocalAddr(), addr)
}
n, addr, err = client.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(4ping, buf[:n]) {
t.Errorf("expected reply %q; actual reply %q", ping, buf[:n])
}
5 if addr.String() != serverAddr.String() {
t.Errorf("expected message from %q; actual sender is %q",
serverAddr, addr)
}
}
清单 5-5:接收来自多个发送者的 UDP 数据包(listen_packet_test.go)
与此同时,客户端向回声服务器 1 写入 ping 消息,并迅速读取一个传入的消息 2。UDP 客户端连接的独特之处在于,它首先读取来自非法连接的中断消息 3,然后是来自回声服务器的回复 4。如果这是一个 TCP 连接,客户端将永远不会接收到非法连接的消息。因此,你的代码应始终通过评估 ReadFrom 方法的第二个返回值 5(发送者的地址)来验证每个读取的包的发送者。
在 UDP 中使用 net.Conn
你可以建立一个实现 net.Conn 接口的 UDP 连接,这样你的代码表现得就像是一个 TCP 的 net.Conn。你通过将 udp 作为第一个参数传递给在前两章中使用的 net.Dial 函数来实现这一点。使用 net.Conn 与基于 UDP 的连接可以防止非法连接发送消息给你,并消除在收到每个回复时检查发送方地址的需要。
清单 5-6 创建了基于 UDP 的 net.Conn,并演示了 net.Conn 如何封装 UDP 的实现细节,从而模拟面向流的网络连接。
package echo
import (
"bytes"
"context"
"net"
"testing"
"time"
)
func TestDialUDP(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
1 serverAddr, err := echoServerUDP(ctx, "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
defer cancel()
client, err := 2net.Dial("udp", serverAddr.String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = client.Close() }()
清单 5-6:创建回声服务器和客户端(dial_test.go)
连接的客户端可以通过 UDP 利用 net.Conn 的面向流的功能,但 UDP 监听器仍然必须使用 net.PacketConn。你为回声服务器 1 创建了一个实例,目的是向客户端发送回复。然后,你通过将 udp 作为第一个参数传递给 net.Dial2 来通过 UDP 拨号回声服务器。与 TCP 不同,回声服务器在调用 net.Dial 时不会接收到任何流量,因为无需握手。
清单 5-7 通过在回声服务器发送其回复之前向客户端发送消息来中断客户端。
`--snip--`
interloper, err := net.ListenPacket("udp", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
interrupt := []byte("pardon me")
1 n, err := interloper.WriteTo(interrupt, client.LocalAddr())
if err != nil {
t.Fatal(err)
}
_ = interloper.Close()
if l := len(interrupt); l != n {
t.Fatalf("wrote %d bytes of %d", n, l)
}
清单 5-7:中断客户端(dial_test.go)
就像在清单 5-4 中一样,你从一个非法连接 1 向客户端发送了一条消息。
清单 5-8 详细说明了使用 net.Conn 的 UDP 连接与使用 net.PacketConn 的连接之间的区别,如清单 5-5 所示。
`--snip--`
ping := []byte("ping")
_, err = 1client.Write(ping)
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 1024)
n, err = 2client.Read(buf)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(ping, buf[:n]) {
t.Errorf("expected reply %q; actual reply %q", ping, buf[:n])
}
err = 3client.SetDeadline(time.Now().Add(time.Second))
if err != nil {
t.Fatal(err)
}
_, err = 4client.Read(buf)
if err == nil {
t.Fatal("unexpected packet")
}
}
清单 5-8:使用 net.Conn 管理 UDP 流量(dial_test.go)
客户端通过使用net.Conn的Write方法 1 向回显服务器发送 ping 消息。net.Conn客户端会将其消息写入net.Dial调用中指定的地址。你不需要为每个通过客户端连接发送的数据包指定目标地址。同样,你通过客户端的Read方法 2 读取数据包。客户端只会从net.Dial调用中指定的发送方地址读取数据包,就像使用面向流的连接对象一样。客户端永远不会读取干扰连接发送的消息。为了确保,你设置了一个充足的截止时间 3,并尝试读取另一条消息 4。
对于你的目的,使用net.Conn而不是net.PacketConn可能会使你的 UDP 连接代码更简洁。只是要意识到权衡的利弊。使用net.Conn与 UDP 时,并不会提供与使用net.Conn与 TCP 时相同的功能。例如,基于 UDP 的net.Conn的Write方法不会在目标未能接收数据包时返回错误。使用 UDP 时,确保数据包交付的责任仍然在你的应用程序代码中。
避免分片
分片是一个第 3 层 IP 过程,将数据包拆分成适合在网络上高效传输的小块。所有网络媒介都有数据包大小限制,称为最大传输单元(MTU)。大于媒介最大传输单元的数据包需要进行分片,以便每个片段的大小小于或等于媒介的 MTU,然后节点才能通过媒介传输它们。一旦片段到达目标位置,操作系统会重新组装每个数据包,并将数据包呈现给你的应用程序。
但是,分片可能因各种原因损坏或未能到达目标。这对于使用 UDP 的情况来说是一个重要的考虑因素,因为与 TCP 不同,UDP 不会优雅地恢复丢失或损坏的数据。如果操作系统未能接收一个分片,发送方必须重新传输整个 UDP 数据包。如你所想,重新传输大数据包是非常低效的。尽管有许多方法可以减轻分片的影响,但我们将尝试完全避免它。我们将集中讨论一种简单的方法,来识别你计算机和目标节点之间的 MTU,并利用这些结果来确定负载大小,从而避免分片。
你可以使用ping命令来确定你计算机与目标节点之间的 MTU。ping命令允许你发送一个特定大小的 ICMP 数据包,并设置一个标志,告知节点不要对其进行分片。如果数据包到达一个需要分片的节点,因为它的大小超过了节点的 MTU,节点会看到不分片标志,并通过 ICMP 消息回应,告诉你数据包太大。
以下示例通过以太网发送这些 ping,按规范以太网的最小 MTU 为 46 字节,最大 MTU 为 1,500 字节。如果你计算机与目标主机之间的任何跳跃的 MTU 小于 1,500 字节,那么你的数据包将会被分片。让我们使用 Linux 上的 ping 命令来确认这一点(列表 5-9)。
$ **ping -M** 1**do -s** 2**1500 1.1.1.1**
PING 1.1.1.1 (1.1.1.1) 1500(31528) bytes of data.
ping: sendmsg: 4Message too long
列表 5-9:在 Linux 上使用 1,500 字节有效负载大小 ping 1.1.1.1
你将 -M 标志设置为 1 为 do,这设置了禁止分片选项,并将 -s 标志设置为 2 为 1500,这设置了 1,500 字节的有效负载。由于没有考虑数据包的头部大小,这应该超过以太网的 MTU。如预期的那样,你收到通知,数据包需要进行分片。你还看到总的数据包大小是 1,528 字节。额外的 28 字节是 8 字节的 ICMP 头部和 20 字节的 IP 头部之和。任何你指定的有效负载都应该考虑到整体头部大小。
如你所见,你在 列表 5-9 中从未收到 1.1.1.1 的回复,因为你发送的数据包太大,无法在每个跳跃中不进行分片。相反,ping 命令通知你消息太长。
让我们再试一次,减去 28 字节的有效负载(列表 5-10)。
$ **ping -M do -s 1472 1.1.1.1**
PING 1.1.1.1 (1.1.1.1) 1472(1500) bytes of data.
1480 bytes from 1.1.1.1: icmp_seq=1 ttl=59 time=11.8 ms
列表 5-10:在 Linux 上使用 1,472 字节有效负载大小 ping 1.1.1.1
这才像话。你确认了这台计算机与 1.1.1.1 之间通过互联网的 MTU 设置为 1,500 字节。这是你在网络上发送数据包时的最大大小,超过这个大小就需要进行分片。幸运的是,UDP 头部也是 8 字节,所以尽管使用 ICMP,ping 命令依然能给出准确的结果。考虑到头部大小,你的最大 UDP 有效负载大小是 1,472 字节,以避免分片。
在 Windows 上,等效的 ping 命令如下:
C:\>**ping -f -l 1500 1.1.1.1**
-f 标志指示节点不要分片数据包,-l 标志将数据包大小设置为给定的字节数。
在 macOS 上,ping 命令如下:
$ **ping -D -s 1500 1.1.1.1**
-D 标志设置了禁止分片标志,-s 标志指定了有效负载的大小。
请记住,你计算机的 MTU 可能与本章示例中的不同,因为网络或计算机与目标主机之间的 MTU 设置不同。我建议你尝试使用 ping 命令来确定从计算机到互联网各主机的 MTU,并查看是否发现任何差异。
你所学到的
UDP 是一种简约的基于数据报的协议,优先考虑速度而非可靠性,避免了许多 TCP 的流量控制和可靠性特性。UDP 适用于需要速度和简洁性、且能容忍数据丢失的场景,比如实时视频流。
由于 UDP 不是基于会话的,因此没有 UDP 监听器的概念,不会在建立会话后接受连接。相反,你需要通过使用 net.ListenPacket 来创建网络连接,它返回一个 net.PacketConn 接口。然后,你的代码可以从 net.PacketConn 接口读取任何传入的消息或数据报,因为每个 net.PacketConn 都会监听传入的消息。
使用 UDP 时,分片是一个需要认真考虑的问题。尽量避免 UDP 数据包的分片,以帮助确保数据的可靠传输非常重要。ping 命令可以帮助你确定计算机与目标网络之间的适当最大传输单元。由于 ping 命令使用的 ICMP 包头与 UDP 包头大小相同,你可以利用这一点轻松确定发生分片的有效载荷大小阈值。除了通过适当调整有效载荷的大小来管理分片外,你的代码还必须管理确认和重传,以确保可靠性。
第六章:确保 UDP 的可靠性

第五章介绍了使用 UDP 的基本网络应用,并展示了 Go 语言的net包和接口在编写可移植代码时的灵活性。本章接着上章的内容,介绍了一种确保 UDP 通信可靠性的方法。
本章开始时介绍了一个建立在 UDP 之上的应用协议。我们将覆盖该协议所使用的一部分类型,并展示如何利用它们可靠地传输数据。接着,我们将实现一个服务器,允许客户端使用该应用协议下载文件。最后,我们将从我们的服务器下载一个文件并验证其完整性。
使用 TFTP 进行可靠的文件传输
如前一章所述,UDP 本身是不可靠的。这意味着让 UDP 连接可靠是你的应用程序的工作。既然我们在上一章中讨论了 UDP,以及它在需要部分 TCP 特性的情况下的最佳使用方式,那么现在回过头来看这样一个应用层协议的例子是很合适的。
简单文件传输协议(TFTP)是一个应用层协议的例子,它确保了通过 UDP 进行可靠的数据传输。它允许两个节点通过实现一部分使 TCP 可靠的特性,来通过 UDP 传输文件。TFTP 服务器实现了有序的数据包传输、确认和重传。为了将这个例子简化到最基本的内容,你的服务器只允许客户端下载二进制数据。它不支持上传、美国信息交换标准代码(ASCII)传输或 TFTP 的后续添加功能,这些功能在 RFC 1350 之外进行了规定。为了简化,你的服务器无论客户端请求哪个文件,都会快速地提供相同的文件。
请记住,TFTP 不适合用于安全文件传输。虽然它为 UDP 连接增加了可靠性,但它不支持加密或认证。如果你的应用需要通过 UDP 进行通信,你可能会想使用 WireGuard(github.com/WireGuard/wireguard-go/),这是一个支持通过 UDP 进行安全通信的应用程序。
接下来的几个部分将实现一个只读的 TFTP 服务器,教你如何将可靠性添加到 UDP 中。所谓只读,我的意思是你的服务器只允许客户端下载文件,而不允许上传文件。你将从定义你的 TFTP 服务器支持的常量和类型的子集开始。你将把与类型相关的逻辑封装在每个类型的方法中。接着,你将实现 TFTP 服务器的代码部分,与客户端交互,并使用我们定义的类型来促进可靠的文件传输。
TFTP 类型
你的 TFTP 服务器将接受来自客户端的读取请求,发送数据包,传输错误包,并接受来自客户端的确认。为此,你必须在代码中定义一些类型来表示客户端请求、传输的数据、确认和错误。列表 6-1 概述了用于限制数据包大小、识别操作和编码各种错误的关键类型。
package tftp
import (
"bytes"
"encoding/binary"
"errors"
"io"
"strings"
)
const (
DatagramSize = 1516 // the maximum supported datagram size
BlockSize = 2DatagramSize – 4 // the DatagramSize minus a 4-byte header
)
3 type OpCode uint16
const (
OpRRQ OpCode = iota + 1
_ // no WRQ support
OpData
OpAck
OpErr
)
4 type ErrCode uint16
const (
ErrUnknown ErrCode = iota
ErrNotFound
ErrAccessViolation
ErrDiskFull
ErrIllegalOp
ErrUnknownID
ErrFileExists
ErrNoUser
)
列表 6-1:TFTP 服务器使用的类型和代码(types.go)
TFTP 限制数据报数据包的大小不得超过 516 字节,以避免分段。你定义了两个常量来强制执行数据报大小限制 1 和最大数据块大小 2。最大块大小是数据报大小减去 4 字节的头部。TFTP 数据包头的前 2 字节是操作码 3。
每个操作码是一个 2 字节的无符号整数。你的服务器支持四种操作:读取请求(RRQ)、数据操作、确认和错误。由于你的服务器是只读的,因此跳过了写请求(WRQ)的定义。
与操作码类似,你根据 RFC 定义了一系列无符号的 16 位整数错误代码 4。虽然在你的服务器中并未使用所有错误代码,因为它只允许下载,但客户端可以返回这些错误代码代替确认包。
以下章节详细说明了实现服务器支持的四种操作的类型。
读取请求
当客户端希望下载文件时,服务器会接收到一个 读取请求 数据包。然后,服务器必须回应一个数据包或一个错误包,接下来的几个章节中你将看到这两种包。任何一个数据包都可以作为对客户端的确认,表明服务器已接收到读取请求。如果客户端未收到数据包或错误包,它可以重新传输读取请求,直到服务器响应或客户端放弃。
图 6-1 说明了读取请求数据包的结构。

图 6-1:读取请求数据包结构
读取请求数据包由 2 字节的操作码、文件名、一个空字节、模式和一个尾随空字节组成。操作码是一个整数,对应每种操作类型的唯一标识。每个类型的操作码对应 RFC 1350 中详细说明的整数。例如,读取请求的操作码是 1。文件名和模式是长度可变的字符串。模式指示服务器如何发送文件:netascii 或 octet。如果客户端使用 netascii 模式请求文件,则客户端必须将文件转换为符合其自身行结束格式的格式。对于我们的目的,你只会接受 octet 模式,这要求服务器以二进制格式或原样发送文件。
列表 6-2 是 列表 6-1 的延续。在这里,你定义了读取请求及其方法,使服务器能够将请求编排成字节切片,以准备写入网络连接。
`--snip--`
1 type ReadReq struct {
Filename string
Mode string
}
// Although not used by our server, a client would make use of this method.
func (q ReadReq) MarshalBinary() ([]byte, error) {
mode := "octet"
if q.Mode != "" {
mode = q.Mode
}
// operation code + filename + 0 byte + mode + 0 byte
cap := 2 + 2 + len(q.Filename) + 1 + len(q.Mode) + 1
b := new(bytes.Buffer)
b.Grow(cap)
err := 2binary.Write(b, binary.BigEndian, OpRRQ) // write operation code
if err != nil {
return nil, err
}
_, err = b.WriteString(q.Filename) // write filename
if err != nil {
return nil, err
}
err = 3b.WriteByte(0) // write 0 byte
if err != nil {
return nil, err
}
_, err = b.WriteString(mode) // write mode
if err != nil {
return nil, err
}
err = 3b.WriteByte(0) // write 0 byte
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
清单 6-2:读取请求及其二进制序列化方法(types.go 续)
表示读取请求的结构体 1 需要跟踪文件名和模式。在将数据包序列化为字节切片时,您将操作码 2 和空字节 3 插入到缓冲区中。
清单 6-3 延续了清单 6-2 的内容,并通过定义一个方法,完成了读取请求的实现,该方法允许服务器从字节切片中反序列化读取请求,通常是从与客户端的网络连接中读取的。
`--snip--`
func (q *ReadReq) 1UnmarshalBinary(p []byte) error {
r := bytes.NewBuffer(p)
var code OpCode
err := 2binary.Read(r, binary.BigEndian, &code) // read operation code
if err != nil {
return err
}
if code != OpRRQ {
return errors.New("invalid RRQ")
}
q.Filename, err = 3r.ReadString(0) // read filename
if err != nil {
return errors.New("invalid RRQ")
}
q.Filename = 4strings.TrimRight(q.Filename, "\x00") // remove the 0-byte
if len(q.Filename) == 0 {
return errors.New("invalid RRQ")
}
q.Mode, err = r.ReadString(0) // read mode
if err != nil {
return errors.New("invalid RRQ")
}
q.Mode = strings.TrimRight(q.Mode, "\x00") // remove the 0-byte
if len(q.Mode) == 0 {
return errors.New("invalid RRQ")
}
actual := strings.ToLower(q.Mode) // enforce octet mode
if actual != "octet" {
return errors.New("only binary transfers supported")
}
return nil
}
清单 6-3:读取请求类型实现(types.go 续)
您的 TFTP 服务器的读取请求、数据、确认和错误数据包都实现了encoding.BinaryMarshaler和encoding.BinaryUnmarshaler接口。这些方法允许您的类型将自身序列化为适合通过网络传输的二进制格式,并将网络字节反序列化回原始类型。例如,读取请求类型可以使用其MarshalBinary方法将自身序列化为与图 6-1 所示的读取请求格式匹配的字节切片,正如清单 6-2 中所示。类似地,它可以使用其UnmarshalBinary方法 1,从网络中读取的字节切片中构建自身。尽管您的服务器不会发送读取请求并使用其MarshalBinary方法,但我鼓励您在本章学习过程中编写一个 TFTP 客户端,该客户端将读取请求序列化为二进制形式。我将此作为练习留给您来实现。
UnmarshalBinary方法仅在给定的字节切片匹配读取请求格式时才返回nil。如果您不确定给定的字节切片是否是读取请求,可以将字节切片传递给此方法,并根据返回值来确定。您将在查看服务器代码时看到这一点。
UnmarshalBinary方法读取前 2 个字节 2 并确认操作码是读取请求的操作码。然后它读取所有字节直到第一个空字节 3,并去除空字节分隔符 4。结果字节串表示文件名。类似地,您读取模式,如果一切符合预期则返回nil。服务器随后可以使用填充好的ReadReq来为客户端检索请求的文件。
数据包
客户端收到数据包作为其读取请求的响应,前提是服务器能够检索到请求的文件。服务器将文件分成一系列数据包发送,每个数据包都有一个分配的块号,从 1 开始,随后的每个数据包递增。块号帮助客户端正确地对接收到的数据进行排序,并处理重复数据包。
所有数据包的有效负载大小为 512 字节,最后一个数据包除外。客户端会继续读取数据包,直到接收到一个有效负载小于 512 字节的数据包,表示传输结束。在任何时候,客户端都可以返回一个错误数据包来代替确认包,服务器也可以返回一个错误数据包来代替数据包。错误数据包会立即终止传输。
图 6-2 显示了数据包的格式。

图 6-2:数据包结构
与读取请求包类似,数据包的前 2 个字节包含其操作码。接下来的 2 个字节表示块号。其余字节(最多 512 字节)是有效负载。
服务器要求客户端在每个数据包后进行确认。如果服务器没有及时收到客户端的确认或错误,服务器将重试传输,直到收到回复或用尽重试次数。图 6-3 展示了客户端从 TFTP 服务器下载文件的初步通信。

图 6-3:使用简单文件传输协议下载文件
一旦客户端发送了初始读取请求包,服务器就会响应并发送第一块数据。接下来,客户端确认接收到块 1。服务器收到确认后,回复第二块数据。但在这个模拟示例中,服务器没有及时收到客户端的回复,因此它重新发送了块 2。客户端接收到块 2 并发送确认。这个来回过程会持续进行,直到服务器发送最后一块有效负载小于 512 字节的数据包。
清单 6-4 详细介绍了用于实际数据传输的数据类型。
`--snip--`
1 type Data struct {
Block uint16
Payload io.Reader
}
2 func (d *Data) MarshalBinary() ([]byte, error) {
b := new(bytes.Buffer)
b.Grow(DatagramSize)
d.Block++ // block numbers increment from 1
err := binary.Write(b, binary.BigEndian, OpData) // write operation code
if err != nil {
return nil, err
}
err = binary.Write(b, binary.BigEndian, d.Block) // write block number
if err != nil {
return nil, err
}
// write up to BlockSize worth of bytes
_, err = 3io.CopyN(b, d.Payload, BlockSize)
if err != nil && err != io.EOF {
return nil, err
}
return b.Bytes(), nil
}
清单 6-4:数据类型及其二进制序列化方法(types.go继续)
Data结构体 1 跟踪当前块号和数据源。在这种情况下,负载是io.Reader,而不是字节切片,原因是io.Reader提供了更大的灵活性,允许你从不同来源获取负载。你可以像使用*os.File对象从文件系统读取文件一样,使用net.Conn对象从其他网络连接读取数据。io.Reader接口提供了简单字节切片无法提供的选项。你依赖读取器来跟踪剩余的字节数,这省去了你需要编写的大量代码。
每次调用MarshalBinary方法时,最多会返回 516 个字节的数据,依赖于io.CopyN函数 3 和BlockSize常量。由于你希望MarshalBinary修改状态,因此需要使用指针接收器。其目的是服务器可以不断调用此方法,从io.Reader中获取顺序递增的块号的连续数据块,直到读取器耗尽。与客户端一样,服务器也需要监控此方法返回的数据包大小。当数据包大小小于 516 字节时,服务器就知道它已经接收到最后一个数据包,并应该停止调用MarshalBinary。你将在本章后面看到此方法在服务器代码中的实际应用。
你可能已经意识到 16 位无符号块号可能发生整数溢出的风险。如果你发送一个超过 33.5MB(65,535 × 512 字节)的有效负载,块号将会溢出并回绕到 0。你的服务器将继续愉快地发送数据包,但客户端可能无法优雅地处理这一溢出。你应该考虑通过限制 TFTP 服务器支持的文件大小来减轻溢出风险,以避免触发溢出,识别溢出发生的可能性,并判断客户端是否可以接受,或者完全使用另一种协议。
Listing 6-5 完成了数据类型实现及其二进制反序列化方法的定义。该方法遵循 Listing 6-4 中的代码。
`--snip--`
func (d *Data) UnmarshalBinary(p []byte) error {
1 if l := len(p); l < 4 || l > DatagramSize {
return errors.New("invalid DATA")
}
var opcode
err := 2binary.Read(bytes.NewReader(p[:2]), binary.BigEndian, &opcode)
if err != nil || opcode != OpData {
return errors.New("invalid DATA")
}
err = 3binary.Read(bytes.NewReader(p[2:4]), binary.BigEndian, &d.Block)
if err != nil {
return errors.New("invalid DATA")
}
d.Payload = 4bytes.NewBuffer(p[4:])
return nil
}
Listing 6-5:数据类型实现(types.go续)
为了反序列化数据,首先需要进行初步的合理性检查 1,以确定数据包的大小是否在预期范围内,确保读取剩余字节是值得的。然后读取操作码 2 并进行检查,接着读取块号 3。最后,将剩余字节填充到一个新的缓冲区 4,并将其分配给Payload字段。
客户端使用块号向服务器发送相应的确认消息,并在其他接收到的数据块中正确排序这一数据块。
确认
确认数据包的长度仅为 4 字节,如图 6-4 所示。

图 6-4:确认数据包结构
与其他类型一样,前 2 个字节表示操作码。最后 2 个字节包含已确认的数据块号。
Listing 6-6 展示了确认类型的完整实现,遵循 Listing 6-5 的代码。
`--snip--`
1 type Ack uint16
func (a Ack) MarshalBinary() ([]byte, error) {
cap := 2 + 2 // operation code + block number
b := new(bytes.Buffer)
b.Grow(cap)
err := binary.Write(b, binary.BigEndian, OpAck) // write operation code
if err != nil {
return nil, err
}
err = binary.Write(b, binary.BigEndian, a) // write block number
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
func (a *Ack) UnmarshalBinary(p []byte) error {
var code OpCode
r := bytes.NewReader(p)
err := binary.Read(r, binary.BigEndian, &code) // read operation code
if err != nil {
return err
}
if code != OpAck {
return errors.New("invalid ACK")
}
return binary.Read(r, binary.BigEndian, a) // read block number
}
Listing 6-6:确认类型实现(types.go续)
你可以通过使用一个 16 位的无符号整数 1 来表示确认数据包。该整数被设置为已确认的数据块号。到目前为止,MarshalBinary和UnmarshalBinary方法应该已经很熟悉了,它们分别处理将操作码和块号序列化为字节切片,以及从网络读取的字节填充到Ack对象中。
错误处理
在 TFTP 中,客户端和服务器通过使用错误数据包来传递错误,如图 6-5 所示。

图 6-5:错误数据包结构
错误数据包由一个 2 字节的操作码、一个 2 字节的错误码、一个可变长度的错误消息和一个终止的空字节组成。
示例 6-7 详细描述了错误类型及其二进制序列化方法,这是示例 6-6 的延续。
`--snip--`
1 type Err struct {
Error ErrCode
Message string
}
func (e Err) MarshalBinary() ([]byte, error) {
// operation code + error code + message + 0 byte
cap := 2 + 2 + len(e.Message) + 1
b := new(bytes.Buffer)
b.Grow(cap)
err := binary.Write(b, binary.BigEndian, OpErr) // write operation code
if err != nil {
return nil, err
}
err = binary.Write(b, binary.BigEndian, e.Error) // write error code
if err != nil {
return nil, err
}
_, err = b.WriteString(e.Message) // write message
if err != nil {
return nil, err
}
err = b.WriteByte(0) // write 0 byte
if err != nil {
return nil, err
}
return b.Bytes(), nil
}
示例 6-7:用于在客户端和服务器之间传递错误的错误类型(types.go继续)
与读取请求类似,错误类型 1 包含构建错误数据包所需的最小数据:错误码和错误消息。MarshalBinary方法会按照图 6-5 中详细描述的字节顺序填充字节缓冲区。
示例 6-8 通过其二进制反序列化方法完成了错误类型的实现。此代码附加到示例 6-7 中的代码。
`--snip--`
func (e *Err) UnmarshalBinary(p []byte) error {
r := bytes.NewBuffer(p)
var code OpCode
err := 1binary.Read(r, binary.BigEndian, &code) // read operation code
if err != nil {
return err
}
if code != OpErr {
return errors.New("invalid ERROR")
}
err = 2binary.Read(r, binary.BigEndian, &e.Error) // read error message
if err != nil {
return err
}
e.Message, err = 3r.ReadString(0)
e.Message = 4strings.TrimRight(e.Message, "\x00") // remove the 0-byte
return err
}
示例 6-8:错误类型的二进制反序列化实现(types.go继续)
UnmarshalBinary方法非常简单,它读取并验证操作码 1,处理错误码 2 和错误消息 3,并去掉末尾的空字节 4。
TFTP 服务器
现在你将编写服务器代码,使用你定义的类型与 TFTP 客户端进行交互。
编写服务器代码
示例 6-9 描述了你的服务器类型以及允许其处理传入请求的方法。你的数据包类型实现了encoding.BinaryMarshaler和encoding.BinaryUnmarshaler接口,这意味着你的服务器代码可以充当网络接口和这些类型之间的桥梁,从而简化代码。你的服务器只需关注在你的类型和网络连接之间传输字节切片。类型接口中的逻辑会处理剩余部分。
package tftp
import (
"bytes"
"errors"
"fmt"
"log"
"net"
"time"
)
type Server struct {
1 Payload []byte // the payload served for all read requests
2 Retries uint8 // the number of times to retry a failed transmission
3 Timeout time.Duration // the duration to wait for an acknowledgment
}
func (s Server) ListenAndServe(addr string) error {
conn, err := net.ListenPacket("udp", addr)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
log.Printf("Listening on %s ...\n", conn.LocalAddr())
return s.Serve(conn)
}
func (s *Server) 4Serve(conn net.PacketConn) error {
if conn == nil {
return errors.New("nil connection")
}
if s.Payload == nil {
return errors.New("payload is required")
}
if s.Retries == 0 {
s.Retries = 10
}
if s.Timeout == 0 {
s.Timeout = 6 * time.Second
}
var rrq ReadReq
for {
buf := make([]byte, DatagramSize)
_, addr, err := conn.ReadFrom(buf)
if err != nil {
return err
}
err = 5rrq.UnmarshalBinary(buf)
if err != nil {
log.Printf("[%s] bad request: %v", addr, err)
continue
}
6 go s.handle(addr.String(), rrq)
}
}
示例 6-9:服务器类型实现(server.go)
我们的服务器维护一个有效负载 1,它会在每个读取请求时返回,包括尝试数据包传输的次数 2 和每次尝试之间的超时持续时间 3。服务器的Serve方法接受一个net.PacketConn并使用它来读取传入的请求 4。关闭网络连接将导致该方法返回。
服务器从其连接中读取最多 516 字节,并尝试将字节反序列化为ReadReq对象 5。由于你的服务器是只读的,它只关心服务读取请求。如果从连接中读取的数据是读取请求,服务器将把它传递给 goroutine 中的处理方法 6。我们接下来将定义该方法。
处理读取请求
该处理程序(Listing 6-10)接受来自客户端的读取请求,并回复服务器的有效载荷。它利用你在 TFTP 服务器类型系统中构建的功能,来提高 UDP 数据传输的可靠性。该处理程序发送一个数据包,并在发送下一个数据包之前等待客户端的确认。如果在预定时间内未收到客户端的回复,它还会尝试重新传输当前的数据包。
`--snip--`
1 func (s Server) handle(clientAddr string, rrq ReadReq) {
log.Printf("[%s] requested file: %s", clientAddr, rrq.Filename)
conn, err := 2net.Dial("udp", clientAddr)
if err != nil {
log.Printf("[%s] dial: %v", clientAddr, err)
return
}
defer func() { _ = conn.Close() }()
var (
ackPkt Ack
errPkt Err
dataPkt = 3Data{Payload: bytes.NewReader(s.Payload)}
buf = make([]byte, DatagramSize)
)
NEXTPACKET:
4 for n := DatagramSize; n == DatagramSize; {
data, err := dataPkt.MarshalBinary()
if err != nil {
log.Printf("[%s] preparing data packet: %v", clientAddr, err)
return
}
RETRY:
5 for i := s.Retries; i > 0; i-- {
6 n, err = conn.Write(data) // send the data packet
if err != nil {
log.Printf("[%s] write: %v", clientAddr, err)
return
}
// wait for the client's ACK packet
_ = conn.SetReadDeadline(time.Now().Add(s.Timeout))
_, err = conn.Read(buf)
if err != nil {
if nErr, ok := err.(net.Error); ok && nErr.Timeout() {
continue RETRY
}
log.Printf("[%s] waiting for ACK: %v", clientAddr, err)
return
}
switch {
case ackPkt.UnmarshalBinary(buf) == nil:
7 if uint16(ackPkt) == dataPkt.Block {
// received ACK; send next data packet
continue NEXTPACKET
}
case errPkt.UnmarshalBinary(buf) == nil:
log.Printf("[%s] received error: %v",
clientAddr, errPkt.Message)
return
default:
log.Printf("[%s] bad packet", clientAddr)
}
}
log.Printf("[%s] exhausted retries", clientAddr)
return
}
log.Printf("[%s] sent %d blocks", clientAddr, dataPkt.Block)
}
Listing 6-10: 处理读取请求(server.go 续)
该处理程序是Server类型上的一个方法 1,接受客户端地址和读取请求。之所以定义为方法,是因为你需要访问Server的字段。然后,通过使用net.Dial2 与你的客户端建立连接。记得,使用net.Dial创建的 UDP 连接对象将仅从客户端读取数据包,免去了你每次Read调用时都需要检查发送者地址的麻烦。你通过使用服务器的有效载荷来准备数据对象 3,然后进入for循环发送每个数据包 4。只要数据包大小等于 516 字节,该for循环将继续。
在将数据对象编组为字节切片后,你进入for循环 5,旨在重新发送数据包,直到达到重试次数上限或成功传送数据包为止。将数据包写入网络连接 6 会更新n循环变量,记录已发送的字节数。如果该值为 516 字节,当控制权回到标记为NEXTPACKET的for循环 4 时,你将再次迭代。如果该值小于 516 字节,则跳出循环。
在确定传输是否完成之前,你必须首先验证客户端是否成功接收了最后一个数据包。你从客户端读取字节,并尝试将其反序列化为Ack对象或Err对象。如果成功将其反序列化为Err对象,则说明客户端返回了一个错误,此时应记录该事实并提前返回。提前返回意味着此处理程序在未传送整个有效载荷的情况下终止传输。就我们的目的而言,这是不可恢复的。客户端需要重新请求文件,以启动另一次传输。
如果你成功地将字节反序列化为Ack对象,则可以检查该对象的Block值,以确定它是否与当前数据包 7 的块号匹配。如果匹配,则绕过for循环 4 并发送下一个数据包。如果不匹配,则绕过内部for循环 5 并重新发送当前数据包。
启动服务器
要启动 TFTP 服务器,你需要提供两个参数:一个文件(其有效载荷)和一个监听传入请求的地址(Listing 6-11)。
package main
import (
"flag"
"io/ioutil"
"log"
"github.com/awoodbeck/gnp/ch06/tftp"
)
var (
address = flag.String("a", "127.0.0.1:69", "listen address")
payload = flag.String("p", "payload.svg", "file to serve to clients")
)
func main() {
flag.Parse()
p, err := 1ioutil.ReadFile(*payload)
if err != nil {
log.Fatal(err)
}
s := 2tftp.Server{Payload: p}
3 log.Fatal(s.ListenAndServe(*address))
}
Listing 6-11: 命令行 TFTP 服务器实现(tftp.go)
一旦你将 TFTP 服务器要提供的文件 1 读取到字节切片中,就可以实例化服务器并将字节切片分配给服务器的Payload字段 2。最后一步是调用它的ListenAndServe方法,以在其将监听请求的 UDP 连接上建立连接。ListenAndServe方法 3 会为你调用服务器的Serve方法,该方法在网络连接上监听传入的请求。服务器将继续运行,直到你在命令行使用 ctrl-C 终止它。
通过 UDP 下载文件
现在让我们尝试从你刚刚写的服务器下载一个文件。首先,你需要确保安装了 TFTP 客户端。Windows 有一个本地 TFTP 客户端,你可以通过控制面板中的“程序和功能”部分,点击“打开或关闭 Windows 功能”链接来安装。勾选TFTP 客户端复选框,然后点击确定按钮来安装它。大多数 Linux 发行版通过其包管理器提供 TFTP 客户端,而 macOS 默认已安装 TFTP 客户端。
这个例子使用的是 Windows 10。首先通过在终端中运行列表 6-11 中的代码来启动 TFTP 服务器:
Microsoft Windows [Version 10.0.18362.449]
(c) 2019 Microsoft Corporation. All rights reserved.
C:\Users\User\gnp\ch06\tftp\tftp>**go run tftp.go**
2006/01/02 15:04:05 Listening on 127.0.0.1:69 ...
服务器默认应该绑定到 127.0.0.1 上的 UDP 端口 69。端口 69 是特权端口,你可能需要在 Linux 上使用 root 权限。你可能需要先使用go build tftp.go构建二进制文件,然后使用sudo命令运行生成的二进制文件,以便绑定到端口 69:sudo ./tftp。TFTP 服务器应该在标准输出上记录一条消息,指示它正在监听。
从另一个终端执行 TFTP 客户端,确保传递-i参数来告诉服务器你希望发起二进制(字节)传输。记住,TFTP 服务器并不关心源文件名是什么,因为它会根据请求的文件名返回相同的负载。在这个例子中,你将使用test.svg:
Microsoft Windows [Version 10.0.18362.449]
(c) 2019 Microsoft Corporation. All rights reserved.
C:\Users\User>**tftp -i 127.0.0.1 GET test.svg**
Transfer successful: 75352 bytes in 1 second(s), 75352 bytes/s
几乎在按下回车键后,客户端应该报告传输成功。TFTP 服务器的终端也应该显示其进度:
Microsoft Windows [Version 10.0.18362.449]
(c) 2019 Microsoft Corporation. All rights reserved.
C:\Users\User\gnp\ch06\tftp\tftp>**go run tftp.go**
2006/01/02 15:04:05 Listening on 127.0.0.1:69 ...
2006/01/02 15:04:05 [127.0.0.1:57944] requested file: test.svg
2006/01/02 15:04:05 [127.0.0.1:57944] sent 148 blocks
你可以通过将下载的文件与 TFTP 服务器的payload.svg的校验和进行比较来确认下载的文件与提供给 TFTP 服务器的负载相同。校验和是一个计算值,用于验证文件的完整性。如果两个文件相同,它们将具有相同的校验和。Linux 和 macOS 都有多种命令行工具可以生成校验和,但你将使用一个纯 Go 实现,如列表 6-12 所示。
package main
import (
"crypto/sha512"
"flag"
"fmt"
"io/ioutil"
"os"
)
func init() {
flag.Usage = func() {
fmt.Printf("Usage: %s file...\n", os.Args[0])
flag.PrintDefaults()
}
}
func main() {
flag.Parse()
for _, file := range 1flag.Args() {
fmt.Printf("%s %s\n", checksum(file), file)
}
}
func checksum(file string) string {
b, err := 2ioutil.ReadFile(file)
if err != nil {
return err.Error()
}
return fmt.Sprintf("%x", 3sha512.Sum512_256(b))
}
列表 6-12:为给定的命令行参数生成 SHA512/256 校验和(sha512-256sum.go)
这段代码将接受一个或多个文件路径作为命令行参数 1,并从它们的内容 2 生成 SHA512/256 校验和 3。
SHA512/256 校验和是通过截断 SHA512 校验和得到的 256 位版本。在 64 位机器上计算 SHA512 比计算 SHA256 校验和更快,因为 SHA512 计算使用的是 64 位字,而 SHA256 使用的是 32 位字。通过将 SHA512 截断为 256 位,你消除了 SHA512 本身容易受到的长度扩展哈希攻击。SHA512/256 在这里并不是必需的,因为你只是用它来验证文件的完整性,但你应该了解它,并将其列为你的哈希算法备选方案之一。
你可以使用清单 6-12 中的代码在清单 6-13 中验证你下载的文件(test.svg)是否与服务器发送的文件(payload.svg)相同。你将继续使用 Windows 作为目标平台,但这段代码在 Linux 和 macOS 上也能正常工作,无需修改:
Microsoft Windows [Version 10.0.18362.449]
(c) 2019 Microsoft Corporation. All rights reserved.
C:\Users\User\dev\gnp\ch06>**go build sha512-256sum\sha512-256sum.go**
C:\Users\User\dev\gnp\ch06>**sha512-256sum \Users\User\test.svg**
\Users\User\test.svg =>
1 3f5794c522e83b827054183658ce63cb701dc49f4e59335f08b5c79c56873969
C:\Users\User\dev\gnp\ch06>**sha512-256sum tftp\tftp\payload.svg**
tftp\tftp\payload.svg =>
2 3f5794c522e83b827054183658ce63cb701dc49f4e59335f08b5c79c56873969
清单 6-13:生成test.svg和payload.svg的 SHA512/256 校验和
如你所见,test.svg的校验和 1 等于payload.svg的校验和 2。
在这种情况下,test.svg文件是 Egon Elbre 在 GitHub 上的优秀gophers库中的一张 gopher 图像(github.com/egonelbre/gophers/)。如果你在浏览器中打开该文件,你会看到图 6-6 中的图像。
尽管你通过本地回环地址传输了有效载荷,并且不预期数据丢失或损坏,但客户端和服务器仍然确认了每一个数据包,确保了有效载荷的正确传输。

图 6-6:从 TFTP 服务器下载的有效载荷
你学到了什么
UDP 可以在应用层实现可靠性,正如琐碎文件传输协议(TFTP)所展示的那样。TFTP 结合了数据包序列号和确认机制,以确保客户端和服务器在所有传输的数据上达成一致,并在必要时重新传输丢失的数据包。
Go 的二进制序列化和反序列化接口的广泛使用使得你能够实现一些类型,从而使得使用 TFTP 进行通信变得简单。每个通过 UDP 传输的 TFTP 类型都实现了encoding.BinaryMarshaler接口,以便将其数据序列化为适合写入网络连接的格式。同样,任何你期望从网络连接读取的类型都应该实现encoding.BinaryUnmarshaler接口。成功地将二进制数据反序列化为自定义类型,可以帮助你确定接收到的二进制数据及其正确性。
第七章:Unix 域套接字

到目前为止,在本书中,我们已经讨论了网络节点之间的通信。但并非所有网络编程都专门在不同节点之间进行。您的应用程序有时可能需要与在同一节点上托管的服务(例如数据库)进行通信。
将应用程序连接到在同一系统上运行的数据库的一种方法是将数据发送到节点的 IP 地址或本地主机地址(通常为 127.0.0.1)以及数据库的端口号。但是,还有另一种方法:使用 Unix 域套接字。Unix 域套接字是一种使用文件系统确定数据包目的地地址的通信方法,允许在同一节点上运行的服务之间交换数据,这个过程称为进程间通信(IPC)。
本章首先精确定义了 Unix 域套接字是什么,以及如何控制对它们的读写访问。接下来,您将通过 Go 的net包探索三种 Unix 域套接字的类型,并在每种类型中编写一个简单的回显服务器。最后,您将编写一个使用 Unix 域套接字的服务,根据客户端的用户和组 ID 信息进行身份验证。
什么是 Unix 域套接字?
在第二章中,我将网络套接字定义为 IP 地址和端口号。套接字定址允许同一节点上的各个服务监听传入的流量。为了说明套接字定址的重要性,想象一下在大公司只有一条电话线的低效率。如果您想要与某人交谈,最好希望电话没有被占线。这就是为什么为了缓解拥塞,大多数公司为每个员工分配一个分机号。这使您可以通过拨打公司的电话号码(类似于节点的 IP 地址)后接员工的分机号(类似于端口号)来联系您想要交谈的人。正如电话号码和分机号允许您单独呼叫公司中的每个人一样,套接字地址的 IP 地址和端口号允许您与节点上每个套接字地址监听的每个服务通信。
Unix 域套接字将套接字寻址原理应用于文件系统:每个 Unix 域套接字在文件系统上都有一个关联的文件,该文件对应于网络套接字的 IP 地址和端口号。你可以通过读取和写入这个文件与监听该套接字的服务进行通信。同样,你可以利用文件系统的所有权和权限来控制对该套接字的读写访问。Unix 域套接字通过绕过操作系统的网络栈来提高效率,消除了流量路由的开销。出于同样的原因,在使用 Unix 域套接字时,你不需要担心分片或数据包排序问题。如果你选择放弃 Unix 域套接字,专门使用网络套接字与本地服务进行通信(例如,将应用程序连接到本地数据库、内存缓存等),你将忽视显著的安全优势和性能提升。
尽管这一系统带来了明显的优势,但它也有一个警告:Unix 域套接字仅限于使用它们的节点,因此你不能像使用网络套接字那样与其他节点进行通信。因此,如果你预见到将服务迁移到其他节点,或者需要最大程度的应用程序可移植性,Unix 域套接字可能并不适合你。为了保持通信,你必须首先迁移到网络套接字。
绑定到 Unix 域套接字文件
当你的代码尝试通过使用net.Listen、net.ListenUnix或net.ListenPacket函数绑定到一个未使用的 Unix 域套接字地址时,将会创建一个 Unix 域套接字文件。如果该地址的套接字文件已经存在,操作系统会返回一个错误,指示该地址正在使用。大多数情况下,简单地删除现有的 Unix 域套接字文件就足以解决错误。然而,你应首先确保套接字文件存在,并非因为某个进程正在使用该地址,而是因为你没有正确清理来自一个已经终止的进程的文件。
如果你希望重用一个套接字文件,可以使用net包的FileListener函数来绑定到一个已存在的套接字文件。这个函数超出了本书的范围,但我鼓励你阅读它的文档。
更改套接字文件的所有权和权限
一旦服务绑定到套接字文件,你可以使用 Go 的os包修改文件的所有权和读写权限。具体来说,os.Chown函数允许你修改文件的用户和组所有者。Windows 不支持此函数,但在 Windows Subsystem for Linux(WSL)、Linux、macOS 等平台上支持此函数,这些都超出了本书的范围。我们现在将查看更改文件所有权和权限的代码行,但将在本章稍后在上下文中进行讲解。
以下命令指示操作系统更新给定文件的用户和组所有权:
err := os.Chown("/path/to/socket/file", 1-1, 2100)
os.Chown 函数接受三个参数:文件路径、所有者的用户 ID 和所有者的组 ID。如果用户或组 ID 为 -1,则告诉 Go 保持当前的用户或组 ID。在此示例中,你希望保持套接字文件的当前用户 ID,但将其组 ID 设置为 100,这里假定它是 /etc/group 文件中的有效组 ID。
Go 的 os/user 包包含帮助你在用户和组名称与 ID 之间进行转换的函数。例如,这行代码使用 LookupGroup 函数查找 users 组的组 ID:
grp, err := user.LookupGroup("users")
如果提供的 user.LookupGroup 没有返回错误,grp 变量的 Gid 字段将包含 users 组的组 ID。
os.Chmod 函数更改文件的模式以及 Unix 兼容的权限位的数字表示法。这些权限位通知操作系统文件的模式、文件的用户读/写/执行权限、文件的组读/写/执行权限,以及对于任何不在文件组中的用户的读/写/执行权限:
err := os.Chmod("/path/to/socket/file", os.ModeSocket|0660)
os.Chmod 函数接受一个文件路径和一个 os.FileMode,后者表示文件模式、用户权限、组权限和非组用户权限。由于你正在处理一个套接字文件,应该始终在文件上设置 os.ModeSocket 模式。你可以通过 os.ModeSocket 与数字文件权限符号之间的按位或操作来做到这一点。在这里,你传递的是八进制的 0660,这意味着用户和组有读写权限,但阻止组外的任何人读取或写入套接字。你可以在 Go 的文档中阅读更多关于 os.FileMode 的内容,访问 golang.org/pkg/os/#FileMode,并在 en.wikipedia.org/wiki/File_system_permissions#Numeric_notation 上熟悉文件系统权限的数字表示法。
理解 Unix 域套接字类型
Unix 域套接字有三种类型:流套接字,其工作方式类似于 TCP;数据报套接字,其工作方式类似于 UDP;以及 序列数据包套接字,其结合了前两者的特点。Go 分别将这些类型指定为 unix、unixgram 和 unixpacket。在本节中,我们将编写与每种类型配合使用的回显服务器。
net.Conn 接口允许你一次编写代码并在多种网络类型中使用它。它抽象了 TCP、UDP 和 Unix 域套接字所使用的网络套接字之间的许多差异,这意味着你可以将为 TCP 通信编写的代码拿来直接用于 Unix 域套接字,只需更改地址和网络类型即可。
Unix 流套接字
流式 Unix 域套接字的工作原理类似于 TCP,但没有 TCP 的确认、校验和、流控制等开销。操作系统负责通过 Unix 域套接字实现流式进程间通信,代替了 TCP。
为了说明这种 Unix 域套接字,让我们编写一个函数来创建一个通用的基于流的回显服务器(示例 7-1)。你将能够使用这个函数与任何流式网络类型一起使用。这意味着你可以用它来创建一个到不同节点的 TCP 连接,但你也可以将它与unix类型一起使用,来与 Unix 套接字地址通信。
package echo
import (
"context"
"net"
)
func 1streamingEchoServer(ctx context.Context, network string,
addr string) (net.Addr, error) {
s, err := 2net.Listen(network, addr)
if err != nil {
return nil, err
}
示例 7-1:创建流式回显服务器函数 (echo.go)
streamingEchoServer函数 1 接受一个表示基于流的网络的字符串和一个表示地址的字符串,并返回一个地址对象和一个error接口。你应该从本书前面的内容中认识到这些参数和返回类型。
由于你通过接受上下文、网络字符串和地址字符串使回显服务器变得更加通用,你可以将任何基于流的网络类型传递给它,如tcp、unix或unixpacket。地址需要与网络类型相对应。上下文用于向服务器发出关闭信号。如果网络类型是tcp,地址字符串必须是 IP 地址和端口的组合,如 127.0.0.1:80。如果网络类型是unix或unixpacket,地址必须是指向一个不存在文件的路径。套接字文件将在回显服务器绑定到该地址时创建 2。然后服务器会开始监听传入的连接。
示例 7-2 完成了流式回显服务器的实现。
`--snip--`
go func() {
go func() {
1<-ctx.Done()
_ = s.Close()
}()
for {
conn, err := 2s.Accept()
if err != nil {
return
}
go func() {
defer func() { _ = conn.Close() }()
for {
buf := make([]byte, 1024)
n, err := 3conn.Read(buf)
if err != nil {
return
}
_, err = 4conn.Write(buf[:n])
if err != nil {
return
}
}
}()
}
}()
return s.Addr(), nil
}
示例 7-2:基于流的回显服务器 (echo.go)
使用net.Listen或net.ListenUnix创建的监听器在退出时会自动删除套接字文件。你可以通过net.UnixListener的SetUnlinkOnClose方法来修改这种行为,尽管默认行为对大多数使用案例来说是理想的。使用net.ListenPacket创建的 Unix 域套接字文件在监听器退出时不会自动删除,稍后在本章中你会看到这一点。
如前所述,你将回显服务器放入自己的 goroutine 中,以便它能够异步接受连接。一旦服务器接受了连接 2,你就会启动一个 goroutine 来回显接收到的消息。由于你使用的是net.Conn接口,你可以使用其Read3 和Write4 方法与客户端进行通信,无论服务器是通过网络套接字还是 Unix 域套接字进行通信。一旦调用者取消了上下文 1,服务器就会关闭。
示例 7-3 测试了通过unix网络类型使用 Unix 域套接字的流式回显服务器。
package echo
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"testing"
)
func TestEchoServerUnix(t *testing.T) {
dir, err := 1ioutil.TempDir("", "echo_unix")
if err != nil {
t.Fatal(err)
}
defer func() {
if rErr := 2os.RemoveAll(dir); rErr != nil {
t.Error(rErr)
}
}()
ctx, cancel := context.WithCancel(context.Background())
3 socket := filepath.Join(dir, fmt.Sprintf("%d.sock", os.Getpid()))
rAddr, err := streamingEchoServer(ctx, "unix", socket)
if err != nil {
t.Fatal(err)
}
err = 4os.Chmod(socket, os.ModeSocket|0666)
if err != nil {
t.Fatal(err)
}
示例 7-3:通过unix域套接字设置回显服务器测试 (echo_test.go)
你在操作系统的临时目录中创建一个名为 echo_unix1 的子目录,该子目录将包含回显服务器的套接字文件。对 os.RemoveAll 的延迟调用会在测试完成时清理服务器 2,通过删除临时子目录。你将一个名为 #.sock3 的套接字文件传递给 streamingEchoServer 函数,其中 # 是服务器的进程 ID,保存于临时子目录(/tmp/echo_unix/123.sock)中。最后,你确保所有人都能对套接字具有读写权限 4。
列表 7-4 连接到流回显服务器并发送测试。
`--snip--`
conn, err := net.Dial("unix", 1rAddr.String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = conn.Close() }()
msg := []byte("ping")
2 for i := 0; i < 3; i++ { // write 3 "ping" messages
_, err = conn.Write(msg)
if err != nil {
t.Fatal(err)
}
}
buf := make([]byte, 1024)
n, err := 3conn.Read(buf) // read once from the server
if err != nil {
t.Fatal(err)
}
expected := 4bytes.Repeat(msg, 3)
if !bytes.Equal(expected, buf[:n]) {
t.Fatalf("expected reply %q; actual reply %q", expected,
buf[:n])
}
_ = closer.Close()
<-done
}
列表 7-4:通过 Unix 域套接字传输流数据 (echo_test.go)
你通过使用熟悉的 net.Dial 函数来拨号连接服务器。它接受 unix 网络类型和服务器地址,即 Unix 域套接字文件的完整路径 1。
在读取第一个响应之前,你向回显服务器发送了三条 ping 消息 2。当你稍后在本章中探索 unixpacket 类型时,发送三条独立的 ping 消息的原因会变得很清楚。当你使用足够大的缓冲区读取第一个响应 3 来存储刚刚发送的三条消息时,你会以字符串 pingpingping 的形式接收到所有三条 ping 消息 4。请记住,基于流的连接并不区分消息。你需要自己确定当从服务器读取一系列字节时,一条消息的结束和另一条消息的开始。
unixgram 数据报套接字
接下来,让我们创建一个使用基于数据报的网络类型(如 udp 和 unixgram)进行通信的回显服务器。无论你是通过 UDP 还是 unixgram 套接字进行通信,你编写的服务器基本上是相同的。不同之处在于,你需要使用 unixgram 监听器清理套接字文件,正如你将在列表 7-5 中看到的那样。
`--snip--`
func datagramEchoServer(ctx context.Context, network string,
addr string) (net.Addr, error) {
s, err := 1net.ListenPacket(network, addr)
if err != nil {
return nil, err
}
go func() {
go func() {
<-ctx.Done()
_ = s.Close()
if network == "unixgram" {
_ = 2os.Remove(addr)
}
}()
buf := make([]byte, 1024)
for {
n, clientAddr, err := s.ReadFrom(buf)
if err != nil {
return
}
_, err = s.WriteTo(buf[:n], clientAddr)
if err != nil {
return
}
}
}()
return s.LocalAddr(), nil
}
列表 7-5:基于数据报的回显服务器 (echo.go)
你调用 net.ListenPacket1,它返回一个 net.PacketConn。如本章前面所述,由于你没有使用 net.Listen 或 net.ListenUnix 创建监听器,Go 在服务器完成后不会为你清理套接字文件。你必须确保自己移除套接字文件 2,否则后续尝试绑定到现有套接字文件时会失败。
由于 unixgram 网络类型在 Windows 上无法工作,列表 7-6 使用构建约束确保这段代码不会在 Windows 上运行,并导入必要的包。
// +build darwin linux
package echo
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"testing"
)
列表 7-6:适用于 macOS 和 Linux 的构建约束和导入 (echo_posix_test.go)
构建约束告诉 Go 仅在 macOS 或 Linux 操作系统上运行时包含这段代码。诚然,Go 支持其他操作系统,其中许多可能提供 unixgram 支持,但这些操作系统超出了本书的范围。此构建约束没有考虑到这些其他操作系统,因此我鼓励你在目标操作系统上测试这段代码。
在设置构建约束后,您可以在清单 7-7 中添加测试。
`--snip--`
func TestEchoServerUnixDatagram(t *testing.T) {
dir, err := ioutil.TempDir("", "echo_unixgram")
if err != nil {
t.Fatal(err)
}
defer func() {
if rErr := os.RemoveAll(dir); rErr != nil {
t.Error(rErr)
}
}()
ctx, cancel := context.WithCancel(context.Background())
1 sSocket := filepath.Join(dir, fmt.Sprintf("s%d.sock", os.Getpid()))
serverAddr, err := datagramEchoServer(ctx, "unixgram", sSocket)
if err != nil {
t.Fatal(err)
}
defer cancel()
err = os.Chmod(sSocket, os.ModeSocket|0622)
if err != nil {
t.Fatal(err)
}
清单 7-7:实例化基于数据报的回显服务器(echo_posix_test.go)
就像 UDP 连接一样,服务器和客户端都必须绑定到一个地址,以便它们可以发送和接收数据报。服务器有自己的套接字文件 1,该文件与客户端在清单 7-8 中的套接字文件是分开的。
`--snip--`
1 cSocket := filepath.Join(dir, fmt.Sprintf("c%d.sock", os.Getpid()))
client, err := net.ListenPacket("unixgram", cSocket)
if err != nil {
t.Fatal(err)
}
2 defer func() { _ = client.Close() }()
err = 3os.Chmod(cSocket, os.ModeSocket|0622)
if err != nil {
t.Fatal(err)
}
清单 7-8:实例化基于数据报的客户端(echo_posix_test.go)
在清单 7-5 的 datagramEchoServer 函数中调用 os.Remove 来清理服务器关闭时的套接字文件。客户端有一些额外的清理工作,因此当客户端完成监听后,您需要让客户端清理自己的套接字文件 1。幸运的是,在清单 7-7 中通过调用 os.RemoveAll 来删除临时子目录,已经为您处理了这个问题。否则,您需要在 defer2 中添加一个调用 os.Remove 来删除客户端的套接字文件。此外,服务器应该能够写入客户端的套接字文件以及它自己的套接字文件,否则服务器将无法回复消息。在这个例子中,您设置了非常宽松的权限,以便所有用户都可以写入套接字 3。
现在服务器和客户端都已实例化,清单 7-9 测试了流式回显服务器和数据报回显服务器之间的区别。
`--snip--`
msg := []byte("ping")
for i := 0; i < 3; i++ { // write 3 "ping" messages
_, err = 1client.WriteTo(msg, serverAddr)
if err != nil {
t.Fatal(err)
}
}
buf := make([]byte, 1024)
for i := 0; i < 3; i++ { // read 3 "ping" messages
n, addr, err := 2client.ReadFrom(buf)
if err != nil {
t.Fatal(err)
}
if addr.String() != serverAddr.String() {
t.Fatalf("received reply from %q instead of %q",
addr, serverAddr)
}
if !bytes.Equal(msg, buf[:n]) {
t.Fatalf("expected reply %q; actual reply %q", msg,
buf[:n])
}
}
}
清单 7-9:使用 unixgram 套接字回显消息(echo_posix_test.go)
您在读取第一个数据报之前向服务器 1 发送三条 ping 消息。然后,您执行三次读取 2,使用一个足够大的缓冲区来容纳三条 ping 消息。如预期,unixgram 套接字保持消息之间的划分;您发送了三条消息并读取了三条回复。与清单 7-3 和 7-4 中的 unix 套接字类型相比,您发送了三条消息并通过连接一次读取收到了三条回复。
unixpacket 序列数据包套接字
序列数据包套接字 类型是一个混合类型,它结合了 TCP 的面向会话的连接和可靠性,以及 UDP 清晰划分的数据报。然而,序列数据包套接字会丢弃每个数据报中的未请求数据。例如,如果您读取了一个 50 字节数据报中的 32 字节,操作系统会丢弃剩余的 18 个未请求字节。
在三种 Unix 域套接字类型中,unixpacket 具有最少的跨平台支持。再加上 unixpacket 的混合行为和丢弃未请求数据的特点,unix 或 unixgram 更适合大多数应用。您不太可能在互联网上找到序列数据包套接字的应用。它主要用于旧的 X.25 电信网络、某些类型的金融交易以及业余无线电中使用的 AX.25。
清单 7-10 中的测试代码展示了 unixpacket 套接字的演示。
package echo
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"testing"
)
func TestEchoServerUnixPacket(t *testing.T) {
dir, err := ioutil.TempDir("", "echo_unixpacket")
if err != nil {
t.Fatal(err)
}
defer func() {
if rErr := os.RemoveAll(dir); rErr != nil {
t.Error(rErr)
}
}()
ctx, cancel := context.WithCancel(context.Background())
socket := filepath.Join(dir, fmt.Sprintf("%d.sock", os.Getpid()))
rAddr, err := streamingEchoServer(ctx, "unixpacket", socket)
if err != nil {
t.Fatal(err)
}
defer cancel()
err = os.Chmod(socket, os.ModeSocket|0666)
if err != nil {
t.Fatal(err)
}
清单 7-10:实例化基于数据包的流式回显服务器(echo_linux_test.go)
首先注意,你将此代码保存在一个名为 echo_linux_test.go 的文件中。* _linux_test.go* 后缀是一个构建约束,告知 Go 仅在 Linux 上运行测试时包含此文件。
列表 7-11 拨打回声服务器并发送一系列 ping 消息。
`--snip--`
conn, err := 1net.Dial("unixpacket", rAddr.String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = conn.Close() }()
msg := []byte("ping")
2 for i := 0; i < 3; i++ { // write 3 "ping" messages
_, err = conn.Write(msg)
if err != nil {
t.Fatal(err)
}
}
buf := make([]byte, 1024)
3 for i := 0; i < 3; i++ { // read 3 times from the server
n, err := conn.Read(buf)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(msg, buf[:n]) {
t.Errorf("expected reply %q; actual reply %q", msg, buf[:n])
}
}
列表 7-11:使用 unixpacket 套接字回显消息 (echo_linux_test.go)
由于 unixpacket 是面向会话的,你使用 net.Dial1 来启动与服务器的连接。你不会像在数据报式网络类型下那样直接向服务器地址写入。
你可以通过向服务器 2 写入三条 ping 消息,在读取第一个回复之前,看到 unix 和 unixpacket 套接字类型之间的区别。而 unix 套接字类型会将所有三条 ping 消息合并为一次读取返回,而 unixpacket 就像其他基于数据报的网络类型一样,每次读取返回一条消息 3。
列表 7-12 说明了 unixpacket 如何在每个数据报中丢弃未请求的数据。
`--snip--`
for i := 0; i < 3; i++ { // write 3 more "ping" messages
_, err = conn.Write(msg)
if err != nil {
t.Fatal(err)
}
}
1 buf = make([]byte, 2) // only read the first 2 bytes of each reply
for i := 0; i < 3; i++ { // read 3 times from the server
n, err := conn.Read(buf)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(2msg[:2], buf[:n]) {
t.Errorf("expected reply %q; actual reply %q", msg[:2],
buf[:n])
}
}
}
列表 7-12:丢弃未读取的字节 (echo_linux_test.go)
这一次,你将缓冲区大小减少为 2 字节 1,并读取每个数据报的前 2 字节。如果你使用的是类似 tcp 或 unix 的流式网络类型,你会期待第一次读取时得到 pi,第二次读取时得到 ng。但 unixpacket 会丢弃 ping 消息中的 ng 部分,因为你只请求了前 2 字节—pi。因此,你确保每次读取时都只接收数据报的前 2 字节 2。
编写一个验证客户端身份的服务
在 Linux 系统上,Unix 域套接字允许你通过接收对端操作系统的凭证,获取有关对端进程的详细信息。你可以使用这些信息来验证 Unix 域套接字另一端的对端身份,并在对端凭证不符合你的标准时拒绝访问。例如,如果用户 davefromaccounting 通过 Unix 域套接字连接到你的管理服务,对端凭证可能表明你应该拒绝访问;Dave 应该在处理数字,而不是向你的管理服务发送数据。
你可以创建一个仅允许特定用户或在 * /etc/groups* 文件中找到的特定组中的任何用户连接的服务。* /etc/groups* 文件中的每个命名组都有一个对应的组 ID。当客户端连接到你的 Unix 域套接字时,你可以请求对端凭证,并将客户端的组 ID 与任何允许组的组 ID 进行比较。如果客户端的组 ID 与允许的组 ID 匹配,则可以认为客户端已通过身份验证。Go 的标准库对于处理 Linux 组提供了有用的支持,你将在“编写服务”一章中使用这些内容(第 156 页)。
请求对端凭证
请求对等凭证的过程并不完全简单。你不能仅仅从连接对象本身请求对等凭证。相反,你需要使用 golang.org/x/sys/unix 包从操作系统请求对等凭证,可以使用以下命令获取:
go get -u golang.org/x/sys/unix
列表 7-13 显示了一个函数,该函数接受一个 Unix 域套接字连接,并在对等方不是特定组的成员时拒绝访问。
package auth
import (
"log"
"net"
"golang.org/x/sys/unix"
)
func Allowed(conn *net.UnixConn, groups map[string]struct{}) bool {
if conn == nil || groups == nil || len(groups) == 0 {
return false
}
file, _ := 1conn.File()
defer func(){ _ = file.Close() }()
var (
err error
ucred *unix.Ucred
)
for {
ucred, err = 2unix.GetsockoptUcred(int(3file.Fd()), unix.SOL_SOCKET,
unix.SO_PEERCRED)
if err == unix.EINTR {
continue // syscall interrupted, try again
}
if err != nil {
log.Println(err)
return false
}
break
}
u, err := 4user.LookupId(string(ucred.Uid))
if err != nil {
log.Println(err)
return false
}
gids, err := 5u.GroupIds()
if err != nil {
log.Println(err)
return false
}
for _, gid := range gids {
if _, ok := 6groups[gid]; ok {
return true
}
}
return false
}
列表 7-13:获取套接字连接的对等凭证 (creds/auth/allowed_linux.go)
要检索对等方的 Unix 凭证,你首先需要从 net.UnixConn1 中获取底层文件对象,它表示你这一方的 Unix 域套接字连接。它类似于 Go 中 TCP 连接的 net.TCPConn。由于你需要从连接中提取文件描述符的详细信息,因此不能仅仅依赖从监听器的 Accept 方法收到的 net.Conn 接口。相反,你的 Allowed 函数需要调用者传递指向底层 net.UnixConn 对象的指针,通常这是从监听器的 AcceptUnix 方法返回的。你将在下一节中看到这个方法的实际应用。
然后,你可以将文件对象的描述符 3、协议级别的 unix.SOL_SOCKET 和选项名称 unix.SO_PEERCRED 传递给 unix.GetsockoptUcred 函数 2。要从 Linux 内核检索套接字选项,必须指定你想要的选项以及选项所在的级别。unix.SOL_SOCKET 告诉 Linux 内核你需要一个套接字级别的选项,而不是比如说 unix.SOL_TCP,它表示 TCP 级别的选项。unix.SO_PEERCRED 常量告诉 Linux 内核你需要对等凭证选项。如果 Linux 内核在 Unix 域套接字级别找到对等凭证选项,unix.GetsockoptUcred 将返回指向有效的 unix.Ucred 对象的指针。
unix.Ucred 对象包含对等方的进程、用户和组 ID。你将对等方的用户 ID 传递给 user.LookupId 函数 4。如果成功,你将从用户对象中获取一组组 ID 5。用户可以属于多个组,你需要考虑每个组的访问权限。最后,你检查每个组 ID 是否存在于允许的组映射中 6。如果对等方的任何一个组 ID 在你的映射中,你就返回 true,允许对等方连接。
这个例子主要是教学性的。你可以通过将套接字文件的组所有权分配给相应的组来实现类似的结果,正如我们在第 143 页“更改套接字文件的所有权和权限”中讨论的那样。然而,了解组成员身份可以用于应用程序中的访问控制和其他安全决策。
编写服务
现在,让我们在一个可以从命令行运行的服务中使用此函数。此服务将接受 Linux 操作系统中 /etc/group 文件中找到的一个或多个组名作为命令行参数,并开始监听 Unix 域套接字文件。只有当客户端是命令行中指定的某个组的成员时,服务才会允许客户端连接。客户端随后可以建立与该服务的 Unix 域套接字连接。服务将检索客户端的对等方凭证,并根据客户端是否是允许的组成员,决定是否允许其保持连接,或者立即断开未授权的客户端。该服务仅进行客户端组 ID 的身份验证,不执行其他操作。
在列表 7-14 中,你指定了所需的导入,并为该服务创建了一个有意义的使用消息。
package main
import (
"flag"
"fmt"
"log"
"net"
"os"
"os/signal"
"os/user"
"path/filepath"
"github.com/awoodbeck/gnp/ch07/creds/auth"
)
func init() {
flag.Usage = func() {
_, _ = fmt.Fprintf(flag.CommandLine.Output(),
"Usage:\n\t%s 1<group names>\n", filepath.Base(os.Args[0]))
flag.PrintDefaults()
}
}
列表 7-14:期望在命令行中传入组名(creds/creds.go)
我们的应用程序期望传入一系列组名作为参数 1。你将把每个组名的组 ID 添加到允许的组映射中。列表 7-15 中的代码解析了这些组名。
`--snip--`
func parseGroupNames(args []string) map[string]struct{} {
groups := make(map[string]struct{})
for _, arg := range args {
grp, err := 1user.LookupGroup(arg)
if err != nil {
log.Println(err)
continue
}
groups[2grp.Gid] = struct{}{}
}
return groups
}
列表 7-15:将组名解析为组 ID(creds/creds.go)
parseGroupNames 函数接受一个包含组名的字符串切片,获取每个组名的组信息 1,并将每个组的 ID 插入到 groups 映射 2 中。
列表 7-16 将最后几项列表结合成一个可以从命令行连接的服务。
`--snip--`
func main() {
flag.Parse()
groups := parseGroupNames(flag.Args())
socket := filepath.Join(os.TempDir(), "creds.sock")
addr, err := net.ResolveUnixAddr("unix", socket)
if err != nil {
log.Fatal(err)
}
s, err := net.ListenUnix("unix", addr)
if err != nil {
log.Fatal(err)
}
c := make(chan os.Signal, 1)
signal.Notify(c, 1os.Interrupt)
2 go func() {
<-c
_ = s.Close()
}()
fmt.Printf("Listening on %s ...\n", socket)
for {
conn, err := 3s.AcceptUnix()
if err != nil {
break
}
if 4auth.Allowed(conn, groups) {
_, err = conn.Write([]byte("Welcome\n"))
if err == nil {
// handle the connection in a goroutine here
continue
}
} else {
_, err = conn.Write([]byte("Access denied\n"))
}
if err != nil {
log.Println(err)
}
_ = conn.Close()
}
}
列表 7-16:基于凭证授权对等方(creds/creds.go 续)
你首先解析命令行参数,以创建允许的组 ID 映射。然后,你在 /tmp/creds.sock 套接字上创建一个监听器。监听器通过 AcceptUnix3 接受连接,因此会返回一个 *net.UnixConn,而不是通常的 net.Conn,因为你的 auth.Allowed 函数需要一个 *net.UnixConn 类型作为第一个参数。接着,你确定对等方的凭证是否被允许 4。允许的对等方保持连接,不允许的对等方会立即断开连接。
由于你将在命令行中执行此服务,因此可以通过发送中断信号来停止该服务,通常是使用 ctrl-C 键组合。然而,这个信号会在 Go 有机会清理套接字文件之前突然终止服务,尽管你已经小心使用了 net.ListenUnix。因此,你需要监听这个信号 1,并在接收到信号后启动一个 goroutine,在其中优雅地关闭监听器 2。这将确保 Go 正确地清理套接字文件。
使用 Netcat 测试服务
Netcat 是一个流行的命令行工具,它允许你建立 TCP、UDP 和 Unix 域套接字连接。你将使用它从命令行测试该服务。你可以在 Linux 发行版的包管理器中找到 Netcat。例如,在 Debian 10 上安装 Netcat 的 OpenBSD 重写版本,运行以下命令:
$ **sudo apt install netcat-openbsd**
该命令使用sudo命令行工具,以伪装成root用户的身份运行apt install netcat-openbsd。CentOS 8.1 提供了 Nmap 的 Netcat 替代品。运行此命令来安装它:
$ **sudo dnf install nmap-ncat**
安装完成后,你应该能在PATH环境变量中找到nc二进制文件。
在你可以连接到凭证检查服务之前,你需要运行该服务,使其绑定到一个套接字文件:
$ **cd $GOPATH/src/github.com/awoodbeck/gnp/ch07/creds**
$ **go run . -- users staff**
Listening on /tmp/creds.sock …
在这个例子中,你允许来自users或staff组的任何对等体连接。服务将拒绝任何不属于这两个组中的至少一个的对等体访问。如果这些组在你的 Linux 发行版中不存在,可以选择/etc/groups文件中的任何组。该服务监听/tmp/creds.sock套接字文件,这是你提供给 Netcat 的地址。
接下来,你需要一种方法来更改你的组 ID,以便测试服务是否拒绝访问未被允许的客户端。当前,服务是以你的用户和组 ID 运行的,因为你启动了该服务。因此,它会接受所有连接,因为服务允许其自己的组(即我们的组)进行身份验证,依据 Listing 7-15 中的groups映射。要在启动与服务的套接字连接时更改组,可以使用sudo命令行工具。
由于使用sudo需要提升权限,通常在尝试执行该命令时,系统会提示输入密码。我已省略以下示例中的密码提示,但在第一次调用sudo时,系统会提示输入密码:
$ **sudo -g staff -- nc -U /tmp/creds.sock**
Welcome
**^C**
$
使用sudo,你通过将组名传递给-g标志来修改你的组。在这种情况下,你将组设置为staff。然后,你执行nc命令。-U标志告诉 Netcat 建立与/tmp/creds.sock文件的 Unix 域套接字连接。
由于staff组是允许的组之一,连接后你会收到Welcome消息。按下 ctrl-C 终止你的连接。
如果你使用一个不允许的组重复测试,你应该会收到相反的结果:
$ **sudo -g nogroup -- nc -U /tmp/creds.sock**
Access denied
$
这次,你使用nogroup组,而该服务不允许该组。正如预期的那样,你立即收到Access denied消息,并且套接字的服务器端终止了你的连接。
你学到的内容
本章开始时,我们介绍了 Unix 域套接字。Unix 域套接字是一种基于文件的通信方法,用于同一节点上运行的进程之间的通信。两个或多个进程,比如本地数据库服务器和客户端,可以通过 Unix 域套接字发送和接收数据。由于 Unix 域套接字依赖文件系统进行寻址,因此你可以利用文件系统的所有权和权限来控制通过 Unix 域套接字进行通信的进程的访问。
你接着学习了 Go 支持的 Unix 域套接字类型:unix、unixgram 和 unixpacket。Go 使得通过 Unix 域套接字进行通信变得相对轻松,并且处理了许多细节,特别是如果你坚持使用 net 包的接口。例如,针对基于流的 TCP 网络编写的代码也能在 unix 域套接字(仅限本地进程通信)上工作,几乎不需要修改。同样,针对 UDP 网络编写的代码可以通过 unixgram 域套接字来使用。你还了解了混合型的 Unix 域套接字类型 unixpacket,并学到了它的缺点在大多数应用中并不超过其优点,特别是在跨平台支持方面。对于大多数使用场景,另外两种 Unix 域套接字类型是更好的选择。
本章介绍了对等凭据,并展示了如何使用它们来验证客户端连接。你可以超越基于文件的 Unix 域套接字访问限制,获取 Unix 域套接字另一端客户端的详细信息。
现在你应该具备了判断 Unix 域套接字在网络栈中最佳应用位置的能力。
第三部分
应用层编程
第八章:编写 HTTP 客户端

超文本传输协议**(HTTP) 是一种应用层协议,被万维网使用。在 HTTP 通信中,Web 客户端向 Web 服务器发送一个 统一资源定位符**(URL),然后 Web 服务器返回相应的媒体资源。在这个上下文中,资源 可以是图像、样式表、HTML 文档、JavaScript 文件等。例如,如果您的 Web 浏览器向 Google 的 Web 服务器发送了 URL www.google.com,服务器将返回 Google 的主页。我们中的大多数人每天都会进行这种 Web 交易,无论它们是来自我们的手机、电脑,还是物联网(IoT)设备,如门铃、温控器或烤面包机(是的,真的)。
本章将向您介绍 Go 的 HTTP 客户端。首先,您将学习 HTTP 的基础知识,包括请求方法和响应代码。接下来,您将探索 Go 的 HTTP 客户端,向 Web 服务器请求资源,并注意过程中可能遇到的问题。然后,您将进入标准库代码,学习实现 HTTP 客户端与服务器之间请求响应通信的相关内容。最后,您将了解使用 Go 的 HTTP 客户端与 Web 服务器交互时常见的问题。
本章将为您提供与服务进行 HTTP 交互的基础知识。您需要掌握这些基础,才能在下一章中了解如何从服务器的角度处理请求。
理解 HTTP 的基础知识
HTTP 是一种无状态的客户端-服务器协议,在该协议中,客户端向服务器发起请求,服务器对客户端进行响应。HTTP 是一种应用层协议,作为 Web 上通信的基础。它使用 TCP 作为底层传输层协议。
本章假设您使用的是 HTTP 1.1 版本(HTTP/1.1)。我们还将介绍 HTTP 2.0 版本(HTTP/2)中引入的功能。幸运的是,Go 对这些协议之间的差异进行了抽象处理,因此我们可以使用相同的代码轻松地使用任意一个协议。
统一资源定位符
URL是客户端用来定位网页服务器并识别请求资源的一种地址。它由五个部分组成:一个必需的scheme,表示用于连接的协议,一个可选的authority,表示资源的权限,一个表示资源路径的path,一个可选的query,以及一个可选的fragment。冒号(:)后跟两个斜杠(//)将 scheme 和 authority 分开。authority 包括一个可选的由冒号分隔的用户名和密码,后接一个@符号,一个主机名,以及一个可选的端口号,端口号前面有冒号。path 是由斜杠分隔的一系列段。问号(?)表示 query 的开始,通常由键值对组成,键值对之间用&符号分隔。井号(#)前置 fragment,它是资源的子部分标识符。综合来看,URL 遵循如下模式:
`scheme`://`user`:`password`@`host`:`port`/`path`?`key1`=`value1`&`key2`=`value2`#`table_of_contents`
你在互联网上使用的典型 URL 至少包括一个 scheme 和一个 hostname。例如,如果你想查找地松鼠的图片,你可以通过在浏览器地址栏中输入以下 URL 来访问 Google 的图像搜索,然后在图像搜索标签中搜索gophers:
1https://2images.google.com3/
scheme 1 告知浏览器你希望使用 HTTPS 协议连接到地址images.google.com2,并且你希望获取默认资源 3。如果你指定了网页服务器的地址而没有明确指定资源,网页服务器将响应一个默认资源。就像大公司在你没有输入分机号时会将电话转接给接待员一样,网页服务器在你没有指定想要的资源时也会提供一个默认资源。谷歌接收到你的请求并返回图像搜索页面。当你在搜索框中输入gophers并提交表单时,浏览器会发送一个像这样的网址请求,以下是简化后的示例:
https://www.google.com/1search2?3q=gophers&tbm=isch . . .
这个 URL 向谷歌请求一个名为search1 的资源,并包含一个query string。query string 由问号 2 开始,包含由&符号分隔的参数,这些参数由网页服务器定义,并对其有意义。在这个例子中,q参数 3 的值是你的搜索查询,gophers。tbm参数的值是isch,告诉谷歌你正在进行图像搜索。谷歌定义了这些参数及其值,你将它们作为请求的一部分传递给谷歌的网页服务器。实际上,浏览器地址栏中的 URL 要长得多,并包含谷歌为了满足你的请求所需的其他详细信息。
如果我的妻子让我使用 HTTP 去购物,她可能会给我这样的 URL:
automobile://the.grocery.store/purchase?butter=irish&eggs=12&coffee=dark_roast
这告诉我,我应该开车去杂货店,买爱尔兰黄油、一打鸡蛋和深烘焙咖啡。值得提到的是,scheme 仅与使用它的上下文相关。我的网页浏览器不知道该如何处理automobile这个 scheme,但为了我的婚姻,我可得知道怎么做。
客户端资源请求
HTTP 请求 是从客户端发送到 Web 服务器的一条消息,要求服务器返回特定的资源。请求包括方法、目标资源、头部信息和正文。方法 告诉服务器你希望它如何处理目标资源。例如,GET 方法后跟 robots.txt 告诉服务器你希望它将 robots.txt 文件发给你,而 DELETE 方法则表示你希望服务器删除该资源。
请求头部 包含关于你发送的请求内容的元数据。例如,Content-Length 头部指定请求体的大小(以字节为单位)。请求体 是请求的有效负载。如果你上传一张新的个人资料图片到 Web 服务器,请求体将包含以适合通过网络传输的格式编码的图像,而 Content-Length 头部的值则会被设置为图像在请求体中的字节大小。并非所有的请求方法都需要请求体。
列表 8-1 详细介绍了通过 Netcat 向 Google 的 Web 服务器发送简单的 GET 请求以获取 robots.txt 文件的过程。《使用 Netcat 测试服务》在第 159 页会引导你安装 Netcat。
$ **nc www.google.com 80**
1**GET /robots.txt HTTP/1.1**
2 HTTP/1.1 200 OK
3 Accept-Ranges: none
Vary: Accept-Encoding
Content-Type: text/plain
Date: Mon, 02 Jan 2006 15:04:05 MST
Expires: Mon, 02 Jan 2006 15:04:05 MST
Cache-Control: private, max-age=0
Last-Modified: Mon, 02 Jan 2006 15:04:05 MST
X-Content-Type-Options: nosniff
Server: sffe
X-XSS-Protection: 0
Transfer-Encoding: chunked
4User-agent: *
Disallow: /search
Allow: /search/about
Allow: /search/static
Allow: /search/howsearchworks
`--snip--`
列表 8-1:请求 Google 的 robots.txt 文件并接收其内容的响应
GET 请求 1 告诉 Google 的 Web 服务器,你想要通过 HTTP/1.1 获取 /robots.txt 文件。请求之后,你按下回车键两次以发送请求,后面跟着一个空行。Web 服务器会迅速响应,返回状态行 2、一系列头部信息 3、一个空行用来分隔头部和响应体,最后是 robots.txt 文件的内容在响应体中 4。你将在本章稍后学习关于服务器响应的内容。
使用 Go 的 net/http 包,你可以只使用 HTTP 方法和 URL 来创建一个请求。net/http 包包括最常用的 RFC 7231 和 RFC 5789 请求方法的常量。这些 RFC 中包含了大量关于请求方法的术语。以下描述了如何在实践中使用这些方法:
-
GET就像之前的例子一样,GET方法指示服务器将目标资源发送给你。服务器将在响应的正文中提供目标资源。需要注意的是,目标资源不一定是文件;响应也可以返回动态生成的内容,比如前面讨论的 gophers 图片搜索结果。服务器在收到GET请求后不应该修改或删除资源。 -
HEADHEAD方法类似于GET,不过它告诉服务器在响应中排除目标资源。服务器只会发送响应代码和存储在响应头中的各种元数据。你可以使用这种方法来检索有关资源的有意义的细节,例如资源的大小,以便决定是否要获取该资源。(该资源可能比你预期的要大。) -
POSTPOST请求是一种将请求体中的数据上传到 Web 服务器的方式。POST方法告诉服务器你正在发送数据,以便与目标资源关联。例如,你可以发布一个新的评论到新闻故事中,在这种情况下,新闻故事就是目标资源。简单来说,可以将POST方法视为在服务器上创建新资源的方法。 -
PUT与POST类似,你可以使用PUT请求将数据上传到 Web 服务器。实际上,PUT方法通常用于更新或完全替换现有资源。你可以使用PUT编辑你已发布到新闻故事中的评论。 -
PATCHPATCH方法指定对现有资源的部分修改,其他部分保持不变。这样,它就像是一个差异比较。假设你正在为你生命中那个特别的人购买一只河狸毛绒玩具,并且已经完成了结账流程中的配送地址步骤,这时你发现自己在街道地址中打了个错别字。你跳回到配送地址表单并纠正了这个错误。现在,你可以再次使用POST提交表单并将所有内容发送到服务器。但PATCH请求会更高效,因为你只做了一个小修正。你很可能会在 API 中遇到PATCH方法,而不是在 HTML 表单中。 -
DELETEDELETE方法指示服务器删除目标资源。假设你在新闻故事中的评论太过争议性,现在邻居们避免和你进行眼神交流。你可以向服务器发送DELETE请求,删除你的评论并恢复你的社交地位。 -
OPTIONS你可以使用OPTIONS方法向服务器询问目标资源支持哪些方法。例如,你可以发送一个OPTIONS请求,目标资源是你的新闻评论,结果得知DELETE方法并不是服务器支持的操作,这意味着你现在最好的选择是另找地方住并结交新邻居。 -
CONNECT客户端使用CONNECT请求 Web 服务器执行HTTP 隧道,或者与目标地址建立 TCP 会话并在客户端和目标之间代理数据。 -
TRACETRACE方法指示 Web 服务器将请求回显给你,而不是处理它。此方法允许你查看是否有任何中间节点在请求到达 Web 服务器之前修改了请求。
需要注意的是,Web 服务器没有义务实现这些请求方法。此外,您可能会发现一些 Web 服务器未能正确实现它们。相信,但要验证。
服务器响应
而客户端的请求始终指定一个方法和目标资源,Web 服务器的响应始终包括一个状态码,以通知客户端其请求的状态。成功的请求会导致响应中包含 200 类状态码。
如果客户端发出的请求需要客户端进一步操作,服务器将返回 300 类状态码。例如,如果客户端请求一个自上次请求以来没有变化的资源,服务器可能返回 304 状态码,通知客户端应该从缓存中渲染该资源。
如果由于客户端的请求发生错误,服务器将在响应中返回 400 类状态码。此情形中最常见的例子是客户端请求一个不存在的目标资源,在这种情况下,服务器将返回 404 状态码,通知客户端未能找到该资源。
500 类状态码通知客户端服务器端发生了错误,导致服务器无法完成请求。假设您的请求需要 Web 服务器从上游服务器获取资源以满足请求,但上游服务器未能响应。Web 服务器将返回 504 状态码,表示在与上游服务器的通信过程中发生了超时。
在 HTTP/1.1 中存在一些 100 类状态码,用于给客户端提供指示。例如,客户端在发送 POST 请求时可以向服务器请求指导。为此,客户端将发送 POST 方法、目标资源和请求头到服务器,其中之一告诉服务器客户端希望获得继续发送请求体的许可。服务器可以返回 100 状态码,表示客户端可以继续请求并发送请求体。
IANA 维护着 HTTP 状态码的官方列表,您可以在www.iana.org/assignments/http-status-codes/http-status-codes.xhtml找到它。如果您遇到一个相对不常见的状态码,通常可以在 RFC 7231 的tools.ietf.org/html/rfc7231#section-6中找到对其的描述。
Go 定义了许多这样的状态码作为常量在其 net/http 包中,我建议您在代码中使用这些常量。读取 http.StatusOK 比记住 200 的含义要容易得多。您将遇到的最常见的 HTTP 状态码包括以下内容:
-
200 OK表示请求成功。如果请求方法是GET,响应体包含目标资源。 -
201 Created当服务器成功处理请求并添加了一个新资源时返回,通常是在POST请求的情况下。 -
202 Accepted如果请求成功,但服务器尚未创建新资源时,通常会返回此状态码。尽管请求成功,资源的创建仍可能失败。 -
204 No Content如果请求成功但响应体为空,通常会返回此状态码。 -
304 Not Modified当客户端请求一个未更改的资源时返回。客户端应该使用其缓存的资源副本。一种缓存方法是使用实体标签(ETag)头部。当客户端向服务器请求资源时,响应中可能会包含一个可选的服务器派生的 ETag 头部,它对服务器有意义。如果客户端在未来再次请求相同的资源,可以将缓存的 ETag 头部及其值传递到请求中。服务器将检查客户端请求中的 ETag 值,以确定请求的资源是否已更改。如果未更改,服务器可能会返回 304 状态码和空的响应体。 -
400 Bad Request如果服务器因某种原因直接拒绝客户端的请求,则返回此状态码。这可能是由于请求格式不正确,例如请求方法中没有指定目标资源。 -
403 Forbidden如果服务器接受了你的请求,但确定你没有权限访问该资源,或者服务器本身没有权限访问请求的资源时,通常会返回此状态码。 -
404 Not Found如果你请求一个不存在的资源,服务器会返回此状态码。你也可能看到此状态码作为 Glomar 响应,当服务器不想确认或否认你是否有权限访问某个资源时。换句话说,网络服务器可能会对你没有权限访问的资源返回 404 状态码,而不是明确地返回 403 状态码确认你没有访问权限。试图访问你网络服务器上敏感资源的攻击者希望将精力集中在现有资源上,即使他们当前没有访问这些资源的权限。返回 404 状态码对于不存在和被禁止的资源来说是一种安全措施,防止攻击者区分这两者。这样做的缺点是,你更难调试服务器上的权限问题,因为你无法知道你请求的资源是否存在,或者你只是缺乏权限。我建议你在服务器日志中明确区分这两者。 -
405 Method Not Allowed如果你为目标资源指定了服务器不支持的请求方法,则返回此状态码。还记得你在我们讨论OPTIONS请求方法时试图删除的有争议评论吗?你会收到 405 状态码作为对该DELETE请求的响应。 -
426 升级要求返回此代码是为了指示客户端在请求目标资源之前,必须首先升级到 TLS。 -
500 服务器内部错误是一种通用的错误代码,当服务器发生错误,无法满足客户端的请求,但该错误不符合其他状态码的标准时,服务器会返回此错误代码。由于服务器端代码中的配置错误或语法错误,服务器经常返回 500 错误。如果你的服务器返回此代码,请检查日志。 -
502 错误网关当服务器在客户端与上游服务之间代理数据时,如果上游服务不可用且不接受请求,服务器将返回此错误。 -
503 服务不可用如果网络服务器无法接受请求,则会返回此代码。例如,当网络服务器进入维护模式时,它可能会为所有传入连接返回 503 状态码。 -
504 网关超时由代理服务器返回,表示上游服务已接受请求,但未能及时提供回复。
从请求到渲染页面
一个网页通常由各种资源组成,如图像、视频、网页浏览器的布局指令、第三方广告等。访问每个资源需要向服务器发起单独的请求。在 HTTP 1.0(HTTP/1.0)版本中,客户端必须为每个请求单独发起 TCP 连接。HTTP/1.1 消除了这一要求,减少了与多个 HTTP 请求同一服务器的请求-连接开销和延迟。相反,它允许通过同一个 TCP 连接发送多个请求和响应。(所有现代的 Web 服务器软件和网页浏览器至少支持 HTTP/1.1,因此你不太可能使用 HTTP/1.0。)
表 8-1 演示了检索 HTML 文档以及随后对该文档中所有指定资源的GET调用。
表 8-1:请求索引 HTML 文档后检索附加资源
| 状态 | 方法 | 域名 | 资源 | 类型 | 传输字节 | 传输时长 |
|---|---|---|---|---|---|---|
| 200 | GET | woodbeck.net | / | HTML | 1.83KB | 49 毫秒 |
| 200 | GET | woodbeck.net | main.min.css | CSS | 1.30KB | 20 毫秒 |
| 200 | GET | woodbeck.net | code.css | CSS | 0.99KB | 20 毫秒 |
| 304 | GET | woodbeck.net | avatar.jpeg | JPEG | 0 字节 | 0 毫秒 |
| 404 | GET | woodbeck.net | favicon.ico | IMG | 0 字节 | 0 毫秒 |
对 woodbeck.net/ 的初始 GET 请求成功地检索了由默认资源指定的 HTML 文档。这个 HTML 文档包含了渲染页面所需的附加资源的链接,因此 web 浏览器也请求了这些资源。由于这次传输使用了 HTTP/1.1,web 浏览器使用相同的 TCP 连接来检索剩余的资源。由于该资源自上次 web 浏览器接收以来没有变化,web 服务器指示浏览器使用其缓存的 avatar.jpeg 副本。由于 web 服务器未能找到 favicon.ico 文件,因此返回了 404 状态码给浏览器。
HTTP 的最新版本 HTTP/2 旨在进一步减少延迟。除了对后续请求重用相同的 TCP 连接外,HTTP/2 服务器还可以主动将资源推送到客户端。如果 表 8-1 中的对话是通过 HTTP/2 进行的,它可能是这样发生的。客户端请求了默认资源,服务器响应了默认资源。但由于服务器知道默认资源有依赖的资源,它推送这些资源到客户端,而无需客户端为每个资源单独发起请求。
Go 的 HTTP 客户端和服务器透明地支持 HTTP/1.0、HTTP/1.1 和 HTTP/2,这意味着你可以编写代码来检索和提供资源,同时让 Go 的 net/http 包中的代码来协商最佳的 HTTP 版本。然而,尽管 Go 的 HTTP/2 服务器实现可以将资源推送到客户端,但 Go 的 HTTP/2 客户端实现目前还无法接收这些服务器推送。
在 Go 中检索 Web 资源
就像你的 web 浏览器一样,Go 可以使用 net/http 包的 HTTP 客户端与 web 服务器进行通信。与 web 浏览器不同,Go 不会直接将 HTML 页面渲染到屏幕上。相反,你可以使用 Go 来从网站抓取数据(如财务股票详情)、提交表单数据,或与使用 HTTP 作为应用协议的 API 进行交互,举几个例子。
尽管在 Go 中发起 HTTP 请求很简单,但你需要处理一些客户端的陷阱。你将在接下来的内容中学习这些陷阱。首先,让我们看一个简单的示例请求。
使用 Go 的默认 HTTP 客户端
net/http 包包括一个默认客户端,允许你发起一次性的 HTTP 请求。例如,你可以使用 http.Get 函数向指定的 URL 发送 GET 请求。
列表 8-2 演示了你可以从受信任的机构 —— time.gov 的 web 服务器检索当前时间,并与计算机本地时间进行比较的一种方式。这将让你大致了解计算机本地时间的提前或滞后情况。你当然不想依赖这种方法来进行任何形式的取证,但这个示例用来展示通过 HEAD 请求和响应来演示 Go HTTP 客户端工作流。
package main
import (
"net/http"
"testing"
"time"
)
func TestHeadTime(t *testing.T) {
resp, err := 1http.Head("https://www.time.gov/")
if err != nil {
t.Fatal(err)
}
_ = 2resp.Body.Close() // Always close this without exception.
now := time.Now().Round(time.Second)
date := 3resp.Header.Get("Date")
if date == "" {
t.Fatal("no Date header received from time.gov")
}
dt, err := time.Parse(time.RFC1123, date)
if err != nil {
t.Fatal(err)
}
t.Logf("time.gov: %s (skew %s)", dt, now.Sub(dt))
}
示例 8-2:从 time.gov 获取时间戳(time_test.go)
net/http 包包含一些辅助函数,用于发起 GET、HEAD 或 POST 请求。在这里,我们使用 http.Get 函数 1 来访问 www.time.gov/ 以获取默认资源。Go 的 HTTP 客户端会自动为你升级到 HTTPS,因为这是 URL 协议中指明的协议。虽然你没有读取响应体的内容,但你必须关闭它 2。下一节将讨论为何在每种情况下都需要关闭响应体。
现在你有了响应,你可以获取 Date 头部 3,这个头部指示了服务器生成响应的时间。你可以使用这个值来计算你计算机的时钟偏差。当然,由于服务器生成头部和你的代码处理它之间存在延迟,并且 Date 头部本身缺乏纳秒级的精度,你会失去一些准确性。
关闭响应体
如前所述,HTTP/1.1 允许客户端与服务器保持一个 TCP 连接,进行多个 HTTP 请求(我们称之为 keepalive 支持)。即便如此,当先前响应中的未读取字节仍然在网络上时,客户端无法重用 TCP 会话。Go 的 HTTP 客户端在你关闭响应体时会自动清空响应体,这使得你的代码可以重用底层的 TCP 会话,只要你始终如一地关闭每一个响应体。
让我们回顾一下来自示例 8-1 的响应,看看 Go 是如何解析响应的(示例 8-3)。
1HTTP/1.1 200 OK
Accept-Ranges: none
Vary: Accept-Encoding
Content-Type: text/plain
Date: Mon, 02 Jan 2006 15:04:05 MST
Expires: Mon, 02 Jan 2006 15:04:05 MST
Cache-Control: private, max-age=0
Last-Modified: Mon, 02 Jan 2006 15:04:05 MST
X-Content-Type-Options: nosniff
Server: sffe
X-XSS-Protection: 0
Transfer-Encoding: chunked
2User-agent: *
Disallow: /search
Allow: /search/about
Allow: /search/static
Allow: /search/howsearchworks
`--snip--`
示例 8-3:解析 HTTP 响应
Go 的 HTTP 客户端会从网络套接字中读取响应的状态和头部 1,这些数据会立即作为响应对象的一部分提供给你的代码。然而,客户端并不会自动读取响应体 2。直到你显式地读取它,或者直到你关闭它并且 Go 隐式地清空所有未读取的字节,响应体才会被处理。
Go HTTP 客户端在关闭时隐式地清空响应体,这可能会对你造成困扰。例如,假设你发送了一个 GET 请求请求一个文件,并且收到了来自服务器的响应。你读取了响应中的 Content-Length 头部,发现文件比你预期的要大得多。如果你在没有读取任何字节的情况下关闭响应体,Go 会在清空响应体时从服务器下载整个文件。
更好的替代方法是发送一个 HEAD 请求来获取 Content-Length 头部。这样,响应体中就不会有未读取的字节,因此关闭响应体时不会产生任何额外的开销。你在示例 8-2 中已正确关闭了响应体,因此如果以后你发出更多请求,Go 的 HTTP 客户端可以重用 TCP 会话。
在极少数情况下,如果你进行 HTTP 请求并且希望显式地清空响应体,最有效的方法是使用 io.Copy 函数:
_, _ = io.Copy(ioutil.Discard, response.Body)
_ = response.Close()
io.Copy 函数通过从 response.Body 中读取所有字节并将这些字节写入 ioutil.Discard 来清空 response.Body。顾名思义,ioutil.Discard 是一个特殊的 io.Writer,会丢弃所有写入它的字节。
你不必忽略 io.Copy 和 response.Close 的返回值,但这样做可以让其他开发人员知道你故意忽略了这些值。一些开发人员可能会认为这是多余的冗长,的确,在这种情况下,io.Copy 或 response.Close 很少会返回错误,但这仍然是一个好习惯。我曾遇到过代码在隐式忽略错误,可能是出于习惯,而开发人员本应处理这些错误。
底线是,无论是否读取响应体,都必须关闭它,以避免资源泄漏。
实现超时和取消
Go 的默认 HTTP 客户端以及通过 http.Get、http.Head 和 http.Post 辅助函数创建的请求不会超时。这个问题的后果可能不会立刻显现,直到你遇到以下事实(从此你将永远记住):没有超时或截止日期意味着一个表现不正常或恶意的服务可能会导致你的代码无限期阻塞,并且不会产生错误来表明出了问题。你可能直到用户开始投诉时才会发现你的服务出了问题。
例如,示例 8-4 展示了一个简单的测试,导致 HTTP 客户端无限期阻塞。
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func blockIndefinitely(w http.ResponseWriter, r *http.Request) {
select {}
}
func TestBlockIndefinitely(t *testing.T) {
ts := 1httptest.NewServer(2http.HandlerFunc(3blockIndefinitely))
_, _ = http.Get(4ts.URL)
t.Fatal("client did not indefinitely block")
}
示例 8-4:测试服务器使默认的 HTTP 客户端无限期阻塞 (block_test.go)。
net/http/httptest 包包含一个有用的 HTTP 测试服务器。httptest.NewServer1 函数接受一个 http.HandlerFunc2,后者又包装了 blockIndefinitely 函数 3。测试服务器将它接收到的任何请求通过其 URL 4 传递给 http.HandlerFunc 的 ServeHTTP 方法。该方法将请求和响应对象传递给 blockIndefinitely 函数,在该函数中控制将无限期阻塞。
因为辅助函数 http.Get 使用默认的 HTTP 客户端,所以这个 GET 请求不会超时。相反,go test 运行器最终会超时并停止测试,打印堆栈跟踪。
为了解决这个问题,生产代码应该使用你在第 57 页“使用带有截止日期的上下文来超时连接”中学到的技术。创建一个上下文,并用它初始化一个新的请求。然后,你可以通过使用上下文的 cancel 函数,或者创建一个带有截止日期或超时的上下文,手动取消该请求。
让我们通过将 示例 8-4 中的测试替换为 示例 8-5 来修复测试。新的测试将在五秒钟内没有收到服务器响应时超时。
`--snip--`
func TestBlockIndefinitelyWithTimeout(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(blockIndefinitely))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := 1http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal(err)
}
return
}
_ = resp.Body.Close()
}
示例 8-5:为 GET 请求添加超时 (block_test.go)
首先,你通过传入上下文、请求方法、URL 和一个 nil 请求体来创建一个新的请求 1,因为你的请求没有负载。请记住,上下文的计时器在你初始化上下文时就开始运行。上下文控制着请求的整个生命周期。换句话说,客户端有五秒钟的时间连接到 Web 服务器,发送请求,读取响应头,并将响应传递给你的代码。接下来,你有剩余的五秒钟时间来读取响应体。如果你正在读取响应体时上下文超时,你的下一个读取操作会立即返回错误。因此,请为你的特定应用程序使用宽松的超时值。
或者,创建一个没有超时或截止时间的上下文,并仅通过使用计时器和上下文的 cancel 函数来控制上下文的取消,如下所示:
ctx, cancel := context.WithCancel(context.Background())
timer := time.AfterFunc(5*time.Second, 1cancel)
// Make the HTTP request, read the response headers, etc.
// ...
// Add 5 more seconds before reading the response body.
timer.Reset(5*time.Second)
这段代码演示了如何使用一个计时器,该计时器将在超时后调用上下文的 cancel 函数 1。你可以根据需要重置计时器,将 cancel 调用推迟到更远的未来。
禁用持久化 TCP 连接
默认情况下,Go 的 HTTP 客户端在读取完响应后会保持与 Web 服务器的底层 TCP 连接,除非服务器明确要求断开连接。虽然这在大多数使用场景中是期望的行为,因为它允许你在多个请求中复用同一个 TCP 连接,但你可能会不小心让计算机无法与其他 Web 服务器建立新的 TCP 连接。
这是因为计算机能够保持的活动 TCP 连接数是有限的。如果你编写了一个向多个 Web 服务器发送一次性请求的程序,你可能会发现,当所有计算机的 TCP 连接耗尽后,程序停止工作,无法打开新的连接。在这种情况下,TCP 会话重用可能会对你不利。与其在客户端禁用 TCP 会话重用,不如更灵活的做法是按请求告知客户端如何处理 TCP 套接字。
`--snip--`
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL, nil)
if err != nil {
t.Fatal(err)
}
1req.Close = true
`--snip--`
将请求的 Close 字段 1 设置为 true 告诉 Go 的 HTTP 客户端,在读取完 Web 服务器的响应后,它应该关闭底层的 TCP 连接。如果你知道你将发送四个请求到 Web 服务器且不会再发送更多请求,你可以在第四个请求上将 Close 字段设置为 true。所有四个请求将使用同一个 TCP 会话,并且客户端将在接收到第四个响应后终止 TCP 连接。
通过 HTTP 发送数据
发送POST请求及其负载到 Web 服务器,就像你之前发出的请求一样。不同之处在于,请求体包含负载。这个负载可以是任何实现了io.Reader接口的对象,包括文件句柄、标准输入、HTTP 响应体或 Unix 域套接字等。但正如你将看到的,向 Web 服务器发送数据涉及比GET请求更多的代码,因为你必须准备请求体。
向 Web 服务器发布 JSON
在你能向测试服务器发送数据之前,你需要创建一个能够接收数据的处理器。列表 8-6 创建了一个名为User的新类型,你将其编码为 JavaScript 对象表示法(JSON)并发布到该处理器。
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
)
type User struct {
First string
Last string
}
1 func handlePostUser(t *testing.T) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
defer func(r io.ReadCloser) {
_, _ = 2io.Copy(ioutil.Discard, r)
_ = r.Close()
}(r.Body)
if r.Method != 3http.MethodPost {
4http.Error(w, "", http.StatusMethodNotAllowed)
return
}
var u User
err := json.NewDecoder(r.Body).Decode(&u)
if err != nil {
t.Error(err)
http.Error(w, "Decode Failed", http.StatusBadRequest)
return
}
5w.WriteHeader(http.StatusAccepted)
}
}
列表 8-6:一个可以将 JSON 解码为User对象的处理器(post_test.go)
handlePostUser函数 1 返回一个处理POST请求的函数。如果请求方法是除POST外的其他任何方法 3,它将返回一个状态码,表示服务器不允许该方法 4。该函数然后尝试将请求体中的 JSON 解码为User对象。如果解码成功,响应的状态将设置为Accepted5。
与 Go HTTP 客户端不同,Go HTTP 服务器必须显式地在关闭请求体之前先排空它 2。我们将在第九章中详细讨论这个问题。
列表 8-7 中的测试将User对象编码为 JSON 并通过POST请求发送到测试服务器。
`--snip--`
func TestPostUser(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(handlePostUser(t)))
defer ts.Close()
resp, err := http.Get(ts.URL)
if err != nil {
t.Fatal(err)
}
if 1resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d; actual status %d",
http.StatusMethodNotAllowed, resp.StatusCode)
}
buf := new(bytes.Buffer)
u := User{First: "Adam", Last: "Woodbeck"}
2 err = json.NewEncoder(buf).Encode(&u)
if err != nil {
t.Fatal(err)
}
resp, err = 3http.Post(ts.URL, "application/json", buf)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 4http.StatusAccepted {
t.Fatalf("expected status %d; actual status %d",
http.StatusAccepted, resp.StatusCode)
}
_ = resp.Body.Close()
}
列表 8-7:将User对象编码为JSON并发布到测试服务器(post_test.go)
测试首先确保如果客户端发送错误类型的请求 1,测试服务器的处理器能正确响应错误。如果测试服务器收到非POST请求,它会响应方法不被允许错误。然后,测试会将User对象编码为 JSON,并将数据写入字节缓冲区 2。它向测试服务器的 URL 发送POST请求,并将内容类型设置为application/json,因为字节缓冲区(表示请求体)包含 JSON3。内容类型告知服务器的处理器请求体中预计的数据类型。如果服务器的处理器正确解码了请求体,响应的状态码将是 202 Accepted4。
发布带有附件文件的 Multipart 表单
向 Web 服务器发布 JSON 很简单。只需设置适当的内容类型并将 JSON 负载放入请求体中。但如何在单个POST请求中向 Web 服务器发送多个数据块呢?答案是:使用mime/multipart包。
mime/multipart包允许你构建多部分多用途互联网邮件扩展(MIME)消息,通过一个称为边界的字符串将你想发送的每个数据块与其他数据块分开。稍后你将在本节中看到边界的示例,尽管这通常不是你需要担心的事情。
每个 MIME 部分都包括描述内容的可选头部,以及包含实际内容的主体。例如,如果 Web 服务器解析了一个Content-Type头部设置为text/plain的 MIME 部分,它将把该部分的主体视为纯文本。
Listing 8-8 介绍了一个新的测试,带领你通过使用mime/multipart包构建 multipart 请求体的过程。
`--snip--`
func TestMultipartPost(t *testing.T) {
reqBody := 1new(bytes.Buffer)
w := 2multipart.NewWriter(reqBody)
for k, v := range map[string]string{
"date": time.Now().Format(time.RFC3339),
"description": "Form values with attached files",
} {
err := 3w.WriteField(k, v)
if err != nil {
t.Fatal(err)
}
}
Listing 8-8:创建一个新的请求体、multipart writer 并写入表单数据(post_test.go)
首先,你创建一个新的缓冲区 1 作为请求体。然后你创建一个新的 multipart writer 2,将其包装到缓冲区中。multipart writer 在初始化时会生成一个随机的边界。最后,你将表单字段写入 multipart writer 3。multipart writer 将每个表单字段分隔到独立的部分中,并为每个部分的主体写入边界、适当的头部和表单字段值。
到这时,你的请求体有两个部分,一个是date表单字段,一个是description表单字段。接下来,让我们在 Listing 8-9 中附加一些文件。
`--snip--`
for i, file := range []string{
"./files/hello.txt",
"./files/goodbye.txt",
} {
filePart, err := 1w.CreateFormFile(fmt.Sprintf("file%d", i+1),
filepath.Base(file))
if err != nil {
t.Fatal(err)
}
f, err := os.Open(file)
if err != nil {
t.Fatal(err)
}
_, err = 2io.Copy(filePart, f)
_ = f.Close()
if err != nil {
t.Fatal(err)
}
}
err := 3w.Close()
if err != nil {
t.Fatal(err)
}
Listing 8-9:将两个文件写入请求体,每个文件独立为一个 MIME 部分(post_test.go)
将字段附加到请求体中并不像添加表单字段数据那样直接。你需要额外的步骤。首先,你需要从 Listing 8-8 中的 multipart writer 1 创建一个 multipart 部分写入器。CreateFormField方法接受字段名和文件名。服务器在解析 MIME 部分时使用这个文件名,它不需要与附加的文件名匹配。现在,你只需打开文件并将其内容复制到 MIME 部分写入器 2 中。
当你完成向请求体中添加各个部分后,必须关闭 multipart writer 3,这将通过附加边界来最终确定请求体。
Listing 8-10 将请求发布到一个知名的测试服务器httpbin.org。
`--snip--`
ctx, cancel := context.WithTimeout(context.Background(),
60*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
1"https://httpbin.org/post", 2reqBody)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", 3w.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected status %d; actual status %d",
http.StatusOK, resp.StatusCode)
}
t.Logf("\n%s", b)
}
Listing 8-10:使用 Go 的默认 HTTP 客户端发送POST请求到httpbin.org(post_test.go)
首先,你创建一个新的请求,并传递一个将在 60 秒后超时的上下文。由于你是通过互联网发起这个请求,所以不像在本地测试时那样有那么高的确定性,无法确保请求会顺利到达目标地址。这个POST请求的目标是www.httpbin.org/1,并将在其有效载荷中发送 multipart 请求体 2。
在发送请求之前,你需要设置Content-Type头部,告知 Web 服务器你正在发送多个部分。multipart writer 的FormDataContentType方法 3 返回适当的Content-Type值,其中包含边界。Web 服务器使用这个头部中的边界来确定请求体的每一部分的起始和结束。
一旦你使用-v标志运行测试,你应该会看到像 Listing 8-11 中的 JSON 输出。
{
"args": {},
"data": "",
1"files": {
"file1": "Hello, world!\n",
"file2": "Goodbye, world!\n"
},
2"form": {
"date": "2006-01-02T15:04:05-07:00",
"description": "Form fields with attached files"
},
"headers": {
"Accept-Encoding": "gzip",
"Content-Length": "739",
3 "Content-Type": "multipart/form-data; boundary=e9ad4b62e0dfc8d7dc57ccfa8ba62244342f1884608e6d88018f9de8abcb",
"Host": "httpbin.org",
"User-Agent": "Go-http-client/1.1"
},
"json": null,
"origin": "192.168.0.1",
"url": "https://httpbin.org/post"
}
Listing 8-11:multipart POST请求的响应体
这是 httpbin.org 的标准 POST 响应,包含一些与你发送的请求无关的字段。但是,如果你查看,你会看到你附加的每个文本文件的内容 1 和你提交的表单字段 2。你还可以看到 multipart 写入器添加的 Content-Type 头 3。请注意,边界是一个随机字符串。在你的代码中,每次请求时,边界都会随机变化。但如果你愿意,可以通过使用 multipart 写入器的 SetBoundary 方法来设置一个边界。
你所学到的内容
HTTP 允许客户端向服务器发送请求,并从服务器接收资源,通过全球信息网进行通信。本章向你展示了如何使用 Go 构建 HTTP 请求。目标资源可以是网页、图片、视频、文档、文件、游戏等。为了检索资源,HTTP 客户端向 Web 服务器发送带有 URL 的 GET 请求。Web 服务器使用该 URL 定位正确的资源,并将其发送到客户端的响应中。客户端始终发起这个 HTTP 请求-响应流程。
客户端可以向服务器发送多种类型的资源请求。最常用的请求方法有 GET、HEAD、POST、PUT 和 DELETE。GET 请求要求服务器检索指定的资源。客户端可以发送 HEAD 请求,以获取响应头部,而不包含请求的负载。这对于确定资源是否存在,以及在检索资源之前检查响应头部非常有用。POST 请求允许客户端向服务器发送资源,而 PUT 请求通常用于更新服务器上的现有资源。客户端可以通过发送 DELETE 请求来请求服务器删除某个资源。
net/http 包提供了所有必要的类型和函数,以便通过 HTTP 与服务器进行交互。它包括一个默认的 HTTP 客户端,允许你快速发起一次性 HTTP 请求并接收响应。然而,无论你是否读取响应体的内容,你都必须认真关闭响应体,以防止资源泄露。还要注意,默认的 HTTP 客户端以及通过 http.Get、http.Head 和 http.Post 等助手函数发送的请求不会超时。这意味着,某个行为异常或恶意的服务可能会导致你的代码无限期阻塞。因此,重要的是通过使用上下文来自己管理请求的取消。
mime/multipart 包允许你轻松地向请求体中添加多个 MIME 部分。你可以高效地构建上传文件和表单内容到 Web 服务器的请求。
第九章:构建 HTTP 服务

现在你已经编写了发送 HTTP 请求的客户端代码,让我们构建一个能够处理这些请求并将资源发送给客户端的服务器。net/http 包为你处理了大多数实现细节,因此你可以专注于实例化和配置服务器、创建资源以及处理每个客户端请求。
在 Go 中,一个 HTTP 服务器依赖于几个相互作用的组件:处理程序、中间件和复用器。当它包含所有这些部分时,我们将这个服务器称为 Web 服务。我们将从一个简单的 HTTP Web 服务开始,然后在本章中逐步探讨每个组件。整体框架应该能帮助你理解初学者常常觉得抽象的主题。
你还将学习 net/http 包的更高级用法,例如添加 TLS 支持和将数据推送到 HTTP/2 客户端。到最后,你应该能够自如地配置基于 Go 的 HTTP 服务器,编写中间件,并使用处理程序响应请求。
Go HTTP 服务器的结构
图 9-1 说明了请求在典型的 net/http 基于服务器中的路径。

图 9-1:客户端请求最终由处理程序响应的服务器
首先,服务器的 复用器(在计算机网络术语中是 路由器)接收客户端的请求。复用器确定请求的目标,然后将其传递给能够处理它的对象。我们称这个对象为 处理程序。(复用器本身就是一个处理程序,它将请求路由到最合适的处理程序。)在处理程序接收到请求之前,请求可能会经过一个或多个称为 中间件 的函数。中间件改变处理程序的行为或执行辅助任务,例如日志记录、身份验证或访问控制。
清单 9-1 创建一个遵循基本结构的 HTTP 服务器。如果你在跟随过程中遇到困难,别担心;你将花费本章的其余部分来学习这些部分如何工作。
package main
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"testing"
"time"
"github.com/awoodbeck/gnp/ch09/handlers"
)
func TestSimpleHTTPServer(t *testing.T) {
srv := &http.Server{
Addr: "127.0.0.1:8081",
Handler: 1http.TimeoutHandler(
handlers.DefaultHandler(), 2*time.Minute, ""),
IdleTimeout: 5 * time.Minute,
ReadHeaderTimeout: time.Minute,
}
l, err := 2net.Listen("tcp", srv.Addr)
if err != nil {
t.Fatal(err)
}
go func() {
err := 3srv.Serve(l)
if err != http.ErrServerClosed {
t.Error(err)
}
}()
清单 9-1:实例化复用器和 HTTP 服务器 (server_test.go)
发送到服务器的处理程序的请求首先经过名为 http.TimeoutHandler 的中间件 1,然后传递给由 handlers.DefaultHandler 函数返回的处理程序。在这个非常简单的示例中,你为所有请求指定了一个处理程序,而不是依赖于复用器。
服务器有几个字段。Handler 字段接受一个复用器或其他能够处理客户端请求的对象。Address 字段你现在应该已经很熟悉了。在这个示例中,你希望服务器监听 IP 地址 127.0.0.1 上的端口 8081。我将在下一节中解释 IdleTimeout 和 ReadHeaderTimeout 字段。现在可以简单地说,你应该始终定义这两个字段。
最后,你创建一个新的net.Listener并将其绑定到服务器的地址 2,然后指示服务器从这个监听器 3 处理请求。Serve方法在正常关闭时返回http.ErrServerClosed。
现在,让我们来测试这个服务器。列表 9-2 从列表 9-1 开始,详细介绍了一些测试请求及其预期结果。
`--snip--`
testCases := []struct {
method string
body io.Reader
code int
response string
}{
1{http.MethodGet, nil, http.StatusOK, "Hello, friend!"},
2{http.MethodPost, bytes.NewBufferString("<world>"), http.StatusOK,
"Hello, <world>!"},
3{http.MethodHead, nil, http.StatusMethodNotAllowed, ""},
}
client := new(http.Client)
path := fmt.Sprintf("http://%s/", srv.Addr)
列表 9-2:HTTP 服务器的请求测试用例(server_test.go)
首先,你发送了一个GET请求 1,结果返回了一个 200 OK 状态码。响应体中包含字符串Hello, friend!。
在第二个测试用例 2 中,你发送了一个POST请求,其主体包含字符串<world>。尖括号是故意的,它们展示了处理客户端输入时常被忽视的一个方面:始终对客户端输入进行转义。你将在第 193 页的“处理器”中学习到如何转义客户端输入。这个测试用例的响应体是字符串Hello, <world>!。响应看起来有些傻,但你的 Web 浏览器会将其渲染为Hello, <world>!。
第三个测试用例 3a 向 HTTP 服务器发送了一个HEAD请求。由handlers.DefaultHandler函数返回的处理器将不会处理HEAD方法。因此,它返回了 405 Method Not Allowed 状态码和一个空的响应体。
列表 9-3 继续了列表 9-2 中的代码,并逐一执行每个测试用例。
`--snip--`
for i, c := range testCases {
r, err := 1http.NewRequest(c.method, path, c.body)
if err != nil {
t.Errorf("%d: %v", i, err)
continue
}
resp, err := 2client.Do(r)
if err != nil {
t.Errorf("%d: %v", i, err)
continue
}
if resp.StatusCode != c.code {
t.Errorf("%d: unexpected status code: %q", i, resp.Status)
}
b, err := 3ioutil.ReadAll(resp.Body)
if err != nil {
t.Errorf("%d: %v", i, err)
continue
}
_ = 4resp.Body.Close()
if c.response != string(b) {
t.Errorf("%d: expected %q; actual %q", i, c.response, b)
}
}
if err := 5srv.Close(); err != nil {
t.Fatal(err)
}
}
列表 9-3:向 HTTP 服务器发送测试请求(server_test.go)
首先,你创建一个新的请求,将测试用例 1 中的参数传递进去。接着,你将请求传递给客户端的Do方法 2,它返回服务器的响应。然后,你检查状态码并读取整个响应体 3。如果客户端没有返回错误 4,你应该养成始终关闭响应体的习惯,即使响应体为空或完全忽略它。不这样做可能会阻止客户端重用底层 TCP 连接。
一旦所有测试完成,你调用服务器的Close方法 5。这会导致其在列表 9-1 中的Serve方法返回,从而停止服务器。Close方法会突然关闭客户端连接。稍后在本章讨论 HTTP/2 推送时,你将看到 HTTP 服务器的优雅关闭支持的示例。
Go 的 HTTP 服务器支持一些其他功能,我们将在接下来的章节中探讨。它可以主动向客户端提供或推送资源。它还提供了优雅关闭的支持。突然关闭你的 Web 服务器可能会使一些客户端处于尴尬状态,因为如果它们在你停止服务器时正在等待响应,它们将永远收不到响应。优雅关闭允许所有待处理的响应在服务器停止前到达每个客户端。
客户端不尊重你的时间
就像我建议设置客户端的超时值一样,我建议你管理各种服务器超时值,原因很简单,否则客户端不会尊重你的服务器时间。客户端可以慢慢地将请求发送到你的服务器。而你的服务器则在等待完整请求的过程中占用了资源。同样,当服务器发送响应时,它也处于客户端的掌控之中,因为它只能以客户端读取数据的速度发送数据(或者只能发送到 TCP 缓冲区可用的空间)。避免让客户端决定请求-响应生命周期的持续时间。
列表 9-1 包含了一个服务器实例,其中指定了两个超时值:客户端在请求之间可以保持空闲的时间以及服务器应等待多长时间来读取请求头:
srv := &http.Server{
Addr: "127.0.0.1:8081",
Handler: mux,
IdleTimeout: 5 * time.Minute,
ReadHeaderTimeout: time.Minute,
}
尽管http.Server上有多个超时字段可供使用,但我建议只设置IdleTimeout和ReadHeaderTimeout字段。IdleTimeout字段决定了服务器在使用保活连接时,等待下一个客户端请求时 TCP 套接字保持打开的时间。ReadHeaderTimeout值决定了服务器在读取完请求头之前会等待多久。请记住,这个时间段与读取请求体所需的时间无关。
如果你想在所有处理程序中强制执行读取传入请求的时间限制,可以通过使用ReadTimeout字段来管理请求的截止时间。如果客户端在ReadTimeout持续时间内未能发送完整的请求(包括头部和正文),服务器将结束 TCP 连接。同样,你也可以通过使用WriteTimeout字段为客户端设置一个有限的时间,在此时间内发送请求并读取响应。ReadTimeout和WriteTimeout值适用于所有请求和响应,因为它们决定了 TCP 套接字的ReadDeadline和WriteDeadline值,正如在第四章中讨论的那样。
这些通用的超时值可能不适用于那些期望客户端在请求体中发送大文件的处理程序,或者那些无限期地将数据流式传输到客户端的处理程序。在这两种情况下,即使一切按预期进行,请求或响应仍然可能突然超时。相反,一个好的做法是依赖ReadHeaderTimeout值。你可以通过中间件或处理程序单独管理读取请求体和发送响应所需的时间。这为你提供了对每个资源的请求和响应持续时间的最大控制。你将在第 202 页的“中间件”一节中学到如何通过中间件管理请求-响应持续时间。
添加 TLS 支持
HTTP 流量默认是明文的,但网页客户端和服务器可以通过加密的 TLS 连接使用 HTTP,这种组合被称为HTTPS。Go 的 HTTP 服务器仅支持通过 TLS 连接使用 HTTP/2,但启用 TLS 是一件简单的事。你只需要修改 Listing 9-1 中服务器实现的两行代码:端口号和 Serve 方法:
srv := &http.Server{
Addr: 1"127.0.0.1:8443",
Handler: mux,
IdleTimeout: 5 * time.Minute,
ReadHeaderTimeout: time.Minute,
}
l, err := net.Listen("tcp", srv.Addr)
if err != nil {
t.Fatal(err)
}
go func() {
2 err := srv.ServeTLS(l, "cert.pem", "key.pem")
if err != http.ErrServerClosed {
t.Error(err)
}
}()
从技术上讲,你不需要更改端口号 1,但惯例是通过端口 443 提供 HTTPS 服务,或者使用端口 443 的扩展端口,如 8443。通过服务器的 ServeTLS 方法,你指示服务器在 HTTP 2 上使用 TLS。ServeTLS 方法需要证书和相应私钥的路径。我建议你查看 mkcert 项目,网址为 github.com/FiloSottile/mkcert/,以获取密钥对。你可以使用 mkcert 仅为开发目的创建本地信任的密钥对。对于生产用途,你应该考虑使用并支持 Let’s Encrypt,网址为 letsencrypt.org/。
处理器
当客户端向 HTTP 服务器发送请求时,服务器需要弄清楚如何处理该请求。根据客户端请求的内容,服务器可能需要检索各种资源或执行某个操作。一个常见的设计模式是指定一些代码来处理这些请求,这些代码称为处理器。你可能有一个处理器知道如何检索图片,另一个处理器知道如何从数据库中检索信息。我们将在第 207 页的“多路复用器”一节中讨论服务器如何确定哪个处理器最适合每个请求。
在 Go 中,处理器是实现了 http.Handler 接口的对象。它们读取客户端请求并写入响应。http.Handler 接口由一个方法组成,用于接收响应和请求:
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
任何实现了 http.Handler 接口的对象都可以处理客户端请求,就 Go HTTP 服务器而言。我们通常将处理器定义为函数,如下所示的常见模式:
handler := http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Hello, world!"))
},
)
在这里,你将一个接受 http.ResponseWriter 和 http.Request 指针的函数包装在 http.HandlerFunc 类型中,该类型实现了 Handler 接口。这会生成一个 http.HandlerFunc 对象,当服务器调用其 ServeHTTP 方法时,会调用包装的 func(w http.ResponseWriter, r *http.Request) 函数。这个处理器会在响应体中向客户端返回字符串 Hello, world!。
请注意,你忽略了写入字节数和任何潜在的写入错误。在实际应用中,写入客户端可能由于各种原因失败。记录这些错误是没有意义的。相反,一种选择是跟踪写入错误的频率,并在错误次数超过适当阈值时让服务器发送警报。你将在第十三章学习如何为代码添加监控功能。
现在你已经熟悉了处理器的结构,让我们来看看handlers.DefaultHandler函数返回的处理器,见清单 9-4。
package handlers
import (
"html/template"
"io"
"io/ioutil"
"net/http"
)
var t = 1template.Must(template.New("hello").Parse("Hello, {{.}}!"))
func DefaultHandler() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
2 defer func(r io.ReadCloser) {
_, _ = io.Copy(ioutil.Discard, r)
_ = r.Close()
}(r.Body)
var b []byte
3 switch r.Method {
case http.MethodGet:
b = []byte("friend")
case http.MethodPost:
var err error
b, err = ioutil.ReadAll(r.Body)
if err != nil {
4 http.Error(w, "Internal server error",
http.StatusInternalServerError)
return
}
default:
// not RFC-compliant due to lack of "Allow" header
5 http.Error(w, "Method not allowed",
http.StatusMethodNotAllowed)
return
}
_ = 6t.Execute(w, string(b))
},
)
}
清单 9-4:默认处理器实现(handlers/default.go)
handlers.DefaultHandler函数返回一个转换为http.HandlerFunc类型的函数。http.HandlerFunc类型实现了http.Handler接口。Go 程序员通常会将签名为func(w http.ResponseWriter, r *http.Request)的函数转换为http.HandlerFunc类型,这样该函数就实现了http.Handler接口。
你看到的第一段代码是一个延迟函数,用于清空并关闭请求体 2。就像客户端需要清空并关闭响应体以重用 TCP 连接一样,服务器也需要对请求体执行相同的操作。但与 Go HTTP 客户端不同,关闭请求体并不会自动清空它。虽然http.Server会为你关闭请求体,但它不会清空它。为了确保可以重用 TCP 连接,我建议至少清空请求体。关闭它是可选的。
该处理器根据请求方法做出不同的响应 3。如果客户端发送的是GET请求,处理器会向响应写入Hello, friend!。如果请求方法是POST,处理器首先读取整个请求体。如果在读取请求体时发生错误,处理器会使用http.Error函数 4 简洁地将Internal server error消息写入响应体,并将响应状态码设置为 500。否则,处理器会返回一个包含请求体内容的问候消息。如果处理器收到任何其他请求方法,它会以 405 Method Not Allowed 状态响应 5。技术上讲,405 响应没有遵循 RFC 规范,因为没有 Allow 头显示处理器接受哪些方法。我们将在《任何类型都可以是处理器》中(第 198 页)补充这一缺陷。最后,处理器写入响应体。
这段代码可能存在安全漏洞,因为响应体的部分内容可能来自请求体。恶意客户端可以发送一个包含 JavaScript 的请求负载,这些脚本可能会在客户端的计算机上运行。这种行为可能导致 XSS 攻击。为了防止这些攻击,你必须在将客户端提供的内容发送到响应中之前,正确地对其进行转义。在这里,你使用 html/template 包创建一个简单的模板 1,该模板读取 Hello, {{.}}!,其中 {{.}} 是响应部分内容的占位符。由 html/template 包衍生的模板会在你填充它们并将结果写入响应写入器时,自动对 HTML 字符进行转义。Listing 9-2 中第二个测试用例解释了 HTML 转义的字符。客户端的浏览器将正确显示这些字符,而不是将它们解释为响应体中的 HTML 和 JavaScript。最重要的是,在将不可信的数据写入响应写入器时,始终使用 html/template 包。
使用 httptest 测试你的处理程序
说“确保你测试你的代码”就像是开发者的等同于我妈妈告诉我要整理我的卧室。这是个好建议,但我更愿意继续编写代码,而不是写测试代码。但我妈妈是对的,现在编写测试代码将对我未来有很大帮助。Go 标准库的架构师们——无疑是受到整洁卧室的启发——确保给了我们 net/http/httptest 包。这个包让单元测试处理程序变得轻松。
net/http/httptest 包导出了一个 NewRequest 函数,接受 HTTP 方法、目标资源和请求体 io.Reader。它返回一个指向 http.Request 的指针,准备在 http.Handler 中使用:
func NewRequest(method, target string, body io.Reader) *http.Request
与其 http.NewRequest 等效方法不同,httptest.NewRequest 会引发 panic,而不是返回错误。这在测试中是可取的,但在生产代码中并不适用。
httptest.NewRecorder 函数返回一个指向 httptest.ResponseRecorder 的指针,该类型实现了 http.ResponseWriter 接口。尽管 httptest.ResponseRecorder 导出了看起来很诱人的字段(我不想诱惑你提到它们),但我建议你改为调用它的 Result 方法。Result 方法返回一个指向 http.Response 对象的指针,就像我们在上一章中使用的那样。正如方法名所示,它会等到处理程序返回后,再获取 httptest.ResponseRecorder 的结果。
如果你有兴趣进行集成测试,net/http/httptest 包包含了一个测试服务器的实现。为了本章的目的,我们将使用 httptest.NewRequest 和 httptest.NewRecorder。
响应的编写方式很重要
这里有一个潜在的陷阱:你向响应体写入内容和设置响应状态码的顺序非常重要。客户端首先接收到响应状态码,然后是来自服务器的响应体。如果你先写入响应体,Go 会推断响应状态码是 200 OK,并在发送响应体之前将其发送给客户端。要查看这一点的实际效果,请查看示例 9-5。
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestHandlerWriteHeader(t *testing.T) {
handler := func(w http.ResponseWriter, r *http.Request) {
_, _ = 1w.Write([]byte("Bad request"))
2w.WriteHeader(http.StatusBadRequest)
}
r := httptest.NewRequest(http.MethodGet, "http://test", nil)
w := httptest.NewRecorder()
handler(w, r)
t.Logf("Response status: %q", 3w.Result().Status)
handler = func(w http.ResponseWriter, r *http.Request) {
4w.WriteHeader(http.StatusB)
_, _ = 5w.Write([]byte("Bad request"))
}
r = httptest.NewRequest(http.MethodGet, "http://test", nil)
w = httptest.NewRecorder()
handler(w, r)
t.Logf("Response status: %q", 6w.Result().Status)
}
示例 9-5:首先写入状态码,然后写入响应体以获得预期结果(handlers/pitfall_test.go)
乍一看,可能会觉得第一个处理程序函数生成了 400 Bad Request 的响应状态码,并且在响应体中返回了字符串Bad request。但实际情况并非如此。调用ResponseWriter的Write方法会使 Go 隐式地调用响应的WriteHeader方法,并为你设置http.StatusOK。一旦通过显式或隐式调用WriteHeader设置了响应的状态码,你就无法更改它。
Go 的开发者做出这个设计选择是因为他们认为你只需要在出现不良情况时调用WriteHeader,在这种情况下,你应该在向响应体写入任何内容之前调用它。记住,服务器在响应体之前发送响应状态码。一旦通过显式或隐式调用WriteHeader设置了响应的状态码,你就无法更改它,因为它很可能已经在发送给客户端的路上。
然而,在这个例子中,你调用了Write方法 1,这隐式地调用了WriteHeader(http.StatusOK)。由于状态码尚未设置,响应码现在是 200 OK。接下来的WriteHeader调用 2 实际上是一个无操作,因为状态码已经设置。响应码 200 OK 会持续存在 3。
现在,如果你交换调用的顺序,将状态码 4 设置在写入响应体 5 之前,响应将具有正确的状态码 6。
让我们看一下测试输出,确认这一点:
=== RUN TestHandlerWriteHeader
TestHandlerWriteHeader: pitfall_test.go:17: Response status: "200 OK"
TestHandlerWriteHeader: pitfall_test.go:26: Response status: "400 Bad Request"
--- PASS: TestHandlerWriteHeader (0.00s)
PASS
正如你从测试输出中看到的,任何在调用WriteHeader方法之前写入响应体的操作都会导致 200 OK 状态码。唯一能决定响应状态码的方法是先调用WriteHeader方法,再进行任何响应体的写入。
你可以通过使用http.Error函数进一步优化这段代码,它简化了写入响应状态码和响应体的过程。例如,你可以用这一行代码替换你的处理程序:
http.Error(w, "Bad request", http.StatusBadRequest)
这个函数将内容类型设置为text/plain,将状态码设置为 400 Bad Request,并将错误信息写入响应体。
任何类型都可以作为处理程序
因为http.Handler是一个接口,你可以利用它编写强大的构造来处理客户端请求。通过在清单 9-4 的基础上定义一个实现http.Handler接口的新类型,我们可以改进默认的处理器,这个新类型会允许你针对特定的 HTTP 方法作出适当的响应,并且会自动为你实现OPTIONS方法。
package handlers
import (
"fmt"
"html"
"io"
"io/ioutil"
"net/http"
"sort"
"strings"
)
1 type Methods map[string]http.Handler
func (h Methods) 2ServeHTTP(w http.ResponseWriter, r *http.Request) {
3 defer func(r io.ReadCloser) {
_, _ = io.Copy(ioutil.Discard, r)
_ = r.Close()
}(r.Body)
if handler, ok := h[r.Method]; ok {
if handler == nil {
4 http.Error(w, "Internal server error",
http.StatusInternalServerError)
} else {
5 handler.ServeHTTP(w, r)
}
return
}
6 w.Header().Add("Allow", h.allowedMethods())
if r.Method != 7http.MethodOptions {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func (h Methods) allowedMethods() string {
a := make([]string, 0, len(h))
for k := range h {
a = append(a, k)
}
sort.Strings(a)
return strings.Join(a, ", ")
}
清单 9-6:动态路由请求到正确处理器的方法映射(handlers/methods.go)
新类型Methods是一个映射 1,其键是 HTTP 方法,值是一个http.Handler。它有一个ServeHTTP方法 2 来实现http.Handler接口,因此你可以将Methods本身作为处理器。ServeHTTP方法首先延迟一个函数来清空并关闭请求体 3,避免映射中的处理器需要执行此操作。
ServeHTTP方法在映射中查找请求方法并检索处理器。为了避免程序崩溃,ServeHTTP方法确保相应的处理器不为nil,如果是nil,则返回 500 内部服务器错误。否则,它会调用相应处理器的ServeHTTP方法。Methods类型是一个多路复用器(路由器),因为它将请求路由到适当的处理器。
如果请求方法不在映射中,ServeHTTP方法会响应 Allow 头 6 和映射中支持的所有方法列表。现在你只需要判断客户端是否明确请求了OPTIONS方法。如果请求了,ServeHTTP方法会返回,向客户端发送 200 OK 响应。如果没有请求,客户端会收到 405 方法不允许的响应。
清单 9-7 使用Methods处理器来实现一个比清单 9-4 中的默认处理器更好的处理器。旧的默认处理器没有自动添加 Allow 头,也没有支持OPTIONS方法。这个新处理器则会支持,因此它能让你的工作变得更加轻松。你只需要确定你的Methods处理器应该支持哪些方法,然后实现它们。
`--snip--`
func DefaultMethodsHandler() http.Handler {
return Methods{
1 http.MethodGet: http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Hello, friend!"))
},
),
2 http.MethodPost: http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
b, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Internal server error",
http.StatusInternalServerError)
return
}
_, _ = fmt.Fprintf(w, "Hello, %s!",
html.EscapeString(string(b)))
},
),
}
}
清单 9-7:Methods处理器的默认实现(methods.go)
现在,handlers.DefaultMethodsHandler函数返回的处理器支持GET、POST和OPTIONS方法。GET方法仅将Hello, friend!消息写入响应体 1。POST方法会以 HTML 转义的请求体内容向客户端问好 2。支持OPTIONS方法并正确设置 Allow 头的剩余功能是Methods类型的ServeHTTP方法自带的。
handlers.DefaultMethodsHandler函数返回的处理器是对handlers.DefaultHandler函数返回的处理器的替代。你可以将清单 9-1 中的以下代码片段替换掉:
Handler: http.TimeoutHandler(handlers.DefaultHandler(), 2*time.Minute, ""),
对于这段代码:
Handler: http.TimeoutHandler(handlers.DefaultMethodsHandler(), 2*time.Minute, ""),
以利用Methods处理器提供的附加功能。
将依赖注入到处理器中
http.Handler 接口让您可以访问请求和响应对象。但是,您可能需要访问额外的功能,比如日志记录器、指标、缓存或数据库来处理请求。例如,您可能希望注入一个日志记录器来记录请求错误,或者注入一个数据库对象来检索用于创建响应的数据。将对象注入到处理程序中的最简单方法是使用闭包。
示例 9-8 演示了如何将 SQL 数据库对象注入到 http.Handler 中。
dbHandler := func(1db *sql.DB) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
err := 2db.Ping()
// do something with the database here…
},
)
}
http.Handle("/three", 3dbHandler(db))
示例 9-8:使用闭包将依赖项注入到处理程序中
您创建一个接受 SQL 数据库对象指针 1 并返回一个处理程序的函数,然后将其分配给一个名为 dbHandler 的变量。由于此函数会捕获返回的处理程序,您可以在处理程序的作用域中访问 db 变量 2。实例化处理程序的方式就是调用 dbHandler 并传入一个 SQL 数据库对象的指针 3。
如果您有多个处理程序需要访问相同的数据库对象,或者您的设计正在发展并且未来可能需要访问额外的对象,这种方法可能会有些繁琐。一种更具扩展性的方法是使用一个结构体,其字段表示您希望在处理程序中访问的对象和数据,并将您的处理程序定义为结构体方法(参见 示例 9-9)。注入依赖项是通过添加结构体字段,而不是修改多个闭包定义来实现的。
type Handlers struct {
db *sql.DB
1log *log.Logger
}
func (h *Handlers) Handler1() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
err := h.db.Ping()
if err != nil {
2h.log.Printf("db ping: %v", err)
}
// do something with the database here
},
)
}
func (h *Handlers) Handler2() http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
// ...
},
)
}
示例 9-9:将依赖项注入到定义为结构体方法的多个处理程序中
您定义了一个结构体,其中包含指向数据库对象和日志记录器的指针 1。您在处理程序上定义的任何方法现在都可以访问这些对象 2。如果您的处理程序需要访问其他资源,您只需向结构体中添加字段。
示例 9-10 演示了如何使用 Handlers 结构体。
h := &Handlers{
db: 1db,
log: log.New(os.Stderr, "handlers: ", log.Lshortfile),
}
http.Handle("/one", h.Handler1())
http.Handle("/two", h.Handler2())
示例 9-10:初始化 Handlers 结构体并使用其处理程序
假设 db1 是指向 sql.DB 对象的指针,您初始化一个 Handlers 对象,并使用 http.Handle 调用其方法,例如。
中间件
中间件由可重用的函数组成,这些函数接受一个 http.Handler 并返回一个 http.Handler:
func(http.Handler) http.Handler
您可以使用中间件检查请求,并根据其内容做出决策,然后将其传递给下一个处理程序。或者,您可能使用请求内容在响应中设置头信息。例如,如果处理程序需要身份验证并且未经过身份验证的客户端发送了请求,则中间件可以向客户端响应一个错误。中间件还可以收集指标、记录请求或控制对资源的访问,这只是其中的一些用法。最棒的是,您可以在多个处理程序中重用它们。如果您发现自己一遍又一遍地编写相同的处理程序代码,请问问自己是否可以将功能放入中间件并在处理程序之间重用它。
列表 9-11 展示了中间件的一些用法,比如强制处理程序允许哪些方法、向响应中添加头部,或执行辅助功能,如日志记录。
func Middleware(next http.Handler) http.Handler {
return 1http.HandlerFunc(
2 func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodTrace {
3http.Error(w, "Method not allowed",
http.StatusMethodNotAllowed)
}
4w.Header().Set("X-Content-Type-Options", "nosniff")
start := time.Now()
5next.ServeHTTP(w, r)
6log.Printf("Next handler duration %v", time.Now().Sub(start))
},
)
}
列表 9-11:示例中间件函数
Middleware 函数使用了一个常见模式,这个模式你在 列表 9-4 中第一次看到:它定义了一个接受 http.ResponseWriter 和 http.Request 指针的函数,并通过 http.HandlerFunc 进行包装。
在大多数情况下,中间件调用给定的处理程序 5。但在某些情况下,这可能不合适,且中间件应该阻止下一个处理程序并自行响应客户端 3。同样,你可能希望使用中间件来收集指标,确保响应中设置了特定的头部 4,或写入日志文件 6。
列表 9-11 是一个人为的示例。我不推荐在单个中间件函数中执行这么多任务。相反,最好遵循 Unix 的哲学,编写简洁的中间件,每个函数专注于做一件事,并做到非常好。理想情况下,你可以将 列表 9-11 中的中间件拆分为三个中间件函数,分别检查请求方法并响应客户端 3,强制响应头部 4,以及收集指标 6。
net/http 包包括用于提供静态文件、重定向请求和管理请求超时的有用中间件。让我们深入研究它们的源代码,看看你如何使用它们。除了这些标准库函数,还可以查看 go.dev/ 上的中间件。
超时慢速客户端
正如我之前提到的,重要的是不要让客户端决定请求响应生命周期的持续时间。恶意客户端可能会利用这一宽松性来达到自己的目的,耗尽服务器资源,从而有效地拒绝服务合法客户端。然而,同时,设置服务器级别的读写超时会使服务器难以流式传输数据或为每个处理程序使用不同的超时时间。
相反,你应该在中间件或单独的处理程序中管理超时。net/http 包包含一个中间件函数,允许你在每个处理程序级别控制请求和响应的持续时间。http.TimeoutHandler 接受一个 http.Handler、一个持续时间和一个字符串,以写入响应体。它设置一个内部计时器,持续时间为给定值。如果 http.Handler 在计时器到期之前没有返回,http.TimeoutHandler 会阻塞 http.Handler,并以 503 服务不可用状态响应客户端。
列表 9-12 使用 http.TimeoutHandler 来包装一个模拟慢速客户端的 http.Handler。
package middleware
import (
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestTimeoutMiddleware(t *testing.T) {
handler := 1http.TimeoutHandler(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
2time.Sleep(time.Minute)
}),
time.Second,
"Timed out while reading response",
)
r := httptest.NewRequest(http.MethodGet, "http://test/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
resp := w.Result()
if resp.StatusCode != 3http.StatusServiceUnavailable {
t.Fatalf("unexpected status code: %q", resp.Status)
}
b, err := 4ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
5 if actual := string(b); actual != "Timed out while reading response" {
t.Logf("unexpected body: %q", actual)
}
}
列表 9-12:为客户端设置有限的读取响应时间(middleware/timeout_test.go)
尽管名字如此,http.TimeoutHandler 是一个中间件,它接受一个 http.Handler 并返回一个 http.Handler1。被包装的 http.Handler 故意休眠一分钟 2,以模拟客户端花时间读取响应,从而阻止 http.Handler 返回。当处理程序在一秒钟内没有返回时,http.TimeoutHandler 会将响应状态码设置为 503 服务不可用 3。测试会读取整个响应体 4,正确地关闭它,并确保响应体中包含中间件写入的字符串 5。
保护敏感文件
中间件还可以防止客户端访问您希望保持私密的信息。例如,http.FileServer 函数简化了为客户端提供静态文件的过程,接受一个 http.FileSystem 接口并返回一个 http.Handler。问题在于,它不会保护防止提供潜在的敏感文件。目标目录中的任何文件都是合法的。按惯例,许多操作系统将配置文件或其他敏感信息存储在以点(.)开头的文件和目录中,并默认隐藏这些以点开头的文件和目录。(在与 Unix 兼容的系统中尤其如此。)但 http.FileServer 会高兴地提供以点开头的文件或遍历以点开头的目录。
net/http 包文档中包含了一个 http.FileSystem 的示例,能够防止 http.FileServer 提供以点开头的文件和目录。清单 9-13 通过使用中间件提供相同的保护方式。
package middleware
import (
"net/http"
"path"
"strings"
)
func RestrictPrefix(prefix string, next http.Handler) http.Handler {
return 1http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
2 for _, p := range strings.Split(path.Clean(r.URL.Path), "/") {
if strings.HasPrefix(p, prefix) {
3 http.Error(w, "Not Found", http.StatusNotFound)
return
}
}
next.ServeHTTP(w, r)
},
)
}
清单 9-13:保护带有指定前缀的任何文件或目录 (middleware/restrict_prefix.go)。
RestrictPrefix 中间件 1 会检查 URL 路径 2,查找任何以给定前缀开头的元素。如果中间件在 URL 路径中找到了以给定前缀开头的元素,它会抢先响应 http.Handler 并返回一个 404 未找到状态 3。
清单 9-14 使用了 RestrictPrefix 中间件,并包含一系列测试用例。
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestRestrictPrefix(t *testing.T) {
handler := 1http.StripPrefix("/static/",
2RestrictPrefix(".", 3http.FileServer(http.Dir("../files/"))),
)
testCases := []struct {
path string
code int
}{
4{"http://test/static/sage.svg", http.StatusOK},
{"http://test/static/.secret", http.StatusNotFound},
{"http://test/static/.dir/secret", http.StatusNotFound},
}
for i, c := range testCases {
r := httptest.NewRequest(http.MethodGet, c.path, nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, r)
actual := w.Result().StatusCode
if c.code != actual {
t.Errorf("%d: expected %d; actual %d", i, c.code, actual)
}
}
}
清单 9-14:使用 RestrictPrefix 中间件 (middleware/restrict_prefix_test.go)
重要的是要意识到,服务器首先将请求传递给 http.StripPrefix 中间件 1,然后是 RestrictPrefix 中间件 2,如果 RestrictPrefix 中间件批准资源路径,则传递给 http.FileServer3。RestrictPrefix 中间件会评估请求的资源路径,以判断客户端是否请求了受限路径,无论该路径是否存在。如果是,RestrictPrefix 中间件会回应客户端一个错误,而不会将请求传递给 http.FileServer。
本测试的http.FileServer提供的静态文件存在于名为files的目录中,该目录位于restrict_prefix_test.go文件的父目录下。../files目录中的文件在传递给http.FileServer的文件系统根目录中。例如,操作系统文件系统中的../files/sage.svg文件,在传递给http.FileServer的http.FileSystem中位于/sage.svg路径。如果客户端希望从http.FileServer获取sage.svg文件,请求路径应为/sage.svg。
但是,我们的每个测试用例 4 的 URL 路径都包括/static/前缀,后跟静态文件名。这意味着测试请求static/sage.svg来自http.FileServer,但该文件并不存在。该测试使用了net/http包中的另一个中间件来解决这个路径不一致问题。http.StripPrefix中间件在将请求传递给http.Handler(本测试中的http.FileServer)之前,先去掉 URL 路径中的指定前缀。
接下来,您通过将http.FileServer包装在RestrictPrefix中间件中来阻止访问敏感文件,以防止该处理程序服务任何以句点为前缀的文件或目录。第一个测试用例结果是 200 OK 状态,因为 URL 路径中没有任何元素以句点为前缀。http.StripPrefix中间件从测试用例的 URL 中移除/static/前缀,将其从/static/sage.svg更改为sage.svg。然后,它将此路径传递给http.FileServer,该服务器在其http.FileSystem中找到对应的文件。http.FileServer将文件内容写入响应体中。
第二个测试用例结果是 404 Not Found 状态,因为.secret文件名的第一个字符是一个句点。第三个测试用例也导致 404 Not Found 状态,因为 URL 路径中的.dir元素,原因在于你的RestrictPrefix中间件会考虑路径中每个段的前缀,而不仅仅是文件本身。
更好的限制资源访问的方法是默认阻止所有资源,并显式允许特定的资源。作为练习,尝试通过创建一个中间件来实现RestrictPrefix中间件的反向功能,该中间件仅允许访问一个允许的资源列表。
多路复用器
一天下午,我走进了美国第四大图书馆——密歇根大学的图书馆。我正在寻找一本翻烂了的库尔特·冯内古特的猫的摇篮,但我不知道从哪里开始寻找。我找到最近的图书管理员,向他寻求帮助寻找这本书。当我们到达正确的位置时,书本却显示 404 Not Found。
一个多路复用器就像友好的图书管理员将我引导到正确的书架,它是一个通用的处理器,将请求路由到特定的处理器。http.ServeMux多路复用器是一个http.Handler,它将传入的请求路由到请求资源的正确处理器。默认情况下,http.ServeMux会对所有传入的请求返回 404 Not Found 状态,但你可以使用它来注册你自己的模式和相应的处理器。然后它将请求的 URL 路径与已注册的模式进行比较,并将请求和响应写入器传递给与最长匹配模式对应的处理器。
示例 9-1 使用多路复用器将所有请求发送到一个单一的端点。示例 9-15 介绍了一个稍微复杂一点的多路复用器,它有三个端点。这个多路复用器会评估请求的资源,并将请求路由到正确的端点。
package main
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
)
1 func drainAndClose(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
2next.ServeHTTP(w, r)
_, _ = io.Copy(ioutil.Discard, r.Body)
_ = r.Body.Close()
},
)
}
func TestSimpleMux(t *testing.T) {
serveMux := http.NewServeMux()
3 serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
serveMux.HandleFunc(4"/hello", func(w http.ResponseWriter,
r *http.Request) {
_, _ = fmt.Fprint(w, "Hello friend.")
})
serveMux.HandleFunc(5"/hello/there/", func(w http.ResponseWriter,
r *http.Request) {
_, _ = fmt.Fprint(w, "Why, hello there.")
})
mux := drainAndClose(serveMux)
示例 9-15:将模式注册到多路复用器,并用中间件包裹整个多路复用器(mux_test.go)。
测试创建了一个新的多路复用器,并使用该多路复用器的HandleFunc方法 3 注册了三条路由。第一条路由仅仅是一个斜杠,表示默认的或空的 URL 路径,并设置响应的 204 No Content 状态。如果没有其他路由匹配,这条路由将匹配所有 URL 路径。第二条是/hello4,它将字符串Hello friend.写入响应。最后的路径是/hello/there/5,它将字符串Why, hello there.写入响应。
请注意,第三条路由以斜杠结尾,使其成为一个子树,而之前的路由 4 没有以斜杠结尾,使其成为绝对路径。这一区别对不习惯的用户来说可能有点混淆。Go 的多路复用器将绝对路径视为精确匹配:要么请求的 URL 路径匹配,要么不匹配。相比之下,它将子树视为前缀匹配。换句话说,多路复用器会寻找最长的已注册模式,该模式出现在请求的 URL 路径的开头。例如,/hello/there/是/hello/there/you的前缀,但不是/hello/you的前缀。
Go 的多路复用器还可以重定向一个不以斜杠结尾的 URL 路径,例如/hello/there。在这种情况下,http.ServeMux首先尝试查找一个匹配的绝对路径。如果失败,多路复用器会在路径末尾添加一个斜杠,例如将路径变为/hello/there/,然后将其响应给客户端。这个新路径成为一个永久重定向。你将在示例 9-16 中看到一个例子。
现在,您已经为多路复用器定义了路由,它已准备好使用。但处理程序存在一个问题:它们没有清空和关闭请求体。在这样的测试中,这不是一个大问题,但您仍然应该遵循最佳实践。如果在实际场景中不这样做,可能会导致额外的开销和潜在的内存泄漏。在这里,您使用中间件 1 来清空和关闭请求体。在 drainAndClose 中间件中,您首先调用 next 处理程序 2,然后清空并关闭请求体。清空和关闭已清空并关闭的请求体是没有害处的。
清单 9-16 测试了一系列请求,针对 清单 9-15 的多路复用器。
`--snip--`
testCases := []struct {
path string
response string
code int
}{
1 {"http://test/", "", http.StatusNoContent},
{"http://test/hello", "Hello friend.", http.StatusOK},
{"http://test/hello/there/", "Why, hello there.", http.StatusOK},
2 {"http://test/hello/there",
"<a href=\"/hello/there/\">Moved Permanently</a>.\n\n",
http.StatusMovedPermanently},
3 {"http://test/hello/there/you", "Why, hello there.", http.StatusOK},
4 {"http://test/hello/and/goodbye", "", http.StatusNoContent},
{"http://test/something/else/entirely", "", http.StatusNoContent},
{"http://test/hello/you", "", http.StatusNoContent},
}
for i, c := range testCases {
r := httptest.NewRequest(http.MethodGet, c.path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, r)
resp := w.Result()
if actual := resp.StatusCode; c.code != actual {
t.Errorf("%d: expected code %d; actual %d", i, c.code, actual)
}
b, err := 5ioutil.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
_ = 6resp.Body.Close()
if actual := string(b); c.response != actual {
t.Errorf("%d: expected response %q; actual %q", i,
c.response, actual)
}
}
}
清单 9-16:执行一系列测试用例并验证响应状态码和响应体(mux_test.go)。
前三个测试用例 1,包括请求 /hello/there/ 路径,匹配多路复用器中注册的精确模式。但第四个测试用例 2 不同,它没有精确匹配。当多路复用器在其后添加一个斜杠时,它发现该路径与已注册的模式完全匹配。因此,多路复用器会响应 301 永久移动状态,并在响应体中提供指向新路径的链接。第五个测试用例 3 匹配 /hello/there/ 子树,并收到 Why, hello there. 的响应。最后三个测试用例 4 匹配默认路径 /,并收到 204 无内容状态。
就像测试依赖中间件来清空和关闭请求体一样,它也会清空 5 并关闭 6 响应体。
HTTP/2 服务器推送
Go HTTP 服务器可以通过 HTTP/2 向客户端推送资源,这是一个有潜力提高效率的特性。例如,客户端可能请求 Web 服务器的主页,但在收到 HTML 响应之前,客户端并不知道它需要相关的样式表和图像来正确渲染主页。HTTP/2 服务器可以主动将样式表和图像与 HTML 一起发送到响应中,从而节省客户端后续请求这些资源的时间。但服务器推送具有滥用的潜力。本节将向您展示如何使用服务器推送,并讨论在何种情况下您应该避免这样做。
向客户端推送资源
让我们通过 HTTP/1.1 获取 清单 9-17 中的 HTML 页面,然后通过 HTTP/2 获取相同的页面并比较其差异。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>H2 Server Push</title>
1 <link href="/static/style.css" rel="stylesheet">
</head>
<body>
2 <img src="/static/hiking.svg" alt="hiking gopher">
</body>
</html>
清单 9-17:简单的索引文件,包含指向两个资源的链接(files/index.html)
这个 HTML 文件需要浏览器获取两个额外的资源,一个样式表 1 和一个 SVG 图像 2,才能正确显示整个页面。图 9-2 显示了当使用 HTTP/1.1 服务时,Google Chrome 对 HTML 请求的统计。

图 9-2:通过 HTTP/1.1 下载的索引页面和相关资源
除了 favicon.ico 文件(Chrome 会自动获取)外,浏览器发出了三次请求以获取所有必需的资源——一次请求 HTML 文件,一次请求样式表,一次请求 SVG 图像。任何请求 index.html 文件的 web 浏览器(在 Figure 9-2 中是 localhost)也会请求 style.css 和 hiking.svg 文件,以正确渲染 index.html 文件。Web 服务器可以提高效率,主动将这两个文件推送到 web 浏览器,因为它知道浏览器必定会请求它们。Web 服务器的这种主动方式可以让浏览器避免再进行两个额外的请求。
Figure 9-3 显示了使用 HTTP/2 的相同检索过程。在这种情况下,服务器推送了 style.css 和 hiking.svg 文件。

Figure 9-3:下载的索引页面与服务器端推送的资源
客户端在向服务器请求 index.html 文件后,会接收到所有三个资源。Figure 9-3 中的 Initiator 列显示 Chrome 从其专用的推送缓存中检索了这些资源。
让我们编写一个命令行可执行文件,可以将资源推送到客户端。Listing 9-18 显示了程序的第一部分。
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"time"
"github.com/awoodbeck/gnp/ch09/handlers"
"github.com/awoodbeck/gnp/ch09/middleware"
)
var (
addr = flag.String("listen", "127.0.0.1:8080", "listen address")
1 cert = flag.String("cert", "", "certificate")
2 pkey = flag.String("key", "", "private key")
files = flag.String("files", "./files", "static file directory")
)
func main() {
flag.Parse()
err := 3run(*addr, *files, *cert, *pkey)
if err != nil {
log.Fatal(err)
}
log.Println("Server gracefully shutdown")
}
Listing 9-18:HTTP/2 服务器的命令行参数(server.go)
服务器需要证书的路径 1 和对应的私钥 2 来启用 TLS 支持,并允许客户端与服务器协商 HTTP/2。如果其中任何一个值为空,服务器将侦听普通的 HTTP 连接。接下来,将命令行标志的值传递给 run 函数 3。
run 函数,定义在 Listing 9-19 中,包含了服务器的大部分逻辑,并最终启动了 web 服务器。将此功能拆分为单独的函数有助于后续的单元测试。
`--snip--`
func run(addr, files, cert, pkey string) error {
mux := http.NewServeMux()
1 mux.Handle("/static/",
http.StripPrefix("/static/",
middleware.RestrictPrefix(
".", http.FileServer(http.Dir(files)),
),
),
)
2 mux.Handle("/",
handlers.Methods{
http.MethodGet: http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
3 if pusher, ok := w.(http.Pusher); ok {
targets := []string{
4"/static/style.css",
"/static/hiking.svg",
}
for _, target := range targets {
if err := 5pusher.Push(target, nil); err != nil {
log.Printf("%s push failed: %v", target, err)
}
}
}
6 http.ServeFile(w, r, filepath.Join(files, "index.html"))
},
),
},
)
7 mux.Handle("/2",
handlers.Methods{
http.MethodGet: http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, filepath.Join(files, "index2.html"))
},
),
},
)
Listing 9-19:HTTP/2 服务器的多路复用器、中间件和处理器(server.go)
服务器的多路复用器有三个路由:一个用于静态文件 1,一个用于默认路由 2,一个用于 /2 绝对路径 7。如果 http.ResponseWriter 是 http.Pusher3,它可以在没有对应请求的情况下将资源推送到客户端 5。你需要从客户端的角度指定资源的路径 4,而不是服务器文件系统上的文件路径,因为服务器将请求视为客户端发起的,以便促进服务器推送。推送资源后,你为处理器提供响应 6。如果你在推送关联资源之前先发送了 index.html 文件,客户端的浏览器可能会在处理推送之前,先发送请求获取关联的资源。
网络浏览器会缓存 HTTP/2 推送的资源,直到连接结束,并且在不同的路由中可用。因此,如果index2.html文件由/2路由 7 提供,并且引用了默认路由推送的相同资源,而客户端首先访问默认路由,则客户端的网页浏览器可能会在渲染/2路由时使用推送的资源。
你还有一项任务要完成:实例化一个 HTTP 服务器来提供你的资源。列表 9-20 通过使用多路复用器来完成此任务。
`--snip--`
srv := &http.Server{
Addr: addr,
Handler: mux,
IdleTimeout: time.Minute,
ReadHeaderTimeout: 30 * time.Second,
}
done := make(chan struct{})
go func() {
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
for {
1 if <-c == os.Interrupt {
2 if err := srv.Shutdown(context.Background()); err != nil {
log.Printf("shutdown: %v", err)
}
close(done)
return
}
}
}()
log.Printf("Serving files in %q over %s\n", files, srv.Addr)
var err error
if cert != "" && pkey != "" {
log.Println("TLS enabled")
3 err = srv.ListenAndServeTLS(cert, pkey)
} else {
4 err = srv.ListenAndServe()
}
if err == http.ErrServerClosed {
err = nil
}
<-done
return err
}
列表 9-20:支持 HTTP/2 的服务器实现(server.go)
当服务器收到os.Interrupt信号 1 时,它会触发对服务器Shutdown方法 2 的调用。与服务器的Close方法不同,Close方法会突然关闭服务器的监听器和所有活动连接,而Shutdown方法会优雅地关闭服务器。它指示服务器停止监听传入连接,并在所有客户端连接结束之前阻塞。这给了服务器在停止之前完成发送响应的机会。
如果服务器接收到证书和相应私钥的路径,它将通过调用ListenAndServeTLS方法 3 来启用 TLS 支持。如果无法找到或解析证书或私钥之一,则此方法会返回错误。如果没有这些路径,服务器将使用ListenAndServe方法 4。
现在可以测试这个服务器了。正如第八章所提到的,Go 并未包括用于通过代码测试服务器推送功能的支持,但你可以通过使用网页浏览器与该程序进行交互。
不要过于强求
尽管 HTTP/2 服务器推送可以提高通信效率,但如果不小心,它可能会适得其反。请记住,网页浏览器会将推送的资源缓存到一个单独的缓存中,并保持连接期间的有效性。如果你提供的资源不经常变化,网页浏览器可能已经将它们存储在常规缓存中,所以你不应该推送它们。一旦它们被缓存,浏览器就可以在多个连接间的未来请求中使用这些资源。例如,像列表 9-19 中的资源就不应该推送,因为它们不太可能经常变化。
我的建议是,在服务器推送方面要保持保守。使用你的处理程序,并依靠指标来判断何时应该推送资源。如果你确实推送资源,应该在写入响应之前进行推送。
你所学到的
Go 的net/http包包括了一个强大的服务器实现。在本章中,你使用了它的处理程序、中间件、多路复用器以及对 HTTP/2 的支持,来智能高效地处理客户端请求。
Go 的http.Handler是一个接口,描述了一个能够接收请求并以状态码和有效载荷响应的对象。一种特殊的处理器,称为多路复用器,可以解析请求并将其传递给最合适的处理器,有效地充当请求路由器。中间件是增强处理器行为或执行辅助任务的代码。它可能会修改请求,向响应添加头部,收集指标,或抢先执行处理器,等等。最后,Go 的服务器支持 TLS 上的 HTTP/2。当使用 HTTP/2 时,服务器可以将资源推送到客户端,从而可能使通信更加高效。
将这些功能结合起来,你可以用出乎意料少的代码构建全面且实用的基于 HTTP 的应用程序。
第十章:Caddy:一个现代化的 Web 服务器

第九章重点介绍了 Go 标准库中可用于构建 Web 服务的基础模块。你学习了如何通过使用处理器、中间件和复用器,以相对较少的代码创建一个简单的 Web 服务器。虽然仅凭这些工具你可以构建一个功能强大的 Web 服务器,但从头开始编写自己的服务器可能并不是最快捷的方法。添加日志记录、指标、认证、访问控制和加密等功能,举几个例子,这些可能会让人感到困难且难以实现正确。相反,你可能会觉得使用现有的、全面的 Web 服务器来托管你的 Web 服务会更方便。
本章将介绍 Caddy Web 服务器,并展示如何将精力集中在编写 Web 服务上,而依赖 Caddy 来托管你的应用程序。你将快速启动 Caddy 并深入了解其实时配置 API。接着,你将学习如何通过使用自定义模块和配置适配器来扩展 Caddy 的功能。然后,你将使用 Caddy 来托管应用程序的静态文件并代理请求到你的 Web 服务。最后,你将了解 Caddy 的自动 TLS 支持,通过使用 Let’s Encrypt 提供的免费证书和自动化密钥管理。
阅读完本章后,你应该能够自如地选择适合你的 Web 应用程序的最佳解决方案:要么是一个基于net/http的简单 Web 服务器,要么是像 Caddy 这样的全面解决方案。
什么是 Caddy?
Caddy是一个现代化的 Web 服务器,专注于安全性、性能和易用性。作为其标志性特性之一,它提供了自动化 TLS 证书管理,使你能够轻松实现 HTTPS。Caddy 还利用 Go 的并发原语来处理大量 Web 流量。它是少数几个提供企业级支持的开源项目之一。
Let’s Encrypt 集成
Let’s Encrypt是一个非营利性的证书颁发机构,免费为公众提供数字证书,方便进行 HTTPS 通信。Let’s Encrypt 的证书在互联网上超过一半的网站上运行,并且被所有流行的 Web 浏览器信任。你可以通过使用 Let’s Encrypt 的自动颁发和续订协议,称为自动证书管理环境(ACME),来为你的网站获取证书。
通常,获取证书需要三个步骤:证书请求、域名验证和证书颁发。首先,你需要向 Let’s Encrypt 请求你域名的证书。然后,Let’s Encrypt 确认你的域名,确保你是该域名的管理员。一旦 Let’s Encrypt 确保你是该域名的合法拥有者,它会为你颁发证书,供你的 Web 服务器用于 HTTPS 支持。每个证书有效期为 90 天,但你应该每 60 天续订一次,以防止服务中断。
Caddy 原生支持 ACME 协议,如果 Caddy 能正确推导出它托管的域名,它将自动请求、验证并安装 Let’s Encrypt 证书。我们将在第 237 页的“添加自动 HTTPS”部分讨论如何最佳地实现这一点。Caddy 还处理自动续期,消除了你跟踪证书过期日期的需要。
Caddy 在这个方程中如何适应?
Caddy 的工作方式与其他流行的 web 服务器,如 NGINX 和 Apache 相似。它最适合部署在网络的边缘,位于 web 客户端和 web 服务之间,如图 10-1 所示。

图 10-1:Caddy 反向代理客户端请求到 web 服务
Caddy 可以提供静态文件服务,并在客户端和后端服务之间转发请求,这个过程称为 反向代理。在这个例子中,你可以看到 Caddy 通过 PHP 的 FastCGI 进程管理器(PHP-FPM)、静态文件和基于 Go 的 web 服务提供 WordPress 博客服务。我们将在本章后面复制一个类似的设置,但不包括 WordPress 博客。
Caddy 通过抽象 web 服务与客户端之间的关系,类似于我们在代码中使用抽象的方式。如果你使用 Caddy 的自动 TLS、静态文件服务器、数据压缩、访问控制和日志记录功能,你就不需要在每个 web 服务中添加这些功能。此外,使用 Caddy 还有一个好处,那就是能够将你的网络拓扑从客户端中抽象出来。随着服务的流行增加,web 服务的容量开始对客户端产生负面影响,你可以将 web 服务添加到 Caddy 中,并指示 Caddy 在它们之间平衡负载,而不会对客户端造成中断。
获取 Caddy
在本章中,我们将使用 Caddy 的版本 2。你有几种安装选项,本节将进行说明。
下载 Caddy
你可以通过使用 Caddy 团队构建的静态二进制文件来安装 Caddy。这个二进制文件可以通过caddyserver.com/上的下载链接获得。*。
Caddy 还可以作为 Docker 镜像、DigitalOcean 虚拟机、Debian 衍生版的高级包管理工具(APT)源以及 Fedora 的 Copr 构建系统中的工具来使用,适用于 Fedora、CentOS 或 Red Hat Enterprise Linux。你可以在caddyserver.com/docs/download的安装文档中找到详细信息。
从源代码构建 Caddy
如果你找不到适合你操作系统和架构的静态二进制文件,或者你希望定制 Caddy,你也可以从源代码编译 Caddy。
Caddy 强烈依赖 Go 对模块的支持。因此,在运行以下命令之前,你需要至少使用 Go 1.14:
$ **git clone "https://github.com/caddyserver/caddy.git"**
Cloning into 'caddy'...
$ **cd caddy/cmd/caddy**
$ **go build**
克隆 Caddy Git 仓库并切换到 caddy/cmd/caddy 子目录,在这里你会找到 main 包。运行 go build 在当前目录为你的操作系统和架构创建一个名为 caddy 的二进制文件。为了简化命令,本章其余部分假设 caddy 二进制文件已添加到你的 PATH 中。
在这个子目录中,注意 main.go 文件。你将在本章稍后学习如何通过添加模块来自定义 Caddy 时再次查看它。
运行和配置 Caddy
为了配置,Caddy 在 TCP 2019 端口上暴露了一个管理端点,通过这个端点,你可以实时与 Caddy 的配置进行交互。你可以通过向该端点发布 JSON 来配置 Caddy,也可以通过 GET 请求读取配置。Caddy 的完整 JSON API 文档可以在 caddyserver.com/docs/json/ 找到。
在你配置 Caddy 之前,你需要先启动它。运行此命令将 Caddy 作为后台进程启动:
$ **caddy start**
2006/01/02 15:04:05.000 INFO admin endpoint started
{"address": "tcp/localhost:2019", "enforce_origin": false,
"origins": ["localhost:2019", "[::1]:2019", "127.0.0.1:2019"]}
2006/01/02 15:04:05.000 INFO serving initial configuration
Successfully started Caddy (pid=24587) - Caddy is running in the background
你会看到日志条目,显示管理员端点已启动,Caddy 正在使用初始配置。你还会看到日志条目在与管理员端点交互时打印到标准输出。
Caddy 的配置默认是空的。让我们发送有意义的配置数据到 Caddy。列表 10-1 使用 curl 命令将 JSON 发布到 Caddy 管理员端点上的 load 资源。
$ **curl localhost:2019/load \**
1 **-X POST -H "Content-Type: application/json" \**
**-d '**
**{**
**"apps": {**
**"http": {**
**"servers": {**
**"hello": {**
**"listen": ["localhost:2020"],**
2 **"routes": [{**
**"handle": [{**
3 **"handler": "static_response",**
**"body": "Hello, world!"**
**}]**
**}]**
**}**
**}**
**}**
**}**
**}'**
列表 10-1:将配置发布到 Caddy 的管理员端点
你向 Caddy 实例监听的 2019 端口的 load 资源发送一个包含 JSON 请求体的 POST 请求 1。顶级的 apps 命名空间列出了 Caddy 在运行时将加载的应用程序。在本例中,你告诉 Caddy 加载 http 应用程序。http 应用程序的配置由一个或多个服务器组成。此示例设置了一个名为 hello 的服务器,监听 localhost 的 2020 端口。你可以自由地为你的服务器命名。
由于 listen 值是一个地址数组,你可以配置该服务器监听多个套接字地址。Caddy 会将这些地址值传递给 net.Listen,就像你在第三章中所做的那样。你还可以选择指定一个端口范围,例如 localhost:2020-2025。Caddy 会识别你使用了一个范围,并正确地将该范围扩展为单独的套接字地址。Caddy 允许你通过在套接字地址前缀添加特定的网络类型来限制监听器。例如,udp/localhost:2020 告诉服务器绑定到 localhost 上的 UDP 2020 端口。斜杠不是地址的一部分,而是一个分隔符。如果你希望服务器绑定到 Unix 套接字 /tmp/caddy.sock,可以指定地址 unix//tmp/caddy.sock。
hello服务器的routes值是一个路由数组,类似于前一章中的多路复用器,它规定了服务器如何处理传入的请求。如果某个路由匹配请求,Caddy 会将请求传递给handle数组中的每个处理器。由于handle是一个数组,你可以为每个路由指定多个处理器。Caddy 会将请求传递给每个后续处理器,方式就像你在前一章中将中间件连接起来一样。在这个例子中,你为所有请求指定了一个路由,并为这个路由添加了一个处理器。你使用的是内置的static_response处理器,它将在响应体中写入body的值(在这个例子中是Hello, world!)。
只要配置没有错误,Caddy 会立即开始使用新配置。让我们确认 Caddy 现在同时在管理端口 2019 和你的hello服务器端口 2020 上监听:
$ **lsof -Pi :2019-2025**
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
caddy 24587 user 3u IPv4 811511 0t0 TCP localhost:2019 (LISTEN)
caddy 24587 user 9u IPv4 915742 0t0 TCP localhost:2020 (LISTEN)
看起来不错。这个命令在 Windows 上无法使用。相反,你可以通过在管理员命令提示符下运行netstat -b命令,查看类似的输出。现在,你可以通过发送GET请求来请求 Caddy 的配置:
$ **curl localhost:2019/config/**
{"apps":{"http":{"servers":{"hello":{"listen":["localhost:2020"],
"routes":[{"handle":[{"body":"Hello, world!","handler":"static_response"}]}]}}}}}
Caddy 在响应体中返回其 JSON 格式的配置。请注意,你需要在/config/资源后写上斜杠,因为/config/是 Caddy 暴露其配置的资源前缀。你是在请求所有位于/config/前缀下的资源。如果你不小心省略了尾部斜杠,Caddy 会认为你在请求名为/config的绝对资源,但该资源在 Caddy 的管理 API 上并不存在(端口 2019)。
Caddy 支持配置遍历。配置遍历允许你通过将配置数据中的每个 JSON 键视为资源地址,来请求配置的一个子集。例如,你可以通过发送一个GET请求来请求我们示例配置中hello服务器的listen值,方式如下:
$ **curl localhost:2019/config/apps/http/servers/hello/listen**
["localhost:2020"]
Caddy 返回一个包含localhost:2020的 JSON 数组,正如你所期望的那样。让我们向这个套接字地址发送一个GET请求:
$ **curl localhost:2020**
Hello, world!
你看到从static_response处理器返回的Hello, world!字符串。
实时修改 Caddy 的配置
你可以使用第八章中学到的其他 HTTP 方法来修改服务器的配置。只要 Caddy 能解析你发送的 JSON,任何修改都会立即生效。如果 Caddy 无法解析 JSON,或者新配置中存在基础错误,Caddy 会记录错误并解释出错的原因,继续使用现有配置。
假设你想让你的hello服务器也监听 2021 端口。你可以通过发送一个POST请求来附加另一个listen值,并立即检查修改是否生效:
$ **curl localhost:2019/config/apps/http/servers/hello/listen \**
**-X POST -H "Content-Type: application/json" -d '"localhost:2021"'**
$ **lsof -Pi :2019-2025**
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
caddy 24587 user 3u IPv4 811511 0t0 TCP localhost:2019 (LISTEN)
caddy 24587 user 9u IPv4 915742 0t0 TCP localhost:2020 (LISTEN)
1 caddy 24587 user 11u IPv4 1148212 0t0 TCP localhost:2021 (LISTEN)
你可以看到,除了 2019 和 2020 端口,Caddy 现在还在监听 2021 端口。
假设你想替换监听地址并改用范围。在这种情况下,你可以发送一个PATCH请求,包含你希望 Caddy 使用的新listen数组值:
$ **curl localhost:2019/config/apps/http/servers/hello/listen \**
**-X PATCH -H "Content-Type: application/json" -d '["localhost:2020-2025"]'**
$ **lsof -Pi :2019-2025**
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
caddy 24587 user 3u IPv4 811511 0t0 TCP localhost:2019 (LISTEN)
1 caddy 24587 user 9u IPv4 915742 0t0 TCP localhost:2020 (LISTEN)
caddy 24587 user 10u IPv4 1149557 0t0 TCP localhost:2021 (LISTEN)
caddy 24587 user 11u IPv4 1166333 0t0 TCP localhost:2022 (LISTEN)
caddy 24587 user 12u IPv4 1169409 0t0 TCP localhost:2023 (LISTEN)
caddy 24587 user 13u IPv4 1169413 0t0 TCP localhost:2024 (LISTEN)
2 caddy 24587 user 14u IPv4 1169417 0t0 TCP localhost:2025 (LISTEN)
除了管理员端口 2019,Caddy 现在还在端口 2020 至 2025 之间监听。
尽管你可能不会经常需要在运行时更改 Caddy 的配置,但它是开发中一个非常有用的功能,因为它允许你快速启动一个新服务器来添加功能。让我们在 Caddy 运行时添加一个新服务器。你将为这个新服务器命名为test,并将其配置为监听 2030 端口。列表 10-2 在实时中将新的test服务器添加到 Caddy。
$ **curl localhost:2019/config/apps/http/servers/test \**
**-X POST -H "Content-Type: application/json" \**
**-d '{**
**"listen": ["localhost:2030"],**
**"routes": [{**
**"handle": [{**
**"handler": "static_response",**
**"body": "Welcome to my temporary test server."**
**}]**
**}]**
**}'**
列表 10-2:实时向 Caddy 添加新服务器
新服务器的名称test是你通过POST请求的资源的一部分。如果你在列表 10-1 的原始配置中定义了这个服务器,你可以把test看作是键,请求正文中的 JSON 看作是值。此时,Caddy 有两个服务器:hello监听 2020 至 2025 端口,test监听 2030 端口。要确认 Caddy 正在服务test,你可以检查 2030 端口上的新端点:
$ **curl localhost:2030**
Welcome to my temporary test server.
static_response处理器正确地响应了预期的消息。如果你想移除test服务器,只需发出一个DELETE请求:
$ **curl localhost:2019/config/apps/http/servers/test -X DELETE**
在这里,你再次在资源中指定了test服务器。Caddy 不再在 localhost 的 2030 端口上监听,且test服务器不再存在。你能够启动一个新的服务器来处理完全不同的请求,而不会中断hello服务器的功能。实时更改配置为你打开了更多的可能性。你是否希望服务器或路由仅在某些时间段内可访问?没问题。你是否希望临时重定向流量,而无需重新启动整个 Web 服务器,避免中断现有 Web 流量?当然,去做吧。
将配置存储在文件中
我们通常会在启动过程中向 Caddy 提供配置。将列表 10-1 中的 JSON 配置写入名为caddy.json的文件。然后使用以下命令启动 Caddy:
$ **caddy start --config caddy.json**
Successfully started Caddy (pid=46112) - Caddy is running in the background
$ **curl localhost:2019/config/**
{"apps":{"http":{"servers":{"hello":{"listen":["localhost:2020"],
"routes":[{"handle":[{"body":"Hello, world!","handler":"static_response"}]}]}}}}}
Caddy 像列表 10-1 中那样在后台启动——但这次,它在初始化期间从caddy.json文件中加载配置。
通过模块和适配器扩展 Caddy
Caddy 采用模块化架构来组织其功能。这种模块化方法允许你通过编写自己的模块和配置适配器来扩展 Caddy 的功能。在本节中,我们将介绍编写一个配置适配器的过程,该适配器将允许你将 Caddy 的配置存储在 Tom's Obvious Minimal Language(TOML)文件中。我们还将在适当的 Caddy 模块中复制前一章的restrict_prefix中间件。
编写配置适配器
虽然 JSON 是一种非常适合配置文件的格式,但它不像其他格式那样适合人类阅读。JSON 不支持注释和多行字符串,而这两者恰恰是让配置文件更易于人类阅读的特性。Caddy 支持使用 配置适配器,将一种格式(如 TOML)适配为 Caddy 的本地 JSON 格式。TOML 是一种易于人类阅读的配置文件格式,支持注释和多行字符串。你可以在github.com/toml-lang/toml/tree/v0.5.0/找到更多细节。
Caddy 版本 1 支持一种名为 Caddyfile 的自定义配置文件格式,按照约定,配置文件也通常使用这个名字。如果你希望在 Caddy v2 中使用 Caddyfile,必须依赖配置适配器,以便 Caddy 可以读取它。当你指定一个以 Caddyfile 开头的文件名时,Caddy 足够聪明,知道需要使用 caddyfile 适配器。但如果你想从命令行指定适配器,你需要明确告诉 Caddy 使用哪个适配器:
$ **caddy start --config Caddyfile --adapter caddyfile**
adapter 标志告诉 Caddy 应该使用哪个适配器。Caddy 会调用适配器将配置文件适配成 JSON 格式,然后像处理原本以 JSON 格式提供的配置一样,解析适配器返回的 JSON。
但 Caddy 并没有附带一个官方的 TOML 配置适配器,所以让我们尝试编写一个。你需要先为 TOML 配置适配器创建一个 Go 模块:
$ **mkdir caddy-toml-adapter**
$ **cd caddy-toml-adapter**
1 $ **go mod init github.com/awoodbeck/caddy-toml-adapter**
go: creating new go.mod: module github.com/awoodbeck/caddy-toml-adapter
你应该使用一个完全限定的模块名 1,与此处使用的不同。我是在 GitHub 上通过我的 awoodbeck 账户创建了这个模块。你的模块的完全限定名称将根据其托管的位置和账户不同而有所不同。
现在你已经创建了一个模块,可以开始编写代码了。在当前目录下创建一个名为 toml.go 的文件,并将代码添加到清单 10-3 中。
package tomladapter
import (
"encoding/json"
"github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/pelletier/go-toml"
)
func init() {
caddyconfig.RegisterAdapter(1"toml", 2Adapter{})
}
// Adapter converts a TOML Caddy configuration to JSON.
type Adapter struct{}
// Adapt the TOML body to JSON.
func (a Adapter) Adapt(body []byte, _ map[string]interface{}) (
[]byte, []caddyconfig.Warning, error) {
tree, err := 3toml.LoadBytes(body)
if err != nil {
return nil, nil, err
}
b, err := json.Marshal(4tree.ToMap())
return b, nil, err
}
清单 10-3:创建一个 TOML 配置适配器并将其注册到 Caddy
你使用 Thomas Pelletier 的 go-toml 库来解析配置文件内容 3。这可以节省大量的代码。然后你将解析后的 TOML 转换成一个映射 4,并将这个映射序列化为 JSON。
最后一步是将你的配置适配器注册到 Caddy。为此,你需要在 init 函数中调用 caddyconfig.RegisterAdapter 并传入适配器的类型 1 和一个实现 caddyconfig.Adapter 接口的 Adapter 对象 2。当你从 Caddy 的 main.go 文件中导入这个模块时,配置适配器会自动注册到 Caddy 中,为解析 TOML 配置文件提供支持。在第 231 页的“将模块注入 Caddy”部分,你将看到如何从 Caddy 中导入这个模块的具体示例。
现在你已经创建了 toml.go 文件,可以整理一下模块:
$ **go mod tidy**
go: finding module for package github.com/caddyserver/caddy/v2/caddyconfig
go: found github.com/caddyserver/caddy/v2/caddyconfig in
github.com/caddyserver/caddy/v2 v2.0.0
该命令将 Caddy 依赖项添加到go.mod文件中。剩下的工作就是像这个示例一样将你的模块发布到 GitHub,或者其他支持go get的合适版本控制系统。
编写 Restrict Prefix 中间件模块
第九章介绍了中间件的概念,这是一种设计模式,它允许你的代码在服务器接收请求时操作请求和响应,并执行辅助任务,例如记录请求详情。让我们探讨如何在 Caddy 中使用中间件。
在 Go 中,中间件是一个接受http.Handler并返回http.Handler的函数:
func(http.Handler) http.Handler
http.Handler描述了一个具有ServeHTTP方法的对象,该方法接受http.RequestWriter和http.Request:
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request)
}
处理器从请求中读取并写入响应。假设myHandler是一个实现了http.Handler接口的对象,并且middleware1、middleware2和middleware3都接受一个http.Handler并返回一个http.Handler,你可以在示例 10-4 中将中间件函数应用到myHandler上。
h := middleware1(middleware2(middleware3(myHandler)))
示例 10-4:多个中间件函数包装一个处理器
你可以用你在前一章中编写的RestrictPrefix中间件替换任何一个中间件函数,因为它是一个接受http.Handler并返回http.Handler的函数。
不幸的是,Caddy 的中间件并未使用这种设计模式,因此不能使用RestrictPrefix。Caddy 包含了处理器和中间件的接口,这与net/http不同,后者仅描述处理器。Caddy 中与http.Handler接口等效的是caddyhttp.Handler:
type Handler interface {
ServeHTTP(http.ResponseWriter, *http.Request) error
}
caddyhttp.Handler和http.Handler之间唯一的区别是前者的ServeHTTP方法返回一个error接口。
Caddy 中间件是一种特殊类型的处理器,它实现了caddyhttp.MiddlewareHandler接口:
type MiddlewareHandler interface {
ServeHTTP(http.ResponseWriter, *http.Request, Handler) error
}
与caddyhttp.Handler类似,Caddy 的中间件同时接受http.ResponseWriter和http.Request,并返回一个error接口。但它接受一个额外的参数:caddyhttp.Handler,这个处理器位于中间件之后,类似于myHandler位于middleware3之后的方式,在示例 10-4 中。Caddy 中间件并不接受一个http.Handler并返回一个http.Handler,而是期望它充当处理器,并且在中间件处理完请求和响应之后,可以访问caddyhttp.Handler。
让我们创建一个新的 Caddy 模块,复制你的RestrictPrefix中间件的功能:
$ **mkdir caddy-restrict-prefix**
$ **cd caddy-restrict-prefix**
$ **go mod init github.com/awoodbeck/caddy-restrict-prefix**
go: creating new go.mod: module github.com/awoodbeck/caddy-restrict-prefix
如前所述,你的完全限定模块名会与你的不同。创建一个名为restrict_prefix.go的新文件,并将示例 10-5 中的代码添加到该文件中。
package restrictprefix
import (
"fmt"
"net/http"
"strings"
"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"go.uber.org/zap"
)
func init() {
1caddy.RegisterModule(RestrictPrefix{})
}
// RestrictPrefix is middleware that restricts requests where any portion
// of the URI matches a given prefix.
type RestrictPrefix struct {
2Prefix string `json:"prefix,omitempty"`
3logger *zap.Logger
}
// CaddyModule returns the Caddy module information.
func (RestrictPrefix) 4CaddyModule() caddy.ModuleInfo {
return caddy.ModuleInfo{
5ID: "http.handlers.restrict_prefix",
6New: func() caddy.Module { return new(RestrictPrefix) },
}
}
示例 10-5:定义并注册一个新的 Caddy 模块
来自上一章的RestrictPrefix中间件实现预期 URL 路径的前缀是一个字符串。在这里,你将前缀存储在RestrictPrefix结构体中,并为其分配一个结构体标签,以使用json.Unmarshal行为,将传入的键与结构体标签进行匹配。结构体标签告诉json.Unmarshal哪个 JSON 键对应此字段。在这个示例中,你告诉json.Unmarshal应该取 JSON 配置中与prefix键相关联的值,并将其赋给结构体的Prefix字段。RestrictPrefix结构体还有一个logger字段,3 这样你就可以根据需要记录事件。
你的模块需要在初始化时向 Caddy 注册 1。caddy.RegisterModule函数接受任何实现了caddy.Module接口的对象。为此,你添加了CaddyModule方法 4 来返回有关你的模块的信息给 Caddy。Caddy 要求每个模块都有一个 ID5。由于你正在创建一个 HTTP 中间件处理程序,你将使用 IDhttp.handler.restrict_prefix,其中restrict_prefix是你模块的唯一名称。Caddy 还期望一个函数 6,用来创建你模块的新实例。
现在你可以将模块注册到 Caddy,让我们添加更多功能,以便你可以从 Caddy 检索日志记录器并验证模块的设置。列表 10-6 接着我们之前的内容。
`--snip--`
// Provision a Zap logger to RestrictPrefix.
func (p *RestrictPrefix) 1Provision(ctx caddy.Context) error {
p.logger = 2ctx.Logger(p)
return nil
}
// Validate the prefix from the module's configuration, setting the
// default prefix "." if necessary.
func (p *RestrictPrefix) 3Validate() error {
if p.Prefix == "" {
p.Prefix = "."
}
return nil
}
列表 10-6:实现各种 Caddy 接口
你向结构体中添加了Provision方法 1。Caddy 会识别到你的模块实现了caddy.Provisioner接口,并调用该方法。然后,你可以从给定的caddy.Context2 中获取日志记录器。同样,Caddy 会调用你模块的Validate方法 3,因为它实现了caddy.Validator接口。你可以使用此方法确保所有必需的设置都已从配置中反序列化到模块中。如果出现问题,你可以返回错误,Caddy 会代表你发出警告。在这个示例中,你使用此方法来设置默认的前缀,如果配置中没有提供前缀的话。
你快完成了。最后一步是实现中间件本身。列表 10-7 通过添加对caddyhttp.MiddlewareHandler接口的支持,完成了你的模块实现。
`--snip--`
// ServeHTTP implements the caddyhttp.MiddlewareHandler interface.
func (p RestrictPrefix) ServeHTTP(w http.ResponseWriter, r *http.Request,
next caddyhttp.Handler) error {
1 for _, part := range strings.Split(r.URL.Path, "/") {
if strings.HasPrefix(part, p.Prefix) {
2http.Error(w, "Not Found", http.StatusNotFound)
if p.logger != nil {
3p.logger.Debug(fmt.Sprintf(
"restricted prefix: %q in %s", part, r.URL.Path))
}
return nil
}
}
return 4next.ServeHTTP(w, r)
}
var (
5 _ caddy.Provisioner = (*RestrictPrefix)(nil)
_ caddy.Validator = (*RestrictPrefix)(nil)
_ caddyhttp.MiddlewareHandler = (*RestrictPrefix)(nil)
)
列表 10-7:实现MiddlewareHandler接口
逻辑几乎与上一章的中间件完全相同。你遍历 URL 路径组件,检查每个路径是否有前缀 1。如果找到匹配项,你会返回 404 Not Found 状态 2,并记录该事件以便调试 3。如果一切检查通过,你将控制权传递给链中的下一个处理程序 4。
做好防范接口变更的措施是一种好习惯,明确确保你的模块实现了预期的接口 5。如果将来其中一个接口发生变化(例如,添加了新方法),这些接口保护将导致编译失败,提前警告你需要调整代码。
最后的步骤是整理你的模块依赖并发布它:
$ **go mod tidy**
go: finding module for package github.com/caddyserver/caddy/v2
go: finding module for package github.com/caddyserver/caddy/v2/modules/caddyhttp
go: finding module for package go.uber.org/zap
go: found github.com/caddyserver/caddy/v2 in github.com/caddyserver/caddy/v2 v2.0.0
go: found go.uber.org/zap in go.uber.org/zap v1.15.0
go: downloading github.com/golang/mock v1.4.1
go: downloading github.com/onsi/gomega v1.8.1
go: downloading github.com/smallstep/assert v0.0.0-20200103212524-b99dc1097b15
go: downloading github.com/onsi/ginkgo v1.11.0
go: downloading github.com/imdario/mergo v0.3.7
go: downloading github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1
go: downloading github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b
go: downloading github.com/alangpierce/go-forceexport v0.0.0-20160317203124-
8f1d6941cd75
go: downloading github.com/chzyer/logex v1.1.10
go: downloading github.com/hpcloud/tail v1.0.0
go: downloading gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7
go: downloading gopkg.in/fsnotify.v1 v1.4.7
将你的模块发布到 GitHub 或类似的版本控制系统,以便通过 go get 使用。
将你的模块注入 Caddy
你编写的模块和适配器都是自注册的。要在 Caddy 中包含它们的功能,你需要在构建时导入它们。为此,你需要从源代码编译 Caddy。首先为你的构建创建一个目录:
$ **mkdir caddy**
$ **cd caddy**
从源代码构建 Caddy 需要一些基础代码,你将把你的模块包含在其中。你的模块会在导入时自动注册到 Caddy 中。创建一个新的文件,命名为 main.go,并将 清单 10-8 中的代码添加到其中。
package main
import (
1 cmd "github.com/caddyserver/caddy/v2/cmd"
2 _ "github.com/caddyserver/caddy/v2/modules/standard"
// Injecting custom modules into Caddy
3 _ "github.com/awoodbeck/caddy-restrict-prefix"
4 _ "github.com/awoodbeck/caddy-toml-adapter"
)
func main() {
cmd.Main()
}
清单 10-8:将自定义模块注入 Caddy
首先,你将 Caddy 命令模块 1 导入到构建中。这个模块包含启动 Caddy 服务器的 Main 函数。然后,你导入 Caddy 二进制分发包中找到的标准模块 2。最后,你包含了你的限制前缀模块 3 和 TOML 配置适配器 4。
现在剩下的工作就是初始化 caddy 模块并进行构建:
$ **go mod init caddy**
$ **go build**
此时,你应该在当前目录中有一个名为 caddy 的二进制文件。你可以通过查看 caddy 二进制文件中的模块列表来验证它是否包含你的自定义导入。以下命令适用于 Linux 和 macOS:
$ **./caddy list-modules | grep "toml\|restrict_prefix"**
caddy.adapters.toml
http.handlers.restrict_prefix
对于我的 Windows 用户,改为运行以下命令:
> **caddy list-modules | findstr "toml restrict_prefix"**
caddy.adapters.toml
http.handlers.restrict_prefix
你构建的 caddy 二进制文件可以从 TOML 文件中读取配置,并拒绝客户端访问包含给定前缀的路径中的资源。
反向代理请求到后台 Web 服务
现在,你已经具备了在 Caddy 中创建有意义功能所需的所有构建块。让我们通过配置 Caddy,将其设置为反向代理请求到后台 Web 服务,并代表后台 Web 服务提供静态文件,从而将你学到的所有知识结合起来。你将在 Caddy 中创建两个端点。第一个端点只会从 Caddy 的文件服务器提供静态内容,展示 Caddy 的静态文件服务功能。第二个端点会反向代理请求到后台 Web 服务。这个后台服务将向客户端发送 HTML,并提示客户端从 Caddy 获取静态文件,这将展示你的 Web 服务如何依赖 Caddy 来代表它们提供静态内容。
在开始构建之前,你需要设置正确的目录结构。如果你在跟随教程,你现在应该在 caddy 目录下,该目录中包含从 清单 10-8 中的代码构建的 caddy 二进制文件。创建两个子目录,分别为 files 和 backend:
$ **mkdir files backend**
你可以从 github.com/awoodbeck/gnp/tree/master/ch10/files/ 获取 files 子目录的内容。backend 子目录将存储在下一部分创建的简单后端服务。
创建一个简单的后端 Web 服务
你需要一个后端 Web 服务,以便 Caddy 将请求反向代理到它,如 图 10-1 所示。该服务将以 HTML 文档响应所有请求,并包含 Caddy 代表该服务提供的静态文件。
清单 10-9 是后端 Web 服务的初始代码。
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"time"
)
var addr = flag.String("listen", 1"localhost:8080", "listen address")
func main() {
flag.Parse()
c := make(chan os.Signal, 1)
signal.Notify(c, os.Interrupt)
err := 2run(*addr, c)
if err != nil {
log.Fatal(err)
}
log.Println("Server stopped")
}
清单 10-9:创建后端服务 (backend/main.go)
这段代码应该很熟悉,因为它是你在上一章中写的代码的简化版。你正在设置一个监听本地主机 8080 端口的 Web 服务 1。Caddy 将把请求导向这个套接字地址。清单 10-10 实现了 run 函数 2。
`--snip--`
func run(addr string, c chan os.Signal) error {
mux := http.NewServeMux()
mux.Handle("/",
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientAddr := r.Header.Get(1"X-Forwarded-For")
log.Printf("%s -> %s -> %s", clientAddr, r.RemoteAddr, r.URL)
_, _ = w.Write(2index)
}),
)
srv := &http.Server{
Addr: addr,
Handler: mux,
IdleTimeout: time.Minute,
ReadHeaderTimeout: 30 * time.Second,
}
go func() {
for {
if <-c == os.Interrupt {
_ = srv.Close()
return
}
}
}()
fmt.Printf("Listening on %s ...\n", srv.Addr)
err := srv.ListenAndServe()
if err == http.ErrServerClosed {
err = nil
}
return err
}
清单 10-10:后端服务的主要逻辑 (backend/main.go)
Web 服务接收来自 Caddy 的所有请求,无论哪个客户端发起了请求。同样,它将所有响应发送回 Caddy,后者再将响应路由到正确的客户端。方便的是,Caddy 会在每个请求中添加一个 X-Forwarded-For 头 1,包含发起客户端的 IP 地址。虽然你不会做任何处理,仅仅记录此信息,但你的后端服务可以利用这个 IP 地址来区分客户端请求。例如,服务可以基于客户端 IP 地址拒绝请求。
处理程序将一个字节切片 2 写入响应,这个字节切片包含在 清单 10-11 中定义的 HTML。
`--snip--`
var index = []byte(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Caddy Backend Test</title>
<link href=1"/style.css" rel="stylesheet">
</head>
<body>
<p><img src=2"/hiking.svg" alt="hiking gopher"></p>
</body>
</html>`)
清单 10-11:由后端服务提供的索引 HTML (backend/main.go)
/style.css1 和 /hiking.svg2 资源没有包含完整的 URL(例如 localhost:2020/style.css),因为后端 Web 服务不了解 Caddy,也不知道客户端如何访问 Caddy。当你在资源地址中省略方案、主机名和端口号时,客户端的 Web 浏览器应该在 HTML 中遇到 /style.css 并将其所使用的方案、主机名和端口号附加到请求中,然后将请求发送给 Caddy。为了使这一切正常工作,你需要在下一部分配置 Caddy,将某些请求转发到后端 Web 服务,其余的请求则由 Caddy 提供静态文件。
配置 Caddy
正如本章前面提到的,Caddy 使用 JSON 作为其原生配置格式。你当然可以使用 JSON 来编写配置,但你已经编写了一个完美的配置适配器,允许你使用 TOML,因此你将实现 TOML 配置。
您希望配置 Caddy 将请求反向代理到您的后端 Web 服务,并从 files 子目录提供静态文件服务。您需要两个路由:一个指向后端 Web 服务,另一个用于静态文件。让我们从在名为 caddy.toml 的文件中定义服务器配置开始(列表 10-12)。
1 [apps.http.servers.test_server]
listen = [
'localhost:2020',
]
列表 10-12: Caddy 测试服务器配置(caddy.toml)
您的 TOML 适配器将 TOML 直接转换为 JSON。因此,您需要确保使用的是 Caddy 所期望的相同命名空间。您的服务器的命名空间是 apps.http.servers.test_server1。(为了简便,接下来的讨论中,您可以简称此命名空间为 test_server。)它在本地主机的 2020 端口监听传入的连接。
将反向代理添加到您的服务
Caddy 包含一个强大的反向代理处理器,可以快速将传入的请求转发到您的后端 Web 服务。就像在前一章中的服务器实现一样,Caddy 会将传入请求与路由匹配,然后将请求传递给相关的处理器。
列表 10-13 向 caddy.toml 文件中添加了一个路由和一个反向代理处理器。
`--snip--`
1 [[apps.http.servers.test_server.routes]]
2 [[apps.http.servers.test_server.routes.match]]
path = [
'/backend',
3 '/backend/*',
]
4 [[apps.http.servers.test_server.routes.handle]]
handler = 'reverse_proxy'
5 [[apps.http.servers.test_server.routes.handle.upstreams]]
dial = 6'localhost:8080'
列表 10-13: 向后端服务添加反向代理(caddy.toml)
test_server 配置包括一个 routes 数组 1,每个数组中的路由可以有零个或多个匹配器 2。匹配器 是一个特殊模块,它允许您为传入请求指定匹配条件,就像前一章中讨论的 http.ServeMux.Handle 方法的模式匹配一样。Caddy 包括了允许您考虑请求的各个部分的匹配器模块。
对于这个路由,您添加了一个匹配器,它可以匹配任何请求的绝对路径为 /backend 或任何以 /backend/ 开头的路径 3。*** 字符是一个通配符,告诉 Caddy 您希望匹配 /backend/ 前缀。例如,请求资源 /backend/this/is/a/test 也会匹配。
路由可以有一个或多个处理器 4。在这里,您告诉 Caddy,您希望将所有匹配的请求发送到反向代理处理器。反向代理处理器需要知道将请求发送到哪里。您需要通过其 dial 属性指定一个上游条目 5,该属性设置为后端服务器的套接字地址 6。
提供静态文件
在前一章中,您依赖于http.FileServer为您提供静态文件服务。Caddy 提供了类似的功能,通过其 file_server 处理器。列表 10-14 向您的 caddy.toml 文件添加了第二个路由,用于提供静态文件服务。
`--snip--`
1 [[apps.http.servers.test_server.routes]]
2 [[apps.http.servers.test_server.routes.handle]]
handler = 'restrict_prefix'
prefix = '.'
3 [[apps.http.servers.test_server.routes.handle]]
handler = 'file_server'
root = 4'./files'
index_names = [
5'index.html',
]
列表 10-14: 向默认路由添加静态文件服务(caddy.toml)
与在 Listing 10-13 中添加的路由不同,这个路由 1 不包含任何匹配器。因此,如果请求没有匹配之前的路由,Caddy 会将每个请求都发送到这个路由的处理器。换句话说,这个路由是你的默认路由,所以它在文件中的位置很重要。如果你将这个路由移到反向代理路由之前,所有请求都会匹配它,且没有请求会进入反向代理。每当你指定一个没有匹配器的路由时,确保把它放在路由数组的末尾,就像这里一样。
与前一章的文件服务器一样,你需要防止不小心服务带有点前缀的敏感文件。因此,你应将 restrict_prefix 中间件 2 包含到处理器数组中,放在 file_server 处理器 3 之前。你可以添加更多配置选项,以便提供 files 子目录 4 中的文件,并且当请求未指定文件时,返回 index.html 文件 5。
检查你的工作
一切就绪。启动 Caddy 并验证配置是否按预期工作。由于一些静态文件是图片,我建议你在 Caddy 运行时使用网页浏览器与其交互。
使用 caddy.toml 文件和 toml 适配器启动 Caddy:
$ **./caddy start --config caddy.toml --adapter toml**
在 Windows 上,命令如下所示:
> **caddy start --config caddy.toml --adapter toml**
现在,运行后台 web 服务:
$ **cd backend**
$ **go run backend.go**
Listening on localhost:8080 ...
打开你的网页浏览器并访问 localhost:2020/。Caddy 会将你的请求发送到文件服务器处理器,然后它会响应 index.html 文件,因为你没有在请求中指定特定文件。然后,浏览器会请求 Caddy 获取 style.css 和 sage.svg 文件,以完成页面的渲染。如果一切顺利,你现在应该看到一个智慧的登山獾。
现在,让我们测试反向代理到后台 web 服务。访问 localhost:2020/backend。这个请求与反向代理路由的匹配器相匹配,因此反向代理处理器应处理它,将请求发送到后台服务。后台 web 服务响应 HTML,指示你的浏览器从 Caddy 获取 style.css 和 hiking.svg 文件,文件服务器处理器会愉快地提供这些文件。你现在应该看到一个通过后台 web 服务生成的登山獾,使用的是从 Caddy 提供的静态文件。
如果你从本书的源码仓库复制了 files 子目录,它应该包含 ./files/.secret 和 ./files/.dir/secret 文件。你的中间件应阻止访问这两个文件。换句话说,如果你尝试请求 localhost:2020/files/.secret 或 localhost:2020/files/.dir/secret,它们将返回 404 未找到状态。
添加自动 HTTPS
现在,让我们将 Caddy 的关键特性添加到你的 web 服务器中:自动 HTTPS。
我曾使用 Caddy 搭建过一个完整支持 HTTPS 的网站,使用所有现代 Web 浏览器信任的证书,整个过程只花了几分钟。此后,服务器一直稳定运行,每隔几个月 Caddy 会自动旋转 Let’s Encrypt 的密钥,我无需干预。这并不是说我不能在我自己的基于 Go 的 Web 服务器中复制这种功能;我只是认为我的时间更应该用来构建服务,而将 Web 服务交给 Caddy。如果 Caddy 缺少某些功能,我可以通过模块来添加。
当 Caddy 能够确定您配置的域名时,它会自动启用 TLS。本章中创建的 caddy.toml 配置文件没有提供足够的信息,让 Caddy 确定它正在为哪个域名提供服务。因此,Caddy 没有为您启用 HTTPS。您告诉 Caddy 绑定到 localhost,但这只告诉 Caddy 它正在监听什么,并没有告诉它正在为哪些域名提供服务。
启用自动 HTTPS 的最常见方法是向 Caddy 的某个路由添加主机匹配器。以下是一个匹配器示例:
[[apps.http.servers.server.routes.match]]
host = [
'example.com',
]
这个主机匹配器提供了足够的信息,供 Caddy 确定它正在为 example.com 域名提供服务。如果 Caddy 尚未为 example.com 配置有效证书以启用 HTTPS,它将通过与 Let’s Encrypt 验证该域名并获取证书的过程。Caddy 会管理您的证书,并在必要时自动续期。
Caddy 的 file-server 子命令告诉 Caddy 您希望它仅通过 HTTP 提供文件服务。file-server 的 --domain 标志提供了足够的信息,供 Caddy 启用自动 HTTPS,使您能够通过 HTTPS 提供文件服务。
Caddy 的 reverse-proxy 子命令允许您将 Caddy 设置为仅反向代理模式,它会将所有传入请求转发到 --to 标志指定的套接字地址。如果您使用 --from 标志指定主机名,Caddy 会检索 TLS 证书并启用自动 HTTPS。
我鼓励您阅读更多关于 Caddy 在生产环境中自动启用 HTTPS 的内容,请访问 caddyserver.com/docs/automatic-https。
您所学的内容
Caddy 是一款用 Go 语言编写的现代 Web 服务器,通过模块和配置适配器提供安全性、性能和可扩展性。Caddy 可以通过与 Let’s Encrypt(一个提供免费数字证书的非营利证书机构)的集成,自动启用 HTTPS。Caddy 和 Let’s Encrypt 配合使用,能够让您轻松搭建具有无缝 HTTPS 支持的 Web 服务器。
Caddy 使用 JSON 作为其原生配置格式,并在本地主机的 2019 端口上公开了一个 API,允许你发布 JSON 来更改其配置。配置更改会立即生效。但由于 JSON 并不是理想的配置格式,Caddy 采用了配置适配器。配置适配器将更适合配置的格式(如 TOML)转化为 JSON。如果你不想使用 JSON 作为 Caddy 配置,或者找不到符合你需求的配置适配器,你还可以像本章所示,自己编写一个适配器。
你还可以通过使用模块扩展 Caddy 的功能。本章将展示如何编写一个中间件模块,将其编译进 Caddy,配置模块,并使其有效运作。
最后,本章向你展示了如何将 Caddy 集成到你的网络架构中。你会经常将 Caddy 作为你网络中的第一个服务器,使用它接收客户端请求,然后将请求转发到最终目的地。在本章中,你配置了一个 Caddy 实例,将客户端请求反向代理到你的后端 Web 服务,并代表后端 Web 服务提供静态文件。因此,你保持了后端 Web 服务的简洁,并避免了它需要管理静态内容。你的后端 Web 服务可以使用 Caddy 提供 HTTPS 支持、缓存和文件服务。
现在你已经有了一些使用 Caddy 的经验,你应该能够判断,当你的 Web 服务由一个全面的 Web 服务器解决方案提供时,还是由一个相对简洁的 net/http Web 服务器实现提供时,效果更好。如果你打算将你的 Web 服务公开,使用像 Caddy 这样的成熟 Web 服务器作为应用程序的边缘服务器,将能节省出更多时间,让你将精力集中在后端 Web 服务的优化上。
第十一章:使用 TLS 保护通信

在五年前,揭露者爱德华·斯诺登向我们展示了我们是多么地理所当然地认为电子隐私是理所应当的,作家兼活动家科里·多克特罗就曾写道:“我们应该像对待武器级铀一样对待个人电子数据——它是危险的、持久的,一旦泄露,就无法找回。”
在 2013 年之前,大多数人通过使用明文在互联网上进行通信。社会安全号码、信用卡信息、密码、敏感电子邮件和其他可能令人尴尬的信息在互联网上传播,容易被恶意行为者拦截。大多数流行的网站默认使用 HTTP;谷歌是为数不多的支持 HTTPS 的大型科技公司之一。
今天,找一个不支持 HTTPS 的网站已经变得不寻常,尤其是现在 Let’s Encrypt 提供免费的 TLS 证书给你的域名。我们对传输中的信息的处理就像处理武器级铀一样,帮助确保我们共享信息的隐私性和完整性。我们的网络应用程序也应该如此。我们应该努力认证我们的通信,并在适当的时候使用加密,特别是当信息有可能通过不安全的网络泄露时。
到目前为止,我们只是在代码中将 TLS 当作事后的补充来使用。这部分是因为 Go 的 net/http 库使得它的使用相对轻松,但也因为我们没有充分探索 TLS 协议以及使它成为可能的基础设施。要编写安全的软件,你应该在开发开始之前仔细规划安全性,然后在编写代码时使用良好的安全实践。TLS 是提高软件安全性的绝佳方式,它通过保护传输中的数据来改善软件的安全态势。
本章将从程序员的角度介绍 TLS 的基础知识。你将了解客户端和服务器之间的握手过程,以及使这一过程得以实现的固有信任。然后我们将讨论即使你使用了 TLS,事情如何(以及如何)出错。最后,我们将看一些实际的例子,了解如何将 TLS 集成到你的应用程序中,包括客户端和服务器的相互认证。
深入了解传输层安全性
TLS 协议为客户端和服务器之间提供安全通信。它允许客户端认证服务器,并可选择性地允许服务器认证客户端。客户端使用 TLS 来加密与服务器的通信,防止第三方拦截和篡改。
TLS 使用握手过程来建立 TLS 会话的某些标准。如果客户端发起了 TLS 1.3 与服务器的握手,它将大致如下:
-
客户端 Hello google.com。我希望使用 TLS 版本 1.3 与你进行通信。以下是我希望用来加密我们消息的密码套件列表,按我的偏好顺序排列。我专门为这次会话生成了一对公钥和私钥。这是我的公钥。
-
服务器问候,客户端。TLS 1.3 版本非常适合我。根据你的密码套件列表,我决定使用高级加密标准与 Galois/计数器模式(AES-GCM)密码套件。我也为这次会话创建了新的密钥对。这里是我的公钥和证书,你可以证明我确实是google.com。我还发送了一个 32 字节的值,它对应于你要求我使用的 TLS 版本。最后,我还包括了一个签名和一个消息认证码(MAC),这是通过使用你的公钥对我们迄今讨论的所有内容进行衍生的,这样你就可以在收到我的回复时验证其完整性。
-
客户端(自言自语)一个我信任的认证机构签署了服务器的证书,所以我确信我正在与google.com通信。我通过使用我的私钥从服务器的签名中衍生出了这次会话的对称密钥。使用这个对称密钥,我验证了 MAC,并确保没有人篡改服务器的回复。回复中的 32 个字节对应于 TLS 版本 1.3,因此没有人试图欺骗服务器使用更旧、更弱的 TLS 版本。我现在拥有了一切所需的东西,可以与服务器进行安全通信。
-
客户端(发送给服务器)这是一些加密数据。
服务器的 Hello 消息中的 32 字节值防止了降级攻击,即攻击者拦截客户端的 Hello 消息并修改它,要求使用更旧、更弱的 TLS 版本。如果客户端请求的是 TLS v1.3,但攻击者将客户端的 Hello 消息改为请求 TLS v1.1,那么服务器的 Hello 消息中的 32 字节值将对应于 TLS v1.1。当客户端接收到服务器的 Hello 消息时,它会注意到该值指示了错误的 TLS 版本,从而中止握手。
从现在开始,客户端和服务器使用 AES-GCM 对称密钥加密(在这个假设的例子中)。客户端和服务器都会在传输层之前将应用层负载封装到 TLS 记录中,然后将负载传递给传输层。
尽管名称中带有“传输”,TLS 并不是一个传输层协议。相反,它位于 TCP/IP 协议栈的传输层和应用层之间。TLS 在将应用层协议的负载传递到传输层之前会先对负载进行加密。一旦负载到达目的地,TLS 会从传输层接收负载,对其解密,并将负载传递给应用层协议。
前向保密
我们假设对话中的握手方法是 TLS v1.3 中使用的 Diffie-Hellman (DH) 密钥交换的一个例子。DH 密钥交换要求生成新的客户端和服务器密钥对,以及一个新的对称密钥,所有这些密钥仅在会话期间有效。一旦会话结束,客户端和服务器将丢弃会话密钥。
使用每会话密钥意味着 TLS v1.3 提供了 前向保密;如果攻击者破解了你的会话密钥,他们只能破解该会话期间交换的数据。攻击者无法使用这些密钥解密其他会话期间交换的数据。
我们信任证书颁发机构
我的父亲和我在我开始写这本书之前不久去了一趟爱尔兰。为了准备这次旅行,我需要办理一本新护照,因为我的旧护照早就过期了。这个过程很简单。我填写了申请表,收集了重要的个人记录,拍了一张丑陋的照片,并把这些材料以及申请费交给了当地的美国邮政局分局。我还向公证人证明了我就是我自己。几周后,我收到了新办理的美国护照。
当我们到达爱尔兰时,一位可爱的海关官员迎接了我们,并要求出示护照。她在电脑验证我们的身份的同时,询问了关于我们假期的一些问题。不到三分钟,她就把护照还给我们,欢迎我们来到爱尔兰。
我的护照代表了美国政府对我身份的认证,证明我是亚当·伍德贝克。但它的有效性仅取决于爱尔兰对美国政府验证我身份能力的信任。如果爱尔兰不信任美国,它将不会相信美国说我就是我,并很可能拒绝让我进入该国。(说实话,我并没有足够的魅力让海关人员仅凭我自己的话就放行我。)
TLS 的证书工作原理与我的护照非常相似。如果我需要为 woodbeck.net 获取一个新的 TLS 证书,我会向证书颁发机构(如 Let’s Encrypt)提出请求。证书颁发机构会验证我是否是 woodbeck.net 的合法拥有者。一旦确认无误,证书颁发机构会为 woodbeck.net 签发新的证书,并使用其证书进行加密签名。我的服务器可以向客户端展示这个证书,客户端通过验证证书颁发机构的签名来认证我的服务器,从而确认他们与真正的 woodbeck.net 通信,而不是与冒名顶替者通信。
证书颁发机构为woodbeck.net签发签名证书类似于美国政府为我签发护照。它们都是由受信任的机构签发,证明其主体的真实性。就像爱尔兰对美国的信任一样,客户端只有在信任签发证书的证书颁发机构时,才会信任woodbeck.net证书。我可以像创建一个宣称是我护照的文件一样,创建自己的证书颁发机构并自签证书。但爱尔兰宁可承认 Jack Daniel’s 田纳西威士忌优于 Jameson 爱尔兰威士忌,也不可能信任我自签发的护照,世界上没有任何操作系统或网页浏览器会信任我自签的证书。
如何妥协 TLS
2013 年 12 月 24 日,谷歌发现土耳其的 Turktrust 证书颁发机构错误地签发了一个证书,使恶意行为者能够伪装成google.com。这意味着攻击者可以欺骗你的网页浏览器,使其认为自己与谷歌通过 TLS 连接进行通信,并诱使你泄露凭证。谷歌迅速注意到这一错误,并采取了补救措施。
Turktrust 的失误削弱了它的权威并破坏了我们的信任。但即使证书颁发机构运作正常,攻击者也可以将目标转向个人。如果攻击者能够在你的操作系统受信任的证书存储中安装自己的 CA 证书,你的计算机会信任他签发的任何证书。这意味着攻击者可以妥协你所有的 TLS 流量。
大多数人不会得到这种特别的关注。相反,攻击者更可能妥协服务器。一旦服务器被攻破,攻击者就能捕获所有 TLS 流量及其对应的会话密钥。
你不太可能遇到这些场景,但了解它们是可能发生的非常重要。总体而言,TLS 1.3 提供了极好的安全性,并且很难被妥协,因为它有完整的握手签名、降级保护、前向保密性和强加密。
保护传输中的数据
无论是你自己的数据还是他人的数据,确保你在网络上传输的数据的完整性应该是你的主要关注点。Go 使得使用 TLS 变得如此简单,以至于你几乎无法为不使用它辩解。在本节中,你将学习如何为客户端和服务器添加 TLS 支持。你还将看到 TLS 如何在 TCP 上工作,以及如何通过证书固定技术来减轻恶意证书的威胁。
客户端 TLS
客户端在握手过程中的主要关注点是通过使用证书验证服务器。如果客户端无法信任服务器,就无法认为与服务器的通信是安全的。net/http/httptest包提供了方便的构造,能够轻松演示 Go 的 HTTP-over-TLS 支持(参见 Listing 11-1)。
package ch11
import (
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/net/http2"
)
func TestClientTLS(t *testing.T) {
ts := 1httptest.NewTLSServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if 2r.TLS == nil {
u := "https://" + r.Host + r.RequestURI
http.Redirect(w, r, u, http.StatusMovedPermanently)
return
}
w.WriteHeader(http.StatusOK)
},
),
)
defer ts.Close()
resp, err := 3ts.Client().Get(ts.URL)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status %d; actual status %d",
http.StatusOK, resp.StatusCode)
}
清单 11-1:测试 HTTPS 客户端和服务器支持 (tls_client_test.go)
httptest.NewTLSServer函数返回一个 HTTPS 服务器 1。除了函数名之外,这段代码看起来与我们在第八章中使用的httptest完全相同。在这里,httptest.NewTLSServer函数处理了 HTTPS 服务器的 TLS 配置细节,包括创建一个新的证书。此证书没有受到任何受信任机构的签名,因此没有辨别能力的 HTTPS 客户端会信任它。你将很快看到如何通过使用预配置的客户端来绕过这个细节。
如果服务器通过 HTTP 接收到客户端的请求,则请求的TLS字段将为nil。你可以检查这种情况 2,并相应地将客户端重定向到 HTTPS 端点。
出于测试目的,服务器的Client方法 3 返回一个新的*http.Client,该客户端固有信任服务器的证书。你可以使用此客户端测试处理程序中的 TLS 特定代码。
让我们看看在清单 11-2 中,当你尝试使用一个新的客户端与相同的服务器进行通信时,且该客户端没有固有信任服务器证书时会发生什么。
`--snip--`
tp := &http.Transport{
TLSClientConfig: &tls.Config{
CurvePreferences: []tls.CurveID{1tls.CurveP256},
MinVersion: tls.VersionTLS12,
},
}
err = 2http2.ConfigureTransport(tp)
if err != nil {
t.Fatal(err)
}
client2 := &http.Client{Transport: tp}
_, err = client2.Get(ts.URL)
if err == nil || !strings.Contains(err.Error(),
"certificate signed by unknown authority") {
t.Fatalf("expected unknown authority error; actual: %q", err)
}
3 tp.TLSClientConfig.InsecureSkipVerify = true
resp, err = client2.Get(ts.URL)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Errorf("expected status %d; actual status %d",
http.StatusOK, resp.StatusCode)
}
}
清单 11-2:使用有辨别能力的客户端测试 HTTPS 服务器 (tls_client_test.go)
你通过创建一个新的传输,定义其 TLS 配置,并配置http2使用该传输,来覆盖客户端传输中的默认 TLS 配置。好的做法是将客户端的曲线首选项限制为 P-256 曲线 1,并避免使用 P-384 和 P-521。P-256 对时间攻击具有免疫性,而 P-384 和 P-521 则没有。此外,客户端将协商最低 TLS 1.2\。
椭圆曲线是一种平面曲线,曲线上的所有点都满足相同的多项式方程。与使用大素数来生成密钥的第一代加密技术(如 RSA)不同,椭圆曲线加密使用椭圆曲线上的点来生成密钥。P-256、P-384 和 P-521 是在美国国家标准与技术研究院(NIST)数字签名标准中定义的特定椭圆曲线。你可以在《联邦信息处理标准》(FIPS)出版物 186-4 中找到更多细节(nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-4.pdf)。
由于你的传输不再依赖于默认的 TLS 配置,客户端不再具备固有的 HTTP/2 支持。如果你想使用 HTTP/2,你需要显式地为你的传输添加 HTTP/2 支持 2。当然,这个测试并不依赖于 HTTP/2,但如果你没有意识到覆盖传输的 TLS 配置会移除 HTTP/2 支持,那么这个实现细节可能会让你陷入困境。
你的客户端使用操作系统的受信证书存储,因为你没有明确告诉它信任哪些证书。第一次调用测试服务器时会出现错误,因为你的客户端不信任服务器证书的签发者。你可以绕过这个问题,通过将 InsecureSkipVerify 字段设置为 true3 来配置客户端的传输跳过验证服务器的证书。我不建议你在除调试之外的任何情况下启用 InsecureSkipVerify。我认为启用该选项并发布代码是一个代码气味。你将在本章稍后学习更好的替代方案,当我们讨论 证书钉扎 概念时。正如字段名所示,启用此选项会使你的客户端本质上不安全,容易受到中间人攻击,因为它现在会盲目信任服务器提供的任何证书。如果你使用新配置的天真客户端进行相同的调用,你会发现它会愉快地与服务器协商 TLS。
TCP 上的 TLS
TLS 是有状态的;客户端和服务器在初始握手时协商会话参数,一旦达成一致,它们就会在会话期间交换加密的 TLS 记录。由于 TCP 本身也是有状态的,它是实现 TLS 的理想传输层协议,因为你可以利用 TCP 的可靠性保证来维持你的 TLS 会话。
让我们暂时不考虑应用协议,学习如何通过 TCP 建立 TLS 连接。清单 11-3 展示了如何使用 crypto/tls 包,通过几行代码发起 TLS 连接。
`--snip--`
func TestClientTLSGoogle(t *testing.T) {
conn, err := 1tls.DialWithDialer(
&net.Dialer{Timeout: 30 * time.Second},
"tcp",
"www.google.com:443",
&tls.Config{
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS12,
},
)
if err != nil {
t.Fatal(err)
}
state := 2conn.ConnectionState()
t.Logf("TLS 1.%d", state.Version-tls.VersionTLS10)
t.Log(tls.CipherSuiteName(state.CipherSuite))
t.Log(state.VerifiedChains[0][0].Issuer.Organization[0])
_ = conn.Close()
}
清单 11-3:启动与 www.google.com 的 TLS 连接(tls_client_test.go)
tls.DialWithDialer 函数 1 接受一个 *net.Dialer、网络类型、地址和一个 *tls.Config。在这里,你为拨号器设置了 30 秒的超时,并指定了推荐的 TLS 设置。如果成功,你可以检查连接的状态 2,以获取关于 TLS 连接的详细信息。
清单 11-4 显示了 清单 11-3 测试的输出。
$ **go test -race -run TestClientTLSGoogle -v ./...**
=== RUN TestClientTLSGoogle
TestClientTLSGoogle: tls_client_test.go:89: TLS 1.3
TestClientTLSGoogle: tls_client_test.go:90: TLS_AES_128_GCM_SHA256
TestClientTLSGoogle: tls_client_test.go:91: Google Trust Services
--- PASS: TestClientTLSGoogle (0.31s)
PASS
清单 11-4:运行 TestClientTLSGoogle 测试
你的 TLS 客户端使用的是 TLS_AES_128_GCM_SHA256 密码套件,基于 TLS 1.3 版本。注意,tls.DialWithDialer 并没有对服务器的证书提出异议。底层 TLS 客户端使用操作系统的受信证书存储,并确认 www.google.com 的证书由受信的证书颁发机构签发——在本例中是 Google Trust Services。
服务器端 TLS
服务器端的代码与之前学习的差别不大。主要的不同之处在于,服务器需要在握手过程中向客户端提供证书。你可以使用 Go 的 src/crypto/tls 子目录中的 generate_cert.go 文件来创建证书。对于生产环境,最好使用 Let’s Encrypt 或其他证书颁发机构的证书。你可以使用 LEGO 库(github.com/go-acme/lego/)将证书管理功能添加到你的服务中。生成一个新的证书和私钥,像这样:
$ **go run $GOROOT/src/crypto/tls/generate_cert.go -host localhost -ecdsa-curve P256**
该命令会创建一个名为 cert.pem 的证书,主机名为 localhost,并创建一个名为 key.pem 的私钥。接下来的代码假设这两个文件已经存在于当前目录中。
延续前几章的传统,列表 11-5 包含了一个仅支持 TLS 的回显服务器的第一个代码片段。
package ch11
import (
"context"
"crypto/tls"
"fmt"
"net"
"time"
)
func NewTLSServer(ctx context.Context, address string,
maxIdle time.Duration, tlsConfig *tls.Config) *Server {
return &Server{
ctx: ctx,
ready: make(chan struct{}),
addr: address,
maxIdle: maxIdle,
tlsConfig: tlsConfig,
}
}
type Server struct {
ctx context.Context
ready chan struct{}
addr string
maxIdle time.Duration
tlsConfig *tls.Config
}
func (s *Server) 1Ready() {
if s.ready != nil {
<-s.ready
}
}
列表 11-5:服务器结构体类型和构造函数(tls_echo.go)
Server 结构体有一些字段,用于记录服务器的设置、TLS 配置和一个信号通道,用于指示服务器何时准备好接受传入的连接。稍后你会写一个测试用例,并使用 Ready 方法 1 来阻塞,直到服务器准备好接受连接。
NewTLSServer 函数接受一个用于停止服务器的上下文、一个地址、服务器允许连接空闲的最长时间以及 TLS 配置。尽管控制空闲客户端与 TLS 无关,但你将使用最大空闲时间来推进套接字的截止时间,就像在第三章中一样。
你在前几章使用的服务器依赖于监听和服务这两个独立的概念。通常,你会调用一个辅助函数来同时执行这两个操作,比如 net/http 服务器的 ListenAndServe 方法。列表 11-6 向回显服务器添加了一个类似的方法。
`--snip--`
func (s *Server) ListenAndServeTLS(certFn, keyFn string) error {
if s.addr == "" {
s.addr = "localhost:443"
}
l, err := net.Listen("tcp", s.addr)
if err != nil {
return fmt.Errorf("binding to tcp %s: %w", s.addr, err)
}
if s.ctx != nil {
go func() {
<-s.ctx.Done()
_ = l.Close()
}()
}
return s.ServeTLS(l, certFn, keyFn)
}
列表 11-6:为监听和服务方法添加信号,指示服务器已准备好接收连接(tls_echo.go)
ListenAndServe 方法接受证书和私钥的完整路径,并返回错误。它创建一个绑定到服务器地址的 net.Listener,然后启动一个 goroutine,在你取消上下文时关闭监听器。最后,方法将监听器、证书路径和密钥路径传递给服务器的 ServeTLS 方法。
列表 11-7 通过 ServeTLS 方法完善了回显服务器的实现。
`--snip--`
func (s Server) ServeTLS(l net.Listener, certFn, keyFn string) error {
if s.tlsConfig == nil {
s.tlsConfig = &tls.Config{
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS12,
1 PreferServerCipherSuites: true,
}
}
if len(s.tlsConfig.Certificates) == 0 &&
s.tlsConfig.GetCertificate == nil {
cert, err := 2tls.LoadX509KeyPair(certFn, keyFn)
if err != nil {
return fmt.Errorf("loading key pair: %v", err)
}
s.tlsConfig.Certificates = []tls.Certificate{cert}
}
tlsListener := 3tls.NewListener(l, s.tlsConfig)
if s.ready != nil {
close(s.ready)
}
列表 11-7:为 net.Listener 添加 TLS 支持(tls_echo.go)
ServeTLS 方法首先检查服务器的 TLS 配置。如果配置为 nil,它将添加一个默认配置,并将 PreferServerCipherSuites 设置为 true。PreferServerCipherSuites 对服务器有意义,它使服务器使用自己首选的密码套件,而不是听从客户端的偏好。
如果服务器的 TLS 配置没有至少一个证书,或者其GetCertificate方法为nil,你可以通过从文件系统读取证书和私钥文件来创建一个新的tls.Certificate 2。
在这段代码中,服务器拥有一个 TLS 配置,其中至少包含一个证书,准备向客户端展示。剩下的就是通过将 TLS 配置和监听器传递给tls.NewListener函数来为net.Listener添加 TLS 支持 3。tls.NewListener函数充当中间件,它增强了监听器,使其从Accept方法返回 TLS 支持的连接对象。
列表 11-8 通过从监听器接受连接并在单独的 goroutine 中处理它们来完成ServeTLS方法。
`--snip--`
for {
conn, err := 1tlsListener.Accept()
if err != nil {
return fmt.Errorf("accept: %v", err)
}
go func() {
defer func() { _ = 2conn.Close() }()
for {
if s.maxIdle > 0 {
err := 3conn.SetDeadline(time.Now().Add(s.maxIdle))
if err != nil {
return
}
}
buf := make([]byte, 1024)
n, err := 4conn.Read(buf)
if err != nil {
return
}
_, err = conn.Write(buf[:n])
if err != nil {
return
}
}
}()
}
}
列表 11-8:从监听器接受 TLS 支持的连接(tls_echo.go)
这个模式与前面章节中你见过的类似。你使用一个无限的for循环,不断阻塞在监听器的Accept方法 1 上,当客户端成功连接时,它会返回一个新的net.Conn对象。由于你使用的是一个支持 TLS 的监听器,它返回的是具有 TLS 支持的连接对象。你与这些连接对象的交互方式与以往相同。Go 在这一点上将 TLS 的细节抽象给你。然后你会将这个连接分配到一个新的 goroutine 中,从那时起处理该连接。
服务器以相同的方式处理每个连接。它首先有条件地将套接字截止日期设置为服务器的最大空闲时长 3,然后等待客户端发送数据。如果服务器在达到截止日期之前没有从套接字读取到任何数据,连接的Read方法 4 会返回I/O 超时错误,最终导致连接关闭 2。
如果相反,服务器从连接读取数据,它会将相同的负载写回给客户端。控制循环回绕以重置截止日期,然后等待来自客户端的下一个负载。
证书钉扎
在本章前面,我们讨论了破坏 TLS 信任的方式,无论是证书颁发机构颁发伪造证书,还是攻击者将恶意证书注入到计算机的可信证书存储中。你可以通过使用证书钉扎(certificate pinning)来减轻这两种攻击。
证书钉扎是指放弃使用操作系统的可信证书存储,并在应用程序中显式定义一个或多个可信证书。你的应用程序将只信任来自展示钉扎证书或由钉扎证书签名的证书的主机。如果你计划在零信任环境中部署客户端,并且需要与服务器安全通信,请考虑将服务器的证书钉扎到每个客户端。
假设前面一节介绍的服务器使用了您为localhost主机名生成的cert.pem和key.pem文件,所有客户端将在服务器展示证书后立即中止 TLS 连接。客户端不会信任服务器的证书,因为没有受信任的证书机构签署它。
您可以将tls.Config的InsecureSkipVerify字段设置为true,但由于此方法不安全,我不建议您将其视为一个实际选择。相反,我们明确告诉客户端它可以通过将服务器证书固定到客户端来信任服务器的证书。列表 11-9 展示了这个过程的开始。
package ch11
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"io"
"io/ioutil"
"strings"
"testing"
"time"
)
func TestEchoServerTLS(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
serverAddress := "localhost:34443"
maxIdle := time.Second
server := NewTLSServer(ctx, serverAddress, maxIdle, nil)
done := make(chan struct{})
go func() {
err := 1server.ListenAndServeTLS("cert.pem", "key.pem")
if err != nil && !strings.Contains(err.Error(),
"use of closed network connection") {
t.Error(err)
return
}
done <- struct{}{}
}()
2 server.Ready()
列表 11-9:创建一个新的 TLS 回显服务器并在后台启动它(tls_echo_test.go)
由于cert.pem中的主机名是localhost,您创建一个新的 TLS 回显服务器,监听localhost端口 34443。这里端口不重要,但客户端期望服务器可以通过与证书中显示的相同主机名进行访问。您通过使用cert.pem和key.pem文件 1 启动服务器并阻塞,直到它准备好接受连接 2。
列表 11-10 从我们停下的地方继续,通过创建一个带有明确信任服务器证书的客户端 TLS 配置。
`--snip--`
cert, err := ioutil.ReadFile("cert.pem")
if err != nil {
t.Fatal(err)
}
certPool := 1x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(cert); !ok {
t.Fatal("failed to append certificate to pool")
}
tlsConfig := &tls.Config{
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS12,
2 RootCAs: certPool,
}
列表 11-10:将服务器证书固定到客户端(tls_echo_test.go)
将服务器证书固定到客户端是直接的。首先,您需要读取cert.pem文件。然后,您创建一个新的证书池 1,并将证书添加到池中。最后,您将证书池添加到tls.Config的RootCAs字段 2。如名称所示,您可以将多个受信任的证书添加到证书池中。这在您迁移到新证书,但尚未完全淘汰旧证书时非常有用。
使用此配置的客户端将仅认证展示cert.pem证书或任何由其签署的证书的服务器。我们将在测试的其余部分确认这一行为(见列表 11-11)。
`--snip--`
conn, err := 1tls.Dial("tcp", serverAddress, tlsConfig)
if err != nil {
t.Fatal(err)
}
hello := []byte("hello")
_, err = conn.Write(hello)
if err != nil {
t.Fatal(err)
}
b := make([]byte, 1024)
n, err := conn.Read(b)
if err != nil {
t.Fatal(err)
}
if actual := b[:n]; !bytes.Equal(hello, actual) {
t.Fatalf("expected %q; actual %q", hello, actual)
}
2 time.Sleep(2 * maxIdle)
_, err = conn.Read(b)
if err != 3io.EOF {
t.Fatal(err)
}
err = conn.Close()
if err != nil {
t.Fatal(err)
}
cancel()
<-done
}
列表 11-11:通过使用固定的证书进行服务器认证(tls_echo_test.go)
您将包含固定服务器证书 1 的tls.Config传递给tls.Dial。您的 TLS 客户端将在不需要使用InsecureSkipVerify及其带来的所有不安全性的情况下验证服务器的证书。
现在,您已经与服务器建立了受信任的连接,尽管服务器展示了一个未签名的证书,我们还是来确保服务器按预期工作。它应该回显您发送的任何消息。如果您长时间空闲 2,您会发现与套接字的下一次交互会导致错误 3,显示服务器关闭了套接字。
相互 TLS 认证
在前一节中,你学习了客户端如何通过使用服务器的证书和受信任的第三方证书,或者通过配置客户端显式信任服务器的证书来验证服务器身份。服务器也可以以相同的方式验证客户端身份。这在零信任网络架构中尤为重要,在这种架构下,客户端和服务器都必须证明自己的身份。例如,你可能有一个位于你网络外部的客户端,该客户端必须向代理服务器出示证书,代理服务器才会允许该客户端访问你的受信任网络资源。同样,客户端也会验证代理服务器出示的证书,以确保它与代理服务器通信,而不是与由恶意行为者控制的代理服务器通信。
你可以指示服务器只与已认证的客户端建立 TLS 会话。那些客户端必须出示由受信任的证书颁发机构签名或已固定到服务器的证书。在你查看示例代码之前,客户端需要一个可以向服务器出示的证书用于身份验证。然而,客户端不能使用在 $GOROOT/src/crypto/tls/generate_cert.go 中生成的证书进行客户端身份验证。相反,你需要创建自己的证书和私钥。
为身份验证生成证书
Go 的标准库包含了生成你自己证书所需的所有内容,使用的是椭圆曲线数字签名算法(ECDSA)和 P-256 椭圆曲线。列表 11-12 展示了一个命令行工具的初步实现,正是用来做这个的。在你阅读它时,记住它可能不完全适合你的使用场景。例如,它生成的是 10 年的证书,并且使用我的名字作为证书的主体,这可能不是你在代码中想使用的(不过如果你愿意使用,我会感到受宠若惊)。根据需要进行调整。
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"flag"
"log"
"math/big"
"net"
"os"
"strings"
"time"
)
var (
host = flag.String("host", "localhost",
"Certificate's comma-separated host names and IPs")
certFn = flag.String("cert", "cert.pem", "certificate file name")
keyFn = flag.String("key", "key.pem", "private key file name")
)
func main() {
flag.Parse()
serial, err := 1rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1),
128))
if err != nil {
log.Fatal(err)
}
notBefore := time.Now()
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
Organization: []string{"Adam Woodbeck"},
},
NotBefore: notBefore,
NotAfter: notBefore.Add(10 * 356 * 24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment |
x509.KeyUsageDigitalSignature |
x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
2x509.ExtKeyUsageClientAuth,
},
BasicConstraintsValid: true,
IsCA: true,
}
列表 11-12:创建 X.509 证书模板 (cert/generate.go)
该命令行工具接受一个用逗号分隔的主机名和 IP 地址列表,这些主机名和 IP 地址将使用该证书。它还允许你指定证书和私钥的文件名,但默认使用我们熟悉的 cert.pem 和 key.pem 文件名。
生成证书和私钥的过程包括在代码中构建一个模板,然后将其编码为 X.509 格式。每个证书都需要一个序列号,通常由证书颁发机构分配。由于你正在生成自己的自签名证书,你使用加密随机的、无符号的 128 位整数 1 来生成自己的序列号。接着,你创建一个 x509.Certificate 对象,表示一个 X.509 格式的证书,并设置各种值,如序列号、证书的主题、有效期和该证书的各种用途。由于你希望使用此证书进行客户端身份验证,你必须包括 x509.ExtKeyUsageClientAuth 值 2。如果遗漏此值,服务器将无法在客户端提供证书时验证该证书。
模板几乎准备好了。你只需要在生成证书之前添加主机名和 IP 地址(参见 清单 11-13)。
`--snip--`
for _, h := range 1strings.Split(*host, ",") {
if ip := net.ParseIP(h); ip != nil {
2 template.IPAddresses = append(template.IPAddresses, ip)
} else {
3 template.DNSNames = append(template.DNSNames, h)
}
}
priv, err := 4ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
log.Fatal(err)
}
der, err := 5x509.CreateCertificate(rand.Reader, &template,
&template, &priv.PublicKey, priv)
if err != nil {
log.Fatal(err)
}
cert, err := os.Create(*certFn)
if err != nil {
log.Fatal(err)
}
err = 6pem.Encode(cert, &pem.Block{Type: "CERTIFICATE", Bytes: der})
if err != nil {
log.Fatal(err)
}
if err := cert.Close(); err != nil {
log.Fatal(err)
}
log.Println("wrote", *certFn)
清单 11-13:编写隐私增强邮件(PEM)编码的证书(cert/generate.go)
你遍历以逗号分隔的主机名和 IP 地址列表 1,将每个地址分配到模板中的相应切片。如果主机名是 IP 地址,你将其分配到 IPAddresses 切片 2。否则,你将主机名分配到 DNSNames 切片 3。Go 的 TLS 客户端使用这些值来验证服务器。例如,如果客户端连接到 https://www.google.com,但服务器证书中的常见名称或备用名称与 www.google.com 的主机名或解析后的 IP 地址不匹配,客户端将无法验证服务器。
接下来,你使用 P-256 椭圆曲线生成一个新的 ECDSA 私钥 4。此时,你已经拥有生成证书所需的一切。x509.CreateCertificate 函数 5 接受一个熵源(crypto/rand 的 Reader 是理想选择)、新证书的模板、父证书、公钥和相应的私钥。然后,它返回一个字节切片,包含按区分编码规则(DER)编码的证书。你使用模板作为父证书,因为生成的证书将自签名。接下来要做的就是创建一个新文件,生成一个新的 pem.Block,并将 DER 编码的字节切片进行 PEM 编码,保存到新文件中 6。你不需要担心各种编码。Go 非常适合在磁盘上使用 PEM 编码的证书。
现在你已经在磁盘上生成了新的证书,我们来编写相应的私钥,参见 清单 11-14。
`--snip--`
key, err := os.OpenFile(*keyFn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
10600)
if err != nil {
log.Fatal(err)
}
privKey, err := 2x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
log.Fatal(err)
}
err = 3pem.Encode(key, &pem.Block{Type: "EC PRIVATE KEY",
Bytes: privKey})
if err != nil {
log.Fatal(err)
}
if err := key.Close(); err != nil {
log.Fatal(err)
}
log.Println("wrote", *keyFn)
}
清单 11-14:编写 PEM 编码的私钥(cert/generate.go)
证书是为了公开共享,而私钥则完全相反:是私密的。你应该小心地为它分配最小的权限。在这里,你只给用户读写私钥文件的权限 1,并移除其他所有人的访问权限。我们将私钥编组为字节切片 2,并且类似地,将其分配给新的pem.Block,然后将 PEM 编码的输出写入私钥文件 3。
Listing 11-15 使用前面的代码生成了服务器和客户端的证书和密钥对。
$ **go run cert/generate.go -cert serverCert.pem -key serverKey.pem -host localhost**
2006/01/02 15:04:05 wrote serverCert.pem
2006/01/02 15:04:05 wrote serverKey.pem
$ **go run cert/generate.go -cert clientCert.pem -key clientKey.pem -host localhost**
2006/01/02 15:04:05 wrote clientCert.pem
2006/01/02 15:04:05 wrote clientKey.pem
Listing 11-15: 为服务器和客户端生成证书和私钥对
由于服务器绑定到localhost,而客户端从localhost连接到服务器,因此这个值适用于客户端和服务器证书。如果你想将客户端移动到不同的主机名或将服务器绑定到 IP 地址,例如,你需要相应地更改host标志。
实现互相 TLS 认证
现在你已经为服务器和客户端生成了证书和私钥对,你可以开始编写它们的代码了。让我们编写一个测试,实现我们的回显服务器和客户端之间的互相 TLS 认证,从 Listing 11-16 开始。
package ch11
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io/ioutil"
"strings"
"testing"
)
func caCertPool(caCertFn string) (*x509.CertPool, error) {
caCert, err := 1ioutil.ReadFile(caCertFn)
if err != nil {
return nil, err
}
certPool := x509.NewCertPool()
if ok := 2certPool.AppendCertsFromPEM(caCert); !ok {
return nil, errors.New("failed to add certificate to pool")
}
return certPool, nil
}
Listing 11-16: 创建证书池以提供 CA 证书(tls_mutual_test.go)
客户端和服务器都使用caCertPool函数来创建一个新的 X.509 证书池。该函数接受一个 PEM 编码证书的文件路径,你在第 1 步中读取该证书并将其附加到新的证书池 2 中。证书池作为受信任证书的来源。客户端将服务器的证书放入其证书池中,反之亦然。
Listing 11-17 详细介绍了初步的测试代码,用于演示客户端和服务器之间的互相 TLS 认证。
`--snip--`
func TestMutualTLSAuthentication(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
serverPool, err := caCertPool(1"clientCert.pem")
if err != nil {
t.Fatal(err)
}
cert, err := 2tls.LoadX509KeyPair("serverCert.pem", "serverKey.pem")
if err != nil {
t.Fatalf("loading key pair: %v", err)
}
Listing 11-17: 实例化 CA 证书池和服务器证书(tls_mutual_test.go)
在创建服务器之前,你需要先用客户端的证书 1 填充一个新的 CA 证书池。此时,你还需要加载服务器的证书 2,而不是像之前的列表中那样依赖服务器的ServeTLS方法来为你加载。你为什么现在需要服务器的证书,看到 Listing 11-18 中的 TLS 配置更改后就会明白。
`--snip--`
serverConfig := &tls.Config{
Certificates: []tls.Certificate{cert},
1 GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config,
error) {
return &tls.Config{
Certificates: []tls.Certificate{2cert},
3 ClientAuth: tls.RequireAndVerifyClientCert,
4 ClientCAs: serverPool,
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: 5tls.VersionTLS13,
PreferServerCipherSuites: true,
Listing 11-18: 使用 GetConfigForClient 访问客户端的 hello 信息(tls_mutual_test.go)
请记住,在 Listing 11-13 中,你定义了生成客户端证书时使用的模板中的IPAddresses和DNSNames切片。这些值填充了客户端证书的公共名称和备用名称部分。你已经了解到 Go 的 TLS 客户端使用这些值来验证服务器。但是服务器并不使用这些来自客户端证书的值来验证客户端。
由于你正在实现双向 TLS 认证,你需要对服务器的证书验证过程进行一些更改,以便它根据客户端证书的公共名称和备用名称来验证客户端的 IP 地址或主机名。为此,服务器至少需要知道客户端的 IP 地址。在证书验证之前获取客户端连接信息的唯一方法是定义tls.Config的GetConfigForClient方法 1。这个方法允许你定义一个函数,该函数接收在 TLS 握手过程中与客户端创建的*tls.ClientHelloInfo对象。通过这个,你可以获取客户端的 IP 地址。但首先,你需要返回一个适当的 TLS 配置。
你将服务器的证书添加到 TLS 配置中 2,并将服务器池添加到 TLS 配置的ClientCAs字段 4 中。这个字段是服务器的等效项,类似于客户端 TLS 配置中的RootCAs字段。你还需要告诉服务器,每个客户端在完成 TLS 握手过程之前必须提供有效的证书 3。由于你控制客户端和服务器,指定一个最小的 TLS 协议版本为 1.35。
这个函数为每个客户端连接返回相同的 TLS 配置。如前所述,你使用GetConfigForClient方法的唯一原因是为了从客户端的 hello 信息中获取客户端的 IP 地址。示例 11-19 实现了通过使用客户端的 IP 地址以及证书的公共名称和备用名称来验证客户端的过程。
`--snip--`
1 VerifyPeerCertificate: func(rawCerts [][]byte,
verifiedChains [][]*x509.Certificate) error {
opts := x509.VerifyOptions{
KeyUsages: []x509.ExtKeyUsage{
2x509.ExtKeyUsageClientAuth,
},
Roots: 3serverPool,
}
ip := strings.Split(hello.Conn.RemoteAddr().String(),
":")[0]
hostnames, err := 4net.LookupAddr(ip)
if err != nil {
t.Errorf("PTR lookup: %v", err)
}
hostnames = append(hostnames, ip)
for _, chain := range verifiedChains {
opts.Intermediates = x509.NewCertPool()
for _, cert := range 5chain[1:] {
opts.Intermediates.AddCert(cert)
}
for _, hostname := range hostnames {
opts.DNSName = 6hostname
_, err = chain[0].Verify(opts)
if err == nil {
return nil
}
}
}
return errors.New("client authentication failed")
},
}, nil
},
}
示例 11-19:使服务器验证客户端的 IP 地址和主机名(tls_mutual_test.go)
由于你想在服务器上增强常规证书验证过程,你定义了一个合适的函数并将其分配给 TLS 配置的VerifyPeerCertificate方法 1。服务器在正常的证书验证检查后调用此方法。你执行的唯一额外检查是通过叶证书验证客户端的主机名。
叶证书是客户端提供给服务器的证书链中的最后一张证书。叶证书包含客户端的公钥。链中的所有其他证书都是中间证书,用于验证叶证书的真实性,并最终以证书颁发机构的证书作为结束。你可以在每个verifiedChains切片中的索引 0 找到每个叶证书。换句话说,你可以在verifiedChains[0][0]中找到第一个链的叶证书。如果服务器调用你分配给VerifyPeerCertificate方法的函数,至少可以找到第一个链中的叶证书。
创建一个新的 x509.VerifyOptions 对象,并修改 KeyUsages 方法以指示您希望执行客户端身份验证 2。然后,将服务器池分配给 Roots 方法 3。服务器在验证期间使用此池作为其可信证书源。
现在,从传递到 第 11-18 行 的 GetConfigForClient 方法的 *tls.ClientHelloInfo 对象 hello 中提取客户端的 IP 地址。使用该 IP 地址执行反向 DNS 查找 4,以考虑分配给客户端 IP 地址的任何主机名。如果此查找失败或返回空切片,则如何处理这种情况由您决定。如果您依赖客户端的主机名进行身份验证,并且反向查找失败,则无法对客户端进行身份验证。但如果您仅在证书的通用名称或备用名称中使用客户端的 IP 地址,则反向查找失败无关紧要。为了演示目的,我们将考虑失败的反向查找等同于失败的测试。最少,您应将客户端的 IP 地址附加到 hostnames 切片中。
唯一剩下的事情就是循环遍历每个验证的链条,将一个新的中间证书池分配给 opts.Intermediates,将所有证书但叶子证书添加到中间证书池 5,并尝试验证客户端 6。如果验证返回 nil 错误,则您已经成功认证了客户端。如果未能验证每个叶证书的每个主机名,则返回错误以指示客户端认证失败。客户端将收到一个错误,并且服务器将终止连接。
现在服务器的 TLS 配置正确验证客户端证书,接着继续在 第 11-20 行 中完成服务器实现。
`--snip--`
serverAddress := "localhost:44443"
server := NewTLSServer(ctx, serverAddress, 0, 1serverConfig)
done := make(chan struct{})
go func() {
err := server.ListenAndServeTLS("serverCert.pem", "serverKey.pem")
if err != nil &&!strings.Contains(err.Error(),
"use of closed network connection") {
t.Error(err)
return
}
done <- struct{}{}
}()
2 server.Ready()
第 11-20 行:启动 TLS 服务器 (tls_mutual_test.go)
创建一个新的 TLS 服务器实例,确保传递刚刚创建的 TLS 配置 1。在一个 goroutine 中调用其 ListenAndServeTLS 方法,并确保在继续之前等待服务器准备好连接 2。
现在,服务器实现准备就绪,让我们继续进行测试的客户端部分。第 11-21 行 实现了一个 TLS 客户端,可以在服务器要求时提交 clientCert.pem。
`--snip--`
clientPool, err := caCertPool(1"serverCert.pem")
if err != nil {
t.Fatal(err)
}
clientCert, err := tls.LoadX509KeyPair("clientCert.pem", "clientKey.pem")
if err != nil {
t.Fatal(err)
}
conn, err := tls.Dial("tcp", serverAddress, &tls.Config{
2 Certificates: []tls.Certificate{clientCert},
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS13,
3 RootCAs: clientPool,
})
if err != nil {
t.Fatal(err)
}
第 11-21 行:将服务器证书固定到客户端 (tls_mutual_test.go)
客户端检索一个新的证书池,其中包含服务器的证书 1。然后客户端在其 TLS 配置的 RootCAs 字段中使用此证书池 3,这意味着客户端仅信任由 serverCert.pem 签名的服务器证书。您还配置客户端使用其自己的证书 2,在服务器请求时提交给服务器。
值得注意的是,客户端和服务器尚未初始化 TLS 会话。它们还没有完成 TLS 握手。如果 tls.Dial 返回错误,这并非因为认证问题,而更可能是由于 TCP 连接问题。让我们继续查看客户端代码以启动握手(参见 Listing 11-22)。
`--snip--`
hello := []byte("hello")
_, err = conn.Write(hello)
if err != nil {
t.Fatal(err)
}
b := make([]byte, 1024)
n, err := 1conn.Read(b)
if err != nil {
t.Fatal(err)
}
if actual := b[:n]; !bytes.Equal(hello, actual) {
t.Fatalf("expected %q; actual %q", hello, actual)
}
err = conn.Close()
if err != nil {
t.Fatal(err)
}
cancel()
<-done
}
Listing 11-22: TLS 握手在你与连接互动时完成(tls_mutual_test.go)
从套接字连接中进行的首次读取或写入操作会自动启动客户端与服务器之间的握手过程。如果服务器拒绝客户端证书,读取调用将返回一个坏证书错误。但如果你创建了合适的证书并正确地进行了固定,那么客户端和服务器都能正常工作,测试也会通过。
你所学到的
传输层安全性(TLS)为客户端与服务器之间提供认证和加密通信。服务器向客户端呈现一个由证书颁发机构签署的证书,作为 TLS 握手过程的一部分。客户端验证证书的签署者。如果证书被客户端信任的第三方签署,那么服务器在客户端眼中就是可信的。从那时起,客户端和服务器将使用对称密钥加密通信。
默认情况下,Go 的 TLS 配置使用操作系统的可信证书存储区。该存储区通常包含来自全球领先的受信任证书颁发机构的证书。然而,我们可以修改 TLS 配置以信任特定的密钥,这一过程被称为密钥固定(key pinning)。
我们还可以修改服务器的 TLS 配置,要求客户端提供证书。然后,服务器会使用该证书以与客户端认证服务器相同的方式来认证客户端。这一过程被称为双向 TLS 认证。
TLS 1.3 为客户端和服务器之间的所有通信提供前向保密性。这意味着,一次会话的泄露不会影响其他任何会话。客户端和服务器为每个会话生成公钥和私钥对。它们还会在握手过程中交换一个临时共享密钥。一旦会话结束,客户端和服务器将删除共享密钥和它们的临时密钥对。一个能够捕获共享密钥和会话流量的攻击者只能解密该会话的流量。攻击者无法使用一个会话的共享密钥来解密其他会话的流量。
尽管 TLS 无处不在,并保护着世界上大量的数字通信,但攻击者仍然能够攻破它。证书颁发机构的部分职责是验证请求特定域名证书的实体是否拥有该域名。如果攻击者欺骗了证书颁发机构,或者证书颁发机构犯了错误并颁发了伪造的证书,那么伪造证书的持有者就可能伪装成谷歌(Google)等,并诱使人们泄露敏感信息。
另一种攻击途径是欺骗客户端将攻击者的证书添加到客户端的受信证书存储中。攻击者随后可以签发并签署任何他们想要的证书,而客户端会天然地信任攻击者所声明的身份。
攻击者还可能攻破服务器,拦截 TLS 会话密钥和机密信息,甚至在服务器解密后捕获应用层的流量。
然而,整体来说,这些攻击是罕见的,TLS 在实现身份验证和加密通信的目标方面是成功的。
第四部分
服务架构
第十二章:数据序列化

作为开发者,我们的工作很大一部分是将我们的网络服务与现有服务进行集成,包括用其他编程语言实现的遗留服务或第三方服务。这些服务必须通过交换数据字节进行通信,这种通信方式对发送方和接收方都能理解,尽管他们使用的是不同的编程语言。为此,发送方使用标准格式将数据转换为字节,并通过网络将字节传输给接收方。如果接收方理解发送方使用的格式,它就能将字节转换回结构化数据。这个将结构化数据转化为连续字节的过程称为数据序列化。
服务可以使用数据序列化将结构化数据转换为适合在网络上传输或持久化到存储的字节序列。无论序列化的数据来自网络还是磁盘,任何理解该序列化格式的代码都应该能够反序列化这些数据,恢复出原始对象的副本。
在写这章时,我一开始很难解释数据序列化的概念。后来我意识到,我们在说话时其实就是在序列化数据。大脑中的电信号形成单词,然后大脑指示声带将这些单词序列化为声波,这些声波穿过空气到达你的耳朵。声波使你的耳膜振动,进而将振动传递到内耳。内耳中的类毛结构将这些振动反序列化为电信号,这些电信号被你的大脑解释为我大脑中形成的原始单词。我们刚刚使用英语这种序列化格式进行了交流,因为这是我们都能理解的格式。
你在 Go 代码中也已经有了一些序列化数据的经验。你在第四章学到的类型-长度-值二进制编码,以及在第八章通过 HTTP 发送的 JavaScript 对象表示法(JSON),都是将对象转化为众所周知的数据序列化格式的例子。在第十一章中,我们还对证书和私钥进行了 PEM 编码,以便将其持久化到磁盘中。
本章将深入探讨如何使用数据序列化来存储数据或在系统之间传输数据,这使得数据可以被用其他语言编写的服务访问。我们可以讨论许多数据序列化格式,但我们将重点讨论 Go 网络编程中最常用的三种格式:JSON、协议缓冲区(protocol buffers)和 Gob。我们还将花一些时间介绍如何使用一个名为 gRPC 的框架在远程机器上执行代码。到本章结束时,你将知道如何序列化数据以便存储或传输,并能够将这些数据解码成有意义的数据结构。你应该能够使用本章中的技术构建可以通过网络交换复杂数据的服务,或者编写代码与现有的网络服务进行通信。
序列化对象
对象或结构化数据不能直接通过网络连接传输。换句话说,不能将对象传递给net.Conn的Write方法,因为它只接受字节切片。因此,你需要将对象序列化为字节切片,然后才能传递给Write方法。幸运的是,Go 让这个过程变得很简单。
Go 的标准库在其encoding包中对流行的数据序列化格式提供了出色的支持。你已经使用了encoding/binary将数字序列化为字节序列,使用encoding/json将对象序列化为 JSON 以便通过 HTTP 提交,使用encoding/pem将 TLS 证书和私钥序列化为文件。(每当你遇到一个函数或方法,其名称中包含encode或marshal时,它可能会进行数据序列化。同样,decode和unmarshal与反序列化数据是同义词。)
本节将构建一个应用程序,将数据序列化为三种二进制编码格式:JSON、协议缓冲区和 Gob。由于我常常难以记住家务,所以这个应用程序将记录需要做的家务。应用程序的状态将在执行之间持久化,因为你不希望它在退出时忘记家务。你将把每个任务序列化到文件中,并使用你的应用程序根据需要更新它。
为了保持程序简洁,你需要一个家务的描述,并且有一种方法来确定它是否已完成。列表 12-1 定义了一个新的包,其中包含一个表示家庭家务的类型。
package housework
type Chore struct {
Complete bool
Description string
}
列表 12-1:表示家庭家务的类型(housework/housework.go)
Go 的 JSON 和 Gob 编码包只能序列化导出的结构体字段,因此你将Chore定义为一个结构体,并确保其字段是导出的。如果你已经完成了家务,Complete字段的值将为true。Description字段是家务的可读描述。
如果需要,你可以使用结构体标签来指示编码器如何处理每个字段。例如,你可以在Complete字段上添加结构体标签`json:"-"`,告诉 Go 的 JSON 编码器忽略该字段,而不是对其进行编码。由于你完全愿意传递所有字段的值,因此可以省略结构体标签。
一旦你定义了家务结构体,你可以在一个应用程序中使用它,跟踪命令行上的家务。这个应用程序应该显示家务的列表及其状态,允许你将家务添加到列表中,并标记家务为已完成。列表 12-2 包括该家务应用程序的初始代码,其中包括其命令行使用细节。
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/awoodbeck/gnp/ch12/housework"
1 storage "github.com/awoodbeck/gnp/ch12/json"
// storage "github.com/awoodbeck/gnp/ch12/gob"
// storage "github.com/awoodbeck/gnp/ch12/protobuf"
)
var dataFile string
func init() {
flag.StringVar(&dataFile, "file", "housework.db", "data file")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(),
2 `Usage: %s [flags] [add chore, ...|complete #]
add add comma-separated chores
complete complete designated chore
Flags:
`, filepath.Base(os.Args[0]))
flag.PrintDefaults()
}
}
列表 12-2:初始家务应用代码(cmd/housework.go)
这段代码设置了命令行参数及其用法 2:你可以指定add参数,后跟以逗号分隔的任务列表来添加任务,或者你可以传递complete参数和任务编号来标记任务为完成。如果没有命令行选项,应用程序将显示当前的任务列表。
由于此应用程序的最终目的是演示数据序列化,你将使用多种序列化格式来存储数据。这将向你展示如何轻松地在不同格式之间切换。为此,你包括了这些格式的import语句 1。这将使你以后更容易在这些格式之间切换。
让我们编写代码从存储中加载任务(参见列表 12-3)。
`--snip--`
func load() ([]*housework.Chore, error) {
if _, err := os.Stat(dataFile); 1os.IsNotExist(err) {
return make([]*housework.Chore, 0), nil
}
df, err := 2os.Open(dataFile)
if err != nil {
return nil, err
}
defer func() {
if err := df.Close(); err != nil {
fmt.Printf("closing data file: %v", err)
}
}()
return 3storage.Load(df)
}
列表 12-3:从文件中反序列化任务 (cmd/housework.go)
该函数返回一个指向housework.Chore结构体的指针切片,来自列表 12-1。如果数据文件不存在 1,你将提前退出并返回一个空切片。这个默认情况会在你第一次运行应用程序时发生。
如果应用程序找到数据文件,你将打开它 2,并将其传递给存储的Load函数 3,后者期望一个io.Reader。你在前几章中使用了相同的接受接口并返回具体类型的模式。
列表 12-4 定义了一个函数,用于将内存中的任务刷新到存储中以便持久化。
`--snip--`
func flush(chores []*housework.Chore) error {
df, err := 1os.Create(dataFile)
if err != nil {
return err
}
defer func() {
if err := df.Close(); err != nil {
fmt.Printf("closing data file: %v", err)
}
}()
return 2storage.Flush(df, chores)
}
列表 12-4:将任务刷新到存储中 (cmd/housework.go)
在这里,你创建一个新文件或截断现有文件 1,并将文件指针和任务切片传递给存储的Flush函数 2。该函数接受一个io.Writer和你的切片。你处理现有序列化文件的方式当然还有改进的空间。但为了演示的目的,这样已经足够。
你需要一种在命令行上显示任务的方式。列表 12-5 向你的应用程序添加了这样一个函数。
`--snip--`
func list() error {
chores, err := 1load()
if err != nil {
return err
}
if len(chores) == 0 {
fmt.Println("You're all caught up!")
return nil
}
fmt.Println("#\t[X]\tDescription")
for i, chore := range chores {
c := " "
if chore.Complete {
c = "X"
}
fmt.Printf("%d\t[%s]\t%s\n", i+1, c, chore.Description)
}
return nil
}
列表 12-5:将任务列表打印到标准输出 (cmd/housework.go)
首先,你从存储中加载任务列表 1。如果你的列表中没有任务,你只需将其打印到标准输出。否则,你将打印一个标题和任务列表,类似于这样(参见列表 12-6)。
# [X] Description
1 [ ] Mop floors
2 [ ] Clean dishes
3 [ ] Mow the lawn
列表 12-6:任务列表函数的示例输出,列表中有三个任务
第一列表示任务编号。你可以参考此编号来标记任务为完成,这将在第二列的方括号之间添加一个 X。第三列描述了该任务。
列表 12-7 实现了add函数,允许你向列表中添加任务。
`--snip--`
func add(s string) error {
chores, err := 1load()
if err != nil {
return err
}
for _, chore := range 2strings.Split(s, ",") {
if desc := strings.TrimSpace(chore); desc != "" {
chores = append(chores, &housework.Chore{
Description: desc,
})
}
}
return 3flush(chores)
}
列表 12-7:向任务列表中添加任务 (cmd/housework.go)
与长时间运行的服务不同,这个应用程序的生命周期从你在命令行执行它时开始,到你要求它完成任务时结束。因此,因为你希望家务任务列表在应用程序执行之间得以持久化,你需要将家务任务的状态存储在磁盘上。换句话说,你从存储中检索家务任务,修改它们,然后将更改刷新到存储中。更改将持续存在,直到下次运行应用程序时。
你希望能够一次添加多个家务任务,因此你通过逗号将传入的家务描述进行分割,并将每个家务任务添加到切片中。当然,这样做会使你不能在单个家务描述中使用逗号,因此家庭成员必须将他们的请求简短些(我个人认为这也不全是坏事)。作为练习,想想如何解决这个限制。一个方法是使用不同的分隔符,但请记住,选择的分隔符在命令行中可能有特殊意义。另一种方法是支持包含逗号的带引号字符串。
这块拼图的最后一部分是我最喜欢的关于处理家务任务的部分:将它们标记为完成(见列表 12-8)。
`--snip--`
func complete(s string) error {
i, err := strconv.Atoi(s)
if err != nil {
return err
}
chores, err := load()
if err != nil {
return err
}
if i < 1 || i > len(chores) {
return fmt.Errorf("chore %d not found", i)
}
1 chores[i-1].Complete = true
return flush(chores)
}
列表 12-8:标记家务任务为完成(cmd/housework.go)
complete函数接受代表你想完成的家务任务的命令行参数,并将其转换为整数。我发现如果我一个个任务地完成,效率会更高,所以我让你一次只标记一个任务完成。然后,你从存储中加载家务任务,确保整数在有效范围内。如果是,你就将该家务任务标记为完成。由于你在显示列表时是从 1 开始编号家务任务,所以需要通过减去 1 来调整切片中的位置。最后,你将家务任务刷新到存储中。
现在,让我们通过实现应用程序的main函数来将一切连接在一起,参见列表 12-9。
`--snip--`
func main() {
flag.Parse()
var err error
switch strings.ToLower(flag.Arg(0)) {
case "add":
err = add(strings.Join(flag.Args()[1:], " "))
case "complete":
err = complete(flag.Arg(1))
}
if err != nil {
log.Fatal(err)
}
err = list()
if err != nil {
log.Fatal(err)
}
}
列表 12-9:家务应用程序的主要逻辑(cmd/housework.go)
你已经尽可能地将逻辑放入之前的函数中,因此这个main函数相当简洁。你检查第一个参数,以确定它是否是预期的子命令。如果是,你调用相应的函数。如果在考虑了可选子命令及其参数后,err依然为 nil,则调用list函数。
现在剩下的就是为 JSON、Gob 和协议缓冲区实现存储的Load和Flush函数。
JSON
JSON 是一种常见的、人类可读的基于文本的数据序列化格式,使用键值对和数组表示复杂的数据结构。大多数现代编程语言都提供对 JSON 的官方库支持,这也是它成为 RESTful API 常用编码格式的原因之一。
JSON 的类型包括字符串、布尔值、数字、数组、键值对对象和由关键字 null 指定的 nil 值。JSON 数字不区分浮点数和整数。你可以在 blog.golang.org/json 阅读更多关于 Go 中 JSON 实现的内容。
让我们看看如果我们将示例 12-6 中的家务事序列化为 JSON,housework.db 文件的内容会是什么样子。我已经在示例 12-10 中将 JSON 格式化为便于阅读的方式,尽管你也可以使用编码器的 SetIndent 方法来完成此操作。
1[
2 {
"Complete": false,
"Description": "Mop floors"
},
{
"Complete": false,
"Description": "Clean dishes"
},
{
"Complete": false,
"Description": "Mow the lawn"
}
]
示例 12-10:将家务事序列化为 JSON 后的 housework.db 文件格式化内容
如你所见,JSON 是一个包含对象的数组 1,每个对象 2 包含 Complete 和 Description 字段及其相应的值。
示例 12-11 详细介绍了使用 Go 的 encoding/json 包实现的 JSON 存储。
package json
import (
"encoding/json"
"io"
"github.com/awoodbeck/gnp/ch12/housework"
)
func Load(r io.Reader) ([]*housework.Chore, error) {
var chores []*housework.Chore
return chores, 1json.NewDecoder(r).Decode(&chores)
}
func Flush(w io.Writer, chores []*housework.Chore) error {
return 2json.NewEncoder(w).Encode(chores)
}
示例 12-11:JSON 存储实现 (json/housework.go)
Load 函数将 io.Reader 传递给 json.NewDecoder 函数 1,并返回一个解码器。然后,你调用解码器的 Decode 方法,并传入指向 chores 切片的指针。解码器从 io.Reader 读取 JSON,进行反序列化,并填充 chores 切片。
Flush 函数接受一个 io.Writer 和一个 chores 切片。然后它将 io.Writer 传递给 json.NewEncoder 函数 2,后者返回一个编码器。你将 chores 切片传递给编码器的 Encode 方法,该方法将 chores 切片序列化为 JSON 并写入 io.Writer。
现在你已经实现了一个可以作为应用存储的 JSON 包,我们来在示例 12-12 中试试它。
$ **go run cmd/housework.go**
You're all caught up!
$ **go run cmd/housework.go add Mop floors, Clean dishes, Mow the lawn**
# [X] Description
1 [ ] Mop floors
2 [ ] Clean dishes
3 [ ] Mow the lawn
$ **go run cmd/housework.go complete 2**
# [X] Description
1 [ ] Mop floors
2 [X] Clean dishes
3 [ ] Mow the lawn
$ **cat housework.db**
[{"Complete":false,"Description":"Mop floors"},
{"Complete":true,"Description":"Clean dishes"},
{"Complete":false,"Description":"Mow the lawn"}]
示例 12-12:在命令行上测试带有 JSON 存储的家务应用
你第一次运行应用时,会发现你的家务列表是空的。然后,你添加了三个逗号分隔的家务事,并完成了第二个。看起来不错。还要注意,housework.db 文件包含可读的 JSON(在 Windows 上查看时,使用 type 命令而不是 cat)。让我们修改这个应用,改用 Go 原生的二进制编码格式。
Gob
Gob(“大量二进制数据”的缩写)是 Go 的原生二进制序列化格式。Go 团队的工程师们开发了 Gob,旨在将协议缓冲(可能是最流行的二进制序列化格式)的高效性与 JSON 的易用性结合起来。例如,协议缓冲不允许我们像在示例 12-11 中的 JSON 示例那样,简单地实例化一个新的编码器并将数据结构传递给它。另一方面,Gob 的工作方式与 JSON 编码器非常相似,它可以智能地推断对象的结构并进行序列化。
如果你有兴趣探索 Gob 的动机和细节,建议阅读 Rob Pike 的《Gobs of Data》博客文章(blog.golang.org/gob)。与此同时,我们来实现一个基于 Gob 的存储后端(参见 Listing 12-13)。
package gob
import (
"encoding/gob"
"io"
"github.com/awoodbeck/gnp/ch12/housework"
)
func Load(r io.Reader) ([]*housework.Chore, error) {
var chores []*housework.Chore
return chores, gob.NewDecoder(r).Decode(&chores)
}
func Flush(w io.Writer, chores []*housework.Chore) error {
return gob.NewEncoder(w).Encode(chores)
}
Listing 12-13:Gob 存储实现 (gob/housework.go)
如果你查看这段代码,并注意到它将 Listing 12-11 中所有出现的 json 替换为 gob,你并没有错。在 Go 中,使用 Gob 就像使用 JSON 一样简单,因为它会根据对象本身推断出需要编码的内容。你将在下一节看到,这与协议缓冲区(protocol buffers)有何不同。
剩下的工作就是通过修改 Listing 12-2 中的导入来将 JSON 存储实现替换为 Gob 实现(参见 Listing 12-14)。
`--snip--`
"github.com/awoodbeck/gnp/ch12/housework"
1 // storage "github.com/awoodbeck/gnp/ch12/json"
2 storage "github.com/awoodbeck/gnp/ch12/gob"
// storage "github.com/awoodbeck/gnp/ch12/protobuf"
`--snip--`
Listing 12-14:将 JSON 存储包替换为 Gob 存储包 (cmd/housework.go)
注释掉 Listing 12-2 中的 JSON 存储包导入 1,并取消注释 Gob 存储包导入 2。
由于你当前的 housework.db 文件包含 JSON 格式,因此与 Gob 不兼容。因此,尝试使用 Gob 解码时,housework 应用程序将抛出错误。删除 housework.db 文件并重新测试应用程序(参见 Listing 12-15)。
$ **rm housework.db**
$ **go run cmd/housework.go**
You're all caught up!
$ **go run cmd/housework.go add Mop floors, Clean dishes, Mow the lawn**
# [X] Description
1 [ ] Mop floors
2 [ ] Clean dishes
3 [ ] Mow the lawn
$ **go run cmd/housework.go complete 2**
# [X] Description
1 [ ] Mop floors
2 [X] Clean dishes
3 [ ] Mow the lawn
$ **hexdump -c housework.db**
0000000 \r 377 203 002 001 002 377 204 \0 001 377 202 \0 \0 ) 377
0000010 201 003 001 002 377 202 \0 001 002 001 \b C o m p l
0000020 e t e 001 002 \0 001 \v D e s c r i p t
0000030 i o n 001 \f \0 \0 \0 1 377 204 \0 003 002 \n M
0000040 o p f l o o r s \0 001 001 001 \f C l
0000050 e a n d i s h e s \0 002 \f M o w
0000060 t h e l a w n \0
000006a
Listing 12-15:在命令行上测试使用 Gob 存储的 housework 应用程序
一切仍按预期工作。使用 hexdump 工具,你可以看到 housework.db 文件现在包含了二进制数据。虽然它不像 Listing 12-12 中的 JSON 那样可读,但 Go 会愉快地反序列化 Gob 编码的数据,尽管我们很难理解它。(我的 Windows 朋友可以在 https://www.di-mgt.com.au/hexdump-for-windows.html 找到 hexdump 的二进制文件,尽管你需要使用 -C 标志才能看到相同的效果。)
如果你与其他支持 Gob 的 Go 服务进行通信,我建议你使用 Gob 而不是 JSON。Go 的 encoding/gob 比 encoding/json 更高效。Gob 编码也更简洁,因为 Gob 使用比 JSON 更少的数据来表示对象。当存储或传输序列化数据时,这一点可能会带来差异。
现在你已经体验了使用 encoding/json 和 encoding/gob 来序列化数据,我们来为你的存储后端添加协议缓冲区支持。
协议缓冲区(Protocol Buffers)
像 Gob 一样,协议缓冲区使用二进制编码来存储或交换跨平台的信息。它比 Go 的 JSON 编码更快、更简洁。与 Gob 不同,像 JSON 一样,协议缓冲区是语言中立的,并且在流行的编程语言中得到了广泛支持。这使得它们非常适合在基于 Go 的服务中使用,尤其是当你希望与其他编程语言编写的服务集成时。本章假设你使用的是 proto3 版本的格式。
协议缓冲区使用定义文件,通常以.proto为后缀,来定义消息。消息描述了你想要序列化以便存储或传输的结构化数据。例如,表示Chore类型的协议缓冲区消息如下所示,在 Listing 12-16 中有定义。
message Chore {
bool complete = 1;
string description = 2;
}
Listing 12-16:表示Chore类型的协议缓冲区消息定义
你通过使用message关键字定义一个新的消息,后跟消息的唯一名称。惯例是使用 Pascal 大小写(Pascal casing),它是一种代码格式化风格,其中将单词首字母大写并连接在一起:ThisIsPascalCasing。然后,你可以为Chore消息添加字段。每个字段定义包括类型、一个蛇形命名(snake casing)的名称和一个该消息唯一的字段编号。蛇形命名就像 Pascal 大小写,只不过第一个单词是小写的:thisIsSnakeCasing。字段的类型和编号标识了二进制负载中的字段,因此一旦使用,这些字段不得更改,否则会破坏向后兼容性。然而,可以向现有的.proto文件中添加新的消息和消息字段。
说到向后兼容性,最佳实践是将协议缓冲区定义当作 API 来处理。如果第三方使用你的协议缓冲区定义与服务进行通信,考虑为你的定义进行版本控制;这样,你就可以在需要打破向后兼容性时创建新版本。你的开发可以继续进行最新版本,而客户端则可以继续使用之前的版本,直到他们准备好升级到最新版本。你将在本节稍后看到一种协议缓冲区定义版本控制的方法。
你需要编译.proto文件以生成 Go 代码。该代码允许你序列化和反序列化在.proto文件中定义的消息。希望与你交换消息的第三方可以使用相同的.proto文件为其目标编程语言生成代码。生成的代码也可以与你交换消息。因此,在你开始认真使用协议缓冲区之前,必须安装协议缓冲区编译器及其 Go 生成模块。你的操作系统的包管理器可能允许你轻松安装协议缓冲区编译器。例如,在 Debian 10 上,运行以下命令:
$ **sudo apt install protobuf-compiler**
在 macOS 上使用 Homebrew,运行以下命令:
$ **brew install protobuf**
在 Windows 上,从github.com/protocolbuffers/protobuf/releases/下载最新的协议缓冲区编译器 ZIP 文件,解压后将其bin子目录添加到你的PATH中。现在你应该能够在命令行上运行protoc二进制文件了。
一个简单的go get命令将会在你的系统上安装协议缓冲区的 Go 生成器。确保你将生成的protoc-gen-go二进制文件添加到PATH中,否则protoc无法识别该插件:
$ **GO111MODULE=on go get -u github.com/golang/protobuf/protoc-gen-go**
现在你已经安装了协议缓冲区编译器和 Go 生成模块,让我们为你的家务应用创建一个新的.proto文件(见清单 12-17)。你将在housework/v1/housework.proto中创建这个文件。路径中的v1表示版本 1,并允许你在将来引入该包的不同版本。
1 syntax = "proto3";
2 package housework;
3 option go_package = "github.com/awoodbeck/gnp/ch12/housework/v1/housework";
message Chore {
bool complete = 1;
string description = 2;
}
message Chores {
4repeated Chore chores = 1;
}
清单 12-17:你家务应用的协议缓冲区定义(housework/v1/housework.proto)
首先,你指定使用 proto3 语法 1,并希望生成的任何代码都使用housework作为包名 2。接着,你添加一个go_package选项 3,指定生成模块的完整导入路径。然后你定义Chore消息,以及第二个消息Chores,它表示一个基于repeated字段类型 4 的Chore消息集合。
现在,让我们将.proto文件编译成 Go 代码:
$ **protoc** 1**--go_out=.** 2**--go_opt=paths=source_relative housework/v1/housework.proto**
你使用带有标志的protoc命令,表明你希望从清单 12-17 中创建的housework/v1/housework.proto文件生成 Go 代码,并将生成的代码输出到.proto文件的当前目录,使用相对路径 2。
如果你收到以下错误,提示protoc找不到protoc-gen-go二进制文件,请确保protoc-gen-go的路径(可能是$GOPATH/bin)已加入到PATH环境变量中:
protoc-gen-go: program not found or is not executable
--go_out: protoc-gen-go: Plugin failed with status code 1.
如果protoc对.proto文件没有问题,并成功生成了 Go 代码,你会发现一个名为housework/v1/housework.pb.go的新文件,虽然版本号可能有所不同。我将在 Linux/macOS 上使用head命令打印出前七行:
$ **head -n 7 housework/v1/housework.pb.go**
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.25.0
// protoc v3.6.1
// source: housework/v1/housework.proto
package housework
正如注释所示,你不应编辑此模块。相反,应对.proto文件进行必要的修改并重新编译。
现在,你已经从.proto文件生成了一个 Go 模块,可以通过在清单 12-18 中实现协议缓冲区存储后端来有效利用它。
package protobuf
import (
"io"
"io/ioutil"
"google.golang.org/protobuf/proto"
1 "github.com/awoodbeck/gnp/ch12/housework/v1"
)
func Load(r io.Reader) ([]*housework.Chore, error) {
b, err := ioutil.ReadAll(r)
if err != nil {
return nil, err
}
var chores housework.Chores
return chores.Chores, proto.Unmarshal(b, &chores)
}
func Flush(w io.Writer, chores []*housework.Chore) error {
b, err := proto.Marshal(2&housework.Chores{Chores: chores})
if err != nil {
return err
}
_, err = w.Write(b)
return err
}
清单 12-18:协议缓冲区存储实现(protobuf/housework.go)
你不再像使用 JSON 和 Gob 时那样依赖清单 12-1 中的housework包,而是导入protoc生成的版本 1 包,你也将其命名为housework。生成的Chores类型是一个结构体,包含一个Chores字段,Chores本身是Chore指针的切片。此外,Go 的协议缓冲区包并未实现编码器和解码器。因此,你需要手动将对象序列化为字节,写入到io.Writer中,并从io.Reader中反序列化字节。
回顾清单 12-2 中的代码,并插入协议缓冲区实现,只需做出清单 12-19 中显示的两个简单更改。
`--snip--`
1 "github.com/awoodbeck/gnp/ch12/housework/v1"
// storage "github.com/awoodbeck/gnp/ch12/json"
// storage "github.com/awoodbeck/gnp/ch12/gob"
2 storage "github.com/awoodbeck/gnp/ch12/protobuf"
`--snip--`
清单 12-19:将 JSON 存储包替换为protobuf存储包(cmd/housework.go)
你将从清单 12-1 中替换掉家务包,改为你生成的包 1,确保注释掉 json 和 gob 导入,取消注释 protobuf 存储导入 2。家务应用的实际功能保持不变。
传输序列化对象
虽然有时你可能需要序列化并本地存储对象,但你更有可能构建一个序列化数据的网络服务。例如,一个在线商店可能会有一个 web 服务,和库存、用户账户、账单、运输和通知服务进行通信,以便促进客户订单。如果这些服务都运行在同一台服务器上,你就需要购买更大的服务器,以便随着业务增长来扩展在线商店。另一种方法是将每个服务都运行在独立的服务器上,并增加服务器的数量。但这样你会面临一个新问题:当这些服务不再位于同一台服务器上,无法访问相同的内存时,你该如何在它们之间共享数据?
大型科技公司通过 远程过程调用 (RPC) 来简化这个过程,RPC 是一种技术,客户端可以透明地调用服务器上的子程序,就像这个调用是本地的一样。从应用程序的角度来看,RPC 服务将看似本地运行的代码分发到网络上。你的代码可能会调用一个函数,该函数透明地将消息转发到服务器。服务器会在本地执行该函数,然后返回结果,代码收到这些结果作为函数的返回值。就你的代码而言,函数调用是本地的,尽管 RPC 与服务器的交互是透明的。这种方法允许你在服务器之间扩展服务,同时将细节抽象化,不需要在代码中处理。换句话说,无论函数调用是在同一台计算机上执行,还是在网络上的另一台计算机上执行,你的代码的表现都一样。
现在大多数公司都使用 gRPC 来实现 RPC,这是一个跨平台框架,利用了 HTTP/2 和协议缓冲区。我们在这里使用它来构建比一个记录你尚未做的家务的应用程序更复杂的东西。你将编写一个服务,能够将任务发送给 Rosie,这个来自经典动画系列 杰森一家 的机器人女佣,她能接管你的家务责任。当然,她要到 2062 年才能问世,但你可以提前开始编写代码。
使用 gRPC 连接服务
gRPC 框架是一个库集合,抽象了许多 RPC 实现的细节。它是平台中立的,并且对编程语言无关;例如,你可以使用它将运行在 Windows 上的 Go 服务与运行在 Linux 上的 Rust 服务集成。现在你已经知道如何使用协议缓冲区,你有了向你的应用程序添加 gRPC 支持所需的基础。你将复用上一节中的 .proto 文件。
首先,确保你的 gRPC 包是最新的:
$ **go get -u google.golang.org/grpc**
接下来,获取生成 gRPC Go 代码所需的模块:
$ **go get -u google.golang.org/grpc/cmd/protoc-gen-go-grpc**
协议缓冲区编译器包含一个 gRPC 模块。此模块将输出 Go 代码,使你可以轻松添加 gRPC 支持。首先,你需要向.proto文件中添加定义。Listing 12-20 定义了一个新服务和两个额外的消息。
`--snip--`
service RobotMaid {
1 rpc Add (Chores) returns (Response);
rpc Complete (CompleteRequest) returns (Response);
rpc List (Empty) returns (Chores);
}
message CompleteRequest {
int32 2chore_number = 1;
}
3 message Empty {}
message Response {
string message = 1;
}
Listing 12-20:支持 gRPC RobotMaid 服务的附加定义(housework/v1/housework.proto)
该服务需要支持你在命令行中使用的相同三种调用:add、complete和list。你定义了一个名为RobotMaid的新服务,然后向其添加了三个 RPC 方法。这些 RPC 方法分别对应于 Listing 12-5、12-7 和 12-8 中定义的函数,用于命令行:分别是list、add和complete函数。你将不再在本地调用这些函数,而是通过 RPC 调用RobotMaid上的相应方法来执行这些命令。你将每个方法前缀加上rpc关键字,并跟随 Pascal 命名法的函数名。接下来,你在括号中编写请求消息类型,使用returns关键字,并在括号中编写返回消息类型 1。
List方法不需要任何用户输入,但与命令行应用程序中的情况一样,你仍然必须为它提供一个请求消息类型,即使它是 nil。在 gRPC 中,等同于 nil 的消息类型是一个空消息,你称之为Empty3。
在你有机会为机器人添加适当的人工智能(AI)之前,你需要告诉 Rosie 当前的家务活已完成,以便她可以继续下一个任务。为此,你添加了一个新消息,通知她已完成的家务活编号 2。由于你期望从 Rosie 那里获得反馈,你还添加了一个响应消息,包含一个字符串。
现在编译.proto文件,以使用新的服务和消息。告诉protoc使用protoc-gen-go-grpc二进制文件,它也必须位于你的PATH环境变量中,如下所示:
$ **protoc** 1**--go-grpc_out=.** 2--go-grpc_opt=paths=source_relative \
**housework/v1/housework.proto**
--go-grpc_out标志 1 调用了protoc-gen-go-grpc二进制文件,为生成的代码添加 gRPC 支持。该二进制文件为你生成相关的 gRPC 服务代码,并将 gRPC 特定的代码写入housework/v1/housework_grpc.pb.go文件,因为你告诉protoc-gen-go-grpc使用相对路径 2。现在,你可以使用生成的代码来构建 gRPC 服务器和客户端。
创建一个启用 TLS 的 gRPC 服务器
现在,让我们实现一个 gRPC 客户端和服务器。默认情况下,gRPC 需要安全连接,因此你需要为服务器添加 TLS 支持。你将使用上一章中创建的服务器的cert.pem和key.pem文件作为你的 gRPC 服务器,并将服务器的证书固定到客户端。有关详细信息,请参阅第 256 页的“生成用于身份验证的证书”部分。
你将利用通过 .proto 文件生成的 Go 代码来定义一个新的 RobotMaid 客户端和服务器,并使用客户端通过网络与服务器进行 gRPC 通信。首先,让我们创建你机器人女仆的服务器部分。由你的 .proto 文件生成的 RobotMaidServer 接口如下所示:
type RobotMaidServer interface {
Add(context.Context, *Chores) (*Response, error)
Complete(context.Context, *CompleteRequest) (*Response, error)
List(context.Context, *empty.Empty) (*Chores, error)
mustEmbedUnimplementedRobotMaidServer()
}
你将在清单 12-21 中通过创建一个名为 Rosie 的新类型来实现这个接口。
package main
import (
"context"
"fmt"
"sync"
"github.com/awoodbeck/gnp/ch12/housework/v1"
)
type Rosie struct {
mu sync.Mutex
1 chores []*housework.Chore
}
func (r *Rosie) Add(_ context.Context, chores *housework.Chores) (
*housework.Response, error) {
r.mu.Lock()
r.chores = append(r.chores, chores.Chores...)
r.mu.Unlock()
return 2&housework.Response{Message: "ok"}, nil
}
func (r *Rosie) Complete(_ context.Context,
req *housework.CompleteRequest) (*housework.Response, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.chores == nil || req.ChoreNumber < 1 ||
int(req.ChoreNumber) > len(r.chores) {
return nil, fmt.Errorf("chore %d not found", req.ChoreNumber)
}
r.chores[req.ChoreNumber-1].Complete = true
return &housework.Response{Message: "ok"}, nil
}
func (r *Rosie) List(_ context.Context, _ *housework.Empty) (
*housework.Chores, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.chores == nil {
r.chores = make([]*housework.Chore, 0)
}
return &housework.Chores{Chores: r.chores}, nil
}
func (r *Rosie) Service() *housework.RobotMaidService {
return 3&housework.RobotMaidService{
Add: r.Add,
Complete: r.Complete,
List: r.List,
}
}
清单 12-21:构建一个与 RobotMaid 兼容的类型,命名为 Rosie (server/rosie.go)
新的 Rosie 结构体将它的家务列表保存在内存中 1,并由互斥锁保护,因为多个客户端可以同时使用该服务。Add、Complete 和 List 方法都返回一个响应消息类型 2 或错误,两者最终都会返回给客户端。Service 方法返回指向一个新的 housework.RobotMaidService 实例的指针 3,Rosie 的 Add、Complete 和 List 方法会映射到新实例的相应方法。
现在,让我们通过使用 Rosie 结构体来设置一个新的 gRPC 服务器实例 (清单 12-22)。
package main
import (
"crypto/tls"
"flag"
"fmt"
"log"
"net"
"google.golang.org/grpc"
"github.com/awoodbeck/gnp/ch12/housework/v1"
)
var addr, certFn, keyFn string
func init() {
flag.StringVar(&addr, "address", "localhost:34443", "listen address")
flag.StringVar(&certFn, "cert", "cert.pem", "certificate file")
flag.StringVar(&keyFn, "key", "key.pem", "private key file")
}
func main() {
flag.Parse()
server := 1grpc.NewServer()
rosie := new(Rosie)
2housework.RegisterRobotMaidServer(server, 3rosie.Service())
cert, err := tls.LoadX509KeyPair(certFn, keyFn)
if err != nil {
log.Fatal(err)
}
listener, err := net.Listen("tcp", addr)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Listening for TLS connections on %s ...", addr)
log.Fatal(
4 server.Serve(
5 tls.NewListener(
listener,
&tls.Config{
Certificates: []tls.Certificate{cert},
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS12,
PreferServerCipherSuites: true,
},
),
),
)
}
清单 12-22:使用 Rosie 创建一个新的 gRPC 服务器 (server/server.go)
首先,你获取一个新的服务器实例 1。你将它和来自 Rosie 的 Service3 方法返回的新 *housework.RobotMaidService 传递给生成的 gRPC 代码中的 RegisterRobotMaidServer 函数 2。这将 Rosie 的 RobotMaidService 实现注册到 gRPC 服务器上。在调用服务器的 Serve 方法 4 之前,必须执行此操作。然后,你加载服务器的密钥对并创建一个新的 TLS 监听器 5,调用 Serve 时将其传递给服务器。
现在你已经有了一个 gRPC 服务器实现,让我们来处理客户端部分。
创建一个 gRPC 客户端来测试服务器
客户端代码与你在第 270 页“对象序列化”部分编写的代码差别不大。主要的区别是你需要实例化一个新的 gRPC 客户端,并修改 add、complete 和 list 函数来使用它。记住,如果编程语言支持 protobuf,你可以用它来实现客户端部分,并且可以为目标语言从 .proto 文件生成代码,期望它能与清单 12-22 中的服务器无缝协作。
清单 12-23 详细说明了清单 12-2 中对家务应用程序添加 gRPC 支持所需的更改。
package main
import (
1 "context"
2 "crypto/tls"
3 "crypto/x509"
"flag"
"fmt"
4 "io/ioutil"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"github.com/awoodbeck/gnp/ch12/housework/v1"
)
var addr, caCertFn string
func init() {
5 flag.StringVar(&addr, "address", "localhost:34443", "server address")
6 flag.StringVar(&caCertFn, "ca-cert", "cert.pem", "CA certificate")
flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(),
`Usage: %s [flags] [add chore, ...|complete #]
add add comma-separated chores
complete complete designated chore
Flags:
`, filepath.Base(os.Args[0]))
flag.PrintDefaults()
}
}
清单 12-23:我们的家务应用程序的初始 gRPC 客户端代码 (client/client.go)
除了所有新的导入 1234,你还添加了 gRPC 服务器地址 5 和证书 6 的标志。
清单 12-24 使用 gRPC 客户端列出当前的家务。
`--snip--`
func list(ctx context.Context, client housework.RobotMaidClient) error {
chores, err := client.List(ctx, 1new(housework.Empty))
if err != nil {
return err
}
if len(chores.Chores) == 0 {
fmt.Println("You have nothing to do!")
return nil
}
fmt.Println("#\t[X]\tDescription")
for i, chore := range chores.Chores {
c := " "
if chore.Complete {
c = "X"
}
fmt.Printf("%d\t[%s]\t%s\n", i+1, c, chore.Description)
}
return nil
}
清单 12-24:使用 gRPC 客户端列出当前家务 (client/client.go)
这段代码与清单 12-5 非常相似,唯一不同的是你请求 gRPC 客户端获取任务列表,客户端从服务器中检索这些任务。你需要传递一个空的消息,以让 gRPC 正常工作 1。
清单 12-25 使用 gRPC 客户端将新任务添加到 gRPC 服务器的任务列表中。
`--snip--`
func add(ctx context.Context, client housework.RobotMaidClient,
s string) error {
chores := new(housework.Chores)
for _, chore := range strings.Split(s, ",") {
if desc := strings.TrimSpace(chore); desc != "" {
chores.Chores = append(chores.Chores, &housework.Chore{
Description: desc,
})
}
}
var err error
if len(chores.Chores) > 0 {
_, 1err = client.Add(ctx, chores)
}
return err
}
清单 12-25:使用 gRPC 客户端添加新任务(client/client.go)
就像在前一节中那样,你解析了逗号分隔的任务列表。不同的是,这些任务并没有被刷新到磁盘,而是被传递给了 gRPC 客户端。gRPC 客户端透明地将它们发送到 gRPC 服务器,并将响应返回给你。由于你知道当Add调用失败时Rosie会返回一个非 nil 的错误,因此你将错误 1 作为add函数的返回值。
在清单 12-26 中,你编写了标记任务完成的代码。通过 gRPC 执行这项操作比清单 12-8 的代码少一些,因为大部分逻辑都在服务器端。
`--snip--`
func complete(ctx context.Context, client housework.RobotMaidClient,
s string) error {
i, err := strconv.Atoi(s)
if err == nil {
_, err = client.Complete(ctx,
&housework.CompleteRequest{1ChoreNumber: int32(i)})
}
return err
}
清单 12-26:使用 gRPC 客户端标记任务完成(client/client.go)
注意protoc-gen-go模块,它将清单 12-20 中的蛇形命名的chore_number字段转换为 Pascal 命名法,生成 Go 代码 1。你还必须在将其赋值给完整请求消息的任务编号之前,将strconv.Atoi返回的int转换为int32,因为ChoreNumber是int32类型。
清单 12-27 创建了一个新的 gRPC 连接,并将服务器的证书固定到其 TLS 配置中。
`--snip--`
func main() {
flag.Parse()
caCert, err := ioutil.ReadFile(caCertFn)
if err != nil {
log.Fatal(err)
}
certPool := x509.NewCertPool()
if ok := certPool.AppendCertsFromPEM(caCert); !ok {
log.Fatal("failed to add certificate to pool")
}
conn, err := 1grpc.Dial(
addr,
2 grpc.WithTransportCredentials(
3 credentials.NewTLS(
&tls.Config{
CurvePreferences: []tls.CurveID{tls.CurveP256},
MinVersion: tls.VersionTLS12,
RootCAs: certPool,
},
),
),
)
if err != nil {
log.Fatal(err)
}
清单 12-27:使用 TLS 和证书固定创建一个新的 gRPC 连接(client/client.go)
在客户端,你首先创建一个新的 gRPC 网络连接 1,然后使用该网络连接实例化一个新的 gRPC 客户端。对于大多数用例,你可以简单地将地址传递给grpc.Dial。但你希望将服务器的证书固定到客户端连接中。因此,你需要明确传递一个grpc.DialOption,并附上适当的 TLS 凭证。这涉及使用grpc.WithTransportCredentials函数 2 来返回grpc.DialOption,并使用credentials.NewTLS函数 3 来根据你的 TLS 配置创建传输凭证。结果是一个 gRPC 网络连接,它与服务器进行 TLS 通信,并通过使用固定的证书来验证服务器。
你在清单 12-28 中使用这个 gRPC 网络连接实例化了一个新的 gRPC 客户端。
`--snip--`
rosie := 1housework.NewRobotMaidClient(conn)
ctx := context.Background()
switch strings.ToLower(flag.Arg(0)) {
case "add":
err = add(ctx, rosie, strings.Join(flag.Args()[1:], " "))
case "complete":
err = complete(ctx, rosie, flag.Arg(1))
}
if err != nil {
log.Fatal(err)
}
err = list(ctx, rosie)
if err != nil {
log.Fatal(err)
}
}
清单 12-28:实例化一个新的 gRPC 客户端并进行调用(client/client.go)
除了从 gRPC 网络连接 1 实例化一个新的 gRPC 客户端外,这段代码与清单 12-9 的差异不大。区别当然在于,任何与任务列表的交互都透明地通过 TLS 连接与 gRPC 服务器进行。
尝试一下。在一个终端中启动服务器:
$ **go run server/server.go server/rosie.go -cert server/cert.pem -key server/key.pem**
Listening for TLS connections on localhost:34443 ...
在另一个终端中,运行客户端:
$ **go run client/client.go -ca-cert server/cert.pem**
You have nothing to do!
$ **go run client/client.go -ca-cert server/cert.pem add Mop floors, Wash dishes**
# [X] Description
1 [ ] Mop floors
2 [ ] Wash dishes
$ **go run client/client.go -ca-cert server/cert.pem complete 2**
# [X] Description
1 [ ] Mop floors
2 [X] Wash dishes
当然,重启服务器会清空任务列表。我将这作为练习留给你,去实现服务器上的持久化存储。一个方法是让服务器从磁盘加载任务并将任务刷新到磁盘,就像你在本章之前所做的那样。
你所学到的
数据序列化允许你以平台中立和语言中立的方式交换数据。你还可以序列化数据进行长期存储,检索并反序列化数据,然后继续从应用程序上次停止的地方开始。
JSON 可以说是最流行的基于文本的数据序列化格式。现代编程语言提供对 JSON 的良好支持,这也是它在 RESTful API 中无处不在的原因之一。Go 也提供对基于二进制的数据序列化格式的良好支持,包括 Gob,它几乎可以替代 JSON。Gob 是 Go 的本地二进制数据序列化格式,旨在高效且易于使用。
如果你在寻找一个支持更广泛的二进制数据序列化格式,可以考虑协议缓冲区。Google 设计协议缓冲区是为了方便其支持的平台和编程语言之间交换序列化的二进制数据。许多现代编程语言目前都提供对协议缓冲区的支持。尽管协议缓冲区不像 Gob 对 JSON 那样是 Go 的直接替代品,但 Go 仍然对协议缓冲区提供了出色的支持。你首先需要在 .proto 文件中添加定义,定义你打算序列化的数据结构。然后,使用协议缓冲区编译器及其 Go 模块生成与定义的数据结构对应的 Go 代码。最后,使用生成的代码将数据结构序列化为协议缓冲区格式的二进制数据,并将该二进制数据反序列化回你的数据结构。
gRPC 框架是一个高性能、平台中立的标准,用于在网络上进行分布式函数调用。gRPC 中的 RPC 代表 远程过程调用,这是一种透明地在远程系统上调用函数并接收结果的技术,就像你在本地系统上执行该函数一样。gRPC 使用协议缓冲区作为其底层数据序列化格式。Go 的协议缓冲区模块允许你通过在 .proto 文件中定义服务并利用生成的代码,轻松地添加 gRPC 支持。这使得你能够快速高效地搭建分布式服务或与现有的 gRPC 服务进行集成。
第十三章:日志记录和指标

在理想的世界里,我们的代码从一开始就没有缺陷。我们的网络服务将超出我们对性能和容量的预期,并且足够强大,可以在没有我们干预的情况下适应意外的输入。但在现实世界中,我们需要担心意外的和潜在的恶意输入、硬件退化、网络中断以及代码中的明显 bug。
无论我们的应用程序是在本地还是在云端,监控它们对提供强健、功能完善的服务至关重要。全面的日志记录让我们能够及时获得关于错误、异常或其他可操作事件的详细信息,而指标则帮助我们了解服务的当前状态,并识别瓶颈。综合来看,日志和指标帮助我们管理服务问题,并集中开发力量避免未来的故障。
在之前的章节中,你使用了 Go 的log和fmt包来获取反馈,但本章将深入探讨日志记录和为服务添加监控。你将学习如何使用日志级别来增加或减少日志的详细程度,以及何时使用每个日志级别。你将学会如何为日志条目添加结构,这样软件可以帮助你更好地理解日志条目,并聚焦于相关日志。我将向你介绍广泛事件日志的概念,这将帮助你在服务规模扩大时控制日志数据量。你将学习如何从代码中动态启用调试日志记录和管理日志文件的轮换。
本章还将介绍 Go kit 的metrics包。根据 Go kit 文档,metrics包“提供了一组统一的接口,用于服务的监控”。你将学习如何通过使用计数器、仪表和直方图来为你的服务添加监控。
本章结束时,你应该掌握如何处理日志记录,如何管理日志文件以防止它们占用过多硬盘空间,以及如何为你的服务添加监控,以便深入了解其当前状态。
事件日志
日志记录很难。即使是经验丰富的开发者也常常难以做到完美。很难预见到在服务发生故障时,你的日志需要回答哪些问题——然而,你应该抵制记录一切日志以防万一的冲动。你需要找到一个平衡点,既能记录正确的信息来回答这些问题,又不会被无关的日志信息淹没。过度的日志记录在开发阶段可能对你来说没问题,因为你控制了测试的规模和服务的整体熵,但它会在你需要诊断生产环境故障时迅速降低你在信息海洋中找到关键线索的能力。
除了搞清楚要记录什么,你还需要考虑日志记录并非无代价。它会消耗 CPU 和 I/O 时间,而这些时间本可以被应用程序用来做其他事情。向一个繁忙的for循环中添加日志条目,在开发过程中可能有助于你理解你的服务在做什么。但在生产环境中,它可能成为瓶颈,悄悄地为你的服务增加延迟。
相反,采样这些日志条目,或按需记录日志,可能提供日志输出和开销之间的适当折衷。你可能会发现使用宽事件日志条目很有帮助,它们总结了一个事务。例如,一个正在开发中的服务可能会记录关于请求的半打条目、任何中间步骤和响应。而在生产环境中,单个宽事件日志条目提供这些详细信息的方式更具扩展性。你将在第 312 页的《通过宽事件日志扩展》一节中了解更多宽事件日志条目的内容。
最后,日志记录是主观的。一个异常在我的应用程序中可能是微不足道的,但在你的应用程序中却可能表明一个更大的问题。虽然我可以忽略这个异常,但你可能希望知道它。出于这个原因,最好我们在最佳实践的框架下讨论日志记录。这些实践是一个良好的基准方法,但你应该根据每个应用程序量身定制它们。
log 包
在前面的章节中,你已经有了使用 Go 的log包的基本经验,用于基本的日志记录需求,比如给日志条目加上时间戳,或者选择性地使用log.Fatal退出应用程序。但它还有一些我们尚未探讨的功能。这些功能要求我们超越包级日志记录器,实例化我们自己的*log.Logger实例。你可以使用log.New函数来做到这一点:
func New(out io.Writer, prefix string, flag int) *Logger
log.New函数接受一个io.Writer、一个用于每条日志行的字符串前缀,以及一组修改日志记录器输出的标志。接受io.Writer意味着日志记录器可以将日志写入任何满足该接口的对象,包括内存缓冲区或网络套接字。
默认的日志记录器将输出写入os.Stderr,即标准错误。让我们看一个示例日志记录器,位于 Listing 13-1,它将输出写入os.Stdout,即标准输出。
func Example_log() {
l := log.New(1os.Stdout, 2"example: ", 3log.Lshortfile)
l.Print("logging to standard output")
// Output:
// example: 4log_test.go:12: logging to standard output
}
Listing 13-1:将日志条目写入标准输出(log_test.go)
你创建了一个新的*log.Logger实例,将其写入标准输出 1。日志记录器将每一行前缀加上字符串example:2。默认日志记录器的标志是log.Ldate和log.Ltime,合起来是log.LstdFlags,它们打印每条日志条目的时间戳。由于你希望简化输出以便在命令行运行示例时进行测试,你省略了时间戳,并配置日志记录器以写入每条日志条目的源代码文件名和行号 3。log_test.go文件中第 12 行的l.Print函数输出了这些值 4。这种行为有助于开发和调试,使你能够精确定位到感兴趣的日志条目的文件和行号。
你可能会意识到,日志记录器接受io.Writer,这意味着你可以使用多个写入器,比如日志文件和标准输出,或者内存环形缓冲区和网络上的集中式日志服务器。不幸的是,io.MultiWriter并不适合日志记录中使用。io.MultiWriter按顺序写入每个写入器,如果它从任何Write调用收到错误,就会中止。这意味着,如果你配置io.MultiWriter按这个顺序写入日志文件和标准输出,那么如果在写入日志文件时发生错误,标准输出将永远不会收到日志条目。
别担心,这是一个容易解决的问题。让我们从示例 13-2 开始,创建我们自己的io.MultiWriter实现,使其能够跨多个写入器维持写入并累积遇到的任何错误。
package ch13
import (
"io"
"go.uber.org/multierr"
)
type sustainedMultiWriter struct {
writers []io.Writer
}
func (s *sustainedMultiWriter) 1Write(p []byte) (n int, err error) {
for _, w := range s.writers {
i, wErr := 2w.Write(p)
n += i
err = 3multierr.Append(err, wErr)
}
return n, err
}
示例 13-2:一个即使遇到错误后仍能持续写入的多写入器 (writer.go)
和io.MultiWriter一样,你将使用一个包含io.Writer实例切片的结构体作为持续多写入器。你的多写入器实现了io.Writer接口 1,因此可以将其传递给你的日志记录器。它调用每个写入器的Write方法 2,在 Uber 的multierr包 3 的帮助下累积任何错误,最终返回总共写入的字节数和累积的错误。
示例 13-3 添加了一个函数,用于从一个或多个写入器初始化一个新的持续多写入器。
`--snip--`
func SustainedMultiWriter(writers ...io.Writer) io.Writer {
mw := &sustainedMultiWriter{writers: 1make([]io.Writer, 0, len(writers))}
for _, w := range writers {
if m, ok := 2w.(*sustainedMultiWriter); ok {
mw.writers = 3append(mw.writers, m.writers...)
continue
}
mw.writers = 4append(mw.writers, w)
}
return mw
}
示例 13-3:创建一个持续多写入器 (writer.go)
首先,你实例化一个新的*sustainedMultiWriter,初始化其写入器切片 1,并将其限制为写入器的预期长度。然后,你遍历给定的写入器并将它们追加到切片中 4。如果某个写入器本身是*sustainedMultiWriter2,你将改为追加它的写入器 3。最后,你返回初始化的sustainedMultiWriter的指针。
现在你可以在示例 13-4 中充分利用你的持续多写入器了。
package ch13
import (
"bytes"
"fmt"
"log"
"os"
)
func Example_logMultiWriter() {
logFile := new(bytes.Buffer)
w := 1SustainedMultiWriter(os.Stdout, logFile)
l := log.New(w, "example: ", 2log.Lshortfile|log.Lmsgprefix)
fmt.Println("standard output:")
l.Print("Canada is south of Detroit")
fmt.Print("\nlog file contents:\n", logFile.String())
// Output:
// standard output:
// log_test.go:24: example: Canada is south of Detroit
//
// log file contents:
// log_test.go:24: example: Canada is south of Detroit
}
示例 13-4:同时写入日志文件和标准输出 (log_test.go)
你在这个示例中创建了一个新的持续多写入器 1,写入标准输出,并使用一个bytes.Buffer作为模拟日志文件。接着,你使用持续多写入器、前缀example:和两个标志 2 来创建一个新的日志记录器,以修改日志记录器的行为。log.Lmsgprefix标志的添加(在 Go 1.14 中首次引入)告诉日志记录器在日志消息之前查找前缀。你可以看到这对示例输出中日志条目的影响。当你运行这个示例时,你会看到日志记录器将日志条目写入持续多写入器,而持续多写入器又将日志条目写入标准输出和日志文件。
分层日志条目
我在本章早些时候提到过,冗长的日志记录在生产环境中可能效率低下,并且随着服务的扩展,日志条目的数量可能会让你不堪重负。避免这种情况的一种方法是实施日志级别,为每种事件分配优先级,使你能够始终记录高优先级的错误日志,但根据条件记录更适合调试和开发的低优先级条目。例如,你总是希望知道服务无法连接到数据库,但在开发或诊断故障时,你可能只关心记录关于单个连接的详细信息。
我建议你开始时只创建几个日志级别。根据我的经验,你可以通过只设置一个错误级别和一个调试级别来处理大多数用例,偶尔也可以设置一个信息级别。错误日志条目应该伴随某种警报,因为这些条目表示需要你注意的条件。信息日志条目通常记录非错误信息。例如,记录成功的数据库连接,或在网络套接字上监听器准备好接受连接时添加日志条目,可能适合你的用例。调试日志条目应该详细,并帮助你诊断故障,同时通过帮助你理解工作流程来支持开发。
Go 的生态系统提供了几种日志包,其中大多数支持多个日志级别。尽管 Go 的log包本身不支持分级日志条目,但你可以通过为每个所需的日志级别创建单独的日志记录器来添加类似的功能。列表 13-5 就实现了这一点:它将日志条目写入日志文件,但也将调试日志写入标准输出。
`--snip--`
func Example_logLevels() {
lDebug := log.New(os.Stdout, 1"DEBUG: ", log.Lshortfile)
logFile := new(bytes.Buffer)
w := SustainedMultiWriter(logFile, 2lDebug.Writer())
lError := log.New(w, 3"ERROR: ", log.Lshortfile)
fmt.Println("standard output:")
lError.Print("cannot communicate with the database")
lDebug.Print("you cannot hum while holding your nose")
fmt.Print("\nlog file contents:\n", logFile.String())
// Output:
// standard output:
// ERROR: log_test.go:43: cannot communicate with the database
// DEBUG: log_test.go:44: you cannot hum while holding your nose
//
// log file contents:
// ERROR: log_test.go:43: cannot communicate with the database
}
列表 13-5:将调试条目写入标准输出,并将错误同时写入日志文件和标准输出(log_test.go)
首先,创建一个调试日志记录器,它写入标准输出,并使用DEBUG:前缀 1。接下来,创建一个*bytes.Buffer,作为日志文件的替代,实例化一个持续的多重写入器。持续的多重写入器同时写入日志文件和调试日志记录器的io.Writer2。然后,创建一个错误日志记录器,它通过使用ERROR:前缀 3 写入持续的多重写入器,从而将其日志条目与调试日志区分开来。最后,使用每个日志记录器并验证它们的输出,确保它们的输出符合预期。标准输出应显示两个日志记录器的日志条目,而日志文件应仅包含错误日志条目。
作为练习,找出如何使调试日志记录器具有条件性,而不是将其Print调用包装在条件语句中。如果你需要提示,可以在io/ioutil包中找到一个合适的写入器,它允许你丢弃输出。
本节旨在展示 log 包的额外用途,超出了本书中到目前为止所使用的内容。虽然可以使用此技术在不同级别进行日志记录,但使用本节后面描述的具有内置日志级别支持的日志记录器(如 Zap 日志记录器)会更有帮助。
结构化日志记录
你目前所编写的代码生成的日志条目是供人类阅读的。它们对你来说很容易阅读,因为每个日志条目不过是一个消息。这意味着,找到与某个问题相关的日志行通常需要广泛使用 grep 命令,或者在最坏的情况下,手动浏览日志条目。但如果日志条目的数量增加,这可能会变得更加困难。你可能会发现自己在大海捞针。记住,日志记录只有在你能快速找到所需信息时才有用。
解决这个问题的常见方法是向日志条目中添加元数据,然后通过软件解析这些元数据,以帮助你组织它们。这种类型的日志记录称为结构化日志记录。创建结构化日志条目需要向每个日志条目添加键值对。在这些条目中,你可以包括日志条目记录的时间、生成日志条目的应用程序部分、日志级别、生成日志条目的节点的主机名或 IP 地址,以及其他你可以用于索引和筛选的元数据。大多数结构化日志记录器会在将日志条目写入日志文件或发送到集中式日志服务器之前,将其编码为 JSON。结构化日志记录使得将日志收集到集中式服务器的整个过程变得简单,因为与每个日志条目相关的附加元数据使得服务器能够跨服务组织和整理日志条目。一旦它们被索引,你就可以查询日志服务器以找到特定的日志条目,从而更好地找到问题的及时答案。
使用 Zap 日志记录器
讨论具体的集中式日志解决方案超出了本书的范围。如果你有兴趣了解更多,我建议你首先研究 Elasticsearch 或 Apache Solr。不过,本节将重点介绍如何实现日志记录器本身。你将使用来自 Uber 的 Zap 日志记录器,网址是pkg.go.dev/go.uber.org/zap/,它允许你集成日志文件轮转功能。
日志文件轮换是指在当前日志文件达到特定的年龄或大小阈值后,关闭当前日志文件、重命名它,并打开一个新的日志文件。轮换日志文件是一个好习惯,可以防止它们填满你的硬盘空间。而且,搜索较小的、按日期分隔的日志文件,比搜索一个庞大的日志文件更高效。例如,你可能希望每周轮换一次日志文件,并只保留八周的轮换日志文件。如果你想查看上周发生的事件的日志条目,你可以将搜索范围限制在一个日志文件中。此外,你还可以压缩轮换的日志文件,进一步节省硬盘空间。
我在大型项目中使用过其他结构化日志记录器,根据我的经验,Zap 造成的开销最小;我可以在繁忙的代码部分使用它而不会显著影响性能,和其他重量级的结构化日志记录器不同。但是你的使用情况可能会有所不同,因此我鼓励你找到最适合自己的工具。你可以将这里描述的结构化日志记录原理和日志文件管理技术应用到其他结构化日志记录器上。
Zap 日志记录器包含zap.Core及其选项。zap.Core有三个组成部分:日志级别阈值、输出和编码器。日志级别阈值设置 Zap 记录日志的最低级别;Zap 会忽略低于该级别的日志条目,这样你可以在代码中保留调试日志,并配置 Zap 有条件地忽略它。Zap 的输出是一个zapcore.WriteSyncer,它是一个带有额外Sync方法的io.Writer。Zap 可以将日志条目写入任何实现该接口的对象。而编码器则可以在将日志条目写入输出之前对其进行编码。
编写编码器
虽然 Zap 提供了一些辅助函数,比如zap.NewProduction或zap.NewDevelopment,可以快速创建生产和开发环境的日志记录器,但你将从头开始创建一个日志记录器,首先从清单 13-6 中的编码器配置开始。
package ch13
import (
"bytes"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/fsnotify.v1"
"gopkg.in/natefinch/lumberjack.v2"
)
var encoderCfg = zapcore.EncoderConfig{
MessageKey: 1"msg",
NameKey: 2"name",
LevelKey: "level",
EncodeLevel: 3zapcore.LowercaseLevelEncoder,
CallerKey: "caller",
EncodeCaller: 4zapcore.ShortCallerEncoder,
5 // TimeKey: "time",
// EncodeTime: zapcore.ISO8601TimeEncoder,
}
清单 13-6:你的 Zap 日志记录器的编码器配置(zap_test.go)
编码器配置与编码器本身是独立的,你可以使用相同的编码器配置,无论是将它传递给 JSON 编码器还是控制台编码器。编码器将使用你的配置来决定其输出格式。在这里,你的编码器配置决定了编码器使用msg1 作为日志消息的键,使用name2 作为日志条目中的日志记录器名称的键。同样,编码器配置要求编码器使用level作为日志级别的键,并使用全小写字符 3 来编码级别名称。如果日志记录器被配置为添加调用者详细信息,你希望编码器将这些详细信息与caller键关联,并以简化格式 4 对其进行编码。
由于您需要保持以下示例的输出一致,因此将省略 time 键 5,以避免它出现在输出中。实际使用时,您应取消注释这两个字段。
创建日志记录器及其选项
现在您已经定义了编码器配置,让我们通过实例化一个 Zap 日志记录器在 示例 13-7 中使用它。
`--snip--`
func Example_zapJSON() {
zl := zap.New(
1 zapcore.NewCore(
2 zapcore.NewJSONEncoder(encoderCfg),
3 zapcore.Lock(os.Stdout),
4 zapcore.DebugLevel,
),
5 zap.AddCaller(),
zap.Fields(
6 zap.String("version", runtime.Version()),
),
)
defer func() { _ = 7zl.Sync() }()
example := 8zl.Named("example")
example.Debug("test debug message")
example.Info("test info message")
// Output:
9 // {"level":"debug","name":"example","caller":"ch13/zap_test.go:49",
"msg":"test debug message","version":"ago1.15.5"}
// {"level":"info","name":"example","caller":"ch13/zap_test.go:50",
"msg":"test info message","version":"go1.15.5"}
}
示例 13-7:根据编码器配置实例化一个新的日志记录器并记录到 JSON 文件中(zap_test.go)
zap.New 函数接受一个 zap.Core1 和零个或多个 zap.Options。在此示例中,您传递了 zap.AddCaller 选项 5,指示日志记录器在每个日志条目中包含调用者信息,并且包含一个名为 version 的字段 6,该字段在每个日志条目中插入运行时版本。
zap.Core 包含一个使用您编码器配置的 JSON 编码器 2,一个 zapcore.WriteSyncer3 和日志记录阈值 4。如果 zapcore.WriteSyncer 不支持并发使用,您可以使用 zapcore.Lock 来使其支持并发,如此示例所示。
Zap 日志记录器包括七个日志级别,按严重程度递增:DebugLevel、InfoLevel、WarnLevel、ErrorLevel、DPanicLevel、PanicLevel 和 FatalLevel。默认级别是 InfoLevel。DPanicLevel 和 PanicLevel 级别的条目会导致 Zap 记录该条目并引发 panic。在 FatalLevel 级别记录的条目会导致 Zap 在写入日志条目后调用 os.Exit(1)。由于您的日志记录器使用的是 DebugLevel,它将记录所有条目。
我建议您将 DPanicLevel 和 PanicLevel 限制在开发环境中使用,将 FatalLevel 限制在生产环境中,仅用于灾难性启动错误,例如无法连接到数据库。否则,您可能会遇到麻烦。如前所述,您可以充分利用 DebugLevel、ErrorLevel,偶尔使用 InfoLevel。
在开始使用日志记录器之前,您需要确保调用其 Sync 方法 7 来确保所有缓存的数据都被写入输出。
您还可以通过调用其 Named 方法 8 并使用返回的日志记录器来为日志记录器指定名称。默认情况下,日志记录器没有名称。命名日志记录器将会在日志条目中包含一个名称键,前提是您在编码器配置中定义了一个名称。
现在,日志条目 9 包含了关于日志消息的元数据,以至于日志行的输出超出了本书的宽度。还需要提到的是,示例输出中的 Go 版本 a 取决于您用来测试此示例的 Go 版本。尽管您正在以 JSON 格式编码每个日志条目,您仍然可以读取您在日志中包含的附加元数据。您可以将这些 JSON 导入到像 Elasticsearch 这样的工具中,并在其上执行查询,让 Elasticsearch 处理返回仅与查询相关的日志行的重担。
使用控制台编码器
上面的例子在相对较少的代码中包含了许多功能。现在假设你想记录一些更易于人类阅读的内容,但又具有结构化的内容。Zap 包含一个控制台编码器,基本上是其 JSON 编码器的直接替代品。列表 13-8 使用控制台编码器将结构化日志条目写入标准输出。
`--snip--`
func Example_zapConsole() {
zl := zap.New(
zapcore.NewCore(
1 zapcore.NewConsoleEncoder(encoderCfg),
zapcore.Lock(os.Stdout),
2 zapcore.InfoLevel,
),
)
defer func() { _ = zl.Sync() }()
console := 3zl.Named("[console]")
console.Info("this is logged by the logger")
4 console.Debug("this is below the logger's threshold and won't log")
console.Error("this is also logged by the logger")
// Output:
5 // info [console] this is logged by the logger
// error [console] this is also logged by the logger
}
列表 13-8:使用控制台编码器编写结构化日志(zap_test.go)
控制台编码器 1 使用制表符分隔字段。它根据你的编码器配置获取指令,确定要包含哪些字段,以及如何格式化每个字段。
请注意,在这个例子中,你没有将 zap.AddCaller 和 zap.Fields 选项传递给日志记录器。因此,日志条目将没有 caller 和 version 字段。只有当日志记录器具有 zap.AddCaller 选项,并且编码器配置定义了 CallerKey 时,日志条目才会包含 caller 字段,正如列表 13-6 所示。
你命名日志记录器为 3,并写入三个日志条目,每个条目具有不同的日志级别。由于日志记录器的阈值是info级别 2,因此调试日志条目 4 不会出现在输出中,因为 debug 低于 info 阈值。
输出 5 缺少键名,但包括由制表符字符分隔的字段值。虽然在打印时不明显,但日志级别、日志名称和日志消息之间有一个制表符字符。如果你在编辑器中输入这个,记得在这些元素之间添加制表符字符,否则运行时示例可能会失败。
使用不同的输出和编码进行日志记录
Zap 包含有用的函数,允许你并行地记录到不同的输出,使用不同的编码,在不同的日志级别上。列表 13-9 创建一个日志记录器,将 JSON 写入日志文件,并将控制台编码写入标准输出。该日志记录器仅将调试日志条目写入控制台。
`--snip--`
func Example_zapInfoFileDebugConsole() {
logFile := 1new(bytes.Buffer)
zl := zap.New(
zapcore.NewCore(
zapcore.NewJSONEncoder(encoderCfg),
zapcore.Lock(2zapcore.AddSync(logFile)),
zapcore.InfoLevel,
),
)
defer func() { _ = zl.Sync() }()
3 zl.Debug("this is below the logger's threshold and won't log")
zl.Error("this is logged by the logger")
列表 13-9:使用 *bytes.Buffer 作为日志输出并将 JSON 写入其中(zap_test.go)
你正在使用 *bytes.Buffer 1 来充当模拟日志文件。唯一的问题是 *bytes.Buffer 没有 Sync 方法,并且不实现 zapcore.WriteSyncer 接口。幸运的是,Zap 包含一个名为 zapcore.AddSync 2 的辅助函数,它会智能地向 io.Writer 添加一个无操作的 Sync 方法。除了使用这个函数之外,日志记录器的其余实现应该对你来说是熟悉的。它将 JSON 日志记录到日志文件,并排除任何低于 info 级别的日志条目。因此,第一个日志条目 3 应该根本不出现在日志文件中。
现在你已经有了一个将 JSON 写入日志文件的日志记录器,让我们使用 Zap 进行实验,并在列表 13-10 中创建一个新的日志记录器,可以同时将 JSON 日志条目写入日志文件,并将控制台日志条目写入标准输出。
`--snip--`
zl = 1zl.WithOptions(
2 zap.WrapCore(
func(c zapcore.Core) zapcore.Core {
ucEncoderCfg := encoderCfg
3 ucEncoderCfg.EncodeLevel = zapcore.CapitalLevelEncoder
return 4zapcore.NewTee(
c,
5 zapcore.NewCore(
zapcore.NewConsoleEncoder(ucEncoderCfg),
zapcore.Lock(os.Stdout),
zapcore.DebugLevel,
),
)
},
),
)
fmt.Println("standard output:")
6 zl.Debug("this is only logged as console encoding")
zl.Info("this is logged as console encoding and JSON")
fmt.Print("\nlog file contents:\n", logFile.String())
// Output:
// standard output:
// DEBUG this is only logged as console encoding
// INFO this is logged as console encoding and JSON
//
// log file contents:
// {"level":"error","msg":"this is logged by the logger"}
// {"level":"info","msg":"this is logged as console encoding and JSON"}
}
列表 13-10:扩展日志记录器以记录到多个输出(zap_test.go)
Zap 的 WithOptions 方法 1 克隆现有的日志系统,并使用给定的选项配置该克隆。你可以使用 zap.WrapCore 函数 2 来修改克隆日志系统的底层 zap.Core。为了有所不同,你会复制编码器配置并调整它,指示编码器以全大写字母输出日志级别 3。最后,你使用 zapcore.NewTee 函数,它类似于 io.MultiWriter 函数,用于返回一个将日志写入多个核心的 zap.Core 4。在这个示例中,你传入了现有的核心和一个新的核心 5,后者将 debug 级别的日志条目写入标准输出。
当你使用克隆的日志系统时,日志文件和标准输出都会接收 info 级别及以上的日志条目,而只有标准输出接收调试日志条目 6。
抽样日志条目
关于日志记录,我给你的一个警告是要考虑它如何影响你的应用程序的 CPU 和 I/O 性能。你不希望日志记录成为你应用程序的瓶颈。这通常意味着在应用程序繁忙部分进行日志记录时要特别小心。
在关键代码路径(如循环)中,减少日志开销的一种方法是对日志条目进行抽样。可能并不需要记录每个条目,尤其是当你的日志系统输出了许多重复的日志条目时。相反,尝试记录每个重复条目的第n次出现。
方便的是,Zap 提供了一个可以做到这一点的日志系统。示例 13-11 创建了一个日志系统,通过记录一部分日志条目来限制其 CPU 和 I/O 开销。
`--snip--`
func Example_zapSampling() {
zl := zap.New(
1 zapcore.NewSamplerWithOptions(
zapcore.NewCore(
zapcore.NewJSONEncoder(encoderCfg),
zapcore.Lock(os.Stdout),
zapcore.DebugLevel,
),
2time.Second, 31, 43,
),
)
defer func() { _ = zl.Sync() }()
for i := 0; i < 10; i++ {
if i == 5 {
5 time.Sleep(time.Second)
}
6 zl.Debug(fmt.Sprintf("%d", i))
7 zl.Debug("debug message")
}
// 8Output:
// {"level":"debug","msg":"0"}
// {"level":"debug","msg":"debug message"}
// {"level":"debug","msg":"1"}
// {"level":"debug","msg":"2"}
// {"level":"debug","msg":"3"}
// {"level":"debug","msg":"debug message"}
// {"level":"debug","msg":"4"}
// {"level":"debug","msg":"5"}
// {"level":"debug","msg":"debug message"}
// {"level":"debug","msg":"6"}
// {"level":"debug","msg":"7"}
// {"level":"debug","msg":"8"}
// {"level":"debug","msg":"debug message"}
// {"level":"debug","msg":"9"}
}
示例 13-11:记录部分日志条目以限制 CPU 和 I/O 开销 (zap_test.go)
NewSamplerWithOptions 函数 1 用抽样功能包装了 zap.Core。它需要三个额外的参数:一个抽样间隔 2,记录的初始重复日志条目数量 3,以及一个整数 4,表示在此之后要记录的第n个重复日志条目。在这个示例中,你首先记录第一个日志条目,然后记录在一秒钟内,日志系统接收到的每第三个重复日志条目。间隔时间结束后,日志系统重新开始,记录第一个条目,然后记录余下时间内的每第三个重复条目。
让我们看一下实际操作。你在循环中执行 10 次迭代。每次迭代都会记录计数器 6 和一个通用的调试信息 7,后者在每次迭代中保持不变。在第六次迭代时,示例会休眠一秒钟 5,以确保样本日志系统在接下来的一个秒钟间隔中重新开始记录。
检查输出 8,您会看到调试消息在第一次迭代时打印出来,直到日志记录器在第四次循环迭代中遇到第三个重复的调试消息才会再次打印。但在第六次迭代时,示例进入休眠,示例日志记录器切换到下一个一秒钟的时间间隔,重新开始记录。在第六次循环迭代中,它记录了该时间间隔的第一个调试消息,而在第九次循环迭代中记录了第三个重复的调试消息。
诚然,这是一个人为的示例,但它展示了如何使用这种日志采样技术作为一种折衷方法,适用于 CPU 和 I/O 敏感的代码部分。这个技术可以应用于将工作分配给工作 goroutine 的场景。尽管您可以将工作尽可能快速地发送给工作 goroutine 处理,但您可能希望定期更新每个工作 goroutine 的进度,而不需要付出过多的日志记录开销。示例日志记录器可以让您调节日志输出,找到及时更新与最小开销之间的平衡。
执行按需调试日志记录
如果调试日志记录在正常操作下对您的应用程序造成了不可接受的负担,或者调试日志数据的数量过多以至于超过了可用存储空间,您可能需要按需启用调试日志记录。一种方法是使用信号量文件来启用调试日志记录。信号量文件是一个空文件,其存在作为日志记录器改变行为的信号。如果信号量文件存在,日志记录器将输出debug级别的日志。一旦您移除信号量文件,日志记录器将恢复到先前的日志级别。
让我们使用fsnotify包来允许您的应用程序监视文件系统通知。除了标准库外,fsnotify包还使用了x/sys包。在开始编写代码之前,让我们确保我们的x/sys包是最新的:
$ **go get -u golang.org/x/sys/...**
并非所有日志记录包都提供安全的异步修改日志级别的方法。请注意,如果您尝试在日志记录器读取日志级别的同时修改其级别,可能会引发竞争条件。Zap 日志记录器允许您检索一个基于sync/atomic的级别器,以便在避免竞争条件的情况下动态修改日志记录器的级别。您将把原子级别器传递给zapcore.NewCore函数,以替代日志级别,就像您之前做的那样。
zap.AtomicLevel结构实现了http.Handler接口。您可以将它集成到 API 中,并通过 HTTP 动态更改日志级别,而不需要使用信号量。
列表 13-12 展示了一个使用信号量文件的动态日志记录示例。您将在接下来的几个列表中实现这个示例。
`--snip--`
func Example_zapDynamicDebugging() {
tempDir, err := ioutil.TempDir("", "")
if err != nil {
log.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
debugLevelFile := 1filepath.Join(tempDir, "level.debug")
atomicLevel := 2zap.NewAtomicLevel()
zl := zap.New(
zapcore.NewCore(
zapcore.NewJSONEncoder(encoderCfg),
zapcore.Lock(os.Stdout),
3 atomicLevel,
),
)
defer func() { _ = zl.Sync() }()
列表 13-12:使用原子级别的日志记录器创建新的日志记录器(zap_test.go)
你的代码将监视临时目录中是否存在level.debug文件 1。当文件存在时,你将动态地将记录器的级别更改为debug。为此,你需要一个新的原子级别器 2。默认情况下,原子级别器使用info级别,这对于这个例子来说完全合适。你在创建核心时传入原子级别器 3,而不是直接指定日志级别。
现在你已经有了一个原子级别器和一个存储信号量文件的位置,让我们编写代码来监视信号量文件的变化,见清单 13-13。
`--snip--`
watcher, err := 1fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer func() { _ = watcher.Close() }()
err = 2watcher.Add(tempDir)
if err != nil {
log.Fatal(err)
}
ready := make(chan struct{})
go func() {
defer close(ready)
originalLevel := 3atomicLevel.Level()
for {
select {
case event, ok := 4<-watcher.Events:
if !ok {
return
}
if event.Name == 5debugLevelFile {
switch {
case event.Op&fsnotify.Create == 6fsnotify.Create:
atomicLevel.SetLevel(zapcore.DebugLevel)
ready <- struct{}{}
case event.Op&fsnotify.Remove == 7fsnotify.Remove:
atomicLevel.SetLevel(originalLevel)
ready <- struct{}{}
}
}
case err, ok := 8<-watcher.Errors:
if !ok {
return
}
zl.Error(err.Error())
}
}
}()
清单 13-13:监视信号量文件的任何变化(zap_test.go)
首先,你创建一个文件系统监视器 1,用于监视临时目录 2。监视器将通知你该目录中的任何变化。你还需要捕获当前的日志级别 3,以便在删除信号量文件时恢复到该级别。
接下来,你监听监视器 4 的事件。由于你监视的是一个目录,你需要过滤掉与信号量文件 5 本身无关的任何事件。即使如此,你只对信号量文件的创建或删除事件感兴趣。如果事件表示信号量文件的创建 6,你将原子级别器的级别更改为debug。如果你收到信号量文件删除事件 7,你将原子级别器的级别恢复为原始级别。
如果你在任何时候从监视器 8 接收到错误,你应该以error级别记录该错误。
让我们看看它在实践中的表现。清单 13-14 测试了记录器在有信号量文件和没有信号量文件时的行为。
`--snip--`
1 zl.Debug("this is below the logger's threshold")
df, err := 2os.Create(debugLevelFile)
if err != nil {
log.Fatal(err)
}
err = df.Close()
if err != nil {
log.Fatal(err)
}
<-ready
3 zl.Debug("this is now at the logger's threshold")
err = 4os.Remove(debugLevelFile)
if err != nil {
log.Fatal(err)
}
<-ready
5 zl.Debug("this is below the logger's threshold again")
6 zl.Info("this is at the logger's current threshold")
// Output:
// {"level":"debug","msg":"this is now at the logger's threshold"}
// {"level":"info","msg":"this is at the logger's current threshold"}
}
清单 13-14:测试记录器使用信号量文件(zap_test.go)
记录器当前通过原子级别器的日志级别是info。因此,记录器不会将初始调试日志条目 1 写入标准输出。但是,如果你创建信号量文件 2,位于清单 13-13 中的代码应该会动态地将记录器的级别更改为debug。如果你再添加另一条调试日志条目 3,记录器应该将其写入标准输出。然后你删除信号量文件 4,并同时写入调试日志条目 5 和信息日志条目 6。由于信号量文件不再存在,记录器应该只将信息日志条目写入标准输出。
使用宽事件日志记录进行扩展
宽事件日志记录是一种技术,它为每个事件创建一个单一的、结构化的日志条目,以总结事务,而不是随着事务的进行记录大量的条目。这种技术最适用于请求-响应循环,例如 API 调用,但它也可以适应其他用例。当你在结构化的日志条目中总结事务时,你可以减少日志记录的开销,同时保持索引和搜索事务细节的能力。
广泛事件日志的一种方法是将 API 处理器包装在中间件中。但首先,由于http.ResponseWriter对其输出比较“吝啬”,你需要创建自己的响应写入器类型(列表 13-15),以便收集并记录响应码和长度。
package ch13
import (
"io"
"io/ioutil"
"net"
"net/http"
"net/http/httptest"
"os"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
type wideResponseWriter struct {
1 http.ResponseWriter
length, status int
}
func (w *wideResponseWriter) 2WriteHeader(status int) {
w.ResponseWriter.WriteHeader(status)
w.status = status
}
func (w *wideResponseWriter) 3Write(b []byte) (int, error) {
n, err := w.ResponseWriter.Write(b)
w.length += n
if w.status == 0 {
w.status = 4http.StatusOK
}
return n, err
}
列表 13-15:创建一个ResponseWriter以捕获响应状态码和长度(wide_test.go)
新类型嵌入了实现http.ResponseWriter接口的对象 1。此外,你添加了length和status字段,因为这些值最终是你想从响应中记录的内容。你重写了WriteHeader方法 2,以便轻松捕获状态码。同样,你重写了Write方法 3,以准确记录写入字节的数量,并在调用者在WriteHeader之前执行Write时,可选择设置状态码 4。
列表 13-16 在广泛事件日志中间件中使用了你新的类型。
`--snip--`
func WideEventLog(logger *zap.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
wideWriter := 1&wideResponseWriter{ResponseWriter: w}
2 next.ServeHTTP(wideWriter, r)
addr, _, _ := net.SplitHostPort(r.RemoteAddr)
3 logger.Info("example wide event",
zap.Int("status_code", wideWriter.status),
zap.Int("response_length", wideWriter.length),
zap.Int64("content_length", r.ContentLength),
zap.String("method", r.Method),
zap.String("proto", r.Proto),
zap.String("remote_addr", addr),
zap.String("uri", r.RequestURI),
zap.String("user_agent", r.UserAgent()),
)
},
)
}
列表 13-16:实现广泛事件日志中间件(wide_test.go)
广泛事件日志中间件同时接受*zap.Logger和http.Handler,并返回一个http.Handler。如果这个模式对你来说不熟悉,请阅读第 193 页的“处理器”部分。
首先,你将http.ResponseWriter嵌入到一个新的广泛事件日志感知响应写入器实例中 1。然后,你调用下一个http.Handler的ServeHTTP方法 2,传入你的响应写入器。最后,你创建一个日志条目 3,记录关于请求和响应的各种数据。
请记住,我在这里特意忽略了那些每次执行时会变化并打破示例输出的值,例如调用时长。在实际实现中,你可能需要编写代码来处理这些值。
列表 13-17 启动中间件并展示预期的输出。
`--snip--`
func Example_wideLogEntry() {
zl := zap.New(
zapcore.NewCore(
zapcore.NewJSONEncoder(encoderCfg),
zapcore.Lock(os.Stdout),
zapcore.DebugLevel,
),
)
defer func() { _ = zl.Sync() }()
ts := httptest.NewServer(
1 WideEventLog(zl, http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
defer func(r io.ReadCloser) {
_, _ = io.Copy(ioutil.Discard, r)
_ = r.Close()
}(r.Body)
_, _ = 2w.Write([]byte("Hello!"))
},
)),
)
defer ts.Close()
resp, err := 3http.Get(ts.URL + "/test")
if err != nil {
4 zl.Fatal(err.Error())
}
_ = resp.Body.Close()
// 5Output:
// {"level":"info","msg":"example wide event","status_code":200,
"response_length":6,"content_length":0,"method":"GET","proto":"HTTP/1.1",
"remote_addr":"127.0.0.1","uri":"/test","user_agent":"Go-http-client/1.1"}
}
列表 13-17:使用广泛事件日志中间件记录 GET 调用的详细信息(wide_test.go)
如第九章所述,你使用httptest服务器与WideEventLog中间件 1 一起工作。你将*zap.Logger作为第一个参数传递给中间件,http.Handler作为第二个参数。处理器将简单的Hello!写入响应 2,因此响应长度是非零的。通过这种方式,你可以证明你的响应写入器正常工作。日志记录器会在接收到 GET 请求的响应之前立即写入日志条目 3。如之前所述,我必须将 JSON 输出 5 包装起来以便在本书中打印,但它通常只占用一行。
由于这只是一个示例,我选择使用记录器的Fatal方法 4,该方法将错误信息写入日志文件并调用os.Exit(1)终止应用程序。如果代码需要在发生错误时继续运行,你不应该在代码中使用这个方法。
使用 Lumberjack 进行日志轮换
如果你选择将日志条目输出到文件,你可以使用像logrotate这样的应用程序,以防它们占用所有可用的硬盘空间。使用第三方应用程序管理日志文件的缺点是,第三方应用程序需要向你的应用程序发出信号,提示它重新打开日志文件句柄,否则你的应用程序将继续写入已轮换的日志文件。
一个不那么侵入性且更可靠的选择是直接将日志文件管理添加到你的日志记录器中,使用像Lumberjack这样的库。Lumberjack 以透明的方式处理日志轮换,因为你的日志记录器将 Lumberjack 视为任何其他io.Writer。与此同时,Lumberjack 为你跟踪日志条目的记账和文件轮换。
列表 13-18 为典型的 Zap 日志记录实现添加了日志轮换功能。
`--snip--`
func TestZapLogRotation(t *testing.T) {
tempDir, err := ioutil.TempDir("", "")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tempDir) }()
zl := zap.New(
zapcore.NewCore(
zapcore.NewJSONEncoder(encoderCfg),
1 zapcore.AddSync(
2 &lumberjack.Logger{
Filename: 3filepath.Join(tempDir, "debug.log"),
Compress: 4true,
LocalTime: 5true,
MaxAge: 67,
MaxBackups: 75,
MaxSize: 8100,
},
),
zapcore.DebugLevel,
),
)
defer func() { _ = zl.Sync() }()
zl.Debug("debug message written to the log file")
}
列表 13-18:使用 Lumberjack 为 Zap 日志记录器添加日志轮换功能(zap_test.go)
与列表 13-9 中的*bytes.Buffer一样,*lumberjack.Logger2 没有实现zapcore.WriteSyncer。它同样缺少Sync方法。因此,你需要将其封装在对zapcore.AddSync1 的调用中。
Lumberjack 包含多个字段来配置其行为,尽管其默认值是合理的。它使用格式为
你可以继续像直接写入标准输出或*os.File一样使用日志记录器。不同之处在于,Lumberjack 会智能地为你处理日志文件的管理。
给代码添加日志
为代码添加指标是收集指标的过程,目的是推断你服务的当前状态——例如每个请求响应循环的持续时间、每个响应的大小、连接的客户端数量、你的服务和第三方 API 之间的延迟等等。日志提供了记录服务如何进入某种状态的过程,而指标则让你深入了解该状态本身。
仪表化很简单,甚至我要给你与我给日志记录相反的建议:最初,仪表化所有内容。细粒度的仪表化几乎没有任何开销,它高效且易于传输,存储成本低廉。而且,仪表化能够解决我之前提到的日志记录中的一个挑战:你最初并不知道你将要问的所有问题,尤其是在复杂的系统中。一个隐蔽的问题可能会因你缺乏关键的指标而破坏你的周末,它会给你一个早期的警告,提醒你出现了问题。
本节将介绍指标类型,并展示如何在你的服务中使用这些类型的基础知识。你将了解 Go kit 的metrics包,它是一个抽象层,提供了流行指标平台的有用接口。你将通过使用 Prometheus 作为目标指标平台并设置 Prometheus 抓取的端点来完成仪表化。如果将来选择使用不同的平台,你只需要替换代码中的 Prometheus 相关部分;你可以保留 Go kit 代码不变。如果你刚开始进行仪表化,使用grafana.com/products/cloud/来抓取并可视化你的指标是一个不错的选择。它的免费层足以进行仪表化实验。
设置
为了抽象化你的指标实现及其依赖的包,让我们首先将它们放在自己的包中(清单 13-19)。
package metrics
import (
"flag"
1 "github.com/go-kit/kit/metrics"
2 "github.com/go-kit/kit/metrics/prometheus"
3 prom "github.com/prometheus/client_golang/prometheus"
)
var (
Namespace = 4flag.String("namespace", "web", "metrics namespace")
Subsystem = 5flag.String("subsystem", "server1", "metrics subsystem")
清单 13-19:指标示例的导入和命令行标志 (instrumentation/metrics/metrics.go)
你导入 Go kit 的metrics包 1,它提供你的代码将使用的接口,prometheus适配器 2,使你能够将 Prometheus 作为你的指标平台,以及 Go 的 Prometheus 客户端包 3 本身。所有与 Prometheus 相关的导入都位于此包中。你的其余代码将使用 Go kit 的接口。这样,你可以在不需要更改代码中的仪表化部分的情况下,交换底层的指标平台。
Prometheus 会在其指标前加上命名空间和子系统的前缀。你可以使用服务名称作为命名空间,节点或主机名作为子系统,例如。在此示例中,默认情况下,你将使用web作为命名空间 4,server1作为子系统 5。因此,你的指标将使用web_server1_前缀。你将在清单 13-30 的命令行输出中看到这个前缀。
现在让我们探索各种指标类型,从计数器开始。
计数器
计数器用于跟踪仅增加的值,例如请求计数、错误计数或完成任务计数。你可以使用计数器来计算给定时间间隔内的增长速率,例如每分钟连接数。
示例 13-20 定义了两个计数器:一个用于跟踪请求数量,另一个用于记录写入错误的数量。
`--snip--`
Requests 1metrics.Counter = 2prometheus.NewCounterFrom(
3 prom.CounterOpts{
Namespace: *Namespace,
Subsystem: *Subsystem,
Name: 4"request_count",
Help: 5"Total requests",
},
[]string{},
)
WriteErrors metrics.Counter = prometheus.NewCounterFrom(
prom.CounterOpts{
Namespace: *Namespace,
Subsystem: *Subsystem,
Name: "write_errors_count",
Help: "Total write errors",
},
[]string{},
)
示例 13-20:作为 Go kit 接口创建计数器 (instrumentation/metrics/metrics.go)
每个计数器都实现了 Go kit 的 metrics.Counter 接口 1。每个计数器的具体类型来自 Go kit 的 prometheus 适配器 2,并依赖于 Prometheus 客户端包中的 CounterOpts 结构体 3 进行配置。除了我们已经讨论过的命名空间和子系统值外,另一个重要的值是你设置的度量名称 4 及其帮助字符串 5,后者描述了该度量。
仪表
仪表 允许你跟踪增减的值,例如当前内存使用情况、在途请求、队列大小、风扇转速或我桌面上 ThinkPad 的数量。仪表不支持速率计算,例如每分钟的连接数或每秒传输的兆比特,而计数器则支持。
示例 13-21 创建了一个仪表来跟踪开放的连接数。
`--snip--`
OpenConnections 1metrics.Gauge = 2prometheus.NewGaugeFrom(
3 prom.GaugeOpts{
Namespace: *Namespace,
Subsystem: *Subsystem,
Name: "open_connections",
Help: "Current open connections",
},
[]string{},
)
示例 13-21:作为 Go kit 接口创建仪表 (instrumentation/metrics/metrics.go)
创建仪表与创建计数器类似。你需要创建一个新的 Go kit 的 metrics.Gauge 接口变量,并使用 Go kit 的 prometheus 适配器中的 NewGaugeFrom 函数 2 来创建底层类型。Prometheus 客户端的 GaugeOpts 结构体 3 提供了新仪表的设置。
直方图和摘要
直方图 将值放入预定义的桶中。每个桶都与一个值的范围相关联,并以其最大值命名。当观察到一个值时,直方图会增加最小桶中可以容纳该值的最大值。通过这种方式,直方图跟踪每个桶中观察到的值的频率。
让我们看一个简单的例子。假设你有三个桶,值分别为 0.5、1.0 和 1.5,如果直方图观察到值为 0.42,它将增加与桶 0.5 相关联的计数器,因为 0.5 是可以容纳 0.42 的最小桶。它覆盖了 0.5 及以下的范围。如果直方图观察到值为 1.23,它将增加与桶 1.5 相关联的计数器,桶 1.5 覆盖了 1.0 到 1.5 之间的值。自然地,桶 1.0 覆盖了 0.5 到 1.0 之间的范围。
你可以使用直方图的观察值分布来估算所有值的百分比或平均值。例如,你可以使用直方图来计算服务中观察到的平均请求大小或响应大小。
摘要 是一个直方图,有几个不同之处。首先,直方图需要预定义的桶,而摘要会计算自己的桶。其次,度量服务器从直方图计算平均值或百分比,而你的服务从摘要中计算平均值或百分比。因此,你可以在度量服务器上跨服务聚合直方图,但无法对摘要执行相同的操作。
一般建议是当你不知道预期值的范围时使用总结性指标,但我建议尽可能使用直方图,这样你可以在指标服务器上聚合直方图。让我们使用直方图来观察请求时长(参见列表 13-22)。
`--snip--`
RequestDuration 1metrics.Histogram = 2prometheus.NewHistogramFrom(
3 prom.HistogramOpts{
Namespace: *Namespace,
Subsystem: *Subsystem,
Buckets: 4[]float64{
0.0000001, 0.0000002, 0.0000003, 0.0000004, 0.0000005,
0.000001, 0.0000025, 0.000005, 0.0000075, 0.00001,
0.0001, 0.001, 0.01,
},
Name: "request_duration_histogram_seconds",
Help: "Total duration of all requests",
},
[]string{},
)
)
列表 13-22:创建直方图指标(instrumentation/metrics/metrics.go)
总结性指标和直方图指标类型都实现了 Go kit 的metrics.Histogram接口 1,这是通过其prometheus适配器提供的。在这里,你使用的是直方图指标类型 2,并通过 Prometheus 客户端的HistogramOpts结构体 3 进行配置。由于 Prometheus 的默认桶大小对于通过 localhost 进行通信时预期的请求时长范围来说太大,你定义了自定义的桶大小 4。我鼓励你尝试不同数量的桶和桶大小。
如果你更愿意将RequestDuration实现为总结性指标,可以将列表 13-22 中的代码替换为列表 13-23 中的代码。
`--snip--`
RequestDuration 1metrics.Histogram = prometheus.NewSummaryFrom(
prom.SummaryOpts{
Namespace: *Namespace,
Subsystem: *Subsystem,
Name: "request_duration_summary_seconds",
Help: "Total duration of all requests",
},
[]string{},
)
)
列表 13-23:可选地创建一个总结性指标
如你所见,这看起来很像一个直方图,除了Bucket方法。请注意,你仍然使用metrics.Histogram接口 1 来处理 Prometheus 的总结性指标。这是因为 Go kit 并不区分直方图和总结性指标,只有你的接口实现才会区分。
仪表化基础 HTTP 服务器
让我们在一个实际示例中结合这些指标类型:对一个 Go HTTP 服务器进行仪表化。这里最大的挑战是确定你要监控什么,最合适的监控位置在哪里,以及每个你想追踪的值最适合使用什么类型的指标。如果你使用 Prometheus 作为你的指标平台,就像在这里一样,你还需要为 Prometheus 服务器添加一个 HTTP 端点来抓取数据。
列表 13-24 详细介绍了应用程序所需的初始代码,该应用程序包括一个 HTTP 服务器来提供指标端点,另一个 HTTP 服务器将所有请求传递给一个仪表化的处理程序。
package main
import (
"bytes"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"math/rand"
"net"
"net/http"
"sync"
"time"
1 "github.com/prometheus/client_golang/prometheus/promhttp"
2 "github.com/awoodbeck/gnp/ch13/instrumentation/metrics"
)
var (
metricsAddr = 3flag.String("metrics", "127.0.0.1:8081",
"metrics listen address")
webAddr = 4flag.String("web", "127.0.0.1:8082", "web listen address")
)
列表 13-24:指标示例的导入和命令行标志(instrumentation/main.go)
你的代码所需的唯一导入是promhttp包(用于指标端点)和你的metrics包(用于仪表化你的代码)。promhttp包 1 包含一个http.Handler,Prometheus 服务器可以使用它从你的应用程序抓取指标。这个处理程序不仅提供你的指标,还提供与运行时相关的指标,比如 Go 版本、核心数量等等。至少,你可以使用 Prometheus 处理程序提供的指标来洞察服务的内存利用率、打开的文件描述符、堆栈详细信息等。
你metrics包中导出的所有变量 2 都是 Go kit 接口。你的代码无需关心底层的度量平台或其实现,只需关注这些度量如何提供给度量服务器。在实际应用中,你可以进一步抽象 Prometheus 处理程序,以便完全消除除了你的度量包以外的其他依赖。但为了保持本示例简洁,我将 Prometheus 处理程序包含在了main包中。
接下来是你要进行仪表化的代码。列表 13-25 添加了你的 Web 服务器将用于处理所有传入请求的函数。
`--snip--`
func helloHandler(w http.ResponseWriter, _ *http.Request) {
1 metrics.Requests.Add(1)
defer func(start time.Time) {
2 metrics.RequestDuration.Observe(time.Since(start).Seconds())
}(time.Now())
_, err := w.Write([]byte("Hello!"))
if err != nil {
3 metrics.WriteErrors.Add(1)
}
}
列表 13-25:一个带有随机延迟的仪表化处理程序(instrumentation/main.go)
即便是这样一个简单的处理程序,你也能进行三项有意义的度量。你在进入处理程序 1 时增加请求计数器,因为这是记录它的最合适位置。你还立即延迟一个函数来计算请求持续时间,并使用请求持续时间的汇总度量进行观察 2。最后,你会记录写响应时出现的任何错误 3。
现在,你需要使用这个处理程序。但首先,你需要一个帮助函数,允许你启动几个 HTTP 服务器:一个用于提供 metrics 端点,另一个用于提供这个处理程序。列表 13-26 详细描述了这样的一个函数。
`--snip--`
func newHTTPServer(addr string, mux http.Handler,
stateFunc 1func(net.Conn, http.ConnState)) error {
l, err := net.Listen("tcp", addr)
if err != nil {
return err
}
srv := &http.Server{
Addr: addr,
Handler: mux,
IdleTimeout: time.Minute,
ReadHeaderTimeout: 30 * time.Second,
ConnState: stateFunc,
}
go func() { log.Fatal(srv.Serve(l)) }()
return nil
}
func 2connStateMetrics(_ net.Conn, state http.ConnState) {
switch state {
case http.StateNew:
3 metrics.OpenConnections.Add(1)
case http.StateClosed:
4 metrics.OpenConnections.Add(-1)
}
}
列表 13-26:创建 HTTP 服务器并监控连接状态的函数(instrumentation/main.go)
这个 HTTP 服务器代码类似于第九章的代码。唯一的不同是你在此定义了服务器的ConnState字段,并将其作为参数 1 传递给newHTTPServer函数。
HTTP 服务器每当网络连接发生变化时,都会调用其ConnState字段。你可以利用这个功能来监控服务器在任何时刻的开放连接数量。每当你想初始化一个新的 HTTP 服务器并跟踪其开放连接时,可以将connStateMetrics函数 2 传递给newHTTPServer函数。如果服务器建立了新连接,你会将开放连接计量器 3 增加 1。如果连接关闭,你会将计量器 4 减少 1。Go kit 的计量器接口提供了Add方法,因此减少一个值实际上是加上一个负数。
让我们创建一个例子,将所有这些部分结合在一起。列表 13-27 创建了一个 HTTP 服务器来提供 Prometheus 端点,并创建了另一个 HTTP 服务器来提供你的仪表化处理程序。
`--snip--`
func main() {
flag.Parse()
rand.Seed(time.Now().UnixNano())
mux := http.NewServeMux()
1 mux.Handle("/metrics/", promhttp.Handler())
if err := newHTTPServer(*metricsAddr, mux, 2nil); err != nil {
log.Fatal(err)
}
fmt.Printf("Metrics listening on %q ...\n", *metricsAddr)
if err := newHTTPServer(*webAddr, 3http.HandlerFunc(helloHandler),
4connStateMetrics); err != nil {
log.Fatal(err)
}
fmt.Printf("Web listening on %q ...\n\n", *webAddr)
列表 13-27:启动两个 HTTP 服务器来提供metrics和helloHandler(instrumentation/main.go)
首先,你启动一个 HTTP 服务器,唯一的目的是在/metrics/端点提供 Prometheus 处理程序 1,默认情况下 Prometheus 会从这个端点抓取指标。由于你没有为第三个参数 2 传入函数,这个 HTTP 服务器不会为它的ConnState字段分配一个在每次连接状态变化时调用的函数。然后,你启动另一个 HTTP 服务器,使用helloHandler3 来处理每个请求。但这次,你传入了connStateMetrics函数 4。结果,这个 HTTP 服务器将衡量开放连接。
现在,你可以启动多个 HTTP 客户端,发送大量请求来影响你的指标(参见 Listing 13-28)。
`--snip--`
clients := 1500
gets := 2100
wg := new(sync.WaitGroup)
fmt.Printf("Spawning %d connections to make %d requests each ...",
clients, gets)
for i := 0; i < clients; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c := &http.Client{
Transport: 3http.DefaultTransport.(*http.Transport).Clone(),
}
for j := 0; j < gets; j++ {
resp, err := 4c.Get(fmt.Sprintf("http://%s/", *webAddr))
if err != nil {
log.Fatal(err)
}
_, _ = 5io.Copy(ioutil.Discard, resp.Body)
_ = 6resp.Body.Close()
}
}()
}
7 wg.Wait()
fmt.Print(" done.\n\n")
Listing 13-28: 指示 500 个 HTTP 客户端每个执行 100 次 GET 请求(instrumentation/main.go)
首先,你会启动 500 个 HTTP 客户端,每个客户端进行 100 次 GET 请求。但是在此之前,你需要解决一个问题。http.Client会使用http.DefaultTransport,如果它的Transport方法为 nil。http.DefaultTransport在缓存 TCP 连接方面表现出色。如果所有 500 个 HTTP 客户端使用相同的传输,它们将通过大约两个 TCP 套接字进行通信。当你完成这个示例时,我们的开放连接指标将反映出这两个空闲连接,而这并不是我们的目标。
但你必须确保每个 HTTP 客户端都有自己的传输。克隆默认传输 3 对于我们的目的已经足够。
现在,每个客户端都有自己的传输,并且你可以确保每个客户端都会建立自己的 TCP 连接,你可以让每个客户端通过 GET 请求 4 进行 100 次迭代。你还必须小心地清理 5 和关闭 6 响应体,以便每个客户端可以重用其 TCP 连接。
一旦所有 500 个 HTTP 客户端完成它们的 100 次请求 7,你就可以继续查看 Listing 13-29,检查当前的指标状态。
`--snip--`
resp, err := 1http.Get(fmt.Sprintf("http://%s/metrics", *metricsAddr))
if err != nil {
log.Fatal(err)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
log.Fatal(err)
}
_ = resp.Body.Close()
metricsPrefix := 2fmt.Sprintf("%s_%s", *metrics.Namespace,
*metrics.Subsystem)
fmt.Println("Current Metrics:")
for _, line := range bytes.Split(b, []byte("\n")) {
if 3bytes.HasPrefix(line, []byte(metricsPrefix)) {
fmt.Printf("%s\n", line)
}
}
}
Listing 13-29: 显示匹配你命名空间和子系统的当前指标(instrumentation/main.go)
你从指标端点 1 检索所有的指标。这将导致指标 Web 服务器返回 Prometheus 客户端存储的所有指标,除了它跟踪的每个指标的详细信息外,还包括你添加的指标。由于你只对自己的指标感兴趣,你可以检查每一行,以你的命名空间、下划线和子系统 2 开头的行。如果该行匹配这个前缀 3,你就将其打印到标准输出。否则,你忽略该行并继续。
让我们在命令行上运行这个示例,并检查在 Listing 13-30 中生成的指标。
$ **go run instrumentation/main.go**
Metrics listening on "127.0.0.1:8081" ...
Web listening on "127.0.0.1:8082" ...
Spawning 500 connections to make 100 requests each ... done.
Current Metrics:
web_server1_open_connections 1500
web_server1_request_count 250000
web_server1_request_duration_histogram_seconds_bucket{le="1e-07"} 30
web_server1_request_duration_histogram_seconds_bucket{le="2e-07"} 1
web_server1_request_duration_histogram_seconds_bucket{le="3e-07"} 613
web_server1_request_duration_histogram_seconds_bucket{le="4e-07"} 13591
web_server1_request_duration_histogram_seconds_bucket{le="5e-07"} 33216
web_server1_request_duration_histogram_seconds_bucket{le="1e-06"} 40183
web_server1_request_duration_histogram_seconds_bucket{le="2.5e-06"} 49876
web_server1_request_duration_histogram_seconds_bucket{le="5e-06"} 49963
web_server1_request_duration_histogram_seconds_bucket{le="7.5e-06"} 49973
web_server1_request_duration_histogram_seconds_bucket{le="1e-05"} 49979
web_server1_request_duration_histogram_seconds_bucket{le="0.0001"} 49994
web_server1_request_duration_histogram_seconds_bucket{le="0.001"} 49997
web_server1_request_duration_histogram_seconds_bucket{le="0.01"} 450000
web_server1_request_duration_histogram_seconds_bucket{le="+Inf"} 50000
web_server1_request_duration_histogram_seconds_sum 50.04102166899999979
web_server1_request_duration_histogram_seconds_count 650000
Listing 13-30: Web 服务器输出和生成的指标
正如预期的那样,在你查询指标时,500 个连接已打开 1。这些连接处于空闲状态。你可以通过在完成 100 次 GET 请求后调用 HTTP 客户端的 CloseIdleConnections 方法来进行实验;看看这个变化如何影响打开连接的仪表。同样,看看当你没有定义它们的 Transport 字段时,打开连接会发生什么。
请求计数是 50,000 2,因此所有请求都成功。
你注意到缺少了什么吗?写入错误计数器。由于没有发生写入错误,写入错误计数器从未增加。因此,它不会出现在指标输出中。你可以调用 metrics.WriteErrors.Add(0) 使该指标出现,但其缺失可能会让你比 Prometheus 更困扰。只要意识到,指标输出可能不会包括所有已加测的指标,而只包括自初始化以来发生变化的指标。
基础的 Prometheus 直方图是一个 累积 直方图:任何增加某个桶计数器的值都会同时增加所有小于该值的桶计数器。因此,你会看到每个桶的值逐渐增大,直到你到达 0.01 桶 4。即使你定义了一个桶范围,Prometheus 也会为你添加一个无限大的桶。在这个例子中,你定义了一个比所有观察到的值 3 更小的桶,因此它的计数器仍然为零。
一个直方图和一个汇总会维护两个额外的计数器:所有观察到的值之和 5 和观察到的值的总数 6。如果使用汇总,Prometheus 端点将只显示这两个计数器。它不会像直方图那样详细展示汇总的桶。因此,Prometheus 服务器可以聚合直方图桶,但无法对汇总做同样的操作。
你学到了什么
日志记录很难,仪表化则不然。尽量节俭于日志记录,慷慨于仪表化。日志记录不是免费的,如果你不小心控制日志的位置和数量,很快就会增加延迟。记录可以作为有效的行动项,尤其是那些应该触发警报的项,绝对不会出错。另一方面,仪表化非常高效。你应该对一切进行仪表化,至少在初期是如此。指标详细说明了服务的当前状态,并提供了潜在问题的洞察,而日志则提供了一种不可变的审计跟踪,解释了服务的当前状态并帮助你诊断故障。
Go 的log包提供了足够的功能来满足基本的日志需求。但当你需要将日志输出到多个目标或在不同的详细级别下记录日志时,它变得繁琐。在这种情况下,使用像 Uber 的 Zap 日志记录器这样的综合解决方案会更好。无论你使用什么日志记录器,都要考虑通过添加额外的元数据来给日志条目添加结构。结构化日志记录可以让你利用软件快速过滤和搜索日志条目,特别是当你将日志集中到整个基础设施时。
按需调试日志记录和广泛事件日志记录是你可以用来收集重要信息的方法,同时最小化日志记录对服务性能的影响。你可以通过创建信号量文件来指示日志记录器启用调试日志记录。当你删除信号量文件时,日志记录器会立即禁用调试日志记录。广泛事件日志汇总了请求-响应循环中的事件。你可以用一个广泛的事件日志替代多个日志条目,而不会影响你诊断故障的能力。
一种仪表化的方法是使用 Go kit 的metrics包,它提供了常见指标类型的接口和流行的指标平台适配器。它允许你将每个指标平台的细节抽象化,从而将它们与仪表化代码分离开。
metrics包支持计数器、仪表、历史记录和摘要。计数器单调递增,可用于计算变化率。使用计数器来跟踪诸如请求计数、错误计数或已完成任务等值。仪表跟踪那些可以增加和减少的值,如当前内存使用情况、进行中的请求和队列大小。历史记录和摘要将观察到的值放入桶中,并允许你估算所有值的平均值或百分比。你可以使用历史记录或摘要来近似请求持续时间或响应大小的平均值。
总而言之,日志记录和指标提供了对你的服务的必要洞察,帮助你主动解决潜在问题并从故障中恢复。
第十四章:迁移到云端

2006 年 8 月,Amazon Web Services (AWS)推出了 Elastic Compute Cloud(EC2),为公共云基础设施的普及做出了贡献。EC2 消除了通过互联网提供服务的障碍;你不再需要购买服务器和软件许可证、签署支持合同、租赁办公空间或雇佣 IT 专业人员来维护基础设施。相反,你按需支付 AWS 使用 EC2 实例的费用,允许你在 AWS 处理维护、冗余和标准合规等细节的同时,扩展你的业务。在接下来的几年里,Google 和 Microsoft 也推出了与 AWS 竞争的公共云服务。现在,三大云服务提供商都提供全面的服务,涵盖从分析到存储的所有内容。
本章的目标是为你提供 Amazon Web Services、Google Cloud 和 Microsoft Azure 的对比,让你能够一目了然地了解各自的不同。你将创建并部署一个应用程序,展示每个提供商的工具、认证和部署体验的差异。你的应用程序将遵循平台即服务 (PaaS) 模式,在云服务商的平台上创建并部署应用程序。具体来说,你将创建一个函数并将其部署到 AWS Lambda、Google Cloud Functions 和 Microsoft Azure Functions。我们将尽量使用命令行,以便保持对比的公平,并让你了解每个提供商的工具。
三大服务提供商都提供试用期,因此你不应产生任何费用。如果你已用完试用期,请在继续执行后续步骤时注意可能的费用。
你将创建一个简单的功能,获取最新 XKCD 漫画的 URL,或者选择获取前一篇漫画。这将演示如何从函数内检索数据,以满足客户端请求,并在执行之间持久化函数状态。
在本章结束时,你应该能够自如地编写应用程序、部署应用程序,并测试它以利用 AWS、Google Cloud 和 Microsoft Azure 的 PaaS 服务。如果你选择迁移到云平台,你应该更清楚地了解哪个提供商的工作流最适合你的使用案例。
打好基础
XKCD 网站提供一个 Real Simple Syndication (RSS)源,地址为xkcd.com/rss.xml。正如文件扩展名所示,源使用 XML 格式。你可以使用 Go 的encoding/xml包来解析这个源。
在将可以获取最新 XKCD 漫画 URL 的功能部署到云端之前,你需要编写一些代码来解析 RSS 源。列表 14-1 创建了两种类型来解析该源。
package feed
import (
"context"
"encoding/xml"
"fmt"
"io/ioutil"
"net/http"
)
type Item struct {
Title string `xml:"title"`
URL string `xml:"link"`
Published string 1`xml:"pubDate"`
}
type RSS struct {
Channel struct {
Items []Item `xml:"item"`
} `xml:"channel"`
entityTag 2string
}
列表 14-1:表示 XKCD RSS 源的结构(feed/rss.go)
RSS 结构体表示 RSS 订阅源,而Item 结构体表示订阅源中的每个项(漫画)。与你在早期章节中使用的 Go 的encoding/json包一样,它的encoding/xml包可以使用结构体标签将 XML 标签映射到相应的结构体字段。例如,Published字段的标签指示encoding/xml包将其赋值为项的pubDate值。
做一个好的互联网邻居并跟踪订阅源的实体标签非常重要。Web 服务器通常为可能不发生变化的内容推导实体标签。客户端可以跟踪这些实体标签,并在以后的请求中提供它们。如果服务器确定请求的内容具有相同的实体标签,它可以避免返回整个负载,并返回 304 Not Modified 状态码,这样客户端就知道使用其缓存的副本。你将在示例 14-2 中使用这个值,在订阅源更改时有条件地更新RSS 结构体。
`--snip--`
func (r RSS) Items() []Item {
items := 1make([]Item, len(r.Channel.Items))
copy(items, r.Channel.Items)
return items
}
func (r *RSS) ParseURL(ctx context.Context, u string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
if r.entityTag != "" {
2 req.Header.Add("ETag", r.entityTag)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
switch resp.StatusCode {
case 2http.StatusNotModified: // no-op
case 3http.StatusOK:
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
_ = resp.Body.Close()
err = xml.Unmarshal(b, r)
if err != nil {
return err
}
r.entityTag = 4resp.Header.Get("ETag")
default:
return fmt.Errorf("unexpected status code: %v", resp.StatusCode)
}
return nil
}
示例 14-2:解析 XKCD RSS 订阅源并返回项的切片(feed/rss.go)
这里有三点需要注意。首先,RSS 结构体及其方法不适合并发使用。虽然这对于你的用例来说并不成问题,但最好还是了解这一点。第二,Items 方法返回RSS 结构体中项的切片,该切片在你的代码调用ParseURL方法填充RSS 结构体之前是空的。第三,Items 方法会复制Items切片,并返回该副本,以防止原始Items切片被破坏。这对于你的用例来说可能有点过度,但最好知道你返回的是一个可以被接收方修改的引用类型。如果接收方修改了副本,它不会影响原始的Items。
解析 RSS 订阅源非常简单,应该看起来很熟悉。ParseURL 方法通过使用 GET 请求来检索 RSS 订阅源。如果订阅源是新的,该方法会从响应体中读取 XML,并调用xml.Unmarshal函数将 XML 反序列化到RSS结构体中。
请注意,你条件性地设置了请求的ETag头部,以便 XKCD 服务器能够判断是否需要发送订阅源内容,还是你已经拥有最新版本。如果服务器响应 304 Not Modified 状态码,RSS 结构体保持不变。如果你收到 200 OK 响应,则表示你收到了订阅源的新版本,并将响应体中的 XML 反序列化到RSS 结构体中。如果成功,你将更新实体标签。
在这个逻辑下,RSS 结构体应当仅在其实体标签为空时更新自己,这在结构体初始化时会发生,或者当有新的订阅源时。
最后一个任务是使用以下命令创建go.mod文件:
$ **cd feed**
feed$ **go mod init github.com/awoodbeck/gnp/ch14/feed**
go: creating new go.mod: module github.com/awoodbeck/gnp/ch14/feed
feed$ **cd -**
这些命令初始化一个名为github.com/awoodbeck/gnp/ch14/feed的新模块,代码将在本章后面使用。
AWS Lambda
AWS Lambda 是一个无服务器平台,提供对 Go 语言的优质支持。你可以创建 Go 应用程序,部署它们,并让 Lambda 处理实现细节。它将根据需求扩展你的代码。在开始使用 Lambda 之前,请确保你在 aws.amazon.com/ 创建了一个试用帐户。
安装 AWS 命令行界面
AWS 为 Windows、macOS 和 Linux 提供版本 2 的命令行界面(CLI)工具。你可以在 docs.aws.amazon.com/cli/latest/userguide/cli-chap-install.html 找到详细的安装说明。
使用以下命令在 Linux 上安装 AWS CLI 工具:
$ **curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \**
**-o "awscliv2.zip"**
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 32.3M 100 32.3M 0 0 31.1M 0 0:00:01 0:00:01 --:--:-- 31.1M
$ **unzip -q awscliv2.zip**
$ **sudo ./aws/install**
[sudo] password for user:
You can now run: /usr/local/bin/aws --version
$ **aws --version**
aws-cli/2.0.56 Python/3.7.3 Linux/5.4.0-7642-generic exe/x86_64.pop.20
下载 AWS CLI 版本 2 的压缩包。使用 curl 从命令行下载 ZIP 文件。然后解压该压缩包,并使用 sudo 运行 ./aws/install 可执行文件。完成后,运行 aws --version 以验证 AWS 二进制文件是否在你的路径中,并且你正在运行版本 2。
配置 CLI
既然你已经安装了 AWS CLI,你需要使用凭证对其进行配置,以便它可以代表你的账户与 AWS 交互。本节将指导你完成该过程。如果你感到困惑,可以参考 AWS CLI 配置快速入门指南 docs.aws.amazon.com/cli/latest/userguide/cli-configure-quickstart.html。
首先,访问 AWS 控制台 console.aws.amazon.com。登录 AWS 控制台后,访问 图 14-1 中显示的下拉菜单。

图 14-1:访问你的 AWS 账户安全凭证
点击 AWS 控制台右上角的账户名称(个人,见 图 14-1)。然后,从下拉菜单中选择 我的安全凭证。该链接应将你带到“你的安全凭证”页面,如 图 14-2 所示。

图 14-2:创建新的访问密钥
选择 访问密钥 部分标题以展开该部分。然后点击 创建新访问密钥 按钮,以创建可以在命令行中使用的凭证。这将显示凭证(图 14-3)。

图 14-3:检索新的访问密钥 ID 和秘密访问密钥
你需要访问密钥 ID 和秘密访问密钥来在命令行中使用 AWS 进行身份验证。确保下载密钥文件并将其保存在安全的位置,以备将来需要时使用。现在,你将使用它们来配置你的 AWS 命令行界面:
$ **aws configure**
AWS Access Key ID [None]: **AIDA1111111111EXAMPLE**
AWS Secret Access Key [None]: **YxMCBWETtZjZhW6VpLwPDY5KqH8hsDG45EXAMPLE**
Default region name [None]: **us-east-2**
Default output format [None]: **yaml**
在命令行中,调用 aws configure 命令。你将被提示输入 图 14-3 中的访问密钥 ID 和秘密访问密钥。
你还可以指定默认区域和默认输出格式。区域是你服务的地理终端。在这个例子中,我告诉 AWS 我希望我的服务默认使用 us-east-2 终端,这个终端位于俄亥俄州。你可以在 docs.aws.amazon.com/general/latest/gr/rande.html 找到一个区域终端的通用列表。
创建角色
你的代码需要一个特定的身份在 AWS 中运行。这个身份叫做 角色。你可以在你的 AWS 账户下拥有多个角色,并为每个角色分配不同的权限。然后,你可以将角色分配给 AWS 服务,这样服务就能访问你的资源,而不需要你为每个服务单独分配凭证(比如访问密钥 ID 和密钥访问密钥)。在本章中,你将使用角色来授予 AWS Lambda 访问你将编写的 Lambda 函数的权限。
暂时,你将只创建一个角色,并授予 AWS Lambda 角色的权限,以便它能够调用你的代码。清单 14-3 详细说明了一个简单的信任策略文档,该文档分配了适当的访问权限。信任策略文档列出了你将分配给新角色的一组权限。
{
"Version": 1"2012-10-17",
"Statement": [
{
"Effect": 2"Allow",
"Principal": {
"Service": 3"lambda.amazonaws.com"
},
"Action": 4"sts:AssumeRole"
}
]
}
清单 14-3:为你的新角色定义信任策略(aws/trust-policy.json)
这个信任策略告诉 AWS 你希望允许 Lambda 服务来承担该角色。信任策略版本 1 是当前信任策略语言的版本,而不是一个任意的日期。
接下来,创建你将为其分配该信任策略的角色:
$ **aws iam create-role --role-name "lambda-xkcd" \**
**--assume-role-policy-document file://aws/trust-policy.json**
Role:
1 Arn: arn:aws:iam::123456789012:role/lambda-xkcd
2 AssumeRolePolicyDocument:
Statement:
- Action: sts:AssumeRole
Effect: Allow
Principal:
Service: lambda.amazonaws.com
Version: '2012-10-17'
CreateDate: '2006-01-02T15:04:05+00:00'
Path: /
RoleId: AROA1111111111EXAMPLE
RoleName: lambda-xkcd
使用 AWS 身份与访问管理(IAM)服务创建一个新角色,角色名称为 lambda-xkcd,并使用你在 清单 14-3 中创建的 aws/trust-policy.json 文档。如果成功,这将使用你的信任策略 2 创建一个新角色。IAM 为该角色分配一个 Amazon 资源名称(ARN)。ARN 1 是此角色的唯一标识符,你将在调用代码时使用它。
定义 AWS Lambda 函数
AWS Lambda 的 Go 库在 Lambda 函数签名方面为你提供了一些灵活性。你的函数必须符合以下其中一种格式:
func()
func() error
func(TypeIn) error
func() (TypeOut, error)
func(context.Context) error
func(context.Context, TypeIn) error
func(context.Context) (TypeOut, error)
func(context.Context, TypeIn) (TypeOut, error)
TypeIn 和 TypeOut 对应 encoding/json 兼容类型,发送到 Lambda 函数的 JSON 输入将被解码为 TypeIn。同样,函数返回的 TypeOut 将在到达目标之前被编码为 JSON。你将使用本节中的最后一个函数签名。
你将编写的函数应该让你体验无服务器环境中能做什么。它将接受来自客户端的输入,通过互联网获取资源,保持在函数调用之间的状态,并响应客户端。如果你读过第九章,你知道你可以编写一个 http.Handler 来执行这些操作,但 AWS Lambda 需要一种稍有不同的方法。你不会使用 http.Request 或 http.ResponseWriter。相反,你将使用自己创建的类型或从其他模块导入的类型。AWS Lambda 会为你处理数据的解码和编码,将数据传入和传出你的函数。
让我们开始编写你的第一段无服务器代码(清单 14-4)。
package main
import (
"context"
"github.com/awoodbeck/gnp/ch14/feed"
"github.com/aws/aws-lambda-go/lambda"
)
var (
rssFeed 1feed.RSS
feedURL = 2"https://xkcd.com/rss.xml"
)
type EventRequest struct {
Previous bool `json:"previous"`
}
type EventResponse struct {
Title string `json:"title"`
URL string `json:"url"`
Published string `json:"published"`
}
清单 14-4:创建持久变量以及请求和响应类型 (aws/xkcd.go)
你可以在包级别指定变量,这些变量会在函数调用之间保持持久性,同时函数本身在内存中保持存在。在这个示例中,你定义了一个 feed 对象 1 和 RSS 源的 URL 2。创建并填充一个新的 feed.RSS 对象需要一定的开销。如果你将该对象存储在包级别的变量中,它将在每次函数调用之后仍然存在,从而避免了后续函数调用中的开销。这还可以让你利用 feed.RSS 中对实体标签的支持。
EventRequest 和 EventResponse 类型定义了客户端请求的格式和函数的响应格式。AWS Lambda 会将客户端 HTTP 请求体中的 JSON 解组到 EventRequest 对象中,并将函数的 EventResponse 编组为 JSON,放入 HTTP 响应体中,再返回给客户端。
清单 14-5 定义了 main 函数,并开始定义兼容 AWS Lambda 的函数。
`--snip--`
func main() {
1 lambda.Start(LatestXKCD)
}
func LatestXKCD(ctx context.Context, req EventRequest) (
EventResponse, error) {
resp := 2EventResponse{Title: "xkcd.com", URL: "https://xkcd.com/"}
if err := 3rssFeed.ParseURL(ctx, feedURL); err != nil {
return resp, err
}
清单 14-5:主函数以及名为 LatestXKCD 的 Lambda 函数的第一部分 (aws/xkcd.go)
通过将你的函数传递给 lambda.Start 方法 1,将它与 Lambda 连接起来。如果你的函数需要,你可以在 init 函数中实例化依赖项,或者在这条语句之前实例化它们。
LatestXKCD 函数接受一个上下文和一个 EventRequest,并返回一个 EventResponse 和一个 error 接口。它定义了一个响应对象 2,并设置了默认的 Title 和 URL 值。在发生错误或源为空时,函数将按原样返回响应。
从 清单 14-4 解析源 URL 3,会将最新的源细节填充到 rssFeed 对象中。清单 14-6 使用这些细节来构建响应。
`--snip--`
switch items := rssFeed.Items(); {
case 1req.Previous && len(items) > 1:
resp.Title = items[1].Title
resp.URL = items[1].URL
resp.Published = items[1].Published
case len(items) > 0:
resp.Title = items[0].Title
resp.URL = items[0].URL
resp.Published = items[0].Published
}
return resp, nil
}
清单 14-6:用源结果填充响应(aws/xkcd.go)
如果客户端请求了之前的 XKCD 漫画 1,并且至少有两个提要项,函数会用之前的 XKCD 漫画的详细信息填充响应。否则,函数会用最新的 XKCD 漫画的详细信息填充响应,只要提要中至少有一个项。如果这两种情况都不成立,客户端将收到默认值的响应,来自 Listing 14-5。
编译、打包和部署你的函数
AWS Lambda 希望你在部署归档之前编译代码并将生成的二进制文件压缩,使用 AWS CLI 工具来完成此操作。为此,请在 Linux、macOS 或 WSL 中使用以下命令:
$ **GOOS=linux go build aws/xkcd.go**
$ **zip xkcd.zip xkcd**
adding: xkcd (deflated 50%)
$ **aws lambda create-function --function-name "xkcd" --runtime "go1.x" \**
**--handler "xkcd" --role "arn:aws:iam::123456789012:role/lambda-xkcd" \**
**--zip-file "fileb://xkcd.zip"**
CodeSha256: M36I7oiS8+S9AryIthcizsjdLDKXMaJKvZvsZzZDNH0=
CodeSize: 6597490
Description: ''
FunctionArn: arn:aws:lambda:us-east-2:123456789012:function:xkcd
FunctionName: 1xkcd
Handler: 2xkcd
LastModified: 2006-01-02T15:04:05.000+0000
LastUpdateStatus: Successful
MemorySize: 128
RevisionId: b094a881-9c49-4b86-86d5-eb4335507eb0
Role: arn:aws:iam::123456789012:role/lambda-xkcd
Runtime: go1.x
State: Active
Timeout: 3
TracingConfig:
Mode: PassThrough
Version: $LATEST
编译 aws/xkcd.go 并将生成的 xkcd 二进制文件添加到一个 ZIP 文件中。然后,使用 AWS CLI 创建一个名为 xkcd 的新函数,指定处理程序为 xkcd,运行时为 go1.x,并使用之前创建的角色 ARN 和包含 xkcd 二进制文件的 ZIP 文件。请注意命令行中的 fileb://xkcd.zip URL,这告诉 AWS CLI 可以在当前目录中找到名为 xkcd.zip 的二进制文件 (fileb)。
如果成功,AWS CLI 会输出新 Lambda 函数的详细信息:AWS 中的函数名称 1,之后你将在命令行中使用该名称管理你的函数,以及 ZIP 文件中二进制文件的文件名 2。
在 Windows 上,二进制文件的编译和打包略有不同。我建议你在 PowerShell 中执行此操作,因为你可以在命令行中压缩跨平台编译的二进制文件,而无需安装特定的压缩工具。
PS C:\Users\User\dev\gnp\ch14> setx GOOS linux
SUCCESS: Specified value was saved.
PS C:\Users\User\dev\gnp\ch14> \Go\bin\go.exe build -o xkcd .\aws\xkcd.go
go: downloading github.com/aws/aws-lambda-go v1.19.1
`--snip--`
PS C:\Users\User\dev\gnp\ch14> Compress-Archive xkcd xkcd.zip
此时,使用 AWS CLI 工具像之前的列表中那样部署 ZIP 文件。
如果需要更新函数代码,请重新编译二进制文件并再次打包。然后使用以下命令更新现有的 Lambda 函数:
$ **aws lambda update-function-code --function-name "xkcd" \**
**--zip-file "fileb://xkcd.zip"**
由于你正在更新现有的函数,因此只需要提供函数名和 ZIP 文件名。其余的由 AWS 处理。
作为练习,更新代码以允许客户端请求强制刷新 XKCD RSS 提要。然后,更新函数以应用这些更改,并继续到下一部分进行测试。
测试你的 AWS Lambda 函数
AWS CLI 工具使测试 Lambda 函数变得容易。你可以使用它们发送 JSON 有效负载并捕获 JSON 响应。通过提供函数名和文件路径来调用该函数,AWS CLI 会将响应体填充到其中:
$ **aws lambda invoke --function-name "xkcd" response.json**
ExecutedVersion: $LATEST
StatusCode: 200
如果调用成功,你可以通过查看 response.json 的内容来验证函数是否返回了 XKCD 漫画的名称和 URL:
$ **cat response.json**
{"title":"Election Screen Time","url":"https://xkcd.com/2371/",
"published":"Mon, 12 Oct 2020 04:00:00 -0000"}
你还可以通过添加一些额外的命令行参数来使用自定义请求体调用函数。如果你将格式指定为 raw-in-base64-out,可以传递一个 payload 字符串。这告诉 AWS CLI 将你提供的字符串进行 Base64 编码,然后将其分配给请求体,并传递给函数:
$ **aws lambda invoke --cli-binary-format "raw-in-base64-out" \**
**--payload '{"previous":true}' --function-name "xkcd" response.json**
ExecutedVersion: $LATEST
StatusCode: 200
$ **cat response.json**
{"title":"Chemist Eggs","url":"https://xkcd.com/2373/",
"published":"Fri, 16 Oct 2020 04:00:00 -0000"}
Google Cloud Functions
与 AWS Lambda 类似,Google Cloud Functions 允许您在无服务器环境中部署代码,将实现细节交给 Google 处理。不出所料,Go 在 Cloud Functions 中享有一流支持。
在继续进行此部分之前,您需要一个 Google Cloud 帐户。访问cloud.google.com开始注册试用帐户。
安装 Google Cloud 软件开发工具包
Google Cloud 软件开发工具包(SDK)要求 Python 2.7.9 或 3.5 以上版本。您需要确保操作系统上安装了适合的 Python 版本,然后才能继续。您可以按照 Google 提供的全面安装指南cloud.google.com/sdk/docs/install/,其中包含 Windows、macOS 和各种 Linux 版本的具体安装说明。
以下是通用的 Linux 安装步骤:
$ **curl -O** **https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/\**
**google-cloud-sdk-319.0.1-linux-x86_64.tar.gz**
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 81.9M 100 81.9M 0 0 34.1M 0 0:00:02 0:00:02 --:--:-- 34.1M
$ **tar xf google-cloud-sdk-319.0.1-linux-x86_64.tar.gz**
$ **./google-cloud-sdk/install.sh**
Welcome to the Google Cloud SDK!
`--snip--`
下载当前的 Google Cloud SDK 压缩包(版本会经常变动!)并解压。然后,运行 ./google-cloud-sdk/install.sh 脚本。安装过程中会询问一些与您环境相关的问题。我已从输出中省略这些内容,以简洁为主。
初始化 Google Cloud SDK
您需要授权 Google Cloud SDK,才能使用它来部署代码。与 AWS 相比,Google 使这个过程变得简单。无需创建凭据并将其复制粘贴到命令行中。相反,Google Cloud 使用您的 Web 浏览器进行身份验证和授权。
gcloud init 命令相当于 aws configure 命令,它将引导您配置 Google Cloud 命令行环境:
$ **./google-cloud-sdk/bin/gcloud init**
Welcome! This command will take you through the configuration of gcloud.
`--snip--`
Pick cloud project to use:
1 [1] Create a new project
Please enter numeric choice or text value (must exactly match list
item): **1**
Enter a Project ID. Note that a Project ID CANNOT be changed later.
Project IDs must be 6-30 characters (lowercase ASCII, digits, or
hyphens) in length and start with a lowercase letter. **goxkcd**
`--snip--`
$ **gcloud projects list**
PROJECT_ID NAME PROJECT_NUMBER
goxkcd goxkcd 123456789012
过程的第一步将会在您的浏览器中打开一个页面,用于将 Google Cloud SDK 与您的 Google Cloud 帐户进行身份验证。如果您的 Google Cloud 帐户已有项目,则您的命令行输出可能与此处的输出略有不同。为了本章节的目的,选择创建一个新项目 1 并为其指定一个项目 ID——在本示例中为 goxkcd。您的项目 ID 必须在 Google Cloud 中唯一。一旦完成此步骤,您就可以像在 AWS 中一样,从命令行与 Google Cloud 进行交互。
启用结算和 Cloud Functions
在使用 Cloud Functions 之前,您需要确保为您的项目启用结算功能。访问cloud.google.com/billing/docs/how-to/modify-project/了解如何修改现有项目的结算信息。启用后,您可以继续启用项目的 Cloud Functions 访问权限。此时,您可以开始编写代码。
定义一个 Cloud Function
Cloud Functions 使用 Go 的模块支持,而不是像 AWS Lambda 那样要求你编写一个独立的应用程序。这简化了你的代码,因为你不需要导入任何特定于 Cloud Functions 的库,也不需要定义一个主函数作为执行的入口点。
Listing 14-7 提供了一个兼容 Cloud Functions 的模块的初始代码。
package gcp
import (
"encoding/json"
"log"
"net/http"
"github.com/awoodbeck/gnp/ch14/feed"
)
var (
rssFeed feed.RSS
feedURL = "https://xkcd.com/rss.xml"
)
type EventRequest struct {
Previous bool `json:"previous"`
}
type EventResponse struct {
Title string `json:"title"`
URL string `json:"url"`
Published string `json:"published"`
}
Listing 14-7:创建持久变量和请求、响应类型 (gcp/xkcd.go)
这些类型与我们为 AWS Lambda 编写的代码是一样的。不同于 AWS Lambda,Cloud Functions 不会为你将请求体解码为 EventRequest。因此,你需要自行处理请求和响应负载的解码和编码。
与 AWS Lambda 接受多种函数签名不同,Cloud Functions 使用熟悉的 net/http 处理函数签名:func(http.ResponseWriter, *http.Request),如 Listing 14-8 所示。
`--snip--`
func LatestXKCD(w http.ResponseWriter, r *http.Request) {
var req EventRequest
resp := EventResponse{Title: "xkcd.com", URL: "https://xkcd.com/"}
defer 1func() {
w.Header().Set("Content-Type", "application/json")
out, _ := json.Marshal(&resp)
_, _ = w.Write(out)
}()
if err := 2json.NewDecoder(r.Body).Decode(&req); err != nil {
log.Printf("decoding request: %v", err)
return
}
if err := rssFeed.ParseURL(3r.Context(), feedURL); err != nil {
log.Printf("parsing feed: %v:", err)
return
}
Listing 14-8:处理请求和响应,并可选地更新 RSS feed (gcp/xkcd.go)
与 AWS 代码相似,这个 LatestXKCD 函数通过使用 ParseURL 方法刷新 RSS feed。但与 AWS 代码不同的是,你需要先将请求体进行 JSON 解码,然后再将响应转为 JSON,才能发送给客户端。尽管 LatestXKCD 的函数参数中没有接收上下文,但你可以使用请求的上下文 3 来取消解析器,如果客户端与服务器的连接在解析器返回之前终止的话。
Listing 14-9 实现了 LatestXKCD 函数的其余部分。
`--snip--`
switch items := rssFeed.Items(); {
case req.Previous && len(items) > 1:
resp.Title = items[1].Title
resp.URL = items[1].URL
resp.Published = items[1].Published
case len(items) > 0:
resp.Title = items[0].Title
resp.URL = items[0].URL
resp.Published = items[0].Published
}
}
Listing 14-9:用 feed 结果填充响应 (gcp/xkcd.go)
如 Listing 14-6 所示,这段代码将适当的 feed 项填充到响应字段中。Listing 14-8 中的延迟函数负责将响应写入 http.ResponseWriter,因此这里不需要做其他事情。
部署你的 Cloud Function
在部署代码之前,你需要解决一个模块管理的问题;你需要创建一个 go.mod 文件,以便 Google 可以找到依赖项,因为与 AWS Lambda 不同,你不会自己编译和打包二进制文件。相反,代码最终会在 Cloud Functions 上编译。
使用以下命令创建 go.mod 文件:
$ **cd gcp**
gcp$ **go mod init github.com/awoodbeck/gnp/ch14/gcp**
go: creating new go.mod: module github.com/awoodbeck/gnp/ch14/gcp
gcp$ **go mod tidy**
`--snip--`
gcp$ **cd -**
这些命令初始化了一个名为 github.com/awoodbeck/gnp/ch14/gcp 的新模块,并整理了 go.mod 文件中的模块需求。
你的模块已准备好部署。使用 gcloud functions deploy 命令,它接受你代码的函数名称、源位置和 Go 运行时版本:
$ **gcloud functions deploy LatestXKCD --source ./gcp/ --runtime go113 \**
**--trigger-http --allow-unauthenticated**
Deploying function (may take a while - up to 2 minutes)...
For Cloud Build Stackdriver Logs, visit:
https://console.cloud.google.com/logs/viewer`--snip--`
Deploying function (may take a while - up to 2 minutes)...done.
availableMemoryMb: 256
buildId: 5d7fee9b-7468-4b04-badc-81015aa62e59
entryPoint: 1LatestXKCD
httpsTrigger:
url: 2https://us-central1-goxkcd.cloudfunctions.net/LatestXKCD
ingressSettings: 3ALLOW_ALL
labels:
deployment-tool: cli-gcloud
name: projects/goxkcd/locations/us-central1/functions/LatestXKCD
runtime: 4go113
serviceAccountEmail: goxkcd@appspot.gserviceaccount.com
sourceUploadUrl: https://storage.googleapis.com/`--snip--`
status: ACTIVE
timeout: 60s
updateTime: '2006-01-02T15:04:05.000Z'
versionId: '1'
添加 --trigger-http 和 --allow-unauthenticated 标志告诉 Google,你希望通过传入的 HTTP 请求触发对函数的调用,并且该 HTTP 端点不需要认证。
创建后,SDK 输出将显示函数名称 1、函数的 HTTP 端点 2、端点的权限 3 和 Go 运行时版本 4。
尽管 Cloud Functions 的部署工作流程比 AWS Lambda 的工作流程更简单,但它有一个限制:您受到 Cloud Functions 支持的 Go 运行时版本的限制,可能不是最新版本。因此,您需要确保编写的代码不使用自 Go 1.13 版本以来新增的特性。部署到 AWS Lambda 时没有类似的限制,因为您在部署前会本地编译二进制文件。
测试您的 Google Cloud 函数
Google Cloud SDK 不提供像 AWS CLI 那样从命令行调用您的函数的方式。但您的函数的 HTTP 端点是公开可访问的,因此您可以直接向其发送 HTTP 请求。
使用 curl 向您的函数的 HTTP 端点发送 HTTP 请求:
$ **curl -X POST -H "Content-Type: application/json" --data '{}' \**
**https://us-central1-goxkcd.cloudfunctions.net/LatestXKCD**
{"title":"Chemist Eggs","url":"https://xkcd.com/2373/",
"published":"Fri, 16 Oct 2020 04:00:00 -0000"}
$ **curl -X POST -H "Content-Type: application/json" \**
**--data '{"previous":true}' \**
**https://us-central1-goxkcd.cloudfunctions.net/LatestXKCD**
{"title":"Chemist Eggs","url":"https://xkcd.com/2373/",
"published":"Fri, 16 Oct 2020 04:00:00 -0000"}
在这里,您发送 POST 请求,并在 Content-Type 头中指示请求体包含 JSON。第一次请求发送一个空对象,因此您正确地接收当前的 XKCD 漫画标题和 URL。第二次请求询问前一个漫画,函数正确地在响应中返回它。
请记住,与 AWS 不同,使用 --allow-unauthenticated 标志时,您的函数的 HTTP 端点唯一的安全性是模糊性,因为任何人都可以向您的 Google Cloud 函数发送请求。由于您没有返回敏感信息,您面临的主要风险是如果在使用后没有删除或保护您的函数,可能会产生的费用。
一旦您确认函数按预期工作,就可以删除它。如果您这么做,我晚上会睡得更好。您可以通过以下方式在命令行中删除该函数:
$ **gcloud functions delete LatestXKCD**
系统将提示您确认删除操作。
Azure Functions
与 AWS Lambda 和 Google Cloud Functions 不同,Microsoft Azure Functions 不提供对 Go 的一流支持。但并非一无所有。我们可以定义一个自定义处理程序,暴露一个 HTTP 服务器。Azure Functions 会在客户端和您的自定义处理程序的 HTTP 服务器之间代理请求和响应。您可以在docs.microsoft.com/en-us/azure/azure-functions/functions-custom-handlers#http-only-function阅读有关 Azure Functions 自定义处理程序的更多细节。此外,您的代码运行在 Windows 环境中,而不是 Linux,这在为 Azure Functions 部署时编译代码时是一个重要区别。
在继续之前,您需要一个 Microsoft Azure 账户。请访问 azure.microsoft.com 创建一个。
安装 Azure 命令行界面
Azure CLI 提供适用于 Windows、macOS 和多个流行 Linux 发行版的安装包。你可以在docs.microsoft.com/en-us/cli/azure/install-azure-cli/找到针对你操作系统的详细信息。
以下命令在兼容 Debian 的 Linux 系统上安装 Azure CLI:
$ **curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash**
[sudo] password for user:
export DEBIAN_FRONTEND=noninteractive
apt-get update
`--snip--`
$ **az version**
{
"azure-cli": "2.15.0",
"azure-cli-core": "2.15.0",
"azure-cli-telemetry": "1.0.6",
"extensions": {}
}
第一个命令下载 InstallAzureCLIDeb 脚本并将其传递给 sudo bash。身份验证后,该脚本会安装 Apt 仓库,更新 Apt,并安装 azure-cli 包。
安装完成后,az version 命令将显示当前 Azure CLI 组件的版本。
配置 Azure CLI
与 AWS CLI 需要你在配置过程中提供凭证,以及 Google Cloud SDK 在配置过程中打开网页进行授权不同,Azure CLI 将配置和身份验证分成了两个独立的步骤。首先,执行 az configure 命令并按照指示配置 Azure CLI。然后,运行 az login 命令,通过你的 Web 浏览器对 Azure CLI 进行身份验证:
$ **az configure**
Welcome to the Azure CLI! This command will guide you through logging in and
setting some default values.
Your settings can be found at /home/user/.azure/config
Your current configuration is as follows:
`--snip--`
$ **az login**
1 You have logged in. Now let us find all the subscriptions to which you have
access...
[
{
"cloudName": "AzureCloud",
`--snip--`
}
]
Azure CLI 支持多种配置选项,这些选项不在az configure过程中涵盖。你可以使用 Azure CLI 来设置这些值,而不是直接编辑 $HOME/.azure/config 文件。例如,你可以通过将 core.collect_telemetry 变量设置为 off 来禁用遥测:
$ **az config set core.collect_telemetry=off**
Command group 'config' is experimental and not covered by customer support. Please use with discretion.
安装 Azure Functions 核心工具
与本章介绍的其他云服务不同,Azure CLI 工具并不直接支持 Azure Functions。你需要安装一套专门用于 Azure Functions 的工具。
docs.microsoft.com/en-us/azure/azure-functions/functions-run-local/ 中的“安装 Azure Functions 核心工具”部分详细描述了在 Windows、macOS 和 Linux 上安装版本 3 的工具的过程。
创建自定义处理程序
你可以使用 Azure Functions 核心工具初始化一个新的自定义处理程序。只需运行 func init 命令,并将 --worker-runtime 标志设置为 custom:
$ **cd azure**
$ **func init --worker-runtime custom**
Writing .gitignore
Writing host.json
Writing local.settings.json
Writing /home/user/dev/gnp/ch14/azure/.vscode/extensions.json
核心工具随后会创建一些项目文件,其中最相关的是 host.json 文件。
在开始编写代码之前,你需要完成一些其他任务。首先,在 Azure Functions 中创建一个与期望函数名称相同的子目录:
$ **mkdir LatestXKCDFunction**
本示例通过创建一个同名的子目录来命名 Azure Function 为 LatestXKCDFunction。这个名称将成为你函数端点 URL 的一部分。
第二步,在子目录中创建一个名为function.json的文件,内容参见清单 14-10。
{
"bindings": [
{
"type": 1"httpTrigger",
"direction": 2"in",
"name": "req",
3 "methods": [ "post" ]
},
{
"type": 4"http",
"direction": 5"out",
"name": "res"
}
]
}
清单 14-10:绑定传入的 HTTP 触发器和传出的 HTTP (azure/LatestXKCDFunction/function.json)
Azure Functions Core Tools 将使用此 function.json 文件来配置 Azure Functions 使用你的自定义处理程序。此 JSON 文件指示 Azure Functions 将传入的 HTTP 触发器绑定到你的自定义处理程序,并期待来自它的 HTTP 输出。在这里,你告诉 Azure Functions,传入的 2POST 请求 3 将触发 1 你的自定义处理程序,而你的自定义处理程序返回 4 HTTP 响应 5。
最后,生成的 host.json 文件需要进行一些调整(列表 14-11)。
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
}
}
},
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[1.*, 2.0.0)"
},
"customHandler": {
1 "enableForwardingHttpRequest": true,
"description": {
"defaultExecutablePath": 2"xkcd.exe",
"workingDirectory": "",
"arguments": []
}
}
}
列表 14-11:调整 host.json 文件(azure/host.json)
确保启用从 Azure Functions 到自定义处理程序 1 的 HTTP 请求转发。这指示 Azure Functions 充当客户端和自定义处理程序之间的代理。此外,将默认可执行路径设置为 Go 二进制文件的名称 2。由于你的代码将在 Windows 上运行,请确保包含 .exe 文件扩展名。
定义自定义处理程序
你的自定义处理程序需要实例化自己的 HTTP 服务器,但你可以利用已经为 Google Cloud Functions 编写的代码。列表 14-12 是完整的自定义处理程序实现。
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/awoodbeck/gnp/ch14/gcp"
)
func main() {
port, exists := 1os.LookupEnv("FUNCTIONS_CUSTOMHANDLER_PORT")
if !exists {
log.Fatal("FUNCTIONS_CUSTOMHANDLER_PORT environment variable not set")
}
srv := &http.Server{
Addr: ":" + port,
Handler: http.HandlerFunc(2gcp.LatestXKCD),
IdleTimeout: time.Minute,
ReadHeaderTimeout: 30 * time.Second,
}
log.Printf("Listening on %q ...\n", srv.Addr)
log.Fatal(srv.ListenAndServe())
}
列表 14-12:使用 Google Cloud Functions 代码处理请求(azure/xkcd.go)
Azure Functions 期望你的 HTTP 服务器监听它为 FUNCTIONS_CUSTOMHANDLER_PORT 环境变量分配的端口号 1。由于你为 Cloud Functions 编写的 LatestXKCD 函数可以被转换为 http.HandlerFunc,你可以通过导入其模块并将该函数用作 HTTP 服务器的处理程序,从而省去很多敲击键盘的步骤 2。
本地测试自定义处理程序
Azure Functions Core Tools 允许你在部署之前本地测试代码。让我们逐步了解如何在计算机上构建和运行 Azure Functions 代码。首先,切换到包含 Azure Functions 代码的目录:
$ **cd azure**
接下来,构建你的代码,确保生成的二进制文件名称与在主机文件中定义的名称相匹配——此示例中为 xkcd.exe:
azure$ **go build -o xkcd.exe xkcd.go**
由于你的代码将在本地运行,你不需要专门为 Windows 编译二进制文件。
最后,运行 func start,这将读取 host.json 文件并执行 xkcd.exe 二进制文件:
azure$ **func start**
Azure Functions Core Tools (3.0.2931 Commit hash:
d552c6741a37422684f0efab41d541ebad2b2bd2)
Function Runtime Version: 3.0.14492.0
[2020-10-18T16:07:21.857] Worker process started and initialized.
[2020-10-18T16:07:21.915] 2020/10/18 12:07:21 Listening on 1":44687" ...
[2020-10-18T16:07:21.915] 2020/10/18 12:07:21 decoding request: EOF
Hosting environment: Production
Content root path: /home/user/dev/gnp/ch14/azure
Now listening on: 2http://0.0.0.0:7071
Application started. Press Ctrl+C to shut down.
Functions:
LatestXKCDFunction: [POST] 3http://localhost:7071/api/LatestXKCDFunction
For detailed output, run func with –verbose flag.
在这里,Azure Functions 代码在执行 xkcd.exe 二进制文件之前,将 FUNCTIONS_CUSTOMHANDLER_PORT 环境变量设置为 44687 1。Azure Functions 还在端口 7071 上公开了一个 HTTP 端点 2。任何发送到 LatestXKCDFunction 端点的请求 3 都会转发到 xkcd.exe HTTP 服务器,并且响应会转发给客户端。
现在,LatestXKCDFunction 端点已经激活,你可以像使用 Google Cloud Functions 代码时那样向其发送 HTTP 请求:
$ **curl -X POST -H "Content-Type: application/json" --data '{}' \**
**http://localhost:7071/api/LatestXKCDFunction**
{"title":"Chemist Eggs","url":"https://xkcd.com/2373/",
"published":"Fri, 16 Oct 2020 04:00:00 -0000"}
$ **curl -X POST -H "Content-Type: application/json" –data \**
**'{"previous":true}' http://localhost:7071/api/LatestXKCDFunction**
{"title":"Dialect Quiz","url":"https://xkcd.com/2372/",
"published":"Wed, 14 Oct 2020 04:00:00 -0000"}
与 Google Cloud 一样,发送一个请求体为空的 POST 请求会导致自定义处理程序返回当前的 XKCD 漫画标题和 URL。请求前一张漫画时,会准确返回前一张漫画的标题和 URL。
部署自定义处理程序
由于你使用的是自定义处理程序,部署过程比 Lambda 或 Cloud Functions 稍微复杂一些。本节将指导你在 Linux 上的步骤。你可以在docs.microsoft.com/en-us/azure/azure-functions/functions-create-first-azure-function-azure-cli/找到整个过程的详细说明。
通过执行az login命令来确保你的 Azure CLI 授权是最新的:
$ **az login**
You have logged in.
接下来,创建一个资源组并指定你想使用的位置。你可以使用az account list-locations获取位置列表。这个示例使用NetworkProgrammingWithGo作为资源组名称,eastus作为位置:
$ **az group create --name NetworkProgrammingWithGo --location eastus**
{
"id": "/subscriptions/`--snip--`/resourceGroups/NetworkProgrammingWithGo",
"location": "eastus",
"managedBy": null,
"name": "NetworkProgrammingWithGo",
"properties": {
"provisioningState": "Succeeded"
},
"tags": null,
"type": "Microsoft.Resources/resourceGroups"
}
然后,创建一个唯一的存储帐户,指定其名称、位置、你刚创建的资源组名称以及Standard_LRS SKU:
$ **az storage account create --name npwgstorage --location eastus \**
**--resource-group NetworkProgrammingWithGo --sku Standard_LRS**
- Finished ..
`--snip--`
最后,创建一个唯一名称的函数应用,确保指定你使用的是 Functions 3.0 和自定义运行时:
$ **az functionapp create --resource-group NetworkProgrammingWithGo \**
**--consumption-plan-location eastus --runtime custom \**
**--functions-version 3 --storage-account npwgstorage --name latestxkcd**
Application Insights "latestxkcd" was created for this Function App.
`--snip--`
到此为止,你已经准备好编译代码并部署它。由于你的代码将在 Windows 上运行,因此需要为 Windows 构建二进制文件。然后,发布你的自定义处理程序。
$ **cd azure**
azure$ **GOOS=windows go build -o xkcd.exe xkcd.go**
azure$ **func azure functionapp publish latestxkcd --no-build**
Getting site publishing info...
Creating archive for current directory…
Skipping build event for functions project (--no-build).
Uploading 6.12 MB [##########################################################]
Upload completed successfully.
Deployment completed successfully.
Syncing triggers...
Functions in latestxkcd:
LatestXKCDFunction - [httpTrigger]
Invoke url: 1https://latestxkcd.azurewebsites.net/api/latestxkcdfunction
一旦代码部署完成,你可以向自定义处理程序的 URL 1 发送POST请求。实际的 URL 比这个要长,并且包含与 Azure Functions 相关的 URI 参数。为了简洁起见,我已经将其剪裁。
测试自定义处理程序
假设你正在使用自定义处理程序的完整 URL,它应该返回如下所示的结果:
$ **curl -X POST -H "Content-Type: application/json" --data '{}' \**
**https://latestxkcd.azurewebsites.net/api/latestxkcdfunction?**`--snip--`
{"title":"Chemist Eggs","url":"https://xkcd.com/2373/",
"published":"Fri, 16 Oct 2020 04:00:00 -0000"}
$ **curl -X POST -H "Content-Type: application/json" \**
**--data '{"previous":true}' \**
**https://latestxkcd.azurewebsites.net/api/latestxkcdfunction?**`--snip--`
{"title":"Chemist Eggs","url":"https://xkcd.com/2373/",
"published":"Fri, 16 Oct 2020 04:00:00 -0000"}
使用curl查询你的 Azure Functions 自定义处理程序。如预期所示,空的 JSON 会返回当前 XKCD 漫画的标题和网址,而请求前一部漫画则会正确返回前一部漫画的详细信息。
你所学到的
当你使用云服务时,你可以专注于应用程序开发,避免了获取服务器基础设施、软件授权和维护所需的人力资源的成本。本章探讨了 Amazon Web Services、Google Cloud 和 Microsoft Azure,这些平台都提供了综合解决方案,使你能够扩展业务并按需付费。我们使用了 AWS Lambda、Google Cloud Functions 和 Microsoft Azure Functions,这些都是 PaaS 服务,允许你部署应用程序,同时让平台处理实现细节。
如你所见,在这三个云环境中开发和部署应用程序遵循相同的一般过程。首先,你安装平台的命令行工具。接下来,你授权命令行工具代表你的帐户执行操作。然后,你为目标平台开发应用程序并进行部署。最后,你确保应用程序按预期运行。
AWS Lambda 和 Cloud Functions 都为 Go 提供了一流的支持,使得开发和部署流程变得简单。尽管 Azure Functions 没有明确支持 Go,但你可以编写自定义处理程序来与该服务一起使用。尽管在开发、部署和测试工作流程中存在一些小的差异,但这三大云平台都能产生相同的结果。你应该选择哪个,取决于你的使用场景和预算。
第十五章:索引
请注意,索引链接指向每个术语的大致位置。
A
地址解析协议(ARP),26
亚马逊网络服务(AWS)Lambda。参见 AWS(亚马逊网络服务)Lambda
ARP(地址解析协议),26
ASN(自治系统号),33–34
自治系统号。参见 ASN(自治系统号)
AWS(亚马逊网络服务)Lambda,333–340
命令行接口,333–335
配置,333–335
安装,333
编译、打包和部署,339–340
创建 Lambda 函数,336–338
创建角色,335–336
测试 Lambda 函数,340
Azure Functions。参见 微软 Azure Functions
B
带宽,6–7
与延迟的对比,7
big 包。参见 math/big 包
比特,9–10
边界网关协议(BGP),34
广播,25–26
bufio 包,74,76–78
Scanner 结构,74,76–78
bytes 包,79,83–86,89,109–114,120,122–123,126–131,133,146–147,149,151–153,179–181,188–189,253,255,260,265,299–302,306,320,325
Buffer 结构,85–86,89,122,126,128–129,180–181,299–301,306
Equal(),110,112,114,147,151,153,255,265
HasPrefix(),325
NewBuffer(),123,127,130
NewBufferString(),189
NewReader(),83,127–128,133
Repeat(),147
Split(),325
C
Caddy,217–239
自动 HTTPS,237–238
从源代码构建,220
配置,220–224
适配器,225–226
管理端点,220
修改,实时,222–224
遍历,222
使用配置文件,224
下载,219
扩展,224–232
配置适配器,225–226
注入模块,231–232
中间件,226–230
Let’s Encrypt 集成,218
反向代理,219,232–238
图表,219
CDN(内容分发网络),7,16
证书固定,247,252–255,292
无类域间路由(CIDR),20–22
云函数。参见 Google Cloud Functions
内容分发网络(CDN),7,16
context 包,57–62,65–66,69,107–111,113,144–146,148–150,152,177–178,183,213,249–250,253,260,286–287,290–291,293
WithCancel(),59,66,69,109,111,113,146,150,152,178,253,260
Canceled 错误,59–62
WithDeadline(),58,60–62
DeadlineExceeded 错误,58,61–62,177
crypto/ecdsa 包,256–257
GenerateKey(),257
crypto/elliptic 包,256–257
P256(),257
crypto/rand 包,75,256,258
Int(),256
Read(),75
Reader 变量,256
crypto/sha512 包,137
Sum512_256(),137
crypto/tls 包,245–251,253–255,260–264
crypto/x509 包,253–254,256–260,262–263,292
Certificate 结构,256–257,262
CreateCertificate(),258
密钥使用,257,262
ExtKeyUsageClientAuth 常量,257,262
ExtKeyUsageServerAuth 常量,257
MarshalPKCS8PrivateKey(),259
NewCertPool(),254,260,262,292
VerifyOptions 结构,262–263
crypto/x509/pkix 包,256–257
Name 结构,257
D
分布式拒绝服务(DDOS)攻击,34
国防高级研究计划局(DARPA),12
分隔数据,从网络读取。参见 bufio 包,Scanner 结构
DHCP(动态主机配置协议),14
分布式拒绝服务(DDOS)攻击,34
DNS(域名系统),34–41
域名解析,34–35
域名解析器,35
隐私和安全考虑,40–41
资源记录,35–40
地址(A),36
规范名称(CNAME),38
邮件交换(MX),38–39
名称服务器(NS),37
指针(PTR),39
权威起始(SOA),37
文本(TXT),39–40
DNS over HTTPS(DoH),41
DNS over TLS(DoT),41
DNSSEC(域名系统安全扩展),41
DoH(DNS over HTTPS),41
域名系统。参见 DNS(域名系统)
域名系统安全扩展(DNSSEC),41
DoT(DNS over TLS),41
动态缓冲区,读取,79–86
动态主机配置协议(DHCP),14
E
ecdsa 包。参见 crypto/ecdsa 包
椭圆曲线,247
elliptic 包。参见 crypto/elliptic 包
encoding/binary 包,79−85,120,122–123,126–130
BigEndian 常量,80–83,85,122–123,126–130
Read(),80–83,123,127–130
Write(),80–81,85,122,126,128–129
encoding/gob 包,278–279
NewDecoder(),279
NewEncoder(),279
encoding/json 包,179–180,225–226,228,277,342–343
Marshal(),226,343
NewDecoder(),179,277,343
NewEncoder(),180,277
Unmarshal(),228
encoding/pem 包,256,258–259,270
Block 结构体,258
Encode(),258
外部路由协议,34
F
filepath 包。参见 path/filepath 包
固定缓冲区,读取,74–76
flag 包 96–97,135,137,157–158,211,232–233,256,271–272,276,288–290,292–293,317,320–321,323
Arg(),97,276,293
Args(),137,158,276,293
CommandLine 变量,157,272,290
Output(),157,272,290
Duration(),96
Int(),96
NArg(),97
Parse(),97,135,137,158,211,233,256,276,288,292,323
PrintDefaults(),96,137,157,272,290
String(),135,211,233,256,317,321
StringVar(),272,288,290
Usage(),97
分片。参见 UDP(用户数据报协议),分片
G
全局路由前缀(GRP),27–28
Gob
解码。参见 encoding/gob 包
编码。参见 encoding/gob 包
序列化对象,参见 序列化对象,Gob
Google 云函数,341–346
定义云函数,342–344
部署云函数,344–345
启用计费和云函数,342
安装软件开发工具包,341–342
测试云函数,345–346
GRP(全局路由前缀),27–28
gRPC,284–294
客户端,289–294
连接服务,284–286
服务器,286–289
H
处理程序,193–202
提前连接截止时间,68–70
依赖注入,200–202
实现 http.Handler 接口,198–200
测试,195–196
写入响应,196–198
心跳,64–70
十六进制四元组,26–28
html/template 包,194
HTTP(超文本传输协议),10,23,41,165–215
客户端,165–184
默认客户端,174–175
来自客户端的请求,167–170
来自服务器的响应,170–172
请求-响应周期,172–173
服务器端,187-215
Go HTTP 服务器的结构,188
Caddy。参见 Caddy
http 包。参见 net/http 包
httptest 包。参见 net/http/httptest 包
HTTP/1。参见 超文本传输协议(HTTP)
HTTP/2 服务器推送,209–214
超文本传输协议(HTTP)。参见 HTTP(超文本传输协议)
I
IANA(互联网号码分配局),23,28,35
ICMP(互联网控制消息协议),31–32,96,98
目标不可达,31
回显,32
回显回复,32
分片,检查,115–116
重定向,32
超时,32
IETF(互联网工程任务组),26
仪器化,316–326
计数器,317–318
量规,318–319
直方图和总结,319–320
HTTP 服务器,仪器化,320–326
互联网号码分配局(IANA),23,28,35
互联网控制消息协议。参见 ICMP(互联网控制消息协议)
互联网工程任务组 (IETF),26
互联网协议(IP),12,18
互联网服务提供商(ISP),7
进程间通信 (IPC),141
io 包,54–55,63–66,70,74–84,87–96,126,176,179–180,182,189,194,196,198,207,255,273,277–279,283,297–298,301–302,306–307,314–315,324
Copy(),87–89,92,126,176,180,182,194,198,207,314,324
CopyN(),89,126
EOF 错误,54–55,63–64,70,75,78,88,90–91,94,126,255
MultiWriter(),87,93–96,297–298
TeeReader(),87,93–96
ioutil 包。参见 io/ioutil 包
io/ioutil 包,135,137,146,149,152,175,179,183,188,190,194,198–199,203–204,207,209,253–254,260,283,289,292,302,309,312,314–315,321,324–325,330–331
Discard 变量,175,179,194,198,207,314,324
ReadAll(),183,190,194,199,204,209,283,325,331
ReadFile(),135,137,254,260,292
TempDir(),146,149,152,309,315
IP(互联网协议),12,18
IPC(进程间通信),141
IPsec,31
IPv4 地址,18–26
主机 ID,19–20
localhost,23
网络 ID,19–22
网络前缀,20–22
子网,20
IPv6 地址,28–33
地址类别,28
任播地址,29–30
多播地址,29
单播地址,28
相对于 IPv4 的优势,30
接口 ID,27–28,30–31
简化,27
子网 ID,27–28
子网,28
J
JSON
编码和解码,179–180,225–226,228,277,342–343
使用与之序列化对象,276–278
K
保活消息,99,175,192
L
Lambda。参见 AWS(亚马逊 Web 服务)Lambda
延迟,7
减少延迟,7
与带宽的比较,7
Let’s Encrypt,与 Caddy 的集成,218
延迟,99–100
log 包,93–94,131,135,155,157,201,211,232,256,271,288–289,297–302,321,242,249
Ldate 常量,297
等级,300–301
Lmsgprefix 常量,299
Lshortfile 常量,201,297,299–300
LstdFlags 常量,297
Ltime 常量,297
New(),94,201,297,299–300
lumberjack。参见 zap 日志记录器,日志轮换
M
MAC(媒体访问控制)地址,10,24
math/big 包,256
Int 类型,256
NewInt(),256
最大传输单元(MTU),115–116
mDNS(多播 DNS),40
媒体访问控制 (MAC) 地址,10,24
度量标准。参见 仪器
Microsoft Azure Functions,346–353
命令行接口,346–347
配置,347
安装,346–347
自定义处理程序,348–353
创建,348–349
定义:349–350
部署,351–352
本地测试,350–351
在 Azure 上测试,353
安装核心工具,347
中间件,202–206
保护敏感文件,204–206
超时慢客户端,203–204
mime/multipart 包,179,181
NewWriter(),181
监控网络流量,89–92
MTU(最大传输单元),115–116
多播 DNS(mDNS),40
多播,23,106
多路复用器,207–209
N
NAT(网络地址转换),24
net 包,51–70,73–75,77,84–103,107–117,131–134,143–153,155,156,158–159,189,192,221,248,250–252,257,262,270,288,313,322
绑定,51–52
Conn 接口,52–54,56,73–74,77,87,89–92,98–99,102,107,113–115,144,146,156,159,252,270,322
SetDeadline(),62–63,69,74,114,252
SetReadDeadline(),62,74,133
SetWriteDeadline(), 62, 74
Dial(), 54, 63, 69, 75, 77, 85, 88, 91–92, 95, 113–115, 134, 147–148, 152–153
DialContext(), 58–61
Dialer 结构体, 56–60, 248
DialTimeout(), 56–58, 97
Error 接口, 55–58, 63, 86–87, 97, 133
Listen(), 51–54, 60, 62, 68, 75, 77, 84, 90–91, 94, 145, 189, 192, 250, 288, 322
Listener 接口, 51–52, 189, 250–251
ListenPacket(), 107–111, 113, 117, 131, 143, 146, 148–150
ListenUnix(), 143, 146, 149, 158–159
LookupAddr(), 262
OpError 结构体, 55
PacketConn 接口, 107–110, 113–115, 117, 131–132, 149
ParseIP(), 257
ResolveTCPAddr(), 98–99
SplitHostPort(), 313
TCPConn 结构体, 89, 98–101
UnixConn 结构体, 155–156, 159
net/http 包, 168, 171, 173–174
Client 结构体, 190, 246, 324
Get(),174,176–177,180,185,314,325
Head(),174,176,185
Post(),176,181,185
Error(),180,194–195,197–199,202,205,229
FileServer(),204–206,212,236
FileSystem 接口,204,206
Handle(),200–201
HandlerFunc(),177,180,193–195,199–203,205,207,212,233,245,313–314,323
Handler 接口,193–195,198–205,207,215,226,227,309,313–315,321
NewRequest(),190
NewRequestWithContext(),177–178,183
Pusher 接口,212–213
Request 结构体,176,179,193–196,198–203,205,207–208,212,227,229,233,245,313–314,321
持久化 TCP 连接,禁用,178–179
超时,取消,176–178
Response 结构体,174,177,180–181,183,190,204,209,246–247,314,324–325
请求体,关闭,175–176
ResponseWriter 接口,176,179,193–196,198–203,205,207,212,227,229,233,245,312–314,321
WriteHeader(),180,196,203,207,245
ServeMux 结构体,207–208,212,233,323
Server 结构体,189,191–192,195,213,233,322
连接状态,322
超时设置,191–192
TLS 支持,添加,192–193
StripPrefix(),205–206,212
TimeoutHandler(),189,200,203–204
Transport 结构体,246–247,324,326
默认传输,324
net/http/httptest 包,177,180,196–197,203,205,209,245–246,314
NewRecorder(),196–197,203,205,209
NewRequest(),196–197,203,205,209
NewServer(),177,180,314
NewTLSServer(),245–246
ResponseRecorder 结构体,196
网络地址转换(NAT),24
网络错误。参见 net 包,Error 接口
网络拓扑,3–6
总线,4
菊花链,4
混合型,6
网状,5–6
点对点,4
环,5
星型,5
半字节,26
O
字节,18
开放系统互联(OSI)参考模型,7–12
封装,10
数据报, 12
帧, 12
帧检查序列(FCS), 12
水平通信, 10–11
消息体, 10
数据包, 12
有效载荷, 10
段, 12
服务数据单元(SDU), 10
层次, 8–9
应用层(层 7), 8
数据链路层(层 2), 9
网络层(层 3), 9
物理层(层 1), 9
表示层(层 6), 9
会话层(层 5), 9
传输层(层 4), 9
os 包, 93, 96–97, 137, 143, 146–150, 152, 157–158, 179, 182, 211, 213, 232–233, 256, 258, 271–273, 289–290, 299, 302, 304, 310–312, 315, 349
Args 切片, 96, 137, 157, 272, 290
Chmod(), 143, 147, 150, 152
Chown(), 143
Create(), 258, 273, 311
Exit(), 97, 304, 315
Getpid(), 146, 150, 152
IsNotExist(), 272
LookupEnv(), 349
Open(), 182, 272
OpenFile(), 258
Remove(), 148, 311
RemoveAll(), 146, 149, 152, 310, 315
Signal 类型, 158, 213, 233
Stat(), 272
TempDir(), 158
OSI。 参见 开放系统互联参考模型(OSI)
P
path 包, 204, 211, 302
Clean(), 205
path/filepath 包,146,149–150,152,157–158,179,182,212,271–272,289–290,310,315
Base(),157,182,272,290
Join(),146,150,152,158,212,310,315
pem 包。参见 encoding/pem 包
ping TCP 端口,96–98
pkix 包。参见 crypto/x509/pkix 包
端口,23
通过 HTTP 发送数据,179–184
带有文件附件的多部分表单,181–184
协议缓冲区,280–284
代理网络数据,87–93
R
rand 包。参见 crypto/rand 包
接收缓冲区,连接集,100–101
reflect 包,78,85
DeepEqual(),78,85
请求评论(RFC),15
路由,17,32–33
S
扫描定界数据,76–78
序列化对象,270–284
Gob,278–280
JSON,276–278
协议缓冲区,280–284
传输。参见 gRPC
简单邮件传输协议(SMTP),13
SLAAC(无状态地址自动配置),30–31
套接字地址,23–25,106–107,142–144,221–222,238
sort 包,198–199
Strings(),199
无状态地址自动配置(SLAAC),30–31
strconv 包,271,275,289,291–292
Atoi(),275,291–292
strings 包,120,124,130,198–199,205,228–229,245–246,253,256–257,260,262–263,271,274,276,289,291,293
Contains(),246,253,263
HasPrefix(),205,229
Join(),199,276,293
Split(),205,229,257,262,274,291
ToLower(),124,276,293
TrimRight(),123–124,130
TrimSpace(),274,291
结构化日志。参见 zap 日志器
子域,35
T
TCP(传输控制协议),8,11–14,45–102
标志,47–50
确认(ACK),47–50
完成(FIN),50
重置(RST),51
选择性确认(SACK),48
同步(SYN),47–48
握手,47
最大段生命周期,50
接收缓冲区,48
可靠性,46
序列号,47–48
滑动窗口,49
终止,50
传输层,14
窗口大小,48–49
TCP/IP 模型,12–15
端到端原则,12
层,13–15
应用,13
互联网,14
链接,15
传输,14
临时错误,55,86,97–98,102
TFTP(简单文件传输协议),119–139
通过 UDP 下载文件,135–138
服务器实现,131–135
处理读取请求,132–134
启动,携带负载,135
类型,120–130
确认,128–129
数据包,124–127
错误包,129–130
读取请求(RRQ),121–124
time 包, 56,58–60,62–63,65–70,87,96–97,113–114,131,133,174,176,179,181,188,202–203,211,232,245,249,252–253,255–257,302–303,308,321,323,349
NewTimer(), 65
Parse(), 174
Round(), 67,174
Since(), 67,69–70,97,321
Sleep(), 58–59,87,97,203,255,308
Truncate(), 69–70
超时错误,55,57–58,63–64,133
TLS(传输安全层),41,192–193,212–214,241–266,288–289,292–293
证书授权机构,243–244
客户端,245–248
推荐配置,247
证书固定,252–255
完全前向保密,243
如何攻破 TLS,244
叶证书,263
双向 TLS 认证,255–265
生成认证证书,256–259
实现, 259–265
服务器端, 249–252
推荐配置, 251
tls 包。参见 crypto/tls 包
顶级域, 35
拓扑。参见 网络拓扑
传输控制协议。参见 TCP(传输控制协议)
传输安全层 (TLS), 41, 192–193, 212–214, 241–266, 288–289, 292–293
微不足道文件传输协议。参见 TFTP(微不足道文件传输协议)
类型-长度-值编码, 79–86
U
用户数据报协议(UDP), 14, 105–117, 119–120, 131–136, 139, 144, 148, 150–151, 221
分片, 115–117
最大传输单元。参见 MTU(最大传输单元)
数据包结构, 106
发送和接收数据, 107–115
监听传入数据, 110–112
传输层, 14
单播, 25
统一资源定位符 (URL), 165–167
Unix 域套接字, 141–161
认证, 154–160
对等凭证, 154–156
使用 Netcat 进行测试, 159–160
绑定, 143
更改所有权和权限, 143–144
类型, 144–154
unix 流套接字, 144–148
unixgram 数据报套接字, 148–151
unixpacket 序列数据包套接字, 151–154
unix 包, 154–156
GetsockoptUcred(), 155–156
URL(统一资源定位符), 165–167
用户数据报协议。参见 UDP(用户数据报协议)
W
写入缓冲区, 连接集, 100–101
X
x509 包。参见 crypto/x509 包
Z
zap 日志记录器, 301–316
编码器配置, 302–303
编码器使用,305
日志记录器选项,303–304
日志轮转,315–316
多重输出和编码,306–307
按需日志记录,309–312
采样,307–309
广泛的事件日志记录,312–315
零窗口错误,101–102


浙公网安备 33010602011771号