简介:一份面向Windows网络编程初学者的CAsyncSocket实战示例包,围绕MFC异步套接字封装,分别给出客户端与服务端两套工程,可用于理解TCP连接建立、监听接受、数据收发以及事件驱动回调的组织方式。压缩包共44个文件,以h头文件和cpp源文件为主体,辅以ico图标、rc资源脚本及dsp、dsw等旧版工程文件,整体约1.82MB,便于在VC6/MFC环境中直接打开对照阅读。已有445人学习下载,说明该示例在入门阶段具有一定参考价值。内容涵盖套接字创建与Connect、Listen、Accept调用,OnConnect、OnAccept、OnReceive、OnSend、OnClose等事件处理,GetLastError错误检查,以及多客户端场景下的线程与文件分块传输思路;读者可从中梳理客户端与服务端的消息流和代码骨架,作为课程实验、毕业设计或自建通信小工具的起步参照。
1. 一个不用开线程的客户端/服务端:CAsyncSocket 例子能解决什么问题
接手一个数据采集小工具时,需求很朴素:服务端要同时接十几台设备的 TCP 连接,客户端断线要能自动重连,而整个界面只有一个 MFC 对话框,不想为通信再开一堆线程。这份 CAsyncSocket 使用例子正好对上——服务端和客户端两侧都做了,靠 CAsyncSocket 的异步回调模型,把连接、收数据、断开全部塞进OnConnect/OnReceive/OnClose,主线程不阻塞。适合两类人:手里有 MFC 老工程、要加一层轻量 TCP 通信又不想引第三方库的;以及刚学 Windows 网络编程、想弄明白“异步 socket 到底异步在哪”的。下面按工程骨架、服务端、客户端、易错点、改造顺序拆。
2. 工程骨架与消息驱动:CAsyncSocket 是怎么把 socket 回调塞进 MFC 消息泵的
很多人第一次用 CAsyncSocket,代码写完编译通过、运行没报错,就是收不到OnReceive。问题不在 socket,在于这套类库的设计前提:它不是“后台线程收数据再通知你”,而是把 socket 事件翻译成窗口消息,投递到创建该 socket 的那个线程的消息队列里。搞不清这一点,后面所有调试都是盲人摸象。
2.1 目录结构与工程配置
例子的结构很小,一共四对文件,服务端和客户端各占一半,公共部分只有一个包缓冲区和协议常量。我把它整理成一张表,方便对着找:
| 文件 | 职责 | 关键点 |
|---|---|---|
NetServer.h/.cpp | 监听套接字,继承CAsyncSocket | 只管OnAccept和自身的OnClose |
ClientSocket.h/.cpp | 服务端侧每个连接对应一个对象 | 收包、拆包、发送队列都在这里 |
NetClient.h/.cpp | 客户端套接字,继承CAsyncSocket | 负责连接、重连、心跳 |
Packet.h | 协议头与打包/拆包小工具 | 4 字节长度前缀 |
XxxDlg.cpp | 宿主对话框 | 创建 socket、起定时器、收回调后刷 UI |
工程配置上有两个容易被忽略的点。第一,项目属性里必须勾上 MFC 支持(“使用 MFC 的共享 DLL”或静态库都行),否则CAsyncSocket根本找不到声明;第二,字符集建议统一用 Unicode,例子里的字符串转换按 Unicode 写的,混用多字节会在Connect传主机名时出现莫名其妙的失败。至于链接库,MFC 工程会自动带上ws2_32.lib,一般不需要手动加。
2.2 消息泵才是真正的调度器:WSAAsyncSelect 的工作方式
CAsyncSocket::Create内部做了一件关键的事:调用WSAAsyncSelect,把 socket 句柄和一个 MFC 内部的隐藏窗口CSocketWnd绑定,并注册感兴趣的事件掩码。此后内核收到FD_READ、FD_ACCEPT、FD_CLOSE这些事件,就往那个隐藏窗口 Post 一条自定义消息;隐藏窗口的窗口过程再把消息翻译成对应虚函数的调用。
这条链路决定了三件事。其一,回调函数运行在拥有消息泵的线程上,主 UI 线程创建的 socket,回调就在主线程执行,所以回调里绝对不能做耗时操作。其二,在工作线程里new一个 CAsyncSocket 是没用的,那个线程如果没有GetMessage/DispatchMessage循环,事件永远躺在队列里没人取,表现出来就是“连接看着成功了,但一条数据都收不到”,这是纯纯的玄学现场。其三,Create时如果不显式调用AsyncSelect,MFC 默认注册的是FD_READ | FD_WRITE | FD_OOB | FD_ACCEPT | FD_CONNECT | FD_CLOSE全套,回调会比你想象的多,排查时容易被日志淹没。
// 服务端和客户端共用的初始化习惯:先 Create,再按需裁剪事件掩码 BOOL CClientSocket::Init(SOCKET hSocket) { // AttachHandle 用于把 Accept 出来的新句柄挂到对象上 if (!Attach(hSocket)) return FALSE; // 只关心读、写、关闭三类事件,避免无谓的回调打断调试 if (!AsyncSelect(FD_READ | FD_WRITE | FD_CLOSE)) { Close(); return FALSE; } return TRUE; }Attach的参数是Accept返回的原始SOCKET句柄,它把已有的内核对象挂到当前 C++ 对象上,不会新建 socket,所以不需要再调Create。AsyncSelect的掩码是位或组合,FD_WRITE一定要留着——后面发送队列靠它续命。AsyncSelect返回FALSE时用GetLastError()能看到具体原因,最常见的是句柄已经失效。
2.3 AfxSocketInit 与启动时的三行初始化
MFC 的 socket 子系统需要显式启动,位置在CWinApp::InitInstance的最前面,早于任何窗口创建。漏掉这一步,Create会返回失败,错误码还很不直观。
BOOL CNetDemoApp::InitInstance() { // 必须在创建任何 CAsyncSocket 之前调用 if (!AfxSocketInit()) { AfxMessageBox(_T("Winsock 初始化失败")); return FALSE; } CWinApp::InitInstance(); // ... 后续创建主对话框 return TRUE; }AfxSocketInit内部做了WSAStartup,并保存了一份WSADATA,MFC 的 socket 类都依赖它。参数可以不传,传的话给一个WSADATA*指针用来取版本信息。这里有个血泪经验:如果项目里还有别的地方手动调了WSAStartup,退出时又调了WSACleanup,两边计数不匹配,MFC 这边的 socket 会在程序退出瞬间报一堆断言,看着像内存越界,其实是 Winsock 被提前关了。
3. 服务端拆解:从 OnAccept 到收发缓冲区的一条完整链路
服务端这块是整个例子里最值得逐行看的部分,因为它把“一个监听 socket 加 N 个连接 socket”的对象关系摆得很清楚。监听对象负责接纳,连接对象负责收发,两边通过一个链表串起来,关掉的时候统一清理。
3.1 监听端初始化:Create、SetSockOpt、Bind、Listen 的顺序不能乱
四个调用的顺序有讲究。Create不带端口创建空 socket,然后立刻设SO_REUSEADDR,再Bind,最后Listen。如果反过来,先Bind再设选项,SO_REUSEADDR就不生效了,调试时反复重启程序会频繁撞上“端口已被占用”。
BOOL CNetServer::Start(UINT nPort) { // 1) 先建空 socket,不带端口,才能设置地址复用 if (!Create()) return FALSE; // 2) 允许立即重用处于 TIME_WAIT 的端口,避免反复调试时 Bind 失败 int nOpt = 1; SetSockOpt(SO_REUSEADDR, &nOpt, sizeof(nOpt)); // 3) 绑定到所有网卡,第二个参数是端口 if (!Bind(nPort)) return FALSE; // 4) 挂起监听,5 是内核待接纳队列长度 if (!Listen(5)) return FALSE; return TRUE; }Create的无参重载会创建一个 TCP socket(默认SOCK_STREAM)。SetSockOpt的第一个参数是选项级别SO_REUSEADDR,缓冲区传int的地址和长度。Bind的端口传 0 表示由系统分配,实际项目里做单实例检测时可以用这个技巧试探端口是否被占。Listen的参数 5 是 backlog,Windows 上这个值会被内核放宽,小规模采集场景不用调太大,几十个连接 5 到 10 足够。
失败时怎么看?Bind失败先查GetLastError()是不是WSAEADDRINUSE(端口被占),再查是不是防火墙或安全软件拦了;Listen失败一般是 socket 状态不对,比如Bind其实没成功但代码没检查返回值就往下走了。
3.2 OnAccept 里的对象生命周期:new 出来的 socket 谁负责 delete
OnAccept是服务端最容易写错的地方,因为它涉及指针所有权。惯例是:new一个连接对象,调Accept把句柄交给它,成功后挂进链表,失败就当场delete。这里没有中间态,Accept返回FALSE就必须立刻回收,不能指望后面某个地方兜底。
void CNetServer::OnAccept(int nErrorCode) { // 错误码非 0 表示这次 accept 本身有问题,交回基类处理 if (nErrorCode != 0) { CAsyncSocket::OnAccept(nErrorCode); return; } CClientSocket* pClient = new CClientSocket(this); if (!Accept(*pClient)) { // 接纳失败:句柄没交出去,对象必须自己收拾 delete pClient; } else { m_lstClients.AddTail(pClient); // 交给服务端统一管理 } CAsyncSocket::OnAccept(0); }Accept的参数是一个CAsyncSocket引用,内部会调用accept并把新句柄Attach到传入的对象上,所以新对象不需要Create。new出来的对象如果Accept失败,它的m_hSocket还是INVALID_SOCKET,直接delete是安全的,析构里不会去关一个无效句柄。
另外一个细节:OnAccept只在有连接到来时触发一次,如果内核队列里积压了多个连接,不会自动连发多次回调。稳妥的写法是在OnAccept末尾再调一次Accept探测,或者干脆接受“下一次事件再来处理”的延迟——对十几台设备的场景,后者完全够用,别为了这点吞吐把代码搞复杂。
3.3 OnReceive 与粘包:长度前缀 + 环形缓冲的拆包逻辑
TCP 是字节流,Receive读到的可能是半个包,也可能是三个半包拼在一起。例子里用最朴素也最可靠的办法:每个包前面加 4 字节长度头,收到数据先追加进缓冲区,然后循环判断“缓冲区里够不够一个完整包”,够就取出来分发,不够就等下一次。
void CClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) { Close(); return; } char szBuf[4096]; int nRet = Receive(szBuf, sizeof(szBuf)); if (nRet == 0) { // 返回 0 不是错误,是对方正常关闭了连接 Close(); return; } if (nRet == SOCKET_ERROR) { // WSAEWOULDBLOCK 表示暂时没数据了,属于正常情况 if (GetLastError() == WSAEWOULDBLOCK) return; Close(); return; } m_recvBuf.Append(szBuf, nRet); // 循环拆包:够一个完整包就处理,直到不足 while (m_recvBuf.Size() >= sizeof(PACKET_HEAD)) { PACKET_HEAD head; m_recvBuf.Peek(&head, sizeof(head)); // 防御:长度头异常直接断开,别让恶意包把内存撑爆 if (head.nLen == 0 || head.nLen > MAX_PACKET_SIZE) { Close(); return; } if (m_recvBuf.Size() < sizeof(head) + head.nLen) break; // 半包,等下一次 OnReceive m_recvBuf.Consume(sizeof(head)); OnPacket(m_recvBuf.Data(), head.nLen); m_recvBuf.Consume(head.nLen); } }Receive的返回值和send/recv一致:大于 0 是实际读到的字节数,0 是对端关闭,SOCKET_ERROR要看GetLastError()。缓冲区m_recvBuf是自己写的一个小类,Append追加、Peek只读不消费、Consume前移读指针、Size返回剩余字节数,内部用std::vector<char>加读写下标实现,比每次memmove便宜。
MAX_PACKET_SIZE建议设成 1MB 到 4MB 之间,具体看业务包里最大的是什么。这个上限不是拍脑袋定的,是防御性的:没有它,一个伪造的长度头就能让程序去new一块几个 G 的缓冲,直接 OOM。
3.4 OnSend 与发送队列:WSAEWOULDBLOCK 不是错误
新手最常踩的坑是把Send返回SOCKET_ERROR当成发送失败然后断开连接。实际上在非阻塞 socket 上,内核发送缓冲区满了就会返回WSAEWOULDBLOCK,意思是“现在发不出去,等会儿再说”。正确做法是把没发完的数据留在队列里,等OnSend回调来了继续发。
BOOL CClientSocket::SendPacket(const void* pData, int nLen) { // 先组包:4 字节长度头 + 包体,一次性进发送队列 PACKET_HEAD head; head.nLen = (UINT)nLen; m_sendBuf.Append(&head, sizeof(head)); m_sendBuf.Append(pData, nLen); return Flush(); } BOOL CClientSocket::Flush() { while (m_sendBuf.Size() > 0) { int nRet = Send(m_sendBuf.Data(), m_sendBuf.Size()); if (nRet == SOCKET_ERROR) { if (GetLastError() == WSAEWOULDBLOCK) { // 缓冲区满,把可写事件重新挂上,等 OnSend 再来 AsyncSelect(FD_READ | FD_WRITE | FD_CLOSE); return TRUE; // 数据还在队列里,不算失败 } Close(); return FALSE; } // 可能只发出去一部分,消费掉已发送的字节继续循环 m_sendBuf.Consume(nRet); } return TRUE; } void CClientSocket::OnSend(int nErrorCode) { if (nErrorCode != 0) { Close(); return; } Flush(); // 把积压的数据尽量吐出去 }SendPacket的参数是包体指针和包体长度,长度头由函数内部补,调用方不用关心协议格式。Flush里Send的返回值是“本次实际写进内核的字节数”,可能小于m_sendBuf.Size(),所以要用Consume(nRet)而不是清空整个队列。
FD_WRITE是边沿触发的:连接建立成功时触发一次,之后只有在上次返回WSAEWOULDBLOCK并重新AsyncSelect之后,才会再触发。如果你在OnSend里什么都不做,或者忘了重新挂FD_WRITE,队列里剩下的数据会永远躺在那里,表现出来就是“日志显示发出去了,对端一条都没收到”,这个问题能查一整天。
4. 客户端拆解:连接、断线重连与心跳超时的状态机
客户端比服务端多两件事:主动发起连接,以及连接断了之后自己爬起来。这两件事都不适合写成“调一次函数就完事”的线性代码,用状态机加定时器更稳。
4.1 连接建立:Connect 返回 FALSE 也可能是正常的
CAsyncSocket::Connect在非阻塞模式下几乎总是返回FALSE,因为连接不可能瞬间完成。这时候要看GetLastError():如果是WSAEWOULDBLOCK,说明连接请求已经发出去了,正在等OnConnect回调;如果是别的错误码,才是真的失败(比如WSAECONNREFUSED对端没监听、WSAETIMEDOUT网络不通)。
void CNetClient::ConnectTo(LPCTSTR lpszHost, UINT nPort) { Shutdown(); // 先把旧 socket 干净地关掉,避免句柄泄漏 if (!Create()) return; m_nState = STATE_CONNECTING; m_strHost = lpszHost; m_nPort = nPort; if (!Connect(lpszHost, nPort)) { int nErr = GetLastError(); // WSAEWOULDBLOCK 是正常路径:连接正在进行中 if (nErr != WSAEWOULDBLOCK) { m_nState = STATE_DISCONNECTED; Close(); ScheduleReconnect(); } } } void CNetClient::OnConnect(int nErrorCode) { if (nErrorCode != 0) { // 连接失败,走退避重连 m_nState = STATE_DISCONNECTED; Close(); ScheduleReconnect(); return; } m_nState = STATE_CONNECTED; m_nRetry = 0; // 成功后重置退避计数 m_dwLastRecvTick = GetTickCount(); Flush(); // 补发断线期间积压的数据 CAsyncSocket::OnConnect(0); }ConnectTo的两个参数分别是主机名(也可以传点分十进制 IP)和端口。Shutdown是自定义的清理函数,内部先Shutdown(SD_BOTH)再Close,比直接Close温和一些,对端能立刻感知到断开而不是等超时。
OnConnect里的nErrorCode是连接结果,0 表示成功,其余是具体的 Winsock 错误码。成功之后立刻调一次Flush是个好习惯——断线期间界面可能已经往队列里塞了几条命令,如果不补发,用户会觉得“按钮点了没反应”。
4.2 状态机与指数退避重连
状态就三个:DISCONNECTED、CONNECTING、CONNECTED。所有回调第一件事都是校验当前状态,不在预期状态的事件直接丢弃,能挡掉大量重入问题。重连用宿主窗口的定时器驱动,延迟按 1s、2s、4s、8s 递增,封顶 30s。
void CNetClient::ScheduleReconnect() { if (m_nRetry >= 8) return; // 连不上太多次就停下,等用户手动重试 UINT nDelay = 1000 << m_nRetry; // 1s, 2s, 4s, 8s ... if (nDelay > 30000) nDelay = 30000; m_nRetry++; m_pHostWnd->SetTimer(TIMER_RECONNECT, nDelay, NULL); } // 宿主对话框里响应定时器 void CNetDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_RECONNECT) { KillTimer(TIMER_RECONNECT); m_client.ConnectTo(m_client.Host(), m_client.Port()); } else if (nIDEvent == TIMER_HEARTBEAT) { m_client.Tick(); } CDialogEx::OnTimer(nIDEvent); }ScheduleReconnect里的1000 << m_nRetry是位移做指数,m_nRetry上限 8 次是为了防止在无人值守的场景无限刷日志。定时器 ID 用宏区分,OnTimer里每个分支处理完记得KillTimer,否则TIMER_RECONNECT会一直重复触发,变成一秒连八次的灾难现场。
这里有个取舍:为什么不用Sleep加循环?因为客户端跑在 UI 线程上,任何Sleep都会让界面假死,而且Connect的回调需要消息泵才能送达,阻塞住就永远等不到。定时器是这套模型里唯一正确的等待方式。
4.3 心跳与半开连接检测
TCP 有个尴尬的中间状态:网线拔了、路由器重启了,本地 socket 并不会立刻收到FD_CLOSE,因为对端没有任何数据发过来,内核也不知道对面死了。这种半开连接只能靠应用层心跳来检测。
void CNetClient::Tick() { if (m_nState != STATE_CONNECTED) return; DWORD dwNow = GetTickCount(); // 超过 3 个心跳周期没收到任何数据,判定连接已死 if (dwNow - m_dwLastRecvTick > HEARTBEAT_INTERVAL * 3) { m_nState = STATE_DISCONNECTED; Close(); ScheduleReconnect(); return; } // 发一个空包体心跳,对端收到后回一个同样的包 PACKET_HEAD head; head.nType = PKT_HEARTBEAT; head.nLen = 0; SendPacket(&head, sizeof(head)); }m_dwLastRecvTick在OnReceive里每收到一次数据就更新一次,所以心跳回包也能续命。HEARTBEAT_INTERVAL取 30 秒,超时判定用 3 倍,也就是 90 秒,这个组合在移动网络和弱网环境下都比较稳;如果设备侧是电池供电的低功耗模块,可以放宽到 60 秒发一次。
注意GetTickCount()的溢出问题:它大约 49.7 天回绕一次,直接用dwNow - m_dwLastRecvTick的无符号减法在回绕时结果反而是对的,不需要额外处理。但用if (dwNow > m_dwLastRecvTick + 90000)这种写法就会在回绕时误判成超时,这个坑我在别的项目里真实踩过。
5. 避坑与排查:CAsyncSocket 里最容易翻车的四件事
下面这几条都是我在实际工程里撞过的,现象、原因、解决一样不少,照着对号入座能省下大半天。
5.1 现象:客户端一关,服务端进程直接崩掉
原因:在OnClose里直接delete this或者delete了正在被回调栈引用的对象。OnClose是从隐藏窗口的窗口过程一路调下来的,delete之后基类的CAsyncSocket::OnClose(nErrorCode)还会访问成员,访问的已经是释放过的内存。
解决:回调里只做标记和关闭,不立刻销毁对象。把待删对象塞进一个待清理列表,用PostMessage通知宿主窗口,在消息处理函数里统一delete。这个时机已经退出了回调栈,安全。
void CClientSocket::OnClose(int nErrorCode) { // 只做标记,销毁交给宿主窗口在消息循环里完成 m_bClosed = TRUE; m_pServer->PostRemove(this); CAsyncSocket::OnClose(nErrorCode); }5.2 现象:发一个大包,对端只收到一半,之后数据全乱
原因:Send一次没发完就直接返回了,剩下的数据丢了,或者调用方以为“一次Send就是一个包”。前者是没处理部分发送,后者是没做粘包处理。
解决:所有发送走发送队列加Flush,未发完的字节留在队列里等OnSend续发;接收侧一律用长度前缀拆包,不要假设一次Receive等于一个完整消息。另外别忘了OnSend里要重新AsyncSelect挂上FD_WRITE,否则队列永远不会被清空。
5.3 现象:界面上点一下就卡三秒,日志里OnReceive之后没下文
原因:在OnReceive里做了阻塞操作,比如查数据库、写文件、调Sleep、等一个锁。因为回调跑在 UI 线程上,卡住回调等于卡住整个消息循环。
解决:回调里只做“收数据 + 进队列”,真正的业务处理丢给工作线程,处理完用PostMessage回主线程刷界面。反过来也一样,工作线程要发数据不能直接调 socket,得PostMessage回主线程发。跨线程直接操作 CAsyncSocket 对象是另一个大坑,句柄的关闭和重连全在 UI 线程,两个线程抢同一份缓冲区迟早出事。
5.4 现象:Debug 下跑得好好的,Release 一压测就崩,错误码还对不上
原因:一是把CAsyncSocket的返回值和GetLastError()混用,OnXxx回调的nErrorCode参数已经是错误码了,不需要再调GetLastError();二是 Release 下ASSERT全被裁掉,一些只有断言保护的空指针就漏过去了。
解决:回调里统一用参数传进来的nErrorCode判断,只有自己直接调Receive/Send且返回值是SOCKET_ERROR时才去查GetLastError()。另外养成习惯,Close之前先判断m_hSocket != INVALID_SOCKET,销毁对象前先Shutdown,别指望析构函数帮你兜全部。
6. 二次开发:把这套例子改成能塞进正式工程的通信层
例子能跑起来只是起点,真要放进项目里,第一件该做的是把“网络”和“界面”切开。现在回调里直接刷控件,改一个字段就得动对话框代码,多个人协作时冲突不断。我一般会加一层回调接口:CClientSocket和CNetClient不再持有窗口指针,改成持有一个INetSink*,OnPacket解析完就调sink->OnPacket(type, data, len),由宿主自己决定是刷界面还是写日志还是入缓存。这样一来,同样的 socket 代码可以给控制台程序用,也可以塞进没有界面的 Windows 服务里,只换一个 sink 实现。
第二个改造点是协议分发。例子里的OnPacket现在是一串switch,业务一多就变成几百行的面条。我的做法是搞一张包类型到处理函数的映射表,注册进去,OnPacket只负责查表调用,未注册的类型直接丢弃并记一条日志。改造后大致是这样:
// 注册:类型 -> 成员函数 m_mapHandler[PKT_HEARTBEAT] = std::bind(&CNetClient::OnHeartbeat, this, _1, _2); m_mapHandler[PKT_DATA] = std::bind(&CNetClient::OnData, this, _1, _2); void CNetClient::OnPacket(const void* pData, int nLen) { if (nLen < sizeof(PACKET_HEAD)) return; const PACKET_HEAD* pHead = static_cast<const PACKET_HEAD*>(pData); auto it = m_mapHandler.find(pHead->nType); if (it == m_mapHandler.end()) { // 未知类型只记录,不断连接,方便前后端版本不一致时灰度 return; } it->second(static_cast<const char*>(pData) + sizeof(PACKET_HEAD), nLen - sizeof(PACKET_HEAD)); }第三个改造点是可观测性。CAsyncSocket 出问题时最难受的是没有现场,只看到“卡住了”。我会在每个回调入口打一条带时间戳和状态的日志,尤其是OnConnect的nErrorCode、OnClose时队列里还剩多少字节、Flush返回WSAEWOULDBLOCK的次数。这三类数字攒起来,弱网问题基本能靠日志定位,不用连上调试器碰运气。
验证改造是否成功,我习惯用两个土办法:一是拿一个只会nc式发包的脚本往服务端灌半包,看拆包逻辑会不会越界;二是把网线拔掉三十秒再插回,确认客户端能自己重连且不重复发数据。这两关过了,再谈压测。
最后说个教训。早些年我改这类通信层,总喜欢顺手把日志和状态判断一起优化掉,觉得代码更干净。结果一次半夜的现场问题,日志里干干净净,什么线索都没有,只能靠猜。从那以后我每次动 socket 代码,都强制自己先跑一遍“拔网线、重启对端、端口占用”这三件套,再动手改业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取