重叠IO之事件通知

重叠IO:
        重叠IO是Windows提供的一种异步读写文件的机制
        正常读写文件(socket本质就是文件操作),如recv,是阻塞的,
        等协议缓存区中的数据全部复制进行自定义的buffer字符数组里,
        函数才结束并返回复制的个数,写也一样,同一时间只能读写一个,其他的都被阻塞,
        只能等读写操作完成之后阻塞才被解除
    重叠IO机制读写,将读的指令以及自定义的buffer投给操作系统,然后函数直接返回,操作系统独立开个线程,
    将数据复制进自定义buffer,数据复制期间,我们不用管,读写过程变成了异步,可以同时投递多个读写操作
    重叠IO 是将accept recv send 优化成了异步过程,被AcceptEx WSARecv WSASend 函数代替了
    重叠IO 是对基本C/S模型的直接优化

    异步选择模型、事件选择模型、重叠IO模型的区别:
    异步选择模型
        把消息与socket绑在一起,然后系统以消息机制处理反馈
    事件选择模型
        把事件与socket绑在一起,然后系统以事件机制处理反馈
    重叠IO模型
        把重叠结构与socket绑在一起,然后系统以重叠IO机制处理反馈
    重叠IO--事件通知逻辑:
        调用AcceptEx WSARecv WSASend投递
        被完成的操作,事件信号置成有信号
        调用WSAWaitForMultipleEvents获取事件信号

1.创建socket

SOCKET WSAAPI WSASocketA(// 创建一个用于异步操作的SOCKET
	  int                 af,				// 地址的类型 AF_INET
	  int                 type,				// 套接字类型 SOCK_STREAM
	  int                 protocol,			// 协议的类型 IPPROTO_TCP
	  LPWSAPROTOCOL_INFOA lpProtocolInfo,	// 设置套接字详细的属性 这里直接填NULL
	  GROUP               g,				// 一组socket的组ID,一次想操作多个socket 这里填0
	  DWORD               dwFlags			// 指定套接字属性 这里填 WSA_FLAG_OVERLAPPED 创建一个供重叠IO模型使用的socket
	);

2. AcceptEX() 函数

BOOL AcceptEx(						// 投递服务器socket,异步接收连接					
  SOCKET       sListenSocket,			// 服务器socket
  SOCKET       sAcceptSocket,			// 连接服务器的客户端的socket
  PVOID        lpOutputBuffer,			// 缓存区的指针,接收在新连接上发送的第一个数据
  DWORD        dwReceiveDataLength,		// 设置为0,则表示取消了3的功能
  DWORD        dwLocalAddressLength,	// 为本地地址信号保留的字节数。此值必须至少比使用的传输协议的最大地址长度16个字节
  DWORD        dwRemoteAddressLength,	// 为远程地址信息保留的字节数。此值必须至少比使用的传输协议的最大地址长度16个字节,不能为0
  LPDWORD      lpdwBytesReceived,		// 该函数可以接收第一次客户端发来的信息,如果这个刚好是调用时候接收到了,也即立即接收到了(客户端连接的同时发送了信息),这个时候装着接收到的字节数
  LPOVERLAPPED lpOverlapped				// 重叠结构体
);

3. WSARecv() 函数

int WSAAPI WSARecv(	// 函数作用:投递异步接收信息			
  SOCKET                             s,						// 客户端socket
  LPWSABUF                           lpBuffers,				// 接收后的新存储buffer 结构体WSABUF
  DWORD                              dwBufferCount,			// 是参数2是WSABUF结构体的个数
  LPDWORD                            lpNumberOfBytesRecvd,	// 接收成功的话,这里装着成功接收到的字节数
  LPDWORD                            lpFlags,				// 指向用于修改WSARecv函数调用行为的标准的指针
  LPWSAOVERLAPPED                    lpOverlapped,			// 重叠结构 
  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine	// 回调函数 这里填NULL
);

4. WSASend() 函数

