4、服务通信实战:服务端节点创建、客户端节点创建、自定义服务接口、Gazebo中的模型控制服务

好,咱们今天聊点硬核的。服务通信,说白了就是ROS2里一种「你问我答」的机制。跟话题通信那种「广播式」的发布订阅不一样,服务通信是一对一的、有去有回的。你发一个请求,它回一个响应,干净利落。

我在做机器人底盘控制的时候,经常用服务来下发指令。比如让机器人「走到坐标(1,2)」,它走完了会告诉我「我到了」。这种场景用话题当然也能做,但服务更直接,也更符合直觉。

4.1 服务通信的核心概念

先理清几个关键角色:

  • 服务端(Service Server):提供服务,接收请求,返回响应。说白了就是「被调用方」。
  • 客户端(Service Client):调用服务,发送请求,等待响应。就是「调用方」。
  • 服务接口(Service Interface):定义了请求和响应的数据结构。ROS2里用.srv文件来描述。

你想想看,服务通信就像你去餐厅点餐。你是客户端,厨师是服务端。你点菜(请求),厨师做好端上来(响应)。就这么简单。

重要区别:话题通信是「发布-订阅」,一对多,数据流是单向的。服务通信是「请求-响应」,一对一,数据流是双向的。选哪个?看场景。如果你需要确认对方收到了并处理完了,用服务。如果只是广播数据,用话题。

4.2 自定义服务接口

ROS2自带了一些标准服务接口,比如std_srvs/srv/Empty、std_srvs/srv/SetBool。但实际项目中,我们经常需要自定义接口。嗯,这里要注意,自定义接口是ROS2开发的基本功。

我来演示一个控制Gazebo中模型的服务接口。假设我们要控制一个机器人模型的关节位置。

首先,在功能包下创建srv文件夹,然后新建SetJointPosition.srv文件:

# 请求部分
string joint_name      # 关节名称
float64 target_position # 目标位置(弧度)
float64 duration       # 运动持续时间(秒)

---
# 响应部分
bool success           # 是否成功
string message         # 状态信息

这里---是分隔符,上面是请求,下面是响应。我个人习惯把请求字段放在前面,响应字段放在后面,这样读起来很清晰。

然后修改CMakeLists.txt,添加:

rosidl_generate_interfaces(${PROJECT_NAME}
  "srv/SetJointPosition.srv"
)

再修改package.xml,确保有:

<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

编译后,你的自定义服务接口就生成了。在代码里可以直接#include使用。

小技巧:接口文件命名建议用驼峰式,比如SetJointPosition.srv。字段命名用下划线式,比如target_position。这是ROS社区的约定俗成,跟着走不会错。

4.3 服务端节点创建

服务端节点负责接收请求,执行任务,返回响应。我以控制Gazebo中模型关节为例,写一个服务端。

#include "rclcpp/rclcpp.hpp"
#include "your_package/srv/set_joint_position.hpp"
#include "gazebo_msgs/srv/set_joint_properties.hpp"

class JointPositionServer : public rclcpp::Node
{
public:
  JointPositionServer() : Node("joint_position_server")
  {
    // 创建服务
    service_ = this->create_service<your_package::srv::SetJointPosition>(
      "set_joint_position",
      std::bind(&JointPositionServer::handle_request, this, std::placeholders::_1, std::placeholders::_2)
    );
    RCLCPP_INFO(this->get_logger(), "服务端已启动,等待请求...");
  }

private:
  rclcpp::Service<your_package::srv::SetJointPosition>::SharedPtr service_;

  void handle_request(
    const std::shared_ptr<your_package::srv::SetJointPosition::Request> request,
    std::shared_ptr<your_package::srv::SetJointPosition::Response> response)
  {
    RCLCPP_INFO(this->get_logger(), "收到请求: 关节=%s, 位置=%.2f, 时长=%.2f",
                request->joint_name.c_str(),
                request->target_position,
                request->duration);

    // 这里调用Gazebo服务来控制关节
    // 实际项目中会调用 gazebo_msgs/srv/SetJointProperties
    // 为了演示,我们直接模拟成功
    response->success = true;
    response->message = "关节 " + request->joint_name + " 已移动到 " + 
                        std::to_string(request->target_position);
  }
};

int main(int argc, char **argv)
{
  rclcpp::init(argc, argv);
  auto node = std::make_shared<JointPositionServer>();
  rclcpp::spin(node);
  rclcpp::shutdown();
  return 0;
}

这段代码里,我创建了一个名为set_joint_position的服务。当客户端调用时,handle_request函数会被触发。我在项目中遇到过一个问题:服务端处理时间太长,导致客户端超时。解决办法是把耗时操作放到另一个线程里,或者用异步服务。

4.4 客户端节点创建

客户端负责发送请求,等待响应。写起来也很直接:

#include "rclcpp/rclcpp.hpp"
#include "your_package/srv/set_joint_position.hpp"

