MFC双向网络通信非指针机制:基于CAsyncSocket的VC6源码解析
简介基于MFC的双向网络通信源码包面向初涉Windows网络编程的C开发者旨在通过非指针机制的Socket实现帮助理解双向通信原理。资源包含客户端与服务端完整源码、可直接运行的exe程序开发环境为Visual C 6.0采用MFC与C/C编写。压缩包共67个文件涵盖h/cpp源文件、dsw/dsp工程文件、rc资源文件及pdb调试信息等整体约4.59MB目录结构清晰便于对照学习。目前已有158人学习下载适合想要快速上手MFC网络编程、或作为局域网聊天通信参考实现的读者。通过该套源码可直观看到监听套接字、连接套接字的建立与消息收发流程对理解Socket阻塞模式及消息映射机制有直接帮助。1. MFC双向网络通信非指针机制先看懂这份能跑通的VC6源码当年学Windows网络编程最难过的坎就是CAsyncSocket的回调机制——为什么收发消息会自己跑到OnReceive里没人愿意先跟你讲清楚。这份基于MFC的双向网络通信工程用Visual C 6.0写服务端ChatServeDlg和客户端ChatClient两个工程都带编译好的exe能在局域网内互相收发消息。它的核心价值在于用“非指针机制”组织socket对象把Socket作为对话框成员变量靠CAsyncSocket的消息路由完成双向收发省掉了new/delete的配对烦恼。适合想弄懂socket事件回调的初学者也适合需要一份能直接编译、改端口就能跑的MFC网络编程起步工程。2. 工程结构与原理非指针机制如何用CAsyncSocket实现双向收发2.1 文件清单梳理两个工程、四个Socket类先看资源里有什么。服务端是ChatServeDlg客户端是ChatClient各自是独立的VC6工作区。服务端工程的核心代码文件是文件职责ChatServeDlg.cpp / ChatServeDlg.h应用类与程序入口ChatServeDlgDlg.cpp / .h服务端主对话框持有socket成员并处理界面消息ListenSocket.cpp / .h监听套接字类负责监听端口和处理连接请求ServeSocket.cpp / .h服务端数据套接字类负责与已连接客户端收发数据resource.h / ChatServeDlg.rc对话框资源和控件ID定义客户端工程对应的是ChatClientDlg主对话框和ClientSocket客户端数据套接字。剩下那一堆.dsp、.dsw、.clw、.ncb、.aps、.opt都是VC6自动生成的工程辅助文件不要手动改少了的话VC6会自己重新生成。Debug目录下是编译好的exe和中间产物ReadMe.txt和README.md两个文件建议先读里面有作者留的编译说明和模块划分。两个工程之间的关系不复杂服务端先监听客户端主动连接连接建立后双方都能主动发消息这就是双向网络通信。整体流程看着短但四个Socket类之间的调用关系如果不先理清改代码时很容易绕晕——尤其是服务端的监听对象和数据对象职责完全不同不能混用。2.2 非指针机制把Socket对象挂到对话框成员上常规的MFC网络编程教学几乎都是这种写法在监听Socket的OnAccept里new一个连接Socket指针传出去连接断开时再delete。这么写本身没问题但新手经常会忘OnClose里没有及时delete连接对象或者Accept失败后对象泄漏调试起来相当迷惑总感觉socket生命周期是个玄学问题。这个工程的不同之处在“非指针”三个字。严格说它是把通信Socket作为对话框类的成员对象而不是用CAsyncSocket*指针去管理。以服务端为例主对话框头文件里的声明类似// ChatServeDlgDlg.h class CChatServeDlgDlg : public CDialog { // 标准MFC对话框的构造与数据交换部分略 protected: ListenSocket m_listenSocket; // 监听对象随对话框生存 ServeSocket m_serverSocket; // 数据对象接受一个客户端连接 };服务端在OnInitDialog里把监听对象Create并Listen客户端连接进来时由监听对象或者对话框把连接交给m_serverSocket完成Accept。整个过程不需要手动new也不需要操心在哪delete。对话框销毁时成员对象析构socket句柄随之释放内存回收由编译器管。这里有个底层前提需要说明CAsyncSocket内部使用WSAAsyncSelect机制把FD_READ、FD_WRITE、FD_ACCEPT、FD_CONNECT等网络事件映射为窗口消息再在MFC的隐藏窗口中分发给各个虚函数。也就是说你重写的OnReceive、OnAccept这些函数本质上是在响应一条窗口消息而不是在繁忙的for循环里轮询。理解了这一点再回头看“对话框成员对象”的设计就很顺了socket对象持有句柄句柄对应的事件会路由到它自己的回调界面刷新只需要再把消息抛回对话框。提示CAsyncSocket不支持拷贝语义所以ServeSocket m_serverSocket这样的成员对象不能被放进按值存储的容器里。一对一聊天没问题“非指针机制”的边界也在这里后面避坑章节会展开。2.3 双向链路从监听、连接到双向收发的完整时间线按程序跑起来后的顺序理一遍服务端启动m_listenSocket.Create(9527)绑定端口并Listen进入监听状态客户端启动m_clientSocket.Create()获取随机本地端口调用Connect(服务器IP, 9527)服务端监听对象触发OnAccept用ServeSocket完成Accept客户端触发OnConnect界面显示连接成功此时TCP链路已建立客户端Send一段文本服务端ServeSocket触发OnReceiveReceive读缓冲区把消息显示在服务端窗口服务端Send回复客户端ClientSocket触发OnReceiveReceive读数据显示在客户端窗口。看上去是单向的两组收发合在一起就是双向。时序上要分清楚Connect是异步的返回值不能表示连接成功OnConnect中的nErrorCode等于0才是成功。收发同理Send返回的字节数可能小于你要发的长度Receive也可能一次只收到半个消息这两个点都是新手最容易踩的后面客户端章节会具体说。3. 服务端实现ListenSocket与ServeSocket的配合3.1 启动监听绑定端口与进入监听状态服务端主对话框的OnInitDialog里核心代码就几行BOOL CChatServeDlgDlg::OnInitDialog() { CDialog::OnInitDialog(); // 绑定固定端口9527不指定IP表示绑定本机所有网卡地址 if (!m_listenSocket.Create(9527)) { AfxMessageBox(监听套接字创建失败请检查端口是否被占用); return FALSE; } // 进入监听模式backlog用默认值5同时等待处理的连接最多5个 if (!m_listenSocket.Listen()) { AfxMessageBox(监听失败); return FALSE; } return TRUE; }Create的参数是端口号服务端必须固定客户端才知道往哪连。这里有个细节Create(0)是让系统自动分配端口客户端可以用服务端不行因为自动分配的端口每次启动都不一样别人没法连。Listen没显式传backlog用默认值5对教学场景足够如果你在真实环境里同时有大量客户端发起连接可以显式传10或20。很多教程会在Create之前手动指定IP地址其实没必要。端口绑定后socket默认绑定到INADDR_ANY也就是所有本机IP局域网内任何一台机器用你的局域网IP都能连上。如果在本机测试客户端地址填127.0.0.1同样能通。这是Windows socket的默认行为不要误以为必须在Create里写IP才能被局域网访问。3.2 接受连接回调回对话框的封装方式监听对象触发OnAccept时要处理的不是监听socket本身而是从等待队列里拿出一个连接交给一个新的socket对象。非指针的做法是这样// ListenSocket.h class CListenSocket : public CAsyncSocket { public: HWND m_hOwnerWnd; // 保存主对话框窗口句柄用于把事件转回界面 virtual void OnAccept(int nErrorCode); }; // ListenSocket.cpp void CListenSocket::OnAccept(int nErrorCode) { CAsyncSocket::OnAccept(nErrorCode); if (nErrorCode ! 0) return; // 告知对话框有客户端连入由对话框用成员ServeSocket去Accept ::PostMessage(m_hOwnerWnd, WM_ACCEPT_CLIENT, 0, 0); }对话框收到WM_ACCEPT_CLIENT后执行真正的AcceptLRESULT CChatServeDlgDlg::OnAcceptClient(WPARAM wParam, LPARAM lParam) { // 新连接进来前先关闭上一次的残留连接保证同一时刻只有一个活动连接 m_serverSocket.Close(); // Accept的参数是CAsyncSocket的引用这里传成员对象而非指针 if (!m_listenSocket.Accept(m_serverSocket)) { TRACE(Accept失败错误码: %d\n, GetLastError()); } return 0; }这段代码的逻辑说明监听socket只负责接客Accept之后真正收发数据的是m_serverSocket。Accept的参数要求是CAsyncSocket的引用这正是“非指针机制”的落点——传的是对话框的成员对象而不是new出来的堆对象。m_hOwnerWnd在OnInitDialog里赋值为对话框句柄即可等于把socket事件转一道到对话框消息里这个转发模式在收发消息时还会继续用。如果不想转发直接在ListenSocket里保存一个ServeSocket对象OnAccept里直接Accept也是通的。这里用PostMessage是为了把socket回调与UI上下文隔开避免在回调里访问对话框控件造成重入问题。记得在对话框头文件里声明afx_msg LRESULT OnAcceptClient(WPARAM, LPARAM);并在消息映射中加ON_MESSAGE(WM_ACCEPT_CLIENT, OnAcceptClient)这条映射漏了的话消息不会触达。3.3 收到消息后转发与回复ServeSocket收到客户端数据时void CServeSocket::OnReceive(int nErrorCode) { CAsyncSocket::OnReceive(nErrorCode); TCHAR szBuffer[4096] {0}; int nLen Receive(szBuffer, sizeof(szBuffer) - 1); if (nLen 0) { // 用长度构造CString避免依赖字符串的\0结尾 CString strMsg(szBuffer, nLen); // 转回主对话框显示new出来的对象在接收侧务必delete ::PostMessage(m_hOwnerWnd, WM_RECV_MSG, 0, (LPARAM)(new CString(strMsg))); } }m_hOwnerWnd是ServeSocket里保存的对话框句柄与ListenSocket里的赋值方式一致。收到消息后对话框的WM_RECV_MSG处理函数把内容追加到编辑框然后可以回复。回复的发送就是另一个方向直接调Send// 服务端界面点“发送”按钮 void CChatServeDlgDlg::OnBtnSend() { CString strMsg; GetDlgItemText(IDC_EDIT_INPUT, strMsg); if (strMsg.IsEmpty()) return; // Send的第二个参数是字节数不是字符数 int nSent m_serverSocket.Send(strMsg, strMsg.GetLength()); if (nSent ! strMsg.GetLength()) { TRACE(发送不完整已发送 %d 字节\n, nSent); } }说明Send的第一个参数是数据指针第二个必须是字节数。在VC6默认的ANSI字符集下CString::GetLength()返回的字符数和字节数一致如果你把工程改成Unicode这里就要乘sizeof(TCHAR)否则中文消息发出去会残缺。服务端的消息转发用PostMessage比SendMessage安全因为网络回调就在窗口消息流里嵌套SendMessage容易出重入问题PostMessage传指针则需要接收侧delete写的时候别漏。4. 客户端实现从Connect到OnReceive的收发闭环4.1 客户端的套接字创建客户端同样在OnInitDialog里创建socketBOOL CChatClientDlg::OnInitDialog() { CDialog::OnInitDialog(); // Create不带参数让系统分配一个合法本地端口 if (!m_clientSocket.Create()) { AfxMessageBox(客户端套接字创建失败); return FALSE; } return TRUE; }客户端socket不需要固定端口所以Create不传端口号。这段代码的坑点是Create要在用户点连接之前就执行否则Connect会因为socket句柄无效返回SOCKET_ERROR同时也不能重复Create同一个socket对象二次Create会报错。如果界面允许用户反复点连接建议在连接前先判断m_clientSocket的句柄状态或者每次连接前先Close再Create。4.2 Connect的异步与OnConnect判活连接按钮的处理函数void CChatClientDlg::OnBtnConnect() { CString strIP; GetDlgItemText(IDC_EDIT_IP, strIP); // Connect是异步操作返回值只代表是否成功发起 if (!m_clientSocket.Connect(strIP, 9527)) { int nErr GetLastError(); if (nErr ! WSAEWOULDBLOCK) // 10035表示连接仍在进行中 { AfxMessageBox(连接失败请检查服务端是否已启动); } } }很多新手在Connect返回FALSE后立刻弹“连接失败”这是错的。CAsyncSocket的Connect在非阻塞模式下几乎总是返回FALSEGetLastError返回WSAEWOULDBLOCK即10035代表连接正在后台建立。真正的结果要看OnConnect回调nErrorCode为0说明连接成功非0则说明失败比如服务端没启动时这里会收到10061连接被拒。把判断逻辑放在OnConnect里才是正确姿势void CClientSocket::OnConnect(int nErrorCode) { CAsyncSocket::OnConnect(nErrorCode); if (nErrorCode 0) { ::PostMessage(m_hOwnerWnd, WM_NET_EVENT, CONNECT_OK, 0); } else { ::PostMessage(m_hOwnerWnd, WM_NET_EVENT, CONNECT_FAIL, nErrorCode); } }这里我用WM_NET_EVENT一条消息携带WPARAM区分成功和失败比定义两条消息更省事。收到CONNECT_OK后界面把连接按钮置灰、发送按钮置亮状态栏显示“已连接到服务器”。这个交互细节能避免用户在网络未就绪时就点发送减少很多莫名其妙的SOCKET_ERROR。4.3 客户端收包与界面刷新客户端收数据与服务端对称void CClientSocket::OnReceive(int nErrorCode) { CAsyncSocket::OnReceive(nErrorCode); TCHAR szBuffer[4096] {0}; int nLen Receive(szBuffer, sizeof(szBuffer) - 1); if (nLen 0) { CString strMsg(szBuffer, nLen); ::PostMessage(m_hOwnerWnd, WM_RECV_MSG, 0, (LPARAM)(new CString(strMsg))); } }对话框的WM_RECV_MSG处理函数里把消息追加到接收区的编辑框或列表控件。这一个闭环走通之后双向网络通信的本质就清楚了两个方向上各有一个socket各自由对方的Send触发自己的OnReceive谁都可以主动发不需要区分服务器端和客户端。平时说的“server只能被动等”在TCP双向通信里不成立建立连接后双方角色平等。这段代码还有一个细节Receive返回0表示对端关闭返回SOCKET_ERROR时要检查是不是WSAEWOULDBLOCK也就是没有数据可读。演示程序在短连接场景下不容易触发但如果你改成高频收发这个判断早晚会用到。建议在OnReceive里把nLen等于0的情况单独处理弹一个“对端已断开”并把socket关闭避免连接已经断了界面还显示在线。5. 避坑排查非指针机制下的编译、乱码与崩溃问题5.1 编译与对象生命周期的两个高频坑记录1CAsyncSocket不能被复制或赋值现象把ServeSocket对象放进CArray或者直接写m_serverSocket otherSocket编译报C2248提示无法访问私有成员。原因CAsyncSocket的拷贝构造函数和赋值运算符被MFC禁用。它持有系统socket句柄按值拷贝会让两个对象操控同一个句柄析构时可能重复Close所以MFC从源头堵住了这种用法。解决非指针机制下就老老实实让Socket对象做对话框成员一对一场景够用。如果你确实需要管理多条连接用CArrayCAsyncSocket*, CAsyncSocket*存指针对象生命周期自己管理这是做多客户端的必由之路。记录2退出程序时Debug断言失败现象程序关闭时Debug版本弹ASSERT失败调用栈指向CSocket::Close或CAsyncSocket::~CAsyncSocket。原因对话框窗口先销毁socket成员对象析构时如果句柄还没关闭MFC在清理阶段会断言失败。另外一种可能socket回调还在路上窗口已经没了消息找不到目标窗口。解决在对话框的OnDestroy里显式关闭所有socket成员顺序注意先关数据socket再关监听socketvoid CChatServeDlgDlg::OnDestroy() { m_serverSocket.Close(); m_listenSocket.Close(); CDialog::OnDestroy(); }只要是成员对象无论指针还是非指针退出前把Close补上能省很多深夜调试时间。这条也算血泪经验我当时在这个断言上卡了整整一个晚上。5.2 连接与收发的运行态问题记录3客户端Connect失败错误码10061现象客户端点连接OnConnect里nErrorCode为10061即WSAECONNREFUSED连不上服务端。原因目标机器上没有程序在对应端口监听或者防火墙拦截了端口。最常见的是服务端没启动、端口写错、服务端程序崩了但监听socket没释放。解决先确认服务端窗口已经出现监听成功提示在客户端机器命令行验证端口通不通telnet 192.168.x.x 9527能连通会进入空白界面或提示连接成功连不通直接报错。Windows防火墙需要允许exe访问网络VC6跑起来的Debug程序经常被拦到防火墙里把exe加入允许列表或者临时关闭防火墙做连通性测试。记录4发送中文乱码或消息被截断现象客户端发“你好”服务端收到“浣犲ソ”或只收到半个字。原因字符集不一致或者发送长度算错。VC6默认使用ANSI代码页即GBK如果你在某个环节用了宽字符函数或者发送时传的是sizeof(strMsg)而不是GetLength()字节长度就会和实际内容对不上中文多字节编码下极易出现半个字。解决工程统一保持ANSI字符集不要混用TCHAR和WCHAR。发送用Send(strMsg, strMsg.GetLength())接收用CString(szBuffer, nLen)强制定长构造不依赖字符串里的\0结尾。CString内部可以存二进制数据截断点永远是nLen不是\0这个点记住了能少踩不少坑。5.3 资源边界与工程迁移补充记录5非指针机制只能同时保持一条连接现象第一台客户端连接正常第二台客户端一直连接不上服务端不再触发OnAccept但监听socket看着没问题。原因Accept完成之后这个工程是用同一个ServeSocket成员对象去接收连接第一次Accept后如果没有释放连接系统不会再生成新的可接受事件或者Accept直接失败。这是非指针方案的结构性边界不是偶发bug。解决一对一教学演示可以接受这个限制。需要一对多时有两个方向一是回到指针方案OnAccept里new一个ServeSocket并保存到数组OnClose里断开前delete二是用CSocket配合线程做阻塞收发。非指针机制的价值不在高并发而在于把内存管理从socket编程里剥离出来让你专注理解事件回调本身。如果只需要小范围改造成一对多按最后一章的最小改动方案做即可。另外补充一个迁移提醒VC6的.dsp工程在新版Visual Studio里直接打开会提示升级并产生大量编译错误这不代表代码本身有问题而是工程格式和Windows SDK头文件差异导致的。想在VS2019/2022里跑建议新建MFC对话框工程把.cpp和.h逐个加进去再处理ANSI与Unicode的差异Socket那部分代码的迁移成本其实很低主要工作量在资源文件和消息映射宏上。6. 双向通信验证与多人消息转发的最小改造6.1 三种方式验证双向链路拿到资源后不用急着看代码先把Debug目录下的Server.exe和Client.exe跑起来。验证顺序建议本机回环客户端IP填127.0.0.1确认本机双向收发正常局域网真实测试两台电脑连同一网络客户端填服务端的IPv4地址确认防火墙没有只放行回环对端主动发送测试连接成功后双方轮流主动发消息不要只由一端发起这样才能确认两个方向的OnReceive都触发正常。6.2 从一对一改成多人的最小改动如果想拿这份代码继续做聊天室最小改动是这样// 维护一个连接指针数组 CArrayCAsyncSocket*, CAsyncSocket* m_arrClients; // OnAccept时创建新对象并加入数组 CServeSocket* pNew new CServeSocket(); if (Accept(*pNew)) m_arrClients.Add(pNew); // 转发消息时遍历所有连接 for (int i 0; i m_arrClients.GetSize(); i) { m_arrClients[i]-Send(strMsg, strMsg.GetLength()); }这一步改完你就从非指针机制回到了传统的指针方案。不是倒退而是在理解非指针方案的Why之后知道什么样的规模该回到指针管理。数组里对象的delete放在OnClose回调里做断开一个删一个同时用m_arrClients.RemoveAt(i)把指针从数组移除。6.3 我现在的验证习惯自从在这类工程上熬过几个夜晚之后我拿到任何socket资源第一件事不是编译而是先在代码里搜三个点OnReceive、OnAccept、OnClose的实现确认错误码判断是否齐全然后看工程字符集是ANSI还是Unicode最后才编译。这套流程走完资源能不能用、改动风险在哪大概就有底了。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →