最近我开始整理一个 C++ 实时中间件项目:PuppetMaster
它不是想一上来就做一个庞大的通用框架,而是从我之前维护过的自动驾驶中间件中,逐步提炼出一套更清晰、更通用、更容易复用的基础能力:模块通信、任务调度、Topic 管理、通信后端抽象、错误处理、生命周期管理,以及后续的配置和可观测性。
GitHub 地址:github.com/SoleyRan/Pu...
到了第三阶段,我开始处理一个更核心的问题:通信层到底应该怎么抽象?
这一步的目标很明确:先不急着把 FastDDS、ZMQ、IPC 都接上来,而是先把它们共同依赖的通信边界抽出来。
为什么要先做通信抽象?
在自动驾驶、机器人或者实时服务里,中间件经常会面对几类通信方式:
- 进程内通信
- 共享内存 / IPC
- ZMQ
- DDS / FastDDS
- 后续可能还有录包、回放、仿真桥接等通信后端
如果业务模块直接依赖某一个后端,比如直接拿 FastDDS 的 DataReader、DataWriter 或者 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:业务层真正依赖的接口
这一阶段最重要的接口其实是 Reader 和 Writer。
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 只负责写消息。
第二,返回值统一使用第二阶段定义的 Status 和 Result<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 的通信层变成了这种结构:
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 以后不再直接面向具体通信实现,而是面向 Transport、Reader、Writer 这些接口。
这也是后面继续做 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
这些接口先稳定下来,后面的工作就可以顺着往上长。
第三阶段完成后,下一步就可以开始写真正的本地通信实现。
项目地址:
Issue:
PR: