Redis源码剖析——TCP连接

目录

前言

一、打开监听端口,等待客户端的命令请求

二、监听描述符进行监听

三.   监听描述符回调函数

总结

前言

通过对redis服务采用的基于epoll反应堆模型的server/client模型进行介绍。


一、打开监听端口,等待客户端的命令请求

服务器通过调用listenToPort函数,根据指定的端口port,以及Redis服务器配置中的bindaddr[REDIS_BINDADDR_MAX]数组指定的地址来构建一组监听文件描述符,并将其保存在整数数组'fds'中,它们的编号设置为'*count'。

如果服务器配置不包含需要绑定的特定地址,即服务器配置中地址数量bindaddr_count==0,则绑定通配地址。

注意:将绑定服务器套接字结构的监听文件描述符设置为非阻塞

int listenToPort(int port, int *fds, int *count) {
    int j;
    if (server.bindaddr_count == 0) server.bindaddr[0] = NULL;

    for (j = 0; j < server.bindaddr_count || j == 0; j++) {
        if (server.bindaddr[j] == NULL) {
            //如果没有给定对应的主机IP地址,则根据地址0.0.0.0,端口号port创建一个服务器套接字结构
            //创建一个监听描述符,并绑定服务器套接字结构,设置最大允许连接客户端数为server.tcp_backlog
            //并设置监听描述符为非阻塞,然后将该监听文件描述符存入fds数组
            //IPv6
            fds[*count] = anetTcp6Server(server.neterr,port,NULL,
                server.tcp_backlog);
            if (fds[*count] != ANET_ERR) {
                anetNonBlock(NULL,fds[*count]);
                (*count)++;
            }
            //IPv4
            fds[*count] = anetTcpServer(server.neterr,port,NULL,
                server.tcp_backlog);
            if (fds[*count] != ANET_ERR) {  //显得多余?????????或者将最后的那两行加到正常IPv6和IPv4中括号中去??????????????
                anetNonBlock(NULL,fds[*count]);
                (*count)++;
            }
            //当一个监听描述符否没有成功获得,则退出循环
            if (*count) break;

        //如果给定对应的主机IP地址server.bindaddr[j],则根据地址server.bindaddr[j],端口号port创建一个服务器套接字结构
        //创建一个监听描述符,并绑定服务器套接字结构,设置最大允许连接客户端数为server.tcp_backlog
        //并设置监听描述符为非阻塞,然后将该监听文件描述符存入fds数组
        //IPv6
        } else if (strchr(server.bindaddr[j],':')) {
            /* Bind IPv6 address. */
            fds[*count] = anetTcp6Server(server.neterr,port,server.bindaddr[j],
                server.tcp_backlog);
        //IPv4
        } else {
            /* Bind IPv4 address. */
            fds[*count] = anetTcpServer(server.neterr,port,server.bindaddr[j],
                server.tcp_backlog);
        }
        //一个监听描述符都没有绑定成功,则直接报告错误
        if (fds[*count] == ANET_ERR) {
            redisLog(REDIS_WARNING,
                "Creating Server TCP listening socket %s:%d: %s",
                server.bindaddr[j] ? server.bindaddr[j] : "*",
                port, server.neterr);
            return REDIS_ERR;
        }
        //绑定主机套接字的监听描述符设置为非阻塞
        anetNonBlock(NULL,fds[*count]);//该两行是否要移动?????
        (*count)++;
    }
    return REDIS_OK;
}

函数名:static int _anetTcpServer(char *err, int port, char *bindaddr, int af, int backlog)

返回值:成功,返回监听描述符,失败返回ANET_ERR。

