gRPC初探

Posted petit kayak

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了gRPC初探相关的知识,希望对你有一定的参考价值。

gRPC基本原理

RPC 框架的目标就是让远程服务调用更加简单、透明,RPC 框架负责屏蔽底层的传输方式(TCP 或者 UDP)、序列化方式(XML/Json/ 二进制)和通信细节。服务调用者可以像调用本地接口一样调用远程的服务提供者,而不需要关心底层通信细节和调用过程。RPC架构实际上是一种网络通信的应用层抽象,它将服务器和客户端之间的通信内容包装成函数的调用,调用的参数对应客户端发送到服务器的请求,而服务器返回的数据则被解析成为函数的返回值,供业务代码使用。

RPC架构的一般调用关系如下:

gRPC是最初由Google公司开发并开源的一种语言中立的 RPC 框架,它的核心代码由C++编写而成,支持包括C、C++、Java、Python、JS、Golang、C#、Ruby等大多数主流编程语言,它的主要特定包括:

  1. 语言中立,支持多种语言相互间的调用;

  2. 基于 IDL 文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端接口以及客户端 Stub;

  3. 通信协议基于标准的 HTTP/2 设计,支持双向流、消息头压缩、单 TCP 的多路复用、服务端推送等特性,这些特性使得 gRPC 在移动端设备上更加省电和节省网络流量;

  4. 序列化支持 PB(Protocol Buffer)和 JSON,PB 是一种语言无关的高性能序列化框架,基于 HTTP/2 + PB, 保障了 RPC 调用的高性能。

gRPC Server是服务器的入口,它完成网络端口的监听、连接的建立和具体的数据通信,将接收到的请求解析成函数的参数,然后调用注册的函数实现,获得返回值后,再将其打包成为用于通信的数据,发送到客户端。

gRPC Stub则相当于服务的代理,当业务应用调用它所提供的函数时,它首先与服务器建立连接,然后将函数的输入参数打包成gRPC的请求报文发送到服务器,在获得反馈的数据后,再解析成函数的输出对象,返回给业务应用。

gRPC只关注通信的封装工作,并不包含其他的治理工具,这带来的主要问题就是,如果在一个大型的信息网络中使用gRPC构建微服务系统,就必须额外搭配各种服务治理工具,好在gRPC的开源生态已经日趋成熟,有大量优秀的开源工具和大公司的成功案例可供选择和参考。

这种仅关注通信的模式,也给gRPC带来了一定的优势,那就是它的应用并不局限与微服务领域,事实上,任何基于服务-客户端模式的网络通信都可以抽象成为远程的调用,因此,其他治理工具完全可以根据实际需要进行选配和裁剪。例如,说对于相对较小的应用网络,不同应用间的通信也可以通过gRPC实现,此时,完全可以使用固定的通信配置而省去繁杂的服务治理,实现一个逻辑清晰且体量轻盈的网络系统。对于规模稍大的系统,还可以在仅仅配备第三方注册中心和部署工具的基础上,实现一个可以快速部署和动态方位的应用系统。

从源代码编译生成

从github.com上获取最新的gRPC发布版本,本文使用的是V1.32.0,注意要将所有的子工程也克隆下来。进入grpc目录,运行以下命令即可完成编译:

 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# prepare install path
grpc_path=`pwd`
export GRPC_INSTALL_PATH=$grpc_path/../grpc-install
mkdir -p $GRPC_INSTALL_PATH
export PATH=$PATH:$GRPC_INSTALL_PATH/bin
# using cmake to build gRPC
mkdir -p cmake/build
pushd cmake/build/
cmake -DgRPC_INSTALL=ON \
-DgRPC_BUILD_TESTS=OFF \
-DCMAKE_INSTALL_PREFIX=$GRPC_INSTALL_PATH \
../..
make -j8
make install
popd

运行C++下的HelloWorld示例

进入示例文件夹examples目录可以看到gRPC为各个支持语言准备好的示例工程,其中C++的示例在cpp目录下,进入cpp/helloworld目录,可以看到该示例程序的源代码,使用cmake编译这个例子:

1
2
3
4
5
cd examples/cpp/helloworld
mkdir -p cmake/build
cd cmake/build
cmake -DCMAKE_INSTALL_PREFIX=$GRPC_INSTALL_PATH ../..
make -j8

