第4章 服务通信实现

码枢沄社 · 嵌入式体系化教程平台
入门 👤 需要实现机器人"一问一答"功能的开发者 🔧 舵机控制指令下发、传感器数据轮询请求
本章你将学到
  • 理解服务通信的请求-响应模型
  • 掌握编写服务端和客户端的方法
  • 学会使用服务进行简单命令交互
核心概念服务客户端-服务器架构同步/异步调用

服务通信:一个电话问清楚,一个电话接明白

比起话题通信的"一直在喊"(发布/订阅),服务通信更像你拿起电话问一个问题,对方立刻回答。这个模式叫"请求-响应",适合需要确认和返回结果的场景,比如发指令给电机:"转到90度"——然后收到"转到位了"。

生活类比:打电话问客服

你问"今晚有特价套餐吗?"(请求),客服查了一下回答"有,芝士汉堡套餐18元"(响应)。打电话过程中,你等回复,打完电话挂断,这事就完了。服务通信就是这样,一锤子买卖。

技术定义

服务通信是ROS2中一对一的同步通信方式:一个节点作为服务端(Server)提供功能,另一个节点作为客户端(Client)发起请求,收到响应后完成交互。底层依赖DDS的请求-响应机制,但接口对用户屏蔽了DDS细节。

客户端节点
请求(Request)
服务端节点
服务端节点
响应(Response)
客户端节点

表:服务器与客户端消息结构对比

消息类型方向包含内容是否必须
请求(Request)客户端→服务端输入参数(如目标角度)必须
响应(Response)服务端→客户端输出结果(如状态码、实际位置)必须

服务的定义与接口文件

服务接口用文件定义,格式类似话题的消息,但分两部分,用---分隔。上部分定义请求字段,下部分定义响应字段。


# example_interfaces/srv/AddTwoInts.srv
int64 a
int64 b
---
int64 sum

实际的.srv文件放在服务的功能包中的srv/目录下,并在CMakeLists.txt中注册。常用内置服务在example_interfaces包和std_srvs包中。

常见误区

  • 请求字段乱写默认值: .srv文件中字段不允许设默认值,必须在代码中给值。
  • 请求和响应的字段类型不匹配: 例如请求端发送float32,服务端定义int64,会导致序列化失败,节点会报"field type mismatch"。
  • 服务名写错: 服务端创建的服务名是/add_two_ints,客户端也必须是/add_two_ints,不能有大小写或下划线差异。

服务端编程:接电话的那个

服务端节点负责接收请求,处理逻辑,返回响应。核心是定义一个回调函数,每次收到请求时调用。


class ServerNode : public rclcpp::Node
{
public:
    ServerNode() : Node("server_node")
    {
        service_ = create_service<example_interfaces::srv::AddTwoInts>(
            "add_two_ints",
            std::bind(&ServerNode::handle_request, this, std::placeholders::_1, std::placeholders::_2));
    }

private:
    void handle_request(
        const std::shared_ptr<example_interfaces::srv::AddTwoInts::Request> req,
        std::shared_ptr<example_interfaces::srv::AddTwoInts::Response> res)
    {
        res->sum = req->a + req->b;
        RCLCPP_INFO(get_logger(), "收到请求: %ld + %ld = %ld", req->a, req->b, res->sum);
    }

    rclcpp::Service<example_interfaces::srv::AddTwoInts>::SharedPtr service_;
};

回调函数中的两个参数分别是请求和响应。请求是常量指针,不能修改;响应是共享指针,你负责填充。服务端不需要任何额外的spin等待,rclcpp默认会在后台处理服务请求。

编者提示: 实际开发中,一个常见问题是服务端执行长时间任务(如电机转到90度需要2秒),此时其他服务请求会被阻塞。可以用多线程触发回调,设置rclcpp::NodeOptions中的use_intra_process_buffer为false,或者将耗时操作放到另一个线程中。

客户端编程:打电话的那个

客户端节点发起请求,然后等待响应。响应可以同步等待(阻塞当前线程)或异步等待(不阻塞)。


