• c++ 网络编程(十) LINUX/windows 异步通知I/O模型与重叠I/O模型 附带示例代码


    原文作者:aircraft

    原文链接:https://www.cnblogs.com/DOMLX/p/9662931.html

    一.异步IO模型(asynchronous IO)

    (1)什么是异步I/O

    异步I/O(asynchronous I/O)由POSIX规范定义。演变成当前POSIX规范的各种早起标准所定义的实时函数中存在的差异已经取得一致。一般地说,这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作(包括将数据从内核复制到我们自己的缓冲区)完成后通知我们。这种模型与前一节介绍的信号驱动模型的主要区别在于:信号驱动式I/O是由内核通知我们何时可以启动一个I/O操作,而异步I/O模型是由内核通知我们I/O操作何时完成。

    示意图如下:

    我们调用aio_read函数(POSIX异步I/O函数以aio_或lio_开头),给内核传递描述符、缓冲区指针、缓冲区大小(与read相同的三个参数)和文件偏移(与lseek类似),并告诉内核当整个操作完成时如何通知我们。该系统调用立即返回,并且在等待I/O完成期间,我们的进程不被阻塞。本例子中我们假设要求内核在操作完成时产生某个信号,该信号直到数据已复制到应用进程缓冲区才产生,这一点不同于信号驱动I/O模型。

    (2)运用到的函数讲解--WSAEventSelect模型

    在WSAEventSelect模型中,基本流程如下:
     1. 创建一个事件对象数组,用于存放所有的事件对象;
     2. 创建一个事件对象(WSACreateEvent);
     3. 将一组你感兴趣的SOCKET事件与事件对象关联(WSAEventSelect),然后加入事件对象数组;
     4. 等待事件对象数组上发生一个你感兴趣的网络事件(WSAWaitForMultipleEvents);
     5. 对发生事件的事件对象查询具体发生的事件类型(WSAEnumNetworkEvents);
     6. 针对不同的事件类型进行不同的处理;
     7. 循环进行

      函数过程:

    1. 初始化网络环境,创建一个监听的socket,然后进行connect操作。接下来WSACreateEvent()创建一个网络事件对象,其声明如下:
      WSAEVENT WSACreateEvent(void); //返回一个手工重置的事件对象句柄
    2. 再调用WSAEventSelect,来将监听的socket与该事件进行一个关联,其声明如下:
      int WSAEventSelect(    
        SOCKET s,                 //套接字  
        WSAEVENT hEventObject,    //网络事件对象  
        long lNetworkEvents       //需要关注的事件  
      ); 

      我们客户端只关心FD_READ和FD_CLOSE操作,所以第三个参数传FD_READ | FD_CLOSE。

    3. 启动一个线程调用WSAWaitForMultipleEvents等待1中的event事件,其声明如下:
      复制代码
      DWORD WSAWaitForMultipleEvents(    
        DWORD cEvents,                  //指定了事件对象数组里边的个数,最大值为64  
        const WSAEVENT FAR *lphEvents,  //事件对象数组  
        BOOL fWaitAll,                  //等待类型,TRUE表示要数组里全部有信号才返回,FALSE表示至少有一个就返回,这里必须为FALSE  
        DWORD dwTimeout,                //等待的超时时间  
        BOOL fAlertable                 //当系统的执行队列有I/O例程要执行时,是否返回,TRUE执行例程返回,FALSE不返回不执行,这里为FALSE  
      );  
      复制代码

      由于我们是客户端,所以只等待一个事件。

    4. 当事件发生,我们需要调用WSAEnumNetworkEvents,来检测指定的socket上的网络事件。其声明如下:
      int WSAEnumNetworkEvents  
      (    
        SOCKET s,                             //指定的socket  
        WSAEVENT hEventObject,                //事件对象  
        LPWSANETWORKEVENTS lpNetworkEvents    //WSANETWORKEVENTS<span style="font-family:Arial, Helvetica, sans-serif;">结构地址</span>  
      );  

      当我们调用这个函数成功后,它会将我们指定的socket和事件对象所关联的网络事件的信息保存到WSANETWORKEVENTS这个结构体里边去,我们来看下这个结构体的声明:

      typedef struct _WSANETWORKEVENTS {  
        long     lNetworkEvents;<span style="white-space:pre">          </span>//指定了哪个已经发生的网络事件  
        int      iErrorCodes[FD_MAX_EVENTS];<span style="white-space:pre">      </span>//错误码  
      } WSANETWORKEVENTS, *LPWSANETWORKEVENTS;  

      根据这个结构体我们就可以判断是否是我们所关注的网络事件已经发生了。如果是我们的读的网络事件发生了,那么我们就调用recv函数进行操作。若是关闭的事件发生了,就调用closesocket将socket关掉,在数组里将其置零等操作。

      整个模型的流程图如下:

     (3)实现服务端代码:

    #include <WinSock2.h>
    #include <process.h>
    #include <stdio.h>
    #pragma comment(lib,"ws2_32.lib")
    
    SOCKET g_sClient[WSA_MAXIMUM_WAIT_EVENTS] = {INVALID_SOCKET};  //client socket数组
    WSAEVENT g_event[WSA_MAXIMUM_WAIT_EVENTS];                     //网络事件对象数组
    SOCKET g_sServer = INVALID_SOCKET;                             //server socket 
    WSAEVENT g_hServerEvent;                                       //server 网络事件对象
    int iTotal = 0;                                                //client个数
    /*
    @function OpenTCPServer             打开TCP服务器
    @param _In_ unsigned short Port     服务器端口
    @param  _Out_ DWORD* dwError        错误代码
    @return  成功返回TRUE 失败返回FALSE
    */
    BOOL OpenTCPServer( _In_ unsigned short Port, _Out_ DWORD* dwError)
    {
        BOOL bRet = FALSE;
        WSADATA wsaData = { 0 };
        SOCKADDR_IN ServerAddr = { 0 };
        ServerAddr.sin_family = AF_INET;
        ServerAddr.sin_port = htons(Port);
        ServerAddr.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
        do
        {
            if (!WSAStartup(MAKEWORD(2, 2), &wsaData))
            {
                if (LOBYTE(wsaData.wVersion) == 2 || HIBYTE(wsaData.wVersion) == 2)
                {
                    g_sServer = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
                    g_hServerEvent = WSACreateEvent();                    //创建网络事件对象
                    WSAEventSelect(g_sServer, g_hServerEvent, FD_ACCEPT);//为server socket注册网络事件 
                    if (g_sServer != INVALID_SOCKET)
                    {
                        if (SOCKET_ERROR != bind(g_sServer, (SOCKADDR*)&ServerAddr, sizeof(ServerAddr)))
                        {
                            if (SOCKET_ERROR != listen(g_sServer, SOMAXCONN))
                            {
                                bRet = TRUE;
                                break;
                            }
                            *dwError = WSAGetLastError();
                            closesocket(g_sServer);
                        }
                        *dwError = WSAGetLastError();
                        closesocket(g_sServer);
                    }
                    *dwError = WSAGetLastError();
                }
                *dwError = WSAGetLastError();
    
            }
            *dwError = WSAGetLastError();
        } while (FALSE);
        return bRet;
    }
    
    //接受client请求线程
    unsigned int __stdcall ThreadAccept(void* lparam)
    {
        WSANETWORKEVENTS networkEvents; //网络事件结构
        while (iTotal < WSA_MAXIMUM_WAIT_EVENTS)  //这个值是64
        {
            if (0 == WSAEnumNetworkEvents(g_sServer, g_hServerEvent, &networkEvents))
            {
                if (networkEvents.lNetworkEvents & FD_ACCEPT) //如果等于FD_ACCEPT,相与就为1
                {
                    if (0 == networkEvents.iErrorCode[FD_ACCEPT_BIT])  //检查有无网络错误
                    {
                        //接受请求
                        SOCKADDR_IN addrServer = { 0 };
                        int iaddrLen = sizeof(addrServer);
                        g_sClient[iTotal] = accept(g_sServer, (SOCKADDR*)&addrServer, &iaddrLen);
                        if (g_sClient[iTotal] == INVALID_SOCKET)
                        {
                            printf("accept failed with error code: %d
    ", WSAGetLastError());
                            return 1;
                        }
                        //为新的client注册网络事件
                        g_event[iTotal] = WSACreateEvent();
                        WSAEventSelect(g_sClient[iTotal], g_event[iTotal], FD_READ | FD_WRITE | FD_CLOSE);
                        iTotal++;
                        printf("accept a connection from IP: %s,Port: %d
    ", inet_ntoa(addrServer.sin_addr), htons(addrServer.sin_port));
                    }
                    else  //错误处理
                    {
                        int iError = networkEvents.iErrorCode[FD_ACCEPT_BIT];
                        printf("WSAEnumNetworkEvents failed with error code: %d
    ", iError);
                        return 1;
                    }
                }
            }
            Sleep(100);
        }
        return 0;
    }
    
    //接收数据
    unsigned int __stdcall ThreadRecv(void* lparam)
    {
        char* buf = (char*)malloc(sizeof(char) * 128);
        while (1)
        {
            if (iTotal == 0)
            {
                Sleep(100);
                continue;
            }
            //等待网络事件
            DWORD dwIndex = WSAWaitForMultipleEvents(iTotal, g_event, FALSE, 1000, FALSE); 
            //当前的事件对象
            WSAEVENT curEvent = g_event[dwIndex];
            //当前的套接字
            SOCKET sCur = g_sClient[dwIndex];
            //网络事件结构
            WSANETWORKEVENTS networkEvents;
            if (0 == WSAEnumNetworkEvents(sCur, curEvent, &networkEvents))
            {
                if (networkEvents.lNetworkEvents & FD_READ)  //有数据可读
                {
                    if (0 == networkEvents.iErrorCode[FD_READ_BIT])
                    {
                        memset(buf, 0, sizeof(buf));
                        int iRet = recv(sCur, buf, sizeof(buf), 0);  //接收数据
                        if (iRet != SOCKET_ERROR)
                        {
                            if (strlen(buf) != 0)
                                printf("Recv: %s
    ", buf);
                        }
                    }
                    else //错误处理
                    {
                        int iError = networkEvents.iErrorCode[FD_ACCEPT_BIT];
                        printf("WSAEnumNetworkEvents failed with error code: %d
    ", iError);
                        break;
                    }
                }
                else if (networkEvents.lNetworkEvents & FD_CLOSE)  //client关闭
                    printf("%d downline
    ", sCur);
            }
            Sleep(100);
        }
        if (buf)
            free(buf);
        return 0;
    }
    
    int main()
    {
        DWORD dwError = 0;
        if (OpenTCPServer(18000, &dwError))
        {
            _beginthreadex(NULL, 0, ThreadAccept, NULL, 0, NULL);
            _beginthreadex(NULL, 0, ThreadRecv, NULL, 0, NULL);
        }
        Sleep(100000000);
        closesocket(g_sServer);
        WSACleanup();
        return 0;
    }

    二.重叠IO模型

    1-重叠模型的优点

    1可以运行在支持Winsock2的所有Windows平台,而不像完成端口只支持NT系统

    2比起阻塞,select,WSAAsyncSelect以及WSAEventSelect等模型,重叠I/O(Overlapped I/O)模型使应用程序能达到更加系统性能

    因为他和其他4种模型不同的是,使用重叠模型的应用程序通知缓冲区收发系统直接使用数据,也就是说,如果应用程序

    投递了一个10kb大小的缓冲区来接收数据,而数据已经到达套接字,则将该数据直接拷贝到投递的缓冲区,

    而4种模型中,数据达到并拷贝到单套接字接收缓冲区,此时应用程序会被告知可以读入的容量,当应用程序调用

    接收函数之后,数据才从单套接字缓冲区拷贝应用程序到缓冲区,差别就体现了。

     

    2-重叠模型的基本原理

    重叠模型是让应用程序使用重叠数据结构(WSAOVERLAPPED),一次投递一个或多个Winsock I/O请求,针对这些提交的

    请求,在他们完成之后,应用程序会收到通知,于是就可通过自己的代码来处理这些数据了。

    使用事件通知的方法来实现重叠IO模型,基于事件的话,就要求将Win事件与WSAOVERLAPPED结构关联在一起,

    使用重叠结构,常用的send,sendto,recv,recvform也被WSASend,WSARecv等替换掉,

    OVERLAPPER SOCKET(重叠Socket)上进行重叠发送的操作,(简单的理解就是异步send,recv)

    他们的参数中都有一个Overlapped参数,就是说所有的重叠Socket都要绑定到这个重叠结构体上,

    提交一个请求,其他的事情就交给重叠结构去操心, 而其中重叠结构要与Windows事件绑定在一起, 

    在样,我们调用完WSARecv后.等重叠操作完成,就会有对应的事件来同意我们操作完成,

    3-重叠模型的函数详解

    (1)创建套接字

         要使用重叠I/O模型,在创建套接字时,必须使用WSASocket函数,设置重叠标志。

      

    The WSASocket function creates a socket that is bound to a specific transport-service provider.

    SOCKET WSASocket(
      __in          int af,
      __in          int type,
      __in          int protocol,//前三个参数与socket函数相同
      __in          LPWSAPROTOCOL_INFO lpProtocolInfo,  //指定下层服务提供者   ,可以是NULL
      __in          GROUP g,    //保留
      __in          DWORD dwFlags    //指定套接字属性。要使用重叠I/O模型,必须指定WSA_FLAG_OVERLAPPED
    );

    由于要用到重叠模型来提交我们的操作,所以原来的recv、send、sendto、recvfrom等函数都要被替换为WSARecv、WSASend、WSASendto、WSARecvFrom函数来代替。

    (2)传输数据

         在重叠I/O模型中,传输数据的函数是WSASendWSARecv(TCP)和WSASendTo、WSARecvFrom等,下面是WSASend的定义:

        

    The WSASend function sends data on a connected socket.

    int WSASend(
      __in          SOCKET s,
      __in          LPWSABUF lpBuffers,
      __in          DWORD dwBufferCount,
      __out         LPDWORD lpNumberOfBytesSent,
      __in          DWORD dwFlags,
      __in          LPWSAOVERLAPPED lpOverlapped,
      __in          LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine
    );

    参数

    s:标识一个已连接套接口的描述字。
    lpBuffers:一个指向WSABUF结构数组指针。每个WSABUF结构包含缓冲区的指针和缓冲区的大小。
    dwBufferCount:lpBuffers数组中WSABUF结构的数目。
    lpNumberOfBytesSent:如果发送操作立即完成,则为一个指向所发送数据字节数的指针。
    dwFlags:标志位。
    lpOverlapped:指向WSAOVERLAPPED结构的指针(对于非重叠套接口则忽略)。
    lpCompletionRoutine:一个指向发送操作完成后调用的完成例程的指针。(对于非重叠套接口则忽略)
     

    返回值

    若无错误发生且发送操作立即完成,则WSASend()函数返回0。这时,完成例程(Completion Routine)应该已经被调度,一旦调用线程处于alertable状态时就会调用它。否则,返回SOCKET_ERROR 。通过WSAGetLastError获得详细的错误代码。WSA_IO_PENDING 这个错误码(其实表示没有错误)表示重叠操作已经提交成功(就是异步IO的意思了),稍后会提示完成(这个完成可不一定是发送成功,没准出问题也不一定)。其他的错误代码都代表重叠操作没有正确开始,也不会有完成标志出现。
    
    

       可以异步接收连接请求的函数是AcceptEX。这是一个Mincrosoft扩展函数,它接受一个新的连接,返回本地和远程地址,取得客户程序发送的第一块数据,函数定义如下:

    
    

    The AcceptEx function accepts a new connection, returns the local and remote address, and receives the first block of data sent by the client application.

    
    

    Note  This function is a Microsoft-specific extension to the Windows Sockets specification.

    
    
    BOOL AcceptEx(
      __in          SOCKET sListenSocket,
      __in          SOCKET sAcceptSocket,
      __in          PVOID lpOutputBuffer,
      __in          DWORD dwReceiveDataLength,
      __in          DWORD dwLocalAddressLength,
      __in          DWORD dwRemoteAddressLength,
      __out         LPDWORD lpdwBytesReceived,
      __in          LPOVERLAPPED lpOverlapped
    );
    


    参数

    sListenSocket
    [in]侦听套接字。服务器应用程序在这个套接字上等待连接。
    sAcceptSocket
    [in]将用于连接的套接字。此套接字必须不能已经绑定或者已经连接。
    lpOutputBuffer
    [in]指向一个缓冲区,该缓冲区用于接收新建连接的所发送数据的第一个块、该服务器的本地地址和客户端的远程地址。接收到的数据将被写入到缓冲区0偏移处,而地址随后写入。 该参数必须指定,如果此参数设置为NULL,将不会得到执行,也无法通过GetAcceptExSockaddrs函数获得本地或远程的地址。
    dwReceiveDataLength
    [in]lpOutputBuffer字节数,指定接收数据缓冲区lpOutputBuffer的大小。这一大小应不包括服务器的本地地址的大小或客户端的远程地址,他们被追加到输出缓冲区。如果dwReceiveDataLength是零,AcceptEx将不等待接收任何数据,而是尽快建立连接。
    dwLocalAddressLength
    [in]为本地地址信息保留的字节数。此值必须比所用传输协议的最大地址大小长16个字节。
    dwRemoteAddressLength
    [in]为远程地址的信息保留的字节数。此值必须比所用传输协议的最大地址大小长16个字节。 该值不能为0。
    dwBytesReceived
    [out]指向一个DWORD用于标识接收到的字节数。此参数只有在同步模式下有意义。如果函数返回ERROR_IO_PENDING并在迟些时候完成操作,那么这个DWORD没有意义,这时你必须获得从完成通知机制中读取操作字节数。
    lpOverlapped
    [in]一个OVERLAPPED结构,用于处理请求。此参数必须指定,它不能为空。
    返回值
    如果没有错误发生,AcceptEx函数成功完成并返回TRUE。 [1] 
    如果函数失败,AcceptEx返回FALSE。可以调用WSAGetLastError函数获得扩展的错误信息。如果WSAGetLastError返回ERROR_IO_PENDING,那么这次行动成功启动并仍在进行中。

    AcceptEX函数将几个套接字函数的功能集合在一起。如果它投递的请求成功完成,则执行了如下3个操作:

    (1)接受了新的连接

    (2)新连接的本地地址和远程地址都会返回

    (3)接收到了远程主机发来的第一块数据

    AcceptEX和大家熟悉的accept函数有很大的不同就是AcceptEX函数需要调用者提供两个套接字,一个指定了在哪个套接字上监听,另一个指定了在哪个套接字上接受连接,也就是说,AcceptEX不会像accept函数一样为新的连接创建套接字。

       如果提供了新的缓冲区,AcceptEX投递的重叠操作直到接受到连接并且读到数据之后才会返回。以SO_CONNECT_TIME为参数调用getsockopt函数可以检查到是否接受了连接,如果接受了连接,这个调用还可以取得连接已经建立了多长时间。

      AcceptEX函数是从Mswsock.lib库中导出的,为了能够直接调用它,而不用链接到Mswsock.lib库,需要使用WSAIoctl函数将AcceptEX函数加载到内存,WSAIoctl函数是ioctlsocket函数的扩展,它可以使用重叠I/O。函数的第3个到第6个参数是输入和输出缓冲区,在这里传递AcceptEX函数的指针


    (4)接收传输结果

    当重叠I/O请求最终完成以后,以之关联的事件对象受信,等待函数返回,应用程序可以使用WSAGetOverlappedResult函数取得重叠操作的结果,函数用法如下:

    The WSAGetOverlappedResult function retrieves the results of an overlapped operation on the specified socket.

    BOOL WSAAPI WSAGetOverlappedResult(
      __in          SOCKET s,
      __in          LPWSAOVERLAPPED lpOverlapped,
      __out         LPDWORD lpcbTransfer,
      __in          BOOL fWait,
      __out         LPDWORD lpdwFlags
    );
    
    参数:
    s:标识套接口。这就是调用重叠操作(WSARecv()WSARecvFrom()、WSASend()、WSASendTo() 或 WSAIoctl())时指定的那个套接口
    lpOverlapped:指向调用重叠操作时指定的WSAOVERLAPPED结构。
    lpcbTransfer:指向一个32位变量,该变量用于存放一个发送或接收操作实际传送的字节数,或WSAIoctl()传送的字节数。
    fWait:指定函数是否等待挂起的重叠操作结束。若为真TRUE则函数在操作完成后才返回。若为假FALSE且函数挂起,则函数返回FALSE,WSAGetLastError()函数返回 WSA_IO_INCOMPLETE。
    lpdwFlags:指向一个32位变量,该变量存放完成状态的附加标志位。如果重叠操作为 WSARecv()或WSARecvFrom(),则本参数包含lpFlags参数所需的结果。
    返回值:
    如果函数成功,则返回值为真TRUE。它意味着重叠操作已经完成,lpcbTransfer所指向的值已经被刷新。应用程序可调用WSAGetLastError()来获取重叠操作的错误信息
    如果函数失败,则返回值为假FALSE。它意味着要么重叠操作未完成,要么由于一个或多个参数的错误导致无法决定完成状态。失败时,lpcbTransfer指向的值不会被刷新。应用程序可用WSAGetLastError()来获取失败的原因。

    4-重叠模型的实例代码:
    //完成例程实现重叠io模型伪代码
    SOCKET acceptSock;
    WSABUF dataBuf;
    
    void main()
    {
        WSAOVERLAPPED overlapped;
        //1.初始化
        //...
    
        //2.接收连接请求
        acceptSock=accept(listenSock,NULL,NULL);
    
        //3.初始化重叠结构
        UINT flag=0;
        ZeroMemory(&overlapped,sizeof(WSAOVERLAPPED));
        dataBuf.len=DATA_BUFSIZE;
        dataBuf.buf=buf;
    
        if (WSARecv(acceptSock,&dataBuf,1,&recvBytes,&flag,&overlapped,workroutine)==SOCKET_ERROR)//最后一个参数时回调函数地址
        {
            if(WSAGetLastError()!=WSA_IO_PENDING)
            {
                printf("WSARecv() failed with error %d
    ",WSAGetLastError());
                return;
            }
        }
        
        //创建事件
        eventArray[0]=WSACreateEvent();
        while (true)
        {
            int index=WSAWaitForMultipleEvents(1,eventArray,FALSE,WSA_INFINITE,TRUE);//最后一个参数最好为true
            if (index==WAIT_IO_COMPLETION)//io请求完成
            {
                break;
            }
            else//io请求出错
            {
                return;
            }
        }
        //调用回调函开始进行处理
    }
    
    void CALLBACK WorkRoutine(DWORD error,DWORD bytesTransferred,LPWSAOVERLAPPED overlapped,DWORD inflag)
    {
        DWORD sendBytes,recvBytes;
        DWORD flags;
    
        if(error!=0||bytesTransferred==0)
        {
            closesocket(acceptSock);
            return;
        }
    
        flags=0;
    
        ZeroMemory(&overlapped,sizeof(WSAOVERLAPPED));
        dataBuf.len=DATA_BUFSIZE;
        dataBuf.data=buf;
    
        if (WSARecv(acceptSock,&dataBuf,1,&recvBytes,&flag,&overlapped,workroutine)==SOCKET_ERROR)//最后一个参数时回调函数地址
        {
            if(WSAGetLastError()!=WSA_IO_PENDING)
            {
                printf("WSARecv() failed with error %d
    ",WSAGetLastError());
                return;
            }
        }
    }
     最后说一句啦。本网络编程入门系列博客是连载学习的,有兴趣的可以看我博客其他篇。。。。c++ 网络编程课设入门超详细教程 ---目录


    参考博客:https://www.cnblogs.com/Dreamcaihao/archive/2012/11/14/2770293.html
    参考博客:https://www.cnblogs.com/tanguoying/p/8506821.html
    参考博客:https://blog.csdn.net/wxf2012301351/article/details/73332588
    参考书籍:《TCP/IP网络编程 ---尹圣雨》

    若有兴趣交流分享技术,可关注本人公众号,里面会不定期的分享各种编程教程,和共享源码,诸如研究分享关于c/c++,python,前端,后端,opencv,halcon,opengl,机器学习深度学习之类有关于基础编程,图像处理和机器视觉开发的知识

  • 相关阅读:
    JSP中page和pageContext的区别
    exe4j生成的exe文件没有把jre文件也打进exe文件中
    exe4j将jar文件和jre文件打包成exe
    exe4j打包jar文件为exe文件出现的问题:The JAVA_HOME environment variable does not point to a working 32-bit JDK or JRE.
    JS作用域
    js 函数
    js数组
    js表达式和语句
    js操作符
    js注释和数据类型转换
  • 原文地址:https://www.cnblogs.com/DOMLX/p/9662931.html
Copyright © 2020-2023  润新知