28 gRPC 核心

[TOC]

gRPC 核心

gRPC 原生支持C/C++、Go 和 Java,还有一些流行语言的 CAPI 包装器,如 Python、Ruby、PHP等。

gRPC 的实现架构可以分为应用程序层、框架层、传输层。最基础的是 gRPC 核心框架层,为应用层抽象了所有网络操作,可以很容易实现远程调用,框架层还提供了对核心功能的扩展,例如认证过滤器用于处理对安全和截止时间过滤器的调用,从而实现调用截止时间等功能。

gRPC 原生架构

gRPC 原生架构

gRPC 高级功能

gRPC 提供了一些高级功能,包括 gRPC 拦截器、gRPC 截止时间、gRPC 取消、错误处理等。

拦截器

客户端或服务端在远程方法执行之前和之后需要执行一些通用逻辑可以使用拦截器,例如权限验证、数据采集等。拦截器是 gRPC 核心扩展机制之一。

服务端拦截器

gRPC 服务端可以插入多个拦截器,在客户端调用远程方法时会先执行拦截器,拦截器中可以实现一些通用逻辑。服务端拦截器有一元 RPC 拦截器、流 RPC 拦截器。

gRPC 服务端拦截器

gRPC 服务端拦截器

一元 RPC 拦截器

一元拦截器接口函数签名 func (ctx context.Context,req interface{},info *grpc.UnaryServerInfo,handler grpc.UnaryHandler) (interface{},error),一元拦截器只需实现函数并在 gRPC 服务端注册拦截器。注册拦截器 server := grpc.NewServer(grpc.UnaryInterceptor(IdentityInfoServiceInterceptor))

流 RPC 拦截器

流 RPC 拦截器接口函数签名 func (srv interface{},ss grpc.ServerStream,info *grpc.StreamServerInfo,handler grpc.StreamHandler) error,除此之外还可以通过 grpc.ServerStream 接口的包装器流接口拦截流 RPC 的消息,包装器中 SendMsg 和 RecvMsg 函数可以在服务发送和接收 RPC 流消息的时候被调用。注册流拦截器 server := grpc.NewServer(grpc.UnaryInterceptor(IdentityInfoServiceInterceptor),grpc.StreamInterceptor(IdentityInfoServiceStreamInterceptor))

客户端拦截器

当客户端发起 RPC 调用 gRPC 服务端的远程方法时,客户端可以拦截 RPC 请求。

一元拦截器

客户端一元拦截器用于拦截一元 RPC 客户端的调用。一元 RPC 拦截器接口函数签名 func (ctx context.Context,method string,req,reply interface{},cc *grpc.ClientConn,invoker grpc.UnaryInvoker,opts ...grpc.CallOption) error,通过 conn, err := grpc.Dial(address, grpc.WithInsecure(),grpc.WithUnaryInterceptor(IdentityInfoClientInterceptor)) 注册拦截器。

流拦截器

gRPC 流拦截器接口函数签名 func (ctx context.Context,desc *grpc.StreamDesc,cc *grpc.ClientConn,method string,streamer grpc.Streamer,opts ...grpc.CallOption) (grpc.ClientStream,error)。实现方式和服务端流拦截器实现相似,也可以实现一个包装器,拦截器的注册 conn, err := grpc.Dial(address, grpc.WithInsecure(),grpc.WithUnaryInterceptor(IdentityInfoClientInterceptor))

截止时间

截止时间和超时时间是两个常用的模式。超时时间指定客户端等待 RPC 调用完成的时间,以持续时长的方式指定,在每个客户端本地进行应用。如果一个请求由多个下游 RPC 组成,即使每个调用都指定超时时间也不能应用于请求的整个生命周期,但使用截止时间就可以做到这点。

截止时间以请求开始的绝对时间来表示,由发起请求的应用程序设置截止时间,应用于多个服务调用。截止时间是必须设置的,如果客户端没有设置截止时间它会无限等待发出的 RPC 请求的响应,正在处理的请求会占用大量资源,会让客户端和服务端都面临资源耗尽的风险,增加服务的延迟,甚至可能导致整个 gRPC 服务崩溃。

