一、当单体项目成为瓶颈
接手这个 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 内部头文件。我们的做法是:
- 先列出所有被外部引用的符号(用一个简单的脚本扫描
#include和使用点) - 逐个判断哪些应该是公开 API,哪些应该是内部实现
- 把公开 API 移到
include/storage_core/下,内部头文件留在src/中 - 对于不得不暴露但不希望外部直接使用的类,用
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 的模块也能看到这些头文件,PRIVATE 的 src/ 目录只有本模块自己能访问。依赖关系通过 target_link_libraries 显式声明,CMake 会自动传递 PUBLIC 依赖。
4.4 第四步:迁移代码并修改引用
把 storage_engine_impl.cpp 和 block_allocator.cpp 从原来的 src/storage/ 目录移到新模块的 src/ 下。然后批量修改其他模块里的 include 路径:
text
// 修改前
#include "storage/storage_engine.h"
// 修改后
#include "storage_core/storage_engine.h"
这一步我们写了一个简单的 Python 脚本辅助,自动扫描所有 #include 语句并做替换。但脚本只是辅助,最终还是要靠编译错误来逐一修正------有些引用是脚本识别不出的(比如前向声明、模板特化等)。
4.5 第五步:编译验证与单元测试
迁移完成后,先在模块内部跑单元测试。因为 storage_core 模块只依赖 base、concurrency 和 protocol,这几个都是已经稳定的底层库,所以单元测试可以独立运行,不需要启动完整的服务集群。单测从原来的 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_libraries 用 PUBLIC 关键字会传递依赖。比如 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 天(按模块逐步深入) |
| 并行开发冲突频率 | 每两天一次 | 几乎消失 |
七、经验总结
回顾整个拆分过程,有几点体会特别深:
- 不要追求一步到位。渐进式拆分虽然慢,但每一步都可控、可回滚、可验证。我们每次只动一个模块,改完就上线,四个多月下来没有出过一次因为拆分导致的生产事故。
- 接口设计是核心 。模块拆得好不好,关键看接口。公开头文件应该尽量窄、尽量稳定,把实现细节藏在
src/和detail命名空间里。一旦公开接口定了,模块内部的任何改动都不会影响外部。 - 工具辅助不可或缺 。include 依赖分析用了
include-what-you-use,循环依赖检测用了自定义脚本,编译加速用了ccache和分布式编译。没有这些工具,纯靠人肉排查十几万行代码的依赖关系几乎不可能。 - 测试是安全网。每拆完一个模块,必须跑通所有相关测试才能合并。我们还在拆分过程中补了不少单元测试,这些测试后来也成了项目质量的重要保障。
- 团队共识比技术方案更重要。拆分开始前我们专门开了两次会,把为什么要拆、怎么拆、遇到问题怎么处理都讲清楚。过程中每个 MR 都要至少两个 reviewer,确保所有人都理解并遵守新的模块规范。
八、后续计划
模块化拆分只是一个开始,后面还有很多事情要做:
- 把最稳定的模块逐步从静态库改成动态库,进一步减少链接时间和二进制体积。
- 引入 Conan 或 vcpkg 管理第三方依赖,把
third_party目录里的「祖传代码」规范化。 - 探索 C++20 Modules,看能不能从根本上解决头文件膨胀和编译速度问题。
如果这篇文章对你有帮助,或者你在做类似的事情时遇到了不同的问题,欢迎留言讨论。