gRPC 实现原理

Posted 鑫炜这么说

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了gRPC 实现原理相关的知识,希望对你有一定的参考价值。

原文摘抄自《gRPC与云原生应用开发 以Go和Java为例》2021年1月第一版 第四章 gRPC的底层原理

gRPC 应用程序使用 RPC 通过网络进行通信。对于实现 RPC 的底层细节、所使用的消息编码技术以及在网络中的运行方式,这些方面都无需 gPRC 应用程序开发人员担心,只需要使用服务定义来生成所选语言对应的服务器端代码和客户端代码即可。所有的底层通信细节都是由所生成的代码实现,你需要做的就是处理高层级的抽象。但是构建基于 gPRC 的复杂系统并在生产环境中运行他们时,从根本上了解 gRPC 的工作原理十分重要。

1. RPC 流

在 RPC 系统中,服务器端会实现一组可以远程调用的方法。客户端会生成一个存根,该存根为服务器端的方法提供抽象。这样一来,客户端应用程序可以直接调用存根方法,进而调用服务器端应用程序的远程方法。以客户端调用远程 getProduct 方法为例,有几个关键步骤:

  1. 客户端进程通过生成的存根调用 getProduct 方法;

  2. 客户端存根使用已编码的消息创建 HTTP POST 请求。在 gRPC 中,所有的请求都是 HTTP POST 请求,并且 content-type 前缀为 application/grpc。要调用的远程方法(/PeoductInfo/getProduct)是以单独的 HTTP 头信息的形式发送的;

  3. HTTP 请求消息通过网络发送到服务器端;

  4. 当接收到消息后,服务器端检查消息头信息,从而确定需要调用的服务方法,然后将消息传递给服务器端骨架;

  5. 服务器端骨架将消息字节解析成特定语言的数据结构;

  6. 借助解析后的消息,服务发起对 getProduct 方法的本地调用。

服务方法的响应经过编码后被发送回客户端。响应消息会遵循我们在客户端上所观察到的相同过程(响应 -> 编码 -> 线路上的HTTP响应),该消息会被解包,它的值将返回给等待的客户端进程。这些步骤与大多数 RPC 系统非常相似,如 CORBA、Java RMI 等。这里 gPRC 的主要区别在于消息的编码方式。在消息编码方面,gRPC 使用了 protocol buffers。只需定义数据该如何进行结构化,就可以使用专门生成的源代码,轻松地在各种数据流之间写入和读取结构化数据。

2. 使用 protocol buffers 编码消息

使用 protocol buffers 定义服务,具体包括定义服务中的远程方法以及希望通过网络发送的消息。正确的定义消息非常重要,这决定了消息该如何进行编码。在接下来要讨论的 ProductID 消息结构中,有一个名为 value 的字段,并且字段索引为 1。当创建 value 值为 15 的消息实例时,对应的字节内容会包含一个用于 value 字段的标识符,随后是其编码后的值。字段的标识符也被称为标签(tag):

 
   
   
 
  1. message ProductID {

  2. string value = 1;

  3. }

如下图所示,其中每个字段包含一个字段标识符及其编码后的值。标签由两个值构成:字段索引和线路类型(wire type)。字段索引就是在 proto 文件中定义消息时,为每个消息字段所设置的唯一数字。线路类型是基于字段类型,也就是能够为字段输入值的数据类型。线路类型会提供信息来确定值的长度。下表展示了线路类型如何映射为字段类型,这些都是预定义的线路类型和字段类型的映射。

线路类型 分类 字段类型
0 Varint int32、int64、uint32、uint64、sint32、sint64、bool、enum
1 64位 fixed64、sfixed64、double
2 基于长度分隔 string、bytes、嵌入式消息、打包的repeated字段
3 起始组 groups(已废弃)
4 结束组 groups(已废弃)
5 32位 fixed32、sfixed32、float

可以使用下面公式来确定其标签的值。这里将表示字段索引的二进制左移3位并与表示线路类型的值进行按位或操作:

 
   
   
 
  1. Tag value = (field index << 3) | wire_type

下一步就是编码消息字段的值。protocol buffers 使用不同的编码技术来编码不同类型的数据。对于字符串,protocol buffers 会使用 UTF-8 对值进行编码;对于 int32 字段类型的整型值,它会使用名为 Varint 的编码技术。当消息编码后,标签和值会连接到一个字节流中。上图展示了如何在消息有多个字段的情况下,将字段值安排成字节流。流的结束会通过发送值为0的标签来进行标记。