int WSAAPI WSASend(	// 投递异步发送信息
  SOCKET                             s,						// 客户端的socket
  LPWSABUF                           lpBuffers,				// 接收后的信息存buffer 结构体WSABUF
  DWORD                              dwBufferCount,			// 是参数2结构体WSABUF的个数
  LPDWORD                            lpNumberOfBytesSent,	// 接收成功的话,这里装着发送的字节数
  DWORD                              dwFlags,				// 函数调用行为的标记
  LPWSAOVERLAPPED                    lpOverlapped,			// 重叠结构
  LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine	// 回调函数 这里直接填NULL
);

5. 等待事件 WSAWaitForMultipleEvents() 函数

DWORD WSAAPI WSAWaitForMultipleEvents(
  DWORD          cEvents,             //事件,socket的个数
  const WSAEVENT *lphEvents,  //事件数组的首地址
  BOOL           fWaitAll,               //直接填FALSE
  DWORD          dwTimeout,      //可以填WSA_INFINITE,表示一直等待事件的发生
  BOOL           fAlertable             //直接填FALSE
);

6. 处理等到的事件 WSAGetOverlappedResult() 函数

BOOL WSAAPI WSAGetOverlappedResult(	// 获取对应的socket上的具体情况
  SOCKET          s,			// 有信号的socket
  LPWSAOVERLAPPED lpOverlapped, // 对应的重叠结构
  LPDWORD         lpcbTransfer, // 由发送或者接收到的实际字节数, 0:代表客户端优雅的下线
  BOOL            fWait,		// 仅当重叠操作选择了基于事件的完成通知时,才能将fWait参数设置为TRUE,这里填TRUE
  LPDWORD         lpdwFlags		// 装WSARecv的参数5 lpFlags
);

服务端代码:

#define _WINSOCK_DEPRECATED_NO_WARNINGS
#include <winsock2.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <mswsock.h>
#pragma comment(lib, "ws2_32.lib")
#pragma comment(lib, "Mswsock.lib")

// 声明socket数组用于装所有的socket
SOCKET g_allsock[1024];
// 声明重叠结构体数组
OVERLAPPED g_allOlp[1024];
// 声明记录装socket的个数
int g_count;
// 声明接收客户端的信息的缓存区
char g_str[1024];

// 声明 封装AcceptEx函数
int PostAcceptEx();
// 声明 封装WSARecv函数
int PostWSARecv(int index);
// 声明 将WSASend封装成函数
int PostWSASend(int index);

// 将关闭所有的socket和对应的重叠IO事件对象封装成函数
void Clear()
{
	// 关闭所有的socket和对应的重叠IO事件对象
	for (int i = 0; i < g_count; i++)
	{
		closesocket(g_allsock[i]);
		WSACloseEvent(g_allOlp[i].hEvent);
	}
}

BOOL WINAPI fun(DWORD dwCtrlType)
{
	switch (dwCtrlType)
	{
	case CTRL_CLOSE_EVENT:
		// 关闭所有的socket和对应的重叠IO事件对象
		for (int i = 0; i < g_count; i++)
		{
			closesocket(g_allsock[i]);
			WSACloseEvent(g_allOlp[i].hEvent);
		}
		break;
	}
	return TRUE;
}

