ROS服务通信,最详细最易懂(从文件构建到原理解析)

ROS中“逻辑处理需求”是什么?

逻辑一词本身的含义就是“规则”的意思,逻辑处理需求就相当于一种基于规则的通信机制,即你呼我应的通信关系。这种通信规则不同于ROS的话题通信,服务通信的通信规则没有时间规律,因此可以在任意时刻创建服务通信并按照预先设定好的通信规则进行。

服务通信的基本原理

服务是基于 C/S 模式的双向数据传输模式(有应答的通信机制),话题是无应答的通信机制。

服务通信和话题通信的对比

由于服务通信较强的实时性也就代表了talker和listener之间的通信链路的存在是暂时的是有条件的,也就是说每当listener接受到talker的请求并与之建立连接后,listener仅仅触发一次服务,即一次请求对应一次响应。这也就解释了:为什么第五步中通过TCP/IP协议回传给listener的是一个数据,而非是为了建立稳定TCP/IP。

相比较之下服务通信与话题通信的不同点在于:

① 在master中注册的信息不一样:

注意:无论是话题通信还是服务通信都涉及topic,通信双方必须都有相同的同topic才可以!

② master对listener的回复不同:

③ listener/talker的连接方式不同于server/client:

④ 服务通信对启动顺序有要求:

服务通信先启动服务器server然后再启动客户端client,但是话题通信的通信双方并没有启动的先后顺序。

⑤ 通信逻辑不同:

话题通信这种无应答的通信方式虽实时性不高,但是传输效率明显高于服务通信;服务通信由于多了一个回应的机制所以在效率上要低于话题通信,但是通信可以根据需求随时快速响应大大提高了实时性。

服务通信的实现

.srv文件的配置

一定要在功能包下建立名称为“srv”的文件夹,再在文件夹中创建.srv文件,否则编译失败,文件内容格式如下:

int32 num1  
int32 num1  
---  
int32 sum 

注:由于服务通信有clientàserver的响应以及serveràclient的回复,因此我们需要分别对两种消息进行数据类型设置,两种数据类型设置以”---”三个小杠为分界线。

CMakelist.txt编译器配置文件的配置

包含编译自定义功能包所需的符合ROS标准的功能包

find_package(catkin REQUIRED COMPONENTS  
  roscpp  
  rospy  
  std_msgs  
  message_generation  
) 

将符合ROS标准功能包编译成符合CMake标准的功能包

catkin_package(  
#  INCLUDE_DIRS include  
#  LIBRARIES hello_ros  
 CATKIN_DEPENDS roscpp rospy std_msgs message_runtime  
#  DEPENDS system_lib  
)  

添加要被编译的.srv文件

​## Generate services in the 'srv' folder  
add_service_files(  
  FILES  
  Addlints.srv  
) 

添加处理.srv文件的功能包

generate_messages(  
  DEPENDENCIES  
  std_msgs  
) 

声明C++可执行文件

generate_messages(  
  DEPENDENCIES  
  std_msgs  
)  

指定要链接库或可执行目标的库

target_link_libraries(srv_num  
  ${catkin_LIBRARIES}  
)  
target_link_libraries(cli_num  
  ${catkin_LIBRARIES}  
)  

该段代码的功能是:链接程序是将有关的目标文件批次链接,也即将一个文件中引用的符号同该符号所在的另外一个文件中的定义链接起来,使得所有的这些目标文件成为一个能够操作系统装入执行的统一整体。

添加源文件运行前需要提前编译的文件

代码格式

add_dependencies(cli_num ${PROJECT_NAME}_gencpp)  
add_dependencies(srv_num ${PROJECT_NAME}_gencpp)  

由于我们的.cpp源文件包含了.srv/.msg编译生成的.h头文件,因此我们需要在编译.cpp源文件之前首先编译.srv/.msg文件,使得由.srv/.msg文件生成的对应.h头文件先于.cpp源文件而编译。

代码位置

确保在正确的地方使用了正确的add dependencies()调用。如果您仔细阅读了CMakeList.txt,您会发现两个add dependencies()调用发生在两个不同的地方。一个在add executable()之前,另一个在之后。第一个是“Add cmake target dependencies of The library”,第二个是“Add cmake target dependencies of The executable”。很明显,如果我要在.cpp源文件执行之前先执行对.srv/.msg的编译就要将add dependencies()的调用放在add executable()之后。要是放在add executable()之前,那就报出如下错误:

Cannot add target-level dependencies to non-existent target "srv_num".

目标文件是什么?