如果客户端截止时间设置为 3 秒,这个截止时间相当于当前时间 + 偏移量 3 秒。客户端在超出截止时间还没有收到响应会以 DEADLINE_EXCEEDED 错误终止。使用 WithDeadline 设置截止时间。

取消

客户端和服务端都有终止 RPC 的方法。取消 RPC 就不能再进行与之相关的消息传递,并且一方取消 RPC 的操作会传递到另一方。gRPC 的取消功能是通过 context 包实现的,当调用 withCancel 函数后客户端 gRPC 会创建所需的头信息,客户端和服务端之间的 RPC 通信终止。

错误处理

发起 gRPC 调用的客户端会接收成功状态的响应或带有对应错误状态的错误。客户端需要处理潜在的错误和错误条件,服务端也要处理错误生成相应的错误状态码。当发生错误时,gRPC 返回一个错误状态码,并附带一条可选的错误消息,消息中提供错误条件的更多细节。状态对象是一个整形码和一条字符串消息组成,适用于不同语言的 gRPC 实现。gRPC 提供的错误模型有限,与底层的 gRPC 数据格式无关,最常见的格式是 protocol buffers,使用 protocol buffers 作为数据格式可以利用 google.rpc 包所提供的更丰富的错误模型,但只有 C++、Go、Java、Python 和 Ruby 的库可以支持。

数字
错误码
描述

0

OK

成功

1

CANCELLED

操作已被调用者取消

2

UNKNOWN

未知错误

3

INVALID_ARGUMENT

客户端指定了非法参数

4

DEADLINE_EXCEEDED

操作完成前已经超过截止时间

5

NOT_FOUND

一些请求实体没找到

6

ALREADY_EXISTS

客户端试图创建已存在实体

7

PERMISSION_DENIED

调用者没有权限执行特定的操作

8

PESOURCE_EXHAUSTED

一些资源耗尽

9

FAILED_PRECONDITION

操作被拒绝,系统未处于执行操作所需的状态

10

ABORTED

操作被终止

11

OUT_OF_RANGE

尝试进行的操作超出了合法的范围

12

UNIMPLEMENTED

服务中未实现或不支持该操作

13

INTERNAL

内部错误

14

UNAVAILABLE

服务当前不可用

15

DATA_LOSS

不可恢复的数据丢失或损坏

16

UNAUTHENTICATED

客户端没有操作的合法凭证

多路复用

gRPC 中的多路复用是指在 gRPC 服务端可以运行多个 gRPC 服务,多个客户端存根也可以使用同一个 gRPC 客户端连接。服务端只需要将不同的 pb 进行注册就可以共享一个服务端。客户端也可以使用多个存根访问服务端。

元数据

共享的 RPC 的信息与 RPC 业务上下文没有关联的,不作为 RPC 参数的一部分,这样的场景中可以使用 gRPC 元数据,元数据可以在 gRPC 服务端、客户端发送和接收。在服务端或客户端创建的元数据可以通过 gRPC 头信息在程序之间交换。元数据的构造遵循键值对形式。元数据最常见用途是在 gRPC 程序之间交换安全头信息。元数据是字符串,也可以是二进制数据,二进制数据发送前会进行 base64 编码,传输之后会解码。

负载均衡

生产环境中要确保程序能够满足高可用和高扩展性就需要多个 gRPC 服务端,服务之间分发 RPC 请求就可以使用负载均衡器。gRPC 有负载均衡器代理和客户端负载均衡。

负载均衡器代理

客户端向负载均衡器代理发起 RPC 后,负载均衡器代理将 RPC 分发给一台可用的后端 gRPC 服务。负载均衡器代理会跟踪每台后端服务器的负载。可以使用任意支持 HTTP2 的负载均衡器作为 gRPC 的负载均衡器。可以使用 Nginx、Envoy 作为 gRPC 的负载均衡器代理。

客户端负载均衡

