服务能跑、压测能过,可换台机器 cmake --build 之后报 No database is supported——Drogon 启动不报任何错,数据库客户端却永远是空的。这种"编译不报错、运行才炸"的坑,根子不在业务代码,而在项目搭建这道关:C++ 的依赖是 ABI 级别的,探测失败不会红字报错,只会静默给你装一个"缺胳膊少腿"的版本。本文把 seckill-cpp 当前的工程结构、CMake 两层写法、以及依赖安装里的暗坑一次讲透。

配套仓库 seckill-cpp(GitHub: https://github.com/Hespethorn/seckill-cpp),本文对应阶段一 v0.1.x,全部脚本与 CMake 见根目录 CMakeLists.txt、src/CMakeLists.txt、scripts/setup-wsl.sh、scripts/build-wsl.sh。真实编译在 WSL(Ubuntu 22.04+)完成。

一、项目搭建是什么、坑在哪、本质一句话

  • 是什么:把 Drogon、MySQL 客户端、jsoncpp 这些依赖,和自己的源码(controller / service / main)组织成一个可重复构建的产物。Java 改个 pom.xml 加个 jar 就完事,C++ 这一步要管头文件路径、链接库、编译标准、以及依赖"到底有没有真的被编进去"。
  • 坑在哪:Drogon 的 MySQL 后端是一个编译期开关。它的 FindMySQL.cmake 探测失败时不会停下来报错,而是默默改用"跳过版" DB 管理器——find_package(Drogon) 照过,add_executable 照编,build/ 里照样出一个能跑的二进制,直到你 getDbClient("default") 时才发现客户端是空的。
  • 本质一句话:C++ 项目搭建的核心矛盾是依赖的编译期耦合——一个依赖没编对,不是连不上那么简单,而是你拿到的整个库对象内部都可能是残的,而且它不会主动告诉你。

二、当前工程结构:两级 CMake 平铺

先看真实目录树(节选自仓库):

1
2
3
4
5
6
7
8
9
10
seckill-cpp/
├── CMakeLists.txt # 顶层:cmake 版本/标准/find_package(Drogon)/add_subdirectory(src)
├── config.json # 运行期配置(端口 + MySQL 客户端)
├── src/
│ ├── CMakeLists.txt # 子项目:把源码编成可执行文件 seckill-cpp
│ ├── main.cc # 启动 + 路由装配(getDbClient 延迟到 handler)
│ ├── controllers/ # HealthController / SeckillController(仅协议转换)
│ └── service/ # SeckillService(事务化原子扣减,核心逻辑)
├── sql/ # schema.sql / init_user.sql
└── scripts/ # setup-wsl.sh / build-wsl.sh / debug-wsl.sh / smoke-seckill.sh

顶层 CMakeLists.txt 完整内容(已是仓库真实写法):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cmake_minimum_required(VERSION 3.16)
project(seckill-cpp LANGUAGES CXX)

# 统一用 C++17:Drogon 的回调式 orm 在 17 下即可编译,
# 不依赖 C++20 协程(协程版的 commit() 返回 Task,API 形态不同,见 README 取舍小节)。
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)

if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()

# Drogon 提供 Drogon::Drogon target;其 orm 模块在启用 MySQL 时自动链入 mysqlclient。
find_package(Drogon REQUIRED)

add_subdirectory(src)

子项目 src/CMakeLists.txt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
add_executable(seckill-cpp
main.cc
controllers/HealthController.cc
controllers/SeckillController.cc
service/SeckillService.cc
)

# 头文件搜索根放在 src/,这样 controller/service 互相 include 时用 "service/SeckillService.h" 即可。
target_include_directories(seckill-cpp PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}
)

target_link_libraries(seckill-cpp PRIVATE Drogon::Drogon)

# 若你的 Drogon 编译时未把 MySQL 客户端带进 Drogon::Drogon,可解除下一行:
# find_package(MySQL REQUIRED)
# target_link_libraries(seckill-cpp PRIVATE MySQL::MySQLClient)

几个容易忽略的点:

  • CMAKE_CXX_STANDARD_REQUIRED ON 不是装饰——它强制 C++17,否则编译器按默认(可能 C++14)编,Drogon::Drogon 的目标属性不一定够用。
  • target_include_directories(... PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) 是让 #include "service/SeckillService.h" 能按"相对 src/ 的路径"解析的关键。不写它,你就得写一堆相对上层的 ../service/xxx.h,丑且易错。
  • add_subdirectory(src) 把构建单元下沉到子目录,顶层只管"全局标准 + 找 Drogon",子目录只管"编什么文件"。这是 CMake 最朴素的模块化形态。

整个构建与链接关系如下:

seckill-cpp 构建链:源码 → 可执行文件 → 链接 Drogon 库 src/ 源文件 main.cc controllers/*.cc service/*.cc 顶层 CMakeLists: C++17 + find_package 子项目 CMakeLists: add_executable + target_link Drogon 编译 seckill-cpp 可执行文件 build/src/ 链接 Drogon::Drogon • 事件循环 / 路由 • HttpController • orm(MySQL ON 才带) • jsoncpp / trantor 静态库 libdrogon.a ldd 查不到(编进二进制) → 排查要 nm 看符号 关键风险:find_package 成功 ≠ MySQL 后端编进去了;库里无 mysql 符号则运行时 getDbClient 返回空。

三、功能抉择一:单层 src/ 平铺 vs 拆多个 CMake 子项目

这是搭建时第一个要拍板的结构决策:

方案 做法 优点 代价
A. 两级平铺(选定) 顶层 + src/ 一个可执行文件,controller/service 按目录分但共用一个编译单元 配置极简、target_include_directories 一处搞定;改一个文件只重编小量 没有编译期模块边界,source 间可任意互相 include,容易长出循环依赖
B. 多子项目 拆成 common/、service/、web/ 三个 add_library 静态库,各自 target_include_directories + target_link_libraries 编译隔离、接口边界清晰、可并行编译、单模块改动只重编该库 要管 link 顺序、头文件可见性(PUBLIC/PRIVATE)、跨库依赖,配置成本陡增

我选 A,放弃的代价是"编译期边界":阶段一只有 5 个源文件,拆成三个静态库带来的并行编译收益几乎为零,反而要天天跟 target_link_libraries 的可见性、循环依赖较劲。C++ 里"目录分层"和"编译单元分层"是两件事——我现在只做目录分层(物理好读),不做编译单元分层(等真需要)。

什么时候该翻到方案 B?两条硬指标,命中任意一条就该拆:

  1. 源文件超过 ~20 个、或单文件编译时间显著拖慢整体(增量编译受不了);
  2. 出现"为了用一个小工具函数,把整条业务链路都拉进同一个编译单元"的循环依赖苗头。

真要拆,写法是把 src/CMakeLists.txt 改成:

1
2
3
4
5
6
7
8
9
10
11
# common/ 基础库(无业务依赖)
add_library(seckill_common STATIC common/util.cc)
target_include_directories(seckill_common PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})

# service/ 业务库,依赖 common
add_library(seckill_service STATIC service/SeckillService.cc)
target_link_libraries(seckill_service PUBLIC seckill_common)

# web/ 可执行文件,依赖 service
add_executable(seckill-cpp main.cc controllers/SeckillController.cc controllers/HealthController.cc)
target_link_libraries(seckill-cpp PRIVATE seckill_service Drogon::Drogon)

注意 PUBLIC vs PRIVATE:上层需要的头文件路径用 PUBLIC(传递下去),只在自己内部用的用 PRIVATE。这一步做对,模块边界才是真的边界。

四、功能抉择二:C++17 回调式 ORM vs C++20 协程

src/CMakeLists.txt 里 set(CMAKE_CXX_STANDARD 17) 不是随手写的。Drogon 的 orm 有两套 API 形态,取决于编译标准:

  • C++17 回调式:commit() / rollback() 是 void,结果和异常靠"成功回调 + 异常回调"两路 >> 挂接。SeckillService.cc 里 execSqlAsync(sql, 成功回调, 异常回调, 参数...) 就是这套。
  • C++20 协程式:commit() 返回 drogon::Task<bool>,必须 co_await 才能拿到结果,整条链路写成 async 函数。

选 C++17,放弃的代价是"回调嵌套":newTransactionAsync → 扣减成功回调 → 落单回调 三层 lambda 嵌套,可读性差、变量要靠按值捕获传递(见 SeckillService.cc 里那一串 cb = std::move(callback)、by value userId)。换来的是:编译环境要求低(WSL 默认 g++ 够用)、不用纠结 co_await 和栈展开的边界。等后面阶段四要做复杂编排时,再评估切协程版——届时把 SeckillService.cc 的提交改成 Task<bool> 即可。

五、功能抉择三:构建类型与三套 build 目录

scripts/build-wsl.sh 固定走 Release:

1
2
cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j"$(nproc)"

选 Release 默认,代价是我被迫准备了另外两个构建目录:前面排查 SIGSEGV 时,Release 是 -O3 -DNDEBUG,gdb 拿不到行号、bt 全是空括号——死活定位不到崩溃行。于是在 scripts/debug-wsl.sh 里补了:

  • build-debug:-DCMAKE_BUILD_TYPE=Debug(-g),进 gdb 看带行号栈;
  • build-asan:Debug + -fsanitize=address,undefined,让 ASan 直接报"第几行踩了空指针"。

教训:默认 Release 让交付产物快、体积小,但调试体验为零。正确做法是"跑用 Release、查问题切 Debug/ASan",而不是为了好调就常驻 Debug(那样压测数据失真)。三套目录各司其职,互不覆盖。

六、依赖安装的暗坑:libmariadb-dev 而不是 libmysqlclient-dev

scripts/setup-wsl.sh 是整套搭建里最值钱的一段——它决定了 Drogon 的 MySQL 后端到底编没编进去:

1
2
3
4
5
6
7
8
9
sudo apt-get install -y git g++ cmake ninja-build \
libjsoncpp-dev libboost-all-dev libssl-dev libc-ares-dev \
libcurl4-openssl-dev uuid-dev zlib1g-dev \
libmariadb-dev libmariadb-dev-compat

cmake -G Ninja -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_EXAMPLES=OFF -DBUILD_MYSQL=ON
cmake --build build -j"$(nproc)"
sudo cmake --install build
sudo ldconfig

两个必须知道的坑:

  1. FindMySQL.cmake 只认这三个库名:mysqlclient_r / mariadbclient / mariadb。Ubuntu 的 libmysqlclient-dev 只装出 libmysqlclient.so(5.5+ 已去掉 _r 变体),不在查找名单里 → 探测静默失败 → 装出"跳过版" libdrogon.a,运行时报 No database is supported。必须装 libmariadb-dev(提供 libmariadbclient.so,命中名单)才能把 orm/MySQL 后端编进去。
  2. Drogon 是静态库 libdrogon.a:ldd ./build/src/seckill-cpp 查不到任何 Drogon/MySQL 记录,因为它被静态编进了可执行文件。排查"到底带没带 MySQL"不能靠 ldd,要 nm libdrogon.a | grep -i mysql 看符号、或 nm ./build/src/seckill-cpp | grep MysqlConnection。这也是前面 debug 脚本里那 7 步探测的来源。

-DBUILD_MYSQL=ON 是 Drogon 源码编译的开关——漏了它,前面所有 apt 装得再对也白搭。-DBUILD_EXAMPLES=OFF 只是省编译时间,与功能无关。sudo ldconfig 让新装的库进系统缓存,否则运行时找库会 404。

七、可运行验证步骤

在 WSL 里搭一遍,确认 MySQL 后端真的编进去了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1) 一次性安装依赖 + Drogon 源码编译(约十几分钟)
bash scripts/setup-wsl.sh

# 2) 确认 Drogon 带 MySQL 符号(决定性,不靠 ldd)
nm /usr/local/lib/libdrogon.a 2>/dev/null | grep -i mysql | head
# 期望看到 newMysqlClient、MysqlConnection 等符号(带 T/U 标记)

# 3) 编译
bash scripts/build-wsl.sh

# 4) 确认可执行文件也带了这些符号
nm ./build/src/seckill-cpp | grep MysqlConnection
# 期望有一条 T 标记(已定义的全局符号)

# 5) 起 MySQL 并建库(见本系列「数据库表设计」验证步骤),再启动
sudo service mysql start
./build/src/seckill-cpp

第 2、4 步是最值得养成的习惯:C++ 的依赖真不真,用 nm 看符号比看 find_package 是否成功靠谱一万倍。

八、搭建取舍总结

决策点 选定 放弃的代价
模块拆分 两级平铺(1 个可执行文件) 无编译期边界,易长循环依赖
编译标准 C++17 回调式 ORM 三层 lambda 嵌套,可读性差
默认构建 Release 调试无行号,需额外 build-debug/asan
生成器 Ninja 首次配置信息比 Make 少(习惯问题)
依赖探测 find_package(Drogon) 成功 ≠ 后端真编入,得用 nm 复核
MySQL 客户端库 libmariadb-dev 不是 Ubuntu 默认的 libmysqlclient-dev
Drogon 链接 静态库 ldd 查不到,排查走 nm

一句话收尾:C++ 项目搭建的可靠性,后端依赖探测>>CMake 写法本身。CMake 写错会立刻红字报错,依赖"探测静默降级"才最要命——而唯一的治本手段,是在 CI 或本地加一条 nm 符号校验,把"看起来编过了"变成"确认编进去了"。

配套仓库:https://github.com/Hespethorn/seckill-cpp(本文对应 v0.1.x,构建脚本 scripts/setup-wsl.sh、scripts/build-wsl.sh、排错 scripts/debug-wsl.sh)。本系列是作者个人的 C++ 秒杀系统实战记录,所有方案、代码与踩坑均为原创。