「这是我参与2022首次更文挑战的第23天,活动详情查看:2022首次更文挑战」
1 安装
我们在 grpc-demo 项目下,在命令行执行 Go 语言的 gRPC 库的安装命令,如下:
$ go get -u google.golang.org/grpc@v1.29.1
2 gRPC 的四种调用方式
在 gRPC 中,一共包含四种调用方式,分别是:
- Unary RPC:一元 RPC。
- Server-side streaming RPC:服务端流式 RPC。
- Client-side streaming RPC:客户端流式 RPC。
- Bidirectional streaming RPC:双向流式 RPC。
不同的调用方式往往代表着不同的应用场景,我们接下来将一同深入了解各个调用方式的实现和使用场景,在下述代码中,我们统一将项目下的 proto 引用名指定为 pb,并设置端口号都由外部传入,如下:
import (
...
// 设置引用别名
pb "github.com/go-programming-tour-book/grpc-demo/proto"
)
var port string
func init() {
flag.StringVar(&port, "p", "8000", "启动端口号")
flag.Parse()
}
我们下述的调用方法都是在 server 目录下的 server.go 和 client 目录的 client.go 中完成,需要注意的该两个文件的 package 名称应该为 main(IDE 默认会创建与目录名一致的 package 名称),这样子你的 main 方法才能够被调用,并且在本章中我们的 proto 引用都会以引用别名 pb 来进行调用。
另外我们在每个调用方式的 Proto 小节都会给出该类型 RPC 方法的 Proto 定义,请注意自行新增并在项目根目录执行重新编译生成语句,如下:
$ protoc --go_out=plugins=grpc:. ./proto/*.proto
2.1 Unary RPC:一元 RPC
一元 RPC,也就是是单次 RPC 调用,简单来讲就是客户端发起一次普通的 RPC 请求,响应,是最基础的调用类型,也是最常用的方式,大致如图:

2.1.1 Proto
rpc SayHello (HelloRequest) returns (HelloReply) {};
2.1.2 Server
type GreeterServer struct{}
func (s *GreeterServer) SayHello(ctx context.Context, r *pb.HelloRequest) (*pb.HelloReply, error) {
return &pb.HelloReply{Message: "hello.world"}, nil
}
func main() {
server := grpc.NewServer()
pb.RegisterGreeterServer(server, &GreeterServer{})
lis, _ := net.Listen("tcp", ":"+port)
server.Serve(lis)
}
- 创建 gRPC Server 对象,你可以理解为它是 Server 端的抽象对象。
- 将 GreeterServer(其包含需要被调用的服务端接口)注册到 gRPC Server。 的内部注册中心。这样可以在接受到请求时,通过内部的 “服务发现”,发现该服务端接口并转接进行逻辑处理。
- 创建 Listen,监听 TCP 端口。
- gRPC Server 开始 lis.Accept,直到 Stop 或 GracefulStop。
2.1.3 Client
func main() {
conn, _ := grpc.Dial(":"+port, grpc.WithInsecure())
defer conn.Close()
client := pb.NewGreeterClient(conn)
_ = SayHello(client)
}
func SayHello(client pb.GreeterClient) error {
resp, _ := client.SayHello(context.Background(), &pb.HelloRequest{Name: "eddycjy"})
log.Printf("client.SayHello resp: %s", resp.Message)
return nil
}
- 创建与给定目标(服务端)的连接句柄。
- 创建 Greeter 的客户端对象。
- 发送 RPC 请求,等待同步响应,得到回调后返回响应结果。
2.2 Server-side streaming RPC:服务端流式 RPC
服务器端流式 RPC,也就是是单向流,并代指 Server 为 Stream,Client 为普通的一元 RPC 请求。
简单来讲就是客户端发起一次普通的 RPC 请求,服务端通过流式响应多次发送数据集,客户端 Recv 接收数据集。大致如图:

2.2.1 Proto
rpc SayList (HelloRequest) returns (stream HelloReply) {};
2.2.2 Server
func (s *GreeterServer) SayList(r *pb.HelloRequest, stream pb.Greeter_SayListServer) error {
for n := 0; n <= 6; n++ {
_ = stream.Send(&pb.HelloReply{Message: "hello.list"})
}
return nil
}
在 Server 端,主要留意 stream.Send 方法,通过阅读源码,可得知是 protoc 在生成时,根据定义生成了各式各样符合标准的接口方法。最终再统一调度内部的 SendMsg 方法,该方法涉及以下过程:
- 消息体(对象)序列化。
- 压缩序列化后的消息体。
- 对正在传输的消息体增加 5 个字节的 header(标志位)。
- 判断压缩 + 序列化后的消息体总字节长度是否大于预设的 maxSendMessageSize(预设值为
math.MaxInt32),若超出则提示错误。 - 写入给流的数据集。
2.2.3 Client
func SayList(client pb.GreeterClient, r *pb.HelloRequest) error {
stream, _ := client.SayList(context.Background(), r)
for {
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
log.Printf("resp: %v", resp)
}
return nil
}
在 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,若有特别需求,可以适当调整。
2.4 Client-side streaming RPC:客户端流式 RPC
客户端流式 RPC,单向流,客户端通过流式发起多次 RPC 请求给服务端,服务端发起一次响应给客户端,大致如图:

2.4.1 Proto
rpc SayRecord(stream HelloRequest) returns (HelloReply) {};
2.4.2 Server
func (s *GreeterServer) SayRecord(stream pb.Greeter_SayRecordServer) error {
for {
resp, err := stream.Recv()
if err == io.EOF {
return stream.SendAndClose(&pb.HelloReply{Message:"say.record"})
}
if err != nil {
return err
}
log.Printf("resp: %v", resp)
}
return nil
}
你可以发现在这段程序中,我们对每一个 Recv 都进行了处理,当发现 io.EOF (流关闭) 后,需要通过 stream.SendAndClose 方法将最终的响应结果发送给客户端,同时关闭正在另外一侧等待的 Recv。
2.4.3 Client
func SayRecord(client pb.GreeterClient, r *pb.HelloRequest) error {
stream, _ := client.SayRecord(context.Background())
for n := 0; n < 6; n++ {
_ = stream.Send(r)
}
resp, _ := stream.CloseAndRecv()
log.Printf("resp err: %v", resp)
return nil
}
在 Server 端的 stream.SendAndClose,与 Client 端 stream.CloseAndRecv 是配套使用的方法。
2.5 Bidirectional streaming RPC:双向流式 RPC
双向流式 RPC,顾名思义是双向流,由客户端以流式的方式发起请求,服务端同样以流式的方式响应请求。
首个请求一定是 Client 发起,但具体交互方式(谁先谁后、一次发多少、响应多少、什么时候关闭)根据程序编写的方式来确定(可以结合协程)。
假设该双向流是按顺序发送的话,大致如图:

2.5.1 Proto
rpc SayRoute(stream HelloRequest) returns (stream HelloReply) {};
2.5.2 Server
func (s *GreeterServer) SayRoute(stream pb.Greeter_SayRouteServer) error {
n := 0
for {
_ = stream.Send(&pb.HelloReply{Message: "say.route"})
resp, err := stream.Recv()
if err == io.EOF {
return nil
}
if err != nil {
return err
}
n++
log.Printf("resp: %v", resp)
}
}
2.5.3 Client
func SayRoute(client pb.GreeterClient, r *pb.HelloRequest) error {
stream, _ := client.SayRoute(context.Background())
for n := 0; n <= 6; n++ {
_ = stream.Send(r)
resp, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return err
}
log.Printf("resp err: %v", resp)
}
_ = stream.CloseSend()
return nil
}
3 思考 Unary 和 Streaming RPC
3.1 为什么不用 Unary RPC
StreamingRPC 为什么要存在呢,是 Unary RPC 有什么问题吗,通过模拟业务场景,可得知在使用 Unary RPC 时,有如下问题:
- 在一些业务场景下,数据包过大,可能会造成瞬时压力。
- 接收数据包时,需要所有数据包都接受成功且正确后,才能够回调响应,进行业务处理(无法客户端边发送,服务端边处理)。
3.2 为什么用 Streaming RPC
- 持续且大数据包场景。
- 实时交互场景。
3.3 思考模拟场景
每天早上 6 点,都有一批百万级别的数据集要同从 A 同步到 B,在同步的时候,会做一系列操作(归档、数据分析、画像、日志等),这一次性涉及的数据量确实大。
在同步完成后,也有人马上会去查阅数据,为了新的一天筹备。也符合实时性。在仅允许使用 Unary 或 StreamingRPC 的情况下,两者相较下,这个场景下更适合使用 Streaming RPC。
4 Client 与 Server 是如何交互的
刚刚我们对 gRPC 的四种调用方式进行了探讨,但光会用还是不够的,知其然知其所然很重要,因此我们需要对 gRPC 的整体调用流转有一个基本印象,那么最简单的方式就是对 Client 端调用 Server 端进行抓包去剖析,看看整个过程中它都做了些什么事。
我们另外启动了一个测试用的后端 gRPC 服务,它的监听端口号为 10001,然后我们使用一个 gRPC 客户端用一元 RPC 来调用它,查看抓包情况如下:
我们略加整理发现共有十二个行为,从上到下分别是 Magic、SETTINGS、HEADERS、DATA、SETTINGS、WINDOW_UPDATE、PING、HEADERS、DATA、HEADERS、WINDOW_UPDATE、PING 是比较重要的。
接下来我们将针对每个行为进行分析,而在开始分析之前,我希望你自行思考一下,它们的作用都是什么,大胆猜测一下,带着疑问去学习效果更佳。
4.1 行为分析
4.1.1 Magic
Magic 帧的主要作用是建立 HTTP/2 请求的前言。在 HTTP/2 中,要求两端都要发送一个连接前言,作为对所使用协议的最终确认,并确定 HTTP/2 连接的初始设置,客户端和服务端各自发送不同的连接前言。
而上图中的 Magic 帧是客户端的前言之一,内容为 PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n,以确定启用 HTTP/2 连接。
4.1.2 SETTINGS
SETTINGS 帧的主要作用是设置这一个连接的参数,作用域是整个连接而并非单一的流。
而上图的 SETTINGS 帧都是空 SETTINGS 帧,图一是客户端连接的前言(Magic 和 SETTINGS 帧分别组成连接前言)。图二是服务端的。另外我们从图中可以看到多个 SETTINGS 帧,这是为什么呢?是因为发送完连接前言后,客户端和服务端还需要有一步互动确认的动作。对应的就是带有 ACK 标识 SETTINGS 帧。
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-timeout、grpc-encoding 也是在这里设置的。
4.1.4 DATA
DATA 帧的主要作用是装填主体信息,是数据帧。而在上图中,可以很明显看到我们的请求参数 gRPC 存储在里面。只需要了解到这一点就可以了。
在上图中 HEADERS 帧比较简单,就是告诉我们 HTTP 响应状态和响应的内容格式。
在上图中 DATA 帧主要承载了响应结果的数据集,图中的 gRPC Server 就是我们 RPC 方法的响应结果。
在上图中 HEADERS 帧主要承载了 gRPC 的状态信息,对应图中的 grpc-status 和 grpc-message 就是我们本次 gRPC 调用状态的结果。
4.2 其它步骤
4.2.1 WINDOW_UPDATE
主要作用是管理和流的窗口控制。通常情况下打开一个连接后,服务器和客户端会立即交换 SETTINGS 帧来确定流控制窗口的大小。默认情况下,该大小设置为约 65 KB,但可通过发出一个 WINDOW_UPDATE 帧为流控制设置不同的大小。
4.2.2 PING/PONG
主要作用是判断当前连接是否仍然可用,也常用于计算往返时间。其实也就是 PING/PONG,大家对此应该很熟。
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,反之亦可。