编译器编译源代码后生成的文件叫做目标文件。从文件结构上来讲,目标文件已经是二进制文件。编译是针对单个源文件的,有几个源文件就会生成几个目标文件,并且在生成过程中不受其他源文件的影响。也就是说,不管当前工程中有多少个源文件,编译器每次只编译一个源文件、生成一个目标文件。

链接是什么?静态链接、动态链接是什么,它们具体做了什么?

静态链接方式:在程序执行之前完成所有的组装工作,生成一个可执行的目标文件(EXE文件),再次方式下,只要一个文件包含库,就将库拷贝至内存中不管是否重复拷贝,这一步仅仅将多个二进制文件物理的组装起来,实际上是在为代码做加法;

动态链接方式:在程序已经为了执行被装入内存之后完成链接工作,并且在内存中一般只保留该编译单元的一份拷贝,也就是说动态连接方式的作用是将程序中的冗余库删除,当多个文件引用一个库时,在内存中只拷贝一份库而不是有几个引用拷贝几个,实际上是在为代码做减法。

package.xml中添加编译依赖与执行依赖

其实package.xml文件的作用就相当于告诉系统“在编译我自定义的.cpp源文件和消息文件时,我要用到那些包,即依赖包”。比如我们需要roscpp,rospy,std_msgs,message_generation功能包来将我们使用C++/Python编写的非ROS标准功能包转化为标准ROS功能包,那我们就必须向编译器声明我需要roscpp,rospy,std_msgs,message_generation功能包。

<build_depend>roscpp</build_depend>  // 在基础ROS项目上创建功能包时所设置的依赖项
<build_depend>rospy</build_depend>  // 在基础ROS项目上创建功能包时所设置的依赖项
<build_depend>std_msgs</build_depend>  // 在基础ROS项目上创建功能包时所设置的依赖项
<build_depend>message_generation</build_depend> // 处理自定义消息文件时需人为添加

然后,我们还要设置运行ROS标准功能包时所需的功能包,即运行功能包的依赖包:

<exec_depend>roscpp</exec_depend>  
<exec_depend>rospy</exec_depend>  
<exec_depend>std_msgs</exec_depend>  
<exec_depend>message_runtime</exec_depend>  

上述roscpp,rospy,std_msgs,message_runtime这四个功能包使得ROS标准功能包最终被编译成CMake标准下的可执行文件。

Package.xml和CMakelist.txt文件的区别

Package.xml文件向编译器说明了“在使用ROS标准/CMake标准进行编译时分别所需的功能包”,但是有是一回事用又是另一回事,package.xml只告诉编译器我需要这些东西,但又没指明“我需要如何操作这些功能包,这些功能包的执行顺序又是什么样的“,CMakelist.txt文件给我们指明了这一点,在CMakelist.txt文件中find_package告知系统我编译功能包时真正需要哪些功能包,当然了这些功能包必须必须先在package.xml文件被声明才行,二者关系如下:

在CMakelist.txt中主要指明了以下几点:

① 在使用ROS标准编译该.cpp源文件之前需要先准备好什么功能包;

② 在使用CMake标准编译ROS标准文件时需要先准备好什么功能包;

③ 自定义消息文件是什么,将这些自定义ROS标准消息文件转化成可以嵌入.cpp源文件的.h文件需要什么功能包;

④ 我需要编译执行的操作对象有哪些,即我需要处理那些.cpp源文件,以及我需要将那些文件链接哪些CMake标准下的目标文件,最终一个项目会被编译链接生成一个可执行目标文件。

而package.xml文件主要指明了以下几点:

① 你别管我如何用这些功能包,我将.cpp源文件编译成ROS标准文件需要这些功能包,你给我准备好就行,具体怎么做我也不知道,但是我就需要这些功能包;

② 你别管我如何用这些功能包,我将ROS标准文件编译成CMake标准下的二进制可执行目标文件需要这些功能包,你给我准备好就行,具体怎么做我也不知道,但是我就需要这些功能包。

功能包文件编译程序流程

注意:第一个是将ROS中.srv/.msg文件编译生成可以嵌入C++源文件的.h头文件,第二个才是将ROS项目中src文件夹下的所有.cpp源文件进行链接编译最终生成可执行的二进制文件。

代码预期实现功能

服务端代码编写——srv_num.cpp

#include "ros/ros.h"  
#include "hello_ros/Addlints.h"  
  
bool DoSum(hello_ros::Addlints::Request& request,  
            hello_ros::Addlints::Response& response)  
{  
    int num1 = request.num1,num2 = request.num2;  
    ROS_INFO("request:%d,%d\n\t",num1,num2);  
    response.sum = num1 + num2;  
    ROS_INFO("response:%d+%d=%d",num1,num2,response.sum);  
  
    return true;  
}  
  
