资讯动态

Qt+SQLite分层架构:DAO与事务管理实战

发布时间:2026/10/9 22:00:10 来源:尧图企业网站定制
简介面向Qt开发者的SQLite数据库分层设计示例工程演示了如何在Qt框架中通过DBC数据库连接、DAO数据访问对象、VO值对象三层架构与SQLite交互适合需要理清数据访问逻辑或进阶Qt数据库编程的学习者。工程共21个文件包括10个cpp实现、9个头文件、1个SQLite数据库文件和1个pro工程文件整体仅4KB代码精炼便于阅读和移植。已有3935人学习下载说明该示例对同类需求具有参考价值。读者可获得完整的项目骨架数据库连接管理、DAO数据访问封装、VO值对象传递以及业务逻辑层与数据访问层的清晰分离。通过阅读和运行示例能快速掌握QSqlDatabase连接配置、SQL语句封装和事务处理的基本要领并可直接在此结构上扩展业务功能。1. 一个 7z 里装着 Qt SQLite 的分层骨架为什么值得拆开看做 Qt 开发的人迟早会撞上同一个场景界面还没写几行业务逻辑和 SQL 已经搅在一起数据库一换就得重写一半代码。这个SqliteDatabase压缩包正是冲着这个痛点来的——它不是一个完整业务系统而是一套「DBC 管连接、DAO 管 SQL、VO 管数据」的分层骨架帮你把 Qt 里操作 SQLite 的代码拆成一个能长期维护的形状。压缩包里有DatabaseManage.cpp/h、DataAccessLayer、BusinessLogicLayer和Sqlite.db麻雀虽小但分层、事务、参数绑定这些关键点一个不缺。适合刚写完第一个 Qt 数据库 Demo、想往工程化方向走的开发者也适合需要快速给老项目补一层数据访问抽象的人。2. 先把三层架构立住DBC、DAO、VO 在这个工程里长什么样很多人在 Qt 里写 SQLite 都是从QSqlQuery一路写到头UI 里直接铺 SQL 字符串改一个字段名要搜遍全工程。这个资源的核心价值就在于它用三个层把数据流切断让你只改该改的那一层。这一章先把文件映射到职责再走一遍调用链搞清楚数据是怎么从数据库一路流到业务层的。2.1 文件清单与三层职责的对应关系先看压缩包解压后的结构。DatabaseManage.h是门面也是大部分人第一个打开的文件DatabaseManage.cpp是 DBC 和 DAO 的落地实现DataAccessLayer和BusinessLogicLayer是目录存放各自的分层代码Sqlite.db是运行时数据文件SqliteDatabase.pro是 qmake 工程配置main.cpp是入口通常会演示一次「连接 → 插入 → 查询 → 关闭」的完整生命周期。表格化之后更清楚文件/目录所属层级主要职责DatabaseManage.h/cppDBC 门面创建/管理QSqlDatabase连接对外暴露open、close、saveUser、getUser等方法DataAccessLayerDAO具体 SQL 语句与事务逻辑隔离底层 JDBC/SQL 方言BusinessLogicLayer业务层调用 DAO 接口组合业务规则不接触QSqlQueryVO类或结构体值对象承载一张表的一行数据例如用户 ID、姓名、年龄Sqlite.db数据文件SQLite 单文件数据库保存实际表与记录SqliteDatabase.pro构建配置声明 Qt 模块、目标文件、源文件列表main.cpp程序入口演示各层如何协作并触发SqliteDatabase的初始化这套划分背后的思路是把「数据库连接」和「数据访问」拆成两个关注点。DBC 管生命周期DAO 管 SQLVO 管传输业务层只认 VO 接口不关心数据来自 SQLite 还是以后换成的 MySQL。这点在摘要里也强调了saveUser(UserVO user)和getUser(int id)是典型 DAO 风格接口参数是值对象返回值也是值对象SQL 细节被完全挡住。如果你之前是直接在MainWindow里拼INSERT INTO user VALUES(...)的写法看完这个工程的DatabaseManage.cpp会意识到一件事所有QSqlQuery的创建、绑定、执行都被收敛到一个类里界面代码里再也看不到exec()。2.2 从 main.cpp 到 DatabaseManage.cpp 的调用链先给出一段读取这个工程常见的 main 入口代码。注意它不是完整源码而是这个场景下最典型的调用序列用来帮你看清层与层之间的边界#include QCoreApplication #include QDebug #include DatabaseManage.h int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); // 1. 初始化数据库连接指定驱动、路径和连接名 DatabaseManage dbManage; dbManage.open(); // 内部构造 QSqlDatabase 并打开 SQLite 文件 // 2. 通过 DAO 风格方法写入一条用户数据 UserVO user; user.id 1; user.name A同学; user.age 23; bool ok dbManage.saveUser(user); if (ok) { // 3. 按主键查回得到 VO而不是裸的 QSqlRecord UserVO fetched dbManage.getUser(1); qDebug() fetched.name fetched.age; } // 4. 收尾关闭连接并释放内部 QSqlDatabase 资源 dbManage.close(); return 0; }这段调用链有三个值得注意的设计点。第一open()由DatabaseManage封装内部创建QSqlDatabase时指定驱动为QSQLITE并通过setDatabaseName()设置文件路径第二saveUser()接收的是UserVO内部再把字段逐一绑定到 SQL 占位符上杜绝字符串拼接第三close()不单单调用数据库的 close还会处理事务回滚残留和连接名的清理。参数层面open()的路径策略通常有两种一种是qApp-applicationDirPath()拼出绝对路径另一种是当前工作目录的相对路径sqlite.db。前者更稳定后者在 IDE 里调试时容易踩到「数据库文件跑偏」的坑具体在下一章展开。2.3 为什么要多定义一个 VO而不是直接传 QSqlRecord这是初学者最容易质疑的地方getUser(int id)为什么非要返回一个UserVO直接返回QSqlQuery或者QVariantList不行吗答案在于分层边界。QSqlQuery是 Qt SQL 层的类型业务层一旦依赖它下次换数据库驱动或改用存储过程业务层就得跟着改。VO 只是内存里的数据容器不绑定任何 Qt SQL 类型所以它能在层与层之间自由穿梭。实际写 DAO 的时候接住查询结果到 VO 的代码长这样UserVO UserDao::findById(int id) { UserVO vo; QSqlQuery query; query.prepare(SELECT id, name, age FROM user WHERE id ?); query.addBindValue(id); if (query.exec() query.next()) { vo.id query.value(0).toInt(); vo.name query.value(1).toString(); vo.age query.value(2).toInt(); } return vo; }这里query.prepare()和addBindValue()是同一件事的两步先用占位符把 SQL 模板立住再按位置绑定参数。好处是 SQL 语句能被 SQLite 预编译缓存重复执行时性能更好同时彻底规避注入问题——不是「小心转义」而是根本没有拼接发生。这段代码里还藏着一个细节query.next()返回 false 时返回的是一个默认构造的UserVO业务层必须有办法区分「查到了 id0 的空数据」和「没查到」常见做法是加一个bool isValid()或isEmpty()判断或者用bool found ...将查找结果一并带出。3. 把工程搭起来qmake 配置、数据库连接串与三个必调 API拿到压缩包后第一步不是急着读代码而是把它编译起来跑通再去改代码。这一章处理三件事.pro文件怎么填、QSqlDatabase连接串怎么写才对、以及open/close/transaction三个 API 的正确用法。这三件事任何一个太随意都会在后续开发中变成埋伏。3.1 SqliteDatabase.pro 里的关键配置qmake 工程文件是 Qt 项目的入口。这个资源的.pro文件虽然叫SqliteDatabase.pro但它的核心配置就是下面这段的标准写法QT core sql TARGET SqliteDatabase TEMPLATE app CONFIG c17 SOURCES \ main.cpp \ DatabaseManage.cpp \ DataAccessLayer/UserDao.cpp HEADERS \ DatabaseManage.h \ DataAccessLayer/UserDao.h DESTDIR $$OUT_PWD/binQT core sql这行是最容易被漏掉的。SQLite 支持不在core模块里QSqlDatabase、QSqlQuery全部收敛在Qt SQL模块没加sql编译直接报unknown type name QSqlDatabase。CONFIG c17不是必须的但Qt 6默认就是 C17且很多项目已经用上了结构化绑定加上它避免后续踩编译器标准不一致的坑。DESTDIR指定输出目录到bin/方便连同sqlite.db一起管理。如果你拿到手的是 Qt 6 而原工程是 Qt 5 写的还要注意一个问题Qt 6 里QSqlDatabase::addDatabase()默认使用连接名qt_sql_default_connection同名重复调用会报 duplicate connection name。所以这个工程的DatabaseManage必须设计成单例或显式传入自定义连接名不能每次open()都往默认连接上叠加。3.2 QSqlDatabase 连接串的三种写法与各自雷区open()里最核心的一句是拿到QSqlDatabase实例并设置数据库文件路径。在实际工程中下面三种写法都有使用场景bool DatabaseManage::open() { // QSQLITE 驱动名是固定的不是可选的 m_db QSqlDatabase::addDatabase(QSQLITE); // 写法一相对路径随工作目录变化 m_db.setDatabaseName(sqlite.db); // 写法二绝对路径用可执行文件所在目录拼接 QString dbPath QCoreApplication::applicationDirPath() /sqlite.db; m_db.setDatabaseName(dbPath); // 写法三内存数据库测试自毁 m_db.setDatabaseName(:memory:); if (!m_db.open()) { qWarning() open db failed: m_db.lastError().text(); return false; } return true; }三种写法按场景选。写法一最简洁但在 Qt Creator 里按不同构建套件运行工作目录可能是 build 目录、源码目录或工程根目录之一结果就是同一个sqlite.db在不同路径各出现一份SQLite 又不报错数据「去哪了」全靠猜。写法二解决定位问题但applicationDirPath()在 macOS 的.app包内指向Contents/MacOS依然要自己管理数据文件层级。写法三适合自动化测试——每次连接都会得到一个全新的空库测完自动释放不污染磁盘。连接建立后还需要确认表存在。SQLite 没有数据库端的CREATE TABLE IF NOT EXISTS自动执行机制所以这个工程里常见做法是第一次 open 后主动执行一段建表 SQLQSqlQuery query(m_db); bool ok query.exec( CREATE TABLE IF NOT EXISTS user ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, age INTEGER) );AUTOINCREMENT在 SQLite 里不是必须的普通INTEGER PRIMARY KEY也会自动增长AUTOINCREMENT额外保证主键永不复用已删除的 id。对于角色、产品这类删除后需要保留 id 历史的表用AUTOINCREMENT否则不加更省空间。3.3 open 和 close 之间的边界连接复用与资源管理很多人的第一版DatabaseManage会在每个 DAO 方法里都执行一次open()和close()。这在 SQLite 本地文件场景下不是不能用但会引入两个隐性成本每次 open 都走一遍文件锁初始化频繁小表操作时这部分开销甚至超过 SQL 本身而且如果中途抛出异常close 没执行连接泄漏的同时文件锁没有释放后续操作直接卡住。正确做法是仿照这个资源的结构open()在程序启动时调用一次负责建立连接close()在退出时调用一次负责收尾中间所有 DAO 方法都复用同一个m_db实例用局部QSqlQuery完成工作。SQLite 是文件锁模型一个写连接抢到锁时其他连接会返回database is locked所以只开一个长连接反而比到处开连接干净。如果确实需要临时开关比如接入外部 SQLite 工具修改了数据文件记得在重新 open 前调用QSqlDatabase::database().close()并removeDatabase()否则同名连接会拿到一个已失效的句柄。这在后面避坑章会细说。4. DAO 层落地把 SQL 从业务代码里剥出去三层架构里DAO 层是代码量最大、也最容易写歪的一层。不少人把 DAO 写成了「用QSqlQuery拼 SQL 的另一个地方」换汤不换药。这一章讲如何从DatabaseManage中把 DAO 真正拆出来以及在参数绑定和事务两个点上的实战细节。4.1 从 DatabaseManage 里拆 DAO 的三种力度这个资源的DataAccessLayer目录是有意留白的——它暗示你应该按业务实体拆类而不是全部堆在DatabaseManage.cpp里。拆 DAO 的常见力度有三种按团队规模和工作量取舍拆法类结构适用场景完全独立 DAOUserDao、ProductDao各自成类表多、每个实体操作复杂分层收益最大加一层仓库DAO 之上再加Repository聚合一个业务聚合根对应多张表只按方法聚合DatabaseManage::userSave、userFind小项目、表少于 5 张独立 DAO 的骨架通常是先定义一个接口比如IUserDao再写实现类UserDaoImpl。在 Qt 工程里这么做略重很多实际项目直接以UserDao为类名持有QSqlDatabase引用方法返回 VO。下面是一个从DatabaseManage剥出来的标准写法class UserDao { public: explicit UserDao(QSqlDatabase db) : m_db(db) {} bool insert(const UserVO user) { QSqlQuery query(m_db); query.prepare(INSERT INTO user (name, age) VALUES (?, ?)); query.addBindValue(user.name); query.addBindValue(user.age); return query.exec(); } private: QSqlDatabase m_db; };构造函数拿到QSqlDatabase 而不是每次addDatabase()这是一个容易被忽视的分层设计业务层不关心这个数据库是不是:memory:也不关心驱动是QSQLITE还是QMYSQL它只依赖 DAO 接口。如果你发现自己 DAO 里有QSqlDatabase::addDatabase()说明分层泄露了——这行代码应该只出现在 DBC 层。4.2 参数绑定 vs 字符串拼接性能与注入的差距很多人在说「参数绑定防 SQL 注入」但 DAO 层真正咬牙用prepare()的原因是 SQLite 的预编译机制带来的性能差异。字符串拼接的方式每次执行一条 SQL 都要让 SQLite 引擎重新解析一次 SQL 文本而prepare()addBindValue()的方式SQL 模板只解析一次后续执行只需替换参数。批量插入 1 万条记录时两种方式的时间差通常在 3 到 10 倍。批量插入的推荐实现是配合事务一次性提交bool UserDao::batchInsert(const QVectorUserVO users) { QSqlDatabase db QSqlDatabase::database(); bool opened db.transaction(); QSqlQuery query(db); query.prepare(INSERT INTO user (name, age) VALUES (?, ?)); for (const UserVO user : users) { query.addBindValue(user.name); query.addBindValue(user.age); if (!query.exec()) { db.rollback(); // 失败回滚整批保持数据一致 return false; } } return db.commit(); }这段代码里transaction()是否成功值得判断一下因为 SQLite 在事务嵌套场景下会静默开启一个自动保存点行为容易让新手困惑。rollback()的时机也很讲究有人习惯在exec()失败后直接return false事务没回滚连接一直握着一个未完成的写事务下一条commit()会把之前的半截数据也提交进去——这是实际开发里最隐蔽的一种数据污染。正确顺序永远是「失败 → rollback → return false」。QSqlDatabase::database()是获取默认连接的静态方法。如果你的工程设置了自定义连接名要写成QSqlDatabase::database(custom_conn)不传参拿到的是默认连接容易绕开你在DatabaseManage里配置的那条。4.3 读路径的 VO 映射与空值处理读操作比写操作更容易出隐藏问题。query.value(0)拿到的QVariant在 NULL 语义下很微妙SQLite 里NULL转QVariant是「空」而不是「0」toInt()返回 0toString()返回空串业务层如果不做区分可能把「年龄未知」和「年龄为 0」混为一谈。处理原则是在 DAO 层就把 NULL 语义转换成明确的业务语义而不是把QVariant原样上抛。UserVO UserDao::findByName(const QString name) { UserVO vo; vo.valid false; QSqlQuery query(m_db); query.prepare(SELECT id, name, age FROM user WHERE name ?); query.addBindValue(name); if (query.exec() query.next()) { vo.id query.value(0).toInt(); vo.name query.value(1).toString(); // SQLite 的 NULL 转 QVariant 后 isEmpty这里统一成 -1 表示无年龄 vo.age query.value(2).isNull() ? -1 : query.value(2).toInt(); vo.valid true; } return vo; }vo.valid字段是值对象的标准做法——值对象本身不带行为但可以带状态。业务层拿到valid false就知道不是「数据有效但 id 为 0」而是「没查到」这两个语义一旦混用后面的逻辑分支全都会错。如果你不喜欢加状态字段也可以把接口改成bool findByName(const QString name, UserVO out)用返回值表达查没查到。两种风格都常见选一种保持一致即可。5. 避坑SQLite 在 Qt 里的五个拦路虎这个工程本身不大但 SQLite 在 Qt 生态里跑起来之后真正麻烦的事几乎都在编译期之外。以下是五条来自一线开发的血泪经验每一条都对应一个可复现的现象、原因和解决方式。坑一驱动加载不了程序一跑就显示 No such driver: QSQLITE现象编译通过运行时m_db.open()返回 falselastError()显示 driver not loaded。这在 Qt 5 时代和 Qt 6 时代都很常见。原因Qt SQL 插件没有随可执行文件部署。QSQLITE驱动的实体是一个动态库如qsqlite.dll或libqsqlite.dylib它不在QSqlDatabase的核心代码里而是作为插件存放在 Qt 安装目录的plugins/sqldrivers/下。开发机运行时有环境变量帮忙找到插件部署到干净机器上就没了。解决使用 windeployqt 或 macdeployqt 部署时确认sqldrivers目录被拷贝如果不依赖部署工具手动把插件目录放到可执行文件相邻路径并在 main 里写QCoreApplication::addLibraryPath(QCoreApplication::applicationDirPath() /plugins)。检验标准很简单输出QSqlDatabase::drivers()列表里必须包含QSQLITE。坑二写到一半就报 database is locked过几秒又好了现象程序长时间跑着偶尔某个exec()突然返回 false错误是 database is locked但重试一次就成功没有稳定复现路径。原因SQLite 是文件级锁模型。常见触发点是外部工具如 Navicat、DB Browser 或另一个进程打开了同一个sqlite.db并持有了写锁或者程序内某个事务不提交、不回滚一直握住写锁其他连接只能等busy_timeout超时。解决所有写操作都走同一个连接并且严格保证事务成对关闭外部工具编辑完及时关连接。如果确实需要多进程写给QSqlDatabase设置setConnectOptions(QSQLITE_BUSY_TIMEOUT5000)让锁等待时间变得可接受而不是瞬间失败。坑三事务里某个 exec 失败后后续数据神秘消失现象批量插入 100 条记录中途第 50 条失败程序继续跑最后发现前 49 条也没了但你明明没有写 rollback 代码。原因SQLite 的事务是原子的任何一条语句执行失败整个事务进入 aborted 状态后续语句全部自动失败直到显式rollback()。你的代码如果忽略exec()的返回值继续循环后面 51 条全是「无效操作」最后调用commit()时 SQLite 直接忽略——因为事务早就陈死了。解决循环里exec()后立刻检查返回值失败就rollback()并中止或者把query.lastError()打印出来先把失败原因解决掉。经验是不要用「最后 commit 是否成功」来判断批量操作成败。坑四用相对路径打开数据到底写哪个文件里了现象程序明明往数据库里插了一条数据重启后查询不到你在多个目录下都找到了sqlite.db大小不一致。原因QSqlDatabase::setDatabaseName(sqlite.db)使用相对路径时路径解析基于进程当前工作目录而不是可执行文件位置。在 Qt Creator 里运行时工作目录默认是 build 目录的某个子级命令行运行时又是另一个目录。解决不用相对路径改为applicationDirPath()拼接一个data子目录并确保目录存在QDir dir(QCoreApplication::applicationDirPath()); if (!dir.exists(data)) dir.mkdir(data); m_db.setDatabaseName(dir.filePath(data/sqlite.db));从那以后你再也不用对着文件搜索发呆了。坑五用户表里中文保存后读出来变成乱码现象INSERT成功SELECT拿到的QString在qDebug()里显示正常但写到界面上或导出文件后全是????。原因SQLite 存储的是 UTF-8 编码Qt 的QString是 UTF-16 内部表示这两者通过QSqlQuery的索引绑定字节序。问题通常出在数据库文件本身是用非 UTF-8 编码的旧工具创建的或建表语句里没有显式指定TEXT字段的编码默认按 UTF-8 解释。解决连接后显式执行一次PRAGMA encoding UTF-8;并确认SET NAMES在 Qt SQL 层是不需要的QSQLITE驱动会自行处理编码转换。如果乱码已经写入只能重新导出再导入没有后悔药。这些坑配置在后面有个共性几乎都是「现象在业务层原因在连接层或事务层」所以排查时别急着改业务逻辑先回到DatabaseManage的open()和 DAO 里的exec()返回值上打日志。6. 验证这一层到底牢不牢用一组边界用例把 DAO 打穿前面的架构和代码都到位了最后要做的是验证——不是跑通一次主流程就完事而是主动去撞那些容易翻车的边界。SQLite 不像大型数据库有整套压力测试工具在 Qt 环境里最实用的验证方式是写一组基于QTest的纯数据层用例用:memory:数据库挂载不碰磁盘文件快且不会污染仓库。这套用例本质上是给 DAO 层上的保险之后改连接方式、换参数绑定策略跑一遍就知道哪里裂了。验证要覆盖五个维度单条插入和查询的正确性批量插入中途失败后的回滚完整性字段为NULL时 VO 的语义不被误读重复主键插入能报错而不是静默覆盖以及事务嵌套下rollback是否真的回滚了最外层。void TestUserDao::rollbackOnBatchFailure() { // 用内存库隔离环境每次用例都是全新、干净的数据 QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE, test_conn); db.setDatabaseName(:memory:); QVERIFY(db.open()); QSqlQuery q(db); q.exec(CREATE TABLE user (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)); UserDao dao(db); QVectorUserVO users; for (int i 0; i 100; i) { UserVO vo; vo.name QString(批量测试_%1).arg(i); vo.age i; users.append(vo); } QVERIFY(dao.batchInsert(users)); int count 0; q.exec(SELECT COUNT(*) FROM user); if (q.next()) count q.value(0).toInt(); QCOMPARE(count, 100); // 全部写入成功 db.commit(); }这段用例用:memory:库连接名是test_conn避免和默认连接冲突。batchInsert内部先transaction()批量执行完成后再commit()所以QCOMPARE检查的是最终提交后落地的记录数。如果事务中间有任何一条失败这个计数不会等于 100用例直接红灯。真正暴露问题的是下面这个主动制造失败的用例故意让第 50 条记录插入失败然后确认整个事务回滚。void TestUserDao::rollbackWhenMiddleInsertFails() { QSqlDatabase db QSqlDatabase::database(test_conn); // 清空数据重新造表 QSqlQuery q(db); q.exec(DELETE FROM user); UserDao dao(db); QVectorUserVO users; for (int i 0; i 100; i) { UserVO vo; vo.name (i 50) ? QString() : QString(name_%1).arg(i); // name 字段 NOT NULL插入空串虽然允许很考验逻辑 vo.age i; users.append(vo); } QSqlQuery q2(db); q2.exec(UPDATE user SET name NULL); // 先把已有数据改成 NULL bool ok dao.batchInsert(users); // 预期失败这里按业务规则判断是否回滚 QVERIFY(!ok); int count 0; QSqlQuery q3(db); q3.exec(SELECT COUNT(*) FROM user); if (q3.next()) count q3.value(0).toInt(); QVERIFY(count 0); // 整个事务已回滚没有残留的半截数据 }这个用例比较狠——它把中间一条记录的name置空并预先制造一列 NULL 数据来模拟脏环境正是这种「总觉得不会发生的组合」最容易让 DAO 层翻车。断言!ok只能确认报错真正有效的断言是事务过后表里没有残留数据。如果你在 DAO 里用return false前忘记rollback()这个用例会立刻报红灯不需要等到线上用户发现。我习惯把所有数据层的边界用例统一放到一个tests/目录用SUBDIRS挂进.pro工程里每次改动DataAccessLayer后强制跑一遍make check。从那以后我每次重构数据库层代码时对自己说一句「先跑用例再说话」这是给 SQLite 这种轻量库补上的最后一道防线。希望这些拆出来的思路和代码段能帮你少走几步弯路把这个分层架构真正用起来。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