4、ROS2基础通信机制(下):服务与动作编程实战

上一章我们把话题通信聊透了,那种“发布-订阅”的模式,说白了就是机器人里的广播体操——一个人喊口令,一群人跟着做。但实际干活的时候,你总会遇到需要“一问一答”的场景。

比如你让机械臂抓个杯子,你不仅得告诉它“去抓”,还得知道它“抓到没有”。这时候,话题就不够用了。我们需要的是服务(Service)和动作(Action)。

4.1 服务通信:客户端与服务器模型

服务的本质很简单:一个节点发请求,另一个节点回响应。就像你去柜台办事,递上材料(请求),工作人员办完给你回执(响应)。

我个人习惯把服务端叫做“干活的”,客户端叫做“派活的”。干活的一次只接一个单,派活的发完请求就等着,直到拿到结果才继续往下走。

4.1.1 服务的数据接口定义

在ROS2里,服务接口用 .srv 文件定义。它分上下两部分,用 --- 隔开。上面是请求,下面是响应。

举个例子,我们做一个“加法计算器”的服务:

# AddTwoInts.srv
int64 a
int64 b
---
int64 sum

嗯,这里要注意:--- 是必须的,少写了编译会报错。我在项目中遇到过新手把 --- 写成 --- 的变体,结果卡了半天。

4.1.2 服务端编程实战

服务端的任务很简单:注册服务,等待请求,处理并返回。

来看代码:

# server.py
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts

class AddServer(Node):
    def __init__(self):
        super().__init__('add_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)
    node = AddServer()
    rclpy.spin(node)
    rclpy.shutdown()

你想想看,回调函数 add_callback 里,request 就是客户端发来的数据,response 就是我们要返回的结果。这里有个坑:回调函数必须返回 response 对象,少写了服务端会卡死。

4.1.3 客户端编程实战

客户端稍微复杂一点,因为它需要“等待服务上线”。

# client.py
import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts

class AddClient(Node):
    def __init__(self):
        super().__init__('add_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)
    node = AddClient()
    response = node.send_request(3, 5)
    node.get_logger().info(f'计算结果: {response.sum}')
    node.destroy_node()
    rclpy.shutdown()
避坑指南: 我曾经在客户端忘记调用 wait_for_service,结果程序直接崩溃。记住:客户端必须先确认服务端活着,才能发请求。另外,call_async 是异步调用,如果你想要同步效果,就用 spin_until_future_complete 等着。

4.2 动作通信:让机器人“边干边汇报”

服务的缺点很明显:如果任务耗时很长(比如导航到某个点),客户端就得一直等着,中间发生了什么完全不知道。这时候,动作(Action) 就派上用场了。

动作可以理解为“带反馈的服务”。它有三个核心部分:

  • Goal:目标,告诉机器人要做什么
  • Feedback:反馈,机器人干活过程中不断汇报进度
  • Result:结果,任务完成后的最终状态

4.2.1 动作接口定义

动作接口用 .action 文件定义,分三块,用 --- 隔开:

# MoveRobot.action
# 目标
geometry_msgs/Pose2D target_pose
---
# 结果
bool success
string message
---
# 反馈
float32 progress
string status_description

我个人觉得,动作接口的设计有点像“项目管理的三个阶段”:定目标、给反馈、看结果。你想想看,如果导航过程中没有反馈,你只能干等着,多难受。

4.2.2 动作服务端编程

动作服务端比普通服务端复杂,因为它要处理“取消请求”和“周期反馈”。

# action_server.py
import rclpy
from rclpy.action import ActionServer
from rclpy.node import Node
from your_package.action import MoveRobot

class MoveRobotServer(Node):
    def __init__(self):
        super().__init__('move_robot_server')
        self._action_server = ActionServer(
            self,
            MoveRobot,
            'move_robot',
            self.execute_callback)

    async def execute_callback(self, goal_handle):
        self.get_logger().info(f'收到目标: {goal_handle.request.target_pose}')
        feedback_msg = MoveRobot.Feedback()
        
        for i in range(10):
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                return MoveRobot.Result(success=False, message='任务被取消')
            
            feedback_msg.progress = (i + 1) * 10.0
            feedback_msg.status_description = f'正在移动... {feedback_msg.progress}%'
            goal_handle.publish_feedback(feedback_msg)
            self.get_logger().info(feedback_msg.status_description)
            rclpy.spin_once(self, timeout_sec=1.0)
        
        goal_handle.succeed()
        return MoveRobot.Result(success=True, message='移动完成')
我的经验: 动作服务端里一定要检查 goal_handle.is_cancel_requested。我在项目中遇到过机器人正在移动,用户突然取消任务,如果不检查这个标志,机器人会一直跑到终点才停下来,那体验就太差了。

4.2.3 动作客户端编程

动作客户端要处理三个回调:反馈回调、完成回调。

# action_client.py
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node
from your_package.action import MoveRobot

class MoveRobotClient(Node):
    def __init__(self):
        super().__init__('move_robot_client')
        self._action_client = ActionClient(self, MoveRobot, 'move_robot')

    def send_goal(self, target_pose):
        goal_msg = MoveRobot.Goal()
        goal_msg.target_pose = target_pose
        
        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):
        self.get_logger().info(f'进度: {feedback_msg.feedback.progress}% - {feedback_msg.feedback.status_description}')

    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.message}')

嗯,这里要注意:反馈回调是在子线程里执行的,不要在反馈回调里做耗时操作,否则会影响通信性能。

4.3 服务与动作的核心区别

我整理了一张表,方便你对比:

特性 服务(Service) 动作(Action)
通信模式 同步请求-响应 异步目标-反馈-结果
适用场景 短耗时任务(<1秒) 长耗时任务(>1秒)
是否支持取消 否 是
是否有进度反馈 否 是
接口文件后缀 .srv .action

说白了,服务适合“问个事”,动作适合“干个活”。比如获取当前温度用服务,导航到某个点用动作。

4.4 本章知识体系图

下面这张图展示了服务与动作在ROS2通信中的位置和关系:

ROS2通信机制:服务与动作 服务(Service) 客户端 服务端 请求 响应 特点:同步、短耗时、无反馈 动作(Action) 动作客户端 动作服务端 目标 反馈 结果 特点:异步、长耗时、有反馈 选择建议 • 任务耗时 < 1秒,不需要中途取消 → 用服务 • 任务耗时 > 1秒,需要进度反馈 → 用动作 • 需要用户随时取消任务 → 用动作 • 只需要一次问答,不需要中间状态 → 用服务 Nav2中:获取地图用服务,导航到目标用动作
核心总结: 服务是“一问一答”,动作是“边干边报”。在Nav2中,服务用于获取静态信息(如地图数据),动作用于执行导航任务。记住这个原则,你在实际开发中就不会选错。