int main(int argc,char* argv[])  
{  
    // locate language  
    setlocale(LC_ALL,"");  
    // initial ROS node  
    ros::init(argc,argv,"srv_object1");  
    // set NodeHandler  
    ros::NodeHandle nh;  
    // create server object  
    ros::ServiceServer service1 = nh.advertiseService<hello_ros::Addlints::Request,  
        hello_ros::Addlints::Response>("Addint",DoSum);  
    // ROS_INFO  
    ROS_INFO("service has been started......");  
    // Callback mechanism  
    ros::spin();  
  
    return 0;  
} 

Server/client的数据通信流程

Client与server信息之间的交互逻辑

有些人不明白为什么执行如下的赋值代码就可以将client的请求发送给server:

hello_ros::Addlints ai;  
ai.request.num1 = atoi(argv[1]);  
ai.request.num2 = atoi(argv[2]); 

其实我们编译完我们的自定义.srv服务消息文件之后,devel/include文件夹下会出现三个.h头文件:

其中Addlints.h是给我们封装好了的,我们直接用这个就可以了,但是其他两个.h文件怎么用呢?这就涉及到数据的交互问题,下面是简单的原理(说明了代码编写的逻辑):

这是封装之后的简单原理图,我们知道server和client之间是通过topic相互联系的,因此server和client之间可以以topic为为中间媒介建立一下模型(说明了server和client如何实时同步信息的):

在整个通信过程中我们所需要操作的部分如下:

我们需要做的就是:

① server端:编写回调函数从函数入口参数中的AddlintsRequest对象中提取request并将其在函数体中处理成response,并将其再次传给函数入口参数AddlintsResponse对象;

② client端:自定义Addlints对象,将request所需的所有信息赋值给Addlints对象中的子类对象request完成对自定义请求消息类型的封装,然后从Addlints对象中的子类对象response中提取server返回给client的response。

client向master上报的数据类型

当我们看了advertiseService函数的重载时,我们就会发现,advertiseService函数需要实例化模板参数,分别是类类型:请求数据类型和响应数据类型。代码如下:

ros::ServiceServer service1 = nh.advertiseService<hello_ros::Addlints::Request,hello_ros::Addlints::Response>("Addint",DoSum);  

上述代码的含义:将request的数据类型和response的数据类型以及topic服务通信主题名称上报给master主节点,从而在master中注册信息。值得注意的是,节点句柄是操作节点和读取关于节点的属性的,例如操作节点与主节点之间通信(即将节点的信息在master主节点中进行注册),读取通过命令行了解节点从命令行中获取的参数等。

回调函数的编写

bool DoSum(hello_ros::Addlints::Request& request,  
            hello_ros::Addlints::Response& response)  
{  
    int num1 = request.num1,num2 = request.num2;  
    ROS_INFO("request:%d,%d\n\t",num1,num2);  
    response.sum = num1 + num2;  
    ROS_INFO("response:%d+%d=%d",num1,num2,response.sum);  
  
    return true;  
} 

首先,函数返回值需要时bool类型的,其中true代表server接收client客户端请求成功并且成功调用回调函数处理client传来的请求信息,false则代表server没有接收到client客户端请求或者处理从client发来的信息时出现异常导致失败。

其次,函数的入口参数要和server服务端所能接收的request和response信息数据类型一致,其中server服务端在master中注册的request和response信息数据类型就是我们下面程序中的实例化模板参数:

ros::ServiceServer service1 = nh.advertiseService<hello_ros::Addlints::Request,hello_ros::Addlints::Response>("Addint",DoSum);  

其实,我们可以把回调函数看作一个“发酵罐”,进去的是酿酒的原料(request)出来的就是成品酒(response),关于“将来自client的request处理成server返回给client的response“的形象化比喻如下所示:

可以将回调函数与client直接的关系也进行如下形象化比喻:

当从client读取到“server处理请求的相关情况(失败/成功)“,我们可以下一步操作。

回调函数参数为何为类对象的引用,仅仅传入函数对象不行吗?

我们前面提到过:

我们要将函数值处理的结果(即response)在装进Addlints自定义类类型对象之中的话,我们必须要将函数参数设置为引用,这样当函数体内部改变了传入参数的数值,传入参数的数值也会发生改变,这就涉及到C++引用传参的具体知识了。

但是传入参数的引用,也导致了一些问题:我们可以在server端的回调函数中修改client传给server的request。具体代码如下:

