
简介基于VS2010和CSocket编写的MFC聊天室服务器端程序面向需要完成网络编程课程设计或毕业设计的开发者也适合对Socket通信机制感兴趣的中初级程序员。服务器端启动后可与多个客户端同时建立连接并支撑客户端之间相互转发消息直观展示多人在线聊天场景下的服务端处理逻辑。服务器端与客户端分两包发布本包仅含服务器端客户端需另配作者发布的对应资源。压缩包内共68个文件约27.74MB涵盖cpp/h源文件、sln与vcxproj工程文件、rc/res资源文件、exe可执行文件、pch预编译文件以及编译调试产生的obj、pdb、tlog等中间文件类型覆盖完整。目前已有683人学习下载。通过这份工程可重点理解MFC与CSocket的事件驱动结合方式、多客户端连接管理、消息转发机制以及VS2010下MFC工程的编译调试流程。项目结构清晰依赖简单可作为开发局域网聊天工具的起步模板也能为后续扩展用户鉴权、消息记录等功能提供参考。 记得第一次用MFC写聊天室调试到凌晨两点程序一跑起来客户端就崩溃折腾了半天才发现问题出在CSocket的阻塞模式和消息循环之间的关系上。后来做完了回头看CSocket这套封装其实是很巧妙的只是一上来就踩坑的人太多。这篇东西就把我做MFCCSocket聊天室的全过程拆开讲一遍包括原理、代码骨架、以及那些不跑一遍根本发现不了的坑希望对正在做课程设计或者刚接触MFC网络编程的朋友有点帮助。1. 为什么选择CSocket而不是直接用WinSock APIMFC里做网络编程摆在你面前的路有三条直接用WinSock的socket、bind、listen那一套裸API用CAsyncSocket或者用CSocket。很多人觉得CSocket是封装层性能有损耗不如直接撸API显得自己很专业。但CSocket的性能问题在这种小规模聊天室场景里几乎可以忽略换来的是代码量和心智负担的大幅下降。CSocket的核心价值在于它解决了几个在WinSock编程里非常折磨人的问题。第一它把socket句柄和Windows窗口消息绑定在了一起。CSocket内部会维护一个窗口消息循环把网络事件比如有数据到达转换成消息通知。这意味着你不需要自己写一个线程去死循环recv不需要处理Select模型和WSAEventSelect那套回调。第二CSocket默认是阻塞模式配合CFile类的派生接口可以像读写文件一样去收发数据。这非常符合人的直觉收数据就是等待数据到来发数据就是把字节流写出去。还有一个很容易被忽略的点CSocket的每个通信线程都绑定了一个CWinThread对象数据收发是在这个线程的上下文里完成的。用熟了之后你会发现配合CWinThread的消息泵界面线程和网络线程之间的通信非常顺畅不需要自己额外封装线程同步机制。当然CSocket也有它的脾气。它不适合处理超大规模并发——单线程阻塞模型下每个连接都需要一个独立线程来伺候。做聊天室这种量级的应用几十个客户端同时在线完全够用。我见过有人拿CSocket做了个实时数据推送服务撑到两百多个连接之后开始出现响应延迟但那是另外一个故事了。2. 聊天室的服务端编程模型先想清楚消息怎么流转动手写代码之前先想清楚一件事聊天室的服务端到底要干什么。很多人上来就写socket监听结果写了一半发现不知道消息怎么转发又推倒重来。这里我用的是经典的中转模型所有客户端连接同一个服务器消息先发给服务器再由服务器广播给其他客户端。服务端的核心模块分成三块监听线程、客户端连接管理、消息广播。监听线程负责等待新连接每个连接进来之后创建一个专门伺候它的工作线程或者叫会话线程这个线程阻塞在Receive上等待消息消息广播则是把收到的消息逐个发送给除了发送者之外的所有客户端。这里涉及到一个很关键的设计问题连接管理容器用什么数据结构。不少人直接用CList或者CArray但多线程并发访问同一个列表容易出现迭代器失效和崩溃。我建议直接用CWinThread指针的数组配合一个CMutex或者CCriticalSection做保护。简单粗暴但是有效而且好调试。通信协议的格式一定要提前定好。这里最朴素的做法是用特殊分隔符来区分消息边界比如每条消息用换行符\n结尾。因为聊天消息本身是我们自己生成的不存在消息内容里包含\n的情况登录名、聊天内容在拼包之前就做了过滤。服务器每收到一个完整的数据包就按照客户端编号广播出去。有些教程会建议用结构体发送头部放一个消息长度的字段这叫长度前缀法。比分隔符法更严谨但实现复杂度高一些。聊天室这种场景分隔符足够了。真做项目的时候再考虑升级。3. 核心代码拆解服务端的监听与消息分发先把服务端的骨架搭出来。MFC的CWinApp里启动监听BOOL CChatServerApp::InitInstance() { // 启动监听线程 CServerThread* pThread (CServerThread*)AfxBeginThread( RUNTIME_CLASS(CServerThread), THREAD_PRIORITY_NORMAL, 0, CREATE_SUSPENDED); pThread-m_hWnd m_pMainWnd-GetSafeHwnd(); pThread-ResumeThread(); return TRUE; }这里有个细节AfxBeginThread有两种用法传一个控制函数指针或者传一个CRuntimeClass。传RUNTIME_CLASS创建的是CWinThread派生类的实例适合需要在线程内部持有状态的结构化设计。挂起再恢复这一步很多人不理解——其实是为了在线程开始跑之前设置好参数避免线程已经跑起来但参数还没就位的竞态条件。监听线程内部的核心结构是这样的UINT CServerThread::Run() { // 创建监听socket m_listenSocket new CSocket(); m_listenSocket-Create(9527); // 端口号 m_listenSocket-Listen(); while (!m_bStop) { CSocket* pClient new CSocket(); if (m_listenSocket-Accept(*pClient)) { // 每个客户端起一个会话线程伺候 CSessionThread* pSession (CSessionThread*)AfxBeginThread( RUNTIME_CLASS(CSessionThread)); pSession-m_pSocket pClient; pSession-m_pServer this; } else { delete pClient; } } m_listenSocket-Close(); delete m_listenSocket; return 0; }Accept这个调用是阻塞的主监听线程会卡在这里等待新连接。当有客户端连进来Accept返回我们创建一个新socket给这个客户端用然后立刻回到Accept继续等待下一个连接。这里有一个性能考量每来一个客户端就新起一个线程线程有创建开销但聊天室这种场景连接不会频繁建立和断开这种方式完全没问题。真正要担心的是线程数量失控——如果你的聊天室预期有几百人同时在线就得改成线程池或者改用IOCP那套模型了。但作为学习项目和中小规模应用1:1的线程模型是整个MFC网络编程里最简单、最好理解的方式。会话线程的Receive循环是消息流转的关键UINT CSessionThread::Run() { CString strRecv; int nRet 0; while (!m_bStop) { // 接收消息阻塞在这里直到有数据 nRet m_pSocket-Receive(strRecv.GetBuffer(4096), 4096); strRecv.ReleaseBuffer(); if (nRet SOCKET_ERROR) { // 客户端异常断开 m_pServer-RemoveSession(this); break; } if (nRet 0) { // 对端关闭连接 m_pServer-RemoveSession(this); break; } // 广播给其他所有客户端 m_pServer-Broadcast(strRecv, this); } m_pSocket-Close(); delete m_pSocket; return 0; }CSocket的Receive返回0表示对端已经优雅关闭返回SOCKET_ERROR表示异常。这两种情况都意味着这个会话废了要做清理工作。很多人写到这里忘了RemoveSession导致已断开的客户端还占着广播列表的位置后面广播的时候往一个已经关闭的socket上Send直接触发异常。广播的逻辑很简单遍历所有会话跳过自己逐个Sendvoid CServerThread::Broadcast(CString strMsg, CSessionThread* pSender) { CSingleLock lock(m_cs, TRUE); for (int i 0; i m_arrSessions.GetSize(); i) { CSessionThread* pSession m_arrSessions.GetAt(i); if (pSession ! pSender) { // 拼上发送者信息再发出去 CString strPacket pSender-m_strName _T(: ) strMsg _T(\n); pSession-m_pSocket-Send(strPacket, strPacket.GetLength()); } } }注意CSingleLock锁的作用范围。因为多线程同时在Send数据给同一个socket是有潜在冲突的这里用临界区保证同一时间只有一个线程在向某个socket写数据。4. 客户端实现界面线程与网络线程的分工客户端这边大多数人犯的错误是把网络接收逻辑直接写在了界面消息响应函数里。比如在按钮点击事件里调用Receive等消息这样做的后果是聊天窗口一动就卡死因为Receive是阻塞的它一跑起来界面就没法响应鼠标键盘了。客户端的正确结构是界面由主线程负责网络收发交给一个独立的工作线程。主线程只做三件事显示聊天记录、接收用户的输入、把输入内容交给网络线程去发送。工作线程里面核心是一个阻塞Receive的循环UINT CClientThread::Run() { char szBuffer[4096]; int nRet 0; while (!m_bStop) { nRet m_pSocket-Receive(szBuffer, 4095); if (nRet 0) { // 服务器断开 PostMessage(m_hWnd, WM_NET_CLOSED, 0, 0); break; } szBuffer[nRet] \0; // 转成CString之后投递到主线程界面显示 CString strMsg(szBuffer); PostMessage(m_hWnd, WM_NET_RECV, (WPARAM)new CString(strMsg), 0); } return 0; }这里有一个关键的技巧网络线程不能直接调用界面的控件方法去更新UI。MFC的控件操作必须在创建控件的那个线程里执行这是MFC的线程亲和性规则违反它的表现就是程序运行一段时间之后随机崩溃。正确的做法是用PostMessage把数据投递到主线程的消息队列主线程在消息响应函数里再做UI更新。因为PostMessage是异步的网络线程不会因为界面更新慢而被拖住。接收到的数据先new了一个CString放在堆上主线程用完记得delete掉很多人容易在这里内存泄漏。主线程处理收到消息的响应函数LRESULT CChatDlg::OnNetRecv(WPARAM wParam, LPARAM lParam) { CString* pStrMsg (CString*)wParam; // 在聊天记录框里追加内容 m_editHistory.AppendText(*pStrMsg); delete pStrMsg; return 0; }发送消息就简单了用户点击发送按钮直接把编辑框的文字塞给网络线程的socketvoid CChatDlg::OnBnClickedBtnSend() { CString strMsg; m_editInput.GetWindowText(strMsg); if (strMsg.IsEmpty()) return; // 发送是同步操作数据量小不会卡界面 m_pClientThread-m_pSocket-Send(strMsg, strMsg.GetLength()); m_editInput.SetWindowText(_T()); }为什么发送可以同步而接收必须放后台线程因为发送是用户主动触发的ShutUp一瞬间就完成了而且它不需要持续监听。如果非要给发送加上排队和重发机制那就需要独立的发送线程和缓冲区了——聊天室这个场景用不上。5. 踩坑实录CSocket编程中那些让人头秃的问题5.1 第二次连接卡死的问题这是CSocket新手遇到最多的问题第一次连接一切正常断开后第二次连接程序就卡在Connect或者Accept那里不动了。原因在于CSocket的消息处理机制。CSocket使用一个隐藏窗口来处理网络消息这个窗口和socket绑定。问题出在socket对象重用或者窗口句柄冲突上。每次创建新的CSocket对象都应该确保它和之前的没有关联。解决方法是确保连接断开后socket对象要被销毁并重新创建不要试图复用。同时监听socket在Accept了多个连接之后它会建立一个内部消息映射。如果程序结构上需要多次监听建议把监听socket的整个生命周期管理好不要在一个函数里反复创建和销毁同一个CSocket*。我自己遇到的情况是客户端断线重连时Socket对象用了同一个CSocket指针调用Close()之后没有delete直接再次调用Create()和Connect()结果就会出现无法预料的错误。改成每次重连都构建全新的CSocket对象之后问题彻底消失。5.2 跨线程访问socket导致的崩溃CSocket不是线程安全的——不过这句话有两种含义。一个含义是多个线程同时调用同一个socket对象的Send需要自己加锁。另一个含义是CSocket对象本身创建的线程决定了它的归属其他线程访问它会有潜在风险。客户端和工作线程之间我传递的是CSocket指针而不是跨线程调用它的方法。发送动作发生在主线程调用m_pClientThread的成员m_pSocket的Send——这里跨了线程。理论上不太安全但只要保证socket对象在线程生命周期内不会被销毁实际运行中基本不出问题。要做到这一点必须在客户端退出时先停工作线程再销毁socket对象顺序不能反。5.3 消息粘连和中文字符乱码CSocket底层是流式套接字TCP保证了数据顺序但是不保证边界。如果客户端连续发送两条消息服务器端可能一次Receive就把两条都收回来了这叫粘包如果消息太大也可能一次Receive只收到一半这叫拆包。聊天室的短消息场景下拆包不太容易出现但粘包非常普遍。我前面那个用\n分隔符的方案就是为了处理这种问题。收到数据之后按\n做split操作得到一条条的完整消息再分别广播。中文字符乱码是另一个经典问题。MFC的CString在多字节字符集下用GBK编码而网络传输的是字节流。如果直接拿char缓冲区收数据再转CString跨机器传输时只要两边的系统代码页一致就没事。但更稳妥的做法是统一用UTF-8编码传输收下来再转成宽字符。可以用MultiByteToWideChar做转换// UTF-8转宽字符 int nLen MultiByteToWideChar(CP_UTF8, 0, szBuffer, -1, NULL, 0); wchar_t* pwszBuf new wchar_t[nLen]; MultiByteToWideChar(CP_UTF8, 0, szBuffer, -1, pwszBuf, nLen); CString strMsg(pwszBuf); delete[] pwszBuf;5.4 Close和Delete混用导致的资源泄漏CSocket的Close()只是关闭底层socket句柄没有释放对象本身的内存。很多人在对话框关闭时调用Close然后就不管了导致CSocket对象泄漏。正确做法是如果是new出来的Close之后记得delete。ReleaseBuffer和Detach要分清楚。还有就是不要两次Close同一个socket第二次Close会触发了底层的断言失败程序直接弹错误框。写了一个CleanupSocket辅助函数来统一处理void CleanupSocket(CSocket* pSocket) { if (pSocket NULL) return; if (pSocket-m_hSocket ! INVALID_SOCKET) { pSocket-ShutDown(); pSocket-Close(); } delete pSocket; pSocket NULL; }6. 界面与交互让聊天室看起来像个正经产品网络部分跑通之后就该处理界面了。MFC做聊天室界面核心控件就三个显示聊天记录的CEdit多行模式、输入消息的CEdit、发送按钮CButton。还有一个列表控件CListCtrl可以显示在线用户列表。在线用户列表的刷新思路是客户端在连接之后主动发送一个登录命令服务器广播一个“某某进入聊天室”的系统消息。各客户端收到之后解析出用户名加入在线列表。有人退出时类似。这里涉及到协议设计的扩展。前面说用\n做分隔符那么协议就是一行一条命令。再定义一个简单的命令格式比如登录命令LOGIN|用户名聊天命令CHAT|用户名|内容退出命令QUIT|用户名。服务器收到后按|分割再按类型分发。这个思路和HTTP协议的做法是一脉相承的——用文本协议定义消息语义简单直观好调试。界面上的聊天记录框建议设成只读模式这样用户不能直接在里面输入避免内容混乱。发送消息快捷键可以响应回车按键在对话框的消息映射里处理OnKeyDown当焦点在输入框且按下回车时触发送入按钮。还有一个细节大家经常忽略关掉窗口时要先通知服务器自己退出让服务器把用户从在线列表里剔除。如果直接强杀进程服务器端那个会话线程会一直阻塞在Receive上直到TCP超时才会发现连接断开这期间广播消息还会报错。所以在OnClose事件里先发送QUIT命令再关闭socket再结束线程最后调CDialogEx的OnClose。7. 调试技巧与运行经验CSocket调试有个难点问题往往出在多线程交互上单步调试不太管用。这里分享几个我实际用下来的方法。第一善用日志。在服务器端的Receive、Accept、Broadcast这些关键路径上加日志输出记录时间和socket句柄跑一遍流程之后看日志定位问题。用MFC程序输出日志简单的方式是写文件。别用OutputDebugString它只在调试器里看得到而且多线程环境下输出顺序会乱。第二Wireshark抓包是网络编程的神器。看三次握手有没有建立看数据包的发送顺序和内容看连接断开时的FIN包。我曾经遇到过一个看似诡异的问题客户端收到的消息顺序不对抓包发现服务器发送顺序就是乱的于是知道问题出在广播逻辑的循环顺序上而不是网络传输。第三CSocket自带一个CAsyncSocket的基类接口游戏开发者用OpenGL那套经验可以借鉴——就是消息驱动。如果你想用MFC的窗口过程来接收socket消息类似于处理WM_SOCKET消息可以把CAsyncSocket当基类用重写OnReceive虚函数。CSocket的Receive其实也依赖内部的窗口消息但被封装掉了。进阶一些的思路是使用带消息参数的重载CSocket::Create(port, socketType, lpszSocketAddress, lpszMessageMap)这个消息映射参数可以用来指定处理网络消息的窗口。第四如果你在VS2013或者更高版本的Visual Studio里开发项目属性里的“字符集”选项经常带来麻烦。建议统一设置为“使用多字节字符集”因为CSocket和CString在这种模式下配合最顺畅。用Unicode字符集会遇到很多字符串转换的重复劳动。8. 从聊天室到更多应用CSocket能做什么MFCCSocket做完聊天室之后你会发现这套通讯框架可以套用到很多场景里。文件传输就是把聊天消息从短文本换成文件块。远程协助就是定义一组屏幕截图的传输协议。即使是不用MFC的场景这种C/S架构的思想、自定义文本协议的设计、多线程收发消息的内存布局规划这些底层能力都是通用的。我觉得对于还在学校的朋友这个项目的价值不只是完成课程设计。它同时覆盖了网络协议栈的实际应用、线程同步、消息驱动、UI与业务逻辑的分离设计这些在任何一个大型项目里都是核心能力。而对已经工作的开发者来说CSocket虽然偏老但MFC里关于消息泵和线程亲和性的设计思路放到今天理解C#的WinForm或者Qt的网络编程依然能看出来一脉相承的东西。最后分享一个我个人的做法在写聊天室项目时把所有的消息处理逻辑都封装到了一个独立的类里界面层只负责把用户的输入传递进来、把服务器返回的文本渲染出去。这样即便未来更换网络框架比如从CSocket换成Socket线程池界面层代码一行都不用动。这种污染隔离的思路比这个项目本身更能让你在写代码的路上走得更远。本文还有配套的精品资源点击获取