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 知识体系总览
下面这张图,把整个项目框架的构建逻辑串起来了。你看一遍,应该就能理解每个模块的职责。
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