编译完成后,会得到服务器程序 greeter_server 和 greeter_client,首先将服务器程序运行到后台:

1
./greeter_server &

运行成功后会得到"Server listening on 0.0.0.0:50051"的提示,此时终端仍然可以输入命令。然后,每运行一次客户端程序 ./greeter_client 都可以得到"Greeter received: Hello world"的打印,证明服务器和客户端已经可以通过本地50051端口进行通信。

运行 killall greeter_server 结束服务器程序,此时再运行客户端就会得到"Greeter received: RPC failed"的提示,证明此时客户端已经无法连接到服务器。

剖析HelloWorld示例

HellowWorld工程共可以生成5个应用程序:

  • greeter_server:同步模式(单线程)运行的服务器

  • greeter_client:同步模式(单线程)运行的客户端

  • greeter_async_server:异步模式(动态多线程)运行的服务器

  • greeter_async_client:异步模式(单线程)运行的客户端

  • greeter_async_client2:异步模式(多线程)运行的客户端

可以看出gRPC的工作模式大体上可以分为同步和异步两种,总的来说,同步就是完成一次调用再处理下一个,是单线程的;而异步则是会分出多个工作流(线程),每个工作流能够独立的工作,这样应用就可以“同时”处理多个调用过程,这里的同时指的是并发,而不是并行。同步模式对应于通信中的阻塞方式,比较适合简单的应用系统,而异步模式对应于非阻塞通信方式,更适合较为复杂且对于性能有一定要求的系统。

下面首先分析一下服务的定义文件,然后剖析一下同步模式的服务器和客户端,最后分析异步模式的服务器和客户端。

服务定义文件

gRPC使用了基于Google Protobuf的数据串行化,因此,它的服务定义由proto协议编写,即gRPC使用proto文件作为它的IDL文件,现在基本使用的都是proto3,proto2正在逐渐被替代。

对于HelloWorld服务,它的IDL文件为helloworld.proto,放在$grpc_path/protos目录下,下面是它的内容。

 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";
option objc_class_prefix = "HLW";

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;
}

从这个文件中看,它主要包括几个部分:

  • 首先是proto3的语法声明,如果缺失了这一部分,protoc工具会按照proto2进行解析;

  • 然后是针对java和Objective-C的一些选项,暂时不考虑;

  • 接下来就是对于服务的定义,用service关键字标识,里面的函数用rpc关键字标识;

  • 最后是对输入参数和输出返回值类型的定义。

具体的proto语法请查看Protobuf官网。

同步模式服务器 greeter_server

打开greeter_server.cc文件,可以看到它首先创建了服务实现类GreeterServiceImpl,它继承自动生成的Greeter::Service类,并实现了服务定义的函数SayHello,如下所示。

1
2
3
4
5
6
7
8
class GreeterServiceImpl final : public Greeter::Service {
Status SayHello(ServerContext *context, const HelloRequest *request,
HelloReply *reply) override {
std::string prefix("Hello ");
reply->set_message(prefix + request->name());
return Status::OK;
}
};
 1
2
3
4
5
6
7
8
9
10
11
12
void RunServer() {
std::string server_address("0.0.0.0:50051"); // prepare listening address and port
GreeterServiceImpl service;// create instance of service implementation class
grpc::EnableDefaultHealthCheckService(true);
grpc::reflection::InitProtoReflectionServerBuilderPlugin();
ServerBuilder builder;// create service builder
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());// set listening address and port, no SSL
builder.RegisterService(&service);//register service implementation
std::unique_ptr<Server> server(builder.BuildAndStart()); // create server
std::cout << "Server listening on " << server_address << std::endl;
server->Wait();//start server
}

gRPC提供的Builder类和Server类将具体的网络通信和调用机制都封装了起来,开发人员只需要关注在具体的服务实现便可以了。

同步模式客户端 greeter_client

