Google gRPC框架学习笔记

  其实gRPC算是比较年轻的项目,虽据说已在Google内部被大规模部署使用,但从GitHub上看是2016年8月19日打的v1.0.0的tag,而官方博客发布声明在2016年8月23日。正式发布也就意味着通信协议的确定、接口API已经稳定,而性能、压力、稳定性各项测试已经满足需求,可以部署到生产环境中,广大基佬们可以安心使用了。
  与gRPC/Protobuf相对应的,莫过于当前最熟悉的传统经典HTTP/JSON模式了,传统开发的惯用手法就是:客户端发起请求、服务端接收请求、服务端解析请求、服务端进行业务逻辑处理、服务端打包响应、服务端发送响应、客户端解析响应。虽然现在的序列化库和网络开发框架漫山遍野多如牛毛,但是服务端和客户端开发还是需要不断地封装、解析数据,处理网络传输的各项细节。
  RPC从本质上来说,就是通过客户端和服务器的协作,将客户端的本地调用转化成请求发送到服务端,服务端进行实际操作后,再将结果返回给客户端,所以从客户端的角度看来就和一个本地调用的效果一样,虽然实际上跨进程、跨主机的调用会遇见各种复杂的情况,但是RPC框架负责屏蔽这些细节信息,用户只需要专注于业务逻辑开发即可。从Wikipedia的资料看来,RPC的概念很早就已经被提出来,而最近风光无限的几个开源RPC框架基本都出自大厂之手,其源于在互联网环境下,大量的分布式应用或服务可以使用RPC的方式轻松解耦,增加了复用性,提高了开发效率。
  此外还想罗嗦一句:gRPC/Protobuf不仅可以用于常规网络服务开发,甚至可以作为本地进程间通信方式使用,因为RPC本来就属于一种IPC手段。
grpc
  gRPC和Protobuf天生有着紧密的联系,在gRPC中Protobuf不仅作为一种序列化的工具使用,而且用于定义服务端与客户端之间的RPC调用接口(IDL的效果),然后通过protoc工具可以快速生成客户端和服务端的代码。gRPC允许通过Protobuf的插件,独立指定客户端和服务端生成的语言类型,这对于时下移动互联网时代的开发意义重大。Protobuf是一种重要的序列化工具,其编码效率和速率非常的高,而且在工程化的过程中Google考虑到前向兼容等各项事宜,简单的手册可以参见之前的《Protobuf数据交换格式的使用方法》。无论以后用哪家的RPC,都建议好好学习熟练掌握它,因为当前一些新开源的框架库基本都默认用它作为数据交互格式。

  下面借着gRPC官方的手册,流水帐般地过一下gRPC的相关东西。

一、RPC生命周期

  gRPC支持四种服务类型:Unary RPCs、Server streaming RPCs、Client streaming RPCs和Bidirectional streaming RPCs,通过参数和返回类型是否有stream关键字来标识区分。最简单的是Unary RPC调用,客户端发送一个请求参数,服务端做出一个应答数据;Server stream RPC调用是服务端可以返回多个数据,客户端一般在while中一直读取结束;Client stream是客户端可以向服务端传输多个请求,告知服务端传输结束后等待服务端返回;而Bidirectional stream则是一个全双工的通信,两端可以在任意时刻发送和接收数据,互相独立互不干扰。
  gRPC允许client提供额外的超时参数,在超时之后如果服务端还没有返回响应的话,则会返回DEADLINE_EXCEEDED错误。服务端可以查询请求的超时参数,以及该调用所剩余的完成时间值。
  RPC调用结果,是由服务端和客户端本地独立决定的,比如服务端认为自己成功发送了response,但是客户端可能在超时后仍然没有收到服务端响应,而认为此次调用失败,毕竟跨进程、跨主机的调用涉及到的可能问题会很多。
  客户端和服务端可以在任何时候取消(cancel)RPC调用,取消的请求会立即生效,之后的工作不会再执行,同时之前的工作也不会undo进行回滚。客户端如果使用同步模式调用,一般是无法取消调用的,因为其执行流已经被block阻塞住了。
  当创建Client Stub的时候,会要求和服务端的指定端口创建一个Channel通道,这个通道可以控制各项参数,以细致化地影响和控制gRPC的行为。
  从上面描述,可见gRPC不保证原子性、最终一致性等特性,这个锅看来是甩给了用户处理了!

