自动驾驶C++实时中间件:PuppetMaster 重构记录,阶段二:公共类型和错误模型定义

最近我开始整理一个 C++ 实时中间件项目:PuppetMaster

它不是想一上来就做一个庞大的通用框架,而是从我之前维护过的自动驾驶中间件中,逐步提炼出一套更清晰、更通用、更容易复用的基础能力:模块通信、任务调度、Topic 管理、通信后端抽象、错误处理、生命周期管理,以及后续的配置和可观测性。

GitHub 地址:

github.com/SoleyRan/Pu...

这一篇记录的是第二阶段:公共类型和错误模型定义

上一篇文章里,我把 PuppetMaster 的项目骨架先整理了一遍。 这一阶段的主题是:

Realize core types and error model

也就是先把公共定义、错误模型、基础消息策略整理出来。

为什么第二阶段先做 core?

在重构一个中间件项目的时候,很容易一上来就去写通信、调度、线程池、FastDDS、ZMQ 或 IPC。

但我这次没有这么做。

因为这些模块虽然看起来是"核心功能",但它们真正依赖的是一层更底部的公共语义:

  • 一个 topic 应该怎么表示?
  • 一个 endpoint 应该包含哪些信息?
  • 一个组件、任务、transport 的名字应该怎么校验?
  • 函数失败时返回什么?
  • 错误信息应该怎么携带?
  • 上层代码要不要直接依赖异常?
  • DDS 里的 QoS 能不能直接放到中间件 core 里?

如果这些东西一开始没有整理好,后面每做一个模块都会临时加一点类型,最后 public API 很容易变成"谁先写谁定义"。

所以第二阶段我主要做了一件事:

先把 PuppetMaster 的公共语言定下来。

这个阶段还不急着实现完整 runtime,也不急着接入具体通信后端,而是先把后续模块都会依赖的 core 层做出来。

这一阶段做了什么?

这次主要新增和整理了这几类内容:

text 复制代码
include/
`-- puppet_master/
    `-- core/
        |-- status.h
        |-- result.h
        |-- types.h
        `-- message_policy.h

docs/
`-- core.md

