自动驾驶C++实时中间件:PuppetMaster 重构记录,阶段三:通信层抽象

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

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

GitHub 地址:github.com/SoleyRan/Pu...

到了第三阶段,我开始处理一个更核心的问题:通信层到底应该怎么抽象?

这一步的目标很明确:先不急着把 FastDDS、ZMQ、IPC 都接上来,而是先把它们共同依赖的通信边界抽出来。

为什么要先做通信抽象?

在自动驾驶、机器人或者实时服务里,中间件经常会面对几类通信方式:

  • 进程内通信
  • 共享内存 / IPC
  • ZMQ
  • DDS / FastDDS
  • 后续可能还有录包、回放、仿真桥接等通信后端

如果业务模块直接依赖某一个后端,比如直接拿 FastDDS 的 DataReaderDataWriter 或者 QoS 类型,那后面会很难扩展。

比如一个算法模块本来只是想表达:

text 复制代码
我订阅 /vehicle/speed
我发布 /planning/trajectory
收到数据后触发一次计算

它不应该关心底层是 FastDDS、ZMQ,还是进程内队列。

所以这一阶段要做的是把通信拆成两个层次:

text 复制代码
业务模块 / Runtime / Scheduler
        |
        v
Transport 抽象层
        |
        v
FastDDS / ZMQ / IPC / InMemory

这一层做好之后,后续再接入具体后端时,Runtime 和 Component 层就不用跟着大改。

这一阶段做了什么?

这次主要新增了 transport 公共接口:

text 复制代码
include/
`-- puppet_master/
    `-- transport/
        |-- message.h
        |-- transport.h
        `-- registry.h

docs/
`-- transport.md

