简单的RPC框架设计和实现

简介

YA-RPC框架(下称本框架)是一个采用C++17编写的简易RPC框架。本框架支持integer,float,string,bool等数据类型,并支持根据服务定义生成相应的RPC服务端代码。

概要设计

本框架主要分RPC服务端和代码生成器两个部分,其中前者提供RPC服务的基础设施,后者根据开发者定义的服务来生成相应的服务代码供给服务端使用。下图展示了本框架的顶层设计:

image

服务定义由库使用者自己编写,类似于GRPC的proto文件,为了简便起见,这里使用了较为通用和易于读写的json格式。代码生成器通过读取服务定义来生成服务代码文件,服务代码文件会预留空白的函数体给库使用者编写具体的服务。库使用者可以在自定义的RPC服务端中添加由代码生成器的服务。

为了便于服务端和客户端之间的消息传递,这里直接使用了websocket来作为消息传输的底层协议。websocket是定义在http协议上层的自带连接的可靠的应用层协议,可以在一定程度上保证消息能够顺利地在客户端和服务端之间传输而不容易丢失。

为了使用RPC服务端提供的服务,客户端需要首先和RPC服务端建立连接,然后发送特定格式的包含必要信息的消息给服务端,服务端会调用服务并把结果传送给客户端。这样便完成了一次RPC调用。

本文编译运行和测试相关环境和使用第三方库如下表所示:

项目 备注
编程语言 C++17
操作系统 Linux 64位操作系统
第三方库 uWebsockets, nlohmann::json 均为github的开源仓库
其它辅助语言 shellscript,python 用于辅助测试
整个框架的详细设计见下一节。

详细设计

服务和消息

一个RPC服务端应当支持多种服务,每个服务应当支持一种或多种方法,每种方法都有自己的参数列表和返回值类型。因此这里采用如下的json格式来定义一个服务:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "String",
"methods": [
{
"name": "Uppercase",
"params": {
"str": "string"
},
"return": 1
}
]
}

其中name定义了服务的名字,method定义了该服务提供的所有方法(函数)列表。列表中的每一项就是一个具体的函数。这样在抽象的层面上,客户端就可以用类似rpc-service.method(args...)的方法来唯一定位自己要调用的服务。因此这里顺便给出客户端的消息(也就是RPC请求)格式:

1
2
3
4
5
6
7
8
{
"client": "127.0.0.1",
"service": "String",
"method": "Uppercase",
"params":{
"str": "Hello World"
}
}

其中client是客户端标识,用于给服务端做身份验证和日志记录等工作,本框架为方便期间未使用该字段。servicemethod定义了客户端想要使用的服务,params给出了该服务所需的参数列表。

服务端设计

服务端主要有websocket服务端、工作线程列表、服务列表这三个部分。RPC服务端运行时会监听特定端口,在收到该请求后会对参数进校验和验证,如果失败会直接给客户端发送一个失败的响应,如果成功会尝试调用对应的服务,并把结果封装在成功响应中并发送给客户端,下图展示了服务端的工作流程:

image

由于服务端有多个线程,因此其在调用服务的时候不会阻塞IO(在请求不是十分繁忙的时候),下图展示了响应消息的格式:

1
2
3
4
{
"code": 0,
"result": "HELLO WORLD"
}

code表示响应的类型,为0的时候表示服务调用成功,结果在result中,不为0的时候表示RPC调用失败,不同的code值代表了不同的错误原因:

状态码 信息
0 成功
1 消息格式错误
2 未找到服务
3 未找到方法
4 身份校验失败
5 服务调用超时

代码生成器

