GRPC入门 | 青训营

209 阅读8分钟

RPC 介绍

根据维基百科的定义,RPC(Remote Procedure Call),即远程过程调用,是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员不用额外地为这个交互作用编程。

通俗来讲,就是服务端实现了一个函数,客户端使用 RPC 框架提供的接口,像调用本地函数一样调用这个函数,并获取返回值。RPC 屏蔽了底层的网络通信细节,使得开发人员无需关注网络编程的细节,可以将更多的时间和精力放在业务逻辑本身的实现上,从而提高开发效率。

RPC 调用具体流程如下:

  1. Client 通过本地调用,调用 Client Stub。
  2. Client Stub 将参数打包(也叫 Marshalling)成一个消息,然后发送这个消息。
  3. Client 所在的 OS 将消息发送给 Server。
  4. Server 端接收到消息后,将消息传递给 Server Stub。
  5. Server Stub 将消息解包(也叫 Unmarshalling)得到参数。
  6. Server Stub 调用服务端的子程序(函数),处理完后,将最终结果按照相反的步骤返回给 Client。

gRPC 介绍

gRPC 是由 Google 开发的高性能、开源、跨多种编程语言的通用 RPC 框架,基于 HTTP 2.0 协议开发,默认采用 Protocol Buffers 数据序列化协议。gRPC 具有如下特性:

  • 支持多种语言,例如 Go、Java、C、C++、C#、Node.js、PHP、Python、Ruby 等。
  • 基于 IDL(Interface Definition Language)文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端接口以及客户端 Stub。通过这种方式,也可以将服务端和客户端解耦,使客户端和服务端可以并行开发。
  • 通信协议基于标准的 HTTP/2 设计,支持双向流、消息头压缩、单 TCP 的多路复用、服务端推送等特性。
  • 支持 Protobuf 和 JSON 序列化数据格式。Protobuf 是一种语言无关的高性能序列化框架,可以减少网络传输流量,提高通信效率。

这里要注意的是,gRPC 的全称不是 golang Remote Procedure Call,而是 google Remote Procedure Call。

在 gRPC 中,客户端可以直接调用部署在不同机器上的 gRPC 服务所提供的方法,调用远端的 gRPC 方法就像调用本地的方法一样,非常简单方便,通过 gRPC 调用,我们可以非常容易地构建出一个分布式应用。

像很多其他的 RPC 服务一样,gRPC 也是通过 IDL 语言,预先定义好接口(接口的名字、传入参数和返回参数等)。在服务端,gRPC 服务实现我们所定义的接口。在客户端,gRPC 存根提供了跟服务端相同的方法。

gRPC 支持多种语言,比如我们可以用 Go 语言实现 gRPC 服务,并通过 Java 语言客户端调用 gRPC 服务所提供的方法。通过多语言支持,我们编写的 gRPC 服务能满足客户端多语言的需求。

gRPC API 接口通常使用的数据传输格式是 Protocol Buffers。接下来,我们就一起了解下 Protocol Buffers。

Protocol Buffers 介绍

Protocol Buffers(ProtocolBuffer/ protobuf)是 Google 开发的一套对数据结构进行序列化的方法,可用作(数据)通信协议、数据存储格式等,也是一种更加灵活、高效的数据格式,与 XML、JSON 类似。它的传输性能非常好,所以常被用在一些对数据传输性能要求比较高的系统中,作为数据传输格式。Protocol Buffers 的主要特性有下面这几个。

  • 更快的数据传输速度:protobuf 在传输时,会将数据序列化为二进制数据,和 XML、JSON 的文本传输格式相比,这可以节省大量的 IO 操作,从而提高数据传输速度。
  • 跨平台多语言:protobuf 自带的编译工具 protoc 可以基于 protobuf 定义文件,编译出不同语言的客户端或者服务端,供程序直接调用,因此可以满足多语言需求的场景。
  • 具有非常好的扩展性和兼容性,可以更新已有的数据结构,而不破坏和影响原有的程序。
  • 基于 IDL 文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端和客户端接口。

在 gRPC 的框架中,Protocol Buffers 主要有三个作用。

**第一,可以用来定义数据结构。**举个例子,下面的代码定义了一个 SecretInfo 数据结构:

// SecretInfo contains secret details.
message SecretInfo {
    string name = 1;
    string secret_id  = 2;
    string username   = 3;
    string secret_key = 4;
    int64 expires = 5;
    string description = 6;
    string created_at = 7;
    string updated_at = 8;
}