static int _anetTcpServer(char *err, int port, char *bindaddr, int af, int backlog)
{
    int s, rv;
    char _port[6]; 
    struct addrinfo hints, *servinfo, *p;

    snprintf(_port,6,"%d",port);
    memset(&hints,0,sizeof(hints));
    hints.ai_family = af;//套接字协议类型AF_INET6/AF_INET
    hints.ai_socktype = SOCK_STREAM;//TCP协议
    hints.ai_flags = AI_PASSIVE;   //如果 bindarry==NULL,返回的就是通配地址

    //getaddrinfo解决了把主机名和服务名转换成套接口地址结构的问题。
    //返回值:0——成功,非0——出错
    //servinfo是一个存储结果的 struct addrinfo 结构体列表
    if ((rv = getaddrinfo(bindaddr,_port,&hints,&servinfo)) != 0) {
        anetSetError(err, "%s", gai_strerror(rv));
        return ANET_ERR;
    }

    //根据套接口地址结构列表servinfo,生成套接字s
    for (p = servinfo; p != NULL; p = p->ai_next) {
        if ((s = socket(p->ai_family,p->ai_socktype,p->ai_protocol)) == -1)
            continue;

        if (af == AF_INET6 && anetV6Only(err,s) == ANET_ERR) goto error;
        if (anetSetReuseAddr(err,s) == ANET_ERR) goto error;
        //绑定并设置该套接字s最大允许的客户端待连接数
        if (anetListen(err,s,p->ai_addr,p->ai_addrlen,backlog) == ANET_ERR) goto error;
        goto end;
    }
    if (p == NULL) {
        anetSetError(err, "unable to bind socket");
        goto error;
    }

error:
    s = ANET_ERR;
end:
    freeaddrinfo(servinfo);
    return s;
}

通过调用getaddrinfo函数,根据给定的端口号port以及bindaddr地址生成一个包含套接字相关信息的struct addrinfo结构。getaddrinfo函数详解参考:http://t.csdn.cn/03sit

struct addrinfo {

        int ai_flags;    //输入模式标志【主机名("www.baidu.com")、数字化的地址字符串(IPv4的点分十进制串("192.168.1.100")、IPv6的16进制串("2000::1:2345:6789:abcd")】

        int ai_family;   //套接字的协议族,AF_INET / AF_INET6 / AF_UNIX。

        int ai_socktype;  //套接字类型,SOCK_STREAM。

        int ai_protocol;   //套接字的协议,0表示默认协议。

        socklen_t ai_addrlen;   //套接字地址的长度。

        struct sockaddr *ai_addr;   //套接字地址。

        char *ai_canonname;   //服务位置的规范名称。

        struct addrinfo *ai_next;   //指向下一个列表的指针。

};

 根据addrinfo结构中的套接字协议族,套接字类型以及套接字协议,使用socket函数创建一个监听描述符s,并使用anetListen函数,为监听描述符s绑定绑定服务器套接字,并设置最大允许连接客户端数。

static int anetListen(char *err, int s, struct sockaddr *sa, socklen_t len, int backlog) {
    if (bind(s,sa,len) == -1) {
        anetSetError(err, "bind: %s", strerror(errno));
        close(s);
        return ANET_ERR;
    }

    if (listen(s, backlog) == -1) {
        anetSetError(err, "listen: %s", strerror(errno));
        close(s);
        return ANET_ERR;
    }
    return ANET_OK;
}

二、监听描述符进行监听

 1.服务器遍历多个监听描述符lfd,将监听描述符加入epoll句柄中监听读事件。
2.将监听描述符lfd构造成已注册文件事件结构,并加入事件处理器状态aeEventLoop中的已注册文件事件event数组中,设置其回调函数为acceptTcpHandler。

    // 将监听的文件描述符绑定文件事件结构后,配置acceptTcpHandler函数为回调函数后加入epoll句柄
    // 用于接受并应答客户端的 connect() 调用
    for (j = 0; j < server.ipfd_count; j++) {
        if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
            acceptTcpHandler,NULL) == AE_ERR)
            {
                redisPanic(
                    "Unrecoverable error creating server.ipfd file event.");
            }
    }

调用 aeCreateFileEvent函数根据监听描述符fd在事件服务器eventLoop的已注册文件事件数组中索引下标为fd的元素,并根据给定的读事件掩码mask、读事件回调函数proc去初始化对应下标为fd的aeFileEvent已注册文件事件结构。

并且将监听描述符fd以及读事件掩码mask组成的epoll_event事件结构加入到epoll红黑树句柄中。

