RPC 同步调用基本使用方法:基于官方 RouteGuide 示例
一、RPC 和 gRPC 的基本理解
1. RPC 要解决什么问题
RPC 的全称是 Remote Procedure Call,也就是远程过程调用。它要解决的问题很直接:一个进程中的代码,想调用另一个进程、甚至另一台机器上的函数,能不能像调用本地函数一样简单?如果是普通网络编程,客户端要自己组织请求报文,服务端要自己解析请求、分发接口、封装响应;而 RPC 框架把这些过程封装起来,让调用方看到的是一个"函数调用",底层实际完成的是序列化、网络传输、反序列化和远程执行
单机函数调用只需要知道函数名、参数和返回值,而远程调用还必须解决三个问题:
- 双方要提前约定调用语义,也就是有哪些接口、每个接口的参数和返回值是什么
- 请求和响应要通过网络传输
- 传输内容要有统一的数据格式。到了分布式场景,还会继续引出服务注册发现、负载均衡、故障处理、超时控制等问题
gRPC 就是一个围绕这些问题设计的 RPC 框架,它基于服务的思想来组织接口,默认使用 Protobuf 作为接口描述语言和数据序列化方式,底层基于 HTTP/2 传输
2. gRPC 的基本模型
gRPC 的开发流程可以简单概括为:先写 .proto 文件,在里面定义 message 和 service;然后用 protoc 生成对应语言的代码;服务端继承生成出来的 Service 类并实现 RPC 方法;客户端通过生成出来的 Stub 对象调用远程服务。这里有几个概念要分清楚:
Service是服务定义,描述服务端提供哪些 RPCRPC是一次远程调用动作Channel是客户端到服务端的通信通道Stub是客户端代理对象,真正发起 RPC 请求的是它ServerBuilder用来创建服务端,配置监听地址、注册服务实现,然后构建并启动Server
gRPC 支持同步和异步两种调用方式。同步调用的特点是代码结构简单,客户端发起调用后会阻塞等待服务端处理完成,拿到响应和状态之后再继续往下执行;异步调用不会一直卡在调用处,而是借助 CompletionQueue、回调或者其他异步机制在请求完成后再处理结果。本文主要讲同步调用
3. Protobuf 和 HTTP/2 的作用
RPC 调用本质上还是网络通信,所以一定要解决"传什么"和"怎么传"的问题。早期很多 RPC 或 HTTP API 会用 JSON 作为数据格式,JSON 的优点是可读性强、跨语言方便,但是字段名、符号、字符串等内容会带来额外体积,复杂类型表达能力和传输效率也不如二进制协议。gRPC 默认使用 Protobuf,.proto 文件中每个字段都有明确类型、字段名和编号,能够表达普通字段、数组、嵌套对象、map 等结构;生成 C++ 代码后,message 会变成 C++ 类,对象可以被序列化成二进制,也可以从二进制反序列化回来
Protobuf参考:Protobuf使用详解
HTTP/2 主要解决传输效率问题。HTTP/1.x 在同一个连接上很难高效区分多个并发请求和响应,容易出现串行排队。HTTP/2 引入了 流 和 帧 的概念,一个请求对应一个流,每个流有自己的 ID,请求和响应数据可以拆成多个帧在同一个 TCP 连接中交错传输,接收方再根据流 ID 组装回对应请求。gRPC 借助 HTTP/2 的多路复用、二进制帧和流控制能力,可以比较自然地支持一元 RPC、服务端流、客户端流和双向流
4. gRPC 的四种 RPC 模式
gRPC 一共支持四种常见调用模式:一元 RPC、服务端流式 RPC、客户端流式 RPC、双向流式 RPC:
- 一元 RPC 最像普通函数调用,客户端发送一个请求,服务端返回一个响应
- 服务端流式 RPC 是客户端发送一个请求,服务端返回一串响应,客户端不断从流里读取结果
- 客户端流式 RPC 是客户端连续发送多个请求,服务端最后返回一个响应
- 双向流式 RPC 是客户端和服务端都持有一个流,双方都可以按自己的逻辑读写消息
从同步 C++ API 的角度看,可以这样记:
- 一元 RPC 直接通过
stub_->RpcName(&context, request, &response)调用 - 服务端流是客户端拿到
ClientReader<T>,服务端使用ServerWriter<T> - 客户端流是客户端拿到
ClientWriter<T>,服务端使用ServerReader<T> - 双向流是客户端使用
ClientReaderWriter<Req, Resp>,服务端使用ServerReaderWriter<Req, Resp>
读流时一般使用 Read(),写流时一般使用 Write(),客户端写完流后使用 WritesDone() 表示不再发送,最后通过 Finish() 获取 RPC 最终状态
二、同步 gRPC 的基本使用流程
1. 编写 proto 文件,定义消息和服务
同步 gRPC 的第一步是写 .proto 文件。.proto 文件不是普通 C++ 代码,而是接口描述文件,它负责描述传输的数据结构和远程调用接口。例如一个最简单的服务可以写成 rpc SayHello(HelloRequest) returns (HelloReply),它表示客户端发送一个 HelloRequest,服务端返回一个 HelloReply。在 C++ 中不能直接编译 .proto 文件,需要通过 protoc 生成 .pb.h/.pb.cc 和 .grpc.pb.h/.grpc.pb.cc,前者主要对应 Protobuf 消息类,后者主要对应 gRPC 服务类和 Stub 类
在 RouteGuide 项目中,route_guide.proto 定义了一个 RouteGuide 服务,里面有四个 RPC 接口,正好覆盖四种 RPC 模式:
proto
service RouteGuide {
rpc GetFeature(Point) returns (Feature) {}
rpc ListFeatures(Rectangle) returns (stream Feature) {}
rpc RecordRoute(stream Point) returns (RouteSummary) {}
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
}
这四行就是整个项目的接口核心
GetFeature是普通一元 RPCListFeatures是服务端流式 RPCRecordRoute是客户端流式 RPCRouteChat是双向流式 RPC
gRPC 的好处就在这里,接口形式在 .proto 中已经写死了,客户端和服务端都根据同一份 proto 生成代码,不需要双方手动约定 URL、请求体格式、响应体格式
2. 服务端继承 Service,实现 RPC 方法
生成代码后,服务端要继承 RouteGuide::Service,然后重写 proto 中定义的 RPC 方法。同步服务端的代码结构比较像普通 C++ 多态:父类由 gRPC 根据 proto 生成,子类由自己写业务逻辑
cpp
class RouteGuideImpl final : public RouteGuide::Service {
public:
explicit RouteGuideImpl(const std::string& db) {
routeguide::ParseDb(db, &feature_list_);
}
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;
}
private:
std::vector<Feature> feature_list_;
};
这里的 ServerContext* context 表示一次服务端 RPC 调用的上下文,里面可以携带元数据、认证信息、取消状态等;const Point* point 是客户端传来的请求;Feature* feature 是服务端要填写的响应对象;返回值 Status 表示 RPC 本身是否成功。同步一元 RPC 的实现方式很直观:读取请求对象,在响应对象里写入数据,然后返回 Status::OK
3. 服务端启动 Server,注册服务对象
RPC 方法实现好之后,还需要启动 gRPC 服务端。同步服务端一般使用 ServerBuilder:先指定监听地址,再注册服务实现对象,最后构建并启动 Server
cpp
void RunServer(const std::string& db_path) {
// 0.0.0.0:50051 表示服务端监听本机所有网卡的 50051 端口
std::string server_address("0.0.0.0:50051");
RouteGuideImpl service(db_path);
ServerBuilder builder;
// InsecureServerCredentials() 表示使用明文通信,不启用 TLS
// 学习和本地测试可以这样写,生产环境一般不建议
builder.AddListeningPort(server_address, grpc::InsecureServerCredentials());
// RegisterService(&service) 把具体服务实现注册到 gRPC Server 中
builder.RegisterService(&service);
std::unique_ptr<Server> server(builder.BuildAndStart());
std::cout << "Server listening on " << server_address << std::endl;
// server->Wait() 会阻塞当前线程,让服务端持续等待客户端请求
server->Wait();
}
4. 客户端创建 Channel 和 Stub,发起同步调用
客户端要先创建 Channel,再基于 Channel 创建 Stub。Channel 表示到某个服务端地址的通信通道,Stub 是客户端代理对象,业务代码通过 Stub 调用远程 RPC
cpp
RouteGuideClient(std::shared_ptr<Channel> channel, const std::string& db)
: stub_(RouteGuide::NewStub(channel)) {
routeguide::ParseDb(db, &feature_list_);
}
客户端主函数中创建连接的方式如下:
cpp
RouteGuideClient guide(
grpc::CreateChannel("localhost:50051",
grpc::InsecureChannelCredentials()),
db);
这里的 localhost:50051 要和服务端监听地址对应。InsecureChannelCredentials() 表示客户端也使用明文连接。创建好 guide 后,客户端依次调用 guide.GetFeature()、guide.ListFeatures()、guide.RecordRoute()、guide.RouteChat(),也就是依次演示四种同步 RPC 的使用方式
三、RouteGuide 项目整体结构
1. 这个项目的业务含义
RouteGuide 是 gRPC 官方示例中的路线导航项目,可以把它理解成一个简化版地图服务。它里面有几个核心数据结构:Point 表示经纬度点,Rectangle 表示一个矩形区域,Feature 表示某个坐标上的地点信息,RouteSummary 表示路线统计结果,RouteNote 表示某个坐标上的留言。客户端可以查询某个点有没有景点,也可以查询某个矩形区域内有哪些景点,还可以模拟走过一段路线,让服务端统计经过的点数、景点数、总距离和耗时,最后还可以在某个坐标发送留言并接收同一位置已有留言
Point 使用的是 E7 表示法,也就是把真实经纬度乘以 10000000 后存成整数,例如 409146138 实际表示 40.9146138。这样比直接用浮点数传输更稳定,也更适合 Protobuf 中的整数编码
proto
message Point {
int32 latitude = 1;
int32 longitude = 2;
}
message Feature {
string name = 1;
Point location = 2;
}
2. proto 文件决定客户端和服务端的代码形态
RouteGuide 的 proto 文件非常适合用来理解 gRPC,因为四个接口刚好对应四种同步调用模式。对于服务端来说,生成代码后需要实现的函数形态大概是这样:
cpp
// 一元 RPC
Status GetFeature(ServerContext* context, const Point* point, Feature* feature);
// 服务端流式 RPC
Status ListFeatures(ServerContext* context,
const Rectangle* rectangle,
ServerWriter<Feature>* writer);
// 客户端流式 RPC
Status RecordRoute(ServerContext* context,
ServerReader<Point>* reader,
RouteSummary* summary);
// 双向流式 RPC
Status RouteChat(ServerContext* context,
ServerReaderWriter<RouteNote, RouteNote>* stream);
对于客户端来说,对应的同步调用方式大概是这样:
cpp
// 一元 RPC
Status status = stub_->GetFeature(&context, point, feature);
// 服务端流式 RPC
std::unique_ptr<ClientReader<Feature>> reader(
stub_->ListFeatures(&context, rect));
// 客户端流式 RPC
std::unique_ptr<ClientWriter<Point>> writer(
stub_->RecordRoute(&context, &stats));
// 双向流式 RPC
std::shared_ptr<ClientReaderWriter<RouteNote, RouteNote>> stream(
stub_->RouteChat(&context));
所以学习这个项目时,重点不是地图业务,而是看清楚 proto 写法、服务端函数参数、客户端调用对象 三者之间的对应关系。只要这个关系理顺了,以后自己写 gRPC 项目就是换一批 message 和 service,本质流程是一样的
四、一元 RPC:GetFeature
1. 接口定义
GetFeature 是最简单的同步 RPC,它的 proto 定义是:
proto
rpc GetFeature(Point) returns (Feature) {}
意思是客户端发送一个 Point,服务端返回一个 Feature。这个过程和普通函数调用最像,只不过普通函数调用发生在本进程内,而 RPC 调用会经过 Stub、序列化、网络传输、服务端反序列化、执行业务、再序列化返回等过程
2. 服务端实现
服务端实现如下:
cpp
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;
}
这里服务端先用 GetFeatureName(*point, feature_list_) 在内存中的地点列表里查找当前坐标对应的名字,然后把名字写入 feature,再把客户端传来的坐标原样拷贝到 feature->location 中。mutable_location() 用来拿到内部嵌套对象的可修改指针,CopyFrom() 用来把一个 Point 拷贝进去。最后返回 Status::OK,表示这次 RPC 正常完成
辅助函数 GetFeatureName 本质上就是遍历查找:
cpp
std::string GetFeatureName(const Point& point,
const std::vector<Feature>& feature_list) {
for (const Feature& f : feature_list) {
if (f.location().latitude() == point.latitude() &&
f.location().longitude() == point.longitude()) {
return f.name();
}
}
return "";
}
3. 客户端调用
客户端真正发起 RPC 的地方是:
cpp
bool GetOneFeature(const Point& point, Feature* feature) {
ClientContext context;
// 调用服务端
Status status = stub_->GetFeature(&context, point, feature);
// 后面都是对返回结果的判断输出
if (!status.ok()) {
std::cout << "GetFeature rpc failed." << std::endl;
return false;
}
if (feature->name().empty()) {
std::cout << "Found no feature at "
<< feature->location().latitude() / kCoordFactor_ << ", "
<< feature->location().longitude() / kCoordFactor_ << std::endl;
} else {
std::cout << "Found feature called " << feature->name() << " at "
<< feature->location().latitude() / kCoordFactor_ << ", "
<< feature->location().longitude() / kCoordFactor_ << std::endl;
}
return true;
}
同步调用最明显的特点就在 Status status = stub_->GetFeature(&context, point, feature); 这一行:调用发出后,当前线程会等待服务端返回;返回后,feature 中已经有服务端填好的响应内容,status 中保存 RPC 状态。业务是否查到地点,不一定等于 RPC 是否成功。比如服务端正常返回一个空名字的 Feature,说明 RPC 是成功的,只是该坐标没有景点;只有 status.ok() 为 false,才表示 RPC 调用本身失败
五、服务端流式 RPC:ListFeatures
1. 接口定义
ListFeatures 的 proto 定义是:
proto
rpc ListFeatures(Rectangle) returns (stream Feature) {}
它表示客户端发送一个矩形区域 Rectangle,服务端返回多个 Feature。这种模式适合"一个请求对应很多结果"的场景,比如查询某个区域内的所有景点。如果用普通一元 RPC,服务端可能需要一次性构造一个很大的数组返回;而服务端流式 RPC 可以边查边写,客户端边读边处理
2. 服务端实现
服务端实现如下:
cpp
Status ListFeatures(ServerContext* context,
const routeguide::Rectangle* rectangle,
ServerWriter<Feature>* writer) override {
auto lo = rectangle->lo();
auto hi = rectangle->hi();
long left = (std::min)(lo.longitude(), hi.longitude());
long right = (std::max)(lo.longitude(), hi.longitude());
long top = (std::max)(lo.latitude(), hi.latitude());
long bottom = (std::min)(lo.latitude(), hi.latitude());
for (const Feature& f : feature_list_) {
if (f.location().longitude() >= left &&
f.location().longitude() <= right &&
f.location().latitude() >= bottom &&
f.location().latitude() <= top) {
writer->Write(f);
}
}
return Status::OK;
}
这里 ServerWriter<Feature>* writer 是关键。服务端不再通过一个 Feature* response 返回单个响应,而是通过 writer->Write(f) 连续写多个响应。每调用一次 Write,客户端那边就有机会通过 Read 读到一个 Feature。当循环结束并返回 Status::OK 后,服务端流就结束了
3. 客户端调用
客户端调用如下:
cpp
std::unique_ptr<ClientReader<Feature> > reader(
stub_->ListFeatures(&context, rect));
while (reader->Read(&feature)) {
std::cout << "Found feature called " << feature.name() << " at "
<< feature.location().latitude() / kCoordFactor_ << ", "
<< feature.location().longitude() / kCoordFactor_ << std::endl;
}
Status status = reader->Finish();
客户端拿到的是 ClientReader<Feature>,它负责从服务端返回流中读取消息。reader->Read(&feature) 每成功一次,就说明读到一个服务端写回来的 Feature;当服务端没有更多数据时,Read 返回 false,循环结束;最后调用 Finish() 获取这次 RPC 的最终状态。这里要注意,Read() 返回 false 不一定表示出错,它也可能只是正常读完了;是否真正失败要看最后的 Status
六、客户端流式 RPC:RecordRoute
1. 接口定义
RecordRoute 的 proto 定义是:
proto
rpc RecordRoute(stream Point) returns (RouteSummary) {}
它表示客户端连续发送多个 Point,服务端最后返回一个 RouteSummary。这个接口模拟的是用户走过一段路线,客户端不断上传经过的坐标点,服务端统计总点数、经过的景点数量、路线距离和耗时
2. 服务端实现
服务端实现如下:
cpp
Status RecordRoute(ServerContext* context, ServerReader<Point>* reader,
RouteSummary* summary) override {
Point point;
int point_count = 0;
int feature_count = 0;
float distance = 0.0;
Point previous;
system_clock::time_point start_time = system_clock::now();
while (reader->Read(&point)) {
point_count++;
if (!GetFeatureName(point, feature_list_).empty()) {
feature_count++;
}
if (point_count != 1) {
distance += GetDistance(previous, point);
}
previous = point;
}
system_clock::time_point end_time = system_clock::now();
summary->set_point_count(point_count);
summary->set_feature_count(feature_count);
summary->set_distance(static_cast<long>(distance));
auto secs =
std::chrono::duration_cast<std::chrono::seconds>(end_time - start_time);
summary->set_elapsed_time(secs.count());
return Status::OK;
}
这里服务端参数是 ServerReader<Point>* reader,说明服务端要从客户端发送的流里不断读 Point。while (reader->Read(&point)) 会一直读取,直到客户端发送完并关闭写方向。循环过程中,服务端统计点数,判断该点是不是已知景点,并用 GetDistance(previous, point) 累加两点之间的距离。等客户端流结束后,服务端再填写 summary 并返回
3. 客户端调用
客户端调用如下:
cpp
std::unique_ptr<ClientWriter<Point> > writer(
stub_->RecordRoute(&context, &stats));
for (int i = 0; i < kPoints; i++) {
const Feature& f = feature_list_[feature_distribution(generator)];
std::cout << "Visiting point " << f.location().latitude() / kCoordFactor_
<< ", " << f.location().longitude() / kCoordFactor_
<< std::endl;
if (!writer->Write(f.location())) {
break;
}
std::this_thread::sleep_for(
std::chrono::milliseconds(delay_distribution(generator)));
}
writer->WritesDone();
Status status = writer->Finish();
客户端拿到的是 ClientWriter<Point>,通过 writer->Write(f.location()) 不断向服务端发送点。发送完所有点后,必须调用 writer->WritesDone(),它的意思是"客户端写方向结束,不再继续发送 Point"。如果不调用它,服务端的 reader->Read(&point) 可能一直等不到流结束,自然也就没法计算最终 summary。最后 writer->Finish() 会等待服务端返回最终状态,同时 stats 中已经被写入服务端返回的统计结果
这个例子很好地体现了客户端流的典型使用场景:请求数据不是一个对象,而是一组连续产生的数据;服务端需要等数据收完后做汇总,然后返回一个结果
七、双向流式 RPC:RouteChat
1. 接口定义
RouteChat 的 proto 定义是:
proto
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
它表示客户端发送的是一个 RouteNote 流,服务端返回的也是一个 RouteNote 流。双向流最灵活,客户端和服务端都有读写能力,具体什么时候读、什么时候写,由双方业务逻辑决定。这个例子里,客户端发送一些位置留言;服务端每收到一条留言,就查找之前同一位置是否已有留言,如果有,就把历史留言写回给客户端
2. 服务端实现
服务端实现如下:
cpp
Status RouteChat(ServerContext* context,
ServerReaderWriter<RouteNote, RouteNote>* stream) override {
RouteNote note;
while (stream->Read(¬e)) {
std::unique_lock<std::mutex> lock(mu_);
for (const RouteNote& n : received_notes_) {
if (n.location().latitude() == note.location().latitude() &&
n.location().longitude() == note.location().longitude()) {
stream->Write(n);
}
}
received_notes_.push_back(note);
}
return Status::OK;
}
ServerReaderWriter<RouteNote, RouteNote>* stream 同时具备读和写的能力。服务端通过 stream->Read(¬e) 读取客户端发来的留言,通过 stream->Write(n) 把历史留言写回客户端。这里用了 std::mutex,因为 received_notes_ 是服务对象中的共享成员,多个客户端同时调用 RouteChat 时可能并发访问这个 vector,所以需要加锁保护
不过这个示例也有一个可以改进的点:它在持有锁的情况下调用了 stream->Write(n),而写网络流可能阻塞。学习 demo 中这样写问题不大,但生产代码中一般会尽量避免持锁执行可能阻塞的 I/O 操作,可以先把要返回的消息拷贝到临时 vector,释放锁之后再写回客户端
3. 客户端调用
客户端调用如下:
cpp
std::shared_ptr<ClientReaderWriter<RouteNote, RouteNote> > stream(
stub_->RouteChat(&context));
std::thread writer([stream]() {
std::vector<RouteNote> notes{MakeRouteNote("First message", 0, 0),
MakeRouteNote("Second message", 0, 1),
MakeRouteNote("Third message", 1, 0),
MakeRouteNote("Fourth message", 0, 0)};
for (const RouteNote& note : notes) {
std::cout << "Sending message " << note.message() << " at "
<< note.location().latitude() << ", "
<< note.location().longitude() << std::endl;
stream->Write(note);
}
stream->WritesDone();
});
RouteNote server_note;
while (stream->Read(&server_note)) {
std::cout << "Got message " << server_note.message() << " at "
<< server_note.location().latitude() << ", "
<< server_note.location().longitude() << std::endl;
}
writer.join();
Status status = stream->Finish();
客户端使用 ClientReaderWriter<RouteNote, RouteNote>,它既能 Write,也能 Read。示例中专门创建了一个写线程负责发送四条留言,主线程则不断读取服务端返回的历史留言。第一条留言是 (0,0),第四条留言也是 (0,0),所以当服务端收到第四条留言时,会发现之前同一位置已经有 First message,于是把它写回客户端
双向流这里要特别注意结束过程。客户端写完后调用 WritesDone() 表示客户端不再发送消息;主线程中的 Read() 会一直读服务端响应,直到服务端结束响应流;最后调用 Finish() 获取 RPC 最终状态。也就是说,流不是无限长连接,流也会结束。客户端发送流通过 WritesDone() 结束,服务端发送流一般通过 RPC 函数返回 Status 来结束,接收方通过 Read() 返回 false 感知流结束
八、同步调用中几个容易混淆的点
1. Stub 和 Service 的区别
Service 是服务端用的,Stub 是客户端用的。RouteGuide::Service 是 gRPC 根据 proto 生成的服务端基类,服务端继承它并重写 RPC 方法;RouteGuide::Stub 是客户端代理对象,客户端通过它发起远程调用。可以简单记成一句话:服务端实现 Service,客户端调用 Stub
2. ClientContext 和 ServerContext 的区别
ClientContext 是客户端一次 RPC 的上下文,客户端每发起一次 RPC 通常都要创建一个新的 ClientContext;ServerContext 是服务端处理一次 RPC 时拿到的上下文。它们可以携带 metadata、deadline、取消状态、认证相关信息等。这个 RouteGuide 示例里没有深入使用它们,但实际项目中设置超时、传 token、读取客户端信息时经常会用到
3. Status 表示 RPC 状态,不一定等于业务状态
Status 表示 RPC 调用本身是否正常完成,比如网络是否正常、服务端是否返回错误码等。业务上的"没查到数据"不一定是 RPC 失败。例如 GetFeature 中,如果某个点没有景点,服务端返回一个 name 为空的 Feature,这仍然可以是 Status::OK。所以写客户端代码时,一般先判断 status.ok(),再判断业务字段
4. Reader、Writer 的方向要站在当前端理解
服务端流式 RPC 中,服务端写、客户端读,所以是 ServerWriter 对应 ClientReader;客户端流式 RPC 中,客户端写、服务端读,所以是 ClientWriter 对应 ServerReader;双向流式 RPC 中,两边都读写,所以都是 ReaderWriter。不要死记类型名,要先判断"谁发多个消息,谁收多个消息"