// 1.打开网络库并检验版本
void OpenAndCheckVersion()
{
	// 1,打开网络库
	WORD version = MAKEWORD(2, 2);	//设置要使用的库的版本 MAKEWORD(主版本,副版本); 
	WSADATA WSAData;
	int n = WSAStartup(version, &WSAData);
	// 检验是否成功打开网络库
	if (0 != n)
	{
		// 打开网络库失败,通过n 返回的错误码,给出提示
		switch (n)
		{
		case WSASYSNOTREADY:
			printf("重启电脑试试,或者检查ws_2_32库是否存在\n");
			break;
		case WSAVERNOTSUPPORTED:
			printf("当前库版本号不支持,尝试更换版本试试\n");
			break;
		case WSAEPROCLIM:
			printf("已达到对Windows套接字实现支持的任务数量的限制\n");
			break;
		case WSAEINPROGRESS:
			printf("正在阻止Windows Sockets 1.1操作,当前函数运行期间,因为某些原因造成阻塞\n");
			break;
		case WSAEFAULT:
			printf("lpWASData参数不是有效的指针,参数写错了\n");
			break;
		default:
			break;
		}
	}
	// 2.校验版本
	if (2 != HIBYTE(WSAData.wVersion) || 2 != LOBYTE(WSAData.wVersion))
	{
		// 打开版本号 2.2 失败
		printf("打开版本号 2.2 失败, 正在退出程序...\n");
		// 关闭网络库
		WSACleanup();
		// 退出程序
		return;
	}
}
// 2.创建服务端socket
SOCKET CreateSocket()
{
	// 3.创建套接字 socket 重叠IO 要将socket改成 WSASocket
	SOCKET sock = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);;
	// 检验是否成功创建套接字
	if (INVALID_SOCKET == sock)
	{
		// 创建套接字失败
		// 获取错误码
		int error = WSAGetLastError();
		printf("创建套接字失败,返回错误号为:%d\n", error);
		// 关闭网络库
		WSACleanup();
		// 退出程序
		return 0;
	}
	return sock;
}
// 3.绑定端口与ip地址
void Bind(SOCKET sock)
{
	// 4.绑定地址与端口号
	// 创建bind() 函数所需的第二个参数的结构体,该结构体装 地址类型,IP地址,端口号
	struct sockaddr_in server_s;
	server_s.sin_family = AF_INET;	// 地址类型
	server_s.sin_port = htons(12345);			// 端口号
	server_s.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");	// ip地址
	int isbind = bind(sock, (struct sockaddr*)&server_s, sizeof(server_s));
	// 检验绑定地址与端口号是否成功
	if (SOCKET_ERROR == isbind)
	{
		// 绑定地址与端口号失败
		// 获取错误号
		int error = WSAGetLastError();
		printf("绑定端口号和IP地址失败,返回错误号为:%d\n", error);
		// 关闭套接字
		closesocket(sock);
		// 关闭网络库
		WSACleanup();
		// 退出程序
		return;
	}
}
// 4.进行监听
void Listen(SOCKET sock)
{
	// 5.进行监听
	if (SOCKET_ERROR == listen(sock, SOMAXCONN))
	{
		// 套接字中断了侦听传入连接的状态
		// 获取错误号
		int error = WSAGetLastError();
		printf("监听失败,返回错误号为:%d\n", error);
		// 关闭套接字
		closesocket(sock);
		// 关闭网络库
		WSACleanup();
		// 退出程序
		return;
	}
}

