4、ROS2通信机制(二):服务通信:Client与Server编程实战,自定义服务接口

好,咱们接着聊。上一章我们把话题通信聊透了,那是一种「发布-订阅」的广播模式。但实际工程里,你总会遇到这样的场景:我需要一个请求,然后等一个确切的回复。比如,我让机器人去抓一个杯子,它抓到了没有?抓成功了还是失败了?这时候话题通信就不太够用了——它只管发,不管回。

这就引出了我们今天的主角:服务通信(Service)。说白了,它是一种「一问一答」的模式。客户端(Client)发一个请求,服务端(Server)处理完,返回一个响应。非常像我们写程序时调一个函数,传参进去,拿返回值出来。

核心区别一句话总结:话题是「广播」,服务是「远程过程调用(RPC)」。

4.1 服务通信的底层逻辑

ROS2的服务通信底层用的是 请求-响应模型。客户端发送一个请求消息,服务端处理完后,返回一个响应消息。这个过程中,客户端会阻塞等待,直到收到响应或超时。

我在项目中遇到过一个问题:刚开始用服务时,总觉得它和话题差不多,只是多了个返回值。但实际用起来,服务的实时性要求更高。你想想看,如果服务端处理慢了,客户端那边就一直卡着,整个系统都可能被拖垮。所以,服务一般用于低频、确定性高的操作,比如「启动导航」、「查询状态」、「设置参数」。高频的传感器数据,还是老老实实用话题。

我的习惯:凡是需要「确认结果」的操作,我都用服务。凡是「只管发,不管结果」的,都用话题。这个原则帮我避免了很多坑。

4.2 自定义服务接口(.srv文件)

ROS2的服务接口定义在 .srv 文件中。它的格式很简单:

# 请求部分
---
# 响应部分

中间用三个横线 --- 隔开。上面是请求,下面是响应。

举个例子,我们做一个「计算矩形面积」的服务:

# RectangleArea.srv
float64 width
float64 height
---
float64 area

嗯,这里要注意:字段类型必须是ROS2支持的基本类型,比如 int32、float64、string、bool 等。也可以嵌套其他消息类型,但一般不建议搞得太复杂。

定义好 .srv 文件后,需要在 CMakeLists.txt 或 setup.py 中注册,然后编译。编译完成后,ROS2会自动生成对应的Python或C++代码。你可以在代码里直接 import 或 #include 使用。

我曾经踩过的坑:有一次我改了 .srv 文件,但忘了重新编译,结果客户端和服务端一直对不上号,报了一堆类型不匹配的错误。所以,每次修改接口文件后,一定要重新编译整个包。

4.3 服务端(Server)编程实战

我们以Python为例,写一个计算矩形面积的服务端。

import rclpy
from rclpy.node import Node
from example_interfaces.srv import AddTwoInts  # 这里用ROS2自带的示例接口

class AreaServer(Node):
    def __init__(self):
        super().__init__('area_server')
        self.srv = self.create_service(AddTwoInts, 'compute_area', self.compute_area_callback)
        self.get_logger().info('服务端已启动,等待请求...')

    def compute_area_callback(self, request, response):
        # 计算面积:宽 * 高
        response.sum = request.a * request.b
        self.get_logger().info(f'收到请求: {request.a} x {request.b} = {response.sum}')
        return response

def main(args=None):
    rclpy.init(args=args)
    node = AreaServer()
    rclpy.spin(node)
    rclpy.shutdown()

if __name__ == '__main__':
    main()

这段代码的核心是 create_service 方法。它需要三个参数:

  • 服务类型:比如 AddTwoInts,这是ROS2自带的示例接口。
  • 服务名称:字符串,比如 'compute_area'。客户端和服务端必须用同一个名字。
  • 回调函数:当收到请求时,ROS2会调用这个函数。函数的参数是 request 和 response,你需要在函数里填充 response 并返回。

我个人习惯:回调函数的名字用 xxx_callback 的格式,这样一看就知道是回调。另外,回调函数里不要做耗时操作,否则会阻塞其他请求。如果确实需要耗时处理,可以开一个线程。

