简介:这是一套面向MFC桌面应用开发者的MySQL数据库访问示例工程,核心演示如何借助ODBC标准数据库接口,在C++程序中完成连接建立、SQL查询、结果集遍历与数据更新等常用操作。压缩包内共含117个文件,压缩后约181KB;其中49个.c与46个.h构成完整源码主体,另有dsp/dsw工程文件、mak/makefile构建脚本、rc资源描述及def/plg等辅助配置,便于在Visual C++环境中直接编译调试或迁移至其他项目;几个.001分卷文件则对应ODBC相关库内容,按序解压即可使用。已有145人浏览学习该资源。示例代码覆盖了MFC ODBC编程的典型环节,包括初始化ODBC模块、通过CDaoDatabase连接数据源、利用CDaoRecordset执行SQL及移动记录、编辑更新字段等,同时展现出MySQL Connector/ODBC驱动配置与DSN设置的完整思路,可帮助开发者快速掌握MFC与MySQL交互的框架与API调用方式,也可作为开发小型数据库管理系统的实用参考模板。
1. MFC 连 MySQL 的工程现场:一个 ODBC 封装包为什么值得反复琢磨
看到MySQL.rar这种命名的压缩包,老 Windows 开发心里基本有数:里面多半是散装的 MFC + MySQL 工程零件,有人用 Visual Studio 写对话框程序,通过 ODBC 把CDatabase和CRecordset接到 MySQL 上,代码流传了几轮,注释还带着拼音。这套东西解决的是桌面端最朴素的需求——老 MFC 工具要直连 MySQL 5.7 或 8.0 做增删改查,不引入重型 ORM,不重写成 Qt 或 Web。适合谁?维护老工程、要给内部工具加数据库面板、又不想把时间耗在第三方库配置上的 Win32 开发者。读完之后,你能从选型、环境到代码把整条链路一次理清,也知道问题出现时先查哪里。
2. 选型:MFC 连 MySQL 的四条路,ODBC 凭什么最省心
2.1 四条路线对比:C API、ODBC、ADO、Connector/C++ 的账
MFC 程序连 MySQL,常见做法是四条路:MySQL 官方 C API、ODBC、ADO、Connector/C++。看到类似c_MySql字样的压缩包时,很多人第一反应是找 libmysql 的动态库,其实后面三条路线都不需要在业务代码里堆第三方头文件。把四条路摆在一起算账,选型就清楚了:
| 路线 | 需要额外安装的东西 | 典型调用方式 | 维护成本 | 适合场景 |
|---|---|---|---|---|
| MySQL C API | libmysql.dll、头文件 | mysql_init / mysql_real_query | 高,结构题和句柄全自己管 | 嵌入式、无 UI 的 C 程序 |
| ODBC | MySQL ODBC Driver | CDatabase / CRecordset | 低,MFC 已封装大半 | 现有 MFC 工程 |
| ADO | 需 ADO 运行时 | _ConnectionPtr / _RecordsetPtr | 中,COM 指针繁琐 | VB 脚本迁移过来的老代码 |
| Connector/C++ | 官方库和大量依赖 | 类似 JDBC 的 API | 中高,版本兼容麻烦 | 新 C++ 项目但不走 MFC |
在 MFC 工程里,ODBC 是回报率最高的一条。理由有三:第一,MFC 从 VC6 时代就把CDatabase、CRecordset焊死在框架里,接手老工程的人不需要学新 API,MSDN 上的例子直接能抄。第二,ODBC 连接串标准统一,今天连 MySQL,明天要换 SQL Server,只改驱动名和连接参数,业务代码几乎不动。第三,驱动由 MySQL 官方维护,Windows 装完即用,不用把一堆运行时 dll 塞进安装包。
Connector/C++ 看起来更现代,但它在 Visual Studio 工程里的配置项太多,Debug/Release、x86/x64 各要配一遍,还经常因为运行时版本不一致在客户机器上当场报错。桌面工具这种量级的查询,ODBC 的封装开销可以忽略。选型的血泪经验就一句:老工程别引入新依赖,除非有预算把整条链路重写一遍。
2.2 环境搭建:odbc 连接字符串与 MySQL ODBC 驱动的安装闭环
选完型,先把环境搞对。常见做法是去 MySQL 官网下载 ODBC Driver,版本选 8.0 系列,它同时兼容 MySQL 8.0 和 5.7 的服务端。注意两点:安装包分 32 位和 64 位,你的 MFC 工程编译成什么平台,就装对应位数;驱动安装向导会检查 Microsoft Visual C++ 2015 Redistributable(14.0 系列运行库),干净机器上驱动装到一半失败,八成是这个运行库没装。
驱动装好后,打开 ODBC 数据源管理器验证。Windows 搜索「ODBC 数据源」会出来两个入口,32 位是C:\Windows\SysWOW64\odbcad32.exe,64 位是C:\Windows\System32\odbcad32.exe。这地方像个黑匣子,最常见的错觉是「装好了」——打开「驱动程序」页,确认能看到MySQL ODBC 8.0 Unicode Driver才算真的装好。DSN 有三种用法:
- 用户 DSN:存在当前 Windows 账号的注册表里,只适合开发机自测。
- 系统 DSN:所有账号可见,适合部署到服务器。
- 无 DSN:不配数据源,直接在代码里写 odbc 连接字符串。最推荐,部署机上少一步配置,换机器不用重新配注册表。
无 DSN 的连接串长这样,这是全篇所有连接的基础:
CString strConn; strConn.Format( _T("Driver={MySQL ODBC 8.0 Unicode Driver};") _T("Server=%s;Port=3306;Database=%s;") _T("User=%s;Password=%s;Option=3;"), strServer, strDb, strUser, strPwd);连接串里每个分号字段都要记清楚:Driver必须和驱动名完全一致,写错一个字母就报Driver not found;Server能用127.0.0.1就别写localhost,某些环境里 localhost 被解析成 IPv6 的::1,反而连不上;Port默认 3306,服务端改过端口这里必须跟着改;Option=3是老例子里的常见杂项开关,照抄不会出错,但别指望它解决权限问题。MySQL 服务端安装时如果勾选了 TCP/IP 监听,记得确认防火墙放行 3306,否则后面所有代码都死在超时上。
注意:32 位和 64 位的 MySQL ODBC 驱动可以共存,但 MFC 工程的编译平台必须和驱动位数一致,这条在部署阶段会反复回来找你。
3. 从 CDatabase 到 CRecordset:跑通第一条查询的最小可运行代码
3.1 工程配置三件事:afxdb.h、odbc32.lib 与字符集
驱动装好只是前提,MFC 工程这边还要开三条路。第一,包含 MFC 的 ODBC 头文件;第二,让链接器带上 ODBC 导入库;第三,定死字符集。新建工程的pch.h(老工程是stdafx.h)里加两行:
// pch.h 顶部追加 #include <afxdb.h> // MFC 的 ODBC 类:CDatabase、CRecordset #pragma comment(lib, "odbc32.lib") // 链接 ODBC 导入库#pragma comment是给编译器看的链接指令,等价于在「项目属性 → 链接器 → 输入 → 附加依赖项」里手动填odbc32.lib。两者选一个就行,手写 pragma 的好处是换机器重配工程时不会漏。
字符集在「项目属性 → 常规 → 字符集」里看。新工程默认是「使用 Unicode 字符集」,对应的连接串 Driver 要用MySQL ODBC 8.0 Unicode Driver;如果工程是历史遗留的多字节(Multi-Byte)工程,可以用配套的MySQL ODBC ANSI Driver。MFC 的CString在 Unicode 工程里是CStringW,在多字节工程里是CStringA,连接串里的_T()宏会自动适配,别自己手写L"..."前缀,否则切字符集时整个工程都是编译错。
3.2 封装一个 CMySqlConnection:连接串、错误捕获与释放
直接在一个按钮里 new 一个CDatabase也能跑,但一个正经桌面工具通常有多个窗口和多个查询点,我一般会先封装一个最小的连接类,把连接串拼装、异常捕获、资源释放收敛到一处:
// MySqlConnection.h #pragma once #include <afxdb.h> class CMySqlConnection { public: CMySqlConnection(); virtual ~CMySqlConnection(); bool Connect(const CString& strServer, const CString& strUser, const CString& strPwd, const CString& strDb, UINT nPort = 3306); void Close(); CDatabase* GetDatabase() const { return m_pDb; } CString GetLastError() const { return m_strError; } private: CDatabase* m_pDb; CString m_strError; };// MySqlConnection.cpp #include "MySqlConnection.h" CMySqlConnection::CMySqlConnection() : m_pDb(nullptr) { } CMySqlConnection::~CMySqlConnection() { Close(); } bool CMySqlConnection::Connect(const CString& strServer, const CString& strUser, const CString& strPwd, const CString& strDb, UINT nPort) { if (m_pDb != nullptr) return true; m_pDb = new CDatabase; CString strConn; strConn.Format( _T("Driver={MySQL ODBC 8.0 Unicode Driver};") _T("Server=%s;Port=%u;Database=%s;User=%s;Password=%s;Option=3;"), strServer, nPort, strDb, strUser, strPwd); try { // noOdbcDialog:连接失败时不弹 ODBC 配置框,避免客户机弹出莫名对话框 m_pDb->OpenEx(strConn, CDatabase::noOdbcDialog); } catch (CDBException* e) { m_strError = e->m_strError; e->Delete(); delete m_pDb; m_pDb = nullptr; return false; } return true; } void CMySqlConnection::Close() { if (m_pDb != nullptr) { if (m_pDb->IsOpen()) m_pDb->Close(); delete m_pDb; m_pDb = nullptr; } }这个类的关键点在OpenEx的第二个参数。noOdbcDialog是必带的——否则连接失败时 ODBC 会弹一个数据源配置对话框,客户机器上没人知道怎么点。CDatabase*用裸指针是因为CDatabase没有拷贝语义,析构函数里做Close(),保证无论哪条路径退出都不漏。GetLastError把CDBException::m_strError留下来,调试时比只看弹窗有用得多。
3.3 第一条查询:CRecordset 的 Open 与 GetFieldValue 列号
连接就绪,跑第一条查询。CRecordset可以直接用临时对象,不需要每次写派生类——只读列表的简单场景,这样最省事:
bool QueryNames(CDatabase& db, const CString& strSql, std::vector<CString>& rows) { CRecordset rs(&db); try { // forwardOnly:只能向前遍历,只读列表时性能最好 rs.Open(CRecordset::forwardOnly, strSql); while (!rs.IsEOF()) { CString strValue; // 列号从 1 开始,这里取第 1 列 rs.GetFieldValue((short)1, strValue); rows.push_back(strValue); rs.MoveNext(); } rs.Close(); return true; } catch (CDBException* e) { TRACE(_T("QueryNames failed: %s, SQL=%s\n"), e->m_strError, strSql); e->Delete(); if (rs.IsOpen()) rs.Close(); return false; } }CRecordset构造时接收CDatabase*,Open的第一个参数指定游标类型。列表填充场景用forwardOnly就够了,它不缓存行集,内存占用最小;只有需要来回滚动或原地编辑时才考虑snapshot和dynaset。GetFieldValue的列号从 1 开始,传 0 会抛「参数索引越界」——这个坑几乎每个 MFC 新手都踩过,后面避坑章还会提到。返回值类型用CString重载,驱动会自动把数字和时间类型转成文本,省去类型分支判断。
4. 写实 CRUD:参数化查询、事务回滚与 NULL 字段处理
4.1 参数化查询:m_strFilter 与 RFX 参数绑定的数量规则
CRecordset直接拼 SQL 做查询虽然能跑,但用户输入一旦带上单引号,SQL 就被截断甚至被注入。正确做法是用 MFC 的参数化机制:从CRecordset派生一个类,在DoFieldExchange里用 RFX 宏声明参数:
class CUserSet : public CRecordset { public: CUserSet(CDatabase* pDb); virtual CString GetDefaultSQL(); virtual void DoFieldExchange(CFieldExchange* pFX); long m_id; CString m_name; CString m_strNameParam; // 绑定 WHERE 条件里的 ? }; CUserSet::CUserSet(CDatabase* pDb) : CRecordset(pDb) { m_id = 0; m_name.Empty(); m_strNameParam.Empty(); m_nFields = 2; // SELECT 结果有两列 m_nParams = 1; // 有一个查询参数 } CString CUserSet::GetDefaultSQL() { return _T("SELECT id, name FROM t_user"); } void CUserSet::DoFieldExchange(CFieldExchange* pFX) { RFX_Long(pFX, _T("id"), m_id); RFX_Text(pFX, _T("name"), m_name); // 参数不是结果列,但 RFX 类型要和对应列类型一致 RFX_Text(pFX, _T("nameParam"), m_strNameParam); }使用时设置m_strFilter,里面的?会按声明顺序取参数值:
CUserSet rs(&db); rs.m_strNameParam = _T("张%"); // 参数值和列类型一致,都是文本 rs.m_strFilter = _T("name LIKE ?"); // ? 的个数必须和 m_nParams 相等 rs.Open(); while (!rs.IsEOF()) { // 这里读 rs.m_id、rs.m_name rs.MoveNext(); } rs.Close();m_nParams是这套机制里最容易被忽略的数字。SQL 里写了两个?,m_nParams却只填 1,打开记录集时驱动直接抛「参数数量不足」。参数类型也讲究:name是varchar就用RFX_Text,id是整型就用RFX_Long,类型不匹配在绑定阶段不报错,取值阶段才出奇怪结果,排查成本极高。至于 INSERT 和 UPDATE,常见做法是CDatabase::ExecuteSQL拼字符串,前提是必须做单引号转义:
CString SafeSql(const CString& strIn) { CString strOut = strIn; strOut.Replace(_T("'"), _T("''")); // 单引号翻倍,防止 SQL 被截断 return strOut; } CString strSql; strSql.Format(_T("INSERT INTO t_user(name, remark) VALUES('%s', '%s')"), (LPCTSTR)SafeSql(strName), (LPCTSTR)SafeSql(strRemark)); db.ExecuteSQL(strSql);写操作没有记录集参数绑定可用,转义就是最后一道防线。内部工具不用武装到牙齿,但这条习惯必须养成——哪天工具暴露到公网,这条习惯能挡掉大部分脚本攻击。
4.2 事务边界:BeginTrans 到 CommitTrans 的完整节奏
涉及账户、订单、库存这类数据,两条 UPDATE 必须在一个事务里。MFC 的CDatabase自带事务方法,完整节奏如下:
if (!db.CanTransact()) { // 驱动或表不支持事务,再往下做就是自欺欺人 AfxMessageBox(_T("当前连接不支持事务,请检查表引擎")); return false; } try { db.BeginTrans(); db.ExecuteSQL(_T("UPDATE t_account SET balance = balance - 100 WHERE id = 1")); db.ExecuteSQL(_T("UPDATE t_account SET balance = balance + 100 WHERE id = 2")); // 业务校验全部通过再提交 db.CommitTrans(); } catch (CDBException* e) { TRACE(_T("事务失败: %s\n"), e->m_strError); e->Delete(); db.Rollback(); // 出错回滚,两条 UPDATE 都不生效 return false; }三个细节要注意。第一,BeginTrans必须是该连接上第一条执行的语句,不能前面跑了一堆查询再开事务。第二,事务只对当前CDatabase连接生效,两条 UPDATE 必须走同一个连接,跨连接无法保证原子性。第三,也是最大的坑:MySQL 只有 InnoDB 引擎支持事务,MyISAM 表会把BeginTrans静默吞掉,Rollback也不报错,但数据照样写进去。老库的表经常是 MyISAM,用之前先确认引擎,具体怎么查放避坑章讲。
4.3 NULL 字段:用 IFNULL 在前端 SQL 层兜底
ODBC 对 NULL 的返回比较含蓄:GetFieldValue取到 NULL 时,不同类型的行为不一致,CString重载拿到的是空串,数字类型拿到的是 0。问题在于空串和 NULL 语义不同,「没有备注」和「备注为空」在业务上可能一个允许一个不允许。与其在 C++ 端反复判断,不如在 SQL 层直接兜底:
rs.Open(CRecordset::forwardOnly, _T("SELECT id, IFNULL(remark, '') AS remark FROM t_user"));IFNULL(remark, '')把 NULL 统一转成空串,C++ 端少一个分支。但注意IFNULL的第二个参数类型决定返回类型:金额字段用IFNULL(amount, 0)会把 NULL 和 0 混为一谈,如果业务区分「未设置」和「金额为 0」,就别用 0 兜底。另一种做法是取CDBVariant,先看m_dwType再取值,代码冗长但语义精确,适合字段级要求严格的场景。我的习惯是:查询路径能用 SQL 函数解决的问题,绝不在 C++ 端加状态判断。
5. 避坑手册:MFC + ODBC + MySQL 的 5 个翻车现场
5.1 中文乱码:Unicode 工程配上了 ANSI 驱动
现象:查询出来的中文在 MessageBox 和 ListCtrl 里变成???或类似乱码,英文和数字正常。
原因:工程是 Unicode 字符集,连接串 Driver 却写成MySQL ODBC 5.3 ANSI Driver(或反过来),客户端与服务端字符集不一致。另一个常见源头是源文件本身没保存成带签名的 UTF-8,字符串字面量在编译期就错了。
解决:连接串统一用MySQL ODBC 8.0 Unicode Driver,并在连接串追加CharacterSet=utf8mb4;。代码里的中文字符串一律用_T()包裹,源文件用 Visual Studio 另存为「带有签名的 UTF-8」编码。改完这两处,再执行一遍查询,乱码通常当场消失。
5.2 odbc missing:32 位工程遇上 64 位驱动的错位
现象:驱动明明装了,程序启动连接时报odbc missing,或报「数据源名称未找到」。更诡异的是同事机器上同样代码能跑,你的机器就是不行。
原因:MFC 工程编译成 x86,系统里只装了 x64 的 MySQL ODBC 驱动;或者 DSN 配在 64 位的 odbcad32 里,程序以 32 位进程去读注册表当然找不到。这类问题被称作玄学,本质是位数错位。
解决:先看工程平台是 Win32 还是 x64,再去对应入口的 ODBC 数据源管理器「驱动程序」页确认驱动存在;如果两边对不上,装对应位数的驱动,或改用无 DSN 连接串彻底绕开注册表。验证方法:在连接串的Driver写错时错误信息里会带「找不到指定的驱动程序」,看到这句先查位数,别怀疑代码。
5.3 MySQL 8.0 连不上:caching_sha2_password 认证插件旧驱动不认
现象:连接串、端口、账号密码全对,OpenEx抛Authentication plugin 'caching_sha2_password' cannot be loaded。
原因:MySQL 8.0 默认认证插件是caching_sha2_password,旧版 MySQL ODBC 驱动不认识新插件。5.x 的老驱动配 8.0 服务端,十个有九个挂在这里。
解决:优先升级驱动到 8.0 系列的较新 Unicode 版本,新驱动官方支持 MySQL 8.0 的认证插件。如果客户机不允许升级驱动,临时方案是把账号改回旧插件:
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY '你的密码';这条 SQL 有后悔药可吃,改回caching_sha2_password也一样一条命令。但它是兼容性让步,不是治本方案,生产环境建议走升级驱动的路,同时确认机器装了 VC++ 2015 运行库,否则新驱动装不上,又是下一个 odbc missing。
5.4 事务不回滚:MyISAM 表把 BeginTrans 静默吞掉
现象:事务代码完全照着文档写,BeginTrans、CommitTrans、Rollback都调用成功,但模拟崩溃时数据仍然写进去了,两条 UPDATE 只成功一条。
原因:MySQL 5.5 之前的默认引擎是 MyISAM,不支持事务。ODBC 层面对BeginTrans的调用被驱动静默接受,MySQL 服务端执行 UPDATE 时根本没有事务上下文,Rollback 自然什么都不做。
解决:先查引擎:
SHOW TABLE STATUS WHERE Name = 't_account';看到Engine列是MyISAM,就执行:
ALTER TABLE t_account ENGINE=InnoDB;新库建表时显式指定ENGINE=InnoDB,别依赖默认配置。代码里保留CanTransact()检查,它返回 FALSE 时就拒绝继续执行,宁可报错也不让用户以为有事务保护。
5.5 参数绑定翻车:m_nParams 与 SQL 里的 ? 数对不上
现象:打开派生记录集时抛Too few parameters. Expected 1.,或者莫名其妙的Invalid parameter number。SQL 单独拷到 MySQL 客户端里能跑,代码里就是报错。
原因:m_strFilter里写了两个?,m_nParams却只填了 1;或者DoFieldExchange里参数化 RFX 的声明顺序和 SQL 中?的出现顺序不一致。MFC 按声明顺序取参数,顺序错位时数据绑到错误的列上。
解决:数清楚 SQL 里?的个数,把它填进构造函数里的m_nParams;然后在DoFieldExchange里按从左到右的顺序声明参数化 RFX 宏。调试时在DoFieldExchange入口下断点,看pFX->m_nOperation是否按预期次数进入参数绑定分支。这个坑最气人的地方是编译期完全正常,只在运行时炸,所以参数个数是每次改 SQL 后第一件要核对的事。
6. 进阶:把查询扔进工作线程,状态栏不再假死
ODBC 查询走网络,MySQL 服务端稍慢一点,界面线程就卡住,鼠标转圈十几秒。桌面软件的体面做法是把查询放到工作线程。这里有一条铁律:CDatabase和CRecordset都不是线程安全的,一个连接对象不能在两个线程里同时用;线程可以有自己的连接,但绝不允许把主线程的CDatabase*传进AfxBeginThread的参数里。常见做法是在工作线程内部 new 一个CMySqlConnection,独立连接、独立查询。
线程结束不能直接操作 UI 控件,MFC 的标准姿势是PostMessage把结果指针丢回界面线程:
UINT QueryWorker(LPVOID pParam) { CMySqlConnection conn; if (!conn.Connect(g_strServer, g_strUser, g_strPwd, g_strDb)) { // 失败也发消息,让界面线程弹错 ::PostMessage(g_hMainWnd, WM_QUERY_DONE, 0, 0); return 1; } std::vector<CString> rows; QueryNames(conn.GetDatabase(), g_strSql, rows); CString* pResult = new CString; // 堆指针跨线程传递 for (size_t i = 0; i < rows.size(); i++) *pResult += rows[i] + _T("\r\n"); ::PostMessage(g_hMainWnd, WM_QUERY_DONE, 1, (LPARAM)pResult); return 0; }界面线程用ON_MESSAGE(WM_QUERY_DONE, &CMainDlg::OnQueryDone)映射处理函数,在函数里把pResult转出来填充CListCtrl,最后delete pResult,顺手用m_wndStatusBar.SetPaneText(0, _T("查询完成"))刷新状态栏。别用SendMessage,它会等界面线程处理完才返回,等于又阻塞了;PostMessage异步投递,工作线程发完就退出。跨线程传指针务必在接收端 delete,这是老 MFC 工程内存泄漏的最大来源,我曾经在一个工具里漏了三年。
我自己的习惯是把「线程 + PostMessage 回传」封装成一个RunAsyncQuery(SQL, 窗口句柄)的包装函数,业务按钮只传 SQL 和接收消息的 HWND,再顺手把m_strError连同 SQL 写进本地日志文件。远程客户机器上的 ODBC 报错,弹窗只能截个图,日志却能定位到底是连接、认证还是 SQL 哪一环出了问题。封装越早做完,后面每个查询按钮就只剩一行调用,希望帮到你。
本文还有配套的精品资源,点击获取