Skip to content

ROS2 service 与 action 的录制与回放

背景

rosbag2 早期只能录 topic。service(请求/响应 RPC)和 action(长任务 goal)不是可订阅的对象,没法直接录。从 Iron 引入 service introspectionJazzyrosbag2 才真正支持 service/action 的录制与回放。本文整理:录的到底是什么通道、录下来的消息长什么样、落盘结构,以及能不能回放。


1. Service 录制:靠 introspection 旁路成一个隐藏 topic

Service 本身是 RPC,不能订阅。为了能观测/录制,ROS 2 引入 service introspection:当 client/server 收发 request/response 时,往一个隐藏 topic 上镜像一条 "service event" 消息。topic 命名规则:

[namespace/]<service_name>/_service_event

例如 service /add_two_ints 对应 /add_two_ints/_service_eventrosbag2 录 service 就是像普通 topic 一样订阅这个隐藏 topic,没有任何特殊录制通道。录制日志也证实这点:

bash
$ ros2 bag record --service /add_two_ints
[INFO] [rosbag2_recorder]: Subscribed to topic '/add_two_ints/_service_event'

区分 service event topic 和普通 topic 的两条准则:topic 名以 /_service_event 结尾,且类型名匹配 */srv/*_Eventros2 bag info 据此把它们归到 "Service information" 分组展示,但存储层完全一样(见第 4 节)。

1.1 前置条件:节点侧必须开 introspection

introspection 有三档,默认 OFF,不开就没有数据:

状态 含义
RCL_SERVICE_INTROSPECTION_OFF 关闭(默认)
RCL_SERVICE_INTROSPECTION_METADATA 只录元数据(event_type、stamp、client_gid、sequence_number),不含 payload
RCL_SERVICE_INTROSPECTION_CONTENTS 元数据 + request/response 用户数据

代码里调 service->configure_introspection(...) / client->configure_introspection(...),或对 demo 节点用参数动态设置:

bash
ros2 run demo_nodes_cpp introspection_service --ros-args -p service_configure_introspection:=contents
ros2 run demo_nodes_cpp introspection_client  --ros-args -p client_configure_introspection:=contents

1.2 录制 CLI

参数 含义
--services S1 [S2 ...] 录指定 service(订阅其 _service_event
--all-services 录所有 service
-a, --all 录所有 topic + service + action(隐藏 topic 除外)
--all-topics 只录所有 topic(不含 service event)
--include-hidden-topics 录所有隐藏 topic(含 service event)
--exclude-services S1 [...] 排除指定 service

要点:录 service 不是默认行为--all-topics 不含 service;要录 service 必须显式 --services / --all-services / -a-a 自 Jazzy 起才纳入 service)。

1.3 一次 RPC 产生哪几个事件

client 侧发 REQUEST_SENTRESPONSE_RECEIVED;server 侧发 REQUEST_RECEIVEDRESPONSE_SENT。要拼出一次完整调用(请求 + 响应 payload),通常需要 client 和 server 两侧都开 CONTENTS,否则只有一方带 payload。


2. 录下来的消息结构:<ServiceName>_Event

每个 .srv 在编译期由 rosidl 额外生成一条 event 消息,类型名 <pkg>/srv/<ServiceName>_Event。以 example_interfaces/srv/AddTwoInts 为例,原始 srv:

msg
# https://github.com/ros2/example_interfaces/blob/rolling/srv/AddTwoInts.srv
int64 a
int64 b
---
int64 sum

生成的 AddTwoInts_Event 结构(字段名据运行时输出反推,request/response 为数组、运行时只有 0 或 1 个元素):

msg
# 自动生成:example_interfaces/srv/AddTwoInts_Event
service_msgs/ServiceEventInfo info
example_interfaces/srv/AddTwoInts_Request[] request
example_interfaces/srv/AddTwoInts_Response[] response

info 字段的类型是 service_msgs/msg/ServiceEventInfo(逐字段原文):

msg
# https://github.com/ros2/rcl_interfaces/blob/rolling/service_msgs/msg/ServiceEventInfo.msg
uint8 REQUEST_SENT = 0
uint8 REQUEST_RECEIVED = 1
uint8 RESPONSE_SENT = 2
uint8 RESPONSE_RECEIVED = 3

# The type of event this message represents
uint8 event_type

# Timestamp for when the event occurred (sent or received time)
builtin_interfaces/Time stamp

# Unique identifier for the client that sent the service request
# Note, this is only unique for the current session.
# 与 rmw_dds_common/msg/Gid 同尺寸(16 字节),因循环依赖不能直接引用那个消息
char[16] client_gid

# Sequence number for the request
# 与 client_gid 组合,唯一标识一次服务事务
int64 sequence_number

字段含义:

字段 类型 含义
event_type uint8 4 个常量之一,标识这是哪种事件
REQUEST_SENT=0 常量 client 侧:请求已发出
REQUEST_RECEIVED=1 常量 server 侧:收到请求
RESPONSE_SENT=2 常量 server 侧:响应已发出
RESPONSE_RECEIVED=3 常量 client 侧:收到响应
stamp builtin_interfaces/Time 事件发生时间(发送或接收时刻)
client_gid char[16] 发起请求的 client 全局标识,定长 16 字节,仅当前会话内唯一
sequence_number int64 请求序号,与 client_gid 组合唯一标识一次事务

request/response 是数组,但同一条 event 里通常只有一方有值:REQUEST_* 事件带 request、RESPONSE_* 事件带 response;METADATA 档下两边都是空数组。

client_gid + sequence_number 是把一次完整 RPC 的 4 个事件配对的钥匙,对离线分析和回放都至关重要。

ros2 service echo --flow-style /add_two_ints 的实际输出印证了该结构:

yaml
info:
  event_type: REQUEST_SENT
  stamp: {sec: 1709432402, nanosec: 680094264}
  client_gid: [1, 15, 0, 18, 86, 208, 115, 86, 0, 0, 0, 0, 0, 0, 21, 3]
  sequence_number: 247
request: [{a: 2, b: 3}]      # REQUEST_* 时带内容
response: []                  # 此时无响应

注:自动生成的 _Event 消息是构建期产物,仓库里没有静态 .msg 文件。如需逐字段精确文本,在装好 ROS 2 的环境跑 ros2 interface show <pkg>/srv/<Svc>_Event


3. Action 录制:2 个 topic + 3 个 service

Action 不是传输层原语,建立在 2 个 topic + 3 个 service 之上。对 /my_action(类型 pkg/action/Fibonacci),5 个底层实体:

实体 名字 类型
status topic /my_action/_action/status action_msgs/msg/GoalStatusArray
feedback topic /my_action/_action/feedback pkg/action/Fibonacci_FeedbackMessage(自动生成)
send_goal service /my_action/_action/send_goal pkg/action/Fibonacci_SendGoal(自动生成 srv)
get_result service /my_action/_action/get_result pkg/action/Fibonacci_GetResult(自动生成 srv)
cancel_goal service /my_action/_action/cancel_goal action_msgs/srv/CancelGoal标准类型

每个 service 又各有自己的 _service_event topic:

/my_action/_action/send_goal/_service_event
/my_action/_action/get_result/_service_event
/my_action/_action/cancel_goal/_service_event

所以 rosbag2 没有 "action 原语" 的录制语义,就是把上述 2 个普通 topic + 3 个 _service_event topic 当普通 topic/service 逐个录。CLI:--actions A1 [...] / --all-actions / --exclude-actions A1 [...]ros2 bag info 会把同一 action 的 5 个实体聚合成一条 "Action information" 展示。

3.1 status topic 用的标准消息

msg
# https://github.com/ros2/rcl_interfaces/blob/rolling/action_msgs/msg/GoalStatusArray.msg
# An array of goal statuses.
GoalStatus[] status_list
msg
# https://github.com/ros2/rcl_interfaces/blob/rolling/action_msgs/msg/GoalStatus.msg
int8 STATUS_UNKNOWN   = 0   # 未正确设置
int8 STATUS_ACCEPTED  = 1   # 已接受,等待执行
int8 STATUS_EXECUTING = 2   # 正在执行
int8 STATUS_CANCELING = 3   # 客户端请求取消且已被接受
int8 STATUS_SUCCEEDED = 4   # 成功完成
int8 STATUS_CANCELED  = 5   # 被外部请求取消
int8 STATUS_ABORTED   = 6   # 未被外部请求而终止

# Goal info (contains ID and timestamp).
GoalInfo goal_info

# Action goal state-machine status.
int8 status
msg
# https://github.com/ros2/rcl_interfaces/blob/rolling/action_msgs/msg/GoalInfo.msg
unique_identifier_msgs/UUID goal_id
builtin_interfaces/Time stamp

即 status topic 上每条消息是一个 GoalStatusArray,是 server 当前所有 goal 的状态快照(goal_id + stamp + 状态机值)。

3.2 cancel_goal service 用的标准 srv

srv
# https://github.com/ros2/rcl_interfaces/blob/rolling/action_msgs/srv/CancelGoal.srv
# 取消策略:
# - goal ID 为零且 timestamp 为零:取消所有 goal
# - goal ID 为零且 timestamp 非零:取消所有在该 timestamp 及之前接受的 goal
# - goal ID 非零且 timestamp 为零:取消指定 ID 的 goal(不论接受时间)
# - goal ID 非零且 timestamp 非零:取消指定 ID 及该 timestamp 及之前接受的所有 goal
GoalInfo goal_info
---
# 返回码
int8 ERROR_NONE=0            # 请求被接受,goals_canceling 非空
int8 ERROR_REJECTED=1        # 请求被拒绝,goals_canceling 为空
int8 ERROR_UNKNOWN_GOAL_ID=2 # 该 goal ID 不存在
int8 ERROR_GOAL_TERMINATED=3 # goal 已处于终止态,不可取消

int8 return_code
GoalInfo[] goals_canceling

3.3 feedback / send_goal / get_result 是生成类型

由 rclcpp_action / rclpy_action 在编译期从用户的 .action 文件生成(命名与字段组成,非仓库静态文件):

  • feedback <Action>_FeedbackMessageaction_msgs/msg/GoalInfo goal_info + <Action>_Feedback feedback
  • send_goal <Action>_SendGoal:请求 GoalInfo goal_info + <Action>_Goal goal;响应 bool accepted + builtin_interfaces/Time stamp
  • get_result <Action>_GetResult:请求 GoalInfo goal_info + builtin_interfaces/Duration timeout;响应 int8 status(同 GoalStatus 状态值)+ <Action>_Result result

这三个是构建期产物,逐字段精确文本用 ros2 interface show <pkg>/action/<Type>_<Suffix> 获取。CancelGoal 是唯一非生成、标准的 action service,已在上文逐字段给出。


4. 落盘结构

每条消息存为一个记录,核心字段:

  • 时间戳recv_timestamp(落盘用)+ 可选 send_timestamp(来自 message_info,中间件提供时才填;MCAP 自 Jazzy 起双时间戳都存)。
  • 序列化负载CDR(Common Data Representation,DDS 线序格式)。ros2 bag info 里每条都标 Serialization Format: cdr
  • 所属 topic:topic 名 + 类型名 + offered_qos_profiles + type_description_hash(RIHS 哈希)。
  • 存储后端:默认 MCAP(自 Humble 起默认),可选 SQLite3(.db3)。

关键事实:service event 消息和普通 topic 消息在存储层完全同构——出现在同一张 topics_with_message_count 列表里,没有独立的 "services" 存储段,区别仅是 name/_service_eventtype*_Event。下面这份 metadata.yaml 来自 rosbag2_tests 里一个 topic + service event 混合 bag(精简了 QoS 串):

yaml
# https://github.com/ros2/rosbag2/blob/jazzy/rosbag2_tests/resources/mcap/bag_with_topics_and_service_events/metadata.yaml
rosbag2_bagfile_information:
  version: 8
  storage_identifier: mcap
  duration: { nanoseconds: 70653944 }
  message_count: 10
  topics_with_message_count:
    - topic_metadata:
        name: /test_service1/_service_event    # service 事件 topic
        type: test_msgs/srv/BasicTypes_Event    # 生成的 _Event 类型
        serialization_format: cdr
      message_count: 4
    - topic_metadata:
        name: /test_service2/_service_event
        type: test_msgs/srv/BasicTypes_Event
        serialization_format: cdr
      message_count: 4
    - topic_metadata:
        name: /test_topic1                      # 普通 topic
        type: test_msgs/msg/Strings
        serialization_format: cdr
      message_count: 1
    - topic_metadata:
        name: /test_topic2
        type: test_msgs/msg/Strings
        serialization_format: cdr
      message_count: 1

metadata.yaml 其它常见字段:starting_timerelative_file_paths / files(分片)、compression_format / compression_mode(如 zstd)、custom_dataros_distro(Jazzy 起新增)。Jazzy 起 metadata 还会同时写进 bag 文件本身(不再只靠 metadata.yaml),让 bag 自包含。


5. 回放:能放成活 RPC,但默认不会

一句话:默认 ros2 bag play 只是把 _service_event topic 当普通 topic 重发(仅供分析,不是真服务调用);要真正发起 service 请求必须显式加 flag。两项能力都自 Jazzy 起。

5.1 真正回放成活服务:--publish-service-requests

bash
ros2 bag play --publish-service-requests <bag>

加了这个 flag,player 会动态构造一个 GenericClient(类比录 topic 用的 GenericPublisher),把记录里的 request 真正发给当前网络里在线的 service server。server 端会真的处理请求并产生新的响应(用 ros2 service echo 能看到新的 REQUEST_RECEIVED / RESPONSE_SENT),证明这是一次活 RPC 而非重发 topic。

参数 含义
--publish-service-requests 把记录的 request 真正发给 live server(不加则只重发 _service_event
--service-requests-source {service_introspection, client_introspection} 选 request 来源,默认 service_introspection
--services S1 [...] / --exclude-services S1 [...] 只回放 / 排除指定 service

5.2 动作回放:--send-actions-as-client

参数 含义
--send-actions-as-client 以 action client 身份,依据录到的 send_goal / cancel_goal / get_result 事件分别重发请求
--actions A1 [...] / --exclude-actions A1 [...] 选择 / 排除要回放的 action

action 的 3 个 service 走与服务相同的回放路径。重要限制

  • 会回放:3 个 service 的请求侧(send_goal / get_result / cancel_goal),player 充当 client 发给当前在线的 action server。
  • 不会回放:status topic 和 feedback topic——语义上由 server 产生,回放期间应由那个 live action server 自己产生新的 status/feedback。

实现上,player 为每个检测到的 action 创建一个 PlayerActionClient,按事件类型分发,并对 goal ID 做重映射(给每个回放的 goal 分配新 UUID、维护 "录制 goal ID → 新 ID" 映射),避免录制会话与当前会话的 goal ID 冲突。

5.3 回放的固有局限

  • 回放时只发 request 侧;响应来自当前 live server,不是录到的响应。所以回放不是 "精确重现当时 client 收到的响应",而是 "用当时的请求再问一次现在的 server"。
  • 需要目标 server 在回放时确实在线,否则请求发不出去。
  • status/feedback 不回放(见 5.2)。

5.4 为什么回放比录制晚、且只在新发行版有

rclcpp 原本没有 "用动态类型支持发任意 service 请求" 的 API,回放需要先在 rclcpp 加 service type support helperrclcpp#2209,Jazzy 合入)再做 GenericClient。所以回放能力比录制晚一个版本。

5.5 跨发行版能力对照

能力 Iron Jazzy 及以后 Humble 及更早
service introspection(_service_event + ServiceEventInfo
ros2 service echo 看服务通信
rosbag2 录制 service(--services / --all-services
rosbag2 回放 service 成活 RPC(--publish-service-requests
rosbag2 录制 / 回放 action(--actions / --send-actions-as-client

附:service introspection 的正式化提案是 REP-2012,但截至现在它仍是 Open(未合并)状态——REP 文本从未作为编号 REP 正式落地,但功能已随 Iron 发布。


要点

  • service / action 都不是一等录制对象:service 靠 _service_event 隐藏 topic 旁路成普通 topic;action 拆成 2 topic + 3 service 逐个录。
  • 录下来的 service 消息是自动生成的 _EventServiceEventInfo(4 态 event_type + stamp + client_gid + sequence_number)+ request[] + response[]
  • client_gid + sequence_number 是把一次 RPC 的请求/响应配对的钥匙,client 和 server 各发 2 个事件。
  • 落盘统一是 CDR + MCAP,service event 和普通 topic 在存储层完全同构,零特殊存储。
  • 回放默认只重发旁路流(分析用);真回放要显式 flag(--publish-service-requests / --send-actions-as-client),且只重发 request 侧,响应来自当前 live server。
  • action 回放时 status / feedback 不回放(应由 server 产生),并做 goal ID 重映射防冲突。
  • service introspection 自 Ironrosbag2 录制 + 回放自 Jazzy;Humble 全无。回放晚于录制,是因为依赖底层新增的泛型 client API。

参考来源

基于 VitePress 构建