class ClientNode : public rclcpp::Node
{
public:
    ClientNode() : Node("client_node")
    {
        client_ = create_client<example_interfaces::srv::AddTwoInts>("add_two_ints");
    }

    void send_request()
    {
        while (!client_->wait_for_service(std::chrono::seconds(1))) {
            RCLCPP_WARN(get_logger(), "等待服务端就绪...");
        }

        auto request = std::make_shared<example_interfaces::srv::AddTwoInts::Request>();
        request->a = 3;
        request->b = 5;

        auto result = client_->async_send_request(request);
        // 处理响应...建议用回调或spin_until_future_complete
    }

private:
    rclcpp::Client<example_interfaces::srv::AddTwoInts>::SharedPtr client_;
};

两种调用方式

  • 同步调用: 使用spin_until_future_complete()阻塞当前线程直到收到响应。适合在简单场景或初始化时使用。
  • 异步调用: 使用async_send_request()并传入回调,不阻塞。适合在主循环中或事件驱动架构中使用。
客户端
wait_for_service(1s)
send_request()
收到Response
服务端
回调触发
填充Response
返回

完整工作流程

  1. 服务端节点创建并注册服务,等待请求到来。
  2. 客户端节点创建,等待服务端就绪(通过wait_for_service())。
  3. 客户端发送异步请求(async_send_request),立即返回,不阻塞。
  4. 服务端收到请求,触发回调,处理后返回响应。
  5. 客户端通过回调或spin_until_future_complete收到响应。

如果客户端先启动但服务端还没起来,wait_for_service会循环等待,避免段错误。这是实际开发中必须处理的情况。

表:服务通信与话题通信关键区别

特征话题通信服务通信
模式发布/订阅请求/响应
是否等待不等待,消息可能丢失等待响应(超时可设)
并发连接一对多、多对多一对一(一客户端一服务端)
适用场景传感器数据、系统状态命令控制、调用服务

关键要点总结

  • 服务通信是同步的:客户端发送请求后等待响应,服务端回调返回结论。
  • 服务端必须注册服务名,客户端必须使用完全相同的服务名。
  • 客户端必须等待服务就绪再发送,否则调用会失败。

多客户端场景

同一个服务端可以同时被多个客户端调用。默认情况下,ROS2服务端是线程安全的,多个请求会依次处理。如果你需要并发处理,可以使用rclcpp::NodeOptions设置CallbackGroup。

实际项目里,我见过一个机器人节点提供"舵机角度设定"服务,多个控制节点同时发送请求。服务端一个接一个处理,如果处理时间短(<1ms),感觉是并发的;如果处理时间长,客户端会超时,要在客户端设置超时参数。

排错线索

如果你看到"Failed to call service"或"Service not available"报错,大概率是服务端节点还没启动,或服务名拼写错误。先在终端用 ros2 service list 查看所有可用服务。

动手试一试

任务: 编写一个简单的服务端和客户端(在同一个功能包中)。服务端接收两个整数,返回它们的乘积。客户端向服务端发送(4, 6),并打印结果"4 * 6 = 24"。

  • 步骤1:创建功能包,添加AddTwoInts服务(或直接使用example_interfaces)
  • 步骤2:编写服务端节点,实现乘法回调
  • 步骤3:编写客户端节点,使用同步调用(spin_until_future_complete)
  • 步骤4:编译运行,先启动服务端,再运行客户端

检验你的理解

  1. 判断题:服务通信中,客户端必须等待服务端响应才能继续执行后续代码。( )
  2. 判断题:一个服务端同时只能被一个客户端调用。( )
  3. 选择题:客户端发送请求前,应该先调用哪个函数确保服务端就绪?
    A. wait_for_topics()
    B. wait_for_service()
    C. check_service()
    D. poll_service()

本章小结

  • 服务通信采用客户端-服务器模式,用于需要确认的指令交互。
  • 服务接口用.srv文件定义,请求和响应用---分隔。
  • 服务端通过回调处理请求,客户端通过wait_for_service等待服务就绪。
  • 调用方式分同步(spin_until_future_complete)和异步(async_send_request回调)。
  • 多个客户端可以调用同一个服务,服务端默认顺序处理。
← 上一章 返回目录 下一章 →