2.1 编码技术

protocol buffers 支持很多种编码技术,它会根据数据类型使用不同的编码技术。在设计消息定义时,了解各种数据类型对应的编码技术很重要,这样做能够为每个消息字段设置最合适的数据类型,从而让消息能够在运行时高效编码。protocol buffers 所支持的字段类型被分成了不同的组,每组使用不同的技术来编码值。

2.1.1 Varint 类型

Varint(可变长度整数)是使用单字节或多字节来序列化整数的方法。它基于这样一种思想:由于大多数数字并非均匀分布,因此为每个值所分配的字节数量是不固定的,而是依赖于具体的值。如上表所示的 int32、int64、uint32、uint64、sint32、sint64、bool 和 enum 这样的字段类型属于 Varint 类型,并且会按照 Varint 进行编码。在 Varint 中,除了最后的字节,其他所有字节都会设置最高有效位(most significant bit,MSB),表明后面还有字节。每字节中较低的 7 位用来存储数字的二进制补码形式。同时,最低有效组放到前面,这意味着我们要在低阶组中添加延续位。

2.1.2 有符号整数类型

有符号整数是能够表示正整数值和负整数值的类型。像 sint32 和 sint64 这样的字段类型就是有符号整数。对于有符号类型,会使用 zigzag 编码来将有符号整数转换成无符号整数。随后,无符号整数会使用前面的 Varint 编码技术来进行编码。在 zigzag 编码中,有符号整数会将负整数和正整数以“之”字形的方式映射为无符号整数。下表展示了如何使用 zigzag 编码实现映射:

原始值 映射值
0 0
-1 1
1 2
-2 3
2 4

原始的负值匹配为奇数正值,原始的正值则匹配为偶数正值。通过 zigzag 编码后,不管原始值的符号是什么,得到的都是正数。在得到正数之后,就可以使用 Varint 对值进行编码。对于负整数,推荐使用像 sint32 和 sint64 这样的有符号整数类型,这是因为如果使用向 int32 或 int64 这样的常规类型,就意味着使用 Varint 编码将负值转换成二进制值,但这比转换正值要使用更多的字节。

2.1.3 非 Varint 类型

非 Varint 类型恰好与 Varint 类型相反。他们分配固定数量的字节,字节数与实际值没有关系。protocol buffers 有两个线路类型属于非 Varint 类型,其中一个用来表示 64 位的数据类型,如 fixed64、sfixed64 和 double;另一个用来表示 32 位的数据类型,如 fixed32、sfixed32 和 float。

2.1.4 字符串类型

在 protocol buffers 中,字符串类型属于基于长度分隔(length-delimited)的线路类型,这意味着首先会有一个经过 Varint 编码的长度值,随后才是指定数量的字节数据。字符串值会使用 UTF-8 字符编码格式来进行编码。

3. 基于长度前缀的消息分帧

通常,消息分帧会构建消息和通信,以便于目标受众很容易的提取信息。对 gRPC 通信来说,情况同样如此。一旦获取了要发送给另一方的已编码数据,就需要以对方易于提取信息的方式打包数据。为了打包要通过网络发送的信息,gRPC 使用了名为长度前缀分帧的消息分帧技术。长度前缀分帧是指在写入消息本身之前,写入长度信息,来表明每条消息的大小。在 gRPC 通信中,每条消息都有额外的 4 字节用来设置其大小。消息大小是一个有限的数字,为其分配 4 字节来表示消息的大小,也就意味着 gRPC 通信可以处理大小不超过 4GB 的所有消息。

当消息使用 protocol buffers 编码时,会得到二进制格式的消息。然后,计算二进制内容大小,并以大端格式将其添加到二进制内容的前面。

除了消息的大小,帧中还有单字节的无符号整数,用来表明数据是否进行了压缩。对于客户端的请求消息,收件方是服务器,而对于响应消息,收件方是客户端。在收件方一侧,当收到消息之后,首先要读取其第一字节,来检查该消息是否经过压缩。然后,收件方读取接下来的 4 字节,以获取编码二进制消息的大小,接着就可以从流中精确的读取确切长度的字节了。对于简单消息,只需处理一条以长度为前缀的消息;而对于流消息,就会有多条以长度为前缀的消息处理。(即判断是否压缩 -> 获取消息大小 -> 读取消息) 目前,gRPC 核心支持 3 种传输实现:HTTP/2、Cronet 和进程内(in-process)。在这 3 种实现中,最常见的是 HTTP/2。

