docs(nav): 阅读路径导航 + 数字统一

This commit is contained in:
xs
2026-08-04 16:10:27 +08:00
parent 549d6b337e
commit 6338f3d36a
95 changed files with 3838 additions and 1260 deletions
+278 -30
View File
@@ -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-包推荐学习顺序)
+4 -3
View File
@@ -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
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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)
+20 -18
View File
@@ -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