简介:一份利用微软基础类库中的套接字封装类实现TCP通信的客户端与服务器架构程序示例,面向C++网络编程初学者,同样适合需要在Windows桌面应用中快速加入通信功能的开发者。整个压缩包只有6KB,包含四个文件,其中两个是源文件,两个是头文件,分别对应客户端连接对话框类与服务器监听对话框类的声明和实现,文件数量不多,目录结构清楚,适合逐行阅读。目前已有1263人学习下载。示例完整覆盖了底层网络库的初始化、通信对象创建、端口绑定和监听、接受客户端连接、客户端主动连接服务器、双向收发数据、关闭连接并释放资源等一系列关键步骤,清楚展现了微软基础类库是如何封装底层网络接口的,也还原了客户端与服务器之间典型的问答交互过程。既可以用作网络编程课程设计参考,也可以作为快速搭建通信原型的样本,尤其适合希望理解TCP连接细节的初学者参考。
1. 从考试系统收卷说起:这个MFC TCP工程到底解决了什么问题
某个下午,某高校的信息中心给我丢来一个需求:期末考试要用局域网收卷,机房五十台机器同时交卷,服务端要稳定接住所有数据,不能丢、不能漏、不能在交卷高峰期把机器卡死。我第一反应是用C#写个Socket服务,但对面老师补了一句:机器是Windows XP的老机房,装的还是VC6.0。于是方案立刻变成MFC + TCP Socket,C/S架构,客户端答题系统负责发,服务端只干一件事——接收、校验、存盘。这个场景就是本资源里那个工程的核心用途:一个用MFC实现的TCP通信骨架,服务端监听、客户端连接、双向收发数据、断线回收,所有代码都是可编译可跑的完整工程。适合恰好也被老项目缠住的人,也适合想搞清楚CAsyncSocket到底怎么用、TCP分包粘包怎么处理、C/S模式在MFC里怎么落地的人。这篇笔记我把整个工程拆开讲清楚,从架构到每一段能抄的代码,再到跑起来之后怎么改造成自己的业务系统。
2. 架构与设计:C/S模式、消息循环与socket生命周期的协同
2.1 为什么是MFC + CAsyncSocket,而不是纯Win32或阻塞Socket
这个工程面临两个硬约束:一是客户端界面不能卡死,二是通信代码必须足够简单,让接手的人能快速看懂。纯Win32的WSAAsyncSelect虽然也能做异步socket,但消息循环、控件绑定、线程管理全要自己写,工程量大;而直接开一个线程去阻塞recv,虽然写法直观,但MFC的窗口消息必须回到主线程处理,线程里直接操作控件容易崩溃,还得自己封装线程安全的消息投递。于是作者选了MFC自带的CAsyncSocket——它把WSAAsyncSelect的FD_READ、FD_WRITE、FD_ACCEPT、FD_CLOSE消息封装成了可重写的虚函数,socket事件来了会以消息的形式投递到窗口,你只需要在继承类里重写OnReceive、OnAccept、OnClose,完全避开了手动管理WSAEvent和消息映射的麻烦。
这里有个容易误用的点:CSocket比CAsyncSocket更“高级”,内部封装了阻塞收发并自动完成消息泵转发,看起来似乎更省事。但CSocket在非GUI线程里用容易出幺蛾子,而且它吞掉了不少错误返回码,排错时很不直观。这个工程选CAsyncSocket,等于把控制权完全留在自己手上,后续我们加协议解析、做断线重传、扩展心跳包,都更可控。
2.2 目录结构与文件职责:一个能编译的MFC工程长什么样
拿到工程后,第一件事不是急着编译,而是先看目录结构。整个解决方案是典型的两工程结构:
TcpServer/ TcpServer.h / TcpServer.cpp // CWinApp派生类,程序入口 TcpServerDlg.h / TcpServerDlg.cpp // 服务端主对话框,负责UI与启动监听 ListenSocket.h / ListenSocket.cpp // CListenSocket:监听socket ClientSocket.h / ClientSocket.cpp // CClientSocket:与客户端通信的socket resource.h / .rc // 对话框资源与控件ID TcpClient/ TcpClient.h / TcpClient.cpp // 客户端CWinApp入口 TcpClientDlg.h / TcpClientDlg.cpp // 客户端主对话框 ClientSocket.h / ClientSocket.cpp // 客户端侧通信socket服务端有两个socket类,职责分开:ListenSocket只干一件事——在OnAccept里接收新连接,接收成功后把新连接交给一个新建的ClientSocket实例;ClientSocket负责具体的数据收发和连接关闭。客户端只有一个ClientSocket,既做连接发起,也做数据接收。这种拆分是C/S项目的标准做法,监听和通信解耦之后,之后想加“同一端口允许多个客户端同时连入”的能力,不用动监听代码,只需要在服务端维护一个ClientSocket数组即可。
2.3 通信链路设计:从监听、握手到断线回收的完整流程
整个通信链路遵循标准的TCP三次握手,但在应用层加了两层封装。服务端启动流程是:创建CListenSocket对象,调用Create绑定端口,调用Listen进入监听状态,然后在OnAccept里接收客户端连接请求。客户端流程是:创建CClientSocket,调用Create创建socket句柄,调用Connect发起连接,连接成功后调用Send发送数据。服务端收到连接后,新连接自动进入独立的ClientSocket,后续收发互不干扰。
这个工程在链路设计上做了两个细节处理。第一个是断线回收:客户端异常退出时,TCP层的连接会断开,服务端ClientSocket会收到FD_CLOSE消息,OnClose被触发,必须在OnClose里调用Close方法并释放对象,否则内存泄漏。第二个是数据分包:TCP是流式协议,一次Send的数据在接收端可能被拆成多次OnReceive到达,也可能多次Send被合并成一次OnReceive,所以工程里定义了一个简单的包头协议——前两个字节存魔数0x5A5A,后两个字节存消息体长度,再后面才是真正的数据。这样每次OnReceive时,先判断缓冲区里的数据是否够一个包头,不够就存着等下一次;够了就按长度字段切包。
3. 核心代码拆解:一个请求从进入到返回的完整链路
3.1 服务端:监听、接受连接与客户端对象管理
先看服务端主对话框里如何启动监听,这一段是C/S架构的起点。
// TcpServerDlg.cpp 中的启动监听按钮事件 void CTcpServerDlg::OnBtnStartListen() { // 检查是否已经在监听 if (m_bListening) return; // 创建监听socket BOOL bRet = m_listenSocket.Create(9527, SOCK_STREAM, FD_ACCEPT); if (bRet == FALSE) { int nErr = GetLastError(); CString strMsg; strMsg.Format(_T("监听端口创建失败,错误码: %d"), nErr); AfxMessageBox(strMsg); return; } // 进入监听状态 if (!m_listenSocket.Listen(5)) { AfxMessageBox(_T("Listen失败")); m_listenSocket.Close(); return; } m_bListening = TRUE; SetDlgItemText(IDC_EDIT_STATUS, _T("服务端已启动,等待客户端连接...")); }这段代码有三个关键点。Create方法第一个参数是端口号9527,第二个参数SOCK_STREAM表示TCP流式socket,第三个参数FD_ACCEPT表示监听socket关心的事件类型——只关心“有客户端连进来”这件事。Listen方法的参数5是backlog,表示内核里排队等待accept的连接数上限,如果机房里有五十台机器同时点交卷,这个数字至少要设到50,不然后续连接会直接被内核拒绝。Create失败时用GetLastError取错误码,这是排错的关键,最常见的错误是端口被占用,错误码10048。
接下来是监听socket收到连接后的处理逻辑:
// ListenSocket.cpp —— 监听socket收到新连接 void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode != 0) { TRACE(_T("OnAccept error: %d\n"), nErrorCode); return; } // 新建一个ClientSocket处理该连接 CClientSocket* pClient = new CClientSocket(); if (!Accept(*pClient)) { delete pClient; return; } // 将新连接对象交给主对话框管理 CString strMsg; strMsg.Format(_T("新客户端接入,当前连接数: %d"), ++m_nConnCount); AfxGetMainWnd()->SetDlgItemText(IDC_EDIT_STATUS, strMsg); // 如果连接数超过阈值,可以拒绝或提示 if (m_nConnCount > MAX_CONNECTION) { TRACE(_T("连接数超限,拒绝新客户端\n")); pClient->Close(); delete pClient; m_nConnCount--; return; } }OnAccept被触发时,TCP三次握手已经完成,连接已经在内核缓冲区就绪,我们需要手动调用Accept把它接出来。这里每次新连接都new一个CClientSocket,因为CAsyncSocket和具体的socket句柄是一一绑定的,一个socket对象不能同时处理两个连接。Accept成功之后,整个连接的管理权就转移到了这个新建的ClientSocket对象上,后续收发都走它,监听socket继续在监听端口上等待新的连接。
参数说明:nErrorCode是异步事件触发的错误码,0表示正常。MAX_CONNECTION是工程里预定义的最大连接数上限,这里设的是100,用于防止accept风暴把服务端资源耗尽。
3.2 客户端:发起连接与参数配置
客户端的连接逻辑放在对话框的连接按钮事件里,核心代码如下:
void CTcpClientDlg::OnBtnConnect() { // 获取界面上的服务器IP和端口 CString strIp, strPort; GetDlgItemText(IDC_EDIT_SERVER_IP, strIp); GetDlgItemText(IDC_EDIT_PORT, strPort); int nPort = _ttoi(strPort); if (strIp.IsEmpty() || nPort <= 0) { AfxMessageBox(_T("请输入有效的服务器地址和端口")); return; } // 创建客户端socket,不绑定具体端口 BOOL bRet = m_clientSocket.Create(0, SOCK_STREAM, FD_CONNECT); if (bRet == FALSE) return; // 发起连接 if (!m_clientSocket.Connect(strIp, nPort)) { int nErr = GetLastError(); // 10061 表示连接被拒绝,通常是服务端没有启动 if (nErr != WSAEWOULDBLOCK) { CString strMsg; strMsg.Format(_T("连接失败,错误码: %d"), nErr); AfxMessageBox(strMsg); m_clientSocket.Close(); } return; } SetDlgItemText(IDC_EDIT_STATUS, _T("连接已发起,等待服务端响应...")); }Create的第一个参数传0,表示让系统自动分配客户端socket的本地端口,这是客户端最常见配置;第三个参数FD_CONNECT表示关心连接建立事件。Connect是一个异步操作,它会立即返回,但真实的连接结果要通过OnConnect回调来获知。这里有一个CAsyncSocket使用中很关键、也最容易误解的点:异步socket的Connect失败时,返回FALSE不一定是真的失败,错误码WSAEWOULDBLOCK(10035)表示“操作正在后台进行中”,这反而是正常的;只有连接被拒绝(10061)、网络不可达(10051)、超时(10060)才是真实错误。代码里用nErr != WSAEWOULDBLOCK先过滤掉这个“假失败”,剩下的错误才弹窗提示。
3.3 数据收发与协议解析:OnReceive的粘包与分包处理
这是全工程含金量最高的一段代码,直接展示了在MFC异步socket中如何稳定接收数据而不丢包。服务端ClientSocket的OnReceive虚函数:
void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) return; // 1. 先读数据到临时缓冲区 int nLen = Receive(m_szBuf, BUFFER_SIZE); if (nLen == SOCKET_ERROR) { int nErr = GetLastError(); if (nErr == WSAEWOULDBLOCK) return; // 没有更多数据,退出 // 其他错误按断开处理 OnClose(0); return; } if (nLen == 0) { // 对端优雅关闭 OnClose(0); return; } // 2. 将本次收到的数据追加到累积缓冲区 memcpy(m_szPacket + m_nPacketLen, m_szBuf, nLen); m_nPacketLen += nLen; // 3. 循环解析,把缓冲区里完整的包切出来 while (m_nPacketLen >= PACKET_HEADER_SIZE) { // 检查魔数,确认包头无误 if (m_szPacket[0] != 0x5A || m_szPacket[1] != 0x5A) { // 数据错乱,丢弃整个缓冲 m_nPacketLen = 0; return; } // 取消息体长度 USHORT nPayloadLen = (m_szPacket[2] << 8) | m_szPacket[3]; // 如果缓冲区的数据不够一个完整包,等下一次OnReceive if (m_nPacketLen < PACKET_HEADER_SIZE + nPayloadLen) return; // 切出一个完整包,交给业务处理函数 OnPacket(&m_szPacket[PACKET_HEADER_SIZE], nPayloadLen); // 把剩余数据移到缓冲区头部 int nRemain = m_nPacketLen - PACKET_HEADER_SIZE - nPayloadLen; if (nRemain > 0) { memmove(m_szPacket, m_szPacket + PACKET_HEADER_SIZE + nPayloadLen, nRemain); } m_nPacketLen = nRemain; } }这段代码解决的正是TCP流式协议最棘手的粘包与半包问题。解释一下:Receive读取的是内核缓冲区中当前可达的数据,一次返回的字节数不保证等于对端一次Send的长度。网络中常见的两种翻车情况是:对端连续两次Send,数据被合并成一次Receive拿到;对端一次Send较大数据,被拆成两次Receive才收完。m_szPacket作为累积缓冲区,把每次读到的数据追加进去,然后循环尝试解析完整包——每当收到一个完整包就立即处理并移出缓冲区,剩余不完整的部分留在原地等下一次OnReceive继续拼接。这个“累积 + 切包 + memmove”的套路就是所有TCP业务层的分包基本法。
参数说明:PACKET_HEADER_SIZE为4字节,魔数0x5A5A用于包头校验,2字节的消息体长度字段支持最长65535字节的单包。OnPacket是工程里预留的业务处理函数,在实际的考试系统场景里,它做的是解析客户端交卷消息、把试卷数据写入数据库、回执成功状态——改造时你只需要替换这个函数的内部实现。
4. 编译与运行:从拿到工程到两端连通
4.1 环境准备:别急着双击工程文件
先说环境。这个工程是VC6.0写的MFC工程,但它在VS2008、VS2010下也能编译通过,只是打开工程时会有“迁移向导”提示,直接点确认就行。如果你手里装的是VS2015以上的版本,中间隔的版本太多,MFC工程迁移容易出各种幺蛾子,我一般会建议找一台装了VC6.0或VS2008的机器(或者虚拟机)来跑,省得在字符集、工具集版本上浪费两小时。
打开解决方案后,先做一件事:检查工程配置的字符集。VC6.0时代默认是ANSI多字节字符集,而VS2008以上版本新建的MFC项目默认Unicode。如果工程里大量使用CString和_T()宏,ANS和Unicode混着配会让字符串显示乱码、甚至编译报错。解决方式是在项目属性 -> 常规 -> 字符集里,确认“使用多字节字符集”,和工程源码保持一致。
4.2 启动步骤:先服务端后客户端,顺序不能乱
编译顺序上有讲究:这个解决方案包含TcpServer和TcpClient两个工程,你需要先把TcpServer设为启动项目,编译并启动服务端,等它进入监听状态之后,再把TcpClient设为启动项目,编译并启动客户端。运行的时候,客户端界面会有一个“服务器IP”输入框,填127.0.0.1就是本机调试,填局域网IP就是真机联调。
这里有一个我踩过的坑:如果直接F5把服务端跑起来,你会看到一个白底对话框,上面有“启动监听”按钮,但服务端并没有自动开始监听,需要手动点一下按钮,状态栏才会变成“服务端已启动,等待客户端连接...”。如果你点了启动监听没有反应,第一件事查防火墙——Windows防火墙默认会拦截外部程序的入站连接,弹窗询问时选“允许访问”,如果错过了弹窗,去防火墙设置里给这个exe加一个入站规则,允许TCP 9527端口。
4.3 验证连通:别光看对话框,关掉再连一次试试
判断两端是否连通的直接方法,是在客户端界面上发一条测试消息,然后看服务端对话框的状态栏是否显示收到数据。但这样只能证明“连上了”,不能证明工程的核心逻辑——分包解析——是可靠的。我习惯做两个自测:一个是连续发送多条数据,正常应该全部完整到达;另一个是发一个超过4KB的数据包,让底层自动分包,正常情况下服务端能完整拼接出整条消息,状态栏会显示完整内容。
关闭顺序也有讲究:先关客户端,再关服务端。如果反过来,服务端会往一个已断开的socket上写数据,虽然没有大碍,但会触发一次FD_CLOSE处理,在调试输出里留下一条“连接到主机的连接失败”的WSAECONNRESET,看着像出了大问题,实际只是你的关闭顺序不够绅士。连接建立之后,抓包工具看的是三个标志位——SYN、SYN-ACK、ACK——三次握手全部出现,基本可以确认TCP层没有问题,再往上就是应用层协议的事了。
5. 避坑记录:MFC Socket开发里最典型的五个翻车现场
5.1 现象:启动监听时界面卡住,像死机一样
原因:在按钮事件里直接调用了阻塞版本的socket函数,或者在一个自定义线程里做了无限循环的recv。CAsyncSocket本身是异步的,你只需要创建、监听、然后在回调里收数据,不需要也不应该在主线程里循环等待数据。
解决:把代码调整回异步模式,Create传入FD_ACCEPT事件,然后让MFC的消息循环去驱动OnAccept和OnReceive。确认自己的代码里没有Sleep、没有while(TRUE)、没有WaitForSingleObject等会阻塞主线程的调用。
5.2 现象:发送端发了一条完整的消息,接收端收到却是半截的
原因:TCP是流式协议,4KB数据在穿越网络时被底层拆成了两段,第一段到了内核缓冲区,OnReceive被触发,只读到了前半段;后半段还在路上。如果你在OnReceive里直接把数据当完整消息处理,必然出问题。
解决:必须使用累积缓冲区,在OnReceive里把数据追加到buffer尾部,然后循环检查是否已经具备完整的包,不够就等下一次回调继续拼。这就是第3.3节那段代码存在的意义。这个坑在局域网内不容易触发,因为网络快、数据碎包概率低;但一旦放到跨公网环境,几乎是必现。
5.3 现象:客户端关闭后,服务端的内存占用只增不减
原因:客户端退出会触发服务端ClientSocket的OnClose,但如果你没有在OnClose里调用Close和delete,那个socket对象就变成了内存泄漏,连接数一多就能观察到一个可笑的进程内存增长曲线。
解决:在OnClose里关闭socket并释放对象。但这里还有第二个坑:OnClose里直接delete this之后,如果外面还有代码引用这个指针,就会野指针崩溃。常见做法是让主对话框统一管理所有ClientSocket指针,在OnClose里向主对话框发一个自定义消息,由主对话框负责从socket数组中移除并delete,而不是在socket自身内部delete了自己。
5.4 现象:明明连接成功了,但收不到任何数据
原因:这个情况有八成是因为事件掩码没设置对。Create函数的第三个参数决定了socket关心哪些事件,如果你只传了FD_CONNECT,那么连接建立后有数据到达时不会触发FD_READ消息,OnReceive永远不会被调用。CAsyncSocket不会自动通知“所有事件”。
解决:客户端创建时务必要同时关心FD_CONNECT和FD_READ,服务端接受连接后,需要显式调用AsyncSelect(FD_READ | FD_CLOSE)声明我关心数据到达和连接关闭。这是CAsyncSocket和更高级的CSocket最大的区别——CSocket帮你自动注册了所有事件,但代价是错误处理的透明度下降。这个坑非常隐蔽,因为编译连接都不会报错,程序看起来运行正常,只是数据就是不来。
5.5 现象:Debug版本一切正常,Release版本一收到数据就崩溃
原因:Release版本里没有TRACE调试宏的那些额外保护,而且编译器优化可能导致缓冲区对齐和边界检查行为与Debug不同。最常见的真凶是缓冲区越界——Receive的缓冲区长度参数传大了,或者memcpy的长度算错了。
解决:给缓冲区边界上一把锁,在解析协议时严格按PACKET_HEADER_SIZE和nPayloadLen做边界判断,并在代码里加上ASSERT,这样即使崩溃,也能在Debug版本里抓到现场。Release版本的问题,本质上不是编译模式的问题,而是你的解析代码本身就存在边界漏洞,Debug版本只是掩盖了它。
6. 二次开发指南:从通信骨架到业务系统的改造路径
6.1 第一步:把固定协议换成你自己的协议结构体
这个工程里协议头是固定的4字节,如果你要做成HTTP式的请求响应模型,需要扩展成一个更大的结构体。我会把包头改成一个固定长度的结构体,包含魔数、消息类型、消息体长度和校验字段,然后OnPacket函数里按消息类型分发到不同的处理函数。这样一套协议定义就能覆盖登录、心跳、业务数据上报等多种报文,具体协议字段建议定义在独立的Protocol.h文件里,两侧工程共用同一份头文件,避免两侧各写一份定义导致字段错位。
6.2 第二步:给通信层加上日志与统计指标
当业务跑起来之后,你一定会遇到“用户说数据丢了,但你不知道在哪一步丢的”这种情形。我会在OnReceive、OnPacket、OnClose三个回调里各加一行日志,记录socket句柄、当前缓冲区长度、本次收到字节数、切包结果。日志格式用时间戳加关键数值,而不是文字描述,这样后续写脚本分析报文延迟、丢包率才有数据支撑。同时维护两个计数器:总共收到多少字节、成功解析多少包,两者对比就能知道有多少数据卡在缓冲区里没有凑成完整包。
6.3 第三步:从单客户端到多客户端接入管理
现在的服务端一个连接就new一个ClientSocket,散落各处在管理上不方便。改造方向是把这些socket放进主对话框的CArray或CList里,每收到一个连接就往数组里塞一个;客户端断开时,OnClose里投递自定义消息WM_SOCKET_CLOSE,携带socket句柄,主对话框根据句柄找到对应对象再释放。同时把监听socket的backlog从5改大——你要是照抄第3.1节的参数跑一个真实的选课系统,高峰期客户端连接一拥而上,backlog太小会直接拒绝连接。经过这一步改造,服务端才真正具备承载多客户端的生产能力。
这个工程我前后帮三个不同项目做过移植,每次都在同一个教训上反复跌倒:MFC的CAsyncSocket网络层不是问题,问题永远是协议设计不合理和对象生命周期没管理好。从那以后我每改一次通信层,都强制自己先画一张socket生命周期图,标注谁创建、谁释放、事件回调和对象析构是否在同一线程,靠一张图挡住了一半以上的通信崩溃。希望这份拆解对你跑通和改造有所帮助。
本文还有配套的精品资源,点击获取