MFC 集成 sqlite3 本地存储:静态库配置与 DbHelper 封装
简介:面向使用 Visual Studio 2010 进行 Windows 桌面开发的 C++ 学习者与 MFC 初学者,这份示例工程聚焦在 MFC 对话框中集成轻量级嵌入式数据库 SQLite3,解决单机应用的数据持久化与增删改查问题。示例覆盖打开数据库、执行 SQL、添加删除修改查询、通过回调函数逐行处理结果、同步与异步查询、错误码与错误信息检查,以及关闭连接和释放资源等关键环节,可对照代码理解数据库句柄与界面线程的配合方式。压缩包共 38 个文件,约 7.01MB,包含 C++ 源码与头文件、VS 解决方案及工程配置、SQLite3 动态库和静态库与命令行工具、示例数据库及 SQL 脚本,另有说明文档和少量编译产物,便于直接打开工程调试或按模块抽取复用。目前已有 1339 人学习,适合作为从建表到查询回调的完整练手样例,也能为异步查询和资源释放等易错点提供排错参考。
1. MFC 项目里塞进 sqlite3:先确认它是不是你的本地存储解
一个 MFC 桌面程序要存配置、日志和业务记录,装一套数据库服务不现实,很多团队会把 sqlite3 直接编进 exe。这份示例工程解决的就是这个场景:不依赖外部服务,单文件落盘,MFC 对话框里完成增删改查。它适合单机工具、设备上位机、内部管理小系统,数据量在几十万行以内、写入频率不高的情况。反过来说,多进程高频写、需要复杂权限和远程访问的项目,不该硬套。资源包给的是可编译的 MFC 工程骨架、DbHelper 封装和建表 SQL,拿到后先跑通链接和中文读写,再换成自己的表结构。
2. 把 sqlite3 接进 MFC 工程:静态库、头文件与字符集三件事
2.1 源码 amalgamation 和预编译 lib 怎么选
MFC 工程接 sqlite3 常见两条路:把 sqlite3.c 和 sqlite3.h 直接加进工程,或者用预编译好的 sqlite3.lib。源码方式的好处是编译链路透明,调试能跟进去,裁剪编译选项也方便;代价是首次编译慢,而且 sqlite3.c 必须按 C 编译,混进 C++ 编译单元容易报一堆类型错误。预编译 lib 接入快,工程里只留一个头文件和一个库文件,适合业务代码已经稳定、不想在第三方库上花时间的项目。
我一般会这样选:小工具、需要长期维护的项目走源码方式,把 sqlite3.c 放在third_party/sqlite3目录,工程里单独设成“编译为 C”;临时工具、内部 demo 走预编译 lib,先把读写跑通再说。两条路不要混用,同一工程里既加 sqlite3.c 又链 sqlite3.lib,符号重复会让链接器直接翻车。
| 方式 | 适合场景 | 要注意的点 |
|---|---|---|
| 源码 amalgamation | 需要调试、裁剪、长期维护 | sqlite3.c 按 C 编译;不要重复链接 lib |
| 预编译 lib | 快速接入、业务稳定 | 位数、运行时库、字符集必须和主工程一致 |
提示:不管选哪条路,工程里只保留一份 sqlite3 实现。源码和 lib 同时存在时,先删掉一个再编译。
2.2 附加包含目录、库目录与运行时库的对齐
预编译方式下,工程属性里要配三处:附加包含目录指向 sqlite3.h 所在目录,附加库目录指向 sqlite3.lib 所在目录,附加依赖项填 sqlite3.lib。MFC 项目通常分 Debug 和 Release,两个配置要分别检查,不能只改 Debug。位数也要对齐:主工程是 x64,lib 也必须是 x64;主工程是 Win32,lib 也必须是 Win32。位数不对时,链接器报 LNK2019 或 LNK1112,看起来像函数没找到,实际是二进制格式不匹配。
运行时库同样要对齐。主工程用 /MDd,lib 却用 /MTd,链接阶段可能过,运行阶段会出现堆分配和释放跨模块的问题。最稳妥的做法是让 sqlite3.lib 和主工程的“运行库”设置保持一致,Debug 对 Debug,Release 对 Release。下面这张表可以直接对着工程属性逐项核对。
| 配置项 | Debug 建议 | Release 建议 | 说明 |
|---|---|---|---|
| 附加包含目录 | $(ProjectDir)third_party\sqlite3 | 同左 | 放 sqlite3.h |
| 附加库目录 | $(ProjectDir)third_party\sqlite3\lib\$(Platform)\Debug | 对应 Release 目录 | 按平台分目录 |
| 附加依赖项 | sqlite3.lib | sqlite3.lib | 只填库名 |
| 运行库 | 与 lib 一致,通常 /MDd | 与 lib 一致,通常 /MD | 不要 Debug 配 /MT |
| 字符集 | Unicode | Unicode | MFC 默认,代码里做 UTF-8 转换 |
配完之后不要急着写业务代码,先做一次最小链接验证。验证代码只做三件事:打开内存库、取版本号、建一张临时表。能编译、能运行、调试输出里能看到版本号,说明头文件、lib、位数和运行时库这条链是通的。
2.3 用内存库跑通第一次链接验证
#include "sqlite3.h" void CLinkCheckDlg::OnBnClickedCheck() { sqlite3* db = nullptr; // :memory: 表示内存库,不落盘,适合验证链接和 API 是否可用 int rc = sqlite3_open(":memory:", &db); if (rc != SQLITE_OK) { TRACE(_T("sqlite3_open failed: %d\n"), rc); if (db) sqlite3_close(db); return; } // 版本号来自链接进来的 sqlite3 实现,能打印出来说明符号已解析 const char* ver = sqlite3_libversion(); TRACE("sqlite3 version: %s\n", ver); char* err = nullptr; rc = sqlite3_exec(db, "CREATE TABLE t_probe(id INTEGER);", nullptr, nullptr, &err); if (rc != SQLITE_OK) { TRACE("exec failed: %s\n", err ? err : "unknown"); sqlite3_free(err); // err 由 sqlite3 分配,必须用它自己的 free } sqlite3_close(db); }这段代码放在对话框的一个按钮响应里,Debug 下打开输出窗口就能看到结果。sqlite3_open的第一个参数是数据库路径,传:memory:时不会生成文件;第二个参数是二级指针,函数内部会分配句柄,失败时也可能返回一个可关闭的句柄,所以失败分支里仍然要判断db是否为空再关闭。sqlite3_libversion返回的是静态字符串,不需要释放。sqlite3_exec的最后一个参数用来接收错误文本,返回非SQLITE_OK时要把err打印出来,再用sqlite3_free释放,不能直接delete。
如果这里报 LNK2019,先查附加依赖项有没有填、库目录有没有指对、平台是不是 x64 对 x64。如果编译报sqlite3.h找不到,查附加包含目录是不是只配了 Release。如果运行时报“找不到 sqlite3.dll”,说明用的是动态库版本,要么把 dll 放到 exe 旁边,要么换成静态 lib。验证通过之后再进业务封装,后面出问题就容易定位到底是数据库层还是界面层。
3. 封装 DbHelper:打开、建表、参数化增删改查
3.1 为什么绕开拼接 SQL,改用 sqlite3_stmt
直接拼 SQL 写起来快,但边界很薄。姓名里带一个单引号,SQL 就断了;字段里带%或反斜杠,转义规则要自己维护;高频插入时每次都要重新解析 SQL,性能也不好看。sqlite3 提供的sqlite3_stmt是预编译语句,SQL 模板先 prepare,参数用 bind 填进去,SQL 结构固定,值只走参数通道,注入和转义问题一起消掉。
// 不推荐:拼接 SQL,姓名里带单引号就会翻车 CString sql; sql.Format(_T("INSERT INTO t_user(name, age) VALUES('%s', %d);"), name, age); m_db.Exec(sql);上面这种写法在 demo 里能跑,一旦姓名是“O'Neil”就会变成语法错误。改成参数绑定后,SQL 里只留?占位符,姓名作为文本参数传进去,单引号由 sqlite3 自己处理。预编译语句还能复用:循环插入一万条时,prepare 一次、bind 多次、reset 多次,比每次拼 SQL 再 exec 快一个量级。下面先定数据结构,再写 DbHelper 的骨架。
struct User { int id = 0; CString name; int age = 0; CString createdAt; }; class DbHelper { public: bool Open(const CString& dbPath); void Close(); bool CreateTable(); bool InsertUser(const CString& name, int age, int& newId); bool QueryAll(std::vector<User>& out); bool DeleteUser(int id); private: bool Exec(const char* sql); sqlite3* m_db = nullptr; };User只放界面需要的字段,createdAt用文本存,方便直接显示。DbHelper把 sqlite3 句柄藏起来,对话框不直接碰sqlite3*,后面换表结构时改动面小。Exec只用来跑建表、PRAGMA 这类没有返回值的 SQL;带参数的增删改查全部走 prepare/bind/step/finalize 流程。Close在对话框销毁时调用,避免程序退出时句柄泄漏。
3.2 打开数据库:路径、WAL 与 busy_timeout
数据库文件不要用相对路径。MFC 程序的工作目录可能是 exe 目录,也可能是工程目录,调试时和双击运行时经常不一致。我一般用GetModuleFileName取 exe 所在目录,再拼data\app.db,目录不存在就先建。路径拼好之后,CString是 UTF-16,sqlite3 的打开接口要 UTF-8,中间必须转一次,不能直接把CString强转成char*。
CString GetExeDir() { TCHAR szPath[MAX_PATH] = { 0 }; ::GetModuleFileName(nullptr, szPath, MAX_PATH); CString path(szPath); int pos = path.ReverseFind(_T('\\')); if (pos >= 0) path = path.Left(pos); return path; } bool DbHelper::Open(const CString& dbPath) { std::string path = CStringToUtf8(dbPath); int rc = sqlite3_open_v2( path.c_str(), &m_db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nullptr); if (rc != SQLITE_OK) { if (m_db) { sqlite3_close(m_db); m_db = nullptr; } return false; } // busy_timeout 让写冲突时先等一会,而不是立刻返回 SQLITE_BUSY sqlite3_busy_timeout(m_db, 3000); Exec("PRAGMA foreign_keys = ON;"); Exec("PRAGMA journal_mode = WAL;"); return true; }SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE表示文件不存在就创建,存在就读写。SQLITE_OPEN_FULLMUTEX让句柄内部串行化,单线程界面程序用不上多线程保护,但加上它不会错;如果确定只在 UI 线程访问,也可以换成SQLITE_OPEN_NOMUTEX,性能略好。sqlite3_busy_timeout的单位是毫秒,3000 表示遇到锁时最多等 3 秒。WAL 模式对读写并发的友好度比默认 rollback journal 好,但数据库文件所在目录必须可写,放在只读目录里会打开失败。
CStringToUtf8的实现放在 3.3 节,打开和绑定都要用。Exec里执行 PRAGMA 时没有回调,返回结果行会被忽略,journal_mode=WAL的返回值不需要处理。如果打开失败,调试输出里打印sqlite3_errmsg(m_db),比只看返回码有用。数据库文件路径里带中文时,UTF-8 转换必须正确,否则 sqlite3 会创建一个奇怪名字的文件,或者直接报 cannot open。
3.3 建表和插入:CString 到 UTF-8 的转换边界
建表 SQL 直接写成常量,字段类型用 SQLite 的亲和类型:INTEGER PRIMARY KEY AUTOINCREMENT做主键,TEXT存姓名和时间,INTEGER存年龄。创建时间用datetime('now','localtime')做默认值,插入时不用手动填。IF NOT EXISTS保证重复启动不会报错。
bool DbHelper::CreateTable() { const char* sql = "CREATE TABLE IF NOT EXISTS t_user(" "id INTEGER PRIMARY KEY AUTOINCREMENT," "name TEXT NOT NULL," "age INTEGER NOT NULL DEFAULT 0," "created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))" ");"; return Exec(sql); }这段 SQL 里没有参数,用Exec就够。AUTOINCREMENT会额外维护sqlite_sequence表,数据量很大时自增 ID 不会复用;如果只是普通自增,去掉AUTOINCREMENT也能工作,但删除后 ID 可能被重新使用。created_at的默认值带括号,这是 SQLite 对表达式默认值的语法要求。建表失败时先看sqlite3_errmsg,常见原因是数据库文件被另一个进程占用,或者目录没有写权限。
CString 到 UTF-8 的转换用WideCharToMultiByte,不要用CStringA直接赋值。CStringA在 Unicode 工程里做的是当前代码页转换,中文会变成问号或乱码。下面这个函数先算需要的字节数,再分配std::string,最后把转换结果写进去。
static std::string CStringToUtf8(const CString& s) { int n = ::WideCharToMultiByte( CP_UTF8, 0, s, -1, nullptr, 0, nullptr, nullptr); if (n <= 0) return std::string(); std::string out(n - 1, '\0'); // n 包含结尾的 '\0',string 不需要 ::WideCharToMultiByte( CP_UTF8, 0, s, -1, &out[0], n, nullptr, nullptr); return out; }WideCharToMultiByte第一次调用传空输出缓冲区,返回包含结尾空字符的字节数;第二次调用把n个字节写进去,std::string的长度取n - 1。CP_UTF8是目标编码,s是 UTF-16 源字符串,-1表示源字符串以空字符结尾。转换失败返回空串,调用方要判断。这个函数只处理 CString 到 UTF-8,反向转换在查询时用MultiByteToWideChar,逻辑对称。
插入用户时,SQL 模板只写一次,姓名用sqlite3_bind_text绑定,年龄用sqlite3_bind_int。绑定文本时长度传utf8.size(),最后一个参数传SQLITE_TRANSIENT,让 sqlite3 自己复制一份字符串。如果传SQLITE_STATIC,字符串生命周期必须覆盖到sqlite3_step执行完,局部变量很容易在这里出问题。
bool DbHelper::InsertUser(const CString& name, int age, int& newId) { const char* sql = "INSERT INTO t_user(name, age) VALUES(?, ?);"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) != SQLITE_OK) return false; std::string utf8Name = CStringToUtf8(name); sqlite3_bind_text(stmt, 1, utf8Name.c_str(), (int)utf8Name.size(), SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 2, age); int rc = sqlite3_step(stmt); if (rc == SQLITE_DONE) { newId = (int)sqlite3_last_insert_rowid(m_db); } sqlite3_finalize(stmt); return rc == SQLITE_DONE; }sqlite3_prepare_v2的第三个参数-1表示 SQL 以空字符结尾,第四个参数接收语句句柄。sqlite3_bind_text的第二个参数是占位符序号,从 1 开始;第三个参数是 UTF-8 字符串指针;第四个参数是字节长度,中文一个字符占 3 个字节,不能用字符数;第五个参数SQLITE_TRANSIENT让 sqlite3 复制数据。sqlite3_step返回SQLITE_DONE表示插入完成,sqlite3_last_insert_rowid取刚才生成的自增 ID。无论成功失败,sqlite3_finalize都要调用,否则语句句柄泄漏,后面可能出现 database is locked。
3.4 查询与错误信息:把 errmsg 落到调试输出
查询用sqlite3_step循环取行,返回SQLITE_ROW表示还有数据,返回SQLITE_DONE表示结束。取文本列用sqlite3_column_text,它返回 UTF-8 的const unsigned char*,可能为空,取之前要判断列类型或指针。转回 CString 用MultiByteToWideChar,和写入时的转换对称。
static CString Utf8ToCString(const char* p) { if (!p) return CString(); int n = ::MultiByteToWideChar(CP_UTF8, 0, p, -1, nullptr, 0); if (n <= 0) return CString(); CStringW w; ::MultiByteToWideChar(CP_UTF8, 0, p, -1, w.GetBuffer(n), n); w.ReleaseBuffer(n - 1); return CString(w); } bool DbHelper::QueryAll(std::vector<User>& out) { const char* sql = "SELECT id, name, age, created_at FROM t_user ORDER BY id DESC;"; sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) != SQLITE_OK) return false; while (sqlite3_step(stmt) == SQLITE_ROW) { User u; u.id = sqlite3_column_int(stmt, 0); u.name = Utf8ToCString( reinterpret_cast<const char*>(sqlite3_column_text(stmt, 1))); u.age = sqlite3_column_int(stmt, 2); u.createdAt = Utf8ToCString( reinterpret_cast<const char*>(sqlite3_column_text(stmt, 3))); out.push_back(u); } sqlite3_finalize(stmt); return true; }sqlite3_column_int的列索引从 0 开始,和 bind 的 1 起始不一样,写的时候容易错一位。sqlite3_column_text返回的指针由语句句柄管理,下一次 step 或 finalize 后可能失效,所以取到之后要立刻转成 CString,不能只保存指针。Utf8ToCString里GetBuffer(n)分配足够空间,ReleaseBuffer(n - 1)把长度设成不含结尾空字符的字符数。查询没有结果时循环不进入,函数返回 true,调用方拿到空 vector 是正常情况。
错误信息统一从sqlite3_errmsg取。Exec里用sqlite3_exec的 err 参数,prepare/step 失败时用sqlite3_errmsg(m_db)。调试输出只写错误文本,不要把整条 SQL 和用户输入拼进去,日志里出现个人信息不好。Release 下可以把错误文本写到一个本地日志文件,或者弹一个简短提示,让用户知道是“写入失败”还是“数据库被占用”。数据库层的错误不要直接吞掉,界面层至少要给出可操作的提示。
4. MFC 界面读写闭环:CListCtrl、编辑框与局部刷新
4.1 控件变量和字段映射先定死
界面不要一边写代码一边改控件 ID。先把列表控件、姓名编辑框、年龄编辑框、三个按钮的 ID 定下来,再在DoDataExchange里绑定控件变量。列表用CListCtrl的报表模式,四列:ID、姓名、年龄、创建时间。编辑框用CEdit,按钮用CButton或者直接走ON_BN_CLICKED,不绑定变量也能响应。
void CUserDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_LIST_USER, m_list); DDX_Control(pDX, IDC_EDIT_NAME, m_editName); DDX_Control(pDX, IDC_EDIT_AGE, m_editAge); } BEGIN_MESSAGE_MAP(CUserDlg, CDialogEx) ON_BN_CLICKED(IDC_BTN_ADD, &CUserDlg::OnBnClickedAdd) ON_BN_CLICKED(IDC_BTN_DEL, &CUserDlg::OnBnClickedDel) END_MESSAGE_MAP()DDX_Control把控件 ID 和成员变量关联起来,后面用m_list、m_editName直接操作。消息映射里只挂两个按钮,编辑和查询可以后续再加。控件 ID 在资源编辑器里改名字时,代码里的宏也要同步改,否则编译报未定义标识符。列表控件记得在资源属性里把 View 设成 Report
本文还有配套的精品资源,点击获取