
第一次看到ROS2里还有action这种通讯方式时我一瞬间是有点懵的topic我会用service我也明白action到底是什么鬼直到我在导航小车上真跑了一次任务才发现动作方式通讯解决的是带反馈、可取消、有状态这类任务型交互——它不是为了传输数据而是为了承接一个完整的工作流。这篇不扯概念直接从实操讲透。先理解action为什么存在再用小乌龟把CLI玩熟接着写一个自定义action接口最后从零实现服务端和客户端把整条链路跑通。适合刚入坑ROS2、被topic和service搞明白但遇到长时间任务不知道怎么下手的同学也适合想把通讯机制真正吃透的人。1. 为什么ROS2要把action单独拎出来先理解它的设计思路1.1 三种通讯方式的本质差异我习惯用一个比喻把ROS2的三种通讯方式讲明白。你想象一下点外卖的过程你打开外卖App下单商家接单骑手在路上实时给你播报位置最后餐送到你手上你还可以随时取消。这个完整的过程就是action。而topic是什么呢更像是在看一场直播主播发布者一直在发内容观众订阅者爱看就看不看了直接关掉双方都不知道对方是否存在也不关心任务是否完成。service则像ATM取款你插卡输入金额机器吐钱交易立刻结束。一次性、同步、有明确结果但中途你不能说我不要了。看明白区别了吧。topic适合高频单向数据流比如雷达数据、图像数据service适合一次性同步请求响应比如查询机器人位置、设置某个参数但一旦遇到给导航目标点让机器人走过去这种耗时可能几十秒、中途需要知道进度、还得能随时取消的任务纯用topic要自己撸一套状态机纯用service就等着超时和阻塞吧。action就是为这种任务型场景设计的通讯原语。1.2 action的底层构成三个topic加一个service当初我查源码时有一个很大的误解以为action是ROS2里多么高深的新协议。实际上action在底层就是把ROS2现有的topic和service组合在一起用的并没有发明什么新的底层传输机制。一个action服务端和一个action客户端之间会建立这样几路通道通道底层载体方向作用Goaltopic客户端 - 服务端发送任务目标比如走到坐标(1,2)Canceltopic客户端 - 服务端请求取消当前目标Statustopic服务端 - 客户端周期上报任务状态Feedbacktopic服务端 - 客户端反馈执行进度比如走了70%Resultservice服务端 - 客户端任务结束后的最终结果这里要记住Feedback是topicResult才是service。为什么这样设计因为反馈需要持续推送订阅者可以随时监听而结果只需要在任务结束那一刻交给客户端属于一次性的查询响应。1.3 action的接口三段式定义action的接口文件非常直观一个.action文件内部用三条横线分割成三段第一段是Goal定义请求方想达成什么目标第二段是Result定义任务完成后返回什么数据第三段是Feedback定义执行过程中阶段性上报的数据。这个三段式定义直接决定了后续所有代码怎么写所以先记牢。2. 先用turtlesim把action跑起来CLI实操与节点结构观察光看概念很难有体感先跑起来再说。ROS2自带的turtlesim包里就带一个现成的action示例不写一行代码就能感受action的完整交互过程。2.1 启动小乌龟环境打开两个终端。终端一启动乌龟模拟器ros2 run turtlesim turtlesim_node终端二启动乌龟遥控器ros2 run turtlesim teleop_turtle注意此时先别急着按键我们先观察。在第三个终端输入ros2 action list -t输出里会看到类似这样的内容/turtle1/rotate_absolute [turtlesim/action/RotateAbsolute] /turtle1/translate_absolute [turtlesim/action/TranslateAbsolute] # 新版才有-t参数会连action的类型一起显示。可以看到小乌龟包里有一个叫rotate_absolute的action类型是turtlesim/action/RotateAbsolute。2.2 用CLI查看一个action的细节先用info看看这个action当前被谁通过通讯连着ros2 action info /turtle1/rotate_absolute输出里会列出Action clients发起方和Action servers执行方。你会看到teleop_turtle节点是客户端turtlesim节点是服务端这就对应了遥控器发起旋转指令、乌龟模拟器执行旋转动作的关系。再进一步用ros2 interface show查看这个action接口长什么样ros2 interface show turtlesim/action/RotateAbsolute你会看到三段式结构# The desired heading in radians float32 theta --- # The angular displacement in radians float32 delta --- # The remaining rotation in radians float32 remaining这就是标准的三段式action接口非常清晰。2.3 真正发一个goal并观察反馈现在用命令行给乌龟发一个旋转目标让海龟头转到1.57弧度也就是90度的位置ros2 action send_goal /turtle1/rotate_absolute turtlesim/action/RotateAbsolute {theta: 1.57} --feedback注意--feedback参数不要漏掉加了它终端才会持续打印服务端上报的反馈数据。执行后你会看到类似输出Waiting for an action server to become available... Sending goal: theta: 1.57 Goal accepted with ID: 一串UUID Feedback: remaining: 0.83 Feedback: remaining: 0.21 Result: delta: 1.57注意反馈的remaining是剩下来的角度也就是剩余幅度它从接近1.57的数值逐渐变小到0然后给出最终结果delta: 1.57。这就是带反馈的任务式通讯最直观的演示。2.4 用rqt_graph观察action的底层结构刚才我们说action底层是3个topic加1个service想眼见为实可以打开rqt_graphrqt_graph在图形界面里把节点和话题展开你会看到/turtle1/rotate_absolute相关的通信链路是被ROS2自动折叠成一个整体的但展开后会看到_action/goal、_action/cancel、_action/status、_action/feedback、_action/result这类节点。命名规律就是action名称加上_action/前缀再加具体通道名这也验证了我们前面说的底层构成。3. 自定义action接口从.action文件到编译生成代码CLI玩明白之后就该自己定义接口了。很多教程会跳过这一步直接用现成的action类型但实际项目里你大概率得自定义接口来适配自己的任务。3.1 action接口的命名规范ROS2里action类型的完整形式是包名/action/类型名。比如我们马上要建的包叫action_tutorials_interfaces里面定义的类型叫Fibonacci那么这个action的完整标识就是action_tutorials_interfaces/action/Fibonacci。这个命名规则贯穿全局编写.action文件时文件放的位置、CMake配置里注册的名字、代码里import的名字都必须严格对应。名字对不上是最常见的低级错误。3.2 创建接口功能包自定义action接口不能写在功能包内部而应该建一个独立的接口定义包。这样其他功能包都能依赖它。我习惯的建包命令是这样的ros2 pkg create action_tutorials_interfaces --build-type ament_cmake为什么用ament_cmake而不是ament_python因为ROS2的消息和action接口生成代码主要依赖CMake机制用Cmake类型构建接口包是最成熟的方案。然后手动创建action目录在里面新建Fibonacci.action文件mkdir -p action_tutorials_interfaces/action编辑Fibonacci.action文件int32 order --- int32[] sequence --- int32[] partial_sequence三段的含义分别是客户端请求计算斐波那契数列目标给一个阶数order任务完成后返回完整序列sequence执行过程中周期性上报当前已经算出来的中间序列partial_sequence。3.3 CMakeLists.txt和package.xml两处都要改这是新手最容易漏掉的地方。建包工具生成的CMakeLists.txt里默认没有包含action接口的配置必须手动加三样东西第一确认rosidl_generate_interfaces里加入了action文件find_package(rosidl_default_generators REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} action/Fibonacci.action )第二在package.xml里确保有这几行依赖描述buildtool_dependrosidl_default_generators/buildtool_depend dependaction_msgs/depend member_of_grouprosidl_interface_packages/member_of_group第三件事实际上就是编译。cd到工作空间根目录执行colcon build --packages-select action_tutorials_interfaces source install/setup.bash这里要特别提醒一句改完接口必须重新编译并重新source。我见过太多人往.action文件里加了字段然后只编译了功能包没编译接口包一运行就报接口类型找不到。编译顺序和source这一步永远不要省。3.4 验证接口是否生成成功编译完成后用ros2 interface命令检查ros2 interface show action_tutorials_interfaces/action/Fibonacci如果正确输出了三段定义说明接口已经生成成功可以开始写业务逻辑了。4. 用Python实现action服务端Fibonacci全流程接口就绪现在写服务端。服务端是action的核心执行方负责接收goal、干活、上报feedback、返回result。我们用最经典的斐波那契数列计算任务来演示。4.1 创建服务端功能包ros2 pkg create action_tutorials_py --build-type ament_python --dependencies rclpy action_tutorials_interfaces--dependencies直接带上action_tutorials_interfaces这样包里的依赖关系就配好了。4.2 服务端核心代码详解在ros2 pkg create生成的包目录下找到action_tutorials_py源码子目录新建fibonacci_action_server.py文件内容如下import time import rclpy from rclpy.action import ActionServer from rclpy.node import Node from action_tutorials_interfaces.action import Fibonacci class FibonacciActionServer(Node): def __init__(self): super().__init__(fibonacci_action_server) self._action_server ActionServer( self, Fibonacci, fibonacci, self.execute_callback) def execute_callback(self, goal_handle): self.get_logger().info(开始执行任务...) feedback_msg Fibonacci.Feedback() feedback_msg.partial_sequence [0, 1] for i in range(1, goal_handle.request.order): feedback_msg.partial_sequence.append( feedback_msg.partial_sequence[i] feedback_msg.partial_sequence[i - 1] ) self.get_logger().info( 当前进度: {0}.format(feedback_msg.partial_sequence) ) goal_handle.publish_feedback(feedback_msg) time.sleep(1) goal_handle.succeed() result Fibonacci.Result() result.sequence feedback_msg.partial_sequence return result def main(argsNone): rclpy.init(argsargs) fibonacci_action_server FibonacciActionServer() rclpy.spin(fibonacci_action_server) rclpy.shutdown() if __name__ __main__: main()逐段拆解一下这段代码的逻辑。第一实例化ActionServer时传了四个关键参数self是节点对象Fibonacci是之前定义的接口类型fibonacci是action的名称self.execute_callback是收到goal之后的处理函数。注意fibonacci这个名字就是action的服务名后面客户端的监听名必须和它完全一致。第二execute_callback函数才是真正的任务逻辑。它接收一个goal_handle参数这个handle里封装了请求数据、服务端状态、反馈通道等所有东西。取请求数据用goal_handle.request发布反馈用goal_handle.publish_feedback()完成任务用goal_handle.succeed()。第三任务结束时必须调用succeed()。如果你忘了succeed客户端会一直等不到最终结果服务端也会在日志里持续报错。这个细节我踩过一次任务明明算完了客户端就是拿不到result查半天发现是忘了标记goal成功。第四time.sleep(1)是为了模拟一个需要一段时间的真实任务让你能在客户端那边观察到持续上报的feedback。实际项目中这就是你的真实长任务比如点云处理、路径规划算法这类耗时操作。4.3 配置entry point并运行服务端修改setup.py把入口函数加进去entry_points{ console_scripts: [ fibonacci_action_server action_tutorials_py.fibonacci_action_server:main, ], },然后在工作空间里重新编译并sourcecolcon build --packages-select action_tutorials_py source install/setup.bash运行服务端ros2 run action_tutorials_py fibonacci_action_server4.4 用命令行直接测试服务端趁客户端还没写我们先用CLI给服务端发一个goal验证服务端是否正常工作。新开一个终端ros2 action send_goal /fibonacci action_tutorials_interfaces/action/Fibonacci {order: 5} --feedback注意action名称/fibonacci和接口类型action_tutorials_interfaces/action/Fibonacci必须准确无误。如果一切正常你会看到服务端开始输出计算进度客户端终端收到一条条feedback最后返回的sequence就是[0, 1, 1, 2, 3, 5]。到这里服务端一侧就通了。5. 用Python实现action客户端异步回调是核心写法服务端好了客户端是发起任务的一方。写客户端最需要理解的是ROS2的异步回调机制——发送goal之后不能干等要注册回调函数让数据到了自动处理。5.1 客户端完整代码与逐层拆解创建fibonacci_action_client.py文件import rclpy from rclpy.action import ActionClient from rclpy.node import Node from action_tutorials_interfaces.action import Fibonacci class FibonacciActionClient(Node): def __init__(self): super().__init__(fibonacci_action_client) self._action_client ActionClient(self, Fibonacci, fibonacci) def send_goal(self, order): goal_msg Fibonacci.Goal() goal_msg.order order self._action_client.wait_for_server() self._send_goal_future self._action_client.send_goal_async( goal_msg, feedback_callbackself.feedback_callback) self._send_goal_future.add_done_callback(self.goal_response_callback) def goal_response_callback(self, future): goal_handle future.result() if not goal_handle.accepted: self.get_logger().info(Goal被拒绝) return self.get_logger().info(Goal已被接受) 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(最终结果: {0}.format(result.sequence)) rclpy.shutdown() def feedback_callback(self, feedback_msg): feedback feedback_msg.feedback self.get_logger().info(收到反馈: {0}.format(feedback.partial_sequence)) def main(argsNone): rclpy.init(argsargs) fibonacci_action_client FibonacciActionClient() fibonacci_action_client.send_goal(10) rclpy.spin(fibonacci_action_client) if __name__ __main__: main()这段代码按数据流可以分成四步理解。第一步等服务端上线。wait_for_server()会阻塞直到找到名为fibonacci的action服务端。如果服务端没启动客户端会一直等不会立刻报错。所以使用时要先启动服务端再启动客户端。第二步异步发送goal。send_goal_async()不是阻塞式调用它立刻返回一个future对象。这里的future可以理解为未来的结果容器你注册一个回调函数等future真正拿到结果时会自动调用它。同时传入的feedback_callbackself.feedback_callback是反馈回调收到服务端的feedback时自动触发。第三步确认目标是否被接受。在goal_response_callback里我拿到future.result()得到goal_handle通过goal_handle.accepted判断服务端是否接受了这个目标。如果被拒绝就赶紧返回不要再继续等结果。第四步获取最终结果。接受目标之后调用goal_handle.get_result_async()再注册一个回调。等服务端任务完成并调用了goal_handle.succeed()之后这里就会拿到最终结果。5.2 从回调机制看ROS2的异步编程习惯这里有个初学者很容易踩的坑只有节点的执行器在spinfuture回调才会被触发。主函数里的rclpy.spin(fibonacci_action_client)不能省。ROS2的异步回调是由执行器循环驱动的事件系统如果程序发完goal就结束或者不进入spin循环回调函数永远不会被调用看起来就像卡死了一样。我之前自己写客户端时图省事把rclpy.shutdown()放在send_goal调用之后结果程序立刻退出goal都没发出去。后来意识到必须把流程拆成发goal-进入spin循环-回调里收尾这种模式ros2的异步模型才算真正理解了。5.3 完整跑通服务端与客户端现在把两个程序都运行起来验证全链路。终端一运行服务端ros2 run action_tutorials_py fibonacci_action_server终端二运行客户端ros2 run action_tutorials_py fibonacci_action_client如果一切正常客户端终端会显示[INFO] 收到反馈: [0, 1] [INFO] 收到反馈: [0, 1, 1] [INFO] 收到反馈: [0, 1, 1, 2] [INFO] 收到反馈: [0, 1, 1, 2, 3] [INFO] 收到反馈: [0, 1, 1, 2, 3, 5] ... [INFO] 最终结果: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]每条feedback间隔1秒这就是服务端上报进度的效果。看到这个输出整条action链路就算完全跑通了。6. 实际踩坑记录这些坑我不希望你重复踩6.1 接口改了忘了重新编译找不到接口类型自定义action接口文件改过之后必须先重新编译接口包再编译引用它的功能包。我在刚接触时经常只colcon build功能包结果运行时报ModuleNotFoundError: No module named action_tutorials_interfaces或者是找不到Fibonacci这个类型。原因就是我改了接口定义但没重新生成代码。养成习惯改接口之后先编译接口包再编译功能包然后source install/setup.bash。这个顺序一步都不能乱。6.2 execute_callback里做重活小心阻塞其他goal请求服务端的execute_callback是在收到goal请求后由action server内部的回调机制调用的。在简单的单客户端单目标场景中库里的executor会为execute_callback分配线程阻塞一会问题不大。但如果你的服务端需要同时处理多个客户端的多个goalexecute_callback里就有耗时操作比如点云配准、路径规划建议要么把耗时操作拆到独立线程要么改用多线程执行器from rclpy.executors import MultiThreadedExecutor executor MultiThreadedExecutor(num_threads4) executor.add_node(fibonacci_action_server) executor.spin()实测下来这个调整能避免一个长任务执行期间其他客户端的请求排队排到怀疑人生的局面。6.3 goal_handle必须做终结处理goal_handle.succeed()是正常完成任务goal_handle.abort()是任务失败提前结束这两个方法必须选一个调用。目标是有生命周期的对象从收到、执行中、到成功或失败状态是被严格管理的。你如果只干活不调用succeed或abort客户端的结果回调永远不会触发ROS2在日志里会持续输出goal handle丢失之类的警告。写代码时在异常分支里记得加abort()这是个好习惯。6.4 客户端别忘了wait_for_serverwait_for_server()这个阻塞等待非常有存在感。如果没有它客户端可能一启动就发goal但服务端还没起来发送直接失败。有它在客户端会一直等直到服务端上线。6.5 字段命名的小规矩.action文件里的字段名要用小写蛇形命名snake_case比如partial_sequence。不要用大写开头不要带斜杠或连字符。如果字段名不符合ROS2的idl约束编译阶段就会报错。这些报错信息往往很晦涩干脆一开始就养成命名习惯更省事。还有一点action名称用简单字符串就能标识比如fibonacci但action的topic路径会自动扩展成/fibonacci/_action/goal这样的结构。所以客户端和服务端能互通靠的是action服务名接口类型两层匹配两层都不能错。我个人现在做项目时判断通讯方式已经有了一套条件反射任务持续超过几秒就要考虑action需要把进度告诉用户选action任务可能被用户中断选action。如果只是查询状态或者一次性指令才考虑service要持续高频传数据才考虑topic。这套判断逻辑帮我少走很多弯路核心就一句话——有状态、带反馈、可取消的通讯需求直接用action别自己用topic和service硬拼。实际跑通一遍之后你会发现action并没有比topic或service复杂多少只是把任务流转中涉及的几类信息都结构化了。掌握了这次的Fibonacci例子下一步去看Nav2的导航action和MoveIt2的机械臂action基本都能很快上手。