int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
        aeFileProc *proc, void *clientData)
{
    //文件描述符超出槽大小,返回错误,并设置errno
    if (fd >= eventLoop->setsize) {
        errno = ERANGE;
        return AE_ERR;
    }

    if (fd >= eventLoop->setsize) return AE_ERR;

    // 取出已注册文件事件数组eventLoop->events中文件描述fd对应下标的元素指针
    aeFileEvent *fe = &eventLoop->events[fd];

    // 监听指定 fd 的指定事件
    //将文件描述符fd以及其对应的事件结构加入到epoll句柄中
    if (aeApiAddEvent(eventLoop, fd, mask) == -1)
        return AE_ERR;

    // 设置fd监听的事件类型,以及事件的处理器的回调函数
    fe->mask |= mask;
    if (mask & AE_READABLE) fe->rfileProc = proc;
    if (mask & AE_WRITABLE) fe->wfileProc = proc;

    // 私有数据,将包含fd文件描述符详细信息放在fd事件结构的私有数据中
    fe->clientData = clientData;

    // 如果有需要,更新事件处理器的最大 fd
    if (fd > eventLoop->maxfd)
        eventLoop->maxfd = fd;

    return AE_OK;
}

三.监听描述符回调函数

循环max次,使用accept函数进行非阻塞等待客户端连接请求。当客户端向服务器发出连接请求,监听描述符对应的读事件满足监听条件时,服务器将调用acceptTcpHandler函数,使得服务器与客户端建立连接,获得已连接描述符cfd。并根据已连接描述符为客户端创建客户端状态。

void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
    int cport, cfd, max = MAX_ACCEPTS_PER_CALL;
    char cip[REDIS_IP_STR_LEN];
    REDIS_NOTUSED(el);
    REDIS_NOTUSED(mask);
    REDIS_NOTUSED(privdata);

    while(max--) {
        // accept 客户端连接
        cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
        if (cfd == ANET_ERR) {
            if (errno != EWOULDBLOCK)
                redisLog(REDIS_WARNING,
                    "Accepting client connection: %s", server.neterr);
            return;
        }
        redisLog(REDIS_VERBOSE,"Accepted %s:%d", cip, cport);
        // 为客户端创建客户端状态(redisClient)
        acceptCommonHandler(cfd,0);
    }
}

其中anetTcpAccept函数主要用于与客户端建立连接,并生成已连接文件描述符cfd。代码如下:

int anetTcpAccept(char *err, int s, char *ip, size_t ip_len, int *port) {
    int fd;
    struct sockaddr_storage sa;
    socklen_t salen = sizeof(sa);
    if ((fd = anetGenericAccept(err,s,(struct sockaddr*)&sa,&salen)) == -1)
        return ANET_ERR;

    if (sa.ss_family == AF_INET) {
        struct sockaddr_in *s = (struct sockaddr_in *)&sa;
        if (ip) inet_ntop(AF_INET,(void*)&(s->sin_addr),ip,ip_len);
        if (port) *port = ntohs(s->sin_port);
    } else {
        struct sockaddr_in6 *s = (struct sockaddr_in6 *)&sa;
        if (ip) inet_ntop(AF_INET6,(void*)&(s->sin6_addr),ip,ip_len);
        if (port) *port = ntohs(s->sin6_port);
    }
    return fd;
}

static int anetGenericAccept(char *err, int s, struct sockaddr *sa, socklen_t *len) {
    int fd;
    while(1) {
        fd = accept(s,sa,len);
        if (fd == -1) {
            if (errno == EINTR)
                continue;
            else {
                anetSetError(err, "accept: %s", strerror(errno));
                return ANET_ERR;
            }
        }
        break;
    }
    return fd;
}

使用acceptCommonHandler(cfd,0);调用createClient为已连接描述符为cfd的客户端创建客户端状态,代码如下:

static void acceptCommonHandler(int fd, int flags) {
    // 创建客户端
    redisClient *c;
    if ((c = createClient(fd)) == NULL) {
        redisLog(REDIS_WARNING,
            "Error registering fd event for the new client: %s (fd=%d)",
            strerror(errno),fd);
        close(fd); /* May be already closed, just ignore errors */
        return;
    }
    // 如果新添加的客户端令服务器的最大客户端数量达到了
    // 那么向新客户端写入错误信息,并关闭新客户端
    // 先创建客户端,再进行数量检查是为了方便地进行错误信息写入
    if (listLength(server.clients) > server.maxclients) {
        char *err = "-ERR max number of clients reached\r\n";
        if (write(c->fd,err,strlen(err)) == -1) {
            /* 不进行任何操作,只是为了避免警告 */
        }
        // 更新拒绝连接数
        server.stat_rejected_conn++;
        //释放新客户端
        freeClient(c);
        return;
    }
    // 更新连接次数
    server.stat_numconnections++;
    // 设置 FLAG
    c->flags |= flags;
}

 调用createClient()函数,根据已连接文件描述符创建一个客户端:
(1)将已连接文件描述符cfd设置为非阻塞,并禁用Nagle算法,设置Keep alive活点检测。
(2)将已连接文件描述符cfd加入epoll句柄中监听读事件。
(3)将已连接文件描述符cfd构造成已注册文件事件结构,并加入事件处理器状态aeEventLoop中的已注册文件事件event数组中,设置其回调函数为readQueryFromClient,文件事件私有数据为客户端C。

redisClient *createClient(int fd) {

    // 分配空间
    redisClient *c = zmalloc(sizeof(redisClient));

    // 当 fd 不为 -1 时,创建带网络连接的客户端
    // 如果 fd 为 -1 ,那么创建无网络连接的伪客户端
    // 因为 Redis 的命令必须在客户端的上下文中使用,所以在执行 Lua 环境中的命令时
    // 需要用到这种伪终端
    if (fd != -1) {
        // 设置文件描述符fd为非阻塞
        anetNonBlock(NULL,fd);

        // 禁用 Nagle 算法,Nagle算法能自动连接许多的小缓冲器消息,
        // 但是服务器和客户端的对及时通信性有很高的要求,
        // 因此禁止使用 Nagle 算法,客户端向内核递交的每个数据包都会立即发送给服务器。
        anetEnableTcpNoDelay(NULL,fd);

        // 设置 keep alive,活点检测
        if (server.tcpkeepalive)
            anetKeepAlive(NULL,fd,server.tcpkeepalive);

        //创建一个文件事件el,且监听读事件,开始开始接收命令请求
        if (aeCreateFileEvent(server.el,fd,AE_READABLE,
            readQueryFromClient, c) == AE_ERR)
        {
            close(fd);
            zfree(c);
            return NULL;
        }
    }

    // 初始化各个属性

    // 默认选0号数据库
    selectDb(c,0);
    // client的套接字
    c->fd = fd;
    // client的名字
    c->name = NULL;
    // 回复缓冲区的偏移量
    c->bufpos = 0;
    // 输入缓存区
    c->querybuf = sdsempty();
    // 输入缓存区峰值
    c->querybuf_peak = 0;
    // 请求协议类型,内联或者多条命令,初始化为0
    c->reqtype = 0;
    // 命令参数数量
    c->argc = 0;
    // 命令参数
    c->argv = NULL;
    // 当前执行的命令和最近一次执行的命令
    c->cmd = c->lastcmd = NULL;
    // 查询缓冲区中未读入的命令内容数量
    c->multibulklen = 0;
    // 读入的参数的长度
    c->bulklen = -1;
    // 已发送字节数
    c->sentlen = 0;
    // client的状态 FLAG
    c->flags = 0;
    // 设置创建client的时间和最后一次互动的时间
    c->ctime = c->lastinteraction = server.unixtime;
    // 认证状态
    c->authenticated = 0;
    // replication复制的状态,初始为无
    c->replstate = REDIS_REPL_NONE;
    // replication复制的偏移量
    c->reploff = 0;
    // 通过 ACK 命令接收到的偏移量
    c->repl_ack_off = 0;
    // 通过 AKC 命令接收到偏移量的时间
    c->repl_ack_time = 0;
    // 客户端为从服务器时使用,记录了从服务器所使用的端口号
    c->slave_listening_port = 0;
    // 回复链表
    c->reply = listCreate();
    // 回复链表的字节量
    c->reply_bytes = 0;
    // 回复缓冲区大小达到软限制的时间
    c->obuf_soft_limit_reached_time = 0;
    // 回复链表的释放和复制函数
    listSetFreeMethod(c->reply,decrRefCountVoid);
    listSetDupMethod(c->reply,dupClientReplyValue);
    // 阻塞类型
    c->btype = REDIS_BLOCKED_NONE;
    // 阻塞超时
    c->bpop.timeout = 0;
    // 造成客户端阻塞的列表键
    c->bpop.keys = dictCreate(&setDictType,NULL);
    // 在解除阻塞时将元素推入到 target 指定的键中
    // BRPOPLPUSH 命令时使用
    c->bpop.target = NULL;
    // 阻塞状态
    c->bpop.numreplicas = 0;
    // 要达到的复制偏移量
    c->bpop.reploffset = 0;
    // 全局的复制偏移量
    c->woff = 0;
    // 进行事务时监视的键
    c->watched_keys = listCreate();
    // 订阅的频道和模式
    c->pubsub_channels = dictCreate(&setDictType,NULL);
    // 订阅模式
    c->pubsub_patterns = listCreate();
    // 被缓存的peerid,peerid就是 ip:port
    c->peerid = NULL;
    // 订阅发布模式的释放和比较方法
    listSetFreeMethod(c->pubsub_patterns,decrRefCountVoid);
    listSetMatchMethod(c->pubsub_patterns,listMatchObjects);

    // 将真正的client放在服务器的客户端链表中
    if (fd != -1) listAddNodeTail(server.clients,c);
    // 初始化客户端的事务状态
    initClientMultiState(c);

    // 返回客户端
    return c;
}