test/
`-- core_types_test.cpp

大致可以分成四块:

  • Status:统一错误模型
  • Result<T>:统一返回值模型
  • types.h:Topic、Endpoint、Trigger 等公共类型
  • MessagePolicy:抽象消息策略,不直接绑定 DDS QoS

统一错误模型:Status

原来的工程里,不同模块有不同的错误表达方式。

有的地方用 bool,有的地方用 int,有的地方可能直接打印日志之后返回。这样在小工程里问题不大,但中间件项目一旦开始拆模块,错误模型如果不统一,调用链会很难维护。

比如一个 transport 创建失败,上层至少需要知道:

  • 是参数错误?
  • 是资源不可用?
  • 是当前后端不支持?
  • 是内部状态不对?
  • 错误信息是什么?
  • 调用方能不能继续降级?

所以我在 core 层加了一个 Status

它不是一个复杂的异常系统,而是一个比较直接的错误描述对象。

大概用法是这样:

cpp 复制代码
#include <puppet_master/core/status.h>

using puppet_master::core::Status;

Status OpenTransport(const std::string& name)
{
    if (name.empty()) {
        return Status::InvalidArgument("transport name is empty");
    }

    if (name == "fastdds") {
        return Status::Unavailable("FastDDS is not enabled");
    }

    return Status::Ok();
}

调用方可以这样判断:

cpp 复制代码
auto status = OpenTransport("fastdds");

if (!status.ok()) {
    std::cerr << status.message() << std::endl;
    return;
}

这里我保留了比较明确的 StatusCode,比如:

cpp 复制代码
enum class StatusCode {
    kOk,
    kCancelled,
    kUnknown,
    kInvalidArgument,
    kDeadlineExceeded,
    kNotFound,
    kAlreadyExists,
    kPermissionDenied,
    kResourceExhausted,
    kFailedPrecondition,
    kAborted,
    kOutOfRange,
    kUnimplemented,
    kInternal,
    kUnavailable,
    kDataLoss,
    kUnsupported,
};

为什么要列这么多?

不是因为现在马上都会用到,而是中间件后面会涉及通信、调度、生命周期、配置加载、插件注册等模块。错误类型如果太粗,最后所有失败都会变成 false 或者 unknown error

这对调试不友好,也不适合开源项目展示。

为什么没有直接用异常?

C++ 项目里当然可以用异常,但在中间件和车端工程里,我更倾向于把公共接口设计成显式返回错误。

主要有几个原因:

  • 调用链更清楚
  • 更容易和 C 风格库、DDS、ZMQ 这类后端对接
  • 更容易在实时或准实时场景里控制错误路径
  • 测试时可以直接验证错误码和错误信息
  • public API 的行为更容易被使用者理解

所以 Status 的定位很明确:

表示一个只关心成功或失败的操作结果。

比如初始化、关闭、注册、写入这类函数,都很适合返回 Status

带返回值的错误模型:Result

有些接口不只是成功或失败,还需要返回一个对象。

比如:

  • 创建 writer
  • 创建 reader
  • 查找 transport
  • 解析 topic spec
  • 加载配置文件

如果只返回 Status,还需要额外传出参。C++ 里出参不是不能用,但 public API 看起来会比较重。

所以我加了 Result<T>

它的意思很简单:

要么拿到一个 T,要么拿到一个 Status

示例:

cpp 复制代码
#include <puppet_master/core/result.h>
#include <puppet_master/core/status.h>

using puppet_master::core::Result;
using puppet_master::core::Status;

Result<int> ParseQueueDepth(const std::string& value)
{
    if (value.empty()) {
        return Status::InvalidArgument("queue depth is empty");
    }

    return 10;
}

调用方:

cpp 复制代码
auto depth = ParseQueueDepth("10");

if (!depth.ok()) {
    std::cerr << depth.status().message() << std::endl;
    return;
}

std::cout << "queue depth: " << depth.value() << std::endl;

这样 public API 的风格会比较统一:

cpp 复制代码
Status Start();

Result<std::shared_ptr<Writer>> CreateWriter(const EndpointConfig& config);

一个负责"操作是否成功",一个负责"成功后返回对象"。

这对后面的 transport abstraction、runtime、component model 都会用到。

公共类型:Topic、Endpoint、Trigger

中间件里最基础的几个概念是:

  • topic
  • endpoint
  • component
  • task
  • transport
  • trigger

这些概念如果一直用裸 std::string 传来传去,短期写起来很方便,但很难表达语义。

比如下面这种接口:

cpp 复制代码
void Subscribe(const std::string& name);

这个 name 到底是 topic name,还是 component name,还是 transport name?

调用方一多,这类接口就容易误用。

所以我在 types.h 里整理了更明确的类型,比如:

cpp 复制代码
using TopicName = std::string;
using TaskName = std::string;
using ComponentName = std::string;
using TransportName = std::string;

同时把 topic、endpoint 这类结构单独定义出来:

cpp 复制代码
struct TopicSpec {
    TopicName name;
    std::string message_type;
    MessagePolicy policy;
};

struct EndpointSpec {
    TopicSpec topic;
    TransportName transport;
};

这里的重点不是"把 string 包一层就高级了",而是让后续 API 的边界更清楚。

比如后面创建 writer 的时候,它不应该只拿一个字符串,而应该拿一个完整的 endpoint 描述:

cpp 复制代码
EndpointSpec endpoint;
endpoint.topic.name = "/perception/objects";
endpoint.topic.message_type = "ObjectArray";
endpoint.transport = "fastdds";

这样上层表达的是"我要通过某个 transport 访问某个 topic",而不是"这里有一串字符串,你猜它是什么意思"。

TriggerSpec:给调度层先留好位置

PuppetMaster 后面会继续整理调度和组件生命周期。

这一阶段虽然还没有真正实现 scheduler,但我先把 trigger 的公共描述放进了 core:

cpp 复制代码
struct TriggerSpec {
    TaskName task;
    TopicName topic;
};

这个类型很简单,但它代表后续一个重要方向:

任务调度不应该散落在业务模块里,而应该被中间件统一描述和管理。

比如旧工程里,模块可能会根据不同消息触发不同任务。后面 PuppetMaster 里可以逐步把这类关系提炼成更清晰的模型:

text 复制代码
topic message -> trigger -> task callback

第二阶段只是先把这个概念放到公共类型里,后面真正的 scheduler 会基于它继续扩展。

MessagePolicy:不要把 DDS QoS 直接塞进 core

这一阶段里有一个比较关键的设计调整。

一开始我考虑过把消息策略叫做 qos.h,因为 FastDDS 里确实有 QoS 的概念,比如 reliability、durability、history、depth 等。

但后来我觉得这个名字不合适。

因为 PuppetMaster 不应该只服务于 FastDDS。它后面还要支持:

  • FastDDS
  • ZMQ
  • shared memory / IPC
  • in-process transport
  • 甚至其他自定义 transport

DDS 的 QoS 是 DDS 的语义,不能直接变成中间件 core 的公共语义。

比如:

  • FastDDS 可以支持 reliable / best effort
  • ZMQ 没有完全等价的 DDS QoS
  • IPC 可能关心队列深度和覆盖策略
  • in-process transport 可能只需要本地队列策略

所以 core 层不应该暴露 DdsQos,而应该抽象成更通用的消息策略。

最后我把它整理成了 MessagePolicy

cpp 复制代码
struct MessagePolicy {
    DeliveryGuarantee delivery;
    RetentionPolicy retention;
    FreshnessPolicy freshness;
    QueueOverflowPolicy overflow;
    std::size_t queue_depth;
};

这几个字段尽量使用中间件自身的语言,而不是某个后端的语言。

比如:

cpp 复制代码
enum class DeliveryGuarantee {
    kBestEffort,
    kReliable,
};

enum class RetentionPolicy {
    kVolatile,
    kTransientLocal,
};

enum class QueueOverflowPolicy {
    kDropOldest,
    kDropNewest,
    kBlock,
};

这样 FastDDS adapter 可以把它映射到 DDS QoS,ZMQ adapter 也可以把其中一部分映射到自己的 socket 选项,IPC adapter 可以映射到共享内存队列策略。

不能映射的部分,就由具体 transport 返回 Unsupported 或者降级处理。

这点对开源项目很重要。

因为如果 core 层一开始就绑定了 FastDDS,后面别人想接入 ZMQ 或者别的通信后端时,就会觉得这个项目不是一个通用中间件,而只是一个 DDS wrapper。

这一步之后的层次关系

整理完第二阶段后,PuppetMaster 的底层大概变成这样:

flowchart TB Runtime["Runtime / Component / Scheduler
后续阶段实现"] --> Core["core 公共语义层"] Core --> Status["Status
统一错误模型"] Core --> Result["Result
带值返回模型"] Core --> Types["TopicSpec / EndpointSpec / TriggerSpec
公共类型"] Core --> Policy["MessagePolicy
通用消息策略"] Transport["Transport Abstraction
下一阶段实现"] --> Core FastDDS["FastDDS Adapter
后续阶段实现"] --> Transport ZMQ["ZMQ / IPC Adapter
后续扩展"] --> Transport Policy -.映射.-> FastDDS Policy -.部分映射.-> ZMQ Policy -.部分映射.-> IPC["Shared Memory / IPC"]

它表达的重点是:core 不依赖具体通信后端,反过来是 transport、adapter、runtime 依赖 core。

测试也要覆盖语义

这一阶段我也补了对应的测试:

text 复制代码
test/
`-- core_types_test.cpp

