简介:这是一份面向具备Java Socket编程经验、希望转向C++网络开发的程序员的MFC实战示例,通过一个服务器对多个客户端的即时通讯场景,演示CSocket异步通信、CPtrList集合管理客户端连接以及CSocketFile与CArchive配合完成网络数据传输的完整思路。资源包共75个文件,约3.44MB,以cpp与h源码文件为主,辅以dsw、dsp工程文件、rc资源脚本及exe可执行程序,服务端与客户端两个工程均配有util辅助类目录,注释详尽、结构清晰。目前已有755人学习下载。读者可从中掌握服务端onAccept回调与客户端OnSendButton的代码阅读路径,理解MFC框架下比Java多线程更简洁的一对多通讯实现方式,并借助驼峰命名法快速定位作者手写的功能性代码,适合作为网络编程入门与效率优化的参考范例。
1. 一个服务器拖多个客户端:MFC Socket 即时通讯示例到底能跑通什么
如果你手头正好有一套 MFC Socket 编程示例,标题写着“一个服务器对多个客户端、实现简单即时通讯”,大概率会先冒出两个疑问:这东西是教学玩具还是能改造成小工具?多客户端并发到底靠什么撑住?我拆过不少这类源码包,结论是——它确实是个教学骨架,但骨架搭得干净,拿来改造成局域网内部聊天、设备状态上报、工控上位机通讯原型都够用。核心机制是CAsyncSocket或CSocket配合FD_ACCEPT、FD_READ事件驱动,服务器端维护一个客户端 socket 列表,收到消息后遍历转发。适合刚接触 Windows 网络编程、想搞懂阻塞与非阻塞区别、需要一份能直接编译运行的 MFC 工程做二次开发的人。下面按“能跑起来 → 看懂结构 → 改出功能 → 避开坑”的顺序拆。
2. 把工程跑起来:环境、编译与最小验证
2.1 环境准备与工程结构确认
这类示例通常是 VS2010 到 VS2019 的 MFC 工程,目录里一般有 Server 和 Client 两个子工程,或者一个解决方案下两个项目。先确认三件事:MFC 库是否安装、字符集是 Unicode 还是多字节、链接库有没有ws2_32.lib。MFC Socket 类底层还是 Winsock,ws2_32.lib必须链上,否则报一堆CAsyncSocket未解析外部符号。
打开解决方案后,先看项目属性里的“MFC 的使用”是否为“在共享 DLL 中使用 MFC”或“在静态库中使用 MFC”。如果换机器编译报mfc140.dll缺失,改成静态库更省事。字符集方面,老示例常用多字节,新环境默认 Unicode,直接编译会出现LPCTSTR与const char*不匹配的报错。
# 检查工程文件里的字符集配置(VS 工程文件本质是 XML) find . -name "*.vcxproj" -exec grep -l "CharacterSet" {} \; # 输出里看到 Unicode 就是 Unicode,看到 MultiByte 就是多字节这段命令用来快速定位工程用的是哪套字符集。find递归找.vcxproj,grep -l只列文件名。知道字符集后,要么统一改工程属性,要么在代码里用_T()宏包字符串,别混着写。
2.2 编译顺序与启动验证
先编译 Server,再编译 Client。启动时先开服务器,再开客户端。服务器界面通常有一个“启动监听”按钮和一个日志框,客户端有“连接”“发送”按钮和消息显示区。
// 服务器端启动监听的核心代码(CAsyncSocket 派生类) BOOL CServerDlg::StartListen(UINT nPort) { if (!m_serverSocket.Create(nPort)) // 创建监听 socket 并绑定端口 { AfxMessageBox(_T("创建监听失败,端口可能被占用")); return FALSE; } if (!m_serverSocket.Listen()) // 开始监听,默认 backlog { AfxMessageBox(_T("Listen 失败")); return FALSE; } m_bListening = TRUE; return TRUE; }Create(nPort)完成 socket 创建、绑定到指定端口;Listen()把 socket 置为监听态。常见做法是端口选 6000 以上,避开系统占用段。如果Create返回 FALSE,先用netstat -ano | findstr 端口号看是否被占。Listen的 backlog 参数不传时用默认值,客户端数量少时够用,几十个以上建议显式传。
验证流程:服务器点启动,日志显示“监听成功”;客户端填127.0.0.1和端口,点连接,服务器日志出现“接受连接”;客户端发一条消息,服务器日志显示收到,其他客户端能看到转发内容。三步都过,说明骨架是通的。
提示:如果客户端连不上,先关 Windows 防火墙的入站规则测试,排除网络层干扰后再查代码。
3. 看懂多客户端并发的骨架:CAsyncSocket 事件模型与消息转发
3.1 为什么用 CAsyncSocket 而不是 CSocket
MFC 里做 Socket 有两条路:CSocket和CAsyncSocket。CSocket是阻塞式封装,配合CSocketFile和CArchive用起来像读写文件,但它在 UI 线程里跑会卡界面,多客户端场景下容易出玄学问题。CAsyncSocket是非阻塞的,靠事件通知,服务器端每个客户端一个 socket 对象,收到FD_READ才去读,天然适合“一个服务器拖多个客户端”。
常见做法是服务器端用一个派生自CAsyncSocket的类做监听 socket,重写OnAccept,每接受一个连接就new一个客户端 socket 对象,存进CPtrList或CArray。客户端 socket 重写OnReceive,读到数据后遍历列表转发。
// 监听 socket 的 OnAccept:接受连接并为每个客户端创建独立 socket void CListenSocket::OnAccept(int nErrorCode) { CAsyncSocket::OnAccept(nErrorCode); if (nErrorCode != 0) return; CClientSocket* pClient = new CClientSocket(); if (m_pDlg->m_serverSocket.Accept(*pClient)) // 接受连接,绑定到新 socket { pClient->SetParentDlg(m_pDlg); // 回传主对话框指针,便于更新 UI m_pDlg->m_clientList.AddTail(pClient); // 加入客户端列表 m_pDlg->AppendLog(_T("新客户端接入")); } else { delete pClient; // 接受失败必须释放,否则内存泄漏 } }Accept(*pClient)把新连接绑定到pClient对象上,之后这个客户端的读写都走它自己的OnReceive。AddTail把对象指针存进CPtrList,转发时遍历这个列表。注意new出来的对象在连接断开时要delete,否则每断一次泄一个对象,跑久了内存涨得明显。
3.2 消息转发与粘包处理
多客户端即时通讯的核心动作是:收到 A 的消息,遍历列表发给除 A 以外的所有人。但 TCP 是字节流,没有消息边界,直接Receive可能一次收到半条消息,也可能两条粘在一起。示例代码里常见两种处理:一是固定长度包头,二是用分隔符。
// 客户端 socket 的 OnReceive:读取数据并转发 void CClientSocket::OnReceive(int nErrorCode) { CAsyncSocket::OnReceive(nErrorCode); if (nErrorCode != 0) return; char buf[1024] = {0}; int nRead = Receive(buf, sizeof(buf) - 1); // 留一个字节给结束符 if (nRead > 0) { buf[nRead] = '\0'; m_pDlg->BroadcastMessage(buf, this); // 转发给其他客户端 } else if (nRead == 0) { m_pDlg->RemoveClient(this); // 对端正常关闭 } else { int err = GetLastError(); if (err != WSAEWOULDBLOCK) // 非阻塞模式下无数据可读是正常的 m_pDlg->RemoveClient(this); } }Receive返回大于 0 表示读到字节数,等于 0 表示对端关闭,小于 0 要看错误码。WSAEWOULDBLOCK在非阻塞模式下表示当前没数据,不是错误,直接返回即可。BroadcastMessage里遍历m_clientList,跳过发送者自己,逐个Send。如果消息超过 1024 字节,这段代码会截断,实际用的时候要么加大缓冲区,要么做分包。
注意:
Send在非阻塞模式下也可能只发出去一部分,返回值小于请求长度时要继续发剩余部分,示例代码常忽略这点,短消息没事,长消息会丢尾巴。
4. 改出实用功能:端口配置、在线列表与断线清理
4.1 端口与 IP 参数怎么改
示例默认端口和 IP 往往写死在代码里,改的时候要同时改服务器监听端口和客户端连接端口。服务器端Create(nPort)的nPort可以从编辑框读取,客户端Connect(ip, port)同理。
// 客户端连接:从界面控件读取 IP 和端口 void CClientDlg::OnBnClickedConnect() { CString strIP, strPort; m_editIP.GetWindowText(strIP); // 例如 127.0.0.1 m_editPort.GetWindowText(strPort); // 例如 6000 UINT nPort = _ttoi(strPort); if (!m_clientSocket.Create()) // 客户端 socket 先创建,不绑定 { AfxMessageBox(_T("创建 socket 失败")); return; } if (!m_clientSocket.Connect(strIP, nPort)) { int err = GetLastError(); if (err != WSAEWOULDBLOCK) // 非阻塞连接返回 WSAEWOULDBLOCK 是正常的 { AfxMessageBox(_T("连接失败")); m_clientSocket.Close(); } } }Create()不传参数时创建的是未绑定 socket,Connect时由系统分配本地端口。非阻塞Connect立即返回 FALSE 且错误码为WSAEWOULDBLOCK是正常现象,真正连上后会触发OnConnect。判断连接成功要在OnConnect里做,不能只看Connect返回值。
4.2 在线列表维护与断线清理
多客户端场景必须维护一份在线列表,否则转发时不知道发给谁。列表用CPtrList存CClientSocket*,界面用CListBox显示。客户端断开时要从列表移除并delete,同时刷新界面。
// 移除客户端:从列表删除、关闭 socket、释放内存、刷新界面 void CServerDlg::RemoveClient(CClientSocket* pClient) { POSITION pos = m_clientList.Find(pClient); if (pos != NULL) { m_clientList.RemoveAt(pos); // 先从列表移除 pClient->Close(); // 关闭底层 socket delete pClient; // 释放对象 RefreshOnlineList(); // 刷新界面在线列表 AppendLog(_T("客户端断开")); } }顺序很重要:先RemoveAt再Close再delete。如果先delete再操作列表,指针悬空,遍历时直接崩。RefreshOnlineList遍历列表把剩余客户端信息重新填到CListBox,清空后重填,别用增量更新,容易对不上。
提示:客户端正常关闭窗口时,服务器端
OnReceive会收到nRead == 0,走RemoveClient。如果客户端进程被强杀,服务器可能收不到关闭通知,需要加心跳或超时检测,示例代码通常没有,这是改造时第一个要补的点。
5. 避坑与排查:多客户端 Socket 示例最容易翻车的五个地方
5.1 界面卡死:在 UI 线程里同步等待
现象:点连接后界面无响应,或者服务器接受多个客户端后日志不刷新。原因:在 UI 线程里用了阻塞式Receive或Accept,或者CSocket默认阻塞模式。解决:统一用CAsyncSocket事件驱动,所有读写都在OnReceive、OnAccept回调里做,不在按钮响应函数里循环等待。
5.2 内存泄漏:new 出来的客户端 socket 没 delete
现象:反复连接断开几十次后,任务管理器里内存持续上涨。原因:OnAccept里new CClientSocket,断开时只Close没delete。解决:在RemoveClient里严格按“移除列表 → Close → delete”顺序执行,并在OnClose回调里也调用RemoveClient,覆盖对端主动关闭的情况。
5.3 消息截断或粘包:缓冲区大小与分包缺失
现象:发长消息对方只收到前半段,或者两条短消息粘成一条。原因:Receive缓冲区固定 1024 字节,超出部分被截;TCP 流式传输没有消息边界。解决:短消息场景加结束符如\n,接收端按结束符切分;长消息场景加 4 字节包头表示长度,先收包头再收包体。
5.4 端口占用:Create 失败但提示不明确
现象:服务器启动报“创建监听失败”,换端口就好。原因:端口被其他进程占用,或者上一次运行没释放。解决:用netstat -ano | findstr 6000查占用进程,换 6000 以上不常用端口;调试时在Create失败后把GetLastError()打出来,别只弹“失败”。
5.5 防火墙拦截:本机通、局域网不通
现象:同一台机器上服务器和客户端能通,换另一台机器连不上。原因:Windows 防火墙默认拦截入站连接。解决:测试阶段在防火墙入站规则里放行该端口,或者临时关闭防火墙验证。正式用时加规则而不是关防火墙。
6. 进阶改造:从示例到可用小工具的三个具体动作
第一个动作是加心跳。示例没有心跳,客户端异常断开服务器不知道,在线列表会留幽灵条目。常见做法是客户端每 10 秒发一个固定格式的心跳包,服务器端记录每个客户端最后活跃时间,定时器每 30 秒扫一遍,超过 60 秒没活跃的踢掉。心跳包用Send发,服务器OnReceive里识别到心跳就只更新时间不转发。
第二个动作是把消息格式从纯文本改成带字段的结构。比如用户名|时间|内容,接收端按|切分,界面分列显示。这样改完,在线列表和聊天记录能分开维护,后面加私聊也方便——私聊就是转发时只发给目标用户而不是广播。
第三个动作是加日志落盘。示例的日志只在界面显示,关掉就没了。在AppendLog里同时写文件,用CStdioFile以追加模式打开,每行带时间戳。排查断线、粘包问题时,界面日志滚动太快看不清,文件日志能慢慢翻。
// 带时间戳的日志落盘 void CServerDlg::AppendLog(LPCTSTR lpszText) { CString strLine; CTime now = CTime::GetCurrentTime(); strLine.Format(_T("[%s] %s\r\n"), now.Format(_T("%H:%M:%S")), lpszText); m_listLog.AddString(strLine); // 界面显示 m_listLog.SetTopIndex(m_listLog.GetCount() - 1); CStdioFile file; if (file.Open(_T("server_log.txt"), CFile::modeCreate | CFile::modeNoTruncate | CFile::modeWrite)) { file.SeekToEnd(); // 追加到末尾 file.WriteString(strLine); file.Close(); } }modeCreate配合modeNoTruncate表示文件不存在就建、存在就追加不覆盖。SeekToEnd保证每次写到末尾。这段代码在消息量大时频繁开关文件有性能损耗,可以改成打开一次保持句柄,程序退出时再关。
从那以后我每次拿到这类 MFC Socket 示例,都先跑通最小验证,再按“心跳 → 消息格式 → 日志落盘”的顺序改,不急着加界面美化。骨架通了,后面都是体力活。希望帮到你。
本文还有配套的精品资源,点击获取