监听客户端已连接描述符读事件满足监听条件后的回调函数readQueryFromClient:
(1)设置已连接描述符cfd对应的客户端为当前客户端,设置命令的读入长度。
(2)从文件描述符cfd中读入命令到客户端的输入缓冲区c->querybuf中,使用processInputBuffer函数处理客户端输入的命令内容。
(3)processInputBuffer函数调用processCommand函数执行命令,执行成功后,重置客户端resetClient(c)。

void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    redisClient *c = (redisClient*) privdata;
    int nread, readlen;
    size_t qblen;
    REDIS_NOTUSED(el);
    REDIS_NOTUSED(mask);
    // 设置服务器的当前客户端
    server.current_client = c;
    // 设置命令读入长度(默认为 16 MB)
    readlen = REDIS_IOBUF_LEN;
    /* If this is a multi bulk request, and we are processing a bulk reply
     * that is large enough, try to maximize the probability that the query
     * buffer contains exactly the SDS string representing the object, even
     * at the risk of requiring more read(2) calls. This way the function
     * processMultiBulkBuffer() can avoid copying buffers to create the
     * Redis Object representing the argument. */
    //如果是批量命令请求,根据命令请求的长度,设置读入的长度readlen
    if (c->reqtype == REDIS_REQ_MULTIBULK && c->multibulklen && c->bulklen != -1
        && c->bulklen >= REDIS_MBULK_BIG_ARG)
    {
        //这句什么什么意思????
        int remaining = (unsigned)(c->bulklen+2)-sdslen(c->querybuf);
        if (remaining < readlen) readlen = remaining;
    }

    // 获取查询缓冲区当前内容的长度
    // 如果读取出现 short read ,那么可能会有内容滞留在读取缓冲区里面
    // 这些滞留内容也许不能完整构成一个符合协议的命令,
    qblen = sdslen(c->querybuf);

    // 如果有需要,更新缓冲区内容长度的峰值(peak)
    if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;

    // 为查询缓冲区分配空间,确保至少会有readlen+1的空闲空间
    c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);

    // 读入命令内容到查询缓存
    nread = read(fd, c->querybuf+qblen, readlen);

    // 读入出错
    if (nread == -1) {
        if (errno == EAGAIN) {
            nread = 0;
        } else {
            redisLog(REDIS_VERBOSE, "Reading from client: %s",strerror(errno));
            //读入出错,释放客户端
            freeClient(c);
            return;
        }
    // 遇到 EOF,读取结束,释放客户端
    } else if (nread == 0) {
        redisLog(REDIS_VERBOSE, "Client closed connection");
        freeClient(c);
        return;
    }

    if (nread) {
        // 根据内容,更新查询缓冲区(SDS) free 和 len 属性
        // 并将 '\0' 正确地放到内容的最后
        sdsIncrLen(c->querybuf,nread);
        // 记录服务器和客户端最后一次互动的时间
        c->lastinteraction = server.unixtime;
        // 如果客户端是 master 的话,更新它的复制偏移量
        if (c->flags & REDIS_MASTER) c->reploff += nread;
    } else {
        // 在 nread == -1 且 errno == EAGAIN 时运行
        server.current_client = NULL;
        return;
    }
    // 查询缓冲区长度超出服务器最大缓冲区长度,将客户端的信息和输入缓冲区中的信息都写进日志
    // 清空缓冲区并释放客户端
    if (sdslen(c->querybuf) > server.client_max_querybuf_len) {
        //将客户端所有的信息都存在ci中
        sds ci = catClientInfoString(sdsempty(),c), bytes = sdsempty();
        //将输入缓冲区中的内容以带引号的形式保存在bytes中
        bytes = sdscatrepr(bytes,c->querybuf,64);
        //将客户端的信息和输入缓冲区中的信息都写进日志
        redisLog(REDIS_WARNING,"Closing client that reached max query buffer length: %s (qbuf initial bytes: %s)", ci, bytes);
        //释放用于保存客户端信息,以及输入缓冲区内容的临时字符串
        sdsfree(ci);
        sdsfree(bytes);
        //释放客户端
        freeClient(c);
        return;
    }

    // 从查询缓存重读取内容,创建参数,并执行命令
    // 函数会执行到缓存中的所有内容都被处理完为止
    processInputBuffer(c);

    server.current_client = NULL;
}