4.4 客户端(Client)编程实战

客户端稍微复杂一点,因为它需要等待服务端上线,然后发送请求,再等待响应。

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

class AreaClient(Node):
    def __init__(self):
        super().__init__('area_client')
        self.cli = self.create_client(AddTwoInts, 'compute_area')
        # 等待服务端上线
        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
        # 异步发送请求,返回一个Future对象
        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 = AreaClient()
    # 从命令行参数获取宽和高
    a = int(sys.argv[1])
    b = int(sys.argv[2])
    response = node.send_request(a, b)
    node.get_logger().info(f'计算结果: {a} x {b} = {response.sum}')
    node.destroy_node()
    rclpy.shutdown()

if __name__ == '__main__':
    main()

这里有几个关键点:

  • wait_for_service:客户端启动时,服务端可能还没起来。这个函数会阻塞等待,直到服务端上线。超时时间可以自己设,我一般设1秒。
  • call_async:ROS2的服务调用是异步的。它返回一个 Future 对象,你可以用 spin_until_future_complete 等待它完成。这样不会阻塞整个节点。
  • spin_until_future_complete:这个函数会一直旋转节点,直到Future完成。说白了,就是「等结果」。

我曾经踩过的坑:有一次我忘了调用 wait_for_service,直接发请求,结果程序崩溃了。因为服务端还没启动,客户端找不到服务。所以,发送请求前一定要确认服务端在线。

4.5 服务通信的完整流程

为了让你更直观地理解,我画了一张流程图:

客户端 (Client) 发送请求 服务端 (Server) 处理请求 请求 (Request) 响应 (Response) 等待响应 处理请求 ROS2 服务通信流程 客户端发送请求 → 服务端处理 → 返回响应 → 客户端继续执行

你看,流程其实很简单。客户端发请求,服务端处理,然后返回结果。但实际工程中,服务通信的可靠性比话题高,因为它有明确的请求-响应配对。如果服务端挂了,客户端会收到超时错误,你可以做相应的处理。

4.6 服务通信 vs 话题通信

我整理了一个对比表,方便你快速区分:

特性 话题通信 服务通信
通信模式 发布-订阅(广播) 请求-响应(RPC)
数据流向 单向,发布者→订阅者 双向,客户端→服务端→客户端
是否阻塞 不阻塞,异步 客户端会阻塞等待响应
适用场景 高频数据流(传感器、状态) 低频操作(查询、设置、触发)
可靠性 尽力而为(QoS可调) 高,有明确的成功/失败反馈
接口定义 .msg 文件 .srv 文件

一句话总结:话题是「喊一嗓子,谁爱听谁听」;服务是「我问你答,没答就再问」。

4.7 实战中的避坑指南

最后,分享几个我在项目中积累的经验:

  • 服务名不要重复:同一个ROS2网络中,服务名必须唯一。如果两个节点注册了同一个服务名,后注册的会覆盖先注册的,容易出问题。
  • 回调函数要快:服务端的回调函数是在ROS2的线程池里执行的。如果回调函数耗时太长,会阻塞其他服务的处理。我一般把耗时操作放到单独的线程里。
  • 超时处理:客户端调用服务时,一定要设置超时。否则服务端挂了,客户端会一直卡着。ROS2的 wait_for_service 和 spin_until_future_complete 都支持超时参数。
  • 接口版本管理:如果你改了 .srv 文件,记得更新版本号。否则旧客户端和新服务端可能不兼容。我习惯在接口文件里加一个注释,记录修改日期和原因。

我的一个小技巧:调试服务通信时,可以用 ros2 service list 查看所有服务,用 ros2 service call 手动调用服务。这样不用写代码就能测试服务端是否正常。

好了,服务通信就聊到这里。你想想看,有了话题和服务这两种通信方式,ROS2里大部分的场景都能覆盖了。下一章我们聊聊另一种更灵活的通信方式——动作(Action),它结合了话题和服务的优点,特别适合长时间的任务。

可可精品资料库