4. 基于 HTTP/2 的 gRPC

gRPC 使用 HTTP/2 作为其传输协议,实现通过网络发送消息。这也是 gRPC 能够成为高性能 RPC 框架的原因之一。

在 HTTP/2 中,客户端和服务端的所有通信都是通过一个 TCP 连接完成的,这个连接可以传送任意数量的双向字节流。为了理解 HTTP/2 的过程,最好熟悉下面这些重要术语。

  • 流:在一个已建立的连接上的双向字节流。一个流可以携带一条或多条消息。

  • 帧:HTTP/2 中最小的通信单元。每一帧都是包含一个帧头,它至少要标记该帧所属的流。

  • 消息:完整的帧序列,映射为一条逻辑上的 HTTP 消息,由一帧或多帧组成。这样的话,允许消息进行多路复用,客户端和服务器端能够将消息分解成独立的帧,交叉发送它们,然后在另一端进行重新组合。

gRPC 通道代表一个到端点的连接,也就是一个HTTP/2 连接。当客户端应用程序创建gRPC通道的时候,它会在幕后创建一个到服务器端的 HTTP/2 连接。在通道创建完成之后,就可以重用它来发送多个到服务器端的远程调用。这些远程调用会映射为 HTTP/2 中的流。远程调用中的消息以 HTTP/2 帧的形式进行发送,帧可能会携带一条 gRPC 长度前缀的消息,也可能在 gRPC 消息非常大的情况下,一条消息跨多帧。当把这些消息以请求消息或响应消息的形式通过网络进行发送时,除了消息本身,还要发送额外的头消息。

4.1 请求消息

请求消息用于初始化远程调用。在 gRPC 中,请求消息始终由客户端应用程序来触发,它包含 3 部分:请求头信息、以长度为前缀的消息以及流结束标记(end of stream flag,以下简称 EOS 标记),如下图所示。远程调用在客户端发送请求头信息之后就会初始化,然后其中会发送以长度作为前缀的消息,最后发送 EOS 标记,通知收件方请求消息已发送。gRPC 实现原理这里可以再次使用 ProductInfo 服务中的 getProduct 方法,来理解请求消息在 HTTP/2 帧中的发送方式。当调用 getProduct 方法时,客户端会通过发送下面的请求头信息来初始化调用。

 
   
   
 
  1. HEADERS (flags = END_HEADERS)

  2. :method = POST #1

  3. :scheme = http #2

  4. :path = /ProductInfo/getProduct #3

  5. :authority = abc.com #4

  6. te = trailers #5

  7. grpc-timeout = 15 #6

  8. content-type = application/grpc #7

  9. grpc-encoding = gzip #8

  10. authorization = Bearer xxxxxx #9

  • #1:定义 HTTP 方法。对 gRPC 来说,:method 头信息始终为 POST;

  • #2:定义 HTTP 模式。如果启用传输层安全协议(TLS),就将模式设置为 https,否则设置为 http;

  • #3:定义端点路径。对 gRPC 来说,这个值的构造为 /{ 服务名 }/{ 方法名 };

  • #4:定义目标 URI 的虚拟主机名;

  • #5:定义对不兼容代理的检测。在 gRPC 中,这个值必须为 trailers;

  • #6:定义调用的超时时间。如果没有指定,服务端会假定超时时间无穷大。

  • #7:定义 content-type。对 gRPC 来说,content-type 应该以 application/grpc 开头。否则,gRPC 会给出 HTTP 状态为 415(不支持的媒体类型)的响应;

  • #8:定义消息的压缩类型。可选的值是 identity、gzip、deflate、snappy 和 {custom};

  • #9:这是可选的元数据。authorization 元数据用来访问安全的端点。

名称以“:”开头的头信息叫作保留头信息,HTTP/2 要求保留头信息出现在其他头信息之前;gRPC 通信中所传递的头信息分为两类:调用定义的头信息和自定义元数据;调用定义的头信息是 HTTP/2 预定义的头信息。这些头信息应该在自定义元数据之前发送;自定义元数据是由应用程序层定义的任意一组键-值对。在声明自定义元数据时,需要确保不要使用 grpc- 开头的名称。在 gRPC 核心中,这被列为保留名字。

