简介:面向Windows平台网络编程初学者与具备C++基础的MFC开发者的一份TCP通信C/S架构示例代码,围绕Socket套接字与CSocket类展开,可用于理解客户端主动发起连接、服务器监听并响应的完整交互流程,也可作为课程实验、课程设计或自学练手素材。压缩包共4个文件,包含2个cpp源文件与2个h头文件,体积约6KB,分别对应客户端连接对话框和服务器端对话框的实现,便于对照查看界面交互、套接字对象创建及消息处理;资源规模精简,重点突出两端关键交互。已有1265人学习下载。代码覆盖Winsock初始化、绑定监听、接受连接、连接服务器以及数据收发与关闭释放等环节,读者可从中梳理MFC封装下TCP通信的调用顺序、对象职责划分和常见排错思路,并在此基础上改造成符合自身需求的C/S通信程序。
1. 从“能连上”到“能交付”:MFC TCP Socket 的 C/S 程序到底要解决什么
做 MFC TCP Socket 的 C/S 程序,最容易出现的误判是客户端弹出“连接成功”,就以为需求完成了。真正要交付的是一套能稳定收发、能处理粘包、断线能恢复、界面不卡死的双端程序。它适合课程设计、毕设里需要演示 Socket 通信、消息协议、线程与 MFC 界面联动的场景,也适合作为从业者改写小型局域网数据采集上位机的骨架。反直觉的地方在于,难点不在connect和accept,而在消息边界、对象生命周期和 UI 线程。把这三件事讲清楚,程序才从“演示能跑”变成“答辩敢现场断网重连”。
2. 总体架构与技术选型:C/S 两端、消息循环与三类线程的职责划分
2.1 需求拆解:连接、收发、界面、异常四条主线
这套程序的原始需求很朴素:服务端监听端口,多个客户端接入,双方按消息收发,界面上能看到连接状态和消息记录。落到工程上,要拆成四条主线。连接管理负责监听、接入、断开和重连;消息收发负责把业务对象编码成字节流,再从字节流里还原成消息;界面刷新负责把连接数、日志、收发计数显示到 MFC 控件;异常处理负责端口占用、对端掉线、发送缓冲满、非法帧这些情况。四条主线不分开,代码就会长成一个几百行的OnReceive,后面加一个功能就牵一片。
我一般会把“连接”和“业务”分开:Socket 对象只关心字节进出,协议解析器只关心帧边界,对话框负责把解析结果投递给界面。这样服务端和客户端可以共用同一份协议代码,调试时只需要换端口和连接地址。课程设计里常见的问题是服务端和客户端各写一份帧头,字段顺序改一次就两边对不上,最后靠打印字节排查,血泪经验。
2.2 为什么用 CAsyncSocket:和 CSocket、纯 Winsock 的取舍
MFC 里做 TCP 有三条路。CSocket是同步封装,调用Receive会阻塞,放在 UI 线程里界面就卡死;放在工作线程里又要自己处理消息泵,初学者容易写出“线程退了 socket 还没关”的玄学问题。纯 Winsock 最灵活,但WSAStartup、窗口消息、非阻塞状态都要自己管,课设周期短的时候容易把时间花在框架上。CAsyncSocket夹在中间:它基于窗口消息通知OnAccept、OnReceive、OnClose,不需要自己写select循环,适合 MFC 对话框程序,也足够讲清楚异步 Socket 的工作方式。
选它不等于没有坑。CAsyncSocket的回调跑在创建它的线程的消息循环里,所以不要在OnReceive里做耗时业务,也不要在回调里直接delete this。它的非阻塞语义要求你在Receive返回SOCKET_ERROR时判断WSAEWOULDBLOCK,而不是一报错就断开。对这个项目来说,连接数在几十到几百、消息以文本和小文件块为主,CAsyncSocket的复杂度刚好;真要上高并发长连接,才考虑 IOCP 或更上层的网络库,那是另一个题目。
2.3 工程目录与模块边界
两端工程建议拆成下面这样,协议头文件只留一份,避免“服务端改了客户端没改”的翻车:
MfcTcpCs/ ├─ MfcTcpServer/ │ ├─ ServerDlg.cpp // 监听、接入、连接表、日志窗口 │ ├─ ClientSocket.cpp // 每个连接一个 CAsyncSocket 派生对象 │ ├─ FrameCodec.cpp // 帧头编解码与粘包解析 │ └─ Protocol.h // 消息类型、帧头常量、最大包长 ├─ MfcTcpClient/ │ ├─ ClientDlg.cpp // 连接、发送队列、UI 刷新 │ ├─ TcpClientSocket.cpp // 客户端 CAsyncSocket │ └─ FrameCodec.cpp // 与服务端共用同一份协议实现 └─ Shared/ └─ Protocol.h // 两端共用,字段顺序只改这里Protocol.h放帧头结构、消息类型枚举、最大负载长度;FrameCodec放编码函数和解析器;ClientSocket只做 Socket 生命周期和收字节。这样答辩时你可以指着模块说清楚“哪一层负责什么”,而不是把所有逻辑都塞进对话框。工程里还要有一个日志模块,至少记录时间、方向、消息类型、序号、长度和错误码。没有日志,粘包和断线问题就只能靠猜。
2.4 线程模型:哪些活必须在 UI 线程,哪些必须挪走
MFC 的控件不是线程安全的,CAsyncSocket的回调又依赖消息循环,所以最简单的模型是:Socket 对象和界面都在主线程,业务处理放到工作线程。收到一帧后,主线程只做解析和投递,不在这里做数据库写入、文件保存、图像处理。工作线程处理完用PostMessage把结果发回主线程,主线程再更新控件。这样既不会卡界面,也不会出现跨线程直接SetWindowText的崩溃。
发送方向同理。UI 线程只把消息放进发送队列,由定时器或专门的工作线程按预算取出发送。一次Send不要试图把几百 KB 全塞进去,CAsyncSocket::Send在发送缓冲满时返回WSAEWOULDBLOCK,要保留剩余数据等下一次。下面这段是客户端发送队列的核心逻辑,重点是“UI 线程只入队”和“发送有预算”:
void CClientDlg::EnqueueSend(uint8_t type, const std::string& utf8Body) { Frame f; f.type = type; f.body = utf8Body; m_sendQueue.push_back(std::move(f)); // UI 线程只入队,不直接 Send if (!m_sendTimer) m_sendTimer = SetTimer(TIMER_SEND, 10, nullptr); } void CClientDlg::OnTimer(UINT_PTR id) { if (id == TIMER_SEND) { int budget = 64 * 1024; // 单次最多 64KB,避免长时间占用 UI 线程 while (!m_sendQueue.empty() && budget > 0) { auto packet = EncodeFrame(m_sendQueue.front()); int n = m_socket.Send(packet.data(), (int)packet.size()); if (n == SOCKET_ERROR) { int err = m_socket.GetLastError(); if (err == WSAEWOULDBLOCK) break; // 发送缓冲满,等下一次定时器 OnSocketError(err); break; } budget -= n; if (n == (int)packet.size()) { m_sendQueue.pop_front(); } else { // 只发了一部分:实际工程要记录偏移,下次从剩余位置继续 break; } } if (m_sendQueue.empty()) { KillTimer(m_sendTimer); m_sendTimer = 0; } } CDialogEx::OnTimer(id); }参数上,TIMER_SEND用 10ms 是折中:太短会频繁进消息循环,太长会让小消息延迟明显;budget按机器性能调,课设场景 64KB 足够。EncodeFrame返回的是完整帧,包含帧头和负载。这里没有处理“部分发送”的偏移,是因为教学版先保证逻辑清楚;正式代码要把sentOffset存进队列项,下一次从packet.data() + sentOffset继续发。这个细节不补,大消息在弱网或本机调试时就会偶发丢尾部,表现是“服务端收到的 JSON 少一个括号”。
3. 通信协议设计:粘包处理、帧头字段与消息编解码
3.1 半包与粘包从哪里来:TCP 是字节流,不是消息队列
TCP 只保证字节有序到达,不保证一次Send对应一次Receive。客户端连续发三条 100 字节的消息,服务端可能一次收到 300 字节,也可能先收到 120 字节、再收到 180 字节。前者叫粘包,后者叫半包,本质都是“应用层没有帧边界”。很多课设程序在OnReceive里直接把缓冲区当字符串显示,本机测试看起来正常,一换机器或加个Sleep就乱序,答辩现场很容易翻车。
解决办法只有一个:在应用层定帧。常见做法是长度前缀,帧头里放负载长度,接收端先收够帧头,再按长度收负载。不要用\n分隔做文本协议,消息体里一旦出现换行就解析错;也不要用固定长度,消息长短不一,浪费空间还限制扩展。长度前缀加魔数校验,是这个项目里最稳的折中。
3.2 帧头字段设计:魔数、版本、类型、长度、序号与校验
帧头固定 20 字节,字段如下。多字节整数统一用网络字节序,字符串统一 UTF-8:
| 字段 | 长度 | 说明 |
|---|---|---|
| magic | 4 字节 | 固定0x4D534731,接收端先校验,防止把非法数据当帧头 |
| version | 1 字节 | 协议版本,当前为 1,后续加字段时递增 |
| type | 1 字节 | 消息类型,1=文本,2=心跳,3=应答,4=文件块 |
| flags | 2 字节 | 预留标志位,置 0 |
| bodyLen | 4 字节 | 负载长度,不含帧头,网络字节序 |
| seq | 4 字节 | 消息序号,用于日志、应答匹配和丢包定位 |
| crc32 | 4 字节 | 负载校验,没有校验库时先置 0,但字段保留 |
bodyLen用uint32_t,不要用int直接memcpy,否则 32 位和 64 位下长度解释不一致。最大负载建议限制在 1MB,超过就断开,避免恶意长度把内存撑爆。seq从 1 开始递增,心跳也占序号,这样日志里能看出“第 37 条之后断了”。crc32不是必须,但加上之后,文件块传输出错能定位到具体帧,而不是重传整个文件。
帧头定义要加#pragma pack(push, 1),并用static_assert锁死 20 字节。注意:不要直接把结构体Send出去,结构体里有填充和字节序问题,必须逐字段编码:
#pragma once #include <cstdint> #pragma pack(push, 1) struct FrameHeader { uint32_t magic; // 固定 0x4D534731 uint8_t version; // 协议版本 uint8_t type; // 消息类型 uint16_t flags; // 预留 uint32_t bodyLen; // 负载长度,网络字节序 uint32_t seq; // 消息序号,网络字节序 uint32_t crc32; // 负载校验,网络字节序 }; #pragma pack(pop) static_assert(sizeof(FrameHeader) == 20, "FrameHeader must be 20 bytes"); std::vector<char> EncodeFrame(const Frame& f) { FrameHeader h{}; h.magic = htonl(0x4D534731); h.version = 1; h.type = f.type; h.flags = htons(0); h.bodyLen = htonl((uint32_t)f.body.size()); h.seq = htonl(f.seq); h.crc32 = htonl(Crc32(f.body)); // 没有校验实现时先置 0 std::vector<char> out(sizeof(h)); memcpy(out.data(), &h, sizeof(h)); out.insert(out.end(), f.body.begin(), f.body.end()); return out; }EncodeFrame里每个多字节字段都走了htonl或htons,接收端对应ntohl、ntohs。Crc32可以先用空实现返回 0,但字段位置不要删,后面加校验时两端还能兼容。static_assert在旧编译器上不支持就换成ASSERT(sizeof(FrameHeader) == 20),但不要省掉这项检查,结构体对齐一旦变成 24 字节,解析全错。
3.3 长度前缀解析器:环形缓冲区实现
解析器要解决三个动作:追加字节、判断是否够帧头、判断是否够负载。下面是一个教学版实现,用std::vector当缓冲区,逻辑清楚,几十个连接够用;连接数上来后把erase换成读写下标,避免每次搬移内存:
bool FrameParser::Append(const char* data, int len, std::vector<Frame>& out) { if (len <= 0) return true; buf_.insert(buf_.end(), data, data + len); while (buf_.size() >= sizeof(FrameHeader)) { FrameHeader hdr{}; memcpy(&hdr, buf_.data(), sizeof(hdr)); uint32_t magic = ntohl(hdr.magic); uint32_t bodyLen = ntohl(hdr.bodyLen); if (magic != 0x4D534731 || bodyLen > kMaxBody) { buf_.clear(); // 非法帧:清空并让上层断开,避免越界 return false; } size_t frameLen = sizeof(FrameHeader) + bodyLen; if (buf_.size() < frameLen) break; // 半包,等下一次 OnReceive Frame f; f.type = hdr.type; f.seq = ntohl(hdr.seq); f.body.assign(buf_.begin() + sizeof(FrameHeader), buf_.begin() + frameLen); out.push_back(std::move(f)); buf_.erase(buf_.begin(), buf_.begin() + frameLen); } return true; }kMaxBody建议设为1024 * 1024。返回false表示流已经不可信,调用方应该关闭连接,而不是继续解析。break表示当前缓冲区不够一帧,保留数据等下一次OnReceive。这个解析器不负责 CRC 校验,校验放在Dispatch里做,失败时记录seq并断开。注意buf_.erase在大包连续到达时会有搬移开销,课设场景可以接受;如果要做压力测试,换成固定大小环形缓冲,读指针追上写指针时扩容。
3.4 消息类型、心跳与超时:让连接状态可观测
消息类型不要只留一个“文本”。至少分四类:1业务文本,2心跳,3心跳应答,4文件块。心跳由客户端每 5 秒发一次,服务端收到后回3,客户端 15 秒没收到任何数据就判定断开,主动Close并进入重连。心跳包负载可以为空,但seq要递增,这样日志里能看出“最后一次心跳是第几号”。
服务端也要有超时清理:每个连接记录lastRecvTick,定时器每 5 秒扫一遍,超过 30 秒没收到任何帧就关闭。不要只靠 TCP 的KeepAlive,默认两小时才触发,答辩等不起。心跳不要发得太密,1 秒一次在几十个连接下会让日志窗口刷屏,反而看不清业务消息;5 秒是课堂演示和局域网场景的舒适区。
3.5 编码与字节序约定:UTF-8、网络字节序和最大包长
MFC 工程默认 Unicode,CString是宽字符,直接Send会发成 UTF-16,服务端按 UTF-8 解就是乱码。发送前用WideCharToMultiByte(CP_UTF8, ...)转成std::string,接收后转回CStringW再显示。协议头里的bodyLen是字节数,不是字符数,中文一个字在 UTF-8 里占 3 字节,长度算错就会半包。字节序统一用网络字节序,magic也走htonl,接收端先ntohl再比较,这样大小端机器互通。
最大包长要在协议文档里写死:帧头 20 字节,负载不超过 1MB,超过直接断开。文件传输不要一个文件一帧,切成 64KB 的块,每块带seq,接收端按序写文件。这样即使某块 CRC 失败,也只重传一块,不用重传整个文件。协议文档不用很长,但字段表、消息类型表、超时参数三样必须留档,答辩时老师问“你的粘包怎么处理的”,直接翻到这一页。
4. 服务端与客户端落地:监听、接入、收发与断线重连的关键实现
4.1 服务端监听与 OnAccept:连接对象的创建和归属
服务端主对话框里放一个监听CAsyncSocket成员,InitInstance里调用AfxSocketInit(),对话框初始化时Create(6000, SOCK_STREAM)再Listen()。端口从配置读,不要硬编码到代码里,答辩换机器时端口被占用是高频翻车点。OnAccept里创建新的连接对象,Accept成功后才放进连接表;失败要delete,否则每失败一次漏一个对象。
void CServerDlg::OnAccept(int nErrorCode) { CAsyncSocket::OnAccept(nErrorCode); if (nErrorCode != 0) return; auto* pClient = new CClientSocket(this); // 归属:连接表 if (!Accept(*pClient)) { delete pClient; // Accept 失败必须回收 return; } m_conns[pClient] = ConnInfo{}; Log(_T("accept: %p"), pClient); }连接表的 key 用CClientSocket*,value 放lastRecvTick、peerAddr、seq。对象归属要定死:连接表拥有对象,OnClose只从表里移除并投递删除消息,不在回调里delete this。Accept的参数是引用,新对象必须先new再传入;Create不需要再调,Accept会接管底层 socket。监听 socket 本身不参与收发,只负责OnAccept,不要把它和连接对象混在一个类里。
4.2 服务端 OnReceive:收包、分发与连接表清理
OnReceive里要循环Receive到WSAEWOULDBLOCK,每次最多读 4KB 或 8KB,读到的字节追加进解析器。解析出完整帧后交给Dispatch,Dispatch只做类型判断和业务投递,不做耗时操作。收到 0 字节表示对端正常关闭,收到SOCKET_ERROR且错误码不是WSAEWOULDBLOCK表示异常,两种情况都走PostClose。
void CClientSocket::OnReceive(int nErrorCode) { CAsyncSocket::OnReceive(nErrorCode); if (nErrorCode != 0) { m_owner->PostClose(this); return; } char buf[4096]; for (;;) { int n = Receive(buf, sizeof(buf)); if (n > 0) { if (!m_parser.Append(buf, n, m_frames)) { m_owner->PostClose(this); return; } for (auto& f : m_frames) m_owner->Dispatch(this, f); m_frames.clear(); } else if (n == 0) { m_owner->PostClose(this); // 对端正常关闭 return; } else { int err = GetLastError(); if (err == WSAEWOULDBLOCK) break; // 当前没有更多数据 m_owner->PostClose(this); return; } } }PostClose用PostMessage把 socket 指针发给主窗口,主窗口在自定义消息里从连接表移除、Close()、delete。不要在OnReceive里直接delete this,因为 MFC 的调用栈还在这个对象上,删了之后CAsyncSocket基类再访问成员就是未定义行为。连接表清理时要把解析器缓冲一起释放,避免下次复用对象时残留半包。
4.3 客户端连接与发送队列:UI 线程只投递,不阻塞
客户端对话框里放一个CAsyncSocket成员,Connect之前先Create。连接成功后启动心跳定时器,发送队列按第 2 章的预算出队。OnConnect里判断nErrorCode,0 表示成功,其他值进入重连。注意OnConnect也可能在连接失败时被调用,不要在这里假设 socket 一定可用。
发送队列的入队函数只做编码和push_back,真正的Send放在定时器里。这样用户连续点“发送”不会卡界面,网络慢的时候消息也不会丢。队列要设上限,比如 1000 条,超过就提示“发送队列已满”,不要无限增长把内存吃光。每条队列项记录sentOffset,部分发送后下次从偏移继续,这是第 2 章代码里留的扩展点。
4.4 断线重连与关闭顺序:状态机比 if-else 可靠
重连用四状态:Disconnected、Connecting、Connected、Closing。OnClose里先置Disconnected,再Close()旧 socket,然后按退避时间SetTimer。退避从 1 秒开始,每次乘 2,上限 30 秒;连接成功后重置为 1 秒。不要在OnClose里立刻Connect,MFC 的 socket 对象还没完全释放,容易报WSAEALREADY。
enum class LinkState { Disconnected, Connecting, Connected, Closing }; void CClientDlg::OnConnect(int nErrorCode) { if (nErrorCode == 0) { m_state = LinkState::Connected; m_retryMs = 1000; StartHeartbeat(); Log(_T("connected")); } else { m_state = LinkState::Disconnected; ScheduleReconnect(); } CAsyncSocket::OnConnect(nErrorCode); } void CClientDlg::ScheduleReconnect() { if (m_state == LinkState::Closing) return; m_state = LinkState::Disconnected; SetTimer(TIMER_RECONNECT, m_retryMs, nullptr); m_retryMs = min(m_retryMs * 2, 30000); // 1s,2s,4s...上限30s }定时器触发时先Close旧 socket,再Create,再Connect。如果Connect返回FALSE且错误码是WSAEWOULDBLOCK,这是正常的异步连接过程,不要当成失败;等OnConnect回调即可。关闭顺序反过来:先停心跳和重连定时器,再Shutdown,再Close,最后删除对象。服务端关闭连接时先Shutdown(SD_SEND)再Close,让对端读到 0 字节,避免客户端一直等。
5. 避坑与排查:端口、线程、粘包、对象生命周期最容易翻车的五处
5.1 连接成功但界面卡死:UI 线程里做阻塞 recv
现象:客户端点“连接”后窗口白屏,标题栏显示“未响应”,过几秒才恢复;或者服务端一收大消息,日志窗口就拖不动。原因通常是在 UI 线程里用了CSocket::Receive或阻塞的recv,或者OnReceive里直接做文件写入、数据库插入。MFC 的消息循环被占住,重绘和按钮消息都排不上队。
解决:Socket 回调用CAsyncSocket,Receive只读缓冲区,业务处理投递到工作线程;如果必须用同步 socket,把它放到工作线程,主线程只通过PostMessage收结果。检查方法很简单:在OnReceive里加一个 200ms 的Sleep,如果界面明显卡顿,说明业务还在 UI 线程。把它挪走,界面立刻顺滑。
5.2 偶发丢消息:把 TCP 当消息队列,没有帧边界
现象:本机测试正常,换一台机器或连续快速发送时,服务端显示的消息少一段、多一段,或者 JSON 解析报“缺少右括号”。原因是没有做长度前缀,接收端把一次Receive当成一条消息;TCP 的粘包和半包把两条消息粘在一起,或者把一条消息拆成两次。
解决:按第 3 章的帧头解析,先收 20 字节帧头,再按bodyLen收负载,不够就保留缓冲等下一次。验收时故意把一条消息拆成两次Send,中间Sleep(50),再连续发 10 条小消息,看服务端是否按seq完整还原。这个用例能过,粘包问题基本就压住了。
5.3 服务端越跑越卡:CAsyncSocket 对象泄漏与 OnClose 未清理
现象:服务端跑一段时间后连接数显示正常,但内存缓慢上涨,客户端断开再连几次后Accept变慢,最后新连接进不来。原因通常是OnAccept里new了对象,Accept失败没删;或者OnClose只从界面列表移除,没有从连接表删除,对象一直挂着。
解决:连接表用std::map<CClientSocket*, ConnInfo>,OnClose里erase并PostMessage删除;Accept失败分支必须delete。每次断开后打印连接表大小,正常应该回到 0。压力测试用 50 个客户端连上断开三轮,看任务管理器里句柄数和内存是否回落,不回落就查泄漏。
5.4 中文乱码或长度不对:字符集、字节序、长度类型混用
现象:英文消息正常,中文变成“锟斤拷”或问号;或者服务端收到的bodyLen很大,直接断开。原因是 MFC 工程用 Unicode,发送时把CString的宽字符缓冲直接Send,接收端按 UTF-8 解;或者帧头字段没有htonl,大小端解释反了;或者长度用了int,负数被当成超大uint32_t。
解决:发送前统一转 UTF-8,接收后转CStringW;帧头多字节字段全部htonl/ntohl;长度用uint32_t,解析时先判断bodyLen > kMaxBody就断开。验收时发一条“中文测试 123”,再发一条 1MB 边界包,看日志里的长度是否和实际字节数一致。字符集问题不要靠“换台机器就好了”,那是碰巧。
5.5 关闭时崩溃:回调重入与 delete this 顺序错误
现象:点关闭按钮时程序偶发崩溃,调试器停在CAsyncSocket内部;或者断开连接后马上重连,报WSAEINVAL。原因是在OnClose或OnReceive里直接delete this,而 MFC 还在回调栈里;或者关闭时先删了 socket 对象,消息队列里还有它的回调消息。
解决:对象删除统一走主窗口的自定义消息,回调里只PostClose;关闭时先KillTimer,再Shutdown,再Close,最后delete。重连前确保旧 socket 已经Close,不要复用同一个对象立刻Connect。这个坑最玄学,因为本机调试不一定复现,答辩现场点得快就崩;把删除路径收口到一处,能少掉大部分随机崩溃。
6. 验收与答辩:用一张检查表把“能连上”变成“可交付”
验收不要只看“客户端能连上”。我一般按下面这张表走一遍,每项都有可观察的通过标准,失败时先看日志再改代码:
| 检查项 | 操作 | 通过标准 | 失败先看 |
|---|---|---|---|
| 本机回环 | 服务端监听 6000,客户端连 127.0.0.1 | 3 秒内显示已连接,日志有 accept | 端口占用、防火墙 |
| 粘包/半包 | 连续发 10 条小消息;把一条拆两次发 | 服务端按 seq 完整显示 10 条 | 帧头长度、解析缓冲 |
| 中文编码 | 发送“中文测试 123” | 两端显示一致,无乱码 | UTF-8 转换、bodyLen |
| 断线重连 | 服务端强关,客户端等 5 秒 | 客户端自动重连,退避不超过 30 秒 | OnClose、重连定时器 |
| 并发连接 | 用脚本开 50 个连接各发 100 帧 | 服务端不崩,连接表回落正常 | 对象泄漏、连接表清理 |
| 大消息 | 发 512KB 文件块 | 分块接收,CRC 或长度校验通过 | 发送偏移、最大包长 |
| 关闭顺序 | 客户端点关闭,再开再关三轮 | 无崩溃,无残留进程 | delete 时机、定时器 |
答辩演示可以按“正常收发 → 粘包用例 → 断线重连 → 50 连接压力”四步走,每步都让日志窗口可见。老师问“你怎么证明不是只在本机能跑”,就把 Python 验收脚本跑一遍,脚本用标准库 socket 按同样帧头发消息,不依赖你的客户端:
import socket, struct, time MAGIC = 0x4D534731 def frame(msg_type: int, seq: int, body: bytes) -> bytes: # 20 字节帧头:magic, version, type, flags, bodyLen, seq, crc32 return struct.pack("!IBBHIII", MAGIC, 1, msg_type, 0, len(body), seq, 0) + body with socket.create_connection(("127.0.0.1", 6000), timeout=3) as s: for i in range(1, 101): s.sendall(frame(1, i, f"第{i}条".encode("utf-8"))) time.sleep(0.01) print("sent 100 frames")struct的!表示网络字节序,格式串长度正好 20 字节,和服务端FrameHeader对齐。脚本能跑通,说明协议不是客户端“自说自话”。答辩时如果被问到“为什么不用 JSON 直接发”,回答“JSON 是负载格式,帧头负责边界和长度,两者不冲突;没有帧头,JSON 粘在一起一样解析不了”。
从那以后我每次做双端通信演示,都强制先跑断线重连、粘包和 50 连接并发这三项,再谈界面功能。希望帮到你。
本文还有配套的精品资源,点击获取