C++大型项目模块化拆分实战记录

一、当单体项目成为瓶颈

接手这个 C++ 项目的时候,代码仓库已经积累了五年多。十几万行代码塞在同一个大目录里,头文件互相 include 得像蜘蛛网,改一个公共函数要重新编译半个小时,新人入职光是理清依赖关系就要花两周。更让人头疼的是,每次发布都得全量构建,CI 流水线跑一次四十分钟起步。

这些问题不是突然出现的,而是项目从小到大自然生长过程中慢慢堆积的。当团队从三五个人扩张到二十多人,当功能模块从三四个膨胀到十几个,原来的单体结构就成了最大的技术债。这篇文章记录了我们把一个大型 C++ 单体项目拆分成多个独立模块的全过程,包括踩过的坑和总结出的经验。

二、现状诊断:先看清问题再动刀

2.1 项目概况

先交代一下背景。我们的项目是一个分布式存储系统,核心语言 C++17,构建工具 CMake,代码量大约 18 万行(不含第三方库),部署在 Linux 服务器上。拆分前整个工程结构大致是这样:

text 复制代码
project/
├── CMakeLists.txt          # 顶层 CMake,将近 800 行
├── src/
│   ├── common/             # 100+ 个头文件和源文件混在一起
│   ├── network/
│   ├── storage/
│   ├── metadata/
│   ├── client/
│   └── ...                 # 十几个子目录,边界模糊
├── include/                # 公共头文件,很多人往里随便扔
├── tests/                  # 单元测试和集成测试混放
└── third_party/            # 第三方库,版本管理混乱

2.2 核心痛点清单

动手之前,我们先花了一周时间把问题逐个梳理出来,按照严重程度排了序:

  • 编译耗时过长:修改一个底层头文件,触发上千个编译单元重新编译。全量构建超过 35 分钟,增量构建也经常需要 10 分钟以上。
  • 头文件循环依赖common/error.h 引用了 network/socket.h,而 network/socket.h 又引用了 common/logging.h,再往回引用 common/error.h,编译器报出来的 include 链能绕地球一圈。
  • 接口边界模糊:内部实现类被外部随意使用,想改一个 private 方法发现十几处非法引用,根本不敢动。
  • 测试困难:模块之间耦合太紧,想单独测 storage 层必须要启动 network 和 metadata 服务,单元测试变成了集成测试。
  • 团队协作冲突:多人同时改同一个 CMakeLists.txt 或者公共头文件,合并冲突是家常便饭。

三、拆分策略:先规划再执行

3.1 确定模块边界

拆分的第一步不是写代码,而是画图。我们用了一周时间,把项目按功能域划分成以下几个核心模块,并在白板上画出模块间的依赖关系:

text 复制代码
storage_core     ← 存储引擎核心,不依赖任何业务模块
    ↑
metadata_mgr    ← 元数据管理,依赖 storage_core
    ↑
network_layer   ← 网络层,依赖 metadata_mgr 和 storage_core
    ↑
client_api      ← 对外暴露的客户端 API,依赖 network_layer

这里有一个关键原则:依赖方向必须自上而下,底层模块不知道上层模块的存在。如果画出来的依赖图出现了环,那就说明边界划分有问题,需要重新审视。

3.2 公共基础设施独立

在拆分业务模块之前,先把真正的基础设施抽出来,形成几个零外部依赖的底层库:

  • base:日志、错误码、字符串工具、时间处理等零依赖的基础组件
  • concurrency:线程池、异步任务队列、锁工具
  • protocol:内部通信协议定义,纯数据结构,不依赖网络实现

这几个库的定位是「项目内部的 Boost」,所有其他模块都可以依赖它们,但它们之间保持独立,不互相依赖。

3.3 渐进式拆分 vs 大爆炸重构

我们讨论过要不要停下手头的业务开发,专门花两三个月做一次彻底的「大爆炸」式重构。最终否决了这个方案,理由很现实:业务不可能停下来等我们,而且一次性改动太大,测试覆盖跟不上,上线风险太高。

最终选择的是渐进式拆分:每次只动一个模块,保持其他部分不变,拆完一个、验证通过、上线稳定后再拆下一个。整个过程持续了将近四个月,但每个阶段的改动量都可控,回滚也容易。

四、实战步骤:以 storage 模块为例

4.1 第一步:建立新模块骨架

以拆分存储引擎为例,我们先创建新的目录结构:

text 复制代码
modules/storage_core/
├── CMakeLists.txt
├── include/
│   └── storage_core/
│       ├── storage_engine.h      # 公开接口
│       ├── types.h               # 公开类型定义
│       └── error_codes.h         # 模块专属错误码
├── src/
│   ├── storage_engine_impl.cpp
│   ├── block_allocator.cpp
│   └── ...
└── tests/
    ├── CMakeLists.txt
    └── storage_engine_test.cpp

关键点:include 路径带上模块名前缀storage_core/storage_engine.h 而不是裸的 storage_engine.h),避免不同模块的头文件名冲突。