二、Authentication认证

  gRPC/Protobuf原生支持SSL/TLS加密方式传输(Token模式暂不讨论),可以加密服务端和客户端的所有通信数据。
  gRPC的认证都围绕着Credentials这个对象,分为ChannelCredentials和CallCredentials两种类型,也可以将两者关联成一个CompositeChannelCredentials,然后用其产生一个新的ChannelCredentials后,那么之后在这个Channel上所有的调用都会默认使用前面设置的CallCredentials。
  (1). 无加密通信模式

1
2
auto channel = grpc::CreateChannel("localhost:50051", InsecureChannelCredentials());
std::unique_ptr<Greeter::Stub> stub(Greeter::NewStub(channel));

  (2). SSL/TSL通信

1
2
3
4
5
6
// Create a default SSL ChannelCredentials object.
auto creds = grpc::SslCredentials(grpc::SslCredentialsOptions());
// Create a channel using the credentials created in the previous step.
auto channel = grpc::CreateChannel(server_name, creds);
std::unique_ptr<Greeter::Stub> stub(Greeter::NewStub(channel));
grpc::Status s = stub->sayHello(&context, *request, response);

三、gRPC/Protobuf C++语言使用实例

  (1). 创建IDL描述文件route_guide.proto
  gRPC是需要先定义服务接口约定,才可以进行RPC调用,使用.proto可以同时定义客户端和服务端交换的数据格式以及RPC调用的接口,然后使用protoc工具加上特定语言的插件生成特定语言版本的辅助代码。其实相比之前的《Protobuf数据交换格式的使用方法》,这里只是新增了service定义和rpc定义的语法。

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
37
38
39
40
41
42
43
44
45
syntax = "proto3";
package routeguide;

service RouteGuide {
rpc GetFeature(Point) returns (Feature) {}
// server-to-client streaming RPC.
rpc ListFeatures(Rectangle) returns (stream Feature) {}
// client-to-server streaming RPC.
rpc RecordRoute(stream Point) returns (RouteSummary) {}
// Bidirectional streaming RPC.
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
}

// Latitudes +/- 90 degrees and longitude +/- 180 degrees (inclusive).
message Point {
int32 latitude = 1;
int32 longitude = 2;
}

// A latitude-longitude rectangle
message Rectangle {
Point lo = 1;
Point hi = 2;
}

// A feature names something at a given point.
// If a feature could not be named, the name is empty.
message Feature {
string name = 1; // The name of the feature.
Point location = 2;
}

// A RouteNote is a message sent while at a given point.
message RouteNote {
Point location = 1;
string message = 2;
}

// A RouteSummary is received in response to a RecordRoute rpc.
message RouteSummary {
int32 point_count = 1; // number of points received.
int32 feature_count = 2; // number of known features passed while traversing
int32 distance = 3; // distance covered in metres.
int32 elapsed_time = 4; // duration of the traversal
}

  (2). 使用protoc产生服务端和客户端代码
  通过protoc和C++ plugin,可以产生C++的服务端和客户端代码

1
2
$ protoc --grpc_out=. --plugin=protoc-gen-grpc=`which grpc_cpp_plugin` ../protos/route_guide.proto
$ protoc --cpp_out=. ../protos/route_guide.proto

  上面的操作会分别产生数据交换和服务接口的源文件:route_guide.pb.[cc|h]和route_guide.grpc.pb.[cc|h],前者覆盖所有数据类型序访问和操作的接口,后者主要生成定义的RPC服务RouteGuide的相关代码。
  grpc_out参数编译之后的源代码,主要产生了class RouteGuide,包含了和客户端Stub相关的内部类class StubInterface和class Stub GRPC_FINAL : public StubInterface,以及和服务端相关的class Service : public ::grpc::Service类。
  (3). 实现服务端业务接口
  通过上面步骤,操作数据和服务接口相关代码都已经自动生成了,接下来服务端的重点就是接口业务逻辑的实现了。手册样例的服务端代码实现在route_guide_server.cc源文件中,通过实现RouteGuide::Service中定义的虚函数接口,可以实现以同步阻塞方式的服务端实现(class RouteGuideImpl final : public RouteGuide::Service),而异步方式则跟RouteGuide::AsyncService这个类相关。
  此处服务端首先需要实现proto service中定义的四个调用接口,通过观察这些虚函数接口,发现他们都是返回::grpc::Status类型(返回值的详细信息可以参看Error model),并且第一个参数都是::grpc::ServerContext*,而剩余部分的参数就跟当初proto文件中声明的参数一致了。在函数的具体实现代码中,设置和获取proto的数据项,就是Protobuf的标准数据操作方式了。通常操作成功后,返回Status::OK。