在 gRPC 客户端层实现的负载均衡的逻辑就不需要中间代理层。客户端需要维护一个服务端列表,使用随机 round_robin 或轮询 pick_first 的方式选择服务端。客户端维护的服务端列表一般会从一个专门服务端获取,在许多微服务架构中一般称为注册中心,客户端从注册中心定期获取最新的列表。

压缩

远程调用时,增加 grpc.CallOption 参数 grpc.UseCompressor(gzip.Name) 就可以实现压缩,例如 client.AddIdentityInfo(ctx,&in,grpc.UseCompressor(gzip.Name))。服务端解压缩只需引入 _ "google.golang.org/grpc/encoding/gzip",执行 gzip 包中的 init 函数就可以实现解压缩。

gRPC 通信模式

gRPC 可以实现不同进程间通信模式,不仅仅是请求-响应模式。gRPC 有4种基础通信模式,一元RPC、服务端流RPC、客户端流RPC和双向流RPC,一元 RPC 模式是一次请求一个响应。而服务端、客户端单向流和双向流等都是流模式,接收或响应流的一方是可以多个请求或多个响应。

一元 RPC 模式

一元 RPC 模式中客户端调用服务端远程方法时,客户端发送请求至服务端并获得一个响应,与响应一起发送的还有状态细节和 trailer 追踪元数据,这种一次请求一个响应的模式是最常用的。

流 RPC 模式

服务端流 RPC 模式中服务端在接收到客户端的请求后可以发回多个响应,这种多个响应组成的序列被称为“流”。在所有服务端响应发送完毕后,服务端会以 trailer 元数据的形式将状态发生给客户端,标记流的结束。

客户端流 RPC 模式中客户端可以发送多个请求给服务端,服务端响应一个请求。服务端不一定要等到接收完所有请求再发送响应。

双向流 RPC 模式客户端和服务端都使用流的形式进行通信。客户端发起调用后,双方都可以多次通信,何时结束要看双方的程序逻辑。

双向流 proto 定义

流 RPC 远程方法定义,将参数类型定义为 stream 。若是服务端流 RPC 模式则定义远程方法响应参数为 stream 类型。客户端流 RPC 模式则定义远程方法接收参数为 stream 类型。

双向流服务实现

双向流读写则关闭流由客户端操作,服务端流单向可以使用 stream.SendAndClose 关闭单向流。proto 定义了参数是 stream 类型时,会编译生成服务对应的例如 IdentityInfoService_AddIdentityInfoServer Server 类型,其中包装了 stream 用于读写。

双向流客户端

客户端实现通过调用远程方法获得 stream ,通过 stream 发送和接收数据,通过 CloseSend() 关闭通信。

gRPC 原理

一般情况下实现服务不需要关心 gRPC 的实现细节、使用的编码技术以及网络中的运行方式,只要使用 proto 服务定义编译所得的基础代码实现具体业务。但在基于 gRPC 构建的复杂的系统就需要了解 gRPC 工作原理,有助于在出现 BUG 时快速找出问题。

RPC 通信的系统中,服务端会实现含有远程方法的服务。客户端会生成一个存根,例如基于 protobuf 定义的接口编译成 Go 源码后的 IdentityInfo.pb.go,这个存根就是服务端的远程方法抽象,客户端只需要调用存根的方法就可以实现远程调用。

protocol buffers 编码

protocol buffers 是一个语言中立和平台无关的实现结构化数据序列化的可扩展机制。只需定义数据该如何进行结构化就可以使用对应语言的源码编译器编译成源码,可以很方便的在各种数据流之间写入和读取结构化数据。

字段标识符编码

proto 中定义的消息类型决定了消息如何进行编码。消息类型定义时需要类型、字段标识符、字段索引 int64 id = 1; 。字段标识符由字段索引和线路类型 wire type 构成,字段索引就是字段定义时的数字,线路类型是基于字段类型。线路类型提供信息确定字段值的长度。

线路类型
分类
字段类型

0

Varint

int32、int64、uint32、uint64、sint32、sint64、bool、enum

1

64位

fixed64、sfixed64、double

2

基于长度

string、bytes、嵌入消息

3

起始组

groups 已废弃

4

结束组

groups 已废弃

5

32位

fixed32、sfixed32、float

