ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++连接MySQL与Socket:手写考试系统核心实战

C++连接MySQL与Socket:手写考试系统核心实战 简介面向具备初步程序设计经验的信息类专业学生与从业者这份实训文档以 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负责给句柄分配内存失败返回 NULLmysql_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排序与比较异常控制台输出编码936GBK显示乱码但数据正确// 连接成功后立即执行顺序不能颠倒 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 BOMVS 对无 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用 C 代码执行建表时mysql_query一次只能跑一条语句多条 SQL 要分开调用或者用CLIENT_MULTI_STATEMENTS标志开启多语句模式再配mysql_next_result遍历结果集实际写业务不用这么绕一条条来更清晰。3.2 参数化查询封装避免拼接引号踩坑新手最容易犯的错误是用字符串拼 SQL题目里带个单引号就崩了或者被注入。合格做法是用mysql_real_escape_string或预编译语句。下面封装一个带参数转义的插入函数这种写法在实训里够用边界也清楚。// 转义后拼 SQL注意转义缓冲要开到 2*len1 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::vectorint 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::vectorPaperItem paper, const std::vectorchar 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 往往比现场调半小时更快。本文还有配套的精品资源点击获取
返回列表