int main()
{
	// 监视窗口关闭按钮
	SetConsoleCtrlHandler(fun, TRUE);

	// 1.打开网络库并检验版本
	OpenAndCheckVersion();

	// 2.创建服务端socket
	SOCKET socketServer = CreateSocket();

	// 3.绑定端口与ip地址
	Bind(socketServer);

	// 4.进行监听
	Listen(socketServer);
	
	// 将socket装进socket结合中
	g_allsock[g_count] = socketServer;
	// 创建事件,将对应的事件装进结合中
	g_allOlp[g_count].hEvent = WSACreateEvent();
	// 注意记录的个数要++
	g_count++;

	// 将AcceptEx 封装成函数
	// 判断AcceptEx返回值
	if (0 != PostAcceptEx())
	{
		// 出错了
		Clear();
		// 清理网络库
		WSACleanup();
		return 0;
	}
	// 循环等待事件
	while (1)
	{
		// 一个一个进行询问事件
		for (int i = 0; i < g_count; i++)
		{
			// 等待事件 获取发送信号的事件
			// 参数1:事件个数,参数2:事件列表,参数3:事件等待方式,参数4:等待事件时间间隔,参数5
			DWORD nWait =  WSAWaitForMultipleEvents(1, &g_allOlp[i].hEvent, FALSE, 0, FALSE);
			// 判断WSAWaitForMultipleEvents函数的返回值
			if (WSA_WAIT_FAILED == nWait || WSA_WAIT_TIMEOUT == nWait)
			{
				//printf("accept success\n");
				continue;	// 没有等待到事件发送,进行继续等待
			}
			// 处理等到事件操作
			// 声明 WSAGetOverlappedResult函数中参数3所需的 发送或者接收到的实际字节数,可判断客户端是否下线
			DWORD cbTransfer;
			// 声明 WSAGetOverlappedResult函数中参数5所需的 装WSARecv的参数5 lpFlags
			DWORD lpdwFlags;
			BOOL nWSAGOR = WSAGetOverlappedResult(g_allsock[i], &g_allOlp[i], &cbTransfer, TRUE, &lpdwFlags);
			if (FALSE == nWSAGOR)
			{
				// 获取错误码
				int a = WSAGetLastError();
				// 当a == 10054 则代表客户端强行下线
				if (10054 == a)
				{
					// 客户端下线了
					printf("Client force close!!!\n");
					// 关闭相应的socket和对应的重叠IO事件对象
					// 先关闭,在删除
					closesocket(g_allsock[i]);
					WSACloseEvent(g_allOlp[i].hEvent);
					// 从数组中删除 可以将最后一位数组元素将要删除的元素进行覆盖
					g_allsock[i] = g_allsock[g_count];
					g_allOlp[i] = g_allOlp[g_count];
					// 将循环变量-- 将要删除的socket从数组中删除,则数组下标要--
					i--;
					// 有效个数--
					g_count--;
					break;
				}
				// 重新等待
				continue;
			}
			// 1.处理accept
			// 当i的值为0是,表示进行链接 g_allsock[0]代表的是socketServer
			if (0 == i)
			{
				PostWSASend(g_count);
				printf("accept success\n");
				// 进行链接处理 调用 AcceptEx
				// 1.调用WSARecv 将WSARecv封装成函数 
				PostWSARecv(g_count);	// 注意参数要填g_count 因为客户端socket装到 g_allsock[g_count] 中
				// 2.根据情况进行WSASend 这里就不写了
				// 3.有效个数++,当接收客户端的连接之后,要将客户端socket装进结合中,记录装进结合socket的个数就要进行++,
				g_count++;	// 每装一个socket和对应的重叠IO事件对象到相应的数组中,记录的个数就要++
				// 调用accept 接收完客户端之后,进行等待其他客户端的连接请求
				PostAcceptEx();
				continue;
			}

			// 通过WSAGetOverlappedResult函数中的参数3判断客户端是否下线了
			if (0 == cbTransfer)
			{
				// 客户端下线了
				printf("Client close\n");
				// 关闭相应的socket和对应的重叠IO事件对象
				// 先关闭,在删除
				closesocket(g_allsock[i]);
				WSACloseEvent(g_allOlp[i].hEvent);
				// 从数组中删除 可以将最后一位数组元素将要删除的元素进行覆盖
				g_allsock[i] = g_allsock[g_count];
				g_allOlp[i] = g_allOlp[g_count];
				// 将循环变量-- 将要删除的socket从数组中删除,则数组下标要--
				i--;
				// 有效个数--
				g_count--;
				continue;
			}
			// 处理客户端没有下线操作
			if (0 != cbTransfer)
			{
				// 判断是否进行接收消息,当接收缓存区字节数组的第一个元素不为0是,进行接收消息
				if (0 != g_str[0])
				{
					// recv 收信息
					// 打印接收到的数据
					printf("%s\n", g_str);
					// 清空缓存区字节数,方便下次再次使用
					memset(g_str, 0, 1024);
					// 投递recv 进行下次接收数据
					PostWSARecv(i);
				}
				else
				{
					// send 发信息
					//printf("send ok\n");
					continue;
				}
			}
		}
	}
	// 关闭socket和对应的重叠IO事件对象
	Clear();
	// 清理网络库
	WSACleanup();
	system("pause");
	return 0;
}
// 定义 封装AcceptEx函数
int PostAcceptEx()
{
	// 创建AcceptEx函数中参数2所需的socket,连接服务器的客户端的socket
	g_allsock[g_count] = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, WSA_FLAG_OVERLAPPED);
	// 创建对应的重叠IO事件对象
	g_allOlp[g_count].hEvent = WSACreateEvent();
	// 定义AcceptEx函数所需的缓存区字符串数组
	char str[1024] = { 0 };
	// 定义AcceptEx函数中参数7所需的字节
	DWORD dwRecvied;
	BOOL nAcc = AcceptEx(g_allsock[0], g_allsock[g_count], str, 0, sizeof(struct sockaddr_in) + 16,
		sizeof(struct sockaddr_in) + 16, &dwRecvied, &g_allOlp[0]);
	// 判断 AcceptEx的返回值
	if (TRUE == nAcc)
	{
		// 立即完成返回 ,刚执行的就已经有客户端进行链接
		// 服务端接收链接,之后进行接收数据(recv),或是发送数据操作(send)
		// 1.调用WSARecv 将WSARecv封装成函数 
		PostWSARecv(g_count);	// 注意参数要填g_count 因为客户端socket装到 g_allsock[g_count] 中
		// 2.根据情况进行WSASend 这里就不写了
		// 3.有效个数++,当接收客户端的连接之后,要将客户端socket装进结合中,记录装进结合socket的个数就要进行++,
		g_count++;	// 每装一个socket和对应的重叠IO事件对象到相应的数组中,记录的个数就要++
		// 自己调用自己 接收完客户端之后,进行等待其他客户端的连接请求
		PostAcceptEx();
		return 0;
	}
	else
	{
		// 调用WSAGetLastError函数查错误码
		int a = WSAGetLastError();
		if (ERROR_IO_PENDING == a)
		{
			// 延迟等待 没有客户端进行链接请求
			return 0;
		}
		else
		{
			return a;
		}
	}
}
// 定义 封装WSARecv函数	参数用于标记是哪个socket和对应的重叠IO事件对象
int PostWSARecv(int index)
{
	// 声明函数WSARecv中参数2所需的结构体
	WSABUF wsaBuf;
	// 给结构体成员赋值
	wsaBuf.buf = g_str;			// 指向字符数组的指针
	wsaBuf.len = sizeof(g_str);	// 字节数
	// 声明函数WSARecv中参数4所需的成功接收到的字节数
	DWORD NumberOfBytesRecvd;
	// 声明函数WSARecv中参数5所需的用于修改WSARecv函数调用行为的标准的指针
	DWORD Flags = 0;
	// WSARecv函数的参数1:客户端的socket,参数6:客户端的重叠IO事件对象结构
	int nWSAR =  WSARecv(g_allsock[index], &wsaBuf, 1, &NumberOfBytesRecvd, &Flags, &g_allOlp[index], NULL);
	if (0 == nWSAR)
	{
		// 立即完成
		// 打印数据
		printf("%s\n", g_str);
		// 将接收字节的缓存区清空
		memset(g_str, 0, 1024);
		// 调用自身
		PostWSARecv(index);
		return 0;
	}
	else
	{
		// 获取错误码
		int a = WSAGetLastError();
		if (WSA_IO_PENDING == a)
		{
			// 延迟完成
			return 0;
		}
		else
		{
			return a;
		}
	}
}
// 定义 将WSASend封装成函数
int PostWSASend(int index)
{
	// 声明函数WSASend所需的结构体
	WSABUF wsaBuf;
	// 给结构体成员赋值
	wsaBuf.buf = "成功调用send";
	wsaBuf.len = sizeof(wsaBuf.buf);
	// 声明WSASend函数中参数4 所需的接收字节数
	DWORD NumberOfBytesSent;
	// 声明WSASend函数中参数5 所需的函数调用行为的的标记
	DWORD dwFlags = 0;

	int nWSAS = WSASend(g_allsock[index], &wsaBuf, 1, &NumberOfBytesSent, dwFlags, &g_allOlp[index], NULL);
	if (0 == nWSAS)
	{
		// 立即完成
		// 打印数据
		printf("send ok\n");
		return 0;
	}
	else
	{
		// 获取错误码
		int a = WSAGetLastError();
		if (WSA_IO_PENDING == a)
		{
			// 延迟完成
			return 0;
		}
		else
		{
			return a;
		}
	}
}

