21 KiB
21 KiB
编程规范 (CODING_STYLE) — ROS2 + Python + C++
目标: 让本仓库所有代码符合 ROS2 REP-2000 + PEP 8 + 工业级实践, 从第一行代码就专业、严谨、可维护,为后续具身智能 / VLA 落地铺平基础。
适用范围: 本仓库所有 Python (rclpy) / C++ (rclcpp) 代码 + 测试 + launch + 文档。
权威参考:
目录
- 1. 设计原则
- 2. Python 规范 (rclpy)
- 3. C++ 规范 (rclcpp)
- 4. 测试规范
- 5. ROS2 特定规范
- 6. 文档规范
- 7. Git 规范
- 8. 错误处理 + 日志规范
- 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 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)
"""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 模板:
class Foo:
"""类的一句话描述。"""
def method(self, arg: int) -> bool:
"""方法的一句话描述。
Args:
arg: 参数描述。
Returns:
返回值描述。
Raises:
ValueError: 何时抛。
"""
2.4 节点模板 (必背)
"""<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 模板
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 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: 不修改成员的方法加constexplicit: 单参数构造加explicit#pragma once: 头文件用nullptr: 不用NULL
3.3 节点模板
// 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
// 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_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)
// 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)
"""共享 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 测试命名
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 (必填)
"""<文件名> - <一句话功能描述>。
功能:
- 列出要点 1
- 列出要点 2
关键概念:
- ROS2 概念 1
- ROS2 概念 2
运行方式:
ros2 run <pkg> <exec>
参考:
- 官方文档链接
"""
6.2 包内 README.md (必填)
每个包必须有 README.md,包含:
- 功能(一句话)
- 关键概念(表格)
- 运行(代码块,3-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 错误处理
# ✅ 正确: 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 优雅退出
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 ❌ 不要
# ❌ 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 ✅ 要
# ✅ 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 test100% 通过- commit message 含 type(scope): subject
违反任何一条,代码不得合并。 一切为了:专业 / 严谨 / 可维护 / 为后续 VLA 落地铺路。