4. 服务通信实战:客户端与服务器模型,同步/异步调用

服务通信,说白了就是ROS2里一种「一问一答」的机制。一个节点发出请求,另一个节点处理并返回结果。这跟话题通信那种「一直广播、谁爱听谁听」的模式完全不同。

我个人习惯把服务通信比作「打电话」。你拨号(发请求),对方接听并回答(返回响应),然后挂断。而话题通信更像是「听收音机」——你打开就能听,但没法跟电台对话。

核心要点:服务通信适用于需要即时反馈的场景,比如控制机器人抓取物体、查询传感器状态、触发某个动作。

4.1 服务通信的底层逻辑

先看一张图,帮你快速建立整体认知:

客户端节点 /nav2_client 服务端节点 /nav2_server 请求 (Request) 响应 (Response) /navigate_to_pose 服务名称:客户端通过此名称找到服务端

嗯,这张图其实已经说清楚了。客户端发请求,服务端处理完返回结果。整个过程是同步的——客户端发完请求后,会一直等着服务端回复。

4.2 服务接口的定义

在ROS2里,服务接口用 .srv 文件定义。我刚开始学的时候,总把它跟 .msg 搞混。其实区别很简单:.srv 文件里用 --- 分隔了请求和响应两部分。

举个例子,Nav2里常用的 NavigateToPose.srv:

# 请求部分
geometry_msgs/PoseStamped pose
bool use_start_pose
geometry_msgs/PoseStamped start_pose

---
# 响应部分
std_msgs/Empty result

你看,--- 上面是客户端要发的内容,下面是服务端返回的内容。这个结构非常清晰。

我的经验:写服务接口时,请求字段别太多。我曾经在一个项目里塞了十几个字段,结果调试时自己都记不清顺序。保持简洁,3-5个字段最合适。

4.3 服务端实现

服务端的任务很简单:注册一个服务,然后等着被调用。来看一个实际例子:

# server.py
import rclpy
from rclpy.node import Node
from nav2_msgs.srv import NavigateToPose

class Nav2Server(Node):
    def __init__(self):
        super().__init__('nav2_server')
        self.srv = self.create_service(
            NavigateToPose,           # 服务类型
            '/navigate_to_pose',      # 服务名称
            self.handle_request       # 回调函数
        )
        self.get_logger().info('服务端已启动,等待请求...')

    def handle_request(self, request, response):
        self.get_logger().info(f'收到导航请求:{request.pose}')
        
        # 这里执行实际的导航逻辑
        # 比如调用路径规划、控制底盘等
        
        self.get_logger().info('导航完成')
        return response

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

这里要注意的是 create_service 的三个参数。我见过不少新手把服务类型和服务名称搞反。服务类型是 NavigateToPose 这个类,服务名称是字符串 '/navigate_to_pose'。

4.4 客户端实现:同步调用

同步调用是最直接的方式。客户端发请求,然后阻塞等待结果。说白了就是「你不回我,我就不走」。

# client_sync.py
import rclpy
from rclpy.node import Node
from nav2_msgs.srv import NavigateToPose

class Nav2ClientSync(Node):
    def __init__(self):
        super().__init__('nav2_client_sync')
        self.cli = self.create_client(
            NavigateToPose,
            '/navigate_to_pose'
        )
        
        # 等待服务端就绪
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().warn('等待服务端启动...')
        
        self.get_logger().info('服务端已就绪')

    def send_request(self):
        req = NavigateToPose.Request()
        req.pose.header.frame_id = 'map'
        req.pose.pose.position.x = 2.0
        req.pose.pose.position.y = 1.0
        
        # 同步调用,会阻塞
        future = self.cli.call_async(req)
        rclpy.spin_until_future_complete(self, future)
        
        if future.result() is not None:
            self.get_logger().info('导航请求成功')
            return future.result()
        else:
            self.get_logger().error('导航请求失败')
            return None

嗯,这里有个坑。虽然叫 call_async,但配合 spin_until_future_complete 用,效果就是同步的。为什么会这样?因为 spin_until_future_complete 会一直阻塞,直到 future 完成。

我曾经踩过的坑:在回调函数里直接调用同步服务。这会导致死锁!因为回调函数本身就在 spin 线程里,你再 spin 一次就卡死了。解决方案是用异步调用,或者把服务调用放到另一个线程。

4.5 客户端实现:异步调用

异步调用更适合在回调函数或循环中使用。它不会阻塞主线程,而是通过回调来获取结果。

# client_async.py
import rclpy
from rclpy.node import Node
from nav2_msgs.srv import NavigateToPose

