
C++ 后端中的启动装配设计----DDD架构
一、前言
当一个 C++ 后端系统开始变复杂后,入口函数通常不再只是“加载配置然后启动服务”这么简单。
系统往往会同时包含:
- 配置加载
- 缓存组件
- 事件总线
- 仓储层
- 领域服务
- 应用服务
- 定时任务
- 事件处理器
如果这些内容全部直接写在 main.cpp 中,随着模块变多,代码很容易出现下面这些问题:
- 初始化顺序不清晰
- 模块依赖散落在各处
- 对象创建逻辑和业务逻辑混在一起
- 控制器、服务、仓储之间直接耦合
- 单元测试难以替换依赖
因此,一个更稳妥的做法是把启动逻辑拆成三类职责:
- 决定谁先初始化
- 决定服务怎么创建
- 决定依赖怎么注册和查找
一个典型实现,通常可以拆成三层:
BootstrapService FactoryService Locator
二、启动装配解决的核心问题
启动装配本质上不是“把对象 new 出来”,而是要回答以下几个问题:
1. 模块的初始化顺序是什么
某些模块天然依赖其他模块,例如:
- 应用服务依赖领域服务
- 领域服务依赖仓储
- 仓储依赖数据库或缓存
- 事件处理器依赖应用服务和事件总线
如果顺序处理不好,就可能出现尚未注册就被使用的问题。
2. 对象由谁来创建
如果控制器自己创建服务,服务自己创建仓储,仓储自己创建底层资源,那么对象构造逻辑会迅速扩散到整个系统中。
3. 对象创建后如何复用
像缓存、事件总线、数据库连接池、配置管理器这类对象,往往不应该反复创建,而应该统一管理生命周期。
4. 各层依赖如何保持清晰
如果没有统一装配机制,层与层之间很容易出现反向依赖或者直接硬编码实现类的问题。
三、Bootstrap:用代码表达系统启动顺序
在多模块后端中,Bootstrap 通常用来承担“系统引导器”的职责。
它本身不负责具体业务,而是负责组织整个系统按顺序启动。
一个典型的结构如下:
class ArchitectureBootstrap {
public:
static void initialize() {
initializeInfrastructure();
initializeDomain();
initializeApplication();
registerEventHandlers();
}
private:
static void initializeInfrastructure();
static void initializeDomain();
static void initializeApplication();
static void registerEventHandlers();
};
这种写法的优点非常直接:
- 启动阶段被明确拆分
- 初始化顺序一眼可见
- 新模块更容易接入
main.cpp不会堆积过多细节
这里最重要的不是“写成几个函数”,而是它表达了一条清晰的依赖链:
基础设施层 → 领域层 → 应用层 → 事件层
也就是DDD架构,这条顺序应该与模块依赖方向保持一致。111
四、为什么基础设施层应该最先初始化
在大多数系统中,基础设施层通常先于其他层初始化,因为后续模块要依赖这些基础能力。
常见的基础设施包括:
- 缓存管理器
- 日志系统
- 配置中心
- 事件总线
- 数据库连接池
- 搜索或向量检索客户端
- 外部服务客户端
可以用下面的简化代码表示:
static void initializeInfrastructure() {
auto& di = ServiceLocator::instance();
di.registerInstance(
std::shared_ptr<CacheManager>(
&CacheManager::getInstance(),
[](CacheManager*) {}
)
);
di.registerInstance(
std::shared_ptr<EventBus>(
&EventBus::getInstance(),
[](EventBus*) {}
)
);
DatabasePool::getInstance().initialize();
SearchClient::getInstance().initialize();
}
这段代码体现了两个关键点。
1. 公共能力优先就绪
后面的领域层和应用层可能都依赖缓存、数据库或者事件总线,因此这些能力应该尽早准备好。
2. 单例对象不一定交给容器销毁
这里把全局单例包装成 shared_ptr 时,使用了空删除器:
std::shared_ptr<CacheManager>(
&CacheManager::getInstance(),
[](CacheManager*) {}
)
这表示:
容器只持有这个对象的引用,不负责释放它。
这样可以避免容器析构时误释放全局单例。
五、Service Locator:集中管理依赖注册与解析
Service Locator 是一种轻量的依赖管理模式。
它的核心职责不是负责业务,而是提供统一的服务注册和解析入口。
一个典型实现大致如下:
class ServiceLocator {
public:
static ServiceLocator& instance();
template <typename T>
void registerInstance(std::shared_ptr<T> instance);
template <typename T>
void registerSingleton(std::function<std::shared_ptr<T>()> factory);
template <typename T>
std::shared_ptr<T> resolve();
template <typename T>
std::shared_ptr<T> resolveRequired();
private:
std::unordered_map<std::type_index, std::shared_ptr<void>> instances_;
std::unordered_map<std::type_index, std::function<std::shared_ptr<void>()>> factories_;
std::recursive_mutex mutex_;
};
从功能上看,这里主要解决了几个问题。
1. 服务注册统一化
所有模块都通过容器注册,而不是各处散落着自己的创建逻辑。
2. 支持实例注册和工厂注册
registerInstance适合已有对象registerSingleton适合延迟创建对象
3. 解析逻辑统一
上层模块只需要调用 resolve() 或 resolveRequired(),而不关心这个对象是如何被构造出来的。
4. 方便替换实现
如果系统上层依赖接口,下层实现通过装配层绑定,那么测试时就很容易替换为 mock 实现。
六、为什么 resolveRequired() 很有必要
很多容器实现除了普通的 resolve(),还会提供一个更严格的 resolveRequired()。
两者区别在于:
resolve()找不到时可以返回空值resolveRequired()找不到时直接报错
示意代码如下:
template <typename T>
std::shared_ptr<T> resolveRequired() {
auto instance = resolve<T>();
if (!instance) {
throw std::runtime_error("Required service not registered");
}
return instance;
}
这个接口的意义在于:
对关键依赖,应该尽早失败,而不是带着空对象继续运行。
这样注册遗漏、配置错误或者装配顺序问题,可以在启动阶段就暴露,而不是拖到运行时某个业务分支才崩溃。
七、为什么这里经常会用 recursive_mutex
很多人第一次写容器时,可能只会想到普通的 std::mutex。
互斥锁有很多的好处,但在实际项目中,服务创建逻辑往往是递归的。
例如:
di.registerSingleton<UserService>([&di]() {
auto repo = di.resolveRequired<UserRepository>();
auto cache = di.resolveRequired<CacheManager>();
return std::make_shared<UserService>(repo, cache);
});
如果 UserRepository 的创建过程中又继续触发别的 resolve(),那么当前线程会重新进入容器内部。
这时如果使用普通互斥锁,就可能出现同线程递归加锁导致的死锁。
因此很多轻量容器会直接使用 std::recursive_mutex,保证同一线程在依赖展开时可以重复进入。
八、Service Factory:把应用服务创建逻辑集中起来
如果说 Service Locator 解决的是“服务怎么注册和查找”,那么 Service Factory 解决的就是:
应用层对象应该如何被统一构造。
典型的工厂写法如下:
class ApplicationServiceFactory {
public:
static void initialize() {
auto& di = ServiceLocator::instance();
auto cachePtr = std::shared_ptr<CacheManager>(
&CacheManager::getInstance(),
[](CacheManager*) {}
);
auto eventPtr = std::shared_ptr<EventBus>(
&EventBus::getInstance(),
[](EventBus*) {}
);
di.registerSingleton<UserApplicationService>([&di, cachePtr, eventPtr]() {
return std::make_shared<UserApplicationService>(
di.resolveRequired<IUserRepository>(),
cachePtr,
eventPtr
);
});
di.registerSingleton<OrderApplicationService>([&di, cachePtr, eventPtr]() {
return std::make_shared<OrderApplicationService>(
di.resolveRequired<IOrderRepository>(),
di.resolveRequired<OrderDomainService>(),
cachePtr,
eventPtr
);
});
}
};
这一层的核心意义在于:
- 上层调用方不再关心构造过程
- 应用服务依赖关系集中可见
- 装配逻辑从业务代码中分离
- 方便统一做生命周期控制
九、为什么应用服务不应该由控制器直接构造
在初期项目中,下面这种写法很常见:
class UserController {
public:
void getUser() {
auto repo = std::make_shared<UserRepository>();
auto service = std::make_shared<UserApplicationService>(repo);
service->execute();
}
};
这种写法的问题在于:
- 控制器承担了构造职责
- 依赖关系开始向接口层泄露
- 替换实现时需要改控制器
- 重复构造开销难以控制
- 测试隔离困难
更合理的方式是由工厂统一注册,再由控制器直接取服务:
class UserController {
public:
void getUser() {
auto service = ServiceLocator::instance()
.resolveRequired<UserApplicationService>();
service->execute();
}
};
这样做的好处是调用层职责更单一,它只关心“用谁”,不关心“怎么造出来”。
十、领域层初始化通常做什么
领域层初始化一般包含两部分内容:
- 仓储接口与具体实现的绑定
- 领域服务的注册
典型写法如下:
static void initializeDomain() {
auto& di = ServiceLocator::instance();
di.registerSingleton<IUserRepository, UserRepository>();
di.registerSingleton<IOrderRepository, OrderRepository>();
di.registerSingleton<OrderDomainService>([&di]() {
return std::make_shared<OrderDomainService>(
di.resolveRequired<IOrderRepository>()
);
});
}
这个结构体现了一条很重要的原则:
上层依赖接口,绑定关系在装配层决定。
这样做的好处是:
- 上层模块与底层实现解耦
- 实现替换更容易
- 分层边界更清晰
- 更方便做测试替身注入
十一、事件处理器为什么通常最后注册
事件驱动系统中,事件处理器一般放在初始化流程的最后阶段注册。
原因很简单:处理器依赖的大部分服务,往往需要先准备好。
示意代码如下:
static void registerEventHandlers() {
auto& eventBus = EventBus::getInstance();
auto orderCreatedHandler = std::make_shared<OrderCreatedHandler>(
ServiceLocator::instance().resolveRequired<OrderApplicationService>()
);
auto notifyHandler = std::make_shared<NotifyHandler>(
ServiceLocator::instance().resolveRequired<NotificationService>()
);
eventBus.subscribe(orderCreatedHandler);
eventBus.subscribe(notifyHandler);
}
把事件处理器放在最后有几个明显好处:
- 订阅时依赖已经完成装配
- 不容易出现半初始化状态
- 启动阶段职责更清楚
- 出问题时定位更容易
如果事件处理器过早注册,就可能出现事件总线已经开始接收消息,但处理器依赖还没准备好的问题。
十二、main.cpp 的职责应该是编排,而不是堆细节
很多系统的 main.cpp 最后都会越来越大。
真正合理的入口函数,应该承担的是“启动编排”职责,而不是所有细节实现。
理想状态下,入口函数只需要表达下面这些内容:
int main() {
loadConfig();
initializeLogger();
ArchitectureBootstrap::initialize();
startHttpServer();
return 0;
}
也就是说,入口函数应该回答的是:
- 先做什么
- 后做什么
- 系统什么时候进入运行态
而不是在这里直接堆满几十个模块的构造细节。
十三、这类设计的几个关键收益
从工程角度看,这种启动装配设计会带来几个非常直接的收益。
1. 初始化顺序更清晰
各层职责分离后,系统依赖关系更容易读懂。
2. 模块耦合降低
对象创建逻辑从业务调用路径中拆出去后,各层关注点更单一。
3. 可测试性更好
通过接口绑定和集中装配,mock 或替身对象更容易注入。
4. 扩展成本更低
新服务只需要在对应层注册,不必修改大量入口代码。
5. 生命周期管理更稳定
特别是单例对象、共享资源和事件总线这类组件,边界更清楚。
十四、实践中需要注意的问题
当然,这类设计也不是没有代价。
1. Service Locator 不能滥用
如果任何地方都可以随意从全局容器取对象,那么依赖关系可能会重新变得隐式。
因此更理想的方式是:容器主要用于装配和边界层,而不是任意业务代码到处直接调用。
2. 注册顺序必须可追踪
如果容器注册顺序混乱,即使代码有 Bootstrap,也可能在某个阶段出现依赖找不到的问题。
3. 单例边界要明确
并不是所有对象都适合做单例。
只有那些天然跨模块共享、生命周期接近全局的资源,才适合这样接入。
4. 错误处理要尽量提前
对关键依赖,宁可在启动阶段失败,也不要带着不完整状态进入运行期。
十五、总结
在 C++ 后端系统中,启动装配代码虽然不如业务逻辑显眼,但它决定了整个系统是否容易维护和扩展。
一个清晰的技术结构通常会把这几件事分开处理:
Bootstrap负责初始化顺序Service Factory负责服务构造Service Locator负责依赖注册与解析
它们共同解决的问题包括:
- 模块如何有序启动
- 对象如何统一创建
- 依赖如何集中管理
- 单例资源如何安全接入
- 事件处理器何时注册最合理
从实现角度看,这类设计并不依赖特别重的框架,
即使只使用标准 C++ 和少量基础设施,也完全可以搭建出一套结构清晰的启动装配体系。
对于需要长期维护的后端项目来说,
这类代码往往比某个局部技巧更能体现整体工程质量。
published by Jokerbai