测试内容主要不是复杂算法,而是验证基础语义:

  • Status::Ok() 是否表示成功
  • 错误状态是否能携带 code 和 message
  • Result<T> 是否能正确保存值
  • Result<T> 是否能正确保存错误状态
  • topic / endpoint 这类公共类型是否能正常组合
  • message policy 的默认值是否符合预期

比如类似这样的测试:

cpp 复制代码
#include <puppet_master/core/status.h>

int main()
{
    auto ok = puppet_master::core::Status::Ok();
    if (!ok.ok()) {
        return 1;
    }

    auto status = puppet_master::core::Status::InvalidArgument("invalid topic");
    if (status.ok()) {
        return 1;
    }

    return 0;
}

这类测试看起来很小,但它能保证后面每次改 core API 时不会无意中破坏基础行为。

底层公共类型一旦稳定,后面的 transport、runtime、scheduler 才能放心往上搭。

快速验证

如果本地已经安装了 CMake 和 C++17 编译器,可以这样验证:

bash 复制代码
cmake -S . -B build -DPUPPETMASTER_BUILD_TESTS=ON
cmake --build build
cd build
ctest --output-on-failure

我建议这里使用 cd build && ctest 的方式,而不是只依赖:

bash 复制代码
ctest --test-dir build

因为不同环境里的 CTest 版本可能对 --test-dir 支持不完全一致。进入 build 目录再执行,是最稳的写法。