bool DoSum(hello_ros::Addlints::Request& request,  
            hello_ros::Addlints::Response& response)  
{  
    request.num1 = 0; request.num2 = 0;  
    ROS_INFO("request have been changed:%d,%d\n\t",request.num1,request.num2 );  
    int num1 = request.num1,num2 = request.num2;  
    ROS_INFO("request:%d,%d\n\t",num1,num2);  
    response.sum = num1 + num2;  
    ROS_INFO("response:%d+%d=%d",num1,num2,response.sum);  
  
    return true;  
}  

客户端响应相应如下:

服务器端相应如下:

上面的响应结果告诉我们:由于相互连接的server和client有相同的topic,这样就会导致一端改变全都改变的局面,即我们居然从server端修改了从client发送过来的request,这一下子就会导致“现在的request已不同于client最初发送的request了“,就相当于“我原来给机械臂发送了向左转的信号,但是由于server端将request进行了误修改导致机械臂的响应是向右转”。

那我们该如何防止在函数体内部的误修改呢?

我们函数体内部将函数入口参数赋值给我们函数体的内部变量,我们禁止改变的变量就赋值给const类型的类类型的引用,我们想改变的可以不进行任何多于操作,具体代码实现如下:

bool DoSum(hello_ros::Addlints::Request& request,  
            hello_ros::Addlints::Response& response)  
{  
    const hello_ros::Addlints::Request& rqu = request;  
    int num1 = rqu.num1,num2 = rqu.num2;  
    ROS_INFO("request:%d,%d\n\t",num1,num2);  
    response.sum = num1 + num2;  
    ROS_INFO("response:%d+%d=%d",num1,num2,response.sum);  
  
    return true;  
}

将传入的request参数在函数体内部赋值给常值引用就可以保证我们不对request进行误操作误修改从而保证了处理request所得的response的正确性。我们这里使用const引用类类型而不用const类类型就是为了提高运行效率,因为你要是用的是const类类型的话,这就涉及到了类对象的拷贝,导致程序执行效率下降。

回调函数的调用

// Callback mechanism  
 ros::spin();  

这句代码表示着:如果server接收到来自client的request,那我就执行被挂起的回到函数。注意:每当有回调函数时,我们都必须调用这段代码。

客户端代码编写——cli_num.cpp

主要代码如下:

#include "ros/ros.h"  
#include "hello_ros/Addlints.h"  
  
int main(int argc, char* argv[])  
{  
    // locate language  
    setlocale(LC_ALL,"");  
  
    if(argc != 3) {  
        ROS_INFO("input arg error......");  
        return 1;  
    }  
  
    // initial ROS node  
    ros::init(argc,argv,"client_object1");  
    // create Nodehandler  
    ros::NodeHandle nh;  
    // create client object under service mode  
    ros::ServiceClient cli_obj1 = nh.serviceClient<hello_ros::Addlints::Request,  
        hello_ros::Addlints::Response>("Addint");  
    // send request to server and receive response from server  
    hello_ros::Addlints ai;  
    ai.request.num1 = atoi(argv[1]);  
    ai.request.num2 = atoi(argv[2]);  
  
    // hang up client request to wait for the appearance of server  
    //cli_obj1.waitForExistence(); // from the view of client  
    ros::service::waitForService("Addint"); // from the view of master  
    bool flag = cli_obj1.call(ai);  
  
    if(flag) {  
        ROS_INFO("sum:%d",ai.response.sum);  
    } else {  
        ROS_INFO("request failed......");  
        return 2;  
    }  
  
    return 0;  
}

Client对外信息输出接口——自定义消息类类对象

在上述程序中,hello_ros命名空间下有一个最最重要的Addlints对象,hello_ros是我们自定义功能包的名字在这里充当变量的命名空间,Addlints类类型则是我们自定义的.srv服务消息类型文件编译而来的类类型。

为什么说Addlints对象最重要呢?我们思考一下,我们在server服务器端程序编写中提及过:server处理请求产生的response信息和client发送给server的request请求信息均会同步存在于server服务器和client客户端中(这是由于他们两个有相同的topic,这使得双方拥有的数据可以同步)。如果在服务器中要找一个既能向server发送request又能从server接收response的变量,那我们只能找我们自定义的Addlints类类型对象了,因为我前面提到过:Addlints.h文件下包含两个子文件,分别为AddlintsRequest.h和AddlintsResponse.h文件,这两个子文件分别负责存储client向server的request和server返回给client的response。

