三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

VC++ Winsock TCP网络编程实战:从基础API到C/S架构实现

VC++ Winsock TCP网络编程实战:从基础API到C/S架构实现

1. 项目概述与核心价值

最近在整理一些老项目,翻出来一个用VC++写的TCP网络通讯程序,包含了完整的服务器和客户端实现,还带源码。这让我想起刚入行那会儿,网络编程这块真是踩了不少坑,尤其是Windows平台下的Winsock,各种初始化、字节序、错误处理,稍不注意程序就崩了。现在很多新手可能直接用现成的库或者框架,但我觉得,亲手用最基础的API实现一遍TCP通讯,对于理解网络编程的底层原理、排查线上问题,甚至是面试时被问到三次握手四次挥手的具体实现,都大有裨益。

这个项目就是一个典型的C/S架构模型,服务器端在指定端口监听,客户端发起连接,双方建立TCP连接后进行双向数据收发。别看功能基础,但麻雀虽小五脏俱全,里面涉及了Winsock库的初始化和清理、阻塞式Socket的创建与关闭、网络地址的转换、以及最基本的send/recv循环。对于想深入理解Windows网络编程、或者需要在MFC、Win32控制台程序中集成网络功能的开发者来说,这是一个非常扎实的起点。我会结合源码,把每个步骤为什么这么做、常见的坑在哪里、以及如何调试都讲清楚,让你不仅能跑通代码,更能明白背后的门道。

2. 核心思路与架构设计

2.1 为什么选择Winsock和阻塞式Socket

在Windows平台用C++做网络开发,Winsock是绕不开的基石。它提供了与BSD Socket高度兼容的API,是微软对Socket编程的标准实现。选择它,意味着你的代码有最广泛的平台兼容性和文档支持。有人可能会问,为什么不直接用更上层的库,比如Boost.Asio或者libevent?对于学习和小型工具来说,直接使用Winsock能让你最直观地接触到TCP/IP协议栈在操作系统层面的接口,理解连接建立、数据收发、错误返回的每一个细节。当你真正理解了这些,再去用高级库,就会明白它们帮你封装了什么,处理了什么,心里更有底。

至于阻塞式Socket,这是最直观、最简单的编程模型。调用connectacceptsendrecv这些函数时,程序会一直等待直到操作完成(成功或失败)才返回。这种同步模型逻辑清晰,非常适合初学者理解“请求-响应”的流程。当然,它的缺点也明显:一个连接上的阻塞操作会挂起整个线程,不适合需要高并发处理大量连接的场景。但作为入门,我们从这里开始,先把单线程、单连接的流程跑通,后续再探讨多线程、I/O复用等进阶方案,学习路径会更平滑。

2.2 服务器与客户端的分工与交互流程

一个典型的TCP通讯流程,可以类比为打电话。服务器就像一部固定电话,它需要先有一个号码(IP地址)并开机待机(绑定端口并监听)。客户端就像一部手机,它需要知道服务器的号码,然后主动拨号(发起连接)。电话接通(TCP三次握手成功)后,双方就可以对话(收发数据)了。最后,任何一方挂断电话(关闭连接),通讯结束。

具体到程序层面,服务器端需要完成以下核心步骤:

  1. 初始化网络库:启动Winsock服务,这是所有网络操作的前提。
  2. 创建监听Socket:创建一个专门用于监听的Socket,它不直接参与数据交换,只负责接收新的连接请求。
  3. 绑定地址和端口:告诉操作系统,这个监听Socket在哪个IP地址和端口上“待机”。
  4. 开始监听:将Socket置于监听状态,准备接收客户端的连接请求。
  5. 接受连接:当有客户端连接时,accept函数会返回一个新的Socket。这个新Socket才是与这个特定客户端进行数据收发的通道。服务器通常会用一个循环来处理多个连接。
  6. 收发数据:使用sendrecv函数通过新Socket与客户端通信。
  7. 关闭连接与清理:通信完毕,关闭数据Socket和监听Socket,并清理Winsock库。

