尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MFC与Socket实战:手写一个局域网即时通讯聊天室

MFC与Socket实战:手写一个局域网即时通讯聊天室 简介示例以MFC Socket实现一台服务器同时连接多个客户端的即时通讯面向有Java网络编程基础、希望用C/MFC写出高效通信程序的开发者。工程基于VC 6.0与Windows 2003 SDK服务端借助CSocket异步特性及CPtrList集合保存客户端对象配合CSocketFile与CArchive完成消息收发避免了Java多线程的繁琐写法逻辑清晰易读。压缩包共75个文件、约3.44MB包含完整VC工程dsw/dsp、头文件与源码h/cpp、可执行程序exe、资源及文本说明等并带有编译生成的Debug/Release文件可先运行体验再对照源码学习。辅助类统一放在util目录核心代码采用驼峰命名注释详细客户端建议从OnSendButton入手服务端从onAccept回调读起便于快速把握对多客户端的监听与转发流程。资源已有755人学习下载适合作为跨语言Socket编程的参考范例。1. 项目概述与核心思路1.1 这个项目到底解决什么问题先说结论这是一个非常适合用来理解网络编程底层模型的练手项目。平时我们聊到Socket编程很多人第一反应是Python、Java或者C#因为语法简洁、两三行就能跑通一个demo。但在Windows平台上做桌面级的即时通讯工具MFC依然是很多老旧项目、工业软件、内网工具的首选框架。我接手过不少维护项目界面还是MFC对话框通信还是裸Socket这种情况下如果你只会Python那套玩法基本就是两眼一抹黑。这个示例做的事情很简单一台服务器程序启动后监听固定端口多个客户端程序连上来任意一个客户端发送消息服务器把消息广播给所有其他客户端实现群聊效果。听起来像是一个简化版聊天室但麻雀虽小五脏俱全连接管理、并发接收、消息转发、界面刷新这些网络编程和Windows GUI编程里的经典难点全都涉及了。1.2 为什么选MFC而不是其他方案我在实际开发中的体会是MFC做这种项目最大的价值在于它能让你把Windows的消息机制和Socket的异步特性结合起来思考。如果单纯写一个控制台程序跑Socket逻辑学到的只是API调用如果单纯用MFC做个漂亮的聊天界面学到的只是控件消息映射。两者结合之后你被迫去解决网络线程怎么跟UI线程通信收数据不能阻塞界面多个客户端连接怎么管理这些真实项目里绕不开的问题。另外一个很现实的原因是Windows下用Socket做通信微软官方给了好几条路原始Winsock API、CAsyncSocket、CSocket、WinSock2的IOCP。CAsyncSocket把Winsock封装成了消息驱动的模式CSocket在此基础上又套了一层阻塞同步语义。直接上手CAsyncSocket容易被底层的FD_READ、FD_WRITE消息搞晕CSocket的阻塞模式又很容易把界面卡死。绕来绕去最稳妥的学习路径反而是直接用原始Winsock API配合工作线程和Windows消息自己掌控一切。这也是今天我分享的方案选型逻辑原始API打底线程负责收发消息负责上屏。等你理解了这套逻辑回头再看那些封装类会瞬间通透。2. 环境准备与核心原理拆解2.1 开发环境搭建既然是基于MFC开发环境首选Visual Studio。我测试用的是VS2013但后续的VS2015、VS2017、VS2019、VS2022都没问题操作路径基本一致。新建项目时选择MFC应用程序应用程序类型选基于对话框这样生成的是一个带对话框界面的最小框架。有一点需要注意如果选择使用Unicode库那么所有字符串都要带_T()宏或者用CString处理。后面代码里凡是出现格式字符串的地方我都写了_T()就是为了避免字符集不一致导致的编译报错。项目属性里的字符集选项建议统一设置为使用Unicode字符集。网络部分不需要额外安装任何库文件。Windows提供了ws2_32.lib大多数情况下MFC框架已经默认链接了。如果你编译时遇到WSAStartup等函数找不到链接手动在项目属性-链接器-输入-附加依赖项里加上ws2_32.lib就行。这是最常见的一个环境坑。2.2 Socket通信的基本流程不管服务器还是客户端TCP通信的过程都可以总结为一条主线初始化Winsock - 创建套接字 - 绑定地址 - 监听/连接 - 收发数据 - 清理资源。服务器端的流程是WSAStartup初始化环境socket()创建监听套接字bind()绑定IP和端口listen()进入监听状态然后循环调用accept()接受客户端的连接请求。每次accept()成功会返回一个全新的套接字这个新套接字专门负责和对应客户端通信。监听套接字则继续等待新的连接。客户端的流程是WSAStartup初始化环境socket()创建套接字connect()向服务器发起连接连接成功后用这个套接字send()和recv()收发数据。这里有一个很多初学者容易忽略的细节TCP是流式协议收发数据不像打电话那样你说一句我回一句而是像水管一样持续流动。recv()函数拿到的数据可能是一次send()发来的也可能是半包也可能是两包合并在一起。在写聊天程序时最常用的做法是定义简单的消息边界。我们这个示例采用最直观的方式每条消息以换行符\n结尾收到数据后按换行符切割成完整消息再处理。这个方案在真实项目中其实也够用很多内网协议就是这么设计的。2.3 一对多连接模型如何选型一个服务器面对多个客户端核心问题是怎么同时处理多个套接字的收发事件。经典方案有三种。第一种是多线程模型服务器每accept()到一个新客户端就创建一个工作线程专门处理这个连接。客户端断开后线程退出。优点是好理解、代码直观缺点是连接数量上百后线程资源开销大。第二种是select模型用一个线程轮询所有套接字的状态有数据才处理。优点是节省线程资源缺点是每次轮询都要把套接字集合从用户态拷贝到内核态连接多了效率下降。第三种是IOCP完成端口这是Windows下的高性能王者几千上万连接都不怕但实现复杂度明显上升。考虑到这个项目定位是简单即时通讯我在代码里选择了多线程模型。理由很实在代码逻辑容易讲清楚新手能看懂调试也方便。你只要把一个客户端一个线程这件事记住后面的代码基本就是顺着这个思路铺开的。3. 服务端代码实现详解3.1 界面设计与控件规划服务端界面我用了三个控件一个CListBox列表框用来显示客户端连接状态和收到的所有消息一个CButton启动服务器按钮用来开启监听一个CButton停止服务器按钮用来关闭监听并断开所有客户端连接。界面元素对应到代码里需要给控件添加变量。列表框变量命名为m_listMsg两个按钮用OnBnClickedStartServer和OnBnClickedStopServer两个消息处理函数响应点击事件。这里我建议把启动按钮在初始时设为可用把停止按钮初始设为禁用防止用户还没启动就点停止导致状态混乱。控件的消息映射函数不需要手写VS的类向导能自动生成。右键对话框资源选择添加事件处理程序选好控件ID和消息类型VS会帮你把函数声明和消息映射表都写进代码里。这是MFC做界面最舒服的地方不用像纯Win32那样手动绑定。3.2 核心数据结构与全局变量在设计服务端之前先规划好需要共享哪些数据。因为要同时管理多个客户端套接字我定义了一个CWinThread指针数组来保存每个客户端线程的句柄用一个SOCKET数组保存每个客户端的通信套接字。这里有一个关键的点多线程访问共享数据必须加锁。两个线程同时往数组里写数据数据错乱是小严重的会直接崩溃。我用了CRITICAL_SECTION临界区对象在访问客户端数组之前EnterCriticalSection进入临界区操作完成后LeaveCriticalSection退出。这是Windows下最简单的线程同步手段性能损耗很小对这个项目完全够用。// 服务器Dlg头文件中的关键成员变量 SOCKET m_sockServer; // 监听套接字 CWinThread* m_pThread[MAX_CLIENT]; // 客户端线程指针 SOCKET m_sockClient[MAX_CLIENT]; // 客户端通信套接字 int m_nClientCount; // 在线客户端数量 CRITICAL_SECTION m_cs; // 临界区对象MAX_CLIENT我在代码里定义成了50这个值可以根据项目需求调整。数值越大数组占用的内存越多但实际影响可以忽略不计。之所以用固定数组而不是动态容器是为了让代码尽量简单避免引入STL容器在多线程环境下的复杂性问题。3.3 启动服务器与监听流程点击启动服务器按钮后代码做的事情可以拆成五步初始化Winsock、创建套接字、绑定端口、开始监听、启动接受线程。void CServerDlg::OnBnClickedStart() { WSADATA wsaData; WSAStartup(MAKEWORD(2, 2), wsaData); m_sockServer socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); SOCKADDR_IN addrSrv; addrSrv.sin_family AF_INET; addrSrv.sin_addr.S_un.S_addr htonl(INADDR_ANY); addrSrv.sin_port htons(6000); bind(m_sockServer, (SOCKADDR*)addrSrv, sizeof(SOCKADDR)); listen(m_sockServer, 5); // 启动接受客户端连接的线程 AfxBeginThread(AcceptThread, this); GetDlgItem(IDC_BTN_START)-EnableWindow(FALSE); GetDlgItem(IDC_BTN_STOP)-EnableWindow(TRUE); }端口我选了6000这个值需要避开那些被系统占用的常见端口比如80、443、3306、6379这些都有特定的服务在跑如果你放的端口正好被占用bind就会报错。INADDR_ANY表示监听本机所有网卡地址这样客户端可以通过本机IP或者回环地址127.0.0.1来连接。我发现很多教程会忽略一个细节listen()的第二个参数表示等待accept处理的连接队列长度。这个5的意思是当服务器忙到来不及调用accept时最多允许5个客户端连接请求在内核排队等待。对这个小项目来说已经很宽松了不用担心。3.4 接受连接线程与客户端数组管理AcceptThread是整个服务端最核心的线程。它做的事情是死循环调用accept()等待新客户端连接一旦有连接进来就把新套接字存进客户端数组并启动一个专门的接收线程去处理这个客户端的消息。UINT AcceptThread(LPVOID pParam) { CServerDlg* pDlg (CServerDlg*)pParam; SOCKADDR_IN addrClient; int len sizeof(SOCKADDR_IN); while (TRUE) { SOCKET sockClient accept(pDlg-m_sockServer, (SOCKADDR*)addrClient, len); if (sockClient INVALID_SOCKET) break; // 将新客户端存入数组 EnterCriticalSection(pDlg-m_cs); if (pDlg-m_nClientCount MAX_CLIENT) { pDlg-m_sockClient[pDlg-m_nClientCount] sockClient; pDlg-m_nClientCount; pDlg-PostMessage(WM_UPDATE_CLIENT_COUNT); } LeaveCriticalSection(pDlg-m_cs); // 为这个客户端创建接收线程 AfxBeginThread(RecvThread, pDlg-m_sockClient[pDlg-m_nClientCount - 1]); } return 0; }这里有一个坑需要特别提醒传给RecvThread的是数组元素的地址但在创建线程之后如果下一个客户端连进来数组元素可能会被覆盖。稳妥的做法是传一个动态分配的结构体里面包含this指针和套接字值线程退出时再释放。我在示例代码中先是简单用了数组地址实测下来在连接数不多时问题不大但严谨的项目里必须用动态结构体。这个细节能体现你是否真正理解线程的生命周期。3.5 接收消息线程与广播转发每个客户端对应一个接收线程线程循环调用recv()读取数据。收到数据后把消息内容组合成标准格式然后遍历客户端数组把消息发送给除发送者之外的所有客户端实现广播效果。UINT RecvThread(LPVOID pParam) { SOCKET sockClient *(SOCKET*)pParam; char szMsg[MAX_MSG_SIZE] {0}; while (TRUE) { int nRecv recv(sockClient, szMsg, MAX_MSG_SIZE - 1, 0); if (nRecv 0) // 为0表示对方正常关闭为SOCKET_ERROR表示出错 break; szMsg[nRecv] \0; CString strMsg; strMsg.Format(_T([客户端] %s), szMsg); // 发送给所有客户端实现群聊 for (int i 0; i MAX_CLIENT; i) { if (pDlg-m_sockClient[i] ! INVALID_SOCKET pDlg-m_sockClient[i] ! sockClient) { send(pDlg-m_sockClient[i], szMsg, strlen(szMsg), 0); } } } return 0; }recv()返回0表示对方调用closesocket()主动关闭了连接返回SOCKET_ERROR表示通信异常这两种情况都要退出循环并清理套接字。特别要留意清理的时候要遍历数组找到当前套接字的位置把这个位置上的套接字关闭再把数组最后面的元素挪过来补齐空位同时把数量减一。这种移动覆盖的技巧在固定数组里处理删除操作很常用逻辑上并不复杂但是漏掉任何一个步骤都会导致数组管理混乱。广播转发时我让发送者不需要重发否则发送者自己会看到两遍消息。这个逻辑就是代码里! sockClient这个条件判断的意义。4. 客户端代码实现详解4.1 客户端界面结构客户端界面相比之下简单很多一个CEdit输入框用来填写服务器IP地址一个CEdit输入框用来填写服务器端口默认6000一个CButton连接服务器按钮一个CButton发送消息按钮一个CEdit多行输入框用来显示聊天记录再加一个CEdit输入框用来输入要发送的消息内容。布局不要太花哨重点保证用户能一眼看出哪里填IP、哪里填端口、哪里输入消息。给每个编辑框加上CEdit控件的成员变量后通过DDX_Text函数把界面上的文本绑定到CString变量这样读取用户输入只需要操作变量不用主动调用GetWindowText。MFC的DDX机制把界面和数据的同步工作做了封装用起来非常顺手。4.2 连接服务器与收发消息客户端连接服务器的代码遵循标准的socket流程。创建一个套接字填写服务器的IP地址和端口调用connect()发起连接。连接成功后启动一个接收线程持续等待服务器转发的消息。void CClientDlg::OnBnClickedConnect() { UpdateData(TRUE); // 从控件读取数据到变量 m_sockClient socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); SOCKADDR_IN addrSrv; addrSrv.sin_family AF_INET; addrSrv.sin_addr.S_un.S_addr inet_addr(m_strIP); addrSrv.sin_port htons(m_nPort); if (connect(m_sockClient, (SOCKADDR*)addrSrv, sizeof(SOCKADDR)) SOCKET_ERROR) { MessageBox(_T(连接服务器失败)); return; } AfxBeginThread(RecvThread, this); }这里有个容易踩的坑inet_addr()函数对字符串的格式要求是点分十进制的IP不能直接传域名。如果要支持域名解析需要用到gethostbyname()。在这个示例里我们只处理IP场景。客户端接收线程和服务端的接收线程逻辑相似区别在于收到数据后不只是追加到界面还要通过PostMessage把消息内容以自定义消息的形式发送到主窗口由主窗口的消息处理函数负责把文本追加到聊天记录编辑框。这样做的原因是工作线程不能直接操作MFC控件MFC的控件操作必须在创建控件的UI线程里进行。跨线程直接操作控件轻则界面不刷新重则直接崩溃。PostMessage是MFC里跨线程通信最安全也最简单的方式。UINT RecvThread(LPVOID pParam) { CClientDlg* pDlg (CClientDlg*)pParam; char szMsg[MAX_MSG_SIZE] {0}; while (TRUE) { int nRecv recv(pDlg-m_sockClient, szMsg, MAX_MSG_SIZE - 1, 0); if (nRecv 0) break; szMsg[nRecv] \0; pDlg-PostMessage(WM_RECV_MSG, nRecv, (LPARAM)szMsg); } return 0; }WM_RECV_MSG是自定义消息值可以定义成WM_USER 100。PostMessage的第三个参数传消息长度第四个参数传消息缓冲区指针的LPARAM强转值。在主窗口的消息处理函数里把缓冲区里的数据转成CString追加到聊天记录控件中然后清空缓冲区。4.3 发送消息与本地回显发送消息的代码可以用一句话概括把编辑框内容编码成UTF-8或者ANSI字符串调用send()发送到服务器。为了提高可读性我在前面加上了一个固定的前缀比如[我说] 这样发送者在自己的聊天记录框里能看到一个标明这是我发的的格式。需要说明的是这个本地回显是在界面层做的不是服务器转发回来的。也就是真正聊天时你自己的消息会先显示一次我说然后服务器再广播给其他客户端其他人看到的是客户端格式的消息。这种不对称的显示格式在真实聊天工具里很常见本地消息用我来标识远端消息用对方昵称来标识。发送按钮的响应函数在send()之后要清空输入框同时把光标焦点重新放回输入框方便连续输入多条消息。这个小体验细节能让程序用起来顺手不少。5. 常见问题与排查技巧实录5.1 端口绑定失败这个项目里最经典的问题就是服务端启动时报错对应到日志里就是类似bind: only one usage of each socket address的提示。我第一次写这个程序时就中招了程序崩溃后立即重新运行结果提示端口被占用。出现这个问题的原因有几种程序上次运行退出了但套接字还处于TIME_WAIT状态系统要等一段时间才能完全释放端口或者另一个程序正在使用同一个端口或者你自己在这个程序里重复调用了bind。解决方法也直接在bind()之前调用setsockopt()设置地址重用选项。BOOL bReuseAddr TRUE; setsockopt(m_sockServer, SOL_SOCKET, SO_REUSEADDR, (const char*)bReuseAddr, sizeof(BOOL));这行代码能很大程度上解决TIME_WAIT导致的端口占用问题。每次启动服务器时先设置这个选项实测下来几乎遇不到重启失败的情况了。5.2 客户端连不上服务器客户端点击连接服务器没反应等了半天弹出失败提示这时候要按顺序排查三个点。首先是服务器是否真的启动了监听用命令提示符执行netstat -an | findstr 6000能看到LISTENING状态说明监听成功。其次是防火墙是否拦截了通信Windows防火墙默认会阻止外部程序接入第一次运行时弹窗选择允许访问即可。最后是确认客户端填的IP地址正确如果服务器和客户端在同一台机器上测试填127.0.0.1就行如果是局域网内不同机器要填服务器的局域网IP。我要特别提醒一个细节你在客服端填的IP是服务器的地址不是你自己机器的地址。很多人拿着服务器IP填到客户端但这个IP对客户端来说必须能访问通。局域网的IP用ipconfig查公网IP或者虚拟机IP的场景要根据实际网络环境调整。5.3 界面卡死无响应程序一运行界面就转圈圈点击按钮没反应这个问题几乎都出在在UI线程里做了阻塞操作。阻塞的recv()函数、不确定耗时的connect()函数、死循环检查这些都是UI线程的大敌。UI线程有它自己的消息循环任何超过50毫秒的阻塞都会让用户感受到卡顿。解决方法其实一直在强调耗时操作全部丢到工作线程界面只用PostMessage通知更新。connect()同样应该放在工作线程等连接结果回来后再通过消息通知UI。我见过很多新人把connect()直接写在按钮响应函数里如果目标地址不可达连接超时可能需要好几秒界面就死了。5.4 消息乱码问题调试时明明发送的是中文客户端收到的却是乱码或者显示一半正常一半乱码。这种情况要检查两个地方。第一是字符集设置如果服务端用的是多字节字符集客户端用Unicode双方编码不一致自然乱码。第二是send()发送的数据长度strlen()计算的是字节数中文字符每个占2个字节GBK或3个字节UTF-8如果你按字符数去发送数据会被截断。最简单稳妥的做法是双方统一使用UTF-8编码发送前把CString转成char*接收后用MultiByteToWideChar转回CString。代码稍微多几行但跨平台兼容性好也不会因为Windows区域设置不同导致乱码。我这个示例为了保持代码简洁直接在网络上发送了ANSI字符串如果你想用在不同语言环境的Windows上建议改成UTF-8。5.5 断开连接后服务器内存泄漏客户端强退或者直接拔网线服务端对应线程如果不做处理就会一直挂在recv()上。Windows系统会在TCP连接异常断开时让recv()返回SOCKET_ERROR或0正常情况下我们就是靠这个返回值判断连接断开。但如果你不关闭对应的套接字也不把数组里的槽位释放时间长了数组会被占满新客户端连不进来。处理思路是recv()返回0或SOCKET_ERROR后调用closesocket()关闭这个套接字从客户端数组里删除对应记录并且把线程自然退出。还有一个容易被忽略的点如果服务器主动关闭时先要调用shutdown()再closesocket()确保发送缓冲区里的数据能完整送出去。6. 我从这个项目里沉淀下来的经验写这个示例前前后后花了一个周末的时间踩的坑比我预想的多得多。回头总结我觉得有三个容易被忽略但特别重要的认知。第一Socket编程里资源管理的复杂度远高于数据收发本身。每次accept()返回的套接字、每个AfxBeginThread创建的线程、每块接收缓冲区都是资源。写代码之前先想清楚这个资源由谁创建、由谁释放、释放时怎么保证没有其他线程还在用它比把数据收发逻辑写对更重要。第二跨线程访问界面这件事没有任何捷径。Windows的消息机制本身就是线程安全的PostMessage可以在任意线程调用这就是MFC留给我们的正确通道。绕开它直接操作控件大概率会出现偶发性崩溃而且是那种换台机器就复现不了的崩溃。我的经验是代码里只要出现工作线程访问控件变量的情况先停下来改成发消息。第三学习网络编程一定要自己动手抓包或者观察日志。程序跑通之后用netstat看连接状态用Wireshark抓TCP报文看你发的每个字节是怎么封包传输的你会发现课本上说的三次握手四次挥手变成了真实可见的过程这种认知转化是看一百篇文章都换不来的。这个示例虽然叫简单即时通讯但它覆盖的知识点完全可以顺延到更大的项目里把固定数组换成连接池把多线程模型换成IOCP把明文消息换成自定义协议并加入心跳检测就能变成一个可用的服务端框架。门槛提高了但底层逻辑依然是你在这几百行代码里理解的这一套。本文还有配套的精品资源点击获取
返回列表