当完成对服务器端调用的初始化后,客户端会以 HTTP/2 数据帧的形式发送以长度作为前缀的消息。如果这条消息不适合放到一个数据帧中,那么它可以跨多个数据帧。请求消息的结束通过在最后一个 DATA 帧上添加 *END_STREAM *标记来实现。当因为没有要发送的数据而需要关闭请求流时,必须发送一个带有 END_STREAM 标记的空数据帧:

 
   
   
 
  1. DATA (flags = END_STREAM)

  2. <Length-Prefixed Message>

4.2 响应消息

响应消息由服务器端生成,用来响应客户端的请求。与请求消息类似,在大多数场景中,响应消息也包含 3 个主要部分:响应头信息,以长度作为前缀的消息以及 trailer。如果没有发送以长度作为前缀的消息来响应客户端,则响应消息只会包含头信息和 trailer,如下图所示:gRPC 实现原理当服务器端发送响应消息至客户端时,首先会发送如下所示的响应头信息。

 
   
   
 
  1. HEADERS (flags = END_HEADERS)

  2. :status = 200 #1

  3. grpc-encoding = gzip #2

  4. content-type = application/grpc #3

  • #1:表明 HTTP 请求状态;

  • #2:定义消息的压缩类型。可选的值是 identity、gzip、deflate、snappy 和 {custom};

  • #3:定义 content-type。对 gRPC 来说,content-type 应该以 application/grpc 开头。

服务器端发送完响应头之后,以长度为前缀的消息就会以 HTTP/2 数据帧的形式在调用中进行发送。与请求消息类似,如果该消息不适合放到一个数据帧中,那么它可以跨多个数据帧。如下所示,END_STREAN 标记并不会随数据帧一起发送,而会作为单独的头信息来发送,名为 trailer:

 
   
   
 
  1. DATA

  2. <Length-Prefixed Message>

最后,通过发送 trailer 来提醒客户端响应消息已发送。trailer 还会携带状态码以及请求的状态信息。

 
   
   
 
  1. HEADERS (flags = END_STREAM, END_HEADERS)

  2. grpc-status = 0 # ok --1

  3. grpc-message = xxxxxx --2

  • --1:定义 gRPC 状态码。gRPC 会使用一组定义良好的状态码。这些状态码的定义可以在 gRPC 官方文档中找到;

  • --2:定义对错误的描述。这是可选的,只有在处理请求出现错误时,才会进行设置。

trailer 会以 HTTP/2 头信息帧的形式进行投递,但会在响应消息结束时发送。响应 EOS 标记就是在 trailer 头信息中设置的 END_STREAN 标记。另外,它还会包含 grpc-status 头信息和 grpc-message 头信息。

在特定的场景中,请求调用可能会立即失败。在这些情况下,服务器端需要发回一个不包含数据帧的响应。因为服务器端只发送 trailer 作为响应,所以这些 trailer 也会以 HTTP/2 头信息帧的形式进行投递,同时会包含 END_STREAM 标记。另外,trailer 会包含下面的头信息。

  • HTTP 状态::status;

  • 内容类型:content-type;

  • 状态:grpc-status;

  • 状态信息:grpc-message。

4.3 理解 gRPC 通信模式中的消息流

本节探讨 gRPC 的四种模式在传输层中的运行方式。

4.3.1 一元 RPC 模式

在一元RPC模式中,gRPC 服务器端和 gRPC 客户端的通信始终只涉及一个请求和一个响应。如图下图所示,请求消息包含头信息,随后是以长度作为前缀的消息,该消息可以跨一个或多个数据帧。消息最后会添加一个 EOS 标记,方便客户端半关(half-close)连接,并标记请求消息的结束。在这里,“半关”指的是客户端在自己的一侧关闭连接,这样一来,客产端无法再向服务器端发送消息,但仍能够监听来自服务器端的消息。只有在接收到完整的消息之后,服务器端才生成响应。响应消息包含一个头信息帧,随后是以长度作为前缀的消息。当服务器端发送带有状态详情的 trailer 头信息之后,通信就会关闭。gRPC 实现原理这是最简单的通信模式。

4.3.2 服务器端流 RPC 模式

