ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UDP+CSocket+MFC实现P2P聊天室:从代码到NAT打洞

UDP+CSocket+MFC实现P2P聊天室:从代码到NAT打洞 简介这份名为P2P.rar的压缩包是一套基于C与MFC实现的UDP P2P多用户聊天室工程源码围绕P2P网络中的节点交互、自定义报文格式、UDP无连接传输等核心问题展开涵盖协议设计、套接字封装、并发处理与界面交互适合需要深入理解P2P通信机制、练习C网络编程或完成相关课程设计的开发者。包内共156个文件其中22个h头文件与15个cpp源文件构成主要代码另有10个CHM帮助文档、10个PDB调试符号、5个可执行exe以及dsp/dsw工程配置文件等资源总大小约29.55MB目录按客户端、公共模块等组织便于对照学习。该资源已有192人浏览学习具备一定参考价值。工程实现了基于UDP的P2P消息收发与多用户聊天室源码中可看到序列号恢复顺序、丢包重发、用户列表维护及线程处理等关键逻辑同时附带的CHM文档覆盖C标准库、MFC类库及Windows API说明能辅助快速查证开发细节。1. 先搞清楚手里这份 P2P.rar 是什么CSocket UDP MFC 聊天室的技术栈拆解你在搜索引擎里看到“P2P.rar_C p2p协议_csocket P2P_csocket udp_udp p2p MFC_聊天室”这个名字时大概率是想找一份能跑通的 P2P 聊天室源码而不是一份资源搜索软件的使用说明。这里面的技术点拆开是四件事P2P 指对等网络模型节点之间直接通信不走中心服务器转发CSocket 是 MFC 对 Winsock 的封装UDP 是底层传输协议MFC 负责聊天窗口。四者合起来就是常见的课程设计、毕业设计、局域网通信练习项目两个客户端互相知道地址直接发包收包实现文字聊天。适合你已经会 C 语言和一点 MFC 对话框想用最短路径理解协议栈、跑通一个能演示的通信程序的人。2. 选型与初始化UDP 做 P2P 的底气CSocket 在工作线程里的正确定位2.1 UDP 与 TCP 的差别为什么 P2P 聊天室把宝押在 UDP 上做聊天室第一反应是 TCP因为有连接、有重传、消息不丢。但题目里明确写着 udp p2p MFC这不是随便选的P2P 场景里 UDP 有两个 TCP 给不了的优势。第一UDP 无连接。TCP 通信前要 listen、connect、accept 三次握手通信双方必须在同一时刻都处在“可接受连接”的状态。而 P2P 场景中两个客户端都躲在自家 NAT 后面互相不知道对方的公网映射TCP 握手包很容易被 NAT 直接丢弃打洞成功率低UDP 可以直接向任意 IP 和端口 SendTo不需要对方先 accept只要 NAT 映射表学习到了这个对端后续报文就能到达。第二聊天室消息允许丢失。掉一两条文字消息用户感知很弱不需要为每一条消息做 TCP 那样的确认重传真要可靠自己在应用层加个编号重传也比 TCP 的字节流处理简单。落到代码上两者差别更直白。TCP 工程要维护监听套接字和连接套接字两套对象UDP 一个套接字就能收发// TCP 型写法需要 listen accept每个客户端一个连接套接字 // 伪代码示意 CAsyncSocket srv; srv.Create(6000, SOCK_STREAM); srv.Listen(); CAsyncSocket* pClient new CAsyncSocket(); srv.Accept(*pClient); // UDP 型写法一个 SOCK_DGRAM 套接字直接收发 CSocket sock; sock.Create(6000, SOCK_DGRAM, NULL); sock.SendTo(_T(hello), 10, 6001, _T(192.168.1.100));逻辑说明TCP 的 accept 会阻塞等待客户端而 UDP 的 Create 完成之后套接字已经能和任意对端通信不需要建立连接。聊天室这种动态进出的场景UDP 省掉了大量的连接管理代码。参数说明Create的第二个参数 SOCK_STREAM 与 SOCK_DGRAM 是核心区别第三个参数传 NULL 表示绑定本机所有网卡地址。如果你在 bind 时指定了具体 IP那多网卡机器上其他网卡收不到包。2.2 CSocket 与 CAsyncSocketMFC 两个套接字封装怎么分工MFC 里 CSocket 继承自 CAsyncSocket但在行为上走了完全不同的路线。CAsyncSocket 是非阻塞、事件驱动的窗口收到 FD_READ 消息后自动回调 OnReceiveCSocket 是阻塞的ReceiveFrom 没有数据时会把线程挂起并且它的高级用法 CSocketFile CArchive 是为了 TCP 字节流设计的UDP 报文用不上那套东西。所以“CSocket 做 UDP”这件事本身就有坑正确姿势是把 CSocket 放进工作线程里阻塞收包收完再 PostMessage 回 UI 线程显示。我一般不建议把 CSocket 的 ReceiveFrom 直接放在 UI 线程否则窗口在等待数据期间拖不动、点不了按钮看起来和死机一样。主流替代是直接用 CAsyncSocket OnReceive 事件但题目既然锁定了 CSocket下面这套“CSocket 阻塞线程 窗口消息”就是最贴近标题、又能不卡界面的方案。2.3 落进 MFC 工程前的三个初始化动作用 CSocket 做 UDP初始化顺序有三个常见坑按下面顺序做基本不会翻车。第一在 CWinApp::InitInstance 里调用AfxSocketInit()加载 Winsock 库。这个调用漏了后面 Create 大概率返回 SOCKET_ERROR错误码 10093WSAENOTINITIALISED很多人查半天查不到原因。第二在工作线程里创建 CSocket 对象而不是在主窗口类里创建。CSocket 的阻塞调用依赖线程状态在主线程创建、在工作线程 ReceiveFrom跨线程操作同一个套接字很容易触发 MFC 的断言失败。我把 CSocket 对象、ReceiveFrom 循环都放在工作线程内部这样最干净。第三创建时绑定固定端口。P2P 打洞要求客户端后续收发的源端口不变如果你用Create(0, SOCK_DGRAM, NULL)让系统分配随机端口NAT 映射每次重启都会变穿透就无从谈起。聊天室场景固定端口我喜欢用 6000 到 9000 之间的高位端口避开常见服务。下面这段是工作线程内部创建 CSocket 并设置接收缓冲区的示例UINT P2PReceiveThread(LPVOID pParam) { // 每个使用 CSocket 的线程都要初始化 Winsock if (!AfxSocketInit()) { return 1; } // 固定本地端口P2P 打洞时需要源端口稳定 CSocket sock; if (!sock.Create(nLocalPort, SOCK_DGRAM, NULL)) { int nErr sock.GetLastError(); // 常见错误 10048端口被占用 return 2; } // 增大接收缓冲区默认 8KB 在聊天室高频发包时容易丢包 int nBufSize 64 * 1024; sock.SetSockOpt(SO_RCVBUF, (const void*)nBufSize, sizeof(nBufSize)); char szBuf[2048] { 0 }; CString strPeerIP; UINT nPeerPort 0; while (!bThreadStop) { memset(szBuf, 0, sizeof(szBuf)); strPeerIP.Empty(); nPeerPort 0; // 阻塞没有数据时线程挂在这里 int nLen sock.ReceiveFrom(szBuf, sizeof(szBuf) - 1, strPeerIP, nPeerPort); if (nLen 0) { // 把收到的内容连 IP、端口一起打包成字符串 CString strMsg; strMsg.Format(_T([%s:%u] %s), (LPCTSTR)strPeerIP, nPeerPort, (LPCTSTR)(LPCTSTR)szBuf); // 投递到主窗口消息循环UI 线程负责显示 ::PostMessage(hMainWnd, WM_P2P_DATA, 0, (LPARAM)new CString(strMsg)); } } sock.Close(); return 0; }逻辑说明CSocket 默认收包缓冲区不大一旦对端在短时间内连续发多条消息UDP 接收缓冲满之后新报文会被协议栈直接丢弃表现就是聊天内容偶尔少几条。这里把 SO_RCVBUF 提到 64KB把丢包窗口拉大。参数说明nLocalPort是你要绑定的本地端口bThreadStop必须是线程间可见的变量建议用 volatile BOOL 或 CEventReceiveFrom的缓冲区szBuf用 2048单条聊天消息足够了UDP 单报文上限是 65507 字节但你在 UDP 里塞太大报文会触发 IP 分片分片丢失反而更容易丢整包数据。3. 最小可运行闭环CSocket 一对一 UDP 聊天室的收发代码3.1 界面与自定义消息对话框成员、编辑框和 WM_P2P_DATA先把工程结构定下来。新建一个 MFC 对话框应用在对话框类里放以下成员// CP2PDlg.h 中的关键成员 public: afx_msg LRESULT OnP2PData(WPARAM wParam, LPARAM lParam); CEdit m_editLocalPort; // 本地端口 CEdit m_editPeerIP; // 对端 IP CEdit m_editPeerPort; // 对端端口 CEdit m_editInput; // 待发送消息 CEdit m_editRecv; // 接收消息列表 CButton m_btnStart; // 启动接收 CButton m_btnSend; // 发送自定义消息要在对话框类的头文件或 stdafx.h 中定义#define WM_P2P_DATA (WM_USER 100)消息映射写在 BEGIN_MESSAGE_MAP 和 END_MESSAGE_MAP 之间BEGIN_MESSAGE_MAP(CP2PDlg, CDialogEx) ON_MESSAGE(WM_P2P_DATA, CP2PDlg::OnP2PData) END_MESSAGE_MAP()逻辑说明MFC 界面控件绑定用 DDX 机制你可以在 DoDataExchange 里用DDX_Control把控件指针关联到成员变量。这里不展开 DDX 细节重点是自定义消息 WM_P2P_DATA 必须用ON_MESSAGE映射这样才能在工作线程里通过 PostMessage 把数据安全地传回 UI 线程。3.2 接收线程与消息处理PostMessage 是跨线程数据传递的后悔药接收线程代码在 2.3 已经给了核心循环这里补上对话框里的接收消息处理函数LRESULT CP2PDlg::OnP2PData(WPARAM wParam, LPARAM lParam) { CString* pStrMsg (CString*)lParam; if (pStrMsg NULL) { return 0; } // 追加到接收编辑框 CString strAll; m_editRecv.GetWindowText(strAll); strAll *pStrMsg; strAll _T(\r\n); m_editRecv.SetWindowText(strAll); delete pStrMsg; // 工作线程 new 出来的对象这里释放 return 0; }逻辑说明为什么不用 CString 直接作为 PostMessage 的参数因为 PostMessage 是异步的消息投递到队列后局部对象已经销毁指针悬空。所以工作线程里必须 new 一个堆对象UI 线程处理完再 delete这是 MFC 跨线程传字符串的标准姿势。参数说明wParam我没用如果你想在 UI 上区分“系统消息”和“聊天消息”可以把消息类型放 wParamlParam只传堆指针不能传栈地址。3.3 发送按钮SendTo 的参数顺序与失败排查发送比接收简单一个 SendTo 搞定void CP2PDlg::OnBnClickedBtnSend() { CString strInput; CString strPeerIP; CString strLocalPort; CString strPeerPort; m_editInput.GetWindowText(strInput); m_editPeerIP.GetWindowText(strPeerIP); m_editPeerPort.GetWindowText(strPeerPort); if (strInput.IsEmpty() || strPeerIP.IsEmpty()) { SetWindowText(_T(请填写对端 IP 和消息内容)); return; } UINT nPeerPort (UINT)_ttoi(strPeerPort); if (nPeerPort 0) { SetWindowText(_T(对端端口不合法)); return; } int nLen strInput.GetLength() * sizeof(TCHAR); int nRet m_sock.SendTo(strInput, nLen, nPeerPort, strPeerIP); if (nRet SOCKET_ERROR) { int nErr m_sock.GetLastError(); CString strErr; strErr.Format(_T(SendTo 失败错误码 %d), nErr); SetWindowText(strErr); } else { SetWindowText(_T(消息已发送)); } }逻辑说明SendTo的参数顺序是“数据指针、数据长度、对端端口、对端 IP”和 ReceiveFrom 的“缓冲区、长度、输出 IP、输出端口”不一样写错顺序最常见的结果是发送失败或者发去了错误的端口。参数说明strInput.GetLength() * sizeof(TCHAR)在 Unicode 工程中是字节数不是字符数如果你从网络调试助手上看到对方收到乱码多半是字符集不统一。这里示例假设两个客户端都是同一套 MFC Unicode 工程跨语言、跨平台通信时我建议统一转 UTF-8 字节流再发。3.4 验证最小闭环用局域网两台机器和 UDP 网络调试助手交叉测试代码写完先别急着上公网按下面的顺序验证每步都能定位问题。先用一台机器自测启动聊天程序本地端口填 6000对端 IP 填 127.0.0.1对端端口填 6000点启动再发一条消息。如果接收区能显示[127.0.0.1:6000]开头的消息说明 CSocket 创建、线程、消息处理链路是通的。这一步有问题的先查防火墙是否拦截了 MFC 程序的 UDP 入站流量。再用同一局域网两台机器测A 的本地端口 6000对端填 B 的 IP 和端口 6001B 反过来填。这个场景如果通说明局域网广播和单播都正常。如果 B 收不到 A 的包但 B 向 A 发能通先看 A 的防火墙入站规则再用 UDP 网络调试助手在 A 机器上监听 6000 端口做对照测试——调试助手能收到说明程序绑定端口有问题调试助手也收不到那就是系统防火墙拦了 UDP。4. 从两台机器到一个聊天室NAT 打洞、房间模型与中继兜底4.1 局域网能通不等于公网能通NAT 打洞的边界在哪里前面的收发闭环在局域网里能跑但放到公网上两个客户端拿到的通常是私有 IP192.168.x.x 或 10.x.x.x这条数据包根本发不出去。要公网通信必须先把数据包发到对方的公网出口也就是 NAT 设备对外映射出来的 IP 和端口。P2P 协议里把这个过程叫 NAT 打洞核心思路是两个客户端先通过一个公网上的协调服务器交换“公网 IP 端口”然后互相向对方的公网地址发 UDP 包NAT 在转发这些包的同时会学习对端映射后续链路就通了。能不能打洞成功取决于双方 NAT 设备的行为。业界一般把 NAT 分成四类我按实战经验整理成一张表NAT 类型UDP 打洞可行性说明Full Cone完全可行NAT 把内网端口映射到固定公网端口任意外部主机都能通过该映射发包进来Restricted Cone可行只有内网主动向某个外部 IP 发过包该外部 IP 才能从公网发回内网Port Restricted Cone通常可行在 Restricted Cone 基础上还校验源端口只要打洞探测时用的端口和后续通信端口一致就能通Symmetric NAT基本不可行每次向外发包都分配新的公网端口映射不固定必须靠中继判断 NAT 类型的常用做法是让客户端向一台公网服务器发包服务器记录来源 IP 和端口再让客户端换个端口发一次两次来源端口一致就是 Cone不一致就是 Symmetric。聊天室程序不一定要做类型探测但你要在对接时有一个清醒预期两端都是比较宽松的 Cone NAT打洞成功率很高遇到 Symmetric NAT直接放弃打洞走中继别在穿透上耗时间。4.2 打洞流程注册、交换地址、同时互发打洞需要一个公网协调服务器它的职责只有一个记录每个客户端的公网映射地址然后把同一个房间内其他人的地址回传给新加入的客户端。下面给出一段客户端注册报文的发送代码BOOL SendRegisterRequest(CSocket sock, LPCTSTR lpszServerIP, UINT nServerPort, LPCTSTR lpszRoomID, LPCTSTR lpszNickName) { CString strPacket; strPacket.Format(_T(REG|%s|%s), lpszRoomID, lpszNickName); int nLen strPacket.GetLength() * sizeof(TCHAR); int nRet sock.SendTo(strPacket, nLen, nServerPort, lpszServerIP); if (nRet SOCKET_ERROR) { return FALSE; } return TRUE; }协调服务器收到 REG 后能从 UDP 报文里取出客户端的公网 IP 和端口把同一房间内所有成员的公网地址拼成 PEER 报文返回。客户端收到 PEER 报文后要做两件事解析出所有对端的公网地址然后马上向每个地址各发一条打洞探测包同时继续监听自己的本地端口等待对端的探测包。因为 NAT 映射是双向学习的过程必须双方同时发、同时收单方向发永远打不通。// 解析服务器返回的 PEER 报文并向所有对端地址发打洞包 void SendPunchPacket(CSocket sock, LPCTSTR lpszPeerList) { CString strList lpszPeerList; // 报文格式PEER|ip1:port1|ip2:port2|... int nPos 0; CString strItem strList.Tokenize(_T(|), nPos); while (strItem.Trim().IsEmpty() FALSE) { if (strItem.Left(4) _T(PEER)) { // 下一个 token 就是 ip:port strItem strList.Tokenize(_T(|), nPos); if (strItem.IsEmpty()) { break; } int nColon strItem.ReverseFind(_T(:)); if (nColon 0) { CString strIP strItem.Left(nColon); UINT nPort (UINT)_ttoi(strItem.Mid(nColon 1)); CString strProbe _T(PUNCH); int nProbeLen strProbe.GetLength() * sizeof(TCHAR); sock.SendTo(strProbe, nProbeLen, nPort, strIP); } } strItem strList.Tokenize(_T(|), nPos); } }逻辑说明Tokenize 是 CString 的字符串切割函数每次调用返回一个 token并通过 nPos 记录位置。PUNCH 报文本身不需要内容目的是让 NAT 设备记录“内网端口 - 公网特定对端”的映射关系。参数说明打洞包不要只发一次。NAT 映射通常有 30 秒到 2 分钟的存活时间聊天室的保活间隔取 10 到 15 秒比较稳。我见过不少程序打洞成功后两分钟内没有新消息就失联因为映射悄无声息过期了。最省事的办法是每 10 秒向所有对端发一个空报文内容只有心跳字节一字节也够了。4.3 打洞失败怎么办中继兜底与房间拓扑现实很骨感打洞失败是常态不是异常。一个人在国内家庭宽带后面NAT 通常是 Port Restricted Cone但如果他开了 IPv6、接了路由器级联、或者运营商做了大网 NAT情况就不可控了。我的习惯是聊天室永远保留中继模式。中继的拓扑很简单所有客户端都连接到一台公网中继服务器A 发送的消息先 UDP 发给中继中继根据房间号转发给房间里其他人。延迟多一跳但保证消息一定到达。中继服务器代码和协调服务器可以合在一起数据转发用一个循环即可// 中继服务器收到聊天消息时把它转发给同房间所有客户端 void RelayToRoom(CSocket srv, CString strRoomID, const void* pBuf, int nLen, const CString strSenderIP, UINT nSenderPort) { // 伪代码遍历房间成员表跳过发送者本人 for (int i 0; i roomMemberCount; i) { CString strIP roomMember[i].ip; UINT nPort roomMember[i].port; srv.SendTo(pBuf, nLen, nPort, strIP); } }逻辑说明中继模式对客户端代码改动很小所有 SendTo 的目的地址从“对端地址”换成“中继服务器地址”即可。为了不让发送者收到自己刚才发的那条转发时用一个senderIP senderPort匹配跳过即可。参数说明房间 ID 是聊天室逻辑分组的核心。同房间的人能互相看到不同房间之间隔离。在 P2P 场景里房间 ID 还可以作为打洞协调的 key服务器只需要为相同房间 ID 的客户端互换地址。5. 避坑清单CSocket UDP 收不到数据、卡界面、乱码与穿透失败的排查5.1 收不到数据先查防火墙再用 UDP 调试工具做对照现象两台机器在同一个局域网A 点击发送后 B 的接收区没有任何反应但 A 那边提示发送成功。原因UDP 是单向无确认的协议SendTo 返回成功只代表数据进了本机协议栈不代表对方收到。最常见的拦截点是 Windows 防火墙UDP 入站默认被拦除非程序出现在允许列表中。解决先临时关闭双方防火墙或把程序加入防火墙入站规则再在 B 机器上用 UDP 网络调试助手监听 B 的端口A 再发一条调试助手能收到说明 B 的 CSocket 绑定端口或线程有问题收不到说明链路被防火墙或路由器阻断。如果你在公司网络里测试还要考虑交换机端口隔离策略这种环境直接换中继模式。5.2 UI 卡死把 ReceiveFrom 放在了 UI 线程现象点击启动接收后拖动窗口没反应按钮点了没效果过一段时间窗口标题显示“未响应”。原因CSocket::ReceiveFrom 是阻塞调用没有数据包时线程挂起。放在 UI 线程里等于让窗口消息循环停摆所有界面操作全部排队等待。解决把 CSocket 对象和 ReceiveFrom 循环整体搬进工作线程用 PostMessage 把数据投递给主窗口。另外不要试图在工作线程里直接操作控件MFC 控件不是线程安全的跨线程 SetWindowText 会偶发崩溃这也是个隐蔽坑。5.3 端口被占用调试退出后重启失败现象第一次运行正常停止调试后再次启动Create 返回 SOCKET_ERRORGetLastError 报 10048。原因UDP 套接字没有正常 Close或者上一次调试的进程没有完全退出。VS 里停止调试默认会终止进程但如果你勾选了“调试时停止进程”之外的托管选项或者程序里有非守护线程没结束进程可能还活着占着端口。解决先打开任务管理器确认没有残留的 Previous Debug Session 进程然后在 CSocket 的析构或对话框 OnClose 里显式调用sock.Close()。调试期间尽量固定使用一个端口减少端口漂移带来的附加问题。5.4 消息乱码Unicode、ANSI 与 UTF-8 三种编码混用现象局域网两个客户端通信英文正常中文显示成乱码或者一方显示另一方发来的是空字符串。原因MFC 工程字符集不统一。一个工程是 Unicode另一个是 ANSICString 的 GetLength() * sizeof(TCHAR) 长度计算不同底层字节完全对不上更常见的是你用网络调试助手发 UTF-8 中文CSocket 按本地代码页解析显示自然崩。解决两个端统一工程字符集或者更稳的做法——应用层协议固定 UTF-8。发送前用WideCharToMultiByte(CP_UTF8, ...)把 CString 转成 UTF-8 字节接收后反过来转回 CString。聊天室这种小报文编码转换的 CPU 开销可以忽略但换来的是和任意平台互通的确定性。5.5 打洞失败双方都能上网却谁也找不到谁现象NAT 打洞代码全部按流程走协调服务器也返回了双方公网地址双方互发 PUNCH 包但直连始终不通。原因最常见的是对称 NAT——每发一个包就换一个新公网端口协调服务器拿到的是上一次发包的端口等你发 PUNCH 时端口已经变了其次是双方打洞时间没对齐一方先发完后停止NAT 映射已经老化。解决打洞代码做成循环脉冲式每 200 毫秒发一次 PUNCH持续 3 到 5 秒不要发一次就等结果同时设置一个打洞超时时间比如 8 秒内没有任何对端报文进来直接切换中继模式。这里有一个取舍打洞成功省一路转发延迟但调试成本高聊天室这种低频小流量场景中继其实完全够用。6. 最后一步一台机器两个进程验证以及聊天室的关键参数要验证这套代码不一定非得找两台机器。你可以把工程编译出两个实例第一个实例本地端口填 6000第二个实例本地端口填 6001两个实例都对端填 127.0.0.1 和对方的端口。这样用一台电脑就能看到完整的收发闭环也能测试 CSV 的同端口冲突——两个实例绑定同一个端口后者必然失败报 10048这本身就是一次很好的端口冲突演练。跑通之后我建议把下面三个参数固化成配置项而不是写死在代码里本地端口默认 6000负责 NAT 映射的稳定性对端或中继地址默认空运行时填入心跳间隔默认 10 秒负责保活打洞后的 NAT 映射。你可以把这三个参数放在一个简单的 INI 配置里每次启动时读取这样换网络环境测试不用重新编译。UI 上还有一个实用小技巧把最近一次发送结果直接显示在窗口标题栏上比如SetWindowText(_T(已发送到 192.168.1.100:6001))调试时比看输出窗口直观得多。这套 CSocket UDP MFC 的方案放到今天看算不上时髦但它的工程价值在于把 Winsock、协议栈、界面线程模型和 NAT 穿透这几个硬骨头一次性串起来。我自己现在写类似项目时接收侧大概率会改用 CAsyncSocket 的事件驱动模型少一个线程少一份心智负担但如果你接手的是现成 CSocket 源码或者课程设计指定了 CSocket工作线程 PostMessage 的这套兜底方案足够你应付到答辩。希望帮到你。本文还有配套的精品资源点击获取
返回列表