30、综合实战:从零构建一个中型C++项目

终于到了最后一章。说实话,前面讲了那么多零散的知识点,你可能已经有点晕了。别担心,这一章我们把所有东西串起来——从零开始,完整搭建一个真正能用的中型C++项目。

这个项目包含:一个核心库、一个可执行文件、单元测试、安装脚本、打包配置,还有CI自动化。说白了,就是你在公司里会遇到的真实项目结构。

本章目标: 用CMake完整构建一个可交付的C++项目,覆盖开发到部署全流程。

项目结构设计

先看看我们要搭什么。我个人习惯把项目分成几个清晰的层级:

my_project/
├── CMakeLists.txt              # 顶层构建文件
├── cmake/
│   └── MyProjectConfig.cmake.in # 安装后的CMake配置模板
├── src/
│   ├── CMakeLists.txt
│   ├── main.cpp                # 可执行文件入口
│   └── app_version.h.in        # 版本号模板
├── lib/
│   ├── CMakeLists.txt
│   ├── include/
│   │   └── mylib/
│   │       ├── core.h
│   │       └── utils.h
│   └── src/
│       ├── core.cpp
│       └── utils.cpp
├── tests/
│   ├── CMakeLists.txt
│   ├── test_core.cpp
│   └── test_utils.cpp
├── packaging/
│   └── CPackConfig.cmake       # 打包配置
└── .github/
    └── workflows/
        └── ci.yml              # GitHub Actions配置

嗯,这个结构你可能觉得眼熟。我在公司里见过不少项目都长这样——库和可执行文件分开,测试独立,打包和CI各归其位。

顶层CMakeLists.txt

顶层文件是整个项目的入口。它负责定义项目、设置全局选项、然后添加子目录。

cmake_minimum_required(VERSION 3.16)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)

# C++标准
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# 构建类型
if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release CACHE STRING "构建类型" FORCE)
endif()

# 选项
option(BUILD_TESTS "构建测试" ON)
option(BUILD_SHARED_LIBS "构建动态库" OFF)

# 添加子目录
add_subdirectory(lib)
add_subdirectory(src)

if(BUILD_TESTS)
    enable_testing()
    add_subdirectory(tests)
endif()

# 安装规则
include(GNUInstallDirs)
install(
    TARGETS mylib myapp
    EXPORT MyProjectTargets
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
)

install(
    EXPORT MyProjectTargets
    FILE MyProjectTargets.cmake
    NAMESPACE MyProject::
    DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/MyProject
)

# 打包配置
set(CPACK_PROJECT_NAME ${PROJECT_NAME})
set(CPACK_PROJECT_VERSION ${PROJECT_VERSION})
include(cmake/CPackConfig.cmake)

这里有个小细节:CMAKE_CXX_EXTENSIONS OFF。我遇到过好几次因为编译器扩展导致跨平台编译失败的情况,所以现在习惯把它关掉。

库模块:lib/CMakeLists.txt

库是项目的核心。我们把它做成一个独立的模块,方便复用。

# 头文件目录
set(MYLIB_PUBLIC_HEADERS
    ${CMAKE_CURRENT_SOURCE_DIR}/include/mylib/core.h
    ${CMAKE_CURRENT_SOURCE_DIR}/include/mylib/utils.h
)

# 源文件
set(MYLIB_SOURCES
    src/core.cpp
    src/utils.cpp
)

# 创建库
add_library(mylib
    ${MYLIB_SOURCES}
    ${MYLIB_PUBLIC_HEADERS}
)

# 包含路径
target_include_directories(mylib
    PUBLIC
        $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
        $<INSTALL_INTERFACE:${CMAKE_INSTALL_INCLUDEDIR}>
)

# 安装头文件
install(
    DIRECTORY include/
    DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
    FILES_MATCHING PATTERN "*.h"
)

注意那个$<BUILD_INTERFACE>$<INSTALL_INTERFACE>的写法。这是CMake的生成器表达式,说白了就是让头文件路径在构建时和安装后自动切换。我第一次用的时候也觉得麻烦,但后来发现这其实是保证项目可移植的关键。

可执行文件:src/CMakeLists.txt

可执行文件依赖库,同时生成版本信息。

# 配置版本头文件
configure_file(
    ${CMAKE_CURRENT_SOURCE_DIR}/app_version.h.in
    ${CMAKE_CURRENT_BINARY_DIR}/app_version.h
)

# 创建可执行文件
add_executable(myapp
    main.cpp
    ${CMAKE_CURRENT_BINARY_DIR}/app_version.h
)

# 链接库
target_link_libraries(myapp PRIVATE mylib)

# 包含路径
target_include_directories(myapp PRIVATE
    ${CMAKE_CURRENT_BINARY_DIR}
)

版本头文件模板长这样:

