30、大型项目实战:多模块项目结构设计、库分层与接口隔离、版本控制与发布流程

终于到了这一章。说实话,前面二十多章讲的全是「零件」,这一章我们要把它们组装成一台能跑起来的机器。

很多朋友学完 CMake 语法后,一上手真实项目就懵了:头文件怎么放?库怎么分?版本号怎么管理?发布包怎么打?

嗯,这些坑我都踩过。今天我们就从零开始,搭一个真正能用的现代 CMake 项目。

一、多模块项目结构设计

先说说我个人的习惯。一个大型 C++ 项目,最忌讳的就是「平铺式」目录——所有源文件堆在一个文件夹里,找文件全靠搜索。

我建议按「功能域」来划分模块。举个例子,假设我们要做一个图像处理引擎,结构可以这样:

project_root/
├── cmake/                  # 自定义 CMake 模块
│   └── CompilerWarnings.cmake
├── modules/
│   ├── core/               # 核心数据结构
│   │   ├── include/core/
│   │   ├── src/
│   │   └── CMakeLists.txt
│   ├── io/                 # 输入输出
│   │   ├── include/io/
│   │   ├── src/
│   │   └── CMakeLists.txt
│   ├── filters/            # 图像滤镜
│   │   ├── include/filters/
│   │   ├── src/
│   │   └── CMakeLists.txt
│   └── ui/                 # 界面层
│       ├── include/ui/
│       ├── src/
│       └── CMakeLists.txt
├── apps/                   # 可执行程序
│   ├── cli/
│   └── gui/
├── tests/                  # 单元测试
├── docs/                   # 文档
├── CMakeLists.txt          # 顶层
└── .gitignore

每个模块都是一个独立的库。为什么这么分?

  • 编译速度快:改一个模块,只重新编译它和依赖它的模块
  • 职责清晰:core 不依赖 io,io 不依赖 ui,谁也别乱引用谁
  • 便于复用:以后做新项目,直接把 core 和 io 拷过去就能用
小技巧:每个模块的 include 目录下,再建一层以模块名命名的子目录。比如 include/core/Image.h 而不是 include/Image.h。这样引用时写 #include "core/Image.h",一看就知道来自哪个模块。

二、库分层与接口隔离

分层这件事,说白了就是「谁可以依赖谁」。我见过最乱的项目,底层工具库居然依赖了 UI 层——那画面太美我不敢看。

我一般遵循三条铁律:

  1. 依赖方向必须单向:core ← io ← filters ← ui,不能反向
  2. 接口与实现分离:对外暴露的头文件只放纯虚类或 Pimpl 指针
  3. 内部头文件藏起来:用 PRIVATEINTERFACE 控制可见性

来看一个具体的 CMake 写法。假设 core 模块定义了一个 Image 接口:

# modules/core/CMakeLists.txt
add_library(core
    src/Image.cpp
    src/ImageImpl.h   # 内部头文件,不对外暴露
)
target_include_directories(core
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
    PRIVATE
        ${CMAKE_CURRENT_SOURCE_DIR}/src
)

注意看 PRIVATE 里的 src 目录。ImageImpl.h 放在 src 下,外部根本看不到它。这就是接口隔离——调用方只知道 Image.h 里有什么函数,不知道里面用了什么黑科技。

核心原则:PUBLIC 的头文件,就是你的「契约」。改 PUBLIC 头文件等于改接口,必须当心。PRIVATE 的头文件随便改,不影响外部。

我曾经在一个项目中,把所有头文件都设成了 PUBLIC。结果底层改了一个内部结构体,上层全部重新编译,整整花了四十分钟。从那以后,我再也不敢偷懒了。

三、版本控制与发布流程

版本号怎么管理?我见过有人手动改 CMakeLists.txt 里的 project() 版本号,每次发布都提心吊胆,怕改漏了。

我的做法是用一个单独的 Version.cmake 文件:

# cmake/Version.cmake
set(PROJECT_VERSION_MAJOR 2)
set(PROJECT_VERSION_MINOR 1)
set(PROJECT_VERSION_PATCH 0)
set(PROJECT_VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}")

然后在顶层 CMakeLists.txt 里引用它:

include(cmake/Version.cmake)
project(ImageEngine VERSION ${PROJECT_VERSION})

这样改版本号只需要改一个文件。配合 Git 标签,发布流程就清晰了:

步骤 操作 说明
1 更新 Version.cmake 比如从 2.0.0 改成 2.1.0
2 提交并打 Git 标签 git tag v2.1.0
3 运行打包脚本 生成 .tar.gz 或 .deb 等
4 发布到内部仓库 供其他团队使用
注意:版本号一旦发布,就不能再修改。如果发现 bug,请发布补丁版本(比如 2.1.1),而不是回退修改。这是语义化版本控制的基本要求。

四、从零搭建一个现代 CMake 项目

好了,理论说完了,我们动手搭一个。假设项目名叫 MiniEngine,包含两个模块:core 和 utils,外加一个 main 可执行文件。

第一步,创建顶层 CMakeLists.txt:

cmake_minimum_required(VERSION 3.20)
project(MiniEngine VERSION 1.0.0 LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# 让 CMake 知道去哪里找模块
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")

# 添加子目录
add_subdirectory(modules/core)
add_subdirectory(modules/utils)
add_subdirectory(apps/main)

第二步,写 core 模块:

# modules/core/CMakeLists.txt
add_library(core
    src/Engine.cpp
)
target_include_directories(core
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)
# 设置导出目标,方便 install
install(TARGETS core
    EXPORT MiniEngineTargets
    ARCHIVE DESTINATION lib
    LIBRARY DESTINATION lib
    RUNTIME DESTINATION bin
)
install(DIRECTORY include/ DESTINATION include)

第三步,写 utils 模块,它依赖 core:

# modules/utils/CMakeLists.txt
add_library(utils
    src/Helper.cpp
)
target_include_directories(utils
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:include>
)
target_link_libraries(utils PUBLIC core)  # 依赖 core

第四步,写 main 程序:

# apps/main/CMakeLists.txt
add_executable(mini_app main.cpp)
target_link_libraries(mini_app PRIVATE utils core)

最后,在顶层 CMakeLists.txt 末尾加上导出配置:

install(EXPORT MiniEngineTargets
    DESTINATION lib/cmake/MiniEngine
    NAMESPACE MiniEngine::
)

这样别人用 find_package(MiniEngine) 就能找到你的库了。

整个流程的核心逻辑

MiniEngine 项目结构 CMakeLists.txt (顶层) modules/core modules/utils apps/main libcore.a / .so libutils.a / .so mini_app (可执行) 依赖 依赖 PUBLIC 接口:include/ 目录下的头文件

嗯,到这里,一个完整的现代 CMake 项目就搭好了。你想想看,从目录结构到接口隔离,从版本控制到发布配置,每一步都有章可循。

我在实际工作中,用这套模板已经搭了不下十个项目。每次新项目启动,直接复制这个骨架,改改名字就能开工。省下来的时间,多喝几杯咖啡不香吗?

最后一个小建议:把上面这个项目结构提交到 Git 仓库,作为你的「项目模板」。以后开新项目,直接 git clone 这个模板,比从零开始快十倍。

公众号:蓝海资料掘金营,微信deep3321