简介:面向具备初步程序设计经验的信息类专业学生与从业者,这份实训文档以 Visual Studio 连接 MySQL 为主线,用 C/C++ 串联数据库操作、C/S 架构与网络通信,帮助读者从环境搭建走向可运行的考试系统。压缩包内含 1 个 docx 文件,约 12KB,轻量易读,可直接作为两周实训的任务书与过程参考。内容按天拆解:先完成库表创建与数据库函数调用,实现账号注册、登录验证及教师与学生身份识别;教师端可对用户表、试题表增删改查,按题目数量随机不重复抽题组卷,学生作答后自动评分。后半程引入 socket 开发,说明 main 函数中的通信流程、收发函数改造,以及客户端登录信息传递、服务器比对数据库并返回应答的思路,并讨论独立工程与 C/S 双工程的组织方式。文档同时给出每日时间分配、成果展示规范与验收评估方式。目前已有 62 人学习,适合想用一个完整项目打通 C++ 数据库编程与网络编程入门的人参考。
1. 为什么这套考试系统值得用 C++ 手写一遍
很多同学第一次听说「C++ 连 MySQL 做一个考试系统」,第一反应是去找现成的成品源码改改交差。但真正拆过这类实训项目的人会发现,它最有价值的地方恰恰不是最终那个能登录、能抽题、能评分的界面,而是中间那一串被逼着搞明白的底层环节:连接字符串怎么写、字符集为什么乱码、抽题去重靠 SQL 还是靠内存、C/S 两端的数据包到底长什么样。这些东西在框架里被封装得干干净净,你在应用层永远碰不到,可一旦线上出问题,能定位到根因的往往就是这些「看不见」的部分。
这套实训的设计思路很务实:先用两周时间,把「Visual Studio + MySQL + C++」这条链路打通,再在这个基础上叠加 socket 网络通信,把单机程序升级成 C/S 架构。教师端负责用户表和试题表的增删改查、设置抽题数量,学生端负责答题、自动评分,中间再加一个服务器做身份识别和指令应答。它适合有一定 C/C++ 基础、但没正经碰过数据库和网络编程的同学,也适合已经工作、想回头补齐「数据库访问 + socket」这块短板的从业者。下面的内容不按教材目录走,而是按我实际动手时会踩的坑的顺序来铺。
2. VS 连接 MySQL 的环境搭建与字符集处理
2.1 MySQL Connector/C++ 与 VS 工程配置
在 Windows 上让 C++ 访问 MySQL,主流做法是用官方提供的 MySQL Connector。下载时注意区分「Connector/C」(纯 C 接口,即 libmysqlclient)和「Connector/C++」(基于 JDBC 风格的 C++ 封装)。教材和实训里多数用的是前者,因为函数少、好记、示例多。装完之后,真正让人卡住的是 Visual Studio 的工程配置,而不是代码本身。
在 VS 里右键项目 → 属性,需要动三个地方:C/C++ → 常规 → 附加包含目录,填 MySQL 安装目录下的include;链接器 → 常规 → 附加库目录,填lib;链接器 → 输入 → 附加依赖项,填libmysql.lib。注意平台要选对,x64 工程必须配 x64 的库,混用会报LNK1112 模块计算机类型冲突。
// main.cpp:最小可运行连接测试,先确认环境通了再写业务 #include <winsock2.h> #include <mysql.h> #include <cstdio> int main() { MYSQL conn; // 连接句柄 mysql_init(&conn); // 初始化句柄,必须在 connect 之前 // 关键参数顺序:句柄 / 主机 / 用户名 / 密码 / 库名 / 端口 / socket / 标志 if (!mysql_real_connect(&conn, "127.0.0.1", "root", "your_pwd", "exam_db", 3306, nullptr, 0)) { printf("连接失败: %s\n", mysql_error(&conn)); return -1; } printf("连接成功, 客户端版本: %s\n", mysql_get_client_info()); mysql_close(&conn); return 0; }这段代码的作用是把「编译期链接」和「运行期连接」两件事分开验证。mysql_init负责给句柄分配内存,失败返回 NULL;mysql_real_connect的第 6 个参数是端口,第 7 个是 Unix socket 路径,Windows 上填nullptr即可;第 8 个是客户端标志,填0表示默认。如果这里报连接失败,先看错误码:2003是服务没起或端口不通,1045是账号密码错,1049是库名不存在,这三个能覆盖绝大多数情况。
2.2 中文乱码的根因与连接层修复
乱码几乎是人人都要撞一次的墙。根因在于 MySQL 服务端的默认字符集、客户端驱动的字符集、以及 C++ 源码文件的编码三者不一致。GBK 的源码把中文传给 UTF-8 的表,存进去就成乱码了。最省事的修法是在连接建立后立刻统一字符集,而不是去改服务端配置。
| 环节 | 常见取值 | 不一致时的表现 |
|---|---|---|
| 源码文件编码 | GBK / UTF-8 with BOM | 编译时就已损坏 |
| 客户端连接字符集 | utf8mb4 | 查询结果为问号 |
| 库/表字符集 | utf8mb4 | 排序与比较异常 |
| 控制台输出编码 | 936(GBK) | 显示乱码但数据正确 |
// 连接成功后立即执行,顺序不能颠倒 if (mysql_set_character_set(&conn, "utf8mb4") != 0) { printf("设置字符集失败: %s\n", mysql_error(&conn)); } // 验证当前生效的字符集,出现问号时先跑这一句 MYSQL_RES *res; mysql_query(&conn, "SHOW VARIABLES LIKE 'character_set%'"); res = mysql_store_result(&conn); while (MYSQL_ROW row = mysql_fetch_row(res)) { printf("%s = %s\n", row[0], row[1]); } mysql_free_result(res);mysql_set_character_set必须在mysql_real_connect之后调用,否则连接还在默认字符集上。如果数据已经以错误的字符集写进去了,改连接参数是救不回来的,得用CONVERT(BINARY CONVERT(col USING latin1) USING utf8mb4)这类方式做数据迁移,这是另一个话题。控制台那块,如果发现数据库里查出来是对的、打印出来是乱的,那是cmd的代码页问题,在执行程序前跑一次chcp 65001就能对齐。
提示:源码文件统一存成 UTF-8 with BOM,VS 对无 BOM 的 UTF-8 中文常量识别不稳定,容易出现莫名奇妙的截断。
3. 用 C++ 封装 MySQL 的增删改查接口
3.1 用户表、试题表的结构设计与建表语句
表结构设计得好不好,直接决定后面抽题和评分顺不顺。用户表至少要包含 id、账号、密码、角色身份四列,角色用一个字符或整数区分即可,别搞复杂枚举。试题表需要 id、题目、四个选项、标准答案、分值,选项字段名建议用opt_a到opt_d,避免用option这种和 SQL 关键字撞车的词。下面这段是常见的建表写法。
CREATE TABLE user ( id INT AUTO_INCREMENT PRIMARY KEY, account VARCHAR(32) NOT NULL UNIQUE, -- 账号唯一,防重复注册 password VARCHAR(64) NOT NULL, -- 存哈希更安全,实训可存明文 role TINYINT NOT NULL DEFAULT 0,-- 0 学生 1 教师 name VARCHAR(32) DEFAULT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE question ( id INT AUTO_INCREMENT PRIMARY KEY, content VARCHAR(255) NOT NULL, opt_a VARCHAR(128) NOT NULL, opt_b VARCHAR(128) NOT NULL, opt_c VARCHAR(128) NOT NULL, opt_d VARCHAR(128) NOT NULL, answer CHAR(1) NOT NULL, -- A/B/C/D score INT NOT NULL DEFAULT 1 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;用 C++ 代码执行建表时,mysql_query一次只能跑一条语句,多条 SQL 要分开调用,或者用CLIENT_MULTI_STATEMENTS标志开启多语句模式再配mysql_next_result遍历结果集,实际写业务不用这么绕,一条条来更清晰。
3.2 参数化查询封装,避免拼接引号踩坑
新手最容易犯的错误是用字符串拼 SQL,题目里带个单引号就崩了,或者被注入。合格做法是用mysql_real_escape_string或预编译语句。下面封装一个带参数转义的插入函数,这种写法在实训里够用,边界也清楚。
// 转义后拼 SQL,注意转义缓冲要开到 2*len+1 bool insertQuestion(MYSQL *conn, const std::string &content, const std::string &a, const std::string &b, const std::string &c, const std::string &d, const std::string &ans, int score) { char esc[1024]; mysql_real_escape_string(conn, esc, content.c_str(), content.size()); char sql[2048]; snprintf(sql, sizeof(sql), "INSERT INTO question(content,opt_a,opt_b,opt_c,opt_d,answer,score)" " VALUES('%s','%s','%s','%s','%s','%s',%d)", esc, a.c_str(), b.c_str(), c.c_str(), d.c_str(), ans.c_str(), score); if (mysql_query(conn, sql) != 0) { printf("插入失败: %s\n", mysql_error(conn)); return false; } return mysql_affected_rows(conn) > 0; // 返回值判断是否真的写进去了 }mysql_affected_rows返回上一次增删改影响的行数,插入成功是 1,更新时如果值没变可能返回 0,这点在写「修改成功」提示时要留意,别把「值没变化」误报成失败。mysql_real_escape_string必须传入连接句柄,因为它依赖当前连接的字符集来转义,传 NULL 是按默认字符集处理,在 utf8mb4 下可能有边界问题。
3.3 查询结果的遍历与内存释放
mysql_query执行 SELECT 后,结果集不在本地,需要用mysql_store_result一次性拉回来,或者用mysql_use_result逐行取。前者占内存但能立刻知道行数,后者省内存但必须读完才能再发查询。实训数据量小,直接用store_result。
MYSQL_RES *res = mysql_store_result(conn); if (res == nullptr) { // 查询无结果集或出错 printf("取结果失败: %s\n", mysql_error(conn)); return; } int rows = mysql_num_rows(res); // 行数,配合随机抽题用 MYSQL_FIELD *fields = mysql_fetch_fields(res); for (int i = 0; i < mysql_num_fields(res); i++) printf("%s\t", fields[i].name); printf("\n"); MYSQL_ROW row; while ((row = mysql_fetch_row(res)) != nullptr) { for (unsigned i = 0; i < mysql_num_fields(res); i++) printf("%s\t", row[i] ? row[i] : "NULL"); // 字段可能为 NULL printf("\n"); } mysql_free_result(res); // 必须释放,否则内存持续增长mysql_fetch_row每次返回一行,元素是char*,已转为字符串形式,NULL字段要单独判断,直接 printf 会崩。mysql_free_result是强制动作,在循环里反复查询又不释放,跑一会儿内存就上去了。字段名通过mysql_fetch_fields拿到,写通用打印函数时很有用。
4. 随机抽题去重、自动评分与 C/S 通信实现
4.1 按数量随机不重复抽题的两种思路
抽题不重复是这道题的核心算法点。最容易想到的是ORDER BY RAND() LIMIT n,它确实能一次拿到 n 条不重复记录,但在数据量大时性能差,因为要先给每行生成随机数再排序。另一种是先在内存里随机取行号,再按 id 查,适合题量小但抽题频繁的场景。实训题量通常几十到几百,两种都行,重点是要能说清区别。
// 思路二:先取全部 id,打乱后取前 n 个,再逐条查询 std::vector<int> ids; mysql_query(conn, "SELECT id FROM question"); MYSQL_RES *res = mysql_store_result(conn); MYSQL_ROW row; while ((row = mysql_fetch_row(res)) != nullptr) ids.push_back(atoi(row[0])); mysql_free_result(res); if ((int)ids.size() < needCount) { // 题量不够要提前拦截 printf("题库不足, 当前 %zu 题, 需要 %d 题\n", ids.size(), needCount); return; } std::shuffle(ids.begin(), ids.end(), std::mt19937{std::random_device{}()}); ids.resize(needCount); // 截断后天然不重复为什么截断就天然不重复?因为std::shuffle是对一个没有重复元素的数组做全排列,取前 n 个自然互异。这里用std::mt19937而不是rand(),是因为rand()的随机性和实现相关,mt19937是标准定义好的梅森旋转算法,分布更均匀。参数needCount就是教师端设置的题目个数,要在服务器端做一次上限校验,超过题库总数就直接拒绝,别让它返回一个残缺的试卷。
4.2 学生答案提交与按分值自动评分
评分逻辑本身不复杂,难点在于把答案和分值对应起来。常见做法是抽题时把id、answer、score一起带回来,存在一个结构体数组里,学生提交答案后逐题比对。下面这段是评分核心。
struct PaperItem { int id; char answer; int score; }; int grade(const std::vector<PaperItem> &paper, const std::vector<char> &studentAns) { int total = 0; for (size_t i = 0; i < paper.size(); i++) { // 统一转大写,避免学生输入小写被判错 char a = toupper((unsigned char)studentAns[i]); if (a == paper[i].answer) total += paper[i].score; } return total; }toupper前要转成unsigned char,这是处理负值字符时的标准做法,直接传char在部分字符集下有未定义行为。评分只依赖标准答案和分值,和题目内容无关,所以完全可以把答案数组提前算出来,网络传输时只传学生答案,减少数据量。
4.3 socket 通信流程设计与身份识别协议
C/S 模式这条链路,最容易乱的是「客户端发什么、服务器回什么」的约定。建议一开始就定好一个简单的协议格式,比如「命令字 + 数据」用空格或竖线分隔,别用二进制,调试时抓包一看就懂。服务器主循环的骨架如下。
// 服务器:登录请求格式 "LOGIN|账号|密码" char buf[512] = {0}; recv(clientSock, buf, sizeof(buf) - 1, 0); std::string msg(buf); if (msg.rfind("LOGIN|", 0) == 0) { // 解析出账号密码,查 user 表比对 // 查到教师返回 "OK|TEACHER",学生返回 "OK|STUDENT" // 失败返回 "FAIL" std::string resp = checkLogin(conn, msg); send(clientSock, resp.c_str(), (int)resp.size(), 0); }recv的返回值要判断,返回 0 表示对端关闭连接,返回 -1 表示出错,只有大于 0 才是有数据。这里用rfind("LOGIN|", 0) == 0判断前缀,比substr拼接更直接。指令分发用 if-else 或 switch 都行,重点是别把业务逻辑塞在recv里,一旦收到不完整的分包就麻烦了。如果要做双向通信,服务器处理完一条命令后要能继续收下一条,客户端也要有对应的发送-接收配对,否则会出现一方阻塞在recv上等不到回应的情况。
注意:
send和recv都不保证一次发完或收完,长度大的数据要循环处理,实训里的小消息可以简化,但得知道简化的前提是数据量小于缓冲区。
5. 多工程组织与演示前必查的几处细节
5.1 单机版与 C/S 版共用代码的组织方式
实训里最灵活的点是:内容 3 的完整功能可以做成一个独立工程,内容 4 的 C/S 再拆成客户端和服务器两个工程,一共三个。这样演示时先跑单机版证明核心逻辑没问题,再跑 C/S 版证明网络这部分通了,风险最低。服务器复用单机版代码时,常见的做法是把数据库操作层抽成一个.cpp/.h对,两边#include同一份文件,而不是复制粘贴两份——复制出来的两份后面改一处忘一处,答辩时最容易被问到。
共享的封装层建议按职责切三块:连接管理(打开/关闭/重连)、用户操作(注册、校验、改角色)、试题操作(增删改查、随机抽题)。这样服务器端只需要在这三层上面加一层收发和协议解析,逻辑上很干净。用 VS 的多项目解决方案来管,右键解决方案 → 添加 → 现有项目,把单机版里的公共源文件以「链接」方式加到 C/S 工程里,可以避免文件重复。
5.2 演示时的验证顺序与常见故障排查
演示前按固定次序自检,能省掉大量当场翻车。第一步确认 MySQL 服务在跑,用mysql -u root -p能登录;第二步跑单机版,确认能注册、登录、教师增删改查、学生抽题评分全通;第三步起服务器,用netstat -ano | findstr 8888看端口有没有监听上;第四步客户端连服务器,登录成功后再验教师设题数、学生答题两个分支。
| 现象 | 大概率原因 | 处置 |
|---|---|---|
| 连接报 2003 | 服务没起或端口占用 | 查服务状态、换端口 |
| 中文存进去是乱码 | 连接字符集未设置 | mysql_set_character_set |
| 客户端连不上服务器 | 防火墙拦了或 IP 写错 | 本机用 127.0.0.1 试 |
| 收到指令无响应 | 协议前缀对不上 | 打印收到的原始报文 |
| 运行一段时间内存涨 | 结果集没释放 | 检查mysql_free_result |
排查网络问题时,先用本机回环地址验证协议和逻辑,排除了网络因素再换 IP 去别的机器上试。用Wireshark或直接在收发函数入口打印日志,能最快定位到底是发错了还是收错了。上面这张表里的五类问题,基本能覆盖演示现场八成的突发状况,剩下的多半是环境没配干净——重装一次 Connector 往往比现场调半小时更快。
本文还有配套的精品资源,点击获取