#ifndef APP_VERSION_H
#define APP_VERSION_H

#define APP_VERSION_MAJOR @PROJECT_VERSION_MAJOR@
#define APP_VERSION_MINOR @PROJECT_VERSION_MINOR@
#define APP_VERSION_PATCH @PROJECT_VERSION_PATCH@
#define APP_VERSION_STRING "@PROJECT_VERSION@"

#endif

为什么用configure_file?因为这样版本号只需要在顶层改一次,所有地方自动同步。我曾经见过有人手动维护三个地方的版本号,结果经常对不上——嗯,那场面挺尴尬的。

测试模块:tests/CMakeLists.txt

测试我们用Google Test。当然你也可以用Catch2或者别的,原理一样。

# 查找或下载Google Test
include(FetchContent)
FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG release-1.12.1
)
FetchContent_MakeAvailable(googletest)

# 测试用例
add_executable(test_core test_core.cpp)
target_link_libraries(test_core PRIVATE mylib gtest_main)

add_executable(test_utils test_utils.cpp)
target_link_libraries(test_utils PRIVATE mylib gtest_main)

# 注册测试
add_test(NAME TestCore COMMAND test_core)
add_test(NAME TestUtils COMMAND test_utils)

这里用FetchContent自动下载Google Test,省去了手动安装的麻烦。不过要注意:如果你的项目需要离线构建,最好把依赖提前下载好放到本地。

打包配置:packaging/CPackConfig.cmake

打包我们用CPack,它支持生成多种格式的安装包。

set(CPACK_GENERATOR "TGZ;DEB;RPM")
set(CPACK_PACKAGE_NAME "myproject")
set(CPACK_PACKAGE_VERSION "${PROJECT_VERSION}")
set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "一个示例C++项目")
set(CPACK_PACKAGE_VENDOR "MyCompany")
set(CPACK_PACKAGE_CONTACT "developer@mycompany.com")

# DEB包特定配置
set(CPACK_DEBIAN_PACKAGE_MAINTAINER "developer@mycompany.com")
set(CPACK_DEBIAN_PACKAGE_SECTION "devel")

# RPM包特定配置
set(CPACK_RPM_PACKAGE_GROUP "Development/Libraries")
set(CPACK_RPM_PACKAGE_LICENSE "MIT")

打包命令很简单:

cmake --build . --target package

然后你就能在构建目录下看到.tar.gz.deb.rpm文件了。我经常用这个给客户发SDK,省心不少。

CI配置:.github/workflows/ci.yml

最后是CI。我们用GitHub Actions,每次推送代码自动构建、测试、打包。

name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: 安装依赖
      run: sudo apt-get update && sudo apt-get install -y cmake g++

    - name: 配置
      run: cmake -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTS=ON

    - name: 构建
      run: cmake --build build --parallel

    - name: 测试
      run: cd build && ctest --output-on-failure

    - name: 打包
      run: cmake --build build --target package

    - name: 上传构建产物
      uses: actions/upload-artifact@v3
      with:
        name: packages
        path: build/*.tar.gz

这个配置我用了很久,基本没出过问题。唯一要注意的是:如果项目依赖第三方库,记得在安装依赖那一步提前装好。

核心流程一览

下面这张图展示了整个项目的构建流程:

CMake项目构建流程 源代码 CMake配置 (cmake -B build) 构建 (cmake --build build) 测试 (ctest) 安装 (cmake --install) 打包 (cpack) CI自动化 (GitHub Actions)

从源码到最终交付,CMake把整个流程串了起来。你只需要写一次配置,剩下的交给工具链自动完成。

避坑指南

最后分享几个我踩过的坑:

  • 路径问题: 我曾经在Windows上写死路径,结果换到Linux全崩了。后来统一用${CMAKE_CURRENT_SOURCE_DIR}这类变量,再也没出过问题。
  • 测试依赖: 如果测试依赖外部库,记得在CI里提前安装。我遇到过测试编译通过但运行时找不到动态库的情况——嗯,那排查起来挺费劲的。
  • 版本号同步:configure_file自动生成版本头文件,别手动维护。血的教训。
  • 打包格式: 不同平台用不同格式。Linux用DEB/RPM,macOS用DMG,Windows用NSIS。CPack都支持,但需要单独配置。

小技巧: 在项目根目录放一个build.sh脚本,把配置、构建、测试、打包一条命令搞定。团队里其他人用起来会感谢你的。

注意: 如果项目要开源,记得在CI里加上cmake --build build --target package_source,生成源码包。很多用户喜欢自己编译。

好了,这一章的内容就到这里。从零到一,从代码到交付,你应该已经掌握了用CMake管理中型C++项目的完整流程。剩下的就是动手实践了——相信我,写一遍比看十遍管用。


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