客户端代码:

#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
#include <Winsock.h>
#pragma comment(lib,"ws2_32.lib")

// 1.打开网络库并校验版本
void OpenAndCheckVersion()
{
	// 1,打开网络库
	WORD version = MAKEWORD(2, 2);	//设置要使用的库的版本 MAKEWORD(主版本,副版本); 
	WSADATA WSAData;
	int n = WSAStartup(version, &WSAData);
	// 检验是否成功打开网络库
	if (0 != n)
	{
		// 打开网络库失败,通过n 返回的错误码,给出提示
		switch (n)
		{
		case WSASYSNOTREADY:
			printf("重启电脑试试,或者检查ws_2_32库是否存在\n");
			break;
		case WSAVERNOTSUPPORTED:
			printf("当前库版本号不支持,尝试更换版本试试\n");
			break;
		case WSAEPROCLIM:
			printf("已达到对Windows套接字实现支持的任务数量的限制\n");
			break;
		case WSAEINPROGRESS:
			printf("正在阻止Windows Sockets 1.1操作,当前函数运行期间,因为某些原因造成阻塞\n");
			break;
		case WSAEFAULT:
			printf("lpWASData参数不是有效的指针,参数写错了\n");
			break;
		default:
			break;
		}
	}

	// 2.校验版本
	if (2 != HIBYTE(WSAData.wVersion) || 2 != LOBYTE(WSAData.wVersion))
	{
		// 打开版本号 2.2 失败
		printf("打开版本号 2.2 失败, 正在退出程序...\n");
		// 关闭网络库
		WSACleanup();
		// 退出程序
		return;
	}
}
// 2.创建服务端socket
SOCKET CreateSocket()
{
	// 3.创建套接字 socket 创建服务端的socket
	SOCKET socketSer = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
	// 检验是否成功创建套接字
	if (INVALID_SOCKET == socketSer)
	{
		// 创建套接字失败
		// 获取错误码
		int error = WSAGetLastError();
		printf("创建套接字失败,返回错误号为:%d\n", error);
		// 关闭网络库
		WSACleanup();
		// 退出程序
		return 0;
	}
	return socketSer;
}
// 3.连接服务端
void ConnectToServer(SOCKET sock)
{
	// 4.连接到服务器
	// 创建connect() 函数中参数2所需的结构体 用来装IP地址和端口号
	sockaddr_in client_s;
	//给结构体成员赋值
	client_s.sin_family = AF_INET;		//IP地址类型
	client_s.sin_port = htons(12345);	//端口号
	client_s.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
	int nCon = connect(sock, (struct sockaddr*)&client_s, sizeof(client_s));
	// 检验是否成功连接到服务端
	if (SOCKET_ERROR == nCon)
	{
		// 连接服务端失败
		// 获取错误码
		int error = WSAGetLastError();
		printf("连接服务端失败,返回错误号为:%d\n", error);
		// 关闭套接字
		closesocket(sock);
		// 关闭网络库
		WSACleanup();
		// 退出程序
		return;
	}
}
// 4.与服务端收发消息
void RecvAndSend(SOCKET sock)
{
	// 只收一次 信息
	// 定义接收服务端信息的缓存区
	char recvBuf[1024] = { 0 };
	int res = recv(sock, recvBuf, 1024, 0);
	// 判断是否成功接收到信息
	if (0 == res)
	{
		printf("服务端下线了\n");
		return;
	}
	else if (SOCKET_ERROR == res)
	{
		// 获取错误码
		int error = WSAGetLastError();
		printf("连接服务端失败,返回错误号为:%d", error);
		printf("\n");
		return;
	}
	else
	{
		// 打印信息
		printf("服务端say: %s\n", recvBuf);
	}
	printf("我是客户端,正在发送信息,请稍等...\n");
	// 5.与服务端进行收发信息
	while (1)
	{
		char sendBuf[1024] = { 0 };
		scanf_s("%s", sendBuf, 1024);

		// 设置输入 0 时自动退出
		if ('0' == sendBuf[0])
		{
			break;	//退出循环
		}
		// 发信息
		send(sock, sendBuf, 1024, 0);
	}
}

int main()
{
	// 1.打开网络库并校验版本
	OpenAndCheckVersion();

	// 2.创建服务端socket
	SOCKET socketServer = CreateSocket();

	// 3.连接服务端
	ConnectToServer(socketServer);
	
	// 4.与服务端收发消息
	RecvAndSend(socketServer);

	// 关闭套接字
	closesocket(socketServer);
	// 关闭网络库
	WSACleanup();
	system("pause");
	return 0;
}

程序运行结果:

 

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值