1
2
3
4
5
6
Status GetFeature(ServerContext* context, const Point* point,
Feature* feature) override {
feature->set_name(GetFeatureName(*point, feature_list_));
feature->mutable_location()->CopyFrom(*point);
return Status::OK;
}

  上面代码是最简单的Unary调用方式,客户端发出一个请求参数,然后服务端返回一个数据响应。对于RPC的服务端,在使用stream模式的调用参数或者返回结果,需要使用到特殊的ServerWriter、ServerReader类型,服务端可以在循环中多次写入/读取以传递多个对象,最后返回Status状态以表示调用结束。

1
2
3
4
5
for (const Feature& f : feature_list_) {
... writer->Write(f);
}

while (reader->Read(&point)) { ... }

  对于请求和响应都是stream的类型,那么参数将直接变成为ServerReaderWriter* stream类型,此时的stream是一个两方向完全独立的全双工信道。

1
2
3
4
5
6
7
while (stream->Read(&note)) {
for (const RouteNote& n : received_notes) {
if ( ... )
stream->Write(n);
}
received_notes.push_back(note);
}

  当把RouteGuide::Service中的虚函数接口全部实现后,服务端的业务开发也就完成了。下面是通用的服务端网络例程,绑定地址端口,接收客户端请求,十分的清晰明白:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void RunServer(const std::string& db_path) {
std::string server_address("0.0.0.0:50051");
RouteGuideImpl service(db_path);

ServerBuilder builder;
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
builder.RegisterService(&service);
std::unique_ptr<Server> server(builder.BuildAndStart());
std::cout << "Server listening on " << server_address << std::endl;
server->Wait(); // until killed or server->Shutdown()
}

int main(int argc, char** argv) {
std::string db = routeguide::GetDbFileContent(argc, argv);
RunServer(db);
return 0;
}

  (4). 创建客户端
  客户端的业务代码定义在route_guide_client.cc源文件中。在RPC的调用体系中,业务相关的代码都已经实现在服务端,所以通常来说客户端会定义同服务端接口相同的函数名(非必需),然后在这些函数实现中,完成对服务端的RPC调用,并获取调用返回的结果。
  客户端在初始化的时候,需要首先创建grpc::Channel和RouteGuide::Stub两个对象。

1
2
3
std::shared_ptr<Channel> channel = grpc::CreateChannel("localhost:50051",
grpc::InsecureChannelCredentials();
std::unique_ptr<RouteGuide::Stub> stub_ = RouteGuide::NewStub(channel);

  上面是用的简单非加密方式创建的Channel。然后,客户端通过stub_对象就可以直接进行RPC调用了

1
2
3
4
5
6
7
8
9
Point point = MakePoint(409146138, -746188906);
Feature feature;
GetOneFeature(point, &feature);

bool GetOneFeature(const Point& point, Feature* feature) {
ClientContext context;
Status status = stub_->GetFeature(&context, point, feature);
...
}

  可见,每次调用都需要传入一个context对象的地址,上面是进行的默认构造,可以通过对这个context对象设置RPC调用相关的细节参数(比如超时等)。因为在不同的RPC调用之间不能共享这个对象,所以其一般都是以局部自动对象的方式创建的。
  上面的Unary调用是最简单的情况。对于stream类型的调用,客户端同样有与服务端相似的ClientReader、ClientWriter以及ClientReaderWriter对象来完成相关操作。这些对象可以调用Finish()来获取服务端返回来的RPC调用状态,而WritesDone()可以显式通知对端写入完成,特别适合client-to-server streaming RPC类型的调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
while (reader->Read(&feature)) { ... }
Status status = reader->Finish();

std::unique_ptr<ClientWriter<Point> > writer(
stub_->RecordRoute(&context, &stats));
for (int i = 0; i < kPoints; i++) {
if (!writer->Write(f.location())) {
break; // Broken stream.
}
std::this_thread::sleep_for(std::chrono::milliseconds(
delay_distribution(generator)));
}
writer->WritesDone();
Status status = writer->Finish();
if (status.IsOk()) { ... }

  对于C++语言,gRPC/Protobuf还原生支持通过CompletionQueue实现异步模式(Asynchronous)工作。但因为手册不是很详细,此处先不讨论。

本文完!

参考