gRPC
gRPC 是一个高性能、通用的开源 RPC 框架,其由 Google 2015 年主要面向移动应用开发并基于 HTTP/2 协议标准而设计,基于 ProtoBuf 序列化协议开发,且支持众多开发语言。
由于是开源框架,通信的双方可以进行二次开发,所以客户端和服务器端之间的通信会更加专注于业务层面的内容,减少了对由 gRPC 框架实现的底层通信的关注。
如下图,DATA 部分即业务层面内容,下面所有的信息都由 gRPC 进行封装。
gRPC 的特点
- 跨语言使用,支持 C++、Java、Go、Python、Ruby、C#、Node.js、Android Java、Objective-C、PHP 等编程语言;
- 基于 IDL 文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端接口以及客户端 Stub;
- 通信协议基于标准的 HTTP/2 设计,支持双向流、消息头压缩、单 TCP 的多路复用、服务端推送等特性,这些特性使得 gRPC 在移动端设备上更加省电和节省网络流量;
- 序列化支持 PB(Protocol Buffer)和 JSON,PB 是一种语言无关的高性能序列化框架,基于 HTTP/2 + PB, 保障了 RPC 调用的高性能;
- 安装简单,扩展方便(用该框架每秒可达到百万个RPC)。
gRPC调用模型
1、客户端(gRPC Stub)调用 A 方法,发起 RPC 调用。
2、对请求信息使用 Protobuf 进行对象序列化压缩(IDL)。
3、服务端(gRPC Server)接收到请求后,解码请求体,进行业务逻辑处理并返回。
4、对响应结果使用 Protobuf 进行对象序列化压缩(IDL)。
5、客户端接受到服务端响应,解码请求体。回调被调用的 A 方法,唤醒正在等待响应(阻塞)的客户端调用并返回响应结果。
客户端与服务端是如何交互的
- 在建立连接之前,客户端/服务端都会发送连接前言(Magic+SETTINGS),确立协议和配置项。
- 在传输数据时,是会涉及滑动窗口(WINDOW_UPDATE)等流控策略的。
- 传播 gRPC 附加信息时,是基于 HEADERS 帧进行传播和设置;而具体的请求/响应数据是存储的 DATA 帧中的。
- 请求/响应结果会分为 HTTP 和 gRPC 状态响应两种类型。
- 客户端发起 PING,服务端就会回应 PONG,反之亦可。
浅谈使用理解
RPC 流
在 RPC 架构下,服务端(Server)定义了一组对外暴露的远程方法(Remote Procedures)。客户端(Client)通过生成对应的存根(Stub)来实现这些远程方法的本地代理。存根内包含与服务端远程方法相对应的函数签名,从而使客户端能够通过本地调用来触发远程操作。操作流程如下:
- 客户端通过存根(Stub)调用 getProduct 方法。
- 客户端存根对传入的消息(Message)执行序列化操作,并构建一个 HTTP POST 请求。在 gRPC 框架中,这一请求遵application/grpc 的 content-type规范,并将目标远程方法(/ProductInfo/getProduct)标记在 HTTP Header(Path)中。
- 经构建的 HTTP POST 请求随后通过网络传输到服务端。
- 服务端接收到消息后,从 HTTP Header 解析出待调用的远程方法,并将消息交由服务端存根(Server Stub)处理。
- 服务端存根对收到的消息执行反序列化,恢复为原始的数据结构。
- 服务端本地执行 getProduct 方法,其中反序列化后的消息作为输入参数。
- 方法执行后,服务端对返回结果进行序列化,并以 HTTP 响应的形式返回给客户端。该响应的构建过程与客户端的请求构建过程相对应。
- 客户端收到服务端的响应消息后,进行反序列化操作,并将解码后的数据提供给等待的客户端进程。
这些步骤与大多数 RPC 系统(如 CORBA、Java RMI 等)非常相似。gRPC 与它们之间的主要区别在于它对 message 进行编码的方式,它使用 protocol buffer 进行编码。
Protocol Buffers
Protocol buffers are Google’s language-neutral, platform-neutral, extensible mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages.
Protocol Buffers 是 Google 的一种语言中立、平台中立和可扩展的结构化数据序列化机制,可以视为是更小、更快、更简单的 XML。您只需一次性定义数据结构,然后可以使用特殊生成的源代码轻松地将结构化数据写入和读取出各种数据流,并且支持多种编程语言。
你可以理解 ProtoBuf 是一种更加灵活、高效的数据格式,与 XML、JSON 类似,在一些高性能且对响应速度有要求的数据传输场景非常适用。
ProtoBuf 在 gRPC 的框架中主要有三个作用:定义数据结构、定义服务接口,通过序列化和反序列化方式提升传输效率。
为什么 ProtoBuf 会提高传输效率呢?
我们知道使用 XML、JSON 进行数据编译时,数据文本格式更容易阅读,但进行数据交换时,设备就需要耗费大量的 CPU 在 I/O 动作上,自然会影响整个传输速率。Protocol Buffers 不像前者,它会将字符串进行序列化后再进行传输,即二进制数据。
接下来我们看下如何使用 protocol buffer 对消息进行编码。
使用 protocol buffer 定义服务包括定义服务中的远程方法和定义我们希望通过网络发送的消息。
仍以 ProductInfo 服务中的 getProduct 函数为例。该 getProduct 函数接受一个 ProductID 消息作为输入参数并返回一个 Product 消息。protocol buffer 定义如下:
syntax = "proto3";
package ecommerce;
service ProductInfo {
rpc getProduct(ProductID) returns (Product);
}
message Product {
string id = 1;
string name = 2;
string description = 3;
float price = 4;
}
message ProductID {
string value = 1;
}
假设我们需要获取产品 ID 为 15 的产品详细信息,我们创建一个值为 15 的 ProductID 消息,并将其传递给 getProduct 函数。
product, err := c.GetProduct(ctx, &pb.ProductID{Value: “15”})
在下面的 ProductID 消息结构中,有一个 value 字段,其索引为 1。当我们创建一个 value 等于 15 的消息实例时,生成的字节内容为该字段的字段标识符(field identifier)+value 具体的编码值。字段标识符有时也称为标签(tag):
message ProductID {
string value = 1;
}
字节内容结构如下图所示,其中,每个消息字段由一个标签值(Tag)和其编码值(Value)组成。
在 Protocol Buffers(通常简称为 Protobuf)中,标签值(Tag)具有非常重要的作用,它用于标识序列化数据流中的各个字段。
- 字段索引: 在 .proto 文件中,每个字段都会有一个唯一的数字标识,通常称为字段索引(Field Index)。这个数字是用于在序列化和反序列化过程中快速识别字段的。
- 类型编号: 类型编号(Type Identifier)表明了该字段的数据类型(例如:int32, string等)。这有助于解析器理解如何解码或解析这个字段。
标签值组合了这两部分信息(字段索引和类型编号)来生成一个唯一的标识符,用于在序列化数据流中定位和解析字段。
在给定的
message ProductID { string value = 1; }
示例中,字段 value 的字段索引是 1。字符串类型(string)在 Protocol Buffers 中对应的类型标识符(Wire Type)是 2。为了生成标签值,首先需要将字段索引左移 3 位,然后将类型标识符添加到其中。
下表显示了字段类型如何映射到类型编号的:
计算标签值:
标签值 = (字段索引 << 3) | 类型标识符
= (1 << 3) | 2
= 8 | 2
= 10
为什么使用左移 3 位(<< 3):这个设计选择允许 3 位用于类型标识符(Wire Type)。左移 3 位是为了在标签值中留出空间来存储类型标识符。类型标识符是一个 0 到 7 的值,正好可以用 3 位二进制数来表示。这样,通过字段索引左移 3 位后,最低的 3 位就可以用来存储类型标识符。这种设计方式实现了一个紧凑、高效的编码方案,同时也提供了足够的信息来解析序列化后的数据。
在Protocol Buffers中,变长整数(Varints)采用一种特殊的编码方式,使得较小的数值可以用较少的字节表示。具体来说,每个字节的最低7位用于存储整数的实际数值,而最高位(第8位)用于标记是否还有更多字节。如果最高位是1,那么表示该整数还有后续字节;如果最高位是0,那么表示这是该整数的最后一个字节。
字符串通常使用一种称为"Length-delimited"的编码方式。这种方法也适用于字节串(byte arrays)和嵌套消息。
- 前缀长度:在实际的字符串内容之前,会有一个变长整数(Varint)来表示接下来的字符串(或字节串或嵌套消息)的长度。这个变长整数是使用Protocol Buffers的Varint编码方式进行编码的。
- 字符串内容:紧接着前缀长度的是字符串的实际内容,它是以原始字节形式存在的。这些字节的数量与前缀长度中指定的数值相匹配。
- 解码:在解码阶段,首先会读取前缀长度(它本身是一个Varint,可以用一个或多个字节表示)。解码器然后会知道接下来要读取多少字节来获取完整的字符串或字节串。
当然,让我给出一个简单的例子以说明如何使用Length-delimited编码方式来编码字符串。假设我们有一个字符串"hello"。
计算长度: 字符串"hello"包含5个字符,因此其长度是5。
编码长度: 使用Protocol Buffers的Varint编码方式,我们将这个长度数字(即5)编码为一个变长整数。在这个特定的情况下,5是一个小的数值,所以它可以用单个字节来表示:0b00000101。
字符串内容: "hello"的ASCII编码分别是:0x68 0x65 0x6C 0x6C 0x6F
组合: 最后,我们将编码后的长度和实际的字符串内容组合起来:0b00000101 0x68 0x65 0x6C 0x6C 0x6F
所以,按照Length-delimited编码方式,字符串"hello"将被编码为:05 68 65 6C 6C 6F(以十六进制表示)。
从上面的介绍,我们得出在编码方面 Protocol Buffers 对比 JSON、XML 的优点:
- 标准的 IDL 和 IDL 编译器,这使得其对工程师非常友好;
- 序列化数据非常简洁,紧凑,与 XML 相比,其序列化之后的数据量约为 1/3 到 1/10;
- 解析速度非常快,比对应的 XML 快约 20-100 倍;
- 提供了非常友好的动态库,使用非常简单,反序列化只需要一行代码。
Protobuf 也有其局限性:
- 二进制格式导致可读性差
- 不具备自描述能力
Protobuf 适用场景:
- Protobuf 具有广泛的用户基础,空间开销小以及高解析性能是其亮点,非常适合于公司内部的对性能要求高的 RPC 调用;
- 由于 Protobuf 提供了标准的 IDL 以及对应的编译器,其 IDL 文件是参与各方的非常强的业务约束;
- Protobuf 与传输层无关,采用 HTTP 具有良好的跨***的访问属性,所以 Protobuf 也适用于公司间对性能要求比较高的场景;
- 由于其解析性能高,序列化后数据量相对少,非常适合应用层对象的持久化场景;
长度前缀消息帧
在 gRPC 通信协议中,采用了长度前缀消息帧(Length-Prefixed Message Framing)机制来进行数据打包和传输。具体来说,每个编码后的消息体前会附加一个 4 字节的字段,用于标识接下来的消息体的字节长度。
这种机制提供了一种有效的方式来界定消息的边界,从而允许接收方进行准确和高效的消息解析。由于长度字段由 4 字节表示,因此理论上最大可支持的消息体尺寸为 2 32 − 1 2^{32} - 1 232−1 字节,即近 4 GB。
这样的设计旨在实现高效的流解析和处理,同时也支持消息体的动态长度,进一步提升了通信协议的灵活性和可扩展性。
假设我们有一个简单的 gRPC 服务,其中有一个名为 GetUserInfo 的远程调用,用于获取用户信息。当客户端要获取用户 ID 为 123 的用户信息时,该调用的消息体(已序列化和编码)可能是一个二进制字符串,例如 0x12 0x07 0x55 0x73 0x65 0x72 0x31 0x32 0x33(这只是一个假设的二进制表示)。在这种情况下,消息体的长度为 9 字节。
长度前缀添加:在这个二进制消息体前,gRPC 会添加一个 4 字节的长度字段。假设长度用大端字节序表示,则该字段可能是 0x00 0x00 0x00 0x09。
完整的消息帧:添加了长度前缀后,完整的消息帧将变为 0x00 0x00 0x00 0x09 0x12 0x07 0x55 0x73 0x65 0x72 0x31 0x32 0x33。
发送到服务器:这个长度为 13 字节的消息帧将通过网络发送到 gRPC 服务器。
服务器解析:服务器首先读取前 4 字节(0x00 0x00 0x00 0x09),解析出消息体的长度为 9 字节。然后,服务器会读取接下来的 9 字节,作为完整的消息体进行解码和处理。
这样,通过使用长度前缀消息帧,gRPC 能够准确地界定每个消息体的开始和结束,从而实现高效和准确的消息解析。
除了消息大小之外,消息帧还会预留一个 1 字节的无符号整数来指示数据是否被压缩。Compressed-Flag 值为 1 表示二进制数据使用 Message-Encoding 标头中声明的压缩方式进行压缩,值 0 表示没有对消息字节进行压缩。
现在消息已装帧并准备好通过网络发送给对应的处理方。对于 client 端请求消息,接收者是 server。对于响应消息,接收者是 client 端。在接收端,一旦收到一条消息,首先需要读取第一个字节,检查消息是否被压缩。然后,读取接下来的四个字节以获取二进制消息的大小。一旦知道大小,就可以从消息流中读取具体的消息了。对于简单消息,我们只需处理一条带长度前缀的消息,而对于流式消息,我们需要处理多条带长度前缀的消息。
在gRPC和Protocol Buffers的上下文中,长度前缀消息帧(Length-Prefixed Message Framing)与字段的三部分(字段编号(Tag)、类型、编码值)是两个在不同层次上应用的编码机制,它们不会互相矛盾。
长度前缀消息帧:这是一种更为底层的打包方式,通常用在gRPC的传输层。这里的4字节长度前缀用于标识接下来整个消息体(即一个完整的Protocol Buffers消息)的长度。这不仅包括字段的编码值,还包括每个字段的标签和类型信息。这样,收到消息的一方知道要读取多少字节来获取完整的消息。
字段的三部分编码:这是Protocol Buffers内部的编码机制。在这里,每个字段都被编码为标签、类型和值。这三者都包含在上面提到的长度前缀消息帧内。
由于这两个机制作用在不同的层次,因此不会有矛盾。具体来说,长度前缀消息帧为整个Protocol Buffers消息提供一个“外壳”,而字段的三部分编码则是这个“外壳”内部的内容。
关于顺序性的问题,实际上,字段的三部分(标签、类型、编码值)总是作为一个整体连续存储和传输的。也就是说,一个字段的标签、类型和值会连续出现在数据流中,然后才是下一个字段的标签、类型和值。这种连续性由Protocol Buffers的编码和解码算法来保证。
综上所述,长度前缀消息帧和字段的三部分编码各自在不同的层面起作用,两者可以和谐共存,不会出现顺序或界定的问题。长度前缀是针对整个消息体,而字段的标签、类型和编码值是针对消息体内部的各个字段。这样设计确保了数据传输的高效和准确。
思考下长度前缀消息帧,是否会失序?
在gRPC和大多数基于TCP的通信协议中,数据的顺序性是由底层的TCP协议保证的。TCP是一个面向连接的、可靠的、字节流协议,它确保所有的字节都会按照发送的顺序到达接收端。
长度前缀消息帧在这种上下文中是作为一个整体发送的,即4字节的长度前缀和跟随的消息体(由长度前缀指定的长度)会作为一个整体在网络上传输。由于TCP保证了数据的顺序性和完整性,因此不会出现长度前缀和消息体之间、或不同消息之间的失序问题。
这样的设计确保了在从底层TCP流中读取数据并解析成gRPC消息时,能够正确地识别每个消息的边界,并按照正确的顺序处理它们。
简言之,长度前缀消息帧在gRPC通信中不会失序,这是由底层的TCP协议保证的。这也是为什么gRPC可以作为一个高性能、可靠的RPC框架被广泛使用的原因之一。
基于 HTTP/2 的 gRPC
除了 Protocol Buffers 之外,从交互图中和分层框架可以看到, gRPC 还有另外一个优势——它是基于 HTTP 2.0 协议的。由于 gRPC 基于 HTTP 2.0 标准设计,带来了更多强大功能,如多路复用、二进制帧、头部压缩、推送机制。
这些功能给设备带来重大益处,如节省带宽、降低 TCP 连接次数、节省 CPU 使用等,gRPC 既能够在客户端应用,也能够在服务器端应用,从而以透明的方式实现两端的通信和简化通信系统的构建。
HTTP 1.X 定义了四种与服务器交互的方式,分别为 GET、POST、PUT、DELETE,这些在 HTTP 2.0 中均保留,我们看看 HTTP 2.0 的新特性:双向流、多路复用、二进制帧、头部压缩。
跟其他的相比:
- XML序列化(Xstream)无论在性能和简洁性上比较差。
- Thrift 与 Protobuf 相比在时空开销方面都有一定的劣势。
- Protobuf 和 Avro 在两方面表现都非常优越。
如下图所示,gRPC Channel 表示 client 端与 server 端之间的连接,即 HTTP/2 连接。当 client 端创建 gRPC Channel 时,它会在后台创建与 server 端的 HTTP/2 连接。创建好 Channel 后,我们可以重用它来向 server 端发送多个远程调用,这些远程调用会映射到 HTTP/2 的流中。在远程调用中发送的消息以 HTTP/2 帧的形式发送。一个帧可能携带一个 gRPC 长度前缀消息,如果一个 gRPC 消息非常大,它可能跨越多个数据帧。
在gRPC中,Channel是一个抽象概念,它代表了客户端(client)与服务端(server)之间的一个连接。具体来说,它封装了底层的HTTP/2连接以及其他与连接相关的状态信息。Channel负责管理底层连接的建立、维护以及关闭,并提供了一种简单的方式来进行远程过程调用(RPC)。
在上一节中,我们讨论了如何将我们的消息帧转为以长度为前缀的消息。当我们通过网络将它们作为请求或响应消息发送时,我们需要随消息一起发送额外的 HTTP 头。下面,我们看看如何构造请求 / 响应消息,以及需要为每条消息传递哪些标头。
请求消息
请求消息是发起远程调用的消息。在 gRPC 中,请求消息总是由 client 端应用程序触发,它由三个主要部分组成:请求头、长度前缀消息和流结束标志,如下图所示。client 端首先发送请求头,之后是长度前缀消息,最后是 EOS,标识消息发送完毕。
下面仍以 ProductInfo 服务中的 getProduct 函数为例,来解释请求消息是如何在 HTTP/2 帧中发送的。
当我们调用该 getProduct 函数时,client 端会发送以下请求头:
HEADERS (flags = END_HEADERS)
:method = POST
:scheme = http
:path = /ProductInfo/getProduct
:authority = abc.com
te = trailers
grpc-timeout = 1S
content-type = application/grpc
grpc-encoding = gzip
authorization = Bearer xxxxxx
:method:设置 HTTP 方法。对于 gRPC,:method 标头始终为 POST.
:scheme:设置 HTTP 协议。如果启用了 TLS,则协议设置为 “https”,否则为 “http”。
:path:设置终端路径。对于 gRPC,此值构造为 “/{服务名称}/{方法名称}”。
:authority:设置目标 URI 的虚拟主机名。
te:设置不兼容代理的检测。对于 gRPC,该值必须是 “trailers”。
grpc-timeout:设置调用超时时常。如果未指定,server 端应假定无限超时。
content-type:设置内容类型。对于 gRPC,内容类型应以 application/grpc. 如果没有,gRPC server 会响应 HTTP 状态 415(不支持的媒体类型)。
grpc-encoding:设置消息压缩方式。可能的值为 identity、gzip、deflate、snappy 及自定义压缩方式。
authorization:这是可选的请求头,用于访问又安全限制的终端服务。
一旦 client 端发起与 server 端的调用,client 端就会以 HTTP/2 数据帧的形式发送带有长度前缀的消息。如果一个数据帧无法放下长度前缀消息,它可以跨越多个数据帧。通过在最后一个数据帧上添加一个 END_STREAM 标志来标识请求消息的结束。当没有数据要发送但我们需要关闭请求流时,我们需要发送一个带有 END_STREAM 标志的空数据帧:
DATA (flags = END_STREAM)
响应消息
响应消息由 server 端响应 client 端的请求而生成。与请求消息类似,在大多数情况下,响应消息也由三个主要部分组成:响应标头、带长度前缀的消息和尾部。当响应中没有以长度为前缀的消息需要发送给 client 端时,响应消息仅包含响应标头和尾部。
继续回到上一个例子。当 server 端向 client 端发送响应时,它首先发送响应头,如下所示:
HEADERS (flags = END_HEADERS)
:status = 200
grpc-encoding = gzip
content-type = application/grpc
:status:标识 HTTP 请求的状态。
grpc-encoding:设置消息压缩类型。可能的值包括 identity、gzip、deflate、snappy 和自定义类型。
content-type:设置内容类型。对于 gRPC,content-type 应该设置为 application/grpc。
一旦 server 端发送完响应标头,就会以 HTTP/2 数据帧的形式发送带有长度前缀的消息。与请求消息类似,如果一个数据帧无法放下长度前缀消息,它可以跨越多个数据帧:
DATA
与请求消息不同的是,END_STREAM 标志不随数据帧一起发送,它作为一个单独的响应头发送(被称作 Trailers),通知 client 端我们完成了响应消息的发送。Trailers 还会携带请求的状态码和状态消息:
HEADERS (flags = END_STREAM, END_HEADERS)
grpc-status = 0 # OK
grpc-message = xxxxxx
grpc-status: gRPC 状态代码。可以参考 gRPC 官方文档查找状态码的定义。
grpc-message:错误描述。这是可选的,仅在处理请求出现错误时设置。
在某些情况下,请求调用可能会立即失败。在这些情况下,server 端需要在没有数据帧的情况下发回响应。此时,server 端只会发送 Trailers 作为响应。
关于gRPC的通信方式,在下一个章节中会介绍。