4.2 第二步:梳理公开接口

这是整个拆分过程中最磨人的一步。原代码里存储引擎的实现类和接口类混在一起,外部代码经常直接 include 内部头文件。我们的做法是:

  1. 先列出所有被外部引用的符号(用一个简单的脚本扫描 #include 和使用点)
  2. 逐个判断哪些应该是公开 API,哪些应该是内部实现
  3. 把公开 API 移到 include/storage_core/ 下,内部头文件留在 src/
  4. 对于不得不暴露但不希望外部直接使用的类,用 detail 命名空间包裹,并在文档里明确标注
cpp 复制代码
// include/storage_core/storage_engine.h ------ 公开接口
#pragma once
#include <string>
#include <optional>
#include "storage_core/types.h"
#include "storage_core/error_codes.h"

namespace storage_core {

class StorageEngine {
public:
    virtual ~StorageEngine() = default;
    virtual Status Put(const std::string& key, const std::string& value) = 0;
    virtual std::optional<std::string> Get(const std::string& key) = 0;
    virtual Status Delete(const std::string& key) = 0;
};

std::unique_ptr<StorageEngine> CreateStorageEngine(const StorageConfig& config);

}  // namespace storage_core

4.3 第三步:编写模块级 CMakeLists.txt

每个模块有独立的 CMakeLists.txt,明确声明自己的编译目标、公开头文件路径、依赖项以及对外可见性:

cmake 复制代码
# modules/storage_core/CMakeLists.txt
add_library(storage_core STATIC)
target_sources(storage_core PRIVATE
    src/storage_engine_impl.cpp
    src/block_allocator.cpp
)

target_include_directories(storage_core
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
    PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
)

target_link_libraries(storage_core
    PUBLIC base concurrency protocol
)

这里 PUBLIC 的头文件目录意味着依赖 storage_core 的模块也能看到这些头文件,PRIVATEsrc/ 目录只有本模块自己能访问。依赖关系通过 target_link_libraries 显式声明,CMake 会自动传递 PUBLIC 依赖。

4.4 第四步:迁移代码并修改引用

storage_engine_impl.cppblock_allocator.cpp 从原来的 src/storage/ 目录移到新模块的 src/ 下。然后批量修改其他模块里的 include 路径:

text 复制代码
// 修改前
#include "storage/storage_engine.h"

// 修改后
#include "storage_core/storage_engine.h"

这一步我们写了一个简单的 Python 脚本辅助,自动扫描所有 #include 语句并做替换。但脚本只是辅助,最终还是要靠编译错误来逐一修正------有些引用是脚本识别不出的(比如前向声明、模板特化等)。

4.5 第五步:编译验证与单元测试

迁移完成后,先在模块内部跑单元测试。因为 storage_core 模块只依赖 baseconcurrencyprotocol,这几个都是已经稳定的底层库,所以单元测试可以独立运行,不需要启动完整的服务集群。单测从原来的 30 秒跑完变成 3 秒跑完,这个体验提升非常大。

cmake 复制代码
# modules/storage_core/tests/CMakeLists.txt
add_executable(storage_core_test
    storage_engine_test.cpp
)

target_link_libraries(storage_core_test
    PRIVATE storage_core gtest_main
)

add_test(NAME storage_core_test COMMAND storage_core_test)

单元测试跑通后,再跑集成测试和回归测试,确认没有破坏外部调用方的行为。

五、踩坑记录与解决方案

5.1 坑一:模板类头文件拆分难

C++ 模板必须在头文件中实现(除非显式实例化),这意味着模板类很难像普通类那样把声明和实现分开。我们的项目中有一个比较核心的模板类 BufferPool<T>,头文件有 600 多行,被十几个模块引用。

解决方案:对于简单模板,保留在头文件中不做改动;对于复杂模板,尽量把与模板参数无关的逻辑抽出来放到非模板基类或工具函数中。比如把 BufferPool<T> 里与管理策略相关的代码抽成 BufferPoolBase,模板子类只保留与类型 T 相关的薄封装:

cpp 复制代码
// 非模板基类 ------ 可放在 .cpp 中实现
class BufferPoolBase {
protected:
    void* AllocateRaw(size_t size);
    void DeallocateRaw(void* ptr);
    // ...
};

// 模板子类 ------ 保留在头文件中,但很薄
template <typename T>
class BufferPool : private BufferPoolBase {
public:
    T* Allocate() {
        return static_cast<T*>(AllocateRaw(sizeof(T)));
    }
};

5.2 坑二:全局变量和单例的迁移

原项目里有一个全局的 ConfigManager 单例,被所有模块依赖。拆模块之后,每个模块都需要访问配置,但又不能反向依赖一个上层模块。解决方案是把配置接口下沉到 base 模块中,变成基础设施的一部分:

cpp 复制代码
// base/config_provider.h ------ 放到 base 模块
namespace base {

class ConfigProvider {
public:
    virtual std::optional<std::string> GetString(const std::string& key) const = 0;
    virtual std::optional<int> GetInt(const std::string& key) const = 0;
};

}  // namespace base

各模块只依赖这个抽象接口,具体实现由主程序在启动时注入。这样既保持了模块的独立性,又没有破坏原有的配置管理能力。

5.3 坑三:CMake 的依赖传递陷阱

CMake 的 target_link_librariesPUBLIC 关键字会传递依赖。比如 network_layer 依赖 metadata_mgr,而 metadata_mgr 以 PUBLIC 方式依赖 storage_core,那么 network_layer 在编译时也会自动链接 storage_core。这本是好事,但如果没注意,会让模块间的依赖关系变得隐式且不可控。

我们的规矩:每个模块的 CMakeLists.txt 必须显式列出自己直接依赖的所有模块 ,即使 CMake 会自动传递也不省略。对于不需要传递的依赖,用 PRIVATE 声明:

cmake 复制代码
target_link_libraries(metadata_mgr
    PUBLIC  storage_core      # 公开头文件里引用了 storage_core 的类型
    PRIVATE concurrency       # 只在 .cpp 里使用,不对外暴露
)

5.4 坑四:ABI 兼容性问题

拆成多个静态库或动态库后,如果不同模块用不同编译选项编译(比如一个开了 RTTI 一个没开),链接阶段会出诡异的问题。我们统一了全局编译选项,放在顶层的 CMake 工具链文件中,所有模块共用:

cmake 复制代码
# cmake/compiler_options.cmake
add_compile_options(
    -Wall -Wextra -Wpedantic
    -std=c++17
    -O2
    -fno-rtti          # 统一关闭 RTTI
    -fno-exceptions    # 统一不使用异常(我们项目的约定)
)

六、拆分后的效果对比

指标 拆分前 拆分后
全量编译时间 35 分钟 22 分钟(并行模块编译)
增量编译(改核心头文件) 10~15 分钟 2~5 分钟
模块单元测试运行时间 无法独立运行 3~10 秒/模块
头文件循环依赖 存在 彻底消除
新人上手时间 约 2 周 约 3 天(按模块逐步深入)
并行开发冲突频率 每两天一次 几乎消失

七、经验总结

回顾整个拆分过程,有几点体会特别深:

  1. 不要追求一步到位。渐进式拆分虽然慢,但每一步都可控、可回滚、可验证。我们每次只动一个模块,改完就上线,四个多月下来没有出过一次因为拆分导致的生产事故。
  2. 接口设计是核心 。模块拆得好不好,关键看接口。公开头文件应该尽量窄、尽量稳定,把实现细节藏在 src/detail 命名空间里。一旦公开接口定了,模块内部的任何改动都不会影响外部。
  3. 工具辅助不可或缺 。include 依赖分析用了 include-what-you-use,循环依赖检测用了自定义脚本,编译加速用了 ccache 和分布式编译。没有这些工具,纯靠人肉排查十几万行代码的依赖关系几乎不可能。
  4. 测试是安全网。每拆完一个模块,必须跑通所有相关测试才能合并。我们还在拆分过程中补了不少单元测试,这些测试后来也成了项目质量的重要保障。
  5. 团队共识比技术方案更重要。拆分开始前我们专门开了两次会,把为什么要拆、怎么拆、遇到问题怎么处理都讲清楚。过程中每个 MR 都要至少两个 reviewer,确保所有人都理解并遵守新的模块规范。

八、后续计划

模块化拆分只是一个开始,后面还有很多事情要做:

  • 把最稳定的模块逐步从静态库改成动态库,进一步减少链接时间和二进制体积。
  • 引入 Conan 或 vcpkg 管理第三方依赖,把 third_party 目录里的「祖传代码」规范化。
  • 探索 C++20 Modules,看能不能从根本上解决头文件膨胀和编译速度问题。

如果这篇文章对你有帮助,或者你在做类似的事情时遇到了不同的问题,欢迎留言讨论。

相关推荐
z落落1 小时前
C# WinForm 线程池与事件等待+进度条暂停恢复实战案例
开发语言·c#
石山代码1 小时前
C++23 新特性在 CLion 中的实战体验
开发语言·c++·c++23
浮江雾1 小时前
Flutter第四节------核心概念学习笔记:从基础语法到异步编程
linux·服务器·开发语言·笔记·flutter·入门·基础
在线OJ的阿川1 小时前
仿muduo库实现高并发服务器
linux·服务器·c++
北冥you鱼1 小时前
Go Modules 使用指南:从入门到精通
开发语言·后端·golang
l1t1 小时前
duckdb 1.6dev新增的.manual命令
开发语言·数据库
你怎么知道我是队长2 小时前
JavaScript的函数介绍
开发语言·前端·javascript
坚定学代码2 小时前
C++ SOLID 原则(上):单一职责、开闭原则与里氏替换
c++
YMWM_2 小时前
python-venv虚拟环境
linux·开发语言·python