processInputBuffer函数处理客户端输入的命令内容,将其解析后调用processCommand函数执行命令。

// 处理客户端输入的命令内容,将其解析后执行命令
// 判断请求的类型
// 简单来说,多条查询是一般客户端发送来的,
// 而内联查询则是 TELNET 发送来的
void processInputBuffer(redisClient *c) {
    // 尽可能地处理查询缓冲区中的内容
    // 如果读取出现 short read ,那么可能会有内容滞留在读取缓冲区里面
    // 这些滞留内容也许不能完整构成一个符合协议的命令,
    // 需要等待下次读事件的就绪
    while(sdslen(c->querybuf)) {

        // 如果客户端不是是一个从服务器,且目前服务器存在正处于暂停状态的客户端,那么直接返回
        if (!(c->flags & REDIS_SLAVE) && clientsArePaused()) return;

        // REDIS_BLOCKED 状态表示客户端正在被阻塞
        if (c->flags & REDIS_BLOCKED) return;

        // 表示有用户对这个客户端执行了CLIENT KILL命令,
        // 或者客户端发送给服务器的命令请求中包含了错误的协议内容,没有必要处理命令了
        if (c->flags & REDIS_CLOSE_AFTER_REPLY) return;

        // 如果是未知的请求类型,则判定请求类型
        if (!c->reqtype) {
            // 如果是"*"开头,则是多条请求,是client发来的
            if (c->querybuf[0] == '*') {
                // 多条查询
                c->reqtype = REDIS_REQ_MULTIBULK;
            // 否则就是内联请求,是Telnet发来的
            } else {
                // 内联查询
                c->reqtype = REDIS_REQ_INLINE;
            }
        }
        // 根据请求命令的类型,将缓冲区中的内容转换成命令,以及命令参数
        // 如果是内联请求
        if (c->reqtype == REDIS_REQ_INLINE) {
            // 处理Telnet发来的内联命令,并创建成对象,保存在client的参数列表中
            if (processInlineBuffer(c) != REDIS_OK) break;
         // 如果是多条请求
        } else if (c->reqtype == REDIS_REQ_MULTIBULK) {
            // 将client的querybuf中的协议内容转换为client的参数列表中的对象
            if (processMultibulkBuffer(c) != REDIS_OK) break;
        } else {
            redisPanic("Unknown request type");
        }
        //多条查询可以看到参数长度<=0的情况
        //当参数个数为0,直接重置客户端,无需执行命令
        if (c->argc == 0) {
            resetClient(c);
        } else {
            // 执行命令,并重置客户端
            if (processCommand(c) == REDIS_OK)
                resetClient(c);
        }
    }
}

具体请求命令的存储,命令的执行,以及命令回复给客户端,在下一篇文章中详细介绍。

总结

。。。。。。。。。。。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值