客户端流程则相对简单:

  1. 初始化网络库:同样需要WSAStartup。
  2. 创建连接Socket:创建一个用于主动发起连接的Socket。
  3. 指定服务器地址:填充一个sockaddr_in结构体,包含服务器的IP和端口。
  4. 发起连接:调用connect函数,向服务器发起TCP三次握手。
  5. 收发数据:连接建立后,使用sendrecv与服务器通信。
  6. 关闭连接与清理:关闭Socket,清理Winsock。

理解这个分工至关重要。很多新手会混淆服务器的监听Socket和连接后产生的数据Socket,导致资源管理混乱。

3. 环境准备与Winsock初始化详解

3.1 开发环境与项目配置

这个项目可以在Visual Studio(建议VS2015及以上)中轻松创建。新建一个“Win32控制台应用程序”项目即可。关键在于链接正确的库文件。Winsock的主要实现位于ws2_32.dll动态库中,其导入库文件是ws2_32.lib

在VC++中,有两种方式链接这个库:

  1. 项目属性配置:在项目属性页 -> 链接器 -> 输入 -> 附加依赖项中,添加ws2_32.lib。这是最规范的做法,适合正式项目。
  2. 代码中预编译指令:在包含头文件后,使用#pragma comment(lib, "ws2_32.lib")。这种方式简单直接,适合小型示例或快速测试。我们源码中采用的就是这种方式。

注意:如果你在非Visual Studio的编译环境(如MinGW、Cygwin)下编译,可能会遇到链接错误。这时需要在编译命令中显式指定链接库,例如g++编译时加上-lws2_32参数。

3.2 WSAStartup与WSACleanup:不可或缺的守卫

这是Windows网络编程特有的步骤,也是第一个容易出错的地方。在Unix/Linux系统下,Socket是系统内核的一部分,可以直接调用。而在Windows下,Socket功能被封装在动态库中,需要先初始化这个库。

#include <winsock2.h> #include <ws2tcpip.h> // 为了使用inet_pton等新函数 #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsaData; int iResult = WSAStartup(MAKEWORD(2, 2), &wsaData); if (iResult != 0) { printf("WSAStartup failed: %d\n", iResult); return 1; } // ... 你的网络代码 WSACleanup(); return 0; }

关键点解析:

  • MAKEWORD(2, 2):这里请求Winsock 2.2版本。高版本兼容低版本,2.2是目前最通用和稳定的版本。你也可以尝试请求更新的版本,但需要检查系统是否支持。
  • WSADATA wsaData:这个结构体在调用WSAStartup后,会被填充当前加载的Winsock库的详细信息,比如版本号、描述等。虽然我们示例中没用到,但在复杂程序中可以用来做版本兼容性检查。
  • 返回值检查WSAStartup的返回值必须检查。非0即表示失败,可能是系统不支持请求的版本,或者Winsock库本身损坏。
  • 配对使用:每一个成功的WSAStartup调用,在程序最后都必须对应一个WSACleanup调用,用于释放库资源。如果忘记调用,在程序退出时可能不会立即有问题,但在长期运行或动态加载/卸载DLL的场景下,会造成资源泄漏。一个好的习惯是,在main函数开头初始化,在return之前清理。

实操心得:我遇到过最诡异的问题是一个DLL模块内部调用了WSAStartup,但主程序不知道,主程序自己也调用了,最后却只调用了一次WSACleanup。这导致程序退出时偶尔会卡住。所以,在模块化设计中,最好由最上层的主模块统一管理Winsock的生命周期。

4. 服务器端核心实现与源码剖析

4.1 创建、绑定与监听:搭建接待前台

服务器端的开始,是创建一个监听Socket。你可以把它想象成公司前台的总机号码。

SOCKET ListenSocket = INVALID_SOCKET; ListenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (ListenSocket == INVALID_SOCKET) { printf("socket failed with error: %ld\n", WSAGetLastError()); WSACleanup(); return 1; }
  • AF_INET:指定使用IPv4地址族。如果要支持IPv6,需使用AF_INET6
  • SOCK_STREAM:指定流式Socket,即TCP协议,提供面向连接的、可靠的、双向的字节流。
  • IPPROTO_TCP:明确指定TCP协议。实际上,当第二个参数为SOCK_STREAM时,这个参数可以简写为0,系统会自动选择TCP。但显式写出是好习惯。

创建好Socket后,需要给它分配一个“电话号码”(绑定IP和端口)。