字段值的结构由字段索引和线路类型组成,例如 string name = 2; 字段索引是2,字段线路类型是2,转换为二进制,字段索引是 0000 0001 ,线路类型是 0000 0010 ,代入公式计算出字段标签值 Tag value = (0000 0001 << 3) | 0000 0010 = 000 1010 。

字段值编码

proto 对不同类型字段使用不同的编码技术。字符串 string 类型使用 UTF-8 编码,int 类型的整型值使用 Varint 编码技术编码。消息编码后标签和值会连接到一个字节流中,通过发送值为 0 的标签来进行结束流。

Varint 类型是可变长度整数使用单字节或多字节来序列化整数的方法。由于大多数数字并非均匀分布,为每个值所分配的字节数量就不是固定的,因此要依赖具体的值。在 Varint 中除了最后的字节,其它所有字节都会设置最高有效位,表明后面还有字节。每字节中较低的7位用来存储数字的二进制补码形式。同时最低有效组放在前面,所以要在低阶组中添加延续位。

字段类型
定义

int32

32位有符号整数值类型,为负数时编码低效。

int64

64位有符号整数值类型,为负数时编码低效。

uint32

32位无符号整数值类型。

uint64

64位无符号整数值类型。

sint32

32位有符号整数值类型,编码效率高于int32。

sint64

64位有符号整数值类型,编码效率高于int64。

bool

true、bool。

enum

一组命名值的值类型。

有符号整数类型能够表示正负整数。sint32、sint64 会使用 zigzag 编码来将有符号整数转换成无符号整数,再使用 Varint 编码技术来进行编码。zigzag 编码使用映射的方式将有符号值映射成无符号值。int32、int64 类型使用 Varint 编码将负值直接转换成二进制会使用更多的字节,所以会比 sint 低效。

fixed64 和 float 等类型分配固定数量的字节,字节数与实际值没有关联。proto 有两个线路类型属于非 Varint 类型,分别是 64 位的 fixed64、sfixed64、double 和 32 位的 fixed32、sfixed32、float。

字符串类型属于长度分隔的线路类型,会首先有一个经过 Varint 编码的长度值,随后是指定数量的字节数据。字符串值会使用 UTF-8 字符串编码格式进行编码。

消息分帧

消息分帧构建消息和通信,使接收方轻松可以提取数据。gRPC 使用名为长度前缀分帧 length prefix framing 的消息分帧技术。长度前缀分帧是在写入消息之前写入长度信息表明每条消息的大小。已编码的二进制消息前面分配了 4 字节指明消息的大小,因此 gRPC 通信的数据不可超过 4GB 。此外,消息的第一个字节是压缩标记位,标记位值为1则是数据使用了 Message-Encoding 头信息中声明的机制进行了压缩。

消息帧

消息帧

大端是一种在系统或消息中对二进制数据进行排序的方式,大端格式序列中的最高有效位存储在最低的存储地址上。

读取消息的一方会先读取第一个字节查看数据是否压缩,然后读取接下去的4个字节查看数据的长度。

传输协议 HTTP2

gRPC 使用 HTTP2 做为传输协议,实现网络通信,这是 gRPC 能成为 RPC 框架的原因之一。gRPC 通道是一个到端点的 HTTP2 连接,在通道创建完成之后可以重用发起多次远程调用。远程调用会映射为HTTP2 中的流,远程调用消息以 HTTP2 帧的形式进行发送,帧可能会携带一条 gRPC 长度前缀的消息,在消息数据很大的情况下一条消息会跨多个帧。

请求消息

RPC 中请求消息都是客户端发起,包括请求头信息、以长度为前缀的消息以及流结束标记 EOS。远程调用在客户端发送请求头信息之后就会初始化,然后其中会发送以长度作为前缀的消息,最后发送 EOS 标记。请求头信息也包含请求方法 method、content-type、grpc-timeout、grpc-encoding、authorization 等。

响应消息

响应消息由服务端生成响应客户端的请求。与请求消息相似,大多数情况下包含响应头信息、以长度作为前缀的消息和 trailer。也可能没有发送以长度为前缀的消息来响应客户端。响应头中包含请求状态、消息压缩类型、content-type等,gRPC 的 content-type 以 application/grpc 开头。

