《Service 服务通信实战:请求-响应模型讲透》
ROS 2 的 Topic 适合连续发布数据,但很多控制动作更像一次明确的“请求并等待结果”:切换机器人模式、清零里程、读取一次配置、触发一次校准。Service(服务)正是为这种请求-响应模型设计的通信机制。
本文从最小模型开始,创建一个 SetRobotMode 自定义服务,完成 Python 服务端、Python 客户端和 C++ 跨包客户端。读者还会看到服务发现、命名空间、异步调用、超时、CLI 验证、Launch 启动、并发回调和接口变更后的排查流程。
1. 版本、环境与教程边界
| 项目 | 本文基线 | 说明 |
|---|---|---|
| ROS 2 主示例 | Humble | Ubuntu 22.04 |
| ROS 2 兼容概念 | Jazzy | Ubuntu 24.04 |
| 构建工具 | colcon | 负责工作空间拓扑构建 |
| 服务接口包 | ament_cmake | 通过 rosidl 生成服务类型 |
| 业务节点 | Python 3、C++17 | 演示同语言与跨语言调用 |
| 默认终端 | Bash | Windows 用户需按发行版调整命令 |
本文使用 ROS 2 的 .srv 接口定义。ROS 1 的 srv、catkin_make 和 rosservice 命令不能直接照搬到 ROS 2。Humble 与 Jazzy 的 rclpy、rclcpp 和 rosidl 基础接口大体一致,但具体发行版的生成目录、默认编译器和第三方包可能不同,遇到差异时应优先核对当前发行版官方文档。
先确认环境:
source /opt/ros/humble/setup.bash
printenv ROS_VERSION
printenv ROS_DISTRO
which ros2
which colcon
python3 --version
安装本文所需工具:
sudo apt update
sudo apt install \
python3-colcon-common-extensions \
python3-rosdep \
ros-humble-rosidl-default-generators \
ros-humble-rosidl-default-runtime \
ros-humble-rclpy \
ros-humble-rclcpp \
ros-humble-launch \
ros-humble-launch-ros \
ros-humble-ros2launch
Jazzy 用户把包名前缀替换为 ros-jazzy-*,并把环境路径替换为 /opt/ros/jazzy/setup.bash。如果使用桌面版 ROS 2,部分依赖可能已经安装,apt 会保留已安装版本。
2. Service 的核心模型:一次请求对应一次响应
Service 把交互拆成两端:Client(客户端)负责发出请求,Server(服务端)负责处理请求并填写响应。服务端回调返回后,请求才算完成。与 Topic 不同,Service 不以持续发布为目标,也不保证服务端一定在线或一定能及时返回。
| 对比项 | Topic | Service |
|---|---|---|
| 交互方向 | 发布者到订阅者 | 客户端到服务端再返回 |
| 数据形态 | 连续消息流 | 单次 request-response |
| 典型用途 | 传感器、状态、轨迹 | 控制动作、查询、配置 |
| 调用结果 | 没有单独返回值 | 有明确响应和完成时机 |
一个服务调用可抽象为下面的时间线:
Client
-> wait for service discovery
-> send request
Server callback
-> validate request
-> update state or compute result
-> fill response
Client
-> receive response or timeout
.srv 文件使用 --- 分隔请求和响应两部分。请求字段描述客户端要提供的数据,响应字段描述服务端必须返回的数据。生成器会为同一个服务生成 Request、Response 和服务类型支持;Python 与 C++ 的调用写法不同,但它们共享同一个运行时接口名。
服务名只是图中的可发现名称,例如 set_robot_mode。完整类型名则是 robot_interfaces/srv/SetRobotMode。服务名可以通过命名空间和重映射改变,类型名由接口包和文件名决定,二者不要混淆。
Service 的成功含义也需要由接口设计者定义。收到响应不等于业务成功,响应中的 accepted、message 或错误码才是业务判断依据。客户端必须同时处理服务不存在、调用超时、响应拒绝和服务端进程退出四种情况。
3. 设计 SetRobotMode 服务契约
本教程的示例解决一个常见控制问题:客户端请求某个机器人切换运行模式,服务端检查编号和模式范围,更新内存状态并返回是否接受、当前模式和说明文字。
服务接口不直接使用 Topic 中的状态消息,避免把“请求改变状态”和“状态持续广播”混成一个概念。实际项目可以让服务端成功后再通过 Topic 发布新的状态,形成命令与状态的分离。
文件:robot_interfaces/srv/SetRobotMode.srv
string robot_id
uint8 requested_mode
---
bool accepted
uint8 current_mode
string message
本文约定模式值:0 表示空闲,1 表示运行,2 表示暂停,3 表示故障。模式表属于业务协议,当前没有把常量写进 .srv,而是在服务端和客户端代码中集中定义,避免初学者混淆服务类型、请求类型和常量生成位置。
| 字段 | 方向 | 约束与含义 |
|---|---|---|
robot_id |
请求 | 非空的稳定机器人编号 |
requested_mode |
请求 | 0 到 3 的模式值 |
accepted |
响应 | 服务端是否接受这次请求 |
current_mode |
响应 | 处理后服务端记录的模式 |
message |
响应 | 面向日志和调试的说明 |
接口设计时必须明确空字符串、未知模式和重复请求的语义。本文把空编号和超出范围的模式视为拒绝,把重复设置同一个模式视为成功但返回提示。生产项目还应补充权限、急停、超时和状态机约束。
4. 创建工作空间和三个功能包
执行位置:已经加载 ROS 2 Humble 的 Bash 终端。
mkdir -p ~/service_demo_ws/src
cd ~/service_demo_ws/src
ros2 pkg create robot_interfaces \
--build-type ament_cmake
ros2 pkg create service_demo_py \
--build-type ament_python
ros2 pkg create service_demo_cpp \
--build-type ament_cmake
mkdir -p robot_interfaces/srv
mkdir -p service_demo_py/launch
mkdir -p service_demo_cpp/src
最终目录应接近下面的结构:
service_demo_ws/
`-- src/
|-- robot_interfaces/
| |-- srv/SetRobotMode.srv
| |-- CMakeLists.txt
| `-- package.xml
|-- service_demo_py/
| |-- launch/service_demo.launch.py
| |-- resource/service_demo_py
| |-- service_demo_py/
| | |-- __init__.py
| | |-- mode_server.py
| | `-- mode_client.py
| |-- package.xml
| |-- setup.cfg
| `-- setup.py
`-- service_demo_cpp/
|-- src/mode_client.cpp
|-- CMakeLists.txt
`-- package.xml
三个包保持单向依赖:两个业务包依赖 robot_interfaces,接口包不依赖业务包。接口包只负责生成服务类型,业务包负责节点、Launch 和实际逻辑,这种拆分便于后续增加其他语言的消费者。
5. 配置并生成自定义服务接口
robot_interfaces/srv/SetRobotMode.srv 的完整定义已经在第 3 节给出,本节直接配置接口包的生成规则,不再重复同一代码块。开始构建前,应确认实际文件仍包含 robot_id、requested_mode、accepted、current_mode 和 message 五个字段,并且请求区与响应区之间只有一行 ---。
文件:robot_interfaces/package.xml
<?xml version="1.0"?>
<package format="3">
<name>robot_interfaces</name>
<version>0.0.1</version>
<description>Custom service interfaces for service communication.</description>
<maintainer email="developer@example.com">Developer</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<buildtool_depend>rosidl_default_generators</buildtool_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>
文件:robot_interfaces/CMakeLists.txt
cmake_minimum_required(VERSION 3.8)
project(robot_interfaces)
find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
"srv/SetRobotMode.srv"
)
ament_export_dependencies(rosidl_default_runtime)
ament_package()
这个接口没有引用其他消息,因此 CMake 不需要 DEPENDENCIES 列表。如果以后在请求或响应中加入 builtin_interfaces/Time、geometry_msgs/Pose 等嵌套类型,必须同步修改 package.xml、find_package() 和 rosidl_generate_interfaces(... DEPENDENCIES ...)。
第一次只构建接口包,先把接口问题和节点问题分开:
cd ~/service_demo_ws
rosdep install --from-paths src --ignore-src -r -y
colcon build --packages-select robot_interfaces
source install/setup.bash
验证生成结果:
ros2 pkg prefix robot_interfaces
ros2 interface list | grep robot_interfaces
ros2 interface show robot_interfaces/srv/SetRobotMode
ros2 interface proto robot_interfaces/srv/SetRobotMode
预期 show 输出包含请求区的 robot_id、requested_mode,以及响应区的 accepted、current_mode、message。proto 会输出可供 ros2 service call 使用的 YAML 原型。如果源文件存在但 show 找不到类型,优先检查是否 source 了当前工作空间,以及 CMake 是否确实调用了 rosidl_generate_interfaces()。
6. Python 服务端:回调中校验请求并返回响应
这个示例解决的问题是:让一个 Python 节点提供可复用的 set_robot_mode 服务,并把请求校验、状态更新和响应填写放在同一个明确的回调中。
文件:service_demo_py/service_demo_py/mode_server.py
import rclpy
from rclpy.node import Node
from robot_interfaces.srv import SetRobotMode
MODE_NAMES = {
0: 'IDLE',
1: 'RUNNING',
2: 'PAUSED',
3: 'ERROR',
}
class ModeServer(Node):
def __init__(self):
super().__init__('mode_server')
self.current_modes = {}
self.service = self.create_service(
SetRobotMode,
'set_robot_mode',
self.handle_set_mode,
)
self.get_logger().info(
'service ready: set_robot_mode')
def handle_set_mode(self, request, response):
robot_id = request.robot_id.strip()
requested_mode = int(request.requested_mode)
if not robot_id:
response.accepted = False
response.current_mode = 0
response.message = 'robot_id must not be empty'
return response
if requested_mode not in MODE_NAMES:
current_mode = self.current_modes.get(robot_id, 0)
response.accepted = False
response.current_mode = current_mode
response.message = (
'requested_mode must be between 0 and 3')
return response
previous_mode = self.current_modes.get(robot_id, 0)
self.current_modes[robot_id] = requested_mode
response.accepted = True
response.current_mode = requested_mode
if previous_mode == requested_mode:
response.message = (
'mode already set to ' + MODE_NAMES[requested_mode])
else:
response.message = (
'mode changed from %s to %s'
% (
MODE_NAMES[previous_mode],
MODE_NAMES[requested_mode],
)
)
self.get_logger().info(
'robot=%s requested=%s accepted=%s'
% (
robot_id,
MODE_NAMES[requested_mode],
response.accepted,
)
)
return response
def main(args=None):
rclpy.init(args=args)
node = ModeServer()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
服务端的关键点有三个。create_service() 注册服务类型、服务名和回调;回调必须返回与服务类型匹配的 response 对象;请求失败时也要填写可观察的响应,而不是让客户端一直等待。current_modes 是进程内字典,节点重启后数据会丢失,因此它只用于演示,不是持久化数据库。
服务端启动命令:
cd ~/service_demo_ws
colcon build --packages-up-to service_demo_py
source install/setup.bash
ros2 run service_demo_py mode_server
验证服务已经出现:
ros2 service list
ros2 service type /set_robot_mode
ros2 service info /set_robot_mode
预期列表中出现 /set_robot_mode,类型为 robot_interfaces/srv/SetRobotMode,服务端数量为 1。注意 ros2 service list 只说明图中发现了服务,不能证明回调逻辑一定正确。
7. Python 客户端:异步请求、等待和超时
这个示例解决的问题是:客户端不能假设服务永远在线,需要先等待服务发现,再发送异步请求并明确判断响应是否在规定时间内返回。
文件:service_demo_py/service_demo_py/mode_client.py
import sys
import rclpy
from rclpy.node import Node
from robot_interfaces.srv import SetRobotMode
class ModeClient(Node):
def __init__(self):
super().__init__('mode_client')
self.client = self.create_client(
SetRobotMode,
'set_robot_mode',
)
def request_mode(self, robot_id, requested_mode):
if not self.client.wait_for_service(timeout_sec=2.0):
self.get_logger().error(
'service set_robot_mode is not available')
return False
request = SetRobotMode.Request()
request.robot_id = robot_id
request.requested_mode = requested_mode
future = self.client.call_async(request)
rclpy.spin_until_future_complete(
self,
future,
timeout_sec=5.0,
)
if not future.done():
self.get_logger().error(
'service request timed out')
return False
response = future.result()
if response is None:
self.get_logger().error(
'service returned no response')
return False
self.get_logger().info(
'accepted=%s current_mode=%d message=%s'
% (
response.accepted,
response.current_mode,
response.message,
)
)
return bool(response.accepted)
def main(args=None):
rclpy.init(args=args)
node = ModeClient()
robot_id = sys.argv[1] if len(sys.argv) > 1 else 'robot_01'
requested_mode = int(sys.argv[2]) if len(sys.argv) > 2 else 1
try:
success = node.request_mode(robot_id, requested_mode)
return 0 if success else 2
finally:
node.destroy_node()
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
raise SystemExit(main())
运行客户端:
source ~/service_demo_ws/install/setup.bash
ros2 run service_demo_py mode_client robot_01 1
ros2 run service_demo_py mode_client robot_01 2
ros2 run service_demo_py mode_client robot_01 9
前两次请求应返回 accepted=True,模式分别变为 RUNNING 和 PAUSED;第三次请求会被服务端拒绝,返回 accepted=False 和范围错误。客户端进程仍能正常退出,只是返回码为 2,这比把业务拒绝当作通信异常更容易被上层流程处理。
最重要的注意事项是:call_async() 只创建请求并返回 Future(未来结果),必须让执行器继续运行,Future 才可能完成。直接调用同步等待而没有 spin,容易造成客户端永远收不到响应。
8. 从 package.xml 到 Python 入口安装
文件:service_demo_py/package.xml
<?xml version="1.0"?>
<package format="3">
<name>service_demo_py</name>
<version>0.0.1</version>
<description>Python service server and client examples.</description>
<maintainer email="developer@example.com">Developer</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_python</buildtool_depend>
<exec_depend>rclpy</exec_depend>
<exec_depend>robot_interfaces</exec_depend>
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>
<exec_depend>ros2launch</exec_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
文件:service_demo_py/setup.py
import os
from glob import glob
from setuptools import find_packages, setup
package_name = 'service_demo_py'
setup(
name=package_name,
version='0.0.1',
packages=find_packages(exclude=['test']),
data_files=[
(
'share/ament_index/resource_index/packages',
['resource/' + package_name],
),
('share/' + package_name, ['package.xml']),
(
os.path.join('share', package_name, 'launch'),
glob('launch/*.launch.py'),
),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='Developer',
maintainer_email='developer@example.com',
description='Python service server and client examples.',
license='Apache-2.0',
entry_points={
'console_scripts': [
'mode_server = service_demo_py.mode_server:main',
'mode_client = service_demo_py.mode_client:main',
],
},
)
文件:service_demo_py/setup.cfg
[develop]
script_dir=$base/lib/service_demo_py
[install]
install_scripts=$base/lib/service_demo_py
resource/service_demo_py 和 service_demo_py/__init__.py 保持 ros2 pkg create 生成的空文件即可。ROS 包 robot_interfaces 不应写入 setup.py 的 install_requires,它不是 PyPI 包;构建拓扑和运行时依赖由 package.xml、rosdep 和环境索引共同处理。
重新构建 Python 包:
cd ~/service_demo_ws
colcon build --packages-up-to service_demo_py
source install/setup.bash
ros2 pkg executables service_demo_py
预期能看到 mode_server 和 mode_client。如果包能被 ros2 pkg list 找到但入口不存在,优先检查 setup.py 的 console_scripts、包目录名和 find_packages(),不要先修改服务代码。
9. C++ 跨包客户端:同一服务的另一种调用方式
这个示例解决的问题是:验证由 Python 服务端生成的服务接口可以被 C++ 包直接包含和调用。跨语言的关键不在于复制 Python 代码,而在于两个包使用同一个完整服务类型。
文件:service_demo_cpp/package.xml
<?xml version="1.0"?>
<package format="3">
<name>service_demo_cpp</name>
<version>0.0.1</version>
<description>C++ client for the custom service.</description>
<maintainer email="developer@example.com">Developer</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>rclcpp</depend>
<depend>robot_interfaces</depend>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>
文件:service_demo_cpp/CMakeLists.txt
cmake_minimum_required(VERSION 3.8)
project(service_demo_cpp)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(robot_interfaces REQUIRED)
add_executable(mode_client_cpp src/mode_client.cpp)
ament_target_dependencies(mode_client_cpp
rclcpp
robot_interfaces
)
target_compile_features(mode_client_cpp PUBLIC cxx_std_17)
install(TARGETS mode_client_cpp
DESTINATION lib/${PROJECT_NAME}
)
ament_package()
文件:service_demo_cpp/src/mode_client.cpp
#include <chrono>
#include <cstdint>
#include <memory>
#include <string>
#include "rclcpp/rclcpp.hpp"
#include "robot_interfaces/srv/set_robot_mode.hpp"
using namespace std::chrono_literals;
class ModeClient : public rclcpp::Node
{
public:
using SetRobotMode = robot_interfaces::srv::SetRobotMode;
ModeClient()
: Node("mode_client_cpp")
{
declare_parameter<std::string>("robot_id", "robot_01");
declare_parameter<int64_t>("requested_mode", 1);
client_ = create_client<SetRobotMode>("set_robot_mode");
}
bool send_request()
{
const auto robot_id = get_parameter("robot_id").as_string();
const auto requested_mode = get_parameter("requested_mode").as_int();
if (robot_id.empty() || requested_mode < 0 || requested_mode > 3) {
RCLCPP_ERROR(
get_logger(),
"robot_id must be non-empty and mode must be 0..3");
return false;
}
if (!client_->wait_for_service(3s)) {
RCLCPP_ERROR(
get_logger(),
"service set_robot_mode is not available");
return false;
}
auto request = std::make_shared<SetRobotMode::Request>();
request->robot_id = robot_id;
request->requested_mode = static_cast<uint8_t>(requested_mode);
auto future = client_->async_send_request(request);
const auto status = rclcpp::spin_until_future_complete(
shared_from_this(),
future,
5s);
if (status != rclcpp::FutureReturnCode::SUCCESS) {
RCLCPP_ERROR(get_logger(), "service request timed out");
return false;
}
const auto response = future.get();
RCLCPP_INFO(
get_logger(),
"accepted=%s current_mode=%u message=%s",
response->accepted ? "true" : "false",
static_cast<unsigned int>(response->current_mode),
response->message.c_str());
return response->accepted;
}
private:
rclcpp::Client<SetRobotMode>::SharedPtr client_;
};
int main(int argc, char * argv[])
{
rclcpp::init(argc, argv);
auto node = std::make_shared<ModeClient>();
const bool success = node->send_request();
rclcpp::shutdown();
return success ? 0 : 2;
}
C++ 生成头文件使用小写下划线:robot_interfaces/srv/set_robot_mode.hpp;C++ 类型使用 robot_interfaces::srv::SetRobotMode。Python 导入则是 from robot_interfaces.srv import SetRobotMode。服务源文件名、C++ 头文件名、Python 模块名和 CLI 类型名看起来不同,但都指向同一份接口。
构建并运行 C++ 客户端:
cd ~/service_demo_ws
colcon build --packages-up-to service_demo_cpp
source install/setup.bash
ros2 run service_demo_cpp mode_client_cpp \
--ros-args \
-p robot_id:=robot_02 \
-p requested_mode:=2
服务端仍运行时,C++ 客户端应打印 accepted=true、current_mode=2 和模式变更信息。若服务端未启动,客户端会在约 3 秒后退出并返回失败。CMake 中同时出现 find_package(robot_interfaces REQUIRED) 和 ament_target_dependencies() 是必要条件,只写源码中的 include 不足以让编译器找到生成头文件和类型支持。
10. 请求、响应与隐藏通信端点
ROS 2 Service 在底层通过一组由中间件管理的请求和响应端点完成通信。用户通常只操作服务名,不直接操作这些隐藏 Topic。这样可以保留请求-响应语义,同时由 ROS 2 处理发现、序列化和传输。
| 可见对象 | 作用 | 推荐观察方式 |
|---|---|---|
| 服务名 | 应用层调用入口 | ros2 service list |
| 服务类型 | 约束请求与响应字段 | ros2 service type |
| 服务端 | 注册回调并返回响应 | ros2 service info |
| 隐藏端点 | 承载请求和响应传输 | 仅在排错时查看 |
不要把 Service 当成“只发一次的 Topic”。Topic 没有天然的请求完成状态,Service 也不适合传输持续高频数据。若服务端需要持续反馈进度,应考虑 Action;若客户端只关心当前状态,应由 Topic 持续发布状态,Service 只负责触发改变。
服务名支持相对名称。代码中的 set_robot_mode 在根命名空间下解析为 /set_robot_mode,放进 robot1 命名空间后解析为 /robot1/set_robot_mode。客户端和服务端必须处于同一解析结果,或者通过 remap 显式连接到同一个服务名。
Service 的 QoS、Domain ID 和网络发现仍会影响可用性,但调用端一般不需要像 Topic 那样手工匹配发布者与订阅者 QoS。跨机器调用失败时,应检查 ROS_DOMAIN_ID、防火墙、DDS 实现和网络接口,而不是只检查服务回调。
11. 同步调用、异步调用和执行器
本文主线采用 call_async() 或 async_send_request(),原因是异步调用可以让节点继续处理其他回调。服务端回调可能访问硬件、文件或规划器,耗时不可控;客户端若同步阻塞整个节点,可能错过定时器、取消信号和其他通信。
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 一次性命令行工具 | spin 到 Future 完成 | 生命周期短且逻辑简单 |
| 常驻控制节点 | 异步请求加定时器 | 不阻塞其他回调 |
| 服务回调继续调用服务 | 多线程执行器或重新设计 | 避免同一线程死锁 |
| 长时间任务 | Action | 有反馈、取消和结果 |
Python 的 rclpy.spin_until_future_complete() 会让当前节点执行,直到 Future 完成或超时。C++ 的 spin_until_future_complete() 使用相同思想。超时之后要记录失败、释放请求上下文,并决定是否重试;不要在无上限循环中不断创建新请求。
服务端回调也可能并发执行。默认单线程执行器适合短小、无阻塞的状态更新;如果使用多线程执行器,访问 current_modes 等共享数据时要设计锁或使用互斥的回调组。更简单的策略是让服务回调只做校验和入队,把耗时工作交给后台任务,再用明确的结果状态返回。
一个常见错误是:服务端回调内部又同步等待另一个服务,而两个回调都在同一个互斥回调组和单线程执行器中。外层回调占住线程,内层请求无法被处理,最终双方都等待。遇到这种结构,应改为异步状态机、分离回调组或多线程执行器,并且明确资源锁的顺序。
12. CLI 验证:先确认类型,再验证业务结果
CLI 是排查 Service 的最短路径。服务端运行后,先确认服务名和类型:
ros2 service list
ros2 service find robot_interfaces/srv/SetRobotMode
ros2 service type /set_robot_mode
ros2 service info /set_robot_mode
手工调用一次服务:
ros2 service call \
/set_robot_mode \
robot_interfaces/srv/SetRobotMode \
"{robot_id: 'cli_robot', requested_mode: 1}"
预期响应类似:
requester: making request: robot_id: cli_robot, requested_mode: 1
response:
accepted: true
current_mode: 1
message: mode changed from IDLE to RUNNING
再次发送相同请求,message 应变为 mode already set to RUNNING。发送空编号或 requested_mode: 9,响应应为 accepted: false。这一步分别验证了接口字段解析、服务发现、服务端校验和响应内容。
如果 ros2 service call 报字段错误,先执行:
ros2 interface show robot_interfaces/srv/SetRobotMode
ros2 interface proto robot_interfaces/srv/SetRobotMode
严格按照原型中的字段名和 YAML 层级填写。服务调用不能把响应字段写进请求,也不能把整数写成带单位的字符串。命令行成功返回也不代表业务一定接受,必须观察 accepted。
13. 参数、命名空间与服务重映射
服务名和业务参数是两层不同配置。robot_id、模式值和超时属于请求或节点参数;set_robot_mode 属于 ROS 图名称。不要把一个 Launch 参数误以为会自动修改服务名,也不要把服务重映射写进业务代码。
直接重映射客户端:
ros2 run service_demo_py mode_client robot_01 1 \
--ros-args \
-r set_robot_mode:=/robot1/set_robot_mode
服务端也必须使用相同解析后的名称,或者启动时把服务端重映射到 /robot1/set_robot_mode。如果只重映射一端,两个节点都能启动,但客户端会一直报服务不可用。
命名空间下的验证:
ros2 service list | grep robot1
ros2 service type /robot1/set_robot_mode
ros2 service call \
/robot1/set_robot_mode \
robot_interfaces/srv/SetRobotMode \
"{robot_id: 'robot_01', requested_mode: 2}"
对于多机器人系统,建议把服务名保持相对形式,并通过 Launch 的 namespace 统一作用域。这样同一个节点可以启动多份,而不需要复制代码或手工拼接绝对名称。
14. Launch 综合入口与多实例
这个 Launch 文件同时启动 Python 服务端和 C++ 客户端,并把它们放进同一个 namespace。客户端请求值通过参数传入,服务名仍由代码中的相对名称解析。
文件:service_demo_py/launch/service_demo.launch.py
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node
from launch_ros.parameter_descriptions import ParameterValue
def generate_launch_description():
namespace = LaunchConfiguration('namespace')
robot_id = LaunchConfiguration('robot_id')
requested_mode = LaunchConfiguration('requested_mode')
return LaunchDescription([
DeclareLaunchArgument(
'namespace',
default_value='robot1',
),
DeclareLaunchArgument(
'robot_id',
default_value='robot_01',
),
DeclareLaunchArgument(
'requested_mode',
default_value='1',
),
Node(
package='service_demo_py',
executable='mode_server',
namespace=namespace,
name='mode_server',
output='screen',
),
Node(
package='service_demo_cpp',
executable='mode_client_cpp',
namespace=namespace,
name='mode_client_cpp',
output='screen',
parameters=[{
'robot_id': ParameterValue(
robot_id,
value_type=str,
),
'requested_mode': ParameterValue(
requested_mode,
value_type=int,
),
}],
),
])
Launch 文件安装在 Python 包的 share/service_demo_py/launch 目录,因此 setup.py 的 data_files 不能省略。先构建接口、C++ 客户端和 Python Launch 包:
cd ~/service_demo_ws
colcon build --symlink-install \
--packages-up-to service_demo_py service_demo_cpp
source install/setup.bash
运行综合入口:
ros2 launch service_demo_py service_demo.launch.py \
namespace:=robot1 \
robot_id:=robot_01 \
requested_mode:=2
验证:
ros2 service type /robot1/set_robot_mode
ros2 service info /robot1/set_robot_mode
ros2 node list
预期出现 /robot1/mode_server 和 /robot1/mode_client_cpp,客户端输出 accepted=true、current_mode=2。另开终端把 namespace 改为 robot2,可以启动第二份服务实例;两份服务的状态字典彼此隔离。
最重要的注意事项是 Launch argument 必须通过 parameters 映射给节点,且 ParameterValue 要明确类型。Launch 的字符串参数不会自动变成 C++ 的整数参数,类型错误会在节点启动或参数声明阶段暴露。
15. 服务端状态、幂等性与错误语义
Service 适合表达“请求一个动作”,但是否可以安全重试取决于动作本身。把模式设置为 2 是幂等操作:重复请求不会造成额外副作用;触发一次机械动作、写入一次文件或发送一次脉冲则可能不是幂等的。
| 请求类型 | 是否适合自动重试 | 设计建议 |
|---|---|---|
| 设置目标模式 | 通常适合 | 返回当前模式和接受状态 |
| 清零计数器 | 需要谨慎 | 增加请求 ID 或版本号 |
| 启动一次动作 | 不一定 | 记录去重键和执行状态 |
| 查询当前配置 | 通常适合 | 明确快照时间和数据来源 |
响应中的 accepted 只能表达服务端是否接受,不等于动作已经完成。如果动作是异步执行,应把响应设计为“已接收、任务编号”,再用 Topic 或 Action 发布进度和最终结果。不要让 Service 回调阻塞几分钟等待硬件动作完成,否则客户端超时、执行器饥饿和系统级级联故障都会更容易发生。
错误消息应能帮助调用者采取下一步行动。robot_id must not be empty 和 requested_mode must be between 0 and 3 是可修正的输入错误;“设备离线”“权限拒绝”和“状态机不允许”则需要不同的错误码或分类字段。仅依赖自由文本 message,后续很难让上层程序稳定判断。
如果服务状态需要跨进程或重启保留,应把状态写入参数、数据库或专门的持久化节点,不要把 Python 进程内字典误当作系统真相。服务本身只定义一次调用的接口,不自动提供数据持久化。
16. 从零构建、运行和结果检查
下面从一个只加载 ROS underlay 的新终端开始完整复现,避免之前 source 过的其他工作空间影响包解析:
source /opt/ros/humble/setup.bash
cd ~/service_demo_ws
rosdep install --from-paths src --ignore-src -r -y
colcon build --symlink-install
source install/setup.bash
ros2 interface show robot_interfaces/srv/SetRobotMode
ros2 pkg executables service_demo_py
ros2 pkg executables service_demo_cpp
ros2 launch service_demo_py service_demo.launch.py
另开终端并加载同一个工作空间:
source /opt/ros/humble/setup.bash
source ~/service_demo_ws/install/setup.bash
ros2 service type /robot1/set_robot_mode
ros2 service info /robot1/set_robot_mode
ros2 service call \
/robot1/set_robot_mode \
robot_interfaces/srv/SetRobotMode \
"{robot_id: 'verify_robot', requested_mode: 1}"
| 检查项 | 可观察结果 | 首要排查 |
|---|---|---|
| 接口发现 | show 与 .srv 一致 |
source、prefix、CMake |
| 服务类型 | 完整类型名一致 | 包名、文件名、环境 |
| 服务端 | service info 有 1 个 server |
节点入口、服务名 |
| 客户端 | Future 在超时前完成 | spin、网络、服务端 |
| 业务响应 | accepted 和 message 合理 | 回调校验、状态字典 |
| 命名空间 | /robot1/set_robot_mode 可调用 |
Launch、相对名称 |
| 多实例 | robot1 与 robot2 不串线 | namespace、remap |
如果包中增加测试,再执行:
colcon test
colcon test-result --verbose
至少应分别复现空编号、合法模式、非法模式、重复设置、服务端不可用和调用超时。接口层检查字段定义,节点层观察回调与响应,系统层检查服务发现、命名空间和跨语言调用;每种情况都应能从命令输出或节点日志中看到明确结果,而不是只依赖一次手工 CLI 调用。
17. 常见问题与复现排查
| 现象 | 常见原因 | 首要检查 |
|---|---|---|
service list 找不到服务 |
节点未启动或环境未加载 | ros2 node list、source |
| 类型找不到 | 接口未生成或使用旧 overlay | ros2 pkg prefix |
| 客户端一直等待 | 服务名、命名空间或 Domain ID 不一致 | service type、service info |
| 回调没有响应 | 回调异常、没有返回 response | 服务端日志和返回路径 |
| Python 导入失败 | setup 安装或接口依赖缺失 | package.xml、PYTHONPATH |
| C++ 头文件找不到 | CMake 未声明接口依赖 | find_package、目标依赖 |
| Launch 找不到文件 | data_files 未安装 launch |
share 目录、setup.py |
| 参数类型错误 | Launch 字符串未转换 | ParameterValue |
| 调用成功但业务拒绝 | accepted 为 false | 响应字段和服务端规则 |
| 重试导致重复动作 | 服务不是幂等操作 | 请求 ID、状态机 |
接口发生变更时,先修改 .srv 文件,再同步更新 rosidl_generate_interfaces()、Python 导入、C++ 头文件、CLI 类型名和所有请求字段。删除或重命名服务文件后,旧生成模块和头文件可能留在 build、install 或 ament 索引中;应清理接口包与消费包对应目录,或者用全新工作空间验证,最后重启所有进程。
跨工作空间时要确认 overlay 顺序:
ros2 pkg prefix robot_interfaces
printenv AMENT_PREFIX_PATH
ros2 interface show robot_interfaces/srv/SetRobotMode
如果 prefix 指向旧工作空间,客户端可能导入旧接口,服务端却使用新接口,结果表现为服务类型不一致或请求无法序列化。不要只检查 src 中是否有文件,ROS 2 运行时发现的是已经构建并 source 的安装空间。
为了让问题能够被他人重复观察,排查记录至少要写明 .srv 当前字段、接口包 prefix、source 顺序、启动命令、实际服务名和类型、请求 YAML、完整响应、超时设置以及服务端日志。接口字段或类型发生变化时,还要记录重新构建了哪些消费包,以及新终端中 ros2 interface show 的输出。
建议始终按“确认接口定义、确认当前环境、确认服务发现、发送最小请求、观察响应、再检查异常分支”的顺序复现。这样可以先区分接口生成、环境覆盖、服务命名和业务回调四类问题,避免在服务尚未被发现时就反复修改请求处理代码。