struct sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_addr.s_addr = INADDR_ANY; // 关键所在 serverAddr.sin_port = htons(8888); // 监听8888端口 iResult = bind(ListenSocket, (struct sockaddr*)&serverAddr, sizeof(serverAddr)); if (iResult == SOCKET_ERROR) { printf("bind failed with error: %d\n", WSAGetLastError()); closesocket(ListenSocket); WSACleanup(); return 1; }
  • INADDR_ANY:这是一个特殊的地址常量(值为0.0.0.0)。它表示让服务器监听本机所有可用的网络接口(网卡)上的连接请求。如果你的服务器有多块网卡(比如一个内网一个外网),使用这个值可以同时接受来自所有网卡的连接。如果你只想监听特定IP(比如只监听内网192.168.1.100),就需要使用inet_ptoninet_addr来指定。
  • htons(8888)htons函数将主机字节序的短整型(16位端口号)转换为网络字节序(大端序)。这是网络编程中必须进行的步骤,因为不同CPU架构的字节序可能不同,网络传输需要统一的标准。记住一个口诀:“主机到网络用hton,网络到主机用ntoh”。

绑定成功后,Socket还处于“关机”状态。需要调用listen将其置于监听状态,开始等待客户端的“来电”。

iResult = listen(ListenSocket, SOMAXCONN); if (iResult == SOCKET_ERROR) { printf("listen failed with error: %d\n", WSAGetLastError()); closesocket(ListenSocket); WSACleanup(); return 1; } printf("Server is listening on port 8888...\n");
  • SOMAXCONN:是系统定义的最大连接队列长度。当客户端发起连接而服务器还没来得及调用accept处理时,连接请求会放在这个队列里。使用这个常量让系统决定最佳值即可。如果队列满了,新的连接请求会被拒绝。

4.2 Accept循环与客户端会话管理

监听设置好后,服务器进入一个循环,不断接受新连接。accept函数是一个阻塞调用,如果没有新连接,程序会停在这里等待。