这一阶段的取舍

这一阶段我没有做几件事情:

  • 没有实现具体通信后端
  • 没有把旧工程里的所有 common/base 一次性搬过来
  • 没有开始写 runtime
  • 没有提前绑定 FastDDS QoS

这是有意的。

PuppetMaster 是从旧的自动驾驶中间件里提炼出来的,但我不希望它只是旧工程的平移。

如果只是把旧代码换个目录复制过来,短期看起来进度很快,但后面会很难开源化、文档化,也很难让别人理解它的边界。

所以第二阶段我更关注这些问题:

  • public API 是否清楚?
  • 错误返回是否统一?
  • core 层是否足够通用?
  • 后续 transport 能不能自然接上?
  • FastDDS、ZMQ、IPC 是否都能在这个模型下找到位置?

这个阶段更像是在打地基。

代码量不算特别大,但它决定了后面几层怎么长。

后续计划

第二阶段完成以后,后面就可以开始做 transport abstraction。

下一阶段我会继续整理:

  • Message
  • Reader
  • Writer
  • Transport
  • TransportRegistry
  • transport capability 描述
  • FastDDS / ZMQ / IPC 适配边界

这里的目标是让上层 runtime 不直接关心底层通信实现。

上层只需要知道:

cpp 复制代码
auto writer = transport->CreateWriter(endpoint);
writer->Write(message);

至于是 FastDDS、ZMQ、IPC,应该由 adapter 层处理。

这也是 PuppetMaster 后续能不能变成一个通用中间件项目的关键一步。

总结

如果说第一阶段是在整理工程骨架,那第二阶段就是在整理项目的公共语言。

StatusResult<T>TopicSpecEndpointSpecMessagePolicy 这些东西看起来都很基础,但中间件项目真正能不能长期维护,往往就是由这些基础类型决定的。

下一篇我会继续记录第三阶段:transport abstraction。也就是把通信层从具体后端里抽出来,为后面的 FastDDS、ZMQ、IPC 接入做准备。

项目地址:

github.com/SoleyRan/Pu...

Issue:

github.com/SoleyRan/Pu...

PR:

github.com/SoleyRan/Pu...

相关推荐
cd_949217214 小时前
HPE以全新自主网络能力推动“自动驾驶的网络”愿景落地,加速安全AI原生运维
网络·安全·自动驾驶
fangzt20101 天前
从零搭建自动驾驶中间件(二):共享内存零拷贝通信的工程实践
人工智能·中间件·自动驾驶
Hi202402171 天前
CUDA-BEVFusion 开箱即用镜像使用指南
人工智能·自动驾驶·cuda·机器视觉
fangzt20101 天前
从零搭建自动驾驶中间件(三):事件驱动与协程调度的工程实践
人工智能·中间件·自动驾驶
fangzt20101 天前
从零搭建自动驾驶中间件(五):状态机、诊断与运维——让系统“可观测、可控制“
中间件·自动驾驶
fangzt20101 天前
从零搭建自动驾驶中间件(四):数据录制与回灌——算法调试的核心基础设施
算法·中间件·自动驾驶
fangzt20101 天前
从零搭建自动驾驶中间件(一):为什么自动驾驶需要自研中间件
人工智能·中间件·自动驾驶
地平线开发者2 天前
Linux 性能优化工具
算法·自动驾驶
地平线开发者2 天前
征程 6X 之 Memory corruption 问题分析方法
算法·自动驾驶