greeter_client.cc文件中,将服务的调用封装在一个名为GreeterClient的类,我认为,这主要是为了和服务器程序中的GreeterService对应,显式的体现服务器-客户端模式,但其实,gRPC的客户端并不需要构建这样的类。事实上,只要创建服务对应的gRPC Stub就可以了,这个自动生成的类本身就是服务的代理。在剥离了多余的代码结构后,可以看出整个服务调用过程大致可以分为3个简单的步骤:

  1. 通过代理调用服务:调用的过程非常简单,Status status = stub->SayHello(&context, request, &reply);,这里的request和replay是服务的输入和输出参数,类型为HelloRequestHelloReply,context是客户端上下文,可以附加一些客户端信息,个人认为一般情况下只用空白的上下文就可以,不需要附在任何信息;

  2. 结果处理:调用完成后,可以使用status.ok()判断调用是否成功,如何成功了,直接使用返回值replay即可,如果失败,可以通过调用status.error_code()status.error_message()获取错误代码和错误信息,可以想象,在较为复杂的大型网络系统中,如果失败了,可以自动触发重试,或更换远端服务重试,这些不在gRPC的讨论范围。

异步模式服务器 greeter_asyn_server

异步模式允许服务器并发的处理多个请求,gRPC通过构建完成队列和自动生成的异步服务类来实现异步模式。

例子greeter_asyn_server的做法是构建了一个动态创建和动态释放的CallData类来处理请求,并且将它的指针与等待队列关联。这种方式实现的比较巧妙,但逻辑上不好理解,而且从性能上讲,如果请求大量的涌入,不能及时处理,这种做法就会一瞬间创建大量的CallData实例,很快将内存耗尽,导致服务器崩溃。

因此,这里计划重新设计一种异步的服务器程序。

首先还是构建服务的实现类,由于在异步模式下,该实现类并不直接注册到grpc::Server上,因此不能继承Greeter::Service类,而是一个简单的实现了服务函数的类:

1
2
3
4
5
6
7
8
9
class GreeterServiceImpl {
public:
Status SayHello(ServerContext *context, const HelloRequest *request,
HelloReply *reply) {
std::string prefix("Hello ");
reply->set_message(prefix + request->name());
return Status::OK;
}
};

异步模式下,可以同时启动多个服务过程,放置在一个异步触发的队列中,在服务接收到请求或服务完成响应后,需要根据对应的“标签”启动响应的处理。这个“标签”其实就是单个访问所需的全部信息。这里定义一个“标签”类来管理访问的信息以及相应的处理。

 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class GreeterServiceTag {
public:
bool requesting;
Greeter::AsyncService* async_service;
ServerCompletionQueue* cq;
GreeterServiceImpl service;
ServerContext *ctx;
HelloRequest request;
HelloReply reply;
ServerAsyncResponseWriter<HelloReply> *responder;

GreeterServiceTag(Greeter::AsyncService* service, ServerCompletionQueue* cq):
requesting(false),async_service(service),cq(cq),ctx(nullptr),responder(nullptr) {
process();
}

void process() {
if(requesting) {
requesting = false;
Status s = service.SayHello(ctx,&request,&reply);
responder->Finish(reply,s,this);
}
else {
requesting = true;
if(responder) {
delete responder;
}
if(ctx) {
delete ctx;
}
ctx = new ServerContext();
responder = new ServerAsyncResponseWriter<HelloReply>(ctx);
async_service->RequestSayHello(ctx,&request,responder,cq,cq,this);
}
}
};

从这个标签类可以看出,异步模式下,每一次服务响应都分为两部分:

  • 获取服务请求,对应自动生成的Greeter::AsyncService类函数RequestSayHello,每一次请求,都需要构建对应于该次请求的上下文和异步的响应器;

  • 处理请求,并反馈结果,这一步需要使用请求过程中准备好的上下文和响应器,在处理完请求后,通过响应器的Finish函数发起反馈过程。

在准备好标签类后,下面开始启动grpc服务,首先还是通过ServerBuilder构建服务,与同步模式不同的是,注册的服务不再是Greeter::Service实现类的实例,而是自动生成的Greeter::AsyncService类实例,并且需要创建完成队列,如下所示:

1
2
3
4
5
6
7
8
    ServerBuilder builder;
std::string server_address("0.0.0.0:50051");
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
Greeter::AsyncService service;
builder.RegisterService(&service);
std::unique_ptr<ServerCompletionQueue> cq = builder.AddCompletionQueue();
std::unique_ptr<Server> server = builder.BuildAndStart();
std::cout << "Server listening on " << server_address << std::endl;

