模块化设计:实战案例——将一个大型单体项目拆分为多个可复用的库目标

说实话,我见过太多项目一开始就是个“大泥球”。所有代码堆在一个目录里,编译一次要喝杯咖啡。更可怕的是,你想复用其中某个功能模块?对不起,得把整个项目都拽过来。

这一章,我们就拿一个真实案例开刀。把一个大型的单体项目,拆成多个可复用的库目标。我会带着你一步步设计依赖关系,让CMake帮你理清这团乱麻。

1. 先看看这个“大泥球”长什么样

假设我们有一个图像处理引擎,叫 ImageEngine。它原本的结构是这样的:

ImageEngine/
├── src/
│   ├── main.cpp
│   ├── core.cpp          // 核心算法
│   ├── io.cpp            // 文件读写
│   ├── filter.cpp        // 滤镜效果
│   ├── ui.cpp            // 界面相关
│   └── utils.cpp         // 工具函数
├── include/
│   └── ImageEngine.h     // 一个巨大的头文件
└── CMakeLists.txt        // 一个巨大的CMake文件

所有源文件都直接编译成一个可执行文件。核心算法、文件IO、UI逻辑全部耦合在一起。你想单独测试滤镜模块?不行,得链接整个项目。你想把IO模块拿出来给另一个项目用?更不行,它依赖了核心算法和工具函数,但依赖关系根本没写清楚。

嗯,这就是典型的“单体架构”在C++项目里的表现。我在早期职业生涯里接手过好几个这样的项目,每次改代码都像在拆炸弹。

2. 拆分的核心原则:高内聚,低耦合

拆分不是乱拆。我习惯先画一张依赖图,搞清楚谁依赖谁。说白了,就是先理清逻辑边界。

对于这个图像引擎,我把它拆成以下几个库目标:

  • ImageCore:核心算法库,不依赖任何其他模块
  • ImageIO:文件读写库,依赖ImageCore
  • ImageFilter:滤镜效果库,依赖ImageCore
  • ImageUI:界面库,依赖ImageCore和ImageFilter
  • ImageApp:最终的可执行文件,链接所有库

你想想看,这样一拆,每个库的职责就非常清晰了。ImageCore是底层,谁都不依赖。ImageIO和ImageFilter是中间层,只依赖Core。ImageUI是上层,依赖Core和Filter。依赖关系是单向的,没有循环依赖。

核心原则:依赖关系应该是单向的、无环的。如果出现循环依赖,说明你的模块划分有问题。

3. 用SVG画一张依赖关系图

光说不够直观,我画了一张图。这张图展示了拆分后的库目标以及它们之间的依赖关系。

ImageEngine 模块依赖关系图 ImageCore 核心算法库(无依赖) ImageIO 文件读写库 ImageFilter 滤镜效果库 ImageUI 界面库 ImageApp 可执行文件 依赖 依赖 依赖 依赖

看到没?ImageCore在最底层,谁都不依赖。ImageApp在最顶层,依赖所有库。依赖关系清晰,没有循环。这就是我们想要的目标。

4. 动手写CMakeLists.txt

好,图有了,接下来就是写CMake代码。我习惯每个库一个独立的目录,每个目录里放自己的CMakeLists.txt。这样每个库都是独立的构建单元。

先看最底层的 ImageCore

# core/CMakeLists.txt
add_library(ImageCore
    src/core.cpp
    src/utils.cpp
)

target_include_directories(ImageCore
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
)

# 注意:ImageCore 不依赖任何其他库
# 它的 PUBLIC 头文件会被所有依赖它的库继承

再看 ImageFilter,它依赖 ImageCore

# filter/CMakeLists.txt
add_library(ImageFilter
    src/filter.cpp
)

target_include_directories(ImageFilter
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
)

target_link_libraries(ImageFilter
    PUBLIC
        ImageCore
)

