3、ROS2核心通信机制(下):服务通信模型、客户端与服务器编程、自定义服务接口、动作通信模型、动作服务器与客户端编程

好,咱们接着聊ROS2的通信机制。上一章我们把话题通信聊透了,这一章我重点讲讲服务和动作。说实话,在我刚接触ROS2那会儿,总觉得话题通信够用了,干嘛还要搞服务和动作?后来在项目里踩了几次坑才明白——不同的通信模式,对应的是不同的应用场景。你想想看,如果所有通信都用话题,那有些场景反而会变得很别扭。

3.1 服务通信模型:一问一答的默契

服务通信,说白了就是客户端发一个请求,服务器回一个响应。这种模式在工程里太常见了。比如你让机器人去抓一个杯子,你问它「抓到了吗」,它回答「抓到了」或者「没抓到」。这就是典型的服务通信。

我个人习惯把服务通信比作「打电话」——你拨号过去,对方接起来,你们完成一次对话,然后挂断。而话题通信更像是「微信群聊」——你发一条消息,所有在群里的人都能看到,但没人保证一定会回复你。

服务通信的核心特点:

  • 同步通信:客户端发送请求后会阻塞等待,直到收到响应
  • 一对一:一个服务只能被一个客户端调用(但可以有多个客户端同时请求)
  • 有反馈:调用方明确知道结果
  • 适合短任务:比如查询状态、触发一次性动作

我在项目中遇到过一个问题:用服务通信去控制机械臂走一段复杂的轨迹。结果客户端一直卡在那里等响应,机械臂走了十几秒,客户端就阻塞了十几秒。后来我才意识到,这种长时间的任务根本不适合用服务——你应该用动作通信。

3.2 客户端与服务器编程:手把手写一个加法服务

咱们直接上代码。先写一个服务端,实现两个整数相加的功能。

服务端代码(add_two_ints_server.py):

import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts

class AddTwoIntsServer(Node):
    def __init__(self):
        super().__init__('add_two_ints_server')
        self.srv = self.create_service(AddTwoInts, 'add_two_ints', self.add_callback)
        self.get_logger().info('服务端已启动,等待请求...')

    def add_callback(self, request, response):
        response.sum = request.a + request.b
        self.get_logger().info(f'收到请求: {request.a} + {request.b} = {response.sum}')
        return response

def main(args=None):
    rclpy.init(args=args)
    server = AddTwoIntsServer()
    rclpy.spin(server)
    server.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

客户端代码(add_two_ints_client.py):

import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts
import sys

class AddTwoIntsClient(Node):
    def __init__(self):
        super().__init__('add_two_ints_client')
        self.cli = self.create_client(AddTwoInts, 'add_two_ints')
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().warn('等待服务端上线...')
        self.req = AddTwoInts.Request()

    def send_request(self, a, b):
        self.req.a = a
        self.req.b = b
        self.future = self.cli.call_async(self.req)
        rclpy.spin_until_future_complete(self, self.future)
        return self.future.result()

def main(args=None):
    rclpy.init(args=args)
    client = AddTwoIntsClient()
    a = int(sys.argv[1]) if len(sys.argv) > 1 else 3
    b = int(sys.argv[2]) if len(sys.argv) > 2 else 5
    response = client.send_request(a, b)
    client.get_logger().info(f'结果: {a} + {b} = {response.sum}')
    client.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

小提示:注意客户端里用了 wait_for_service 来等待服务端上线。我曾经在调试时忘了加这个,结果客户端一启动就报错——服务端还没跑起来呢。这个等待机制是必须的,别偷懒。

3.3 自定义服务接口:打造你自己的通信协议

用现成的 AddTwoInts 当然方便,但实际项目中你肯定需要自定义接口。比如你要控制机器人抓取物体,请求里需要包含目标位置、抓取力度、超时时间等信息。

自定义服务接口的步骤很简单:

  1. 在功能包下创建 srv 文件夹
  2. 编写 .srv 文件
  3. 修改 CMakeLists.txtpackage.xml
  4. 编译生成代码

举个例子,创建一个抓取服务的接口:

# GraspObject.srv
# 请求部分
string object_name      # 目标物体名称
float32 grasp_force     # 抓取力度(牛顿)
float32 timeout         # 超时时间(秒)
---
# 响应部分
bool success            # 是否成功
string message          # 结果描述
float32 actual_force    # 实际施加的力度

这里有个细节要注意:--- 上面是请求,下面是响应。中间用三个短横线隔开。我刚开始写的时候老忘记这个分隔符,编译一直报错,查了半天才发现是格式问题。

