ARTICLE DETAIL

资讯详情

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

MFC文件传输实战:CAsyncSocket实现客户端服务端与粘包处理

MFC文件传输实战:CAsyncSocket实现客户端服务端与粘包处理 简介这份资源面向学习网络编程与MFC框架的C开发者及高校学生提供一套完整的文件传输实验方案包含客户端与服务端两个可运行程序帮助理解Socket通信、TCP/IP协议栈与Windows GUI开发的结合方式。压缩包共63个文件约109.68MB涵盖cpp与h源码、vcxproj与sln工程文件、exe可执行程序、rc资源脚本及pdb、obj等编译中间产物结构完整可直接编译运行或对照研究。目前已有316人学习下载。通过该实验读者可掌握CSocket类的创建、连接、监听、收发与关闭流程理解客户端读取本地文件并发送、服务端绑定端口接收并保存文件的完整链路同时熟悉MFC对Windows Socket API的封装思路与文件I/O操作适合作为课程实验参考、项目原型或网络编程入门练手素材。1. 网络编程实验里的 MFC 文件传输为什么它仍是理解 C/S 通信的硬核入口很多人第一次接触 socket 网络编程是在控制台里敲send和recv看着黑框里蹦出几行字符觉得通了就行。可一旦要把这套东西搬到带界面的工程里问题立刻变得具体界面线程不能被阻塞、粘包要自己处理、大文件不能一次性读进内存、客户端断线服务端得知道。MFC 实现文件传输客户端 服务端这个题目恰好把这些真实问题一次性摊开。它不是一个玩具 demo而是一个把 Windows 消息循环、CSocket/CAsyncSocket 封装、文件 IO 和 TCP 流式协议缝在一起的综合练习。适合谁适合已经会写基本 socket 调用、但没在 GUI 工程里完整跑通过一次文件收发的同学也适合想回头补一补 MFC 四大类CWinApp、CFrameWnd、CView、CDocument怎么和网络层协作的熟手。这一章先把要做什么、边界在哪讲清楚后面几章再动手。2. 选型先立住MFC 里做文件传输到底该用哪套 socket 封装2.1 CSocket、CAsyncSocket 和原生 Winsock 的取舍MFC 提供了两层 socket 封装。CAsyncSocket是对 Winsock 的薄封装回调式事件驱动你必须自己处理OnReceive、OnSend、OnClose这些通知好处是灵活、不阻塞界面。CSocket继承自CAsyncSocket额外做了阻塞式同步配合CArchive和CSocketFile能像读写文件一样收发写起来舒服但它在界面线程里直接阻塞稍不注意界面就假死。原生 WinsockWSAStartupsocketsend/recv最底层控制力最强但所有状态机、缓冲区、错误码都得自己管。我的经验是实验性质、要讲清楚原理的用 CAsyncSocket追求代码短、能接受工作线程里跑 CSocket 的用 CSocket要上生产、要精细控制超时和缓冲的直接原生 Winsock。这里有个反直觉的点很多人以为CSocket更高级所以更好其实它的阻塞语义在 GUI 里是负担。常见做法是把CSocket放到一个AfxBeginThread起的工作线程里让阻塞发生在后台主线程只管刷界面。2.2 协议设计先定包头再谈传输TCP 是字节流没有消息边界。文件传输最容易翻车的地方就是粘包——你发两次对面可能一次recv全收到。所以第一步不是写代码是定协议。我一般用一个定长包头字段类型长度说明magicuint324 字节固定值 0x46494C45用于校验fileNameLenuint162 字节文件名字节数fileSizeuint648 字节文件总大小fileNamechar[]变长UTF-8 编码文件名包头固定 14 字节后面跟文件名再后面跟文件内容。接收端先收满 14 字节解析出fileNameLen和fileSize再收文件名最后按fileSize循环收内容。这样粘包问题被按长度读化解。提示字节序要统一。Windows 是小端如果两端都是 Windows 就不用转但养成用htonl/ntohl的习惯将来跨平台不返工。2.3 界面与网络的分层MFC 的对话框工程里别把 socket 逻辑塞进OnBnClickedButtonSend。正确做法是分三层界面层对话框类只负责取文件路径、更新进度条网络层一个CFileTransferServer/CFileTransferClient类封装连接、收发、协议解析文件层负责打开、分块读、写盘。界面层通过自定义消息WM_USER N或PostMessage从网络层拿进度绝不让网络层直接碰控件。这样线程安全也好调试。3. 服务端实现从监听端口到落盘一个完整文件3.1 用 CAsyncSocket 搭监听与接收骨架服务端核心是一个继承CAsyncSocket的类重写OnAccept、OnReceive、OnClose。下面是最小可跑骨架// FileServerSocket.h class CFileServerSocket : public CAsyncSocket { public: virtual void OnAccept(int nErrorCode); virtual void OnReceive(int nErrorCode); virtual void OnClose(int nErrorCode); private: CFileRecvSocket m_recv; // 每个连接一个接收 socket }; // FileServerSocket.cpp void CFileServerSocket::OnAccept(int nErrorCode) { if (nErrorCode ! 0) return; // 接受新连接交给专门的接收 socket 处理 if (m_recv.m_hSocket ! INVALID_SOCKET) m_recv.Close(); Accept(m_recv); m_recv.m_state RECV_HEADER; // 状态机起点 } void CFileRecvSocket::OnReceive(int nErrorCode) { if (nErrorCode ! 0) { Close(); return; } char buf[4096]; int n Receive(buf, sizeof(buf)); if (n 0) return; m_buffer.Append(buf, n); // 先攒进缓冲区 ParseBuffer(); // 按状态机解析 }逻辑说明OnAccept里用Accept把新连接绑定到m_recv然后进入状态机。OnReceive不假设一次收全先把数据追加到m_buffer一个CByteArray或std::vectorchar再交给ParseBuffer按当前状态消费。参数上Receive的缓冲区大小 4096 是经验值太小系统调用频繁太大栈上分配有风险用堆或成员缓冲更稳。3.2 状态机解析把收满包头再收内容写清楚ParseBuffer是服务端的心脏用状态机驱动enum RecvState { RECV_HEADER, RECV_NAME, RECV_BODY, RECV_DONE }; void CFileRecvSocket::ParseBuffer() { while (true) { if (m_state RECV_HEADER) { if (m_buffer.GetSize() 14) return; // 不够等下次 memcpy(m_header, m_buffer.GetData(), 14); m_buffer.RemoveAt(0, 14); m_state RECV_NAME; } else if (m_state RECV_NAME) { if (m_buffer.GetSize() m_header.fileNameLen) return; m_fileName.assign(m_buffer.GetData(), m_buffer.GetData() m_header.fileNameLen); m_buffer.RemoveAt(0, m_header.fileNameLen); m_file.Open(m_saveDir \\ m_fileName, CFile::modeCreate | CFile::modeWrite); m_received 0; m_state RECV_BODY; } else if (m_state RECV_BODY) { ULONGLONG remain m_header.fileSize - m_received; if (remain 0) { m_state RECV_DONE; m_file.Close(); return; } DWORD take (DWORD)min(remain, (ULONGLONG)m_buffer.GetSize()); if (take 0) return; // 没数据等 m_file.Write(m_buffer.GetData(), take); m_buffer.RemoveAt(0, take); m_received take; // 通知界面更新进度 ::PostMessage(m_hWndNotify, WM_USER 100, (WPARAM)m_received, (LPARAM)m_header.fileSize); } else return; } }逻辑说明每个状态只做数据够不够的判断不够就return等下一次OnReceive够了就消费并推进状态。RemoveAt(0, n)从缓冲区头部删掉已消费数据保证不重复处理。参数上m_header.fileSize是 64 位m_received也用ULONGLONG避免大文件超过 4GB溢出。进度通知用PostMessage而不是直接调界面跨线程安全。3.3 落盘与异常文件句柄、磁盘满、重名怎么处理CFile::Open失败要捕获CFileException常见原因是目录不存在或同名文件被占用。我的做法是保存目录启动时用CreateDirectory确保存在重名文件自动加时间戳后缀而不是覆盖。磁盘满时Write会抛异常捕获后关闭 socket 并给客户端回一个错误码协议里可以预留一个 1 字节的响应包。这些边界不处理实验演示时一旦触发就是程序莫名退出很难查。4. 客户端实现选文件、发包头、分块推流与进度反馈4.1 连接与发送线程的划分客户端界面线程负责CFileDialog选文件、点发送真正的发送放到工作线程避免大文件把界面卡死。连接用CAsyncSocket的Connect成功后触发OnConnect在那里启动发送线程。UINT SendThread(LPVOID pParam) { CFileSendSocket* pSock (CFileSendSocket*)pParam; CFile file; if (!file.Open(pSock-m_filePath, CFile::modeRead | CFile::shareDenyWrite)) return 1; ULONGLONG total file.GetLength(); // 组包头 FileHeader hdr; hdr.magic 0x46494C45; hdr.fileNameLen (uint16_t)pSock-m_fileName.GetLength(); hdr.fileSize total; pSock-Send(hdr, 14); pSock-Send(pSock-m_fileName.c_str(), hdr.fileNameLen); // 分块推流 const int CHUNK 64 * 1024; char* buf new char[CHUNK]; ULONGLONG sent 0; while (sent total) { UINT n file.Read(buf, CHUNK); if (n 0) break; int off 0; while (off (int)n) { // Send 可能只发一部分 int w pSock-Send(buf off, n - off); if (w SOCKET_ERROR) { /* 错误处理 */ } off w; } sent n; ::PostMessage(pSock-m_hWndNotify, WM_USER 101, (WPARAM)sent, (LPARAM)total); } delete[] buf; file.Close(); return 0; }逻辑说明包头和文件名先发再循环读文件分块发。关键点是Send返回值可能小于请求长度发送缓冲区满所以内层要while直到这块发完。参数上CHUNK取 64KB是吞吐和内存的折中太小系统调用多太大单次阻塞久。进度同样用PostMessage回界面。4.2 进度条与取消别让用户以为程序死了进度条更新频率要控制每收/发一块就PostMessage一次64KB 一块对几百 MB 文件也就几千次消息界面扛得住。取消功能用一个volatile bool m_cancel标志发送线程每轮检查置位就关闭 socket 退出。注意取消后要清理半截文件服务端收到OnClose时如果状态不是RECV_DONE删掉未完成的临时文件。4.3 客户端和服务端接口测试的最小验证法写完别急着传大文件。先用一个几字节的文本文件验证协议解析对不对再用telnet或自己写个脚本模拟半包发送故意把包头拆成两次send看服务端状态机是否能正确等待。这一步能提前暴露 90% 的粘包和边界 bug。服务端接口测试的思路在这里同样适用把接口当成协议构造异常输入magic 错误、fileNameLen 超大、fileSize 为 0看程序是否优雅处理而不是崩溃。5. 避坑与排查文件传输实验里最容易翻车的五个点5.1 现象小文件正常大文件传到一半卡死原因Send在发送缓冲区满时阻塞而接收端因为界面线程忙没及时Receive双方互等。解决发送放工作线程接收端保证OnReceive及时被消息循环调度必要时接收也放线程。5.2 现象收到的文件比原文件大或内容错位原因粘包没处理把两次消息当成一次或RemoveAt用错偏移。解决严格按状态机够多少消费多少每次消费后立即从缓冲区移除打印每步m_buffer.GetSize()核对。5.3 现象中文文件名变成乱码原因CString默认TCHAR在 Unicode 工程里是宽字符直接Send发的是 UTF-16接收端按 UTF-8 解析就乱。解决发送前统一转 UTF-8WideCharToMultiByte接收端再转回协议里明确编码。5.4 现象程序退出时报 socket 资源泄漏或断言失败原因CAsyncSocket对象析构前没Close或在工作线程还在跑时主窗口已销毁。解决窗口OnDestroy里先置取消标志、WaitForSingleObject等工作线程退出再Close所有 socket。5.5 现象OnReceive里Receive返回 0 却没进OnClose原因对端正常关闭时Receive返回 0但有些封装不会立刻触发OnClose。解决在OnReceive里判断n 0主动Close并清理别只依赖OnClose。6. 进阶技巧把传输做成可断点续传与可校验的版本基础版跑通后值得往上加两个能力它们能把实验变成能用的工具。断点续传协议里加一个已收到偏移的握手。客户端连接后先发文件名和已存在临时文件的大小服务端比对后从该偏移继续发。实现上服务端CFile::Seek到偏移客户端以追加模式打开临时文件。关键是把fileSize和offset都放进包头接收端校验offset fileSize。完整性校验传完后追加一个 32 字节的哈希常见做法是 MD5 或 SHA-256用 Windows 自带的CryptoAPI或BCrypt系列函数算。接收端算完比对不一致就删文件重传。这一步能挡住网络抖动导致的静默损坏是血泪经验——没有校验的传输你永远不知道收到的字节是不是原样。能力协议改动服务端改动客户端改动断点续传包头加 offset 字段Seek 到 offset 再发追加模式写先报 offset完整性校验尾部加 32 字节 hash发送前算 hash 追加收完算 hash 比对验证方法故意在传输中途杀掉客户端重启后看是否从断点继续故意改一个字节再传看校验是否报错。这两个测试过了这套 MFC 文件传输才算真正立住。我自己踩得最狠的一次是没做取消清理测试时反复中断临时目录堆了几十个半截文件排查了半天才发现是OnClose里漏了删除逻辑。后来养成习惯任何未完成状态退出第一件事就是清理现场。希望帮到你。本文还有配套的精品资源点击获取
返回列表