从客户端的角度来说,一元 RPC 模式和服务器端流 RPC 模式具有相同的请求信息流。这两种情况都是发送一条请求消息,主要差异在于服务器端。在服务器端流 RRC 模式中,服务器端不再向客户端发送一条响应消息,而会发送多条响应消息。服务器端会持续等待,直到接收到完整的通求消息,随后它会发这响应头消息和多条以长度作为前缀的消息,如下图所示。在服务器端发送带有状态详情的 trailer 头信息之后,通信就会关闭。gRPC 实现原理

4.3.3 客户端流 RPC 模式

客户端流 RPC 模式很大程度上与服务器端流 RPC 模式相反。在客户端流 RPC 模式中,客户端向服务器端发送多条消息,服务器端在响应时发送一条消息。客户端首先通过发送头信息帧来与服务器端建立连接,然后以数据帧的形式,向服务器端发送多条以长度作为前缀的消息,如下图所示。最后,通过在末尾的数据帧中发送 EOS 标记,客户端将连接没置为半关的状态。与此同时,服务器端读取所接收到的来自客户端的消息。在接收到所有的消息之后,服务器端发送一条响应消息和 trailer 头信息,并关闭连接。gRPC 实现原理

4.3.4 双向流 RPC 模式

这种模式中,客户端与服务器端都会给对方发送多条消息,直到他们关闭连接为止。在双向流 RPC 模式中,客户端通过发送头信息帧与服务器端建立连接。然后,它们会互发以长度作为前缀的消息,无须等待对方结束。如下图所示,客户端和服务器端会同时发送消息。两者都可以在自己的一侧关闭连接,这意味着它们不能再发送消息了。网络以及通信中有关传输的操作通常是在 gRPC 核心层处理的,但 gRPC 开发应用程序开发人员无须关注这些细节。

5. gRPC 实现架构

如下图所示,gRPC 实现架构可以分为多层。最基础的是 gRPC 核心层,它为其上的层抽象了所有的网络操作,使得应用程序开发人员可以很容易地通过网络发送 RPC 调用。RPC 核心层还提供了对核心功能的扩展,其中一些扩展点是认证过滤器,用来处理对安全和截止时间(deadline)过滤器的调用,从而实现调用截止时间等功能。

gRPC 原生支持 C/C++ 语言、Go 语言和 Java 语言,它还提供了很多流行语言的绑定,如 Python、Ruby、php 等,这些语言绑定是对低层级 C API 的包装器。应用程序代码构建在这些语言绑定之上。应用程序层处理应用程序逻辑和数据编码逻辑,在正常情况下,开发人员会使用不同语言所提供的编译器生成数据编码逻辑的源代码。如果使用 protocol buffers 编码数据,就可以使用 protocol buffers 编译器来生成源代码。因此,开发人员可以通过调用生成源代码的方法来编写业务逻辑。

6. 小结

gRPC 构建在两个快速、高效的协议之上,也就是 protocol buffers 和 HTTP/2。protocol buffers 是一个语言中立、平台无关的数据序列化协议,并提供了可扩展的机制来实现结构化数据的序列化。当序列化完成之后,该协议会生成二进制载荷,这种载荷会比常见的 JSON 载荷更小,并且是强类型的。序列化之后的二进制载荷会通过名为 HTTP/2 的二进制传输协议进行发送。HTTP/2 是互联网协议 HTTP 的第 2 个主版本。HTTP/2 是完全多路复用的,这意味着 HTTP/2 可以在 TCP 连接上并行发送多个数据请求。这样一来,使用 HTTP/2 编写的应用程序更快、更简洁、更稳健。以上诸多因素使 gRPC 成为高性能的 RPC 框架。以上介绍了 gRPC 通信的底层细节。对于开发 gRPC 应用程序来说,由于 gRPC 库已经处理了这些细节,因此我们无须再掌握它们,但在生产环境中使用 gRPC 时,了解底层 gRPC 消息流,对于排查 gRPC 通信的相关问题至关重要。


以上是关于gRPC 实现原理的主要内容,如果未能解决你的问题,请参考以下文章

小米gRPC 系列——grpc 超时传递原理

gRPC:客户端创建和调用原理

GoGolang实现gRPC的Proxy的原理

gRPC 本地服务搭建

Grpc原理

grpc教程grpc的底层原理