gRPC 通信模式的消息流

一元 RPC 模式下,gRPC 服务端和客户端的通信始终是一个请求和一个响应。 请求消息包含请求头、以长度为前缀的消息,消息可以跨多个数据帧,最后添加一个 EOS 标记,客户端半关闭连接不再可以发送数据但依然接收服务端响应数据。服务端生成响应,响应头包含一个头信息帧、长度为前缀的消息,当服务端发送带有状态详情的 trailer 头信息之后,通信关闭。

双向流模式中,客户端通过发送头消息帧与服务端建立连接,然后互发以长度为前缀的消息,无须等待对方结束,两者都可以在自己的一侧关闭连接。单向流模式下,使用流的一方可以持续发送多个消息。

gRPC 安全连接

基于 gRPC 的网络远程通信可以启用 TLS 传输层安全协议建立安全的连接,这是单向的安全连接。需要 server.key RSA 私钥,用于签名和认证公钥。server.pem/server.crt 是用于分发的自签名 X.509 公钥。生成秘钥可以使用 OpenSSL 。

如果要控制哪些客户端可以连接服务端则可以使用 mTLS 保护的连接,mTLS 会限制服务端只接受一组范围有限、已验证的客户端的连接。mTLS 的通信流程,客户端请求服务端受保护的信息,服务端响应 X.509 证书,客户端通过 CA 对接收到的证书进行校验判断是否为 CA 签名的证书,服务端也验证客户端的证书,验证通过才允许客户端访问受保护的数据。可以使用 OpenSSL 生成秘钥和证书。需要 server.key 服务端 RSA 私钥,server.crt 服务端的证书,client.key 客户端的 RSA 私钥,client.crt 客户端的证书,ca.crt CA 的证书用于签名所有公开证书。

身份认证

gRPC 对远程调用者的身份进行验证,并使用不同的调用凭证技术,实现访问控制功能。

basic 认证

使用 basic 认证机制,增加消息头 Authorization: Basic base64(用户名:密码),使用 base64.StdEncoding.EncodeToString() 对用户名、密码进行编码。另外实现 func (ctx context.Context,in ...string) 方法用于将凭证转换为元数据。必须开启 TLS 才能使用这种认证方式。

服务端使用拦截器进行校验,校验通过则继续执行远程调用,校验不通过则不会继续执行远程方法的调用。一元 RPC 和 流 RPC 拦截器都要进行校验。

客户端远程调用时填入认证的参数,通过 grpc.WithPerRPCCredentials(auth) 在 grpc.Dial 连接时增加一个参数。

OAuth 2.0

OAuth 2.0 是一个访问委托的框架,是用于以自己的名义授权服务有限的访问,不会像 basic 用户名密码方式授予全部访问权限。OAuth 2.0 的流程中有4个主要角色,客户端、授权服务器、资源服务器和资源拥有者,客户端访问服务端的资源前需要向授权服务器获得一个不固定的字符串令牌 token,客户端得到令牌之后就可以向资源服务器发送请求,资源服务器会向授权服务器通信校验令牌。它必须开启 TLS 才能使用这种认证方式。

OAuth 2.0 令牌校验服务端与 basic 认证方式校验类似,也是从元数据读取认证的信息进行校验。

客户端通过 grpc.WithPerRPCCredentials(auth) 在 grpc.Dial 连接时增加一个参数。

JWT

JWT 定义客户端和服务端传输身份信息的容器。签名的 JWT 可用作自包含的访问令牌 token,资源服务器不需要与授权服务器验证 token,可以使用签名来校验令牌。客户端请求授权服务,授权服务器校验客户端的凭证,创建 JWT 并发送给客户端,客户端就可以使用 JWT 访问资源服务器。gRPC 内置了对 JWT 的支持。客户端通过 jwt,err := oauth.NewJWTAccessFromFile("jwt.json") 使用 JWT,连接时增加 DialOption 参数 grpc.WithPerRPCCredentials(jwt)

Last updated