ROS 2 服务端(Service Server)与客户端(Service Client)
ROS 2 服务端(Service Server)与客户端(Service Client)
ROS 2 中节点间通信除了异步的 话题(Topic) 模式,还有一种同步的 服务 / 客户端(Service) 模式。本文带你理解它的原理,并用 Python 从零写出一个最小可运行的服务端与客户端。
一、什么是服务端与客户端?
在 ROS 2 中,话题适合”一个发、多个收”的异步数据流;而服务适合”请求一次、得到一次结果”的同步问答式通信:
- 服务端(Service Server):接收客户端的请求,处理并返回响应。
- 客户端(Service Client):发起请求,然后阻塞等待服务端的响应。
sequenceDiagram
participant C as 客户端节点<br/>minimal_client
participant S as 服务端节点<br/>minimal_service
C->>S: 请求 request (a=41, b=1)
Note over S: 处理请求<br/>sum = a + b
S-->>C: 响应 response (sum=42)
这种模式的几个关键特点:
| 特点 | 说明 |
|---|---|
| 同步 | 客户端发出请求后要等待服务端返回结果,是”一问一答” |
| 一对一 | 一次请求对应一次响应,且同一时刻只有一个客户端能调用某个服务 |
| 有始有终 | 请求与响应各有一个类型定义(Request 与 Response),成对出现 |
| 面向调用 | 适合”查询状态、触发动作并拿结果”的场景,如让机器人做个动作、读取传感器数值 |
打个比方:服务就像餐厅点餐。你(客户端)叫来服务员下单(请求),服务员把菜做好端上来(响应),你拿到菜后才离开。而话题则像广播电台,电台只管播,听众随时听,双方互不等待。
何时用话题、何时用服务?持续流动的数据(传感器流、状态流)用话题;“调用一次、等待结果”(一次性查询、一次性指令)用服务。
二、准备工作
本文基于 ROS 2 Jazzy + Python 3,假设你的环境已经配置好:
# 检查 ROS 2 是否可用
printenv ROS_DISTRO # 应输出 jazzy
# 每次打开终端都要 source 环境(也可写入 ~/.bashrc)
source /opt/ros/jazzy/setup.bash
source ~/ros_ws/install/setup.bash
本文使用官方标准服务接口 example_interfaces/srv/AddTwoInts(接收两个整数,返回它们的和),无需自定义接口,是最省事的入门方式。
三、最小代码样例
下面是最精简的服务端 / 客户端,使用标准接口 example_interfaces/srv/AddTwoInts,服务名为 add_two_ints。
3.1 服务端 minimal_service.py
import rclpy # ROS 2 Python 客户端库
from rclpy.node import Node # 节点基类
from example_interfaces.srv import AddTwoInts # 服务接口类型(请求/响应的数据结构)
class MinimalService(Node):
"""服务端节点:提供 add_two_ints 服务,接收两个整数,返回它们的和"""
def __init__(self):
super().__init__('minimal_service') # 节点名称(ros2 node list 可见)
self.srv = self.create_service( # 创建服务端
AddTwoInts, # 服务接口类型(决定请求/响应各有哪些字段)
'add_two_ints', # 服务名称(客户端必须用同名调用)
self.add_two_ints_callback) # 收到请求时调用的回调函数
def add_two_ints_callback(self, request, response):
"""处理请求并返回响应。
request 是客户端发来的数据(字段 a、b)
response 是要回传给客户端的结果(字段 sum)"""
response.sum = request.a + request.b # 取两个整数相加,填入响应字段
self.get_logger().info(
f'Incoming request: a={request.a}, b={request.b} -> sum={response.sum}')
return response # 必须返回 response,否则客户端收不到结果
def main(args=None):
rclpy.init(args=args) # 1. 初始化 rclpy(每个进程必须调用一次)
node = MinimalService() # 2. 创建服务端节点(此时服务已注册)
rclpy.spin(node) # 3. 阻塞运行:一直监听并处理客户端请求
node.destroy_node() # 4. 清理:销毁节点(Ctrl+C 退出后执行)
rclpy.shutdown()
if __name__ == '__main__':
main()
3.2 客户端 minimal_client.py
import sys # 读取命令行参数
import rclpy # ROS 2 Python 客户端库
from rclpy.node import Node # 节点基类
from example_interfaces.srv import AddTwoInts # 服务接口类型(请求/响应的数据结构)
class MinimalClient(Node):
"""客户端节点:调用 add_two_ints 服务,计算 a + b"""
def __init__(self):
super().__init__('minimal_client') # 节点名称(ros2 node list 可见)
self.cli = self.create_client( # 创建客户端
AddTwoInts, # 服务接口类型(与服务端保持一致)
'add_two_ints') # 服务名称(与服务端保持一致)
# 等待服务端就绪:每秒检查一次,没等到就一直等(先启动客户端也不会报错)
while not self.cli.wait_for_service(timeout_sec=1.0):
self.get_logger().info('service not available, waiting again...')
def call_service(self, a, b):
# 构造请求对象并给字段赋值(a、b 由调用方传入)
req = AddTwoInts.Request()
req.a = a
req.b = b
# 异步发起请求:不阻塞主线程,返回一个 Future 对象,之后轮询它是否完成
future = self.cli.call_async(req)
# 阻塞等待请求完成:等待期间仍会处理节点回调
rclpy.spin_until_future_complete(self, future)
if future.result() is not None: # 成功拿到响应
self.get_logger().info(
f'Result: {req.a} + {req.b} = {future.result().sum}')
else: # 调用失败(如服务端异常/超时)
self.get_logger().error('Service call failed')
def main(args=None):
rclpy.init(args=args) # 1. 初始化 rclpy
# 2. 解析命令行参数:a、b 取 sys.argv 前两个位置参数(缺省为 0)
a = int(sys.argv[1]) if len(sys.argv) > 1 else 0
b = int(sys.argv[2]) if len(sys.argv) > 2 else 0
node = MinimalClient() # 3. 创建客户端节点(内部已等待服务就绪)
node.call_service(a, b) # 4. 发起调用并打印结果
node.destroy_node() # 5. 清理
rclpy.shutdown()
if __name__ == '__main__':
main()
3.3 代码要点解读
| 代码 | 作用 |
|---|---|
rclpy.init() |
初始化 ROS 2 客户端库,每个进程必须调用一次 |
create_service(Type, name, cb) |
创建服务端:接口类型 / 服务名 / 回调。回调返回 response |
create_client(Type, name) |
创建客户端:接口类型 / 服务名 |
wait_for_service(秒) |
阻塞等待服务端上线,返回 bool,用于客户端先于服务端启动的场景 |
AddTwoInts.Request() |
构造请求对象,为其字段(a、b)赋值(值来自命令行参数) |
call_async(req) |
异步发起请求,返回一个 Future 对象 |
spin_until_future_complete(node, future) |
在等待期间处理节点回调,直到请求完成;也可用 future.result() 拿结果 |
为什么回调要
return response? 服务端回调的签名固定为(request, response) -> response。你在回调里修改response的字段,最后必须把它返回,ROS 2 才会把结果送回客户端。
call_async是异步的:它不会阻塞主线程,而是返回Future。用rclpy.spin_until_future_complete()或rclpy.spin_once()+future.done()来等待完成,这样节点在等待期间仍能处理其他回调。
四、完整过程(从包到运行)
第 1 步:创建功能包
在 src/ 下用官方命令创建 Python 包(这里包名用 py_service 演示,也可换成你自己的名字):
cd ~/ros_ws/src
ros2 pkg create py_service --build-type ament_python --node-name service
--node-name service 会自动生成 py_service/service.py 并在 setup.py 中注册入口。
第 2 步:放置代码文件
把上面两段代码分别保存为:
src/py_service/py_service/service.py # 服务端
src/py_service/py_service/client.py # 客户端
(也可以直接使用官方样例生成的 service_member_function.py / client_member_function.py,本文为你手写的是更精简的版本。)
第 3 步:配置 setup.py
编辑 src/py_service/setup.py,在 console_scripts 中注册两个可执行入口:
entry_points={
'console_scripts': [
'service = py_service.service:main',
'client = py_service.client:main',
],
},
格式为:命令名 = 模块路径:函数名。这样构建后就能用 ros2 run py_service service 直接启动。
第 4 步:确认 package.xml 依赖
确保 package.xml 里声明了 rclpy 和 example_interfaces(ros2 pkg create 默认已带 rclpy,需手动补充 example_interfaces):
<exec_depend>rclpy</exec_depend>
<exec_depend>example_interfaces</exec_depend>
第 5 步:构建
回到工作区根目录,构建这个包(每次改代码后都要重新构建):
cd ~/ros_ws
colcon build --packages-select py_service
source install/setup.bash
第 6 步:运行
开两个终端,先启动服务端,再启动客户端(客户端会等待服务端就绪,所以顺序颠倒也不会报错):
# 终端 1:服务端
source /opt/ros/jazzy/setup.bash
source ~/ros_ws/install/setup.bash
ros2 run py_service service
# 终端 2:客户端(在命令末尾带上 a、b 两个整数参数,如 41 1)
source /opt/ros/jazzy/setup.bash
source ~/ros_ws/install/setup.bash
ros2 run py_service client 41 1
第 7 步:验证结果
- 客户端终端会打印:
Result: 41 + 1 = 42,随后退出(a、b取自命令行参数;不传参时默认为0,会打印Result: 0 + 0 = 0)。 - 服务端终端会打印:
Incoming request: a=41, b=1 -> sum=42。 - 另开一个终端可以查看服务与节点信息:
ros2 service list # 查看所有服务,应包含 /add_two_ints
ros2 service type /add_two_ints # 查看服务类型:example_interfaces/srv/AddTwoInts
ros2 node list # 查看节点:/minimal_service /minimal_client
- 也可以不写客户端,直接从命令行调用服务测试服务端:
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 5, b: 7}"
预期输出示例:
$ ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 5, b: 7}"
requester: making request: example_interfaces.srv.AddTwoInts_Request(a=5, b=7)
response:
example_interfaces.srv.AddTwoInts_Response(sum=12)
五、常见问题排查
| 现象 | 原因 / 解决 |
|---|---|
ros2 run 提示找不到包 |
没 source 工作区:source ~/ros_ws/install/setup.bash |
ros2 run 提示找不到可执行文件 |
setup.py 的 console_scripts 没注册,或改后没重新 colcon build |
| 客户端一直打印 “service not available” | 服务端没启动,或服务名不一致(两边必须都是 add_two_ints) |
future.result() 为 None |
服务端崩溃或调用超时,客户端会进入 else 分支打印 Service call failed |
服务端回调没返回 response |
回调必须 return response,否则客户端收不到结果、会一直卡住 |
| 改了代码但行为没变 | 忘记重新构建:colcon build --packages-select py_service |
六、小结
- 服务端 / 客户端是 ROS 2 中”一问一答”的同步通信模式,通过服务名 + 服务接口(srv)关联,适合一次性调用拿结果的场景。
- 用 Python 只需掌握
create_service/create_client、wait_for_service、call_async+spin_until_future_complete几个关键点。 - 完整流程:建包 → 写代码 → 注册入口 → 补充依赖 → 构建 → 运行,每一步缺一不可。
- 本文使用现成的
AddTwoInts接口。当你要传递自己的数据结构时,需要自定义.srv文件(格式为请求字段+---+响应字段)并在package.xml中添加rosidl_default_generators等依赖,这部分可以留待进阶文章展开。
掌握了 Service,你就掌握了 ROS 2 里”同步调用”的通信方式;它与前面学的 Pub/Sub(异步)、以及后续要学的 Action(长耗时任务)一起,构成了 ROS 2 节点通信的三大核心模式。