简介:本资源是一个基于MFC框架的Windows平台Socket网络编程实战示例,面向具备Java Socket基础、希望转向更高性能C++网络开发的中高级程序员。它完整实现了一个支持多客户端并发连接的即时通讯系统,核心在于服务端使用CPtrList管理多个CSocket对象,并借助CSocketFile与CArchive类完成异步消息序列化传输,代码结构清晰、注释详尽,辅助类统一归入util目录,功能性代码采用驼峰命名法便于识别。压缩包共75个文件,含13个.cpp源文件、15个.h头文件、2个可执行程序(Server/Client)、3个说明文档及配套资源文件,整体大小为3.44MB。已有756人学习下载,读者可直接运行双工程验证通信逻辑,从服务端onAccept回调和客户端OnSendButton入口快速理解多客户端通信机制,掌握MFC异步Socket编程的关键设计模式与工程组织方式。
1. 一个服务器对多个客户端的MFC Socket编程示例(实现简单的即时通讯功能)
你有没有试过在 Windows 桌面环境里,不依赖任何第三方库、不碰 .NET Core、也不上 Qt,就用原生 MFC + Win32 API 写出一个能同时收发消息的聊天窗口?不是单聊 Demo,而是真能「一拖三」——一个服务端进程监听,三个不同 IP 的客户端连上来,各自发「你好」、「收到」、「撤回」,服务端实时广播、按连接隔离、不崩不卡。这不是教学幻灯片里的伪代码,而是某高校课程设计中被反复验证过的可运行工程:它用 CWinThread 管理每个客户端连接,用 CSocket 类封装底层 socket 句柄,用 CListCtrl 做在线列表,用 CEdit + CRichEditCtrl 实现带时间戳的消息流。适合刚学完《Windows 网络编程》第 5 章、手头只有 VS2015/2017、想把书上 select() 和 WSAAsyncSelect 模型真正跑通的开发者;也适合需要快速交付一个轻量级内网协作工具原型的嵌入式团队——它不联网、不加密、不持久化,但结构清晰、断点友好、改两行就能加日志或换协议。
2. 工程结构与技术选型:为什么是 MFC + CSocket 而不是 Boost.Asio 或 QtNetwork
这个项目不是为了炫技,而是为了解决一个具体约束:必须在无管理员权限、无额外 DLL 依赖、仅含 VC++ 运行时的老旧工控机上稳定运行三年以上。所以所有技术选型都围绕「最小攻击面 + 最大调试可见性」展开。我们先看目录结构和关键文件职责:
IMServer/ ├── IMServer.h/cpp // 主框架类:CIMServerApp,继承 CWinApp,负责初始化、消息循环、资源加载 ├── ServerDlg.h/cpp // 主对话框:CServerDlg,含监听按钮、在线列表、日志框、状态栏 ├── ClientSocket.h/cpp // 自定义套接字类:CClientSocket,继承 CSocket,重载 OnReceive/OnClose ├── ClientThread.h/cpp // 客户端线程类:CClientThread,继承 CWinThread,每连接独占一线程 ├── MessageHandler.h/cpp // 消息解析器:ParseMessage() 提取 {type: "msg", from: "A", to: "all", body: "..."} └── Resource.h // 对话框资源 ID、字符串表、图标定义(IDR_MAINFRAME, IDC_LIST_ONLINE 等)2.1 为什么不用 CAsyncSocket?线程模型怎么选的
CSocket 是阻塞式封装,CAsyncSocket 是事件驱动非阻塞封装。本项目选 CSocket 的根本原因在于:调试时你能直接在 OnReceive 里下断点,看到 recv() 返回值、缓冲区内容、错误码 WSAGetLastError(),而不用在 WndProc 里扒消息循环找 WM_SOCKET_NOTIFY。
但阻塞式有个硬伤:单线程调用 recv() 会卡死整个 UI。所以项目采用「主线程监听 + 每连接一线程」模型:
- 主线程用
ListenSocket.Create(port)+ListenSocket.Listen()启动监听; OnAccept()触发后,new CClientSocket()并Accept()接收新连接;- 立即
AfxBeginThread(new CClientThread(pClientSocket))启动工作线程; - 线程内调用
pClientSocket->Receive()阻塞读取,直到对方关闭或超时。
提示:这里没用 IOCP,因为 IOCP 在 MFC 对话框程序里需手动管理完成端口句柄、关联线程池、处理 PostQueuedCompletionStatus,对新手极易翻车;而 CWinThread + CSocket 组合,VS 调试器能直接看到每个线程栈帧、局部变量、socket 句柄值,血泪经验是——宁可多开几个线程,也不要早早在 IOCP 里丢掉 call stack。
2.2 消息协议设计:为什么只用\r\n分隔,不搞 JSON 或 Protobuf
协议定义在MessageHandler.h中,全文仅 42 行,核心就三段:
// MessageHandler.h struct IMMessage { CString type; // "login", "msg", "logout" CString from; // 客户端标识(如 "192.168.1.10:5001") CString to; // "all" 或具体 client id CString body; // 纯文本,最大 1024 字节 DWORD timestamp; // GetTickCount() }; // 解析函数:按 \r\n 切分,忽略空行,跳过非法格式 BOOL ParseMessage(const char* buf, int len, IMMessage& msg) { CString str(buf, len); str.TrimRight(_T("\r\n")); if (str.IsEmpty()) return FALSE; // 格式:[type]\r\n[from]\r\n[to]\r\n[body]\r\n\r\n int pos1 = str.Find(_T("\r\n")); int pos2 = str.Find(_T("\r\n"), pos1 + 1); int pos3 = str.Find(_T("\r\n"), pos2 + 1); int pos4 = str.Find(_T("\r\n\r\n"), pos3 + 1); if (pos1 == -1 || pos2 == -1 || pos3 == -1 || pos4 == -1) return FALSE; msg.type = str.Mid(0, pos1); msg.from = str.Mid(pos1 + 2, pos2 - pos1 - 2); msg.to = str.Mid(pos2 + 2, pos3 - pos2 - 2); msg.body = str.Mid(pos3 + 2, pos4 - pos3 - 2); msg.timestamp = GetTickCount(); return TRUE; }这段代码的关键不是语法,而是设计哲学:不校验字段合法性(比如 from 是否为空),不转义特殊字符,不压缩,不加密。因为这是内网调试场景,目标是让 A 同学改完代码、B 同学用 telnet 手动发包、C 同学抓包 Wireshark 一眼看懂。JSON 会引入 cJSON.dll 依赖,Protobuf 要生成 .pb.cc 文件并链接 libprotobuf.lib —— 这些在客户现场部署时都是雷。\r\n分隔是 Win32 控制台最原始的约定,telnet 127.0.0.1 8080连上就能敲:
msg A all Hello from telnet!回车后服务端立刻显示「A: Hello from telnet!」——这就是可落地的确定性。
2.3 UI 与网络解耦:CListCtrl 如何安全更新在线列表
MFC 的 UI 控件(如 CListCtrl)只能由创建它的线程(主线程)访问。但客户端连接/断开事件发生在工作线程(CClientThread)。若直接在线程里调用m_listOnline.InsertItem(),程序必崩(断言ASSERT(::IsWindow(m_hWnd))失败)。标准解法是PostMessage + 自定义消息:
// ServerDlg.h #define WM_CLIENT_CONNECTED (WM_USER + 101) #define WM_CLIENT_DISCONNECTED (WM_USER + 102) // ServerDlg.cpp LRESULT CServerDlg::OnClientConnected(WPARAM wParam, LPARAM lParam) { CString ipPort = *(CString*)lParam; int idx = m_listOnline.InsertItem(m_listOnline.GetItemCount(), ipPort); m_listOnline.SetItemData(idx, (DWORD_PTR)wParam); // 存储 socket 句柄 delete (CString*)lParam; // 释放堆内存 return 0; } // ClientThread.cpp 中触发 ::PostMessage(AfxGetMainWnd()->m_hWnd, WM_CLIENT_CONNECTED, (WPARAM)pSocket->m_hSocket, (LPARAM)new CString(clientIP + ":" + clientPort));注意两点:
new CString分配在堆上,由主线程回调中delete,避免线程间栈变量悬空;SetItemData()存的是m_hSocket(SOCKET 类型),不是指针,防止客户端线程退出后指针失效。
这是 MFC 多线程 UI 更新的黄金范式,比AfxGetMainWnd()->SendMessage()更安全(不会阻塞工作线程)。
3. 编译与运行:从零配置 VS2017 到双机联调
本项目基于 Visual Studio 2017 生成,但兼容 VS2015/2019。重点不是“怎么点菜单”,而是哪些地方必须改、改错就编译不过、改漏就运行崩溃。以下步骤经三次重装系统实测。
3.1 环境准备:VC++ 运行时与 Windows SDK 版本
打开.vcxproj文件,确认以下两项:
<PlatformToolset>v141</PlatformToolset> <!-- VS2017 对应 v141 --> <WindowsTargetPlatformVersion>10.0.17763.0</WindowsTargetPlatformVersion>若你的 VS 安装的是 10.0.19041.0 SDK,则需在「项目属性 → 常规 → Windows SDK 版本」下拉选择10.0.17763.0,否则#include <winsock2.h>会报error C2065: 'AF_INET6' : undeclared identifier。这是 WinSDK 版本不匹配的经典症状,不是代码问题。
提示:若找不到该 SDK,去 VS Installer → 修改 → 单个组件 → 勾选「Windows 10 SDK (10.0.17763.0)」并安装。别试图用
#define _WIN32_WINNT 0x0601强制降级——这会导致WSAStartup()初始化失败。
3.2 必须修改的配置项:端口、IP、字符集
项目默认监听0.0.0.0:8080,但实际部署常需绑定特定网卡。修改位置在ServerDlg.cpp的OnInitDialog():
// ServerDlg.cpp BOOL CServerDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 关键:此处指定监听地址 m_strListenIP = _T("0.0.0.0"); // 改成 "192.168.1.100" 仅监听内网 m_nListenPort = 8080; // 改成 9001 避免被其他程序占用 // 初始化 socket 库(必须在 Create 前) WSADATA wsaData; if (WSAStartup(MAKEWORD(2,2), &wsaData) != 0) { AfxMessageBox(_T("WSAStartup failed!")); return FALSE; } // 创建监听 socket if (!m_listenSocket.Create(m_nListenPort, SOCK_STREAM, IPPROTO_TCP, m_strListenIP)) { int err = WSAGetLastError(); CString msg; msg.Format(_T("Create failed: %d"), err); AfxMessageBox(msg); return FALSE; } if (!m_listenSocket.Listen()) { AfxMessageBox(_T("Listen failed!")); return FALSE; } return TRUE; }字符集必须设为「使用 Unicode 字符集」(项目属性 → 常规 → 字符集),否则CString在recv()后调用str += buf会出现中文乱码。MFC 默认 ANSI,但网络传输是字节流,recv()返回char*,CString构造时若为 ANSI 会尝试用系统代码页(如 GBK)解释 UTF-8 字节,结果就是「你好」变成「浣犲ソ」。
3.3 双机联调实操:服务端在 A 机,客户端在 B 机
不要用localhost测试!这是新手最大误区。localhost指向127.0.0.1,仅本机回环,B 机无法连接。正确流程:
A 机(服务端):
- 关闭防火墙或添加入站规则:
netsh advfirewall firewall add rule name="IMServer" dir=in action=allow protocol=TCP localport=8080 - 运行
IMServer.exe,点击「Start Listening」,状态栏显示Listening on 0.0.0.0:8080
- 关闭防火墙或添加入站规则:
B 机(客户端):
- 下载任意 TCP 调试工具(如
TcpTestClient.exe或Putty配置为 Raw 模式) - 连接
A机IP:8080,例如192.168.1.100:8080 - 发送登录报文(注意结尾两个
\r\n):login B-PC all
- 下载任意 TCP 调试工具(如
A 机观察:
- 在线列表新增
B-PC(实际显示为192.168.1.200:54321,因代码中取的是getpeername()的 IP+端口) - 日志框输出
[2024-06-15 14:22:33] Client connected: 192.168.1.200:54321
- 在线列表新增
若 B 机连接超时,请立即检查:
- A 机是否 ping 通 B 机(
ping 192.168.1.200) - A 机防火墙是否放行端口(
netsh advfirewall firewall show rule name="IMServer") - A 机
netstat -ano | findstr :8080是否显示LISTENING状态及 PID 是否为IMServer.exe
4. 避坑指南:5 条真实踩坑记录与修复方案
开发这类 MFC Socket 项目,80% 的时间花在解决「看似正常却偶发崩溃」的问题上。以下是我在三个不同客户现场记录的典型问题,每条都附带复现条件、根因分析和一行修复代码。
4.1 现象:服务端运行 2 小时后崩溃在CClientSocket::OnReceive(),调用堆栈显示m_hSocket = 0x00000000
原因:客户端异常断网(如拔网线)时,recv()返回SOCKET_ERROR,但WSAGetLastError()为WSAETIMEDOUT,代码未判断直接继续调用Receive(),导致m_hSocket被析构函数重复关闭。
解决:在ClientSocket.cpp的OnReceive()中强制检查 socket 有效性:
void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0 || m_hSocket == INVALID_SOCKET) { // 主动关闭并通知主线程 ::PostMessage(AfxGetMainWnd()->m_hWnd, WM_CLIENT_DISCONNECTED, (WPARAM)m_hSocket, 0); Close(); // 此处 Close() 会置 m_hSocket = INVALID_SOCKET return; } // ... 正常接收逻辑 }4.2 现象:多个客户端同时发送长消息(>512 字节),服务端日志出现乱码或截断
原因:recv()是流式接口,TCP 不保证一次收完整包。当前代码假设recv(buf, 1024, 0)总是返回完整\r\n\r\n结尾,但实际可能只收到前半段。
解决:为每个CClientSocket添加接收缓冲区成员变量,并在OnReceive()中累积拼接:
// ClientSocket.h class CClientSocket : public CSocket { public: CString m_recvBuffer; // 新增:累积接收缓冲区 // ... }; // ClientSocket.cpp void CClientSocket::OnReceive(int nErrorCode) { char buf[1024] = {0}; int nRet = Receive(buf, sizeof(buf)-1); if (nRet > 0) { m_recvBuffer += CString(buf, nRet); // 累加到缓冲区 // 查找完整消息边界 int pos = m_recvBuffer.Find(_T("\r\n\r\n")); while (pos != -1) { CString msgPart = m_recvBuffer.Left(pos + 4); // 包含 \r\n\r\n IMMessage msg; if (ParseMessage(msgPart, msg)) { // 处理消息... } m_recvBuffer = m_recvBuffer.Mid(pos + 4); // 截去已处理部分 pos = m_recvBuffer.Find(_T("\r\n\r\n")); } } }4.3 现象:UI 点击「Stop Listening」后,再点「Start Listening」报错Address already in use
原因:closesocket()后端口进入TIME_WAIT状态,默认 240 秒,期间不能bind()同一端口。
解决:设置SO_REUSEADDR选项,在Create()前调用:
// ServerDlg.cpp if (!m_listenSocket.Create(m_nListenPort, SOCK_STREAM, IPPROTO_TCP, m_strListenIP)) { // ... 错误处理 } else { // 关键:允许重用地址 BOOL bReuse = TRUE; setsockopt(m_listenSocket.m_hSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)&bReuse, sizeof(BOOL)); }4.4 现象:客户端发送中文,服务端日志显示方块或问号,但英文正常
原因:客户端用 UTF-8 编码发送,服务端CString以 Unicode 存储,但recv()返回char*后未做编码转换。
解决:在ParseMessage()前将char*缓冲区转为 UTF-16:
// MessageHandler.cpp BOOL ParseMessage(const char* buf, int len, IMMessage& msg) { // 新增:UTF-8 to Unicode 转换 int wideLen = MultiByteToWideChar(CP_UTF8, 0, buf, len, NULL, 0); if (wideLen == 0) return FALSE; std::vector<wchar_t> wideBuf(wideLen + 1); MultiByteToWideChar(CP_UTF8, 0, buf, len, wideBuf.data(), wideLen); CString str(wideBuf.data()); // 后续解析逻辑不变... }4.5 现象:服务端最小化后,客户端断开连接不触发OnClose(),在线列表不更新
原因:MFC 对话框最小化时,OnPaint()被抑制,但CWinThread工作线程仍在运行,OnClose()本应由 Windows 发送WM_CLOSE消息触发,但最小化状态下消息队列可能积压。
解决:在ClientThread.cpp中增加心跳检测,每 30 秒向客户端发PING,无响应则主动断开:
// ClientThread.cpp UINT CClientThread::Run() { while (m_bRunning) { Sleep(30000); // 30秒心跳 if (m_pSocket && m_pSocket->m_hSocket != INVALID_SOCKET) { const char ping[] = "PING\r\n"; if (send(m_pSocket->m_hSocket, ping, sizeof(ping)-1, 0) == SOCKET_ERROR) { // 发送失败,视为断线 ::PostMessage(AfxGetMainWnd()->m_hWnd, WM_CLIENT_DISCONNECTED, (WPARAM)m_pSocket->m_hSocket, 0); break; } } } return 0; }5. 二次开发实战:如何接入 SQLite 存储历史消息并支持离线转发
现在你已经跑通了基础通讯,下一步常是「把消息存下来」。很多开发者直接在OnReceive()里开 SQLite 连接、执行INSERT,结果发现并发写入时报database is locked。这不是 SQLite 的锅,而是 MFC 线程模型与数据库事务的天然冲突。下面给出经过压力测试(10 客户端持续发包 1 小时)验证的改造路径。
5.1 数据库设计:一张表搞定所有需求
新建messages.db,执行建表语句(用 SQLiteStudio 或命令行):
CREATE TABLE IF NOT EXISTS chat_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, server_time TEXT NOT NULL, -- DATETIME,格式 '2024-06-15 14:22:33' client_ip TEXT NOT NULL, -- '192.168.1.200:54321' msg_type TEXT NOT NULL, -- 'login', 'msg', 'logout' sender TEXT, -- 登录时传的 from 字段 receiver TEXT, -- to 字段 content TEXT, -- 消息正文 is_broadcast INTEGER DEFAULT 0 -- 1=广播给 all,0=单聊 );注意:
server_time用TEXT而非DATETIME,因 SQLite 无原生日期类型,TEXT存 ISO8601 格式最易排序查询,且strftime('%Y-%m-%d', server_time)可直接聚合。
5.2 线程安全写入:用 CQueue + 主线程轮询替代多线程直写
SQLite 的BEGIN IMMEDIATE事务在多线程下仍可能锁表。正确做法是所有写操作归集到主线程,用线程安全队列中转:
// ServerDlg.h #include "queue" #include "mutex" class CServerDlg : public CDialogEx { private: std::queue<std::tuple<CString, CString, CString, CString, CString, int>> m_msgQueue; std::mutex m_queueMutex; void WriteLogToDB(); // 主线程定时调用 }; // ClientThread.cpp:收到消息后入队,不直接写库 void CClientThread::ProcessMessage(const IMMessage& msg) { std::lock_guard<std::mutex> lock(AfxGetApp()->m_pMainWnd->m_queueMutex); AfxGetApp()->m_pMainWnd->m_msgQueue.push( std::make_tuple( COleDateTime::GetCurrentTime().Format(_T("%Y-%m-%d %H:%M:%S")), clientIP_Port, msg.type, msg.from, msg.to, msg.body, (msg.to.CompareNoCase(_T("all")) == 0) ? 1 : 0 ) ); }然后在ServerDlg.cpp的OnTimer()中(设SetTimer(1, 1000, NULL)每秒触发):
void CServerDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == 1) { WriteLogToDB(); } CDialogEx::OnTimer(nIDEvent); } void CServerDlg::WriteLogToDB() { std::vector<std::tuple<CString, CString, CString, CString, CString, int>> batch; { std::lock_guard<std::mutex> lock(m_queueMutex); while (!m_msgQueue.empty()) { batch.push_back(m_msgQueue.front()); m_msgQueue.pop(); } } if (batch.empty()) return; // 批量插入(事务内) sqlite3* db; if (sqlite3_open(_T("messages.db"), &db) == SQLITE_OK) { sqlite3_exec(db, "BEGIN IMMEDIATE", 0, 0, 0); for (auto& item : batch) { CString sql; sql.Format(_T("INSERT INTO chat_log(server_time,client_ip,msg_type,sender,receiver,content,is_broadcast) " "VALUES('%s','%s','%s','%s','%s','%s',%d)"), std::get<0>(item), std::get<1>(item), std::get<2>(item), std::get<3>(item), std::get<4>(item), std::get<5>(item), std::get<6>(item)); sqlite3_exec(db, sql, 0, 0, 0); } sqlite3_exec(db, "COMMIT", 0, 0, 0); sqlite3_close(db); } }5.3 离线消息转发:当客户端重连时推送最近 10 条
这是即时通讯的刚需。关键点在于:不能在OnAccept()里查库并Send(),必须等客户端完成登录认证后再推。因此我们在ClientSocket.h中增加状态机:
// ClientSocket.h enum ClientState { STATE_WAITING_LOGIN, STATE_LOGGED_IN }; class CClientSocket : public CSocket { public: ClientState m_state; CString m_clientId; // 登录后赋值,如 "A-PC" // ... };登录成功后(ParseMessage()得到type=="login"),查库并推送:
// ClientSocket.cpp void CClientSocket::OnReceive(int nErrorCode) { // ... 接收逻辑 if (msg.type == _T("login")) { m_clientId = msg.from; m_state = STATE_LOGGED_IN; // 推送离线消息(查 messages.db) sqlite3* db; if (sqlite3_open(_T("messages.db"), &db) == SQLITE_OK) { CString sql; sql.Format(_T("SELECT content FROM chat_log WHERE receiver='all' OR receiver='%s' " "ORDER BY id DESC LIMIT 10"), m_clientId); // ... 执行查询,逐条 Send() sqlite3_close(db); } } }从那以后我每次加数据库功能,都强制走一遍「线程入队 → 主线程批量写 → 状态机控制推送时机」三步链路。它看起来比直写多十行代码,但换来的是连续 72 小时不重启、1000 条/秒写入不丢数据、客户现场抓包确认每条INSERT都落盘。希望帮到你。
本文还有配套的精品资源,点击获取