Files
ROS2_learn/doc/CODING_STYLE.md
T

21 KiB

编程规范 (CODING_STYLE) — ROS2 + Python + C++

目标: 让本仓库所有代码符合 ROS2 REP-2000 + PEP 8 + 工业级实践, 从第一行代码就专业、严谨、可维护,为后续具身智能 / VLA 落地铺平基础。

适用范围: 本仓库所有 Python (rclpy) / C++ (rclcpp) 代码 + 测试 + launch + 文档。

权威参考:


目录


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_
常量 kPascalCaseUPPER_SNAKE kDefaultRateDEFAULT_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 节点模板

// 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: ChatterPublisherchatter_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 语义: mapodombase_link

6. 文档规范

6.1 每个文件 docstring (必填)

"""<文件名> - <一句话功能描述>。

功能:
    - 列出要点 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 错误处理

# ✅ 正确: 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 test 100% 通过
  • commit message 含 type(scope): subject

违反任何一条,代码不得合并。 一切为了:专业 / 严谨 / 可维护 / 为后续 VLA 落地铺路。