test/
`-- transport_abstraction_test.cpp

可以看出来,这一步不是在"接 FastDDS",而是在先把通信后端应该遵守的公共协议整理出来。

Message:先统一消息模型

通信层首先要回答一个问题:消息怎么表示?

我没有在 transport 层直接引入模板消息类型,也没有让它依赖 protobuf、FastDDS type support 或者某个序列化框架,而是先抽象成字节流。

核心类型在 message.h 里:

cpp 复制代码
namespace puppet_master::transport {

using ByteBuffer = std::vector<core::Byte>;

class ByteView {
public:
    ByteView(const core::Byte* data, std::size_t size);
    explicit ByteView(const ByteBuffer& buffer);

    static ByteView From(const void* data, std::size_t size);

    const core::Byte* data() const noexcept;
    std::size_t size() const noexcept;
    bool empty() const noexcept;

    core::Status Validate() const;
};

struct MessageDescriptor {
    std::string type_name;
    std::string encoding {"application/octet-stream"};

    core::Status Validate() const;
};

struct MessageMetadata {
    core::SequenceNumber sequence {0};
    core::TimePoint source_timestamp {};
    core::TimePoint reception_timestamp {};
};

struct Message {
    ByteBuffer payload;
    MessageMetadata metadata;
};

}

这里我把消息拆成了三部分:

  • payload:真正传输的数据
  • descriptor:描述消息类型和编码方式
  • metadata:序号、源时间戳、接收时间戳等运行时信息

这样做的好处是 transport 层可以保持简单。

FastDDS 以后可以在 adapter 内部把 MessageDescriptor 映射到具体的 type support;ZMQ 可以直接传 byte buffer;in-memory transport 也可以直接复制或移动字节数据。

也就是说,transport 层只负责"搬运消息",不负责"理解业务类型"。

为什么不是一上来就做模板化接口?

一开始很容易想到这种接口:

cpp 复制代码
Writer<T>
Reader<T>

看起来很舒服,但它会把问题提前复杂化。

因为不同后端对类型系统的支持差异很大:

  • FastDDS 需要 type support
  • ZMQ 本质上是字节消息
  • IPC 可能是共享内存块或 ring buffer
  • 后续录包回放可能只关心原始 payload

如果现在就把 transport 接口设计成强模板类型,后面每个后端都会被迫适配同一套类型机制,反而会让底层变重。

所以我这一阶段先选择了更稳定的方式:公共 transport 层走 byte-oriented message,类型序列化放在更上层或者具体 adapter 内部处理。

这也符合我对中间件分层的理解:越靠底层,语义越少;越靠上层,类型和业务表达越丰富。

EndpointConfig:把 Topic 和 Message 绑定起来

有了消息模型之后,还需要描述一个通信端点。

这一步新增了 EndpointConfig

cpp 复制代码
struct EndpointConfig {
    core::TopicSpec topic;
    MessageDescriptor message;

    core::Status Validate() const
    {
        auto status = topic.Validate();
        if (!status.ok()) {
            return status;
        }
        return message.Validate();
    }
};

这里复用了第二阶段整理出来的 TopicSpec

TopicSpec 里有几个关键字段:

cpp 复制代码
struct TopicSpec {
    TopicName name;
    TransportKind transport {TransportKind::kInMemory};
    MessagePolicy message_policy;
};

这就把几个东西串起来了:

  • topic 名称
  • 选择哪个 transport backend
  • 消息策略,比如 latest / queued、queue depth 等
  • 消息类型描述

换句话说,一个 endpoint 不只是 /vehicle/speed 这个字符串,而是完整描述了"我要用什么通信方式、传什么数据、按什么策略缓存"。

Reader 和 Writer:业务层真正依赖的接口

这一阶段最重要的接口其实是 ReaderWriter

cpp 复制代码
class Reader {
public:
    virtual const core::TopicName& topic_name() const noexcept = 0;
    virtual const MessageDescriptor& message_descriptor() const noexcept = 0;
    virtual core::Result<Message> Read(ReadOptions options = {}) = 0;
    virtual core::Status SetDataAvailableCallback(DataAvailableCallback callback) = 0;
};

class Writer {
public:
    virtual const core::TopicName& topic_name() const noexcept = 0;
    virtual const MessageDescriptor& message_descriptor() const noexcept = 0;
    virtual core::Status Write(ByteView payload, WriteOptions options = {}) = 0;
};

这两个接口有几个特点:

第一,接口很薄。

Reader 只负责读消息,Writer 只负责写消息。

第二,返回值统一使用第二阶段定义的 StatusResult<T>

比如:

cpp 复制代码
auto message = reader->Read();
if (!message.ok()) {
    return message.status();
}

这种写法比直接抛异常更适合中间件边界,因为通信失败、暂时没数据、后端不可用,这些都属于正常运行时状态。

第三,Reader 支持 callback。

cpp 复制代码
virtual core::Status SetDataAvailableCallback(DataAvailableCallback callback) = 0;

这个接口是给后续 scheduler 留的。

以后做数据触发调度时,scheduler 不需要轮询每一个 reader,而是可以让 transport 在数据到达时通知 runtime。

ReadOptions 和 WriteOptions

读写接口里也预留了运行时选项。

cpp 复制代码
struct ReadOptions {
    bool wait {false};
    core::Nanoseconds timeout {0};
};

struct WriteOptions {
    core::TimePoint source_timestamp {};
};

ReadOptions 主要用于后续支持阻塞读和超时读。

比如 in-memory transport 里可以自然支持:

cpp 复制代码
reader->Read({true, std::chrono::milliseconds(10)});

而 FastDDS 这类后端如果不能提供完全一致的行为,就应该返回 Status::Unsupported 或者在 adapter 内部做适配。

WriteOptions 里目前只放了源时间戳。后续如果需要 trace id、frame id、deadline 等元信息,也可以继续扩展。

Transport:后端统一实现的协议

Transport 是具体通信后端需要实现的接口。

cpp 复制代码
class Transport {
public:
    virtual const core::TransportName& name() const noexcept = 0;
    virtual core::TransportKind kind() const noexcept = 0;
    virtual TransportCapabilities capabilities() const noexcept = 0;

    virtual core::Status Open() = 0;
    virtual core::Status Close() noexcept = 0;
    virtual bool is_open() const noexcept = 0;

    virtual core::Status ValidateEndpoint(const EndpointConfig& endpoint) const;
    virtual core::Result<ReaderPtr> CreateReader(const EndpointConfig& endpoint) = 0;
    virtual core::Result<WriterPtr> CreateWriter(const EndpointConfig& endpoint) = 0;
};

这里的重点是两个:

  • 生命周期:Open() / Close()
  • endpoint 创建:CreateReader() / CreateWriter()

也就是说,Runtime 以后只需要知道:

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

至于这个 transport 后面是 FastDDS 还是 ZMQ,就不该影响 Runtime 的上层逻辑。

TransportCapabilities:不要假装所有后端都一样

不同通信后端能力是不一样的。

所以我加了 TransportCapabilities

cpp 复制代码
struct TransportCapabilities {
    core::TransportKind kind {core::TransportKind::kInMemory};
    bool supports_callbacks {false};
    bool supports_blocking_read {false};
    bool supports_reliable_delivery {false};
    bool supports_keep_all {false};
    bool supports_zero_copy {false};
};

这块很重要。

比如:

  • FastDDS 可以比较自然地支持 reliable delivery
  • in-memory queue 可以支持 blocking read
  • ZMQ 不一定天然支持所有 QoS 语义
  • shared memory 可能更适合 zero copy
  • 某些 adapter 可能不支持 callback,只能轮询

我不希望 transport 接口写得很理想化,然后每个后端都"假装支持"。

更好的方式是:后端明确告诉 Runtime 自己支持什么,不支持什么。如果调用方请求了不支持的能力,就返回明确的 StatusCode::kUnsupported

这比静默降级更可靠。

TransportRegistry:先有一个轻量注册表

这一阶段还加了一个很小的 TransportRegistry

cpp 复制代码
class TransportRegistry {
public:
    core::Status Register(TransportPtr transport);
    core::Result<TransportPtr> Find(const core::TransportName& name) const;
    core::Status Unregister(const core::TransportName& name);
    std::vector<core::TransportName> ListNames() const;
};

它现在还不是完整 runtime 服务,只是一个轻量工具,用来表达:

text 复制代码
我当前有哪些 transport backend?
我能不能根据名字找到某个 transport?

后面在 RuntimeContext 那一步,这个 registry 就可以被 runtime 持有,用来统一管理 in-memory、FastDDS、ZMQ 等后端。

这一阶段的架构变化

整理完之后,PuppetMaster 的通信层变成了这种结构:

flowchart TB Runtime["Runtime / Scheduler / Component"] --> TransportAPI["Transport Abstraction"] TransportAPI --> Message["Message
ByteBuffer / Metadata / Descriptor"] TransportAPI --> Endpoint["EndpointConfig
TopicSpec + MessageDescriptor"] TransportAPI --> Reader["Reader
Read / Callback"] TransportAPI --> Writer["Writer
Write"] TransportAPI --> Transport["Transport
Open / Close / CreateReader / CreateWriter"] TransportAPI --> Registry["TransportRegistry"] Transport --> InMemory["InMemory Transport
后续实现"] Transport --> FastDDS["FastDDS Adapter
后续实现"] Transport --> ZMQ["ZMQ Adapter
后续扩展"] Transport --> IPC["IPC / Shared Memory
后续扩展"] Core["Core
Status / Result / TopicSpec / MessagePolicy"] --> TransportAPI

这个阶段最关键的变化是:Runtime 以后不再直接面向具体通信实现,而是面向 TransportReaderWriter 这些接口。

这也是后面继续做 in-memory transport、FastDDS adapter、RuntimeContext 的前提。

和 FastDDS 的关系

这一阶段并没有真正接 FastDDS。

原因也很简单:如果抽象边界还没定好,就急着写 FastDDS adapter,很容易把 DDS 的概念带进公共 API。

比如 DDS 里有 QoS、DomainParticipant、Publisher、Subscriber、DataReader、DataWriter、TypeSupport 等概念。

这些当然重要,但它们不应该直接出现在 PuppetMaster 的公共核心接口里。

PuppetMaster 更希望表达的是:

text 复制代码
Topic
MessagePolicy
Reader
Writer
Transport

FastDDS adapter 要做的是把这些通用语义映射到 DDS,而不是让整个项目都围绕 DDS 设计。

这也是前面把 qos.h 改成 message_policy.h 的原因。QoS 是 DDS 里的叫法,而 PuppetMaster core 里需要的是更中性的消息策略。

快速验证

如果拉到本地,可以这样构建和测试:

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

单独看这一阶段的测试,可以关注:

bash 复制代码
ctest -R puppet_master.transport_abstraction --output-on-failure

总结

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

  • 没有实现真实 in-memory transport
  • 没有接 FastDDS type support
  • 没有设计完整序列化系统
  • 没有把 scheduler 接进来
  • 没有做复杂 topic registry

原因是这一步的目标不是"通信能跑多远",而是"通信抽象能不能站住"。

如果这一层不稳定,后面每加一个后端都会重新撕一次接口。

所以我更愿意先把这些边界压小:

text 复制代码
Message
EndpointConfig
Reader
Writer
Transport
TransportRegistry

这些接口先稳定下来,后面的工作就可以顺着往上长。

第三阶段完成后,下一步就可以开始写真正的本地通信实现。

项目地址:

github.com/SoleyRan/Pu...

Issue:

github.com/SoleyRan/Pu...

PR:

github.com/SoleyRan/Pu...

相关推荐
不吃土豆的马铃薯2 小时前
5.SGI STL 二级空间配置器 _S_chunk_alloc核心函数解析
开发语言·c++·vscode·c·内存池
-快乐的程序员-2 小时前
C++的md5函数
开发语言·c++
Huangjin007_2 小时前
【C++ STL篇(九)】map容器——零基础入门与核心用法精讲
开发语言·c++·算法
十年编程老舅3 小时前
Linux NUMA架构深度剖析:内存管理、进程调度与性能优化
linux·数据库·c++·内存管理·numa
少司府3 小时前
C++基础入门:深挖list的那些事
开发语言·数据结构·c++·容器·list·类型转换·类和对象
诙_3 小时前
C++学习总结
开发语言·c++·学习
XX風3 小时前
VSCode + CMake + C++:配置文件体系完整说明
c++·ide·vscode
闻缺陷则喜何志丹3 小时前
【C++动态规划】B3734 [信息与未来 2017] 加强版密码锁|普及+
c++·算法·动态规划·洛谷
承渊政道3 小时前
【贪心算法】(经典实战应用解析(三):K次取反后最⼤化的数组和、按⾝⾼排序、优势洗牌、最⻓回⽂串、增减字符串匹配)
数据结构·c++·学习·算法·贪心算法·线性回归·哈希算法