SOCKET ClientSocket = INVALID_SOCKET; struct sockaddr_in clientAddr; int clientAddrLen = sizeof(clientAddr); while (true) { printf("Waiting for client connection...\n"); ClientSocket = accept(ListenSocket, (struct sockaddr*)&clientAddr, &clientAddrLen); if (ClientSocket == INVALID_SOCKET) { printf("accept failed with error: %d\n", WSAGetLastError()); closesocket(ListenSocket); WSACleanup(); return 1; } // 成功接收到一个客户端连接 char clientIP[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &clientAddr.sin_addr, clientIP, INET_ADDRSTRLEN); printf("Client connected from %s:%d\n", clientIP, ntohs(clientAddr.sin_port)); // 处理这个客户端连接(例如,创建一个新线程来处理) // HandleClientConnection(ClientSocket); // 为了示例简单,我们直接在当前线程处理,但这会阻塞接受新连接 // 实际项目中应使用多线程或I/O复用 HandleClient(ClientSocket); }

关键点解析:

  • accept返回一个新的Socket描述符(ClientSocket)。这个Socket是与刚刚建立连接的客户端进行唯一通信的通道。原始的ListenSocket继续用于监听新的连接。
  • clientAddr结构体被accept函数填充,包含了连接过来的客户端的IP地址和端口信息。使用inet_ntop可以将网络字节序的IP地址转换回可读的字符串。
  • 阻塞问题:上面的代码中,HandleClient函数会处理与当前客户端的全部通信,如果这个函数不返回(比如在循环收发数据),accept就无法被再次调用,服务器就无法处理第二个客户端的连接。这是阻塞式Socket最典型的局限性。

解决方案:在实际应用中,我们必须为每个接受的客户端连接创建一个独立的线程(或使用线程池)来运行HandleClient,这样主线程就能快速回到accept调用,继续接收新连接。这就是经典的“一个连接一个线程”模型。当然,对于连接数非常高的场景,就需要考虑I/O复用(如select,poll,IOCP)或异步模型了。

4.3 数据收发与协议设计雏形

HandleClient函数中,我们使用recvsend进行通信。

void HandleClient(SOCKET ClientSocket) { char recvBuf[512]; int iResult; int iSendResult; do { iResult = recv(ClientSocket, recvBuf, sizeof(recvBuf), 0); if (iResult > 0) { printf("Bytes received: %d, Data: %.*s\n", iResult, iResult, recvBuf); // 简单回显 iSendResult = send(ClientSocket, recvBuf, iResult, 0); if (iSendResult == SOCKET_ERROR) { printf("send failed: %d\n", WSAGetLastError()); closesocket(ClientSocket); return; } printf("Bytes sent back: %d\n", iSendResult); } else if (iResult == 0) { printf("Connection closing...\n"); } else { printf("recv failed: %d\n", WSAGetLastError()); closesocket(ClientSocket); return; } } while (iResult > 0); iResult = shutdown(ClientSocket, SD_SEND); if (iResult == SOCKET_ERROR) { printf("shutdown failed: %d\n", WSAGetLastError()); } closesocket(ClientSocket); }

关键点解析:

  1. recv的返回值
    • > 0:成功接收到的字节数。注意,recvBuf末尾不会自动添加字符串结束符\0。如果你把它当C字符串处理,需要手动添加:recvBuf[iResult] = '\0';。上面的printf使用了%.*s格式说明符来安全地打印指定长度的字符串。
    • == 0:对方优雅地关闭了连接(发送了FIN包)。这是正常的断开流程。
    • == SOCKET_ERROR:接收出错,调用WSAGetLastError()获取错误码。
  2. send的返回值:它返回实际成功发送的字节数。在阻塞模式下,如果返回值小于你要求发送的长度,通常意味着本地网络缓冲区已满,需要你稍后重试剩余部分。但在简单的回显服务器中,我们假设一次能发完。
  3. TCP是流式协议:这是网络编程中最核心的概念之一。recvsend操作的是字节流,没有消息边界。对方一次send的100字节数据,你可能分两次recv(一次30,一次70)收到。对方两次send(每次50字节),你可能一次recv就收到100字节。绝对不能假设一次recv就能收到一个完整的“消息”。
  4. 优雅关闭shutdown(ClientSocket, SD_SEND)用于关闭本端的发送通道,告诉对方“我没有数据要发了”,但还可以继续接收对方可能还在发送的数据。这是一个好的习惯,确保了TCP连接关闭的完整性。最后调用closesocket释放资源。

协议设计雏形:为了解决TCP无消息边界的问题,必须在应用层设计协议。最简单的方式是“定长协议”或“变长协议(长度前缀)”。例如,可以在每条消息前加一个4字节的头部,表示后面消息体的长度。接收方先收4字节,解析出长度N,然后循环recv直到收满N字节,这才算一个完整的应用层消息。这是实现任何严肃网络通信的基础。

5. 客户端核心实现与连接策略

5.1 地址转换函数的选用与陷阱

客户端需要指定服务器的地址。这里涉及将字符串形式的IP(如"127.0.0.1""192.168.1.1")转换为网络字节序的二进制格式。

struct sockaddr_in serverAddr; serverAddr.sin_family = AF_INET; serverAddr.sin_port = htons(8888); // 服务器端口 // 方法一:使用 inet_pton (推荐,支持IPv4/IPv6) iResult = inet_pton(AF_INET, "127.0.0.1", &serverAddr.sin_addr); if (iResult != 1) { printf("inet_pton failed, invalid address string or address family.\n"); // 处理错误 } // 方法二:使用 inet_addr (传统,仅IPv4) serverAddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1"); if (serverAddr.sin_addr.S_un.S_addr == INADDR_NONE) { printf("inet_addr failed, invalid address.\n"); // 处理错误 }

关键点与避坑指南:

  • inet_pton(presentation to network)是现代、推荐的方式,它直接填充到sockaddr_insin_addr结构体中,并且同时支持IPv4和IPv6。但它需要ws2tcpip.h头文件。
  • inet_addr是旧函数,只支持IPv4,它返回一个unsigned long类型的值。你需要将其赋值给sin_addr.S_un.S_addr
  • 一个大坑(MinGW用户尤其注意):在某些编译环境(如老版本MinGW)下,inet_pton可能未被定义或链接不到。这是因为其依赖的Windows系统版本宏_WIN32_WINNT设置过低。解决方案有两种:
    1. 在包含头文件前定义宏:#define _WIN32_WINNT 0x0600(Windows Vista及以上)。
    2. 回退使用inet_addr。我们的源码为了兼容性,通常使用inet_addr

5.2 Connect与收发数据循环

客户端连接服务器的代码相对直接。

SOCKET ConnectSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); // ... 创建socket,检查错误 iResult = connect(ConnectSocket, (struct sockaddr*)&serverAddr, sizeof(serverAddr)); if (iResult == SOCKET_ERROR) { printf("connect failed: %d\n", WSAGetLastError()); closesocket(ConnectSocket); WSACleanup(); return 1; } printf("Connected to server!\n");

connect函数会发起TCP三次握手,这是一个阻塞调用,直到握手成功或失败才会返回。常见的错误包括网络不可达、服务器未监听、防火墙拦截等。

连接建立后,客户端就可以进入一个循环,发送用户输入的数据并接收服务器回显。

char sendBuf[512]; char recvBuf[512]; do { // 获取用户输入(示例用fgets,实际可能有GUI) printf("Enter message to send (or 'quit' to exit): "); fgets(sendBuf, sizeof(sendBuf), stdin); // 去掉末尾的换行符 sendBuf[strcspn(sendBuf, "\n")] = 0; if (strlen(sendBuf) == 0) continue; if (strcmp(sendBuf, "quit") == 0) break; // 发送数据 iResult = send(ConnectSocket, sendBuf, (int)strlen(sendBuf), 0); if (iResult == SOCKET_ERROR) { printf("send failed: %d\n", WSAGetLastError()); break; } printf("Bytes Sent: %ld\n", iResult); // 接收回显 iResult = recv(ConnectSocket, recvBuf, sizeof(recvBuf), 0); if (iResult > 0) { recvBuf[iResult] = '\0'; // 添加字符串结束符 printf("Echo from server: %s\n", recvBuf); } else if (iResult == 0) { printf("Server closed the connection.\n"); break; } else { printf("recv failed: %d\n", WSAGetLastError()); break; } } while (true); // 关闭连接 closesocket(ConnectSocket);

这个简单的交互循环演示了最基本的“一问一答”模式。在实际应用中,发送和接收很可能不在同一个循环内,或者需要处理超时、并发发送等问题。

6. 错误处理、资源管理与调试技巧

6.1 无处不在的WSAGetLastError

Winsock函数失败时,几乎都会返回SOCKET_ERROR(通常是-1)。此时,绝对不能简单地打印“出错了”,必须调用WSAGetLastError()函数获取具体的错误代码。这个代码是排查问题的唯一线索。

int errorCode = WSAGetLastError(); printf("Operation failed with error: %d\n", errorCode);

你可以根据错误码去查微软官方文档。一些常见错误:

  • 10061:连接被拒绝。通常是服务器没启动,或者IP/端口不对。
  • 10048:地址已在使用。通常是端口被占用,可能是之前的服务器进程没有完全退出。
  • 10054:连接被对方强制关闭。对方可能崩溃或调用了closesocket
  • 10060:连接超时。

6.2 资源泄漏的预防:关闭与清理的顺序

Socket和Winsock库都是系统资源,必须妥善管理,避免泄漏。一个基本原则是:谁创建,谁关闭;先创建的后关闭

  1. 关闭Socket:对于每个创建的Socket(无论是监听Socket还是客户端Socket),在使用完毕后,必须调用closesocket。在错误处理路径上,也要记得关闭已成功创建的Socket。
  2. 调用WSACleanup:在程序最后,确保调用WSACleanup。通常放在main函数的最后,在所有Socket都关闭之后。
  3. 优雅关闭流程:对于已建立的连接,建议先调用shutdown(socket, SD_SEND)通知对方不再发送数据,然后继续recv直到收到0(对方关闭),最后再closesocket。这确保了TCP连接正常终止,避免了TIME_WAIT状态过多的问题。

一个典型的资源清理模板如下:

// 初始化 WSAStartup(...); SOCKET s = socket(...); // ... 其他操作 // 错误处理或结束时的清理 if (s != INVALID_SOCKET) { shutdown(s, SD_SEND); // 可选,优雅关闭 closesocket(s); } WSACleanup();

6.3 调试与排查实战记录

问题一:bind失败,错误10048这是新手最常遇到的问题。你刚关闭服务器程序,马上再次运行,就提示“Address already in use”。

  • 原因:TCP连接关闭后,端口会进入TIME_WAIT状态,默认持续2*MSL(约2-4分钟),在此期间端口不能被复用。
  • 解决方案:在bind之前,对监听Socket设置SO_REUSEADDR选项。
    int optval = 1; setsockopt(ListenSocket, SOL_SOCKET, SO_REUSEADDR, (char*)&optval, sizeof(optval));
    这告诉系统,允许重用处于TIME_WAIT状态的地址。注意:这主要用于服务器快速重启,在客户端Socket上一般不需要。

问题二:recvsend卡住不动在阻塞模式下,如果网络连接异常(如网线被拔),这些调用可能会一直阻塞。

  • 解决方案:对于需要健壮性的程序,应该使用select函数来检查Socket是否可读或可写,或者设置接收/发送超时。
    // 设置接收超时为5秒 int timeout = 5000; // 毫秒 setsockopt(ClientSocket, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout));
    设置后,recv在超时后会返回SOCKET_ERROR,并且WSAGetLastError()会是10060(WSAETIMEDOUT)。

问题三:数据收发不完整或粘包如前所述,这是TCP流式协议的特性,不是Bug。

  • 解决方案:设计应用层协议。这里给出一个最简单的“长度前缀法”接收函数的伪代码:
    bool ReceiveFixedLength(SOCKET s, char* buffer, int lengthToReceive) { int totalReceived = 0; while (totalReceived < lengthToReceive) { int ret = recv(s, buffer + totalReceived, lengthToReceive - totalReceived, 0); if (ret <= 0) return false; // 出错或连接关闭 totalReceived += ret; } return true; } // 使用:先接收4字节的头部(消息长度N),再调用此函数接收N字节的消息体。

7. 项目扩展与进阶思考

实现一个基础的echo服务器和客户端只是起点。基于这个框架,你可以进行很多有意义的扩展,这会让你的网络编程能力真正上一个台阶。

1. 支持多客户端连接(多线程版)这是最直接的扩展。修改服务器端的accept循环,每当接受到一个新连接,就创建一个新的线程(或从线程池取一个)来执行HandleClient函数,并将ClientSocket传递给该线程。主线程只负责接受连接。关键点:需要确保线程间对共享资源(如日志文件、客户端列表)的访问是安全的,通常使用互斥锁。同时,要设计好线程如何安全地退出。

2. 定义简单的应用层协议实现一个简单的聊天程序。规定每条消息的格式为:[消息长度(4字节)][实际消息内容]。服务器和客户端都按照这个格式来组包和解包。这能让你彻底理解如何解决TCP粘包/半包问题。

3. 加入简单的命令处理让服务器不仅能回显,还能处理一些简单命令。例如,客户端发送"GETTIME",服务器回复当前时间;发送"ECHO:hello",服务器回复"hello"。这涉及到在HandleClient函数中解析接收到的字符串。

4. 移植到I/O复用模型(select)当连接数成百上千时,“一个连接一个线程”的模型会消耗大量资源。可以尝试使用select函数实现单线程管理多个Socket。select可以同时监视多个Socket的可读、可写、异常事件,当任何一个Socket有事件发生时才去处理,避免了为每个连接创建线程的开销。这是理解高性能网络编程(如epoll,IOCP)的重要一步。

5. 封装成C++类将Socket的创建、连接、绑定、监听、收发数据等操作封装到一个TcpSocketNetworkSession类中。利用RAII(资源获取即初始化)思想,在构造函数中创建资源,在析构函数中自动关闭和清理。这能极大提高代码的复用性和安全性。

从最基础的阻塞式Socket起步,逐步解决遇到的各种问题,再向更高级的模式演进,这个过程本身就是学习网络编程的最佳路径。附带的源码提供了一个可以编译运行的起点,希望你不仅能运行它,更能拆解它、修改它、扩展它,最终把它变成你自己项目里稳定可靠的网络通信模块。

← 返回列表