在准备好服务器后,通过构建多个标签类实例来异步的处理服务请求,这里默认构造了10个标签实例,即服务器可以同时异步的处理10个请求,这个数量可以根据实际需求调整:

1
2
3
    for(int i=0; i<10; i++) {
GreeterServiceTag *tag = new GreeterServiceTag(&service, cq.get());
}

最后,启动循环,等待并处理完成队列事件。

1
2
3
4
5
6
7
    void *tag = nullptr;
bool ok = false;
while (true) {
GPR_ASSERT(cq->Next(&tag, &ok));
GPR_ASSERT(ok);
static_cast<GreeterServiceTag*>(tag)->process();
}

可以看出,异步模式虽然比同步模式更加复杂,但逻辑仍然比较清晰,增加的复杂度可控,且带来了巨大的优势,那就是对网络通信等待时间的利用,极大的增加了CPU、网络等资源的使用率,因此,服务端程序更应该设计为异步模式。

异步模式客户端客户端 greeter_asyn_client2

异步模式的客户端与服务器类似,也是需要构建完成队列,并为每一次调用设定“标签”,在调用完成时,可以从完成队列获取到之前设定的标签,根据标签处理返回的结果。不同的是,客户端只有一阶段等待。

与同步模式相同,需要首先准备服务代理stub,这一步就不赘述了。

然后需要设计标签类,在greeter_async_client2例子中的定义为:

1
2
3
4
5
6
struct AsyncClientCall {
HelloReply reply;
ClientContext context;
Status status;
std::unique_ptr<ClientAsyncResponseReader<HelloReply>> response_reader;
};

在每一次发起调用时,首先创建标签实例,然后准备反馈的阅读器,通过阅读器发起调用,具体过程如下:

1
2
3
4
5
        AsyncClientCall* call = new AsyncClientCall;//准备标签实例
call->response_reader =
stub_->PrepareAsyncSayHello(&call->context, request, &cq_);//准备阅读器
call->response_reader->StartCall();//发起调用
call->response_reader->Finish(&call->reply, &call->status, (void*)call);//进入等待

在完成队列的Next函数返回后,取出对应的标签实例,从中解读结果,然后释放该实例,一次调用也就完成了,具体如下:

 1
2
3
4
5
6
7
8
9
10
11
        void *got_tag;
bool ok = false;
while (cq_.Next(&got_tag, &ok)) {//等待完成队列
AsyncClientCall *call = static_cast<AsyncClientCall*>(got_tag);//获取标签
GPR_ASSERT(ok);
if (call->status.ok())//解读结果
std::cout << "Greeter received: " << call->reply.message() << std::endl;
else
std::cout << "RPC failed" << std::endl;
delete call;//释放标签
}

对于客户端而言,异步模式并不实用,RPC最大的优势就是可以将对远端服务的调用抽象成为本地函数的调用,使用异步模式之后,这种封装上的优势就丢失了。当然,异步模式,可以不阻塞线程,可以在等待结果的过程中,进行其他的工作,但考虑到具体的应用场景,这并没有太大的好处:

  • 很多客户程序,都需要调用返回结果之后,才能继续后面的任务,在结果返回之前必须等待;

  • 对于那些在等待过程中可以进行其他工作的客户端程序,更好的逻辑设计是将服务调用相关的任务和其他可以同步进行的任务分离开,前者在单独的子线程中进行且仍然使用同步模式,这样程序逻辑比单一线程中使用异步模式更加清晰;

当然,也有适合异步模式的客户端程序,那就是需要一次性发起很多请求,然后等待处理每一个返回结果的应用。比如一些客户端需要在开始的时候从不同的服务器获取多个数据,在所有或者一部分数据返回以后才能进行接下来的任务,这种客户端程序就适合使用异步模式。


以上是关于gRPC初探的主要内容,如果未能解决你的问题,请参考以下文章

云原生 API 网关,gRPC-Gateway V2 初探

gRPC 初探与简单使用

gRPC服务开发和接口测试初探Go

gRPC初探

gRPC服务开发和接口测试初探「Go」

gRPC四种类型示例分析知识笔记