feat(level1): ROS2 完全体 12 包 / 80 测试 / 23 文档 / 工程化 / Docker 分组

This commit is contained in:
xs
2026-08-04 10:19:47 +08:00
parent 5ef38ab508
commit 549d6b337e
141 changed files with 8949 additions and 1594 deletions
+775
View File
@@ -0,0 +1,775 @@
# 编程规范 (CODING_STYLE) — ROS2 + Python + C++
> **目标**: 让本仓库所有代码符合 **ROS2 REP-2000** + **PEP 8** + **工业级实践**,
> 从第一行代码就**专业、严谨、可维护**,为后续具身智能 / VLA 落地铺平基础。
>
> **适用范围**: 本仓库所有 Python (rclpy) / C++ (rclcpp) 代码 + 测试 + launch + 文档。
>
> **权威参考**:
> - [ROS2 REP-2000: ROS 2 Design](https://www.ros.org/reps/rep-2002.html)
> - [ROS2 Humble Code Style](https://docs.ros.org/en/humble/Contributing/Code-Style-Language-Versions.html)
> - [PEP 8](https://peps.python.org/pep-0008/) / [PEP 257](https://peps.python.org/pep-0257/) / [PEP 484](https://peps.python.org/pep-0484/)
> - [Google Python Style Guide](https://google.github.io/styleguide/pyguide.html)
> - [ROS2 Design: Parameter](https://design.ros2.org/articles/ros_parameters.html)
---
## 目录
- [1. 设计原则](#1-设计原则)
- [2. Python 规范 (rclpy)](#2-python-规范-rclpy)
- [3. C++ 规范 (rclcpp)](#3-c-规范-rclcpp)
- [4. 测试规范](#4-测试规范)
- [5. ROS2 特定规范](#5-ros2-特定规范)
- [6. 文档规范](#6-文档规范)
- [7. Git 规范](#7-git-规范)
- [8. 错误处理 + 日志规范](#8-错误处理--日志规范)
- [9. 反模式 (Anti-Patterns)](#9-反模式-anti-patterns)
---
## 1. 设计原则
### 1.1 五大铁律
| # | 原则 | 含义 |
|---|---|---|
| 1 | **配置与代码解耦** | 参数 / YAML / launch 传值,代码不硬编码 |
| 2 | **错误显式处理** | callback 异常用 try/except + `get_logger().error`,不静默吞 |
| 3 | **可测试优先** | 每个包至少 1 个 pytest/gtest,关键路径 100% 覆盖 |
| 4 | **接口契约清晰** | type hints / docstring / 错误码 三件套 |
| 5 | **命名即文档** | `publisher_` 不是 `pub`,`timer_callback` 不是 `cb` |
### 1.2 SOLID 简化版
- **S** (Single Responsibility): 一个节点一个职责,不要混合"传感器读取 + 控制 + 日志上传"
- **O** (Open-Closed): 通过参数和 launch 扩展,不改代码
- **L** (Liskov): 子类可替换父类(虚函数 override)
- **I** (Interface Segregation): 接口小而专,避免上帝节点
- **D** (Dependency Inversion): 依赖抽象 (msg / service / action 类型),不依赖实现
---
## 2. Python 规范 (rclpy)
### 2.1 命名 (Naming)
| 类型 | 规则 | 例子 |
|---|---|---|
| 模块 | `snake_case` | `publisher_node.py` |
| 类 | `PascalCase` | `ChatterPublisher` (不是 `MyPublisher`) |
| 节点属性 | `snake_case_`(后缀下划线) | `self.publisher_`, `self.timer_` |
| 私有方法 | `_snake_case` | `def _on_timer(self)` |
| 常量 | `UPPER_SNAKE_CASE` | `DEFAULT_RATE_HZ = 1.0` |
| ROS2 节点名 | `snake_case`,表示功能 | `chatter_publisher` (不是 `node1`) |
| ROS2 话题名 | `snake_case`,可加前缀 | `/chatter`, `/robot1/joint_states` |
| ROS2 参数名 | `snake_case` | `publish_rate_hz` (不是 `period_ms` 混用) |
**关键:节点属性后缀下划线**避免与 rclpy 内部方法同名(`timer`, `publisher`, `subscription` 都是 rclpy 内部属性)。
### 2.2 Type Hints (必填)
```python
# Python 3.10+ 用内置类型,不用 typing.List/Dict
from typing import List # 除非必要,否则不导入
class ChatterPublisher(Node):
def __init__(self) -> None:
super().__init__('chatter_publisher')
self.declare_parameter('publish_rate_hz', 1.0)
rate: float = self.get_parameter('publish_rate_hz').value
self.publisher_: Publisher[String] = self.create_publisher(String, 'chatter', 10)
```
### 2.3 Docstring (Google Style)
```python
"""ChatterPublisher - 周期性发布 String 到 /chatter 话题。
设计思想:
ROS2 Topic 是异步多对多单向通信,本节点演示:
1. 参数声明 + 类型推断
2. 周期性发布 + QoS
3. 优雅退出(KeyboardInterrupt + rclpy.shutdown)
参考:
- ROS2 设计稿 https://design.ros2.org/articles/topic_and_service.html
- QoS 文档 https://docs.ros.org/en/humble/Concepts/About-Quality-of-Service.html
"""
```
类/方法的 docstring 模板:
```python
class Foo:
"""类的一句话描述。"""
def method(self, arg: int) -> bool:
"""方法的一句话描述。
Args:
arg: 参数描述。
Returns:
返回值描述。
Raises:
ValueError: 何时抛。
"""
```
### 2.4 节点模板 (必背)
```python
"""<NodeName> — <一句话描述>"""
from typing import List, Optional
import rclpy
from rclpy.node import Node
from rclpy.publisher import Publisher
from std_msgs.msg import String
class MyNode(Node):
"""节点描述。"""
DEFAULT_RATE_HZ: float = 1.0
DEFAULT_TOPIC: str = 'chatter'
QUEUE_SIZE: int = 10
def __init__(self, *, node_name: str = 'my_node') -> None:
super().__init__(node_name)
# 1) 声明参数(类型由默认值推断)+ 描述符
self.declare_parameter(
'publish_rate_hz', self.DEFAULT_RATE_HZ,
descriptor='发布频率 (Hz), 大于 0 的浮点数',
)
# 2) 读取参数 + 构造组件
rate: float = self.get_parameter('publish_rate_hz').value
self.publisher_: Publisher[String] = self.create_publisher(
String, self.DEFAULT_TOPIC, self.QUEUE_SIZE,
)
period: float = 1.0 / rate if rate > 0 else 1.0
self.timer_ = self.create_timer(period, self._on_timer)
# 3) 内部状态(下划线)
self._publish_count: int = 0
self.get_logger().info(f'MyNode started: rate={rate}Hz')
def _on_timer(self) -> None:
"""定时器回调(下划线=内部方法)。"""
msg = String()
msg.data = f'Hello #{self._publish_count}'
self.publisher_.publish(msg)
self._publish_count += 1
def main(args: Optional[List[str]] = None) -> None:
"""ROS2 节点入口(标准模板)。"""
rclpy.init(args=args)
try:
node = MyNode()
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
if rclpy.ok():
rclpy.shutdown()
if __name__ == '__main__':
main()
```
### 2.5 包结构 (ament_python)
```
src/<package_name>/
├── package.xml # ROS2 包元数据
├── setup.py # Python 包配置 + entry_points
├── setup.cfg # ament_python install 路径
├── resource/<package_name> # 空文件,只用于 ament 索引
├── <package_name>/ # Python 模块
│ ├── __init__.py
│ └── <node_module>.py
├── launch/ # launch 文件 (被 colcon 安装)
│ └── <name>_launch.py
├── config/ # YAML 配置文件
│ └── default.yaml
├── urdf/ # (可选) URDF
├── srv/ msg/ action/ # (可选) 自定义接口
├── test/ # pytest 用例
│ ├── conftest.py # 共享 fixture
│ └── test_<unit>.py
└── README.md # 包自描述文档(每个包必须有)
```
### 2.6 setup.py 模板
```python
from setuptools import setup
import os
from glob import glob
PACKAGE_NAME = '<package_name>'
setup(
name=PACKAGE_NAME,
version='0.1.0',
packages=[PACKAGE_NAME],
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/*.py')),
(os.path.join('share', PACKAGE_NAME, 'config'), glob('config/*.yaml')),
(os.path.join('share', PACKAGE_NAME, 'urdf'), glob('urdf/*')),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='<name>',
maintainer_email='<email>',
description='<一句话描述>',
license='MIT',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'<exec_name> = <package_name>.<module>:main',
],
},
)
```
### 2.7 package.xml 模板
```xml
<?xml version="1.0"?>
<?xml-model
href="http://download.ros.org/schema/package_format3.xsd"
schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name><package_name></name>
<version>0.1.0</version>
<description><一句话描述,></description>
<maintainer email="<email>"><name></maintainer>
<license>MIT</license>
<!-- 运行依赖 -->
<depend>rclpy</depend>
<depend>std_msgs</depend>
<!-- 测试依赖 -->
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
```
---
## 3. C++ 规范 (rclcpp)
### 3.1 命名
| 类型 | 规则 | 例子 |
|---|---|---|
| 类 | `PascalCase` | `ChatterPublisher` |
| 函数/方法 | `snake_case` (ROS2 风格) | `timer_callback()` |
| 成员变量 | `snake_case_`(后缀下划线) | `publisher_`, `count_` |
| 常量 | `kPascalCase``UPPER_SNAKE` | `kDefaultRate``DEFAULT_RATE` |
| 命名空间 | `snake_case` | `my_robot::control` |
### 3.2 必须项
- **智能指针**: `std::shared_ptr<T>` + `std::make_shared<T>()`
- **`override`**: 虚函数必须标
- **`const`**: 不修改成员的方法加 `const`
- **`explicit`**: 单参数构造加 `explicit`
- **`#pragma once`**: 头文件用
- **`nullptr`**: 不用 `NULL`
### 3.3 节点模板
```cpp
// chatter_publisher.hpp
#pragma once
#include <chrono>
#include <memory>
#include <string>
#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"
namespace my_robot
{
class ChatterPublisher : public rclcpp::Node
{
public:
explicit ChatterPublisher(const rclcpp::NodeOptions & options = rclcpp::NodeOptions());
private:
void timer_callback();
rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
rclcpp::TimerBase::SharedPtr timer_;
size_t count_;
};
} // namespace my_robot
```
```cpp
// chatter_publisher.cpp
#include "my_robot/chatter_publisher.hpp"
namespace my_robot
{
using namespace std::chrono_literals;
ChatterPublisher::ChatterPublisher(const rclcpp::NodeOptions & options)
: rclcpp::Node("chatter_publisher", options), count_(0)
{
this->declare_parameter<int>("period_ms", 500);
this->declare_parameter<std::string>("topic", "chatter");
const int period_ms = this->get_parameter("period_ms").as_int();
const std::string topic = this->get_parameter("topic").as_string();
publisher_ = this->create_publisher<std_msgs::msg::String>(topic, 10);
timer_ = this->create_wall_timer(
std::chrono::milliseconds(period_ms),
std::bind(&ChatterPublisher::timer_callback, this));
RCLCPP_INFO(this->get_logger(),
"ChatterPublisher started: topic=%s, period=%dms",
topic.c_str(), period_ms);
}
void ChatterPublisher::timer_callback()
{
auto msg = std_msgs::msg::String();
msg.data = "Hello from C++, seq=" + std::to_string(count_++);
publisher_->publish(msg);
}
} // namespace my_robot
// main
int main(int argc, char * argv[])
{
rclcpp::init(argc, argv);
rclcpp::spin(std::make_shared<my_robot::ChatterPublisher>());
rclcpp::shutdown();
return 0;
}
```
### 3.4 CMakeLists.txt 模板
```cmake
cmake_minimum_required(VERSION 3.16)
project(my_robot LANGUAGES CXX)
if(NOT CMAKE_CXX_STANDARD)
set(CMAKE_CXX_STANDARD 17)
endif()
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()
set(THIS_PACKAGE_INCLUDE_DEPENDS
rclcpp
std_msgs
)
# 头文件库
add_library(${PROJECT_NAME}_core SHARED
src/chatter_publisher.cpp
)
target_include_directories(${PROJECT_NAME}_core PUBLIC src)
ament_target_dependencies(${PROJECT_NAME}_core ${THIS_PACKAGE_INCLUDE_DEPENDS})
# 可执行文件
add_executable(chatter_publisher src/main.cpp)
target_link_libraries(chatter_publisher ${PROJECT_NAME}_core)
# 安装
install(TARGETS chatter_publisher
DESTINATION lib/${PROJECT_NAME}
)
install(DIRECTORY launch config
DESTINATION share/${PROJECT_NAME}
)
ament_package()
```
### 3.5 测试 (gtest)
```cpp
// test/test_chatter_publisher.cpp
#include <gtest/gtest.h>
#include <memory>
#include "rclcpp/rclcpp.hpp"
#include "my_robot/chatter_publisher.hpp"
class ChatterPublisherTest : public ::testing::Test
{
protected:
static void SetUpTestSuite() { rclcpp::init(0, nullptr); }
static void TearDownTestSuite() { rclcpp::shutdown(); }
};
TEST_F(ChatterPublisherTest, ConstructsWithDefaults)
{
auto node = std::make_shared<my_robot::ChatterPublisher>();
EXPECT_EQ(node->get_name(), std::string("chatter_publisher"));
EXPECT_EQ(node->get_parameter("period_ms").as_int(), 500);
EXPECT_EQ(node->get_parameter("topic").as_string(), std::string("chatter"));
}
TEST_F(ChatterPublisherTest, PublishesMessages)
{
auto node = std::make_shared<my_robot::ChatterPublisher>();
auto exec = std::make_shared<rclcpp::executors::SingleThreadedExecutor>();
exec->add_node(node);
const auto end = std::chrono::steady_clock::now() + std::chrono::seconds(1);
while (std::chrono::steady_clock::now() < end) {
exec->spin_some(std::chrono::milliseconds(50));
}
SUCCEED();
}
```
---
## 4. 测试规范
### 4.1 测试金字塔
```
┌─────────────┐
│ E2E (1-3) │ ← launch_testing + 真实场景
├─────────────┤
│ Integ (4-8) │ ← 同进程 spin + DDS
├─────────────┤
│ Unit (10+) │ ← 纯函数 / 参数声明 / 消息构造
└─────────────┘
```
### 4.2 pytest 模板 (conftest.py)
```python
"""共享 fixture - 整个仓库所有 Python 包共用一套模式。"""
from typing import Iterator
import pytest
import rclpy
@pytest.fixture(scope='session')
def ros_context() -> Iterator[None]:
"""整个 session 共享 rclpy 上下文(避免反复 init/shutdown 引发 bug)。"""
rclpy.init()
try:
yield
finally:
if rclpy.ok():
rclpy.shutdown()
@pytest.fixture
def node(ros_context: None) -> Iterator:
"""每个测试一个独立节点实例。"""
from <package>.<module> import MyNode
instance = MyNode()
try:
yield instance
finally:
instance.destroy_node()
```
### 4.3 测试命名
```python
def test_<unit>_<scenario>_<expected>():
"""例: test_publisher_init_with_default_rate_uses_1hz"""
```
### 4.4 测试覆盖要求
| 层级 | 数量 | 内容 |
|---|---|---|
| Unit | ≥3 | 参数声明 / 默认值 / 关键方法调用 |
| Integ | ≥2 | 同进程 spin / DDS roundtrip |
| E2E | 1 (可选) | 完整 launch + 多节点 |
---
## 5. ROS2 特定规范
### 5.1 节点命名
- **节点名**: `snake_case`,表示功能(`chatter_publisher` 不是 `node1`)
- **节点必须有 docstring**: 一句话说清做什么
- **节点类名 = 节点名 CamelCase**: `ChatterPublisher``chatter_publisher`
- **不混用**: `MyNode` 这种名字只用于基类,不要直接用
### 5.2 参数
- **参数名 `snake_case`**: `publish_rate_hz`, `topic_name`, `frame_id`
- **带单位后缀**: `_hz`, `_ms`, `_sec`, `_bytes` (避免歧义)
- **声明时给 `descriptor`**: 便于 `ros2 param describe`
- **运行时不变参数**: `readonly=True`
- **运行时可变**: 注册 `add_on_set_parameters_callback` 校验
### 5.3 消息 / Service / Action
- **优先标准接口**: `std_msgs` / `sensor_msgs` / `geometry_msgs` / `example_interfaces`
- **必须自定义时**: 在自己包内 `msg/`, `srv/`, `action/`
- **字段命名**: `snake_case`,带单位 (`velocity_mps`)
- **不要嵌指针 / 引用类型**: 用 ID (`string object_id`) 而非 `string&`
### 5.4 QoS
- **默认 RELIABLE + KEEP_LAST(10)**: 跨语言互通零障碍
- **传感器流**: BEST_EFFORT + KEEP_LAST(1)
- **控制指令**: RELIABLE + KEEP_LAST(1) + DEADLINE
- **状态发布**: TRANSIENT_LOCAL + KEEP_LAST(1)
### 5.5 Launch 文件
- **函数签名**: `def generate_launch_description() -> LaunchDescription`
- **可配置参数**: 用 `LaunchConfiguration` + `DeclareLaunchArgument`
- **路径**: `PathJoinSubstitution` + `FindPackageShare`
- **嵌套**: `IncludeLaunchDescription` + `PythonLaunchDescriptionSource`
- **节点命名空间**: 必要时 `PushRosNamespace`
### 5.6 TF
- **frame_id `snake_case`**: `base_link`, `gripper`, `camera_optical_frame`
- **REP-103 约定**: x 前, y 左, z 上 (右手系)
- **REP-105 语义**: `map``odom``base_link`
---
## 6. 文档规范
### 6.1 每个文件 docstring (必填)
```python
"""<文件名> - <一句话功能描述>。
功能:
- 列出要点 1
- 列出要点 2
关键概念:
- ROS2 概念 1
- ROS2 概念 2
运行方式:
ros2 run <pkg> <exec>
参考:
- 官方文档链接
"""
```
### 6.2 包内 README.md (必填)
每个包必须有 `README.md`,包含:
1. **功能**(一句话)
2. **关键概念**(表格)
3. **运行**(代码块,3-5 种)
4. **测试**(代码块)
5. **深度学习链接**(指向 doc/ 下的文档)
### 6.3 commit message
```
<type>(<scope>): <subject>
<body>
<footer>
类型: feat / fix / docs / style / refactor / test / chore
例子:
feat(py_pubsub): 重写 publisher 为工业级风格 + type hints
fix(cpp_robot_tf2): 加 const-correct 与 override
docs(doc/15-params): 新增参数系统深度文档
```
---
## 7. Git 规范
- **分支命名**: `feat/<name>` / `fix/<name>` / `docs/<name>`
- **commit**: 中文/英文都行,但 subject ≤ 72 字符
- **不修改全局 git config**: 用 `git -c user.name=x -c user.email=y commit` 临时设
- **.gitignore**: 必须包含 `build/`, `install/`, `log/`, `.venv/`, `__pycache__/`
---
## 8. 错误处理 + 日志规范
### 8.1 日志级别
| 级别 | 何时用 |
|---|---|
| DEBUG | 周期事件(默认不打印,生产可用 `--log-level DEBUG` 看) |
| INFO | 启动 / 关闭 / 配置变更 |
| WARN | 异常但可恢复 |
| ERROR | 操作失败但节点继续 |
| FATAL | 节点即将退出 |
### 8.2 错误处理
```python
# ✅ 正确: callback 里 try/except + 日志
def _on_timer(self) -> None:
try:
msg = self._build_message()
self.publisher_.publish(msg)
except Exception as exc:
self.get_logger().error(f'publish failed: {exc}', exc_info=True)
# ❌ 错: 让异常冒泡,节点崩
def _on_timer(self) -> None:
msg = self._build_message()
self.publisher_.publish(msg)
```
### 8.3 优雅退出
```python
def main(args=None):
rclpy.init(args=args)
try:
node = MyNode()
rclpy.spin(node)
except KeyboardInterrupt:
node.get_logger().info('KeyboardInterrupt → 退出')
finally:
if rclpy.ok():
rclpy.shutdown()
```
---
## 9. 反模式 (Anti-Patterns)
### 9.1 ❌ 不要
```python
# ❌ 1) 节点名不规范
super().__init__('node1')
# ❌ 2) 硬编码
self.publisher_ = self.create_publisher(String, 'chatter', 10) # topic 不可配
# ❌ 3) 无 type hints
def callback(self, msg):
pass
# ❌ 4) 无 docstring
class MyNode(Node):
def __init__(self):
...
# ❌ 5) print 而不是日志
print('starting...')
# ❌ 6) callback 里阻塞 / 抛异常
def _on_timer(self):
result = blocking_io_call()
self.publisher_.publish(result) # 如果 blocking_io_call 抛 → 节点崩
# ❌ 7) 重复 init/shutdown
def test_a():
rclpy.init()
...
rclpy.shutdown()
def test_b():
rclpy.init() # 第二次 init → 异常
# ❌ 8) 命名混淆
self.pub = self.create_publisher(...) # pub 是 keyword 别用
self.timer = self.create_timer(...) # timer 是 rclpy 内部属性,会冲突
# ❌ 9) 包名叫 launch (与 ROS2 系统包冲突)
# 包名必须不能与 ROS2 自带包同名
```
### 9.2 ✅ 要
```python
# ✅ 1) 节点名表示功能
super().__init__('chatter_publisher')
# ✅ 2) 参数化
self.declare_parameter('topic', 'chatter')
topic = self.get_parameter('topic').value
# ✅ 3) 完整 type hints
def _on_timer(self) -> None:
msg: String = self._build_message()
# ✅ 4) Google style docstring
class ChatterPublisher(Node):
"""ChatterPublisher - 周期性发布 String 到 /chatter 话题。"""
# ✅ 5) 用 get_logger
self.get_logger().info('starting...')
# ✅ 6) callback 里 try/except
def _on_timer(self) -> None:
try:
...
except Exception as exc:
self.get_logger().error(f'failed: {exc}')
# ✅ 7) conftest.py 共享 fixture
# 在 conftest.py 里做 session-scope rclpy.init
# ✅ 8) 节点属性后缀下划线
self.publisher_: Publisher[String] = self.create_publisher(...)
self.timer_: Timer = self.create_timer(...)
# ✅ 9) 包名避让 ROS2 系统包
# 用 bringup, my_robot, my_arm 等
```
---
## 10. 自检清单 (提交前)
- [ ] 每个文件顶部有 docstring(功能 / 关键概念 / 运行方式 / 参考)
- [ ] 类 / 方法有 Google-style docstring
- [ ] 所有函数有 type hints
- [ ] 节点名 `snake_case`,类名 `PascalCase`,属性后缀 `_`
- [ ] 参数带 `descriptor` 描述
- [ ] callback 有 try/except
- [ ] 测试用 conftest.py 共享 fixture
- [ ] 包内有 README.md
- [ ] `colcon build` 通过
- [ ] `colcon test` 100% 通过
- [ ] commit message 含 type(scope): subject
---
**违反任何一条,代码不得合并。**
**一切为了:专业 / 严谨 / 可维护 / 为后续 VLA 落地铺路。**