类对象在我们server服务器端和client端都必须存在,只不过在server端,我们将AddlintsRequest和AddlintsResponse类对象用于实例化了回到函数并且作为参数传入用于处理request的回调函数中,在回调函数中我们处理request并且返回response。

那client呢?在client端,我们必须自定义一个Addlints类类型对象,我们可以向Addlints对象的AddlintsRequest子对象传入信息,这一步就相当于我们将我们传入的信息封装进了request中以待向server传递。此外,我们还可以从Addlints类对象的AddlintsResponse子对象在client端中读取server处理request所返回给client客户端的response信息。

Addlints自定义服务消息类类型对象在Server和client端中充当的角色如下所示:

程序优化:从命令行读入信息

int main(int argc, char* argv[])  
{  
    if(argc != 3) {  
        ROS_INFO("input arg error......");  
        return 1;  
    }//判断参数个数是否正确  
    ......  
    hello_ros::Addlints ai;  
    ai.request.num1 = atoi(argv[1]); // 读取数据  
    ai.request.num2 = atoi(argv[2]); // 读取数据  
    ......  
}  

在main函数的参数中int argc表示参数的个数,如果我们不通过命令行给main函数传参,那么argc=1,这一个参数代表了main函数所在.cpp文件的调用地址指明了main函数的入口地址,这里我们不需要这个参数,我们需要的是除第一个main函数入口地址外的其他参数,从命令行输入参数的形式如下:

上述命令输入格式为:rosrun package_name cpp_name arg1 arg2,其中main函数入口参数中argv是装有字符型指针的一维数组,其结构如下:

由于我们需要的是int类型的参数,而argv[][]中存储的是char类型的字符,因此我们要使用atoi函数将char类型的数字转化为int类型的数字。

服务要先于客户端启动,那不这样做行吗?如何改进?

正常情况下不可以,为什么呢?因为服务通信讲究的是实时性,client发出的请求之后server收到了信息传输通道就可以建立,如果client想要连接的server还没有在master中完成注册或者根本还没有启动,client可没时间跟你浪费时间,client发出的请求一般都是要server实时响应的。如果server不响应client的实施请求,client端就会立刻返回请求发送失败,即如果你在未启动server之前就率先启动client,系统会返回如下错误:

一对server/client可以这样干,但是ROS系统中节点那么多,在我们编写launch文件时可能弄错了某一对server/client节点的启动顺序,那直接报错,这也太麻烦了。于是ROS系统提供了两种确保server没启动时将client请求发送函数挂起的内置函数:

client向master询问“接收我的请求信息的server启动没有呀?”

对应函数如下:

ros::service::waitForService("Addint");  

只要是以ros为命名空间的都是和master有关的操作,这个函数就是client向master询问“接收我的请求信息的server启动没有呀?”的函数,如果master告诉client“大兄弟能接收你请求的server还没在我这里注册完信息或者根本没被启动,你要不先别发送请求先耐心等等吧”。一旦server启动,发送client请求的函数会被立刻执行,然后client会和server建立基于TCP/IP协议的一次性的通信关系(即一次请求只对应一次响应),输出结果如下:

① 服务端:

② 客户端:

注意:这里的内置函数只能确保client发送请求的函数挂起,并没有改变“如果client先于server启动,服务通信就会失败的本质”!

Client尝试着发送request试试,看看能否成功,如果不成功就等待

对应函数如下:

cli_obj1.waitForExistence(); // from the view of client  

这段代码的含义就是不断地尝试与server取得联系,如果连接失败说明server还未启动或者还未在master中注册完自身信息,那发送client请求的函数会被挂起暂时不执行。

使client请求发送函数挂起的内置函数在代码中的位置

动作的发起者:client对象调用call函数

  1. bool flag = cli_obj1.call(ai);  

这段代码表示了“client发送request”的动作,函数的输入参数是client下的Addlints对象,返回参数是一个bool类型的变量,表征着server是否接受请求并且返回响应信息。

Call函数执行成功标志着如下图所示的部分已经被执行完成了:

处理response并且进行下一步动作

我这里是:如果执行成功(flag=true)并成功得到response就会将server发给client的response打印到命令行中;若执行失败(flag=false),则在命令行打印"request failed......"。具体实现程序如下:

if(flag) {  
    ROS_INFO("sum:%d",ai.response.sum);  
} else {  
    ROS_INFO("request failed......");  
    return 2;  
}  

我使用client客户端对象(即ros::ServiceClient类型的对象)调用的call函数的bool类型的返回值进行判断执行是否成功。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

肥肥胖胖是太阳

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值