由于语言和语义上的原因,RPC无法做到通用的运行时服务生成的功能(某些支持反射的语言或者某些脚本语言除外)。因此需要预先使用第三方程序根据定义来生成代码。对于每个名为Sample的服务都会生成SampleService.h,SampleService.cppSampleServiceStub.h三个文件。其中SampleServiceStub是提供给RPC服务端的接口。一个典型的SmapleServiceStub文件如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class StringServiceStub : public RpcServiceStubInterface {
public:
explicit StringServiceStub() : RpcServiceStubInterface("String") {
this->RegisterMethods();
}

void UppercaseStub(const json &params, RpcResponse &response) {
try {
std::string str = params["str"].get<std::string>();
auto c = stringService.Len(str);
response.result["result"] = c;
} catch (std::exception &e) {
response.result["result"] = "";
}
}

void RegisterMethods() {
//...
}
private:
StringService stringService;
};

服务中每个具体的方法Method都有一个对应的MethodStub方法,该方法负责将json格式的参数列表解析成对应参数类型的参数列表,由于这部分无法运行时完成,因此也是使用代码生成最主要的原因。在完成参数解析后会将参数传递给由SampleService.h中定义的实际服务。SampleService.hSampleService.cpp中的内容如下所示:

1
2
3
4
5
6
//SampleSerivce.h
class StringService {
public:
int Len(std::string str);
};

1
2
3
4
5
6
//sampleService.cpp
#include <string>
#include "StringService.h"
int StringService::Len(std::string str) {
//供库使用者填写
}

其中CPP文件是空白的函数体,供给库使用者填写具体的服务实现。框架还编写了一个安装脚本install.sh,在管理员权限下可将其安装到系统目录,方便测试使用。

测试和分析

服务端

本章节使用单精度浮点类型的求和函数和字符串的转大写服务来进行测试。这里定义前者在Arithmetic服务中,而后者定义在String服务中,两个服务的定义文件如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"version": 0,
"name": "String",
"methods": [
{
"name": "Uppercase",
"params": {
"str": "Abc"
},
"return": "abc"
}
]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"version": 0,
"name": "Arithmetic",
"methods": [
{
"name": "Sum",
"params": {
"a": 1.0,
"b": 1.0
},
"return": 1.0
}
]
}

并在这两个json所在的目录下运行如下的命令来生成每个服务的源码文件:

1
/opt/rpcgen . Test

其中rpcgen即为本框架的代码生成器,第二个参数.表示查找当前目录下的定义文件,Test表示该RPC服务器的名称。

然后定义TescRpcServer继承自本框架提供的RPCServer基类并添加上方生成的两个服务:

1
2
3
4
5
6
7
class TestRPCServer : public RpcServer {
public:
TestRPCServer(size_t t, uint16_t port) : RpcServer(t, port) {
this->AddService(new ArithmeticServiceStub());
this->AddService(new StringServiceStub());
}
};

客户端

测试的客户端使用由python编写的简单WS客户端做测试,客户端会随机生成测试用例并在本地计算结果,然后将结果和服务端返回的结果做比对,如果二者结果相同就认为该测试用例测试通过。为了测试并发性,这里同时运行4个客户端来向RPC服务端发消息。

测试结果

下方选取了部分测试结果(客户端输出):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
=========SUM TEST==========
[112.25968170166016 == 112.25967970499761] ==> Passed
[46.3238639831543 == 46.3238626744408] ==> Passed
[77.39429473876953 == 77.39429371831518] ==> Passed
[82.80653381347656 == 82.80652726251887] ==> Passed
...
All sum test passed
======UPPERCASE TEST=======
[BI0QPGTR == BI0QPGTR] ==> Passed
[XDUENYVR == XDUENYVR] ==> Passed
[M9PBLKHS == M9PBLKHS] ==> Passed
[U8N4KFAK == U8N4KFAK] ==> Passed
...
All sum test passed

测试结果显示所有的测试通过。

总结和展望

总结

本文借用了第三方的websocketjson库实现了简单的RPC框架,并使用多个客户端通过了测试。

展望

本次实现的RPC框架还非常简陋,还有很大的改进空间,如:

  1. 不支持自定义的数据类型或者结构体
  2. 在可靠性语义上没有做到明确的区分和定义
  3. 在TCP的基础上做消息传递
  4. 其它等等