
一、当单体项目成为瓶颈接手这个 C 项目的时候代码仓库已经积累了五年多。十几万行代码塞在同一个大目录里头文件互相 include 得像蜘蛛网改一个公共函数要重新编译半个小时新人入职光是理清依赖关系就要花两周。更让人头疼的是每次发布都得全量构建CI 流水线跑一次四十分钟起步。这些问题不是突然出现的而是项目从小到大自然生长过程中慢慢堆积的。当团队从三五个人扩张到二十多人当功能模块从三四个膨胀到十几个原来的单体结构就成了最大的技术债。这篇文章记录了我们把一个大型 C 单体项目拆分成多个独立模块的全过程包括踩过的坑和总结出的经验。二、现状诊断先看清问题再动刀2.1 项目概况先交代一下背景。我们的项目是一个分布式存储系统核心语言 C17构建工具 CMake代码量大约 18 万行不含第三方库部署在 Linux 服务器上。拆分前整个工程结构大致是这样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 确定模块边界拆分的第一步不是写代码而是画图。我们用了一周时间把项目按功能域划分成以下几个核心模块并在白板上画出模块间的依赖关系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 第一步建立新模块骨架以拆分存储引擎为例我们先创建新的目录结构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命名空间包裹并在文档里明确标注// 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::optionalstd::string Get(const std::string key) 0; virtual Status Delete(const std::string key) 0; }; std::unique_ptrStorageEngine CreateStorageEngine(const StorageConfig config); } // namespace storage_core4.3 第三步编写模块级 CMakeLists.txt每个模块有独立的 CMakeLists.txt明确声明自己的编译目标、公开头文件路径、依赖项以及对外可见性# 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 路径// 修改前 #include storage/storage_engine.h // 修改后 #include storage_core/storage_engine.h这一步我们写了一个简单的 Python 脚本辅助自动扫描所有#include语句并做替换。但脚本只是辅助最终还是要靠编译错误来逐一修正——有些引用是脚本识别不出的比如前向声明、模板特化等。4.5 第五步编译验证与单元测试迁移完成后先在模块内部跑单元测试。因为storage_core模块只依赖base、concurrency和protocol这几个都是已经稳定的底层库所以单元测试可以独立运行不需要启动完整的服务集群。单测从原来的 30 秒跑完变成 3 秒跑完这个体验提升非常大。# 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 模板必须在头文件中实现除非显式实例化这意味着模板类很难像普通类那样把声明和实现分开。我们的项目中有一个比较核心的模板类BufferPoolT头文件有 600 多行被十几个模块引用。解决方案对于简单模板保留在头文件中不做改动对于复杂模板尽量把与模板参数无关的逻辑抽出来放到非模板基类或工具函数中。比如把BufferPoolT里与管理策略相关的代码抽成BufferPoolBase模板子类只保留与类型 T 相关的薄封装// 非模板基类 —— 可放在 .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_castT*(AllocateRaw(sizeof(T))); } };5.2 坑二全局变量和单例的迁移原项目里有一个全局的ConfigManager单例被所有模块依赖。拆模块之后每个模块都需要访问配置但又不能反向依赖一个上层模块。解决方案是把配置接口下沉到base模块中变成基础设施的一部分// base/config_provider.h —— 放到 base 模块 namespace base { class ConfigProvider { public: virtual std::optionalstd::string GetString(const std::string key) const 0; virtual std::optionalint 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声明target_link_libraries(metadata_mgr PUBLIC storage_core # 公开头文件里引用了 storage_core 的类型 PRIVATE concurrency # 只在 .cpp 里使用不对外暴露 )5.4 坑四ABI 兼容性问题拆成多个静态库或动态库后如果不同模块用不同编译选项编译比如一个开了 RTTI 一个没开链接阶段会出诡异的问题。我们统一了全局编译选项放在顶层的 CMake 工具链文件中所有模块共用# cmake/compiler_options.cmake add_compile_options( -Wall -Wextra -Wpedantic -stdc17 -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目录里的「祖传代码」规范化。探索 C20 Modules看能不能从根本上解决头文件膨胀和编译速度问题。如果这篇文章对你有帮助或者你在做类似的事情时遇到了不同的问题欢迎留言讨论。