这里有个关键点:我用的是 PUBLIC 而不是 PRIVATE。为什么?因为 ImageFilter 的头文件里可能包含了 ImageCore 的头文件。如果我用 PRIVATE,那么任何链接 ImageFilter 的库(比如 ImageUI)就看不到 ImageCore 的头文件了。这会导致编译错误。

小技巧:判断用 PUBLIC 还是 PRIVATE,就看你的头文件里是否包含了依赖库的头文件。如果包含了,就用 PUBLIC;如果只在 .cpp 文件里使用,就用 PRIVATE。

接着看 ImageUI,它依赖 ImageCoreImageFilter

# ui/CMakeLists.txt
add_library(ImageUI
    src/ui.cpp
)

target_include_directories(ImageUI
    PUBLIC
        ${CMAKE_CURRENT_SOURCE_DIR}/include
)

target_link_libraries(ImageUI
    PUBLIC
        ImageCore
    PRIVATE
        ImageFilter
)

注意这里:ImageCore 是 PUBLIC,ImageFilter 是 PRIVATE。为什么?因为 ImageUI 的头文件里只包含了 ImageCore 的头文件,没有包含 ImageFilter 的。所以 ImageFilter 只需要在编译 ImageUI 的 .cpp 文件时可用就行,不需要传递给依赖 ImageUI 的库。

最后是顶层的 ImageApp

# app/CMakeLists.txt
add_executable(ImageApp
    src/main.cpp
)

target_link_libraries(ImageApp
    PRIVATE
        ImageCore
        ImageIO
        ImageFilter
        ImageUI
)

可执行文件用 PRIVATE 就够了,因为不会有其他库来链接它。

5. 根目录的CMakeLists.txt

每个子目录都写好了,根目录的CMakeLists.txt就很简单了:

# 根目录 CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(ImageEngine VERSION 1.0.0 LANGUAGES CXX)

# 添加子目录
add_subdirectory(core)
add_subdirectory(io)
add_subdirectory(filter)
add_subdirectory(ui)
add_subdirectory(app)

就这么几行。每个子目录独立构建,互不干扰。你甚至可以单独编译某个库:

mkdir build && cd build
cmake .. -DBUILD_SHARED_LIBS=ON
make ImageFilter   # 只编译 ImageFilter 库

6. 避坑指南:我曾经踩过的坑

我曾经在一个项目里,把依赖关系搞成了循环依赖。A依赖B,B依赖C,C又依赖A。结果链接的时候报了一堆未定义符号的错误。我花了两天时间才找到问题根源。

后来我学乖了,每次拆分模块之前,先画依赖图。如果发现循环依赖,就重新思考模块划分。实在不行,就引入一个中间层或者用依赖注入的方式解决。

警告:循环依赖是模块化设计的大忌。它不仅会导致链接错误,还会让代码难以理解和维护。如果你发现两个模块互相依赖,说明它们应该合并成一个模块,或者你需要提取出一个更底层的公共模块。

另一个我常犯的错误是:把所有头文件都设为 PUBLIC。这样虽然编译不会报错,但会导致“接口膨胀”。依赖你库的人会看到一堆他们不需要的头文件,增加了编译时间,也增加了理解成本。

我的建议是:最小化 PUBLIC 接口。只把那些真正需要暴露给外部使用的头文件设为 PUBLIC,其他的都用 PRIVATE 或者干脆不暴露。

7. 总结一下

模块化设计说白了就是两件事:划清边界理清依赖。划清边界靠的是对业务逻辑的理解,理清依赖靠的是CMake的 target_link_librariesPUBLIC/PRIVATE/INTERFACE 关键字。

我个人习惯在项目初期就做好模块划分。哪怕一开始只有几个文件,也按模块分目录、写CMakeLists.txt。这样后期扩展起来非常轻松。你想想看,如果一开始就偷懒,把所有代码堆在一起,等到项目膨胀到几十万行代码的时候再想拆分,那可就难了。

好,这一章就到这里。记住:好的模块化设计,能让你的项目活得更久。


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