简介:这是一份采用MFC与CSocket编写的聊天室服务器端工程,开发环境为VS2010,适合正在学习MFC网络编程或者Socket通信的开发者。启动服务器端后,可以接收多个客户端登录连接,并承担消息转发中枢的角色,使得多个客户端之间能够正常互聊。压缩包一共包含68个文件,大小约27.74兆字节,里面主要有源程序文件、头文件、对话框资源文件、编译产生的中间文件和调试信息,同时附带一个可以直接运行的服务器端执行程序,方便对比验证。目前已有683人学习使用。通过这份服务器端代码,可以深入理解基于CSocket的监听连接、接收数据、转发消息等关键流程,也能掌握使用消息机制或线程处理多客户端通信的思路,再配合作者发布的客户端程序,就能组成一套完整的局域网聊天室演示系统,对课程设计或毕业设计都有实用价值。
1. 项目整体设计与技术选型
1.1 为什么选MFC+CSocket搭建聊天室
如果你玩过Windows桌面开发,应该对MFC不陌生。这个框架虽然老,但在企业级工具、上位机、内部通讯软件里依然有大量存量项目。CSocket是MFC对WinSock的封装,最大的优势是能把网络事件通过消息机制分发给窗口,写起来比裸用WinSock省心不少。
我当初接这个聊天室程序的需求,核心目标是做一个局域网内的多人文本聊天工具,要求界面是Windows原生风格,能部署在XP到Win10的老机器上,不需要额外运行时。选型时对比过几种方案:C#的WinForm上手快,但目标机器不一定装了.NET;Qt功能强,但项目里要引入一大套依赖;最后选了MFC+CSocket,理由很直接:微软自家的封装,Win平台兼容性稳,CSocket自带阻塞式收发和消息映射,代码结构清晰,适合快速交付。
1.2 聊天室的整体架构与消息流转
聊天室分服务端和客户端两个进程。服务端监听一个端口,维护在线客户端列表,负责把某个客户端发来的消息广播给其他所有客户端;客户端连接服务端后,既能发送消息,也能接收服务端转发的他人消息。
消息流转过程是这样的:客户端A调用CSocket::Send发送一段文本,服务端收到后用OnReceive进行异步通知,在回调里调用Receive接收数据,解析出消息体和来源ID,然后遍历客户端列表,把消息逐一转发给除A之外的其他客户端。整个数据流是典型的星型结构,服务端是中心节点,好处是客户端之间不用互连,网络结构简单,权限控制也方便。
2. 核心类设计与接口封装
2.1 服务端核心类:CListenSocket与CClientSocket
我用MFC的CSocket派生两个类:CListenSocket负责监听和接受连接,CClientSocket负责与每个客户端通信。先看监听类:
class CListenSocket : public CSocket { public: virtual void OnAccept(int nErrorCode); }; void CListenSocket::OnAccept(int nErrorCode) { if (nErrorCode != 0) return; CClientSocket* pClient = new CClientSocket(); if (Accept(*pClient)) { // 将新客户端加入列表,并通知主窗口刷新在线人数 theApp.GetMainWnd()->PostMessage(WM_NEW_CLIENT, (WPARAM)pClient, 0); } else { delete pClient; } }关键点在于Accept之后,必须用new创建CClientSocket对象,并在客户端断开时delete它。如果你用栈对象,函数结束连接就会被销毁,通讯立刻中断。
CClientSocket的核心是重写OnReceive和OnClose。OnReceive是异步通知,告诉你有数据到了,但不会告诉你数据长度,需要自己循环接收直到缓冲区读空。服务端的CClientSocket还要负责转发,所以要保存一个指向主窗口的指针以及当前客户端的ID。
class CClientSocket : public CSocket { public: int m_nClientID; CWnd* m_pWnd; virtual void OnReceive(int nErrorCode); virtual void OnClose(int nErrorCode); }; void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) return; // 循环读取,直到没有数据 while (true) { char szBuf[1024] = {0}; int nRead = Receive(szBuf, 1023); if (nRead == SOCKET_ERROR || nRead == 0) break; // 把数据连同客户端ID发给主窗口处理 m_pWnd->PostMessage(WM_DISPATCH_MSG, (WPARAM)m_nClientID, (LPARAM)strdup(szBuf)); } }这里有个细节,CSocket ^1 的Receive返回值:0表示连接关闭,SOCKET_ERROR表示出错。很多新手在OnReceive里只接收一次就退出,结果客户端发来的长消息被截成几段,所以要循环接收。
2.2 客户端核心类:CClientSocket与消息处理
客户端相对简单,只需要一个CClientSocket,连接成功后,把它的指针保存到对话框成员变量里。客户端同样重写OnReceive,收到消息后把文本PostMessage到主窗口,由主窗口负责显示在列表控件或编辑框里。
客户端的Connect是阻塞式的,如果服务器没启动或网络不通,界面会卡死。解决办法是在连接前调用一下SetBlockingMode(FALSE),或者干脆把连接放到工作线程里。我个人习惯的做法是:先设置非阻塞模式,在OnConnect里根据错误码判断是否成功。但CSocket的Connect非阻塞模式有个坑,连接成功会触发OnConnect回调,失败时OnConnect的nErrorCode不等于0,判断逻辑要写对。
BOOL CClientSocket::ConnectServer(const CString& strIP, UINT nPort) { SetBlockingMode(FALSE); // 先设非阻塞 Create(); BOOL bOK = Connect(strIP, nPort); if (GetLastError() == WSAEWOULDBLOCK) return TRUE; // 连接进行中,等待OnConnect return FALSE; } void CClientSocket::OnConnect(int nErrorCode) { if (nErrorCode == 0) { // 连接成功 m_pWnd->PostMessage(WM_NET_CONNECTED, 0, 0); } else { // 连接失败 m_pWnd->PostMessage(WM_NET_FAILED, 0, 0); } }这里GetLastError判断WSAEWOULDBLOCK是标准写法,判断连接是否挂起。非阻塞模式在连接过程中会返回这个错误码,属于正常现象。如果你看到返回TRUE就以为连接成功,那就错了,必须以OnConnect回调为准。
3. 关键功能的实现细节
3.1 界面布局与控件处理
聊天室界面不需要花哨,但有三个控件是刚需:显示消息的列表框、输入消息的编辑框、发送按钮。我建议用CListBox显示消息,设置行宽自动滚动,这样消息多了不会卡界面,比CRichEditCtrl轻量得多。
为了让列表框自动滚到底部,可以在添加消息后调用SetTopIndex(GetCount() - 1)。另外,如果你要在列表里区分“自己发的”“别人发的”“系统提示”,可以用不同的前缀,或者干脆用不同颜色的字体。CListBox不支持按行设置颜色,如果一定要颜色区分,就得用CListCtrl或者重绘CListBox,这里推荐一个简单方案:消息文本统一拼接成[昵称] 内容格式,视觉上已经够用。
编辑框和按钮的处理有个易错点:发送按钮点击后,如果消息为空就直接Return,避免发送空数据。收到的消息要经过编码转换,下面细说。
3.2 消息收发与字符串编码转换
MFC里最常见的就是CString,但CString默认是宽字符(Unicode),网络传输的是字节流,所以发送前必须将CString转成UTF-8或GBK的char数组。我用的是UTF-8,因为UTF-8兼容ASCII,跨平台调试也不容易乱码。转换代码如下:
CStringA UTF8_CStringA(const CString& str) { int nLen = WideCharToMultiByte(CP_UTF8, 0, str, -1, NULL, 0, NULL, NULL); CStringA strA; char* pBuf = strA.GetBuffer(nLen); WideCharToMultiByte(CP_UTF8, 0, str, -1, pBuf, nLen, NULL, NULL); strA.ReleaseBuffer(); return strA; } CString UTF8_ToCString(const char* pUTF8) { int nLen = MultiByteToWideChar(CP_UTF8, 0, pUTF8, -1, NULL, 0); CString str; wchar_t* pBuf = str.GetBuffer(nLen); MultiByteToWideChar(CP_UTF8, 0, pUTF8, -1, pBuf, nLen); str.ReleaseBuffer(); return str; }发送时:先转换再Send;接收时:先Receive到char数组,再转成CString显示。这个环节最大的坑是中文乱码。如果你的项目是Unicode字符集,直接用(char*)str.GetBuffer()把宽字节塞进Send,对方收到就是乱码甚至字符串截断。务必使用WideCharToMultiByte。
3.3 粘包半包处理与协议设计
TCP是流协议,不像UDP有消息边界。假设你一次性Send了1000字节,对方OnReceive可能收到全部,也可能分两次收到各500字节;反过来,你两次Send,对方可能一次性就收完了。这就是经典的粘包/半包问题。
最简单的解决方案是自定义协议,这里推荐“长度头+消息体”的格式:
| 4字节长度(网络字节序) | 消息体(UTF-8字节) |每次Send之前,把消息体长度放到前4个字节,一起发送。接收端OnReceive时,先把数据暂存到一个缓冲区,解析出前4字节得到消息长度,如果缓冲区里有完整的一条消息,就把它切出来处理,剩下的继续缓存。下面是我用的一个简易循环缓冲逻辑:
// 在CClientSocket里维护一个CByteArray m_arrBuffer // OnReceive中读取数据后调用这函数 void ProcessBufferedData() { while (m_arrBuffer.GetSize() >= 4) { // 取长度 unsigned int nLen = 0; for (int i=0; i<4; ++i) { nLen = (nLen << 8) | (m_arrBuffer[i] & 0xFF); // 大端序 } if (m_arrBuffer.GetSize() - 4 < (int)nLen) break; // 数据不够,等下一次接收 char* pMsg = new char[nLen + 1]; for (unsigned int i=0; i<nLen; ++i) pMsg[i] = m_arrBuffer[4 + i]; pMsg[nLen] = 0; CString strMsg = UTF8_ToCString(pMsg); m_pWnd->PostMessage(WM_DISPATCH_MSG, (WPARAM)m_nClientID, (LPARAM)strMsg.Detach()); // 移除已处理部分 m_arrBuffer.RemoveAt(0, 4 + nLen); delete[] pMsg; } }这个代码演示了如何处理TCP流,虽然看着麻烦,但聊天室一旦上人多了,没有协议处理,消息一定会错乱。很多初学者绕开这一块,直接Receive一次当成一条消息,结果是时好时坏,百思不得其解。强烈建议从一开始就按“长度头+消息体”来做。
4. 实操中的坑与排查实录
4.1 CSocket阻塞与界面卡死的解决方案
CSocket在默认情况下是阻塞模式,即Send和Receive如果没有完成,会一直等待。在MFC的UI线程里,如果某个操作阻塞超过几十毫秒,界面就会假死。最常见的是服务端Accept后,直接在那个线程里处理收数据,一旦某个客户端连接后不发数据,整个服务端就卡住了。
我遇到过一次典型问题:客户端连接后,服务端对话框的OnReceive里调用Receive,但客户端因为某种原因在收到数据前先断开,服务端的Receive返回0,按理说应该正常处理,但当时我在OnReceive里加了个无限循环等待数据齐全,结果界面直接无响应。后来我把所有耗时操作从UI线程剥离,OnReceive只负责收原始数据,然后PostMessage给主线程,主线程里再处理协议解析和转发。这样界面只接收消息、刷新控件,再也不会卡死。
4.2 常见错误及调试技巧
CSocket开发中,我整理过几个出现频率极高的错误:
| 错误表现 | 可能原因 | 解决办法 |
|---|---|---|
| Create失败,错误码10047 | 地址族不匹配,比如服务器IPv6监听,客户端IPv4连接 | 统一使用AF_INET,创建时指定端口 |
| 连接不上,错误码10061 | 服务端没启动或端口不对 | 确认服务端Listen成功,telnet测试端口 |
| Send返回错误,WSAECONNRESET | 对方强制关闭连接(比如崩溃退出) | 服务端OnClose里清理客户端指针,避免向已断开客户端继续Send |
| 中文乱码 | CString直接转char发送 | 用WideCharToMultiByte转UTF-8 |
| 消息时序错乱 | 缺少协议处理 | 添加长度头解析逻辑 |
调试技巧方面,我习惯在关键函数里加TRACE输出,比如OnAccept、OnReceive、OnClose,用DebugView查看,不用打断点也能快速定位流程。另外,如果你用Visual Studio 2013,记得在工程属性里把字符集设为“使用Unicode字符集”,否则CString和char互转的行为会变得很诡异。
4.3 关闭程序时崩溃的根因
这个坑几乎每个MFC网络程序都会踩:你关闭主窗口,程序却在析构时崩溃,或者报“Debug Assertion Failed”。原因就是CSocket对象的生命周期没管理好。
CSocket在底层创建了一个窗口句柄,用于接收Winsock消息。如果客户端Socket还没被销毁,主窗口先销毁了,或者反过来,Socket在窗口销毁后还尝试向窗口PostMessage,就会访问空指针。正确的关闭顺序是:
- 先从窗口消息处理函数中移除对该Socket的引用。
- 调用
Close()关闭Socket。 - delete指针(如果是new出来的)。
- 最后再结束窗口。
另外,对话框的OnClose事件里,要遍历所有客户端列表,逐个关闭并清理。注意CSocket的Close不是线程安全的,如果你在DoModal退出前清理Socket,建议不要在工作线程里调用Close,而是PostMessage回UI线程清理。
我在项目里是这么做的:维护一个CPtrList m_clientList,在窗口销毁时遍历:
void CServerDlg::CloseAllClient() { POSITION pos = m_clientList.GetHeadPosition(); while (pos != NULL) { CClientSocket* pClient = (CClientSocket*)m_clientList.GetNext(pos); if (pClient) { pClient->ShutDown(2); pClient->Close(); delete pClient; } } m_clientList.RemoveAll(); }OnClose回调里最好先判断指针是否非空,同时给客户端设置一个“已关闭”标记,防止同一个Socket被重复关闭导致二次delete。
5. 功能扩展与项目打包
5.1 给聊天室增加房间、昵称和心跳
基础聊天室跑通之后,扩展功能方向很多。一个是增加用户昵称和私聊,协议头部可以增加“消息类型”字段:0表示公聊,1表示私聊,2表示系统消息。另一个是心跳检测,TCP连接断开并不总是及时,比如拔网线、休眠唤醒,服务端可能很久之后才感知到客户端已下线。做法是客户端每5秒发送一个心跳包,服务端如果10秒没收到某个客户端的数据,就主动关闭该Socket。
从协议设计角度,建议在长度头后面增加1字节的“命令字”,这样后续扩展消息类型不用破坏原有格式。我自己的项目后来就加了一个简单的“房间ID”字段,客户端在连接时上报要加入的房间,服务端维护一个房间到客户端列表的映射,消息只在同房间广播,实现分频道聊天。
5.2 MFC项目打包与分发注意点
项目写完之后,发布是另一道坎。MFC程序在目标机器上跑,需要对应的VC运行库。Visual Studio 2013默认是动态链接MFC,所以目标机器需要安装vc_redist_x86.exe(或x64)。如果你不想带运行库,可以在项目属性里把“MFC的使用”改为“在静态库中使用MFC”,“运行时库”改为“多线程(/MT)”,这样生成的exe就能在裸机上跑,体积会大几百KB,但省去安装步骤。
我建议发布时用Release x86配置,因为很多老机器是32位或64位但装了32位兼容库。程序里如果用了控件,建议开启manifest自动生成,否则可能会出现“初始化失败”的提示。另外,如果你在聊天室里用了本地端口7777,记得在Windows防火墙里给这个端口加个入站规则,否则别的电脑连不上。
最后一个小提醒:CSocket类默认不支持跨平台,这个聊天室程序定位就是Windows局域网内使用。如果你想扩展到Linux服务器,可能需要换成异步Socket库或者直接上Boost.Asio。但作为MFC入门级网络编程,CSocket这套消息驱动模型确实能让初学者快速理解“网络事件驱动UI”的套路。我后来做其他项目时回看这段代码,仍然觉得把协议处理、线程模型、资源生命周期这三件事理清楚,比用什么框架都重要。
本文还有配套的精品资源,点击获取