
ROS 2 开发里绝大多数人最早接触的是话题Topic但话题是单向广播你想让一个节点“问一句、等一个回答”就得换成服务通信。这篇直接讲怎么用ament_python构建法把 ROS 2 的服务端Service Server和客户端Service Client从零搭起来并跑通。你先别急着翻长篇文档跟着我建一个最小加法服务代码写出来、构建完、能调通这套通信机制的理解就到位了。适合刚开始学 ROS 2、正在用 Python 写节点但对服务通信还停留在概念层面的朋友。1. 先聊清楚服务通信为什么适合“一问一答”1.1 从话题广播到点对点请求很多人学完 Publisher 和 Subscriber 之后会觉得“这不就能通信了吗为什么还要服务通信”。这个疑问很自然。话题的工作模式是发布者把数据丢到“公共频道”订阅者被动接收话题本身不对“这条消息被谁处理了、处理结果如何”负责。就好像你往群里发了一条“房间有点冷”群里有人看到了可能把空调调高但没人会专门把结果回复给你。服务通信不一样。它是一对一的请求/响应模式客户端把请求发出去服务端处理完再把结果返回客户端能明确知道自己是否调用成功、结果是什么。这种模式很接近我们平时写代码时的函数调用适合需要“确认结果”的场景比如机器人让导航模块规划一条路径规划成功与否必须明确告知又比如两个节点之间查一个状态、切换一个配置开关都需要服务端返回结果。话题当然也能做但靠异步消息去模拟这种一问一答代码复杂度和资源占用都会明显增加。在 ROS 2 里还有第三种动作通信Action它本质是服务加话题的升级版适合长时间、可取消、带进度的任务。而服务通信更轻量适合耗时短、结果明确的调用。理解了这个区别你就不会在一开始盲目给所有通信都套服务。1.2 一次 service 调用到底发生了什么用 ROS 2 的视角看一次服务调用要经历几个环节。先说名字每个服务都必须有一个服务名比如add_two_ints它是客户端和服务端互相找到对方的钥匙还必须有一个服务类型也就是.srv文件定义的数据结构。如果你在终端输出ros2 service list看到的是服务名而ros2 service type /add_two_ints才会告诉你它对应的类型比如example_interfaces/srv/AddTwoInts。.srv文件的结构很特殊它由上、下两部分组成中间用---隔开。上方是请求字段下方是响应字段。内置接口AddTwoInts大致长这样int64 a int64 b --- int64 sum服务端会声明自己提供某个服务名并绑定一个类型客户端则向这个服务名发起请求。ROS 2 底层通过 DDS 完成发现和通信所以服务端、客户端可以运行在不同机器上只要网络和 ROS 2 环境能打通跨机器调用和本机调用没有本质区别。还有一个容易忽略的细节服务调用天然是“需要等待”的。客户端发出请求后如果服务端没启动或者网络不稳定客户端就会一直阻塞或超时。因此 ROS 2 的 Python 客户端接口里专门提供了wait_for_service这种预检机制逻辑上很像你打电话之前先确认对方有没有上线。1.3 一个加法服务需要几个参与者我做的演示很简单但足以覆盖服务通信的全部核心要素。整包会包含两个可执行节点一个负责服务端一个负责客户端。服务端启动后会创建名为add_two_ints的服务并且在回调里接收客户端发来的两个整数相加后写回响应的sum字段。客户端启动后通过命令行参数拿到两个整数然后调用服务端。为了让过程可观察两边都会打印日志你随时能在终端看到“谁什么时候收到了什么”。这两个节点都放同一个ament_python包里。我特意没说“把所有代码都写在一个文件里”因为在真实工程里一个包通常会拆成多个模块服务端和客户端分开写后面维护才不会一团乱麻。这次我们顺着包结构走先建一个干净的工程骨架。2. ament_python 建包先把工程骨架立起来2.1 确认环境你装的是哪个 ROS 2 发行版动手之前先确认版本。不同 ROS 2 发行版比如 Foxy、Humble、Jazzy安装路径和默认 Python 版本不同但基本 API 差别不大下面代码在 Humble 上实测没有问题。打开终端执行source /opt/ros/humble/setup.bash ros2 --version如果打出来的版本号不符合预期先检查你是否还 source 了别的工作空间或者系统里装过多个 ROS 2。我见过不少“明明装了但版本不对”的情况多数是终端初始化脚本把某次安装的路径写死了。可以用echo $ROS_DISTRO看看当前环境变量正常应该输出你的发行版标识。2.2 一行命令创建 Python 包进入工作空间的src目录执行下面的命令ros2 pkg create demo_service --build-type ament_python --dependencies rclpy example_interfaces--build-type ament_python意味着这是一个 Python 编写的 ROS 2 包不涉及 CMake 编译但ament_python仍然会通过colcon完成安装和链接。--dependencies后面跟着两个依赖rclpy是 Python 版 ROS 2 客户库example_interfaces是示例接口包里面包含了很多拿来即用的消息和服务类型。如果你不指定--dependencies也能建包但后面我会手动补依赖而且容易漏。建包时一步到位更保险。生成后的目录结构大致如下src/demo_service/ ├── package.xml ├── resource │ └── demo_service ├── setup.cfg ├── setup.py ├── test │ ├── test_copyright.py │ ├── test_flake8.py │ └── test_pep257.py └── demo_service └── __init__.py2.3 包内文件逐个说清楚别被 setup.py 吓到先看package.xml。在 ROS 2 里它是包的“身份证”声明了包名、描述、许可证、维护者以及依赖。用文本编辑器打开你会看到rclpy和example_interfaces已经自动写进了exec_depend这是运行时需要的依赖。真正容易被忽略的是license字段有时忘了改后面用bloom或别人下载你的包时会有不必要的麻烦建议建包后立刻把模板里的 TODO 都替换掉。再看setup.py。ament_python包的安装本质上还是走 Python 的 setuptools 流程colcon build会读取这个文件去做打包、生成可执行入口。包名、版本、维护者这些内容沿用上面填的信息就行。你的自定义 Python 节点必须放到这个包的demo_service目录下并且要在entry_points里注册否则ros2 run根本找不到对应命令。setup.cfg里面有一行非常关键[develop] script_dir$base/lib/demo_service这一行的意思是所有可执行脚本的安装目录。ROS 2 的ros2 run会去install/demo_service/lib/demo_service找脚本如果你在setup.py里写了入口点但setup.cfg被改坏了就会出现“包能构建但命令找不到”的怪问题。resource/demo_service只是一个空的标记文件帮助 ROS 2 在ament index中索引到这个包通常不用动。如果你以后在别的包通过 launch 文件引用这个包ROS 2 就是靠这个索引保证“这个包确实存在”。2.4 Python 模块应该放在哪个目录建完包自动生成的demo_service/__init__.py所在目录就是放 Python 源码的地方。我们接下来要新增两个 Python 文件service_server.py和service_client.py。放在这里就能被 Python 正确导入完整路径是demo_service/ ├── __init__.py ├── service_server.py └── service_client.py不需要再单独把源码放到子目录除非你需要组织很多模块。ament_python会直接把整个demo_service包安装到 Python 的 site-packages/当前环境路径下所以模块导入名是这种带包前缀的风格from demo_service.service_server import main。稍后注册入口点时我会再次用到这个写法。3. 代码实现服务端和客户端怎么写3.1 先写服务端节点打开demo_service/service_server.py我直接给完整代码import rclpy from rclpy.node import Node from example_interfaces.srv import AddTwoInts class ServiceServer(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(Service server ready, waiting for request...) def add_callback(self, request, response): response.sum request.a request.b self.get_logger().info( fReceive request: a{request.a}, b{request.b}, return {response.sum} ) return response def main(argsNone): rclpy.init(argsargs) node ServiceServer() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() if rclpy.ok(): rclpy.shutdown() if __name__ __main__: main()这段代码的核心是self.create_service。第一个参数是服务类型告诉 ROS 2 “我这个服务发的请求和响应是什么结构”第二个参数是服务名第三个参数是回调函数。回调函数的签名固定为接收到request和response两个对象函数体里要给响应对象赋值最后必须把response返回。请特别留意add_callback里那行日志。服务刚启动时你看不到任何输出直到有请求进来回调触发。我见过很多新手写完服务端后跑起来发现终端安安静静就误以为服务没在工作。其实服务端工作就是“等着被调用”不需要主动刷日志。如果你想确认服务有没有注册成功可以另开终端执行ros2 service list这样更直接。3.2 再写客户端节点打开demo_service/service_client.py完整代码import sys import rclpy from rclpy.node import Node from example_interfaces.srv import AddTwoInts class ServiceClient(Node): def __init__(self): super().__init__(add_two_ints_client) self.client self.create_client(AddTwoInts, add_two_ints) def call_add(self, a, b): if not self.client.wait_for_service(timeout_sec1.0): self.get_logger().warn(Service not available, keep waiting...) return None request AddTwoInts.Request() request.a a request.b b future self.client.call_async(request) rclpy.spin_until_future_complete(self, future, timeout_sec5.0) if future.done(): try: result future.result() return result.sum except Exception as e: self.get_logger().error(fService call failed: {e}) return None else: self.get_logger().error(Service call timeout.) return None def main(argsNone): if len(sys.argv) ! 3: print(Please give two integers, like:) print( ros2 run demo_service client 3 5) return a int(sys.argv[1]) b int(sys.argv[2]) rclpy.init(argsargs) node ServiceClient() try: result node.call_add(a, b) if result is not None: node.get_logger().info(fCall service result: {a} {b} {result}) else: node.get_logger().error(No result received.) finally: node.destroy_node() if rclpy.ok(): rclpy.shutdown() if __name__ __main__: main()客户端有几个容易踩坑的地方。wait_for_service返回True表示服务可用False说明等了一秒还没发现服务。这里我选择返回None而不是直接让程序崩溃好处是你单独启动客户端时终端会展示明显的警告通过日志你能判断病因是“服务端没启动”而不是“代码崩了”。实际把请求发出去用的是call_async。ROS 2 的 Python 客户端默认异步发起请求返回一个future对象然后你用rclpy.spin_until_future_complete阻塞等待这个 future 完成。我加了一个超时参数timeout_sec5.0这是经验之谈生产环境里服务端可能因为忙或网络延迟而迟迟不返回如果没超时保护客户端节点会一直挂在这里。等到超时后再通过future.done()判断是否真的拿到结果。3.3 在 setup.py 里注册两个入口点写好的代码必须注册否则ros2 run demo_service server会提示找不到节点。打开setup.py在entry_points部分改成entry_points{ console_scripts: [ server demo_service.service_server:main, client demo_service.service_client:main, ], },console_scripts是 setuptools 提供的入口点机制。左边server是你在命令行使用的命令名右边指向模块内的函数入口。这样注册后colcon build会自动生成一个可执行脚本放到install/demo_service/lib/demo_service/server。你可以把这里的名字叫server_node、srv_server或任何你喜欢的名字只要跟后面运行命令统一就行。有些教程会把server定义成跟服务名一样的名字这不是不行但命令行里容易产生歧义。我的习惯是命令名尽量简单server和client一眼就能分辨。3.4 同步调用和异步调用的取舍刚才客户端代码用的是call_async。如果你去看 ROS 2 早期版本教程会发现有的示例用self.client.call(request)这种同步写法但在新版rclpy里官方更推荐异步方式。背后的原因和 ROS 2 的执行模型有关节点内部有一套回调机制如果你在某个回调函数里用同步等待很可能把整个执行器卡死因为处理回调的线程一直不释放。实际开发中绝大多数场景建议用call_async spin_until_future_complete。如果客户端节点本身已经是通过spin在跑并且你的调用放在节点回调里那就不能直接调用spin_until_future_complete而是要用回调组callback group和 Future 机制或者把调用逻辑放到独立线程。这个进阶问题我放在后面避坑部分展开。现在这个简单例子里客户端入口函数在执行一次调用后就退出spin_until_future_complete完全够用。4. 构建、运行与三分钟命令行体检4.1 colcon build 参数为什么我总开 --symlink-install回到工作空间根目录也就是包含src的那一层执行colcon build --packages-select demo_service --symlink-install我强烈建议调试阶段都加上--symlink-install。这个参数的意思是构建时不会把源码复制一份到install目录而是用软链接指回src。效果就是你改了service_server.py里的代码不需要重新colcon build下次ros2 run直接就是新代码。对 Python 这种不需要编译的语言来说开发效率高很多。如果忘记了--symlink-install修改代码后至少会碰到两种疑惑一种是你重新 build 了但日志还显示旧代码内容另一种是 build 成功后没有重新source install/setup.bash导致当前 shell 用的还是旧的PYTHONPATH。正确姿势其实固定三条改代码、重新colcon build或--symlink-install则可免、必须重新source install/setup.bash再跑节点。只要有一次没 source行为就不可预期。4.2 双终端联调谁先启动都行打开两个终端都先执行环境加载source /opt/ros/humble/setup.bash source install/setup.bash第一个终端启动服务端ros2 run demo_service server第二个终端启动客户端ros2 run demo_service client 3 5正常情况下客户端终端会打印Call service result: 3 5 8服务端终端会打印它收到的请求日志。你可以在服务端不启动的情况下先跑客户端观察wait_for_service的警告日志然后启动服务端客户端会立刻发现服务、继续完成调用。这比“等所有节点都启动好再调用”更接近真实场景。如果第二个终端里客户端已经退出调用结果已经拿到服务端仍然在spin中等着其他请求。你每执行一次ros2 run demo_service client就是完成一次“发一个新客户端请求”的动作。这对测试分布式服务非常有用。4.3 用 ros2 service 系列命令做“体检”服务端在跑的时候另开一个终端可以逐个验证状态。先看服务列表ros2 service list输出里能看到/add_two_ints。如果没有任何输出说明服务端没有正确启动或者两个终端没有 source 同一个工作空间。接着看类型ros2 service type /add_two_ints应该输出example_interfaces/srv/AddTwoInts。这个命令特有用因为当你不确定某个接口的字段结构时先查类型再继续查详情ros2 interface show example_interfaces/srv/AddTwoInts最后还可以直接不用写 Python 客户端用命令行发起一次真实调用ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts {a: 10, b: 20}如果服务正常终端会直接打印响应结果同时你服务端那边的终端也会打印收到请求的日志。很多人调试自己的 Python 客户端时习惯先跑ros2 service call验证服务端没问题再回来查客户端代码这个排查顺序可以省下大量时间。4.4 用 Python launch 文件把两端一起拉起来手动开两个终端联调没问题但在工作区日常测试时我更建议写一个 launch 文件。在demo_service包中创建一个launch目录新增add_two_ints.launch.pyfrom launch import LaunchDescription from launch_ros.actions import Node def generate_launch_description(): return LaunchDescription([ Node( packagedemo_service, executableserver, outputscreen ), Node( packagedemo_service, executableclient, arguments[3, 5], outputscreen ), ])launch 文件只需要引用包名和可执行名也就是entry_points里注册的命令名。运行方式ros2 launch demo_service add_two_ints.launch.py由于客户端在wait_for_service哪怕启动顺序有微小先后差也能等到服务端就绪很少发生竞态问题。这里我用的是 Python 格式 launch 文件ROS 2 里这是主流也能兼容 XML 格式但个人感受是 Python 灵活性强适合后续加条件参数。稍微注意launch 文件默认不一定被colcon build自动安装所以--symlink-install同样能帮上忙它能让你改 launch 文件后免去重新 build。5. 常见问题与实战排坑5.1 客户端一直等待日志循环刷“Service not available”这个症状最常见几乎每个新手都会遇到。优先查三件事第一服务端终端里有没有报错退出。如果服务端启动后立刻闪退多半是example_interfaces没依赖到或者代码里有语法/导入错误。可以单独恢复执行ros2 run demo_service server观察输出。第二两个终端是否都加载了同一个工作空间的install/setup.bash。如果在 A 终端 build 完B 终端忘了 source两个节点其实不在同一个 DDS 网络上下文里互相发现不了。我过去调试朋友的问题时发现他开了好几个终端每个终端的 ROS 环境还不一致这种问题肉眼很难分辨。第三服务名是否拼写一致。服务端注册的是add_two_ints客户端请求的也是add_two_ints只要有空格、多一个斜杠、大小写不同都匹配不上。可以用ros2 service list从服务端视角看实际名字再回对客户端的字符串。5.2 ros2 run 提示找不到 server 或 client这种大多不是包没编译而是入口点没配对。重新检查setup.pyserver demo_service.service_server:main client demo_service.service_client:main注意等号左边是命令名右边格式是“模块路径 : 入口函数名”。如果你把server写成了server.py或者右边少写了模块都会导致构建成功但命令不存在。另外修改了setup.py后必须重新colcon build并重新source因为入口点是在构建阶段根据setup.py生成脚本的不是运行时动态读取。5.3 改了代码没有生效如果确定用了--symlink-install却还是没生效先看你是不是不小心改了install目录里的副本有人会直接在 install 下找文件改因为没有符号链接习惯。更隐蔽的是你在工作空间内开了多个终端当前终端的PYTHONPATH还没更新。重新打开一个终端执行两行 source再观察日志输出。还有一个检查技巧直接打印节点启动时的自定义日志。比如我在服务端__init__里写了Service server ready...如果你改了这段文字但终端还是旧文字基本就是环境或缓存问题如果新文字出现了说明代码加载没问题通信问题再往服务名和 QoS 方向查。5.4 如果想自己定义 srv 接口怎么处理最省心刚才全程用的是example_interfaces里的现成AddTwoInts但真实项目里你很快会遇到自定义结构需求。要给 ROS 2 加自定义.srv我建议单独建一个接口包而不是强行塞进ament_python业务包。为什么因为 ROS 2 接口编译生成涉及rosidl工具链虽然纯 Python 包也能通过复杂配置生成接口但主流的、文档完善的方式还是用ament_cmake建接口包。操作大致是ros2 pkg create tutorial_interfaces --build-type ament_cmake --dependencies rosidl_default_generators mkdir tutorial_interfaces/srv # 在 srv 目录下写你的 .srv然后在接口包的CMakeLists.txt和package.xml里增加接口生成相关的声明。构建并 source 后业务包如果想使用这个接口需要在package.xml中增加依赖同时在setup.py的install_requires或依赖声明里补上接口包。客户端和服务端的导入代码从example_interfaces.srv改成自己的包名即可。这个过程不算复杂但经常出错的点在于接口包构建顺序必须早于业务包如果两个包第一次同时 build可能出现“找不到接口”或“类型未注册”。稳妥做法是先把接口包单独colcon build一次再一起构建业务包。5.5 服务端回调卡顿与多线程执行器用默认方式创建的服务端回调默认在单线程执行器里运行。这意味着如果服务端节点同时还订阅了话题某个话题回调在执行耗时任务服务回调可能会被阻塞等待。这在实际机器人工程中非常致命比如激光雷达回调做耗时计算服务请求迟迟得不到处理。解决办法是给服务回调使用独立的回调组。给ServiceServer增加一个ReentrantCallbackGroupfrom rclpy.callback_groups import ReentrantCallbackGroup self.callback_group ReentrantCallbackGroup() self.srv self.create_service( AddTwoInts, add_two_ints, self.add_callback, callback_groupself.callback_group )创建节点时还要配合 MultiThreadedExecutorfrom rclpy.executors import MultiThreadedExecutor executor MultiThreadedExecutor() executor.add_node(node) executor.spin()这个改进对当前这个加法服务是“杀鸡用牛刀”但也正是这个抽象能把问题说清楚ROS 2 的 Executor 决定回调何时被调度回调组决定哪些回调可以并行。你把长时间阻塞的操作交给普通话题回调把服务端放独立回调组服务端就不会被节点里的其他业务拖死。在我做导航任务下发这类服务时这个策略基本是标配。谈到接口和类型再分享一个我常年在用的排查技巧每次写服务通信前先在命令行ros2 interface show example_interfaces/srv/AddTwoInts把字段名和类型看清楚。request.a、request.b、response.sum这些名字全部来自.srv定义不是我自己起的。如果你把请求字段名写错比如写成了request.xPython 运行时会直接报属性错误有些新手会被这种报错绕晕。要记住ROS 2 的 Python 接口对象是自动生成类字段名跟接口文件一一对应不跟你代码里的局部变量名走。每次改.srv接口后都要重新构建接口包并且确保所有依赖它的业务包重新 source否则你会在终端看到各种莫名其妙的 import 错误。我个人做 ROS 2 服务通信调试时最顺手的一套动作是先起服务端再另开终端跑ros2 service list确认服务存在然后在第三个终端直接ros2 service call敲一次真实数据。三步走完服务端有没有问题立刻能判断。如果所有命令行检查都通过客户端还是失败再把目光收回到 Python 代码的执行器、回调组和 future 处理逻辑上。按这个顺序排查九成服务通信的坑都能在几分钟内定位。