class JointPositionClient : public rclcpp::Node
{
public:
  JointPositionClient() : Node("joint_position_client")
  {
    client_ = this->create_client<your_package::srv::SetJointPosition>("set_joint_position");
    
    // 等待服务端上线
    while (!client_->wait_for_service(std::chrono::seconds(1))) {
      RCLCPP_WARN(this->get_logger(), "等待服务端上线...");
    }

    // 构造请求
    auto request = std::make_shared<your_package::srv::SetJointPosition::Request>();
    request->joint_name = "arm_joint";
    request->target_position = 1.57;  // 90度
    request->duration = 2.0;

    // 发送请求(异步方式)
    auto future = client_->async_send_request(request);
    
    // 等待响应
    if (rclcpp::spin_until_future_complete(this->get_node_base_interface(), future) ==
        rclcpp::FutureReturnCode::SUCCESS)
    {
      auto response = future.get();
      if (response->success) {
        RCLCPP_INFO(this->get_logger(), "成功: %s", response->message.c_str());
      } else {
        RCLCPP_ERROR(this->get_logger(), "失败: %s", response->message.c_str());
      }
    } else {
      RCLCPP_ERROR(this->get_logger(), "服务调用失败");
    }
  }

private:
  rclcpp::Client<your_package::srv::SetJointPosition>::SharedPtr client_;
};

int main(int argc, char **argv)
{
  rclcpp::init(argc, argv);
  auto node = std::make_shared<JointPositionClient>();
  rclcpp::shutdown();
  return 0;
}

这里我用了async_send_request异步调用。为什么不用同步?因为同步会阻塞整个节点,导致无法处理其他事情。异步调用配合spin_until_future_complete,既不会阻塞,又能拿到结果。

避坑指南:我曾经在客户端里忘了检查wait_for_service的返回值,结果服务端还没启动,客户端就直接发请求了,程序崩溃。记住:一定要先确认服务端在线,再发请求。

4.5 Gazebo中的模型控制服务

Gazebo本身提供了很多现成的服务,用于控制模型。常用的有:

服务名称 功能 接口类型
/gazebo/set_model_state 设置模型的位置和姿态 gazebo_msgs/srv/SetModelState
/gazebo/apply_joint_effort 对关节施加力矩 gazebo_msgs/srv/ApplyJointEffort
/gazebo/set_joint_properties 设置关节属性(位置、速度等) gazebo_msgs/srv/SetJointProperties
/gazebo/spawn_urdf_model 在仿真中生成URDF模型 gazebo_msgs/srv/SpawnModel

在实际项目中,我们通常不会直接调用这些服务,而是封装一层。比如我上面写的SetJointPosition服务,内部可以调用Gazebo的/gazebo/set_joint_properties来实现关节控制。这样做的好处是:

  • 统一接口:不管底层是Gazebo还是真实硬件,对外接口不变
  • 增加逻辑:可以在服务端里加限位检查、速度规划等
  • 方便调试:可以在服务端里加日志,方便排查问题

我举个例子,在handle_request里调用Gazebo服务:

void handle_request(...)
{
  // 创建Gazebo服务客户端
  auto gazebo_client = this->create_client<gazebo_msgs::srv::SetJointProperties>(
    "/gazebo/set_joint_properties");
  
  // 等待Gazebo服务
  if (!gazebo_client->wait_for_service(std::chrono::seconds(5))) {
    response->success = false;
    response->message = "Gazebo服务不可用";
    return;
  }

  // 构造Gazebo请求
  auto gazebo_request = std::make_shared<gazebo_msgs::srv::SetJointProperties::Request>();
  gazebo_request->joint_name = request->joint_name;
  // ... 设置其他参数

  // 调用Gazebo服务
  auto future = gazebo_client->async_send_request(gazebo_request);
  // ... 处理响应
}

你看,服务端内部又调用了另一个服务。这就是服务链的概念。在复杂系统中,服务可以层层嵌套,形成调用链。

4.6 调试与测试

写完代码怎么测?我推荐几个方法:

  1. 命令行测试:用ros2 service list查看所有服务,用ros2 service call直接调用服务。比如:
    ros2 service call /set_joint_position your_package/srv/SetJointPosition "{joint_name: 'arm_joint', target_position: 1.57, duration: 2.0}"
  2. rqt工具:启动rqt,选择Services插件,图形化调用服务。
  3. 日志观察:在服务端和客户端里加RCLCPP_INFO,观察调用流程。

调试心得:我习惯在服务端里打印请求参数和响应结果,在客户端里打印调用耗时。这样一眼就能看出是服务端处理慢了,还是网络延迟了。

4.7 本章小结

服务通信是ROS2里非常实用的机制。它比话题通信更直接,适合那些需要确认结果的场景。自定义服务接口让你可以定义自己的数据结构,非常灵活。Gazebo提供的服务接口,让我们可以方便地控制仿真模型。

嗯,最后说一句:服务通信虽然好用,但别滥用。如果某个操作需要频繁调用(比如每10ms一次),那还是用话题吧。服务适合那些「偶尔调用、需要确认」的场景。

服务通信架构图 客户端节点 JointPositionClient 服务端节点 JointPositionServer 请求 (Request) joint_name, target_position, duration 响应 (Response) success, message 服务: set_joint_position 通信流程 1. 客户端构造请求 → 2. 发送请求到服务端 → 3. 服务端处理请求 4. 服务端返回响应 → 5. 客户端接收响应 → 6. 完成一次服务调用

可可精品资料库