3.3 gRPC 的使用和了解

3.3.1 安装

我们在 grpc-demo 项目下,在命令行执行 Go 语言的 gRPC 库的安装命令,如下:

  1. $ go get -u google.golang.org/grpc@v1.29.1

3.3.2 gRPC 的四种调用方式

在 gRPC 中,一共包含四种调用方式,分别是:

  1. Unary RPC:一元 RPC。
  2. Server-side streaming RPC:服务端流式 RPC。
  3. Client-side streaming RPC:客户端流式 RPC。
  4. Bidirectional streaming RPC:双向流式 RPC。

不同的调用方式往往代表着不同的应用场景,我们接下来将一同深入了解各个调用方式的实现和使用场景,在下述代码中,我们统一将项目下的 proto 引用名指定为 pb,并设置端口号都由外部传入,如下:

  1. import (
  2. ...
  3. // 设置引用别名
  4. pb "github.com/go-programming-tour-book/grpc-demo/proto"
  5. )
  6. var port string
  7. func init() {
  8. flag.StringVar(&port, "p", "8000", "启动端口号")
  9. flag.Parse()
  10. }

我们下述的调用方法都是在 server 目录下的 server.go 和 client 目录的 client.go 中完成,需要注意的该两个文件的 package 名称应该为 main(IDE 默认会创建与目录名一致的 package 名称),这样子你的 main 方法才能够被调用,并且在本章中我们的 proto 引用都会以引用别名 pb 来进行调用

