Go-GRPC-专家指南-全-
Go GRPC 专家指南(全)
原文:
zh.annas-archive.org/md5/7b33fb42279b9f88c91bdb41fd7ee9d5译者:飞龙
前言
在高度互联的微服务世界中,gRPC 已经成为一项重要的通信技术。通过站在 Protobuf 的肩膀上并在通信过程中实现必备功能,gRPC 提供了可靠、高效且用户友好的 API。在这本书中,你将探索为什么是这样,如何编写这些 API,以及如何在生产环境中使用它们。总体目标是让你了解如何使用 gRPC,以及 gRPC 的工作原理。首先,你将了解在使用 gRPC 之前需要了解的网络和 Protobuf 概念。然后,你将看到如何编写单例和不同类型的流 API。最后,在本书的其余部分,你将学习 gRPC 的功能和如何创建生产级别的 API。
本书面向对象
如果你是一名软件工程师或架构师,曾经为编写 API 而挣扎,或者你在寻找现有 API 的更高效替代方案,这本书就是为你准备的。
本书涵盖内容
第一章, 《网络基础》,将教你关于 gRPC 背后的网络概念。
第二章, 《Protobuf 初学者指南》, 将帮助你理解 Protobuf 对于高效通信的重要性。
第三章, 《gRPC 简介》,将让你了解为什么 gRPC 比传统的 REST API 更高效。
第四章, 《设置项目》,将标志着你进入 gRPC 世界的开始。
第五章, 《gRPC 端点类型》,将描述如何编写单例、服务器流、客户端流和双向流 API。
第六章, 《设计有效的 API》,将概述设计 gRPC API 时的不同权衡。
第七章, 《开箱即用功能》,将介绍 gRPC 提供的主要开箱即用功能。
第八章, 《更多必备功能》,将解释社区项目如何使你的 API 更加强大和安全。
第九章, 《生产级 API》,将教你如何测试、调试和部署你的 API。
为了最大限度地利用本书
你必须已经熟悉 Go 语言。尽管大多数时候你只需要基本的编码技能,但理解 Go 的并发概念将会很有帮助。
| 本书涵盖的软件/硬件 | 操作系统要求 |
|---|---|
| Go 1.20.4 | Windows, macOS, 或 Linux |
| Protobuf 23.2 | Windows, macOS, 或 Linux |
| gRPC 1.55.0 | Windows, macOS, 或 Linux |
| Buf 1.15.1 | Windows, macOS, 或 Linux |
| Bazel 6.2.1 | Windows, macOS, 或 Linux |
如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
您可以从 GitHub 在github.com/PacktPublishing/gRPC-Go-for-Professionals下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供包含本书中使用的截图和图表的彩色图像的 PDF 文件。您可以从这里下载:packt.link/LEms7。
使用的约定
本书使用了多种文本约定。
文本中的代码: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“使用插件,我们可以在 protoc 中使用--validate_out选项。”
代码块按照以下方式设置:
message AddTaskRequest {
string description = 1;
google.protobuf.Timestamp due_date = 2;
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
proto/todo/v2
├── todo.pb.go
├── todo.pb.validate.go
├── todo.proto
└── todo_grpc.pb.go
任何命令行输入或输出都按照以下方式编写:
$ bazel run //:gazelle-update-repos
小贴士或重要注意事项
看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈: 如果您对本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。
勘误表: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者: 如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
分享您的想法
一旦您阅读了《专业级 gRPC Go》,我们很乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载本书的免费 PDF 副本
感谢您购买本书!
您喜欢在路上阅读,但无法随身携带您的印刷书籍吗?您的电子书购买是否与您选择的设备不兼容?
别担心,现在每购买一本 Packt 图书,您都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠远不止于此,您还可以获得独家折扣、时事通讯和每天收件箱中的精彩免费内容。
按照以下简单步骤获取这些好处:
- 扫描下面的二维码或访问以下链接
![img/B19664_QR_Free_PDF.jpg]()
https://packt.link/free-ebook/9781837638840
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件中
第一章:网络基础
网络通信是我们所有现代技术的核心,而 gRPC 是我们可以用来实现高效数据接收和传输的高级框架之一。由于它是高级的,它为你提供了发送和接收数据的抽象,无需考虑在通过网络通信时可能出现的所有问题。在本章中,目标是理解在较低级别(不是最低级别)上,当我们发送/接收 gRPC Go 中的消息时会发生什么。这将帮助你了解正在发生的事情,并且在我们讨论调试和可观察性时,你将能够更容易地掌握所提出的概念。
在本章中,我们将涵盖以下主要主题:
-
HTTP/2
-
RPC 操作
-
RPC 类型
-
RPC 的生命周期
前提条件
在本章中,我将使用 第一章目录。
要显示这些捕获文件,您可以将它们导入 Wireshark 并应用显示过滤器。由于我们特别关注 HTTP/2 和 gRPC 有效负载,而我使用端口 50051 进行通信,您可以使用以下过滤器:tcp.port == 50051 and (grpc or http2)。
理解 HTTP/2
如果您正在阅读这本书,我将假设您熟悉 HTTP/1.1,或者至少您对如何在网络上进行传统 HTTP API 调用有一个概念。我猜是这样,因为与我们交互的大多数 API 都有由该协议带来的概念。我指的是像头部这样的概念,它可以提供调用的元数据;主体,它包含主要数据;以及诸如 GET、POST、UPDATE 等操作,这些操作定义了您打算对主体中的数据做什么。
HTTP/2 仍然具有所有这些概念,但在几个方面提高了效率、安全性和可用性。HTTP/2 相对于传统的 HTTP/1.1 的第一个优势是压缩到二进制。在 HTTP/2 之前,通过网络发送的所有内容都是纯文本,用户需要决定是否对其进行压缩。在版本 2 中,HTTP 语义的每一部分都被转换为二进制,从而使计算机在调用之间序列化和反序列化数据时更快,从而减少了请求/响应的有效负载大小。
HTTP/2 的第二个优势是一个名为服务器推送的功能。这是一个赋予服务器从客户端的单次调用中发送多个响应的能力的功能。这里的总体目标是减少服务器和客户端之间的嘈杂通信,从而减少达到相同最终结果的总有效负载。没有这个功能,当客户端想要请求一个网页及其所有资源时,它必须为每个资源进行一次请求。然而,有了服务器推送功能,客户端只需发送一个网页请求,服务器就会返回该网页,然后返回 CSS 和可能的一些 JS 脚本。这导致客户端只需一个调用,而不是三个。

图 1.2 – 通过网络传输的 HTTP/2 交错数据包
这里展示的显然是对 HTTP/2 协议的过度简化。要解释协议的所有实现细节,可能需要一本书。当我们谈论 gRPC 时,我们主要需要了解的是,在 HTTP/2 中,我们可以通过网络发送结构化二进制消息而不是文本,我们可以有流,其中服务器可以为单个响应发送多个响应,最后,我们以高效的方式做到这一点,因为我们只创建一个 TCP 连接,它将处理多个请求和响应。然而,了解 gRPC 在 HTTP/2 之上有自己的通信协议也很重要。这意味着这里展示的所有 HTTP 协议改进都是通信的促进者。gRPC 使用所有这些,并结合四个 RPC 操作。
RPC 操作
服务器和客户端之间通过 gRPC 进行的每个交互都可以描述为四个 RPC 操作。这些操作以创建框架中的复杂高级操作的方式组合。让我们看看这些操作,然后我将解释一个简单的 gRPC 调用是如何使用它们的。
重要提示
在本节中,我将使用 Wireshark 的 RPC 调用结果。我将在本书后面的章节中解释如何复制我在本节中做的事情。现在,我将只强调在转储中需要注意的重要事项。
发送头部
发送头部操作让服务器知道客户端将发送请求,或者让客户端知道服务器将发送响应。这充当服务器和客户端之间的开关,让双方都知道谁需要读取谁需要写入。
通过使用 Wireshark 分析一个简单的 gRPC 调用,我们可以观察到以下头部(简化版)是由客户端发送的,以便让服务器知道它将发送一个请求:
HyperText Transfer Protocol 2
Stream: HEADERS, Stream ID: 1, Length 67, POST
/greet.GreetService/Greet
Flags: 0x04, End Headers
00.0 ..0\. = Unused: 0x00
..0\. .... = Priority: False
.... 0... = Padded: False
.... .1.. = End Headers: True
.... ...0 = End Stream: False
Header: :method: POST
Header: content-type: application/grpc
在这个头部中需要注意的重要信息是,它提到客户端想要在/greet.GreetService/Greet路由上调用 HTTP POST,然后在标志中提到这是头部数据的结束。
然后,在调用过程中稍后,我们将看到以下头部(简化版)由服务器发送,以便让客户端知道它将发送响应:
HyperText Transfer Protocol 2
Stream: HEADERS, Stream ID: 1, Length 14, 200 OK
Flags: 0x04, End Headers
00.0 ..0\. = Unused: 0x00
..0\. .... = Priority: False
.... 0... = Padded: False
.... .1.. = End Headers: True
.... ...0 = End Stream: False
Header: :status: 200 OK
Header: content-type: application/grpc
在这里,我们再次可以看到这是一个头部,这是将要发送的最后一个。不过,主要区别在于服务器告诉客户端请求已被正确处理,并且通过发送状态码 200 来表示。
发送消息
Send Message操作是发送实际数据的操作。对于我们这些 API 开发者来说,这是最重要的操作。在发送头部之后,客户端可以发送一个消息作为请求,服务器可以发送一个消息作为响应。
通过使用 Wireshark 分析与Send Header相同的 gRPC 调用,我们可以观察到以下数据(简化版)作为请求由客户端发送:
GRPC Message: /greet.GreetService/Greet, Request
0... .... = Frame Type: Data (0)
.... ...0 = Compressed Flag: Not Compressed (0)
Message Length: 9
Message Data: 9 bytes
Protocol Buffers: /greet.GreetService/Greet,request
Message: <UNKNOWN> Message Type
Field(1):
[Field Name: <UNKNOWN>]
.000 1... = Field Number: 1
.... .010 = Wire Type: Length-delimited (2)
Value Length: 7
Value: 436c656d656e74
在这个头部中需要注意的重要信息是,它提到客户端在/greet.GreetService/Greet路由上发送数据,这与头部中发送的相同。然后,我们可以看到我们正在发送协议缓冲区数据(稍后详细介绍)以及该消息的二进制值为436c656d656e74。
之后,在调用过程中,在服务器头部之后,我们看到以下数据(简化版)作为响应由服务器发送:
GRPC Message: /greet.GreetService/Greet, Response
0... .... = Frame Type: Data (0)
.... ...0 = Compressed Flag: Not Compressed (0)
Message Length: 15
Message Data: 15 bytes
Protocol Buffers: /greet.GreetService/Greet,response
Message: <UNKNOWN> Message Type
Field(1):
[Field Name: <UNKNOWN>]
.000 1... = Field Number: 1
.... .010 = Wire Type: Length-delimited (2)
Value Length: 13
Value: 48656c6c6f20436c656d656e74
在这里,我们可以看到这是一个对在/greet.GreetService/Greet路由上进行的调用发送的响应消息,该消息的二进制值为48656c6c6f20436c656d656e74。
发送半关闭
Send Half Close操作关闭了 actor 的输入或输出。例如,在传统的请求/响应设置中,当客户端完成发送请求时,发送Half Close关闭客户端流。这在某种程度上类似于Send Header,因为它作为一个开关来通知服务器现在是工作的时间。
再次查看与相同 gRPC 调用相关的 Wireshark 转储,我们应该能够看到在Send Message操作期间设置了头部。我们可以观察到以下数据(简化版):
HyperText Transfer Protocol 2
Stream: DATA, Stream ID: 1, Length 14
Length: 14
Type: DATA (0)
Flags: 0x01, End Stream
0000 .00\. = Unused: 0x00
.... 0... = Padded: False
.... ...1 = End Stream: True
这次,我们有一个表示请求结束的标志。然而请注意,在这里,我们发送的是类型为DATA的有效负载。这与我们之前看到的不同,因为DATA比头部要轻得多。这是用于半关闭的,因为我们只想发送一个布尔值,表示客户端已完成。
发送尾部
最后,我们有一个用于终止整个 RPC 的操作。这是 Send Trailer 操作。这个操作也给我们提供了更多关于调用的信息,例如状态码、错误消息等。就本书的这一部分而言,我们只需要知道这些信息主要用于处理 API 错误。
如果我们查看相同的 Wireshark 调用,我们将得到以下数据(简化版):
HyperText Transfer Protocol 2
Stream: HEADERS, Stream ID: 1, Length 24
Length: 24
Type: HEADERS (1)
Flags: 0x05, End Headers, End Stream
00.0 ..0\. = Unused: 0x00
..0\. .... = Priority: False
.... 0... = Padded: False
.... .1.. = End Headers: True
.... ...1 = End Stream: True
Header: grpc-status: 0
Header: grpc-message:
注意,拖车基本上是一个头。有了这个头,我们将获得更多关于调用的信息(grpc-status 和 grpc-message)。然后我们接收两个标志 – 一个表示这是流的结束(在我们的情况下,请求/响应)。另一个表示这个拖车在这里结束。
RPC 类型
现在我们知道了有四种 RPC 操作,我们可以看到它们是如何组合起来创建 gRPC 提供的不同 RPC 类型的。我们将讨论一元、服务器流、客户端流和双向 RPC 类型。我们将看到每种类型都是之前提出的 RPC 操作的组合。
一元
一元 RPC 是执行一个请求并返回一个响应的 RPC。我们已经在上一节中提到了这一点,但让我们继续并使这个过程更清晰。
总是首先,客户端发送初始头。这个头将包含与我们想要调用的 RPC 端点相关的信息。就本书的这一部分而言,我们只需要知道这主要包含 RPC 路由和流 ID。前者是让服务器知道它应该调用哪个用户代码函数来处理请求。后者是确定数据应该发送到哪个流的方式。这是因为我们可以同时进行多个流。
由于服务器现在知道客户端将发送请求,客户端现在可以发送消息。这个消息将包含实际的请求有效负载。在我们的情况下,我们只将发送 Protocol Buffers 编码的数据,但请注意,您可以使用 gRPC 发送任何类型的数据。
之后,因为我们处于一元设置中,客户端已经完成了请求的发送。正如我们所知,客户端现在应该发送一个半关闭。这是告诉服务器 我已经完成,请发送给我 响应。
在这一点上,服务器将执行类似的工作。如图中所示,它将发送一个头,发送作为 Protobuf 编码消息的响应,并结束 RPC。然而,正如我们所知,服务器不会发送半关闭;它会发送拖车。这是表示调用是否成功的一些数据,有一个可选的错误消息,以及一些我们可以通过用户代码添加的其他键值对。

图 1.3 – 一元 RPC 流
服务器流
服务器流式 RPC 是执行一个请求并返回一个或多个响应的 RPC。这种 RPC 类型在客户端期望从服务器获取更新时很有用。例如,我们可以有一个客户端显示选定公司的股票价格。使用服务器流式,客户端可以订阅,服务器可以在一段时间内发送不同的价格。
在这种情况下,客户端没有任何变化。它将发送头部、消息和半关闭。然而,在服务器端,我们将交错发送 HTTP 数据消息和数据有效负载。
如以下图所示,服务器将首先发送其头部。当一个演员想要让另一个演员知道它将发送消息时,这是惯例。之后,如上所述,服务器将在发送 HTTP 数据消息和 Protobuf 有效负载之间交替。最初的数据消息将看起来像这样(简化版):
HyperText Transfer Protocol 2
Stream: DATA, Stream ID: 1, Length 30
Length: 30
Type: DATA (0)
Flags: 0x00
0000 .00\. = Unused: 0x00
.... 0... = Padded: False
.... ...0 = End Stream: False
这表示将发送一条消息。这是一个轻量级的头部。一旦我们到达要发送的最后一条消息,服务器将使用尾部完成 RPC,此时客户端将知道服务器已完成发送响应。

图 1.4 – 服务器流式处理流程
客户端流式
客户端流式 RPC 与服务器流式 RPC 类似,但这次客户端可以发送一个或多个请求,而服务器返回一个响应。这在客户端需要向服务器发送实时信息的情况下非常有用。例如,这可能对微控制器发送来自某种传感器的数据并更新服务器当前测量状态很有用。
客户端流式与服务器流式类似。正如你在以下图中可以看到的,客户端将执行服务器在服务器流式中所做的操作。这意味着客户端将交错发送 HTTP 数据消息,这些消息类似于之前提到的服务器流式中的消息,以及 Protobuf 消息。最后,当客户端完成时,它将简单地发送半关闭。

图 1.5 – 客户端流式处理流程
双向流
到目前为止,你可能已经猜到双向流是服务器流式和客户端流的混合。客户端可以发送一个或多个请求,服务器返回一个或多个响应。这在其中一个演员需要对其数据反馈时特别有用。例如,如果你有一个寻找出租车的应用程序,服务器发送有关出租车的更新可能就不够了。用户可能也在向目的地走去,希望能在路上截到出租车。因此,服务器也需要知道用户的位置。
双向流比客户端和服务器流更不可预测。这是因为每个参与者发送消息的顺序没有定义。服务器可以为每个请求或任何数量的请求提供响应。因此,对于本节,让我们假设我们正在与一个为每个请求返回响应的服务器一起工作。
在这种情况下,正如你在下面的图中可以看到的,客户端将发送一个头部和一个消息。然后,服务器将发送其头部和消息。之后,我们将从每个参与者那里获取数据和消息。最后,我们将从客户端获得半关闭(Half Close)和从服务器获得尾部(Trailer)。

图 1.6 – 双向流流程
RPC 的生命周期
现在我们已经了解了可以在 gRPC 中执行的基本 RPC 操作和不同类型的 RPC,我们可以看看 RPC 的生命周期。在本节中,我们将自上而下地介绍,首先解释当客户端发送请求和服务器接收它、发送响应和客户端接收响应时发生了什么。然后,我们将深入一点,讨论三个阶段:
-
连接 - 当客户端连接到服务器时会发生什么?
-
客户端端 - 当客户端发送消息时会发生什么?
-
服务器端 - 当服务器接收到消息时会发生什么?
重要提示
gRPC 在不同语言中有多种实现。最初的是 C++,一些实现只是 C++代码的包装器。然而,gRPC Go 是一个独立的实现。这意味着它是从头开始在 Go 中实现的,并没有包装 C++代码。因此,在本节中,我们将专门讨论 gRPC Go,这可能在其他实现中有所不同。
在深入细节之前,让我们先从宏观的角度出发,通过定义一些概念来开始。首先,我们需要明确的是,gRPC 是由用户代码中生成的代码驱动的。这意味着我们只与 gRPC API 的几个点进行交互,我们主要处理的是基于我们的 Protocol Buffer 服务定义生成的代码。现在不必过于担心这一点;我们将在最后一节中介绍它。
第二个重要的概念是传输的概念。传输可以看作是参与者和连接之间的管理者,它通过网络发送/接收原始字节。它包含一个读写流,该流设计为能够以任何顺序在网络中读写。在我们的情况下,最重要的方面是我们可以在io.Reader上调用读取,我们可以在io.Writer上调用写入。
最后,需要澄清的是,客户端和服务器非常相似。在客户端上调用的所有函数也将被服务器调用。它们只是在不同的对象上被调用(例如,ClientTransport和ServerTransport)。
现在,我们理解了所有这些,我们可以看看 RPC 生命周期的可视化表示。

图 1.7 – RPC 生命周期的鸟瞰图
我们可以看到,我们可以简单地定义一个通用的参与者,它将代表服务器和客户端。然后,我们可以看到生成的代码将通过调用名为 SendMsg 的函数直接与 gRPC 框架交互。
这正如其名所示,是为了在网络中发送数据。这个 SendMsg 函数将调用一个名为 Write 的底层函数。这是 Transport 中 io.Writer 提供的函数。一旦完成,其他参与者将在 io.Reader 上读取,然后是 RcvMsg 函数,最后,用户代码将接收到数据。
现在,我们将更深入地探讨 gRPC 通信的重要部分。对于任何类型的有线传输,客户端都需要连接到服务器,因此我们将从连接的具体细节开始。
连接
要创建一个连接,客户端代码将调用一个名为 Dial 的函数,该函数带有目标 URI 和一些选项作为参数。当接收到 Dial 请求时,gRPC 框架将根据 RFC 3986 解析目标地址,并根据 URI 的方案创建一个 Resolver。例如,如果我们使用 dns:// 方案,这是 gRPC 在 URI 中省略方案或提供的方案未知时使用的默认方案,gRPC 将创建一个 dnsResolver 对象。
dnsResolver 然后,解析器将执行其工作,即解析主机名并返回一个可以连接的地址列表。有了这些地址,gRPC 将根据用户在 Dial 选项中提供的配置创建一个负载均衡器。框架默认提供两个负载均衡器:
-
首先选择(默认),它连接到它可以连接的第一个地址,并将所有 RPC 发送到它
-
轮询,它连接到所有地址,并按顺序逐个将 RPC 发送到每个后端
如我们所见,负载均衡器的目标是找出客户端应该在哪个地址(们)上创建连接(们)。因此,它将返回一个地址列表,gRPC 应连接到这些地址,然后 gRPC 将创建一个通道,这是 RPC 使用的连接的抽象,以及子通道,这是负载均衡器可以用来将数据定向到一个或多个后端的连接的抽象。

图 1.8 – 通道与子通道
最后,用户代码将接收到一个ClientConn对象,该对象将用于关闭连接,但更重要的是,用于创建在生成的代码中定义的客户端对象,我们可以在其上调用 RPC 端点。最后要注意的是,默认情况下,整个过程是非阻塞的。这意味着 gRPC 不会等待连接建立就返回ClientConn对象。

图 1.9 – RPC 连接摘要
客户端端
现在我们已经建立了连接,我们可以开始考虑发送请求。目前,让我们假设我们已经生成了代码,并且它有一个Greet RPC 端点。并且对于我们的当前目的,它正在做什么并不重要;它只是一个 API 端点。
要发送请求,用户代码将简单地调用Greet端点。这将触发 gRPC 框架中名为NewStream的函数。那个函数的名字有点误导,因为在这里流不一定代表一个流式 RPC。实际上,无论你是否在进行流式 RPC,它都会被调用,并创建一个ClientStream对象。所以在这里,Stream大致等同于所有 RPC 的抽象。
在创建那个ClientStream的过程中,gRPC 框架将执行两个操作。第一个操作是它会调用负载均衡器以获取一个可用的子通道。这是根据在连接创建期间选择的负载均衡器策略来完成的。第二个操作是与传输交互。gRPC 框架将创建ClientTransport,它包含用于发送和接收数据的读写流,并将头部发送到服务器以初始化一个 RPC 调用。
一旦完成这些,gRPC 框架将简单地返回ClientStream给生成的代码,生成的代码将简单地用另一个对象封装它,为用户提供一组更小的可调用函数(例如,Send、Recv等)。

图 1.10 – 客户端通信摘要
服务器端
自然地,在发送请求后,我们期望从服务器获得响应。正如我们目前所知,客户端发送了一个头部来初始化一个 RPC 调用。这个头部将由ServerTransport处理。现在服务器已经知道客户端想要发送一个针对Greet RPC 端点的请求。
在此基础上,传输将向 gRPC 框架发送一个transport.Stream对象。然后,再次,这个流将被薄薄地封装在一个ServerStream对象中,并传递给生成的代码。此时,生成的代码知道要调用哪个用户代码函数。它之所以知道这一点,是因为用户代码将函数注册到特定的 RPC 端点上。
就这样,服务器将对接收到的数据进行计算,并将简单地通过相应的传输向客户端返回响应。ClientTransport 将读取它并将响应返回给用户代码。

图 1.11 – 服务器端通信摘要
摘要
所有这些知识现在可能令人感到压倒,但不要担心,你不需要记住所有展示给对象的名称来理解 gRPC 的工作原理。本章的重点是更多地给你一个关于在建立连接和发送/接收数据过程中涉及的不同角色的感觉。
我们看到,我们有四个客户端和/或服务器可以执行 RPC 操作。每个参与者发送一个头部来指示轮到它发送数据,然后他们发送消息,最后,他们各自有一个特殊操作来指示他们已完成发送消息。
之后,我们看到了 gRPC 是如何创建服务器和客户端之间的连接的。这是通过解析器完成的,解析器根据我们尝试连接的地址找到 IP 地址,以及负载均衡器,它帮助 gRPC 确定将数据发送到哪个子通道。
然后,我们讨论了通道和子通道。我们看到了它们是如何由客户端创建来连接服务器的。最后,我们看到了服务器将接收数据并调用用户代码为 RPC 端点注册的某些代码。
在下一章中,我们将介绍 Protocol Buffers 以及它们与 gRPC 的关系。
测验
-
哪个 RPC 操作告诉服务器客户端已准备好发送请求?
-
发送尾部 -
发送消息 -
发送头部
-
-
哪个 RPC 操作告诉客户端服务器已完成返回响应(s)?
-
发送半关闭 -
发送尾部 -
发送头部
-
-
哪种 RPC 类型可以从客户端以块的形式在一个请求中下载信息?
-
服务器流式传输
-
客户端流式传输
-
双向流式传输
-
单一
-
-
哪种 RPC 类型相当于传统的 HTTP/1.1 请求?
-
服务器流式传输
-
客户端流式传输
-
双向流式传输
-
单一
-
-
什么是通道?
-
RPCs 用于表示通过负载均衡器发现的任何可用服务器连接的抽象。
-
负载均衡器用于表示连接到特定服务器的抽象。
-
以上两者
-
-
什么是子通道?
-
RPCs 用于表示通过负载均衡器发现的任何可用服务器连接的抽象。
-
负载均衡器用于表示连接到特定服务器的抽象。
-
以上两者
-
-
当从
grpc.Dial接收ClientConn对象时,你能确定客户端已与服务器建立了连接吗?-
是
-
否
-
答案
-
C
-
B
-
A
-
D
-
A
-
B
第二章:Protobuf 初学者指南
既然我们已经理解了 gRPC 背后的基本网络概念,我们可以触及构建你的 gRPC API 的另一个支柱。这个支柱是 协议缓冲区,更通俗地称为 Protobuf。它是通信过程中的一个重要部分,因为我们看到,在上一章中,每条消息都被编码成二进制,这正是 Protobuf 在 gRPC 中为我们所做的事情。在本章中,目标是理解 Protobuf 是什么以及为什么它对于高效通信是必需的。最后,我们将探讨一些关于消息序列化和反序列化的细节。
在本章中,我们将涵盖以下主要主题:
-
Protobuf 是一种 接口描述 语言(IDL)
-
序列化/反序列化
-
Protobuf 与 JSON 的比较
-
编码细节
-
常见类型
-
服务
前提条件
你可以在 https://github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter2 找到本章的代码。在本章中,我们将讨论 Protocol Buffers 如何序列化和反序列化数据。虽然这可以通过编写代码来完成,但我们将避免这样做,以便学习如何使用 protoc 编译器来调试和优化我们的 Protobuf 模式。因此,如果你想重现指定的示例,你需要从 Protobuf GitHub 发布 页面(https://github.com/protocolbuffers/protobuf/releases)下载 protoc 编译器。开始的最简单方法是下载二进制发布版本。这些发布版本按照以下约定命名:protoc-${VERSION}-${OS}-{ARCHITECTURE}。解压缩 zip 文件,并遵循 readme.txt 指示(注意:我们打算在未来使用已知类型,所以请确保你也安装了包含文件)。之后,你应该能够运行以下命令:
$ protoc --version
最后,像往常一样,你可以在 GitHub 仓库当前章节文件夹(chapter2)下找到配套代码。
Protobuf 是一种 IDL
Protobuf 是一种语言。更确切地说,它是一种 IDL。做出这种区分很重要,因为正如我们稍后会更详细地看到的那样,在 Protobuf 中,我们不会像在编程语言中那样编写任何逻辑,而是编写数据模式,这些模式是用于序列化的合约,并且需要在反序列化时得到满足。因此,在我们解释编写 .proto 文件时需要遵循的所有规则,并详细了解序列化和反序列化的所有细节之前,我们首先需要了解什么是 IDL 以及这种语言的目标。
如我们之前所看到的,IDL 是接口描述语言的缩写,我们可以看到,名称包含三个部分。第一部分是接口,描述了一段代码,位于两个或更多应用程序之间,隐藏了实现的复杂性。因此,我们不对应用程序运行的硬件、运行的操作系统以及用哪种编程语言编写的假设。这个接口按设计是硬件无关、操作系统无关和语言无关的。这对于 Protobuf 和几个其他序列化数据模式来说很重要,因为它允许开发者一次编写代码,并且可以在不同的项目中使用。
第二部分是包含 ID、用户名和该账户拥有的权限的Account,我们可以编写以下内容:
syntax = "proto3";
enum AccountRight {
ACCOUNT_RIGHT_UNSPECIFIED = 0;
ACCOUNT_RIGHT_READ = 1;
ACCOUNT_RIGHT_READ_WRITE = 2;
ACCOUNT_RIGHT_ADMIN = 3;
}
message Account {
uint64 id = 1;
string username = 2;
AccountRight right = 3;
}
如果我们跳过一些在这个阶段不重要的细节,我们可以看到我们定义了以下内容:
-
一个列出所有可能权限的枚举和一个额外的角色
ACCOUNT_RIGHT_UNSPECIFIED -
一个消息(相当于类或结构体),列出
Account类型应该拥有的三个属性
再次,不查看细节,它也是可读的,并且Account和AccountRight之间的关系很容易理解。
最后,最后一部分是之前定义的Account类型在 Go 中的表示:
type AccountRight int32
const (
AccountRight_ACCOUNT_RIGHT_UNSPECIFIED AccountRight = 0
AccountRight_ACCOUNT_RIGHT_READ AccountRight = 1
AccountRight_ACCOUNT_RIGHT_READ_WRITE AccountRight = 2
AccountRight_ACCOUNT_RIGHT_ADMIN AccountRight = 3
)
type Account struct {
Id uint64 `protobuf:"varint,1,…`
Username string `protobuf:"bytes,2,…`
Right AccountRight `protobuf:"varint,3,…`
}
在此代码中,有一些重要的事情需要注意。让我们将此代码分解成几个部分:
type AccountRight int32
const (
AccountRight_ACCOUNT_RIGHT_UNSPECIFIED AccountRight = 0
AccountRight_ACCOUNT_RIGHT_READ AccountRight = 1
AccountRight_ACCOUNT_RIGHT_READ_WRITE AccountRight = 2
AccountRight_ACCOUNT_RIGHT_ADMIN AccountRight = 3
)
我们的AccountRight枚举定义为具有int32类型的值的常量。每个枚举变体的名称都以前缀枚举的名称开头,每个常量都有我们在 Protobuf 代码中等于号后面的设置值。这些值被称为字段标签,我们将在本章后面介绍它们。
现在,看一下以下代码:
type Account struct {
Id uint64 `protobuf:"varint,1,…`
Username string `protobuf:"bytes,2,…`
Right AccountRight `protobuf:"varint,3,…`
}
这里,我们的Account消息被转换为一个具有Id、Username和Right导出字段的 struct。这些字段中的每个字段都有一个类型,该类型从 Protobuf 类型转换为 Golang 类型。在我们的例子中,Go 类型和 Protobuf 类型具有完全相同的名称,但重要的是要知道在某些情况下,类型将不同地转换。这样的例子是 Protobuf 中的double,它将转换为 Go 的float64。最后,我们还有字段标签,在字段后面的元数据中引用。再次,它们的含义将在本章后面解释。
因此,为了总结,IDL 是一段代码,位于不同的应用程序之间,通过遵循某些定义的规则来描述对象及其关系。在这种情况下,Protobuf 的 IDL 将被读取,并用于在另一种语言中生成代码。然后,生成的代码将被用户代码用于序列化和反序列化数据。
序列化和反序列化
序列化和反序列化是许多方式和许多类型的应用程序中使用的两个概念。本节将讨论这两个概念在 Protobuf 的背景下。因此,即使你对这两个概念的理解很有信心,了解它们也是非常重要的。一旦你做到了,处理编码细节部分就会更容易,我们将深入探讨 Protobuf 如何底层序列化和反序列化数据。
让我们从序列化开始,然后简要提及反序列化,它是一个相反的过程。序列化的目的是存储数据,通常以更紧凑或可读的表示形式,以便以后使用。对于 Protobuf,这种序列化发生在你生成的代码对象中设置的数据上。例如,如果我们设置了Id、Username和Right字段在我们的Account结构体中,这些数据将是 Protobuf 将要处理的数据。它将根据字段类型使用不同的算法将每个字段转换为二进制表示。然后,我们使用这个内存中的二进制数据,要么通过网络(例如使用 gRPC)发送数据,要么将其存储在更持久的存储中。
当我们再次使用这个序列化数据时,Protobuf 将执行反序列化。这是一个读取之前创建的二进制文件并将数据重新填充到你喜欢的编程语言中的对象的过程,以便能够对其采取行动。再次强调,Protobuf 将根据读取的数据类型使用不同的算法来读取底层的二进制文件,并知道如何设置或不设置相关对象的每个字段。
总结来说,Protobuf 通过二进制序列化使数据比其他格式(如 XML 或 JSON)更紧凑。为此,它将从生成的代码对象的各个字段中读取数据,将其转换为二进制,并使用不同的算法,然后当我们最终需要数据时,Protobuf 将读取数据并填充给定对象的字段。
Protobuf 与 JSON
如果你已经在后端甚至前端工作过,有 99.99%的几率你已经使用过 JSON。这无疑是目前最受欢迎的数据模式,并且有原因使其成为如此。在本节中,我们将讨论 JSON 和 Protobuf 的优缺点,并解释哪种情况更适合哪种情况。我们的目标是保持客观,因为作为工程师,我们需要选择适合正确工作的正确工具。
由于我们可以为每种技术的优缺点写上章节,我们将缩小这些优缺点的范围到三个类别。这些类别是开发者在开发应用程序时最关心的,具体如下:
-
序列化数据的大小:我们希望在通过网络发送数据时减少带宽
-
数据模式和序列化数据的可读性:我们希望能够有一个描述性的模式,以便新来者或用户可以快速理解它,并且我们希望能够可视化序列化的数据,用于调试或编辑目的。
-
模式严格性:当 API 增长时,这迅速成为一项需求,我们需要确保不同应用程序之间发送和接收的数据类型是正确的
序列化数据大小
在序列化过程中,许多用例中的圣杯是减少数据的大小。这是因为我们通常希望将数据发送到网络上的另一个应用程序,负载越轻,它应该到达另一侧的速度就越快。在这个领域,Protobuf 相对于 JSON 是明显的赢家。这是因为 JSON 序列化为文本,而 Protobuf 序列化为二进制,因此有更多的空间来改进序列化数据的大小。一个例子是数字。如果你在 JSON 中将一个数字设置为id字段,你会得到类似以下的内容:
{ id: 123 }
首先,我们有一些带有大括号的模板代码,但最重要的是我们有一个占用三个字符或三个字节的数字。在 Protobuf 中,如果我们对同一个字段设置相同的值,我们将在以下示例中看到其十六进制表示。
重要提示
在配套 GitHub 仓库的chapter2文件夹中,你可以找到复制本章所有结果所需的文件。使用 protoc,我们将能够显示我们序列化数据的十六进制表示。为此,你可以运行以下命令:
Linux/Mac: cat ${INPUT_FILE_NAME}.txt | protoc --encode=${MESSAGE_NAME} ${PROTO_FILE_NAME}.proto | hexdump –C
Windows (PowerShell): (Get-Content ${INPUT_FILE_NAME}.txt | protoc --encode=${MESSAGE_NAME} ${PROTO_FILE_NAME}.proto) -join "`n" | Format-Hex
例如:
$ cat account.txt | protoc --encode=Account account.proto | hexdump -C
00000000 08 7b |.{|
00000002
目前,这可能会看起来像是魔法数字,但我们在下一节将看到它是如何编码成两个字节的。现在,两个字节而不是三个字节可能看起来微不足道,但想象一下这种差异在规模上的影响,你就会浪费数百万字节。
可读性
数据模式序列化的下一个重要问题是可读性。然而,可读性这个概念有点过于宽泛,尤其是在 Protobuf 的上下文中。正如我们所见,与 JSON 相比,Protobuf 将模式与序列化数据分开。我们在.proto文件中编写模式,然后序列化将给我们一些二进制数据。在 JSON 中,模式就是实际的序列化数据。因此,为了更清晰和更精确地描述可读性,让我们将可读性分为两部分:模式的可读性和序列化数据的可读性。
至于方案的可读性,这是一个个人偏好的问题,但有一些要点使 Protobuf 脱颖而出。其中之一是 Protobuf 可以包含注释,这对于描述需求的额外文档来说是个好东西。JSON 不允许在方案中包含注释,因此我们必须找到不同的方式来提供文档。通常,这是通过 GitHub wiki 或其他外部文档平台来完成的。这是一个问题,因为当项目和团队变大时,这种类型的文档很快就会过时。一个简单的疏忽,你的文档就不会描述你的 API 的真实状态。使用 Protobuf,尽管仍然可能存在过时的文档,但文档与代码更接近,这提供了更多激励和意识来更改相关的注释。
Protobuf 更易读的第二个特性是它具有显式的类型。JSON 有类型,但它们是隐式的。你知道如果一个字段的值被双引号包围,那么它包含一个字符串;如果值仅是数字,那么它是一个数字,等等。在 Protobuf 中,尤其是对于数字,我们从类型中获得了更多信息。如果我们有一个int32类型,我们可以明显知道这是一个数字,但除此之外,我们还知道它可以接受负数,并且我们可以知道可以存储在这个字段中的数字范围。显式类型不仅对于安全性(稍后会更详细地讨论)很重要,而且还可以让开发者了解每个字段的详细信息,并让他们能够准确地描述其模式以满足业务需求。
为了方案的易读性,我认为我们可以一致认为 Protobuf 在这里是赢家,因为它可以被编写为自文档化的代码,并且我们为对象中的每个字段都获得了显式的类型。
至于序列化数据的可读性,JSON 在这里是明显的赢家。如前所述,JSON 既是数据模式也是序列化数据。你所看到的就是你所得到的。然而,Protobuf 将数据序列化为二进制,即使你知道如何序列化和反序列化 Protobuf 数据,阅读起来也相当困难。最终,这是可读性和序列化数据大小之间的权衡。在序列化数据方面,Protobuf 将优于 JSON,并且在数据模式的可读性方面更加明确。然而,如果你需要可以手动编辑的人可读数据,Protobuf 可能不是你的用例的最佳选择。
方案严格性
最后,最后一个类别是方案的严格性。当你的团队和项目规模扩大时,这通常是一个很好的特性,因为它确保了方案被正确填充,并且对于某些目标语言,它缩短了开发者的反馈循环。
模式总是有效的,因为每个字段都有一个明确的类型,只能包含特定的值。我们绝对不能将字符串传递给期望数字的字段,或者将负数传递给期望正数的字段。这种约束在生成的代码中通过动态语言的运行时检查或类型语言的编译时检查来执行。在我们的情况下,由于 Go 是一种类型语言,我们将有编译时检查。
最后,在类型语言中,模式缩短了反馈循环,因为我们不再需要可能触发或可能不触发错误的运行时检查,我们只需要一个编译错误。这使得我们的软件更加可靠,开发者可以确信,如果他们能够编译,那么放入对象中的数据集将是有效的。
在纯 JSON 中,我们无法确保在编译时我们的模式是正确的。通常,开发者会添加额外的配置,如 JSON Schema,以便在运行时获得这种保证。这增加了我们项目的复杂性,并要求每个开发者都应自律,因为他们可以简单地编写代码而不开发模式。在 Protobuf 中,我们进行模式驱动开发。模式首先出现,然后我们的应用程序围绕生成的类型展开。此外,我们在编译时确保我们设置的值是正确的,我们不需要将设置复制到所有我们的微服务或子项目中。最终,我们在配置上花费的时间更少,我们在数据模式和数据编码上的思考时间更多。
编码细节
到目前为止,我们谈论了很多关于“算法”的内容;然而,我们并没有深入到具体的细节。在本节中,我们将探讨在 Protobuf 中序列化和反序列化过程中所涉及的 主要算法。我们首先将查看我们可以用于字段的全部类型,然后根据这些类型,我们将它们分为三个类别,最后我们将解释每个类别所使用的算法。
在 Protobuf 中,被认为是简单且由 Protobuf 自带提供的类型被称为标量类型。我们可以使用这里列出的 15 种此类类型:
-
int32 -
int64 -
uint32 -
uint64 -
sint32 -
sint64 -
fixed32 -
fixed64 -
sfixed32 -
sfixed64 -
double -
float -
string -
bytes -
bool
在这 15 种类型中,有 10 种是用于整数的(前 10 种)。这些类型一开始可能看起来有些令人畏惧,但请不要过于担心如何在这之间做出选择,我们将在本节中讨论这一点。现在最重要的理解是,三分之二的类型是用于整数的,这显示了 Protobuf 的优势——编码整数。
现在我们已经了解了标量类型,让我们将这些类型分为三个类别。然而,我们在这里不是为了创建简单的类别,比如数字、数组等等。我们想要创建与 Protobuf 序列化算法相关的类别。总共有三个:固定大小数字、可变大小整数(varints)和长度限定类型。以下是一个包含每个类别的表格:
| 固定大小数字 | Varints | Length-delimited types |
|---|---|---|
fixed32 |
int32 |
string |
fixed64 |
int64 |
bytes |
sfixed32 |
uint32 |
|
sfixed64 |
uint64 |
|
double |
bool |
|
float |
让我们逐一介绍。
固定大小数字
对于习惯于静态语言的开发者来说,最容易理解的是固定大小数字。如果你在尝试优化存储空间的底层语言中工作过,你知道在大多数硬件上,我们可以用 32 位(4 字节)或 64 位(8 字节)存储一个整数。fixed32 和 fixed64 只是正常数字的二进制表示,这些数字在允许你控制整数存储大小的语言中(例如 Go、C++、Rust 等)会有。如果我们把数字 42 序列化为 fixed32 类型,我们将得到以下结果:
$ cat fixed.txt | protoc --encode=Fixed32Value
wrappers.proto | hexdump -C
00000000 0d 2a 00 00 00 |.*...|
00000005
在这里,2a 是 42,而 0d 是字段标签和字段类型的组合(关于这一点将在本节后面详细介绍)。以同样的方式,如果我们以 fixed64 类型序列化 42,我们将得到以下结果:
$ cat fixed.txt | protoc --encode=Fixed64Value
wrappers.proto | hexdump -C
00000000 09 2a 00 00 00 00 00 00 00 |.*.......|
00000009
唯一改变的是字段类型和字段标签的组合(09)。这主要是因为我们将类型更改为 64 位数字。
两种易于理解的标量类型是 float 和 double。再次强调,Protobuf 会生成这些类型的二进制表示。如果我们把 42.42 编码为 float,将会得到以下输出:
$ cat floating_point.txt | protoc --encode=FloatValue
wrappers.proto | hexdump -C
00000000 0d 14 ae 29 42 |...)B|
00000005
在这种情况下,解码稍微复杂一些,但这仅仅是因为浮点数以不同的方式编码。如果你对这种数据存储感兴趣,可以查看 IEEE 浮点算术标准 (IEEE 754),它解释了浮点数在内存中的形成方式。这里需要注意的是,浮点数以 4 个字节编码,前面是我们的标签 + 类型。而对于值为 42.42 的 double 类型,我们将得到以下结果:
$ cat floating_point.txt | protoc --encode=DoubleValue
wrappers.proto | hexdump -C
00000000 09 f6 28 5c 8f c2 35 45 40 |..(\..5E@|
00000009
这以 8 字节编码,并带有标签 + 类型。请注意,标签 + 类型在这里也发生了变化,因为我们现在处于 64 位数字的领域。
最后,我们剩下 sfixed32 和 sfixed64。我们之前没有提到它,但 fixed32 和 fixed64 是无符号数。这意味着我们无法在具有这些类型的字段中存储负数。sfixed32 和 sfixed64 解决了这个问题。因此,如果我们把 -42 编码为 sfixed32 类型,我们将得到以下结果:
$ cat sfixed.txt | protoc --encode=SFixed32Value
wrappers.proto | hexdump -C
00000000 0d d6 ff ff ff |.....|
00000005
这是通过取 42 的二进制表示,翻转所有位(1 的补码),然后加 1(2 的补码)得到的。否则,如果你序列化一个正数,你将得到与fixed32类型相同的二进制表示。然后,如果我们用类型为sfixed64的字段编码-42,我们将得到以下结果:
$ cat sfixed.txt | protoc --encode=SFixed64Value
wrappers.proto | hexdump -C
00000000 09 d6 ff ff ff ff ff ff ff |.........|
00000009
这类似于sfixed32类型,只是标签+类型发生了变化。
总结一下,固定整数是整数在大多数计算机内存中存储方式的简单二进制表示。正如其名所示,它们的序列化数据将始终序列化成相同数量的字节。对于某些用例,使用这种表示是可行的;然而,在大多数情况下,我们希望减少仅用于填充的比特数。在这些用例中,我们将使用称为 varints 的东西。
Varints
现在我们已经看到了固定整数,让我们转向另一种数字序列化类型:可变长度整数。正如其名所示,在序列化整数时,我们不会得到固定数量的字节。
为了更精确,整数越小,它序列化成的字节数就越少,整数越大,它序列化成的字节数就越多。让我们看看算法是如何工作的。
在这个例子中,让我们序列化数字 300。首先,我们将取这个数字的二进制表示:
100101100
使用这个二进制,我们现在可以将其分成 7 位一组,并在需要时用零填充:
0000010
0101100
现在,由于我们缺少 2 个比特来创建 2 个字节,我们将为除了第一个组之外的所有组添加 1 作为最高有效位(MSB),并且我们将为第一个组添加 0 作为 MSB:
00000010
10101100
这些 MSB 是连续位。这意味着,当我们有 1 时,我们后面还有 7 个比特要读取,如果我们有 0,这就是要读取的最后一个组。最后,我们将这个数字放入小端序,我们得到以下:
10101100 00000010
或者,我们会在十六进制中表示为AC 02。现在我们已经将 300 序列化为AC 02,并且考虑到反序列化是序列化的相反过程,我们可以反序列化这些数据。我们取AC 02的二进制表示,丢弃连续位(MSB),并反转字节的顺序。最后,我们得到以下二进制:
100101100
这与我们的起始二进制相同。它等于 300。
现在,在现实世界中,你可能会遇到更大的数字。关于正数的快速参考,以下是一个列表,列出了字节数量增加的阈值:
| 阈值值 | 字节大小 |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 128 | 2 |
| 16,384 | 3 |
| 2,097,152 | 4 |
| 268,435,456 | 5 |
| 34,359,738,368 | 6 |
| 4,398,046,511,104 | 7 |
| 562,949,953,421,312 | 8 |
| 72,057,594,037,927,936 | 9 |
| 9,223,372,036,854,775,807 | 9 |
一个敏锐的读者可能会注意到,使用 varint 通常是有益的,但在某些情况下,我们可能将值编码成比所需更多的字节。例如,如果我们把 720,575,904,037,927,936 编码成int64类型,它将被序列化为 9 字节,而使用fixed64类型,它将被编码为 8 字节。此外,我们刚才看到的编码问题之一是负数将被编码成一个大的正数,因此将被编码成 9 字节。这引出了以下问题:我们如何有效地在不同的整数类型之间进行选择?
如何选择?
答案是,一如既往,这取决于具体情况。然而,我们可以系统地做出选择,以避免许多错误。我们主要需要根据我们想要序列化的数据做出以下三个选择:
-
需要的数字范围
-
负数的需要
-
数据分布
范围
到现在为止,你可能已经注意到我们类型上的 32 和 64 后缀并不总是关于我们的数据将被序列化成多少位。对于 varints,这更多的是关于可以序列化的数字范围。这些范围取决于用于序列化的算法。
对于固定、有符号和可变长度的整数,数字的范围与开发者在 32 位和 64 位上所习惯的范围相同。这意味着我们得到以下:
[-2^(NUMBER_OF_BITS – 1), 2^(NUMBER_OF_BITS – 1) – 1]
在这里,NUMBER_OF_BITS取决于你想要使用的类型,是 32 还是 64。
对于无符号数字(uint)——这又像是开发者所期望的那样——我们将得到以下范围:
[0, 2 * 2^(NUMBER_OF_BITS – 1) - 1]
负数的需要
在你根本不需要负数的情况下(例如,对于 ID),理想的使用类型是无符号整数(uint32,uint64)。这将防止你编码负数,与有符号整数相比,它在正数上有两倍的取值范围,并且将使用 varint 算法进行序列化。
你可能还会遇到另一种类型,即用于有符号整数的类型(sint32,sint64)。我们不会深入讲解如何序列化它们,但算法会将任何负数转换成正数(ZigZag 编码),然后使用 varint 算法序列化正数。这对于序列化负数来说更有效率,因为我们可以利用 varint 编码,而不是将负数序列化为一个大的正数(9 字节),现在我们使用 varint 编码。然而,对于序列化正数来说,这就不太有效率了,因为现在我们混合了之前是负数的数字和正数。这意味着对于相同的正数,我们可能会有不同数量的编码字节。
数据分布
最后,值得一提的一点是,编码效率高度依赖于你的数据分布。你可能根据某些假设选择了某些类型,但你的实际数据可能不同。两个常见的例子是选择int32或int64类型,因为我们预计很少会有负值,以及选择int64类型,因为我们预计很少会有非常大的数字。在这两种情况下,都可能导致显著的低效,因为在这两种情况下,我们可能会将很多值序列化为 9 个字节。
不幸的是,没有一种方法可以决定一个始终完美匹配数据的类型。在这种情况下,最好的办法是在代表你整个数据集的真实数据上做实验。这将给你一个关于你做对了什么和做错了什么的想法。
长度限定类型
现在我们已经看到了数字的所有类型,我们剩下的是长度限定类型。这些类型,如字符串和字节,我们在编译时无法知道它们的长度。将这些视为动态数组。
要序列化这种动态结构,我们只需在随后的原始数据前加上该数据的长度。这意味着如果我们有一个长度为 10 的字符串,内容为“0123456789”,我们将有以下字节序列:
$ cat length-delimited.txt | protoc --encode=StringValue
wrappers.proto | hexdump -C
00000000 0a 0a 30 31 32 33 34 35 36 37 38
39 |..0123456789|
0000000c
在这里,第一个0a实例是字段标签+类型,第二个0a实例是 10 的十六进制表示,然后是我们每个字符的 ASCII 值。要了解为什么 0 变成 30,你可以在你的终端中输入man ascii并查找十六进制集,以查看 ASCII 手册。你应该会有以下类似的输出:
30 0 31 1 32 2 33 3 34 4
35 5 36 6 37 7 38 8 39 9
在这里,每一对中的第一个数字是第二个数字的十六进制值。
另一种将被序列化为长度限定类型的消息字段是重复字段。重复字段相当于列表。要写入这样的字段,我们只需在字段类型前加上repeated关键字。如果我们想序列化 ID 列表,我们可以写出以下内容:
repeated uint64 ids = 1;
有了这个,我们可以存储 0 个或多个 ID。
同样,这些字段将以长度作为前缀进行序列化。如果我们取ids字段并将数字从 1 到 9 进行序列化,我们将有以下序列:
$ cat repeated.txt | protoc --encode=RepeatedUInt64Values
wrappers.proto | hexdump -C
00000000 0a 09 01 02 03 04 05 06 07 08 09 |...........|
0000000b
这是一个包含 9 个元素的列表,后面跟着 1,2,……等等。
重要提示
重复字段仅在存储标量类型(除了字符串和字节)时作为长度限定类型进行序列化。这些重复字段被认为是打包的。对于复杂类型或用户定义的类型(消息),值将以不太优化的方式进行编码。每个值都将单独编码,并以前面加上类型+标签字节(而不是只序列化一次类型+标签)。
字段标签和线类型
到目前为止,你多次读取了“标签+类型”,但我们并没有真正看到这意味着什么。正如之前提到的,每个序列化字段的第一个字节(或字节)将是一个字段类型和字段标签的组合。让我们先看看字段标签是什么。你肯定注意到了字段语法的不同之处。每次我们定义一个字段时,我们都会添加一个等号,然后是一个递增的数字。以下是一个例子:
uint64 id = 1;
虽然它们看起来像是将值分配给字段,但它们只是在这里为字段提供一个唯一的标识符。这些标识符,称为标签,可能看起来微不足道,但它们是序列化最重要的信息。它们用于告诉 Protobuf 将哪些数据反序列化到哪个字段。正如我们在不同序列化算法的展示中看到的,字段名称不会被序列化——只有类型和标签会被序列化。因此,当反序列化开始时,它会看到一个数字,并知道将后续的数据重定向到何处。
现在我们知道这些标签只是标识符,让我们看看这些值是如何编码的。标签简单地作为 varint 序列化,但它们与线缆类型一起序列化。线缆类型是分配给 Protobuf 中一组类型的数字。以下是线缆类型的列表:
| 类型 | 含义 | 用于 |
|---|---|---|
| 0 | Varint | int32、int64、uint32、uint64、sint32、sint64、bool、enum |
| 1 | 64 位 | fixed64、sfixed64、double |
| 2 | 长度限定 | 字符串、字节、打包重复字段 |
| 5 | 32 位 | fixed32、sfixed32、float |
这里,0 是 varint 的类型,1 是 64 位,以此类推。
为了将标签和线缆类型组合起来,Protobuf 使用了一个称为位打包的概念。这是一种旨在减少数据序列化所需位数的技巧。在我们的例子中,数据是字段元数据(著名的标签+类型)。所以,这是它的工作方式。序列化元数据的最后 3 位是保留给线缆类型的,其余的是标签。如果我们拿我们在“固定大小数字”部分提到的第一个例子,在那里我们使用标签 1 将 42 序列化到fixed32字段中,我们得到以下内容:
0d 2a 00 00 00
这次我们只对0d部分感兴趣。这是字段的元数据。为了看到它是如何序列化的,让我们将0d转换为二进制(使用 0 填充):
00001101
在这里,我们为线缆类型有 101(5)——这是 32 位线缆类型——并且为标签 1 有 00001(1)。现在,由于标签被序列化为 varint,这意味着该元数据的字节可能超过 1 个。以下是一个参考,了解字节数量增加的阈值:
| 字段标签 | 大小(位) |
|---|---|
| 1 | 5 |
| 16 | 13 |
| 2,048 | 21 |
| 262,144 | 29 |
| 33,554,432 | 37 |
| 536,870,911 | 37 |
这意味着,由于没有设置值的字段将不会被序列化,我们需要保留最低的标签给那些最常填充的字段。这将降低存储元数据所需的开销。一般来说,15 个标签就足够了,但如果您遇到需要更多标签的情况,您可能需要考虑将一组数据移动到一个具有更低标签的新消息中。
常见类型
到目前为止,如果您检查了配套代码,您会看到我们定义了很多“无聊”的类型,它们只是围绕一个字段进行包装。需要注意的是,我们是手动编写的,只是为了简单地给出如何检查某些数据序列化的示例。大多数时候,您将能够使用已经定义的、做同样事情的类型。
已知类型
Protobuf 本身自带了一组已经定义的类型。我们称它们为 已知类型。虽然其中许多类型在 Protobuf 库本身或高级用例之外很少有用,但其中一些类型很重要,我们将在本书中使用一些这些类型。
我们可以很容易理解的类型是包装器。我们之前手动编写了一些。它们通常以它们所包装的类型名称开头,并以 Value 结尾。以下是一个包装器的列表:
-
BoolValue -
BytesValue -
DoubleValue -
EnumValue -
FloatValue -
Int32Value -
Int64Value -
StringValue -
UInt32Value -
UInt64Value
这些类型可能对调试用例很有趣,比如我们之前看到的那些,或者只是序列化简单的数据,比如数字、字符串等。
然后,还有一些表示时间的类型,如 Duration 和 Timestamp。这两个类型是以完全相同的方式定义的([Duration | Timestamp] 不是一个正确的 Protobuf 语法,它的意思是我们可以用其中的任何一个术语来替换):
message [Duration | Timestamp] {
// Represents seconds of UTC time since Unix epoch
// 1970-01-01T00:00:00Z. Must be from 0001-01-
01T00:00:00Z to
// 9999-12-31T23:59:59Z inclusive.
int64 seconds = 1;
// Non-negative fractions of a second at nanosecond
// resolution. Negative
// second values with fractions must still have non-
// negative nanos values
// that count forward in time. Must be from 0 to
// 999,999,999
// inclusive.
int32 nanos = 2;
}
然而,正如它们的名称所暗示的,它们代表不同的概念。Duration 类型是开始时间和结束时间之间的差异,而 Timestamp 类型是一个简单的时间点。
最后,还有一个非常重要的已知类型是 FieldMask。这是一个表示在序列化另一个类型时应包含的字段集合的类型。为了理解这个类型,可能给出一个例子会更好。假设我们有一个 API 端点返回一个包含 id、username 和 email 的账户。如果您只想获取账户的电子邮件地址以准备一个您想要发送促销电子邮件的人的名单,您可以使用 FieldMask 类型告诉 Protobuf 只序列化 email 字段。这让我们减少了序列化和反序列化的额外成本,因为我们现在只处理一个字段而不是三个。
Google 常见类型
在已知的类型之上,还有一些是由 Google 定义的类型。这些类型定义在 googleapis/api-common-protos GitHub 仓库下的 google/type 目录中,并且可以在 Golang 代码中轻松使用。我鼓励您检查所有类型,但我想要提及一些有趣的类型:
-
LatLng: 存储为双精度浮点数的纬度/经度对 -
Money: 带有按 ISO 4217 定义的货币的金额 -
Date: 年、月和日以int32存储的日期
再次提醒,前往仓库检查所有其他内容。这些类型已经过实战检验,并且在很多情况下比我们编写的简单类型更优化。然而,请注意,这些类型可能也不适合您的用例。没有一种适合所有情况的解决方案。
服务
最后,我们需要关注并将在本书中使用的最后一个结构是服务。在 Protobuf 中,一个服务是一组 RPC 端点,包含两个主要部分。第一部分是 RPC 的输入,第二部分是输出。因此,如果我们想为我们的账户定义一个服务,我们可以有如下所示的内容:
message GetAccountRequest {…}
message GetAccountResponse {…}
service AccountService {
rpc GetAccount(GetAccountRequest) returns (GetAccountResponse);
//...
}
在这里,我们定义了一个表示请求的消息,另一个表示响应的消息,我们使用这些作为 getAccount RPC 调用的输入和输出。在下一章中,我们将介绍更多关于服务的先进用法,但当前重要的是要理解 Protobuf 定义了服务,但不会为它们生成代码。只有 gRPC 会这样做。
Protobuf 的服务在这里是为了描述一个合同,并且是 RPC 框架的工作在客户端和服务器端履行这个合同。请注意,我写的是 一个 RPC 框架,而不是简单的 gRPC。任何 RPC 框架都可以读取 Protobuf 服务提供的信息并从中生成代码。在这里,Protobuf 的目标是独立于任何语言和框架。应用程序对序列化数据所做的事情对 Protobuf 来说并不重要。
最后,这些服务是 gRPC 的支柱。正如我们将在本书后面看到的那样,我们将使用它们来发起请求,并在服务器端实现它们以返回响应。在客户端使用定义的服务将让我们感觉就像直接在服务器上调用一个函数。如果我们以 AccountService 为例,我们可以通过以下代码调用 GetAccount:
res := client.GetAccount(req)
在这里,client 是 gRPC 客户端的实例,req 是 GetAccountRequest 的实例,而 res 是 GetAccountResponse 的实例。在这种情况下,这有点像我们在调用服务器端实现的 GetAccount。然而,这是 gRPC 的作用。它将隐藏所有复杂的序列化和反序列化对象以及将它们发送到客户端和服务器的过程。
摘要
在本章中,我们看到了如何编写消息和服务,以及标量类型是如何序列化和反序列化的。这为我们准备了一本书的其余部分,我们将广泛使用这些知识。
在下一章中,我们将讨论 gRPC,为什么它使用 Protobuf 进行序列化和反序列化,以及它在幕后做了什么,我们还将将其与 REST 和 GraphQL API 进行比较。
问答
-
数字 32 在
int32标量类型中代表什么?-
序列化数据将存储的位数
-
可以适合标量类型的数字范围
-
类型是否可以接受负数
-
-
varint 编码在做什么?
-
以这种方式压缩数据,使得序列化数据所需的字节数更少
-
将每个负数转换为正数
-
-
ZigZag 编码在做什么?
-
以这种方式压缩数据,使得序列化数据所需的字节数更少
-
将每个负数转换为正数
-
-
在以下代码中,
= 1语法是什么,它用来做什么?uint64 ids = 1;-
这是在将值 1 赋给一个字段
-
1 是一个标识符,其唯一目的是帮助开发者
-
1 是一个标识符,它帮助编译器知道将二进制数据反序列化到哪个字段。
-
-
消息是什么?
-
包含字段并代表实体的对象
-
API 端点集合
-
可能状态列表
-
-
枚举是什么?
-
包含字段并代表实体的对象
-
API 端点集合
-
可能状态列表
-
-
服务是什么?
-
包含字段并代表实体的对象
-
API 端点集合
-
可能状态列表
-
答案
-
B
-
A
-
B
-
C
-
A
-
C
-
B
第三章:gRPC 简介
现在我们已经对数据在网络中如何流动以及 Protobuf 如何工作有了基本的了解,我们可以进入 gRPC 世界。在本章中,目标是理解 gRPC 在 HTTP/2 上做了什么,为什么 Protobuf 是 gRPC 的完美匹配,并且看到 gRPC 是一个由行业中的主要公司支持的技术。这将让我们明白为什么 gRPC 被描述为“HTTP/2 上的 Protobuf”,并让我们在使用它时充满信心,无需担心技术太新或缺乏社区。
在本章中,我们将涵盖以下主要主题:
-
gRPC 的主要用例
-
使用 Protobuf 的优势
-
在 Protobuf 之上 gRPC 的作用
前提条件
你可以在github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter3找到本章的代码。在本章中,我将使用 protoc 从 .proto 文件生成 Go 代码。这意味着你需要确保你已经安装了 protoc。你可以从 readme.txt 指令中下载一个 zip 文件(注意:我们打算在未来使用已知类型,所以请确保你也安装了包含文件)。在 protoc 之上,你还需要两个 protoc 插件:protoc-gen-go 和 protoc-gen-go-grpc。前者生成 Protobuf 代码,后者生成 gRPC 代码。要添加它们,你可以简单地运行以下命令:
$ go install google.golang.org/protobuf/cmd/protoc-gen-go
@latest
$ go install google.golang.org/grpc/cmd/protoc-gen-go-
grpc@latest
最后,确保你的 GOPATH 环境变量包含在你的 PATH 环境变量中。通常,在安装 Golang 时这已经为你完成了,但如果你在找不到 protoc-gen-go 或 protoc-gen-go-grpc 时遇到任何错误,你需要手动完成。要获取 GOPATH 环境变量,你可以运行以下命令:
$ go env GOPATH
然后,根据你的操作系统,你可以按照步骤将输出添加到你的 PATH 环境变量中。
一个成熟的技术
gRPC 不仅仅是一个你可以忽视的新酷框架,它是一个经过 Google 在规模上战斗测试超过十年的框架。最初,该项目是用于内部使用,但在 2016 年,Google 决定提供一个开源版本,这个版本不依赖于公司内部工具和架构的特定性。
之后,像 Uber 这样的公司以及更多公司迁移了它们现有的服务到 gRPC,以提高效率,同时也为了它提供的所有额外功能。此外,一些开源项目,如 etcd,它是一个在 Kubernetes 核心中使用的分布式键值存储,使用 gRPC 在多个实例之间进行通信。
最近,微软加入了构建 .NET 实现的 gRPC 项目的努力。虽然本书的目标不是解释它所做的一切,但它显然对项目表现出浓厚的兴趣。此外,像这样的公司越愿意贡献,可用的资源就越多,社区和工具也越强大。该项目得到了强有力的支持,这对我们所有人来说都是好事。
现在,所有这些都听起来很棒,但我意识到我们中的大多数人不会达到这些巨头的规模,因此了解 gRPC 的优点很重要。让我们看看它在哪些用例中表现出色。第一个大家都在谈论的用例是微服务之间的通信。这个用例很有吸引力,特别是对于多语言微服务。作为软件工程师,我们的任务是选择合适的工具,以及在不同语言中进行代码生成,以便我们能够做到这一点。
另一个用例是实时更新。正如我们所见,gRPC 给我们提供了流式传输数据的能力。这有多种形式,如服务器端流式传输,这可能有助于保持与股票价格等数据的同步。然后,我们有客户端流式传输,这可能有助于传感器将数据流式传输到后端。最后,我们还有双向流式传输,当客户端和服务器都需要意识到对方的更新时,这可能很有趣,例如在消息应用中。
另一个重要的用例是进程间通信(IPC)。这是在同一台机器上不同进程之间发生的通信。它可以用于同步两个或多个不同的应用程序,通过模块化架构实现关注点分离(SOC),或者通过应用程序沙箱化来提高安全性。
显然,我介绍了我在外面看到的 gRPC 最常见应用,但它的应用还有很多,重要的是你要在自己的用例中测试它,看看它是否符合你的要求。如果你对测试 gRPC 感兴趣,你需要开始尝试找出 Protobuf 如何减少你的有效载荷和应用程序效率。
为什么是 Protobuf?
到现在为止,你应该已经理解了 Protobuf 为我们提供了一种编写数据模式的方法,描述了我们的数据应该如何进行序列化和反序列化。然后,Protobuf 编译器(protoc)让我们从这些模式中生成一些代码,以便在代码中使用生成的类型,而这些可序列化类型正是 gRPC 用于让用户代码与请求和响应对象交互,并通过网络发送它们的二进制表示。
消息的二进制表示是 Protobuf 被用作 gRPC 默认数据模式的最大原因。数据序列化所需的字节数比传统数据模式(如 XML、JSON 等)要少得多。这意味着不仅消息可以更快地传递,而且反序列化也会更快。
重要提示
以下实验主要是为了展示 Protobuf 的性能。示例被夸大了,但这将给你一个关于 JSON 在反序列化过程中额外成本的感觉。结果可能会因运行、操作系统和硬件而异,所以如果你想运行自己的实验,你可以在 chapter3 文件夹中找到基准测试代码和数据(github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter3)。要获取数据,你需要使用 gzip 解压缩它。你可以通过运行 gzip -dk accounts.json.gz 或 gzip -dk accounts.bin.gz 命令来完成。之后,为了运行实验,你首先需要使用 protoc --go_out=proto -Iproto --go_opt=module=https://github.com/PacktPublishing/gRPC-Go-for-Professionals/proto proto/*.proto 编译 .proto 文件,然后你可以在 chapter3 文件夹中通过运行 go run main.go 来执行 Go 代码。
为了证明这一点,我们可以进行一个简单的实验——我们可以生成 100,000 个账户(带有 ID 和用户名),运行 1,000 次反序列化,并计算反序列化所有数据所需的平均时间。以下是其中一个运行结果,与未修剪的(换行符和空格)JSON 相比,与 Protobuf 二进制文件:
JSON: 49.773ms
PB: 9.995ms
然而,大多数开发者都会修剪他们的 JSON,所以这是移除换行符和空格后的结果:
JSON: 38.692ms
PB: 9.712ms
它更好,但仍然比 Protobuf 慢得多。
最后,我们可以查看实验中使用的序列化数据大小。对于未压缩的 JSON 与未压缩的 Protobuf,我们有以下输出:
1.3M accounts.bin
3.1M accounts.json
对于压缩版本(gzip),我们有以下输出:
571K accounts.bin.gz
650K accounts.json.gz
我鼓励你更多地实验这个,特别是针对你的用例进行实验,但除非在 proto 文件设计中有重大错误,否则你会发现 Protobuf 在大小和序列化/反序列化时间方面要高效得多。
除了提供数据序列化之外,我们还发现 Protobuf 还有一个服务概念,这是客户端和服务器之间的一个合约。虽然这个概念并不专属于 gRPC(你可以生成其他框架的代码包装),但 gRPC 使用它来生成适当的 API 端点。这为我们提供了客户端和服务器两边的类型安全。如果我们尝试发送错误的数据,并且我们在编译型语言中工作,我们将得到编译错误而不是在运行时得到错误。这大大缩短了开发者的反馈循环,并减少了代码中可能失败的区域。
最后,Protobuf 本身是语言无关的。这意味着这是一个独立的数据模式,可以在多个项目中共享。如果你为某个微控制器编写了 C++代码,并将数据发送到用 Go 编写的后端,而后端又将数据发送到用 JS 编写的 Web 前端,你可以简单地共享相同的 Protobuf 文件,并使用 protoc 生成你的模型。你不必在每次不同的项目中都重写它们。这减少了在添加或更新功能时需要更新的区域,并为多个团队需要达成一致意见的接口提供了支持。
最后,Protobuf 通过创建更小的有效负载(例如,通过 gRPC)来启用更快的通信,它为我们提供了通信两端的类型安全,并且它可以在多种语言中完成所有这些,这样我们就可以为不同的任务使用合适的工具。
gRPC 在做什么?
gRPC 被描述为“HTTP/2 上的 Protobuf”。这意味着 gRPC 将生成所有通信代码,这些代码包装在 gRPC 框架中,并站在 Protobuf 的肩膀上以序列化和反序列化数据。为了知道客户端和服务器上可用的哪些 API 端点,gRPC 将查看我们.proto文件中定义的服务,并从中学到生成一些元数据和所需函数所需的基本信息。
对于 gRPC,首先需要理解的是,它有多种实现。例如,在 Go 中,你得到一个纯 gRPC 实现。这意味着整个代码生成过程和通信都是用 Go 编写的。其他语言可能有类似的实现,但很多都是围绕 C 实现进行包装的。虽然在这个书的背景下我们不需要了解它们,但重要的是要知道它们是可用的,因为这解释了 protoc 编译器插件的存在。
如你所知,现在有很多种语言。有些相对较新,有些则相当古老,所以跟上每种语言的演变实际上是不切实际的。这就是为什么我们有 protoc 插件。任何对支持一种语言感兴趣的开发商或公司都可以编写这样的插件来生成代码,该代码将发送通过 HTTP/2 的 Protobuf。例如,Swift 支持就是由苹果公司添加的。
既然我们在谈论 Go 语言,我们想看看生成什么样的代码,以便了解 gRPC 是如何工作的,同时也知道如何调试以及在哪里查找函数签名。让我们从一个简单的服务开始——在proto/account.proto中,我们有以下内容:
syntax = "proto3";
option go_package = "github.com/PacktPublishing/
gRPC-Go-for-Professionals";
message Account {
uint64 id = 1;
string username = 2;
}
message LogoutRequest {
Account account = 1;
}
message LogoutResponse {}
service AccountService {
rpc Logout (LogoutRequest) returns (LogoutResponse);
}
在这个服务中,我们有一个名为Logout的 API 端点,它接受一个参数LogoutRequest(Account的包装器)并返回一个LogoutResponse参数。LogoutResponse是一个空消息,因为我们想发送需要停止会话的账户,我们不需要任何结果,只需要一个指示调用成功的标志。
然后,为了从这个中生成 Protobuf 和 gRPC 代码,我们将运行以下命令:
$ protoc --go_out=. \
--go_opt=module=github.com/PacktPublishing/gRPC-Go-for-
Professionals \
--go-grpc_out=. \
--go-grpc_opt=module=github.com/PacktPublishing/gRPC-Go-for-
Professionals \
proto/account.proto
我们已经看到,在 Protobuf 中,消息将被转换为结构体,但现在我们还有一个包含 gRPC 通信代码的 _grpc.pb.go 文件。
服务器
首先,让我们看看服务器端生成了什么。我们将从文件的底部开始,从服务描述符开始。但在那之前,我们需要知道什么是描述符。在 Protobuf 和 gRPC 的上下文中,描述符是一个元对象,它表示 Protobuf 代码。这意味着在我们的情况下,我们有一个 Go 对象代表一个服务或其他概念。实际上,我们在上一章中没有深入探讨它,但如果你查看 Account 生成的代码,你也会发现提到了 Desc。
对于我们的 AccountService 服务,我们有以下描述符:
var AccountService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "AccountService",
HandlerType: (*AccountServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "Logout",
Handler: _AccountService_Logout_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "account.proto",
}
这意味着我们有一个名为 AccountService 的服务,它与名为 AccountServiceServer 的类型相关联,并且这个服务有一个名为 Logout 的方法,该方法应由名为 _AccountService_Logout_Handler 的函数处理。
你应该在服务描述符上方找到这个处理程序。它看起来如下(简化版):
func _AccountService_Logout_Handler(srv interface{}, ctx
context.Context, dec func(interface{}) error, interceptor
grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(LogoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AccountServiceServer).Logout(ctx, in)
}
//...
}
这个处理程序负责创建一个类型为 LogoutRequest 的新对象,并在将其传递给类型为 AccountServiceServer 的 Logout 函数之前填充它。注意,这里我们假设我们总是有一个等于 nil 的拦截器,因为这是一个更高级的功能,但稍后我们将看到一个如何设置并使用它的例子。
最后,我们看到提到了 AccountServiceServer 类型。它看起来是这样的:
type AccountServiceServer interface {
Logout(context.Context, *LogoutRequest) (*LogoutResponse,
error)
mustEmbedUnimplementedAccountServiceServer()
}
这是一个包含我们的 RPC 端点函数签名和 mustEmbedUnimplementedAccountServiceServer 函数的类型。
在前往 Logout 函数之前,让我们先理解 mustEmbedUnimplemented AccountServiceServer。这对于 gRPC 来说是一个重要的概念,因为它在这里提供我们服务的向前兼容实现,这意味着我们 API 的旧版本将能够与新版本通信而不会崩溃。
如果你检查 AccountServiceServer 的定义,你会看到以下内容:
// UnimplementedAccountServiceServer must be embedded to
have forward compatible implementations.
type UnimplementedAccountServiceServer struct {
}
func (UnimplementedAccountServiceServer)
Logout(context.Context, *LogoutRequest) (*LogoutResponse,
error) {
return nil, status.Errorf(codes.Unimplemented, "method
Logout not implemented")
}
有了这个,我们可以理解这个 UnimplementedAccountServiceServer 类型必须在某个地方嵌入,而这个“某个地方”就是我们将在本书后面定义的类型,当我们编写 API 端点时。我们将有以下的代码:
type struct Server {
UnimplementedAccountServiceServer
}
这被称为类型嵌入,这是 Go 添加来自另一个类型的属性和方法的方式。你可能听说过建议优先使用组合而非继承,这正是如此。我们将 UnimplementedAccountServiceServer 中的方法定义添加到 Server 中。这将使我们能够生成默认实现,返回 method Logout not implemented。这意味着如果一个没有完整实现的服务器在其未实现的 API 端点之一上收到调用,它将返回错误但不会因为不存在的端点而崩溃。
一旦我们理解了这一点,Logout 方法的签名就很简单了。如前所述,稍后我们将定义自己的服务器类型,该类型嵌入 UnimplementedAccountServiceServer 类型,并将覆盖 Logout 函数的实现。任何对 Logout 的调用都将被重定向到实现,而不是默认生成的代码。
客户端
客户端生成的代码甚至比服务器代码还要简单。我们有一个名为 AccountServiceClient 的接口,其中包含所有 API 端点:
type AccountServiceClient interface {
Logout(ctx context.Context, in *LogoutRequest, opts
...grpc.CallOption) (*LogoutResponse, error)
}
我们还有该接口的实际实现,称为 accountServiceClient:
type accountServiceClient struct {
cc grpc.ClientConnInterface
}
func NewAccountServiceClient(cc grpc.ClientConnInterface)
AccountServiceClient {
return &accountServiceClient{cc}
}
func (c *accountServiceClient) Logout(ctx context.Context,
in *LogoutRequest, opts ...grpc.CallOption)
(*LogoutResponse, error) {
out := new(LogoutResponse)
err := c.cc.Invoke(ctx, "/AccountService/Logout", in,
out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
我们可以在这段代码中注意到一个重要的事情。我们有一个端点路由 /AccountService/Logout。如果你回顾一下标题为 服务器 的部分中描述的 AccountService_ServiceDesc 变量,你会发现这个路由是 ServiceName 和 MethodName 属性的连接。这将让服务器知道如何将这个请求路由到 _AccountService_Logout_Handler 处理器。
就这些。我们可以看到 gRPC 正在处理调用端点的所有样板代码。我们只需要通过调用 NewAccountServiceClient 来创建一个遵循 AccountServiceClient 接口的对象,然后通过这个对象,我们可以调用 Logout 成员。
读写流程
现在我们已经看到了什么是 Protobuf 和 gRPC,是时候回到我们在 第一章 中展示的读写流程了。这样做是为了让它更详细一些,并包括我们所学的知识。
作为快速提醒,我们在编写和读取数据时看到它们主要是三个级别。我们有用户代码、gRPC 框架和传输层。对我们来说,这里最有趣的是用户代码。我们没有在 第一章 中过多地深入细节,但现在我们有了更多关于 gRPC 是如何工作的知识,我们可以更清楚地理解这个过程。
用户代码层是开发者编写的并与 gRPC 框架交互的代码。对于客户端,这是调用端点,对于服务器,这是端点的实现。如果我们继续使用我们的 AccountService 服务,我们可以给出一个读写流程的具体例子。
我们首先可以做的就是将用户代码层分成两部分:实现和生成的代码。此外,在第一章中,我们提供了一个相当通用的架构,其中我们描述了整体流程并绘制了一个神秘的组件,称为Other Actor。现在,让我们将服务器和客户端分成两个不同的参与者,我们得到以下系统:

图 3.1 – AccountService 的读写流程专业化
重要提示
在前面的图中,我使用缩写“c”和“s”分别指代客户端和服务器。“c”是由NewAccountServiceClient创建的AccountServiceClient实例,“s”是定义在Implementation中的一种类型,该类型定义了Logout函数。
一旦我们展开这个图,我们就能看到一些重要的事情正在发生。第一个有趣的概念是生成的代码在各个不同的通信参与者之间是共享的。我们看到 gRPC Go 插件会生成一个包含服务器和客户端类型的单个文件。这意味着这个文件应该在所有用 Go 编写的参与者之间共享。
我们还可以注意到,gRPC 框架和生成的代码为我们抽象了一切。这让我们只需关注调用一个带有Request对象的端点,并编写处理该Request对象并返回Response对象的端点处理。这极大地限制了我们需要编写的代码量,因此使得我们的代码更容易测试,因为我们只需要关注更少的代码。
最后,需要注意的最后一件事是,我们可以限制自己只阅读生成的代码来了解我们每个端点的参数和返回类型。这很有帮助,因为生成的代码将被你的 IDE 捕获,你将获得自动完成,或者你可以简单地检查一个文件来获取你需要的信息。
gRPC 为什么重要?
现在我们对 gRPC 有了概念,我们可以探讨它为什么重要。为了解释 gRPC 的作用,我们将将其与两种其他执行客户端/服务器通信的方式进行比较。第一种是基于 HTTP 和 JSON 的传统 REST API 架构,第二种是 GraphQL。
REST
虽然我假设大多数阅读这本书的人对 REST API 都很熟悉,但我仍然认为介绍设计此类 API 的原则很重要。这将帮助我们理解 gRPC 在哪些方面与 REST 相似,在哪些方面不同。
REST API,就像本比较研究中提到的其他每项技术一样,是信息提供者和消费者之间的接口。在编写这样的 API 时,我们会在特定的 URL(路由)上公开端点,客户端可以使用这些端点来创建、读取、更新和删除资源。
然而,REST API 与 gRPC 和 GraphQL 不同。主要区别在于 REST 不是一个框架——它是一组可以在不同方式中实现的架构实践。主要的约束如下:
-
客户端、服务器和资源是通信过程中的主要实体。客户端从服务器请求资源,服务器返回相关资源。
-
请求和响应由 HTTP 管理。
GET用于读取资源,POST用于创建资源,PUT用于更新资源,PATCH用于更新资源的一部分,DELETE用于删除资源。 -
请求之间不应存储任何与客户端相关的信息。这是一种无状态通信,每个请求都是独立的。
最后,尽管这样的 API 没有绑定到任何数据格式,但最常用的格式是 JSON。这主要是因为 JSON 拥有广泛的社区,许多语言和框架都能处理这种数据格式。
GraphQL
GraphQL 被呈现为 API 的查询语言。它允许开发者编写描述可用数据的数据模式,并允许他们查询模式中存在的特定字段集。
由于它允许我们编写查询,我们可以拥有更通用的端点,并且只请求我们感兴趣的特征字段。这解决了过度获取和不足获取的问题,因为我们只获取我们请求的数据量。
在所有这些之上,由于 GraphQL 主要使用 JSON 作为数据格式,并在其数据模式中使用显式类型和注释,这使得 GraphQL 更易于阅读和自文档化。这使得这项技术在规模化的公司中更加成熟,因为我们可以在编译时进行类型检查,缩短反馈循环,我们不需要将文档和代码分开,因此它不太可能不同步。
与 gRPC 的比较
既然我们已经概述了每种技术的作用,我们可以开始比较它们与 gRPC。我们将重点关注这四种设计 API 方式之间的最大区别。这些区别如下:
-
用于通信的传输、数据格式和数据模式
-
API 端点的关注点分离
-
开发者在编写 API 时的流程
-
开箱即用的功能便利性
传输、数据格式和数据模式
在这方面,GraphQL 和 REST API 是相似的。它们都使用 HTTP/1.1 作为底层传输,并且大多数情况下,开发者使用 JSON 发送结构化数据。另一方面,gRPC 默认使用 HTTP/2 和 Protobuf。这意味着,在 gRPC 中,我们有更小的有效负载要发送,并且我们有更高效的连接处理。
当我们处理 Protobuf 而不是 JSON 时,有一些事情需要更加小心。Protobuf 根据字段的类型提供隐式默认值,并且这些默认值不会序列化到最终的二进制文件中。例如,int32的默认值是 0。这意味着我们无法区分值 0 被设置还是字段没有被设置。当然,有处理这种情况的方法,但这使得客户端的使用稍微复杂一些。在这方面,GraphQL 处理默认值的方式不同。我们可以将默认值作为端点的参数传递,这意味着我们可以以更用户友好的方式处理特定情况。
最后,重要的是要提到,所有这些技术都非常灵活,可以处理通过网络传输的数据格式。REST API 处理二进制和其他类型的数据,GraphQL 也可以接受二进制数据,而 gRPC 可以发送 JSON 数据。然而,这种灵活性也带来了问题。如果你在 REST API 上使用二进制,你让客户端和服务器解释这个二进制代表什么。没有类型安全,我们需要处理序列化/反序列化错误,否则这些错误将由库或框架处理。如果你使用 GraphQL 的二进制,你将大大减少可以使用的社区工具数量。最后,如果你使用 gRPC 的 JSON,你将失去 Protobuf 的所有优势。
API 端点的关注点分离
设计 API 的关注点分离可能很棘手,可能导致问题,如未获取或过度获取。GraphQL 被设计用来解决在请求时获取过多或过少数据的问题。有了它,你可以简单地请求特定功能所需的特定字段集。虽然使用 gRPC 和 REST API 也可以做类似的事情,但当你的 API 面对外部用户时,这仍然不够用户友好。
然而,API 中的关注点分离可以帮助我们处理一些事情。首先,它可以帮助我们缩小端点的测试范围。我们不需要考虑端点可能具有的所有可能的输入和输出,我们只需要关注特定的输入和输出。
其次,拥有更小、更具体的端点将有助于 API 滥用的处理。因为我们可以清楚地知道哪个请求被发送到了哪个端点,我们可以按客户端对它们进行速率限制,从而保护我们的 API。在像 GraphQL 这样的更灵活的 API 端点中,这本质上更难实现,因为我们需要考虑是否要在整个路由、特定的输入或只是一个简单的查询上进行速率限制。
开发者的工作流程
这些技术的另一个重要方面,但常常被忽视的是,开发者编写 API 时的开发流程。使用 REST API 时,我们通常分别在不同的服务器和客户端上工作,这个过程容易出错。如果没有关于预期数据的规范,我们可能会陷入长时间的调试。此外,即使我们有数据规范,开发者也是人,人都会犯错误。客户端可能期望某种类型的数据,但服务器发送的却是另一种。
现在,这不仅仅是一个只影响 REST API 的问题——gRPC 和 GraphQL API 也存在这个问题。然而,问题范围缩小了,因为我们能确保只有特定类型的数据可以用作请求,另一种用作响应。这使得我们可以专注于“快乐路径”,而不是编写检查序列化和反序列化是否失败的代码。
gRPC 和 GraphQL 开发 API 的方式被称为int,它将在编译时告诉我们。这也使得测试范围缩小,因为我们现在可以专注于功能本身,而不是由于外部问题可能发生的许多可能的错误。
便利性
最后,另一个被忽视的话题是使用技术的便利性。这可能是由于社区开发工具,或者仅仅是框架自带的一些即用功能。在这种情况下,使用 JSON 的技术通常拥有更多的工具和支持。这是因为 JSON 已经广泛使用很长时间了,它因其可读性而受到青睐。
然而,即使与 JSON 支持的 API 相比缺少工具,gRPC 也是基于帮助谷歌扩展和保障其产品的原则设计的,它拥有许多你无需额外依赖就能获得的功能。gRPC 具有拦截器、TLS 认证以及许多其他内置的高端特性,作为官方框架的一部分,因此编写安全且高效的代码变得更加简单。
最后,GraphQL 是三种技术中唯一明确指出端点具有副作用的技术。这可以在 gRPC 或 REST API 中记录;然而,这不能进行静态检查。这很重要,因为它使 API 用户更加了解后台发生的事情,并可能导致更好的路由选择。
总结
总结来说,gRPC 是一种成熟的技术,被科技巨头和开源社区采用,用于创建高效且性能卓越的客户端/服务器通信。这不仅适用于分布式系统,也适用于使用 IPC 的本地环境。gRPC 默认使用 Protobuf,因为它具有紧凑的二进制序列化和快速反序列化能力,同时也因为其类型安全和语言无关性。在此基础上,gRPC 会生成代码,通过 HTTP/2 发送 Protobuf。它为我们生成服务器和客户端,这样我们就不必考虑通信的细节。所有细节都由 gRPC 框架处理。
在下一章,我们终于要开始动手实践了。我们将设置一个 gRPC 项目,确保我们的代码生成工作正常,并为服务器和客户端编写一些样板代码。
测验
-
Protobuf 成为 gRPC 默认数据格式的其中一个原因是什么?
-
序列化的数据是可读的
-
它是动态类型的
-
它是类型安全的
-
-
在 Go 实现中,哪个组件生成服务器/客户端代码?
-
Protoc
-
gRPC Go 插件
-
其他
-
-
在 gRPC 生成的代码的上下文中,什么是服务描述符?
-
它们描述了服务有哪些端点以及如何处理请求
-
它们描述了如何返回响应
-
这两个
-
-
用户代码将如何实现登出端点?
-
通过在生成的
Logout函数中编写代码 -
通过创建生成的代码的副本并编辑它
-
通过使用生成的服务器中的类型嵌入并实现该类型的
Logout
-
答案
-
C
-
B
-
A
-
C
第四章:设置项目
正如章节标题所暗示的,我们将从头开始搭建一个 gRPC 项目。我们首先将创建我们的 Protobuf 模式,因为我们正在进行模式驱动开发。一旦模式创建完成,我们将生成 Go 代码。最后,我们将编写服务器和客户端的模板,以便我们可以在本书的后续部分重用它们。
在本章中,我们将涵盖以下主要主题:
-
常见的 gRPC 项目架构
-
从模式生成 Go 代码
-
编写可重用的服务器/客户端模板
前提条件
我假设你已经从上一章安装了 protoc。如果没有,现在是安装它的正确时机,因为没有它,你将无法从本章中获得太多好处。
在本章中,我将展示设置 gRPC 项目的常见方法。我将使用 protoc、Buf 和 Bazel。因此,根据你感兴趣的,你可能需要下载相应的工具。Buf 是 protoc 的一个抽象层,使我们能够更容易地运行 protoc 命令。在此基础上,它还提供了诸如 linting 和检测破坏性更改等功能。你可以从这里下载 Buf:docs.buf.build/installation。我还会使用 Bazel 来自动从 Protobuf 生成 Go 代码以及服务器和客户端的二进制文件。如果你有兴趣使用它,你可以查看安装文档(github.com/bazelbuild/bazelisk#installation)。
最后,你可以在配套 GitHub 仓库的chapter4文件夹中找到本章的代码(github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter4)。
创建.proto 文件定义
由于本章的目标是编写一个可以用于后续项目的模板,我们将创建一个虚拟的 proto 文件,这样我们可以测试我们的构建系统是否正常工作。这个虚拟的 proto 文件将包含一个消息和一个服务,因为我们想测试 Protobuf 和 gRPC 的代码生成。
被称为DummyMessage的消息将定义如下:
message DummyMessage {}
被称为DummyService的服务将定义如下:
service DummyService {}
现在,因为我们计划生成 Golang 代码,我们仍然需要定义一个名为go_package的选项,并将其值设置为 Go 模块名称与包含 proto 文件的子文件夹名称的连接。这个选项很重要,因为它允许我们定义生成代码应该所在的包。在我们的情况下,项目架构如下:
.
├── client
│ └── go.mod
├── go.work
├── proto
│ ├── dummy
│ │ └── v1
│ │ └── dummy.proto
│ └── go.mod
└── server
└── go.mod
我们有一个 monorepo(Go 工作空间),包含三个子模块:client、proto和server。我们通过进入每个文件夹并运行以下命令来创建每个子模块:
$ go mod init github.com/github.com/PacktPublishing/
gRPC-Go-for-Professionals/
$FOLDER_NAME
$FOLDER_NAME应替换为你当前所在的文件夹名称(client、proto或server)。
为了使过程更快一些,我们可以创建一个命令,该命令将列出 root 目录中的文件夹并执行 go 命令。为此,您可以使用以下 UNIX(Linux/macOS)命令:
$ find . -maxdepth 1 -type d -not -path . -execdir sh -c "pushd {}; go
mod init 'github.com/PacktPublishing/
gRPC-Go-for-Professionals/{}';
popd" ";"
如果您使用的是 Windows,您可以使用 PowerShell 运行以下命令:
$ Get-ChildItem . -Name -Directory | ForEach-Object { Push-
Location $_; go mod init "github.com/PacktPublishing/
gRPC-Go-for-Professionals/$_" ;
Pop-Location }
之后,我们可以创建工作空间文件。我们这样做是通过进入项目的根目录(chapter4)并运行以下命令:
$ go work init client server proto
现在我们有以下 go.work:
go 1.20
use (
./client
./proto
./server
)
每个子模块都有以下 go.mod:
module $MODULE_NAME/$SUBMODULE_NAME
go 1.20
在这里,我们将用 URL 替换 $MODULE_NAME,例如 github.com/PacktPublishing/gRPC-Go-for-Professionals,并用文件夹包含文件的相应名称替换 $SUBMODULE_NAME。在客户端的 go.mod 的情况下,我们将有如下内容:
module github.com/PacktPublishing/gRPC-Go-for-Professionals/client
go 1.20
最后,我们可以通过在我们的文件中添加以下行来完成 dummy.proto:
option go_package = "github.com/PacktPublishing/
gRPC-Go-for-Professionals/
proto/dummy/v1";
现在我们有以下 dummy.proto:
syntax = "proto3";
option go_package = "github.com/PacktPublishing/
gRPC-Go-for-Professionals/proto/dummy/v1";
message DummyMessage {}
service DummyService {}
这就是我们测试从 dummy.proto 生成代码所需的所有内容。
生成 Go 代码
为了在生成代码所需的工具方面保持公正,我将从底层到高层介绍三种不同的工具。我们将首先看看如何使用 protoc 手动生成代码。然后,因为我们不想总是编写冗长的命令行,我们将看看如何使用 Buf 使这一生成过程变得更简单。最后,我们将看看如何使用 Bazel 将代码生成集成到我们的构建过程中。
重要提示
在本节中,我将展示编译您的 proto 文件的基本方法。大多数情况下,这些命令可以满足您的需求,但有时您可能需要查看每个工具的文档。对于 protoc,您可以通过运行 protoc --help 来获取选项列表。对于 Buf,您可以访问在线文档:docs.buf.build/installation。对于 Bazel,您也可以在bazel.build/reference/be/protocol-buffer找到在线文档。
Protoc
使用 protoc 是从 proto 文件中手动生成代码的方法。如果您只处理几个 proto 文件且文件之间(导入)没有很多依赖关系,这种方法可能很合适。否则,正如我们将看到的,这将会相当痛苦。
然而,我仍然认为我们应该稍微学习一下 protoc 命令,以便我们了解我们可以用它做什么。此外,高级工具基于 protoc,这将帮助我们理解其不同的功能。
使用我们上一节中的 dummy.proto 文件,我们可以在根目录(chapter4)中运行 protoc,如下所示:
$ protoc --go_out=. \
--go_opt=module=github.com/PacktPublishing/
gRPC-Go-for-Professionals \
--go-grpc_out=. \
--go-grpc_opt=module=github.com/PacktPublishing/
gRPC-Go-for-Professionals \
proto/dummy/v1/dummy.proto
现在,这可能会看起来有点吓人,实际上,这不是您能写的最短的命令之一。当我们谈到 Buf 时,我会向您展示一个更紧凑的命令。但首先,让我们将前面的命令分解成几个部分。
在讨论--go_out和--go-grpc_out之前,让我们看看--go_opt=module和--go-grpc_opt=module。这些选项在告诉 protoc 关于要被proto文件中go_package选项传递的值移除的公共模块。假设我们有以下内容:
option go_package = "github.com/PacktPublishing/
gRPC-Go-for-Professionals/proto/dummy/v1";
然后,- -go_opt=module=github.com/PacktPublishing/gRPC-Go-for-Professionals将会从我们的go_package中移除module=后面的值,因此现在我们只有/proto/dummy/v1。
现在我们已经理解了这一点,我们可以进入--go_out和--go-grpc_out。这两个选项告诉 protoc 在哪里生成 Go 代码。在我们的例子中,看起来我们正在告诉 protoc 在根级别生成我们的代码,但实际上,因为它与前面两个选项结合在一起,它将在 proto 文件旁边生成代码。这是由于包的移除,导致 protoc 在/``proto/dummy/v1包中生成代码。
现在,你可以看到一直编写这种命令可能会多么痛苦。大多数人不会这样做。他们要么编写一个脚本来自动完成这项工作,要么使用其他工具,例如 Buf。
Buf
对于 Buf,我们需要进行一些额外的设置来生成代码。在项目的根目录(chapter4)中,我们将创建一个 Buf 模块。为了做到这一点,我们可以简单地运行以下命令:
$ buf mod init
这将创建一个名为buf.yaml的文件。这个文件是设置项目级别选项的地方,例如代码检查或跟踪破坏性更改。这些内容超出了本书的范围,但如果你对这个工具感兴趣,可以查看其文档(buf.build/docs/tutorials/getting-started-with-buf-cli/))。
一旦我们有了这些,我们需要编写生成配置。在一个名为buf.gen.yaml的文件中,我们将有如下内容:
version: v1
plugins:
- plugin: go
out: proto
opt: paths=source_relative
- plugin: go-grpc
out: proto
opt: paths=source_relative
在这里,我们正在定义 Go 插件在 Protobuf 和 gRPC 中的使用。对于每一个,我们都在说我们希望生成代码在proto目录下,并且我们使用另一个--go_opt和--go-grpc_opt选项来配置 protoc,其值为paths=source_relative。当这个设置被启用时,生成的代码将被放置在输入文件(dummy.proto)相同的目录中。因此,最终,Buf 正在运行类似于我们在 protoc 部分所做的事情。它正在运行以下命令:
$ protoc --go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
proto/dummy/v1/dummy.proto
为了使用 Buf 运行生成,我们只需要运行以下命令(在chapter4目录下):
$ buf generate proto
使用 Buf 在中型或大型项目中相当普遍。它有助于自动化代码生成,并且易于开始使用。然而,你可能已经注意到你需要分步生成代码然后构建你的 Go 应用程序。Bazel 将帮助我们一步完成所有这些。
Bazel
重要提示
在本节中,我将使用名为 GO_VERSION、RULES_GO_VERSION、RULES_GO_SHA256、GAZELLE_VERSION、GAZELLE_SHA256 和 PROTO_VERSION 的变量。我们没有在本节中包含这些变量,以确保书籍易于更新。您可以在 chapter4 文件夹中的 versions.bzl 文件中找到这些版本(github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter4)。
Bazel 的设置稍微复杂一些,但值得付出努力。一旦您的构建系统启动并运行,您将能够通过一条命令构建整个应用程序(生成和构建)以及/或运行它。
在 Bazel 中,我们首先在根级别定义一个名为 WORKSPACE.bazel 的文件。在这个文件中,我们定义了我们项目的所有依赖项。在我们的例子中,我们依赖于 Protobuf 和 Go。除此之外,我们还将添加一个对 Gazelle 的依赖,这将帮助我们创建生成代码所需的 BUILD.bazel 文件。
因此,在 WORKSPACE.bazel 中,在定义其他任何内容之前,我们将定义我们的工作区名称,导入我们的版本变量,并导入一些用于克隆 Git 仓库和下载存档的实用工具:
workspace(name = "github_com_packtpublishing_grpc_go_for_
professionals")
load("//:versions.bzl",
"GO_VERSION",
"RULES_GO_VERSION",
"RULES_GO_SHA256",
"GAZELLE_VERSION",
"GAZELLE_SHA256",
"PROTO_VERSION"
)
load("@bazel_tools//tools/build_defs/repo:http.bzl",
"http_archive")
load("@bazel_tools//tools/build_defs/repo:git.bzl",
"git_repository")
然后,我们将定义 Gazelle 的依赖项:
http_archive(
name = "bazel_gazelle",
sha256 = GAZELLE_SHA256,
urls = [
"https://mirror.bazel.build/github.com/bazelbuild/
bazel-gazelle/releases/download/%s/bazel-gazelle-
%s.tar.gz" % (GAZELLE_VERSION, GAZELLE_VERSION),
"https://github.com/bazelbuild/bazel-gazelle/
releases/download/%s/bazel-gazelle-%s.tar.gz" %
(GAZELLE_VERSION, GAZELLE_VERSION),
],
)
接着,我们需要拉取构建 Go 二进制文件、应用程序等的依赖项:
http_archive(
name = "io_bazel_rules_go",
sha256 = RULES_GO_SHA256,
urls = [
"https://mirror.bazel.build/github.com/bazelbuild/
rules_go/releases/download/%s/rules_go-%s.zip" %
(RULES_GO_VERSION, RULES_GO_VERSION),
"https://github.com/bazelbuild/rules_go/releases/
download/%s/rules_go-%s.zip" % (RULES_GO_VERSION,
RULES_GO_VERSION),
],
)
现在我们有了这个,我们可以拉取 rules_go 的依赖项,设置构建 Go 项目的工具链,并告诉 Gazelle 我们 WORKSPACE.bazel 文件的位置:
load("@io_bazel_rules_go//go:deps.bzl",
"go_register_toolchains", "go_rules_dependencies")
load("@bazel_gazelle//:deps.bzl", "gazelle_dependencies")
go_rules_dependencies()
go_register_toolchains(version = GO_VERSION)
gazelle_dependencies(go_repository_default_config =
"//:WORKSPACE.bazel")
最后,我们拉取 Protobuf 的依赖并加载其依赖项:
git_repository(
name = "com_google_protobuf",
tag = PROTO_VERSION,
remote = "https://github.com/protocolbuffers/protobuf"
)
load("@com_google_protobuf//:protobuf_deps.bzl",
"protobuf_deps")
protobuf_deps()
我们的 WORKSPACE.bazel 文件已经完成。
现在,让我们转到根级别的 BUILD.bazel 文件。在这个文件中,我们将定义运行 Gazelle 的命令,并将让 Gazelle 知道 Go 模块名称,以及我们希望它不要考虑 proto 目录中的 Go 文件。我们这样做是因为否则,Gazelle 会认为 proto 目录中的 Go 文件也应该有自己的 Bazel 目标文件,这可能会在以后造成问题:
load("@bazel_gazelle//:def.bzl", "gazelle")
# gazelle:exclude proto/**/*.go
# gazelle:prefix github.com/PacktPublishing/gRPC-Go-for-Professionals
gazelle(name = "gazelle")
注意
有了这些,我们现在可以运行以下命令:
$ bazel run //:gazelle
如果您通过 Bazelisk 安装了 Bazel,每次您运行 bazel 命令时,Bazel 都会尝试获取其最新版本。为了避免这种情况,您可以创建一个名为 .bazelversion 的文件,包含您当前安装的 bazel 版本。您可以通过输入以下命令找到版本:
bazel --version
一个例子可以在 chapter4 文件夹中找到。
在拉取依赖并编译完成后,您应该能够在 proto/dummy/v1 目录中看到一个生成的 BUILD.bazel 文件。此文件最重要的部分是以下 go_library:
go_library(
name = "dummy",
embed = [":v1_go_proto"],
importpath = "github.com/PacktPublishing/gRPC-Go-for-
Professionals/proto/dummy/v1",
visibility = ["//visibility:public"],
)
之后,我们将使用这个库并将其链接到我们的二进制文件中。它包含我们开始所需的全部生成代码。
服务器模板
我们的建设系统已经准备好了。现在我们可以专注于代码。但在进入之前,让我们定义我们想要的东西。在本节中,我们想要构建一个 gRPC 服务器的模板,我们可以将其用于后续章节,甚至用于书外的后续项目。为此,有一些事情我们想要避免:
-
实现细节,如服务实现
-
特定的连接选项
-
将 IP 地址设置为常量
我们可以通过不再关心生成的代码来解决这些问题。它只是为了测试我们的构建系统。然后,我们将默认使用不安全的连接进行测试。最后,我们将 IP 地址作为程序的参数:
让我们一步一步来做:
-
我们首先需要将 gRPC 依赖项添加到
server/go.mod中。因此,在server目录中,我们可以输入以下命令:$ go get google.golang.org/grpc -
然后,我们将获取程序传递的第一个参数,如果没有传递参数,则返回一个用法信息:
args := os.Args[1:]if len(args) == 0 {log.Fatalln("usage: server [IP_ADDR]")}addr := args[0] -
之后,我们需要监听传入的连接。我们可以使用 Go 提供的
net.Listen来实现。这个监听器在程序结束时需要被关闭。这可能是当用户终止它或服务器失败时。显然,如果在构造监听器期间发生错误,我们只想让程序失败并通知用户:lis, err := net.Listen("tcp", addr)if err != nil {log.Fatalf("failed to listen: %v\n", err)}defer func(lis net.Listener) {if err := lis.Close(); err != nil {log.Fatalf("unexpected error: %v", err)}}(lis)log.Printf("listening at %s\n", addr) -
现在,有了所有这些,我们可以开始创建一个
grpc.Server。我们首先需要定义一些连接选项。由于这是一个未来项目的模板,我们将保持选项为空。有了这个grpc.ServerOption对象数组,我们可以创建一个新的 gRPC 服务器。这是我们稍后用于注册端点的服务器。之后,我们将在某个时候关闭服务器,因此我们使用defer语句来实现。最后,我们在创建的grpc.Server上调用名为Serve的函数。它接受监听器作为参数。它可能会失败,所以如果有错误,我们将将其返回给客户端:opts := []grpc.ServerOption{}s := grpc.NewServer(opts...)//registration of endpointsdefer s.Stop()if err := s.Serve(lis); err != nil {log.Fatalf("failed to serve: %v\n", err)}
最后,我们有以下main函数(server/main.go):
package main
import (
"log"
"net"
"os"
"google.golang.org/grpc"
)
func main() {
args := os.Args[1:]
if len(args) == 0 {
log.Fatalln("usage: server [IP_ADDR]")
}
addr := args[0]
lis, err := net.Listen("tcp", addr)
if err != nil {
log.Fatalf("failed to listen: %v\n", err)
}
defer func(lis net.Listener) {
if err := lis.Close(); err != nil {
log.Fatalf("unexpected error: %v", err)
}
}(lis)
log.Printf("listening at %s\n", addr)
opts := []grpc.ServerOption{}
s := grpc.NewServer(opts...)
//registration of endpoints
defer s.Stop()
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v\n", err)
}
}
现在,我们可以通过在server/main.go上运行go run命令来运行我们的服务器。我们可以使用Ctrl + C来终止执行:
$ go run server/main.go 0.0.0.0:50051
listening at 0.0.0.0:50051
Bazel
如果您想使用 Bazel,您需要几个额外的步骤。第一步是更新根目录中的BUILD.bazel。在那里,我们将使用一个 Gazelle 命令来检测我们项目所需的所有依赖项,并将它们放入一个名为deps.bzl的文件中。因此,在gazelle命令之后,我们只需添加以下内容:
gazelle(
name = "gazelle-update-repos",
args = [
"-from_file=go.work",
"-to_macro=deps.bzl%go_dependencies",
"-prune",
],
command = "update-repos",
)
现在,我们可以运行以下命令:
$ bazel run //:gazelle-update-repos
在检测完server模块的所有依赖项后,它将创建一个deps.bzl文件并将其链接到我们的WORKSPACE.bazel中。您应该在工作空间文件中包含以下行:
load("//:deps.bzl", "go_dependencies")
# gazelle:repository_macro deps.bzl%go_dependencies
go_dependencies()
最后,我们可以重新运行gazelle命令以确保它为我们的服务器创建BUILD.bazel文件。我们运行以下命令:
$ bazel run //:gazelle
然后,我们在server目录中获取我们的BUILD.bazel文件。需要注意的是,在这个文件中,我们可以看到 Bazel 将 gRPC 链接到了server_lib。我们应该有类似以下的内容:
go_library(
name = "server_lib",
srcs = ["main.go"],
deps = [
"@org_golang_google_grpc//:go_default_library",
],
#...
)
我们现在可以用与go run命令相同的方式运行我们的服务器:
注意
这个命令将拉取 Protobuf 并构建它。最近的 Protobuf 版本需要用 C++14 或更高版本构建。你可以告诉 bazel 在名为.bazelrc的文件中自动指定构建 Protobuf 的 C++版本。为了保持本章的版本独立性,我们建议你检查 GitHub 仓库中的 chapter4 目录下的.bazelrc文件。你可以将文件复制粘贴到你的项目文件夹中。
$ bazel run //server:server 0.0.0.0:50051
listening at 0.0.0.0:50051
因此,服务器部分我们已经完成。这是一个简单的模板,将使我们能够在下一章中轻松地创建新的服务器。它正在监听指定的端口并等待一些请求。现在,为了使执行此类请求变得容易,让我们为客户端创建一个模板。
客户端模板
现在我们来编写客户端模板。这将会非常类似于编写服务器模板,但不同的是,我们不是在 IP 和端口上创建监听器,而是要调用grpc.Dial函数,并将连接选项传递给它。
再次强调,我们不会硬编码要连接的地址。我们将将其作为一个参数来处理:
args := os.Args[1:]
if len(args) == 0 {
log.Fatalln("usage: client [IP_ADDR]")
}
addr := args[0]
之后,我们将创建一个DialOption实例,为了使这个模板通用,我们将使用insecure.NewCredentials()函数与服务器建立一个不安全的连接。不过,不用担心;我们稍后会讨论如何建立安全连接:
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
最后,我们只需调用grpc.Dial函数来创建一个grpc.ClientConn对象。这是我们稍后需要用来调用 API 端点的对象。最后,这是一个连接对象,所以在我们客户端的生命周期结束时,我们将关闭它:
conn, err := grpc.Dial(addr, opts...)
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer func(conn *grpc.ClientConn) {
if err := conn.Close(); err != nil {
log.Fatalf("unexpected error: %v", err)
}
}(conn)
对于客户端来说,这就差不多了。完整的代码如下(client/main.go):
package main
import (
"log"
"os"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
func main() {
args := os.Args[1:]
if len(args) == 0 {
log.Fatalln("usage: client [IP_ADDR]")
}
addr := args[0]
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
conn, err := grpc.Dial(addr, opts...)
if err != nil {
log.Fatalf("did not connect: %v", err)
}
defer func(conn *grpc.ClientConn) {
if err := conn.Close(); err != nil {
log.Fatalf("unexpected error: %v", err)
}
}(conn)
}
显然,目前它什么都没做;然而,我们可以通过先运行我们的服务器来测试它:
$ go run server/main.go 0.0.0.0:50051
然后,我们运行我们的客户端:
$ go run client/main.go 0.0.0.0:50051
服务器应该无限期地等待,而客户端应该在终端上无错误地返回。如果是这种情况,你就准备好编写一些 gRPC API 端点。
Bazel
这次,Bazel 的设置不会像服务器那样长。这主要是因为我们已经有了一个deps.bzl文件,我们可以为客户端重用它。我们只需要使用 Gazelle 生成我们的BUILD.bazel文件,然后我们就完成了:
$ bazel run //:gazelle
我们现在应该在client目录中有一个BUILD.bazel文件。需要注意的是,在这个文件中,我们可以看到 Bazel 将 gRPC 链接到了client_lib。我们应该有类似以下的内容:
go_library(
name = "client_lib",
srcs = ["main.go"],
deps = [
"@org_golang_google_grpc//:go_default_library",
"@org_golang_google_grpc//credentials/insecure",
],
#...
)
我们现在可以用与go run命令相同的方式运行我们的客户端:
$ bazel run //client:client 0.0.0.0:50051
现在我们已经有了服务器和客户端。到目前为止,它们什么也不做,但这正是预期的目的。在这本书的稍后部分,通过简单地复制它们,我们就能专注于最重要的部分,即 API。但在做任何那之前,让我们快速看一下服务器和客户端设置的一些最重要的选项。
服务器和拨号选项
我们简要提到了 ServerOption 和 DialOption,使用了 grpc.ServerOption 对象和 grpc.WithTransportCredentials 函数。然而,还有很多其他选项可以选择。为了可读性,我不会详细介绍每一个,但我想要展示一些你可能需要使用的主要选项。所有 ServerOptions 都可以在 grpc-go 仓库的 server.go 文件中找到(github.com/grpc/grpc-go/blob/master/server.go),而 DialOptions 在 dialoptions.go 文件中(github.com/grpc/grpc-go/blob/master/dialoptions.go)。
grpc.Creds
这是一个选项,在服务器和客户端双方,当我们谈论保护 API 时都会使用。目前,我们看到我们可以使用 grpc.WithTransportCredentials 与 insecure.NewCredentials 的结果一起调用,这给我们提供了一个不安全的连接。这意味着请求和响应都没有加密;任何人都可以拦截这些消息并读取它们。
grpc.Creds 允许我们提供一个 TransportCredentials 对象实例,这是一个适用于所有受支持传输安全协议的通用接口,例如 TLS 和 SSL。如果我们有一个名为 server.crt 的证书文件和一个名为 server.pem 的密钥文件,我们可以创建以下 ServerOption:
certFile := "server.crt"
keyFile := "server.pem"
creds, err := credentials.NewServerTLSFromFile(certFile, keyFile)
if err != nil {
log.Fatalf("failed loading certificates: %v\n", err)
}
opts = append(opts, grpc.Creds(creds))
同样,在客户端,我们会有一个 DialOptions 来与服务器通信:
certFile := "ca.crt"
creds, err := credentials.NewClientTLSFromFile(
certFile, "")
if err != nil {
log.Fatalf("error while loading CA trust certificate:
%v\n", err)
}
opts = append(opts, grpc.WithTransportCredentials(creds))
到目前为止,你不必太担心这一点。正如我提到的,我们稍后会使用它,并看到如何获取证书。
grpc.*Interceptor
如果你不太熟悉拦截器,这些是在处理请求(服务器端)之前或之后(客户端)调用的代码片段。通常的目标是向请求添加一些额外信息,但它也可以用来记录请求或拒绝某些请求,例如,如果它们没有正确的头信息。
我们将在稍后看到如何定义拦截器,但想象一下,我们有一段代码记录我们的请求,另一段代码检查授权头是否已设置。我们可以在服务器端像这样链式调用这些拦截器:
opts = append(opts, grpc.ChainUnaryInterceptor
(LogInterceptor(), CheckHeaderInterceptor()))
注意,这些拦截器的顺序很重要,因为它们将按照 grpc.ChainUnaryInterceptor 函数中提供的顺序被调用。
对于客户端,我们可以有相同类型的日志拦截器,以及另一个添加带有缓存令牌值的授权头部的拦截器。这将给出以下内容:
opts = append(opts, grpc.WithChainUnaryInterceptor
(LogInterceptor(), AddHeaderInterceptor()))
最后,请注意,你可以使用其他函数来添加这些拦截器。以下是一些其他函数:
-
使用
WithUnaryInterceptor来设置一个单一 RPC 拦截器 -
使用
WithStreamInterceptor来设置一个流 RPC 拦截器 -
使用
WithChainStreamInterceptor来链式连接多个流 RPC 拦截器
我们看到了两个重要的选项,这两个选项可以在服务器和客户端两侧进行配置。通过使用凭证,我们可以保护通信参与者之间的通信安全,通过使用拦截器,我们可以在发送或接收请求之前运行任意代码。显然,我们只看到了两个选项,而每一侧都有更多选项。如果你对查看所有这些选项感兴趣,我邀请你访问本节开头链接的 GitHub 仓库。
摘要
在本章中,我们为未来的服务器和客户端创建了模板。目标是编写样板代码并设置我们的构建,以便我们可以生成代码并运行我们的 Go 应用程序。
我们看到我们可以手动使用 protoc 生成 Go 代码并将其与我们的应用程序一起使用。然后我们看到我们可以通过使用 Buf 生成代码来使这个过程更加顺畅。最后,我们看到我们可以使用 Bazel 在单步中生成我们的代码并运行我们的应用程序。
最后,我们看到了我们可以使用多个 ServerOptions 和 DialOptions 来调整服务器和客户端。我们主要看了 grpc.Creds 和拦截器,但还有更多选项可以在 grpc-go 仓库中检查。
在下一章中,我们将看到如何编写 gRPC 提供的每种类型的 API。我们将从单一 API 开始,然后检查服务器和客户端流 API,最后,我们将了解如何编写双向流端点。
测验
-
手动使用 protoc 的优势是什么?
-
无需设置;你只需要安装 protoc。
-
更短的生成命令。
-
我们可以同时生成 Go 代码并运行应用程序。
-
-
使用 Buf 的优势是什么?
-
无需设置;你只需要安装 protoc。
-
更短的生成命令。
-
我们可以同时生成 Go 代码并运行应用程序。
-
-
使用 Bazel 的优势是什么?
-
无需设置;你只需要安装 protoc。
-
更短的生成命令。
-
我们可以同时生成 Go 代码并运行应用程序。
-
-
什么是拦截器?
-
一段外部代码,它拦截通信的有效负载
-
在服务器处理程序中运行的代码片段
-
在处理或发送请求之前或之后运行的代码片段
-
答案
-
A
-
B
-
C
-
C
第五章:gRPC 端点的类型
在本章中,我们将看到您可以编写的不同类型的 gRPC 端点。对于每个端点,我们将了解我们正在讨论的通信类型的背后理念,并在我们的 Protobuf 服务中定义一个 RPC 端点。最后,我们将实现该端点并编写一个客户端来消费该端点。本章的最终目标是实现一个 TODO API,它将允许我们创建、更新、删除和列出我们的 TODO 列表中的任务。
本章我们将涵盖以下主要主题:
-
您可以编写的四种 RPC 端点类型
-
何时使用每个端点
-
如何实现端点的逻辑
-
如何消费 gRPC 端点
技术要求
您可以在本书的配套仓库中找到本章的代码,该仓库位于github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter5。
使用模板
注意
如果您想与 github 仓库相同的架构一起工作,本节是必要的。继续从上一章的代码工作是完全可行的。
如果您还记得,上一章的目标是创建一个模板,我们可以用它来创建一个新的 gRPC 项目。既然我们现在要开始这样一个项目,我们需要将chapter4文件夹的内容复制到chapter5文件夹中。为此,只需运行以下命令:
$ mkdir chapter5
$ cp -R chapter4/* chapter5
在我们的情况下,我们传递–R是因为我们想要递归地复制chapter4文件夹中的所有文件。
最后,我们还可以稍微清理一下模板。我们可以在proto文件夹中删除虚拟目录,因为这个目录只是为了测试我们的代码生成。然后,如果你为chapter4使用了bazel,我们可以删除所有以bazel-前缀开始的构建文件夹。为此,我们只需简单地按照以下方式删除它们:
$ cd chapter5
$ rm -rf proto/dummy
$ rm -rf bazel-*
我们现在可以开始使用这个模板,并查看我们可以使用 gRPC 编写的不同 API 端点。
一元 API
重要注意事项
在底层协议方面,正如我们在第一章中提到的,网络基础,一元 API 使用客户端的Send Header、Send Message和Half-Close,以及服务器端的Send Message和Send Trailer。如果您需要对这些操作进行复习,我建议您快速查看本书第一章中的RPC 操作部分。这将帮助您在调用此 API 端点时了解正在发生的事情。
你可以编写的最简单且最熟悉的 API 端点是单参数端点。这些大致对应于GET、POST以及其他你可能已经在 REST API 中使用过的 HTTP 动词。你发送一个请求,然后得到一个响应。通常,这些端点将是你最常使用的,用于表示一个资源的处理。例如,如果你编写一个登录方法,你只需要发送LoginRequest并接收LoginResponse。
对于本节,我们将编写一个名为AddTask的 RPC 端点。正如其名称所暗示的,这个端点将在列表中创建一个新的任务。因此,在能够做到这一点之前,我们需要定义什么是任务。
任务是一个包含以下属性的对象:
-
id:一个标识该任务的数字 -
description:实际要完成的任务以及用户所阅读的内容 -
done:任务是否已经完成 -
due_date:当这个任务到期时
如果我们将这些翻译成 Protobuf 代码,我们可能会有以下内容(位于chapter5/proto/todo/v1目录下的todo.proto文件):
syntax = "proto3";
package todo.v1;
import "google/protobuf/timestamp.proto";
option go_package = "github.com/PacktPublishing/
gRPC-Go-for-Professionals/proto/todo/v1";
message Task {
uint64 id = 1;
string description = 2;
bool done = 3;
google.protobuf.Timestamp due_date = 4;
}
注意这里使用了Timestamp类型。这是一个在google.protobuf包下提供的知名类型。我们使用这个类型来表示任务应该在未来的某个时间点完成。我们可以重写我们自己的Date类型或使用在googleapis存储库中定义的Date类型(github.com/googleapis/googleapis/blob/master/google/type/date.proto),但Timestamp对于这个 API 来说已经足够了。
现在我们有了任务,我们可以考虑我们的 RPC 端点。我们希望我们的端点能够接收一个任务,该任务应被插入到列表中,并返回该任务的标识符给客户端:
message AddTaskRequest {
string description = 1;
google.protobuf.Timestamp due_date = 2;
}
message AddTaskResponse {
uint64 id = 1;
}
service TodoService {
rpc AddTask(AddTaskRequest) returns (AddTaskResponse);
}
在这里需要注意的一个重要事项是,我们不是使用Task消息作为AddTask端点的参数,而是在端点所需的信息周围创建了一个包装器(AddTaskRequest)。不多也不少。我们本来可以直接使用消息,但这可能会导致客户端在网络上发送不必要的额外数据(例如,设置 ID,这可能会被服务器忽略)。此外,对于我们 API 的将来版本,我们可以在AddTaskRequest中添加更多字段,而不会影响Task消息。我们有效地解耦了请求/响应的实际数据表示。
代码生成
重要提示
如果你现在还不确定如何从 proto 文件生成代码,我强烈建议你查看第四章,在那里我们介绍了三种生成代码的方法。在本章中,我们将手动生成一切,但你可以在 GitHub 存储库的chapter5目录中找到如何使用 Buf 和 Bazel 完成相同操作的方法。
现在我们已经为我们的 API 端点创建了接口,我们希望能够实现其背后的逻辑。为此,第一步是生成一些 Go 代码。为了做到这一点,我们将使用protoc和paths选项的source_relative值。所以,知道我们的todo.proto文件位于proto/todo/v1/下,我们可以运行以下命令:
$ protoc --go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
proto/todo/v1/*.proto
运行之后,你应该有一个proto/todo/v1/目录,如下所示:
proto/todo/v1/
├── todo.pb.go
├── todo.proto
└── todo_grpc.pb.go
这就是我们开始所需的所有内容。
检查生成的代码
在生成的代码中,我们有两个文件——Protobuf 生成的代码和 gRPC 代码。Protobuf 生成的代码在一个名为todo.pb.go的文件中。如果我们检查这个文件,我们可以看到的最重要的事情是以下代码(这是简化的):
type Task struct {
Id uint64
Description string
Done bool
DueDate *timestamppb.Timestamp
}
type AddTaskRequest struct {
Description string
DueDate *timestamppb.Timestamp
}
type AddTaskResponse struct {
Id uint64
}
这意味着我们现在可以在 Go 代码中创建Task、TaskRequest和TaskResponse实例,这正是我们在本章后面将要做的。
对于 gRPC 生成的代码(todo_grpc.pb.go),为客户端和服务器都生成了接口。它们应该看起来像以下这样:
type TodoServiceClient interface {
AddTask(ctx context.Context, in *AddTaskRequest, opts
...grpc.CallOption) (*AddTaskResponse, error)
}
type TodoServiceServer interface {
AddTask(context.Context, *AddTaskRequest)
(*AddTaskResponse, error)
mustEmbedUnimplementedTodoServiceServer()
}
它们看起来很相似,但服务器端的AddTask是我们唯一需要实现逻辑的。客户端的AddTask基本上是生成调用我们服务器上的 API 端点的请求,并返回它接收到的响应。
注册服务
为了让 gRPC 知道如何处理某个请求,我们需要注册服务的实现。为了注册这样的服务,我们将调用一个生成的函数,该函数存在于todo_grpc.pb.go中。在我们的例子中,这个函数叫做RegisterTodoServiceServer,其函数签名如下:
func RegisterTodoServiceServer(s grpc.ServiceRegistrar, srv
TodoServiceServer)
它接受grpc.ServiceRegistrar接口,这是grpc.Server实现的接口,以及TodoServiceServer接口,这是我们之前看到的接口。这个函数将把框架提供的通用 gRPC 服务器与我们的端点实现链接起来,以便框架知道如何处理请求。
因此,首先要做的是创建我们的服务器。我们首先将创建一个嵌入UnimplementedTodoServiceServer的结构体,这是一个生成的结构体,其中包含端点的默认实现。在我们的例子中,默认实现如下:
func (UnimplementedTodoServiceServer) AddTask
(context.Context, *AddTaskRequest) (*AddTaskResponse,
error) {
return nil, status.Errorf(codes.Unimplemented, "method
AddTask not implemented")
}
如果我们没有在我们的服务器中实现AddTask,这个端点将被调用,并且每次调用它时都会返回一个错误。目前这看起来似乎没有用,因为这除了返回错误之外什么也不做,但事实是这是一个安全网,我们将在讨论 API 演变时看到原因。
接下来,我们的服务器将包含对我们数据库的引用。你可以根据你熟悉的任何数据库进行适配,但在这个例子中,我们将使用接口来抽象数据库,因为这将让我们专注于 gRPC,而不是其他技术,例如 MongoDB。
因此,我们的服务器类型(server/server.go)将看起来像这样:
package main
import (
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/ todo/v1"
)
type server struct {
d db
pb.UnimplementedTodoServiceServer
}
现在,让我们看看 db 接口的样子。我们首先将有一个名为 addTask 的函数,它接受一个描述和一个 dueDate 值,并返回创建的任务的 id 值或错误。现在,需要注意的是,这个数据库接口应该与生成代码解耦。这再次是因为我们 API 的演变,因为如果我们改变端点或 Request/Response 对象,我们就必须更改我们的接口和所有实现。在这里,接口与生成代码独立。在 server/db.go 中,我们现在可以编写以下内容:
package main
import "time"
type db interface {
addTask(description string, dueDate time.Time) (uint64,
error)
}
这个接口将允许我们对伪造的数据库实现进行测试,并为非发布环境实现内存数据库。
最后一步是实现内存数据库。我们将有一个常规数组 Task 来存储我们的待办事项,addTask 将简单地向该数组追加一个任务并返回当前任务的 ID。在名为 server/in_memory.go 的文件中,我们可以添加以下内容:
package main
import (
"time"
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/
proto/todo/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
type inMemoryDb struct {
tasks []*pb.Task
}
func New() db {
return &inMemoryDb{}
}
func (d *inMemoryDb) addTask(description string, dueDate
time.Time) (uint64, error) {
nextId := uint64(len(d.tasks) + 1)
task := &pb.Task{
Id: nextId,
Description: description,
DueDate: timestamppb.New(dueDate),
}
d.tasks = append(d.tasks, task)
return nextId, nil
}
关于这个实现,有几个需要注意的事项。首先,这可能是显而易见的,但这不是一个最优的“数据库”,并且仅用于开发目的。其次,不深入细节,我们可以使用 Golang 构建标签在编译时选择我们想要运行的数据库。例如,如果我们有 inMemoryDb 和 mongoDb 实现,我们可以在每个文件的顶部添加 go:build 标签。对于 in_memory.go,我们可以有如下内容:
//go:build in_memory_db
//...
type inMemoryDb struct
func New() db
对于 mongodb.go,我们可以有如下内容:
//go:build mongodb
//...
type mongoDb struct
func New() db
这将允许我们在编译时选择我们想要使用的 New 函数,从而创建 inMemoryDb 或 mongoDb 实例。
最后,你可能已经注意到,我们在实现这个“数据库”时使用了生成的代码。由于这是一个作为开发环境使用的“数据库”,这个实现是否与我们的生成代码耦合并不重要。最重要的是,不要将 db 接口与其耦合,这样你就可以使用任何数据库,甚至无需处理生成代码。
现在,我们终于准备好注册我们的服务器类型了。为此,我们只需进入我们的 server/main.go main 函数并添加以下行:
import pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/
proto/todo/v1"
//...
s := grpc.NewServer(opts...)
pb.RegisterTodoServiceServer(s, &server{
d: New(),
})
defer s.Stop()
这意味着我们现在已经将名为 s 的 gRPC 服务器链接到了创建的服务器实例。请注意,这里的 New 函数是我们定义在 in_memory.go 文件中的函数。
实现 AddTask
对于实现,我们将创建一个名为 server/impl.go 的文件,该文件将包含所有端点的实现。请注意,这纯粹是为了方便,你也可以为每个 RPC 端点创建一个文件。
现在你可能还记得,我们服务器的生成接口要求我们实现以下函数:
AddTask(context.Context, *AddTaskRequest)
(*AddTaskResponse, error)
因此,我们只需将这个函数添加到我们的服务器类型中,通过编写以服务器实例名称和服务器类型命名的函数前缀即可:
func (s *server) AddTask(_ context.Context, in
*pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
}
最后,我们可以实现这个函数。这将调用我们的 db 接口中的 addTask 函数,由于目前这个函数从不返回错误(现在是这样),我们将获取给定的 ID 并将其作为 AddTaskResponse 返回:
package main
import (
"context"
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/proto/ todo/v1"
)
func (s *server) AddTask(_ context.Context, in
*pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
id, _ := s.d.addTask(in.Description, in.DueDate.AsTime())
return &pb.AddTaskResponse{Id: id}, nil
}
注意,AsTime 是由 google.golang.org/protobuf/types/known/timestamppb 包提供的一个函数,它返回一个 Golang time.Time 对象。timestamppb 包是一组函数,允许我们操作 google.protobuf.Timestamp 对象,并在我们的 Go 代码中以惯用的方式使用它。
目前,你可能觉得这太简单了,但请记住,我们刚刚开始我们的 API。在本书的后面部分,我们将进行错误处理,并了解如何拒绝不正确的参数。
从客户端调用 AddTask
最后,让我们看看如何从 Go 客户端代码中调用端点。这很简单,因为我们已经在上一章中创建了样板代码。
我们将创建一个名为 AddTask 的函数,该函数将调用我们在服务器上注册的 API 端点。为此,我们需要传递一个 TodoServiceClient 实例、任务的描述和截止日期。我们稍后会创建客户端实例,但请注意 TodoServiceClient 是我们在检查生成的代码时看到的接口。在 client/main.go 中,我们可以添加以下内容:
import (
//...
google.golang.org/protobuf/types/known/timestamppb
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/
proto/todo/v1"
//...
)
func addTask(c pb.TodoServiceClient, description string,
dueDate time.Time) uint64 {
}
之后,使用参数,我们只需构建一个新的 AddTaskRequest 实例并将其发送到服务器。
func addTask(c pb.TodoServiceClient, description string,
dueDate time.Time) uint64 {
req := &pb.AddTaskRequest{
Description: description,
DueDate: timestamppb.New(dueDate),
}
res, err := c.AddTask(context.Background(), req)
//...
}
最后,我们将从我们的 API 调用中接收到 AddTaskResponse 或错误。如果有错误,我们将在屏幕上记录它,如果没有错误,我们记录并返回 ID:
func addTask(c pb.TodoServiceClient, description string,
dueDate time.Time) uint64 {
//...
if err != nil {
panic(err)
}
fmt.Printf("added task: %d\n", res.Id)
return res.Id
}
要调用此函数,我们需要使用一个名为 NewTodoServiceClient 的生成函数,我们将一个连接传递给它,它返回一个新的 TodoServiceClient 实例。然后,我们只需简单地将以下几行代码添加到客户端的 main 函数末尾:
conn, err := grpc.Dial(addr, opts...)
if err != nil {
log.Fatalf("did not connect: %v", err)
}
c := pb.NewTodoServiceClient(conn)
fmt.Println("--------ADD--------")
dueDate := time.Now().Add(5 * time.Second)
addTask(c, "This is a task", dueDate)
fmt.Println("-------------------")
defer func(conn *grpc.ClientConn) {
/*...*/}(conn)
注意,这里我们正在添加一个五秒截止日期和描述为 This is a task 的任务。这只是一个示例,我鼓励你自己尝试使用不同的值进行更多调用。
现在,我们基本上可以运行我们的服务器和客户端,看看它们是如何交互的。要运行服务器,使用这个 go run 命令:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,在另一个终端中,我们可以以类似的方式运行客户端:
$ go run ./client 0.0.0.0:50051
--------ADD--------
added task: 1
-------------------
最后,要停止服务器,你只需在运行它的终端中按 Ctrl + C 即可。
因此,我们可以看到我们已经在服务器上注册了一个服务实现,我们的客户端正在正确地发送请求并返回响应。我们所做的是一个简单的 Unary API 端点示例。
Bazel
重要注意事项
在本节中,我们将看到如何使用 Bazel 运行应用程序。然而,由于如果每个部分都有这样的说明就会变得重复,我想提醒您,我们不会每次都走这些步骤。对于每个部分,您都可以运行下面的bazel run命令(对于服务器和客户端),而gazelle命令仅适用于本节。
到目前为止,您的 Bazel BUILD 文件可能已经过时。为了同步它们,我们可以简单地运行gazelle命令,它将更新所有依赖项和文件以进行编译:
$ bazel run //:gazelle
之后,我们可以通过运行以下命令轻松运行服务器和客户端来执行服务器:
$ bazel run //server:server 0.0.0.0:50051
listening at 0.0.0.0:50051
同时,使用以下命令运行客户端:
$ bazel run //client:client 0.0.0.0:50051
--------ADD--------
added task: 1
-------------------
这是我们的服务器和客户端工作正常的一个另一个例子。我们可以使用go run和bazel run命令来运行它们。我们现在对 Unary API 有信心;让我们转向服务器流式 API。
服务器流式 API
重要提示
在底层协议方面,服务器流式 API 使用客户端的Send Header、Send Message和Half-Close,以及服务器端的多个Send Message和Send Trailer。
现在,我们知道如何注册服务、与“数据库”交互以及运行客户端和服务器后,一切都会更快。我们将主要关注 API 端点本身。在我们的例子中,我们将创建一个ListTasks端点,正如其名称所暗示的,它将列出数据库中所有可用的任务。
我们将要做的其中一件事是,对于每个列出的任务,我们将返回该任务是否已过期。这样做主要是为了让您了解如何在response对象中提供有关某个对象的更多信息。
因此,在todo.proto文件中,我们将添加一个名为ListTasks的 RPC 端点,它将接受ListTasksRequest并返回一个ListTasksResponse流。这就是服务器流式 API。我们接收一个请求并返回零个或多个响应:
message ListTasksRequest {
}
message ListTasksResponse {
Task task = 1;
bool overdue = 2;
}
service TodoService {
//...
rpc ListTasks(ListTasksRequest) returns (stream ListTasksResponse);
}
注意,这次我们发送一个空对象作为请求,并获取多个Task及其是否过期。我们可以通过发送我们想要列出的任务 ID 范围(分页)来使请求更加智能,但为了简洁起见,我们选择使其简单。
数据库的演变
在能够实现ListTasks端点之前,我们需要一种方法来访问我们的 TODO 列表中的所有元素。再次强调,我们不希望将db接口绑定到我们的生成代码,因此我们有几种选择:
-
我们创建了一些抽象来遍历任务。这对于我们的内存数据库可能很好,但例如与 Postgres 一起使用时会如何呢?
-
我们将我们的接口绑定到一个已经存在的抽象,例如数据库的游标。这稍微好一些,但我们仍然耦合了我们的接口。
-
我们只是将迭代留给我们的
db接口实现,并将一个用户提供的函数应用于所有行。这样,我们就不会耦合到任何其他组件。
因此,我们将迭代留给我们的接口实现。这意味着我们将要添加到inMemoryDb的新函数将遍历所有任务并将一个作为参数提供的函数应用于每一个:
type db interface {
//...
getTasks(f func(interface{}) error) error
}
如你所见,作为参数传递的函数本身将获得一个interface{}作为参数。这并不是类型安全的;然而,我们将确保在稍后处理时运行时接收一个Task。
然后,对于内存实现,我们有以下内容:
func (d *inMemoryDb) getTasks(f func(interface{}) error)
error {
for _, task := range d.tasks {
if err := f(task); err != nil {
return err
}
}
return nil
}
这里有一个需要注意的地方。我们将使错误对进程致命。如果用户提供的函数返回一个错误,getTasks函数将返回一个错误。
那就是数据库的全部内容;我们现在可以从它获取所有任务并对任务应用某种逻辑。让我们实现ListTasks。
实现 ListTasks
为了实现端点,让我们从 proto 文件生成 Go 代码,并取我们需要实现的函数签名。我们只需运行以下命令:
$ protoc --go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
proto/todo/v1/*.proto
如果我们查看proto/todo/v1/todo_grpc.pb.go,我们现在可以看到TodoServiceServer接口有一个额外的函数:
type TodoServiceServer interface {
//...
ListTasks(*ListTasksRequest, TodoService_ListTasksServer)
error
//...
}
如我们所见,签名与从AddTask函数得到的签名略有不同。我们现在只返回一个错误或nil,并且我们将TodoService_ListTasksServer作为一个参数。
如果你深入研究生成的代码,你会看到TodoService_ListTasksServer被定义为以下内容:
type TodoService_ListTasksServer interface {
Send(*ListTasksResponse) error
grpc.ServerStream
}
这是一个可以发送ListTasksResponse对象的流。
现在我们知道了这一点,让我们在我们的代码中实现这个函数。我们可以进入服务器下的impl.go文件,并将ListTasks函数的签名复制粘贴到TodoServiceServer中:
func (s *server) ListTasks(req *pb.ListTasksRequest, stream
pb.TodoService_ListTasksServer) error
显然,我们添加了服务器类型来指定我们正在为我们的服务器实现ListTasks,并且我们命名了参数。req是我们从客户端接收的请求,stream是我们将用来发送多个答案的对象。
然后,我们函数的逻辑再次简单明了。我们将遍历所有任务,确保我们处理的是Task对象,并且对于这些任务中的每一个,我们将“计算”逾期情况,通过检查这些任务是否完成(对于完成的任务没有逾期)以及due_date字段是否在当前时间之前。总之,我们将只创建包含这些信息的ListTasksResponse并发送给客户端(server/impl.go):
func (s *server) ListTasks(req *pb.ListTasksRequest, stream
pb.TodoService_ListTasksServer) error {
return s.d.getTasks(func(t interface{}) error {
task := t.(*pb.Task)
overdue := task.DueDate != nil && !task.Done &&
task.DueDate.AsTime().Before(time.Now().UTC())
err := stream.Send(&pb.ListTasksResponse{
Task: task,
Overdue: overdue,
})
return err
})
}
有一个需要注意的地方是,AsTime函数将创建一个 UTC 时区的时间,所以当你与它比较时间时,也需要它也在 UTC 时区。这就是为什么我们有time.Now().UTC()而不是简单地time.Now()。
显然,这个函数可能会失败(例如,如果名为t的变量不是一个Task怎么办?)但现阶段,我们不必过于担心错误处理。我们稍后会看到。现在,让我们从客户端调用这个端点。
从客户端调用 ListTasks
要从客户端调用ListTasks API 端点,我们需要了解如何消费服务器流式 RPC 端点。要做到这一点,我们检查方法签名或接口名称中的生成函数TodoServiceClient。它应该看起来像以下这样:
ListTasks(ctx context.Context, in *ListTasksRequest, opts
...grpc.CallOption) (TodoService_ListTasksClient, error)
我们可以看到我们需要传递一个上下文和一个请求,还有一些可选的调用选项。然后,我们还可以看到我们将得到一个TodoService_ListTasksClient或一个错误。TodoService_ListTasksClient类型与我们之前在ListTasks端点中处理的流非常相似。不过,主要的区别是,我们不再有一个名为Send的函数,我们现在有一个名为Recv的函数。以下是TodoService_ListTasksClient的定义:
type TodoService_ListTasksClient interface {
Recv() (*ListTasksResponse, error)
grpc.ClientStream
}
因此,我们将如何处理这个流呢?我们将遍历从Recv获取的所有响应,然后某个时刻,服务器会说:“我完成了。”这发生在我们得到一个等于io.EOF的错误时。
我们可以在client/main.go中创建一个名为printTasks的函数,该函数将反复调用Recv并检查我们是否完成或有错误,如果没有这种情况,它将在终端上打印我们的Task对象的字符串表示:
func printTasks(c pb.TodoServiceClient) {
req := &pb.ListTasksRequest{}
stream, err := c.ListTasks(context.Background(), req)
if err != nil {
log.Fatalf("unexpected error: %v", err)
}
for {
res, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
log.Fatalf("unexpected error: %v", err)
}
fmt.Println(res.Task.String(), "overdue: ",
res.Overdue)
}
}
一旦我们有了这个,我们就可以在main函数中addTask调用之后调用该函数:
fmt.Println("--------ADD--------")
//...
fmt.Println("--------LIST-------")
printTasks(c)
fmt.Println("-------------------")
我们现在可以使用go run先运行我们的服务器,然后是我们的客户端。所以,在项目的根目录下,我们可以运行以下命令:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,我们运行我们的客户端:
$ go run ./client 0.0.0.0:50051
//...
--------LIST-------
id:1 description:"This is a task" due_date:
{seconds:1680158076 nanos:574914000} overdue: false
-------------------
这正如预期的那样工作。现在,我鼓励你尝试自己添加更多任务,尝试不同的值,并在添加所有任务后使用printTasks函数。这应该有助于你熟悉 API。
现在我们能够添加任务并列出所有任务,如果我们可以更新已经存在的任务那就太好了。这可能对标记任务为完成和更改截止日期很有用。我们将通过客户端流式 API 来测试这一点。
客户端流式 API
重要提示
在底层协议方面,客户端流式 API 使用客户端的Send Header,然后是多个Send Message和一个Half-Close,以及服务器端的Send Message和Send Trailer。
对于客户端流式 API 端点,我们可以发送零个或多个请求并得到一个响应。这是一个重要的概念,尤其是在实时上传数据时。一个例子可能是我们在前端点击一个编辑按钮,这会触发一个编辑会话,并且我们实时地发布每个编辑。显然,由于我们不是在处理这样复杂的前端,我们只会关注使 API 与这种功能兼容。
要定义一个客户端流式 API,我们只需在参数子句中写入 stream 关键字而不是 return。之前,对于我们的服务器流式,我们有以下内容:
rpc ListTasks(ListTasksRequest) returns (stream ListTasksResponse);
现在,我们将有以下的 UpdateTasks:
message UpdateTasksRequest {
Task task = 1;
}
message UpdateTasksResponse {
}
service TodoService {
//...
rpc UpdateTasks(stream UpdateTasksRequest) returns
(UpdateTasksResponse);
}
注意,在这种情况下,我们使用的是完整的 Task 消息作为请求,而不是像 AddTask 中的那样分开的字段。这并不是一个错误,我们将在第六章中进一步讨论这个问题。
这实际上意味着客户端发送多个请求,服务器返回一个响应。我们现在又向实现端点迈进了一步。然而,在这样做之前,让我们先谈谈数据库。
数据库的演变
在考虑实现 UpdateTasks 之前,我们需要看看我们如何与数据库交互。首先考虑的是,对于给定的任务,可以更新哪些信息。在我们的案例中,我们不希望客户端能够更新 ID;这是由数据库处理的一个细节。然而,对于所有其他信息,我们希望让用户能够更新它。当任务完成时,我们需要能够将 done 设置为 true。当 Task 描述需要更新时,客户端应该能够在数据库中更改它。最后,当截止日期更改时,客户端也应该能够更新它。
了解这些后,我们可以在数据库中定义 updateTask 的函数签名。它将接受任务 ID 和所有可以更改的信息作为参数,并返回一个错误或 nil:
type db interface {
//...
updateTask(id uint64, description string, dueDate
time.Time, done bool) error
}
再次强调,传递这么多参数看起来有点多,但我们不希望将此接口与任何生成的代码耦合。如果我们以后需要添加更多信息或删除一些,这就像更新此接口和更新实现一样简单。
现在,为了实现这一点,我们将进入 in_memory.go 文件。该函数将简单地遍历数据库中的所有任务,如果任何 Task 的 ID 与参数中传入的 ID 相同,我们将逐个更新所有字段:
func (d *inMemoryDb) updateTask(id uint64, description
string, dueDate time.Time, done bool) error {
for i, task := range d.tasks {
if task.Id == id {
t := d.tasks[i]
t.Description = description
t.DueDate = timestamppb.New(dueDate)
t.Done = done
return nil
}
}
return fmt.Errorf("task with id %d not found", id)
}
这意味着每次我们收到请求时,我们都会遍历所有任务。这并不高效,尤其是在数据库变得更大时。然而,我们也不是在处理真实的数据库,所以对于这本书中的用例来说应该足够好了。
实现 UpdateTasks
要实现端点,让我们从 proto 文件生成 Go 代码,并获取我们需要实现的函数签名。我们只需运行以下命令:
$ protoc --go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
proto/todo/v1/*.proto
如果我们查看 proto/todo/v1/todo_grpc.pb.go,我们现在可以看到 TodoServiceServer 接口有一个额外的函数:
type TodoServiceServer interface {
//...
UpdateTasks(TodoService_UpdateTasksServer) error
//...
}
如我们所见,签名变更类似于 ListTasks;然而,这次我们甚至不处理请求。我们只是处理 TodoService_UpdateTasksServer 类型的流。如果我们检查 TodoService_UpdateTasksServer 类型的定义,我们会看到以下内容:
type TodoService_UpdateTasksServer interface {
SendAndClose(*UpdateTasksResponse) error
Recv() (*UpdateTasksRequest, error)
grpc.ServerStream
}
我们已经熟悉了Recv函数。它让我们获取一个对象,但现在我们还有一个SendAndClose函数。这个函数让我们告诉客户端我们在服务器端已完成。这用于在客户端发送io.EOF时关闭流。
带着这些知识,我们可以实现我们的端点。我们将反复在流上调用Recv函数,如果我们收到io.EOF,我们将使用SendAndClose函数;否则,我们将调用我们数据库上的updateTask函数:
func (s *server) UpdateTasks(stream pb.TodoService
_UpdateTasksServer) error {
for {
req, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&pb.UpdateTasksResponse{})
}
if err != nil {
return err
}
s.d.updateTask(
req.Task.Id,
req.Task.Description,
req.Task.DueDate.AsTime(),
req.Task.Done,
)
}
}
现在我们应该能够触发这个 API 端点以实时更改一组Task。现在让我们看看客户端如何调用端点。
从客户端调用 UpdateTasks
这次,因为我们正在处理客户端流,我们将与服务器流相反。客户端将反复调用Send,服务器将反复调用Recv。最后,客户端将调用定义在生成的代码中的CloseAndRecv函数。
如果我们查看客户端的UpdateTasks生成的代码,我们将在TodoServiceClient类型中看到以下签名:
UpdateTasks(ctx context.Context, opts ...grpc.CallOption)
(TodoService_UpdateTasksClient, error)
注意现在UpdateTasks函数不再接受任何request参数,但它将返回一个TodoService_UpdateTasksClient类型的流。正如提到的,这个类型将包含两个函数:Send和CloseAndRecv。如果我们查看生成的代码,我们将看到以下内容:
type TodoService_UpdateTasksClient interface {
Send(*UpdateTasksRequest) error
CloseAndRecv() (*UpdateTasksResponse, error)
grpc.ClientStream
}
Send发送UpdateTasksRequest,而CloseAndRecv将通知服务器它已完成发送请求,并请求UpdateTasksResponse。
现在我们已经理解了,我们可以在client/main.go文件中实现UpdateTasks函数,我们将从 gRPC 客户端调用UpdateTasks函数。这将返回一个流,然后我们将通过它发送给定的任务。一旦我们遍历了所有需要更新的任务,我们将调用CloseAndRecv函数:
func updateTasks(c pb.TodoServiceClient, reqs
...*pb.UpdateTasksRequest) {
stream, err := c.UpdateTasks(context.Background())
if err != nil {
log.Fatalf("unexpected error: %v", err)
}
for _, req := range reqs {
err := stream.Send(req)
if err != nil {
return
}
if err != nil {
log.Fatalf("unexpected error: %v", err)
}
if req.Task != nil {
fmt.Printf("updated task with id: %d\n", req.Task.Id)
}
}
if _, err = stream.CloseAndRecv(); err != nil {
log.Fatalf("unexpected error: %v", err)
}
}
现在,正如你在updateTasks中看到的,我们需要传递零个或多个UpdateTasksRequest作为参数。为了获取创建任务实例并填充UpdateTasksRequest.task字段所需的 ID,我们将使用addTasks记录之前创建的任务的 ID。因此,以前在main中我们有以下内容:
addTask(c, "This is a task", dueDate)
我们现在将得到以下类似的内容:
id1 := addTask(c, "This is a task", dueDate)
id2 := addTask(c, "This is another task", dueDate)
id3 := addTask(c, "And yet another task", dueDate)
现在,我们可以创建一个UpdateTasksRequest数组,就像这样:
[]*pb.UpdateTasksRequest{
{Task: &pb.Task{Id: id1, Description: "A better name for
the task"}},
{Task: &pb.Task{Id: id2, DueDate: timestamppb.New
(dueDate.Add(5 * time.Hour))}},
{Task: &pb.Task{Id: id3, Done: true}},
}
这意味着具有id1的Task对象将被更新为具有新的描述,具有id2的Task对象将被更新为具有新的due_date值,最后,最后一个将被标记为done。
我们现在可以将客户端传递给updateTasks,并通过使用…操作符将这个数组作为可变参数展开。在main函数中,我们现在可以添加以下内容:
fmt.Println("-------UPDATE------")
updateTasks(c, []*pb.UpdateTasksRequest{
{Task: &pb.Task{Id: id1, Description: "A better name for
the task"}},
{Task: &pb.Task{Id: id2, DueDate: timestamppb.New
(dueDate.Add(5 * time.Hour))}},
{Task: &pb.Task{Id: id3, Done: true}},
}...)
printTasks(c)
fmt.Println("-------------------")
我们现在可以以类似前几节的方式运行它。我们使用go run首先运行服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后我们运行客户端来调用 API 端点:
$ go run ./client 0.0.0.0:50051
//...
-------UPDATE------
updated task with id: 1
updated task with id: 2
updated task with id: 3
id:1 description:"A better name for the task" due_date:{}
id:2 due_date:{seconds:1680267768 nanos:127075000}
id:3 done:true due_date:{}
-------------------
在继续之前,这里有一个重要的事情需要注意。你可能会对任务在更新时丢失一些信息的事实感到惊讶。这是因为 Protobuf 会在字段未设置时使用默认值——这意味着如果客户端发送一个只有done等于true的Task对象,描述将被反序列化为空字符串,而due_date将是一个空的google.protobuf.Timestamp。目前,这非常低效,因为我们需要重新发送所有信息来更新单个字段。在本书的后面,我们将讨论如何解决这个问题。现在,我们可以根据它们的 ID 实时更新多个任务。现在,让我们转向最后一种可用的 API 类型:双向流。
双向流式 API
重要注意事项
就底层协议而言,双向流式 API 使用客户端的Send Header,然后是服务器和/或客户端的多个Send Message,客户端的Half-Close,最后是服务器的Send Trailer。
在双向流式 API 中,目标是让客户端发送零个或多个请求,并让服务器发送零个或多个响应。我们将使用这一点来模拟一个类似于updateTasks的功能,但在其中,我们将在每次删除后直接从DeleteTasks端点 API 获得反馈,而不是等待所有删除操作完成。
在继续实施之前,需要明确的一点是关于为什么不将DeleteTasks设计为服务器流式 API 或updateTasks设计为双向流式 API 的问题。这两个任务之间的区别在于它们的“破坏性”程度。我们可以在发送请求到服务器之前直接在客户端进行更新。如果存在任何错误,我们只需查看客户端上的列表,并根据修改时间将其与服务器上的列表同步。对于删除操作,这会稍微复杂一些。我们可以在客户端保留已删除的行,稍后由垃圾回收器回收,或者我们需要在其他地方存储信息以与服务器同步。这会带来更多的开销。
因此,我们将发送多个DeleteTasksRequest,并且对于每一个,我们将得到确认它已被删除。如果发生错误,我们仍然可以确信错误之前的任务已在服务器上删除。我们的 RPC 和消息如下所示:
message DeleteTasksRequest {
uint64 id = 1;
}
message DeleteTasksResponse {
}
service TodoService {
//...
rpc DeleteTasks(stream DeleteTasksRequest) returns
(stream DeleteTasksResponse);
}
我们反复发送我们想要删除的Task的 ID,如果我们收到DeleteTasksResponse,这意味着任务已被删除。否则,我们会得到一个错误。
现在,在深入实施之前,让我们看看我们的数据库接口。
数据库的演变
因为我们想要在数据库中删除一个 Task 对象,我们将需要一个 deleteTask 函数。这个函数将接受要删除的 Task 对象的 ID,对其执行操作,并返回一个错误或 nil。我们可以在 server/db.go 中添加以下函数:
type db interface {
//...
deleteTask(id uint64) error
}
实现起来很像 updateTask。然而,当我们找到具有正确 ID 的任务时,我们不会更新信息,而是使用 go slice 技巧来删除它。在 server/in_memory.go 中,我们现在有以下内容:
func (d *inMemoryDb) deleteTask(id uint64) error {
for i, task := range d.tasks {
if task.Id == id {
d.tasks = append(d.tasks[:i], d.tasks[i+1:]...)
return nil
}
}
return fmt.Errorf("task with id %d not found", id)
}
切片技巧将当前任务之后的元素追加到前一个任务中。这实际上覆盖了数组中的当前任务,从而删除了它。有了这个,我们现在可以准备实现 DeleteTasks 端点。
实现删除任务
在实现实际端点之前,我们需要了解我们将如何处理生成的代码。因此,让我们使用以下命令生成代码:
$ protoc --go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
proto/todo/v1/*.proto
如果我们检查 proto/todo/v1 文件夹中的 todo_grpc.pb.go,我们应该在 TodoServiceServer 中添加以下函数:
DeleteTasks(TodoService_DeleteTasksServer) error
这与 UpdateTasks 函数类似,因为我们得到一个流,我们返回一个错误或 nil。然而,我们没有 Send 和 SendAndClose 函数,我们现在有 Send 和 Recv。TodoService_DeleteTasksServer 定义如下:
type TodoService_DeleteTasksServer interface {
Send(*DeleteTasksResponse) error
Recv() (*DeleteTasksRequest, error)
grpc.ServerStream
}
这意味着在我们的情况下,我们可以调用 Recv 来获取 DeleteTasksRequest,然后为每个请求发送一个 DeleteTasksResponse。最后,因为我们正在处理流,我们仍然需要检查错误和 io.EOF。当我们得到 io.EOF 时,我们只需使用 return 结束函数:
func (s *server) DeleteTasks(stream
pb.TodoService_DeleteTasksServer) error {
for {
req, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
s.d.deleteTask(req.Id)
stream.Send(&pb.DeleteTasksResponse{})
}
}
这里需要注意的一点是 stream.Send 调用。尽管这很简单,但这区分了客户端流和双向流。如果我们没有这个调用,我们实际上会从客户端发送多个请求,最终服务器会返回 nil 来关闭流。这将与 UpdateTasks 完全相同。但因为 Send 调用,我们现在在每次删除后都有直接的反馈。
从客户端调用 UpdateTasks
现在我们有了我们的端点,我们可以从客户端调用它。在我们这样做之前,我们需要查看 TodoServiceClient 生成的代码。我们现在应该有以下函数:
DeleteTasks(ctx context.Context, opts ...grpc.CallOption)
(TodoService_DeleteTasksClient, error)
再次强调,这与我们在 ListTasks 和 UpdateTasks 函数中看到的内容类似,因为它返回一个我们可以与之交互的字符串。然而,正如你所猜到的,我们现在可以使用 Send 和 Recv。TodoService_DeleteTasksClient 看起来是这样的:
type TodoService_DeleteTasksClient interface {
Send(*DeleteTasksRequest) error
Recv() (*DeleteTasksResponse, error)
grpc.ClientStream
}
使用这个生成的代码和底层的 gRPC 框架,我们现在可以发送多个 DeleteTasksRequest 并获取多个 DeleteTasksResponse。
现在,我们将在 client/main.go 中创建一个新的函数,该函数将接受 DeleteTasksRequest 的可变参数。然后,我们将创建一个通道,它将帮助我们等待接收和发送的整个过程的完成。如果我们没有这样做,我们会在完成之前从函数中返回。这个通道将在一个 goroutine 中使用,该 goroutine 将在后台使用 Recv。一旦在这个 goroutine 中接收到 io.EOF,我们将关闭这个通道。最后,我们将遍历所有请求并发送它们,一旦完成,我们将等待通道关闭。
这可能现在看起来有点抽象,但想想客户需要完成的任务。它需要同时使用 Recv 和 Send;因此,我们需要一些简单的并发代码:
func deleteTasks(c pb.TodoServiceClient, reqs
...*pb.DeleteTasksRequest) {
stream, err := c.DeleteTasks(context.Background())
if err != nil {
log.Fatalf("unexpected error: %v", err)
}
waitc := make(chan struct{})
go func() {
for {
_, err := stream.Recv()
if err == io.EOF {
close(waitc)
break
}
if err != nil {
log.Fatalf("error while receiving: %v\n", err)
}
log.Println("deleted tasks")
}
}()
for _, req := range reqs {
if err := stream.Send(req); err != nil {
return
}
}
if err := stream.CloseSend(); err != nil {
return
}
<-waitc
}
最后,在运行服务器和客户端之前,让我们在 main 函数中调用该函数。我们将删除使用 addTasks 创建的所有任务,并通过尝试打印所有任务来证明没有更多任务:
fmt.Println("-------DELETE------")
deleteTasks(c, []*pb.DeleteTasksRequest{
{Id: id1},
{Id: id2},
{Id: id3},
}...)
printTasks(c)
fmt.Println("-------------------")
通过那样,我们可以先运行服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后我们可以运行我们的客户端:
$ go run ./client 0.0.0.0:50051
//...
-------DELETE------
2023/03/31 18:54:21 deleted tasks
2023/03/31 18:54:21 deleted tasks
2023/03/31 18:54:21 deleted tasks
-------------------
注意这里,与客户端流只有一个响应不同,我们有三个响应(三个已删除的任务)。这是因为我们对每个请求都得到一个响应。我们实际上实现了双向流。
我们在这里实现了双向流,这使得我们可以获取我们发送给服务器的每个请求的反馈。有了这个,我们可以确保在不需要等待服务器响应或错误的情况下更新客户端的资源。这对于需要实时更新的用例来说很有趣。
摘要
在本章中,我们看到了我们可以编写的不同类型的 gRPC API。我们看到了我们可以创建与我们在 REST API 中熟悉的类似 API 端点。这些端点被称为单一端点。然后,我们看到了我们可以创建服务器流 API,让服务器返回多个响应。同样,我们看到了客户端可以通过客户端流返回多个请求。最后,我们看到了我们可以“混合”服务器和客户端流以获得双向流。
我们当前的端点很简单,没有处理对生产级 API 至关重要的许多情况。
在下一章中,我们将开始看到我们可以在 API 层面上进行哪些改进。这将让我们首先关注 API 的可用性,然后再深入到生产级 API 的所有方面。
问答
-
当你想发送一个请求并接收一个响应时,你应该使用哪种类型的 API 端点?
-
双向流
-
客户端流
-
单一
-
-
当你想发送零个或多个请求并接收一个响应时,你应该使用哪种类型的 API 端点?
-
服务器流
-
双向流
-
客户端流
-
-
当你想发送一个请求并接收零个或多个响应时,你应该使用哪种类型的 API 端点?
-
服务器流
-
客户端流
-
双向流
-
-
当你想发送零个或多个请求并接收零个或多个响应时,应该使用哪种 API 端点?
-
客户端流
-
双向流
-
服务器流
-
答案
-
C
-
C
-
A
-
B
第六章:设计有效的 API
虽然 gRPC 性能良好,但很容易犯错误,这些错误在长期或大规模上可能会给你带来损失。在本章中,我们将探讨设计高效 gRPC API 的重要考虑因素。由于我们正在讨论 API 设计,这些考虑因素将与 Protobuf 相关联,因为正如你所知,我们在 Protobuf 中定义我们的类型和端点。
在本章中,我们将涵盖以下主题:
-
如何选择正确的整数类型
-
理解字段标签对序列化数据大小的影响
-
如何使用字段掩码解决过度获取问题
-
理解重复字段如何导致比预期更大的有效负载
技术要求
对于本章,你可以在附带的 GitHub 仓库中找到相关代码,该仓库位于名为chapter6的文件夹中(github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter6)。
选择正确的整数类型
Protobuf 由于它的二进制格式以及它对整数的表示而性能优越。虽然某些类型,如字符串,是“原样”序列化并附加字段标签、类型和长度,但数字——尤其是整数——通常以比在计算机内存中布局的方式更少的位进行序列化。
然而,你可能已经注意到我说了“通常序列化”。这是因为如果你为你的数据选择了错误的整数类型,varint编码算法可能会将int32编码成 5 个字节或更多,而在内存中它只有 4 个字节。
让我们看看一个错误的整数类型选择的例子。假设我们想要编码值 268,435,456。我们可以通过使用 Go 标准库中的unsafe.Sizeof函数和由 Protobuf 提供的proto.Marshal函数来检查这个值在内存中和使用 Protobuf 序列化时的样子。最后,我们还将使用已知的Int32Value类型来包装该值,以便能够使用 Protobuf 进行序列化。
在编写主函数之前,让我们尝试创建一个名为serializedSize的泛型函数,该函数将返回整数在内存中的大小以及使用 Protobuf 序列化的相同整数的大小。
重要提示
这里展示的代码位于附带的 GitHub 仓库的helpers目录下。我们认为将 TODO API 和这类代码混合在一起没有意义,所以我们将其分开。
让我们先添加依赖项:
$ go get –u google.golang.org/protobuf
$ go get –u golang.org/x/exp/constraints
第一是能够访问已知的Int32Value类型,第二是能够访问预定义的类型约束以用于泛型。
我们将使用泛型来接受任何类型的整数作为数据,并让我们指定一个包装消息,以便能够使用 Protobuf 序列化数据。我们将有以下函数:
func serializedSizeD constraints.Integer, W
protoreflect.ProtoMessage (uintptr,
int) {
//...
}
然后,我们可以简单地使用 Protobuf 库中的 proto.Marshal 函数来序列化包装器,并返回 unsafe.Sizeof 的结果和序列化数据的长度:
func serializedSizeD constraints.Integer, W
protoreflect.ProtoMessage (uintptr, int) {
out, err := proto.Marshal(wrapper)
if err != nil {
log.Fatal(err)
}
return unsafe.Sizeof(data), len(out) - 1
}
之后,就简单了。我们只需从我们的 main 函数中调用该函数,传递一个包含值 268,435,456 的变量和一个 Int32Value 实例:
import (
"fmt"
"unsafe"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/wrapperspb"
"golang.org/x/exp/constraints"
)
//...
func main() {
var data int32 = 268_435_456
i32 := &wrapperspb.Int32Value{
Value: data,
}
d, w := serializedSize(data, i32)
fmt.Printf("in memory: %d\npb: %d\n", d, w)
}
如果我们运行这个程序,我们应该得到以下结果:
$ go run integers.go
in memory: 4
pb: 5
现在,如果你仔细看了代码,你可能会认为 len(out) 后面的 –1 是在作弊。在 Protobuf 中,Int32Value 被序列化为 6 个字节。虽然你关于实际序列化大小是 6 个字节的事实是正确的,但前几个字节代表类型和字段标签。因此,为了使序列化数据的比较公平,我们移除了元数据,只比较数字本身。
你可能正在想,我们当前的 TODO API,它使用 uint64 作为 ID,也存在这个问题,你完全正确。你可以很容易地看到,通过将 int32 改为 uint64,将 Int32Value 改为 UInt64Value,并将我们的数据设置为 72,057,594,037,927,936:
func main() {
var data uint64 = 72_057_594_037_927_936
ui64 := &wrapperspb.UInt64Value{
Value: data,
}
d, w := serializedSize(data, ui64)
fmt.Printf("in memory: %d\npb: %d\n", d, w)
}
使用前面的代码,我们会得到以下结果:
$ go run integers.go
in memory: 8
pb: 9
这意味着在注册了大约 72 万亿个任务之后,我们会遇到这个问题。显然,对于我们的用例,我们使用 uint64 作为 id 是安全的,因为要出现这样的问题,我们需要地球上每个人创建 9000 万个任务(72 万亿 / 80 亿)。但这个问题在其他用例中可能更为严重,我们需要意识到我们 API 的局限性。
使用整数的替代方案
一个经常被引用,甚至被 Google 推荐的替代方案是使用字符串作为 ID。他们提到 2⁶⁴ (int64) 已经“不像以前那么大了。”在公司的背景下,这是可以理解的。他们必须处理大量的数据,以及比我们大多数人更大的数字。
然而,字符串相对于数字类型的优势不仅仅如此。最大的优势可能是你的 API 的演变。如果在某个时刻你需要存储更大的数字,你唯一的替代方案就是切换到字符串类型。但问题是,你之前使用的数字类型和字符串之间没有前后兼容性。因此,你将不得不在你的模式中添加一个新字段,使消息定义变得杂乱,并让开发者检查在与旧版/新版应用程序通信时 ID 是否被设置为字符串或数字。
字符串还提供了安全性,因为这些不能用于算术运算。这在一定程度上限制了聪明开发者,但也是一种好的限制,使他们不能对 ID 进行一些聪明的操作,最终导致数字溢出。ID 被有效地视为全局变量,不应该有人手动处理。
总之,对于某些用例,直接从字符串开始为 ID 编写可能是个好主意。如果你预计要扩展或简单地处理比整数限制更大的数字,字符串是解决方案。然而,在许多情况下,你可能只需要 uint64。只需了解你的需求并规划未来即可。
选择正确的字段标签
如你所知,字段标签与实际数据一起序列化,以便 Protobuf 知道将数据反序列化到哪个字段。由于这些标签以 varint 编码,标签越大,对序列化数据大小的冲击就越大。在本节中,让我们讨论你必须考虑的两个因素,以防止这些标签过多地影响你的有效载荷。
必需/可选
如果你意识到权衡,大字段标签可能没问题。处理大标签的一种常见方式是将它们视为用于可选字段。可选字段意味着它不太常填充数据,由于 Protobuf 不序列化未填充的字段,因此标签本身不会被序列化。然而,我们偶尔会填充这个字段,这将产生成本。
这种设计的优点是,在不创建大量消息以保持字段标签较小的情况下,将相关信息保持在一起。这将使代码更容易阅读,并让读者了解他们可以填充的可能字段。
然而,缺点是,如果你正在创建一个面向用户的 API,你可能会经常产生成本。这可能是由于用户不理解如何正确使用你的 API,或者简单地因为用户有特定的需求。这种情况也可能在公司环境中发生,但可以通过高级软件工程师或内部文档来缓解。
让我们看看大标签带来的负面影响的例子。为了举例,让我们假设我们有以下消息(helpers/tags.proto):
message Tags {
int32 tag = 1;
int32 tag2 = 16;
int32 tag3 = 2048;
int32 tag4 = 262_144;
int32 tag5 = 33_554_432;
int32 tag6 = 536_870_911;
}
注意,这些数字并非随机。如果你还记得,在 Protobuf 入门介绍中,我解释过标签是以 varints 编码的。这些数字是标签单独序列化所需额外一个字节的阈值。
现在,有了这个,我们将计算消息的大小,我们将逐步设置字段的值。我们将从一个空对象开始,然后设置 tag,然后 tag2,依此类推。请注意,我们将为所有字段设置相同的值(1)。这将显示仅序列化标签所需的额外开销。
在 helpers/tags.go 中,我们有以下内容:
package main
import (
"fmt"
"log"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/
helpers/proto"
)
func serializedSizeM protoreflect.ProtoMessage int
{
out, err := proto.Marshal(msg)
if err != nil {
log.Fatal(err)
}
return len(out)
}
func main() {
t := &pb.Tags{}
tags := []int{1, 16, 2048, 262_144, 33_554_432, 536_870_911}
fields := []*int32{&t.Tag, &t.Tag2, &t.Tag3, &t.Tag4,
&t.Tag5, &t.Tag6}
sz := serializedSize(t)
fmt.Printf("0 - %d\n", sz)
for i, f := range fields {
*f = 1
sz := serializedSize(t)
fmt.Printf("%d - %d\n", tags[i], sz-(i+1))
}
}
我们重新使用了之前看到的serializedSize。我们通过取消对字段指针的引用来设置字段,我们使用新设置的字段计算Tag消息的大小,并打印结果。这个结果经过一点处理,只显示标签的字节。我们从大小中减去 i+1,因为 i 是零索引的(所以+1)。因此,实际上,我们从大小中减去了已设置的字段数量,这也是不包含标签序列化数据所需的大小(值为 1 的字节)。
最后,如果我们运行这个,我们将得到以下结果(美化后的):
$ go run tags.go
Tag Bytes
---- ----
0 0
1 1
16 3
2048 6
262144 10
33554432 15
536870911 20
这告诉我们,每次我们通过一个阈值,我们的序列化数据就会多出一个字节的开销。一开始,我们有一个空消息,所以得到 0 字节,然后我们有一个标签 1,它序列化为 1 字节,之后标签 2 序列化为 2 字节,以此类推。我们可以查看两行之间的差异来得到开销。将value设置为标签为2048的字段而不是标签为16的字段的开销是 3 字节(6 - 3 字节)。
总结来说,我们需要保留较小的字段标签,用于最常使用或必需的字段。这是因为这些标签几乎总是会被序列化,而我们希望最小化标签序列化的影响。对于可选字段,我们可能会使用较大的标签来将相关字段放在一起,并且因此,我们可能会产生非重复的数据负载增加。
分割消息
通常,我们更喜欢分割消息以保持较小的对象和较少的字段,从而有较小的标签。这让我们能够将信息安排成实体,并理解给定信息所代表的内容。我们的Task消息就是一个例子。它将信息分组,我们可以在例如UpdateTasksRequest中重用这个实体,以接受一个功能齐全的Task作为请求。
然而,虽然能够将信息分离成实体很有趣,但这并不是免费的。你的负载会受到用户定义类型的使用的影響。让我们看看分割消息的例子以及它如何影响序列化数据的大小。这个例子表明,在分割消息时会有大小开销。为了展示这一点,我们将创建一个包含名称和一个名称包装器的消息。第一次检查大小,我们只会设置字符串,第二次我们只会设置包装器。这就是我所说的这样的消息:
message ComplexName {
string name = 1;
}
message Split {
string name = 1;
ComplexName complex_name = 2;
}
目前,我们不必担心这个示例的有用性。我们只是在尝试证明分割消息会有额外的开销。
然后,我们将编写一个main函数,该函数简单地首先设置name的值,然后计算大小并打印它。然后,我们将清除名称,设置ComplexName.name字段,计算大小并打印它。如果有开销,大小应该不同。在helpers/split.go中,我们有以下内容:
package main
import (
"fmt"
"log"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals
/helpers/proto"
)
func serializedSizeM protoreflect.ProtoMessage int
{
out, err := proto.Marshal(msg)
if err != nil {
log.Fatal(err)
}
return len(out)
}
func main() {
s := &pb.Split{Name: "Packt"}
sz := serializedSize(s)
fmt.Printf("With Name: %d\n", sz)
s.Name = ""
s.ComplexName = &pb.ComplexName{Name: "Packt"}
sz = serializedSize(s)
fmt.Printf("With ComplexName: %d\n", sz)
}
如果我们运行这个,我们应该得到:
$ go run split.go
With Name: 7
With ComplexName: 9
实际上,这两个大小是不同的。但区别在哪里呢?区别在于用户定义的类型被序列化为长度限定类型。在我们的例子中,简单的名称会被序列化为 0a 05 50 61 63 6b 74。0a 是长度限定+标签 1 的线类型,其余的是字符。但对于复杂类型,我们有 12 07 0a 05 50 61 63 6b 74。我们识别了最后的 7 个字节,但前面还有两个字节。12 是长度限定线类型+标签 2,07 是后续字节的长度。
总之,我们再次面临权衡。消息中的标签越多,我们在有效载荷大小方面承担成本的可能性就越大。然而,我们越试图分割消息以保持标签小,我们也将承担更多的成本,因为数据将被序列化为长度限定数据。
改进UpdateTasksRequest
为了反思我们在上一节中学到的内容,我们将改进UpdateTasksRequest的序列化大小。这很重要,因为消息的使用上下文。这是一个客户端可能会发送 0 次或多次的消息,因为它用于客户端流式 RPC 端点。这意味着序列化数据中的任何开销都将乘以我们在线发送此消息的次数。
重要提示
以下代码位于附带的 GitHub 仓库中。你将在proto/todo/v2文件夹中找到新的 Protobuf 代码,并且UpdateTasks的服务器/客户端代码将被更新以反映这一变化。最后,需要注意的一点是我们不提供前后兼容性。chapter6中的服务器不能接收来自chapter5中的客户端的请求。需要更多的工作来实现这一点。
如果我们查看当前的消息,我们有以下内容:
message UpdateTasksRequest {
Task task = 1;
}
这正是我们想要描述的内容,但现在我们知道由于子消息的存在,将序列化一些额外的字节。为了解决这个问题,我们可以简单地复制我们允许用户更改的字段以及描述要更新哪个任务的 ID。这将给我们以下结果:
message UpdateTasksRequest {
uint64 id = 1;
string description = 2;
bool done = 3;
google.protobuf.Timestamp due_date = 4;
}
这与Task消息的定义相同。
现在,你可能认为我们在重复自己,这样做是浪费的。然而,这样做有两个重要的好处:
-
我们不再需要为用户定义类型的序列化承担开销。在每次请求中,我们节省 2 个字节(标签+类型和长度)。
-
现在,我们对用户可能更新的字段有了更多的控制。如果我们不想让用户再更改
due_date,我们只需将其从UpdateTaskRequest消息中删除并保留标签 4。
为了证明这在序列化数据大小方面更有效,我们可以暂时修改server/impl.go中的UpdateTasks函数,以chapter5和chapter6为例。为了计算有效负载的大小,我们可以使用我们之前使用的proto.Marshal并汇总总序列化大小。最后,我们可以在接收到 EOF 时在终端上打印结果。
在chapter6中,它看起来是这样的:
func (s *server) UpdateTasks(stream
pb.TodoService_UpdateTasksServer) error {
totalLength := 0
for {
req, err := stream.Recv()
if err == io.EOF {
log.Println("TOTAL: ", totalLength)
return stream.SendAndClose(&pb.UpdateTasksResponse{})
}
if err != nil {
return err
}
out, _ := proto.Marshal(req)
totalLength += len(out)
s.d.updateTask(
req.Id,
req.Description,
req.DueDate.AsTime(),
req.Done,
)
}
}
对于chapter5,这导致网络中发送了 56 字节的请求,而对于chapter6,我们只发送了 50 字节。再次强调,这看起来微不足道,因为我们是在小规模上做的,但一旦我们收到流量,它将迅速累积并影响我们的成本。
采用 FieldMasks 以减少有效负载
在改进我们的UpdateTasksRequest消息之后,我们现在可以开始查看FieldMasks以进一步减少有效负载大小,但这次我们将专注于ListTasksResponse。
首先,让我们了解什么是FieldMasks。它指的是包含一系列路径的对象,告诉 Protobuf 包含哪些字段,并隐式地告诉它哪些不应该包含。以下是一个例子。假设我们有一个Task这样的消息:
message Task {
uint64 id = 1;
string description = 2;
bool done = 3;
google.protobuf.Timestamp due_date = 4;
}
如果我们只想选择id和done字段,我们可以有一个简单的FieldMask,如下所示:
mask {
paths: "id"
paths: "done"
}
然后,我们可以将这个掩码应用于Task的一个实例,并且它将只保留提到的字段的值。当我们进行类似于GET的操作,并且不想获取太多不必要的数据(过度获取)时,这很有趣。
我们的 TODO API 包含一个这样的用例:ListTasks。为什么?因为如果用户只想获取部分信息,他们将无法做到。选择部分数据可能对同步本地存储到后端等特性很有用。如果后端有 ID 1、2 和 3,而本地有 1、2、3、4 和 5,我们希望能够计算出需要上传的任务的增量。为此,我们只需要列出 ID,获取描述、完成日期和due_date值将是浪费的。
改进 ListTasksRequest
ListTasksResponse是一种服务器端流式 API。我们发送一个请求,然后得到 0 个或多个响应。这一点很重要,因为发送FieldMask并不是免费的。我们仍然需要在网络中传输字节。然而,在我们的情况下,使用掩码很有趣,因为我们只需发送一次,它就会应用于服务器返回的所有元素。
我们需要做的第一件事是声明这样的FieldMask。为此,我们导入field_mask.proto并在ListTasksRequest中添加一个字段:
import "google/protobuf/field_mask.proto";
//...
message ListTasksRequest {
google.protobuf.FieldMask mask = 1;
}
然后,我们可以转到服务器端并将该掩码应用于我们发送的所有响应。这是通过反射和一些样板代码完成的。我们需要做的第一件事是在服务器中添加一个依赖项,以处理切片并特别访问Contains函数:
$ go get golang.org/x/exp/slices
之后,我们可以使用反射。我们将遍历给定消息的所有字段,如果其名称不在掩码路径中,我们将移除其值:
重要提示
以下代码是一个简单的实现,用于在消息中过滤字段,但这对于我们的用例来说已经足够了。在现实中,FieldMasks 有更多强大的功能,如过滤映射、列表和子消息。不幸的是,Protobuf 的 Go 实现没有提供像其他实现那样的此类实用工具,因此我们需要依赖编写自己的代码或使用社区项目。
import (
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/reflect/protoreflect"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"golang.org/x/exp/slices"
)
//...
func Filter(msg proto.Message, mask *fieldmaskpb.FieldMask) {
if mask == nil || len(mask.Paths) == 0 {
return
}
rft := msg.ProtoReflect()
rft.Range(func(fd protoreflect.FieldDescriptor, _
protoreflect.Value) bool {
if !slices.Contains(mask.Paths, string(fd.Name())) {
rft.Clear(fd)
}
return true
})
}
这样,我们现在基本上可以在 ListTasks 实现中使用 Filter 来过滤将在 ListTasksResponse 中发送的 Task 对象:
func (s *server) ListTasks(req *pb.ListTasksRequest, stream
pb.TodoService_ListTasksServer) error {
return s.d.getTasks(func(t interface{}) error {
task := t.(*pb.Task)
Filter(task, req.Mask)
overdue := task.DueDate != nil && !task.Done &&
task.DueDate.AsTime().Before(time.Now().UTC())
err := stream.Send(&pb.ListTasksResponse{
Task: task,
Overdue: overdue,
})
return err
})
}
注意,Filter 在计算 Overdue 之前被调用。这是因为如果我们没有在 FieldMask 中包含 due_date,我们假设用户不关心逾期。最终,逾期将是 false,未序列化,因此不会通过网络发送。
然后,我们需要看看如何在客户端使用它。在这个例子中,printTasks 将只打印 ID。我们将接收 FieldMask 作为 printTasks 的参数,并将其添加到 ListTasksRequest 中:
func printTasks(c pb.TodoServiceClient, fm *fieldmaskpb
.FieldMask) {
req := &pb.ListTasksRequest{
Mask: fm,
}
//...
}
最后,使用 fieldmaskpb.New,我们首先使用路径 id 创建一个 FieldMask。这个函数将检查 id 是否是我们提供的第一个参数中的消息中的有效路径。如果没有错误,我们可以在我们的 ListTasksRequest 实例中设置 Mask 字段:
func main() {
//...
fm, err := fieldmaskpb.New(&pb.Task{}, "id")
if err != nil {
log.Fatalf("unexpected error: %v", err)
}
//...
fmt.Println("--------LIST-------")
printTasks(c, fm)
fmt.Println("-------------------")
//...
}
如果我们运行它,我们应该得到以下输出:
--------LIST-------
id:1 overdue:false
id:2 overdue:false
id:3 overdue:false
-------------------
注意,overdue 仍然被打印为 false,但在这个例子中,我们可以忽略它,因为我们已经在 printTasks 函数中打印了逾期,逾期(bool)的默认值是 false。
谨防解压缩重复字段
最后一个考虑因素对我们 TODO API 没有帮助,但值得一提。在 Protobuf 中,我们有不同的方式来编码重复字段。我们有两种重复字段的压缩和解压缩方式。
压缩重复字段
为了理解,让我们看看一个压缩重复字段的例子。假设我们有以下消息:
message RepeatedUInt32Values {
repeated uint32 values = 1;
}
这是一个简单的 uint32 标量类型的列表。如果我们使用值 1、2 和 3 进行序列化,我们会得到以下结果:
$ cat repeated_scalar.txt | protoc --encode=
RepeatedUInt32Values proto/repeated.proto | hexdump -C
0a 03 01 02 03
00000005
前一个命令中的 repeated_scalar.txt 包含以下内容:
values: 1
values: 2
values: 3
这是一个压缩重复字段的例子,因为它如何包装多个值。你可能会认为这是正常的,因为这是一个列表,但我们将稍后看到这并不总是正确的。
要理解“包装多个值”的含义,我们需要仔细看看 hexdump 展示的十六进制数。我们有 5 个字节:0a 03 01 02 03。正如我们所知,重复字段被序列化为长度限定类型。所以 0a 是类型(varint)和字段标签(1)的组合,03 表示列表中有三个元素,其余的是实际值。
解压缩重复字段
然而,重复字段的序列化数据并不总是那么紧凑。让我们看看一个未展开的重复字段的例子。假设我们为名为values的字段添加了packed选项,并将其值设置为false:
message RepeatedUInt32Values {
repeated uint32 values = 1 [packed = false];
}
现在,如果我们用相同的值运行相同的命令,我们应该得到以下结果:
$ cat repeated_scalar.txt | protoc --encode=
RepeatedUInt32Values proto/repeated.proto | hexdump -C
08 01 08 02 08 03
00000006
我们可以看到,我们有了完全不同的数据序列化方式。这次,我们反复序列化uint32。在这里,08 代表类型(varint)和标签(1),你可以看到它出现了三次,因为我们有三个值。如果我们重复字段中有超过两个值,这实际上会为每个值添加一个字节。在我们的例子中,我们序列化整个为 6 字节,而不是之前的 5 字节。
现在,你可能认为你将不会使用packed选项,并且你应该始终有一个packed字段。对于作用于标量的重复字段来说,你会是对的,但对于更复杂的类型来说则不然。例如,字符串、字节和用户定义的类型将始终以未打包的形式序列化,而且无法避免这一点。
让我们用一个用户定义的类型为例。假设我们有以下 Protobuf 代码:
message UserDefined {
uint32 value = 1;
}
message RepeatedUserDefinedValues {
repeated UserDefined values = 1;
}
现在,我们可以尝试运行以下命令:
$ cat repeated_ud.txt | protoc --encode=
RepeatedUserDefinedValues proto/repeated.proto | hexdump -C
0a 02 08 01 0a 02 08 02 0a 02 08 03
0000000c
前一个命令中的repeated_ud.txt包含以下内容:
values: {value: 1}
values: {value: 2}
values: {value: 3}
我们可以看到,我们现在有了本章早期与子消息相关的开销,以及我们的重复字段现在是未打包的。我们有 0a 和 02,它们对应于子消息本身,以及 08 + value,它对应于名为value的字段。正如你所看到的,这现在浪费了更多的字节。
现在,由于在复杂类型上这是不可避免的,所以说我们不应该在这样类型上使用重复字段是不正确的。这是一个非常有用的概念,应该谨慎使用,并且我们应该意识到它的成本。
摘要
在本章中,我们看到了在设计我们的 API 时需要考虑的主要因素。其中大部分与 Protobuf 有关,因为它是我们 API 的接口,并且负责序列化和反序列化。我们了解到选择正确的整数类型很重要,这可能会导致有效载荷大小的问题,而且在我们想要演进我们的 API 时也会引发问题。
之后,我们看到了选择正确的字段标签也很重要。这是因为标签会与数据一起序列化,并且它们被序列化为varints。所以标签越大,我们的有效载荷就越大。
然后,我们看到了如何利用FieldMasks来选择我们所需的数据,并避免过度获取问题。虽然这个概念在 gRPC Go 中并不是非常发达,但其他实现广泛使用了它。这显著减少了我们在网络中发送的有效载荷。
最后,我们看到了在使用 Protobuf 中的重复字段时需要小心。这是因为如果我们在一个复杂类型上使用它们,我们会浪费一些字节。然而,不应该因为这一点而避免使用重复字段。有时它们是正确的数据结构。在下一章中,我们将介绍如何使 API 调用既高效又安全。
测验
-
为什么对于整数类型来说,并不总是使用
varint编码更优?-
没有理由,它们总是比固定整数更优
-
varint编码将更大的数字序列化成更多的字节 -
varint编码将较小的数字序列化成更多的字节
-
-
我们如何得到消息序列化后的字节数?
-
proto.Marshal +len -
proto.Unmarshal +len -
len
-
-
我们应该给经常填充的字段分配什么样的标签?
-
更大的标签
-
较小的标签
-
-
将消息拆分以使用较小的标签的主要问题是什么?
-
我们有开销,因为子消息被序列化为长度分隔类型
-
没问题——这就是正确的方法
-
-
什么是
FieldMask?-
一组字段路径,告诉我们需要排除哪些数据
-
一组字段路径,告诉我们需要包含哪些数据
-
-
何时重复字段以未打包的形式序列化?
-
当重复字段作用于标量类型时
-
只有当我们使用带有值
false的打包选项时 -
当重复字段作用于复杂类型时
-
答案
-
B
-
A
-
B
-
A
-
B
-
C
第七章:开箱即用的功能
由于编写生产就绪的 API 比发送请求和接收响应要复杂得多,因此 gRPC 比我们看到的简单通信模式提供了更多功能。在本章中,我们将看到我们可以使用的一些最重要的功能,以便使我们的 API 更加健壮、高效和安全。
在本章中,我们将涵盖以下主题:
-
处理错误、取消和截止日期
-
发送 HTTP 头
-
在线加密数据
-
使用拦截器提供额外的逻辑
-
调度到不同服务器的请求
到本章结束时,我们将了解到使用 gRPC 时直接提供的最重要的功能。
技术要求
对于本章,你将在附带的 GitHub 仓库中找到名为chapter7的文件夹中的相关代码(github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter7)。
在上一节中,我将使用 Kubernetes 来展示客户端负载均衡。我假设你已经安装了 Docker 并且有一个 Kubernetes 集群。你可以以任何你想要的方式完成这件事,但我提供了一个 Kind (kind.sigs.k8s.io/) 配置,以便轻松且本地地启动一个集群。这个配置位于chapter7的k8s文件夹中,在名为kind.yaml的文件中。一旦安装了 Kind,你可以这样使用它:
$ kind create cluster --config k8s/kind.yaml
你可以通过运行以下命令来丢弃它:
$ kind delete cluster
处理错误
到目前为止,我们还没有讨论可能出现在业务逻辑内部或外部的潜在错误。这对于一个生产就绪的 API 来说显然不是很好,因此我们将看到如何解决这些问题。在本节中,我们将集中精力解决名为AddTask的 RPC 端点。
在开始编码之前,我们需要了解 gRPC 中错误的工作方式,但这不应该很难,因为它们与我们习惯的 REST API 非常相似。
错误是通过一个名为Status的包装结构返回的。这个结构可以以多种方式构建,但本节中我们感兴趣的是以下几种:
func Error(c codes.Code, msg string) error
func Errorf(c codes.Code, format string, a ...interface{}) error
它们都接受一个错误消息和一个错误代码。让我们专注于代码,因为消息只是描述错误的字符串。状态代码是预定义的代码,它们在不同的 gRPC 实现中是一致的。它与 HTTP 代码(如404和500)类似,但主要区别是它们有更描述性的名称,并且比 HTTP 中的代码少得多(总共 16 个)。
要查看所有这些代码,你可以访问 gRPC Go 文档(pkg.go.dev/google.golang.org/grpc/codes#Code)。它包含了每个错误的良好解释,并且比 HTTP 代码更不模糊,所以不要害怕。不过,对于本节,我们感兴趣的两种常见错误是:
-
InvalidArgument -
Internal
第一个表示客户端指定了一个不适用于端点正常工作的参数。第二个表示系统的一个预期属性已损坏。
InvalidArgument非常适合验证输入。我们将在AddTask中使用它来确保Task的描述不为空(没有描述的任务是无用的),并且已指定截止日期且不在过去。注意,我们使截止日期成为必需的,但如果你希望使其可选,我们只需检查请求中的DueDate属性是否为nil并相应地处理:
import (
//...
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
}
func (s *server) AddTask(_ context.Context, in *pb.AddTaskRequest)
(*pb.AddTaskResponse, error) {
if len(in.Description) == 0 {
return nil, status.Error(
codes.InvalidArgument,
"expected a task description, got an empty string",
)
}
if in.DueDate.AsTime().Before(time.Now().UTC()) {
return nil, status.Error(
codes.InvalidArgument,
"expected a task due_date that is in the future",
)
}
//...
}
这些检查将确保我们的数据库中只有有用的任务,并且我们的截止日期在将来。
最后,我们还有一个可能来自addTask函数的错误,这将是数据库传递的错误。我们可以进行广泛的检查,根据每个数据库错误创建更精确的错误代码,但在这个例子中,为了简单起见,我们只是简单地说任何数据库错误都是Internal错误。
我们将从addTask函数获取潜在的错误,并执行与我们对InvalidArgument所做类似的操作,但这次将是一个Internal代码,我们将使用Errorf函数来传递错误的详细信息:
func (s *server) AddTask(_ context.Context, in *pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
//...
id, err := s.d.addTask(in.Description,
in.DueDate.AsTime())
if err != nil {
return nil, status.Errorf(
codes.Internal,
"unexpected error: %s",
err.Error(),
)
}
//...
}
现在,我们已经完成了服务器端。我们可以切换到客户端 - 如果你之前没有注意到,我们已经在addTask中“处理”错误了。我们有以下几行:
res, err := c.AddTask(context.Background(), req)
if err != nil {
panic(err)
}
当然,客户端可能会进行更复杂的错误处理或甚至恢复,但我们的目标现在是要确保我们的服务器错误被正确地传递给客户端。为了测试InvalidArgument错误,我们可以简单地尝试添加一个没有描述的Task。在main的末尾,我们可以添加以下内容:
import (
//...
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func main() {
//...
fmt.Println("-------ERROR-------")
addTask(c, "", dueDate)
fmt.Println("-------------------")
}
然后,我们运行我们的服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
而我们的客户端应该返回预期的错误:
$ go run ./client 0.0.0.0:50051
-------ERROR-------
panic: rpc error: code = InvalidArgument desc = expected a task description, got an empty string
然后,我们可以通过提供一个过去时间的Time实例来检查截止日期错误:
fmt.Println("-------ERROR-------")
// addTask(c, "", dueDate)
addTask(c, "not empty", time.Now().Add(-5 * time.Second))
fmt.Println("-------------------")
我们应该得到以下结果:
$ go run ./client 0.0.0.0:50051
-------ERROR-------
panic: rpc error: code = InvalidArgument desc = expected a task due_
date that is in the future
最后,我们不会显示Internal错误,因为这会使我们在内存数据库中创建一个假错误,但请理解它将返回以下内容:
$ go run ./client 0.0.0.0:50051
-------ERROR-------
panic: rpc error: code = Internal desc = unexpected error: <AN_ERROR_
MESSAGE>
在完成本节之前,了解我们如何检查错误的类型并相应地采取行动也很重要。我们基本上会恐慌,但会有更易读的消息。例如,想象以下代码的情况:
rpc error: code = InvalidArgument desc = expected a task due_date that
is in the future
相反,我们将打印以下内容:
InvalidArgument: expected a task due_date that is in the future
为了做到这一点,我们将修改addTask,使其在出现错误时尝试使用FromError函数将其转换为状态——如果转换正确完成,我们将打印错误代码和错误消息;如果没有转换为状态,我们将像以前一样直接 panic:
func addTask(c pb.TodoServiceClient, description string, dueDate time.
Time) uint64 {
//...
res, err := c.AddTask(context.Background(), req)
if err != nil {
if s, ok := status.FromError(err); ok {
switch s.Code() {
case codes.InvalidArgument, codes.Internal:
log.Fatalf("%s: %s", s.Code(), s.Message())
default:
log.Fatal(s)
}
} else {
panic(err)
}
}
//...
}
现在,在运行了之前定义的错误之一后,我们可以得到以下结果:
$ go run ./client 0.0.0.0:50051
-------ERROR-------
InvalidArgument: expected a task due_date that is in the future
Bazel
重要提示
这里展示的命令每次更新与 gRPC 相关的导入时都需要使用。为了简化,我们只在本章中展示一次,并假设你将在其他部分中能够做到这一点。
由于我们将在本章中添加更多依赖项,我们需要更新我们的BUILD文件。如果我们现在尝试使用 Bazel 运行服务器,我们会得到一个错误,显示以下内容:
No dependencies were provided.
Check that imports in Go sources match importpath attributes in deps.
为了解决这个问题,我们只需运行gazelle命令,如下所示:
$ bazel run //:gazelle
然后,我们就能正确地运行服务器和客户端:
$ bazel run //server:server 0.0.0.0:50051
listening at 0.0.0.0:50051
$ bazel run //client:client 0.0.0.0:50051
总结一下,我们看到了我们可以在服务器端使用status包中的Error和Errorf函数创建一个错误。我们有多个错误代码可供选择。我们只看到了两个,但它们是常见的。最后,在客户端,我们看到了我们可以根据错误代码相应地采取行动,通过将 Go 错误转换为状态并根据状态码编写条件。
取消调用
当你想根据某些条件停止一个调用或中断一个长时间运行的流时,gRPC 为你提供了可以在任何时候执行的取消函数。
如果你之前在任何分布式系统代码或 API 中使用过 Go,你可能见过一个名为context的类型。这是提供请求范围信息和在 API 的参与者之间传递信号的习惯用法,这也是 gRPC 的一个重要组成部分。
如果你没有注意到,到目前为止,我们每次发起请求时都使用了context.Background()。在 Go 文档中,这被描述为返回“一个非空、空的上下文。它永远不会取消,没有值,也没有截止日期。”正如你可以猜到的,仅此不足以用于生产就绪的 API,以下是一些原因:
-
如果用户想要提前终止请求怎么办?
-
如果 API 调用永远不会返回怎么办?
-
如果我们需要服务器知道全局值(例如,一个认证令牌)怎么办?
在本节中,让我们专注于第一个问题,在接下来的两个部分中,我们将回答其他问题。
为了获得取消调用的能力,我们将使用context包中的WithCancel函数(pkg.go.dev/context#WithCancel)。这个函数将返回构建的上下文和一个cancel函数,我们可以执行它来中断使用上下文进行的调用。所以,现在,我们不仅使用context.Background(),我们将创建一个上下文,如下所示:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
注意,在函数末尾调用 cancel 函数是很重要的,以释放与上下文相关的资源。为了确保函数被调用,我们可以使用 defer。然而,这并不意味着我们不能在函数结束之前调用该函数。
作为例子,我们将创建一个虚构的需求。它是虚构的,因为我们将改进/删除在本节中将要编写的代码。虚构的需求是在我们收到逾期 Task 后取消 ListTasks 调用。我们可以同意,从功能的角度来看,这没有意义,但无论如何,本节的目标是尝试取消一个调用。
要实现这样的功能,我们将使用 WithCancel 函数创建上下文,将此上下文传递给 ListTasks API 端点,最后,我们将在读取循环中添加另一个 if,检查是否存在逾期任务。如果确实如此,我们将调用 cancel 函数:
func printTasks(c pb.TodoServiceClient) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
//...
stream, err := c.ListTasks(ctx, req)
//...
for {
//...
if res.Overdue {
log.Printf("CANCEL called")
cancel()
}
fmt.Println(res.Task.String(), "overdue: ",
res.Overdue)
}
}
注意,我们可能会选择断开连接而不是直接调用 cancel 函数。然后,defer cancel() 将触发,服务器将停止工作。然而,我决定直接调用 cancel 并让客户端循环运行,因为我想要展示,当我们取消调用时,我们会收到一个错误。
现在,我们需要意识到 cancel 需要时间在网络中传播,因此服务器可能会在我们不知道的情况下继续运行。为了检查服务器发送的内容,我们将在将其发送到客户端之前,在终端上简单地打印出 Task:
func (s *server) ListTasks(req *pb.ListTasksRequest, stream
pb.TodoService_ListTasksServer) error {
return s.d.getTasks(func(t interface{}) error {
//...
log.Println(task)
overdue := //...
err := stream.Send(&pb.ListTasksResponse{
//...
})
return err
})
}
最后,我想提到,我们不需要在客户端的 main 函数中添加任何代码,这是因为我们已经看到,当我们运行当前的 main 代码时,我们会遇到逾期任务。第一个逾期任务应该出现在 update 部分:
fmt.Println("-------UPDATE------")
updateTasks(c, []*pb.UpdateTasksRequest{
{Id: id1, Description: "A better name for the task"},
//...
}...)
printTasks(c, nil)
fmt.Println("-------------------")
这是因为,如果你记得,我们更新了 Id 值和那个 Task 的描述,并将 Task 的其余属性设置为默认值。这意味着 Done 将设置为 false,DueDate 将设置为空的时间对象。
现在,我们可以这样运行服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,我们运行客户端:
重要提示
在运行以下代码之前,请确保你已经注释掉了会导致恐慌的函数调用。这包括我们在上一节中添加的两个 addTask。
$ go run ./client 0.0.0.0:50051
你应该在客户端注意到一切运行正常,即使 update 部分包含以下消息,也没有任何内容被取消:
CANCEL called.
原因是服务器不知道调用已被取消。为了解决这个问题,我们可以使服务器能够感知取消。
要做到这一点,我们需要检查上下文的 Done 通道。当取消传播到服务器时,此通道将被关闭,在取消示例中,上下文将有一个等于 context.Canceled 的错误。当我们有这个事件时,我们知道服务器需要返回并有效地停止处理剩余的请求:
func (s *server) ListTasks(req *pb.ListTasksRequest, stream
pb.TodoService_ListTasksServer) error {
ctx := stream.Context()
return s.d.getTasks(func(t interface{}) error {
select {
case <-ctx.Done():
switch ctx.Err() {
case context.Canceled:
log.Printf("request canceled: %s", ctx.Err())
default:
}
return ctx.Err()
/// TODO: replace following case by 'default:' on production APIs.
case <-time.After(1 * time.Millisecond):
}
//...
})
}
在运行此代码之前,有几个需要注意的事项。
第一件事是,当处理stream时,我们可以通过使用生成的流类型(在我们的案例中是pb.TodoService_ListTasksServer)中可用的Context()函数来获取上下文。
其次,请注意,我们故意在每个闭包调用中暂停 1 毫秒。在生产环境中不会发生这种情况;我们会有一个默认分支。这样做是为了让服务器有时间注意到取消操作。请注意,这个数字是任意的;这是我能在我的机器上注意到取消错误的最小时间。你可能需要将其设置得更大,或者你可以将其设置得更小。
现在,我们可以这样运行服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,我们可以再次运行客户端:
$ go run ./client 0.0.0.0:50051
//...
CANCEL called
id:1 description:"A better name for the task" due_date:{} overdue: true
unexpected error: rpc error: code = Canceled desc = context canceled
最后,你应该注意,在服务器端,你会收到以下信息:
request canceled: context canceled
总结来说,我们看到了如何使用context.WithCancel()创建一个可取消的上下文。我们还看到这个函数返回一个cancel函数,我们需要在作用域结束时调用它来释放上下文关联的资源,但我们也可以根据某些条件提前调用它。最后,我们看到了如何让服务器知道取消操作,这样它就不会执行超过所需的工作。
指定截止日期
在处理异步通信时,截止日期是最重要的事情。这是因为通话可能因为网络或其他问题而永远无法返回。这就是为什么谷歌建议我们为每个 RPC 调用设置一个截止日期。幸运的是,对我们来说,这就像取消一个调用一样简单。
在客户端,我们首先需要做的是创建一个上下文。这类似于WithCancel函数,但这次,我们将使用WithTimeout。它接受一个父上下文,就像WithCancel一样,但除此之外,它还接受一个Time实例,表示我们愿意等待服务器回答的最大时间。
在printTasks中的WithCancel代替,我们现在将使用以下上下文:
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
defer cancel()
显然,1 毫秒的超时时间对于让服务器回答来说太低了,但我们故意这样做是为了得到一个DeadlineExceeded错误。在现实场景中,我们需要根据为服务设定的要求来设置超时。这非常依赖于你的用例和服务的任务,所以你需要进行实验并跟踪服务器响应的平均时间。
这就是我们在 gRPC 中设置截止日期所需的一切。我们现在可以运行我们的服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,我们可以运行客户端:
$ go run ./client 0.0.0.0:50051
//...
unexpected error: rpc error: code = DeadlineExceeded desc = context
deadline exceeded
我们可以看到,第一个ListTasks正如预期的那样失败了。
现在,虽然这已经是你设置截止日期所需的一切,但你也可以让服务器知道DeadlineExceeded错误。即使从技术上讲这已经完成,因为我们当Done通道关闭时返回ctx.Err(),我们仍然想打印一条消息,说明截止日期已经超过。
要做到这一点,这就像Canceled错误一样,但这次,我们将在ctx.Err()的switch上添加一个DeadlineExceeded分支:
func (s *server) ListTasks(req *pb.ListTasksRequest, stream
pb.TodoService_ListTasksServer) error {
ctx := stream.Context()
return s.d.getTasks(func(t interface{}) error {
select {
case <-ctx.Done():
switch ctx.Err() {
//...
case context.DeadlineExceeded:
log.Printf("request deadline exceeded: %s",
ctx.Err())
}
return ctx.Err()
//...
}
//...
}
如果我们重新运行服务器和客户端,现在应该在运行服务器的终端上看到以下消息:
request deadline exceeded: context deadline exceeded
总结来说,我们看到了,类似于WithCancel,我们可以使用WithTimeout来为调用创建一个截止日期。建议始终设置一个截止日期,因为我们可能永远也收不到服务器的回答。最后,我们还看到了如何使服务器具有截止日期意识,以便它不会过度工作。
发送元数据
基于上下文构建的另一个特性是可以通过调用传递元数据。在 gRPC 中,这些元数据可以是 HTTP 头或 HTTP 尾迹。它们都是键值对列表,用于多种目的,例如传递身份验证令牌和数字签名、数据完整性等。在本节中,我们将主要关注通过头传递元数据。尾迹是发送在消息之后而不是之前的头。开发者较少使用,但 gRPC 使用它来实现流接口。无论如何,如果你感兴趣,可以查看grpc.SetTrailer函数(pkg.go.dev/google.golang.org/grpc#SetTrailer)。
我们的用例是将身份验证令牌传递给UpdateTasks RPC 端点,并在检查之后,我们将决定是更新任务还是返回一个Unauthenticated错误。显然,我们不会处理如何生成auth令牌,因为这是一个实现细节,但我们将简单地使用authd作为正确的令牌,其他所有内容都将被视为不正确。
让我们从服务器端开始。服务器正在接收上下文中的数据;因此,我们将使用 gRPC 中metadata包的FromIncomingContext函数。这将返回一个映射和是否有元数据。在UpdateTasks中,我们可以做以下操作:
func (s *server) UpdateTasks(stream pb.TodoService_UpdateTasksServer) error {
ctx := stream.Context()
md, _ := metadata.FromIncomingContext(ctx)
//...
}
我们现在可以检查是否提供了身份验证令牌。为此,这是一个简单的 Golang 映射使用。我们尝试访问md映射中的auth_token元素。它将返回值和一个布尔值,表示键是否在映射中。如果是,我们将检查只有一个值,并且这个值等于"authd"。如果不是,我们将返回一个Unauthenticated错误:
func (s *server) UpdateTasks(stream pb.TodoService_UpdateTasksServer) error {
ctx := stream.Context()
md, _ := metadata.FromIncomingContext(ctx)
if t, ok := md["auth_token"]; ok {
switch {
case len(t) != 1:
return status.Errorf(
codes.InvalidArgument,
"auth_token should contain only 1 value",
)
case t[0] != "authd":
return status.Errorf(
codes.Unauthenticated,
"incorrect auth_token",
)
}
} else {
return status.Errorf(
codes.Unauthenticated,
"failed to get auth_token",
)
}
//...
}
对于服务器来说,这就是全部了;如果没有元数据,如果有元数据但没有auth_token,如果有多个值的auth_token,以及如果auth_token的值与"authd"不同,我们将返回一个错误。
现在我们可以转到客户端发送适当的头信息。这可以通过metadata包中的另一个函数AppendToOutgoingContext来完成。我们知道在调用端点之前我们已经创建了一个上下文,所以我们只需将auth_token附加到它。这就像以下这样简单:
func updateTasks(c pb.TodoServiceClient, reqs ...*pb.
UpdateTasksRequest) {
ctx := context.Background()
ctx = metadata.AppendToOutgoingContext(ctx, "auth_token", "authd")
stream, err := c.UpdateTasks(ctx)
//...
}
我们通过包含键值对的新的上下文来覆盖我们创建的上下文。请注意,键值对可以交错。这意味着我们可以有如下情况:
metadata.AppendToOutgoingContext(ctx, K1, V1, K2, V2, ...)
在这里,K代表键,V代表值。
现在我们可以运行服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后我们运行客户端:
$ go run ./client 0.0.0.0:50051
一切都应该顺利。然而,如果你为auth_token设置了不同于"authd"的值,你应该得到以下消息:
unexpected error: rpc error: code = Unauthenticated desc = incorrect
auth_token
如果你没有设置auth_token头信息,你会看到以下内容:
unexpected error: rpc error: code = Unauthenticated desc = failed to
get auth_token
假设你为auth_token设置了多个值,如下所示:
ctx = metadata.AppendToOutgoingContext(ctx, "auth_token", "authd",
"auth_token", "authd")
你应该得到以下错误:
unexpected error: rpc error: code = InvalidArgument desc = auth_token
should contain only 1 value
总结来说,我们看到了如何使用metadata.FromIncomingContext函数从上下文中获取元数据,以及当我们这样做时可能出现的所有可能的错误。我们还看到了如何通过使用metadata.AppendToOutgoingContext函数将键值对附加到上下文中来实际从客户端发送元数据。
使用拦截器的外部逻辑
虽然某些头信息可能只适用于一个端点,但大多数情况下,我们希望能够在不同的端点之间应用相同的逻辑。在auth_token头信息的情况下,如果我们有多个路由,只有在用户登录时才能调用,我们不希望重复我们在上一节中做的所有检查。这会使代码膨胀;它不可维护;并且可能会在开发者寻找端点核心时分散他们的注意力。这就是为什么我们将使用身份验证拦截器。我们将提取那个身份验证逻辑,它将在 API 中的每次调用之前被调用。
我们的拦截器将被命名为authInterceptor。服务器端的拦截器将简单地执行我们在上一节中做的所有检查,如果一切顺利,将启动端点的执行。否则,拦截器将返回错误,端点将不会被调用。
要定义服务器端拦截器,我们有两种可能性。第一种是在我们使用单一 RPC 端点(例如,AddTasks)时使用。拦截器函数将如下所示:
func unaryInterceptor(ctx context.Context, req interface{}, info
*grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error)
然后我们有了在流上工作的拦截器。它们看起来如下所示:
func streamInterceptor(srv interface{}, ss grpc.ServerStream, info
*grpc.StreamServerInfo, handler grpc.StreamHandler) error
它们看起来非常相似。主要区别是参数的类型。现在,我们不会使用所有参数来处理我们的用例,所以我鼓励您查看文档(pkg.go.dev/google.golang.org/grpc)中的UnaryServerInterceptor和StreamServerInterceptor,并尝试使用它们。
让我们从 AddTasks 将会使用的单一拦截器开始。我们首先将检查提取到一个函数中,该函数将在拦截器之间共享。在一个名为 interceptors.go 的文件中,我们可以编写以下内容:
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
const authTokenKey string = "auth_token"
const authTokenValue string = "authd"
func validateAuthToken(ctx context.Context) error {
md, _ := metadata.FromIncomingContext(ctx)
if t, ok := md[authTokenKey]; ok {
switch {
case len(t) != 1:
return status.Errorf(
codes.InvalidArgument,
fmt.Sprintf("%s should contain only 1 value", authTokenKey),
)
case t[0] != authTokenValue:
return status.Errorf(
codes.Unauthenticated,
fmt.Sprintf("incorrect %s", authTokenKey),
)
}
} else {
return status.Errorf(
codes.Unauthenticated,
fmt.Sprintf("failed to get %s", authTokenKey),
)
}
return nil
}
这与我们直接在 UpdateTasks 中所做的没有区别。但现在,编写我们的拦截器很简单。我们只需调用 validateAuthToken 函数并检查错误。如果有错误,我们将直接返回它。如果没有错误,我们将调用 handler 函数,这实际上会调用端点:
func unaryAuthInterceptor(ctx context.Context, req interface{}, info
*grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if err := validateAuthToken(ctx); err != nil {
return nil, err
}
return handler(ctx, req)
}
我们可以对流做同样的操作。唯一会改变的是处理器的参数以及我们如何获取上下文:
func streamAuthInterceptor(srv interface{}, ss grpc.ServerStream, info
*grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := validateAuthToken(ss.Context()); err != nil {
return err
}
return handler(srv, ss)
}
现在,你可能认为我们有函数,但没有人来调用它们。你完全正确。我们需要注册这些拦截器,以便我们的服务器知道它们的存在。这是在 server/main.go 中完成的,在那里我们可以将拦截器作为选项添加到 gRPC 服务器中。目前,我们创建服务器的方式如下:
var opts []grpc.ServerOption
s := grpc.NewServer(opts...)
要添加拦截器,我们只需将它们添加到 opts 变量中:
opts := []grpc.ServerOption{
grpc.UnaryInterceptor(unaryAuthInterceptor),
grpc.StreamInterceptor(streamAuthInterceptor),
}
我们现在可以运行服务器:
重要提示
在运行服务器之前,您可以在 server/impl.go 中的 UpdateTasks 函数中删除整个认证逻辑。由于拦截器将自动认证请求,这不再需要。
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,我们可以运行客户端:
$ go run ./client 0.0.0.0:50051
--------ADD--------
rpc error: code = Unauthenticated desc = failed to get auth_token
exit status 1
如预期的那样,我们得到了一个错误,因为我们从未在客户端的 addTask 函数中添加 auth_token 标题。
显然,我们不想手动将标题添加到所有的调用中。我们将创建一个客户端拦截器,在发送请求之前为我们添加它。在客户端,我们有两种定义拦截器的方式。对于单一调用,我们有以下内容:
func unaryInterceptor(ctx context.Context, method string, req
interface{}, reply interface{}, cc *grpc.ClientConn, invoker grpc.
UnaryInvoker, opts ...grpc.CallOption) error
对于流,我们有以下内容:
func streamInterceptor(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error)
如您所见,这一侧有更多的参数。而且,尽管大多数参数对我们用例来说并不重要,但我鼓励您查看 UnaryClientInterceptor 和 StreamClientInterceptor 的文档 (pkg.go.dev/google.golang.org/grpc),并尝试使用它们。
在客户端拦截器中,我们将简单地创建一个新的上下文,并在调用端点之前附加元数据。我们甚至不需要创建一个单独的函数来共享逻辑,因为这就像调用我们之前看到的 AppendToOutgoingContext 函数一样简单。
在 client/interceptors.go 中,我们可以编写以下内容:
import (
"context"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
const authTokenKey string = "auth_token"
const authTokenValue string = "authd"
func unaryAuthInterceptor(ctx context.Context, method string, req,
reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker,
opts ...grpc.CallOption) error {
ctx = metadata.AppendToOutgoingContext(ctx, authTokenKey,
authTokenValue)
err := invoker(ctx, method, req, reply, cc, opts...)
return err
}
func streamAuthInterceptor(ctx context.Context, desc *grpc.StreamDesc,
cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
ctx = metadata.AppendToOutgoingContext(ctx, authTokenKey, authTokenValue)
s, err := streamer(ctx, desc, cc, method, opts...)
if err != nil {
return nil, err
}
return s, nil
}
最后,就像在服务器中一样,我们还需要注册这些拦截器。这次,这些拦截器将通过将 DialOptions 添加到我们在 main 中使用的 Dial 函数来注册。目前,你应该有如下内容:
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
}
我们现在可以添加拦截器如下:
opts := []grpc.DialOption{
//...
grpc.WithUnaryInterceptor(unaryAuthInterceptor),
grpc.WithStreamInterceptor(streamAuthInterceptor),
}
当它们被注册后,我们可以运行我们的服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,我们可以运行客户端:
重要提示
在运行客户端之前,你可以在client/main.go文件中的updateTask中删除对AppendToOutgoingContext的调用。由于拦截器会自动执行,所以这不再需要。
$ go run ./client 0.0.0.0:50051
现在所有的调用都应该没有错误地通过。
总结来说,在本节中,我们看到了我们可以在服务器和客户端的两侧编写单一和流拦截器。这些拦截器的目标是自动在多个端点之间执行一些重复性工作。在我们的例子中,我们自动化了auth_token头部的添加和检查以进行身份验证。
压缩有效载荷
虽然 Protobuf 将数据序列化为二进制格式,这比文本数据涉及更小的有效载荷,但我们可以在二进制数据上应用压缩。gRPC 为我们提供了 gzip Compressor (pkg.go.dev/google.golang.org/grpc/encoding/gzip),并且对于更高级的使用场景,允许我们编写自己的 Compressor (pkg.go.dev/google.golang.org/grpc/encoding)。
在深入探讨如何使用 gzip Compressor 之前,重要的是要理解无损压缩可能会导致更大的有效载荷大小。如果你的有效载荷不包含重复数据,这正是 gzip 检测并压缩的数据,你将发送比所需的更多字节。所以,你需要对典型的有效载荷进行实验,看看 gzip 如何影响其大小。
为了展示一个例子,我在helpers文件夹中包含了一个名为gzip.go的文件,该文件包含一个名为compressedSize的辅助函数。这个函数返回序列化数据的原始大小及其经过 gzip 压缩后的大小:
func compressedSizeM protoreflect.ProtoMessage (int, int) {
var b bytes.Buffer
gz := gzip.NewWriter(&b)
out, err:= proto.Marshal(msg)
if err != nil {
log.Fatal(err)
}
if _, err := gz.Write(out); err != nil {
log.Fatal(err)
}
if err := gz.Close(); err != nil {
log.Fatal(err)
}
return len(out), len(b.Bytes())
}
由于这是一个通用函数,我们可以用它来处理任何消息。我们可以从一个不适合压缩的消息开始:Int32Value。所以,在文件的main函数中,我们将创建一个Int32Value实例,通过compressedSize函数传递它,并将打印原始大小和新大小:
func main() {
var data int32 = 268_435_456
i32 := &wrapperspb.Int32Value{
Value: data,
}
o, c := compressedSize(i32)
fmt.Printf("original: %d\ncompressed: %d\n", o, c)
}
如果我们运行这个程序,我们应该得到以下结果:
$ go run gzip.go
original: 6
compressed: 30
压缩后的有效载荷是原始大小的五倍。这绝对是在生产环境中需要避免的事情。显然,大多数时候,我们不会发送如此简单的消息,所以让我们看看一个更具体的例子。我们将使用本书中定义的Task消息:
syntax = "proto3";
package todo;
import "google/protobuf/timestamp.proto";
option go_package = "github.com/PacktPublishing/gRPC-Go-for-Professionals/helpers/proto";
message Task {
uint64 id = 1;
string description = 2;
bool done = 3;
google.protobuf.Timestamp due_date = 4;
}
然后,我们可以使用以下命令来编译它:
$ protoc --go_out=. \
--go_opt=module=github.com/PacktPublishing/
gRPC-Go-for-Professionals/helpers \
proto/todo.proto
然后,我们现在可以创建一个Task实例,并将compressedSize函数传递给它,以查看压缩的结果:
func main() {
task := &pb.Task{
Id: 1,
Description: "This is a task",
DueDate: timestamppb.New(time.Now().Add(5 * 24 *
time.Hour)),
}
o, c := compressedSize(task)
fmt.Printf("original: %d\ncompressed: %d\n", o, c)
}
如果我们运行它,我们应该得到以下大小:
$ go run gzip.go
original: 32
compressed: 57
这比之前的例子要好,但仍然不够高效,因为我们发送的字节比所需的更多。所以,在之前我们看到的情况下,使用 gzip 压缩是没有意义的。
最后,让我们看看压缩何时有用。假设我们的大部分Task实例都有很长的描述。例如,我们可能像这样:
task := &pb.Task{
//...
Description: `This is a task that is quite long and requires a lot
of work.
We are not sure we can finish it even after 5 days.
Some planning will be needed and a meeting is required.`,
//...
}
然后,运行compressedSize函数将给出以下大小:
$ go run gzip.go
original: 192
compressed: 183
这里的教训是,在 gRPC 中启用 gzip 压缩之前,我们需要了解我们的数据。现在,让我们看看如何启用它。
在服务器端(server/main.go),这就像添加以下导入一样简单:
_ "google.golang.org/grpc/encoding/gzip"
注意,我们在它前面添加一个下划线,以避免编译器错误,错误信息是我们在不使用导入的情况下操作。
那就是服务器端的全部内容。在客户端,代码稍微多一点,但这也很简单。我们可以通过添加DialOption来为所有 RPC 端点启用压缩,或者我们可以通过添加CallOption来为单个端点启用压缩(pkg.go.dev/google.golang.org/grpc#CallOption)。
对于第一个选项,我们可以简单地添加以下内容:
opts := []grpc.DialOption{
//...
grpc.WithDefaultCallOptions(grpc.UseCompres
sor(gzip.Name))
}
gzip 添加了与服务器中相同的作用,但没有前面的下划线。
而对于按调用添加压缩,我们可以添加CallOption。如果我们想将 gzip 压缩添加到AddTask调用中,我们会得到以下内容:
res, err := c.AddTask(context.Background(), req, grpc.UseCompressor(gzip.Name))
总结一下,我们看到了总是添加压缩不是一个好主意,我们应该在测试我们的数据后再添加它。然后,我们看到了如何在服务器和客户端注册 gzip 压缩器。最后,我们看到了我们可以全局或按调用启用压缩。
保护连接
到目前为止,我们还没有使我们的连接安全——我们使用了不安全的凭证。在 gRPC 中,我们可以使用 TLS、mTLS 和 ATLS 连接。第一个使用单向认证,客户端可以验证服务器的身份。第二个是双向通信,服务器验证客户端的身份,客户端验证服务器的。最后,ATLS 类似于 TLS,但设计和优化了 Google 的使用。
如果你正在处理较小规模的通信或与 Google Cloud 合作,那么 mTLS 和 ATLS 都值得探索。如果你对 mTLS 感兴趣,你应该检查grpc-go GitHub 仓库中的 mTLS 文件夹:github.com/grpc/grpc-go/tree/master/examples/features/encryption/mTLS。如果你想使用 ATLS,查看这个链接:grpc.io/docs/languages/go/alts/。然而,在我们的情况下,我们将看到最常用的加密形式,即 TLS。
要这样做,我们需要创建一些自签名的证书。显然,在生产环境中,这些证书将自动通过类似 Let’s Encrypt 的工具创建。然而,一旦这些证书可用,整体设置是相同的。
现在,为了简化,我们将从grpc-go存储库中的示例下载这些证书。这些证书也可以在chapter7文件夹下的certs目录中找到。我们首先需要获取服务器证书及其密钥:
$ curl https://raw.githubusercontent.com/grpc/grpc-go/master/examples/
data/x509/server_cert.pem --output server_cert.pem
$ curl https://raw.githubusercontent.com/grpc/grpc-go/master/examples/
data/x509/server_key.pem --output server_key.pem
然后我们需要获取证书颁发机构(CA)证书:
$ curl https://raw.githubusercontent.com/grpc/grpc-go/master/examples/
data/x509/ca_cert.pem --output ca_cert.pem
现在,我们可以从服务器开始。我们将添加凭证作为ServerOption,因为我们希望所有调用都加密。为了创建凭证,我们可以使用 gRPC 的credential包中的NewServerTLSFromFile函数。它读取两个文件,服务器证书和服务器密钥:
func main() {
//...
creds, err := credentials.NewServerTLSFrom
File("./certs/server_cert.pem", "./certs/server_key.pem")
if err != nil {
log.Fatalf("failed to create credentials: %v", err)
}
}
一旦创建,我们就可以使用grpc.Creds函数,该函数创建一个ServerOption:
opts := []grpc.ServerOption{
grpc.Creds(creds),
//...
}
让我们看看现在尝试运行服务器会发生什么:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,我们运行客户端:
$ go run ./client 0.0.0.0:50051
--------ADD--------
rpc error: code = Unavailable desc = connection error: desc = "error
reading server preface: EOF"
我们得到一个错误,基本上告诉我们客户端无法连接到服务器。为了解决这个问题,我们需要转到客户端并创建凭证的DialOption。
这次,我们将使用NewClientTLSFromFile函数,该函数接受 CA 证书。为了测试目的,我们将添加主机 URL 作为第二个参数(证书域是*.test.example.com)。
creds, err := credentials.NewClientTLSFromFile("./certs/ca_cert.pem",
"x.test.example.com")
if err != nil {
log.Fatalf("failed to load credentials: %v", err)
}
要添加凭证,我们使用一个名为 WithTransportCredentials的函数,该函数创建一个DialOption。
opts := []grpc.DialOption{
grpc.WithTransportCredentials(creds),
//grpc.WithTransportCredentials(insecure.NewCredentials())
//...
}
注意,我们移除了不安全的凭证,因为我们现在想要加密通信。
让我们现在重新运行服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后,我们对客户端做同样的操作:
$ go run ./client 0.0.0.0:50051
一切顺利——我们应该通过所有之前通过的调用,但现在我们的通信是安全的。
Bazel
为了使用 Bazel 运行我们在这个部分编写的代码,我们需要在BUILD文件中包含证书文件。这可以通过导出它们并将它们作为data添加到server_lib和client_lib目标中来实现。
要导出文件,我们需要在certs文件夹中创建一个BUILD.bazel文件,该文件包含以下内容:
exports_files([
"server_cert.pem",
"server_key.pem",
"ca_cert.pem"
])
然后,在服务器的BUILD文件中,我们现在可以添加对server_cert和server_key的依赖,如下所示(在server/BUILD.bazel中):
go_library(
name = "server_lib",
//...
data = [
"//certs:server_cert.pem",
"//certs:server_key.pem",
],
//...
)
最后,我们可以在客户端添加对ca_cert的依赖,如下所示(在client/BUILD.bazel中):
go_library(
name = "client_lib",
//...
data = [
"//certs:ca_cert.pem",
],
//...
)
你现在应该能够像前几章中展示的那样,使用 Bazel 正确运行服务器和客户端。
总结一下,我们了解到创建服务器端连接需要服务器证书和服务器密钥文件,而在客户端则需要 CA 证书。我们还使用了自签名证书,但在生产环境中,这些证书应由我们生成。最后,我们看到了如何创建ServerOption和DialOption以在 gRPC 中启用 TLS。
使用负载均衡分发请求
通常来说,负载均衡是一个复杂的话题。有许多种实现它的方法。gRPC 默认提供客户端负载均衡。相比于旁路或代理负载均衡,这并不是一个特别受欢迎的选择,因为它需要“知道”所有服务器的地址,并在客户端拥有复杂的逻辑,但它有一个优点,那就是可以直接与服务器通信,从而实现低延迟通信。如果你想了解更多关于如何为你的用例选择正确的负载均衡方法,请查看以下文档:grpc.io/blog/grpc-load-balancing/。
为了看到客户端负载均衡的力量,我们将部署我们的服务器在 Kubernetes 上的三个实例,并让客户端在这三个实例之间进行负载均衡。我事先创建了 Docker 镜像,这样我们就不必在这里经历所有这些步骤。如果你对检查 Docker 文件感兴趣,你可以在server和client文件夹中看到它们。它们有详细的文档。此外,我还将镜像上传到了 Docker Hub,这样我们就可以轻松地拉取它们(hub.docker.com/r/clementjean/grpc-go-packt-book/tags)。
在部署服务器和客户端之前,让我们看看在代码方面我们需要做哪些更改。在服务器端,我们将简单地打印出我们收到的每个请求。这是通过一个类似于以下拦截器来完成的(在server/interceptors.go中):
func unaryLogInterceptor(ctx context.Context, req interface{}, info
*grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
log.Println(info.FullMethod, "called")
return handler(ctx, req)
}
func streamLogInterceptor(srv interface{}, ss grpc.ServerStream, info
*grpc.StreamServerInfo, handler grpc.StreamHandler) error {
log.Println(info.FullMethod, "called")
return handler(srv, ss)
}
这只是简单地打印出被调用的方法并继续执行。
之后,这些拦截器需要在拦截器链中注册。这是因为我们已经有了一个认证拦截器,而 gRPC 只接受一个grpc.UnaryInterceptor和grpc.StreamInterceptor的调用。现在,我们可以在server/main.go中将相同类型(单一或流)的两个拦截器合并:
opts := []grpc.ServerOption{
//...
grpc.ChainUnaryInterceptor(unaryAuthInterceptor,
unaryLogInterceptor),
grpc.ChainStreamInterceptor(streamAuthInterceptor,
streamLogInterceptor),
}
服务器端的介绍就到这里。现在让我们专注于客户端。我们将使用grpc.WithDefaultServiceConfig函数添加一个DialOption。这个函数需要一个 JSON 字符串作为参数,它代表服务及其方法的全局客户端配置。如果你对深入了解配置感兴趣,可以查看以下文档:github.com/grpc/grpc/blob/master/doc/service_config.md。
对于我们来说,配置将会很简单;我们只需说明我们的客户端应该使用round_robin负载均衡策略。默认策略被称为pick_first。这意味着客户端将尝试连接到所有可用的地址(由 DNS 解析),一旦它能够连接到一个地址,它将把所有请求发送到该地址。round_robin则不同。它将尝试连接到所有可用的地址。然后,它将依次将请求转发到每个服务器。
要设置 round_robin 负载均衡,我们只需要在 client/main.go 中添加一个 DialOption,如下所示:
opts := []grpc.DialOption{
//...
grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":
[{"round_robin":{}}]}`),
}
最后,有一点需要注意,负载均衡只与 DNS 方案一起工作。这意味着我们将改变运行客户端的方式。之前,我们有以下内容:
$ go run ./client 0.0.0.0:50051
现在,我们需要在前面添加 dns:/// 方案,如下所示:
$ go run ./client dns:///$HOSTNAME:50051
现在,我们准备讨论部署我们的应用程序。让我们开始部署服务器。我们首先需要的是一个无头服务。这是通过将 ClusterIP 设置为 None 来实现的,这允许客户端通过 DNS 找到所有服务器实例。每个服务器实例都将有自己的 DNS A 记录,该记录指示实例的 IP。在此基础上,我们将向服务器公开端口 50051 并使选择器等于 todo-server,这样所有具有该选择器的 Pods 都将被公开。
目前,在 k8s/server.yaml 中,我们有以下内容:
apiVersion: v1
kind: Service
metadata:
name: todo-server
spec:
clusterIP: None
ports:
- name: grpc
port: 50051
selector:
app: todo-server
之后,我们将创建一个包含 3 个实例的 Deployment。我们将确保这些 Deployment 有正确的标签,以便服务能够找到它们,并且我们将公开端口 50051。
我们现在可以在服务之后添加以下内容:
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: todo-server
labels:
app: todo-server
spec:
replicas: 3
selector:
matchLabels:
app: todo-server
template:
metadata:
labels:
app: todo-server
spec:
containers:
- name: todo-server
image: clementjean/grpc-go-packt-book:server
ports:
- name: grpc
containerPort: 50051
我们现在可以通过以下命令部署服务器实例:
$ kubectl apply -f k8s/server.yaml
稍后,我们应该有以下的 Pods(名称可能不同):
$ kubectl get pods
NAME READY STATUS
todo-server-85cf594fb6-tkqm9 1/1 Running
todo-server-85cf594fb6-vff6q 1/1 Running
todo-server-85cf594fb6-w4s6l 1/1 Running
接下来,我们需要为客户端创建一个 Pod。通常,如果客户端不是一个微服务,我们就不需要将其部署到 Kubernetes 中。然而,由于我们的客户端是一个简单的 Go 应用程序,将其部署到容器中与我们的服务器实例通信会更容易一些。
在 k8s/client.yaml 中,我们有以下简单的 Pod:
apiVersion: v1
kind: Pod
metadata:
name: todo-client
spec:
containers:
- name: todo-client
image: clementjean/grpc-go-packt-book:client
restartPolicy: Never
我们现在可以通过以下命令运行客户端:
$ kubectl apply -f k8s/client.yaml
几秒钟后,我们应该得到类似的输出(或者错误而不是完成):
$ kubectl get pods
NAME READY STATUS
todo-client 0/1 Completed
现在,最重要的是看到负载均衡的实际效果。为了做到这一点,我们将对每个服务器名称执行一个 kubectl logs 命令:
$ kubectl logs todo-server-85cf594fb6-tkqm9
listening at 0.0.0.0:50051
/todo.v2.TodoService/UpdateTasks called
/todo.v2.TodoService/ListTasks called
$ kubectl logs todo-server-85cf594fb6-vff6q
listening at 0.0.0.0:50051
/todo.v2.TodoService/DeleteTasks called
$ kubectl logs todo-server-85cf594fb6-w4s6l
listening at 0.0.0.0:50051
/todo.v2.TodoService/AddTask called
/todo.v2.TodoService/AddTask called
/todo.v2.TodoService/AddTask called
/todo.v2.TodoService/ListTasks called
/todo.v2.TodoService/ListTasks called
现在,你可能会有不同的结果,但你应该能够看到负载被分配到不同的实例上。还有一点需要注意,因为我们没有使用真实的数据库,所以 todo-client 的日志可能是不正确的。这是因为我们可能在服务器 1 上有一个 Task,并要求列出服务器 2 的 Task,而服务器 2 并不知道我们想要的 Task。在生产环境中,我们会使用真实的数据库,这种情况不应该发生。
总结来说,我们看到了默认的负载均衡策略是 pick_first,它尝试按顺序连接到所有可用的地址,直到找到一个可到达的地址,并将所有请求发送给它。然后,我们使用了一个 round_robin 负载均衡策略,它依次将请求发送到每个服务器。最后,我们看到了在 gRPC 代码中设置客户端负载均衡是简单的。其余的配置主要是 DevOps 的工作。
摘要
在本章中,我们看到了使用 gRPC 时默认获得的关键特性。我们了解到我们通过错误代码和消息返回错误。与 HTTP 相比,gRPC 中的错误代码要少得多,这使得它们更不容易产生歧义。
之后,我们看到了我们可以使用上下文来使调用可取消并指定截止日期。这些特性对于确保在服务器端返回之前如果出现问题,我们的客户端不会无限期地等待,以及进行可靠调用非常重要。
使用上下文和中间件,我们还看到了我们可以发送元数据并使用它们来验证请求。在我们的案例中,我们每次请求时都会检查认证令牌。在客户端,我们看到了中间件可以自动为我们添加元数据。这对于在服务和/或端点之间共享的元数据特别有用。
然后,我们看到了如何加密网络通信。我们使用了 TLS,因为这是最常见的加密方式。我们看到了一旦我们有了证书,我们就可以简单地创建一个ServerOption和一个DialOption,让服务器和客户端知道如何相互理解。
之后,我们看到了如何压缩数据包。最重要的是,我们看到了何时这可能是有用的,何时则不是。
最后,我们使用了客户端负载均衡,采用round_robin策略,将请求分配到我们服务器的不同实例。
在下一章中,我们将看到更多与本章类似的重要特性。我们将介绍中间件的概念,并了解如何使用不同类型的中间件来使我们的 API 更加稳固。
测验
-
上下文用于什么?
-
在客户端和服务器之间传递元数据
-
使调用可取消
-
指定超时
-
所有上述内容
-
-
中间件用于什么?
-
在端点之间共享逻辑
-
拦截恶意数据
-
-
使用 gRPC 中压缩的潜在问题是什么?
-
没有问题
-
存在数据包损坏的可能性
-
存在数据包变大的可能性
-
答案
-
D
-
A
-
C
挑战
-
在服务器端实现更多错误。一个例子可能是处理来自
updateTask和deleteTasks的错误,它们正在与数据库通信。 -
由于截止日期可以节省时间和资源,因此指定它们很重要。确保所有调用到我们的客户端都有 200 毫秒的截止日期。
-
创建一个客户端中间件,用于记录客户端发送的请求。
第八章:更多重要功能
我们之前看到 gRPC 为我们提供了许多重要的开箱即用的功能,使我们的工作变得更简单。在本章中,我们将深入了解一些 gRPC 未提供但由社区提供的功能。它们通常建立在 gRPC 功能之上,以提供更多便利。它们还提供了一种实现最常见实践的方法,以保护和优化您的 API。
在本章中,我们将介绍以下主要内容:
-
验证请求消息
-
创建中间件
-
验证请求
-
跟踪 API 调用
-
应用速率限制
-
错误重试
到本章结束时,我们将了解中间件是什么以及它们的作用。我们将通过学习名为protoc-gen-validate和go-grpc-middleware的出色社区项目来实现这一点。
技术要求
对于本章,您可以在附带的 GitHub 仓库中找到相关代码,文件夹名为chapter8(github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter8)。
验证请求
我们将要做的第一件事是减少检查请求消息某些属性的代码。我们将使用protoc的protoc-gen-validate插件,该插件帮助我们为某些消息生成验证代码。这对于检查任务描述长度和截止日期的使用场景非常有用。我们只需调用生成的Validate()函数,它就会告诉我们请求消息的要求是否得到满足。
我们要生成此代码的第一件事是安装插件。这是一个由 Buf 维护的插件,您可以这样获取它:
$ go install github.com/envoyproxy/protoc-gen-validate
一旦我们有了这个,我们现在就可以使用 protoc 的--validate_out选项了。
现在,无论我们是手动使用 protoc 还是使用 Buf CLI,我们都需要从 GitHub 仓库复制validate.proto文件。此文件可以在以下位置找到:github.com/bufbuild/protoc-gen-validate/blob/main/validate/validate.proto。我们将将其复制到validate目录下的proto文件夹中:
proto
└── validate
└── validate.proto
现在,我们可以将此文件导入其他 proto 文件,并使用提供的验证规则作为字段选项。
让我们以proto/todo/v2/todo.proto中的AddTaskRequest为例。目前,我们有以下内容:
message AddTaskRequest {
string description = 1;
google.protobuf.Timestamp due_date = 2;
}
如我们所知,每次我们尝试在服务器端添加一个任务时,我们都会检查描述是否为空,以及due_date是否大于time.Now()。
我们现在将把这个逻辑编码到我们的 proto 文件中。首先,我们需要做的是导入 validate.proto 文件。然后,我们将能够访问 validate.rules 字段选项,它包含多个类型的规则集。我们将处理 string 和 Timestamp,我们将使用 min_len 和 gt_now 字段。第一个描述了在调用 Validate 时字符串应该具有的最小长度,第二个告诉我们提供的 Timestamp 应该在未来:
import "validate/validate.proto";
//...
message AddTaskRequest {
string description = 1 [
(validate.rules).string.min_len = 1
];
google.protobuf.Timestamp due_date = 2 [
(validate.rules).timestamp.gt_now = true
];
}
现在我们已经描述了这个逻辑,我们需要生成检查这个逻辑的代码。否则,这些选项毫无价值。为了生成此代码,我们将手动使用 protoc,然后我会向你展示如何使用 Buf 和 Bazel 来完成:
如前所述,我们可以使用插件在 protoc 中使用 --validate_out 选项。它看起来如下所示:
$ protoc -Iproto --go_out=proto --go_opt=paths=
source_relative --go-grpc_out=proto --go-grpc_opt=
paths=source_relative --validate_out=
"lang=go,paths=source_relative:proto" proto/todo/v2/*.proto
注意到命令与我们过去运行的类似。我们只是添加了新的选项,并告诉它处理 Go 代码,并基于 v2 文件夹中的 proto 文件生成代码。
现在,在 Protobuf 和 gRPC 生成的代码之上,你应该在 v2 文件夹中有一个 .pb.validate.go 文件。它应该看起来像这样:
proto/todo/v2
├── todo.pb.go
├── todo.pb.validate.go
├── todo.proto
└── todo_grpc.pb.go
在生成的文件中,你应该能够看到以下函数(以及其他函数):
// Validate checks the field values on Task with the rules
defined in the proto
// definition for this message. If any rules are violated,
the first error
// encountered is returned, or nil if there are no
violations.
func (m *Task) Validate() error {
return m.validate(false)
}
这是我们要在服务器端的 AddTask 端点使用的函数。目前,我们有以下检查:
func (s *server) AddTask(_ context.Context, in
*pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
if len(in.Description) == 0 {
return nil, status.Error(
codes.InvalidArgument,
"expected a task description, got an empty string",
)
}
if in.DueDate.AsTime().Before(time.Now().UTC()) {
return nil, status.Error(
codes.InvalidArgument,
"expected a task due_date that is in the future",
)
}
//...
}
让我们用 Validate 函数来替换它。我们只是简单地在 in 参数上调用该函数,如果它返回任何错误,我们将从函数返回错误,否则,我们将简单地继续我们的执行:
func (s *server) AddTask(_ context.Context, in
*pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
if err := in.Validate(); err != nil {
return nil, err
}
//...
}
如此简单,我们就避免了手动编写所有检查并尝试在不同端点保持错误信息一致。
我们现在可以进入 main 客户端,并逐个取消错误部分中的函数注释:
func main() {
//...
fmt.Println("-------ERROR-------")
addTask(c, "", dueDate)
addTask(c, "not empty", time.Now().Add(-5*time.Second))
fmt.Println("-------------------")
}
我们应该为第一个 addTask 获得以下错误:
$ go run ./client 0.0.0.0:50051
-------ERROR-------
rpc error: code = Unknown desc = invalid AddTaskRequest
.Description: value length must be at least 1 runes
这是为第二个 addTask 添加的:
$ go run ./client 0.0.0.0:50051
-------ERROR-------
rpc error: code = Unknown desc = invalid AddTaskRequest
.DueDate: value must be greater than now
注意到代码错误是 Unknown。截至本书编写时,protoc-gen-validate 似乎没有自定义错误代码。这可能在插件的 v2 版本中出现。然而,它为我们提供了一个简单的验证代码和清晰的错误信息。
Buf
使用 Buf CLI 的 protoc-gen-validate 非常简单。我们将在我们的 YAML 文件中添加一些配置以生成代码。首先,我们需要在我们的 buf.yaml 文件中添加对 protoc-gen-validate 的依赖:
version: v1
#...
deps:
- buf.build/envoyproxy/protoc-gen-validate
这告诉 Buf 在生成过程中需要 protoc-gen-validate。它将稍后自己找出如何拉取依赖。
之后,我们需要在 buf.gen.yaml 文件中配置插件:
version: v1
plugins:
#...
- plugin: buf.build/bufbuild/validate-go
out: proto
opt: paths=source_relative
这些选项与我们之前手动输入的相同。现在,我们可以通过输入以下命令来常规生成:
$ buf generate proto
你现在应该拥有与使用 protoc 命令获得相同的三个生成文件:todo.pb.validate.go、todo_grpc.pb.go 和 todo.pb.go。注意,在这种情况下,我们还为 v1 和 validate.proto 生成了代码。
Bazel
和往常一样,我们首先需要在 WORKSPACE.bazel 文件中定义依赖项。我们将从 GitHub 获取 protoc-gen-validate 项目并加载其相关依赖项:
重要提示
下面的代码引用了一个名为 PROTOC_GEN_VALIDATE_VERSION 的变量。这个变量在 chapter8 文件夹中的 versions.bzl 文件中定义。我们在这里不包括它,以保持代码与版本无关。
#...
git_repository(
name = "com_envoyproxy_protoc_gen_validate",
tag = PROTOC_GEN_VALIDATE_VERSIO**N**,
remote = "https://github.com/bufbuild/protoc-gen-validate"
)
load("@com_envoyproxy_protoc_gen_validate//bazel:
repositories.bzl", "pgv_dependencies")
load("@com_envoyproxy_protoc_gen_validate//
:dependencies.bzl", "go_third_party")
pgv_dependencies()
# gazelle:repository_macro deps.bzl%go_third_party
go_third_party()
这样,我们现在需要更新 deps.bzl 文件中的依赖项。我们可以通过输入以下命令来完成:
$ bazel run //:gazelle-update-repos
最后,我们需要生成代码并将其链接到我们现有的 proto/todo/v2/BUILD.bazel 中的 todo go_library。
首先需要添加对 v2_proto proto_library 中 protoc-gen-validate validate.proto 的依赖。这将允许 todo.proto 导入它:
proto_library(
name = "v2_proto",
#…
deps = [
#...
"@com_envoyproxy_protoc_gen_validate//
validate:validate_proto",
],
)
然后,我们将用 pgv_go_proto_library 替换 v2_go_proto go_proto_library (protoc-gen-validate 库,以便生成的代码可以访问编译所需的任何 protoc-gen-validate 内部代码:
load("@com_envoyproxy_protoc_gen_validate//bazel:pgv_proto_
library.bzl", "pgv_go_proto_library")
//...
pgv_go_proto_library(
name = "v2_go_proto",
compilers = ["@io_bazel_rules_go//proto:go_grpc"],
importpath = "github.com/PacktPublishing/gRPC-Go-for-Professionals/
proto/todo/v2",
proto = ":v2_proto",
deps = ["@com_envoyproxy_protoc_gen_validate//
validate:validate_go"],
)
最后,为了避免在下次运行 Gazelle 时对 validate/validate.proto 的模糊导入,我们将 validate/validate.proto 的导入(在 proto/todo/v2/todo.proto 中)映射到 @com_envoyproxy_protoc_gen_validate//validate:validate_proto(在 protoc-gen-validate 中定义)。在 proto/todo/v2/BUILD.bazel 文件顶部,我们可以添加以下 Gazelle 指令:
# gazelle:resolve proto validate/validate.proto
@com_envoyproxy_protoc_gen_validate//validate:
validate_proto
现在我们已经用 pgv_go_proto_library 替换了旧的 v2_go_proto,依赖于这个库的代码将自动获得对生成的 Validate 函数的访问。
我们可以尝试运行服务器:
$ bazel run //server:server 0.0.0.0:50051
listening at 0.0.0.0:50051
然后运行带有取消注释的错误部分代码的客户端:
$ bazel run //client:client 0.0.0.0:50051
-------ERROR-------
rpc error: code = Unknown desc = invalid AddTaskRequest
.Description: value length must be at least 1 runes
总结来说,我们看到了我们可以在 proto 文件中编码验证逻辑,并使用 protoc-gen-validate 自动生成验证代码。这简化了我们的代码,并在我们的 API 端点提供了一致的错误消息。
中间件 = 拦截器
在 gRPC 的上下文中,一个中间件是一个拦截器。它位于开发人员注册的代码和实际的 gRPC 框架之间。当 gRPC 从线路上接收到一些数据时,它首先将数据通过中间件传递,然后如果允许通过,数据将到达实际的端点处理器。
这些中间件通常用于保护端点免受恶意行为者的攻击或强制执行某些先决条件。一个保护 API 的例子是限制客户端的速率。这是在给定时间段内客户端可以发出的请求数量的限制,这是很重要的,因为它可以防止许多攻击,如暴力攻击、DoS 和 DDoS 攻击,以及网页抓取。为了强制执行某些先决条件,我们已经看到了一个例子,其中客户端在能够调用端点之前需要被认证。
在查看社区提供的中间件之前,我想提醒你,我们已经在第七章中创建了中间件。我们只是从未把它们称为中间件。实际上,我们创建了两个,如下所示:
opts := []grpc.ServerOption{
//...
grpc.ChainUnaryInterceptor(unaryAuthInterceptor,
unaryLogInterceptor),
grpc.ChainStreamInterceptor(streamAuthInterceptor,
streamLogInterceptor),
}
如果你还记得,这些中间件首先会检查是否存在一个值为authd的auth_token头,并且该头存在。然后,如果情况如此,它将在终端上记录 API 调用并继续执行我们为 API 端点编写的代码。
总结一下,中间件是一个可以根据某些条件中断执行的拦截器,它的作用是保护 API 端点。
验证请求
在本节和接下来的内容中,我们将简化我们目前拥有的中间件。首先,我们将从简化认证过程开始。我们在上一章中看到,我们可以轻松地创建一个检查头中认证令牌的拦截器。在本节中,我们将更进一步,使其更加简单。
重要提示
gRPC 支持通过 RBAC 策略重试请求的认证,而不需要第三方库。然而,配置相当冗长,并且没有很好的文档记录。如果你有兴趣尝试,可以查看以下示例:github.com/grpc/grpc-go/blob/master/examples/features/authz/README.md。
在之前编写拦截器时,我们需要为单一拦截器创建以下函数:
func unaryAuthInterceptor(ctx context.Context, req
interface{}, info *grpc.UnaryServerInfo, handler
grpc.UnaryHandler) (interface{}, error)
以及以下类似的流拦截器:
func streamAuthInterceptor(srv interface{}, ss
grpc.ServerStream, info *grpc.StreamServerInfo, handler
grpc.StreamHandler) error
虽然这为我们提供了关于调用、上下文等信息,但也使得我们的代码变得简短,我们需要考虑如何在流拦截器和单一拦截器之间共享常见的业务逻辑。
通过我们将要添加的中间件,我们将专注于我们的逻辑,并且我们将在我们的 gRPC 服务器中像以前一样轻松地注册拦截器。这个中间件是 GitHub 仓库中名为 go-grpc-middleware 的认证中间件(github.com/grpc-ecosystem/go-grpc-middleware)。它将使我们摆脱为拦截器添加的复杂认证函数定义,并允许我们直接使用预定义的拦截器中的 validateAuthToken 函数进行注册。
要开始,我们将从我们的 server 文件夹中获取依赖项:
$ go get github.com/grpc-ecosystem/go-grpc-middleware/v2/
interceptors/auth
然后,我们将从 server/interceptors.go 文件中移除 unaryAuthInterceptor 和 streamAuthInterceptor。由于新的认证中间件将为我们处理一切,所以我们不再需要它们。
最后,我们将前往 server/main.go,在那里我们将用 auth.UnaryServerInterceptor 和 auth.StreamServerInterceptor 替换旧的拦截器。这两个拦截器接受一个 AuthFunc,它基本上代表了认证逻辑。在我们的情况下,我们将传递我们的 validateAuthToken。
AuthFunc 类型看起来是这样的:
type AuthFunc func(ctx context.Context) (context.Context,
error)
因此,我们需要稍微修改 validateAuthToken 以返回一个上下文和/或一个错误。我们新的函数将如下所示:
func validateAuthToken(ctx context.Context)
(context.Context, error) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Errorf(/*...*/)
}
if t, ok := md["auth_token"]; ok {
switch {
case len(t) != 1:
return nil, status.Errorf(/*...*/)
case t[0] != "authd":
return nil, status.Errorf(/*...*/)
}
} else {
return nil, status.Errorf(/*...*/)
}
return ctx, nil
}
这使我们能够在 gRPC 服务器中注册 validateAuthToken。我们新的主函数现在将如下所示:
import (
//...
"github.com/grpc-ecosystem/go-grpc-middleware/v2/
interceptors/auth"
)
func main() {
//...
opts := []grpc.ServerOption{
//...
grpc.ChainUnaryInterceptor
(auth.UnaryServerInterceptor(validateAuthToken),
unaryLogInterceptor),
grpc.ChainStreamInterceptor(auth
.StreamServerInterceptor(validateAuthToken),
streamLogInterceptor),
}
//...
}
现在,我们应该能够运行服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
我们还运行了客户端:
$ go run ./client 0.0.0.0:50051
我们没有收到任何错误,并且它的工作方式与之前相似。然而,为了测试中间件是否正常工作,我们可以临时修改客户端的拦截器以添加错误的认证头(client/interceptors.go):
const authTokenValue string = "notauthd"
如果我们重新运行客户端,我们应该得到以下错误:
$ go run ./client 0.0.0.0:50051
--------ADD--------
rpc error: code = Unauthenticated desc = incorrect
auth_token
这证明了我们的中间件按预期工作,并且我们可以仅依靠 validAuthToken 来进行认证检查。
Bazel
为了使用 Bazel 运行它,我们需要更新我们的依赖项并将新的依赖项链接到 server/BUILD.bazel 中的 server_lib 目标。因此,我们首先运行 gazelle-update-repos 命令,这将获取 go-grpc-middleware 依赖项:
$ bazel run //:gazelle-update-repos
一旦我们有了这个,我们现在可以让 gazelle 命令将 go-grpc-middleware 依赖项包含到目标中:
$ bazel run //:gazelle
最后,我们将能够运行我们的服务器:
$ bazel run //server:server 0.0.0.0:50051
listening at 0.0.0.0:50051
并且带有错误 auth 令牌的客户端应该给出以下信息:
$ bazel run //client:client 0.0.0.0:50051
--------ADD--------
rpc error: code = Unauthenticated desc = incorrect
auth_token
总结来说,在本节中,我们看到了我们可以通过使用 go-grpc-middleware 包来简化认证拦截器。它让我们专注于实际的逻辑,而不是如何编写可以注册到 gRPC 的拦截器。
记录 API 调用
在本节中,让我们简化日志拦截器。这就像我们在上一节中所做的那样,但我们将使用另一个中间件:日志中间件。
虽然这个中间件与许多不同的日志记录器集成,但我们将使用它与 Golang 的默认 log 包。这样,集成您喜欢的日志记录器将变得容易。
重要提示
下一个命令仅在您没有获取 go-grpc-middleware 的上一个依赖项时才需要。如果您是按章节顺序阅读的,那么您应该不需要它。
要开始,让我们获取中间件的依赖项。在 server 文件夹中,我们将运行以下命令:
$ go get github.com/grpc-ecosystem/go-grpc-middleware/v2/
interceptors/logging
现在,我们可以开始创建我们的日志记录器。我们将通过定义一个返回 loggerFunc 的函数来创建它。这是一个具有以下签名的函数:
func(ctx context.Context, lvl logging.Level, msg string,
fields ...any)
我们已经知道上下文是什么,但所有其余的都是针对日志记录器的。级别是一个日志级别,如 Debug、Info、Warning 或 Error。这通常用于根据严重程度过滤日志。然后,消息只是一个由日志中间件生成的消息,如 ":started call" 或 ":finished call"。这有助于我们理解日志的上下文。最后,字段是我们需要打印有用日志的所有其他信息。在我们的情况下,我们将使用服务名称和方法名称。这将使我们能够创建如下日志:
INFO :started call todo.v2.TodoService UpdateTasks
一件难以理解的事情是 fields 参数。这是因为它被表示为 any 的 vararg。实际上,我们可以将其转换为映射,以获取特定的字段名称,如 grpc.service、grpc.method 等。为此,我们可以简单地编写以下代码:
f := make(map[string]any, len(fields)/2)
i := logging.Fields(fields).Iterator()
for i.Next() {
k, v := i.At()
f[k] = v
}
注意,我们正在创建一个长度为 len(fields)/2 的映射。这是因为 fields 参数中字段名称和它们的值是交错排列的。以下是一个例子:
grpc.service todo.v2.TodoService grpc.method ListTasks
你可以通过展开 vararg 来打印字段并亲自查看整个内容:
log.Println(fields...)
现在我们有了这个知识,我们可以继续编写日志记录器。我们将创建一个名为 logCalls 的函数,它接受一个 log.Logger(来自 golang 标准库)作为参数,并返回一个 logging.Logger(来自日志中间件)。日志记录器的逻辑将是检查日志级别,将消息的级别添加到前面,然后我们将服务名称和方法名称添加到整个消息中:
import (
//...
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
)
const grpcService = "grpc.service"
const grpcMethod = "grpc.method"
func logCalls(l *log.Logger) logging.Logger {
return logging.LoggerFunc(func(_ context.Context, lvl
logging.Level, msg string, fields ...any) {
f := make(map[string]any, len(fields)/2)
i := logging.Fields(fields).Iterator()
for i.Next() {
k, v := i.At()
f[k] = v
}
switch lvl {
case logging.LevelDebug:
msg = fmt.Sprintf("DEBUG :%v", msg)
case logging.LevelInfo:
msg = fmt.Sprintf("INFO :%v", msg)
case logging.LevelWarn:
msg = fmt.Sprintf("WARN :%v", msg)
case logging.LevelError:
msg = fmt.Sprintf("ERROR :%v", msg)
default:
panic(fmt.Sprintf("unknown level %v", lvl))
}
l.Println(msg, f[grpcService], f[grpcMethod])
})
}
现在,虽然这个方法总是准确的,因为我们可以在构建的映射中检索键,但这意味着每次调用这个拦截器时,我们都需要构建一个映射。这并不真的高效。我想在你看到高效的例子之前先展示完整的例子,这样你就能理解如何使用 fields 参数。
为了更高效,我们可以利用我们的服务和方法是始终位于索引 5 和 7 的这一事实。因此,我们将移除映射创建部分,我们将用 5 和 7 替换 grpcService 和 grpcMethod,并将访问 fields 的第 5 和第 7 个元素:
const grpcService = 5
const grpcMethod = 7
func logCalls(l *log.Logger) logging.Logger {
return logging.LoggerFunc(func(_ context.Context, lvl
logging.Level, msg string, fields ...any) {
// ...
l.Println(msg, fields[grpcService], fields[grpcMethod])
})
}
这要高效得多。现在,有一点需要提及的是,这不太安全。我们假设我们接收到的所有字段都将始终包含在相同索引处的service和method,并且我们的fields数组足够大。我们可以安全地假设,在撰写本文时,因为这些是始终按此顺序添加的常见字段。然而,如果库发生变化,你可能会尝试进行越界访问或获取不同的信息。请注意这一点。
最后一件我们需要做的事情是注册拦截器。这与我们之前所做的身份验证拦截器类似,但主要区别在于现在我们需要创建一个记录器并将其传递给logCalls函数。我们将使用 golang 的log.Logger,它在消息之前打印日期和时间。最后,我们将logCalls的结果传递给logging.UnaryServerInterceptor和logging.StreamServerInterceptor:
import (
//...
"github.com/grpc-ecosystem/go-grpc-middleware/v2/
interceptors/logging"
)
//...
func main() {
//...
logger := log.New(os.Stderr, "", log.Ldate|log.Ltime)
opts := []grpc.ServerOption{
//...
grpc.ChainUnaryInterceptor(
//...
logging.UnaryServerInterceptor(logCalls(logger)),
),
grpc.ChainStreamInterceptor(
//...
logging.StreamServerInterceptor(logCalls(logger)),
),
}
//...
}
之后,我们现在可以运行我们的服务器:
$ go run ./server 0.0.0.0:50051
listening at 0.0.0.0:50051
注意
在运行客户端之前,请确保将client/interceptors.go文件中authTokenValue的值替换为authd。
然后运行我们的客户端:
$ go run ./client 0.0.0.0:50051
如果我们检查服务器正在运行的终端,我们应该会看到一些类似以下的消息:
INFO :started call todo.v2.TodoService ListTasks
INFO :finished call todo.v2.TodoService ListTasks
总结来说,我们看到了,与身份验证中间件类似,我们只需在我们的 gRPC 服务器中添加一个记录器即可。我们还看到,通过将fields varargs转换为映射,我们可以访问比服务名和方法名更多的信息。最后,我们看到了一些字段在vararg中始终位于相同的位置,因此,我们不必为每个调用生成映射,可以直接通过索引访问信息。
跟踪 API 调用
在日志记录的基础上,它以开发者友好的方式简单地描述事件之外,你可能还需要获取可以被仪表板工具聚合的指标。这些指标可能包括每秒请求数、状态分布(Ok、Internal等),以及其他许多指标。在本节中,我们将使用 OpenTelemetry 和 Prometheus 对我们的代码进行仪表化,以便可以使用 Grafana 等工具创建仪表板。
首先要理解的是,我们将运行一个用于 Prometheus 指标的 HTTP 服务器。Prometheus 通过/metrics路由将指标暴露给外部工具,以便想要查询数据的工具可以了解所有可用的指标类型。
因此,为了创建这样的服务器,我们将获取 Prometheus 的 Go 库的依赖项。我们将通过获取对go-grpc-middleware/providers/prometheus的依赖来实现这一点。Prometheus Go库是这个库的传递依赖,我们仍然需要能够注册在Prometheus提供程序中定义的一些更多拦截器:
$ go get github.com/grpc-ecosystem/go-grpc-middleware/
providers/prometheus
现在,我们可以创建一个 HTTP 服务器,稍后它将被用来公开/metrics路由。我们将创建一个名为newMetricsServer的函数,它接受服务器运行地址:
重要提示
下面的代码解释了server/main.go文件中的每个部分。在这里显示整个文件可能会让人感到不知所措。因此,我们将遍历所有代码,你将能够在main.go中看到导入和整体结构。请注意,为了更好地解释,我们将在本节后面添加某些元素。如果你看到尚未展示的代码部分,请继续阅读,你将得到你所查看代码的解释。
func newMetricsServer(httpAddr string) *http.Server {
httpSrv := &http.Server{Addr: httpAddr}
m := http.NewServeMux()
httpSrv.Handler = m
return httpSrv
}
现在我们有了 HTTP 服务器,我们将重构main函数,并将 gRPC 服务器的创建分离到另一个函数中:
func newGrpcServer(lis net.Listener) (*grpc.Server, error) {
creds, err := credentials.NewServerTLSFromFile
("./certs/server_cert.pem", "./certs/server_key.pem")
if err != nil {
return nil, err
}
logger := log.New(os.Stderr, "", log.Ldate|log.Ltime)
opts := []grpc.ServerOption{
//...
}
s := grpc.NewServer(opts...)
pb.RegisterTodoServiceServer(s, &server{
d: New(),
})
return s, nil
}
实际上没有发生任何变化;我们只是将创建过程分离成一个函数,以便我们可以在以后并行运行两个服务器。不过,在着手做那之前,我们的main函数也应该包含两个地址作为参数。第一个是为 gRPC 服务器准备的,另一个是为 HTTP 服务器准备的:
func main() {
args := os.Args[1:]
if len(args) != 2 {
log.Fatalln("usage: server [GRPC_IP_ADDR]
[METRICS_IP_ADDR]")
}
grpcAddr := args[0]
httpAddr := args[1]
}
现在,我们可以处理同时运行两个服务器。我们将使用errgroup(pkg.go.dev/golang.org/x/sync/errgroup)包。它允许我们将多个 goroutines 添加到组中并等待它们。
我们首先需要为组创建一个上下文。我们将创建一个可取消的上下文,以便稍后我们可以释放服务器的资源:
ctx := context.Background()
ctx, cancel := context.WithCancel(ctx)
defer cancel()
接下来,我们可以开始处理SIGTERM信号。这是因为当我们想要退出两个服务器时,我们将按Ctrl + C。这将发送SIGTERM信号,我们期望服务器能够优雅地关闭。为了处理这一点,我们将创建一个通道,当接收到SIGTERM信号时,该通道将被释放:
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
之后,我们现在可以创建我们的两个服务器的组。我们首先将从我们创建的可取消上下文中创建组。然后,我们将使用Go(func() error)函数将 goroutines 添加到该组中。第一个 goroutine 将处理 gRPC 服务器的服务,第二个 goroutine 将处理 HTTP 服务器:
lis, err := net.Listen("tcp", grpcAddr)
if err != nil {
log.Fatalf("unexpected error: %v", err)
}
g, ctx := errgroup.WithContext(ctx)
grpcServer, err := newGrpcServer(lis)
if err != nil {
log.Fatalf("unexpected error: %v", err)
}
g.Go(func() error {
log.Printf("gRPC server listening at %s\n", grpcAddr)
if err := grpcServer.Serve(lis); err != nil {
log.Printf("failed to gRPC server: %v\n", err)
return err
}
log.Println("gRPC server shutdown")
return nil
})
metricsServer := newMetricsServer(httpAddr)
g.Go(func() error {
log.Printf("metrics server listening at %s\n", httpAddr)
if err := metricsServer.ListenAndServe(); err != nil &&
err != http.ErrServerClosed {
log.Printf("failed to serve metrics: %v\n", err)
return err
}
log.Println("metrics server shutdown")
return nil
})
现在我们有了组,我们可以等待上下文完成或等待quit通道接收事件:
select {
case <-quit:
break
case <-ctx.Done():
break
}
一旦收到这些事件之一,我们将通过确保上下文完成(调用cancel函数)来启动资源的释放,最后,我们可以等待组完成我们注册的所有 goroutines:
cancel()
timeoutCtx, timeoutCancel := context.WithTimeout(
context.Background(),
10*time.Second,
)
defer timeoutCancel()
log.Println("shutting down servers, please wait...")
grpcServer.GracefulStop()
metricsServer.Shutdown(timeoutCtx)
if err := g.Wait(); err != nil {
log.Fatal(err)
}
最后,作为本节最终目标,我们需要添加跟踪功能。度量服务器将公开度量路由,而 gRPC 服务器将收集度量并将它们添加到 Prometheus 注册表中。这个注册表是一个收集器的集合。我们将一个或多个收集器注册到其中,然后注册表将收集不同的度量,最后,它将公开这些度量。
在创建注册表之前,我们首先使用go-grpc-middleware/providers/prometheus包中提供的NewServerMetrics函数创建一个收集器。然后,我们将实际创建注册表。最后,我们将注册收集器:
srvMetrics := grpcprom.NewServerMetrics(
grpcprom.WithServerHandlingTimeHistogram(
grpcprom.WithHistogramBuckets([]float64{0.001, 0.01,
0.1, 0.3, 0.6, 1, 3, 6, 9, 20, 30, 60, 90, 120}),
),
)
reg := prometheus.NewRegistry()
reg.MustRegister(srvMetrics)
注意,我们向NewServerMetrics传递了一个选项。这个选项将使我们能够根据调用延迟将调用放入不同的桶中。这基本上告诉我们有多少请求在 0.001 秒内被服务,0.01 秒,等等。
最后,我们将把注册表传递给 HTTP 服务器,以便它知道有哪些度量标准可用,并且我们将把收集器传递给我们的 gRPC 服务器,以便它可以将其推送到它:
func newMetricsServer(httpAddr string, reg
*prometheus.Registry) *http.Server {
//...
m.Handle("/metrics", promhttp.HandlerFor(reg,
promhttp.HandlerOpts{}))
//...
return httpSrv
}
func newGrpcServer(lis net.Listener, srvMetrics
*grpcprom.ServerMetrics) (*grpc.Server, error) {
//...
opts := []grpc.ServerOption{
//...
grpc.ChainUnaryInterceptor(
otelgrpc.UnaryServerInterceptor(),
srvMetrics.UnaryServerInterceptor(),
//...
),
grpc.ChainStreamInterceptor(
otelgrpc.StreamServerInterceptor(),
srvMetrics.StreamServerInterceptor(),
//...
),
}
//...
}
func main() {
//...
grpcServer, err := newGrpcServer(lis, srvMetrics)
//...
metricsServer := newMetricsServer(httpAddr, reg)
//...
}
注意,我们现在正在使用opentelemetry(otelgrpc)。这是一个工具,它允许我们自动从我们的 gRPC 服务器生成所有指标。然后,Prometheus 将选择那些由收集器(srvMetrics)选择的指标。最后,HTTP 服务器将能够公开这些指标。
要为 gRPC 获取 OpenTelemetry,我们只需要获取依赖项:
$ go get go.opentelemetry.io/contrib/instrumentation/
google.golang.org/grpc/otelgrpc
我们现在应该能够运行我们的服务器:
$ go run ./server 0.0.0.0:50051 0.0.0.0:50052
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
然后,我们可以针对0.0.0.0:50051地址运行我们的客户端:
$ go run ./client 0.0.0.0:50051
在客户端调用全部服务后,我们可以查看如下指标:
$ curl http://localhost:50052/metrics
你现在应该有如下所示的日志(简化以仅显示AddTask):
grpc_server_handled_total{grpc_code="OK",grpc_method="
AddTask",grpc_service="todo.v2.TodoService",grpc_type=
"unary"} 3
grpc_server_handling_seconds_bucket{grpc_method="AddTask",
grpc_service="todo.v2.TodoService",grpc_type="unary",le=
"0.001"} 3
grpc_server_handling_seconds_sum{grpc_method="AddTask",grpc
_service="todo.v2.TodoService",grpc_type="unary"}
0.000119291
grpc_server_msg_received_total{grpc_method="AddTask",grpc_
service="todo.v2.TodoService",grpc_type="unary"} 3
grpc_server_msg_sent_total{grpc_method="AddTask",grpc_
service="todo.v2.TodoService",grpc_type="unary"} 3
grpc_server_started_total{grpc_method="AddTask",grpc_
service="todo.v2.TodoService",grpc_type="unary"} 3
这些指标意味着服务器接收了三个AddTask请求,在 0.001 秒内处理了它们(总计:0.000119291),并向客户端返回了三个响应。
显然,还有更多的事情要做来处理这些指标。然而,这可能需要一本书来详细说明。如果你对这个领域感兴趣,我鼓励你查看如何将 Prometheus 与 Grafana 等工具集成,以创建以更易于阅读的方式表示这些指标的仪表板。
Bazel
我们需要更新依赖项,以便 Prometheus 和 OpenTelemetry 能够正常工作。为此,我们将运行gazelle-update-repos:
$ bazel run //:gazelle-update-repos
然后,我们将运行gazelle以自动将依赖项链接到我们的代码:
$ bazel run //:gazelle
最后,我们现在可以运行我们的服务器:
$ bazel run //:server:server 0.0.0.0:50051 0.0.0.0:50052
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
然后运行我们的客户端:
$ bazel run //client:client 0.0.0.0:50051
总之,我们看到了如何通过使用 OpenTelemetry 和 Prometheus 从我们的 gRPC 服务器中获取指标。我们通过创建一个在/metrics路由上导出指标的第二个服务器,并通过使用 Prometheus 注册表和收集器,将 gRPC 服务器的指标交换到 HTTP 服务器中来实现这一点。
使用速率限制来保护 API
对于我们将要添加到服务器的最后一个拦截器,我们将使用一个速率限制器。更确切地说,我们将使用由golang.org/x/time/rate包提供的令牌桶速率限制器的实现。在本节中,我们不会深入探讨速率限制器是什么或如何构建一个——这超出了本书的范围,然而,你将看到如何在 gRPC 的上下文中使用一个速率限制器(一个现成的或自定义的)。
我们需要做的第一件事是获取速率限制器的依赖项:
$ go get golang.org/x/time/rate
重要提示
下一个命令仅在您没有获取到 go-grpc-middleware 的上一个依赖项时才需要。如果您是按章节顺序进行的,那么您应该不需要它。
然后我们获取拦截器的依赖项:
$ go get github.com/grpc-ecosystem/go-grpc-middleware/v2/
interceptors/ratelimit
现在,我们将创建一个名为 limit.go 的文件,其中将包含我们的逻辑和 rate.Limiter 的包装器。我们创建这样的包装器是因为我们稍后将要使用的拦截器需要限流器实现一个名为 Limit 的函数,该函数接受一个上下文作为参数,而 rate.Limiter 没有这样的函数:
package main
import (
"context"
"fmt"
"golang.org/x/time/rate"
)
type simpleLimiter struct {
limiter *rate.Limiter
}
func (l *simpleLimiter) Limit(_ context.Context) error {
if !l.limiter.Allow() {
return fmt.Errorf("reached Rate-Limiting %v", l
.limiter.Limit())
}
return nil
}
注意我们只是检查速率限制器是否允许(或不允许)调用通过。如果不允许,我们返回一个错误,否则返回 nil。
最后要做的事情是在拦截器中注册 simpleLimiter。我们将创建一个类型为 rate.Limiter 的实例,每秒 2 个令牌(称为 r),突发大小为 4(称为 b)。如果您对这些参数不清楚,我们建议您阅读关于限流器的文档(pkg.go.dev/golang.org/x/time/rate#Limiter):
import (
//...
"github.com/grpc-ecosystem/go-grpc-middleware/v2/
interceptors/ratelimit"
)
func newGrpcServer(lis net.Listener, srvMetrics
*grpcprom.ServerMetrics) (*grpc.Server, error) {
//...
limiter := &simpleLimiter{
limiter: rate.NewLimiter(2, 4),
}
opts := []grpc.ServerOption{
//...
grpc.ChainUnaryInterceptor(
ratelimit.UnaryServerInterceptor(limiter),
//...
),
grpc.ChainStreamInterceptor(
ratelimit.StreamServerInterceptor(limiter),
//...
),
}
//...
}
就这些。现在我们已经为我们的 API 启用了速率限制。我们现在可以运行我们的服务器:
$ go run ./server 0.0.0.0:50051 0.0.0.0:50052
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
然后,我们可以尝试每秒执行超过两个调用。这不应该很难。事实上,您通常可以运行一次客户端,它应该会失败。但为了确保它失败,请多次运行客户端。在 Linux 和 Mac 上,您可以运行以下命令:
$ for i in {1..10}; do go run ./client 0.0.0.0:50051; done
在 Windows(PowerShell)上,您可以运行以下命令:
$ foreach ($item in 1..10) { go run ./client 0.0.0.0:50051 }
您应该会看到一些查询返回响应,然后,您应该能够很快看到以下消息:
rpc error: code = ResourceExhausted desc =
/todo.v2.TodoService/UpdateTasks is rejected by
grpc_ratelimit middleware, please retry later. reached
Rate-Limiting 2
显然,我们的速率非常低,这在生产中并不实用。我们选择这么低的速率是为了向您展示如何进行速率限制。在生产中,您将会有特定的业务需求需要遵循。您将不得不调整我们展示的代码以匹配这些需求。
Bazel
为了使用 Bazel 运行此示例,我们需要更新仓库并运行 Gazelle 以将新的依赖项 (golang.org/x/time/rate) 导入到我们的库中:
$ bazel run //:gazelle-update-repos
$ bazel run //:gazelle
之后,您应该能够像这样运行服务器:
$ bazel run //server:server 0.0.0.0:50051 0.0.0.0:50052
总结来说,我们看到了我们可以在我们的 gRPC 服务器中集成速率限制器。go-grpc-middleware 的速率限制拦截器使得添加现成的实现或自定义实现变得容易。
重试调用
到目前为止,我们只专注于服务器端。现在让我们看看客户端的一个重要特性。这个特性是根据状态码重试失败的调用。这可能对网络不可靠的使用场景很有趣。如果我们得到一个 Unavailable 错误代码,我们将以指数级增加的等待时间进行重试。这是因为我们不希望过于频繁地重试并超载网络。
重要提示
gRPC 支持无需第三方库的重试。然而,配置相当冗长,并且文档不是很好。如果您有兴趣尝试,可以查看以下示例:github.com/grpc/grpc-go/blob/master/examples/features/retry/README.md。
让我们获取我们需要的依赖项(client文件夹):
$ go get github.com/grpc-ecosystem/go-grpc-middleware/v2/
interceptors/retry
然后,我们可以为重试定义一些选项。我们将定义重试的次数和错误代码。我们希望重试 3 次,使用指数退避(从 100 毫秒开始),错误代码为Unavailable:
retryOpts := []retry.CallOption{
retry.WithMax(3),
retry.WithBackoff(retry.BackoffExponential(100 *
time.Millisecond)),
retry.WithCodes(codes.Unavailable),
}
然后,我们只需将这些选项传递给retry包提供的拦截器:
import (
//...
"github.com/grpc-ecosystem/go-grpc-middleware/v2/
interceptors/retry"
)
func main() {
//...
retryOpts := []retry.CallOption{
//...
}
opts := []grpc.DialOption{
//...
grpc.WithChainUnaryInterceptor(
retry.UnaryClientInterceptor(retryOpts...),
//...
),
grpc.WithChainStreamInterceptor(
retry.StreamClientInterceptor(retryOpts...),
//...
),
//...
}
//...
}
重要提示
对于客户端流,无法进行重试。如果您尝试在这样一个 RPC 端点上进行重试,您将收到以下错误:rpc error: code = Unimplemented desc = grpc_retry: cannot retry on ClientStreams, set grpc_retry.Disable()。因此,添加retry.StreamClientInterceptor有一定的风险。我们只是想向您展示一些流也可以进行重试。
一旦我们有了这个,我们现在就遇到了一个问题。我们的 API 在本地运行,我们几乎不可能得到一个Unavailable错误。所以,为了测试和演示,我们将暂时让我们的AddTask直接返回这样的错误。在server/impl.go中,我们可以注释掉函数的其余部分,并添加以下内容:
func (s *server) AddTask(_ context.Context, in
*pb.AddTaskRequest) (*pb.AddTaskResponse, error) {
return nil, status.Errorf(
codes.Unavailable,
"unexpected error: %s",
"unavailable",
)
}
现在,我们运行我们的服务器:
$ go run ./server 0.0.0.0:50051 0.0.0.0:50052
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
然后运行我们的客户端:
$ go run ./client 0.0.0.0:50051
--------ADD--------
rpc error: code = Unavailable desc = unexpected error:
unavailable
我们得到一个错误。虽然这看起来只执行了一个查询,但如果您回顾您的服务器,您应该能够看到以下内容:
INFO :started call todo.v2.TodoService AddTask
WARN :finished call todo.v2.TodoService AddTask
INFO :started call todo.v2.TodoService AddTask
WARN :finished call todo.v2.TodoService AddTask
INFO :started call todo.v2.TodoService AddTask
WARN :finished call todo.v2.TodoService AddTask
这实际上是三个请求。
Bazel
总是如此,您需要运行gazelle-update-repos和gazelle以获取新的依赖项并将它们链接到您的库:
$ bazel run //:gazelle-update-repos
$ bazel run //:gazelle
现在您应该能够正确运行您的客户端:
$ bazel run //client:client 0.0.0.0:50051
--------ADD--------
rpc error: code = Unavailable desc = unexpected error:
unavailable
总结来说,在本节中,我们看到了可以根据某些条件进行重试,使用指数退避,并且持续一定时间。重试是一个重要的功能,因为网络通常不可靠,我们不希望用户每次出现问题都要手动重试。
摘要
在本章中,我们探讨了通过使用社区项目,如protoc-gen-validate或go-grpc-middleware,我们可以获得的关键功能。我们看到了我们可以在我们的 proto 文件中编码请求验证逻辑。这使得我们的代码更精简,并且在整个 API 端点之间提供错误消息的一致性。
然后,我们探讨了中间件是什么以及如何创建一个。我们从重构我们的身份验证和日志拦截器开始。我们看到了通过使用go-grpc-middleware,我们可以专注于拦截器的实际逻辑,并且有更少的样板代码要处理。
之后,我们看到我们可以从我们的 API 中公开跟踪数据。我们使用了 OpenTelemetry 和 Prometheus 从 gRPC API 收集数据,并通过 HTTP 服务器公开它。
然后,我们学习了如何在我们的 API 上应用速率限制。这有助于防止欺诈行为者或缺陷客户端过载我们的服务器。我们使用了令牌桶算法和一个现成的速率限制器实现来对我们的 API 应用限制。
最后,我们还看到我们可以通过与重试中间件一起工作在客户端使用拦截器。这让我们可以根据错误代码重试调用,最多重试次数,并且可选地使用指数退避。
在下一章中,我们将讨论 gRPC API 的开发生命周期,如何确保它们的正确性,如何调试它们,以及如何部署它们。
问答
-
protoc-gen-validate 插件的目的是什么?
-
在
.proto文件中提供检查逻辑 -
生成验证代码
-
两者都是
-
-
go-grpc-middleware用于什么?-
提供常用的拦截器
-
生成验证代码
-
-
哪个中间件用于将事件显示为可读文本?
-
tracing -
auth -
logging
-
-
哪个中间件用于限制每秒请求的数量?
-
tracing -
ratelimit -
auth
-
答案
-
C
-
A
-
C
-
B
挑战
-
通过使用日志中间件简化你在上一章中创建的客户端日志记录器。
-
检查
protoc-gen-validate规则(github.com/bufbuild/protoc-gen-validate/blob/main/README.md),并简化你在上一章挑战中添加的错误处理。 -
检查
github.com/grpc-ecosystem/go-grpc-middleware/tree/v2中可用的其他中间件,并尝试实现一个。一个例子可以是选择器中间件。 -
基于服务器公开的指标创建一个简单的 Grafana 仪表板。一个例子可以是显示成功请求百分比的仪表板。
第九章:量产级 API
迄今为止,我们一直关注 gRPC 提供的功能和社区项目添加的功能。这是一个重要的主题,但并不是全部。我们现在需要考虑如何测试、调试和部署我们的 gRPC 服务器。
在本章中,我们将看到如何对服务进行单元和负载测试。然后,我们将看到如何手动与我们的 API 交互以调试它。最后,我们将看到如何容器化和部署我们的服务。本章分为以下主要主题:
-
测试 API
-
使用服务器反射进行调试
-
在 Kubernetes 上部署 gRPC 服务
技术要求
您可以在本书配套仓库中名为 chapter5 的文件夹中找到本章的代码,该仓库的网址为github.com/PacktPublishing/gRPC-Go-for-Professionals/tree/main/chapter9。在本章中,我将使用三个主要工具:ghz、grpcurl和 Wireshark。您应该已经从第一章安装了 Wireshark,但如果没有,您可以在www.wireshark.org/找到它。ghz 是一个让我们能够进行 API 负载测试的工具。您可以通过访问ghz.sh/来获取它。最后,我们将使用 grpcurl 从终端与我们的 API 交互。您应该能够从github.com/fullstorydev/grpcurl获取它。
测试
开发量产级 API 始于编写全面的测试,以确保满足业务需求,同时验证 API 的一致性和性能。第一部分主要在单元和集成测试中处理,第二部分通过负载测试处理。
在本节的第一个部分,我们将重点关注服务器的单元测试。我们将针对每种 API 类型进行一次测试,以了解如何在未来引入更多测试。在第二部分,我们将介绍 ghz,这是一个用于负载测试 gRPC API 的工具。我们将介绍该工具的不同选项以及如何使用凭据、作为头部的认证令牌等进行 API 负载测试。
单元测试
如前所述,我们将重点关注服务器的单元测试。在开始之前,重要的是要知道这里展示的测试并不是我们可能做的所有测试。为了使本书易于阅读,我将展示如何为每种 API 类型编写单元测试,您可以在server/impl_test.go文件中找到其他测试的示例。
在编写任何测试之前,我们需要做一些设置。我们将为不同的测试编写一些样板代码,以便它们可以共享相同的服务器和连接。这主要是为了避免每次运行测试时都创建新的服务器和连接。然而,请注意,这些是非密封测试。这意味着意外的状态可能会在多个测试之间共享,从而使测试变得不可靠。我们将介绍处理这种情况的方法,并确保我们清除状态。
我们首先可以做的事情是创建一个假数据库。这就像我们处理 inMemoryDb 一样,实际上,FakeDb 是 inMemoryDb 的包装器,但我们还将测试由于数据库连接问题引起的问题。
为了做到这一点,我们将使用与 grpc.ServerOption 相同的模式。grpc.ServerOption 是一个将值应用到私有结构体的函数。这个模式的例子是 grpc.Creds:
func Creds(c credentials.TransportCredentials) ServerOption {
return newFuncServerOption(func(o *serverOptions) {
o.creds = c
})
}
它返回一个函数,一旦调用,就会将 c 的值设置为 serverOptions 中的 creds 属性。请注意,serverOptions 与 ServerOption 不同。这是一个私有结构体。
我们将创建一个函数,告诉我们数据库是否可用。稍后,我们将启用选项,如果不可用则返回错误。在 test_options.go 中,我们将有以下内容:
func IsAvailable(a bool) TestOption {
return newFuncTestOption(func(o *testOptions) {
o.isAvailable = a
})
}
我将留给你们去检查 test_options.go 的其余内容。那里的函数和结构体只是创建一些工具和变量,以便能够编写 IsAvailable 函数并获取 isAvailable 的默认值。
现在,我们可以创建 FakeDb。如前所述,这是一个 inMemoryDb 的包装器,它有一些选项。在 fake_db.go 中,我们可以有以下内容:
type FakeDb struct {
d *inMemoryDb
opts testOptions
}
func NewFakeDb(opt ...TestOption) *FakeDb {
opts := defaultTestOptions
for _, o := range opt {
o.apply(&opts)
}
return &FakeDb{
d: &inMemoryDb{},
opts: opts,
}
}
func (db *FakeDb) Reset() {
db.opts = defaultTestOptions
db.d = &inMemoryDb{}
}
我们现在可以通过多种方式创建一个 FakeDb:
NewFakeDb()
NewFakeDb(IsAvailable(false))
我们还重写了 inMemoryDb 的函数,以便我们的 FakeDb 实现了 db 接口,并且我们可以使用这个数据库实例化一个服务器。FakeDb 的每个函数都遵循相同的模式。我们检查数据库是否可用;如果不,我们返回一个错误,如果可用,我们返回 inMemoryDb 的结果。一个例子是 addTask(在 fake_db.go 中):
func (db *FakeDb) addTask(description string, dueDate
time.Time) (uint64, error) {
if !db.opts.isAvailable {
return 0, fmt.Errorf(
"couldn't access the database",
)
}
return db.d.addTask(description, dueDate)
}
既然我们已经有了这个,我们就可以再向前迈进一步,编写实际的单元测试。我们现在需要创建一个服务器。然而,我们不想这个服务器实际上使用我们电脑上的端口。使用实际端口可能会使我们的测试变得不可靠,因为如果端口已经被占用,测试会直接返回一个错误,表示无法创建服务器的实例。
为了解决这个问题,gRPC 有一个名为bufconn的包(grpc/test/bufconn)。它允许我们创建一个缓冲连接,因此不需要使用端口。bufconn.Listen将创建一个监听器,我们将能够使用这个监听器来处理请求。在server_test.go中,我们将监听器和数据库作为全局变量共享。这将允许我们在所有测试完成后销毁监听器,并在测试中从数据库中添加/清除任务。此外,我们将创建一个返回net.Conn连接的函数,这样我们就可以在测试中使用它来创建一个客户端:
import (
"context"
"log"
"net"
pb "github.com/PacktPublishing/gRPC-Go-for-Professionals/
proto/todo/v2"
"google.golang.org/grpc"
"google.golang.org/grpc/test/bufconn"
)
const bufSize = 1024 * 1024
var lis *bufconn.Listener
var fakeDb *FakeDb = NewFakeDb()
func init() {
lis = bufconn.Listen(bufSize)
s := grpc.NewServer()
var testServer *server = &server{
d: fakeDb,
}
pb.RegisterTodoServiceServer(s, testServer)
go func() {
if err := s.Serve(lis); err != nil && err.Error() !=
"closed" {
log.Fatalf("Server exited with error: %v\n", err)
}
}()
}
func bufDialer(context.Context, string) (net.Conn, error) {
return lis.Dial()
}
首先要注意的是,我们使用 Go 的init()函数在测试开始之前进行此设置。然后,注意我们创建了我们服务器的实例并注册了我们的TodoService实现的实例。最后,服务器在一个 goroutine 中提供服务。所以,我们需要确保 goroutine 被取消。
我们几乎完成了样板代码。我们需要创建一个使用bufDialer函数通过缓冲连接连接到服务器的客户端。在impl_test.go中,我们将创建一个返回TodoServiceClient和grpc.ClientConn的函数。第一个显然是用来调用我们的端点,但第二个是为了我们在每个测试结束时关闭客户端连接:
func newClient(t *testing.T) (*grpc.ClientConn,
pb.TodoServiceClient) {
ctx := context.Background()
creds := grpc.WithTransportCredentials
(insecure.NewCredentials())
conn, err := grpc.DialContext(ctx, "bufnet",
grpc.WithContextDialer(bufDialer), creds)
if err != nil {
t.Fatalf("failed to dial bufnet: %v", err)
}
return conn, pb.NewTodoServiceClient(conn)
}
在这里需要理解的一个重要的事情是,我们不是在测试我们在main.go中编写的整个服务器。我们只是在测试我们的端点实现。这就是为什么我们可以使用不安全的凭据连接到服务器。拦截器、加密等应该在集成测试中进行测试。
最后,我们可以创建一个小的实用函数来检查一个错误是否是 gRPC 错误,并且它有一个预期的消息:
func errorIs(err error, code codes.Code, msg string) bool {
if err != nil {
if s, ok := status.FromError(err); ok {
if code == s.Code() && s.Message() == msg {
return true
}
}
}
return false
}
我们现在准备编写一些单元测试。我们将创建一个函数,该函数将运行所有单元测试,并在所有子测试完成后销毁监听器:
func TestRunAll(t *testing.T) {
}
现在,我们能够用子测试填充TestRunAll函数,如下所示:
func TestRunAll(t *testing.T) {
t.Run("AddTaskTests", func(t *testing.T) {
//...
})
t.Cleanup(func() {
lis.Close()
})
}
现在,让我们编写testAddTaskEmptyDescription函数,该函数检查当我们发送一个带有空描述的请求时是否会得到一个错误。我们将创建一个客户端的新实例,创建一个空请求,将其发送到AddTask,最后检查我们的错误是否有一个未知代码(由protoc-gen-validate返回)以及消息是invalid AddTaskRequest.Description: value length must be at least 1 runes(也来自protoc-gen-validate):
const (
errorInvalidDescription = "invalid AddTaskRequest
.Description: value length must be at least 1 runes"
)
func testAddTaskEmptyDescription(t *testing.T) {
conn, c := newClient(t)
defer conn.Close()
req := &pb.AddTaskRequest{}
_, err := c.AddTask(context.TODO()), req)
if !errorIs(err, codes.Unknown, errorInvalidDescription) {
t.Errorf(
"expected Unknown with message \"%s\", got %v",
errorInvalidDescription, err,
)
}
}
然后,我们可以将其添加到我们的TestRunAll函数中,如下所示:
func TestRunAll(t *testing.T) {
t.Run("AddTaskTests", func(t *testing.T) {
t.Run("TestAddTaskEmptyDescription",
testAddTaskEmptyDescription)
}
//...
}
要运行这个测试,我们可以在根目录中运行以下命令:
$ go test -run ^TestRunAll$ ./server
ok
现在,在继续查看如何测试流之前,让我们看看我们如何测试一个不可用的数据库。这几乎与我们在testAddTaskEmptyDescription中做的是一样的,但我们将要覆盖数据库。最后,我们将检查我们得到一个内部错误并重置数据库(以清除选项):
const (
//...
errorNoDatabaseAccess = "unexpected error: couldn't
access the database"
)
func testAddTaskUnavailableDb(t *testing.T) {
conn, c := newClient(t)
defer conn.Close()
newDb := NewFakeDb(IsAvailable(false))
*fakeDb = *newDb
req := &pb.AddTaskRequest{
Description: "test",
DueDate: timestamppb.New(time.Now().Add(5 *
time.Hour)),
}
_, err := c.AddTask(context.TODO(), req)
fakeDb.Reset()
if !errorIs(err, codes.Internal, errorNoDatabaseAccess) {
t.Errorf("expected Internal, got %v", err)
}
}
我们可以看到测试数据库故障很容易。这就是一元 RPC 的全部内容。我将让你将testAddTaskUnavailableDb添加到TestRunAll中,并查看impl_test.go中AddTasks的其他测试。
现在,我们将测试ListTasks。我们将在我们的模拟数据库中添加一些任务,调用ListTasks,确保没有错误,并检查ListTasks是否遍历了所有任务:
func testListTasks(t *testing.T) {
conn, c := newClient(t)
defer conn.Close()
fakeDb.d.tasks = []*pb.Task{
{}, {}, {}, // 3 empty tasks
}
expectedRead := len(fakeDb.d.tasks)
req := &pb.ListTasksRequest{}
count := 0
res, err := c.ListTasks(context.TODO(), req)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
for {
_, err := res.Recv()
if err == io.EOF {
break
}
if err != nil {
t.Errorf("error while reading stream: %v", err)
}
count++
}
if count != expectedRead {
t.Errorf(
"expected reading %d tasks, read %d",
expectedRead, count,
)
}
}
在调用 API 方面没有新的内容。我们已经在编写客户端时知道了所有这些。然而,对于这次测试,主要的不同之处在于我们不看值;我们只是断言我们循环的时间。当然,你可以从这个基础上创建更复杂的测试,但我只想展示一个简单的服务器流式 API 测试,这样你就可以在此基础上构建。
接下来,让我们测试客户端流式 API 端点。由于我们正在使用UpdateTasks端点,我们需要在我们的数据库中设置数据。之后,我们将基本上创建一个UpdateTasksRequest数组,以便更改数据库中的所有项目,发送请求,并检查所有更新是否运行无误:
func testUpdateTasks(t *testing.T) {
conn, c := newClient(t)
defer conn.Close()
fakeDb.d.tasks = []*pb.Task{
{Id: 0, Description: "test1"},
{Id: 1, Description: "test2"},
{Id: 2, Description: "test3"},
}
requests := []*pb.UpdateTasksRequest{
{Id: 0}, {Id: 1}, {Id: 2},
}
expectedUpdates := len(requests)
stream, err := c.UpdateTasks(context.TODO())
count := 0
if err != nil {
t.Errorf("unexpected error: %v", err)
}
for _, req := range requests {
if err := stream.Send(req); err != nil {
t.Fatal(err)
}
count++
}
_, err = stream.CloseAndRecv()
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if count != expectedUpdates {
t.Errorf(
"expected updating %d tasks, updated %d",
expectedUpdates, count,
)
}
}
这与之前的测试类似。我们使用计数器来检查所有更新都被“应用”。在一个集成测试中,你将不得不检查数据库中的值是否实际上发生了变化;然而,因为我们是在单元测试中,并且我们有一个内存数据库,检查实际值并没有什么意义。
最后,我们将测试双向流式 API。在测试环境中,这稍微复杂一些,但我们将一步一步地解决这个问题。之前,在客户端,当一个 goroutine 中发生错误时,我们简单地运行log.Fatalf来退出。然而,在这里,因为我们想跟踪错误,并且我们不能从测试的另一个 goroutine 中调用t.Fatalf,我们将使用一个名为countAndError的struct通道。正如其名所示,这是一个包含计数器和可选错误的结构:
type countAndError struct {
count int
err error
}
这很有用,因为现在我们将能够等待 goroutine 完成并获取通道的结果。首先,让我们创建一个发送所有请求的函数。这个函数被命名为sendRequestsOverStream,它也将在一个单独的 goroutine 中被调用:
func sendRequestsOverStream(stream
pb.TodoService_DeleteTasksClient, requests
[]*pb.DeleteTasksRequest, waitc chan countAndError) {
for _, req := range requests {
if err := stream.Send(req); err != nil {
waitc <- countAndError{err: err}
close(waitc)
return
}
}
if err := stream.CloseSend(); err != nil {
waitc <- countAndError{err: err}
close(waitc)
}
}
如果发生错误,我们将使用在countAndError结构中设置错误来关闭等待的通道。
然后,我们可以创建一个读取响应的函数。这个函数被命名为readResponsesOverStream,它将在一个单独的 goroutine 中被调用:
func readResponsesOverStream(stream
pb.TodoService_DeleteTasksClient, waitc chan
countAndError) {
count := 0
for {
_, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
waitc <- countAndError{err: err}
close(waitc)
return
}
count++
}
waitc <- countAndError{count: count}
close(waitc)
}
这次,如果一切顺利,通道将获得一个设置计数的countAndError。这个计数与我们在之前的测试中所做的是相同的。它检查收集到的没有错误的响应数量。
现在我们有了这两个函数,我们就可以为我们的双向流 API 编写实际的测试了。这与我们为 ListTasks 和 UpdateTasks 做的事情类似;然而,这次,我们启动了两个 goroutines,等待结果,并检查我们没有错误并且计数等于请求数量:
func testDeleteTasks(t *testing.T) {
conn, c := newClient(t)
defer conn.Close()
fakeDb.d.tasks = []*pb.Task{
{Id: 1}, {Id: 2}, {Id: 3},
}
expectedRead := len(fakeDb.d.tasks)
waitc := make(chan countAndError)
requests := []*pb.DeleteTasksRequest{
{Id: 1}, {Id: 2}, {Id: 3},
}
stream, err := c.DeleteTasks(context.TODO())
if err != nil {
t.Errorf("unexpected error: %v", err)
}
go sendRequestsOverStream(stream, requests, waitc)
go readResponsesOverStream(stream, waitc)
countAndError := <-waitc
if countAndError.err != nil {
t.Errorf("expected error: %v", countAndError.err)
}
if countAndError.count != expectedRead {
t.Errorf(
"expected reading %d responses, read %d",
expectedRead, countAndError.count,
)
}
}
有了这个,我们终于完成了所有不同类型 gRPC API 的测试。再次强调,还有更多可以进行的测试,其他示例可以在 impl_test.go 中找到。我强烈建议你看看那里,以便获得更多灵感。
在将所有这些测试添加到 TestRunAll 之后,你应该能够像这样运行它们:
$ go test -run ^TestRunAll$ ./server
ok
如果你想查看测试运行了什么更详细的信息,可以添加 –v 选项。这将返回类似以下的内容:
$ go test -run ^TestRunAll$ -v ./server
--- PASS: TestRunAll
--- PASS: TestRunAll/AddTaskTests
--- PASS: TestRunAll/AddTaskTests/
TestAddTaskUnavailableDb
--- PASS:
//...
PASS
Bazel
为了使用 Bazel 运行测试,你可以运行 Gazelle 生成 //server:server_test 目标:
$ bazel run //:gazelle
然后,你将在 server/BUILD.bazel 中有这个目标,你应该能够运行以下命令:
$ bazel run //server:server_test
PASS
如果你想为你的测试获取更详细的输出,可以使用 –test_arg 选项并将其设置为 -test.v。它将返回类似以下的内容:
$ bazel run //server:server_test --test_arg=-test.v
--- PASS: TestRunAll
--- PASS: TestRunAll/AddTaskTests
--- PASS: TestRunAll/AddTaskTests/
TestAddTaskUnavailableDb
--- PASS:
//...
PASS
为了总结,我们看到了如何测试一元、服务器端流、客户端流和双向流 API。我们看到了在使用 bufconn 时,我们不需要在运行测试的机器上使用端口。这使得我们的测试对运行环境的依赖性更小。最后,我们还看到了我们可以使用模拟来测试我们的系统依赖。这超出了本书的范围,但对我来说,展示你可以即使使用 gRPC 也能编写正常的测试是很重要的。
压力测试
测试你的服务时,确保它们高效并能处理特定负载是另一个重要步骤。为此,我们使用并发向我们的服务发送请求的负载测试工具。ghz 就是这样一个工具。在本节中,我们将看到如何使用这个工具以及我们需要设置的一些选项,以便测试我们的 API。
ghz 是一个高度可配置的工具。运行以下命令以查看和理解输出:
$ ghz --help
显然,我们不会使用所有这些选项,但我们将检查最常用的选项以及在某些特定情况下我们需要使用的选项。让我们先尝试进行一个简单的调用。
重要提示
为了运行以下负载测试,你需要在 server/main.go 文件中禁用速率限制中间件。你可以通过注释掉 ratelimit.UnaryServerInterceptor 和 ratelimit.StreamServerInterceptor 来做到这一点。
我们首先运行我们的服务器:
$ go run ./server 0.0.0.0:50051 0.0.0.0:50052
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
我们将要讨论的前四个选项是最常见的。我们需要能够命名我们想要调用的服务和方法(--call),指出服务定义在哪个 proto 文件中(--proto)以及在哪里可以找到导入(--import_paths),最后,指定作为请求发送的数据。在我们的例子中,一个基本的命令,从chapter9文件夹运行,看起来像这样:
$ ghz --proto ./proto/todo/v2/todo.proto \
--import-paths=proto \
--call todo.v2.TodoService.AddTask \
--data '{"description":"task"}' \
0.0.0.0:50051
然而,如果你尝试运行这个命令,你最终会得到一个类似以下错误的消息:
connection error: desc = "transport: authentication
handshake failed: tls: failed to verify certificate: x509:
"test-server1" certificate is not standards compliant"
如你从消息中可以肯定猜到的,这是因为我们设置了我们的服务器只接受安全连接。为了解决这个问题,我们将使用--cacert选项,它允许我们指定 CA 证书的路径。如果你记得,这正是我们在客户端代码中做的。ghz也需要这个信息:
$ ghz #... \
--cacert ./certs/ca_cert.pem \
0.0.0.0:50051
如果你运行这个命令,你会得到与之前相同的错误。这是因为证书与一个域名相关联。这意味着只有来自特定域名的请求才会被接受。然而,因为我们是从本地主机工作的,这根本不符合那个要求,所以失败了。为了解决这个问题,我们将使用--cname选项来覆盖我们发送的域名,以符合证书:
$ ghz #... \
--cacert ./certs/ca_cert.pem \
--cname "check.test.example.com" \
0.0.0.0:50051
在这里,我们使用了check.test.example.com,因为我们从github.com/grpc/grpc-go/tree/master/examples/data/x509下载的生成证书是以 DNS 名称*.test.example.com生成的(见openssl.cnf)。此外,请注意,这个--cacert和--cname选项仅对自签名证书有用。通常,除了特定情况外,这些证书用于测试和非生产环境。
现在,如果你运行前面的命令,你应该会得到以下错误:
Unauthenticated desc = failed to get auth_token
这应该会让你想起什么。这是我们发送到认证拦截器中的错误,当客户端没有提供auth_token元数据时。为了发送这些元数据,我们将使用--metadata选项,它接受一个 JSON 字符串作为键和值:
ghz #... \
--metadata '{"auth_token":"authd"}' \
0.0.0.0:50051
在运行了所有这些选项之后,我们应该能够运行我们的第一次负载测试(你的结果可能会有所不同):
$ ghz --proto ./proto/todo/v2/todo.proto \
--import-paths=proto \
--call todo.v2.TodoService.AddTask \
--data '{"description":"task"}' \
--cacert ./certs/ca_cert.pem \
--cname "check.test.example.com" \
--metadata '{"auth_token":"authd"}' \
0.0.0.0:50051
Summary:
Count: 200
Total: 22.89 ms
Slowest: 16.70 ms
Fastest: 0.20 ms
Average: 4.60 ms
Requests/sec: 8736.44
Response time histogram:
0.204 [1] |
1.854 [111] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎∎
3.504 [38] |∎∎∎∎∎∎∎∎∎∎∎∎∎∎
5.153 [0] |
6.803 [0] |
8.453 [0] |
10.103 [0] |
11.753 [0] |
13.403 [2] |∎
15.053 [26] |∎∎∎∎∎∎∎∎∎
16.703 [22] |∎∎∎∎∎∎∎∎
Latency distribution:
10 % in 0.33 ms
25 % in 0.78 ms
50 % in 1.75 ms
75 % in 2.39 ms
90 % in 15.12 ms
95 % in 15.31 ms
99 % in 16.48 ms
Status code distribution:
[OK] 200 responses
在这个总结中有很多东西可以讨论和观察。然而,让我们关注一些有趣的观点。第一个是请求的数量。我们可以看到在这个测试中我们发出了 200 个请求。这是默认的请求数量。我们可以通过使用--total选项并设置另一个数字(例如,500)来改变它。
在响应时间直方图中,我们可以看到 200 个请求中有 111 个在约 2.29 毫秒内执行。这里还有一个有趣的现象,我们有一些命令(50 个)运行时间超过 13 毫秒。如果我们处于生产环境,我们可能需要深入挖掘以找出这些“高”执行时间的原因。这很大程度上取决于用例和需求。在我们的案例中,这几乎肯定是由于我们使用的“数据库”效率低下,或者更确切地说,是我们在 inMemoryDb.addTask 中反复调用的 append。
之后,我们来看我们的执行时间分布。我们可以看到,75% 的请求在 2.39 毫秒以下执行。实际上,这与之前展示的信息类似。如果我们把 3.504 毫秒以下的请求数量加起来,并计算百分比,我们得到 (1 + 111 + 38) * 100 / 200 = 75%。
然后,我们来看状态码分布。在我们的案例中,所有 200 个请求都成功了。然而,在生产环境中,你可能会遇到类似以下情况(来自 ghz 文档):
Status code distribution:
[Unavailable] 3 responses
[PermissionDenied] 3 responses
[OK] 186 responses
[Internal] 8 responses
最后,我们无法看到的是错误分布。这是错误消息的分布。同样,在生产环境中,你可能会看到类似以下情况(来自 ghz 文档):
Error distribution:
[8] rpc error: code = Internal desc = Internal error.
[3] rpc error: code = PermissionDenied desc = Permission
denied.
[3] rpc error: code = Unavailable desc = Service unavailable.
显然,我们可以用这个工具做更多的事情。如前所述,它是高度可配置的,甚至可以将结果链接到 Grafana(https://ghz.sh/docs/extras)进行可视化。然而,这超出了本书的范围。我将把它留给你去尝试不同的选项,并在我们的其他 API 端点调用 ghz 来查看它们的性能。
总结来说,我们看到了如何使用 ghz 对我们的服务进行负载测试。我们只展示了如何用于我们的单一 API,但它也适用于测试其他所有流式 API。执行 ghz 命令后,我们看到了可以获取有关延迟、错误代码、错误消息分布以及最快和最慢运行时间的信息。所有这些都很有用,但重要的是要理解,当与可视化工具如 Grafana 链接时,它可能更加强大。
调试
无论我们如何对服务进行单元测试,我们都是人,人都会犯错误。在某个时刻,我们都需要调试一个服务。在本节中,我们将探讨如何进行调试。我们首先将启用服务器反射,这将使我们能够从命令行简单地调用我们的服务。之后,我们将使用 Wireshark 检查线上的数据。最后,因为错误可能并不总是直接来自我们的代码,我们将了解如何查看 gRPC 日志。
服务器反射
服务器反射是向外部客户端暴露 API 时一个有趣的功能。这是因为它让服务器描述自己。换句话说,服务器知道所有注册的服务和消息定义。如果客户端请求更多信息,服务器通过反射可以列出所有服务、消息等。有了这个,客户端甚至不需要有 proto 文件的副本。现在,这不仅对向外部客户端暴露 API 有用。它对手动测试/调试也很有用。它让开发者/测试者只需专注于调试 API,而不必让整个环境都工作(复制 proto 文件等)。
在 gRPC Go 中启用服务器反射是一件简单的事情。我们只需要两行代码:一个 import 语句和对 reflection.Register 函数的调用,以在我们的服务器上注册反射服务。它看起来像这样(server/main.go):
import (
//...
"google.golang.org/grpc/reflection"
)
func newGrpcServer(lis net.Listener, srvMetrics
*grpcprom.ServerMetrics) (*grpc.Server, error) {
//...
s := grpc.NewServer(opts...)
pb.RegisterTodoServiceServer(/*…*/)
reflection.Register(s)
return s, nil
}
然而,尽管这足以暴露信息,我们仍需要获取一个联系服务器并理解其获取信息的客户端。市面上有多个这样的工具。最受欢迎的一个是 grpcurl (https://github.com/fullstorydev/grpcurl)。如果你熟悉 cURL,这个工具基本上是类似的,但它理解 gRPC 协议。尽管我们将使用这个工具来探索服务器反射,但要知道它也可以发出其他正常请求。如果你对这样的工具感兴趣,仓库的 README 中充满了如何使用它进行其他任务的示例。
让我们首先尝试使用 grpcurl 创建一个简单的命令。我们将使用与我们在 ghz 中使用的类似选项。我们将使用 CA 证书,并用 –cacert 和 -authority 覆盖域名。然后,我们将为反射添加一个 auth_token 标头,最后,我们将使用列表动词来列出服务器上存在的服务:
$ grpcurl -cacert ./certs/ca_cert.pem \
-authority "check.test.example.com" \
-reflect-header 'auth_token: authd' \
0.0.0.0:50051 list
一旦我们运行这个命令,我们应该得到以下输出:
grpc.reflection.v1alpha.ServerReflection
todo.v2.TodoService
我们可以看到,我们既有我们的 TodoService,也有我们之前注册的 ServerReflection 服务。有了这些,我们可以描述一个获取它包含的所有 RPC 端点服务的服务。我们通过在服务名称后跟 describe 动词来实现这一点:
$ grpcurl -cacert ./certs/ca_cert.pem \
-authority "check.test.example.com" \
-reflect-header 'auth_token: authd' \
0.0.0.0:50051 describe todo.v2.TodoService
运行此命令将显示服务的定义:
todo.v2.TodoService is a service:
service TodoService {
rpc AddTask ( .todo.v2.AddTaskRequest ) returns (
.todo.v2.AddTaskResponse );
rpc DeleteTasks ( stream .todo.v2.DeleteTasksRequest )
returns ( stream .todo.v2.DeleteTasksResponse );
rpc ListTasks ( .todo.v2.ListTasksRequest ) returns (
stream .todo.v2.ListTasksResponse );
rpc UpdateTasks ( stream .todo.v2.UpdateTasksRequest )
returns ( .todo.v2.UpdateTasksResponse );
}
我们还可以通过将 describe 后面的服务名称替换为消息名称来查看消息内容。以下是对 AddTaskRequest 的一个示例:
$ grpcurl -cacert ./certs/ca_cert.pem \
-authority "check.test.example.com" \
-reflect-header 'auth_token: authd' \
0.0.0.0:50051 describe todo.v2.AddTaskRequest
todo.v2.AddTaskRequest is a message:
message AddTaskRequest {
string description = 1 [(.validate.rules) = {
string:<min_len:1> }];
.google.protobuf.Timestamp due_date = 2
[(.validate.rules) = { timestamp:<gt_now:true> }];
}
现在,既然我们在本节中讨论调试,我们希望能够调用这些 RPC 端点并使用不同的数据对其进行测试。这很简单,因为我们甚至不需要携带 proto 文件。服务器反射将帮助 grpcurl 为我们解决所有问题。让我们用无效请求调用 AddTask 端点:
$ grpcurl -cacert ./certs/ca_cert.pem \
-authority "check.test.example.com" \
-rpc-header 'auth_token: authd' \
-reflect-header 'auth_token: authd' \
-d '' \
-use-reflection \
0.0.0.0:50051 todo.v2.TodoService.AddTask
注意我们在这里使用了其他选项。我们使用 -d 选项来设置我们想要发送的数据作为 AddTaskRequest。我们使用 –use-reflection 选项,以便 grpcurl 可以验证数据的有效性(我们很快就会看到这一点),并且使用 –rpc-header 在 –reflect-header 之上,因为 –reflect-header 只会将头部发送到 ServerReflection 服务,而我们还需要将头部发送到 TodoService。
如预期,之前的命令返回以下错误:
ERROR:
Code: Unknown
Message: invalid AddTaskRequest.Description: value length
must be at least 1 runes
现在,正如提到的,grpcurl 不允许我们在不添加防护措施的情况下执行命令。在这里使用反射是有用的,因为它不允许我们发送无法在请求消息中反序列化的数据。以下是一个示例:
$ grpcurl #... \
-d '{"notexisting": true}' \
0.0.0.0:50051 todo.v2.TodoService.AddTask
Error invoking method "todo.v2.TodoService.AddTask": error
getting request data: message type todo.v2.AddTaskRequest
has no known field named notexisting
最后,因为我们还有一个我们想要测试的非一元 RPC 端点,我们可以使用交互式终端。这将使我们能够发送和接收多个消息。为此,我们将数据设置为 @ 并以 <<EOF 结束命令,其中 EOF 代表文件结束(你可以使用任何后缀)。这将使我们能够交互式地输入数据,当我们完成时,我们写入 EOF 来让 grpcurl 知道。
让我们先在我们的服务器上添加两个新的任务:
$ grpcurl #… \
-d '{"description": "a task!"}' \
0.0.0.0:50051 todo.v2.TodoService.AddTask
$ grpcurl #… \
-d '{"description": "another task!"}' \
0.0.0.0:50051 todo.v2.TodoService.AddTask
然后,我们可以使用 ListTasks 来显示任务:
$ grpcurl #... \
-d '' \
0.0.0.0:50051 todo.v2.TodoService.ListTasks
{
"task": {
"id": "1",
"description": "a task!",
"dueDate": "1970-01-01T00:00:00Z"
},
"overdue": true
}
{
"task": {
"id": "2",
"description": "another task!",
"dueDate": "1970-01-01T00:00:00Z"
},
"overdue": true
}
你能在这里找到任何 bug 吗?如果没有,请不要担心;我们很快就会回到这个问题上。
然后,为了调用我们的客户端流式 API (UpdateTasks),我们可以在 Linux/Mac 上使用以下命令(在最后一个 EOF 后按 Enter):
$ grpcurl #… \
-d @ \
0.0.0.0:50051 todo.v2.TodoService.UpdateTasks <<EOF
{ "id": 1, "description": "a better task!" }
{ "id": 2, "description": "another better task!" }
EOF
Windows(PowerShell)用户应使用以下命令:
$ $Messages = @"
{ "id": 1, "description": "new description" }
{ "id": 2, "description": "new description" }
"@
$ grpcurl #… \
-d $Messages \
0.0.0.0:50051 todo.v2.TodoService.UpdateTasks
之后,再次调用 ListTasks 应该会显示带有新描述的数据。
现在,在执行这些函数时,你可能已经注意到了一个 bug。如果你没有,请不要担心;我们将一起解决这个问题。问题是我们可以发送一个空的 DueDate,然后它被转换为 Unix 时间(1970-01-01)中的 0 值。这个 bug 来自于 protoc-gen-validate 只在设置时检查 DueDate。
为了解决这个问题,我们可以在 todo.proto 文件中为 due_date 添加另一个验证规则。这个规则是 required。这将使得字段无法不设置。你可能认为任务不需要截止日期,但我们可以为 Adding Notes 创建一个不同的端点,并说 Tasks 应该有截止日期,但 Note 不需要。
due_date 将被定义为以下形式:
google.protobuf.Timestamp due_date = 2 [
(validate.rules).timestamp.gt_now = true,
(validate.rules).timestamp.required = true
];
我们可以重新运行 validate 插件的生成(在 chapter9 中):
$ protoc -Iproto --validate_out="lang=go,
paths=source_relative:proto" proto/todo/v2/*.proto
然后,我们关闭并重新启动我们的服务器:
$ go run ./server 0.0.0.0:50051 0.0.0.0:50052
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
如果我们重新运行之前的 AddTask 命令之一,它应该会失败:
$ grpcurl #… \
-d '{"description": "a task!"}' \
0.0.0.0:50051 todo.v2.TodoService.AddTask
ERROR:
Code: Unknown
Message: invalid AddTaskRequest.DueDate: value is
required
我们解决了 bug!这真是太棒了?
如果你现在想发送一个带有 due_data 值的请求,你必须指定一个日期,格式为 RFC3339 的字符串。以下是一个示例,使用从写作此文档之日起 500 年的 due_date 值:
$ ghz #… \
-d '{"description":"task", "due_date": "2523-06-01T14
:18:25+00:00"}' \
0.0.0.0:50051
Bazel
为了使用 Bazel 运行服务器,你必须更新依赖项。你可以运行 Gazelle 来更新 //server:server:
$ bazel run //:gazelle
然后,你将能够正常运行服务器:
$ bazel run //server:server 0.0.0.0:50051 0.0.0.0:50052
总结来说,我们看到了可以通过开启服务器反射来获取信息和与服务器交互以进行调试。我们看到了可以列出服务并描述服务和消息。我们还看到了可以调用单一 RPC 端点,最后,我们看到了也可以使用交互式终端调用流式 API。
使用 Wireshark
有时,我们需要能够检查通过线缆传输的数据。这让我们能够了解有效载荷的重量,知道我们是否执行了过多的请求等等。在本节中,我们将看到如何使用 Wireshark 分析有效载荷和请求。
为了获取可读信息,我们首先需要禁用我们在 第七章 中启用的 TLS 加密。请注意,这应该没问题,因为我们处于开发模式,但当你将代码推回生产环境时,你需要确保加密是开启的。
要禁用加密,我们将创建一个开关变量。使用这个变量,通过将 ENABLE_TLS 环境变量设置为 false 来禁用 TLS。显然,因为我们想将 TLS 设置为默认值,所以我们将检查环境变量值是否与 false 不同,这样如果值有误或未设置,TLS 将被启用。
在 server/main.go 中,我们可以有如下代码:
func newGrpcServer(lis net.Listener, srvMetrics
*grpcprom.ServerMetrics) (*grpc.Server, error) {
var credsOpt grpc.ServerOption
enableTls := os.Getenv("ENABLE_TLS") != "false"
if enableTls {
creds, err := credentials.NewServerTLSFromFile(
"./certs/server_cert.pem", "./certs/server_key.pem")
if err != nil {
return nil, err
}
credsOpt = grpc.Creds(creds)
}
//...
opts := []grpc.ServerOption{/*…*/}
if credsOpt != nil {
opts = append(opts, credsOpt)
}
//...
}
我们现在需要在客户端进行类似操作(client/main.go):
func main() {
//...
var credsOpt grpc.DialOption
enableTls := os.Getenv("ENABLE_TLS") != "false"
if enableTls {
creds, err := credentials.NewClientTLSFromFile
("./certs/ca_cert.pem", "x.test.example.com")
if err != nil {
log.Fatalf("failed to load credentials: %v", err)
}
credsOpt = grpc.WithTransportCredentials(creds)
} else {
credsOpt = grpc.WithTransportCredentials
(insecure.NewCredentials())
}
//...
opts := []grpc.DialOption{
credsOpt,
//...
}
//...
}
通过这样,我们现在可以轻松地启用/禁用 TLS。要在 Linux 或 Mac 上运行服务器而不使用 TLS,现在可以运行以下命令:
$ ENABLE_TLS=false go run ./server 0.0.0.0:50051
0.0.0.0:50052
对于 Windows(PowerShell),我们可以运行以下命令:
$ $env:ENABLE_TLS='false'; go run ./server 0.0.0.0:50051
0.0.0.0:50052; $env:ENABLE_TLS=$null
同样,对于客户端,我们可以运行以下命令(Linux/Mac):
$ ENABLE_TLS=false go run ./client 0.0.0.0:50051
对于 Windows(PowerShell),可以运行以下命令:
$ $env:ENABLE_TLS='false'; go run ./client 0.0.0.0:50051;
$env:ENABLE_TLS=$null
现在,我们已经准备好开始检查通过线缆发送的数据。在 Wireshark 中,我们首先检查我们想要拦截有效载荷的网络接口:

图 9.1 – 选择网络接口
回环接口是我们正在工作的接口:localhost。通过双击它,我们将进入记录界面。但在做之前,我们希望告诉 Wireshark 哪里可以找到我们的 proto 文件。如果没有它们,它只会显示字段标签和值。如果能同时看到字段名会更好。
要做到这一点,我们将进入 chapter9/proto 文件夹以及访问已知类型的所需文件夹。最后一个路径取决于你如何安装 protoc。以下是最常见的路径:
-
如果通过 GitHub 发布版安装并且将
include文件夹移动到/usr/local,则第二个路径是/usr/local/include。 -
如果通过
brew安装,你应该可以使用brew --prefix protobuf命令获取 protobuf 安装的路径。这将给出一个路径;只需将/include添加到路径中。 -
如果通过 Chocolatey 安装,你应该运行
choco list --local-only --exact protoc --trace命令。这将列出以.files结尾的路径。在记事本等工具中打开路径,找到包含include/google/protobuf的路径,并选择它直到include文件夹 – 例如,C:\ProgramData\chocolatey\lib\protoc\tools\include。

图 9.2 – 将路径添加到 proto 文件中
完成这些后,我们可以回到我们的回环接口,并双击它。现在,我们应该有以下记录接口。
然后,我们将输入一个过滤器,只显示端口 50051 上的请求,以及与 gRPC 和 Protobuf 相关的请求。确保你点击过滤器区域旁边的箭头;否则,你将看到接口上发出的所有请求。

图 9.3 – 输入过滤器
之后,我们可以继续运行服务器和客户端。一旦客户端执行完毕,你将在 Wireshark 中看到一些日志出现。这应该看起来像以下这样:

图 9.4 – Wireshark 中出现的日志
现在,我们可以理解通过网络发送了什么。如果我们查看有效载荷,我们应该查看 DATA (GRPC) (PROTOBUF) 帧。例如,AddTask 的 DATA 帧如下:
Protocol Buffers: /todo.v2.TodoService/AddTask,request
Message: todo.v2.AddTaskRequest
Field(1): description = This is another task
(string)
Field(2): due_date = 2023-06-01T17:06:20
.531406+0800 (message)
Message: google.protobuf.Timestamp
Field(1): seconds = 1685610380 (int64)
Field(2): nanos = 531406000 (int32)
[Message Value: 2023-06-01T17:06:
20.531406+0800]
最后,如果我们正在查看与 gRPC 相关的记录,我们可以查看 HEADERS 和 DATA (GRPC) 帧。这些可以告诉你何时发送半关闭和跟踪器以及它们的大小。ListTasks 的一个半关闭示例如下:
HyperText Transfer Protocol 2
Stream: DATA, Stream ID: 7, Length 45
Length: 45
Type: DATA (0)
Flags: 0x00
0000 .00\. = Unused: 0x00
.... 0... = Padded: False
.... ...0 = End Stream: False
0... .... .... .... .... .... ... = Reserved: 0x0
.000 0000 0000 0000 0000 0000 0000 0111 = Stream
Identifier: 7
[Pad Length: 0]
DATA payload (45 bytes)
DeleteTasks 的一个示例跟踪器如下:
HyperText Transfer Protocol 2
Stream: HEADERS, Stream ID: 13, Length 2
Length: 2
Type: HEADERS (1)
Flags: 0x05, End Headers, End Stream
00.0 ..0\. = Unused: 0x00
..0\. .... = Priority: False
.... 0... = Padded: False
.... .1.. = End Headers: True
.... ...1 = End Stream: True
0... .... .... .... .... .. .... = Reserved: 0x0
.000 0000 0000 0000 0000 0000 0000 1101 = Stream
Identifier: 13
[Pad Length: 0]
Header Block Fragment: bfbe
[Header Length: 40]
[Header Count: 2]
Header: grpc-status: 0
Header: grpc-message:
为了使这本书易于阅读,我们不得不在这里结束这一节。然而,还有很多东西可以查看和发现。我们看到了可以使用 Wireshark 截获线上的消息。我们创建了一个开关变量,以便暂时禁用 TLS,以便不读取加密数据。我们将 protobuf 消息加载到 Wireshark 中,以便它知道如何反序列化消息。最后,我们看到了我们可以查看消息,以及 HTTP2 协议的更低级别的部分。
打开 gRPC 日志
最后,如果你准备比 Wireshark 更低级别地调试 gRPC 应用程序,gRPC 提供了两个重要的环境变量来获取框架的日志。
第一个环境变量是 GRPC_GO_LOG_SEVERITY_LEVEL。它将根据某些严重级别(debug、info 或 error)提供 gRPC 编写的日志。要启用此功能,你可以简单地使用 GRPC_GO_LOG_SEVERITY_LEVEL 在二进制文件或 Go 命令之前执行。我们用自定义的 ENABLE_TLS 变量做了类似的事情。
在启动服务器和关闭服务器时设置 GRPC_GO_LOG_SEVERITY_LEVEL 为 info 的示例如下(对于 Linux/Mac):
$ GRPC_GO_LOG_SEVERITY_LEVEL=info go run ./server
0.0.0.0:50051 0.0.0.0:50052
INFO: [core] [Server #1] Server created
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
INFO: [core] [Server #1 ListenSocket #2] ListenSocket
created
shutting down servers, please wait...
INFO: [core] [Server #1 ListenSocket #2] ListenSocket
deleted
gRPC server shutdown
metrics server shutdown
对于 Windows(PowerShell),我们有以下内容:
$ $env:GRPC_GO_LOG_SEVERITY_LEVEL='info'; go run ./server
0.0.0.0:50051 0.0.0.0:50052;
$env:GRPC_GO_LOG_SEVERITY_LEVEL=$null
在严重程度级别之上,您还可以使用 GRPC_GO_LOG_VERBOSITY_LEVEL 设置这些日志的详细程度,它接受一个介于 2 到 99 之间的数字,数字越大,日志越详细。这不会在短期运行时出现,比如我们现在所拥有的。这在长期运行中更有用,我们通常为服务器进行长期运行。要启用它,我们在 GRPC_GO_LOG_SEVERITY_LEVEL 之后添加 GRPC_GO_LOG_VERBOSITY_LEVEL:
$ GRPC_GO_LOG_SEVERITY_LEVEL=info GRPC_GO_LOG
_VERBOSITY_LEVEL=99 go run ./server 0.0.0.0:50051
0.0.0.0:50052
最后,我知道我说过有两个重要的环境变量,但还有一个值得提及。如果您计划解析日志,这个变量很重要。您可以设置日志的格式化器。到目前为止,我们有以下设置:
INFO: [core] [Server #1] Server created
但我们可以将格式设置为 JSON 以获取以下内容:
{"message":"[core] [Server #1] Server created\n",
"severity":"INFO"}
现在,您将能够反序列化 JSON 并实现所有需要的工具来监控和报告错误。
总结来说,在本节中,我们看到了我们可以获取我们未编写的代码的信息:gRPC 框架。我清楚本节中展示的例子是表面的,但通常,这些标志是在真正出错或您参与 gRPC Go 本身开发时设置的。我仍然认为了解它们的存续很重要,并鼓励您尝试从中获取更有趣的消息。
调试的方法有无数种,取决于需求和设置。因此,我们无法在此涵盖所有内容,但至少您有了基本的技能和工具来开始破解。在本节中,我们看到了我们可以启用服务器反射以从服务器获取信息,并使用 grpcurl 与其交互。我们还看到了我们可以使用 Wireshark 截获消息以了解请求及其大小。最后,我们看到了我们可以打开一个特定的标志来获取 gRPC 的日志。在进入下一节之前,我想提到还有一个可能对您有用的工具,我们没有在此涵盖。这个工具叫做 Channelz (grpc.io/blog/a-short-introduction-to-channelz/)。它的目的是调试网络问题。您可能想看看它。
部署
生产级 API 的另一个关键步骤是将服务在线部署。在本节中,我们将看到如何为 gRPC Go 创建 Docker 镜像,将其部署到 Kubernetes,最后部署 Envoy 代理以允许客户端从集群外部向集群内部的服务器发送请求。
Docker
部署的第一步通常是使用 Docker 容器化您的应用程序。如果我们没有这样做,我们就必须处理与服务器架构相关的错误,工具不可用等问题。通过容器化我们的应用程序,我们只需构建一次镜像,就可以在任何有 Docker 的地方运行。
我们将专注于容器化我们的服务器。与客户端相比,这更有意义,因为我们将在 Kubernetes 中将我们的 gRPC 服务器作为微服务部署,并且我们将使客户端(外部)向它们发出请求。
我们首先可以想到的是构建我们应用程序所需的步骤。我们已经运行了它很多次,但我们需要记住最初设置的所有的工具。这包括以下内容:
-
使用 protoc 编译我们的 proto 文件
-
Proto Go、gRPC 和
validate插件,用于从 proto 文件生成 Go 代码 -
显然,是 Golang
让我们从获取 protoc 开始。为此,我们将基于 Alpine 创建一个第一阶段,使用 wget 获取 protoc ZIP 文件并在 /usr/local 内解压它。如果你不耐烦,可以在 server/Dockerfile 中找到整个 Dockerfile,但我们将会一步一步地解释它:
FROM --platform=$BUILDPLATFORM alpine as protoc
ARG BUILDPLATFORM TARGETOS TARGETARCH
RUN export PROTOC_VERSION=23.0 \
&& export PROTOC_ARCH=$(uname -m | sed
s/aarch64/aarch_64/) \
&& export PROTOC_OS=$(echo $TARGETOS | sed
s/darwin/linux/) \
&& export PROTOC_ZIP=protoc-$PROTOC_VERSION-$PROTOC_OS-
$PROTOC_ARCH.zip \
&& echo "downloading: " https://github.com/
protocolbuffers/protobuf/releases/download/
v$PROTOC_VERSION/$PROTOC_ZIP \
&& wget https://github.com/protocolbuffers/protobuf/
releases/download/v$PROTOC_VERSION/$PROTOC_ZIP \
&& unzip -o $PROTOC_ZIP -d /usr/local bin/protoc
'include/*' \
&& rm -f $PROTOC_ZIP
这里发生了很多事情。首先请注意,我们正在使用 Docker BuildKit 引擎。这让我们可以使用定义的变量,如 BUILDPLATFORM、TARGETOS 和 TARGETARCH。我们这样做是因为尽管我们正在容器化我们的应用程序以避免处理架构,但运行与主机(虚拟化)具有相同架构的容器(容器)比仿真要高效得多。此外,正如你所见,我们需要在 URL 中指定架构和操作系统以下载 protoc。
然后,我们定义一些对于构建下载 URL 重要的变量。我们设置 protoc 的版本(这里为 23.0)。然后,我们设置我们想要工作的架构。这是基于 uname –m 的结果,它提供了有关机器的信息。请注意,我们使用了一个小技巧将 aarch64 替换为 aarch_64。这是因为如果你查看 Protobuf 存储库的发布版(https://github.com/protocolbuffers/protobuf/releases),它们在 ZIP 文件名中使用 aarch_64。
之后,我们使用 TARGETOS 变量来定义我们想要处理的操作系统。请注意,再次使用类似的小技巧将 darwin 替换为 linux。这仅仅是因为 protoc 没有针对 macOS 的特定二进制文件。你可以简单地使用 Linux 的。
然后,我们通过连接之前定义的所有变量来实际下载文件,并将文件解压到 /usr/local。请注意,我们正在提取 protoc 二进制文件(/bin/protoc)和 /include 文件夹,因为前者是我们将要使用的编译器,后者是包含 Well-Known Types 所需的所有文件。
现在已经完成,我们可以为使用 Go 构建应用程序创建另一个阶段。在这里,我们将从上一个阶段复制 protoc,下载 protoc 插件,编译 proto 文件,并编译 Go 项目。我们将使用基于 Alpine 的镜像来完成这项工作:
FROM --platform=$BUILDPLATFORM golang:1.20-alpine as build
ARG BUILDPLATFORM TARGETOS TARGETARCH
COPY --from=protoc /usr/local/bin/protoc /usr/local/
bin/protoc
COPY --from=protoc /usr/local/include/google /usr/local/
include/google
RUN go install google.golang.org/protobuf/cmd/protoc-gen-
go@latest
RUN go install google.golang.org/grpc/cmd/protoc-gen-go-
grpc@latest
RUN go install github.com/envoyproxy/protoc-gen-
validate@latest
WORKDIR /go/src/proto
COPY ./proto .
RUN protoc –I. \
--go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
--validate_out="lang=go,paths=source_relative:." \
**/*.proto
WORKDIR /go/src/server
COPY ./server .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go
build -ldflags="-s -w" -o /go/bin/server
到目前为止,这一切都不应该让人感到困惑。这正是我们在本书前面所做过的。然而,我想提及一些在这里发生的不平凡的事情。我们再次使用 BuildKit 定义的参数。这使得我们可以使用GOOS和GOARCH环境变量来为这个特定设置构建 Go 二进制文件。
此外,请注意,我们正在复制 protoc 和include文件夹。正如所述,后者是包含已知类型的目录,我们在我们的 proto 文件中使用了一些类型,因此这是必要的。
最后,我使用了两个链接器标志。-s标志在这里是为了禁用 Go 符号表的生成。虽然我不会深入探讨这意味着什么,但有时在创建较小的二进制文件时,会使用它来删除不应影响运行时功能的一些信息。-w移除调试信息。由于这些信息在生产环境中不是必需的,我们可以直接删除它们。
最后,我们将构建我们的最后一个阶段,它将基于一个 scratch 镜像。这是一个没有操作系统的镜像,我们用它来托管二进制文件并使我们的镜像非常小。在那里,我们将我们的证书复制到certs目录中,复制我们使用go build创建的二进制文件,并使用我们通常设置的参数启动应用程序:
FROM scratch
COPY ./certs/server_cert.pem ./certs/server_cert.pem
COPY ./certs/server_key.pem ./certs/server_key.pem
COPY --from=build /go/bin/server /
EXPOSE 50051 50052
CMD ["/server", "0.0.0.0:50051", "0.0.0.0:50052"]
有了这些,我们就准备好构建我们的第一个服务器镜像。我们可以创建的第一件事是一个 Docker Builder。正如 Docker 文档所述:“Builder 实例是构建可以调用的隔离环境。”这基本上是我们需要启动镜像构建的环境。为了创建它,我们可以运行以下命令:
重要提示
你需要确保 Docker 正在运行。这就像确保 Docker Desktop 正在运行一样简单。最后,如果你在 Linux/Mac 上,并且你没有创建 Docker 组并将你的用户添加到其中,你可能需要将所有以下 Docker 命令的前缀设置为sudo。
$ docker buildx create --name mybuild --driver=docker-
container
注意,我们给这个构建环境命名为mybuild,并且我们使用的是docker-container驱动程序。这个驱动程序将允许我们生成多平台镜像。我们将在稍后看到这一点。
执行完命令后,我们将能够在另一个 Docker 命令中使用这个 Builder:docker buildx build。使用这个命令,我们将生成镜像。我们将给它一个标签(一个名称),指定 Dockerfile 的位置,指定我们想要构建的架构,并将镜像加载到 Docker 中。要为arm64(你可以尝试amd64)构建镜像,我们从chapter9运行以下命令:
$ docker buildx build \
--tag clementjean/grpc-go-packt-book:server \
--file server/Dockerfile \
--platform linux/arm64 \
--builder mybuild \
--load .
一切构建完成后,我们可以通过执行以下命令来查看镜像:
$ docker image ls
REPOSITORY TAG SIZE
clementjean/grpc-go-packt-book server 10.9MB
最后,让我们尝试运行服务器镜像并向其发送请求。我们将运行我们刚刚创建的镜像,并将我们用于服务器的端口(50051和50052)暴露在主机上的相同端口:
$ docker run -p 50051:50051 -p 50052:50052
clementjean/grpc-go-packt-book:server
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
现在,如果我们正常运行我们的客户端,我们应该能够获取我们之前所有的日志:
$ go run ./client 0.0.0.0:50051
总结来说,我们看到了我们可以在我们的 gRPC 应用程序周围创建瘦镜像。我们使用了一个多阶段 Dockerfile,首先下载了 protoc 和 Protobuf Well-Known Types。然后下载了所有 Golang 依赖项并构建了一个二进制文件,最后将二进制文件复制到一个 scratch 镜像中,以创建一个围绕它的薄包装。
Kubernetes
现在我们已经有了我们的服务器镜像,我们可以部署我们服务的多个实例,这样我们就已经创建了我们的待办微服务。在本节中,我们将主要关注如何部署我们的 gRPC 服务。这意味着我们将编写一个 Kubernetes 配置。如果你不熟悉 Kubernetes,没有必要害怕。我们的配置很简单,我会解释所有的块。
我们首先需要考虑的是我们的服务如何被访问。我们有两种主要方式来公开我们的服务:只从集群内部访问或从集群外部访问。在大多数情况下,我们不希望我们的服务被直接访问。我们希望通过一个代理来访问,该代理将重定向并将请求负载均衡到我们服务的多个实例。
因此,我们将创建一个 Kubernetes 服务,它将为我们的服务所有实例分配一个 DNS A 记录。这基本上意味着我们的每个服务都将有一个集群内部的独立地址。这将让我们的代理解析所有地址并在它们之间进行负载均衡。
这样的服务被称为无头服务。在 Kubernetes 中,这是一个将clusterIp属性设置为None的服务。以下是服务定义(k8s/server.yaml):
apiVersion: v1
kind: Service
metadata:
name: todo-server
spec:
clusterIP: None
ports:
- name: grpc
port: 50051
selector:
app: todo-server
注意我们创建了一个名为grpc的端口,其值为50051。这是因为我们希望能够访问端口50051上的所有服务。然后,请注意我们正在创建一个选择器来指定这个服务将处理哪个应用程序。在我们的例子中,我们称之为todo-server,这将是我们的部署名称。
现在,我们可以考虑创建我们服务的实例。我们将使用 Kubernetes Deployment 来完成这项工作。这将让我们指定我们想要多少实例,使用哪个镜像,以及使用哪个容器端口。这看起来如下(k8s/server.yaml):
apiVersion: apps/v1
kind: Deployment
metadata:
name: todo-server
labels:
app: todo-server
spec:
replicas: 3
selector:
matchLabels:
app: todo-server
template:
metadata:
labels:
app: todo-server
spec:
containers:
- name: todo-server
image: clementjean/grpc-go-packt-book:server
imagePullPolicy: Always
ports:
- name: grpc
containerPort: 50051
在这里,我们指定 Pod 的名称将与todo-server匹配。这使得它们由服务处理。然后,我们指定我们想要使用我们之前创建的镜像。然而,请注意,我们在这里将imagePullPolicy设置为Always。这意味着每次我们创建 Pod 时,它们都会从镜像仓库拉取一个新的镜像。这确保了我们总是获取仓库上的最新镜像。然而,请注意,如果镜像不经常更改,并且你有本地副本的镜像且这些镜像不是过时的,这可能会效率低下。我建议你根据你的 Kubernetes 环境,检查imagePullPolicy的值应该使用什么。最后,我们使用端口50051。这并没有比指定我们的服务在哪个端口上暴露 API 更多。
重要提示
在本章的剩余部分,我期望你已经有一个 Kubernetes 集群。如果你在云中有,这很完美,你可以继续。如果你没有,你可以参考 Kind (kind.sigs.k8s.io/),一旦安装,你可以使用在k8s/kind.yaml中提供的配置创建一个简单的集群。只需运行kind create cluster --config k8s/kind.yaml。
现在,我们可以部署我们的三个服务。我们将从chapter9文件夹运行以下命令:
$ kubectl apply -f k8s/server.yaml
我们将执行以下命令来查看正在创建的 Pod:
$ kubectl get pods
NAME READY STATUS
todo-server-7d874bfbdb-2cqjn 1/1 Running
todo-server-7d874bfbdb-gzfch 1/1 Running
todo-server-7d874bfbdb-hkmtp 1/1 Running
现在,由于我们没有代理,我们将简单地使用 Kubernetes 的port-forward命令来访问一个服务器并查看它是否工作。这纯粹是为了测试目的,我们将在稍后看到如何通过代理隐藏服务。所以,我们运行以下命令:
$ kubectl port-forward pod/todo-server-7d874bfbdb-2cqjn
50051
Forwarding from 127.0.0.1:50051 -> 50051
Forwarding from [::1]:50051 -> 50051
然后,我们应该能够在localhost:50051上正常使用我们的客户端:
$ go run ./client 0.0.0.0:50051
总结来说,我们看到了我们可以使用无头服务为部署中的每个 Pod 创建一个 DNS A 记录。然后我们部署了三个 Pod,并看到我们可以通过在kubectl中使用port-forward命令来测试它们是否工作。
Envoy 代理
现在我们已经创建了我们的微服务,我们需要添加一个代理来在它们之间平衡负载。这个代理是 Envoy。这是少数几个可以与 gRPC 服务交互的代理之一。我们将看到如何设置 Envoy 来将流量重定向到我们的服务,使用轮询算法进行负载均衡,并启用 TLS。
让我们首先专注于编写一个监听器。这是一个指定要监听地址和端口的实体,并定义了一些过滤器。这些过滤器,至少在我们的案例中,将允许我们将todo.v2.TodoService的请求路由到 Envoy 集群。集群是允许我们定义实际端点并展示如何进行负载均衡的实体。我们首先编写我们的监听器(envoy/envoy.yaml):
node:
id: todo-envoy-proxy
cluster: grpc_cluster
static_resources:
listeners:
- name: listener_grpc
address:
socket_address:
address: 0.0.0.0
port_value: 50051
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions
.filters.network.http_connection_manager.
v3.HttpConnectionManager
stat_prefix: listener_http
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions
.filters.http.router.v3.Router
route_config:
name: route
virtual_hosts:
- name: vh
domains: ["*"]
routes:
- match:
prefix: /todo.v2.TodoService
grpc: {}
route:
cluster: grpc_cluster
需要注意的最重要的事情是,我们定义了一个匹配来自任何域名且匹配/todo.v2.TodoService前缀的所有 gRPC 请求的路由。然后,所有这些请求都将被重定向到grpc_cluster。
之后,让我们定义我们的集群。我们将使用STRICT_DNS解析通过 DNS A 记录检测所有 gRPC 服务。然后,我们将指定我们只接受 HTTP/2 请求。这是因为,正如你所知,gRPC 基于 HTTP/2。之后,我们将设置负载均衡策略为轮询。最后,我们将指定端点的地址和端口:
clusters:
- name: grpc_cluster
type: STRICT_DNS
http2_protocol_options: {}
lb_policy: round_robin
load_assignment:
cluster_name: grpc_cluster
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: "todo-server.default.svc
.cluster.local"
port_value: 50051
注意我们使用的是 Kubernetes 生成的地址。其形式为$SERVICE_NAME-$NAMESPACE-svc-cluster.local。
为了测试我们的配置,我们首先可以在本地运行一切。我们将临时将listener_0端口设置为50050,以免与我们的服务器端口冲突:
static_resources:
listeners:
- name: listener_grpc
address:
socket_address:
address: 0.0.0.0
port_value: 50050
我们还必须将端点地址设置为 localhost,以便访问本地运行的服务器:
- endpoint:
address:
socket_address:
address: 0.0.0.0
port_value: 50051
然后,我们将运行我们的服务器:
$ go run ./server 0.0.0.0:50051 0.0.0.0:50052
metrics server listening at 0.0.0.0:50052
gRPC server listening at 0.0.0.0:50051
我们现在可以使用func-e运行我们的 envoy 实例:
$ func-e run -c envoy/envoy.yaml
最后,我们可以在端口50050而不是50051上运行我们的客户端:
$ go run ./client 0.0.0.0:50050
--------ADD--------
2023/06/04 11:36:45 rpc error: code = Unavailable desc =
last connection error: connection error: desc = "transport:
authentication handshake failed: tls: first record does not
look like a TLS handshake"
如你所猜,这是因为 Envoy 在某种程度上破坏了服务器和客户端之间的 TLS 连接。为了解决这个问题,我们将指定我们的集群上游使用 TLS,并且我们的监听器下游也使用 TLS。
在过滤器中,我们将告诉 Envoy 在哪里找到我们的自签名证书:
#...
filter_chains:
- filters:
#...
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions
.transport_sockets.tls.v3.DownstreamTlsContext
common_tls_context:
tls_certificates:
- certificate_chain:
filename: /etc/envoy/certs/server_cert.pem
private_key:
filename: /etc/envoy/certs/server_key.pem
注意,这很可能不是你会在生产中做的事情。你将使用像 Let’s Encrypt 这样的工具来自动生成你的证书并将它们链接起来。
现在,我们将告诉集群上游也使用 TLS:
clusters:
- name: grpc_cluster
#...
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions
.transport_sockets.tls.v3.UpstreamTlsContext
显然,这不会直接工作。在我们的本地计算机上,我们没有/etc/envoy/certs/server_cert.pem和/etc/envoy/certs/server_key.pem文件。但我们在chapter9的certs文件夹中有它们。我们将临时替换它们:
- certificate_chain:
filename: ./certs/server_cert.pem
private_key:
filename: ./certs/server_key.pem
现在让我们杀死之前的 Envoy 实例并重新运行它:
$ func-e run -c envoy/envoy.yaml
最后,我们应该能够运行我们的客户端并从我们的服务器接收响应:
$ go run ./client 0.0.0.0:50050
我们现在可以确定我们的请求是通过 Envoy 并且被重定向到我们的 gRPC 服务器。下一步将是撤销我们为测试所做的所有临时更改(监听器端口到50051,端点地址到todo-server.default.svc.cluster.local,以及certs路径到/etc/envoy),并创建一个我们将用于在 Kubernetes 集群中部署 Envoy 的 Docker 镜像。
要构建这样的镜像,我们将证书复制到/etc/envoy/certs(再次强调,在生产环境中不推荐这样做)和配置(envoy.yaml)到/etc/envoy。最后,这个镜像将使用带有--config-path标志的envoy命令运行,该标志将指向/etc/envoy/envoy.yaml路径。在envoy/Dockerfile中,我们有以下内容:
FROM envoyproxy/envoy-distroless:v1.26-latest
COPY ./envoy/envoy.yaml /etc/envoy/envoy.yaml
COPY ./certs/server_cert.pem /etc/envoy/certs/
server_cert.pem
COPY ./certs/server_key.pem /etc/envoy/certs/server_key.pem
EXPOSE 50051
CMD ["--config-path", "/etc/envoy/envoy.yaml"]
我们现在可以构建arm64(你可以使用amd64)的镜像,如下所示:
$ docker buildx build \
--tag clementjean/grpc-go-packt-book:envoy-proxy \
--file ./envoy/Dockerfile \
--platform linux/arm64 \
--builder mybuild \
--load .
就这样!我们准备好在 TODO 微服务前部署 Envoy 了。我们需要一个无头服务来为 Envoy 服务。这与我们为微服务创建无头服务时的原因相同。在生产环境中,可能存在多个 Envoy 实例,你需要确保它们都是可访问的。在envoy/service.yaml中,我们有以下内容:
apiVersion: v1
kind: Service
metadata:
name: todo-envoy
spec:
clusterIP: None
ports:
- name: grpc
port: 50051
selector:
app: todo-envoy
然后,我们需要创建一个Deployment。这次,因为我们处于开发环境,我们将只为 Envoy 部署一个 Pod。其余的配置与我们在 gRPC 服务器上所做的类似。在envoy/deployment.yaml中,我们有以下内容:
apiVersion: apps/v1
kind: Deployment
metadata:
name: todo-envoy
labels:
app: todo-envoy
spec:
replicas: 1
selector:
matchLabels:
app: todo-envoy
template:
metadata:
labels:
app: todo-envoy
spec:
containers:
- name: todo-envoy
image: clementjean/grpc-go-packt-book:envoy-proxy
imagePullPolicy: Always
ports:
- name: grpc
containerPort: 50051
我们现在可以运行所有这些。我假设你没有拆掉我们之前为部署微服务所做的步骤。现在,你应该有以下内容:
$ kubectl get pods
NAME READY STATUS
todo-server-7d874bfbdb-2cqjn 1/1 Running
todo-server-7d874bfbdb-gzfch 1/1 Running
todo-server-7d874bfbdb-hkmtp 1/1 Running
因此,现在我们可以首先添加服务,然后添加 Envoy 的部署:
$ kubectl apply -f envoy/service.yaml
$ kubectl apply -f envoy/deployment.yaml
$ kubectl get pods
NAME READY STATUS
todo-envoy-64db4dcb9c-s2726 1/1 Running
todo-server-7d874bfbdb-2cqjn 1/1 Running
todo-server-7d874bfbdb-gzfch 1/1 Running
todo-server-7d874bfbdb-hkmtp 1/1 Running
最后,在运行客户端之前,我们可以使用port-forward命令将 Envoy 的端口50051转发到localhost:50051:
$ kubectl port-forward pod/todo-envoy-64db4dcb9c-s2726
50051
Forwarding from 127.0.0.1:50051 -> 50051
Forwarding from [::1]:50051 -> 50051
我们可以运行客户端,并且应该能够得到一些结果:
$ go run ./client 0.0.0.0:50051
//...
error while receiving: rpc error: code = Internal desc =
unexpected error: task with id 1 not found
注意,由于负载均衡以及我们没有使用真实数据库,Pod 无法找到存储在其他 Pod 内存中的任务。在我们的情况下这是正常的,但在生产环境中,你会依赖于共享数据库,这些问题就不会出现。
总结来说,我们看到了我们可以在服务前实例化 Envoy,以使用特定的负载均衡策略重定向请求。这次,与我们在第七章中看到的负载均衡不同,客户端实际上并不知道任何服务器地址。它连接到 Envoy,Envoy 正在重定向请求和响应。显然,我们没有涵盖 Envoy 的所有可能配置,我建议你查看其他功能,如速率限制和身份验证。
摘要
在本章中,我们介绍了单元测试和负载测试。我们看到了通过广泛测试我们系统的不同部分,我们可以找到错误和性能问题。然后,我们看到了当我们找到错误时如何调试我们的应用程序。我们使用了服务器反射和 grpcurl 从终端与我们的 API 交互。最后,我们看到了我们如何容器化我们的服务并在 Kubernetes 上部署它们。我们看到了我们可以创建无头服务,通过每个 gRPC 服务器的 DNS A 记录来公开我们的微服务,并且我们看到了我们可以在它们前面放置 Envoy 来进行负载均衡、速率限制、身份验证等。
问答
-
哪个工具对负载测试有用?
-
Wireshark
-
grpcurl
-
ghz
-
-
在 Wireshark 中,你可以查看哪些信息?
-
gRPC HTTP/2 帧
-
Protobuf 消息
-
所有这些
-
-
Envoy 用于什么?
-
重定向请求和响应
-
记录日志
-
暴露指标
-
负载均衡
-
A 和 D
-
B 和 C
-
答案
-
C
-
C
-
E
挑战
-
添加对真实数据库的支持。你应该可以通过实现
db接口并在注册的服务器实例中创建你的结构体实例来实现这一点。 -
在你的 Kubernetes 集群中公开 Prometheus 指标。你可以查看
prometheus-operator(github.com/prometheus-operator/prometheus-operator)。
尾声
随着我们来到这本关于在 Golang 中构建 gRPC 微服务的书籍的结尾,我希望你发现 gRPC Go 既有趣又有用,并且你愿意在下一个项目中尝试它。这本书是我开始学习这项令人惊叹的技术时希望拥有的书籍,并且希望它在任何方面都对你有所帮助。
在这本书中,我们共同探讨了 gRPC 服务的一些理论元素和一些实际实现。从学习网络概念到纯实现和我们可以使用的工具,再到学习在设计 API 时有用的考虑因素,你学习了作为后端工程师职业生涯中最重要的技能。
为了结束这本书,我想邀请你关注所有与 gRPC 和 Protobuf 相关的主题。你可以通过关注 GitHub Topics、阅读一些博客文章或者简单地参与一些开源项目来实现这一点。这是一个需要更多关注、更多通过构建工具提供帮助以及更多人在全球范围内建立社区的后端工程领域。
感谢你陪伴我走过这段制作生产级 gRPC API 的旅程。我祝愿你在未来的努力中一切顺利。愿你能创造出创新和有效的 API。
祝你工程愉快!



浙公网安备 33010602011771号