• 网络IPC:套接字之数据传输


    既然将套接字端点表示为文件描述符,那么只要建立连接,就可以使用read和write来通过套接字通信。通过在connect函数里设置对方地址,数据报套接字也可以“连接”。在套接字描述符上采用read和write是非常有意义的,因为可以传递套接字描述符到那些原先设计为处理本地文件的函数。而且可以安排传递套接字描述符到执行程序的子进程,该子进程并不了解套接字。

    尽管可以通过read和write交换数据,但这就是这两个函数所能做的一切。如果想指定选项、从多个客户端接收数据包或者发送带外数据,需要采用6个传递数据的套接字函数中的一个。

    三个函数用来发送数据,三个用来接收数据。首先,考察用于发送数据的函数

    最简单的是send,它和write很像,但是可以指定标志来改变处理传输数据的方式。

    #include <sys/socket.h>
    ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);
    返回值:若成功则返回发送的字节数,若出错则返回-1

    类似write,使用send时套接字必须已经连接。参数buf和nbytes与write中的含义一致。

    然而,与write不同的是,send支持第四个参数flags。其中3个标志是Single UNIX Specification规定的,但是其他标志通常实现也支持。表16-7总结了这些标志。

                                                                       表16-7 send套接字调用标志

    未命名

    如果send成功返回,并不必然表示连接另一端的进程接收数据。所保证的仅是当send成功返回时,数据已经无错误地发送到网络上。

    对于支持为报文设限的协议,如果单个报文超过协议所支持的最大尺寸,send失败并将errno设置为EMSGSIZE;对于字节流协议,send会阻塞直到整个数据被传输

    函数sendto和send很类似。区别在于sendto允许在无连接的套接字上指定一个目标地址。

    #include <sys/socket.h>
    ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags,
                   const struct sockaddr *destaddr, socklen_t destlen);
    返回值:若成功则返回发送的字节数,若出错则返回-1

    对于面向连接的套接字,目标地址是忽略的,因为目标地址蕴含在连接中。对于无连接的套接字,不能使用send,除非在调用connect时预先设定了目标地址,或者采用sendto来提供另外一种发送报文方式。

    可以使用不止一个的选择来通过套接字发送数据。可以调用带有msghdr结构的sendmsg来指定多重缓冲区传输数据,这和writev很相像(http://www.cnblogs.com/nufangrensheng/p/3559304.html)。

    #include <sys/socket.h>
    ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
    返回值:若成功则返回发送的字节数,出错则返回-1

    POSIX.1定义了msghdr结构,它至少应该有如下成员:

    struct msghdr {
        void            *msg_name;         /* optional address */
        socklen_t        msg_namelen;      /* address size in bytes */
        struct iovec    *msg_iov;          /* array of I/O buffers */
        int              msg_iovlen;       /* number of elements in array */
        void            *msg_control;      /* ancillary data */
        socklen_t        msg_controllen;   /* number of ancillary bytes */
        int              msg_flags;        /* flags for received message */
        ...
    };

    iovec结构可参考http://www.cnblogs.com/nufangrensheng/p/3559304.html

    下面是用于接收数据的函数。

    函数recv和read很像,但是允许指定选项来控制如何接收数据。

    #include <sys/socket.h>
    ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags);
    返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,出错则返回-1

    表16-8总结了flags标志。其中只有三个标志是Single UNIX Specification规定的。

                                                                       表16-8 recv套接字调用标志

    未命名

    当指定MSG_PEEK标志时,可以查看下一个要读的数据但不会真正取走。当再次调用read或recv函数时会返回刚才查看的数据。

    对于SOCK_STREAM套接字,接收的数据可以比请求少。标志MSG_WAITALL阻止这种行为,除非所需数据全部收到,recv才会返回。对于SOCK_DGRAM和SOCK_SEQPACKET套接字,MSG_WAITALL标志没有改变什么行为,因为这些基于报文的套接字类型一次读取就返回整个报文。

    如果发送者已经调用shutdown(http://www.cnblogs.com/nufangrensheng/p/3564695.html)来结束传输,或者网络协议支持默认的顺序关闭并且发送端已经关闭,那么当所有数据接收完毕后,recv返回0。

    如果有兴趣定位发送者,可以使用recvfrom来得到数据发送者的源地址。

    #include <sys/socket.h>
    ssize_t recvfrom(int sockfd, void *restrict buf, size_t len, int flags,
                     struct sockaddr *restrict addr,
                     socklen_t *restrict addrlen);
    返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,若出错则返回-1

    如果addr非空,它将包含数据发送者的套接字端点地址。当调用recvfrom时,需要设置addrlen参数指向一个包含addr所指的套接字缓冲区字节大小的整数。返回时,该整数设为该地址的实际大小。

    因为可以获得发送者的实际地址,recvfrom通常用于无连接套接字。否则recvfrom等同于recv。

    为了将接收到的数据送入多个缓冲区(类似于readv(http://www.cnblogs.com/nufangrensheng/p/3559304.html)),或者想接收辅助数据,可以使用recvmsg

    #include <sys/socket.h>
    ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
    返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,若出错则返回-1

    结构msghdr(在sendmsg中见过)被recvmsg用于指定接收数据的输入缓冲区。可以设置参数flags来改变recvmsg的默认行为。返回时,msghdr结构中的msg_flags字段被设为所接收数据的各种特征(进入recvmsg时msg_flags被忽略)。从recvmsg中返回的各种可能值总结在表16-9中。

                                                   表16-9 从recvmsg中返回的msg_flags标志

    未命名

    实例:面向连接的客户端

    程序清单16-4显示了一个客户端命令,该命令用于与服务器通信以获得系统命令uptime的输出。该服务成为“remote uptime”(简称为“ruptime”)。

    程序清单16-4 用于获取服务器uptime的客户端命令

    #include "apue.h"
    #include <netdb.h>
    #include <errno.h>
    #include <sys/socket.h>
    
    #define MAXADDRLEN    256
    
    #define    BUFLEN        128    
    
    extern int connect_retry(int, const struct sockaddr *, socklen_t);
    
    void 
    print_uptime(int sockfd)
    {
        int    n;
        char     buf[BUFLEN];
        
        while(( n = recv(sockfd, buf, BUFLEN, 0)) > 0)
            write(STDOUT_FILENO, buf, n);
        if(n < 0)
            err_sys("recv error");
    }
    
    int main(int argc, char *argv[])
    {
        struct addrinfo *ailist, *aip;
        struct addrinfo hint;
        int        sockfd, err;
    
        if(argc != 2)
            err_quit("usage: ruptime hostname");
        hint.ai_flags = 0;
        hint.ai_family = 0;
        hint.ai_socktype = SOCK_STREAM;
        hint.ai_protocol = 0;
        hint.ai_addrlen = 0;
        hint.ai_canonname = NULL;
        hint.ai_addr = NULL;
        hint.ai_next = NULL;
        if((err = getaddrinfo(argv[1], "ruptime", &hint, &ailist)) != 0)
            err_quit("getaddrinfo error: %s", gai_strerror(err));
        for(aip = ailist; aip != NULL; aip = aip->ai_next)
        {
            if((sockfd = socket(aip->ai_family, SOCK_STREAM, 0)) < 0)
                err = errno;
            if(connect_retry(sockfd, aip->ai_addr, aip->ai_addrlen) < 0)
            {
                err = errno;
            }
            else
            {
                print_uptime(sockfd);
                exit(0);
            }
        }
        fprintf(stderr, "can't connect to %s: %s
    ", argv[1], strerror(err));
        exit(1);
    }

    其中,connect_retry函数见:http://www.cnblogs.com/nufangrensheng/p/3565858.html中的程序清单16-2

    这个程序连接服务器,读取服务器发送过来的字符串并将其打印到标准输出。既然使用SOCK_STREAM套接字,就不能保证在一次recv调用中会读取整个字符串,所以需要重复调用直到返回0。

    如果服务器支持多重网络接口或多重网络协议,函数getaddrinfo会返回不止一个候选地址。轮流尝试每个地址,当找到一个允许连接到服务的地址时便可停止。

    编译上面的程序成功后,执行时出现错误:getaddrinfo error:Servname not supported for ai_socktype,后来经查询在http://blog.163.com/yjie_life/blog/static/16319833720110311528528/找到了解决办法。其原因是我们在getaddrinfo第二个参数传入的服务名“ruptime”还没有分配端口号,我们可以手动为其添加端口号,只需在/etc/services文件中添加一行:ruptime      8888/tcp  其中8888是分配的端口号,需要大于1024且不与其他服务的端口号重复就行,后面的tcp是协议。

    实例:面向连接的服务器

    程序清单16-5显示服务器程序,用来提供uptime命令到程序清单16-4的客户端程序的输出

    程序清单16-5 提供系统uptime的服务器程序

    #include "apue.h"
    #include <netdb.h>
    #include <errno.h>
    #include <syslog.h>
    #include <sys/socket.h>
    
    #define BUFLEN    128
    #define QLEN    10
    
    #ifndef HOST_NAME_MAX
    #define HOST_NAME_MAX    256
    #endif
    
    extern int initserver(int, struct sockaddr *, socklen_t, int);
    
    void
    serve(int sockfd)
    {
        int      clfd;
        FILE    *fp;
        char     buf[BUFLEN];
    
        for(;;)
        {
            clfd = accept(sockfd, NULL, NULL);
            if(clfd < 0)
            {
                syslog(LOG_ERR, "ruptimed: accept error: %s", strerror(errno));
                exit(1);
            }
            if((fp = popen("/usr/bin/uptime", "r")) == NULL)
            {
                sprintf(buf, "error: %s
    ", strerror(errno));
                send(clfd, buf, strlen(buf), 0);
            }
            else
            {
                while(fgets(buf, BUFLEN, fp) != NULL)
                    send(clfd, buf, strlen(buf), 0);
                pclose(fp);
            }
            close(clfd);
        }
    }
    
    int
    main(int argc, char *argv[])
    {
        struct addrinfo *ailist, *aip;
        struct addrinfo hint;
        int        sockfd, err, n;
        char        *host;
            
        if(argc != 1)
            err_quit("usage: ruptimed");
    #ifdef _SC_HOST_NAME_MAX
        n = sysconf(_SC_HOST_NAME_MAX);
        if(n < 0)    /* best guess */
    #endif
            n = HOST_NAME_MAX;
        host = malloc(n);
        if(host == NULL)
            err_sys("malloc error");
        if(gethostname(host, n) < 0)
            err_sys("gethostname error");
        daemonize("ruptimed");
        hint.ai_flags = AI_CANONNAME;
        hint.ai_family = 0;
        hint.ai_socktype = SOCK_STREAM;
        hint.ai_protocol = 0;
        hint.ai_addrlen = 0;
        hint.ai_canonname = NULL;
        hint.ai_addr = NULL;
        hint.ai_next = NULL;
        if((err = getaddrinfo(host, "ruptime", &hint, &ailist)) != 0)
        {
            syslog(LOG_ERR, "ruptimed: getaddrinfo error: %s", gai_strerror(err));
            exit(1);
        }
        for(aip = ailist; aip != NULL; aip = aip->ai_next)
        {
            if((sockfd = initserver(SOCK_STREAM, aip->ai_addr, aip->ai_addrlen, QLEN)) >= 0)
            {
                serve(sockfd);
                exit(0);
            }
        }
        exit(1);
    }

    其中,

    initserver函数见:http://www.cnblogs.com/nufangrensheng/p/3565858.html中的程序清单16-3

    daemonize函数见:http://www.cnblogs.com/nufangrensheng/p/3544104.html中的程序清单13-1

    为了找到地址,服务器程序需要获得其运行时的主机名字。一些系统不定义_SC_HOST_NAME_MAX常量,因此这种情况下使用HOST_NAME_MAX。如果系统不定义HOST_NAME_MAX就自己定义。POSXI.1规定该值的最小值为255字节,不包括终结符,因此定义HOST_NAME_MAX为256以包括终结符。

    通过调用gethostname,服务器程序获得主机名字。并查看远程uptime服务(ruptime)地址。可能会有多个地址返回,但简单地选择第一个来建立被动套接字端点,在这个端点等待到来的连接请求。

    实例:另一个面向连接的服务器

    前面说过采用文件描述符来访问套接字是非常有意义的,因为允许程序对联网环境的网络访问一无所知。程序清单16-6中显示的服务器程序版本显示了这一点。为了代替从uptime命令中读取输出并发送到客户端,服务器安排uptime命令的标准输出和标准出错替换为连接到客户端的套接字端点。

    程序清单16-6 用于显示命令直接写到套接字的服务器程序

    #include "apue.h"
    #include <netdb.h>
    #include <errno.h>
    #include <syslog.h>
    #include <fcntl.h>
    #include <sys/socket.h>
    #include <sys/wait.h>
    
    #define QLEN    10
    
    #ifndef HOST_NAME_MAX
    #define HOST_NAME_MAX 256
    #endif
    
    extern int initserver(int, struct sockaddr *, socklen_t, int);
    
    void
    serve(int sockfd)
    {
        int      clfd, status;
        pid_t    pid;
        
        for(;;)
        {
            clfd = accept(sockfd, NULL, NULL);
            if(clfd < 0)
            {
                syslog(LOG_ERR, "ruptimed: accept error: %s",
                    strerror(errno));
                exit(1);
            }
            if((pid = fork()) < 0)
            {
                syslog(LOG_ERR, "ruptimed: fork error: %s", 
                    strerror(errno));
                exit(1);
            }
            else if(pid == 0)    /* child */
            {
                /*
                * The parent called daemonize, so 
                * STDIN_FILENO, STDOUT_FILENO, and STDERR_FILENO
                * are already open to /dev/null. Thus, the call to
                * close doesn't need to be protected by checks that
                * clfd isn't already equal to one of these values.
                */
                if(dup2(clfd, STDOUT_FILENO) != STDOUT_FILENO || 
                   dup2(clfd, STDERR_FILENO) != STDERR_FILENO)
                {
                    syslog(LOG_ERR, "ruptimed: unexpected error");
                    exit(1);
                }
                close(clfd);
                execl("/usr/bin/uptime", "uptime", (char *)0);
                syslog(LOG_ERR, "ruptimed: unexpected return from exec:                 %s", strerror(errno));
            }
            else    /* parent */
            {
                close(clfd);
                waitpid(pid, &status, 0);
            }    
        }
    }
    
    
    int
    main(int argc, char *argv[])
    {
        struct addrinfo *ailist, *aip;
        struct addrinfo  hint;
        int              sockfd, err, n;
        char            *host;
    
        if(argc != 1)
            err_quit("usage: ruptimed");
    #ifdef _SC_HOST_NAME_MAX
        n = sysconf(_SC_HOST_NAME_MAX);
        if(n < 0)    /* best guess */
    #endif
            n = HOST_NAME_MAX;
        host = malloc(n);
        if(host == NULL)
            err_sys("malloc error");
        if(gethostname(host, n) < 0)
            err_sys("gethostname error");
        daemonize("ruptimed");
        hint.ai_flags = AI_CANONNAME;
        hint.ai_family = 0;
        hint.ai_socktype = SOCK_STREAM;
        hint.ai_protocol = 0;
        hint.ai_addrlen = 0;
        hint.ai_canonname = NULL;
        hint.ai_addr = NULL;
        hint.ai_next = NULL;
        if((err = getaddrinfo(host, "ruptime", &hint, &ailist)) != 0)
        {
            syslog(LOG_ERR, "ruptimed: getaddrinfo error: %s", 
                gai_strerror(err));
            exit(1);
        }
        for(aip = ailist; aip != NULL; aip = aip->ai_next)
        {
            if((sockfd = initserver(SOCK_STREAM, aip->ai_addr, 
                aip->ai_addrlen, QLEN)) >= 0)
            {
                serve(sockfd);
                exit(0);
            }
        }
        exit(1);
    }

    以前的方式是采用popen来运行uptime命令,并从连接到命令标准输出的管道读取输出,现在采用fork来创建一个子进程,并使用dup2使子进程的STDIN_FILENO的副本打开到/dev/null、STDOUT_FILENO和STDERR_FILENO打开到套接字端点。当执行uptime时,命令将结果写到标准输出,该标准输出连到套接字,所以数据被送到ruptime客户端命令。

    父进程可以安全地关闭连接到客户端的文件描述符,因为子进程仍旧打开着。父进程等待子进程处理完毕,所以子进程不会变成僵死进程。既然运行uptime花费时间不会太长,父进程在接受下一个连接请求之前,可以等待子进程退出。不过,这种策略不适合子进程运行时间比较长的情况。

    前面的例子采用面向连接的套接字。但如何选择合适的套接字类型?何时采用面向连接的套接字,何时采用无连接的套接字呢?答案取决于要做的工作以及对错误的容忍程度。

    对于无连接的套接字,数据包的到来可能已经没有次序,因此当所有的数据不能放在一个包里时,在应用程序里必须关心包的次序。包的最大尺寸是通信协议的特性。并且对于无连接套接字,包可能丢失。如果应用程序不能容忍这种丢失,必须使用面向连接的套接字。

    容忍包丢失意味着两个选择。如果想和对方可靠通信,必须对数据报编号,如果发现包丢失,则要求对方重新传输。既然包可能因延迟而疑似丢失,我们要求重传,但该包却又出现,与重传过来的包重复。因此必须识别重复包,如果出现重复包,则将其丢弃。

    另外一个选择是通过让用户再次尝试命令来处理错误。对于简单的应用程序,这就足够;但对于复杂的应用程序,这种处理方式通常不是可行的选择,一般在这种情况下使用面向连接的套接字更为可取。

    面向连接的套接字的缺陷在于需要更多的时间和工作来建立一个连接,并且每个连接需要从操作系统中消耗更多的资源。

    实例:无连接的客户端

    程序清单16-7中的程序是采用数据报套接字接口的uptime客户端命令版本。

    程序清单16-7 采用数据报服务的客户端命令

    #include "apue.h"
    #include <netdb.h>
    #include <errno.h>
    #include <sys/socket.h>
    
    #define BUFLEN    128
    #define TIMEOUT    20
    
    void 
    sigalrm(int signo)
    {
    }
    
    void 
    print_uptime(int sockfd, struct addrinfo *aip)
    {
        int    n;
        char    buf[BUFLEN];
        
        buf[0] = 0;
        if(sendto(sockfd, buf, 1, 0, aip->ai_addr, aip->ai_addrlen) < 0)
            err_sys("sendto error");
        alarm(TIMEOUT);
        if((n = recvfrom(sockfd, buf, BUFLEN, 0, NULL, NULL)) < 0)
        {
            if(errno != EINTR)
                alarm(0);
            err_sys("recv error");
        }
        alarm(0);
        write(STDOUT_FILENO, buf, 0);
    }
    
    int 
    main(int argc, char *argv[])
    {
        struct addrinfo        *ailist, *aip;
        struct addrinfo         hint;
        int                     sockfd, err;
        struct sigaction        sa;
        
        if(argc != 2)
            err_quit("usage: ruptime hostname");
        sa.sa_handler = sigalrm;
        sa.sa_flags = 0;
        sigemptyset(&sa.sa_mask);
        if(sigaction(SIGALRM, &sa, NULL) < 0)
            err_sys("sigaction error");
        hint.ai_flags = 0;
        hint.ai_family = 0;
        hint.ai_socktype = SOCK_DGRAM;
        hint.ai_protocol = 0;
        hint.ai_addrlen = 0;
        hint.ai_canonname = NULL;
        hint.ai_addr = NULL;
        hint.ai_next = NULL;
        if((err = getaddrinfo(argv[1], "ruptime", &hint, &ailist)) != 0)
            err_quit("getaddrinfo error: %s", gai_strerror(err));
    
        for(aip = ailist; aip != NULL; aip = aip->ai_next)
        {
            if((sockfd = socket(aip->ai_family, SOCK_DGRAM, 0)) < 0)
            {
                err = errno;
            }
            else
            {
                print_uptime(sockfd, aip);
                exit(0);
            }
        }
        fprintf(stderr, "can't contact %s: %s
    ", argv[1], strerror(err));
        exit(1);
    }

    除了为SIGALRM增加了一个信号处理程序以外,基于数据报的客户端main函数和面向连接的客户端中的类似。使用alarm函数来避免调用recvfrom时无限期阻塞。

    对于面向连接的协议,需要在交换数据前连接服务器。对于服务器来说,到来的连接请求已经足够判断出所需提供给客户端的服务。但是对于基于数据报的协议,需要有一种方法来通知服务器需要它提供服务。本例中,只是简单地给服务器发送1字节的消息。服务器接收后从包中得到地址,并使用这个地址来发送响应消息。如果服务器提供多个服务,可以使用这个请求消息来指示所需要的服务,但既然服务器只做一件事情,1字节消息的内容是无关紧要的。

    如果服务器不在运行状态,客户端调用recvfrom便会无限期阻塞。对于面向连接的例子,如果服务器不运行,connect调用会失败。为了避免无限期阻塞,调用recvfrom之前设置警告时钟。

    实例:无连接服务器

    程序清单16-8中的程序是数据报版本的uptime服务器程序。

    程序清单16-8 基于数据报提供系统uptime的服务器程序

    #include "apue.h"
    #include <netdb.h>
    #include <errno.h>
    #include <syslog.h>
    #include <sys/socket.h>
    
    #define BUFLEN        128
    #define    MAXADDRLEN    256
    
    #ifndef    HOST_NAME_MAX
    #define HOST_NAME_MAX    256
    #endif
    
    extern int initserver(int, struct sockaddr *, socklen_t, int);
    
    void
    serve(int sockfd)
    {
        int          n;
        socklen_t    alen;
        FILE        *fp;    
        char         buf[BUFLEN];
        char         abuf[MAXADDRLEN];
    
        for(;;)
        {
            alen = MAXADDRLEN;
            if((n = recvfrom(sockfd, buf, BUFLEN, 0,
                (struct sockaddr *)abuf, &alen)) < 0)
            {
                syslog(LOG_ERR, "ruptimed: recvfrom error: %s", 
                    strerror(errno));
                exit(1);
            }
            if((fp = popen("/usr/bin/uptime", "r")) == NULL)
            {
                sprintf(buf, "error: %s
    ", strerror(errno));
                sendto(sockfd, buf, strlen(buf), 0,
                    (struct sockaddr *)abuf, alen);
            }
            else
            {
                if(fgets(buf, BUFLEN, fp) != NULL)
                    sendto(sockfd, buf, strlen(buf), 0,
                        (struct sockaddr *)abuf, alen);
                pclose(fp);
            }
        }    
    }
    
    
    int
    main(int argc, char *argv[])
    {
        struct addrinfo *ailist, *aip;
        struct addrinfo  hint;
        int              sockfd, err, n;
        char            *host;
        
        if(argc != 1)
        {
            err_quit("usage: ruptimed");        
        }
    #ifdef _SC_HOST_NAME_MAX
        n = sysconf(_SC_HOST_NAME_MAX);
        if(n < 0)    /* best guess */
    #endif
            n = HOST_NAME_MAX;
        host = malloc(n);
        if(host == NULL)
            err_sys("malloc error");
        if(gethostname(host, n) < 0)
            err_sys("gethostname error");
        daemonize("ruptimed");
        hint.ai_flags = AI_CANONNAME;
        hint.ai_family = 0;
        hint.ai_socktype = SOCK_DGRAM;
        hint.ai_protocol = 0;
        hint.ai_addrlen = 0;
        hint.ai_canonname = NULL;
        hint.ai_addr = NULL;
        hint.ai_next = NULL;
        if((err = getaddrinfo(host, "ruptime", &hint, &ailist)) != 0)
        {
            syslog(LOG_ERR, "ruptimed: getaddrinfo error: %s",
                gai_strerror(err));
            exit(1);
        }
        for(aip = ailist; aip != NULL; aip = aip->ai_next)
        {
            if((sockfd = initserver(SOCK_DGRAM, aip->ai_addr, 
                aip->ai_addrlen, 0)) >= 0)
            {
                serve(sockfd);
                exit(0);
            }
        }
        exit(1);
    }

    服务器在recvfrom中阻塞等待服务请求。当一个请求到达时,保存请求者地址并使用popen来运行uptime命令。采用sendto函数将输出发送到客户端,其目标地址就设为刚才的请求者地址。

    本篇博文内容摘自《UNIX环境高级编程》(第2版),仅作个人学习记录所用。关于本书可参考:http://www.apuebook.com/

  • 相关阅读:
    swift运算符使用_02_swift基本数据类型
    OSChina-01Swift终端命令行
    开源框架汇总-01-第三方
    修改App名称-01-修改项目中APP名
    NSAttributedString.h 中文本属性key的说明-06
    SQLite总结-05
    Linux系统判断gcc是否安装
    翻译文件结构规范
    并行SVN版本控制
    页面设计规范
  • 原文地址:https://www.cnblogs.com/nufangrensheng/p/3567376.html
Copyright © 2020-2023  润新知