面向 7×24 无人值守场景,一套 C++17 代码同时跑 Windows 服务与 Linux Daemon,业务插件与框架底座完全解耦。本文分享这套架构从分层设计、插件 ABI 安全到跨平台落地的完整思路。
写在前面
后台常驻服务的架构,通常面临两个绕不开的问题:
- 跨平台:客户环境可能是 Windows 服务器,也可能是各种 Linux 发行版,甚至新旧版本混搭;
- 扩展性:采集/对接的设备种类会持续增加,每接一种新设备就动一次主程序,成本极高,风险也大。
我参与过的一套跨平台数据采集后台服务,核心诉求就是"一次开发,长期无人值守运行,新业务接入不改框架"。本文把这套架构的核心设计整理出来,重点讲三个部分:四层解耦架构、插件 ABI 安全、框架底座的关键能力。
说明:文中所有业务场景均已抽象为通用描述,不涉及具体行业、客户与真实部署。
一、总体架构:四层解耦
整套系统自上而下分为 4 层:
业务插件层 各业务的采集/解析插件,互相独立
插件抽象接口层 统一的插件 API,框架只依赖抽象,不依赖具体业务
框架底座层 调度、网络、数据库、传输、日志、重试等通用能力
系统适配层 抹平 Windows / Linux 的系统差异
核心原则有几点:
- 依赖倒置:框架底座只依赖插件抽象接口,不包含任何业务解析代码;插件反向注册到框架。
- 跨平台隔离:所有系统相关 API 全部封装在系统适配层,业务插件与框架核心逻辑做到平台无关。
- 配置驱动:点位、数据库、传输、线程池等参数全部由配置文件驱动,无需重新编译程序。
- 故障隔离:单个采集任务异常不影响整体服务,一个点位故障不拖垮其他点位。
这套分层的收益很直接:新增一种业务,只写一个插件,主程序一行不改。
二、插件系统:ABI 安全设计(最值得关注的部分)
C++ 插件系统最常见的坑,是动态库(DLL/SO)跨模块的 ABI 不兼容。插件和主程序往往分开编译、分别升级,一旦 ABI 出问题,轻则加载失败,重则内存崩溃。
这里有几条必须遵守的硬约束:
1. 导出函数只用 extern "C"
插件只对外导出两个 C 函数:
arduino
extern "C"
{
ICapturePlugin* CreatePluginInstance();
void DestroyPluginInstance(ICapturePlugin* p);
}
C++ 的名字修饰(mangling)在不同编译器/版本间不一致,用 C 接口导出最稳妥。
2. 接口边界只传递 POD 类型
STL 容器(std::string、std::vector)禁止跨插件边界传递------不同编译单元的内存分配器、堆实现不同,直接传递会崩溃。
- 允许:基础类型、固定内存布局的 POD 结构体、
const char*+ 长度; - 字符串:
const char*+size_t长度,不依赖\0; - 配置:以 JSON 字符串传入,插件内部自行解析。
3. 虚接口类规范
- 接口类只含纯虚函数 + 虚析构,无成员变量;
- 新增虚函数只能追加到接口末尾,不能插入中间,防止虚表偏移------这是插件向后兼容的关键。
4. 内存分配规则:谁分配,谁释放
- 插件对象由插件内
CreatePluginInstance创建、DestroyPluginInstance销毁,主程序永不直接 delete; - 禁止跨模块 new/delete。
5. 跨编译器约束(很多人忽略)
extern "C" 只解决了函数名,虚函数表的 ABI 在不同编译器体系之间并不兼容(例如 MSVC 与 GCC/MinGW 的虚表布局不同)。所以必须明确:
主程序与插件必须使用同一编译器、同一 ABI 链编译。
这一点如果不在交付文档里写清楚,客户用另一套编译器编译插件,加载时就会出诡异的内存问题。
6. 回调指针的生命周期
框架提供给插件的回调,会传入指向插件内存的指针。如果框架异步消费这些数据(比如入队上传、异步写日志),指针会在插件调用返回后失效,形成悬垂引用。
约定 :回调要么同步消费,要么框架在回调内立即深拷贝到自有内存,绝不持有插件指针到异步阶段。
三、跨平台服务托管
系统适配层把平台差异封在最小范围内,对外提供统一的服务生命周期接口:
csharp
class IServiceHost {
public:
virtual int Init() = 0;
virtual void Run() = 0;
virtual void Stop() = 0;
virtual bool IsRunning() = 0;
};
// WindowsServiceHost : IServiceHost
// LinuxDaemonHost : IServiceHost
- Windows:用现代 Win32 Service API,原生注册为系统服务,兼容 Win10 及服务器系列;
- Linux:标准 Daemon + 原生适配 systemd,支持开机自启、崩溃自动重启、资源限制(CPU/内存/文件句柄)、优雅停机信号。
关键点:核心框架代码完全不包含任何平台服务代码,平台差异化只存在于各自的入口目标里。用 CMake 分两个编译目标,而不是维护两套独立源码仓库。
四、框架底座的关键能力
框架底座是"一次开发、所有插件共享"的通用能力层。有几个设计细节值得展开。
4.1 多线程调度与队列背压
使用多级线程池:IO 等待型任务、CPU 解析型任务、批量入库、文件传输各自独立,避免互相阻塞。
最容易被忽略的是队列背压。7×24 场景下,前端设备瞬时洪峰(比如集中上报)会让队列无限增长,最终 OOM 崩溃。必须:
- 每个队列设置最大长度上限;
- 定义满时策略:丢弃 + 告警,或落本地缓存兜底,或背压暂停新任务。
宁可短暂丢数据 + 告警,也不能让进程 OOM 崩溃------这是无人值守服务的底线思维。
4.2 网络 IO 的职责边界
框架只负责连接生命周期 (建连、重连、超时、字节流收发、IO 多路复用),绝不解析业务协议;协议解析全部在插件内完成。
这样做的原因很实际:不同厂商设备的协议差异极大,把协议层放进框架会严重耦合。框架向插件提供连接句柄(IConn),插件在句柄之上实现自己的协议。
4.3 数据库适配层
提供统一数据库接口,上层不感知底层是哪种数据库,运行时动态选择驱动:
- 连接池:复用连接,避免频繁创建销毁;
- 参数化 SQL 防注入;批量插入(高频数据的关键);
- 表路由:通用入库接口必须带表名维度,否则框架不知道数据写到哪张表;
- 事务原语:涉及多表一致性的业务需要预留 Begin/Commit/Rollback。
4.4 文件传输:抽象优于硬编码
一开始往往只对接一种上传协议(比如 FTP)。但如果接口里写死某一种协议,后续扩展(HTTP、对象存储、SFTP)就要动框架底座。
正解是抽象一个传输接口:
csharp
class ITransport {
public:
virtual ~ITransport() = default;
virtual HubErrCode Connect(...) = 0;
virtual HubErrCode UploadFile(const char* local, const char* remote) = 0;
virtual bool IsConnected() const = 0;
};
// FtpTransport / HttpTransport / S3Transport / SftpTransport
FTP 是首个实现,但接口层先抽象好,后续协议扩展无痛。
4.5 日志、重试与保活
- 日志:直接用成熟库(如 spdlog),不自研;异步模式,写文件不阻塞业务线程;滚动切割防磁盘打满;
- 重试:统一策略,区分可重试错误(网络抖动)与不可重试错误(配置错误),避免无意义循环;
- 保活 :复用操作系统原生服务管理器(systemd / SCM),框架内部不做重复的"父进程守护子进程"。要区分清楚------可捕获的异常由框架兜底,内存级崩溃进程内无法隔离,交给系统服务管理器自动拉起。
五、实践经验与踩坑总结
- 插件 ABI 是全套系统最容易翻车的地方,务必把
extern "C"、POD 传递、虚表追加、跨编译器约束写进强制规范; - 队列必须有界,背压是 7×24 服务的生命线;
- 回调指针生命周期要想清楚,异步消费必须深拷贝;
- 传输、数据库都要抽象,接口层抽象好,后续扩展不动框架;
- 第三方库许可证要审计------商用闭源产品严禁引入 GPL/LGPL 传染性许可的库;
- 发布环境与开发环境版本要分离:开发机用新工具链没问题,但交付给旧系统的二进制要用兼容环境(如旧发行版容器/CI)构建,避免 glibc 版本不兼容。
六、总结
这套架构的核心,是把"会变的"(业务、协议、平台)和"不变的"(调度、存储、传输、容错)彻底分开:
- 业务插件层承接变化,只写插件不改框架;
- 系统适配层承接平台差异,核心代码平台无关;
- 框架底座层沉淀通用能力,一次开发、长期复用。
配合 ABI 安全规范和跨平台 CMake 工程,就能得到一套"跨平台、可扩展、7×24 稳定运行"的后台服务底座。
如果你对某个部分的实现细节感兴趣(比如线程池的有界队列、传输抽象的多协议扩展、或跨平台 CMake 工程组织),欢迎留言交流。