最近我开始整理一个 C++ 实时中间件项目:PuppetMaster
它不是想一上来就做一个庞大的通用框架,而是从我之前维护过的自动驾驶中间件中,逐步提炼出一套更清晰、更通用、更容易复用的基础能力:模块通信、任务调度、Topic 管理、通信后端抽象、错误处理、生命周期管理,以及后续的配置和可观测性。
GitHub 地址:
这一篇记录的是第二阶段:公共类型和错误模型定义
上一篇文章里,我把 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 的底层大概变成这样:
后续阶段实现"] --> 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。
下一阶段我会继续整理:
MessageReaderWriterTransportTransportRegistrytransport capability 描述FastDDS / ZMQ / IPC 适配边界
这里的目标是让上层 runtime 不直接关心底层通信实现。
上层只需要知道:
cpp
auto writer = transport->CreateWriter(endpoint);
writer->Write(message);
至于是 FastDDS、ZMQ、IPC,应该由 adapter 层处理。
这也是 PuppetMaster 后续能不能变成一个通用中间件项目的关键一步。
总结
如果说第一阶段是在整理工程骨架,那第二阶段就是在整理项目的公共语言。
Status、Result<T>、TopicSpec、EndpointSpec、MessagePolicy 这些东西看起来都很基础,但中间件项目真正能不能长期维护,往往就是由这些基础类型决定的。
下一篇我会继续记录第三阶段:transport abstraction。也就是把通信层从具体后端里抽出来,为后面的 FastDDS、ZMQ、IPC 接入做准备。
项目地址:
Issue:
PR: