docs(nav): 阅读路径导航 + 数字统一
This commit is contained in:
+278
-30
@@ -1,49 +1,297 @@
|
||||
# py_srv
|
||||
# py_srv — Python Service 请求-响应
|
||||
|
||||
ROS2 Service req/resp 演示包(Python)。属于 Level 1 基础机制第 3 块。
|
||||
> ROS2 三种通信模式第二种:**Service(服务)**。同步、一问一答。
|
||||
>
|
||||
> 预计学习时间:1-2 小时。
|
||||
>
|
||||
> **前置知识**:读完 `py_pubsub` 的 README(知道节点和话题是什么)。**可选**:看完 `cpp_pubsub`(Service Client/Server 也有 C++ 版,但跟 Python 几乎一一对应)。
|
||||
|
||||
## 功能
|
||||
---
|
||||
|
||||
- **`add_two_ints_server`**: 接收 `AddTwoInts` 请求,返回 `a + b`
|
||||
- **`add_two_ints_client`**: 异步发请求 + 等响应
|
||||
## 这是什么?
|
||||
|
||||
## 关键概念
|
||||
**Service** 是 ROS2 的"打电话"模式:
|
||||
- **Client** 发请求(Request),等回应
|
||||
- **Server** 收到请求,处理完返回响应(Response)
|
||||
- 同步的(等结果才能干别的)
|
||||
|
||||
| 概念 | 用途 |
|
||||
|---|---|
|
||||
| `Service` / `Client` | 同步请求-响应 |
|
||||
| `wait_for_service` / `service_is_ready` | 等待 server 注册 |
|
||||
| `call_async` + `spin_until_future_complete` | 异步发请求 |
|
||||
**对比三种通信模式**:
|
||||
|
||||
## 运行
|
||||
| 模式 | 用途 | 是否同步 | 是否可取消 |
|
||||
|---|---|---|---|
|
||||
| **Topic** | 广播消息(传感器数据流) | ❌ 异步 | ❌ |
|
||||
| **Service** | 一次性"问-答"(算数学、查数据) | ✅ 同步 | ❌ |
|
||||
| **Action** | 长任务(导航、机械臂运动) | ❌ 异步 | ✅ |
|
||||
|
||||
```bash
|
||||
# 终端 1:启 server
|
||||
ros2 launch py_srv srv_launch.py
|
||||
**生活化例子**:
|
||||
- Topic = 广播体操,大家都能听到
|
||||
- Service = 打电话问"12+30=?",对方必须回答
|
||||
- Action = 点外卖,可以看到骑手位置 + 可以取消订单
|
||||
|
||||
# 终端 2:CLI 调用
|
||||
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 12, b: 30}"
|
||||
# 预期: sum: 42
|
||||
---
|
||||
|
||||
# 终端 2 备选:启 client 节点(从参数读 a/b)
|
||||
ros2 run py_srv add_two_ints_client --ros-args -p a:=12 -p b:=30
|
||||
## 🎯 学完之后你能做什么?
|
||||
|
||||
1. ✅ 写 Service Server(同步处理请求)
|
||||
2. ✅ 写 Service Client(用 `call_async` 异步发 + spin 等结果)
|
||||
3. ✅ 用 `rclpy.spin_until_future_complete` 等待异步结果
|
||||
4. ✅ 手动用 `ros2 service call` 测试服务
|
||||
5. ✅ 理解为什么不能在 callback 里阻塞
|
||||
|
||||
---
|
||||
|
||||
## 📁 文件结构
|
||||
|
||||
```
|
||||
src/py_srv/
|
||||
├── py_srv/
|
||||
│ ├── add_two_ints_server.py # 服务端(a + b = sum)
|
||||
│ └── add_two_ints_client.py # 客户端
|
||||
├── launch/service_launch.py # 一键启动 server + client
|
||||
├── test/
|
||||
│ ├── test_srv_server.py # 服务端单元测试
|
||||
│ ├── test_srv_client.py # 客户端单元测试
|
||||
│ └── test_srv.py # 端到端测试
|
||||
└── setup.py
|
||||
```
|
||||
|
||||
## 测试
|
||||
**关键服务类型**:`example_interfaces/srv/AddTwoInts`(ROS2 自带的标准接口)
|
||||
```
|
||||
# srv 文件格式(本包用现成的)
|
||||
int64 a
|
||||
int64 b
|
||||
---
|
||||
int64 sum
|
||||
```
|
||||
`---` 上面是 **Request**,下面是 **Response**。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 跑起来
|
||||
|
||||
### 启动 Server
|
||||
|
||||
**终端 1**(容器内):
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 run py_srv add_two_ints_server
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [add_two_ints_server]: AddTwoIntsServer ready: service="add_two_ints"
|
||||
```
|
||||
|
||||
### 启动 Client(另开终端)
|
||||
|
||||
**终端 2**:
|
||||
```bash
|
||||
source /opt/ros/humble/setup.bash
|
||||
source /root/ros2_ws/install/setup.bash
|
||||
|
||||
ros2 run py_srv add_two_ints_client a:=12 b:=30
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
[INFO] [add_two_ints_client]: result: 12 + 30 = 42
|
||||
```
|
||||
|
||||
### 手动调用服务(不开 Client)
|
||||
|
||||
```bash
|
||||
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 12, b: 30}"
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
response: example_interfaces.srv.AddTwoInts_Response(sum=42)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Server 代码
|
||||
|
||||
```python
|
||||
class AddTwoIntsServer(Node):
|
||||
def __init__(self):
|
||||
super().__init__('add_two_ints_server')
|
||||
|
||||
# create_service(消息类型, 服务名, 回调)
|
||||
self.service_ = self.create_service(
|
||||
AddTwoInts, 'add_two_ints', self._handle_request)
|
||||
|
||||
def _handle_request(self, request, response):
|
||||
"""处理请求的回调。
|
||||
|
||||
注意签名: (request, response) -> response
|
||||
必须原地修改 response,最后返回它。
|
||||
"""
|
||||
try:
|
||||
response.sum = request.a + request.b # 处理逻辑
|
||||
self.get_logger().info(
|
||||
f'{request.a} + {request.b} = {response.sum}')
|
||||
except Exception as exc:
|
||||
self.get_logger().error(f'处理失败: {exc}')
|
||||
response.sum = 0
|
||||
return response # 必须返回
|
||||
```
|
||||
|
||||
**回调签名**:`(request, response) -> response`,**原地修改** response 然后返回。
|
||||
|
||||
---
|
||||
|
||||
## 📖 Client 代码(异步模式)
|
||||
|
||||
```python
|
||||
class AddTwoIntsClient(Node):
|
||||
def __init__(self):
|
||||
super().__init__('add_two_ints_client')
|
||||
|
||||
# create_client(消息类型, 服务名)
|
||||
self._client = self.create_client(AddTwoInts, 'add_two_ints')
|
||||
|
||||
def call_once(self, a, b, timeout_sec=5.0):
|
||||
"""异步调用 + spin 等结果"""
|
||||
# 1) 等服务可用(必须轮询!)
|
||||
if not self.wait_for_service_ready(timeout_sec):
|
||||
self.get_logger().warn('service not available')
|
||||
return None
|
||||
|
||||
# 2) 构造请求
|
||||
request = AddTwoInts.Request()
|
||||
request.a = a
|
||||
request.b = b
|
||||
|
||||
# 3) 异步发请求(立刻返回,不等结果)
|
||||
future = self._client.call_async(request)
|
||||
|
||||
# 4) spin 直到 future 完成(或超时)
|
||||
rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec)
|
||||
|
||||
# 5) 拿结果
|
||||
if not future.done():
|
||||
self.get_logger().warn('调用超时')
|
||||
return None
|
||||
return future.result().sum
|
||||
```
|
||||
|
||||
**`call_async` 特点**:发完请求立刻返回 `future`,你可以干别的事,最后再 `spin_until_future_complete` 阻塞等结果。
|
||||
|
||||
**`wait_for_service_ready` 轮询模式**:
|
||||
```python
|
||||
def wait_for_service_ready(self, timeout_sec=5.0):
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
if self._client.service_is_ready():
|
||||
return True
|
||||
time.sleep(0.05) # 让出 CPU,下次再查
|
||||
return False
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 为什么不能在 `__init__` / callback 里阻塞等待?
|
||||
|
||||
**`spin` 是单线程的**:它需要反复执行 callback 才能让系统工作。如果在 callback 里阻塞,spin 就被卡住,**其他 callback 全都执行不了**。
|
||||
|
||||
**真实场景的"死锁"**:
|
||||
|
||||
```python
|
||||
# ❌ 错误写法
|
||||
class BadClient(Node):
|
||||
def _on_timer(self):
|
||||
# 这是订阅者回调(在 spin 里执行)
|
||||
result = self._client.call(request) # ❌ 阻塞等结果
|
||||
# call 内部要 spin,才能把 response callback 跑起来
|
||||
# 但我们正在 spin 里 → 永远等不到 response callback 完成
|
||||
# → 死锁!节点卡死
|
||||
```
|
||||
|
||||
**正确写法**:**异步**(把响应处理放到 callback,而不是当前函数里阻塞等):
|
||||
|
||||
```python
|
||||
# ✅ 正确写法
|
||||
class GoodClient(Node):
|
||||
def _on_timer(self):
|
||||
future = self._client.call_async(request)
|
||||
# 立刻返回,不等结果
|
||||
future.add_done_callback(self._response_callback)
|
||||
|
||||
def _response_callback(self, future):
|
||||
# 这个 callback 由 spin 在将来某个时刻调用
|
||||
result = future.result()
|
||||
# ...处理 result
|
||||
```
|
||||
|
||||
**规律**:**任何在 callback / `__init__` / spin 里的代码,都不能"同步等一个需要 spin 才会发生的事"**。
|
||||
|
||||
---
|
||||
|
||||
## 🧪 跑测试
|
||||
|
||||
```bash
|
||||
colcon test --packages-select py_srv
|
||||
colcon test-result --all --verbose
|
||||
```
|
||||
|
||||
测试覆盖:
|
||||
**预期**:`py_srv: pytest 7/7 ✓` 全部通过。
|
||||
|
||||
| 文件 | 用例数 | 内容 |
|
||||
**测试覆盖**:
|
||||
- 服务端:节点名 / 服务名 / 处理正负零(3 个)
|
||||
- 客户端:能调用服务(1 个)
|
||||
- 端到端:同进程跑 server + client 互调(2 个)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 自己写 Service
|
||||
|
||||
1. **定义 .srv 文件**:
|
||||
```
|
||||
# srv/MyService.srv
|
||||
string name
|
||||
int32 count
|
||||
---
|
||||
bool success
|
||||
string message
|
||||
```
|
||||
(放在 `srv/` 目录下)
|
||||
|
||||
2. **在 `setup.py` 加一行**(`data_files`):
|
||||
```python
|
||||
data_files=[
|
||||
(...),
|
||||
(os.path.join('share', PACKAGE_NAME, 'srv'), glob('srv/*.srv')),
|
||||
]
|
||||
```
|
||||
|
||||
3. **重编译**:`colcon build`
|
||||
|
||||
4. **用**:
|
||||
```python
|
||||
from my_pkg.srv import MyService
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 深入学习
|
||||
|
||||
- [doc/30-services.md](../../doc/30-services.md) — Service 深度(异步 vs 同步、回调组)
|
||||
|
||||
---
|
||||
|
||||
## ⏭️ 下一个包
|
||||
|
||||
学完这个,继续学 **[py_action_demo](../py_action_demo/README.md)** — 学习 Action(带进度回调 + 可取消的长任务)。
|
||||
|
||||
---
|
||||
|
||||
## 📍 学习路径导航
|
||||
|
||||
| ⏮ 上一个 | 🏠 当前位置 | ⏭ 下一个 |
|
||||
|---|---|---|
|
||||
| `test_srv_server.py` | 5 | 节点名 + 参数 + 回调(正/负/零) |
|
||||
| `test_srv_client.py` | 1 | 同进程 client 调用 server(12+30=42) |
|
||||
| **总计** | **6** | **目标 6/6 100% 通过** |
|
||||
| [py_pubsub — Python Topic](../py_pubsub/README.md) | **py_srv — Python Service** | [py_action_demo — Python Action](../py_action_demo/README.md) |
|
||||
|
||||
## 深度学习
|
||||
|
||||
- 编程规范:[`doc/CODING_STYLE.md`](../doc/CODING_STYLE.md)
|
||||
- Service 深度:[`doc/30-services.md`](../doc/30-services.md)
|
||||
📍 完整 12 包学习顺序见 [主 README](../../README.md#-12-包推荐学习顺序)
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from example_interfaces.srv import AddTwoInts
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
|
||||
|
||||
@@ -36,15 +37,15 @@ class AddTwoIntsClient(Node):
|
||||
|
||||
self.declare_parameter(
|
||||
'service_name', self.DEFAULT_SERVICE_NAME,
|
||||
descriptor='要调用的服务名',
|
||||
ParameterDescriptor(description='要调用的服务名'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'a', self.DEFAULT_A,
|
||||
descriptor='第一个加数',
|
||||
ParameterDescriptor(description='第一个加数'),
|
||||
)
|
||||
self.declare_parameter(
|
||||
'b', self.DEFAULT_B,
|
||||
descriptor='第二个加数',
|
||||
ParameterDescriptor(description='第二个加数'),
|
||||
)
|
||||
|
||||
service_name: str = self.get_parameter('service_name').value
|
||||
|
||||
@@ -15,6 +15,7 @@ from typing import List, Optional
|
||||
|
||||
import rclpy
|
||||
from example_interfaces.srv import AddTwoInts
|
||||
from rcl_interfaces.msg import ParameterDescriptor
|
||||
from rclpy.node import Node
|
||||
from rclpy.service import Service
|
||||
|
||||
@@ -39,7 +40,7 @@ class AddTwoIntsServer(Node):
|
||||
|
||||
self.declare_parameter(
|
||||
'service_name', self.DEFAULT_SERVICE_NAME,
|
||||
descriptor='服务名(字符串)',
|
||||
ParameterDescriptor(description='服务名(字符串)'),
|
||||
)
|
||||
|
||||
service_name: str = self.get_parameter('service_name').value
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_service_inproc_roundtrip(ros_context):
|
||||
req.a = 7
|
||||
req.b = 35
|
||||
|
||||
future = client.client.call_async(req)
|
||||
future = client._client.call_async(req)
|
||||
end = time.time() + 3.0
|
||||
while not future.done() and time.time() < end:
|
||||
exec_.spin_once(timeout_sec=0.05)
|
||||
|
||||
@@ -18,26 +18,28 @@ def test_client_calls_server_inproc(ros_context: None) -> None:
|
||||
executor.add_node(server)
|
||||
executor.add_node(client)
|
||||
|
||||
# 触发 client 调用
|
||||
result_future_container: list = []
|
||||
# 等 service 就绪
|
||||
deadline = time.time() + 3.0
|
||||
while time.time() < deadline:
|
||||
executor.spin_once(timeout_sec=0.05)
|
||||
if client._client.service_is_ready():
|
||||
break
|
||||
|
||||
def call_in_thread() -> None:
|
||||
# 给点时间让 server 注册(同进程也需要一点 spin 时间)
|
||||
deadline = time.time() + 2.0
|
||||
while time.time() < deadline:
|
||||
executor.spin_once(timeout_sec=0.05)
|
||||
if client._client.service_is_ready():
|
||||
break
|
||||
result = client.call_once(12, 30, timeout_sec=3.0)
|
||||
result_future_container.append(result)
|
||||
assert client._client.service_is_ready(), 'service not ready'
|
||||
|
||||
import threading
|
||||
t = threading.Thread(target=call_in_thread)
|
||||
t.start()
|
||||
t.join(timeout=5.0)
|
||||
# 发请求
|
||||
req = AddTwoInts.Request()
|
||||
req.a = 12
|
||||
req.b = 30
|
||||
future = client._client.call_async(req)
|
||||
|
||||
end = time.time() + 3.0
|
||||
while not future.done() and time.time() < end:
|
||||
executor.spin_once(timeout_sec=0.05)
|
||||
|
||||
assert future.done(), 'service call did not complete in 3s'
|
||||
assert future.result() is not None
|
||||
assert future.result().sum == 42
|
||||
|
||||
server.destroy_node()
|
||||
client.destroy_node()
|
||||
|
||||
assert len(result_future_container) == 1
|
||||
assert result_future_container[0] == 42
|
||||
Reference in New Issue
Block a user