ODB 与 MySQL:C++ 里的 ORM 实践(类即表、事务铁律与查询封装)

📚 系列: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

相关推荐
FRing032 小时前
JavaScript入门到精通二十九(内置构造函数(Object、Array))
开发语言·前端·javascript
小鱼仙官2 小时前
使用Qt封装C/C++ 动态库 用于第三方调用
c语言·c++·qt
小嘉丶学长2 小时前
Day08 · 字符串(二)
服务器·开发语言·python·学习·1024程序员节
时间售卖铺2 小时前
MySQL 备份
运维·数据库·mysql·云原生
靠谱的工程师朋友3 小时前
电子会计档案管理软件科普 核心能力与选型要点解析
网络·数据库·人工智能·电子会计档案
萌动的小火苗3 小时前
MQTT的QoS0、QoS1、QoS2有什么区别?
开发语言·网络·python·php·信息与通信
买房动力十足3 小时前
秒杀商城 + AI 运营:Lua 防超卖、MQ 削峰,AI 负责选品和策划
开发语言·人工智能·lua
silvamux_3 小时前
10款模型写“君幸食”讲解词:统一事实档案与逐款审校
开发语言·php
北冥you鱼3 小时前
Go 语言 Ticker 定时任务全解析:原理、陷阱与最佳实践
开发语言·数据库·golang