**第二,可以用来定义服务接口。**下面的代码定义了一个 Cache 服务,服务包含了 ListSecrets 和 ListPolicies 两个 API 接口。

// Cache implements a cache rpc service.
service Cache{
  rpc ListSecrets(ListSecretsRequest) returns (ListSecretsResponse) {}
  rpc ListPolicies(ListPoliciesRequest) returns (ListPoliciesResponse) {}
}

第三,可以通过 protobuf 序列化和反序列化,提升传输效率。

下面是一个protobuf的实例,我们编写一个hello.proto文件。

// 说明我们使用的是proto3语法
syntax = "proto3";
//关于最后生成的go文件在哪个目录哪个包中,.代表当前目录,service代表生成的go文件的包名
option  go_package = ".;service";

// 定义一个服务, 服务中需要一个方法, 接收客户端的参数, 返回服务器的响应
// 定义了一个service叫SayHello, 服务中有一个rpc方法, 叫SayHello
service SayHello{
  rpc SayHello(HelloRequest) returns (HelloResponse) {}
}
// message关键字, 类似于golang中的结构体
// 变量名后的赋值是这个变量在message中的位置
message HelloRequest {
  string requestName = 1; // 标识号(位置)1
//  int64 age = 2;
}

message HelloResponse {
  string responseMsg = 1;
}

在该文件中我们定义了一个service叫SayHello, 服务中有一个rpc方法, 叫SayHello。还定义了两个message(message类似golang中的结构体)HelloRequest和HelloResponse,分别是SayHello方法的两个参数:接收客户端的参数、返回服务器的响应。

注意:变量名后的赋值是这个变量在message中的位置。

gRPC 示例

开发gRPC的流程;

  1. 写proto文件定义服务和消息
  2. 使用protoc工具生成代码
  3. 编写业务逻辑代码提供服务

代码结构如下:

$ tree
├── client
│   └── main.go
├── helloworld
│   ├── helloworld.pb.go
│   └── helloworld.proto
└── server
    └── main.go

client 目录存放 Client 端的代码,helloworld 目录用来存放服务的 IDL 定义,server 目录用来存放 Server 端的代码。

下面我具体介绍下这个示例的四个步骤。

  1. 定义 gRPC 服务。

首先,需要定义我们的服务。进入 helloworld 目录,新建文件 helloworld.proto:

$ cd helloworld
$ vi helloworld.proto

内容如下:

syntax = "proto3";

option go_package = "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld";

package helloworld;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

在 helloworld.proto 定义文件中,option 关键字用来对.proto 文件进行一些设置,其中 go_package 是必需的设置,而且 go_package 的值必须是包导入的路径。package 关键字指定生成的.pb.go 文件所在的包名。我们通过 service 关键字定义服务,然后再指定该服务拥有的 RPC 方法,并定义方法的请求和返回的结构体类型:

service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

gRPC 支持定义 4 种类型的服务方法,分别是简单模式、服务端数据流模式、客户端数据流模式和双向数据流模式。

  • 简单模式(Simple RPC):是最简单的 gRPC 模式。客户端发起一次请求,服务端响应一个数据。定义格式为 rpc SayHello (HelloRequest) returns (HelloReply) {}。
  • 服务端数据流模式(Server-side streaming RPC):客户端发送一个请求,服务器返回数据流响应,客户端从流中读取数据直到为空。定义格式为 rpc SayHello (HelloRequest) returns (stream HelloReply) {}。
  • 客户端数据流模式(Client-side streaming RPC):客户端将消息以流的方式发送给服务器,服务器全部处理完成之后返回一次响应。定义格式为 rpc SayHello (stream HelloRequest) returns (HelloReply) {}。
  • 双向数据流模式(Bidirectional streaming RPC):客户端和服务端都可以向对方发送数据流,这个时候双方的数据可以同时互相发送,也就是可以实现实时交互 RPC 框架原理。定义格式为 rpc SayHello (stream HelloRequest) returns (stream HelloReply) {}。

本示例使用了简单模式。.proto 文件也包含了 Protocol Buffers 消息的定义,包括请求消息和返回消息。例如请求消息:

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}
  1. 生成客户端和服务器代码。

接下来,我们需要根据.proto 服务定义生成 gRPC 客户端和服务器接口。我们可以使用 protoc 编译工具,并指定使用其 Go 语言插件来生成:

$ protoc -I. --go_out=plugins=grpc:$GOPATH/src helloworld.proto
$ ls
helloworld.pb.go  helloworld.proto

