DDS通信中间件——RPC(函数调用模式)
做了十年DDS通信中间件产品的程序员和大家分享一下对DDS这套规范的个人理解。预期本系列文章将包括以下内容陆续更新:
- DDS规范概述
- DCPS规范解读 & QoS策略
- XTypes规范解读
- RTPS规范解读
- DDS安全规范解读
- DDS-RPC规范解读( 请求响应模式 & 函数调用模式 )
- DDS-TSN规范解读
- DDS-XRCE规范解读
1. 概述
1.1. RPC是什么?
RPC是Remote Procedure Call(远程过程调用)的缩写,RPC是一种通信模式,常见的通信模式如下图所示,RPC区别于其他通信模式的特点在于:RPC是双向通信,即通信双方均需要发送和接收数据,图中的请求响应模式和远程过程调用在广义上都可以归类为RPC通信模式,两者的主要区别在于:
- 请求响应模式还是比较低层次的数据的概念,请求端发送请求数据、响应端发送响应数据,不抽象“功能”,优点是实现简单,适用于需要接收端发送响应的场景,缺点是使用起来比较繁琐,例如下面的计算服务Calculater,一种方法是将每个方法看成一个服务,需要定义包含方法入参的结构体作为请求数据,定义包含返回值类型成员的结构提作为响应数据,另一种方式是将Calculater作为一个服务,定义联合体并根据不同的方法名称定义不同关联的数据类型来定义请求类型以及响应类型,这两种方式在包含多个服务或者服务包含的方法非常复杂的场景下需要定义的数据结构会非常复杂;
- 远程函数调用模式是比较高层次的抽象,不使用“数据”的概念来描述请求或者响应,而用函数(功能、方法)的形式来描述服务,请求端调用某个函数(功能),响应端实现函数(功能)返回值,常见的此类通信模式的产品包括:gRPC、SOME/IP、Corba、HTTP。同样以下面定义的Calculater服务作为示例,客户端只需要根据业务调用不同的方法即可,底层方法名称、参数、返回值的封装都自动生成。
// 计算服务
interface Calculater
{
// 加法计算
int add(long a, long b);
// 减法计算
double sub(double a, double b, double c);
// 平均值计算
double avg(long datas[]);
};
// 请求响应模式需要定义的数据结构
union CalculaterRequest switch(kind)
{
case ADD:
long a;
long b;
case SUB:
double a;
double b;
double c;
case AVG:
sequence<long> datas;
};
union CaluculaterResponse switch(kind)
{
case ADD:
long ret;
case SUB:
double ret;
case AVG:
double ret;
};
1.2. RPC有什么好处?
RPC的使用场景非常广泛,包括:
- 发送方发送数据后需要接收端确认的场景;
- 服务化场景,在系统中将某个特定的功能集中到某一个应用(组件),其他需要该功能的组件通过RPC来请求,这种架构可以:
- 降低对系统资源的需求,当实现这个功能需要很高的系统资源时,这种服务化架构可以只让服务端部署在高配置的环境,而客户端仅需要支持RPC通信即可,降低了客户端对系统资源的依赖。
- 简化系统的设计,服务化架构使得系统的设计变得清晰,组件与组件之间的功能松耦合。
1.3. 使用DDS实现RPC有什么好处?
- DDS的主题数据通过IDL来描述,而IDL同样适合于需要平台无关的语言来描述服务(接口、函数),两者可以使用相同的建模语言;
- DDS本身已经是将系统中的数据按照主题的方式逻辑化进行应用与硬件的解耦,非常适合同样需要将系统功能“服务化”的需求,并且DDS的自动发现机制可以轻易的解决服务定位的需求;
- 使用DDS实现RPC可以将DDS本身的优良的特性带给RPC通信模式,包括:
- 异构屏蔽,支持异构平台(操作系统、通信协议)以及异构编程语言的特性;
- QoS的配置,支持在RPC时配置各种不同的QoS;
- 安全性,可以复用底层DDS的安全策略进行:身份验证、访问控制以及数据加密等安全性功能;
- 互操作性,由于标准规范的存在,替换不同厂家的RPC开发库或者DDS库的代价极低;
- 实时性高,基于DDS通信的实时性得到保证;
- 使用DDS实现RPC使得一个项目里面仅需要采用一种技术即可完成多种通信模式,降低项目依赖以及复杂度;
2. DDS-RPC规范
DDS-RPC规范中定义了请求响应模式以及函数调用两种模式的RPC接口,本篇文章主要介绍函数调用模式,请求响应模式在前一篇文章中已经介绍。
2.1. 概念与架构
DDS-RPC的通信架构图参见下图,通信实体包括:
- 服务端实体,服务端实现服务(例如:上面定义的Calculater服务中的加法、减法、求平均的逻辑),并通过实现服务函数对外提供服务。在底层通信实现层面,服务端关联一个数据读者(关联请求主题)用于接收服务请求(函数名称、参数)以及一个数据写者(关联响应主题)用于发送函数的返回值。
- 客户端实体,客户端调用服务的桩函数实现服务请求,返回值实现服务结果,在底层通信实现层面,客户端关联一个数据写者(关联请求主题)用于发送服务请求(函数名称、参数)以及一个数据读者(关联响应主题)用于接收返回值信息。
2.2. 请求响应 VS. 函数调用
比较项 | 请求响应 | 函数调用 |
---|---|---|
易用性 | 需要手动调用请求数据,并接收响应数据。 | 对于程序员来说是最自然的,客户端只需要调用对应的函数,服务端只需要实现对应的函数。 |
通信模式 | 本质上是异步的,请求和响应可以分开,即应用请求完成后可以先不关心响应,等待合适的时候再处理响应数据。 | 同步的,请求(调用函数)后需要等待响应(返回值)到达,才能进行其他的工作。 |
适用场景 | 可以实现更加复杂的交互逻辑,例如:一次请求,响应多个数据。 | 由于函数的语义绑定导致一次请求只能有一次响应。 |
实现 | 相对简单,仅需为自定义数据类型添加请求头、响应头信息,并生成关联的实体。 | 相对复杂,需完成高层服务描述到DDS主题数据类型的自动映射过程,并生成服务的存根(stubs)和骨架(skeletons )。 |
2.3. 服务定义
函数调用模式下需要像定义类一样定义服务,详细的文法和语法参考:
- OMG IDL 3.5规范的第7.4章节;
- DDS-XTypes的第7.3.1.12.1章节。
// 可能的异常
exception ValueOverflow {};
// 计算服务
interface Calculater
{
// 属性定义
attribute boolean property;
// 加法计算
int add(long a, long b) raises (ValueOverflow);
// 减法计算
double sub(double a, double b, double c);
// 平均值计算
double avg(long datas[]);
};
2.4. 主题映射
底层的两个主题名要么是用户指定要么是根据服务名自动构造,也有两种形式:
- 在IDL中使用标记来指定;
- 在代码中通过设置请求者以及响应者的参数指定。
<topic_name> ::= <interface_name>"_"<service_name>"_"[ "Request" | "Reply" ]
| <user_def_alpha_num>
<service_name> ::= "Service"
| <user_def_alpha_num>
<user_def_alpha_num> ::= ^[[:alnum:]_ ]+$
2.5. 类型映射
下面以Calculator服务为例,说明如何转化为DDS主题可以绑定的数据类型。
2.5.1. 服务映射
- 请求结构体
// 每个服务生成一个该结构体
struct Calculator_Request
{
// 用于请求数据的标识
dds::rpc::RequestHeader header;
// 调用信息
Calculator_Call data;
};
// 携带请求的联合体
union Calculator_Call switch(long)
{
default dds::rpc::UnknownOperation unknownOp;
// add函数,每个函数对应一个case,其中_In结构参见函数映射
case Calculator_add_Hash:
Calculator_add_In add;
// 其他函数对应的case
...
};
- 响应结构体
// 每个服务生成一个该结构体
struct Calculator_Reply
{
// 标识请求数据
dds::rpc::RequestHeader header;
// 返回值信息
Calculator_Return data;
};
// 携带返回值的联合体结构
union Calculator_Return switch (long)
{
default:
dds::rpc::UnknownOperation unknownOp;
// add函数,每个函数对应一个case,其中_Result结构参见函数映射
case Calculator_add_Hash:
Calculator_add_Result add;
// 其他函数对应的case
...
};
2.5.2. 函数映射
- 请求端数据类型映射
// 针对每个函数生成 ${interfaceName}_${operationName}_In 结构体
struct Calculater_add_In
{
// 参数1
long a;
// 参数2
long b;
// 如果没有参数则自动添加以下成员
dds::rpc::UnusedMember dummy;
};
- 响应端数据类型映射
// 针对每个函数生成 ${interfaceName}_${operationName}_Out 结构体
struct Calculater_add_Out
{
// 返回值
long return_;
// 如果没有返回值则自动添加以下成员
dds::rpc::UnusedMember dummy;
};
// 携带调用结果的联合体
union Calculater_add_Result switch(long)
{
// 操作成功时的返回值,通过上面的_Out结构描述返回值
case dds::RETCODE_OK:
RobotControl_setSpeed_Out result;
// 可能的异常结果,每种异常一个case
case ValueOverflow_Ex_Hash:
ValueOverflow valueOverflow_ex;
// 其他异常情况
...
};
2.5.3. 属性映射
针对每个属性,自动映射为两个函数后再适用函数映射的规则。
- get_attribute_[attribute-name]
- set_attribute_[attribute-name]
2.6. 函数调用模式接口
协议中是以Modern C++的语法来定义的,对于不熟悉的同学来看会比较难理解,这里我们以Traditional C++的语法进行描述请求响应提供了哪些接口。
2.6.1. 客户端参数(ClientParams)
配置 | 说明 | 是否必须 |
---|---|---|
域号 | 在指定的DDS域内进行RPC通信 | 否 |
域参与者 | 允许传入已经创建好的域参与者,用于复用,默认自动创建新的域参与者 | 否 |
服务名称 | 该请求者请求的服务名称 | 是 |
请求主题名 | 自定义请求主题名,默认根据服务按照规则自动构造 | 否 |
响应主题名 | 自定义响应主题名,默认根据服务按照规则自动构造 | 否 |
数据写者QoS | 指定发送请求所需的服务质量,默认为持久化1的可靠传输 | 否 |
数据读者QoS | 指定接收响应所需的服务质量,默认为持久化1的可靠传输 | 否 |
2.6.2. 桩接口(Stubs)
针对IDL中定义的每个interface,以C++为例应生成如下的桩接口:
- 名为
{interface}
的抽象类和一个名为{interface}Async
的抽象类。 {interface}
和{interface}Async
都应具有一个公共虚析构函数。{interface}
抽象类应为IDL中定义的所有操作和属性包含公共的纯虚函数,遵循以下规则:- 函数名称应与IDL操作名称相同。
- 参数的数量和顺序应与IDL中定义的相同。
- 函数不应具有任何异常说明,实现中抛出IDL操作中规定的异常。
- IDL基本类型和容器类型映射到C++类型的详细信息在DDS-CXX-PSM规范的7.4.2小节中提供。
- In、Out和InOut基本类型及复杂类型(例如,结构体)的映射在DDS-CXX-PSM规范7.4.5小节中提供。
- 接口中属性的Getter/Setter函数在DDS-CXX-PSM规范7.4.6小节中有说明。
- 返回基本类型和枚举类型的函数,其对应的C++函数应按DDS-CXX-PSM规范7.4.2小节中的映射按值返回C++类型。
- 返回构造类型(例如,结构体)的函数应映射为第一个出口参数,命名为“cxx_return”,类型为引用,函数应返回void。
{interfaceAsync}
抽象类应为IDL中定义的所有操作和属性包含异步公共纯虚函数,遵循以下规则:- 函数名称应为
{interfaceAsync}
抽象类应为IDL中定义的所有操作和属性包含异步公共纯虚函数,遵循以下规则: - 函数名称应为
{operation_async}
。 - 函数应仅接受In和InOut参数,不应有Out参数。
- 函数不应具有任何异常说明。
- IDL基本类型和容器类型映射到C++类型的详细信息在DDS-CXX-PSM规范7.4.2小节中提供。所有参数应为const。
- In和InOut基本类型及构造类型(例如,结构体)的映射在DDS-CXX-PSM规范7.4.5小节中提供。所有参数应为const。
- 属性的Getter函数不应接受任何参数,且应返回与属性同类型的dds::rpc::future。
- 属性的Setter函数应根据DDS-CXX-PSM规范7.4.6小节的规定接受一个参数。应返回dds::rpc::future。
- 生成常规返回值(基本类型或构造类型)的操作应返回对应类型的dds::rpc::future值。
- 生成一个或多个Out/InOut参数的返回值的操作应返回
{interface}_{operation}_Out
类型的dds::rpc::future值。否则,它将返回dds::rpc::future。 - IDL文件中任何在C++中为保留字的标识符(例如,参数、操作名称、异常、接口、模块)应以“cxx_”作为前缀。
- 函数名称应为
2.6.3. 服务端参数(ServerParams)
配置 | 说明 | 是否必须 |
---|---|---|
域号 | 在指定的DDS域内进行RPC通信 | 否 |
域参与者 | 允许传入已经创建好的域参与者,用于复用,默认自动创建新的域参与者 | 否 |
服务名称 | 该响应者提供的服务名称 | 是 |
服务实例 | 该响应者的服务实例 | 否 |
请求主题名 | 自定义请求主题名,默认根据服务按照规则自动构造 | 否 |
响应主题名 | 自定义响应主题名,默认根据服务按照规则自动构造 | 否 |
数据写者QoS | 指定发送响应所需的服务质量,默认为持久化1的可靠传输 | 否 |
数据读者QoS | 指定接收请求所需的服务质量,默认为持久化1的可靠传输 | 否 |
2.6.4. 骨架接口(Skeletons )
与桩接口类似,只是用户应继承骨架接口来实现服务。
3. DDS-RPC与其他RPC技术
3.1. DDS-RPC与SOME/IP中RR模式
比较项 | DDS-RPC | SOME/IP |
---|---|---|
服务定义 | IDL | AUTOSAR |
服务发现 | 纯分布式自动发现 | SOME/IP-SD |
序列化 | CDR | AUTOSAR自定义 |
多语言支持 | C++、Java等 | C++ |
开发接口 | 请求响应/函数调用,类型安全接口 | 函数调用,非类型安全 |
底层传输协议 | DDS(可绑定:UDP/TCP/共享内存/高速总线等) | UDP、TCP |
3.2. DDS-RPC与gRPC
比较项 | DDS-RPC | gRPC |
---|---|---|
服务定义 | IDL | protoBuf |
服务发现 | 纯分布式自动发现 | 注册中心,额外提供负载均衡服务 |
序列化 | CDR | protoBuf |
多语言支持 | C++、Java等 | C++、Java、Go、Python等 |
开发接口 | 请求响应/函数调用,类型安全接口 | 函数调用,类型安全接口 |
底层传输协议 | DDS(可绑定:UDP/TCP/共享内存/高速总线等) | HTTP/2 |
4. 请求响应模式代码示例
以下示例还是以ZRDDS为例。
4.1. 服务定义
在RPCFCExample.idl文件中定义以下内容,包括请求类型以及响应类型。
enum Command { START_COMMAND, STOP_COMMAND };
struct Status
{
string msg;
float curSpeed;
};
@Service
interface robotControl
{
float temperature;
string<256> name;
void command(Command com);
void setSpeed(float speed) ;
float getSpeed();
void getStatus(out Status status);
boolean turnLeft();
};
4.2. 支撑代码生成
使用支持DDS-RPC的IDL编译器编译IDL,生成支撑代码,将生成的代码添加到工程中备用。
文件 | 说明 |
---|---|
RPCFCExample.h | RPCFCExample实体参数和函数声明 |
RPCFCExample.cpp | RPCFCExample实体参数和函数实现 |
RPCFCTypeSupportExample.h | RPCFCExample支持类型声明 |
RPCFCTypeSupportExample.cpp | RPCFCExample支持类型实现 |
RPCFCRPCExample.h | 请求实体和响应实体声明 |
RPCFCRPCExample.cpp | 请求实体和响应实体实现 |
RPCFCDataReaderExample.h | 数据读者结构声明 |
RPCFCDataWriterExample.h | 数据写者结构声明 |
RPCFCClientExample.h | 客户端桩接口声明 |
RPCFCClientExample.cpp | 客户端桩接口实现 |
RPCFCServiceExample.h | 服务端骨架接口声明 |
RPCFCServiceExample.cpp | 服务端骨架接口实现 |
RPCFC_clientMainExample.cpp | 客户端具体调用示例程序 |
RPCFC_serviceMainExample.cpp | 服务端具体实现示例程序 |
4.3. 客户端代码
4.3.1. 创建客户端对象
首先要使用 ClientParams 类型的参数来创建客户端对象。
// 声明ClientParams类型参数
ClientParams robotControlclientParams;
// 通过ClientParams类型参数创建robotControlClient类型客户端对象
robotControlClient *robotControlclient = new robotControlClient(robotControlclientParams);
4.3.2. 调用接口
通过创建好的对象调用已声明的接口及其内部属性和操作,示例代码如下:
// 调用由name属性自动生成的set方法
robotControlclient->setName("zrdds");
// 调用由name属性自动生成的get方法
std::string nameRet = robotControlclient->getName();
std::cout << "get name " << nameRet << std::endl;
// 调用用户定义的setSpeed函数
robotControlclient->setSpeed(40);
// 调用用户定义的getSpeed函数
float speedRet = robotControlclient->getSpeed();
std::cout << "get speed " << speedRet << std::endl;
// 调用由temperature属性自动生成的set方法
robotControlclient->setTemperature(37.5);
// 调用由temperature属性自动生成的get方法
float temperatureRet = robotControlclient->getTemperature();
std::cout << "get temperature " << temperatureRet << std::endl;
// 调用用户定义的turnLeft函数
bool turnLeftRet = robotControlclient->turnLeft();
if (turnLeftRet)
{
std::cout << "turn left successfully" << std::endl;
}
// 调用用户定义的getStatus函数
Status statusRet;
// 由于msg为unbounded类型,需要用户手动为其分配空间
statusRet.msg = (DDS_Char*)ZRMalloc(NULL, 1024);
strcpy(statusRet.msg, "no signal");
statusRet.curSpeed = 0;
robotControlclient->getStatus(statusRet);
std::cout << "get status: msg " << statusRet.msg << ", current speed" << statusRet.curSpeed << std::endl;
4.4. 服务端代码
4.4.1. 实现服务接口
用户需要在服务端实现服务接口中对应的操作,也就是在MyService类中实现所有的纯虚函数,示例代码如下:
class MyrobotControlService :public robotControlService
{
public:
// TODO 在此实现service类中的纯虚函数
MyrobotControlService(ServiceParams &SerParams) :robotControlService(SerParams)
{
};
virtual ~MyrobotControlService()
{
};
virtual virtual void command(const Command& com)
{
// TODO 在此实现具体操作
};
virtual void setSpeed(DDS_Float speed)
{
// TODO 在此实现具体操作
mSpeed = speed;
std::cout << "set speed " << mSpeed << std::endl;
};
virtual DDS_Float getSpeed()
{
// TODO 在此实现具体操作
mSpeed = 40;
std::cout << "get speed " << mSpeed << std::endl;
return mSpeed;
};
virtual void getStatus(Status& status)
{
// TODO 在此实现具体操作
// 由于msg为unbounded类型,需要用户手动为其分配空间
status.msg = (DDS_Char*)ZRMalloc(NULL, 1024);
strcpy(status.msg,"normal");
status.curSpeed = mSpeed;
};
virtual DDS_Boolean turnLeft()
{
// TODO 在此实现具体操作
return true;
};
virtual DDS_Float getTemperature()
{
// TODO 在此实现具体操作
return mTemperature;
};
virtual void setTemperature(DDS_Float temperature)
{
// TODO 在此实现具体操作
mTemperature = temperature;
std::cout << "set temperature " << mTemperature << std::endl;
};
virtual std::string getName()
{
// TODO 在此实现具体操作
return mName;
};
virtual void setName(const std::string& name)
{
// TODO 在此实现具体操作
mName = name;
std::cout << "set name " << mName << std::endl;
};
protected:
DDS_Float mSpeed;
DDS_Float mTemperature;
std::string mName;
Status mStatus;
};
4.4.2. 创建服务对象
使用 ServiceParams 类型的参数来创建服务端对象:
// 声明ServiceParams类型的参数
ServiceParams robotControlSerParams;
// 使用ServiceParams类型的参数创建MyrobotControlService类型的服务端对象
MyrobotControlService *robotControlservice = new MyrobotControlService(robotControlSerParams);