简介:这是一份基于MFC与CSocket编写的局域网聊天室服务器端程序源码包,面向MFC网络编程学习者和需要实现即时通讯功能的开发者。工程在VS2010下创建,完整实现了服务器启动、客户端接入、消息转发以及多客户端并发管理等核心环节,是理解C/S架构与Socket通信流程的典型示例。资源包共六十八个文件,主要包括cpp源文件、h头文件、rc界面资源文件、sln/vcxproj工程配置,以及编译好的exe可执行程序和pdb调试信息等,压缩包大小约27.74MB。目前已有684人学习/下载。通过阅读和运行该服务器端程序,可以直观学习CSocket在监听、连接、收发数据各阶段的使用方法,掌握多个客户端同时在线时消息分发与线程处理的思路。配合作者另发的客户端程序,即可完成聊天室联调验证,也可在此基础上进行二次开发,适合MFC入门实践或课程设计参考。
1. MFC+CSocket聊天室程序:先理解它是个网络事件分发器
很多人看到“聊天室”三个字,第一反应是登录窗口、聊天列表、发送框。真拿MFC的CSocket去写服务端才发现,界面反而是最简单的,难的是怎么管理一堆客户端Socket、谁上线谁下线、消息怎么不卡界面、怎么不粘包。这份资源把服务器端MFC和客户端放到了一起,按照“监听→Accept→收发→广播”的流程组织代码,适合刚学网络编程的学生、要做课程设计或毕设的人,也适合想快速看Windows Socket事件模型如何在MFC落地的工作者。它解决的不仅是“能聊天”,而是“多个客户端同时在线时仍然稳定”这个核心问题。
2. 为什么选CSocket而不是直接写WinSock:从消息驱动到工程初始化
2.1 CSocket与CAsyncSocket的关系
很多资料会把CSocket和CAsyncSocket混着说。MFC里CSocket是从CAsyncSocket派生的,但两者的行为差异非常大。CAsyncSocket是非阻塞异步模式,网络事件通过消息回调触发,OnReceive、OnAccept、OnConnect这些虚函数就是分发入口。CSocket则被设计成阻塞同步模式,一般和CSocketFile、CArchive配合,在独立线程里用Create、Receive这些操作,Receive会一直等到有数据才返回。
聊天室这种“多个连接、消息随时进来”的场景,服务端如果用CSocket阻塞模式,就要每个客户端开一个线程,线程里循环Receive,代价高,代码也绕。常见做法是直接使用CAsyncSocket作为服务器端监听和通信Socket,OnReceive在网络事件到达时由MFC消息泵自动调用。所以项目说明里写“基于MFC CSocket类族”,实际落地的服务器端主通信类从CAsyncSocket派生,这样才不至于一边聊天一边假死。
2.2 MFC消息循环为什么能驱动网络事件
CAsyncSocket内部用的是WSAAsyncSelect,把FD_READ、FD_ACCEPT等网络事件投递成一个窗口消息,再转到对应的回调虚函数。也就是说只要你有一个消息循环,网络事件就能被调度。MFC的CWinApp和窗口类天然具备消息泵,所以网络回调与UI操作在同一个线程里执行,省去了跨线程同步。但这也是“界面卡死”的隐患来源:OnReceive里如果做耗时解析、列表全量刷新、以及任何阻塞操作,整个窗口都会没有响应。正确做法是OnReceive里只收数据、解析和投递到队列,UI刷新交给定时器或自定义消息。
2.3 工程初始化:AfxSocketInit必须放在窗口创建之前
用CSocket家族之前,先要让MFC加载WinSock库。标准位置是App的InitInstance最前面。代码:
BOOL CMyChatApp::InitInstance() { // 必须在任何Socket对象创建前初始化,失败时多半是系统网络组件异常 if (!AfxSocketInit()) { AfxMessageBox(_T("Socket初始化失败,请检查系统网络支持")); return FALSE; } CWinApp::InitInstance(); // 主对话框在这里创建 CMyChatDlg dlg; m_pMainWnd = &dlg; dlg.DoModal(); return TRUE; }AfxSocketInit内部会调用WSAStartup,它读取协议版本和套接字实现等信息。如果返回FALSE,不要继续往下走,否则后续Create和Accept都会返回错误,而且很难排查。还有一个容易被忽略的点:这个调用不一定要在App类里,也可以放在主对话框的OnInitDialog,前提是在创建任何Socket对象之前。我一般习惯放在InitInstance,因为之后所有窗口和Socket对象的创建顺序可控。
提示:AfxSocketInit只需要在进程初始化时调用一次,别在每次连接前都调。重复调用WSAStartup可能让引用计数出错,程序退出时出现莫名问题。
2.4 创建监听Socket时的参数怎么给
服务器端监听Socket的创建决定了很多潜在问题。下面的代码演示了最基础的启动监听:
BOOL CListenSocket::Start(UINT nPort) { // SOCK_STREAM表示TCP流式;FD_ACCEPT表示仅注册accept事件 if (!Create(nPort, SOCK_STREAM, FD_ACCEPT)) return FALSE; // 绑定所有本机地址,端口由参数指定 if (!Bind(nPort, _T("0.0.0.0"))) return FALSE; // 第二个参数是等待accept的队列长度,不是最大客户端数 if (!Listen(5)) return FALSE; return TRUE; }Create的第一个参数传0表示让系统分配随机端口,服务端必须显式传端口。SOCK_STREAM对应TCP,聊天室收发顺序有保证,但TCP是字节流,没有保留消息边界,后面章节会专门说。FD_ACCEPT表示只关心连接请求事件;如果你还要处理监听Socket的其他事件,可以用FD_READ | FD_ACCEPT这种组合。Bind失败最常见的原因是端口被占用,以及程序没有管理员权限却绑定了小于1024的端口。Listen的第二个参数在Windows上不是最大并发数,它只是待处理连接队列的上限,真正的并发上限由Accept循环和系统资源决定。
2.5 一个客户端一个对象:列表管理的必要性
服务端不能只用一个Socket收所有人数据。每个连接Accept之后都要new一个通信Socket对象,并加入容器。常见做法是维护一个CList<CClientSocket*>或者CArray,后续广播就遍历这个容器。为什么不能像某些控制台示例那样共用全局缓冲区?因为网络事件回调是按Socket对象派发的,共用缓冲区会导致A客户端没读完的数据被B客户端覆盖,“多人聊天”立刻变成“串台”。这个点做服务器端MFC时几乎都会踩一次。
3. 服务器端MFC:Accept连接、客户端管理与消息广播的实现
3.1 监听Socket的生命周期
监听Socket通常作为主对话框的子对象,也可以是App的成员。它负责接收新的连接请求,不负责收发数据。生命周期从Start开始,到关闭程序时结束。需要保证Start调用前AfxSocketInit已经完成。一个容易犯的错是在对话框构造函数里Start,那时窗口还没创建,消息泵不稳定,可能导致事件回调收不到。建议在OnInitDialog里启动。
BOOL CMyChatDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 此时窗口句柄已经创建,消息循环可用 if (!m_listenSocket.Start(DEFAULT_PORT)) { AfxMessageBox(_T("监听启动失败,端口可能被占用")); EndDialog(IDCANCEL); return FALSE; } return TRUE; }监听Socket关闭时,要把等待Accept的请求全部清掉。先Close再删除对象,顺序不能反。如果程序退出时直接delete监听Socket,而没有先Close,底层socket句柄可能不会立即释放,重启程序时会偶尔碰到“端口被占用”的报错。
3.2 Accept新连接时要处理哪些事
当有客户端连接时,监听Socket的OnAccept被调用。这时必须创建一个新的CClientSocket对象,调用Accept。这里的关键点是必须动态分配,因为对象要存活到连接关闭。如果写成栈对象,函数返回后对象析构,Socket会被Close,连接立刻断开。参考代码:
void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode != 0) return; // 动态创建,生命周期交给连接本身 CClientSocket* pClient = new CClientSocket(); if (Accept(*pClient)) { // 把客户端对象交给主对话框管理 GetDlg()->AddClient(pClient); } else { // Accept失败要立即delete,否则泄漏 delete pClient; } }为什么用一个GetDlg()而不是直接访问对话框对象?因为监听Socket是分离的类,要用回调通知外层。常见方式是在监听Socket中保存对话框指针,或定义自定义消息发送给窗口。网上很多Demo直接全局变量,程序小没问题,但把指针清楚存起来,后续维护会少很多头疼。AddClient里要做两件事:把客户端对象加入CList,并通知界面刷新在线人数。
3.3 OnReceive收消息:先收进临时缓冲,再解析
客户端通信Socket的核心是OnReceive。这个回调在TCP数据到达时触发,但每次调用多少次、每次到达多少字节都没有保证。简单粗暴地直接处理,会出现半包和粘包。下面先给一个简化版,能跑通,但不推荐直接用于资源里,完整版会在第四章说:
void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) { // 错误码表明连接异常,需要善后 GetDlg()->RemoveClient(this); return; } char szBuf[2048] = {0}; int nRecv = Receive(szBuf, 2047); if (nRecv > 0) { szBuf[nRecv] = 0; // 简化处理:把收到的数据原样广播 GetDlg()->Broadcast(szBuf, nRecv); } else if (nRecv == 0) { // 0表示对端主动关闭 GetDlg()->RemoveClient(this); } else { // 接收失败,用GetLastError()查原因,常见10054表示对端复位 int nErr = GetLastError(); } }这个版本的问题是Receive一次最多2047字节,如果客户端一次发了3000字,剩余的数据还会再触发OnReceive,但已经被拆成两次广播,聊天内容就会断。所以纯粹这样写只能当演示。
3.4 广播:遍历客户端列表,逐个Send
广播逻辑看起来简单:for循环对每个客户端调用Send。但有两件事必须处理:一个是Send返回值和剩余字节,一个是在遍历中不能remove元素。如果某个客户端已经断开了,但还没来得及从列表移除,Send会失败,不能因此中断整个广播。参考写法:
void CMainDlg::Broadcast(const char* pBuf, int nLen) { POSITION pos = m_clientList.GetHeadPosition(); while (pos != NULL) { POSITION cur = pos; CClientSocket* pClient = m_clientList.GetNext(pos); int nSent = 0; while (nSent < nLen) { int nRet = pClient->Send(pBuf + nSent, nLen - nSent); if (nRet == SOCKET_ERROR) { // 发送失败,将该客户端标记为待删除 pClient->MarkForRemove(); break; } nSent += nRet; } } }为什么用内层while循环?因为Send在非阻塞模式下可能只发送一部分数据,需要循环发送到全部发出。按经验,局域网内一次Send通常能发全,但跨网络、数据长时,剩余部分若不兜底,就会出现前面能收到、后面丢一截的诡异现象。MarkForRemove只是打个标记,不要在这里直接删除元素。正确做法是广播结束后统一遍历一次,把标记过的对象从列表移除并delete,避免迭代器失效。
3.5 程序退出时的清理顺序
对话框销毁时,必须关闭所有Socket并delete对象。一个可靠顺序是:先让监听Socket停止监听,再遍历关闭客户端Socket,然后清理列表。如果顺序反了,监听Socket仍可能在Accept新连接,导致动态对象创建后无人接管,造成泄漏。
void CMainDlg::CleanUpAllSockets() { // 第一步:关闭监听,不再接受新连接 if (m_listenSocket.m_hSocket != INVALID_SOCKET) { m_listenSocket.Close(); } // 第二步:关闭所有客户端连接 POSITION pos = m_clientList.GetHeadPosition(); while (pos != NULL) { CClientSocket* pClient = m_clientList.GetNext(pos); if (pClient->m_hSocket != INVALID_SOCKET) pClient->Close(); } // 第三步:统一释放对象 pos = m_clientList.GetHeadPosition(); while (pos != NULL) { CClientSocket* pClient = m_clientList.GetNext(pos); delete pClient; } m_clientList.RemoveAll(); }这里要特别注意,Close和delete最好分开两轮做。有些人在第一轮里既Close又delete,但遍历用的POSITION在删除后可能失效。两轮循环虽然多几行,但能避开很多崩溃。退出时如果还有数据没发完,直接Close会丢弃发送缓冲,所以正式资源里会在Close前先Shutdown(SD_BOTH)或等一个短延时。
4. 客户端与通信协议:长度前缀、粘包处理与中文编码
4.1 客户端连接:Connect、OnConnect与OnReceive
客户端逻辑比服务端简单,但同样要用CAsyncSocket的派生类。在Connect之前先Create,创建一个空Socket,然后Connect到主机和端口。Connect是非阻塞的,返回SOCKET_ERROR不一定是失败,结果由OnConnect回调通知。
BOOL CClientSocket::Start(LPCTSTR lpszServer, UINT nPort) { if (!Create()) return FALSE; return Connect(lpszServer, nPort); } void CClientSocket::OnConnect(int nErrorCode) { if (nErrorCode == 0) { // 连接成功,发送登录协议 SendLogin(_T("visitor01")); } else { AfxMessageBox(_T("服务器连接失败")); } }注意Create()没有参数时,会创建默认的SOCK_STREAM套接字,但不会绑定端口。客户端不需要指定本地端口,系统会随机分配。OnConnect的nErrorCode为0才表示成功。这里有个坑:有些资料在Connect之后就立刻Send,对于异步Socket这是无效的,因为连接还没建立。
提示:Connect返回SOCKET_ERROR时,GetLastError()通常是WSAEWOULDBLOCK(10035),表示操作正在进行,不是真失败,一定要等OnConnect。
4.2 自定义协议:不要用sizeof(结构体)发送
聊天室要区分“上线、聊天、下线”这些动作,就得定义协议。最简单的做法是:第一个字节是消息类型,随后是两字节的包体长度,最后是包体内容。MFC的Debug配置默认是4字节对齐,如果直接定义一个结构体发送,不同编译选项、不同编译器下结构体大小会不一样,接收端就会越界。所以要用#pragma pack(push,1)强制紧凑布局。
#pragma pack(push,1) typedef struct tagMsgHeader { BYTE byType; // 1:LOGIN 2:MSG 3:LOGOUT 4:PING WORD wBodyLen; // 包体长度,不含头 // 后面紧跟包体 } MSG_HEADER, *PMSG_HEADER; #pragma pack(pop)发送端组装:
void CClientSocket::SendMessage(BYTE byType, const char* pBody) { MSG_HEADER header = {0}; header.byType = byType; header.wBodyLen = (WORD)strlen(pBody); Send((const char*)&header, sizeof(MSG_HEADER)); Send(pBody, header.wBodyLen); }这里为什么分开两次Send?因为这样写代码直观,但要注意两次Send并不保证两次完整到达。TCP会把它们拼在一起,接收端需要自己拆包。我一般会在一个循环里拼一个buffer,发送时也可以拼成一段再Send,减少网络小包数量。这个Demo为了好读,分开写,对应接收端的处理要严谨。
中文乱码问题:如果项目使用Unicode字符集,CString的内容是UTF-16宽字符,直接转char*发出去会乱码。要做到稳定,客户端发送前统一转成UTF-8,服务器接收后如果需要显示,再转回CString。常用转换函数是WideCharToMultiByte和MultiByteToWideChar,资源里已经把封装写好了。这一步不做,局域网内可能看着正常,换到另一台机器编码环境就翻车。
4.3 接收缓冲与拆包:解决粘包和半包
既然TCP是字节流,接收端就面临两个问题:一次可能收到多个完整消息,或一个消息只到了一半。常见解法是维护一个重建缓冲区:每次OnReceive收到数据,先追加到m_strBuffer,然后循环检查缓冲区前部能否构成一个完整包。
void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) return; char szTmp[4096] = {0}; int nRead = Receive(szTmp, 4095); if (nRead <= 0) { // 异常或退出处理 return; } m_strBuffer.Append(szTmp, nRead); // 不断处理完整包 while (m_strBuffer.GetLength() >= (int)sizeof(MSG_HEADER)) { MSG_HEADER header; memcpy(&header, m_strBuffer.GetBuffer(0), sizeof(MSG_HEADER)); int nTotal = sizeof(MSG_HEADER) + header.wBodyLen; if (m_strBuffer.GetLength() < nTotal) break; // 半包,等待后续数据 // 从缓冲区取出一个完整包 CStringA strBody = m_strBuffer.Mid(sizeof(MSG_HEADER), header.wBodyLen); ProcessPacket(header.byType, strBody); // 删掉已处理的前nTotal字节 m_strBuffer.Delete(0, nTotal); } }这个写法有几个细节容易出错。m_strBuffer用CStringA,包含二进制数据时可能遇到0x00截断问题,最好用CByteArray或std::string。Mid返回的内容可能含\0,CStringA虽然能保存,但后续strlen会有问题。资源里我换成自己封装的一个ByteBuffer,上面代码只是讲思路。header.wBodyLen是WORD,最大65535,如果聊天内容超长,需要扩展协议或者分块。循环里用GetLength()判断是否够,但GetBuffer返回的指针,如果缓冲区插入新数据,之前的指针可能失效,所以每轮重新取。
半包处理依靠break:缓冲区长度小于nTotal时,说明包没到齐,等下次OnReceive继续追加。这一小段逻辑是聊天室能不能稳定使用的分水岭,值得多看两遍。
4.4 客户端退出:主动通知服务端
用户点退出时,先发送LOGOUT包,再Close和Delete。如果不发通知,服务端只能靠OnReceive返回0来推断掉线,在线列表会延迟更新。发送完稍等一个小延时再关闭,避免数据没发完。实际上异步Socket中,Send后立刻Close可能导致数据丢失,常见补救是设置SO_LINGER或者用一个标志位等FD_CLOSE。
void CClientSocket::LogoutAndClose() { // 先发退出包 SendMessage(MSG_LOGOUT, ""); // 关闭发送通道,等待剩余数据发送完成 Shutdown(SD_SEND); // 稍微延时,让服务器来得及处理 Sleep(50); Close(); delete this; }Shutdown(SD_SEND)告诉对端“我不会再发数据了”,但还能接收。这样比直接Close温和,丢包概率小。Sleep(50)是个工程上的土办法,正规做法是等FD_CLOSE事件再Close,不过这里为了简单。
5. 避坑指南:CSocket聊天室最常见的五个翻车点
5.1 连不上服务器:AfxSocketInit没在起点
现象:服务器启动后监听了,但客户端Connect超时,或者程序启动时直接弹“Socket初始化失败”。
原因:AfxSocketInit调用晚了,比如放进某个按钮事件里,Socket库没就绪。也可能是系统网络组件异常,或者被安全软件拦了底层Winsock调用。
解决:把AfxSocketInit放到InitInstance最前面,并且检查返回值。如果返回FALSE,别用AfxMessageBox弹一下然后继续跑,直接return FALSE。排查时先用一个简单的控制台Winsock程序验证这台机器能否正常socket连接,排除环境问题。还有一点,有些基于动态链接库的MFC工程需要在资源里包含ws2_32.lib,虽然MFC默认会链接,但如果你手工调整过链接器依赖,缺了它会在链接期报一堆WSA函数未定义。
5.2 程序一运行就崩:Socket对象用了栈变量
现象:服务端启动正常,客户端一连上,服务端程序立刻崩溃,断点定位到Accept或者OnReceive。
原因:OnAccept里创建了CClientSocket client;这样的栈对象,函数返回后局部对象析构,连接被Close,但MFC内部可能还有针对该socket句柄的异步事件,事件到达时对象已经不存在,形成悬垂。
解决:坚持用new创建客户端Socket对象,并交给主对话框持有。对象释放的时机要明确:在OnClose或OnReceive返回0时,先Close再delete,并且把列表里对应指针置空。如果你担心内存泄漏,可以在删除后用OutputDebugString打一条日志,确认每个客户端都走了清理路径。
5.3 消息乱了:粘包与半包
现象:聊天记录有时把两条消息拼一起,有时只有半句,而且没有规律,局域网内偶尔正常,数据一多就出现。
原因:TCP是流式协议,没有消息边界。一次Receive可能得到多个包,也可能只得到半个包。如果按\n或空字符结尾判断,在中文编码下更容易出错。
解决:必须使用“头部长度字段+包体”的协议,并维护重建缓冲区。接收时先读固定长度的头,再根据wBodyLen判断完整包是否到达。资源里已经实现了这个拆包类,自己重写时特别要注意缓冲区Delete操作后的offset更新,别用旧的指针。
注意:粘包不是bug,是TCP的正常行为。不要想着让发送端每包Sleep一下来避免,那样只能降低概率,不能根除。
5.4 窗口假死:OnReceive里刷UI
现象:消息多时,界面无响应,移动窗口出现白板,CPU占用还不一定高。
原因:OnReceive本身是通过窗口消息泵调度的,在回调里直接操作ListBox、Edit等控件,或者执行Find、排序这类耗时逻辑,会阻塞消息循环。更糟的是有人直接在OnReceive里调用WaitForSingleObject等同步操作,直接卡死。
解决:OnReceive只做收数据和存入队列。处理完包后,用PostMessage给主对话框发一个自定义消息,把包内容传过去,UI更新在窗口线程的消息处理函数里做。如果你有实时性很强的需求,可以用定时器每100ms批量刷新一次聊天列表,避免每条消息都刷一遍。刚开始写网络程序的人最容易把这里写成“功能正常但界面稀碎”,这个坑值得提前防。
5.5 退出时泄漏或崩溃:delete之前没有关Socket
现象:每次关闭聊天室程序,任务管理器显示连接数不降,或者退出时触发CAsyncSocket断言。
原因:直接delete CClientSocket对象,而没有先Close,底层SOCKET句柄不会自动释放。另一个常见错误是在遍历列表时删除当前元素,导致POSITION失效,下次访问野指针。
解决:先统一Close所有Socket,再统一delete对象。遍历时先标记待删除,循环结束后再清理。程序退出时,监听Socket也要先Close再delete。如果你用CMap或者CArray保存客户端,遍历方式不同,但“标记后清理”这条原则不变。我自己的习惯是封装一个RemoveDeadClients()函数,每轮广播后或者定时器里调用,把所有失败标记的Socket送走,代码会清爽很多。
6. 进阶:把聊天室从Demo拉到能用的三个细节
6.1 心跳与断线检测
服务器给每个客户端记录lastRecvTime,用一个定时器,超过60秒没收到任何数据就判定掉线,从列表移除。注意心跳包不要走聊天协议,单独定义一个MSG_PING类型。客户端每30秒发一次。这样比等OnReceive返回0更及时,尤其网络断开时操作系统不会立刻通知。心跳的间隔要和服务器超时时间配合好,客户端30秒发一次,服务器90秒没收到再判定,避免网络抖动误杀。
6.2 发送顺序与消息回显
客户端把聊天内容发给服务器后,不要本地回显,等服务器转发回来再显示,这样所有人看到的内容顺序一致。如果要严格保序,给消息加一个自增序号,接收端按序号插入;聊天室对实时性要求高,最少做到“服务器转发即真相”。服务器广播时也要注意,先收完一条完整消息再广播,不能收到半包就发。
6.3 定时器刷新在线列表
在线列表不用每次收到消息都刷新,用定时器每500ms读取一次客户端列表,重新设置ListBox数据。这样刷UI频率可控,也不会在OnReceive里直接操作控件。当初我写第一个聊天室,把网络逻辑全塞在OnReceive里,结果消息稍多界面就卡成一团;后来把接收、拆包、广播、刷新拆成四层,才真正理解服务器端MFC的玩法。从那以后我每做一个通信项目,都会强制先画一张消息流转图,再动手写代码。希望帮到你。
本文还有配套的精品资源,点击获取