zoukankan      html  css  js  c++  java
  • Linux 高性能服务器编程——Linux网络编程基础API

    问题聚焦:
        这节介绍的不仅是网络编程的几个API
        更重要的是,探讨了Linux网络编程基础API与内核中TCP/IP协议族之间的关系。
        这节主要介绍三个方面的内容:套接字(socket地址)API,socket基础API,和网络信息API。



    套接字API
    套接字socket:(ip, port),即IP地址和端口对,唯一地表示了使用该TCP通信的一端。

    需要了解:主机字节序和网络字节序。
    原因:考虑32位的机器,CPU的累加器一次装载4字节的内容。那么这4字节在内存中的顺序将影响它被累加器装载后的所代表的含义。
    分类:
    • 大端字节序:“高低,低高”,即,一个整数的高位字节(23~31位)存储在内存的低地址处,低位字节存储在内存的高地址处。
    • 小端字节序:“高高,低低”。
    主机字节序:小端字节序。
    网络字节序:大端字节序。
    存在的问题:并不是所有的机器都采用主机字节序。一旦收发双方的字节序不同,必然导致错误的接收。
    解决方法:发送端总是把要发送的数据转化成大端字节序,再发送。
    注意:即使同一台机器上的两个进程,也要考虑字节序的问题,如一个客户端使用C语言编写,而另一个采用java编写(java虚拟机使用大端字节序)。

    Linux提供的转换函数:
    #inlcude<netinet/in.h>
    unsigned long int htonl( unsigned long int hostlong );
    unsigned short int htons( unsigned short int hostshort );
    unsigned long int ntohl( unsigned long int netlong );
    unsigned short int ntohs( unsigned short int netshort );

    通用socket地址(结构体)
    #include <bits/socket.h>
    struct sockaddr
    {
        sa_family_t sa_family;
        char sa_data[14];
    }
    sa_family_da:地址族类型,与协议族类型对应。取值和对应关系如下图所示:

    sa_data(char):存放socket地址。
    协议族及其地址值:

    数组长度有限,所以Linux提供了下面的新的通用socket地址结构体。
    #include <bits/socket.h>
    struct sockaddr_storage
    {
        sa_family_t sa_family;
        unsigned long int __ssalign;
        char __ss_padding[128-sizeof(__ss__align)];
    }
    __ss__align成员的作用:内存对齐。

    专用socket地址
    既然有通用socket地址,那么自然就有专用的socket地址。
    专用:针对各个协议族的。
    分类:
    • UNIX本地域协议族专用socket地址
    #inlcude<sys/un.h>
    struct sockaddr_un
    {
        sa_family_t sin_family;            /* 地址族:AF_UNIX  */
        char sun_path[108];               /* 文件路径名 */
    };

    • IPv4专用socket地址
    #inlcude<sys/un.h>
    struct sockaddr_in
    {
        sa_family_t sin_family;            /* 地址族:AF_INET  */
        u_int16_t sin_port;
        struct int_addr sin_addr;               /* 文件路径名 */
    };
    struct in_addr
    {
        u_int32_t s_addr;
    };

    • IPv6专用socket地址
    #inlcude<sys/un.h>
    struct sockaddr_in6
    {
        sa_family_t sin6_family;                /* 地址族:AF_INET6*/
        u_int16_t sin_port;                        /* 端口号,要用网络字节序表示 */
        u_int32_t sin6_flowinfo;                /* 流信息,应设置为0  */
        struct int6_addr sin6_addr;           /* IPv6地址结构体 */
        u_int32_t sin6_scope_id;               / * scope Id.,尚处于试验阶段  */
    };
    struct in_addr
    {
        unsigned char sa_addr[16];     /* IPv6地址,要用网络字节序表示  */
    };

           虽然Linux给我们提供了专用的socket地址。但是,在实际使用时,都要强制转换为通用socket地址类型因为所有socket编程接口使用的地址参数的类型都是sockaddr。

    IP地址转换函数
    #include<arpa/inet.h>
    in_addr_t inet_addr( const char* strptr );        /* 点分十进制字符串表示的IPv4地址转化为用网络字节序整数表示的IPv4地址。失败时返回INADDR_NONE */
    int inet_aton( const char* cp, struct in_addr* inp );        /* 完成和in_addr_t同样的功能,但是将转化结果存储于参数inp指向的地址结构中。 成功时返回1, 失败时返回0 */
    char* inet_ntoa( struct in_addr in );        /* inet_ntoa函数将网络字节序整数表示的IPv4地址转化为用点分十进制字符串表示的IPv4地址 */

    有一点需要注意,看下面的代码
    char* szValue1 = inet_ntoa( "1.2.3.4" );
    char* szValue2 = inet_ntoa( "10.194.71.60" );
    printf( "address 1: %s
    ", szValue1 );
    printf( "address 2: %s
    ", szValue2 );
    
    //打印结果
    address1: 10.194.71.60
    address2: 10.194.71.60

    原因:inet_ntoa是不可重入的。因为该函数内部用一个固定的静态变量存储结果,函数的返回值指向该内存。每次调用,都是写入相同的静态内存区,所以不可以多次计算。

    同时适用于IPv4和IPv6地址的转换函数
    #include <arpa/inet.h>
    int inet_pton(int af, const char* src, void* dst);
    const char* inet_ntop( int af, const void* src, char* dst, socklen, cnt);

    函数说明:
    inet_pton函数将用字符串表示的IP地址stc(用点分十进制表示的IPv4地址或用十六进制字符串表示的IPv6地址)转换成用网络字节序整数表示的IP地址,并把转换结果可存储与dst指向的内存中。
    af: 指定地址族,可以是AF_INET或AF_INET6
    inet_pton成功返回1,失败返回0并设置errno

    inet_ntop函数进行相反的转换,前三个参数含义与inet_pton的参数相同,最后一个参数cnt指定目标存储单元的大小。
    下面的两个宏能帮助我们指定这个大小
    #include <netinet/in.h>
    #define INET_ADDRSTRLEN 16
    #define INET6_ADDRSTRLEN 46

    inet_ntop成功时返回目标存储单元的地址,失败则返回NULL并设置errno。 



    创建socket
    UNIX/Linux的一个哲学是:所有东西都是文件。socket也不例外,它就是可读、可写、可控制、可关闭的文件描述符。

    创建一个socket
    #include <sys/types.h>
    #include <sys/socket.h>
    int scoket( int domain, int type, int protocol );

    函数说明:
    domain: 告诉系统使用哪个底层协议。对TCP/IP 协议族而言,该参数应该设置为PF_INET(用于IPv4)或PF_INET6(用于IPv6); 对于UNIX本地域协议族而言,该参数应该设置为PF_UNIX 。 
    type: 指定服务类型。服务类型主要有SOCK_STREAM服务(流服务,使用TCP协议)和SOCK_UGRAM(数据报,使用UDP协议)服务。对于TCP/IP协议族而言,其值取SOCK_STREAM(TCP协议)、SOCK_DGRAM(UDP协议)。
             还可以接受两个标志:SOCK_NONBLOCK(将新创建的socket设为非阻塞)和SOCK_CLOEXEC(用fork调用创建子进程时在子进程中关闭该socket)
    protocol:在前两个参数构成的协议集合下,再选择一个具体的协议。几乎所有情况,把它设置为0.
    成功时返回socket文件描述符,失败则返回-1并设置errno。



    绑定socket
    绑定:将一个上面创建的socket与一个具体的socket地址绑定
    只有绑定了socket之后,客户端才能知道该如何连接它。
    调用:bind
    #include <sys/types.h>
    #include <sys/socket.h>
    int bind( int sockfd, const struct sockaddr* my_addr, socklen_t addrlen );

    函数说明:
    将my_addr所指的socket地址分配给未命名的sockfd文件描述符
    addrlen:指出该socket地址的长度
    成功时返回0,失败时返回-1并设置errno
    其中两种常见的errno是EACCES 和 EADDRINUSE,它们的含义分别是:
    • EACCES : 被绑定的地址是受保护的地址,仅超级用户能够访问。比如普通用户将socket绑定到知名服务器端口(端口号为 0~1023)上时,bind将返回EACCES 错误。
    • EADDRINUSE:被绑定的地址正在使用中。比如讲socket绑定到一个处于TIME_WAIT状态的socket地址。



    监听socket
    监听:socket绑定后依然不能马上接受客户连接,我们需要创建一个监听队列以存放待处理的客户连接。
    调用:listen
    #include <sys/socket.h>
    int listen( int sockfd, int backlog );
    函数说明:
    sockfd:被监听的socket
    backlog:提示内核监听队列的最大长度。监听队列的长度如果超过backlog,服务器将不受理新的客户连接,客户端也将收到ECONNREFUSED 错误信息。在内核版本2.2之前的linux中,backlog参数是指所有处于半连接状态(SYN_RCVD)和完全连接状态(ESTABLISHED)的socket的上限,在内核版本2.2之后,它表示处于完全连接状态的socket的上限,处于半连接状态的socket的上限则由/proc/sys/net/ipv4/tcp_max_syn_backlog 内核参数定义。backlog典型值是5,通常监听队列中完整连接的上限通常币backlog值略大。
    成功时返回0,失败时返回-1并设置errno。
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <signal.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <assert.h>
    #include <stdio.h>
    #include <string.h>
    
    static int stop = 0;
    static void handle_term( int sig )
    {
        stop = 1;
    }
    
    int main( int argc, char* argv[] )
    {
        signal( SIGTERM, handle_term );
    
        if( argc <= 3 )
        {
            printf( "usage: %s ip_address port_number backlog
    ", basename( argv[0] ) );
            return 1;
        }
        const char* ip = argv[1];
        int port = atoi( argv[2] );
        int backlog = atoi( argv[3] );
    
        int sock = socket( PF_INET, SOCK_STREAM, 0 );
        assert( sock >= 0 );
    
        struct sockaddr_in address;
        bzero( &address, sizeof( address ) );
        address.sin_family = AF_INET;
        inet_pton( AF_INET, ip, &address.sin_addr );
        address.sin_port = htons( port );
    
        int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
        assert( ret != -1 );
    
        ret = listen( sock, backlog );
        assert( ret != -1 );
    
        while ( ! stop )
        {
            sleep( 1 );
        }
    
        close( sock );
        return 0;
    }
    运行:
    #./telnetlisten 10.8.56.206 12345 5  //监听12345端口 backlog取值为5
    #telnet 10.8.56.206 12345 //运行10次
    #netstat -nt | grep 12345

              
    发现处于ESTABLISHED 状态的连接有6个(backlog加1 ),其它的4个连接处于SYN_RCVD 状态。 最终发现完全连接最多有(backlog +1 )个,在不同的系统下,运行结果可能有区别,不过监听队列中完全连接的上限通常比backlog值略大。



    接受连接
    调用:accept
    #include <sys/types.h>
    #include <sys/socket.h>
    int accept( int sockfd, struct sockaddr *addr, socklen_t *addrlen );

    作用:从listen监听队列中取出一个连接。服务器可以通过读写该socket来与被接受连接对应的客户端通信。
    函数说明:
    sockfd:处于监听状态的socket。
    addr:用来获取被接受连接的远端socket地址。
    addrlen:该socket地址的长度由addrlen指定。
    成功返回0,失败返回-1并设置errno。

    现在考虑如下情况:如果监听队列中处于ESTABLISHED 状态的连接对应的客户端出现网络异常(比如掉线),或者提前退出,那么服务器对这个连接执行的accept调用是否成功?
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <assert.h>
    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <errno.h>
    #include <string.h>
    
    int main( int argc, char* argv[] )
    {
        if( argc <= 2 )
        {
            printf( "usage: %s ip_address port_number
    ", basename( argv[0] ) );
            return 1;
        }
        const char* ip = argv[1];
        int port = atoi( argv[2] );
    
        struct sockaddr_in address;
        bzero( &address, sizeof( address ) );
        address.sin_family = AF_INET;
        inet_pton( AF_INET, ip, &address.sin_addr );
        address.sin_port = htons( port );
    
        int sock = socket( PF_INET, SOCK_STREAM, 0 );
        assert( sock >= 0 );
    
        int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
        assert( ret != -1 );
    
        ret = listen( sock, 5 );
        assert( ret != -1 );
    	// 暂停20s 以等待客户端连接和相关操作(掉线或者退出)完成
        sleep(20);
        struct sockaddr_in client;
        socklen_t client_addrlength = sizeof( client );
        int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
        if ( connfd < 0 )
        {
            printf( "errno is: %d
    ", errno );
        }
        else
        {
            char remote[INET_ADDRSTRLEN ];
            printf( "connected with ip: %s and port: %d
    ", 
                inet_ntop( AF_INET, &client.sin_addr, remote, INET_ADDRSTRLEN ), ntohs( client.sin_port ) );
            close( connfd );
        }
    
        close( sock );
        return 0;
    }
    具体操作如下:
    #./accept 10.8.56.206 12345
    #telnet 10.8.56.206 12345
    在启动telnet客户端程序后,在20s内关闭telnet 客户端。结果发现accept调用能够正常返回,服务器输出如下:
    [root@vm MOTO]# ./accept 10.8.56.206 12345
    connected with ip: 10.8.56.201 and port: 1313

    由此可见,accept 只是从监听队列中取出连接,而不论连接处于何种状态(如上图CLOSE_WAIT状态或者或者ESTABLISHED状态(断开telent客户端与服务器的网络,服务器该连接的状态)),更不关心任何网络状态的变化。




    发起连接
    调用:connect
    #include <sys/types.h>
    #include <sys/socket.h>
    int connect( int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen );

    函数说明:
    sockfd:由socket调用返回一个socket
    serv_addr:服务器监听的socket地址
    addrlen:指定这个地址的长度
    成功时返回0,失败则返回-并设置errno
    其中两种常见的errno是ECONNREFUSED和ETIMEDOUT,它们的含义如下:
    • ECONNREFUSED:目标端口不存在,连接被拒绝。
    • ETIMEDOUT:连接超时。



    关闭连接
    关闭:close
    #include <unist.h>
    int close ( int fd );

    函数说明:
    fd:待关闭的socket。
    注意:
    close系统调用并非立即关闭一个连接,而是将fd的引用计数减1,只有当fd的引用计数为0时,才真正关闭连接。
    多进程程序中,一次fork调用默认使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程两种都对该socket执行close调用才能将该连接关闭。

    为了避免上面的麻烦,对于无论如何都要立即关闭连接,可以使用下面的调用:、
    #include <sys/socket.h>
    int shutdown ( int sockfd, int howto );

    函数说明
    sockfd:等待关闭的socket
    howto:决定了shutdown的行为。可选值如下表:
        


    数据读写

    用于TCP流数据读写的调用:
    #include <sys/types.h>
    #include <sys/socket.h>
    ssize_t recv ( int sockfd, void *buf, size_t len, int flags );
    ssize_t send ( int sockfd, const void *buf, size_t len, int flags );

    函数说明:
    recv读取sockfd上的数据,buf和len参数分别指定读缓冲区的位置和大小,flags一般设置为0。返回0说明连接关闭,返回-1出错并设置errno。recv可能返回0,这意味着通信对方已经关闭连接了。
    send往sockfd上写入数据,buf和len参数分别指定写缓冲区的位置和大小。成功时返回实际写入的数据的长度,失败则返回-1并设置errno。
               
    MSG_OOB选项给应用程序提供了发送和接收带外数据的方法:
    发送方:
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <assert.h>
    #include <stdio.h>
    #include <unistd.h>
    #include <string.h>
    #include <stdlib.h>
    
    int main( int argc, char* argv[] )
    {
        if( argc <= 2 )
        {
            printf( "usage: %s ip_address port_number
    ", basename( argv[0] ) );
            return 1;
        }
        const char* ip = argv[1];
        int port = atoi( argv[2] );
    
        struct sockaddr_in server_address;
        bzero( &server_address, sizeof( server_address ) );
        server_address.sin_family = AF_INET;
        inet_pton( AF_INET, ip, &server_address.sin_addr );
        server_address.sin_port = htons( port );
    
        int sockfd = socket( PF_INET, SOCK_STREAM, 0 );
        assert( sockfd >= 0 );
        if ( connect( sockfd, ( struct sockaddr* )&server_address, sizeof( server_address ) ) < 0 )
        {
            printf( "connection failed
    " );
        }
        else
        {
            printf( "send oob data out
    " );
            const char* oob_data = "abc";
            const char* normal_data = "123";
            send( sockfd, normal_data, strlen( normal_data ), 0 );
            send( sockfd, oob_data, strlen( oob_data ), MSG_OOB );
            send( sockfd, normal_data, strlen( normal_data ), 0 );
        }
    
        close( sockfd );
        return 0;
    }

    接收方:
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include <assert.h>
    #include <stdio.h>
    #include <unistd.h>
    #include <stdlib.h>
    #include <errno.h>
    #include <string.h>
    
    #define BUF_SIZE 1024
    
    int main( int argc, char* argv[] )
    {
        if( argc <= 2 )
        {
            printf( "usage: %s ip_address port_number
    ", basename( argv[0] ) );
            return 1;
        }
        const char* ip = argv[1];
        int port = atoi( argv[2] );
    
        struct sockaddr_in address;
        bzero( &address, sizeof( address ) );
        address.sin_family = AF_INET;
        inet_pton( AF_INET, ip, &address.sin_addr );
        address.sin_port = htons( port );
    
        int sock = socket( PF_INET, SOCK_STREAM, 0 );
        assert( sock >= 0 );
    
        int ret = bind( sock, ( struct sockaddr* )&address, sizeof( address ) );
        assert( ret != -1 );
    
        ret = listen( sock, 5 );
        assert( ret != -1 );
    
        struct sockaddr_in client;
        socklen_t client_addrlength = sizeof( client );
        int connfd = accept( sock, ( struct sockaddr* )&client, &client_addrlength );
        if ( connfd < 0 )
        {
            printf( "errno is: %d
    ", errno );
        }
        else
        {
            char buffer[ BUF_SIZE ];
    
            memset( buffer, '', BUF_SIZE );
            ret = recv( connfd, buffer, BUF_SIZE-1, 0 );
            printf( "got %d bytes of normal data '%s'
    ", ret, buffer );
    
            memset( buffer, '', BUF_SIZE );
            ret = recv( connfd, buffer, BUF_SIZE-1, MSG_OOB );
            printf( "got %d bytes of oob data '%s'
    ", ret, buffer );
    
            memset( buffer, '', BUF_SIZE );
            ret = recv( connfd, buffer, BUF_SIZE-1, 0 );
            printf( "got %d bytes of normal data '%s'
    ", ret, buffer );
    
            close( connfd );
        }
    
        close( sock );
        return 0;
    }
    接收方两种结果:
    [root@vm MOTO]# ./recv 10.8.56.206 12345
    got 5 bytes of normal data '123ab'
    got 1 bytes of oob data 'c'
    got 3 bytes of normal data '123'

    由此可见客户端给服务器的3字节的带外数据“abc"中,仅有最后一个字符"c"被服务器当成了真正的带外数据接收,并且,服务器对正常数据的接收将被带外数据截断,即前一部分正常数据"123ab"和后续的正常数据"123"是不能被一个recv调用全部读取的。

    或者:
    [root@vm MOTO]# ./recv 10.8.56.206 12345
    got 3 bytes of normal data '123'
    got -1 bytes of oob data ''
    got 2 bytes of normal data 'ab'

    客户端发送的"123"到服务器的接收缓冲区,立马被服务器读走,然后客户端发送的带外数据"abc"到服务器的接收缓冲区,当服务器调用recv( connfd, buffer, BUF_SIZE-1, MSG_OOB );时失败,因为第一个字符不是带外数据,而是字符"a",真正的带外数据是"c"。服务器接着读走了"ab"字符后关闭了连接,但是发送的不是结束报文段,发送的是复位报文段,即如果recv( connfd, buffer, BUF_SIZE-1, MSG_OOB );失败时,最后发送的不是结束报文段,是复位报文段。

    接收方结果:
    [root@vm MOTO]# ./send 10.8.56.206 12345
    send oob data out

    在实际应用中,我们通常无法预期带外数据何时到来。好在Linux内核检测到TCP紧急标志时,将通知应用程序有带外数据需要接收。内核通知应用程序带外数据到达的两种方式是:
    • I/O复用产生的异常事件。
    • SIGURG信号。
    但是,即使应用程序得到了有带外数据需要接收的通知,还需要知道带外数据在数据流中的具体位置,才能准确接收带外数据。这点可通过如下系统调用实现:
           #include <sys/socket.h>
    
           int sockatmark(int fd);
    
    sockatmark判断sockfd是否处于带外标记,即下一个被读到的数据是否是带外数据,如果是,sockatmark返回1,此时我们就是利用带MSG_OOB标志的recv调用来接收带外数据。如果不是,则seckatmark返回0。


    用于UDP数据报的读写:
    #include <sys/types.h>
    #include <sys/socket.h>
    ssize_t recvfrom ( int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen );
    ssize_t sendto ( int sockfd, const void* buf, size_t len, int flags, const struct sockaddr* dest_addr, socklen_t addrlen );

    函数说明:
    recvfrom读取sockfd上的数据,buf和len参数分别指定读缓冲区的位置和大小。因为UDP通信没有连接的概念,所以我们每次读取数据都需要获取发送端的socket地址,即参数src_addr所指的内容,addrlen参数则指定该地址的长度。
    sendto往sockfd上写入数据,buf和len分别指定写缓冲区的位置和大小,dest_addr指定接收端的socket地址,addrlen指定该地址的长度。

    通用数据读写函数:
    #include <sys/socket.h>
    ssize_t recvmsg( int sockfd, struct msghdr* msg, int flags );
    ssize_t sendmsg( int sockfd, struct msghdr* msg, int flags );

    函数说明:
    sockfd:指定被操作的目标sockfd
    msg:msghdr结构体类型的指针,定义如下:
     struct msghdr
    {
        void* msg_name;                    /* socket地址 */
        socklen_t msg_namelen;        /* socktet地址的长度 */
        struct iovec* msg_iov;             /* 分散的内存块 */
        int msg_iovlen;                        /* 分散的内存块数量 */
        void msg_control;                   /* 指向辅助数据的起始位置 */
        socklen_t msg_controllen;      /* 辅助数据的大小 */
        int msg_flags;                          /* 复制函数中的flags参数,并在调用过程中更新 */
    };
    
    struct iovec
    {
        void *iov_base;    /* 内存起始地址 */
        size_t iov_len;       /* 这块内存的长度 */
    };
           由上可见,iovec结构体封装了一块内存的起始位置和长度。msg_iovlen指定这样的iovec结构对象有多少个。对于recvmsg而言,数据将被读取并存放在msg_iovlen块分散的内存中,这些内存的位置和长度则由msg_iov指向的数组指定,这称为分散读;对于sendmsg而言,msg_iovlen块分散内存中的数据将被一并发送,这称为集中写
           recvmsg/sendmsg 的flags参数以及返回值的含义均与send/recv的flags参数及返回值相同。 


    地址信息函数
    功能:获取连接的本端socket地址,以及远端的socket地址。
    函数:
    #include <sys/socket.h>
    int getsockname ( int sockfd, struct sockaddr* address, socklen_t* address_len );    /* 获取sockfd本端socket地址,并将其存储于address参数指定的内存中 */
    int getpeername ( int sockfd, struct sockaddr* address, socklen_t* address_len );    /* 获取sockfd对应的远端socket地址,其参数及返回值的含义与getsockname的参数及返回值相同 */
    getsockname 获取sockfd对应的本端socket地址。
    getpeername 获取sockfd对应的远端socket地址。


    参考资料:
    《Linux高性能服务器编程》
  • 相关阅读:
    Binary Tree Inorder Traversal
    Populating Next Right Pointers in Each Node
    Minimum Depth of Binary Tree
    Majority Element
    Excel Sheet Column Number
    Reverse Bits
    Happy Number
    House Robber
    Remove Linked List Elements
    Contains Duplicate
  • 原文地址:https://www.cnblogs.com/wangfengju/p/6172421.html
Copyright © 2011-2022 走看看