30、综合实战:从零搭建一个现代C++项目框架

终于到了最后一章。说实话,我写这个系列的时候,脑子里一直在想——怎么让大家真正用起来?

前面29章,我们聊了目标导向、聊了依赖管理、聊了测试和安装。但这些东西如果分开看,总觉得差点意思。就像你有了螺丝刀、扳手、电钻,但没给你一张完整的组装图。

这一章,我们就来画这张图。

我会带着你从零开始,搭建一个真正能用的现代C++项目框架。它集成了Google Test做单元测试、spdlog做日志、CLI11做命令行解析。更重要的是——它支持安装和导出,别人用find_package就能找到你的库。

本章目标: 一个命令 cmake --build . --target install 之后,你的项目就能被其他CMake项目无缝引用。

30.1 项目结构设计

我个人习惯,项目根目录下只放最顶层的东西。别把源码和构建文件混在一起。

my_project/
├── CMakeLists.txt          # 顶层构建文件
├── cmake/
│   ├── MyProjectConfig.cmake.in   # 导出模板
│   └── CompilerWarnings.cmake     # 编译器警告配置
├── src/
│   ├── CMakeLists.txt
│   ├── core/
│   │   ├── CMakeLists.txt
│   │   ├── calculator.h
│   │   └── calculator.cpp
│   └── app/
│       ├── CMakeLists.txt
│       └── main.cpp
├── tests/
│   ├── CMakeLists.txt
│   └── test_calculator.cpp
└── extern/                 # 第三方依赖(FetchContent 或 git submodule)
    └── CMakeLists.txt

嗯,这里要注意:cmake/ 目录放的是CMake模块文件,不是构建产物。很多新手会把 CMakePresets.json 也放这里,我个人觉得放根目录更直观。

30.2 顶层 CMakeLists.txt

顶层文件负责三件事:项目声明、全局选项、引入子目录。

cmake_minimum_required(VERSION 3.20)
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)

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

# 选项
option(BUILD_TESTING "Build tests" ON)
option(BUILD_APP "Build application" ON)

# 编译器警告
include(cmake/CompilerWarnings.cmake)

# 子目录
add_subdirectory(extern)
add_subdirectory(src)

if(BUILD_TESTING)
  enable_testing()
  add_subdirectory(tests)
endif()

你可能会问:为什么把 extern 放在最前面?因为 src 里的目标可能依赖外部库,CMake要求依赖先定义。

小技巧: 我习惯把 CMAKE_CXX_EXTENSIONS 设为 OFF。这样能避免编译器特有的扩展混进来,跨平台时少踩坑。

30.3 集成第三方库

我们用 FetchContent 来拉取依赖。为什么不用 find_package?因为我想让项目开箱即用,用户不用提前安装任何东西。

# extern/CMakeLists.txt
include(FetchContent)

# spdlog
FetchContent_Declare(
  spdlog
  GIT_REPOSITORY https://github.com/gabime/spdlog.git
  GIT_TAG v1.12.0
)
set(SPDLOG_BUILD_TESTING OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(spdlog)

# CLI11
FetchContent_Declare(
  CLI11
  GIT_REPOSITORY https://github.com/CLIUtils/CLI11.git
  GIT_TAG v2.3.2
)
FetchContent_MakeAvailable(CLI11)

# Google Test(仅在构建测试时拉取)
if(BUILD_TESTING)
  FetchContent_Declare(
    googletest
    GIT_REPOSITORY https://github.com/google/googletest.git
    GIT_TAG v1.14.0
  )
  set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
  FetchContent_MakeAvailable(googletest)
endif()

我曾经踩过一个坑:FetchContent 拉取下来的库,如果没设置 FORCE 选项,缓存变量可能会被用户之前的构建残留影响。所以你看我上面用了 CACHE BOOL "" FORCE,确保每次都是干净的。

30.4 核心库:calculator

我们写一个简单的计算器库,用来演示目标导向设计。

// src/core/calculator.h
#pragma once
#include <string>

namespace my_project {

class Calculator {
public:
  double add(double a, double b);
  double subtract(double a, double b);
  double multiply(double a, double b);
  double divide(double a, double b);
};

} // namespace my_project
// src/core/calculator.cpp
#include "calculator.h"
#include <stdexcept>

namespace my_project {

double Calculator::add(double a, double b) { return a + b; }
double Calculator::subtract(double a, double b) { return a - b; }
double Calculator::multiply(double a, double b) { return a * b; }

double Calculator::divide(double a, double b) {
  if (b == 0.0) {
    throw std::runtime_error("Division by zero");
  }
  return a / b;
}

} // namespace my_project

对应的CMake文件:

# src/core/CMakeLists.txt
add_library(my_core
  calculator.cpp
  calculator.h
)

target_include_directories(my_core
  PUBLIC
    $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}>
    $<INSTALL_INTERFACE:include>
)

target_link_libraries(my_core
  PUBLIC
    spdlog::spdlog
)

# 设置导出组
set_target_properties(my_core PROPERTIES
  EXPORT_NAME Core
)

