ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从TCP通讯录课设看Socket编程与应用层协议设计

从TCP通讯录课设看Socket编程与应用层协议设计 简介基于TCP协议的通讯录网络应用课程设计报告是一份面向计算机专业学生和初学者的课程设计范文围绕Socket编程实现通讯录客户端与服务端功能涵盖系统需求、功能分析、详细设计、总结与完整代码能帮助读者掌握TCP网络编程和结构化设计流程。压缩包仅1个docx文档大小270KB内容结构完整从目的意义到附录代码一应俱全。目前已有537人学习浏览适合作为计算机网络课程设计参考或毕设选题前的技术验证。文档详细展示了套接字接口的使用方法、C对话框编程技巧以及添加、删除、浏览、退出等功能的实现思路并配有客户端与服务器流程图和运行结果截图便于对照理解与二次开发。1. TCP 通讯录的骨架把一台服务器和一群客户端连起来的课设这份课程设计报告看起来是教学产物代码量不大但里面藏着一个完整的 C/S 架构 TCP 网络应用的运转逻辑服务端在 10011 端口监听客户端连接后用自定义命令号REQ_ADD、REQ_DEL、REQ_BROW与服务端交互联系人数据在内存里用 std::list 维护同时以纯文本形式追加到 4.txt 文件。没有引入任何第三方网络库全部基于 Windows Socket API所以很适合拿来理解 TCP 协议在真实程序里的落地方式。如果你正在写计算机网络课设或者多年后想快速回忆 CSocket 编程的要点这份代码和下面这些拆解都能用得上。2. 命令号 结构体自定义应用层协议的拆解2.1 TCP 是字节流业务边界要靠应用层协议来划TCP 提供的是一条可靠的字节流通道它本身不知道什么是添加联系人、什么是删除联系人。客户端发过来 4 个字节0x1111服务端最多只能确认收到 4 字节无法推断这是要执行哪条业务。所以两端必须在 Socket 之上约定一套消息格式这就是应用层协议的工作。课程设计里用的方案很直白先发一个固定 4 字节的 int 命令号再发一个固定大小的结构体。命令号告诉服务端接下来我要干什么结构体则是这条命令的附加数据。这种命令 参数的模式是所有 RPC 和网络应用协议的雏形。2.2 命令号设计魔数与顺序递增的取舍enum { PORT10011 }; enum { REQ_ADD0x1111, REQ_DEL, REQ_MOD, // 预留未实现 REQ_FIND, // 预留未实现 REQ_BROW, };命令号并不是从 0 开始顺序编号而是从0x1111起步。0x1111 这样的魔数在调试时一眼能认出来抓包看到11 11 00 00就知道是添加操作。顺序递增之后REQ_DEL 是 0x1112REQ_BROW 是 0x1114。REQ_MOD 和 REQ_FIND 在枚举里保留了编号但没有实现分支这在课程设计里是合理的——先定协议框架再逐步填实现后续加功能不用改已有编号避免破坏历史数据的兼容性。2.3 DATA 结构体直接用内存布局当传输格式联系人数据用结构体承载通过sizeof(DATA)和Receive(d, sizeof(d))一次收完。typedef struct SStud { int nNumb; // 学号4 字节 char sName[20]; // 姓名 char sAge[20]; // 年龄 char sSex[4]; // 性别 char sUnit[20]; // 单位 char sAdd[20]; // 手机号码 } DATA;字段全部用固定长度 char 数组而不是 std::string原因在于网络传输需要确定性的字节长度。std::string 内部有指针和动态堆区不能直接跨进程传输char 数组则可以通过sizeof精确计算大小服务端和客户端只要编译选项一致sizeof(DATA)就是同样的值。按默认对齐规则计算4 20 20 4 20 20 88 字节且每个字段的偏移量都天然对齐到 4 的倍数不需要填充字节。偏移量 字段 类型 字节数 0 nNumb int 4 4 sName char[20] 20 24 sAge char[20] 20 44 sSex char[4] 4 48 sUnit char[20] 20 68 sAdd char[20] 20 总计 88这里有一个隐藏约定发送端和接收端必须是相同的字节序和相同的结构体对齐方式。课设运行在 x86 平台上小端字节序一致所以裸传结构体没问题。但如果你把服务端换成 ARM 或 SPARC 架构的机器int 的字节序反转解析结果就完全错乱。工程上通常的做法是把每个字段转成网络字节序htonl/htons或者干脆用序列化框架如 protobuf替代裸结构体。课设阶段用裸结构体胜在直观。2.4 一个完整的收发时序添加一条联系人客户端添加联系人时先发命令号再发结构体// 客户端 Input() 中的关键代码 int nCmd REQ_ADD; m_sock.Send(nCmd, sizeof(nCmd)); // 第 1 段命令号 4 字节 m_sock.Send(d, sizeof(d)); // 第 2 段联系人数据 88 字节 Print(); // 发送后立刻请求浏览全量列表服务端收到后先取命令号再按分支接收结构体int nCmd 0; if (pSocka-Receive(nCmd, sizeof(nCmd)) 0) return FALSE; switch (nCmd) { case REQ_ADD: return AddData(pSocka); // 内部再 Receive(d, sizeof(d)) case REQ_DEL: return Delete(pSocka); case REQ_BROW: return Browse(pSocka); }Receive是阻塞调用服务端在收到数据之前会一直等在这里所以发送顺序就是接收顺序。TCP 保证字节流有序到达因此先命令号后数据的时序不会乱。这里真正要注意的边界情况是半包如果客户端只发了 4 字节命令号就暂停服务端第一次Receive读到的长度可能为 4但OnReceive没有判断返回值是否等于sizeof(nCmd)直接进了switch而后续的Receive(d, sizeof(d))可能只读到一部分数据0判断又只处理了断开连接。这种问题在局域网低延迟环境下很难触发但放到公网或高负载服务端上就会成为偶发性的解包错误。提示严谨的做法是记录每次Receive的实际返回值循环接收直到凑足一个完整的命令号 结构体或者发送端在数据前面加上消息总长度字段。3. CSocket 服务端与客户端的生命周期管理3.1 WSAStartup 与监听套接字的创建服务端和客户端的第一步都是初始化 Windows Socket 环境这步在工程里常被忽略但少了它任何 Socket 调用都会返回SOCKET_ERROR。void CStudent::Start() { WSADATA wd; WSAStartup(0x0202, wd); // 请求 2.2 版本的 Winsock 库 if (!m_sock.Create(PORT)) { // 服务端绑定 10011 端口 cout 端口创建失败 WSAGetLastError() endl; return; } m_sock.Listen(); // 进入监听状态 CSocket socka; sockaddr_in sa; int nLen sizeof(sa); while (m_sock.Accept(socka, (sockaddr*)sa, nLen)) { cout 用户登录 inet_ntoa(sa.sin_addr) htons(sa.sin_port) endl; while (OnReceive(socka)) ; // 直到客户端断开才退出内层循环 socka.Close(); } }Create(PORT)完成的是 socket() bind() 两步Listen()把套接字置为被动监听状态Accept从连接队列里取出一个已完成三次握手的客户端连接返回给新的CSocket socka。这里m_sock是监听套接字只负责接受新连接socka是会话套接字专门和当前这个客户端通信。inet_ntoa和htons把二进制的 IP 与端口转成可读格式便于观察哪个客户端登录了。3.2 客户端连接与循环菜单客户端代码相对直白void CStudent::Start() { WSADATA wd; WSAStartup(0x0202, wd); if (!m_sock.Create()) { // 客户端不需要绑定具体端口 cout 网络启动失败 WSAGetLastError() endl; return; } if (!m_sock.Connect(127.0.0.1, PORT)) { // 课设里应填服务端 IP cout 连接网络服务器时失败 WSAGetLastError() endl; return; } while (Menu()); // 菜单循环返回 0 退出 }Create()不带参数时系统自动为客户端分配一个临时端口不关心具体数值。Connect触发三次握手目标地址是服务端 IP课设环境里10.201.1.55是机房局域网地址自己测试直接用127.0.0.1即可。连接成功后进入菜单循环用户输入 1 查看、2 添加、3 删除、0 退出每次操作都会走一次发送命令号 发送数据 等待响应的流程。3.3 OnReceive 返回值的状态机作用服务端的OnReceive返回值非常关键它决定了当前这个客户端连接是否要继续保持BOOL CStudent::OnReceive(CSocket *pSocka) { int nCmd 0; if (pSocka-Receive(nCmd, sizeof(nCmd)) 0) return FALSE; // 客户端关闭或异常断开连接 switch (nCmd) { case REQ_ADD: return AddData(pSocka); case REQ_DEL: return Delete(pSocka); case REQ_BROW: return Browse(pSocka); } return FALSE; // 无法识别的命令断开连接 }当客户端执行完操作后输入 0 退出Socket 关闭Receive返回 0 或SOCKET_ERROROnReceive返回 FALSE内层while退出调用socka.Close()释放会话连接。这个模式本质上是一个基于返回值驱动的状态机TRUE 继续读下一条命令FALSE 终止当前会话。理解了这一点后面要在服务端加入对REQ_FIND查询命令的支持时只需要在switch里加一个case分支。3.4 用错误码定位连接问题WSAGetLastError()返回的是 Winsock 错误码课设代码里只打印数字不解释含义实操时会一头雾水。以下三个最常见错误码 含义 排查方向 10048 端口被占用 换端口或检查是否残留 Listen 进程 10061 目标主机拒绝连接 服务端没启动或 IP/端口写错 10054 连接被对端重置 客户端异常退出服务端继续收数据提示连接失败时先用netstat -ano | findstr 10011确认服务端是否真的在监听再确认防火墙是否放行了 TCP 10011 端口。机房环境下最常见的原因是两台机器不在同一网段。4. std::list、文件持久化与删除操作的边界4.1 用 std::list 而不是数组的理由服务端代码里用std::listDATA m_list存联系人而不是数组或std::vector。联系人操作以删除和遍历为主list 在中间插入和删除都是 O(1) 的节点指针操作不需要搬移后续元素数组删除中间元素要memmove移动大量数据。联系人规模在几十条这个量级时vector 和 list 的性能差异几乎可以忽略所以这里的选型更多是从课程教学角度出发——展示链表的典型应用场景。实际操作中 list 有个明显的坑erase之后迭代器失效。原代码的删除流程找到了节点后先erase再返回std::listDATA::iterator it m_list.begin(); while (it ! m_list.end()) { if (it-nNumb nNumb) { // 按学号查找 m_list.erase(it); // 删除节点 return true; } it; }这段代码在找到目标后立即 erase 并返回没有再使用该迭代器所以是安全的。但如果你在遍历的同时做其他操作比如删除后再打印 it-sName就是典型的悬空指针访问。稳妥的做法是先记录下一个节点位置再删除auto next std::next(it); m_list.erase(it); it next;4.2 文件持久化追加写与全量重写的混用添加联系人时服务端把数据以追加方式写入 4.txtFILE *fp fopen(4.txt, a); // a 追加模式 if (fp NULL) { printf(打开文件失败); return FALSE; } fprintf(fp, \n%d\t%s\t%s\t%s\t%s\t%s\n, d.nNumb, d.sName, d.sAge, d.sSex, d.sUnit, d.sAdd); fclose(fp);a模式保证每次写入都落在文件末尾不会覆盖已有内容适合只增不改的场景。TAB 分隔的纯文本格式可读性强用 Excel 或文本编辑器都能直接查看也方便写答题脚本做自动化测试。但追加写不适用于删除场景删除一条记录后4.txt 里那条记录依然存在内存链表和磁盘文件就出现了不一致。4.3 Browse 里重建文件的逻辑隐患Browse函数本来只负责把联系人列表发给客户端但原代码混杂了文件重写逻辑而且这个问题藏得很深BOOL CStudent::Browse(CSocket *pSocka) { system(cls); int n m_list.size(); pSocka-Send(n, sizeof(n)); // 先发条数 std::listDATA::iterator it m_list.begin(); while (it ! m_list.end()) { DATA d *it; pSocka-Send(d, sizeof(d)); // 发一条记录 FILE *fp; int n m_list.size(); fp fopen(4.txt, w); // 每次循环都重开文件 fprintf(fp, \n%d\t%s\t%s\t%s\t%s\t%s\n, d.nNumb, d.sName, d.sAge, d.sSex, d.sUnit, d.sAdd); fclose(fp); } return TRUE; }问题有两个。第一循环体内用w模式反复打开同一个文件每处理一条记录就会把文件清空重写一次最终文件里只保留下最后一次循环写入的记录。m_list 里有 5 条联系人浏览一次就只剩最后 1 条。第二代码里的注释写将某项信息删除后更新文件但 Browse 是浏览操作不是删除操作把文件重写逻辑塞在这里语义完全错位。正确做法是把文件写操作移到循环外先打开一次循环内只 fprintf最后统一 fcloseFILE *fp fopen(4.txt, w); if (fp ! NULL) { for (auto d : m_list) { fprintf(fp, %d\t%s\t%s\t%s\t%s\t%s\n, d.nNumb, d.sName, d.sAge, d.sSex, d.sUnit, d.sAdd); } fclose(fp); }更稳妥的方案是先写临时文件再 rename 覆盖防止写一半断电导致原文件损坏方案 写入方式 风险点 适用场景 追加写a 每添加一次追加一行 删除后文件残留 只增不改的日志类数据 全量重写w 先打开一次循环写 写中断损坏原文件 联系人数据规模较小 临时文件 rename 写 temp 后原子替换 需要确保 temp 清理 对数据完整性有要求的场景4.4 删除操作与文件持久化的缺口Delete 函数从m_list中删掉了对应节点但文件没有同步更新。更麻烦的是CStudent::Load()和Save()函数在代码里都是空实现服务端重启后m_list完全为空4.txt 里残留的记录不会被加载回来。这意味着整个程序的持久化是只写不读的单向通道记录丢失只是时间问题。要在课设范围内补上这个洞最简单的做法是在 Delete 成功后调用一次全量重写把 m_list 里所有剩余节点重新写入文件。文件 IO 在这个数据规模下几乎不耗时没必要引入 SQLite 或 JSON 库但顺序必须是先改内存再落盘且要保证写文件失败时内存数据不回滚。5. 把课设变成顺手工具输入校验、确认机制与协议头扩展课程设计的要求里明确写了程序要有健壮性不能因为输入错误中断执行但代码里cin 仍然有不少薄弱点输入非数字时cin进入 fail 状态后续所有读取都会失效sName没有限制字符串长度超长输入会溢出缓冲区。至少要加一层防御int inputNumb; while (!(cin inputNumb)) { cin.clear(); // 清除 fail 位 cin.ignore(numeric_limitsstreamsize::max(), \n); cout 请输入合法数字; }删除确认机制已经实现了用的是getch()做单字符响应不要求回车。注意这里只对Y和y放行输入其他任意字符都不发送 REQ_DEL这个设计是合格的。但确认信息打印在发送之前客户端不会收到服务端删除是否成功的回执也就无法判断删除操作到底是成功了还是学号不存在。要给客户端反馈可以约定一个 1 字节的 ACK// 服务端 Delete 中 bool found false; // ... 查找并 erase设置 found true char ack found ? 1 : 0; pSocka-Send(ack, sizeof(ack));客户端接收后根据 ACK 值提示删除成功或未找到该学号。这是对原协议的最小补充不改变命令号枚举只增加一条响应包格式。最后建议在正式做网络模块时把协议头升级为带长度和版本的格式而不是依赖固定大小的结构体struct NetMsgHeader { unsigned short version; // 协议版本从 1 开始 unsigned short cmd; // 命令号 unsigned int length; // 后续数据字节数 };收包时先读 8 字节头部根据 length 字段循环接收完整包体如果收到不认识的高版本号直接返回协议错误。这样后续增加字段比如加一个备注字段只需升版本号老客户端不会被新结构体撑爆缓冲区。调试时可以在客户端发送前把数据用十六进制打印出来确认命令号和结构体字节数与预期一致unsigned char *p (unsigned char*)d; for (int i 0; i sizeof(d); i) { printf(%02X , p[i]); if ((i 1) % 16 0) printf(\n); }对照 2.3 节的字段偏移表你能清楚看到 nNumb 的 4 字节小端排列和每个 char 数组的内容排查结构和字节序问题会比只断点看变量快得多。本文还有配套的精品资源点击获取
返回列表