另外我们在每个调用方式的 Proto 小节都会给出该类型 RPC 方法的 Proto 定义,请注意自行新增并在项目根目录执行重新编译生成语句,如下:

  1. $ protoc --go_out=plugins=grpc:. ./proto/*.proto

3.3.2.1 Unary RPC:一元 RPC

一元 RPC,也就是是单次 RPC 调用,简单来讲就是客户端发起一次普通的 RPC 请求,响应,是最基础的调用类型,也是最常用的方式,大致如图:

image

3.3.2.1.1 Proto

  1. rpc SayHello (HelloRequest) returns (HelloReply) {};

3.3.2.1.2 Server

  1. type GreeterServer struct{}
  2. func (s *GreeterServer) SayHello(ctx context.Context, r *pb.HelloRequest) (*pb.HelloReply, error) {
  3. return &pb.HelloReply{Message: "hello.world"}, nil
  4. }
  5. func main() {
  6. server := grpc.NewServer()
  7. pb.RegisterGreeterServer(server, &GreeterServer{})
  8. lis, _ := net.Listen("tcp", ":"+port)
  9. server.Serve(lis)
  10. }
  • 创建 gRPC Server 对象,你可以理解为它是 Server 端的抽象对象。
  • 将 GreeterServer(其包含需要被调用的服务端接口)注册到 gRPC Server。 的内部注册中心。这样可以在接受到请求时,通过内部的 “服务发现”,发现该服务端接口并转接进行逻辑处理。
  • 创建 Listen,监听 TCP 端口。
  • gRPC Server 开始 lis.Accept,直到 Stop 或 GracefulStop。

3.3.2.1.3 Client

  1. func main() {
  2. conn, _ := grpc.Dial(":"+port, grpc.WithInsecure())
  3. defer conn.Close()
  4. client := pb.NewGreeterClient(conn)
  5. _ = SayHello(client)
  6. }
  7. func SayHello(client pb.GreeterClient) error {
  8. resp, _ := client.SayHello(context.Background(), &pb.HelloRequest{Name: "eddycjy"})
  9. log.Printf("client.SayHello resp: %s", resp.Message)
  10. return nil
  11. }
  • 创建与给定目标(服务端)的连接句柄。
  • 创建 Greeter 的客户端对象。
  • 发送 RPC 请求,等待同步响应,得到回调后返回响应结果。

3.3.2.2 Server-side streaming RPC:服务端流式 RPC

服务器端流式 RPC,也就是是单向流,并代指 Server 为 Stream,Client 为普通的一元 RPC 请求。

简单来讲就是客户端发起一次普通的 RPC 请求,服务端通过流式响应多次发送数据集,客户端 Recv 接收数据集。大致如图:

image

3.3.2.2.1 Proto

  1. rpc SayList (HelloRequest) returns (stream HelloReply) {};

3.3.2.2.2 Server

  1. func (s *GreeterServer) SayList(r *pb.HelloRequest, stream pb.Greeter_SayListServer) error {
  2. for n := 0; n <= 6; n++ {
  3. _ = stream.Send(&pb.HelloReply{Message: "hello.list"})
  4. }
  5. return nil
  6. }

在 Server 端,主要留意 stream.Send 方法,通过阅读源码,可得知是 protoc 在生成时,根据定义生成了各式各样符合标准的接口方法。最终再统一调度内部的 SendMsg 方法,该方法涉及以下过程:

  • 消息体(对象)序列化。
  • 压缩序列化后的消息体。
  • 对正在传输的消息体增加 5 个字节的 header(标志位)。
  • 判断压缩 + 序列化后的消息体总字节长度是否大于预设的 maxSendMessageSize(预设值为 math.MaxInt32),若超出则提示错误。
  • 写入给流的数据集。

3.3.2.2.3 Client

  1. func SayList(client pb.GreeterClient, r *pb.HelloRequest) error {
  2. stream, _ := client.SayList(context.Background(), r)
  3. for {
  4. resp, err := stream.Recv()
  5. if err == io.EOF {
  6. break
  7. }
  8. if err != nil {
  9. return err
  10. }
  11. log.Printf("resp: %v", resp)
  12. }
  13. return nil
  14. }

在 Client 端,主要留意 stream.Recv() 方法,我们可以思考一下,什么情况下会出现 io.EOF ,又在什么情况下会出现错误信息呢?实际上 stream.Recv 方法,是对 ClientStream.RecvMsg 方法的封装,而 RecvMsg 方法会从流中读取完整的 gRPC 消息体,我们可得知:

  • RecvMsg 是阻塞等待的。

  • RecvMsg 当流成功/结束(调用了 Close)时,会返回 io.EOF

  • RecvMsg 当流出现任何错误时,流会被中止,错误信息会包含 RPC 错误码。而在 RecvMsg 中可能出现如下错误,例如:

    • io.EOF、io.ErrUnexpectedEOF
    • transport.ConnectionError
    • google.golang.org/grpc/codes(gRPC 的预定义错误码)

需要注意的是,默认的 MaxReceiveMessageSize 值为 1024 *1024* 4,若有特别需求,可以适当调整。

3.3.2.4 Client-side streaming RPC:客户端流式 RPC

客户端流式 RPC,单向流,客户端通过流式发起多次 RPC 请求给服务端,服务端发起一次响应给客户端,大致如图:

image

3.3.2.4.1 Proto

  1. rpc SayRecord(stream HelloRequest) returns (HelloReply) {};

3.3.2.4.2 Server

  1. func (s *GreeterServer) SayRecord(stream pb.Greeter_SayRecordServer) error {
  2. for {
  3. resp, err := stream.Recv()
  4. if err == io.EOF {
  5. return stream.SendAndClose(&pb.HelloReply{Message:"say.record"})
  6. }
  7. if err != nil {
  8. return err
  9. }
  10. log.Printf("resp: %v", resp)
  11. }
  12. return nil
  13. }

你可以发现在这段程序中,我们对每一个 Recv 都进行了处理,当发现 io.EOF (流关闭) 后,需要通过 stream.SendAndClose 方法将最终的响应结果发送给客户端,同时关闭正在另外一侧等待的 Recv。

3.3.2.4.3 Client

  1. func SayRecord(client pb.GreeterClient, r *pb.HelloRequest) error {
  2. stream, _ := client.SayRecord(context.Background())
  3. for n := 0; n < 6; n++ {
  4. _ = stream.Send(r)
  5. }
  6. resp, _ := stream.CloseAndRecv()
  7. log.Printf("resp err: %v", resp)
  8. return nil
  9. }

在 Server 端的 stream.CloseAndRecv,与 Client 端 stream.SendAndClose 是配套使用的方法。

3.3.2.5 Bidirectional streaming RPC:双向流式 RPC

双向流式 RPC,顾名思义是双向流,由客户端以流式的方式发起请求,服务端同样以流式的方式响应请求。

首个请求一定是 Client 发起,但具体交互方式(谁先谁后、一次发多少、响应多少、什么时候关闭)根据程序编写的方式来确定(可以结合协程)。

假设该双向流是按顺序发送的话,大致如图:

image

3.3.2.5.1 Proto

  1. rpc SayRoute(stream HelloRequest) returns (stream HelloReply) {};

3.3.2.5.2 Server

  1. func (s *GreeterServer) SayRoute(stream pb.Greeter_SayRouteServer) error {
  2. n := 0
  3. for {
  4. _ = stream.Send(&pb.HelloReply{Message: "say.route"})
  5. resp, err := stream.Recv()
  6. if err == io.EOF {
  7. return nil
  8. }
  9. if err != nil {
  10. return err
  11. }
  12. n++
  13. log.Printf("resp: %v", resp)
  14. }
  15. }

3.3.2.5.3 Client

  1. func SayRoute(client pb.GreeterClient, r *pb.HelloRequest) error {
  2. stream, _ := client.SayRoute(context.Background())
  3. for n := 0; n <= 6; n++ {
  4. _ = stream.Send(r)
  5. resp, err := stream.Recv()
  6. if err == io.EOF {
  7. break
  8. }
  9. if err != nil {
  10. return err
  11. }
  12. log.Printf("resp err: %v", resp)
  13. }
  14. _ = stream.CloseSend()
  15. return nil
  16. }

3.3.3 思考 Unary 和 Streaming RPC

3.3.3.1 为什么不用 Unary RPC

StreamingRPC 为什么要存在呢,是 Unary RPC 有什么问题吗,通过模拟业务场景,可得知在使用 Unary RPC 时,有如下问题:

  • 在一些业务场景下,数据包过大,可能会造成瞬时压力。
  • 接收数据包时,需要所有数据包都接受成功且正确后,才能够回调响应,进行业务处理(无法客户端边发送,服务端边处理)。

3.3.3.2 为什么用 Streaming RPC

  • 持续且大数据包场景。
  • 实时交互场景。

3.3.3.3 思考模拟场景

每天早上 6 点,都有一批百万级别的数据集要同从 A 同步到 B,在同步的时候,会做一系列操作(归档、数据分析、画像、日志等),这一次性涉及的数据量确实大。

在同步完成后,也有人马上会去查阅数据,为了新的一天筹备。也符合实时性。在仅允许使用 Unary 或 StreamingRPC 的情况下,两者相较下,这个场景下更适合使用 Streaming RPC。

3.3.4 Client 与 Server 是如何交互的

刚刚我们对 gRPC 的四种调用方式进行了探讨,但光会用还是不够的,知其然知其所然很重要,因此我们需要对 gRPC 的整体调用流转有一个基本印象,那么最简单的方式就是对 Client 端调用 Server 端进行抓包去剖析,看看整个过程中它都做了些什么事。

我们另外启动了一个测试用的后端 gRPC 服务,它的监听端口号为 10001,然后我们使用一个 gRPC 客户端用一元 RPC 来调用它,查看抓包情况如下:

image

我们略加整理发现共有十二个行为,从上到下分别是 Magic、SETTINGS、HEADERS、DATA、SETTINGS、WINDOW_UPDATE、PING、HEADERS、DATA、HEADERS、WINDOW_UPDATE、PING 是比较重要的。

接下来我们将针对每个行为进行分析,而在开始分析之前,我希望你自行思考一下,它们的作用都是什么,大胆猜测一下,带着疑问去学习效果更佳。

3.3.4.1 行为分析

3.3.4.1.1 Magic

image

Magic 帧的主要作用是建立 HTTP/2 请求的前言。在 HTTP/2 中,要求两端都要发送一个连接前言,作为对所使用协议的最终确认,并确定 HTTP/2 连接的初始设置,客户端和服务端各自发送不同的连接前言。

而上图中的 Magic 帧是客户端的前言之一,内容为 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,以确定启用 HTTP/2 连接。

3.3.4.1.2 SETTINGS

image

image

SETTINGS 帧的主要作用是设置这一个连接的参数,作用域是整个连接而并非单一的流。

而上图的 SETTINGS 帧都是空 SETTINGS 帧,图一是客户端连接的前言(Magic 和 SETTINGS 帧分别组成连接前言)。图二是服务端的。另外我们从图中可以看到多个 SETTINGS 帧,这是为什么呢?是因为发送完连接前言后,客户端和服务端还需要有一步互动确认的动作。对应的就是带有 ACK 标识 SETTINGS 帧。

3.3.4.1.3 HEADERS

image

HEADERS 帧的主要作用是存储和传播 HTTP 的标头信息。我们关注到 HEADERS 里有一些眼熟的信息,分别如下:

  • method:POST
  • scheme:http
  • path:/proto.SearchService/Search
  • authority::10001
  • content-type:application/grpc
  • user-agent:grpc-go/1.20.0-dev

你会发现这些东西非常眼熟,其实都是 gRPC 的基础属性,实际上远远不止这些,只是设置了多少展示多少。例如像平时常见的 grpc-timeoutgrpc-encoding 也是在这里设置的。

3.3.4.1.4 DATA

image

DATA 帧的主要作用是装填主体信息,是数据帧。而在上图中,可以很明显看到我们的请求参数 gRPC 存储在里面。只需要了解到这一点就可以了。

3.3.4.1.5 HEADERS, DATA, HEADERS

image

在上图中 HEADERS 帧比较简单,就是告诉我们 HTTP 响应状态和响应的内容格式。

imgae

在上图中 DATA 帧主要承载了响应结果的数据集,图中的 gRPC Server 就是我们 RPC 方法的响应结果。

image

在上图中 HEADERS 帧主要承载了 gRPC 的状态信息,对应图中的 grpc-statusgrpc-message 就是我们本次 gRPC 调用状态的结果。

3.3.4.2 其它步骤

3.3.4.2.1 WINDOW_UPDATE

主要作用是管理和流的窗口控制。通常情况下打开一个连接后,服务器和客户端会立即交换 SETTINGS 帧来确定流控制窗口的大小。默认情况下,该大小设置为约 65 KB,但可通过发出一个 WINDOW_UPDATE 帧为流控制设置不同的大小。

image

3.3.4.2.2 PING/PONG

主要作用是判断当前连接是否仍然可用,也常用于计算往返时间。其实也就是 PING/PONG,大家对此应该很熟。

3.3.4.3 小结

在本章节中,我们对于 gRPC 的基本使用和交互原理进行了一个简单剖析,我们总结如下:

  • gRPC 一共支持四种调用方式,分别是:

    • Unary RPC:一元 RPC。
    • Server-side streaming RPC:服务端流式 RPC。
    • Client-side streaming RPC:客户端流式 RPC。
    • Bidirectional streaming RPC:双向流式 RPC。
  • gRPC 在建立连接之前,客户端/服务端都会发送连接前言(Magic+SETTINGS),确立协议和配置项。

  • gRPC 在传输数据时,是会涉及滑动窗口(WINDOW_UPDATE)等流控策略的。

  • 传播 gRPC 附加信息时,是基于 HEADERS 帧进行传播和设置;而具体的请求/响应数据是存储的 DATA 帧中的。

  • gRPC 请求/响应结果会分为 HTTP 和 gRPC 状态响应(grpc-status、grpc-message)两种类型。

  • 客户端发起 PING,服务端就会回应 PONG,反之亦可。

本图书由 煎鱼©2020 版权所有,所有文章采用知识署名-非商业性使用-禁止演绎 4.0 国际进行许可。

3.3 gRPC 的使用和了解 - 图15