这里有个关键点:$<BUILD_INTERFACE>$<INSTALL_INTERFACE> 的用法。说白了就是——构建时用源码路径,安装后用安装路径。这样用户 find_package 之后,头文件路径自动就对了。

30.5 应用程序:main

// src/app/main.cpp
#include <iostream>
#include <spdlog/spdlog.h>
#include <CLI/CLI.hpp>
#include "core/calculator.h"

int main(int argc, char** argv) {
  CLI::App app{"MyProject Calculator"};

  double a = 0, b = 0;
  std::string op = "add";

  app.add_option("-a,--a", a, "First number")->required();
  app.add_option("-b,--b", b, "Second number")->required();
  app.add_option("-o,--op", op, "Operation: add, sub, mul, div");

  CLI11_PARSE(app, argc, argv);

  my_project::Calculator calc;
  double result = 0;

  if (op == "add") result = calc.add(a, b);
  else if (op == "sub") result = calc.subtract(a, b);
  else if (op == "mul") result = calc.multiply(a, b);
  else if (op == "div") result = calc.divide(a, b);
  else {
    spdlog::error("Unknown operation: {}", op);
    return 1;
  }

  spdlog::info("Result: {}", result);
  return 0;
}
# src/app/CMakeLists.txt
add_executable(my_app main.cpp)

target_link_libraries(my_app
  PRIVATE
    my_core
    CLI11::CLI11
)

30.6 单元测试

// tests/test_calculator.cpp
#include <gtest/gtest.h>
#include "core/calculator.h"

TEST(CalculatorTest, Add) {
  my_project::Calculator calc;
  EXPECT_DOUBLE_EQ(calc.add(2.0, 3.0), 5.0);
}

TEST(CalculatorTest, DivideByZero) {
  my_project::Calculator calc;
  EXPECT_THROW(calc.divide(1.0, 0.0), std::runtime_error);
}
# tests/CMakeLists.txt
add_executable(test_calculator test_calculator.cpp)

target_link_libraries(test_calculator
  PRIVATE
    my_core
    GTest::gtest_main
)

add_test(NAME CalculatorTest COMMAND test_calculator)

30.7 安装与导出

这是最核心的部分。我们让 my_core 库可以被 find_package 找到。

首先,创建导出模板:

# cmake/MyProjectConfig.cmake.in
@PACKAGE_INIT@

include("${CMAKE_CURRENT_LIST_DIR}/MyProjectTargets.cmake")

check_required_components(MyProject)

然后在顶层 CMakeLists.txt 末尾添加:

# 安装头文件
install(
  DIRECTORY src/core/
  DESTINATION include
  FILES_MATCHING PATTERN "*.h"
)

# 安装库和导出目标
install(
  TARGETS my_core
  EXPORT MyProjectTargets
  ARCHIVE DESTINATION lib
  LIBRARY DESTINATION lib
  RUNTIME DESTINATION bin
)

# 生成并安装 CMake 配置文件
install(
  EXPORT MyProjectTargets
  FILE MyProjectTargets.cmake
  NAMESPACE MyProject::
  DESTINATION lib/cmake/MyProject
)

include(CMakePackageConfigHelpers)
configure_package_config_file(
  cmake/MyProjectConfig.cmake.in
  "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfig.cmake"
  INSTALL_DESTINATION lib/cmake/MyProject
)

install(
  FILES "${CMAKE_CURRENT_BINARY_DIR}/MyProjectConfig.cmake"
  DESTINATION lib/cmake/MyProject
)
注意: 安装时一定要指定 NAMESPACE。否则用户用 find_package 之后,目标名就是裸的 my_core,容易和别的库冲突。加上 MyProject:: 前缀,就变成了 MyProject::Core,清晰多了。

30.8 知识体系总览

下面这张图,把整个项目框架的构建逻辑串起来了。你看一遍,应该就能理解每个模块的职责。

现代C++项目框架结构 顶层 CMakeLists.txt extern/ 第三方依赖 src/ 源码 tests/ 测试 my_core 库 my_app 可执行文件 链接 spdlog CLI11 安装 & 导出 (Config.cmake) 用户项目 find_package(MyProject)

30.9 构建与测试

最后,我们验证一下整个流程:

# 配置
cmake -B build -DBUILD_TESTING=ON

# 构建
cmake --build build

# 运行测试
cd build && ctest --output-on-failure

# 安装到本地目录
cmake --install build --prefix install_dir

# 另一个项目使用
# 在另一个项目的 CMakeLists.txt 中:
# find_package(MyProject REQUIRED)
# target_link_libraries(my_other_app PRIVATE MyProject::Core)

我曾经帮一个团队迁移项目,他们之前用纯手写的Makefile,每次加一个新模块都要改一堆路径。换成这套框架之后,新同事入职第一天就能独立添加模块并跑通测试。说白了,好的项目框架就是——让对的人做对的事,让CMake帮你管好依赖。

最后说一句: 这套框架不是银弹。如果你的项目只有两个文件,别硬套。但如果你在做一个会持续迭代、多人协作的库,花半小时搭好这个架子,后面省下的时间是以天计算的。

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