避坑指南:我曾经在自定义接口里用了 int 类型,结果编译报错说找不到。ROS2的接口类型必须用 int32float64 这种带位数的写法。别问我怎么知道的——翻了好久的文档才找到原因。

3.4 动作通信模型:给任务一个「进度条」

好,现在聊聊动作通信。为什么需要它?你想想看,如果你让机器人从A点走到B点,这个过程可能需要几十秒甚至几分钟。用话题通信?你得自己维护状态机。用服务通信?客户端会一直阻塞。这时候动作通信就派上用场了。

动作通信的核心是三个部分:

  • 目标(Goal):你要让机器人做什么
  • 反馈(Feedback):机器人做到哪一步了
  • 结果(Result):最终做完了没有

我个人觉得,动作通信最妙的地方在于它允许你「中途取消」。比如你让机器人去拿杯子,走到一半你发现杯子其实在另一个地方,你可以直接取消当前动作,重新发一个目标。这在话题和服务里实现起来都很麻烦。

3.5 动作服务器与客户端编程:让机器人走个正方形

咱们写一个动作服务器,控制机器人走一个正方形的轨迹。这里用 nav2_msgs/action/NavigateToPose 作为例子,但为了演示,我们自己定义一个简单的动作接口。

先定义动作接口(SquarePath.action):

# 目标
float32 side_length    # 正方形边长(米)
---
# 结果
bool success
float32 total_distance # 总行走距离
---
# 反馈
float32 current_x      # 当前位置x
float32 current_y      # 当前位置y
float32 progress       # 进度百分比(0-100)

动作服务器端(简化版):

import rclpy
from rclpy.action import ActionServer
from rclpy.node import Node
from your_package.action import SquarePath

class SquarePathServer(Node):
    def __init__(self):
        super().__init__('square_path_server')
        self._action_server = ActionServer(
            self,
            SquarePath,
            'square_path',
            self.execute_callback)

    async def execute_callback(self, goal_handle):
        self.get_logger().info(f'收到目标: 边长 {goal_handle.request.side_length}米')
        feedback_msg = SquarePath.Feedback()
        
        # 模拟走正方形的四个边
        for i in range(4):
            # 模拟移动
            for step in range(25):
                feedback_msg.current_x = step * 0.04
                feedback_msg.current_y = 0.0
                feedback_msg.progress = (i * 25 + step)
                goal_handle.publish_feedback(feedback_msg)
                await asyncio.sleep(0.1)
            
            # 检查是否被取消
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                return SquarePath.Result(success=False, total_distance=0.0)
        
        goal_handle.succeed()
        result = SquarePath.Result()
        result.success = True
        result.total_distance = goal_handle.request.side_length * 4
        return result

动作客户端:

import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from your_package.action import SquarePath

class SquarePathClient(Node):
    def __init__(self):
        super().__init__('square_path_client')
        self._action_client = ActionClient(self, SquarePath, 'square_path')

    def send_goal(self, side_length):
        goal_msg = SquarePath.Goal()
        goal_msg.side_length = side_length
        
        self._action_client.wait_for_server()
        self._send_goal_future = self._action_client.send_goal_async(
            goal_msg,
            feedback_callback=self.feedback_callback)
        self._send_goal_future.add_done_callback(self.goal_response_callback)

    def feedback_callback(self, feedback_msg):
        fb = feedback_msg.feedback
        self.get_logger().info(f'进度: {fb.progress}%, 位置: ({fb.current_x:.2f}, {fb.current_y:.2f})')

    def goal_response_callback(self, future):
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().info('目标被拒绝')
            return
        self.get_logger().info('目标被接受')
        self._get_result_future = goal_handle.get_result_async()
        self._get_result_future.add_done_callback(self.get_result_callback)

    def get_result_callback(self, future):
        result = future.result().result
        self.get_logger().info(f'结果: 成功={result.success}, 总距离={result.total_distance:.2f}米')

关键点总结:

  • 动作服务器用 execute_callback 处理目标,可以异步执行
  • 客户端通过 feedback_callback 实时获取进度
  • 支持取消操作,服务器端要检查 is_cancel_requested
  • 动作通信适合长时间任务,比如导航、机械臂运动

嗯,这里要注意一个细节:动作通信的反馈是「可选的」。也就是说,你可以不发反馈,只返回最终结果。但我在实际项目中强烈建议你加上反馈——调试的时候太有用了。有一次机器人导航卡在半路,我一看反馈信息,发现进度停在37%不动了,立刻就知道是路径规划出了问题。

好了,这一章的内容就到这里。服务通信和动作通信是ROS2里非常重要的两个机制,尤其是动作通信,在机器人运动控制中几乎是标配。下一章我们聊聊参数服务器和生命周期节点,这两个东西在工程里也经常用到。