
📚 系列:C++ 工程实践 第 17 篇 ⏱ 阅读约 15 分钟
📌 一句话总结
缓存挡不住的那部分终究要落到关系库,而在 C++ 里手拼 SQL 是典型的「写三遍、改一处、漏两处」。本文用 ODB 把这层收掉:C++ 类就是表定义 ,一条 odb 命令在编译期生成全部持久化代码,业务侧只面对对象;再讲清事务铁律、查询表达式与多表视图,最后用「配置 + 工厂 + DAO」分层收口。
📄 摘要
本文以 C++ 微服务脚手架中的点播系统为例,介绍如何用 ODB 在 C++ 里实现 ORM 式持久化。核心思路是让类定义成为表的唯一事实来源 :给字段加上 #pragma db 标注,一条 odb 命令即可在构建期生成持久化代码与建表 SQL,业务侧只面对对象、不写一行 SQL。文章依次覆盖 pragma 标注与类型映射、事务铁律下的增删改查、查询表达式与多表视图,最后用「配置 + 工厂 + DAO」完成二次封装,并汇总了构建链路与 pragma 对齐上的常见坑。
文章目录
-
- [1. 先看结果:一次插入拿到自增主键](#1. 先看结果:一次插入拿到自增主键)
- [2. 名词速查](#2. 名词速查)
- [3. 为什么要 ORM:C++ 里手拼 SQL 的三个痛点](#3. 为什么要 ORM:C++ 里手拼 SQL 的三个痛点)
- [4. 类就是表:pragma 怎么标注](#4. 类就是表:pragma 怎么标注)
- [5. 增删改查:每步都在事务里](#5. 增删改查:每步都在事务里)
- [6. 查询与封装:表达式、视图、工厂](#6. 查询与封装:表达式、视图、工厂)
- [7. 踩坑与小结](#7. 踩坑与小结)
1. 先看结果:一次插入拿到自增主键
对象丢进去,主键拿回来,全程没写一行 SQL。
bash
make # 先跑 odb 生成持久化代码,再编译
./odb_demo
text
插入完成,第一个 id = 1
查得: 1 示例影片 A 120s
已把 id=1 的时长改为 200s
删除了 1 行
make 之后目录里会多出四个文件,它们是这一步的关键:
text
video-odb.cc video-odb.hxx video-odb.ixx video.sql
前三个是 ODB 生成的持久化代码,video.sql 是建表语句。也就是说:你只写了 video.hxx 里那个类,剩下的 INSERT/UPDATE/SELECT 与 CREATE TABLE 全部由工具生成。
2. 名词速查
| 名词 | 一句话解释 |
|---|---|
| ORM | 把「对象 ←→ 表」的转换交给框架,业务代码只面对对象不再手拼 SQL |
| 持久化类 | 加了 pragma 标注、字段会落到表里的 C++ 类,类即表定义 |
#pragma db object |
标注「这是持久化类」,默认表名就用类名 |
#pragma db id auto |
标注这一列是主键,且值由数据库自增、插入后回填到对象 |
#pragma db not_null |
标注该列不允许为空,空值插入会报错 |
| 代码生成 | ODB 在构建阶段生成 .cc/.hxx 持久化代码,不是运行期反射 |
| 事务 | 一组操作要么全成功要么全回滚;ODB 里没有事务连 persist 都不生效 |
| 连接池 | 提前建好一批连接反复借还,避免每个请求都重新握手 |
| 整行覆盖 | update 按主键把整行所有列重写一遍,所以必须先取全对象再改 |
| 查询表达式 | Query::成员名 拼出的条件在编译期检查,成员名写错直接编不过 |
| 视图(view) | 多表 JOIN 时装查询结果的载体,不建表只是临时结构 |
odb::nullable |
表达「这一列可以为空」,对应 SQL 的 INT NULL |
3. 为什么要 ORM:C++ 里手拼 SQL 的三个痛点
不是嫌 SQL 不好写,是嫌同一个结构在三处重复、且没有任何编译期检查。
不引入 ORM 的写法长这样:拼字段、拼 VALUES、拼 WHERE,然后 mysql_query,再 mysql_fetch_row 拿回字符串数组,按列序号手工塞回结构体。三个痛点都非常具体:
| 痛点 | 表现 | 后果 |
|---|---|---|
| 结构写三遍 | INSERT / UPDATE / SELECT 各写一遍字段 | 加一个成员要同步改三处 |
| 类型安全为零 | 字符串、整数、时间都靠手拼字面量 | 转义漏一个就是注入或脏数据 |
| 返回侧靠序号 | 按列下标取值 | 列顺序一换就悄悄错位 |
ODB 的做法是让类定义成为唯一事实来源:字段加 pragma,工具生成持久化代码,业务代码只操作对象。类型在编译期就能被检查,字段名写错编不过。
ℹ️ 它是「代码生成」而不是「运行期映射」
ODB 不是运行期反射框架,它在构建阶段就把映射代码生成成 C++ 文件,和你自己的 .cc 一起编译。所以构建流程多了一步 odb 命令------这是它唯一的使用门槛。
整个工作流只有四步,多出来的那一步就是生成:
#mermaid-svg-fq1babFGLkyZHUyo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fq1babFGLkyZHUyo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fq1babFGLkyZHUyo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fq1babFGLkyZHUyo .error-icon{fill:#552222;}#mermaid-svg-fq1babFGLkyZHUyo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fq1babFGLkyZHUyo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fq1babFGLkyZHUyo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fq1babFGLkyZHUyo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fq1babFGLkyZHUyo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fq1babFGLkyZHUyo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fq1babFGLkyZHUyo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fq1babFGLkyZHUyo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fq1babFGLkyZHUyo .marker.cross{stroke:#333333;}#mermaid-svg-fq1babFGLkyZHUyo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fq1babFGLkyZHUyo p{margin:0;}#mermaid-svg-fq1babFGLkyZHUyo .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fq1babFGLkyZHUyo .cluster-label text{fill:#333;}#mermaid-svg-fq1babFGLkyZHUyo .cluster-label span{color:#333;}#mermaid-svg-fq1babFGLkyZHUyo .cluster-label span p{background-color:transparent;}#mermaid-svg-fq1babFGLkyZHUyo .label text,#mermaid-svg-fq1babFGLkyZHUyo span{fill:#333;color:#333;}#mermaid-svg-fq1babFGLkyZHUyo .node rect,#mermaid-svg-fq1babFGLkyZHUyo .node circle,#mermaid-svg-fq1babFGLkyZHUyo .node ellipse,#mermaid-svg-fq1babFGLkyZHUyo .node polygon,#mermaid-svg-fq1babFGLkyZHUyo .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fq1babFGLkyZHUyo .rough-node .label text,#mermaid-svg-fq1babFGLkyZHUyo .node .label text,#mermaid-svg-fq1babFGLkyZHUyo .image-shape .label,#mermaid-svg-fq1babFGLkyZHUyo .icon-shape .label{text-anchor:middle;}#mermaid-svg-fq1babFGLkyZHUyo .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fq1babFGLkyZHUyo .rough-node .label,#mermaid-svg-fq1babFGLkyZHUyo .node .label,#mermaid-svg-fq1babFGLkyZHUyo .image-shape .label,#mermaid-svg-fq1babFGLkyZHUyo .icon-shape .label{text-align:center;}#mermaid-svg-fq1babFGLkyZHUyo .node.clickable{cursor:pointer;}#mermaid-svg-fq1babFGLkyZHUyo .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fq1babFGLkyZHUyo .arrowheadPath{fill:#333333;}#mermaid-svg-fq1babFGLkyZHUyo .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fq1babFGLkyZHUyo .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fq1babFGLkyZHUyo .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fq1babFGLkyZHUyo .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fq1babFGLkyZHUyo .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fq1babFGLkyZHUyo .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fq1babFGLkyZHUyo .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fq1babFGLkyZHUyo .cluster text{fill:#333;}#mermaid-svg-fq1babFGLkyZHUyo .cluster span{color:#333;}#mermaid-svg-fq1babFGLkyZHUyo div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fq1babFGLkyZHUyo .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fq1babFGLkyZHUyo rect.text{fill:none;stroke-width:0;}#mermaid-svg-fq1babFGLkyZHUyo .icon-shape,#mermaid-svg-fq1babFGLkyZHUyo .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fq1babFGLkyZHUyo .icon-shape p,#mermaid-svg-fq1babFGLkyZHUyo .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fq1babFGLkyZHUyo .icon-shape .label rect,#mermaid-svg-fq1babFGLkyZHUyo .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fq1babFGLkyZHUyo .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fq1babFGLkyZHUyo .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fq1babFGLkyZHUyo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} video.hxx 类定义
odb 命令生成
video-odb.cc 与 hxx
video.sql 建表语句
g++ 一起编译
运行期连 MySQL
图 1:类定义进,持久化代码与建表 SQL 出,最后一起编译
4. 类就是表:pragma 怎么标注
pragma 不是注释,是给 ODB 编译器的元数据------它决定表名、主键、列名、约束。
一个最小的持久化类:
cpp
#pragma db object
class Video {
public:
Video(const std::string& title, int duration)
: title_(title), duration_(duration) {}
unsigned long id() const { return id_; }
const std::string& title() const { return title_; }
int duration() const { return duration_; }
void duration(int v) { duration_ = v; }
private:
friend class odb::access; // 让生成的代码能访问私有成员与默认构造
Video() = default;
#pragma db id auto // 主键,由数据库自增
unsigned long id_;
#pragma db not_null
std::string title_;
int duration_;
#pragma db type("TIMESTAMP") not_null
boost::posix_time::ptime update_;
};
两个细节不能少:私有默认构造 + friend class odb::access。生成代码要靠默认构造创建空对象、再直接写私有成员,没这个友元就编不过(如果成员和默认构造都是 public,可以省掉)。
常用 pragma 就这几个:
| pragma | 作用 |
|---|---|
db object |
声明这是持久化类,默认表名 = 类名 |
db table("tbl") |
指定表名 |
db id / id auto |
标主键;auto 表示数据库自增 |
db column("col") |
指定列名,默认用成员名 |
db not_null / unique / index |
附加约束 |
db type("VARCHAR(32)") |
指定数据库类型 |
db transient |
该成员不持久化 |
类型怎么映射有一套默认规则,写 pragma 之前先看一遍这张表:

图 2:常用 C++ 类型到 MySQL 列的默认映射,NULL 语义决定要不要用 nullable
要表达「这一列可以为空」,用 odb::nullable<T> 包一层,而不是用指针或哨兵值:
cpp
odb::nullable<int> age_; // 对应 INT NULL
odb::nullable<double> score_;
⚠️ 用 boost 时间类型必须带 profile
成员里出现 boost::posix_time::ptime,生成命令就必须加 --profile boost/date-time,链接还要加 -lodb-boost。漏了的表现是生成阶段直接报解析失败,或者时间列建不出来。
5. 增删改查:每步都在事务里
ODB 有一条铁律:所有数据操作都必须在事务内部完成。没有事务,persist 也白写。
先看连接。构造 odb::mysql::database 的参数是固定的九个:
cpp
std::unique_ptr<odb::mysql::connection_factory> pool(
new odb::mysql::connection_pool_factory(3)); // 最多 3 条连接
auto db = std::make_shared<odb::mysql::database>(
"root", "123456", "vod", // 用户、密码、库名
"127.0.0.1", 3306, // 主机、端口
0, "utf8", 0, // socket、字符集、client flags
std::move(pool)); // 第九个参数:连接池
增 ------persist 插入一行,主键由数据库分配后回填到对象:
cpp
{
odb::transaction t(db->begin());
Video a("示例影片 A", 120);
unsigned long id = db->persist(a); // id 现在有值了
t.commit(); // 忘了 commit,数据不落库
}
查 ------query::成员名 在编译期生成 SQL:
cpp
typedef odb::query<Video> Query;
typedef odb::result<Video> Result;
odb::transaction t(db->begin());
Result r(db->query<Video>(Query::duration_ > 100));
for (const auto& v : r) {
std::cout << v.id() << " " << v.title() << "\n";
}
t.commit();
注意 query:: 后面跟的是成员名 (duration_),不是列名,也不是类名。写错编不过------这正是它比手拼 SQL 强的地方。
改 ------update 是按主键整行覆盖:
cpp
auto row = db->query_one<Video>(Query::id == id);
if (row) {
row->duration(200);
db->update(*row); // 按主键覆盖整行
}
因为它是整行覆盖,必须先取到完整对象再改字段。拿一个只填了 id 和 duration 的半成品去 update,其他列会被写成空值。
删 ------按条件批量删,比逐个 erase 少一堆往返:
cpp
const auto n = db->erase_query<Video>(Query::duration_ < 100);
🚫 事务对象不要跨线程
事务对象持有连接状态,跨线程使用会让连接错乱。事务与它派生的 db 只在当前作用域内用完就结束;需要并发就开多个事务,别共享一个。
6. 查询与封装:表达式、视图、工厂
表达式负责单表条件,视图负责多表连接,工厂负责把这些能力装配成可复用的一层。
单表条件用表达式最安全,排序与分页这类子句则直接拼字符串更顺手------两种风格可以混用:
cpp
// 表达式:编译期检查字段名
auto r1 = db->query<Video>(Query::duration_ > 100 && Query::title_ != "");
// 字符串条件:排序 + LIMIT 这类子句拼起来更直接
std::string cond = "order by duration_ desc limit ";
cond += std::to_string(10);
auto r2 = db->query<Video>(cond);
多表连接用 #pragma db view,它不建表,只是查询结果的载体:
cpp
#pragma db view object(Classes) \
object(Student : Student::classes_id_ == Classes::id_)
struct ClassesStudent {
std::shared_ptr<Student> student;
std::shared_ptr<Classes> classes;
};
连接条件直接写在 pragma 里,等价于 SQL 的 ON;查询时按视图成员访问两张表的完整对象:
cpp
typedef odb::query<ClassesStudent> Query;
typedef odb::result<ClassesStudent> Result;
Result res(db->query<ClassesStudent>(Query::Classes::name_ == "一年级一班"));
for (const auto& row : res) {
if (row.student) std::cout << row.student->name_ << "\n";
}
最后是封装。ODB 的增删改查本身已经很完整,所以二次封装只做三件事:收配置、造句柄、分 DAO。
cpp
struct Settings {
std::string host = "127.0.0.1";
unsigned int port = 3306;
std::string user = "root";
std::string password;
std::string database;
std::string charset = "utf8";
std::size_t pool_size = 3;
};
std::shared_ptr<odb::database> DBFactory::createMySql(const Settings& s) {
std::unique_ptr<odb::mysql::connection_factory> pool(
new odb::mysql::connection_pool_factory(s.pool_size));
return std::make_shared<odb::mysql::database>(
s.user, s.password, s.database,
s.host, s.port, 0, s.charset, 0, std::move(pool));
}
业务侧就一句话:
cpp
auto db = dbx::DBFactory::createMySql(settings);
✅ 封装到「工厂 + DAO」就够
再往上包一层「通用 Repository」收益很低:ODB 的查询表达式本来就是类型安全的,包一层反而把类型信息藏起来。按表分 DAO(每个 DAO 内部统一处理事务与异常)是最省心的粒度。
7. 踩坑与小结
坑集中在两处:构建链路上的生成物,以及 pragma 与实际表的对齐。
| 现象 | 根因 | 修法 |
|---|---|---|
libodb-2.5.0-b.25.so: cannot open shared object file |
运行库在/usr/local/lib,链接器没搜到 |
sudo ldconfig 或设 LD_LIBRARY_PATH |
bash: /usr/bin/odb: No such file or directory |
odb 在 /usr/local/bin,PATH 没含 |
把该目录加进 PATH |
一堆undefined reference to ...odb... |
生成的-odb.cc 没参与编译 |
编译时带上生成物 |
db.query<>() 链接期报未定义 |
生成时漏了--generate-query |
重新用完整参数生成 |
| 时间字段解析失败 | 缺--profile boost/date-time |
补 profile,并链-lodb-boost |
| 运行报「表不存在」 | 没导入生成的.sql |
mysql -u root -p vod < video.sql |
update() 把其他列写空了 |
拿半成品对象去整行覆盖 | 先query_one 取全对象再改 |
| 高并发下连接状态错乱 | 事务对象跨线程 | 事务只在本作用域用 |
📝 小结
① 类定义就是表定义:字段加 pragma,工具生成持久化代码与建表 SQL。
② 没有事务就没有操作:增删改查全部要在 `db->begin()` 开的事务里做。
③ 生成的 -odb.cc 必须进编译,否则链接期一堆未定义引用。
④ update 是整行覆盖:先取全对象再改,别拿半成品更新。
⑤ 单表用表达式、多表用视图,工厂收连接池,DAO 收事务与异常。
📦 项目源码
本文示例所在的 C++ 微服务脚手架(点播系统):https://github.com/syagain-zsx/cpp-microservice-scaffold-vod-system
🔜 下一篇
《Elasticsearch 客户端与二次封装》------精确查询交给 MySQL,全文检索交给搜索引擎。关注专栏不迷路。
🔗 参考
1 ODB 官方手册:ODB Manual
2 ODB 项目主页:codesynthesis.com/odb
3 build2 工具链:build2.org