ros2 服务编程实现
一、自定义服务接口
服务接口格式及步骤
#客户端发送请求的数据结构定义
int64 a
int64 b
---
#服务端响应结果的数据结构定义
int64 sum
#1.新建`srv`文件夹,并在文件夹下新建`xxx.srv`
#2.在`xxx.srv`下编写服务接口内容并保存
#3.在`CmakeLists.txt`添加依赖和srv文件目录
#4.在`package.xml`中添加`xxx.srv`所需的依赖
#5.编译功能包即可生成`python`与c++头文件
1.创建借钱服务接口
-
确定数据结构
string name uint32 money --- bool success uint32 money
客户端:
- 借钱者名字,字符串类型、可以用
string
表示 - 金额,整形,可以用
uint32
表示
服务端:
- 是否出借:只有成功和失败两种情况,布尔类型(
bool
)可表示 - 出借金额:无符号整形,可以用
uint32
表示,借钱失败时为0
- 借钱者名字,字符串类型、可以用
-
创建srv文件夹及
BorrowMoney.srv
消息文件srv
是service
的缩写cd src/village_interfaces mkdir srv && cd srv touch BorrowMoney.srv
-
修改
CMakeLists.txt
因为在4.6中我们已经添加过依赖
DEPENDENCIES
和msg
文件了,所以这里我们直接添加一个srv
即可find_package(rosidl_default_generators REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} #---msg--- "msg/Novel.msg" #---srv--- "srv/BorrowMoney.srv" DEPENDENCIES sensor_msgs )
-
修改package.xml
<build_depend>sensor_msgs</build_depend> <build_depend>rosidl_default_generators</build_depend> <exec_depend>rosidl_default_runtime</exec_depend> <member_of_group>rosidl_interface_packages</member_of_group>
2.创建买书服务接口
-
确定数据结构
用无符号整形表示
money
大家已经很清楚如何做了,那如何表示小说数组呢?其实只需在类型的后面加上[]
中括号。uint32 money --- string[] novels
-
创建srv文件夹及
SellNovel.srv
消息文件cd src/village_interfaces cd srv touch SellNovel.srv
-
修改
CMakeLists.txt
在上面的基础上,再添加上一行
"srv/SellNovel.srv"
代码即可:rosidl_generate_interfaces(${PROJECT_NAME} #---msg--- "msg/Novel.msg" #---srv--- "srv/BorrowMoney.srv" "srv/SellNovel.srv" DEPENDENCIES sensor_msgs )
-
修改package.xml
同样的,因为我们已经修改过了,这里就不需要修改了。
二、服务实现(Python)
剧情梗概:
李三(客户端)向李四(服务端)借钱吃麻辣烫。李四外借的规则为:①借钱一定要打欠条,收到欠条才能给钱;②最多借出去100x10%=10
块钱
1.编写服务端代码
服务端基本步骤:
#1.导入服务接口
#2.创建服务端回调函数
#3.声明并创建服务端
#4.编写回调函数逻辑处理请求
-
添加依赖
导入依赖是为了能够让我们的代码找到对应的接口。
因为
village_li
是包类型是ament_python
这里只需要在package.xml
中加入下面的代码即可:<depend>village_interfaces</depend>
-
导入服务接口
#从村庄接口服务类中导入借钱服务 from village_interfaces.srv import BorrowMoney
-
创建服务端
# 新建借钱服务 self.borrow_server = self.create_service(BorrowMoney, "borrow_money", self.borrow_money_callback)
-
编写回调函数
def borrow_money_callback(self,request, response): """ 借钱回调函数 参数:request 客户端请求 response 服务端响应 返回值:response """ self.get_logger().info("收到来自: %s 的借钱请求,目前账户内还有%d元" % (request.name, self.account)) #根据李四借钱规则,借出去的钱不能多于自己所有钱的十分之一,不然就不借 if request.money <= int(self.account*0.1): response.success = True response.money = request.money self.account = self.account - request.money self.get_logger().info("借钱成功,借出%d 元 ,目前账户余额%d 元" % (response.money,self.account)) else: response.success = False response.money = 0 self.get_logger().info("对不起兄弟,手头紧,不能借给你") return response
这里代码其实并不复杂,先判断要借钱的金额是否满足要借出去的数量,如果满足则借,不然就不借。
为了测试方便,我们为李四的光头账户打赏70块钱,将
__init__
函数中的self.account 默认账户值改为70即可
2.编写客户端端代码
客户端基本步骤:
#导入服务接口
#创建请求结果接收回调函数
#声明并创建客户端
#编写结果接收逻辑
#调用客户端发送请求
-
添加依赖
第一步和服务端相同,导入对应的接口,因为李四和李三是在同一个包
village_li
内,所以不需要再次修改package.xml
。 -
导入服务接口
#从村庄接口服务类中导入借钱服务 from village_interfaces.srv import BorrowMoney
-
创建客户端
#在__init__函数中创建一个服务的客户端 self.borrow_money_client_ = self.create_client(BorrowMoney, "borrow_money")
创建客户端使用函数
create_client
该函数有两个入口参数,一个是服务接口类型,一个是服务名称。这里的两个参数需和服务端的完全一致,方可通信。名字不一致,会找不到对应服务,数据类型不一致会导致无法通信。
-
编写结果接收逻辑
def borrow_respoonse_callback(self,response): """ 借钱结果回调 """ # 打印一下信息 result = response.result() if result.success == True: self.get_logger().info("果然是亲弟弟,借到%d,吃麻辣烫去了" % result.money) else: self.get_logger().info("害,连几块钱都不借,我还是不是他亲哥了,世态炎凉呀")
-
调用客户端发送请求
接着我们在
BaiPiaoNode中
编写一个函数用于创建发送的数据,并发送请求。def borrow_money_eat(self): """ 借钱吃麻辣烫函数 """ #打印一句话 self.get_logger().info("找我弟借钱吃麻辣烫喽") #等待服务启动,每1s检查一次,如果服务没有启动,则一直循环 while not self.borrow_money_client_.wait_for_service(1.0): self.get_logger().warn("我弟不在线,我再等等。") # 构建请求内容 request = BorrowMoney.Request() #将当前节点名称作为借钱者姓名 request.name = self.get_name() #借钱金额10元 request.money = 10 #发送异步借钱请求,借钱成功后就调用borrow_respoonse_callback()函数 self.borrow_money_client_.call_async(request).add_done_callback(self.borrow_respoonse_callback)
wait_for_service(1.0)
用于等待服务上线,这是一种很优雅的做法,调用之前检测一下服务是否在线call_async(request).add_done_callback
这里是代码的核心部分,用于发送异步服务请求,并且添加了一个任务完成时的回调函数borrow_respoonse_callback
三、服务实现(C++)
剧情梗概:
张三(客户端)拿多少钱钱给王二,王二(服务端)凑够多少个章节的艳娘传奇给他
1.编写服务端代码
服务端基本步骤:
#1.导入服务接口
#2.创建服务端回调函数
#3.声明并创建服务端
#4.编写回调函数逻辑处理请求
-
添加依赖
第一步修改
package.xml
加入下面的代码(告诉colcon,编译之前要确保有village_interfaces存在)
<depend>village_interfaces</depend>
第二步修改和
CMakeLists.txt
①查找库
find_package(village_interfaces REQUIRED)
②将其和可执行文件链接起来
ament_target_dependencies(wang2_node rclcpp village_interfaces )
-
导入服务接口
#include "village_interfaces/srv/sell_novel.hpp"
-
创建服务端
在
private:
下声明服务端// 声明一个服务端 rclcpp::Service<village_interfaces::srv::SellNovel>::SharedPtr server_;
在构造函数中实例化服务端
// 实例化卖二手书的服务 server_ = this->create_service<village_interfaces::srv::SellNovel>("sell_novel", std::bind(&SingleDogNode::sell_book_callback,this,_1,_2), rmw_qos_profile_services_default, callback_group_service_);
-
编写回调函数
①声明并实例化回调函数组(用以实现多线程)
// 声明一个服务回调组 rclcpp::CallbackGroup::SharedPtr callback_group_service_;
// 实例化回调函数组 callback_group_service_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive);
②编写回调函数(其中引入了队列容器来存放小说队列)
//创建一个小说章节队列 std::queue<std::string> novels_queue;
// 声明一个回调函数,当收到买书请求时调用该函数,用于处理数据 void sell_book_callback(const village_interfaces::srv::SellNovel::Request::SharedPtr request, const village_interfaces::srv::SellNovel::Response::SharedPtr response) { RCLCPP_INFO(this->get_logger(), "收到一个买书请求,一共给了%d钱",request->money); unsigned int novelsNum = request->money*1; //应给小说数量,一块钱一章 //判断当前书库里书的数量是否满足张三要买的数量,不够则进入等待函数 if(novels_queue.size()<novelsNum) { RCLCPP_INFO(this->get_logger(), "当前艳娘传奇章节存量为%d:不能满足需求,开始等待",novels_queue.size()); // 设置rate周期为1s,代表1s检查一次 rclcpp::Rate loop_rate(1); //当书库里小说数量小于请求数量时一直循环 while (novels_queue.size()<novelsNum) { //判断系统是否还在运行 if(!rclcpp::ok()) { RCLCPP_ERROR(this->get_logger(), "程序被终止了"); return ; } //打印一下当前的章节数量和缺少的数量 RCLCPP_INFO(this->get_logger(), "等待中,目前已有%d章,还差%d章",novels_queue.size(),novelsNum-novels_queue.size()); //rate.sleep()让整个循环1s运行一次 loop_rate.sleep(); } } // 章节数量满足需求了 RCLCPP_INFO(this->get_logger(), "当前艳娘传奇章节存量为%d:已经满足需求",novels_queue.size()); //一本本把书取出来,放进请求响应对象response中 for(unsigned int i =0 ;i<novelsNum;i++) { response->novels.push_back(novels_queue.front()); novels_queue.pop(); } }
③修改订阅者的回调函数(因为王2的书来源于李4发布的,所以当其发布后要将小说放入novels_queue中
novels_queue.push(msg->data)
)// 收到话题数据的回调函数 void topic_callback(const std_msgs::msg::String::SharedPtr msg){ // 新建一张人民币 std_msgs::msg::UInt32 money; money.data = 10; // 发送人民币给李四 pub_->publish(money); RCLCPP_INFO(this->get_logger(), "王二:我收到了:'%s' ,并给了李四:%d 元的稿费", msg->data.c_str(),money.data); //将小说放入novels_queue中 novels_queue.push(msg->data); };
-
修改main函数
新建一个多线程执行器,添加王二节点并
spin
,完整代码如下:int main(int argc, char **argv) { rclcpp::init(argc, argv); /*产生一个Wang2的节点*/ auto node = std::make_shared<SingleDogNode>("wang2"); /* 运行节点,并检测退出信号*/ rclcpp::executors::MultiThreadedExecutor exector; exector.add_node(node); exector.spin(); rclcpp::shutdown(); return 0; }
2.编写客户端端代码
客户端基本步骤:
#导入服务接口
#创建请求结果接收回调函数
#声明并创建客户端
#编写结果接收逻辑
#调用客户端发送请求
-
添加依赖
方法同上,不再赘述
-
导入服务接口
方法同上,不再赘述
-
声明并实例化客户端
// 创建一个客户端 rclcpp::Client<village_interfaces::srv::SellNovel>::SharedPtr client_;
//实例化客户端 client_ = this->create_client<village_interfaces::srv::SellNovel>("sell_novel");
-
编写结果接收逻辑
//创建接收到小说的回调函数 void novels_callback(rclcpp::Client<village_interfaces::srv::SellNovel>::SharedFuture response) { auto result = response.get(); RCLCPP_INFO(this->get_logger(), "收到%d章的小说,现在开始按章节开读", result->novels.size()); for(std::string novel:result->novels) { //打印小说章节内容 RCLCPP_INFO(this->get_logger(), "%s", novel.c_str()); } RCLCPP_INFO(this->get_logger(), "小说读完了,好刺激,写的真不错,好期待下面的章节呀!"); }
-
调用客户端发送请求
编写请求函数
but_novel()
。整个函数可以分为三个部分:
- 等待服务端上线
- 构造请求数据
- 发送异步请求
void buy_novel() { RCLCPP_INFO(this->get_logger(), "买小说去喽"); //1.等待服务端上线 while (!client_->wait_for_service(std::chrono::seconds(1))) { //等待时检测rclcpp的状态 if (!rclcpp::ok()) { RCLCPP_ERROR(this->get_logger(), "等待服务的过程中被打断..."); return; } RCLCPP_INFO(this->get_logger(), "等待服务端上线中"); } //2.构造请求的钱 auto request = std::make_shared<village_interfaces::srv::SellNovel_Request>(); //先来五块钱的看看好不好看 request->money = 5; //3.发送异步请求,然后等待返回,返回时调用回调函数 client_->async_send_request(request,std::bind(&PoorManNode::novels_callback, this, _1)); };
四、收获与释疑
1.mkdir
与touch
mkdir
是用于创建新目录(文件夹)的命令。例如,mkdir srv
将创建一个名为 srv
的目录。
touch
是用于创建新文件或更新已有文件的访问和修改时间的命令
2.基本功能包依赖项与自定义消息和服务的依赖项:
基本功能包依赖项:
ament_cmake
: ROS 2 构建系统工具。rclcpp
: ROS 2 C++ 客户端库。rclpy
: ROS 2 Python 客户端库。std_msgs
: 标准消息类型。sensor_msgs
: 传感器消息类型。geometry_msgs
: 几何消息类型。
自定义消息和服务的依赖项:
如果你要定义自定义消息和服务,需要依赖 rosidl
和相关生成器:
rosidl_default_generators
: 默认的 ROSIDL 生成器。rosidl_default_runtime
: 生成的消息和服务的运行时依赖
3.为什么c++中创建节点要使用std::shared_ptr
类型,python只是简单创建个节点?
std::shared_ptr
作用有:
- 共享所有权:
std::shared_ptr
允许多个指针共享同一个对象。当最后一个指针被销毁时,对象才会被释放。 - 引用计数:
shared_ptr
内部维护一个引用计数,每次拷贝时计数加一,每次销毁时计数减一。 - 自动管理内存:与
unique_ptr
类似,shared_ptr
也会在超出作用域时自动释放内存。
而在python中,在Python中,内存管理是由垃圾回收器自动处理的,因此不需要像在C++中那样显式地使用智能指针(例如 std::shared_ptr
)来管理对象的生命周期。Python的内存管理系统使用引用计数和垃圾回收来管理对象的生命周期,这使得开发者不需要手动管理内存
4.为什么服务端的回调函数传入的response
和request
是SharedPtr
类型,而客户端的回调函数传入的response
是SharedFuture
类型?
-
在服务文件(
.srv
文件)中定义的请求和响应类型本身没有指定它们是共享指针类型。这是ROS 2的实现细节,服务处理的实际代码在运行时会使用共享指针来管理这些对象。在服务回调函数中,
Request::SharedPtr
和Response::SharedPtr
是ROS 2自动生成的类型定义的一部分,用于管理请求和响应对象的生命周期。 -
当你使用
async_send_request
方法发送异步请求时,它会返回一个std::shared_future
对象。这允许你稍后在需要的时候获取操作的结果。
5.在C++中,::
和 /
在包含头文件和使用命名空间时具有不同的含义和用法?
包含头文件时使用 "
和 /
:
- 当你使用
#include "village_interfaces/srv/sell_novel.hpp"
时,"
表示你正在包含一个头文件,而/
表示路径分隔符。 - 在文件系统中,路径使用
/
分隔目录。在这里,village_interfaces/srv/sell_novel.hpp
表示头文件的相对路径,指向服务定义文件SellNovel.srv
自动生成的 C++ 头文件。
命名空间访问时使用 ::
:
- 在
rclcpp::Client<village_interfaces::srv::SellNovel>
中,::
用于指示命名空间的访问。rclcpp
是一个命名空间,Client
是其中的一个类,而village_interfaces::srv::SellNovel
则是你定义的服务类型。 village_interfaces::srv::SellNovel
表示village_interfaces
命名空间下的srv
子命名空间下的SellNovel
类型,这是由ROS 2生成的服务接口的类名。
总结
- 使用
"
和/
来包含头文件,指定文件的路径。 - 使用
::
来访问命名空间中的类、函数、变量等。
6.为什么有些需要使用回调函数组,有些不用?
使用回调函数组的情况:
sell_novels_callback_group
被创建为一个互斥(Mutually Exclusive)的回调组,意味着属于这个组的回调不会并发执行。这是为了确保在处理服务请求时,不会有其他服务请求同时执行,避免了共享资源的竞争条件。sell_server
在创建时被绑定到sell_novels_callback_group
回调组,这确保了服务回调函数sell_novel_callback
受到回调组的管理。
如果你希望某些回调可以并发执行,你可以将它们放入另一个回调组,并指定为可以并发执行的类型(Reentrant)。但在你的例子中,服务回调函数需要确保顺序执行,因此使用了互斥回调组。
不用回调函数组的情况:
- 简单的单线程逻辑:在这个节点中,服务客户端只涉及一个简单的异步请求和一个回调函数。没有涉及到复杂的并发操作或需要互斥的资源访问。
- 默认行为足够:ROS 2默认的执行模型对于这种简单的节点已经足够。默认情况下,回调函数是顺序执行的,能够满足你的需求。
口的类名。
总结
- 使用
"
和/
来包含头文件,指定文件的路径。 - 使用
::
来访问命名空间中的类、函数、变量等。
6.为什么有些需要使用回调函数组,有些不用?
使用回调函数组的情况:
sell_novels_callback_group
被创建为一个互斥(Mutually Exclusive)的回调组,意味着属于这个组的回调不会并发执行。这是为了确保在处理服务请求时,不会有其他服务请求同时执行,避免了共享资源的竞争条件。sell_server
在创建时被绑定到sell_novels_callback_group
回调组,这确保了服务回调函数sell_novel_callback
受到回调组的管理。
如果你希望某些回调可以并发执行,你可以将它们放入另一个回调组,并指定为可以并发执行的类型(Reentrant)。但在你的例子中,服务回调函数需要确保顺序执行,因此使用了互斥回调组。
不用回调函数组的情况:
- 简单的单线程逻辑:在这个节点中,服务客户端只涉及一个简单的异步请求和一个回调函数。没有涉及到复杂的并发操作或需要互斥的资源访问。
- 默认行为足够:ROS 2默认的执行模型对于这种简单的节点已经足够。默认情况下,回调函数是顺序执行的,能够满足你的需求。
- 无资源竞争问题:由于这个节点没有复杂的资源共享或多个回调函数同时执行的需求,不需要通过回调函数组来管理并发或互斥行为。