class Nav2ClientAsync(Node):
    def __init__(self):
        super().__init__('nav2_client_async')
        self.cli = self.create_client(
            NavigateToPose,
            '/navigate_to_pose'
        )
        
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().warn('等待服务端启动...')
        
        self.timer = self.create_timer(5.0, self.timer_callback)

    def timer_callback(self):
        req = NavigateToPose.Request()
        req.pose.header.frame_id = 'map'
        req.pose.pose.position.x = 3.0
        req.pose.pose.position.y = 2.0
        
        # 异步调用,不阻塞
        future = self.cli.call_async(req)
        future.add_done_callback(self.response_callback)
        
        self.get_logger().info('请求已发送,继续执行其他任务')

    def response_callback(self, future):
        try:
            response = future.result()
            self.get_logger().info('收到服务端响应')
        except Exception as e:
            self.get_logger().error(f'服务调用异常:{e}')

你看,异步调用的核心是 add_done_callback。请求发出去后,程序继续往下走。等服务端返回结果时,回调函数会被自动触发。

4.6 同步 vs 异步:怎么选?

我整理了一个对比表,帮你快速决策:

对比维度 同步调用 异步调用
代码复杂度 简单,线性逻辑 稍复杂,需要回调
阻塞行为 阻塞等待 非阻塞
适用场景 初始化、单次请求 循环、回调、高并发
调试难度 容易,堆栈清晰 较难,回调链复杂
性能影响 可能阻塞主循环 不影响主循环

我个人习惯是:初始化阶段用同步,运行阶段用异步。比如机器人启动时,用同步调用确认各个服务是否就绪。正常运行时,用异步调用发送导航指令,不影响其他任务。

4.7 实战:Nav2中的服务调用

在Nav2里,最常用的服务就是 /navigate_to_pose。我拿一个实际项目中的代码片段来说明:

class RobotController(Node):
    def __init__(self):
        super().__init__('robot_controller')
        self.nav_client = self.create_client(
            NavigateToPose,
            '/navigate_to_pose'
        )
        
        # 等待Nav2就绪
        self.get_logger().info('等待Nav2服务就绪...')
        self.nav_client.wait_for_service()
        self.get_logger().info('Nav2已就绪')

    def go_to_pose(self, x, y, theta):
        """发送导航目标点"""
        req = NavigateToPose.Request()
        req.pose.header.stamp = self.get_clock().now().to_msg()
        req.pose.header.frame_id = 'map'
        req.pose.pose.position.x = x
        req.pose.pose.position.y = y
        
        # 使用四元数表示朝向
        from tf_transformations import quaternion_from_euler
        q = quaternion_from_euler(0, 0, theta)
        req.pose.pose.orientation.x = q[0]
        req.pose.pose.orientation.y = q[1]
        req.pose.pose.orientation.z = q[2]
        req.pose.pose.orientation.w = q[3]
        
        # 异步调用
        future = self.nav_client.call_async(req)
        future.add_done_callback(self.nav_done_callback)
        
        self.get_logger().info(f'发送导航目标:({x:.2f}, {y:.2f}, {theta:.2f})')

    def nav_done_callback(self, future):
        try:
            result = future.result()
            self.get_logger().info('导航完成')
        except Exception as e:
            self.get_logger().error(f'导航失败:{e}')

避坑指南:我曾经在发送导航请求时忘了设置时间戳 header.stamp,结果Nav2一直报错说「目标点过期」。后来才发现,Nav2会检查时间戳,如果跟当前时间差太多,就认为目标点无效。所以记得用 self.get_clock().now().to_msg() 设置当前时间。

4.8 调试技巧

服务通信出问题时,别慌。我一般按这个顺序排查:

  1. 检查服务是否注册:用 ros2 service list 查看所有服务
  2. 检查服务类型:用 ros2 service type /service_name 确认类型匹配
  3. 手动调用测试:用 ros2 service call /service_name type "{...}" 测试
  4. 查看日志:设置 RCLCPP_LOG_LEVEL=DEBUG 看详细输出

嗯,说到手动调用,这个命令特别实用。比如测试导航服务:

ros2 service call /navigate_to_pose nav2_msgs/srv/NavigateToPose "{pose: {header: {frame_id: 'map'}, pose: {position: {x: 1.0, y: 2.0}}}}"

如果这个命令能正常工作,说明服务端没问题。问题大概率出在客户端代码上。

服务通信这块,说白了就是「请求-响应」模型。同步调用简单直接,异步调用灵活高效。你在实际项目中,根据场景选择合适的模式就好。记住我前面说的那个死锁的坑,还有时间戳的问题,基本就能顺利跑通了。