LK 博客
C++ 后端中的启动装配设计----DDD架构
前后端
约 1 分钟阅读 0 赞 0 条评论 鸿蒙黑体

C++ 后端中的启动装配设计----DDD架构

Jokerbai
Jokerbai @Jokerbai
累计点赞 0 登录后每个账号只能点一次
内容长度 0 正文词元数
正文
目录会跟随阅读位置移动。
阅读进度

一、前言

当一个 C++ 后端系统开始变复杂后,入口函数通常不再只是“加载配置然后启动服务”这么简单。
系统往往会同时包含:

  • 配置加载
  • 缓存组件
  • 事件总线
  • 仓储层
  • 领域服务
  • 应用服务
  • 定时任务
  • 事件处理器

如果这些内容全部直接写在 main.cpp 中,随着模块变多,代码很容易出现下面这些问题:

  • 初始化顺序不清晰
  • 模块依赖散落在各处
  • 对象创建逻辑和业务逻辑混在一起
  • 控制器、服务、仓储之间直接耦合
  • 单元测试难以替换依赖

因此,一个更稳妥的做法是把启动逻辑拆成三类职责:

  1. 决定谁先初始化
  2. 决定服务怎么创建
  3. 决定依赖怎么注册和查找

一个典型实现,通常可以拆成三层:

  • Bootstrap
  • Service Factory
  • Service 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();
    }
};

这样做的好处是调用层职责更单一,它只关心“用谁”,不关心“怎么造出来”。

十、领域层初始化通常做什么

领域层初始化一般包含两部分内容:

  1. 仓储接口与具体实现的绑定
  2. 领域服务的注册

典型写法如下:

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

作者名片

Jokerbai
Jokerbai
@Jokerbai

这个作者暂时还没有填写个人简介。

评论区
文章作者和管理员都可以管理这里的评论。
0 条评论
登录后即可参与评论。 去登录
还没有评论,欢迎留下第一条交流内容。