你可以看到,新增了一个 helloworld.pb.go 文件。

  1. 实现 gRPC 服务。

接着,我们就可以实现 gRPC 服务了。进入 server 目录,新建 main.go 文件:

$ cd ../server
$ vi main.go

main.go 内容如下:

// Package main implements a server for Greeter service.
package main

import (
  "context"
  "log"
  "net"

  pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
  "google.golang.org/grpc"
)

const (
  port = ":50051"
)

// server is used to implement helloworld.GreeterServer.
type server struct {
  pb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
  log.Printf("Received: %v", in.GetName())
  return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

func main() {
  lis, err := net.Listen("tcp", port)
  if err != nil {
    log.Fatalf("failed to listen: %v", err)
  }
  s := grpc.NewServer()
  pb.RegisterGreeterServer(s, &server{})
  if err := s.Serve(lis); err != nil {
    log.Fatalf("failed to serve: %v", err)
  }
}

上面的代码实现了我们上一步根据服务定义生成的 Go 接口。

我们先定义了一个 Go 结构体 server,并为 server 结构体添加SayHello(context.Context, pb.HelloRequest) (pb.HelloReply, error)方法,也就是说 server 是 GreeterServer 接口(位于 helloworld.pb.go 文件中)的一个实现。

在我们实现了 gRPC 服务所定义的方法之后,就可以通过 net.Listen(...) 指定监听客户端请求的端口;接着,通过 grpc.NewServer() 创建一个 gRPC Server 实例,并通过 pb.RegisterGreeterServer(s, &server{}) 将该服务注册到 gRPC 框架中;最后,通过 s.Serve(lis) 启动 gRPC 服务。

创建完 main.go 文件后,在当前目录下执行 go run main.go ,启动 gRPC 服务。

  1. 实现 gRPC 客户端。

打开一个新的 Linux 终端,进入 client 目录,新建 main.go 文件:

$ cd ../client
$ vi main.go

main.go 内容如下:

// Package main implements a client for Greeter service.
package main

import (
  "context"
  "log"
  "os"
  "time"

  pb "github.com/marmotedu/gopractise-demo/apistyle/greeter/helloworld"
  "google.golang.org/grpc"
)

const (
  address     = "localhost:50051"
  defaultName = "world"
)

func main() {
  // Set up a connection to the server.
  conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
  if err != nil {
    log.Fatalf("did not connect: %v", err)
  }
  defer conn.Close()
  c := pb.NewGreeterClient(conn)

  // Contact the server and print out its response.
  name := defaultName
  if len(os.Args) > 1 {
    name = os.Args[1]
  }
  ctx, cancel := context.WithTimeout(context.Background(), time.Second)
  defer cancel()
  r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
  if err != nil {
    log.Fatalf("could not greet: %v", err)
  }
  log.Printf("Greeting: %s", r.Message)
}

在上面的代码中,我们通过如下代码创建了一个 gRPC 连接,用来跟服务端进行通信:

// Set up a connection to the server.
conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
    log.Fatalf("did not connect: %v", err)
}
defer conn.Close()

在创建连接时,我们可以指定不同的选项,用来控制创建连接的方式,例如 grpc.WithInsecure()、grpc.WithBlock() 等。gRPC 支持很多选项,更多的选项可以参考 grpc 仓库下dialoptions.go文件中以 With 开头的函数。

连接建立起来之后,我们需要创建一个客户端 stub,用来执行 RPC 请求c := pb.NewGreeterClient(conn)。创建完成之后,我们就可以像调用本地函数一样,调用远程的方法了。例如,下面一段代码通过 c.SayHello 这种本地式调用方式调用了远端的 SayHello 接口:

r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
if err != nil {
    log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.Message)

从上面的调用格式中,我们可以看到 RPC 调用具有下面两个特点。

  • 调用方便:RPC 屏蔽了底层的网络通信细节,使得调用 RPC 就像调用本地方法一样方便,调用方式跟大家所熟知的调用类的方法一致:ClassName.ClassFuc(params)。
  • 不需要打包和解包:RPC 调用的入参和返回的结果都是 Go 的结构体,不需要对传入参数进行打包操作,也不需要对返回参数进行解包操作,简化了调用步骤。

最后,创建完 main.go 文件后,在当前目录下,执行 go run main.go 发起 RPC 调用:

$ go run main.go
2020/10/17 07:55:00 Greeting: Hello world