1. 项目缘起与整体设计思路
1.1 为什么还在用 MFC 做登录界面
先把话说在前头:MFC 不是什么新东西,但它在某些场景下依然有不可替代的位置。我接触过不少做工业控制、医疗设备上位机、实验室仪器配套软件的团队,他们的技术栈就是 VS2019 + MFC + MySQL 这套组合。原因很实在——存量代码多、界面响应快、部署简单、不依赖运行时环境,一个 exe 拷过去就能跑。
登录界面是所有管理系统的第一道门。别看它简单,麻雀虽小五脏俱全:要有输入框、要有密码掩码、要有登录按钮的响应逻辑、要连接数据库做校验、还要处理各种异常情况。很多新手觉得“不就是个登录嘛”,结果一上手就卡在环境配置、字符集乱码、连接失败这些问题上,折腾一整天都跑不起来。
这篇内容就是把我自己踩过的坑和验证过的流程完整梳理一遍。适合两类人看:一类是刚接触 MFC 的学生或者转岗开发者,需要快速跑通一个能用的登录 Demo;另一类是有一定基础但每次配 MySQL 连接都要重新查资料的老手,可以当作一份速查手册。核心关键词就三个:VS2019、MFC、MySQL 连接,围绕这三个词把登录界面从零到一讲透。
1.2 整体架构与技术选型考量
这个项目的架构非常简单,三层结构:界面层(MFC 对话框)→ 业务逻辑层(登录校验函数)→ 数据层(MySQL 数据库)。没有复杂的框架,没有 ORM 映射,就是最朴素的 C++ 调用 MySQL C API。
为什么不用 ODBC?因为 ODBC 需要额外配置数据源,换一台机器就要重新配一遍,部署起来麻烦。而 MySQL C API 是直接链接库文件,把 libmysql.dll 和 libmysql.lib 一起打包就行,对单机部署的桌面程序来说更省心。
为什么不用 MySQL Connector/C++?那个是基于 JDBC 风格的接口,用起来确实更“现代”,但它依赖的运行时库更多,而且不同版本之间的 ABI 兼容性经常出问题。对于 MFC 这种偏传统的技术栈,用最原始的 C API 反而最稳。
数据库表的设计也很简单,一张用户表就够了:
CREATE TABLE user_info ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意:密码字段这里先存明文,是为了让 Demo 跑通。实际项目里必须做哈希加盐处理,后面我会专门讲这块怎么改。
2. 环境准备与依赖配置
2.1 VS2019 安装时的关键选项
VS2019 的安装器里,工作负载要勾选“使用 C++ 的桌面开发”。这个大类下面有几个组件必须确认勾上:
- MSVC v142 - VS 2019 C++ x64/x86 生成工具:这是编译器核心,不勾什么都干不了。
- Windows 10 SDK:选最新版本就行,MFC 的头文件依赖它。
- C++ MFC 最新 v142 生成工具:这个默认是不勾的,必须手动勾上,否则新建项目时找不到 MFC 模板。
- C++ ATL 最新 v142 生成工具:MFC 内部依赖 ATL 的部分组件,建议一起勾上。
安装完之后,打开 VS2019 新建项目,在搜索框输入“MFC”,应该能看到“MFC 应用”模板。如果看不到,说明 MFC 组件没装好,回到安装器里补勾。
2.2 MySQL 服务端与开发库的版本匹配
MySQL 的版本选择有个坑:服务端版本和开发库版本必须匹配。我见过有人服务端装的是 MySQL 8.0,开发库却下载了 5.7 的,结果编译能过,运行时报“找不到指定模块”或者直接崩溃。
推荐的做法是:服务端装什么版本,开发库就下什么版本。MySQL 官网提供两个东西:
- MySQL Installer:用来装服务端和 Workbench 管理工具。
- MySQL Connector/C:这是纯 C 语言的连接库,包含头文件和 lib 文件。
下载 Connector/C 的时候注意选对平台。VS2019 默认编译的是 32 位程序(除非你手动改成 x64),所以要下 32 位的库。如果你打算编译 64 位程序,那就下 64 位的。两者不能混用。
安装完 Connector/C 之后,目录结构大概是这样:
C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\ ├── include\ │ ├── mysql.h │ ├── mysql_com.h │ └── ... ├── lib\ │ ├── libmysql.lib │ ├── libmysql.dll │ └── ...记住这个路径,后面配置项目属性要用。
2.3 项目属性配置的四个关键位置
新建 MFC 项目之后,右键项目 → 属性,需要改四个地方。这里以 32 位 Debug 配置为例:
第一处:C/C++ → 常规 → 附加包含目录
添加 Connector/C 的 include 路径:
C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\include第二处:链接器 → 常规 → 附加库目录
添加 lib 路径:
C:\Program Files (x86)\MySQL\MySQL Connector C 6.1\lib第三处:链接器 → 输入 → 附加依赖项
添加:
libmysql.lib第四处:把 libmysql.dll 拷贝到输出目录
这一步最容易忘。libmysql.lib 只是导入库,运行时真正加载的是 libmysql.dll。你需要把 Connector/C 的 lib 目录下的 libmysql.dll 拷贝到你的项目输出目录(通常是项目名\Debug\或项目名\x64\Debug\)。
实操心得:我习惯在项目根目录建一个
libs文件夹,把 include、lib、dll 都放进去,然后用相对路径配置。这样整个项目拷到别的机器上也能直接编译,不用重新配路径。
3. 登录界面设计与核心代码实现
3.1 对话框布局与控件命名
在资源视图里打开主对话框,把默认的“TODO: 在此放置对话框控件”删掉,然后拖入以下控件:
| 控件类型 | ID | 用途 |
|---|---|---|
| Static Text | IDC_STATIC_USER | 显示“用户名:” |
| Edit Control | IDC_EDIT_USER | 输入用户名 |
| Static Text | IDC_STATIC_PWD | 显示“密码:” |
| Edit Control | IDC_EDIT_PWD | 输入密码 |
| Button | IDC_BTN_LOGIN | 登录按钮 |
| Button | IDC_BTN_EXIT | 退出按钮 |
密码输入框要设置一个属性:在属性面板里找到Password,设为True。这样输入的内容会显示为圆点,不会明文暴露。
另外建议把对话框的System Menu设为 False,去掉标题栏的关闭按钮,防止用户直接点 X 绕过登录逻辑(虽然 Demo 里无所谓,但养成习惯是好的)。
3.2 数据库连接类的封装
直接在对话框类里写 MySQL 连接代码不是不行,但复用性差。我习惯封装一个简单的数据库工具类,叫CDbHelper。核心代码如下:
// DbHelper.h #pragma once #include <mysql.h> #include <string> class CDbHelper { public: CDbHelper(); ~CDbHelper(); bool Connect(const std::string& host, const std::string& user, const std::string& password, const std::string& dbName, unsigned int port = 3306); void Disconnect(); bool CheckLogin(const std::string& username, const std::string& password); std::string GetLastError(); private: MYSQL* m_pConn; std::string m_lastError; };实现文件:
// DbHelper.cpp #include "pch.h" #include "DbHelper.h" CDbHelper::CDbHelper() : m_pConn(nullptr) { m_pConn = mysql_init(nullptr); if (m_pConn) { // 设置字符集为 utf8mb4,防止中文乱码 mysql_options(m_pConn, MYSQL_SET_CHARSET_NAME, "utf8mb4"); } } CDbHelper::~CDbHelper() { Disconnect(); } bool CDbHelper::Connect(const std::string& host, const std::string& user, const std::string& password, const std::string& dbName, unsigned int port) { if (!m_pConn) { m_lastError = "mysql_init failed"; return false; } if (!mysql_real_connect(m_pConn, host.c_str(), user.c_str(), password.c_str(), dbName.c_str(), port, nullptr, 0)) { m_lastError = mysql_error(m_pConn); return false; } return true; } void CDbHelper::Disconnect() { if (m_pConn) { mysql_close(m_pConn); m_pConn = nullptr; } } bool CDbHelper::CheckLogin(const std::string& username, const std::string& password) { if (!m_pConn) return false; // 使用 mysql_real_escape_string 防止 SQL 注入 char escapedUser[256] = { 0 }; char escapedPwd[256] = { 0 }; mysql_real_escape_string(m_pConn, escapedUser, username.c_str(), username.length()); mysql_real_escape_string(m_pConn, escapedPwd, password.c_str(), password.length()); std::string sql = "SELECT id FROM user_info WHERE username='"; sql += escapedUser; sql += "' AND password='"; sql += escapedPwd; sql += "' LIMIT 1"; if (mysql_query(m_pConn, sql.c_str()) != 0) { m_lastError = mysql_error(m_pConn); return false; } MYSQL_RES* res = mysql_store_result(m_pConn); if (!res) { m_lastError = mysql_error(m_pConn); return false; } bool found = (mysql_num_rows(res) > 0); mysql_free_result(res); return found; } std::string CDbHelper::GetLastError() { return m_lastError; }这段代码有几个关键点值得展开说。
第一,mysql_options 设置字符集。很多人连接数据库之后发现中文用户名查不到,就是因为字符集没设对。MySQL 8.0 默认是 utf8mb4,但 C API 默认可能是 latin1,两边不一致就会出问题。在 mysql_init 之后、mysql_real_connect 之前调用 mysql_options 设置字符集,是最稳妥的做法。
第二,mysql_real_escape_string 防注入。登录框是 SQL 注入的重灾区。如果用户输入' OR '1'='1,拼接出来的 SQL 就变成了SELECT id FROM user_info WHERE username='' OR '1'='1' AND password='...',直接绕过校验。用 escape 函数把特殊字符转义掉,能挡住绝大部分注入攻击。
第三,mysql_store_result 和 mysql_free_result 必须配对。每次查询完都要把结果集释放掉,否则内存会持续泄漏。在长时间运行的程序里,这个泄漏会累积到程序崩溃。
3.3 登录按钮的响应逻辑
在对话框类里添加一个成员变量CDbHelper m_db;,然后在 OnInitDialog 里初始化连接:
BOOL CLoginDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 尝试连接数据库 if (!m_db.Connect("127.0.0.1", "root", "your_password", "test_db")) { CString errMsg; errMsg.Format(_T("数据库连接失败:%s"), CString(m_db.GetLastError().c_str())); AfxMessageBox(errMsg); } return TRUE; }登录按钮的响应函数:
void CLoginDlg::OnBnClickedBtnLogin() { CString strUser, strPwd; GetDlgItemText(IDC_EDIT_USER, strUser); GetDlgItemText(IDC_EDIT_PWD, strPwd); // 空值校验 if (strUser.IsEmpty() || strPwd.IsEmpty()) { AfxMessageBox(_T("用户名和密码不能为空")); return; } // 长度校验 if (strUser.GetLength() > 50 || strPwd.GetLength() > 100) { AfxMessageBox(_T("输入长度超出限制")); return; } // 转换为 std::string(注意宽字符转窄字符) CT2A userA(strUser); CT2A pwdA(strPwd); if (m_db.CheckLogin(std::string(userA), std::string(pwdA))) { AfxMessageBox(_T("登录成功")); // 这里可以跳转到主界面 // CDialogEx::OnOK(); } else { AfxMessageBox(_T("用户名或密码错误")); // 清空密码框,方便重新输入 SetDlgItemText(IDC_EDIT_PWD, _T("")); GetDlgItem(IDC_EDIT_PWD)->SetFocus(); } }这里有个字符转换的细节:MFC 默认使用 Unicode 字符集,CString 是宽字符(wchar_t),而 MySQL C API 用的是窄字符(char)。所以需要转换。CT2A是 ATL 提供的转换宏,会自动处理编码转换。如果项目用的是多字节字符集,那就不需要转换,直接用strUser.GetBuffer()就行。
注意:CT2A 转换后的字符串生命周期只在当前作用域内有效,不要把它保存到全局变量里。
4. 常见问题排查与避坑指南
4.1 连接失败的五种典型情况
数据库连接失败是新手遇到最多的问题,没有之一。我把常见的失败原因整理成一张速查表:
| 错误信息关键词 | 可能原因 | 解决方法 |
|---|---|---|
| Can't connect to MySQL server | MySQL 服务没启动 | 在服务管理器里启动 MySQL 服务 |
| Access denied for user | 用户名或密码错误 | 检查连接参数,确认 root 密码 |
| Unknown database | 数据库名写错或不存在 | 在 Workbench 里确认数据库已创建 |
| Can't connect to MySQL server on '127.0.0.1' | 端口不对或被防火墙拦截 | 确认端口是 3306,检查防火墙规则 |
| 找不到 libmysql.dll | dll 没拷贝到输出目录 | 把 dll 拷到 exe 同级目录 |
还有一种情况是编译时报“无法打开 libmysql.lib”,这说明附加库目录配错了,或者 lib 文件和当前编译平台不匹配(32 位项目用了 64 位的 lib)。
4.2 中文乱码的根因分析
中文乱码的本质是编码不一致。数据从输入框到数据库,中间经过了多个环节,每个环节都有自己的编码方式:
- MFC 输入框:Unicode(宽字符)
- CT2A 转换:取决于系统区域设置,中文系统默认是 GBK
- MySQL C API:默认 latin1,需要手动设为 utf8mb4
- MySQL 表字段:建表时指定的字符集
- MySQL 服务端:配置文件里的 character-set-server
只要有一个环节不一致,就会出现乱码。最稳妥的做法是全部统一为 utf8mb4:
- 建表时指定
DEFAULT CHARSET=utf8mb4 - 连接后执行
SET NAMES utf8mb4 - 或者在 mysql_options 里设置
MYSQL_SET_CHARSET_NAME为utf8mb4 - CT2A 转换时指定代码页为 CP_UTF8:
CT2A userA(strUser, CP_UTF8);
我实测下来,最后这种组合最稳:数据库、表、连接全部 utf8mb4,CT2A 显式指定 CP_UTF8。
4.3 密码明文存储的安全隐患
Demo 里密码是明文存的,但实际项目绝对不能这么干。正确的做法是:
- 注册时:生成一个随机盐值(salt),把
password + salt做哈希(推荐 bcrypt 或 argon2),把哈希值和盐值一起存进数据库。 - 登录时:根据用户名取出盐值,把用户输入的密码加盐哈希,和数据库里的哈希值比对。
如果不想引入第三方哈希库,至少也要用 SHA-256 加盐。虽然 SHA-256 抗暴力破解能力不如 bcrypt,但比明文强太多了。
// 伪代码示意 std::string salt = GenerateRandomSalt(); std::string hash = SHA256(password + salt); // 存库:INSERT INTO user_info (username, password, salt) VALUES (?, ?, ?)实操心得:盐值长度建议至少 16 字节,每个用户独立生成,不要用固定盐。固定盐等于没加盐,彩虹表照样能破。
4.4 内存泄漏与资源释放
MySQL C API 有几个资源需要手动释放:
mysql_init返回的 MYSQL* 需要mysql_closemysql_store_result返回的 MYSQL_RES* 需要mysql_free_resultmysql_fetch_row返回的 MYSQL_ROW 不需要手动释放,它指向结果集内部的内存
在 Debug 模式下,VS 会在程序退出时输出内存泄漏报告。如果看到Detected memory leaks!,大概率是某个 MYSQL_RES 忘了释放。我的习惯是在每个mysql_store_result之后立刻写mysql_free_result,中间不要插入其他逻辑,避免忘记。
5. 功能扩展与优化方向
5.1 记住用户名功能的实现
“记住用户名”是个很实用的功能,实现起来也不复杂。思路是把用户名写进注册表或者配置文件,下次打开时自动填充。
用注册表的方式:
// 保存 HKEY hKey; RegCreateKeyEx(HKEY_CURRENT_USER, _T("Software\\MyLoginApp"), 0, nullptr, REG_OPTION_NON_VOLATILE, KEY_WRITE, nullptr, &hKey, nullptr); RegSetValueEx(hKey, _T("LastUser"), 0, REG_SZ, (const BYTE*)strUser.GetString(), (strUser.GetLength() + 1) * sizeof(TCHAR)); RegCloseKey(hKey); // 读取 TCHAR buf[256] = { 0 }; DWORD bufSize = sizeof(buf); DWORD type = REG_SZ; if (RegOpenKeyEx(HKEY_CURRENT_USER, _T("Software\\MyLoginApp"), 0, KEY_READ, &hKey) == ERROR_SUCCESS) { RegQueryValueEx(hKey, _T("LastUser"), nullptr, &type, (LPBYTE)buf, &bufSize); RegCloseKey(hKey); SetDlgItemText(IDC_EDIT_USER, buf); }用配置文件的方式更简单,直接读写 ini 文件:
WritePrivateProfileString(_T("Login"), _T("LastUser"), strUser, _T(".\\config.ini")); GetPrivateProfileString(_T("Login"), _T("LastUser"), _T(""), buf, 256, _T(".\\config.ini"));两种方式各有优劣:注册表更“正规”,但卸载时容易残留;ini 文件更直观,但容易被用户误删。我个人偏好 ini 文件,因为调试方便,出问题了直接打开看就行。
5.2 登录失败次数限制
为了防止暴力破解,可以加一个失败次数限制:连续失败 3 次就锁定账号 5 分钟。实现思路是在数据库里加两个字段:
ALTER TABLE user_info ADD COLUMN fail_count INT DEFAULT 0; ALTER TABLE user_info ADD COLUMN lock_until TIMESTAMP NULL;每次登录失败时fail_count + 1,达到阈值就设置lock_until = NOW() + INTERVAL 5 MINUTE。登录时先检查lock_until是否大于当前时间,如果是就拒绝登录。
这个逻辑放在 SQL 里做比放在 C++ 里做更可靠,因为 SQL 是原子的,不会出现并发问题。
5.3 从 Demo 到生产环境的差距
这个 Demo 跑通之后,距离生产环境还有不少距离。我列几个必须补上的点:
- 密码加密:前面说过了,必须做哈希加盐。
- 连接池:Demo 里是单连接,并发高了会排队。生产环境要用连接池。
- 日志记录:每次登录成功/失败都要记日志,方便审计和排查。
- 异常处理:网络断开、数据库重启这些情况要有兜底逻辑,不能直接崩溃。
- 界面美化:MFC 默认界面比较朴素,可以用自绘或者第三方库美化一下。
不过话说回来,Demo 的价值在于跑通流程、理解原理。把这些基础打牢了,往上加功能就是水到渠成的事。
6. 我个人在实际操作中的几点体会
最后分享几个我在实际项目中总结的小经验,都是文档里不会写的。
第一,连接字符串不要硬编码。我见过太多项目把数据库密码写死在代码里,换环境就要重新编译。正确的做法是放在配置文件里,程序启动时读取。哪怕是个 Demo,养成这个习惯也是好的。
第二,编译平台要统一。32 位和 64 位的库不能混用,这个坑我踩过不止一次。建议在项目属性里明确指定目标平台,不要用“Any CPU”这种模糊的选项。
第三,测试用例要覆盖边界情况。空用户名、超长用户名、特殊字符、SQL 注入尝试,这些都要测一遍。我习惯在数据库里预置几个测试账号,包括一个用户名为空的、一个密码为空的,专门用来验证边界逻辑。
第四,libmysql.dll 的版本要和 lib 文件一致。有时候从不同地方下载的 dll 和 lib 版本不匹配,编译能过但运行时报错。最保险的做法是只用 Connector/C 安装目录里的那一套,不要从别处拷贝。
这套流程我前后配过不下十次,每次换新机器都要重新走一遍。现在把它完整写下来,一方面是给自己留个备忘,另一方面也希望能帮到正在踩坑的人。MFC 虽然老,但把基础打扎实了,后面学 Qt 或者 Web 开发,很多概念是相通的。