Service 服务通信实战:请求-响应模型讲透

《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 的输出。

建议始终按“确认接口定义、确认当前环境、确认服务发现、发送最小请求、观察响应、再检查异常分支”的顺序复现。这样可以先区分接口生成、环境覆盖、服务命名和业务回调四类问题,避免在服务尚未被发现时就反复修改请求处理代码。

18. 参考资料

1 个赞